// Filename: frontend/js/pages/chat/index.js import { nanoid } from '../../vendor/nanoid.js'; import { uiPatterns } from '../../components/ui.js'; import { apiFetch } from '../../services/api.js'; import { marked } from '../../vendor/marked.min.js'; import { SessionManager } from './SessionManager.js'; import { ChatSettings } from './chatSettings.js'; import CustomSelectV2 from '../../components/customSelectV2.js'; marked.use({ breaks: true, gfm: true }); function getCookie(name) { let cookieValue = null; if (document.cookie && document.cookie !== '') { const cookies = document.cookie.split(';'); for (let i = 0; i < cookies.length; i++) { const cookie = cookies[i].trim(); if (cookie.substring(0, name.length + 1) === (name + '=')) { cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); break; } } } return cookieValue; } class ChatPage { constructor() { this.sessionManager = new SessionManager(); this.isStreaming = false; this.elements = {}; this.initialized = false; this.searchTerm = ''; this.settingsManager = null; } init() { if (!document.querySelector('[data-page-id="chat"]')) { return; } this.sessionManager.init(); this.initialized = true; this._initDOMElements(); this._initComponents(); this._initEventListeners(); this._render(); console.log("ChatPage initialized. Session management is delegated.", this.sessionManager.state); } _initDOMElements() { this.elements.chatScrollContainer = document.getElementById('chat-scroll-container'); this.elements.chatMessagesContainer = document.getElementById('chat-messages-container'); this.elements.messageForm = document.getElementById('message-form'); this.elements.messageInput = document.getElementById('message-input'); this.elements.sendBtn = document.getElementById('send-btn'); this.elements.newSessionBtn = document.getElementById('new-session-btn'); this.elements.sessionListContainer = document.getElementById('session-list-container'); this.elements.chatHeaderTitle = document.querySelector('.chat-header-title'); this.elements.clearSessionBtn = document.getElementById('clear-session-btn'); this.elements.sessionSearchInput = document.getElementById('session-search-input'); this.elements.toggleQuickSettingsBtn = document.getElementById('toggle-quick-settings'); this.elements.toggleSessionParamsBtn = document.getElementById('toggle-session-params'); this.elements.quickSettingsPanel = document.getElementById('quick-settings-panel'); this.elements.sessionParamsPanel = document.getElementById('session-params-panel'); this.elements.directRoutingOptions = document.getElementById('direct-routing-options'); this.elements.btnGroups = document.querySelectorAll('.btn-group'); this.elements.temperatureSlider = document.getElementById('temperature-slider'); this.elements.temperatureValue = document.getElementById('temperature-value'); this.elements.contextSlider = document.getElementById('context-slider'); this.elements.contextValue = document.getElementById('context-value'); this.elements.groupSelectContainer = document.getElementById('group-select-container'); } // [NEW] A dedicated method for initializing complex UI components _initComponents() { if (this.elements.groupSelectContainer) { new CustomSelectV2(this.elements.groupSelectContainer); } // In the future, we will initialize the model select component here as well } _initEventListeners() { // --- Initialize Settings Manager First --- this.settingsManager = new ChatSettings(this.elements); this.settingsManager.init(); // --- Core Chat Event Listeners --- this.elements.messageForm.addEventListener('submit', (e) => { e.preventDefault(); this._handleSendMessage(); }); this.elements.messageInput.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this._handleSendMessage(); } }); this.elements.messageInput.addEventListener('input', () => this._autoResizeTextarea()); this.elements.newSessionBtn.addEventListener('click', () => { this.sessionManager.createSession(); this._render(); this.elements.messageInput.focus(); }); this.elements.sessionListContainer.addEventListener('click', (e) => { const sessionItem = e.target.closest('[data-session-id]'); const deleteBtn = e.target.closest('.delete-session-btn'); if (deleteBtn) { e.preventDefault(); const sessionId = deleteBtn.closest('[data-session-id]').dataset.sessionId; this._handleDeleteSession(sessionId); } else if (sessionItem) { e.preventDefault(); const sessionId = sessionItem.dataset.sessionId; this.sessionManager.switchSession(sessionId); this._render(); this.elements.messageInput.focus(); } }); this.elements.clearSessionBtn.addEventListener('click', () => this._handleClearSession()); this.elements.sessionSearchInput.addEventListener('input', (e) => { this.searchTerm = e.target.value.trim(); this._renderSessionList(); }); this.elements.chatMessagesContainer.addEventListener('click', (e) => { const messageElement = e.target.closest('[data-message-id]'); if (!messageElement) return; const messageId = messageElement.dataset.messageId; const copyBtn = e.target.closest('.action-copy'); const deleteBtn = e.target.closest('.action-delete'); const retryBtn = e.target.closest('.action-retry'); if (copyBtn) { this._handleCopyMessage(messageId); } else if (deleteBtn) { this._handleDeleteMessage(messageId, e.target); } else if (retryBtn) { this._handleRetryMessage(messageId); } }); } _handleCopyMessage(messageId) { const currentSession = this.sessionManager.getCurrentSession(); if (!currentSession) return; const message = currentSession.messages.find(m => m.id === messageId); if (!message || !message.content) { console.error("Message content not found for copying."); return; } // Handle cases where content might be HTML (like error messages) // by stripping tags to get plain text. let textToCopy = message.content; if (textToCopy.includes('<') && textToCopy.includes('>')) { const tempDiv = document.createElement('div'); tempDiv.innerHTML = textToCopy; textToCopy = tempDiv.textContent || tempDiv.innerText || ''; } navigator.clipboard.writeText(textToCopy) .then(() => { Swal.fire({ toast: true, position: 'top-end', icon: 'success', title: '已复制', showConfirmButton: false, timer: 1500, customClass: { popup: `${document.documentElement.classList.contains('dark') ? 'swal2-dark' : ''}` } }); }) .catch(err => { console.error('Failed to copy text: ', err); Swal.fire({ toast: true, position: 'top-end', icon: 'error', title: '复制失败', showConfirmButton: false, timer: 1500, customClass: { popup: `${document.documentElement.classList.contains('dark') ? 'swal2-dark' : ''}` } }); }); } _handleDeleteMessage(messageId, targetElement) { // Remove any existing popover first to prevent duplicates const existingPopover = document.getElementById('delete-confirmation-popover'); if (existingPopover) { existingPopover.remove(); } // Create the popover element with your specified dimensions. const popover = document.createElement('div'); popover.id = 'delete-confirmation-popover'; // [MODIFIED] - Using your w-36, and adding flexbox classes for centering. popover.className = 'absolute z-50 p-3 w-45 border border-border rounded-md shadow-lg bg-background text-popover-foreground flex flex-col items-center'; // [MODIFIED] - Added an icon and classes for horizontal centering. popover.innerHTML = `

