| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606 |
- 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);
- fd.append('request_id', crypto.randomUUID());
- 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');
- return true;
- } 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 || ev.ask_user);
- 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 = '<div class="title">待办</div>' +
- todos.map((t) => `${mark[t.status] || '[ ]'} ${escapeHtml(t.content || '')}`).join('<br>');
- }
- 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 = '<div class="title">来源(' + items.length + ')</div>' +
- items.map((it, i) => {
- const label = escapeHtml(it.title || it.url || '未命名');
- const link = /^https?:\/\//i.test(it.url || '') ? ` <a href="${escapeAttr(it.url)}" target="_blank" rel="noreferrer">链接</a>` : '';
- const score = (it.score !== undefined) ? ` <span style="color:var(--fg-dim)">score=${it.score}</span>` : '';
- return `${i + 1}. ${label}${link}${score}`;
- }).join('<br>');
- }
- ui.bubble.appendChild(box);
- scrollDown();
- }
- function renderInterrupt(ui, ev) {
- const ta = ev.tool_approval;
- if (ev.ask_user) {
- const card = addCardFor(ui, 'approval', '需要补充信息', '');
- const inputs = ev.ask_user.questions.map((question) => {
- const label = document.createElement('label'); label.textContent = question;
- const input = document.createElement('textarea'); input.rows = 2;
- label.appendChild(input); card.appendChild(label); return input;
- });
- const submit = document.createElement('button'); submit.textContent = '提交回答';
- submit.onclick = async () => {
- if (streaming || resuming || inputs.some((input) => !input.value.trim())) return;
- submit.disabled = true;
- try { await resume({ answers: inputs.map((input) => input.value.trim()), interrupt_token: ev.interrupt_token }, ui); }
- finally { if (!ui.node.dataset.resolved) submit.disabled = false; }
- };
- const cancel = document.createElement('button'); cancel.textContent = '取消';
- cancel.onclick = async () => {
- if (streaming || resuming || ui.node.dataset.resolved) return;
- cancel.disabled = true;
- try { await resume({ status: 'cancelled', interrupt_token: ev.interrupt_token }, ui); }
- finally { if (!ui.node.dataset.resolved) cancel.disabled = false; }
- };
- const row = document.createElement('div'); row.className = 'actions';
- row.append(submit, cancel); card.appendChild(row);
- return;
- }
- if (!ta) {
- addCardFor(ui, 'approval', '智能体暂停', ev.message || '等待输入');
- return;
- }
- const actions = ta.actions || [{ name: ta.tool, args: ta.args, description: ta.reason }];
- const card = addCardFor(ui, 'approval',
- actions.length > 1 ? `需确认 ${actions.length} 项操作` : `需确认:${ta.tool}`,
- actions.map((action, index) => `${index + 1}. ${action.name}\n${action.description || ''}\n参数:${JSON.stringify(action.args, null, 1)}`).join('\n\n'));
- const row = document.createElement('div');
- row.className = 'actions';
- const ok = document.createElement('button'); ok.className = 'ok'; ok.textContent = '批准';
- if (actions.length > 1) ok.textContent = `批准全部 ${actions.length} 项`;
- 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, interrupt_token: ta.interrupt_token }, 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 {
- const accepted = await stream(`${API}/chat/resume`, 'POST', buildResumeBody(extra), true);
- if (accepted) {
- ui.node.dataset.resolved = 'true';
- ui.node.querySelectorAll('.approval button, .approval textarea').forEach((control) => { control.disabled = 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 = '<svg aria-hidden="true"><use href="#i-chat"/></svg><span></span>';
- 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();
|