diff --git a/frontend/input.css b/frontend/input.css index 7d4d583..03e799a 100644 --- a/frontend/input.css +++ b/frontend/input.css @@ -19,7 +19,7 @@ --secondary: theme(colors.zinc.200); --secondary-foreground: theme(colors.zinc.900); - --destructive: theme(colors.red.600); + --destructive: theme(colors.red.500); --destructive-foreground: theme(colors.white); --accent: theme(colors.zinc.100); --accent-foreground: theme(colors.zinc.900); @@ -69,10 +69,10 @@ @apply bg-primary text-primary-foreground hover:bg-primary/90; } .btn-secondary { - @apply bg-secondary text-secondary-foreground hover:bg-secondary/80; + @apply bg-secondary text-secondary-foreground border border-zinc-500/30 hover:bg-secondary/80; } .btn-destructive { - @apply bg-destructive text-destructive-foreground hover:bg-destructive/90; + @apply bg-destructive text-destructive-foreground border border-zinc-500/30 hover:bg-destructive/90; } .btn-outline { @apply border border-input bg-background hover:bg-accent hover:text-accent-foreground; @@ -83,7 +83,9 @@ .btn-link { @apply text-primary underline-offset-4 hover:underline; } - + .btn-group-item.active { + @apply bg-primary text-primary-foreground; + } /* 按钮尺寸变体 */ .btn-lg { @apply h-11 rounded-md px-8; } .btn-md { @apply h-10 px-4 py-2; } diff --git a/frontend/js/components/ui.js b/frontend/js/components/ui.js index b87730c..9e55a9b 100644 --- a/frontend/js/components/ui.js +++ b/frontend/js/components/ui.js @@ -326,6 +326,41 @@ class UIPatterns { }); } } + /** + * Sets a button to a loading state by disabling it and showing a spinner. + * It stores the button's original content to be restored later. + * @param {HTMLButtonElement} button - The button element to modify. + */ + setButtonLoading(button) { + if (!button) return; + // Store original content if it hasn't been stored already + if (!button.dataset.originalContent) { + button.dataset.originalContent = button.innerHTML; + } + button.disabled = true; + button.innerHTML = ''; + } + /** + * Restores a button from its loading state to its original content and enables it. + * @param {HTMLButtonElement} button - The button element to restore. + */ + clearButtonLoading(button) { + if (!button) return; + if (button.dataset.originalContent) { + button.innerHTML = button.dataset.originalContent; + // Clean up the data attribute + delete button.dataset.originalContent; + } + button.disabled = false; + } + /** + * Returns the HTML for a streaming text cursor animation. + * This is used as a placeholder in the chat UI while waiting for an assistant's response. + * @returns {string} The HTML string for the loader. + */ + renderStreamingLoader() { + return '▋'; + } } /** diff --git a/frontend/js/main.js b/frontend/js/main.js index 804761f..d159874 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -16,6 +16,7 @@ const pageModules = { 'dashboard': () => import('./pages/dashboard.js'), 'keys': () => import('./pages/keys/index.js'), 'logs': () => import('./pages/logs/index.js'), + 'chat': () => import('./pages/chat/index.js'), // 'settings': () => import('./pages/settings.js'), // 未来启用 settings 页面 // 未来新增的页面,只需在这里添加一行映射,esbuild会自动处理 }; diff --git a/frontend/js/pages/chat/SessionManager.js b/frontend/js/pages/chat/SessionManager.js new file mode 100644 index 0000000..ce5494b --- /dev/null +++ b/frontend/js/pages/chat/SessionManager.js @@ -0,0 +1,178 @@ +// Filename: frontend/js/pages/chat/SessionManager.js + +import { nanoid } from 'https://cdn.jsdelivr.net/npm/nanoid/nanoid.js'; + +const LOCAL_STORAGE_KEY = 'gemini_chat_state'; + +/** + * Manages the state and persistence of chat sessions. + * This class handles loading from/saving to localStorage, + * and all operations like creating, switching, and deleting sessions. + */ +export class SessionManager { + constructor() { + this.state = null; + } + + /** + * Initializes the manager by loading state from localStorage or creating a default state. + */ + init() { + this._loadState(); + } + + // --- Public API for state access --- + + getSessions() { + return this.state.sessions; + } + + getCurrentSessionId() { + return this.state.currentSessionId; + } + + getCurrentSession() { + return this.state.sessions.find(s => s.id === this.state.currentSessionId); + } + + // --- Public API for state mutation --- + + /** + * Creates a new, empty session and sets it as the current one. + */ + createSession() { + const newSessionId = nanoid(); + const newSession = { + id: newSessionId, + name: '新会话', + systemPrompt: '', + messages: [], + modelConfig: { model: 'gemini-2.0-flash-lite' }, + params: { temperature: 0.7 } + }; + this.state.sessions.unshift(newSession); + this.state.currentSessionId = newSessionId; + this._saveState(); + } + + /** + * Switches the current session to the one with the given ID. + * @param {string} sessionId The ID of the session to switch to. + */ + switchSession(sessionId) { + if (this.state.currentSessionId === sessionId) return; + this.state.currentSessionId = sessionId; + this._saveState(); + } + + /** + * Deletes a session by its ID. + * @param {string} sessionId The ID of the session to delete. + */ + deleteSession(sessionId) { + this.state.sessions = this.state.sessions.filter(s => s.id !== sessionId); + + if (this.state.currentSessionId === sessionId) { + this.state.currentSessionId = this.state.sessions[0]?.id || null; + if (!this.state.currentSessionId) { + this._createInitialState(); // Create a new one if all are deleted + } + } + + this._saveState(); + } + + /** + * [NEW] Clears all messages from the currently active session. + */ + clearCurrentSession() { + const currentSession = this.getCurrentSession(); + if (currentSession) { + currentSession.messages = []; + this._saveState(); + } + } + + /** + * Adds a message to the current session and updates the session name if it's the first message. + * @param {object} message The message object to add. + * @returns {object} The session that was updated. + */ + addMessage(message) { + const currentSession = this.getCurrentSession(); + if (currentSession) { + if (currentSession.messages.length === 0 && message.role === 'user') { + currentSession.name = message.content.substring(0, 30); + } + currentSession.messages.push(message); + this._saveState(); + return currentSession; + } + return null; + } + + deleteMessage(messageId) { + const currentSession = this.getCurrentSession(); + if (currentSession) { + const messageIndex = currentSession.messages.findIndex(m => m.id === messageId); + if (messageIndex > -1) { + currentSession.messages.splice(messageIndex, 1); + this._saveState(); + console.log(`Message ${messageId} deleted.`); + } + } + } + + truncateMessagesAfter(messageId) { + const currentSession = this.getCurrentSession(); + if (currentSession) { + const messageIndex = currentSession.messages.findIndex(m => m.id === messageId); + // Ensure the message exists and it's not already the last one + if (messageIndex > -1 && messageIndex < currentSession.messages.length - 1) { + currentSession.messages.splice(messageIndex + 1); + this._saveState(); + console.log(`Truncated messages after ${messageId}.`); + } + } + } + // --- Private persistence methods --- + + _saveState() { + try { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this.state)); + } catch (error) { + console.error("Failed to save session state:", error); + } + } + + _loadState() { + try { + const stateString = localStorage.getItem(LOCAL_STORAGE_KEY); + if (stateString) { + this.state = JSON.parse(stateString); + } else { + this._createInitialState(); + } + } catch (error) { + console.error("Failed to load session state, creating initial state:", error); + this._createInitialState(); + } + } + + _createInitialState() { + const initialSessionId = nanoid(); + this.state = { + sessions: [{ + id: initialSessionId, + name: '新会话', + systemPrompt: '', + messages: [], + modelConfig: { model: 'gemini-2.0-flash-lite' }, + params: { temperature: 0.7 } + }], + currentSessionId: initialSessionId, + settings: {} + }; + this._saveState(); + } +} diff --git a/frontend/js/pages/chat/chatSettings.js b/frontend/js/pages/chat/chatSettings.js new file mode 100644 index 0000000..cc23e33 --- /dev/null +++ b/frontend/js/pages/chat/chatSettings.js @@ -0,0 +1,113 @@ +// Filename: frontend/js/pages/chat/chatSettings.js + +export class ChatSettings { + constructor(elements) { + // [MODIFIED] Store the root elements passed from ChatPage + this.elements = {}; + this.elements.root = elements; // Keep a reference to all elements + + // [MODIFIED] Query for specific elements this class controls, relative to their panels + this._initScopedDOMElements(); + + // Initialize panel states to ensure they are collapsed on load + this.elements.quickSettingsPanel.style.gridTemplateRows = '0fr'; + this.elements.sessionParamsPanel.style.gridTemplateRows = '0fr'; + } + + // [NEW] A dedicated method to find elements within their specific panels + _initScopedDOMElements() { + this.elements.quickSettingsPanel = this.elements.root.quickSettingsPanel; + this.elements.sessionParamsPanel = this.elements.root.sessionParamsPanel; + this.elements.toggleQuickSettingsBtn = this.elements.root.toggleQuickSettingsBtn; + this.elements.toggleSessionParamsBtn = this.elements.root.toggleSessionParamsBtn; + + // Query elements within the quick settings panel + this.elements.btnGroups = this.elements.quickSettingsPanel.querySelectorAll('.btn-group'); + this.elements.directRoutingOptions = this.elements.quickSettingsPanel.querySelector('#direct-routing-options'); + + // Query elements within the session params panel + this.elements.temperatureSlider = this.elements.sessionParamsPanel.querySelector('#temperature-slider'); + this.elements.temperatureValue = this.elements.sessionParamsPanel.querySelector('#temperature-value'); + this.elements.contextSlider = this.elements.sessionParamsPanel.querySelector('#context-slider'); + this.elements.contextValue = this.elements.sessionParamsPanel.querySelector('#context-value'); + } + + init() { + if (!this.elements.toggleQuickSettingsBtn) { + console.warn("ChatSettings: Aborting initialization, required elements not found."); + return; + } + this._initPanelToggleListeners(); + this._initButtonGroupListeners(); + this._initSliderListeners(); + } + + _initPanelToggleListeners() { + this.elements.toggleQuickSettingsBtn.addEventListener('click', () => + this._togglePanel(this.elements.quickSettingsPanel, this.elements.toggleQuickSettingsBtn) + ); + this.elements.toggleSessionParamsBtn.addEventListener('click', () => + this._togglePanel(this.elements.sessionParamsPanel, this.elements.toggleSessionParamsBtn) + ); + } + + _initButtonGroupListeners() { + // [FIXED] This logic is now guaranteed to work with the correctly scoped elements. + this.elements.btnGroups.forEach(group => { + group.addEventListener('click', (e) => { + const button = e.target.closest('.btn-group-item'); + if (!button) return; + + group.querySelectorAll('.btn-group-item').forEach(btn => btn.removeAttribute('data-active')); + button.setAttribute('data-active', 'true'); + + if (button.dataset.group === 'routing-mode') { + this._handleRoutingModeChange(button.dataset.value); + } + }); + }); + } + + _initSliderListeners() { + // [FIXED] Add null checks for robustness, now that elements are queried scoped. + if (this.elements.temperatureSlider) { + this.elements.temperatureSlider.addEventListener('input', () => { + this.elements.temperatureValue.textContent = parseFloat(this.elements.temperatureSlider.value).toFixed(1); + }); + } + if (this.elements.contextSlider) { + this.elements.contextSlider.addEventListener('input', () => { + this.elements.contextValue.textContent = `${this.elements.contextSlider.value}k`; + }); + } + } + + _handleRoutingModeChange(selectedValue) { + // [FIXED] This logic now correctly targets the scoped element. + if (this.elements.directRoutingOptions) { + if (selectedValue === 'direct') { + this.elements.directRoutingOptions.classList.remove('hidden'); + } else { + this.elements.directRoutingOptions.classList.add('hidden'); + } + } + } + + _togglePanel(panel, button) { + const isExpanded = panel.hasAttribute('data-expanded'); + + this.elements.quickSettingsPanel.removeAttribute('data-expanded'); + this.elements.sessionParamsPanel.removeAttribute('data-expanded'); + this.elements.toggleQuickSettingsBtn.removeAttribute('data-active'); + this.elements.toggleSessionParamsBtn.removeAttribute('data-active'); + + this.elements.quickSettingsPanel.style.gridTemplateRows = '0fr'; + this.elements.sessionParamsPanel.style.gridTemplateRows = '0fr'; + + if (!isExpanded) { + panel.setAttribute('data-expanded', 'true'); + button.setAttribute('data-active', 'true'); + panel.style.gridTemplateRows = '1fr'; + } + } +} diff --git a/frontend/js/pages/chat/index.js b/frontend/js/pages/chat/index.js new file mode 100644 index 0000000..2c680e2 --- /dev/null +++ b/frontend/js/pages/chat/index.js @@ -0,0 +1,545 @@ +// 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 = ` +
确认删除此消息吗?
+'+(n?i:w(i,!0))+`
+`:""+(n?i:w(i,!0))+`
+`}blockquote({tokens:e}){return`+${this.parser.parse(e)}+`}html({text:e}){return e}def(e){return""}heading({tokens:e,depth:t}){return`
${this.parser.parseInline(e)}
+`}table(e){let t="",n="";for(let i=0;i${w(e,!0)}`}br(e){return"An error occurred:
"+w(n.message+"",!0)+"";return t?Promise.resolve(r):r}if(t)return Promise.reject(n);throw n}}};var _=new B;function d(u,e){return _.parse(u,e)}d.options=d.setOptions=function(u){return _.setOptions(u),d.defaults=_.defaults,Z(d.defaults),d};d.getDefaults=L;d.defaults=T;d.use=function(...u){return _.use(...u),d.defaults=_.defaults,Z(d.defaults),d};d.walkTokens=function(u,e){return _.walkTokens(u,e)};d.parseInline=_.parseInline;d.Parser=b;d.parser=b.parse;d.Renderer=P;d.TextRenderer=$;d.Lexer=x;d.lexer=x.lex;d.Tokenizer=y;d.Hooks=S;d.parse=d;var Dt=d.options,Ht=d.setOptions,Zt=d.use,Gt=d.walkTokens,Nt=d.parseInline,Qt=d,Ft=b.parse,jt=x.lex;export{S as Hooks,x as Lexer,B as Marked,b as Parser,P as Renderer,$ as TextRenderer,y as Tokenizer,T as defaults,L as getDefaults,jt as lexer,d as marked,Dt as options,Qt as parse,Nt as parseInline,Ft as parser,Ht as setOptions,Zt as use,Gt as walkTokens}; +//# sourceMappingURL=marked.esm.js.map \ No newline at end of file diff --git a/frontend/js/vendor/nanoid.js b/frontend/js/vendor/nanoid.js new file mode 100644 index 0000000..ffa1d4b --- /dev/null +++ b/frontend/js/vendor/nanoid.js @@ -0,0 +1 @@ +let a="useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict";export let nanoid=(e=21)=>{let t="",r=crypto.getRandomValues(new Uint8Array(e));for(let n=0;n
' + (n ? i : w(i, true)) + `
+` : "" + (n ? i : w(i, true)) + `
+`;
+ }
+ blockquote({ tokens: e }) {
+ return `+${this.parser.parse(e)}+`; + } + html({ text: e }) { + return e; + } + def(e) { + return ""; + } + heading({ tokens: e, depth: t }) { + return `
${this.parser.parseInline(e)}
+`; + } + table(e) { + let t = "", n = ""; + for (let i = 0; i < e.header.length; i++) n += this.tablecell(e.header[i]); + t += this.tablerow({ text: n }); + let r = ""; + for (let i = 0; i < e.rows.length; i++) { + let s = e.rows[i]; + n = ""; + for (let a2 = 0; a2 < s.length; a2++) n += this.tablecell(s[a2]); + r += this.tablerow({ text: n }); + } + return r && (r = `${r}`), `${w(e, true)}`;
+ }
+ br(e) {
+ return "An error occurred:
" + w(n.message + "", true) + ""; + return t ? Promise.resolve(r) : r; + } + if (t) return Promise.reject(n); + throw n; + }; + } +}; +var _ = new B(); +function d(u3, e) { + return _.parse(u3, e); +} +d.options = d.setOptions = function(u3) { + return _.setOptions(u3), d.defaults = _.defaults, Z(d.defaults), d; +}; +d.getDefaults = L; +d.defaults = T; +d.use = function(...u3) { + return _.use(...u3), d.defaults = _.defaults, Z(d.defaults), d; +}; +d.walkTokens = function(u3, e) { + return _.walkTokens(u3, e); +}; +d.parseInline = _.parseInline; +d.Parser = b; +d.parser = b.parse; +d.Renderer = P; +d.TextRenderer = $; +d.Lexer = x; +d.lexer = x.lex; +d.Tokenizer = y; +d.Hooks = S; +d.parse = d; +var Dt = d.options; +var Ht = d.setOptions; +var Zt = d.use; +var Gt = d.walkTokens; +var Nt = d.parseInline; +var Ft = b.parse; +var jt = x.lex; + +// frontend/js/pages/chat/SessionManager.js +import { nanoid as nanoid2 } from "https://cdn.jsdelivr.net/npm/nanoid/nanoid.js"; +var LOCAL_STORAGE_KEY = "gemini_chat_state"; +var SessionManager = class { + constructor() { + this.state = null; + } + /** + * Initializes the manager by loading state from localStorage or creating a default state. + */ + init() { + this._loadState(); + } + // --- Public API for state access --- + getSessions() { + return this.state.sessions; + } + getCurrentSessionId() { + return this.state.currentSessionId; + } + getCurrentSession() { + return this.state.sessions.find((s) => s.id === this.state.currentSessionId); + } + // --- Public API for state mutation --- + /** + * Creates a new, empty session and sets it as the current one. + */ + createSession() { + const newSessionId = nanoid2(); + const newSession = { + id: newSessionId, + name: "\u65B0\u4F1A\u8BDD", + systemPrompt: "", + messages: [], + modelConfig: { model: "gemini-2.0-flash-lite" }, + params: { temperature: 0.7 } + }; + this.state.sessions.unshift(newSession); + this.state.currentSessionId = newSessionId; + this._saveState(); + } + /** + * Switches the current session to the one with the given ID. + * @param {string} sessionId The ID of the session to switch to. + */ + switchSession(sessionId) { + if (this.state.currentSessionId === sessionId) return; + this.state.currentSessionId = sessionId; + this._saveState(); + } + /** + * Deletes a session by its ID. + * @param {string} sessionId The ID of the session to delete. + */ + deleteSession(sessionId) { + this.state.sessions = this.state.sessions.filter((s) => s.id !== sessionId); + if (this.state.currentSessionId === sessionId) { + this.state.currentSessionId = this.state.sessions[0]?.id || null; + if (!this.state.currentSessionId) { + this._createInitialState(); + } + } + this._saveState(); + } + /** + * [NEW] Clears all messages from the currently active session. + */ + clearCurrentSession() { + const currentSession = this.getCurrentSession(); + if (currentSession) { + currentSession.messages = []; + this._saveState(); + } + } + /** + * Adds a message to the current session and updates the session name if it's the first message. + * @param {object} message The message object to add. + * @returns {object} The session that was updated. + */ + addMessage(message) { + const currentSession = this.getCurrentSession(); + if (currentSession) { + if (currentSession.messages.length === 0 && message.role === "user") { + currentSession.name = message.content.substring(0, 30); + } + currentSession.messages.push(message); + this._saveState(); + return currentSession; + } + return null; + } + deleteMessage(messageId) { + const currentSession = this.getCurrentSession(); + if (currentSession) { + const messageIndex = currentSession.messages.findIndex((m2) => m2.id === messageId); + if (messageIndex > -1) { + currentSession.messages.splice(messageIndex, 1); + this._saveState(); + console.log(`Message ${messageId} deleted.`); + } + } + } + truncateMessagesAfter(messageId) { + const currentSession = this.getCurrentSession(); + if (currentSession) { + const messageIndex = currentSession.messages.findIndex((m2) => m2.id === messageId); + if (messageIndex > -1 && messageIndex < currentSession.messages.length - 1) { + currentSession.messages.splice(messageIndex + 1); + this._saveState(); + console.log(`Truncated messages after ${messageId}.`); + } + } + } + // --- Private persistence methods --- + _saveState() { + try { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this.state)); + } catch (error) { + console.error("Failed to save session state:", error); + } + } + _loadState() { + try { + const stateString = localStorage.getItem(LOCAL_STORAGE_KEY); + if (stateString) { + this.state = JSON.parse(stateString); + } else { + this._createInitialState(); + } + } catch (error) { + console.error("Failed to load session state, creating initial state:", error); + this._createInitialState(); + } + } + _createInitialState() { + const initialSessionId = nanoid2(); + this.state = { + sessions: [{ + id: initialSessionId, + name: "\u65B0\u4F1A\u8BDD", + systemPrompt: "", + messages: [], + modelConfig: { model: "gemini-2.0-flash-lite" }, + params: { temperature: 0.7 } + }], + currentSessionId: initialSessionId, + settings: {} + }; + this._saveState(); + } +}; + +// frontend/js/pages/chat/chatSettings.js +var ChatSettings = class { + constructor(elements) { + this.elements = {}; + this.elements.root = elements; + this._initScopedDOMElements(); + this.elements.quickSettingsPanel.style.gridTemplateRows = "0fr"; + this.elements.sessionParamsPanel.style.gridTemplateRows = "0fr"; + } + // [NEW] A dedicated method to find elements within their specific panels + _initScopedDOMElements() { + this.elements.quickSettingsPanel = this.elements.root.quickSettingsPanel; + this.elements.sessionParamsPanel = this.elements.root.sessionParamsPanel; + this.elements.toggleQuickSettingsBtn = this.elements.root.toggleQuickSettingsBtn; + this.elements.toggleSessionParamsBtn = this.elements.root.toggleSessionParamsBtn; + this.elements.btnGroups = this.elements.quickSettingsPanel.querySelectorAll(".btn-group"); + this.elements.directRoutingOptions = this.elements.quickSettingsPanel.querySelector("#direct-routing-options"); + this.elements.temperatureSlider = this.elements.sessionParamsPanel.querySelector("#temperature-slider"); + this.elements.temperatureValue = this.elements.sessionParamsPanel.querySelector("#temperature-value"); + this.elements.contextSlider = this.elements.sessionParamsPanel.querySelector("#context-slider"); + this.elements.contextValue = this.elements.sessionParamsPanel.querySelector("#context-value"); + } + init() { + if (!this.elements.toggleQuickSettingsBtn) { + console.warn("ChatSettings: Aborting initialization, required elements not found."); + return; + } + this._initPanelToggleListeners(); + this._initButtonGroupListeners(); + this._initSliderListeners(); + } + _initPanelToggleListeners() { + this.elements.toggleQuickSettingsBtn.addEventListener( + "click", + () => this._togglePanel(this.elements.quickSettingsPanel, this.elements.toggleQuickSettingsBtn) + ); + this.elements.toggleSessionParamsBtn.addEventListener( + "click", + () => this._togglePanel(this.elements.sessionParamsPanel, this.elements.toggleSessionParamsBtn) + ); + } + _initButtonGroupListeners() { + this.elements.btnGroups.forEach((group) => { + group.addEventListener("click", (e) => { + const button = e.target.closest(".btn-group-item"); + if (!button) return; + group.querySelectorAll(".btn-group-item").forEach((btn) => btn.removeAttribute("data-active")); + button.setAttribute("data-active", "true"); + if (button.dataset.group === "routing-mode") { + this._handleRoutingModeChange(button.dataset.value); + } + }); + }); + } + _initSliderListeners() { + if (this.elements.temperatureSlider) { + this.elements.temperatureSlider.addEventListener("input", () => { + this.elements.temperatureValue.textContent = parseFloat(this.elements.temperatureSlider.value).toFixed(1); + }); + } + if (this.elements.contextSlider) { + this.elements.contextSlider.addEventListener("input", () => { + this.elements.contextValue.textContent = `${this.elements.contextSlider.value}k`; + }); + } + } + _handleRoutingModeChange(selectedValue) { + if (this.elements.directRoutingOptions) { + if (selectedValue === "direct") { + this.elements.directRoutingOptions.classList.remove("hidden"); + } else { + this.elements.directRoutingOptions.classList.add("hidden"); + } + } + } + _togglePanel(panel, button) { + const isExpanded = panel.hasAttribute("data-expanded"); + this.elements.quickSettingsPanel.removeAttribute("data-expanded"); + this.elements.sessionParamsPanel.removeAttribute("data-expanded"); + this.elements.toggleQuickSettingsBtn.removeAttribute("data-active"); + this.elements.toggleSessionParamsBtn.removeAttribute("data-active"); + this.elements.quickSettingsPanel.style.gridTemplateRows = "0fr"; + this.elements.sessionParamsPanel.style.gridTemplateRows = "0fr"; + if (!isExpanded) { + panel.setAttribute("data-expanded", "true"); + button.setAttribute("data-active", "true"); + panel.style.gridTemplateRows = "1fr"; + } + } +}; + +// frontend/js/pages/chat/index.js +d.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; +} +var ChatPage = class { + 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); + } + } + _initEventListeners() { + this.settingsManager = new ChatSettings(this.elements); + this.settingsManager.init(); + 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((m2) => m2.id === messageId); + if (!message || !message.content) { + console.error("Message content not found for copying."); + return; + } + 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: "\u5DF2\u590D\u5236", + 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: "\u590D\u5236\u5931\u8D25", + showConfirmButton: false, + timer: 1500, + customClass: { + popup: `${document.documentElement.classList.contains("dark") ? "swal2-dark" : ""}` + } + }); + }); + } + _handleDeleteMessage(messageId, targetElement) { + const existingPopover = document.getElementById("delete-confirmation-popover"); + if (existingPopover) { + existingPopover.remove(); + } + const popover = document.createElement("div"); + popover.id = "delete-confirmation-popover"; + 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"; + popover.innerHTML = ` +
\u786E\u8BA4\u5220\u9664\u6B64\u6D88\u606F\u5417?
+