# Agent 问答页面设计 > 文档目的:为 agent-management 项目新增一个独立的"agent 问答页面",让用户以自然语言与 Hermes Agent 直接对话,过程复用现有工作流运行的思考过程展示能力。 > 设计时点:2026-08-03 > 状态:草案,待评审 --- ## 0. 一句话定位 提供一个 **类似 ChatGPT/Claude 的对话页面**:用户输入问题、可选选择模型与技能,后端调用 Hermes Bridge 直接执行(不经过工作流编排),前端复用现有 `agent-thinking/*` 组件实时展示思考过程、工具调用、待办清单、用户确认等中间状态,并支持多轮上下文对话与历史会话回看。 --- ## 1. 关键设计决策(已确认) | 决策点 | 选择 | 说明 | |---|---|---| | "智能体执行"的语义 | **Hermes 自主执行** | 不区分"回答/技能执行/智能体执行"三种模式,统一调 Hermes Bridge `/run`:用户可选模型 + 可选技能(作为系统提示词),让 Hermes Agent 自主决策如何响应。Hermes Agent 本身具备 40+ 工具与 `skill_view` 能力,能根据问题自动调用合适工具或发现并加载技能 | | 是否多轮对话 | **需要** | 同一会话内多轮问答,复用 Hermes Agent 的会话状态(`session_id`) | | 是否持久化历史 | **需要** | 关闭页面后可在侧栏切换历史会话回看,类似现有"运行历史" | | 持久化策略 | **分层结构化存储** | 主表 `chat_message`(消息元数据 + 最终文本)+ 子表 `chat_message_log`(按行结构化存思考/工具/todo),便于编辑、查询、统计;详见 §4.1 | | 是否支持"重新生成回复" | **需要** | 基于"消息树"实现:每次重新生成创建新的 assistant 分支节点,旧分支保留可切换。详见 §4.1.4 + §6.4 | | 是否支持"消息编辑" | **需要** | 编辑用户消息后从该点重新发起 run,生成新的子分支;与"重新生成"共用同一套树结构 | | 会话标题 | **自动提取** | 创建时标题留空;首次 user 消息完成后,后端取前 30 字作为标题写入;用户可在侧栏双击编辑 | | 是否支持文件上传 | **暂缓** | 第一版不实现。架构上预留 `chat_message.attachment_id` 字段,后续接入 | | 是否支持导出对话 | **需要** | 后端 `GET /api/chat/sessions/{id}/export?format=md\|json`;前端"导出"按钮 | | 是否支持多会话并行运行 | **需要** | 全局 run 池 `useGlobalRuns` 管理所有进行中的 run;会话切换不中断 SSE 流;侧栏显示 loading 指示;详见 §5.7 + §6.7 | | 多层记忆模式 | **复用上游 + UI 视图** | 不在外层重复实现压缩,直接依赖 Hermes Agent 的 `compress_context`;前端提供"工作记忆 / 短期 / 中期 / 长期"四个读取视图;详见 §6.6 | --- ## 2. 用户故事 ### 2.1 典型流程 1. 用户从侧栏进入"Agent 对话"页面。 2. 左侧"会话列表"展示历史会话(按时间倒序),点击新建会话按钮开始新对话。 3. 右侧"对话区"是消息流(用户消息 / 助手回复交替)+ 底部输入区。 4. 输入区上方有"模型选择器"和"技能选择器"两个下拉(均可留空): - 不选模型 → 用系统默认模型 - 不选技能 → Hermes 凭自身系统提示与 `skill_view` 工具自主决策 - 选了技能 → 把该技能的名称作为系统提示词注入,如“使用 readme-gen 技能,完成任务。” 5. 用户输入问题、点击发送: - 用户消息立即出现在对话区 - 助手回复区出现一个"思考中"占位卡片,卡片内实时展示思考过程(5 区域:思考流、实时状态、当前工具、工具历史、待办清单) - 若 Agent 中途发起 clarify(请求用户确认),卡片内出现 AskforPanel,用户点选项或自定义输入后继续 - 完成后,思考过程折叠为一行摘要,最终答案展开显示 6. 用户可基于当前回复继续提问,会话上下文累积。 7. 关闭页面后再次进入,会话列表仍可见,点击任一会话可回看完整对话。 ### 2.2 边界场景 - **多会话并行(核心场景)**:会话 A 的 run 进行中,用户切到会话 B 发新消息——A 的 SSE 流不中断,B 的 run 也正常启动;侧栏会话列表中 A、B 同时显示 loading 圈圈;用户切回 A 仍能看到实时流式输出(详见 §5.7 + §6.7)。 - **会话切换**:用户从会话 A 切到会话 B,前端只是把 A 的 SSE 监听移交给全局 run 池(不关闭),让 A 在后台继续;切回 A 时从全局池恢复监听。 - **会话进行中刷新页面**:通过 SSE 断线重连机制(`GET /api/chat/runs/{runId}/stream?lastEventId=N`)恢复。 - **会话进行中关闭浏览器再打开**:通过持久化的 `WorkflowPause` + `GET /api/chat/runs/{runId}/state` 轮询恢复 clarify 等待态。 - **跨标签页**:同一会话不允许在多个标签页同时发新消息(前端用 `localStorage` 简单锁,避免 session_id 冲突)。 - **重新生成回复**:用户对某条 assistant 消息点"重新生成" → 后端从该 user 消息分支创建新的 assistant 子节点 → 旧节点 `is_active=0` 仍可查看;用户可用"上一版 / 下一版"按钮在分支间切换。 - **消息编辑**:用户对某条 user 消息编辑后重发 → 后端创建新的 user 子节点(同 parent_id 的兄弟分支)→ 触发新一轮 assistant run;编辑前的对话分支保留。 - **记忆压缩**:用户在某轮对话中触发 Token 阈值(默认 50%),Hermes Agent 自动调用 `compress_context` 把早期消息归档为摘要(`active=0, compacted=1`);前端在消息流中插入一条"系统摘要"分隔条作为视觉提示。 --- ## 3. 系统架构 ### 3.1 整体调用链路 ``` [前端 ChatPage.vue] POST /api/chat/sessions 新建会话 → 返回 sessionId POST /api/chat/sessions/{id}/runs 发起新一轮问答(携带 userMessage + 可选 modelId/skillFolderName) │ ↓ 返回 runId │ GET /api/chat/runs/{runId}/stream?lastEventId=N 订阅 SSE ↓ [后端 ChatController] - 创建 ChatMessage(user) 记录 - 调 ChatService.startRun(...) │ ↓ [ChatService] - 解析 modelId → AiModelConfig(可选) - 解析 skillFolderName → systemPrompt(可选,读 SKILL.md) - 注册 SseEventBus.register(runId) - 异步调 HermesBridgeClient.run(systemPrompt, userMessage, sessionId, ..., modelConfig, sink) │ ↓ 接收 Python Bridge 的 SSE 流 │ ↓ 解析为 ExecutionLog + WorkflowRunEvent │ ↓ 通过 sink 推到 SseEventBus ↓ - HermesBridgeClient.run 返回 finalText 时,写入 ChatMessage(assistant) + 更新 ChatSession.lastMessageAt [前端 SSE 处理] - 复用 useChat composable 解析事件 → 更新当前消息的 5 区域状态 - workflow_complete 时把 finalText 写入 ChatMessage(assistant).content ``` ### 3.2 与现有工作流执行的关系 | 维度 | 工作流运行 | Agent 问答 | |---|---|---| | 入口 | `POST /api/workflows/{name}/run` | `POST /api/chat/sessions/{id}/runs`(新建) | | 编排 | DAG,多节点按 level 调度 | 无编排,单次 Bridge `/run` 调用 | | 节点 | userInput/llm/agent/skill/output 等多种 | 隐式单一"agent"节点 | | 复用 WorkflowEngine | 是 | **否**(绕过,避免 DAG / 工作目录 / NodeWorkspace 等无关开销) | | 复用 HermesBridgeClient | 是(通过 HermesAgentExecutor) | **是**(直接调用,不经 Executor) | | 复用 SseEventBus | 是 | **是** | | 复用 WorkflowPause | 是(clarify 暂停) | **是** | | 复用 WorkflowRun 持久化 | 是 | **否**(新建 ChatSession / ChatMessage,语义更清晰) | | 复用 agent-thinking 组件 | 是 | **是**(核心复用点) | **核心设计原则**:Agent 问答是工作流执行的一个"特例"——单节点、无 DAG、无工作目录。绕过 WorkflowEngine 直接调 Bridge,但保留所有可复用的基础设施(SSE 总线、clarify 持久化、断线重连、思考过程展示组件)。 --- ## 4. 后端设计 ### 4.1 数据模型 > **设计原则**:分层结构化存储,便于查询、统计、编辑、版本管理。 > - 主表 `chat_message`:消息元数据(role/status/model/技能)+ 最终文本(user 原文 / assistant 回复) > - 子表 `chat_message_log`:每条 assistant 消息的思考过程日志按"行"结构化存储,与 `WorkflowRunNode.logs` 元素一一对应 > - 通过 `parent_id` + `is_active` 字段形成"消息树",原生支持"重新生成"与"消息编辑" #### 4.1.1 `chat_session`(会话主表) ```sql CREATE TABLE chat_session ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_uuid VARCHAR(36) NOT NULL UNIQUE, -- 对外暴露的 UUID title VARCHAR(255), -- 会话标题(首次 user 消息完成后自动取前 30 字,可编辑) model_id BIGINT, -- 当前会话默认模型(可切换) skill_folder VARCHAR(255), -- 当前会话默认技能 folderName(可切换) hermes_session VARCHAR(64), -- Hermes Bridge 的 session_id(多轮复用 Agent 实例) status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE / ARCHIVED last_summarized_at TIMESTAMP, -- 上游最后一次压缩时间戳(来自压缩事件的 `_previous_summary` 时间字段),用于"中期记忆"视图的边界 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, last_message_at TIMESTAMP, -- 用于按最新活动排序 updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX idx_chat_session_last_msg ON chat_session(status, last_message_at DESC); ``` > **为什么单独建表而非复用 WorkflowRun**:WorkflowRun 表语义绑定 `workflowId`,inputs/outputs 是工作流级别的 JSON Map。对话有独特的"消息流"结构(多轮 user/assistant 交替 + 消息树 + 历史版本),强行塞入 WorkflowRun 会让两类数据互相污染,查询/管理都需要伪值过滤。新建独立表语义清晰,未来扩展(点赞点踩、Token 统计)也方便。 #### 4.1.2 `chat_message`(消息主表) ```sql CREATE TABLE chat_message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id BIGINT NOT NULL, -- 关联 chat_session.id parent_id BIGINT, -- 父消息 id(首条消息为 NULL);用于消息树(重新生成/编辑) is_active BOOLEAN NOT NULL DEFAULT TRUE, -- 当前分支是否激活(同 parent_id 下仅一条为 TRUE);非激活的旧版本仍可查看 branch_order INT NOT NULL DEFAULT 0, -- 同 parent_id 下的兄弟排序(用户切换"上一版/下一版"用) run_id VARCHAR(64), -- 关联本轮 Bridge run(仅 ASSISTANT 消息有;USER 消息为 NULL) role VARCHAR(16) NOT NULL, -- USER / ASSISTANT / SYSTEM(压缩摘要用 SYSTEM) content TEXT NOT NULL, -- USER 原文 / ASSISTANT 最终回复 / SYSTEM 摘要文本 status VARCHAR(16) NOT NULL, -- USER: 'SENT' / ASSISTANT: 'RUNNING'/'SUCCESS'/'FAILED' / SYSTEM: 'SUMMARY' model_id BIGINT, -- 实际使用的模型(冗余存储,便于回看当时配置) skill_folder VARCHAR(255), -- 实际使用的技能 error TEXT, -- FAILED 时的错误信息 attachment_id BIGINT, -- 预留:未来支持文件上传时关联附件表 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, completed_at TIMESTAMP, FOREIGN KEY (session_id) REFERENCES chat_session(id) ON DELETE CASCADE, FOREIGN KEY (parent_id) REFERENCES chat_message(id) ON DELETE CASCADE ); CREATE INDEX idx_chat_message_session ON chat_message(session_id, created_at); CREATE INDEX idx_chat_message_parent ON chat_message(parent_id, is_active); CREATE INDEX idx_chat_message_run ON chat_message(run_id); ``` **字段设计要点**: - `parent_id` + `is_active` + `branch_order`:构成 ChatGPT 风格的消息树 - 首条消息 `parent_id = NULL`,后续消息默认 `parent_id = 上一条激活消息的 id` - "重新生成"某条 assistant 消息:新建一条同 `parent_id` 的 assistant 节点,旧的 `is_active` 置 false,新的置 true,`branch_order` 递增 - "编辑"某条 user 消息:同样新建一条同 `parent_id` 的 user 节点(旧的置 false),然后基于这条新 user 节点开新的 assistant run - 用户切换版本时,前端沿 `parent_id → children` 树导航,激活不同的 `branch_order` - `attachment_id`:架构上预留文件上传字段,第一版不实现 - 不再有 `logs_json` 字段:日志移到子表 `chat_message_log`,便于按消息查询、按 kind 统计、按工具筛选 #### 4.1.3 `chat_message_log`(消息日志子表,结构化) ```sql CREATE TABLE chat_message_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, message_id BIGINT NOT NULL, -- 关联 chat_message.id(仅 ASSISTANT 消息有日志) seq INT NOT NULL, -- 同一消息内的顺序(从 0 递增) kind VARCHAR(24) NOT NULL, -- thinking / status / tool_call / tool_result / todo_update / clarify_request / ... content TEXT, -- 内容文本(与 ExecutionLog.content 同义) tool_name VARCHAR(128), -- 仅 kind=tool_call/tool_result 有 meta_json TEXT, -- 扩展元数据:如 clarifyId/choices、todo 列表 JSON 等 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (message_id) REFERENCES chat_message(id) ON DELETE CASCADE ); CREATE INDEX idx_chat_log_message ON chat_message_log(message_id, seq); CREATE INDEX idx_chat_log_kind ON chat_message_log(message_id, kind); ``` **为什么不直接用 `logs_json` JSON 字段**: | 维度 | `logs_json` 字段 | `chat_message_log` 子表 | |---|---|---| | 写入流式 | 整条 JSON 重写或读-改-写 | 一行 INSERT,O(1) | | 按 kind 查询 | 取出后内存过滤 | `WHERE kind='tool_call'` 索引扫描 | | 统计 | 全表扫所有 message | `GROUP BY tool_name` 走索引 | | 字段长度限制 | 单字段可能超大(思考流数千字) | 每行独立,无单字段超大问题 | | 历史回看 | 取一条 message 后整体反序列化 | 取所有行按 seq 排序即可 | | 与 `WorkflowRunNode.logs` 元素对应 | 数组直接对应 | 行直接对应(每行 = 数组一个元素) | **回看时的转换**:`SELECT * FROM chat_message_log WHERE message_id=? ORDER BY seq` → 直接映射为 `ExecutionLog[]`,与 `WorkflowRunNode.logs` 完全同构,可无缝传给 `runResultReplay.buildReplayNode`。 #### 4.1.4 消息树示意图 ``` Session A 的消息树(举例): [msg-1 USER] parent=NULL, is_active=true ← 首条消息 │ ├─[msg-2 ASSISTANT success] parent=1, is_active=true ← 当前激活的回复 │ │ │ └─[msg-3 USER] parent=2, is_active=true ← 第二轮用户输入 │ │ │ ├─[msg-4 ASSISTANT success] parent=3, is_active=true ← 当前激活(branch_order=0) │ │ │ └─[msg-5 ASSISTANT success] parent=3, is_active=false ← 用户点"重新生成"产生的旧版本(branch_order=1) │ └─[msg-6 ASSISTANT failed] parent=1, is_active=false ← 用户曾对 msg-1 重新生成(旧版失败) 用户编辑 msg-3 的文本: → INSERT msg-7 USER, parent=msg-2(与 msg-3 同 parent), is_active=true → UPDATE msg-3 SET is_active=false → 触发新一轮 assistant run,结果 INSERT msg-8 ASSISTANT, parent=msg-7 ``` **查询当前激活分支**:递归从 root 出发,每层取 `is_active=true` 的子节点。SQL 用 CTE 递归或应用层循环均可(深度通常 < 100)。 **展示规则**: - 默认仅渲染"当前激活分支"(root → ... → leaf),等价于 ChatGPT 单链对话视图 - 用户在某条 assistant 消息上看到"< 2/3 >"切换器 → 沿 `parent_id` 取所有兄弟节点,`branch_order` 排序 - 用户在某条 user 消息上看到"编辑"按钮 → 触发"编辑分支"流程 #### 4.1.5 持久化策略 | 事件 | 操作 | |---|---| | 用户发起新会话 | INSERT chat_session(hermes_session 用 `"chat-" + UUID`) | | 用户发送消息 | INSERT chat_message(role=USER, parent_id=上一条激活消息, is_active=true, status=SENT) → 立即返回;异步触发 Bridge run | | 助手 run 启动 | INSERT chat_message(role=ASSISTANT, parent_id=对应 USER 消息, is_active=true, status=RUNNING, run_id=xxx) | | 助手 run 流式产生日志 | **逐行** INSERT chat_message_log(message_id=助手消息 id, seq=自增, kind/content/tool_name/...) | | 助手 run 完成(success) | UPDATE chat_message SET status=SUCCESS, content=finalText, completed_at=now | | 助手 run 完成(failed) | UPDATE chat_message SET status=FAILED, error=..., completed_at=now | | 助手 run 中途 clarify | INSERT workflow_pause(**复用现有表**,按 run_id 关联,不绑 workflowId) | | 助手 run 完成 | UPDATE chat_session SET last_message_at=now;若是首条消息,自动 SET title=截取前 30 字 | | 用户重新生成 assistant 消息 | UPDATE chat_message SET is_active=false WHERE id=旧 assistant;INSERT 新 chat_message(parent_id=旧 parent, is_active=true);触发新 run | | 用户编辑 user 消息 | UPDATE chat_message SET is_active=false WHERE id=旧 user;INSERT 新 chat_message(parent_id=同 parent, is_active=true, content=新文本);触发新 run | | 上游触发压缩 | INSERT chat_message(role=SYSTEM, status=SUMMARY, content=摘要文本);UPDATE chat_session.last_summarized_at=now | > **逐行 INSERT 日志性能**:单条 assistant 消息的日志行数通常 10-100,最多约 500(极端长 run)。逐行 INSERT 在批量场景下也可改为"每 N 条/每 200ms 批量提交"。MySQL 默认 autocommit 下,100 行日志的写入耗时约 50ms,可接受。 > > **复用 WorkflowPause 表**:现有 WorkflowPause 字段(runId/nodeId/clarifyId/question/choices/status/answer)与 workflowId 无强耦合,nodeId 字段对 chat 场景填 "agent" 常量即可,无需新建 chat_pause 表。 ### 4.2 REST API 设计 #### 4.2.1 会话管理 | Method | Path | 用途 | |---|---|---| | `POST` | `/api/chat/sessions` | 新建会话。Body: `{title?, modelId?, skillFolder?}`。返回 `{sessionId, sessionUuid}` | | `GET` | `/api/chat/sessions` | 列出当前用户的所有会话(分页,按 last_message_at 倒序)。返回 `[{id, title, modelId, skillFolder, status, lastMessageAt, lastMessagePreview?, hasActiveRun?}]`(`hasActiveRun` 用于侧栏显示 loading) | | `GET` | `/api/chat/sessions/{id}` | 获取会话详情 + 当前激活分支的所有消息。返回 `{session, messages: [{role, content, status, logs, ...}]}` | | `PATCH` | `/api/chat/sessions/{id}` | 更新会话(改标题、切换 modelId/skillFolder、归档) | | `DELETE` | `/api/chat/sessions/{id}` | 删除会话(级联删除消息 + 日志) | | `POST` | `/api/chat/sessions/{id}/reset` | 重置会话(清空 Hermes Agent 的会话状态),**Bridge 需新增端点**。Body: `{keepHistory: true}`(默认保留消息历史,只重置 Agent 实例) | | `GET` | `/api/chat/sessions/{id}/export` | 导出对话。Query: `?format=md\|json`、`?branch=active\|all`(默认 active)。返回文件流 | | `GET` | `/api/chat/sessions/{id}/messages` | 分页加载消息(懒加载历史)。Query: `?before=msgId&limit=50`。返回 `{messages, hasMore}` | #### 4.2.2 单轮问答(核心) | Method | Path | 用途 | |---|---|---| | `POST` | `/api/chat/sessions/{sessionId}/runs` | 发起一轮问答。Body: `{message, modelId?, skillFolder?, maxIterations?, parentMessageId?}`(`parentMessageId` 指定父消息,用于编辑/重新生成场景;正常发送时传当前激活分支的叶子消息 id)。返回 `{runId, messageId}` | | `GET` | `/api/chat/runs/{runId}/stream` | SSE 订阅(支持 `?lastEventId=N` 断线重连)。**完全复用 SseEventBus** | | `GET` | `/api/chat/runs/{runId}/state` | 查询当前 run 状态(用于断线后恢复 clarify 等待态) | | `POST` | `/api/chat/runs/{runId}/resume` | 提交 clarify 答案。Body: `{clarifyId, answer}`。**复用 WorkflowPause** | | `POST` | `/api/chat/runs/{runId}/stop` | 中止运行(可选实现,调 Bridge 的 abort) | #### 4.2.3 消息版本管理(重新生成 / 编辑) | Method | Path | 用途 | |---|---|---| | `POST` | `/api/chat/messages/{messageId}/regenerate` | 重新生成 assistant 回复。后端:把该 messageId 置 `is_active=false`,同 parent 创建新 assistant 消息 `is_active=true`,触发新 run。返回 `{runId, newMessageId}` | | `PATCH` | `/api/chat/messages/{messageId}` | 编辑 user 消息。Body: `{content}`。后端:把该 messageId 置 `is_active=false`,同 parent 创建新 user 消息 `is_active=true, content=新内容`;**不自动触发 run**(前端用返回的 newMessageId 调 `POST /runs` 触发)。返回 `{newMessageId}` | | `GET` | `/api/chat/messages/{messageId}/versions` | 列出该消息的所有兄弟版本(同 parent_id)。返回 `[{messageId, branchOrder, isActive, createdAt, preview}]` | | `POST` | `/api/chat/messages/{messageId}/activate` | 切换激活版本。后端:UPDATE 同 parent 的所有消息 `is_active=false`,UPDATE 指定 messageId `is_active=true` | > **路径前缀决策**:新端点统一用 `/api/chat/...`,与现有 `/api/workflows/...` 平级。虽然 `GET stream` / `GET state` / `POST resume` 在逻辑上与 WorkflowController 同名端点等价(都基于 SseEventBus + WorkflowPause),但**不共享路径**——独立 Controller 更清晰,未来如果 chat 与工作流的 clarify 处理逻辑出现分歧也好扩展。 #### 4.2.4 资源选择器辅助 | Method | Path | 用途 | |---|---|---| | `GET` | `/api/models` | 已有,列出模型 | | `GET` | `/api/skills` | 已有,列出技能(folderName + 名称 + 描述) | ### 4.3 Service 层 #### 4.3.1 `ChatSessionService` / `ChatSessionRepository` 标准 CRUD。增加: - `touch(sessionId)`:更新 `last_message_at` - `autoTitleIfNeeded(sessionId)`:若 `title IS NULL`,取最近一条 user 消息内容前 30 字(去掉换行)作为标题 - `export(sessionId, format, branch)`:导出 Markdown / JSON,`branch=active` 仅导出当前激活分支;`branch=all` 导出整棵树(含历史版本) #### 4.3.2 `ChatMessageService` / `ChatMessageRepository` 标准 CRUD。增加: - `appendLog(messageId, log)`:assistant run 过程中**逐行**写入 `chat_message_log`(性能优化:批量提交,每 N 条或每 200ms flush 一次) - `markSuccess(messageId, finalText)` - `markFailed(messageId, error)` - `regenerate(messageId)`:核心方法 ```java @Transactional public RegenerateResult regenerate(Long oldMessageId) { ChatMessage old = repo.findById(oldMessageId).orElseThrow(); // 1. 旧消息置 inactive old.setIsActive(false); repo.save(old); // 2. 创建新的 assistant 消息(同 parent_id) int newOrder = repo.maxBranchOrderByParent(old.getParentId()) + 1; ChatMessage fresh = new ChatMessage( old.getSessionId(), old.getParentId(), true, newOrder, null, Role.ASSISTANT, "", Status.PENDING, old.getModelId(), old.getSkillFolder()); repo.save(fresh); return new RegenerateResult(fresh.getId(), old.getParentId()); } ``` - `editUserMessage(messageId, newContent)`:把旧 user 消息置 inactive,创建同 parent 的新 user 消息(**不触发 run**,由前端单独调 `POST /runs`) - `activateVersion(messageId)`:把同 parent_id 的所有消息置 inactive,指定 messageId 置 active - `getActiveBranch(sessionId)`:递归查询当前激活分支的所有消息(CTE 或应用层循环) - `getVersions(parentId)`:列出同 parent_id 的所有兄弟消息 #### 4.3.3 `ChatMessageLogService` / `ChatMessageLogRepository` - `appendBatch(messageId, List logs, startSeq)`:批量写入 - `findByMessage(messageId)`:按 seq 排序返回,可映射为 `ExecutionLog[]` 给前端回放 - `findByMessageAndKind(messageId, kind)`:按类型筛选 #### 4.3.4 `ChatRunService`(核心编排) ```java public RunStartResult startRun(Long sessionId, String userMessage, Long modelId, String skillFolder, Integer maxIterations, Long parentMessageId) { ChatSession session = sessionRepo.findById(sessionId).orElseThrow(...); // 1. 写用户消息(若 parentMessageId 已指定编辑场景,前端已先调 PATCH /messages,这里跳过) Long userMsgId; if (parentMessageId != null && isUserMessage(parentMessageId)) { // 编辑场景:parentMessageId 是新创建的 user 消息 userMsgId = parentMessageId; } else { ChatMessage userMsg = messageRepo.save(new ChatMessage( sessionId, parentMessageId, true, nextOrder(parentMessageId), null, Role.USER, userMessage, Status.SENT, modelId, skillFolder)); userMsgId = userMsg.getId(); } // 2. 解析模型配置 AiModelConfig modelConfig = (modelId != null) ? aiModelService.getModelConfig(modelId) : null; // 3. 解析技能系统提示 String systemPrompt = (skillFolder != null) ? skillService.getSkillFullContent(skillFolder) : null; // 4. 生成 runId,注册 SSE 总线 String runId = UUID.randomUUID().toString().substring(0, 8); eventBus.register(runId); // 5. 写助手占位消息(parent = userMsgId) ChatMessage assistantMsg = messageRepo.save(new ChatMessage( sessionId, userMsgId, true, 0, runId, Role.ASSISTANT, "", Status.RUNNING, modelId, skillFolder)); // 6. 异步调 Bridge executor.submit(() -> { try { List buffer = new ArrayList<>(); String finalText = hermesBridgeClient.run( systemPrompt, userMessage, maxIterations != null ? maxIterations : 100, null, null, session.getHermesSession(), null, runId, modelConfig, (kind, content, toolName) -> { // 1) SSE 推送 WorkflowRunEvent evt = WorkflowRunEvent.nodeStream(runId, "agent", kind, content, toolName); eventBus.publish(runId, evt.getType(), evt); // 2) 累积到缓冲区,批量写库(每 200ms 或 50 条 flush 一次) buffer.add(ExecutionLog.fromStream(kind, content, toolName)); logBufferService.flushIfFull(assistantMsg.getId(), buffer); }); // 最终 flush 残留日志 logBufferService.flushAll(assistantMsg.getId(), buffer); messageRepo.markSuccess(assistantMsg.getId(), finalText); sessionRepo.touch(sessionId); sessionRepo.autoTitleIfNeeded(sessionId); // 自动标题 } catch (Exception e) { messageRepo.markFailed(assistantMsg.getId(), e.getMessage()); eventBus.publish(runId, "workflow_error", WorkflowRunEvent.workflowError(runId, e.getMessage())); } }); return new RunStartResult(runId, assistantMsg.getId()); } ``` > **关键复用点**: > - `HermesBridgeClient.run(...)` 的 sink 回调签名与 `NodeStreamSink.emit(nodeId, kind, content, toolName)` 一致,直接传 lambda 即可。 > - `ExecutionLog` 类型枚举与工厂方法直接复用(`ExecutionLog.thinking(...)` / `toolCall(...)` 等)。 > - `WorkflowRunEvent.nodeStream` / `workflowError` 静态工厂直接复用。 > - 日志缓冲批量写库:避免每条日志一次 INSERT 的开销,缓冲到 50 条或 200ms 时一次性写入。 #### 4.3.5 会话重置(Bridge 端配合) `POST /api/chat/sessions/{id}/reset` 流程: 1. 后端调 `POST /hermes/sessions/{hermesSessionId}/reset`(**Bridge 新增端点**) 2. Bridge 端从 `_agents` 字典中删除该 session 的 Agent 实例(key 为 `session_id::hermes_home::model_id`) 3. 后端 UPDATE `chat_session.hermes_session = "chat-" + newUUID`(下次对话会创建新 Agent) 4. 后端 UPDATE 当前会话所有 assistant 消息 `is_active=false`,保留作为历史可见 5. 返回新的 sessionId,前端切换到"全新开始"状态 > **Bridge 端需新增的端点**:参考 `backend/hermes-bridge/hermes_bridge.py:120-123` 的 `_agents` LRU 池,添加 `POST /sessions/{sessionId}/reset` 路由,逻辑:`_agents.pop(sessionKey, None)`。 ### 4.4 HermesBridgeClient 改造评估 **结论:Java 端无需改造,Bridge 端需新增 2 个端点 + 1 个 SSE 事件类型**。 #### 4.4.1 Java 端(无需改造) 现有 `HermesBridgeClient.run(...)` 签名已经包含所有需要的参数: ```java // HermesBridgeClient.java:143-146 public String run(String systemPrompt, String userMessage, int maxIterations, String workingDir, String hermesHome, String sessionId, String runId, AiModelConfig modelConfig, NodeStreamSink sink) { ... } ``` Agent 问答场景的调用差异: - `workingDir` / `hermesHome`:传 null(无文件操作上下文;若未来要让 Agent 操作文件,可传临时目录) - `sessionId`:从 `chat_session.hermes_session` 取,多轮复用 Agent 实例 - `runId`:本轮 UUID - `modelConfig`:从 `chat_session.model_id` 解析 - `sink`:直接用 lambda 推到 `SseEventBus` **clarify 落库拦截**:当前 `WorkflowLevelExecutor.wrapSinkWithClarifyHook` @ `WorkflowLevelExecutor.java:790-828` 在 sink 外包了一层拦截器,遇到 `clarify_request` 时写 `WorkflowPause` 表。Agent 问答需要复用这个拦截器,建议把 `wrapSinkWithClarifyHook` 抽到一个独立的工具方法(如 `ClarifyHooks.wrap(sink, runId, pauseRepo)`)供两条链路共用。 **多会话并行**:Java 端 `SseEventBus` 按 `runId` 维护订阅,天然支持多个 run 并行;不同 runId 的事件互不干扰。 #### 4.4.2 Bridge 端(Python,需改造) | 端点 / 事件 | 状态 | 用途 | |---|---|---| | `POST /hermes/sessions/{sessionId}/reset` | **新增** | 重置会话(从 `_agents` 字典删除该 Agent 实例)。详见 §4.3.5 | | `GET /hermes/sessions/{sessionId}/memory` | **新增(后续迭代)** | 返回 `MEMORY.md` / `USER.md` 内容 | | `POST /hermes/sessions/{sessionId}/memory` | **新增(后续迭代)** | 保存编辑后的长期记忆 | | SSE 事件 `context_compacted` | **新增** | 当上游 `compress_context` 触发时推送,携带 `{summary, compacted_until_message_id}`。详见 §6.6 | > **第一版最小改动**:仅 `POST /sessions/{sessionId}/reset` 必需(用于"重置会话"按钮)。`context_compacted` 事件可缺失——压缩仍会发生(上游自动),但前端无法实时显示摘要分隔条;可后续迭代补全。`/memory` 端点对应"长期记忆编辑"功能,可放到更后期。 --- ## 5. 前端设计 ### 5.1 路由与导航 - 路由:`/chat` 与 `/chat/:sessionId`(新增到 `frontend/src/router/index.js`) - 侧栏菜单:在 `AppSidebar.vue` 的"其他管理"分组上方新增"Agent 对话"入口,图标用 `chat` 或 `message` ### 5.2 页面布局 ``` ┌────────────────────────────────────────────────────────────────────┐ │ [新建会话] [搜索框] [Agent 对话] │ ├────────────────┬───────────────────────────────────────────────────┤ │ 会话列表 │ 对话区 │ │ ┌────────────┐│ ┌─────────────────────────────────────────────┐ │ │ │• 会话 A ││ │ USER: 用户消息 1 │ │ │ │ 会话 B ││ │ ASSISTANT: 助手回复 1(含折叠的思考过程) │ │ │ │ 会话 C ││ │ USER: 用户消息 2 │ │ │ └────────────┘│ │ ASSISTANT: 助手回复 2(运行中) │ │ │ │ │ ├ ThinkingLive (区域②:实时状态) │ │ │ │ │ ├ 思考流 (区域①:可折叠) │ │ │ │ │ ├ AgentThinkingPanel │ │ │ │ │ │ ├ ToolCalls (区域③:工具调用) │ │ │ │ │ │ ├ TodoListPanel (区域④:待办清单) │ │ │ │ │ │ └ AskforPanel (区域⑤:用户确认) │ │ │ │ │ └ RunReconnectBadge (断线重连状态) │ │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ │ │ ┌─────────────────────────────────────────────┐ │ │ │ │ [模型选择器] [技能选择器] │ │ │ │ │ ┌─────────────────────────────────────────┐ │ │ │ │ │ │ 输入问题... │ │ │ │ │ │ │ │ │ │ │ │ │ │ [发送] │ │ │ │ │ │ └─────────────────────────────────────────┘ │ │ │ │ └─────────────────────────────────────────────┘ │ └────────────────┴───────────────────────────────────────────────────┘ ``` ### 5.3 组件划分 #### 5.3.1 新建组件 | 组件路径 | 作用 | |---|---| | `frontend/src/views/chat/ChatPage.vue` | 路由页面,左右布局,组合会话列表 + 对话区 | | `frontend/src/views/chat/ChatSessionList.vue` | 左侧会话列表(新建/切换/删除/重命名 + **loading 指示**) | | `frontend/src/views/chat/ChatMessageList.vue` | 中间消息流(user/assistant 交替 + 压缩摘要分隔条 + **版本切换器**) | | `frontend/src/views/chat/ChatMessageItem.vue` | 单条消息渲染:user 简单气泡(含编辑按钮),assistant 复用 AgentThinkingPanel(含重新生成按钮) | | `frontend/src/views/chat/ChatMessageVersionSwitcher.vue` | "⟨ 2/3 ⟩" 版本切换组件(用于重新生成/编辑历史) | | `frontend/src/views/chat/ChatComposer.vue` | 底部输入区:模型选择器 + 技能选择器 + 文本框 + 发送按钮 + **导出/重置会话按钮** | | `frontend/src/views/chat/ChatSummaryDivider.vue` | 压缩摘要分隔条(显示"前 N 条消息已被压缩为摘要") | | `frontend/src/composables/useGlobalRuns.js` | **全局 run 池**:管理所有进行中的 run,会话切换不中断(详见 §5.7) | | `frontend/src/composables/useChat.js` | 当前会话状态管理(绑定到 useGlobalRuns)(详见 5.4) | | `frontend/src/api/chat.js` | API 封装 | | `frontend/src/components/common/ModelSelect.vue` | **新抽取的可复用模型选择器**(详见 5.5) | | `frontend/src/components/common/SkillSelect.vue` | **新抽取的可复用技能选择器** | #### 5.3.2 直接复用的现有组件 | 组件 | 文件 | 复用方式 | |---|---|---| | `AgentThinkingPanel.vue` | `frontend/src/components/agent-thinking/AgentThinkingPanel.vue` | `` | | `ThinkingLive.vue` | 同目录 | `` | | `ToolCalls.vue` | 同目录 | 通过 AgentThinkingPanel 间接复用 | | `TodoListPanel.vue` | 同目录 | 通过 AgentThinkingPanel 间接复用 | | `AskforPanel.vue` | 同目录 | 通过 AgentThinkingPanel 间接复用 | | `RunReconnectBadge.vue` | 同目录 | `` | #### 5.3.3 直接复用的工具函数 | 函数 | 文件 | 用途 | |---|---|---| | `buildReplayNode` | `frontend/src/utils/runResultReplay.js:136` | 历史会话回看时,从 `logs[]` 还原思考过程 | | `extractStreamingThinking` | 同文件 :27 | 单独提取思考流(用于历史消息展开) | | `extractToolHistory` | 同文件 :48 | 单独提取工具调用历史 | | `extractLatestTodos` | 同文件 :96 | 单独提取最新 todos | | `stripHermesThinkingDecoration` | `frontend/src/utils/hermesThinking.js:69` | 剥离颜文字装饰 | ### 5.4 `useChat.js` composable 设计 参考现有 `useWorkflowRunner.js`,但去除 VueFlow 画布相关逻辑,并**绑定到全局 run 池**(详见 §5.7): ```javascript // frontend/src/composables/useChat.js import { useGlobalRuns } from './useGlobalRuns' export function useChat() { // 当前会话状态 const sessions = ref([]) // 会话列表 const currentSession = ref(null) // 当前会话对象 const messages = ref([]) // 当前激活分支的消息列表 // 输入区状态 const inputText = ref('') const selectedModelId = ref(null) const selectedSkillFolder = ref(null) // 全局 run 池:管理所有进行中的 run(关键依赖) const globalRuns = useGlobalRuns() // 当前会话的运行时态(从全局池派生) const currentRunId = computed(() => messages.value.length && messages.value[messages.value.length - 1].status === 'RUNNING' ? messages.value[messages.value.length - 1].runId : null ) const currentMessage = computed(() => currentRunId.value ? globalRuns.getState(currentRunId.value) : null) const pendingAsk = computed(() => currentRunId.value ? globalRuns.getPendingAsk(currentRunId.value) : null) const reconnecting = computed(() => currentRunId.value ? globalRuns.isReconnecting(currentRunId.value) : false) const isRunning = computed(() => currentRunId.value !== null) // 监听全局池中当前 runId 的事件,把变化应用到 messages 的对应消息上 watch(currentRunId, (runId) => { if (!runId) return // 让全局池知道"当前视图关注这个 run",从而把 SSE 事件回写到 messages globalRuns.attachView(runId, (event) => handleSSEEvent(runId, event)) }) // SSE 事件分发(基于全局池回调) function handleSSEEvent(runId, event) { const msg = messages.value.find(m => m.runId === runId) if (!msg) return if (msg.runtime == null) msg.runtime = { streamingThinking: '', currentStatus: '', currentTool: null, toolHistory: [], todos: [] } if (event.type === 'node_stream' && event.kind === 'thinking') { msg.runtime.streamingThinking += event.content } else if (event.type === 'node_stream' && event.kind === 'status') { msg.runtime.currentStatus = stripHermesThinkingDecoration(event.content) } else if (event.type === 'node_stream' && event.kind === 'tool_call') { msg.runtime.currentTool = { name: event.toolName, argsSummary: event.content, result: null } msg.runtime.toolHistory.push({ ...msg.runtime.currentTool }) } else if (event.type === 'node_stream' && event.kind === 'tool_result') { if (msg.runtime.currentTool) msg.runtime.currentTool.result = event.content } else if (event.type === 'node_stream' && event.kind === 'todo_update') { msg.runtime.todos = JSON.parse(event.content) } else if (event.type === 'node_stream' && event.kind === 'clarify_request') { // pendingAsk 已由 globalRuns 维护,组件直接读取 } else if (event.type === 'workflow_complete') { msg.content = event.output?.text || event.finalText || '' msg.status = 'SUCCESS' } else if (event.type === 'workflow_error') { msg.status = 'FAILED' msg.error = event.message } else if (event.type === 'context_compacted') { // 上游触发压缩:插入一条 SYSTEM 摘要消息作为视觉分隔 messages.value.splice(messages.value.indexOf(msg), 0, { role: 'SYSTEM', status: 'SUMMARY', content: '前 N 条消息已压缩为摘要:' + event.summary, }) } } async function startRun(parentMessageId = null) { const userText = inputText.value.trim() if (!userText || !currentSession.value || isRunning.value) return inputText.value = '' // 1. 调 POST /api/chat/sessions/{id}/runs(携带 parentMessageId) const { runId, messageId } = await startChatRun(currentSession.value.id, { message: userText, modelId: selectedModelId.value, skillFolder: selectedSkillFolder.value, parentMessageId, // 编辑场景下传入新创建的 user 消息 id;正常发送时传当前激活分支叶子 id }) // 2. 立即把用户消息 + 助手占位消息加到 messages(乐观更新) const userMsg = { id: parentMessageId, role: 'USER', content: userText, status: 'SENT', createdAt: Date.now() } const assistantMsg = { id: messageId, runId, role: 'ASSISTANT', content: '', status: 'RUNNING', runtime: null } if (!parentMessageId) messages.value.push(userMsg) messages.value.push(assistantMsg) // 3. 让全局池接管 SSE 订阅(不会因会话切换而中断) globalRuns.startStreaming(runId) } // 重新生成 assistant 回复 async function regenerate(messageId) { const { runId, newMessageId } = await regenerateMessage(messageId) // 局部重载当前分支 await reloadActiveBranch() globalRuns.startStreaming(runId) } // 编辑 user 消息 async function editMessage(messageId, newContent) { const { newMessageId } = await editUserMessage(messageId, newContent) // 创建了新的 user 消息,基于它发起新 run await startRun(newMessageId) } // 切换版本 async function activateVersion(messageId) { await activateMessageVersion(messageId) await reloadActiveBranch() } // 历史会话加载 async function loadSession(sessionId) { const { session, messages: history } = await getSessionDetail(sessionId) currentSession.value = session selectedModelId.value = session.modelId selectedSkillFolder.value = session.skillFolder // 把持久化的 logs[] 通过 buildReplayNode 还原为运行时态 messages.value = history.map(m => { if (m.role === 'ASSISTANT' && m.logs?.length) { return { ...m, runtime: buildReplayNode({ logs: m.logs }) } } return m }) // 检查是否有未完成的 run(页面刷新后恢复) const lastMsg = messages.value[messages.value.length - 1] if (lastMsg?.status === 'RUNNING') { globalRuns.startStreaming(lastMsg.runId) // 重连续传 } } // 切换会话:不主动 abort SSE,让全局池接管 async function switchSession(sessionId) { // 不需要 abort;当前 SSE 流由全局池继续维护 await loadSession(sessionId) } return { sessions, currentSession, messages, currentMessage, pendingAsk, reconnecting, isRunning, inputText, selectedModelId, selectedSkillFolder, loadSessions: ..., startRun, regenerate, editMessage, activateVersion, submitClarify: ..., switchSession, exportSession: ..., resetSession: ..., } } ``` > **关键变化**:与初稿相比,useChat 不再自己管理 SSE 连接——所有 SSE 连接由 `useGlobalRuns` 统一管理,会话切换时不中断。`useChat` 通过 `globalRuns.attachView(runId, callback)` 订阅当前视图关注的 run 事件。 ### 5.5 抽取 ModelSelect / SkillSelect 组件 当前 `WorkflowEditor.vue` 内联了 4 处 ``(`WorkflowEditor.vue:1533/1665/1756/1854`)。建议: 1. 抽取为 `frontend/src/components/common/ModelSelect.vue`: ```vue ``` 2. WorkflowEditor.vue 的 4 处替换为 ``(**后续重构任务,不在本次范围内**)。 3. ChatComposer.vue 直接使用 ``。 SkillSelect 同理(数据源 `getSkillList`)。 ### 5.6 历史会话回看 **复用 RunHistory.vue 的回放模式**: 1. 用户点击会话列表中某条历史会话 → `loadSession(sessionId)`。 2. 后端 `GET /api/chat/sessions/{id}` 返回 `{session, messages}`,每条 assistant 消息含 `logs: [{kind, content, toolName, ...}]` 数组(由后端从 `chat_message_log` 表组装)。 3. 前端用 `buildReplayNode({ logs: m.logs })` 把日志数组还原为运行时态对象。 4. `` 渲染: - user 消息:简单气泡显示 `m.content`,悬停显示"编辑"按钮 - assistant 消息:默认折叠态,显示首行 + "展开思考过程" 按钮 + "重新生成"按钮 + 版本切换器(如有多版本) - 展开后:完整复用 `` - system 摘要消息:渲染为浅色分隔条 `ChatSummaryDivider` ### 5.7 多会话并行运行(全局 run 池) > **核心目标**:会话 A 的 run 进行中,用户切到会话 B 发新消息——A 的 SSE 流不中断;切回 A 仍能看到实时流式输出。 #### 5.7.1 设计思路 把"哪个 run 当前在跑"和"当前视图关注哪个 run"两个状态彻底解耦: | 状态 | 维护者 | 用途 | |---|---|---| | 所有进行中的 run + 其 SSE 连接 | `useGlobalRuns` 单例(应用级) | 后台运行,不中断 | | 当前查看的会话 + 当前关注的 runId | `useChat` 实例(页面级) | 渲染当前对话区 | #### 5.7.2 `useGlobalRuns.js` 接口 ```javascript // frontend/src/composables/useGlobalRuns.js(应用级单例) const runs = reactive(new Map()) // runId → { state, pendingAsk, reconnecting, sseController, viewCallback } export function useGlobalRuns() { return { // 启动 SSE 流(创建 AbortController,注册事件回调) startStreaming(runId, lastEventId = null) { ... }, // 视图绑定:useChat 调用,把回调挂上去,便于把事件回写到当前 messages attachView(runId, callback) { ... }, detachView(runId) { ... }, // 查询 getState(runId) { ... }, getPendingAsk(runId) { ... }, isReconnecting(runId) { ... }, isRunning(sessionId) { ... }, // 给侧栏会话列表用,决定是否显示 loading 圈圈 listActiveRuns() { ... }, // 调试用 // 提交 clarify 答案 submitClarify(runId, clarifyId, answer) { ... }, // 自动重连:网络断开时所有 run 自动尝试重连续传 // 自动清理:runId 完成(SUCCESS/FAILED)后保留 5 分钟,然后清理 state } } ``` #### 5.7.3 工作流程示意 ``` [用户在会话 A 发消息] useChat.startRun() → POST /runs 返回 runId-A → messages.push(assistant 占位消息 with runId-A) → globalRuns.startStreaming(runId-A) └→ 创建 EventSource,订阅 /api/chat/runs/runId-A/stream └→ 接收事件 → 调用 viewCallback → useChat.handleSSEEvent └→ 持续流式更新 messages 中 runId-A 对应的消息 [用户切到会话 B] useChat.switchSession(B) → loadSession(B) → ❌ 不调用 globalRuns.stopStreaming(runId-A) → ✅ 仅 detachView(runId-A),但 SSE 连接仍在跑 用户在 B 发消息 → 同样流程创建 runId-B,B 的 SSE 流并存 [用户切回 A] useChat.switchSession(A) → loadSession(A) 加载 A 的 messages(包含 runId-A 那条仍在 RUNNING) → attachView(runId-A, ...) 重新挂上回调 → 此后 SSE 事件再次驱动 messages 更新(已经积累的事件通过 lastEventId 补传) [runId-A 完成] 收到 workflow_complete 事件 → useChat 把消息标记 SUCCESS → globalRuns 5 分钟后清理 runId-A 的 state(保留 chat_message_log 已落库的日志) ``` #### 5.7.4 侧栏会话列表的 loading 指示 ```javascript // ChatSessionList.vue import { useGlobalRuns } from '@/composables/useGlobalRuns' const globalRuns = useGlobalRuns() const sessions = ref([]) // 每个会话右侧的 loading 圈圈 function isActive(session) { return globalRuns.isRunning(session.id) } ``` 模板:`` 或用 NaiveUI 的 ``。 #### 5.7.5 注意事项 - **同会话禁并发 run**:同一 `chat_session` 同时只允许一个 run(前端 `useChat.isRunning` 锁 + 后端校验);不同会话可并行。 - **跨标签页**:`localStorage` 锁继续生效;如果某会话在另一标签页已在跑,本标签页的 `startRun` 直接报错"已在其他标签页运行"。 - **Bridge 端 Agent 池**:`hermes_bridge.py:120` 的 `_agents` LRU 池默认容量 8,多个会话同时跑时不会冲突——每个 sessionId 独立 Agent 实例。如超出容量,最久未访问的会被淘汰,下次该会话发消息时 Bridge 会重建 Agent(保留 SQLite 持久化的消息历史,但运行时上下文丢失)。 - **页面卸载**:`onBeforeUnmount` 中 `globalRuns.detachAllViews()`(仅断开视图回调,不停止 SSE?还是停止?**选择停止**——用户关闭页面意味着不再需要后台运行;下次进入页面时若 run 未完成,通过 `lastEventId` 重连续传)。 ### 5.8 多层记忆 UI(读取视图) > **设计原则**:不在外层重复实现记忆压缩,直接依赖 Hermes Agent 上游的 `compress_context` 能力。前端只提供"读取视图"。 #### 5.8.1 四层记忆的语义对应 | UI 视图 | 数据来源 | 显示形式 | |---|---|---| | **工作记忆**(Working Memory) | 当前 Agent 实例的实时上下文(活跃消息) | 对话区中的消息流(默认视图) | | **短期记忆**(Short-term) | `chat_message_log` 中当前激活分支的最近 N 条 assistant 消息日志 | 对话区上方"最近工具调用"侧边面板(可折叠) | | **中期记忆**(Medium-term) | 已被上游 `compress_context` 压缩的 `chat_message(role=SYSTEM, status=SUMMARY)` 记录 | 对话区中插入的"压缩摘要分隔条";点击展开查看摘要全文 | | **长期记忆**(Long-term) | Hermes Agent 的 `MEMORY.md` / `USER.md` 持久记忆(上游文件) | 设置抽屉中的"长期记忆"标签页,通过 `GET /api/chat/sessions/{id}/memory` 读取(**Bridge 需新增端点**) | #### 5.8.2 关键 UI 组件 - **`ChatSummaryDivider.vue`**:对话流中遇到 `role=SYSTEM, status=SUMMARY` 消息时渲染为浅色横条,文本如"前 23 条消息已压缩为摘要(点击展开)" - **`ChatMemoryDrawer.vue`**:右上角"记忆"按钮打开的抽屉 - 标签页 1:长期记忆(`MEMORY.md` / `USER.md`,只读,可编辑后调 Bridge 端点保存) - 标签页 2:压缩历史(列出所有 SUMMARY 消息,可逐条展开) - 标签页 3:会话统计(消息数、Token 估算、压缩次数、最近压缩时间) #### 5.8.3 触发压缩的提示 上游压缩由 Agent 内部根据 Token 阈值自动触发,前端不主动控制。当 Bridge SSE 推送 `context_compacted` 事件时,前端: 1. 在当前对话流中插入一条 `SYSTEM` 摘要消息(视觉分隔) 2. 在状态栏显示"上下文已压缩"通知 3. 重新加载长期记忆视图(如果有变化) #### 5.8.4 关键依赖(Bridge 端改造) - Bridge 需在 SSE 事件中暴露压缩动作(`context_compacted` 事件,携带 `summary` 字段)——**需要 Bridge 改造** - Bridge 需新增 `GET /sessions/{sessionId}/memory` 端点返回 `MEMORY.md` / `USER.md` 内容 - Bridge 需新增 `POST /sessions/{sessionId}/memory` 端点保存编辑后的长期记忆 > 第一版可不实现"长期记忆编辑"和"压缩历史视图"——先做基础对话流 + 摘要分隔条,桥端 `context_compacted` 事件配合。后续迭代补全。 --- ## 6. 关键技术点 ### 6.1 多轮对话 session_id 管理 - **首次对话**:前端 `POST /api/chat/sessions` 创建会话,后端生成 `hermesSession = "chat-" + UUID` 存入 `chat_session.hermes_session`。 - **后续每轮**:`ChatRunService.startRun` 从 `chat_session.hermes_session` 读出 sessionId 传给 `HermesBridgeClient.run(..., sessionId, ...)`。 - **Bridge 行为**:`hermes_bridge.py:453-458` 的 `agent.run_conversation(session_id=...)` 会复用 Agent 实例(包括记忆、上下文),实现真正的多轮。 - **会话切换**:每个 chat_session 有独立的 hermes_session,不会串话。 ### 6.2 SSE 断线重连 **完全复用现有机制**: - `GET /api/chat/runs/{runId}/stream?lastEventId=N`:基于 `SseEventBus.subscribe(runId, lastEventId)`,按 lastEventId 补发历史事件。 - `GET /api/chat/runs/{runId}/state`:回退方案,前端 30 秒轮询查询当前状态 + 当前 clarify 暂停点。 - 前端 `useChat.js` 复用 `consumeSSEStream / tryReconnect / pollRunState` 三层降级(从 `useWorkflowRunner.js` 抽出)。 ### 6.3 clarify 流程 - Bridge SSE 事件 `clarify_request` → 后端 `wrapSinkWithClarifyHook` 拦截 → 写入 `workflow_pause` 表(run_id 关联,node_id 填 "agent" 常量)→ 推到前端。 - 前端 `pendingAsk` 状态触发 `AskforPanel`。 - 用户提交答案 → `POST /api/chat/runs/{runId}/resume` → 后端调 `HermesBridgeClient.submitClarifyAnswer(clarifyId, answer)` → 唤醒 Bridge 阻塞线程。 ### 6.4 消息版本管理(重新生成 / 编辑) #### 6.4.1 数据结构 `chat_message` 表的 `parent_id` + `is_active` + `branch_order` 构成消息树(详见 §4.1.2/§4.1.4)。 #### 6.4.2 操作语义 | 操作 | 数据变更 | 备注 | |---|---|---| | 重新生成 assistant | UPDATE 旧 assistant `is_active=false`;INSERT 新 assistant 同 `parent_id`, `is_active=true`, `branch_order+1` | 同 parent_id 下形成兄弟分支 | | 编辑 user | UPDATE 旧 user `is_active=false`;INSERT 新 user 同 `parent_id`, `is_active=true`, `content=新文本` | 不立即触发 run;前端单独调 POST /runs | | 切换版本 | UPDATE 同 parent_id 所有消息 `is_active=false`;UPDATE 指定消息 `is_active=true` | 前端切换"上一版/下一版" | | 删除消息 | 不真删,仅 `is_active=false` | 保留历史可追溯;CASCADE 删除仅由 session 删除触发 | #### 6.4.3 Hermes Agent 上下文一致性 - Hermes Agent 通过 `session_id` 复用实例 + SQLite 持久化消息历史。 - 重新生成场景下:Agent 的"记忆"仍包含旧回复,新生成的回复可能与旧版相似。 - **解决**:调用 Bridge 时携带 `parent_message_id`,Bridge 端基于该 id 截断 SQLite 历史到该位置(`messages WHERE id <= parent_id`),从而模拟"从此点重新生成"的语义——**需要 Bridge 改造**。 - 简化方案(第一版):不截断,让 Agent 完整记忆——重新生成可能产生相似的回复,但实现简单。后续迭代支持精准回退。 ### 6.5 多会话并行运行架构 详见 §5.7。关键点: - **全局 run 池**:`useGlobalRuns` 单例管理所有进行中的 SSE 连接,会话切换仅切换"视图绑定",不关闭连接。 - **后端无状态**:SSE 连接由 `SseEventBus` 维护,与 HTTP session 无关,可被任意客户端订阅(按 `runId`)。 - **Bridge 端 Agent 池容量**:`hermes_bridge.py:120` 的 `_agents` OrderedDict LRU 默认 8,超出时淘汰最久未访问。多会话并行需保证不同 sessionId 不冲突——可正常并行;若同时跑超 8 个会话,最老的 Agent 被淘汰(SQLite 历史不丢,但运行时上下文丢失,下次该会话发消息时 Agent 重建)。 - **资源限制**:建议前端 + 后端校验"同时活跃 run 数上限"(如 5 个),避免单用户开太多并行会话压垮 Bridge。 ### 6.6 多层记忆模式(基于 Hermes Agent 上游) #### 6.6.1 上游能力概览 Hermes Agent `compress_context`(详见 `backend/hermes-agent/agent/conversation_compression.py:2128` + `agent/context_compressor.py:1317`)已实现: - **触发**:当前对话 token 用量超过模型上下文的 `compression.threshold`(默认 0.50,即 50%)时自动触发 - **流程**:4 阶段 LLM 摘要(gather → summarize → format → finalize),把早期消息归档为结构化摘要 - **软归档**:被压缩的消息 `active=0, compacted=1`(详见 `hermes_state.py:6413` 的 `archive_and_compact`),仍保留在 SQLite 中可查询,但不进入下次 LLM 上下文 - **跨次压缩连续性**:通过 `_previous_summary` 字段把上次摘要带入下次压缩,避免摘要漂移 - **配置**:`compression.threshold=0.50`、`compression.target_ratio=0.20`、`compression.protect_last_n=20`(详见 `hermes_cli/config_defaults.py:559-747`) #### 6.6.2 设计原则 **外层(agent-management)不重复实现压缩,只做读取视图**。 - ❌ 不在 Java 端扫描消息历史 → 调 LLM 生成摘要 - ❌ 不在外层维护"摘要表" - ✅ 完全依赖 Hermes Agent 自身的 `compress_context` 自动触发 - ✅ 前端通过 Bridge SSE 事件 + 数据库查询展示"工作 / 短期 / 中期 / 长期"四层视图(详见 §5.8) #### 6.6.3 辅助压缩模型注意点 - 上游配置 `compression.auxiliary.compression.model: null` 默认与主模型一致 - **关键约束**:辅助模型的 context window 必须 ≥ 主模型,否则压缩过程中可能 silently drop 中间片段 - agent-management 端在 `AiModelConfig` 上提供"是否可作为压缩模型"标签,让用户在模型管理页面标注(**后续迭代,不在第一版**) #### 6.6.4 与消息树的交互 - 压缩发生时,Bridge SSE 推送 `context_compacted` 事件(携带 `summary` + `compacted_until_message_id`) - 后端监听该事件,在 `chat_message` 表 INSERT 一条 `role=SYSTEM, status=SUMMARY, content=summary` 消息,`parent_id` 指向 `compacted_until_message_id` - 该 SYSTEM 消息参与消息树展示,但**不参与重新生成 / 编辑的分支判断**(前端跳过 role=SYSTEM 节点) ### 6.7 SSE 断线重连(适配全局池) 详见 §5.7。关键变化: - 单个 SSE 连接的断线重连由 `useGlobalRuns` 内部处理,不依赖当前视图 - 网络恢复后,连接自动以 `lastEventId` 续传,期间产生的事件不丢失 - 若 `runId` 已完成(后端已发 `workflow_complete`),不再重连,直接通过 `GET /api/chat/runs/{runId}/state` 拉取最终状态 ### 6.8 clarify 流程(适配全局池) - Bridge SSE 事件 `clarify_request` → 后端 `wrapSinkWithClarifyHook` 拦截 → 写入 `workflow_pause` 表 → 推到前端 - 前端 `useGlobalRuns` 把 `pendingAsk` 存到 runId 对应的 state 中 - 若该 runId 是当前视图关注的 run → `useChat` 派生出 `pendingAsk`,触发 AskforPanel - 若该 runId 不是当前视图关注的 run(用户切到别的会话了)→ AskforPanel 不显示;但侧栏会话列表显示"等待输入"角标,点击切回即可看到 AskforPanel - 用户提交答案 → `globalRuns.submitClarify(runId, clarifyId, answer)` → POST /resume → 唤醒 Bridge 阻塞线程 ### 6.9 并发与生命周期 - **单会话单流**:同一 chat_session 不允许并发 run(前端 `useChat.isRunning` 锁 + 后端校验)。 - **跨会话并行**:不同 chat_session 可并行(受全局上限约束,见 §6.5)。 - **会话切换**:前端切换会话时**不** abort SSE——交给全局池继续维护。 - **页面卸载**:`onBeforeUnmount` 调用 `globalRuns.shutdown()` 关闭所有 SSE 连接。 - **跨标签页**:用 `localStorage.setItem('chat-active-run-' + sessionId, runId)` 简单锁。 ### 6.10 性能 - `chat_message_log` 表:单条 assistant 消息产生 10-500 条日志,逐行 INSERT 累计耗时可控(详见 §4.1.5 性能注释)。批量提交(每 50 条或 200ms flush)可进一步降低开销。 - `getSessionDetail` 返回时考虑分页(最近 50 条),更老的通过 `GET /api/chat/sessions/{id}/messages?before=msgId` 懒加载。 - `chat_session` 表索引 `(status, last_message_at DESC)` 加速左侧列表查询。 - `chat_message` 表索引 `(session_id, created_at)` + `(parent_id, is_active)` 加速消息流与版本查询。 - `chat_message_log` 表索引 `(message_id, seq)` + `(message_id, kind)` 加速日志回放与按类型筛选。 --- ## 7. 复用矩阵 | 资产 | 状态 | 备注 | |---|---|---| | `SseEventBus` | ✅ 直接复用 | 注入即用 | | `WorkflowPause` 表 + Repository | ✅ 直接复用 | node_id 填 "agent" 常量 | | `HermesBridgeClient.run` | ✅ 直接复用 | 9 参数签名已涵盖所有场景 | | `HermesBridgeClient.submitClarifyAnswer` | ✅ 直接复用 | | | `ExecutionLog` + `WorkflowRunEvent` | ✅ 直接复用 | SSE 事件 DTO | | `AiModelService` / `SkillService` | ✅ 直接复用 | | | `agent-thinking/*` 6 个组件 | ✅ 直接复用 | 核心复用点 | | `runResultReplay.js` / `hermesThinking.js` | ✅ 直接复用 | 历史回放 | | `useWorkflowRunner.js` 的 SSE/重连/clarify 逻辑 | 🟡 抽取核心逻辑 | 复制到 `useChat.js`,去掉 nodeResults 数组与 VueFlow 耦合 | | `WorkflowLevelExecutor.wrapSinkWithClarifyHook` | 🟡 抽取为工具方法 | 供两条链路共用 | | `WorkflowEditor.vue` 内联的 `` 模型选择器 | 🟡 抽取为 ModelSelect | WorkflowEditor 后续重构可同步替换 | | `WorkflowEngine` / `WorkflowLevelExecutor` 主流程 | ❌ 不复用 | 绕过,避免无关开销 | | `WorkflowRun` / `WorkflowRunNode` 表 | ❌ 不复用 | 新建 ChatSession / ChatMessage 语义更清晰 | | `WorkflowController` 现有端点 | ❌ 不复用 | 新建 ChatController 路径 `/api/chat/...` | --- ## 8. 实施计划 按依赖顺序分阶段(每阶段可独立验证): ### 阶段 1:后端骨架(约 2 天) 1. 新建 `ChatSession` / `ChatMessage` / `ChatMessageLog` 实体 + Repository + 基础 CRUD Service 2. 新建 `ChatController`:会话 CRUD 端点 3. 抽取 `ClarifyHooks.wrap(sink, runId, pauseRepo)` 工具方法(从 `WorkflowLevelExecutor.wrapSinkWithClarifyHook` 复制) 4. 实现 `ChatRunService.startRun(...)`:调 `HermesBridgeClient.run` + `SseEventBus.publish` + 批量日志写入(缓冲 flush) 5. 添加 `GET /api/chat/runs/{runId}/stream` / `state` / `POST /resume` 端点 6. 实现自动标题(`autoTitleIfNeeded`) 7. 单元/集成测试:构造一次问答,验证 SSE 流式输出 + 持久化(含 `chat_message_log` 行) **验证**:用 curl/Postman 触发 `POST /api/chat/sessions/{id}/runs`,订阅 SSE 流,确认事件正常推送;查询 `chat_message_log` 表确认日志逐行落库。 ### 阶段 2:前端骨架 + 多会话并行(约 2-3 天) 1. 抽取 `ModelSelect.vue` / `SkillSelect.vue` 组件 2. 实现 `useGlobalRuns.js` 单例(核心:管理 SSE 连接、视图绑定、自动重连) 3. 实现 `useChat.js` composable(绑定 `useGlobalRuns`) 4. 实现 `api/chat.js` 封装 5. 实现 `ChatPage.vue` + 子组件(SessionList with loading 指示 / MessageList / MessageItem / Composer) 6. 路由 + 侧栏菜单注册 **验证**: - 完整跑通"新建会话 → 发消息 → 看到思考过程 → 收到回复" - **多会话并行场景**:会话 A run 进行中 → 切到 B 发新消息 → 切回 A 仍能看流式 → A 完成后看到完整回复 ### 阶段 3:clarify + 断线重连(约 1 天) 1. 验证 clarify_request → AskforPanel → 提交答案 → 继续执行 全链路 2. 验证刷新页面后通过 `lastEventId` 重连续传 3. 验证关闭浏览器后通过 `GET /state` 轮询恢复 clarify 等待态 4. 验证多会话并行时 clarify 在非当前会话的处理(侧栏角标提示) ### 阶段 4:版本管理(重新生成 / 编辑)(约 1-2 天) 1. 实现后端 `regenerate` / `editUserMessage` / `activateVersion` Service + Controller 2. 实现前端 `ChatMessageVersionSwitcher.vue` 切换组件 3. 实现消息编辑交互(hover 显示编辑按钮 → 弹出编辑框 → 提交后触发新 run) 4. 验证 Hermes Agent 上下文一致性(第一版不截断历史,仅测试效果) ### 阶段 5:导出 + 会话重置(约 1 天) 1. 实现后端 `export(sessionId, format, branch)` Service(Markdown + JSON 两种格式) 2. 实现前端导出按钮 + 下载逻辑 3. 实现 Bridge 端 `POST /sessions/{id}/reset` 端点(删除 Agent 实例) 4. 实现前端"重置会话"按钮(重置后保留消息历史,仅清空 Agent 上下文) ### 阶段 6:多层记忆 UI(约 1-2 天,部分依赖 Bridge 改造) 1. Bridge 端 SSE 推送 `context_compacted` 事件 2. 后端监听该事件写入 `chat_message(role=SYSTEM, status=SUMMARY)` 3. 前端实现 `ChatSummaryDivider.vue` 分隔条 4. 后续迭代:长期记忆视图(依赖 Bridge `GET/POST /sessions/{id}/memory` 端点) ### 阶段 7:打磨(按需) 1. 会话搜索、归档 2. 助手回复复制 3. Token 用量统计 4. 多标签页锁提示 --- ## 9. 风险与开放问题 ### 9.1 已识别风险 | 风险 | 缓解措施 | |---|---| | Hermes Bridge 长时间运行(数小时 clarify 等待)下 session_id 复用可能因 Python 进程重启失效 | Bridge 进程重启后 session_id 失效,下次对话会自动创建新 Agent(Bridge 端的 `_agents` 字典 LRU 清理);前端检测到 "session not found" 错误时,自动新建会话 | | `chat_message_log` 单条消息日志行数过多(极端长 run 可能 500+ 行) | 批量 INSERT(每 50 条或 200ms flush);按 `message_id+seq` 索引高效查询;超过阈值(如 1000 行)报警并截断思考流 | | Agent 调用工具时访问文件系统可能产生工作目录污染 | Agent 问答场景下传 `workingDir=null`,禁用文件工具;如未来需要,传临时目录 + 复用 `sandbox_patch.py` | | 同一用户在多个标签页打开同一会话 | localStorage 简单锁;后期可引入 WebSocket 双向通知 | | 多轮对话累积上下文导致 Token 超限 | 复用 Hermes Agent 自身的 `compress_context` 能力(上游已实现),不额外处理;前端通过 `context_compacted` 事件提示 | | 多会话并行时 Bridge Agent 池溢出(默认 8 个上限) | 前端 + 后端校验"同时活跃 run 数上限"(建议 5 个);超出时拒绝新建 run 并提示用户 | | 重新生成/编辑后 Hermes Agent 上下文不一致 | 第一版不截断 Agent 历史,仅新建分支;接受"重新生成可能相似"的体验;后续 Bridge 改造支持历史截断 | | `chat_message_log` 行级写入开销(高频小事务) | 缓冲 flush 机制(50 条或 200ms 批量提交);极端场景可关闭逐行持久化,仅 final 时一次性写入 | | 辅助压缩模型 context window 小于主模型导致摘要 silently drop | 在 `AiModelConfig` 标注"是否可作为压缩模型";后续在模型选择器过滤 | | Bridge 端 Agent LRU 淘汰后下次对话丢失运行时上下文 | session SQLite 历史不丢,但 Agent 运行时状态丢失——下次消息会重建 Agent,对话连续性受影响;可考虑增大 LRU 容量或后台保活 | ### 9.2 已确认的决策(原开放问题) | 决策点 | 结论 | |---|---| | 是否需要"重新生成回复" | **需要**(阶段 4 实现) | | 是否需要"消息编辑" | **需要**(阶段 4 实现,与重新生成共用消息树) | | 会话标题策略 | **自动从首条消息前 30 字截取**,用户可在侧栏双击编辑 | | 是否支持文件上传 | **暂缓**(架构上预留 `chat_message.attachment_id` 字段) | | 是否需要导出对话 | **需要**(阶段 5 实现 Markdown + JSON 两种格式) | | 是否支持多会话并行 | **需要**(阶段 2 即实现,核心:全局 run 池) | | 多层记忆模式 | **复用上游 + UI 视图**(阶段 6 部分实现,长期记忆编辑后续迭代) | ### 9.3 仍待确认的问题 1. **Bridge 端 Agent LRU 容量**:当前默认 8,是否需要增大以支持更多并行会话?(建议保持 8,超出时优雅降级) 2. **会话搜索范围**:仅搜标题,还是扩展到消息内容(需要全文索引)? 3. **导出格式细节**:Markdown 用什么模板?JSON 是否包含 `chat_message_log` 完整日志? 4. **多用户场景**:当前设计未涉及用户体系(所有会话对所有人可见),未来接入用户系统时是否需要加 `owner_id` 字段? --- ## 10. 参考链接 ### 现有可复用资产 - [`backend/src/main/java/com/agent/management/engine/hermes/HermesBridgeClient.java`](../../backend/src/main/java/com/agent/management/engine/hermes/HermesBridgeClient.java) —— Bridge 调用入口 - [`backend/src/main/java/com/agent/management/external/SseEventBus.java`](../../backend/src/main/java/com/agent/management/external/SseEventBus.java) —— SSE 事件总线 - [`backend/src/main/java/com/agent/management/engine/WorkflowLevelExecutor.java:790`](../../backend/src/main/java/com/agent/management/engine/WorkflowLevelExecutor.java) —— `wrapSinkWithClarifyHook` 抽取源 - [`frontend/src/components/agent-thinking/`](../../frontend/src/components/agent-thinking/) —— 思考过程展示组件目录 - [`frontend/src/composables/useWorkflowRunner.js`](../../frontend/src/composables/useWorkflowRunner.js) —— `useChat.js` 的逻辑抽取源 - [`frontend/src/utils/runResultReplay.js`](../../frontend/src/utils/runResultReplay.js) —— 历史回放工具 ### 上游压缩相关源码 - [`backend/hermes-agent/agent/conversation_compression.py:2128`](../../backend/hermes-agent/agent/conversation_compression.py) —— `compress_context` 主流程 - [`backend/hermes-agent/agent/context_compressor.py:1317`](../../backend/hermes-agent/agent/context_compressor.py) —— `ContextCompressor` 类 - [`backend/hermes-agent/hermes_state.py:6413`](../../backend/hermes-agent/hermes_state.py) —— `archive_and_compact` 软归档逻辑 - [`backend/hermes-agent/hermes_state_schema.py:655`](../../backend/hermes-agent/hermes_state_schema.py) —— `messages` 表 schema(`active` / `compacted` 字段) - [`backend/hermes-agent/hermes_cli/config_defaults.py:559-747`](../../backend/hermes-agent/hermes_cli/config_defaults.py) —— 压缩配置默认值 - [`backend/hermes-bridge/hermes_bridge.py:120-123, 170`](../../backend/hermes-bridge/hermes_bridge.py) —— Bridge `_agents` LRU 池(多会话并行基础) ### 相关文档 - [`docs/design/hermes-sandbox-design.md`](./hermes-sandbox-design.md) —— Hermes Bridge 沙箱设计 - [`docs/reference/hermes-agent-upgrade-reference.md`](../reference/hermes-agent-upgrade-reference.md) —— Hermes-Agent 升级参考 - [`docs/design/workflow-output-envelope-design.md`](./workflow-output-envelope-design.md) —— 节点输出信封化设计(消息状态结构参考) ### 上游依赖 - Hermes Bridge `/run` 端点支持 `systemPrompt + userMessage + sessionId + modelConfig`(详见 `backend/hermes-bridge/hermes_bridge.py:138-149`) - Hermes Agent 支持纯对话模式(`agent.run_conversation`,详见 `backend/hermes-bridge/hermes_bridge.py:453-458`) - 上游 PR [#69774](https://github.com/NousResearch/hermes-agent/pull/69774) 已让 clarify 永久等待成为原生能力(详见 [`docs/patches/hermes-agent/README.md`](../patches/hermes-agent/README.md)) - 上游 `compress_context` 自动触发,Bridge 通过 SSE 暴露 `context_compacted` 事件(**需 Bridge 改造,第一版可缺失**)