2026-09-17.md 33 KB

2026-09-17

AI 数据分析 · 富内容块全屏查看(图谱 + 图表)

改动文件:

  • 新增 ai-frontend/src/ai/utils/useBlockFullscreen.ts(公共 composable:浮层全屏 / Esc 退出 / 锁 body 滚动 / 切换后 onResize 回调)
  • 新增 ai-frontend/src/ai/styles/ai.less 里的 .ai-block-fullscreen 浮层基类
  • ai-frontend/src/ai/components/blocks/GraphBlock.vue
  • ai-frontend/src/ai/components/blocks/EChartsBlock.vue
  • ai-frontend/uno.config.ts

要点:

  • 两块头部新增「全屏查看 / 退出全屏」按钮(含 Esc 退出),全屏用 CSS 浮层(position: fixed + width/height:100%) 而非 Fullscreen API —— 产品可能嵌在 iframe 里,requestFullscreen 会被拒;浮层还能保留头部操作栏与详情栏。
  • 浮层定位/层级统一放 .ai-block-fullscreen(ai.less,全局类),各块只在 scoped 样式里补自己的重排规则。
  • z-index 用 calc(var(--ai-z-modal) + 10),低于 --ai-z-toast。
  • 全屏切换后需主动适配视口:RelationGraph 内部 resize 监听是异步回调且保留原缩放/偏移, 会停在左上角。做法是 await nextTick() → 量 stageRef 尺寸 → graph.refresh(false)(重新测量+居中,不重跑力导向布局)→ graph.zoomToFit()。 注意 refresh() 默认 doLayout=true 会让节点位置跳动,必须传 false。
  • 全屏时给 body 加 overflow: hidden,退出/卸载还原。

踩坑记录(重要)

  1. viewSize 不在公开 RGOptions 类型里(只在内部 RGOptionsFull), graph.setOptions({ viewSize }) 会 TS2353 报错。改用 graph.refresh(false) + zoomToFit()。
  2. uno.config.ts 的 safelist 必须手工登记动态拼接的图标类。 Icon 组件运行时拼 i-<collection>:<name>,UnoCSS 静态扫描不到 → 图标空白。 本次新增 i-mdi:fullscreen、i-mdi:fullscreen-exit。以后再往 AI 组件里加 mdi 图标,记得同步 safelist。
  3. stylelint 禁止 inset(declaration-property-value-disallowed-list), 同时 declaration-block-no-redundant-longhand-properties 又要求简写 —— 二者冲突。 项目既有写法是 position: fixed; top: 0; left: 0; width: 100%; height: 100%;(只给 top/left 两个长手,绕开简写规则)。
  4. 校验命令:./node_modules/.bin/vue-tsc --noEmit --skipLibCheck、eslint、stylelint <file> --custom-syntax postcss-html。
  5. ECharts 不会跟随容器尺寸变化,全屏切换后必须显式 resize()(useAiChart 返回的 resize);图谱则走 refresh(false) + zoomToFit()。

图谱交互增强(直线连线 + 搜索 + 邻居高亮)

改动:GraphBlock.vue

  • 连线改直线:defaultLineShape: RGLineShape.StandardStraight(原来是 StandardCurve)。
  • 全屏工具条(.ai-graph__tools):节点搜索(名称/分类/ID,最多 8 条,回车取第一条,Esc 清空,点击外部收起)
    • 「高亮邻居」开关 + 焦点/邻居数提示。全屏与内联都显示(用户明确要求)。 搜索框 flex: 0 1 240px; min-width: 150px,工具条 flex-wrap: wrap,适配内联 900px 列宽。
  • 点击节点即成为高亮焦点(focusId);搜索结果点击后 moveToCenter([node]) 居中(不缩放)。
  • 高亮状态跨全屏切换保留(工具条一直可见,用户可自行关闭)。

