diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f126564 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,87 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.5.0] - 2025-04-29 + +### Added + +#### Multi-Profile Support +- **Profile-based usage tracking**: Added `profile` field to `session_usage` table for filtering statistics by profile +- **Profile-aware session management**: All sessions now track their originating profile (default, hermes, custom) +- **Group chat agent profiles**: Each agent can run with its own Hermes profile configuration +- **Cross-profile usage aggregation**: Usage stats page correctly filters by active profile + +#### Group Chat Enhancements +- **Context compression with multi-profile**: Group chat compression now uses agent's own profile +- **Usage tracking for compression**: Token usage from context compression runs is recorded with room ID +- **Session profile mapping**: New `gc_session_profiles` table tracks ephemeral session to profile relationships + +#### Single Chat Improvements +- **Ephemeral session cleanup**: Automatic deletion of temporary Hermes sessions after sync +- **User message persistence**: User messages are now properly saved to local database +- **Usage synchronization**: Token usage from Hermes sessions correctly syncs to local usage store + +### Fixed + +#### Token Estimation +- **Fixed overestimation**: Removed `senderName` from token calculation to avoid inflated estimates +- **Configurable estimation**: Token estimation now uses `charsPerToken` config instead of hardcoded value +- **Adjusted compression trigger**: Increased `charsPerToken` from 4 to 6 for more conservative estimation + - This prevents premature compression triggering in group chats + - Better matches actual LLM tokenization (~6-8 chars/token for English) + +#### WSL Compatibility +- **Auto-detect WSL environment**: Database path automatically uses WSL local filesystem when detected +- **Improved SQLite settings**: Changed to WAL mode with `synchronous=NORMAL` and `busy_timeout=5000` + - Fixes cross-filesystem write failures in WSL2 environments + - Better concurrency and reliability + +#### Database Schema +- **Unified table initialization**: Created `initAllStores()` for consistent table creation across all stores +- **Session usage schema**: Added `id` PRIMARY KEY AUTOINCREMENT for better query performance +- **Production environment**: Set `NODE_ENV=production` in production start scripts for correct database path + +#### Logging +- **Enhanced error logging**: Improved error messages in `syncFromHermes` with detailed context +- **Database path logging**: Added explicit logging of Hermes state.db path for debugging + +### Changed + +- **Default compression trigger**: Group chat rooms now default to 100,000 tokens (was 10,000) +- **Database location**: In WSL, database always uses `~/.hermes-web-ui/` to avoid cross-filesystem issues + +### Technical Details + +#### Database Tables +- `sessions`: Added `profile` field +- `session_usage`: Added `profile` field and `id` PRIMARY KEY +- `gc_pending_session_deletes`: Tracks profile-specific session cleanup +- `gc_session_profiles`: Maps ephemeral sessions to profiles and rooms + +#### Code Organization +- Created `packages/server/src/db/hermes/init.ts`: Unified store initialization +- Updated `packages/server/src/db/index.ts`: WSL detection and improved SQLite settings +- Refactored `packages/server/src/services/hermes/context-engine/`: Better token estimation + +--- + +## [0.4.x] - Previous Releases + +### Features +- Real-time streaming chat via SSE +- Multi-session management +- Platform channel integration (Telegram, Discord, Slack, WhatsApp) +- Usage statistics and cost tracking +- Scheduled jobs management +- Skills browsing and memory management +- Integrated terminal with node-pty + +### Technical Stack +- **Frontend**: Vue 3, Naive UI, Pinia, SCSS +- **Backend**: Koa 2, @koa/router, node-pty +- **Database**: SQLite (node:sqlite) +- **Language**: TypeScript (strict mode) diff --git a/README.md b/README.md index c5842ef..a9f938b 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ - Real-time streaming via SSE with async run support - Multi-session management — create, rename, delete, switch between sessions +- **Self-built session database** — local SQLite storage with automatic sync from Hermes state.db on first startup - Session grouping by source (Telegram, Discord, Slack, etc.) with collapsible accordion - Active session indicator — live sessions pin to top with spinner icon - Sessions sorted by latest message time diff --git a/README_zh.md b/README_zh.md index 52a7aca..0f620bf 100644 --- a/README_zh.md +++ b/README_zh.md @@ -43,6 +43,7 @@ - 通过 SSE 实时流式输出,支持异步 Run - 多会话管理 — 创建、重命名、删除、切换会话 +- **自建会话数据库** — 本地 SQLite 存储,首次启动时自动从 Hermes state.db 同步 api_server 会话 - 按来源分组会话(Telegram、Discord、Slack 等),可折叠手风琴面板 - 活跃会话实时指示器 — 正在进行的会话置顶并显示旋转图标 - 按最新消息时间排序会话列表 diff --git a/bin/hermes-web-ui.mjs b/bin/hermes-web-ui.mjs index 7f8c8bc..612d71f 100755 --- a/bin/hermes-web-ui.mjs +++ b/bin/hermes-web-ui.mjs @@ -205,7 +205,7 @@ function startDaemon(port) { const child = spawn(process.execPath, [serverEntry], { detached: true, stdio: ['ignore', logStream, logStream], - env: { ...process.env, PORT: String(port), AUTH_TOKEN: token }, + env: { ...process.env, NODE_ENV: 'production', PORT: String(port), AUTH_TOKEN: token }, windowsHide: true, }) @@ -393,7 +393,7 @@ switch (command) { const port = !isNaN(command) ? parseInt(command) : DEFAULT_PORT const child = spawn(process.execPath, [serverEntry], { stdio: 'inherit', - env: { ...process.env, PORT: String(port) }, + env: { ...process.env, NODE_ENV: 'production', PORT: String(port) }, windowsHide: true, }) child.on('exit', (code) => process.exit(code ?? 1)) diff --git a/docs/openapi.json b/docs/openapi.json index e597ca0..f2f606a 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,8 +2,8 @@ "openapi": "3.0.3", "info": { "title": "Hermes Web UI API", - "description": "BFF server API for Hermes Web UI — chat sessions, platform channels, model management, skills, memory, logs, file browser, and terminal.", - "version": "0.4.4" + "description": "BFF server API for Hermes Web UI — chat sessions, scheduled jobs, platform channels, model management, skills, memory, logs, file browser, group chat, and terminal.", + "version": "0.5.0" }, "servers": [ { "url": "http://localhost:8648", "description": "Local development" } @@ -27,9 +27,12 @@ { "name": "Profiles", "description": "Hermes profile management" }, { "name": "Gateways", "description": "Gateway process management" }, { "name": "Update", "description": "Self-update management" }, + { "name": "Jobs", "description": "Scheduled job management (cron, one-time tasks)" }, { "name": "Terminal", "description": "WebSocket terminal (node-pty)" }, { "name": "Webhook", "description": "Webhook receiver" }, - { "name": "Proxy", "description": "Reverse proxy to Hermes API" } + { "name": "Proxy", "description": "Reverse proxy to Hermes API" }, + { "name": "Copilot Auth", "description": "GitHub Copilot device-code OAuth flow" }, + { "name": "Group Chat", "description": "Multi-agent group chat rooms" } ], "paths": { "/api/auth/status": { @@ -1091,6 +1094,336 @@ } } }, + "/api/hermes/jobs": { + "get": { + "tags": ["Jobs"], + "summary": "List all scheduled jobs", + "operationId": "listJobs", + "responses": { + "200": { "description": "Job list", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/JobListResponse" } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" } + }, + "security": [{ "BearerAuth": [] }] + }, + "post": { + "tags": ["Jobs"], + "summary": "Create a new scheduled job", + "operationId": "createJob", + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateJobRequest" } } } }, + "responses": { + "200": { "description": "Job created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/JobResponse" } } } }, + "400": { "description": "Invalid request", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" } + }, + "security": [{ "BearerAuth": [] }] + } + }, + "/api/hermes/jobs/{id}": { + "get": { + "tags": ["Jobs"], + "summary": "Get job detail", + "operationId": "getJob", + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { + "200": { "description": "Job detail", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/JobResponse" } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "description": "Job not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + }, + "security": [{ "BearerAuth": [] }] + }, + "patch": { + "tags": ["Jobs"], + "summary": "Update job", + "operationId": "updateJob", + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateJobRequest" } } } }, + "responses": { + "200": { "description": "Job updated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/JobResponse" } } } }, + "400": { "description": "Invalid request", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "description": "Job not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + }, + "security": [{ "BearerAuth": [] }] + }, + "delete": { + "tags": ["Jobs"], + "summary": "Delete job", + "operationId": "deleteJob", + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { + "200": { "description": "Job deleted", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" } } } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "description": "Job not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + }, + "security": [{ "BearerAuth": [] }] + } + }, + "/api/hermes/jobs/{id}/pause": { + "post": { + "tags": ["Jobs"], + "summary": "Pause a job", + "operationId": "pauseJob", + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { + "200": { "description": "Job paused", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/JobResponse" } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "description": "Job not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + }, + "security": [{ "BearerAuth": [] }] + } + }, + "/api/hermes/jobs/{id}/resume": { + "post": { + "tags": ["Jobs"], + "summary": "Resume a paused job", + "operationId": "resumeJob", + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { + "200": { "description": "Job resumed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/JobResponse" } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "description": "Job not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + }, + "security": [{ "BearerAuth": [] }] + } + }, + "/api/hermes/jobs/{id}/run": { + "post": { + "tags": ["Jobs"], + "summary": "Trigger a job run immediately", + "operationId": "runJob", + "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { + "200": { "description": "Job triggered", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/JobResponse" } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "description": "Job not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + }, + "security": [{ "BearerAuth": [] }] + } + }, + "/api/hermes/auth/copilot/start": { + "post": { + "tags": ["Copilot Auth"], + "summary": "Start GitHub Copilot OAuth device flow", + "operationId": "copilotAuthStart", + "security": [{ "BearerAuth": [] }], + "responses": { + "200": { "description": "Device code flow started", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OAuthStartResponse" } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "500": { "description": "Failed to start", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + } + } + }, + "/api/hermes/auth/copilot/poll/{sessionId}": { + "get": { + "tags": ["Copilot Auth"], + "summary": "Poll GitHub Copilot OAuth status", + "operationId": "copilotAuthPoll", + "parameters": [{ "name": "sessionId", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { + "200": { "description": "OAuth poll result", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CopilotOAuthPollResponse" } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" } + }, + "security": [{ "BearerAuth": [] }] + } + }, + "/api/hermes/auth/copilot/check-token": { + "get": { + "tags": ["Copilot Auth"], + "summary": "Check GitHub Copilot token validity", + "operationId": "copilotCheckToken", + "security": [{ "BearerAuth": [] }], + "responses": { + "200": { "description": "Token status", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CopilotTokenStatusResponse" } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/api/hermes/auth/copilot/enable": { + "post": { + "tags": ["Copilot Auth"], + "summary": "Enable GitHub Copilot auth", + "operationId": "copilotEnable", + "security": [{ "BearerAuth": [] }], + "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "enabled": { "type": "boolean" } }, "required": ["enabled"] } } } }, + "responses": { + "200": { "description": "Auth enabled", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" } } } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "/api/hermes/group-chat/rooms": { + "get": { + "tags": ["Group Chat"], + "summary": "List all group chat rooms", + "operationId": "listGroupChatRooms", + "responses": { + "200": { "description": "Room list", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GroupChatRoomListResponse" } } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "503": { "description": "Group chat not initialized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + }, + "security": [{ "BearerAuth": [] }] + }, + "post": { + "tags": ["Group Chat"], + "summary": "Create a new group chat room", + "operationId": "createGroupChatRoom", + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateGroupChatRoomRequest" } } } }, + "responses": { + "200": { "description": "Room created", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GroupChatRoomDetailResponse" } } } } }, + "400": { "description": "Missing required fields", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "503": { "description": "Group chat not initialized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + }, + "security": [{ "BearerAuth": [] }] + } + }, + "/api/hermes/group-chat/rooms/{roomId}": { + "get": { + "tags": ["Group Chat"], + "summary": "Get group chat room detail", + "operationId": "getGroupChatRoom", + "parameters": [{ "name": "roomId", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { + "200": { "description": "Room detail", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GroupChatRoomDetailResponse" } } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "description": "Room not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "503": { "description": "Group chat not initialized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + }, + "security": [{ "BearerAuth": [] }] + }, + "delete": { + "tags": ["Group Chat"], + "summary": "Delete a group chat room", + "operationId": "deleteGroupChatRoom", + "parameters": [{ "name": "roomId", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { + "200": { "description": "Room deleted", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" } } } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "503": { "description": "Group chat not initialized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + }, + "security": [{ "BearerAuth": [] }] + } + }, + "/api/hermes/group-chat/rooms/join/{code}": { + "get": { + "tags": ["Group Chat"], + "summary": "Get room by invite code", + "operationId": "joinGroupChatRoom", + "parameters": [{ "name": "code", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { + "200": { "description": "Room detail", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GroupChatRoomResponse" } } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "description": "Room not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "503": { "description": "Group chat not initialized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + }, + "security": [{ "BearerAuth": [] }] + } + }, + "/api/hermes/group-chat/rooms/{roomId}/invite-code": { + "put": { + "tags": ["Group Chat"], + "summary": "Update room invite code", + "operationId": "updateRoomInviteCode", + "parameters": [{ "name": "roomId", "in": "path", "required": true, "schema": { "type": "string" } }], + "requestBody": { "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { "inviteCode": { "type": "string" } }, "required": ["inviteCode"] } } } }, + "responses": { + "200": { "description": "Invite code updated", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" } } } } } }, + "400": { "description": "Missing inviteCode", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "503": { "description": "Group chat not initialized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + }, + "security": [{ "BearerAuth": [] }] + } + }, + "/api/hermes/group-chat/rooms/{roomId}/agents": { + "get": { + "tags": ["Group Chat"], + "summary": "List agents in room", + "operationId": "listRoomAgents", + "parameters": [{ "name": "roomId", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { + "200": { "description": "Agent list", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GroupChatAgentListResponse" } } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "503": { "description": "Group chat not initialized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + }, + "security": [{ "BearerAuth": [] }] + }, + "post": { + "tags": ["Group Chat"], + "summary": "Add agent to room", + "operationId": "addRoomAgent", + "parameters": [{ "name": "roomId", "in": "path", "required": true, "schema": { "type": "string" } }], + "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AddRoomAgentRequest" } } } }, + "responses": { + "200": { "description": "Agent added", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GroupChatAgentResponse" } } } } }, + "400": { "description": "Missing profile", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "409": { "description": "Agent already in room", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "503": { "description": "Group chat not initialized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + }, + "security": [{ "BearerAuth": [] }] + } + }, + "/api/hermes/group-chat/rooms/{roomId}/agents/{agentId}": { + "delete": { + "tags": ["Group Chat"], + "summary": "Remove agent from room", + "operationId": "removeRoomAgent", + "parameters": [ + { "name": "roomId", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "agentId", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { "description": "Agent removed", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" } } } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "503": { "description": "Group chat not initialized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + }, + "security": [{ "BearerAuth": [] }] + } + }, + "/api/hermes/group-chat/rooms/{roomId}/config": { + "put": { + "tags": ["Group Chat"], + "summary": "Update room compression config", + "operationId": "updateRoomConfig", + "parameters": [{ "name": "roomId", "in": "path", "required": true, "schema": { "type": "string" } }], + "requestBody": { "required": false, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UpdateRoomConfigRequest" } } } }, + "responses": { + "200": { "description": "Room config updated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/GroupChatRoomResponse" } } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "503": { "description": "Group chat not initialized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + }, + "security": [{ "BearerAuth": [] }] + } + }, + "/api/hermes/group-chat/rooms/{roomId}/compress": { + "post": { + "tags": ["Group Chat"], + "summary": "Force compress room context", + "operationId": "compressRoomContext", + "parameters": [{ "name": "roomId", "in": "path", "required": true, "schema": { "type": "string" } }], + "responses": { + "200": { "description": "Context compressed", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CompressRoomResponse" } } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" }, + "404": { "description": "Room not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, + "503": { "description": "Group chat not initialized", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } + }, + "security": [{ "BearerAuth": [] }] + } + }, + "/api/hermes/auth/copilot/disable": { + "post": { + "tags": ["Copilot Auth"], + "summary": "Disable GitHub Copilot auth", + "operationId": "copilotDisable", + "security": [{ "BearerAuth": [] }], + "responses": { + "200": { "description": "Auth disabled", "content": { "application/json": { "schema": { "type": "object", "properties": { "success": { "type": "boolean" } } } } } }, + "401": { "$ref": "#/components/responses/Unauthorized" } + } + } + }, + "components": { "components": { "securitySchemes": { "BearerAuth": { @@ -1658,6 +1991,139 @@ "success": { "type": "boolean" }, "message": { "type": "string" } } + }, + "JobListResponse": { + "type": "object", + "properties": { + "jobs": { + "type": "array", + "items": { "type": "object" } + } + } + }, + "JobResponse": { + "type": "object", + "properties": { + "job": { "type": "object" } + } + }, + "CreateJobRequest": { + "type": "object", + "properties": { + "cron": { "type": "string", "description": "Cron expression (e.g. \"0 9 * * *\" for daily at 9am)" }, + "prompt": { "type": "string", "description": "Task prompt to execute" }, + "recurring": { "type": "boolean", "description": "Whether this is a recurring job" } + }, + "required": ["cron", "prompt"] + }, + "UpdateJobRequest": { + "type": "object", + "properties": { + "cron": { "type": "string" }, + "prompt": { "type": "string" }, + "recurring": { "type": "boolean" } + } + }, + "CopilotOAuthPollResponse": { + "type": "object", + "properties": { + "status": { "type": "string", "enum": ["pending", "approved", "expired", "error"] }, + "error": { "type": "string", "nullable": true } + } + }, + "CopilotTokenStatusResponse": { + "type": "object", + "properties": { + "valid": { "type": "boolean" } + } + }, + "GroupChatRoomListResponse": { + "type": "object", + "properties": { + "rooms": { "type": "array", "items": { "type": "object" } } + } + }, + "GroupChatRoomDetailResponse": { + "type": "object", + "properties": { + "room": { "type": "object", "description": "Room detail" }, + "messages": { "type": "array", "items": { "type": "object" }, "description": "Room messages" }, + "agents": { "type": "array", "items": { "type": "object" }, "description": "Room agents" }, + "members": { "type": "array", "items": { "type": "object" }, "description": "Room members" } + } + }, + "GroupChatRoomResponse": { + "type": "object", + "properties": { + "room": { "type": "object" } + } + }, + "CreateGroupChatRoomRequest": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Room name" }, + "inviteCode": { "type": "string", "description": "Invite code for joining" }, + "agents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "profile": { "type": "string" }, + "name": { "type": "string" }, + "description": { "type": "string" }, + "invited": { "type": "boolean" } + } + }, + "description": "Initial agents to add" + }, + "compression": { + "type": "object", + "properties": { + "triggerTokens": { "type": "number" }, + "maxHistoryTokens": { "type": "number" }, + "tailMessageCount": { "type": "number" } + }, + "description": "Compression configuration" + } + }, + "required": ["name", "inviteCode"] + }, + "GroupChatAgentListResponse": { + "type": "object", + "properties": { + "agents": { "type": "array", "items": { "type": "object" } } + } + }, + "GroupChatAgentResponse": { + "type": "object", + "properties": { + "agent": { "type": "object" } + } + }, + "AddRoomAgentRequest": { + "type": "object", + "properties": { + "profile": { "type": "string", "description": "Hermes profile name" }, + "name": { "type": "string", "description": "Agent display name" }, + "description": { "type": "string", "description": "Agent description" }, + "invited": { "type": "boolean", "description": "Whether agent is invited" } + }, + "required": ["profile"] + }, + "UpdateRoomConfigRequest": { + "type": "object", + "properties": { + "triggerTokens": { "type": "number" }, + "maxHistoryTokens": { "type": "number" }, + "tailMessageCount": { "type": "number" } + } + }, + "CompressRoomResponse": { + "type": "object", + "properties": { + "success": { "type": "boolean" }, + "summary": { "type": "string", "description": "Compression summary" } + } } } } diff --git a/package.json b/package.json index ed3b213..f38e887 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hermes-web-ui", - "version": "0.4.9", + "version": "0.5.0", "description": "Self-hosted AI chat dashboard for Hermes Agent — multi-model (Claude, GPT, Gemini, DeepSeek) web UI with Telegram, Discord, Slack, WhatsApp integration", "repository": { "type": "git", @@ -51,7 +51,7 @@ "dev:server": "nodemon --signal SIGTERM --watch packages/server/src -e ts,tsx --exec TS_NODE_PROJECT=packages/server/tsconfig.json node -r ts-node/register packages/server/src/index.ts", "build": "vue-tsc -b && vite build && tsc --noEmit -p packages/server/tsconfig.json && node scripts/build-server.mjs", "prepare": "[ -d dist ] || npm run build", - "preview": "vite preview", + "preview": "NODE_ENV=production vite preview", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage" @@ -62,15 +62,16 @@ ], "dependencies": { "eventsource": "^4.1.0", + "js-tiktoken": "^1.0.21", "node-pty": "^1.1.0", "socket.io": "^4.8.3", "socket.io-client": "^4.8.3" }, "devDependencies": { - "@multiavatar/multiavatar": "^1.0.7", "@koa/bodyparser": "^5.0.0", "@koa/cors": "^5.0.0", "@koa/router": "^15.4.0", + "@multiavatar/multiavatar": "^1.0.7", "@pinia/testing": "^1.0.3", "@types/eventsource": "^1.1.15", "@types/js-yaml": "^4.0.9", diff --git a/packages/client/src/api/hermes/chat.ts b/packages/client/src/api/hermes/chat.ts index 48e706f..475499c 100644 --- a/packages/client/src/api/hermes/chat.ts +++ b/packages/client/src/api/hermes/chat.ts @@ -1,3 +1,4 @@ +import { io, type Socket } from 'socket.io-client' import { request, getBaseUrlValue, getApiKey } from '../client' export interface ChatMessage { @@ -8,7 +9,6 @@ export interface ChatMessage { export interface StartRunRequest { input: string | ChatMessage[] instructions?: string - conversation_history?: ChatMessage[] session_id?: string model?: string } @@ -38,70 +38,152 @@ export interface RunEvent { output_tokens: number total_tokens: number } + /** session_id tag added by server for client-side filtering */ + session_id?: string } -export async function startRun(body: StartRunRequest): Promise { - const headers: Record = {} - if (body.session_id) { - headers['X-Hermes-Session-Id'] = body.session_id +// ============================ +// Socket.IO chat run connection +// ============================ + +let chatRunSocket: Socket | null = null + +export function getChatRunSocket(): Socket | null { + return chatRunSocket +} + +export function connectChatRun(): Socket { + if (chatRunSocket?.connected) return chatRunSocket + + // Clean up old socket to prevent duplicate event listeners + if (chatRunSocket) { + chatRunSocket.removeAllListeners() + chatRunSocket.disconnect() } - return request('/api/hermes/v1/runs', { - method: 'POST', - body: JSON.stringify(body), - headers, + + const baseUrl = getBaseUrlValue() + const token = getApiKey() + const profile = localStorage.getItem('hermes_active_profile_name') || 'default' + + chatRunSocket = io(`${baseUrl}/chat-run`, { + auth: { token }, + query: { profile }, + transports: ['websocket', 'polling'], + reconnection: true, + reconnectionAttempts: Infinity, + reconnectionDelay: 1000, + reconnectionDelayMax: 10000, }) + + return chatRunSocket } -export function streamRunEvents( - runId: string, +export function disconnectChatRun(): void { + if (chatRunSocket) { + chatRunSocket.disconnect() + chatRunSocket = null + } +} + +/** + * Start a chat run via Socket.IO and stream events back. + * Returns an AbortController-compatible handle for cancellation. + */ +/** + * Resume a session via Socket.IO. Returns messages, working status, and events. + */ +export function resumeSession( + sessionId: string, + onResumed: (data: { session_id: string; messages: any[]; isWorking: boolean; events: any[]; inputTokens?: number; outputTokens?: number }) => void, +): Socket { + const socket = connectChatRun() + + socket.once('resumed', onResumed) + socket.emit('resume', { session_id: sessionId }) + + return socket +} + +export function startRunViaSocket( + body: StartRunRequest, onEvent: (event: RunEvent) => void, onDone: () => void, onError: (err: Error) => void, -) { - const baseUrl = getBaseUrlValue() - const token = getApiKey() - const profile = localStorage.getItem('hermes_active_profile_name') - const params = new URLSearchParams() - if (token) params.set('token', token) - if (profile && profile !== 'default') params.set('profile', profile) - const qs = params.toString() - const url = `${baseUrl}/api/hermes/v1/runs/${runId}/events${qs ? `?${qs}` : ''}` - + onStarted?: (runId: string) => void, +): { abort: () => void } { + const socket = connectChatRun() let closed = false - const source = new EventSource(url) - source.onmessage = (e) => { + function cleanup() { if (closed) return - try { - const parsed = JSON.parse(e.data) - onEvent(parsed) + closed = true + socket.off('run.started', onRunStarted) + socket.off('run.failed', onRunFailed) + socket.off('message.delta', onMessageDelta) + socket.off('reasoning.delta', onReasoningDelta) + socket.off('thinking.delta', onReasoningDelta) + socket.off('reasoning.available', onReasoningAvailable) + socket.off('tool.started', onToolStarted) + socket.off('tool.completed', onToolCompleted) + socket.off('run.completed', onRunCompleted) + socket.off('compression.started', onCompressionStarted) + socket.off('compression.completed', onCompressionCompleted) + socket.off('usage.updated', onUsageUpdated) + } - if (parsed.event === 'run.completed' || parsed.event === 'run.failed') { - closed = true - source.close() - onDone() - } - } catch { - onEvent({ event: 'message', delta: e.data }) + // All event handlers share the same cleanup logic + const handleEvent = (event: RunEvent) => { + if (closed) return + onEvent(event) + if (event.event === 'run.completed' || event.event === 'run.failed') { + cleanup() + onDone() } } - source.onerror = () => { - if (closed) return - closed = true - source.close() - onError(new Error('SSE connection error')) + function onRunStarted(data: RunEvent) { + handleEvent(data) + onStarted?.(data.run_id || '') } + function onRunFailed(data: RunEvent) { + handleEvent(data) + onError?.(new Error(data.error || 'Run failed')) + } + function onMessageDelta(data: RunEvent) { handleEvent(data) } + function onReasoningDelta(data: RunEvent) { handleEvent(data) } + function onThinkingDelta(data: RunEvent) { handleEvent(data) } + function onReasoningAvailable(data: RunEvent) { handleEvent(data) } + function onToolStarted(data: RunEvent) { handleEvent(data) } + function onToolCompleted(data: RunEvent) { handleEvent(data) } + function onRunCompleted(data: RunEvent) { handleEvent(data) } + function onCompressionStarted(data: RunEvent) { handleEvent(data) } + function onCompressionCompleted(data: RunEvent) { handleEvent(data) } + function onUsageUpdated(data: RunEvent) { handleEvent(data) } + + socket.on('run.started', onRunStarted) + socket.on('run.failed', onRunFailed) + socket.on('message.delta', onMessageDelta) + socket.on('reasoning.delta', onReasoningDelta) + socket.on('thinking.delta', onThinkingDelta) + socket.on('reasoning.available', onReasoningAvailable) + socket.on('tool.started', onToolStarted) + socket.on('tool.completed', onToolCompleted) + socket.on('run.completed', onRunCompleted) + socket.on('compression.started', onCompressionStarted) + socket.on('compression.completed', onCompressionCompleted) + socket.on('usage.updated', onUsageUpdated) + + // Emit run:start with ack callback to get run_id + socket.emit('run', body) - // Return AbortController-compatible object return { abort: () => { if (!closed) { - closed = true - source.close() + socket.emit('abort', { session_id: body.session_id }) + cleanup() } }, - } as unknown as AbortController + } } export async function fetchModels(): Promise<{ data: Array<{ id: string }> }> { diff --git a/packages/client/src/api/hermes/sessions.ts b/packages/client/src/api/hermes/sessions.ts index 786c8ef..a545c61 100644 --- a/packages/client/src/api/hermes/sessions.ts +++ b/packages/client/src/api/hermes/sessions.ts @@ -95,6 +95,36 @@ export async function renameSession(id: string, title: string): Promise } } +export interface UsageStatsResponse { + total_input_tokens: number + total_output_tokens: number + total_cache_read_tokens: number + total_cache_write_tokens: number + total_reasoning_tokens: number + total_sessions: number + total_cost: number + model_usage: Array<{ + model: string + input_tokens: number + output_tokens: number + cache_read_tokens: number + cache_write_tokens: number + reasoning_tokens: number + sessions: number + }> + daily_usage: Array<{ + date: string + tokens: number + cache: number + sessions: number + cost: number + }> +} + +export async function fetchUsageStats(): Promise { + return request('/api/hermes/usage/stats') +} + export async function fetchSessionUsage(ids: string[]): Promise> { if (ids.length === 0) return {} const params = new URLSearchParams() diff --git a/packages/client/src/components/hermes/chat/ChatPanel.vue b/packages/client/src/components/hermes/chat/ChatPanel.vue index 0f92921..de5c222 100644 --- a/packages/client/src/components/hermes/chat/ChatPanel.vue +++ b/packages/client/src/components/hermes/chat/ChatPanel.vue @@ -28,7 +28,6 @@ const currentMode = ref<'chat' | 'live'>('chat') const showSessions = ref( typeof window === 'undefined' || !window.matchMedia('(max-width: 768px)').matches, ) -const lastChatSessionsVisibility = ref(showSessions.value) let mobileQuery: MediaQueryList | null = null const isMobile = ref(false) @@ -37,17 +36,6 @@ function handleSessionClick(sessionId: string) { if (mobileQuery?.matches) showSessions.value = false } -function handleModeChange(mode: 'chat' | 'live') { - if (mode === currentMode.value) return - if (mode === 'live') { - lastChatSessionsVisibility.value = showSessions.value - showSessions.value = false - } else { - showSessions.value = mobileQuery?.matches ? false : lastChatSessionsVisibility.value - } - currentMode.value = mode -} - function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) { isMobile.value = e.matches if (e.matches && showSessions.value) { @@ -79,9 +67,6 @@ function sourceSortKey(source: string): number { function sortSessionsWithActiveFirst(items: Session[]): Session[] { return [...items].sort((a, b) => { - const aLive = chatStore.isSessionLive(a.id) - const bLive = chatStore.isSessionLive(b.id) - if (aLive !== bLive) return aLive ? -1 : 1 return (b.updatedAt || 0) - (a.updatedAt || 0) }) } @@ -107,9 +92,6 @@ const groupedSessions = computed(() => { } const keys = [...map.keys()].sort((a, b) => { - const aHasLive = map.get(a)?.some(s => chatStore.isSessionLive(s.id)) || false - const bHasLive = map.get(b)?.some(s => chatStore.isSessionLive(s.id)) || false - if (aHasLive !== bHasLive) return aHasLive ? -1 : 1 const ka = sourceSortKey(a) const kb = sourceSortKey(b) if (ka !== kb) return ka - kb @@ -288,9 +270,9 @@ async function handleRenameConfirm() { :key="`pinned-${s.id}`" :session="s" :active="s.id === chatStore.activeSessionId" - :live="chatStore.isSessionLive(s.id)" :pinned="true" :can-delete="s.id !== chatStore.activeSessionId || chatStore.sessions.length > 1" + :streaming="chatStore.isSessionLive(s.id)" @select="handleSessionClick(s.id)" @contextmenu="handleContextMenu($event, s.id)" @delete="handleDeleteSession(s.id)" @@ -309,9 +291,9 @@ async function handleRenameConfirm() { :key="s.id" :session="s" :active="s.id === chatStore.activeSessionId" - :live="chatStore.isSessionLive(s.id)" :pinned="false" :can-delete="s.id !== chatStore.activeSessionId || chatStore.sessions.length > 1" + :streaming="chatStore.isSessionLive(s.id)" @select="handleSessionClick(s.id)" @contextmenu="handleContextMenu($event, s.id)" @delete="handleDeleteSession(s.id)" @@ -360,20 +342,7 @@ async function handleRenameConfirm() { {{ getSourceLabel(activeSessionSource) }}
-
- {{ t('chat.chatMode') }} - {{ t('chat.liveMode') }} -
+