状态:待确认(确认后再动代码) 日期:2026-09-17 范围:
ai-frontend/src/ai/**、ai-server/.../agent/service/impl/AgentChatServiceImpl.java
聊天界面展示工具调用记录时,看不到工具调用的内容:
第五轮其实已经实现过 ToolCallPanel.vue(入参 + 输出智能结构化 + 表格渲染),
问题出在真机行为:代码在、数据在,但被两个坑同时挡住。
GET /js/a/chat/sessions/1494404202000630103/messages
→ 单条 assistant 消息 toolEvents 含 13 次工具调用
→ 每条都有 input / result / status=done(result 最长 297,801 字符)
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 → 表格这条路从来没走通过。
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。用户看到的是「只有工具名 + 一句参数摘要」,
既没有入参也没有输出,主观感受就是"没有展示工具调用的内容"。
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 这条链路,不是落库层的统一问题。
文件:ai-server/src/main/java/com/zsjz/ai/module/agent/service/impl/AgentChatServiceImpl.java
新增归一化工具方法(幂等,失败原样返回):
/**
* 归一化工具结果文本: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;
}
}
ToolResultTextDeltaEvent 分支累加时套用:
} else if (event instanceof ToolResultTextDeltaEvent e) {
toolResults.computeIfAbsent(e.getToolCallId(), k -> new StringBuilder())
.append(unwrapJsonString(e.getDelta()));
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落库共用同一个累积容器,改在累加点两个出口同时干净。
新增: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() 兜底,双保险 |
ToolCallPanel.vue)execute_sql 载荷:按 columns 渲染分页表格(>20 行本地分页,scroll: {x:'max-content', y:360}),保留「原始 JSON」切换render_chart / render_graph 的 option JSON:等宽代码块,不做表格JSON.stringify 一次(避免 [object Object])——已有,保留a-table 的 row-key 从 index 改为稳定字段(消除 antd 的 deprecated 警告)ToolCallPanel.vue)execute_sql:把 sql 单独抽成 SQL 代码块(默认折叠 4 行,可展开全文),其余参数留在 JSON 视图文件:components/ProcessTimeline.vue
入参 · 输出 N 行execute_sql(或最近一次工具调用)默认展开详情需求原文:「思考内容输出时默认展开,不要叠起来。」
现状(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; // ← 结束即收起
});
两处叠加的结果:思考内容一闪而过,输出完就"叠"回一行,用户看不到。
改法:
openThinks 语义反转:由「已展开集合」改为「被用户手动收起的集合」collapsedThinks。
这样新出现的思考段天然就是展开态,不需要任何 watch 去补。thinkingActive 结束时的自动收起分支:思考输出结束不再收起。active 由 true → false 时保留展开态),否则思考刚展开又被整条折起来。\n\n 分段渲染,段首给「深度思考 1 / 2 / 3」序号 + 轻分隔线,
不要把多段拼成一坨。userToggled / 收起集合标记)。需你确认的一点(D2):流式结束后如果保留整条时间线展开,历史会话打开时也会直接铺满思考 + 工具流水, 长回答页面会变长。三个可选口径:
| 项 | 命令 | 通过标准 |
|---|---|---|
| 后端 | 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 |
通过 |
脚本:C:/Users/cc/AppData/Local/Temp/ai-verify/ui-tool-detail.cjs
(playwright-core + headless chrome,复用案件 2 / 会话「端到端验证-已重命名」,全程只读、不烧 token)
断言项:
.ai-tool-detail .ant-table 数量 ≥ 1(当前是 0)rows.length.ai-trace__think 可见且数量 == 思考段数(当前是 0)\n\n 分段数,序号 1..N 连续| # | 文件 | 改动 |
|---|---|---|
| 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 原文,不走这条链路)ToolResults / 各工具类tool_events 数据| 编号 | 待定项 | 我的默认建议 |
|---|---|---|
| D1 | 思考内容默认展开(流式输出中) | 必做(你已明确要求) |
| D2 | 流式结束后 / 历史会话打开时,整条时间线是否保留展开 | 选 A:保留展开,只有用户手动收起才收起(否则"默认展开"输出完就失效) |
| D3 | 多段思考是否加序号 + 分隔线 | 加,避免多段拼成一坨 |
| D4 | 入参是否也默认全展开 | 暂不:execute_sql 只默认展开 SQL 块(4 行内),其余 JSON 折叠,避免页面过长 |
确认或改完 D2~D4 我就开工。