确认删除此消息吗?

`; document.body.appendChild(popover); // Position the popover above the clicked icon const iconRect = targetElement.closest('button').getBoundingClientRect(); const popoverRect = popover.getBoundingClientRect(); popover.style.top = `${window.scrollY + iconRect.top - popoverRect.height - 8}px`; popover.style.left = `${window.scrollX + iconRect.left + (iconRect.width / 2) - (popoverRect.width / 2)}px`; // Event listener to close the popover if clicked outside const outsideClickListener = (event) => { if (!popover.contains(event.target) && event.target !== targetElement) { popover.remove(); document.removeEventListener('click', outsideClickListener); } }; setTimeout(() => document.addEventListener('click', outsideClickListener), 0); // Event listeners for the buttons inside the popover popover.querySelector('.popover-confirm').addEventListener('click', () => { this.sessionManager.deleteMessage(messageId); this._renderChatMessages(); this._renderSessionList(); popover.remove(); document.removeEventListener('click', outsideClickListener); }); popover.querySelector('.popover-cancel').addEventListener('click', () => { popover.remove(); document.removeEventListener('click', outsideClickListener); }); } _handleRetryMessage(messageId) { if (this.isStreaming) return; // Prevent retrying while a response is already generating const currentSession = this.sessionManager.getCurrentSession(); if (!currentSession) return; const message = currentSession.messages.find(m => m.id === messageId); if (!message) return; if (message.role === 'user') { // Logic for retrying from a user's prompt this.sessionManager.truncateMessagesAfter(messageId); } else if (message.role === 'assistant') { // Logic for regenerating an assistant's response (must be the last one) this.sessionManager.deleteMessage(messageId); } // After data manipulation, update the UI and trigger a new response this._renderChatMessages(); this._renderSessionList(); this._getAssistantResponse(); } _autoResizeTextarea() { const el = this.elements.messageInput; el.style.height = 'auto'; el.style.height = (el.scrollHeight) + 'px'; } _handleSendMessage() { if (this.isStreaming) return; const content = this.elements.messageInput.value.trim(); if (!content) return; const userMessage = { id: nanoid(), role: 'user', content: content }; this.sessionManager.addMessage(userMessage); this._renderChatMessages(); this._renderSessionList(); this.elements.messageInput.value = ''; this._autoResizeTextarea(); this.elements.messageInput.focus(); this._getAssistantResponse(); } async _getAssistantResponse() { this.isStreaming = true; this._setLoadingState(true); const currentSession = this.sessionManager.getCurrentSession(); const assistantMessageId = nanoid(); let finalAssistantMessage = { id: assistantMessageId, role: 'assistant', content: '' }; // Step 1: Create and render a temporary UI placeholder for streaming. // [MODIFIED] The placeholder now uses the three-dot animation. const placeholderHtml = `
`; this.elements.chatMessagesContainer.insertAdjacentHTML('beforeend', placeholderHtml); this._scrollToBottom(); const assistantMessageContentEl = this.elements.chatMessagesContainer.querySelector(`[data-message-id="${assistantMessageId}"] .message-content`); try { const token = getCookie('gemini_admin_session'); const headers = { 'Authorization': `Bearer ${token}` }; const response = await apiFetch('/v1/chat/completions', { method: 'POST', headers, body: JSON.stringify({ model: currentSession.modelConfig.model, messages: currentSession.messages.filter(m => m.content).map(({ role, content }) => ({ role, content })), stream: true, }) }); if (!response.body) throw new Error("Response body is null."); const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { value, done } = await reader.read(); if (done) break; const chunk = decoder.decode(value); const lines = chunk.split('\n').filter(line => line.trim().startsWith('data:')); for (const line of lines) { const dataStr = line.replace(/^data: /, '').trim(); if (dataStr !== '[DONE]') { try { const data = JSON.parse(dataStr); const deltaContent = data.choices[0]?.delta?.content; if (deltaContent) { finalAssistantMessage.content += deltaContent; assistantMessageContentEl.innerHTML = marked.parse(finalAssistantMessage.content); this._scrollToBottom(); } } catch (e) { /* ignore malformed JSON */ } } } } } catch (error) { console.error('Fetch stream error:', error); const errorMessage = error.rawMessageFromServer || error.message; finalAssistantMessage.content = `请求失败: ${errorMessage}`; } finally { this.sessionManager.addMessage(finalAssistantMessage); this._renderChatMessages(); this._renderSessionList(); this.isStreaming = false; this._setLoadingState(false); this.elements.messageInput.focus(); } } _renderMessage(message, replace = false, isLastMessage = false) { let contentHtml; if (message.role === 'user') { const escapedContent = message.content.replace(//g, ">"); contentHtml = `

