# 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` - 三段路径均做字符白名单校验 + `normalize()` 后 `startsWith(pyOutRoot)` 双重防目录穿越 - 按扩展名设 Content-Type,1 小时公共缓存 - `PythonExecutor.IMAGE_URL_PREFIX`:`/api/v1/py/files/` → **`/js/a/py/files/`**(原值无对应 Controller,属死配置) ### 3.4 未改动 `/chat/*` 继续裸返回;`/models/*` 继续 `Result` 包装。 --- ## 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. 行首裸 `` / `` 文档块 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-:` 类名的,而 `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 会因别名冲突启动失败。