|
|
@@ -0,0 +1,573 @@
|
|
|
+
|
|
|
+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 = '<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 (!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 = '<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();
|