文档目的:为 agent-management 项目新增一个独立的"agent 问答页面",让用户以自然语言与 Hermes Agent 直接对话,过程复用现有工作流运行的思考过程展示能力。 设计时点:2026-08-03 状态:草案,待评审
提供一个 类似 ChatGPT/Claude 的对话页面:用户输入问题、可选选择模型与技能,后端调用 Hermes Bridge 直接执行(不经过工作流编排),前端复用现有 agent-thinking/* 组件实时展示思考过程、工具调用、待办清单、用户确认等中间状态,并支持多轮上下文对话与历史会话回看。
| 决策点 | 选择 | 说明 |
|---|---|---|
| "智能体执行"的语义 | 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 |
skill_view 工具自主决策GET /api/chat/runs/{runId}/stream?lastEventId=N)恢复。WorkflowPause + GET /api/chat/runs/{runId}/state 轮询恢复 clarify 等待态。localStorage 简单锁,避免 session_id 冲突)。is_active=0 仍可查看;用户可用"上一版 / 下一版"按钮在分支间切换。compress_context 把早期消息归档为摘要(active=0, compacted=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
| 维度 | 工作流运行 | 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 持久化、断线重连、思考过程展示组件)。
设计原则:分层结构化存储,便于查询、统计、编辑、版本管理。
- 主表
chat_message:消息元数据(role/status/model/技能)+ 最终文本(user 原文 / assistant 回复)- 子表
chat_message_log:每条 assistant 消息的思考过程日志按"行"结构化存储,与WorkflowRunNode.logs元素一一对应- 通过
parent_id+is_active字段形成"消息树",原生支持"重新生成"与"消息编辑"
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 统计)也方便。
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 = 上一条激活消息的 idparent_id 的 assistant 节点,旧的 is_active 置 false,新的置 true,branch_order 递增parent_id 的 user 节点(旧的置 false),然后基于这条新 user 节点开新的 assistant runparent_id → children 树导航,激活不同的 branch_orderattachment_id:架构上预留文件上传字段,第一版不实现logs_json 字段:日志移到子表 chat_message_log,便于按消息查询、按 kind 统计、按工具筛选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。
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)。
展示规则:
parent_id 取所有兄弟节点,branch_order 排序| 事件 | 操作 |
|---|---|
| 用户发起新会话 | 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 表。
| 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} |
| 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) |
| 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 处理逻辑出现分歧也好扩展。
| Method | Path | 用途 |
|---|---|---|
GET |
/api/models |
已有,列出模型 |
GET |
/api/skills |
已有,列出技能(folderName + 名称 + 描述) |
ChatSessionService / ChatSessionRepository标准 CRUD。增加:
touch(sessionId):更新 last_message_atautoTitleIfNeeded(sessionId):若 title IS NULL,取最近一条 user 消息内容前 30 字(去掉换行)作为标题export(sessionId, format, branch):导出 Markdown / JSON,branch=active 仅导出当前激活分支;branch=all 导出整棵树(含历史版本)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 的所有兄弟消息
ChatMessageLogService / ChatMessageLogRepositoryappendBatch(messageId, List<ExecutionLog> logs, startSeq):批量写入findByMessage(messageId):按 seq 排序返回,可映射为 ExecutionLog[] 给前端回放findByMessageAndKind(messageId, kind):按类型筛选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 时一次性写入。
POST /api/chat/sessions/{id}/reset 流程:
POST /hermes/sessions/{hermesSessionId}/reset(Bridge 新增端点)_agents 字典中删除该 session 的 Agent 实例(key 为 session_id::hermes_home::model_id)chat_session.hermes_session = "chat-" + newUUID(下次对话会创建新 Agent)is_active=false,保留作为历史可见Bridge 端需新增的端点:参考
backend/hermes-bridge/hermes_bridge.py:120-123的_agentsLRU 池,添加POST /sessions/{sessionId}/reset路由,逻辑:_agents.pop(sessionKey, None)。
结论:Java 端无需改造,Bridge 端需新增 2 个端点 + 1 个 SSE 事件类型。
现有 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:本轮 UUIDmodelConfig:从 chat_session.model_id 解析sink:直接用 lambda 推到 SseEventBusclarify 落库拦截:当前 WorkflowLevelExecutor.wrapSinkWithClarifyHook @ WorkflowLevelExecutor.java:790-828 在 sink 外包了一层拦截器,遇到 clarify_request 时写 WorkflowPause 表。Agent 问答需要复用这个拦截器,建议把 wrapSinkWithClarifyHook 抽到一个独立的工具方法(如 ClarifyHooks.wrap(sink, runId, pauseRepo))供两条链路共用。
多会话并行:Java 端 SseEventBus 按 runId 维护订阅,天然支持多个 run 并行;不同 runId 的事件互不干扰。
| 端点 / 事件 | 状态 | 用途 |
|---|---|---|
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端点对应"长期记忆编辑"功能,可放到更后期。
/chat 与 /chat/:sessionId(新增到 frontend/src/router/index.js)AppSidebar.vue 的"其他管理"分组上方新增"Agent 对话"入口,图标用 chat 或 message┌────────────────────────────────────────────────────────────────────┐
│ [新建会话] [搜索框] [Agent 对话] │
├────────────────┬───────────────────────────────────────────────────┤
│ 会话列表 │ 对话区 │
│ ┌────────────┐│ ┌─────────────────────────────────────────────┐ │
│ │• 会话 A ││ │ USER: 用户消息 1 │ │
│ │ 会话 B ││ │ ASSISTANT: 助手回复 1(含折叠的思考过程) │ │
│ │ 会话 C ││ │ USER: 用户消息 2 │ │
│ └────────────┘│ │ ASSISTANT: 助手回复 2(运行中) │ │
│ │ │ ├ ThinkingLive (区域②:实时状态) │ │
│ │ │ ├ 思考流 (区域①:可折叠) │ │
│ │ │ ├ AgentThinkingPanel │ │
│ │ │ │ ├ ToolCalls (区域③:工具调用) │ │
│ │ │ │ ├ TodoListPanel (区域④:待办清单) │ │
│ │ │ │ └ AskforPanel (区域⑤:用户确认) │ │
│ │ │ └ RunReconnectBadge (断线重连状态) │ │
│ │ └─────────────────────────────────────────────┘ │
│ │ │
│ │ ┌─────────────────────────────────────────────┐ │
│ │ │ [模型选择器] [技能选择器] │ │
│ │ │ ┌─────────────────────────────────────────┐ │ │
│ │ │ │ 输入问题... │ │ │
│ │ │ │ │ │ │
│ │ │ │ [发送] │ │ │
│ │ │ └─────────────────────────────────────────┘ │ │
│ │ └─────────────────────────────────────────────┘ │
└────────────────┴───────────────────────────────────────────────────┘
| 组件路径 | 作用 |
|---|---|
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 |
新抽取的可复用技能选择器 |
| 组件 | 文件 | 复用方式 |
|---|---|---|
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" /> |
| 函数 | 文件 | 用途 |
|---|---|---|
buildReplayNode |
frontend/src/utils/runResultReplay.js:136 |
历史会话回看时,从 logs[] 还原思考过程 |
extractStreamingThinking |
同文件 :27 | 单独提取思考流(用于历史消息展开) |
extractToolHistory |
同文件 :48 | 单独提取工具调用历史 |
extractLatestTodos |
同文件 :96 | 单独提取最新 todos |
stripHermesThinkingDecoration |
frontend/src/utils/hermesThinking.js:69 |
剥离颜文字装饰 |
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 事件。
当前 WorkflowEditor.vue 内联了 4 处 <n-select :options="modelOptions">(WorkflowEditor.vue:1533/1665/1756/1854)。建议:
抽取为 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>
WorkflowEditor.vue 的 4 处替换为 <ModelSelect v-model="selectedData.modelId" />(后续重构任务,不在本次范围内)。
ChatComposer.vue 直接使用 <ModelSelect v-model="selectedModelId" />。
SkillSelect 同理(数据源 getSkillList)。
复用 RunHistory.vue 的回放模式:
loadSession(sessionId)。GET /api/chat/sessions/{id} 返回 {session, messages},每条 assistant 消息含 logs: [{kind, content, toolName, ...}] 数组(由后端从 chat_message_log 表组装)。buildReplayNode({ logs: m.logs }) 把日志数组还原为运行时态对象。<ChatMessageItem :message="m"> 渲染:
m.content,悬停显示"编辑"按钮<AgentThinkingPanel :node="m.runtime" />ChatSummaryDivider核心目标:会话 A 的 run 进行中,用户切到会话 B 发新消息——A 的 SSE 流不中断;切回 A 仍能看到实时流式输出。
把"哪个 run 当前在跑"和"当前视图关注哪个 run"两个状态彻底解耦:
| 状态 | 维护者 | 用途 |
|---|---|---|
| 所有进行中的 run + 其 SSE 连接 | useGlobalRuns 单例(应用级) |
后台运行,不中断 |
| 当前查看的会话 + 当前关注的 runId | useChat 实例(页面级) |
渲染当前对话区 |
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
}
}
[用户在会话 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 已落库的日志)
// 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" />。
chat_session 同时只允许一个 run(前端 useChat.isRunning 锁 + 后端校验);不同会话可并行。localStorage 锁继续生效;如果某会话在另一标签页已在跑,本标签页的 startRun 直接报错"已在其他标签页运行"。hermes_bridge.py:120 的 _agents LRU 池默认容量 8,多个会话同时跑时不会冲突——每个 sessionId 独立 Agent 实例。如超出容量,最久未访问的会被淘汰,下次该会话发消息时 Bridge 会重建 Agent(保留 SQLite 持久化的消息历史,但运行时上下文丢失)。onBeforeUnmount 中 globalRuns.detachAllViews()(仅断开视图回调,不停止 SSE?还是停止?选择停止——用户关闭页面意味着不再需要后台运行;下次进入页面时若 run 未完成,通过 lastEventId 重连续传)。设计原则:不在外层重复实现记忆压缩,直接依赖 Hermes Agent 上游的
compress_context能力。前端只提供"读取视图"。
| 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 需新增端点) |
ChatSummaryDivider.vue:对话流中遇到 role=SYSTEM, status=SUMMARY 消息时渲染为浅色横条,文本如"前 23 条消息已压缩为摘要(点击展开)"ChatMemoryDrawer.vue:右上角"记忆"按钮打开的抽屉
MEMORY.md / USER.md,只读,可编辑后调 Bridge 端点保存)上游压缩由 Agent 内部根据 Token 阈值自动触发,前端不主动控制。当 Bridge SSE 推送 context_compacted 事件时,前端:
SYSTEM 摘要消息(视觉分隔)context_compacted 事件,携带 summary 字段)——需要 Bridge 改造GET /sessions/{sessionId}/memory 端点返回 MEMORY.md / USER.md 内容POST /sessions/{sessionId}/memory 端点保存编辑后的长期记忆第一版可不实现"长期记忆编辑"和"压缩历史视图"——先做基础对话流 + 摘要分隔条,桥端
context_compacted事件配合。后续迭代补全。
POST /api/chat/sessions 创建会话,后端生成 hermesSession = "chat-" + UUID 存入 chat_session.hermes_session。ChatRunService.startRun 从 chat_session.hermes_session 读出 sessionId 传给 HermesBridgeClient.run(..., sessionId, ...)。hermes_bridge.py:453-458 的 agent.run_conversation(session_id=...) 会复用 Agent 实例(包括记忆、上下文),实现真正的多轮。完全复用现有机制:
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 抽出)。clarify_request → 后端 wrapSinkWithClarifyHook 拦截 → 写入 workflow_pause 表(run_id 关联,node_id 填 "agent" 常量)→ 推到前端。pendingAsk 状态触发 AskforPanel。POST /api/chat/runs/{runId}/resume → 后端调 HermesBridgeClient.submitClarifyAnswer(clarifyId, answer) → 唤醒 Bridge 阻塞线程。chat_message 表的 parent_id + is_active + branch_order 构成消息树(详见 §4.1.2/§4.1.4)。
| 操作 | 数据变更 | 备注 |
|---|---|---|
| 重新生成 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 删除触发 |
session_id 复用实例 + SQLite 持久化消息历史。parent_message_id,Bridge 端基于该 id 截断 SQLite 历史到该位置(messages WHERE id <= parent_id),从而模拟"从此点重新生成"的语义——需要 Bridge 改造。详见 §5.7。关键点:
useGlobalRuns 单例管理所有进行中的 SSE 连接,会话切换仅切换"视图绑定",不关闭连接。SseEventBus 维护,与 HTTP session 无关,可被任意客户端订阅(按 runId)。hermes_bridge.py:120 的 _agents OrderedDict LRU 默认 8,超出时淘汰最久未访问。多会话并行需保证不同 sessionId 不冲突——可正常并行;若同时跑超 8 个会话,最老的 Agent 被淘汰(SQLite 历史不丢,但运行时上下文丢失,下次该会话发消息时 Agent 重建)。Hermes Agent compress_context(详见 backend/hermes-agent/agent/conversation_compression.py:2128 + agent/context_compressor.py:1317)已实现:
compression.threshold(默认 0.50,即 50%)时自动触发active=0, compacted=1(详见 hermes_state.py:6413 的 archive_and_compact),仍保留在 SQLite 中可查询,但不进入下次 LLM 上下文_previous_summary 字段把上次摘要带入下次压缩,避免摘要漂移compression.threshold=0.50、compression.target_ratio=0.20、compression.protect_last_n=20(详见 hermes_cli/config_defaults.py:559-747)外层(agent-management)不重复实现压缩,只做读取视图。
compress_context 自动触发compression.auxiliary.compression.model: null 默认与主模型一致AiModelConfig 上提供"是否可作为压缩模型"标签,让用户在模型管理页面标注(后续迭代,不在第一版)context_compacted 事件(携带 summary + compacted_until_message_id)chat_message 表 INSERT 一条 role=SYSTEM, status=SUMMARY, content=summary 消息,parent_id 指向 compacted_until_message_id详见 §5.7。关键变化:
useGlobalRuns 内部处理,不依赖当前视图lastEventId 续传,期间产生的事件不丢失runId 已完成(后端已发 workflow_complete),不再重连,直接通过 GET /api/chat/runs/{runId}/state 拉取最终状态clarify_request → 后端 wrapSinkWithClarifyHook 拦截 → 写入 workflow_pause 表 → 推到前端useGlobalRuns 把 pendingAsk 存到 runId 对应的 state 中useChat 派生出 pendingAsk,触发 AskforPanelglobalRuns.submitClarify(runId, clarifyId, answer) → POST /resume → 唤醒 Bridge 阻塞线程useChat.isRunning 锁 + 后端校验)。onBeforeUnmount 调用 globalRuns.shutdown() 关闭所有 SSE 连接。localStorage.setItem('chat-active-run-' + sessionId, runId) 简单锁。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) 加速日志回放与按类型筛选。| 资产 | 状态 | 备注 |
|---|---|---|
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/... |
按依赖顺序分阶段(每阶段可独立验证):
ChatSession / ChatMessage / ChatMessageLog 实体 + Repository + 基础 CRUD ServiceChatController:会话 CRUD 端点ClarifyHooks.wrap(sink, runId, pauseRepo) 工具方法(从 WorkflowLevelExecutor.wrapSinkWithClarifyHook 复制)ChatRunService.startRun(...):调 HermesBridgeClient.run + SseEventBus.publish + 批量日志写入(缓冲 flush)GET /api/chat/runs/{runId}/stream / state / POST /resume 端点autoTitleIfNeeded)chat_message_log 行)验证:用 curl/Postman 触发 POST /api/chat/sessions/{id}/runs,订阅 SSE 流,确认事件正常推送;查询 chat_message_log 表确认日志逐行落库。
ModelSelect.vue / SkillSelect.vue 组件useGlobalRuns.js 单例(核心:管理 SSE 连接、视图绑定、自动重连)useChat.js composable(绑定 useGlobalRuns)api/chat.js 封装ChatPage.vue + 子组件(SessionList with loading 指示 / MessageList / MessageItem / Composer)验证:
lastEventId 重连续传GET /state 轮询恢复 clarify 等待态regenerate / editUserMessage / activateVersion Service + ControllerChatMessageVersionSwitcher.vue 切换组件export(sessionId, format, branch) Service(Markdown + JSON 两种格式)POST /sessions/{id}/reset 端点(删除 Agent 实例)context_compacted 事件chat_message(role=SYSTEM, status=SUMMARY)ChatSummaryDivider.vue 分隔条GET/POST /sessions/{id}/memory 端点)| 风险 | 缓解措施 |
|---|---|
| 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 容量或后台保活 |
| 决策点 | 结论 |
|---|---|
| 是否需要"重新生成回复" | 需要(阶段 4 实现) |
| 是否需要"消息编辑" | 需要(阶段 4 实现,与重新生成共用消息树) |
| 会话标题策略 | 自动从首条消息前 30 字截取,用户可在侧栏双击编辑 |
| 是否支持文件上传 | 暂缓(架构上预留 chat_message.attachment_id 字段) |
| 是否需要导出对话 | 需要(阶段 5 实现 Markdown + JSON 两种格式) |
| 是否支持多会话并行 | 需要(阶段 2 即实现,核心:全局 run 池) |
| 多层记忆模式 | 复用上游 + UI 视图(阶段 6 部分实现,长期记忆编辑后续迭代) |
chat_message_log 完整日志?owner_id 字段?backend/src/main/java/com/agent/management/engine/hermes/HermesBridgeClient.java —— Bridge 调用入口backend/src/main/java/com/agent/management/external/SseEventBus.java —— SSE 事件总线backend/src/main/java/com/agent/management/engine/WorkflowLevelExecutor.java:790 —— wrapSinkWithClarifyHook 抽取源frontend/src/components/agent-thinking/ —— 思考过程展示组件目录frontend/src/composables/useWorkflowRunner.js —— useChat.js 的逻辑抽取源frontend/src/utils/runResultReplay.js —— 历史回放工具backend/hermes-agent/agent/conversation_compression.py:2128 —— compress_context 主流程backend/hermes-agent/agent/context_compressor.py:1317 —— ContextCompressor 类backend/hermes-agent/hermes_state.py:6413 —— archive_and_compact 软归档逻辑backend/hermes-agent/hermes_state_schema.py:655 —— messages 表 schema(active / compacted 字段)backend/hermes-agent/hermes_cli/config_defaults.py:559-747 —— 压缩配置默认值backend/hermes-bridge/hermes_bridge.py:120-123, 170 —— Bridge _agents LRU 池(多会话并行基础)docs/design/hermes-sandbox-design.md —— Hermes Bridge 沙箱设计docs/reference/hermes-agent-upgrade-reference.md —— Hermes-Agent 升级参考docs/design/workflow-output-envelope-design.md —— 节点输出信封化设计(消息状态结构参考)/run 端点支持 systemPrompt + userMessage + sessionId + modelConfig(详见 backend/hermes-bridge/hermes_bridge.py:138-149)agent.run_conversation,详见 backend/hermes-bridge/hermes_bridge.py:453-458)docs/patches/hermes-agent/README.md)compress_context 自动触发,Bridge 通过 SSE 暴露 context_compacted 事件(需 Bridge 改造,第一版可缺失)