electric-beacon-darwin-pwxCEck4.md 21 KB

AI 数据分析应用 —— 实施完成记录

本文件由实施过程回写,记录实际落地结果与计划期的差异。 计划期版本见对话历史;本版为准。


0. 交付范围

交付物 位置
前端 AI 模块 ai-frontend/src/ai/(27 个新文件)
三个页面 对话分析 / 模型管理 / 聊天历史(一级菜单 + 3 子页)
后端补充 2 个新 Controller + 会话归属修复 + 1 个阻塞级配置修复
数据库脚本 sql/agent_chat_session_add_user_id.sql

1. 旧 AI 代码清除(已完成)

前端

  • 删除 ai-frontend/src/cms/(整个目录,11 个文件)
  • src/App.vue 重写:移除 AI 悬浮面板(2 处 Teleport、全部 ai* 状态/函数/.ai-* 样式),保留主题/水印/授权逻辑
  • src/core/store/modules/menu.json:删除「AI 助手」IFRAME 节点(id 10261)
  • package.json:移除 @jeesite/cms-lib
  • AI_AGENT.md §6:前端消费方指向 ai-frontend/src/ai/api/

后端

  • 删除 templates/chat.html、static/js/chat.js、static/js/marked.min.js、static/css/chat.css、static/css/theme.css、static/layui/
  • src/main/resources/static/ 与 templates/ 目录已清空移除

2. ★ 阻塞级修复:context-path 无法启动

问题:application.yaml 的 server.servlet.context-path: "!/js/a/" 是 Solon 时代写法(! 表示精确匹配)。 Spring Boot 的 AbstractServletWebServerFactory.setContextPath() 会调用 checkContextPath(), 实测(直接构造 TomcatServletWebServerFactory 调用 setContextPath):

REJECTED  contextPath=[!/js/a/] -> IllegalArgumentException: ContextPath must start with '/' and not end with '/'
REJECTED  contextPath=[/js/a/]  -> IllegalArgumentException: ContextPath must start with '/' and not end with '/'
ACCEPTED  contextPath=[/js/a]
ACCEPTED  contextPath=[]

即 ai-server 在 Spring Boot 下根本无法启动(Spring Boot 3.5.16,spring-boot-starter-web Servlet 栈)。

修复:context-path: /js/a(注释说明原因)。 与前端 ctxPath(/js) + adminPath(/a) 一致,也与已删除的 static/js/chat.js 使用的 /js/a/chat/sessions 一致。


3. 后端改动(mvn -pl ai-server compile 已通过)

3.1 会话归属修复

AgentChatServiceImpl.assertOwner 原先把 case_id(工作空间)与登录 userId 比较 → 传案件ID必然 403,且实体无 user_id 列。

文件 改动
sql/agent_chat_session_add_user_id.sql 新增:ADD COLUMN IF NOT EXISTS user_id BIGINT + 索引;不做数据回填(回填会把历史会话误判为他人会话,脚本内以注释给出可选语句)
entity/AgentChatSession.java 新增 @TableField("user_id") Long userId
service/impl/AgentChatServiceImpl.java createSession 写入 userId;assertOwner 改比 userId(user_id 为 NULL 的历史行宽放并告警);listSessions 追加 (user_id = ? OR user_id IS NULL);convertSessionToVO 补 userId

3.2 结果集分页接口(新增)

