const API = '/api'; const USER = 'default'; // 2.0 无鉴权:这里自报身份 let sessionId = null; let controller = null; let streaming = false; let resuming = false; let loadingHistory = false; let sessions = []; let pendingApproval = false; const el = (id) => document.getElementById(id); const log = el('log'); function setStatus(text, state) { el('statusPill').textContent = text; el('statusPill').title = text; el('statusDot').className = 'status-dot ' + (state || (streaming ? 'busy' : 'ready')); } function scrollDown() { const area = el('conversationScroll'); area.scrollTop = area.scrollHeight; } function addNode(cls, html) { const wrap = document.createElement('div'); wrap.className = 'msg ' + cls; wrap.innerHTML = html; el('welcome').hidden = true; log.appendChild(wrap); scrollDown(); return wrap; } function addUserBubble(text) { const node = addNode('user', ''); const b = document.createElement('div'); b.className = 'bubble'; b.textContent = text; node.appendChild(b); return node; } function newAssistant() { const node = addNode('assistant', ''); const b = document.createElement('div'); b.className = 'bubble'; node.appendChild(b); return { node, bubble: b, tools: {}, thinking: null, segment: null }; } // Keep Markdown source separate from the rendered DOM. Each text segment owns // its timer, so a tool card cannot steal a pending update from the previous one. const markdownSegments = new WeakMap(); function renderMarkdownSegment(segment) { const state = markdownSegments.get(segment); if (!state) return; clearTimeout(state.timer); state.timer = null; if (!window.marked || !window.DOMPurify) { segment.classList.add('plain-text'); segment.textContent = state.source; return; } segment.classList.remove('plain-text'); const html = marked.parse(state.source, { gfm: true, breaks: true, async: false }); // Model output and stored history are untrusted. Sanitize before insertion. const fragment = DOMPurify.sanitize(html, { RETURN_DOM_FRAGMENT: true, ALLOWED_TAGS: ['p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'strong', 'em', 'del', 's', 'blockquote', 'ul', 'ol', 'li', 'pre', 'code', 'hr', 'a', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'input'], ALLOWED_ATTR: ['href', 'title', 'src', 'alt', 'start', 'align', 'type', 'checked', 'disabled'], ALLOW_DATA_ATTR: false, ALLOW_ARIA_ATTR: false, }); fragment.querySelectorAll('a').forEach((link) => { link.target = '_blank'; link.rel = 'noopener noreferrer'; }); fragment.querySelectorAll('input').forEach((checkbox) => { checkbox.type = 'checkbox'; checkbox.disabled = true; }); fragment.querySelectorAll('table').forEach((table) => { const wrapper = document.createElement('div'); wrapper.className = 'markdown-table'; wrapper.tabIndex = 0; wrapper.setAttribute('role', 'region'); wrapper.setAttribute('aria-label', '表格,可横向滚动'); table.replaceWith(wrapper); wrapper.appendChild(table); }); segment.replaceChildren(fragment); } function appendToken(ui, text) { if (!ui.segment) { ui.segment = document.createElement('div'); ui.segment.className = 'segment'; ui.bubble.appendChild(ui.segment); markdownSegments.set(ui.segment, { source: '', timer: null }); } const segment = ui.segment; const state = markdownSegments.get(segment); state.source += text; // Batch bursts of tokens rather than parsing the entire reply per token. if (state.timer === null) { state.timer = setTimeout(() => { renderMarkdownSegment(segment); if (segment.isConnected) scrollDown(); }, 50); } } // Flush before adding tool/status cards to preserve chronological ordering. function breakSegment(ui) { if (ui.segment) renderMarkdownSegment(ui.segment); ui.segment = null; } function addCard(bubble, cls, title, body) { const card = document.createElement('div'); card.className = 'card ' + cls; const t = document.createElement('div'); t.className = 'title'; t.textContent = title; card.appendChild(t); if (body) { const pre = document.createElement('pre'); pre.textContent = body; card.appendChild(pre); } bubble.appendChild(card); scrollDown(); return card; } function addCardFor(ui, cls, title, body) { breakSegment(ui); return addCard(ui.bubble, cls, title, body); } // ---------- 发送 ---------- async function send() { const input = el('input'); const text = input.value.trim(); if (!text || streaming || loadingHistory || pendingApproval) return; const form = buildForm(text); if (!sessionId) el('chatTitle').textContent = text.slice(0, 30); const attachmentName = el('file').files[0]?.name; input.value = ''; input.style.height = 'auto'; addUserBubble(text + (attachmentName ? '\n📎 ' + attachmentName : '')); clearAttachment(); await stream(`${API}/chat`, 'POST', form); } function buildForm(text) { const fd = new FormData(); fd.append('message', text); if (sessionId) fd.append('session_id', sessionId); fd.append('mode', el('mode').value); fd.append('user_name', USER); const f = el('file').files[0]; if (f) fd.append('file', f); return fd; } function buildResumeBody(extra) { return JSON.stringify(Object.assign({ session_id: sessionId, thread_id: sessionId, user_name: USER, }, extra)); } // ---------- 流式 ---------- async function stream(url, method, body, isJson) { const ui = newAssistant(); controller = new AbortController(); streaming = true; updateControls(); setStatus('连接中...'); let citations = []; try { const headers = {}; if (isJson) headers['Content-Type'] = 'application/json'; const res = await fetch(url, { method, body, headers, signal: controller.signal }); if (!res.ok) { let detail = ''; const raw = await res.text(); try { const parsed = JSON.parse(raw); detail = parsed.detail?.message || JSON.stringify(parsed.detail || parsed); } catch (e) { detail = raw; } addCard(ui.bubble, 'error', `HTTP ${res.status}`, detail); setStatus('请求失败', 'error'); return; } if (isJson) { pendingApproval = false; log.querySelectorAll('.approval .actions').forEach((row) => { row.closest('.msg').dataset.resolved = 'true'; row.replaceWith(document.createTextNode('已提交确认')); }); } const sid = res.headers.get('X-Session-Id'); if (sid) { sessionId = sid; el('sessionPill').textContent = '会话:' + sid.slice(0, 8); } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (!line.startsWith('data: ')) continue; try { handle(JSON.parse(line.slice(6)), ui, citations); } catch (e) { /* 半截 JSON,忽略 */ } } } if (!ui.failed) setStatus(pendingApproval ? '等待你的确认' : '回复已完成', pendingApproval ? 'busy' : 'ready'); } catch (e) { if (e.name === 'AbortError') { addCard(ui.bubble, '', '已停止生成', ''); setStatus('已停止', 'ready'); } else { addCard(ui.bubble, 'error', '连接中断,请重试', String(e)); setStatus('连接失败', 'error'); } } finally { breakSegment(ui); scrollDown(); streaming = false; controller = null; updateControls(); loadSessions(); } } function handle(ev, ui, citations) { switch (ev.type) { case 'agent_start': setStatus(`${ev.cn_agent || ev.agent} 已启动`); break; case 'thinking': setStatus(ev.cn_agent ? `${ev.cn_agent}:${ev.message}` : ev.message); break; case 'thinking_token': if (!ui.thinking) { breakSegment(ui); ui.thinking = document.createElement('details'); const s = document.createElement('summary'); s.textContent = '思考过程'; s.style.color = 'var(--fg-dim)'; ui.thinking.appendChild(s); const p = document.createElement('pre'); p.style.fontSize = '12px'; p.style.color = 'var(--fg-dim)'; ui.thinking.appendChild(p); ui.bubble.appendChild(ui.thinking); } ui.thinking.querySelector('pre').textContent += ev.content || ''; break; case 'executing': setStatus(ev.message || '执行中...'); if (ev.tools && ev.tools.length) { const i = ev.tools.length - 1; ui.tools[ev.tools[i]] = addCardFor(ui, 'pending', `调用 ${ev.cn_tools ? ev.cn_tools[i] : ev.tools[i]}`, ''); } break; case 'tool_call': // 按 tool_call_id 索引:同一工具被调多次时按名字索引会把结果挂错卡 ui.tools[ev.tool_call_id || ev.tool] = addCardFor(ui, 'pending', `工具 ${ev.cn_tool || ev.tool}`, JSON.stringify(ev.args, null, 1)); break; case 'tool_result': { const key = ev.tool_call_id || ev.tool; const card = ui.tools[key] || ui.tools[ev.tool]; const body = typeof ev.content === 'string' ? ev.content : JSON.stringify(ev.content, null, 1); if (card) { card.className = 'card ' + (ev.error ? 'error' : 'done'); const pre = document.createElement('pre'); pre.textContent = body; card.appendChild(pre); } else { addCardFor(ui, ev.error ? 'error' : 'done', `结果 ${ev.cn_tool || ev.tool}`, body); } break; } case 'updated_todo_list': renderTodos(ui, ev.todos || []); break; case 'token': appendToken(ui, ev.content || ''); break; case 'citations': citations.length = 0; (ev.items || []).forEach((it) => citations.push(it)); renderCitations(ui, citations, ev.model_only); break; case 'interrupt': pendingApproval = !!ev.tool_approval; setStatus('等待你的确认', 'busy'); renderInterrupt(ui, ev); break; case 'agent_done': setStatus(`完成 ${ev.duration_ms ? ev.duration_ms + 'ms' : ''} 上下文 ${ev.context_tokens || 0}/${ev.context_window || 0}`); break; case 'agent_error': case 'error': ui.failed = true; setStatus('回复出错', 'error'); addCardFor(ui, 'error', `错误 [${ev.code || '-'}]`, ev.message || ''); break; case 'done': if (ev.usage) { addCardFor(ui, '', '用量', `输入 ${ev.usage.input_tokens} / 输出 ${ev.usage.output_tokens} / 合计 ${ev.usage.total_tokens}(${ev.usage.usage_source})`); } break; } } function renderTodos(ui, todos) { breakSegment(ui); let card = ui.node.querySelector('.todos'); if (!card) { card = document.createElement('div'); card.className = 'card todos'; ui.bubble.appendChild(card); } const mark = { completed: '[x]', in_progress: '[~]', pending: '[ ]' }; card.innerHTML = '
待办
' + todos.map((t) => `${mark[t.status] || '[ ]'} ${escapeHtml(t.content || '')}`).join('
'); } function renderCitations(ui, items, modelOnly) { breakSegment(ui); const box = document.createElement('div'); box.className = 'card cite'; if (!items.length) { box.textContent = modelOnly ? '本回答未引用外部数据源' : '无来源'; } else { box.innerHTML = '
来源(' + items.length + ')
' + items.map((it, i) => { const label = escapeHtml(it.title || it.url || '未命名'); const link = /^https?:\/\//i.test(it.url || '') ? ` 链接` : ''; const score = (it.score !== undefined) ? ` score=${it.score}` : ''; return `${i + 1}. ${label}${link}${score}`; }).join('
'); } ui.bubble.appendChild(box); scrollDown(); } function renderInterrupt(ui, ev) { const ta = ev.tool_approval; if (!ta) { addCardFor(ui, 'approval', '智能体暂停', ev.message || '等待输入'); return; } const card = addCardFor(ui, 'approval', `需确认:${ta.tool}`, `${ta.reason || ''}\n${ta.side_effect || ''}\n\n参数:${JSON.stringify(ta.args, null, 1)}`); const row = document.createElement('div'); row.className = 'actions'; const ok = document.createElement('button'); ok.className = 'ok'; ok.textContent = '批准'; const no = document.createElement('button'); no.className = 'no'; no.textContent = '拒绝'; const decide = async (extra) => { if (streaming || resuming || ui.node.parentNode !== log) return; ok.disabled = no.disabled = true; await resume(extra, ui); // Failed requests keep their original approval controls available for retry. if (pendingApproval && !ui.node.dataset.resolved) ok.disabled = no.disabled = false; }; ok.onclick = () => decide({ approved: true }); no.onclick = () => decide({ approved: false, status: 'cancelled' }); row.appendChild(ok); row.appendChild(no); card.appendChild(row); } async function resume(extra, ui) { // 双重保险:按钮点击后已经被 row.remove() 摘掉,这里再挡一次重复调用。 // 恢复请求重复发出会让图在同一 thread 上被 resume 两次,第二次必然失败。 if (streaming || resuming) return; resuming = true; setStatus('恢复中...'); try { await stream(`${API}/chat/resume`, 'POST', buildResumeBody(extra), true); } finally { resuming = false; } } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function escapeAttr(s) { return escapeHtml(s).replace(/`/g, '`'); } // ---------- 工作台交互 ---------- function updateControls() { const busy = streaming || loadingHistory; el('sendBtn').disabled = busy || pendingApproval || !el('input').value.trim(); el('sendBtn').hidden = streaming; el('stopBtn').disabled = !streaming; el('stopBtn').hidden = !streaming; el('newBtn').disabled = busy; el('mode').disabled = busy || pendingApproval; el('attachBtn').disabled = busy || pendingApproval; el('removeFileBtn').disabled = busy; document.querySelectorAll('.session-button').forEach((button) => { button.disabled = busy; }); } function resizeInput() { const input = el('input'); input.style.height = 'auto'; input.style.height = Math.min(input.scrollHeight, 160) + 'px'; updateControls(); } function clearAttachment() { el('file').value = ''; el('attachment').hidden = true; el('fileName').textContent = ''; } function toggleSidebar(open) { el('sidebar').classList.toggle('open', open); el('sidebarBackdrop').hidden = !open; el('sidebarToggle').setAttribute('aria-expanded', String(open)); el('sidebarToggle').setAttribute('aria-label', open ? '收起会话列表' : '展开会话列表'); } function renderSessions() { const list = el('sessionList'); list.replaceChildren(); if (!sessions.length) { const empty = document.createElement('p'); empty.className = 'list-empty'; empty.textContent = '还没有对话。开启新对话,记录你的每一个想法。'; list.appendChild(empty); } for (const session of sessions) { const button = document.createElement('button'); button.className = 'session-button' + (session.id === sessionId ? ' active' : ''); button.setAttribute('aria-current', session.id === sessionId ? 'page' : 'false'); button.title = session.title || '未命名对话'; button.innerHTML = ''; button.querySelector('span').textContent = (session.pinned ? '⌁ ' : '') + button.title; button.onclick = () => openSession(session); list.appendChild(button); } updateControls(); } async function loadSessions() { el('refreshBtn').disabled = true; try { const response = await fetch(`${API}/sessions?user_name=${encodeURIComponent(USER)}`); if (!response.ok) throw new Error('会话列表加载失败'); const data = await response.json(); sessions = data.items || []; renderSessions(); } catch (error) { if (!sessions.length) { const message = document.createElement('p'); message.className = 'list-empty'; message.textContent = '暂时无法加载对话,请点击刷新重试。'; el('sessionList').replaceChildren(message); } } finally { el('refreshBtn').disabled = false; } } async function openSession(session) { if (streaming || loadingHistory || sessionId === session.id) return; loadingHistory = true; updateControls(); setStatus('正在加载对话', 'busy'); try { const response = await fetch(`${API}/chat/history/${encodeURIComponent(session.id)}?user_name=${encodeURIComponent(USER)}&limit=500`); if (!response.ok) throw new Error('无法加载这段对话,请重试'); const data = await response.json(); sessionId = session.id; pendingApproval = false; el('sessionPill').textContent = '会话:' + session.id.slice(0, 8); el('chatTitle').textContent = session.title || '未命名对话'; el('mode').value = session.mode === 'plan' ? 'plan' : 'full'; el('input').value = ''; clearAttachment(); log.replaceChildren(); el('welcome').hidden = !!data.items.length; for (const message of data.items) { if (message.msg_role === 'user') addUserBubble(message.content); else if (message.msg_role === 'assistant') { const ui = newAssistant(); appendToken(ui, message.content || ''); breakSegment(ui); if (Array.isArray(message.citations) && message.citations.length) renderCitations(ui, message.citations, false); } } renderSessions(); toggleSidebar(false); setStatus('对话已加载', 'ready'); try { const response = await fetch(`${API}/chat/interrupt?session_id=${encodeURIComponent(sessionId)}&user_name=${encodeURIComponent(USER)}`); if (!response.ok) throw new Error('确认状态读取失败'); if (response.status !== 204) { const data = await response.json(); for (const event of data.interrupts || []) handle(event, newAssistant(), []); } } catch (error) { setStatus('对话已加载,确认状态获取失败', 'error'); } } catch (error) { setStatus(error.message, 'error'); } finally { loadingHistory = false; resizeInput(); scrollDown(); } } el('sendBtn').onclick = send; el('stopBtn').onclick = () => { if (controller) controller.abort(); }; el('newBtn').onclick = () => { if (streaming || loadingHistory) return; sessionId = null; pendingApproval = false; el('sessionPill').textContent = '新会话'; el('chatTitle').textContent = '新对话'; el('input').value = ''; clearAttachment(); log.replaceChildren(); el('welcome').hidden = false; setStatus('就绪', 'ready'); renderSessions(); resizeInput(); toggleSidebar(false); el('input').focus(); }; el('input').addEventListener('keydown', (event) => { if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && event.keyCode !== 229) { event.preventDefault(); send(); } }); el('input').addEventListener('input', resizeInput); el('attachBtn').onclick = () => el('file').click(); el('removeFileBtn').onclick = clearAttachment; el('file').addEventListener('change', () => { const file = el('file').files[0]; el('attachment').hidden = !file; el('fileName').textContent = file?.name || ''; }); el('refreshBtn').onclick = loadSessions; el('sidebarToggle').onclick = () => toggleSidebar(!el('sidebar').classList.contains('open')); el('sidebarBackdrop').onclick = () => { toggleSidebar(false); el('sidebarToggle').focus(); }; document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && el('sidebar').classList.contains('open')) { toggleSidebar(false); el('sidebarToggle').focus(); } }); fetch(`${API}/model`).then((response) => { if (!response.ok) throw new Error('服务不可达'); return response.json(); }).then((data) => { el('modelName').textContent = data.model || 'Deep Agent'; if (!streaming && !loadingHistory) setStatus('服务已就绪', 'ready'); }).catch(() => { el('modelName').textContent = '暂未连接模型'; if (!streaming && !loadingHistory) setStatus('服务不可达', 'error'); }); loadSessions();