cc 1 hafta önce
ebeveyn
işleme
213d33000f
24 değiştirilmiş dosya ile 490 ekleme ve 272 silme
  1. 3 0
      .gitignore
  2. 73 80
      .zcode/plans/plan-sess_fbc85caf-ba9c-45c6-b871-268c8ad99fcb.md
  3. 27 0
      ai-electron/frontend/src/ai/api/types.ts
  4. 31 1
      ai-electron/frontend/src/ai/components/ChatComposer.vue
  5. 4 0
      ai-electron/frontend/src/ai/components/ChatPanel.vue
  6. 29 2
      ai-electron/frontend/src/ai/composables/useAiChatPage.ts
  7. 24 3
      ai-electron/frontend/src/ai/store/chatStream.ts
  8. 22 0
      ai-electron/frontend/src/ai/utils/constants.ts
  9. 24 9
      ai-electron/frontend/src/ai/views/aiWorkbench/index.vue
  10. 0 48
      ai-server/.agentscope/users/1/workspaces/5/agents/1/default/MEMORY.md
  11. 0 7
      ai-server/.agentscope/users/1/workspaces/5/agents/1/default/agents/数刃/sessions/4311687975235309032.jsonl
  12. 0 7
      ai-server/.agentscope/users/1/workspaces/5/agents/1/default/agents/数刃/sessions/4311687975235309032.log.jsonl
  13. 0 9
      ai-server/.agentscope/users/1/workspaces/5/agents/1/default/agents/数刃/sessions/sessions.json
  14. 0 7
      ai-server/.agentscope/users/1/workspaces/5/agents/1/default/default/数刃/4311687975235309032/events/0-24-2fd70198.jsonl
  15. 0 2
      ai-server/.agentscope/users/1/workspaces/5/agents/1/default/default/数刃/4311687975235309032/events/25-38-dfee7e74.jsonl
  16. 0 14
      ai-server/.agentscope/users/1/workspaces/5/agents/1/default/default/数刃/4311687975235309032/events/39-52-7e107a0d.jsonl
  17. 0 14
      ai-server/.agentscope/users/1/workspaces/5/agents/1/default/default/数刃/4311687975235309032/events/53-66-0088fd5c.jsonl
  18. 0 5
      ai-server/.agentscope/users/1/workspaces/5/agents/1/default/default/数刃/4311687975235309032/events/67-72-32dc097d.jsonl
  19. 0 2
      ai-server/.agentscope/users/1/workspaces/5/agents/1/default/default/数刃/4311687975235309032/events/73-81-82ba4c12.jsonl
  20. 0 1
      ai-server/.agentscope/users/1/workspaces/5/agents/1/default/memory/.consolidation_state
  21. 0 38
      ai-server/.agentscope/users/1/workspaces/5/agents/1/default/memory/2026-09-28.md
  22. 13 0
      ai-server/src/main/java/com/zsjz/ai/module/agent/entity/AgentEntity.java
  23. 188 2
      ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentService.java
  24. 52 21
      ai-server/src/main/java/com/zsjz/ai/module/agent/tools/AgentToolRegistry.java

+ 3 - 0
.gitignore

@@ -33,3 +33,6 @@ nbdist/
 /.d89af0ca/agent/sessions/
 /logs/
 /QingJian/
+/ai-server/.agentscope/
+/ai-server/data/
+/ai-server/QingJian/

+ 73 - 80
.zcode/plans/plan-sess_fbc85caf-ba9c-45c6-b871-268c8ad99fcb.md

@@ -1,86 +1,79 @@
-# AI 聊天「产物输出」设计方案(右侧栏 + CSV 全量落盘)
+# 「专家页面」设计与实施计划 v2(含技能、工具配置)
 
-## 一、结论先行:业内做法 + 我们的取舍
+## 一、已探明的关键事实
 
-调研了 Claude Artifacts、ChatGPT Canvas、Manus、Dify/扣子、LibreChat/open-webui 后,业内共识 5 条,全部落到本方案:
+- `agent` 表实体有 `skills_allow_json` / `tools_allow_json` / `skills_deny_json` 字段但**全部无人消费**——正是为本需求预留的;缺的只有 `user_id`(隔离)与 `is_builtin`(内置标记)。
+- 智能体**没有任何 CRUD 接口**,照抄 `/models` 模板(AgentModelController + Result 包装 + 前端 modelApi)。
+- 会话绑定天然满足:`agent_chat_session.agent_id` 创建时写入(兜底默认 row_id=1),后端无任何修改通道 →「绑定后不能更换」。
+- **技能机制**(关键):技能=磁盘上含 `SKILL.md` 的目录,装在 `.agentscope/users/{userId}/skills`(天然按用户隔离);运行时模型只见「名称+描述」目录、正文用 `load_skill_through_path` 按需加载;**`b.skillFilter(SkillFilter.only(名…))` 就是 builder 级按名白名单**,与现有「用户停用技能」overlay 正确叠加。候选技能列表接口 `GET /capability/skills` 已有(前端 capabilityApi.listSkills ✓)。
+- **工具机制**:`AgentToolRegistry.registerBusinessTools(toolkit, activeGroups)` 只控制**初始装备**,6 个组全部注册、模型可用 `reset_equipped_tools` 随时换组 → 不构成限制。要做真限制需「按组注册」:只注册专家启用的组(组工具不注册=模型不可见、切不过去)。基础设施工具(execute_sql/render_chart/render_graph 等)始终可用,不纳入配置(非目标)。
+- 组元数据已有:`GROUP_LABELS(groupLabel)`、组常量 6 个(person/call/trans/track/graph/file,otg 未注册)、`GET /capability/tools` 每条带 groupCode/groupLabel/groupDescription/defaultActive → **前端工具组勾选无需新接口**。
+- ⚠️ AgentService 实例池无失效机制:任何专家配置变更必须 `evictAgentPool(rowId)`。
+- 表 DDL 不在仓库(无 Flyway):新列用手写幂等 SQL 放 sql/,用 jshell+postgres 驱动执行。
 
-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 还原
+### SQL `sql/agent_expert.sql`(幂等)
+```sql
+ALTER TABLE agent ADD COLUMN IF NOT EXISTS user_id BIGINT;
+ALTER TABLE agent ADD COLUMN IF NOT EXISTS is_builtin SMALLINT NOT NULL DEFAULT 0;
+CREATE INDEX IF NOT EXISTS idx_agent_user_id ON agent(user_id);
+UPDATE agent SET is_builtin = 1, user_id = COALESCE(user_id, 1) WHERE user_id IS NULL;  -- 存量→内置
 ```
