index.html 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. <!DOCTYPE html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8" />
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  6. <title>deep_agent 2.0 · 测试页</title>
  7. <style>
  8. :root {
  9. --bg: #0f1115; --panel: #171a21; --panel-2: #1e222b; --line: #2a2f3a;
  10. --fg: #e6e9ef; --fg-dim: #98a1b3; --accent: #4f8cff; --ok: #34d399;
  11. --warn: #fbbf24; --err: #f87171;
  12. }
  13. * { box-sizing: border-box; }
  14. body {
  15. margin: 0; background: var(--bg); color: var(--fg);
  16. font: 14px/1.6 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
  17. display: flex; flex-direction: column; height: 100vh;
  18. }
  19. header {
  20. padding: 10px 16px; border-bottom: 1px solid var(--line);
  21. display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  22. }
  23. header h1 { font-size: 15px; margin: 0; font-weight: 600; }
  24. .spacer { flex: 1; }
  25. .pill {
  26. font-size: 12px; color: var(--fg-dim); background: var(--panel-2);
  27. border: 1px solid var(--line); border-radius: 999px; padding: 2px 10px;
  28. }
  29. button {
  30. background: var(--panel-2); color: var(--fg); border: 1px solid var(--line);
  31. border-radius: 6px; padding: 5px 12px; cursor: pointer; font-size: 13px;
  32. }
  33. button:hover { border-color: var(--accent); }
  34. button:disabled { opacity: .45; cursor: not-allowed; }
  35. button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
  36. button.ok { border-color: var(--ok); color: var(--ok); }
  37. button.no { border-color: var(--err); color: var(--err); }
  38. #log { flex: 1; overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 10px; }
  39. .msg { max-width: 860px; width: 100%; margin: 0 auto; }
  40. .msg.user { display: flex; justify-content: flex-end; }
  41. .msg.user .bubble {
  42. background: #24324a; border-radius: 10px 10px 2px 10px; padding: 8px 12px;
  43. max-width: 75%; white-space: pre-wrap; word-break: break-word;
  44. }
  45. .msg.assistant .bubble {
  46. background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
  47. padding: 10px 14px; word-break: break-word;
  48. }
  49. /* 正文文本段:每段对应「两次工具调用之间」模型说的话 */
  50. .segment { white-space: pre-wrap; }
  51. .segment + .segment { margin-top: 8px; }
  52. .card {
  53. border: 1px solid var(--line); background: var(--panel); border-radius: 8px;
  54. padding: 8px 12px; margin: 6px 0; font-size: 13px;
  55. }
  56. .card .title { color: var(--fg-dim); font-size: 12px; margin-bottom: 4px; }
  57. .card pre {
  58. margin: 4px 0 0; font-size: 12px; color: var(--fg-dim);
  59. white-space: pre-wrap; word-break: break-all; max-height: 160px; overflow: auto;
  60. }
  61. .card.pending { border-color: var(--warn); }
  62. .card.done { border-color: var(--ok); }
  63. .card.error { border-color: var(--err); }
  64. .status { text-align: center; color: var(--fg-dim); font-size: 12px; padding: 4px; }
  65. .approval { border-color: var(--warn); }
  66. .approval .actions { display: flex; gap: 8px; margin-top: 8px; }
  67. .cite { font-size: 12px; color: var(--fg-dim); }
  68. .cite a { color: var(--accent); }
  69. footer { border-top: 1px solid var(--line); padding: 10px 16px; }
  70. .composer { max-width: 860px; margin: 0 auto; display: flex; gap: 8px; align-items: flex-end; }
  71. textarea {
  72. flex: 1; background: var(--panel); color: var(--fg); border: 1px solid var(--line);
  73. border-radius: 8px; padding: 9px 12px; resize: none; font: inherit; min-height: 42px; max-height: 160px;
  74. }
  75. textarea:focus { outline: none; border-color: var(--accent); }
  76. select, input[type=file] {
  77. background: var(--panel); color: var(--fg-dim); border: 1px solid var(--line);
  78. border-radius: 6px; padding: 5px; font-size: 12px;
  79. }
  80. </style>
  81. </head>
  82. <body>
  83. <header>
  84. <h1>deep_agent 2.0</h1>
  85. <span class="pill" id="sessionPill">会话:新</span>
  86. <span class="pill" id="statusPill">空闲</span>
  87. <div class="spacer"></div>
  88. <select id="mode" title="权限模式">
  89. <option value="full">full(直接执行)</option>
  90. <option value="plan">plan(先出计划)</option>
  91. </select>
  92. <input type="file" id="file" />
  93. <button id="newBtn">新会话</button>
  94. </header>
  95. <div id="log"></div>
  96. <footer>
  97. <div class="composer">
  98. <textarea id="input" rows="1" placeholder="输入消息,Enter 发送 / Shift+Enter 换行。可用 #skill:demo-skill 前缀指定技能"></textarea>
  99. <button class="primary" id="sendBtn">发送</button>
  100. <button id="stopBtn" disabled>停止</button>
  101. </div>
  102. </footer>
  103. <script>
  104. const API = '/api';
  105. const USER = 'default'; // 2.0 无鉴权:这里自报身份
  106. let sessionId = null;
  107. let controller = null;
  108. let streaming = false;
  109. let resuming = false;
  110. const el = (id) => document.getElementById(id);
  111. const log = el('log');
  112. function setStatus(text) { el('statusPill').textContent = text; }
  113. function scrollDown() { log.scrollTop = log.scrollHeight; }
  114. function addNode(cls, html) {
  115. const wrap = document.createElement('div');
  116. wrap.className = 'msg ' + cls;
  117. wrap.innerHTML = html;
  118. log.appendChild(wrap);
  119. scrollDown();
  120. return wrap;
  121. }
  122. function addUserBubble(text) {
  123. const node = addNode('user', '');
  124. const b = document.createElement('div');
  125. b.className = 'bubble';
  126. b.textContent = text;
  127. node.appendChild(b);
  128. return node;
  129. }
  130. function newAssistant() {
  131. const node = addNode('assistant', '');
  132. const b = document.createElement('div');
  133. b.className = 'bubble';
  134. node.appendChild(b);
  135. return { node, bubble: b, tools: {}, thinking: null, segment: null };
  136. }
  137. // 正文按「文本段」切分,与工具卡片按时间顺序交替排列。
  138. //
  139. // 为什么不能直接 `bubble.textContent += token`:读 textContent 会连子元素
  140. // (工具卡片)的文本一起读出来,赋值又会覆盖 bubble 的全部子节点——结果是
  141. // 卡片被抹平成一坨纯文本,且每次 append 都重新拼接全文(O(N²))。
  142. function appendToken(ui, text) {
  143. if (!ui.segment) {
  144. ui.segment = document.createElement('div');
  145. ui.segment.className = 'segment';
  146. ui.bubble.appendChild(ui.segment);
  147. }
  148. ui.segment.textContent += text;
  149. scrollDown();
  150. }
  151. // 插入卡片时结束当前文本段,下一批 token 会另起一段,从而保留先后顺序
  152. function breakSegment(ui) {
  153. ui.segment = null;
  154. }
  155. function addCard(bubble, cls, title, body) {
  156. const card = document.createElement('div');
  157. card.className = 'card ' + cls;
  158. const t = document.createElement('div');
  159. t.className = 'title';
  160. t.textContent = title;
  161. card.appendChild(t);
  162. if (body) {
  163. const pre = document.createElement('pre');
  164. pre.textContent = body;
  165. card.appendChild(pre);
  166. }
  167. bubble.appendChild(card);
  168. scrollDown();
  169. return card;
  170. }
  171. function addCardFor(ui, cls, title, body) {
  172. breakSegment(ui);
  173. return addCard(ui.bubble, cls, title, body);
  174. }
  175. // ---------- 发送 ----------
  176. async function send() {
  177. const input = el('input');
  178. const text = input.value.trim();
  179. if (!text || streaming) return;
  180. input.value = '';
  181. input.style.height = 'auto';
  182. addUserBubble(text);
  183. await stream(`${API}/chat`, 'POST', buildForm(text));
  184. }
  185. function buildForm(text) {
  186. const fd = new FormData();
  187. fd.append('message', text);
  188. if (sessionId) fd.append('session_id', sessionId);
  189. fd.append('mode', el('mode').value);
  190. fd.append('user_name', USER);
  191. const f = el('file').files[0];
  192. if (f) fd.append('file', f);
  193. return fd;
  194. }
  195. function buildResumeBody(extra) {
  196. return JSON.stringify(Object.assign({
  197. session_id: sessionId, thread_id: sessionId, user_name: USER,
  198. }, extra));
  199. }
  200. // ---------- 流式 ----------
  201. async function stream(url, method, body, isJson) {
  202. const ui = newAssistant();
  203. controller = new AbortController();
  204. streaming = true;
  205. el('sendBtn').disabled = true;
  206. el('stopBtn').disabled = false;
  207. setStatus('连接中...');
  208. let citations = [];
  209. try {
  210. const headers = {};
  211. if (isJson) headers['Content-Type'] = 'application/json';
  212. const res = await fetch(url, { method, body, headers, signal: controller.signal });
  213. if (!res.ok) {
  214. let detail = '';
  215. try { detail = JSON.stringify(await res.json()); } catch (e) { detail = await res.text(); }
  216. addCard(ui.bubble, 'error', `HTTP ${res.status}`, detail);
  217. setStatus('请求失败');
  218. return;
  219. }
  220. const sid = res.headers.get('X-Session-Id');
  221. if (sid) { sessionId = sid; el('sessionPill').textContent = '会话:' + sid.slice(0, 8); }
  222. const reader = res.body.getReader();
  223. const decoder = new TextDecoder();
  224. let buffer = '';
  225. while (true) {
  226. const { done, value } = await reader.read();
  227. if (done) break;
  228. buffer += decoder.decode(value, { stream: true });
  229. const lines = buffer.split('\n');
  230. buffer = lines.pop() || '';
  231. for (const line of lines) {
  232. if (!line.startsWith('data: ')) continue;
  233. try { handle(JSON.parse(line.slice(6)), ui, citations); }
  234. catch (e) { /* 半截 JSON,忽略 */ }
  235. }
  236. }
  237. setStatus('空闲');
  238. } catch (e) {
  239. if (e.name === 'AbortError') addCard(ui.bubble, '', '已停止', '');
  240. else addCard(ui.bubble, 'error', '网络错误', String(e));
  241. setStatus('空闲');
  242. } finally {
  243. streaming = false;
  244. controller = null;
  245. el('sendBtn').disabled = false;
  246. el('stopBtn').disabled = true;
  247. }
  248. }
  249. function handle(ev, ui, citations) {
  250. switch (ev.type) {
  251. case 'agent_start':
  252. setStatus(`${ev.cn_agent || ev.agent} 已启动`);
  253. break;
  254. case 'thinking':
  255. setStatus(ev.cn_agent ? `${ev.cn_agent}:${ev.message}` : ev.message);
  256. break;
  257. case 'thinking_token':
  258. if (!ui.thinking) {
  259. breakSegment(ui);
  260. ui.thinking = document.createElement('details');
  261. const s = document.createElement('summary');
  262. s.textContent = '思考过程';
  263. s.style.color = 'var(--fg-dim)';
  264. ui.thinking.appendChild(s);
  265. const p = document.createElement('pre');
  266. p.style.fontSize = '12px';
  267. p.style.color = 'var(--fg-dim)';
  268. ui.thinking.appendChild(p);
  269. ui.bubble.appendChild(ui.thinking);
  270. }
  271. ui.thinking.querySelector('pre').textContent += ev.content || '';
  272. break;
  273. case 'executing':
  274. setStatus(ev.message || '执行中...');
  275. if (ev.tools && ev.tools.length) {
  276. const i = ev.tools.length - 1;
  277. ui.tools[ev.tools[i]] = addCardFor(ui, 'pending', `调用 ${ev.cn_tools ? ev.cn_tools[i] : ev.tools[i]}`, '');
  278. }
  279. break;
  280. case 'tool_call':
  281. // 按 tool_call_id 索引:同一工具被调多次时按名字索引会把结果挂错卡
  282. ui.tools[ev.tool_call_id || ev.tool] = addCardFor(ui, 'pending', `工具 ${ev.cn_tool || ev.tool}`, JSON.stringify(ev.args, null, 1));
  283. break;
  284. case 'tool_result': {
  285. const key = ev.tool_call_id || ev.tool;
  286. const card = ui.tools[key] || ui.tools[ev.tool];
  287. const body = typeof ev.content === 'string' ? ev.content : JSON.stringify(ev.content, null, 1);
  288. if (card) {
  289. card.className = 'card ' + (ev.error ? 'error' : 'done');
  290. const pre = document.createElement('pre'); pre.textContent = body; card.appendChild(pre);
  291. } else {
  292. addCardFor(ui, ev.error ? 'error' : 'done', `结果 ${ev.cn_tool || ev.tool}`, body);
  293. }
  294. break;
  295. }
  296. case 'updated_todo_list':
  297. renderTodos(ui, ev.todos || []);
  298. break;
  299. case 'token':
  300. appendToken(ui, ev.content || '');
  301. break;
  302. case 'citations':
  303. citations.length = 0;
  304. (ev.items || []).forEach((it) => citations.push(it));
  305. renderCitations(ui, citations, ev.model_only);
  306. break;
  307. case 'interrupt':
  308. setStatus('等待人工确认');
  309. renderInterrupt(ui, ev);
  310. break;
  311. case 'agent_done':
  312. setStatus(`完成 ${ev.duration_ms ? ev.duration_ms + 'ms' : ''} 上下文 ${ev.context_tokens || 0}/${ev.context_window || 0}`);
  313. break;
  314. case 'error':
  315. setStatus('出错');
  316. addCardFor(ui, 'error', `错误 [${ev.code || '-'}]`, ev.message || '');
  317. break;
  318. case 'done':
  319. if (ev.usage) {
  320. addCardFor(ui, '', '用量',
  321. `输入 ${ev.usage.input_tokens} / 输出 ${ev.usage.output_tokens} / 合计 ${ev.usage.total_tokens}(${ev.usage.usage_source})`);
  322. }
  323. break;
  324. }
  325. }
  326. function renderTodos(ui, todos) {
  327. breakSegment(ui);
  328. let card = ui.node.querySelector('.todos');
  329. if (!card) {
  330. card = document.createElement('div');
  331. card.className = 'card todos';
  332. ui.bubble.appendChild(card);
  333. }
  334. const mark = { completed: '[x]', in_progress: '[~]', pending: '[ ]' };
  335. card.innerHTML = '<div class="title">待办</div>' +
  336. todos.map((t) => `${mark[t.status] || '[ ]'} ${escapeHtml(t.content || '')}`).join('<br>');
  337. }
  338. function renderCitations(ui, items, modelOnly) {
  339. breakSegment(ui);
  340. const box = document.createElement('div');
  341. box.className = 'card cite';
  342. if (!items.length) {
  343. box.textContent = modelOnly ? '本回答未引用外部数据源' : '无来源';
  344. } else {
  345. box.innerHTML = '<div class="title">来源(' + items.length + ')</div>' +
  346. items.map((it, i) => {
  347. const label = escapeHtml(it.title || it.url || '未命名');
  348. const link = it.url ? ` <a href="${escapeAttr(it.url)}" target="_blank" rel="noreferrer">链接</a>` : '';
  349. const score = (it.score !== undefined) ? ` <span style="color:var(--fg-dim)">score=${it.score}</span>` : '';
  350. return `${i + 1}. ${label}${link}${score}`;
  351. }).join('<br>');
  352. }
  353. ui.bubble.appendChild(box);
  354. scrollDown();
  355. }
  356. function renderInterrupt(ui, ev) {
  357. const ta = ev.tool_approval;
  358. if (!ta) {
  359. addCardFor(ui, 'approval', '智能体暂停', ev.message || '等待输入');
  360. return;
  361. }
  362. const card = addCardFor(ui, 'approval',
  363. `需确认:${ta.tool}`,
  364. `${ta.reason || ''}\n${ta.side_effect || ''}\n\n参数:${JSON.stringify(ta.args, null, 1)}`);
  365. const row = document.createElement('div');
  366. row.className = 'actions';
  367. const ok = document.createElement('button'); ok.className = 'ok'; ok.textContent = '批准';
  368. const no = document.createElement('button'); no.className = 'no'; no.textContent = '拒绝';
  369. ok.onclick = () => { row.remove(); resume({ approved: true }, ui); };
  370. no.onclick = () => { row.remove(); resume({ approved: false, status: 'cancelled' }, ui); };
  371. row.appendChild(ok); row.appendChild(no);
  372. card.appendChild(row);
  373. }
  374. async function resume(extra, ui) {
  375. // 双重保险:按钮点击后已经被 row.remove() 摘掉,这里再挡一次重复调用。
  376. // 恢复请求重复发出会让图在同一 thread 上被 resume 两次,第二次必然失败。
  377. if (streaming || resuming) return;
  378. resuming = true;
  379. setStatus('恢复中...');
  380. try {
  381. await stream(`${API}/chat/resume`, 'POST', buildResumeBody(extra), true);
  382. } finally {
  383. resuming = false;
  384. }
  385. }
  386. function escapeHtml(s) {
  387. return String(s).replace(/[&<>"']/g, (c) =>
  388. ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
  389. }
  390. function escapeAttr(s) { return escapeHtml(s).replace(/`/g, '&#96;'); }
  391. // ---------- 交互 ----------
  392. el('sendBtn').onclick = send;
  393. el('stopBtn').onclick = () => { if (controller) controller.abort(); };
  394. el('newBtn').onclick = () => {
  395. if (streaming) return;
  396. sessionId = null;
  397. el('sessionPill').textContent = '会话:新';
  398. log.innerHTML = '';
  399. setStatus('空闲');
  400. };
  401. el('input').addEventListener('keydown', (e) => {
  402. if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
  403. });
  404. el('input').addEventListener('input', (e) => {
  405. e.target.style.height = 'auto';
  406. e.target.style.height = Math.min(e.target.scrollHeight, 160) + 'px';
  407. });
  408. // 加载时探一下服务是否就绪
  409. fetch(`${API}/model`).then((r) => r.json())
  410. .then((d) => setStatus(`就绪 · ${d.model}`))
  411. .catch(() => setStatus('服务不可达'));
  412. </script>
  413. </body>
  414. </html>