plan-sess_fbc85caf-ba9c-45c6-b871-268c8ad99fcb.md 9.4 KB

AI 聊天「产物输出」设计方案(右侧栏 + CSV 全量落盘)

一、结论先行:业内做法 + 我们的取舍

调研了 Claude Artifacts、ChatGPT Canvas、Manus、Dify/扣子、LibreChat/open-webui 后,业内共识 5 条,全部落到本方案:

  1. 预览限量、下载全量(Claude/数据分析类产品一致):面板和消息里只渲染前 N 行,完整数据走服务端文件流式下载——这正好解决"不限条数"和"不爆内存/不爆 token"的矛盾。
  2. 文件是独立对象,聊天里只传引用(Dify file 变量、扣子 file_id):工具结果 JSON 里只带 artifact 元数据(id/行数/大小/列名),不把数据本体塞进 SSE 和模型上下文。
  3. 产物挂在会话/消息上,右栏聚合展示(Claude Artifacts 侧栏),点击可定位到产生它的消息。
  4. 面板可收起/可拖宽(Claude/Canvas 共性),首次产出时自动展开。
  5. 下载走鉴权请求而非裸链接——本项目 sa-token 只从 x-token 请求头读,<a href> 直链必然 401,必须 fetch+blob。

你已确认的两个取舍:每次表格类工具结果都自动生成 CSV;右栏仅展示当前会话产物。

二、现状关键事实(探索结论)

  • 所有表格类工具结果都收口在两处:ToolResultTable.store()(业务分析工具共用)和 SqlAnalysisTool.executeSql()(execute_sql)→ 落盘只改这两处即可覆盖全部工具。
  • 目前 4 层截断:单元格 500 字符(ToolResultTable.CELL_LIMIT,且污染缓存全量行)、工具行数上限(10k/100k 超了直接报错)、SSE 单帧 100k 字符、框架 eviction 只给模型留 2k 预览。要"全量输出",必须在进模型/SSE 之前就把数据写文件。
  • agent_message.tool_events(jsonb)持久化未截断的工具结果,且前端 hydrateMessage 已还原成 toolSteps → 产物元数据零新增协议、零新表即可随消息持久化与刷新恢复。
  • 工具执行时 CaseContextHolder.get() 可拿到 caseId(SqlResultStore 已在用);workspaceId == caseId,Python 沙箱已有"落盘 + 白名单下载接口"成熟样例(AgentPythonFileController)。
  • 前端 awb-body 只有左栏+中栏,右栏是空白插入点;折叠+拖宽面板在 cleaning.vue 有现成模式;codex 产物栏的列表/预览 UI 可参考(其数据层是 Electron IPC,不可复用)。

三、架构设计

数据流(一次工具调用 → 产物)

工具执行(execute_sql / 业务分析工具)
  ├─ 全量行 ──stream──▶ ArtifactCsvWriter ──▶ QingJian/workspace/{caseId}/artifacts/{uuid}_{tool}_{ts}.csv
  │                      (UTF-8 BOM + 表头,逐行刷写,不截断单元格,无行数上限)
  ├─ 预览行(≤10万,沿用现状)─▶ SqlResultStore(聊天内表格分页不变)
  └─ 工具返回 JSON 首部加 "artifact": {id, fileName, totalRows, sizeBytes, columns, previewRows}
        │
        ├─▶ SSE tool_result 帧(体量小,不受 100k 截断影响)→ 前端即时上屏
        └─▶ agent_message.tool_events 持久化 → 刷新后 hydrateMessage 还原

关键点:artifact 字段放在工具结果 JSON 的最前面(紧跟 resultId),这样即便大页结果触发 SSE 100k 截断,产物元数据也一定存活。

后端改动(ai-server)

# 文件 改动
1 新 agent/artifact/ArtifactCsvWriter.java commons-csv(项目已有,CleanTask 在用)逐行写:UTF-8 带 BOM(Excel 打开中文不乱码)、首行表头、每 1000 行 flush、返回 {rows, bytes}。不做 500 字符截断——原始值全量入文件
2 新 agent/artifact/ArtifactService.java 目录 QingJian/workspace/{caseId}/artifacts/(caseId 取 CaseContextHolder);uuid 命名;写完顺带清理 7 天前旧文件(照抄 PythonExecutor 的清理范式);preview(id, limit)(流式读前 N 条记录返回 JSON)、open(id)(路径 normalize + 必须仍在 artifacts 根内,防穿越)
3 新 agent/controller/AgentArtifactController.java GET /agent/artifact/preview?id=&limit=(JSON:columns/rows/totalRows)、GET /agent/artifact/download?id=(text/csv + Content-Disposition)。不进白名单,走 sa-token header 鉴权,跨案件访问按 SqlResultStore 范式拒绝
4 tools/ToolResultTable.java store() 增加导出:原始行→CSV,sanitize 后的行→SqlResultStore(预览),返回 JSON 前插 artifact 字段。签名不变(caseId 自取),各业务工具零改动即可受益
5 tools/SqlAnalysisTool.java 删除 MAX_TOTAL_ROWS>10万就报错:新增 MyBatis 流式查询重载(ResultHandler),边流边写 CSV,内存只留前 10 万行做预览分页;总行数 >预览时结果里标 previewTruncated:true,聊天表格照常翻页,完整数据在产物里。更新 @Tool description(去掉"不能超过 10 万行",改为"完整结果自动导出 CSV 产物")
6 tools/Call/Trans/Track/Person/FileAnalysisTool 去掉 FETCH_LIMIT+1 探测报错(10k/5k 上限),全量交给 store() 落盘;留一个配置保险丝 artifact.max-rows(默认 0=不限制,满足你"不限条数"的要求,极端情况可运维侧兜底)
7 AgentService / AgentToolRegistry 注入 ArtifactService 给上述工具

