From 9a9416c99ca0cf9352096959b6e786ee7ba5765c Mon Sep 17 00:00:00 2001 From: ekko <152005280+EKKOLearnAI@users.noreply.github.com> Date: Tue, 19 May 2026 16:09:59 +0800 Subject: [PATCH] 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 --- README.md | 30 +- README_zh.md | 30 +- TODAY_TEST_CASES.md | 439 +++++++++ docker-compose.yml | 2 + docs/cli-chat-sessions.md | 2 +- docs/docker.md | 8 +- package.json | 2 +- packages/client/src/api/hermes/chat.ts | 6 +- packages/client/src/api/hermes/config.ts | 3 +- packages/client/src/api/hermes/gateways.ts | 39 - packages/client/src/api/hermes/group-chat.ts | 16 + packages/client/src/api/hermes/profiles.ts | 2 - packages/client/src/api/hermes/sessions.ts | 8 +- packages/client/src/api/hermes/system.ts | 22 +- .../src/components/hermes/chat/ChatInput.vue | 19 +- .../src/components/hermes/chat/ChatPanel.vue | 519 ++++++----- .../components/hermes/chat/MessageList.vue | 2 +- .../hermes/chat/SessionListItem.vue | 49 +- .../hermes/group-chat/CreateRoomForm.vue | 4 +- .../hermes/group-chat/GroupChatInput.vue | 307 +++++- .../hermes/group-chat/GroupChatPanel.vue | 197 +++- .../hermes/group-chat/GroupMessageItem.vue | 878 +++++++++++++++++- .../hermes/group-chat/GroupMessageList.vue | 9 +- .../components/hermes/models/ProviderCard.vue | 45 +- .../hermes/models/XaiOAuthLoginModal.vue | 10 + .../hermes/profiles/ProfileCard.vue | 4 - .../hermes/settings/PlatformSettings.vue | 233 +++-- .../components/hermes/skills/SkillDetail.vue | 5 +- .../src/components/layout/AppSidebar.vue | 9 - .../src/components/layout/ModelSelector.vue | 15 +- packages/client/src/i18n/locales/de.ts | 5 +- packages/client/src/i18n/locales/en.ts | 6 +- packages/client/src/i18n/locales/es.ts | 5 +- packages/client/src/i18n/locales/fr.ts | 5 +- packages/client/src/i18n/locales/ja.ts | 5 +- packages/client/src/i18n/locales/ko.ts | 5 +- packages/client/src/i18n/locales/pt.ts | 5 +- packages/client/src/i18n/locales/zh-TW.ts | 5 +- packages/client/src/i18n/locales/zh.ts | 6 +- packages/client/src/router/index.ts | 5 - packages/client/src/stores/hermes/app.ts | 4 + packages/client/src/stores/hermes/chat.ts | 51 +- packages/client/src/stores/hermes/gateways.ts | 51 - .../client/src/stores/hermes/group-chat.ts | 350 ++++++- packages/client/src/stores/hermes/models.ts | 6 +- packages/client/src/stores/hermes/settings.ts | 4 +- .../client/src/views/hermes/GatewaysView.vue | 191 ---- .../client/src/views/hermes/HistoryView.vue | 32 +- packages/server/src/controllers/health.ts | 15 +- .../server/src/controllers/hermes/config.ts | 62 +- .../src/controllers/hermes/cron-history.ts | 9 +- .../server/src/controllers/hermes/gateways.ts | 33 - .../server/src/controllers/hermes/jobs.ts | 345 +++++-- .../server/src/controllers/hermes/kanban.ts | 3 +- .../server/src/controllers/hermes/media.ts | 223 +++++ .../server/src/controllers/hermes/models.ts | 319 ++++++- .../server/src/controllers/hermes/profiles.ts | 22 +- .../server/src/controllers/hermes/sessions.ts | 98 +- .../server/src/controllers/hermes/skills.ts | 3 +- .../server/src/controllers/hermes/xai-auth.ts | 25 +- packages/server/src/controllers/update.ts | 1 + .../server/src/db/hermes/conversations-db.ts | 3 +- packages/server/src/db/hermes/schemas.ts | 9 + .../server/src/db/hermes/session-store.ts | 11 +- packages/server/src/db/hermes/sessions-db.ts | 13 +- packages/server/src/index.ts | 30 +- .../context-compressor/export-compressor.ts | 15 +- .../src/lib/context-compressor/index.ts | 108 +-- packages/server/src/routes/hermes/gateways.ts | 9 - packages/server/src/routes/hermes/media.ts | 6 + .../server/src/routes/hermes/proxy-handler.ts | 9 +- packages/server/src/routes/index.ts | 4 +- .../server/src/services/config-helpers.ts | 20 +- .../server/src/services/gateway-bootstrap.ts | 15 - .../services/hermes/agent-bridge/client.ts | 69 +- .../hermes/agent-bridge/hermes_bridge.py | 599 +++++++++++- .../hermes/context-engine/compressor.ts | 61 +- .../hermes/context-engine/gateway-client.ts | 156 +--- .../services/hermes/context-engine/prompt.ts | 24 +- .../services/hermes/context-engine/types.ts | 5 + .../src/services/hermes/file-provider.ts | 95 +- .../src/services/hermes/gateway-autostart.ts | 125 +++ .../src/services/hermes/gateway-manager.ts | 55 +- .../src/services/hermes/gateway-runner.ts | 22 + .../hermes/group-chat/agent-clients.ts | 543 +++++++---- .../src/services/hermes/group-chat/index.ts | 449 ++++++++- .../server/src/services/hermes/hermes-cli.ts | 229 ++++- .../server/src/services/hermes/hermes-path.ts | 19 +- .../src/services/hermes/hermes-profile.ts | 20 +- .../src/services/hermes/model-context.ts | 16 +- .../src/services/hermes/run-chat/abort.ts | 2 +- .../services/hermes/run-chat/bridge-delta.ts | 95 ++ .../services/hermes/run-chat/compression.ts | 33 +- .../hermes/run-chat/content-blocks.ts | 68 +- .../hermes/run-chat/handle-api-run.ts | 22 +- .../hermes/run-chat/handle-bridge-run.ts | 107 +-- .../src/services/hermes/run-chat/index.ts | 32 +- .../services/hermes/run-chat/model-config.ts | 47 + .../hermes/run-chat/session-command.ts | 9 +- .../src/services/hermes/run-chat/types.ts | 1 + .../src/services/hermes/skill-injector.ts | 97 ++ packages/server/src/services/logger.ts | 7 +- packages/server/src/services/shutdown.ts | 43 - packages/skills/grok-image-to-video/SKILL.md | 96 ++ packages/website/src/i18n/en.ts | 20 +- packages/website/src/i18n/zh.ts | 20 +- scripts/build-server.mjs | 18 +- tests/client/profiles-store.test.ts | 22 +- tests/client/tool-trace-visibility.test.ts | 4 +- tests/e2e/chat-streaming.spec.ts | 2 +- tests/server/agent-bridge-manager.test.ts | 7 + .../server/codex-credential-pool-auth.test.ts | 2 +- .../config-controller-file-lock.test.ts | 29 +- tests/server/config-helpers-file-lock.test.ts | 51 + tests/server/context-engine.test.ts | 7 +- tests/server/file-provider-paths.test.ts | 15 + tests/server/gateway-autostart.test.ts | 17 + tests/server/health-controller.test.ts | 12 - tests/server/jobs-controller.test.ts | 88 +- tests/server/media-controller.test.ts | 23 + .../model-visibility-controller.test.ts | 8 +- tests/server/proxy-handler.test.ts | 13 +- tests/server/run-chat-bridge-delta.test.ts | 40 + tests/server/run-chat-content-blocks.test.ts | 52 ++ tests/server/run-chat-model-config.test.ts | 63 ++ tests/server/sessions-controller.test.ts | 31 +- tests/server/skill-injector.test.ts | 64 ++ tests/server/update-controller.test.ts | 2 + tests/server/xai-auth-controller.test.ts | 34 + 129 files changed, 7017 insertions(+), 1838 deletions(-) create mode 100644 TODAY_TEST_CASES.md delete mode 100644 packages/client/src/api/hermes/gateways.ts delete mode 100644 packages/client/src/stores/hermes/gateways.ts delete mode 100644 packages/client/src/views/hermes/GatewaysView.vue delete mode 100644 packages/server/src/controllers/hermes/gateways.ts create mode 100644 packages/server/src/controllers/hermes/media.ts delete mode 100644 packages/server/src/routes/hermes/gateways.ts create mode 100644 packages/server/src/routes/hermes/media.ts delete mode 100644 packages/server/src/services/gateway-bootstrap.ts create mode 100644 packages/server/src/services/hermes/gateway-autostart.ts create mode 100644 packages/server/src/services/hermes/gateway-runner.ts create mode 100644 packages/server/src/services/hermes/run-chat/bridge-delta.ts create mode 100644 packages/server/src/services/hermes/run-chat/model-config.ts create mode 100644 packages/server/src/services/hermes/skill-injector.ts create mode 100644 packages/skills/grok-image-to-video/SKILL.md create mode 100644 tests/server/gateway-autostart.test.ts create mode 100644 tests/server/media-controller.test.ts create mode 100644 tests/server/run-chat-bridge-delta.test.ts create mode 100644 tests/server/run-chat-content-blocks.test.ts create mode 100644 tests/server/run-chat-model-config.test.ts create mode 100644 tests/server/skill-injector.test.ts create mode 100644 tests/server/xai-auth-controller.test.ts diff --git a/README.md b/README.md index f601046..ec528b3 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README_zh.md b/README_zh.md index ed7b3b8..692926c 100644 --- a/README_zh.md +++ b/README_zh.md @@ -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、配置/凭证管理、微信扫码登录、模型发现、技能/记忆管理、日志读取和静态文件服务。 ## 技术栈 diff --git a/TODAY_TEST_CASES.md b/TODAY_TEST_CASES.md new file mode 100644 index 0000000..d2e8a32 --- /dev/null +++ b/TODAY_TEST_CASES.md @@ -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。 diff --git a/docker-compose.yml b/docker-compose.yml index 577c8cd..3c41686 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/cli-chat-sessions.md b/docs/cli-chat-sessions.md index b7932cf..343ac40 100644 --- a/docs/cli-chat-sessions.md +++ b/docs/cli-chat-sessions.md @@ -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() ``` diff --git a/docs/docker.md b/docs/docker.md index 593455a..53710c8 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -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 diff --git a/package.json b/package.json index 85658c7..512559c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/client/src/api/hermes/chat.ts b/packages/client/src/api/hermes/chat.ts index 6f75cc6..3026be4 100644 --- a/packages/client/src/api/hermes/chat.ts +++ b/packages/client/src/api/hermes/chat.ts @@ -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') -} diff --git a/packages/client/src/api/hermes/config.ts b/packages/client/src/api/hermes/config.ts index 82be950..f381c02 100644 --- a/packages/client/src/api/hermes/config.ts +++ b/packages/client/src/api/hermes/config.ts @@ -72,10 +72,11 @@ export async function fetchConfig(sections?: string[]): Promise { export async function updateConfigSection( section: string, values: Record, + options?: { restart?: boolean }, ): Promise { await request('/api/hermes/config', { method: 'PUT', - body: JSON.stringify({ section, values }), + body: JSON.stringify({ section, values, ...options }), }) } diff --git a/packages/client/src/api/hermes/gateways.ts b/packages/client/src/api/hermes/gateways.ts deleted file mode 100644 index cb031ee..0000000 --- a/packages/client/src/api/hermes/gateways.ts +++ /dev/null @@ -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 { - const res = await request<{ gateways: GatewayStatus[] }>('/api/hermes/gateways') - return res.gateways -} - -export async function startGateway(name: string): Promise { - 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 { - await request(`/api/hermes/gateways/${name}/stop`, { method: 'POST' }) -} - -export async function checkGatewayHealth(name: string): Promise { - const res = await request<{ gateway: GatewayStatus }>(`/api/hermes/gateways/${name}/health`) - return res.gateway -} diff --git a/packages/client/src/api/hermes/group-chat.ts b/packages/client/src/api/hermes/group-chat.ts index 20aefae..c53ff95 100644 --- a/packages/client/src/api/hermes/group-chat.ts +++ b/packages/client/src/api/hermes/group-chat.ts @@ -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 { diff --git a/packages/client/src/api/hermes/profiles.ts b/packages/client/src/api/hermes/profiles.ts index e9fa7f9..3f55269 100644 --- a/packages/client/src/api/hermes/profiles.ts +++ b/packages/client/src/api/hermes/profiles.ts @@ -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 diff --git a/packages/client/src/api/hermes/sessions.ts b/packages/client/src/api/hermes/sessions.ts index 83e5114..66602f0 100644 --- a/packages/client/src/api/hermes/sessions.ts +++ b/packages/client/src/api/hermes/sessions.ts @@ -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 { +export async function fetchSessions(source?: string, limit?: number, profile?: string): Promise { 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 { +export async function fetchContextLength(profile?: string, provider?: string, model?: string): Promise { 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 diff --git a/packages/client/src/api/hermes/system.ts b/packages/client/src/api/hermes/system.ts index bc9faaf..338de68 100644 --- a/packages/client/src/api/hermes/system.ts +++ b/packages/client/src/api/hermes/system.ts @@ -45,11 +45,19 @@ export interface AvailableModelGroup { model_meta?: Record } +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> model_visibility?: ModelVisibility @@ -76,8 +84,18 @@ export async function fetchConfigModels(): Promise { return request('/api/hermes/config/models') } -export async function fetchAvailableModels(): Promise { - return request('/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 { + const params = new URLSearchParams() + params.set('profile', profile || 'default') + return request(`/api/hermes/available-models?${params.toString()}`) } export async function fetchProviderModels(data: { diff --git a/packages/client/src/components/hermes/chat/ChatInput.vue b/packages/client/src/components/hermes/chat/ChatInput.vue index 089cebf..3a7e077 100644 --- a/packages/client/src/components/hermes/chat/ChatInput.vue +++ b/packages/client/src/components/hermes/chat/ChatInput.vue @@ -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 diff --git a/packages/client/src/components/hermes/chat/ChatPanel.vue b/packages/client/src/components/hermes/chat/ChatPanel.vue index 0e14839..f2c260e 100644 --- a/packages/client/src/components/hermes/chat/ChatPanel.vue +++ b/packages/client/src/components/hermes/chat/ChatPanel.vue @@ -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>(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(null); const renameInputRef = ref | null>(null); -const collapsedGroups = ref>( - new Set(JSON.parse(localStorage.getItem("hermes_collapsed_groups") || "[]")), -); +const sessionProfileFilter = ref(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(() => { - const map = new Map(); - 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, - ), - ); - 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 }, +const unpinnedSessions = computed(() => + sortSessionsWithActiveFirst( + chatStore.sessions.filter( + (session) => !sessionBrowserPrefsStore.isPinned(session.id), + ), + ), ); 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("default"); +const newChatProvider = ref(""); +const newChatModel = ref(""); +const newChatLoading = ref(false); -function handleNewChat() { - chatStore.newChat(); +function getModelGroupsForProfile(profile: string) { + const profileModels = appStore.profileModelGroups.find( + (entry) => entry.profile === profile, + ); + return profileModels?.groups?.length ? profileModels.groups : appStore.modelGroups; } -function handleNewCliChat() { - const session = chatStore.newCliSession() - chatStore.switchSession(session.id) +function getDefaultModelForProfile(profile: string) { + const groups = getModelGroupsForProfile(profile); + const profileModels = appStore.profileModelGroups.find( + (entry) => entry.profile === profile, + ); + const defaultProvider = profileModels?.default_provider || ""; + const defaultModel = profileModels?.default || ""; + const providerGroup = defaultProvider + ? groups.find((group) => group.provider === defaultProvider) + : undefined; + const fallbackGroup = providerGroup || groups.find((group) => group.models.length > 0); + return { + provider: fallbackGroup?.provider || "", + model: fallbackGroup?.models.includes(defaultModel) + ? defaultModel + : fallbackGroup?.models[0] || "", + }; } -const newChatOptions = computed(() => [ - { - label: "API", - key: "api_server", - }, - { - label: "Bridge (beta)", - key: "cli", - }, -]); +const newChatProfileOptions = computed(() => + (profilesStore.profiles.length > 0 ? profilesStore.profiles : [{ name: "default" }]).map((profile) => ({ + label: profile.name, + value: profile.name, + })), +); -function handleNewChatSelect(key: string | number) { - if (key === "cli") { - handleNewCliChat(); - return; +const newChatModelGroups = computed(() => { + return getModelGroupsForProfile(newChatProfile.value); +}); + +const newChatProviderOptions = computed(() => + newChatModelGroups.value.map((group) => ({ + label: group.label || group.provider, + value: group.provider, + })), +); + +const newChatModelOptions = computed(() => { + const group = newChatModelGroups.value.find( + (item) => item.provider === newChatProvider.value, + ); + return (group?.models || []).map((model) => ({ + label: appStore.displayModelName(model, group?.provider), + value: model, + })); +}); + +function syncNewChatModelSelection() { + const defaults = getDefaultModelForProfile(newChatProfile.value); + newChatProvider.value = defaults.provider; + newChatModel.value = defaults.model; +} + +async function openNewChatModal() { + showNewChatModal.value = true; + newChatLoading.value = true; + try { + if (profilesStore.profiles.length === 0) await profilesStore.fetchProfiles(); + if (appStore.modelGroups.length === 0 && appStore.profileModelGroups.length === 0) { + await appStore.loadModels(); + } + newChatProfile.value = + profilesStore.activeProfileName || + profilesStore.profiles.find((profile) => profile.active)?.name || + profilesStore.profiles[0]?.name || + "default"; + syncNewChatModelSelection(); + } finally { + newChatLoading.value = false; } - handleNewChat(); +} + +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')" > @@ -1460,27 +1514,32 @@ async function handleSessionModelCustomSubmit() { line-height: 22px; } -.session-scope-note { - margin: 0 12px 10px; - padding: 8px 10px; - border: 1px solid rgba($accent-primary, 0.16); - border-radius: $radius-sm; - background: rgba($accent-primary, 0.06); - color: $text-secondary; - font-size: 11px; - line-height: 1.45; +.session-profile-filter { + margin: 0 8px 10px; } -.session-scope-link { - display: inline-block; - margin-left: 4px; - color: $accent-primary; - font-weight: 500; - text-decoration: none; +.new-chat-form { + display: flex; + flex-direction: column; + gap: 14px; +} - &:hover { - text-decoration: underline; - } +.new-chat-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.new-chat-label { + font-size: 12px; + color: $text-muted; + font-weight: 500; +} + +.new-chat-actions { + display: flex; + justify-content: flex-end; + gap: 8px; } .session-group-header { diff --git a/packages/client/src/components/hermes/chat/MessageList.vue b/packages/client/src/components/hermes/chat/MessageList.vue index 8442e86..670a4e4 100644 --- a/packages/client/src/components/hermes/chat/MessageList.vue +++ b/packages/client/src/components/hermes/chat/MessageList.vue @@ -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(() => { diff --git a/packages/client/src/components/hermes/chat/SessionListItem.vue b/packages/client/src/components/hermes/chat/SessionListItem.vue index 56e7375..58c2af0 100644 --- a/packages/client/src/components/hermes/chat/SessionListItem.vue +++ b/packages/client/src/components/hermes/chat/SessionListItem.vue @@ -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 | null = null const longPressTriggered = ref(false) @@ -107,6 +113,10 @@ onUnmounted(() => { {{ sessionModelName }} {{ formatTimestampMs(session.createdAt) }} + + + {{ profileName }} + + + diff --git a/packages/client/src/components/hermes/group-chat/CreateRoomForm.vue b/packages/client/src/components/hermes/group-chat/CreateRoomForm.vue index 4c96147..81625f6 100644 --- a/packages/client/src/components/hermes/group-chat/CreateRoomForm.vue +++ b/packages/client/src/components/hermes/group-chat/CreateRoomForm.vue @@ -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(null) const compression = ref({ diff --git a/packages/client/src/components/hermes/group-chat/GroupChatInput.vue b/packages/client/src/components/hermes/group-chat/GroupChatInput.vue index 22fd09a..e7864eb 100644 --- a/packages/client/src/components/hermes/group-chat/GroupChatInput.vue +++ b/packages/client/src/components/hermes/group-chat/GroupChatInput.vue @@ -1,17 +1,38 @@