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 = '