不做的事(有意为之):不新建 artifact 表——你选了"仅当前会话",元数据放 tool_events 已足够且与消息天然一致,免 DDL 迁移;跨会话"全部产物"视图以后要做时再补表。不改 SSE 协议(无新事件类型,三处协议文件都不用动)。

前端改动(ai-electron/frontend)

# 文件 改动
8 ai/api/types.ts 工具结果载荷类型加 artifact?: ArtifactMeta(id/fileName/totalRows/sizeBytes/columns/previewRows)
9 新 ai/utils/artifacts.ts extractArtifacts(messages):从 message.toolSteps[].result 解析 artifact(防御式,解析失败跳过)——用 computed 从 messages 派生,实时(tool_result 到达)与刷新恢复共用一条路径,无状态同步问题;downloadArtifact():fetch 带 x-token → blob → 复用 core/utils/file/download.ts 的 saveBlobResponse
10 新 ai/components/ArtifactPanel.vue 右栏主体:头部「产物 N」+ 收起按钮;空态文案"查询结果会自动导出为 CSV 出现在这里";列表行 = CSV 图标 + 文件名 + N 行 · 大小 + hover 操作(预览/下载/定位到消息),最新一条带「新」角标;点击行定位回产生它的消息
11 ai/views/aiWorkbench/index.vue .awb-body 内 awb-view 之后插 <ArtifactPanel v-if="view==='chat'">;可收起:收起后右缘留一条 6px 触发条(带产物数角标,悬停/点击展开),首次产出产物时自动展开;可拖宽:照抄 cleaning.vue 的 panel-resizer 模式,宽 340(min 280 / max 560)存 localStorage
12 ai/components/ChatMessageItem.vue 消息尾部加产物行(面板收起时也能看到):文件卡片 + 下载/预览按钮
13 新 ai/components/ArtifactPreviewModal.vue 预览弹窗:调 preview 接口取前 200 行 → a-table 展示 + 表头;底部「共 N 行」+「下载完整 CSV」。参考 codex 预览弹窗布局但独立实现(不引 codex 主题)
14 ai/styles/ai.less 或组件 scoped .awb-aside 用 --ai-* token:flex:0 0 auto + 左边框 + 自滚动(骨架照抄 .wb-aside)

四、实施步骤(建议顺序,每步可独立验证)

  1. 后端核心:ArtifactCsvWriter + ArtifactService + Controller + ToolResultTable.store() 收口 → 单测(表头/逗号换行引号转义/BOM/全量行数/跨案件 404/路径穿越)。
  2. execute_sql 全量:流式查询重载 + 放开 10 万报错 + 工具描述更新 → 用 38263 行的通话表实测 CSV 行数 = 38263+表头。
  3. 业务工具放开:5 个分析工具去 FETCH_LIMIT 报错 + artifact.max-rows 配置。
  4. 前端右栏:types + extract + ArtifactPanel + 折叠/拖宽/自动展开。
  5. 前端交互:消息产物卡片 + 预览弹窗 + 下载。
  6. 端到端验证(浏览器):提问触发大查询 → 右栏出现产物 → 折叠/展开/拖宽 → 预览前 200 行 → 下载全量 CSV(Excel 打开中文表头正常)→ 刷新页面产物仍在 → 点击定位回消息 → 收起状态刷新后保持。

五、验证标准(验收口径)

  • CSV:带表头、UTF-8 BOM、行数 = 查询全量(不截断单元格)、含逗号/换行/引号的值正确转义。
  • 面板:在右侧、可收起可展开、可拖宽、宽度与收起状态刷新后保持、空态有引导。
  • 一致性:实时流、刷新恢复、历史会话打开,三处看到的产物列表一致。
  • 安全:跨案件访问产物 404;路径穿越拒绝;下载必须带 x-token。
  • 不回归:聊天内 DataTableBlock 分页、工具过程时间线、SSE 协议(无新事件)全部原样。

六、已知边界(写进代码注释)

  • 单次工具调用有 5 分钟上限(AgentScopeToolCallback),超大导出受此约束;预览内存上限 10 万行(可配),文件本身无上限。
  • 框架 ToolResultEvictionMiddleware 只裁模型上下文,不影响文件——这正是"全量落盘、小载荷回模型"设计要利用的机制。