本文件由实施过程回写,记录实际落地结果与计划期的差异。 计划期版本见对话历史;本版为准。
| 交付物 | 位置 |
|---|---|
| 前端 AI 模块 | ai-frontend/src/ai/(27 个新文件) |
| 三个页面 | 对话分析 / 模型管理 / 聊天历史(一级菜单 + 3 子页) |
| 后端补充 | 2 个新 Controller + 会话归属修复 + 1 个阻塞级配置修复 |
| 数据库脚本 | sql/agent_chat_session_add_user_id.sql |
前端
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-libAI_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/ 目录已清空移除问题: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 一致。
mvn -pl ai-server compile 已通过)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 |
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 是否存在。controller/AgentPythonFileController.java,@RequestMapping("/py/files")
GET /py/files/{workspaceId}/{execId}/{filename} → ResponseEntity<Resource>normalize() 后 startsWith(pyOutRoot) 双重防目录穿越PythonExecutor.IMAGE_URL_PREFIX:/api/v1/py/files/ → /js/a/py/files/(原值无对应 Controller,属死配置)/chat/* 继续裸返回;/models/* 继续 Result<T> 包装。
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
后端系统提示词强制表格以「裸 JSON 混在 markdown 正文」输出(明确禁止围栏),
因此不能只按围栏切分 —— scanner.ts 实现:
/~~~,未闭合标pending)→echarts/graph/html/code`<html> / <svg> 文档块isTablePayload 四重校验
(对象 + columns 数组 + rows 数组 + 有 resultId 或 totalRows){"resultId" 等)提前产出 pending 表格块占位kind:start(内容只追加 → key 稳定,DOM/图表实例不重建)增量策略:已完成块冻结(end <= len - 8KB)+ 只重扫尾部 + 尾部块按 key 复用对象
(保证 data 引用稳定,避免图表重复 setOption、表格分页被重置)+ 100ms 节流重扫。
\n\n / \r\n\r\n 分帧、多行 data: 合并、注释行忽略、TextDecoder 单例 + {stream:true}、AbortController 中断、
非 SSE 错误响应(400/403)兜底读 body 提示、credentials: 'include'(Sa-Token Cookie)。
| 组件 | 实现 |
|---|---|
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 |
json),downloadByData`menu.json 顶层追加一级分组 id: 10263(component: LAYOUT,redirect: /aiAnalysis/index,3 个子节点 10264/10265/10266)routeHelper.ts 的 explicitDynamicViewMap 增加 3 条显式映射(与 /graph/*、/aiBaseStation/index 同法,避免与 node_modules/**/views 同名冲突)Icon 组件是运行时拼出 i-<collection>:<name> 类名的,而 uno.config.ts 的
content.pipeline.include 只扫 **/*.{vue,tsx,ts} —— menu.json 不在扫描范围,
菜单里配置的图标不会生成 CSS(项目既有隐患)。已在 uno.config.ts 增加 safelist,
列出菜单图标与模块内动态拼接图标,保证一定渲染。
| 项 | 结果 |
|---|---|
后端 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) |
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'
处理(脚本分析后机械执行):
com.qingjian 引用中 130 处可映射到 com.zsjz.ai.*(目标类均已验证存在);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 checkout 完全回退。修复后 mvn -pl ai-server clean compile BUILD SUCCESS,且应用成功启动。
应用启动后,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 是按"客户端应用"设计的,裸跑仓库目录跑不通。
未擅自改共享库(补齐平台库结构属于超出本次任务范围的改动)。 完整端到端联调需先补齐库结构与客户端运行时资产。
| # | 缺陷 | 修正 |
|---|---|---|
| 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,是待改进点) |
对 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)。
项目没有测试框架(无 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()) 即可运行。
| 被测对象 | 断言数 | 覆盖内容 |
|---|---|---|
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 淘汰、并发(小规模正确性 + 高竞争下隔离不变量) |
scanner.ts:围栏正文多带一个结尾换行 —— 代码块会多出一个空行,导出/复制也带上多余换行。
已改为剥离正文末尾的一个 \n / \r\n。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]取错调用、等等), 修正用例后全部通过 —— 这些是用例问题,不是代码缺陷。
streamdown-vue 的数学公式依赖 katex/dist/katex.min.css,
但 katex 未在本项目 package.json 声明(仅存在于 pnpm 虚拟存储),顶层无法解析。
当前 AI 数据分析输出基本不用 LaTeX,公式会以未加样式形态展示。
如需完整数学渲染:pnpm add katex@0.16.27 后在 src/main.ts 引入其 CSS。/chat/results/{resultId} 未做用户级隔离user_id 为 NULL 的存量会话在 assertOwner 中"宽放"(仅告警)。
确认 case_id 与登录 userId 同源后可执行脚本注释里的回填语句。agent_chat_session / agent_message 未登记在元数据三表;graph 块选用 relation-graph(未用 G6)。AppLoadEndEventListener 因开发库缺平台库结构(table_info)而失败退出(详见 §6.5 阻塞 B)。
要完成端到端,需先:① 把平台库结构(table_info/table_field/person_lib_no 等)补到 zsjz-ai,
或把 agent 结构合并到 etl-2 并改数据源;② 备齐客户端运行时资产(RocksDB 目录、基站库文件)。
之后按 §6 验收清单走一遍真实对话(SSE 流式、表格翻页、图谱、导出)。application.yaml 的 type-aliases-package: com.qingjian.ai.server.model.*.entity.* 仍指向不存在的包
(当前无影响:XML 全用全限定名)。若要启用别名,需先确认 com.zsjz.ai.common.model.*.entity
下无重名简单类名,否则 MyBatis 会因别名冲突启动失败。