(function () { 'use strict'; const state = { config: null, sessionId: null, messages: [], expandedMessages: new Set(), historyPage: 0, historyPageSize: 9999, historyTotal: 0, historyItems: [], model: '', outputMode: '流式输出 (Stream)', historyCount: 0, searchQuery: '', streaming: false, }; const dom = {}; document.addEventListener('DOMContentLoaded', init); async function init() { cacheDom(); bindEvents(); try { 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(); } catch (err) { showToast(err.message || '初始化失败', 'error'); } renderSidebar(); renderMessages(); renderHistory(); } function cacheDom() { 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.toast = document.getElementById('toast'); if (dom.sendButton && !dom.sendButton.dataset.defaultText) { dom.sendButton.dataset.defaultText = dom.sendButton.textContent || '发送'; } } function bindEvents() { 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.streaming) { showToast('请等待当前回复完成后再新建会话', 'error'); return; } try { const data = await fetchJSON('/api/session/new', { method: 'POST' }); state.sessionId = data.session_id; state.messages = []; state.historyCount = 0; state.searchQuery = ''; dom.searchInput.value = ''; state.expandedMessages = new Set(); state.historyPage = 0; renderSidebar(); renderMessages(); renderHistory(); updateSessionInUrl(state.sessionId, { replace: false }); 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); } 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 = {}) { const { updateUrl = true, replaceUrl = false } = options; const data = await fetchJSON('/api/session/latest'); state.sessionId = typeof data.session_id === 'number' ? data.session_id : 0; 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(); if (updateUrl) { updateSessionInUrl(state.sessionId, { replace: replaceUrl }); } } async function loadSession(sessionId, options = {}) { 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}`); state.sessionId = data.session_id; 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(); if (updateUrl) { updateSessionInUrl(state.sessionId, { replace: replaceUrl }); } return true; } catch (err) { if (!silent) { showToast(err.message || '加载会话失败', 'error'); } throw err; } } async function loadHistory() { 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(); } function updateHistorySlider() { const total = state.messages.length; dom.historyRange.max = String(total); 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 (active) { setStatus('正在生成回复…', 'running'); } } function renderHistory() { if (dom.historyCount) { const total = Number.isFinite(state.historyTotal) ? state.historyTotal : 0; dom.historyCount.textContent = `共 ${total} 条`; } dom.historyList.innerHTML = ''; 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 displayTitle = (item.title && item.title.trim()) ? item.title.trim() : `会话 #${item.session_id}`; 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 = item.filename ? item.filename : `会话 #${item.session_id}`; 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 { await fetchJSON('/api/history/move', { method: 'POST', body: { session_id: item.session_id }, }); showToast('已移动到备份。', 'success'); await loadHistory(); } 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; dom.historyPrev.disabled = state.historyPage <= 0; dom.historyNext.disabled = state.historyPage >= totalPages - 1; } function renderMessages() { dom.chatMessages.innerHTML = ''; 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 { await fetchJSON('/api/export', { method: 'POST', body: { content: message.content }, }); showToast('已导出到 blog 文件夹。', '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.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)', }; setStreaming(true); try { if (payload.stream) { await streamAssistantReply(payload, assistantMessage, assistantIndex); } else { const data = await fetchJSON('/api/chat', { method: 'POST', body: payload, }); assistantMessage.content = data.message || ''; updateMessageContent(assistantIndex, assistantMessage.content); showToast('已生成回复', 'success'); setStatus(''); } } catch (err) { state.messages.splice(assistantIndex, 1); renderMessages(); const message = err.message || '发送失败'; setStatus(message, 'error'); showToast(message, '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', 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) { const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); 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 === '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() { 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.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); } } async function fetchJSON(url, options = {}) { const opts = { ...options }; opts.headers = { ...(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.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); } })();