Bläddra i källkod

fix(sse,web): 工具卡按 tool_call_id 索引 + 测试页正文渲染修复

浏览器实测暴露两个问题:

1. SSE 的 tool_call / tool_result 帧缺 tool_call_id,前端只能按工具名索引
   卡片,同一轮里同名工具被调多次时结果会挂到错误的卡上。
   新增 tool_call_id 为**附加字段**(1.5 无此字段,旧前端忽略未知字段不受
   影响),sse_core 从 AIMessage.tool_calls[].id 与 ToolMessage.tool_call_id
   透传。

2. 测试页用 `bubble.textContent += token` 追加正文:读 textContent 会把子
   元素(工具卡片)的文本一起读出来,赋值又覆盖全部子节点——工具卡片被
   抹成一坨纯文本,且每次 append 重新拼接全文。改为按时间顺序切分的
   「文本段 + 卡片」结构(appendToken / addCardFor / breakSegment)。

3. 测试页 resume() 加并发标志,防止任何形式的重复恢复请求(重复 resume
   同一 thread 第二次必然失败)。

回归:pytest 143 项、http_e2e 49 项全过;浏览器实测渲染顺序与卡片配对正确。
deep_agent 1 månad sedan
förälder
incheckning
bad01c32e0
4 ändrade filer med 81 tillägg och 15 borttagningar
  1. 2 0
      api/sse_core.py
  2. 13 0
      api/sse_events.py
  3. 54 15
      static/index.html
  4. 12 0
      tests/test_sse_contract.py

+ 2 - 0
api/sse_core.py

@@ -384,6 +384,7 @@ class ChatStream:
                     args=args,
                     cn_tool=get_label(name),
                     agent_id=subagent_id,
+                    tool_call_id=str(call.get("id") or ""),
                 )
             )
         return frames
@@ -423,6 +424,7 @@ class ChatStream:
                 cn_tool=get_label(name),
                 agent_id=subagent_id,
                 error=str(getattr(message, "status", "") or "") == "error",
+                tool_call_id=call_id,
             )
         )
         return frames

+ 13 - 0
api/sse_events.py

@@ -182,8 +182,17 @@ class EventWriter:
         args: Any = None,
         cn_tool: str = "",
         agent_id: str = "",
+        tool_call_id: str = "",
     ) -> str:
+        """工具调用卡。
+
+        ``tool_call_id`` 是 **2.0 的附加字段**(1.5 没有):同一轮里同一个工具
+        可能被调多次,只按 ``tool`` 名索引卡片会导致结果挂到错误的卡上。
+        附加字段不破坏兼容——旧前端忽略未知字段即可。
+        """
         fields: dict[str, Any] = {"tool": tool, "cn_tool": cn_tool or tool, "args": args}
+        if tool_call_id:
+            fields["tool_call_id"] = tool_call_id
         if agent_id:
             fields["agent_id"] = agent_id
         return self.frame(TOOL_CALL, **fields)
@@ -195,13 +204,17 @@ class EventWriter:
         cn_tool: str = "",
         agent_id: str = "",
         error: bool = False,
+        tool_call_id: str = "",
     ) -> str:
+        """工具结果卡。``tool_call_id`` 与对应的 tool_call 帧一致。"""
         fields: dict[str, Any] = {
             "tool": tool,
             "cn_tool": cn_tool or tool,
             "content": content,
             "error": error,
         }
+        if tool_call_id:
+            fields["tool_call_id"] = tool_call_id
         if agent_id:
             fields["agent_id"] = agent_id
         return self.frame(TOOL_RESULT, **fields)

+ 54 - 15
static/index.html

