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 = '
待办
' +
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 (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 = '';
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();