Update CLI chat session bridge (#697)
* feat: add CLI chat sessions with Python agent bridge Introduce a new CLI chat mode that connects Web UI directly to Hermes Agent's AIAgent via a Python bridge subprocess and Socket.IO, bypassing the API Server /v1/responses path. Supports streaming, slash commands (/new, /undo, /retry, /branch, /compress, /save, /title), interrupt, and steer. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * feat: update CLI chat session bridge * fix: extend agent bridge startup timeouts * docs: update bridge chat session design * feat: align bridge compression and provider registry * chore: bump version to 0.5.20 --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,8 @@ package-lock.json
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
server/dist
|
||||
packages/server/dist
|
||||
*.local
|
||||
|
||||
@@ -33,6 +33,7 @@ RUN npm run build && npm prune --omit=dev
|
||||
ENV NODE_ENV=production
|
||||
ENV HOME=/home/agent
|
||||
ENV HERMES_HOME=/home/agent/.hermes
|
||||
ENV PATH=/opt/hermes/.venv/bin:$PATH
|
||||
|
||||
EXPOSE 6060
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ services:
|
||||
- PORT=${PORT:-6060}
|
||||
- HERMES_HOME=/home/agent/.hermes
|
||||
- HERMES_BIN=/opt/hermes/.venv/bin/hermes
|
||||
- PATH=/opt/hermes/.venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
- AUTH_DISABLED=${AUTH_DISABLED:-false}
|
||||
- HERMES_ALLOW_ROOT_GATEWAY=1
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
# CLI/Bridge Chat Sessions 实现文档
|
||||
|
||||
> 分支:`feat/cli-chat-sessions`
|
||||
|
||||
## 概述
|
||||
|
||||
当前实现把原来的聊天通道统一到 Socket.IO namespace `/chat-run`。前端仍使用同一套 `ChatPanel + MessageList + ChatInput`,通过会话的 `source` 字段区分运行方式:
|
||||
|
||||
| source | 运行路径 | 说明 |
|
||||
|--------|----------|------|
|
||||
| `api_server` | Web UI Server → Hermes Gateway `/v1/responses` | 默认聊天路径 |
|
||||
| `cli` | Web UI Server → Python agent bridge → `AIAgent` | Bridge(beta),在 Web UI 服务端子进程里直接运行 Hermes Agent |
|
||||
|
||||
Bridge 会话不是一个独立 UI 面板,而是普通会话的一种来源。用户通过“新建聊天”下拉菜单选择 `API` 或 `Bridge (beta)`。
|
||||
|
||||
Bridge 模式支持:
|
||||
|
||||
- 流式文本输出
|
||||
- reasoning/thinking 增量
|
||||
- tool started/completed 事件
|
||||
- 工具审批请求与响应
|
||||
- abort 中断
|
||||
- per-session 队列
|
||||
- profile 隔离
|
||||
- 从 DB resume 会话
|
||||
- 与 API Server 路径共用上下文压缩逻辑
|
||||
|
||||
当前不再支持旧文档里的独立 `/cli-chat-run` namespace、`CliChatPanel.vue`、`cli-chat.ts` 和 CLI 命令控制层。前端不会再发送 `command` 或 `steer` socket 事件,也不会把 `/new`、`/reset`、`/undo`、`/retry`、`/branch`、`/compress` 等输入当作特殊命令处理。
|
||||
|
||||
---
|
||||
|
||||
## 整体架构
|
||||
|
||||
```text
|
||||
ChatPanel.vue
|
||||
├─ MessageList.vue
|
||||
└─ ChatInput.vue
|
||||
│
|
||||
│ Socket.IO /chat-run
|
||||
▼
|
||||
ChatRunSocket (Node.js)
|
||||
├─ source=api_server → Hermes Gateway /v1/responses
|
||||
└─ source=cli → AgentBridgeClient
|
||||
│ TCP/Unix socket, newline JSON
|
||||
▼
|
||||
hermes_bridge.py
|
||||
│ in-process import
|
||||
▼
|
||||
AIAgent (hermes-agent)
|
||||
```
|
||||
|
||||
### 分流规则
|
||||
|
||||
`ChatRunSocket.resolveRunSource()` 决定本轮运行走哪个后端:
|
||||
|
||||
1. `run` payload 中 `source === 'cli'` 时走 bridge。
|
||||
2. `source === 'api_server'` 时走 gateway。
|
||||
3. 未显式传 `source` 时,如果 DB 中已有 session 的 `source` 是 `cli`,继续走 bridge。
|
||||
4. 其他情况默认走 `api_server`。
|
||||
|
||||
---
|
||||
|
||||
## 主要文件
|
||||
|
||||
### 前端
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `packages/client/src/components/hermes/chat/ChatPanel.vue` | 统一聊天面板;新建菜单包含 `API` 和 `Bridge (beta)`;渲染审批条 |
|
||||
| `packages/client/src/components/hermes/chat/MessageList.vue` | 统一消息列表;展示文本、reasoning、tool 消息等 |
|
||||
| `packages/client/src/components/hermes/chat/ChatInput.vue` | 统一输入框;发送、停止、附件上传入口 |
|
||||
| `packages/client/src/api/hermes/chat.ts` | `/chat-run` Socket.IO 客户端;注册 session 事件处理器;发送 run/abort/approval |
|
||||
| `packages/client/src/stores/hermes/chat.ts` | 会话状态、发送流程、resume、队列、审批、消息映射 |
|
||||
|
||||
### 后端
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `packages/server/src/services/hermes/chat-run-socket.ts` | `/chat-run` Socket.IO 服务;同时处理 API Server 和 Bridge 运行 |
|
||||
| `packages/server/src/services/hermes/agent-bridge/client.ts` | Node 端 bridge 客户端;通过 socket 请求 Python bridge |
|
||||
| `packages/server/src/services/hermes/agent-bridge/manager.ts` | Python bridge 子进程生命周期管理 |
|
||||
| `packages/server/src/services/hermes/agent-bridge/hermes_bridge.py` | Python bridge 服务;创建并复用 `AIAgent` 实例 |
|
||||
| `packages/server/src/services/hermes/agent-bridge/index.ts` | bridge 模块导出 |
|
||||
| `packages/server/src/index.ts` | 启动 `AgentBridgeManager` 和 `ChatRunSocket` |
|
||||
| `packages/server/src/services/shutdown.ts` | 关闭时停止 chat socket 和 bridge 子进程 |
|
||||
| `packages/server/src/controllers/hermes/sessions.ts` | 会话列表和详情读取,包含 `source` 信息 |
|
||||
| `packages/server/src/controllers/hermes/profiles.ts` | profile 切换/管理时清理 bridge 内存会话 |
|
||||
|
||||
### 已移除的旧文件
|
||||
|
||||
| 文件 | 状态 |
|
||||
|------|------|
|
||||
| `packages/client/src/api/hermes/cli-chat.ts` | 已删除 |
|
||||
| `packages/client/src/components/hermes/chat/CliChatPanel.vue` | 已删除 |
|
||||
| `packages/server/src/services/hermes/cli-chat-run-socket.ts` | 已删除 |
|
||||
|
||||
---
|
||||
|
||||
## 前端流程
|
||||
|
||||
### 新建会话
|
||||
|
||||
`ChatPanel.vue` 中的新建按钮使用下拉菜单:
|
||||
|
||||
- `API`:调用 `chatStore.newChat()`,创建默认 `api_server` 会话。
|
||||
- `Bridge (beta)`:调用 `chatStore.newCliSession()`,创建 `source: 'cli'` 会话。
|
||||
|
||||
Bridge 会话 ID 使用类似 `YYYYMMDD_HHMMSS_xxxxxx` 的格式,便于与 Hermes CLI 风格的 session ID 对齐。
|
||||
|
||||
### 发送消息
|
||||
|
||||
1. `ChatInput.vue` 触发 store 的发送逻辑。
|
||||
2. `chat.ts` 根据 active session 组装输入内容,附件会被转为 `ContentBlock[]`。
|
||||
3. 调用 `startRunViaSocket()`。
|
||||
4. 前端向 `/chat-run` emit:
|
||||
|
||||
```ts
|
||||
socket.emit('run', {
|
||||
session_id,
|
||||
input,
|
||||
instructions,
|
||||
model,
|
||||
queue_id,
|
||||
source, // api_server 或 cli
|
||||
})
|
||||
```
|
||||
|
||||
5. 前端注册本 session 的事件 handler,通过 `session_id` 隔离多会话并发事件。
|
||||
|
||||
### Resume
|
||||
|
||||
切换会话、页面恢复可见、或刷新后,前端通过:
|
||||
|
||||
```ts
|
||||
socket.emit('resume', { session_id })
|
||||
```
|
||||
|
||||
服务端返回:
|
||||
|
||||
```ts
|
||||
{
|
||||
session_id,
|
||||
messages,
|
||||
isWorking,
|
||||
isAborting,
|
||||
events,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
queueLength,
|
||||
}
|
||||
```
|
||||
|
||||
如果服务端发现该 session 仍在运行,前端会重新注册 handler,并允许继续 abort。
|
||||
|
||||
### 审批
|
||||
|
||||
Bridge 工具需要人工确认时,服务端会发 `approval.requested`,前端 store 记录为 `activePendingApproval`,`ChatPanel.vue` 在输入框上方显示审批条。
|
||||
|
||||
前端响应审批:
|
||||
|
||||
```ts
|
||||
socket.emit('approval.respond', {
|
||||
session_id,
|
||||
approval_id,
|
||||
choice, // once | session | always | deny
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `/chat-run` Socket.IO 协议
|
||||
|
||||
### 客户端 → 服务端
|
||||
|
||||
| 事件 | 数据 | 说明 |
|
||||
|------|------|------|
|
||||
| `run` | `{ session_id, input, model?, instructions?, queue_id?, source? }` | 启动一轮运行;`source` 决定 API Server 或 Bridge |
|
||||
| `resume` | `{ session_id }` | 加入 session room 并恢复状态 |
|
||||
| `abort` | `{ session_id }` | 中断当前运行 |
|
||||
| `cancel_queued_run` | `{ session_id, queue_id }` | 取消等待队列中的一条 run |
|
||||
| `approval.respond` | `{ session_id, approval_id, choice }` | 响应 Bridge 工具审批 |
|
||||
|
||||
当前没有 `command`、`steer` 或 slash-command 相关 Socket.IO 事件。
|
||||
|
||||
### 服务端 → 客户端
|
||||
|
||||
| 事件 | 说明 |
|
||||
|------|------|
|
||||
| `resumed` | 返回 DB 消息、运行状态、队列长度和最近事件 |
|
||||
| `run.started` | 运行开始 |
|
||||
| `run.queued` | 当前 session 已有运行,新请求进入队列 |
|
||||
| `message.delta` | 文本增量 |
|
||||
| `reasoning.delta` | reasoning 增量 |
|
||||
| `thinking.delta` | thinking 增量 |
|
||||
| `reasoning.available` | reasoning 内容可用 |
|
||||
| `tool.started` | 工具调用开始 |
|
||||
| `tool.completed` | 工具调用结束 |
|
||||
| `approval.requested` | Bridge 工具请求人工审批 |
|
||||
| `approval.resolved` | 审批完成或超时 |
|
||||
| `compression.started` | 上下文压缩开始 |
|
||||
| `compression.completed` | 上下文压缩结束 |
|
||||
| `usage.updated` | token 用量更新 |
|
||||
| `abort.started` | 中断开始 |
|
||||
| `abort.completed` | 中断结束 |
|
||||
| `run.completed` | 运行完成 |
|
||||
| `run.failed` | 运行失败 |
|
||||
|
||||
### 认证
|
||||
|
||||
`/chat-run` 使用 Socket.IO auth token:
|
||||
|
||||
```ts
|
||||
io(`${baseUrl}/chat-run`, {
|
||||
auth: { token },
|
||||
query: { profile },
|
||||
})
|
||||
```
|
||||
|
||||
如果未设置 `AUTH_DISABLED=1`,服务端会与 Web UI token 比对。
|
||||
|
||||
---
|
||||
|
||||
## ChatRunSocket 后端行为
|
||||
|
||||
### API Server 路径
|
||||
|
||||
`source=api_server` 时:
|
||||
|
||||
1. 写入用户消息到 Web UI 本地 session DB。
|
||||
2. 通过 `buildCompressedHistory()` 构建上下文。
|
||||
3. 请求当前 profile 的 Hermes Gateway:
|
||||
|
||||
```text
|
||||
POST <upstream>/v1/responses
|
||||
```
|
||||
|
||||
4. 读取 SSE frame,映射为统一的 `/chat-run` 事件。
|
||||
5. 完成后写入 assistant/tool 消息,更新 usage。
|
||||
|
||||
### Bridge 路径
|
||||
|
||||
`source=cli` 时:
|
||||
|
||||
1. 写入用户消息到 Web UI 本地 session DB。
|
||||
2. 复用同一套 `buildCompressedHistory()` 构建压缩上下文。
|
||||
3. 调用:
|
||||
|
||||
```ts
|
||||
this.bridge.chat(session_id, input, history, instructions, profile)
|
||||
```
|
||||
|
||||
4. 轮询 `AgentBridgeClient.streamOutput(run_id)`。
|
||||
5. 将 Python bridge 的 delta 和 events 映射成统一事件。
|
||||
6. 将 assistant 文本、reasoning、tool 调用结果 flush 回 DB。
|
||||
|
||||
### 队列
|
||||
|
||||
同一个 `session_id` 同时只能有一个 active run。新的 `run` 到达时:
|
||||
|
||||
- 如果当前 session 正在运行,则放入 `state.queue`。
|
||||
- 发送 `run.queued` 更新队列长度。
|
||||
- 当前 run 结束或 abort 完成后,自动执行下一条 queued run。
|
||||
|
||||
---
|
||||
|
||||
## Python Agent Bridge
|
||||
|
||||
### 通信协议
|
||||
|
||||
Node 和 Python bridge 之间使用本地 socket 的单行 JSON 协议:
|
||||
|
||||
```json
|
||||
{ "action": "chat", "session_id": "xxx", "message": "hello" }
|
||||
```
|
||||
|
||||
响应也是单行 JSON:
|
||||
|
||||
```json
|
||||
{ "ok": true, "run_id": "xxx", "session_id": "xxx", "status": "running" }
|
||||
```
|
||||
|
||||
### Endpoint
|
||||
|
||||
默认 endpoint 按平台选择:
|
||||
|
||||
| 平台 | 默认 endpoint |
|
||||
|------|---------------|
|
||||
| Windows | `tcp://127.0.0.1:18765` |
|
||||
| macOS/Linux | `ipc:///tmp/hermes-agent-bridge.sock` |
|
||||
|
||||
Windows 使用 TCP 是因为部分 Python/Windows 环境没有 Unix domain socket 支持。
|
||||
|
||||
### 当前实际使用的 action
|
||||
|
||||
| Action | 说明 |
|
||||
|--------|------|
|
||||
| `chat` | 启动一轮 `AIAgent.run_conversation()` |
|
||||
| `get_output` | 通过 `cursor` 和 `event_cursor` 获取增量文本与事件 |
|
||||
| `interrupt` | 调用 agent 中断当前运行 |
|
||||
| `approval_respond` | 响应工具审批 |
|
||||
| `destroy_all` | profile 切换/管理时销毁全部 bridge 内存 session |
|
||||
|
||||
bridge 代码里还保留了一些调试/维护 action,例如 `ping`、`get_result`、`get_history`、`destroy`、`list`、`shutdown`、`steer`,但当前 `/chat-run` 前端路径不会暴露这些能力。
|
||||
|
||||
旧的 `command` action 已移除,bridge 不再处理 `/new`、`/undo`、`/retry`、`/branch`、`/compress` 等斜杠命令。
|
||||
|
||||
### 会话和 profile
|
||||
|
||||
`AgentPool` 维护 `session_id -> AgentSession`:
|
||||
|
||||
- 每个 session 持有独立 `AIAgent` 实例。
|
||||
- session 按 profile 创建,profile 改变时会重建对应 agent。
|
||||
- `HERMES_HOME` 会在创建 agent 时临时切到 profile home。
|
||||
- `SessionDB` 按 profile 的 `state.db` 路径缓存。
|
||||
- 空闲 session 会被 bridge GC,默认 30 分钟无运行后销毁内存态。
|
||||
|
||||
### 工具和审批事件
|
||||
|
||||
bridge 从 `AIAgent` 回调中收集事件:
|
||||
|
||||
- `stream.delta`
|
||||
- `reasoning.delta`
|
||||
- `thinking.delta`
|
||||
- `tool.started`
|
||||
- `tool.completed`
|
||||
- `tool.progress`
|
||||
- `approval.requested`
|
||||
- `approval.resolved`
|
||||
- `turn.boundary`
|
||||
- `status`
|
||||
|
||||
`ChatRunSocket` 会把这些事件转换为前端统一事件,并负责 DB 落盘。
|
||||
|
||||
审批默认等待 60 秒,超时自动 `deny`。
|
||||
|
||||
---
|
||||
|
||||
## AgentBridgeClient
|
||||
|
||||
`AgentBridgeClient` 是 Node 端本地 socket 客户端。
|
||||
|
||||
行为:
|
||||
|
||||
- 支持 `ipc://` 和 `tcp://` endpoint。
|
||||
- 每次请求新建 socket,发送一行 JSON,读取一行 JSON。
|
||||
- 请求通过内部 lock 串行化。
|
||||
- 默认请求响应超时为 `120000ms`。
|
||||
- `streamOutput()` 每 100ms 轮询一次 `get_output`。
|
||||
|
||||
示例:
|
||||
|
||||
```ts
|
||||
const started = await bridge.chat(sessionId, input, history, instructions, profile)
|
||||
|
||||
for await (const chunk of bridge.streamOutput(started.run_id)) {
|
||||
// chunk.delta
|
||||
// chunk.events
|
||||
// chunk.done
|
||||
}
|
||||
```
|
||||
|
||||
注意:目前 socket connect 阶段没有独立 connect timeout,主要依赖系统连接错误和请求响应 timeout。
|
||||
|
||||
---
|
||||
|
||||
## AgentBridgeManager
|
||||
|
||||
`AgentBridgeManager` 负责启动和停止 Python bridge。
|
||||
|
||||
启动流程:
|
||||
|
||||
1. 定位 `hermes_bridge.py`。
|
||||
2. 发现 `hermes-agent` 根目录。
|
||||
3. 选择 Python 解释器。
|
||||
4. 以子进程启动:
|
||||
|
||||
```text
|
||||
python hermes_bridge.py --endpoint <endpoint> --agent-root <root> --hermes-home <home>
|
||||
```
|
||||
|
||||
5. 监听 stdout,等待:
|
||||
|
||||
```json
|
||||
{ "event": "ready", "endpoint": "..." }
|
||||
```
|
||||
|
||||
6. 默认 ready 超时为 `120000ms`。
|
||||
|
||||
Python 选择优先级:
|
||||
|
||||
1. `HERMES_AGENT_BRIDGE_PYTHON`
|
||||
2. `agentRoot/venv` 或 `agentRoot/.venv`
|
||||
3. installed `hermes` 命令 shebang
|
||||
4. `uv run --project <agentRoot> python`
|
||||
5. 系统 `python3` / `python`
|
||||
|
||||
关闭时先发 `SIGTERM`,1.5 秒后仍未退出则 `SIGKILL`。
|
||||
|
||||
---
|
||||
|
||||
## 启动与关闭
|
||||
|
||||
### 启动
|
||||
|
||||
`bootstrap()` 中会先尝试启动 bridge:
|
||||
|
||||
```ts
|
||||
agentBridgeManager = await startAgentBridgeManager()
|
||||
```
|
||||
|
||||
bridge 启动失败不会阻止 Web UI 启动,但 Bridge(beta) 会话后续运行会失败。
|
||||
|
||||
随后创建统一的 chat socket:
|
||||
|
||||
```ts
|
||||
chatRunServer = new ChatRunSocket(groupChatServer.getIO(), getGatewayManagerInstance())
|
||||
chatRunServer.init()
|
||||
```
|
||||
|
||||
### 关闭
|
||||
|
||||
服务关闭时会清理:
|
||||
|
||||
- `/chat-run` Socket.IO 状态
|
||||
- Python agent bridge 子进程
|
||||
- 其他 WebSocket/Socket.IO 服务
|
||||
|
||||
---
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `HERMES_AGENT_BRIDGE_ENDPOINT` | Bridge endpoint;Windows 默认 `tcp://127.0.0.1:18765`,macOS/Linux 默认 `ipc:///tmp/hermes-agent-bridge.sock` |
|
||||
| `HERMES_AGENT_BRIDGE_TIMEOUT_MS` | Node 等待 bridge 请求响应的超时,默认 `120000` ms |
|
||||
| `HERMES_AGENT_BRIDGE_STARTUP_TIMEOUT_MS` | Node 等待 Python bridge ready 的超时,默认 `120000` ms |
|
||||
| `HERMES_AGENT_BRIDGE_PYTHON` | 指定 Python 解释器路径 |
|
||||
| `HERMES_AGENT_ROOT` | hermes-agent 安装目录 |
|
||||
| `HERMES_AGENT_BRIDGE_UV` | 指定 uv 可执行文件路径 |
|
||||
| `HERMES_AGENT_BRIDGE_PLATFORM` | bridge 传给 Hermes Agent 的平台标识,默认 `cli` |
|
||||
| `HERMES_BRIDGE_PROVIDER` | 覆盖 bridge 使用的 provider |
|
||||
| `HERMES_BRIDGE_MAX_TURNS` | 覆盖 bridge 最大轮数 |
|
||||
| `UV` | uv 可执行文件路径 fallback |
|
||||
|
||||
Windows 首次启动慢时可以临时放大:
|
||||
|
||||
```powershell
|
||||
$env:HERMES_AGENT_BRIDGE_STARTUP_TIMEOUT_MS = "300000"
|
||||
$env:HERMES_AGENT_BRIDGE_TIMEOUT_MS = "300000"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 当前限制
|
||||
|
||||
- Bridge(beta) 仍依赖 Python bridge 成功启动;启动失败时 Web UI 可用,但 bridge 会话不可用。
|
||||
- bridge socket connect 阶段还没有单独 connect timeout。
|
||||
- 旧 CLI 独立面板和独立 `/cli-chat-run` namespace 已移除。
|
||||
- 旧 bridge 斜杠命令和 `command/steer` socket 控制层已移除;现在输入框内容一律按普通用户消息发送。
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hermes-web-ui",
|
||||
"version": "0.5.17",
|
||||
"version": "0.5.20",
|
||||
"description": "Self-hosted AI chat dashboard for Hermes Agent — multi-model web UI with multi-platform integration",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface StartRunRequest {
|
||||
session_id?: string
|
||||
model?: string
|
||||
queue_id?: string
|
||||
source?: 'api_server' | 'cli'
|
||||
}
|
||||
|
||||
export interface StartRunResponse {
|
||||
@@ -77,6 +78,8 @@ const sessionEventHandlers = new Map<string, {
|
||||
onAbortCompleted: (event: RunEvent) => void
|
||||
onUsageUpdated: (event: RunEvent) => void
|
||||
onRunQueued?: (event: RunEvent) => void
|
||||
onApprovalRequested?: (event: RunEvent) => void
|
||||
onApprovalResolved?: (event: RunEvent) => void
|
||||
}>()
|
||||
|
||||
/**
|
||||
@@ -288,6 +291,26 @@ function globalUsageUpdatedHandler(event: RunEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
function globalApprovalRequestedHandler(event: RunEvent): void {
|
||||
const sid = event.session_id
|
||||
if (!sid) return
|
||||
|
||||
const handlers = sessionEventHandlers.get(sid)
|
||||
if (handlers?.onApprovalRequested) {
|
||||
handlers.onApprovalRequested(event)
|
||||
}
|
||||
}
|
||||
|
||||
function globalApprovalResolvedHandler(event: RunEvent): void {
|
||||
const sid = event.session_id
|
||||
if (!sid) return
|
||||
|
||||
const handlers = sessionEventHandlers.get(sid)
|
||||
if (handlers?.onApprovalResolved) {
|
||||
handlers.onApprovalResolved(event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register event handlers for a session
|
||||
* @param sessionId - Session ID
|
||||
@@ -312,6 +335,8 @@ export function registerSessionHandlers(
|
||||
onAbortCompleted: (event: RunEvent) => void
|
||||
onUsageUpdated: (event: RunEvent) => void
|
||||
onRunQueued?: (event: RunEvent) => void
|
||||
onApprovalRequested?: (event: RunEvent) => void
|
||||
onApprovalResolved?: (event: RunEvent) => void
|
||||
}
|
||||
): () => void {
|
||||
sessionEventHandlers.set(sessionId, handlers)
|
||||
@@ -330,6 +355,19 @@ export function unregisterSessionHandlers(sessionId: string): void {
|
||||
sessionEventHandlers.delete(sessionId)
|
||||
}
|
||||
|
||||
export function respondToolApproval(
|
||||
sessionId: string,
|
||||
approvalId: string,
|
||||
choice: 'once' | 'session' | 'always' | 'deny',
|
||||
): void {
|
||||
const socket = connectChatRun()
|
||||
socket.emit('approval.respond', {
|
||||
session_id: sessionId,
|
||||
approval_id: approvalId,
|
||||
choice,
|
||||
})
|
||||
}
|
||||
|
||||
export function getChatRunSocket(): Socket | null {
|
||||
return chatRunSocket
|
||||
}
|
||||
@@ -365,7 +403,9 @@ export function connectChatRun(): Socket {
|
||||
reconnection: true,
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 10000,
|
||||
reconnectionDelayMax: 30000,
|
||||
randomizationFactor: 0.5,
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// Register global listeners only once per socket connection
|
||||
@@ -385,6 +425,8 @@ export function connectChatRun(): Socket {
|
||||
chatRunSocket.on('run.failed', globalRunFailedHandler)
|
||||
chatRunSocket.on('run.completed', globalRunCompletedHandler)
|
||||
chatRunSocket.on('run.queued', globalRunQueuedHandler)
|
||||
chatRunSocket.on('approval.requested', globalApprovalRequestedHandler)
|
||||
chatRunSocket.on('approval.resolved', globalApprovalResolvedHandler)
|
||||
|
||||
// Compression events
|
||||
chatRunSocket.on('compression.started', globalCompressionStartedHandler)
|
||||
@@ -527,6 +569,14 @@ export function startRunViaSocket(
|
||||
if (closed) return
|
||||
onEvent(evt)
|
||||
},
|
||||
onApprovalRequested: (evt: RunEvent) => {
|
||||
if (closed) return
|
||||
onEvent(evt)
|
||||
},
|
||||
onApprovalResolved: (evt: RunEvent) => {
|
||||
if (closed) return
|
||||
onEvent(evt)
|
||||
},
|
||||
}
|
||||
|
||||
// Register handlers in the global session map
|
||||
|
||||
@@ -66,11 +66,13 @@ export function connectGroupChat(opts?: { userId?: string; userName?: string; de
|
||||
name: opts?.userName || localStorage.getItem('gc_user_name') || undefined,
|
||||
description: opts?.description || localStorage.getItem('gc_user_description') || undefined,
|
||||
},
|
||||
transports: ['websocket'],
|
||||
transports: ['websocket', 'polling'],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 30000,
|
||||
randomizationFactor: 0.5,
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
return socket
|
||||
@@ -185,4 +187,3 @@ export async function forceCompress(roomId: string): Promise<{ success: boolean;
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -124,11 +124,16 @@ const groupedSessions = computed<SessionGroup[]>(() => {
|
||||
|
||||
return keys.map((key) => ({
|
||||
source: key,
|
||||
label: key ? getSourceLabel(key) : t("chat.other"),
|
||||
label: key ? getChatSourceLabel(key) : t("chat.other"),
|
||||
sessions: sortSessionsWithActiveFirst(map.get(key)!),
|
||||
}));
|
||||
});
|
||||
|
||||
function getChatSourceLabel(source?: string): string {
|
||||
if (source === "cli") return "Bridge (beta)";
|
||||
return getSourceLabel(source);
|
||||
}
|
||||
|
||||
function toggleGroup(source: string) {
|
||||
const isExpanded = !collapsedGroups.value.has(source);
|
||||
if (isExpanded) {
|
||||
@@ -204,10 +209,40 @@ const activeSessionSource = computed(() =>
|
||||
currentMode.value === "chat" ? chatStore.activeSession?.source || "" : "",
|
||||
);
|
||||
|
||||
const activeApproval = computed(() => chatStore.activePendingApproval);
|
||||
|
||||
function handleNewChat() {
|
||||
chatStore.newChat();
|
||||
}
|
||||
|
||||
function handleNewCliChat() {
|
||||
const session = chatStore.newCliSession()
|
||||
chatStore.switchSession(session.id)
|
||||
}
|
||||
|
||||
const newChatOptions = computed(() => [
|
||||
{
|
||||
label: "API",
|
||||
key: "api_server",
|
||||
},
|
||||
{
|
||||
label: "Bridge (beta)",
|
||||
key: "cli",
|
||||
},
|
||||
]);
|
||||
|
||||
function handleNewChatSelect(key: string | number) {
|
||||
if (key === "cli") {
|
||||
handleNewCliChat();
|
||||
return;
|
||||
}
|
||||
handleNewChat();
|
||||
}
|
||||
|
||||
function handleApproval(choice: "once" | "session" | "always" | "deny") {
|
||||
chatStore.respondApproval(choice);
|
||||
}
|
||||
|
||||
async function copySessionId(id?: string) {
|
||||
const sessionId = id || chatStore.activeSessionId;
|
||||
if (sessionId) {
|
||||
@@ -556,21 +591,27 @@ async function handleWorkspaceConfirm() {
|
||||
</svg>
|
||||
</template>
|
||||
</NButton>
|
||||
<NButton quaternary size="tiny" @click="handleNewChat" circle>
|
||||
<template #icon>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</template>
|
||||
</NButton>
|
||||
<NDropdown
|
||||
trigger="click"
|
||||
:options="newChatOptions"
|
||||
@select="handleNewChatSelect"
|
||||
>
|
||||
<NButton quaternary size="tiny" circle>
|
||||
<template #icon>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</template>
|
||||
</NButton>
|
||||
</NDropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showSessions" class="session-scope-note">
|
||||
@@ -723,7 +764,7 @@ async function handleWorkspaceConfirm() {
|
||||
</NButton>
|
||||
<span class="header-session-title">{{ headerTitle }}</span>
|
||||
<span v-if="activeSessionSource" class="source-badge">{{
|
||||
getSourceLabel(activeSessionSource)
|
||||
getChatSourceLabel(activeSessionSource)
|
||||
}}</span>
|
||||
<span
|
||||
v-if="chatStore.activeSession?.workspace"
|
||||
@@ -766,28 +807,74 @@ async function handleWorkspaceConfirm() {
|
||||
</template>
|
||||
{{ t("chat.copySessionId") }}
|
||||
</NTooltip>
|
||||
<NButton size="small" :circle="isMobile" @click="handleNewChat">
|
||||
<template #icon>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</template>
|
||||
<template v-if="!isMobile">{{ t("chat.newChat") }}</template>
|
||||
</NButton>
|
||||
<NDropdown
|
||||
trigger="click"
|
||||
:options="newChatOptions"
|
||||
@select="handleNewChatSelect"
|
||||
>
|
||||
<NButton size="small" :circle="isMobile">
|
||||
<template #icon>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</template>
|
||||
<template v-if="!isMobile">{{ t("chat.newChat") }}</template>
|
||||
</NButton>
|
||||
</NDropdown>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<template v-if="currentMode === 'chat'">
|
||||
<MessageList />
|
||||
<div v-if="activeApproval" class="approval-bar">
|
||||
<div class="approval-main">
|
||||
<div class="approval-title">Tool approval required</div>
|
||||
<div class="approval-desc">{{ activeApproval.description }}</div>
|
||||
<code class="approval-command">{{ activeApproval.command }}</code>
|
||||
</div>
|
||||
<div class="approval-actions">
|
||||
<NButton
|
||||
v-if="activeApproval.choices.includes('once')"
|
||||
size="small"
|
||||
type="primary"
|
||||
@click="handleApproval('once')"
|
||||
>
|
||||
Allow once
|
||||
</NButton>
|
||||
<NButton
|
||||
v-if="activeApproval.choices.includes('session')"
|
||||
size="small"
|
||||
@click="handleApproval('session')"
|
||||
>
|
||||
Allow session
|
||||
</NButton>
|
||||
<NButton
|
||||
v-if="activeApproval.choices.includes('always')"
|
||||
size="small"
|
||||
@click="handleApproval('always')"
|
||||
>
|
||||
Always
|
||||
</NButton>
|
||||
<NButton
|
||||
v-if="activeApproval.choices.includes('deny')"
|
||||
size="small"
|
||||
type="error"
|
||||
ghost
|
||||
@click="handleApproval('deny')"
|
||||
>
|
||||
Deny
|
||||
</NButton>
|
||||
</div>
|
||||
</div>
|
||||
<ChatInput />
|
||||
</template>
|
||||
<ConversationMonitorPane
|
||||
@@ -1259,6 +1346,54 @@ async function handleWorkspaceConfirm() {
|
||||
}
|
||||
}
|
||||
|
||||
.approval-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
border-top: 1px solid $border-color;
|
||||
background: $bg-card;
|
||||
}
|
||||
|
||||
.approval-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.approval-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
.approval-desc {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
.approval-command {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
max-height: 56px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-size: 12px;
|
||||
color: $text-primary;
|
||||
background: $bg-secondary;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.approval-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@keyframes rainbow-glow {
|
||||
0% {
|
||||
box-shadow:
|
||||
|
||||
@@ -26,10 +26,6 @@ function formatToolDuration(seconds: number): string {
|
||||
return `${mins}m ${secs}s`
|
||||
}
|
||||
|
||||
const displayMessages = computed(() =>
|
||||
chatStore.messages.filter((m) => m.role !== "tool"),
|
||||
);
|
||||
|
||||
const currentToolCalls = computed(() => {
|
||||
const msgs = chatStore.messages;
|
||||
// Find the last user message index
|
||||
@@ -45,6 +41,22 @@ const currentToolCalls = computed(() => {
|
||||
return [...tools].reverse();
|
||||
});
|
||||
|
||||
const displayMessages = computed(() =>
|
||||
chatStore.messages.filter((m) => {
|
||||
if (m.role === "tool") return false;
|
||||
if (
|
||||
m.role === "assistant" &&
|
||||
m.isStreaming &&
|
||||
!m.content?.trim() &&
|
||||
!!m.reasoning?.trim() &&
|
||||
currentToolCalls.value.length === 0
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
|
||||
const queuedMessages = computed(() => {
|
||||
const sid = chatStore.activeSessionId;
|
||||
if (!sid) return [];
|
||||
|
||||
@@ -131,6 +131,7 @@ export default {
|
||||
contextEditSuccess: 'Context length updated',
|
||||
contextEditFailed: 'Update failed',
|
||||
emptyState: 'Start a conversation with Hermes Agent',
|
||||
cliEmptyState: 'Start a CLI chat session',
|
||||
inputPlaceholder: 'Type a message... (Enter to send, Shift+Enter for new line)',
|
||||
attachFiles: 'Attach files',
|
||||
autoPlaySpeech: 'Auto-play voice',
|
||||
@@ -159,6 +160,7 @@ export default {
|
||||
searchEnterHint: 'Enter to open · Esc to close',
|
||||
searchFailed: 'Failed to search sessions',
|
||||
newChat: 'New Chat',
|
||||
newCliChat: 'New CLI',
|
||||
deleteSession: 'Delete this session?',
|
||||
sessionDeleted: 'Session deleted',
|
||||
toggleBatchMode: 'Batch selection',
|
||||
|
||||
@@ -131,6 +131,7 @@ export default {
|
||||
contextEditSuccess: '上下文长度已更新',
|
||||
contextEditFailed: '更新失败',
|
||||
emptyState: '开始与 Hermes Agent 对话',
|
||||
cliEmptyState: '开始 CLI 对话',
|
||||
inputPlaceholder: '输入消息... (Enter 发送,Shift+Enter 换行)',
|
||||
attachFiles: '添加附件',
|
||||
autoPlaySpeech: '自动播放语音',
|
||||
@@ -159,6 +160,7 @@ export default {
|
||||
searchEnterHint: 'Enter 打开 · Esc 关闭',
|
||||
searchFailed: '搜索会话失败',
|
||||
newChat: '新建对话',
|
||||
newCliChat: '新建 CLI',
|
||||
deleteSession: '确定删除此会话?',
|
||||
sessionDeleted: '会话已删除',
|
||||
toggleBatchMode: '批量选择',
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
/**
|
||||
* Provider registry — single source of truth for both frontend and backend.
|
||||
* Synced from hermes-agent hermes_cli/models.py _PROVIDER_MODELS.
|
||||
*/
|
||||
|
||||
export interface ProviderPreset {
|
||||
label: string
|
||||
value: string
|
||||
base_url: string
|
||||
models: string[]
|
||||
}
|
||||
|
||||
export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
{
|
||||
label: 'Anthropic',
|
||||
value: 'anthropic',
|
||||
base_url: 'https://api.anthropic.com',
|
||||
models: [
|
||||
'claude-opus-4-7',
|
||||
'claude-opus-4-6',
|
||||
'claude-sonnet-4-6',
|
||||
'claude-opus-4-5-20251101',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-sonnet-4-20250514',
|
||||
'claude-haiku-4-5-20251001',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Google AI Studio',
|
||||
value: 'gemini',
|
||||
base_url: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||
models: [
|
||||
'gemini-3.1-pro-preview',
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-3.1-flash-lite-preview',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash-lite',
|
||||
'gemma-4-31b-it',
|
||||
'gemma-4-26b-it',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'DeepSeek',
|
||||
value: 'deepseek',
|
||||
base_url: 'https://api.deepseek.com',
|
||||
models: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
},
|
||||
{
|
||||
label: 'Z.AI / GLM',
|
||||
value: 'zai',
|
||||
base_url: 'https://api.z.ai/api/paas/v4',
|
||||
models: ['glm-5.1', 'glm-5', 'glm-5v-turbo', 'glm-5-turbo', 'glm-4.7', 'glm-4.5', 'glm-4.5-flash'],
|
||||
},
|
||||
{
|
||||
label: 'Kimi for Coding',
|
||||
value: 'kimi-coding',
|
||||
base_url: 'https://api.kimi.com/coding/v1',
|
||||
models: [
|
||||
'kimi-for-coding',
|
||||
'kimi-k2.5',
|
||||
'kimi-k2-thinking',
|
||||
'kimi-k2-thinking-turbo',
|
||||
'kimi-k2-turbo-preview',
|
||||
'kimi-k2-0905-preview',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Kimi for Coding (CN)',
|
||||
value: 'kimi-coding-cn',
|
||||
base_url: 'https://api.kimi.com/coding/v1',
|
||||
models: [
|
||||
'kimi-k2.5',
|
||||
'kimi-k2-thinking',
|
||||
'kimi-k2-turbo-preview',
|
||||
'kimi-k2-0905-preview',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Moonshot',
|
||||
value: 'moonshot',
|
||||
base_url: 'https://api.moonshot.cn/v1',
|
||||
models: [
|
||||
'kimi-k2.5',
|
||||
'kimi-k2-thinking',
|
||||
'kimi-k2-turbo-preview',
|
||||
'kimi-k2-0905-preview',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'xAI',
|
||||
value: 'xai',
|
||||
base_url: 'https://api.x.ai/v1',
|
||||
models: ['grok-4.20-reasoning', 'grok-4-1-fast-reasoning'],
|
||||
},
|
||||
{
|
||||
label: 'MiniMax',
|
||||
value: 'minimax',
|
||||
base_url: 'https://api.minimax.io/anthropic/v1',
|
||||
models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2', 'MiniMax-M2-highspeed'],
|
||||
},
|
||||
{
|
||||
label: 'MiniMax (China)',
|
||||
value: 'minimax-cn',
|
||||
base_url: 'https://api.minimaxi.com/v1',
|
||||
models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2', 'MiniMax-M2-highspeed'],
|
||||
},
|
||||
{
|
||||
label: 'Alibaba Cloud',
|
||||
value: 'alibaba',
|
||||
base_url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||
models: [
|
||||
'qwen3.5-plus',
|
||||
'qwen3-coder-plus',
|
||||
'qwen3-coder-next',
|
||||
'glm-5',
|
||||
'glm-4.7',
|
||||
'kimi-k2.5',
|
||||
'MiniMax-M2.5',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Alibaba Cloud (Coding Plan)',
|
||||
value: 'alibaba-coding-plan',
|
||||
// NOTE: This is the international (intl) DashScope endpoint, matching upstream
|
||||
// hermes-agent (auth.py:255). Mainland China DashScope accounts (sk-sp-* keys
|
||||
// issued by dashscope.aliyun.com) must override via ALIBABA_CODING_PLAN_BASE_URL=
|
||||
// https://coding.dashscope.aliyuncs.com/v1 (no -intl), since the -intl endpoint
|
||||
// returns HTTP 401 for those keys.
|
||||
base_url: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
models: [
|
||||
'qwen3.5-plus',
|
||||
'qwen3-max-2026-01-23',
|
||||
'qwen3-coder-next',
|
||||
'qwen3-coder-plus',
|
||||
'glm-5',
|
||||
'glm-4.7',
|
||||
'kimi-k2.5',
|
||||
'MiniMax-M2.5',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Hugging Face',
|
||||
value: 'huggingface',
|
||||
base_url: 'https://router.huggingface.co/v1',
|
||||
models: [
|
||||
'Qwen/Qwen3.5-397B-A17B',
|
||||
'Qwen/Qwen3.5-35B-A3B',
|
||||
'deepseek-ai/DeepSeek-V3.2',
|
||||
'moonshotai/Kimi-K2.5',
|
||||
'MiniMaxAI/MiniMax-M2.5',
|
||||
'zai-org/GLM-5',
|
||||
'XiaomiMiMo/MiMo-V2-Flash',
|
||||
'moonshotai/Kimi-K2-Thinking',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Xiaomi MiMo',
|
||||
value: 'xiaomi',
|
||||
base_url: 'https://api.xiaomimimo.com/v1',
|
||||
models: ['mimo-v2-pro', 'mimo-v2-omni', 'mimo-v2-flash'],
|
||||
},
|
||||
{
|
||||
label: 'Kilo Code',
|
||||
value: 'kilocode',
|
||||
base_url: 'https://api.kilo.ai/api/gateway',
|
||||
models: [
|
||||
'anthropic/claude-opus-4.6',
|
||||
'anthropic/claude-sonnet-4.6',
|
||||
'openai/gpt-5.4',
|
||||
'google/gemini-3-pro-preview',
|
||||
'google/gemini-3-flash-preview',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Vercel AI Gateway',
|
||||
value: 'ai-gateway',
|
||||
base_url: 'https://ai-gateway.vercel.sh/v1',
|
||||
models: [
|
||||
'anthropic/claude-opus-4.6',
|
||||
'anthropic/claude-sonnet-4.6',
|
||||
'anthropic/claude-sonnet-4.5',
|
||||
'anthropic/claude-haiku-4.5',
|
||||
'openai/gpt-5',
|
||||
'openai/gpt-4.1',
|
||||
'openai/gpt-4.1-mini',
|
||||
'google/gemini-3-pro-preview',
|
||||
'google/gemini-3-flash',
|
||||
'google/gemini-2.5-pro',
|
||||
'google/gemini-2.5-flash',
|
||||
'deepseek/deepseek-v3.2',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'CLIProxyAPI',
|
||||
value: 'cliproxyapi',
|
||||
base_url: 'http://127.0.0.1:8317/v1',
|
||||
models: [
|
||||
'gpt-5.5',
|
||||
'gpt-5-codex',
|
||||
'claude-sonnet-4-6',
|
||||
'claude-sonnet-4-5-20250929',
|
||||
'gemini-3.1-pro-preview',
|
||||
'gemini-2.5-pro',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'OpenCode Zen',
|
||||
value: 'opencode-zen',
|
||||
base_url: 'https://opencode.ai/zen/v1',
|
||||
models: [
|
||||
'gpt-5.4-pro',
|
||||
'gpt-5.4',
|
||||
'gpt-5.3-codex',
|
||||
'gpt-5.3-codex-spark',
|
||||
'gpt-5.2',
|
||||
'gpt-5.2-codex',
|
||||
'gpt-5.1',
|
||||
'gpt-5.1-codex',
|
||||
'gpt-5.1-codex-max',
|
||||
'gpt-5.1-codex-mini',
|
||||
'gpt-5',
|
||||
'gpt-5-codex',
|
||||
'gpt-5-nano',
|
||||
'claude-opus-4-6',
|
||||
'claude-opus-4-5',
|
||||
'claude-opus-4-1',
|
||||
'claude-sonnet-4-6',
|
||||
'claude-sonnet-4-5',
|
||||
'claude-sonnet-4',
|
||||
'claude-haiku-4-5',
|
||||
'claude-3-5-haiku',
|
||||
'gemini-3.1-pro',
|
||||
'gemini-3-pro',
|
||||
'gemini-3-flash',
|
||||
'minimax-m2.7',
|
||||
'minimax-m2.5',
|
||||
'minimax-m2.5-free',
|
||||
'minimax-m2.1',
|
||||
'glm-5',
|
||||
'glm-4.7',
|
||||
'glm-4.6',
|
||||
'kimi-k2.5',
|
||||
'kimi-k2-thinking',
|
||||
'kimi-k2',
|
||||
'qwen3-coder',
|
||||
'big-pickle',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'OpenCode Go',
|
||||
value: 'opencode-go',
|
||||
base_url: 'https://opencode.ai/zen/go/v1',
|
||||
models: ['glm-5.1', 'glm-5', 'kimi-k2.5', 'mimo-v2-pro', 'mimo-v2-omni', 'minimax-m2.7', 'minimax-m2.5'],
|
||||
},
|
||||
{
|
||||
label: 'OpenAI Codex',
|
||||
value: 'openai-codex',
|
||||
base_url: 'https://chatgpt.com/backend-api/codex',
|
||||
models: ['gpt-5.5', 'gpt-5.4-mini', 'gpt-5.4', 'gpt-5.3-codex', 'gpt-5.2-codex', 'gpt-5.1-codex-max', 'gpt-5.1-codex-mini'],
|
||||
},
|
||||
{
|
||||
label: 'Arcee AI',
|
||||
value: 'arcee',
|
||||
base_url: 'https://api.arcee.ai/v1',
|
||||
models: ['trinity-large-thinking', 'trinity-large-preview', 'trinity-mini'],
|
||||
},
|
||||
{
|
||||
label: 'OpenRouter',
|
||||
value: 'openrouter',
|
||||
base_url: 'https://openrouter.ai/api/v1',
|
||||
models: [],
|
||||
},
|
||||
{
|
||||
label: 'GitHub Copilot',
|
||||
value: 'copilot',
|
||||
base_url: 'https://api.githubcopilot.com',
|
||||
models: [
|
||||
'gpt-5.4',
|
||||
'gpt-5.4-mini',
|
||||
'gpt-5-mini',
|
||||
'gpt-5.3-codex',
|
||||
'gpt-5.2-codex',
|
||||
'gpt-4.1',
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
'claude-sonnet-4.6',
|
||||
'claude-sonnet-4',
|
||||
'claude-sonnet-4.5',
|
||||
'claude-haiku-4.5',
|
||||
'gemini-3.1-pro-preview',
|
||||
'gemini-3-pro-preview',
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-2.5-pro',
|
||||
'grok-code-fast-1',
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** Build a Record<providerKey, models[]> for backend lookup */
|
||||
export function buildProviderModelMap(): Record<string, string[]> {
|
||||
const map: Record<string, string[]> = {}
|
||||
for (const p of PROVIDER_PRESETS) {
|
||||
if (p.models.length > 0) {
|
||||
map[p.value] = p.models
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, type RunEvent, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat'
|
||||
import { startRunViaSocket, resumeSession, registerSessionHandlers, unregisterSessionHandlers, getChatRunSocket, respondToolApproval, type RunEvent, type ContentBlock as ContentBlockImport } from '@/api/hermes/chat'
|
||||
import { deleteSession as deleteSessionApi, fetchSession, fetchSessions, type HermesMessage, type SessionSummary } from '@/api/hermes/sessions'
|
||||
import { getApiKey } from '@/api/client'
|
||||
import { defineStore } from 'pinia'
|
||||
@@ -43,6 +43,16 @@ export interface Message {
|
||||
queued?: boolean
|
||||
}
|
||||
|
||||
export interface PendingApproval {
|
||||
sessionId: string
|
||||
approvalId: string
|
||||
command: string
|
||||
description: string
|
||||
choices: Array<'once' | 'session' | 'always' | 'deny'>
|
||||
allowPermanent: boolean
|
||||
requestedAt: number
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string
|
||||
title: string
|
||||
@@ -320,6 +330,11 @@ export const useChatStore = defineStore('chat', () => {
|
||||
const queueLengths = ref<Map<string, number>>(new Map())
|
||||
/** sessionId → queued user messages not yet visible in the transcript */
|
||||
const queuedUserMessages = ref<Map<string, Message[]>>(new Map())
|
||||
const pendingApprovals = ref<Map<string, PendingApproval>>(new Map())
|
||||
const activePendingApproval = computed(() => {
|
||||
const sid = activeSessionId.value
|
||||
return sid ? pendingApprovals.value.get(sid) || null : null
|
||||
})
|
||||
|
||||
// 自动播放语音开关
|
||||
const autoPlaySpeechEnabled = ref(false)
|
||||
@@ -432,6 +447,30 @@ export const useChatStore = defineStore('chat', () => {
|
||||
return session
|
||||
}
|
||||
|
||||
function newCliSession(): Session {
|
||||
const now = new Date()
|
||||
const ts = [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, '0'),
|
||||
String(now.getDate()).padStart(2, '0'),
|
||||
'_',
|
||||
String(now.getHours()).padStart(2, '0'),
|
||||
String(now.getMinutes()).padStart(2, '0'),
|
||||
String(now.getSeconds()).padStart(2, '0'),
|
||||
].join('')
|
||||
const hex = Math.random().toString(16).slice(2, 8)
|
||||
const session: Session = {
|
||||
id: `${ts}_${hex}`,
|
||||
title: '',
|
||||
source: 'cli',
|
||||
messages: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
sessions.value.unshift(session)
|
||||
return session
|
||||
}
|
||||
|
||||
async function switchSession(sessionId: string, focusId?: string | null) {
|
||||
clearThinkingObservationFor(sessionId)
|
||||
activeSessionId.value = sessionId
|
||||
@@ -503,6 +542,49 @@ export const useChatStore = defineStore('chat', () => {
|
||||
setAbortState({ aborting: true, synced: null })
|
||||
} else if (e.event === 'abort.completed') {
|
||||
setAbortState({ aborting: false, synced: e.synced ?? false })
|
||||
} else if (e.event === 'approval.requested') {
|
||||
setPendingApproval({ ...e, session_id: sessionId } as RunEvent)
|
||||
} else if (e.event === 'approval.resolved') {
|
||||
clearPendingApproval({ ...e, session_id: sessionId } as RunEvent)
|
||||
} else if (e.event === 'tool.started') {
|
||||
const msgs = getSessionMsgs(sessionId)
|
||||
const toolCallId = e.tool_call_id as string | undefined
|
||||
const existingTool = toolCallId
|
||||
? msgs.find(m => m.role === 'tool' && m.toolCallId === toolCallId)
|
||||
: null
|
||||
if (existingTool) {
|
||||
updateMessage(sessionId, existingTool.id, {
|
||||
toolName: e.tool || e.name,
|
||||
toolArgs: typeof e.arguments === 'string' ? e.arguments : existingTool.toolArgs,
|
||||
toolPreview: e.preview || existingTool.toolPreview,
|
||||
toolStatus: existingTool.toolStatus || 'running',
|
||||
})
|
||||
} else {
|
||||
addMessage(sessionId, {
|
||||
id: uid(),
|
||||
role: 'tool',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
toolName: e.tool || e.name,
|
||||
toolCallId,
|
||||
toolPreview: e.preview,
|
||||
toolArgs: typeof e.arguments === 'string' ? e.arguments : undefined,
|
||||
toolStatus: 'running',
|
||||
})
|
||||
}
|
||||
} else if (e.event === 'tool.completed') {
|
||||
const msgs = getSessionMsgs(sessionId)
|
||||
const toolCallId = e.tool_call_id as string | undefined
|
||||
const toolMsgs = toolCallId
|
||||
? msgs.filter(m => m.role === 'tool' && m.toolCallId === toolCallId)
|
||||
: msgs.filter(m => m.role === 'tool' && m.toolStatus === 'running')
|
||||
if (toolMsgs.length > 0) {
|
||||
updateMessage(sessionId, toolMsgs[toolMsgs.length - 1].id, {
|
||||
toolStatus: e.error === true ? 'error' : 'done',
|
||||
toolDuration: e.duration,
|
||||
toolResult: typeof e.output === 'string' ? e.output : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -603,6 +685,45 @@ export const useChatStore = defineStore('chat', () => {
|
||||
})
|
||||
}
|
||||
|
||||
function setPendingApproval(evt: RunEvent) {
|
||||
const sid = evt.session_id
|
||||
const approvalId = (evt as any).approval_id as string | undefined
|
||||
if (!sid || !approvalId) return
|
||||
const rawChoices = Array.isArray((evt as any).choices) ? (evt as any).choices : ['once', 'session', 'deny']
|
||||
const choices = rawChoices
|
||||
.filter((choice: unknown): choice is PendingApproval['choices'][number] =>
|
||||
choice === 'once' || choice === 'session' || choice === 'always' || choice === 'deny')
|
||||
pendingApprovals.value.set(sid, {
|
||||
sessionId: sid,
|
||||
approvalId,
|
||||
command: String((evt as any).command || ''),
|
||||
description: String((evt as any).description || ''),
|
||||
choices: choices.length ? choices : ['once', 'session', 'deny'],
|
||||
allowPermanent: Boolean((evt as any).allow_permanent),
|
||||
requestedAt: Date.now(),
|
||||
})
|
||||
pendingApprovals.value = new Map(pendingApprovals.value)
|
||||
}
|
||||
|
||||
function clearPendingApproval(evt: RunEvent) {
|
||||
const sid = evt.session_id
|
||||
if (!sid) return
|
||||
const current = pendingApprovals.value.get(sid)
|
||||
if (!current) return
|
||||
const approvalId = (evt as any).approval_id
|
||||
if (approvalId && current.approvalId !== approvalId) return
|
||||
pendingApprovals.value.delete(sid)
|
||||
pendingApprovals.value = new Map(pendingApprovals.value)
|
||||
}
|
||||
|
||||
function respondApproval(choice: PendingApproval['choices'][number]) {
|
||||
const pending = activePendingApproval.value
|
||||
if (!pending) return
|
||||
respondToolApproval(pending.sessionId, pending.approvalId, choice)
|
||||
pendingApprovals.value.delete(pending.sessionId)
|
||||
pendingApprovals.value = new Map(pendingApprovals.value)
|
||||
}
|
||||
|
||||
function showNextQueuedUserMessage(sessionId: string) {
|
||||
const queue = queuedUserMessages.value.get(sessionId)
|
||||
if (!queue?.length) return
|
||||
@@ -715,6 +836,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
session_id: sid,
|
||||
model: sessionModel || undefined,
|
||||
queue_id: userMsg.id,
|
||||
source: (activeSession.value?.source === 'cli' ? 'cli' : 'api_server') as 'cli' | 'api_server',
|
||||
}
|
||||
|
||||
if (shouldQueue) {
|
||||
@@ -967,6 +1089,16 @@ export const useChatStore = defineStore('chat', () => {
|
||||
break
|
||||
}
|
||||
|
||||
case 'approval.requested': {
|
||||
setPendingApproval(evt)
|
||||
break
|
||||
}
|
||||
|
||||
case 'approval.resolved': {
|
||||
clearPendingApproval(evt)
|
||||
break
|
||||
}
|
||||
|
||||
case 'run.completed': {
|
||||
const msgs = getSessionMsgs(sid)
|
||||
const lastMsg = activeAssistantMessageId
|
||||
@@ -1394,6 +1526,16 @@ export const useChatStore = defineStore('chat', () => {
|
||||
break
|
||||
}
|
||||
|
||||
case 'approval.requested': {
|
||||
setPendingApproval(evt)
|
||||
break
|
||||
}
|
||||
|
||||
case 'approval.resolved': {
|
||||
clearPendingApproval(evt)
|
||||
break
|
||||
}
|
||||
|
||||
case 'run.completed': {
|
||||
const hasQueue = (evt as any).queue_remaining > 0
|
||||
if (hasQueue) {
|
||||
@@ -1689,12 +1831,15 @@ export const useChatStore = defineStore('chat', () => {
|
||||
isAborting,
|
||||
queueLengths,
|
||||
queuedUserMessages,
|
||||
pendingApprovals,
|
||||
activePendingApproval,
|
||||
removeQueuedMessage,
|
||||
isLoadingSessions,
|
||||
sessionsLoaded,
|
||||
isLoadingMessages,
|
||||
|
||||
newChat,
|
||||
newCliSession,
|
||||
switchSession,
|
||||
switchSessionModel,
|
||||
addOrUpdateSession,
|
||||
@@ -1702,6 +1847,7 @@ export const useChatStore = defineStore('chat', () => {
|
||||
deleteSession,
|
||||
sendMessage,
|
||||
stopStreaming,
|
||||
respondApproval,
|
||||
loadSessions,
|
||||
refreshActiveSession,
|
||||
getThinkingObservation,
|
||||
|
||||
@@ -240,12 +240,12 @@ watch(hermesSessionsLoaded, (loaded) => {
|
||||
if (loaded && hermesSessions.value.length > 0) {
|
||||
// Only auto-load if no session is currently active
|
||||
if (!historySessionId.value || !hermesSessions.value.find(s => s.id === historySessionId.value)) {
|
||||
// Find first CLI session
|
||||
// Find first CLI session.
|
||||
const firstCliSession = hermesSessions.value.find(s => s.source === 'cli')
|
||||
if (firstCliSession) {
|
||||
// Ensure the CLI group is expanded
|
||||
if (collapsedGroups.value.has('cli')) {
|
||||
collapsedGroups.value = new Set([...collapsedGroups.value].filter(s => s !== 'cli'))
|
||||
if (collapsedGroups.value.has(firstCliSession.source)) {
|
||||
collapsedGroups.value = new Set([...collapsedGroups.value].filter(s => s !== firstCliSession.source))
|
||||
}
|
||||
// Load session details
|
||||
handleSessionClick(firstCliSession.id)
|
||||
|
||||
@@ -7,6 +7,26 @@ import { SessionDeleter } from '../../services/hermes/session-deleter'
|
||||
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
|
||||
import { logger } from '../../services/logger'
|
||||
import { smartCloneCleanup } from '../../services/hermes/profile-credentials'
|
||||
import { detectHermesHome } from '../../services/hermes/hermes-path'
|
||||
|
||||
function profileExistsForManualSwitch(name: string): boolean {
|
||||
const base = detectHermesHome()
|
||||
if (!name || name === 'default') return true
|
||||
return existsSync(join(base, 'profiles', name, 'config.yaml')) || existsSync(join(base, 'profiles', name))
|
||||
}
|
||||
|
||||
async function useProfileWithFallback(name: string): Promise<string> {
|
||||
try {
|
||||
return await hermesCli.useProfile(name)
|
||||
} catch (err: any) {
|
||||
if (!profileExistsForManualSwitch(name)) throw err
|
||||
|
||||
const base = detectHermesHome()
|
||||
writeFileSync(join(base, 'active_profile'), `${name}\n`, 'utf-8')
|
||||
logger.warn(err, '[switchProfile] hermes profile use failed; wrote active_profile directly for existing profile "%s"', name)
|
||||
return `Switched to profile ${name}`
|
||||
}
|
||||
}
|
||||
|
||||
export async function list(ctx: any) {
|
||||
try {
|
||||
@@ -159,7 +179,7 @@ export async function switchProfile(ctx: any) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const output = await hermesCli.useProfile(name)
|
||||
const output = await useProfileWithFallback(name)
|
||||
|
||||
// Verify the active_profile file immediately (Hermes CLI writes synchronously)
|
||||
// Quick verification with 2 retries to handle edge cases (filesystem delays, concurrency)
|
||||
@@ -185,6 +205,16 @@ export async function switchProfile(ctx: any) {
|
||||
const mgr = getGatewayManagerInstance()
|
||||
if (mgr) { mgr.setActiveProfile(name) }
|
||||
|
||||
// Destroy all bridge sessions so they get recreated with the new profile config
|
||||
try {
|
||||
const { AgentBridgeClient } = await import('../../services/hermes/agent-bridge')
|
||||
const bridge = new AgentBridgeClient()
|
||||
await bridge.destroyAll()
|
||||
logger.info('[switchProfile] destroyed all bridge sessions for profile "%s"', name)
|
||||
} catch (err: any) {
|
||||
logger.warn(err, '[switchProfile] failed to destroy bridge sessions')
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await hermesCli.getProfile(name)
|
||||
logger.debug('Profile detail.path = %s', detail.path)
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import * as hermesCli from '../../services/hermes/hermes-cli'
|
||||
import { listConversationSummaries, getConversationDetail } from '../../services/hermes/conversations'
|
||||
import { listConversationSummariesFromDb, getConversationDetailFromDb } from '../../db/hermes/conversations-db'
|
||||
import { listSessionSummaries, searchSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb } from '../../db/hermes/sessions-db'
|
||||
import { listSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb } from '../../db/hermes/sessions-db'
|
||||
import {
|
||||
listSessions as localListSessions,
|
||||
searchSessions as localSearchSessions,
|
||||
getSessionDetail as localGetSessionDetail,
|
||||
deleteSession as localDeleteSession,
|
||||
renameSession as localRenameSession,
|
||||
useLocalSessionStore,
|
||||
} from '../../db/hermes/session-store'
|
||||
import { ExportCompressor } from '../../lib/context-compressor/export-compressor'
|
||||
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
|
||||
import { deleteUsage, getUsage, getUsageBatch, getLocalUsageStats } from '../../db/hermes/usage-store'
|
||||
import type { LocalUsageStats, UsageStatsModelRow, UsageStatsDailyRow } from '../../db/hermes/usage-store'
|
||||
import { deleteUsage, getUsage, getUsageBatch } from '../../db/hermes/usage-store'
|
||||
import type { UsageStatsModelRow, UsageStatsDailyRow } from '../../db/hermes/usage-store'
|
||||
import { getModelContextLength } from '../../services/hermes/model-context'
|
||||
import { getActiveProfileName } from '../../services/hermes/hermes-profile'
|
||||
import { getGroupChatServer } from '../../routes/hermes/group-chat'
|
||||
@@ -36,130 +33,75 @@ function filterPendingDeletedConversationSummaries(items: ConversationSummary[])
|
||||
|
||||
export async function listConversations(ctx: any) {
|
||||
const source = (ctx.query.source as string) || undefined
|
||||
const humanOnly = (ctx.query.humanOnly as string) !== 'false' && ctx.query.humanOnly !== '0'
|
||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
||||
|
||||
if (useLocalSessionStore()) {
|
||||
const profile = getActiveProfileName()
|
||||
const sessions = localListSessions(profile, source, limit && limit > 0 ? limit : 200)
|
||||
const summaries: ConversationSummary[] = sessions.map(s => ({
|
||||
id: s.id,
|
||||
source: s.source,
|
||||
model: s.model,
|
||||
title: s.title,
|
||||
started_at: s.started_at,
|
||||
ended_at: s.ended_at,
|
||||
last_active: s.last_active,
|
||||
message_count: s.message_count,
|
||||
tool_call_count: s.tool_call_count,
|
||||
input_tokens: s.input_tokens,
|
||||
output_tokens: s.output_tokens,
|
||||
cache_read_tokens: s.cache_read_tokens,
|
||||
cache_write_tokens: s.cache_write_tokens,
|
||||
reasoning_tokens: s.reasoning_tokens,
|
||||
billing_provider: s.billing_provider,
|
||||
estimated_cost_usd: s.estimated_cost_usd,
|
||||
actual_cost_usd: s.actual_cost_usd,
|
||||
cost_status: s.cost_status,
|
||||
preview: s.preview,
|
||||
workspace: s.workspace || null,
|
||||
is_active: s.ended_at == null && (Date.now() / 1000 - s.last_active) <= 300,
|
||||
thread_session_count: 1,
|
||||
}))
|
||||
ctx.body = { sessions: filterPendingDeletedConversationSummaries(summaries) }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const sessions = await listConversationSummariesFromDb({ source, humanOnly, limit })
|
||||
ctx.body = { sessions: filterPendingDeletedConversationSummaries(sessions) }
|
||||
return
|
||||
} catch (err) {
|
||||
logger.warn(err, 'Hermes Conversation DB: summary query failed, falling back to CLI export')
|
||||
}
|
||||
|
||||
const sessions = await listConversationSummaries({ source, humanOnly, limit })
|
||||
ctx.body = { sessions: filterPendingDeletedConversationSummaries(sessions) }
|
||||
const profile = getActiveProfileName()
|
||||
const sessions = localListSessions(profile, source, limit && limit > 0 ? limit : 200)
|
||||
const summaries: ConversationSummary[] = sessions.map(s => ({
|
||||
id: s.id,
|
||||
source: s.source,
|
||||
model: s.model,
|
||||
title: s.title,
|
||||
started_at: s.started_at,
|
||||
ended_at: s.ended_at,
|
||||
last_active: s.last_active,
|
||||
message_count: s.message_count,
|
||||
tool_call_count: s.tool_call_count,
|
||||
input_tokens: s.input_tokens,
|
||||
output_tokens: s.output_tokens,
|
||||
cache_read_tokens: s.cache_read_tokens,
|
||||
cache_write_tokens: s.cache_write_tokens,
|
||||
reasoning_tokens: s.reasoning_tokens,
|
||||
billing_provider: s.billing_provider,
|
||||
estimated_cost_usd: s.estimated_cost_usd,
|
||||
actual_cost_usd: s.actual_cost_usd,
|
||||
cost_status: s.cost_status,
|
||||
preview: s.preview,
|
||||
workspace: s.workspace || null,
|
||||
is_active: s.ended_at == null && (Date.now() / 1000 - s.last_active) <= 300,
|
||||
thread_session_count: 1,
|
||||
}))
|
||||
ctx.body = { sessions: filterPendingDeletedConversationSummaries(summaries) }
|
||||
}
|
||||
|
||||
export async function getConversationMessages(ctx: any) {
|
||||
const source = (ctx.query.source as string) || undefined
|
||||
const humanOnly = (ctx.query.humanOnly as string) !== 'false' && ctx.query.humanOnly !== '0'
|
||||
|
||||
if (useLocalSessionStore()) {
|
||||
const detail = localGetSessionDetail(ctx.params.id)
|
||||
if (!detail) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'Conversation not found' }
|
||||
return
|
||||
}
|
||||
const messages = detail.messages
|
||||
.filter(m => {
|
||||
if (humanOnly && m.role !== 'user' && m.role !== 'assistant') return false
|
||||
if (!m.content) return false
|
||||
return true
|
||||
})
|
||||
.map(m => ({
|
||||
id: m.id,
|
||||
session_id: m.session_id,
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content,
|
||||
timestamp: m.timestamp,
|
||||
}))
|
||||
ctx.body = {
|
||||
session_id: ctx.params.id,
|
||||
messages,
|
||||
visible_count: messages.length,
|
||||
thread_session_count: 1,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const detail = await getConversationDetailFromDb(ctx.params.id, { source, humanOnly })
|
||||
if (!detail) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'Conversation not found' }
|
||||
return
|
||||
}
|
||||
ctx.body = detail
|
||||
return
|
||||
} catch (err) {
|
||||
logger.warn(err, 'Hermes Conversation DB: detail query failed, falling back to CLI export')
|
||||
}
|
||||
|
||||
const detail = await getConversationDetail(ctx.params.id, { source, humanOnly })
|
||||
const detail = localGetSessionDetail(ctx.params.id)
|
||||
if (!detail) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'Conversation not found' }
|
||||
return
|
||||
}
|
||||
ctx.body = detail
|
||||
const messages = detail.messages
|
||||
.filter(m => {
|
||||
if (humanOnly && m.role !== 'user' && m.role !== 'assistant') return false
|
||||
if (!m.content) return false
|
||||
return true
|
||||
})
|
||||
.map(m => ({
|
||||
id: m.id,
|
||||
session_id: m.session_id,
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content,
|
||||
timestamp: m.timestamp,
|
||||
}))
|
||||
ctx.body = {
|
||||
session_id: ctx.params.id,
|
||||
messages,
|
||||
visible_count: messages.length,
|
||||
thread_session_count: 1,
|
||||
}
|
||||
}
|
||||
|
||||
export async function list(ctx: any) {
|
||||
if (useLocalSessionStore()) {
|
||||
const source = (ctx.query.source as string) || undefined
|
||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
||||
const profile = getActiveProfileName()
|
||||
const sessions = localListSessions(profile, source, limit && limit > 0 ? limit : 2000)
|
||||
ctx.body = { sessions: filterPendingDeletedSessions(sessions) }
|
||||
return
|
||||
}
|
||||
|
||||
const source = (ctx.query.source as string) || undefined
|
||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
||||
const profile = getActiveProfileName()
|
||||
const effectiveLimit = limit && limit > 0 ? limit : 2000
|
||||
|
||||
try {
|
||||
const sessions = await listSessionSummaries(source, limit && limit > 0 ? limit : 2000)
|
||||
ctx.body = { sessions: filterPendingDeletedSessions(sessions) }
|
||||
return
|
||||
} catch (err) {
|
||||
logger.warn(err, 'Hermes Session DB: summary query failed, falling back to CLI')
|
||||
}
|
||||
|
||||
const sessions = await hermesCli.listSessions(source, limit)
|
||||
ctx.body = { sessions: filterPendingDeletedSessions(sessions) }
|
||||
const allSessions = localListSessions(profile, source, effectiveLimit)
|
||||
ctx.body = { sessions: filterPendingDeletedSessions(allSessions.filter(s => s.source === 'api_server' || s.source === 'cli')) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -169,58 +111,23 @@ export async function list(ctx: any) {
|
||||
export async function listHermesSessions(ctx: any) {
|
||||
const source = (ctx.query.source as string) || undefined
|
||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
||||
const profile = getActiveProfileName()
|
||||
const effectiveLimit = limit && limit > 0 ? limit : 2000
|
||||
|
||||
try {
|
||||
const sessions = await listSessionSummaries(source, limit && limit > 0 ? limit : 2000)
|
||||
ctx.body = { sessions: filterPendingDeletedSessions(sessions.filter(s => s.source !== 'api_server')) }
|
||||
return
|
||||
} catch (err) {
|
||||
logger.warn(err, 'Hermes Session DB: summary query failed, falling back to CLI')
|
||||
}
|
||||
|
||||
const sessions = await hermesCli.listSessions(source, limit)
|
||||
ctx.body = { sessions: filterPendingDeletedSessions(sessions.filter(s => s.source !== 'api_server')) }
|
||||
const allSessions = localListSessions(profile, source, effectiveLimit)
|
||||
ctx.body = { sessions: filterPendingDeletedSessions(allSessions.filter(s => s.source !== 'api_server')) }
|
||||
}
|
||||
|
||||
export async function search(ctx: any) {
|
||||
if (useLocalSessionStore()) {
|
||||
const q = typeof ctx.query.q === 'string' ? ctx.query.q : ''
|
||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
||||
const profile = getActiveProfileName()
|
||||
const results = localSearchSessions(profile, q, limit && limit > 0 ? limit : 20)
|
||||
ctx.body = { results: filterPendingDeletedSessions(results) }
|
||||
return
|
||||
}
|
||||
|
||||
const q = typeof ctx.query.q === 'string' ? ctx.query.q : ''
|
||||
const source = typeof ctx.query.source === 'string' && ctx.query.source.trim()
|
||||
? ctx.query.source.trim()
|
||||
: undefined
|
||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
|
||||
|
||||
try {
|
||||
const results = await searchSessionSummaries(q, source, limit && limit > 0 ? limit : 20)
|
||||
ctx.body = { results: filterPendingDeletedSessions(results) }
|
||||
} catch (err) {
|
||||
logger.error(err, 'Hermes Session DB: search failed')
|
||||
ctx.status = 500
|
||||
ctx.body = { error: 'Failed to search sessions' }
|
||||
}
|
||||
const profile = getActiveProfileName()
|
||||
const results = localSearchSessions(profile, q, limit && limit > 0 ? limit : 20)
|
||||
ctx.body = { results: filterPendingDeletedSessions(results) }
|
||||
}
|
||||
|
||||
export async function get(ctx: any) {
|
||||
if (useLocalSessionStore()) {
|
||||
const session = localGetSessionDetail(ctx.params.id)
|
||||
if (!session) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'Session not found' }
|
||||
return
|
||||
}
|
||||
ctx.body = { session }
|
||||
return
|
||||
}
|
||||
|
||||
const session = await hermesCli.getSession(ctx.params.id)
|
||||
const session = localGetSessionDetail(ctx.params.id)
|
||||
if (!session) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'Session not found' }
|
||||
@@ -262,21 +169,8 @@ export async function getHermesSession(ctx: any) {
|
||||
}
|
||||
|
||||
export async function remove(ctx: any) {
|
||||
if (useLocalSessionStore()) {
|
||||
const sessionId = ctx.params.id
|
||||
const ok = localDeleteSession(sessionId)
|
||||
if (!ok) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: 'Failed to delete session' }
|
||||
return
|
||||
}
|
||||
deleteUsage(sessionId)
|
||||
ctx.body = { ok: true }
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = ctx.params.id
|
||||
const ok = await hermesCli.deleteSession(sessionId)
|
||||
const ok = localDeleteSession(sessionId)
|
||||
if (!ok) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: 'Failed to delete session' }
|
||||
@@ -307,27 +201,14 @@ export async function batchRemove(ctx: any) {
|
||||
errors: [] as Array<{ id: string; error: string }>
|
||||
}
|
||||
|
||||
if (useLocalSessionStore()) {
|
||||
for (const id of validIds) {
|
||||
const ok = localDeleteSession(id)
|
||||
if (ok) {
|
||||
deleteUsage(id)
|
||||
results.deleted++
|
||||
} else {
|
||||
results.failed++
|
||||
results.errors.push({ id, error: 'Failed to delete session' })
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const id of validIds) {
|
||||
const ok = await hermesCli.deleteSession(id)
|
||||
if (ok) {
|
||||
deleteUsage(id)
|
||||
results.deleted++
|
||||
} else {
|
||||
results.failed++
|
||||
results.errors.push({ id, error: 'Failed to delete session' })
|
||||
}
|
||||
for (const id of validIds) {
|
||||
const ok = localDeleteSession(id)
|
||||
if (ok) {
|
||||
deleteUsage(id)
|
||||
results.deleted++
|
||||
} else {
|
||||
results.failed++
|
||||
results.errors.push({ id, error: 'Failed to delete session' })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,30 +235,13 @@ export async function usageSingle(ctx: any) {
|
||||
}
|
||||
|
||||
export async function rename(ctx: any) {
|
||||
if (useLocalSessionStore()) {
|
||||
const { title } = ctx.request.body as { title?: string }
|
||||
if (!title || typeof title !== 'string') {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'title is required' }
|
||||
return
|
||||
}
|
||||
const ok = localRenameSession(ctx.params.id, title.trim())
|
||||
if (!ok) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: 'Failed to rename session' }
|
||||
return
|
||||
}
|
||||
ctx.body = { ok: true }
|
||||
return
|
||||
}
|
||||
|
||||
const { title } = ctx.request.body as { title?: string }
|
||||
if (!title || typeof title !== 'string') {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'title is required' }
|
||||
return
|
||||
}
|
||||
const ok = await hermesCli.renameSession(ctx.params.id, title.trim())
|
||||
const ok = localRenameSession(ctx.params.id, title.trim())
|
||||
if (!ok) {
|
||||
ctx.status = 500
|
||||
ctx.body = { error: 'Failed to rename session' }
|
||||
@@ -393,20 +257,14 @@ export async function setWorkspace(ctx: any) {
|
||||
ctx.body = { error: 'workspace must be a string or null' }
|
||||
return
|
||||
}
|
||||
if (useLocalSessionStore()) {
|
||||
const { updateSession, getSession, createSession } = await import('../../db/hermes/session-store')
|
||||
const { getActiveProfileName } = await import('../../services/hermes/hermes-profile')
|
||||
const id = ctx.params.id
|
||||
// Create session if it doesn't exist yet (user may set workspace before sending first message)
|
||||
if (!getSession(id)) {
|
||||
createSession({ id, profile: getActiveProfileName(), title: '' })
|
||||
}
|
||||
updateSession(id, { workspace: workspace || null } as any)
|
||||
ctx.body = { ok: true }
|
||||
return
|
||||
const { updateSession, getSession, createSession } = await import('../../db/hermes/session-store')
|
||||
const { getActiveProfileName } = await import('../../services/hermes/hermes-profile')
|
||||
const id = ctx.params.id
|
||||
if (!getSession(id)) {
|
||||
createSession({ id, profile: getActiveProfileName(), title: '' })
|
||||
}
|
||||
ctx.status = 501
|
||||
ctx.body = { error: 'Workspace setting only supported in local session store mode' }
|
||||
updateSession(id, { workspace: workspace || null } as any)
|
||||
ctx.body = { ok: true }
|
||||
}
|
||||
|
||||
export async function contextLength(ctx: any) {
|
||||
@@ -418,11 +276,6 @@ export async function usageStats(ctx: any) {
|
||||
const rawDays = parseInt(String(ctx.query?.days ?? '30'), 10)
|
||||
const days = Number.isFinite(rawDays) && rawDays > 0 ? Math.min(rawDays, 365) : 30
|
||||
|
||||
// Local Web UI chat usage is kept in the dashboard DB and must be merged
|
||||
// with Hermes' native state.db analytics for the same period.
|
||||
const currentProfile = getActiveProfileName()
|
||||
const local = getLocalUsageStats(currentProfile, days)
|
||||
|
||||
let hermes = {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
@@ -442,29 +295,6 @@ export async function usageStats(ctx: any) {
|
||||
logger.warn(err, 'usageStats: failed to load Hermes usage analytics from state.db')
|
||||
}
|
||||
|
||||
const totalInput = local.input_tokens + hermes.input_tokens
|
||||
const totalOutput = local.output_tokens + hermes.output_tokens
|
||||
const totalCacheRead = local.cache_read_tokens + hermes.cache_read_tokens
|
||||
const totalCacheWrite = local.cache_write_tokens + hermes.cache_write_tokens
|
||||
const totalReasoning = local.reasoning_tokens + hermes.reasoning_tokens
|
||||
const totalSessions = local.sessions + hermes.sessions
|
||||
|
||||
const modelMap = new Map<string, UsageStatsModelRow>()
|
||||
for (const m of [...local.by_model, ...hermes.by_model]) {
|
||||
const model = (m.model || '').trim() || 'unknown'
|
||||
const existing = modelMap.get(model)
|
||||
if (existing) {
|
||||
existing.input_tokens += m.input_tokens
|
||||
existing.output_tokens += m.output_tokens
|
||||
existing.cache_read_tokens += m.cache_read_tokens
|
||||
existing.cache_write_tokens += m.cache_write_tokens
|
||||
existing.reasoning_tokens += m.reasoning_tokens
|
||||
existing.sessions += m.sessions
|
||||
} else {
|
||||
modelMap.set(model, { ...m, model })
|
||||
}
|
||||
}
|
||||
|
||||
const dayMap = new Map<string, UsageStatsDailyRow>()
|
||||
const now = new Date()
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
@@ -473,7 +303,7 @@ export async function usageStats(ctx: any) {
|
||||
const key = d.toISOString().slice(0, 10)
|
||||
dayMap.set(key, { date: key, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, sessions: 0, errors: 0, cost: 0 })
|
||||
}
|
||||
for (const d of [...local.by_day, ...hermes.by_day]) {
|
||||
for (const d of hermes.by_day) {
|
||||
const existing = dayMap.get(d.date)
|
||||
if (existing) {
|
||||
existing.input_tokens += d.input_tokens; existing.output_tokens += d.output_tokens
|
||||
@@ -483,16 +313,16 @@ export async function usageStats(ctx: any) {
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
total_input_tokens: totalInput,
|
||||
total_output_tokens: totalOutput,
|
||||
total_cache_read_tokens: totalCacheRead,
|
||||
total_cache_write_tokens: totalCacheWrite,
|
||||
total_reasoning_tokens: totalReasoning,
|
||||
total_sessions: totalSessions,
|
||||
total_input_tokens: hermes.input_tokens,
|
||||
total_output_tokens: hermes.output_tokens,
|
||||
total_cache_read_tokens: hermes.cache_read_tokens,
|
||||
total_cache_write_tokens: hermes.cache_write_tokens,
|
||||
total_reasoning_tokens: hermes.reasoning_tokens,
|
||||
total_sessions: hermes.sessions,
|
||||
total_cost: hermes.cost,
|
||||
total_api_calls: hermes.total_api_calls,
|
||||
period_days: days,
|
||||
model_usage: [...modelMap.values()].sort((a, b) => (b.input_tokens + b.output_tokens) - (a.input_tokens + a.output_tokens)),
|
||||
model_usage: hermes.by_model.sort((a, b) => (b.input_tokens + b.output_tokens) - (a.input_tokens + a.output_tokens)),
|
||||
daily_usage: [...dayMap.values()],
|
||||
}
|
||||
}
|
||||
@@ -545,20 +375,7 @@ export async function listWorkspaceFolders(ctx: any) {
|
||||
const exportCompressor = new ExportCompressor()
|
||||
|
||||
export async function exportSession(ctx: any) {
|
||||
let session: any = null
|
||||
|
||||
if (useLocalSessionStore()) {
|
||||
session = localGetSessionDetail(ctx.params.id)
|
||||
} else {
|
||||
try {
|
||||
session = await getSessionDetailFromDb(ctx.params.id)
|
||||
} catch (err) {
|
||||
logger.warn(err, 'Hermes Session DB: export detail query failed, falling back to CLI')
|
||||
}
|
||||
if (!session) {
|
||||
session = await hermesCli.getSession(ctx.params.id)
|
||||
}
|
||||
}
|
||||
const session = localGetSessionDetail(ctx.params.id)
|
||||
|
||||
if (!session) {
|
||||
ctx.status = 404
|
||||
@@ -630,38 +447,32 @@ export async function getConversationMessagesPaginated(ctx: any) {
|
||||
const offset = ctx.query.offset ? parseInt(ctx.query.offset as string, 10) : 0
|
||||
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : 50
|
||||
|
||||
if (useLocalSessionStore()) {
|
||||
const { getSessionDetailPaginated } = await import('../../db/hermes/session-store')
|
||||
const result = getSessionDetailPaginated(ctx.params.id, offset, limit)
|
||||
const { getSessionDetailPaginated } = await import('../../db/hermes/session-store')
|
||||
const result = getSessionDetailPaginated(ctx.params.id, offset, limit)
|
||||
|
||||
if (!result) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'Conversation not found' }
|
||||
return
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
session: {
|
||||
id: result.session.id,
|
||||
source: result.session.source,
|
||||
model: result.session.model,
|
||||
title: result.session.title,
|
||||
started_at: result.session.started_at,
|
||||
ended_at: result.session.ended_at,
|
||||
last_active: result.session.last_active,
|
||||
message_count: result.session.message_count,
|
||||
input_tokens: result.session.input_tokens,
|
||||
output_tokens: result.session.output_tokens,
|
||||
},
|
||||
messages: result.messages,
|
||||
total: result.total,
|
||||
offset: result.offset,
|
||||
limit: result.limit,
|
||||
hasMore: result.hasMore,
|
||||
}
|
||||
if (!result) {
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'Conversation not found' }
|
||||
return
|
||||
}
|
||||
|
||||
ctx.status = 404
|
||||
ctx.body = { error: 'Conversation not found' }
|
||||
ctx.body = {
|
||||
session: {
|
||||
id: result.session.id,
|
||||
source: result.session.source,
|
||||
model: result.session.model,
|
||||
title: result.session.title,
|
||||
started_at: result.session.started_at,
|
||||
ended_at: result.session.ended_at,
|
||||
last_active: result.session.last_active,
|
||||
message_count: result.session.message_count,
|
||||
input_tokens: result.session.input_tokens,
|
||||
output_tokens: result.session.output_tokens,
|
||||
},
|
||||
messages: result.messages,
|
||||
total: result.total,
|
||||
offset: result.offset,
|
||||
limit: result.limit,
|
||||
hasMore: result.hasMore,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,14 +129,16 @@ function mapMessageRow(row: Record<string, unknown>): HermesMessageRow {
|
||||
export function createSession(data: {
|
||||
id: string
|
||||
profile?: string
|
||||
source?: string
|
||||
model?: string
|
||||
title?: string
|
||||
workspace?: string
|
||||
}): HermesSessionRow {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const source = data.source || 'api_server'
|
||||
if (!isSqliteAvailable()) {
|
||||
return {
|
||||
id: data.id, profile: data.profile || 'default', source: 'api_server',
|
||||
id: data.id, profile: data.profile || 'default', source,
|
||||
user_id: null, model: data.model || '', title: data.title || null,
|
||||
started_at: now, ended_at: null, end_reason: null,
|
||||
message_count: 0, tool_call_count: 0,
|
||||
@@ -148,8 +150,8 @@ export function createSession(data: {
|
||||
const db = getDb()!
|
||||
db.prepare(
|
||||
`INSERT INTO ${SESSIONS_TABLE} (id, profile, source, model, title, started_at, last_active, workspace)
|
||||
VALUES (?, ?, 'api_server', ?, ?, ?, ?, ?)`,
|
||||
).run(data.id, data.profile || 'default', data.model || '', data.title || null, now, now, data.workspace || null)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(data.id, data.profile || 'default', source, data.model || '', data.title || null, now, now, data.workspace || null)
|
||||
return getSession(data.id)!
|
||||
}
|
||||
|
||||
|
||||
@@ -565,6 +565,10 @@ function aggregateSessionDetail(
|
||||
}
|
||||
}
|
||||
|
||||
function chainOrderSql(ids: string[]): string {
|
||||
return ids.map((_, index) => `WHEN ? THEN ${index}`).join(' ')
|
||||
}
|
||||
|
||||
async function openSessionDb() {
|
||||
if (!SQLITE_AVAILABLE) {
|
||||
throw new Error(`node:sqlite requires Node >= 22.5, current: ${process.versions.node}`)
|
||||
@@ -598,7 +602,7 @@ export async function getSessionMessagesFromDb(sessionId: string): Promise<{
|
||||
const messageRows = db.prepare(`
|
||||
SELECT * FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp, id
|
||||
ORDER BY id
|
||||
`).all(sessionId) as Record<string, unknown>[]
|
||||
|
||||
return {
|
||||
@@ -622,11 +626,12 @@ export async function getSessionDetailFromDb(sessionId: string): Promise<HermesS
|
||||
|
||||
const ids = chain.map(session => session.id)
|
||||
const placeholders = ids.map(() => '?').join(', ')
|
||||
const orderSql = chainOrderSql(ids)
|
||||
const messageRows = db.prepare(`
|
||||
SELECT * FROM messages
|
||||
WHERE session_id IN (${placeholders})
|
||||
ORDER BY timestamp, id
|
||||
`).all(...ids) as Record<string, unknown>[]
|
||||
ORDER BY CASE session_id ${orderSql} ELSE ${ids.length} END, id
|
||||
`).all(...ids, ...ids) as Record<string, unknown>[]
|
||||
const messages = messageRows.map(mapMessageRow)
|
||||
return aggregateSessionDetail(chain, messages, sessionId)
|
||||
} finally {
|
||||
@@ -648,11 +653,12 @@ export async function getSessionDetailFromDbWithProfile(sessionId: string, profi
|
||||
|
||||
const ids = chain.map(session => session.id)
|
||||
const placeholders = ids.map(() => '?').join(', ')
|
||||
const orderSql = chainOrderSql(ids)
|
||||
const messageRows = db.prepare(`
|
||||
SELECT * FROM messages
|
||||
WHERE session_id IN (${placeholders})
|
||||
ORDER BY timestamp, id
|
||||
`).all(...ids) as Record<string, unknown>[]
|
||||
ORDER BY CASE session_id ${orderSql} ELSE ${ids.length} END, id
|
||||
`).all(...ids, ...ids) as Record<string, unknown>[]
|
||||
const messages = messageRows.map(mapMessageRow)
|
||||
return aggregateSessionDetail(chain, messages, sessionId)
|
||||
} finally {
|
||||
@@ -672,7 +678,7 @@ export async function getExactSessionDetailFromDbWithProfile(sessionId: string,
|
||||
const messageRows = db.prepare(`
|
||||
SELECT * FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY timestamp, id
|
||||
ORDER BY id
|
||||
`).all(sessionId) as Record<string, unknown>[]
|
||||
const messages = messageRows.map(mapMessageRow)
|
||||
return aggregateSessionDetail([requested], messages, sessionId)
|
||||
@@ -818,10 +824,6 @@ export async function getUsageStatsFromDb(
|
||||
const apiCallsExpr = tableHasColumn(db, 'sessions', 'api_call_count')
|
||||
? 'COALESCE(SUM(api_call_count), 0)'
|
||||
: '0'
|
||||
const sourceFilter = tableHasColumn(db, 'sessions', 'source')
|
||||
? " AND COALESCE(source, '') != 'api_server'"
|
||||
: ''
|
||||
|
||||
const totals = db.prepare(`
|
||||
SELECT
|
||||
COALESCE(SUM(input_tokens), 0) AS input_tokens,
|
||||
@@ -833,7 +835,7 @@ export async function getUsageStatsFromDb(
|
||||
COUNT(*) AS sessions,
|
||||
${apiCallsExpr} AS total_api_calls
|
||||
FROM sessions
|
||||
WHERE started_at > ?${sourceFilter}
|
||||
WHERE started_at > ?
|
||||
`).get(since) as Record<string, unknown> | undefined
|
||||
|
||||
if (!totals) return empty
|
||||
@@ -848,7 +850,7 @@ export async function getUsageStatsFromDb(
|
||||
COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens,
|
||||
COUNT(*) AS sessions
|
||||
FROM sessions
|
||||
WHERE started_at > ?${sourceFilter} AND model IS NOT NULL
|
||||
WHERE started_at > ? AND model IS NOT NULL
|
||||
GROUP BY model
|
||||
ORDER BY COALESCE(SUM(input_tokens), 0) + COALESCE(SUM(output_tokens), 0) DESC
|
||||
`).all(since).map(row => ({
|
||||
@@ -871,7 +873,7 @@ export async function getUsageStatsFromDb(
|
||||
COUNT(*) AS sessions,
|
||||
COALESCE(SUM(COALESCE(actual_cost_usd, estimated_cost_usd, 0)), 0) AS cost
|
||||
FROM sessions
|
||||
WHERE started_at > ?${sourceFilter}
|
||||
WHERE started_at > ?
|
||||
GROUP BY date
|
||||
ORDER BY date ASC
|
||||
`).all(since).map(row => ({
|
||||
|
||||
@@ -20,6 +20,7 @@ import { setGroupChatServer } from './routes/hermes/group-chat'
|
||||
import { setChatRunServer } from './routes/hermes/chat-run'
|
||||
import { GroupChatServer } from './services/hermes/group-chat'
|
||||
import { ChatRunSocket } from './services/hermes/chat-run-socket'
|
||||
import { startAgentBridgeManager } from './services/hermes/agent-bridge'
|
||||
import { logger } from './services/logger'
|
||||
|
||||
// Injected by esbuild at build time; fallback to reading package.json in dev mode
|
||||
@@ -46,6 +47,7 @@ process.on('unhandledRejection', (reason) => {
|
||||
let server: any = null
|
||||
let servers: any[] = []
|
||||
let chatRunServer: any = null
|
||||
let agentBridgeManager: any = null
|
||||
|
||||
interface ListenResult {
|
||||
primary: any
|
||||
@@ -94,6 +96,13 @@ export async function bootstrap() {
|
||||
|
||||
await initGatewayManager()
|
||||
console.log('[bootstrap] gateway manager initialized')
|
||||
try {
|
||||
agentBridgeManager = await startAgentBridgeManager()
|
||||
console.log('[bootstrap] agent bridge started')
|
||||
} catch (err) {
|
||||
logger.warn(err, '[bootstrap] agent bridge failed to start')
|
||||
console.warn('[bootstrap] agent bridge failed to start:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
// Initialize all web-ui SQLite tables
|
||||
const { initAllStores } = await import('./db/hermes/init')
|
||||
@@ -102,11 +111,6 @@ export async function bootstrap() {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
console.log('[bootstrap] all stores initialized')
|
||||
|
||||
// Sync Hermes sessions from all profiles (only if local DB is empty)
|
||||
const { syncAllHermesSessionsOnStartup } = await import('./services/hermes/session-sync')
|
||||
await syncAllHermesSessionsOnStartup()
|
||||
console.log('[bootstrap] Hermes session sync completed')
|
||||
|
||||
app.use(cors({ origin: config.corsOrigins }))
|
||||
app.use(bodyParser())
|
||||
console.log('[bootstrap] cors + bodyParser registered')
|
||||
@@ -187,7 +191,7 @@ export async function bootstrap() {
|
||||
})
|
||||
})
|
||||
|
||||
bindShutdown(servers, groupChatServer, chatRunServer)
|
||||
bindShutdown(servers, groupChatServer, chatRunServer, agentBridgeManager)
|
||||
startVersionCheck()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Agent Bridge
|
||||
|
||||
Optional backend-side bridge for talking to `~/.hermes/hermes-agent` by
|
||||
instantiating `run_agent.AIAgent` directly in a Python process.
|
||||
|
||||
This is intentionally separate from the current Web UI chat path.
|
||||
|
||||
## Python Service
|
||||
|
||||
```bash
|
||||
python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py
|
||||
```
|
||||
|
||||
Default endpoint:
|
||||
|
||||
```text
|
||||
ipc:///tmp/hermes-agent-bridge.sock
|
||||
```
|
||||
|
||||
On Windows, the default endpoint is TCP because Python may not support Unix
|
||||
domain sockets there:
|
||||
|
||||
```text
|
||||
tcp://127.0.0.1:18765
|
||||
```
|
||||
|
||||
Override with:
|
||||
|
||||
```bash
|
||||
HERMES_AGENT_BRIDGE_ENDPOINT=tcp://127.0.0.1:8765 python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py
|
||||
```
|
||||
|
||||
The service discovers Hermes Agent in this order:
|
||||
|
||||
1. `--agent-root`
|
||||
2. `HERMES_AGENT_ROOT`
|
||||
3. the installed `hermes` command path
|
||||
4. current working directory and parent directories
|
||||
5. common locations such as `~/.hermes/hermes-agent`, `~/hermes-agent`, and `/opt/hermes-agent`
|
||||
|
||||
Hermes home is resolved from `--hermes-home`, `HERMES_HOME`, then `~/.hermes`.
|
||||
|
||||
Default agent root:
|
||||
|
||||
```text
|
||||
~/.hermes/hermes-agent
|
||||
```
|
||||
|
||||
You can pass both paths explicitly:
|
||||
|
||||
```bash
|
||||
python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py \
|
||||
--agent-root ~/.hermes/hermes-agent \
|
||||
--hermes-home ~/.hermes
|
||||
```
|
||||
|
||||
The socket transport uses Python and Node standard libraries. No ZMQ dependency
|
||||
is required.
|
||||
|
||||
## Backend Usage
|
||||
|
||||
```ts
|
||||
import { AgentBridgeClient } from './services/hermes/agent-bridge'
|
||||
|
||||
const bridge = new AgentBridgeClient()
|
||||
const run = await bridge.chat(sessionId, message)
|
||||
|
||||
for await (const chunk of bridge.streamOutput(run.run_id)) {
|
||||
if (chunk.delta) {
|
||||
// forward chunk.delta to Socket.IO/SSE/etc.
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The external chat call only sends `session_id` and `message`. Provider, model,
|
||||
keys, tools, reasoning, and session DB are resolved by hermes-agent from the
|
||||
normal Hermes config and environment.
|
||||
|
||||
The bridge instantiates `AIAgent` with `platform="cli"` by default so behavior
|
||||
matches CLI chat. Override it only if a caller intentionally needs a distinct
|
||||
platform identity:
|
||||
|
||||
```bash
|
||||
HERMES_AGENT_BRIDGE_PLATFORM=agent-bridge python packages/server/src/services/hermes/agent-bridge/hermes_bridge.py
|
||||
```
|
||||
@@ -0,0 +1,330 @@
|
||||
import { setTimeout as delay } from 'timers/promises'
|
||||
import { createConnection, type Socket } from 'net'
|
||||
import { URL } from 'url'
|
||||
|
||||
export const DEFAULT_AGENT_BRIDGE_ENDPOINT = process.platform === 'win32'
|
||||
? 'tcp://127.0.0.1:18765'
|
||||
: 'ipc:///tmp/hermes-agent-bridge.sock'
|
||||
export const DEFAULT_AGENT_BRIDGE_TIMEOUT_MS = 120000
|
||||
|
||||
function envPositiveInt(name: string): number | undefined {
|
||||
const raw = process.env[name]
|
||||
if (!raw) return undefined
|
||||
const value = Number(raw)
|
||||
return Number.isFinite(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
export type AgentBridgeStatus = 'running' | 'complete' | 'interrupted' | 'error'
|
||||
|
||||
export interface AgentBridgeOptions {
|
||||
endpoint?: string
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export interface AgentBridgeRequestOptions {
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export type AgentBridgeMessage =
|
||||
| string
|
||||
| Array<Record<string, unknown>>
|
||||
|
||||
export interface AgentBridgeResponse {
|
||||
ok: true
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface AgentBridgeChatStarted extends AgentBridgeResponse {
|
||||
run_id: string
|
||||
session_id: string
|
||||
status: AgentBridgeStatus
|
||||
}
|
||||
|
||||
export interface AgentBridgeOutput extends AgentBridgeResponse {
|
||||
run_id: string
|
||||
session_id: string
|
||||
status: AgentBridgeStatus
|
||||
delta: string
|
||||
cursor: number
|
||||
output: string
|
||||
done: boolean
|
||||
result?: unknown
|
||||
error?: string | null
|
||||
events: Array<Record<string, unknown>>
|
||||
event_cursor: number
|
||||
}
|
||||
|
||||
export interface AgentBridgeRunResult extends AgentBridgeResponse {
|
||||
run_id: string
|
||||
session_id: string
|
||||
status: AgentBridgeStatus
|
||||
output: string
|
||||
deltas: string[]
|
||||
events: unknown[]
|
||||
result?: unknown
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export interface AgentBridgeCommandResult extends AgentBridgeResponse {
|
||||
session_id: string
|
||||
command: string
|
||||
handled: boolean
|
||||
message?: string
|
||||
new_session_id?: string
|
||||
history?: unknown[]
|
||||
retry?: boolean
|
||||
retry_input?: AgentBridgeMessage
|
||||
title?: string
|
||||
}
|
||||
|
||||
export class AgentBridgeError extends Error {
|
||||
response?: unknown
|
||||
}
|
||||
|
||||
export class AgentBridgeClient {
|
||||
readonly endpoint: string
|
||||
readonly timeoutMs: number
|
||||
private lock: Promise<unknown> = Promise.resolve()
|
||||
|
||||
constructor(options: AgentBridgeOptions = {}) {
|
||||
this.endpoint = options.endpoint || process.env.HERMES_AGENT_BRIDGE_ENDPOINT || DEFAULT_AGENT_BRIDGE_ENDPOINT
|
||||
this.timeoutMs = options.timeoutMs ?? envPositiveInt('HERMES_AGENT_BRIDGE_TIMEOUT_MS') ?? DEFAULT_AGENT_BRIDGE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
async connect(): Promise<this> {
|
||||
return this
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
return undefined
|
||||
}
|
||||
|
||||
private connectSocket(): Promise<Socket> {
|
||||
return new Promise((resolveConnect, rejectConnect) => {
|
||||
const endpoint = this.endpoint
|
||||
let socket: Socket
|
||||
if (endpoint.startsWith('ipc://')) {
|
||||
socket = createConnection(endpoint.slice('ipc://'.length))
|
||||
} else if (endpoint.startsWith('tcp://')) {
|
||||
const url = new URL(endpoint)
|
||||
socket = createConnection({
|
||||
host: url.hostname || '127.0.0.1',
|
||||
port: Number(url.port),
|
||||
})
|
||||
} else {
|
||||
rejectConnect(new Error(`unsupported agent bridge endpoint: ${endpoint}`))
|
||||
return
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
socket.off('connect', onConnect)
|
||||
socket.off('error', onError)
|
||||
}
|
||||
const onConnect = () => {
|
||||
cleanup()
|
||||
resolveConnect(socket)
|
||||
}
|
||||
const onError = (err: Error) => {
|
||||
cleanup()
|
||||
socket.destroy()
|
||||
rejectConnect(err)
|
||||
}
|
||||
socket.once('connect', onConnect)
|
||||
socket.once('error', onError)
|
||||
})
|
||||
}
|
||||
|
||||
private readResponse(socket: Socket, timeoutMs: number): Promise<string> {
|
||||
return new Promise((resolveRead, rejectRead) => {
|
||||
let buffer = ''
|
||||
const timeout = timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
cleanup()
|
||||
socket.destroy()
|
||||
rejectRead(new Error(`Agent bridge request timed out after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
: null
|
||||
|
||||
const cleanup = () => {
|
||||
if (timeout) clearTimeout(timeout)
|
||||
socket.off('data', onData)
|
||||
socket.off('error', onError)
|
||||
socket.off('end', onEnd)
|
||||
socket.off('close', onClose)
|
||||
}
|
||||
const finish = (line: string) => {
|
||||
cleanup()
|
||||
socket.end()
|
||||
resolveRead(line)
|
||||
}
|
||||
const onData = (chunk: Buffer) => {
|
||||
buffer += chunk.toString('utf8')
|
||||
const idx = buffer.indexOf('\n')
|
||||
if (idx >= 0) finish(buffer.slice(0, idx))
|
||||
}
|
||||
const onError = (err: Error) => {
|
||||
cleanup()
|
||||
socket.destroy()
|
||||
rejectRead(err)
|
||||
}
|
||||
const onEnd = () => {
|
||||
const line = buffer.trim()
|
||||
if (line) finish(line)
|
||||
}
|
||||
const onClose = () => {
|
||||
if (!buffer.trim()) {
|
||||
cleanup()
|
||||
rejectRead(new Error('Agent bridge socket closed without a response'))
|
||||
}
|
||||
}
|
||||
|
||||
socket.on('data', onData)
|
||||
socket.once('error', onError)
|
||||
socket.once('end', onEnd)
|
||||
socket.once('close', onClose)
|
||||
})
|
||||
}
|
||||
|
||||
async request<T extends AgentBridgeResponse = AgentBridgeResponse>(
|
||||
payload: Record<string, unknown>,
|
||||
options: AgentBridgeRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const run = async (): Promise<T> => {
|
||||
const timeoutMs = options.timeoutMs || this.timeoutMs
|
||||
const socket = await this.connectSocket()
|
||||
socket.write(`${JSON.stringify(payload)}\n`)
|
||||
const raw = await this.readResponse(socket, timeoutMs)
|
||||
const response = JSON.parse(raw) as { ok?: boolean; error?: string }
|
||||
if (!response.ok) {
|
||||
const error = new AgentBridgeError(response.error || 'Agent bridge request failed')
|
||||
error.response = response
|
||||
throw error
|
||||
}
|
||||
return response as T
|
||||
}
|
||||
|
||||
const next = this.lock.then(run, run)
|
||||
this.lock = next.catch(() => undefined)
|
||||
return next
|
||||
}
|
||||
|
||||
ping(): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'ping' })
|
||||
}
|
||||
|
||||
chat(
|
||||
sessionId: string,
|
||||
message: AgentBridgeMessage,
|
||||
conversationHistory?: unknown[],
|
||||
instructions?: string,
|
||||
profile?: string,
|
||||
): Promise<AgentBridgeChatStarted> {
|
||||
return this.request<AgentBridgeChatStarted>({
|
||||
action: 'chat',
|
||||
session_id: sessionId,
|
||||
message,
|
||||
...(conversationHistory ? { conversation_history: conversationHistory } : {}),
|
||||
...(instructions ? { instructions } : {}),
|
||||
...(profile ? { profile } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
command(sessionId: string, command: string): Promise<AgentBridgeCommandResult> {
|
||||
return this.request<AgentBridgeCommandResult>({
|
||||
action: 'command',
|
||||
session_id: sessionId,
|
||||
command,
|
||||
})
|
||||
}
|
||||
|
||||
getOutput(runId: string, cursor = 0, eventCursor = 0, options: AgentBridgeRequestOptions = {}): Promise<AgentBridgeOutput> {
|
||||
return this.request<AgentBridgeOutput>({
|
||||
action: 'get_output',
|
||||
run_id: runId,
|
||||
cursor,
|
||||
event_cursor: eventCursor,
|
||||
}, options)
|
||||
}
|
||||
|
||||
async *streamOutput(
|
||||
runId: string,
|
||||
options: AgentBridgeRequestOptions & { intervalMs?: number } = {},
|
||||
): AsyncGenerator<AgentBridgeOutput> {
|
||||
const intervalMs = options.intervalMs || 100
|
||||
let cursor = 0
|
||||
let eventCursor = 0
|
||||
for (;;) {
|
||||
const chunk = await this.getOutput(runId, cursor, eventCursor, options)
|
||||
cursor = chunk.cursor
|
||||
eventCursor = chunk.event_cursor
|
||||
if (chunk.delta || chunk.done || (chunk.events && chunk.events.length > 0)) yield chunk
|
||||
if (chunk.done) return
|
||||
await delay(intervalMs)
|
||||
}
|
||||
}
|
||||
|
||||
async chatStream(
|
||||
sessionId: string,
|
||||
message: AgentBridgeMessage,
|
||||
onDelta: (delta: string, chunk: AgentBridgeOutput) => void | Promise<void>,
|
||||
options: AgentBridgeRequestOptions & { intervalMs?: number } = {},
|
||||
): Promise<AgentBridgeOutput> {
|
||||
const started = await this.chat(sessionId, message)
|
||||
let last: AgentBridgeOutput | null = null
|
||||
for await (const chunk of this.streamOutput(started.run_id, options)) {
|
||||
last = chunk
|
||||
if (chunk.delta) await onDelta(chunk.delta, chunk)
|
||||
}
|
||||
if (!last) throw new Error(`Agent bridge run ${started.run_id} produced no output state`)
|
||||
return last
|
||||
}
|
||||
|
||||
getResult(runId: string, options: AgentBridgeRequestOptions = {}): Promise<AgentBridgeRunResult> {
|
||||
return this.request<AgentBridgeRunResult>({ action: 'get_result', run_id: runId }, options)
|
||||
}
|
||||
|
||||
interrupt(sessionId: string, message?: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'interrupt', session_id: sessionId, message })
|
||||
}
|
||||
|
||||
steer(sessionId: string, text: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'steer', session_id: sessionId, text })
|
||||
}
|
||||
|
||||
approvalRespond(approvalId: string, choice: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'approval_respond', approval_id: approvalId, choice })
|
||||
}
|
||||
|
||||
compressionRespond(
|
||||
requestId: string,
|
||||
payload: { messages?: unknown[]; system_message?: string; error?: string },
|
||||
): Promise<AgentBridgeResponse> {
|
||||
return this.request({
|
||||
action: 'compression_respond',
|
||||
request_id: requestId,
|
||||
...payload,
|
||||
}, { timeoutMs: this.timeoutMs })
|
||||
}
|
||||
|
||||
destroyAll(): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'destroy_all' })
|
||||
}
|
||||
|
||||
getHistory(sessionId: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'get_history', session_id: sessionId })
|
||||
}
|
||||
|
||||
destroy(sessionId: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'destroy', session_id: sessionId })
|
||||
}
|
||||
|
||||
list(): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'list' })
|
||||
}
|
||||
|
||||
shutdown(): Promise<AgentBridgeResponse> {
|
||||
return this.request({ action: 'shutdown' })
|
||||
}
|
||||
}
|
||||
|
||||
export default AgentBridgeClient
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
export * from './client'
|
||||
export * from './manager'
|
||||
@@ -0,0 +1,360 @@
|
||||
import { execFileSync, spawn, type ChildProcess } from 'child_process'
|
||||
import { existsSync, readFileSync } from 'fs'
|
||||
import { dirname, isAbsolute, join, resolve } from 'path'
|
||||
import { logger } from '../../logger'
|
||||
import { detectHermesHome, getHermesBin } from '../hermes-path'
|
||||
import { DEFAULT_AGENT_BRIDGE_ENDPOINT } from './client'
|
||||
|
||||
const DEFAULT_AGENT_BRIDGE_STARTUP_TIMEOUT_MS = 120000
|
||||
|
||||
export interface AgentBridgeManagerOptions {
|
||||
endpoint?: string
|
||||
python?: string
|
||||
agentRoot?: string
|
||||
hermesHome?: string
|
||||
startupTimeoutMs?: number
|
||||
}
|
||||
|
||||
interface BridgeCommand {
|
||||
command: string
|
||||
argsPrefix: string[]
|
||||
agentRoot?: string
|
||||
hermesHome: string
|
||||
}
|
||||
|
||||
function envPositiveInt(name: string): number | undefined {
|
||||
const raw = process.env[name]
|
||||
if (!raw) return undefined
|
||||
const value = Number(raw)
|
||||
return Number.isFinite(value) && value > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function pathCandidates(agentRoot?: string): string[] {
|
||||
if (!agentRoot) return []
|
||||
return process.platform === 'win32'
|
||||
? [
|
||||
join(agentRoot, 'venv', 'Scripts', 'python.exe'),
|
||||
join(agentRoot, 'venv', 'Scripts', 'python3.exe'),
|
||||
join(agentRoot, '.venv', 'Scripts', 'python.exe'),
|
||||
join(agentRoot, '.venv', 'Scripts', 'python3.exe'),
|
||||
]
|
||||
: [
|
||||
join(agentRoot, 'venv', 'bin', 'python3'),
|
||||
join(agentRoot, 'venv', 'bin', 'python'),
|
||||
join(agentRoot, '.venv', 'bin', 'python3'),
|
||||
join(agentRoot, '.venv', 'bin', 'python'),
|
||||
]
|
||||
}
|
||||
|
||||
function uvCandidates(agentRoot?: string): string[] {
|
||||
return [
|
||||
process.env.HERMES_AGENT_BRIDGE_UV,
|
||||
process.env.UV,
|
||||
...(process.platform === 'win32'
|
||||
? [
|
||||
agentRoot ? join(agentRoot, 'venv', 'Scripts', 'uv.exe') : '',
|
||||
agentRoot ? join(agentRoot, 'venv', 'Scripts', 'uv.cmd') : '',
|
||||
agentRoot ? join(agentRoot, '.venv', 'Scripts', 'uv.exe') : '',
|
||||
agentRoot ? join(agentRoot, '.venv', 'Scripts', 'uv.cmd') : '',
|
||||
]
|
||||
: [
|
||||
agentRoot ? join(agentRoot, 'venv', 'bin', 'uv') : '',
|
||||
agentRoot ? join(agentRoot, '.venv', 'bin', 'uv') : '',
|
||||
]),
|
||||
'uv',
|
||||
].filter((value): value is string => !!value && value.trim().length > 0)
|
||||
}
|
||||
|
||||
function resolveExecutable(command: string): string | undefined {
|
||||
const trimmed = command.trim()
|
||||
if (!trimmed) return undefined
|
||||
if (isAbsolute(trimmed) || trimmed.includes('/') || trimmed.includes('\\')) {
|
||||
return existsSync(trimmed) ? resolve(trimmed) : undefined
|
||||
}
|
||||
try {
|
||||
const lookup = process.platform === 'win32'
|
||||
? execFileSync('where.exe', [trimmed], { encoding: 'utf-8', windowsHide: true })
|
||||
: execFileSync('which', [trimmed], { encoding: 'utf-8' })
|
||||
return lookup.split(/\r?\n/).map(line => line.trim()).find(Boolean)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function agentRootFromHermesBin(): string | undefined {
|
||||
const hermesBin = resolveExecutable(getHermesBin())
|
||||
if (!hermesBin) return undefined
|
||||
|
||||
const binDir = dirname(hermesBin)
|
||||
const rootCandidates = [
|
||||
resolve(binDir, '..'),
|
||||
resolve(binDir, '..', '..'),
|
||||
resolve(binDir, '..', 'hermes-agent'),
|
||||
resolve(binDir, '..', '..', 'hermes-agent'),
|
||||
]
|
||||
const root = rootCandidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
|
||||
if (root) return root
|
||||
|
||||
try {
|
||||
const first = readFileSync(hermesBin, 'utf-8').split(/\r?\n/, 1)[0]
|
||||
const match = first.match(/^#!\s*(.+)$/)
|
||||
const python = match?.[1]?.trim().split(/\s+/)[0]
|
||||
if (python) {
|
||||
const pyDir = dirname(python)
|
||||
const shebangRootCandidates = [
|
||||
resolve(pyDir, '..', '..'),
|
||||
resolve(pyDir, '..', '..', 'hermes-agent'),
|
||||
]
|
||||
return shebangRootCandidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
|
||||
}
|
||||
} catch {}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function hermesBinPython(): string | undefined {
|
||||
const hermesBin = resolveExecutable(getHermesBin())
|
||||
if (!hermesBin) return undefined
|
||||
try {
|
||||
const first = readFileSync(hermesBin, 'utf-8').split(/\r?\n/, 1)[0]
|
||||
const match = first.match(/^#!\s*(.+)$/)
|
||||
const python = match?.[1]?.trim().split(/\s+/)[0]
|
||||
return python && existsSync(python) ? python : undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function firstExistingExecutable(candidates: string[]): string | undefined {
|
||||
for (const candidate of candidates) {
|
||||
if (!isAbsolute(candidate) && !candidate.includes('/') && !candidate.includes('\\')) {
|
||||
const resolved = resolveExecutable(candidate)
|
||||
if (resolved) return resolved
|
||||
continue
|
||||
}
|
||||
try {
|
||||
if (existsSync(candidate)) return candidate
|
||||
} catch {}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function resolveAgentRoot(explicit?: string, hermesHome = detectHermesHome()): string | undefined {
|
||||
const candidates = [
|
||||
explicit,
|
||||
process.env.HERMES_AGENT_ROOT,
|
||||
join(hermesHome, 'hermes-agent'),
|
||||
agentRootFromHermesBin(),
|
||||
process.cwd(),
|
||||
join(process.cwd(), 'hermes-agent'),
|
||||
].filter((value): value is string => !!value && value.trim().length > 0)
|
||||
return candidates.find(candidate => existsSync(join(candidate, 'run_agent.py')))
|
||||
}
|
||||
|
||||
function bridgeCommand(options: AgentBridgeManagerOptions): BridgeCommand {
|
||||
const hermesHome = options.hermesHome || detectHermesHome()
|
||||
const agentRoot = resolveAgentRoot(options.agentRoot, hermesHome)
|
||||
const explicitPython = options.python || process.env.HERMES_AGENT_BRIDGE_PYTHON
|
||||
if (explicitPython) {
|
||||
return { command: explicitPython, argsPrefix: [], agentRoot, hermesHome }
|
||||
}
|
||||
|
||||
const venvPython = firstExistingExecutable(pathCandidates(agentRoot))
|
||||
if (venvPython) {
|
||||
return { command: venvPython, argsPrefix: [], agentRoot, hermesHome }
|
||||
}
|
||||
|
||||
const shebangPython = hermesBinPython()
|
||||
if (shebangPython && existsSync(shebangPython)) {
|
||||
return { command: shebangPython, argsPrefix: [], agentRoot, hermesHome }
|
||||
}
|
||||
|
||||
const uv = firstExistingExecutable(uvCandidates(agentRoot))
|
||||
if (uv) {
|
||||
const prefix = ['run']
|
||||
if (agentRoot) prefix.push('--project', agentRoot)
|
||||
prefix.push('python')
|
||||
return { command: uv, argsPrefix: prefix, agentRoot, hermesHome }
|
||||
}
|
||||
|
||||
const fallback = firstExistingExecutable([
|
||||
process.env.PYTHON || '',
|
||||
...(process.platform === 'win32' ? ['py', 'python', 'python3'] : ['python3', 'python']),
|
||||
]) || (process.platform === 'win32' ? 'python' : 'python3')
|
||||
return { command: fallback, argsPrefix: [], agentRoot, hermesHome }
|
||||
}
|
||||
|
||||
function bridgeScriptPath(): string {
|
||||
const candidates = [
|
||||
// Built server: dist/server/index.js -> dist/server/agent-bridge/hermes_bridge.py
|
||||
resolve(__dirname, 'agent-bridge', 'hermes_bridge.py'),
|
||||
// ts-node/dev source tree.
|
||||
resolve(__dirname, 'services/hermes/agent-bridge/hermes_bridge.py'),
|
||||
resolve(process.cwd(), 'packages/server/src/services/hermes/agent-bridge/hermes_bridge.py'),
|
||||
]
|
||||
const found = candidates.find(candidate => existsSync(candidate))
|
||||
if (!found) {
|
||||
throw new Error(`agent bridge Python script not found. Tried: ${candidates.join(', ')}`)
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
export class AgentBridgeManager {
|
||||
readonly endpoint: string
|
||||
private readonly options: AgentBridgeManagerOptions
|
||||
private child: ChildProcess | null = null
|
||||
private starting: Promise<void> | null = null
|
||||
private ready = false
|
||||
|
||||
constructor(options: AgentBridgeManagerOptions = {}) {
|
||||
this.options = options
|
||||
this.endpoint = options.endpoint || process.env.HERMES_AGENT_BRIDGE_ENDPOINT || DEFAULT_AGENT_BRIDGE_ENDPOINT
|
||||
}
|
||||
|
||||
get running(): boolean {
|
||||
return !!this.child && !this.child.killed && this.ready
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.running) return
|
||||
if (this.starting) return this.starting
|
||||
this.starting = this.startProcess()
|
||||
try {
|
||||
await this.starting
|
||||
} finally {
|
||||
this.starting = null
|
||||
}
|
||||
}
|
||||
|
||||
private async startProcess(): Promise<void> {
|
||||
const script = bridgeScriptPath()
|
||||
const command = bridgeCommand(this.options)
|
||||
const args = [...command.argsPrefix, script, '--endpoint', this.endpoint]
|
||||
const agentRoot = command.agentRoot
|
||||
const hermesHome = command.hermesHome
|
||||
if (agentRoot) args.push('--agent-root', agentRoot)
|
||||
if (hermesHome) args.push('--hermes-home', hermesHome)
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
HERMES_AGENT_BRIDGE_ENDPOINT: this.endpoint,
|
||||
HERMES_HOME: hermesHome,
|
||||
...(agentRoot ? { HERMES_AGENT_ROOT: agentRoot } : {}),
|
||||
}
|
||||
|
||||
logger.info('[agent-bridge] starting: %s %s', command.command, args.join(' '))
|
||||
const child = spawn(command.command, args, {
|
||||
env,
|
||||
cwd: process.cwd(),
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
})
|
||||
this.child = child
|
||||
this.ready = false
|
||||
|
||||
child.once('exit', (code, signal) => {
|
||||
logger.warn('[agent-bridge] exited code=%s signal=%s', code, signal)
|
||||
this.ready = false
|
||||
if (this.child === child) this.child = null
|
||||
})
|
||||
|
||||
child.stderr?.on('data', chunk => {
|
||||
const text = String(chunk).trim()
|
||||
if (text) logger.warn('[agent-bridge] %s', text)
|
||||
})
|
||||
|
||||
await new Promise<void>((resolveReady, rejectReady) => {
|
||||
let buffered = ''
|
||||
const startupTimeoutMs = this.options.startupTimeoutMs
|
||||
?? envPositiveInt('HERMES_AGENT_BRIDGE_STARTUP_TIMEOUT_MS')
|
||||
?? DEFAULT_AGENT_BRIDGE_STARTUP_TIMEOUT_MS
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
rejectReady(new Error(`agent bridge did not become ready within ${startupTimeoutMs}ms`))
|
||||
}, startupTimeoutMs)
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
child.off('exit', onExitBeforeReady)
|
||||
child.off('error', onError)
|
||||
}
|
||||
|
||||
const onError = (err: Error) => {
|
||||
cleanup()
|
||||
child.stdout?.off('data', onStdout)
|
||||
rejectReady(err)
|
||||
}
|
||||
|
||||
const onExitBeforeReady = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
cleanup()
|
||||
child.stdout?.off('data', onStdout)
|
||||
rejectReady(new Error(`agent bridge exited before ready code=${code} signal=${signal}`))
|
||||
}
|
||||
|
||||
let readyResolved = false
|
||||
const onStdout = (chunk: Buffer) => {
|
||||
const text = chunk.toString('utf8')
|
||||
buffered += text
|
||||
for (;;) {
|
||||
const newline = buffered.indexOf('\n')
|
||||
if (newline < 0) break
|
||||
const line = buffered.slice(0, newline).trim()
|
||||
buffered = buffered.slice(newline + 1)
|
||||
if (!line) continue
|
||||
logger.info('[agent-bridge] %s', line)
|
||||
if (!readyResolved) {
|
||||
try {
|
||||
const parsed = JSON.parse(line)
|
||||
if (parsed?.event === 'ready') {
|
||||
this.ready = true
|
||||
readyResolved = true
|
||||
cleanup()
|
||||
resolveReady()
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
child.once('error', onError)
|
||||
child.once('exit', onExitBeforeReady)
|
||||
child.stdout?.on('data', onStdout)
|
||||
})
|
||||
|
||||
logger.info('[agent-bridge] ready at %s', this.endpoint)
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const child = this.child
|
||||
if (!child) return
|
||||
this.ready = false
|
||||
this.child = null
|
||||
|
||||
await new Promise<void>((resolveStop) => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (!child.killed) child.kill('SIGKILL')
|
||||
resolveStop()
|
||||
}, 1500)
|
||||
child.once('exit', () => {
|
||||
clearTimeout(timeout)
|
||||
resolveStop()
|
||||
})
|
||||
if (!child.killed) {
|
||||
child.kill('SIGTERM')
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let singleton: AgentBridgeManager | null = null
|
||||
|
||||
export function getAgentBridgeManager(): AgentBridgeManager {
|
||||
if (!singleton) singleton = new AgentBridgeManager()
|
||||
return singleton
|
||||
}
|
||||
|
||||
export async function startAgentBridgeManager(): Promise<AgentBridgeManager> {
|
||||
const manager = getAgentBridgeManager()
|
||||
await manager.start()
|
||||
return manager
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -101,6 +101,8 @@ class AgentClient {
|
||||
reconnectionAttempts: Infinity,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 30000,
|
||||
randomizationFactor: 0.5,
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
this.bindEvents()
|
||||
|
||||
@@ -424,7 +424,13 @@ export class GroupChatServer {
|
||||
const servers = Array.isArray(httpServers) ? httpServers : [httpServers]
|
||||
|
||||
this.io = new Server(servers[0], {
|
||||
cors: { origin: '*' }
|
||||
cors: { origin: '*' },
|
||||
pingInterval: 25_000,
|
||||
pingTimeout: 90_000,
|
||||
connectionStateRecovery: {
|
||||
maxDisconnectionDuration: 2 * 60_000,
|
||||
skipMiddlewares: true,
|
||||
},
|
||||
})
|
||||
servers.slice(1).forEach((httpServer) => this.io.attach(httpServer))
|
||||
this.nsp = this.io.of('/group-chat')
|
||||
|
||||
@@ -27,7 +27,7 @@ function shouldStopGatewaysOnShutdown(signal: string): boolean {
|
||||
return shouldStop
|
||||
}
|
||||
|
||||
export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?: any): void {
|
||||
export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?: any, agentBridgeManager?: any): void {
|
||||
let isShuttingDown = false
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
@@ -58,6 +58,15 @@ export function bindShutdown(server: any, groupChatServer?: any, chatRunServer?:
|
||||
logger.info('Skipping gateway shutdown for %s', signal)
|
||||
}
|
||||
|
||||
if (agentBridgeManager) {
|
||||
try {
|
||||
await agentBridgeManager.stop()
|
||||
logger.info('Agent bridge stopped')
|
||||
} catch (err) {
|
||||
logger.warn(err, 'Failed to stop agent bridge (non-fatal)')
|
||||
}
|
||||
}
|
||||
|
||||
// Close ChatRunSocket first to abort all active runs and close EventSource connections
|
||||
if (chatRunServer) {
|
||||
chatRunServer.close()
|
||||
|
||||
@@ -62,13 +62,9 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
base_url: 'https://generativelanguage.googleapis.com/v1beta/openai',
|
||||
models: [
|
||||
'gemini-3.1-pro-preview',
|
||||
'gemini-3-pro-preview',
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-3.1-flash-lite-preview',
|
||||
'gemini-2.5-pro',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-flash-lite',
|
||||
'gemma-4-31b-it',
|
||||
'gemma-4-26b-it',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -76,7 +72,7 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
value: 'deepseek',
|
||||
builtin: true,
|
||||
base_url: 'https://api.deepseek.com',
|
||||
models: ['deepseek-v4-flash', 'deepseek-v4-pro'],
|
||||
models: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-chat', 'deepseek-reasoner'],
|
||||
},
|
||||
{
|
||||
label: 'Z.AI / GLM',
|
||||
@@ -98,7 +94,6 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
builtin: true,
|
||||
base_url: 'https://api.kimi.com/coding/v1',
|
||||
models: [
|
||||
'kimi-for-coding',
|
||||
'kimi-k2.6',
|
||||
'kimi-k2.5',
|
||||
'kimi-k2-thinking',
|
||||
@@ -124,7 +119,17 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
value: 'xai',
|
||||
builtin: true,
|
||||
base_url: 'https://api.x.ai/v1',
|
||||
models: ['grok-4.20-reasoning', 'grok-4-1-fast-reasoning'],
|
||||
models: [
|
||||
'grok-4.20-0309-reasoning',
|
||||
'grok-4.20-0309-non-reasoning',
|
||||
'grok-4.20-multi-agent-0309',
|
||||
'grok-4-1-fast',
|
||||
'grok-4-1-fast-non-reasoning',
|
||||
'grok-4-fast',
|
||||
'grok-4-fast-non-reasoning',
|
||||
'grok-4',
|
||||
'grok-code-fast-1',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'MiniMax',
|
||||
@@ -146,12 +151,13 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
builtin: true,
|
||||
base_url: 'https://dashscope-intl.aliyuncs.com/compatible-mode/v1',
|
||||
models: [
|
||||
'qwen3.6-plus',
|
||||
'kimi-k2.5',
|
||||
'qwen3.5-plus',
|
||||
'qwen3-coder-plus',
|
||||
'qwen3-coder-next',
|
||||
'glm-5',
|
||||
'glm-4.7',
|
||||
'kimi-k2.5',
|
||||
'MiniMax-M2.5',
|
||||
],
|
||||
},
|
||||
@@ -166,13 +172,13 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
// returns HTTP 401 for those keys.
|
||||
base_url: 'https://coding-intl.dashscope.aliyuncs.com/v1',
|
||||
models: [
|
||||
'qwen3.6-plus',
|
||||
'qwen3.5-plus',
|
||||
'qwen3-max-2026-01-23',
|
||||
'qwen3-coder-next',
|
||||
'qwen3-coder-plus',
|
||||
'qwen3-coder-next',
|
||||
'kimi-k2.5',
|
||||
'glm-5',
|
||||
'glm-4.7',
|
||||
'kimi-k2.5',
|
||||
'MiniMax-M2.5',
|
||||
],
|
||||
},
|
||||
@@ -182,14 +188,15 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
builtin: true,
|
||||
base_url: 'https://router.huggingface.co/v1',
|
||||
models: [
|
||||
'moonshotai/Kimi-K2.5',
|
||||
'Qwen/Qwen3.5-397B-A17B',
|
||||
'Qwen/Qwen3.5-35B-A3B',
|
||||
'deepseek-ai/DeepSeek-V3.2',
|
||||
'moonshotai/Kimi-K2.5',
|
||||
'MiniMaxAI/MiniMax-M2.5',
|
||||
'zai-org/GLM-5',
|
||||
'XiaomiMiMo/MiMo-V2-Flash',
|
||||
'moonshotai/Kimi-K2-Thinking',
|
||||
'moonshotai/Kimi-K2.6',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -214,14 +221,11 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
builtin: true,
|
||||
base_url: 'https://api.xiaomimimo.com/v1',
|
||||
models: [
|
||||
'mimo-v2-omni',
|
||||
'mimo-v2-pro',
|
||||
'mimo-v2-tts',
|
||||
'mimo-v2.5',
|
||||
'mimo-v2.5-pro',
|
||||
'mimo-v2.5-tts',
|
||||
'mimo-v2.5-tts-voiceclone',
|
||||
'mimo-v2.5-tts-voicedesign',
|
||||
'mimo-v2.5',
|
||||
'mimo-v2-pro',
|
||||
'mimo-v2-omni',
|
||||
'mimo-v2-flash',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -243,18 +247,21 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
builtin: true,
|
||||
base_url: 'https://ai-gateway.vercel.sh/v1',
|
||||
models: [
|
||||
'anthropic/claude-opus-4.6',
|
||||
'moonshotai/kimi-k2.6',
|
||||
'alibaba/qwen3.6-plus',
|
||||
'zai/glm-5.1',
|
||||
'minimax/minimax-m2.7',
|
||||
'anthropic/claude-sonnet-4.6',
|
||||
'anthropic/claude-sonnet-4.5',
|
||||
'anthropic/claude-opus-4.7',
|
||||
'anthropic/claude-opus-4.6',
|
||||
'anthropic/claude-haiku-4.5',
|
||||
'openai/gpt-5',
|
||||
'openai/gpt-4.1',
|
||||
'openai/gpt-4.1-mini',
|
||||
'google/gemini-3-pro-preview',
|
||||
'openai/gpt-5.4',
|
||||
'openai/gpt-5.4-mini',
|
||||
'openai/gpt-5.3-codex',
|
||||
'google/gemini-3.1-pro-preview',
|
||||
'google/gemini-3-flash',
|
||||
'google/gemini-2.5-pro',
|
||||
'google/gemini-2.5-flash',
|
||||
'deepseek/deepseek-v3.2',
|
||||
'google/gemini-3.1-flash-lite-preview',
|
||||
'xai/grok-4.20-reasoning',
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -277,10 +284,10 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
builtin: true,
|
||||
base_url: 'https://opencode.ai/zen/v1',
|
||||
models: [
|
||||
'kimi-k2.5',
|
||||
'gpt-5.4-pro',
|
||||
'gpt-5.4',
|
||||
'gpt-5.3-codex',
|
||||
'gpt-5.3-codex-spark',
|
||||
'gpt-5.2',
|
||||
'gpt-5.2-codex',
|
||||
'gpt-5.1',
|
||||
@@ -308,7 +315,6 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
'glm-5',
|
||||
'glm-4.7',
|
||||
'glm-4.6',
|
||||
'kimi-k2.5',
|
||||
'kimi-k2-thinking',
|
||||
'kimi-k2',
|
||||
'qwen3-coder',
|
||||
@@ -320,7 +326,20 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
value: 'opencode-go',
|
||||
builtin: true,
|
||||
base_url: 'https://opencode.ai/zen/go/v1',
|
||||
models: ['glm-5.1', 'glm-5', 'kimi-k2.5', 'mimo-v2-pro', 'mimo-v2-omni', 'minimax-m2.7', 'minimax-m2.5'],
|
||||
models: [
|
||||
'kimi-k2.6',
|
||||
'kimi-k2.5',
|
||||
'glm-5.1',
|
||||
'glm-5',
|
||||
'mimo-v2.5-pro',
|
||||
'mimo-v2.5',
|
||||
'mimo-v2-pro',
|
||||
'mimo-v2-omni',
|
||||
'minimax-m2.7',
|
||||
'minimax-m2.5',
|
||||
'qwen3.6-plus',
|
||||
'qwen3.5-plus',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'LongCat',
|
||||
@@ -352,12 +371,13 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
'moonshotai/kimi-k2.6',
|
||||
'xiaomi/mimo-v2.5-pro',
|
||||
'xiaomi/mimo-v2.5',
|
||||
'tencent/hy3-preview',
|
||||
'anthropic/claude-opus-4.7',
|
||||
'anthropic/claude-opus-4.6',
|
||||
'anthropic/claude-sonnet-4.6',
|
||||
'anthropic/claude-sonnet-4.5',
|
||||
'anthropic/claude-haiku-4.5',
|
||||
'openai/gpt-5.4',
|
||||
'openai/gpt-5.5',
|
||||
'openai/gpt-5.4-mini',
|
||||
'openai/gpt-5.3-codex',
|
||||
'google/gemini-3-pro-preview',
|
||||
@@ -374,10 +394,12 @@ export const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
'z-ai/glm-5v-turbo',
|
||||
'z-ai/glm-5-turbo',
|
||||
'x-ai/grok-4.20-beta',
|
||||
'x-ai/grok-4.3',
|
||||
'nvidia/nemotron-3-super-120b-a12b',
|
||||
'arcee-ai/trinity-large-thinking',
|
||||
'openai/gpt-5.4-pro',
|
||||
'openai/gpt-5.5-pro',
|
||||
'openai/gpt-5.4-nano',
|
||||
'deepseek/deepseek-v4-pro',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as esbuild from 'esbuild'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { readFileSync } from 'fs'
|
||||
import { cpSync, mkdirSync, readFileSync } from 'fs'
|
||||
|
||||
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const pkg = JSON.parse(readFileSync(resolve(rootDir, 'package.json'), 'utf-8'))
|
||||
@@ -23,3 +23,10 @@ await esbuild.build({
|
||||
treeShaking: true,
|
||||
logLevel: 'info',
|
||||
})
|
||||
|
||||
const bridgeOutDir = resolve(rootDir, 'dist/server/agent-bridge')
|
||||
mkdirSync(bridgeOutDir, { recursive: true })
|
||||
cpSync(
|
||||
resolve(rootDir, 'packages/server/src/services/hermes/agent-bridge/hermes_bridge.py'),
|
||||
resolve(bridgeOutDir, 'hermes_bridge.py'),
|
||||
)
|
||||
|
||||
@@ -4,10 +4,6 @@ import {
|
||||
PROVIDER_PRESETS as SERVER_PROVIDER_PRESETS,
|
||||
buildProviderModelMap as buildServerProviderModelMap,
|
||||
} from '../../packages/server/src/shared/providers'
|
||||
import {
|
||||
PROVIDER_PRESETS as CLIENT_PROVIDER_PRESETS,
|
||||
buildProviderModelMap as buildClientProviderModelMap,
|
||||
} from '../../packages/client/src/shared/providers'
|
||||
|
||||
const OPENAI_CODEX_PROVIDER = 'openai-codex'
|
||||
const GPT_5_5_MODEL = 'gpt-5.5'
|
||||
@@ -19,13 +15,11 @@ function modelsForProvider(providerPresets: Array<{ value: string; models: strin
|
||||
}
|
||||
|
||||
describe('provider presets', () => {
|
||||
it('lists GPT-5.5 for OpenAI Codex on both client and server', () => {
|
||||
expect(modelsForProvider(CLIENT_PROVIDER_PRESETS, OPENAI_CODEX_PROVIDER)).toContain(GPT_5_5_MODEL)
|
||||
it('lists GPT-5.5 for OpenAI Codex', () => {
|
||||
expect(modelsForProvider(SERVER_PROVIDER_PRESETS, OPENAI_CODEX_PROVIDER)).toContain(GPT_5_5_MODEL)
|
||||
})
|
||||
|
||||
it('exposes GPT-5.5 through provider model maps', () => {
|
||||
expect(buildClientProviderModelMap()[OPENAI_CODEX_PROVIDER]).toContain(GPT_5_5_MODEL)
|
||||
expect(buildServerProviderModelMap()[OPENAI_CODEX_PROVIDER]).toContain(GPT_5_5_MODEL)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user