# 2026-09-17 ## ★★ 全局接口返回空白:LicenseFilter 空壳过滤器吞掉所有请求(已修) 用户报「`GET /sys/health` 返回内容空白」。排查后发现**不是单个接口的问题,是全站接口都返回空响应**。 ### 现象与实测(关键证据) | 请求 | 实测响应 | | --- | --- | | `GET /js/a/sys/health` | `200` + `Content-Length: 0`,**无 Content-Type** | | `GET /js/a/sys/info` / `/js/a/sys/code` / `/js/a/agent/model/list` | 同上 | | `GET /js/a/definitely-not-exist-xyz`(不存在的路径) | **同样是 `200` + `Content-Length: 0`** ← 决定性证据 | | `GET /sys/health`(漏了 context-path) | 正常 404(Tomcat 层,未进 context) | 「不存在的路径也不返回 404」说明请求**根本没进 DispatcherServlet**,被 Servlet 过滤器链截断了。 Controller 里 `Result.succeed("dsfsd")` 从头到尾没被执行。 ### 根因 `ai-server/src/main/java/com/zsjz/ai/common/config/LicenseFilter.java`(2026-09-16 commit `6114881` 新增): ```java @Component // ← Spring Boot 自动注册为全局 Filter,URL 规则默认 /* public class LicenseFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) { // 方法体为空:没有 chain.doFilter(req, res) } } ``` 两个因素叠加:`@Component` + `implements jakarta.servlet.Filter` → 全局注册;`doFilter` 不放行 → 链终止, Tomcat 直接提交空 200。**日志无任何异常**,所以从日志侧完全看不出来。 该文件是 Solon → Spring Boot 迁移的残留:真正的授权逻辑整段被注释掉,用的还是 Solon API (`Context`、`ctx.pathNew()`、`ctx.outputAsJson()`、`R.failure()`),这些在 Spring Boot 下不存在, 所以只能注释、留了个空壳,没意识到空壳的副作用。`IGNORE_PATH` 里的 `/sys/health` 白名单也因此失去作用。 ### 修复(用户选定:透传但保留文件) `LicenseFilter.doFilter` 补上 `chain.doFilter(request, response)`;类头补了长注释说明踩坑点与 「@Component + Filter 必须放行」的硬约束;`IGNORE_PATH` 保留并标注「供将来恢复全局拦截时使用」。 ### 排查手法(可复用) 1. `curl -s -i` 看**是否有 Content-Type** —— 正常 Spring MVC 响应一定有;`200 + Content-Length: 0 + 无 Content-Type` 是「被过滤器吞掉」的指纹。 2. **请求一个不存在的路径**:返回 404 = 已进 Spring;返回 200 空 = 卡在过滤器链。 3. 全仓 grep `implements Filter|OncePerRequestFilter|FilterRegistrationBean` 找可疑过滤器(本项目只有这 1 个)。 ### 环境备忘 - 本机后端**正在运行**(监听 8980,PID 16376),且运行的是**改动前**的构建 → 验证修复必须重启。 - `curl` 记得加 `--noproxy '*'`(否则走沙箱代理拿到假象);上下文路径是 `/js/a`。 - 用户自行重启验证,未做运行期实测。 ### 相关 - skill `qingjian-ai-dev` 已新增该陷阱(§9(h) 第 5 条是同一接口 `/sys/health` 的**另一个**原因: `@Controller` 无 `@ResponseBody` 导致 404 转发,两者别混淆)。 --- ## ★★ 手动清洗后 tableRuleList 的 zf 字段被丢弃(2026-09-17,已定位) 用户报:上传页点「手动清洗」,带过去的数据缺少 `zf` 字段;而上传页表格的接口是返回了的。 ### 实测证据(真机调接口,不是推断) 开案:`POST /js/a/case/open` body `{"id":2,"pwd":"123456"}`(案件 2 密码就是 123456)。 上传:`POST /js/a/dm/preFileUpload?batchId=999000111222&reClean=1`(multipart,`file` 字段; 带 `reClean=1` 跳过 MD5 去重,否则重复文件返回 `data:null`)。 响应:`data.children[0].tableRuleList` **30 条规则,30 条都带 `zf` 键**(本例全为 0), 其中 MONEY 字段有 3 个:`transAmount` / `transBalance` / `otherBalance`(zf 均 0)。 → 用户说的「上传接口返回了 zf」属实。 ### 根因:`cleaning.vue` 的 `buildTableRuleListFromState` 只给「交易金额」写 zf `ai-frontend/src/case/views/data/cleaning.vue:3782`: ```js const zf = isTransAmountTargetField(field) ? (recognitionAmountZfEnabled.value ? 1 : 0) : undefined; ``` - 非「交易金额」字段 → `zf: undefined` → `JSON.stringify` 直接把键丢掉; - 同函数里 `required`/`matched`/`matchedRequired`/`directionConf` 都走 `pickFirstDefinedRuleFlagValue(模板字段值, 上一条规则值, 兜底)` 的**三级回退**, **只有 `zf` 是异类**:既不读 `previous.zf` 也不读 `templateFieldRecord.zf`。 **触发时机(很关键)**:进清洗页即触发 —— `loadFileTree()` 选中首个节点 → `switchNodeState()` → `finally` 里 `syncActiveNodeStructure()` → `syncNodeStructure()` → `node.tableRuleList = buildTableRuleListFromState(...)` + `persistFileListToSessionStorage()` (`cleaning.vue:3863 / 3006 / 3057`)。所以一进清洗页,`case_clean_file_infos` 就被重写, 首个 sheet 的非交易金额规则 zf 全部消失。切节点/点保存同理。 ### 影响:后端会 NPE(不只是少个字段) `ai-server/.../clean/impl/cleaner/` 里 4 处(`TransStdDataCleaner:71`、`TransStdDataCleaner1:79`、 `TransInOutDataCleaner2:74`、`TransInOutDataCleaner5:70`)都是: ```java switch (rule.getFieldType()) { case MONEY -> { ...; if (rule.getZf() == 1) { ... } } // Integer 与 int 比较 → 自动拆箱 } ``` `getZf()` 返回 `Integer`,缺失时是 `null` → **自动拆箱 NPE**。 而 `AbstractDataCleaner:84` 的 `tableRules` 就是 `sheet.getTableRuleList()` 过滤 `fileColIndex != null`, 即**前端发什么就判什么**。所以 `transBalance` / `otherBalance` 这类 MONEY 字段只要被映射了、 zf 又被前端抹掉,清洗就会抛 NPE(或 正负转换 静默失效)。 ### 修复(用户选定:前端 + 后端都改;交易金额保持「开关优先」) **前端** `cleaning.vue`: - 新增 `resolveStoredZfFlag(templateField, previous)`:模板字段 zf → 上一条规则 zf → 兜底 0, 保证**始终返回整数**; - `getRawRuleFlagValue` 的 key 联合类型加 `'zf'`; - `buildTableRuleListFromState` 里 `const zf = ...` 改为 「交易金额 → `recognitionAmountZfEnabled ? 1 : 0`;其余 → `resolveStoredZfFlag(templateFieldRecord, previous)`」, 并把该声明下移到 `templateFieldRecord` 之后(原来在它之前,拿不到模板字段)。 **后端**:`AbstractDataCleaner` 新增 `protected static boolean isZfEnabled(TableRuleDTO rule)`(`Integer.valueOf(1).equals(rule.getZf())`), `TransStdDataCleaner:71`、`TransStdDataCleaner1:79`、`TransInOutDataCleaner2:74`、`TransInOutDataCleaner5:70` 4 处 `if (rule.getZf() == 1)` → `if (isZfEnabled(rule))`。 ### 验证(都做了) - 后端 `mvn -pl ai-server -B compile` → **BUILD SUCCESS** - 前端 `eslint cleaning.vue --max-warnings 0` → **0 错误**(先有一处 prettier 折行报错,已手工改单行) - `vue-tsc --noEmit` → cleaning.vue **0 错误**,全仓仍 **103**(与既有基线一致,无新增) - **用真实上传响应跑前后对比脚本**(`C:/Users/cc/AppData/Local/Temp/zfcheck.cjs`): 修复前 `roundTrip` 后 **0/30** 条带 zf(3 个 MONEY 字段全丢);修复后 **30/30** 条带 zf、 全为整数、再重建一次结果完全一致(幂等)。 ★ 脚本第一版写错过:`{zf: undefined}` 的对象**自身仍有 zf 键**,必须补 `JSON.parse(JSON.stringify(x))` 这一步才能复现丢键,否则会误判"没丢"。 ### 同类地雷批量清理(2026-09-17,用户说"继续"后执行) `AbstractDataCleaner` 抽出三个 helper:`isFlagOn(Integer)`(通用 0/1 判断)、 `isZfEnabled(TableRuleDTO)`、`isRequired(TableRuleDTO)`,后两者都走 `isFlagOn`。 - **29 个清洗器 / 58 处** `rule.getRequired() == 1` → `isRequired(rule)`(用 Node 脚本按精确子串替换, 逐文件断言命中数=2、替换后长度 = 原长 − 命中数×(FROM.length−TO.length),确保除目标子串外零改动) - `GlobalCache:204` `field.getMatched() == 1`(`TableField.matched` 是 Integer)→ `Integer.valueOf(1).equals(...)` - `FunRadix:33` `getRadixType() == 0`(Integer,默认 1)→ `Integer.valueOf(0).equals(...)`,null 走 else 与默认值 1 语义一致 - `RagSchemaService:263/277` 已有 `!= null &&` 前置判断,安全,**刻意未动** - 验证:`mvn -pl ai-server -B clean compile` → **BUILD SUCCESS**(622 源文件); 残留 `getRequired() == 1` 仅剩 AbstractDataCleaner 的 javadoc 示例; CRLF 保留(`tr -cd '\r' | wc -c` == `wc -l`,抽查 3 个文件) ### 遗留(未改,已报用户,属别的模块) 全仓还有一批「可空 Integer 直接 `== 数字`」**且无 null 前置判断**的写法: `CaseDataCache:55/57`(`PersonLibNo.libType`)、`SpecialDateService:37/69`、`IntimacyService:119`、 `TrackExpressInfoService:72/82`、`TransFundFlowService:368/434/499/563`(`TransFlowGraphQuery.level`)、 `DmService:748`、`AgentChatServiceImpl:214`。这些字段在各自业务里大多必填,属**潜伏**风险, 动之前要先确认字段是否真会为 null。 已带 `!= null &&` 前置判断的是安全的:`DataProfileService:225`、`TransRecordService:87/140`、 `TableInfoController:34/35`、`DmService:287/380/597`、`TrackTogetherLiveService:165`。 ★ 排查手法:`grep -rn "get[A-Za-z0-9_]*() *[!=]= *[0-9]" src/main/java --include=*.java` 再回查字段声明类型。 ### 结论 前端应保证每条规则都带**整数** `zf`(非交易金额字段沿用上传接口/模板的值,兜底 0), 而不是只给交易金额写、其余置 undefined。已按「前端 + 后端都改」落地。 ### 复用要点 - `table_field.zf`(平台库,`etl-2` / `zsjz-ai` 都有)语义 = 「开启正负转换」,只配在 `transAmount` / MONEY 字段上(实测 16 行 zf=1,field_name_en 全是 `transAmount`)。 - `PreDataListener.getTableRuleList()`(line 400-440)里 `zf` 取自**主模板**字段 (`entityToDto(mainTable)`),而 `fieldNameCn`/`directionConf`/`matched`/`required` 会被**子模板**同名字段覆盖 —— `zf` 不在覆盖列表里,这是个潜在不一致点。 --- ## 清洗流程三个页面统一为「独立全屏路由」(不套 LAYOUT) ### 背景 上传 → 手动清洗 → 清洗导入是同一条流程。此前只有 `upload.vue` 是独立全屏路由, `cleaning.vue` / `cleanProgress.vue` 挂在 `DataRoute`(`component: LAYOUT`)下, 进流程后菜单/页签会重新冒出来,与上传页割裂。 ### 改动 1:`ai-frontend/src/core/router/routes/index.ts` - 从 `DataRoute.children` 删除 `cleaning`、`cleanProgress` 两个子路由(连同 `currentActiveMenu`) - 仿 `CaseUploadRoute` 新增 `CaseCleaningRoute` / `CaseCleanProgressRoute`, `path` 用 `PageEnum.BASE_CLEANING`(`/data/cleaning`) / `BASE_CLEAN_PROGRESS`(`/data/cleanProgress`), `meta` 保留 `title` + `hideMenu: true` + `hideTab: true` - 两者加进 `basicRoutes`(放在 `CaseUploadRoute` 之后、`DataRoute` 之前) - 路由 `name` 未变(`CaseCleaning` / `CaseCleanProgress`),全仓无其它引用 ### 改动 2:两个页面的根样式(**脱离 LAYOUT 必须改,否则高度错**) - `cleaning.vue`:`height/max-height: calc(100vh - 100px)` → `100vh`。 那个 `100px` 是布局 header + tabs 的高度,脱离 LAYOUT 后不存在,扣掉会平白少一截。 - `cleanProgress.vue`:`min-height/height: 100%` → `100vh`。 父级不再是有确定高度的布局内容容器,`100%` 会退化成 `auto`,页面塌成内容高度。 - 两页背景由 `#fff` 改为 `#f0f2f5`(与 `upload.vue` 一致)。因为页内面板本身是 「白底 + 边框 + 阴影」的卡片(`.left-panel/.middle-panel/.right-panel`、`.progress-card`), 白底铺满会糊成一片;灰底才能让卡片浮起来。 - 两页都没有暗色主题样式块(`upload.vue` 有),保持原样未补。 ### 关键判断依据(后续改路由前先看这些) - `basicRoutes` 是直接传给 `createRouter({ routes: basicRoutes })` 的**静态路由**, 所以独立全屏路由**刷新/直链也能命中**,不依赖 `permissionGuard` 的动态注入。 - `hideMenu` 的过滤在 `core/router/helper/menuHelper.ts:41`(`if (node.meta.hideMenu) return;`), 与是否套 LAYOUT 无关,删掉 `currentActiveMenu` 无副作用。 - `core/store/modules/multipleTab.ts:32` 的 `HIDDEN_FLOW_TAB_PATHS` 按 **path** 判断, 不依赖路由层级,**无需同步修改**;`hideTab: true` 也让 `addTab` 直接 early-return。 - LAYOUT 内页面原高度参考:`.jeesite-layout-content` 有 `padding: 12px 12px 0` 且高度是 JS 算的 (`useContentViewHeight`),所以布局内页面用 `calc(100vh - 100px)`,全屏页必须用 `100vh`。 - `upload.vue` 的 `onBeforeRouteLeave` 靠 `to.path` 比对 `BASE_CLEANING`/`BASE_CLEAN_PROGRESS` 决定是否保留 sessionStorage 缓存 —— 路径没变,逻辑不受影响。 ### 验证(全部实测通过) - `eslint --max-warnings 0` 三个改动文件 → **0 错误** - `vue-tsc --noEmit --skipLibCheck` → 全仓 **103**(与基线一致),三个改动文件 **0 错误** - **UI 级验证**(`%TEMP%/ai-verify/ui-route-flow2.cjs` + `ui-route-flow3.cjs`,用 playwright-core 复用 agent-browser 下载的 chrome,1680×1000 视口): - 深链直开 `/data/cleaning`、`/data/cleanProgress`、`/data/upload` → `.jeesite-default-layout` / `.jeesite-layout-content` / `.ant-layout-sider` 全为 **0**, 页面根 `getBoundingClientRect().height` **== window.innerHeight**,背景 `rgb(240,242,245)`; - 对照组深链直开 `/data/index` → 三者均为 1(有布局),证明判定方法有效; - 真实点击:`/data/index` 点「选择文件」→ `/data/upload?mode=single`(无布局、1000px 撑满) → 点「返回」→ `/data/index`(布局恢复、上传页已卸载); - 全程 **0 条控制台错误**、无 4xx/5xx。 - ★ 踩坑:`agent-browser` 守护进程在本机起不来(详见技能文档 §9.5(f)), 应直接用 **playwright-core**;我一开始没看技能文档,白试了三轮。 ### 环境备忘(本次新得) - 后端 context-path 是 `/js/a`,健康检查完整地址 = `http://127.0.0.1:8980/js/a/sys/health` → `{"code":200,"message":"","data":null}`。**直接访问 `/sys/health` 会 404,别误判后端没起来。** - 前端 dev server 在 **3100**。 - ★ **路由模式分环境**:`routeHelper.ts:151` 按 `VITE_ROUTE_WEB_HISTORY` 决定 —— `.env.development` = `true`(**history**,真实 URL 形如 `http://localhost:3100/data/cleaning`), `.env.production` = `false`(**hash**),`.env.tomcat` = `true`。 所以**本地调试用不带 `#` 的路径**;写验证脚本时改 `location.hash` 是无效的(history 模式不响应), 必须用 `page.goto(BASE + '/data/xxx')` 或真实点击。 - 案件数据页 `/data/index` 的「导入数据 / 导入文件」是 **页签**(`button.import-tabs__item`), 真正的上传入口是右上角 **「选择文件」按钮**(`handleUploadNavigate('single')` → `/data/upload?mode=single`)。 ### 模型管理页:对话/向量分类 Tab + 各自默认(2026-09-17) 需求:模型管理页按用途类型分 Tab,且 llm 与 embedding **各自**维护一个默认模型。 后端(编译通过 + 27 项纯逻辑断言通过): - 新增 `common/enums/ModelTypeEnum`(LLM="llm" / EMBEDDING="embedding",`fromCode` 把 null/未知值归一为 LLM)。 - `AgentModelServiceImpl#setDefaultModel` 由「清空全表默认」改为**只清同类型**默认; `createModel` / `updateModel(defaultModel=true)` 同样走 `clearDefaultOfType`,避免同类型多个默认。 - `getDefaultModelId()` 语义收紧为**默认对话模型**(type=llm,含 `type IS NULL` 历史行); 新增 `getDefaultModelId(String type)`。两个 wrapper 的构建抽成包级静态方法 `buildClearDefaultWrapper` / `buildDefaultQueryWrapper`,便于脱离 Spring/DB 断言。 - `EmbeddingModelFactory.resolveDefault()` 改用枚举常量 + 次级排序(create_at DESC)保证确定性。 - ★ 验证手法:`dependency:build-classpath -Dmdep.outputFile=...` 导出依赖 → 用 `target/classes` + 该 classpath 跑 main → `TableInfoHelper.initTableInfo(assistant, AgentModel.class)` 初始化后打印 `wrapper.getCustomSqlSegment()`,断言 and/or 括号组合。**不用起 Spring、不用连库。** 前端: - `ai/views/aiModel/index.vue`:a-tabs 两个页签(对话模型 / 向量模型 + 数量角标),表格按 tab 过滤; 去掉冗余「类型」列;「设为默认」不再拦截 embedding(类型已隔离),已是默认时置灰为「已是默认」; summary 同时显示两类默认;新增按钮文案跟随当前 tab。 - `ai/components/ModelFormModal.vue`:新增 `defaultType` prop(新增时类型跟随当前 tab); 默认开关文案按类型区分,并提示「同类只能有一个默认」。 - UI 级验证(playwright-core):`tabs=["对话模型2","向量模型1"]`、列名无「类型」、 默认角标「默认对话」在「本地模型」上、弹窗类型默认「向量模型」、0 控制台错误 / 0 个 4xx。 - ⚠️ 当时后端进程(PID 6980)跑的是改动前的代码,**需重启后端类型隔离才生效**(本次未擅自重启)。 - ⚠️ 后端常驻进程是 **IDEA 以 Debug 模式**启动的(`jps` 可见 `com.zsjz.ai.App` + `-agentlib:jdwp` + IDEA captureAgent),**不要杀**:会丢进程内 DuckDB 案件状态、打断调试会话。改完后端代码需用户在 IDEA 里重启。 - ★ 不重启后端的验证手法:JDBC 连库 → `setAutoCommit(false)` → 跑与代码等价的 SQL → 断言 → `rollback()`。 本次据此在真实 `agent_model` 上验证默认模型类型隔离(10 项全过,含"旧逻辑会误清另一类型"的对照),数据零变更。 --- ## AI 对话界面(aiAnalysis)排版改造:对齐主流 AI 客户端 需求:用户觉得对话输出排版"不好用",要求参考 WorkBuddy / ChatGPT 重排。 ### 用户拍板的决策 - 内容列宽 **900 → 1024px**(消息流 / 输入区 / 欢迎页共用同一列) - **用户消息 = 右对齐气泡;AI 消息 = 头像 + 内容列,不用气泡** - 思考 / 工具调用 → **默认折叠成一行弱化灰条** - 输入区重排(圆角容器 + 圆形发送/停止按钮 + 模型收成 chip) ### 改动文件(全部在 `ai-frontend/src/ai/`) - `styles/ai.less`:新增令牌 `--ai-avatar:28px`、`--ai-radius-bubble:12px`,`--ai-content-max` 改 1024px; 新增 `.ai-turn`(grid:头像列 + minmax(0,1fr) 内容列)与 `.ai-process`(过程条基元 = 弱化灰条 + 展开区 2px 左轴);`.ai-markdown` 重排(**去 h2 下边框**、行内 code 去边框、表格改发丝横线去斑马纹、 标题上间距 > 下间距);**删除消息间分隔线**,改相邻选择器控制轮次节奏 (assistant→user 28px / user→assistant 12px / assistant→assistant 20px); 操作条显隐 = hover/focus 露出 + 最后一条 AI 常驻 + `@media (hover: none)` 常驻 - `ChatMessageItem.vue`:拆成两个分支(用户气泡 / AI 头像+内容列),用户消息时间移到气泡下方,错误态加图标 - `MessageToolbar.vue`:5 个文字按钮 → 30px mdi 图标按钮 + tooltip,删除加 Popconfirm 二次确认 - `ThinkingPanel.vue` / `ToolCallPanel.vue`:换 `.ai-process` 外壳(自动展开/收起/滚底逻辑一行未改) - `ChatComposer.vue`:圆角 14px 容器、模型收成 chip(Dropdown + Menu)、圆形 32px 发送/停止、hint 移出容器 - `views/aiAnalysis/index.vue`:新增 `__flow` 定位容器 + 「回到底部」按钮(距底 >200px 显示) - `uno.config.ts`:safelist 补 10 个 mdi 图标(sparkles / content-copy / check / tray-arrow-down / star / star-outline / alert-circle-outline / chip / arrow-up / arrow-down) ### 关键陷阱(下次直接照做) - ★ **`Icon` 组件的类名是运行时拼的**(`i-mdi:xxx`),UnoCSS 静态扫描不到 → 新增图标**必须**加进 `uno.config.ts` 的 `safelist`,否则图标一片空白 - ★ **输入区宽度要与消息内容列对齐**:`.ai-column` 自身有 24px 内边距,composer 的 box 必须 `max-width: calc(var(--ai-content-max) - var(--ai-sp-5) * 2)`,否则输入框比正文宽 48px、左边不齐 - `bodyRef` 只在 AI 分支存在(PDF 导出依赖它),用户消息的 toolbar 没有导出菜单所以安全 - Tooltip 与 Dropdown 不要套在同一个按钮上(互相抢 click/hover 事件),导出按钮只留 Dropdown ### 验证 - `pnpm -C ai-frontend type:check` → `src/ai` **0 错误** (全仓既有基线是 `src/trans` 下约 100 条历史报错,与本次无关,别误判为自己引入) - `pnpm -C ai-frontend build` → **通过**(约 1m50s) ### 追加:过程区改成「执行时间线」(第二轮,用户贴 WorkBuddy 截图后) 用户反馈第一版"没啥变化",并贴出 WorkBuddy 的过程展示 —— 他要的是**执行时间线**, 不是"思考和工具各自折叠成一条灰条"(第一版方向错了)。 - 新建 `components/ProcessTimeline.vue`(根类 `.ai-trace`): - 折叠态 = 一行汇总「已完成 · 3 步工具调用 · 12s」/ 流式中「正在分析…」 - 展开态 = 左侧一条细竖线 + 逐行流水,行 = 13px 图标 + 动作名 + 参数摘要 - 思考行可展开全文(多段思考按 `\n\n` 拆成多行「深度思考」),工具行可展开入参/结果 - 工具 → 图标映射见组件内 `TOOL_ICONS`;摘要从入参 JSON 里按 `ARG_KEYS` 取第一个字符串值截 72 字 - `ChatMessageItem.vue`:用 ProcessTimeline 替掉 ThinkingPanel + ToolCallPanel; `StreamStatusBar` 改为仅在 `!hasTrace` 时显示(否则和时间线汇总行重复计时) - ai.less:删掉上一轮加的 `.ai-process` 基元(样式已收进组件 scoped);`hover:none` 规则补 `.ai-trace__detail-btn` - ⚠️ **两个旧组件文件没删掉**:`ThinkingPanel.vue` / `ToolCallPanel.vue` 已无任何引用(grep 确认), 但本环境 bash 坏了(`dirname: command not found`)、PowerShell 的 `Remove-Item` 也被拦(exit 1 无输出), **需用户手动删除**或下次在正常 shell 里删 - ★ 时序是**展示层近似**:后端只下发「思考全文 + toolEvents 数组」,无统一时序字段。 思考段数 M 与工具数 N 满足 M==N 或 M==N+1 时按 ReAct 常见形态交替排列,否则退化为"思考在前、工具在后"。 要精确还原需后端落库时一并保存事件顺序。 - ★ 模板类型收窄陷阱:`
…` 里, 中间那个 v-if 会**截断联合类型收窄**,`item.step` 会报类型错。必须改成 `` / `` 各包一整块。 - ★ `watch(x, cb, { deep: true })` 在**程序化**修改时也会触发,会把手动标记误置位 —— "用户是否手动操作过"这类标记只在 click handler 里置位。 - 验证:`type:check` → src/ai **0 错误**;`build` → **通过** ### 图标白名单最终清单(`uno.config.ts` safelist 里 AI 相关部分) refresh / plus / send / stop / magnify / pin / pin-outline / pencil-outline / trash-can-outline / fullscreen / fullscreen-exit / phone-outline / bank-transfer / brain / chart-box-outline / sparkles / content-copy / check / tray-arrow-down / star / star-outline / alert-circle-outline / chip / arrow-up / arrow-down / chevron-right / chevron-down / chevron-up / database-search-outline / format-list-bulleted / table-search / language-python / graph-outline / folder-search-outline / tools ### 追加(第三轮):思考与工具的**顺序**错了 —— 根因是 store 把时序拍平了 用户反馈:"思考后不是调用工具吗?每一个思考过后的步骤要在一起,不是把工具和思考分开放。" **根因**:SSE 本身是**按真实顺序**下发的(thinking start → delta → end、tool_call、tool_input、 tool_result,`chatStream.ts` 的回调也确实顺序触发),但 store 把它们**拍平**成 「`thinking` 一段拼接全文 + `toolSteps` 一个数组」,顺序信息在拼接时就丢了。 渲染端只能靠"思考段数与工具数是否匹配"去猜,猜不中就退化成"思考全堆前面、工具全堆后面" —— 用户看到的正是这个。 **修法**: - `store/chatStream.ts`:新增 `TraceNode { kind: 'think' | 'tool'; text?; toolCallId? }` 与 `ChatMessage.trace`; `onThinking(phase=start)` push 一个思考节点、`onThinking(delta)` 用新加的 `appendToLastThink()` 填正文、 `onToolCall` push 一个工具节点(带 toolCallId)→ **流式期间时序 100% 准确** - `ProcessTimeline`:新增 `trace` prop 并**优先使用** —— 按到达顺序渲染,每段思考后紧跟它引发的工具 - 历史消息(无 trace)的近似算法重写:`思考 i → 工具 i`,多出来的顺序接末尾, **取消原来"不匹配就分两堆"的兜底分支** - `ChatMessageItem`:传 `:trace="message.trace"` **遗留(要彻底解决需改后端)**:刷新页面 / 重新打开历史会话时消息从后端重拉, `metadata` 只有 thinking 全文、`toolEvents` 只有工具数组,没有事件顺序 → 只能近似排列。 彻底解决需后端落库时把事件顺序一并写进 metadata。 **验证**:`type:check` → src/ai 0 错误;`build` → 通过。 ### 追加(第四轮):工具详情恢复 + 输出用表格渲染 用户:"ToolCallPanel 还是要有啊。展示出工具入参和输出啊。输出的如果是 json 数据 要用表格渲染出来。" - 重建 `components/ToolCallPanel.vue`(**用 Write 直接覆盖旧文件**,顺带解决"旧文件删不掉"的问题)。 职责改为「单个工具的详情」:入参(格式化 JSON 的 pre)+ 输出(智能结构化)。 输出渲染优先级: 1. execute_sql 载荷 → 复用 `scanner.ts` 的 `isTablePayload()`,按 `columns` 定义渲染表格 2. 对象数组 `[{…}]` → 取键并集作列 3. 原始值数组 `[1,2,3]` → 单列 4. 普通对象 → 字段 / 值两列 5. 兜底 → 等宽 pre 表格形态保留「原始 JSON」切换按钮;> 20 行本地分页;`scroll: { x: 'max-content', y: 360 }`; 单元格里的对象/数组再 `JSON.stringify` 一次,避免 `[object Object]` - `ProcessTimeline`:工具行详情区改为 `
` 原始 JSON(还是带转义的样子)
3. ★ UI 实测(playwright-core + headless chrome,dev 3100):
`detail button = exists opacity=0 display=flex size=18x18 visible=false`,
hover 后 `opacity=1`(出现在 `.ai-trace__row:hover` 里)→ 那个详情按钮**默认完全不可见**,
用户根本发现不了要再点一次,表现就是"没有展示入参和输出"
(脚手架脚本:`C:/Users/cc/AppData/Local/Temp/ai-verify/ui-tool-detail.cjs`,
直接复用案件 2 / 会话"端到端验证-已重命名",全程只读、不烧 token)
**Fix 方向(用户确认后执行)**
- 后端 `AgentChatServiceImpl` 累加 `ToolResultTextDeltaEvent.getDelta()` 时做一次
`unwrapJsonString()` 归一化(首尾 `"` 且能 readValue 成 String 就解一层、幂等),
SSE 帧与落库同时修好;顺带修 409 行 `if (!result.isEmpty())` 的 NPE(无 delta 时 get() 为 null)
- 前端 `parseToolEvents` / `onToolResult` / `ToolCallPanel` 统一做"最多解一层多余包装"的容错
(历史数据已双重编码,必须兼容,否则老会话还是渲染不出表)
- 交互:工具行整行可点、展开态常驻箭头、行内提示"入参 · 输出 N 行",不要再靠 hover 才显形
**复用要点**:`agentscope-2.0.0-sources.jar`(m2 里有 sources 包)可用 node 手工解析 zip
中央目录(本地文件头 compSize=0,必须走中央目录)提取任意 `.java` 源码来核对框架行为。
### 第六轮 · 已执行并验证通过(2026-09-17)
计划文档 `tool-call-detail-plan.md`(仓库根)已按用户拍板执行。
**后端** `AgentChatServiceImpl.java`
- 新增 `private static String unwrapJsonString(String)`:首尾是 `"` 且 `MAPPER.readValue(v, String.class)`
成功就返回内层,否则原样返回;**幂等**(二次调用结果不变)。`MAPPER` 复用文件里已有的
`private static final ObjectMapper MAPPER = new ObjectMapper();`(line 71),未新增字段。
- `ToolResultTextDeltaEvent` 分支累加时套一层 `unwrapJsonString(e.getDelta())`。
- 顺带修 `ToolResultEndEvent` 分支的 **NPE**:原来是 `if (!toolResults.get(id).toString().isEmpty())`,
无 delta 时 `get()` 返回 `null` → NPE。改成 `StringBuilder acc = ...; String text = acc == null ? "" : unwrapJsonString(acc.toString()); if (!text.isEmpty())`。
- `buildToolEventsJson` 里 `ev.put("result", ...)` 同样走 `unwrapJsonString`。
- **只在写入侧归一化**(SSE 帧 + 落库两条路径都过这方法),历史数据靠前端容错兼容。
**前端**
- `utils/parseMessageContent.ts` 新增两个工具函数:
- `parseJsonPayload(raw)` —— parse 一次;结果是 string 就**再 parse 一次**(治双重编码),
返回 object/array 才算成功,否则 null。
- `normalizeToolPayloadText(raw)` —— 双编码就返回内层 JSON 文本,否则原样;**幂等**。
- `parseToolEvents` 对 input/result 都过 `normalizeToolPayloadText`;
- `parseTableFromToolResult` 由 `try{JSON.parse}` 改为 `parseJsonPayload`。
- `store/chatStream.ts`:`onToolInput` / `onToolResult` 都过 `normalizeToolPayloadText`(流式期间也治)。
- `components/ToolCallPanel.vue` **重写**:
- `payload` 用 `parseJsonPayload`;`isTablePayload`(`scanner.ts`)判表格;`truncated`(≥100000 字);
- `sqlText` 从 input 里抽 `.sql` 字段,**单独一个代码块**渲染 + 折叠/展开 + 「全部参数」切换;
- 表格行 `withRowKeys()` 前置 `__key`,`rowKey = '__key'`(**常量字符串**,不是函数
—— 修掉 ant-design-vue 的 `rowKey` 弃用告警);
- 表格形态保留「原始 JSON」切换;非表格不再给 `isJson/rawText/showRaw`,直接 pretty。
- `components/ProcessTimeline.vue` **重写交互**:
- `expanded = ref(true)`(过程区默认展开);`watch(active)` **只展开、不收起**
(原来自动折叠,用户手动操作过就尊重手动 —— `userToggled` 只在 click handler 里置位)。
- `collapsedThinks = ref>(new Set())` —— **思考默认展开**(语义反转:集合里存的是"被手动折叠的")。
- 工具行**整行可点**(`