tool-call-detail-plan.md 14 KB

聊天界面「工具调用记录」不展示入参与返回结果 —— 修复计划

状态:待确认(确认后再动代码) 日期:2026-09-17 范围:ai-frontend/src/ai/**、ai-server/.../agent/service/impl/AgentChatServiceImpl.java


一、问题现象

聊天界面展示工具调用记录时,看不到工具调用的内容:

  1. 看不到入参;
  2. 看不到返回结果;
  3. 返回结果是 JSON 时应渲染成表格。

第五轮其实已经实现过 ToolCallPanel.vue(入参 + 输出智能结构化 + 表格渲染), 问题出在真机行为:代码在、数据在,但被两个坑同时挡住。


二、实测证据(真机,不是推断)

2.1 数据侧是完整的,后端没丢数据

GET /js/a/chat/sessions/1494404202000630103/messages
  → 单条 assistant 消息 toolEvents 含 13 次工具调用
  → 每条都有 input / result / status=done(result 最长 297,801 字符)

2.2 ★ 核心:result 比 input 多编码了一层

字段 实际存的值(截断) JSON.parse 结果
input {"sql": "SELECT table_name, ..."} object ✅
result "{\"resultId\":\"36a42785-...\",\"sql\":\"SELECT ...\"}" string ❌

实测两次解析:

INPUT  typeof=string  starts={"sql": "SELECT ...
RESULT typeof=string  starts="{\"resultId\":\"36a42785-...
parse1 type= string
parse2 type= object  keys=[resultId, sql, columns, rows, page, pageSize, totalRows, totalPages]

后果链:

step.result(双层编码的 JSON 字符串字面量)
  → ToolCallPanel 里 JSON.parse(step.result) 得到 string
  → buildTable(string) 判定失败,返回 null
  → 表格分支 (v-if="table && !showRaw") 永不成立
  → 永远落回 <pre> 原始 JSON(还是带转义的样子)

即:JSON → 表格这条路从来没走通过。

2.3 ★ 详情按钮默认完全不可见

playwright-core + headless chrome 实测(dev 3100,案件 2 / 会话「端到端验证-已重命名」,全程只读):

trace blocks      = 5
trace heads       = ["已完成 13 步工具调用", "已完成 1 步工具调用", ...]
detail-btn count  = 39
detail button     = exists opacity=0 display=flex size=18x18 visible=false
after hover       = opacity=1 size=18x18
ai-tool-detail after click = 1
detail labels     = ["入参", "输出"]
detail pre len    = 89, 3380     ← 输出渲染成 pre(3380 字符),不是表格
detail tables     = 0            ← ★ 表格数量为 0

按钮的显隐规则写在 .ai-trace__row:hover .ai-trace__detail-btn { opacity: 1 }, 不悬停就是 opacity: 0。用户看到的是「只有工具名 + 一句参数摘要」, 既没有入参也没有输出,主观感受就是"没有展示工具调用的内容"。

2.4 附带地雷:一次 NPE 风险

AgentChatServiceImpl:

StringBuilder result = toolResults.get(e.getToolCallId());
if (!result.isEmpty()) {          // ← 该工具一次 delta 都没来 → get() 返回 null → NPE

NPE 发生在响应式流内部,会直接打断整条 SSE。


三、根因

# 根因 位置 影响
1 工具结果文本在累积时被多包了一层 JSON 字符串字面量,SSE 帧与 tool_events 落库都用这个脏值 AgentChatServiceImpl 的 ToolResultTextDeltaEvent 分支(累加点) 表格渲染失效(实时 + 历史都失效)
2 前端只做一次 JSON.parse,遇到双层编码直接降级成字符串 ToolCallPanel.parseResult / parseToolEvents / onToolResult 同上
3 详情入口靠 hover 才显形(opacity: 0),等于没有入口 ProcessTimeline.vue 的 __detail-btn 样式与模板 用户以为"没有这个功能"
4 无 result delta 时 get() 返回 null 触发 NPE AgentChatServiceImpl:409 打断 SSE 流

旁证:input 走的是 ToolCallDeltaEvent.getDelta()(模型产出的原始 JSON 参数), result 走的是 ToolResultTextDeltaEvent.getDelta()(TextBlock.getText())—— 两者编码层数不一致,说明多出来的一层来自 result 这条链路,不是落库层的统一问题。


四、修复方案

4.1 后端:结果归一化(修源头,新数据不再脏)

文件:ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentChatServiceImpl.java

  1. 新增归一化工具方法(幂等,失败原样返回):

    /**
    * 归一化工具结果文本:AgentScope 的 ToolResultTextDeltaEvent 有时给出的是
    * 「JSON 字符串字面量」(整体被引号+转义包了一层),这里解一层。
    * 不是该形态(或解不出来)时原样返回,因此可重复调用。
    */
    private static String unwrapJsonString(String raw) {
    if (raw == null) return null;
    String s = raw.trim();
    if (s.length() < 2 || s.charAt(0) != '"' || s.charAt(s.length() - 1) != '"') return raw;
    try {
        return MAPPER.readValue(s, String.class);
    } catch (Exception ignore) {
        return raw;
    }
    }
    
  2. ToolResultTextDeltaEvent 分支累加时套用:

    } else if (event instanceof ToolResultTextDeltaEvent e) {
    toolResults.computeIfAbsent(e.getToolCallId(), k -> new StringBuilder())
               .append(unwrapJsonString(e.getDelta()));
    
  3. ToolResultEndEvent 分支:修 NPE + 下发前再兜一层

    StringBuilder sb = toolResults.get(e.getToolCallId());
    String text = sb == null ? "" : unwrapJsonString(sb.toString());
    if (!text.isEmpty()) {
    data.put("toolResult", text.length() > 100000 ? text.substring(0, 100000) : text);
    }
    

SSE 帧与 buildToolEventsJson 落库共用同一个累积容器,改在累加点两个出口同时干净。

4.2 前端:容错解析(兼容已经写脏的历史数据)

新增:ai-frontend/src/ai/utils/parseMessageContent.ts 里导出统一函数

/**
 * 解出 JSON 载荷:最多多解一层「字符串包装」。
 * 老数据里 result 被双重编码过,这里兼容;不是该形态时原样返回。
 */
export function decodeJsonPayload(raw?: string | null): any { /* 最多 parse 两次 */ }

接入点(三处,保证实时与历史行为一致):

文件 位置 处理
utils/parseMessageContent.ts parseToolEvents(历史消息) input / result 存归一化后的文本(统一成单层 JSON 文本)
store/chatStream.ts onToolResult / onToolInput(实时流) 同上,落进 ToolStep 前归一化
components/ToolCallPanel.vue 解析入口 computed table 用 decodeJsonPayload() 兜底,双保险

4.3 前端:输出展示强化(ToolCallPanel.vue)

  • execute_sql 载荷:按 columns 渲染分页表格(>20 行本地分页,scroll: {x:'max-content', y:360}),保留「原始 JSON」切换
  • render_chart / render_graph 的 option JSON:等宽代码块,不做表格
  • 结果被后端截断(>100,000 字符)时,标注「结果过大已截断」
  • 单元格内的对象/数组再 JSON.stringify 一次(避免 [object Object])——已有,保留
  • a-table 的 row-key 从 index 改为稳定字段(消除 antd 的 deprecated 警告)

4.4 前端:入参展示(ToolCallPanel.vue)

  • execute_sql:把 sql 单独抽成 SQL 代码块(默认折叠 4 行,可展开全文),其余参数留在 JSON 视图
  • 其它工具:保持格式化 JSON

4.5 前端:交互可见性("看不到"的直接原因)

文件:components/ProcessTimeline.vue

  • 工具行整行可点展开/收起详情,不再依赖 hover 才出现的 18×18 小箭头
  • 展开态箭头常驻显示(收起态可弱化,但保证桌面端可见)
  • 行内右侧增加提示文案:入参 · 输出 N 行
  • 展开工具行 = 入参 + 输出同时显示,取消"还要再点一次"的第二层交互
  • 时间线展开时,首个 execute_sql(或最近一次工具调用)默认展开详情

4.6 前端:思考内容默认展开(2026-09-17 追加需求)

需求原文:「思考内容输出时默认展开,不要叠起来。」

现状(ProcessTimeline.vue 现有逻辑,是"叠起来"的来源):

// 只有「正在输出的最后一段」自动展开;一旦思考结束就自动收起
watch(() => props.thinkingActive, (val) => {
  if (thinkTouched.value) return;
  const key = lastThinkKey.value;
  if (val) next.add(key); else next.delete(key);   // ← 结束即收起
});
// 整条过程时间线:流式结束 → 自动收起成一行汇总
watch(() => props.active, (val) => {
  if (val) { if (!userToggled.value) expanded.value = true; }
  else if (!userToggled.value) expanded.value = false;   // ← 结束即收起
});

两处叠加的结果:思考内容一闪而过,输出完就"叠"回一行,用户看不到。

改法:

  1. openThinks 语义反转:由「已展开集合」改为「被用户手动收起的集合」collapsedThinks。 这样新出现的思考段天然就是展开态,不需要任何 watch 去补。
  2. 删掉 thinkingActive 结束时的自动收起分支:思考输出结束不再收起。
  3. 时间线整体不再自动收起(active 由 true → false 时保留展开态),否则思考刚展开又被整条折起来。
  4. 多段思考不挤压:每段默认展开、按 \n\n 分段渲染,段首给「深度思考 1 / 2 / 3」序号 + 轻分隔线, 不要把多段拼成一坨。
  5. 保留手动收起能力:思考行标题可点击收起/展开(收起态显示「深度思考 · N 字」), 时间线头部仍可整体收起;用户手动操作过的状态优先,不被后续流式事件覆盖(沿用现有 userToggled / 收起集合标记)。
  6. 历史会话与实时一致:从库里重新拉取的会话,思考同样默认展开(不再只展开"最后一段")。

需你确认的一点(D2):流式结束后如果保留整条时间线展开,历史会话打开时也会直接铺满思考 + 工具流水, 长回答页面会变长。三个可选口径:

  • A(本计划默认):思考默认展开、时间线默认展开,全部保留,只有用户手动收起才收起 —— 最贴合"不要叠起来";
  • B:实时流保留展开,历史会话打开时仍折叠成一行汇总(需要点击才展开);
  • C:只展开思考区,工具流水行仍保持紧凑。

五、验证方案

5.1 静态

项 命令 通过标准
后端 mvn -pl ai-server -B clean compile BUILD SUCCESS
前端 ESLint 改动文件 --max-warnings 0 0 错误
前端类型 pnpm -C ai-frontend type:check src/ai 0 错误(全仓约 103 条历史错误为既有基线,不新增)
构建 pnpm -C ai-frontend build 通过

5.2 真机 UI 断言(复用现成脚本)

脚本:C:/Users/cc/AppData/Local/Temp/ai-verify/ui-tool-detail.cjs (playwright-core + headless chrome,复用案件 2 / 会话「端到端验证-已重命名」,全程只读、不烧 token)

断言项:

  1. 展开时间线后,工具行无需 hover 即可点击并展开详情
  2. .ai-tool-detail .ant-table 数量 ≥ 1(当前是 0)
  3. 表格数据行数 == 载荷 rows.length
  4. 入参区出现 SQL 代码块
  5. 「原始 JSON」/「表格」切换可用
  6. 历史脏数据(已双重编码的老会话)同样能渲染出表
  7. 思考内容默认展开:时间线展开后,.ai-trace__think 可见且数量 == 思考段数(当前是 0)
  8. 多段思考未挤压:思考段数 == \n\n 分段数,序号 1..N 连续
  9. 0 条控制台报错、0 个 ≥400 响应

5.3 环境注意

  • 后端是 IDEA 以 Debug 模式常驻 的进程(监听 8980),改完后端需用户重启才能验到新数据
  • 前端 dev server 在 3100,有 HMR,可直接验
  • 已有历史数据(双重编码)无法回填修复,靠前端容错兼容

六、改动清单

# 文件 改动
1 ai-server/.../AgentChatServiceImpl.java unwrapJsonString() + 累加点归一化 + 修 NPE
2 ai-frontend/src/ai/utils/parseMessageContent.ts 新增 decodeJsonPayload(),parseToolEvents 接入
3 ai-frontend/src/ai/store/chatStream.ts onToolInput / onToolResult 接入归一化
4 ai-frontend/src/ai/components/ToolCallPanel.vue 解析兜底 + 输出表格强化 + 入参 SQL 块 + rowKey
5 ai-frontend/src/ai/components/ProcessTimeline.vue 整行可点、箭头常驻、行内提示、默认展开首条 + 思考默认展开(§4.6)

顺序:1 → 2/3 → 4 → 5 → 验证(后端先干净,前端再做展示与交互)


七、风险与不做的事

  • unwrapJsonString() 只解「整体是合法 JSON 字符串字面量」的情况,且解不出来时原样返回: 极端情况下工具真的返回 "hello" 这类带引号文本会被解成 hello(展示层可接受,LLM 侧不受影响 —— LLM 走的是 ToolResultBlock 原文,不走这条链路)
  • 不改 AgentScope 框架本身,不动 ToolResults / 各工具类
  • 不回填历史 tool_events 数据
  • 不做运行期端到端"发新消息"的验证(会消耗模型额度),以历史会话 + 重启后实测为准

八、待你拍板

编号 待定项 我的默认建议
D1 思考内容默认展开(流式输出中) 必做(你已明确要求)
D2 流式结束后 / 历史会话打开时,整条时间线是否保留展开 选 A:保留展开,只有用户手动收起才收起(否则"默认展开"输出完就失效)
D3 多段思考是否加序号 + 分隔线 加,避免多段拼成一坨
D4 入参是否也默认全展开 暂不:execute_sql 只默认展开 SQL 块(4 行内),其余 JSON 折叠,避免页面过长

确认或改完 D2~D4 我就开工。