relation-graph 内部机制(重要,别再重复考古)

  • Vue3 反应式模式下 _dataUpdated() 几乎什么都不做:_doSomethingAfterDataUpdated 在 useReactiveDataToAutoUpdateView && !performanceMode 时只调 updateMiniView()。 真正的重绘靠 options 是响应式对象(optionsRef)以及 runtimeDATA4ShouldRenderItems 这个 ref 被赋新数组。所以:
    • 走 options 的更新一定生效:setOptions/updateOptions(canvasZoom/canvasOffset/checkedNodeId…)。 已核实:画布 transform 来自 computed(() => optionsRef.value.canvasOffset/canvasZoom)(RGCanvas), 所以 moveToCenter([node]) → setCanvasCenter → setCanvasOffset → updateOptions({canvasOffset}) 确实会重绘,搜索定位可用。
    • updateNode/updateLine(id, {opacity}) 不可靠:非 performanceMode 下 getShouldRenderNodes()/getShouldRenderLines() 返回的是同一个数组引用, a.nodes = sameRef 不触发;连线的 lineConfig 还是 computed(() => generateLineConfig(line)),无响应式依赖 → 永久缓存。
    • performanceMode 默认 false。
  • 因此图谱高亮不要用库的 opacity API,用 CSS 覆盖。稳定契约(已核对产物代码):
    • 节点元素:.rg-node-peel[data-id="<id>"],透明度取 --rg-node-opacity
    • 连线元素:.rg-line-peel[data-id="<line id>"],透明度取 --rg-line-opacity(自带 transition: opacity .2s)
    • 库规则特异性只有 (0,1,0),.ai-graph__stage.is-highlight .rg-node-peel 是 (0,3,0) → 无需 !important。
    • 注意:覆盖 .rg-node-peel 的 transition 时要把 transform .15s ease 一起写上,否则会吃掉库的位移动画。
    • 连线的文字在 foreignObject 里的 .rg-line-peel,同样带 data-id,会被同一条规则压暗。
    • 注入的 CSS 里,节点 id 来自后端,必须转义 \ 与 "(防 CSS 注入)。
  • 其它有用 API:getNodeById/getLineById、moveToCenter([node])(居中不缩放)、 focusNodeById(会强制 100% 缩放,慎用)、getNodeRelatedNodes、refresh(false)。

直线连线的文字(已验证,无需担心)

  • 库的 defaultLineShape 默认就是 StandardStraight,我们原来显式设成 StandardCurve 反而是偏离默认。
  • 库里 useTextOnPath 会被自动降级:useTextOnPath && lineShape !== StandardStraight 才走 <textPath>, 直线时自动改用普通 <text> + translate/rotate(deg),所以设了 defaultLineTextOnPath: true 也不会坏,标签照常显示。
  • 两个节点坐标完全重合时,库会把 StandardStraight/Curve2/Curve3/SimpleOrthogonal/Curve5 兜底转成 StandardCurve,避免退化路径。

本机环境事实(省时间)

  • ai-frontend 开发服务器常驻在 :3100(strictPort),可直接 curl http://localhost:3100/... 做编译层验证:
    • curl http://localhost:3100/src/xxx.vue 返回 200 即该 SFC 编译通过(失败会返回 500 + 错误信息)。
    • UnoCSS 产物:curl http://localhost:3100/__uno.css,图标类在产物里是转义冒号形式 i-mdi\:fullscreen,用 grep "i-mdi:fullscreen" 搜不到,要搜 i-mdi\\\\:fullscreen 或直接 grep -o "i-mdi[^ ,{]*"。
  • agent-browser 在本机不可用:open 一律 SIGTERM(沙箱内、沙箱外都失败),无法做浏览器视觉验证。 node_modules 里也没有 jsdom/happy-dom/playwright。需要视觉验证时只能留一个预览页给用户手动看。 (本次曾建 __preview-graph.html + src/__preview-graph.ts 预览页,已按用户要求删除;下次可临时再建。)

修复:图谱全屏后 graph.refresh is not a function(GraphBlock.vue)

  • 根因:<RelationGraph> 上同时写了 ref="graphRef",而 setup 里也用同一个 graphRef 手动持有 @on-ready 给的实例。Vue 的 setRef(@vue/runtime-core,isRef 分支)在每次重渲染 都无条件 ref.value = 组件公开实例,没有"值相同就跳过"的守卫 → 切全屏时 isFullscreen 变化触发重渲染, 实例句柄被覆写成 RelationGraph 组件实例,紧接着 fitAfterResize 调 .refresh 就炸。 之所以只在全屏时暴露:applyData() 是在 onReady 里同步调用的,那一刻实例还没被覆写。
  • 修法:删掉模板里的 ref="graphRef"(并加注释防止回归),所有取用点改走 getGraph() —— 内部做 typeof zoomToFit === 'function' 能力校验,拿不到实例就放弃本次适配 (库自带 ResizeObserver 仍会 resetViewSize(),不会白屏)。
  • 顺带确认:@on-ready 编译产物是 onOnReady,与 emit("onReady", inst) 的 toHandlerKey("onReady") 匹配,所以 onReady(graph) 拿到的确实是实例。
  • 改动文件:ai-frontend/src/ai/components/blocks/GraphBlock.vue(fitView/relayout/applyData/focusOnNode 一并收口)。
  • 验证:vue-tsc 无 src/ai/ 错误、eslint 干净、stylelint 干净;dev server 产物已无 ref: 绑定。
  • 清理:临时探针 __probe-rg.mjs / __rg-nocss.mjs 已删除。

