Fix bridge history, profile models, and Windows gateway handling (#845)

* feat: support profile-aware group chat bridge flows

* feat: route cron jobs through hermes cli

* Fix group chat routing and isolate bridge tests

* Add Grok image-to-video media skill

* Default Grok videos to media directory

* Fix bridge profile fallback and cron repeat clearing

* Refine bridge chat and gateway platform handling

* Filter bridge tool-call text deltas

* Preserve structured bridge chat history

* Prepare beta release build artifacts

* Fix Windows run profile resolution

* Fix Windows path compatibility checks

* Fix profile-scoped model page display

* Hide Windows subprocess windows for jobs and updates

* Hide Windows file backend subprocess windows

* Avoid Windows gateway restart lock conflicts

* Treat Windows gateway lock as running on startup

* Force release Windows gateway lock on restart

* Tighten Windows gateway lock cleanup

* Update chat e2e source expectation

* Bump package version to 0.5.30

---------

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
ekko
2026-05-19 16:09:59 +08:00
committed by GitHub
parent 3d74d78698
commit 9a9416c99c
129 changed files with 7017 additions and 1838 deletions
+12 -18
View File
@@ -33,7 +33,7 @@
### AI Chat
- Real-time chat streaming over Socket.IO `/chat-run`; API Server runs consume Hermes Gateway streaming responses
- Real-time chat streaming over Socket.IO `/chat-run`; chat runs execute through the Hermes agent bridge
- Multi-session management — create, rename, delete, switch between sessions
- **Self-built session database** — local SQLite storage for Web UI sessions; Hermes state.db remains a read-only source for Hermes history APIs
- Session grouping by source (Telegram, Discord, Slack, etc.) with collapsible accordion
@@ -64,7 +64,6 @@ Unified configuration for **8 platforms** in one page:
- Credential management writes to `~/.hermes/.env`
- Channel behavior settings write to `~/.hermes/config.yaml`
- Auto gateway restart on config change
- Per-platform configured/unconfigured status detection
### Usage Analytics
@@ -90,13 +89,11 @@ Unified configuration for **8 platforms** in one page:
- Provider URL auto-detection for non-v1 API versions (e.g. `/v4`)
- Provider-level model grouping with default model switching
### Multi-Profile & Gateway
### Multi-Profile
- Create, rename, delete, and switch between Hermes profiles
- Clone existing profile or import from archive (`.tar.gz`)
- Export profile for backup or sharing
- Multi-gateway management — start, stop, and monitor gateway per profile
- Auto port conflict resolution
- Profile-scoped configuration and cache isolation
### File Browser
@@ -125,7 +122,7 @@ Unified configuration for **8 platforms** in one page:
### Logs
- View agent / gateway / error logs
- View agent / server / error logs
- Filter by log level, log file, and keyword
- Structured log parsing with HTTP access log highlighting
@@ -143,7 +140,7 @@ Unified configuration for **8 platforms** in one page:
- Session reset (idle timeout, scheduled reset)
- Privacy (PII redaction)
- Model settings (default model & provider)
- API server configuration
- Profile and provider configuration
### Web Terminal
@@ -180,7 +177,7 @@ bash <(curl -fsSL https://raw.githubusercontent.com/EKKOLearnAI/hermes-web-ui/ma
hermes-web-ui start
```
> WSL auto-detects and uses `hermes gateway run` for background startup (no launchd/systemd).
> WSL uses the same Web UI daemon startup flow as other local installs; no separate gateway service is started by Web UI.
### Docker Compose
@@ -232,8 +229,6 @@ These variables configure Hermes Web UI itself. Provider API keys and Hermes Age
| `MAX_DOWNLOAD_SIZE` | `200MB` | Maximum file download size. |
| `MAX_EDIT_SIZE` | `10MB` | Maximum editable file size. |
| `WORKSPACE_BASE` | `/opt/data/workspace` | Base directory for workspace browsing. |
| `GATEWAY_HOST` | `127.0.0.1` | Default gateway host written into profile config. |
| `HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN` | environment-dependent | Whether Web UI shutdown also stops managed gateways. |
### CLI Commands
@@ -255,10 +250,8 @@ These variables configure Hermes Web UI itself. Provider API keys and Hermes Age
On startup the BFF server automatically:
- Validates `~/.hermes/config.yaml` and fills missing `api_server` fields
- Backs up original config to `config.yaml.bak` if modified
- Detects and starts the gateway if needed
- Resolves port conflicts (kills stale processes)
- Initializes Web UI data directories, local databases, and bundled skills
- Starts the Hermes agent bridge used by `/chat-run`
- Opens browser on successful startup
---
@@ -273,7 +266,7 @@ npm run dev
```
- Frontend: http://localhost:5173
- BFF Server: http://localhost:8648 (proxies to Hermes on 8642)
- BFF Server: http://localhost:8648
```bash
npm run build # outputs to dist/
@@ -284,10 +277,11 @@ See [DEVELOPMENT.md](./DEVELOPMENT.md) for project development guidelines.
## Architecture
```
Browser → BFF (Koa, :8648) → Hermes Gateway (:8642)
Browser → BFF (Koa, :8648) → Socket.IO /chat-run
Hermes CLI (sessions, logs, version)
Hermes agent bridge → Hermes Agent runtime
Hermes CLI / profiles
~/.hermes/config.yaml (channel behavior)
~/.hermes/auth.json (credential pool)
Tencent iLink API (WeChat QR login)
@@ -295,7 +289,7 @@ Browser → BFF (Koa, :8648) → Hermes Gateway (:8642)
The frontend is designed with **multi-agent extensibility** — all Hermes-specific code is namespaced under `hermes/` directories (API, components, views, stores), making it straightforward to add new agent integrations alongside.
The BFF layer handles API proxy (with path rewriting), SSE streaming, file upload and download (multi-backend: local/Docker/SSH/Singularity), session CRUD via CLI, config/credential management, WeChat QR login, model discovery, skills/memory management, log reading, and static file serving.
The BFF layer handles Socket.IO chat streaming, the Hermes agent bridge, file upload and download (multi-backend: local/Docker/SSH/Singularity), session CRUD, config/credential management, WeChat QR login, model discovery, skills/memory management, log reading, and static file serving.
## Tech Stack
+12 -18
View File
@@ -41,7 +41,7 @@
### AI 聊天
- 聊天前端通过 Socket.IO `/chat-run` 实时流式更新;API Server 路径内部消费 Hermes Gateway 流式响应
- 聊天前端通过 Socket.IO `/chat-run` 实时流式更新;聊天运行通过 Hermes agent bridge 执行
- 多会话管理 — 创建、重命名、删除、切换会话
- **自建会话数据库** — Web UI 会话使用本地 SQLiteHermes state.db 仅作为只读来源用于 Hermes 历史 API
- 按来源分组会话(Telegram、Discord、Slack 等),可折叠手风琴面板
@@ -72,7 +72,6 @@
- 凭证管理写入 `~/.hermes/.env`
- 渠道行为设置写入 `~/.hermes/config.yaml`
- 配置变更后自动重启网关
- 每个平台已配置/未配置状态检测
### 用量分析
@@ -98,13 +97,11 @@
- Provider URL 自动检测,支持非 v1 API 版本(如 `/v4`
- Provider 级别模型分组,支持切换默认模型
### 多配置文件与网关
### 多配置文件
- 创建、重命名、删除、切换 Hermes 配置文件(Profile
- 克隆现有配置文件或从归档导入(`.tar.gz`
- 导出配置文件用于备份或分享
- 多网关管理 — 按 Profile 启动、停止、监控网关
- 自动端口冲突解决
- 配置文件级别的配置和缓存隔离
### 文件浏览器
@@ -133,7 +130,7 @@
### 日志
- 查看 Agent / Gateway / Error 日志
- 查看 Agent / Server / Error 日志
- 按日志级别、日志文件和关键词过滤
- 结构化日志解析,HTTP 访问日志高亮
@@ -151,7 +148,7 @@
- 会话重置(空闲超时、定时重置)
- 隐私(PII 脱敏)
- 模型设置(默认模型 & Provider
- API 服务器配置
- Profile 和 Provider 配置
### Web 终端
@@ -188,7 +185,7 @@ bash <(curl -fsSL https://cdn.jsdelivr.net/gh/EKKOLearnAI/hermes-web-ui@main/scr
hermes-web-ui start
```
> WSL 会自动检测并使用 `hermes gateway run` 进行后台启动(无需 launchd/systemd
> WSL 使用与其他本地安装相同的 Web UI 后台启动流程;Web UI 不再单独启动 gateway 服务
### Docker Compose
@@ -239,8 +236,6 @@ Web UI 启动后端聊天能力时,会优先使用包含 `run_agent.py` 的源
| `MAX_DOWNLOAD_SIZE` | `200MB` | 最大文件下载大小。 |
| `MAX_EDIT_SIZE` | `10MB` | 最大可编辑文件大小。 |
| `WORKSPACE_BASE` | `/opt/data/workspace` | Workspace 浏览根目录。 |
| `GATEWAY_HOST` | `127.0.0.1` | 写入 profile config 的默认 gateway host。 |
| `HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN` | 视环境而定 | Web UI 关闭时是否同时停止托管的 gateways。 |
### CLI 命令
@@ -262,10 +257,8 @@ Web UI 启动后端聊天能力时,会优先使用包含 `run_agent.py` 的源
启动时 BFF 服务器会自动:
- 校验 `~/.hermes/config.yaml` 并补全缺失的 `api_server` 字段
- 修改时备份原配置到 `config.yaml.bak`
- 检测并启动网关(如未运行)
- 解决端口冲突(清理残留进程)
- 初始化 Web UI 数据目录、本地数据库和内置技能
- 启动 `/chat-run` 使用的 Hermes agent bridge
- 启动成功后自动打开浏览器
---
@@ -280,7 +273,7 @@ npm run dev
```
- 前端:http://localhost:5173
- BFF 服务器:http://localhost:8648(代理到 Hermes 网关 8642
- BFF 服务器:http://localhost:8648
```bash
npm run build # 构建输出到 dist/
@@ -291,10 +284,11 @@ npm run build # 构建输出到 dist/
## 架构
```
浏览器 → BFF (Koa, :8648) → Hermes 网关 (:8642)
浏览器 → BFF (Koa, :8648) → Socket.IO /chat-run
Hermes CLI (会话、日志、版本)
Hermes agent bridge → Hermes Agent runtime
Hermes CLI / profiles
~/.hermes/config.yaml (渠道行为配置)
~/.hermes/auth.json (凭证池)
腾讯 iLink API (微信扫码登录)
@@ -302,7 +296,7 @@ npm run build # 构建输出到 dist/
前端采用 **多 Agent 可扩展架构** — 所有 Hermes 相关代码都按命名空间组织在 `hermes/` 目录下(API、组件、视图、Store),可以方便地并行接入新的 Agent。
BFF 层负责:API 代理(含路径重写)、SSE 流式推送、文件上传与下载(多 Backend 支持:local/Docker/SSH/Singularity)、通过 CLI 管理会话 CRUD、配置/凭证管理、微信扫码登录、模型发现、技能/记忆管理、日志读取和静态文件服务。
BFF 层负责:Socket.IO 聊天流式推送、Hermes agent bridge、文件上传与下载(多 Backend 支持:local/Docker/SSH/Singularity)、会话 CRUD、配置/凭证管理、微信扫码登录、模型发现、技能/记忆管理、日志读取和静态文件服务。
## 技术栈
+439
View File
@@ -0,0 +1,439 @@
# 今日改动测试用例
日期:2026-05-18
## 基础检查
### TC-001 类型检查
步骤:
1. 在项目根目录执行 `npx tsc --noEmit -p packages/server/tsconfig.json`
2. 执行 `npx vue-tsc -b --noEmit`
期望:
- 两个命令都通过。
- 没有新增 TypeScript 编译错误。
### TC-002 启动服务
步骤:
1. 启动本地开发服务。
2. 打开 `http://localhost:5173`
3. 观察控制台和服务端日志。
期望:
- Vite 和 server 正常启动。
- 不出现 `ECONNREFUSED 127.0.0.1:8648` 之外的持续异常。
- 页面可以正常进入 Hermes。
## Profile 与模型
### TC-010 available-models 返回多 profile 合集
步骤:
1. 准备至少两个 profile,每个 profile 配置不同 provider/model。
2. 请求 `GET /api/hermes/available-models`
3. 检查返回模型列表。
期望:
- 返回所有有效 profile 的 provider/model 合集。
- 需要远程拉模型的 provider 按 base URL 去重请求。
- 默认模型优先使用当前 active profile 的默认配置。
### TC-011 新建对话选择 profile 和模型
步骤:
1. 点击新建对话。
2. 在弹窗选择 profile、provider、model。
3. 发送第一条消息。
期望:
- 新建时会把选择的 profile/provider/model 带到后端。
- 不依赖前端长期 state 存储 provider/model。
- 聊天使用选择的 profile 启动。
### TC-012 Sidebar 模型切换
步骤:
1. 在 sidebar 切换当前会话模型。
2. 等待接口返回。
3. 刷新页面或重新打开会话。
期望:
- UI 不会自动跳回旧模型。
- 当前会话继续显示新模型。
- 后续请求使用新模型。
## 单聊 Bridge 与上下文压缩
### TC-020 多 profile bridge worker
步骤:
1. 使用 default profile 发起一次聊天。
2. 切换到另一个 profile 发起聊天。
3. 查看 bridge 日志。
期望:
- 不会因为切换 profile 杀掉其他 profile 的 worker。
- `chat``destroy` 日志中的 profile、profile_dir、config 路径匹配实际会话 profile。
### TC-021 强制上下文压缩使用会话模型
步骤:
1. 创建一个非 default profile 的会话。
2. 设置不同 provider/model/context_length。
3. 触发上下文压缩。
4. 查看日志和压缩请求。
期望:
- context_length 依据当前 session 的 profile/provider/model 获取。
- 获取顺序为 sqlite 会话信息、profile 配置、硬编码 fallback。
- 压缩请求通过 `source=api_server` 走 bridge。
- Web UI 本地数据库不写入压缩会话记录。
### TC-022 指令压缩
步骤:
1. 在单聊中执行压缩相关指令。
2. 使用非 default profile 会话重复执行。
期望:
- 指令压缩同样使用当前 session 的 profile/provider/model。
- 不固定使用 default 模型。
- 不污染正常聊天历史。
## Session 列表与历史
### TC-030 Session 列表合并
步骤:
1. 使用多个 profile 创建会话。
2. 打开会话列表。
3. 使用 profile 过滤下拉。
期望:
- 默认显示所有有效 profile 下的会话。
- 传入 profile 过滤时只显示该 profile 会话。
- 已删除 profile 的旧会话被过滤,不再进入后报错。
### TC-031 Chat 列表 profile 信息
步骤:
1. 打开普通聊天会话列表。
2. 查看每条 session item。
期望:
- 普通 chat session item 显示 profile 头像和 profile 名称。
- profile 信息位于模型和日期下方。
- history 页面不显示 profile 信息。
### TC-032 History profile 过滤
步骤:
1. 打开历史页面。
2. 查看顶部说明和 profile 下拉。
3. 切换 “只显示当前 profile”。
期望:
- 原描述文案被替换为 profile 过滤控件。
- “All Profiles” 已国际化。
- history 列表按过滤条件变化。
## 删除会话
### TC-040 单个删除同步 Hermes
步骤:
1. 创建一个 Hermes 侧存在的会话。
2. 在 Web UI session 列表删除单条会话。
3. 查看本地 DB 和 Hermes profile 侧数据。
期望:
- Web UI 本地会话被删除。
- 如果 Hermes 对应 profile 下存在该 session,也同步删除。
- profile 缺失或 Hermes 侧不存在时不报错。
### TC-041 批量删除同步 Hermes
步骤:
1. 选择多个 session,覆盖不同 profile。
2. 点击批量删除。
3. 在确认弹窗确认。
期望:
- 确认弹窗显示 loading。
- 每条会话按自己的 profile 删除 Hermes 侧数据。
- 批量删除期间 UI 不重复提交。
- 部分 Hermes 删除失败时,本地删除逻辑不被无关 profile 阻塞。
## 群聊基础
### TC-050 群聊清空消息
步骤:
1. 进入群聊房间并发送几条消息。
2. 清空群聊消息。
3. 再发起一次群聊。
期望:
- 消息被清空。
- room 生成新的 sessionId/sessionSeed。
- 后续 agent run 不复用旧 session。
### TC-051 群聊并发触发
步骤:
1. 在同一条用户消息里 @ 多个 agent。
2. 观察多个 agent 回复。
3. 在某个 agent 回复未结束时再次 @ 同一个 agent。
期望:
- 不同 agent 可以并发回复。
- 同一个 agent 串行处理。
- 同一 agent 忙时新 mention 进入该 agent 的队列,最终只处理最新一条排队消息。
### TC-052 群聊 source 使用 api_server
步骤:
1. 在群聊中 @ agent。
2. 查看服务端日志和 bridge 请求。
期望:
- 群聊 agent 调用 source 为 `api_server`
- 不再走 cli source。
## 群聊流式与消息入库
### TC-060 群聊流式输出
步骤:
1. @ 一个 agent 并观察回复过程。
2. 刷新前查看 UI。
3. 刷新后再次查看消息。
期望:
- agent 回复流式显示。
- 流式结束前不落库空 content 占位消息。
- 刷新后不会出现空 assistant 消息。
- 完成后 loading/thinking 状态消失。
### TC-061 toolcall/toolresult 展示
步骤:
1. 让 agent 执行一个工具调用。
2. 查看群聊消息气泡。
3. 展开工具详情。
期望:
- toolcall 和 toolresult 合并成一条工具消息展示。
- 工具消息显示头像和 agent 名称。
- 工具样式与单聊一致。
- 参数和结果有截断,长内容不撑破 UI。
- `hermes_show_tool_calls` 只影响群聊自身可见性,不影响单聊常显规则。
### TC-062 toolcall 顺序
步骤:
1. 让 agent 回复中先说一句话,再调用工具,再继续回复。
2. 查看 UI 和 `group-chat-history-preview.json`
期望:
- 工具调用前的普通文本保留在 toolcall 前面。
- toolcall/toolresult 不被错误插到最终回复下面。
- 最终 agent 回复不会丢失。
### TC-063 入库原子性
步骤:
1. 同时 @ 多个 agent。
2. 等待多个 agent 回复完成。
3. 查看 `gc_messages`
期望:
- 每个 agent 的一次回复作为完整消息落库。
- 不出现谁先完成谁把别人的消息合并进同一条的情况。
- 工具消息和最终文本消息的归属正确。
## 群聊 History 组装
### TC-070 生成预览 JSON
步骤:
1. 在群聊产生用户消息、agent 回复、toolcall、toolresult。
2. 生成 `group-chat-history-preview.json`
3. 检查 JSON 顺序和 role。
期望:
- 当前 agent 自己的普通回复为 `assistant`
- 当前 agent 自己的 toolcall 为 `assistant`,内容格式为 `[Calling tool: name with arguments: ...]`
- toolresult 为 `user`
- 其他 agent 的回复、toolcall、toolresult 都作为 `user`
- 每条内容只带 `[发送者]:` 前缀,不生成 `[发送者 to 目标]:`
- 预览中的 `source``sourceRole``originalMessageId` 只用于调试,不发送给 bridge。
### TC-071 @User 清理
步骤:
1. 用户或 agent 消息中包含 `@User-dfd5fd`
2. 生成 history preview。
期望:
- 对应内容转换为 `[发送者]: 内容`
- body 中原始 `@User-dfd5fd` 被移除。
- history preview 中不出现 `[test to User-dfd5fd]:` 这种前缀。
### TC-072 群聊 prompt 约束
步骤:
1. 只 @ 一个 agent,让它回答普通问题。
2. 不要求它转交、邀请、询问其他成员。
期望:
- agent 不会主动 @ 其他人。
- 不会在结尾要求其他 agent 接力。
- 只有明确需要对方执行动作、提供信息、确认决策时才 @。
### TC-073 群聊 token 统计
步骤:
1. 群聊中产生多轮 user/assistant/tool 消息。
2. 请求 `GET /api/hermes/group-chat/rooms`
3. 对比房间 `totalTokens`
期望:
- token 估算逻辑与单聊一致,按 role/input/output/tool_calls 统计。
- 不是简单拼接 content/senderName 计算。
- snapshot 场景下统计不重复。
## 群聊附件与图片
### TC-080 用户发送图片
步骤:
1. 在群聊输入框上传或粘贴图片。
2. 输入文字并发送。
3. 查看本地 UI 和 agent 收到的内容。
期望:
- 用户消息不显示原始 JSON 数组。
- 图片以缩略图展示。
- 点击图片可以预览。
- 文本只显示 text block。
- 发送给 bridge 时图片转 base64,与单聊 ContentBlock[] 处理一致。
### TC-081 用户发送文件
步骤:
1. 在群聊发送普通文件。
2. 查看消息展示。
期望:
- 文件以文件附件样式展示。
- 不被错误当作纯文本 JSON 展示。
- 下载链接可用。
### TC-082 Windows 路径兼容
步骤:
1. 构造或上传一个路径形如 `C:\path\file.jpg` 的附件记录。
2. 查看群聊消息。
期望:
- 下载 URL 中路径被标准化为 `C:/path/file.jpg`
- 图片和文件都可以正常展示或下载。
## 群聊语音与操作栏
### TC-090 自动播放开关
步骤:
1. 打开群聊输入框的自动播放语音开关。
2. 让 agent 回复一条完整消息。
期望:
- 回复完成后触发语音播放。
- 不在流式未完成时播放半截内容。
- 设置与单聊共用 `autoPlaySpeech` 行为。
### TC-091 手动播放语音
步骤:
1. 点击群聊 assistant 消息底部语音按钮。
2. 再次点击暂停或恢复。
期望:
- 按当前 TTS provider 播放。
- WebSpeech、OpenAI、custom、edge、mimo 路径与单聊一致。
- 播放状态按钮图标变化。
### TC-092 呼吸灯和操作栏样式
步骤:
1. 播放群聊 assistant 消息语音。
2. 对比单聊消息播放态。
期望:
- 群聊气泡出现与单聊一致的呼吸灯动画。
- 群聊底部操作栏包含语音按钮、复制按钮、时间。
- 操作栏 hover 显示,移动端常显。
- 操作栏和气泡之间有合理间距,不贴边。
### TC-093 复制消息
步骤:
1. 点击群聊消息底部复制按钮。
2. 粘贴剪贴板内容。
期望:
- 复制的是当前气泡可读文本。
- ContentBlock[] 消息只复制文本部分,不复制图片 JSON。
- tool 消息不显示普通复制按钮。
## 群聊工具可见性
### TC-100 工具显示开关
步骤:
1. 在群聊输入框切换工具调用显示开关。
2. 触发一次工具调用。
期望:
- 关闭时隐藏已完成工具消息。
- 正在运行的工具消息仍可见,避免用户误以为卡住。
- 打开后工具消息恢复显示。
## 回归检查
### TC-110 单聊不受群聊改动影响
步骤:
1. 在普通单聊发送文本、图片、工具调用消息。
2. 播放语音并复制消息。
3. 触发上下文压缩。
期望:
- 单聊工具调用仍常显。
- 单聊图片展示、预览、base64 发送正常。
- 单聊语音呼吸灯和操作栏样式不变。
- 单聊压缩仍走正确 session profile/model。
### TC-111 已删除 profile 数据
步骤:
1. 创建一个 profile 并产生聊天记录。
2. 删除该 profile。
3. 打开 session 列表和历史页面。
期望:
- 不展示不属于当前全部有效 profile 的聊天记录。
- 不会因为进入旧会话请求缺失 profile 而报错。
### TC-112 多语言文案
步骤:
1. 切换到中文、英文、日文等语言。
2. 查看 profile 过滤选项。
期望:
- `All Profiles` 或对应翻译正常显示。
- 不出现缺失 i18n key。
+2
View File
@@ -7,6 +7,7 @@ services:
container_name: ${WEBUI_CONTAINER_NAME:-hermes-webui}
ports:
- "${PORT:-6060}:${PORT:-6060}"
- "${XAI_OAUTH_PORT:-56121}:56121"
volumes:
- ${HERMES_DATA_DIR:-./hermes_data}:/home/agent/.hermes
- ${HERMES_DATA_DIR:-./hermes_data}/hermes-web-ui:/home/agent/.hermes-web-ui
@@ -14,6 +15,7 @@ services:
- PORT=${PORT:-6060}
- HERMES_HOME=/home/agent/.hermes
- HERMES_BIN=/opt/hermes/.venv/bin/hermes
- HERMES_WEB_UI_XAI_CALLBACK_BIND_HOST=0.0.0.0
- 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
+1 -1
View File
@@ -417,7 +417,7 @@ bridge 启动失败不会阻止 Web UI 启动,但 Bridge(beta) 会话后续运
随后创建统一的 chat socket
```ts
chatRunServer = new ChatRunSocket(groupChatServer.getIO(), getGatewayManagerInstance())
chatRunServer = new ChatRunSocket(groupChatServer.getIO())
chatRunServer.init()
```
+4 -4
View File
@@ -26,7 +26,7 @@ This compose file runs a single service:
- `hermes-webui` — Web UI dashboard with integrated Hermes Agent runtime (pre-built image or built from source)
The Web UI container is built on the `nousresearch/hermes-agent` base image and internally manages the Hermes Agent gateway lifecycle via `GatewayManager`.
The Web UI container is built on the `nousresearch/hermes-agent` base image and uses the Hermes CLI / agent bridge runtime for chat execution. It does not start or manage a separate Hermes gateway process.
## Environment Variables
@@ -76,14 +76,14 @@ AUTH_DISABLED=false
|---|---|
| `${PORT}` (6060) | Web UI dashboard |
Hermes Agent gateway ports (8642-8670) are used internally within the container and are not exposed to the host.
No Hermes gateway ports are exposed by this compose setup.
## Code Runtime Behavior
- Hermes CLI binary comes from `HERMES_BIN` env (`packages/server/src/services/hermes-cli.ts`).
- If `HERMES_BIN` is not provided, code falls back to `hermes` in `PATH`.
- Profile switching dynamically resolves upstream URLs via `GatewayManager`.
- The Web UI automatically starts and manages the Hermes Agent gateway process on startup.
- Profile-specific chat runs are handled through the Hermes agent bridge.
- The Web UI does not automatically start or manage a Hermes Agent gateway process on startup.
## Common Operations
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hermes-web-ui",
"version": "0.5.28",
"version": "0.5.30",
"description": "Self-hosted AI chat dashboard for Hermes Agent — multi-model web UI with multi-platform integration",
"repository": {
"type": "git",
+1 -5
View File
@@ -1,5 +1,5 @@
import { io, type Socket } from 'socket.io-client'
import { request, getBaseUrlValue, getApiKey } from '../client'
import { getBaseUrlValue, getApiKey } from '../client'
export type ContentBlock =
| { type: 'text'; text: string }
@@ -616,7 +616,3 @@ export function startRunViaSocket(
},
}
}
export async function fetchModels(): Promise<{ data: Array<{ id: string }> }> {
return request('/api/hermes/v1/models')
}
+2 -1
View File
@@ -72,10 +72,11 @@ export async function fetchConfig(sections?: string[]): Promise<AppConfig> {
export async function updateConfigSection(
section: string,
values: Record<string, any>,
options?: { restart?: boolean },
): Promise<void> {
await request('/api/hermes/config', {
method: 'PUT',
body: JSON.stringify({ section, values }),
body: JSON.stringify({ section, values, ...options }),
})
}
@@ -1,39 +0,0 @@
import { request } from '../client'
export interface GatewayStatus {
profile: string
port: number
host: string
url: string
running: boolean
pid?: number
diagnostics?: {
pid_path: string
config_path: string
pid_file_exists: boolean
config_exists: boolean
health_url: string
health_checked_at: string
health_ok?: boolean
reason: string
}
}
export async function fetchGateways(): Promise<GatewayStatus[]> {
const res = await request<{ gateways: GatewayStatus[] }>('/api/hermes/gateways')
return res.gateways
}
export async function startGateway(name: string): Promise<GatewayStatus> {
const res = await request<{ success: boolean; gateway: GatewayStatus }>(`/api/hermes/gateways/${name}/start`, { method: 'POST' })
return res.gateway
}
export async function stopGateway(name: string): Promise<void> {
await request(`/api/hermes/gateways/${name}/stop`, { method: 'POST' })
}
export async function checkGatewayHealth(name: string): Promise<GatewayStatus> {
const res = await request<{ gateway: GatewayStatus }>(`/api/hermes/gateways/${name}/health`)
return res.gateway
}
@@ -30,6 +30,22 @@ export interface ChatMessage {
senderName: string
content: string
timestamp: number
role?: string
tool_call_id?: string | null
tool_calls?: any[] | null
tool_name?: string | null
finish_reason?: string | null
reasoning?: string | null
reasoning_details?: string | null
reasoning_content?: string | null
isStreaming?: boolean
toolName?: string
toolCallId?: string
toolArgs?: string
toolPreview?: string
toolResult?: string
toolStatus?: 'running' | 'done' | 'error'
attachments?: Array<{ id: string; name: string; type: string; size: number; url: string }>
}
export interface MemberInfo {
@@ -4,7 +4,6 @@ export interface HermesProfile {
name: string
active: boolean
model: string
gateway: string
alias: string
}
@@ -13,7 +12,6 @@ export interface HermesProfileDetail {
path: string
model: string
provider: string
gateway: string
skills: number
hasEnv: boolean
hasSoulMd: boolean
+6 -2
View File
@@ -2,6 +2,7 @@ import { request, getApiKey, getBaseUrlValue } from '../client'
export interface SessionSummary {
id: string
profile?: string
source: string
model: string
provider?: string
@@ -48,10 +49,11 @@ export interface HermesMessage {
reasoning: string | null
}
export async function fetchSessions(source?: string, limit?: number): Promise<SessionSummary[]> {
export async function fetchSessions(source?: string, limit?: number, profile?: string): Promise<SessionSummary[]> {
const params = new URLSearchParams()
if (source) params.set('source', source)
if (limit) params.set('limit', String(limit))
if (profile) params.set('profile', profile)
const query = params.toString()
const res = await request<{ sessions: SessionSummary[] }>(`/api/hermes/sessions${query ? `?${query}` : ''}`)
return res.sessions
@@ -231,9 +233,11 @@ export async function fetchSessionUsageSingle(id: string): Promise<{ input_token
}
}
export async function fetchContextLength(profile?: string): Promise<number> {
export async function fetchContextLength(profile?: string, provider?: string, model?: string): Promise<number> {
const params = new URLSearchParams()
if (profile) params.set('profile', profile)
if (provider) params.set('provider', provider)
if (model) params.set('model', model)
const query = params.toString()
const res = await request<{ context_length: number }>(`/api/hermes/sessions/context-length${query ? `?${query}` : ''}`)
return res.context_length
+20 -2
View File
@@ -45,11 +45,19 @@ export interface AvailableModelGroup {
model_meta?: Record<string, { preview?: boolean; disabled?: boolean; alias?: string }>
}
export interface ProfileAvailableModels {
profile: string
default: string
default_provider: string
groups: AvailableModelGroup[]
}
export interface AvailableModelsResponse {
default: string
default_provider: string
groups: AvailableModelGroup[]
allProviders: AvailableModelGroup[]
profiles?: ProfileAvailableModels[]
/** Web UI-only display aliases keyed by provider -> canonical model ID. */
model_aliases?: Record<string, Record<string, string>>
model_visibility?: ModelVisibility
@@ -76,8 +84,18 @@ export async function fetchConfigModels(): Promise<ConfigModelsResponse> {
return request<ConfigModelsResponse>('/api/hermes/config/models')
}
export async function fetchAvailableModels(): Promise<AvailableModelsResponse> {
return request<AvailableModelsResponse>('/api/hermes/available-models')
function currentProfileName(): string {
try {
return localStorage.getItem('hermes_active_profile_name') || 'default'
} catch {
return 'default'
}
}
export async function fetchAvailableModels(profile = currentProfileName()): Promise<AvailableModelsResponse> {
const params = new URLSearchParams()
params.set('profile', profile || 'default')
return request<AvailableModelsResponse>(`/api/hermes/available-models?${params.toString()}`)
}
export async function fetchProviderModels(data: {
@@ -161,9 +161,8 @@ async function saveContextLimit() {
isSavingContextLimit.value = true
try {
const appStore = useAppStore()
const provider = appStore.selectedProvider || ''
const model = appStore.selectedModel || ''
const provider = chatStore.activeSession?.provider || useAppStore().selectedProvider || ''
const model = chatStore.activeSession?.model || useAppStore().selectedModel || ''
if (!provider || !model) {
message.error(t('chat.contextEditFailed'))
@@ -183,8 +182,13 @@ async function saveContextLimit() {
async function loadContextLength() {
try {
const profile = useProfilesStore().activeProfileName || undefined
contextLength.value = await fetchContextLength(profile)
const activeSession = chatStore.activeSession
const profile = activeSession?.profile || useProfilesStore().activeProfileName || undefined
contextLength.value = await fetchContextLength(
profile,
activeSession?.provider || undefined,
activeSession?.model || undefined,
)
} catch {
contextLength.value = FALLBACK_CONTEXT
}
@@ -192,7 +196,12 @@ async function loadContextLength() {
onMounted(loadContextLength)
watch(() => useProfilesStore().activeProfileName, loadContextLength)
watch(() => useAppStore().selectedProvider, loadContextLength)
watch(() => useAppStore().selectedModel, loadContextLength)
watch(() => chatStore.activeSession?.id, loadContextLength)
watch(() => chatStore.activeSession?.profile, loadContextLength)
watch(() => chatStore.activeSession?.provider, loadContextLength)
watch(() => chatStore.activeSession?.model, loadContextLength)
const totalTokens = computed(() => {
const input = chatStore.activeSession?.inputTokens ?? 0
@@ -2,6 +2,7 @@
import { renameSession, setSessionWorkspace, batchDeleteSessions, exportSession } from "@/api/hermes/sessions";
import { useChatStore, type Session } from "@/stores/hermes/chat";
import { useAppStore } from "@/stores/hermes/app";
import { useProfilesStore } from "@/stores/hermes/profiles";
import { useSessionBrowserPrefsStore } from "@/stores/hermes/session-browser-prefs";
import {
NButton,
@@ -16,7 +17,6 @@ import {
} from "naive-ui";
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { getSourceLabel } from "@/shared/session-display";
import { copyToClipboard } from "@/utils/clipboard";
import FolderPicker from "./FolderPicker.vue";
import ChatInput from "./ChatInput.vue";
@@ -28,6 +28,7 @@ import OutlinePanel from "./OutlinePanel.vue";
const chatStore = useChatStore();
const appStore = useAppStore();
const profilesStore = useProfilesStore();
const sessionBrowserPrefsStore = useSessionBrowserPrefsStore();
const message = useMessage();
const { t } = useI18n();
@@ -41,6 +42,8 @@ const currentMode = ref<"chat" | "live">("chat");
// Batch selection mode
const isBatchMode = ref(false);
const selectedSessionIds = ref<Set<string>>(new Set());
const showBatchDeleteConfirm = ref(false);
const isBatchDeleting = ref(false);
// Initialize synchronously from the media query so first paint is correct.
// On narrow viewports the session list is an absolute-positioned overlay
@@ -71,6 +74,9 @@ onMounted(() => {
mobileQuery = window.matchMedia("(max-width: 768px)");
handleMobileChange(mobileQuery);
mobileQuery.addEventListener("change", handleMobileChange);
if (profilesStore.profiles.length === 0) {
void profilesStore.fetchProfiles();
}
});
onUnmounted(() => {
@@ -80,15 +86,18 @@ const showRenameModal = ref(false);
const renameValue = ref("");
const renameSessionId = ref<string | null>(null);
const renameInputRef = ref<InstanceType<typeof NInput> | null>(null);
const collapsedGroups = ref<Set<string>>(
new Set(JSON.parse(localStorage.getItem("hermes_collapsed_groups") || "[]")),
);
const sessionProfileFilter = ref<string | null>(null);
const profileFilterOptions = computed(() => [
{ label: t("chat.allProfiles"), value: "__all__" },
...profilesStore.profiles.map((profile) => ({
label: profile.name,
value: profile.name,
})),
]);
// Source sort order: api_server first, cron last, others alphabetical
function sourceSortKey(source: string): number {
if (source === "api_server") return -1;
if (source === "cron") return 999;
return 0;
async function handleProfileFilterChange(value: string) {
sessionProfileFilter.value = value === "__all__" ? null : value;
await chatStore.loadSessions(sessionProfileFilter.value);
}
function sortSessionsWithActiveFirst(items: Session[]): Session[] {
@@ -97,13 +106,6 @@ function sortSessionsWithActiveFirst(items: Session[]): Session[] {
});
}
// Group sessions by source, with sort order
interface SessionGroup {
source: string;
label: string;
sessions: Session[];
}
const pinnedSessions = computed(() =>
sortSessionsWithActiveFirst(
chatStore.sessions.filter((session) =>
@@ -112,80 +114,12 @@ const pinnedSessions = computed(() =>
),
);
const groupedSessions = computed<SessionGroup[]>(() => {
const map = new Map<string, Session[]>();
for (const s of chatStore.sessions) {
if (sessionBrowserPrefsStore.isPinned(s.id)) continue;
const key = s.source || "";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(s);
}
const keys = [...map.keys()].sort((a, b) => {
const ka = sourceSortKey(a);
const kb = sourceSortKey(b);
if (ka !== kb) return ka - kb;
return a.localeCompare(b);
});
return keys.map((key) => ({
source: key,
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) {
collapsedGroups.value = new Set([...collapsedGroups.value, source]);
} else {
collapsedGroups.value = new Set(
groupedSessions.value.map((g) => g.source).filter((s) => s !== source),
);
const group = groupedSessions.value.find((g) => g.source === source);
if (group?.sessions.length) {
chatStore.switchSession(group.sessions[0].id);
}
}
localStorage.setItem(
"hermes_collapsed_groups",
JSON.stringify([...collapsedGroups.value]),
);
}
watch(
groupedSessions,
(groups) => {
if (localStorage.getItem("hermes_collapsed_groups") !== null) {
const activeSource = chatStore.activeSession?.source;
if (activeSource && collapsedGroups.value.has(activeSource)) {
collapsedGroups.value = new Set(
[...collapsedGroups.value].filter(
(source) => source !== activeSource,
const unpinnedSessions = computed(() =>
sortSessionsWithActiveFirst(
chatStore.sessions.filter(
(session) => !sessionBrowserPrefsStore.isPinned(session.id),
),
),
);
localStorage.setItem(
"hermes_collapsed_groups",
JSON.stringify([...collapsedGroups.value]),
);
}
return;
}
collapsedGroups.value = new Set(
groups.slice(1).map((group) => group.source),
);
localStorage.setItem(
"hermes_collapsed_groups",
JSON.stringify([...collapsedGroups.value]),
);
},
{ once: true },
);
watch(
@@ -211,39 +145,110 @@ const headerTitle = computed(() =>
: activeSessionTitle.value,
);
const activeSessionSource = computed(() =>
currentMode.value === "chat" ? chatStore.activeSession?.source || "" : "",
);
const activeApproval = computed(() => chatStore.activePendingApproval);
const visibleApproval = computed(() => activeApproval.value);
const showNewChatModal = ref(false);
const newChatProfile = ref<string>("default");
const newChatProvider = ref<string>("");
const newChatModel = ref<string>("");
const newChatLoading = ref(false);
function handleNewChat() {
chatStore.newChat();
function getModelGroupsForProfile(profile: string) {
const profileModels = appStore.profileModelGroups.find(
(entry) => entry.profile === profile,
);
return profileModels?.groups?.length ? profileModels.groups : appStore.modelGroups;
}
function handleNewCliChat() {
const session = chatStore.newCliSession()
chatStore.switchSession(session.id)
function getDefaultModelForProfile(profile: string) {
const groups = getModelGroupsForProfile(profile);
const profileModels = appStore.profileModelGroups.find(
(entry) => entry.profile === profile,
);
const defaultProvider = profileModels?.default_provider || "";
const defaultModel = profileModels?.default || "";
const providerGroup = defaultProvider
? groups.find((group) => group.provider === defaultProvider)
: undefined;
const fallbackGroup = providerGroup || groups.find((group) => group.models.length > 0);
return {
provider: fallbackGroup?.provider || "",
model: fallbackGroup?.models.includes(defaultModel)
? defaultModel
: fallbackGroup?.models[0] || "",
};
}
const newChatOptions = computed(() => [
{
label: "API",
key: "api_server",
},
{
label: "Bridge (beta)",
key: "cli",
},
]);
const newChatProfileOptions = computed(() =>
(profilesStore.profiles.length > 0 ? profilesStore.profiles : [{ name: "default" }]).map((profile) => ({
label: profile.name,
value: profile.name,
})),
);
function handleNewChatSelect(key: string | number) {
if (key === "cli") {
handleNewCliChat();
return;
const newChatModelGroups = computed(() => {
return getModelGroupsForProfile(newChatProfile.value);
});
const newChatProviderOptions = computed(() =>
newChatModelGroups.value.map((group) => ({
label: group.label || group.provider,
value: group.provider,
})),
);
const newChatModelOptions = computed(() => {
const group = newChatModelGroups.value.find(
(item) => item.provider === newChatProvider.value,
);
return (group?.models || []).map((model) => ({
label: appStore.displayModelName(model, group?.provider),
value: model,
}));
});
function syncNewChatModelSelection() {
const defaults = getDefaultModelForProfile(newChatProfile.value);
newChatProvider.value = defaults.provider;
newChatModel.value = defaults.model;
}
async function openNewChatModal() {
showNewChatModal.value = true;
newChatLoading.value = true;
try {
if (profilesStore.profiles.length === 0) await profilesStore.fetchProfiles();
if (appStore.modelGroups.length === 0 && appStore.profileModelGroups.length === 0) {
await appStore.loadModels();
}
handleNewChat();
newChatProfile.value =
profilesStore.activeProfileName ||
profilesStore.profiles.find((profile) => profile.active)?.name ||
profilesStore.profiles[0]?.name ||
"default";
syncNewChatModelSelection();
} finally {
newChatLoading.value = false;
}
}
function handleNewChatProfileChange(value: string) {
newChatProfile.value = value;
syncNewChatModelSelection();
}
function handleNewChatProviderChange(value: string) {
newChatProvider.value = value;
newChatModel.value = newChatModelOptions.value[0]?.value || "";
}
function confirmNewChat() {
chatStore.newChat({
profile: newChatProfile.value,
provider: newChatProvider.value,
model: newChatModel.value,
});
showNewChatModal.value = false;
}
function handleApproval(choice: "once" | "session" | "always" | "deny") {
@@ -266,19 +271,25 @@ function handleDeleteSession(id: string) {
}
function toggleBatchMode() {
if (isBatchDeleting.value) return;
isBatchMode.value = !isBatchMode.value;
if (!isBatchMode.value) {
selectedSessionIds.value.clear();
showBatchDeleteConfirm.value = false;
}
}
function toggleSessionSelection(id: string) {
if (isBatchDeleting.value) return;
if (selectedSessionIds.value.has(id)) {
selectedSessionIds.value.delete(id);
} else {
selectedSessionIds.value.add(id);
}
selectedSessionIds.value = new Set(selectedSessionIds.value);
if (selectedSessionIds.value.size === 0) {
showBatchDeleteConfirm.value = false;
}
}
function isSessionSelected(id: string): boolean {
@@ -286,9 +297,10 @@ function isSessionSelected(id: string): boolean {
}
async function handleBatchDelete() {
if (selectedSessionIds.value.size === 0) return;
if (selectedSessionIds.value.size === 0 || isBatchDeleting.value) return;
const ids = Array.from(selectedSessionIds.value);
isBatchDeleting.value = true;
try {
const result = await batchDeleteSessions(ids);
if (result.deleted > 0) {
@@ -311,12 +323,20 @@ async function handleBatchDelete() {
} catch (err: any) {
message.error(t("chat.batchDeleteFailed"));
} finally {
isBatchDeleting.value = false;
showBatchDeleteConfirm.value = false;
isBatchMode.value = false;
selectedSessionIds.value.clear();
}
}
function handleBatchDeleteConfirm() {
void handleBatchDelete();
return false;
}
function selectAllSessions() {
if (isBatchDeleting.value) return;
selectedSessionIds.value.clear();
for (const session of chatStore.sessions) {
if (session.id !== chatStore.activeSessionId) {
@@ -502,12 +522,21 @@ const sessionModelProvider = ref("");
const sessionModelCustomInput = ref("");
const sessionModelCustomProvider = ref("");
const sessionModelProfile = computed(() => {
const session = chatStore.sessions.find((s) => s.id === sessionModelSessionId.value);
return session?.profile || profilesStore.activeProfileName || "default";
});
const sessionModelBaseGroups = computed(() =>
getModelGroupsForProfile(sessionModelProfile.value),
);
const sessionModelProviderOptions = computed(() =>
appStore.modelGroups.map((group) => ({ label: group.label, value: group.provider })),
sessionModelBaseGroups.value.map((group) => ({ label: group.label, value: group.provider })),
);
const sessionModelGroupsWithCustom = computed(() =>
appStore.modelGroups.map((group) => ({
sessionModelBaseGroups.value.map((group) => ({
...group,
models: [
...group.models,
@@ -534,9 +563,10 @@ const filteredSessionModelGroups = computed(() => {
function openSessionModelModal(sessionId: string) {
const session = chatStore.sessions.find((s) => s.id === sessionId);
const defaults = getDefaultModelForProfile(session?.profile || profilesStore.activeProfileName || "default");
sessionModelSessionId.value = sessionId;
sessionModelValue.value = session?.model || appStore.selectedModel || "";
sessionModelProvider.value = session?.provider || appStore.selectedProvider || "";
sessionModelValue.value = session?.model || defaults.model || "";
sessionModelProvider.value = session?.provider || defaults.provider || "";
sessionModelCustomProvider.value = sessionModelProvider.value;
sessionModelSearch.value = "";
sessionModelCustomInput.value = "";
@@ -565,7 +595,7 @@ function sessionModelAlias(model: string, provider: string) {
}
async function selectSessionModel(model: string, provider: string) {
const meta = appStore.modelGroups.find((group) => group.provider === provider)?.model_meta?.[model];
const meta = sessionModelBaseGroups.value.find((group) => group.provider === provider)?.model_meta?.[model];
if (meta?.disabled || !sessionModelSessionId.value) return;
const ok = await chatStore.switchSessionModel(model, provider, sessionModelSessionId.value);
if (ok) {
@@ -643,7 +673,7 @@ async function handleSessionModelCustomSubmit() {
quaternary
size="tiny"
@click="selectAllSessions"
:disabled="!canSelectAll"
:disabled="!canSelectAll || isBatchDeleting"
:title="t('chat.selectAll')"
>
<template #icon>
@@ -662,10 +692,13 @@ async function handleSessionModelCustomSubmit() {
</NButton>
<NPopconfirm
v-if="isBatchMode && selectedCount > 0"
@positive-click="handleBatchDelete"
v-model:show="showBatchDeleteConfirm"
:positive-button-props="{ loading: isBatchDeleting, disabled: isBatchDeleting }"
:negative-button-props="{ disabled: isBatchDeleting }"
@positive-click="handleBatchDeleteConfirm"
>
<template #trigger>
<NButton quaternary size="tiny" type="error">
<NButton quaternary size="tiny" type="error" :loading="isBatchDeleting" :disabled="isBatchDeleting">
<template #icon>
<svg
width="14"
@@ -688,6 +721,7 @@ async function handleSessionModelCustomSubmit() {
quaternary
size="tiny"
@click="toggleBatchMode"
:disabled="isBatchDeleting"
>
<template #icon>
<svg
@@ -703,12 +737,7 @@ async function handleSessionModelCustomSubmit() {
</svg>
</template>
</NButton>
<NDropdown
trigger="click"
:options="newChatOptions"
@select="handleNewChatSelect"
>
<NButton quaternary size="tiny" circle>
<NButton quaternary size="tiny" circle @click="openNewChatModal">
<template #icon>
<svg
width="14"
@@ -723,14 +752,16 @@ async function handleSessionModelCustomSubmit() {
</svg>
</template>
</NButton>
</NDropdown>
</div>
</div>
<div v-if="showSessions" class="session-scope-note">
<span>{{ t("chat.sessionScopeHint") }}</span>
<RouterLink class="session-scope-link" :to="{ name: 'hermes.history' }">
{{ t("chat.openHistory") }}
</RouterLink>
<div v-if="showSessions" class="session-profile-filter">
<NSelect
:value="sessionProfileFilter || '__all__'"
:options="profileFilterOptions"
size="small"
:loading="profilesStore.loading"
@update:value="handleProfileFilterChange"
/>
</div>
<div v-if="showSessions" class="session-items">
<div
@@ -761,6 +792,7 @@ async function handleSessionModelCustomSubmit() {
:streaming="chatStore.isSessionLive(s.id)"
:selectable="isBatchMode"
:selected="isSessionSelected(s.id)"
:show-profile="true"
@select="handleSessionClick(s.id)"
@contextmenu="handleContextMenu($event, s.id)"
@delete="handleDeleteSession(s.id)"
@@ -768,26 +800,8 @@ async function handleSessionModelCustomSubmit() {
/>
</template>
<template v-for="group in groupedSessions" :key="group.source">
<div class="session-group-header" @click="toggleGroup(group.source)">
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="group-chevron"
:class="{ collapsed: collapsedGroups.has(group.source) }"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span class="session-group-label">{{ group.label }}</span>
<span class="session-group-count">{{ group.sessions.length }}</span>
</div>
<template v-if="!collapsedGroups.has(group.source)">
<SessionListItem
v-for="s in group.sessions"
v-for="s in unpinnedSessions"
:key="s.id"
:session="s"
:active="s.id === chatStore.activeSessionId"
@@ -799,13 +813,12 @@ async function handleSessionModelCustomSubmit() {
:streaming="chatStore.isSessionLive(s.id)"
:selectable="isBatchMode"
:selected="isSessionSelected(s.id)"
:show-profile="true"
@select="handleSessionClick(s.id)"
@contextmenu="handleContextMenu($event, s.id)"
@delete="handleDeleteSession(s.id)"
@toggle-select="toggleSessionSelection(s.id)"
/>
</template>
</template>
</div>
</aside>
@@ -946,6 +959,56 @@ async function handleSessionModelCustomSubmit() {
</div>
</NModal>
<NModal
v-model:show="showNewChatModal"
preset="card"
:title="t('chat.newChat')"
:style="{ width: 'min(440px, calc(100vw - 32px))' }"
:mask-closable="true"
>
<div class="new-chat-form">
<label class="new-chat-field">
<span class="new-chat-label">{{ t("sidebar.profiles") }}</span>
<NSelect
:value="newChatProfile"
:options="newChatProfileOptions"
:loading="newChatLoading || profilesStore.loading"
@update:value="handleNewChatProfileChange"
/>
</label>
<label class="new-chat-field">
<span class="new-chat-label">{{ t("models.provider") }}</span>
<NSelect
:value="newChatProvider"
:options="newChatProviderOptions"
:disabled="newChatLoading"
@update:value="handleNewChatProviderChange"
/>
</label>
<label class="new-chat-field">
<span class="new-chat-label">{{ t("models.models") }}</span>
<NSelect
v-model:value="newChatModel"
:options="newChatModelOptions"
:disabled="newChatLoading || !newChatProvider"
filterable
/>
</label>
</div>
<template #footer>
<div class="new-chat-actions">
<NButton @click="showNewChatModal = false">{{ t("common.cancel") }}</NButton>
<NButton
type="primary"
:disabled="!newChatProfile || !newChatProvider || !newChatModel"
@click="confirmNewChat"
>
{{ t("chat.newChat") }}
</NButton>
</div>
</template>
</NModal>
<div class="chat-main">
<header class="chat-header">
<div class="header-left">
@@ -973,9 +1036,6 @@ async function handleSessionModelCustomSubmit() {
</template>
</NButton>
<span class="header-session-title">{{ headerTitle }}</span>
<span v-if="activeSessionSource" class="source-badge">{{
getChatSourceLabel(activeSessionSource)
}}</span>
<span
v-if="chatStore.activeSession?.workspace"
class="workspace-badge"
@@ -1041,12 +1101,7 @@ async function handleSessionModelCustomSubmit() {
</template>
{{ t("chat.copySessionId") }}
</NTooltip>
<NDropdown
trigger="click"
:options="newChatOptions"
@select="handleNewChatSelect"
>
<NButton size="small" :circle="isMobile">
<NButton size="small" :circle="isMobile" @click="openNewChatModal">
<template #icon>
<svg
width="14"
@@ -1062,7 +1117,6 @@ async function handleSessionModelCustomSubmit() {
</template>
<template v-if="!isMobile">{{ t("chat.newChat") }}</template>
</NButton>
</NDropdown>
</template>
</div>
</header>
@@ -1460,27 +1514,32 @@ async function handleSessionModelCustomSubmit() {
line-height: 22px;
}
.session-scope-note {
margin: 0 12px 10px;
padding: 8px 10px;
border: 1px solid rgba($accent-primary, 0.16);
border-radius: $radius-sm;
background: rgba($accent-primary, 0.06);
color: $text-secondary;
font-size: 11px;
line-height: 1.45;
.session-profile-filter {
margin: 0 8px 10px;
}
.session-scope-link {
display: inline-block;
margin-left: 4px;
color: $accent-primary;
font-weight: 500;
text-decoration: none;
.new-chat-form {
display: flex;
flex-direction: column;
gap: 14px;
}
&:hover {
text-decoration: underline;
}
.new-chat-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.new-chat-label {
font-size: 12px;
color: $text-muted;
font-weight: 500;
}
.new-chat-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.session-group-header {
@@ -44,7 +44,7 @@ const currentToolCalls = computed(() => {
});
const visibleToolCalls = computed(() =>
toolTraceVisible.value ? currentToolCalls.value.filter((tool) => !!tool.toolName) : [],
currentToolCalls.value.filter((tool) => !!tool.toolName),
);
const displayMessages = computed(() => {
@@ -2,11 +2,12 @@
import { computed, ref, onUnmounted } from 'vue'
import { NPopconfirm, NCheckbox } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import multiavatar from '@multiavatar/multiavatar'
import type { Session } from '@/stores/hermes/chat'
import { useAppStore } from '@/stores/hermes/app'
import { formatTimestampMs } from '@/shared/session-display'
const props = defineProps<{
const props = withDefaults(defineProps<{
session: Session
active: boolean
pinned: boolean
@@ -14,7 +15,10 @@ const props = defineProps<{
streaming?: boolean
selectable?: boolean
selected?: boolean
}>()
showProfile?: boolean
}>(), {
showProfile: true,
})
const emit = defineEmits<{
select: []
@@ -30,6 +34,8 @@ const sessionModelName = computed(() =>
? appStore.displayModelName(props.session.model, props.session.provider)
: '',
)
const profileName = computed(() => props.session.profile || 'default')
const profileAvatar = computed(() => multiavatar(profileName.value))
let longPressTimer: ReturnType<typeof setTimeout> | null = null
const longPressTriggered = ref(false)
@@ -107,6 +113,10 @@ onUnmounted(() => {
<span v-if="sessionModelName" class="session-item-model" :title="session.model">{{ sessionModelName }}</span>
<span class="session-item-time">{{ formatTimestampMs(session.createdAt) }}</span>
</span>
<span v-if="props.showProfile" class="session-item-profile">
<span class="session-item-profile-avatar" v-html="profileAvatar" />
<span class="session-item-profile-name">{{ profileName }}</span>
</span>
</div>
<NPopconfirm v-if="canDelete && !selectable" @positive-click="emit('delete')">
<template #trigger>
@@ -118,3 +128,38 @@ onUnmounted(() => {
</NPopconfirm>
</button>
</template>
<style scoped>
.session-item-profile {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
margin-top: 4px;
}
.session-item-profile-avatar {
display: inline-flex;
width: 16px;
height: 16px;
flex: 0 0 16px;
border-radius: 50%;
overflow: hidden;
}
.session-item-profile-avatar :deep(svg) {
width: 16px;
height: 16px;
display: block;
}
.session-item-profile-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
line-height: 16px;
color: var(--text-muted);
}
</style>
@@ -15,8 +15,8 @@ const emit = defineEmits<{
const roomName = ref('')
const inviteCode = ref('')
const userName = ref('')
const description = ref('')
const userName = ref(localStorage.getItem('gc_user_name') || '')
const description = ref(localStorage.getItem('gc_user_description') || '')
const roomInput = ref<InputLikeInstance | null>(null)
const compression = ref({
@@ -1,17 +1,38 @@
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onUnmounted } from 'vue'
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { NButton } from 'naive-ui'
import { NButton, NSwitch, NTooltip } from 'naive-ui'
import { useGroupChatStore } from '@/stores/hermes/group-chat'
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
import type { Attachment } from '@/stores/hermes/chat'
const { t } = useI18n()
const emit = defineEmits<{ send: [content: string] }>()
const emit = defineEmits<{ send: [content: string, attachments?: Attachment[]] }>()
const store = useGroupChatStore()
const { toolTraceVisible, toggleToolTraceVisible } = useToolTraceVisibility()
const inputText = ref('')
const textareaRef = ref<HTMLTextAreaElement>()
const dropdownRef = ref<HTMLDivElement>()
const fileInputRef = ref<HTMLInputElement>()
const attachments = ref<Attachment[]>([])
const isDragging = ref(false)
const dragCounter = ref(0)
const isComposing = ref(false)
const autoPlaySpeech = ref(false)
onMounted(() => {
const saved = localStorage.getItem('autoPlaySpeech')
if (saved !== null) {
autoPlaySpeech.value = saved === 'true'
store.setAutoPlaySpeech(autoPlaySpeech.value)
}
})
watch(autoPlaySpeech, (value) => {
localStorage.setItem('autoPlaySpeech', String(value))
store.setAutoPlaySpeech(value)
})
// 自定义高度拖拽
const textareaHeight = ref<number | null>(null)
@@ -58,7 +79,7 @@ const filteredAgents = computed(() => {
return store.agents.filter(a => a.name.toLowerCase().includes(query))
})
const canSend = computed(() => !!inputText.value.trim())
const canSend = computed(() => !!inputText.value.trim() || attachments.value.length > 0)
// ─── Scroll active item into view ──────────────────────
@@ -199,10 +220,11 @@ function handleKeydown(e: KeyboardEvent) {
function handleSend() {
const content = inputText.value.trim()
if (!content) return
if (!content && attachments.value.length === 0) return
emit('send', content)
emit('send', content, attachments.value.length > 0 ? attachments.value : undefined)
inputText.value = ''
attachments.value = []
mentionActive.value = false
// 发送后重置到自定义高度(不清除拖拽状态)
}
@@ -256,11 +278,147 @@ function handleCompositionEnd() {
updateMentionState()
})
}
function addFile(file: File) {
if (attachments.value.find(a => a.name === file.name)) return
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
attachments.value.push({
id,
name: file.name,
type: file.type,
size: file.size,
url: URL.createObjectURL(file),
file,
})
}
function handleAttachClick() {
fileInputRef.value?.click()
}
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files) return
for (const file of input.files) addFile(file)
input.value = ''
}
function handlePaste(e: ClipboardEvent) {
const items = Array.from(e.clipboardData?.items || [])
const imageItems = items.filter(i => i.type.startsWith('image/'))
if (!imageItems.length) return
e.preventDefault()
for (const item of imageItems) {
const blob = item.getAsFile()
if (!blob) continue
const ext = item.type.split('/')[1] || 'png'
addFile(new File([blob], `pasted-${Date.now()}.${ext}`, { type: item.type }))
}
}
function handleDragOver(e: DragEvent) {
e.preventDefault()
}
function handleDragEnter(e: DragEvent) {
e.preventDefault()
if (e.dataTransfer?.types.includes('Files')) {
dragCounter.value++
isDragging.value = true
}
}
function handleDragLeave() {
dragCounter.value--
if (dragCounter.value <= 0) {
dragCounter.value = 0
isDragging.value = false
}
}
function handleDrop(e: DragEvent) {
e.preventDefault()
dragCounter.value = 0
isDragging.value = false
for (const file of Array.from(e.dataTransfer?.files || [])) addFile(file)
textareaRef.value?.focus()
}
function removeAttachment(id: string) {
const idx = attachments.value.findIndex(a => a.id === id)
if (idx !== -1) {
URL.revokeObjectURL(attachments.value[idx].url)
attachments.value.splice(idx, 1)
}
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
function isImage(type: string): boolean {
return type.startsWith('image/')
}
</script>
<template>
<div class="chat-input-area">
<div class="input-wrapper">
<div class="input-top-bar">
<NTooltip trigger="hover">
<template #trigger>
<NButton quaternary size="tiny" circle @click="handleAttachClick">
<template #icon>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</template>
</NButton>
</template>
{{ t('chat.attachFiles') }}
</NTooltip>
<div class="auto-play-speech-switch">
<NTooltip trigger="hover">
<template #trigger>
<div class="switch-label">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</div>
</template>
{{ t('chat.autoPlaySpeech') }}
</NTooltip>
<NSwitch v-model:value="autoPlaySpeech" size="small" :round="false" />
</div>
<NTooltip trigger="hover">
<template #trigger>
<NButton quaternary size="tiny" class="tool-trace-toggle" :class="{ active: toolTraceVisible }" @click="toggleToolTraceVisible">
<svg class="tool-trace-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M14.7 6.3a4.5 4.5 0 0 0-5.8 5.8L3.5 17.5a2.1 2.1 0 0 0 3 3l5.4-5.4a4.5 4.5 0 0 0 5.8-5.8l-3 3-3-3 3-3z"/>
</svg>
</NButton>
</template>
{{ toolTraceVisible ? t('chat.hideToolCalls') : t('chat.showToolCalls') }}
</NTooltip>
</div>
<div v-if="attachments.length > 0" class="attachment-previews">
<div v-for="att in attachments" :key="att.id" class="attachment-preview" :class="{ image: isImage(att.type) }">
<img v-if="isImage(att.type)" :src="att.url" :alt="att.name" class="attachment-thumb" />
<div v-else class="attachment-file">
<span class="file-name">{{ att.name }}</span>
<span class="file-size">{{ formatSize(att.size) }}</span>
</div>
<button class="attachment-remove" @click="removeAttachment(att.id)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
</div>
<div
class="input-wrapper"
:class="{ 'drag-over': isDragging }"
@dragover="handleDragOver"
@dragenter="handleDragEnter"
@dragleave="handleDragLeave"
@drop="handleDrop"
>
<input ref="fileInputRef" type="file" multiple class="file-input-hidden" @change="handleFileChange" />
<div class="resize-handle" @mousedown="startResize"></div>
<textarea
ref="textareaRef"
@@ -273,6 +431,7 @@ function handleCompositionEnd() {
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
@input="handleInput"
@paste="handlePaste"
/>
<div class="input-actions">
<NButton
@@ -320,11 +479,138 @@ function handleCompositionEnd() {
@use "@/styles/variables" as *;
.chat-input-area {
padding: 20px 20px 16px;
padding: 12px 20px 16px;
border-top: 1px solid $border-color;
flex-shrink: 0;
}
.input-top-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 0 0 6px;
}
.auto-play-speech-switch {
display: flex;
align-items: center;
gap: 6px;
padding-left: 8px;
border-left: 1px solid $border-light;
margin-left: 4px;
.switch-label {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: #999999;
}
}
.tool-trace-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
color: #999999;
width: 24px;
min-width: 24px;
height: 22px;
margin-left: -4px;
padding: 0;
background: transparent !important;
:deep(.n-button__state-border),
:deep(.n-button__border),
:deep(.n-button__ripple) {
display: none;
}
.tool-trace-icon {
display: block;
width: 16px;
height: 16px;
}
}
.attachment-previews {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 0 10px;
}
.attachment-preview {
position: relative;
border-radius: $radius-sm;
overflow: hidden;
background-color: $bg-secondary;
border: 1px solid $border-color;
&.image {
width: 64px;
height: 64px;
}
}
.attachment-thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.attachment-file {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
padding: 8px 12px;
min-width: 80px;
max-width: 140px;
color: $text-secondary;
.file-name {
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.file-size {
font-size: 10px;
color: $text-muted;
}
}
.attachment-remove {
position: absolute;
top: 2px;
right: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.5);
color: var(--text-on-overlay);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity $transition-fast;
.attachment-preview:hover & {
opacity: 1;
}
}
.file-input-hidden {
display: none;
}
.typing-dots {
display: inline-flex;
align-items: center;
@@ -363,6 +649,11 @@ function handleCompositionEnd() {
border-color: $accent-primary;
}
&.drag-over {
border-color: $accent-primary;
background-color: rgba($accent-primary, 0.08);
}
.dark & {
background-color: #333333;
}
@@ -8,6 +8,7 @@ import { useProfilesStore } from '@/stores/hermes/profiles'
import { updateRoomConfig, forceCompress } from '@/api/hermes/group-chat'
import GroupMessageList from './GroupMessageList.vue'
import GroupChatInput from './GroupChatInput.vue'
import type { Attachment } from '@/stores/hermes/chat'
const { t } = useI18n()
const message = useMessage()
@@ -42,6 +43,7 @@ function agentAvatarUrl(name: string): string {
}
const hasRoom = computed(() => !!store.currentRoomId)
const visibleApproval = computed(() => store.activePendingApproval)
function formatTokens(tokens: number): string {
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k tokens`
@@ -131,9 +133,9 @@ async function handleSelectRoom(roomId: string) {
}
}
async function handleSendMessage(content: string) {
async function handleSendMessage(content: string, attachments?: Attachment[]) {
try {
await store.sendMessage(content)
await store.sendMessage(content, attachments)
} catch (err: any) {
message.error(err.message)
}
@@ -217,6 +219,22 @@ async function handleRemoveAgent(agentId: string) {
}
}
async function handleInterruptAgent(agentName: string) {
try {
await store.interruptAgent(agentName)
} catch (err: any) {
message.error(err.message || t('common.saveFailed'))
}
}
async function handleApproval(choice: 'once' | 'session' | 'always' | 'deny') {
try {
await store.respondApproval(choice)
} catch (err: any) {
message.error(err.message || t('common.saveFailed'))
}
}
// Auto-scroll on new messages
const messageListRef = ref()
watch(() => store.sortedMessages.length, async () => {
@@ -370,6 +388,12 @@ watch(() => store.sortedMessages.length, async () => {
<span v-else>
@{{ status.agentName }} {{ t('groupChat.agentReplying') }}
</span>
<button class="context-stop-btn" :title="t('common.cancel')" @click="handleInterruptAgent(status.agentName)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</div>
<div v-else-if="store.typingText" class="typing-indicator">
@@ -379,6 +403,38 @@ watch(() => store.sortedMessages.length, async () => {
{{ store.typingText }}
</div>
</div>
<div v-if="visibleApproval" class="approval-bar">
<div class="approval-icon" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10" />
<path d="m9 12 2 2 4-4" />
</svg>
</div>
<div class="approval-content">
<div class="approval-main">
<div class="approval-kicker">{{ t('chat.approvalKicker') }}</div>
<div class="approval-title">
<span v-if="visibleApproval.agentName">@{{ visibleApproval.agentName }} · </span>{{ t('chat.approvalTitle') }}
</div>
<div class="approval-desc">{{ visibleApproval.description }}</div>
<code class="approval-command">{{ visibleApproval.command }}</code>
</div>
<div class="approval-actions">
<NButton v-if="visibleApproval.choices.includes('once')" size="small" type="primary" @click="handleApproval('once')">
{{ t('chat.approvalAllowOnce') }}
</NButton>
<NButton v-if="visibleApproval.choices.includes('session')" size="small" secondary @click="handleApproval('session')">
{{ t('chat.approvalAllowSession') }}
</NButton>
<NButton v-if="visibleApproval.choices.includes('always')" size="small" secondary @click="handleApproval('always')">
{{ t('chat.approvalAlways') }}
</NButton>
<NButton v-if="visibleApproval.choices.includes('deny')" size="small" type="error" secondary @click="handleApproval('deny')">
{{ t('chat.approvalDeny') }}
</NButton>
</div>
</div>
</div>
<GroupChatInput @send="handleSendMessage" />
</template>
@@ -585,6 +641,143 @@ export default defineComponent({ components: { CreateRoomForm } })
}
}
.context-stop-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border: 1px solid rgba(var(--error-rgb), 0.18);
border-radius: $radius-sm;
background: rgba(var(--error-rgb), 0.06);
color: $error;
cursor: pointer;
padding: 0;
transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
&:hover {
color: #ffffff;
background: $error;
border-color: $error;
}
}
.approval-bar {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 0 16px 12px;
padding: 12px;
border: 1px solid $border-color;
border-radius: 8px;
background: $bg-card;
box-shadow: none;
}
.approval-icon {
display: grid;
place-items: center;
flex: 0 0 32px;
width: 32px;
height: 32px;
color: var(--accent-primary);
background: rgba(var(--accent-primary-rgb), 0.12);
border: 1px solid rgba(var(--accent-primary-rgb), 0.2);
border-radius: 8px;
}
.approval-content {
flex: 1;
min-width: 0;
}
.approval-main {
min-width: 0;
}
.approval-kicker {
margin-bottom: 2px;
font-size: 10px;
font-weight: 700;
line-height: 1.2;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent-primary);
}
.approval-title {
font-size: 14px;
font-weight: 700;
line-height: 1.3;
color: $text-primary;
}
.approval-desc {
margin-top: 4px;
font-size: 12px;
line-height: 1.45;
color: $text-secondary;
}
.approval-command {
display: block;
margin-top: 8px;
max-height: 96px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: "SFMono-Regular", "Cascadia Code", "Roboto Mono", Consolas, monospace;
font-size: 11px;
line-height: 1.45;
color: $text-primary;
background: $bg-secondary;
border: 1px solid $border-color;
border-radius: 6px;
padding: 8px 10px;
}
.approval-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid $border-color;
}
@media (max-width: 768px) {
.approval-bar {
margin: 0 10px 10px;
padding: 10px;
}
.approval-icon {
flex-basis: 28px;
width: 28px;
height: 28px;
}
.approval-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.approval-actions :deep(.n-button) {
width: 100%;
}
}
@media (max-width: 420px) {
.approval-bar {
gap: 8px;
}
.approval-actions {
grid-template-columns: 1fr;
}
}
.typing-indicator {
display: flex;
align-items: center;
@@ -1,17 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage } from 'naive-ui'
import multiavatar from '@multiavatar/multiavatar'
import MarkdownRenderer from '../chat/MarkdownRenderer.vue'
import {
copyTextToClipboard,
handleCodeBlockCopyClick,
renderHighlightedCodeBlock,
} from '../chat/highlight'
import { parseThinking, countThinkingChars } from '@/utils/thinking-parser'
import { useGlobalSpeech } from '@/composables/useSpeech'
import { useVoiceSettings } from '@/composables/useVoiceSettings'
import { speedToEdgeRate, hzToEdgePitch } from '@/utils/ttsHelpers'
import { getDownloadUrl } from '@/api/hermes/download'
import type { ChatMessage, RoomAgent } from '@/api/hermes/group-chat'
const TOOL_PAYLOAD_DISPLAY_LIMIT = 1000
const JSON_STRING_DISPLAY_LIMIT = 200
const JSON_MAX_DEPTH = 6
const JSON_MAX_NODES = 1000
const JSON_MAX_KEYS_PER_OBJECT = 50
const JSON_MAX_ITEMS_PER_ARRAY = 50
const JSON_TRUNCATED_KEY = '__truncated__'
const props = defineProps<{
message: ChatMessage
agents: RoomAgent[]
currentUserId?: string
}>()
const { t } = useI18n()
const toast = useMessage()
const speech = useGlobalSpeech()
const voiceSettings = useVoiceSettings()
const previewUrl = ref<string | null>(null)
const isAgent = computed(() => {
return props.agents.some(a => a.agentId === props.message.senderId)
return props.agents.some(a => a.agentId === props.message.senderId || a.name === props.message.senderName)
})
const isSelf = computed(() => {
@@ -19,7 +44,7 @@ const isSelf = computed(() => {
})
const agentInfo = computed(() => {
return props.agents.find(a => a.agentId === props.message.senderId)
return props.agents.find(a => a.agentId === props.message.senderId || a.name === props.message.senderName)
})
const timeStr = computed(() => {
@@ -32,10 +57,377 @@ const avatarSvg = computed(() => {
})
const mentionNames = computed(() => props.agents.map(a => a.name).filter(Boolean))
const parsedThinking = computed(() => parseThinking(props.message.content || '', { streaming: !!props.message.isStreaming }))
const hasReasoningField = computed(() => !!(props.message.reasoning && props.message.reasoning.length > 0))
const hasThinking = computed(() => hasReasoningField.value || parsedThinking.value.hasThinking)
const thinkingFullText = computed(() => {
const parts: string[] = []
if (props.message.reasoning) parts.push(props.message.reasoning)
parts.push(...parsedThinking.value.segments)
if (parsedThinking.value.pending) parts.push(parsedThinking.value.pending)
return parts.join('\n\n')
})
const thinkingCharCount = computed(() => {
let count = countThinkingChars(parsedThinking.value)
if (props.message.reasoning) count += props.message.reasoning.length
return count
})
const thinkingStreamingNow = computed(() => {
if (!props.message.isStreaming) return false
if (parsedThinking.value.pending !== null) return true
if (hasReasoningField.value && !props.message.content) return true
return false
})
const thinkingOverride = ref<boolean | null>(null)
const thinkingExpanded = computed(() => {
if (thinkingStreamingNow.value) return true
if (thinkingOverride.value !== null) return thinkingOverride.value
return false
})
const assistantBody = computed(() => parsedThinking.value.body || props.message.content || '')
const contentBlocks = computed(() => {
const content = props.message.content || ''
const trimmed = content.trim()
if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) return null
try {
const parsed = JSON.parse(trimmed)
return Array.isArray(parsed) ? parsed : null
} catch {
return null
}
})
const renderedAttachments = computed(() => {
if (props.message.attachments?.length) return props.message.attachments
const blocks = contentBlocks.value
if (!blocks) return []
return blocks.flatMap((block: any, index: number) => {
if (block?.type !== 'image' && block?.type !== 'file') return []
const path = String(block.path || '')
if (!path) return []
const name = String(block.name || `${block.type}-${index + 1}`)
return [{
id: `${props.message.id}_attachment_${index}`,
name,
type: block.type === 'image' ? String(block.media_type || 'image/*') : String(block.media_type || 'application/octet-stream'),
size: 0,
url: getDownloadUrl(normalizeLocalFilePath(path), name),
}]
})
})
const hasAttachments = computed(() => renderedAttachments.value.length > 0)
const displayBody = computed(() => {
if (props.message.role !== 'user') return assistantBody.value
const blocks = contentBlocks.value
if (!blocks) return assistantBody.value
return blocks
.filter((block: any) => block?.type === 'text' && typeof block.text === 'string')
.map((block: any) => block.text)
.join('\n')
})
const copyableContent = computed(() => {
if (isToolMessage.value) return null
const content = displayBody.value || ''
return content.trim() ? content : null
})
const toolExpanded = ref(false)
const isToolMessage = computed(() => props.message.role === 'tool')
const hasToolDetails = computed(() => !!(props.message.toolArgs || props.message.toolResult))
const toolArgsPayload = computed(() => formatToolPayload(props.message.toolArgs))
const toolResultPayload = computed(() => formatToolPayload(props.message.toolResult))
const fullToolArgs = computed(() => toolArgsPayload.value.full)
const formattedToolArgs = computed(() => toolArgsPayload.value.display)
const fullToolResult = computed(() => toolResultPayload.value.full)
const formattedToolResult = computed(() => toolResultPayload.value.display)
const renderedToolArgs = computed(() => formattedToolArgs.value ? renderToolPayload(formattedToolArgs.value, toolArgsPayload.value.language) : '')
const renderedToolResult = computed(() => formattedToolResult.value ? renderToolPayload(formattedToolResult.value, toolResultPayload.value.language) : '')
const canPlaySpeech = computed(() => {
if (props.message.role !== 'assistant') return false
if (!assistantBody.value.trim()) return false
if (voiceSettings.provider.value === 'openai' || voiceSettings.provider.value === 'custom' || voiceSettings.provider.value === 'edge' || voiceSettings.provider.value === 'mimo') return true
return speech.isSupported
})
const isPlayingThisMessage = computed(() => {
if (voiceSettings.provider.value === 'openai' || voiceSettings.provider.value === 'custom' || voiceSettings.provider.value === 'edge' || voiceSettings.provider.value === 'mimo') {
return speech.currentCustomMessageId.value === props.message.id && speech.isCustomPlaying.value
}
return speech.currentMessageId.value === props.message.id && speech.isPlaying.value
})
const isPausedThisMessage = computed(() => {
if (voiceSettings.provider.value === 'openai' || voiceSettings.provider.value === 'custom' || voiceSettings.provider.value === 'edge' || voiceSettings.provider.value === 'mimo') {
return speech.currentCustomMessageId.value === props.message.id && speech.isCustomPaused.value
}
return speech.currentMessageId.value === props.message.id && speech.isPaused.value
})
type ToolPayload = {
full: string
display: string
language?: string
}
function truncateLongString(value: string, marker: string): string {
return value.length > JSON_STRING_DISPLAY_LIMIT ? value.slice(0, JSON_STRING_DISPLAY_LIMIT) + '\n' + marker : value
}
function truncateJsonValue(value: unknown, marker: string): unknown {
let nodeCount = 0
const seen = new WeakSet<object>()
function stringifyLength(candidate: unknown): number {
return JSON.stringify(candidate, null, 2).length
}
function visit(current: unknown, depth: number): unknown {
nodeCount += 1
if (nodeCount > JSON_MAX_NODES) return marker
if (typeof current === 'string') return truncateLongString(current, marker)
if (current === null || typeof current !== 'object') return current
if (seen.has(current)) return `[Circular ${marker}]`
if (depth >= JSON_MAX_DEPTH) return Array.isArray(current) ? `[Array ${marker}]` : `[Object ${marker}]`
seen.add(current)
if (Array.isArray(current)) {
const result: unknown[] = []
const maxItems = Math.min(current.length, JSON_MAX_ITEMS_PER_ARRAY)
for (let i = 0; i < maxItems; i += 1) {
const remaining = current.length - i
result.push(visit(current[i], depth + 1))
if (stringifyLength(result) > TOOL_PAYLOAD_DISPLAY_LIMIT) {
result.pop()
result.push(`${marker}: ${remaining} more items`)
seen.delete(current)
return result
}
}
if (current.length > maxItems) result.push(`${marker}: ${current.length - maxItems} more items`)
seen.delete(current)
return result
}
const entries = Object.entries(current as Record<string, unknown>)
const result: Record<string, unknown> = {}
const maxKeys = Math.min(entries.length, JSON_MAX_KEYS_PER_OBJECT)
for (let i = 0; i < maxKeys; i += 1) {
const [key, val] = entries[i]
const remaining = entries.length - i
result[key] = visit(val, depth + 1)
if (stringifyLength(result) > TOOL_PAYLOAD_DISPLAY_LIMIT) {
delete result[key]
result[JSON_TRUNCATED_KEY] = `${marker}: ${remaining} more keys`
seen.delete(current)
return result
}
}
if (entries.length > maxKeys) result[JSON_TRUNCATED_KEY] = `${marker}: ${entries.length - maxKeys} more keys`
seen.delete(current)
return result
}
const truncated = visit(value, 0)
if (stringifyLength(truncated) <= TOOL_PAYLOAD_DISPLAY_LIMIT) return truncated
return { [JSON_TRUNCATED_KEY]: marker }
}
function formatToolPayload(raw?: string): ToolPayload {
if (!raw) return { full: '', display: '' }
try {
const parsed = JSON.parse(raw)
const full = JSON.stringify(parsed, null, 2)
const display = full.length > TOOL_PAYLOAD_DISPLAY_LIMIT
? JSON.stringify(truncateJsonValue(parsed, t('chat.truncated')), null, 2)
: full
return { full, display, language: 'json' }
} catch {
return {
full: raw,
display: raw.length > TOOL_PAYLOAD_DISPLAY_LIMIT ? raw.slice(0, TOOL_PAYLOAD_DISPLAY_LIMIT) + '\n' + t('chat.truncated') : raw,
}
}
}
function renderToolPayload(content: string, language?: string): string {
return renderHighlightedCodeBlock(content, language, t('common.copy'), {
maxHighlightLength: TOOL_PAYLOAD_DISPLAY_LIMIT,
})
}
async function handleToolDetailClick(event: MouseEvent): Promise<void> {
const target = event.target
if (!(target instanceof HTMLElement)) return
const button = target.closest<HTMLElement>('[data-copy-code="true"]')
if (!button) return
event.preventDefault()
const source = button.closest<HTMLElement>('[data-copy-source]')?.dataset.copySource
if (source === 'tool-args' && fullToolArgs.value) {
const ok = await copyTextToClipboard(fullToolArgs.value)
if (ok) toast.success(t('common.copied'))
else toast.error(t('chat.copyFailed'))
return
}
if (source === 'tool-result' && fullToolResult.value) {
const ok = await copyTextToClipboard(fullToolResult.value)
if (ok) toast.success(t('common.copied'))
else toast.error(t('chat.copyFailed'))
return
}
const copyResult = await handleCodeBlockCopyClick(event)
if (copyResult) toast.success(t('common.copied'))
else if (copyResult === false) toast.error(t('chat.copyFailed'))
}
function playSpeech(content: string, autoplay = false) {
if (!content.trim()) return
if (voiceSettings.provider.value === 'openai') {
if (!voiceSettings.openaiBaseUrl.value) return
const play = autoplay ? speech.openaiPlay : speech.openaiToggle
play(props.message.id, content, {
baseUrl: voiceSettings.openaiBaseUrl.value,
apiKey: voiceSettings.openaiApiKey.value,
model: voiceSettings.openaiModel.value,
voice: voiceSettings.openaiVoice.value,
})
return
}
if (voiceSettings.provider.value === 'custom') {
if (!voiceSettings.customUrl.value) return
const play = autoplay ? speech.openaiPlay : speech.openaiToggle
play(props.message.id, content, {
baseUrl: voiceSettings.customUrl.value,
apiKey: voiceSettings.customApiKey.value || undefined,
})
return
}
if (voiceSettings.provider.value === 'edge') {
const play = autoplay ? speech.openaiPlay : speech.openaiToggle
play(props.message.id, content, {
baseUrl: '/api/tts/proxy',
voice: voiceSettings.edgeVoice.value,
rate: speedToEdgeRate(voiceSettings.edgeRate.value),
pitch: hzToEdgePitch(voiceSettings.edgePitchHz.value),
})
return
}
if (voiceSettings.provider.value === 'mimo') {
if (!voiceSettings.mimoApiKey.value) return
const play = autoplay ? speech.mimoPlay : speech.mimoToggle
play(props.message.id, content, {
baseUrl: voiceSettings.mimoBaseUrl.value,
apiKey: voiceSettings.mimoApiKey.value,
model: voiceSettings.mimoModel.value,
voice: voiceSettings.mimoVoice.value,
voiceDesignDesc: voiceSettings.mimoVoiceDesignDesc.value || undefined,
stylePrompt: voiceSettings.mimoStylePrompt.value || undefined,
})
return
}
if (voiceSettings.provider.value === 'webspeech') {
const text = speech.extractReadableText(content)
if (!text) return
speech.stop(false)
speech.speakViaBrowser(props.message.id, text, {
voiceName: voiceSettings.webspeechVoice.value || undefined,
})
return
}
if (autoplay) speech.enqueue(props.message.id, content)
else speech.toggle(props.message.id, content)
}
function handleSpeechToggle() {
if (canPlaySpeech.value) playSpeech(assistantBody.value)
}
async function copyBubbleContent() {
const text = copyableContent.value
if (!text) return
const ok = await copyTextToClipboard(text)
if (ok) toast.success(t('chat.copiedBubble'))
else toast.error(t('chat.copyFailed'))
}
function isImage(type: string): boolean {
return type.startsWith('image/')
}
function normalizeLocalFilePath(path: string): string {
return /^[a-zA-Z]:\\/.test(path) ? path.replace(/\\/g, '/') : path
}
function formatSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
let autoPlayHandler: ((e: Event) => void) | null = null
onMounted(() => {
autoPlayHandler = (e: Event) => {
const event = e as CustomEvent<{ messageId: string; content: string }>
if (event.detail?.messageId === props.message.id && canPlaySpeech.value) {
playSpeech(event.detail.content || assistantBody.value, true)
}
}
window.addEventListener('auto-play-speech', autoPlayHandler)
})
onBeforeUnmount(() => {
if (autoPlayHandler) window.removeEventListener('auto-play-speech', autoPlayHandler)
if (speech.currentMessageId.value === props.message.id) speech.stop()
})
</script>
<template>
<div class="group-message" :class="{ agent: isAgent, self: isSelf }">
<div v-if="isToolMessage" class="group-message tool-message">
<div class="avatar">
<span v-html="avatarSvg" />
</div>
<div class="msg-body">
<div class="msg-header">
<span class="sender-name">{{ message.senderName }}</span>
<span v-if="isAgent && agentInfo?.description" class="agent-desc">{{ agentInfo.description }}</span>
</div>
<div class="tool-line" :class="{ expandable: hasToolDetails }" @click="hasToolDetails && (toolExpanded = !toolExpanded)">
<svg
v-if="hasToolDetails"
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="tool-chevron"
:class="{ rotated: toolExpanded }"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<svg v-else width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="tool-icon">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" />
</svg>
<span class="tool-name">{{ message.toolName || message.tool_name || 'tool' }}</span>
<span v-if="message.toolPreview && !toolExpanded" class="tool-preview">{{ message.toolPreview }}</span>
<span v-if="message.toolStatus === 'running'" class="tool-spinner"></span>
<span v-if="message.toolStatus === 'error'" class="tool-error-badge">{{ t('chat.error') }}</span>
</div>
<div v-if="toolExpanded && hasToolDetails" class="tool-details" @click="handleToolDetailClick">
<div v-if="formattedToolArgs" class="tool-detail-section" data-copy-source="tool-args">
<div class="tool-detail-label">{{ t('chat.arguments') }}</div>
<div class="tool-detail-code-block" v-html="renderedToolArgs"></div>
</div>
<div v-if="formattedToolResult" class="tool-detail-section" data-copy-source="tool-result">
<div class="tool-detail-label">{{ t('chat.result') }}</div>
<div class="tool-detail-code-block" v-html="renderedToolResult"></div>
</div>
</div>
<span class="msg-time">{{ timeStr }}</span>
</div>
</div>
<div v-else class="group-message" :class="{ agent: isAgent, self: isSelf }">
<!-- Avatar -->
<div class="avatar">
<span v-html="avatarSvg" />
@@ -46,11 +438,88 @@ const mentionNames = computed(() => props.agents.map(a => a.name).filter(Boolean
<span class="sender-name">{{ message.senderName }}</span>
<span v-if="isAgent && agentInfo?.description" class="agent-desc">{{ agentInfo.description }}</span>
</div>
<div class="msg-content" :class="{ 'agent-content': isAgent }">
<MarkdownRenderer :content="message.content" :mention-names="mentionNames" />
<div
class="msg-content"
:class="{
'agent-content': isAgent,
'speech-playing': isPlayingThisMessage && !isPausedThisMessage,
}"
>
<div v-if="hasAttachments" class="msg-attachments">
<div
v-for="att in renderedAttachments"
:key="att.id"
class="msg-attachment"
:class="{ image: isImage(att.type) }"
>
<img v-if="isImage(att.type)" :src="att.url" :alt="att.name" class="msg-attachment-thumb" @click="previewUrl = att.url" />
<a v-else class="msg-attachment-file" :href="att.url" :title="t('download.downloadFile')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span class="att-name">{{ att.name }}</span>
<span class="att-size">{{ formatSize(att.size) }}</span>
</a>
</div>
<span class="msg-time">{{ timeStr }}</span>
</div>
<div v-if="hasThinking" class="thinking-block" :class="{ expanded: thinkingExpanded }">
<div class="thinking-header" @click="thinkingOverride = !thinkingExpanded">
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
class="thinking-chevron"
:class="{ rotated: thinkingExpanded }"
>
<polyline points="9 18 15 12 9 6" />
</svg>
<span class="thinking-icon">💭</span>
<span class="thinking-label">
{{ thinkingStreamingNow ? t('chat.thinkingInProgress') : t('chat.thinkingLabel') }}
</span>
<span class="thinking-meta">· {{ t('chat.thinkingChars', { count: thinkingCharCount }) }}</span>
</div>
<div v-if="thinkingExpanded" class="thinking-body">
<MarkdownRenderer :content="thinkingFullText" />
</div>
</div>
<MarkdownRenderer v-if="displayBody" :content="displayBody" :mention-names="mentionNames" />
<span v-if="message.isStreaming && !displayBody" class="streaming-dots">
<span></span><span></span><span></span>
</span>
</div>
<div class="message-meta">
<button
v-if="canPlaySpeech"
class="speech-bubble-btn"
:class="{ playing: isPlayingThisMessage, paused: isPausedThisMessage }"
:title="isPlayingThisMessage ? (isPausedThisMessage ? t('chat.resumeSpeech') : t('chat.pauseSpeech')) : t('chat.playSpeech')"
@click="handleSpeechToggle"
>
<svg v-if="!isPlayingThisMessage || isPausedThisMessage" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
</button>
<button
v-if="copyableContent"
class="copy-bubble-btn"
:title="t('chat.copyBubble')"
@click="copyBubbleContent"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
</svg>
</button>
<span class="message-time">{{ timeStr }}</span>
</div>
</div>
</div>
<div v-if="previewUrl" class="image-preview-overlay" @click.self="previewUrl = null">
<img :src="previewUrl" class="image-preview-img" @click="previewUrl = null" />
</div>
</template>
@@ -62,7 +531,6 @@ const mentionNames = computed(() => props.agents.map(a => a.name).filter(Boolean
gap: 10px;
padding: 2px 0;
&.agent,
&.self {
flex-direction: row-reverse;
@@ -84,6 +552,121 @@ const mentionNames = computed(() => props.agents.map(a => a.name).filter(Boolean
}
}
.tool-message {
align-items: flex-start;
}
.tool-line {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 4px;
border-radius: $radius-sm;
color: $text-muted;
font-size: 11px;
&.expandable {
cursor: pointer;
&:hover {
background: rgba(0, 0, 0, 0.03);
}
}
}
.tool-chevron {
flex-shrink: 0;
transition: transform 0.15s ease;
&.rotated {
transform: rotate(90deg);
}
}
.tool-icon,
.tool-chevron {
flex: 0 0 auto;
opacity: 0.75;
}
.tool-name {
flex-shrink: 0;
font-family: $font-code;
color: $text-muted;
font-weight: 400;
}
.tool-preview {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 400px;
}
.tool-spinner {
width: 10px;
height: 10px;
border: 1.5px solid $text-muted;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.6s linear infinite;
flex-shrink: 0;
}
.tool-error-badge {
font-size: 9px;
color: $error;
background: rgba(var(--error-rgb), 0.08);
padding: 0 4px;
border-radius: 3px;
line-height: 14px;
margin-left: 4px;
}
.tool-details {
margin-left: 16px;
margin-top: 2px;
border-left: 2px solid $border-light;
padding-left: 10px;
}
.tool-detail-section {
margin-bottom: 6px;
}
.tool-detail-label {
margin-bottom: 2px;
color: $text-muted;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.3px;
text-transform: uppercase;
}
.tool-detail-code-block {
:deep(.hljs-code-block) {
margin: 0;
}
:deep(.code-header) {
background: rgba(0, 0, 0, 0.02);
}
:deep(code.hljs) {
font-size: 11px;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.avatar {
width: 36px;
height: 36px;
@@ -124,11 +707,117 @@ const mentionNames = computed(() => props.agents.map(a => a.name).filter(Boolean
}
}
.msg-time {
.msg-time,
.message-time {
font-size: 12px;
color: var(--text-muted);
opacity: 0.6;
margin-top: 2px;
user-select: none;
}
.message-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
padding: 0 4px;
opacity: 0;
transition: opacity 0.15s ease;
.group-message:hover & {
opacity: 1;
}
@media (max-width: 768px) {
opacity: 1;
}
}
.copy-bubble-btn,
.speech-bubble-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
color: $text-muted;
cursor: pointer;
border-radius: $radius-sm;
padding: 0;
transition: color 0.15s ease, background 0.15s ease;
&:hover {
color: $text-secondary;
background: rgba(0, 0, 0, 0.06);
}
.dark & {
color: #999999;
&:hover {
color: #cccccc;
background: rgba(255, 255, 255, 0.1);
}
}
}
.speech-bubble-btn {
&.playing {
color: var(--accent-primary);
animation: pulse 1.5s ease-in-out infinite;
&.paused {
animation: none;
opacity: 0.6;
}
}
}
@keyframes rainbow-glow {
0% {
box-shadow:
0 0 0 2px #ff6b6b,
0 0 10px rgba(255, 107, 107, 0.4),
0 0 20px rgba(255, 107, 107, 0.2);
}
16.66% {
box-shadow:
0 0 0 2px #feca57,
0 0 10px rgba(254, 202, 87, 0.4),
0 0 20px rgba(254, 202, 87, 0.2);
}
33.33% {
box-shadow:
0 0 0 2px #48dbfb,
0 0 10px rgba(72, 219, 251, 0.4),
0 0 20px rgba(72, 219, 251, 0.2);
}
50% {
box-shadow:
0 0 0 2px #ff9ff3,
0 0 10px rgba(255, 159, 243, 0.4),
0 0 20px rgba(255, 159, 243, 0.2);
}
66.66% {
box-shadow:
0 0 0 2px #54a0ff,
0 0 10px rgba(84, 160, 255, 0.4),
0 0 20px rgba(84, 160, 255, 0.2);
}
83.33% {
box-shadow:
0 0 0 2px #5f27cd,
0 0 10px rgba(95, 39, 205, 0.4),
0 0 20px rgba(95, 39, 205, 0.2);
}
100% {
box-shadow:
0 0 0 2px #ff6b6b,
0 0 10px rgba(255, 107, 107, 0.4),
0 0 20px rgba(255, 107, 107, 0.2);
}
}
.msg-content {
@@ -141,10 +830,179 @@ const mentionNames = computed(() => props.agents.map(a => a.name).filter(Boolean
word-break: break-word;
overflow-wrap: break-word;
&.speech-playing {
box-shadow:
0 0 0 2px #ff6b6b,
0 0 10px rgba(255, 107, 107, 0.4),
0 0 20px rgba(255, 107, 107, 0.2);
animation: rainbow-glow 4s linear infinite;
}
:deep(.mention-highlight) {
color: #409eff;
font-weight: 600;
cursor: default;
}
}
.msg-attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.msg-attachment {
border-radius: $radius-sm;
overflow: hidden;
background-color: $bg-secondary;
border: 1px solid $border-color;
&.image {
width: 96px;
height: 96px;
}
}
.msg-attachment-thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
cursor: zoom-in;
}
.msg-attachment-file {
display: flex;
align-items: center;
gap: 6px;
min-width: 140px;
max-width: 220px;
padding: 8px 10px;
color: $text-secondary;
text-decoration: none;
.att-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.att-size {
font-size: 11px;
color: $text-muted;
}
}
.image-preview-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.82);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.image-preview-img {
max-width: min(96vw, 1400px);
max-height: 92vh;
object-fit: contain;
border-radius: 6px;
cursor: zoom-out;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}
.thinking-block {
margin-bottom: 8px;
padding: 4px 0;
border-bottom: 1px dashed $border-light;
.thinking-header {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: $text-muted;
cursor: pointer;
padding: 2px 4px;
border-radius: $radius-sm;
user-select: none;
&:hover {
background: rgba(0, 0, 0, 0.03);
}
}
.thinking-chevron {
flex-shrink: 0;
transition: transform 0.15s ease;
&.rotated {
transform: rotate(90deg);
}
}
.thinking-icon {
font-size: 11px;
flex-shrink: 0;
}
.thinking-label {
font-weight: 500;
flex-shrink: 0;
}
.thinking-meta {
color: $text-muted;
font-variant-numeric: tabular-nums;
}
.thinking-body {
margin-top: 6px;
padding: 6px 10px;
border-left: 2px solid $border-light;
font-size: 13px;
opacity: 0.85;
font-style: italic;
:deep(p) {
margin: 0.3em 0;
}
}
}
.streaming-dots {
display: flex;
gap: 4px;
padding: 4px 0;
span {
width: 6px;
height: 6px;
background-color: $text-muted;
border-radius: 50%;
animation: pulse 1.4s infinite ease-in-out;
&:nth-child(2) { animation-delay: 0.2s; }
&:nth-child(3) { animation-delay: 0.4s; }
}
}
@keyframes pulse {
0%,
80%,
100% {
opacity: 0.3;
transform: scale(0.8);
}
40% {
opacity: 1;
transform: scale(1);
}
}
</style>
@@ -1,13 +1,16 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { computed, ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useGroupChatStore } from '@/stores/hermes/group-chat'
import { useToolTraceVisibility } from '@/composables/useToolTraceVisibility'
import GroupMessageItem from './GroupMessageItem.vue'
const store = useGroupChatStore()
const { t } = useI18n()
const { toolTraceVisible } = useToolTraceVisibility()
const listRef = ref<HTMLDivElement>()
const isNearBottom = ref(true)
const displayMessages = computed(() => store.sortedMessages.filter(msg => msg.role !== 'tool' || toolTraceVisible.value || msg.toolStatus === 'running'))
function checkNearBottom(): void {
if (!listRef.value) return
@@ -36,12 +39,12 @@ defineExpose({ scrollToBottom })
<template>
<div ref="listRef" class="message-list" @scroll="handleScroll">
<div v-if="store.sortedMessages.length === 0" class="empty-state">
<div v-if="displayMessages.length === 0" class="empty-state">
<img src="/logo.png" alt="Hermes" class="empty-logo" />
<p>{{ t("chat.emptyState") }}</p>
</div>
<GroupMessageItem
v-for="msg in store.sortedMessages"
v-for="msg in displayMessages"
:key="msg.id"
:message="msg"
:agents="store.agents"
@@ -37,6 +37,11 @@ const allModels = computed(() => props.provider.available_models?.length ? props
const visibilityRule = computed(() => appStore.getProviderVisibility(props.provider.provider))
const isFiltered = computed(() => visibilityRule.value.mode === 'include')
const visibleCountLabel = computed(() => `${props.provider.models.length}/${allModels.value.length}`)
const isDefaultProvider = computed(() => modelsStore.defaultProvider === props.provider.provider)
function isDefaultModel(model: string) {
return isDefaultProvider.value && modelsStore.defaultModel === model
}
function modelAlias(model: string) {
return appStore.getModelAlias(model, props.provider.provider)
@@ -157,10 +162,13 @@ async function handleDelete() {
<div class="provider-card">
<div class="card-header">
<h3 class="provider-name">{{ displayName }}</h3>
<div class="provider-badges">
<span v-if="isDefaultProvider" class="type-badge default">{{ t('models.currentDefault') }}</span>
<span class="type-badge" :class="isCustom ? 'custom' : 'builtin'">
{{ isCustom ? t('models.customType') : t('models.builtIn') }}
</span>
</div>
</div>
<div class="card-body">
<div class="info-row">
@@ -182,11 +190,13 @@ async function handleDelete() {
v-for="model in provider.models.slice(0, 20)"
:key="model"
class="model-tag model-tag-button"
:class="{ default: isDefaultModel(model) }"
type="button"
:title="t('models.aliasTitleFor', { model })"
@click="openAliasEditor(model)"
>
<span class="model-tag-name">{{ modelDisplayName(model) }}</span>
<span v-if="isDefaultModel(model)" class="model-tag-default">{{ t('models.defaultShort') }}</span>
<span v-if="modelAlias(model)" class="model-tag-id">{{ model }}</span>
</button>
<span v-if="provider.models.length > 20" class="model-tag model-tag-more">
@@ -213,6 +223,7 @@ async function handleDelete() {
<div v-for="model in provider.models" :key="model" class="alias-row">
<div class="alias-row-text">
<span class="alias-row-name">{{ modelDisplayName(model) }}</span>
<span v-if="isDefaultModel(model)" class="alias-row-default">{{ t('models.defaultShort') }}</span>
<code class="alias-row-id">{{ model }}</code>
</div>
<NButton size="tiny" quaternary @click="openAliasEditor(model)">{{ t('models.aliasEdit') }}</NButton>
@@ -311,6 +322,7 @@ async function handleDelete() {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
@@ -324,11 +336,20 @@ async function handleDelete() {
max-width: 70%;
}
.provider-badges {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
min-width: 0;
}
.type-badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
font-weight: 500;
white-space: nowrap;
&.builtin {
background: rgba(var(--accent-primary-rgb), 0.12);
@@ -339,6 +360,11 @@ async function handleDelete() {
background: rgba(var(--success-rgb), 0.12);
color: $success;
}
&.default {
background: rgba(var(--warning-rgb), 0.14);
color: $warning;
}
}
.card-body {
@@ -409,6 +435,11 @@ async function handleDelete() {
color: $accent-primary;
font-weight: 500;
}
&.default {
background: rgba(var(--warning-rgb), 0.14);
color: $text-primary;
}
}
.model-tag-button {
@@ -433,6 +464,14 @@ async function handleDelete() {
font-size: 9px;
}
.model-tag-default,
.alias-row-default {
color: $warning;
font-family: $font-ui;
font-size: 10px;
font-weight: 600;
}
.card-actions {
display: flex;
gap: 8px;
@@ -3,6 +3,7 @@ import { ref, onUnmounted } from 'vue'
import { NModal, NButton, NSpin, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { startXaiLogin, pollXaiLogin } from '@/api/hermes/xai-auth'
import { copyToClipboard } from '@/utils/clipboard'
const { t } = useI18n()
const emit = defineEmits<{ close: []; success: [] }>()
@@ -73,6 +74,12 @@ function openLink() {
window.open(authorizationUrl.value, '_blank')
}
async function copyLink() {
const ok = await copyToClipboard(authorizationUrl.value)
if (ok) message.success(t('common.copied'))
else message.error(t('chat.copyFailed'))
}
function retry() {
status.value = 'idle'
authorizationUrl.value = ''
@@ -104,6 +111,9 @@ startLogin()
<NButton type="primary" block @click="openLink">
{{ t('models.xaiOpenLink') }}
</NButton>
<NButton block @click="copyLink">
{{ t('models.xaiCopyLink') }}
</NButton>
</div>
<div v-else-if="status === 'approved'" class="xai-login__state xai-login__state--success">
@@ -97,10 +97,6 @@ async function handleExport() {
<span class="info-label">{{ t('profiles.model') }}</span>
<code class="info-value mono">{{ profile.model }}</code>
</div>
<div class="info-row">
<span class="info-label">{{ t('profiles.gateway') }}</span>
<code class="info-value mono">{{ profile.gateway }}</code>
</div>
</div>
<div class="card-detail-toggle" @click="toggleDetail">
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ref, reactive, onUnmounted } from 'vue'
import { NSwitch, NInput, NButton, useMessage } from 'naive-ui'
import { ref, reactive, onUnmounted, watch } from 'vue'
import { NSwitch, NInput, NButton, NSpin, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useSettingsStore } from '@/stores/hermes/settings'
import { saveCredentials as saveCredsApi, fetchWeixinQrCode, pollWeixinQrStatus, saveWeixinCredentials } from '@/api/hermes/config'
@@ -11,43 +11,93 @@ const settingsStore = useSettingsStore()
const message = useMessage()
const { t } = useI18n()
// Track saving state per platform.field
const saving = reactive<Record<string, boolean>>({})
const configDrafts = reactive<Record<string, Record<string, any>>>({})
const credentialDrafts = reactive<Record<string, Record<string, any>>>({})
const touchedConfig = reactive<Record<string, boolean>>({})
const touchedCredentials = reactive<Record<string, boolean>>({})
function savingKey(platform: string, field: string) {
return `${platform}.${field}`
function cloneValue<T>(value: T): T {
return JSON.parse(JSON.stringify(value || {}))
}
function isSaving(platform: string, field: string) {
return !!saving[savingKey(platform, field)]
function mergeDeep(target: Record<string, any>, values: Record<string, any>) {
for (const [key, value] of Object.entries(values)) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
target[key] = mergeDeep({ ...(target[key] || {}) }, value as Record<string, any>)
} else {
target[key] = value
}
}
return target
}
// Immediate save for switches
async function immediateSave(platform: string, field: string, saveFn: () => Promise<void>) {
const key = savingKey(platform, field)
saving[key] = true
function configDraft(platform: string) {
if (!configDrafts[platform]) {
configDrafts[platform] = cloneValue(settingsStore[platform as keyof typeof settingsStore] as Record<string, any>)
}
return configDrafts[platform]
}
function credentialDraft(platform: string) {
if (!credentialDrafts[platform]) credentialDrafts[platform] = cloneValue(getCreds(platform))
return credentialDrafts[platform]
}
function setConfigDraft(platform: string, values: Record<string, any>) {
configDrafts[platform] = mergeDeep({ ...configDraft(platform) }, values)
touchedConfig[platform] = true
}
function setCredentialDraft(platform: string, values: Record<string, any>) {
credentialDrafts[platform] = mergeDeep({ ...credentialDraft(platform) }, values)
touchedCredentials[platform] = true
}
function sameJson(a: unknown, b: unknown) {
return JSON.stringify(a || {}) === JSON.stringify(b || {})
}
function hasConfigChanges(platform: string) {
return !!touchedConfig[platform] && !!configDrafts[platform] && !sameJson(configDrafts[platform], settingsStore[platform as keyof typeof settingsStore])
}
function hasCredentialChanges(platform: string) {
return !!touchedCredentials[platform] && !!credentialDrafts[platform] && !sameJson(credentialDrafts[platform], getCreds(platform))
}
function hasUnsavedChanges(platform: string) {
return hasConfigChanges(platform) || hasCredentialChanges(platform)
}
function isSavingPlatform(platform: string) {
return !!saving[platform]
}
async function savePlatform(platform: string) {
saving[platform] = true
try {
await saveFn()
const configChanged = hasConfigChanges(platform)
const credentialsChanged = hasCredentialChanges(platform)
if (configChanged) {
await settingsStore.saveSection(platform, configDraft(platform), { restart: !credentialsChanged })
}
if (credentialsChanged) {
await saveCredsApi(platform, credentialDraft(platform))
await settingsStore.fetchSettings()
}
configDrafts[platform] = cloneValue(settingsStore[platform as keyof typeof settingsStore] as Record<string, any>)
credentialDrafts[platform] = cloneValue(getCreds(platform))
touchedConfig[platform] = false
touchedCredentials[platform] = false
message.success(t('settings.saved'))
} catch (err: any) {
message.error(t('settings.saveFailed'))
message.error(err?.message || t('settings.saveFailed'))
} finally {
saving[key] = false
saving[platform] = false
}
}
async function saveChannel(platform: string, field: string, values: Record<string, any>) {
immediateSave(platform, field, () => settingsStore.saveSection(platform, values))
}
// Save credentials to .env (matching hermes gateway setup behavior)
async function saveCredentials(platform: string, field: string, values: Record<string, any>) {
immediateSave(platform, field, async () => {
await saveCredsApi(platform, values)
await settingsStore.fetchSettings()
})
}
function getCreds(key: string) {
return (settingsStore.platforms[key] || {}) as Record<string, any>
}
@@ -180,6 +230,25 @@ const platforms = [
icon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 01.213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 00.167-.054l1.903-1.114a.864.864 0 01.717-.098 10.16 10.16 0 002.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178A1.17 1.17 0 014.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 01-1.162 1.178 1.17 1.17 0 01-1.162-1.178c0-.651.52-1.18 1.162-1.18zm3.68 4.025c-3.694 0-6.69 2.462-6.69 5.496 0 3.034 2.996 5.496 6.69 5.496.753 0 1.477-.1 2.158-.28a.66.66 0 01.548.074l1.46.854a.25.25 0 00.127.041.224.224 0 00.221-.225c0-.055-.022-.109-.037-.162l-.298-1.131a.453.453 0 01.163-.509C21.81 18.613 22.77 16.973 22.77 15.512c0-3.034-2.996-5.496-6.69-5.496h.198zm-2.454 3.347c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902zm4.912 0c.491 0 .889.404.889.902a.896.896 0 01-.889.903.896.896 0 01-.889-.903c0-.498.398-.902.889-.902z"/></svg>',
},
]
watch(
() => platforms.map((platform) => ({
key: platform.key,
config: settingsStore[platform.key as keyof typeof settingsStore],
credentials: getCreds(platform.key),
})),
(items) => {
for (const item of items) {
if (!touchedConfig[item.key]) {
configDrafts[item.key] = cloneValue(item.config as Record<string, any>)
}
if (!touchedCredentials[item.key]) {
credentialDrafts[item.key] = cloneValue(item.credentials)
}
}
},
{ deep: true, immediate: true },
)
</script>
<template>
@@ -196,158 +265,158 @@ const platforms = [
<!-- Telegram -->
<template v-if="p.key === 'telegram'">
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
<NInput :default-value="getCreds('telegram').token || ''" :loading="isSaving('telegram', 'token')" clearable size="small" class="input-lg" placeholder="123456:ABC-DEF..." @change="v => saveCredentials('telegram', 'token', { token: v })" />
<NInput :value="credentialDraft('telegram').token || ''" :loading="isSavingPlatform('telegram')" clearable size="small" class="input-lg" placeholder="123456:ABC-DEF..." @update:value="v => setCredentialDraft('telegram', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="settingsStore.telegram.require_mention" :loading="isSaving('telegram', 'require_mention')" @update:value="v => saveChannel('telegram', 'require_mention', { require_mention: v })" />
<NSwitch :value="configDraft('telegram').require_mention" :loading="isSavingPlatform('telegram')" @update:value="v => setConfigDraft('telegram', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.reactions')" :hint="t('platform.reactionsHint')">
<NSwitch :value="settingsStore.telegram.reactions" :loading="isSaving('telegram', 'reactions')" @update:value="v => saveChannel('telegram', 'reactions', { reactions: v })" />
<NSwitch :value="configDraft('telegram').reactions" :loading="isSavingPlatform('telegram')" @update:value="v => setConfigDraft('telegram', { reactions: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
<NInput :default-value="settingsStore.telegram.free_response_chats || ''" :loading="isSaving('telegram', 'free_response_chats')" size="small" placeholder="chat_id1,chat_id2" @change="v => saveChannel('telegram', 'free_response_chats', { free_response_chats: v })" />
<NInput :value="configDraft('telegram').free_response_chats || ''" :loading="isSavingPlatform('telegram')" size="small" placeholder="chat_id1,chat_id2" @update:value="v => setConfigDraft('telegram', { free_response_chats: v })" />
</SettingRow>
<SettingRow :label="t('platform.mentionPatterns')" :hint="t('platform.mentionPatternsHint')">
<NInput :default-value="(settingsStore.telegram.mention_patterns || []).join(', ')" :loading="isSaving('telegram', 'mention_patterns')" size="small" placeholder="pattern1, pattern2" @change="v => saveChannel('telegram', 'mention_patterns', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
<NInput :value="(configDraft('telegram').mention_patterns || []).join(', ')" :loading="isSavingPlatform('telegram')" size="small" placeholder="pattern1, pattern2" @update:value="v => setConfigDraft('telegram', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
</SettingRow>
</template>
<!-- Discord -->
<template v-if="p.key === 'discord'">
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
<NInput :default-value="getCreds('discord').token || ''" :loading="isSaving('discord', 'token')" clearable size="small" class="input-lg" placeholder="Bot token..." @change="v => saveCredentials('discord', 'token', { token: v })" />
<NInput :value="credentialDraft('discord').token || ''" :loading="isSavingPlatform('discord')" clearable size="small" class="input-lg" placeholder="Bot token..." @update:value="v => setCredentialDraft('discord', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
<NSwitch :value="settingsStore.discord.require_mention" :loading="isSaving('discord', 'require_mention')" @update:value="v => saveChannel('discord', 'require_mention', { require_mention: v })" />
<NSwitch :value="configDraft('discord').require_mention" :loading="isSavingPlatform('discord')" @update:value="v => setConfigDraft('discord', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.autoThread')" :hint="t('platform.autoThreadHint')">
<NSwitch :value="settingsStore.discord.auto_thread" :loading="isSaving('discord', 'auto_thread')" @update:value="v => saveChannel('discord', 'auto_thread', { auto_thread: v })" />
<NSwitch :value="configDraft('discord').auto_thread" :loading="isSavingPlatform('discord')" @update:value="v => setConfigDraft('discord', { auto_thread: v })" />
</SettingRow>
<SettingRow :label="t('platform.reactions')" :hint="t('platform.reactionsHint')">
<NSwitch :value="settingsStore.discord.reactions" :loading="isSaving('discord', 'reactions')" @update:value="v => saveChannel('discord', 'reactions', { reactions: v })" />
<NSwitch :value="configDraft('discord').reactions" :loading="isSavingPlatform('discord')" @update:value="v => setConfigDraft('discord', { reactions: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChannels')" :hint="t('platform.freeResponseChannelsHint')">
<NInput :default-value="settingsStore.discord.free_response_channels || ''" :loading="isSaving('discord', 'free_response_channels')" size="small" placeholder="channel_id1,channel_id2" @change="v => saveChannel('discord', 'free_response_channels', { free_response_channels: v })" />
<NInput :value="configDraft('discord').free_response_channels || ''" :loading="isSavingPlatform('discord')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => setConfigDraft('discord', { free_response_channels: v })" />
</SettingRow>
<SettingRow :label="t('platform.allowedChannels')" :hint="t('platform.allowedChannelsHint')">
<NInput :default-value="settingsStore.discord.allowed_channels || ''" :loading="isSaving('discord', 'allowed_channels')" size="small" placeholder="channel_id1,channel_id2" @change="v => saveChannel('discord', 'allowed_channels', { allowed_channels: v })" />
<NInput :value="configDraft('discord').allowed_channels || ''" :loading="isSavingPlatform('discord')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => setConfigDraft('discord', { allowed_channels: v })" />
</SettingRow>
<SettingRow :label="t('platform.ignoredChannels')" :hint="t('platform.ignoredChannelsHint')">
<NInput :default-value="settingsStore.discord.ignored_channels || ''" :loading="isSaving('discord', 'ignored_channels')" size="small" placeholder="channel_id1,channel_id2" @change="v => saveChannel('discord', 'ignored_channels', { ignored_channels: v })" />
<NInput :value="configDraft('discord').ignored_channels || ''" :loading="isSavingPlatform('discord')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => setConfigDraft('discord', { ignored_channels: v })" />
</SettingRow>
<SettingRow :label="t('platform.noThreadChannels')" :hint="t('platform.noThreadChannelsHint')">
<NInput :default-value="settingsStore.discord.no_thread_channels || ''" :loading="isSaving('discord', 'no_thread_channels')" size="small" placeholder="channel_id1,channel_id2" @change="v => saveChannel('discord', 'no_thread_channels', { no_thread_channels: v })" />
<NInput :value="configDraft('discord').no_thread_channels || ''" :loading="isSavingPlatform('discord')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => setConfigDraft('discord', { no_thread_channels: v })" />
</SettingRow>
</template>
<!-- Slack -->
<template v-if="p.key === 'slack'">
<SettingRow :label="t('platform.botToken')" :hint="t('platform.botTokenHint')">
<NInput :default-value="getCreds('slack').token || ''" :loading="isSaving('slack', 'token')" clearable size="small" class="input-lg" placeholder="xoxb-..." @change="v => saveCredentials('slack', 'token', { token: v })" />
<NInput :value="credentialDraft('slack').token || ''" :loading="isSavingPlatform('slack')" clearable size="small" class="input-lg" placeholder="xoxb-..." @update:value="v => setCredentialDraft('slack', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionChannel')">
<NSwitch :value="settingsStore.slack.require_mention" :loading="isSaving('slack', 'require_mention')" @update:value="v => saveChannel('slack', 'require_mention', { require_mention: v })" />
<NSwitch :value="configDraft('slack').require_mention" :loading="isSavingPlatform('slack')" @update:value="v => setConfigDraft('slack', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.allowBots')" :hint="t('platform.allowBotsHint')">
<NSwitch :value="settingsStore.slack.allow_bots" :loading="isSaving('slack', 'allow_bots')" @update:value="v => saveChannel('slack', 'allow_bots', { allow_bots: v })" />
<NSwitch :value="configDraft('slack').allow_bots" :loading="isSavingPlatform('slack')" @update:value="v => setConfigDraft('slack', { allow_bots: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChannels')" :hint="t('platform.freeResponseChannelsHint')">
<NInput :default-value="settingsStore.slack.free_response_channels || ''" :loading="isSaving('slack', 'free_response_channels')" size="small" placeholder="channel_id1,channel_id2" @change="v => saveChannel('slack', 'free_response_channels', { free_response_channels: v })" />
<NInput :value="configDraft('slack').free_response_channels || ''" :loading="isSavingPlatform('slack')" size="small" placeholder="channel_id1,channel_id2" @update:value="v => setConfigDraft('slack', { free_response_channels: v })" />
</SettingRow>
</template>
<!-- WhatsApp -->
<template v-if="p.key === 'whatsapp'">
<SettingRow :label="t('platform.waEnabled')" :hint="t('platform.waEnabledHint')">
<NSwitch :value="getCreds('whatsapp').enabled" :loading="isSaving('whatsapp', 'enabled')" @update:value="v => saveCredentials('whatsapp', 'enabled', { enabled: v })" />
<NSwitch :value="credentialDraft('whatsapp').enabled" :loading="isSavingPlatform('whatsapp')" @update:value="v => setCredentialDraft('whatsapp', { enabled: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="settingsStore.whatsapp.require_mention" :loading="isSaving('whatsapp', 'require_mention')" @update:value="v => saveChannel('whatsapp', 'require_mention', { require_mention: v })" />
<NSwitch :value="configDraft('whatsapp').require_mention" :loading="isSavingPlatform('whatsapp')" @update:value="v => setConfigDraft('whatsapp', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
<NInput :default-value="settingsStore.whatsapp.free_response_chats || ''" :loading="isSaving('whatsapp', 'free_response_chats')" size="small" placeholder="chat_id1,chat_id2" @change="v => saveChannel('whatsapp', 'free_response_chats', { free_response_chats: v })" />
<NInput :value="configDraft('whatsapp').free_response_chats || ''" :loading="isSavingPlatform('whatsapp')" size="small" placeholder="chat_id1,chat_id2" @update:value="v => setConfigDraft('whatsapp', { free_response_chats: v })" />
</SettingRow>
<SettingRow :label="t('platform.mentionPatterns')" :hint="t('platform.mentionPatternsHint')">
<NInput :default-value="(settingsStore.whatsapp.mention_patterns || []).join(', ')" :loading="isSaving('whatsapp', 'mention_patterns')" size="small" placeholder="pattern1, pattern2" @change="v => saveChannel('whatsapp', 'mention_patterns', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
<NInput :value="(configDraft('whatsapp').mention_patterns || []).join(', ')" :loading="isSavingPlatform('whatsapp')" size="small" placeholder="pattern1, pattern2" @update:value="v => setConfigDraft('whatsapp', { mention_patterns: v ? v.split(',').map(s => s.trim()) : [] })" />
</SettingRow>
</template>
<!-- Matrix -->
<template v-if="p.key === 'matrix'">
<SettingRow :label="t('platform.accessToken')" :hint="t('platform.accessTokenHint')">
<NInput :default-value="getCreds('matrix').token || ''" :loading="isSaving('matrix', 'token')" clearable size="small" class="input-lg" placeholder="syt_..." @change="v => saveCredentials('matrix', 'token', { token: v })" />
<NInput :value="credentialDraft('matrix').token || ''" :loading="isSavingPlatform('matrix')" clearable size="small" class="input-lg" placeholder="syt_..." @update:value="v => setCredentialDraft('matrix', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.homeserver')" :hint="t('platform.homeserverHint')">
<NInput :default-value="getCreds('matrix').extra?.homeserver || ''" :loading="isSaving('matrix', 'homeserver')" clearable size="small" class="input-lg" placeholder="https://matrix.org" @change="v => saveCredentials('matrix', 'homeserver', { extra: { ...getCreds('matrix').extra, homeserver: v } })" />
<NInput :value="credentialDraft('matrix').extra?.homeserver || ''" :loading="isSavingPlatform('matrix')" clearable size="small" class="input-lg" placeholder="https://matrix.org" @update:value="v => setCredentialDraft('matrix', { extra: { ...credentialDraft('matrix').extra, homeserver: v } })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionRoom')">
<NSwitch :value="settingsStore.matrix.require_mention" :loading="isSaving('matrix', 'require_mention')" @update:value="v => saveChannel('matrix', 'require_mention', { require_mention: v })" />
<NSwitch :value="configDraft('matrix').require_mention" :loading="isSavingPlatform('matrix')" @update:value="v => setConfigDraft('matrix', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.autoThread')" :hint="t('platform.autoThreadHintRoom')">
<NSwitch :value="settingsStore.matrix.auto_thread" :loading="isSaving('matrix', 'auto_thread')" @update:value="v => saveChannel('matrix', 'auto_thread', { auto_thread: v })" />
<NSwitch :value="configDraft('matrix').auto_thread" :loading="isSavingPlatform('matrix')" @update:value="v => setConfigDraft('matrix', { auto_thread: v })" />
</SettingRow>
<SettingRow :label="t('platform.dmMentionThreads')" :hint="t('platform.dmMentionThreadsHint')">
<NSwitch :value="settingsStore.matrix.dm_mention_threads" :loading="isSaving('matrix', 'dm_mention_threads')" @update:value="v => saveChannel('matrix', 'dm_mention_threads', { dm_mention_threads: v })" />
<NSwitch :value="configDraft('matrix').dm_mention_threads" :loading="isSavingPlatform('matrix')" @update:value="v => setConfigDraft('matrix', { dm_mention_threads: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseRooms')" :hint="t('platform.freeResponseRoomsHint')">
<NInput :default-value="settingsStore.matrix.free_response_rooms || ''" :loading="isSaving('matrix', 'free_response_rooms')" size="small" placeholder="room_id1,room_id2" @change="v => saveChannel('matrix', 'free_response_rooms', { free_response_rooms: v })" />
<NInput :value="configDraft('matrix').free_response_rooms || ''" :loading="isSavingPlatform('matrix')" size="small" placeholder="room_id1,room_id2" @update:value="v => setConfigDraft('matrix', { free_response_rooms: v })" />
</SettingRow>
</template>
<!-- Feishu -->
<template v-if="p.key === 'feishu'">
<SettingRow :label="t('platform.appId')" :hint="t('platform.appIdHint')">
<NInput :default-value="getCreds('feishu').extra?.app_id || ''" :loading="isSaving('feishu', 'app_id')" clearable size="small" class="input-lg" placeholder="cli_..." @change="v => saveCredentials('feishu', 'app_id', { extra: { ...getCreds('feishu').extra, app_id: v } })" />
<NInput :value="credentialDraft('feishu').extra?.app_id || ''" :loading="isSavingPlatform('feishu')" clearable size="small" class="input-lg" placeholder="cli_..." @update:value="v => setCredentialDraft('feishu', { extra: { ...credentialDraft('feishu').extra, app_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.appSecretHint')">
<NInput :default-value="getCreds('feishu').extra?.app_secret || ''" :loading="isSaving('feishu', 'app_secret')" clearable size="small" class="input-lg" placeholder="App Secret" @change="v => saveCredentials('feishu', 'app_secret', { extra: { ...getCreds('feishu').extra, app_secret: v } })" />
<NInput :value="credentialDraft('feishu').extra?.app_secret || ''" :loading="isSavingPlatform('feishu')" clearable size="small" class="input-lg" placeholder="App Secret" @update:value="v => setCredentialDraft('feishu', { extra: { ...credentialDraft('feishu').extra, app_secret: v } })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="settingsStore.feishu.require_mention" :loading="isSaving('feishu', 'require_mention')" @update:value="v => saveChannel('feishu', 'require_mention', { require_mention: v })" />
<NSwitch :value="configDraft('feishu').require_mention" :loading="isSavingPlatform('feishu')" @update:value="v => setConfigDraft('feishu', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
<NInput :default-value="settingsStore.feishu.free_response_chats || ''" :loading="isSaving('feishu', 'free_response_chats')" size="small" placeholder="chat_id1,chat_id2" @change="v => saveChannel('feishu', 'free_response_chats', { free_response_chats: v })" />
<NInput :value="configDraft('feishu').free_response_chats || ''" :loading="isSavingPlatform('feishu')" size="small" placeholder="chat_id1,chat_id2" @update:value="v => setConfigDraft('feishu', { free_response_chats: v })" />
</SettingRow>
</template>
<!-- DingTalk -->
<template v-if="p.key === 'dingtalk'">
<SettingRow :label="t('platform.clientId')" :hint="t('platform.clientIdHint')">
<NInput :default-value="getCreds('dingtalk').extra?.client_id || ''" :loading="isSaving('dingtalk', 'client_id')" clearable size="small" class="input-lg" placeholder="Client ID" @change="v => saveCredentials('dingtalk', 'client_id', { extra: { ...getCreds('dingtalk').extra, client_id: v } })" />
<NInput :value="credentialDraft('dingtalk').extra?.client_id || ''" :loading="isSavingPlatform('dingtalk')" clearable size="small" class="input-lg" placeholder="Client ID" @update:value="v => setCredentialDraft('dingtalk', { extra: { ...credentialDraft('dingtalk').extra, client_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.clientSecret')" :hint="t('platform.clientSecretHint')">
<NInput :default-value="getCreds('dingtalk').extra?.client_secret || ''" :loading="isSaving('dingtalk', 'client_secret')" clearable size="small" class="input-lg" placeholder="Client Secret" @change="v => saveCredentials('dingtalk', 'client_secret', { extra: { ...getCreds('dingtalk').extra, client_secret: v } })" />
<NInput :value="credentialDraft('dingtalk').extra?.client_secret || ''" :loading="isSavingPlatform('dingtalk')" clearable size="small" class="input-lg" placeholder="Client Secret" @update:value="v => setCredentialDraft('dingtalk', { extra: { ...credentialDraft('dingtalk').extra, client_secret: v } })" />
</SettingRow>
<SettingRow :label="t('platform.allowAllUsers')" :hint="t('platform.allowAllUsersHint')">
<NSwitch :value="boolValue(getCreds('dingtalk').allow_all_users)" :loading="isSaving('dingtalk', 'allow_all_users')" @update:value="v => saveCredentials('dingtalk', 'allow_all_users', { allow_all_users: v })" />
<NSwitch :value="boolValue(credentialDraft('dingtalk').allow_all_users)" :loading="isSavingPlatform('dingtalk')" @update:value="v => setCredentialDraft('dingtalk', { allow_all_users: v })" />
</SettingRow>
<SettingRow :label="t('platform.allowedUsers')" :hint="t('platform.allowedUsersHint')">
<NInput :default-value="getCreds('dingtalk').allowed_users || ''" :loading="isSaving('dingtalk', 'allowed_users')" clearable size="small" class="input-lg" placeholder="user_id1,user_id2" @change="v => saveCredentials('dingtalk', 'allowed_users', { allowed_users: v })" />
<NInput :value="credentialDraft('dingtalk').allowed_users || ''" :loading="isSavingPlatform('dingtalk')" clearable size="small" class="input-lg" placeholder="user_id1,user_id2" @update:value="v => setCredentialDraft('dingtalk', { allowed_users: v })" />
</SettingRow>
<SettingRow :label="t('platform.requireMention')" :hint="t('platform.requireMentionGroup')">
<NSwitch :value="settingsStore.dingtalk.require_mention" :loading="isSaving('dingtalk', 'require_mention')" @update:value="v => saveChannel('dingtalk', 'require_mention', { require_mention: v })" />
<NSwitch :value="configDraft('dingtalk').require_mention" :loading="isSavingPlatform('dingtalk')" @update:value="v => setConfigDraft('dingtalk', { require_mention: v })" />
</SettingRow>
<SettingRow :label="t('platform.freeResponseChats')" :hint="t('platform.freeResponseChatsHint')">
<NInput :default-value="settingsStore.dingtalk.free_response_chats || ''" :loading="isSaving('dingtalk', 'free_response_chats')" size="small" placeholder="chat_id1,chat_id2" @change="v => saveChannel('dingtalk', 'free_response_chats', { free_response_chats: v })" />
<NInput :value="configDraft('dingtalk').free_response_chats || ''" :loading="isSavingPlatform('dingtalk')" size="small" placeholder="chat_id1,chat_id2" @update:value="v => setConfigDraft('dingtalk', { free_response_chats: v })" />
</SettingRow>
</template>
<!-- QQBot -->
<template v-if="p.key === 'qqbot'">
<SettingRow :label="t('platform.qqAppId')" :hint="t('platform.qqAppIdHint')">
<NInput :default-value="getCreds('qqbot').extra?.app_id || ''" :loading="isSaving('qqbot', 'app_id')" clearable size="small" class="input-lg" placeholder="App ID" @change="v => saveCredentials('qqbot', 'app_id', { extra: { ...getCreds('qqbot').extra, app_id: v } })" />
<NInput :value="credentialDraft('qqbot').extra?.app_id || ''" :loading="isSavingPlatform('qqbot')" clearable size="small" class="input-lg" placeholder="App ID" @update:value="v => setCredentialDraft('qqbot', { extra: { ...credentialDraft('qqbot').extra, app_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.qqAppSecret')" :hint="t('platform.qqAppSecretHint')">
<NInput :default-value="getCreds('qqbot').extra?.client_secret || ''" :loading="isSaving('qqbot', 'client_secret')" clearable size="small" class="input-lg" placeholder="App Secret" @change="v => saveCredentials('qqbot', 'client_secret', { extra: { ...getCreds('qqbot').extra, client_secret: v } })" />
<NInput :value="credentialDraft('qqbot').extra?.client_secret || ''" :loading="isSavingPlatform('qqbot')" clearable size="small" class="input-lg" placeholder="App Secret" @update:value="v => setCredentialDraft('qqbot', { extra: { ...credentialDraft('qqbot').extra, client_secret: v } })" />
</SettingRow>
<SettingRow :label="t('platform.allowedUsers')" :hint="t('platform.allowedUsersHint')">
<NInput :default-value="getCreds('qqbot').allowed_users || ''" :loading="isSaving('qqbot', 'allowed_users')" clearable size="small" class="input-lg" placeholder="openid1,openid2" @change="v => saveCredentials('qqbot', 'allowed_users', { allowed_users: v })" />
<NInput :value="credentialDraft('qqbot').allowed_users || ''" :loading="isSavingPlatform('qqbot')" clearable size="small" class="input-lg" placeholder="openid1,openid2" @update:value="v => setCredentialDraft('qqbot', { allowed_users: v })" />
</SettingRow>
<SettingRow :label="t('platform.allowAllUsers')" :hint="t('platform.allowAllUsersHint')">
<NSwitch :value="boolValue(getCreds('qqbot').allow_all_users)" :loading="isSaving('qqbot', 'allow_all_users')" @update:value="v => saveCredentials('qqbot', 'allow_all_users', { allow_all_users: v })" />
<NSwitch :value="boolValue(credentialDraft('qqbot').allow_all_users)" :loading="isSavingPlatform('qqbot')" @update:value="v => setCredentialDraft('qqbot', { allow_all_users: v })" />
</SettingRow>
<SettingRow :label="t('platform.qqMarkdown')" :hint="t('platform.qqMarkdownHint')">
<NSwitch :value="settingsStore.qqbot.extra?.markdown_support ?? true" :loading="isSaving('qqbot', 'markdown_support')" @update:value="v => saveChannel('qqbot', 'markdown_support', { extra: { ...settingsStore.qqbot.extra, markdown_support: v } })" />
<NSwitch :value="configDraft('qqbot').extra?.markdown_support ?? true" :loading="isSavingPlatform('qqbot')" @update:value="v => setConfigDraft('qqbot', { extra: { ...configDraft('qqbot').extra, markdown_support: v } })" />
</SettingRow>
</template>
@@ -371,22 +440,34 @@ const platforms = [
</div>
</div>
<SettingRow :label="t('platform.weixinToken')" :hint="t('platform.weixinTokenHint')">
<NInput :default-value="getCreds('weixin').token || ''" :loading="isSaving('weixin', 'token')" clearable size="small" class="input-lg" placeholder="Token" @change="v => saveCredentials('weixin', 'token', { token: v })" />
<NInput :value="credentialDraft('weixin').token || ''" :loading="isSavingPlatform('weixin')" clearable size="small" class="input-lg" placeholder="Token" @update:value="v => setCredentialDraft('weixin', { token: v })" />
</SettingRow>
<SettingRow :label="t('platform.accountId')" :hint="t('platform.accountIdHint')">
<NInput :default-value="getCreds('weixin').extra?.account_id || ''" :loading="isSaving('weixin', 'account_id')" clearable size="small" class="input-lg" placeholder="Account ID" @change="v => saveCredentials('weixin', 'account_id', { extra: { ...getCreds('weixin').extra, account_id: v } })" />
<NInput :value="credentialDraft('weixin').extra?.account_id || ''" :loading="isSavingPlatform('weixin')" clearable size="small" class="input-lg" placeholder="Account ID" @update:value="v => setCredentialDraft('weixin', { extra: { ...credentialDraft('weixin').extra, account_id: v } })" />
</SettingRow>
</template>
<!-- WeCom -->
<template v-if="p.key === 'wecom'">
<SettingRow :label="t('platform.botId')" :hint="t('platform.botIdHint')">
<NInput :default-value="getCreds('wecom').extra?.bot_id || ''" :loading="isSaving('wecom', 'bot_id')" clearable size="small" class="input-lg" placeholder="Bot ID" @change="v => saveCredentials('wecom', 'bot_id', { extra: { ...getCreds('wecom').extra, bot_id: v } })" />
<NInput :value="credentialDraft('wecom').extra?.bot_id || ''" :loading="isSavingPlatform('wecom')" clearable size="small" class="input-lg" placeholder="Bot ID" @update:value="v => setCredentialDraft('wecom', { extra: { ...credentialDraft('wecom').extra, bot_id: v } })" />
</SettingRow>
<SettingRow :label="t('platform.appSecret')" :hint="t('platform.wecomSecretHint')">
<NInput :default-value="getCreds('wecom').extra?.secret || ''" :loading="isSaving('wecom', 'secret')" clearable size="small" class="input-lg" placeholder="Secret" @change="v => saveCredentials('wecom', 'secret', { extra: { ...getCreds('wecom').extra, secret: v } })" />
<NInput :value="credentialDraft('wecom').extra?.secret || ''" :loading="isSavingPlatform('wecom')" clearable size="small" class="input-lg" placeholder="Secret" @update:value="v => setCredentialDraft('wecom', { extra: { ...credentialDraft('wecom').extra, secret: v } })" />
</SettingRow>
</template>
<div class="platform-actions">
<NButton
type="primary"
size="small"
:loading="isSavingPlatform(p.key)"
:disabled="!hasUnsavedChanges(p.key)"
@click="savePlatform(p.key)"
>
{{ t('common.save') }}
</NButton>
</div>
</PlatformCard>
</section>
</template>
@@ -415,4 +496,12 @@ const platforms = [
font-size: 13px;
color: $text-secondary;
}
.platform-actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid $border-light;
}
</style>
@@ -57,9 +57,10 @@ async function viewFile(filePath: string) {
// filePath might be absolute or relative; normalize to relative under category/skill/
const base = `${props.category}/${props.skill}/`
let relPath = filePath
if (filePath.startsWith('/')) {
if (filePath.startsWith('/') || /^[a-zA-Z]:[\\/]/.test(filePath)) {
// Strip absolute prefix to get relative path
const segments = filePath.split('/.hermes/skills/')[1]
const normalizedPath = filePath.replace(/\\/g, '/')
const segments = normalizedPath.split(/(?:^|\/)(?:\.hermes|hermes)\/skills\//)[1]
if (segments) {
const afterSkillDir = segments.split('/').slice(2).join('/')
relPath = afterSkillDir
@@ -244,15 +244,6 @@ function openChangelog() {
</svg>
</div>
<div v-show="!isGroupCollapsed('system')" class="nav-group-items">
<button class="nav-item" :class="{ active: selectedKey === 'hermes.gateways' }" @click="handleNav('hermes.gateways')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" />
<rect x="2" y="14" width="20" height="8" rx="2" ry="2" />
<line x1="6" y1="6" x2="6.01" y2="6" />
<line x1="6" y1="18" x2="6.01" y2="18" />
</svg>
<span>{{ t("sidebar.gateways") }}</span>
</button>
<button class="nav-item" :class="{ active: selectedKey === 'hermes.profiles' }" @click="handleNav('hermes.profiles')">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
@@ -2,10 +2,12 @@
import { ref, computed } from 'vue'
import { NModal, NInput, NSelect } from 'naive-ui'
import { useAppStore } from '@/stores/hermes/app'
import { useProfilesStore } from '@/stores/hermes/profiles'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const appStore = useAppStore()
const profilesStore = useProfilesStore()
const showModal = ref(false)
const searchQuery = ref('')
@@ -14,15 +16,20 @@ const customInput = ref('')
const customProvider = ref('')
const selectedDisplayName = computed(() => appStore.displayModelName(appStore.selectedModel, appStore.selectedProvider))
const activeProfileName = computed(() => profilesStore.activeProfileName || 'default')
const activeModelGroups = computed(() => {
const profileModels = appStore.profileModelGroups.find(entry => entry.profile === activeProfileName.value)
return profileModels?.groups?.length ? profileModels.groups : appStore.modelGroups
})
const providerOptions = computed(() => {
const current = appStore.selectedProvider
customProvider.value = current
return appStore.modelGroups.map(g => ({ label: g.label, value: g.provider }))
return activeModelGroups.value.map(g => ({ label: g.label, value: g.provider }))
})
const modelGroupsWithCustom = computed(() =>
appStore.modelGroups.map(g => ({
activeModelGroups.value.map(g => ({
...g,
models: [
...g.models,
@@ -66,7 +73,7 @@ function isGroupCollapsed(provider: string) {
}
function handleSelect(model: string, provider: string) {
const meta = appStore.modelGroups.find(g => g.provider === provider)?.model_meta?.[model]
const meta = activeModelGroups.value.find(g => g.provider === provider)?.model_meta?.[model]
if (meta?.disabled) return
appStore.switchModel(model, provider)
showModal.value = false
@@ -85,7 +92,7 @@ function handleCustomSubmit() {
const model = customInput.value.trim()
if (!model || !customProvider.value) return
// disabled custom input
const meta = appStore.modelGroups.find(g => g.provider === customProvider.value)?.model_meta?.[model]
const meta = activeModelGroups.value.find(g => g.provider === customProvider.value)?.model_meta?.[model]
if (meta?.disabled) return
appStore.switchModel(model, customProvider.value)
showModal.value = false
+4 -1
View File
@@ -158,10 +158,11 @@ export default {
contextUsed: 'Kontext verwendet:',
sessions: 'Sitzungen',
webUiSessions: 'Sitzungen',
allProfiles: 'Alle Profile',
sessionScopeHint: 'Chat zeigt nur Web-UI/API-Server-Sitzungen. CLI-, Telegram-, Discord-, Cron- und andere Kanal-Sitzungen sind schreibgeschützt im Verlauf.',
openHistory: 'Verlauf öffnen',
hermesHistory: 'Hermes-Verlauf',
historyScopeHint: 'Schreibgeschützte Hermes-Verlaufssitzungen, nach Quelle gruppiert.',
historyScopeHint: 'Schreibgeschützte Hermes-Verlaufssitzungen des aktuellen Profils, nach Quelle gruppiert.',
noSessions: 'Keine Sitzungen',
newChat: 'Neuer Chat',
approvalKicker: 'Terminal-Berechtigung',
@@ -460,6 +461,8 @@ jobTriggered: 'Job ausgelost',
customModelHint: 'Für vom Provider unterstützte Modelle, die die API nicht zurückgibt; keine Anzeige-Umbenennung. Enter zum Laden.',
noProviders: 'Keine Anbieter gefunden. Fugen Sie einen benutzerdefinierten Anbieter hinzu, um zu beginnen.',
clearVisibleModels: 'Auswahl löschen',
currentDefault: 'Aktueller Standard',
defaultShort: 'Standard',
builtIn: 'Integriert',
customType: 'Benutzerdefiniert',
provider: 'Anbieter',
+5 -1
View File
@@ -171,10 +171,11 @@ export default {
contextUsed: 'Context used:',
sessions: 'Sessions',
webUiSessions: 'Sessions',
allProfiles: 'All profiles',
sessionScopeHint: 'Chat shows Web UI/API Server sessions only. CLI, Telegram, Discord, Cron, and other channel sessions are read-only in History.',
openHistory: 'Open History',
hermesHistory: 'Hermes History',
historyScopeHint: 'Read-only Hermes history sessions grouped by source.',
historyScopeHint: 'Read-only Hermes history sessions for the current profile, grouped by source.',
noSessions: 'No sessions',
searchTitle: 'Search Sessions',
searchSubtitle: 'Search by title or message content',
@@ -587,6 +588,7 @@ export default {
xaiLoginTitle: 'xAI Grok OAuth Login',
xaiWaiting: 'Complete authorization in the opened xAI page. This window will close automatically once approved.',
xaiOpenLink: 'Open xAI authorization page',
xaiCopyLink: 'Copy authorization link',
xaiApproved: 'Sign-in succeeded!',
xaiExpired: 'The authorization link has expired. Please retry.',
customBadge: 'CUSTOM',
@@ -618,6 +620,8 @@ export default {
visibilitySaveFailed: 'Failed to save visible models',
showAllModels: 'Show all models',
clearVisibleModels: 'Clear selection',
currentDefault: 'Current default',
defaultShort: 'Default',
builtIn: 'Built-in',
customType: 'Custom',
provider: 'Provider',
+4 -1
View File
@@ -158,10 +158,11 @@ export default {
contextUsed: 'Contexto utilizado:',
sessions: 'Sesiones',
webUiSessions: 'Sesiones',
allProfiles: 'Todos los perfiles',
sessionScopeHint: 'Chat solo muestra sesiones de Web UI/API Server. Las sesiones de CLI, Telegram, Discord, Cron y otros canales son de solo lectura en Historial.',
openHistory: 'Abrir historial',
hermesHistory: 'Historial de Hermes',
historyScopeHint: 'Sesiones del historial de Hermes, de solo lectura y agrupadas por origen.',
historyScopeHint: 'Sesiones del historial de Hermes del perfil actual, de solo lectura y agrupadas por origen.',
noSessions: 'Sin sesiones',
newChat: 'Nuevo chat',
approvalKicker: 'Permiso de terminal',
@@ -460,6 +461,8 @@ jobTriggered: 'Job ejecutado',
customModelHint: 'Para modelos compatibles con el proveedor que la API no devuelve; no es un cambio de nombre visible. Enter para cargar.',
noProviders: 'No se encontraron proveedores. Anade un proveedor personalizado para comenzar.',
clearVisibleModels: 'Borrar selección',
currentDefault: 'Predeterminado actual',
defaultShort: 'Predeterminado',
builtIn: 'Integrado',
customType: 'Personalizado',
provider: 'Proveedor',
+4 -1
View File
@@ -158,10 +158,11 @@ export default {
contextUsed: 'Contexte utilise :',
sessions: 'Sessions',
webUiSessions: 'Sessions',
allProfiles: 'Tous les profils',
sessionScopeHint: 'Le chat affiche uniquement les sessions Web UI/API Server. Les sessions CLI, Telegram, Discord, Cron et autres canaux sont en lecture seule dans Historique.',
openHistory: 'Ouvrir lhistorique',
hermesHistory: 'Historique Hermes',
historyScopeHint: 'Sessions dhistorique Hermes en lecture seule, regroupées par source.',
historyScopeHint: 'Sessions dhistorique Hermes du profil actuel en lecture seule, regroupées par source.',
noSessions: 'Aucune session',
newChat: 'Nouvelle discussion',
approvalKicker: 'Permission terminal',
@@ -460,6 +461,8 @@ jobTriggered: 'Job declenche',
customModelHint: 'Pour les modèles pris en charge par le fournisseur mais non renvoyés par lAPI ; ce nest pas un renommage affiché. Entrée pour charger.',
noProviders: 'Aucun fournisseur trouve. Ajoutez un fournisseur personnalise pour commencer.',
clearVisibleModels: 'Effacer la sélection',
currentDefault: 'Par défaut actuel',
defaultShort: 'Défaut',
builtIn: 'Integre',
customType: 'Personnalise',
provider: 'Fournisseur',
+4 -1
View File
@@ -158,10 +158,11 @@ export default {
contextUsed: 'コンテキスト使用量:',
sessions: 'セッション',
webUiSessions: 'セッション',
allProfiles: 'すべてのプロファイル',
sessionScopeHint: 'チャットには Web UI/API Server セッションのみ表示されます。CLI、Telegram、Discord、Cron などのチャンネルセッションは履歴で読み取り専用として表示されます。',
openHistory: '履歴を開く',
hermesHistory: 'Hermes 履歴',
historyScopeHint: 'ソース別にグループ化された Hermes 履歴セッションを読み取り専用で表示します。',
historyScopeHint: '現在の profile の Hermes 履歴セッションをソース別に読み取り専用で表示します。',
noSessions: 'セッションがありません',
newChat: '新しいチャット',
approvalKicker: 'ターミナル権限',
@@ -460,6 +461,8 @@ export default {
customModelHint: 'プロバイダーは対応しているが API が返さないモデル用です。表示名の変更ではありません。Enter で読み込み。',
noProviders: 'プロバイダーがありません。カスタムプロバイダーを追加して始めましょう。',
clearVisibleModels: '選択をクリア',
currentDefault: '現在のデフォルト',
defaultShort: 'デフォルト',
builtIn: '組み込み',
customType: 'カスタム',
provider: 'プロバイダー',
+4 -1
View File
@@ -158,10 +158,11 @@ export default {
contextUsed: '사용된 컨텍스트:',
sessions: '세션',
webUiSessions: '세션',
allProfiles: '모든 프로필',
sessionScopeHint: '채팅에는 Web UI/API Server 세션만 표시됩니다. CLI, Telegram, Discord, Cron 등 채널 세션은 기록에서 읽기 전용으로 볼 수 있습니다.',
openHistory: '기록 열기',
hermesHistory: 'Hermes 기록',
historyScopeHint: '소스별로 그룹화된 Hermes 기록 세션을 읽기 전용으로 봅니다.',
historyScopeHint: '현재 profile의 Hermes 기록 세션을 소스별로 읽기 전용으로 봅니다.',
noSessions: '세션 없음',
newChat: '새 채팅',
approvalKicker: '터미널 권한',
@@ -460,6 +461,8 @@ export default {
customModelHint: '제공자는 지원하지만 API가 반환하지 않는 모델용입니다. 표시 이름 변경이 아닙니다. Enter로 불러옵니다.',
noProviders: 'Provider가 없습니다. 사용자 지정 Provider를 추가하여 시작하세요.',
clearVisibleModels: '선택 지우기',
currentDefault: '현재 기본값',
defaultShort: '기본값',
builtIn: '내장',
customType: '사용자 지정',
provider: 'Provider',
+4 -1
View File
@@ -158,10 +158,11 @@ export default {
contextUsed: 'Contexto utilizado:',
sessions: 'Sessoes',
webUiSessions: 'Sessões',
allProfiles: 'Todos os perfis',
sessionScopeHint: 'O chat mostra apenas sessões da Web UI/API Server. Sessões de CLI, Telegram, Discord, Cron e outros canais são somente leitura no Histórico.',
openHistory: 'Abrir histórico',
hermesHistory: 'Histórico Hermes',
historyScopeHint: 'Sessões do histórico Hermes somente leitura, agrupadas por origem.',
historyScopeHint: 'Sessões do histórico Hermes do perfil atual, somente leitura, agrupadas por origem.',
noSessions: 'Sem sessoes',
newChat: 'Novo chat',
approvalKicker: 'Permissão do terminal',
@@ -460,6 +461,8 @@ jobTriggered: 'Job acionado',
customModelHint: 'Para modelos compatíveis com o provedor que a API não retorna; não é uma renomeação de exibição. Enter para carregar.',
noProviders: 'Nenhum provedor encontrado. Adicione um provedor personalizado para comecar.',
clearVisibleModels: 'Limpar seleção',
currentDefault: 'Padrão atual',
defaultShort: 'Padrão',
builtIn: 'Integrado',
customType: 'Personalizado',
provider: 'Provedor',
+4 -1
View File
@@ -170,10 +170,11 @@ export default {
contextUsed: '上下文已用:',
sessions: '工作階段',
webUiSessions: '工作階段',
allProfiles: '全部設定',
sessionScopeHint: '這裡只顯示目前工作階段;CLI、Telegram、Discord、Cron 等頻道工作階段在歷史中以唯讀方式查看。',
openHistory: '開啟歷史',
hermesHistory: 'Hermes 歷史',
historyScopeHint: '這裡按來源以唯讀方式查看 Hermes 歷史工作階段。',
historyScopeHint: '這裡按來源以唯讀方式查看目前 profile 的 Hermes 歷史工作階段。',
noSessions: '目前無工作階段',
searchTitle: '搜尋工作階段',
searchSubtitle: '依標題或訊息內容搜尋',
@@ -610,6 +611,8 @@ export default {
visibilitySaveFailed: '儲存可見模型失敗',
showAllModels: '顯示全部模型',
clearVisibleModels: '取消全選',
currentDefault: '目前預設',
defaultShort: '預設',
aliasEdit: '重新命名',
aliasTitle: '模型顯示名',
aliasTitleFor: '{model} 的顯示名',
+5 -1
View File
@@ -171,10 +171,11 @@ export default {
contextUsed: '上下文已用:',
sessions: '会话',
webUiSessions: '会话',
allProfiles: '全部配置',
sessionScopeHint: '这里只显示当前会话;CLI、Telegram、Discord、Cron 等通道会话在历史中只读查看。',
openHistory: '打开历史',
hermesHistory: 'Hermes 历史',
historyScopeHint: '这里按来源只读查看 Hermes 历史会话。',
historyScopeHint: '这里按来源只读查看当前 profile 的 Hermes 历史会话。',
noSessions: '暂无会话',
searchTitle: '搜索会话',
searchSubtitle: '按标题或消息内容搜索',
@@ -587,6 +588,7 @@ export default {
xaiLoginTitle: 'xAI Grok OAuth 登录',
xaiWaiting: '请在打开的 xAI 页面完成授权。授权完成后窗口会自动关闭。',
xaiOpenLink: '打开 xAI 授权页',
xaiCopyLink: '复制授权链接',
xaiApproved: '登录成功!',
xaiExpired: '授权链接已过期,请重试。',
customBadge: '自定义',
@@ -618,6 +620,8 @@ export default {
visibilitySaveFailed: '保存可见模型失败',
showAllModels: '显示全部模型',
clearVisibleModels: '取消全选',
currentDefault: '当前默认',
defaultShort: '默认',
builtIn: '内置',
customType: '自定义',
provider: 'Provider',
-5
View File
@@ -75,11 +75,6 @@ const router = createRouter({
name: 'hermes.settings',
component: () => import('@/views/hermes/SettingsView.vue'),
},
{
path: '/hermes/gateways',
name: 'hermes.gateways',
component: () => import('@/views/hermes/GatewaysView.vue'),
},
{
path: '/hermes/channels',
name: 'hermes.channels',
+4
View File
@@ -9,6 +9,7 @@ import {
updateModelAlias,
type AvailableModelGroup,
type AvailableModelsResponse,
type ProfileAvailableModels,
type ModelVisibility,
type ModelVisibilityRule,
} from '@/api/hermes/system'
@@ -31,6 +32,7 @@ export const useAppStore = defineStore('app', () => {
const clientOutdated = ref(false)
const updating = ref(false)
const modelGroups = ref<AvailableModelGroup[]>([])
const profileModelGroups = ref<ProfileAvailableModels[]>([])
const selectedModel = ref('')
const selectedProvider = ref('')
const customModels = ref<Record<string, string[]>>({})
@@ -80,6 +82,7 @@ export const useAppStore = defineStore('app', () => {
function applyAvailableModelsResponse(res: AvailableModelsResponse) {
modelGroups.value = res.groups
profileModelGroups.value = res.profiles || []
modelAliases.value = res.model_aliases || {}
modelVisibility.value = res.model_visibility || {}
@@ -300,6 +303,7 @@ export const useAppStore = defineStore('app', () => {
doUpdate,
reloadClient,
modelGroups,
profileModelGroups,
customModels,
modelAliases,
modelVisibility,
+37 -14
View File
@@ -58,6 +58,7 @@ export interface PendingApproval {
export interface Session {
id: string
profile?: string
title: string
source?: string
messages: Message[]
@@ -232,6 +233,7 @@ function mapHermesMessages(msgs: HermesMessage[]): Message[] {
function mapHermesSession(s: SessionSummary): Session {
return {
id: s.id,
profile: s.profile || 'default',
title: s.title || '',
source: s.source || undefined,
messages: [],
@@ -389,10 +391,19 @@ export const useChatStore = defineStore('chat', () => {
return streamStates.value.has(sessionId) || serverWorking.value.has(sessionId)
}
async function loadSessions() {
function clearActiveSession() {
activeSessionId.value = null
activeSession.value = null
focusMessageId.value = null
setAbortState(null)
setCompressionState(null)
removeItem(storageKey())
}
async function loadSessions(profile?: string | null) {
isLoadingSessions.value = true
try {
const list = await fetchSessions()
const list = await fetchSessions(undefined, undefined, profile || undefined)
const fresh = list.map(mapHermesSession)
// Preserve already-loaded messages for sessions that are still present,
// so we don't blow away the active session's messages on refresh.
@@ -410,6 +421,8 @@ export const useChatStore = defineStore('chat', () => {
: sessions.value[0]?.id
if (targetId) {
await switchSession(targetId)
} else {
clearActiveSession()
}
} catch (err) {
console.error('Failed to load sessions:', err)
@@ -439,14 +452,17 @@ export const useChatStore = defineStore('chat', () => {
}
function createSession(): Session {
function createSession(options: { profile?: string; model?: string; provider?: string } = {}): Session {
const session: Session = {
id: uid(),
profile: options.profile || useProfilesStore().activeProfileName || 'default',
title: '',
source: 'api_server',
source: 'cli',
messages: [],
createdAt: Date.now(),
updatedAt: Date.now(),
model: options.model || undefined,
provider: options.provider || '',
}
sessions.value.unshift(session)
return session
@@ -606,12 +622,13 @@ export const useChatStore = defineStore('chat', () => {
resumeServerWorkingRun(sessionId)
}
function newChat() {
const session = createSession()
// Inherit current global model
function newChat(options: { profile?: string; model?: string; provider?: string } = {}) {
const appStore = useAppStore()
session.model = appStore.selectedModel || undefined
session.provider = appStore.selectedProvider || ''
const session = createSession({
profile: options.profile,
model: options.model || appStore.selectedModel || undefined,
provider: options.provider || appStore.selectedProvider || '',
})
switchSession(session.id)
}
@@ -852,7 +869,10 @@ export const useChatStore = defineStore('chat', () => {
// Capture session ID at send time — all callbacks use this, not activeSessionId
const sid = activeSessionId.value!
const isBridgeSlashCommand = activeSession.value?.source === 'cli' && content.trim().startsWith('/')
const shouldSendInitialSessionConfig = activeSession.value
? activeSession.value.messageCount == null || activeSession.value.messageCount === 0
: false
const isBridgeSlashCommand = content.trim().startsWith('/')
const isBridgeCompressCommand = isBridgeSlashCommand && /^\/compress(?:\s|$)/i.test(content.trim())
const wasLiveBeforeSend = isSessionLive(sid)
const shouldQueue = wasLiveBeforeSend && !isBridgeSlashCommand
@@ -912,19 +932,22 @@ export const useChatStore = defineStore('chat', () => {
const appStore = useAppStore()
await appStore.waitForModelsForRun()
const sessionModel = activeSession.value?.model || appStore.selectedModel
const isBridgeSource = activeSession.value?.source === 'cli'
const sessionProvider = activeSession.value?.provider || appStore.selectedProvider
const runPayload = {
input,
session_id: sid,
model: isBridgeSource ? undefined : sessionModel || undefined,
provider: isBridgeSource ? undefined : sessionProvider || undefined,
profile: shouldSendInitialSessionConfig ? activeSession.value?.profile || undefined : undefined,
model: shouldSendInitialSessionConfig ? sessionModel || undefined : undefined,
provider: shouldSendInitialSessionConfig ? sessionProvider || undefined : undefined,
model_groups: appStore.modelGroups.map(group => ({
provider: group.provider,
models: group.models,
})),
queue_id: userMsg.id,
source: (isBridgeSource ? 'cli' : 'api_server') as 'cli' | 'api_server',
source: 'cli' as const,
}
if (shouldSendInitialSessionConfig && activeSession.value) {
activeSession.value.messageCount = Math.max(activeSession.value.messageCount || 0, 1)
}
if (shouldQueue) {
@@ -1,51 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { fetchGateways, startGateway, stopGateway, type GatewayStatus } from '@/api/hermes/gateways'
export const useGatewayStore = defineStore('gateways', () => {
const gateways = ref<GatewayStatus[]>([])
const loading = ref(false)
async function fetchStatus() {
loading.value = true
try {
const data = await fetchGateways()
gateways.value = Array.isArray(data) ? data : Object.values(data || {})
} finally {
loading.value = false
}
}
async function start(name: string) {
loading.value = true
try {
const status = await startGateway(name)
// Update the specific gateway in the list
const idx = gateways.value.findIndex(g => g.profile === name)
if (idx >= 0) {
gateways.value[idx] = status
} else {
gateways.value.push(status)
}
} finally {
loading.value = false
}
}
async function stop(name: string) {
loading.value = true
try {
await stopGateway(name)
// Update the specific gateway in the list
const gw = gateways.value.find(g => g.profile === name)
if (gw) {
gw.running = false
gw.pid = undefined
}
} finally {
loading.value = false
}
}
return { gateways, loading, fetchStatus, start, stop }
})
+344 -6
View File
@@ -1,5 +1,8 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { getApiKey } from '@/api/client'
import { getDownloadUrl } from '@/api/hermes/download'
import type { Attachment, ContentBlock } from './chat'
import {
connectGroupChat,
disconnectGroupChat,
@@ -22,6 +25,66 @@ import {
clearRoomContext,
} from '@/api/hermes/group-chat'
async function uploadGroupFiles(attachments: Attachment[]): Promise<{ name: string; path: string }[]> {
const formData = new FormData()
for (const att of attachments) {
if (att.file) formData.append('file', att.file, att.name)
}
const token = getApiKey()
const res = await fetch('/upload', {
method: 'POST',
body: formData,
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error(`Upload failed: ${res.status}`)
const data = await res.json() as { files: { name: string; path: string }[] }
return data.files
}
function buildGroupContentBlocks(content: string, attachments: Attachment[], files: { name: string; path: string }[]): ContentBlock[] {
const blocks: ContentBlock[] = []
if (content.trim()) blocks.push({ type: 'text', text: content.trim() })
for (let i = 0; i < files.length; i += 1) {
const file = files[i]
const attachment = attachments[i]
if (attachment?.type.startsWith('image/')) {
blocks.push({
type: 'image',
name: file.name,
path: file.path,
media_type: attachment.type,
})
} else {
blocks.push({
type: 'file',
name: file.name,
path: file.path,
media_type: attachment?.type,
})
}
}
return blocks
}
function uid(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
}
function normalizeLocalFilePath(path: string): string {
return /^[a-zA-Z]:\\/.test(path) ? path.replace(/\\/g, '/') : path
}
export interface GroupPendingApproval {
roomId: string
agentName: string
approvalId: string
command: string
description: string
choices: Array<'once' | 'session' | 'always' | 'deny'>
allowPermanent: boolean
requestedAt: number
}
export const useGroupChatStore = defineStore('groupChat', () => {
// ─── State ─────────────────────────────────────────────
const connected = ref(false)
@@ -35,6 +98,18 @@ export const useGroupChatStore = defineStore('groupChat', () => {
const error = ref<string | null>(null)
const typingUsers = ref<Map<string, { name: string; timer: ReturnType<typeof setTimeout> }>>(new Map())
const contextStatuses = ref<Map<string, { agentName: string; status: string }>>(new Map())
const autoPlaySpeechEnabled = ref(false)
const pendingApprovals = ref<Map<string, GroupPendingApproval>>(new Map())
function setAutoPlaySpeech(enabled: boolean) {
autoPlaySpeechEnabled.value = enabled
}
function playMessageSpeech(messageId: string, content: string) {
window.dispatchEvent(new CustomEvent('auto-play-speech', {
detail: { messageId, content },
}))
}
// Computed: returns first active status for backward compat
const contextStatus = computed(() => {
@@ -43,13 +118,18 @@ export const useGroupChatStore = defineStore('groupChat', () => {
}
return null
})
const activePendingApproval = computed(() => {
if (!currentRoomId.value) return null
for (const approval of pendingApprovals.value.values()) {
if (approval.roomId === currentRoomId.value) return approval
}
return null
})
const userId = ref(getStoredUserId())
const userName = ref(getStoredUserName() || '')
// ─── Computed ───────────────────────────────────────────
const sortedMessages = computed(() => {
return [...messages.value].sort((a, b) => a.timestamp - b.timestamp)
})
const sortedMessages = computed(() => mapGroupMessages([...messages.value].sort((a, b) => a.timestamp - b.timestamp)))
const memberNames = computed(() => {
return members.value.map(m => m.name)
@@ -94,10 +174,89 @@ export const useGroupChatStore = defineStore('groupChat', () => {
socket.on('message', (msg: ChatMessage) => {
if (msg.roomId === currentRoomId.value) {
const idx = messages.value.findIndex(m => m.id === msg.id)
const existing = idx >= 0 ? messages.value[idx] : null
const resolvedMsg = {
...msg,
isStreaming: false,
attachments: existing?.attachments,
}
if (idx >= 0) {
messages.value[idx] = resolvedMsg
messages.value = [...messages.value]
} else {
messages.value.push(resolvedMsg)
}
if (autoPlaySpeechEnabled.value && resolvedMsg.role === 'assistant' && resolvedMsg.content?.trim()) {
setTimeout(() => playMessageSpeech(resolvedMsg.id, resolvedMsg.content), 300)
}
}
})
socket.on('message_stream_start', (msg: ChatMessage) => {
if (msg.roomId !== currentRoomId.value) return
messages.value = messages.value.filter(m => !(
m.roomId === msg.roomId &&
m.senderId === msg.senderId &&
m.id !== msg.id &&
m.isStreaming &&
!m.content?.trim() &&
!m.reasoning?.trim() &&
!m.tool_calls?.length
))
msg.isStreaming = true
const idx = messages.value.findIndex(m => m.id === msg.id)
if (idx >= 0) {
messages.value[idx] = { ...messages.value[idx], ...msg, isStreaming: true }
messages.value = [...messages.value]
} else {
messages.value.push(msg)
}
})
socket.on('message_stream_delta', (data: { roomId: string; id: string; delta: string }) => {
if (data.roomId !== currentRoomId.value) return
const idx = messages.value.findIndex(m => m.id === data.id)
if (idx < 0) return
messages.value[idx] = {
...messages.value[idx],
content: messages.value[idx].content + data.delta,
}
messages.value = [...messages.value]
})
socket.on('message_reasoning_delta', (data: { roomId: string; id: string; delta: string }) => {
if (data.roomId !== currentRoomId.value) return
const idx = messages.value.findIndex(m => m.id === data.id)
if (idx < 0) return
messages.value[idx] = {
...messages.value[idx],
reasoning: (messages.value[idx].reasoning || '') + data.delta,
reasoning_content: (messages.value[idx].reasoning_content || '') + data.delta,
isStreaming: true,
}
messages.value = [...messages.value]
})
socket.on('message_stream_end', (data: { roomId: string; id: string }) => {
if (data.roomId !== currentRoomId.value) return
const idx = messages.value.findIndex(m => m.id === data.id)
if (
idx >= 0 &&
!messages.value[idx].content?.trim() &&
!messages.value[idx].reasoning?.trim() &&
!messages.value[idx].tool_calls?.length
) {
messages.value.splice(idx, 1)
} else if (idx >= 0) {
messages.value[idx] = {
...messages.value[idx],
isStreaming: false,
}
messages.value = [...messages.value]
}
})
socket.on('member_joined', (data: { roomId: string; members: MemberInfo[] }) => {
if (data.roomId === currentRoomId.value) {
members.value = data.members
@@ -129,6 +288,18 @@ export const useGroupChatStore = defineStore('groupChat', () => {
if (data.roomId === currentRoomId.value) {
if (data.status === 'ready') {
contextStatuses.value.delete(data.agentName)
messages.value = messages.value
.map(m => (
m.senderName === data.agentName && m.isStreaming
? { ...m, isStreaming: false }
: m
))
.filter(m => !(
m.senderName === data.agentName &&
!m.content?.trim() &&
!m.reasoning?.trim() &&
!m.tool_calls?.length
))
} else {
contextStatuses.value.set(data.agentName, { agentName: data.agentName, status: data.status })
}
@@ -137,6 +308,30 @@ export const useGroupChatStore = defineStore('groupChat', () => {
}
})
socket.on('approval.requested', (data: { roomId: string; agentName?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean }) => {
if (!data.approval_id) return
const choices = (Array.isArray(data.choices) ? data.choices : ['once', 'session', 'deny'])
.filter((choice): choice is GroupPendingApproval['choices'][number] =>
choice === 'once' || choice === 'session' || choice === 'always' || choice === 'deny')
pendingApprovals.value.set(data.approval_id, {
roomId: data.roomId,
agentName: data.agentName || '',
approvalId: data.approval_id,
command: data.command || '',
description: data.description || '',
choices: choices.length ? choices : ['once', 'session', 'deny'],
allowPermanent: Boolean(data.allow_permanent),
requestedAt: Date.now(),
})
pendingApprovals.value = new Map(pendingApprovals.value)
})
socket.on('approval.resolved', (data: { approval_id?: string }) => {
if (!data.approval_id) return
pendingApprovals.value.delete(data.approval_id)
pendingApprovals.value = new Map(pendingApprovals.value)
})
socket.on('room_updated', (data: { roomId: string; totalTokens: number }) => {
const room = rooms.value.find(r => r.id === data.roomId)
if (room) room.totalTokens = data.totalTokens
@@ -149,6 +344,7 @@ export const useGroupChatStore = defineStore('groupChat', () => {
messages.value = []
typingUsers.value.clear()
contextStatuses.value.clear()
pendingApprovals.value.clear()
}
})
}
@@ -163,6 +359,7 @@ export const useGroupChatStore = defineStore('groupChat', () => {
roomName.value = ''
typingUsers.value.clear()
contextStatuses.value.clear()
pendingApprovals.value.clear()
}
function setUserInfo(name: string, description: string) {
@@ -194,7 +391,11 @@ export const useGroupChatStore = defineStore('groupChat', () => {
const socket = getSocket()
if (socket) {
await new Promise<void>((resolve) => {
socket.emit('join', { roomId, name: userName.value || undefined }, (res: any) => {
socket.emit('join', {
roomId,
name: userName.value || undefined,
description: localStorage.getItem('gc_user_description') || undefined,
}, (res: any) => {
if (!res?.error) {
members.value = res.members || []
if (res.agents) agents.value = res.agents
@@ -222,14 +423,34 @@ export const useGroupChatStore = defineStore('groupChat', () => {
}
}
async function sendMessage(content: string) {
async function sendMessage(content: string, attachments?: Attachment[]) {
const socket = getSocket()
if (!socket || !currentRoomId.value) return
emitStopTyping()
const messageId = uid()
let finalContent: string | ContentBlock[] = content.trim()
if (attachments?.length) {
const uploaded = await uploadGroupFiles(attachments)
finalContent = buildGroupContentBlocks(content, attachments, uploaded)
const urlMap = new Map(uploaded.map(f => {
return [f.name, getDownloadUrl(normalizeLocalFilePath(f.path), f.name)]
}))
messages.value.push({
id: messageId,
roomId: currentRoomId.value,
senderId: userId.value,
senderName: userName.value || 'You',
content: JSON.stringify(finalContent),
timestamp: Date.now(),
role: 'user',
attachments: attachments.map(att => ({ ...att, url: urlMap.get(att.name) || att.url, file: undefined })),
})
}
return new Promise<void>((resolve, reject) => {
socket!.emit('message', { roomId: currentRoomId.value, content }, (res: { id?: string; error?: string }) => {
socket!.emit('message', { roomId: currentRoomId.value, id: messageId, content: finalContent }, (res: { id?: string; error?: string }) => {
if (res.error) {
messages.value = messages.value.filter(m => m.id !== messageId)
reject(new Error(res.error))
return
}
@@ -365,6 +586,35 @@ export const useGroupChatStore = defineStore('groupChat', () => {
if (_typingTimer) { clearTimeout(_typingTimer); _typingTimer = null }
}
async function interruptAgent(agentName: string) {
const socket = getSocket()
if (!socket || !currentRoomId.value) return
await new Promise<void>((resolve, reject) => {
socket.emit('interrupt_agent', { roomId: currentRoomId.value, agentName }, (res: any) => {
if (res?.error) reject(new Error(res.error))
else resolve()
})
})
}
async function respondApproval(choice: GroupPendingApproval['choices'][number]) {
const socket = getSocket()
const pending = activePendingApproval.value
if (!socket || !pending) return
await new Promise<void>((resolve, reject) => {
socket.emit('approval.respond', {
roomId: pending.roomId,
approval_id: pending.approvalId,
choice,
}, (res: any) => {
if (res?.error) reject(new Error(res.error))
else resolve()
})
})
pendingApprovals.value.delete(pending.approvalId)
pendingApprovals.value = new Map(pendingApprovals.value)
}
return {
// State
connected,
@@ -378,6 +628,9 @@ export const useGroupChatStore = defineStore('groupChat', () => {
error,
contextStatus,
contextStatuses,
pendingApprovals,
activePendingApproval,
autoPlaySpeechEnabled,
userId,
userName,
// Computed
@@ -389,11 +642,14 @@ export const useGroupChatStore = defineStore('groupChat', () => {
connect,
disconnect,
setUserInfo,
setAutoPlaySpeech,
joinRoom,
sendMessage,
loadRooms,
emitTyping,
emitStopTyping,
interruptAgent,
respondApproval,
createNewRoom,
joinByCode,
deleteRoom,
@@ -404,3 +660,85 @@ export const useGroupChatStore = defineStore('groupChat', () => {
removeAgentFromRoom,
}
})
function mapGroupMessages(msgs: ChatMessage[]): ChatMessage[] {
const toolNameMap = new Map<string, string>()
const toolArgsMap = new Map<string, string>()
for (const msg of msgs) {
if (msg.role === 'assistant' && msg.tool_calls?.length) {
for (const tc of msg.tool_calls) {
if (!tc?.id) continue
if (tc.function?.name) toolNameMap.set(tc.id, tc.function.name)
if (tc.function?.arguments) toolArgsMap.set(tc.id, tc.function.arguments)
}
}
}
const result: ChatMessage[] = []
for (const msg of msgs) {
if (
msg.role !== 'tool' &&
!msg.tool_calls?.length &&
!msg.content?.trim() &&
!msg.reasoning?.trim() &&
(!msg.isStreaming || msg.finish_reason === 'streaming')
) {
continue
}
if (msg.role === 'assistant' && msg.tool_calls?.length && !msg.content?.trim()) {
for (const tc of msg.tool_calls) {
result.push({
...msg,
id: `${msg.id}_${tc.id}`,
role: 'tool',
content: '',
toolName: tc.function?.name || undefined,
toolCallId: tc.id,
toolArgs: tc.function?.arguments || undefined,
toolStatus: 'running',
})
}
continue
}
if (msg.role === 'tool') {
const tcId = msg.tool_call_id || ''
const toolName = msg.tool_name || toolNameMap.get(tcId) || undefined
const toolArgs = toolArgsMap.get(tcId) || undefined
let preview = ''
if (msg.content) {
try {
const parsed = JSON.parse(msg.content)
preview = parsed.url || parsed.title || parsed.preview || parsed.summary || ''
} catch {
preview = msg.content.slice(0, 80)
}
}
const placeholderIdx = result.findIndex(
m => m.role === 'tool' && m.toolCallId === tcId && !m.toolResult
)
const merged: ChatMessage = {
...msg,
id: placeholderIdx !== -1 ? result[placeholderIdx].id : msg.id,
senderId: placeholderIdx !== -1 ? result[placeholderIdx].senderId : msg.senderId,
senderName: placeholderIdx !== -1 ? result[placeholderIdx].senderName : msg.senderName,
timestamp: placeholderIdx !== -1 ? result[placeholderIdx].timestamp : msg.timestamp,
role: 'tool',
content: '',
toolName: toolName || (placeholderIdx !== -1 ? result[placeholderIdx].toolName : undefined),
toolCallId: tcId || undefined,
toolArgs: toolArgs || (placeholderIdx !== -1 ? result[placeholderIdx].toolArgs : undefined),
toolPreview: typeof preview === 'string' ? preview.slice(0, 100) || undefined : undefined,
toolResult: msg.content || undefined,
toolStatus: 'done',
}
if (placeholderIdx !== -1) result[placeholderIdx] = merged
else result.push(merged)
continue
}
result.push(msg)
}
return result
}
+5 -1
View File
@@ -9,6 +9,7 @@ export const useModelsStore = defineStore('models', () => {
const providers = ref<AvailableModelGroup[]>([])
const allProviders = ref<AvailableModelGroup[]>([])
const defaultModel = ref('')
const defaultProvider = ref('')
const loading = ref(false)
const customProviders = computed(() =>
@@ -26,7 +27,7 @@ export const useModelsStore = defineStore('models', () => {
provider: g.provider,
label: g.label,
base_url: g.base_url,
isDefault: m === defaultModel.value,
isDefault: m === defaultModel.value && g.provider === defaultProvider.value,
})),
),
)
@@ -39,6 +40,7 @@ export const useModelsStore = defineStore('models', () => {
providers.value = res.groups
allProviders.value = res.allProviders
defaultModel.value = res.default
defaultProvider.value = res.default_provider || ''
const appStore = useAppStore()
appStore.applyAvailableModelsResponse(res)
} catch (err) {
@@ -51,6 +53,7 @@ export const useModelsStore = defineStore('models', () => {
async function setDefaultModel(modelId: string, provider: string) {
await systemApi.updateDefaultModel({ default: modelId, provider })
defaultModel.value = modelId
defaultProvider.value = provider
const appStore = useAppStore()
appStore.reloadModels()
}
@@ -69,6 +72,7 @@ export const useModelsStore = defineStore('models', () => {
providers,
allProviders,
defaultModel,
defaultProvider,
loading,
customProviders,
builtinProviders,
@@ -83,10 +83,10 @@ export const useSettingsStore = defineStore('settings', () => {
}
}
async function saveSection(section: string, values: Record<string, any>) {
async function saveSection(section: string, values: Record<string, any>, options?: { restart?: boolean }) {
saving.value = true
try {
await configApi.updateConfigSection(section, values)
await configApi.updateConfigSection(section, values, options)
switch (section) {
case 'display': display.value = { ...display.value, ...values }; break
case 'agent': agent.value = { ...agent.value, ...values }; break
@@ -1,191 +0,0 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { NSpin, NButton, NTag, useMessage } from 'naive-ui'
import { useI18n } from 'vue-i18n'
import { useGatewayStore } from '@/stores/hermes/gateways'
const { t } = useI18n()
const message = useMessage()
const gatewayStore = useGatewayStore()
onMounted(() => {
gatewayStore.fetchStatus()
})
async function handleToggle(name: string, running: boolean) {
try {
if (running) {
await gatewayStore.stop(name)
message.success(`${t('gateways.stopped')}: ${name}`)
} else {
await gatewayStore.start(name)
message.success(`${t('gateways.started')}: ${name}`)
}
} catch (err: any) {
message.error(err.message)
}
}
</script>
<template>
<div class="gateways-view">
<header class="page-header">
<h2 class="header-title">{{ t('gateways.title') }}</h2>
</header>
<div class="gateways-content">
<NSpin :show="gatewayStore.loading" size="large">
<div v-if="gatewayStore.gateways.length === 0" class="empty-state">
{{ t('common.noData') }}
</div>
<div v-else class="gateway-list">
<div v-for="gw in gatewayStore.gateways" :key="gw.profile" class="gateway-card">
<div class="gateway-info">
<div class="gateway-name">{{ gw.profile }}</div>
<div class="gateway-meta">
<span class="meta-item">{{ gw.host }}:{{ gw.port }}</span>
<span v-if="gw.pid" class="meta-item">PID: {{ gw.pid }}</span>
</div>
<div v-if="gw.diagnostics" class="gateway-diagnostics">
<span class="diag-item">{{ gw.diagnostics.reason }}</span>
<span class="diag-item">PID: {{ gw.diagnostics.pid_path }}</span>
<span class="diag-item">Config: {{ gw.diagnostics.config_path }}</span>
</div>
</div>
<div class="gateway-actions">
<NTag :type="gw.running ? 'success' : 'default'" size="small" round>
{{ gw.running ? t('gateways.running') : t('gateways.stopped') }}
</NTag>
<NButton
size="small"
:type="gw.running ? 'warning' : 'primary'"
round
@click="handleToggle(gw.profile, gw.running)"
>
{{ gw.running ? t('common.stop') : t('common.start') }}
</NButton>
</div>
</div>
</div>
</NSpin>
</div>
</div>
</template>
<style scoped lang="scss">
@use '@/styles/variables' as *;
.gateways-view {
height: calc(100 * var(--vh));
display: flex;
flex-direction: column;
}
.gateways-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.empty-state {
text-align: center;
color: $text-muted;
padding: 40px 0;
}
.gateway-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.gateway-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 20px;
background-color: $bg-card;
border: 1px solid $border-color;
border-radius: $radius-md;
transition: border-color $transition-fast;
&:hover {
border-color: $text-muted;
}
}
.gateway-info {
min-width: 0;
flex: 1;
}
.gateway-name {
font-size: 14px;
font-weight: 600;
color: $text-primary;
margin-bottom: 4px;
}
.gateway-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.gateway-diagnostics {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.meta-item {
font-size: 12px;
color: $text-muted;
}
.diag-item {
max-width: 100%;
font-size: 12px;
color: $text-muted;
background: rgba(127, 127, 127, 0.08);
padding: 2px 8px;
border-radius: 999px;
overflow-wrap: anywhere;
line-height: 1.5;
}
.gateway-actions {
display: flex;
align-items: center;
flex-shrink: 0;
gap: 8px;
}
@media (max-width: 640px) {
.gateways-content {
padding: 16px;
}
.gateway-card {
align-items: stretch;
flex-direction: column;
padding: 16px;
}
.gateway-diagnostics {
flex-direction: column;
gap: 6px;
}
.diag-item {
border-radius: $radius-sm;
}
.gateway-actions {
justify-content: flex-start;
}
}
</style>
@@ -11,7 +11,7 @@ import { copyToClipboard } from '@/utils/clipboard'
import HistoryMessageList from '@/components/hermes/chat/HistoryMessageList.vue'
import SessionListItem from '@/components/hermes/chat/SessionListItem.vue'
import OutlinePanel from '@/components/hermes/chat/OutlinePanel.vue'
import { fetchHermesSessions, fetchHermesSession, type SessionSummary } from '@/api/hermes/sessions'
import { deleteSession, fetchHermesSessions, fetchHermesSession, type SessionSummary } from '@/api/hermes/sessions'
const chatStore = useChatStore()
const appStore = useAppStore()
@@ -132,11 +132,13 @@ const collapsedGroups = ref<Set<string>>(new Set(JSON.parse(localStorage.getItem
function sessionSummaryToSession(summary: SessionSummary): Session {
return {
id: summary.id,
profile: summary.profile,
title: summary.title || '',
source: summary.source,
createdAt: summary.started_at * 1000,
updatedAt: (summary.last_active || summary.started_at) * 1000,
model: summary.model,
provider: summary.provider,
messageCount: summary.message_count,
inputTokens: summary.input_tokens,
outputTokens: summary.output_tokens,
@@ -269,6 +271,26 @@ async function copySessionId(id?: string) {
}
}
async function handleDeleteSession(id: string) {
const ok = await deleteSession(id)
if (!ok) {
message.error(t('common.deleteFailed'))
return
}
sessionBrowserPrefsStore.removePinned(id)
hermesSessions.value = hermesSessions.value.filter(s => s.id !== id)
if (historySessionId.value === id) {
historySessionId.value = null
historySession.value = null
const next = historySessions.value[0]
if (next) await handleSessionClick(next.id)
}
message.success(t('chat.sessionDeleted'))
}
</script>
<template>
@@ -301,9 +323,11 @@ async function copySessionId(id?: string) {
:session="s"
:active="s.id === historySessionId"
:pinned="true"
:can-delete="false"
:can-delete="true"
:streaming="false"
:show-profile="false"
@select="handleSessionClick(s.id)"
@delete="handleDeleteSession(s.id)"
/>
</template>
@@ -320,9 +344,11 @@ async function copySessionId(id?: string) {
:session="s"
:active="s.id === historySessionId"
:pinned="false"
:can-delete="false"
:can-delete="true"
:streaming="false"
:show-profile="false"
@select="handleSessionClick(s.id)"
@delete="handleDeleteSession(s.id)"
/>
</template>
</template>
+2 -13
View File
@@ -1,7 +1,6 @@
import { existsSync, readFileSync } from 'fs'
import { resolve } from 'path'
import * as hermesCli from '../services/hermes/hermes-cli'
import { getGatewayManagerInstance } from '../services/gateway-bootstrap'
declare const __APP_VERSION__: string
@@ -69,21 +68,11 @@ export function startVersionCheck(): void {
export async function healthCheck(ctx: any) {
const raw = await hermesCli.getVersion()
const hermesVersion = raw.split('\n')[0].replace('Hermes Agent ', '') || ''
let gatewayOk = false
try {
const mgr = getGatewayManagerInstance()
const upstream = mgr?.getUpstream()
if (!upstream) {
throw new Error('GatewayManager not initialized')
}
const res = await fetch(`${upstream.replace(/\/$/, '')}/health`, { signal: AbortSignal.timeout(5000) })
gatewayOk = res.ok
} catch { }
ctx.body = {
status: gatewayOk ? 'ok' : 'error',
status: 'ok',
platform: 'hermes-agent',
version: hermesVersion,
gateway: gatewayOk ? 'running' : 'stopped',
gateway: 'running',
webui_version: LOCAL_VERSION,
webui_latest: cachedLatestVersion,
webui_update_available: Boolean(LOCAL_VERSION && cachedLatestVersion && cachedLatestVersion !== LOCAL_VERSION),
@@ -1,6 +1,7 @@
import { readFile } from 'fs/promises'
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
import { getActiveConfigPath, getActiveEnvPath } from '../../services/hermes/hermes-profile'
import { getActiveConfigPath, getActiveEnvPath, getActiveProfileName } from '../../services/hermes/hermes-profile'
import { AgentBridgeClient } from '../../services/hermes/agent-bridge'
import { restartGateway } from '../../services/hermes/hermes-cli'
import { saveEnvValue } from '../../services/config-helpers'
import { logger } from '../../services/logger'
import { safeFileStore } from '../../services/safe-file-store'
@@ -78,6 +79,15 @@ function deepMerge(target: Record<string, any>, source: Record<string, any>): Re
return target
}
async function destroyBridgeProfile(profile: string): Promise<void> {
try {
const result = await new AgentBridgeClient().destroyProfile(profile)
logger.info('[config] destroyed bridge sessions after gateway restart profile=%s destroyed=%s', profile, result.destroyed)
} catch (err) {
logger.warn(err, '[config] failed to destroy bridge sessions after gateway restart profile=%s', profile)
}
}
async function readEnvPlatforms(): Promise<Record<string, any>> {
try {
const raw = await readFile(envPath(), 'utf-8')
@@ -127,7 +137,7 @@ export async function getConfig(ctx: any) {
}
export async function updateConfig(ctx: any) {
const { section, values } = ctx.request.body as { section: string; values: Record<string, any> }
const { section, values, restart } = ctx.request.body as { section: string; values: Record<string, any>; restart?: boolean }
if (!section || !values) {
ctx.status = 400; ctx.body = { error: 'Missing section or values' }; return
}
@@ -142,17 +152,19 @@ export async function updateConfig(ctx: any) {
},
})
// 使用 GatewayManager 重启平台网关
if (PLATFORM_SECTIONS.has(section)) {
const mgr = getGatewayManagerInstance()
if (mgr) {
// Platform adapters still run through Hermes gateway; restart it so channel
// config changes (Feishu/Weixin/etc.) are applied, then refresh bridge sessions.
if (restart !== false && PLATFORM_SECTIONS.has(section)) {
const activeProfile = getActiveProfileName()
try {
const activeProfile = mgr.getActiveProfile()
await mgr.stop(activeProfile)
await mgr.start(activeProfile)
const restartResult = await restartGateway()
logger.info('[config] gateway restarted after config update section=%s profile=%s result=%s', section, activeProfile, restartResult)
await destroyBridgeProfile(activeProfile)
} catch (err) {
logger.error(err, 'GatewayManager restart failed')
}
logger.error(err, 'Gateway restart failed')
ctx.status = 500
ctx.body = { error: err instanceof Error ? err.message : 'Gateway restart failed' }
return
}
}
@@ -208,16 +220,18 @@ export async function updateCredentials(ctx: any) {
},
})
// 使用 GatewayManager 重启平台网关
const mgr = getGatewayManagerInstance()
if (mgr) {
// Platform adapters still run through Hermes gateway; restart it so channel
// credentials are applied, then refresh bridge sessions.
const activeProfile = getActiveProfileName()
try {
const activeProfile = mgr.getActiveProfile()
await mgr.stop(activeProfile)
await mgr.start(activeProfile)
const restartResult = await restartGateway()
logger.info('[config] gateway restarted after credentials update platform=%s profile=%s result=%s', platform, activeProfile, restartResult)
await destroyBridgeProfile(activeProfile)
} catch (err) {
logger.error(err, 'GatewayManager restart failed')
}
logger.error(err, 'Gateway restart failed')
ctx.status = 500
ctx.body = { error: err instanceof Error ? err.message : 'Gateway restart failed' }
return
}
ctx.body = { success: true }
@@ -250,7 +250,14 @@ export async function readRun(ctx: Context) {
}
// Prevent path traversal
if (jobId.includes('..') || fileName.includes('..') || jobId.includes('/') || fileName.includes('/')) {
if (
jobId.includes('..')
|| fileName.includes('..')
|| jobId.includes('/')
|| fileName.includes('/')
|| jobId.includes('\\')
|| fileName.includes('\\')
) {
ctx.status = 400
ctx.body = { error: 'Invalid path' }
return
@@ -1,33 +0,0 @@
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
export async function list(ctx: any) {
const mgr = getGatewayManagerInstance()
if (!mgr) { ctx.status = 503; ctx.body = { error: 'GatewayManager not initialized' }; return }
const gateways = await mgr.listAll()
ctx.body = { gateways }
}
export async function start(ctx: any) {
const mgr = getGatewayManagerInstance()
if (!mgr) { ctx.status = 503; ctx.body = { error: 'GatewayManager not initialized' }; return }
try {
const status = await mgr.start(ctx.params.name)
ctx.body = { success: true, gateway: status }
} catch (err: any) { ctx.status = 500; ctx.body = { error: err.message } }
}
export async function stop(ctx: any) {
const mgr = getGatewayManagerInstance()
if (!mgr) { ctx.status = 503; ctx.body = { error: 'GatewayManager not initialized' }; return }
try {
await mgr.stop(ctx.params.name)
ctx.body = { success: true }
} catch (err: any) { ctx.status = 500; ctx.body = { error: err.message } }
}
export async function health(ctx: any) {
const mgr = getGatewayManagerInstance()
if (!mgr) { ctx.status = 503; ctx.body = { error: 'GatewayManager not initialized' }; return }
const status = await mgr.detectStatus(ctx.params.name)
ctx.body = { gateway: status }
}
+262 -87
View File
@@ -1,135 +1,310 @@
import type { Context } from 'koa'
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
import { execFile } from 'child_process'
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { promisify } from 'util'
import { getHermesBin } from '../../services/hermes/hermes-path'
import { getActiveProfileName, getProfileDir } from '../../services/hermes/hermes-profile'
function getUpstream(profile: string): string {
const mgr = getGatewayManagerInstance()
if (!mgr) {
throw new Error('GatewayManager not initialized')
}
return mgr.getUpstream(profile)
}
const execFileAsync = promisify(execFile)
const TIMEOUT_MS = 60_000
function getApiKey(profile: string): string | null {
const mgr = getGatewayManagerInstance()
return mgr?.getApiKey(profile) ?? null
}
type JobRecord = Record<string, any>
function resolveProfile(ctx: Context): string {
// Use header/query from request first, then fall back to authoritative source
const requestedProfile = ctx.get('x-hermes-profile') || (ctx.query.profile as string)
return requestedProfile || getActiveProfileName()
}
if (requestedProfile) {
return requestedProfile
}
function resolveProfileDir(profile: string): string {
return getProfileDir(profile || 'default')
}
// Fallback: read from authoritative source (active_profile file)
try {
const { getActiveProfileName } = require('../../services/hermes/hermes-profile')
return getActiveProfileName()
} catch {
return 'default'
function getJobsPath(profile: string): string {
return join(resolveProfileDir(profile), 'cron', 'jobs.json')
}
function normalizeJob(job: JobRecord): JobRecord {
const id = job.job_id || job.id
const skills = Array.isArray(job.skills)
? job.skills
: (job.skill ? [job.skill] : [])
return {
...job,
id,
job_id: id,
skills,
skill: job.skill ?? skills[0] ?? null,
model: job.model ?? null,
provider: job.provider ?? null,
base_url: job.base_url ?? null,
script: job.script ?? null,
schedule_display: job.schedule_display ?? job.schedule?.display ?? job.schedule?.expr ?? '',
repeat: job.repeat ?? { times: null, completed: 0 },
enabled: job.enabled ?? true,
state: job.state ?? ((job.enabled ?? true) ? 'scheduled' : 'paused'),
paused_at: job.paused_at ?? null,
paused_reason: job.paused_reason ?? null,
created_at: job.created_at ?? '',
next_run_at: job.next_run_at ?? null,
last_run_at: job.last_run_at ?? null,
last_status: job.last_status ?? null,
last_error: job.last_error ?? null,
deliver: job.deliver ?? 'local',
origin: job.origin ?? null,
last_delivery_error: job.last_delivery_error ?? null,
}
}
function buildHeaders(profile: string): Record<string, string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
const apiKey = getApiKey(profile)
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
return headers
function readJobs(profile: string, includeDisabled = true): JobRecord[] {
const jobsPath = getJobsPath(profile)
if (!existsSync(jobsPath)) return []
const parsed = JSON.parse(readFileSync(jobsPath, 'utf-8'))
const rawJobs = Array.isArray(parsed) ? parsed : parsed?.jobs
const jobs = Array.isArray(rawJobs) ? rawJobs.map(normalizeJob) : []
if (includeDisabled) return jobs
return jobs.filter((job) => job.enabled !== false)
}
const TIMEOUT_MS = 30_000
async function readUpstreamError(res: Response): Promise<unknown> {
const contentType = res.headers.get('content-type') || ''
if (contentType.includes('application/json')) {
try {
return await res.json()
} catch {
// Fall through to a stable error shape below.
}
}
const text = await res.text().catch(() => '')
return { error: { message: text || `Upstream error: ${res.status} ${res.statusText}` } }
function findJob(profile: string, jobId: string): JobRecord | null {
return readJobs(profile, true).find((job) => job.job_id === jobId || job.id === jobId) ?? null
}
async function proxyRequest(ctx: Context, upstreamPath: string, method?: string): Promise<void> {
const profile = resolveProfile(ctx)
let upstream: string
try {
upstream = getUpstream(profile)
} catch (e: any) {
ctx.status = 503
ctx.set('Content-Type', 'application/json')
ctx.body = { error: { message: e?.message || 'GatewayManager not initialized' } }
return
function boolQuery(value: unknown, defaultValue: boolean): boolean {
if (value == null) return defaultValue
const text = String(value).toLowerCase()
return text === '1' || text === 'true' || text === 'yes'
}
function getBody(ctx: Context): Record<string, any> {
return (ctx.request.body && typeof ctx.request.body === 'object')
? ctx.request.body as Record<string, any>
: {}
}
function getRepeatValue(repeat: unknown): number | null {
if (repeat == null || repeat === '') return null
if (typeof repeat === 'number' && Number.isFinite(repeat)) return repeat
if (typeof repeat === 'object') {
const times = (repeat as any).times
if (typeof times === 'number' && Number.isFinite(times)) return times
if (typeof times === 'string' && times.trim()) {
const parsed = Number(times)
return Number.isFinite(parsed) ? parsed : null
}
const params = new URLSearchParams(ctx.search || '')
params.delete('token')
const search = params.toString()
const url = `${upstream}${upstreamPath}${search ? `?${search}` : ''}`
return null
}
const parsed = Number(repeat)
return Number.isFinite(parsed) ? parsed : null
}
const headers = buildHeaders(profile)
const body = ctx.req.method !== 'GET' && ctx.req.method !== 'HEAD'
? JSON.stringify(ctx.request.body || {})
: undefined
function hasRepeatField(body: Record<string, any>): boolean {
return Object.prototype.hasOwnProperty.call(body, 'repeat')
}
let res: Response
function getSkills(body: Record<string, any>): string[] | null {
if (Array.isArray(body.skills)) {
return body.skills.map((skill) => String(skill || '').trim()).filter(Boolean)
}
if (typeof body.skill === 'string') {
const skill = body.skill.trim()
return skill ? [skill] : []
}
return null
}
async function runHermesCron(profile: string, args: string[]): Promise<void> {
const profileDir = resolveProfileDir(profile)
try {
res = await fetch(url, {
method: method || ctx.req.method,
headers,
body,
signal: AbortSignal.timeout(TIMEOUT_MS),
await execFileAsync(getHermesBin(), args, {
cwd: process.cwd(),
env: { ...process.env, HERMES_HOME: profileDir },
timeout: TIMEOUT_MS,
maxBuffer: 1024 * 1024,
windowsHide: true,
})
} catch (e: any) {
ctx.status = 502
ctx.set('Content-Type', 'application/json')
ctx.body = { error: { message: `Proxy error: ${e.message}` } }
return
} catch (error: any) {
const stderr = String(error?.stderr || '').trim()
const stdout = String(error?.stdout || '').trim()
throw new Error(stderr || stdout || error?.message || 'Hermes cron command failed')
}
}
if (!res.ok) {
ctx.status = res.status
ctx.set('Content-Type', 'application/json')
ctx.body = await readUpstreamError(res)
return
}
function sendJobNotFound(ctx: Context): void {
ctx.status = 404
ctx.body = { error: { message: 'Job not found' } }
}
ctx.status = res.status
ctx.set('Content-Type', res.headers.get('content-type') || 'application/json')
ctx.body = await res.json()
function sendCommandError(ctx: Context, error: any): void {
ctx.status = 500
ctx.body = { error: { message: error?.message || 'Hermes cron command failed' } }
}
function findCreatedJob(beforeJobs: JobRecord[], afterJobs: JobRecord[]): JobRecord | null {
const beforeIds = new Set(beforeJobs.map((job) => job.job_id || job.id))
const created = afterJobs.find((job) => !beforeIds.has(job.job_id || job.id))
if (created) return created
return [...afterJobs].sort((a, b) => {
const aTime = Date.parse(a.created_at || '') || 0
const bTime = Date.parse(b.created_at || '') || 0
return bTime - aTime
})[0] ?? null
}
export async function list(ctx: Context) {
await proxyRequest(ctx, '/api/jobs')
const profile = resolveProfile(ctx)
const includeDisabled = boolQuery(ctx.query.include_disabled, false)
ctx.body = { jobs: readJobs(profile, includeDisabled) }
}
export async function get(ctx: Context) {
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}`)
const profile = resolveProfile(ctx)
const job = findJob(profile, ctx.params.id)
if (!job) return sendJobNotFound(ctx)
ctx.body = { job }
}
export async function create(ctx: Context) {
await proxyRequest(ctx, '/api/jobs')
const profile = resolveProfile(ctx)
const body = getBody(ctx)
const schedule = String(body.schedule || body.schedule_display || '').trim()
const prompt = String(body.prompt || '').trim()
if (!schedule) {
ctx.status = 400
ctx.body = { error: { message: 'Schedule is required' } }
return
}
const beforeJobs = readJobs(profile, true)
const args = ['cron', 'create']
const name = String(body.name || '').trim()
if (name) args.push('--name', name)
if (body.deliver != null && String(body.deliver).trim()) args.push('--deliver', String(body.deliver).trim())
const repeat = getRepeatValue(body.repeat)
if (repeat != null) {
args.push('--repeat', String(repeat))
} else if (hasRepeatField(body)) {
// Hermes CLI normalizes repeat <= 0 to an unbounded/null repeat.
args.push('--repeat', '0')
}
const skills = getSkills(body)
for (const skill of skills || []) args.push('--skill', skill)
if (body.script != null && String(body.script).trim()) args.push('--script', String(body.script).trim())
if (body.workdir != null) args.push('--workdir', String(body.workdir))
if (body.no_agent === true) args.push('--no-agent')
args.push(schedule)
if (prompt) args.push(prompt)
try {
await runHermesCron(profile, args)
const job = findCreatedJob(beforeJobs, readJobs(profile, true))
ctx.body = { job }
} catch (error: any) {
sendCommandError(ctx, error)
}
}
export async function update(ctx: Context) {
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}`)
const profile = resolveProfile(ctx)
const body = getBody(ctx)
if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx)
const args = ['cron', 'edit', ctx.params.id]
if (body.schedule != null || body.schedule_display != null) {
args.push('--schedule', String(body.schedule ?? body.schedule_display))
}
if (body.prompt != null) args.push('--prompt', String(body.prompt))
if (body.name != null) args.push('--name', String(body.name))
if (body.deliver != null) args.push('--deliver', String(body.deliver))
const repeat = getRepeatValue(body.repeat)
if (repeat != null) {
args.push('--repeat', String(repeat))
} else if (hasRepeatField(body)) {
// Hermes CLI normalizes repeat <= 0 to an unbounded/null repeat.
args.push('--repeat', '0')
}
const skills = getSkills(body)
if (skills) {
if (skills.length === 0) {
args.push('--clear-skills')
} else {
for (const skill of skills) args.push('--skill', skill)
}
}
if (body.script != null) args.push('--script', String(body.script))
if (body.workdir != null) args.push('--workdir', String(body.workdir))
if (body.no_agent === true) args.push('--no-agent')
if (body.no_agent === false) args.push('--agent')
try {
await runHermesCron(profile, args)
const job = findJob(profile, ctx.params.id)
if (!job) return sendJobNotFound(ctx)
ctx.body = { job }
} catch (error: any) {
sendCommandError(ctx, error)
}
}
export async function remove(ctx: Context) {
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}`)
const profile = resolveProfile(ctx)
if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx)
try {
await runHermesCron(profile, ['cron', 'remove', ctx.params.id])
ctx.body = { ok: true }
} catch (error: any) {
sendCommandError(ctx, error)
}
}
export async function pause(ctx: Context) {
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}/pause`)
const profile = resolveProfile(ctx)
if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx)
try {
await runHermesCron(profile, ['cron', 'pause', ctx.params.id])
const job = findJob(profile, ctx.params.id)
ctx.body = { job }
} catch (error: any) {
sendCommandError(ctx, error)
}
}
export async function resume(ctx: Context) {
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}/resume`)
const profile = resolveProfile(ctx)
if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx)
try {
await runHermesCron(profile, ['cron', 'resume', ctx.params.id])
const job = findJob(profile, ctx.params.id)
ctx.body = { job }
} catch (error: any) {
sendCommandError(ctx, error)
}
}
export async function run(ctx: Context) {
await proxyRequest(ctx, `/api/jobs/${ctx.params.id}/run`)
const profile = resolveProfile(ctx)
if (!findJob(profile, ctx.params.id)) return sendJobNotFound(ctx)
try {
await runHermesCron(profile, ['cron', 'run', ctx.params.id])
const job = findJob(profile, ctx.params.id)
ctx.body = { job }
} catch (error: any) {
sendCommandError(ctx, error)
}
}
@@ -3,6 +3,7 @@ import { readFile } from 'fs/promises'
import { resolve, normalize } from 'path'
import { homedir } from 'os'
import * as kanbanCli from '../../services/hermes/hermes-kanban'
import { isPathWithin } from '../../services/hermes/hermes-path'
import {
searchSessionSummariesWithProfile,
getSessionDetailFromDbWithProfile,
@@ -596,7 +597,7 @@ export async function readArtifact(ctx: Context) {
const kanbanDir = resolve(homedir(), '.hermes', 'kanban', 'workspaces')
const resolved = resolve(normalize(filePath))
if (!resolved.startsWith(kanbanDir)) {
if (!isPathWithin(resolved, kanbanDir)) {
ctx.status = 403
ctx.body = { error: 'Path must be within kanban workspaces' }
return
@@ -0,0 +1,223 @@
import type { Context } from 'koa'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { dirname, extname, isAbsolute, join, resolve } from 'path'
import { getActiveAuthPath } from '../../services/hermes/hermes-profile'
import { config } from '../../config'
const XAI_VIDEO_GENERATIONS_URL = 'https://api.x.ai/v1/videos/generations'
const XAI_VIDEO_STATUS_URL = 'https://api.x.ai/v1/videos'
const XAI_VIDEO_MODEL = 'grok-imagine-video'
const MAX_IMAGE_BYTES = 25 * 1024 * 1024
const DEFAULT_POLL_INTERVAL_MS = 5000
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000
type AuthJson = {
providers?: Record<string, any>
credential_pool?: Record<string, any[]>
}
function readJsonFile(path: string): any {
try {
return JSON.parse(readFileSync(path, 'utf-8'))
} catch {
return null
}
}
function resolveXaiToken(): { token: string; source: string } | null {
const envToken = String(process.env.XAI_API_KEY || '').trim()
if (envToken) return { token: envToken, source: 'XAI_API_KEY' }
const auth = readJsonFile(getActiveAuthPath()) as AuthJson | null
const providerToken = String(auth?.providers?.['xai-oauth']?.tokens?.access_token || auth?.providers?.['xai-oauth']?.access_token || '').trim()
if (providerToken) return { token: providerToken, source: 'xai-oauth' }
const pool = auth?.credential_pool?.['xai-oauth']
if (Array.isArray(pool)) {
const poolToken = String(pool.find(entry => entry?.access_token)?.access_token || '').trim()
if (poolToken) return { token: poolToken, source: 'xai-oauth' }
}
return null
}
function mimeFromPath(path: string): string | null {
const ext = extname(path).toLowerCase()
if (ext === '.png') return 'image/png'
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
if (ext === '.webp') return 'image/webp'
return null
}
function mimeFromMagic(buffer: Buffer): string | null {
if (buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return 'image/png'
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'image/jpeg'
if (buffer.length >= 12 && buffer.subarray(0, 4).toString('ascii') === 'RIFF' && buffer.subarray(8, 12).toString('ascii') === 'WEBP') return 'image/webp'
return null
}
function imagePathToDataUri(imagePath: string): string {
const resolvedPath = isAbsolute(imagePath) ? imagePath : resolve(process.cwd(), imagePath)
const image = readFileSync(resolvedPath)
if (image.length > MAX_IMAGE_BYTES) {
const err: any = new Error(`image is too large (max ${MAX_IMAGE_BYTES} bytes)`)
err.status = 413
throw err
}
const mime = mimeFromMagic(image) || mimeFromPath(resolvedPath)
if (!mime) {
const err: any = new Error('unsupported image type; use png, jpeg, or webp')
err.status = 400
throw err
}
return `data:${mime};base64,${image.toString('base64')}`
}
function normalizeImageInput(body: any): string {
const imageUrl = typeof body.image_url === 'string' ? body.image_url.trim() : ''
if (imageUrl) return imageUrl
const imageBase64 = typeof body.image_base64 === 'string' ? body.image_base64.trim() : ''
if (imageBase64) {
if (imageBase64.startsWith('data:image/')) return imageBase64
const mime = typeof body.mime_type === 'string' ? body.mime_type.trim() : ''
if (!mime.startsWith('image/')) {
const err: any = new Error('mime_type is required when image_base64 is not a data URI')
err.status = 400
throw err
}
return `data:${mime};base64,${imageBase64}`
}
const imagePath = typeof body.image_path === 'string' ? body.image_path.trim() : ''
if (!imagePath) {
const err: any = new Error('image_path, image_url, or image_base64 is required')
err.status = 400
throw err
}
if (!existsSync(isAbsolute(imagePath) ? imagePath : resolve(process.cwd(), imagePath))) {
const err: any = new Error('image_path does not exist')
err.status = 404
throw err
}
return imagePathToDataUri(imagePath)
}
function normalizeDuration(value: unknown): number {
const duration = Number(value || 8)
if (!Number.isFinite(duration) || duration < 1 || duration > 15) {
const err: any = new Error('duration must be between 1 and 15 seconds')
err.status = 400
throw err
}
return duration
}
export function defaultMediaOutputPath(requestId: string, now = new Date()): string {
const safeRequestId = requestId.replace(/[^A-Za-z0-9_-]/g, '_') || `video_${now.getTime()}`
return join(config.appHome, 'media', `${safeRequestId}.mp4`)
}
async function requestXaiJson(url: string, token: string, init: RequestInit = {}): Promise<any> {
const res = await fetch(url, {
...init,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
...(init.headers || {}),
},
})
const text = await res.text()
let data: any = null
try { data = text ? JSON.parse(text) : null } catch {}
if (!res.ok) {
const detail = data?.error?.message || data?.error || text || res.statusText
const err: any = new Error(`xAI request failed: ${res.status} ${detail}`)
err.status = res.status === 401 || res.status === 403 ? 502 : 502
throw err
}
return data
}
async function downloadVideo(url: string, outputPath: string): Promise<void> {
const res = await fetch(url)
if (!res.ok) throw new Error(`failed to download generated video: ${res.status} ${res.statusText}`)
const arrayBuffer = await res.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
mkdirSync(dirname(outputPath), { recursive: true })
writeFileSync(outputPath, buffer)
}
export async function grokImageToVideo(ctx: Context) {
const tokenInfo = resolveXaiToken()
if (!tokenInfo) {
ctx.status = 401
ctx.body = {
error: 'Missing xAI token. Set XAI_API_KEY or complete xAI OAuth login first.',
code: 'missing_xai_token',
}
return
}
const body = ctx.request.body as any
const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : ''
if (!prompt) {
ctx.status = 400
ctx.body = { error: 'prompt is required', code: 'missing_prompt' }
return
}
try {
const image = normalizeImageInput(body)
const duration = normalizeDuration(body.duration)
const rawTimeoutMs = Number(body.timeout_ms || DEFAULT_TIMEOUT_MS)
const timeoutMs = Number.isFinite(rawTimeoutMs)
? Math.max(10000, Math.min(rawTimeoutMs, 30 * 60 * 1000))
: DEFAULT_TIMEOUT_MS
const requestedOutputPath = typeof body.output_path === 'string' ? body.output_path.trim() : ''
const started = await requestXaiJson(XAI_VIDEO_GENERATIONS_URL, tokenInfo.token, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: XAI_VIDEO_MODEL,
prompt,
image: { url: image },
duration,
}),
})
const requestId = String(started?.request_id || '').trim()
if (!requestId) throw new Error('xAI response missing request_id')
const deadline = Date.now() + timeoutMs
let latest: any = null
while (Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, DEFAULT_POLL_INTERVAL_MS))
latest = await requestXaiJson(`${XAI_VIDEO_STATUS_URL}/${encodeURIComponent(requestId)}`, tokenInfo.token)
if (latest?.status === 'done') {
const videoUrl = String(latest?.video?.url || '').trim()
const outputPath = requestedOutputPath || defaultMediaOutputPath(requestId)
if (videoUrl) await downloadVideo(videoUrl, outputPath)
ctx.body = {
request_id: requestId,
status: latest.status,
video_url: videoUrl,
output_path: outputPath,
token_source: tokenInfo.source,
}
return
}
if (latest?.status === 'expired' || latest?.status === 'failed' || latest?.status === 'error') {
ctx.status = 502
ctx.body = { request_id: requestId, status: latest.status, error: latest?.error || 'xAI video generation failed' }
return
}
}
ctx.status = 504
ctx.body = { request_id: requestId, status: latest?.status || 'pending', error: 'Timed out waiting for xAI video generation' }
} catch (err: any) {
ctx.status = err.status || 500
ctx.body = { error: err.message || String(err) }
}
}
@@ -1,7 +1,8 @@
import { readFile } from 'fs/promises'
import { existsSync, readFileSync } from 'fs'
import { getActiveEnvPath, getActiveAuthPath } from '../../services/hermes/hermes-profile'
import { readConfigYaml, updateConfigYaml, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers'
import { join } from 'path'
import { getActiveEnvPath, getActiveAuthPath, getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile'
import { readConfigYaml, readConfigYamlForProfile, updateConfigYaml, fetchProviderModels, buildModelGroups, PROVIDER_ENV_MAP } from '../../services/config-helpers'
import { buildProviderModelMap, PROVIDER_PRESETS } from '../../shared/providers'
import { getCopilotModelsDetailed, resolveCopilotOAuthToken, type CopilotModelMeta } from '../../services/hermes/copilot-models'
import { readAppConfig, writeAppConfig, type ModelVisibilityRule } from '../../services/app-config'
@@ -118,6 +119,73 @@ function resolveVisibleDefault(defaultModel: string, defaultProvider: string, gr
return { defaultModel: fallback?.models[0] || '', defaultProvider: fallback?.provider || '' }
}
function profileEnvPath(profile: string): string {
return join(getProfileDir(profile), '.env')
}
function profileAuthPath(profile: string): string {
return join(getProfileDir(profile), 'auth.json')
}
function envReader(envContent: string) {
const envHasValue = (key: string): boolean => {
if (!key) return false
const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm'))
return !!match && match[1].trim() !== '' && !match[1].trim().startsWith('#')
}
const envGetValue = (key: string): string => {
if (!key) return ''
const match = envContent.match(new RegExp(`^${key}\\s*=\\s*(.+)`, 'm'))
return match?.[1]?.trim() || ''
}
return { envHasValue, envGetValue }
}
function providerKeyForCustom(name: string): string {
return `custom:${name.trim().toLowerCase().replace(/ /g, '-')}`
}
function mergeAvailableGroups(groups: AvailableGroup[]): AvailableGroup[] {
const byProvider = new Map<string, AvailableGroup>()
for (const group of groups) {
const existing = byProvider.get(group.provider)
if (!existing) {
byProvider.set(group.provider, {
...group,
models: [...new Set(group.models)],
available_models: [...new Set(group.available_models || group.models)],
model_meta: group.model_meta ? { ...group.model_meta } : undefined,
})
continue
}
existing.models = [...new Set([...existing.models, ...group.models])]
existing.available_models = [...new Set([...(existing.available_models || existing.models), ...(group.available_models || group.models)])]
existing.api_key = existing.api_key || group.api_key
existing.base_url = existing.base_url || group.base_url
existing.builtin = existing.builtin || group.builtin
existing.model_meta = { ...(existing.model_meta || {}), ...(group.model_meta || {}) }
if (existing.model_meta && Object.keys(existing.model_meta).length === 0) delete existing.model_meta
}
return [...byProvider.values()]
}
type ProviderFetchCache = Map<string, Promise<string[]>>
function cachedProviderModels(
cache: ProviderFetchCache,
baseUrl: string,
apiKey: string,
freeOnly = false,
): Promise<string[]> {
const key = `${baseUrl.replace(/\/+$/, '')}\n${apiKey}\n${freeOnly ? 'free' : 'all'}`
let pending = cache.get(key)
if (!pending) {
pending = fetchProviderModels(baseUrl, apiKey, freeOnly)
cache.set(key, pending)
}
return pending
}
// Copilot 授权检测:复用同一套 token 解析逻辑(含 ~/.config/github-copilot/apps.json
// 与 ghp_ PAT 跳过),与 getCopilotModels 行为一致,避免出现"模型能拉到却被判未授权"。
@@ -125,8 +193,244 @@ async function isCopilotAuthorized(envContent: string): Promise<boolean> {
return !!(await resolveCopilotOAuthToken(envContent))
}
async function buildAvailableForProfile(
profile: string,
fetchCache: ProviderFetchCache,
appConfig: Awaited<ReturnType<typeof readAppConfig>>,
): Promise<{
profile: string
default: string
default_provider: string
groups: AvailableGroup[]
}> {
const config = await readConfigYamlForProfile(profile)
const modelSection = config.model
let currentDefault = ''
let currentDefaultProvider = ''
if (typeof modelSection === 'object' && modelSection !== null) {
currentDefault = String(modelSection.default || '').trim()
currentDefaultProvider = String(modelSection.provider || '').trim()
if (currentDefaultProvider === 'custom' && currentDefault) {
const cps = Array.isArray(config.custom_providers) ? config.custom_providers as any[] : []
const match = cps.find(
(cp: any) => cp.base_url?.replace(/\/+$/, '') === String(modelSection.base_url || '').replace(/\/+$/, '')
&& cp.model === currentDefault,
)
if (match) currentDefaultProvider = providerKeyForCustom(String(match.name || ''))
}
} else if (typeof modelSection === 'string') {
currentDefault = modelSection.trim()
}
let envContent = ''
try { envContent = await readFile(profileEnvPath(profile), 'utf-8') } catch {}
const { envHasValue, envGetValue } = envReader(envContent)
const isOAuthAuthorized = (providerKey: string): boolean => {
try {
const authPath = profileAuthPath(profile)
if (!existsSync(authPath)) return false
const auth = JSON.parse(readFileSync(authPath, 'utf-8'))
const provider = auth.providers?.[providerKey]
const pool = auth.credential_pool?.[providerKey]
return !!(
provider?.tokens?.access_token ||
provider?.access_token ||
(Array.isArray(pool) && pool.some((entry: any) => entry?.access_token))
)
} catch { return false }
}
let copilotLiveModels: CopilotModelMeta[] | null = null
const getCopilotLive = async (): Promise<CopilotModelMeta[]> => {
if (copilotLiveModels !== null) return copilotLiveModels
try { copilotLiveModels = await getCopilotModelsDetailed(envContent) }
catch { copilotLiveModels = [] }
return copilotLiveModels
}
const groups: AvailableGroup[] = []
const seenProviders = new Set<string>()
const addGroup = (provider: string, label: string, base_url: string, models: string[], api_key: string, builtin?: boolean, model_meta?: Record<string, ModelMeta>) => {
if (seenProviders.has(provider)) return
seenProviders.add(provider)
const availableModels = [...new Set(models)]
groups.push({ provider, label, base_url, models: availableModels, available_models: availableModels, api_key, ...(builtin ? { builtin: true } : {}), ...(model_meta ? { model_meta } : {}) })
}
const copilotEnabled = appConfig.copilotEnabled === true
if (!copilotEnabled && currentDefaultProvider.toLowerCase() === 'copilot') {
currentDefault = ''
currentDefaultProvider = ''
}
for (const [providerKey, envMapping] of Object.entries(PROVIDER_ENV_MAP)) {
if (envMapping.api_key_env && !envHasValue(envMapping.api_key_env)) continue
if (!envMapping.api_key_env) {
if (providerKey === 'copilot') {
if (!copilotEnabled) continue
if (!(await isCopilotAuthorized(envContent))) continue
} else if (!isOAuthAuthorized(providerKey)) {
continue
}
}
const preset = PROVIDER_PRESETS.find((p: any) => p.value === providerKey)
const label = preset?.label || providerKey.replace(/^custom:/, '')
let baseUrl = preset?.base_url || ''
if (envMapping.base_url_env && envHasValue(envMapping.base_url_env)) {
baseUrl = envGetValue(envMapping.base_url_env) || baseUrl
}
const catalogModels = PROVIDER_MODEL_CATALOG[providerKey]
let modelsList: string[] = catalogModels && catalogModels.length > 0 ? [...catalogModels] : []
let modelMeta: Record<string, ModelMeta> | undefined
if (providerKey === 'copilot') {
const live = await getCopilotLive()
if (live.length > 0) {
modelsList = live.map((m) => m.id)
modelMeta = {}
for (const m of live) {
if (m.preview || m.disabled) {
modelMeta[m.id] = {
...(m.preview ? { preview: true } : {}),
...(m.disabled ? { disabled: true } : {}),
}
}
}
if (Object.keys(modelMeta).length === 0) modelMeta = undefined
}
} else if (providerKey === 'openrouter' || providerKey === 'cliproxyapi' || providerKey === 'ollama-cloud') {
if (envMapping.api_key_env) {
const apiKey = envGetValue(envMapping.api_key_env)
if (apiKey) {
const fetched = await cachedProviderModels(fetchCache, baseUrl, apiKey, providerKey === 'openrouter')
if (fetched.length > 0) modelsList = fetched
}
}
}
if (modelsList.length > 0) {
const apiKey = envMapping.api_key_env ? envGetValue(envMapping.api_key_env) : ''
addGroup(providerKey, label, baseUrl, modelsList, apiKey, true, modelMeta)
}
}
const customProviders = Array.isArray(config.custom_providers)
? config.custom_providers as Array<{ name: string; base_url: string; model: string; api_key?: string }>
: []
const customFetches = await Promise.allSettled(
customProviders.map(async cp => {
if (!cp.base_url) return null
const providerKey = providerKeyForCustom(cp.name)
const baseUrl = cp.base_url.replace(/\/+$/, '')
let models = [cp.model].filter(Boolean)
if (cp.api_key) {
const fetched = await cachedProviderModels(fetchCache, baseUrl, cp.api_key)
if (fetched.length > 0) models = [...new Set([...models, ...fetched])]
}
return { providerKey, label: cp.name, base_url: baseUrl, models, api_key: cp.api_key || '' }
}),
)
for (const result of customFetches) {
if (result.status === 'fulfilled' && result.value?.models.length) {
const { providerKey, label, base_url, models, api_key } = result.value
addGroup(providerKey, label, base_url, models, api_key)
}
}
if (groups.length === 0) {
const fallback = buildModelGroups(config)
for (const group of fallback.groups) {
const models = group.models.map(model => model.id)
if (models.length) addGroup(group.provider, group.provider, '', models, '')
}
currentDefault = currentDefault || fallback.default
}
for (const g of groups) {
g.models = Array.from(new Set(g.models))
g.available_models = Array.from(new Set(g.available_models || g.models))
}
return { profile, default: currentDefault, default_provider: currentDefaultProvider, groups }
}
export async function getAvailable(ctx: any) {
try {
const requestedProfile = typeof ctx.query.profile === 'string' && ctx.query.profile.trim()
? ctx.query.profile.trim()
: ''
if (!requestedProfile) {
const appConfig = await readAppConfig()
const modelAliases = normalizeAliases(appConfig.modelAliases)
const modelVisibility = normalizeModelVisibility(appConfig.modelVisibility)
const fetchCache: ProviderFetchCache = new Map()
const profileResults = await Promise.all(
listProfileNamesFromDisk().map(profile => buildAvailableForProfile(profile, fetchCache, appConfig)),
)
const mergedGroups = mergeAvailableGroups(profileResults.flatMap(result => result.groups))
const groupsWithAliases = applyModelAliases(mergedGroups, modelAliases)
const visibleGroups = applyModelVisibility(groupsWithAliases, modelVisibility)
const activeProfile = getActiveProfileName()
const defaultProfile = profileResults.find(result => result.profile === activeProfile && (result.default || result.default_provider))
|| profileResults.find(result => result.default && result.default_provider)
|| profileResults.find(result => result.default)
const visibleDefault = resolveVisibleDefault(
defaultProfile?.default || '',
defaultProfile?.default_provider || '',
visibleGroups,
)
const allProvidersBase = PROVIDER_PRESETS.map((p: any) => ({
provider: p.value,
label: p.label,
base_url: p.base_url,
models: p.models,
api_key: '',
}))
ctx.body = {
default: visibleDefault.defaultModel,
default_provider: visibleDefault.defaultProvider,
groups: visibleGroups,
allProviders: applyModelAliases(allProvidersBase, modelAliases),
model_aliases: modelAliases,
model_visibility: modelVisibility,
profiles: profileResults.map(result => ({
profile: result.profile,
default: result.default,
default_provider: result.default_provider,
groups: applyModelVisibility(applyModelAliases(result.groups, modelAliases), modelVisibility),
})),
}
return
}
const appConfigForProfile = await readAppConfig()
const modelAliasesForProfile = normalizeAliases(appConfigForProfile.modelAliases)
const modelVisibilityForProfile = normalizeModelVisibility(appConfigForProfile.modelVisibility)
const profileResult = await buildAvailableForProfile(requestedProfile, new Map(), appConfigForProfile)
const profileGroupsWithAliases = applyModelAliases(profileResult.groups, modelAliasesForProfile)
const visibleProfileGroups = applyModelVisibility(profileGroupsWithAliases, modelVisibilityForProfile)
const visibleProfileDefault = resolveVisibleDefault(profileResult.default, profileResult.default_provider, visibleProfileGroups)
ctx.body = {
default: visibleProfileDefault.defaultModel,
default_provider: visibleProfileDefault.defaultProvider,
groups: visibleProfileGroups,
allProviders: applyModelAliases(PROVIDER_PRESETS.map((p: any) => ({
provider: p.value,
label: p.label,
base_url: p.base_url,
models: p.models,
api_key: '',
})), modelAliasesForProfile),
model_aliases: modelAliasesForProfile,
model_visibility: modelVisibilityForProfile,
profiles: [{
profile: profileResult.profile,
default: profileResult.default,
default_provider: profileResult.default_provider,
groups: visibleProfileGroups,
}],
}
return
const config = await readConfigYaml()
const modelSection = config.model
let currentDefault = ''
@@ -239,16 +543,16 @@ export async function getAvailable(ctx: any) {
const live = await getCopilotLive()
if (live.length > 0) {
modelsList = live.map((m) => m.id)
modelMeta = {}
const nextModelMeta: Record<string, ModelMeta> = {}
for (const m of live) {
if (m.preview || m.disabled) {
modelMeta[m.id] = {
nextModelMeta[m.id] = {
...(m.preview ? { preview: true } : {}),
...(m.disabled ? { disabled: true } : {}),
}
}
}
if (Object.keys(modelMeta).length === 0) modelMeta = undefined
modelMeta = Object.keys(nextModelMeta).length > 0 ? nextModelMeta : undefined
}
} else if (providerKey === 'openrouter' || providerKey === 'cliproxyapi' || providerKey === 'ollama-cloud') {
// OpenRouter and local CLIProxyAPI expose dynamic OpenAI-compatible /models catalogs.
@@ -286,8 +590,9 @@ export async function getAvailable(ctx: any) {
)
for (const result of customFetches) {
if (result.status === 'fulfilled' && result.value) {
const { providerKey, label, base_url, models, api_key: cpApiKey, builtin: cpBuiltin } = result.value as any
const value = (result as { value?: any }).value
if (value) {
const { providerKey, label, base_url, models, api_key: cpApiKey, builtin: cpBuiltin } = value
addGroup(providerKey, label, base_url, models, cpApiKey, cpBuiltin)
}
}
@@ -4,7 +4,7 @@ import { basename, join } from 'path'
import { tmpdir } from 'os'
import * as hermesCli from '../../services/hermes/hermes-cli'
import { SessionDeleter } from '../../services/hermes/session-deleter'
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
import { AgentBridgeClient } from '../../services/hermes/agent-bridge'
import { logger } from '../../services/logger'
import { smartCloneCleanup } from '../../services/hermes/profile-credentials'
import { detectHermesRootHome } from '../../services/hermes/hermes-path'
@@ -42,7 +42,6 @@ function listProfilesFromDisk(activeProfileName: string): HermesProfile[] {
name: 'default',
active: activeProfileName === 'default',
model: '—',
gateway: 'stopped',
alias: '',
}]
const profilesDir = join(base, 'profiles')
@@ -56,7 +55,6 @@ function listProfilesFromDisk(activeProfileName: string): HermesProfile[] {
name,
active: name === activeProfileName,
model: '—',
gateway: 'stopped',
alias: '',
})
}
@@ -186,12 +184,6 @@ export async function create(ctx: any) {
}
}
const mgr = getGatewayManagerInstance()
if (mgr) {
try { await mgr.start(name) } catch (err: any) {
logger.error(err, 'Failed to start gateway for profile "%s"', name)
}
}
ctx.body = {
success: true,
message: output.trim(),
@@ -223,8 +215,12 @@ export async function remove(ctx: any) {
return
}
try {
const mgr = getGatewayManagerInstance()
if (mgr) { try { await mgr.stop(name) } catch { } }
try {
const result = await new AgentBridgeClient().destroyProfile(name)
logger.info('[profiles] destroyed bridge sessions for deleted profile "%s" destroyed=%s', name, result.destroyed)
} catch (err) {
logger.warn(err, '[profiles] failed to destroy bridge sessions for deleted profile "%s"', name)
}
const ok = await hermesCli.deleteProfile(name)
if (ok) {
ctx.body = { success: true }
@@ -296,10 +292,6 @@ export async function switchProfile(ctx: any) {
return
}
// Update GatewayManager to match the authoritative source
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')
@@ -1,18 +1,19 @@
import * as hermesCli from '../../services/hermes/hermes-cli'
import { listSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb } from '../../db/hermes/sessions-db'
import { listSessionSummaries, getUsageStatsFromDb, getSessionDetailFromDb, getExactSessionDetailFromDbWithProfile } from '../../db/hermes/sessions-db'
import {
listSessions as localListSessions,
searchSessions as localSearchSessions,
getSession as localGetSession,
getSessionDetail as localGetSessionDetail,
deleteSession as localDeleteSession,
renameSession as localRenameSession,
} from '../../db/hermes/session-store'
import { ExportCompressor } from '../../lib/context-compressor/export-compressor'
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
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 { getActiveProfileName, listProfileNamesFromDisk } from '../../services/hermes/hermes-profile'
import { isPathWithin } from '../../services/hermes/hermes-path'
import { getGroupChatServer } from '../../routes/hermes/group-chat'
import { logger } from '../../services/logger'
import type { ConversationSummary } from '../../services/hermes/conversations'
@@ -31,6 +32,43 @@ function filterPendingDeletedConversationSummaries(items: ConversationSummary[])
return filterPendingDeletedSessions(items)
}
interface HermesDeleteResult {
attempted: boolean
deleted: boolean
profile?: string
error?: string
}
function hasProfileOnDisk(profile: string): boolean {
return listProfileNamesFromDisk().includes(profile || 'default')
}
async function deleteHermesSessionIfPresent(sessionId: string, profile?: string | null): Promise<HermesDeleteResult> {
const targetProfile = profile || 'default'
if (!hasProfileOnDisk(targetProfile)) {
return { attempted: false, deleted: false, profile: targetProfile }
}
try {
const hermesSession = await getExactSessionDetailFromDbWithProfile(sessionId, targetProfile)
if (!hermesSession) {
return { attempted: false, deleted: false, profile: targetProfile }
}
const deleted = await hermesCli.deleteSessionForProfile(sessionId, targetProfile)
return {
attempted: true,
deleted,
profile: targetProfile,
error: deleted ? undefined : 'Failed to delete Hermes session',
}
} catch (err: any) {
const message = err?.message || 'Failed to inspect Hermes session'
logger.warn({ err, sessionId, profile: targetProfile }, 'Hermes Session: profile delete skipped')
return { attempted: true, deleted: false, profile: targetProfile, error: message }
}
}
export async function listConversations(ctx: any) {
const source = (ctx.query.source as string) || undefined
const limit = ctx.query.limit ? parseInt(ctx.query.limit as string, 10) : undefined
@@ -98,11 +136,19 @@ export async function getConversationMessages(ctx: any) {
export async function list(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 profile = typeof ctx.query.profile === 'string' && ctx.query.profile.trim()
? ctx.query.profile.trim()
: undefined
const effectiveLimit = limit && limit > 0 ? limit : 2000
const allSessions = localListSessions(profile, source, effectiveLimit)
ctx.body = { sessions: filterPendingDeletedSessions(allSessions.filter(s => s.source === 'api_server' || s.source === 'cli')) }
const knownProfiles = profile ? null : new Set(listProfileNamesFromDisk())
ctx.body = {
sessions: filterPendingDeletedSessions(allSessions.filter(s =>
(s.source === 'api_server' || s.source === 'cli') &&
(!knownProfiles || knownProfiles.has(s.profile || 'default')),
)),
}
}
/**
@@ -171,14 +217,17 @@ export async function getHermesSession(ctx: any) {
export async function remove(ctx: any) {
const sessionId = ctx.params.id
const ok = localDeleteSession(sessionId)
if (!ok) {
const existing = localGetSession(sessionId)
const hermesProfile = existing?.profile || getActiveProfileName()
const hermes = await deleteHermesSessionIfPresent(sessionId, hermesProfile)
const localDeleted = existing ? localDeleteSession(sessionId) : true
if (!localDeleted) {
ctx.status = 500
ctx.body = { error: 'Failed to delete session' }
return
}
deleteUsage(sessionId)
ctx.body = { ok: true }
ctx.body = { ok: true, deleted: Boolean(existing), hermes }
}
export async function batchRemove(ctx: any) {
@@ -199,10 +248,22 @@ export async function batchRemove(ctx: any) {
const results = {
deleted: 0,
failed: 0,
errors: [] as Array<{ id: string; error: string }>
hermesDeleted: 0,
hermesFailed: 0,
errors: [] as Array<{ id: string; error: string }>,
hermesErrors: [] as Array<{ id: string; profile?: string; error: string }>
}
for (const id of validIds) {
const existing = localGetSession(id)
const hermes = await deleteHermesSessionIfPresent(id, existing?.profile)
if (hermes.deleted) {
results.hermesDeleted++
} else if (hermes.attempted && hermes.error) {
results.hermesFailed++
results.hermesErrors.push({ id, profile: hermes.profile, error: hermes.error })
}
const ok = localDeleteSession(id)
if (ok) {
deleteUsage(id)
@@ -292,7 +353,9 @@ export async function setModel(ctx: any) {
export async function contextLength(ctx: any) {
const profile = (ctx.query.profile as string) || undefined
ctx.body = { context_length: getModelContextLength(profile) }
const model = typeof ctx.query.model === 'string' ? ctx.query.model : undefined
const provider = typeof ctx.query.provider === 'string' ? ctx.query.provider : undefined
ctx.body = { context_length: getModelContextLength({ profile, model, provider }) }
}
export async function usageStats(ctx: any) {
@@ -365,7 +428,7 @@ export async function listWorkspaceFolders(ctx: any) {
// Security: prevent path traversal
const fullPath = resolve(join(WORKSPACE_BASE, subPath))
if (!fullPath.startsWith(resolve(WORKSPACE_BASE))) {
if (!isPathWithin(fullPath, WORKSPACE_BASE)) {
ctx.status = 403
ctx.body = { error: 'Access denied' }
return
@@ -437,10 +500,9 @@ export async function exportSession(ctx: any) {
}
async function compressSession(session: any) {
const mgr = getGatewayManagerInstance()
const profile = getActiveProfileName()
const upstream = mgr ? mgr.getUpstream(profile).replace(/\/$/, '') : ''
const apiKey = mgr ? mgr.getApiKey(profile) || undefined : undefined
const profile = session.profile || getActiveProfileName()
const upstream = ''
const apiKey = undefined
const messages = (session.messages || []).map((m: any) => ({
role: m.role,
content: m.content || '',
@@ -450,7 +512,11 @@ async function compressSession(session: any) {
reasoning_content: m.reasoning,
}))
return exportCompressor.compress(messages, upstream, apiKey, session.id, profile)
return exportCompressor.compress(messages, upstream, apiKey, session.id, {
profile,
model: session.model,
provider: session.provider,
})
}
function serializeAsText(title: string | null, messages: any[]): string {
@@ -6,6 +6,7 @@ import {
safeReadFile, extractDescription, listFilesRecursive, getHermesDir,
} from '../../services/config-helpers'
import { pinSkill } from '../../services/hermes/hermes-cli'
import { isPathWithin } from '../../services/hermes/hermes-path'
import { getSkillUsageStatsFromDb } from '../../db/hermes/sessions-db'
/** Read bundled manifest as a name→hash map from ~/.hermes/skills/.bundled_manifest */
@@ -301,7 +302,7 @@ export async function readFile_(ctx: any) {
realPath = filePath.slice(5)
}
const fullPath = resolve(join(hd, 'skills', realPath))
if (!fullPath.startsWith(join(hd, 'skills'))) {
if (!isPathWithin(fullPath, join(hd, 'skills'))) {
ctx.status = 403
ctx.body = { error: 'Access denied' }
return
@@ -13,9 +13,11 @@ const XAI_OAUTH_CLIENT_ID = 'b1a00492-073a-47ea-816f-4c329264a828'
const XAI_OAUTH_SCOPE = 'openid profile email offline_access grok-cli:access api:access'
const XAI_DEFAULT_BASE_URL = 'https://api.x.ai/v1'
const XAI_REDIRECT_HOST = '127.0.0.1'
const XAI_CALLBACK_BIND_HOST = process.env.HERMES_WEB_UI_XAI_CALLBACK_BIND_HOST?.trim() || XAI_REDIRECT_HOST
const XAI_REDIRECT_PORT = 56121
const XAI_REDIRECT_PATH = '/callback'
const POLL_MAX_DURATION = 15 * 60 * 1000
const XAI_DEFAULT_MODEL = 'grok-4.3'
interface XaiSession {
id: string
@@ -41,6 +43,18 @@ interface AuthJson {
const sessions = new Map<string, XaiSession>()
export function applyXaiOAuthDefaultModel(config: Record<string, any>): Record<string, any> {
if (typeof config.model !== 'object' || config.model === null) config.model = {}
const currentDefault = String(config.model.default || '').trim()
config.model.provider = 'xai-oauth'
config.model.default = currentDefault.toLowerCase().startsWith('grok-')
? currentDefault
: XAI_DEFAULT_MODEL
delete config.model.base_url
delete config.model.api_key
return config
}
function cleanupExpiredSessions() {
const now = Date.now()
sessions.forEach((session, id) => {
@@ -181,14 +195,7 @@ async function saveTokens(session: XaiSession, tokenData: any) {
}]
saveAuthJson(authPath, auth)
await updateConfigYaml((config) => {
if (typeof config.model !== 'object' || config.model === null) config.model = {}
config.model.provider = 'xai-oauth'
config.model.default = config.model.default || 'grok-4.3'
delete config.model.base_url
delete config.model.api_key
return config
})
await updateConfigYaml(applyXaiOAuthDefaultModel)
}
async function exchangeCode(session: XaiSession, code: string) {
@@ -257,7 +264,7 @@ function startCallbackServer(sessionId: string, preferredPort = XAI_REDIRECT_POR
reject(err)
}
})
server.listen(preferredPort, XAI_REDIRECT_HOST, () => {
server.listen(preferredPort, XAI_CALLBACK_BIND_HOST, () => {
const address = server.address()
const port = typeof address === 'object' && address ? address.port : preferredPort
resolve({ server, redirectUri: `http://${XAI_REDIRECT_HOST}:${port}${XAI_REDIRECT_PATH}` })
@@ -65,6 +65,7 @@ function runNpm(args: string[], options: { timeout?: number } = {}) {
timeout: options.timeout,
stdio: ['pipe', 'pipe', 'pipe'],
env: getCurrentNodeEnv(),
windowsHide: true,
}).trim()
}
@@ -1,3 +1,4 @@
import { join } from 'path'
import { getActiveProfileDir } from '../../services/hermes/hermes-profile'
import type {
ConversationDetail,
@@ -62,7 +63,7 @@ interface ConversationSessionRow {
}
function conversationDbPath(): string {
return `${getActiveProfileDir()}/state.db`
return join(getActiveProfileDir(), 'state.db')
}
function normalizeNumber(value: unknown, fallback = 0): number {
+9
View File
@@ -118,6 +118,7 @@ export const GC_ROOMS_SCHEMA: Record<string, string> = {
maxHistoryTokens: 'INTEGER NOT NULL DEFAULT 32000',
tailMessageCount: 'INTEGER NOT NULL DEFAULT 10',
totalTokens: 'INTEGER NOT NULL DEFAULT 0',
sessionSeed: "TEXT NOT NULL DEFAULT '0'",
}
export const GC_MESSAGES_TABLE = 'gc_messages'
@@ -129,6 +130,14 @@ export const GC_MESSAGES_SCHEMA: Record<string, string> = {
senderName: 'TEXT NOT NULL',
content: 'TEXT NOT NULL',
timestamp: 'INTEGER NOT NULL',
role: "TEXT NOT NULL DEFAULT 'user'",
tool_call_id: 'TEXT',
tool_calls: 'TEXT',
tool_name: 'TEXT',
finish_reason: 'TEXT',
reasoning: 'TEXT',
reasoning_details: 'TEXT',
reasoning_content: 'TEXT',
}
export const GC_ROOM_AGENTS_TABLE = 'gc_room_agents'
@@ -219,9 +219,10 @@ export function renameSession(id: string, title: string): boolean {
return result.changes > 0
}
export function listSessions(profile: string, source?: string, limit = 2000): HermesSessionRow[] {
export function listSessions(profile?: string, source?: string, limit = 2000): HermesSessionRow[] {
if (!isSqliteAvailable()) return []
const db = getDb()!
const profileFilter = profile?.trim()
// Use a subquery to generate preview from first user message if not set
const sql = `
@@ -239,13 +240,17 @@ export function listSessions(profile: string, source?: string, limit = 2000): He
''
) AS preview
FROM ${SESSIONS_TABLE} s
WHERE s.profile = ?
WHERE 1 = 1
${profileFilter ? 'AND s.profile = ?' : ''}
${source ? 'AND s.source = ?' : ''}
ORDER BY s.last_active DESC
LIMIT ?
`
const params: any[] = [profile]
const params: any[] = []
if (profileFilter) {
params.push(profileFilter)
}
if (source) {
params.push(source)
}
+7 -6
View File
@@ -1,4 +1,5 @@
import { getActiveProfileDir, getProfileDir } from '../../services/hermes/hermes-profile'
import { join } from 'path'
import type { LocalUsageStats } from './usage-store'
const SQLITE_AVAILABLE = (() => {
@@ -66,7 +67,7 @@ interface HermesSessionInternalRow extends HermesSessionRow {
}
function sessionDbPath(): string {
return `${getActiveProfileDir()}/state.db`
return join(getActiveProfileDir(), 'state.db')
}
function normalizeNumber(value: unknown, fallback = 0): number {
@@ -643,7 +644,7 @@ export async function getSessionDetailFromDb(sessionId: string): Promise<HermesS
export async function getSessionDetailFromDbWithProfile(sessionId: string, profile: string): Promise<HermesSessionDetailRow | null> {
const { DatabaseSync } = await import('node:sqlite')
const dbPath = `${getProfileDir(profile)}/state.db`
const dbPath = join(getProfileDir(profile), 'state.db')
const db = new DatabaseSync(dbPath, { open: true, readOnly: true })
try {
const idx = loadAllSessions(db)
@@ -670,7 +671,7 @@ export async function getSessionDetailFromDbWithProfile(sessionId: string, profi
export async function getExactSessionDetailFromDbWithProfile(sessionId: string, profile: string): Promise<HermesSessionDetailRow | null> {
const { DatabaseSync } = await import('node:sqlite')
const dbPath = `${getProfileDir(profile)}/state.db`
const dbPath = join(getProfileDir(profile), 'state.db')
const db = new DatabaseSync(dbPath, { open: true, readOnly: true })
try {
const idx = loadAllSessions(db)
@@ -702,7 +703,7 @@ export async function findLatestExactSessionIdWithProfile(
if (!trimmed) return null
const { DatabaseSync } = await import('node:sqlite')
const dbPath = `${getProfileDir(profile)}/state.db`
const dbPath = join(getProfileDir(profile), 'state.db')
const db = new DatabaseSync(dbPath, { open: true, readOnly: true })
const loweredQuery = trimmed.toLowerCase()
const likePattern = buildLikePattern(loweredQuery)
@@ -1212,7 +1213,7 @@ export async function listSessionSummaries(source?: string, limit = 2000, profil
}
const { DatabaseSync } = await import('node:sqlite')
const dbPath = profile ? `${getProfileDir(profile)}/state.db` : sessionDbPath()
const dbPath = profile ? join(getProfileDir(profile), 'state.db') : sessionDbPath()
const db = new DatabaseSync(dbPath, { open: true, readOnly: true })
try {
@@ -1259,7 +1260,7 @@ export async function searchSessionSummariesWithProfile(
if (!trimmed) return []
const { DatabaseSync } = await import('node:sqlite')
const dbPath = `${getProfileDir(profile)}/state.db`
const dbPath = join(getProfileDir(profile), 'state.db')
const db = new DatabaseSync(dbPath, { open: true, readOnly: true })
const normalized = sanitizeFtsQuery(trimmed)
const prefixQuery = toPrefixQuery(normalized)
+23 -7
View File
@@ -10,7 +10,6 @@ import { readFileSync } from 'fs'
import { config } from './config'
import { getToken, requireAuth } from './services/auth'
import { initLoginLimiter } from './services/login-limiter'
import { initGatewayManager, getGatewayManagerInstance } from './services/gateway-bootstrap'
import { bindShutdown } from './services/shutdown'
import { setupTerminalWebSocket } from './routes/hermes/terminal'
import { setupKanbanEventsWebSocket } from './routes/hermes/kanban-events'
@@ -21,6 +20,8 @@ import { setChatRunServer } from './routes/hermes/chat-run'
import { GroupChatServer } from './services/hermes/group-chat'
import { ChatRunSocket } from './services/hermes/run-chat'
import { startAgentBridgeManager } from './services/hermes/agent-bridge'
import { HermesSkillInjector } from './services/hermes/skill-injector'
import { ensureProfileGatewaysRunning } from './services/hermes/gateway-autostart'
import { logger } from './services/logger'
// Injected by esbuild at build time; fallback to reading package.json in dev mode
@@ -88,14 +89,30 @@ export async function bootstrap() {
const authToken = await getToken()
await initLoginLimiter()
try {
const skillInjector = new HermesSkillInjector()
const injectionResult = await skillInjector.injectMissingSkills()
if (injectionResult.injected.length > 0) {
console.log('[bootstrap] bundled skills injected:', injectionResult.injected.join(', '))
}
if (injectionResult.updated.length > 0) {
console.log('[bootstrap] bundled skills updated:', injectionResult.updated.join(', '))
}
} catch (err) {
logger.warn(err, '[bootstrap] failed to inject bundled skills')
console.warn('[bootstrap] failed to inject bundled skills:', err instanceof Error ? err.message : err)
}
// Debug: log environment variable
console.log('[bootstrap] HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN =', process.env.HERMES_WEB_UI_STOP_GATEWAYS_ON_SHUTDOWN)
try {
await ensureProfileGatewaysRunning()
console.log('[bootstrap] profile gateways checked')
} catch (err) {
logger.warn(err, '[bootstrap] failed to ensure profile gateways')
console.warn('[bootstrap] failed to ensure profile gateways:', err instanceof Error ? err.message : err)
}
const app = new Koa()
await initGatewayManager()
console.log('[bootstrap] gateway manager initialized')
try {
agentBridgeManager = await startAgentBridgeManager()
console.log('[bootstrap] agent bridge started')
@@ -151,10 +168,9 @@ export async function bootstrap() {
// Group chat Socket.IO (must be after server is created)
const groupChatServer = new GroupChatServer(servers)
setGroupChatServer(groupChatServer)
groupChatServer.setGatewayManager(getGatewayManagerInstance())
// Chat run Socket.IO — shares the same Server instance, just adds /chat-run namespace
chatRunServer = new ChatRunSocket(groupChatServer.getIO(), getGatewayManagerInstance())
chatRunServer = new ChatRunSocket(groupChatServer.getIO())
setChatRunServer(chatRunServer)
chatRunServer.init()
@@ -13,6 +13,7 @@ import {
type ChatMessage,
type CompressionConfig,
type CompressedResult,
type SummarizerOptions,
DEFAULT_COMPRESSION_CONFIG,
countTokens,
serializeForSummary,
@@ -35,7 +36,7 @@ export class ExportCompressor {
upstream: string,
apiKey: string | undefined,
sessionId?: string,
profile?: string,
summarizer?: string | SummarizerOptions,
): Promise<CompressedResult> {
const total = messages.length
@@ -57,7 +58,7 @@ export class ExportCompressor {
sessionId, snapshot.lastMessageIndex,
)
return this.incrementalCompress(
messages, snapshot, upstream, apiKey, meta, profile,
messages, snapshot, upstream, apiKey, meta, summarizer,
)
}
@@ -65,7 +66,7 @@ export class ExportCompressor {
'[export-compressor] session=%s: full compress %d messages',
sessionId, total,
)
return this.fullCompress(messages, upstream, apiKey, meta, profile)
return this.fullCompress(messages, upstream, apiKey, meta, summarizer)
}
private async incrementalCompress(
@@ -74,7 +75,7 @@ export class ExportCompressor {
upstream: string,
apiKey: string | undefined,
meta: CompressedResult['meta'],
profile?: string,
summarizer?: string | SummarizerOptions,
): Promise<CompressedResult> {
const { summary: previousSummary, lastMessageIndex } = snapshot
const newMessages = messages.slice(lastMessageIndex + 1)
@@ -86,7 +87,7 @@ export class ExportCompressor {
const history = buildConversationHistory(newMessages)
const t0 = Date.now()
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, previousSummary, profile)
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, previousSummary, summarizer)
logger.info('[export-compressor] incremental-llm done in %dms, %d chars', Date.now() - t0, summary!.length)
} catch (err: any) {
logger.warn('[export-compressor] incremental-llm failed: %s — reusing previous summary', err.message)
@@ -112,7 +113,7 @@ export class ExportCompressor {
upstream: string,
apiKey: string | undefined,
meta: CompressedResult['meta'],
profile?: string,
summarizer?: string | SummarizerOptions,
): Promise<CompressedResult> {
if (messages.length === 0) {
return { messages: [], meta }
@@ -125,7 +126,7 @@ export class ExportCompressor {
const history = buildConversationHistory(messages)
const t0 = Date.now()
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, undefined, profile)
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, undefined, summarizer)
logger.info('[export-compressor] full-llm done in %dms, %d chars', Date.now() - t0, summary!.length)
} catch (err: any) {
logger.warn('[export-compressor] full-llm failed: %s', err.message)
@@ -14,7 +14,9 @@
*/
import { encodingForModel, getEncoding } from 'js-tiktoken'
import { randomUUID } from 'crypto'
import { logger } from '../../services/logger'
import { AgentBridgeClient, type AgentBridgeRunResult } from '../../services/hermes/agent-bridge'
import {
getCompressionSnapshot,
saveCompressionSnapshot,
@@ -70,6 +72,12 @@ export interface CompressedResult {
}
}
export interface SummarizerOptions {
profile?: string
model?: string | null
provider?: string | null
}
// ─── Token counting ─────────────────────────────────────
let _encoder: ReturnType<typeof getEncoding> | null = null
@@ -372,8 +380,14 @@ export async function callSummarizer(
history: Array<{ role: string; content: string }>,
timeoutMs: number,
previousSummary?: string,
profile?: string,
summarizer?: string | SummarizerOptions,
): Promise<string> {
void upstream
void apiKey
const options: SummarizerOptions = typeof summarizer === 'string'
? { profile: summarizer }
: summarizer || {}
const profile = options.profile || 'default'
const convHistory: Array<{ role: string; content: string }> = [...history]
if (previousSummary) {
@@ -383,60 +397,38 @@ export async function callSummarizer(
)
}
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
const bridge = new AgentBridgeClient({ timeoutMs: timeoutMs + 15_000 })
const sessionId = `compress_${Date.now().toString(36)}_${randomUUID().replace(/-/g, '').slice(0, 12)}`
const res = await fetch(`${upstream.replace(/\/$/, '')}/v1/responses`, {
method: 'POST',
headers,
body: JSON.stringify({
input: prompt,
conversation_history: convHistory,
stream: true,
store: false,
}),
signal: AbortSignal.timeout(timeoutMs),
})
if (!res.ok) {
throw new Error(`Summarization response failed: ${res.status}`)
}
if (!res.body) {
throw new Error('Summarization response stream missing')
}
let output = ''
for await (const frame of readSseFrames(res.body)) {
let parsed: any
try {
parsed = JSON.parse(frame.data)
} catch {
continue
}
const eventType = parsed.type || frame.event || parsed.event
const result = await bridge.request<AgentBridgeRunResult>({
action: 'chat',
session_id: sessionId,
message: prompt,
conversation_history: convHistory,
profile,
source: 'api_server',
wait: true,
timeout: Math.ceil(timeoutMs / 1000),
...(options.model ? { model: options.model } : {}),
...(options.provider ? { provider: options.provider } : {}),
}, { timeoutMs: timeoutMs + 15_000 })
if (eventType === 'response.output_text.delta' && parsed.delta) {
output += parsed.delta
continue
if (result.status === 'error') {
throw new Error(result.error || 'Summarization bridge run failed')
}
if (eventType === 'response.completed') {
const response = parsed.response || parsed
const finalText = extractResponseText(response)
if (!output && finalText) output = finalText
if (!output || output.trim() === '') {
throw new Error('Empty summarization response')
const payload = result.result as any
const output = String(
payload?.final_response ||
result.output ||
'',
).trim()
if (!output) throw new Error('Empty summarization response')
return output
} finally {
await bridge.destroy(sessionId, profile).catch(() => undefined)
}
return output.trim()
}
if (eventType === 'response.failed') {
throw new Error(parsed.error?.message || parsed.error || 'Summarization response failed')
}
}
throw new Error('Summarization response stream ended without a terminal event')
}
// ─── Main Compressor ────────────────────────────────────
@@ -465,7 +457,7 @@ export class ChatContextCompressor {
upstream: string,
apiKey: string | undefined,
sessionId?: string,
profile?: string,
summarizer?: string | SummarizerOptions,
): Promise<CompressedResult> {
const total = messages.length
@@ -489,7 +481,7 @@ export class ChatContextCompressor {
sessionId, snapshot.lastMessageIndex,
)
return this.incrementalCompress(
messages, snapshot, upstream, apiKey, sessionId!, makeMeta(), profile,
messages, snapshot, upstream, apiKey, sessionId!, makeMeta(), summarizer,
)
} else {
// No snapshot → full compress (compress all messages)
@@ -497,7 +489,7 @@ export class ChatContextCompressor {
'[context-compressor] session=%s: full compress %d messages',
sessionId, total,
)
return this.fullCompress(messages, upstream, apiKey, sessionId!, makeMeta(), profile)
return this.fullCompress(messages, upstream, apiKey, sessionId!, makeMeta(), summarizer)
}
}
@@ -508,7 +500,7 @@ export class ChatContextCompressor {
apiKey: string | undefined,
sessionId: string,
meta: CompressedResult['meta'],
profile?: string,
summarizer?: string | SummarizerOptions,
): Promise<CompressedResult> {
const { summary: previousSummary, lastMessageIndex } = snapshot
const total = messages.length
@@ -550,7 +542,7 @@ export class ChatContextCompressor {
const history = buildConversationHistory(toCompress)
const t0 = Date.now()
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, previousSummary, profile)
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, previousSummary, summarizer)
logger.info('[context-compressor] incremental-llm done in %dms, %d chars', Date.now() - t0, summary.length)
} catch (err: any) {
logger.warn('[context-compressor] incremental-llm failed: %s — keeping new messages verbatim', err.message)
@@ -599,7 +591,7 @@ export class ChatContextCompressor {
apiKey: string | undefined,
sessionId: string,
meta: CompressedResult['meta'],
profile?: string,
summarizer?: string | SummarizerOptions,
): Promise<CompressedResult> {
const total = messages.length
const cleaned = pruneOldToolResults(messages, this.config.tailMessageCount)
@@ -625,7 +617,7 @@ export class ChatContextCompressor {
let summary: string | null = null
try {
const t0 = Date.now()
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, undefined, profile)
summary = await callSummarizer(upstream, apiKey, prompt, history, this.config.summarizationTimeoutMs, undefined, summarizer)
logger.info('[context-compressor] full-llm done in %dms, %d chars', Date.now() - t0, summary.length)
} catch (err: any) {
logger.warn('[context-compressor] full-llm failed: %s', err.message)
@@ -1,9 +0,0 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/gateways'
export const gatewayRoutes = new Router()
gatewayRoutes.get('/api/hermes/gateways', ctrl.list)
gatewayRoutes.post('/api/hermes/gateways/:name/start', ctrl.start)
gatewayRoutes.post('/api/hermes/gateways/:name/stop', ctrl.stop)
gatewayRoutes.get('/api/hermes/gateways/:name/health', ctrl.health)
@@ -0,0 +1,6 @@
import Router from '@koa/router'
import * as ctrl from '../../controllers/hermes/media'
export const mediaRoutes = new Router()
mediaRoutes.post('/api/hermes/media/grok-image-to-video', ctrl.grokImageToVideo)
@@ -1,8 +1,13 @@
import type { Context } from 'koa'
import { getGatewayManagerInstance } from '../../services/gateway-bootstrap'
import { updateUsage } from '../../db/hermes/usage-store'
function getGatewayManager() { return getGatewayManagerInstance() }
let gatewayManager: any = null
export function setGatewayManagerForTest(manager: any): void {
gatewayManager = manager
}
function getGatewayManager() { return gatewayManager }
// --- run_id → session_id mapping (in-memory, ephemeral) ---
+2 -2
View File
@@ -21,7 +21,6 @@ import { codexAuthRoutes } from './hermes/codex-auth'
import { nousAuthRoutes } from './hermes/nous-auth'
import { copilotAuthRoutes } from './hermes/copilot-auth'
import { xaiAuthRoutes } from './hermes/xai-auth'
import { gatewayRoutes } from './hermes/gateways'
import { weixinRoutes } from './hermes/weixin'
import { fileRoutes } from './hermes/files'
import { downloadRoutes } from './hermes/download'
@@ -29,6 +28,7 @@ import { jobRoutes } from './hermes/jobs'
import { cronHistoryRoutes } from './hermes/cron-history'
import { kanbanRoutes } from './hermes/kanban'
import { ttsRoutes } from './hermes/tts'
import { mediaRoutes } from './hermes/media'
import { proxyRoutes, proxyMiddleware } from './hermes/proxy'
import { groupChatRoutes, setGroupChatServer } from './hermes/group-chat'
@@ -64,7 +64,6 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next)
app.use(nousAuthRoutes.routes())
app.use(copilotAuthRoutes.routes())
app.use(xaiAuthRoutes.routes())
app.use(gatewayRoutes.routes())
app.use(weixinRoutes.routes())
app.use(groupChatRoutes.routes()) // Must be before proxy
app.use(fileRoutes.routes()) // Must be before proxy (proxy catch-all matches everything)
@@ -72,6 +71,7 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next)
app.use(jobRoutes.routes()) // Must be before proxy
app.use(cronHistoryRoutes.routes()) // Must be before proxy
app.use(kanbanRoutes.routes()) // Must be before proxy
app.use(mediaRoutes.routes()) // Must be before proxy
app.use(proxyRoutes.routes())
// Proxy catch-all middleware (must be last)
+19 -1
View File
@@ -2,7 +2,7 @@ import { readFile, chmod } from 'fs/promises'
import { readdir, stat } from 'fs/promises'
import { existsSync, readFileSync } from 'fs'
import { join } from 'path'
import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getActiveAuthPath } from './hermes/hermes-profile'
import { getActiveProfileDir, getActiveConfigPath, getActiveEnvPath, getActiveAuthPath, getProfileDir } from './hermes/hermes-profile'
import { logger } from './logger'
import { safeFileStore } from './safe-file-store'
@@ -76,6 +76,10 @@ export async function readConfigYaml(): Promise<Record<string, any>> {
return safeFileStore.readYaml(configPath())
}
export async function readConfigYamlForProfile(profile: string): Promise<Record<string, any>> {
return safeFileStore.readYaml(join(getProfileDir(profile), 'config.yaml'))
}
export async function writeConfigYaml(config: Record<string, any>): Promise<void> {
await safeFileStore.writeYaml(configPath(), config, { backup: true })
}
@@ -86,6 +90,20 @@ export async function updateConfigYaml<T = void>(
return safeFileStore.updateYaml(configPath(), updater, { backup: true })
}
export function stripLegacyApiServerGatewayConfig(config: Record<string, any>): { config: Record<string, any>; changed: boolean } {
if (!config.platforms || typeof config.platforms !== 'object' || Array.isArray(config.platforms)) {
return { config, changed: false }
}
if (config.platforms.api_server !== undefined) {
delete config.platforms.api_server
if (Object.keys(config.platforms).length === 0) delete config.platforms
return { config, changed: true }
}
return { config, changed: false }
}
// --- .env helpers ---
function assertValidEnvKey(key: string): void {
@@ -1,15 +0,0 @@
let gatewayManager: any = null
export function getGatewayManagerInstance(): any {
return gatewayManager
}
export async function initGatewayManager(): Promise<void> {
const { GatewayManager } = await import('./hermes/gateway-manager')
const { getActiveProfileName } = await import('./hermes/hermes-profile')
const activeProfile = getActiveProfileName()
gatewayManager = new GatewayManager(activeProfile)
await gatewayManager.detectAllOnStartup()
await gatewayManager.startAll()
}
@@ -1,13 +1,23 @@
import { setTimeout as delay } from 'timers/promises'
import { createConnection, type Socket } from 'net'
import { tmpdir } from 'os'
import { URL } from 'url'
import { join } from 'path'
import { bridgeLogger } from '../../logger'
import { getActiveProfileName, getProfileDir } from '../hermes-profile'
export const DEFAULT_AGENT_BRIDGE_ENDPOINT = process.platform === 'win32'
function resolveDefaultAgentBridgeEndpoint(): string {
if (process.env.VITEST) {
return process.platform === 'win32'
? `tcp://127.0.0.1:${28000 + (process.pid % 10000)}`
: `ipc://${join(tmpdir(), `hermes-agent-bridge-test-${process.pid}.sock`)}`
}
return process.platform === 'win32'
? 'tcp://127.0.0.1:18765'
: 'ipc:///tmp/hermes-agent-bridge.sock'
}
export const DEFAULT_AGENT_BRIDGE_ENDPOINT = resolveDefaultAgentBridgeEndpoint()
export const DEFAULT_AGENT_BRIDGE_TIMEOUT_MS = 120000
function envPositiveInt(name: string): number | undefined {
@@ -26,6 +36,7 @@ export interface AgentBridgeOptions {
export interface AgentBridgeRequestOptions {
timeoutMs?: number
serialize?: boolean
}
export interface AgentBridgeChatOptions {
@@ -33,6 +44,9 @@ export interface AgentBridgeChatOptions {
storage_message?: AgentBridgeMessage
model?: string
provider?: string
source?: string
wait?: boolean
timeout?: number
}
export type AgentBridgeMessage =
@@ -298,6 +312,10 @@ export class AgentBridgeClient {
}
}
if (!options.serialize) {
return run()
}
const next = this.lock.then(run, run)
this.lock = next.catch(() => undefined)
return next
@@ -325,6 +343,9 @@ export class AgentBridgeClient {
...(profile ? { profile } : {}),
...(options.model ? { model: options.model } : {}),
...(options.provider ? { provider: options.provider } : {}),
...(options.source ? { source: options.source } : {}),
...(options.wait ? { wait: true } : {}),
...(options.timeout ? { timeout: options.timeout } : {}),
...(options.force_compress ? { force_compress: true } : {}),
})
}
@@ -383,12 +404,22 @@ export class AgentBridgeClient {
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 })
interrupt(sessionId: string, message?: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'interrupt',
session_id: sessionId,
message,
...(profile ? { profile } : {}),
})
}
steer(sessionId: string, text: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'steer', session_id: sessionId, text })
steer(sessionId: string, text: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'steer',
session_id: sessionId,
text,
...(profile ? { profile } : {}),
})
}
approvalRespond(approvalId: string, choice: string): Promise<AgentBridgeResponse> {
@@ -407,15 +438,27 @@ export class AgentBridgeClient {
}
destroyAll(): Promise<AgentBridgeResponse> {
return this.request({ action: 'destroy_all' })
return this.request({ action: 'destroy_all' }, { serialize: true })
}
getHistory(sessionId: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'get_history', session_id: sessionId })
destroyProfile(profile: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'destroy_profile', profile }, { serialize: true })
}
destroy(sessionId: string): Promise<AgentBridgeResponse> {
return this.request({ action: 'destroy', session_id: sessionId })
getHistory(sessionId: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'get_history',
session_id: sessionId,
...(profile ? { profile } : {}),
})
}
destroy(sessionId: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'destroy',
session_id: sessionId,
...(profile ? { profile } : {}),
})
}
list(): Promise<AgentBridgeResponse> {
@@ -423,7 +466,7 @@ export class AgentBridgeClient {
}
shutdown(): Promise<AgentBridgeResponse> {
return this.request({ action: 'shutdown' })
return this.request({ action: 'shutdown' }, { serialize: true })
}
}
@@ -11,13 +11,16 @@ from __future__ import annotations
import argparse
import copy
import hashlib
import importlib.util
import json
import os
import queue
import shutil
import socket
import subprocess
import sys
import tempfile
import threading
import time
import traceback
@@ -174,6 +177,11 @@ def _base_hermes_home() -> Path:
return _normalize_base_home(_discover_hermes_home(os.environ.get("HERMES_AGENT_BRIDGE_BASE_HOME") or DEFAULT_HERMES_HOME))
def _worker_profile() -> str | None:
raw = os.environ.get("HERMES_AGENT_BRIDGE_WORKER_PROFILE", "").strip()
return raw or None
def _profile_home(profile: str | None) -> Path:
base = _base_hermes_home()
if not profile or profile == "default":
@@ -319,8 +327,20 @@ def _restore_profile_dotenv(snapshot: dict[str, str | None]) -> None:
os.environ[key] = value
def _set_worker_profile_env(profile: str | None) -> None:
profile_home = _profile_home(profile)
os.environ["HERMES_HOME"] = str(profile_home)
os.environ["HERMES_AGENT_BRIDGE_WORKER_PROFILE"] = profile or "default"
values = _read_dotenv(profile_home / ".env")
for key, value in values.items():
os.environ[key] = value
@contextmanager
def _profile_env(profile: str | None):
if _worker_profile():
yield
return
original = _apply_profile_env(profile)
env_snapshot = _apply_profile_dotenv(profile)
try:
@@ -832,6 +852,7 @@ class AgentPool:
storage_message: Any | None,
conversation_history: list[dict[str, Any]] | None,
profile: str | None,
source: str | None = None,
) -> bool:
persist_message = storage_message if storage_message is not None else message
user_content = str(persist_message) if not isinstance(persist_message, dict) else str(persist_message.get("content", persist_message))
@@ -848,7 +869,7 @@ class AgentPool:
if hasattr(db, "create_session"):
db.create_session(
session_id=session.session_id,
source=_bridge_platform(),
source=source or _bridge_platform(),
model=session.config.get("model"),
)
@@ -958,6 +979,7 @@ class AgentPool:
force_compress: bool = False,
model: str | None = None,
provider: str | None = None,
source: str | None = None,
) -> RunRecord:
session = self.get_or_create(session_id, profile=profile, model=model, provider=provider)
with session.lock:
@@ -973,14 +995,14 @@ class AgentPool:
thread = threading.Thread(
target=self._run_chat,
args=(session, record, message, storage_message, instructions, conversation_history, profile, force_compress),
args=(session, record, message, storage_message, instructions, conversation_history, profile, force_compress, source),
daemon=True,
name=f"hermes-bridge-run-{run_id[:8]}",
)
thread.start()
return record
def _run_chat(self, session: AgentSession, record: RunRecord, message: Any, storage_message: Any | None = None, instructions: str | None = None, conversation_history: list[dict[str, Any]] | None = None, profile: str | None = None, force_compress: bool = False) -> None:
def _run_chat(self, session: AgentSession, record: RunRecord, message: Any, storage_message: Any | None = None, instructions: str | None = None, conversation_history: list[dict[str, Any]] | None = None, profile: str | None = None, force_compress: bool = False, source: str | None = None) -> None:
with self._run_lock:
with _profile_env(profile):
def stream_callback(delta: str) -> None:
@@ -1004,7 +1026,7 @@ class AgentPool:
os.environ["HERMES_EXEC_ASK"] = "1"
except Exception:
previous_approval_callback = None
self._prepersist_user_message(session, message, storage_message, conversation_history, profile)
self._prepersist_user_message(session, message, storage_message, conversation_history, profile, source)
db_count_after_prepersist = self._session_db_message_count(session.session_id, profile)
if force_compress:
compress = getattr(session.agent, "_compress_context", None)
@@ -1265,7 +1287,13 @@ class BridgeServer:
raise ValueError("action is required")
if action == "ping":
return {"pong": True, "time": time.time(), "agent_root": str(_agent_root())}
return {
"pong": True,
"time": time.time(),
"agent_root": str(_agent_root()),
"profile": _worker_profile() or "default",
"hermes_home": str(_hermes_home()),
}
if action == "chat":
session_id = str(req.get("session_id") or "").strip() or uuid.uuid4().hex
@@ -1276,6 +1304,7 @@ class BridgeServer:
profile = req.get("profile")
model = req.get("model")
provider = req.get("provider")
source = req.get("source")
record = self.pool.start_chat(
session_id,
message,
@@ -1286,6 +1315,7 @@ class BridgeServer:
bool(req.get("force_compress")),
model,
provider,
source,
)
if req.get("wait"):
timeout = float(req.get("timeout", 0) or 0)
@@ -1355,50 +1385,13 @@ class BridgeServer:
raise ValueError(f"unknown action: {action}")
def _make_server_socket(self) -> socket.socket:
if self.endpoint.startswith("ipc://"):
if not hasattr(socket, "AF_UNIX"):
raise RuntimeError("ipc:// endpoints require Unix domain socket support; use tcp://host:port on this platform")
sock_path = Path(self.endpoint.removeprefix("ipc://"))
sock_path.parent.mkdir(parents=True, exist_ok=True)
try:
sock_path.unlink(missing_ok=True)
except OSError:
pass
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(str(sock_path))
return server
parsed = urlparse(self.endpoint)
if parsed.scheme != "tcp":
raise RuntimeError(f"unsupported endpoint scheme: {self.endpoint}")
host = parsed.hostname or "127.0.0.1"
port = int(parsed.port or 0)
if port <= 0:
raise RuntimeError(f"tcp endpoint requires a port: {self.endpoint}")
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((host, port))
return server
return _make_listen_socket(self.endpoint)
def _read_request(self, conn: socket.socket) -> dict[str, Any]:
chunks: list[bytes] = []
while True:
chunk = conn.recv(65536)
if not chunk:
break
chunks.append(chunk)
if b"\n" in chunk:
break
if not chunks:
raise RuntimeError("empty request")
line = b"".join(chunks).split(b"\n", 1)[0].strip()
if not line:
raise RuntimeError("empty request")
return json.loads(line.decode("utf-8"))
return _read_json_request(conn)
def _write_response(self, conn: socket.socket, resp: dict[str, Any]) -> None:
payload = json.dumps(resp, ensure_ascii=False, default=str) + "\n"
conn.sendall(payload.encode("utf-8"))
_write_json_response(conn, resp)
def _gc_idle_sessions(self) -> None:
"""Destroy sessions idle longer than IDLE_TIMEOUT_SECONDS."""
@@ -1458,16 +1451,530 @@ class BridgeServer:
pass
class WorkerProcess:
STARTUP_TIMEOUT_SECONDS = 120
REQUEST_TIMEOUT_SECONDS = 120
def __init__(self, profile: str, endpoint: str, agent_root: str | None, hermes_home: str | None) -> None:
self.profile = profile or "default"
self.endpoint = endpoint
self.agent_root = agent_root
self.hermes_home = hermes_home
self.process: subprocess.Popen[str] | None = None
self.last_used_at = time.time()
self._lock = threading.RLock()
@property
def running(self) -> bool:
return self.process is not None and self.process.poll() is None
def start(self) -> None:
with self._lock:
if self.running:
return
args = [
sys.executable,
str(Path(__file__).resolve()),
"--endpoint",
self.endpoint,
"--worker-profile",
self.profile,
]
if self.agent_root:
args.extend(["--agent-root", self.agent_root])
if self.hermes_home:
args.extend(["--hermes-home", self.hermes_home])
env = {
**os.environ,
"HERMES_AGENT_BRIDGE_ENDPOINT": self.endpoint,
"HERMES_AGENT_BRIDGE_WORKER_PROFILE": self.profile,
}
self.process = subprocess.Popen(
args,
env=env,
cwd=os.getcwd(),
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
self._pipe_stderr()
self._wait_ready()
def _pipe_stderr(self) -> None:
proc = self.process
if proc is None or proc.stderr is None:
return
def run() -> None:
assert proc.stderr is not None
for line in proc.stderr:
text = line.rstrip()
if text:
print(f"[hermes-bridge-worker:{self.profile}] {text}", file=sys.stderr, flush=True)
threading.Thread(target=run, daemon=True, name=f"hermes-bridge-worker-stderr-{self.profile}").start()
def _wait_ready(self) -> None:
proc = self.process
if proc is None or proc.stdout is None:
raise RuntimeError(f"profile worker {self.profile} did not start")
lines: queue.Queue[str | None] = queue.Queue()
ready_event = threading.Event()
def read_stdout() -> None:
assert proc.stdout is not None
try:
for line in proc.stdout:
if ready_event.is_set():
text = line.rstrip()
if text:
print(f"[hermes-bridge-worker:{self.profile}] {text}", file=sys.stderr, flush=True)
else:
lines.put(line)
finally:
lines.put(None)
threading.Thread(target=read_stdout, daemon=True, name=f"hermes-bridge-worker-stdout-{self.profile}").start()
deadline = time.time() + self.STARTUP_TIMEOUT_SECONDS
while time.time() < deadline:
if proc.poll() is not None:
raise RuntimeError(f"profile worker {self.profile} exited before ready")
try:
line = lines.get(timeout=0.1)
except queue.Empty:
continue
if line is None:
time.sleep(0.05)
continue
text = line.strip()
if text:
print(f"[hermes-bridge-worker:{self.profile}] {text}", file=sys.stderr, flush=True)
try:
data = json.loads(text)
if data.get("event") == "ready":
ready_event.set()
return
except Exception:
pass
self.stop()
raise RuntimeError(f"profile worker {self.profile} did not become ready within {self.STARTUP_TIMEOUT_SECONDS}s")
def stop(self) -> None:
with self._lock:
proc = self.process
self.process = None
if proc is None:
return
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=3)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=3)
if self.endpoint.startswith("ipc://"):
try:
Path(self.endpoint.removeprefix("ipc://")).unlink(missing_ok=True)
except OSError:
pass
def request(self, req: dict[str, Any]) -> dict[str, Any]:
self.start()
self.last_used_at = time.time()
return _send_bridge_request(self.endpoint, req, self.REQUEST_TIMEOUT_SECONDS)
def _worker_endpoint(profile: str) -> str:
safe = hashlib.sha256(profile.encode("utf-8")).hexdigest()[:16]
if os.name == "nt":
port_base = int(os.environ.get("HERMES_AGENT_BRIDGE_WORKER_PORT_BASE", "18780"))
return f"tcp://127.0.0.1:{port_base + int(safe[:4], 16) % 1000}"
root = Path(tempfile.gettempdir()) / "hermes-agent-bridge-workers"
return f"ipc://{root / f'{safe}.sock'}"
def _connect_bridge_socket(endpoint: str, timeout: float) -> socket.socket:
if endpoint.startswith("ipc://"):
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.settimeout(timeout)
sock.connect(endpoint.removeprefix("ipc://"))
return sock
parsed = urlparse(endpoint)
if parsed.scheme != "tcp":
raise RuntimeError(f"unsupported endpoint scheme: {endpoint}")
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
sock.connect((parsed.hostname or "127.0.0.1", int(parsed.port or 0)))
return sock
def _send_bridge_request(endpoint: str, req: dict[str, Any], timeout: float) -> dict[str, Any]:
sock = _connect_bridge_socket(endpoint, timeout)
try:
sock.sendall((json.dumps(req, ensure_ascii=False, default=str) + "\n").encode("utf-8"))
chunks: list[bytes] = []
while True:
chunk = sock.recv(65536)
if not chunk:
break
chunks.append(chunk)
if b"\n" in chunk:
break
line = b"".join(chunks).split(b"\n", 1)[0].strip()
if not line:
raise RuntimeError("worker closed without a response")
resp = json.loads(line.decode("utf-8"))
if not resp.get("ok"):
raise RuntimeError(str(resp.get("error") or "worker request failed"))
return resp
finally:
try:
sock.close()
except OSError:
pass
def _make_listen_socket(endpoint: str) -> socket.socket:
if endpoint.startswith("ipc://"):
if not hasattr(socket, "AF_UNIX"):
raise RuntimeError("ipc:// endpoints require Unix domain socket support; use tcp://host:port on this platform")
sock_path = Path(endpoint.removeprefix("ipc://"))
sock_path.parent.mkdir(parents=True, exist_ok=True)
try:
sock_path.unlink(missing_ok=True)
except OSError:
pass
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
server.bind(str(sock_path))
return server
parsed = urlparse(endpoint)
if parsed.scheme != "tcp":
raise RuntimeError(f"unsupported endpoint scheme: {endpoint}")
host = parsed.hostname or "127.0.0.1"
port = int(parsed.port or 0)
if port <= 0:
raise RuntimeError(f"tcp endpoint requires a port: {endpoint}")
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((host, port))
return server
def _read_json_request(conn: socket.socket) -> dict[str, Any]:
chunks: list[bytes] = []
while True:
chunk = conn.recv(65536)
if not chunk:
break
chunks.append(chunk)
if b"\n" in chunk:
break
if not chunks:
raise RuntimeError("empty request")
line = b"".join(chunks).split(b"\n", 1)[0].strip()
if not line:
raise RuntimeError("empty request")
return json.loads(line.decode("utf-8"))
def _write_json_response(conn: socket.socket, resp: dict[str, Any]) -> None:
payload = json.dumps(resp, ensure_ascii=False, default=str) + "\n"
conn.sendall(payload.encode("utf-8"))
class BridgeBroker:
IDLE_TIMEOUT_SECONDS = 30 * 60
GC_INTERVAL_SECONDS = 60
def __init__(self, endpoint: str, agent_root: str | None = None, hermes_home: str | None = None) -> None:
self.endpoint = endpoint
self.agent_root = agent_root
self.hermes_home = hermes_home
self._workers: dict[str, WorkerProcess] = {}
self._run_profile: dict[str, str] = {}
self._session_profile: dict[str, str] = {}
self._approval_profile: dict[str, str] = {}
self._compression_profile: dict[str, str] = {}
self._lock = threading.RLock()
self._stop = threading.Event()
self._last_gc = time.time()
def _normalize_profile(self, value: Any) -> str:
profile = str(value or "").strip()
return profile or "default"
def _worker_for_profile(self, profile: str) -> WorkerProcess:
profile = self._normalize_profile(profile)
with self._lock:
worker = self._workers.get(profile)
if worker is None:
worker = WorkerProcess(profile, _worker_endpoint(profile), self.agent_root, self.hermes_home)
self._workers[profile] = worker
return worker
def _profile_for_run(self, run_id: str) -> str:
with self._lock:
profile = self._run_profile.get(run_id)
if not profile:
raise KeyError(f"unknown run: {run_id}")
return profile
def _profile_for_session(self, session_id: str, fallback_profile: Any = None) -> str:
with self._lock:
profile = self._session_profile.get(session_id)
if not profile:
fallback = self._normalize_profile(fallback_profile)
if fallback_profile is not None and fallback:
return fallback
raise KeyError(f"unknown session: {session_id}")
return profile
def _record_response_routes(self, profile: str, resp: dict[str, Any]) -> None:
run_id = str(resp.get("run_id") or "")
session_id = str(resp.get("session_id") or "")
with self._lock:
if run_id:
self._run_profile[run_id] = profile
if session_id:
self._session_profile[session_id] = profile
for event in resp.get("events") or []:
if not isinstance(event, dict):
continue
approval_id = str(event.get("approval_id") or "")
if approval_id:
self._approval_profile[approval_id] = profile
request_id = str(event.get("request_id") or "")
if event.get("event") == "bridge.compression.requested" and request_id:
self._compression_profile[request_id] = profile
if event.get("event") in {"bridge.compression.completed", "bridge.compression.failed"} and request_id:
self._compression_profile.pop(request_id, None)
def _forward(self, profile: str, req: dict[str, Any]) -> dict[str, Any]:
worker = self._worker_for_profile(profile)
forwarded = dict(req)
forwarded["profile"] = profile
resp = worker.request(forwarded)
self._record_response_routes(profile, resp)
return resp
def handle(self, req: dict[str, Any]) -> dict[str, Any]:
action = str(req.get("action") or "").strip()
if not action:
raise ValueError("action is required")
if action == "ping":
with self._lock:
workers = {profile: worker.running for profile, worker in self._workers.items()}
return {"pong": True, "time": time.time(), "mode": "broker", "workers": workers}
if action == "worker_ping":
profile = self._normalize_profile(req.get("profile"))
resp = self._forward(profile, {"action": "ping"})
resp["worker_profile"] = profile
return resp
if action == "chat":
profile = self._normalize_profile(req.get("profile"))
return self._forward(profile, req)
if action in {"get_result", "get_output"}:
profile = self._profile_for_run(str(req.get("run_id") or ""))
return self._forward(profile, req)
if action in {"interrupt", "steer", "get_history", "destroy"}:
session_id = str(req.get("session_id") or "")
profile = self._profile_for_session(session_id, req.get("profile"))
resp = self._forward(profile, req)
if action == "destroy":
with self._lock:
self._session_profile.pop(session_id, None)
return resp
if action == "approval_respond":
approval_id = str(req.get("approval_id") or "").strip()
if not approval_id:
raise ValueError("approval_id is required")
with self._lock:
profile = self._approval_profile.get(approval_id)
if not profile:
raise KeyError(f"unknown approval request: {approval_id}")
return self._forward(profile, req)
if action == "compression_respond":
request_id = str(req.get("request_id") or "").strip()
if not request_id:
raise ValueError("request_id is required")
with self._lock:
profile = self._compression_profile.get(request_id)
if not profile:
raise KeyError(f"unknown compression request: {request_id}")
return self._forward(profile, req)
if action == "destroy_all":
with self._lock:
workers = list(self._workers.values())
self._run_profile.clear()
self._session_profile.clear()
self._approval_profile.clear()
self._compression_profile.clear()
destroyed = 0
for worker in workers:
if not worker.running:
worker.stop()
continue
try:
resp = worker.request({"action": "destroy_all"})
destroyed += int(resp.get("destroyed") or 0)
except Exception:
pass
return {"destroyed": destroyed}
if action == "destroy_profile":
profile = self._normalize_profile(req.get("profile"))
with self._lock:
worker = self._workers.get(profile)
self._run_profile = {key: value for key, value in self._run_profile.items() if value != profile}
self._session_profile = {key: value for key, value in self._session_profile.items() if value != profile}
self._approval_profile = {key: value for key, value in self._approval_profile.items() if value != profile}
self._compression_profile = {key: value for key, value in self._compression_profile.items() if value != profile}
if worker is None or not worker.running:
if worker is not None:
worker.stop()
return {"profile": profile, "destroyed": 0}
try:
resp = worker.request({"action": "destroy_all"})
return {"profile": profile, "destroyed": int(resp.get("destroyed") or 0)}
except Exception:
return {"profile": profile, "destroyed": 0}
if action == "list":
sessions: list[Any] = []
with self._lock:
workers = list(self._workers.items())
for profile, worker in workers:
if not worker.running:
continue
try:
resp = worker.request({"action": "list"})
for session in resp.get("sessions") or []:
if isinstance(session, dict):
session.setdefault("profile", profile)
sessions.append(session)
except Exception:
pass
return {"sessions": sessions}
if action == "shutdown":
self._stop.set()
with self._lock:
workers = list(self._workers.values())
for worker in workers:
if not worker.running:
worker.stop()
continue
try:
worker.request({"action": "shutdown"})
except Exception:
worker.stop()
return {"status": "shutting_down"}
raise ValueError(f"unknown action: {action}")
def _make_server_socket(self) -> socket.socket:
return _make_listen_socket(self.endpoint)
def _read_request(self, conn: socket.socket) -> dict[str, Any]:
return _read_json_request(conn)
def _write_response(self, conn: socket.socket, resp: dict[str, Any]) -> None:
_write_json_response(conn, resp)
def _gc_idle_workers(self) -> None:
now = time.time()
if now - self._last_gc < self.GC_INTERVAL_SECONDS:
return
self._last_gc = now
with self._lock:
idle = [
profile for profile, worker in self._workers.items()
if worker.running and now - worker.last_used_at > self.IDLE_TIMEOUT_SECONDS
]
for profile in idle:
with self._lock:
worker = self._workers.pop(profile, None)
if worker:
worker.stop()
def serve_forever(self) -> None:
server = self._make_server_socket()
server.listen(64)
server.settimeout(0.2)
print(json.dumps({"event": "ready", "endpoint": self.endpoint, "mode": "broker"}), flush=True)
while not self._stop.is_set():
conn: socket.socket | None = None
try:
try:
conn, _addr = server.accept()
except socket.timeout:
self._gc_idle_workers()
continue
try:
req = self._read_request(conn)
data = self.handle(req)
resp = {"ok": True, **_jsonable(data)}
except Exception as exc:
resp = {
"ok": False,
"error": str(exc),
"error_type": exc.__class__.__name__,
}
self._write_response(conn, resp)
except KeyboardInterrupt:
break
except Exception as exc:
print(f"[hermes-bridge-broker] server loop error: {exc}", file=sys.stderr, flush=True)
finally:
if conn is not None:
try:
conn.close()
except OSError:
pass
with self._lock:
workers = list(self._workers.values())
self._workers.clear()
for worker in workers:
worker.stop()
server.close()
if self.endpoint.startswith("ipc://"):
try:
Path(self.endpoint.removeprefix("ipc://")).unlink(missing_ok=True)
except OSError:
pass
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Hermes AIAgent in-process bridge")
parser.add_argument("--endpoint", default=os.environ.get("HERMES_AGENT_BRIDGE_ENDPOINT", DEFAULT_ENDPOINT))
parser.add_argument("--agent-root", default=os.environ.get("HERMES_AGENT_ROOT", DEFAULT_AGENT_ROOT))
parser.add_argument("--hermes-home", default=os.environ.get("HERMES_HOME", DEFAULT_HERMES_HOME))
parser.add_argument("--worker-profile", default=os.environ.get("HERMES_AGENT_BRIDGE_WORKER_PROFILE"))
args = parser.parse_args(argv)
_set_path_env(args.agent_root, args.hermes_home)
_ensure_agent_imports()
if args.worker_profile:
_set_worker_profile_env(str(args.worker_profile or "default"))
BridgeServer(args.endpoint).serve_forever()
else:
BridgeBroker(args.endpoint, args.agent_root, args.hermes_home).serve_forever()
return 0
@@ -127,7 +127,7 @@ export class ContextEngine {
// Under threshold — return summary + new messages directly
if (totalTokens <= config.triggerTokens) {
logger.debug(`[ContextEngine] [Path A] UNDER threshold — return summary + ${newMessages.length} verbatim msgs directly`)
const history = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId)
const history = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId, input.agentName)
this.logHistory('Path A (no compress)', history)
return { conversationHistory: history, instructions, meta }
}
@@ -155,7 +155,7 @@ export class ContextEngine {
meta.summaryTokenEstimate = this.countTokens(result.summary)
logger.debug(`[ContextEngine] [Path A] incremental compression DONE in ${elapsed}ms, newSummaryLen=${result.summary.length}, newLastMsgId=${lastMsg.id}`)
logger.debug(`[ContextEngine] [Path A] NEW SUMMARY (${result.summary.length} chars): ${result.summary.slice(0, 300)}`)
const history = this.buildHistory(result.summary, newMessages, input.agentSocketId)
const history = this.buildHistory(result.summary, newMessages, input.agentSocketId, input.agentName)
this.logHistory('Path A (after incremental compress)', history)
if (result.sessionId) this.sessionCleaner?.(result.sessionId)
return { conversationHistory: history, instructions, meta }
@@ -163,7 +163,7 @@ export class ContextEngine {
// Compression failed — degrade
logger.warn(`[ContextEngine] [Path A] incremental compression FAILED (${elapsed}ms) — degrading to summary + trimmed verbatim`)
const history = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId)
const history = this.buildHistory(snapshot.summary, newMessages, input.agentSocketId, input.agentName)
this.trimToBudget(history, summaryTokens, config.maxHistoryTokens)
return { conversationHistory: history, instructions, meta }
}
@@ -177,7 +177,7 @@ export class ContextEngine {
// Under threshold — pass all messages verbatim
if (totalTokens <= config.triggerTokens) {
logger.debug(`[ContextEngine] [Path B] UNDER threshold — return all ${total} msgs verbatim`)
const history = messages.map(m => this.mapToHistory(m, input.agentSocketId))
const history = messages.map(m => this.mapToHistory(m, input.agentSocketId, input.agentName))
this.logHistory('Path B (no compress)', history)
return { conversationHistory: history, instructions, meta }
}
@@ -209,7 +209,7 @@ export class ContextEngine {
meta.summaryTokenEstimate = this.countTokens(result.summary)
logger.debug(`[ContextEngine] [Path B] full compression DONE in ${elapsed}ms, summaryLen=${result.summary.length}, compressed=${toCompress.length} msgs, keptTail=${tail.length} msgs, savedLastMsgId=${lastCompressedMsg.id}`)
logger.debug(`[ContextEngine] [Path B] COMPRESSED SUMMARY (${result.summary.length} chars): ${result.summary.slice(0, 300)}`)
const history = this.buildHistory(result.summary, tail, input.agentSocketId)
const history = this.buildHistory(result.summary, tail, input.agentSocketId, input.agentName)
this.logHistory('Path B (after full compress)', history)
if (result.sessionId) this.sessionCleaner?.(result.sessionId)
return { conversationHistory: history, instructions, meta }
@@ -217,7 +217,7 @@ export class ContextEngine {
// Compression failed — degrade
logger.warn(`[ContextEngine] [Path B] full compression FAILED (${elapsed}ms) — degrading to trimmed verbatim`)
const history = messages.map(m => this.mapToHistory(m, input.agentSocketId))
const history = messages.map(m => this.mapToHistory(m, input.agentSocketId, input.agentName))
this.trimToBudget(history, 0, config.maxHistoryTokens)
meta.verbatimCount = history.length
return { conversationHistory: history, instructions, meta }
@@ -265,6 +265,7 @@ export class ContextEngine {
summary: string,
messages: StoredMessage[],
agentSocketId: string,
agentName: string,
): Array<{ role: 'user' | 'assistant'; content: string }> {
const history: Array<{ role: 'user' | 'assistant'; content: string }> = []
@@ -275,7 +276,7 @@ export class ContextEngine {
)
}
history.push(...messages.map(m => this.mapToHistory(m, agentSocketId)))
history.push(...messages.map(m => this.mapToHistory(m, agentSocketId, agentName)))
return history
}
@@ -314,11 +315,51 @@ export class ContextEngine {
private mapToHistory(
msg: StoredMessage,
agentSocketId: string,
agentName: string,
): { role: 'user' | 'assistant'; content: string } {
if (msg.senderId === agentSocketId) {
return { role: 'assistant', content: msg.content }
const senderName = msg.senderName || 'unknown'
const isOwnAgent = msg.senderId === agentSocketId || senderName === agentName
if (msg.role === 'tool') {
const label = msg.tool_name ? `Tool result: ${msg.tool_name}` : 'Tool result'
return { role: 'user', content: `[${senderName}] [${label}]\n${msg.content || ''}` }
}
return { role: 'user', content: `[${msg.senderName}]: ${msg.content}` }
if (msg.role === 'assistant' && msg.tool_calls?.length) {
const toolsInfo = msg.tool_calls.map(tc => {
const name = tc.function?.name || 'unknown'
let args = tc.function?.arguments || '{}'
if (args.length > 4000) args = `${args.slice(0, 4000)}...`
return `[Calling tool: ${name} with arguments: ${args}]`
}).join('\n')
const content = msg.content?.trim()
return {
role: isOwnAgent ? 'assistant' : 'user',
content: content
? `${this.formatAttributedContent(senderName, content)}\n${this.formatAttributionPrefix(senderName, content)}${toolsInfo}`
: `${this.formatAttributionPrefix(senderName, content)}${toolsInfo}`,
}
}
return {
role: isOwnAgent ? 'assistant' : 'user',
content: this.formatAttributedContent(senderName, msg.content || ''),
}
}
private formatAttributedContent(senderName: string, content: string): string {
return `${this.formatAttributionPrefix(senderName)}${this.stripMentions(content)}`
}
private formatAttributionPrefix(senderName: string, _content?: string): string {
return `[${senderName}]: `
}
private stripMentions(content: string): string {
return String(content || '')
.replace(/@([^\s@]+)/g, '')
.replace(/[ \t]{2,}/g, ' ')
.replace(/^\s+/, '')
}
private trimToBudget(
@@ -6,10 +6,11 @@ import {
} from './prompt'
import { updateUsage } from '../../../db/hermes/usage-store'
import { logger } from '../../logger'
import { AgentBridgeClient, type AgentBridgeRunResult } from '../agent-bridge'
/**
* Calls Hermes /v1/responses to produce LLM-generated summaries.
* The context engine owns history assembly; Responses storage/chaining is not used.
* Calls the local bridge to produce LLM-generated summaries.
* The context engine owns history assembly; gateway storage/chaining is not used.
*/
export class GatewaySummarizer implements GatewayCaller {
private timeoutMs: number
@@ -19,8 +20,8 @@ export class GatewaySummarizer implements GatewayCaller {
}
async summarize(
upstream: string,
apiKey: string | null,
_upstream: string,
_apiKey: string | null,
systemPrompt: string,
messages: StoredMessage[],
roomId: string,
@@ -29,7 +30,7 @@ export class GatewaySummarizer implements GatewayCaller {
): Promise<{ summary: string; sessionId: string }> {
const history: Array<{ role: string; content: string }> = messages.map(m => ({
role: 'user',
content: `[${m.senderName}]: ${m.content}`,
content: summarizeMessageForPrompt(m),
}))
if (previousSummary) {
@@ -43,132 +44,67 @@ export class GatewaySummarizer implements GatewayCaller {
? buildIncrementalUpdatePrompt()
: buildFullSummaryPrompt()
const res = await fetch(`${upstream.replace(/\/$/, '')}/v1/responses`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify({
input: userPrompt,
const bridge = new AgentBridgeClient({ timeoutMs: this.timeoutMs + 15_000 })
const sessionId = `gc_compress_${roomId}_${profile}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
.replace(/[^a-zA-Z0-9_-]/g, '_')
.slice(0, 160)
try {
const result = await bridge.request<AgentBridgeRunResult>({
action: 'chat',
session_id: sessionId,
message: userPrompt,
instructions: systemPrompt || buildSummarizationSystemPrompt(),
conversation_history: history,
stream: true,
store: false,
}),
signal: AbortSignal.timeout(this.timeoutMs),
})
profile,
source: 'api_server',
wait: true,
timeout: Math.ceil(this.timeoutMs / 1000),
}, { timeoutMs: this.timeoutMs + 15_000 })
if (!res.ok) {
throw new Error(`Summarization response failed: ${res.status}`)
}
if (!res.body) {
throw new Error('Summarization response stream missing')
if (result.status === 'error') {
throw new Error(result.error || 'Summarization bridge run failed')
}
let output = ''
for await (const frame of readSseFrames(res.body)) {
let parsed: any
try {
parsed = JSON.parse(frame.data)
} catch {
continue
}
const eventType = parsed.type || frame.event || parsed.event
const payload = result.result as any
const output = String(payload?.final_response || result.output || '').trim()
if (!output) throw new Error('Empty summarization response')
if (eventType === 'response.output_text.delta' && parsed.delta) {
output += parsed.delta
continue
}
if (eventType === 'response.completed') {
const response = parsed.response || parsed
const finalText = extractResponseText(response)
if (!output && finalText) output = finalText
const usage = response.usage || {}
const usage = payload?.usage || payload?.response?.usage
if (usage) {
updateUsage(roomId, {
inputTokens: usage.input_tokens ?? usage.inputTokens ?? 0,
outputTokens: usage.output_tokens ?? usage.outputTokens ?? 0,
cacheReadTokens: usage.cache_read_tokens ?? usage.cacheReadTokens ?? 0,
cacheWriteTokens: usage.cache_write_tokens ?? usage.cacheWriteTokens ?? 0,
reasoningTokens: usage.reasoning_tokens ?? usage.reasoningTokens ?? 0,
model: response.model || '',
model: payload?.model || payload?.response?.model || '',
profile,
})
logger.debug(`[GatewaySummarizer] Recorded response usage for compression room ${roomId} (profile=${profile}): input=${usage.input_tokens ?? 0}, output=${usage.output_tokens ?? 0}`)
if (!output || output.trim() === '') {
throw new Error('Empty summarization response')
}
return { summary: output.trim(), sessionId: '' }
}
if (eventType === 'response.failed') {
throw new Error(parsed.error?.message || parsed.error || 'Summarization response failed')
}
}
throw new Error('Summarization response stream ended without a terminal event')
}
}
async function* readSseFrames(stream: ReadableStream<Uint8Array>): AsyncGenerator<{ event?: string; data: string }> {
const decoder = new TextDecoder()
const reader = stream.getReader()
let buffer = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let boundary = buffer.indexOf('\n\n')
while (boundary >= 0) {
const raw = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const frame = parseSseFrame(raw)
if (frame?.data) yield frame
boundary = buffer.indexOf('\n\n')
}
}
buffer += decoder.decode()
const frame = parseSseFrame(buffer)
if (frame?.data) yield frame
logger.debug(`[GatewaySummarizer] Bridge compression completed for room ${roomId} (profile=${profile})`)
return { summary: output, sessionId }
} finally {
reader.releaseLock()
await bridge.destroy(sessionId, profile).catch(() => undefined)
}
}
}
function parseSseFrame(raw: string): { event?: string; data: string } | null {
let event: string | undefined
const data: string[] = []
for (const line of raw.split(/\r?\n/)) {
if (!line || line.startsWith(':')) continue
if (line.startsWith('event:')) {
event = line.slice(6).trim()
} else if (line.startsWith('data:')) {
data.push(line.slice(5).trimStart())
function summarizeMessageForPrompt(message: StoredMessage): string {
if (message.role === 'tool') {
const label = message.tool_name ? `Tool result: ${message.tool_name}` : 'Tool result'
return `[${label}]\n${message.content || ''}`
}
}
if (data.length === 0) return null
return { event, data: data.join('\n') }
}
function extractResponseText(response: any): string {
const output = Array.isArray(response?.output) ? response.output : []
const parts: string[] = []
for (const item of output) {
if (item.type !== 'message') continue
const content = Array.isArray(item.content) ? item.content : []
for (const part of content) {
if (part.type === 'output_text' || part.type === 'text') {
parts.push(part.text || '')
if (message.role === 'assistant' && message.tool_calls?.length) {
const toolsInfo = message.tool_calls.map(tc => {
const name = tc.function?.name || 'tool'
const args = tc.function?.arguments || '{}'
return `${name}(${args})`
}).join(', ')
const content = message.content?.trim()
return `[${message.senderName}]: ${content ? `${content}\n` : ''}[Tool calls: ${toolsInfo}]`
}
}
}
if (parts.length > 0) return parts.join('')
return typeof response?.output_text === 'string' ? response.output_text : ''
return `[${message.senderName}]: ${message.content}`
}
@@ -52,15 +52,23 @@ export function buildAgentInstructions(params: AgentInstructionsParams): string
${memberSection}
- @${params.agentName}
- @自己
-
-
-
- AI
-
-
-
- agent 使 @名字
- @任何人`
- AI
-
- "[发送者]: ..."仿
- 使使 @名字"[${params.agentName}]:"
-
-
- agent @名字 @某个成员
- agent agent @名字
- agent 使 @名字
- @
- @
- @ agent
- @名字
- @任何人`
return getSystemPrompt(basePrompt)
}
@@ -8,6 +8,11 @@ export interface StoredMessage {
senderName: string
content: string
timestamp: number
role?: string
tool_call_id?: string | null
tool_calls?: Array<{ id?: string; type?: string; function?: { name?: string; arguments?: string } }> | null
tool_name?: string | null
finish_reason?: string | null
}
// ─── Compression Config ────────────────────────────────────
@@ -1,11 +1,12 @@
import { readFile, stat as fsStat, readdir, mkdir, rm, rename, copyFile as fsCopyFile, writeFile as fsWriteFile } from 'fs/promises'
import { resolve, normalize, isAbsolute, basename } from 'path'
import { resolve, normalize, isAbsolute, basename, join } from 'path'
import { execFile } from 'child_process'
import { promisify } from 'util'
import { existsSync, readFileSync } from 'fs'
import YAML from 'js-yaml'
import { config } from '../../config'
import { getActiveProfileDir, getActiveEnvPath } from './hermes-profile'
import { isPathWithin, relativePathFromBase } from './hermes-path'
const execFileAsync = promisify(execFile)
const execOpts = { windowsHide: true }
@@ -90,11 +91,7 @@ export function validatePath(filePath: string): string {
* Check if a path is inside the upload directory.
*/
export function isInUploadDir(filePath: string): boolean {
const normalized = normalize(resolve(filePath))
const uploadNormalized = normalize(resolve(config.uploadDir))
return normalized.startsWith(uploadNormalized + '/')
|| normalized.startsWith(uploadNormalized + '\\')
|| normalized === uploadNormalized
return isPathWithin(filePath, config.uploadDir)
}
/**
@@ -120,7 +117,7 @@ export function resolveHermesPath(relativePath: string): string {
throw Object.assign(new Error('Invalid file path'), { code: 'invalid_path' })
}
const resolved = resolve(homeDir, normalized)
if (!resolved.startsWith(homeDir)) {
if (!isPathWithin(resolved, homeDir)) {
throw Object.assign(new Error('Path traversal detected'), { code: 'invalid_path' })
}
return resolved
@@ -160,9 +157,7 @@ export class LocalFileProvider implements FileProvider {
try {
const fullPath = resolve(p, entry.name)
const s = await fsStat(fullPath)
const relPath = fullPath.startsWith(homeDir)
? fullPath.slice(homeDir.length + 1)
: entry.name
const relPath = relativePathFromBase(fullPath, homeDir) ?? entry.name
results.push({
name: entry.name,
path: relPath,
@@ -181,9 +176,7 @@ export class LocalFileProvider implements FileProvider {
const p = validatePath(filePath)
const homeDir = getActiveProfileDir()
const s = await fsStat(p)
const relPath = p.startsWith(homeDir)
? p.slice(homeDir.length + 1)
: basename(p)
const relPath = relativePathFromBase(p, homeDir) ?? basename(p)
return {
name: basename(p),
path: relPath || basename(p),
@@ -291,7 +284,7 @@ export class DockerFileProvider implements FileProvider {
// Node.js supports encoding: 'buffer' but @types/node doesn't type it correctly
const { stdout } = await execFileAsync('docker', [
'exec', this.containerName, 'cat', p,
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any })
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any, ...execOpts })
return stdout as unknown as Buffer
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) {
@@ -309,7 +302,7 @@ export class DockerFileProvider implements FileProvider {
try {
await execFileAsync('docker', [
'exec', this.containerName, 'test', '-f', p,
], { timeout: 5000 })
], { timeout: 5000, ...execOpts })
return true
} catch {
return false
@@ -321,9 +314,9 @@ export class DockerFileProvider implements FileProvider {
try {
const { stdout } = await execFileAsync('docker', [
'exec', this.containerName, 'ls', '-la', '--time-style=+%Y-%m-%dT%H:%M:%S', p,
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT })
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relParent = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : ''
const relParent = relativePathFromBase(p, homeDir) ?? ''
return parseLsOutput(stdout, relParent)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -338,9 +331,9 @@ export class DockerFileProvider implements FileProvider {
try {
const { stdout } = await execFileAsync('docker', [
'exec', this.containerName, 'stat', '-c', '%n|%F|%s|%Y', p,
], { timeout: BACKEND_TIMEOUT })
], { timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relPath = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : basename(p)
const relPath = relativePathFromBase(p, homeDir) ?? basename(p)
return parseStatOutput(stdout, relPath)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -354,7 +347,7 @@ export class DockerFileProvider implements FileProvider {
try {
await execFileAsync('docker', [
'exec', '-i', this.containerName, 'sh', '-c', `cat > '${p.replace(/'/g, "'\\''")}'`,
], { timeout: BACKEND_TIMEOUT, input: content } as any)
], { timeout: BACKEND_TIMEOUT, input: content, ...execOpts } as any)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
@@ -364,7 +357,7 @@ export class DockerFileProvider implements FileProvider {
async deleteFile(filePath: string): Promise<void> {
const p = validatePath(filePath)
try {
await execFileAsync('docker', ['exec', this.containerName, 'rm', p], { timeout: BACKEND_TIMEOUT })
await execFileAsync('docker', ['exec', this.containerName, 'rm', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
@@ -374,7 +367,7 @@ export class DockerFileProvider implements FileProvider {
async deleteDir(dirPath: string): Promise<void> {
const p = validatePath(dirPath)
try {
await execFileAsync('docker', ['exec', this.containerName, 'rm', '-rf', p], { timeout: BACKEND_TIMEOUT })
await execFileAsync('docker', ['exec', this.containerName, 'rm', '-rf', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
@@ -385,7 +378,7 @@ export class DockerFileProvider implements FileProvider {
const op = validatePath(oldPath)
const np = validatePath(newPath)
try {
await execFileAsync('docker', ['exec', this.containerName, 'mv', op, np], { timeout: BACKEND_TIMEOUT })
await execFileAsync('docker', ['exec', this.containerName, 'mv', op, np], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
@@ -395,7 +388,7 @@ export class DockerFileProvider implements FileProvider {
async mkDir(dirPath: string): Promise<void> {
const p = validatePath(dirPath)
try {
await execFileAsync('docker', ['exec', this.containerName, 'mkdir', '-p', p], { timeout: BACKEND_TIMEOUT })
await execFileAsync('docker', ['exec', this.containerName, 'mkdir', '-p', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
@@ -406,7 +399,7 @@ export class DockerFileProvider implements FileProvider {
const sp = validatePath(srcPath)
const dp = validatePath(destPath)
try {
await execFileAsync('docker', ['exec', this.containerName, 'cp', sp, dp], { timeout: BACKEND_TIMEOUT })
await execFileAsync('docker', ['exec', this.containerName, 'cp', sp, dp], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Docker error: ${err.message}`), { code: 'backend_error' })
@@ -451,7 +444,7 @@ export class SSHFileProvider implements FileProvider {
// Pass a single quoted command string to prevent shell injection on remote
const { stdout } = await execFileAsync('ssh', [
...this.sshArgs(), `cat ${this.shellEscape(p)}`,
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any })
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any, ...execOpts })
return stdout as unknown as Buffer
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) {
@@ -469,7 +462,7 @@ export class SSHFileProvider implements FileProvider {
try {
await execFileAsync('ssh', [
...this.sshArgs(), `test -f ${this.shellEscape(p)}`,
], { timeout: 5000 })
], { timeout: 5000, ...execOpts })
return true
} catch {
return false
@@ -481,9 +474,9 @@ export class SSHFileProvider implements FileProvider {
try {
const { stdout } = await execFileAsync('ssh', [
...this.sshArgs(), `ls -la --time-style=+%Y-%m-%dT%H:%M:%S ${this.shellEscape(p)}`,
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT })
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relParent = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : ''
const relParent = relativePathFromBase(p, homeDir) ?? ''
return parseLsOutput(stdout, relParent)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -498,9 +491,9 @@ export class SSHFileProvider implements FileProvider {
try {
const { stdout } = await execFileAsync('ssh', [
...this.sshArgs(), `stat -c '%n|%F|%s|%Y' ${this.shellEscape(p)}`,
], { timeout: BACKEND_TIMEOUT })
], { timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relPath = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : basename(p)
const relPath = relativePathFromBase(p, homeDir) ?? basename(p)
return parseStatOutput(stdout, relPath)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -514,7 +507,7 @@ export class SSHFileProvider implements FileProvider {
try {
await execFileAsync('ssh', [
...this.sshArgs(), `cat > ${this.shellEscape(p)}`,
], { timeout: BACKEND_TIMEOUT, input: content } as any)
], { timeout: BACKEND_TIMEOUT, input: content, ...execOpts } as any)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
@@ -524,7 +517,7 @@ export class SSHFileProvider implements FileProvider {
async deleteFile(filePath: string): Promise<void> {
const p = validatePath(filePath)
try {
await execFileAsync('ssh', [...this.sshArgs(), `rm ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT })
await execFileAsync('ssh', [...this.sshArgs(), `rm ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
@@ -534,7 +527,7 @@ export class SSHFileProvider implements FileProvider {
async deleteDir(dirPath: string): Promise<void> {
const p = validatePath(dirPath)
try {
await execFileAsync('ssh', [...this.sshArgs(), `rm -rf ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT })
await execFileAsync('ssh', [...this.sshArgs(), `rm -rf ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
@@ -545,7 +538,7 @@ export class SSHFileProvider implements FileProvider {
const op = validatePath(oldPath)
const np = validatePath(newPath)
try {
await execFileAsync('ssh', [...this.sshArgs(), `mv ${this.shellEscape(op)} ${this.shellEscape(np)}`], { timeout: BACKEND_TIMEOUT })
await execFileAsync('ssh', [...this.sshArgs(), `mv ${this.shellEscape(op)} ${this.shellEscape(np)}`], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
@@ -555,7 +548,7 @@ export class SSHFileProvider implements FileProvider {
async mkDir(dirPath: string): Promise<void> {
const p = validatePath(dirPath)
try {
await execFileAsync('ssh', [...this.sshArgs(), `mkdir -p ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT })
await execFileAsync('ssh', [...this.sshArgs(), `mkdir -p ${this.shellEscape(p)}`], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
@@ -566,7 +559,7 @@ export class SSHFileProvider implements FileProvider {
const sp = validatePath(srcPath)
const dp = validatePath(destPath)
try {
await execFileAsync('ssh', [...this.sshArgs(), `cp ${this.shellEscape(sp)} ${this.shellEscape(dp)}`], { timeout: BACKEND_TIMEOUT })
await execFileAsync('ssh', [...this.sshArgs(), `cp ${this.shellEscape(sp)} ${this.shellEscape(dp)}`], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`SSH error: ${err.message}`), { code: 'backend_error' })
@@ -590,7 +583,7 @@ export class SingularityFileProvider implements FileProvider {
// Node.js supports encoding: 'buffer' but @types/node doesn't type it correctly
const { stdout } = await execFileAsync('singularity', [
'exec', this.imagePath, 'cat', p,
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any })
], { maxBuffer: MAX_DOWNLOAD_SIZE, timeout: BACKEND_TIMEOUT, encoding: 'buffer' as any, ...execOpts })
return stdout as unknown as Buffer
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) {
@@ -608,7 +601,7 @@ export class SingularityFileProvider implements FileProvider {
try {
await execFileAsync('singularity', [
'exec', this.imagePath, 'test', '-f', p,
], { timeout: 5000 })
], { timeout: 5000, ...execOpts })
return true
} catch {
return false
@@ -620,9 +613,9 @@ export class SingularityFileProvider implements FileProvider {
try {
const { stdout } = await execFileAsync('singularity', [
'exec', this.imagePath, 'ls', '-la', '--time-style=+%Y-%m-%dT%H:%M:%S', p,
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT })
], { maxBuffer: 10 * 1024 * 1024, timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relParent = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : ''
const relParent = relativePathFromBase(p, homeDir) ?? ''
return parseLsOutput(stdout, relParent)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -637,9 +630,9 @@ export class SingularityFileProvider implements FileProvider {
try {
const { stdout } = await execFileAsync('singularity', [
'exec', this.imagePath, 'stat', '-c', '%n|%F|%s|%Y', p,
], { timeout: BACKEND_TIMEOUT })
], { timeout: BACKEND_TIMEOUT, ...execOpts })
const homeDir = getActiveProfileDir()
const relPath = p.startsWith(homeDir) ? p.slice(homeDir.length + 1).replace(/\\/g, '/') : basename(p)
const relPath = relativePathFromBase(p, homeDir) ?? basename(p)
return parseStatOutput(stdout, relPath)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
@@ -653,7 +646,7 @@ export class SingularityFileProvider implements FileProvider {
try {
await execFileAsync('singularity', [
'exec', this.imagePath, 'sh', '-c', `cat > '${p.replace(/'/g, "'\\''")}'`,
], { timeout: BACKEND_TIMEOUT, input: content } as any)
], { timeout: BACKEND_TIMEOUT, input: content, ...execOpts } as any)
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
@@ -663,7 +656,7 @@ export class SingularityFileProvider implements FileProvider {
async deleteFile(filePath: string): Promise<void> {
const p = validatePath(filePath)
try {
await execFileAsync('singularity', ['exec', this.imagePath, 'rm', p], { timeout: BACKEND_TIMEOUT })
await execFileAsync('singularity', ['exec', this.imagePath, 'rm', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
@@ -673,7 +666,7 @@ export class SingularityFileProvider implements FileProvider {
async deleteDir(dirPath: string): Promise<void> {
const p = validatePath(dirPath)
try {
await execFileAsync('singularity', ['exec', this.imagePath, 'rm', '-rf', p], { timeout: BACKEND_TIMEOUT })
await execFileAsync('singularity', ['exec', this.imagePath, 'rm', '-rf', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
@@ -684,7 +677,7 @@ export class SingularityFileProvider implements FileProvider {
const op = validatePath(oldPath)
const np = validatePath(newPath)
try {
await execFileAsync('singularity', ['exec', this.imagePath, 'mv', op, np], { timeout: BACKEND_TIMEOUT })
await execFileAsync('singularity', ['exec', this.imagePath, 'mv', op, np], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
@@ -694,7 +687,7 @@ export class SingularityFileProvider implements FileProvider {
async mkDir(dirPath: string): Promise<void> {
const p = validatePath(dirPath)
try {
await execFileAsync('singularity', ['exec', this.imagePath, 'mkdir', '-p', p], { timeout: BACKEND_TIMEOUT })
await execFileAsync('singularity', ['exec', this.imagePath, 'mkdir', '-p', p], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
@@ -705,7 +698,7 @@ export class SingularityFileProvider implements FileProvider {
const sp = validatePath(srcPath)
const dp = validatePath(destPath)
try {
await execFileAsync('singularity', ['exec', this.imagePath, 'cp', sp, dp], { timeout: BACKEND_TIMEOUT })
await execFileAsync('singularity', ['exec', this.imagePath, 'cp', sp, dp], { timeout: BACKEND_TIMEOUT, ...execOpts })
} catch (err: any) {
if (err.code === 'ETIMEDOUT' || err.killed) throw Object.assign(new Error('Backend timeout'), { code: 'backend_timeout' })
throw Object.assign(new Error(`Singularity error: ${err.message}`), { code: 'backend_error' })
@@ -720,7 +713,7 @@ export class SingularityFileProvider implements FileProvider {
*/
export function getTerminalConfig(): TerminalConfig {
try {
const configPath = `${getActiveProfileDir()}/config.yaml`
const configPath = join(getActiveProfileDir(), 'config.yaml')
if (!existsSync(configPath)) return { backend: 'local' }
const raw = readFileSync(configPath, 'utf-8')
const doc = YAML.load(raw, { json: true }) as any
@@ -777,7 +770,7 @@ async function resolveDockerContainer(cfg: TerminalConfig): Promise<string> {
try {
const { stdout } = await execFileAsync('docker', [
'ps', '-q', '--filter', `ancestor=${cfg.docker_image}`, '--latest',
], { timeout: 5000 })
], { timeout: 5000, ...execOpts })
const id = stdout.trim()
if (id) return id
} catch { }
@@ -0,0 +1,125 @@
import { execFile } from 'child_process'
import { existsSync } from 'fs'
import { join } from 'path'
import { promisify } from 'util'
import { stripLegacyApiServerGatewayConfig } from '../config-helpers'
import { logger } from '../logger'
import { safeFileStore } from '../safe-file-store'
import { getProfileDir, listProfileNamesFromDisk } from './hermes-profile'
import { startGatewayRunManaged } from './gateway-runner'
const execFileAsync = promisify(execFile)
function resolveHermesBin(): string {
return process.env.HERMES_BIN?.trim() || 'hermes'
}
function isDockerRuntime(): boolean {
return existsSync('/.dockerenv')
}
function isTermuxRuntime(): boolean {
const prefix = process.env.PREFIX || ''
return !!process.env.TERMUX_VERSION ||
prefix.includes('/com.termux/') ||
existsSync('/data/data/com.termux/files/usr')
}
export function gatewayStatusLooksRunning(output: string): boolean {
const text = output.toLowerCase()
if (text.includes('gateway is not running') || text.includes('not running')) return false
return text.includes('gateway is running') || text.includes('running')
}
export function gatewayStatusLooksRuntimeLocked(output: string): boolean {
const text = output.toLowerCase()
return text.includes('runtime lock is already held')
|| text.includes('gateway runtime lock is already held')
|| text.includes('already held by another instance')
}
export async function isGatewayRunningForProfile(hermesBin: string, profileDir: string): Promise<boolean> {
try {
const { stdout, stderr } = await execFileAsync(hermesBin, ['gateway', 'status'], {
timeout: 10000,
windowsHide: true,
env: {
...process.env,
HERMES_HOME: profileDir,
},
})
return gatewayStatusLooksRunning(`${stdout}\n${stderr}`)
} catch (err: any) {
const output = `${err?.stdout || ''}\n${err?.stderr || ''}\n${err?.message || ''}`
if (gatewayStatusLooksRuntimeLocked(output)) {
logger.info({ profileDir }, 'Hermes gateway status reported runtime lock held; treating gateway as already running')
return true
}
if (output.trim()) {
logger.warn({ err, profileDir }, 'Hermes gateway status failed; treating as not running')
}
return false
}
}
async function startGatewayForProfile(hermesBin: string, profile: string, profileDir: string): Promise<void> {
if (isDockerRuntime() || isTermuxRuntime()) {
const result = startGatewayRunManaged(hermesBin, { profileDir })
logger.info(
'[gateway-autostart] gateway started via background run profile=%s home=%s pid=%s',
profile,
profileDir,
result.pid || 'unknown',
)
return
}
try {
await execFileAsync(hermesBin, ['gateway', 'start'], {
timeout: 30000,
windowsHide: true,
env: {
...process.env,
HERMES_HOME: profileDir,
},
})
logger.info('[gateway-autostart] gateway started via Hermes CLI service profile=%s home=%s', profile, profileDir)
} catch (err) {
logger.warn(err, '[gateway-autostart] Hermes CLI gateway start failed; falling back to background run profile=%s home=%s', profile, profileDir)
const result = startGatewayRunManaged(hermesBin, { profileDir })
logger.info(
'[gateway-autostart] gateway started via fallback background run profile=%s home=%s pid=%s',
profile,
profileDir,
result.pid || 'unknown',
)
}
}
export async function clearApiServerForProfile(profileDir: string): Promise<void> {
const configPath = join(profileDir, 'config.yaml')
try {
await safeFileStore.updateYaml(configPath, (config) => {
const result = stripLegacyApiServerGatewayConfig(config)
return { data: result.config, result: undefined, write: result.changed }
}, { backup: true })
} catch (err) {
logger.warn(err, 'Failed to clear legacy api_server gateway config before gateway startup: %s', profileDir)
}
}
export async function ensureProfileGatewaysRunning(): Promise<void> {
const hermesBin = resolveHermesBin()
const profiles = listProfileNamesFromDisk()
for (const profile of profiles) {
const profileDir = getProfileDir(profile)
const running = await isGatewayRunningForProfile(hermesBin, profileDir)
if (running) {
logger.info('[gateway-autostart] gateway already running profile=%s home=%s', profile, profileDir)
continue
}
await clearApiServerForProfile(profileDir)
await startGatewayForProfile(hermesBin, profile, profileDir)
}
}
@@ -14,13 +14,13 @@
* - stopped
*
* detectStatus
* config.yaml resolvePort
* config.yaml
*
* resolvePort
*
* PID
* Web UI
* 8642 config.yaml
* 8642 使 config.yaml
*
*
* - 使 `hermes gateway run --replace`
@@ -36,7 +36,6 @@ import { createServer } from 'net'
import yaml from 'js-yaml'
import { logger } from '../logger'
import { detectHermesHome, getHermesBin } from './hermes-path'
import { safeFileStore } from '../safe-file-store'
const execFileAsync = promisify(execFile)
@@ -334,53 +333,6 @@ export class GatewayManager {
})
}
// ============================
// 配置写入
// ============================
/**
* profile config.yaml
*
* platforms:
* api_server:
* enabled: true
* key: ''
* cors_origins: '*'
* extra:
* port: <port>
* host: <host>
* port/host Hermes
*/
private async writeProfilePort(name: string, port: number, host: string): Promise<void> {
const configPath = join(this.profileDir(name), 'config.yaml')
try {
await safeFileStore.updateYaml(configPath, (cfg) => {
// 确保 platforms.api_server 结构存在(不会影响其他位置的 platforms)
if (!cfg.platforms) cfg.platforms = {}
if (!cfg.platforms.api_server) cfg.platforms.api_server = {}
if (!cfg.platforms.api_server.extra) cfg.platforms.api_server.extra = {}
cfg.platforms.api_server.enabled = true
cfg.platforms.api_server.key = ''
cfg.platforms.api_server.cors_origins = '*'
cfg.platforms.api_server.extra.port = port
cfg.platforms.api_server.extra.host = host
// 清理旧的顶层 port/hostHermes 只从 extra 读取
if (cfg.platforms.api_server.port !== undefined) {
delete cfg.platforms.api_server.port
}
if (cfg.platforms.api_server.host !== undefined) {
delete cfg.platforms.api_server.host
}
return cfg
})
logger.debug('Updated %s: api_server.extra.port = %d', configPath, port)
} catch (err) {
logger.error(err, 'Failed to write config for profile "%s"', name)
}
}
// ============================
// 端口分配
// ============================
@@ -392,7 +344,6 @@ export class GatewayManager {
* 1. profile 使
* 2. 8642
* 3. profile / / TCP
* 4. config.yaml gateway
*/
private async resolvePort(name: string): Promise<{ port: number; host: string }> {
const { port: configuredPort, host } = this.readProfilePort(name)
@@ -437,8 +388,6 @@ export class GatewayManager {
} else {
logger.debug('Assigning port %d for profile "%s"', port, name)
}
await this.writeProfilePort(name, port, host)
this.allocatedPorts.add(port)
return { port, host }
}
@@ -0,0 +1,22 @@
import { spawn } from 'child_process'
import { getActiveProfileDir } from './hermes-profile'
export function startGatewayRunManaged(
hermesBin: string,
opts: { profileDir?: string } = {},
): { pid: number | null; reused: boolean } {
const profileDir = opts.profileDir || getActiveProfileDir()
const child = spawn(hermesBin, ['gateway', 'run', '--replace'], {
detached: true,
stdio: 'ignore',
windowsHide: true,
env: {
...process.env,
HERMES_HOME: profileDir,
},
})
child.unref()
const pid = child.pid ?? null
return { pid, reused: false }
}
@@ -1,8 +1,10 @@
import { io, Socket } from 'socket.io-client'
import { getToken } from '../../../services/auth'
import type { GatewayManager } from '../gateway-manager'
import { logger } from '../../../services/logger'
import { updateUsage } from '../../../db/hermes/usage-store'
import { AgentBridgeClient, type AgentBridgeMessage, type AgentBridgeOutput } from '../agent-bridge'
import { convertContentBlocksForAgent, isContentBlockArray } from '../run-chat/content-blocks'
import type { ContentBlock } from '../run-chat/types'
// ─── Types ────────────────────────────────────────────────────
@@ -22,6 +24,15 @@ interface MessageData {
timestamp: number
}
type MentionMessage = {
content: string
senderName: string
senderId: string
timestamp: number
input?: string | ContentBlock[]
mentionDepth?: number
}
interface MemberData {
id: string
name: string
@@ -55,9 +66,10 @@ class AgentClient {
private joinedRooms = new Set<string>()
private handlers: AgentEventHandler
private _reconnecting = false
private gatewayManager: GatewayManager | null = null
private contextEngine: any = null
private storage: any = null
private pendingToolCallIds = new Map<string, string[]>()
private pendingToolBaseIds = new Map<string, string>()
constructor(config: AgentConfig, handlers: AgentEventHandler = {}) {
this.agentId = Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
@@ -75,10 +87,6 @@ class AgentClient {
return this.socket?.id
}
setGatewayManager(manager: GatewayManager): void {
this.gatewayManager = manager
}
setContextEngine(engine: any): void {
this.contextEngine = engine
}
@@ -146,10 +154,10 @@ class AgentClient {
})
}
sendMessage(roomId: string, content: string): Promise<string> {
sendMessage(roomId: string, content: string, messageId?: string, extra?: Record<string, unknown>): Promise<string> {
this.ensureConnected()
return new Promise((resolve, reject) => {
this.socket!.emit('message', { roomId, content }, (res: { id?: string; error?: string }) => {
this.socket!.emit('message', { roomId, content, id: messageId, ...extra }, (res: { id?: string; error?: string }) => {
if (res.error) {
reject(new Error(res.error))
} else {
@@ -174,6 +182,52 @@ class AgentClient {
this.socket!.emit('context_status', { roomId, agentName: this.name, status })
}
emitApprovalRequested(roomId: string, payload: Record<string, unknown>): void {
this.ensureConnected()
this.socket!.emit('approval.requested', { roomId, agentName: this.name, ...payload })
}
emitApprovalResolved(roomId: string, payload: Record<string, unknown>): void {
this.ensureConnected()
this.socket!.emit('approval.resolved', { roomId, agentName: this.name, ...payload })
}
async interrupt(roomId: string): Promise<void> {
const sessionSeed = String(this.storage?.getRoom?.(roomId)?.sessionSeed || '0')
const sessionId = groupBridgeSessionId(roomId, this.profile, this.name, sessionSeed)
await new AgentBridgeClient().interrupt(sessionId, 'Interrupted by group chat user', this.profile)
this.stopTyping(roomId)
this.emitContextStatus(roomId, 'ready')
}
emitMessageStreamStart(roomId: string, messageId: string): void {
this.ensureConnected()
this.socket!.emit('message_stream_start', {
roomId,
id: messageId,
senderId: this.socket?.id || this.agentId,
senderName: this.name,
timestamp: Date.now(),
})
}
emitMessageStreamDelta(roomId: string, messageId: string, delta: string): void {
if (!delta) return
this.ensureConnected()
this.socket!.emit('message_stream_delta', { roomId, id: messageId, delta })
}
emitMessageReasoningDelta(roomId: string, messageId: string, delta: string): void {
if (!delta) return
this.ensureConnected()
this.socket!.emit('message_reasoning_delta', { roomId, id: messageId, delta })
}
emitMessageStreamEnd(roomId: string, messageId: string): void {
this.ensureConnected()
this.socket!.emit('message_stream_end', { roomId, id: messageId })
}
getJoinedRooms(): string[] {
return Array.from(this.joinedRooms)
}
@@ -193,23 +247,10 @@ class AgentClient {
*/
async replyToMention(
roomId: string,
msg: { content: string; senderName: string; senderId: string; timestamp: number },
msg: MentionMessage,
onStatus?: (status: 'compressing' | 'replying' | 'ready') => void,
): Promise<void> {
logger.debug(`[AgentClients] ${this.name} mentioned by ${msg.senderName}: "${msg.content.slice(0, 50)}"`)
if (!this.gatewayManager) {
logger.debug(`[AgentClients] ${this.name}: gatewayManager is null, skipping`)
return
}
const upstream = this.gatewayManager.getUpstream(this.profile)
const apiKey = this.gatewayManager.getApiKey(this.profile)
logger.debug(`[AgentClients] ${this.name}: upstream=${upstream}, profile=${this.profile}`)
if (!upstream) {
logger.error(`[AgentClients] ${this.name}: no gateway upstream for profile "${this.profile}"`)
return
}
try {
// Notify room that agent is typing
this.startTyping(roomId)
@@ -244,8 +285,8 @@ class AgentClient {
roomName: roomId,
memberNames,
members,
upstream,
apiKey,
upstream: '',
apiKey: null,
currentMessage: msg,
compression,
profile: this.profile,
@@ -261,86 +302,101 @@ class AgentClient {
}
}
// Strip @mention from input — agent already knows it was mentioned
const input = msg.content.replace(new RegExp(`@${this.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi'), '').trim() || msg.content
const responseRes = await fetch(`${upstream.replace(/\/$/, '')}/v1/responses`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
// Keep the original mentions visible and add an explicit routing note.
// When a user mentions multiple agents, stripping only this agent's
// name can make the remaining input look like it was meant for
// someone else.
const routedPrefix = `群聊系统:这条消息已经提及你(${this.name}),请直接回复;即使消息同时提及其他成员,也不要因此输出空回复。`
const ownMentionPattern = new RegExp(`@${this.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`, 'gi')
const rawInput = msg.input || msg.content
const input = isContentBlockArray(rawInput)
? rawInput.map((block) => {
if (block.type !== 'text') return block
const text = String(block.text || msg.content).replace(ownMentionPattern, '').trim()
return { ...block, text: `${routedPrefix}\n\n原始消息:${text || msg.content}` }
})
: `${routedPrefix}\n\n原始消息:${msg.content.replace(ownMentionPattern, '').trim() || msg.content}`
const bridgeInput: AgentBridgeMessage = isContentBlockArray(input)
? await convertContentBlocksForAgent(input)
: input
const bridge = new AgentBridgeClient()
const sessionSeed = String(this.storage?.getRoom?.(roomId)?.sessionSeed || '0')
const sessionId = groupBridgeSessionId(roomId, this.profile, this.name, sessionSeed)
const runMessageId = groupMessageId(roomId, this.profile, this.name)
let partIndex = 0
let streamMessageId = groupMessagePartId(runMessageId, partIndex)
let currentContent = ''
let totalContent = ''
let reasoningContent = ''
const flushedAssistantParts = new Set<string>()
let lastChunk: AgentBridgeOutput | null = null
const started = await bridge.chat(
sessionId,
bridgeInput,
conversationHistory,
instructions,
this.profile,
{
source: 'api_server',
},
body: JSON.stringify({
input,
...(conversationHistory.length > 0 ? { conversation_history: conversationHistory } : {}),
...(instructions ? { instructions } : {}),
stream: true,
store: false,
}),
signal: AbortSignal.timeout(120000),
)
this.emitMessageStreamStart(roomId, streamMessageId)
for await (const chunk of bridge.streamOutput(started.run_id, { timeoutMs: 120000 })) {
lastChunk = chunk
reasoningContent += await this.recordBridgeEvents(roomId, chunk, () => streamMessageId, async () => {
const toolBaseId = streamMessageId
if (currentContent.trim()) {
await this.sendMessage(roomId, currentContent, streamMessageId, {
role: 'assistant',
mentionDepth: nextMentionDepth(msg),
reasoning: reasoningContent || null,
reasoning_content: reasoningContent || null,
})
if (!responseRes.ok) {
const text = await responseRes.text().catch(() => '')
logger.error(`[AgentClients] ${this.name}: gateway response failed (${responseRes.status}): ${text}`)
this.stopTyping(roomId)
onStatus?.('ready')
return
flushedAssistantParts.add(streamMessageId)
currentContent = ''
}
if (!responseRes.body) {
logger.error(`[AgentClients] ${this.name}: gateway response stream missing`)
this.stopTyping(roomId)
onStatus?.('ready')
return
}
let fullContent = ''
for await (const frame of readSseFrames(responseRes.body)) {
let parsed: any
try {
parsed = JSON.parse(frame.data)
} catch {
continue
}
const eventType = parsed.type || frame.event || parsed.event
logger.debug(`[AgentClients] ${this.name}: event=${eventType}`)
if (eventType === 'response.output_text.delta' && parsed.delta) {
fullContent += parsed.delta
continue
}
if (eventType === 'response.completed') {
const response = parsed.response || parsed
const finalText = extractResponseText(response)
if (!fullContent && finalText) fullContent = finalText
const usage = response.usage || {}
updateUsage(roomId, {
inputTokens: usage.input_tokens ?? usage.inputTokens ?? 0,
outputTokens: usage.output_tokens ?? usage.outputTokens ?? 0,
cacheReadTokens: usage.cache_read_tokens ?? usage.cacheReadTokens ?? 0,
cacheWriteTokens: usage.cache_write_tokens ?? usage.cacheWriteTokens ?? 0,
reasoningTokens: usage.reasoning_tokens ?? usage.reasoningTokens ?? 0,
model: response.model || '',
profile: this.profile,
this.emitMessageStreamEnd(roomId, toolBaseId)
partIndex += 1
streamMessageId = groupMessagePartId(runMessageId, partIndex)
this.emitMessageStreamStart(roomId, streamMessageId)
return toolBaseId
})
logger.debug(`[AgentClients] ${this.name}: response completed, content length=${fullContent.length}`)
if (fullContent) {
this.stopTyping(roomId)
this.sendMessage(roomId, fullContent)
if (chunk.delta) {
currentContent += chunk.delta
totalContent += chunk.delta
this.emitMessageStreamDelta(roomId, streamMessageId, chunk.delta)
}
}
if (lastChunk?.status === 'error') {
logger.error(`[AgentClients] ${this.name}: bridge response failed: ${lastChunk.error || 'unknown error'}`)
this.emitMessageStreamEnd(roomId, streamMessageId)
this.stopTyping(roomId)
onStatus?.('ready')
return
}
if (eventType === 'response.failed') {
logger.error(`[AgentClients] ${this.name}: response failed`)
if (!totalContent) {
currentContent = extractBridgeFinalText(lastChunk)
totalContent = currentContent
}
recordBridgeUsage(roomId, this.profile, lastChunk?.result)
logger.debug(`[AgentClients] ${this.name}: bridge response completed, content length=${totalContent.length}`)
if (currentContent) {
this.stopTyping(roomId)
await this.sendMessage(roomId, currentContent, streamMessageId, {
role: 'assistant',
mentionDepth: nextMentionDepth(msg),
reasoning: reasoningContent || null,
reasoning_content: reasoningContent || null,
})
this.emitMessageStreamEnd(roomId, streamMessageId)
onStatus?.('ready')
return
}
}
logger.warn(`[AgentClients] ${this.name}: response stream ended without terminal event`)
logger.warn(`[AgentClients] ${this.name}: bridge response completed without content`)
this.emitMessageStreamEnd(roomId, streamMessageId)
this.stopTyping(roomId)
onStatus?.('ready')
} catch (err: any) {
@@ -350,6 +406,132 @@ class AgentClient {
}
}
private async recordBridgeEvents(
roomId: string,
chunk: AgentBridgeOutput,
getCurrentMessageId: () => string,
beforeToolStarted: () => Promise<string>,
): Promise<string> {
let reasoning = ''
for (const ev of chunk.events || []) {
const eventType = String((ev as any)?.event || '')
if (eventType === 'tool.started') {
const toolBaseId = await beforeToolStarted()
this.recordToolStarted(roomId, ev as Record<string, unknown>, toolBaseId)
} else if (eventType === 'tool.completed') {
this.recordToolCompleted(roomId, ev as Record<string, unknown>)
} else if (eventType === 'approval.requested') {
this.emitApprovalRequested(roomId, {
event: 'approval.requested',
approval_id: (ev as any).approval_id,
command: (ev as any).command,
description: (ev as any).description,
choices: Array.isArray((ev as any).choices) ? (ev as any).choices : undefined,
allow_permanent: (ev as any).allow_permanent,
})
} else if (eventType === 'approval.resolved') {
this.emitApprovalResolved(roomId, {
event: 'approval.resolved',
approval_id: (ev as any).approval_id,
choice: (ev as any).choice,
})
} else if (eventType === 'reasoning.delta' || eventType === 'thinking.delta') {
const text = String((ev as any)?.text || '')
reasoning += text
this.emitMessageReasoningDelta(roomId, getCurrentMessageId(), text)
}
}
return reasoning
}
private recordToolStarted(roomId: string, ev: Record<string, unknown>, runMessageId: string): void {
const toolName = String(ev.tool_name || ev.tool || ev.name || '')
const toolCallId = groupToolCallId(ev.tool_call_id, toolName, this.nextToolIndex(roomId, toolName))
this.trackPendingToolCall(roomId, toolName, toolCallId)
this.pendingToolBaseIds.set(toolCallId, runMessageId)
const timestamp = Date.now()
const rawArgs = ev.args ?? ev.arguments ?? ev.input ?? {}
const args = normalizeToolArgs(rawArgs)
const toolCall = {
id: toolCallId,
type: 'function',
function: {
name: toolName,
arguments: JSON.stringify(args),
},
}
const msg: MessageData & Record<string, any> = {
id: `${runMessageId}_toolcall_${safeId(toolCallId)}`,
roomId,
senderId: this.socket?.id || this.agentId,
senderName: this.name,
content: '',
timestamp,
role: 'assistant',
tool_calls: [toolCall],
finish_reason: 'tool_calls',
}
this.sendMessage(roomId, '', msg.id, {
role: 'assistant',
tool_calls: msg.tool_calls,
finish_reason: 'tool_calls',
timestamp,
}).catch((err: any) => logger.warn(`[AgentClients] failed to record tool call: ${err.message}`))
}
private recordToolCompleted(roomId: string, ev: Record<string, unknown>): void {
const toolName = String(ev.tool_name || ev.tool || ev.name || '')
const rawId = String(ev.tool_call_id || '').trim()
const toolCallId = rawId || this.takePendingToolCall(roomId, toolName) || groupToolCallId(null, toolName, this.nextToolIndex(roomId, toolName))
const runMessageId = this.pendingToolBaseIds.get(toolCallId) || groupMessagePartId(groupMessageId(roomId, this.profile, this.name), 0)
this.pendingToolBaseIds.delete(toolCallId)
const output = bridgeToolOutput(ev)
const timestamp = Date.now()
const msg: MessageData & Record<string, any> = {
id: `${runMessageId}_toolresult_${safeId(toolCallId)}_${Date.now()}`,
roomId,
senderId: this.socket?.id || this.agentId,
senderName: this.name,
content: output,
timestamp,
role: 'tool',
tool_call_id: toolCallId,
tool_name: toolName || null,
}
this.sendMessage(roomId, output, msg.id, {
role: 'tool',
tool_call_id: toolCallId,
tool_name: toolName || null,
timestamp,
}).catch((err: any) => logger.warn(`[AgentClients] failed to record tool result: ${err.message}`))
}
private pendingToolKey(roomId: string, toolName: string): string {
return `${roomId}::${toolName || 'tool'}`
}
private trackPendingToolCall(roomId: string, toolName: string, toolCallId: string): void {
const key = this.pendingToolKey(roomId, toolName)
const list = this.pendingToolCallIds.get(key) || []
list.push(toolCallId)
this.pendingToolCallIds.set(key, list)
}
private takePendingToolCall(roomId: string, toolName: string): string | undefined {
const key = this.pendingToolKey(roomId, toolName)
const list = this.pendingToolCallIds.get(key)
if (!list?.length) return undefined
const id = list.shift()
if (list.length) this.pendingToolCallIds.set(key, list)
else this.pendingToolCallIds.delete(key)
return id
}
private nextToolIndex(roomId: string, toolName: string): number {
const key = this.pendingToolKey(roomId, toolName)
return (this.pendingToolCallIds.get(key)?.length || 0) + 1
}
private bindEvents(): void {
const s = this.socket!
@@ -387,77 +569,79 @@ class AgentClient {
}
}
async function* readSseFrames(stream: ReadableStream<Uint8Array>): AsyncGenerator<{ event?: string; data: string }> {
const decoder = new TextDecoder()
const reader = stream.getReader()
let buffer = ''
function groupBridgeSessionId(roomId: string, profile: string, name: string, sessionSeed: string): string {
const raw = `gc_${roomId}_${profile}_${name}_${sessionSeed || '0'}`
return raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 120)
}
function groupMessageId(roomId: string, profile: string, name: string): string {
const raw = `gcmsg_${safeId(roomId)}_${safeId(profile)}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
return raw.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 160)
}
function groupMessagePartId(runMessageId: string, partIndex: number): string {
return `${safeId(runMessageId)}_part_${partIndex}`
}
function groupToolCallId(rawToolCallId: unknown, toolName: string, index: number): string {
const raw = String(rawToolCallId || '').trim()
if (raw) return raw
return `cli_${safeId(toolName || 'tool')}_${Date.now()}_${index}`
}
function safeId(value: string): string {
return String(value || 'item').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80)
}
function bridgeToolOutput(ev: Record<string, unknown>): string {
const value = ev.result ?? ev.output ?? ev.result_preview ?? ev.preview ?? ''
return typeof value === 'string' ? value : JSON.stringify(value ?? '')
}
function normalizeToolArgs(value: unknown): Record<string, unknown> {
if (!value) return {}
if (typeof value === 'string') {
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let boundary = buffer.indexOf('\n\n')
while (boundary >= 0) {
const raw = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const frame = parseSseFrame(raw)
if (frame?.data) yield frame
boundary = buffer.indexOf('\n\n')
const parsed = JSON.parse(value)
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : { value }
} catch {
return { value }
}
}
buffer += decoder.decode()
const frame = parseSseFrame(buffer)
if (frame?.data) yield frame
} finally {
reader.releaseLock()
}
return typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : { value }
}
function parseSseFrame(raw: string): { event?: string; data: string } | null {
let event: string | undefined
const data: string[] = []
for (const line of raw.split(/\r?\n/)) {
if (!line || line.startsWith(':')) continue
if (line.startsWith('event:')) {
event = line.slice(6).trim()
} else if (line.startsWith('data:')) {
data.push(line.slice(5).trimStart())
}
}
if (data.length === 0) return null
return { event, data: data.join('\n') }
function extractBridgeFinalText(chunk: AgentBridgeOutput | null): string {
const result = chunk?.result as any
const output = result?.final_response || chunk?.output || ''
return typeof output === 'string' ? output.trim() : ''
}
function extractResponseText(response: any): string {
const output = Array.isArray(response?.output) ? response.output : []
const parts: string[] = []
for (const item of output) {
if (item.type !== 'message') continue
const content = Array.isArray(item.content) ? item.content : []
for (const part of content) {
if (part.type === 'output_text' || part.type === 'text') {
parts.push(part.text || '')
}
}
}
if (parts.length > 0) return parts.join('')
return typeof response?.output_text === 'string' ? response.output_text : ''
function recordBridgeUsage(roomId: string, profile: string, result: unknown): void {
const payload = result as any
const usage = payload?.usage || payload?.response?.usage
if (!usage) return
updateUsage(roomId, {
inputTokens: usage.input_tokens ?? usage.inputTokens ?? 0,
outputTokens: usage.output_tokens ?? usage.outputTokens ?? 0,
cacheReadTokens: usage.cache_read_tokens ?? usage.cacheReadTokens ?? 0,
cacheWriteTokens: usage.cache_write_tokens ?? usage.cacheWriteTokens ?? 0,
reasoningTokens: usage.reasoning_tokens ?? usage.reasoningTokens ?? 0,
model: payload?.model || payload?.response?.model || '',
profile,
})
}
// ─── AgentClients (roomId -> agents) ──────────────────────────
export class AgentClients {
private rooms = new Map<string, Map<string, AgentClient>>()
private _gatewayManager: GatewayManager | null = null
private _contextEngine: any = null
private _storage: any = null
// Per-room processing lock + mention queue
private _processingRooms = new Set<string>()
private _mentionQueue = new Map<string, Array<{ agent: AgentClient; msg: { content: string; senderName: string; senderId: string; timestamp: number } }>>()
private _mentionQueue = new Map<string, Array<{ agent: AgentClient; msg: MentionMessage }>>()
/**
* Create an agent client and connect it to the server.
@@ -468,7 +652,6 @@ export class AgentClients {
await client.connect(port)
// Auto-apply stored references (fixes propagation for agents created after set*)
if (this._gatewayManager) client.setGatewayManager(this._gatewayManager)
if (this._contextEngine) client.setContextEngine(this._contextEngine)
if (this._storage) client.setStorage(this._storage)
@@ -557,6 +740,13 @@ export class AgentClients {
return Promise.all(agents.map((agent) => agent.sendMessage(roomId, content)))
}
async interruptAgent(roomId: string, agentName: string): Promise<void> {
const agent = this.getAgents(roomId).find(a => a.name === agentName)
if (!agent) throw new Error(`Agent "${agentName}" not found in room "${roomId}"`)
this._mentionQueue.delete(`${roomId}:${agent.name}`)
await agent.interrupt(roomId)
}
/**
* Disconnect all agents in a room.
*/
@@ -576,7 +766,12 @@ export class AgentClients {
resetRoomContext(roomId: string): void {
this._mentionQueue.delete(roomId)
this._processingRooms.delete(roomId)
for (const key of Array.from(this._mentionQueue.keys())) {
if (key.startsWith(`${roomId}:`)) this._mentionQueue.delete(key)
}
for (const key of Array.from(this._processingRooms)) {
if (key.startsWith(`${roomId}:`)) this._processingRooms.delete(key)
}
if (this._contextEngine) {
try { this._contextEngine.invalidateRoom(roomId) } catch { /* ignore */ }
}
@@ -593,16 +788,6 @@ export class AgentClients {
logger.info('[AgentClients] All agents disconnected')
}
/**
* Set gateway manager for all existing and future agents.
*/
setGatewayManager(manager: GatewayManager): void {
this._gatewayManager = manager
this.rooms.forEach((room) => {
room.forEach((client) => client.setGatewayManager(manager))
})
}
/**
* Set context engine for all existing and future agents.
*/
@@ -628,13 +813,14 @@ export class AgentClients {
* Server-side: parse @mentions and forward to matching agents directly.
* If the room is already processing (compressing/replying), queue the mention.
*/
async processMentions(roomId: string, msg: { content: string; senderName: string; senderId: string; timestamp: number }): Promise<void> {
if (!this._gatewayManager) return
const content = msg.content.toLowerCase()
async processMentions(roomId: string, msg: MentionMessage): Promise<void> {
const agents = this.getAgents(roomId)
const senderName = msg.senderName.toLowerCase()
const mentioned = agents.filter(a => content.includes(`@${a.name.toLowerCase()}`))
const mentioned = agents.filter(a => (
a.name.toLowerCase() !== senderName &&
isAgentMentioned(msg.content, a.name)
))
if (mentioned.length === 0) return
logger.debug(`[AgentClients] ${mentioned.map(a => a.name).join(', ')} mentioned by ${msg.senderName}`)
@@ -652,7 +838,7 @@ export class AgentClients {
private async _processAgentMention(
roomId: string,
agent: AgentClient,
msg: { content: string; senderName: string; senderId: string; timestamp: number },
msg: MentionMessage,
): Promise<void> {
const agentKey = `${roomId}:${agent.name}`
if (this._processingRooms.has(agentKey)) {
@@ -693,9 +879,16 @@ export class AgentClients {
// Process the last queued mention only (most recent, discards stale intermediate ones)
const last = queue[queue.length - 1]
this._processingRooms.add(agentKey)
this._processAgentMention(roomId, last.agent, last.msg).catch((err) => {
logger.error(`[AgentClients] error processing queued mention: ${err.message}`)
})
await this._processAgentMention(roomId, last.agent, last.msg)
}
}
function nextMentionDepth(msg: MentionMessage): number {
return Math.max(0, msg.mentionDepth || 0) + 1
}
function isAgentMentioned(content: string, agentName: string): boolean {
const escaped = agentName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const pattern = new RegExp(`@${escaped}(?=$|\\s|[.,!?;:,。!?;:])`, 'i')
return pattern.test(content)
}
@@ -6,6 +6,8 @@ import { getDb } from '../../../db'
import { AgentClients } from './agent-clients'
import { ContextEngine } from '../context-engine/compressor'
import { SessionDeleter } from '../session-deleter'
import { countTokens, SUMMARY_PREFIX } from '../../../lib/context-compressor'
import { AgentBridgeClient } from '../agent-bridge'
// ─── Types ────────────────────────────────────────────────────
@@ -16,6 +18,43 @@ interface ChatMessage {
senderName: string
content: string
timestamp: number
role?: string
tool_call_id?: string | null
tool_calls?: any[] | null
tool_name?: string | null
finish_reason?: string | null
reasoning?: string | null
reasoning_details?: string | null
reasoning_content?: string | null
mentionDepth?: number
}
function contentToStorageString(content: unknown): string {
if (typeof content === 'string') return content
return JSON.stringify(content ?? '')
}
function contentToText(content: unknown): string {
if (typeof content === 'string') {
const trimmed = content.trim()
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
try {
return contentToText(JSON.parse(trimmed))
} catch {
return content
}
}
return content
}
if (Array.isArray(content)) {
return content.map((block: any) => {
if (block?.type === 'text') return block.text || ''
if (block?.type === 'image') return `[Image: ${block.name || block.path || ''}]`
if (block?.type === 'file') return `[File: ${block.name || block.path || ''}]`
return ''
}).filter(Boolean).join('\n')
}
return content == null ? '' : String(content)
}
interface RoomAgent {
@@ -64,6 +103,64 @@ export interface PendingSessionDeleteDrainResult {
failed: Array<{ sessionId: string; error: string }>
}
function parseJsonArray(value: unknown): any[] | null {
if (value == null || value === '') return null
if (Array.isArray(value)) return value
if (typeof value !== 'string') return null
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : null
} catch {
return null
}
}
function normalizeMessageRole(role: unknown): string {
const value = String(role || '').trim()
return ['user', 'assistant', 'tool', 'command'].includes(value) ? value : 'user'
}
function normalizeMentionDepth(depth: unknown): number {
const value = Number(depth)
return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0
}
function groupRunOrder(id: string): { baseId: string; phase: number } {
const value = String(id || '')
const partMatch = value.match(/^(.*)_part_(\d+)(?:_(toolcall|toolresult)_.+)?$/)
if (partMatch) {
const part = Number(partMatch[2] || 0)
const kind = partMatch[3] || 'assistant'
const offset = kind === 'toolcall' ? 1 : kind === 'toolresult' ? 2 : 0
return { baseId: partMatch[1], phase: part * 3 + offset }
}
const toolIdx = value.indexOf('_toolcall_')
if (toolIdx >= 0) return { baseId: value.slice(0, toolIdx), phase: 0 }
const resultIdx = value.indexOf('_toolresult_')
if (resultIdx >= 0) return { baseId: value.slice(0, resultIdx), phase: 1 }
return { baseId: value, phase: 2 }
}
function sortGroupMessages<T extends { id: string; timestamp: number }>(messages: T[]): T[] {
const baseMinTimestamp = new Map<string, number>()
for (const msg of messages) {
const { baseId } = groupRunOrder(msg.id)
const existing = baseMinTimestamp.get(baseId)
if (existing == null || msg.timestamp < existing) baseMinTimestamp.set(baseId, msg.timestamp)
}
return [...messages].sort((a, b) => {
const ao = groupRunOrder(a.id)
const bo = groupRunOrder(b.id)
const at = baseMinTimestamp.get(ao.baseId) ?? a.timestamp
const bt = baseMinTimestamp.get(bo.baseId) ?? b.timestamp
if (at !== bt) return at - bt
if (ao.baseId !== bo.baseId) return ao.baseId.localeCompare(bo.baseId)
if (ao.phase !== bo.phase) return ao.phase - bo.phase
if (a.timestamp !== b.timestamp) return a.timestamp - b.timestamp
return a.id.localeCompare(b.id)
})
}
class ChatStorage {
private db() { return getDb() }
@@ -175,16 +272,16 @@ class ChatStorage {
// ─── Rooms ────────────────────────────────────────────────
getRoom(roomId: string): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number } | undefined {
return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens FROM gc_rooms WHERE id = ?').get(roomId) as any
getRoom(roomId: string): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number; sessionSeed: string } | undefined {
return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms WHERE id = ?').get(roomId) as any
}
getRoomByInviteCode(code: string): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number } | undefined {
return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens FROM gc_rooms WHERE inviteCode = ?').get(code) as any
getRoomByInviteCode(code: string): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number; sessionSeed: string } | undefined {
return this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms WHERE inviteCode = ?').get(code) as any
}
getAllRooms(): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number }[] {
return (this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens FROM gc_rooms ORDER BY id').all() || []) as any[]
getAllRooms(): { id: string; name: string; inviteCode: string | null; triggerTokens: number; maxHistoryTokens: number; tailMessageCount: number; totalTokens: number; sessionSeed: string }[] {
return (this.db()?.prepare('SELECT id, name, inviteCode, triggerTokens, maxHistoryTokens, tailMessageCount, totalTokens, sessionSeed FROM gc_rooms ORDER BY id').all() || []) as any[]
}
saveRoom(id: string, name: string, inviteCode?: string, config?: { triggerTokens?: number; maxHistoryTokens?: number; tailMessageCount?: number }): void {
@@ -212,25 +309,132 @@ class ChatStorage {
this.db()?.prepare('UPDATE gc_rooms SET totalTokens = ? WHERE id = ?').run(tokens, roomId)
}
rotateRoomSessionSeed(roomId: string): string {
const seed = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`
this.db()?.prepare('UPDATE gc_rooms SET sessionSeed = ? WHERE id = ?').run(seed, roomId)
return seed
}
estimateTokens(text: string): number {
const cjk = (text.match(/[\u2e80-\u9fff\uac00-\ud7af\u3000-\u303f\uff00-\uffef]/g) || []).length
const other = text.length - cjk
return Math.ceil(cjk * 1.5 + other / 4)
}
private contentToUsageText(content: unknown): string {
if (typeof content === 'string') return content
if (!content) return ''
if (Array.isArray(content)) {
return content.map((block: any) => {
if (typeof block?.text === 'string') return block.text
if (typeof block?.type === 'string') return `[${block.type}]`
return String(block || '')
}).join('\n')
}
return String(content)
}
private estimateUsageTokensFromMessages(messages: ChatMessage[]): { inputTokens: number; outputTokens: number } {
const inputTokens = messages
.filter(m => (m.role || 'user') === 'user')
.reduce((sum, m) => sum + countTokens(this.contentToUsageText(m.content)), 0)
const outputTokens = messages
.filter(m => m.role === 'assistant' || m.role === 'tool')
.reduce((sum, m) => sum + countTokens(this.contentToUsageText(m.content)) + countTokens(String(m.tool_calls || '')), 0)
return { inputTokens, outputTokens }
}
private estimateRoomTotalTokens(roomId: string, messages: ChatMessage[]): number {
const snapshot = this.getContextSnapshot(roomId)
if (snapshot && messages.length) {
const snapshotIdx = messages.findIndex(m => m.id === snapshot.lastMessageId)
const newMessages = snapshotIdx >= 0
? messages.slice(snapshotIdx + 1)
: messages.filter(m => m.timestamp > snapshot.lastMessageTimestamp)
const newUsage = this.estimateUsageTokensFromMessages(newMessages)
return countTokens(SUMMARY_PREFIX + snapshot.summary) + newUsage.inputTokens + newUsage.outputTokens
}
const usage = this.estimateUsageTokensFromMessages(messages)
return usage.inputTokens + usage.outputTokens
}
// ─── Messages ─────────────────────────────────────────────
getMessages(roomId: string, limit = 500): ChatMessage[] {
const rows = (this.db()?.prepare(
'SELECT id, roomId, senderId, senderName, content, timestamp FROM gc_messages WHERE roomId = ? ORDER BY timestamp DESC LIMIT ?'
'SELECT id, roomId, senderId, senderName, content, timestamp, role, tool_call_id, tool_calls, tool_name, finish_reason, reasoning, reasoning_details, reasoning_content FROM gc_messages WHERE roomId = ? ORDER BY timestamp DESC LIMIT ?'
).all(roomId, limit) || []) as any[]
return rows.reverse()
return sortGroupMessages(rows.map(row => ({
...row,
tool_calls: parseJsonArray(row.tool_calls),
})))
}
getMessage(messageId: string): ChatMessage | null {
const row = this.db()?.prepare(
'SELECT id, roomId, senderId, senderName, content, timestamp, role, tool_call_id, tool_calls, tool_name, finish_reason, reasoning, reasoning_details, reasoning_content FROM gc_messages WHERE id = ?'
).get(messageId) as any
if (!row) return null
return {
...row,
tool_calls: parseJsonArray(row.tool_calls),
}
}
addMessage(msg: ChatMessage): void {
this.upsertMessage(msg)
}
upsertMessage(msg: ChatMessage): void {
const toolCallsJson = msg.tool_calls ? JSON.stringify(msg.tool_calls) : null
this.db()?.prepare(
'INSERT INTO gc_messages (id, roomId, senderId, senderName, content, timestamp) VALUES (?, ?, ?, ?, ?, ?)'
).run(msg.id, msg.roomId, msg.senderId, msg.senderName, msg.content, msg.timestamp)
`INSERT INTO gc_messages (id, roomId, senderId, senderName, content, timestamp, role, tool_call_id, tool_calls, tool_name, finish_reason, reasoning, reasoning_details, reasoning_content)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ ` ON CONFLICT(id) DO UPDATE SET
roomId = excluded.roomId,
senderId = excluded.senderId,
senderName = excluded.senderName,
content = excluded.content,
timestamp = excluded.timestamp,
role = excluded.role,
tool_call_id = excluded.tool_call_id,
tool_calls = excluded.tool_calls,
tool_name = excluded.tool_name,
finish_reason = excluded.finish_reason,
reasoning = excluded.reasoning,
reasoning_details = excluded.reasoning_details,
reasoning_content = excluded.reasoning_content`
).run(
msg.id, msg.roomId, msg.senderId, msg.senderName, msg.content, msg.timestamp,
msg.role || 'user',
msg.tool_call_id ?? null,
toolCallsJson,
msg.tool_name ?? null,
msg.finish_reason ?? null,
msg.reasoning ?? null,
msg.reasoning_details ?? null,
msg.reasoning_content ?? null,
)
}
saveMessageAndRefreshRoom(msg: ChatMessage, options: { preserveExistingTimestamp?: boolean } = {}): { message: ChatMessage; totalTokens: number } {
const db = this.db()
if (!db) return { message: msg, totalTokens: 0 }
db.exec('BEGIN IMMEDIATE')
try {
const existing = this.getMessage(msg.id)
const message = existing && options.preserveExistingTimestamp ? { ...msg, timestamp: existing.timestamp } : msg
this.upsertMessage(message)
this.pruneMessages(msg.roomId)
const messages = this.getMessages(msg.roomId)
const totalTokens = this.estimateRoomTotalTokens(msg.roomId, messages)
this.updateRoomTotalTokens(msg.roomId, totalTokens)
db.exec('COMMIT')
return { message, totalTokens }
} catch (err) {
try { db.exec('ROLLBACK') } catch { /* ignore */ }
throw err
}
}
clearRoomContext(roomId: string): void {
@@ -238,7 +442,7 @@ class ChatStorage {
if (!db) return
db.prepare('DELETE FROM gc_messages WHERE roomId = ?').run(roomId)
db.prepare('DELETE FROM gc_context_snapshots WHERE roomId = ?').run(roomId)
db.prepare('UPDATE gc_rooms SET totalTokens = 0 WHERE id = ?').run(roomId)
db.prepare('UPDATE gc_rooms SET totalTokens = 0, sessionSeed = ? WHERE id = ?').run(`${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`, roomId)
}
pruneMessages(roomId: string, keep = 500): void {
@@ -419,13 +623,6 @@ export class GroupChatServer {
/** roomId -> (agentName -> { agentName, status }) */
private contextStatusState = new Map<string, Map<string, { agentName: string; status: string }>>()
setGatewayManager(manager: any): void {
this.agentClients.setGatewayManager(manager)
if (this._contextEngine && manager) {
this._contextEngine.setUpstream(manager.getUpstream(''), manager.getApiKey(''))
}
}
constructor(httpServers: HttpServer | HttpServer[]) {
this.storage = new ChatStorage()
this.storage.init()
@@ -569,10 +766,18 @@ export class GroupChatServer {
logger.debug(`[GroupChat] Connected: ${userName} (socket=${socket.id}, user=${userId})`)
socket.on('join', (data: { roomId?: string; name?: string }, ack?: (response?: unknown) => void) => this.handleJoin(socket, data, ack))
socket.on('message', (data: { roomId?: string; content: string }, ack?: (response?: unknown) => void) => this.handleMessage(socket, data, ack))
socket.on('message', (data: Partial<ChatMessage> & { roomId?: string; content: string | Array<Record<string, unknown>>; id?: string; mentionDepth?: number }, ack?: (response?: unknown) => void) => this.handleMessage(socket, data, ack))
socket.on('message_stream_start', (data: { roomId?: string; id?: string; senderId?: string; senderName?: string; timestamp?: number }) => this.handleMessageStreamStart(socket, data))
socket.on('message_stream_delta', (data: { roomId?: string; id?: string; delta?: string }) => this.handleMessageStreamDelta(socket, data))
socket.on('message_reasoning_delta', (data: { roomId?: string; id?: string; delta?: string }) => this.handleMessageReasoningDelta(socket, data))
socket.on('message_stream_end', (data: { roomId?: string; id?: string }) => this.handleMessageStreamEnd(socket, data))
socket.on('typing', (data: { roomId?: string }) => this.handleTyping(socket, data))
socket.on('stop_typing', (data: { roomId?: string }) => this.handleStopTyping(socket, data))
socket.on('context_status', (data: { roomId?: string; agentName?: string; status?: string }) => this.handleContextStatus(socket, data))
socket.on('interrupt_agent', (data: { roomId?: string; agentName?: string }, ack?: (response?: unknown) => void) => this.handleInterruptAgent(socket, data, ack))
socket.on('approval.requested', (data: { roomId?: string; agentName?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean }) => this.handleApprovalRequested(socket, data))
socket.on('approval.resolved', (data: { roomId?: string; agentName?: string; approval_id?: string; choice?: string }) => this.handleApprovalResolved(socket, data))
socket.on('approval.respond', (data: { roomId?: string; approval_id?: string; choice?: string }, ack?: (response?: unknown) => void) => this.handleApprovalRespond(socket, data, ack))
socket.on('disconnect', () => this.handleDisconnect(socket))
}
@@ -581,14 +786,18 @@ export class GroupChatServer {
private handleJoin(socket: Socket, data: { roomId?: string; name?: string; description?: string }, ack?: (res: any) => void): void {
const socketId = socket.id
const userId = this.socketUserMap.get(socketId) || socketId
const userInfo = this.userInfoMap.get(userId) || { name: `User-${userId.slice(0, 6)}`, description: '' }
const userName = data.name || userInfo.name
const description = data.description || userInfo.description
const roomId = data.roomId || 'general'
const existingMember = this.storage.getMemberByUserId(roomId, userId)
const userInfo = this.userInfoMap.get(userId) || {
name: existingMember?.name || `User-${userId.slice(0, 6)}`,
description: existingMember?.description || '',
}
const userName = data.name || existingMember?.name || userInfo.name
const description = data.description || existingMember?.description || userInfo.description
// Update stored user info
this.userInfoMap.set(userId, { name: userName, description })
const roomId = data.roomId || 'general'
let room = this.rooms.get(roomId)
if (!room) {
room = new ChatRoom(roomId)
@@ -628,7 +837,7 @@ export class GroupChatServer {
logger.debug(`[GroupChat] ${userName} (user=${userId}) joined room: ${roomId}`)
}
private handleMessage(socket: Socket, data: { roomId?: string; content: string }, ack?: (res: any) => void): void {
private handleMessage(socket: Socket, data: Partial<ChatMessage> & { roomId?: string; content: string | Array<Record<string, unknown>>; id?: string; mentionDepth?: number }, ack?: (res: any) => void): void {
const socketId = socket.id
const roomId = data.roomId || 'general'
const room = this.rooms.get(roomId)
@@ -643,36 +852,104 @@ export class GroupChatServer {
const userName = member?.name || `User-${socketId.slice(0, 6)}`
const msg: ChatMessage = {
id: this.generateId(),
id: this.normalizeClientMessageId(data.id) || this.generateId(),
roomId,
senderId: userId,
senderName: userName,
content: data.content,
timestamp: Date.now(),
content: contentToStorageString(data.content),
timestamp: this.normalizeMessageTimestamp(data.timestamp, data.role),
role: normalizeMessageRole(data.role),
tool_call_id: data.tool_call_id ?? null,
tool_calls: Array.isArray(data.tool_calls) ? data.tool_calls : null,
tool_name: data.tool_name ?? null,
finish_reason: data.finish_reason ?? null,
reasoning: data.reasoning ?? null,
reasoning_details: data.reasoning_details ?? null,
reasoning_content: data.reasoning_content ?? null,
}
this.storage.addMessage(msg)
this.storage.pruneMessages(roomId)
const saved = this.storage.saveMessageAndRefreshRoom(msg)
const savedMsg = saved.message
const totalTokens = saved.totalTokens
// Recalculate total tokens for the room
const messages = this.storage.getMessages(roomId)
const totalTokens = this.storage.estimateTokens(messages.map(m => m.content + m.senderName).join(''))
this.storage.updateRoomTotalTokens(roomId, totalTokens)
this.nsp.to(roomId).emit('message', msg)
this.nsp.to(roomId).emit('message', savedMsg)
this.nsp.to(roomId).emit('room_updated', { roomId, totalTokens })
ack?.({ id: msg.id })
ack?.({ id: savedMsg.id })
// Server-side @mention routing — parse mentions and invoke agents directly
const mentionDepth = normalizeMentionDepth(data.mentionDepth)
const shouldRouteMentions =
savedMsg.role === 'user' ||
(savedMsg.role === 'assistant' && mentionDepth < 2)
if (shouldRouteMentions) {
// Server-side @mention routing — parse user mentions and invoke agents directly.
this.agentClients.processMentions(roomId, {
content: msg.content,
senderName: msg.senderName,
senderId: msg.senderId,
timestamp: msg.timestamp,
content: contentToText(savedMsg.content),
input: Array.isArray(data.content) ? data.content : undefined,
senderName: savedMsg.senderName,
senderId: savedMsg.senderId,
timestamp: savedMsg.timestamp,
mentionDepth,
}).catch((err) => {
logger.error(`[GroupChat] processMentions error: ${err.message}`)
})
}
}
private handleMessageStreamStart(socket: Socket, data: { roomId?: string; id?: string; senderId?: string; senderName?: string; timestamp?: number }): void {
const roomId = data.roomId || 'general'
const room = this.rooms.get(roomId)
if (!room || !room.hasOnlineMember(socket.id)) return
const id = this.normalizeClientMessageId(data.id)
if (!id) return
const member = room.getOnlineMemberBySocketId(socket.id)
this.nsp.to(roomId).emit('message_stream_start', {
id,
roomId,
senderId: data.senderId || member?.userId || socket.id,
senderName: data.senderName || member?.name || `User-${socket.id.slice(0, 6)}`,
content: '',
timestamp: data.timestamp || Date.now(),
role: 'assistant',
finish_reason: 'streaming',
})
}
private handleMessageStreamDelta(socket: Socket, data: { roomId?: string; id?: string; delta?: string }): void {
const roomId = data.roomId || 'general'
const room = this.rooms.get(roomId)
if (!room || !room.hasOnlineMember(socket.id)) return
const id = this.normalizeClientMessageId(data.id)
if (!id || !data.delta) return
this.nsp.to(roomId).emit('message_stream_delta', {
roomId,
id,
delta: String(data.delta),
})
}
private handleMessageReasoningDelta(socket: Socket, data: { roomId?: string; id?: string; delta?: string }): void {
const roomId = data.roomId || 'general'
const room = this.rooms.get(roomId)
if (!room || !room.hasOnlineMember(socket.id)) return
const id = this.normalizeClientMessageId(data.id)
if (!id || !data.delta) return
this.nsp.to(roomId).emit('message_reasoning_delta', {
roomId,
id,
delta: String(data.delta),
})
}
private handleMessageStreamEnd(socket: Socket, data: { roomId?: string; id?: string }): void {
const roomId = data.roomId || 'general'
const room = this.rooms.get(roomId)
if (!room || !room.hasOnlineMember(socket.id)) return
const id = this.normalizeClientMessageId(data.id)
if (!id) return
this.nsp.to(roomId).emit('message_stream_end', { roomId, id })
}
private handleTyping(socket: Socket, data: { roomId?: string }): void {
const roomId = data.roomId || 'general'
@@ -749,6 +1026,75 @@ export class GroupChatServer {
})
}
private async handleInterruptAgent(socket: Socket, data: { roomId?: string; agentName?: string }, ack?: (response?: unknown) => void): Promise<void> {
const roomId = data.roomId
const agentName = data.agentName
if (!roomId || !agentName) {
ack?.({ error: 'roomId and agentName are required' })
return
}
const room = this.rooms.get(roomId)
if (!room?.hasOnlineMember(socket.id)) {
ack?.({ error: 'Not in room' })
return
}
try {
await this.agentClients.interruptAgent(roomId, agentName)
this.nsp.to(roomId).emit('context_status', { roomId, agentName, status: 'ready' })
ack?.({ ok: true })
} catch (err: any) {
logger.warn(`[GroupChat] failed to interrupt agent ${agentName} in room ${roomId}: ${err.message}`)
ack?.({ error: err.message || 'interrupt failed' })
}
}
private handleApprovalRequested(socket: Socket, data: { roomId?: string; agentName?: string; approval_id?: string; command?: string; description?: string; choices?: string[]; allow_permanent?: boolean }): void {
const roomId = data.roomId
if (!roomId || !data.approval_id) return
this.nsp.to(roomId).emit('approval.requested', {
event: 'approval.requested',
roomId,
agentName: data.agentName || '',
approval_id: data.approval_id,
command: data.command || '',
description: data.description || '',
choices: Array.isArray(data.choices) ? data.choices : ['once', 'session', 'deny'],
allow_permanent: Boolean(data.allow_permanent),
})
}
private handleApprovalResolved(socket: Socket, data: { roomId?: string; agentName?: string; approval_id?: string; choice?: string }): void {
const roomId = data.roomId
if (!roomId || !data.approval_id) return
this.nsp.to(roomId).emit('approval.resolved', {
event: 'approval.resolved',
roomId,
agentName: data.agentName || '',
approval_id: data.approval_id,
choice: data.choice || '',
})
}
private async handleApprovalRespond(socket: Socket, data: { roomId?: string; approval_id?: string; choice?: string }, ack?: (response?: unknown) => void): Promise<void> {
const roomId = data.roomId
if (!roomId || !data.approval_id) {
ack?.({ error: 'roomId and approval_id are required' })
return
}
const room = this.rooms.get(roomId)
if (!room?.hasOnlineMember(socket.id)) {
ack?.({ error: 'Not in room' })
return
}
try {
const result = await new AgentBridgeClient().approvalRespond(data.approval_id, data.choice || 'deny')
ack?.({ ok: true, resolved: Boolean((result as any)?.resolved) })
} catch (err: any) {
logger.warn(`[GroupChat] failed to respond approval ${data.approval_id}: ${err.message}`)
ack?.({ error: err.message || 'approval response failed' })
}
}
private handleDisconnect(socket: Socket): void {
const socketId = socket.id
const userId = this.socketUserMap.get(socketId)
@@ -804,4 +1150,19 @@ export class GroupChatServer {
private generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
}
private normalizeClientMessageId(id?: string): string | null {
const cleaned = String(id || '').trim()
if (!cleaned || cleaned.length > 160) return null
return /^[a-zA-Z0-9_-]+$/.test(cleaned) ? cleaned : null
}
private normalizeMessageTimestamp(timestamp?: unknown, role?: unknown): number {
const normalizedRole = normalizeMessageRole(role)
if (normalizedRole !== 'user') {
const value = Number(timestamp)
if (Number.isFinite(value) && value > 0) return value
}
return Date.now()
}
}
+215 -14
View File
@@ -1,12 +1,20 @@
import { execFile, spawn } from 'child_process'
import { existsSync } from 'fs'
import { existsSync, readFileSync, unlinkSync } from 'fs'
import { join } from 'path'
import { promisify } from 'util'
import { logger } from '../logger'
import { stripLegacyApiServerGatewayConfig, updateConfigYaml } from '../config-helpers'
import { getActiveProfileDir, getProfileDir } from './hermes-profile'
import { startGatewayRunManaged } from './gateway-runner'
import { isGatewayRunningForProfile } from './gateway-autostart'
const execFileAsync = promisify(execFile)
const execOpts = { windowsHide: true }
const isDocker = existsSync('/.dockerenv')
const isTermux = !!process.env.TERMUX_VERSION ||
(process.env.PREFIX || '').includes('/com.termux/') ||
existsSync('/data/data/com.termux/files/usr')
/**
* Hermes CLI
@@ -18,6 +26,156 @@ function resolveHermesBin(): string {
const HERMES_BIN = resolveHermesBin()
async function waitForGatewayRunning(profileDir: string, timeoutMs = 15000): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (await isGatewayRunningForProfile(HERMES_BIN, profileDir)) return true
await new Promise(resolve => setTimeout(resolve, 500))
}
return false
}
async function stopGatewayForActiveProfile(): Promise<void> {
try {
await execFileAsync(HERMES_BIN, ['gateway', 'stop'], {
timeout: 30000,
...activeGatewayExecOpts(),
})
} catch (err) {
logger.warn(err, 'hermes gateway stop before restart failed; continuing with run --replace')
}
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (err: any) {
return err?.code === 'EPERM'
}
}
function readJsonPid(path: string): number | null {
if (!existsSync(path)) return null
try {
const data = JSON.parse(readFileSync(path, 'utf-8'))
const pid = typeof data?.pid === 'number' ? data.pid : parseInt(String(data?.pid || ''), 10)
return Number.isFinite(pid) && pid > 0 ? pid : null
} catch {
return null
}
}
function readGatewayLockPid(profileDir: string): number | null {
return readJsonPid(join(profileDir, 'gateway.lock'))
}
function readGatewayStatePid(profileDir: string): number | null {
const pid = readJsonPid(join(profileDir, 'gateway.pid'))
if (pid) return pid
const statePath = join(profileDir, 'gateway_state.json')
if (!existsSync(statePath)) return null
try {
const data = JSON.parse(readFileSync(statePath, 'utf-8'))
const state = data?.gateway_state
const statePid = typeof data?.pid === 'number' ? data.pid : parseInt(String(data?.pid || ''), 10)
return statePid && Number.isFinite(statePid) && statePid > 0 && (state === 'running' || state === 'starting')
? statePid
: null
} catch {
return null
}
}
async function killWindowsPid(pid: number): Promise<void> {
if (!pid || process.platform !== 'win32') return
try {
await execFileAsync('taskkill', ['/PID', String(pid), '/T', '/F'], {
timeout: 5000,
windowsHide: true,
})
} catch (err) {
logger.warn(err, 'Failed to taskkill gateway PID %d; falling back to process.kill', pid)
try { process.kill(pid) } catch {}
}
}
function cleanupStaleGatewayLock(profileDir: string, allowMalformedDelete = false): boolean {
const lockPath = join(profileDir, 'gateway.lock')
if (!existsSync(lockPath)) return true
try {
const lockData = JSON.parse(readFileSync(lockPath, 'utf-8'))
const pid = Number(lockData?.pid)
if (Number.isFinite(pid) && pid > 0 && isProcessAlive(pid)) return false
unlinkSync(lockPath)
return true
} catch {
if (!allowMalformedDelete) return false
try {
unlinkSync(lockPath)
return true
} catch {
return false
}
}
}
async function waitForGatewayLockReleased(profileDir: string, timeoutMs = 15000, allowMalformedDelete = false): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (cleanupStaleGatewayLock(profileDir, allowMalformedDelete)) return true
await sleep(500)
}
return cleanupStaleGatewayLock(profileDir, allowMalformedDelete)
}
async function forceReleaseWindowsGatewayLock(profileDir: string): Promise<void> {
if (process.platform !== 'win32') return
const pids = new Set<number>()
const lockPid = readGatewayLockPid(profileDir)
const statePid = readGatewayStatePid(profileDir)
if (lockPid) pids.add(lockPid)
if (statePid) pids.add(statePid)
for (const pid of pids) {
if (isProcessAlive(pid)) {
logger.warn('Gateway lock is still held by PID %d; force killing Windows process tree', pid)
await killWindowsPid(pid)
}
}
}
async function waitForGatewayLockReleasedAfterStop(profileDir: string, timeoutMs = 15000): Promise<boolean> {
if (await waitForGatewayLockReleased(profileDir, timeoutMs)) return true
await forceReleaseWindowsGatewayLock(profileDir)
return waitForGatewayLockReleased(profileDir, 5000, true)
}
function activeGatewayExecOpts() {
return {
...execOpts,
env: {
...process.env,
HERMES_HOME: getActiveProfileDir(),
},
}
}
async function clearLegacyApiServerGatewayConfig(): Promise<void> {
try {
await updateConfigYaml((config) => {
const result = stripLegacyApiServerGatewayConfig(config)
return { data: result.config, result: undefined, write: result.changed }
})
} catch (err) {
logger.warn(err, 'Failed to clear legacy api_server gateway config before restart')
}
}
export interface HermesSession {
id: string
source: string
@@ -210,6 +368,26 @@ export async function deleteSession(id: string): Promise<boolean> {
}
}
/**
* Delete a session from a specific Hermes profile.
*/
export async function deleteSessionForProfile(id: string, profile: string): Promise<boolean> {
try {
await execFileAsync(HERMES_BIN, ['sessions', 'delete', id, '--yes'], {
timeout: 10000,
...execOpts,
env: {
...process.env,
HERMES_HOME: getProfileDir(profile),
},
})
return true
} catch (err: any) {
logger.error({ err, sessionId: id, profile }, 'Hermes CLI: profile session delete failed')
return false
}
}
/**
* Rename a session title via Hermes CLI
*/
@@ -255,7 +433,7 @@ export async function startGateway(): Promise<string> {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'start'], {
timeout: 30000,
...execOpts,
...activeGatewayExecOpts(),
})
return stdout || stderr
}
@@ -269,22 +447,49 @@ export async function startGatewayBackground(): Promise<number | null> {
detached: true,
stdio: 'ignore',
windowsHide: true,
env: {
...process.env,
HERMES_HOME: getActiveProfileDir(),
},
})
child.unref()
return child.pid ?? null
}
/**
* Restart Hermes gateway (stop then start)
* Restart Hermes gateway through Hermes CLI, falling back to detached
* `gateway run` when the environment does not support `gateway restart`.
*/
export async function restartGateway(): Promise<string> {
try {
await stopGateway()
} catch (err) {
// Ignore stop errors, gateway might not be running
await clearLegacyApiServerGatewayConfig()
const profileDir = getActiveProfileDir()
if (isDocker || isTermux || process.platform === 'win32') {
await stopGatewayForActiveProfile()
const lockReleased = await waitForGatewayLockReleasedAfterStop(profileDir)
if (!lockReleased) throw new Error('Gateway stopped but runtime lock is still held by another process')
const result = startGatewayRunManaged(HERMES_BIN, { profileDir })
const ready = await waitForGatewayRunning(profileDir)
if (!ready) throw new Error(`Gateway run replace triggered but gateway did not report running within timeout${result.pid ? ` (PID: ${result.pid})` : ''}`)
return result.pid ? `Gateway run replaced (PID: ${result.pid})` : 'Gateway run replaced'
}
try {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'restart'], {
timeout: 30000,
...activeGatewayExecOpts(),
})
const ready = await waitForGatewayRunning(profileDir)
if (!ready) throw new Error('Hermes gateway restart completed but gateway did not report running within timeout')
return stdout || stderr
} catch (err: any) {
logger.warn(err, 'hermes gateway restart failed; falling back to gateway run')
await stopGatewayForActiveProfile()
const lockReleased = await waitForGatewayLockReleasedAfterStop(profileDir)
if (!lockReleased) throw new Error('Gateway restart failed and runtime lock is still held by another process')
const result = startGatewayRunManaged(HERMES_BIN, { profileDir })
const ready = await waitForGatewayRunning(profileDir)
if (!ready) throw new Error(`Gateway run fallback triggered but gateway did not report running within timeout${result.pid ? ` (PID: ${result.pid})` : ''}`)
return result.pid ? `Gateway run started (PID: ${result.pid})` : 'Gateway run started'
}
const result = await startGateway()
return result
}
/**
@@ -293,7 +498,7 @@ export async function restartGateway(): Promise<string> {
export async function stopGateway(): Promise<string> {
const { stdout, stderr } = await execFileAsync(HERMES_BIN, ['gateway', 'stop'], {
timeout: 30000,
...execOpts,
...activeGatewayExecOpts(),
})
return stdout || stderr
}
@@ -363,7 +568,6 @@ export interface HermesProfile {
name: string
active: boolean
model: string
gateway: string
alias: string
}
@@ -372,7 +576,6 @@ export interface HermesProfileDetail {
path: string
model: string
provider: string
gateway: string
skills: number
hasEnv: boolean
hasSoulMd: boolean
@@ -403,7 +606,6 @@ export async function listProfiles(): Promise<HermesProfile[]> {
name: match[2],
active: !!match[1],
model: match[3],
gateway: match[4],
alias: match[5].trim() === '—' ? '' : match[5].trim(),
})
}
@@ -443,7 +645,6 @@ export async function getProfile(name: string): Promise<HermesProfileDetail> {
path: result.path || '',
model,
provider: providerMatch ? providerMatch[1] : '',
gateway: result.gateway || '',
skills: parseInt(result.skills || '0', 10),
hasEnv: result['.env'] === 'exists',
hasSoulMd: result['soul.md'] === 'exists',
@@ -7,7 +7,7 @@
* - 用户自定义: HERMES_HOME
*/
import { basename, dirname, resolve, join } from 'path'
import { basename, dirname, isAbsolute, relative, resolve, join } from 'path'
import { homedir } from 'os'
/**
@@ -62,3 +62,20 @@ export function getHermesBin(customBin?: string): string {
if (process.env.HERMES_BIN?.trim()) return process.env.HERMES_BIN.trim()
return 'hermes'
}
function comparablePath(path: string): string {
return process.platform === 'win32' ? path.toLowerCase() : path
}
export function isPathWithin(targetPath: string, basePath: string): boolean {
const base = resolve(basePath)
const target = resolve(targetPath)
const rel = relative(comparablePath(base), comparablePath(target))
return rel === '' || (!!rel && !rel.startsWith('..') && !isAbsolute(rel))
}
export function relativePathFromBase(targetPath: string, basePath: string): string | null {
if (!isPathWithin(targetPath, basePath)) return null
const rel = relative(resolve(basePath), resolve(targetPath))
return rel.replace(/\\/g, '/')
}
@@ -1,5 +1,5 @@
import { join } from 'path'
import { readFileSync, existsSync } from 'fs'
import { readFileSync, existsSync, readdirSync } from 'fs'
import { detectHermesRootHome } from './hermes-path'
export function getHermesBaseDir(): string {
@@ -69,3 +69,21 @@ export function getProfileDir(name: string): string {
const dir = join(hermesBase, 'profiles', name)
return existsSync(dir) ? dir : hermesBase
}
export function listProfileNamesFromDisk(): string[] {
const hermesBase = getHermesBaseDir()
const names = new Set<string>(['default'])
const profilesDir = join(hermesBase, 'profiles')
try {
for (const entry of readdirSync(profilesDir, { withFileTypes: true })) {
if (entry.isDirectory() && entry.name.trim()) {
names.add(entry.name)
}
}
} catch {}
return [...names].sort((a, b) => {
if (a === 'default') return -1
if (b === 'default') return 1
return a.localeCompare(b)
})
}
@@ -10,6 +10,12 @@ const HERMES_BASE = detectHermesHome()
const MODELS_DEV_CACHE = resolve(HERMES_BASE, 'models_dev_cache.json')
const DEFAULT_CONTEXT_LENGTH = 200_000
export interface ModelContextLengthOptions {
profile?: string
model?: string | null
provider?: string | null
}
interface ModelLimit {
context?: number
output?: number
@@ -351,15 +357,19 @@ function lookupContextFromDatabase(modelName: string, provider: string | null):
}
}
export function getModelContextLength(profile?: string): number {
export function getModelContextLength(input?: string | ModelContextLengthOptions): number {
const options: ModelContextLengthOptions = typeof input === 'string'
? { profile: input }
: input || {}
const profile = options.profile
const profileDir = getProfileDir(profile)
const config = loadConfig(profileDir)
if (!config) return DEFAULT_CONTEXT_LENGTH
const model = getDefaultModel(config)
const model = String(options.model || '').trim() || getDefaultModel(config)
if (!model) return DEFAULT_CONTEXT_LENGTH
const provider = getDefaultProvider(config)
const provider = String(options.provider || '').trim() || getDefaultProvider(config)
// 0. Database model_context table (highest priority)
const dbCtx = lookupContextFromDatabase(model, provider)
@@ -60,7 +60,7 @@ export async function handleAbort(
if (state.source === 'cli') {
try {
await bridge.interrupt(sessionId, 'Aborted by user')
await bridge.interrupt(sessionId, 'Aborted by user', state.profile)
} catch (err) {
logger.warn(err, '[chat-run-socket][abort] failed to interrupt CLI bridge for session %s', sessionId)
}
@@ -0,0 +1,95 @@
export interface BridgeDeltaFilterState {
bridgePendingToolCallMarkup?: string
}
const TOOL_CALL_MARKER = '[Calling tool:'
const MAX_PENDING_TOOL_MARKUP_LENGTH = 100_000
function findToolMarkupEnd(text: string, start: number): number {
let depth = 0
let inString = false
let escaped = false
for (let i = start; i < text.length; i += 1) {
const ch = text[i]
if (inString) {
if (escaped) {
escaped = false
} else if (ch === '\\') {
escaped = true
} else if (ch === '"') {
inString = false
}
continue
}
if (ch === '"') {
inString = true
continue
}
if (ch === '[') {
depth += 1
continue
}
if (ch === ']') {
depth -= 1
if (depth === 0) return i + 1
}
}
return -1
}
function trailingMarkerPrefixLength(text: string): number {
const max = Math.min(text.length, TOOL_CALL_MARKER.length - 1)
for (let len = max; len > 0; len -= 1) {
if (TOOL_CALL_MARKER.startsWith(text.slice(text.length - len))) return len
}
return 0
}
export function filterBridgeToolCallMarkupDelta(
state: BridgeDeltaFilterState,
delta: string,
): string {
if (!delta) return ''
const text = `${state.bridgePendingToolCallMarkup || ''}${delta}`
state.bridgePendingToolCallMarkup = ''
let out = ''
let idx = 0
while (idx < text.length) {
const markerIdx = text.indexOf(TOOL_CALL_MARKER, idx)
if (markerIdx < 0) {
const rest = text.slice(idx)
const pendingPrefixLength = trailingMarkerPrefixLength(rest)
if (pendingPrefixLength > 0) {
out += rest.slice(0, rest.length - pendingPrefixLength)
state.bridgePendingToolCallMarkup = rest.slice(rest.length - pendingPrefixLength)
} else {
out += rest
}
break
}
out += text.slice(idx, markerIdx)
const end = findToolMarkupEnd(text, markerIdx)
if (end < 0) {
state.bridgePendingToolCallMarkup = text.slice(markerIdx)
if (state.bridgePendingToolCallMarkup.length > MAX_PENDING_TOOL_MARKUP_LENGTH) {
state.bridgePendingToolCallMarkup = ''
}
break
}
idx = end
if (text[idx] === '\r' && text[idx + 1] === '\n') {
idx += 2
} else if (text[idx] === '\n') {
idx += 1
}
}
return out
}
@@ -5,6 +5,7 @@
import {
getSessionDetail,
getSession,
} from '../../../db/hermes/session-store'
import { getCompressionSnapshot } from '../../../db/hermes/compression-snapshot'
import { ChatContextCompressor, SUMMARY_PREFIX } from '../../../lib/context-compressor'
@@ -96,12 +97,17 @@ export async function buildCompressedHistory(
apiKey: string | undefined,
emit: (event: string, payload: any) => void,
sessionMap: Map<string, SessionState>,
modelContext: { model?: string | null; provider?: string | null } = {},
): Promise<ChatMessage[]> {
try {
let history = await buildDbHistory(sessionId, { excludeLastUser: true })
if (history.length === 0) return []
const contextLength = getModelContextLength(profile)
const contextLength = getModelContextLength({
profile,
model: modelContext.model,
provider: modelContext.provider,
})
const triggerTokens = Math.floor(contextLength / 2)
const cState = getOrCreateSession(sessionMap, sessionId)
const assembledTokens = await calcAndUpdateUsage(sessionId, cState, emit)
@@ -118,13 +124,13 @@ export async function buildCompressedHistory(
...newMessages,
] as ChatMessage[]
} else {
history = await compressHistory(history, newMessages, sessionId, upstream, apiKey, cState, totalTokens, emit, sessionMap)
history = await compressHistory(history, newMessages, sessionId, upstream, apiKey, cState, totalTokens, emit, sessionMap, modelContext)
}
} else if (history.length > 4) {
if (totalTokens <= triggerTokens && history.length <= 150) {
logger.info('[context-compress] session=%s: %d messages, ~%d tokens — under threshold, skip', sessionId, history.length, totalTokens)
} else {
history = await compressHistory(history, null, sessionId, upstream, apiKey, cState, totalTokens, emit, sessionMap)
history = await compressHistory(history, null, sessionId, upstream, apiKey, cState, totalTokens, emit, sessionMap, modelContext)
}
}
@@ -145,6 +151,7 @@ export async function compressHistory(
totalTokens: number,
emit: (event: string, payload: any) => void,
sessionMap: Map<string, SessionState>,
modelContext: { model?: string | null; provider?: string | null } = {},
): Promise<ChatMessage[]> {
const msgCount = newMessagesOnly ? newMessagesOnly.length : history.length
pushState(sessionMap, sessionId, 'compression.started', {
@@ -155,7 +162,12 @@ export async function compressHistory(
})
try {
const result = await compressor.compress(history, upstream, apiKey, sessionId)
const session = getSession(sessionId)
const result = await compressor.compress(history, upstream, apiKey, sessionId, {
profile: session?.profile,
model: modelContext.model || session?.model,
provider: modelContext.provider || session?.provider,
})
const afterTokens = await calcAndUpdateUsage(sessionId, cState, emit)
const compressedMeta = {
event: 'compression.completed' as const,
@@ -211,8 +223,6 @@ export async function forceCompressBridgeHistory(
sessionId: string,
profile: string,
_messages: ChatMessage[],
getUpstream: (profile: string) => string,
getApiKey: (profile: string) => string | undefined,
): Promise<BridgeCompressionResult> {
const history = await buildDbHistory(sessionId, { excludeLastUser: true })
@@ -231,8 +241,9 @@ export async function forceCompressBridgeHistory(
}
}
const upstream = getUpstream(profile).replace(/\/$/, '')
const apiKey = getApiKey(profile) || undefined
const upstream = ''
const apiKey = undefined
const session = getSession(sessionId)
const beforeUsage = estimateSnapshotAwareHistoryUsage(sessionId, history)
const totalTokens = beforeUsage.tokenCount
bridgeLogger.info({
@@ -245,7 +256,11 @@ export async function forceCompressBridgeHistory(
snapshotAware: true,
}, '[chat-run-socket] bridge forced compression started')
const result = await compressor.compress(history, upstream, apiKey, sessionId, profile)
const result = await compressor.compress(history, upstream, apiKey, sessionId, {
profile: session?.profile || profile,
model: session?.model,
provider: session?.provider,
})
const compressedMessages = result.messages.map(m => {
const msg: any = { role: m.role, content: m.content }
if (m.reasoning_content) msg.reasoning_content = m.reasoning_content
@@ -1,5 +1,8 @@
import type { ContentBlock } from './types'
type ResponseContentPart = { type: string; text?: string; image_url?: string }
type AgentContentPart = { type: string; text?: string; image_url?: { url: string } }
/**
* Convert ContentBlock[] to string for display/storage
*/
@@ -29,22 +32,16 @@ export function isContentBlockArray(input: any): input is ContentBlock[] {
/**
* Convert ContentBlock[] to multimodal format for /v1/responses API.
*/
export async function convertContentBlocks(blocks: ContentBlock[]): Promise<Array<{ type: string; text?: string; image_url?: string }>> {
const parts: Array<{ type: string; text?: string; image_url?: string }> = []
const fs = await import('fs/promises')
const path = await import('path')
export async function convertContentBlocks(blocks: ContentBlock[]): Promise<ResponseContentPart[]> {
const parts: ResponseContentPart[] = []
for (const block of blocks) {
if (block.type === 'text') {
parts.push({ type: 'input_text', text: block.text })
} else if (block.type === 'image') {
try {
const buf = await fs.readFile(block.path)
const ext = path.extname(block.path).toLowerCase().replace('.', '')
const mime = ext === 'jpg' ? 'jpeg' : ext || 'png'
const base64 = buf.toString('base64')
parts.push({ type: 'input_image', image_url: `data:image/${mime};base64,${base64}` })
} catch {
const dataUri = await imageBlockToDataUri(block)
if (dataUri) {
parts.push({ type: 'input_image', image_url: dataUri })
} else {
parts.push({ type: 'input_text', text: `[Image: ${block.path}]` })
}
} else if (block.type === 'file') {
@@ -59,15 +56,42 @@ export async function convertContentBlocks(blocks: ContentBlock[]): Promise<Arra
* Convert ContentBlock[] to the normalized multimodal shape Hermes agent
* receives after /v1/responses input normalization.
*/
export async function convertContentBlocksForAgent(blocks: ContentBlock[]): Promise<Array<{ type: string; text?: string; image_url?: { url: string } }>> {
const responseParts = await convertContentBlocks(blocks)
return responseParts.map((part) => {
if (part.type === 'input_text') {
return { type: 'text', text: part.text || '' }
}
if (part.type === 'input_image') {
return { type: 'image_url', image_url: { url: part.image_url || '' } }
}
return { type: 'text', text: part.text || '' }
export async function convertContentBlocksForAgent(blocks: ContentBlock[]): Promise<AgentContentPart[]> {
const parts: AgentContentPart[] = []
for (const block of blocks) {
if (block.type === 'text') {
parts.push({ type: 'text', text: block.text || '' })
} else if (block.type === 'image') {
parts.push({
type: 'text',
text: `[Attached image: ${block.name || block.path}]\nLocal image path for tools: ${block.path}`,
})
const dataUri = await imageBlockToDataUri(block)
if (dataUri) {
parts.push({ type: 'image_url', image_url: { url: dataUri } })
}
} else if (block.type === 'file') {
parts.push({
type: 'text',
text: `[Attached file: ${block.name || block.path}]\nLocal file path for tools: ${block.path}`,
})
}
}
return parts
}
async function imageBlockToDataUri(block: Extract<ContentBlock, { type: 'image' }>): Promise<string | null> {
try {
const fs = await import('fs/promises')
const path = await import('path')
const buf = await fs.readFile(block.path)
const ext = path.extname(block.path).toLowerCase().replace('.', '')
const mimeFromExt = ext === 'jpg' ? 'jpeg' : ext || 'png'
const mime = block.media_type?.startsWith('image/')
? block.media_type.slice('image/'.length)
: mimeFromExt
return `data:image/${mime};base64,${buf.toString('base64')}`
} catch {
return null
}
}
@@ -25,15 +25,8 @@ import { countTokens, SUMMARY_PREFIX } from '../../../lib/context-compressor'
import { getCompressionSnapshot } from '../../../db/hermes/compression-snapshot'
import type { ContentBlock, SessionState, ChatRunSource } from './types'
export function resolveRunSource(source?: string, sessionId?: string): ChatRunSource {
const normalized = String(source || '').trim()
if (normalized === 'cli') return 'cli'
if (normalized === 'api_server') return 'api_server'
if (sessionId) {
const existing = getSession(sessionId)
if (existing?.source === 'cli') return 'cli'
}
return 'api_server'
export function resolveRunSource(_source?: string, _sessionId?: string): ChatRunSource {
return 'cli'
}
export async function loadSessionStateFromDb(sid: string, _sessionMap: Map<string, SessionState>): Promise<SessionState> {
@@ -78,7 +71,6 @@ export async function handleApiRun(
data: { input: string | ContentBlock[]; session_id?: string; model?: string; provider?: string; instructions?: string; source?: string },
profile: string,
sessionMap: Map<string, SessionState>,
gatewayManager: any,
skipUserMessage = false,
dequeueNextQueuedRun: (socket: Socket, sessionId: string, fallbackProfile?: string) => void,
) {
@@ -96,8 +88,8 @@ export async function handleApiRun(
}
}
const upstream = gatewayManager.getUpstream(profile).replace(/\/$/, '')
const apiKey = gatewayManager.getApiKey(profile) || undefined
const upstream = ''
const apiKey = undefined
const runMarker = session_id
? `resp_run_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
@@ -179,7 +171,11 @@ export async function handleApiRun(
if (model) body.model = model
body.instructions = fullInstructions
if (session_id) {
const compressed = await buildCompressedHistory(session_id, profile, upstream, apiKey, emit, sessionMap)
const sessionRow = getSession(session_id)
const compressed = await buildCompressedHistory(session_id, profile, upstream, apiKey, emit, sessionMap, {
model: sessionRow?.model || model,
provider: sessionRow?.provider || provider,
})
if (compressed.length > 0) {
body.conversation_history = compressed
}
@@ -9,7 +9,6 @@ import { getSession, createSession, addMessage, updateSession, updateSessionStat
import { updateUsage } from '../../../db/hermes/usage-store'
import { logger, bridgeLogger } from '../../logger'
import { AgentBridgeClient, type AgentBridgeMessage, type AgentBridgeOutput } from '../agent-bridge'
import { readConfigYaml } from '../../config-helpers'
import { contentBlocksToString, convertContentBlocksForAgent, extractTextForPreview, isContentBlockArray } from './content-blocks'
import { buildCompressedHistory } from './compression'
import { pushState, replaceState } from './compression'
@@ -24,43 +23,19 @@ import {
import { forceCompressBridgeHistory } from './compression'
import { summarizeToolArguments } from './response-utils'
import { buildDbHistory } from './compression'
import { convertHistoryFormat } from './message-format'
import type { ContentBlock, SessionState } from './types'
import type { ChatMessage } from '../../../lib/context-compressor'
import { resolveBridgeRunModelConfig, type RunModelGroup } from './model-config'
import { filterBridgeToolCallMarkupDelta } from './bridge-delta'
const BRIDGE_USAGE_FLUSH_DELAY_MS = 200
type RunModelGroup = { provider: string; models: string[] }
async function resolveDefaultModelConfig(): Promise<{ model: string; provider: string }> {
try {
const config = await readConfigYaml()
const modelConfig = config?.model
const model = typeof modelConfig === 'string'
? modelConfig.trim()
: String(modelConfig?.default || '').trim()
const provider = typeof modelConfig === 'object'
? String(modelConfig?.provider || '').trim()
: ''
return { model, provider }
} catch {
return { model: '', provider: '' }
}
}
function hasModelInGroups(groups: RunModelGroup[] | undefined, provider: string, model: string): boolean {
if (!groups?.length || !provider || !model) return false
const group = groups.find(item => item.provider === provider)
return Array.isArray(group?.models) && group.models.includes(model)
}
export async function handleBridgeRun(
nsp: ReturnType<Server['of']>,
socket: Socket,
data: { input: string | ContentBlock[]; session_id?: string; model?: string; provider?: string; model_groups?: RunModelGroup[]; instructions?: string; source?: string },
profile: string,
sessionMap: Map<string, SessionState>,
gatewayManager: any,
bridge: AgentBridgeClient,
_skipUserMessage = false,
loadSessionStateFromDbFn: (sid: string, sessionMap: Map<string, SessionState>) => Promise<SessionState>,
@@ -78,14 +53,14 @@ export async function handleBridgeRun(
const sessionRow = getSession(session_id)
const sessionModel = sessionRow?.model || ''
const sessionProvider = sessionRow?.provider || ''
const hasGroups = Array.isArray(data.model_groups) && data.model_groups.length > 0
const sessionModelAvailable = hasGroups && hasModelInGroups(data.model_groups, sessionProvider, sessionModel)
const shouldUseDefault = !sessionModel || !sessionProvider || !sessionModelAvailable
const defaultModelConfig = shouldUseDefault
? await resolveDefaultModelConfig()
: { model: '', provider: '' }
const resolvedModel = shouldUseDefault ? defaultModelConfig.model : sessionModel
const resolvedProvider = shouldUseDefault ? defaultModelConfig.provider : sessionProvider
const { model: resolvedModel, provider: resolvedProvider } = await resolveBridgeRunModelConfig({
profile,
sessionModel,
sessionProvider,
requestedModel: data.model,
requestedProvider: data.provider,
modelGroups: data.model_groups,
})
if (sessionRow) {
const updates: { model?: string; provider?: string } = {}
if (resolvedModel && sessionRow.model !== resolvedModel) updates.model = resolvedModel
@@ -117,6 +92,7 @@ export async function handleBridgeRun(
state.bridgeOutput = ''
state.bridgePendingAssistantContent = ''
state.bridgePendingReasoningContent = ''
state.bridgePendingToolCallMarkup = ''
state.bridgeToolCounter = 0
state.bridgePendingTools = []
state.responseRun = undefined
@@ -154,12 +130,13 @@ export async function handleBridgeRun(
const history = await buildCompressedHistory(
session_id, profile,
gatewayManager.getUpstream(profile).replace(/\/$/, ''),
gatewayManager.getApiKey(profile) || undefined,
'',
undefined,
emit,
sessionMap,
{ model: resolvedModel, provider: resolvedProvider },
)
const bridgeHistory = history.length > 0 ? convertHistoryFormat(history) : history
const bridgeHistory = history
try {
const bridgeInput = isContentBlockArray(input)
@@ -207,7 +184,7 @@ export async function handleBridgeRun(
})
for await (const chunk of bridge.streamOutput(started.run_id)) {
await applyBridgeChunkAsync(nsp, socket, state, session_id, runMarker, chunk, emit, profile, sessionMap, gatewayManager, bridge, dequeueNextQueuedRun)
await applyBridgeChunkAsync(nsp, socket, state, session_id, runMarker, chunk, emit, profile, sessionMap, bridge, dequeueNextQueuedRun)
if (chunk.done) break
}
} catch (err: any) {
@@ -220,6 +197,7 @@ export async function handleBridgeRun(
state.runId = undefined
state.activeRunMarker = undefined
state.events = []
state.bridgePendingToolCallMarkup = undefined
flushBridgePendingToDb(state, session_id)
updateSessionStats(session_id)
const message = err instanceof Error ? err.message : String(err)
@@ -244,7 +222,6 @@ async function applyBridgeChunkAsync(
emit: (event: string, payload: any) => void,
profile: string,
sessionMap: Map<string, SessionState>,
gatewayManager: any,
bridge: AgentBridgeClient,
dequeueNextQueuedRun: (socket: Socket, sessionId: string, fallbackProfile?: string) => void,
): Promise<void> {
@@ -357,8 +334,6 @@ async function applyBridgeChunkAsync(
sessionId,
profile,
ev.messages as ChatMessage[],
(p: string) => gatewayManager.getUpstream(p),
(p: string) => gatewayManager.getApiKey(p),
)
state.bridgeCompressionResults = state.bridgeCompressionResults || {}
state.bridgeCompressionResults[String(ev.request_id)] = compressed
@@ -421,11 +396,13 @@ async function applyBridgeChunkAsync(
}
if (chunk.delta) {
state.bridgeOutput = (state.bridgeOutput || '') + chunk.delta
state.bridgePendingAssistantContent = (state.bridgePendingAssistantContent || '') + chunk.delta
const delta = filterBridgeToolCallMarkupDelta(state, chunk.delta)
if (delta) {
state.bridgeOutput = (state.bridgeOutput || '') + delta
state.bridgePendingAssistantContent = (state.bridgePendingAssistantContent || '') + delta
const last = [...state.messages].reverse().find(m => m.runMarker === runMarker)
if (last?.role === 'assistant' && last.finish_reason == null) {
last.content += chunk.delta
last.content += delta
syncBridgeReasoningToMessage(last, state.bridgePendingReasoningContent)
} else {
state.messages.push({
@@ -433,7 +410,7 @@ async function applyBridgeChunkAsync(
session_id: sessionId,
runMarker,
role: 'assistant',
content: chunk.delta,
content: delta,
reasoning: state.bridgePendingReasoningContent || null,
reasoning_content: state.bridgePendingReasoningContent || null,
timestamp: Math.floor(Date.now() / 1000),
@@ -442,10 +419,11 @@ async function applyBridgeChunkAsync(
emit('message.delta', {
event: 'message.delta',
run_id: chunk.run_id,
delta: chunk.delta,
delta,
output: state.bridgeOutput,
})
}
}
if (!chunk.done) return
if (!state.isWorking) return
@@ -459,6 +437,7 @@ async function applyBridgeChunkAsync(
}
flushBridgePendingToDb(state, sessionId)
state.bridgePendingToolCallMarkup = undefined
updateSessionStats(sessionId)
await delay(BRIDGE_USAGE_FLUSH_DELAY_MS)
const usage = await calcAndUpdateUsage(sessionId, state, emit)
@@ -12,7 +12,7 @@ import type { Server, Socket } from 'socket.io'
import { logger } from '../../logger'
import { getSystemPrompt } from '../../../lib/llm-prompt'
import { getSession } from '../../../db/hermes/session-store'
import { getActiveProfileName } from '../hermes-profile'
import { getActiveProfileName, getProfileDir, listProfileNamesFromDisk } from '../hermes-profile'
import { AgentBridgeClient } from '../agent-bridge'
import { handleApiRun, resolveRunSource, loadSessionStateFromDb } from './handle-api-run'
import { handleBridgeRun } from './handle-bridge-run'
@@ -25,14 +25,12 @@ export type { ContentBlock } from './types'
export class ChatRunSocket {
private nsp: ReturnType<Server['of']>
private gatewayManager: any
private bridge = new AgentBridgeClient()
/** sessionId → session state (messages, working status, events, run tracking) */
private sessionMap = new Map<string, SessionState>()
constructor(io: Server, gatewayManager: any) {
constructor(io: Server) {
this.nsp = io.of('/chat-run')
this.gatewayManager = gatewayManager
}
init() {
@@ -60,6 +58,17 @@ export class ChatRunSocket {
private onConnection(socket: Socket) {
const socketProfile = (socket.handshake.query?.profile as string) || 'default'
const currentProfile = () => getActiveProfileName() || socketProfile || 'default'
const profileExists = (profile: string) => {
if (!profile || profile === 'default') return true
return listProfileNamesFromDisk().includes(profile)
}
const resolveRunProfile = (sessionId?: string, requested?: string) => {
const requestedProfile = typeof requested === 'string' ? requested.trim() : ''
if (requestedProfile && profileExists(requestedProfile)) return requestedProfile
if (!sessionId) return currentProfile()
const storedProfile = getSession(sessionId)?.profile || ''
return storedProfile && profileExists(storedProfile) ? storedProfile : currentProfile()
}
socket.on('run', async (data: {
input: string | ContentBlock[]
@@ -70,7 +79,9 @@ export class ChatRunSocket {
model_groups?: Array<{ provider: string; models: string[] }>
queue_id?: string
source?: string
profile?: string
}) => {
const runProfile = resolveRunProfile(data.session_id, data.profile)
if (data.session_id) {
const state = getOrCreateSession(this.sessionMap, data.session_id)
const source = resolveRunSource(data.source, data.session_id)
@@ -82,8 +93,7 @@ export class ChatRunSocket {
socket,
sessionMap: this.sessionMap,
bridge: this.bridge,
gatewayManager: this.gatewayManager,
profile: currentProfile(),
profile: runProfile,
model: data.model,
instructions: data.instructions,
runQueuedItem: this.runQueuedItem.bind(this),
@@ -107,7 +117,7 @@ export class ChatRunSocket {
provider: data.provider,
model_groups: data.model_groups,
instructions: data.instructions,
profile: currentProfile(),
profile: runProfile,
source,
})
this.nsp.to(`session:${data.session_id}`).emit('run.queued', {
@@ -119,11 +129,11 @@ export class ChatRunSocket {
return
}
state.isWorking = true
state.profile = currentProfile()
state.profile = runProfile
state.source = source
}
try {
await this.handleRun(socket, data, currentProfile())
await this.handleRun(socket, data, runProfile)
} catch (err) {
if (data.session_id) {
const state = this.sessionMap.get(data.session_id)
@@ -224,7 +234,7 @@ export class ChatRunSocket {
await handleBridgeRun(
this.nsp, socket, { ...data, instructions: fullInstructions }, profile,
this.sessionMap, this.gatewayManager, this.bridge,
this.sessionMap, this.bridge,
skipUserMessage,
loadSessionStateFromDb,
this.dequeueNextQueuedRun.bind(this),
@@ -234,7 +244,7 @@ export class ChatRunSocket {
await handleApiRun(
this.nsp, socket, data, profile,
this.sessionMap, this.gatewayManager,
this.sessionMap,
skipUserMessage,
this.dequeueNextQueuedRun.bind(this),
)
@@ -0,0 +1,47 @@
import { readConfigYamlForProfile } from '../../config-helpers'
export type RunModelGroup = { provider: string; models: string[] }
async function resolveDefaultModelConfig(profile: string): Promise<{ model: string; provider: string }> {
try {
const config = await readConfigYamlForProfile(profile)
const modelConfig = config?.model
const model = typeof modelConfig === 'string'
? modelConfig.trim()
: String(modelConfig?.default || '').trim()
const provider = typeof modelConfig === 'object'
? String(modelConfig?.provider || '').trim()
: ''
return { model, provider }
} catch {
return { model: '', provider: '' }
}
}
function hasModelInGroups(groups: RunModelGroup[] | undefined, provider: string, model: string): boolean {
if (!groups?.length || !provider || !model) return false
const group = groups.find(item => item.provider === provider)
return Array.isArray(group?.models) && group.models.includes(model)
}
export async function resolveBridgeRunModelConfig(options: {
profile: string
sessionModel?: string | null
sessionProvider?: string | null
requestedModel?: string | null
requestedProvider?: string | null
modelGroups?: RunModelGroup[]
}): Promise<{ model: string; provider: string }> {
const sessionModel = String(options.sessionModel || '').trim()
const sessionProvider = String(options.sessionProvider || '').trim()
const requestedModel = String(options.requestedModel || '').trim()
const requestedProvider = String(options.requestedProvider || '').trim()
const candidateModel = sessionModel || requestedModel
const candidateProvider = sessionProvider || requestedProvider
const hasGroups = Array.isArray(options.modelGroups) && options.modelGroups.length > 0
const candidateAvailable = hasGroups && hasModelInGroups(options.modelGroups, candidateProvider, candidateModel)
const shouldUseDefault = !candidateModel || !candidateProvider || !candidateAvailable
return shouldUseDefault
? resolveDefaultModelConfig(options.profile)
: { model: candidateModel, provider: candidateProvider }
}
@@ -30,7 +30,6 @@ interface SessionCommandContext {
socket: Socket
sessionMap: Map<string, SessionState>
bridge: AgentBridgeClient
gatewayManager: any
profile: string
model?: string
instructions?: string
@@ -243,8 +242,6 @@ export async function handleSessionCommand(
sessionId,
ctx.profile,
[],
(profile: string) => ctx.gatewayManager.getUpstream(profile),
(profile: string) => ctx.gatewayManager.getApiKey(profile),
)
state.bridgeCompressionResults = state.bridgeCompressionResults || {}
await calcAndUpdateUsage(sessionId, state, emit)
@@ -312,11 +309,11 @@ export async function handleSessionCommand(
try {
if (wasWorking) {
flushBridgePendingToDb(state, sessionId)
await ctx.bridge.interrupt(sessionId, 'Destroyed by user').catch((err) => {
await ctx.bridge.interrupt(sessionId, 'Destroyed by user', state.profile).catch((err) => {
logger.warn(err, '[chat-run-socket] /destroy interrupt failed for session %s', sessionId)
})
}
await ctx.bridge.destroy(sessionId).catch((err) => {
await ctx.bridge.destroy(sessionId, state.profile).catch((err) => {
bridgeReachable = false
bridgeError = err instanceof Error ? err.message : String(err)
logger.warn(err, '[chat-run-socket] /destroy bridge unavailable for session %s', sessionId)
@@ -337,6 +334,7 @@ export async function handleSessionCommand(
state.queue = []
state.bridgePendingAssistantContent = undefined
state.bridgePendingReasoningContent = undefined
state.bridgePendingToolCallMarkup = undefined
state.bridgeOutput = undefined
state.bridgePendingTools = undefined
state.bridgeCompressionResults = undefined
@@ -366,6 +364,7 @@ export async function handleSessionCommand(
function clearTransientRunState(state: SessionState) {
state.events = []
state.bridgePendingTools = undefined
state.bridgePendingToolCallMarkup = undefined
state.bridgeCompressionResults = undefined
state.responseRun = undefined
state.activeRunMarker = undefined
@@ -52,6 +52,7 @@ export interface SessionState {
source?: ChatRunSource
bridgePendingAssistantContent?: string
bridgePendingReasoningContent?: string
bridgePendingToolCallMarkup?: string
bridgeOutput?: string
bridgeToolCounter?: number
bridgePendingTools?: Array<{

Some files were not shown because too many files have changed in this diff Show More