| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444 |
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
- <title>deep_agent 2.0 · 测试页</title>
- <style>
- :root {
- --bg: #0f1115; --panel: #171a21; --panel-2: #1e222b; --line: #2a2f3a;
- --fg: #e6e9ef; --fg-dim: #98a1b3; --accent: #4f8cff; --ok: #34d399;
- --warn: #fbbf24; --err: #f87171;
- }
- * { box-sizing: border-box; }
- body {
- margin: 0; background: var(--bg); color: var(--fg);
- font: 14px/1.6 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
- display: flex; flex-direction: column; height: 100vh;
- }
- header {
- padding: 10px 16px; border-bottom: 1px solid var(--line);
- display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
- }
- header h1 { font-size: 15px; margin: 0; font-weight: 600; }
- .spacer { flex: 1; }
- .pill {
- font-size: 12px; color: var(--fg-dim); background: var(--panel-2);
- border: 1px solid var(--line); border-radius: 999px; padding: 2px 10px;
- }
- button {
- background: var(--panel-2); color: var(--fg); border: 1px solid var(--line);
- border-radius: 6px; padding: 5px 12px; cursor: pointer; font-size: 13px;
- }
- button:hover { border-color: var(--accent); }
- button:disabled { opacity: .45; cursor: not-allowed; }
- button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
- button.ok { border-color: var(--ok); color: var(--ok); }
- button.no { border-color: var(--err); color: var(--err); }
- #log { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px; }
- .msg { max-width: 860px; width: 100%; margin: 0 auto; }
- .msg.user { display: flex; justify-content: flex-end; }
- .msg.user .bubble {
- background: #24324a; border-radius: 10px 10px 2px 10px; padding: 8px 12px;
- max-width: 75%; white-space: pre-wrap; word-break: break-word;
- }
- .msg.assistant .bubble {
- background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
- padding: 10px 14px; word-break: break-word;
- }
- /* 正文文本段:每段对应「两次工具调用之间」模型说的话 */
- .segment { white-space: pre-wrap; }
- .segment + .segment { margin-top: 8px; }
- .card {
- border: 1px solid var(--line); background: var(--panel); border-radius: 8px;
- padding: 8px 12px; margin: 6px 0; font-size: 13px;
- }
- .card .title { color: var(--fg-dim); font-size: 12px; margin-bottom: 4px; }
- .card pre {
- margin: 4px 0 0; font-size: 12px; color: var(--fg-dim);
- white-space: pre-wrap; word-break: break-all; max-height: 160px; overflow: auto;
- }
- .card.pending { border-color: var(--warn); }
- .card.done { border-color: var(--ok); }
- .card.error { border-color: var(--err); }
- .status { text-align: center; color: var(--fg-dim); font-size: 12px; padding: 4px; }
- .approval { border-color: var(--warn); }
- .approval .actions { display: flex; gap: 8px; margin-top: 8px; }
- .cite { font-size: 12px; color: var(--fg-dim); }
- .cite a { color: var(--accent); }
- footer { border-top: 1px solid var(--line); padding: 10px 16px; }
- .composer { max-width: 860px; margin: 0 auto; display: flex; gap: 8px; align-items: flex-end; }
- textarea {
- flex: 1; background: var(--panel); color: var(--fg); border: 1px solid var(--line);
- border-radius: 8px; padding: 9px 12px; resize: none; font: inherit; min-height: 42px; max-height: 160px;
- }
- textarea:focus { outline: none; border-color: var(--accent); }
- select, input[type=file] {
- background: var(--panel); color: var(--fg-dim); border: 1px solid var(--line);
- border-radius: 6px; padding: 5px; font-size: 12px;
- }
- </style>
- </head>
- <body>
- <header>
- <h1>deep_agent 2.0</h1>
- <span class="pill" id="sessionPill">会话:新</span>
- <span class="pill" id="statusPill">空闲</span>
- <div class="spacer"></div>
- <select id="mode" title="权限模式">
- <option value="full">full(直接执行)</option>
- <option value="plan">plan(先出计划)</option>
- </select>
- <input type="file" id="file" />
- <button id="newBtn">新会话</button>
- </header>
- <div id="log"></div>
- <footer>
- <div class="composer">
- <textarea id="input" rows="1" placeholder="输入消息,Enter 发送 / Shift+Enter 换行。可用 #skill:demo-skill 前缀指定技能"></textarea>
- <button class="primary" id="sendBtn">发送</button>
- <button id="stopBtn" disabled>停止</button>
- </div>
- </footer>
- <script>
- const API = '/api';
- const USER = 'default'; // 2.0 无鉴权:这里自报身份
- let sessionId = null;
- let controller = null;
- let streaming = false;
- let resuming = false;
- const el = (id) => document.getElementById(id);
- const log = el('log');
- function setStatus(text) { el('statusPill').textContent = text; }
- function scrollDown() { log.scrollTop = log.scrollHeight; }
- function addNode(cls, html) {
- const wrap = document.createElement('div');
- wrap.className = 'msg ' + cls;
- wrap.innerHTML = html;
- 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 };
- }
- // 正文按「文本段」切分,与工具卡片按时间顺序交替排列。
- //
- // 为什么不能直接 `bubble.textContent += token`:读 textContent 会连子元素
- // (工具卡片)的文本一起读出来,赋值又会覆盖 bubble 的全部子节点——结果是
- // 卡片被抹平成一坨纯文本,且每次 append 都重新拼接全文(O(N²))。
- function appendToken(ui, text) {
- if (!ui.segment) {
- ui.segment = document.createElement('div');
- ui.segment.className = 'segment';
- ui.bubble.appendChild(ui.segment);
- }
- ui.segment.textContent += text;
- scrollDown();
- }
- // 插入卡片时结束当前文本段,下一批 token 会另起一段,从而保留先后顺序
- function breakSegment(ui) {
- 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) return;
- input.value = '';
- input.style.height = 'auto';
- addUserBubble(text);
- await stream(`${API}/chat`, 'POST', buildForm(text));
- }
- 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;
- el('sendBtn').disabled = true;
- el('stopBtn').disabled = false;
- 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 = '';
- try { detail = JSON.stringify(await res.json()); } catch (e) { detail = await res.text(); }
- addCard(ui.bubble, 'error', `HTTP ${res.status}`, detail);
- setStatus('请求失败');
- return;
- }
- 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,忽略 */ }
- }
- }
- setStatus('空闲');
- } catch (e) {
- if (e.name === 'AbortError') addCard(ui.bubble, '', '已停止', '');
- else addCard(ui.bubble, 'error', '网络错误', String(e));
- setStatus('空闲');
- } finally {
- streaming = false;
- controller = null;
- el('sendBtn').disabled = false;
- el('stopBtn').disabled = true;
- }
- }
- 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':
- setStatus('等待人工确认');
- 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 'error':
- setStatus('出错');
- 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 = 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 = '拒绝';
- ok.onclick = () => { row.remove(); resume({ approved: true }, ui); };
- no.onclick = () => { row.remove(); resume({ approved: false, status: 'cancelled' }, ui); };
- 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, '`'); }
- // ---------- 交互 ----------
- el('sendBtn').onclick = send;
- el('stopBtn').onclick = () => { if (controller) controller.abort(); };
- el('newBtn').onclick = () => {
- if (streaming) return;
- sessionId = null;
- el('sessionPill').textContent = '会话:新';
- log.innerHTML = '';
- setStatus('空闲');
- };
- el('input').addEventListener('keydown', (e) => {
- if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
- });
- el('input').addEventListener('input', (e) => {
- e.target.style.height = 'auto';
- e.target.style.height = Math.min(e.target.scrollHeight, 160) + 'px';
- });
- // 加载时探一下服务是否就绪
- fetch(`${API}/model`).then((r) => r.json())
- .then((d) => setStatus(`就绪 · ${d.model}`))
- .catch(() => setStatus('服务不可达'));
- </script>
- </body>
- </html>
|