新增:AI 图谱连线/节点点击查通话·交易明细(GraphBlock.vue)

  • 需求:点击连线按「类型 + 两端节点的人」查记录,点击节点按人名查记录,都用弹框展示。
  • 复用了现成弹框 src/call/views/components/CallRecordModal.vue 与 src/trans/views/components/TransRecordModal.vue(都基于 QueryTableModal, 只需 v-model:open + title + z-index + base-query + fetch-api + filter-fields)。 接口:POST /cr/getCallRecord、POST /trans/getTransRecord,personName/otherName 精确匹配。
  • 类型识别:边的 label 是模型自由生成的文字,用关键词打分判类 (通话/呼叫/主叫/短信… vs 转账/交易/资金/收款…),平局或都不命中才弹兜底菜单让用户选。
  • 连线查询做了双向合并(A→B 与 B→A 各查一次再合并去重、客户端分页,单方向上限 500 条), 因为后端没有 pair 语义入参,单查一个方向会漏反向记录。
  • 节点查询交给弹框默认取数(服务端分页);节点详情节点的两个入口按钮按「关联边是否全是同一种类型」 决定哪个做主按钮。
  • 踩坑与实证(已同步进 qingjian-ai-dev skill §7):
    • 弹框必须显式传 z-index=1015,否则全屏查看时会被 .ai-block-fullscreen(1010)盖住。
    • relation-graph 连线的可点元素是 .rg-line-bg(热区 lineWidth + 6px), .rg-line-peel 是 pointer-events: none;onLineClick(line, link, nativeEvent) 原生事件在第三个参数。
    • <Icon> 运行时赋类,新增 mdi:phone-outline / mdi:bank-transfer 必须进 uno safelist。
  • 验证:vue-tsc 无 src/ai/ 报错、eslint/stylelint 干净、dev server 产物 200 且 onOnLineClick 已挂上,两个新图标已在 /__uno.css 中生成。

改造:图谱边类型改为后端输出(不再猜 label)

  • 背景:之前前端按 label 关键词猜「通话 / 交易」,不准。
  • 后端:
    • 新增 common/enums/GraphEdgeType.java:CALL("call","通话") / TRANS("trans","交易") / OTHER("other","其他"), 带 from() 宽松匹配(code 或中文、大小写/空白不敏感)与 allowedCodes()。
    • GraphRenderTool.renderGraph 改为解析成 ObjectNode → 校验+规范化 → Json.toStr(root) 返回: edges[].type 必填,就地 put 成 code;缺失/非法直接报错并把允许取值写进错误信息。
    • AgentService.appendRenderPrompt 第 3 条同步补上 type 字段与「按边实际来自哪张表填、不要猜 label」的说明。
  • 前端 GraphBlock.vue:GraphEdge 加 type?;resolveEdgeKind() 先查 type(含中文别名 Map, 用 Map 避免 Record 原型链污染),查不到才退回 detectRecordKindByLabel()(只服务历史消息); other 不自动开弹框,弹兜底菜单并带说明文案;activeNodeKind 只统计 call/trans。
  • 新增测试 ai-server/src/test/java/com/zsjz/ai/module/agent/tools/GraphRenderToolTest.java(8 例全绿), 含数字 value 不被序列化成字符串的回归断言。
  • 验证:mvn -pl ai-server compile SUCCESS、-Dtest=GraphRenderToolTest 8/8 通过; 前端 vue-tsc / eslint / stylelint 干净,dev server 产物 200。

