(function () { 'use strict'; const TOKEN_KEY = 'chatfast_token'; const state = { config: null, sessionId: null, sessionNumber: null, messages: [], expandedMessages: new Set(), historyPage: 0, historyPageSize: 9999, historyTotal: 0, historyItems: [], model: '', outputMode: '流式输出 (Stream)', historyCount: 0, searchQuery: '', streaming: false, token: null, user: null, authMode: 'login', myExports: [], adminUsers: [], adminExports: [], activeAbortController: null, userMenuOpen: false, }; const dom = {}; document.addEventListener('DOMContentLoaded', init); async function init() { cacheDom(); bindEvents(); resetChatState(); state.token = window.localStorage.getItem(TOKEN_KEY); if (!state.token) { showAuthView('login'); return; } try { await fetchProfile(); await bootstrapAfterAuth(); } catch (err) { console.error('Failed to bootstrap with existing session', err); handleUnauthorized(false); } } function cacheDom() { dom.appShell = document.getElementById('app-shell'); dom.authView = document.getElementById('auth-view'); dom.loginForm = document.getElementById('login-form'); dom.registerForm = document.getElementById('register-form'); dom.authSwitchers = document.querySelectorAll('[data-auth-mode]'); dom.logoutButton = document.getElementById('logout-btn'); dom.userBadge = document.getElementById('user-badge'); dom.adminButton = document.getElementById('admin-btn'); dom.exportButton = document.getElementById('export-btn'); dom.adminPanel = document.getElementById('admin-panel'); dom.adminClose = document.getElementById('admin-close'); dom.adminCreateForm = document.getElementById('admin-create-form'); dom.adminUserList = document.getElementById('admin-user-list'); dom.adminExportSearch = document.getElementById('admin-export-search'); dom.adminExportRefresh = document.getElementById('admin-export-refresh'); dom.adminExportList = document.getElementById('admin-export-list'); dom.exportPanel = document.getElementById('export-panel'); dom.exportClose = document.getElementById('export-close'); dom.exportList = document.getElementById('export-list'); dom.modelSelect = document.getElementById('model-select'); dom.outputMode = document.getElementById('output-mode'); dom.searchInput = document.getElementById('search-input'); dom.searchFeedback = document.getElementById('search-feedback'); dom.historyRange = document.getElementById('history-range'); dom.historyRangeLabel = document.getElementById('history-range-label'); dom.historyRangeValue = document.getElementById('history-range-value'); dom.historyList = document.getElementById('history-list'); dom.historyCount = document.getElementById('history-count'); dom.historyPrev = document.getElementById('history-prev'); dom.historyNext = document.getElementById('history-next'); dom.newChatButton = document.getElementById('new-chat-btn'); dom.chatMessages = document.getElementById('chat-messages'); dom.chatForm = document.getElementById('chat-form'); dom.chatInput = document.getElementById('chat-input'); dom.sendButton = document.getElementById('send-btn'); dom.fileInput = document.getElementById('file-input'); dom.chatStatus = document.getElementById('chat-status'); dom.sessionIndicator = document.getElementById('session-indicator'); dom.userMenu = document.getElementById('user-menu'); dom.userMenuToggle = document.getElementById('user-menu-toggle'); dom.userMenuDropdown = document.getElementById('user-menu-dropdown'); dom.userAvatarInitials = document.getElementById('user-avatar-initials'); dom.stopButton = document.getElementById('end-chat-btn'); dom.adminRoleIndicator = document.getElementById('admin-role-indicator'); dom.toast = document.getElementById('toast'); if (dom.sendButton && !dom.sendButton.dataset.defaultText) { dom.sendButton.dataset.defaultText = dom.sendButton.textContent || '发送'; } } function bindEvents() { if (dom.loginForm) { dom.loginForm.addEventListener('submit', handleLogin); } if (dom.registerForm) { dom.registerForm.addEventListener('submit', handleRegister); } if (dom.authSwitchers && dom.authSwitchers.length) { dom.authSwitchers.forEach((btn) => { btn.addEventListener('click', () => { const mode = btn.getAttribute('data-auth-mode') || 'login'; setAuthMode(mode); }); }); } if (dom.logoutButton) { dom.logoutButton.addEventListener('click', handleLogout); } if (dom.adminButton) { dom.adminButton.addEventListener('click', () => { setUserMenuOpen(false); void openAdminPanel(); }); } if (dom.adminClose) { dom.adminClose.addEventListener('click', hideAdminPanel); } if (dom.adminCreateForm) { dom.adminCreateForm.addEventListener('submit', handleAdminCreate); } if (dom.adminExportRefresh) { dom.adminExportRefresh.addEventListener('click', async (event) => { event.preventDefault(); await loadAdminExports(dom.adminExportSearch.value || ''); }); } if (dom.adminExportSearch) { dom.adminExportSearch.addEventListener('keydown', async (event) => { if (event.key === 'Enter') { event.preventDefault(); await loadAdminExports(dom.adminExportSearch.value || ''); } }); } if (dom.exportButton) { dom.exportButton.addEventListener('click', () => { setUserMenuOpen(false); void openExportPanel(); }); } if (dom.exportClose) { dom.exportClose.addEventListener('click', hideExportPanel); } setupOverlayDismiss(dom.adminPanel, hideAdminPanel); setupOverlayDismiss(dom.exportPanel, hideExportPanel); dom.modelSelect.addEventListener('change', () => { state.model = dom.modelSelect.value; }); dom.outputMode.addEventListener('change', () => { state.outputMode = dom.outputMode.value; }); dom.searchInput.addEventListener('input', () => { state.searchQuery = dom.searchInput.value.trim(); state.expandedMessages = new Set(); renderMessages(); }); dom.historyRange.addEventListener('input', () => { state.historyCount = Number(dom.historyRange.value || 0); updateHistorySlider(); }); dom.historyPrev.addEventListener('click', async () => { if (state.historyPage > 0) { state.historyPage -= 1; await loadHistory(); } }); dom.historyNext.addEventListener('click', async () => { const totalPages = Math.ceil(state.historyTotal / state.historyPageSize) || 1; if (state.historyPage < totalPages - 1) { state.historyPage += 1; await loadHistory(); } }); dom.newChatButton.addEventListener('click', async () => { if (!state.token) { showToast('请先登录', 'error'); return; } if (state.streaming) { showToast('请等待当前回复完成后再新建会话', 'error'); return; } try { const data = await fetchJSON('/api/session/new', { method: 'POST' }); updateActiveSession(data.session_id, data.session_number, { updateUrl: true }); state.messages = []; state.historyCount = 0; state.searchQuery = ''; dom.searchInput.value = ''; state.expandedMessages = new Set(); state.historyPage = 0; renderSidebar(); renderMessages(); renderHistory(); showToast('当前会话已清空。', 'success'); await loadHistory(); } catch (err) { showToast(err.message || '新建会话失败', 'error'); } }); dom.chatForm.addEventListener('submit', handleSubmitMessage); dom.chatInput.addEventListener('keydown', (event) => { if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.altKey) { event.preventDefault(); if (typeof dom.chatForm.requestSubmit === 'function') { dom.chatForm.requestSubmit(); } else if (dom.sendButton) { dom.sendButton.click(); } } }); window.addEventListener('popstate', handlePopState); if (dom.stopButton) { dom.stopButton.addEventListener('click', handleAbortConversation); } if (dom.userMenuToggle) { dom.userMenuToggle.addEventListener('click', (event) => { event.preventDefault(); setUserMenuOpen(!state.userMenuOpen); }); } document.addEventListener('click', (event) => { if (!state.userMenuOpen || !dom.userMenu) { return; } if (event.target instanceof Node && dom.userMenu.contains(event.target)) { return; } setUserMenuOpen(false); }); document.addEventListener('keydown', (event) => { if (event.key === 'Escape') { setUserMenuOpen(false); hideAdminPanel(); hideExportPanel(); } }); } function setupOverlayDismiss(overlay, closeHandler) { if (!overlay || typeof closeHandler !== 'function') { return; } overlay.addEventListener('click', (event) => { if (event.target === overlay) { closeHandler(); } }); } function resetChatState() { state.sessionId = null; state.sessionNumber = null; state.messages = []; state.expandedMessages = new Set(); state.historyItems = []; state.historyTotal = 0; state.historyPage = 0; state.historyCount = 0; state.myExports = []; state.adminUsers = []; state.adminExports = []; state.activeAbortController = null; setUserMenuOpen(false); renderSidebar(); renderMessages(); renderHistory(); renderMyExports(); renderAdminUsers(); renderAdminExports(); updateSessionIndicator(); } function showAuthView(mode = 'login') { state.authMode = mode; if (dom.appShell) { dom.appShell.classList.add('hidden'); } if (dom.authView) { dom.authView.classList.remove('hidden'); } if (dom.loginForm) { dom.loginForm.reset(); } if (dom.registerForm) { dom.registerForm.reset(); } setAuthMode(mode); } function hideAuthView() { if (dom.authView) { dom.authView.classList.add('hidden'); } if (dom.appShell) { dom.appShell.classList.remove('hidden'); } } function setAuthMode(mode) { state.authMode = mode; if (!dom.loginForm || !dom.registerForm) { return; } if (mode === 'register') { dom.loginForm.classList.add('hidden'); dom.registerForm.classList.remove('hidden'); } else { dom.registerForm.classList.add('hidden'); dom.loginForm.classList.remove('hidden'); } } function saveToken(token) { state.token = token; if (token) { window.localStorage.setItem(TOKEN_KEY, token); } } function clearToken() { state.token = null; window.localStorage.removeItem(TOKEN_KEY); } async function fetchProfile() { const data = await fetchJSON('/api/auth/me'); state.user = data; updateUserUi(); } async function bootstrapAfterAuth() { hideAuthView(); try { if (!state.config) { await loadConfig(); } const querySessionId = getSessionIdFromUrl(); let loadedFromQuery = false; if (querySessionId !== null) { try { await loadSession(querySessionId, { silent: true, updateUrl: true, replaceUrl: true }); loadedFromQuery = true; } catch (err) { console.warn('Failed to load session from URL parameter:', err); showToast('指定的会话不存在,已加载最新会话。', 'error'); } } if (!loadedFromQuery) { await loadLatestSession({ updateUrl: true, replaceUrl: true }); } await loadHistory(); await loadMyExports(); if (isAdmin()) { await loadAdminUsers(); await loadAdminExports(dom.adminExportSearch ? dom.adminExportSearch.value || '' : ''); } else { renderAdminUsers(); renderAdminExports(); } } catch (err) { showToast(err.message || '初始化失败', 'error'); } renderSidebar(); renderMessages(); renderHistory(); } function updateUserUi() { if (dom.userBadge) { if (state.user) { const roleText = state.user.role === 'admin' ? '管理员' : '普通用户'; dom.userBadge.textContent = `${state.user.username} · ${roleText}`; } else { dom.userBadge.textContent = ''; } } if (dom.adminButton) { dom.adminButton.classList.toggle('hidden', !isAdmin()); } if (dom.exportButton) { dom.exportButton.disabled = !state.token; } if (dom.logoutButton) { dom.logoutButton.disabled = !state.token; } if (dom.userMenu) { dom.userMenu.classList.toggle('hidden', !state.token); } if (dom.userMenuToggle) { dom.userMenuToggle.disabled = !state.token; } if (!state.token) { setUserMenuOpen(false); } if (dom.userAvatarInitials) { const initials = state.user ? extractInitials(state.user.username) : ''; dom.userAvatarInitials.textContent = initials; } if (dom.adminRoleIndicator) { dom.adminRoleIndicator.classList.toggle('hidden', !isAdmin()); } updateSessionIndicator(); } function isAdmin() { return Boolean(state.user && state.user.role === 'admin'); } function handleUnauthorized(showMessage = true) { if (!state.token) { showAuthView('login'); return; } clearToken(); state.user = null; resetChatState(); showAuthView('login'); updateUserUi(); if (showMessage) { showToast('登录状态已过期,请重新登录。', 'error'); } } async function handleLogin(event) { event.preventDefault(); const formData = new FormData(dom.loginForm); const username = String(formData.get('username') || '').trim(); const password = String(formData.get('password') || '').trim(); if (!username || !password) { showToast('请输入用户名和密码', 'error'); return; } try { const data = await fetchJSON('/api/auth/login', { method: 'POST', body: { username, password }, }); saveToken(data.token); state.user = data.user; updateUserUi(); showToast('登录成功', 'success'); await bootstrapAfterAuth(); } catch (err) { showToast(err.message || '登录失败', 'error'); } } async function handleRegister(event) { event.preventDefault(); const formData = new FormData(dom.registerForm); const username = String(formData.get('username') || '').trim(); const password = String(formData.get('password') || '').trim(); if (!username || !password) { showToast('请输入用户名和密码', 'error'); return; } try { const data = await fetchJSON('/api/auth/register', { method: 'POST', body: { username, password }, }); saveToken(data.token); state.user = data.user; updateUserUi(); showToast('注册并登录成功', 'success'); await bootstrapAfterAuth(); } catch (err) { showToast(err.message || '注册失败', 'error'); } } async function handleLogout(event) { event.preventDefault(); setUserMenuOpen(false); if (!state.token) { showAuthView('login'); return; } try { await fetchJSON('/api/auth/logout', { method: 'POST' }); } catch (err) { console.warn('Logout failed', err); } finally { clearToken(); state.user = null; resetChatState(); showAuthView('login'); updateUserUi(); hideAdminPanel(); hideExportPanel(); } } async function loadMyExports() { if (!state.token) { state.myExports = []; renderMyExports(); return; } try { const data = await fetchJSON('/api/exports/me'); state.myExports = Array.isArray(data.items) ? data.items : []; renderMyExports(); } catch (err) { console.warn('Failed to load user exports', err); } } function renderMyExports() { if (!dom.exportList) { return; } dom.exportList.innerHTML = ''; if (!state.token) { const empty = document.createElement('p'); empty.className = 'empty-note'; empty.textContent = '登录后可查看导出历史。'; dom.exportList.appendChild(empty); return; } if (!state.myExports.length) { const empty = document.createElement('p'); empty.className = 'empty-note'; empty.textContent = '暂无导出记录。'; dom.exportList.appendChild(empty); return; } state.myExports.forEach((item) => { const row = document.createElement('div'); row.className = 'admin-row'; const info = document.createElement('div'); info.className = 'admin-row-info'; const title = document.createElement('strong'); title.textContent = item.filename || `导出 #${item.id}`; info.appendChild(title); const meta = document.createElement('span'); meta.className = 'admin-row-meta'; meta.textContent = new Date(item.created_at || Date.now()).toLocaleString(); info.appendChild(meta); const preview = document.createElement('div'); preview.className = 'admin-row-preview'; preview.textContent = item.content_preview || ''; info.appendChild(preview); row.appendChild(info); const actionBox = document.createElement('div'); actionBox.className = 'admin-row-actions'; const downloadButton = document.createElement('button'); downloadButton.type = 'button'; downloadButton.className = 'secondary-button small'; downloadButton.textContent = '下载'; downloadButton.addEventListener('click', () => { void downloadExport(item.id, item.filename); }); actionBox.appendChild(downloadButton); row.appendChild(actionBox); dom.exportList.appendChild(row); }); } async function openExportPanel() { if (!state.token) { showToast('请先登录', 'error'); return; } await loadMyExports(); if (dom.exportPanel) { dom.exportPanel.classList.remove('hidden'); } } function hideExportPanel() { if (dom.exportPanel) { dom.exportPanel.classList.add('hidden'); } } async function openAdminPanel() { if (!isAdmin()) { showToast('需要管理员权限', 'error'); return; } await Promise.all([ loadAdminUsers(), loadAdminExports(dom.adminExportSearch ? dom.adminExportSearch.value || '' : ''), ]); if (dom.adminPanel) { dom.adminPanel.classList.remove('hidden'); } } function hideAdminPanel() { if (dom.adminPanel) { dom.adminPanel.classList.add('hidden'); } } async function loadAdminUsers() { if (!isAdmin()) { state.adminUsers = []; renderAdminUsers(); return; } try { const data = await fetchJSON('/api/admin/users?page=0&page_size=200'); state.adminUsers = Array.isArray(data.items) ? data.items : []; renderAdminUsers(); } catch (err) { showToast(err.message || '加载用户列表失败', 'error'); } } function renderAdminUsers() { if (!dom.adminUserList) { return; } dom.adminUserList.innerHTML = ''; if (!state.adminUsers.length) { const empty = document.createElement('p'); empty.className = 'empty-note'; empty.textContent = isAdmin() ? '暂无普通用户。' : '无权限查看用户。'; dom.adminUserList.appendChild(empty); return; } state.adminUsers.forEach((user) => { const row = document.createElement('div'); row.className = 'admin-row'; const info = document.createElement('div'); info.className = 'admin-row-info'; const name = document.createElement('strong'); name.textContent = user.username; info.appendChild(name); const meta = document.createElement('span'); meta.className = 'admin-row-meta'; const roleLabel = user.role === 'admin' ? '管理员' : '普通用户'; meta.textContent = `${roleLabel} · ${new Date(user.created_at || Date.now()).toLocaleString()}`; info.appendChild(meta); row.appendChild(info); const actions = document.createElement('div'); actions.className = 'admin-row-actions'; const resetButton = document.createElement('button'); resetButton.type = 'button'; resetButton.className = 'secondary-button small'; resetButton.textContent = '重置密码'; resetButton.disabled = user.role === 'admin'; resetButton.addEventListener('click', () => { void handleAdminReset(user); }); actions.appendChild(resetButton); const deleteButton = document.createElement('button'); deleteButton.type = 'button'; deleteButton.className = 'secondary-button danger small'; deleteButton.textContent = '删除'; deleteButton.disabled = user.role === 'admin'; deleteButton.addEventListener('click', () => { void handleAdminDelete(user); }); actions.appendChild(deleteButton); row.appendChild(actions); dom.adminUserList.appendChild(row); }); } async function handleAdminCreate(event) { event.preventDefault(); if (!isAdmin()) { showToast('需要管理员权限', 'error'); return; } const formData = new FormData(dom.adminCreateForm); const username = String(formData.get('username') || '').trim(); const password = String(formData.get('password') || '').trim(); if (!username || !password) { showToast('请输入用户名和密码', 'error'); return; } try { await fetchJSON('/api/admin/users', { method: 'POST', body: { username, password }, }); dom.adminCreateForm.reset(); showToast('已创建用户', 'success'); await loadAdminUsers(); } catch (err) { showToast(err.message || '创建用户失败', 'error'); } } async function handleAdminReset(user) { if (!isAdmin() || !user || user.role === 'admin') { return; } const password = window.prompt(`请输入 ${user.username} 的新密码:`); if (!password) { return; } try { await fetchJSON(`/api/admin/users/${user.id}`, { method: 'PUT', body: { password }, }); showToast('密码已更新', 'success'); } catch (err) { showToast(err.message || '重置密码失败', 'error'); } } async function handleAdminDelete(user) { if (!isAdmin() || !user || user.role === 'admin') { return; } const confirmed = window.confirm(`确定要删除 ${user.username} 吗?`); if (!confirmed) { return; } try { await fetchJSON(`/api/admin/users/${user.id}`, { method: 'DELETE' }); showToast('已删除用户', 'success'); await loadAdminUsers(); } catch (err) { showToast(err.message || '删除用户失败', 'error'); } } async function loadAdminExports(keyword = '') { if (!isAdmin()) { state.adminExports = []; renderAdminExports(); return; } const query = keyword ? `?keyword=${encodeURIComponent(keyword)}` : ''; try { const data = await fetchJSON(`/api/admin/exports${query}`); state.adminExports = Array.isArray(data.items) ? data.items : []; renderAdminExports(); } catch (err) { showToast(err.message || '获取导出列表失败', 'error'); } } function renderAdminExports() { if (!dom.adminExportList) { return; } dom.adminExportList.innerHTML = ''; if (!state.adminExports.length) { const empty = document.createElement('p'); empty.className = 'empty-note'; empty.textContent = isAdmin() ? '暂无导出记录。' : '无权限查看。'; dom.adminExportList.appendChild(empty); return; } state.adminExports.forEach((item) => { const row = document.createElement('div'); row.className = 'admin-row'; const info = document.createElement('div'); info.className = 'admin-row-info'; const title = document.createElement('strong'); title.textContent = item.filename || `导出 #${item.id}`; info.appendChild(title); const meta = document.createElement('span'); meta.className = 'admin-row-meta'; meta.textContent = `${item.username || '未知用户'} · ${new Date(item.created_at || Date.now()).toLocaleString()}`; info.appendChild(meta); const preview = document.createElement('div'); preview.className = 'admin-row-preview'; preview.textContent = item.content_preview || ''; info.appendChild(preview); row.appendChild(info); const actions = document.createElement('div'); actions.className = 'admin-row-actions'; const downloadButton = document.createElement('button'); downloadButton.type = 'button'; downloadButton.className = 'secondary-button small'; downloadButton.textContent = '下载'; downloadButton.addEventListener('click', () => { void downloadExport(item.id, item.filename); }); actions.appendChild(downloadButton); row.appendChild(actions); dom.adminExportList.appendChild(row); }); } async function downloadExport(exportId, filename) { try { const response = await fetch(`/api/exports/${exportId}/download`, { headers: buildAuthHeaders(), }); if (response.status === 401) { handleUnauthorized(); return; } if (!response.ok) { const message = await readErrorMessage(response); throw new Error(message || '下载失败'); } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename || `export-${exportId}.txt`; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); showToast('下载完成', 'success'); } catch (err) { showToast(err.message || '下载失败', 'error'); } } async function loadConfig() { const config = await fetchJSON('/api/config'); state.config = config; const models = Array.isArray(config.models) ? config.models : []; state.model = config.default_model || models[0] || ''; populateSelect(dom.modelSelect, models, state.model); populateSelect(dom.outputMode, config.output_modes || [], state.outputMode); } function populateSelect(selectEl, values, selected) { selectEl.innerHTML = ''; values.forEach((value) => { const option = document.createElement('option'); option.value = value; option.textContent = value; if (value === selected) { option.selected = true; } selectEl.appendChild(option); }); if (!values.length) { const option = document.createElement('option'); option.value = ''; option.textContent = '无可用选项'; selectEl.appendChild(option); } } async function loadLatestSession(options = {}) { if (!state.token) { return; } const { updateUrl = true, replaceUrl = false } = options; const data = await fetchJSON('/api/session/latest'); updateActiveSession(data.session_id, data.session_number, { updateUrl, replace: replaceUrl }); state.messages = Array.isArray(data.messages) ? data.messages : []; state.expandedMessages = new Set(); state.historyCount = Math.min(state.historyCount, state.messages.length); state.searchQuery = ''; dom.searchInput.value = ''; state.historyPage = 0; renderSidebar(); renderMessages(); renderHistory(); } async function loadSession(sessionId, options = {}) { if (!state.token) { return false; } const { silent = false, updateUrl = true, replaceUrl = false } = options; if (state.streaming) { if (!silent) { showToast('请等待当前回复完成后再切换会话', 'error'); } return false; } try { const data = await fetchJSON(`/api/session/${sessionId}`); updateActiveSession(data.session_id, data.session_number, { updateUrl, replace: replaceUrl }); state.messages = Array.isArray(data.messages) ? data.messages : []; state.historyCount = Math.min(state.historyCount, state.messages.length); state.expandedMessages = new Set(); state.searchQuery = ''; dom.searchInput.value = ''; renderSidebar(); renderMessages(); renderHistory(); return true; } catch (err) { if (!silent) { showToast(err.message || '加载会话失败', 'error'); } throw err; } } async function loadHistory() { if (!state.token) { renderHistory(); return; } try { const data = await fetchJSON(`/api/history?page=${state.historyPage}&page_size=${state.historyPageSize}`); const total = data.total || 0; const items = Array.isArray(data.items) ? data.items : []; if (state.historyPage > 0 && items.length === 0 && total > 0) { const maxPage = Math.max(0, Math.ceil(total / state.historyPageSize) - 1); if (state.historyPage > maxPage) { state.historyPage = maxPage; await loadHistory(); return; } } state.historyTotal = total; state.historyItems = items; renderHistory(); } catch (err) { showToast(err.message || '获取历史记录失败', 'error'); } } function renderSidebar() { if (state.config) { populateSelect(dom.modelSelect, state.config.models || [], state.model); populateSelect(dom.outputMode, state.config.output_modes || [], state.outputMode); } updateHistorySlider(); updateSearchFeedback(); updateUserUi(); } function updateHistorySlider() { const total = state.messages.length; if (dom.historyRange) { dom.historyRange.max = String(total); dom.historyRange.disabled = !state.token; state.historyCount = Math.min(state.historyCount, total); dom.historyRange.value = String(state.historyCount); } dom.historyRangeLabel.textContent = `选择使用的历史消息数量(共${total}条)`; dom.historyRangeValue.textContent = `您选择的历史消息数量是: ${state.historyCount}`; } function updateSearchFeedback() { if (!state.searchQuery) { dom.searchFeedback.textContent = '无匹配。'; return; } const matches = state.messages.filter((msg) => messageMatches(msg.content, state.searchQuery)).length; dom.searchFeedback.textContent = `共找到 ${matches} 条匹配。`; } function setStatus(message, stateClass) { if (!dom.chatStatus) { return; } dom.chatStatus.textContent = message || ''; dom.chatStatus.classList.remove('running', 'error'); if (!message) { return; } if (stateClass) { dom.chatStatus.classList.add(stateClass); } } function setStreaming(active) { state.streaming = active; if (dom.sendButton) { dom.sendButton.disabled = active; const defaultText = dom.sendButton.dataset.defaultText || '发送'; dom.sendButton.textContent = active ? '发送中…' : defaultText; } if (dom.newChatButton) { dom.newChatButton.disabled = active; } if (dom.stopButton) { dom.stopButton.setAttribute('aria-hidden', active ? 'false' : 'true'); dom.stopButton.title = '提前结束此次对话'; } if (active) { setStatus('正在生成回复…', 'running'); } if (!active) { if (dom.stopButton) { dom.stopButton.classList.add('hidden'); dom.stopButton.disabled = true; } state.activeAbortController = null; } else if (dom.stopButton) { dom.stopButton.classList.remove('hidden'); dom.stopButton.disabled = false; } } function handleAbortConversation() { if (!state.streaming || !state.activeAbortController) { return; } try { state.activeAbortController.abort(); } catch (err) { console.warn('Abort failed', err); } if (dom.stopButton) { dom.stopButton.disabled = true; } } function renderHistory() { if (dom.historyCount) { const total = Number.isFinite(state.historyTotal) ? state.historyTotal : 0; dom.historyCount.textContent = state.token ? `共 ${total} 条` : ''; } dom.historyList.innerHTML = ''; if (!state.token) { const notice = document.createElement('div'); notice.className = 'sidebar-help'; notice.textContent = '请先登录以查看历史记录。'; dom.historyList.appendChild(notice); if (dom.historyPrev) { dom.historyPrev.disabled = true; } if (dom.historyNext) { dom.historyNext.disabled = true; } return; } if (!state.historyItems.length) { const empty = document.createElement('div'); empty.className = 'sidebar-help'; empty.textContent = '无记录。'; dom.historyList.appendChild(empty); } else { state.historyItems.forEach((item) => { const row = document.createElement('div'); row.className = 'history-row'; row.dataset.sessionId = String(item.session_id); row.setAttribute('role', 'listitem'); if (item.session_id === state.sessionId) { row.classList.add('active'); } const loadLink = document.createElement('a'); loadLink.className = 'history-title-link'; loadLink.href = buildSessionUrl(item.session_id); const sessionNumber = Number.isFinite(Number(item.session_number)) ? Number(item.session_number) : item.session_id; const displayTitle = (item.title && item.title.trim()) ? item.title.trim() : `会话 #${sessionNumber}`; const primary = document.createElement('span'); primary.className = 'history-title-text'; primary.textContent = displayTitle; loadLink.appendChild(primary); const subtitle = document.createElement('span'); subtitle.className = 'history-subtitle'; subtitle.textContent = `会话 #${sessionNumber}`; loadLink.appendChild(subtitle); loadLink.title = `会话 #${item.session_id} · 点击加载`; loadLink.addEventListener('click', async (event) => { const isModified = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0; if (isModified) { return; } event.preventDefault(); try { await loadSession(item.session_id, { replaceUrl: false }); } catch (err) { console.warn('Failed to load session from history list:', err); } }); row.appendChild(loadLink); const moveButton = document.createElement('button'); moveButton.className = 'history-icon-button'; moveButton.type = 'button'; moveButton.textContent = '📦'; moveButton.title = '移动到备份文件夹'; moveButton.addEventListener('click', async (event) => { event.stopPropagation(); try { const isActive = item.session_id === state.sessionId; await fetchJSON('/api/history/move', { method: 'POST', body: { session_id: item.session_id }, }); showToast('已移动到备份。', 'success'); await loadHistory(); if (isActive) { await loadLatestSession({ updateUrl: true, replaceUrl: true }); } } catch (err) { showToast(err.message || '移动失败', 'error'); } }); row.appendChild(moveButton); const deleteButton = document.createElement('button'); deleteButton.className = 'history-icon-button'; deleteButton.type = 'button'; deleteButton.textContent = '❌'; deleteButton.title = '删除'; deleteButton.addEventListener('click', async (event) => { event.stopPropagation(); try { await fetchJSON(`/api/history/${item.session_id}`, { method: 'DELETE' }); showToast('已删除。', 'success'); if (item.session_id === state.sessionId) { await loadLatestSession(); } await loadHistory(); } catch (err) { showToast(err.message || '删除失败', 'error'); } }); row.appendChild(deleteButton); dom.historyList.appendChild(row); }); } const totalPages = Math.ceil(state.historyTotal / state.historyPageSize) || 1; if (dom.historyPrev) { dom.historyPrev.disabled = state.historyPage <= 0; } if (dom.historyNext) { dom.historyNext.disabled = state.historyPage >= totalPages - 1; } } function renderMessages() { if (!dom.chatMessages) { return; } dom.chatMessages.innerHTML = ''; if (!state.token) { const notice = document.createElement('div'); notice.className = 'message notice'; notice.textContent = '请先登录以开始聊天。'; dom.chatMessages.appendChild(notice); return; } const total = state.messages.length; const searching = Boolean(state.searchQuery); state.messages.forEach((message, index) => { const wrapper = document.createElement('div'); wrapper.className = `message ${message.role === 'assistant' ? 'assistant' : 'user'}`; wrapper.dataset.index = String(index); const header = document.createElement('div'); header.className = 'message-header'; header.textContent = message.role === 'assistant' ? 'Assistant' : 'User'; wrapper.appendChild(header); const contentEl = document.createElement('div'); contentEl.className = 'message-content'; const expanded = state.expandedMessages.has(index); const shouldClamp = !searching && index < total - 1 && !expanded; if (shouldClamp) { contentEl.classList.add('clamped'); } const query = state.searchQuery && messageMatches(message.content, state.searchQuery) ? state.searchQuery : ''; renderContent(message.content, contentEl, query); wrapper.appendChild(contentEl); const actions = document.createElement('div'); actions.className = 'message-actions'; if (!searching && index < total - 1) { const toggleButton = document.createElement('button'); toggleButton.className = 'message-button'; toggleButton.textContent = expanded ? '<<' : '>>'; toggleButton.addEventListener('click', () => { if (expanded) { state.expandedMessages.delete(index); } else { state.expandedMessages.add(index); } renderMessages(); }); actions.appendChild(toggleButton); } if (message.role === 'assistant') { const exportButton = document.createElement('button'); exportButton.className = 'message-button'; exportButton.textContent = '导出'; exportButton.addEventListener('click', async () => { try { const data = await fetchJSON('/api/export', { method: 'POST', body: { content: message.content, session_id: state.sessionId }, }); if (data && data.export) { state.myExports = [data.export, ...state.myExports]; renderMyExports(); } showToast('已导出并保存到数据库。', 'success'); } catch (err) { showToast(err.message || '导出失败', 'error'); } }); actions.appendChild(exportButton); } wrapper.appendChild(actions); dom.chatMessages.appendChild(wrapper); }); updateSearchFeedback(); scrollToBottom(); } function renderContent(content, container, query) { container.innerHTML = ''; const highlightQuery = query || ''; if (typeof content === 'string' || content === null || content === undefined) { renderMarkdownContent(container, String(content || '')); applyHighlight(container, highlightQuery); return; } if (Array.isArray(content)) { content.forEach((part) => { if (part && part.type === 'text') { const textContainer = document.createElement('div'); renderMarkdownContent(textContainer, String(part.text || '')); container.appendChild(textContainer); } else if (part && part.type === 'image_url') { const url = part.image_url && part.image_url.url ? part.image_url.url : ''; const img = document.createElement('img'); img.src = url; img.alt = '上传的图片'; img.loading = 'lazy'; container.appendChild(img); } else { const fallback = document.createElement('pre'); fallback.textContent = JSON.stringify(part, null, 2); container.appendChild(fallback); } }); applyHighlight(container, highlightQuery); return; } const pre = document.createElement('pre'); pre.textContent = typeof content === 'object' ? JSON.stringify(content, null, 2) : String(content || ''); container.appendChild(pre); applyHighlight(container, highlightQuery); } function renderMarkdownContent(container, text) { const normalized = String(text || '').replace(/\r\n/g, '\n'); const lines = normalized.split('\n'); let paragraphBuffer = []; let listBuffer = []; let blockquoteBuffer = []; let inCodeBlock = false; let codeLang = ''; let codeBuffer = []; const flushParagraph = () => { if (!paragraphBuffer.length) { return; } const paragraphText = paragraphBuffer.join('\n'); const paragraph = document.createElement('p'); appendInlineMarkdown(paragraph, paragraphText); container.appendChild(paragraph); paragraphBuffer = []; }; const flushList = () => { if (!listBuffer.length) { return; } const list = document.createElement('ul'); listBuffer.forEach((item) => { const li = document.createElement('li'); appendInlineMarkdown(li, item); list.appendChild(li); }); container.appendChild(list); listBuffer = []; }; const flushBlockquote = () => { if (!blockquoteBuffer.length) { return; } const blockquote = document.createElement('blockquote'); const textContent = blockquoteBuffer.join('\n'); appendInlineMarkdown(blockquote, textContent); container.appendChild(blockquote); blockquoteBuffer = []; }; const flushCode = () => { const pre = document.createElement('pre'); const code = document.createElement('code'); if (codeLang) { code.dataset.lang = codeLang; code.className = `language-${codeLang}`; } code.textContent = codeBuffer.join('\n'); pre.appendChild(code); container.appendChild(pre); codeBuffer = []; codeLang = ''; inCodeBlock = false; }; lines.forEach((rawLine) => { const line = rawLine; const fenceMatch = line.match(/^```([A-Za-z0-9_-]+)?\s*$/); if (fenceMatch) { if (inCodeBlock) { flushCode(); } else { flushParagraph(); flushList(); flushBlockquote(); inCodeBlock = true; codeLang = fenceMatch[1] ? fenceMatch[1].toLowerCase() : ''; codeBuffer = []; } return; } if (inCodeBlock) { codeBuffer.push(line); return; } const listMatch = line.match(/^\s*[-*+]\s+(.*)$/); if (listMatch) { flushParagraph(); flushBlockquote(); listBuffer.push(listMatch[1]); return; } const blockquoteMatch = line.match(/^>\s?(.*)$/); if (blockquoteMatch) { flushParagraph(); flushList(); blockquoteBuffer.push(blockquoteMatch[1]); return; } if (!line.trim()) { flushParagraph(); flushList(); flushBlockquote(); return; } const headingMatch = line.match(/^(#{1,6})\s+(.*)$/); if (headingMatch) { flushParagraph(); flushList(); flushBlockquote(); const level = Math.min(headingMatch[1].length, 6); const heading = document.createElement(`h${level}`); appendInlineMarkdown(heading, headingMatch[2]); container.appendChild(heading); return; } paragraphBuffer.push(line); }); if (inCodeBlock) { flushCode(); } flushParagraph(); flushList(); flushBlockquote(); } function appendInlineMarkdown(parent, text) { const pattern = /(!?\[[^\]]*\]\([^\)]+\)|`[^`]*`|\*\*[^*]+\*\*|\*[^*]+\*|~~[^~]+~~)/g; let lastIndex = 0; let match; while ((match = pattern.exec(text)) !== null) { if (match.index > lastIndex) { appendTextNode(parent, text.slice(lastIndex, match.index)); } appendMarkdownToken(parent, match[0]); lastIndex = pattern.lastIndex; } if (lastIndex < text.length) { appendTextNode(parent, text.slice(lastIndex)); } } function appendMarkdownToken(parent, token) { if (token.startsWith('`') && token.endsWith('`')) { const code = document.createElement('code'); code.textContent = token.slice(1, -1); parent.appendChild(code); return; } if (token.startsWith('**') && token.endsWith('**')) { const strong = document.createElement('strong'); appendInlineMarkdown(strong, token.slice(2, -2)); parent.appendChild(strong); return; } if (token.startsWith('*') && token.endsWith('*')) { const em = document.createElement('em'); appendInlineMarkdown(em, token.slice(1, -1)); parent.appendChild(em); return; } if (token.startsWith('~~') && token.endsWith('~~')) { const del = document.createElement('del'); appendInlineMarkdown(del, token.slice(2, -2)); parent.appendChild(del); return; } if (token.startsWith('![')) { const match = token.match(/^!\[([^\]]*)\]\(([^\)]+)\)$/); if (match) { const img = document.createElement('img'); img.alt = match[1]; img.src = match[2]; img.loading = 'lazy'; parent.appendChild(img); return; } } if (token.startsWith('[')) { const match = token.match(/^\[([^\]]+)\]\(([^\)]+)\)$/); if (match) { const anchor = document.createElement('a'); anchor.href = match[2]; anchor.target = '_blank'; anchor.rel = 'noopener noreferrer'; anchor.textContent = match[1]; parent.appendChild(anchor); return; } } appendTextNode(parent, token); } function appendTextNode(parent, text) { if (!text) { return; } const fragments = String(text).split(/(\n)/); fragments.forEach((fragment) => { if (fragment === '\n') { parent.appendChild(document.createElement('br')); } else if (fragment) { parent.appendChild(document.createTextNode(fragment)); } }); } function clearHighlights(root) { if (!root) { return; } root.querySelectorAll('mark.hl').forEach((mark) => { const parent = mark.parentNode; if (!parent) { return; } while (mark.firstChild) { parent.insertBefore(mark.firstChild, mark); } parent.removeChild(mark); parent.normalize(); }); } function applyHighlight(root, query) { if (!root) { return; } clearHighlights(root); if (!query) { return; } const lowerQuery = query.toLowerCase(); const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); const matches = []; while (walker.nextNode()) { const node = walker.currentNode; if (!node || !node.nodeValue || !node.nodeValue.trim()) { continue; } const text = node.nodeValue; const lowerText = text.toLowerCase(); let index = lowerText.indexOf(lowerQuery); while (index !== -1) { matches.push({ node, start: index, end: index + query.length }); index = lowerText.indexOf(lowerQuery, index + query.length); } } for (let i = matches.length - 1; i >= 0; i -= 1) { const { node, start, end } = matches[i]; if (!node || !node.parentNode) { continue; } const range = document.createRange(); range.setStart(node, start); range.setEnd(node, end); const mark = document.createElement('mark'); mark.className = 'hl'; range.surroundContents(mark); } } function messageMatches(content, query) { if (!query) { return false; } const lower = query.toLowerCase(); if (typeof content === 'string') { return content.toLowerCase().includes(lower); } if (Array.isArray(content)) { return content.some((part) => { if (!part || part.type !== 'text') { return false; } return String(part.text || '').toLowerCase().includes(lower); }); } try { return JSON.stringify(content).toLowerCase().includes(lower); } catch (err) { return false; } } async function handleSubmitMessage(event) { event.preventDefault(); if (!state.token) { showToast('请先登录后再聊天', 'error'); return; } if (state.streaming) { showToast('请等待当前回复完成', 'error'); return; } const text = dom.chatInput.value.trim(); const files = dom.fileInput.files; if (!text && (!files || files.length === 0)) { showToast('请输入内容或上传文件', 'error'); return; } let uploads = []; const hasFiles = files && files.length > 0; if (hasFiles) { try { setStatus('正在上传文件…', 'running'); uploads = await uploadAttachments(files); } catch (err) { const message = err.message || '文件上传失败'; setStatus(message, 'error'); showToast(message, 'error'); return; } } const { content } = buildUserContent(text, uploads); if (!hasContent(content)) { setStatus('内容不能为空', 'error'); showToast('内容不能为空', 'error'); return; } setStatus(''); state.expandedMessages = new Set(); const userMessage = { role: 'user', content }; state.messages.push(userMessage); renderMessages(); scrollToBottom(); dom.chatInput.value = ''; dom.fileInput.value = ''; const assistantMessage = { role: 'assistant', content: '' }; state.messages.push(assistantMessage); const assistantIndex = state.messages.length - 1; renderMessages(); scrollToBottom(); const payload = { session_id: state.sessionId ?? 0, model: state.model, content, history_count: state.historyCount, stream: state.outputMode === '流式输出 (Stream)', }; const controller = new AbortController(); state.activeAbortController = controller; setStreaming(true); try { if (payload.stream) { await streamAssistantReply(payload, assistantMessage, assistantIndex, controller); } else { const data = await fetchJSON('/api/chat', { method: 'POST', body: payload, signal: controller.signal, }); if (Number.isFinite(Number(data.session_id))) { updateActiveSession(data.session_id, data.session_number); } assistantMessage.content = data.message || ''; updateMessageContent(assistantIndex, assistantMessage.content); showToast('已生成回复', 'success'); setStatus(''); } } catch (err) { const aborted = err && (err.name === 'AbortError'); if (!aborted) { state.messages.splice(assistantIndex, 1); renderMessages(); const message = err.message || '发送失败'; setStatus(message, 'error'); showToast(message, 'error'); } else { setStatus('对话已提前结束', 'error'); showToast('当前对话已中止', 'error'); } } finally { try { state.historyPage = 0; await loadHistory(); } catch (historyErr) { console.error('刷新历史记录失败', historyErr); } finally { updateHistorySlider(); setStreaming(false); } } } function hasContent(content) { if (typeof content === 'string') { return Boolean(content.trim()); } if (Array.isArray(content)) { return content.length > 1 || (content[0] && String(content[0].text || '').trim()); } return Boolean(content); } async function uploadAttachments(fileList) { if (!fileList || fileList.length === 0) { return []; } const formData = new FormData(); Array.from(fileList).forEach((file) => formData.append('files', file)); const response = await fetch('/api/upload', { method: 'POST', headers: buildAuthHeaders(), body: formData, }); if (!response.ok) { throw new Error('文件上传失败'); } return await response.json(); } function buildUserContent(text, uploads) { const results = Array.isArray(uploads) ? uploads : []; if (!results.length) { return { content: text }; } const contentParts = [{ type: 'text', text: text }]; let additionalPrompt = ''; results.forEach((item) => { if (item.type === 'image' && item.data) { contentParts.push({ type: 'image_url', image_url: { url: item.data }, }); } else if (item.type === 'file' && item.url) { additionalPrompt += `本次提问包含:${item.url} 文件\n`; } }); const promptSuffix = additionalPrompt.trim(); if (contentParts.length > 1) { const base = contentParts[0].text || ''; contentParts[0].text = promptSuffix ? `${base}\n${promptSuffix}`.trim() : base; return { content: contentParts }; } let combined = text || ''; if (promptSuffix) { combined = combined ? `${combined}\n${promptSuffix}` : promptSuffix; } return { content: combined.trim() }; } async function streamAssistantReply(payload, assistantMessage, assistantIndex, controller) { const response = await fetch('/api/chat', { method: 'POST', headers: buildAuthHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(payload), signal: controller ? controller.signal : undefined, }); if (response.status === 401) { handleUnauthorized(); throw new Error('未授权'); } if (!response.ok || !response.body) { const errorText = await safeReadText(response); throw new Error(errorText || '生成失败'); } const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; let done = false; while (!done) { const { value, done: streamDone } = await reader.read(); done = streamDone; if (value) { buffer += decoder.decode(value, { stream: !done }); let newlineIndex = buffer.indexOf('\n'); while (newlineIndex !== -1) { const line = buffer.slice(0, newlineIndex).trim(); buffer = buffer.slice(newlineIndex + 1); if (line) { const status = handleStreamLine(line, assistantMessage, assistantIndex); if (status === 'end') { return; } } newlineIndex = buffer.indexOf('\n'); } } } setStatus(''); } function handleStreamLine(line, assistantMessage, assistantIndex) { let payload; try { payload = JSON.parse(line); } catch (err) { return; } if (payload.type === 'meta') { updateActiveSession(payload.session_id, payload.session_number); return null; } if (payload.type === 'delta') { if (typeof assistantMessage.content !== 'string') { assistantMessage.content = ''; } assistantMessage.content += payload.text || ''; updateMessageContent(assistantIndex, assistantMessage.content); scrollToBottom(); return null; } else if (payload.type === 'end') { showToast('已生成回复', 'success'); setStatus(''); return 'end'; } else if (payload.type === 'error') { throw new Error(payload.message || '生成失败'); } } function updateMessageContent(index, content) { const selector = `.message[data-index="${index}"] .message-content`; const node = dom.chatMessages.querySelector(selector); if (!node) { renderMessages(); return; } node.classList.remove('clamped'); renderContent(content, node, state.searchQuery && messageMatches(content, state.searchQuery) ? state.searchQuery : ''); } function scrollToBottom() { if (!dom.chatMessages) { return; } dom.chatMessages.scrollTop = dom.chatMessages.scrollHeight; } function getSessionIdFromUrl() { const params = new URLSearchParams(window.location.search); const value = params.get('session'); if (!value) { return null; } const parsed = Number(value); return Number.isInteger(parsed) && parsed >= 0 ? parsed : null; } function buildSessionUrl(sessionId) { const current = new URL(window.location.href); if (Number.isInteger(sessionId) && sessionId >= 0) { current.searchParams.set('session', String(sessionId)); } else { current.searchParams.delete('session'); } current.hash = ''; const search = current.searchParams.toString(); return `${current.pathname}${search ? `?${search}` : ''}`; } function updateSessionInUrl(sessionId, options = {}) { if (!window.history || typeof window.history.replaceState !== 'function') { return; } const { replace = false } = options; const target = buildSessionUrl(sessionId); const stateData = { sessionId }; if (replace) { window.history.replaceState(stateData, '', target); } else { window.history.pushState(stateData, '', target); } } async function handlePopState(event) { if (!state.token) { return; } if (state.streaming) { return; } const stateSessionId = event.state && Number.isInteger(event.state.sessionId) ? event.state.sessionId : getSessionIdFromUrl(); try { if (stateSessionId !== null) { await loadSession(stateSessionId, { silent: true, updateUrl: false }); } else { await loadLatestSession({ updateUrl: false }); } await loadHistory(); } catch (err) { console.warn('Failed to restore session from history navigation:', err); } } function buildAuthHeaders(baseHeaders = {}) { const headers = { ...(baseHeaders || {}) }; if (state.token) { headers.Authorization = `Bearer ${state.token}`; } return headers; } async function fetchJSON(url, options = {}) { const opts = { ...options }; opts.headers = buildAuthHeaders(opts.headers || {}); if (opts.body && !(opts.body instanceof FormData) && typeof opts.body !== 'string') { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(opts.body); } const response = await fetch(url, opts); if (response.status === 401) { handleUnauthorized(); const message = await readErrorMessage(response); throw new Error(message || '未授权'); } if (!response.ok) { const message = await readErrorMessage(response); throw new Error(message || '请求失败'); } if (response.status === 204) { return {}; } const text = await response.text(); return text ? JSON.parse(text) : {}; } async function readErrorMessage(response) { const text = await safeReadText(response); if (!text) { return response.statusText; } try { const data = JSON.parse(text); return data.detail || data.message || text; } catch (err) { return text; } } async function safeReadText(response) { try { return await response.text(); } catch (err) { return ''; } } let toastTimer; function showToast(message, type = 'success') { if (!dom.toast) { return; } dom.toast.textContent = message; dom.toast.classList.remove('hidden', 'success', 'error', 'show'); dom.toast.classList.add(type, 'show'); clearTimeout(toastTimer); toastTimer = setTimeout(() => { dom.toast.classList.remove('show'); toastTimer = setTimeout(() => dom.toast.classList.add('hidden'), 300); }, 2500); } function extractInitials(name = '') { if (!name) { return '?'; } const trimmed = name.trim(); if (!trimmed) { return '?'; } return trimmed.slice(0, 2).toUpperCase(); } function setUserMenuOpen(open) { const nextState = Boolean(open && state.token); state.userMenuOpen = nextState; if (dom.userMenuDropdown) { dom.userMenuDropdown.classList.toggle('hidden', !nextState); } if (dom.userMenu) { dom.userMenu.classList.toggle('open', nextState); } if (dom.userMenuToggle) { dom.userMenuToggle.setAttribute('aria-expanded', nextState ? 'true' : 'false'); } } function updateSessionIndicator() { if (!dom.sessionIndicator) { return; } if (!state.token || state.sessionId === null) { dom.sessionIndicator.textContent = ''; dom.sessionIndicator.classList.add('hidden'); return; } const displayNumber = Number.isInteger(state.sessionNumber) ? state.sessionNumber : state.sessionId; dom.sessionIndicator.textContent = `会话 #${displayNumber}`; dom.sessionIndicator.classList.remove('hidden'); } function updateActiveSession(sessionId, sessionNumber, options = {}) { const parsedId = Number(sessionId); const parsedNumber = Number(sessionNumber); const nextId = Number.isFinite(parsedId) ? parsedId : null; const previousId = state.sessionId; state.sessionId = nextId; state.sessionNumber = Number.isFinite(parsedNumber) ? parsedNumber : null; updateSessionIndicator(); if (options.updateUrl === false) { return; } if (previousId === state.sessionId && !options.replace) { return; } updateSessionInUrl(state.sessionId, { replace: Boolean(options.replace) }); } })();