# 聊天界面「工具调用记录」不展示入参与返回结果 —— 修复计划 > 状态:**待确认**(确认后再动代码) > 日期: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") 永不成立 → 永远落回
 原始 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`:

```java
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. 新增归一化工具方法(幂等,失败原样返回):

```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;
    }
}
```

2. `ToolResultTextDeltaEvent` 分支累加时套用:

```java
} else if (event instanceof ToolResultTextDeltaEvent e) {
    toolResults.computeIfAbsent(e.getToolCallId(), k -> new StringBuilder())
               .append(unwrapJsonString(e.getDelta()));
```

3. `ToolResultEndEvent` 分支:修 NPE + 下发前再兜一层

```java
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` 里导出统一函数

```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` 现有逻辑,是"叠起来"的来源)**:

```ts
// 只有「正在输出的最后一段」自动展开;一旦思考结束就自动收起
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 我就开工。