改造:render_graph 改用 AgentScope 结构化工具输入/输出

  • 背景:用户指出 AgentScope Java 2.0 支持「工具结构化输出」,应参考官方 API 直接声明结构化数据。 查了 agentscope-core-2.0.1-sources.jar(本地仓库有 sources,比翻文档快)+ 官方文档确认。
  • 关键实测结论(写进 skill §7.5):
    1. @ToolParam 参数声明成 POJO → 框架用 victools 自动生成嵌套 JSON Schema,并在调用前 用 networknt-schema 校验(required/type/嵌套),错误文案是中文且带 JSON Pointer 路径。
    2. 工具返回 String 会被 DefaultToolResultConverter 再 toJson 一次 → LLM 收到 "{\"a\":1}" 这种转义字符串字面量。返回 POJO 或 ToolResultBlock 才是干净 JSON。
    3. @Tool(strict=true) 对 OpenAI 原生要求 additionalProperties:false + 全 required,否则 400, DeepSeek/GLM/Kimi 又丢弃 strict → 不开。
    4. victools 固定配置只输出 Enum.name()(@JsonProperty/@JsonValue 都不影响), 所以 type 保持 String + 工具内归一化。
    5. 数值字段用 Number 而非 Double(否则整数变 3.0)。
  • 改动:
    • 新增 module/agent/tools/GraphSpec.java(public 字段 + @ToolParam + @JsonInclude(NON_NULL), 含 Node/Edge/Category 嵌套类),作为 render_graph 的入参契约。
    • GraphRenderTool.renderGraph 签名改为 (GraphSpec graph),返回 ToolResultBlock (成功 SUCCESS + 干净 JSON 文本,失败 ERROR + 问题清单);语义校验改为操作 POJO。
    • AgentService.appendRenderPrompt 第 3 条删掉手写结构串,改为「入参结构以该工具的 schema 为准」。
    • SqlAnalysisTool 的拒绝话术同步措辞。
    • GraphRenderToolTest 重写为 12 例:语义校验 + 输出形态(干净 JSON、NON_NULL、数字不变形)
    • 自动生成的 tool schema 契约断言(required 集合、type 为 string、描述含三个取值)。
  • 验证:mvn -pl ai-server compile BUILD SUCCESS(625 文件);-Dtest=GraphRenderToolTest 12/12 通过; 另写探针用真实 Toolkit 走通 schema 生成→校验→绑定→执行 9 个场景(含中文 type 归一化、 缺 required 被框架拦、value 传字符串被拦、旧式字符串入参被拒),结果全部符合预期。
  • 未做(已告知用户):SqlAnalysisTool 的 execute_sql / list_tables / render_chart 同样返回 String, 存在同一处二次序列化问题;因后端跑在用户 IDEA 调试实例上(8980 不能重启),未一并改动。
  • 前端无需改动:type 契约与 value 数字形态不变。

追加:全部工具返回值结构化(用户回复「要动」)

  • 新增 module/agent/tools/ToolResults.java(ok(String) / error(String)), 统一 @Tool 返回 ToolResultBlock;GraphRenderTool、SqlAnalysisTool 改用它。
  • SqlAnalysisTool:
    • execute_sql / list_tables / render_chart 返回值 String → ToolResultBlock (validateReadOnly 改为返回纯问题描述,不再返回 error JSON);
    • render_chart 的入参 String option → JsonNode option(schema {"type":"object"}), 消除「JSON 字符串套 JSON」的入参转义;containsGraphSeries 签名同步清理。
  • 其余三个工具一并改造:WorkspaceInfoTool(workspaceJson 改静态、去掉未用 error helper)、 RagSchemaSearchTool、PythonAnalysisTool。
  • WorkspaceInfoTool 里那段「把 @Tool 塞进 javadoc」的停用代码改成规整块注释, 并写清恢复步骤(缺 UserMapper/UserEntity 依赖)。
  • AgentService.appendRenderPrompt 第 2 条补「option 是结构化 JSON 对象,按 schema 传,不要传字符串」。
  • 新增 SqlAnalysisToolTest(8 例):render_chart 回吐原样 JSON / 拒绝 graph series / 拒绝空值 / option schema 为 object;execute_sql 白名单与多语句拦截 / 成功干净 JSON / 10 万行上限; list_tables 干净 JSON。用 Mockito mock mapper,@BeforeAll 里 TableInfoHelper.initTableInfo 解决 can not find lambda cache。
  • 验证:mvn -pl ai-server test -Dtest=SqlAnalysisToolTest,GraphRenderToolTest → 20/20 通过,BUILD SUCCESS。 另写全工具探针(真实 Toolkit + 真实工具类 + Mockito 依赖替身)跑通 7 个工具, 全部 state=SUCCESS 且结果文本不含转义引号。
  • 前端无需改动(SSE 只转发文本,ReActAgent.emitToolResultDelta 取 TextBlock 文本)。
  • 待用户在 IDEA 里重启后端做端到端确认:新对话里模型需按新 schema 传参(render_graph 传对象、 render_chart 传对象),旧式字符串入参会收到框架的「已找到 string,必须是 object」并自动重试。

把 call 模块 service 封装成 Agent 工具(CallAnalysisTool)

需求:把 module/call 的业务 service 暴露给 Agent,且不能影响原 HTTP 接口。 已确认的 4 个决策:1:1 细粒度 7 个工具 / 返回复用 resultId 分页契约 / 入参用专用 POJO / CallOpenInfoService(仅基类 CRUD)跳过。