@@ -44,8 +44,11 @@
   }
   .msg.assistant .bubble {
     background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
-    padding: 10px 14px; white-space: pre-wrap; word-break: break-word;
+    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;
@@ -106,7 +109,7 @@ const USER = 'default';           // 2.0 无鉴权:这里自报身份
 let sessionId = null;
 let controller = null;
 let streaming = false;
-let pendingApproval = null;
+let resuming = false;
 
 const el = (id) => document.getElementById(id);
 const log = el('log');
@@ -138,7 +141,27 @@ function newAssistant() {
   const b = document.createElement('div');
   b.className = 'bubble';
   node.appendChild(b);
-  return { node, bubble: b, tools: {}, thinking: null };
+  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) {
@@ -158,6 +181,11 @@ function addCard(bubble, cls, title, body) {
   return card;
 }
 
+function addCardFor(ui, cls, title, body) {
+  breakSegment(ui);
+  return addCard(ui.bubble, cls, title, body);
+}
+
 // ---------- 发送 ----------
 async function send() {
   const input = el('input');
@@ -251,6 +279,7 @@ function handle(ev, ui, citations) {
       break;
     case 'thinking_token':
       if (!ui.thinking) {
+        breakSegment(ui);
         ui.thinking = document.createElement('details');
         const s = document.createElement('summary');
         s.textContent = '思考过程';
@@ -268,20 +297,22 @@ function handle(ev, ui, citations) {
       setStatus(ev.message || '执行中...');
       if (ev.tools && ev.tools.length) {
         const i = ev.tools.length - 1;
-        ui.tools[ev.tools[i]] = addCard(ui.bubble, 'pending', `调用 ${ev.cn_tools ? ev.cn_tools[i] : ev.tools[i]}`, '');
+        ui.tools[ev.tools[i]] = addCardFor(ui, 'pending', `调用 ${ev.cn_tools ? ev.cn_tools[i] : ev.tools[i]}`, '');
       }
       break;
     case 'tool_call':
-      ui.tools[ev.tool] = addCard(ui.bubble, 'pending', `工具 ${ev.cn_tool || ev.tool}`, JSON.stringify(ev.args, null, 1));
+      // 按 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 card = ui.tools[ev.tool];
+      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 {
-        addCard(ui.bubble, ev.error ? 'error' : 'done', `结果 ${ev.cn_tool || ev.tool}`, body);
+        addCardFor(ui, ev.error ? 'error' : 'done', `结果 ${ev.cn_tool || ev.tool}`, body);
       }
       break;
     }
@@ -289,8 +320,7 @@ function handle(ev, ui, citations) {
       renderTodos(ui, ev.todos || []);
       break;
     case 'token':
-      ui.bubble.textContent += ev.content || '';
-      scrollDown();
+      appendToken(ui, ev.content || '');
       break;
     case 'citations':
       citations.length = 0;
@@ -306,11 +336,11 @@ function handle(ev, ui, citations) {
       break;
     case 'error':
       setStatus('出错');
-      addCard(ui.bubble, 'error', `错误 [${ev.code || '-'}]`, ev.message || '');
+      addCardFor(ui, 'error', `错误 [${ev.code || '-'}]`, ev.message || '');
       break;
     case 'done':
       if (ev.usage) {
-        addCard(ui.bubble, '', '用量',
+        addCardFor(ui, '', '用量',
           `输入 ${ev.usage.input_tokens} / 输出 ${ev.usage.output_tokens} / 合计 ${ev.usage.total_tokens}(${ev.usage.usage_source})`);
       }
       break;
@@ -318,6 +348,7 @@ function handle(ev, ui, citations) {
 }
 
 function renderTodos(ui, todos) {
+  breakSegment(ui);
   let card = ui.node.querySelector('.todos');
   if (!card) {
     card = document.createElement('div');
@@ -330,6 +361,7 @@ function renderTodos(ui, todos) {
 }
 
 function renderCitations(ui, items, modelOnly) {
+  breakSegment(ui);
   const box = document.createElement('div');
   box.className = 'card cite';
   if (!items.length) {
@@ -350,10 +382,10 @@ function renderCitations(ui, items, modelOnly) {
 function renderInterrupt(ui, ev) {
   const ta = ev.tool_approval;
   if (!ta) {
-    addCard(ui.bubble, 'approval', '智能体暂停', ev.message || '等待输入');
+    addCardFor(ui, 'approval', '智能体暂停', ev.message || '等待输入');
     return;
   }
-  const card = addCard(ui.bubble, 'approval',
+  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');
@@ -367,9 +399,16 @@ function renderInterrupt(ui, ev) {
 }
 
 async function resume(extra, ui) {
-  if (streaming) return;
+  // 双重保险:按钮点击后已经被 row.remove() 摘掉,这里再挡一次重复调用。
+  // 恢复请求重复发出会让图在同一 thread 上被 resume 两次,第二次必然失败。
+  if (streaming || resuming) return;
+  resuming = true;
   setStatus('恢复中...');
-  await stream(`${API}/chat/resume`, 'POST', buildResumeBody(extra), true);
+  try {
+    await stream(`${API}/chat/resume`, 'POST', buildResumeBody(extra), true);
+  } finally {
+    resuming = false;
+  }
 }
 
 function escapeHtml(s) {

+ 12 - 0
tests/test_sse_contract.py

@@ -116,6 +116,18 @@ class TestEventContract:
         assert p["args"] == {"expression": "1+1"}
         assert p["agent_id"] == "sub-1"
 
+    def test_tool_call_id_is_additive_2_0_field(self):
+        """tool_call_id 是 2.0 新增字段(1.5 没有):同一工具多次调用时按它
+        索引才不会把结果挂错卡。附加字段不破坏兼容。"""
+        call = parse(writer().tool_call("calculate", {}, "计算", tool_call_id="call-abc"))
+        result = parse(writer().tool_result("calculate", 2, "计算", tool_call_id="call-abc"))
+        assert call["tool_call_id"] == "call-abc"
+        assert result["tool_call_id"] == call["tool_call_id"]
+
+    def test_tool_call_id_omitted_when_absent(self):
+        """没有 id 时不发该字段,而不是发空串。"""
+        assert "tool_call_id" not in parse(writer().tool_call("t", {}))
+
     def test_tool_result_fields(self):
         p = parse(writer().tool_result("calculate", {"result": 2}, "安全算术计算", error=False))
         assert p["type"] == "tool_result"