controller/AgentResultController.java,@RequestMapping("/chat/results")

  • GET /chat/results/{resultId}?page=&pageSize= → 裸返回 SqlResultPageVO(与 /chat/* 保持一致)
  • pageSize clamp [1,500];SqlResultStore.get 返回 null(30 分钟过期)→ ServerException(404, "查询结果不存在或已过期…")
  • 顺手修掉 SqlResultStore 的既有缺陷:put(sql, …) 收到 sql 但未存进 CachedResult,导致 PageData.sql 恒为 null(现已存储并回传)
  • 结果集归属隔离(已实现):SqlResultStore 缓存条目增加 ownerKey(登录 userId 字符串), get(resultId, page, pageSize, ownerKey) 校验归属,不匹配或未登录一律返回 null → 404。 链路:AgentChatServiceImpl.doStream 的 userId → AgentService.getOrCreateAgent → buildAgent(..., ownerKey) → SqlAnalysisTool(..., ownerKey) → SqlResultStore.put(ownerKey, ...)。 agent 实例本就按 u{userId}-m{modelId} 池化,故把 userId 绑进工具是安全的。 刻意不区分「不存在」与「无权限」,避免探测他人 resultId 是否存在。

3.3 Python 图片文件服务(新增)

controller/AgentPythonFileController.java,@RequestMapping("/py/files")

  • GET /py/files/{workspaceId}/{execId}/{filename} → ResponseEntity<Resource>
  • 三段路径均做字符白名单校验 + normalize() 后 startsWith(pyOutRoot) 双重防目录穿越
  • 按扩展名设 Content-Type,1 小时公共缓存
  • PythonExecutor.IMAGE_URL_PREFIX:/api/v1/py/files/ → /js/a/py/files/(原值无对应 Controller,属死配置)

3.4 未改动

/chat/* 继续裸返回;/models/* 继续 Result<T> 包装。


4. 前端模块结构(实际落地)

ai-frontend/src/ai/
├─ index.ts                       # 模块入口(全局样式)
├─ api/
│  ├─ types.ts                    # 全部 DTO/VO/SSE 帧类型
│  ├─ http.ts                     # aiHttp:裸响应(isTransformResponse:false) / Result 两套
│  ├─ chatApi.ts                  # 会话·消息·结果分页 + streamChat(fetch+SSE)
│  ├─ modelApi.ts                 # 模型/厂商 CRUD
│  └─ index.ts
├─ utils/
│  ├─ constants.ts                # 接口前缀/工具名映射/块类型/节流参数
│  ├─ scanner.ts                  # ★ 富内容块扫描(裸 JSON 配平 + 围栏)
│  ├─ parseMessageContent.ts      # 工具事件/表格抽取/markdown 还原/时间格式
│  ├─ reportExport.ts             # md / xlsx / pdf 导出
│  ├─ echartsSetup.ts             # 补注册 16 类图表 + CanvasRenderer
│  └─ useAiChart.ts               # 图表 composable(暗色主题 + ResizeObserver)
├─ store/chatStream.ts            # Pinia 流式状态机
├─ styles/ai.less                 # 设计 token + markdown 正文 + 布局基元(全局)
├─ components/
│  ├─ BlockRenderer.vue           # 按 block.kind 分发
│  ├─ blocks/{MarkdownBlock,CodeBlock,HtmlBlock,EChartsBlock,GraphBlock,DataTableBlock}.vue
│  ├─ ChatMessageItem.vue         # 消息外壳(角色/时间/状态/工具/块/建议/工具条)
│  ├─ ToolCallPanel.vue           # 工具调用折叠时间线
│  ├─ StreamStatusBar.vue         # 阶段 + 耗时 + 意图徽标
│  ├─ SuggestionBar.vue           # 追问建议
│  ├─ MessageToolbar.vue          # 复制/导出(md·excel·pdf)/重新生成/收藏/删除
│  ├─ SessionList.vue             # 会话侧栏(搜索/置顶/重命名/删除,行内改名)
│  ├─ ChatComposer.vue            # 输入区(模型选择/Enter 发送/停止生成)
│  └─ ModelFormModal.vue          # 模型表单弹窗
└─ views/{aiAnalysis,aiModel,aiChatHistory}/index.vue

4.1 ★ 富内容扫描(核心技术点)

后端系统提示词强制表格以「裸 JSON 混在 markdown 正文」输出(明确禁止围栏), 因此不能只按围栏切分 —— scanner.ts 实现:

  1. 行首围栏识别(/~~~,未闭合标pending)→echarts/graph/html/code`
  2. 行首裸 <html> / <svg> 文档块
  3. 字符级花括号配平扫描(字符串/转义感知)识别裸 JSON;isTablePayload 四重校验 (对象 + columns 数组 + rows 数组 + 有 resultId 或 totalRows)
  4. 流式未配平时用头部特征({"resultId" 等)提前产出 pending 表格块占位
  5. 块 key = kind:start(内容只追加 → key 稳定,DOM/图表实例不重建)

增量策略:已完成块冻结(end <= len - 8KB)+ 只重扫尾部 + 尾部块按 key 复用对象 (保证 data 引用稳定,避免图表重复 setOption、表格分页被重置)+ 100ms 节流重扫。

4.2 SSE 解析

\n\n / \r\n\r\n 分帧、多行 data: 合并、注释行忽略、TextDecoder 单例 + {stream:true}、AbortController 中断、 非 SSE 错误响应(400/403)兜底读 body 提示、credentials: 'include'(Sa-Token Cookie)。

4.3 各块组件实现要点

组件 实现
MarkdownBlock streamdown-vue 的 StreamMarkdown;流式时 parseIncompleteMarkdown 修复未闭合语法;放行 / 前缀图片(Python 图走同源 /js/a/py/files/,默认策略会丢弃)
CodeBlock 语言标签 + 复制(useClipboard),pending 提示
HtmlBlock sandbox="allow-scripts"(不给 allow-same-origin)隔离;注入高度上报脚本 + 父级校验 event.source;预览/源码/新窗口
EChartsBlock useAiChart;高度按 series 类型自适应;保存 PNG;配置 JSON 折叠
GraphBlock @relation-graph/vue 力导向;分类着色;自绘工具栏(适配画布/重新布局);节点点击出详情侧栏
DataTableBlock 首页数据随回复到达;翻页走 /chat/results/{resultId};404 → 「结果已过期」;SQL 折叠;导出本页 xlsx

4.4 报告导出(纯前端)

  • md:块反序列化还原(表格回写为 ``json),downloadByData`
  • xlsx:抽取会话/回答内全部表格块 → 多 sheet(sheet 名去重、长度截断 31)
  • pdf:克隆消息 DOM → canvas 转 PNG → 隐藏 iframe 打印(浏览器「另存为 PDF」)

5. 菜单与路由接入

  • menu.json 顶层追加一级分组 id: 10263(component: LAYOUT,redirect: /aiAnalysis/index,3 个子节点 10264/10265/10266)
  • routeHelper.ts 的 explicitDynamicViewMap 增加 3 条显式映射(与 /graph/*、/aiBaseStation/index 同法,避免与 node_modules/**/views 同名冲突)