新增文件:

  • module/agent/tools/ToolResultTable.java(package-private):从 SqlAnalysisTool 抽出的 列推导 / 单元格清洗(500 截断)/ 分页切片 / 首页 JSON 组装,新增 toRows(List<?>) (走全局 ObjectMapper,日期与 Long 的字符串形态与 execute_sql 一致)与 store(store, sql, records, page, pageSize) 一步出口。sql 传 null 时该键不输出 (前端 DataTableBlock 是 v-if="data.sql")。
  • module/agent/tools/CallToolSpecs.java:5 个 public 字段 POJO (CallRecordSpec / NightSpec / ContinuousSpec / SensitiveSummarySpec / SensitiveDetailSpec), 覆盖 7 个工具;不暴露 orderKey/sort/tableName。
  • module/agent/tools/CallAnalysisTool.java:7 个 @Tool (get_call_records、stat_call_night_summary/detail、stat_call_continuous_summary/detail、 stat_call_sensitive_summary/detail),只做适配、业务仍走原 service。
  • test/.../CallAnalysisToolTest.java:12 例。

修改:

  • SqlAnalysisTool:4 个私有静态方法改为委托 ToolResultTable,公开行为与 JSON 键序不变。
  • AgentService:构造器注入 4 个 call service;buildAgent 注册 CallAnalysisTool; appendRenderPrompt 第 1 条补入 7 个 call 工具名并强调「专用工具优先于手写 SQL」。
  • AI_AGENT.md:工具清单表补通话 7 件套 + 设计说明。

关键实现细节(踩坑点):

  • 单次取数上限 10,000 行(比 execute_sql 的 10 万收紧,因 SqlResultStore 是 LRU 50 进程级共享)。 取数用 limit = FETCH_LIMIT + 1 让 DB 只回上限内数据,再用 Page#getTotal() 判超限, 超限报错而不是静默截断(截断会让模型基于残缺数据下结论)。 statSecondCallNight 的 SQL 无 LIMIT,只能事后判 size。
  • 空串必须归一成 null:XML 里 personName/otherPhone(sensitive 二层、night 二层)只判 != null,传空串会拼出 = '' 恒空结果。
  • statFirstCallSensitive 的 XML 只读 keyword(otherName = keyword OR otherPhone = keyword), otherName/otherPhone 在该方法无效;二层反之。工具按此分别映射。
  • selectCallRecordPage 的 XML 只有 personPhones(List),没有单值 personPhone; 前端弹框也是把逗号串转数组传 personPhones。
  • 不暴露 duration/symbol:XML 里 symbol == 'ge' 反而走 duration <= 值(语义反转), 且前端通话记录弹框实际只用 personName/personPhone/otherName/otherPhone/callDirection/日期。
  • get_call_records 内部硬编码 orderKey=callDateTime/sort=DESC(不来自模型)。
  • GovernConfService.getCallTableName() 缓存 key 含 StateManager.getCaseId(),开/关案清缓存, 不会跨案件串表(已核实 StrConsts.cache_get_call_table_name())。
  • 工具返回的单元格统一 String.valueOf(数字也带引号,callCount → "7"),与 execute_sql 同规则。

验证:

  • mvn -pl ai-server test -Dtest=CallAnalysisToolTest,SqlAnalysisToolTest,GraphRenderToolTest → 32/32 通过(12 新 + 12 graph + 8 sql),SqlAnalysisToolTest 全绿证明抽取未破坏原契约。
  • schema 断言:7 个工具全部注册、properties.query.type == "object"、 整段 schema JSON 不含 orderKey/sort/tableName(锁死不暴露注入面)。
  • ⚠️ 8980 上的后端仍是 IDEA Debug 实例,未重启;端到端(对话触发 7 个工具 + 表格翻页) 需用户在 IDEA 重启后确认。

续:5 个业务域共 77 个工具封装完成 + 工具组按需激活

产出(全部为新增文件,未改动任何既有 service / mapper / HTTP 接口)

  • TransToolSpecs + TransAnalysisTool(19 个工具)
  • TrackToolSpecs + TrackAnalysisTool(16 个工具)
  • OtgToolSpecs + OtgAnalysisTool(5 个工具)
  • GraphToolSpecs + GraphAnalysisTool(6 个工具)
  • PersonToolSpecs + PersonAnalysisTool(24 个工具)
  • AgentToolRegistry(新增 @Component,注入 30 个业务 service,统一注册 6 个工具组)
  • AgentToolRegistryTest(6 例)
  • 改动:AgentService(构造器改为注入 AgentToolRegistry;buildAgent 调 agentToolRegistry.registerBusinessTools(toolkit) + b.enableMetaTool(true); appendRenderPrompt 第 1 条改写为「按域分组 + reset_equipped_tools 用法」)
  • 改动:AI_AGENT.md(新增「业务域工具分组」章节)

