# 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`) | | 是否持久化历史 | **需要** | 关闭页面后可在侧栏切换历史会话回看,类似现有"运行历史" | --- ## 2. 用户故事 ### 2.1 典型流程 1. 用户从侧栏进入"Agent 对话"页面。 2. 左侧"会话列表"展示历史会话(按时间倒序),点击新建会话按钮开始新对话。 3. 右侧"对话区"是消息流(用户消息 / 助手回复交替)+ 底部输入区。 4. 输入区上方有"模型选择器"和"技能选择器"两个下拉(均可留空): - 不选模型 → 用系统默认模型 - 不选技能 → Hermes 凭自身系统提示与 `skill_view` 工具自主决策 - 选了技能 → 把该技能的 `SKILL.md` 作为系统提示词注入 5. 用户输入问题、点击发送: - 用户消息立即出现在对话区 - 助手回复区出现一个"思考中"占位卡片,卡片内实时展示思考过程(5 区域:思考流、实时状态、当前工具、工具历史、待办清单) - 若 Agent 中途发起 clarify(请求用户确认),卡片内出现 AskforPanel,用户点选项或自定义输入后继续 - 完成后,思考过程折叠为一行摘要,最终答案展开显示 6. 用户可基于当前回复继续提问,会话上下文累积。 7. 关闭页面后再次进入,会话列表仍可见,点击任一会话可回看完整对话。 ### 2.2 边界场景 - **会话切换**:用户从会话 A 切到会话 B,A 的 SSE 流应被正确清理(不留监听器、不混状态)。 - **会话进行中刷新页面**:通过 SSE 断线重连机制(`GET /api/chat/runs/{runId}/stream?lastEventId=N`)恢复。 - **会话进行中关闭浏览器再打开**:通过持久化的 `WorkflowPause` + `GET /api/chat/runs/{runId}/state` 轮询恢复 clarify 等待态。 - **跨标签页**:同一会话不允许在多个标签页同时发新消息(前端用 `localStorage` 简单锁,避免 session_id 冲突)。 --- ## 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 数据模型(新增两张表) #### 4.1.1 `chat_session`(会话主表) ```sql CREATE TABLE chat_session ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_uuid VARCHAR(36) NOT NULL UNIQUE, -- 对外暴露的 UUID(即 Hermes session_id) title VARCHAR(255), -- 会话标题(默认取首条用户消息前 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 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 run_id VARCHAR(64), -- 关联本轮 Bridge run(assistant 消息才有,user 消息为 NULL) role VARCHAR(16) NOT NULL, -- USER / ASSISTANT / SYSTEM(预留) content TEXT NOT NULL, -- 消息文本(user 输入原文 / assistant 最终回复) status VARCHAR(16) NOT NULL, -- USER(Always 'SENT') / ASSISTANT(RUNNING/SUCCESS/FAILED) model_id BIGINT, -- 实际使用的模型(冗余存储,便于回看当时配置) skill_folder VARCHAR(255), -- 实际使用的技能 logs_json TEXT, -- ASSISTANT 思考过程日志(同 WorkflowRunNode.logs 结构) error TEXT, -- FAILED 时的错误信息 created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, completed_at TIMESTAMP, FOREIGN KEY (session_id) REFERENCES chat_session(id) ON DELETE CASCADE ); CREATE INDEX idx_chat_message_session ON chat_message(session_id, created_at); ``` > `logs_json` 字段存储与 `WorkflowRunNode.logs` 完全相同的 ExecutionLog 数组(JSON),这样前端可**直接复用** `runResultReplay.js` 的 `buildReplayNode` 函数还原思考过程。 #### 4.1.3 持久化策略 | 事件 | 操作 | |---|---| | 用户发起新会话 | INSERT chat_session(hermes_session 用 `UUID` 或自增 id 转字符串) | | 用户发送消息 | INSERT chat_message(role=USER, status=SENT) → 立即返回;异步触发 Bridge run | | 助手 run 启动 | INSERT chat_message(role=ASSISTANT, status=RUNNING, run_id=xxx) | | 助手 run 完成(success) | UPDATE chat_message SET status=SUCCESS, content=finalText, logs_json=..., 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 | > **复用 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?}]` | | `GET` | `/api/chat/sessions/{id}` | 获取会话详情 + 所有消息。返回 `{session, messages: [{role, content, status, logs, ...}]}` | | `PATCH` | `/api/chat/sessions/{id}` | 更新会话(改标题、切换 modelId/skillFolder、归档) | | `DELETE` | `/api/chat/sessions/{id}` | 删除会话(级联删除消息) | #### 4.2.2 单轮问答(核心) | Method | Path | 用途 | |---|---|---| | `POST` | `/api/chat/sessions/{sessionId}/runs` | 发起一轮问答。Body: `{message, modelId?, skillFolder?, maxIterations?}`。返回 `{runId}`,**SSE 流通过单独端点订阅** | | `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** | > **路径前缀决策**:新端点统一用 `/api/chat/...`,与现有 `/api/workflows/...` 平级。虽然 `GET stream` / `GET state` / `POST resume` 在逻辑上与 WorkflowController 同名端点等价(都基于 SseEventBus + WorkflowPause),但**不共享路径**——独立 Controller 更清晰,未来如果 chat 与工作流的 clarify 处理逻辑出现分歧也好扩展。 #### 4.2.3 资源选择器辅助 | Method | Path | 用途 | |---|---|---| | `GET` | `/api/models` | 已有,列出模型 | | `GET` | `/api/skills` | 已有,列出技能(folderName + 名称 + 描述) | ### 4.3 Service 层 #### 4.3.1 `ChatSessionService` / `ChatSessionRepository` 标准 CRUD。增加 `touch(sessionId)` 方法更新 `last_message_at`。 #### 4.3.2 `ChatMessageService` / `ChatMessageRepository` 标准 CRUD。增加: - `appendLog(runId, log)`:assistant run 过程中流式累积 logs(可选;也可仅在完成时一次性写入) - `markSuccess(runId, finalText, logsJson)` - `markFailed(runId, error)` #### 4.3.3 `ChatRunService`(核心编排) ```java public String startRun(Long sessionId, String userMessage, Long modelId, String skillFolder, Integer maxIterations) { ChatSession session = sessionRepo.findById(sessionId).orElseThrow(...); // 1. 写用户消息 messageRepo.save(new ChatMessage(sessionId, Role.USER, userMessage, Status.SENT)); // 2. 解析模型配置 AiModelConfig modelConfig = (modelId != null) ? aiModelService.getModelConfig(modelId) : null; // null → Bridge 走环境变量默认 // 3. 解析技能系统提示 String systemPrompt = (skillFolder != null) ? skillService.getSkillFullContent(skillFolder) : null; // 4. 生成 runId,注册 SSE 总线 String runId = UUID.randomUUID().toString().substring(0, 8); eventBus.register(runId); // 5. 写助手占位消息 ChatMessage assistantMsg = messageRepo.save(new ChatMessage( sessionId, runId, Role.ASSISTANT, "", Status.RUNNING, modelId, skillFolder)); // 6. 异步调 Bridge executor.submit(() -> { try { List logs = new ArrayList<>(); String finalText = hermesBridgeClient.run( systemPrompt, userMessage, maxIterations != null ? maxIterations : 100, null, null, session.getHermesSession(), // session_id 复用 Agent 实例 null, runId, modelConfig, (kind, content, toolName) -> { // sink 回调 WorkflowRunEvent evt = WorkflowRunEvent.nodeStream(runId, "agent", kind, content, toolName); eventBus.publish(runId, evt.getType(), evt); logs.add(ExecutionLog.fromStream(kind, content, toolName)); // 累积持久化 }); messageRepo.markSuccess(runId, finalText, toJson(logs)); sessionRepo.touch(sessionId); } catch (Exception e) { messageRepo.markFailed(runId, e.getMessage()); eventBus.publish(runId, "workflow_error", WorkflowRunEvent.workflowError(runId, e.getMessage())); } }); return runId; } ``` > **关键复用点**: > - `HermesBridgeClient.run(...)` 的 sink 回调签名与 `NodeStreamSink.emit(nodeId, kind, content, toolName)` 一致,直接传 lambda 即可。 > - `ExecutionLog` 类型枚举与工厂方法直接复用(`ExecutionLog.thinking(...)` / `toolCall(...)` 等)。 > - `WorkflowRunEvent.nodeStream` / `workflowError` 静态工厂直接复用。 ### 4.4 HermesBridgeClient 改造评估 **结论:无需改造**。 现有 `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)`)供两条链路共用。 --- ## 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` | 左侧会话列表(新建/切换/删除/重命名) | | `frontend/src/views/chat/ChatMessageList.vue` | 中间消息流(user/assistant 交替) | | `frontend/src/views/chat/ChatMessageItem.vue` | 单条消息渲染:user 简单气泡,assistant 复用 AgentThinkingPanel | | `frontend/src/views/chat/ChatComposer.vue` | 底部输入区:模型选择器 + 技能选择器 + 文本框 + 发送按钮 | | `frontend/src/composables/useChat.js` | 核心 composable(详见 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_json` 还原思考过程 | | `extractStreamingThinking` | 同文件 :27 | 单独提取思考流(用于历史消息展开) | | `extractToolHistory` | 同文件 :48 | 单独提取工具调用历史 | | `extractLatestTodos` | 同文件 :96 | 单独提取最新 todos | | `stripHermesThinkingDecoration` | `frontend/src/utils/hermesThinking.js:69` | 剥离颜文字装饰 | ### 5.4 `useChat.js` composable 设计 参考现有 `useWorkflowRunner.js`,但去除 VueFlow 画布相关逻辑: ```javascript // frontend/src/composables/useChat.js export function useChat() { // 当前会话状态 const sessions = ref([]) // 会话列表 const currentSession = ref(null) // 当前会话对象 const messages = ref([]) // 当前会话的消息列表 // 运行时态(仅最新一条 assistant 消息) const currentMessage = ref(null) // 形如 { runId, streamingThinking, currentStatus, currentTool, toolHistory, todos } const pendingAsk = ref(null) // { nodeId, clarifyId, question, choices } const reconnecting = ref(false) const isRunning = ref(false) // 输入区状态 const inputText = ref('') const selectedModelId = ref(null) const selectedSkillFolder = ref(null) // SSE 流处理(核心:从 useWorkflowRunner 抽出,去掉 nodeResults 数组逻辑) async function startRun() { const userText = inputText.value.trim() if (!userText || !currentSession.value || isRunning.value) return // 1. 立即把用户消息加到 messages messages.value.push({ role: 'USER', content: userText, createdAt: Date.now() }) inputText.value = '' // 2. 调 POST /api/chat/sessions/{id}/runs const { runId } = await startChatRun(currentSession.value.id, { message: userText, modelId: selectedModelId.value, skillFolder: selectedSkillFolder.value, }) // 3. 创建助手占位消息 currentMessage.value = { runId, streamingThinking: '', currentStatus: '', currentTool: null, toolHistory: [], todos: [], } messages.value.push({ role: 'ASSISTANT', runId, status: 'RUNNING', runtime: currentMessage.value }) isRunning.value = true // 4. 订阅 SSE 流 await consumeSSEStream(runId) } // SSE 事件分发(从 useWorkflowRunner.handleSSEEvent 简化) function handleSSEEvent(event) { if (event.type === 'node_stream' && event.kind === 'thinking') { currentMessage.value.streamingThinking += event.content } else if (event.type === 'node_stream' && event.kind === 'status') { currentMessage.value.currentStatus = stripHermesThinkingDecoration(event.content) } else if (event.type === 'node_stream' && event.kind === 'tool_call') { currentMessage.value.currentTool = { name: event.toolName, argsSummary: event.content, result: null } currentMessage.value.toolHistory.push({ ...currentMessage.value.currentTool }) } else if (event.type === 'node_stream' && event.kind === 'tool_result') { if (currentMessage.value.currentTool) currentMessage.value.currentTool.result = event.content } else if (event.type === 'node_stream' && event.kind === 'todo_update') { currentMessage.value.todos = JSON.parse(event.content) } else if (event.type === 'node_stream' && event.kind === 'clarify_request') { pendingAsk.value = { nodeId: event.nodeId || 'agent', clarifyId: event.clarifyId, question: event.question, choices: event.choices || [], } } else if (event.type === 'workflow_complete') { // 把 finalText 写入 messages 中对应 runId 的助手消息 const msg = messages.value.find(m => m.runId === event.runId) if (msg) { msg.content = event.output?.text || event.finalText || '' msg.status = 'SUCCESS' } isRunning.value = false currentMessage.value = null } else if (event.type === 'workflow_error') { const msg = messages.value.find(m => m.runId === event.runId) if (msg) { msg.status = 'FAILED'; msg.error = event.message } isRunning.value = false currentMessage.value = null } } // 复用 useWorkflowRunner 的 consumeSSEStream / tryReconnect / pollRunState 三层降级 // 仅把 GET /api/workflows/runs/{runId}/stream 改为 GET /api/chat/runs/{runId}/stream // 历史会话加载 async function loadSession(sessionId) { const { session, messages: history } = await getSessionDetail(sessionId) currentSession.value = session selectedModelId.value = session.modelId selectedSkillFolder.value = session.skillFolder // 把持久化的 logs_json 通过 buildReplayNode 还原为运行时态 messages.value = history.map(m => { if (m.role === 'ASSISTANT' && m.logsJson) { return { ...m, runtime: buildReplayNode({ logs: JSON.parse(m.logsJson) }) } } return m }) } // 切换会话时清理 SSE 监听 function switchSession(sessionId) { if (currentMessage.value) { // 主动断开当前 SSE 连接 abortSSE() } currentMessage.value = null pendingAsk.value = null isRunning.value = false loadSession(sessionId) } return { sessions, currentSession, messages, currentMessage, pendingAsk, reconnecting, isRunning, inputText, selectedModelId, selectedSkillFolder, loadSessions: ..., startRun: startRun, submitClarify: ..., switchSession, } } ``` ### 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. 后端返回 `messages` 数组,每条 assistant 消息含 `logsJson` 字段。 3. 前端用 `buildReplayNode({ logs: JSON.parse(logsJson) })` 把日志还原为运行时态对象。 4. `` 渲染: - user 消息:简单气泡显示 `m.content` - assistant 消息:默认折叠态,显示首行 + "展开思考过程" 按钮 - 展开后:完整复用 `` --- ## 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 并发与生命周期 - **单会话单流**:同一 chat_session 不允许并发 run(前端 `isRunning` 锁 + 后端校验)。 - **会话切换**:前端切换会话时主动 `abortSSE()`,避免遗留监听器。 - **页面卸载**:`onBeforeUnmount` 中 abort SSE 连接,避免内存泄漏。 - **跨标签页**:用 `localStorage.setItem('chat-active-run-' + sessionId, runId)` 简单锁,新标签页发现已有 run 在跑则提示"已在其他标签页运行"。 ### 6.5 性能 - `logs_json` 字段可能较大(思考流数千字 + 工具调用结果)。`getSessionDetail` 返回时考虑分页(如仅返回最近 50 条消息,更老的通过 `GET /api/chat/sessions/{id}/messages?before=msgId` 懒加载)。 - `chat_session` 表加索引 `(status, last_message_at DESC)` 加速左侧列表查询。 - `chat_message` 表加索引 `(session_id, created_at)` 加速消息流查询。 --- ## 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:后端骨架(约 1-2 天) 1. 新建 `ChatSession` / `ChatMessage` 实体 + Repository + 基础 CRUD Service 2. 新建 `ChatController`:会话 CRUD 端点 3. 抽取 `ClarifyHooks.wrap(sink, runId, pauseRepo)` 工具方法(从 `WorkflowLevelExecutor.wrapSinkWithClarifyHook` 复制) 4. 实现 `ChatRunService.startRun(...)`:调 `HermesBridgeClient.run` + `SseEventBus.publish` 5. 添加 `GET /api/chat/runs/{runId}/stream` / `state` / `POST /resume` 端点 6. 单元/集成测试:构造一次问答,验证 SSE 流式输出 + 持久化 **验证**:用 curl/Postman 触发 `POST /api/chat/sessions/{id}/runs`,订阅 SSE 流,确认事件正常推送。 ### 阶段 2:前端骨架(约 1-2 天) 1. 抽取 `ModelSelect.vue` / `SkillSelect.vue` 组件 2. 实现 `useChat.js` composable(从 `useWorkflowRunner.js` 抽取核心逻辑) 3. 实现 `api/chat.js` 封装 4. 实现 `ChatPage.vue` + 子组件(SessionList / MessageList / MessageItem / Composer) 5. 路由 + 侧栏菜单注册 6. **不替换** WorkflowEditor.vue 的内联 ``(YAGNI,后续单独重构) **验证**:完整跑通"新建会话 → 发消息 → 看到思考过程 → 收到回复 → 切换会话 → 切回看到历史"。 ### 阶段 3:clarify + 断线重连(约 1 天) 1. 验证 clarify_request → AskforPanel → 提交答案 → 继续执行 全链路 2. 验证刷新页面后通过 `lastEventId` 重连续传 3. 验证关闭浏览器后通过 `GET /state` 轮询恢复 clarify 等待态 ### 阶段 4:打磨(按需) 1. 会话标题自动从首条消息截取 2. 会话搜索、归档 3. 助手回复复制/重新生成 4. Token 用量统计(如果 Hermes Bridge 能返回) 5. 多标签页锁提示 --- ## 9. 风险与开放问题 ### 9.1 已识别风险 | 风险 | 缓解措施 | |---|---| | Hermes Bridge 长时间运行(数小时 clarify 等待)下 session_id 复用可能因 Python 进程重启失效 | Bridge 进程重启后 session_id 失效,下次对话会自动创建新 Agent(Bridge 端的 `_agents` 字典 LRU 清理);前端检测到 "session not found" 错误时,自动新建会话 | | `logs_json` 字段过大影响查询性能 | 单条 message 仅存当前轮日志;会话级聚合通过 message 分页查询;超过 1MB 时考虑压缩存储 | | Agent 调用工具时访问文件系统可能产生工作目录污染 | Agent 问答场景下传 `workingDir=null`,禁用文件工具;如未来需要,传临时目录 + 复用 `sandbox_patch.py` | | 同一用户在多个标签页打开同一会话 | localStorage 简单锁;后期可引入 WebSocket 双向通知 | | 多轮对话累积上下文导致 Token 超限 | 复用 Hermes Agent 自身的 `compress_context` 能力(上游已实现),不额外处理 | ### 9.2 开放问题(待用户/评审确认) 1. **是否需要"重新生成回复"功能**(类似 ChatGPT 的 regenerate 按钮)? 2. **是否需要"消息编辑"功能**(用户修改某条历史消息后重新发送)? 3. **会话标题**:自动从首条用户消息截取,还是要求用户手动填? 4. **是否支持文件上传**(用户上传文件作为提问上下文)? 5. **是否需要导出对话**(Markdown / JSON)? > 这些都是"好用"层面的功能,不影响基础架构。建议第一版仅实现核心流程(新建会话 → 发消息 → 看思考 → 收回复 → 多轮 → 历史回看),上述增强功能按需迭代。 --- ## 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) —— 历史回放工具 ### 相关文档 - [`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))