5.1 ★ 图标白名单(必要修复)

Icon 组件是运行时拼出 i-<collection>:<name> 类名的,而 uno.config.ts 的 content.pipeline.include 只扫 **/*.{vue,tsx,ts} —— menu.json 不在扫描范围, 菜单里配置的图标不会生成 CSS(项目既有隐患)。已在 uno.config.ts 增加 safelist, 列出菜单图标与模块内动态拼接图标,保证一定渲染。


6. 验证结果

项 结果
后端 mvn -pl ai-server compile ✅ BUILD SUCCESS(14 个 MapStruct 未映射属性告警为既有问题)
前端 pnpm type:check → src/ai/** ✅ 0 错误(项目其他模块有大量既有类型错误,非本次引入)
前端 eslint src/ai/** + 改动文件 ✅ 0 问题
前端 pnpm build ✅ 成功;三页各自产出独立懒加载 chunk
运行时冒烟(dev server 逐模块请求) ✅ 3 个视图 + 15 个组件 + 3 个 utils/store 全部 200,无 transform 错误
UnoCSS 图标白名单 ✅ 产物 CSS 中 i-mdi:history 等含真实内联 SVG(--un-icon:url("data:image/svg+xml;..."))
markdown 样式 ✅ streamdown 样式已随聊天 chunk CSS 打包
menu.json / package.json JSON 合法性 ✅
旧 AI 残留(cms-lib / /src/cms / chatView / /ai/call) ✅ 无(仅 memory/计划文档中作为"已删除"的描述出现)
AI_AGENT.md 同步 ✅ 目录树 cms/→ai/;§6 控制器/工具清单纠正 + 新增"前端渲染管线";§8 新增四个坑;§11 context-path 标记为已实证修复
真实启动 ai-server ✅ Tomcat started on port 8980 (http) with context path '/js/a' + Started App in 3.719 seconds(修掉 mappers 残留后)
DB 迁移实际执行 ✅ 对 zsjz-ai 执行 user_id 加列 + 索引(additive/幂等),存量 4 会话 user_id 全 NULL、case_id 全有值
端到端对话链路 ❌ 未能完成,见 §7(开发库缺平台库结构 + 缺客户端运行时资产)
核心逻辑独立断言(196 项) ✅ 全通过 —— scanner 56 / SSE 25 / chatStream store 68 / reportExport 19 / SqlResultStore 28,并挖出 2 个真实 bug(见 §6.8)

6.5 ★★ 真实启动后端时发现的两个阻塞(重要)

阻塞 A:mappers XML 全量残留 com.qingjian 包名 —— 已修

ai-server/src/main/resources/mappers/**/*.xml 共 85 个文件全部是 com.qingjian.* 命名空间, 而 Java 侧 0 处引用 com.qingjian,同时有 85 个 com.zsjz.* Mapper 接口与之一一对应 → 包名从 com.qingjian 迁到 com.zsjz.ai 时漏改了 XML。

后果:MyBatis 启动即炸,ai-server 在本仓库完全无法启动:

Failed to parse mapping resource: 'mappers/ai/SkillMapper.xml'
  Caused by: Could not resolve type alias 'com.qingjian.common.model.ai.dto.AiStatCallFrequencyTop10'

处理(脚本分析后机械执行):

  • 144 处唯一 com.qingjian 引用中 130 处可映射到 com.zsjz.ai.*(目标类均已验证存在);
  • 14 处不可映射的全部落在 mappers/ai/(6 个不存在的 DTO + 8 个不存在的 Mapper)—— 该目录属 QingJian 旧 AI 模块,现 AI 能力在 module/agent(MyBatis-Plus BaseMapper,无需 XML)。
  • 故:删除 mappers/ai/(8 个文件) + 77 个文件、160 处 com.qingjian → com.zsjz.ai。
  • 所有文件改动前均在 git 跟踪且无本地修改 → 可 git checkout 完全回退。

修复后 mvn -pl ai-server clean compile BUILD SUCCESS,且应用成功启动。

阻塞 B:开发库缺平台库结构 + 缺客户端运行时资产 —— 未修(属环境)

应用启动后,AppLoadEndEventListener(CommandLineRunner)执行缓存预热时失败:

PSQLException: 关系 "table_info" 不存在

库结构勘查结果(本机无 psql,用 JDBC 小程序扫):

库 public 表数 table_info/table_field agent_*
zsjz-ai(application-dev.yaml 指向它) 14 无 有
etl-2 21 有 无
lightbot 48 无 无
testai / etl / etl-1 2 / 0 / 0 无 无

没有任何库同时具备两者 —— 平台库结构在 etl-2,agent 结构在 zsjz-ai。

此外该启动链还依赖本地运行时资产:GlobalCache 需 table_info+table_field、 CaseDataCache 需 person_lib_no、initCaseRocksDbData() 需 RocksDB 目录、 TowerUtils.init() 需基站库文件,而 PathConst.ROOT_PATH = user.dir ⇒ ai-server 是按"客户端应用"设计的,裸跑仓库目录跑不通。

未擅自改共享库(补齐平台库结构属于超出本次任务范围的改动)。 完整端到端联调需先补齐库结构与客户端运行时资产。

6.6 由联调反查出的 3 个前端真实缺陷 —— 已修

# 缺陷 修正
1 模型表单把 type 当厂商协议,选项写成 openai/dashscope/ollama… agent_model.type 是用途类型 llm/embedding;厂商协议在 agent_model_provider.type。选项已改为 llm/embedding
2 状态按 '0' 判断停用 实际是 StatusEnum:ACTIVE/SUSPENDED/ARCHIVED/DISABLED/DELETED,已改为枚举映射 + 配色
3 聊天模型选择器会把 embedding 模型列出来(库里确实有一条),选中必然失败 过滤 type === 'llm',默认模型选取同步过滤;另加"向量模型不能设为默认"的前端拦截提示(后端 getDefaultModelId() 不区分 type,是待改进点)

6.7 已执行的数据库变更(唯一一处写共享库)

对 zsjz-ai 执行 sql/agent_chat_session_add_user_id.sql:ADD COLUMN IF NOT EXISTS user_id BIGINT

  • COMMENT + CREATE INDEX IF NOT EXISTS idx_agent_chat_session_user_case。均为 additive / 幂等。

执行前该列与索引都不存在;执行后存量数据:4 个会话,user_id 全为 NULL、case_id 全有值 → 正好印证了"NULL 宽放"的设计必要性(若回填 case_id,这些历史会话会被判成他人会话而全部 403)。


6.8 ★ 核心逻辑独立验证:196 项断言,挖出 2 个真实 bug

项目没有测试框架(无 vitest/jest),但 esbuild 是 devDependency,足够做行为断言: 用 --alias(CLI)或 JS API 的 alias + onResolve 插件把依赖 import.meta.env / axios 的模块 替换为桩,打包成 CJS 后在 Node 里跑断言;后端则直接 java -cp target/classes 跑 main (无需 Spring / DB)。Pinia 在 Node 下用 setActivePinia(createPinia()) 即可运行。

覆盖范围(196 项)