工具组机制(已用源码 + 测试双重确认,AgentScope 2.0.1)

  • 组必须是 ToolGroupScope.META,否则 reset_equipped_tools 直接拒绝 ("Error: Group 'x' is not manageable by this tool."),且组名不会进该工具 to_activate 的 enum(MetaToolFactory 用 getMetaGroupNames() 填 enum)。
  • 未激活组的工具不进 getToolSchemas(activeGroups)(ToolSchemaProvider#buildSchemas 只保留「未分组」或「在激活组里」的工具)。
  • ReActAgent 构造时 initialActiveToolGroups = List.copyOf(toolkit.getActiveGroups()), 新会话 freshState 用它填 ToolContextState.activatedGroups —— 所以「默认装备哪一组」只由 AgentToolRegistry#DEFAULT_ACTIVE_GROUPS 决定。
  • reset_equipped_tools 是全量替换语义(replaceMetaActiveGroups)。
  • 每轮请求走 toolkit.getToolSchemas(state.getToolContext().getActivatedGroups())(会话级), 但会话恢复/结束时用 toolkit.setActiveGroups/getActiveGroups(共享 Toolkit 的全局状态)—— 同一 agent 实例并发多会话理论上可能互相覆盖全局状态,已在 AI_AGENT.md 注明为已知限制。
  • 默认装备 person 组(24 个:人员枚举/画像/TOP/汇总/趋势/9 类亲密度),其余 5 组按需激活。 77 个工具若全量进 schema 约 2.5~3 万 token,分组后默认只加载 24+1 个。

各域实现要点 / 踩坑

  • 注入面:GraphService 把 startMoney/endMoney/callNum 直接拼进 SQL; TrackMeetMapper 有 INTERVAL '${query.timeInterval} minutes'; GraphCallDetailQuery.getTransAmountSymbol() 把枚举直接取成字符串拼进 SQL。 以上字段一律声明为 Number 或走 SymbolEnum 白名单转换,绝不透传模型字符串。
  • GraphQuery 必填:graph() 里 query.getPersonNames().forEach(...)(null 会 NPE), 且 type 为空直接返回空 Map;type 的交易类(101/102/103)与通话类(201/202/203) 各最多生效一个(else if 链)。工具做了人员库预检(CaseDataCache)。
  • GraphService#transDetail 在 lx ∈ {2,3} 时对 personCardNo 调 contains("|") → 必填。
  • get_case_graph 不表格化(render_graph 要的就是 {nodeList, edgeList} 结构), 且新增了 500 条边的截断保护:超限按 num 降序保留前 500 + 附 truncated/totalEdges/note(原样返回 1 万条边约 30 万 token,会冲爆上下文)。
  • TrackEnLocalService#setTime 把 nightTime/morningTime 拼成 "null:00:00" 时夜间条件恒假 → 工具层必须兜底默认值(20 / 8)。
  • TrackTogetherTravelService#statSecondTogetherTravel 遍历 query.getIds() → null 会 NPE; statThreeTogetherTravel 的 switch(type) 对 null 抛 NPE → 工具校验 type 白名单。
  • TrackTogetherLiveService#hotelStayInfoDetail 用 personBasicInfoMapper.selectById(personName) (人名当主键),togetherLiveInfoDetail 的 eq(queryName, liveWithName) 传 null 恒假 → 两者必填。
  • TrackExpressInfoService#statSecondExpressInfo 的 orderKey 直接进 OrderItem.setColumn → 不暴露;statFirstExpressInfo 是内存分页。
  • OtgAnalysisTool:TimeSeriesMapper 的 selectTimeSeriesList 硬编码 limit 20, applyFetchWindow 对它无效(工具描述已写明一次最多 20 行); rowIdSymbol 是 ${} 拼接 → 走 SymbolEnum 白名单。
  • trans 域有意跳过 TransCashFlowService#statTransCashCallBeforeAfterTimeLine (已 @Deprecated、前端无引用、gapDay 为 null 会 NPE)。

验证

  • mvn -o -pl ai-server compile → BUILD SUCCESS
  • mvn -o -pl ai-server test -Dtest='AgentToolRegistryTest,CallAnalysisToolTest,GraphRenderToolTest,SqlAnalysisToolTest' → 38/38 通过(6 + 12 + 12 + 8)
  • AgentToolRegistryTest 断言:77 个工具全部注册且不重复归组、getActiveGroups() 恰为 [person]、 空激活列表下只剩 reset_equipped_tools、激活 person 组后 call 工具不可见、 多组并集正确、6 个组名都在 to_activate 的 enum 里。
  • AiServerApplicationTests(Spring 上下文)失败:PhoneIspMapper bean 找不到 —— 与本次改动无关(PhoneIspMapper 只被 GlobalCache 用 SpringUtil.getBean 取,无注入点), 属既有问题。

⚠️ git 仓库事故(重要教训)

  • 执行 git stash push -- <AgentService.java> 时命令被 SIGTERM 中断, 触发了 git 的 gc --auto:repack 删掉了旧 pack 但没写完新 pack, 导致 .git/objects/pack/*.pack 丢失、.git/refs 目录消失、本地 5 个提交(message 都是 "1")的 对象全部不可读。工作区文件未受影响。
  • 恢复过程:mkdir .git/refs/{heads,tags,remotes} → 从 .git/logs/refs/heads/dev_1 reflog 取回旧 hash 写回 → 移除指向已丢对象的 ref → git -c gc.auto=0 -c maintenance.auto=false fetch origin --force --prune --tags 拉回远程对象 → git update-ref refs/heads/dev_1 refs/remotes/origin/dev_1 → git reset --mixed HEAD 重建 index。
  • 结果:git 可用,HEAD = 8a50fb2(origin/dev_1 的旧点)。agent 模块在 git 里变成未跟踪, 因为承载它的本地提交已丢;代码内容全在工作区,需要时重新提交即可。
  • 教训:在 IDEA 打开项目(其 Git 集成会并发操作)时,不要跑 git stash 这类会触发 gc 的命令; 必要时加 -c gc.auto=0。 本机 maven 也一样:本地仓库在 D:\soft\repository, 必须用 java -classpath <maven>/boot/plexus-classworlds-2.9.0.jar ... classworlds.launcher.Launcher 启动(直接 mvn 会报找不到 Launcher 主类)。

收尾:schema 注入面回归测试 + track 域 flatten 静默丢字段修复

新增回归测试(AgentToolRegistryTest,8 例)

  • noSchemaLeaksInjectionSurface:6 组全激活后遍历 77 份 schema,断言每份有 description(≥30 字)、 parameters.type == object、序列化 JSON 不含 "orderKey" / "sort" / "tableName" / "keyword"。
    • 第一版断言写成裸词匹配,被 sort by 之类描述文本和 search_global 自己的 keyword 业务参数(走 #{} 绑定,不是注入面)误伤,两例失败 → 改成带引号匹配 JSON key 后通过。
  • nestedSpecFieldsAreValidJsonSchemaTypes:抽查 get_call_records / get_trans_records / get_person_call_profile,确认嵌套 POJO 入参的 query 在 schema 里是 object。

修复:TrackAnalysisTool#flatten 静默丢字段(真 bug)

  • 原实现无条件 parent.remove(nestedKey) 再展开子列表。当嵌套值不是「元素为 Map 的列表」时 (如 TravelTimelineDTO.idList 是 List<String>),该字段被移除后既不展开也不回填 —— 整列静默消失,表格照渲染,测试断言写粗一点根本发现不了。
  • 修法:只有 nested instanceof List && !isEmpty && first instanceof Map 才 remove 并展开; 其它形态(字符串数组、单对象、空列表、null)原样以 prefix + key 留在父字段上。
  • 新增 TrackAnalysisToolTest(5 例):摊平 + 父字段加前缀防同名覆盖、子列表为空保父行、 非对象列表原样保留、无嵌套键的记录保留、空输入不抛异常。
  • 验证:mvn -o -pl ai-server test -Dtest='AgentToolRegistryTest,TrackAnalysisToolTest,CallAnalysisToolTest,GraphRenderToolTest,SqlAnalysisToolTest' → 45/45 通过(8 + 5 + 12 + 12 + 8),BUILD SUCCESS。

person 域审查结论

PersonToolSpecs 字段干净,orderKey/sort 只出现在注释里,无注入面泄露。

技能文档已更新

~/.workbuddy-ai/skills/qingjian-ai-dev/SKILL.md:

  • 补 flatten 的坑与修法(「只对元素是 Map 的非空列表才 remove 展开」);
  • 补全量 schema 回归测试的写法与两个误报教训(带引号匹配 key、keyword 走 #{} 不算注入面);
  • 测试命令补上 TrackAnalysisToolTest。

新增:AI 聊天界面输出「思考过程」(模型 reasoning_content)

需求:把模型的思考过程也输出到聊天界面上。

关键事实(先查清了才动手)

  • AgentScope 2.0.1 有完整思考链路:ThinkingBlock(消息块)+ ThinkingBlockStart/Delta/EndEvent (ReActAgent.ModelCallBlockLifecycle 里按 model call 发射,blockId 恒为 "thinking")。 HarnessAgent.streamEvents 直通 ReActAgent,事件不会被过滤。
  • OpenAI 兼容扩展的流式解析器会读 delta.reasoning_content 并转成 ThinkingBlock (OpenAIResponseParser:432),回传时 OpenAIMessageConverter.convertAssistantMessage 也会 把它写回 reasoningContent —— 所以走 openai: 前缀(本项目 provider=DeepSeek 就是走这条) 一样能拿到思考内容,不需要改成 deepseek: 前缀。
  • DeepSeek 官方文档:思考模式默认开启(effort=high),参数是 {"thinking":{"type":"enabled|disabled"}}, 模型名示例就是用户库里配的 deepseek-flash。⇒ 之前模型一直在吐 reasoning_content,只是后端把它丢了。
  • ModelCreationContext.enableThinking 是 AgentScope 的思考开关;DeepSeek/GLM provider 会据此 下发 thinking body param,为 null 时不下发(保持厂商默认)。

改动

后端:

  • module/agent/service/impl/AgentChatServiceImpl.java
    • 新增 ThinkingBlockStart/Delta/EndEvent 分支 → SSE thinking 帧: {type:'thinking', phase:'start'|'delta'|'end', data?}; 多段思考之间后端补 \n\n 分隔。
    • 新增 AtomicReference<StringBuilder> accumulatedThinking,随 AgentResultEvent / doOnCancel 落库。
    • 新增私有静态 helper buildThinkingMetadata(String)(→ {"thinking":"..."},空白返回 null) 与 mergeThinkingMetadata(baseJson, thinkingJson)(中断消息保留原标志位)。
    • persistAssistantMessage / persistPartialMessage 增加 thinkingJson 参数, 写入 agent_message.metadata(不新建列,jsonb 直接放)。
  • module/agent/service/AgentModelFactory.java
    • 新增 readEnableThinking(AgentModel):从 agent_model.config 读 enableThinking(true/false), 有值才 .enableThinking(...),缺省/非法 JSON 一律 null(不改既有行为)。
    • 想显式关掉思考就填 {"enableThinking": false}。

前端:

  • ai/api/types.ts:SseEventName 加 'thinking';新增 SseThinkingData;SseHandlers.onThinking。
  • ai/api/chatApi.ts:createParser 增加 case 'thinking'。
  • ai/utils/parseMessageContent.ts:新增 parseThinkingFromMetadata(raw)(安全解析 metadata)。
  • ai/store/chatStream.ts:ChatMessage 增 thinking? / thinkingActive?; onThinking 按 phase 累积;hydrateMessage 从 metadata 还原(无 thinking 时不写键); finally / stop() 复位 thinkingActive。
  • 新增 ai/components/ThinkingPanel.vue:折叠面板(与 ToolCallPanel 同交互), 思考中自动展开 + 自动滚到底 + 脉冲点,结束后自动收起(用户手动点开过则不收起), 纯空白不渲染。
  • ai/components/ChatMessageItem.vue:面板置于工具面板之上; StreamStatusBar 增加 thinkingActive prop → 阶段文案「正在深度思考」。

验证(都跑过)

  • 后端 mvn -o -pl ai-server compile BUILD SUCCESS。
  • 前端 vue-tsc(src/ai 零错误)/ eslint / stylelint 全干净;dev server(:3100)逐模块 200。
  • esbuild 打包行为断言(放 temp,已清理):
    • chatApi SSE 解析 6/6:thinking 生命周期、多段+工具/正文混排、末帧无空行、多字节跨 chunk、CRLF、无 phase。
    • chatStream store 14/14:累积、段间空行、空 delta、裸增量、hydrate 还原(含中断消息)、 无 thinking 不产生空键、非法/缺失 metadata 不抛、stop 复位。
    • 反射跑后端私有静态方法 10/10 + 8/8:metadata JSON 转义/裁剪/空白→null/merge 保留标志位、 config 的 enableThinking 解析(true/false/缺键/空/非法/字符串值)。

未做

  • 思考过程未进导出/复制(messageToMarkdown 只导正文)。
  • 后端仍跑在用户 IDEA 调试实例上,需用户在 IDEA 重启后端才能看到 SSE thinking 帧。