diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 1039c62..6ef51ff 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -41,9 +41,9 @@ jobs: platforms: linux/amd64,linux/arm64 push: true tags: | - ${{ secrets.DOCKERHUB_USERNAME }}/hermes-web-ui:latest - ${{ secrets.DOCKERHUB_USERNAME }}/hermes-web-ui:${{ github.sha }} - ${{ secrets.DOCKERHUB_USERNAME }}/hermes-web-ui:${{ github.event.release.tag_name || github.ref_name }} + ${{ secrets.DOCKERHUB_USERNAME }}/hermes-ui:latest + ${{ secrets.DOCKERHUB_USERNAME }}/hermes-ui:${{ github.sha }} + ${{ secrets.DOCKERHUB_USERNAME }}/hermes-ui:${{ github.event.release.tag_name || github.ref_name }} keep-release-out-of-latest: name: Keep release out of GitHub latest diff --git a/.github/workflows/webui-release.yml b/.github/workflows/webui-release.yml index 2877de7..af6d9f1 100644 --- a/.github/workflows/webui-release.yml +++ b/.github/workflows/webui-release.yml @@ -51,14 +51,14 @@ jobs: run: | set -euo pipefail version="$(node -p "require('./package.json').version")" - asset="hermes-web-ui-${version}.tar.gz" - manifest="hermes-web-ui-${version}.json" - mkdir -p release/webui /tmp/hermes-web-ui-package/webui + asset="hermes-ui-${version}.tar.gz" + manifest="hermes-ui-${version}.json" + mkdir -p release/webui /tmp/hermes-ui-package/webui - cp package.json package-lock.json /tmp/hermes-web-ui-package/webui/ - cp -R bin dist node_modules /tmp/hermes-web-ui-package/webui/ + cp package.json package-lock.json /tmp/hermes-ui-package/webui/ + cp -R bin dist node_modules /tmp/hermes-ui-package/webui/ - tar -czf "release/webui/${asset}" -C /tmp/hermes-web-ui-package webui + tar -czf "release/webui/${asset}" -C /tmp/hermes-ui-package webui sha256="$(shasum -a 256 "release/webui/${asset}" | awk '{print $1}')" size="$(stat -c%s "release/webui/${asset}")" printf '%s %s\n' "$sha256" "$asset" > "release/webui/${asset}.sha256" diff --git a/bin/hermes-ui-mcp.mjs b/bin/hermes-ui-mcp.mjs index d9f0d72..85afa75 100755 --- a/bin/hermes-ui-mcp.mjs +++ b/bin/hermes-ui-mcp.mjs @@ -155,7 +155,7 @@ async function request(path, options = {}) { function appendQuery(path, query) { if (!query || typeof query !== 'object' || Array.isArray(query)) return path - const parsed = new URL(path, 'http://hermes-web-ui.local') + const parsed = new URL(path, 'http://hermes-ui.local') for (const [key, value] of Object.entries(query)) { if (value == null) continue if (Array.isArray(value)) { @@ -227,7 +227,7 @@ function normalizeApiPath(path) { const raw = String(path || '').trim() if (!raw || !raw.startsWith('/') || raw.startsWith('//')) return null if (raw === '/v1' || raw.startsWith('/v1/')) return null - const parsed = new URL(raw, 'http://hermes-web-ui.local') + const parsed = new URL(raw, 'http://hermes-ui.local') const normalized = `${parsed.pathname}${parsed.search}` if (parsed.pathname === '/api/openapi.json') return normalized if (parsed.pathname === '/health') return normalized @@ -248,7 +248,7 @@ async function openApiDocument(options = {}) { } function pathWithoutQuery(path) { - return new URL(path, 'http://hermes-web-ui.local').pathname + return new URL(path, 'http://hermes-ui.local').pathname } function pathTemplateRegex(template) { @@ -273,7 +273,7 @@ function findOpenApiOperation(openapi, method, path) { } function queryObjectFromPath(path, query) { - const parsed = new URL(path, 'http://hermes-web-ui.local') + const parsed = new URL(path, 'http://hermes-ui.local') const values = {} for (const [key, value] of parsed.searchParams.entries()) { if (values[key] === undefined) values[key] = value diff --git a/bin/hermes-ui.mjs b/bin/hermes-ui.mjs index f0e4d70..0a40c1f 100755 --- a/bin/hermes-ui.mjs +++ b/bin/hermes-ui.mjs @@ -450,12 +450,12 @@ function startDaemon(port) { const interval = 500 let waited = 0 - console.log(` ⏳ Starting hermes-web-ui (PID: ${child.pid}, port: ${port})...`) + console.log(` ⏳ Starting hermes-ui (PID: ${child.pid}, port: ${port})...`) function poll() { waited += interval if (!isRunning(child.pid)) { - console.log(' ✗ Failed to start hermes-web-ui') + console.log(' ✗ Failed to start hermes-ui') console.log(` Check log: ${LOG_FILE}`) removePid() process.exit(1) @@ -507,7 +507,7 @@ function stopDaemon(options = {}) { let cleanedStalePid = false if (pidFromFile && !isRunning(pidFromFile)) { removePid() - console.log(` ✓ hermes-web-ui was not running (cleaned stale PID: ${pidFromFile})`) + console.log(` ✓ hermes-ui was not running (cleaned stale PID: ${pidFromFile})`) pidFromFile = null cleanedStalePid = true } @@ -516,16 +516,16 @@ function stopDaemon(options = {}) { if (!pid) { if (cleanedStalePid) return if (stoppedPreviewPids) { - console.log(` ✓ hermes-web-ui preview stopped`) + console.log(` ✓ hermes-ui preview stopped`) return } - console.log(' ✗ hermes-web-ui is not running') + console.log(' ✗ hermes-ui is not running') process.exit(1) } if (!isRunning(pid)) { removePid() - console.log(` ✓ hermes-web-ui was not running (cleaned stale PID)`) + console.log(` ✓ hermes-ui was not running (cleaned stale PID)`) return } @@ -561,11 +561,11 @@ function stopDaemon(options = {}) { function showStatus() { const pid = getPid() if (pid && isRunning(pid)) { - console.log(` ✓ hermes-web-ui is running (PID: ${pid})`) + console.log(` ✓ hermes-ui is running (PID: ${pid})`) console.log(` PID file: ${PID_FILE}`) } else { if (pid) removePid() - console.log(' ✗ hermes-web-ui is not running') + console.log(' ✗ hermes-ui is not running') } } @@ -588,7 +588,7 @@ function clearLoginLocks(options = {}) { } if (!silent && serverRunning) { - console.log(' ⚠ hermes-web-ui is running; restart it to clear in-memory login locks.') + console.log(' ⚠ hermes-ui is running; restart it to clear in-memory login locks.') console.log(' Run: hermes-ui restart') } @@ -747,7 +747,7 @@ Options: } function doUpdate() { - console.log(' ⬆ Updating hermes-web-ui...') + console.log(' ⬆ Updating hermes-ui...') const npm = getNpmBin() try { diff --git a/docker-compose.yml b/docker-compose.yml index ee5c742..8610fe5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,17 +1,17 @@ services: - hermes-webui: + hermes-ui: build: context: . dockerfile: Dockerfile - image: ${WEBUI_IMAGE:-hermes-web-ui-local:latest} - container_name: ${WEBUI_CONTAINER_NAME:-hermes-webui} + image: ${WEBUI_IMAGE:-hermes-ui-local:latest} + container_name: ${WEBUI_CONTAINER_NAME:-hermes-ui} ports: - "${PORT:-6060}:${PORT:-6060}" - "${PREVIEW_FRONTEND_PORT:-8651}:8651" - "${XAI_OAUTH_PORT:-56121}:56121" volumes: - ${HERMES_DATA_DIR:-./hermes_data}:/home/agent/.hermes - - ${HERMES_DATA_DIR:-./hermes_data}/hermes-web-ui:/home/agent/.hermes-web-ui + - ${HERMES_DATA_DIR:-./hermes_data}/hermes-ui:/home/agent/.hermes-web-ui environment: - PORT=${PORT:-6060} - HERMES_HOME=/home/agent/.hermes diff --git a/docs/cli-chat-sessions.md b/docs/cli-chat-sessions.md new file mode 100644 index 0000000..d1b51b6 --- /dev/null +++ b/docs/cli-chat-sessions.md @@ -0,0 +1,932 @@ +# Chat Sessions 链路文档 + +> 状态:本文档按当前 `main` 重新整理普通 Chat 会话的完整实现链路。旧的独立 +> CLI Chat 面板、`/cli-chat-run` namespace、`cli-chat.ts` 客户端和 Python +> bridge 直连命令层已经不再是产品入口。 +> +> 最后重建时间:2026-06-03。 +> +> 维护要求:后续 PR 如果修改本文列出的普通 Chat 链路核心文件,需要新增 +> `docs/chat-chain-changes/` 下的独立变更片段。每个 PR 一个变更片段,写清楚 +> 修改时间、PR/commit、动到的功能和行为影响,避免多个 PR 同时改本文产生冲突。 +> `packages/server/src/services/hermes/agent-bridge/` 是普通 Chat 的核心链路, +> 该目录下任何改动都算 Chat 链路改动;即使只是启动、环境变量、日志或品牌 +> attribution,也要记录影响范围,必要时明确“运行行为无变化”。 +> `packages/server/src/services/hermes/group-chat/`、`/group-chat` Socket.IO、 +> group-chat 前端 store/API/component、共享压缩器和 context-engine 也属于核心 +> 聊天链路,改动时同样需要记录。 + +## 1. 结论先行 + +当前普通 Chat 的主链路是: + +```text +ChatPanel / ChatInput + -> Pinia chat store + -> packages/client/src/api/hermes/chat.ts + -> Socket.IO namespace /chat-run + -> ChatRunSocket + -> handleBridgeRun() + -> AgentBridgeClient + -> hermes_bridge.py broker + -> profile worker + -> AIAgent / Hermes Agent tools +``` + +也就是说,当前 Web UI 普通聊天默认不是走 Hermes Gateway `/v1/responses`, +而是走 `source=cli` 的 Agent Bridge 路径。代码里仍保留 `api_server` +类型和 `handle-api-run.ts`,但 `resolveRunSource()` 当前固定返回 `cli`。 + +核心原则: + +- UI 只有一套普通 Chat 页面,不再有单独 CLI Chat 页面。 +- 所有长连接事件统一走 `/chat-run`。 +- 所有普通会话都落 Web UI 自己的 SQLite `sessions/messages` 表。 +- Hermes profile、model、provider、workspace、source 都是 session 级上下文。 +- 多 tab / 页面刷新通过 `resume` 重新加入同一个 `session:{id}` room。 +- 同一个 session 同时只跑一个 active run,后续输入进入 session 队列。 +- 工具审批、用户澄清、压缩、abort、usage、slash command 都复用同一条 + `/chat-run` 事件通道。 + +### 最近链路变更记录 + +变更记录按 PR 拆分保存在 `docs/chat-chain-changes/`。新增 Chat session chain、 +Agent Bridge、compression 或 Group Chat 核心链路改动时,新增一个 fragment 文件, +不要继续修改本文维护集中表格。 + +每个 fragment 至少包含: + +```md +--- +date: YYYY-MM-DD +pr: 1234 +feature: 改动功能 +impact: 行为影响 +--- + +补充说明。 +``` + +开 PR 前还没有编号时可以先写 `pr: pending`,PR 创建后再把该 fragment 改成实际 +PR 号。 + +## 2. 主要文件 + +### 前端 + +| 文件 | 职责 | +| --- | --- | +| `packages/client/src/components/hermes/chat/ChatPanel.vue` | 普通 Chat 页面容器,组合消息列表、输入框、审批条、澄清条、抽屉面板。 | +| `packages/client/src/components/hermes/chat/ChatInput.vue` | 输入框、发送、附件、停止按钮入口。 | +| `packages/client/src/components/hermes/chat/MessageList.vue` / `VirtualMessageList.vue` | 消息列表渲染和虚拟滚动。 | +| `packages/client/src/components/hermes/chat/MessageItem.vue` | 单条消息渲染,包含 assistant、tool、reasoning、附件等 UI。 | +| `packages/client/src/stores/hermes/chat.ts` | Chat 核心状态机:session 列表、发送、resume、队列、流式事件、审批、澄清、abort、压缩状态。 | +| `packages/client/src/api/hermes/chat.ts` | `/chat-run` Socket.IO 客户端,负责连接、全局事件分发、run/resume/abort/approval/clarify 协议。 | +| `packages/client/src/api/hermes/sessions.ts` | HTTP session API:列表、详情分页、删除、重命名、模型更新。 | +| `packages/client/src/api/hermes/group-chat.ts` | `/group-chat` Socket.IO 客户端和 group-chat HTTP room/agent/config API。 | +| `packages/client/src/stores/hermes/group-chat.ts` | Group Chat 状态机:rooms、members、messages、agents、streaming、context/compression 状态。 | +| `packages/client/src/components/hermes/group-chat/*` | Group Chat 页面、输入框、消息列表、成员/agent 展示和房间创建配置。 | + +### 后端 + +| 文件 | 职责 | +| --- | --- | +| `packages/server/src/services/hermes/run-chat/index.ts` | `ChatRunSocket`,`/chat-run` namespace 入口、认证、profile 校验、run/resume/abort/approval/clarify/queue 分发。 | +| `packages/server/src/services/hermes/run-chat/handle-bridge-run.ts` | 当前主运行路径:创建/更新本地 session,构建上下文,调用 Agent Bridge,消费 bridge 事件,落库。 | +| `packages/server/src/services/hermes/run-chat/handle-api-run.ts` | 保留的 API Server 路径实现;当前 `resolveRunSource()` 固定返回 `cli`,正常不会走到这里。 | +| `packages/server/src/services/hermes/run-chat/session-command.ts` | slash command 解析和执行。 | +| `packages/server/src/services/hermes/run-chat/abort.ts` | active run 中断、状态落盘、队列衔接。 | +| `packages/server/src/services/hermes/run-chat/compression.ts` | DB history 构建、snapshot-aware history、上下文压缩。 | +| `packages/server/src/services/hermes/run-chat/bridge-message.ts` | Bridge assistant/tool 消息的内存态与 DB flush。 | +| `packages/server/src/services/hermes/run-chat/bridge-delta.ts` | 过滤 bridge 输出中的工具调用标记,避免 UI 文本重复或丢字符。 | +| `packages/server/src/services/hermes/agent-bridge/client.ts` | Node 到 Python bridge 的本地 socket 客户端。 | +| `packages/server/src/services/hermes/agent-bridge/manager.ts` | Python bridge broker 子进程生命周期管理。 | +| `packages/server/src/services/hermes/agent-bridge/python/hermes_bridge.py` | Python broker/worker entrypoint;实现拆分在同目录的 `bridge_*.py` 模块中,覆盖 `AIAgent` 会话池、工具审批、澄清、压缩协作、goal/plan 命令等。 | +| `packages/server/src/services/hermes/group-chat/index.ts` | `/group-chat` Socket.IO server、room/member/message 存储、agent 恢复、mention 分发、approval/interrupt 入口。 | +| `packages/server/src/services/hermes/group-chat/agent-clients.ts` | Group Chat agent socket client,调用 Agent Bridge 执行被 mention 的 agent,并同步 tool/reasoning/context 状态。 | +| `packages/server/src/services/hermes/context-engine/*` | Group Chat 上下文压缩和 summary cache。 | +| `packages/server/src/lib/context-compressor/*` | 普通 Chat 和 Group Chat 共用的 token 估算、摘要压缩和 context message 处理。 | +| `packages/server/src/routes/hermes/group-chat.ts` | Group Chat HTTP room/agent/config/compress/clear-context API。 | +| `packages/server/src/db/hermes/session-store.ts` | Web UI 本地 session/message SQLite 存储。 | +| `packages/server/src/controllers/hermes/sessions.ts` | HTTP session 列表、详情、分页、删除、导入/导出等控制器。 | + +## 3. 数据模型 + +普通 Chat 使用 Web UI 本地 SQLite,而不是直接把 Hermes CLI 历史当作唯一状态。 +核心表由 `packages/server/src/db/hermes/schemas.ts` 初始化。 + +### sessions + +`session-store.ts` 暴露的主要字段: + +| 字段 | 说明 | +| --- | --- | +| `id` | Web UI session id。前端新建时生成,发送 run 时传入。 | +| `profile` | Hermes profile。新 session 使用当前 active profile,已存在 session 优先使用 DB 中的 profile。 | +| `source` | 当前会话来源。当前普通 Chat 实际写入 `cli`。历史数据可能存在 `api_server`。 | +| `model` / `provider` | session 绑定模型。首轮发送会写入选中的模型/供应商,后续可更新。 | +| `title` / `preview` | 会话标题和预览。标题可以由 `/title` 改,也可由首条用户输入生成。 | +| `workspace` | 当前工作目录上下文,会被注入 run instructions。 | +| `message_count` / `tool_call_count` | 由 `updateSessionStats()` 从 messages 表统计。 | +| `input_tokens` / `output_tokens` | usage 统计结果。 | +| `last_active` / `started_at` / `ended_at` | 会话时间元数据。 | + +### messages + +主要 role: + +| role | 说明 | +| --- | --- | +| `user` | 普通用户输入。 | +| `command` | 用户输入的 slash command 展示消息。 | +| `assistant` | Agent 输出文本,可带 `reasoning` / `reasoning_content`。 | +| `tool` | 工具执行结果。 | + +工具调用相关字段: + +- `tool_call_id` +- `tool_calls` +- `tool_name` +- `finish_reason` +- `reasoning` +- `reasoning_details` +- `reasoning_content` + +前端读取历史时通过 `mapHermesMessages()` 把 DB 行映射成 UI `Message`,包括: + +- assistant 文本消息 +- reasoning 内容 +- tool started/tool completed 的可视化行 +- 队列消息 +- command/system 消息 + +## 4. 前端状态机 + +`useChatStore()` 是普通 Chat 的中心状态。 + +常见状态: + +| 状态 | 说明 | +| --- | --- | +| `sessions` | 当前加载的 session 列表和每个 session 的消息数组。 | +| `activeSessionId` / `activeSession` | 当前页面正在显示的 session。 | +| `serverWorking` | 前端认为服务端仍有 active run 的 session id 集合。 | +| `streamStates` | 当前前端已注册流式 handler 的 session。 | +| `queueLengths` / queued user messages | 每个 session 的服务端队列长度和 UI 可见队列消息。 | +| `pendingApprovals` | 工具审批请求,按 `sessionId + approvalId` 存。 | +| `pendingClarifies` | 用户澄清请求,按 `sessionId + clarifyId` 存。 | +| `compressionStates` | 上下文压缩中的临时 UI 状态。 | +| `abortState` | 当前 active run 的中断状态。 | + +### 新建会话 + +普通新建入口最终调用: + +```ts +newChat(options) +``` + +它会: + +1. 从当前 app model/profile 状态创建本地前端 session 对象。 +2. 切换到这个 session。 +3. 此时 DB 里不一定已经有 session 行;真正落库发生在第一次 run 时。 + +历史上的 `newCliSession()` 仍存在,但普通 Chat 当前也是 `source=cli`,不再需要单独 UI 面板区分。 + +### 切换会话 + +切换 session 时,store 会优先通过 Socket.IO `resume`: + +```ts +resumeSession(sessionId, onResumed, profile) +``` + +服务端会: + +1. `socket.join("session:")` +2. 如果内存 `sessionMap` 有状态,直接返回。 +3. 否则从 DB 读取分页消息和 usage。 +4. 如果该 session 正在运行,返回最近 transient events。 + +前端收到 `resumed` 后会: + +- 替换/补齐本地消息。 +- 恢复 `isWorking`、`isAborting`、queue、usage。 +- 对 active run 重新注册 handlers。 +- 重新显示审批、澄清、压缩、abort 等未完成状态。 + +## 5. 发送消息链路 + +### 前端发送 + +入口是 `chat.ts` store 的: + +```ts +sendMessage(content, attachments?) +``` + +主要步骤: + +1. 如果没有 active session,先创建一个。 +2. 捕获发送时的 `sid`,后续所有回调都用这个 sid,避免用户切换 session 后事件写错地方。 +3. 判断是否 slash command:`content.trim().startsWith("/")`。 +4. 判断是否需要排队: + - 如果服务端当前 session 正在运行; + - 且不是可立即处理的 mid-run slash command; + - 则 UI 先显示 queued 消息。 +5. 如果有附件: + - 先走 `/upload`; + - 再组装 `ContentBlock[]`; + - image block 包含 `type/name/path/media_type`; + - file block 包含 `type/name/path/media_type?`。 +6. 等待模型列表 ready,读取当前 session 或全局选择的 model/provider。 +7. 构造 run payload。 + +当前 payload 形态: + +```ts +{ + input, + session_id: sid, + profile, + model, + provider, + model_groups, + queue_id: userMsg.id, + source: "cli", +} +``` + +首轮发送会带 `model/provider`,已存在 session 后通常依赖 DB 中的 session 模型。 + +### Socket.IO 发送 + +`startRunViaSocket()` 负责: + +1. `connectChatRun(profile)` 建立或复用 `/chat-run` socket。 +2. 注册当前 `session_id` 的事件 handlers 到 `sessionEventHandlers`。 +3. 发送: + +```ts +socket.emit("run", body) +``` + +如果同一个 session 已经有 handler,新的 run 只 emit,不重复注册,避免多 tab/多次发送造成事件重复处理。 + +## 6. `/chat-run` 连接和认证 + +前端连接: + +```ts +io(`${baseUrl}/chat-run`, { + auth: { token }, + query: { profile }, + transports: ["websocket", "polling"], + reconnection: true, +}) +``` + +后端 `ChatRunSocket.authMiddleware()`: + +1. 如果 auth 关闭,直接放行。 +2. 如果 auth 开启,使用 `authenticateUserToken()` 验证 token。 +3. 如果 socket query 里带 profile,检查用户是否可访问该 profile。 +4. 把 authenticated user 写入 `socket.data.user`。 + +每次 run 时还会调用 `resolveRunProfile()`: + +- payload 显式 `profile` 优先。 +- 没有 session id 时使用 socket 当前 profile。 +- 有 session id 时优先用 DB 中 session.profile。 +- 非 super admin 需要通过 `userCanAccessProfile()`。 + +## 7. 服务端 run 分发 + +`ChatRunSocket` 监听: + +| 客户端事件 | 服务端行为 | +| --- | --- | +| `run` | 解析 profile、source、slash command、queue,然后进入 run handler。 | +| `resume` | 加入 session room,返回 DB/内存状态。 | +| `abort` | 调用 `handleAbort()`。 | +| `cancel_queued_run` | 从 session queue 删除指定 queue item。 | +| `approval.respond` | 转发到 bridge `approval_respond`。 | +| `clarify.respond` | 转发到 bridge `clarify_respond`。 | + +### source 分流 + +当前代码: + +```ts +export function resolveRunSource(_source?: string, _sessionId?: string): ChatRunSource { + return "cli" +} +``` + +因此: + +- payload 即使传 `source: "api_server"`,当前仍会被解析为 `cli`。 +- `handleApiRun()` 是保留实现,不是当前普通 Chat 的实际路径。 +- 新会话落库时 `source` 写为 `cli`。 + +### sessionMap + +后端内存态是: + +```ts +Map +``` + +每个 `SessionState` 包含: + +- `messages` +- `isWorking` +- `isAborting` +- `events` +- `queue` +- `runId` +- `activeRunMarker` +- `profile` +- `source` +- bridge pending text/reasoning/tool 状态 +- usage/context token 状态 + +DB 是持久层,`sessionMap` 是运行时和 resume 用的 transient 层。 + +## 8. Bridge run 详细链路 + +实际执行在: + +```ts +handleBridgeRun(...) +``` + +### 8.1 初始化 run + +服务端会: + +1. 根据 session DB 和 payload 解析 model/provider。 +2. 生成 `runMarker = cli_run_<...>`,用于把同一轮用户、assistant、tool 消息串起来。 +3. 初始化 `SessionState`: + - `isWorking = true` + - `isAborting = false` + - `source = "cli"` + - 清空 pending assistant/reasoning/tool 状态 +4. 如果需要展示用户输入: + - 内存 `state.messages.push(...)` + - 如果 DB session 不存在,`createSession({ source: "cli" })` + - `addMessage()` 写入 DB +5. `socket.join("session:")` +6. 向其他同 room tab 广播 `run.peer_user_message`。 + +### 8.2 instructions 组装 + +最终 instructions 包含: + +- `getSystemPrompt()` +- 用户或调用方传入的 `instructions` +- 当前 Hermes profile +- session workspace +- Web UI 工具调用提示:如果工具调用 Web UI API,需要带 `X-Hermes-Profile` + +### 8.3 上下文构建 + +run 前调用: + +```ts +buildCompressedHistory(...) +``` + +它会基于 DB history 和 compression snapshot 构建适合本次 run 的历史: + +- 读取本 session messages。 +- 排除或包含当前用户输入,视调用场景决定。 +- 使用 snapshot-aware history,避免已经压缩的旧上下文重复膨胀。 +- 必要时触发上下文压缩。 +- 对 Bridge 路径会调用 `context_estimate` 估算固定上下文开销: + - system prompt tokens + - tool tokens + - tool count/names + +### 8.4 调用 AgentBridgeClient + +服务端发起: + +```ts +bridge.chat(sessionId, bridgeInput, bridgeHistory, fullInstructions, profile, { + storage_message, + model, + provider, +}) +``` + +返回: + +```ts +{ + ok: true, + run_id, + session_id, + status +} +``` + +随后 Node 轮询: + +```ts +for await (const chunk of bridge.streamOutput(run_id)) { + applyBridgeChunkAsync(...) +} +``` + +`streamOutput()` 实际通过 `get_output` action,携带 `cursor` 和 `event_cursor` +增量读取文本和事件。 + +## 9. Python Agent Bridge + +### 9.1 进程结构 + +`AgentBridgeManager` 启动 `hermes_bridge.py` broker 子进程: + +```text +python hermes_bridge.py --endpoint --agent-root --hermes-home +``` + +默认 endpoint: + +| 平台 | 默认 | +| --- | --- | +| Windows | `tcp://127.0.0.1:18765` | +| macOS/Linux | `ipc:///tmp/hermes-agent-bridge.sock` | + +broker 会再按 profile 路由到 worker。worker 里维护实际 `AIAgent` 会话池。 + +### 9.2 Node/Python 协议 + +Node 和 Python 使用本地 socket 的单行 JSON 协议。 + +示例请求: + +```json +{"action":"chat","session_id":"abc","message":"hello","profile":"default"} +``` + +示例响应: + +```json +{"ok":true,"run_id":"...","session_id":"abc","status":"running"} +``` + +`AgentBridgeClient` 每次请求新建 socket,发送一行 JSON,读取一行 JSON。 +请求默认串行化,默认 timeout 是 120 秒,connect 失败有短重试窗口。 + +### 9.3 常用 action + +| action | 说明 | +| --- | --- | +| `chat` | 启动一轮 agent conversation。 | +| `get_output` | 根据 cursor/event_cursor 拉取增量输出。 | +| `context_estimate` | 估算 system prompt/tool 固定上下文 token。 | +| `interrupt` | 中断 session 当前 run。 | +| `steer` | 给正在运行的 session 追加 steering instruction。 | +| `command` | 执行 plan/goal/subgoal 等 bridge command。 | +| `approval_respond` | 响应工具审批。 | +| `clarify_respond` | 响应澄清请求。 | +| `compression_respond` | Node 完成本地压缩后把压缩结果交还给 bridge。 | +| `goal_evaluate` / `goal_pause` | goal continuation 的状态评估和暂停。 | +| `status` | 查询 bridge session 状态。 | +| `destroy` | 销毁指定 bridge session。 | +| `destroy_all` | 销毁全部 bridge session,主要用于进程关闭或维护。 | +| `mcp_*` | MCP 相关维护动作,例如 reload。 | + +## 10. 流式事件映射 + +Bridge 输出 chunk 里有两类数据: + +- `delta`:聚合文本增量。 +- `events`:有序事件列表。 + +如果 `events` 中出现 `stream.delta`,Node 以事件顺序处理文本,避免再处理 +聚合 `chunk.delta`,否则会重复输出。 + +### 事件映射 + +| Bridge event | `/chat-run` event | UI 结果 | +| --- | --- | --- | +| `stream.delta` / chunk `delta` | `message.delta` | assistant 文本增量。 | +| `reasoning.delta` | `reasoning.delta` | reasoning 增量。 | +| `thinking.delta` | `thinking.delta` | thinking/reasoning 增量。 | +| `reasoning.available` | `reasoning.available` | 标记 reasoning 可用。 | +| `tool.started` | `tool.started` | 显示工具开始行,先 flush pending assistant 文本。 | +| `tool.completed` | `tool.completed` | 显示工具结果和耗时/error。 | +| `subagent.*` | `subagent.*` | 显示子代理状态、工具、进度和总结。 | +| `status` | `agent.event` | 显示 agent 状态事件。 | +| `approval.requested` | `approval.requested` | 显示审批条。 | +| `approval.resolved` | `approval.resolved` | 清理审批条。 | +| `clarify.requested` | `clarify.requested` | 显示澄清输入。 | +| `clarify.resolved` | `clarify.resolved` | 清理澄清输入。 | +| `bridge.compression.requested` | `compression.started` | UI 显示压缩中,Node 执行本地压缩。 | +| `bridge.compression.completed` | `compression.completed` | UI 显示压缩结果,更新 context tokens。 | +| terminal chunk done | `run.completed` / `run.failed` | 结束 run,更新 usage,衔接队列。 | + +### 消息落库 + +Bridge run 中不会每个字符都立即落库。Node 会维护 pending assistant/tool 状态: + +- 文本增量累加到 `bridgePendingAssistantContent`。 +- reasoning 累加到 `bridgePendingReasoningContent`。 +- 工具开始前、turn boundary、run 结束、abort 前会 `flushBridgePendingToDb()`。 +- tool started/completed 通过 `recordBridgeToolStarted()` 和 + `recordBridgeToolCompleted()` 形成 DB 可恢复的 assistant/tool 结构。 + +这样可以保证: + +- UI 实时流式显示。 +- DB 中历史可恢复。 +- 刷新页面后 tool/reasoning/assistant 不丢。 + +## 11. run 完成和失败 + +当 bridge chunk `done=true`: + +1. flush pending 工具标记和 assistant 文本。 +2. `updateSessionStats(sessionId)`。 +3. 延迟短时间等待 usage flush。 +4. `calcAndUpdateUsage()` 计算 input/output tokens。 +5. `refreshFinalContextUsage()` 计算 snapshot-aware context tokens。 +6. `updateUsage()` 写 usage store。 +7. 检测 `bridgeTerminalError()`: + - bridge status 为 `error` + - result 中带 error/exception + - final_response 看起来是上游错误 +8. 发送 `run.completed` 或 `run.failed`。 +9. 如果队列中还有消息,自动 dequeue 下一条。 + +前端收到 terminal event 后: + +- 关闭 streaming assistant 状态。 +- 清理 `serverWorking`。 +- 播放完成提示音(如果开启)。 +- 更新 session title/usage/context tokens。 +- 如果 `queue_remaining > 0`,保持 handler,不提前清理。 + +## 12. 队列 + +同一个 session 同时只允许一个 active run。 + +队列进入点: + +1. 服务端收到 `run`,发现 `state.isWorking=true`。 +2. 前端 `/queue ` command。 +3. `/plan` 或 `/goal` 生成 kickoff prompt,但当前 session 正在运行。 +4. abort 完成后仍有队列。 + +队列项类型: + +```ts +interface QueuedRun { + queue_id: string + input: string | ContentBlock[] + displayInput?: string | ContentBlock[] | null + displayRole?: "user" | "command" + storageMessage?: string + model?: string + provider?: string + model_groups?: Array<{ provider: string; models: string[] }> + instructions?: string + profile: string + source?: "cli" | "api_server" + originSocketId?: string + goalContinuation?: boolean +} +``` + +队列事件: + +- `run.queued`:队列长度变化。 +- `queued_messages`:UI 可见队列消息。 +- `dequeued_queue_id`:某条队列开始执行,前端移除 queued 展示。 + +取消队列: + +```ts +socket.emit("cancel_queued_run", { session_id, queue_id }) +``` + +## 13. Abort + +前端 stop 按钮最终调用: + +```ts +socket.emit("abort", { session_id }) +``` + +服务端 `handleAbort()`: + +1. 如果没有 active run,发送 ignored 的 `abort.completed`。 +2. 设置 `state.isAborting=true`。 +3. 发送 `abort.started`。 +4. 先 flush 当前内存 assistant/tool 状态到 DB。 +5. CLI source 调用: + - `bridge.interrupt(sessionId, "Aborted by user", profile)` + - `bridge.goalPause(..., "user-interrupted")` + - 移除 goal continuation 队列项 +6. 标记 abort completed,更新 usage。 +7. 如果队列不为空,启动下一条队列。 + +前端收到: + +- `abort.started`:显示中断中。 +- `abort.completed`:清理中断状态;如果有队列继续保持 streaming handler。 + +## 14. 工具审批 + +Bridge 里有两类审批来源: + +1. Hermes 工具层直接回调,例如 terminal/本地命令审批。 +2. gateway approval notify,经 `tools.approval` 按 session key 路由。 + +Python 侧生成: + +```json +{ + "event": "approval.requested", + "approval_id": "...", + "command": "...", + "description": "...", + "choices": ["once", "session", "always", "deny"], + "allow_permanent": true, + "timeout_ms": 60000 +} +``` + +Node 映射到 `/chat-run`: + +```ts +approval.requested +``` + +前端: + +- `chat.ts` store 写入 `pendingApprovals`。 +- `ChatPanel.vue` 显示审批 UI。 +- 用户选择后调用: + +```ts +respondToolApproval(sessionId, approvalId, choice) +``` + +再发送: + +```ts +socket.emit("approval.respond", { + session_id, + approval_id, + choice, // once | session | always | deny +}) +``` + +Python bridge 会: + +- 找到对应 approval queue 或 gateway session。 +- 写入选择。 +- 发送 `approval.resolved`。 +- 在运行开始/审批相关路径刷新 `command_allowlist` 的进程内缓存,确保“始终允许”的持久配置能被后续工具判断读到。 + +## 15. 用户澄清 + +澄清请求和审批类似,但用于 agent 主动问用户问题。 + +Bridge event: + +```json +{ + "event": "clarify.requested", + "clarify_id": "...", + "question": "...", + "choices": ["..."], + "timeout_ms": 60000 +} +``` + +前端显示澄清 UI,并发送: + +```ts +socket.emit("clarify.respond", { + session_id, + clarify_id, + response +}) +``` + +服务端转发到: + +```ts +bridge.clarifyRespond(clarifyId, response) +``` + +然后通过 `clarify.resolved` 清理 UI 状态。 + +## 16. Slash Commands + +slash command 不是独立 socket 事件。它们作为普通 `run.input` 发给 `/chat-run`, +后端在 `source=cli` 时由 `session-command.ts` 解析。 + +当前支持: + +| 命令 | 说明 | +| --- | --- | +| `/usage` | 计算并显示当前 session usage。 | +| `/status` | 显示 session/bridge/profile/model/queue/run 状态。 | +| `/abort` | 请求中断当前 run。 | +| `/queue ` | 当前 run 活跃时追加一条队列消息。 | +| `/plan ...` | 调用 bridge plan command,可能生成 kickoff prompt 并立即/排队运行。 | +| `/goal ...` | 设置、查询、暂停、恢复、清理 goal。 | +| `/subgoal ...` | 子目标命令。 | +| `/clear` | 清理当前显示状态,不删 DB 历史。 | +| `/clear --history` | 删除当前 session DB messages,要求 session idle。 | +| `/title ` | 重命名 session。 | +| `/compress` | session idle 时手动触发上下文压缩。 | +| `/steer <instruction>` | 对正在运行的 bridge run 发送 steering instruction。 | +| `/destroy` | 销毁 bridge agent,清空运行态和队列。 | +| `/reload-mcp [server]` | session idle 时重载 MCP。 | + +未知 command 会返回 `session.command` error,不进入 agent run。 + +## 17. 上下文压缩 + +压缩有三种触发方式: + +1. run 前 `buildCompressedHistory()` 根据上下文窗口自动判断。 +2. Bridge 运行中发出 `bridge.compression.requested`。 +3. 用户执行 `/compress`。 + +压缩结果会写 compression snapshot,并在后续 context 构建时使用 +snapshot-aware history,避免重复发送完整长历史。 + +事件: + +- `compression.started` +- `compression.completed` + +前端会显示临时压缩状态,并更新 session `contextTokens`。 + +## 18. 多 tab 和断线恢复 + +多 tab 使用同一个 `session:{id}` room。 + +关键机制: + +- 当前 tab 发出用户消息后,其他 tab 收到 `run.peer_user_message`。 +- 所有服务端事件都带 `session_id`。 +- 前端全局 Socket.IO listener 根据 `session_id` 分发到对应 handler。 +- transient disconnect 后,前端 reconnect 会自动发 `resume`。 +- `resume` 返回: + - DB/内存 messages + - `isWorking` + - `isAborting` + - transient `events` + - usage/context tokens + - queue length/messages + +因此页面刷新、切换 session、断线重连后可以恢复: + +- streaming assistant +- pending approval +- pending clarify +- compression state +- abort state +- queue state + +## 19. HTTP Session API 和 Chat 的关系 + +Socket.IO `/chat-run` 负责 active run。HTTP API 负责静态数据读写: + +| 能力 | 路径/模块 | +| --- | --- | +| session list | `controllers/hermes/sessions.ts` + `session-store.ts` | +| session detail/page | `fetchSessionMessagesPage()` 对应后端分页 detail | +| delete session | 删除 Web UI DB session/messages,同时尝试删除对应 Hermes profile 历史(如果存在) | +| rename session | `renameSession()` | +| set session model | `setSessionModel()` | +| conversation monitor | 从本地 session/conversation summary 读,不驱动 active run | + +Chat 运行过程中落库由 Socket.IO run handler 做;页面历史和列表刷新使用 HTTP API。 + +## 20. Group Chat 链路 + +Group Chat 是聊天核心链路的一部分,使用独立 namespace `/group-chat`,但 agent +执行、context token、压缩、tool/reasoning 展示仍会复用 Agent Bridge 和共享 +context-compressor。 + +主链路: + +```text +GroupChatPanel / GroupChatInput + -> group-chat Pinia store + -> packages/client/src/api/hermes/group-chat.ts + -> Socket.IO namespace /group-chat + -> GroupChatServer + -> AgentClients + -> AgentBridgeClient + -> hermes_bridge.py broker + -> profile worker + -> AIAgent / Hermes Agent tools +``` + +核心行为: + +- `GroupChatServer` 管理 room、member、typing、message、agent runtime 和 room + runtime state。 +- 普通用户通过 `/group-chat` socket 加入 room;agent 也作为 socket client 加入 + 同一个 namespace,但使用 `source=agent` 和 `GROUP_CHAT_AGENT_SOCKET_SECRET`。 +- `AgentClients` 根据 mention routing 选择目标 agent,构造 group history 和 + instructions,然后通过 `AgentBridgeClient` 调用对应 profile 的 bridge session。 +- group-chat 消息、tool call、tool result、reasoning、context status 都写入 + group-chat 自己的 DB 表,并广播给 room。 +- group-chat 有独立 room compression 配置: + `triggerTokens`、`maxHistoryTokens`、`tailMessageCount`。 +- context 压缩通过 `ContextEngine` 和共享 `context-compressor` 实现;压缩进度会 + 以 room/agent 维度同步到前端。 +- 用户头像、agent 成员、在线状态、邀请链接属于 group-chat member 元数据链路; + 改动这些也需要记录,因为它们会影响聊天页面展示和 room 同步。 + +排查 group-chat 时优先看: + +- `/group-chat` socket handshake auth、`authUserId`、`source=agent`。 +- `GroupChatServer.onConnection()`、room join/message/approval/interrupt handlers。 +- `AgentClients` 的 mention routing、bridge context cache、tool/reasoning 映射。 +- `ContextEngine` 和 room compression 配置。 +- `gc_*` DB 表以及 `group-chat` 相关 server/client tests。 + +## 21. 启动和关闭 + +启动时: + +1. server bootstrap 初始化 DB schema。 +2. 启动 group chat Socket.IO server。 +3. 尝试启动 Agent Bridge manager。 +4. 创建 `ChatRunSocket(groupChatServer.getIO())` 并 `init()`。 + +Bridge 启动失败不会阻止 Web UI server 启动,但普通 Chat run 会在调用 bridge 时失败。 + +关闭时: + +- `ChatRunSocket.close()` abort active response stream/清理内存态。 +- `AgentBridgeManager.stop()` 默认关闭 Python broker,包括 `restart` 使用的 + `SIGUSR2`。如果需要保留 broker 和正在运行的 bridge session,可显式设置 + `HERMES_AGENT_BRIDGE_STOP_ON_SHUTDOWN=0`。 +- 其他 WebSocket/Socket.IO 服务按 shutdown 流程停止。 + +## 22. 环境变量 + +| 变量 | 说明 | +| --- | --- | +| `HERMES_AGENT_BRIDGE_ENDPOINT` | Node 到 Python bridge broker 的 endpoint。Windows 默认 TCP,macOS/Linux 默认 IPC。 | +| `HERMES_AGENT_BRIDGE_WORKER_TRANSPORT` | broker 到 profile worker 的 transport,支持 `tcp`/`ipc`。 | +| `HERMES_AGENT_BRIDGE_WORKER_PORT_BASE` | TCP worker 起始端口。 | +| `HERMES_AGENT_BRIDGE_TIMEOUT_MS` | Node 等待 bridge 请求响应的超时,默认 120000ms。 | +| `HERMES_AGENT_BRIDGE_CONNECT_RETRY_MS` | Node connect bridge 的短重试窗口,默认 5000ms。 | +| `HERMES_AGENT_BRIDGE_STARTUP_TIMEOUT_MS` | bridge ready 超时,默认 120000ms。 | +| `HERMES_AGENT_BRIDGE_STOP_ON_SHUTDOWN` | Web UI shutdown/restart 是否关闭 bridge broker,默认开启。设为 `0`/`false`/`no`/`off` 才会保留 broker。 | +| `HERMES_AGENT_BRIDGE_AUTO_RESTART` | broker 意外退出是否自动重启,默认开启。 | +| `HERMES_AGENT_BRIDGE_RESTART_DELAY_MS` | 自动重启基础延迟。 | +| `HERMES_AGENT_BRIDGE_PYTHON` | 指定 Python 解释器。 | +| `HERMES_AGENT_ROOT` | 指定 hermes-agent 根目录。 | +| `HERMES_AGENT_BRIDGE_UV` / `UV` | 指定 uv。 | +| `HERMES_AGENT_BRIDGE_PLATFORM` | bridge 传给 Hermes Agent 的 platform,默认 `cli`。 | +| `HERMES_BRIDGE_PROVIDER` | 覆盖 bridge provider。 | +| `HERMES_BRIDGE_MAX_TURNS` | 覆盖 bridge 最大轮数。 | +| `HERMES_WEB_UI_DISABLE_GATEWAY_AUTOSTART` | 跳过启动时的 gateway 检查/自动启动;dashboard-only 部署可用。 | +| `HERMES_WEB_UI_DISABLE_SKILL_INJECTION` | 跳过启动时的内置 skill 注入;外部管理 skills 时可用。默认注入只更新 Web UI 管理或完全相同的内置副本,用户修改的同名 skills 会跳过。 | +| `HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_TRANSPORT` | Version Preview bridge transport。 | +| `HERMES_WEB_UI_PREVIEW_AGENT_BRIDGE_ENDPOINT` | Version Preview bridge endpoint。 | + +## 23. 当前限制和注意事项 + +- 当前普通 Chat 实际固定走 `cli` bridge;`api_server` handler 是保留代码,不是主链路。 +- Bridge 启动失败时 Web UI 仍能打开,但 Chat run 会失败。 +- Bridge socket 是本地 JSON line 协议,不是浏览器直接连接 Python。 +- 同一 session 只有一个 active run;并发输入走队列。 +- `sessionMap` 是进程内 transient 状态,server 重启后只能从 DB 恢复已落库内容,不能恢复已丢失的 Python 内存 run。 +- 工具审批依赖 Python bridge 和 Hermes tools 的审批回调;“始终允许”需要持久 allowlist 和进程内 allowlist cache 都更新。 +- `workspace`、profile、model/provider 都会影响 run 上下文,排查问题时不要只看 session id。 +- group-chat 是独立 namespace `/group-chat` 和独立 store/service,但属于聊天核心链路,相关改动也要更新本文变更记录。 + +## 24. 排查入口 + +常见问题和优先检查点: + +| 问题 | 检查 | +| --- | --- | +| 发送后没有输出 | 看 server log 中 `[chat-run-socket] starting CLI bridge run` 和 bridge 是否 ready。 | +| Socket 认证失败 | 检查 localStorage token、`/chat-run` auth middleware、用户 profile 权限。 | +| profile 不对 | 看 socket query profile、payload profile、DB session.profile。已存在 session 优先使用 DB profile。 | +| 输出重复 | 看 `stream.delta` 和 `chunk.delta` 是否被重复处理,重点查 `bridge-delta.ts` / `applyBridgeChunkAsync()`。 | +| 工具消息丢失 | 查 `flushBridgePendingToDb()`、`recordBridgeToolStarted()`、`recordBridgeToolCompleted()`。 | +| 刷新后状态不对 | 查 `resume` payload、`state.events`、前端 `resumeServerWorkingRun()`。 | +| 队列不同步 | 查 `run.queued`、`queued_messages`、`dequeued_queue_id`。 | +| 审批一直弹 | 查 bridge approval callback、`approval.respond`、工具 allowlist 是否持久化且 cache 已刷新。 | +| abort 后仍运行 | 查 `bridge.interrupt()`、`goalPause()`、`abort.completed` 和 Python worker 状态。 | +| token/context 数不对 | 查 `context_estimate`、compression snapshot、`refreshFinalContextUsage()`。 | diff --git a/package.json b/package.json index 75e5bc0..d9d374d 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,11 @@ { "name": "hermes-ui", "version": "0.6.29", - "description": "Self-hosted AI chat dashboard for Hermes Agent — multi-model web UI with multi-platform integration", + "description": "Self-hosted web dashboard for Hermes Agent — chat, sessions, skills, MCP, and usage analytics", "repository": { "type": "git", - "url": "https://github.com/EKKOLearnAI/hermes-studio.git" + "url": "https://gitea.erhe.top/toor/hermes-ui.git" }, - "homepage": "https://hermes-studio.ai", "license": "BSL-1.1", "engines": { "node": ">=23.0.0" @@ -14,7 +13,7 @@ "keywords": [ "hermes", "hermes-agent", - "hermes-web", + "hermes-ui", "agent", "ai", "ai-agent", diff --git a/packages/client/index.html b/packages/client/index.html index 98c3768..d92abe1 100644 --- a/packages/client/index.html +++ b/packages/client/index.html @@ -8,9 +8,9 @@ <link rel="manifest" href="/manifest.webmanifest" /> <meta name="theme-color" content="#f7f7f4" /> <meta name="apple-mobile-web-app-capable" content="yes" /> - <meta name="apple-mobile-web-app-title" content="Hermes Studio" /> + <meta name="apple-mobile-web-app-title" content="hermes-ui" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> - <title>Hermes Studio + hermes-ui