被测对象 断言数 覆盖内容
utils/scanner.ts 56 围栏(``/~~~/ 四个反引号 / 未闭合→pending)、**裸 JSON 表格识别**(字符串内花括号、多表格、未配平→pending)、HTML/SVG 块、isTablePayload/findJsonEnd、增量扫描(冻结块复用同一对象引用、key 稳定、data` 引用稳定、短内容回退全量、流式表格组装)、导出还原
api/chatApi.ts 的 SSE 解析 25 mock globalThis.fetch + ReadableStream:跨 chunk 分帧、中文/emoji 多字节被切断、\r\n、多行 data、8 种事件、error 帧、非 SSE 错误响应、末帧无结尾空行、请求体与凭据、Abort 语义
store/chatStream.ts 流式状态机 68 init 幂等、自动建会话 + 乐观插入 + 流式中状态、token 累积、节流 100ms 中途解析、工具步骤按 toolCallId 打补丁(两个并发调用互不串)、intent/suggestions、会话元信息本地更新、错误帧/传输异常/已有内容不被错误覆盖、stop() 触发 abort 与占位文案、会话 CRUD、selectSession 反序列化 toolEvents、本地消息不调接口、reset、无工作空间不发送
utils/reportExport.ts 19 单表 sheet 结构与表头(用 columns.label)、多表分 sheet、sheet 名唯一/带序号/≤31 字符/不含 Excel 非法字符、无表格返回 null、图表不算表格、缺列定义按行键推断、空行集、撞名去重、空值与缺键容错
SqlResultStore(后端) 28 ownerKey 归属隔离(本人可读 / 他人与未登录不可读 / 不泄露存在性 / ownerKey=null 历史结果宽放)、sql 回传、分页 clamp(超界 / 0 / 负数)、自定义 pageSize、空结果、LRU 淘汰、并发(小规模正确性 + 高竞争下隔离不变量)

挖出并修掉的真实 bug

  1. scanner.ts:围栏正文多带一个结尾换行 —— 代码块会多出一个空行,导出/复制也带上多余换行。 已改为剥离正文末尾的一个 \n / \r\n。
  2. chatApi.ts:末帧没有结尾空行时整帧被丢弃 —— SSE 规范允许最后一帧不带空行 (连接正常关闭即可),而原 createParser 只按 \n\n 分帧、EOF 时没有冲刷缓冲区, 会丢掉 token 文本或 done 事件。已改为返回 { push, flush },读取循环结束后 parse.push(decoder.decode()) + parse.flush()。

附带说明:验证过程中我自己的测试用例也错了 7 处(分页把第 2 页当第 1 页断言、 并发用例 1600 次 put 必然触发 LRU 淘汰、多行 data 切在非法 JSON 位置、 mock 流瞬间完成导致观察不到"流式中"状态、api.calls[0] 取错调用、等等), 修正用例后全部通过 —— 这些是用例问题,不是代码缺陷。


7. 已知限制与后续项

  1. katex 样式未引入:streamdown-vue 的数学公式依赖 katex/dist/katex.min.css, 但 katex 未在本项目 package.json 声明(仅存在于 pnpm 虚拟存储),顶层无法解析。 当前 AI 数据分析输出基本不用 LaTeX,公式会以未加样式形态展示。 如需完整数学渲染:pnpm add katex@0.16.27 后在 src/main.ts 引入其 CSS。
  2. /chat/results/{resultId} 未做用户级隔离 —— 已实现(见 §3.2 结果集归属隔离)。
  3. 历史会话归属:user_id 为 NULL 的存量会话在 assertOwner 中"宽放"(仅告警)。 确认 case_id 与登录 userId 同源后可执行脚本注释里的回填语句。
  4. PDF 为浏览器打印方案:不引入 jspdf 等新依赖,用户需在打印面板选择「另存为 PDF」。
  5. 表格「导出本页」只导出当前页(仅当前页数据在前端),按钮文案已如实标注。
  6. agent_chat_session / agent_message 未登记在元数据三表;graph 块选用 relation-graph(未用 G6)。
  7. 端到端对话链路未跑通:已验证到「应用能启动 + context path 正确 + MyBatis 映射全部解析」, 但 AppLoadEndEventListener 因开发库缺平台库结构(table_info)而失败退出(详见 §6.5 阻塞 B)。 要完成端到端,需先:① 把平台库结构(table_info/table_field/person_lib_no 等)补到 zsjz-ai, 或把 agent 结构合并到 etl-2 并改数据源;② 备齐客户端运行时资产(RocksDB 目录、基站库文件)。 之后按 §6 验收清单走一遍真实对话(SSE 流式、表格翻页、图谱、导出)。
  8. application.yaml 的 type-aliases-package: com.qingjian.ai.server.model.*.entity.* 仍指向不存在的包 (当前无影响:XML 全用全限定名)。若要启用别名,需先确认 com.zsjz.ai.common.model.*.entity 下无重名简单类名,否则 MyBatis 会因别名冲突启动失败。