agent-chat-page-design.md 38 KB

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(会话主表)

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(消息明细表)

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(核心编排)

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<ExecutionLog> 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(...) 签名已经包含所有需要的参数:

// 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 <AgentThinkingPanel :node="currentMessage" :pending-ask="pendingAsk" :reconnecting="reconnecting" @submit-clarify="submitClarify" />
ThinkingLive.vue 同目录 <ThinkingLive :status="msg.currentStatus" />
ToolCalls.vue 同目录 通过 AgentThinkingPanel 间接复用
TodoListPanel.vue 同目录 通过 AgentThinkingPanel 间接复用
AskforPanel.vue 同目录 通过 AgentThinkingPanel 间接复用
RunReconnectBadge.vue 同目录 <RunReconnectBadge :reconnecting="reconnecting" />

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 画布相关逻辑:

// 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 处 <n-select :options="modelOptions">(WorkflowEditor.vue:1533/1665/1756/1854)。建议:

  1. 抽取为 frontend/src/components/common/ModelSelect.vue:

    <template>
     <n-select :value="modelId == null ? null : Number(modelId)"
               :options="modelOptions" placeholder="选择模型(默认使用默认模型)"
               size="small" clearable @update:value="v => $emit('update:modelId', v)" />
    </template>
    <script setup>
    import { ref, onMounted } from 'vue'
    import { getModelList } from '@/api/model'
    const props = defineProps({ modelId: [Number, String, null] })
    const emit = defineEmits(['update:modelId'])
    const modelOptions = ref([])
    onMounted(async () => {
     const list = await getModelList()
     modelOptions.value = list.map(m => ({ label: `${m.name} (${m.modelName})`, value: m.id }))
    })
    </script>
    
  2. WorkflowEditor.vue 的 4 处替换为 <ModelSelect v-model="selectedData.modelId" />(后续重构任务,不在本次范围内)。

  3. ChatComposer.vue 直接使用 <ModelSelect v-model="selectedModelId" />。

SkillSelect 同理(数据源 getSkillList)。

5.6 历史会话回看

复用 RunHistory.vue 的回放模式:

  1. 用户点击会话列表中某条历史会话 → loadSession(sessionId)。
  2. 后端返回 messages 数组,每条 assistant 消息含 logsJson 字段。
  3. 前端用 buildReplayNode({ logs: JSON.parse(logsJson) }) 把日志还原为运行时态对象。
  4. <ChatMessageItem :message="m"> 渲染:
    • user 消息:简单气泡显示 m.content
    • assistant 消息:默认折叠态,显示首行 + "展开思考过程" 按钮
    • 展开后:完整复用 <AgentThinkingPanel :node="m.runtime" />

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 内联的 <n-select> 模型选择器 🟡 抽取为 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 的内联 <n-select>(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. 参考链接

现有可复用资产

相关文档

上游依赖

  • 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 已让 clarify 永久等待成为原生能力(详见 docs/patches/hermes-agent/README.md)