文档目的:为 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) |
| 是否持久化历史 | 需要 | 关闭页面后可在侧栏切换历史会话回看,类似现有"运行历史" |
skill_view 工具自主决策SKILL.md 作为系统提示词注入GET /api/chat/runs/{runId}/stream?lastEventId=N)恢复。WorkflowPause + GET /api/chat/runs/{runId}/state 轮询恢复 clarify 等待态。localStorage 简单锁,避免 session_id 冲突)。[前端 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_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 统计)也方便。
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函数还原思考过程。
| 事件 | 操作 |
|---|---|
| 用户发起新会话 | 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 表。
| 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} |
删除会话(级联删除消息) |
| 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 处理逻辑出现分歧也好扩展。
| Method | Path | 用途 |
|---|---|---|
GET |
/api/models |
已有,列出模型 |
GET |
/api/skills |
已有,列出技能(folderName + 名称 + 描述) |
ChatSessionService / ChatSessionRepository标准 CRUD。增加 touch(sessionId) 方法更新 last_message_at。
ChatMessageService / ChatMessageRepository标准 CRUD。增加:
appendLog(runId, log):assistant run 过程中流式累积 logs(可选;也可仅在完成时一次性写入)markSuccess(runId, finalText, logsJson)markFailed(runId, error)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静态工厂直接复用。
结论:无需改造。
现有 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 推到 SseEventBus唯一可能的改造点:clarify 落库拦截。当前 WorkflowLevelExecutor.wrapSinkWithClarifyHook @ WorkflowLevelExecutor.java:790-828 在 sink 外包了一层拦截器,遇到 clarify_request 时写 WorkflowPause 表。Agent 问答需要复用这个拦截器,建议把 wrapSinkWithClarifyHook 抽到一个独立的工具方法(如 ClarifyHooks.wrap(sink, runId, pauseRepo))供两条链路共用。
/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 |
左侧会话列表(新建/切换/删除/重命名) |
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 |
新抽取的可复用技能选择器 |
| 组件 | 文件 | 复用方式 |
|---|---|---|
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_json 还原思考过程 |
extractStreamingThinking |
同文件 :27 | 单独提取思考流(用于历史消息展开) |
extractToolHistory |
同文件 :48 | 单独提取工具调用历史 |
extractLatestTodos |
同文件 :96 | 单独提取最新 todos |
stripHermesThinkingDecoration |
frontend/src/utils/hermesThinking.js:69 |
剥离颜文字装饰 |
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,
}
}
当前 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)。messages 数组,每条 assistant 消息含 logsJson 字段。buildReplayNode({ logs: JSON.parse(logsJson) }) 把日志还原为运行时态对象。<ChatMessageItem :message="m"> 渲染:
m.content<AgentThinkingPanel :node="m.runtime" />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 阻塞线程。isRunning 锁 + 后端校验)。abortSSE(),避免遗留监听器。onBeforeUnmount 中 abort SSE 连接,避免内存泄漏。localStorage.setItem('chat-active-run-' + sessionId, runId) 简单锁,新标签页发现已有 run 在跑则提示"已在其他标签页运行"。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) 加速消息流查询。| 资产 | 状态 | 备注 |
|---|---|---|
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 实体 + Repository + 基础 CRUD ServiceChatController:会话 CRUD 端点ClarifyHooks.wrap(sink, runId, pauseRepo) 工具方法(从 WorkflowLevelExecutor.wrapSinkWithClarifyHook 复制)ChatRunService.startRun(...):调 HermesBridgeClient.run + SseEventBus.publishGET /api/chat/runs/{runId}/stream / state / POST /resume 端点验证:用 curl/Postman 触发 POST /api/chat/sessions/{id}/runs,订阅 SSE 流,确认事件正常推送。
ModelSelect.vue / SkillSelect.vue 组件useChat.js composable(从 useWorkflowRunner.js 抽取核心逻辑)api/chat.js 封装ChatPage.vue + 子组件(SessionList / MessageList / MessageItem / Composer)<n-select>(YAGNI,后续单独重构)验证:完整跑通"新建会话 → 发消息 → 看到思考过程 → 收到回复 → 切换会话 → 切回看到历史"。
lastEventId 重连续传GET /state 轮询恢复 clarify 等待态| 风险 | 缓解措施 |
|---|---|
| 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 能力(上游已实现),不额外处理 |
这些都是"好用"层面的功能,不影响基础架构。建议第一版仅实现核心流程(新建会话 → 发消息 → 看思考 → 收回复 → 多轮 → 历史回看),上述增强功能按需迭代。
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 —— 历史回放工具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)