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:
@@ -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
@@ -41,7 +41,7 @@
|
||||
|
||||
### AI 聊天
|
||||
|
||||
- 聊天前端通过 Socket.IO `/chat-run` 实时流式更新;API Server 路径内部消费 Hermes Gateway 流式响应
|
||||
- 聊天前端通过 Socket.IO `/chat-run` 实时流式更新;聊天运行通过 Hermes agent bridge 执行
|
||||
- 多会话管理 — 创建、重命名、删除、切换会话
|
||||
- **自建会话数据库** — Web UI 会话使用本地 SQLite;Hermes 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、配置/凭证管理、微信扫码登录、模型发现、技能/记忆管理、日志读取和静态文件服务。
|
||||
|
||||
## 技术栈
|
||||
|
||||
|
||||
@@ -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。
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 +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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
handleNewChat();
|
||||
|
||||
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();
|
||||
}
|
||||
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;
|
||||
.new-chat-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.new-chat-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.new-chat-label {
|
||||
font-size: 12px;
|
||||
color: $text-muted;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 l’historique',
|
||||
hermesHistory: 'Historique Hermes',
|
||||
historyScopeHint: 'Sessions d’historique Hermes en lecture seule, regroupées par source.',
|
||||
historyScopeHint: 'Sessions d’historique 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 l’API ; ce n’est 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',
|
||||
|
||||
@@ -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: 'プロバイダー',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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} 的顯示名',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
if (requestedProfile) {
|
||||
return requestedProfile
|
||||
return requestedProfile || getActiveProfileName()
|
||||
}
|
||||
|
||||
// Fallback: read from authoritative source (active_profile file)
|
||||
function resolveProfileDir(profile: string): string {
|
||||
return getProfileDir(profile || '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 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)
|
||||
}
|
||||
|
||||
function findJob(profile: string, jobId: string): JobRecord | null {
|
||||
return readJobs(profile, true).find((job) => job.job_id === jobId || job.id === jobId) ?? null
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return null
|
||||
}
|
||||
const parsed = Number(repeat)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
|
||||
function hasRepeatField(body: Record<string, any>): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(body, 'repeat')
|
||||
}
|
||||
|
||||
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 {
|
||||
const { getActiveProfileName } = require('../../services/hermes/hermes-profile')
|
||||
return getActiveProfileName()
|
||||
} catch {
|
||||
return 'default'
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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}` } }
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
const params = new URLSearchParams(ctx.search || '')
|
||||
params.delete('token')
|
||||
const search = params.toString()
|
||||
const url = `${upstream}${upstreamPath}${search ? `?${search}` : ''}`
|
||||
|
||||
const headers = buildHeaders(profile)
|
||||
const body = ctx.req.method !== 'GET' && ctx.req.method !== 'HEAD'
|
||||
? JSON.stringify(ctx.request.body || {})
|
||||
: undefined
|
||||
|
||||
let res: Response
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
if (data.length === 0) return null
|
||||
return { event, data: data.join('\n') }
|
||||
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 || ''}`
|
||||
}
|
||||
|
||||
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,14 +52,22 @@ export function buildAgentInstructions(params: AgentInstructionsParams): string
|
||||
${memberSection}
|
||||
|
||||
规则:
|
||||
- 有人用 @${params.agentName} 提及你时才需要回复,重点回应提及你的人。
|
||||
- 禁止@自己。
|
||||
- 当你收到群聊任务时,说明系统已经判断你需要回复;请直接回应当前消息,不要因为消息里同时提及其他成员而拒绝回复或输出空回复。
|
||||
- 重点回应提及你的人。
|
||||
- 回答简洁、对群聊有帮助。
|
||||
- 不要假装是人类,需要时明确表明自己是 AI。
|
||||
- 对话历史中包含多个人的消息,每条消息前标有发送者名字。
|
||||
- 历史消息里的"[发送者]: ..."只是系统添加的归属标记,用来帮助你理解谁说了这句话;不要在你的回复中复述或模仿这种方括号前缀。
|
||||
- 回复时使用自然语言即可;如果需要点名某人,只使用 @名字,不要输出"[${params.agentName}]:"这类格式。
|
||||
- 对话开头可能包含之前的对话摘要,用于提供更早的上下文。
|
||||
- 回复最新一条提及你的消息。
|
||||
- 如果需要其他 agent 协作或明确回复某个人,使用 @名字 来提及对方。
|
||||
- 群聊系统支持 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/host,Hermes 只从 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 }
|
||||
}
|
||||
}
|
||||
return typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : { value }
|
||||
}
|
||||
|
||||
buffer += decoder.decode()
|
||||
const frame = parseSseFrame(buffer)
|
||||
if (frame?.data) yield frame
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
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 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 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 || '' }
|
||||
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 } })
|
||||
}
|
||||
if (part.type === 'input_image') {
|
||||
return { type: 'image_url', image_url: { url: part.image_url || '' } }
|
||||
}
|
||||
return { type: 'text', text: part.text || '' }
|
||||
} 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
Reference in New Issue
Block a user