agent-chat-page-design.md 72 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
是否持久化历史 需要 关闭页面后可在侧栏切换历史会话回看,类似现有"运行历史"
持久化策略 分层结构化存储 主表 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(会话主表)

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

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(消息日志子表,结构化)

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):核心方法

    @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<ExecutionLog> logs, startSeq):批量写入
  • findByMessage(messageId):按 seq 排序返回,可映射为 ExecutionLog[] 给前端回放
  • findByMessageAndKind(messageId, kind):按类型筛选

4.3.4 ChatRunService(核心编排)

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<ExecutionLog> 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}/resetBridge 新增端点
  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(...) 签名已经包含所有需要的参数:

// 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 端 SseEventBusrunId 维护订阅,天然支持多个 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 对话"入口,图标用 chatmessage

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 <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[] 还原思考过程
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):

// 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 处 <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. 后端 GET /api/chat/sessions/{id} 返回 {session, messages},每条 assistant 消息含 logs: [{kind, content, toolName, ...}] 数组(由后端从 chat_message_log 表组装)。
  3. 前端用 buildReplayNode({ logs: m.logs }) 把日志数组还原为运行时态对象。
  4. <ChatMessageItem :message="m"> 渲染:
    • user 消息:简单气泡显示 m.content,悬停显示"编辑"按钮
    • assistant 消息:默认折叠态,显示首行 + "展开思考过程" 按钮 + "重新生成"按钮 + 版本切换器(如有多版本)
    • 展开后:完整复用 <AgentThinkingPanel :node="m.runtime" />
    • 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 接口

// 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 指示

// ChatSessionList.vue
import { useGlobalRuns } from '@/composables/useGlobalRuns'
const globalRuns = useGlobalRuns()

const sessions = ref([])
// 每个会话右侧的 loading 圈圈
function isActive(session) {
  return globalRuns.isRunning(session.id)
}

模板:<span v-if="isActive(s)" class="loading-dot"></span> 或用 NaiveUI 的 <n-spin :size="12" />

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 持久化的消息历史,但运行时上下文丢失)。
  • 页面卸载onBeforeUnmountglobalRuns.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.startRunchat_session.hermes_session 读出 sessionId 传给 HermesBridgeClient.run(..., sessionId, ...)
  • Bridge 行为hermes_bridge.py:453-458agent.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:6413archive_and_compact),仍保留在 SQLite 中可查询,但不进入下次 LLM 上下文
  • 跨次压缩连续性:通过 _previous_summary 字段把上次摘要带入下次压缩,避免摘要漂移
  • 配置compression.threshold=0.50compression.target_ratio=0.20compression.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 表 → 推到前端
  • 前端 useGlobalRunspendingAsk 存到 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 内联的 <n-select> 模型选择器 🟡 抽取为 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. 参考链接

现有可复用资产

上游压缩相关源码

相关文档

上游依赖

  • 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
  • 上游 compress_context 自动触发,Bridge 通过 SSE 暴露 context_compacted 事件(需 Bridge 改造,第一版可缺失