${escapedContent.replace(/\n/g, '
')}

`; } else { // [FIXED] Simplified logic: if it's an assistant message, it either has real content or error HTML. // The isStreamingPlaceholder case is now handled differently and removed from here. const isErrorHtml = message.content && message.content.includes(''); contentHtml = isErrorHtml ? `
${message.content}
` : `
${marked.parse(message.content || '')}
`; } // [FIXED] No special handling for streaming placeholders needed anymore. // If a message has content, it gets actions. An error message has content, so it will get actions. let actionsHtml = ''; let retryButton = ''; if (message.role === 'user') { retryButton = ` `; } else if (message.role === 'assistant' && isLastMessage) { // This now correctly applies to final error messages too. retryButton = ` `; } const toolbarBaseClasses = "message-actions flex items-center gap-1 transition-opacity duration-200"; const toolbarPositionClass = isLastMessage ? "mt-2" : "absolute bottom-2.5 right-2.5"; const visibilityClass = isLastMessage ? "" : "opacity-0 group-hover:opacity-100"; actionsHtml = `
${retryButton}
`; const messageBubbleClasses = `relative group rounded-lg p-5 ${message.role === 'user' ? 'bg-muted' : 'bg-primary/10 border/20'}`; const messageHtml = `
${contentHtml} ${actionsHtml}
`; const existingElement = this.elements.chatMessagesContainer.querySelector(`[data-message-id="${message.id}"]`); if (replace && existingElement) { existingElement.outerHTML = messageHtml; } else if (!existingElement) { this.elements.chatMessagesContainer.insertAdjacentHTML('beforeend', messageHtml); } if (!replace) { this._scrollToBottom(); } } _scrollToBottom() { this.elements.chatScrollContainer.scrollTop = this.elements.chatScrollContainer.scrollHeight; } _render() { this._renderSessionList(); this._renderChatMessages(); this._renderChatHeader(); } _renderSessionList() { let sessions = this.sessionManager.getSessions(); const currentSessionId = this.sessionManager.getCurrentSessionId(); if (this.searchTerm) { const lowerCaseSearchTerm = this.searchTerm.toLowerCase(); sessions = sessions.filter(session => { const titleMatch = session.name.toLowerCase().includes(lowerCaseSearchTerm); const messageMatch = session.messages.some(message => message.content && message.content.toLowerCase().includes(lowerCaseSearchTerm) ); return titleMatch || messageMatch; }); } this.elements.sessionListContainer.innerHTML = sessions.map(session => { const isActive = session.id === currentSessionId; const lastMessage = session.messages.length > 0 ? session.messages[session.messages.length - 1].content : '新会话'; return `
${session.name}
${session.messages.length > 0 ? (lastMessage.includes('') ? '[请求失败]' : lastMessage) : '新会话'}
`; }).join(''); } _renderChatMessages() { this.elements.chatMessagesContainer.innerHTML = ''; const currentSession = this.sessionManager.getCurrentSession(); if (currentSession) { const messages = currentSession.messages; const lastMessageIndex = messages.length > 0 ? messages.length - 1 : -1; messages.forEach((message, index) => { const isLastMessage = (index === lastMessageIndex); this._renderMessage(message, false, isLastMessage); }); } } _renderChatHeader() { const currentSession = this.sessionManager.getCurrentSession(); if (currentSession && this.elements.chatHeaderTitle) { this.elements.chatHeaderTitle.textContent = currentSession.name; } } _setLoadingState(isLoading) { this.elements.messageInput.disabled = isLoading; this.elements.sendBtn.disabled = isLoading; if (isLoading) { uiPatterns.setButtonLoading(this.elements.sendBtn); } else { uiPatterns.clearButtonLoading(this.elements.sendBtn); } } _handleClearSession() { Swal.fire({ width: '22rem', backdrop: `rgba(0,0,0,0.5)`, heightAuto: false, customClass: { popup: `swal2-custom-style ${document.documentElement.classList.contains('dark') ? 'swal2-dark' : ''}` }, title: '确定要清空会话吗?', text: '当前会话的所有聊天记录将被删除,但会话本身会保留。', showCancelButton: true, confirmButtonText: '确认清空', cancelButtonText: '取消', reverseButtons: false, confirmButtonColor: '#ef4444', cancelButtonColor: '#6b7280', focusConfirm: false, focusCancel: true, }).then((result) => { if (result.isConfirmed) { this.sessionManager.clearCurrentSession(); // This method needs to be added to SessionManager this._render(); } }); } _handleDeleteSession(sessionId) { Swal.fire({ width: '22rem', backdrop: `rgba(0,0,0,0.5)`, heightAuto: false, customClass: { popup: `swal2-custom-style ${document.documentElement.classList.contains('dark') ? 'swal2-dark' : ''}` }, title: '确定要删除吗?', text: '此会话的所有记录将被永久删除,无法撤销。', showCancelButton: true, confirmButtonText: '确认删除', cancelButtonText: '取消', reverseButtons: false, confirmButtonColor: '#ef4444', cancelButtonColor: '#6b7280', focusConfirm: false, focusCancel: true, }).then((result) => { if (result.isConfirmed) { this.sessionManager.deleteSession(sessionId); this._render(); } }); } } export default function() { const page = new ChatPage(); page.init(); }