-
-**关键点**: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` 只裁模型上下文,不影响文件——这正是"全量落盘、小载荷回模型"设计要利用的机制。
+(`skills_allow_json`/`tools_allow_json` 列按实体已存在,脚本里附注释核对:`ALTER TABLE agent ADD COLUMN IF NOT EXISTS skills_allow_json TEXT;` 同 tools_allow_json 兜底)
+
+### 接口 `AgentExpertController`(`/agents`,Result 包装,仿 /models)
+| 方法 | 语义 |
+|---|---|
+| `GET /agents` | 列表:`is_builtin=1 OR user_id=me`;VO 带 `builtin`/`mine` + `skillNames[]` + `toolGroups[]` |
+| `POST /agents` | 新建(user_id=me, is_builtin=0);body=SaveExpertDTO |
+| `PUT /agents/{rowId}` | 编辑;**内置拒绝**(400「内置专家不可编辑」);校验归属 |
+| `DELETE /agents/{rowId}` | 删除;**内置拒绝**(400「内置专家不可删除」);仅物理删自建 |
+
+- **SaveExpertDTO**:name(@NotBlank ≤100)、description(≤500)、sysPrompt(@NotBlank)、`skillNames: List<String>`、`toolGroups: List<String>`(校验 toolGroups ⊆ 6 个已注册组名;skillNames 校验存在于用户技能目录,复用 SkillStore.resolveDir)。
+- userId 由 controller 取 `StpUtil` 后传 service(便于单测)。
+
+### AgentService 改动
+1. `buildAgent`:读 `entry.getToolsAllowJson()` → 新增 `registerBusinessTools(toolkit, enabledGroups, activeGroups)` 重载(**只注册** enabledGroups 的工具 + createGroup,active=enabled;enabled=null 走旧全注册路径,InsightAgentFactory/MCP/能力枚举零改动);读 `entry.getSkillsAllowJson()` 非空 → `b.skillFilter(SkillFilter.only(名…))`。
+2. 新增 `evictAgentPool(Long agentRowId)`:`agentPool.keySet().removeIf(k -> k.contains("-a" + rowId + "-"))`;create/update/delete 专家后调用。
+3. `getOrCreateAgent` 加 agent==null 兜底 → 404「智能体不存在或已删除」(被删专家的旧会话再对话得到明确报错)。
+
+## 三、前端(src/ai)
+
+| 文件 | 内容 |
+|---|---|
+| 新 `api/agentApi.ts` | listExperts/createExpert/updateExpert/deleteExpert(Result 族,仿 modelApi;雪花 ID 一律字符串) |
+| `api/types.ts` | +`AiExpert`(含 builtin/mine/skillNames/toolGroups)、`SaveExpertParams` |
+| 新 `views/aiExpert/index.vue` | 卡片网格页(照 aiModel 骨架:ai-page 壳 + grid + a-spin + 空态 + Modal.confirm 删除);两个分组:**内置专家**(只读卡片:去对话)/**我的专家**(去对话、编辑、删除);删除确认提示「绑定它的会话将无法继续对话」 |
+| 新 `components/AgentFormModal.vue` | 新建/编辑弹窗(照 ModelFormModal):名称(必填)、描述、系统提示词(必填 textarea)、**工具**(6 业务组勾选:从 `capabilityApi.listTools()` 按 groupCode 聚合,显示中文名/描述/数量;默认勾 person)、**技能**(从 `capabilityApi.listSkills()` 勾选,默认全选=不限制;空数组=不限制语义) |
+| `views/aiWorkbench/index.vue` | WorkbenchView +`'agent'`;RAIL_ITEMS +「专家」;分支(plugin 的裸 v-else 改 v-else-if);专家页 `@chat` → `view='chat'` + `store.setPendingExpert(expert)` |
+| `store/chatStream.ts` | +`pendingExpertId/setPendingExpert`;`createSession` 透传 `agentId`(会话就此绑定);绑定后清 pending |
+| `composables/useAiChatPage.ts` | +experts 加载、`expertName` 计算(已绑定会话→绑定的;否则 pending;兜底「默认专家」) |
+| `components/ChatComposer.vue` + `ChatPanel.vue` | 输入框加**只读专家 chip**「专家:xxx」(会话已绑定=固定,不可点)——体现绑定后不可更换 |
+
+**「去对话」**:同工作台内不走路由,emit → `view='chat'` + pendingExpert(首条消息创建会话时带上 agentId);不动当前会话。
+
+## 四、交互口径(成文)
+
+1. 卡片操作:**去对话**(预选该专家,下一条消息创建绑定它的新会话)/ **编辑** / **删除**(后两者仅自建)。
+2. 会话创建即绑定智能体,UI 无更换入口;后端本就没有改 agent_id 的接口。
+3. 内置专家:全局可见、只有「去对话」,不可编辑/删除(后端双重校验,不靠前端隐藏)。
+4. 删除自建专家:确认提示绑定它的会话将无法继续对话;之后这些会话再对话 → 404「智能体不存在或已删除」。
+5. 工具配置=组级启用(选中的组注册并初始装备,未选的组模型不可见也切不出去);技能配置=名称白名单(不勾=不限制)。编辑保存后 evict 池,新对话立即生效。
+
+## 五、实施步骤
+
+1. SQL + `AgentEntity` 加列 → jshell 执行 DDL 并核对既有两列。
+2. 后端:DTO/VO/Controller + AgentService(CRUD/隔离/内置校验/evict/两处 buildAgent 配置消费)+ `registerBusinessTools` 三参重载 → 编译。
+3. 后端单测:`AgentExpertTest`(隔离过滤:内置+自己的;内置拒改拒删;归属校验;toolGroups/skillNames 校验;evict 生效;被删专家 getOrCreateAgent 兜底)——userId 走参数不依赖 sa-token。
+4. 前端:agentApi/types → AgentFormModal(含工具组+技能勾选)→ aiExpert 卡片页 → 工作台接入 + composer 只读 chip → tsc/eslint。
+5. E2E(浏览器):新建专家(勾 call 组 + 2 个技能)→ 卡片在「我的」分组 → 去对话 → 提问 → 会话绑定该专家(chip 固置)→ 编辑提示词 → 新会话验证 evict 生效 → 内置专家无编辑/删除 → 删除自建专家 → 旧会话对话报「智能体不存在或已删除」。
+
+## 六、验收
+
+- 内置:全局可见、仅去对话;自建:仅本人可见(后端过滤)可编辑可删。
+- 会话绑定后无任何更换入口;被删专家的会话报错明确。
+- 专家配置的工具组/技能在新对话真实生效(未启用组模型不可见;技能目录只见白名单),保存后立即生效(evict)。
+- 卡片列表 + 内置/我的分组展示;tsc/eslint/后端测试全绿(存量 8 个失败除外)。

+ 27 - 0
ai-electron/frontend/src/ai/api/types.ts

@@ -40,6 +40,33 @@ export interface ChatSession {
   updateAt?: string;
 }
 
+/** 专家(智能体):内置全局可见、不可改删;自建按用户隔离 */
+export interface AiExpert {
+  /** 雪花/自增主键,一律字符串 */
+  id: string;
+  name: string;
+  description?: string;
+  /** 系统提示词(人设与分析规则) */
+  sysPrompt?: string;
+  /** 内置专家:全局可见,只有「去对话」 */
+  builtin: boolean;
+  /** 是否当前用户的自建专家 */
+  mine: boolean;
+  /** 启用的业务工具组;null/空 = 不限制(全部组可用,默认装备人员画像组) */
+  toolGroups?: string[] | null;
+  /** 允许的技能白名单;null/空 = 不限制 */
+  skillNames?: string[] | null;
+}
+
+/** 新建/编辑专家入参(builtin/userId 由服务端按登录态决定,前端不可传) */
+export interface SaveExpertParams {
+  name: string;
+  description?: string;
+  sysPrompt: string;
+  toolGroups?: string[];
+  skillNames?: string[];
+}
+
 /** 消息角色 */
 export type MessageRole = 'user' | 'assistant' | 'system';
 

+ 31 - 1
ai-electron/frontend/src/ai/components/ChatComposer.vue

@@ -16,6 +16,12 @@
       />
 
       <div class="ai-composer__bar">
+        <!-- 专家 chip(只读):会话创建时绑定所选专家,绑定后不可更换 -->
+        <span v-if="expertName" class="ai-composer__expert" title="该会话已绑定此专家,不可更换(要换请新建会话)">
+          <Icon icon="mdi:account-tie-outline" :size="14" />
+          <span class="ai-composer__expert-name">{{ expertName }}</span>
+        </span>
+
         <!-- 模型入口收成一颗紧凑 chip:模型不常改,不值得占 200px 的宽度 -->
         <a-dropdown :trigger="['click']" placement="topLeft" :disabled="disabled || streaming">
           <button class="ai-composer__model" type="button" :disabled="disabled || streaming">
@@ -84,10 +90,12 @@
       models?: AiModel[];
       /** 模型ID(雪花 ID 字符串,禁止转 number) */
       modelId?: string | null;
+      /** 当前专家名(只读展示) */
+      expertName?: string;
       disabled?: boolean;
       streaming?: boolean;
     }>(),
-    { models: () => [], modelId: null, disabled: false, streaming: false },
+    { models: () => [], modelId: null, expertName: '', disabled: false, streaming: false },
   );
 
   const emit = defineEmits<{
@@ -214,6 +222,28 @@
     margin-top: 6px;
   }
 
+  /* 专家 chip(只读,无 hover/点击态) */
+  .ai-composer__expert {
+    display: inline-flex;
+    align-items: center;
+    gap: 6px;
+    max-width: 200px;
+    height: 26px;
+    padding: 0 10px;
+    overflow: hidden;
+    font-size: 12px;
+    color: var(--ai-text-2);
+    white-space: nowrap;
+    background: var(--ai-bg-subtle);
+    border: 1px solid var(--ai-border);
+    border-radius: 13px;
+
+    &-name {
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+  }
+
   /* 模型 chip */
   .ai-composer__model {
     display: inline-flex;

+ 4 - 0
ai-electron/frontend/src/ai/components/ChatPanel.vue

@@ -62,6 +62,7 @@
       ref="composerRef"
       :model-id="modelId"
       :models="models"
+      :expert-name="expertName"
       :disabled="disabled"
       :streaming="streaming"
       @update:model-id="emit('update:modelId', $event)"
@@ -100,6 +101,8 @@
       models?: AiModel[];
       /** 模型ID(雪花 ID 字符串,禁止转 number) */
       modelId?: string | null;
+      /** 当前专家名(只读展示;空 = 不显示专家 chip) */
+      expertName?: string;
     }>(),
     {
       title: '',
@@ -108,6 +111,7 @@
       streaming: false,
       models: () => [],
       modelId: null,
+      expertName: '',
     },
   );
 

+ 29 - 2
ai-electron/frontend/src/ai/composables/useAiChatPage.ts

@@ -2,7 +2,8 @@ import { computed, onMounted, ref, watch } from 'vue';
 import { useMessage } from '@/core/hooks/web/useMessage';
 import { useUserStore } from '@/core/store/modules/case';
 import { listModels } from '../api/modelApi';
-import type { AiModel } from '../api/types';
+import { listExperts } from '../api/agentApi';
+import type { AiExpert, AiModel } from '../api/types';
 import { useChatStreamStore } from '../store/chatStream';
 
 /**
@@ -26,6 +27,8 @@ export function useAiChatPage() {
   const models = ref<AiModel[]>([]);
   /** 当前选中的模型ID(雪花 ID 字符串,禁止转 number) */
   const modelId = ref<string | null>(null);
+  /** 专家清单(内置 + 我的):输入框只读 chip 展示当前专家名用 */
+  const experts = ref<AiExpert[]>([]);
 
   /**
    * 当前案件ID —— 作为后端 workspaceId。
@@ -44,11 +47,25 @@ export function useAiChatPage() {
    */
   const chatModels = computed(() => models.value.filter((m) => !m.type || m.type === 'llm'));
 
+  /**
+   * 当前专家名(只读展示,体现「会话绑定后不可更换」):
+   * 会话已绑定 → 绑定的专家;有预选 → 预选名;否则默认(内置)专家。
+   */
+  const expertName = computed(() => {
+    const bound = store.activeSession?.agentId;
+    if (bound !== undefined && bound !== null && bound !== '') {
+      const hit = experts.value.find((e) => e.id === String(bound));
+      if (hit) return hit.name;
+    }
+    if (store.pendingExpert) return store.pendingExpert.name;
+    return experts.value.find((e) => e.builtin)?.name || '默认专家';
+  });
+
   onMounted(async () => {
     if (workspaceId.value) {
       await store.init(workspaceId.value);
     }
-    await loadModels();
+    await Promise.all([loadModels(), loadExperts()]);
   });
 
   // 切换案件时重置会话上下文
@@ -62,6 +79,14 @@ export function useAiChatPage() {
     await store.init(id, true);
   });
 
+  async function loadExperts() {
+    try {
+      experts.value = (await listExperts()) || [];
+    } catch {
+      experts.value = [];
+    }
+  }
+
   async function loadModels() {
     try {
       models.value = (await listModels()) || [];
@@ -159,6 +184,8 @@ export function useAiChatPage() {
     panelRef,
     models,
     modelId,
+    experts,
+    expertName,
     chatModels,
     workspaceId,
     caseName,

+ 24 - 3
ai-electron/frontend/src/ai/store/chatStream.ts

@@ -116,6 +116,11 @@ export const useChatStreamStore = defineStore('ai-chat-stream', {
     streaming: false,
     /** 已初始化标记 */
     initialized: false,
+    /**
+     * 待绑定专家(专家页「去对话」预选):下一条消息**新建**的会话会绑定它。
+     * 会话一旦创建即绑定、不可更换 —— 显式选择已有会话会清掉这个预选。
+     */
+    pendingExpert: null as { id: string; name: string } | null,
   }),
 
   getters: {
@@ -156,6 +161,7 @@ export const useChatStreamStore = defineStore('ai-chat-stream', {
       this.activeSessionId = null;
       this.initialized = false;
       this.workspaceId = null;
+      this.pendingExpert = null;
     },
 
     /* ------------------------------------------------------------ *
@@ -173,14 +179,22 @@ export const useChatStreamStore = defineStore('ai-chat-stream', {
       }
     },
 
-    /** 新建会话(不立即选中时可传 false) */
+    /** 专家页「去对话」:预选专家,下一条消息创建的会话绑定它(绑定后不可更换) */
+    setPendingExpert(expert: { id: string; name: string } | null) {
+      this.pendingExpert = expert;
+    },
+
+    /** 新建会话(不立即选中时可传 false);有待选专家时本次创建即绑定它 */
     async createSession(title?: string, select = true): Promise<ChatSession | null> {
       if (!this.workspaceId) return null;
       const session = await chatApi.createSession({
         workspaceId: this.workspaceId,
         title: title || '新对话',
+        agentId: this.pendingExpert?.id,
       });
       this.sessions = [session, ...this.sessions];
+      // 预选专家已被本次创建消费
+      this.pendingExpert = null;
       if (select) {
         this.activeSessionId = session.id;
         this.messages = [];
@@ -188,9 +202,10 @@ export const useChatStreamStore = defineStore('ai-chat-stream', {
       return session;
     },
 
-    /** 选中会话并加载消息 */
+    /** 选中会话并加载消息(显式选已有会话 = 放弃专家预选) */
     async selectSession(sessionId: string) {
       if (this.streaming) this.stop();
+      this.pendingExpert = null;
       this.activeSessionId = sessionId;
       await this.loadMessages(sessionId);
     },
@@ -254,8 +269,14 @@ export const useChatStreamStore = defineStore('ai-chat-stream', {
       const content = (text || '').trim();
       if (!content || this.streaming || !this.workspaceId) return;
 
-      // 1) 无会话则先创建
+      // 1) 无会话、或当前会话绑定的专家与预选不一致 → 新建(会话绑定后不可更换)
       let sessionId = this.activeSessionId;
+      if (sessionId && this.pendingExpert) {
+        const bound = this.sessions.find((s) => s.id === sessionId)?.agentId;
+        if (bound !== undefined && bound !== null && bound !== '' && String(bound) !== this.pendingExpert.id) {
+          sessionId = null;
+        }
+      }
       if (!sessionId) {
         const session = await this.createSession(content.slice(0, 30));
         if (!session) return;

+ 22 - 0
ai-electron/frontend/src/ai/utils/constants.ts

@@ -85,6 +85,28 @@ export const PLAIN_CODE_LANGS = [
   'less',
 ];
 
+/* ------------------------------------------------------------------ *
+ * 业务工具组(与后端 AgentToolRegistry#groupLabel 保持一致)
+ * ------------------------------------------------------------------ */
+
+/** 业务工具组短标签:专家页展示 / 编辑弹窗勾选项标题用 */
+export const TOOL_GROUP_LABELS: Record<string, string> = {
+  person: '人员画像与亲密度',
+  call: '通话分析',
+  trans: '交易与资金分析',
+  track: '轨迹、出行与快递',
+  graph: '关系图谱',
+  file: '非表格材料档案',
+};
+
+/** 专家配置里可能出现的全部业务组(顺序即页面展示顺序) */
+export const TOOL_GROUP_KEYS = Object.keys(TOOL_GROUP_LABELS);
+
+export function toolGroupLabel(group?: string | null): string {
+  if (!group) return '';
+  return TOOL_GROUP_LABELS[group] || group;
+}
+
 /* ------------------------------------------------------------------ *
  * 报告导出
  * ------------------------------------------------------------------ */

+ 24 - 9
ai-electron/frontend/src/ai/views/aiWorkbench/index.vue

@@ -1,12 +1,7 @@
 <template>
   <div class="awb-page">
     <!-- 工作台顶栏:与布局壳共用 AppTopBar(结构/模式切换/头像/窗口控制都在里面) -->
-    <AppTopBar
-      :case-name="caseName"
-      :logo-title="'清鉴 · 智能工作台'"
-      show-case-exit
-      @exit-case="onExitCase"
-    >
+    <AppTopBar :case-name="caseName" :logo-title="'清鉴 · 智能工作台'" show-case-exit @exit-case="onExitCase">
       <!-- 工作台特有状态 chip:当前会话 -->
       <template #info>
         <span v-if="activeTitle" class="awb-chip" :title="activeTitle">
@@ -57,6 +52,7 @@
           :streaming="store.streaming"
           :models="chatModels"
           v-model:model-id="modelId"
+          :expert-name="expertName"
           @send="onSend"
           @stop="store.stop()"
           @star="store.toggleStar($event)"
@@ -66,8 +62,11 @@
         <!-- ② 模型管理 -->
         <AiModelPage v-else-if="view === 'model'" />
 
-        <!-- ③ 插件与技能 -->
-        <AiPluginPage v-else />
+        <!-- ③ 专家(智能体) -->
+        <AiExpertPage v-else-if="view === 'agent'" @chat="onChatWithExpert" />
+
+        <!-- ④ 插件与技能 -->
+        <AiPluginPage v-else-if="view === 'plugin'" />
       </main>
 
       <!-- 右栏:本会话产物(工具结果导出的 CSV)。可收起/拖宽,首次产出自动展开 -->
@@ -102,18 +101,21 @@
   import ArtifactPanel from '../../components/ArtifactPanel.vue';
   import ArtifactPreviewModal from '../../components/ArtifactPreviewModal.vue';
   import AiModelPage from '../aiModel/index.vue';
+  import AiExpertPage from '../aiExpert/index.vue';
   import AiPluginPage from '../aiPlugin/index.vue';
   import { useAiChatPage } from '../../composables/useAiChatPage';
   import { extractArtifacts } from '../../utils/artifacts';
+  import type { AiExpert } from '../../api/types';
 
   const userStore = useUserStore();
   const { createConfirm } = useMessage();
 
-  type WorkbenchView = 'chat' | 'model' | 'plugin';
+  type WorkbenchView = 'chat' | 'model' | 'agent' | 'plugin';
   const view = ref<WorkbenchView>('chat');
 
   const RAIL_ITEMS: Array<{ key: WorkbenchView; icon: string; label: string }> = [
     { key: 'chat', icon: 'mdi:robot-outline', label: '对话' },
+    { key: 'agent', icon: 'mdi:account-tie-outline', label: '专家' },
     { key: 'model', icon: 'mdi:brain', label: '模型管理' },
     { key: 'plugin', icon: 'mdi:puzzle-outline', label: '插件与技能' },
   ];
@@ -122,6 +124,7 @@
     store,
     panelRef,
     modelId,
+    expertName,
     chatModels,
     workspaceId,
     caseName,
@@ -144,6 +147,18 @@
    */
   const artifacts = computed(() => extractArtifacts(store.messages || []));
 
+  /**
+   * 专家页「去对话」:预选该专家并切回对话视图。
+   * 会话是「创建时绑定、绑定后不可更换」——预选在下一条消息**新建会话**时生效,
+   * 用户若显式选择已有会话则被清掉(store.selectSession)。
+   */
+  async function onChatWithExpert(expert: AiExpert) {
+    store.setPendingExpert({ id: expert.id, name: expert.name });
+    view.value = 'chat';
+    await nextTick();
+    panelRef.value?.focus();
+  }
+
   /** 产物栏「定位到消息」:把产出它的消息滚到视口中间 */
   function locateMessage(messageId: string) {
     const el = document.getElementById(`ai-msg-${messageId}`);

+ 0 - 48
ai-server/.agentscope/users/1/workspaces/5/agents/1/default/MEMORY.md

@@ -1,48 +0,0 @@
-# MEMORY.md
-
-## Context & Session — 2026-09-28
-
-Session timeline: repeated requests to query case call-record details (通话记录明细), sorted by call time DESC, first 20 rows (`pageSize=20`). Tool groups activated: `call` (通话明细分析, 7 tools) and `person` (人员画像与亲密度分析, 24 tools).
-
-### Case data-access gate — resolved this session
-Earlier sessions (~03:25–04:00) returned code=400 on all three data entry layers (`get_call_records`, `list_persons`, `execute_sql` DuckDB direct query): error "请先打开案件后再操作数据". Root cause: client-side loading validation gate — case not yet opened/loaded in the client; metadata/index layer online, but data-read channels gated until case unlocked.
-
-**Error-message variability:** a later session returned a different string ("当前案件数据源未打开或已释放,请重新打开案件", code=400) — suggests dynamic/error-message variability across case-load gates.
-
-**Workspace context:** workspaceId=5, name "AI 对话测试" (AI conversation test), description field empty — signature consistent with an un-loaded formal case.
-
-### Data channel RESTORED (≈04:08) — channel stable
-Unlike prior 400-error sessions, `get_call_records` succeeded and real call-detail data was retrieved. Prior gate appears resolved or session-specific. **Data-read channel remains accessible** — a repeat query at ~05:51 (`pageSize=20`, `callDateTime` DESC) succeeded without the code=400 gate, confirming channel stability across sessions.
-
-**Retrieval specifics:** query completed for `pageSize=20`, sorted by `callDateTime` DESC; total case records = **38,263 rows across 1914 pages**; CSV artifacts:
-- `call_analysis_20260928120148_76eedc24.csv` (~5.4MB / 5,437,351 bytes, timestamp 12:01)
-- `call_analysis_20260928134916_5d851f61.csv` (~5.4MB, timestamp 13:49:16) — distinct from the prior export
-
-resultId `9060ac11-d267-4a88-ad50-5eee736d9386`.
-
-### Actual subjects (from live data — not metadata guesses)
-- **吴得福** — 机主/primary subject, 中国移动; phones 13948567895 & 13648652680; certNo 513126197412174415
-- **刘术** — 四川电信; phones 13888856054 & 13861904573
-- **许天** — 中国移动; phone 13832954621
-- **周海** — NEW fourth subject identified in the ~05:51 repeat query's first 20 rows (previously only three subjects documented); specifics not yet detailed.
-
-### Anomaly patterns (first 20 rows, dates 2025-12-26 → 12-30)
-- Three-person closed-loop interplay.
-- 吴得福↔刘术 frequent same-second bidirectional "mirror" calls (主叫/被叫镜像) at **12-29 00:55:22, 12-28 11:55:12, 12-27 04:43:47** — suspected confirmation-style brief calls.
-- Deep-night window (21:00→next-day 06:00) prominent: 12-29 00:55, 12-28 03:13, 12-27 04:43.
-- Placeholder/test numbers: 吴得福 dialed **13555555555** and **18777777777**.
-
-### Known data structures (DuckDB)
-`call_record` table confirmed present; fields include `callDateTime` (呼叫开始时间), `personName`, `personPhone`, `otherName`, `otherPhone`, `callDirection`, `callType`, `duration`, `operatorId`, `callLocal`, `personCertNo`, `otherCertNo`, etc.
-
-### Java mapper references (from auto-generated error output)
-Layered error responses expose system package structure — useful for future debugging of the layered data-access architecture:
-- Person layer → `com/zsjz/ai/module/person/mapper/PersonBasicInfoMapper.java` (method `selectList`)
-- Agent layer → `com/zsjz/ai/module/agent/mapper/SqlQueryMapper.java` (method `executeQueryStream`)
-
-### Recommended next drill-downs (case now loaded)
-1. Night-call专项 for 吴得福 — deep-night window analysis.
-2. Base-station (基站) trajectory collision analysis.
-3. Financial/transaction tracing定位 of 刘术 & 许天 (and 周海 once details established).
-- Operational principle: per-subject queries over full-case blind scan to reduce load; confirm subject list via `list_persons` before drilling.
-- User awaiting decision on which drill-down to continue ("需要我继续下钻哪一项?") — no further instruction in this session.

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 7
ai-server/.agentscope/users/1/workspaces/5/agents/1/default/agents/数刃/sessions/4311687975235309032.jsonl


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 7
ai-server/.agentscope/users/1/workspaces/5/agents/1/default/agents/数刃/sessions/4311687975235309032.log.jsonl


+ 0 - 9
ai-server/.agentscope/users/1/workspaces/5/agents/1/default/agents/数刃/sessions/sessions.json

@@ -1,9 +0,0 @@
-{
-  "sessions" : {
-    "4311687975235309032" : {
-      "summary" : "transcript updated (33 entries)",
-      "updatedAt" : "2026-09-28T06:12:48.751344300Z"
-    }
-  },
-  "version" : 1
-}

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 7
ai-server/.agentscope/users/1/workspaces/5/agents/1/default/default/数刃/4311687975235309032/events/0-24-2fd70198.jsonl


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 2
ai-server/.agentscope/users/1/workspaces/5/agents/1/default/default/数刃/4311687975235309032/events/25-38-dfee7e74.jsonl


+ 0 - 14
ai-server/.agentscope/users/1/workspaces/5/agents/1/default/default/数刃/4311687975235309032/events/39-52-7e107a0d.jsonl

@@ -1,14 +0,0 @@
-{"type":"message","id":"56ed4b0a-65e1-4c60-99b2-5757ce1acebc","parentId":"chatcmpl-7etzKMMPC1GfReGEf7OUlPY7KBaJW3ud","timestamp":1790567714.849649600,"role":"USER","content":"查询通话记录明细前20条,按通话时间倒序"}
-{"type":"message","id":"9e381dae-9c7d-438b-861b-06a44a39b69f","parentId":"56ed4b0a-65e1-4c60-99b2-5757ce1acebc","timestamp":1790567714.849649600,"role":"USER","content":"查询通话记录明细前20条,按通话时间倒序"}
-{"type":"message","id":"chatcmpl-klxggQmxTvzsXUUMJ5X7CdmSFYzy2tZr","parentId":"9e381dae-9c7d-438b-861b-06a44a39b69f","timestamp":1790567714.849649600,"role":"ASSISTANT","content":"The user wants to query call record details, first 20 rows, sorted by call time descending. This is in the `call` group. Let me check my current tool groups.\n\nAccording to memory context, the case may not be opened yet. But let me try - the user is asking for call records. The `call` group isn't currently equipped (default is person). Let me activate the call group.\n\nWait, but there's a risk based on memory that all three layers return \"请先打开案件后再操作数据\". Let me first check the current workspace and list_persons to understand state. Actually, let me just try the get_call_records tool since that's what's needed.\n\nLet me activate the call group first via reset_equipped_tools.\n"}
-{"type":"tool_use","id":"chatcmpl-klxggQmxTvzsXUUMJ5X7CdmSFYzy2tZr:use:ZaFMieOr2HwtMP1rtvwGfdnSfMKgxgC0","parentId":"chatcmpl-klxggQmxTvzsXUUMJ5X7CdmSFYzy2tZr","timestamp":1790567714.849649600,"toolCallId":"ZaFMieOr2HwtMP1rtvwGfdnSfMKgxgC0","name":"reset_equipped_tools","input":{"to_activate":["call","person"]},"truncated":false}
-{"type":"tool_result","id":"0d46cc05-195d-473c-81c1-19c64c74d93d:result:ZaFMieOr2HwtMP1rtvwGfdnSfMKgxgC0","parentId":"chatcmpl-klxggQmxTvzsXUUMJ5X7CdmSFYzy2tZr:use:ZaFMieOr2HwtMP1rtvwGfdnSfMKgxgC0","timestamp":1790567714.849649600,"toolCallId":"ZaFMieOr2HwtMP1rtvwGfdnSfMKgxgC0","name":"reset_equipped_tools","output":"The currently activated tool group(s): call, person.\n<tool-instructions>\n<group name=\"call\">通话明细分析(7 个工具):原始通话记录明细,以及夜间通话、连续通话、特殊日期敏感通话的汇总与明细。★ 问「某人具体打了哪些电话」「夜间/凌晨通话」「连续密集通话」「特殊日期谁和谁联系过」时激活本组。只要「通话统计口径」(画像、TOP、汇总)用 person 组即可,本组是通话明细的下钻入口。</group>\n<group name=\"person\">人员画像与亲密度分析(24 个工具):人员枚举与分组、通话/交易画像、常联系与常交易 TOP、通话与交易汇总、运营商归属、银行卡汇总、月度/年度交易趋势,以及 9 类亲密度行为(大额交易、特殊日期交易/通话、同号绑定、持续通话/交易、同行、夜间通话)。★ 任何「从人出发」的问题都先激活本组:要回答某人的情况,通常得先用 list_person_names 确认案件里有哪些人。本组默认已装备。</group>\n</tool-instructions>","outputBlockTypes":["text"],"truncated":false}
-{"type":"message","id":"chatcmpl-GUJvwWknERIH3DLRNkdWCi03gqbaKX61","parentId":"0d46cc05-195d-473c-81c1-19c64c74d93d:result:ZaFMieOr2HwtMP1rtvwGfdnSfMKgxgC0","timestamp":1790567714.849649600,"role":"ASSISTANT","content":"Now let me query the call records. The user wants first 20 rows, sorted by call time descending. Let me use get_call_records. Since no specific person is named, I'll do a general query. But to avoid full-case blind scan, I should be careful. The user just says \"查询通话记录明细前20条,按通话时间倒序\" - general query.\n\nLet me call get_call_records with pageSize=20.\n"}
-{"type":"tool_use","id":"chatcmpl-GUJvwWknERIH3DLRNkdWCi03gqbaKX61:use:D8fteSOlAynKnlRocx2xzJylVouB9ZJw","parentId":"chatcmpl-GUJvwWknERIH3DLRNkdWCi03gqbaKX61","timestamp":1790567714.849649600,"toolCallId":"D8fteSOlAynKnlRocx2xzJylVouB9ZJw","name":"get_call_records","input":{"query":{"pageSize":20}},"truncated":false}
-{"type":"tool_result","id":"7396139d-50d9-4936-bf6d-82f846ca6e7e:result:D8fteSOlAynKnlRocx2xzJylVouB9ZJw","parentId":"chatcmpl-GUJvwWknERIH3DLRNkdWCi03gqbaKX61:use:D8fteSOlAynKnlRocx2xzJylVouB9ZJw","timestamp":1790567714.849649600,"toolCallId":"D8fteSOlAynKnlRocx2xzJylVouB9ZJw","name":"get_call_records","output":"Error: 通话记录查询失败: \r\n### Error querying database.  Cause: ServerException(code=400, msg=请先打开案件后再操作数据)\r\n### Cause: ServerException(code=400, msg=请先打开案件后再操作数据)。若提示数据表不存在,请确认已在客户端打开案件后重试","outputBlockTypes":["text"],"truncated":false}
-{"type":"message","id":"chatcmpl-9dUDbDGFHcRY9TKzZOodAaWEAfOpT4OL","parentId":"7396139d-50d9-4936-bf6d-82f846ca6e7e:result:D8fteSOlAynKnlRocx2xzJylVouB9ZJw","timestamp":1790567714.849649600,"role":"ASSISTANT","content":"Same error as before. Let me also verify with person group and execute_sql to confirm the state. Actually, let me check list_persons and execute_sql to confirm whether the case is loaded.\n\nActually, the memory already tells us all three layers return the same error. But let me be thorough and confirm - maybe the case needs to be opened. Let me quickly check list_persons and execute_sql to give the user a complete picture.\n\nActually, let me not waste too many calls. The error is clear: \"请先打开案件后再操作数据\". This means the case hasn't been opened/loaded in the client yet. Let me report this to the user.\n\nBut wait - let me double check by trying execute_sql and list_persons to confirm the same gate applies across all layers, so my conclusion is solid.\n"}
-{"type":"tool_use","id":"chatcmpl-9dUDbDGFHcRY9TKzZOodAaWEAfOpT4OL:use:C187iPfpcw8ovjg7GCSQzPkn0Vp571kj","parentId":"chatcmpl-9dUDbDGFHcRY9TKzZOodAaWEAfOpT4OL","timestamp":1790567714.849649600,"toolCallId":"C187iPfpcw8ovjg7GCSQzPkn0Vp571kj","name":"list_persons","input":{},"truncated":false}
-{"type":"tool_use","id":"chatcmpl-9dUDbDGFHcRY9TKzZOodAaWEAfOpT4OL:use:dbKRfLkWca9EJFuE6I2FHXIGye4PxgD7","parentId":"chatcmpl-9dUDbDGFHcRY9TKzZOodAaWEAfOpT4OL:use:C187iPfpcw8ovjg7GCSQzPkn0Vp571kj","timestamp":1790567714.849649600,"toolCallId":"dbKRfLkWca9EJFuE6I2FHXIGye4PxgD7","name":"execute_sql","input":{"sql":"SELECT * FROM call_record ORDER BY callDateTime DESC LIMIT 20"},"truncated":false}
-{"type":"tool_result","id":"70f8436b-c27e-4cdb-982e-2c29566f0beb:result:C187iPfpcw8ovjg7GCSQzPkn0Vp571kj","parentId":"chatcmpl-9dUDbDGFHcRY9TKzZOodAaWEAfOpT4OL:use:dbKRfLkWca9EJFuE6I2FHXIGye4PxgD7","timestamp":1790567714.849649600,"toolCallId":"C187iPfpcw8ovjg7GCSQzPkn0Vp571kj","name":"list_persons","output":"Error: 对象人员列表查询失败: \r\n### Error querying database.  Cause: ServerException(code=400, msg=请先打开案件后再操作数据)\r\n### The error may exist in com/zsjz/ai/module/person/mapper/PersonBasicInfoMapper.java (best guess)\r\n### The error may involve com.zsjz.ai.module.person.mapper.PersonBasicInfoMapper.selectList\r\n### The error occurred while executing a query\r\n### Cause: ServerException(code=400, msg=请先打开案件后再操作数据)。若提示数据表不存在,请确认已在客户端打开案件后重试","outputBlockTypes":["text"],"truncated":false}
-{"type":"tool_result","id":"3edacbfb-6a1f-41fe-8e2a-812164f251fd:result:dbKRfLkWca9EJFuE6I2FHXIGye4PxgD7","parentId":"70f8436b-c27e-4cdb-982e-2c29566f0beb:result:C187iPfpcw8ovjg7GCSQzPkn0Vp571kj","timestamp":1790567714.849649600,"toolCallId":"dbKRfLkWca9EJFuE6I2FHXIGye4PxgD7","name":"execute_sql","output":"Error: SQL 执行失败: \r\n### Error querying database.  Cause: ServerException(code=400, msg=请先打开案件后再操作数据)\r\n### The error may exist in com/zsjz/ai/module/agent/mapper/SqlQueryMapper.java (best guess)\r\n### The error may involve com.zsjz.ai.module.agent.mapper.SqlQueryMapper.executeQueryStream\r\n### The error occurred while executing a query\r\n### Cause: ServerException(code=400, msg=请先打开案件后再操作数据)","outputBlockTypes":["text"],"truncated":false}
-{"type":"message","id":"chatcmpl-vlgnFMcFUsHEcgjTDalBkacPf37aAD6T","parentId":"3edacbfb-6a1f-41fe-8e2a-812164f251fd:result:dbKRfLkWca9EJFuE6I2FHXIGye4PxgD7","timestamp":1790567714.849649600,"role":"ASSISTANT","content":"All three layers confirm the same gate. Let me report to the user.\n\n三层数据入口均已查询,结论一致。\n\n**【研判结果:数据通道未解锁】**\n\n- **调用对象**:`get_call_records`(通话明细)、`list_persons`(人员列表)、`execute_sql`(DuckDB 直查)\n- **返回错误**:三层均返回 `ServerException(code=400):请先打开案件后再操作数据`\n- **错误码含义**:客户端侧加载校验闸门拦截。案件尚未在客户端完成\"打开/加载\"动作——数据元数据/索引层在线,但实际读取通道(话单、人员、SQL)全部处于锁定状态。\n\n**【根因判断】**\n\n当前工作空间 `workspaceId=5`(名称\"AI 对话测试\",描述字段为空)。该签名与**未加载的正式案件**一致:元数据存在,但无实时数据读取通道。因此即便传入 `pageSize=20`、按 `callDateTime DESC` 排序的条件,前端也拿不到任何话单行。\n\n**【下一步操作建议】**\n\n1. 请先在客户端**打开目标案件**(完成案件加载/解锁校验),使数据读取通道生效;\n2. 重启本会话后,我将立即执行:先 `list_persons` 确认涉案人员清单,再按机主姓名逐人调取 `get_call_records`(前 20 条、通话时间倒序),并按需下钻夜间/敏感日期专项;\n3. 若打开案件后仍报同类错误,请核查客户端与后端数据索引层的同步状态。\n\n待案件解锁后随时下达指令。"}

+ 0 - 14
ai-server/.agentscope/users/1/workspaces/5/agents/1/default/default/数刃/4311687975235309032/events/53-66-0088fd5c.jsonl

@@ -1,14 +0,0 @@
-{"type":"message","id":"a4477d07-c482-4fac-ab39-83b334ad3a3f","parentId":"chatcmpl-vlgnFMcFUsHEcgjTDalBkacPf37aAD6T","timestamp":1790567961.221830300,"role":"USER","content":"查询通话记录明细前20条,按通话时间倒序"}
-{"type":"message","id":"d327ebbf-da07-4282-bcbc-25afdf649cae","parentId":"a4477d07-c482-4fac-ab39-83b334ad3a3f","timestamp":1790567961.221830300,"role":"USER","content":"<intent-analysis>\n意图: 《data_query》\n增强问题: 请帮我查询通话记录明细的前20条,并按通话时间倒序排列。\n关键实体: 通话记录明细, 通话时间\n</intent-analysis>"}
-{"type":"message","id":"chatcmpl-r27PCxZk52va1TAn7OeJd4DX4Pk0QkNn","parentId":"d327ebbf-da07-4282-bcbc-25afdf649cae","timestamp":1790567961.221830300,"role":"ASSISTANT","content":"The user wants to query call record details (通话记录明细), first 20 rows, sorted by call time descending.\n\nLet me recall from memory: The last time we tried this, all three data entry layers returned the same error \"请先打开案件后再操作数据\" (code=400). This suggests the case isn't yet opened/loaded in the client.\n\nBut let me try again - maybe the situation has changed. The user is asking for call record details. Let me check what tools I have available.\n\nCurrently, the person group is default equipped. To query call record details (get_call_records), I need the `call` group. Let me first check the current state and try the query.\n\nActually, let me think about this. The memory says all three layers returned \"请先打开案件后再操作数据\". But that was a prior session. Let me try again to see if it works now.\n\nFirst, I need to activate the `call` group for get_call_records. Let me reset_equipped_tools to include call and person groups.\n\nActually, let me be careful. The workflow from memory:\n1. First run `list_persons` to confirm the subject list.\n2. Then per-subject `get_call_records`.\n\nBut the user just wants \"通话记录明细前20条\" - they want raw call records. Let me activate the call group and try get_call_records.\n\nLet me reset_equipped_tools to include call and person groups, then try get_call_records.\n\nActually, let me first check what tools are available and try. The get_call_records tool is in the call group. Let me activate it.\n\nLet me do reset_equipped_tools with call and person groups.\n"}
-{"type":"tool_use","id":"chatcmpl-r27PCxZk52va1TAn7OeJd4DX4Pk0QkNn:use:IVXXzemQM1YkPaKkIxPNfNDdYHOaAtRS","parentId":"chatcmpl-r27PCxZk52va1TAn7OeJd4DX4Pk0QkNn","timestamp":1790567961.221830300,"toolCallId":"IVXXzemQM1YkPaKkIxPNfNDdYHOaAtRS","name":"reset_equipped_tools","input":{"to_activate":["call","person"]},"truncated":false}
-{"type":"tool_result","id":"5b6a101c-3072-42a8-97e4-17457d0a41bf:result:IVXXzemQM1YkPaKkIxPNfNDdYHOaAtRS","parentId":"chatcmpl-r27PCxZk52va1TAn7OeJd4DX4Pk0QkNn:use:IVXXzemQM1YkPaKkIxPNfNDdYHOaAtRS","timestamp":1790567961.222822300,"toolCallId":"IVXXzemQM1YkPaKkIxPNfNDdYHOaAtRS","name":"reset_equipped_tools","output":"The currently activated tool group(s): call, person.\n<tool-instructions>\n<group name=\"call\">通话明细分析(7 个工具):原始通话记录明细,以及夜间通话、连续通话、特殊日期敏感通话的汇总与明细。★ 问「某人具体打了哪些电话」「夜间/凌晨通话」「连续密集通话」「特殊日期谁和谁联系过」时激活本组。只要「通话统计口径」(画像、TOP、汇总)用 person 组即可,本组是通话明细的下钻入口。</group>\n<group name=\"person\">人员画像与亲密度分析(24 个工具):人员枚举与分组、通话/交易画像、常联系与常交易 TOP、通话与交易汇总、运营商归属、银行卡汇总、月度/年度交易趋势,以及 9 类亲密度行为(大额交易、特殊日期交易/通话、同号绑定、持续通话/交易、同行、夜间通话)。★ 任何「从人出发」的问题都先激活本组:要回答某人的情况,通常得先用 list_person_names 确认案件里有哪些人。本组默认已装备。</group>\n</tool-instructions>","outputBlockTypes":["text"],"truncated":false}
-{"type":"message","id":"chatcmpl-ItdAkFw92aIF5X1aaWqsG2HY3S5gAewX","parentId":"5b6a101c-3072-42a8-97e4-17457d0a41bf:result:IVXXzemQM1YkPaKkIxPNfNDdYHOaAtRS","timestamp":1790567961.222822300,"role":"ASSISTANT","content":"Now let me try to query call records. But first, I need to know who to query. The user just said \"通话记录明细前20条\" without specifying a person. Let me first check the persons list, then query.\n\nActually, the memory says all three layers returned error \"请先打开案件后再操作数据\". Let me try get_call_records and see. But without a personName, it might be a full-case scan. The user wants first 20 rows sorted by call time descending - this is essentially a full case scan.\n\nLet me first try list_persons to confirm subjects, then decide. Actually, let me just try get_call_records directly since the user wants raw records. But I need at least one parameter to locate. Let me check list_persons first.\n\nActually, given the memory context, let me be efficient. Let me try get_call_records with a full-case approach (no personName) to get the first 20 rows sorted by call time descending. The tool description says it returns paginated table and full results export to CSV.\n\nLet me try. The query needs at least one parameter. For \"first 20 rows sorted by call time descending\" without a specific person, this is a full-case scan. Let me call get_call_records with pageSize=20.\n\nWait, but the tool doesn't have a sort parameter - it returns results and the frontend sorts. Actually the description says \"结果以可翻页表格返回\". Let me just call it.\n\nLet me first check list_persons to see if there are subjects, since without personName it's a full scan. But actually let me just try get_call_records directly.\n\n先确认当前会话的数据通道状态,我直接查询通话明细。由于您未指定具体机主,我先按全案扫描取前 20 条(按通话时间倒序)。\n\n"}
-{"type":"tool_use","id":"chatcmpl-ItdAkFw92aIF5X1aaWqsG2HY3S5gAewX:use:MLxpKJBvmTlKaBR7JovFGwu2CDPPobOe","parentId":"chatcmpl-ItdAkFw92aIF5X1aaWqsG2HY3S5gAewX","timestamp":1790567961.222822300,"toolCallId":"MLxpKJBvmTlKaBR7JovFGwu2CDPPobOe","name":"get_call_records","input":{"query":{"pageSize":20}},"truncated":false}
-{"type":"tool_result","id":"7642934e-4f9e-481e-bbfd-577c8657a865:result:MLxpKJBvmTlKaBR7JovFGwu2CDPPobOe","parentId":"chatcmpl-ItdAkFw92aIF5X1aaWqsG2HY3S5gAewX:use:MLxpKJBvmTlKaBR7JovFGwu2CDPPobOe","timestamp":1790567961.222822300,"toolCallId":"MLxpKJBvmTlKaBR7JovFGwu2CDPPobOe","name":"get_call_records","output":"Error: 通话记录查询失败: \r\n### Error querying database.  Cause: ServerException(code=400, msg=当前案件数据源未打开或已释放,请重新打开案件)\r\n### Cause: ServerException(code=400, msg=当前案件数据源未打开或已释放,请重新打开案件)。若提示数据表不存在,请确认已在客户端打开案件后重试","outputBlockTypes":["text"],"truncated":false}
-{"type":"message","id":"chatcmpl-69wvrogkBNBKNMXamC84otPFSvXSjdHw","parentId":"7642934e-4f9e-481e-bbfd-577c8657a865:result:MLxpKJBvmTlKaBR7JovFGwu2CDPPobOe","timestamp":1790567961.222822300,"role":"ASSISTANT","content":"Same error as before. The case data source is not opened or released. Let me verify by trying list_persons and execute_sql to confirm the state.\n\nActually, let me confirm - the memory says all three layers returned the same error. Let me do a quick check with list_persons and execute_sql to confirm.\n\n数据通道返回 400 错误。让我并行确认其他入口的状态,以判断是通道故障还是案件未加载。\n\n"}
-{"type":"tool_use","id":"chatcmpl-69wvrogkBNBKNMXamC84otPFSvXSjdHw:use:unwKbDRh5dMc13MtVsPVUGU7M61pndLl","parentId":"chatcmpl-69wvrogkBNBKNMXamC84otPFSvXSjdHw","timestamp":1790567961.222822300,"toolCallId":"unwKbDRh5dMc13MtVsPVUGU7M61pndLl","name":"list_persons","input":{},"truncated":false}
-{"type":"tool_use","id":"chatcmpl-69wvrogkBNBKNMXamC84otPFSvXSjdHw:use:6xUkUnu86V5BeoXPS2IrjGrKLc5OKecs","parentId":"chatcmpl-69wvrogkBNBKNMXamC84otPFSvXSjdHw:use:unwKbDRh5dMc13MtVsPVUGU7M61pndLl","timestamp":1790567961.222822300,"toolCallId":"6xUkUnu86V5BeoXPS2IrjGrKLc5OKecs","name":"execute_sql","input":{"sql":"SELECT callDateTime, personName, otherName FROM call_record ORDER BY callDateTime DESC LIMIT 20"},"truncated":false}
-{"type":"tool_result","id":"8bab08fc-5d04-4a47-934f-7c0489072337:result:unwKbDRh5dMc13MtVsPVUGU7M61pndLl","parentId":"chatcmpl-69wvrogkBNBKNMXamC84otPFSvXSjdHw:use:6xUkUnu86V5BeoXPS2IrjGrKLc5OKecs","timestamp":1790567961.222822300,"toolCallId":"unwKbDRh5dMc13MtVsPVUGU7M61pndLl","name":"list_persons","output":"Error: 对象人员列表查询失败: \r\n### Error querying database.  Cause: ServerException(code=400, msg=当前案件数据源未打开或已释放,请重新打开案件)\r\n### The error may exist in com/zsjz/ai/module/person/mapper/PersonBasicInfoMapper.java (best guess)\r\n### The error may involve com.zsjz.ai.module.person.mapper.PersonBasicInfoMapper.selectList\r\n### The error occurred while executing a query\r\n### Cause: ServerException(code=400, msg=当前案件数据源未打开或已释放,请重新打开案件)。若提示数据表不存在,请确认已在客户端打开案件后重试","outputBlockTypes":["text"],"truncated":false}
-{"type":"tool_result","id":"a0969575-5af6-42e1-8e65-91af127eafae:result:6xUkUnu86V5BeoXPS2IrjGrKLc5OKecs","parentId":"8bab08fc-5d04-4a47-934f-7c0489072337:result:unwKbDRh5dMc13MtVsPVUGU7M61pndLl","timestamp":1790567961.222822300,"toolCallId":"6xUkUnu86V5BeoXPS2IrjGrKLc5OKecs","name":"execute_sql","output":"Error: SQL 执行失败: \r\n### Error querying database.  Cause: ServerException(code=400, msg=当前案件数据源未打开或已释放,请重新打开案件)\r\n### The error may exist in com/zsjz/ai/module/agent/mapper/SqlQueryMapper.java (best guess)\r\n### The error may involve com.zsjz.ai.module.agent.mapper.SqlQueryMapper.executeQueryStream\r\n### The error occurred while executing a query\r\n### Cause: ServerException(code=400, msg=当前案件数据源未打开或已释放,请重新打开案件)","outputBlockTypes":["text"],"truncated":false}
-{"type":"message","id":"chatcmpl-c26R9OQJmNHXbBabeWYmWnmLRdidYOsw","parentId":"a0969575-5af6-42e1-8e65-91af127eafae:result:6xUkUnu86V5BeoXPS2IrjGrKLc5OKecs","timestamp":1790567961.222822300,"role":"ASSISTANT","content":"All three layers (call, person, SQL direct) return the same 400 error. This confirms the case data source is not opened/released in the client. The metadata/index layer is online, but actual data-read channels are gated until the case is unlocked.\n\nLet me report this to the user clearly.\n\n三层数据入口(通话明细 / 人员列表 / DuckDB 直连)全部返回同一个 400 错误:\n\n```\n当前案件数据源未打开或已释放,请重新打开案件\n```\n\n**研判结论:**\n\n- **【异常概览】**:当前会话处于\"未开案\"状态——数据索引层在线(能识别表结构、返回元数据),但实际的数据读取通道全部被拦截,需先在客户端打开对应案件后方可查询。\n\n- **【通道状态】**:三套入口并行探测结果一致,非单点故障,而是统一的访问闸门(client-side loading validation gate)。\n\n- **【下一步操作】**:请确认以下任一项后重试——\n  1. 在客户端**打开目标案件**(加载案件数据源);\n  2. 或提供**明确的机主姓名/号码**(即使通道已恢复,未开案时按 personName 精确查询仍会先过此闸门)。\n\n- **【补强建议】**:待案件解锁后,建议优先执行 `list_persons` 确认涉案人员清单,再按机主逐人调取 `get_call_records`(避免全案盲扫造成数据负载),并对夜间通话、敏感日期等专项做异常模式匹配。\n\n通道已就绪,案件一经打开即可恢复查询。请重新加载案件后告知,我再执行前 20 条倒序明细的拉取。"}

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 5
ai-server/.agentscope/users/1/workspaces/5/agents/1/default/default/数刃/4311687975235309032/events/67-72-32dc097d.jsonl


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 2
ai-server/.agentscope/users/1/workspaces/5/agents/1/default/default/数刃/4311687975235309032/events/73-81-82ba4c12.jsonl


+ 0 - 1
ai-server/.agentscope/users/1/workspaces/5/agents/1/default/memory/.consolidation_state

@@ -1 +0,0 @@
-2026-09-28T06:12:48.727290100Z

+ 0 - 38
ai-server/.agentscope/users/1/workspaces/5/agents/1/default/memory/2026-09-28.md

@@ -1,38 +0,0 @@
-
-## Memory Flush — 2026-09-28T03:25:40.896897300Z
-- User repeatedly requested query of case call record details (通话记录明细), sorted by call time descending, taking first 20 rows (pageSize=20)
-- Assistant activated tool groups: `call` (通话明细分析/7 tools) and `person` (人员画像与亲密度分析/24 tools) for the query
-- All three data entry layers returned identical error "请先打开案件后再操作数据" (code=400): `get_call_records`, `list_persons`, and `execute_sql` (DuckDB direct query)
-- Root cause determined: client-side loading validation gate — case not yet opened/loaded in the client; data metadata/index layer is online but actual data-read channels are gated until case is unlocked
-- Current workspace identified as workspaceId=5, name "AI 对话测试" (AI conversation test), description field empty — consistent with un-loaded formal case data signature
-- `call_record` table structure confirmed present in DuckDB; fields include callDateTime (呼叫开始时间), personName, personPhone, otherName, otherPhone, callDirection, callType, duration, operatorId, callLocal, personCertNo, otherCertNo, etc.
-- Recommended next steps if case unlocked: first `list_persons` to confirm subject list, then per-subject `get_call_records` (avoid full-case blind scan to reduce load), and drill into night/sensitive-date专项 tools for anomaly patterns
-
-## Memory Flush — 2026-09-28T03:55:35.444231400Z
-- Error output from `list_persons` and `execute_sql` includes auto-generated "best guess" Java mapper references revealing system package structure: person layer maps to `com/zsjz/ai/module/person/mapper/PersonBasicInfoMapper.java` (method `selectList`), agent layer maps to `com/zsjz/ai/module/agent/mapper/SqlQueryMapper.java` (method `executeQueryStream`) — useful for future debugging of the layered data access architecture
-
-## Memory Flush — 2026-09-28T04:00:09.424786200Z
-- Error message wording differs from prior sessions: this query returned "当前案件数据源未打开或已释放,请重新打开案件" (code=400), whereas earlier entries recorded the string as "请先打开案件后再操作数据" — may indicate dynamic/error-message variability across case-load gates
-- Assistant committed to resuming the call record query (first 20 rows, sorted by callDateTime descending) once the case is loaded; explicitly requested the user reload the case in the client and notify before retrying
-
-## Memory Flush — 2026-09-28T04:08:06.250739300Z
-## Memory Flush — 2026-09-28 (call record query, channel restored)
-
-- **Data channel RESTORED this session** — unlike prior sessions that returned code=400 ("当前案件数据源未打开/已释放"), `get_call_records` succeeded and real call-detail data was retrieved. Prior 400-error gate appears resolved or session-specific.
-- **Retrieval specifics**: query completed for `pageSize=20`, sorted by `callDateTime` DESC; total case records = **38,263 rows** across 1914 pages; CSV artifact exported as `call_analysis_20260928120148_76eedc24.csv` (~5,437,351 bytes / ~5.4MB); resultId `9060ac11-d267-4a88-ad50-5eee736d9386`.
-- **Actual subjects confirmed from live data** (not metadata guesses): primary subject/机主 **吴得福** (中国移动, phones 13948567895 & 13648652680, certNo 513126197412174415); **刘术** (四川电信, phones 13888856054 & 13861904573); **许天** (中国移动, phone 13832954621).
-- **Anomaly patterns in first 20 rows (dates 2025-12-26 to 12-30)**: three-person closed-loop interplay; 吴得福↔刘术 shows frequent same-second bidirectional "mirror" calls (主叫/被叫镜像) at **12-29 00:55:22, 12-28 11:55:12, 12-27 04:43:47** — suspected confirmation-style brief calls; deep-night window calls (21:00–next-day 06:00) prominent: 12-29 00:55, 12-28 03:13, 12-27 04:43; placeholder/test numbers 吴得福 dialed **13555555555** and **18777777777**.
-- **Assistant recommended next drill-downs**: night-call专项 for 吴得福, base-station (基站) trajectory collision analysis, and financial/transaction tracing定位 of 刘术 & 许天.
-- **User awaiting decision** on which drill-down to continue ("需要我继续下钻哪一项?") — no further instruction given in this session.
-
-## Memory Flush — 2026-09-28T05:51:40.948840400Z
-- New call-detail retrieval completed this session: `get_call_records` with pageSize=20, sorted by callDateTime DESC; total case records = 38,263 rows; **new CSV artifact** `call_analysis_20260928134916_5d851f61.csv` (~5.4MB) — distinct from prior export `call_analysis_20260928120148_76eedc24.csv` (earlier timestamp 12:01 vs this one's 13:49:16)
-- **New subject identified**: first 20 rows now cover four subjects — 吴得福, 许天, 刘术, and **周海** (previously only three subjects were documented in prior sessions); 周海's specifics not yet detailed
-- Call-detail query succeeded without the code=400 gate this session (repeat of the same query as prior restored-channel session), confirming data-read channel remains accessible
-
-## Memory Flush — 2026-09-28T06:14:56.258949100Z
-- Call-record query window (first 20 rows, callDateTime DESC): assistant repeatedly invoked `get_person_call_profile` (person-group tool) for subjects 吴得福/刘术/许天 before finally executing the requested `get_call_records` (call-group tool) query with pageSize=20 at session end — tool-routing mismatch worth noting for future sessions
-- `get_person_call_profile` output structure: single-row CSV artifacts (~235–251 bytes); personName field is **null** in results, keyed instead by operator phone map (operCountMap); columns include phoneCount, callCount, otherCount, outCount, inCount, callRatioMap, callDateStart/callDateEnd
-- Call-count aggregates per subject from person profile: 吴得福 = 954 calls (out 554 / in 400, other 19); 刘术 = 924 (out 515 / in 409, other 187); 许天 = 489 (out 248 / in 241, other 24) — new aggregate data not documented in prior sessions
-- 吴得福's callDateStart shows placeholder date "1970-01-01" in person-profile output — new confirmation of placeholder-data anomaly beyond earlier test/placeholder-number findings in call-detail records
-- Repeated `get_person_call_profile` calls generated distinct tiny CSV artifacts each time (IDs: person_analysis_20260928141029, 141132, 141137, 141146, 141153, 141159, 141205, 141212, 141218, 141224)

+ 13 - 0
ai-server/src/main/java/com/zsjz/ai/module/agent/entity/AgentEntity.java

@@ -25,6 +25,19 @@ public class AgentEntity {
     @TableField(value = "owner_id")
     private String ownerId;
 
+    /**
+     * 创建者(sa-token 登录用户 ID)——按用户隔离的唯一依据。
+     * {@code is_builtin=1} 的内置专家对该字段不敏感(脚本统一写 1)。
+     */
+    @TableField(value = "user_id")
+    private Long userId;
+
+    /**
+     * 是否内置专家:1=内置(全局可见,不可编辑/删除),0=用户自建。
+     */
+    @TableField(value = "is_builtin")
+    private Integer isBuiltin;
+
     @TableField(value = "agent_id")
     private String agentId;
 

+ 188 - 2
ai-server/src/main/java/com/zsjz/ai/module/agent/service/AgentService.java

@@ -11,6 +11,7 @@ import com.zsjz.ai.module.agent.WorkspaceManagerFactory;
 import com.zsjz.ai.module.agent.config.EtlProperties;
 import com.zsjz.ai.module.agent.config.SkillRepositoryConfigEntry;
 import com.zsjz.ai.module.agent.config.SkillRepositorySupport;
+import com.zsjz.ai.module.agent.dto.SaveExpertDTO;
 import com.zsjz.ai.module.agent.entity.AgentEntity;
 import com.zsjz.ai.module.agent.entity.AgentModel;
 import com.zsjz.ai.module.agent.followup.FollowupMiddleware;
@@ -27,10 +28,12 @@ import com.zsjz.ai.module.agent.rag.RagSchemaService;
 import com.zsjz.ai.module.agent.scaffold.WorkspaceScaffolder;
 import com.zsjz.ai.module.agent.artifact.ArtifactService;
 import com.zsjz.ai.module.agent.tools.*;
+import com.zsjz.ai.module.agent.vo.ExpertVO;
 import com.zsjz.ai.module.plat.mapper.CaseInfoMapper;
 import com.zsjz.ai.module.plat.mapper.TableFieldMapper;
 import com.zsjz.ai.module.plat.mapper.TableInfoMapper;
 import io.agentscope.core.model.Model;
+import io.agentscope.core.skill.SkillFilter;
 import io.agentscope.core.skill.repository.FileSystemSkillRepository;
 import io.agentscope.core.state.AgentStateStore;
 import io.agentscope.core.state.InMemoryAgentStateStore;
@@ -144,7 +147,7 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         return agentPool.computeIfAbsent(key, k -> {
             AgentEntity entry = getById(agentRowId);
             if (entry == null) {
-                throw new ServerException(404, "Agent 配置不存在: " + agentRowId);
+                throw new ServerException(404, "智能体不存在或已删除: " + agentRowId);
             }
             AgentModel agentModelConfig = modelMapper.selectById(modelId);
             if (agentModelConfig == null) {
@@ -220,7 +223,18 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         //    默认只装备 person 组,其余组由模型调用 reset_equipped_tools 按需切换,避免每轮塞几十份 schema。
         Toolkit toolkit = new Toolkit();
         registerInfraTools(toolkit);
-        agentToolRegistry.registerBusinessTools(toolkit);
+        // 业务工具组:专家配置了 tools_allow_json → 只注册并装备这些组
+        // (组外工具不注册 = 模型不可见、reset_equipped_tools 也切不过去,构成真实限制);
+        // 未配置 → 全组注册、默认装备 person(原口径不变)
+        List<String> expertGroups = readStringList(entry.getToolsAllowJson());
+        if (expertGroups == null || expertGroups.isEmpty()) {
+            agentToolRegistry.registerBusinessTools(toolkit);
+        } else {
+            // enabled = 专家启用的组(限制集);初始装备仍按默认口径(person,省 token),
+            // 专家没启用 person 时重载会自动回落到「启用的组」
+            agentToolRegistry.registerBusinessTools(toolkit, expertGroups,
+                    AgentToolRegistry.DEFAULT_ACTIVE_GROUPS);
+        }
         b.toolkit(toolkit);
         // 注册 reset_equipped_tools 元工具(内部会调 toolkit.registerMetaTool()),
         // 模型据此在对话中自行装备/卸载业务工具组;必须放在 toolkit(...) 之后、build() 之前。
@@ -257,6 +271,12 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         if (Files.isDirectory(userSkills)) {
             b.skillRepository(new FileSystemSkillRepository(userSkills));
         }
+        // 技能白名单:专家配置了 skills_allow_json → 系统提示目录只列这些技能
+        // (SkillFilter.only 作用于「所有技能仓库合并后」,与用户停用的 overlay 正确叠加)
+        List<String> expertSkills = readStringList(entry.getSkillsAllowJson());
+        if (expertSkills != null && !expertSkills.isEmpty()) {
+            b.skillFilter(SkillFilter.only(expertSkills.toArray(new String[0])));
+        }
 
         b.stateStore(agentStateStore);
         b.filesystem(new LocalFilesystemSpec().isolationScope(IsolationScope.USER));
@@ -289,6 +309,172 @@ public class AgentService extends ServiceImpl<AgentMapper, AgentEntity> {
         }
     }
 
+
+    // ==================== 专家(智能体)管理 ====================
+
+    /** 技能名合法字符(与 SkillStore 校验口径一致:中英文/数字/._-,1~64) */
+    private static final String SKILL_NAME_RE = "^[A-Za-z0-9一-龥._-]{1,64}$";
+
+    /**
+     * 专家列表:内置(全局可见)+ 当前用户自建,内置排前。
+     *
+     * <p>按用户隔离是<b>后端</b>过滤(内置 OR 属于我),不是前端隐藏。
+     */
+    public List<ExpertVO> listExperts(Long userId) {
+        // agent 是极小的表(专家数量级),全量取回后在 Java 侧过滤:
+        // 隔离规则单测可覆盖(loadAllExperts 是测试缝),语义也更直白
+        return loadAllExperts().stream()
+                .filter(e -> isBuiltin(e) || (userId != null && userId.equals(e.getUserId())))
+                .map(e -> toExpertVO(e, userId))
+                .toList();
+    }
+
+    /** 查询全部专家(内置在前、自建按创建顺序);包级可见,单测可 stub */
+    List<AgentEntity> loadAllExperts() {
+        return lambdaQuery()
+                .orderByDesc(AgentEntity::getIsBuiltin)
+                .orderByAsc(AgentEntity::getRowId)
+                .list();
+    }
+
+    private static boolean isBuiltin(AgentEntity e) {
+        return e.getIsBuiltin() != null && e.getIsBuiltin() == 1;
+    }
+
+    /** 新建自建专家(user_id=当前用户,is_builtin=0) */
+    public ExpertVO createExpert(Long userId, SaveExpertDTO dto) {
+        validateExpertConfig(userId, dto);
+        AgentEntity entity = new AgentEntity();
+        entity.setUserId(userId);
+        entity.setIsBuiltin(0);
+        applyExpertConfig(entity, dto);
+        save(entity);
+        return toExpertVO(getById(entity.getRowId()), userId);
+    }
+
+    /** 编辑专家:内置拒绝;自建必须属于当前用户;保存后驱逐池化实例 */
+    public ExpertVO updateExpert(Long userId, Long rowId, SaveExpertDTO dto) {
+        requireOwnedExpert(userId, rowId, "编辑");
+        validateExpertConfig(userId, dto);
+        AgentEntity entity = getById(rowId);
+        applyExpertConfig(entity, dto);
+        updateById(entity);
+        evictAgentPool(rowId);
+        return toExpertVO(getById(rowId), userId);
+    }
+
+    /**
+     * 删除专家:内置拒绝;绑定它的会话继续对话时由 {@link #getOrCreateAgent} 返回明确错误。
+     */
+    public void deleteExpert(Long userId, Long rowId) {
+        AgentEntity entity = requireOwnedExpert(userId, rowId, "删除");
+        removeById(rowId);
+        evictAgentPool(rowId);
+        log.info("删除专家: rowId={}, name={}, userId={}", rowId, entity.getName(), userId);
+    }
+
+    /**
+     * 驱逐某智能体的全部池化实例。池 key = w{ws}-a{rowId}-m{model}-u{user},
+     * 配置(提示词/工具组/技能)变更后必须调用,否则旧实例继续生效。
+     */
+    public void evictAgentPool(Long agentRowId) {
+        String marker = "-a" + agentRowId + "-";
+        int removed = 0;
+        for (String key : agentPool.keySet()) {
+            if (key.contains(marker) && agentPool.remove(key) != null) {
+                removed++;
+            }
+        }
+        if (removed > 0) {
+            log.info("驱逐 agent 实例池: agentRowId={}, removed={}", agentRowId, removed);
+        }
+    }
+
+    /** 内置专家不可编辑/删除;自建必须属于当前用户 */
+    private AgentEntity requireOwnedExpert(Long userId, Long rowId, String action) {
+        AgentEntity entity = getById(rowId);
+        if (entity == null) {
+            throw new ServerException(404, "智能体不存在或已删除");
+        }
+        if (entity.getIsBuiltin() != null && entity.getIsBuiltin() == 1) {
+            throw new ServerException(400, "内置专家不可" + action);
+        }
+        if (userId == null || !userId.equals(entity.getUserId())) {
+            throw new ServerException(400, "无权操作该专家");
+        }
+        return entity;
+    }
+
+    /** 入参校验:工具组必须是已注册组;技能名格式合法且在当前用户技能目录中存在 */
+    private void validateExpertConfig(Long userId, SaveExpertDTO dto) {
+        if (dto.getToolGroups() != null) {
+            List<String> known = AgentToolRegistry.registeredGroupNames();
+            for (String group : dto.getToolGroups()) {
+                if (group == null || !known.contains(group)) {
+                    throw new ServerException(400, "未知工具组: " + group);
+                }
+            }
+        }
+        List<String> skills = dto.getSkillNames();
+        if (skills == null || skills.isEmpty()) {
+            return;
+        }
+        Path cwd = Paths.get(System.getProperty("user.dir")).toAbsolutePath().normalize();
+        Path skillsRoot = workspaceManagerFactory.resolveUserSkillsPath(cwd, userId);
+        boolean rootExists = Files.isDirectory(skillsRoot);
+        for (String name : skills) {
+            if (name == null || !name.matches(SKILL_NAME_RE)) {
+                throw new ServerException(400, "非法技能名: " + name);
+            }
+            // 未安装时拒绝写入白名单(SkillFilter.only 全不命中 = 该专家技能全被藏起来)
+            if (!rootExists || !Files.isDirectory(skillsRoot.resolve(name))) {
+                throw new ServerException(400, "技能不存在或未安装: " + name);
+            }
+        }
+    }
+
+    private void applyExpertConfig(AgentEntity entity, SaveExpertDTO dto) {
+        entity.setName(dto.getName().trim());
+        entity.setDescription(dto.getDescription());
+        entity.setSysPrompt(dto.getSysPrompt().trim());
+        entity.setToolsAllowJson(writeStringList(dto.getToolGroups()));
+        entity.setSkillsAllowJson(writeStringList(dto.getSkillNames()));
+    }
+
+    private ExpertVO toExpertVO(AgentEntity e, Long userId) {
+        ExpertVO vo = new ExpertVO();
+        vo.setId(String.valueOf(e.getRowId()));
+        vo.setName(e.getName());
+        vo.setDescription(e.getDescription());
+        vo.setSysPrompt(e.getSysPrompt());
+        vo.setBuiltin(isBuiltin(e));
+        vo.setMine(!vo.isBuiltin() && userId != null && userId.equals(e.getUserId()));
+        vo.setToolGroups(readStringList(e.getToolsAllowJson()));
+        vo.setSkillNames(readStringList(e.getSkillsAllowJson()));
+        return vo;
+    }
+
+    /**
+     * 解析 JSON 字符串数组(tools_allow_json / skills_allow_json 通用);
+     * null/空/坏 JSON 一律返回 null —— 等同「未配置,走默认口径」。
+     */
+    public static List<String> readStringList(String json) {
+        if (json == null || json.isBlank()) return null;
+        try {
+            return Json.objectMapper().readValue(json, new TypeReference<>() {
+            });
+        } catch (JsonProcessingException ex) {
+            return null;
+        }
+    }
+
+    /** 序列化字符串数组;null/空写 null(让 buildAgent 走默认口径而不是空白名单) */
+    private static String writeStringList(List<String> list) {
+        if (list == null || list.isEmpty()) return null;
+        List<String> distinct = list.stream().filter(v -> v != null && !v.isBlank()).distinct().toList();
+        return distinct.isEmpty() ? null : Json.toStr(distinct);
+    }
+
     /**
      * 在用户配置的 sysPrompt 后追加 RAG 检索指引(Agentic RAG:Agent 自行决定何时检索)
      */

+ 52 - 21
ai-server/src/main/java/com/zsjz/ai/module/agent/tools/AgentToolRegistry.java

@@ -314,25 +314,56 @@ public class AgentToolRegistry {
      * @param activeGroups 新会话初始激活的组名集合
      */
     public void registerBusinessTools(Toolkit toolkit, List<String> activeGroups) {
-        List<String> active = (activeGroups == null || activeGroups.isEmpty())
+        registerBusinessTools(toolkit, null, activeGroups);
+    }
+
+    /**
+     * 核心重载:{@code enabledGroups} 控制「注册哪些组」——组外工具连注册都不做,
+     * 模型不可见、{@code reset_equipped_tools} 也切不过去(专家工具配置的真实限制)。
+     *
+     * @param enabledGroups null/空 = 注册全部组(原口径);非空 = 只注册这些组
+     * @param activeGroups  null/空 = 初始装备 {@link #DEFAULT_ACTIVE_GROUPS}
+     */
+    public void registerBusinessTools(Toolkit toolkit, List<String> enabledGroups, List<String> activeGroups) {
+        List<String> known = registeredGroupNames();
+        List<String> requested = (enabledGroups == null || enabledGroups.isEmpty()) ? known : enabledGroups;
+        List<String> filtered = requested.stream().filter(known::contains).distinct().toList();
+        final List<String> enabled = filtered.isEmpty() ? known : filtered;
+        List<String> wanted = (activeGroups == null || activeGroups.isEmpty())
                 ? DEFAULT_ACTIVE_GROUPS : activeGroups;
-        // ---- 注册工具实例 ----
-        toolkit.registerTool(new CallAnalysisTool(callRecordService, callNightService,
-                callContinuousService, callSensitiveService, artifactService));
-        toolkit.registerTool(new TransAnalysisTool(transBigService, transCardHoldService,
-                transCashFlowService, transContinuousService, transFastFundFlowService,
-                transFinancialService, transFixedDepositService, transFrequencyService,
-                transRecordService, transSensitiveService, artifactService));
-        toolkit.registerTool(new TrackAnalysisTool(trackCellTowerService, trackEnLocalService,
-                trackExpressInfoService, trackMeetService, trackTogetherLiveService,
-                trackTogetherTravelService, artifactService));
-        toolkit.registerTool(new GraphAnalysisTool(graphService));
-        toolkit.registerTool(new PersonAnalysisTool(dataProfileService, intimacyService,
-                personBasicInfoService, artifactService));
-        toolkit.registerTool(new FileAnalysisTool(fileAiProfileService, artifactService));
+        // 只装备被启用的组;交集为空时把启用的组全部装备(专家至少该有一组工具)
+        List<String> intersected = wanted.stream().filter(enabled::contains).toList();
+        final List<String> active = intersected.isEmpty() ? enabled : intersected;
+
+        // ---- 注册工具实例(只注册被启用组对应的实例,组外工具连 schema 都不进模型上下文) ----
+        if (enabled.contains(GROUP_CALL)) {
+            toolkit.registerTool(new CallAnalysisTool(callRecordService, callNightService,
+                    callContinuousService, callSensitiveService, artifactService));
+        }
+        if (enabled.contains(GROUP_TRANS)) {
+            toolkit.registerTool(new TransAnalysisTool(transBigService, transCardHoldService,
+                    transCashFlowService, transContinuousService, transFastFundFlowService,
+                    transFinancialService, transFixedDepositService, transFrequencyService,
+                    transRecordService, transSensitiveService, artifactService));
+        }
+        if (enabled.contains(GROUP_TRACK)) {
+            toolkit.registerTool(new TrackAnalysisTool(trackCellTowerService, trackEnLocalService,
+                    trackExpressInfoService, trackMeetService, trackTogetherLiveService,
+                    trackTogetherTravelService, artifactService));
+        }
+        if (enabled.contains(GROUP_GRAPH)) {
+            toolkit.registerTool(new GraphAnalysisTool(graphService));
+        }
+        if (enabled.contains(GROUP_PERSON)) {
+            toolkit.registerTool(new PersonAnalysisTool(dataProfileService, intimacyService,
+                    personBasicInfoService, artifactService));
+        }
+        if (enabled.contains(GROUP_FILE)) {
+            toolkit.registerTool(new FileAnalysisTool(fileAiProfileService, artifactService));
+        }
 
         // ---- 建组并分配工具 ----
-        createGroup(toolkit, GROUP_PERSON,
+        if (enabled.contains(GROUP_PERSON)) createGroup(toolkit, GROUP_PERSON,
                 "人员画像与亲密度分析(24 个工具):人员枚举与分组、通话/交易画像、常联系与常交易 TOP、"
                         + "通话与交易汇总、运营商归属、银行卡汇总、月度/年度交易趋势,"
                         + "以及 9 类亲密度行为(大额交易、特殊日期交易/通话、同号绑定、持续通话/交易、同行、夜间通话)。"
@@ -340,20 +371,20 @@ public class AgentToolRegistry {
                         + "确认案件里有哪些人。本组默认已装备。",
                 PERSON_TOOLS, active);
 
-        createGroup(toolkit, GROUP_CALL,
+        if (enabled.contains(GROUP_CALL)) createGroup(toolkit, GROUP_CALL,
                 "通话明细分析(7 个工具):原始通话记录明细,以及夜间通话、连续通话、特殊日期敏感通话的汇总与明细。"
                         + "★ 问「某人具体打了哪些电话」「夜间/凌晨通话」「连续密集通话」「特殊日期谁和谁联系过」时激活本组。"
                         + "只要「通话统计口径」(画像、TOP、汇总)用 person 组即可,本组是通话明细的下钻入口。",
                 CALL_TOOLS, active);
 
-        createGroup(toolkit, GROUP_TRANS,
+        if (enabled.contains(GROUP_TRANS)) createGroup(toolkit, GROUP_TRANS,
                 "交易明细与资金分析(16 个工具):交易原始明细、大额交易(汇总/按日/明细)、共同交易、代持卡、"
                         + "现金流、连续交易、快速资金流转、理财、定期存款、交易频次、"
                         + "特殊日期敏感交易。"
                         + "★ 问「某人具体转了哪些账」「大额交易」「资金流向」「谁给谁转了多少钱」时激活本组。",
                 TRANS_TOOLS, active);
 
-        createGroup(toolkit, GROUP_TRACK,
+        if (enabled.contains(GROUP_TRACK)) createGroup(toolkit, GROUP_TRACK,
                 "轨迹、出行与快递分析(16 个工具):基站轨迹(汇总/通联明细)、境外本地昼夜活动、"
                         + "快递收寄(按人/按号码/原始单号)、人员碰面(汇总/明细)、"
                         + "同住(汇总/明细/旅馆住宿/同住服务)、同行出行(汇总/行程明细/票务原始记录)。"
@@ -367,13 +398,13 @@ public class AgentToolRegistry {
         //                + "做结构化字段的条件筛选/聚合/排序请用 execute_sql 或其它业务工具,不要用全文检索。",
         //        OTG_TOOLS);
 
-        createGroup(toolkit, GROUP_GRAPH,
+        if (enabled.contains(GROUP_GRAPH)) createGroup(toolkit, GROUP_GRAPH,
                 "关系图谱(1 个工具):案件关系网络取数(节点 + 连线)。"
                         + "★ 用户要「关系图谱」「资金往来网络」「通话关系图」「关联图」时激活本组:"
                         + "先用 get_case_graph 取数,再调用 render_graph(无需分组,始终可用)渲染。",
                 GRAPH_TOOLS, active);
 
-        createGroup(toolkit, GROUP_FILE,
+        if (enabled.contains(GROUP_FILE)) createGroup(toolkit, GROUP_FILE,
                 "非表格材料档案(3 个工具):txt / doc / docx / pdf / ppt / 压缩包 这类"
                         + "「上传后解析不了表格」的文件,上传时已由大模型识别出业务分类与摘要,"
                         + "可用 list_file_profiles 按分类/关键词检索清单、stat_file_profiles_by_category 看构成、"

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor