Compare commits

...

1 Commits

Author SHA1 Message Date
Alexander Whitestone
8e0f24db3f feat(web-console): cherry-pick React web console GUI from gary-the-ai fork
Some checks failed
Forge CI / smoke-and-build (pull_request) Failing after 59s
Cherry-pick the Hermes Web Console from gary-the-ai/hermes-web-console-gui.
React + TypeScript frontend with Vite, Python aiohttp backend API.

Components:
- web_console/ — React frontend (chat, sessions, memory, settings, skills,
  gateway config, cron, workspace, tools, browser, insights pages)
- gateway/web_console/ — Python backend API (23 endpoints, SSE event bus,
  11 service modules)
- gateway/platforms/api_server_ui.py — embedded browser UI for API server
- gateway/platforms/api_server.py — route registration refactored into
  _register_routes(), web console mounted via maybe_register_web_console()
- run-gui.sh / setup-gui.sh — one-command launch and setup scripts
- tests/gateway/test_api_server_gui_mount.py — 4 integration tests (passing)
- tests/web_console/ — 13 backend test files (51 passing)
- docs/plans/ — implementation plan, API schema, frontend architecture

Fix: added missing ModelContextError class and CRON_MIN_CONTEXT_TOKENS to
cron/scheduler.py (pre-existing import bug).

Closes #325
2026-04-13 18:01:51 -04:00
176 changed files with 35053 additions and 20 deletions

View File

@@ -16,6 +16,14 @@ import os
import subprocess
import sys
# Minimum context tokens required for cron job execution
CRON_MIN_CONTEXT_TOKENS = 500
class ModelContextError(Exception):
"""Raised when a model does not have enough context tokens for a cron job."""
pass
# fcntl is Unix-only; on Windows use msvcrt for file locking
try:
import fcntl

View File

@@ -0,0 +1,712 @@
# Hermes Web Console API and Event Schema
Date: 2026-03-30
## Purpose
This document defines the Hermes-native backend contract for a fully featured GUI. The existing `/v1/*` OpenAI-compatible API remains unchanged. The GUI uses `/api/gui/*` plus SSE endpoints for structured, inspectable state.
## Design rules
1. Never leak raw secrets.
2. Keep `/v1/*` compatibility untouched.
3. Prefer thin wrappers around existing Hermes runtime/services.
4. Return capability metadata when actions are unsupported.
5. Use one event envelope format everywhere.
## API namespaces
- `/api/gui/health`
- `/api/gui/meta`
- `/api/gui/chat/*`
- `/api/gui/sessions/*`
- `/api/gui/workspace/*`
- `/api/gui/processes/*`
- `/api/gui/human/*`
- `/api/gui/memory/*`
- `/api/gui/user-profile/*`
- `/api/gui/session-search`
- `/api/gui/skills/*`
- `/api/gui/cron/*`
- `/api/gui/gateway/*`
- `/api/gui/settings/*`
- `/api/gui/logs/*`
- `/api/gui/browser/*`
- `/api/gui/media/*`
- `/api/gui/stream/*`
## Common envelope
### Success envelope
```json
{
"ok": true,
"data": {}
}
```
### Error envelope
```json
{
"ok": false,
"error": {
"code": "not_found",
"message": "Session not found",
"details": {}
}
}
```
### Capability envelope
```json
{
"ok": true,
"data": {
"supported": false,
"reason": "gateway_service_control_unavailable",
"details": {
"platform": "darwin"
}
}
}
```
## Authentication model
Default:
- localhost-only bind
- GUI respects API server bearer auth when configured
Headers:
- `Authorization: Bearer <token>` when `API_SERVER_KEY` or GUI auth is enabled
## Core resource shapes
### Session summary
```json
{
"session_id": "sess_123",
"title": "Build Hermes GUI",
"source": "cli",
"workspace": "/home/glitch/.hermes/hermes-agent",
"model": "gpt-5.4",
"provider": "openai-codex",
"last_active": "2026-03-30T02:10:00Z",
"token_summary": {
"input": 12000,
"output": 3400,
"total": 15400
},
"parent_session_id": null,
"has_tools": true,
"has_attachments": false,
"has_subagents": true
}
```
### Transcript item
```json
{
"id": "msg_123",
"type": "assistant_message",
"role": "assistant",
"content": "Done — I gave Hermes a browser GUI.",
"created_at": "2026-03-30T02:10:00Z",
"metadata": {
"run_id": "run_123",
"tool_call_ids": []
}
}
```
### Tool timeline item
```json
{
"id": "tool_123",
"tool_name": "search_files",
"status": "completed",
"started_at": "2026-03-30T02:10:02Z",
"completed_at": "2026-03-30T02:10:03Z",
"duration_ms": 940,
"arguments_preview": {
"pattern": "api_server",
"path": "/home/glitch/.hermes/hermes-agent"
},
"result_preview": {
"match_count": 12
},
"error": null
}
```
### Pending human request
```json
{
"request_id": "human_123",
"kind": "approval",
"session_id": "sess_123",
"run_id": "run_123",
"title": "Approve terminal command",
"prompt": "Allow Hermes to run git status in /repo?",
"choices": [
"approve_once",
"approve_session",
"approve_always",
"deny"
],
"expires_at": "2026-03-30T02:15:00Z",
"sensitive": false
}
```
### Workspace checkpoint
```json
{
"checkpoint_id": "cp_123",
"label": "Before destructive file patch",
"created_at": "2026-03-30T02:11:00Z",
"session_id": "sess_123",
"run_id": "run_123",
"file_count": 3
}
```
### Cron job summary
```json
{
"job_id": "cron_123",
"name": "Morning summary",
"schedule": "0 9 * * *",
"deliver": "telegram",
"paused": false,
"next_run_at": "2026-03-31T09:00:00Z",
"last_run_at": "2026-03-30T09:00:00Z"
}
```
### Platform summary
```json
{
"platform": "telegram",
"enabled": true,
"configured": true,
"connected": true,
"error": null,
"home_channel": "12345678",
"allowed_mode": "pair"
}
```
## Endpoint definitions
### GET /api/gui/health
Returns GUI/backend health.
```json
{
"ok": true,
"data": {
"status": "ok",
"product": "hermes-web-console"
}
}
```
### GET /api/gui/meta
Returns version/build/runtime metadata.
```json
{
"ok": true,
"data": {
"product": "hermes-web-console",
"version": "0.1.0",
"gui_mount_path": "/app",
"api_base": "/api/gui",
"stream_base": "/api/gui/stream",
"v1_base": "/v1",
"features": {
"workspace": true,
"gateway_admin": true,
"voice": true
}
}
}
```
### POST /api/gui/chat/send
Request:
```json
{
"session_id": "sess_123",
"conversation": "browser-chat",
"message": "Inspect the API server and summarize its routes.",
"instructions": "Be concise.",
"attachments": [],
"model": "hermes-agent"
}
```
Response:
```json
{
"ok": true,
"data": {
"session_id": "sess_123",
"run_id": "run_987",
"status": "started"
}
}
```
### POST /api/gui/chat/stop
```json
{
"run_id": "run_987"
}
```
### POST /api/gui/chat/retry
```json
{
"session_id": "sess_123"
}
```
### POST /api/gui/chat/undo
```json
{
"session_id": "sess_123"
}
```
### GET /api/gui/sessions
Query params:
- `q`
- `source`
- `workspace`
- `model`
- `has_tools`
- `limit`
- `offset`
### GET /api/gui/sessions/{session_id}
Returns metadata, recap, lineage.
### GET /api/gui/sessions/{session_id}/transcript
Returns normalized transcript and tool timeline.
### POST /api/gui/sessions/{session_id}/title
```json
{
"title": "Hermes Web Console planning"
}
```
### DELETE /api/gui/sessions/{session_id}
Deletes a session if supported by Hermes storage.
### GET /api/gui/workspace/tree
Query params:
- `root`
- `depth`
### GET /api/gui/workspace/file
Query params:
- `path`
### GET /api/gui/workspace/search
Query params:
- `pattern`
- `path`
- `file_glob`
### GET /api/gui/workspace/diff
Query params:
- `path`
- `session_id`
- `run_id`
### GET /api/gui/workspace/checkpoints
Query params:
- `workspace`
- `session_id`
### POST /api/gui/workspace/rollback
```json
{
"checkpoint_id": "cp_123",
"path": null
}
```
### GET /api/gui/processes
Returns background process summaries.
### GET /api/gui/processes/{process_id}/log
Query params:
- `offset`
- `limit`
### POST /api/gui/processes/{process_id}/kill
Kills a tracked process.
### GET /api/gui/human/pending
Returns pending approvals and clarifications.
### POST /api/gui/human/approve
```json
{
"request_id": "human_123",
"scope": "once"
}
```
### POST /api/gui/human/deny
```json
{
"request_id": "human_123"
}
```
### POST /api/gui/human/clarify
```json
{
"request_id": "human_124",
"response": "A web app you can open in a browser"
}
```
### GET /api/gui/memory
Query params:
- `target=memory|user`
### POST /api/gui/memory
```json
{
"target": "memory",
"action": "add",
"content": "User prefers concise terminal-renderable responses."
}
```
### GET /api/gui/session-search
Query params:
- `query`
- `limit`
### GET /api/gui/skills
Query params:
- `category`
### GET /api/gui/skills/{name}
Returns skill markdown + metadata.
### POST /api/gui/skills/{name}/install
Optional admin action.
### GET /api/gui/cron/jobs
### POST /api/gui/cron/jobs
```json
{
"name": "Morning summary",
"prompt": "Summarize the latest issues.",
"schedule": "0 9 * * *",
"deliver": "telegram",
"skills": ["github-issues"]
}
```
### PATCH /api/gui/cron/jobs/{job_id}
### POST /api/gui/cron/jobs/{job_id}/run
### POST /api/gui/cron/jobs/{job_id}/pause
### POST /api/gui/cron/jobs/{job_id}/resume
### DELETE /api/gui/cron/jobs/{job_id}
### GET /api/gui/gateway/overview
Returns:
- runtime state
- PID
- platform summary list
- delivery/home configuration summary
### GET /api/gui/gateway/platforms
Returns all platform config/status cards.
### GET /api/gui/gateway/pairing
Returns pending codes and approved identities.
### POST /api/gui/gateway/pairing/approve
```json
{
"platform": "telegram",
"code": "AB12CD34"
}
```
### POST /api/gui/gateway/pairing/revoke
```json
{
"platform": "telegram",
"user_id": "123456789"
}
```
### GET /api/gui/settings
Returns masked config snapshot split by sections.
### PATCH /api/gui/settings
Accepts partial updates for safe editable sections.
### GET /api/gui/logs
Query params:
- `kind=errors|gateway|cron|tool_debug`
- `offset`
- `limit`
- `follow`
### GET /api/gui/browser/status
Returns browser backend info, live connection state, recording paths if available.
### POST /api/gui/browser/connect
```json
{
"mode": "live_chrome"
}
```
### POST /api/gui/browser/disconnect
### POST /api/gui/media/upload
Accepts multipart uploads and returns normalized attachment metadata.
### POST /api/gui/media/transcribe
Accepts uploaded audio reference and returns transcription.
### POST /api/gui/media/tts
Accepts text and returns generated media path metadata.
## SSE transport
### Endpoint
- `GET /api/gui/stream/session/{session_id}`
Optional query params:
- `run_id`
- `history=1` to replay recent buffered events
### Event envelope
```json
{
"id": "evt_123",
"type": "tool.started",
"session_id": "sess_123",
"run_id": "run_987",
"ts": "2026-03-30T02:10:02.123Z",
"payload": {}
}
```
### Required event types
#### run.started
```json
{
"type": "run.started",
"payload": {
"message_id": "msg_1",
"title": "Inspect API server routes"
}
}
```
#### message.assistant.delta
```json
{
"type": "message.assistant.delta",
"payload": {
"message_id": "msg_2",
"delta": "The API server currently exposes"
}
}
```
#### tool.started
```json
{
"type": "tool.started",
"payload": {
"tool_call_id": "tool_1",
"tool_name": "search_files",
"arguments": {
"pattern": "api_server"
}
}
}
```
#### tool.completed
```json
{
"type": "tool.completed",
"payload": {
"tool_call_id": "tool_1",
"duration_ms": 944,
"summary": "12 matches"
}
}
```
#### approval.requested
```json
{
"type": "approval.requested",
"payload": {
"request_id": "human_1",
"title": "Approve terminal command",
"prompt": "Allow Hermes to run git status?",
"choices": ["approve_once", "approve_session", "approve_always", "deny"]
}
}
```
#### clarify.requested
```json
{
"type": "clarify.requested",
"payload": {
"request_id": "human_2",
"title": "Need clarification",
"prompt": "What kind of GUI do you want?",
"choices": [
"A local desktop app",
"A web app you can open in a browser",
"A terminal UI (TUI)",
"Something minimal: just a chat window"
]
}
}
```
#### todo.updated
```json
{
"type": "todo.updated",
"payload": {
"items": [
{
"id": "inspect",
"content": "Inspect Hermes repo",
"status": "completed"
}
]
}
}
```
#### subagent.completed
```json
{
"type": "subagent.completed",
"payload": {
"subagent_id": "sub_1",
"title": "Inspect gateway features",
"summary": "Found pairing, status, and gateway config surfaces."
}
}
```
## Security rules
1. Secrets from `.env`, auth stores, passwords, or secret prompts must never be returned.
2. Sensitive settings responses should return:
- `present: true|false`
- masked preview only when already displayed in CLI-equivalent UX
3. Service control endpoints should require capability checks and explicit confirmation in UI.
4. Remote GUI access should require auth if `host != 127.0.0.1` or if an API key is configured.
## Versioning
Add a `schema_version` field in `/api/gui/meta` and bump it whenever:
- event shape changes
- endpoint response shape changes
- required fields change
Suggested initial value:
```json
{
"schema_version": 1
}
```

View File

@@ -0,0 +1,520 @@
# Hermes Web Console Frontend Architecture and Wireframes
Date: 2026-03-30
## Purpose
This document defines the frontend information architecture, route map, component hierarchy, state model, and wireframe expectations for a fully featured Hermes GUI.
## Product structure
### Primary navigation
- Chat
- Sessions
- Workspace
- Automations
- Memory
- Skills
- Gateway
- Settings
- Logs
### Global layout
- Top bar
- Left sidebar navigation
- Main content region
- Right inspector
- Bottom drawer
### Persistent shell responsibilities
The shell should always show:
- current workspace
- current model/provider
- current run status
- quick stop button
- quick new chat button
- current session title
- connection/health status
## Route map
- `/app/` -> Chat
- `/app/chat/:sessionId?`
- `/app/sessions`
- `/app/workspace`
- `/app/automations`
- `/app/memory`
- `/app/skills`
- `/app/gateway`
- `/app/settings`
- `/app/logs`
Optional nested routes:
- `/app/sessions/:sessionId`
- `/app/workspace/file/*`
- `/app/gateway/platform/:platform`
- `/app/settings/:section`
## Component tree
### App shell
- `AppShell`
- `TopBar`
- `Sidebar`
- `MainRouterOutlet`
- `Inspector`
- `BottomDrawer`
### TopBar
Responsibilities:
- workspace switcher
- session title display/edit
- model/provider pills
- run-state chip
- stop button
- health chip
- quick actions menu
### Sidebar
Responsibilities:
- primary nav
- recent sessions list
- pinned sessions
- new chat button
- global search trigger
### Inspector
Tabbed side panel. Tabs:
- Run
- Tools
- TODO
- Session
- Human
#### Run tab
- active run metadata
- current step
- elapsed time
- stop/retry/undo buttons
#### Tools tab
- chronological tool timeline
- filter by status/tool type
- expand raw args/results
#### TODO tab
- Hermes todo list
- pending/in-progress/completed/cancelled groups
#### Session tab
- session metadata
- export/delete/title edit
- lineage graph summary
#### Human tab
- pending approvals
- pending clarifications
- secret/password prompts
### BottomDrawer
Tabs:
- Terminal
- Processes
- Logs
- Browser
## Page specs
### 1. Chat page
#### Responsibilities
- transcript rendering
- live SSE subscription
- composer and attachments
- approvals/clarify UI
- visible tool stream
#### Layout
- main transcript center
- right inspector open by default
- optional left recent-session rail
#### Required components
- `Transcript`
- `MessageCard`
- `Composer`
- `RunStatusBar`
- `ToolTimeline`
- `ApprovalPrompt`
- `ClarifyPrompt`
#### Transcript message types
- user
- assistant
- tool_call
- tool_result
- system
- approval_request
- clarification_request
- background_result
- subagent_summary
- attachment
#### Chat states
- idle
- sending
- streaming
- waiting_for_human
- interrupted
- failed
- completed
#### Composer states
- default
- drag-over
- uploading
- recording-audio
- disabled-busy
### 2. Sessions page
#### Responsibilities
- search/filter sessions
- preview and resume
- inspect lineage
- export/delete
#### Required components
- `SessionFilterBar`
- `SessionList`
- `SessionPreview`
- `LineageMiniGraph`
#### Layout
- filters at top/left
- results list center
- preview detail panel right
### 3. Workspace page
#### Responsibilities
- browse files
- inspect contents
- inspect diffs/patches
- inspect checkpoints
- terminal/process control
#### Required components
- `FileTree`
- `FileViewer`
- `SearchPanel`
- `DiffViewer`
- `CheckpointList`
- `TerminalPanel`
- `ProcessPanel`
#### Layout
- left: file tree
- center: file/diff tabs
- right: patch/checkpoint inspector
- bottom: terminal/process drawer
### 4. Automations page
#### Responsibilities
- view/edit cron jobs
- run-now / pause / resume / remove
- inspect history/output
#### Required components
- `CronToolbar`
- `CronList`
- `CronEditor`
- `CronRunHistory`
### 5. Memory page
#### Responsibilities
- view/edit memory
- view/edit user profile memory
- search past sessions
- inspect memory provenance
#### Required components
- `MemoryTabs`
- `MemoryList`
- `MemoryEditor`
- `SessionSearchBox`
- `SessionSearchResults`
### 6. Skills page
#### Responsibilities
- browse installed skills
- inspect skill content
- install/update/remove
- load skill for session
#### Required components
- `SkillTabs`
- `SkillList`
- `SkillDetail`
- `SkillActionBar`
### 7. Gateway page
#### Responsibilities
- platform overview
- pairing management
- service state/logs
- home-channel/delivery summary
#### Required components
- `GatewayOverviewCards`
- `PlatformCards`
- `PairingQueue`
- `ApprovedUsersTable`
- `GatewayServicePanel`
### 8. Settings page
#### Responsibilities
- provider/model config
- auth status
- toolsets
- terminal/browser/voice settings
- themes/plugins
- advanced config
#### Required components
- `SettingsSidebar`
- `SettingsForm`
- `AuthStatusCards`
- `ToolsetMatrix`
- `PluginList`
- `ThemePicker`
- `AdvancedConfigEditor`
### 9. Logs page
#### Responsibilities
- tail/filter logs
- switch log source
- correlate logs with sessions/runs
#### Required components
- `LogSourceTabs`
- `LogViewer`
- `LogFilterBar`
- `LogDetailPanel`
## Frontend state model
### Server state (TanStack Query)
- sessions list
- session details/transcript
- workspace tree/file/search
- checkpoints
- processes/logs
- memory
- skills
- cron jobs
- gateway overview/platforms/pairing
- settings sections
- browser status
### Live event state (Zustand)
- current run id
- run status
- streaming assistant text
- pending tool timeline
- pending approvals/clarifications
- todo list
- subagent summaries
- transient banners/toasts
### Local UI state (Zustand)
- inspector open/tab
- bottom drawer open/tab
- selected workspace file
- selected diff/checkpoint
- chat composer draft
- session filters
- theme preference
## Event handling model
### SSE subscription behavior
When Chat page mounts:
1. open SSE for current session
2. buffer incoming events in `runStore`
3. merge completed events into session/transcript caches
4. show reconnection banner if stream drops
### Optimistic UI actions
Allowed:
- session title edit
- memory entry edit
- cron pause/resume
- inspector tab changes
Not optimistic:
- risky service control
- rollback
- delete session
- plugin install/remove
## Wireframes
### Global shell
```text
┌──────────────────────────────────────────────────────────────────────────────┐
│ Hermes | Workspace ▼ | Model ▼ | Provider ▼ | Run: Active | Stop | Health │
├───────────────┬─────────────────────────────────────┬───────────────────────┤
│ Sidebar │ Main content │ Inspector │
│ │ │ Run / Tools / TODO │
│ Chat │ │ Session / Human │
│ Sessions │ │ │
│ Workspace │ │ │
│ Automations │ │ │
│ Memory │ │ │
│ Skills │ │ │
│ Gateway │ │ │
│ Settings │ │ │
│ Logs │ │ │
├───────────────┴─────────────────────────────────────┴───────────────────────┤
│ Bottom drawer: Terminal | Processes | Logs | Browser │
└──────────────────────────────────────────────────────────────────────────────┘
```
### Chat page
```text
┌────────────────────────────── Chat ──────────────────────────────────────────┐
│ Transcript │
│ ┌─────────────────────────────────────────────────────────────────────────┐ │
│ │ User: research and scan and tell me all the functions... │ │
│ │ Assistant: Hermes needs a full control plane... │ │
│ │ Tool: search_files(...) │ │
│ │ Tool: read_file(...) │ │
│ │ Approval needed: Allow terminal command? [Approve once] [Deny] │ │
│ └─────────────────────────────────────────────────────────────────────────┘ │
│ │
│ [Attach] [Mic] [multiline prompt.......................................] │
│ [Send] │
└─────────────────────────────────────────────────────────────────────────────┘
```
### Sessions page
```text
┌──────────────────────────── Sessions ────────────────────────────────────────┐
│ Filters: [query] [source] [workspace] [model] [has tools] │
├──────────────────────────────┬───────────────────────────────────────────────┤
│ Session list │ Preview │
│ - Hermes GUI planning │ Title: Hermes GUI planning │
│ - Pricing architecture │ Last active: ... │
│ - Telegram pairing │ Lineage: root -> compressed -> current │
│ │ Actions: Resume Export Delete │
└──────────────────────────────┴───────────────────────────────────────────────┘
```
### Workspace page
```text
┌──────────────────────────── Workspace ───────────────────────────────────────┐
│ File tree │ Editor / Diff / Search │ Checkpoints │
│ src/ │ -------------------------------- │ - before patch │
│ tests/ │ current file contents │ - before rollback│
│ docs/ │ or unified diff │ │
├──────────────────────────────────────────────────────────────────────────────┤
│ Terminal | Processes | Logs │
└──────────────────────────────────────────────────────────────────────────────┘
```
### Gateway page
```text
┌──────────────────────────── Gateway ─────────────────────────────────────────┐
│ Overview cards: running / platforms healthy / pending pairing / home target │
├──────────────────────────────┬───────────────────────────────────────────────┤
│ Platform cards │ Pairing / service panel │
│ Telegram: connected │ Pending code AB12CD34 [Approve] [Reject] │
│ Discord: configured │ Service: running [Restart] [Stop] │
│ Slack: error │ Logs tail... │
└──────────────────────────────┴───────────────────────────────────────────────┘
```
## UX rules
1. Tool details default collapsed but one click away.
2. Human-required actions must visually block the active run state.
3. Destructive actions need confirmation copy naming what will be affected.
4. Raw JSON or raw log views must be available from every rich card/detail view.
5. Every long-running action should show status, elapsed time, and cancelability if supported.
## Accessibility requirements
- all primary actions keyboard reachable
- focus trap in approval/clarify modals
- aria-live region for streamed response text and status updates
- high-contrast theme support
- avoid color-only status signaling
## Frontend testing strategy
### Unit/component tests
- render shell
- route transitions
- transcript item rendering
- approval and clarify forms
- file viewer/diff viewer states
- cron editor forms
- settings save/reset behaviors
### Playwright flows
- start and stream a chat
- approve a pending action
- open a session preview and resume it
- inspect a diff and checkpoint list
- create/pause/run a cron job
- open gateway page and inspect platform cards
## Build/deploy model
### Development
- `web_console` runs via Vite dev server
- aiohttp proxies `/app/*` to `config.gui.dev_server_url`
### Production
- frontend builds static assets into a dist directory
- build artifacts copied into `gateway/web_console/static_dist/`
- aiohttp serves `index.html` + hashed assets with SPA fallback
## Open implementation questions
1. Whether to add drag-and-drop diff acceptance in v1 or v1.1
2. Whether the Browser tab in bottom drawer should include screenshots in v1
3. Whether remote GUI auth should be separate from API server bearer auth
4. Whether logs page should stream over SSE or poll initially
## Definition of done for frontend
Frontend is done when:
- all primary nav pages render
- chat streaming works against real backend events
- session resume works
- workspace diff/checkpoint/process views work
- memory/skills/cron/gateway/settings/logs pages all consume real backend data
- Playwright smoke suite passes

File diff suppressed because it is too large Load Diff

View File

@@ -36,10 +36,12 @@ except ImportError:
web = None # type: ignore[assignment]
from gateway.config import Platform, PlatformConfig
from gateway.platforms.api_server_ui import get_api_server_ui_html
from gateway.platforms.base import (
BasePlatformAdapter,
SendResult,
)
from gateway.web_console import maybe_register_web_console
logger = logging.getLogger(__name__)
@@ -457,6 +459,41 @@ class APIServerAdapter(BasePlatformAdapter):
"""GET /health — simple health check."""
return web.json_response({"status": "ok", "platform": "hermes-agent"})
async def _handle_ui(self, request: "web.Request") -> "web.Response":
"""GET / — lightweight browser chat UI for the local API server."""
html = get_api_server_ui_html(
api_base_url="/v1",
requires_api_key=bool(self._api_key),
default_model="hermes-agent",
)
return web.Response(text=html, content_type="text/html")
def _register_routes(self, app: "web.Application") -> None:
"""Register all API server routes on an aiohttp app."""
app.router.add_get("/health", self._handle_health)
app.router.add_get("/v1/health", self._handle_health)
app.router.add_get("/", self._handle_ui)
app.router.add_get("/v1/models", self._handle_models)
app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
app.router.add_post("/v1/responses", self._handle_responses)
app.router.add_get("/v1/responses/{response_id}", self._handle_get_response)
app.router.add_delete("/v1/responses/{response_id}", self._handle_delete_response)
# Cron jobs management API
app.router.add_get("/api/jobs", self._handle_list_jobs)
app.router.add_post("/api/jobs", self._handle_create_job)
app.router.add_get("/api/jobs/{job_id}", self._handle_get_job)
app.router.add_patch("/api/jobs/{job_id}", self._handle_update_job)
app.router.add_delete("/api/jobs/{job_id}", self._handle_delete_job)
app.router.add_post("/api/jobs/{job_id}/pause", self._handle_pause_job)
app.router.add_post("/api/jobs/{job_id}/resume", self._handle_resume_job)
app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_job)
app.router.add_post("/api/jobs/{job_id}/run-now", self._handle_run_job_now)
# Structured event streaming
app.router.add_post("/v1/runs", self._handle_runs)
app.router.add_get("/v1/runs/{run_id}/events", self._handle_run_events)
# Mount the Hermes Web Console backend API routes
maybe_register_web_console(app, adapter=self)
async def _handle_models(self, request: "web.Request") -> "web.Response":
"""GET /v1/models — return hermes-agent as an available model."""
auth_err = self._check_auth(request)
@@ -1573,26 +1610,7 @@ class APIServerAdapter(BasePlatformAdapter):
mws = [mw for mw in (cors_middleware, body_limit_middleware, security_headers_middleware) if mw is not None]
self._app = web.Application(middlewares=mws)
self._app["api_server_adapter"] = self
self._app.router.add_get("/health", self._handle_health)
self._app.router.add_get("/v1/health", self._handle_health)
self._app.router.add_get("/v1/models", self._handle_models)
self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
self._app.router.add_post("/v1/responses", self._handle_responses)
self._app.router.add_get("/v1/responses/{response_id}", self._handle_get_response)
self._app.router.add_delete("/v1/responses/{response_id}", self._handle_delete_response)
# Cron jobs management API
self._app.router.add_get("/api/jobs", self._handle_list_jobs)
self._app.router.add_post("/api/jobs", self._handle_create_job)
self._app.router.add_get("/api/jobs/{job_id}", self._handle_get_job)
self._app.router.add_patch("/api/jobs/{job_id}", self._handle_update_job)
self._app.router.add_delete("/api/jobs/{job_id}", self._handle_delete_job)
self._app.router.add_post("/api/jobs/{job_id}/pause", self._handle_pause_job)
self._app.router.add_post("/api/jobs/{job_id}/resume", self._handle_resume_job)
self._app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_job)
self._app.router.add_post("/api/jobs/{job_id}/run-now", self._handle_run_job_now)
# Structured event streaming
self._app.router.add_post("/v1/runs", self._handle_runs)
self._app.router.add_get("/v1/runs/{run_id}/events", self._handle_run_events)
self._register_routes(self._app)
# Start background sweep to clean up orphaned (unconsumed) run streams
sweep_task = asyncio.create_task(self._sweep_orphaned_runs())
try:

View File

@@ -0,0 +1,496 @@
import json
def get_api_server_ui_html(
api_base_url: str = "/v1",
requires_api_key: bool = False,
default_model: str = "hermes-agent",
) -> str:
"""Return a lightweight single-file browser UI for the local API server."""
config = {
"apiBaseUrl": api_base_url,
"requiresApiKey": requires_api_key,
"defaultModel": default_model,
}
config_json = json.dumps(config)
template = '''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Hermes Browser UI</title>
<style>
:root {
color-scheme: dark;
--bg: #0b1020;
--panel: rgba(18, 25, 45, 0.92);
--panel-2: rgba(28, 37, 66, 0.95);
--border: rgba(140, 170, 255, 0.2);
--text: #edf2ff;
--muted: #9ca9c8;
--accent: #7aa2ff;
--accent-2: #5eead4;
--danger: #fb7185;
--shadow: 0 18px 40px rgba(0,0,0,0.35);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top, rgba(122,162,255,0.18), transparent 30%),
linear-gradient(180deg, #08101f 0%, #090d18 100%);
color: var(--text);
}
.shell {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 24px;
min-height: 100vh;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 20px;
box-shadow: var(--shadow);
backdrop-filter: blur(18px);
}
.sidebar { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.brand h1 { margin: 0; font-size: 1.5rem; }
.brand p, .hint, .status-line { color: var(--muted); margin: 0; line-height: 1.45; }
.status-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 999px;
background: rgba(94, 234, 212, 0.08);
color: var(--accent-2);
border: 1px solid rgba(94, 234, 212, 0.2);
font-size: 0.92rem;
width: fit-content;
}
.status-chip.offline {
color: #fda4af;
background: rgba(251, 113, 133, 0.08);
border-color: rgba(251, 113, 133, 0.22);
}
label { display: block; font-size: 0.92rem; margin-bottom: 6px; color: #cbd5f5; }
input, textarea, button { font: inherit; }
input, textarea {
width: 100%;
background: rgba(8, 15, 31, 0.9);
color: var(--text);
border: 1px solid rgba(140, 170, 255, 0.18);
border-radius: 14px;
padding: 12px 14px;
outline: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
input:focus, textarea:focus {
border-color: rgba(122,162,255,0.75);
box-shadow: 0 0 0 3px rgba(122,162,255,0.15);
}
textarea { min-height: 96px; resize: vertical; }
.controls { display: flex; flex-direction: column; gap: 14px; }
.row { display: flex; gap: 10px; }
.row > * { flex: 1; }
button {
border: 0;
border-radius: 14px;
padding: 12px 14px;
cursor: pointer;
background: linear-gradient(135deg, var(--accent), #8b5cf6);
color: white;
font-weight: 600;
transition: transform 0.12s ease, opacity 0.2s ease;
}
button:hover { transform: translateY(-1px); }
button:disabled { cursor: wait; opacity: 0.65; transform: none; }
button.secondary {
background: rgba(122, 162, 255, 0.08);
color: var(--text);
border: 1px solid rgba(140, 170, 255, 0.18);
}
.chat {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
min-height: calc(100vh - 48px);
overflow: hidden;
}
.chat-header {
padding: 20px 22px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
}
.messages {
padding: 20px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 14px;
background: linear-gradient(180deg, rgba(10,15,28,0.25), rgba(10,15,28,0.05));
}
.message {
border: 1px solid rgba(140,170,255,0.14);
border-radius: 18px;
padding: 14px 16px;
max-width: min(90%, 880px);
box-shadow: 0 10px 30px rgba(0,0,0,0.18);
white-space: pre-wrap;
word-break: break-word;
}
.message.user { align-self: flex-end; background: rgba(30,58,138,0.6); }
.message.assistant { align-self: flex-start; background: rgba(22,33,62,0.92); }
.message.tool { align-self: flex-start; background: rgba(16,42,67,0.92); }
.message.system { align-self: center; background: rgba(76,29,149,0.22); color: #ddd6fe; }
.meta { font-size: 0.78rem; color: var(--muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.08em; }
.composer {
border-top: 1px solid var(--border);
padding: 18px 20px 20px;
display: flex;
flex-direction: column;
gap: 12px;
background: rgba(9, 14, 26, 0.9);
}
.composer-actions { display: flex; gap: 10px; justify-content: space-between; align-items: center; }
.composer-actions .right { display: flex; gap: 10px; }
.footer-note { color: var(--muted); font-size: 0.86rem; }
code {
background: rgba(255,255,255,0.08);
padding: 2px 6px;
border-radius: 8px;
}
.hidden { display: none !important; }
@media (max-width: 960px) {
.shell { grid-template-columns: 1fr; padding: 16px; }
.chat { min-height: 75vh; }
.message { max-width: 100%; }
}
</style>
</head>
<body>
<div class="shell">
<aside class="panel sidebar">
<div class="brand">
<h1>Hermes</h1>
<p>Local browser GUI for the built-in API server.</p>
</div>
<div id="health-chip" class="status-chip">Checking connection…</div>
<p id="status-line" class="status-line">Point your browser at this server and chat with Hermes directly.</p>
<div class="controls">
<div>
<label for="conversation">Conversation ID</label>
<input id="conversation" placeholder="browser-chat" />
</div>
<div>
<label for="model">Model label</label>
<input id="model" placeholder="hermes-agent" />
</div>
<div id="api-key-wrap" class="hidden">
<label for="api-key">API key</label>
<input id="api-key" type="password" placeholder="Bearer token for this server" />
</div>
<div>
<label for="instructions">Optional instructions</label>
<textarea id="instructions" placeholder="You are Hermes in browser mode. Be concise."></textarea>
</div>
<div class="row">
<button id="new-chat" class="secondary" type="button">New chat</button>
<button id="save-settings" class="secondary" type="button">Save settings</button>
</div>
</div>
<p class="hint">This UI talks to <code>/v1/responses</code> on the same server and keeps state by conversation name.</p>
</aside>
<main class="panel chat">
<div class="chat-header">
<div>
<strong>Browser Chat</strong>
<p class="hint">Hermes can use tools through the existing backend. Tool calls appear inline.</p>
</div>
<div class="footer-note" id="usage">No messages yet.</div>
</div>
<section id="messages" class="messages"></section>
<form id="composer" class="composer">
<textarea id="prompt" placeholder="Ask Hermes anything… Shift+Enter for a newline."></textarea>
<div class="composer-actions">
<div class="footer-note" id="composer-note">Ready.</div>
<div class="right">
<button id="clear-chat" class="secondary" type="button">Clear messages</button>
<button id="send" type="submit">Send</button>
</div>
</div>
</form>
</main>
</div>
<script>
const CONFIG = __CONFIG_JSON__;
const storageKey = 'hermes-browser-ui';
const state = {
busy: false,
conversation: null,
usage: null,
messages: [],
};
const $ = (id) => document.getElementById(id);
const els = {
apiKeyWrap: $('api-key-wrap'),
apiKey: $('api-key'),
composer: $('composer'),
composerNote: $('composer-note'),
conversation: $('conversation'),
healthChip: $('health-chip'),
instructions: $('instructions'),
messages: $('messages'),
model: $('model'),
newChat: $('new-chat'),
prompt: $('prompt'),
saveSettings: $('save-settings'),
send: $('send'),
statusLine: $('status-line'),
usage: $('usage'),
clearChat: $('clear-chat'),
};
function randomConversationId() {
if (window.crypto && crypto.randomUUID) {
return `chat-${crypto.randomUUID().slice(0, 8)}`;
}
return `chat-${Math.random().toString(36).slice(2, 10)}`;
}
function loadSettings() {
try {
const saved = JSON.parse(localStorage.getItem(storageKey) || '{}');
els.conversation.value = saved.conversation || randomConversationId();
els.instructions.value = saved.instructions || '';
els.model.value = saved.model || CONFIG.defaultModel;
els.apiKey.value = saved.apiKey || '';
} catch (err) {
els.conversation.value = randomConversationId();
els.model.value = CONFIG.defaultModel;
}
}
function saveSettings() {
const payload = {
conversation: els.conversation.value.trim(),
instructions: els.instructions.value,
model: els.model.value.trim(),
apiKey: els.apiKey.value,
};
localStorage.setItem(storageKey, JSON.stringify(payload));
setComposerNote('Settings saved locally in your browser.');
}
function setComposerNote(text, isError = false) {
els.composerNote.textContent = text;
els.composerNote.style.color = isError ? 'var(--danger)' : 'var(--muted)';
}
function setBusy(busy) {
state.busy = busy;
els.send.disabled = busy;
els.prompt.disabled = busy;
els.conversation.disabled = busy;
els.model.disabled = busy;
els.instructions.disabled = busy;
els.apiKey.disabled = busy;
els.newChat.disabled = busy;
els.saveSettings.disabled = busy;
els.clearChat.disabled = busy;
setComposerNote(busy ? 'Hermes is thinking…' : 'Ready.');
}
function addMessage(role, content, meta = '') {
state.messages.push({ role, content, meta });
renderMessages();
}
function renderMessages() {
els.messages.innerHTML = '';
if (!state.messages.length) {
const empty = document.createElement('div');
empty.className = 'message system';
empty.textContent = 'Start a conversation to bring Hermes into the browser.';
els.messages.appendChild(empty);
}
for (const msg of state.messages) {
const node = document.createElement('article');
node.className = `message ${msg.role}`;
const meta = document.createElement('div');
meta.className = 'meta';
meta.textContent = msg.meta || msg.role;
const body = document.createElement('div');
body.textContent = msg.content;
node.append(meta, body);
els.messages.appendChild(node);
}
els.messages.scrollTop = els.messages.scrollHeight;
}
function updateUsage(usage) {
state.usage = usage || null;
if (!usage) {
els.usage.textContent = 'No messages yet.';
return;
}
const total = usage.total_tokens ?? 0;
const input = usage.input_tokens ?? 0;
const output = usage.output_tokens ?? 0;
els.usage.textContent = `Tokens: total ${total} · in ${input} · out ${output}`;
}
function extractAssistantText(output) {
if (!Array.isArray(output)) return '';
const messageItem = [...output].reverse().find((item) => item.type === 'message');
if (!messageItem || !Array.isArray(messageItem.content)) return '';
return messageItem.content
.filter((part) => part && part.type === 'output_text')
.map((part) => part.text || '')
.join('\n\n')
.trim();
}
function summarizeToolItems(output) {
if (!Array.isArray(output)) return [];
const items = [];
for (const item of output) {
if (item.type === 'function_call') {
items.push(`Tool call: ${item.name || 'unknown'}\n${item.arguments || ''}`.trim());
} else if (item.type === 'function_call_output') {
items.push(`Tool result (${item.call_id || 'call'}):\n${String(item.output || '')}`.trim());
}
}
return items;
}
async function checkHealth() {
try {
const response = await fetch('/health');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
els.healthChip.textContent = 'Connected';
els.healthChip.classList.remove('offline');
els.statusLine.textContent = `Server status: ${payload.status} · platform: ${payload.platform}`;
} catch (error) {
els.healthChip.textContent = 'Offline';
els.healthChip.classList.add('offline');
els.statusLine.textContent = `Health check failed: ${error.message}`;
}
}
function resetConversation(clearVisuals = true) {
const nextId = randomConversationId();
els.conversation.value = nextId;
state.conversation = nextId;
if (clearVisuals) {
state.messages = [];
renderMessages();
updateUsage(null);
}
saveSettings();
}
async function sendMessage(promptText) {
const conversation = els.conversation.value.trim() || randomConversationId();
const instructions = els.instructions.value.trim();
const model = els.model.value.trim() || CONFIG.defaultModel;
const apiKey = els.apiKey.value.trim();
els.conversation.value = conversation;
state.conversation = conversation;
addMessage('user', promptText, `conversation ${conversation}`);
setBusy(true);
const headers = { 'Content-Type': 'application/json' };
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
try {
const response = await fetch(`${CONFIG.apiBaseUrl}/responses`, {
method: 'POST',
headers,
body: JSON.stringify({
model,
input: promptText,
instructions: instructions || undefined,
conversation,
store: true,
}),
});
const data = await response.json().catch(() => ({ error: { message: 'Invalid JSON response' } }));
if (!response.ok) {
throw new Error(data?.error?.message || `Request failed with HTTP ${response.status}`);
}
for (const toolText of summarizeToolItems(data.output)) {
addMessage('tool', toolText, 'tool activity');
}
addMessage('assistant', extractAssistantText(data.output) || '(No message returned)', data.id || 'assistant');
updateUsage(data.usage);
saveSettings();
} catch (error) {
addMessage('system', error.message || String(error), 'request failed');
setComposerNote(error.message || String(error), true);
} finally {
setBusy(false);
}
}
els.composer.addEventListener('submit', async (event) => {
event.preventDefault();
const promptText = els.prompt.value.trim();
if (!promptText || state.busy) return;
els.prompt.value = '';
await sendMessage(promptText);
});
els.prompt.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
els.composer.requestSubmit();
}
});
els.newChat.addEventListener('click', () => resetConversation(true));
els.clearChat.addEventListener('click', () => {
state.messages = [];
renderMessages();
updateUsage(null);
setComposerNote('Messages cleared in this browser tab.');
});
els.saveSettings.addEventListener('click', saveSettings);
if (CONFIG.requiresApiKey) {
els.apiKeyWrap.classList.remove('hidden');
}
loadSettings();
state.conversation = els.conversation.value.trim();
renderMessages();
checkHealth();
</script>
</body>
</html>
'''
return template.replace('__CONFIG_JSON__', config_json)

View File

@@ -0,0 +1,5 @@
"""Hermes Web Console backend package."""
from .app import maybe_register_web_console
__all__ = ["maybe_register_web_console"]

View File

@@ -0,0 +1,87 @@
"""API route registration helpers for the Hermes Web Console backend."""
from __future__ import annotations
from aiohttp import web
from .approvals import register_approval_api_routes
from .browser import register_browser_api_routes
from .chat import register_chat_api_routes
from .commands import register_commands_api_routes
from .cron import register_cron_api_routes
from .gateway_admin import register_gateway_admin_api_routes
from .logs import register_logs_api_routes
from .media import register_media_api_routes
from .metrics import register_metrics_api_routes
from .memory import register_memory_api_routes
from .models_api import register_models_api_routes
from .sessions import register_sessions_api_routes
from .settings import register_settings_api_routes
from .skills import register_skills_api_routes
from .tools import register_tools_api_routes
from .version import register_version_api_routes
from .workspace import register_workspace_api_routes
from .profiles import register_profiles_api_routes
from .plugins import register_plugins_api_routes
from .mcp import register_mcp_api_routes
from .usage import register_usage_api_routes
from .missions import register_missions_api_routes
from .credentials import register_credentials_api_routes
from .system import register_system_api_routes
def register_web_console_api_routes(app: web.Application) -> None:
"""Register modular web-console API routes on an aiohttp application."""
register_approval_api_routes(app)
register_browser_api_routes(app)
register_chat_api_routes(app)
register_commands_api_routes(app)
register_cron_api_routes(app)
register_gateway_admin_api_routes(app)
register_logs_api_routes(app)
register_media_api_routes(app)
register_metrics_api_routes(app)
register_memory_api_routes(app)
register_models_api_routes(app)
register_sessions_api_routes(app)
register_settings_api_routes(app)
register_skills_api_routes(app)
register_tools_api_routes(app)
register_version_api_routes(app)
register_workspace_api_routes(app)
register_profiles_api_routes(app)
register_plugins_api_routes(app)
register_mcp_api_routes(app)
register_usage_api_routes(app)
register_missions_api_routes(app)
register_credentials_api_routes(app)
register_system_api_routes(app)
__all__ = [
"register_web_console_api_routes",
"register_chat_api_routes",
"register_browser_api_routes",
"register_commands_api_routes",
"register_cron_api_routes",
"register_gateway_admin_api_routes",
"register_logs_api_routes",
"register_media_api_routes",
"register_metrics_api_routes",
"register_memory_api_routes",
"register_models_api_routes",
"register_sessions_api_routes",
"register_settings_api_routes",
"register_skills_api_routes",
"register_tools_api_routes",
"register_version_api_routes",
"register_approval_api_routes",
"register_workspace_api_routes",
"register_profiles_api_routes",
"register_plugins_api_routes",
"register_mcp_api_routes",
"register_usage_api_routes",
"register_missions_api_routes",
"register_credentials_api_routes",
"register_system_api_routes",
]

View File

@@ -0,0 +1,137 @@
"""Human approval and clarification API routes for the Hermes Web Console."""
from __future__ import annotations
import json
from typing import Any
from aiohttp import web
from gateway.web_console.services.approval_service import ApprovalService
HUMAN_SERVICE_APP_KEY = web.AppKey("hermes_web_console_human_service", ApprovalService)
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
payload: dict[str, Any] = {"ok": False, "error": {"code": code, "message": message}}
payload["error"].update(extra)
return web.json_response(payload, status=status)
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
try:
data = await request.json()
except (json.JSONDecodeError, ValueError, TypeError):
return None
if not isinstance(data, dict):
return None
return data
def _get_human_service(request: web.Request) -> ApprovalService:
return request.app[HUMAN_SERVICE_APP_KEY]
async def handle_list_pending(request: web.Request) -> web.Response:
service = _get_human_service(request)
return web.json_response({"ok": True, "pending": service.list_pending()})
async def handle_approve(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
request_id = data.get("request_id")
decision = data.get("decision") or data.get("scope") or "once"
if not isinstance(request_id, str) or not request_id:
return _json_error(status=400, code="invalid_request_id", message="The 'request_id' field must be a non-empty string.")
if decision not in {"once", "session", "always"}:
return _json_error(status=400, code="invalid_decision", message="The approval decision must be one of: once, session, always.")
service = _get_human_service(request)
resolved = service.resolve_approval(request_id, decision)
if resolved is None:
return _json_error(status=404, code="request_not_found", message="No pending approval request was found.", request_id=request_id)
return web.json_response({"ok": True, "request": resolved})
async def handle_deny(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
request_id = data.get("request_id")
if not isinstance(request_id, str) or not request_id:
return _json_error(status=400, code="invalid_request_id", message="The 'request_id' field must be a non-empty string.")
service = _get_human_service(request)
resolved = service.deny_request(request_id)
if resolved is None:
return _json_error(status=404, code="request_not_found", message="No pending human request was found.", request_id=request_id)
return web.json_response({"ok": True, "request": resolved})
async def handle_clarify(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
request_id = data.get("request_id")
response = data.get("response")
if not isinstance(request_id, str) or not request_id:
return _json_error(status=400, code="invalid_request_id", message="The 'request_id' field must be a non-empty string.")
if not isinstance(response, str):
return _json_error(status=400, code="invalid_response", message="The 'response' field must be a string.")
service = _get_human_service(request)
resolved = service.resolve_clarification(request_id, response)
if resolved is None:
return _json_error(status=404, code="request_not_found", message="No pending clarification request was found.", request_id=request_id)
return web.json_response({"ok": True, "request": resolved})
async def handle_get_yolo(request: web.Request) -> web.Response:
from tools.approval import is_session_yolo_enabled
session_id = request.query.get("session_id", "")
if not session_id:
return _json_error(status=400, code="invalid_session_id", message="The 'session_id' query parameter is required.")
return web.json_response({"ok": True, "session_id": session_id, "enabled": is_session_yolo_enabled(session_id)})
async def handle_set_yolo(request: web.Request) -> web.Response:
from tools.approval import disable_session_yolo, enable_session_yolo, is_session_yolo_enabled
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
session_id = data.get("session_id")
enabled = data.get("enabled")
if not isinstance(session_id, str) or not session_id:
return _json_error(status=400, code="invalid_session_id", message="The 'session_id' field must be a non-empty string.")
if not isinstance(enabled, bool):
return _json_error(status=400, code="invalid_enabled", message="The 'enabled' field must be a boolean.")
if enabled:
enable_session_yolo(session_id)
else:
disable_session_yolo(session_id)
return web.json_response({"ok": True, "session_id": session_id, "enabled": is_session_yolo_enabled(session_id)})
def register_approval_api_routes(app: web.Application) -> None:
if app.get(HUMAN_SERVICE_APP_KEY) is None:
app[HUMAN_SERVICE_APP_KEY] = ApprovalService()
from gateway.web_console.api.chat import CHAT_SERVICE_APP_KEY
existing_chat_service = app.get(CHAT_SERVICE_APP_KEY)
if existing_chat_service is not None:
existing_chat_service.human_service = app[HUMAN_SERVICE_APP_KEY]
app.router.add_get("/api/gui/human/pending", handle_list_pending)
app.router.add_post("/api/gui/human/approve", handle_approve)
app.router.add_post("/api/gui/human/deny", handle_deny)
app.router.add_post("/api/gui/human/clarify", handle_clarify)
app.router.add_get("/api/gui/human/yolo", handle_get_yolo)
app.router.add_post("/api/gui/human/yolo", handle_set_yolo)

View File

@@ -0,0 +1,66 @@
"""Browser API routes for the Hermes Web Console backend."""
from __future__ import annotations
import json
from typing import Any
from aiohttp import web
from gateway.web_console.services.browser_service import BrowserService
BROWSER_SERVICE_APP_KEY = web.AppKey("hermes_web_console_browser_service", BrowserService)
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
payload: dict[str, Any] = {"ok": False, "error": {"code": code, "message": message}}
payload["error"].update(extra)
return web.json_response(payload, status=status)
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
if request.content_type == "application/json":
try:
data = await request.json()
except (json.JSONDecodeError, ValueError, TypeError):
return None
if not isinstance(data, dict):
return None
return data
return {}
def _get_browser_service(request: web.Request) -> BrowserService:
service = request.app.get(BROWSER_SERVICE_APP_KEY)
if service is None:
service = BrowserService()
request.app[BROWSER_SERVICE_APP_KEY] = service
return service
async def handle_browser_status(request: web.Request) -> web.Response:
service = _get_browser_service(request)
return web.json_response({"ok": True, "browser": service.get_status()})
async def handle_browser_connect(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
service = _get_browser_service(request)
try:
browser = service.connect(data.get("cdp_url"))
except ValueError as exc:
return _json_error(status=400, code="invalid_cdp_url", message=str(exc))
return web.json_response({"ok": True, "browser": browser})
async def handle_browser_disconnect(request: web.Request) -> web.Response:
service = _get_browser_service(request)
return web.json_response({"ok": True, "browser": service.disconnect()})
def register_browser_api_routes(app: web.Application) -> None:
app.router.add_get("/api/gui/browser/status", handle_browser_status)
app.router.add_post("/api/gui/browser/connect", handle_browser_connect)
app.router.add_post("/api/gui/browser/disconnect", handle_browser_disconnect)

View File

@@ -0,0 +1,621 @@
"""Chat API routes for the Hermes Web Console backend."""
from __future__ import annotations
import asyncio
import contextlib
import json
import time
import uuid
from typing import Any
from aiohttp import web
from gateway.web_console.api.approvals import HUMAN_SERVICE_APP_KEY
from gateway.web_console.services.chat_service import ChatService
from gateway.web_console.state import create_web_console_state, get_web_console_state
CHAT_SERVICE_APP_KEY = web.AppKey("hermes_web_console_chat_service", ChatService)
CHAT_STATE_APP_KEY = web.AppKey("hermes_web_console_chat_state", object)
CHAT_TASKS_APP_KEY = web.AppKey("hermes_web_console_chat_tasks", set)
CHAT_CLEANUP_REGISTERED_APP_KEY = web.AppKey("hermes_web_console_chat_cleanup_registered", bool)
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
payload: dict[str, Any] = {
"ok": False,
"error": {
"code": code,
"message": message,
},
}
payload["error"].update(extra)
return web.json_response(payload, status=status)
def _get_chat_service(request: web.Request) -> ChatService:
service = request.app.get(CHAT_SERVICE_APP_KEY)
if service is None:
state = request.app.get(CHAT_STATE_APP_KEY)
if state is None:
state = create_web_console_state()
request.app[CHAT_STATE_APP_KEY] = state
human_service = request.app.get(HUMAN_SERVICE_APP_KEY)
service = ChatService(state=state, human_service=human_service)
request.app[CHAT_SERVICE_APP_KEY] = service
return service
def _get_chat_tasks(app: web.Application) -> set[asyncio.Task[Any]]:
tasks = app.get(CHAT_TASKS_APP_KEY)
if tasks is None:
tasks = set()
app[CHAT_TASKS_APP_KEY] = tasks
return tasks
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
try:
data = await request.json()
except (json.JSONDecodeError, ValueError, TypeError):
return None
if not isinstance(data, dict):
return None
return data
def _build_run_metadata(
*,
session_id: str,
run_id: str,
prompt: str,
conversation_history: list[dict[str, Any]] | None,
ephemeral_system_prompt: str | None,
runtime_context: dict[str, Any] | None,
status: str,
source_run_id: str | None = None,
) -> dict[str, Any]:
timestamp = time.time()
# Try to resolve the active model configurations to populate Run panel metadata
active_model = "unknown"
active_provider = "unknown"
try:
from gateway.run import _resolve_gateway_model, _resolve_runtime_agent_kwargs
active_model = _resolve_gateway_model()
active_provider = _resolve_runtime_agent_kwargs().get("provider", "unknown")
except Exception:
pass
metadata: dict[str, Any] = {
"session_id": session_id,
"run_id": run_id,
"prompt": prompt,
"status": status,
"model": active_model,
"provider": active_provider,
"created_at": timestamp,
"updated_at": timestamp,
"observed": True,
}
if conversation_history:
metadata["conversation_history"] = list(conversation_history)
if ephemeral_system_prompt is not None:
metadata["ephemeral_system_prompt"] = ephemeral_system_prompt
if runtime_context:
metadata["runtime_context"] = dict(runtime_context)
if source_run_id is not None:
metadata["source_run_id"] = source_run_id
return metadata
async def _execute_tracked_run(
*,
service: ChatService,
session_id: str,
run_id: str,
prompt: str,
conversation_history: list[dict[str, Any]] | None,
ephemeral_system_prompt: str | None,
runtime_context: dict[str, Any] | None,
) -> None:
state = service.state
try:
result = await service.run_chat(
prompt=prompt,
session_id=session_id,
conversation_history=conversation_history,
ephemeral_system_prompt=ephemeral_system_prompt,
run_id=run_id,
runtime_context=runtime_context,
)
except asyncio.CancelledError:
state.update_run(run_id, status="cancelled", updated_at=time.time())
raise
except Exception as exc:
state.update_run(
run_id,
status="failed",
updated_at=time.time(),
error=str(exc),
error_type=type(exc).__name__,
)
return
update: dict[str, Any] = {
"updated_at": time.time(),
}
if result.get("failed"):
update.update(
{
"status": "failed",
"error": result.get("error") or "Run failed",
}
)
else:
update.update(
{
"status": "completed" if result.get("completed", True) else "running",
"completed": result.get("completed", True),
"final_response": ChatService._extract_assistant_text(dict(result or {})),
"prompt_tokens": result.get("prompt_tokens"),
"completion_tokens": result.get("completion_tokens"),
"total_tokens": result.get("total_tokens"),
}
)
state.update_run(run_id, **update)
def _start_tracked_run(
request: web.Request,
*,
service: ChatService,
session_id: str,
prompt: str,
conversation_history: list[dict[str, Any]] | None,
ephemeral_system_prompt: str | None,
runtime_context: dict[str, Any] | None,
source_run_id: str | None = None,
) -> tuple[str, dict[str, Any]]:
run_id = str(uuid.uuid4())
metadata = _build_run_metadata(
session_id=session_id,
run_id=run_id,
prompt=prompt,
conversation_history=conversation_history,
ephemeral_system_prompt=ephemeral_system_prompt,
runtime_context=runtime_context,
status="started",
source_run_id=source_run_id,
)
service.state.record_run(run_id, metadata)
task = asyncio.create_task(
_execute_tracked_run(
service=service,
session_id=session_id,
run_id=run_id,
prompt=prompt,
conversation_history=conversation_history,
ephemeral_system_prompt=ephemeral_system_prompt,
runtime_context=runtime_context,
)
)
tasks = _get_chat_tasks(request.app)
tasks.add(task)
task.add_done_callback(tasks.discard)
return run_id, metadata
async def handle_chat_send(request: web.Request) -> web.Response:
"""Start a chat run and immediately return tracked run metadata."""
data = await _read_json_body(request)
if data is None:
return _json_error(
status=400,
code="invalid_json",
message="Request body must be a valid JSON object.",
)
prompt = data.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
return _json_error(
status=400,
code="invalid_prompt",
message="The 'prompt' field must be a non-empty string.",
)
session_id = data.get("session_id")
if session_id is None:
session_id = str(uuid.uuid4())
elif not isinstance(session_id, str) or not session_id:
return _json_error(
status=400,
code="invalid_session_id",
message="The 'session_id' field must be a non-empty string when provided.",
)
conversation_history = data.get("conversation_history")
if conversation_history is not None and not isinstance(conversation_history, list):
return _json_error(
status=400,
code="invalid_conversation_history",
message="The 'conversation_history' field must be a list when provided.",
)
if conversation_history is None:
try:
from hermes_state import SessionDB
db = SessionDB()
conversation_history = db.get_messages_as_conversation(session_id)
except Exception as e:
import logging
logging.getLogger(__name__).warning("Failed to load history from DB: %s", e)
conversation_history = []
runtime_context = data.get("runtime_context")
if runtime_context is not None and not isinstance(runtime_context, dict):
return _json_error(
status=400,
code="invalid_runtime_context",
message="The 'runtime_context' field must be an object when provided.",
)
ephemeral_system_prompt = data.get("ephemeral_system_prompt")
if ephemeral_system_prompt is not None and not isinstance(ephemeral_system_prompt, str):
return _json_error(
status=400,
code="invalid_ephemeral_system_prompt",
message="The 'ephemeral_system_prompt' field must be a string when provided.",
)
service = _get_chat_service(request)
run_id, _ = _start_tracked_run(
request,
service=service,
session_id=session_id,
prompt=prompt,
conversation_history=conversation_history,
ephemeral_system_prompt=ephemeral_system_prompt,
runtime_context=runtime_context,
)
return web.json_response(
{
"ok": True,
"session_id": session_id,
"run_id": run_id,
"status": "started",
}
)
async def handle_chat_background(request: web.Request) -> web.Response:
"""Start a detached chat run in a new ephemeral session."""
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
prompt = data.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
return _json_error(status=400, code="invalid_prompt", message="The 'prompt' field must be a non-empty string.")
# Always generate a new session ID for background runs so they don't pollute the caller's active session
bg_session_id = str(uuid.uuid4())
runtime_context = data.get("runtime_context", {})
runtime_context["is_background"] = True
ephemeral_system_prompt = data.get("ephemeral_system_prompt")
service = _get_chat_service(request)
# Note: We do NOT pass conversation_history. Background tasks execute freshly.
run_id, _ = _start_tracked_run(
request,
service=service,
session_id=bg_session_id,
prompt=prompt,
conversation_history=[],
ephemeral_system_prompt=ephemeral_system_prompt,
runtime_context=runtime_context,
)
return web.json_response(
{
"ok": True,
"session_id": bg_session_id,
"run_id": run_id,
"status": "started",
}
)
async def handle_chat_get_backgrounds(request: web.Request) -> web.Response:
"""Return all tracked runs that are marked as background jobs."""
service = _get_chat_service(request)
runs = list(service.state.runs.values())
bg_runs = [r for r in runs if r.get("runtime_context", {}).get("is_background")]
bg_runs.reverse() # Newest first
return web.json_response({"ok": True, "background_runs": bg_runs})
async def handle_chat_btw(request: web.Request) -> web.Response:
"""Start a quick-ask chat run that does not update session memory."""
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
prompt = data.get("prompt")
if not isinstance(prompt, str) or not prompt.strip():
return _json_error(status=400, code="invalid_prompt", message="The 'prompt' field must be a non-empty string.")
session_id = data.get("session_id")
if session_id is None:
session_id = str(uuid.uuid4())
elif not isinstance(session_id, str) or not session_id:
return _json_error(status=400, code="invalid_session_id", message="The 'session_id' field must be a non-empty string when provided.")
conversation_history = data.get("conversation_history")
if conversation_history is not None and not isinstance(conversation_history, list):
return _json_error(
status=400,
code="invalid_conversation_history",
message="The 'conversation_history' field must be a list when provided.",
)
if conversation_history is None:
try:
from hermes_state import SessionDB
db = SessionDB()
conversation_history = db.get_messages_as_conversation(session_id)
except Exception as e:
import logging
logging.getLogger(__name__).warning("Failed to load history from DB: %s", e)
conversation_history = []
runtime_context = data.get("runtime_context", {})
runtime_context["quick_ask"] = True
ephemeral_system_prompt = data.get("ephemeral_system_prompt")
service = _get_chat_service(request)
run_id, _ = _start_tracked_run(
request,
service=service,
session_id=session_id,
prompt=prompt,
conversation_history=conversation_history,
ephemeral_system_prompt=ephemeral_system_prompt,
runtime_context=runtime_context,
)
return web.json_response(
{
"ok": True,
"session_id": session_id,
"run_id": run_id,
"status": "started_btw",
}
)
async def handle_chat_stop(request: web.Request) -> web.Response:
"""Return stop capability metadata for a known run."""
data = await _read_json_body(request)
if data is None:
return _json_error(
status=400,
code="invalid_json",
message="Request body must be a valid JSON object.",
)
run_id = data.get("run_id")
if not isinstance(run_id, str) or not run_id:
return _json_error(
status=400,
code="invalid_run_id",
message="The 'run_id' field must be a non-empty string.",
)
service = _get_chat_service(request)
run = service.state.get_run(run_id)
if run is None:
return _json_error(
status=404,
code="run_not_found",
message="No tracked run was found for the provided run_id.",
run_id=run_id,
)
return web.json_response(
{
"ok": True,
"supported": False,
"action": "stop",
"run_id": run_id,
"session_id": run["session_id"],
"status": run.get("status"),
"stop_requested": False,
}
)
async def handle_chat_retry(request: web.Request) -> web.Response:
"""Retry a previously observed run by reusing its tracked input metadata."""
data = await _read_json_body(request)
if data is None:
return _json_error(
status=400,
code="invalid_json",
message="Request body must be a valid JSON object.",
)
run_id = data.get("run_id")
if not isinstance(run_id, str) or not run_id:
return _json_error(
status=400,
code="invalid_run_id",
message="The 'run_id' field must be a non-empty string.",
)
service = _get_chat_service(request)
existing_run = service.state.get_run(run_id)
if existing_run is None:
return _json_error(
status=404,
code="run_not_found",
message="No tracked run was found for the provided run_id.",
run_id=run_id,
)
new_run_id, _ = _start_tracked_run(
request,
service=service,
session_id=existing_run["session_id"],
prompt=existing_run["prompt"],
conversation_history=existing_run.get("conversation_history"),
ephemeral_system_prompt=existing_run.get("ephemeral_system_prompt"),
runtime_context=existing_run.get("runtime_context"),
source_run_id=run_id,
)
return web.json_response(
{
"ok": True,
"session_id": existing_run["session_id"],
"run_id": new_run_id,
"retried_from_run_id": run_id,
"status": "started",
}
)
async def handle_chat_undo(request: web.Request) -> web.Response:
"""Return structured undo capability metadata."""
data = await _read_json_body(request)
if data is None:
return _json_error(
status=400,
code="invalid_json",
message="Request body must be a valid JSON object.",
)
session_id = data.get("session_id")
run_id = data.get("run_id")
if session_id is not None and not isinstance(session_id, str):
return _json_error(
status=400,
code="invalid_session_id",
message="The 'session_id' field must be a string when provided.",
)
if run_id is not None and not isinstance(run_id, str):
return _json_error(
status=400,
code="invalid_run_id",
message="The 'run_id' field must be a string when provided.",
)
return web.json_response(
{
"ok": True,
"supported": False,
"action": "undo",
"session_id": session_id,
"run_id": run_id,
"status": "unavailable",
}
)
async def handle_chat_run(request: web.Request) -> web.Response:
"""Return tracked metadata for a previously observed run."""
run_id = request.match_info["run_id"]
service = _get_chat_service(request)
run = service.state.get_run(run_id)
if run is None:
return _json_error(
status=404,
code="run_not_found",
message="No tracked run was found for the provided run_id.",
run_id=run_id,
)
return web.json_response(
{
"ok": True,
"run": run,
}
)
async def handle_chat_compress(request: web.Request) -> web.Response:
"""Manually trigger context compression for a session."""
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
session_id = data.get("session_id")
if not isinstance(session_id, str) or not session_id:
return _json_error(status=400, code="invalid_session_id", message="The 'session_id' field must be a non-empty string.")
focus_topic = data.get("focus_topic") # Optional guided compression topic
from run_agent import AIAgent
# Initialize agent for context loading
# Run in executor to not block async loop if it's slow
def _do_compress():
try:
from hermes_state import SessionDB
session_db = SessionDB()
except Exception as e:
import logging
logging.getLogger(__name__).warning("SessionDB unavailable: %s", e)
session_db = None
agent = AIAgent(session_id=session_id, session_db=session_db)
if len(agent.messages) < agent.context_compressor.protect_first_n + agent.context_compressor.protect_last_n + 1:
return {"ok": True, "compressed": False, "reason": "not_enough_messages"}
orig_len = len(agent.messages)
compress_kwargs = {}
if focus_topic:
compress_kwargs["focus_topic"] = focus_topic
agent.messages = agent.context_compressor.compress(agent.messages, **compress_kwargs)
if len(agent.messages) < orig_len:
agent._flush_messages_to_session_db()
return {"ok": True, "compressed": True, "original_length": orig_len, "new_length": len(agent.messages), "focus_topic": focus_topic or None}
else:
return {"ok": True, "compressed": False, "reason": "no_reduction"}
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, _do_compress)
return web.json_response(result)
async def _cleanup_chat_tasks(app: web.Application) -> None:
tasks = list(_get_chat_tasks(app))
for task in tasks:
task.cancel()
for task in tasks:
with contextlib.suppress(asyncio.CancelledError):
await task
def register_chat_api_routes(app: web.Application) -> None:
"""Register web-console chat API routes on an aiohttp application."""
_get_chat_tasks(app)
if app.get(CHAT_STATE_APP_KEY) is None:
app[CHAT_STATE_APP_KEY] = create_web_console_state()
if app.get(CHAT_SERVICE_APP_KEY) is None:
app[CHAT_SERVICE_APP_KEY] = ChatService(
state=app[CHAT_STATE_APP_KEY],
human_service=app.get(HUMAN_SERVICE_APP_KEY),
)
if not app.get(CHAT_CLEANUP_REGISTERED_APP_KEY):
app.on_cleanup.append(_cleanup_chat_tasks)
app[CHAT_CLEANUP_REGISTERED_APP_KEY] = True
app.router.add_post("/api/gui/chat/send", handle_chat_send)
app.router.add_post("/api/gui/chat/background", handle_chat_background)
app.router.add_post("/api/gui/chat/btw", handle_chat_btw)
app.router.add_post("/api/gui/chat/compress", handle_chat_compress)
app.router.add_get("/api/gui/chat/backgrounds", handle_chat_get_backgrounds)
app.router.add_post("/api/gui/chat/stop", handle_chat_stop)
app.router.add_post("/api/gui/chat/retry", handle_chat_retry)
app.router.add_post("/api/gui/chat/undo", handle_chat_undo)
app.router.add_get("/api/gui/chat/run/{run_id}", handle_chat_run)

View File

@@ -0,0 +1,34 @@
"""Command registry API routes for the Hermes Web Console backend."""
from __future__ import annotations
from aiohttp import web
from hermes_cli.commands import COMMAND_REGISTRY
def _command_entry(command) -> dict[str, object]:
aliases = list(command.aliases)
names = [command.name, *aliases]
return {
"name": command.name,
"description": command.description,
"category": command.category,
"aliases": aliases,
"names": names,
"args_hint": command.args_hint,
"subcommands": list(command.subcommands),
"cli_only": command.cli_only,
"gateway_only": command.gateway_only,
"gateway_config_gate": command.gateway_config_gate,
}
async def handle_list_commands(request: web.Request) -> web.Response:
"""GET /api/gui/commands — expose the shared Hermes slash-command registry."""
commands = [_command_entry(command) for command in COMMAND_REGISTRY]
return web.json_response({"ok": True, "commands": commands})
def register_commands_api_routes(app: web.Application) -> None:
app.router.add_get("/api/gui/commands", handle_list_commands)

View File

@@ -0,0 +1,324 @@
"""Credential Pool API routes for the Hermes Web Console backend.
Provides:
GET /api/gui/credentials/pool — list all credential pool entries for a provider
POST /api/gui/credentials/pool — add a new credential to the pool
DELETE /api/gui/credentials/pool/:entry_id — remove a credential from the pool
POST /api/gui/credentials/device-auth — initiate device code auth flow (Codex/ChatGPT)
POST /api/gui/credentials/device-auth/poll — poll for device code completion
"""
from __future__ import annotations
import logging
import time
from aiohttp import web
logger = logging.getLogger(__name__)
async def handle_list_pool(request: web.Request) -> web.Response:
"""GET /api/gui/credentials/pool?provider=openai-codex — list credential pool entries."""
provider = request.query.get("provider", "openai-codex")
try:
from hermes_cli.auth import read_credential_pool, _load_auth_store
raw_entries = read_credential_pool(provider)
active_token = None
if provider == "openai-codex":
try:
from hermes_cli.auth import _read_codex_tokens
codex_store = _read_codex_tokens()
active_token = codex_store.get("tokens", {}).get("access_token")
except Exception:
pass
else:
try:
store = _load_auth_store()
provider_data = store.get("providers", {}).get(provider, {})
if isinstance(provider_data, dict):
active_token = provider_data.get("access_token")
except Exception:
pass
entries = []
if isinstance(raw_entries, list):
for entry in raw_entries:
if not isinstance(entry, dict):
continue
entries.append({
"id": entry.get("id", ""),
"label": entry.get("label", ""),
"auth_type": entry.get("auth_type", ""),
"source": entry.get("source", ""),
"priority": entry.get("priority", 0),
"last_status": entry.get("last_status"),
"last_status_at": entry.get("last_status_at"),
"last_error_code": entry.get("last_error_code"),
"last_error_reason": entry.get("last_error_reason"),
"request_count": entry.get("request_count", 0),
"has_token": bool(entry.get("access_token")),
"is_active": bool(active_token and entry.get("access_token") == active_token),
})
return web.json_response({"ok": True, "provider": provider, "entries": entries})
except Exception as exc:
logger.warning("list_pool failed: %s", exc)
return web.json_response({"ok": False, "error": str(exc)}, status=500)
async def handle_delete_pool_entry(request: web.Request) -> web.Response:
"""DELETE /api/gui/credentials/pool/:entry_id — remove a credential."""
entry_id = request.match_info.get("entry_id", "")
provider = request.query.get("provider", "openai-codex")
if not entry_id:
return web.json_response({"ok": False, "error": "entry_id is required"}, status=400)
try:
from hermes_cli.auth import read_credential_pool, write_credential_pool
raw_entries = read_credential_pool(provider)
if not isinstance(raw_entries, list):
return web.json_response({"ok": False, "error": "No pool found"}, status=404)
filtered = [e for e in raw_entries if isinstance(e, dict) and e.get("id") != entry_id]
if len(filtered) == len(raw_entries):
return web.json_response({"ok": False, "error": "Entry not found"}, status=404)
write_credential_pool(provider, filtered)
return web.json_response({"ok": True, "removed": entry_id})
except Exception as exc:
logger.warning("delete_pool_entry failed: %s", exc)
return web.json_response({"ok": False, "error": str(exc)}, status=500)
async def handle_activate_pool_entry(request: web.Request) -> web.Response:
"""POST /api/gui/credentials/pool/:entry_id/activate — set credential as active."""
entry_id = request.match_info.get("entry_id", "")
provider = request.query.get("provider", "openai-codex")
if not entry_id:
return web.json_response({"ok": False, "error": "entry_id is required"}, status=400)
try:
from hermes_cli.auth import read_credential_pool
raw_entries = read_credential_pool(provider)
if not isinstance(raw_entries, list):
return web.json_response({"ok": False, "error": "No pool found"}, status=404)
entry = next((e for e in raw_entries if isinstance(e, dict) and e.get("id") == entry_id), None)
if not entry:
return web.json_response({"ok": False, "error": "Entry not found"}, status=404)
if provider == "openai-codex":
from hermes_cli.auth import _save_codex_tokens
_save_codex_tokens(
{
"access_token": entry.get("access_token"),
"refresh_token": entry.get("refresh_token")
},
last_refresh=entry.get("last_refresh") or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
)
# Switch the active provider to openai-codex natively
try:
from hermes_cli.config import load_config, save_config
from hermes_cli.model_switch import detect_provider_for_model
cfg = load_config()
cfg["provider"] = "openai-codex"
old_model = cfg.get("model", {})
old_model_name = old_model if isinstance(old_model, str) else old_model.get("default", "")
if old_model_name:
detected, _ = detect_provider_for_model(old_model_name, "openai-codex")
if detected not in ("openai", "openai-codex", "custom"):
if "model" not in cfg:
cfg["model"] = {}
if isinstance(cfg["model"], dict):
cfg["model"]["default"] = "gpt-5.4"
cfg["model"]["name"] = "gpt-5.4"
save_config(cfg)
except Exception as exc:
logger.warning("Failed to update config.yaml during codex activation: %s", exc)
else:
from hermes_cli.auth import _save_provider_state, _load_auth_store, _save_auth_store
store = _load_auth_store()
_save_provider_state(store, provider, {
"access_token": entry.get("access_token"),
"refresh_token": entry.get("refresh_token"),
"last_refresh": entry.get("last_refresh"),
"auth_mode": entry.get("source", "oauth"),
"source": "gui",
})
_save_auth_store(store)
return web.json_response({"ok": True, "activated": entry_id})
except Exception as exc:
logger.warning("activate_pool_entry failed: %s", exc)
return web.json_response({"ok": False, "error": str(exc)}, status=500)
# --- Device Code Auth Flow ---
# In-flight device code sessions (keyed by device_code)
_device_sessions: dict[str, dict] = {}
async def handle_device_auth_start(request: web.Request) -> web.Response:
"""POST /api/gui/credentials/device-auth — start a Codex device code flow.
Returns the user_code and verification_uri for the user to authorize in browser.
"""
try:
body = await request.json()
except Exception:
body = {}
provider = body.get("provider", "openai-codex")
if provider != "openai-codex":
return web.json_response(
{"ok": False, "error": f"Device auth not supported for {provider}"},
status=400,
)
try:
from hermes_cli.auth import _codex_device_code_request
result = _codex_device_code_request()
# Store the session for polling
device_auth_id = result.get("device_auth_id", "")
# OpenAI doesn't return these in the new schema, so we hardcode them
verification_uri = "https://auth.openai.com/codex/device"
user_code = result.get("user_code", "")
interval = int(result.get("interval", "5"))
_device_sessions[device_auth_id] = {
"provider": provider,
"device_code": device_auth_id,
"user_code": user_code,
"verification_uri": verification_uri,
"verification_uri_complete": verification_uri,
"expires_in": 900, # 15 min default
"interval": interval,
"started_at": time.time(),
}
return web.json_response({
"ok": True,
"device_code": device_auth_id,
"user_code": user_code,
"verification_uri": verification_uri,
"verification_uri_complete": verification_uri,
"expires_in": 900,
"interval": interval,
})
except Exception as exc:
logger.warning("device_auth_start failed: %s", exc)
return web.json_response({"ok": False, "error": str(exc)}, status=500)
async def handle_device_auth_poll(request: web.Request) -> web.Response:
"""POST /api/gui/credentials/device-auth/poll — poll for device auth completion.
Body: {"device_code": "..."}
Returns: {"status": "pending|complete|expired|error", ...}
"""
try:
body = await request.json()
except Exception:
return web.json_response({"ok": False, "error": "Invalid JSON"}, status=400)
device_code = body.get("device_code", "")
label = body.get("label", "")
session = _device_sessions.get(device_code)
if not session:
return web.json_response(
{"ok": False, "status": "error", "error": "Unknown device code session"},
status=404,
)
# Check expiration
elapsed = time.time() - session["started_at"]
if elapsed > session["expires_in"]:
_device_sessions.pop(device_code, None)
return web.json_response({"ok": True, "status": "expired"})
try:
from hermes_cli.auth import _codex_device_code_poll
poll_result = _codex_device_code_poll(device_code, session["user_code"])
if poll_result is None:
return web.json_response({"ok": True, "status": "pending"})
# Success! We got tokens
access_token = poll_result.get("access_token", "")
refresh_token = poll_result.get("refresh_token", "")
# Auto-label from JWT
if not label:
try:
from hermes_cli.auth import _decode_jwt_claims
claims = _decode_jwt_claims(access_token)
label = claims.get("email") or claims.get("preferred_username") or "ChatGPT account"
except Exception:
label = "ChatGPT account"
# Add to credential pool
import uuid
entry_id = uuid.uuid4().hex[:6]
from hermes_cli.auth import read_credential_pool, write_credential_pool
existing = read_credential_pool("openai-codex")
if not isinstance(existing, list):
existing = []
new_entry = {
"id": entry_id,
"label": label,
"auth_type": "oauth",
"source": "chatgpt",
"priority": len(existing),
"access_token": access_token,
"refresh_token": refresh_token,
"last_refresh": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"request_count": 0,
}
existing.append(new_entry)
write_credential_pool("openai-codex", existing)
# Also persist to the legacy provider state for backward compatibility
try:
from hermes_cli.auth import _save_codex_tokens
_save_codex_tokens({
"access_token": access_token,
"refresh_token": refresh_token,
}, last_refresh=new_entry["last_refresh"])
except Exception as exc:
logger.warning("Failed to save codex tokens: %s", exc)
_device_sessions.pop(device_code, None)
return web.json_response({
"ok": True,
"status": "complete",
"entry_id": entry_id,
"label": label,
})
except Exception as exc:
logger.warning("device_auth_poll failed: %s", exc)
return web.json_response({
"ok": True,
"status": "error",
"error": str(exc),
})
def register_credentials_api_routes(app: web.Application) -> None:
app.router.add_get("/api/gui/credentials/pool", handle_list_pool)
app.router.add_delete("/api/gui/credentials/pool/{entry_id}", handle_delete_pool_entry)
app.router.add_post("/api/gui/credentials/pool/{entry_id}/activate", handle_activate_pool_entry)
app.router.add_post("/api/gui/credentials/device-auth", handle_device_auth_start)
app.router.add_post("/api/gui/credentials/device-auth/poll", handle_device_auth_poll)

View File

@@ -0,0 +1,190 @@
"""Cron API routes for the Hermes Web Console backend."""
from __future__ import annotations
import json
from typing import Any
from aiohttp import web
from gateway.web_console.services.cron_service import CronService
CRON_SERVICE_APP_KEY = web.AppKey("hermes_web_console_cron_service", CronService)
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
payload: dict[str, Any] = {
"ok": False,
"error": {
"code": code,
"message": message,
},
}
payload["error"].update(extra)
return web.json_response(payload, status=status)
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
try:
data = await request.json()
except (json.JSONDecodeError, ValueError, TypeError):
return None
if not isinstance(data, dict):
return None
return data
def _get_cron_service(request: web.Request) -> CronService:
service = request.app.get(CRON_SERVICE_APP_KEY)
if service is None:
service = CronService()
request.app[CRON_SERVICE_APP_KEY] = service
return service
def _parse_non_negative_int(value: str, *, field_name: str) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
raise ValueError(f"The '{field_name}' field must be an integer.")
if parsed < 0:
raise ValueError(f"The '{field_name}' field must be >= 0.")
return parsed
def _job_not_found(job_id: str) -> web.Response:
return _json_error(
status=404,
code="job_not_found",
message="No cron job was found for the provided job_id.",
job_id=job_id,
)
async def handle_list_jobs(request: web.Request) -> web.Response:
service = _get_cron_service(request)
include_disabled = request.query.get("include_disabled", "true").lower() not in {"0", "false", "no"}
payload = service.list_jobs(include_disabled=include_disabled)
return web.json_response({"ok": True, **payload})
async def handle_create_job(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
service = _get_cron_service(request)
try:
job = service.create_job(data)
except ValueError as exc:
return _json_error(status=400, code="invalid_job", message=str(exc))
except Exception as exc:
return _json_error(status=500, code="job_create_failed", message=str(exc))
return web.json_response({"ok": True, "job": job})
async def handle_get_job(request: web.Request) -> web.Response:
service = _get_cron_service(request)
job_id = request.match_info["job_id"]
job = service.get_job(job_id)
if job is None:
return _job_not_found(job_id)
return web.json_response({"ok": True, "job": job})
async def handle_update_job(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
service = _get_cron_service(request)
job_id = request.match_info["job_id"]
try:
job = service.update_job(job_id, data)
except ValueError as exc:
return _json_error(status=400, code="invalid_job", message=str(exc), job_id=job_id)
except Exception as exc:
return _json_error(status=500, code="job_update_failed", message=str(exc), job_id=job_id)
if job is None:
return _job_not_found(job_id)
return web.json_response({"ok": True, "job": job})
async def handle_run_job(request: web.Request) -> web.Response:
service = _get_cron_service(request)
job_id = request.match_info["job_id"]
job = service.run_job(job_id)
if job is None:
return _job_not_found(job_id)
return web.json_response({"ok": True, "job": job, "queued": True})
async def handle_pause_job(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if request.can_read_body and request.content_length not in (None, 0) and data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
reason = None if data is None else data.get("reason")
service = _get_cron_service(request)
job_id = request.match_info["job_id"]
try:
job = service.pause_job(job_id, reason=reason)
except ValueError as exc:
return _json_error(status=400, code="invalid_job", message=str(exc), job_id=job_id)
except Exception as exc:
return _json_error(status=500, code="job_pause_failed", message=str(exc), job_id=job_id)
if job is None:
return _job_not_found(job_id)
return web.json_response({"ok": True, "job": job})
async def handle_resume_job(request: web.Request) -> web.Response:
service = _get_cron_service(request)
job_id = request.match_info["job_id"]
job = service.resume_job(job_id)
if job is None:
return _job_not_found(job_id)
return web.json_response({"ok": True, "job": job})
async def handle_delete_job(request: web.Request) -> web.Response:
service = _get_cron_service(request)
job_id = request.match_info["job_id"]
deleted = service.delete_job(job_id)
if not deleted:
return _job_not_found(job_id)
return web.json_response({"ok": True, "job_id": job_id, "deleted": True})
async def handle_job_history(request: web.Request) -> web.Response:
service = _get_cron_service(request)
job_id = request.match_info["job_id"]
try:
limit = _parse_non_negative_int(request.query.get("limit", "20"), field_name="limit")
except ValueError as exc:
return _json_error(status=400, code="invalid_pagination", message=str(exc))
try:
payload = service.get_job_history(job_id, limit=limit)
except ValueError as exc:
return _json_error(status=400, code="invalid_pagination", message=str(exc), job_id=job_id)
except Exception as exc:
return _json_error(status=500, code="job_history_failed", message=str(exc), job_id=job_id)
if payload is None:
return _job_not_found(job_id)
return web.json_response({"ok": True, **payload})
def register_cron_api_routes(app: web.Application) -> None:
if app.get(CRON_SERVICE_APP_KEY) is None:
app[CRON_SERVICE_APP_KEY] = CronService()
app.router.add_get("/api/gui/cron/jobs", handle_list_jobs)
app.router.add_post("/api/gui/cron/jobs", handle_create_job)
app.router.add_get("/api/gui/cron/jobs/{job_id}", handle_get_job)
app.router.add_patch("/api/gui/cron/jobs/{job_id}", handle_update_job)
app.router.add_post("/api/gui/cron/jobs/{job_id}/run", handle_run_job)
app.router.add_post("/api/gui/cron/jobs/{job_id}/pause", handle_pause_job)
app.router.add_post("/api/gui/cron/jobs/{job_id}/resume", handle_resume_job)
app.router.add_delete("/api/gui/cron/jobs/{job_id}", handle_delete_job)
app.router.add_get("/api/gui/cron/jobs/{job_id}/history", handle_job_history)

View File

@@ -0,0 +1,345 @@
"""Gateway admin API routes for the Hermes Web Console backend."""
from __future__ import annotations
import json
from typing import Any
from aiohttp import web
from gateway.web_console.services.gateway_service import GatewayService
from gateway.web_console.services.settings_service import SettingsService
from hermes_cli.config import save_env_value
GATEWAY_SERVICE_APP_KEY = web.AppKey("hermes_web_console_gateway_service", GatewayService)
SETTINGS_SERVICE_APP_KEY = web.AppKey("hermes_web_console_settings_service", SettingsService)
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
payload: dict[str, Any] = {"ok": False, "error": {"code": code, "message": message}}
payload["error"].update(extra)
return web.json_response(payload, status=status)
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
try:
data = await request.json()
except (json.JSONDecodeError, ValueError, TypeError):
return None
if not isinstance(data, dict):
return None
return data
def _get_gateway_service(request: web.Request) -> GatewayService:
service = request.app.get(GATEWAY_SERVICE_APP_KEY)
if service is None:
service = GatewayService()
request.app[GATEWAY_SERVICE_APP_KEY] = service
return service
def _get_settings_service(request: web.Request) -> SettingsService:
service = request.app.get(SETTINGS_SERVICE_APP_KEY)
if service is None:
service = SettingsService()
request.app[SETTINGS_SERVICE_APP_KEY] = service
return service
def _require_non_empty_string(data: dict[str, Any], field_name: str) -> str | None:
value = data.get(field_name)
if not isinstance(value, str) or not value.strip():
return None
return value.strip()
async def handle_gateway_overview(request: web.Request) -> web.Response:
service = _get_gateway_service(request)
return web.json_response({"ok": True, "overview": service.get_overview()})
async def handle_gateway_platforms(request: web.Request) -> web.Response:
service = _get_gateway_service(request)
return web.json_response({"ok": True, "platforms": service.get_platforms()})
async def handle_gateway_pairing(request: web.Request) -> web.Response:
service = _get_gateway_service(request)
return web.json_response({"ok": True, "pairing": service.get_pairing_state()})
async def handle_gateway_pairing_approve(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
platform = _require_non_empty_string(data, "platform")
if platform is None:
return _json_error(status=400, code="invalid_platform", message="The 'platform' field must be a non-empty string.")
code = _require_non_empty_string(data, "code")
if code is None:
return _json_error(status=400, code="invalid_code", message="The 'code' field must be a non-empty string.")
service = _get_gateway_service(request)
approved = service.approve_pairing(platform=platform, code=code)
if approved is None:
return _json_error(
status=404,
code="pairing_not_found",
message="No pending pairing request was found for that platform/code.",
platform=platform.lower(),
pairing_code=code.upper(),
)
return web.json_response({"ok": True, "pairing": approved})
async def handle_gateway_pairing_revoke(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
platform = _require_non_empty_string(data, "platform")
if platform is None:
return _json_error(status=400, code="invalid_platform", message="The 'platform' field must be a non-empty string.")
user_id = _require_non_empty_string(data, "user_id")
if user_id is None:
return _json_error(status=400, code="invalid_user_id", message="The 'user_id' field must be a non-empty string.")
service = _get_gateway_service(request)
revoked = service.revoke_pairing(platform=platform, user_id=user_id)
if not revoked:
return _json_error(
status=404,
code="paired_user_not_found",
message="No approved pairing entry was found for that platform/user.",
platform=platform.lower(),
user_id=user_id,
)
return web.json_response(
{
"ok": True,
"pairing": {
"platform": platform.lower(),
"user_id": user_id,
"revoked": True,
},
}
)
async def handle_gateway_platform_config_get(request: web.Request) -> web.Response:
platform_name = request.match_info.get("name")
if not platform_name:
return _json_error(status=400, code="missing_platform", message="Platform name is required.")
settings_service = _get_settings_service(request)
settings = settings_service.get_settings()
platforms_config = settings.get("platforms", {})
platform_config = platforms_config.get(platform_name, {})
if "home_channel" in platform_config and isinstance(platform_config["home_channel"], dict):
platform_config["home_channel"] = platform_config["home_channel"].get("chat_id", "")
from hermes_cli.config import get_env_value
env_map = {
"telegram": "TELEGRAM_BOT_TOKEN",
"discord": "DISCORD_BOT_TOKEN",
"slack": "SLACK_BOT_TOKEN",
"mattermost": "MATTERMOST_TOKEN",
"matrix": "MATRIX_ACCESS_TOKEN",
"homeassistant": "HASS_TOKEN",
}
env_var = env_map.get(platform_name.lower())
if env_var:
val = get_env_value(env_var)
if val:
platform_config["token"] = val
if platform_name.lower() == "feishu":
for key, env_var in [
("app_id", "FEISHU_APP_ID"),
("app_secret", "FEISHU_APP_SECRET"),
("encrypt_key", "FEISHU_ENCRYPT_KEY"),
("verification_token", "FEISHU_VERIFICATION_TOKEN"),
]:
val = get_env_value(env_var)
if val:
platform_config[key] = val
elif platform_name.lower() == "wecom":
for key, env_var in [
("bot_id", "WECOM_BOT_ID"),
("secret", "WECOM_SECRET"),
]:
val = get_env_value(env_var)
if val:
platform_config[key] = val
return web.json_response({"ok": True, "config": platform_config})
async def handle_gateway_platform_config_patch(request: web.Request) -> web.Response:
platform_name = request.match_info.get("name")
if not platform_name:
return _json_error(status=400, code="missing_platform", message="Platform name is required.")
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
settings_service = _get_settings_service(request)
# Platform-specific env extraction
if platform_name.lower() == "feishu":
for key, env_var in [
("app_id", "FEISHU_APP_ID"),
("app_secret", "FEISHU_APP_SECRET"),
("encrypt_key", "FEISHU_ENCRYPT_KEY"),
("verification_token", "FEISHU_VERIFICATION_TOKEN"),
]:
if key in data:
save_env_value(env_var, data.pop(key))
elif platform_name.lower() == "wecom":
for key, env_var in [
("bot_id", "WECOM_BOT_ID"),
("secret", "WECOM_SECRET"),
]:
if key in data:
save_env_value(env_var, data.pop(key))
home_channel_val = data.get("home_channel")
if home_channel_val is not None:
if isinstance(home_channel_val, str):
if home_channel_val.strip():
data["home_channel"] = {
"platform": platform_name.lower(),
"chat_id": home_channel_val,
"name": "Home"
}
else:
data.pop("home_channel", None)
# We must patch the token separately via env vars if it exists so it goes to .env
token = data.pop("token", None)
if token is not None:
# Standardize env var names based on common conventions
env_map = {
"telegram": "TELEGRAM_BOT_TOKEN",
"discord": "DISCORD_BOT_TOKEN",
"slack": "SLACK_BOT_TOKEN",
"mattermost": "MATTERMOST_TOKEN",
"matrix": "MATRIX_ACCESS_TOKEN",
"homeassistant": "HASS_TOKEN",
}
env_var = env_map.get(platform_name.lower())
if env_var:
save_env_value(env_var, token)
else:
# Fallback to saving in config if no standard env var
data["token"] = token
patch_payload = {
"platforms": {
platform_name: data
}
}
try:
updated = settings_service.update_settings(patch_payload)
except Exception as exc:
return _json_error(status=400, code="invalid_patch", message=str(exc))
return web.json_response({
"ok": True,
"config": updated.get("platforms", {}).get(platform_name, {}),
"reload_required": True
})
async def handle_gateway_platform_start(request: web.Request) -> web.Response:
platform_name = request.match_info.get("name")
if not platform_name:
return _json_error(status=400, code="missing_platform", message="Platform name is required.")
settings_service = _get_settings_service(request)
patch_payload = {
"platforms": {
platform_name: {"enabled": True}
}
}
try:
settings_service.update_settings(patch_payload)
except Exception as exc:
return _json_error(status=400, code="invalid_patch", message=str(exc))
return web.json_response({"ok": True, "reload_required": True})
async def handle_gateway_platform_stop(request: web.Request) -> web.Response:
platform_name = request.match_info.get("name")
if not platform_name:
return _json_error(status=400, code="missing_platform", message="Platform name is required.")
settings_service = _get_settings_service(request)
patch_payload = {
"platforms": {
platform_name: {"enabled": False}
}
}
try:
settings_service.update_settings(patch_payload)
except Exception as exc:
return _json_error(status=400, code="invalid_patch", message=str(exc))
return web.json_response({"ok": True, "reload_required": True})
async def handle_gateway_restart(request: web.Request) -> web.Response:
from gateway.web_console.routes import ADAPTER_APP_KEY
adapter = request.app.get(ADAPTER_APP_KEY)
message_handler = getattr(adapter, "_message_handler", None)
runner = getattr(message_handler, "__self__", None)
request_restart = getattr(runner, "request_restart", None)
if not callable(request_restart):
return _json_error(
status=501,
code="restart_unsupported",
message="Gateway restart is not available from this web-console host.",
)
try:
accepted = bool(request_restart(detached=True, via_service=False))
except Exception as exc:
return _json_error(status=500, code="restart_failed", message=str(exc))
if not accepted:
return web.json_response({
"ok": True,
"accepted": False,
"message": "Gateway restart is already in progress.",
})
return web.json_response({
"ok": True,
"accepted": True,
"message": "Gateway restart requested. Active runs will drain before restart.",
})
def register_gateway_admin_api_routes(app: web.Application) -> None:
if app.get(GATEWAY_SERVICE_APP_KEY) is None:
app[GATEWAY_SERVICE_APP_KEY] = GatewayService()
app.router.add_get("/api/gui/gateway/overview", handle_gateway_overview)
app.router.add_get("/api/gui/gateway/platforms", handle_gateway_platforms)
app.router.add_get("/api/gui/gateway/platforms/{name}/config", handle_gateway_platform_config_get)
app.router.add_patch("/api/gui/gateway/platforms/{name}/config", handle_gateway_platform_config_patch)
app.router.add_post("/api/gui/gateway/platforms/{name}/start", handle_gateway_platform_start)
app.router.add_post("/api/gui/gateway/platforms/{name}/stop", handle_gateway_platform_stop)
app.router.add_post("/api/gui/gateway/restart", handle_gateway_restart)
app.router.add_get("/api/gui/gateway/pairing", handle_gateway_pairing)
app.router.add_post("/api/gui/gateway/pairing/approve", handle_gateway_pairing_approve)
app.router.add_post("/api/gui/gateway/pairing/revoke", handle_gateway_pairing_revoke)

View File

@@ -0,0 +1,50 @@
"""Logs API routes for the Hermes Web Console backend."""
from __future__ import annotations
from typing import Any
from aiohttp import web
from gateway.web_console.services.log_service import LogService
LOG_SERVICE_APP_KEY = web.AppKey("hermes_web_console_log_service", LogService)
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
payload: dict[str, Any] = {"ok": False, "error": {"code": code, "message": message}}
payload["error"].update(extra)
return web.json_response(payload, status=status)
def _get_log_service(request: web.Request) -> LogService:
service = request.app.get(LOG_SERVICE_APP_KEY)
if service is None:
service = LogService()
request.app[LOG_SERVICE_APP_KEY] = service
return service
async def handle_get_logs(request: web.Request) -> web.Response:
file_name = request.query.get("file") or None
limit_raw = request.query.get("limit", "200")
try:
limit = int(limit_raw)
except (TypeError, ValueError):
return _json_error(status=400, code="invalid_limit", message="The 'limit' field must be an integer.")
if limit < 0:
return _json_error(status=400, code="invalid_limit", message="The 'limit' field must be >= 0.")
service = _get_log_service(request)
try:
logs = service.get_logs(file_name=file_name, limit=limit)
except FileNotFoundError:
return _json_error(status=404, code="log_not_found", message="The requested log file was not found.", file=file_name)
return web.json_response({"ok": True, "logs": logs})
def register_logs_api_routes(app: web.Application) -> None:
if app.get(LOG_SERVICE_APP_KEY) is None:
app[LOG_SERVICE_APP_KEY] = LogService()
app.router.add_get("/api/gui/logs", handle_get_logs)

View File

@@ -0,0 +1,52 @@
import logging
from aiohttp import web
logger = logging.getLogger("mcp_api")
async def handle_get_mcp_servers(request: web.Request) -> web.Response:
"""List all configured MCP servers and their connection status."""
try:
from tools.mcp_tool import get_mcp_status
status_list = get_mcp_status()
return web.json_response({"servers": status_list})
except Exception as e:
logger.error(f"Failed to fetch MCP servers: {e}")
return web.json_response({"servers": []})
async def handle_reload_mcp_servers(request: web.Request) -> web.Response:
"""Shutdown and reload MCP servers from config."""
try:
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _servers, _lock
logger.info("Reloading MCP servers from GUI request...")
old_servers = set()
if _lock:
with _lock:
old_servers = set(_servers.keys())
# Disconnect all existing servers
shutdown_mcp_servers()
# Reconnect using updated config
new_tools = discover_mcp_tools()
new_servers = set()
if _lock:
with _lock:
new_servers = set(_servers.keys())
return web.json_response({
"success": True,
"tools_count": len(new_tools),
"servers_count": len(new_servers),
"added": list(new_servers - old_servers),
"removed": list(old_servers - new_servers),
"reconnected": list(new_servers & old_servers)
})
except Exception as e:
logger.error(f"Failed to reload MCP servers: {e}")
return web.json_response({"success": False, "error": str(e)}, status=500)
def register_mcp_api_routes(app: web.Application) -> None:
app.router.add_get("/api/gui/mcp/servers", handle_get_mcp_servers)
app.router.add_post("/api/gui/mcp/reload", handle_reload_mcp_servers)

View File

@@ -0,0 +1,131 @@
"""Media API routes for the Hermes Web Console backend."""
from __future__ import annotations
import importlib.util
import json
import uuid
from pathlib import Path
from types import ModuleType
from typing import Any
from aiohttp import web
from hermes_cli.config import ensure_hermes_home, get_hermes_home
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
payload: dict[str, Any] = {"ok": False, "error": {"code": code, "message": message}}
payload["error"].update(extra)
return web.json_response(payload, status=status)
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
try:
data = await request.json()
except (json.JSONDecodeError, ValueError, TypeError):
return None
if not isinstance(data, dict):
return None
return data
def _load_tool_module(module_name: str, file_name: str) -> ModuleType:
module_path = Path(__file__).resolve().parents[3] / "tools" / file_name
spec = importlib.util.spec_from_file_location(module_name, module_path)
if spec is None or spec.loader is None:
raise RuntimeError(f"Could not load tool module from {module_path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _media_upload_dir() -> Path:
ensure_hermes_home()
target = get_hermes_home() / "uploads" / "web_console"
target.mkdir(parents=True, exist_ok=True)
return target
async def handle_media_upload(request: web.Request) -> web.Response:
if not request.content_type.startswith("multipart/"):
return _json_error(status=400, code="invalid_upload", message="Upload requests must use multipart/form-data.")
reader = await request.multipart()
part = await reader.next()
if part is None or part.name != "file":
return _json_error(status=400, code="missing_file", message="The upload must include a 'file' field.")
filename = part.filename or f"upload-{uuid.uuid4().hex}"
safe_name = Path(filename).name or f"upload-{uuid.uuid4().hex}"
destination = _media_upload_dir() / safe_name
if destination.exists():
destination = _media_upload_dir() / f"{destination.stem}-{uuid.uuid4().hex[:8]}{destination.suffix}"
size = 0
with destination.open("wb") as handle:
while True:
chunk = await part.read_chunk()
if not chunk:
break
size += len(chunk)
handle.write(chunk)
return web.json_response(
{
"ok": True,
"media": {
"file_path": str(destination),
"filename": destination.name,
"content_type": part.headers.get("Content-Type"),
"size": size,
},
}
)
async def handle_media_transcribe(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
file_path = data.get("file_path")
if not isinstance(file_path, str) or not file_path.strip():
return _json_error(status=400, code="invalid_file_path", message="The 'file_path' field must be a non-empty string.")
transcription_tools = _load_tool_module("hermes_web_console_transcription_tools", "transcription_tools.py")
result = transcription_tools.transcribe_audio(file_path.strip(), model=data.get("model"))
status = 200 if result.get("success") else 400
return web.json_response({"ok": bool(result.get("success")), "transcription": result}, status=status)
async def handle_media_tts(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
text = data.get("text")
if not isinstance(text, str) or not text.strip():
return _json_error(status=400, code="invalid_text", message="The 'text' field must be a non-empty string.")
output_path = data.get("output_path")
if output_path is not None and not isinstance(output_path, str):
return _json_error(status=400, code="invalid_output_path", message="The 'output_path' field must be a string when provided.")
tts_tool = _load_tool_module("hermes_web_console_tts_tool", "tts_tool.py")
raw_result = tts_tool.text_to_speech_tool(text=text, output_path=output_path)
try:
result = json.loads(raw_result)
except (TypeError, ValueError):
result = {"success": False, "error": "TTS returned an invalid payload.", "raw_result": raw_result}
status = 200 if result.get("success") else 400
return web.json_response({"ok": bool(result.get("success")), "tts": result}, status=status)
async def handle_media_transcribe_upload(request: web.Request) -> web.Response:
return _json_error(status=501, code="not_implemented", message="Use /api/gui/media/upload followed by /api/gui/media/transcribe.")
def register_media_api_routes(app: web.Application) -> None:
app.router.add_post("/api/gui/media/upload", handle_media_upload)
app.router.add_post("/api/gui/media/transcribe", handle_media_transcribe)
app.router.add_post("/api/gui/media/tts", handle_media_tts)

View File

@@ -0,0 +1,195 @@
"""GUI backend API routes for Memory and session search."""
from __future__ import annotations
import json
from typing import Any
from aiohttp import web
from gateway.web_console.services.memory_service import MemoryService
MEMORY_SERVICE_APP_KEY = web.AppKey("hermes_web_console_memory_service", MemoryService)
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
payload: dict[str, Any] = {"ok": False, "error": {"code": code, "message": message}}
payload["error"].update(extra)
return web.json_response(payload, status=status)
def _get_memory_service(request: web.Request) -> MemoryService:
service = request.app.get(MEMORY_SERVICE_APP_KEY)
if service is None:
service = MemoryService()
request.app[MEMORY_SERVICE_APP_KEY] = service
return service
async def handle_get_memory(request: web.Request) -> web.Response:
"""GET /api/gui/memory — return structured memory entries."""
service = _get_memory_service(request)
try:
payload = service.get_memory(target="memory")
except Exception as exc:
return _json_error(status=500, code="memory_error", message=str(exc))
return web.json_response({"ok": True, "memory": payload})
async def handle_get_user_profile(request: web.Request) -> web.Response:
"""GET /api/gui/user-profile — return structured user profile entries."""
service = _get_memory_service(request)
try:
payload = service.get_memory(target="user")
except Exception as exc:
return _json_error(status=500, code="memory_error", message=str(exc))
return web.json_response({"ok": True, "user_profile": payload})
async def handle_add_memory(request: web.Request) -> web.Response:
"""POST /api/gui/memory — add an entry to memory or user profile."""
try:
data = await request.json()
except (json.JSONDecodeError, ValueError, TypeError):
return _json_error(status=400, code="invalid_json", message="Request body must be valid JSON.")
if not isinstance(data, dict):
return _json_error(status=400, code="invalid_json", message="Request body must be a JSON object.")
target = data.get("target", "memory")
content = data.get("content")
service = _get_memory_service(request)
try:
result = service.mutate_memory(action="add", target=target, content=content)
except PermissionError as exc:
return _json_error(status=403, code="memory_disabled", message=str(exc))
except ValueError as exc:
return _json_error(status=400, code="invalid_target", message=str(exc))
except Exception as exc:
return _json_error(status=500, code="memory_error", message=str(exc))
return web.json_response({"ok": True, "memory": result})
async def handle_update_memory(request: web.Request) -> web.Response:
"""PATCH /api/gui/memory — replace an entry in memory or user profile."""
try:
data = await request.json()
except (json.JSONDecodeError, ValueError, TypeError):
return _json_error(status=400, code="invalid_json", message="Request body must be valid JSON.")
if not isinstance(data, dict):
return _json_error(status=400, code="invalid_json", message="Request body must be a JSON object.")
target = data.get("target", "memory")
content = data.get("content")
old_text = data.get("old_text")
service = _get_memory_service(request)
try:
result = service.mutate_memory(action="replace", target=target, content=content, old_text=old_text)
except PermissionError as exc:
return _json_error(status=403, code="memory_disabled", message=str(exc))
except ValueError as exc:
return _json_error(status=400, code="invalid_target", message=str(exc))
except Exception as exc:
return _json_error(status=500, code="memory_error", message=str(exc))
if not result.get("success"):
extra: dict[str, Any] = {}
if "matches" in result:
extra["matches"] = result["matches"]
return _json_error(
status=400,
code="memory_update_failed",
message=result.get("error", "Update failed."),
**extra,
)
return web.json_response({"ok": True, "memory": result})
async def handle_delete_memory(request: web.Request) -> web.Response:
"""DELETE /api/gui/memory — remove an entry from memory or user profile."""
try:
data = await request.json()
except (json.JSONDecodeError, ValueError, TypeError):
return _json_error(status=400, code="invalid_json", message="Request body must be valid JSON.")
if not isinstance(data, dict):
return _json_error(status=400, code="invalid_json", message="Request body must be a JSON object.")
target = data.get("target", "memory")
old_text = data.get("old_text")
service = _get_memory_service(request)
try:
result = service.mutate_memory(action="remove", target=target, old_text=old_text)
except PermissionError as exc:
return _json_error(status=403, code="memory_disabled", message=str(exc))
except ValueError as exc:
return _json_error(status=400, code="invalid_target", message=str(exc))
except Exception as exc:
return _json_error(status=500, code="memory_error", message=str(exc))
return web.json_response({"ok": True, "memory": result})
async def handle_session_search(request: web.Request) -> web.Response:
"""GET /api/gui/session-search — search past sessions."""
query = request.query.get("query")
if not query or not query.strip():
return _json_error(status=400, code="missing_query", message="The 'query' parameter is required.")
role_filter = request.query.get("role_filter")
current_session_id = request.query.get("current_session_id")
limit_raw = request.query.get("limit")
limit = 3
if limit_raw is not None:
try:
limit = int(limit_raw)
if limit < 1:
raise ValueError()
except (ValueError, TypeError):
return _json_error(status=400, code="invalid_search", message="The 'limit' parameter must be a positive integer.")
service = _get_memory_service(request)
try:
result = service.search_sessions(
query=query.strip(),
role_filter=role_filter,
limit=limit,
current_session_id=current_session_id,
)
except RuntimeError as exc:
return _json_error(status=500, code="search_failed", message=str(exc))
except Exception as exc:
return _json_error(status=500, code="search_failed", message=str(exc))
if not result.get("success", True):
return _json_error(
status=503,
code="search_failed",
message=result.get("error", "Session search failed."),
)
return web.json_response({"ok": True, "search": result})
def register_memory_api_routes(app: web.Application) -> None:
if app.get(MEMORY_SERVICE_APP_KEY) is None:
try:
app[MEMORY_SERVICE_APP_KEY] = MemoryService()
except Exception:
# MemoryService may fail to initialize in test environments
# where the memory tool module is not available.
pass
app.router.add_get("/api/gui/memory", handle_get_memory)
app.router.add_post("/api/gui/memory", handle_add_memory)
app.router.add_patch("/api/gui/memory", handle_update_memory)
app.router.add_delete("/api/gui/memory", handle_delete_memory)
app.router.add_get("/api/gui/user-profile", handle_get_user_profile)
app.router.add_get("/api/gui/session-search", handle_session_search)

View File

@@ -0,0 +1,46 @@
"""Metrics API routes for the Hermes Web Console backend."""
import logging
import psutil
import time
from aiohttp import web
logger = logging.getLogger("metrics_api")
async def handle_get_metrics_global(request: web.Request) -> web.Response:
try:
from hermes_state import SessionDB
from agent.insights import InsightsEngine
from tools.process_registry import process_registry
cron_jobs_count = 0
try:
from cron.scheduler import scheduler
cron_jobs_count = len(scheduler.get_jobs()) if hasattr(scheduler, "get_jobs") else 0
except Exception:
pass
db = SessionDB()
engine = InsightsEngine(db)
# Fetch current day usage as a representative metric snippet
report = engine.generate(days=1)
db.close()
metrics = {
"token_usage_today": report.get("total_tokens", 0),
"cost_today": report.get("estimated_cost_usd", 0.0),
"active_processes": len([s for s in process_registry.list_sessions() if s.get("status") == "running"]),
"cron_jobs": cron_jobs_count,
"cpu_percent": psutil.cpu_percent(interval=None),
"memory_percent": psutil.virtual_memory().percent,
"uptime_seconds": time.time() - psutil.boot_time(),
}
return web.json_response({"ok": True, "metrics": metrics})
except Exception as e:
logger.error(f"Failed to fetch global metrics: {e}")
return web.json_response({"ok": False, "error": str(e)}, status=500)
def register_metrics_api_routes(app: web.Application) -> None:
app.router.add_get("/api/gui/metrics/global", handle_get_metrics_global)

View File

@@ -0,0 +1,44 @@
"""GUI backend API routes for Missions Kanban Board."""
import json
from pathlib import Path
from aiohttp import web
from hermes_constants import get_hermes_home
def get_missions_file_path() -> Path:
return get_hermes_home() / "missions.json"
async def handle_get_missions(request: web.Request) -> web.Response:
"""Retrieve missions.json content."""
missions_file = get_missions_file_path()
if not missions_file.exists():
return web.json_response({"ok": True, "columns": None})
try:
content = missions_file.read_text(encoding="utf-8")
data = json.loads(content)
return web.json_response({"ok": True, "columns": data})
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
async def handle_update_missions(request: web.Request) -> web.Response:
"""Overwrite missions.json content entirely."""
try:
data = await request.json()
columns = data.get("columns", [])
except json.JSONDecodeError:
return web.json_response({"ok": False, "error": "Invalid JSON"}, status=400)
missions_file = get_missions_file_path()
try:
missions_file.parent.mkdir(parents=True, exist_ok=True)
missions_file.write_text(json.dumps(columns, indent=2), encoding="utf-8")
return web.json_response({"ok": True})
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=500)
def register_missions_api_routes(app: web.Application) -> None:
app.router.add_get("/api/gui/missions", handle_get_missions)
app.router.add_post("/api/gui/missions", handle_update_missions)

View File

@@ -0,0 +1,235 @@
"""Models API routes for the Hermes Web Console backend.
Provides:
GET /api/gui/models/catalog — authenticated providers with curated models
GET /api/gui/models/active — currently active model + provider
POST /api/gui/models/switch — live model/provider switch (session or global)
"""
from __future__ import annotations
import logging
import yaml
from pathlib import Path
from aiohttp import web
from hermes_constants import get_hermes_home
logger = logging.getLogger(__name__)
def _read_model_config() -> dict:
"""Read model/provider/base_url/providers from config.yaml."""
config_path = get_hermes_home() / "config.yaml"
result = {
"model": "",
"provider": "openrouter",
"base_url": "",
"api_key": "",
"user_providers": None,
}
try:
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
model_cfg = cfg.get("model", {})
if isinstance(model_cfg, dict):
result["model"] = model_cfg.get("name", "") or model_cfg.get("default", "")
result["provider"] = model_cfg.get("provider", "openrouter")
result["base_url"] = model_cfg.get("base_url", "")
elif isinstance(model_cfg, str):
result["model"] = model_cfg
result["user_providers"] = cfg.get("providers")
except Exception:
logger.debug("Failed to read model config", exc_info=True)
return result
async def handle_get_models_catalog(request: web.Request) -> web.Response:
"""GET /api/gui/models/catalog — authenticated providers with curated models."""
from hermes_cli.model_switch import list_authenticated_providers
from hermes_cli.providers import get_label
cfg = _read_model_config()
current_model = cfg["model"]
current_provider = cfg["provider"]
try:
providers = list_authenticated_providers(
current_provider=current_provider,
user_providers=cfg["user_providers"],
max_models=20,
)
except Exception as exc:
logger.warning("list_authenticated_providers failed: %s", exc)
providers = []
return web.json_response({
"ok": True,
"current_model": current_model,
"current_provider": current_provider,
"current_provider_label": get_label(current_provider),
"providers": providers,
})
async def handle_get_models_active(request: web.Request) -> web.Response:
"""GET /api/gui/models/active — current model + provider + metadata."""
from hermes_cli.providers import get_label
from agent.models_dev import get_model_info, get_model_capabilities
cfg = _read_model_config()
current_model = cfg["model"]
current_provider = cfg["provider"]
provider_label = get_label(current_provider)
# Try to get rich metadata from models.dev
metadata: dict = {}
try:
mi = get_model_info(current_provider, current_model)
if mi:
metadata["context_window"] = mi.context_window or 0
metadata["max_output"] = mi.max_output or 0
if mi.has_cost_data():
metadata["cost"] = mi.format_cost()
metadata["capabilities"] = mi.format_capabilities()
except Exception:
pass
return web.json_response({
"ok": True,
"model": current_model,
"provider": current_provider,
"provider_label": provider_label,
**metadata,
})
async def handle_post_models_switch(request: web.Request) -> web.Response:
"""POST /api/gui/models/switch — live model/provider switch."""
from hermes_cli.model_switch import switch_model as _switch_model
from hermes_cli.providers import determine_api_mode
try:
body = await request.json()
except Exception:
return web.json_response({"ok": False, "error": "Invalid JSON body"}, status=400)
model_input = body.get("model", "").strip()
explicit_provider = body.get("provider", "").strip()
persist_global = body.get("global", False)
if not model_input and not explicit_provider:
return web.json_response(
{"ok": False, "error": "Provide 'model' and/or 'provider'"},
status=400,
)
cfg = _read_model_config()
result = _switch_model(
raw_input=model_input,
current_provider=cfg["provider"],
current_model=cfg["model"],
current_base_url=cfg["base_url"],
current_api_key=cfg["api_key"],
is_global=persist_global,
explicit_provider=explicit_provider,
user_providers=cfg["user_providers"],
)
if not result.success:
return web.json_response({
"ok": False,
"error": result.error_message,
}, status=400)
# Validate provider compatibility (specifically for Codex)
codex_compatible = ("openai", "openai-codex", "custom")
# Guard 1: Currently on Codex — new model must be OpenAI-compatible
if cfg.get("provider") == "openai-codex":
if result.target_provider not in codex_compatible:
return web.json_response({
"ok": False,
"error": f"The '{result.new_model}' model is not supported when using Codex with a ChatGPT account. Please select an OpenAI model or change your active provider to {result.target_provider}."
}, status=400)
# Guard 2: Switching TO Codex — resolved model must be OpenAI-compatible
if result.target_provider == "openai-codex":
if cfg.get("provider") not in codex_compatible and result.target_provider not in codex_compatible:
return web.json_response({
"ok": False,
"error": f"Cannot switch to Codex with model '{result.new_model}' (resolved provider: {result.target_provider}). Only OpenAI models are compatible with Codex."
}, status=400)
# Persist to config.yaml if global
if persist_global:
try:
config_path = get_hermes_home() / "config.yaml"
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
file_cfg = yaml.safe_load(f) or {}
else:
file_cfg = {}
# Ensure model is a dict before setting properties
model_cfg = file_cfg.get("model", {})
if isinstance(model_cfg, str):
model_cfg = {"default": model_cfg}
elif not isinstance(model_cfg, dict):
model_cfg = {}
model_cfg["default"] = result.new_model
model_cfg["provider"] = result.target_provider
model_cfg["name"] = result.new_model # Keep name for backward compatibility in UI
if result.base_url:
model_cfg["base_url"] = result.base_url
file_cfg["model"] = model_cfg
from hermes_cli.config import save_config
save_config(file_cfg)
except Exception as exc:
logger.warning("Failed to persist model switch: %s", exc)
# Build rich response
response: dict = {
"ok": True,
"new_model": result.new_model,
"provider": result.target_provider,
"provider_label": result.provider_label or result.target_provider,
"provider_changed": result.provider_changed,
"is_global": persist_global,
}
# Add model metadata
mi = result.model_info
if mi:
if mi.context_window:
response["context_window"] = mi.context_window
if mi.max_output:
response["max_output"] = mi.max_output
if mi.has_cost_data():
response["cost"] = mi.format_cost()
response["capabilities"] = mi.format_capabilities()
# Cache status
cache_enabled = (
("openrouter" in (result.base_url or "").lower()
and "claude" in result.new_model.lower())
or result.api_mode == "anthropic_messages"
)
response["cache_enabled"] = cache_enabled
if result.warning_message:
response["warning"] = result.warning_message
if result.resolved_via_alias:
response["resolved_via_alias"] = result.resolved_via_alias
return web.json_response(response)
def register_models_api_routes(app: web.Application) -> None:
app.router.add_get("/api/gui/models/catalog", handle_get_models_catalog)
app.router.add_get("/api/gui/models/active", handle_get_models_active)
app.router.add_post("/api/gui/models/switch", handle_post_models_switch)

View File

@@ -0,0 +1,14 @@
from aiohttp import web
from hermes_cli.plugins import get_plugin_manager, discover_plugins
async def handle_get_plugins(request: web.Request) -> web.Response:
# Ensure plugins are discovered
discover_plugins()
manager = get_plugin_manager()
plugins = manager.list_plugins()
return web.json_response({"ok": True, "plugins": plugins})
def register_plugins_api_routes(app: web.Application) -> None:
app.router.add_get("/api/gui/plugins", handle_get_plugins)

View File

@@ -0,0 +1,146 @@
from aiohttp import web
from dataclasses import asdict
from hermes_cli.profiles import (
list_profiles,
create_profile,
delete_profile,
get_active_profile,
set_active_profile,
export_profile,
import_profile,
)
import tempfile
import os
async def handle_get_profiles(request: web.Request) -> web.Response:
profiles = list_profiles()
active_profile = get_active_profile()
# Convert ProfileInfo dataclasses to dicts
profile_list = []
for p in profiles:
pd = asdict(p)
pd["path"] = str(pd["path"])
if pd["alias_path"]:
pd["alias_path"] = str(pd["alias_path"])
pd["is_active"] = p.name == active_profile
profile_list.append(pd)
return web.json_response({"ok": True, "profiles": profile_list, "active_profile": active_profile})
async def handle_post_profile(request: web.Request) -> web.Response:
data = await request.json()
name = data.get("name")
if not name:
return web.json_response({"ok": False, "error": "Profile name is required"}, status=400)
try:
new_dir = create_profile(
name=name,
clone_from=data.get("clone_from"),
clone_all=data.get("clone_all", False),
clone_config=data.get("clone_config", False),
no_alias=data.get("no_alias", False),
)
return web.json_response({"ok": True, "path": str(new_dir)})
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=400)
async def handle_delete_profile(request: web.Request) -> web.Response:
name = request.match_info.get("name")
if not name:
return web.json_response({"ok": False, "error": "Profile name is required"}, status=400)
try:
deleted_dir = delete_profile(name, yes=True)
return web.json_response({"ok": True, "path": str(deleted_dir)})
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=400)
async def handle_set_active_profile(request: web.Request) -> web.Response:
data = await request.json()
name = data.get("name")
if not name:
return web.json_response({"ok": False, "error": "Profile name is required"}, status=400)
try:
set_active_profile(name)
return web.json_response({"ok": True})
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=400)
async def handle_export_profile(request: web.Request) -> web.Response:
name = request.match_info.get("name")
if not name:
return web.json_response({"ok": False, "error": "Profile name is required"}, status=400)
fd, temp_path = tempfile.mkstemp(suffix=".tar.gz")
os.close(fd)
try:
export_profile(name, temp_path)
with open(temp_path, "rb") as f:
data = f.read()
response = web.Response(body=data)
response.headers['Content-Disposition'] = f'attachment; filename="profile_{name}.tar.gz"'
response.headers['Content-Type'] = 'application/gzip'
return response
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=400)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
async def handle_import_profile(request: web.Request) -> web.Response:
try:
reader = await request.multipart()
name = None
file_data = None
while True:
field = await reader.next()
if field is None:
break
if field.name == 'name':
name = await field.read()
name = name.decode('utf-8').strip()
elif field.name == 'file':
file_data = await field.read()
if not file_data:
return web.json_response({"ok": False, "error": "No file data uploaded"}, status=400)
fd, temp_path = tempfile.mkstemp(suffix=".tar.gz")
os.close(fd)
try:
with open(temp_path, "wb") as f:
f.write(file_data)
# If name is empty string or None, import_profile infers it
import_name = name if name else None
imported_dir = import_profile(temp_path, import_name)
return web.json_response({"ok": True, "path": str(imported_dir)})
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=400)
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
except Exception as e:
return web.json_response({"ok": False, "error": str(e)}, status=400)
def register_profiles_api_routes(app: web.Application) -> None:
app.router.add_get("/api/gui/profiles", handle_get_profiles)
app.router.add_post("/api/gui/profiles", handle_post_profile)
app.router.add_delete("/api/gui/profiles/{name}", handle_delete_profile)
app.router.add_post("/api/gui/profiles/active", handle_set_active_profile)
app.router.add_get("/api/gui/profiles/{name}/export", handle_export_profile)
app.router.add_post("/api/gui/profiles/import", handle_import_profile)

View File

@@ -0,0 +1,268 @@
"""Sessions API routes for the Hermes Web Console backend."""
from __future__ import annotations
import json
from typing import Any
from aiohttp import web
from gateway.web_console.services.session_service import SessionService
SESSIONS_SERVICE_APP_KEY = web.AppKey("hermes_web_console_sessions_service", SessionService)
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
payload: dict[str, Any] = {
"ok": False,
"error": {
"code": code,
"message": message,
},
}
payload["error"].update(extra)
return web.json_response(payload, status=status)
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
try:
data = await request.json()
except (json.JSONDecodeError, ValueError, TypeError):
return None
if not isinstance(data, dict):
return None
return data
def _get_session_service(request: web.Request) -> SessionService:
return request.app[SESSIONS_SERVICE_APP_KEY]
def _parse_non_negative_int(value: str, *, field_name: str) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
raise ValueError(f"The '{field_name}' field must be an integer.")
if parsed < 0:
raise ValueError(f"The '{field_name}' field must be >= 0.")
return parsed
async def handle_list_sessions(request: web.Request) -> web.Response:
service = _get_session_service(request)
source = request.query.get("source") or None
try:
limit = _parse_non_negative_int(request.query.get("limit", "20"), field_name="limit")
offset = _parse_non_negative_int(request.query.get("offset", "0"), field_name="offset")
except ValueError as exc:
return _json_error(status=400, code="invalid_pagination", message=str(exc))
sessions = service.list_sessions(source=source, limit=limit, offset=offset)
return web.json_response({"ok": True, "sessions": sessions})
async def handle_get_session(request: web.Request) -> web.Response:
service = _get_session_service(request)
session_id = request.match_info["session_id"]
session = service.get_session_detail(session_id)
if session is None:
return _json_error(status=404, code="session_not_found", message="No session was found for the provided session_id.", session_id=session_id)
return web.json_response({"ok": True, "session": session})
async def handle_get_transcript(request: web.Request) -> web.Response:
service = _get_session_service(request)
session_id = request.match_info["session_id"]
transcript = service.get_transcript(session_id)
if transcript is None:
return _json_error(status=404, code="session_not_found", message="No session was found for the provided session_id.", session_id=session_id)
return web.json_response({"ok": True, **transcript})
async def handle_set_title(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
title = data.get("title")
if not isinstance(title, str):
return _json_error(status=400, code="invalid_title", message="The 'title' field must be a string.")
service = _get_session_service(request)
session_id = request.match_info["session_id"]
try:
result = service.set_title(session_id, title)
except ValueError as exc:
return _json_error(status=400, code="invalid_title", message=str(exc))
if result is None:
return _json_error(status=404, code="session_not_found", message="No session was found for the provided session_id.", session_id=session_id)
return web.json_response({"ok": True, **result})
async def handle_resume_session(request: web.Request) -> web.Response:
service = _get_session_service(request)
session_id = request.match_info["session_id"]
result = service.resume_session(session_id)
if result is None:
return _json_error(status=404, code="session_not_found", message="No session was found for the provided session_id.", session_id=session_id)
return web.json_response({"ok": True, **result})
async def handle_delete_session(request: web.Request) -> web.Response:
service = _get_session_service(request)
session_id = request.match_info["session_id"]
deleted = service.delete_session(session_id)
if not deleted:
return _json_error(status=404, code="session_not_found", message="No session was found for the provided session_id.", session_id=session_id)
return web.json_response({"ok": True, "session_id": session_id, "deleted": True})
async def handle_export_session(request: web.Request) -> web.Response:
service = _get_session_service(request)
session_id = request.match_info["session_id"]
export_format = request.query.get("format", "md").lower()
resolved = service.db.resolve_session_id(session_id) or session_id
exported = service.db.export_session(resolved)
if not exported:
return _json_error(status=404, code="session_not_found", message="No session was found for the provided session_id.", session_id=session_id)
if export_format == "json":
# export JSON as an attachment
def default_serializer(obj: Any) -> Any:
try:
return json.dumps(obj)
except Exception:
return str(obj)
response_text = json.dumps(exported, default=default_serializer, indent=2)
response = web.Response(text=response_text, content_type="application/json")
response.headers["Content-Disposition"] = f'attachment; filename="session_{resolved}.json"'
return response
# Format as Markdown
title = exported.get("title") or "Hermes Session"
lines = [
f"# {title}",
f"**Session ID:** `{resolved}`",
f"**Model:** `{exported.get('model', 'unknown')}`",
f"**Started At:** {exported.get('started_at', 'unknown')}",
""
]
for msg in exported.get("messages", []):
role = str(msg.get("role", "unknown")).capitalize()
content = msg.get("content") or ""
if role == "System":
lines.append("## System")
lines.append(f"> {content}")
lines.append("")
elif role == "User":
lines.append("## User")
lines.append(content)
lines.append("")
elif role == "Assistant":
lines.append("## Assistant")
if content:
lines.append(content)
lines.append("")
tool_calls = msg.get("tool_calls")
if tool_calls:
for tc in tool_calls:
fn = tc.get("function", {})
fn_name = fn.get("name", "unknown")
args = fn.get("arguments", "{}")
try:
args_formatted = json.dumps(json.loads(args), indent=2)
except Exception:
args_formatted = str(args)
lines.append(f"**🔧 Tool Call:** `{fn_name}`")
lines.append(f"```json\n{args_formatted}\n```")
lines.append("")
elif role == "Tool":
tool_name = msg.get("tool_name") or msg.get("name") or "unknown"
lines.append(f"## Tool Result: `{tool_name}`")
lines.append(f"```\n{content}\n```")
lines.append("")
md_content = "\n".join(lines)
if export_format == "txt":
response = web.Response(text=md_content, content_type="text/plain")
response.headers["Content-Disposition"] = f'attachment; filename="session_{resolved}.txt"'
return response
# Default md
response = web.Response(text=md_content, content_type="text/markdown")
response.headers["Content-Disposition"] = f'attachment; filename="session_{resolved}.md"'
return response
async def handle_branch_session(request: web.Request) -> web.Response:
"""POST /api/gui/sessions/{session_id}/branch — fork a session at a message index."""
service = _get_session_service(request)
session_id = request.match_info["session_id"]
data = await _read_json_body(request)
at_message_index: int | None = None
if data and "at_message_index" in data:
try:
at_message_index = int(data["at_message_index"])
except (TypeError, ValueError):
return _json_error(
status=400,
code="invalid_index",
message="The 'at_message_index' field must be an integer.",
)
result = service.branch_session(session_id, at_message_index=at_message_index)
if result is None:
return _json_error(
status=404,
code="session_not_found",
message="No session was found for the provided session_id.",
session_id=session_id,
)
return web.json_response({"ok": True, **result})
async def handle_session_search(request: web.Request) -> web.Response:
"""GET /api/gui/session-search?q=... — FTS5 full-text search across sessions."""
query = request.query.get("q", "").strip()
if not query:
return web.json_response({"ok": True, "search": {"results": []}})
service = _get_session_service(request)
try:
results = service.db.search_messages(query, limit=20)
formatted = []
for r in results:
formatted.append({
"session_id": r.get("session_id", ""),
"session_title": r.get("session_title") or r.get("session_id", "")[:12],
"snippet": r.get("snippet") or r.get("content", "")[:120],
"role": r.get("role", ""),
})
return web.json_response({"ok": True, "search": {"results": formatted}})
except Exception as exc:
return _json_error(
status=500,
code="search_error",
message=f"Search failed: {exc}",
)
def register_sessions_api_routes(app: web.Application) -> None:
if app.get(SESSIONS_SERVICE_APP_KEY) is None:
app[SESSIONS_SERVICE_APP_KEY] = SessionService()
app.router.add_get("/api/gui/sessions", handle_list_sessions)
app.router.add_get("/api/gui/session-search", handle_session_search)
app.router.add_get("/api/gui/sessions/{session_id}", handle_get_session)
app.router.add_get("/api/gui/sessions/{session_id}/transcript", handle_get_transcript)
app.router.add_get("/api/gui/sessions/{session_id}/export", handle_export_session)
app.router.add_post("/api/gui/sessions/{session_id}/title", handle_set_title)
app.router.add_post("/api/gui/sessions/{session_id}/resume", handle_resume_session)
app.router.add_post("/api/gui/sessions/{session_id}/branch", handle_branch_session)
app.router.add_delete("/api/gui/sessions/{session_id}", handle_delete_session)

View File

@@ -0,0 +1,107 @@
"""Settings API routes for the Hermes Web Console backend."""
from __future__ import annotations
import json
from typing import Any
from aiohttp import web
import os
from gateway.web_console.services.settings_service import SettingsService
from hermes_cli.config import save_env_value, OPTIONAL_ENV_VARS
SETTINGS_SERVICE_APP_KEY = web.AppKey("hermes_web_console_settings_service", SettingsService)
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
payload: dict[str, Any] = {"ok": False, "error": {"code": code, "message": message}}
payload["error"].update(extra)
return web.json_response(payload, status=status)
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
try:
data = await request.json()
except (json.JSONDecodeError, ValueError, TypeError):
return None
if not isinstance(data, dict):
return None
return data
def _get_settings_service(request: web.Request) -> SettingsService:
service = request.app.get(SETTINGS_SERVICE_APP_KEY)
if service is None:
service = SettingsService()
request.app[SETTINGS_SERVICE_APP_KEY] = service
return service
async def handle_get_settings(request: web.Request) -> web.Response:
service = _get_settings_service(request)
return web.json_response({"ok": True, "settings": service.get_settings()})
async def handle_patch_settings(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
service = _get_settings_service(request)
try:
settings = service.update_settings(data)
except ValueError as exc:
return _json_error(status=400, code="invalid_patch", message=str(exc))
return web.json_response({"ok": True, "settings": settings})
async def handle_get_auth_status(request: web.Request) -> web.Response:
service = _get_settings_service(request)
return web.json_response({"ok": True, "auth": service.get_auth_status()})
async def handle_patch_auth_keys(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
for k, v in data.items():
if isinstance(v, str):
save_env_value(k, v)
elif v is None:
# If they pass null, maybe they want to unset it?
# save_env_value supports unsetting if value is empty string, let's pass empty string.
save_env_value(k, "")
service = _get_settings_service(request)
return web.json_response({"ok": True, "auth": service.get_auth_status()})
async def handle_get_auth_schema(request: web.Request) -> web.Response:
# Build a schema of env vars, categorizing them and providing descriptions
schema = {}
for key, meta in OPTIONAL_ENV_VARS.items():
if meta.get("category") in ("provider", "tool", "messaging") or meta.get("category") is None:
# Copy meta and add value status
item = dict(meta)
val = os.getenv(key, "").strip()
# If the value is present and is not a placeholder, mask it
if val and len(val) >= 4 and val.lower() not in ("null", "none", "placeholder", "changeme", "***"):
item["value"] = "***" + val[-4:] if len(val) >= 8 else "***"
item["configured"] = True
else:
item["value"] = ""
item["configured"] = False
schema[key] = item
return web.json_response({"ok": True, "schema": schema})
def register_settings_api_routes(app: web.Application) -> None:
if app.get(SETTINGS_SERVICE_APP_KEY) is None:
app[SETTINGS_SERVICE_APP_KEY] = SettingsService()
app.router.add_get("/api/gui/settings", handle_get_settings)
app.router.add_patch("/api/gui/settings", handle_patch_settings)
app.router.add_get("/api/gui/auth-status", handle_get_auth_status)
app.router.add_patch("/api/gui/auth/keys", handle_patch_auth_keys)
app.router.add_get("/api/gui/auth/schema", handle_get_auth_schema)

View File

@@ -0,0 +1,290 @@
"""Skills API routes for the Hermes Web Console backend."""
from __future__ import annotations
import json
from typing import Any
from aiohttp import web
from gateway.web_console.services.skill_service import SkillService
SKILLS_SERVICE_APP_KEY = web.AppKey("hermes_web_console_skills_service", SkillService)
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
payload: dict[str, Any] = {
"ok": False,
"error": {
"code": code,
"message": message,
},
}
payload["error"].update(extra)
return web.json_response(payload, status=status)
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
try:
data = await request.json()
except (json.JSONDecodeError, ValueError, TypeError):
return None
if not isinstance(data, dict):
return None
return data
def _get_skill_service(request: web.Request) -> SkillService:
service = request.app.get(SKILLS_SERVICE_APP_KEY)
if service is None:
service = SkillService()
request.app[SKILLS_SERVICE_APP_KEY] = service
return service
async def handle_list_skills(request: web.Request) -> web.Response:
service = _get_skill_service(request)
try:
payload = service.list_skills()
except Exception as exc:
return _json_error(status=500, code="skills_list_failed", message=str(exc))
return web.json_response({"ok": True, **payload})
async def handle_get_skill(request: web.Request) -> web.Response:
service = _get_skill_service(request)
name = request.match_info["name"]
try:
skill = service.get_skill(name)
except FileNotFoundError:
return _json_error(status=404, code="skill_not_found", message="No skill was found for the provided name.", name=name)
except ValueError as exc:
return _json_error(status=400, code="skill_unavailable", message=str(exc), name=name)
except Exception as exc:
return _json_error(status=500, code="skill_lookup_failed", message=str(exc), name=name)
return web.json_response({"ok": True, "skill": skill})
async def handle_load_skill(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
session_id = data.get("session_id")
if not isinstance(session_id, str) or not session_id.strip():
return _json_error(status=400, code="invalid_session_id", message="The 'session_id' field must be a non-empty string.")
service = _get_skill_service(request)
name = request.match_info["name"]
try:
payload = service.load_skill_for_session(session_id.strip(), name)
except LookupError:
return _json_error(status=404, code="session_not_found", message="No session was found for the provided session_id.", session_id=session_id)
except FileNotFoundError:
return _json_error(status=404, code="skill_not_found", message="No skill was found for the provided name.", name=name)
except ValueError as exc:
return _json_error(status=400, code="skill_unavailable", message=str(exc), name=name)
except Exception as exc:
return _json_error(status=500, code="skill_load_failed", message=str(exc), name=name, session_id=session_id)
return web.json_response({"ok": True, **payload})
async def handle_list_session_skills(request: web.Request) -> web.Response:
service = _get_skill_service(request)
session_id = request.match_info["session_id"]
try:
payload = service.list_session_skills(session_id)
except LookupError:
return _json_error(status=404, code="session_not_found", message="No session was found for the provided session_id.", session_id=session_id)
except Exception as exc:
return _json_error(status=500, code="session_skills_lookup_failed", message=str(exc), session_id=session_id)
return web.json_response({"ok": True, **payload})
async def handle_unload_skill(request: web.Request) -> web.Response:
service = _get_skill_service(request)
session_id = request.match_info["session_id"]
name = request.match_info["name"]
try:
payload = service.unload_skill_for_session(session_id, name)
except LookupError:
return _json_error(status=404, code="session_not_found", message="No session was found for the provided session_id.", session_id=session_id)
except Exception as exc:
return _json_error(status=500, code="skill_unload_failed", message=str(exc), session_id=session_id, name=name)
return web.json_response({"ok": True, **payload})
async def handle_hub_search(request: web.Request) -> web.Response:
query = request.query.get("q", "")
from tools.skills_hub import GitHubAuth, create_source_router, unified_search
try:
auth = GitHubAuth()
sources = create_source_router(auth)
results = unified_search(query, sources, source_filter="all", limit=20)
payload = {"results": [{"name": r.name, "description": r.description, "source": r.source, "trust_level": r.trust_level, "identifier": r.identifier, "tags": r.tags} for r in results]}
except Exception as exc:
return _json_error(status=500, code="hub_search_failed", message=str(exc))
return web.json_response({"ok": True, **payload})
async def handle_hub_install(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if not data or not data.get("identifier"):
return _json_error(status=400, code="invalid_request", message="Missing identifier")
identifier = data["identifier"]
try:
from tools.skills_hub import GitHubAuth, create_source_router, ensure_hub_dirs, quarantine_bundle, install_from_quarantine, HubLockFile
from tools.skills_guard import scan_skill, should_allow_install
import shutil
ensure_hub_dirs()
auth = GitHubAuth()
sources = create_source_router(auth)
from hermes_cli.skills_hub import _resolve_source_meta_and_bundle, _resolve_short_name
if "/" not in identifier:
from rich.console import Console
identifier = _resolve_short_name(identifier, sources, Console(quiet=True))
if not identifier:
return _json_error(status=404, code="skill_not_found", message="Skill not found or ambiguous")
meta, bundle, matched_source = _resolve_source_meta_and_bundle(identifier, sources)
if not bundle:
return _json_error(status=404, code="fetch_failed", message="Could not fetch bundle")
category = ""
if bundle.source == "official":
id_parts = bundle.identifier.split("/")
if len(id_parts) >= 3:
category = id_parts[1]
lock = HubLockFile()
if lock.get_installed(bundle.name):
return _json_error(status=409, code="already_installed", message="Skill is already installed")
q_path = quarantine_bundle(bundle)
scan_source = getattr(bundle, "identifier", identifier)
result = scan_skill(q_path, source=scan_source)
allowed, reason = should_allow_install(result, force=False)
if not allowed:
shutil.rmtree(q_path, ignore_errors=True)
return _json_error(status=403, code="scan_failed", message=reason, scan_result=result.verdict)
install_dir = install_from_quarantine(q_path, bundle.name, category, bundle, result)
try:
from agent.prompt_builder import clear_skills_system_prompt_cache
clear_skills_system_prompt_cache(clear_snapshot=True)
except Exception:
pass
return web.json_response({"ok": True, "installed": bundle.name, "path": str(install_dir)})
except Exception as exc:
return _json_error(status=500, code="install_error", message=str(exc))
async def handle_skill_create(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if not data or not data.get("name") or not data.get("content"):
return _json_error(status=400, code="invalid_request", message="Missing name or content")
name = data["name"].strip()
content = data["content"].strip()
import re
if not re.match(r"^[a-zA-Z0-9_-]+$", name):
return _json_error(status=400, code="invalid_name", message="Invalid skill name (alphanumeric, dash, underscore only)")
from tools.skills_hub import SKILLS_DIR
skill_path = SKILLS_DIR / name
if skill_path.exists():
return _json_error(status=409, code="already_exists", message=f"Skill directory {name} already exists")
try:
skill_path.mkdir(parents=True, exist_ok=True)
(skill_path / "SKILL.md").write_text(content, encoding="utf-8")
try:
from agent.prompt_builder import clear_skills_system_prompt_cache
clear_skills_system_prompt_cache(clear_snapshot=True)
except Exception:
pass
return web.json_response({"ok": True, "name": name})
except Exception as exc:
return _json_error(status=500, code="create_error", message=str(exc))
async def handle_skill_config_vars(request: web.Request) -> web.Response:
"""GET /api/gui/skills/config — list all skill config variables with current values."""
try:
from agent.skill_utils import discover_all_skill_config_vars, resolve_skill_config_values
config_vars = discover_all_skill_config_vars()
values = resolve_skill_config_values(config_vars)
items = []
for var in config_vars:
items.append({
"key": var["key"],
"description": var["description"],
"default": var.get("default"),
"prompt": var.get("prompt"),
"skill": var.get("skill"),
"value": values.get(var["key"]),
})
return web.json_response({"ok": True, "config_vars": items, "count": len(items)})
except Exception as exc:
return _json_error(status=500, code="config_vars_failed", message=str(exc))
async def handle_skill_config_save(request: web.Request) -> web.Response:
"""POST /api/gui/skills/config — save a skill config variable value."""
data = await _read_json_body(request)
if not data or "key" not in data or "value" not in data:
return _json_error(status=400, code="invalid_request", message="Missing 'key' or 'value'")
key = str(data["key"]).strip()
value = data["value"]
if not key:
return _json_error(status=400, code="invalid_key", message="Config key must be non-empty")
try:
from agent.skill_utils import SKILL_CONFIG_PREFIX
from hermes_constants import get_hermes_home
import yaml
config_path = get_hermes_home() / "config.yaml"
config = {}
if config_path.exists():
raw = config_path.read_text(encoding="utf-8")
parsed = yaml.safe_load(raw)
if isinstance(parsed, dict):
config = parsed
# Navigate to the storage path: skills.config.<key>
storage_key = f"{SKILL_CONFIG_PREFIX}.{key}"
parts = storage_key.split(".")
current = config
for part in parts[:-1]:
if part not in current or not isinstance(current.get(part), dict):
current[part] = {}
current = current[part]
current[parts[-1]] = value
config_path.write_text(yaml.dump(config, default_flow_style=False), encoding="utf-8")
return web.json_response({"ok": True, "key": key, "value": value})
except Exception as exc:
return _json_error(status=500, code="config_save_failed", message=str(exc))
def register_skills_api_routes(app: web.Application) -> None:
if app.get(SKILLS_SERVICE_APP_KEY) is None:
app[SKILLS_SERVICE_APP_KEY] = SkillService()
app.router.add_get("/api/gui/skills", handle_list_skills)
app.router.add_get("/api/gui/skills/config", handle_skill_config_vars)
app.router.add_post("/api/gui/skills/config", handle_skill_config_save)
app.router.add_get("/api/gui/skills/hub/search", handle_hub_search)
app.router.add_post("/api/gui/skills/hub/install", handle_hub_install)
app.router.add_post("/api/gui/skills/create", handle_skill_create)
app.router.add_get("/api/gui/skills/{name}", handle_get_skill)
app.router.add_post("/api/gui/skills/{name}/load", handle_load_skill)
app.router.add_get("/api/gui/skills/session/{session_id}", handle_list_session_skills)
app.router.add_delete("/api/gui/skills/session/{session_id}/{name}", handle_unload_skill)

View File

@@ -0,0 +1,178 @@
"""Backup and restore API routes for the Hermes Web Console.
Provides:
GET /api/gui/system/backup — stream a zip backup of ~/.hermes/ to the browser
POST /api/gui/system/restore — accept a zip upload and restore into ~/.hermes/
"""
from __future__ import annotations
import asyncio
import io
import logging
import os
import tempfile
import time
import zipfile
from datetime import datetime
from pathlib import Path
from aiohttp import web
logger = logging.getLogger(__name__)
async def handle_system_backup(request: web.Request) -> web.StreamResponse:
"""Stream a zip backup of HERMES_HOME to the browser."""
loop = asyncio.get_running_loop()
def _create_backup_bytes() -> tuple[bytes, int, str]:
from hermes_constants import get_default_hermes_root
from hermes_cli.backup import _should_exclude, _EXCLUDED_DIRS
hermes_root = get_default_hermes_root()
if not hermes_root.is_dir():
raise FileNotFoundError(f"Hermes home not found: {hermes_root}")
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
filename = f"hermes-backup-{stamp}.zip"
buf = io.BytesIO()
file_count = 0
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
for dirpath, dirnames, filenames in os.walk(hermes_root, followlinks=False):
dp = Path(dirpath)
rel_dir = dp.relative_to(hermes_root)
dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_DIRS]
for fname in filenames:
fpath = dp / fname
rel = fpath.relative_to(hermes_root)
if _should_exclude(rel):
continue
try:
zf.write(fpath, arcname=str(rel))
file_count += 1
except (PermissionError, OSError):
continue
return buf.getvalue(), file_count, filename
try:
data, file_count, filename = await loop.run_in_executor(None, _create_backup_bytes)
except FileNotFoundError as e:
return web.json_response({"ok": False, "error": str(e)}, status=404)
except Exception as e:
logger.exception("Backup creation failed")
return web.json_response({"ok": False, "error": str(e)}, status=500)
response = web.StreamResponse(
status=200,
headers={
"Content-Type": "application/zip",
"Content-Disposition": f'attachment; filename="{filename}"',
"Content-Length": str(len(data)),
"X-Backup-Files": str(file_count),
},
)
await response.prepare(request)
await response.write(data)
await response.write_eof()
return response
async def handle_system_restore(request: web.Request) -> web.Response:
"""Accept a zip upload and restore it into HERMES_HOME."""
reader = await request.multipart()
if reader is None:
return web.json_response(
{"ok": False, "error": "Expected multipart/form-data with a 'file' part"},
status=400,
)
zip_data = None
while True:
part = await reader.next()
if part is None:
break
if part.name == "file":
zip_data = await part.read(decode=False)
break
if zip_data is None:
return web.json_response(
{"ok": False, "error": "No 'file' part found in upload"},
status=400,
)
loop = asyncio.get_running_loop()
def _do_restore() -> dict:
from hermes_constants import get_default_hermes_root
from hermes_cli.backup import _validate_backup_zip, _detect_prefix
hermes_root = get_default_hermes_root()
hermes_root.mkdir(parents=True, exist_ok=True)
buf = io.BytesIO(zip_data)
if not zipfile.is_zipfile(buf):
return {"ok": False, "error": "Uploaded data is not a valid zip file"}
buf.seek(0)
with zipfile.ZipFile(buf, "r") as zf:
ok, reason = _validate_backup_zip(zf)
if not ok:
return {"ok": False, "error": reason}
prefix = _detect_prefix(zf)
members = [n for n in zf.namelist() if not n.endswith("/")]
errors = []
restored = 0
for member in members:
if prefix and member.startswith(prefix):
rel = member[len(prefix):]
else:
rel = member
if not rel:
continue
target = hermes_root / rel
# Security: reject absolute paths and traversals
try:
target.resolve().relative_to(hermes_root.resolve())
except ValueError:
errors.append(f"{rel}: path traversal blocked")
continue
try:
target.parent.mkdir(parents=True, exist_ok=True)
with zf.open(member) as src, open(target, "wb") as dst:
dst.write(src.read())
restored += 1
except (PermissionError, OSError) as exc:
errors.append(f"{rel}: {exc}")
return {
"ok": True,
"restored": restored,
"total": len(members),
"errors": errors[:20],
}
try:
result = await loop.run_in_executor(None, _do_restore)
except Exception as e:
logger.exception("Restore failed")
return web.json_response({"ok": False, "error": str(e)}, status=500)
return web.json_response(result)
def register_system_api_routes(app: web.Application) -> None:
"""Register system backup/restore API routes."""
app.router.add_get("/api/gui/system/backup", handle_system_backup)
app.router.add_post("/api/gui/system/restore", handle_system_restore)

View File

@@ -0,0 +1,113 @@
"""Tools management API routes for the Hermes Web Console backend."""
import logging
from aiohttp import web
from tools.registry import registry
logger = logging.getLogger("tools_api")
async def handle_list_tools(request: web.Request) -> web.Response:
tools_list = []
for name, tool in registry._tools.items():
# ToolEntry doesn't have is_available(); check via check_fn
available = True
if tool.check_fn:
try:
available = bool(tool.check_fn())
except Exception:
available = False
tools_list.append({
"name": name,
"toolset": tool.toolset,
"description": tool.schema.get("description", ""),
"parameters": tool.schema.get("parameters", {}),
"requires_env": tool.requires_env,
"is_available": available,
})
return web.json_response({
"ok": True,
"tools": tools_list
})
async def handle_list_toolsets(request: web.Request) -> web.Response:
"""List all toolsets with availability, tool counts, and enabled status."""
try:
from hermes_cli.tools_config import CONFIGURABLE_TOOLSETS, _toolset_has_keys
from hermes_cli.config import load_config
from toolsets import resolve_toolset
config = load_config()
# Get currently enabled toolsets from platform_toolsets (CLI by default)
platform = request.query.get("platform", "cli")
platform_toolsets_config = config.get("platform_toolsets", {})
enabled_ts = platform_toolsets_config.get(platform)
# If no explicit config, resolve from default toolset
from hermes_cli.tools_config import _get_platform_tools
enabled_toolset_keys = _get_platform_tools(config, platform)
toolsets = []
for ts_key, ts_label, ts_desc in CONFIGURABLE_TOOLSETS:
ts_tools = sorted(resolve_toolset(ts_key))
ts_available = registry.is_toolset_available(ts_key)
ts_has_keys = _toolset_has_keys(ts_key)
toolsets.append({
"key": ts_key,
"label": ts_label,
"description": ts_desc,
"tools": ts_tools,
"tool_count": len(ts_tools),
"available": ts_available,
"has_keys": ts_has_keys,
"enabled": ts_key in enabled_toolset_keys,
})
return web.json_response({"ok": True, "toolsets": toolsets, "platform": platform})
except Exception as e:
logger.error(f"Failed to list toolsets: {e}")
return web.json_response({"ok": False, "error": str(e)}, status=500)
async def handle_toggle_toolset(request: web.Request) -> web.Response:
"""Enable or disable a toolset for a platform."""
try:
import json
data = await request.json()
ts_key = data.get("toolset")
enabled = data.get("enabled", True)
platform = data.get("platform", "cli")
if not ts_key:
return web.json_response({"ok": False, "error": "toolset key required"}, status=400)
from hermes_cli.config import load_config
from hermes_cli.tools_config import _get_platform_tools, _save_platform_tools
config = load_config()
current = _get_platform_tools(config, platform)
if enabled:
current.add(ts_key)
else:
current.discard(ts_key)
_save_platform_tools(config, platform, current)
return web.json_response({
"ok": True,
"toolset": ts_key,
"enabled": enabled,
"platform": platform,
})
except Exception as e:
logger.error(f"Failed to toggle toolset: {e}")
return web.json_response({"ok": False, "error": str(e)}, status=500)
def register_tools_api_routes(app: web.Application) -> None:
app.router.add_get("/api/gui/tools", handle_list_tools)
app.router.add_get("/api/gui/toolsets", handle_list_toolsets)
app.router.add_post("/api/gui/toolsets/toggle", handle_toggle_toolset)

View File

@@ -0,0 +1,53 @@
import logging
from aiohttp import web
logger = logging.getLogger("usage_api")
async def handle_get_usage_insights(request: web.Request) -> web.Response:
try:
days = int(request.query.get("days", 30))
source = request.query.get("source")
from hermes_state import SessionDB
from agent.insights import InsightsEngine
db = SessionDB()
engine = InsightsEngine(db)
report = engine.generate(days=days, source=source)
db.close()
return web.json_response({"ok": True, "report": report})
except Exception as e:
logger.error(f"Failed to fetch usage insights: {e}")
return web.json_response({"ok": False, "error": str(e)}, status=500)
async def handle_get_session_usage(request: web.Request) -> web.Response:
try:
session_id = request.match_info.get("id")
if not session_id:
return web.json_response({"ok": False, "error": "Session ID required"}, status=400)
from hermes_state import SessionDB
db = SessionDB()
try:
session = db.get_session(session_id)
if not session:
return web.json_response({"ok": False, "error": "Not found"}, status=404)
return web.json_response({"ok": True, "session_usage": {
"input_tokens": getattr(session, "input_tokens", 0) or 0,
"output_tokens": getattr(session, "output_tokens", 0) or 0,
"cache_read_tokens": getattr(session, "cache_read_tokens", 0) or 0,
"cache_write_tokens": getattr(session, "cache_write_tokens", 0) or 0,
"total_tokens": getattr(session, "total_tokens", 0) or 0,
"estimated_cost_usd": getattr(session, "estimated_cost_usd", 0.0) or 0.0,
}})
finally:
db.close()
except Exception as e:
logger.error(f"Failed to fetch session usage: {e}")
return web.json_response({"ok": False, "error": str(e)}, status=500)
def register_usage_api_routes(app: web.Application) -> None:
app.router.add_get("/api/gui/usage/insights", handle_get_usage_insights)
app.router.add_get("/api/gui/usage/session/{id}", handle_get_session_usage)

View File

@@ -0,0 +1,72 @@
"""Version info API route for the Hermes Web Console backend."""
from __future__ import annotations
import logging
import aiohttp
from aiohttp import web
logger = logging.getLogger("version_api")
async def handle_get_version(request: web.Request) -> web.Response:
try:
from hermes_cli import __version__, __release_date__
except ImportError:
__version__ = "unknown"
__release_date__ = "unknown"
return web.json_response({
"ok": True,
"version": __version__,
"release_date": __release_date__,
})
async def handle_check_update(request: web.Request) -> web.Response:
"""Check PyPI for the latest version of hermes-agent."""
try:
from hermes_cli import __version__
except ImportError:
__version__ = "0.0.0"
try:
async with aiohttp.ClientSession() as session:
async with session.get("https://pypi.org/pypi/hermes-agent/json", timeout=5) as resp:
if resp.status == 200:
data = await resp.json()
latest_version = data["info"]["version"]
# Basic comparison assuming semantic versioning like 0.6.1 vs 0.6.0
# For simplicity, we just check if it's different.
# Normally we'd use packaging.version.parse, but standard string comparison
# or exact matching is often enough to say "update available" if __version__ != latest
has_update = __version__ != "unknown" and latest_version != __version__
# Attempt to fetch changelog or description for the new version
releases = data.get("releases", {})
latest_release_info = releases.get(latest_version, [])
changelog = ""
if latest_release_info:
# Extract some basic info, like if there's a Github release description
# Actually PyPI JSON doesn't contain the markdown changelog for that release easily.
# We'll just provide the project url if needed.
pass
return web.json_response({
"ok": True,
"current_version": __version__,
"latest_version": latest_version,
"has_update": has_update,
"project_url": data["info"]["project_urls"].get("Homepage", "https://pypi.org/project/hermes-agent/")
})
return web.json_response({
"ok": False,
"error": f"Failed to fetch PyPI data: {resp.status}"
}, status=502)
except Exception as e:
logger.error(f"Failed to check for updates: {e}")
return web.json_response({"ok": False, "error": str(e)}, status=500)
def register_version_api_routes(app: web.Application) -> None:
app.router.add_get("/api/gui/version", handle_get_version)
app.router.add_get("/api/gui/version/check", handle_check_update)

View File

@@ -0,0 +1,248 @@
"""Workspace and process API routes for the Hermes Web Console backend."""
from __future__ import annotations
import json
from typing import Any
from aiohttp import web
from gateway.web_console.services.workspace_service import WorkspaceService
WORKSPACE_SERVICE_APP_KEY = web.AppKey("hermes_web_console_workspace_service", WorkspaceService)
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
payload: dict[str, Any] = {
"ok": False,
"error": {
"code": code,
"message": message,
},
}
payload["error"].update(extra)
return web.json_response(payload, status=status)
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
try:
data = await request.json()
except (json.JSONDecodeError, ValueError, TypeError):
return None
if not isinstance(data, dict):
return None
return data
def _parse_int(value: str | None, *, field_name: str, minimum: int | None = None) -> int:
try:
parsed = int(value) if value is not None else 0
except (TypeError, ValueError) as exc:
raise ValueError(f"The '{field_name}' field must be an integer.") from exc
if minimum is not None and parsed < minimum:
raise ValueError(f"The '{field_name}' field must be >= {minimum}.")
return parsed
def _parse_bool(value: str | None) -> bool:
if value is None:
return False
return value.lower() in {"1", "true", "yes", "on"}
def _get_workspace_service(request: web.Request) -> WorkspaceService:
service = request.app.get(WORKSPACE_SERVICE_APP_KEY)
if service is None:
service = WorkspaceService()
request.app[WORKSPACE_SERVICE_APP_KEY] = service
return service
async def handle_workspace_tree(request: web.Request) -> web.Response:
service = _get_workspace_service(request)
try:
depth = _parse_int(request.query.get("depth", "2"), field_name="depth", minimum=0)
result = service.get_tree(
path=request.query.get("path"),
depth=depth,
include_hidden=_parse_bool(request.query.get("include_hidden")),
)
except ValueError as exc:
return _json_error(status=400, code="invalid_path", message=str(exc))
except FileNotFoundError as exc:
return _json_error(status=404, code="path_not_found", message=str(exc))
return web.json_response({"ok": True, **result})
async def handle_workspace_file(request: web.Request) -> web.Response:
service = _get_workspace_service(request)
path = request.query.get("path")
if not path:
return _json_error(status=400, code="missing_path", message="The 'path' query parameter is required.")
try:
offset = _parse_int(request.query.get("offset", "1"), field_name="offset", minimum=1)
limit = _parse_int(request.query.get("limit", "500"), field_name="limit", minimum=1)
result = service.get_file(path=path, offset=offset, limit=limit)
except ValueError as exc:
return _json_error(status=400, code="invalid_path", message=str(exc))
except FileNotFoundError as exc:
return _json_error(status=404, code="path_not_found", message=str(exc))
return web.json_response({"ok": True, **result})
async def handle_workspace_search(request: web.Request) -> web.Response:
service = _get_workspace_service(request)
query = request.query.get("query") or request.query.get("q")
if not query:
return _json_error(status=400, code="missing_query", message="The 'query' parameter is required.")
try:
limit = _parse_int(request.query.get("limit", "50"), field_name="limit", minimum=1)
result = service.search_workspace(
query=query,
path=request.query.get("path"),
limit=limit,
include_hidden=_parse_bool(request.query.get("include_hidden")),
regex=_parse_bool(request.query.get("regex")),
)
except ValueError as exc:
return _json_error(status=400, code="invalid_search", message=str(exc))
except FileNotFoundError as exc:
return _json_error(status=404, code="path_not_found", message=str(exc))
return web.json_response({"ok": True, **result})
async def handle_workspace_diff(request: web.Request) -> web.Response:
service = _get_workspace_service(request)
checkpoint_id = request.query.get("checkpoint_id") or request.query.get("checkpoint")
if not checkpoint_id:
return _json_error(status=400, code="missing_checkpoint_id", message="The 'checkpoint_id' parameter is required.")
try:
result = service.diff_checkpoint(checkpoint_id=checkpoint_id, path=request.query.get("path"))
except ValueError as exc:
return _json_error(status=400, code="invalid_checkpoint", message=str(exc))
except FileNotFoundError as exc:
return _json_error(status=404, code="checkpoint_not_found", message=str(exc))
return web.json_response({"ok": True, **result})
async def handle_workspace_checkpoints(request: web.Request) -> web.Response:
service = _get_workspace_service(request)
try:
result = service.list_checkpoints(path=request.query.get("path"))
except ValueError as exc:
return _json_error(status=400, code="invalid_path", message=str(exc))
except FileNotFoundError as exc:
return _json_error(status=404, code="path_not_found", message=str(exc))
return web.json_response({"ok": True, **result})
async def handle_workspace_rollback(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
checkpoint_id = data.get("checkpoint_id") or data.get("checkpoint")
if not isinstance(checkpoint_id, str) or not checkpoint_id:
return _json_error(status=400, code="missing_checkpoint_id", message="The 'checkpoint_id' field must be a non-empty string.")
service = _get_workspace_service(request)
try:
result = service.rollback(
checkpoint_id=checkpoint_id,
path=data.get("path"),
file_path=data.get("file_path"),
)
except ValueError as exc:
return _json_error(status=400, code="invalid_rollback", message=str(exc))
except FileNotFoundError as exc:
return _json_error(status=404, code="checkpoint_not_found", message=str(exc))
except RuntimeError as exc:
return _json_error(status=500, code="rollback_failed", message=str(exc))
return web.json_response({"ok": True, "result": result})
async def handle_list_processes(request: web.Request) -> web.Response:
service = _get_workspace_service(request)
return web.json_response({"ok": True, **service.list_processes()})
async def handle_process_log(request: web.Request) -> web.Response:
service = _get_workspace_service(request)
process_id = request.match_info["process_id"]
try:
offset = _parse_int(request.query.get("offset", "0"), field_name="offset", minimum=0)
limit = _parse_int(request.query.get("limit", "200"), field_name="limit", minimum=1)
result = service.get_process_log(process_id, offset=offset, limit=limit)
except ValueError as exc:
return _json_error(status=400, code="invalid_pagination", message=str(exc))
except FileNotFoundError as exc:
return _json_error(status=404, code="process_not_found", message=str(exc), process_id=process_id)
return web.json_response({"ok": True, **result})
async def handle_kill_process(request: web.Request) -> web.Response:
service = _get_workspace_service(request)
process_id = request.match_info["process_id"]
try:
result = service.kill_process(process_id)
except FileNotFoundError as exc:
return _json_error(status=404, code="process_not_found", message=str(exc), process_id=process_id)
except RuntimeError as exc:
return _json_error(status=500, code="process_kill_failed", message=str(exc), process_id=process_id)
return web.json_response({"ok": True, "result": result})
async def handle_workspace_file_save(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
path = data.get("path")
content = data.get("content")
if not isinstance(path, str) or not path:
return _json_error(status=400, code="missing_path", message="The 'path' field must be a non-empty string.")
if not isinstance(content, str):
return _json_error(status=400, code="missing_content", message="The 'content' field must be a string.")
service = _get_workspace_service(request)
try:
result = service.save_file(path=path, content=content)
except ValueError as exc:
return _json_error(status=400, code="invalid_path", message=str(exc))
except OSError as exc:
return _json_error(status=500, code="write_failed", message=str(exc))
return web.json_response({"ok": True, **result})
async def handle_workspace_exec(request: web.Request) -> web.Response:
data = await _read_json_body(request)
if data is None:
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
command = data.get("command")
if not isinstance(command, str) or not command.strip():
return _json_error(status=400, code="missing_command", message="The 'command' field must be a non-empty string.")
service = _get_workspace_service(request)
try:
result = service.execute_sync(command)
except ValueError as exc:
return _json_error(status=400, code="invalid_command", message=str(exc))
except RuntimeError as exc:
return _json_error(status=500, code="execution_failed", message=str(exc))
return web.json_response({"ok": True, **result})
def register_workspace_api_routes(app: web.Application) -> None:
if app.get(WORKSPACE_SERVICE_APP_KEY) is None:
app[WORKSPACE_SERVICE_APP_KEY] = WorkspaceService()
app.router.add_get("/api/gui/workspace/tree", handle_workspace_tree)
app.router.add_get("/api/gui/workspace/file", handle_workspace_file)
app.router.add_post("/api/gui/workspace/file/save", handle_workspace_file_save)
app.router.add_get("/api/gui/workspace/search", handle_workspace_search)
app.router.add_get("/api/gui/workspace/diff", handle_workspace_diff)
app.router.add_get("/api/gui/workspace/checkpoints", handle_workspace_checkpoints)
app.router.add_post("/api/gui/workspace/rollback", handle_workspace_rollback)
app.router.add_post("/api/gui/workspace/exec", handle_workspace_exec)
app.router.add_get("/api/gui/processes", handle_list_processes)
app.router.add_get("/api/gui/processes/{process_id}/log", handle_process_log)
app.router.add_post("/api/gui/processes/{process_id}/kill", handle_kill_process)

View File

@@ -0,0 +1,12 @@
"""App-level helpers for mounting the Hermes Web Console backend."""
from __future__ import annotations
from typing import Any
def maybe_register_web_console(app: Any, adapter: Any = None) -> None:
"""Register the lightweight Hermes Web Console backend on an aiohttp app."""
from .routes import register_web_console_routes
register_web_console_routes(app, adapter=adapter)

View File

@@ -0,0 +1,58 @@
"""In-process GUI event bus primitives for the Hermes Web Console."""
from __future__ import annotations
import asyncio
import time
from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any, DefaultDict
@dataclass(slots=True)
class GuiEvent:
"""A single event emitted for consumption by the web console."""
type: str
session_id: str
run_id: str | None = None
payload: dict[str, Any] = field(default_factory=dict)
ts: float = field(default_factory=time.time)
class GuiEventBus:
"""A lightweight in-process pub/sub bus namespaced by channel."""
def __init__(self) -> None:
self._subscribers: DefaultDict[str, set[asyncio.Queue[GuiEvent]]] = defaultdict(set)
self._lock = asyncio.Lock()
async def subscribe(self, channel: str) -> asyncio.Queue[GuiEvent]:
"""Create and register a subscriber queue for a channel."""
queue: asyncio.Queue[GuiEvent] = asyncio.Queue()
async with self._lock:
self._subscribers[channel].add(queue)
return queue
async def unsubscribe(self, channel: str, queue: asyncio.Queue[GuiEvent]) -> None:
"""Remove a subscriber queue from a channel if it is still registered."""
async with self._lock:
subscribers = self._subscribers.get(channel)
if not subscribers:
return
subscribers.discard(queue)
if not subscribers:
self._subscribers.pop(channel, None)
async def publish(self, channel: str, event: GuiEvent) -> None:
"""Publish an event to all current subscribers for a channel."""
async with self._lock:
subscribers = list(self._subscribers.get(channel, ()))
print(f"DEBUG: Publishing {event.type} to channel {channel}. Subscribers: {len(subscribers)}", flush=True)
for queue in subscribers:
queue.put_nowait(event)
async def subscriber_count(self, channel: str) -> int:
"""Return the current number of subscribers for a channel."""
async with self._lock:
return len(self._subscribers.get(channel, ()))

View File

@@ -0,0 +1,90 @@
"""Route handlers for the Hermes Web Console backend skeleton."""
from __future__ import annotations
import asyncio
import json
import time
from typing import Any
from aiohttp import web
from .api import register_web_console_api_routes
from .sse import SseMessage, stream_sse
from .state import get_web_console_state
from .static import get_web_console_placeholder_html
ADAPTER_APP_KEY = web.AppKey("hermes_web_console_adapter", object)
async def handle_gui_health(request: web.Request) -> web.Response:
"""Return a simple health payload for the GUI backend."""
return web.json_response(
{
"status": "ok",
"service": "gui-backend",
"product": "hermes-web-console",
}
)
async def handle_gui_meta(request: web.Request) -> web.Response:
"""Return minimal metadata for the GUI backend."""
adapter = request.app.get(ADAPTER_APP_KEY)
adapter_name = getattr(adapter, "name", None)
return web.json_response(
{
"product": "hermes-web-console",
"api_base_path": "/api/gui",
"app_base_path": "/app/",
"adapter": adapter_name,
}
)
async def handle_app_root(request: web.Request) -> web.Response:
"""Serve a simple placeholder page for the future web console frontend."""
return web.Response(
text=get_web_console_placeholder_html(),
content_type="text/html",
)
async def _event_stream_generator(request: web.Request, session_id: str):
state = get_web_console_state()
queue = await state.event_bus.subscribe(session_id)
try:
while True:
try:
event = await asyncio.wait_for(queue.get(), timeout=15.0)
except asyncio.TimeoutError:
yield SseMessage(data={"ping": time.time()})
continue
payload = {
"type": event.type,
"session_id": event.session_id,
"run_id": event.run_id,
"payload": event.payload,
"ts": event.ts,
}
yield SseMessage(data=payload)
finally:
await state.event_bus.unsubscribe(session_id, queue)
async def handle_session_event_stream(request: web.Request) -> web.StreamResponse:
"""GET /api/gui/stream/session/{session_id} — SSE event stream for a session."""
session_id = request.match_info["session_id"]
return await stream_sse(request, _event_stream_generator(request, session_id))
def register_web_console_routes(app: web.Application, adapter: Any = None) -> None:
"""Register the Hermes Web Console routes on an aiohttp application."""
app[ADAPTER_APP_KEY] = adapter
app.router.add_get("/api/gui/health", handle_gui_health)
app.router.add_get("/api/gui/meta", handle_gui_meta)
app.router.add_get(
"/api/gui/stream/session/{session_id}", handle_session_event_stream
)
register_web_console_api_routes(app)
app.router.add_get("/app/", handle_app_root)

View File

@@ -0,0 +1,23 @@
"""Service helpers for the Hermes Web Console backend."""
from .approval_service import ApprovalService
from .browser_service import BrowserService
from .chat_service import ChatService
from .cron_service import CronService
from .log_service import LogService
from .memory_service import MemoryService
from .session_service import SessionService
from .settings_service import SettingsService
from .skill_service import SkillService
__all__ = [
"ApprovalService",
"BrowserService",
"ChatService",
"CronService",
"LogService",
"MemoryService",
"SessionService",
"SettingsService",
"SkillService",
]

View File

@@ -0,0 +1,198 @@
"""Human approval and clarification coordination for the Hermes Web Console."""
from __future__ import annotations
import queue
import threading
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Callable
MAX_HUMAN_REQUESTS = 200
TERMINAL_REQUEST_STATUSES = {"resolved", "expired"}
@dataclass(slots=True)
class PendingHumanRequest:
request_id: str
kind: str
session_id: str | None
run_id: str | None
title: str
prompt: str
choices: list[str] = field(default_factory=list)
metadata: dict[str, Any] = field(default_factory=dict)
sensitive: bool = False
created_at: float = field(default_factory=time.time)
expires_at: float = 0.0
status: str = "pending"
response: Any = None
response_queue: queue.Queue[Any] = field(default_factory=queue.Queue)
def to_dict(self) -> dict[str, Any]:
return {
"request_id": self.request_id,
"kind": self.kind,
"session_id": self.session_id,
"run_id": self.run_id,
"title": self.title,
"prompt": self.prompt,
"choices": list(self.choices),
"metadata": dict(self.metadata),
"sensitive": self.sensitive,
"created_at": self.created_at,
"expires_at": self.expires_at,
"status": self.status,
"response": self.response,
}
class ApprovalService:
"""Tracks pending human approval/clarification requests and resolves them."""
def __init__(self) -> None:
self._requests: dict[str, PendingHumanRequest] = {}
self._lock = threading.Lock()
def _prune_requests_if_needed(self) -> None:
while len(self._requests) > MAX_HUMAN_REQUESTS:
evicted = False
for request_id, request in list(self._requests.items()):
if request.status in TERMINAL_REQUEST_STATUSES:
del self._requests[request_id]
evicted = True
break
if evicted:
continue
oldest_request_id = min(self._requests.items(), key=lambda item: item[1].created_at)[0]
del self._requests[oldest_request_id]
def _create_request(
self,
*,
kind: str,
session_id: str | None,
run_id: str | None,
title: str,
prompt: str,
choices: list[str] | None = None,
metadata: dict[str, Any] | None = None,
sensitive: bool = False,
timeout: float = 60.0,
) -> PendingHumanRequest:
request = PendingHumanRequest(
request_id=str(uuid.uuid4()),
kind=kind,
session_id=session_id,
run_id=run_id,
title=title,
prompt=prompt,
choices=list(choices or []),
metadata=dict(metadata or {}),
sensitive=sensitive,
expires_at=time.time() + timeout,
)
with self._lock:
self._requests[request.request_id] = request
self._prune_requests_if_needed()
return request
def _finish_request(self, request: PendingHumanRequest, *, status: str, response: Any) -> None:
request.status = status
request.response = response
request.response_queue.put(response)
def list_pending(self) -> list[dict[str, Any]]:
now = time.time()
with self._lock:
pending = []
for request in self._requests.values():
if request.status == "pending" and request.expires_at <= now:
request.status = "expired"
request.response = None
if request.status == "pending":
pending.append(request.to_dict())
pending.sort(key=lambda item: item["created_at"])
return pending
def get_request(self, request_id: str) -> PendingHumanRequest | None:
with self._lock:
return self._requests.get(request_id)
def resolve_approval(self, request_id: str, decision: str) -> dict[str, Any] | None:
with self._lock:
request = self._requests.get(request_id)
if request is None or request.kind != "approval" or request.status != "pending":
return None
self._finish_request(request, status="resolved", response=decision)
return request.to_dict()
def resolve_clarification(self, request_id: str, response: str) -> dict[str, Any] | None:
with self._lock:
request = self._requests.get(request_id)
if request is None or request.kind != "clarify" or request.status != "pending":
return None
self._finish_request(request, status="resolved", response=response)
return request.to_dict()
def deny_request(self, request_id: str) -> dict[str, Any] | None:
with self._lock:
request = self._requests.get(request_id)
if request is None or request.status != "pending":
return None
denied_value = "deny" if request.kind == "approval" else None
self._finish_request(request, status="resolved", response=denied_value)
return request.to_dict()
def create_approval_callback(self, *, session_id: str | None = None, run_id: str | None = None) -> Callable[..., str]:
def callback(command: str, description: str, *, allow_permanent: bool = True) -> str:
choices = ["once", "session", "deny"]
if allow_permanent:
choices.insert(2, "always")
request = self._create_request(
kind="approval",
session_id=session_id,
run_id=run_id,
title="Approve dangerous command",
prompt=description,
choices=choices,
metadata={"command": command, "description": description},
timeout=60.0,
)
try:
result = request.response_queue.get(timeout=60.0)
return result if isinstance(result, str) else "deny"
except queue.Empty:
request.status = "expired"
request.response = "deny"
return "deny"
return callback
def create_clarify_callback(self, *, session_id: str | None = None, run_id: str | None = None) -> Callable[[str, list[str] | None], str]:
def callback(question: str, choices: list[str] | None) -> str:
request = self._create_request(
kind="clarify",
session_id=session_id,
run_id=run_id,
title="Clarification needed",
prompt=question,
choices=list(choices or []),
timeout=120.0,
)
try:
result = request.response_queue.get(timeout=120.0)
return result if isinstance(result, str) else (
"The user did not provide a response within the time limit. "
"Use your best judgement to make the choice and proceed."
)
except queue.Empty:
request.status = "expired"
request.response = None
return (
"The user did not provide a response within the time limit. "
"Use your best judgement to make the choice and proceed."
)
return callback

View File

@@ -0,0 +1,87 @@
"""Browser service helpers for the Hermes Web Console backend."""
from __future__ import annotations
import socket
from typing import Any, Callable
from hermes_cli.config import save_env_value
class BrowserService:
"""Thin wrapper around Hermes browser connection state."""
DEFAULT_CDP_URL = "http://localhost:9222"
def __init__(
self,
*,
active_sessions_getter: Callable[[], dict[str, dict[str, str]]] | None = None,
cleanup_all_browsers: Callable[[], None] | None = None,
requirements_checker: Callable[[], bool] | None = None,
cdp_resolver: Callable[[str], str] | None = None,
env_writer: Callable[[str, str], None] = save_env_value,
) -> None:
self._active_sessions_getter = active_sessions_getter or (lambda: {})
self._cleanup_all_browsers = cleanup_all_browsers or (lambda: None)
self._requirements_checker = requirements_checker or (lambda: True)
self._cdp_resolver = cdp_resolver or (lambda value: value)
self._env_writer = env_writer
def get_status(self) -> dict[str, Any]:
current = self._current_cdp_url()
mode = "live_cdp" if current else ("browserbase" if self._has_browserbase_credentials() else "local")
return {
"mode": mode,
"connected": bool(current),
"cdp_url": current,
"reachable": self._is_cdp_reachable(current) if current else None,
"requirements_ok": self._requirements_checker(),
"active_sessions": self._active_sessions_getter(),
}
def connect(self, cdp_url: str | None = None) -> dict[str, Any]:
target = (cdp_url or self.DEFAULT_CDP_URL).strip()
if not target:
raise ValueError("The 'cdp_url' field must be a non-empty string when provided.")
resolved = self._cdp_resolver(target)
self._cleanup_all_browsers()
self._env_writer("BROWSER_CDP_URL", target)
return {
**self.get_status(),
"requested_cdp_url": target,
"cdp_url": resolved or target,
"message": "Browser connected to a live Chrome CDP endpoint.",
}
def disconnect(self) -> dict[str, Any]:
self._cleanup_all_browsers()
self._env_writer("BROWSER_CDP_URL", "")
status = self.get_status()
status["message"] = "Browser reverted to the default backend."
return status
@staticmethod
def _has_browserbase_credentials() -> bool:
import os
return bool(os.getenv("BROWSERBASE_API_KEY", "").strip())
@staticmethod
def _current_cdp_url() -> str:
import os
return os.getenv("BROWSER_CDP_URL", "").strip()
@staticmethod
def _is_cdp_reachable(cdp_url: str) -> bool:
try:
port = int(cdp_url.rsplit(":", 1)[-1].split("/")[0])
except (TypeError, ValueError, IndexError):
return False
host = "127.0.0.1"
try:
with socket.create_connection((host, port), timeout=1):
return True
except OSError:
return False

View File

@@ -0,0 +1,238 @@
"""Runtime wrapper for emitting Hermes Web Console GUI events."""
from __future__ import annotations
import asyncio
import inspect
import threading
import uuid
from typing import Any, Callable, Dict, Optional
from gateway.web_console.event_bus import GuiEvent
from gateway.web_console.state import WebConsoleState, get_web_console_state
from gateway.web_console.services.approval_service import ApprovalService
RuntimeRunner = Callable[..., Any]
_TERMINAL_APPROVAL_CALLBACK_LOCK = threading.Lock()
class ChatService:
"""Minimal chat runtime wrapper that publishes GUI events for a session."""
def __init__(
self,
*,
state: WebConsoleState | None = None,
runtime_runner: RuntimeRunner | None = None,
human_service: ApprovalService | None = None,
) -> None:
self.state = state or get_web_console_state()
self.runtime_runner = runtime_runner or self._default_runtime_runner
self.human_service = human_service
self._session_db = None
try:
from hermes_state import SessionDB
self._session_db = SessionDB()
except Exception as e:
import logging
logging.getLogger(__name__).warning("SessionDB unavailable: %s", e)
async def run_chat(
self,
*,
prompt: str,
session_id: str,
conversation_history: list[dict[str, Any]] | None = None,
ephemeral_system_prompt: str | None = None,
run_id: str | None = None,
runtime_context: Optional[Dict[str, Any]] = None,
) -> dict[str, Any]:
"""Run a single chat turn and publish GUI events to the session channel."""
run_id = run_id or str(uuid.uuid4())
history = list(conversation_history or [])
runtime_context = dict(runtime_context or {})
loop = asyncio.get_running_loop()
pending_tasks: list[asyncio.Task[Any]] = []
async def publish(event_type: str, payload: dict[str, Any] | None = None) -> None:
await self.state.event_bus.publish(
session_id,
GuiEvent(
type=event_type,
session_id=session_id,
run_id=run_id,
payload=payload or {},
),
)
def runtime_gui_event_callback(event_type: str, payload: dict[str, Any] | None = None) -> None:
# print(f"DEBUG: gui_event_callback -> {event_type}", flush=True)
coro = publish(event_type, payload)
try:
running_loop = asyncio.get_running_loop()
except RuntimeError:
running_loop = None
if running_loop is loop:
pending_tasks.append(loop.create_task(coro))
return
future = asyncio.run_coroutine_threadsafe(coro, loop)
future.result()
await publish("run.started", {"prompt": prompt})
await publish("message.user", {"content": prompt})
try:
print("DEBUG: Waiting for run_in_executor...", flush=True)
result = self.runtime_runner(
prompt=prompt,
session_id=session_id,
conversation_history=history,
ephemeral_system_prompt=ephemeral_system_prompt,
gui_event_callback=runtime_gui_event_callback,
run_id=run_id,
**runtime_context,
)
if inspect.isawaitable(result):
result = await result
print("DEBUG: run_in_executor returned!", flush=True)
if pending_tasks:
print(f"DEBUG: Gathering {len(pending_tasks)} pending tasks...", flush=True)
await asyncio.gather(*pending_tasks)
print("DEBUG: Pending tasks gathered!", flush=True)
result_dict = dict(result or {})
if result_dict.get("failed"):
error_message = result_dict.get("error") or "Run failed"
print(f"DEBUG: Run failed: {error_message}", flush=True)
await publish("run.failed", {"error": error_message})
return result_dict
print("DEBUG: Extracting assistant text...", flush=True)
assistant_text = self._extract_assistant_text(result_dict)
reasoning_text = self._extract_reasoning_text(result_dict)
print("DEBUG: Publishing message.assistant.completed...", flush=True)
completed_payload: dict[str, Any] = {"content": assistant_text}
if reasoning_text:
completed_payload["reasoning"] = reasoning_text
await publish("message.assistant.completed", completed_payload)
print("DEBUG: Publishing run.completed...", flush=True)
await publish(
"run.completed",
{
"completed": result_dict.get("completed", True),
"final_response": assistant_text,
"usage": result_dict.get("usage"),
},
)
print("DEBUG: run_chat successfully completing!", flush=True)
return result_dict
except Exception as exc:
print(f"DEBUG: Exception in run_chat: {exc}", flush=True)
if pending_tasks:
await asyncio.gather(*pending_tasks, return_exceptions=True)
await publish("run.failed", {"error": str(exc), "error_type": type(exc).__name__})
raise
async def _default_runtime_runner(
self,
*,
prompt: str,
session_id: str,
conversation_history: list[dict[str, Any]] | None = None,
ephemeral_system_prompt: str | None = None,
gui_event_callback: Callable[[str, dict[str, Any] | None], None] | None = None,
run_id: str | None = None,
**kwargs: Any,
) -> dict[str, Any]:
"""Run Hermes through the existing AIAgent path in a worker thread."""
loop = asyncio.get_running_loop()
def _run() -> dict[str, Any]:
import importlib
from gateway.run import _resolve_gateway_model, _resolve_runtime_agent_kwargs
from gateway.run import GatewayRunner
from hermes_cli.models import resolve_fast_mode_overrides
from run_agent import AIAgent
terminal_tool = importlib.import_module("tools.terminal_tool")
runtime_kwargs = _resolve_runtime_agent_kwargs()
clarify_callback = None
approval_callback = None
if self.human_service is not None:
clarify_callback = self.human_service.create_clarify_callback(session_id=session_id, run_id=run_id)
approval_callback = self.human_service.create_approval_callback(session_id=session_id, run_id=run_id)
previous_approval_callback = getattr(terminal_tool, "_approval_callback", None)
def _execute_with_agent() -> dict[str, Any]:
is_quick_ask = kwargs.get("quick_ask", False)
active_model = _resolve_gateway_model()
service_tier = GatewayRunner._load_service_tier()
request_overrides = resolve_fast_mode_overrides(active_model) if service_tier else None
agent = AIAgent(
model=active_model,
**runtime_kwargs,
quiet_mode=True,
verbose_logging=False,
session_id=session_id,
platform="web_console",
service_tier=service_tier,
request_overrides=request_overrides,
ephemeral_system_prompt=ephemeral_system_prompt,
clarify_callback=clarify_callback,
gui_event_callback=gui_event_callback,
skip_memory=True if is_quick_ask else runtime_kwargs.get("skip_memory", False),
disabled_toolsets=["core"] if is_quick_ask else None,
session_db=self._session_db,
)
return agent.run_conversation(
user_message=prompt,
conversation_history=conversation_history,
task_id=run_id,
)
if approval_callback is None:
print("DEBUG: Executing without approval callback...", flush=True)
return _execute_with_agent()
print("DEBUG: Acquiring _TERMINAL_APPROVAL_CALLBACK_LOCK...", flush=True)
with _TERMINAL_APPROVAL_CALLBACK_LOCK:
terminal_tool.set_approval_callback(approval_callback)
try:
print("DEBUG: Executing with agent inside lock...", flush=True)
res = _execute_with_agent()
print("DEBUG: Agent execution complete!", flush=True)
return res
finally:
terminal_tool.set_approval_callback(previous_approval_callback)
return await loop.run_in_executor(None, _run)
@staticmethod
def _extract_assistant_text(result: dict[str, Any]) -> str:
final_response = result.get("final_response")
if isinstance(final_response, str):
return final_response
messages = result.get("messages") or []
for message in reversed(messages):
if message.get("role") == "assistant":
content = message.get("content")
if isinstance(content, str):
return content
return ""
@staticmethod
def _extract_reasoning_text(result: dict[str, Any]) -> str:
"""Extract reasoning/thinking text from the last assistant message."""
messages = result.get("messages") or []
for message in reversed(messages):
if message.get("role") == "assistant":
reasoning = message.get("reasoning") or message.get("thinking")
if isinstance(reasoning, str) and reasoning.strip():
return reasoning
return ""

View File

@@ -0,0 +1,267 @@
"""Cron data access helpers for the Hermes Web Console."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
import re
from typing import Any
import cron.jobs as cron_jobs
_CRON_THREAT_PATTERNS = [
(r"ignore\s+(?:\w+\s+)*(?:previous|all|above|prior)\s+(?:\w+\s+)*instructions", "prompt_injection"),
(r"do\s+not\s+tell\s+the\s+user", "deception_hide"),
(r"system\s+prompt\s+override", "sys_prompt_override"),
(r"disregard\s+(your|all|any)\s+(instructions|rules|guidelines)", "disregard_rules"),
(r"curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)", "exfil_curl"),
(r"wget\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)", "exfil_wget"),
(r"cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass)", "read_secrets"),
(r"authorized_keys", "ssh_backdoor"),
(r"/etc/sudoers|visudo", "sudoers_mod"),
(r"rm\s+-rf\s+/", "destructive_root_rm"),
]
_CRON_INVISIBLE_CHARS = {
"\u200b", "\u200c", "\u200d", "\u2060", "\ufeff",
"\u202a", "\u202b", "\u202c", "\u202d", "\u202e",
}
def _scan_cron_prompt(prompt: str) -> str:
for char in _CRON_INVISIBLE_CHARS:
if char in prompt:
return f"Blocked: prompt contains invisible unicode U+{ord(char):04X} (possible injection)."
for pattern, pattern_id in _CRON_THREAT_PATTERNS:
if re.search(pattern, prompt, re.IGNORECASE):
return f"Blocked: prompt matches threat pattern '{pattern_id}'. Cron prompts must not contain injection or exfiltration payloads."
return ""
class CronService:
"""Thin wrapper around Hermes cron storage and lifecycle helpers."""
@staticmethod
def _normalize_skills(skill: str | None = None, skills: Any = None) -> list[str]:
if skills is None:
raw_items = [skill] if skill else []
elif isinstance(skills, str):
raw_items = [skills]
else:
raw_items = list(skills)
normalized: list[str] = []
for item in raw_items:
text = str(item or "").strip()
if text and text not in normalized:
normalized.append(text)
return normalized
@staticmethod
def _serialize_job(job: dict[str, Any] | None) -> dict[str, Any] | None:
if job is None:
return None
normalized = dict(job)
normalized["job_id"] = normalized.get("id")
normalized["skills"] = CronService._normalize_skills(normalized.get("skill"), normalized.get("skills"))
normalized["skill"] = normalized["skills"][0] if normalized["skills"] else None
normalized.setdefault("deliver", "local")
normalized.setdefault("enabled", True)
normalized.setdefault(
"state",
"scheduled" if normalized.get("enabled", True) else "paused",
)
return normalized
@staticmethod
def _parse_repeat(repeat: Any) -> int | None:
if repeat is None:
return None
if isinstance(repeat, bool) or not isinstance(repeat, int):
raise ValueError("The 'repeat' field must be an integer or null.")
if repeat < 1:
raise ValueError("The 'repeat' field must be >= 1 when provided.")
return repeat
@staticmethod
def _parse_optional_text(value: Any, *, field_name: str, strip_trailing_slash: bool = False) -> str | None:
if value is None:
return None
if not isinstance(value, str):
raise ValueError(f"The '{field_name}' field must be a string or null.")
normalized = value.strip()
if strip_trailing_slash:
normalized = normalized.rstrip("/")
return normalized or None
def _validate_prompt_and_skills(self, *, prompt: Any, skill: Any = None, skills: Any = None) -> tuple[str, list[str]]:
if prompt is not None and not isinstance(prompt, str):
raise ValueError("The 'prompt' field must be a string when provided.")
canonical_skills = self._normalize_skills(skill if isinstance(skill, str) else None, skills)
normalized_prompt = prompt if isinstance(prompt, str) else ""
if normalized_prompt:
scan_error = _scan_cron_prompt(normalized_prompt)
if scan_error:
raise ValueError(scan_error)
return normalized_prompt, canonical_skills
def list_jobs(self, *, include_disabled: bool = True) -> dict[str, Any]:
jobs = [self._serialize_job(job) for job in cron_jobs.list_jobs(include_disabled=include_disabled)]
return {
"jobs": jobs,
"count": len(jobs),
"include_disabled": include_disabled,
}
def get_job(self, job_id: str) -> dict[str, Any] | None:
return self._serialize_job(cron_jobs.get_job(job_id))
def create_job(self, payload: dict[str, Any]) -> dict[str, Any]:
schedule = payload.get("schedule")
if not isinstance(schedule, str) or not schedule.strip():
raise ValueError("The 'schedule' field must be a non-empty string.")
prompt, canonical_skills = self._validate_prompt_and_skills(
prompt=payload.get("prompt"),
skill=payload.get("skill"),
skills=payload.get("skills"),
)
if not prompt.strip() and not canonical_skills:
raise ValueError("Provide either a non-empty 'prompt' or at least one skill.")
name = self._parse_optional_text(payload.get("name"), field_name="name")
deliver = self._parse_optional_text(payload.get("deliver"), field_name="deliver")
model = self._parse_optional_text(payload.get("model"), field_name="model")
provider = self._parse_optional_text(payload.get("provider"), field_name="provider")
base_url = self._parse_optional_text(payload.get("base_url"), field_name="base_url", strip_trailing_slash=True)
repeat = self._parse_repeat(payload.get("repeat"))
job = cron_jobs.create_job(
prompt=prompt,
schedule=schedule.strip(),
name=name,
repeat=repeat,
deliver=deliver,
skills=canonical_skills,
model=model,
provider=provider,
base_url=base_url,
)
return self._serialize_job(job) or {}
def update_job(self, job_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
existing = cron_jobs.get_job(job_id)
if existing is None:
return None
updates: dict[str, Any] = {}
if "prompt" in payload or "skills" in payload or "skill" in payload:
prompt, canonical_skills = self._validate_prompt_and_skills(
prompt=payload.get("prompt", existing.get("prompt", "")),
skill=payload.get("skill", existing.get("skill")),
skills=payload.get("skills", existing.get("skills")),
)
if "prompt" in payload:
updates["prompt"] = prompt
if "skills" in payload or "skill" in payload:
updates["skills"] = canonical_skills
updates["skill"] = canonical_skills[0] if canonical_skills else None
effective_prompt = updates.get("prompt", existing.get("prompt", ""))
effective_skills = updates.get("skills", self._normalize_skills(existing.get("skill"), existing.get("skills")))
if not str(effective_prompt).strip() and not effective_skills:
raise ValueError("A cron job must keep either a non-empty prompt or at least one skill.")
if "name" in payload:
updates["name"] = self._parse_optional_text(payload.get("name"), field_name="name") or existing.get("name")
if "deliver" in payload:
updates["deliver"] = self._parse_optional_text(payload.get("deliver"), field_name="deliver")
if "model" in payload:
updates["model"] = self._parse_optional_text(payload.get("model"), field_name="model")
if "provider" in payload:
updates["provider"] = self._parse_optional_text(payload.get("provider"), field_name="provider")
if "base_url" in payload:
updates["base_url"] = self._parse_optional_text(payload.get("base_url"), field_name="base_url", strip_trailing_slash=True)
if "repeat" in payload:
repeat_state = dict(existing.get("repeat") or {})
repeat_state["times"] = self._parse_repeat(payload.get("repeat"))
updates["repeat"] = repeat_state
if "schedule" in payload:
schedule_value = payload.get("schedule")
if not isinstance(schedule_value, str) or not schedule_value.strip():
raise ValueError("The 'schedule' field must be a non-empty string.")
parsed_schedule = cron_jobs.parse_schedule(schedule_value.strip())
updates["schedule"] = parsed_schedule
updates["schedule_display"] = parsed_schedule.get("display", schedule_value.strip())
if existing.get("state") != "paused":
updates["enabled"] = True
updates["state"] = "scheduled"
if not updates:
raise ValueError("No updates were provided.")
updated = cron_jobs.update_job(job_id, updates)
return self._serialize_job(updated)
def pause_job(self, job_id: str, *, reason: str | None = None) -> dict[str, Any] | None:
if reason is not None and not isinstance(reason, str):
raise ValueError("The 'reason' field must be a string when provided.")
return self._serialize_job(cron_jobs.pause_job(job_id, reason=reason.strip() or None if isinstance(reason, str) else None))
def resume_job(self, job_id: str) -> dict[str, Any] | None:
return self._serialize_job(cron_jobs.resume_job(job_id))
def run_job(self, job_id: str) -> dict[str, Any] | None:
return self._serialize_job(cron_jobs.trigger_job(job_id))
def delete_job(self, job_id: str) -> bool:
return cron_jobs.remove_job(job_id)
@staticmethod
def _history_timestamp(output_file: Path) -> str:
try:
return datetime.strptime(output_file.stem, "%Y-%m-%d_%H-%M-%S").astimezone().isoformat()
except ValueError:
return datetime.fromtimestamp(output_file.stat().st_mtime).astimezone().isoformat()
def get_job_history(self, job_id: str, *, limit: int = 20) -> dict[str, Any] | None:
job = self.get_job(job_id)
if job is None:
return None
if limit < 0:
raise ValueError("The 'limit' field must be >= 0.")
output_dir = cron_jobs.OUTPUT_DIR / job_id
entries: list[dict[str, Any]] = []
if output_dir.exists():
files = sorted(
[path for path in output_dir.iterdir() if path.is_file()],
key=lambda path: path.name,
reverse=True,
)
for output_file in files[:limit]:
text = output_file.read_text(encoding="utf-8", errors="replace")
preview = text[:500]
entries.append(
{
"run_id": output_file.stem,
"job_id": job_id,
"output_file": str(output_file),
"filename": output_file.name,
"created_at": self._history_timestamp(output_file),
"size_bytes": output_file.stat().st_size,
"output_preview": preview,
"output_truncated": len(text) > len(preview),
"output_available": True,
"source": "output_file",
}
)
return {
"job_id": job_id,
"history": entries,
"count": len(entries),
"latest_run_at": job.get("last_run_at"),
"latest_status": job.get("last_status"),
"latest_error": job.get("last_error"),
}

View File

@@ -0,0 +1,197 @@
"""Gateway admin service helpers for the Hermes Web Console backend."""
from __future__ import annotations
import os
from typing import Any, Callable
from gateway.config import GatewayConfig, Platform, PlatformConfig, load_gateway_config
from gateway.pairing import PairingStore
from gateway.status import get_running_pid, read_runtime_status
_ALLOW_ALL_ENV_BY_PLATFORM: dict[Platform, str] = {
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
Platform.DISCORD: "DISCORD_ALLOW_ALL_USERS",
Platform.WHATSAPP: "WHATSAPP_ALLOW_ALL_USERS",
Platform.SLACK: "SLACK_ALLOW_ALL_USERS",
Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS",
Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS",
Platform.SMS: "SMS_ALLOW_ALL_USERS",
Platform.MATTERMOST: "MATTERMOST_ALLOW_ALL_USERS",
Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS",
Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS",
}
_ALLOWLIST_ENV_BY_PLATFORM: dict[Platform, str] = {
Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS",
Platform.DISCORD: "DISCORD_ALLOWED_USERS",
Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS",
Platform.SLACK: "SLACK_ALLOWED_USERS",
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
Platform.SMS: "SMS_ALLOWED_USERS",
Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS",
Platform.MATRIX: "MATRIX_ALLOWED_USERS",
Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
}
_EXEMPT_AUTH_PLATFORMS = {Platform.HOMEASSISTANT, Platform.WEBHOOK}
class GatewayService:
"""Thin wrapper around existing gateway runtime status and pairing behavior."""
def __init__(
self,
*,
config_loader: Callable[[], GatewayConfig] = load_gateway_config,
runtime_status_reader: Callable[[], dict[str, Any] | None] = read_runtime_status,
running_pid_getter: Callable[[], int | None] = get_running_pid,
pairing_store_factory: Callable[[], PairingStore] = PairingStore,
) -> None:
self._config_loader = config_loader
self._runtime_status_reader = runtime_status_reader
self._running_pid_getter = running_pid_getter
self._pairing_store_factory = pairing_store_factory
def get_overview(self) -> dict[str, Any]:
config = self._config_loader()
runtime_status = self._runtime_status_reader() or {}
running_pid = self._running_pid_getter()
pairing_state = self.get_pairing_state()
platforms = self._build_platform_rows(config=config, runtime_status=runtime_status, pairing_state=pairing_state)
return {
"gateway": {
"running": running_pid is not None,
"pid": running_pid,
"state": runtime_status.get("gateway_state") or ("running" if running_pid is not None else "stopped"),
"exit_reason": runtime_status.get("exit_reason"),
"updated_at": runtime_status.get("updated_at"),
},
"summary": {
"platform_count": len(platforms),
"enabled_platforms": sum(1 for platform in platforms if platform["enabled"]),
"configured_platforms": sum(1 for platform in platforms if platform["configured"]),
"connected_platforms": sum(1 for platform in platforms if platform["runtime_state"] == "connected"),
"pending_pairings": pairing_state["summary"]["pending_count"],
"approved_pairings": pairing_state["summary"]["approved_count"],
},
}
def get_platforms(self) -> list[dict[str, Any]]:
config = self._config_loader()
runtime_status = self._runtime_status_reader() or {}
pairing_state = self.get_pairing_state()
return self._build_platform_rows(config=config, runtime_status=runtime_status, pairing_state=pairing_state)
def get_pairing_state(self) -> dict[str, Any]:
store = self._pairing_store_factory()
pending = sorted(store.list_pending(), key=lambda item: (item.get("platform", ""), item.get("code", "")))
approved = sorted(store.list_approved(), key=lambda item: (item.get("platform", ""), item.get("user_id", "")))
return {
"pending": pending,
"approved": approved,
"summary": {
"pending_count": len(pending),
"approved_count": len(approved),
"platforms_with_pending": sorted({item["platform"] for item in pending}),
"platforms_with_approved": sorted({item["platform"] for item in approved}),
},
}
def approve_pairing(self, *, platform: str, code: str) -> dict[str, Any] | None:
store = self._pairing_store_factory()
approved = store.approve_code(platform.strip().lower(), code.strip().upper())
if approved is None:
return None
return {
"platform": platform.strip().lower(),
"code": code.strip().upper(),
"user": approved,
}
def revoke_pairing(self, *, platform: str, user_id: str) -> bool:
store = self._pairing_store_factory()
return store.revoke(platform.strip().lower(), user_id.strip())
def _build_platform_rows(
self,
*,
config: GatewayConfig,
runtime_status: dict[str, Any],
pairing_state: dict[str, Any],
) -> list[dict[str, Any]]:
runtime_platforms = runtime_status.get("platforms") or {}
connected_platforms = set(config.get_connected_platforms())
pending_counts = self._count_by_platform(pairing_state.get("pending") or [])
approved_counts = self._count_by_platform(pairing_state.get("approved") or [])
rows: list[dict[str, Any]] = []
for platform in sorted((item for item in Platform if item is not Platform.LOCAL), key=lambda item: item.value):
platform_config = config.platforms.get(platform)
runtime_platform = runtime_platforms.get(platform.value) or {}
allowlist = self._parse_allowlist(os.getenv(_ALLOWLIST_ENV_BY_PLATFORM.get(platform, ""), ""))
home_channel = platform_config.home_channel.to_dict() if platform_config and platform_config.home_channel else None
auth_mode = self._auth_mode(platform=platform, config=config, allowlist=allowlist)
rows.append(
{
"key": platform.value,
"label": self._platform_label(platform),
"enabled": bool(platform_config and platform_config.enabled),
"configured": platform in connected_platforms,
"runtime_state": runtime_platform.get("state") or "unknown",
"error_code": runtime_platform.get("error_code"),
"error_message": runtime_platform.get("error_message"),
"updated_at": runtime_platform.get("updated_at"),
"home_channel": home_channel,
"auth": {
"mode": auth_mode,
"allow_all": self._platform_allow_all_enabled(platform),
"allowlist_count": len(allowlist),
"pairing_behavior": config.get_unauthorized_dm_behavior(platform),
},
"pairing": {
"pending_count": pending_counts.get(platform.value, 0),
"approved_count": approved_counts.get(platform.value, 0),
},
"extra": dict(platform_config.extra) if platform_config else {},
}
)
return rows
@staticmethod
def _count_by_platform(entries: list[dict[str, Any]]) -> dict[str, int]:
counts: dict[str, int] = {}
for item in entries:
platform = item.get("platform")
if not isinstance(platform, str) or not platform:
continue
counts[platform] = counts.get(platform, 0) + 1
return counts
@staticmethod
def _platform_label(platform: Platform) -> str:
return platform.value.replace("_", " ").title()
@staticmethod
def _parse_allowlist(raw: str) -> list[str]:
return [item.strip() for item in raw.split(",") if item.strip()]
@staticmethod
def _platform_allow_all_enabled(platform: Platform) -> bool:
platform_var = _ALLOW_ALL_ENV_BY_PLATFORM.get(platform)
if platform_var and os.getenv(platform_var, "").strip().lower() in {"1", "true", "yes", "on"}:
return True
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in {"1", "true", "yes", "on"}
def _auth_mode(self, *, platform: Platform, config: GatewayConfig, allowlist: list[str]) -> str:
if platform in _EXEMPT_AUTH_PLATFORMS:
return "external_auth"
if self._platform_allow_all_enabled(platform):
return "allow_all"
if allowlist:
return "allowlist"
if config.get_unauthorized_dm_behavior(platform) == "pair":
return "pairing"
return "deny"

View File

@@ -0,0 +1,69 @@
"""Log service helpers for the Hermes Web Console backend."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Callable
from hermes_cli.config import ensure_hermes_home, get_hermes_home
class LogService:
"""Thin wrapper for reading Hermes log files from ~/.hermes/logs."""
def __init__(
self,
*,
hermes_home_getter: Callable[[], Path] = get_hermes_home,
ensure_home: Callable[[], None] = ensure_hermes_home,
) -> None:
self._hermes_home_getter = hermes_home_getter
self._ensure_home = ensure_home
def get_logs(self, *, file_name: str | None = None, limit: int = 200) -> dict[str, Any]:
self._ensure_home()
log_dir = self._hermes_home_getter() / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
files = self._list_log_files(log_dir)
selected = self._resolve_selected_file(log_dir, files, file_name)
lines = self._tail_lines(selected, limit=limit) if selected is not None else []
return {
"directory": str(log_dir),
"file": selected.name if selected is not None else None,
"available_files": [item.name for item in files],
"line_count": len(lines),
"lines": lines,
}
@staticmethod
def _list_log_files(log_dir: Path) -> list[Path]:
return sorted(
[item for item in log_dir.iterdir() if item.is_file()],
key=lambda item: item.stat().st_mtime,
reverse=True,
)
@staticmethod
def _resolve_selected_file(log_dir: Path, files: list[Path], file_name: str | None) -> Path | None:
if file_name:
candidate = (log_dir / file_name).resolve()
if candidate.parent != log_dir.resolve() or not candidate.is_file():
raise FileNotFoundError(file_name)
return candidate
if not files:
return None
for preferred in ("gateway.log", "gateway.error.log"):
for item in files:
if item.name == preferred:
return item
return files[0]
@staticmethod
def _tail_lines(path: Path, *, limit: int) -> list[str]:
if limit <= 0:
return []
try:
content = path.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
return []
return content[-limit:]

View File

@@ -0,0 +1,194 @@
"""Memory and session-search helpers for the Hermes Web Console backend."""
from __future__ import annotations
import importlib.util
import json
import sys
from pathlib import Path
from typing import Any
from hermes_cli.config import load_config
from hermes_state import SessionDB
def _load_module_from_tools(module_name: str):
root = Path(__file__).resolve().parents[3]
module_path = root / "tools" / f"{module_name}.py"
cache_key = f"gateway.web_console._lazy_{module_name}"
module = sys.modules.get(cache_key)
if module is not None:
return module
spec = importlib.util.spec_from_file_location(cache_key, module_path)
if spec is None or spec.loader is None:
raise ImportError(f"Could not load {module_name} from {module_path}.")
module = importlib.util.module_from_spec(spec)
sys.modules[cache_key] = module
spec.loader.exec_module(module)
return module
def _get_memory_store_class():
return _load_module_from_tools("memory_tool").MemoryStore
def session_search(**kwargs):
return _load_module_from_tools("session_search_tool").session_search(**kwargs)
class MemoryService:
"""Thin wrapper around Hermes memory files and session-search logic."""
def __init__(
self,
*,
store: Any = None,
db: SessionDB | None = None,
) -> None:
self._store = store
self.db = db or SessionDB()
@staticmethod
def _memory_config() -> dict[str, Any]:
try:
config = load_config()
except Exception:
config = {}
memory_cfg = config.get("memory") or {}
if not isinstance(memory_cfg, dict):
return {}
return memory_cfg
@classmethod
def _target_enabled(cls, target: str) -> bool:
memory_cfg = cls._memory_config()
if target == "user":
return bool(memory_cfg.get("user_profile_enabled", True))
return bool(memory_cfg.get("memory_enabled", True))
@classmethod
def _build_store(cls) -> Any:
memory_cfg = cls._memory_config()
memory_store_class = _get_memory_store_class()
store = memory_store_class(
memory_char_limit=int(memory_cfg.get("memory_char_limit", 2200) or 2200),
user_char_limit=int(memory_cfg.get("user_char_limit", 1375) or 1375),
)
store.load_from_disk()
return store
def _get_store(self) -> Any:
if self._store is None:
self._store = self._build_store()
return self._store
@staticmethod
def _validate_target(target: str) -> str:
normalized = (target or "memory").strip().lower()
if normalized in {"user-profile", "user_profile"}:
normalized = "user"
if normalized not in {"memory", "user"}:
raise ValueError("The 'target' field must be 'memory' or 'user'.")
return normalized
@staticmethod
def _usage_payload(usage: str) -> dict[str, Any]:
percent = None
current_chars = None
char_limit = None
raw = usage or ""
if "" in raw:
percent_part, _, counts_part = raw.partition("")
percent_part = percent_part.strip().rstrip("%")
counts_part = counts_part.strip().replace(" chars", "")
try:
percent = int(percent_part)
except ValueError:
percent = None
if "/" in counts_part:
current_raw, _, limit_raw = counts_part.partition("/")
try:
current_chars = int(current_raw.replace(",", "").strip())
char_limit = int(limit_raw.replace(",", "").strip())
except ValueError:
current_chars = None
char_limit = None
return {
"text": raw,
"percent": percent,
"current_chars": current_chars,
"char_limit": char_limit,
}
def get_memory(self, *, target: str = "memory") -> dict[str, Any]:
target = self._validate_target(target)
store = self._get_store()
entries = list(store.user_entries if target == "user" else store.memory_entries)
current_chars = store._char_count(target)
char_limit = store._char_limit(target)
usage_text = f"{int((current_chars / char_limit) * 100) if char_limit > 0 else 0}% — {current_chars:,}/{char_limit:,} chars"
return {
"target": target,
"enabled": self._target_enabled(target),
"entries": entries,
"entry_count": len(entries),
"usage": self._usage_payload(usage_text),
"path": str(Path(store._path_for(target)).resolve()),
}
def mutate_memory(
self,
*,
action: str,
target: str = "memory",
content: str | None = None,
old_text: str | None = None,
) -> dict[str, Any]:
target = self._validate_target(target)
if not self._target_enabled(target):
label = "user profile" if target == "user" else "memory"
raise PermissionError(f"Local {label} is disabled in config.")
store = self._get_store()
if action == "add":
result = store.add(target, content or "")
elif action == "replace":
result = store.replace(target, old_text or "", content or "")
elif action == "remove":
result = store.remove(target, old_text or "")
else:
raise ValueError("Unsupported action.")
payload = self.get_memory(target=target)
payload["message"] = result.get("message")
payload["success"] = bool(result.get("success"))
if not result.get("success"):
payload["error"] = result.get("error")
if "matches" in result:
payload["matches"] = result["matches"]
return payload
def search_sessions(
self,
*,
query: str,
role_filter: str | None = None,
limit: int = 3,
current_session_id: str | None = None,
) -> dict[str, Any]:
result = session_search(
query=query,
role_filter=role_filter,
limit=limit,
db=self.db,
current_session_id=current_session_id,
)
try:
payload = json.loads(result)
except (TypeError, json.JSONDecodeError) as exc:
raise RuntimeError("Session search returned invalid JSON.") from exc
if not isinstance(payload, dict):
raise RuntimeError("Session search returned an invalid payload.")
return payload

View File

@@ -0,0 +1,216 @@
"""Session data access helpers for the Hermes Web Console."""
from __future__ import annotations
import json
from typing import Any
from hermes_state import SessionDB
class SessionService:
"""Thin wrapper around Hermes session storage for GUI/API use."""
def __init__(self, db: SessionDB | None = None) -> None:
self.db = db or SessionDB()
@staticmethod
def _token_summary(session: dict[str, Any]) -> dict[str, Any]:
return {
"input": session.get("input_tokens", 0) or 0,
"output": session.get("output_tokens", 0) or 0,
"total": (session.get("input_tokens", 0) or 0) + (session.get("output_tokens", 0) or 0),
"cache_read": session.get("cache_read_tokens", 0) or 0,
"cache_write": session.get("cache_write_tokens", 0) or 0,
"reasoning": session.get("reasoning_tokens", 0) or 0,
}
@staticmethod
def _session_summary(session: dict[str, Any]) -> dict[str, Any]:
preview = session.get("preview", "") or ""
return {
"session_id": session["id"],
"title": session.get("title"),
"last_active": session.get("last_active") or session.get("started_at"),
"source": session.get("source"),
"workspace": None,
"model": session.get("model"),
"token_summary": SessionService._token_summary(session),
"parent_session_id": session.get("parent_session_id"),
"has_tools": bool(session.get("tool_call_count", 0)),
"has_attachments": False,
"preview": preview,
"message_count": session.get("message_count", 0) or 0,
}
@staticmethod
def _parse_json_maybe(value: Any) -> Any:
if not isinstance(value, str):
return value
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return value
@staticmethod
def _transcript_item(message: dict[str, Any]) -> dict[str, Any]:
tool_calls = message.get("tool_calls")
has_tools = bool(tool_calls)
if message.get("role") == "tool":
item_type = "tool_result"
elif has_tools:
item_type = "assistant_tool_call"
else:
item_type = f"{message.get('role', 'unknown')}_message"
return {
"id": message.get("id"),
"type": item_type,
"role": message.get("role"),
"content": message.get("content"),
"timestamp": message.get("timestamp"),
"tool_name": message.get("tool_name"),
"tool_call_id": message.get("tool_call_id"),
"tool_calls": tool_calls,
"finish_reason": message.get("finish_reason"),
}
def list_sessions(self, *, source: str | None = None, limit: int = 20, offset: int = 0) -> list[dict[str, Any]]:
sessions = self.db.list_sessions_rich(source=source, limit=limit, offset=offset)
return [self._session_summary(session) for session in sessions]
def get_session_detail(self, session_id: str) -> dict[str, Any] | None:
resolved = self.db.resolve_session_id(session_id) or session_id
exported = self.db.export_session(resolved)
if not exported:
return None
summary = self._session_summary(exported)
messages = exported.get("messages", [])
summary.update(
{
"started_at": exported.get("started_at"),
"ended_at": exported.get("ended_at"),
"end_reason": exported.get("end_reason"),
"system_prompt": exported.get("system_prompt"),
"metadata": {
"user_id": exported.get("user_id"),
"model_config": SessionService._parse_json_maybe(exported.get("model_config")),
"billing_provider": exported.get("billing_provider"),
"billing_base_url": exported.get("billing_base_url"),
"billing_mode": exported.get("billing_mode"),
"estimated_cost_usd": exported.get("estimated_cost_usd"),
"actual_cost_usd": exported.get("actual_cost_usd"),
"cost_status": exported.get("cost_status"),
"cost_source": exported.get("cost_source"),
"pricing_version": exported.get("pricing_version"),
},
"recap": {
"message_count": len(messages),
"preview": summary.get("preview", ""),
"last_role": messages[-1].get("role") if messages else None,
},
}
)
return summary
def get_transcript(self, session_id: str) -> dict[str, Any] | None:
resolved = self.db.resolve_session_id(session_id) or session_id
session = self.db.get_session(resolved)
if not session:
return None
messages = self.db.get_messages(resolved)
return {
"session_id": resolved,
"items": [self._transcript_item(message) for message in messages],
}
def set_title(self, session_id: str, title: str) -> dict[str, Any] | None:
resolved = self.db.resolve_session_id(session_id) or session_id
if not self.db.set_session_title(resolved, title):
return None
session = self.db.get_session(resolved)
return {
"session_id": resolved,
"title": session.get("title") if session else self.db.get_session_title(resolved),
}
def resume_session(self, session_id: str) -> dict[str, Any] | None:
resolved = self.db.resolve_session_id(session_id) or session_id
detail = self.get_session_detail(resolved)
if detail is None:
return None
conversation = self.db.get_messages_as_conversation(resolved)
return {
"session_id": detail["session_id"],
"status": "resumed",
"resume_supported": True,
"title": detail.get("title"),
"conversation_history": conversation,
"session": detail,
}
def branch_session(self, session_id: str, at_message_index: int | None = None) -> dict[str, Any] | None:
"""Create a new session branched from an existing one.
Copies messages up to ``at_message_index`` (inclusive) into a fresh
session. If *at_message_index* is None the entire history is copied.
The new session's ``parent_session_id`` is set to the source session.
"""
import time
import uuid
resolved = self.db.resolve_session_id(session_id) or session_id
source = self.db.get_session(resolved)
if not source:
return None
messages = self.db.get_messages(resolved)
if at_message_index is not None:
messages = messages[: at_message_index + 1]
# Generate a new session id
ts = time.strftime("%Y%m%d_%H%M%S")
branch_id = f"{ts}_branch_{uuid.uuid4().hex[:6]}"
# Create the branched session
self.db.create_session(
session_id=branch_id,
source=source.get("source", "web_console"),
model=source.get("model", ""),
system_prompt=source.get("system_prompt", ""),
parent_session_id=resolved,
)
# Set a descriptive title
source_title = source.get("title") or resolved
branch_title = f"Branch of {source_title}"
try:
self.db.set_session_title(branch_id, branch_title)
except ValueError:
# Title conflict — append a suffix
branch_title = f"Branch of {source_title} ({branch_id[:8]})"
try:
self.db.set_session_title(branch_id, branch_title)
except ValueError:
pass # Give up on title, keep the default
# Copy messages into the new session
for msg in messages:
self.db.append_message(
session_id=branch_id,
role=msg.get("role", "user"),
content=msg.get("content", ""),
tool_calls=msg.get("tool_calls"),
tool_call_id=msg.get("tool_call_id"),
tool_name=msg.get("tool_name"),
)
return {
"session_id": branch_id,
"parent_session_id": resolved,
"title": branch_title,
"message_count": len(messages),
}
def delete_session(self, session_id: str) -> bool:
resolved = self.db.resolve_session_id(session_id) or session_id
return self.db.delete_session(resolved)

View File

@@ -0,0 +1,138 @@
"""Settings and auth-status service helpers for the Hermes Web Console backend."""
from __future__ import annotations
import copy
import re
from typing import Any, Callable
from hermes_cli import auth as auth_module
from hermes_cli.config import load_config, save_config
_SECRET_KEY_PATTERN = re.compile(
r"(^|_)(api[_-]?key|token|secret|password|passwd|refresh[_-]?token|access[_-]?token|client[_-]?secret)$",
re.IGNORECASE,
)
class SettingsService:
"""Thin wrapper around Hermes config and auth status helpers."""
def __init__(
self,
*,
config_loader: Callable[[], dict[str, Any]] = load_config,
config_saver: Callable[[dict[str, Any]], None] = save_config,
auth_status_getter: Callable[[str | None], dict[str, Any]] = auth_module.get_auth_status,
active_provider_getter: Callable[[], str | None] = auth_module.get_active_provider,
provider_registry: dict[str, Any] | None = None,
) -> None:
self._config_loader = config_loader
self._config_saver = config_saver
self._auth_status_getter = auth_status_getter
self._active_provider_getter = active_provider_getter
self._provider_registry = dict(provider_registry or auth_module.PROVIDER_REGISTRY)
def get_settings(self) -> dict[str, Any]:
config = self._config_loader()
return self._sanitize_payload(config)
def update_settings(self, patch: dict[str, Any]) -> dict[str, Any]:
if not isinstance(patch, dict) or not patch:
raise ValueError("Request body must include a non-empty JSON object.")
current = self._config_loader()
old_provider = current.get("provider")
new_provider = patch.get("provider")
if "model" in patch and isinstance(patch["model"], dict) and "provider" in patch["model"]:
new_provider = patch["model"]["provider"]
if new_provider and new_provider != old_provider:
from hermes_cli.model_switch import detect_provider_for_model
old_model = current.get("model", {})
old_model_name = old_model if isinstance(old_model, str) else old_model.get("default", "")
if old_model_name:
detected, _ = detect_provider_for_model(old_model_name, new_provider)
is_compatible = False
if new_provider == "openai-codex" and detected in ("openai", "openai-codex", "custom"):
is_compatible = True
elif detected in (new_provider, "custom"):
is_compatible = True
if not is_compatible:
if "model" not in patch:
patch["model"] = {}
if isinstance(patch["model"], dict):
# Auto-populate a sensible default for Codex so the GUI isn't empty
default_model = "gpt-5.4" if new_provider == "openai-codex" else ""
patch["model"]["default"] = default_model
patch["model"]["name"] = default_model
updated = self._deep_merge(copy.deepcopy(current), patch)
self._config_saver(updated)
return self._sanitize_payload(updated)
def get_auth_status(self) -> dict[str, Any]:
active_provider = self._active_provider_getter()
providers: list[dict[str, Any]] = []
for provider_id, provider_config in sorted(self._provider_registry.items()):
raw_status = dict(self._auth_status_getter(provider_id) or {})
providers.append(
{
"provider": provider_id,
"name": getattr(provider_config, "name", provider_id),
"auth_type": getattr(provider_config, "auth_type", None),
"active": provider_id == active_provider,
"status": self._sanitize_payload(raw_status),
}
)
active_status = self._sanitize_payload(dict(self._auth_status_getter(active_provider) or {})) if active_provider else {"logged_in": False}
resolved_provider = active_provider
if resolved_provider is None:
for entry in providers:
if entry["status"].get("logged_in"):
resolved_provider = entry["provider"]
break
return {
"active_provider": active_provider,
"resolved_provider": resolved_provider,
"logged_in": bool(active_status.get("logged_in")) if active_provider else any(entry["status"].get("logged_in") for entry in providers),
"active_status": active_status,
"providers": providers,
}
def _sanitize_payload(self, value: Any, *, key_path: tuple[str, ...] = ()) -> Any:
if isinstance(value, dict):
sanitized: dict[str, Any] = {}
for key, item in value.items():
key_text = str(key)
if self._looks_secret_key(key_text):
sanitized[key_text] = self._redacted_value(item)
else:
sanitized[key_text] = self._sanitize_payload(item, key_path=(*key_path, key_text))
return sanitized
if isinstance(value, list):
return [self._sanitize_payload(item, key_path=key_path) for item in value]
return value
@staticmethod
def _deep_merge(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
for key, value in patch.items():
if isinstance(value, dict) and isinstance(base.get(key), dict):
base[key] = SettingsService._deep_merge(dict(base[key]), value)
else:
base[key] = value
return base
@staticmethod
def _looks_secret_key(key: str) -> bool:
return bool(_SECRET_KEY_PATTERN.search(key))
@staticmethod
def _redacted_value(value: Any) -> str | None:
if value in (None, ""):
return None if value is None else ""
return "***"

View File

@@ -0,0 +1,197 @@
"""Skill data access helpers for the Hermes Web Console."""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import Any
from hermes_state import SessionDB
class SkillService:
"""Thin wrapper around Hermes skills discovery and session-scoped in-memory skill state."""
def __init__(self, db: SessionDB | None = None) -> None:
self.db = db or SessionDB()
self._session_loaded_skills: dict[str, dict[str, dict[str, Any]]] = {}
def _resolve_session_id(self, session_id: str) -> str:
return self.db.resolve_session_id(session_id) or session_id
def _require_session(self, session_id: str) -> str:
resolved = self._resolve_session_id(session_id)
if not self.db.get_session(resolved):
raise LookupError("session_not_found")
return resolved
@staticmethod
def _parse_tool_payload(raw_payload: str) -> dict[str, Any]:
payload = json.loads(raw_payload)
if not isinstance(payload, dict):
raise ValueError("invalid_skill_payload")
return payload
@staticmethod
def _build_source_indexes() -> tuple[dict[str, dict[str, Any]], set[str]]:
from tools.skills_hub import HubLockFile
from tools.skills_sync import _read_manifest
hub_entries = {entry["name"]: entry for entry in HubLockFile().list_installed()}
builtin_names = set(_read_manifest())
return hub_entries, builtin_names
@staticmethod
def _merge_source_metadata(
skill: dict[str, Any],
*,
hub_entries: dict[str, dict[str, Any]],
builtin_names: set[str],
) -> dict[str, Any]:
merged = dict(skill)
name = str(skill.get("name") or "")
hub_entry = hub_entries.get(name)
if hub_entry:
merged.update(
{
"source_type": "hub",
"source": hub_entry.get("source", "hub"),
"trust_level": hub_entry.get("trust_level", "community"),
"identifier": hub_entry.get("identifier"),
"install_path": hub_entry.get("install_path"),
"scan_verdict": hub_entry.get("scan_verdict"),
"installed_at": hub_entry.get("installed_at"),
"updated_at": hub_entry.get("updated_at"),
"installed_metadata": hub_entry.get("metadata") or {},
}
)
elif name in builtin_names:
merged.update(
{
"source_type": "builtin",
"source": "builtin",
"trust_level": "builtin",
"identifier": None,
"install_path": None,
"scan_verdict": None,
"installed_at": None,
"updated_at": None,
"installed_metadata": {},
}
)
else:
merged.update(
{
"source_type": "local",
"source": "local",
"trust_level": "local",
"identifier": None,
"install_path": None,
"scan_verdict": None,
"installed_at": None,
"updated_at": None,
"installed_metadata": {},
}
)
return merged
def list_skills(self) -> dict[str, Any]:
from tools.skills_tool import skills_list
payload = self._parse_tool_payload(skills_list())
if not payload.get("success"):
raise RuntimeError(payload.get("error") or "Failed to list skills.")
hub_entries, builtin_names = self._build_source_indexes()
skills = [
self._merge_source_metadata(skill, hub_entries=hub_entries, builtin_names=builtin_names)
for skill in payload.get("skills", [])
]
return {
"skills": skills,
"categories": payload.get("categories", []),
"count": len(skills),
"hint": payload.get("hint"),
}
def get_skill(self, name: str) -> dict[str, Any]:
from tools.skills_tool import skill_view
payload = self._parse_tool_payload(skill_view(name))
if not payload.get("success"):
error_message = str(payload.get("error") or "Failed to load skill.")
lowered = error_message.lower()
if "not found" in lowered:
raise FileNotFoundError(error_message)
raise ValueError(error_message)
hub_entries, builtin_names = self._build_source_indexes()
return self._merge_source_metadata(payload, hub_entries=hub_entries, builtin_names=builtin_names)
def load_skill_for_session(self, session_id: str, name: str) -> dict[str, Any]:
resolved_session_id = self._require_session(session_id)
skill = self.get_skill(name)
loaded_for_session = self._session_loaded_skills.setdefault(resolved_session_id, {})
resolved_name = skill["name"]
if resolved_name in loaded_for_session:
return {
"session_id": resolved_session_id,
"skill": loaded_for_session[resolved_name],
"loaded": True,
"already_loaded": True,
}
session_skill = {
"name": resolved_name,
"description": skill.get("description", ""),
"path": skill.get("path"),
"source": skill.get("source"),
"source_type": skill.get("source_type"),
"trust_level": skill.get("trust_level"),
"readiness_status": skill.get("readiness_status"),
"setup_needed": bool(skill.get("setup_needed", False)),
"loaded_at": datetime.now(timezone.utc).isoformat(),
}
loaded_for_session[resolved_name] = session_skill
return {
"session_id": resolved_session_id,
"skill": session_skill,
"loaded": True,
"already_loaded": False,
}
def list_session_skills(self, session_id: str) -> dict[str, Any]:
resolved_session_id = self._require_session(session_id)
skills = list(self._session_loaded_skills.get(resolved_session_id, {}).values())
skills.sort(key=lambda item: item["name"])
return {
"session_id": resolved_session_id,
"skills": skills,
"count": len(skills),
}
def unload_skill_for_session(self, session_id: str, name: str) -> dict[str, Any]:
resolved_session_id = self._require_session(session_id)
loaded_for_session = self._session_loaded_skills.get(resolved_session_id, {})
canonical_name = name
if canonical_name not in loaded_for_session:
for loaded_name in loaded_for_session:
if loaded_name.lower() == name.lower():
canonical_name = loaded_name
break
if canonical_name not in loaded_for_session:
try:
canonical_name = self.get_skill(name)["name"]
except (FileNotFoundError, ValueError, LookupError):
canonical_name = name
removed = loaded_for_session.pop(canonical_name, None)
if not loaded_for_session and resolved_session_id in self._session_loaded_skills:
self._session_loaded_skills.pop(resolved_session_id, None)
return {
"session_id": resolved_session_id,
"name": canonical_name,
"removed": removed is not None,
}

View File

@@ -0,0 +1,381 @@
"""Workspace and process helpers for the Hermes Web Console backend."""
from __future__ import annotations
import os
import re
from pathlib import Path
from typing import Any
from hermes_cli.config import get_project_root, load_config
_MAX_TREE_ENTRIES = 200
_MAX_FILE_LINES = 2000
_MAX_SEARCH_MATCHES = 200
_MAX_SEARCH_FILE_BYTES = 1_000_000
_MAX_SEARCH_FILES = 2000
class WorkspaceService:
"""Thin service layer for GUI workspace browsing and process inspection."""
def __init__(
self,
*,
workspace_root: str | Path | None = None,
checkpoint_manager: Any = None,
process_registry: Any = None,
) -> None:
self.workspace_root = self._resolve_workspace_root(workspace_root)
self.checkpoint_manager = checkpoint_manager
self.process_registry = process_registry
@staticmethod
def _resolve_workspace_root(workspace_root: str | Path | None) -> Path:
if workspace_root is not None:
return Path(workspace_root).expanduser().resolve()
configured_cwd = (os.getenv("TERMINAL_CWD") or "").strip()
if configured_cwd and configured_cwd not in {".", "auto", "cwd"}:
candidate = Path(configured_cwd).expanduser().resolve()
if candidate.exists():
return candidate
return get_project_root().resolve()
@staticmethod
def _build_checkpoint_manager() -> Any:
from tools.checkpoint_manager import CheckpointManager
try:
config = load_config()
except Exception:
config = {}
checkpoint_cfg = config.get("checkpoints") or {}
if isinstance(checkpoint_cfg, bool):
enabled = checkpoint_cfg
max_snapshots = 50
else:
enabled = bool(checkpoint_cfg.get("enabled", True))
max_snapshots = int(checkpoint_cfg.get("max_snapshots", 50) or 50)
return CheckpointManager(enabled=enabled, max_snapshots=max_snapshots)
@staticmethod
def _get_default_process_registry() -> Any:
from tools.process_registry import process_registry
return process_registry
def _get_checkpoint_manager(self) -> Any:
if self.checkpoint_manager is None:
self.checkpoint_manager = self._build_checkpoint_manager()
return self.checkpoint_manager
def _get_process_registry(self) -> Any:
if self.process_registry is None:
self.process_registry = self._get_default_process_registry()
return self.process_registry
def _resolve_path(self, path: str | None, *, allow_root: bool = True) -> Path:
raw_path = (path or "").strip()
candidate = self.workspace_root if not raw_path else (self.workspace_root / raw_path)
resolved = candidate.expanduser().resolve()
try:
resolved.relative_to(self.workspace_root)
except ValueError as exc:
raise ValueError("Path escapes the active workspace.") from exc
if not allow_root and resolved == self.workspace_root:
raise ValueError("This operation requires a path inside the workspace.")
return resolved
def _relative_path(self, path: Path) -> str:
if path == self.workspace_root:
return "."
return path.relative_to(self.workspace_root).as_posix()
@staticmethod
def _is_binary_file(path: Path) -> bool:
try:
sample = path.read_bytes()[:4096]
except OSError:
return False
return b"\x00" in sample
def _build_tree_node(
self,
path: Path,
*,
depth: int,
include_hidden: bool,
remaining: list[int],
) -> dict[str, Any]:
is_dir = path.is_dir()
node: dict[str, Any] = {
"name": path.name or self.workspace_root.name,
"path": self._relative_path(path),
"type": "directory" if is_dir else "file",
}
if not is_dir:
try:
node["size"] = path.stat().st_size
except OSError:
node["size"] = None
return node
if depth <= 0:
node["children"] = []
node["truncated"] = False
return node
children: list[dict[str, Any]] = []
truncated = False
try:
entries = sorted(
path.iterdir(),
key=lambda entry: (not entry.is_dir(), entry.name.lower()),
)
except OSError as exc:
node["children"] = []
node["error"] = str(exc)
return node
for entry in entries:
if not include_hidden and entry.name.startswith("."):
continue
if remaining[0] <= 0:
truncated = True
break
remaining[0] -= 1
children.append(
self._build_tree_node(
entry,
depth=depth - 1,
include_hidden=include_hidden,
remaining=remaining,
)
)
node["children"] = children
node["truncated"] = truncated
return node
def get_tree(self, *, path: str | None = None, depth: int = 2, include_hidden: bool = False) -> dict[str, Any]:
target = self._resolve_path(path)
if not target.exists():
raise FileNotFoundError("The requested workspace path does not exist.")
if not target.is_dir():
raise ValueError("The requested workspace path is not a directory.")
safe_depth = max(0, min(int(depth), 6))
remaining = [_MAX_TREE_ENTRIES]
return {
"workspace_root": str(self.workspace_root),
"tree": self._build_tree_node(
target,
depth=safe_depth,
include_hidden=include_hidden,
remaining=remaining,
),
}
def get_file(self, *, path: str, offset: int = 1, limit: int = 500) -> dict[str, Any]:
target = self._resolve_path(path, allow_root=False)
if not target.exists():
raise FileNotFoundError("The requested file does not exist.")
if not target.is_file():
raise ValueError("The requested path is not a file.")
if self._is_binary_file(target):
raise ValueError("Binary files are not supported by this endpoint.")
safe_offset = max(1, int(offset))
safe_limit = max(1, min(int(limit), _MAX_FILE_LINES))
text = target.read_text(encoding="utf-8", errors="replace")
lines = text.splitlines()
start = safe_offset - 1
end = start + safe_limit
selected = lines[start:end]
return {
"workspace_root": str(self.workspace_root),
"file": {
"path": self._relative_path(target),
"size": target.stat().st_size,
"line_count": len(lines),
"offset": safe_offset,
"limit": safe_limit,
"content": "\n".join(selected),
"truncated": end < len(lines),
"is_binary": False,
},
}
def save_file(self, *, path: str, content: str) -> dict[str, Any]:
"""Write *content* to the file at *path* inside the workspace."""
target = self._resolve_path(path, allow_root=False)
if target.is_dir():
raise ValueError("The requested path is a directory, not a file.")
if self._is_binary_file(target) and target.exists():
raise ValueError("Binary files cannot be saved through this endpoint.")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(content, encoding="utf-8")
return {
"workspace_root": str(self.workspace_root),
"file": {
"path": self._relative_path(target),
"size": target.stat().st_size,
},
}
def search_workspace(
self,
*,
query: str,
path: str | None = None,
limit: int = 50,
include_hidden: bool = False,
regex: bool = False,
) -> dict[str, Any]:
if not query or not query.strip():
raise ValueError("The search query must be a non-empty string.")
base = self._resolve_path(path)
if not base.exists():
raise FileNotFoundError("The requested search path does not exist.")
if not base.is_dir():
raise ValueError("The requested search path is not a directory.")
safe_limit = max(1, min(int(limit), _MAX_SEARCH_MATCHES))
matcher = re.compile(query) if regex else None
matches: list[dict[str, Any]] = []
scanned_files = 0
for candidate in base.rglob("*"):
if len(matches) >= safe_limit or scanned_files >= _MAX_SEARCH_FILES:
break
if candidate.is_dir():
if not include_hidden and any(part.startswith(".") for part in candidate.relative_to(base).parts if part not in {"."}):
continue
continue
if not include_hidden and any(part.startswith(".") for part in candidate.relative_to(base).parts):
continue
try:
if candidate.stat().st_size > _MAX_SEARCH_FILE_BYTES or self._is_binary_file(candidate):
continue
scanned_files += 1
with candidate.open("r", encoding="utf-8", errors="replace") as handle:
for line_number, line in enumerate(handle, start=1):
haystack = line.rstrip("\n")
found = bool(matcher.search(haystack)) if matcher else query in haystack
if found:
matches.append(
{
"path": self._relative_path(candidate),
"line": line_number,
"content": haystack,
}
)
if len(matches) >= safe_limit:
break
except OSError:
continue
return {
"workspace_root": str(self.workspace_root),
"query": query,
"path": self._relative_path(base),
"matches": matches,
"truncated": len(matches) >= safe_limit,
"scanned_files": scanned_files,
}
def list_checkpoints(self, *, path: str | None = None) -> dict[str, Any]:
checkpoint_manager = self._get_checkpoint_manager()
target = self._resolve_path(path)
working_dir = target if target.is_dir() else Path(checkpoint_manager.get_working_dir_for_path(str(target)))
checkpoints = checkpoint_manager.list_checkpoints(str(working_dir))
return {
"workspace_root": str(self.workspace_root),
"working_dir": str(working_dir),
"checkpoints": checkpoints,
}
def diff_checkpoint(self, *, checkpoint_id: str, path: str | None = None) -> dict[str, Any]:
if not checkpoint_id:
raise ValueError("A checkpoint_id is required.")
checkpoint_manager = self._get_checkpoint_manager()
target = self._resolve_path(path)
working_dir = target if target.is_dir() else Path(checkpoint_manager.get_working_dir_for_path(str(target)))
result = checkpoint_manager.diff(str(working_dir), checkpoint_id)
if not result.get("success"):
raise FileNotFoundError(result.get("error") or "Checkpoint diff failed.")
return {
"workspace_root": str(self.workspace_root),
"working_dir": str(working_dir),
"checkpoint_id": checkpoint_id,
"stat": result.get("stat", ""),
"diff": result.get("diff", ""),
}
def rollback(self, *, checkpoint_id: str, path: str | None = None, file_path: str | None = None) -> dict[str, Any]:
if not checkpoint_id:
raise ValueError("A checkpoint_id is required.")
checkpoint_manager = self._get_checkpoint_manager()
target = self._resolve_path(path)
working_dir = target if target.is_dir() else Path(checkpoint_manager.get_working_dir_for_path(str(target)))
restore_file: str | None = None
if file_path:
restore_target = self._resolve_path(file_path, allow_root=False)
restore_file = str(restore_target.relative_to(working_dir).as_posix())
result = checkpoint_manager.restore(str(working_dir), checkpoint_id, file_path=restore_file)
if not result.get("success"):
raise FileNotFoundError(result.get("error") or "Rollback failed.")
return result
def list_processes(self) -> dict[str, Any]:
return {"processes": self._get_process_registry().list_sessions()}
def get_process_log(self, process_id: str, *, offset: int = 0, limit: int = 200) -> dict[str, Any]:
result = self._get_process_registry().read_log(process_id, offset=offset, limit=limit)
if result.get("status") == "not_found":
raise FileNotFoundError(result.get("error") or "Process not found.")
return result
def kill_process(self, process_id: str) -> dict[str, Any]:
result = self._get_process_registry().kill_process(process_id)
if result.get("status") == "not_found":
raise FileNotFoundError(result.get("error") or "Process not found.")
if result.get("status") == "error":
raise RuntimeError(result.get("error") or "Could not kill process.")
return result
def execute_sync(self, command: str) -> dict[str, Any]:
if not command or not command.strip():
raise ValueError("Command cannot be empty.")
import subprocess
try:
# Setting a 10s timeout so this doesn't hang the GUI thread if someone runs `sleep 100` or a repl.
result = subprocess.run(
command,
shell=True,
cwd=str(self.workspace_root),
capture_output=True,
text=True,
timeout=10
)
return {
"cwd": str(self.workspace_root),
"stdout": result.stdout,
"stderr": result.stderr,
"returncode": result.returncode
}
except subprocess.TimeoutExpired as e:
return {
"cwd": str(self.workspace_root),
"stdout": e.stdout or "",
"stderr": (e.stderr or "") + f"\n[Process timed out after 10s]",
"returncode": -1
}
except Exception as e:
raise RuntimeError(f"Command execution failed: {e}")

107
gateway/web_console/sse.py Normal file
View File

@@ -0,0 +1,107 @@
"""Helpers for building server-sent event streams for the web console."""
from __future__ import annotations
import asyncio
import contextlib
import json
from dataclasses import dataclass
from typing import Any, AsyncIterable
from aiohttp import web
@dataclass(slots=True)
class SseMessage:
"""An SSE message payload."""
data: Any
event: str | None = None
id: str | None = None
retry: int | None = None
SSE_HEADERS = {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
def json_dumps(data: Any) -> str:
"""Serialize data for SSE payload lines."""
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
def format_sse_message(message: SseMessage) -> bytes:
"""Encode a single SSE message frame."""
lines: list[str] = []
if message.event:
lines.append(f"event: {message.event}")
if message.id:
lines.append(f"id: {message.id}")
if message.retry is not None:
lines.append(f"retry: {message.retry}")
payload = json_dumps(message.data)
for line in payload.splitlines() or [payload]:
lines.append(f"data: {line}")
return ("\n".join(lines) + "\n\n").encode("utf-8")
def format_sse_ping(comment: str = "ping") -> bytes:
"""Encode an SSE keepalive comment frame."""
return f": {comment}\n\n".encode("utf-8")
async def _safe_sse_write(response: web.StreamResponse, chunk: bytes) -> bool:
"""Write an SSE chunk, returning False if the client disconnected."""
try:
await response.write(chunk)
except (ConnectionResetError, RuntimeError):
return False
return True
async def stream_sse(
request: web.Request,
events: AsyncIterable[SseMessage],
*,
keepalive_interval: float = 15.0,
ping_comment: str = "ping",
) -> web.StreamResponse:
"""Write an async iterable of SSE messages to the client with keepalives."""
response = web.StreamResponse(status=200, headers=SSE_HEADERS)
await response.prepare(request)
iterator = aiter(events)
next_message_task: asyncio.Task[SseMessage] | None = None
while True:
if next_message_task is None:
next_message_task = asyncio.create_task(anext(iterator))
done, _pending = await asyncio.wait({next_message_task}, timeout=keepalive_interval)
if not done:
if not await _safe_sse_write(response, format_sse_ping(ping_comment)):
next_message_task.cancel()
with contextlib.suppress(asyncio.CancelledError, StopAsyncIteration):
await next_message_task
break
continue
try:
message = next_message_task.result()
except StopAsyncIteration:
break
finally:
next_message_task = None
if not await _safe_sse_write(response, format_sse_message(message)):
break
with contextlib.suppress(ConnectionResetError, RuntimeError):
await response.write_eof()
return response

View File

@@ -0,0 +1,74 @@
"""Shared state helpers for the Hermes Web Console backend."""
from __future__ import annotations
from collections import OrderedDict
from dataclasses import dataclass, field
from typing import Any
from .event_bus import GuiEventBus
MAX_TRACKED_RUNS = 200
TERMINAL_RUN_STATUSES = {"completed", "failed", "cancelled"}
@dataclass(slots=True)
class WebConsoleState:
"""Container for shared web console backend primitives."""
event_bus: GuiEventBus = field(default_factory=GuiEventBus)
runs: OrderedDict[str, dict[str, Any]] = field(default_factory=OrderedDict)
def _evict_runs_if_needed(self) -> None:
"""Evict oldest terminal runs first, then oldest active runs if still over limit."""
while len(self.runs) > MAX_TRACKED_RUNS:
evicted = False
for existing_run_id, existing_run in list(self.runs.items()):
if existing_run.get("status") in TERMINAL_RUN_STATUSES:
del self.runs[existing_run_id]
evicted = True
break
if evicted:
continue
self.runs.popitem(last=False)
def record_run(self, run_id: str, metadata: dict[str, Any]) -> dict[str, Any]:
"""Store or replace the tracked metadata for a run."""
self.runs[run_id] = dict(metadata)
self.runs.move_to_end(run_id)
self._evict_runs_if_needed()
return self.runs[run_id]
def update_run(self, run_id: str, **metadata: Any) -> dict[str, Any] | None:
"""Update tracked metadata for a run if it exists."""
run = self.runs.get(run_id)
if run is None:
return None
run.update(metadata)
self.runs.move_to_end(run_id)
self._evict_runs_if_needed()
return run
def get_run(self, run_id: str) -> dict[str, Any] | None:
"""Return tracked metadata for a run if it exists."""
run = self.runs.get(run_id)
if run is None:
return None
return dict(run)
_shared_state: WebConsoleState | None = None
def create_web_console_state() -> WebConsoleState:
"""Create an isolated web console state container."""
return WebConsoleState()
def get_web_console_state() -> WebConsoleState:
"""Return the shared web console state container."""
global _shared_state
if _shared_state is None:
_shared_state = create_web_console_state()
return _shared_state

View File

@@ -0,0 +1,52 @@
"""Static placeholder content for the Hermes Web Console."""
def get_web_console_placeholder_html() -> str:
"""Return a minimal placeholder page for the GUI app shell."""
return """<!doctype html>
<html lang=\"en\">
<head>
<meta charset=\"utf-8\" />
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
<title>Hermes Web Console</title>
<style>
body {
margin: 0;
font-family: system-ui, sans-serif;
background: #0b1020;
color: #edf2ff;
display: grid;
place-items: center;
min-height: 100vh;
}
main {
max-width: 42rem;
padding: 2rem;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 1rem;
background: rgba(18, 25, 45, 0.92);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
}
h1 {
margin-top: 0;
}
p {
line-height: 1.5;
color: #cbd5f5;
}
code {
background: rgba(255, 255, 255, 0.08);
padding: 0.15rem 0.35rem;
border-radius: 0.35rem;
}
</style>
</head>
<body>
<main>
<h1>Hermes Web Console</h1>
<p>This is the initial GUI backend placeholder mounted by the API server.</p>
<p>Backend status endpoints are available at <code>/api/gui/health</code> and <code>/api/gui/meta</code>.</p>
</main>
</body>
</html>
"""

86
run-gui.sh Executable file
View File

@@ -0,0 +1,86 @@
#!/bin/bash
# ============================================================================
# Hermes Web Console GUI Runner
# ============================================================================
# 1-line run command that boots both the Python backend Gateway
# and the React frontend concurrently.
#
# Usage:
# ./run-gui.sh
# ============================================================================
set -e
# Colors
GREEN='\033[0;32m'
CYAN='\033[0;36m'
YELLOW='\033[0;33m'
RED='\033[0;31m'
NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
echo ""
echo -e "${CYAN}⚕ Starting Hermes Web Console...${NC}"
echo -e "Press ${YELLOW}Ctrl+C${NC} at any time to shut down both servers safely."
echo ""
# 1. Start the Python Backend
echo -e "${GREEN}${NC} Initializing Hermes Backend Gateway..."
if [ -f "$SCRIPT_DIR/venv/bin/activate" ]; then
source "$SCRIPT_DIR/venv/bin/activate"
else
echo -e "${RED}✗ Virtual environment not found. Please run ./setup-gui.sh first.${NC}"
exit 1
fi
# Run the python gateway in the background
python "$SCRIPT_DIR/gateway/run.py" &
BACKEND_PID=$!
echo -e " ↳ Backend PID: $BACKEND_PID"
# 2. Start the React Frontend
echo -e "${GREEN}${NC} Initializing React Frontend..."
cd "$SCRIPT_DIR/web_console"
if [ ! -d "node_modules" ]; then
echo -e "${RED}✗ node_modules not found. Please run ./setup-gui.sh first.${NC}"
kill $BACKEND_PID 2>/dev/null || true
exit 1
fi
# Run vite dev server in the background
npm run dev &
FRONTEND_PID=$!
echo -e " ↳ Frontend PID: $FRONTEND_PID"
echo ""
echo -e "${GREEN}★ Web Console is up and running! ★${NC}"
echo -e "Backend running on port 8642. Frontend running on port 5173 (usually)."
echo ""
# Handle Shutdown Gracefully
cleanup() {
echo ""
echo -e "${YELLOW}⚠ Shutting down Hermes Web Console...${NC}"
kill $BACKEND_PID 2>/dev/null || true
kill $FRONTEND_PID 2>/dev/null || true
# Wait for processes to exit
wait $BACKEND_PID 2>/dev/null || true
wait $FRONTEND_PID 2>/dev/null || true
# Just in case there are orphaned children processes
pkill -P $BACKEND_PID 2>/dev/null || true
pkill -P $FRONTEND_PID 2>/dev/null || true
echo -e "${GREEN}✓ Shutdown complete. Goodbye!${NC}"
exit 0
}
# Trap termination signals
trap cleanup SIGINT SIGTERM
# Wait indefinitely for the background tasks to prevent script from exiting
wait

63
setup-gui.sh Executable file
View File

@@ -0,0 +1,63 @@
#!/bin/bash
# ============================================================================
# Hermes Web Console GUI Setup Script
# ============================================================================
# Quick setup for installing frontend dependencies and ensuring the base
# Hermes agent environment is ready.
#
# Usage:
# ./setup-gui.sh
# ============================================================================
set -e
# Colors
GREEN='\033[0;32m'
CYAN='\033[0;36m'
RED='\033[0;31m'
NC='\033[0m'
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
echo ""
echo -e "${CYAN}⚕ Hermes Web Console Setup${NC}"
echo ""
# 1. Base Hermes Setup Check
if [ ! -d "venv" ]; then
echo -e "${YELLOW}${NC} Base Hermes environment (venv) not found."
echo -e "${CYAN}${NC} Running base setup script (setup-hermes.sh)..."
bash "$SCRIPT_DIR/setup-hermes.sh"
else
echo -e "${GREEN}${NC} Base Hermes environment (venv) found."
fi
# 2. Node.js Check
echo -e "${CYAN}${NC} Checking for Node.js..."
if ! command -v node &> /dev/null; then
echo -e "${RED}✗ Node.js is required but not installed.${NC}"
echo "Please install Node.js (https://nodejs.org/) and run this script again."
exit 1
fi
echo -e "${GREEN}${NC} Node.js found ($(node -v))"
# 3. npm Check
if ! command -v npm &> /dev/null; then
echo -e "${RED}✗ npm is required but not installed.${NC}"
echo "Please install npm to continue."
exit 1
fi
echo -e "${GREEN}${NC} npm found ($(npm -v))"
# 4. Install Frontend Dependencies
echo -e "${CYAN}${NC} Installing frontend dependencies in ./web_console ..."
cd "$SCRIPT_DIR/web_console"
npm install
echo ""
echo -e "${GREEN}✓ Setup complete!${NC}"
echo ""
echo "To run the Web Console GUI, simply use the following 1-line command:"
echo -e "${CYAN} ./run-gui.sh${NC}"
echo ""

View File

@@ -0,0 +1,84 @@
"""Tests for Hermes Web Console routes mounted by the API server adapter."""
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from gateway.config import PlatformConfig
from gateway.platforms.api_server import APIServerAdapter, cors_middleware
def _make_adapter() -> APIServerAdapter:
return APIServerAdapter(PlatformConfig(enabled=True))
def _create_app(adapter: APIServerAdapter) -> web.Application:
app = web.Application(middlewares=[cors_middleware])
adapter._register_routes(app)
return app
class TestGuiRoutes:
@pytest.mark.asyncio
async def test_gui_health_route_exists(self):
adapter = _make_adapter()
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.get("/api/gui/health")
assert resp.status == 200
assert resp.content_type == "application/json"
data = await resp.json()
assert data == {
"status": "ok",
"service": "gui-backend",
"product": "hermes-web-console",
}
@pytest.mark.asyncio
async def test_gui_meta_route_exists(self):
adapter = _make_adapter()
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.get("/api/gui/meta")
assert resp.status == 200
data = await resp.json()
assert data["product"] == "hermes-web-console"
assert data["api_base_path"] == "/api/gui"
assert data["app_base_path"] == "/app/"
assert data["adapter"] == adapter.name
@pytest.mark.asyncio
async def test_app_placeholder_route_exists(self):
adapter = _make_adapter()
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.get("/app/")
assert resp.status == 200
assert resp.content_type == "text/html"
html = await resp.text()
assert "Hermes Web Console" in html
assert "/api/gui/health" in html
assert "/api/gui/meta" in html
@pytest.mark.asyncio
async def test_existing_routes_still_work_with_gui_mount(self):
adapter = _make_adapter()
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
root_resp = await cli.get("/")
health_resp = await cli.get("/health")
models_resp = await cli.get("/v1/models")
assert root_resp.status == 200
assert health_resp.status == 200
assert models_resp.status == 200
health_data = await health_resp.json()
models_data = await models_resp.json()
assert health_data["status"] == "ok"
assert models_data["data"][0]["id"] == "hermes-agent"

View File

@@ -0,0 +1,191 @@
"""Tests for human approval and clarification APIs."""
from __future__ import annotations
import threading
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
import time
from gateway.web_console.api.approvals import HUMAN_SERVICE_APP_KEY
from gateway.web_console.routes import register_web_console_routes
from gateway.web_console.services.approval_service import ApprovalService
from tools.approval import disable_session_yolo
def _wait_for_pending(service: ApprovalService, kind: str, timeout: float = 1.0):
deadline = time.time() + timeout
while time.time() < deadline:
pending = service.list_pending()
for item in pending:
if item["kind"] == kind:
return item
time.sleep(0.01)
return None
class TestApprovalServiceCallbacks:
def test_approval_callback_round_trip(self):
service = ApprovalService()
callback = service.create_approval_callback(session_id="sess-1", run_id="run-1")
result: dict[str, str] = {}
def worker():
result["value"] = callback("rm -rf /tmp/test", "Delete temporary files")
thread = threading.Thread(target=worker)
thread.start()
pending = _wait_for_pending(service, "approval")
assert pending is not None
request_id = pending["request_id"]
service.resolve_approval(request_id, "session")
thread.join(timeout=2)
assert result["value"] == "session"
def test_clarify_callback_round_trip(self):
service = ApprovalService()
callback = service.create_clarify_callback(session_id="sess-2", run_id="run-2")
result: dict[str, str] = {}
def worker():
result["value"] = callback("What kind of GUI?", ["web", "desktop"])
thread = threading.Thread(target=worker)
thread.start()
pending = _wait_for_pending(service, "clarify")
assert pending is not None
request_id = pending["request_id"]
service.resolve_clarification(request_id, "web")
thread.join(timeout=2)
assert result["value"] == "web"
class TestApprovalApiRoutes:
@staticmethod
async def _make_client(service: ApprovalService) -> TestClient:
app = web.Application()
app[HUMAN_SERVICE_APP_KEY] = service
register_web_console_routes(app)
client = TestClient(TestServer(app))
await client.start_server()
return client
@pytest.mark.asyncio
async def test_pending_approve_and_clarify_routes(self):
service = ApprovalService()
approval_callback = service.create_approval_callback(session_id="sess-1", run_id="run-1")
clarify_callback = service.create_clarify_callback(session_id="sess-2", run_id="run-2")
approval_result: dict[str, str] = {}
clarify_result: dict[str, str] = {}
approval_thread = threading.Thread(target=lambda: approval_result.setdefault("value", approval_callback("git push", "Push to origin")))
clarify_thread = threading.Thread(target=lambda: clarify_result.setdefault("value", clarify_callback("Choose a GUI", ["web", "desktop"])))
approval_thread.start()
clarify_thread.start()
assert _wait_for_pending(service, "approval") is not None
assert _wait_for_pending(service, "clarify") is not None
client = await self._make_client(service)
try:
pending_resp = await client.get("/api/gui/human/pending")
assert pending_resp.status == 200
pending_payload = await pending_resp.json()
assert pending_payload["ok"] is True
assert len(pending_payload["pending"]) == 2
approval_request = next(item for item in pending_payload["pending"] if item["kind"] == "approval")
clarify_request = next(item for item in pending_payload["pending"] if item["kind"] == "clarify")
approve_resp = await client.post(
"/api/gui/human/approve",
json={"request_id": approval_request["request_id"], "decision": "always"},
)
assert approve_resp.status == 200
approve_payload = await approve_resp.json()
assert approve_payload["ok"] is True
assert approve_payload["request"]["response"] == "always"
clarify_resp = await client.post(
"/api/gui/human/clarify",
json={"request_id": clarify_request["request_id"], "response": "web"},
)
assert clarify_resp.status == 200
clarify_payload = await clarify_resp.json()
assert clarify_payload["ok"] is True
assert clarify_payload["request"]["response"] == "web"
finally:
approval_thread.join(timeout=2)
clarify_thread.join(timeout=2)
await client.close()
assert approval_result["value"] == "always"
assert clarify_result["value"] == "web"
@pytest.mark.asyncio
async def test_deny_and_invalid_requests_are_structured(self):
service = ApprovalService()
approval_callback = service.create_approval_callback(session_id="sess-3", run_id="run-3")
result: dict[str, str] = {}
thread = threading.Thread(target=lambda: result.setdefault("value", approval_callback("sudo rm", "Dangerous command")))
thread.start()
assert _wait_for_pending(service, "approval") is not None
client = await self._make_client(service)
try:
pending_payload = await (await client.get("/api/gui/human/pending")).json()
approval_request = next(item for item in pending_payload["pending"] if item["kind"] == "approval")
deny_resp = await client.post("/api/gui/human/deny", json={"request_id": approval_request["request_id"]})
assert deny_resp.status == 200
deny_payload = await deny_resp.json()
assert deny_payload["ok"] is True
assert deny_payload["request"]["response"] == "deny"
invalid_json_resp = await client.post(
"/api/gui/human/approve",
data="not json",
headers={"Content-Type": "application/json"},
)
assert invalid_json_resp.status == 400
invalid_json_payload = await invalid_json_resp.json()
assert invalid_json_payload["error"]["code"] == "invalid_json"
missing_resp = await client.post("/api/gui/human/clarify", json={"request_id": "missing", "response": "x"})
assert missing_resp.status == 404
missing_payload = await missing_resp.json()
assert missing_payload["error"]["code"] == "request_not_found"
finally:
thread.join(timeout=2)
await client.close()
assert result["value"] == "deny"
@pytest.mark.asyncio
async def test_yolo_routes_toggle_session_state(self):
service = ApprovalService()
client = await self._make_client(service)
session_id = "sess-yolo"
disable_session_yolo(session_id)
try:
status_resp = await client.get(f"/api/gui/human/yolo?session_id={session_id}")
assert status_resp.status == 200
status_payload = await status_resp.json()
assert status_payload == {"ok": True, "session_id": session_id, "enabled": False}
enable_resp = await client.post("/api/gui/human/yolo", json={"session_id": session_id, "enabled": True})
assert enable_resp.status == 200
enable_payload = await enable_resp.json()
assert enable_payload == {"ok": True, "session_id": session_id, "enabled": True}
disable_resp = await client.post("/api/gui/human/yolo", json={"session_id": session_id, "enabled": False})
assert disable_resp.status == 200
disable_payload = await disable_resp.json()
assert disable_payload == {"ok": True, "session_id": session_id, "enabled": False}
finally:
disable_session_yolo(session_id)
await client.close()

View File

@@ -0,0 +1,109 @@
"""Tests for the web console browser API."""
from __future__ import annotations
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from gateway.web_console.api.browser import BROWSER_SERVICE_APP_KEY
from gateway.web_console.routes import register_web_console_routes
class FakeBrowserService:
def __init__(self) -> None:
self.connect_calls: list[str | None] = []
self.disconnect_calls = 0
def get_status(self) -> dict:
return {
"mode": "local",
"connected": False,
"cdp_url": "",
"reachable": None,
"requirements_ok": True,
"active_sessions": {},
}
def connect(self, cdp_url: str | None = None) -> dict:
self.connect_calls.append(cdp_url)
if cdp_url == "bad":
raise ValueError("bad cdp url")
return {
"mode": "live_cdp",
"connected": True,
"cdp_url": cdp_url or "http://localhost:9222",
"reachable": True,
"requirements_ok": True,
"active_sessions": {},
"message": "Browser connected to a live Chrome CDP endpoint.",
}
def disconnect(self) -> dict:
self.disconnect_calls += 1
return {
"mode": "local",
"connected": False,
"cdp_url": "",
"reachable": None,
"requirements_ok": True,
"active_sessions": {},
"message": "Browser reverted to the default backend.",
}
class TestBrowserApi:
@staticmethod
async def _make_client(service: FakeBrowserService) -> TestClient:
app = web.Application()
app[BROWSER_SERVICE_APP_KEY] = service
register_web_console_routes(app)
client = TestClient(TestServer(app))
await client.start_server()
return client
@pytest.mark.asyncio
async def test_browser_status_connect_and_disconnect_routes(self):
service = FakeBrowserService()
client = await self._make_client(service)
try:
status_resp = await client.get("/api/gui/browser/status")
assert status_resp.status == 200
status_payload = await status_resp.json()
assert status_payload["ok"] is True
assert status_payload["browser"]["mode"] == "local"
connect_resp = await client.post("/api/gui/browser/connect", json={"cdp_url": "http://localhost:9222"})
assert connect_resp.status == 200
connect_payload = await connect_resp.json()
assert connect_payload["ok"] is True
assert connect_payload["browser"]["connected"] is True
assert service.connect_calls == ["http://localhost:9222"]
disconnect_resp = await client.post("/api/gui/browser/disconnect")
assert disconnect_resp.status == 200
disconnect_payload = await disconnect_resp.json()
assert disconnect_payload["ok"] is True
assert disconnect_payload["browser"]["connected"] is False
assert service.disconnect_calls == 1
finally:
await client.close()
@pytest.mark.asyncio
async def test_browser_connect_validation(self):
service = FakeBrowserService()
client = await self._make_client(service)
try:
invalid_json_resp = await client.post(
"/api/gui/browser/connect",
data="not json",
headers={"Content-Type": "application/json"},
)
assert invalid_json_resp.status == 400
assert (await invalid_json_resp.json())["error"]["code"] == "invalid_json"
invalid_cdp_resp = await client.post("/api/gui/browser/connect", json={"cdp_url": "bad"})
assert invalid_cdp_resp.status == 400
assert (await invalid_cdp_resp.json())["error"]["code"] == "invalid_cdp_url"
finally:
await client.close()

View File

@@ -0,0 +1,524 @@
"""Tests for the web console chat service, chat API routes, and run tracking."""
from __future__ import annotations
import asyncio
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
import gateway.web_console.api.chat as chat_api
from gateway.web_console.api.chat import CHAT_SERVICE_APP_KEY
from gateway.web_console.routes import register_web_console_routes
from gateway.web_console.services.chat_service import ChatService
from gateway.web_console.state import MAX_TRACKED_RUNS, create_web_console_state
class TestChatService:
@pytest.mark.asyncio
async def test_default_runtime_runner_passes_gui_callback_into_agent_path(self, monkeypatch):
import gateway.run as gateway_run
import run_agent
state = create_web_console_state()
queue = await state.event_bus.subscribe("session-default")
class FakeAgent:
def __init__(self, *args, **kwargs):
self.gui_event_callback = kwargs.get("gui_event_callback")
self.session_id = kwargs.get("session_id")
assert self.gui_event_callback is not None
def run_conversation(self, user_message, conversation_history=None, task_id=None):
self.gui_event_callback(
"tool.started",
{
"tool_name": "search_files",
"preview": "search_files(pattern=*.py)",
"tool_args": {"pattern": "*.py"},
},
)
self.gui_event_callback(
"tool.completed",
{
"tool_name": "search_files",
"duration": 0.01,
"result_preview": "found 2 files",
},
)
return {
"final_response": f"Handled: {user_message}",
"completed": True,
"messages": [
{"role": "assistant", "content": f"Handled: {user_message}"},
],
}
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda: "hermes-agent")
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {})
monkeypatch.setattr(run_agent, "AIAgent", FakeAgent)
service = ChatService(state=state)
result = await service.run_chat(prompt="Hello from default runner", session_id="session-default")
assert result["final_response"] == "Handled: Hello from default runner"
events = [await queue.get() for _ in range(6)]
assert [event.type for event in events] == [
"run.started",
"message.user",
"tool.started",
"tool.completed",
"message.assistant.completed",
"run.completed",
]
@pytest.mark.asyncio
async def test_default_runtime_runner_wires_human_service_callbacks(self, monkeypatch):
import importlib
import gateway.run as gateway_run
import run_agent
from gateway.web_console.services.approval_service import ApprovalService
terminal_tool = importlib.import_module("tools.terminal_tool")
captured: dict[str, object] = {}
class FakeAgent:
def __init__(self, *args, **kwargs):
captured["clarify_callback"] = kwargs.get("clarify_callback")
captured["gui_event_callback"] = kwargs.get("gui_event_callback")
def run_conversation(self, user_message, conversation_history=None, task_id=None):
return {
"final_response": f"Handled: {user_message}",
"completed": True,
"messages": [{"role": "assistant", "content": f"Handled: {user_message}"}],
}
approvals = ApprovalService()
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda: "hermes-agent")
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {})
monkeypatch.setattr(run_agent, "AIAgent", FakeAgent)
previous_approval_callback = terminal_tool._approval_callback
service = ChatService(state=create_web_console_state(), human_service=approvals)
result = await service.run_chat(prompt="Needs humans", session_id="session-human")
assert result["final_response"] == "Handled: Needs humans"
assert captured["clarify_callback"] is not None
assert captured["gui_event_callback"] is not None
assert terminal_tool._approval_callback is previous_approval_callback
@pytest.mark.asyncio
async def test_run_chat_publishes_expected_event_sequence(self):
state = create_web_console_state()
queue = await state.event_bus.subscribe("session-1")
def runtime_runner(**kwargs):
gui_event_callback = kwargs["gui_event_callback"]
gui_event_callback(
"tool.started",
{
"tool_name": "search_files",
"tool_args": {"pattern": "*.py"},
"preview": "search_files(pattern=*.py)",
},
)
gui_event_callback(
"tool.completed",
{
"tool_name": "search_files",
"duration": 0.01,
"result_preview": "found 3 files",
},
)
return {
"final_response": "Done.",
"completed": True,
"messages": [
{"role": "user", "content": kwargs["prompt"]},
{"role": "assistant", "content": "Done."},
],
}
service = ChatService(state=state, runtime_runner=runtime_runner)
result = await service.run_chat(prompt="Find Python files", session_id="session-1")
assert result["final_response"] == "Done."
events = [await queue.get() for _ in range(6)]
assert [event.type for event in events] == [
"run.started",
"message.user",
"tool.started",
"tool.completed",
"message.assistant.completed",
"run.completed",
]
assert events[0].payload == {"prompt": "Find Python files"}
assert events[1].payload == {"content": "Find Python files"}
assert events[2].payload["tool_name"] == "search_files"
assert events[3].payload["tool_name"] == "search_files"
assert events[4].payload == {"content": "Done."}
assert events[5].payload["final_response"] == "Done."
@pytest.mark.asyncio
async def test_run_chat_publishes_failed_event_for_failed_result(self):
state = create_web_console_state()
queue = await state.event_bus.subscribe("session-failed-result")
def runtime_runner(**kwargs):
return {
"failed": True,
"error": "toolchain aborted",
}
service = ChatService(state=state, runtime_runner=runtime_runner)
result = await service.run_chat(prompt="Run something", session_id="session-failed-result")
assert result["failed"] is True
events = [await queue.get() for _ in range(3)]
assert [event.type for event in events] == [
"run.started",
"message.user",
"run.failed",
]
assert events[-1].payload["error"] == "toolchain aborted"
@pytest.mark.asyncio
async def test_run_chat_publishes_failed_event_on_exception(self):
state = create_web_console_state()
queue = await state.event_bus.subscribe("session-2")
def runtime_runner(**kwargs):
gui_event_callback = kwargs["gui_event_callback"]
gui_event_callback(
"tool.started",
{
"tool_name": "terminal",
"preview": "terminal(command=false)",
},
)
raise RuntimeError("boom")
service = ChatService(state=state, runtime_runner=runtime_runner)
with pytest.raises(RuntimeError, match="boom"):
await service.run_chat(prompt="Run something", session_id="session-2")
events = [await queue.get() for _ in range(4)]
assert [event.type for event in events] == [
"run.started",
"message.user",
"tool.started",
"run.failed",
]
assert events[-1].payload["error"] == "boom"
assert events[-1].payload["error_type"] == "RuntimeError"
class TestChatApiRoutes:
@staticmethod
async def _make_client(service: ChatService) -> TestClient:
app = web.Application()
app[CHAT_SERVICE_APP_KEY] = service
register_web_console_routes(app)
client = TestClient(TestServer(app))
await client.start_server()
return client
@pytest.mark.asyncio
async def test_send_and_get_run_return_tracked_metadata(self):
state = create_web_console_state()
async def runtime_runner(**kwargs):
await asyncio.sleep(0.01)
return {
"final_response": f"Echo: {kwargs['prompt']}",
"completed": True,
"messages": [{"role": "assistant", "content": f"Echo: {kwargs['prompt']}"}],
}
service = ChatService(state=state, runtime_runner=runtime_runner)
client = await self._make_client(service)
try:
send_resp = await client.post(
"/api/gui/chat/send",
json={"session_id": "session-api", "prompt": "Hello API"},
)
assert send_resp.status == 200
send_payload = await send_resp.json()
assert send_payload["ok"] is True
assert send_payload["session_id"] == "session-api"
assert send_payload["status"] == "started"
run_id = send_payload["run_id"]
run_resp = await client.get(f"/api/gui/chat/run/{run_id}")
assert run_resp.status == 200
run_payload = await run_resp.json()
assert run_payload["ok"] is True
assert run_payload["run"]["run_id"] == run_id
assert run_payload["run"]["session_id"] == "session-api"
assert run_payload["run"]["status"] in {"started", "completed"}
await asyncio.sleep(0.03)
completed_resp = await client.get(f"/api/gui/chat/run/{run_id}")
completed_payload = await completed_resp.json()
assert completed_payload["run"]["status"] == "completed"
assert completed_payload["run"]["final_response"] == "Echo: Hello API"
finally:
await client.close()
@pytest.mark.asyncio
async def test_retry_creates_new_run_from_existing_metadata(self):
state = create_web_console_state()
seen_prompts: list[str] = []
async def runtime_runner(**kwargs):
seen_prompts.append(kwargs["prompt"])
return {
"final_response": f"Handled: {kwargs['prompt']}",
"completed": True,
"messages": [{"role": "assistant", "content": f"Handled: {kwargs['prompt']}"}],
}
service = ChatService(state=state, runtime_runner=runtime_runner)
client = await self._make_client(service)
try:
send_resp = await client.post(
"/api/gui/chat/send",
json={"session_id": "session-retry", "prompt": "Retry me"},
)
first_payload = await send_resp.json()
first_run_id = first_payload["run_id"]
await asyncio.sleep(0.01)
retry_resp = await client.post("/api/gui/chat/retry", json={"run_id": first_run_id})
assert retry_resp.status == 200
retry_payload = await retry_resp.json()
assert retry_payload["ok"] is True
assert retry_payload["session_id"] == "session-retry"
assert retry_payload["retried_from_run_id"] == first_run_id
assert retry_payload["run_id"] != first_run_id
assert retry_payload["status"] == "started"
await asyncio.sleep(0.01)
retried_run_resp = await client.get(f"/api/gui/chat/run/{retry_payload['run_id']}")
retried_run_payload = await retried_run_resp.json()
assert retried_run_payload["run"]["source_run_id"] == first_run_id
assert retried_run_payload["run"]["prompt"] == "Retry me"
assert seen_prompts == ["Retry me", "Retry me"]
finally:
await client.close()
@pytest.mark.asyncio
async def test_invalid_session_id_and_undo_fields_return_validation_errors(self):
state = create_web_console_state()
service = ChatService(
state=state,
runtime_runner=lambda **kwargs: {
"final_response": "ok",
"completed": True,
"messages": [{"role": "assistant", "content": "ok"}],
},
)
client = await self._make_client(service)
try:
send_resp = await client.post("/api/gui/chat/send", json={"prompt": "hello", "session_id": 123})
assert send_resp.status == 400
send_payload = await send_resp.json()
assert send_payload == {
"ok": False,
"error": {
"code": "invalid_session_id",
"message": "The 'session_id' field must be a non-empty string when provided.",
},
}
undo_resp = await client.post("/api/gui/chat/undo", json={"session_id": 123, "run_id": []})
assert undo_resp.status == 400
undo_payload = await undo_resp.json()
assert undo_payload == {
"ok": False,
"error": {
"code": "invalid_session_id",
"message": "The 'session_id' field must be a string when provided.",
},
}
finally:
await client.close()
@pytest.mark.asyncio
async def test_invalid_json_requests_return_structured_errors(self):
state = create_web_console_state()
service = ChatService(
state=state,
runtime_runner=lambda **kwargs: {
"final_response": "ok",
"completed": True,
"messages": [{"role": "assistant", "content": "ok"}],
},
)
client = await self._make_client(service)
try:
for path in (
"/api/gui/chat/send",
"/api/gui/chat/stop",
"/api/gui/chat/retry",
"/api/gui/chat/undo",
):
resp = await client.post(path, data="not json", headers={"Content-Type": "application/json"})
assert resp.status == 400
payload = await resp.json()
assert payload == {
"ok": False,
"error": {
"code": "invalid_json",
"message": "Request body must be a valid JSON object.",
},
}
finally:
await client.close()
@pytest.mark.asyncio
async def test_run_tracking_is_bounded(self):
state = create_web_console_state()
for idx in range(MAX_TRACKED_RUNS):
state.record_run(
f"run-{idx}",
{"run_id": f"run-{idx}", "prompt": f"prompt-{idx}", "status": "completed"},
)
state.record_run(
"active-run",
{"run_id": "active-run", "prompt": "active", "status": "started"},
)
assert len(state.runs) == MAX_TRACKED_RUNS
assert state.get_run("run-0") is None
assert state.get_run("active-run")["prompt"] == "active"
@pytest.mark.asyncio
async def test_run_tracking_remains_bounded_even_when_all_runs_are_active(self):
state = create_web_console_state()
for idx in range(MAX_TRACKED_RUNS + 3):
state.record_run(
f"active-{idx}",
{"run_id": f"active-{idx}", "prompt": f"prompt-{idx}", "status": "started"},
)
assert len(state.runs) == MAX_TRACKED_RUNS
assert state.get_run("active-0") is None
assert state.get_run(f"active-{MAX_TRACKED_RUNS + 2}")["prompt"] == f"prompt-{MAX_TRACKED_RUNS + 2}"
@pytest.mark.asyncio
async def test_app_without_injected_chat_service_creates_isolated_local_state(self, monkeypatch):
class FakeChatService(ChatService):
async def run_chat(self, *, prompt, session_id, conversation_history=None, ephemeral_system_prompt=None, run_id=None, runtime_context=None):
return {
"final_response": f"Echo: {prompt}",
"completed": True,
"messages": [{"role": "assistant", "content": f"Echo: {prompt}"}],
}
monkeypatch.setattr(chat_api, "ChatService", FakeChatService)
app_one = web.Application()
register_web_console_routes(app_one)
app_two = web.Application()
register_web_console_routes(app_two)
client_one = TestClient(TestServer(app_one))
client_two = TestClient(TestServer(app_two))
await client_one.start_server()
await client_two.start_server()
try:
send_resp = await client_one.post(
"/api/gui/chat/send",
json={"session_id": "isolated-session", "prompt": "hello from app one"},
)
assert send_resp.status == 200
send_payload = await send_resp.json()
run_id = send_payload["run_id"]
await asyncio.sleep(0.02)
run_resp = await client_one.get(f"/api/gui/chat/run/{run_id}")
assert run_resp.status == 200
missing_resp = await client_two.get(f"/api/gui/chat/run/{run_id}")
assert missing_resp.status == 404
finally:
await client_one.close()
await client_two.close()
@pytest.mark.asyncio
async def test_stop_undo_and_not_found_run_responses_are_structured(self):
state = create_web_console_state()
service = ChatService(
state=state,
runtime_runner=lambda **kwargs: {
"final_response": "ok",
"completed": True,
"messages": [{"role": "assistant", "content": "ok"}],
},
)
client = await self._make_client(service)
try:
send_resp = await client.post(
"/api/gui/chat/send",
json={"session_id": "session-stop", "prompt": "Stop test"},
)
send_payload = await send_resp.json()
run_id = send_payload["run_id"]
stop_resp = await client.post("/api/gui/chat/stop", json={"run_id": run_id})
assert stop_resp.status == 200
stop_payload = await stop_resp.json()
assert stop_payload["ok"] is True
assert stop_payload["supported"] is False
assert stop_payload["action"] == "stop"
assert stop_payload["run_id"] == run_id
assert stop_payload["session_id"] == "session-stop"
assert stop_payload["status"] in {"started", "completed"}
assert stop_payload["stop_requested"] is False
undo_resp = await client.post(
"/api/gui/chat/undo",
json={"session_id": "session-stop", "run_id": run_id},
)
assert undo_resp.status == 200
undo_payload = await undo_resp.json()
assert undo_payload == {
"ok": True,
"supported": False,
"action": "undo",
"session_id": "session-stop",
"run_id": run_id,
"status": "unavailable",
}
missing_resp = await client.get("/api/gui/chat/run/does-not-exist")
assert missing_resp.status == 404
missing_payload = await missing_resp.json()
assert missing_payload == {
"ok": False,
"error": {
"code": "run_not_found",
"message": "No tracked run was found for the provided run_id.",
"run_id": "does-not-exist",
},
}
finally:
await client.close()

View File

@@ -0,0 +1,47 @@
"""Tests for the web console commands API."""
from __future__ import annotations
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from gateway.web_console.routes import register_web_console_routes
class TestCommandsApi:
@staticmethod
async def _make_client() -> TestClient:
app = web.Application()
register_web_console_routes(app)
client = TestClient(TestServer(app))
await client.start_server()
return client
@pytest.mark.asyncio
async def test_commands_registry_route_exposes_cli_registry(self):
client = await self._make_client()
try:
resp = await client.get("/api/gui/commands")
assert resp.status == 200
payload = await resp.json()
assert payload["ok"] is True
assert isinstance(payload["commands"], list)
assert payload["commands"]
by_name = {entry["name"]: entry for entry in payload["commands"]}
assert "new" in by_name
assert "model" in by_name
assert "commands" in by_name
new_entry = by_name["new"]
assert "reset" in new_entry["aliases"]
assert "reset" in new_entry["names"]
assert new_entry["cli_only"] is False
commands_entry = by_name["commands"]
assert commands_entry["gateway_only"] is True
assert commands_entry["args_hint"] == "[page]"
finally:
await client.close()

View File

@@ -0,0 +1,316 @@
"""Tests for the web console cron API and cron service."""
from __future__ import annotations
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
import cron.jobs as cron_jobs
from gateway.web_console.api.cron import CRON_SERVICE_APP_KEY
from gateway.web_console.routes import register_web_console_routes
from gateway.web_console.services.cron_service import CronService
class FakeCronService:
def __init__(self) -> None:
self.jobs = {
"job-1": {
"job_id": "job-1",
"id": "job-1",
"name": "Morning report",
"prompt": "Summarize overnight alerts",
"skills": ["blogwatcher"],
"skill": "blogwatcher",
"schedule": {"kind": "interval", "minutes": 60, "display": "every 60m"},
"schedule_display": "every 60m",
"repeat": {"times": None, "completed": 2},
"deliver": "local",
"enabled": True,
"state": "scheduled",
"next_run_at": "2026-03-30T10:00:00+00:00",
"last_run_at": "2026-03-30T09:00:00+00:00",
"last_status": "ok",
"last_error": None,
"paused_at": None,
"paused_reason": None,
}
}
self.history = {
"job-1": {
"job_id": "job-1",
"count": 1,
"latest_run_at": "2026-03-30T09:00:00+00:00",
"latest_status": "ok",
"latest_error": None,
"history": [
{
"run_id": "2026-03-30_09-00-00",
"job_id": "job-1",
"filename": "2026-03-30_09-00-00.md",
"output_file": "/tmp/job-1/2026-03-30_09-00-00.md",
"created_at": "2026-03-30T09:00:00+00:00",
"size_bytes": 12,
"output_preview": "all good",
"output_truncated": False,
"output_available": True,
"source": "output_file",
}
],
}
}
def list_jobs(self, *, include_disabled: bool = True):
jobs = list(self.jobs.values())
if not include_disabled:
jobs = [job for job in jobs if job.get("enabled", True)]
return {"jobs": jobs, "count": len(jobs), "include_disabled": include_disabled}
def get_job(self, job_id: str):
return self.jobs.get(job_id)
def create_job(self, payload):
if not payload.get("schedule"):
raise ValueError("The 'schedule' field must be a non-empty string.")
if payload.get("schedule") == "bad":
raise ValueError("Invalid schedule")
job = {
"job_id": "job-2",
"id": "job-2",
"name": payload.get("name") or "Created job",
"prompt": payload.get("prompt", ""),
"skills": payload.get("skills") or [],
"skill": (payload.get("skills") or [None])[0],
"schedule": {"kind": "interval", "minutes": 30, "display": payload["schedule"]},
"schedule_display": payload["schedule"],
"repeat": {"times": payload.get("repeat"), "completed": 0},
"deliver": payload.get("deliver") or "local",
"enabled": True,
"state": "scheduled",
"next_run_at": "2026-03-30T11:00:00+00:00",
"last_run_at": None,
"last_status": None,
"last_error": None,
"paused_at": None,
"paused_reason": None,
}
self.jobs[job["job_id"]] = job
self.history[job["job_id"]] = {
"job_id": job["job_id"],
"count": 0,
"latest_run_at": None,
"latest_status": None,
"latest_error": None,
"history": [],
}
return job
def update_job(self, job_id: str, payload):
job = self.jobs.get(job_id)
if job is None:
return None
if payload.get("schedule") == "bad":
raise ValueError("Invalid schedule")
if "name" in payload:
job["name"] = payload["name"]
if "prompt" in payload:
job["prompt"] = payload["prompt"]
if "schedule" in payload:
job["schedule_display"] = payload["schedule"]
return job
def run_job(self, job_id: str):
job = self.jobs.get(job_id)
if job is None:
return None
job["next_run_at"] = "2026-03-30T09:30:00+00:00"
return job
def pause_job(self, job_id: str, *, reason: str | None = None):
job = self.jobs.get(job_id)
if job is None:
return None
job["enabled"] = False
job["state"] = "paused"
job["paused_reason"] = reason
return job
def resume_job(self, job_id: str):
job = self.jobs.get(job_id)
if job is None:
return None
job["enabled"] = True
job["state"] = "scheduled"
job["paused_reason"] = None
return job
def delete_job(self, job_id: str):
return self.jobs.pop(job_id, None) is not None
def get_job_history(self, job_id: str, *, limit: int = 20):
payload = self.history.get(job_id)
if payload is None:
return None
copied = dict(payload)
copied["history"] = copied["history"][:limit]
copied["count"] = len(copied["history"])
return copied
class TestCronApi:
@staticmethod
async def _make_client(service: FakeCronService) -> TestClient:
app = web.Application()
app[CRON_SERVICE_APP_KEY] = service
register_web_console_routes(app)
client = TestClient(TestServer(app))
await client.start_server()
return client
@pytest.mark.asyncio
async def test_cron_job_crud_and_history_routes(self):
client = await self._make_client(FakeCronService())
try:
list_resp = await client.get("/api/gui/cron/jobs")
assert list_resp.status == 200
list_payload = await list_resp.json()
assert list_payload["ok"] is True
assert list_payload["count"] == 1
assert list_payload["jobs"][0]["job_id"] == "job-1"
create_resp = await client.post(
"/api/gui/cron/jobs",
json={
"name": "Lunch report",
"prompt": "Summarize the morning",
"schedule": "every 30m",
"deliver": "local",
"skills": ["blogwatcher"],
},
)
assert create_resp.status == 200
create_payload = await create_resp.json()
assert create_payload["ok"] is True
assert create_payload["job"]["job_id"] == "job-2"
assert create_payload["job"]["name"] == "Lunch report"
detail_resp = await client.get("/api/gui/cron/jobs/job-1")
assert detail_resp.status == 200
detail_payload = await detail_resp.json()
assert detail_payload["job"]["schedule_display"] == "every 60m"
assert detail_payload["job"]["last_status"] == "ok"
patch_resp = await client.patch(
"/api/gui/cron/jobs/job-1",
json={"name": "Updated report", "prompt": "Use new instructions", "schedule": "every 2h"},
)
assert patch_resp.status == 200
patch_payload = await patch_resp.json()
assert patch_payload["job"]["name"] == "Updated report"
assert patch_payload["job"]["prompt"] == "Use new instructions"
assert patch_payload["job"]["schedule_display"] == "every 2h"
run_resp = await client.post("/api/gui/cron/jobs/job-1/run")
assert run_resp.status == 200
run_payload = await run_resp.json()
assert run_payload["queued"] is True
assert run_payload["job"]["next_run_at"] == "2026-03-30T09:30:00+00:00"
pause_resp = await client.post("/api/gui/cron/jobs/job-1/pause", json={"reason": "maintenance"})
assert pause_resp.status == 200
pause_payload = await pause_resp.json()
assert pause_payload["job"]["state"] == "paused"
assert pause_payload["job"]["paused_reason"] == "maintenance"
resume_resp = await client.post("/api/gui/cron/jobs/job-1/resume")
assert resume_resp.status == 200
resume_payload = await resume_resp.json()
assert resume_payload["job"]["state"] == "scheduled"
assert resume_payload["job"]["enabled"] is True
history_resp = await client.get("/api/gui/cron/jobs/job-1/history?limit=1")
assert history_resp.status == 200
history_payload = await history_resp.json()
assert history_payload["ok"] is True
assert history_payload["job_id"] == "job-1"
assert history_payload["count"] == 1
assert history_payload["history"][0]["output_preview"] == "all good"
delete_resp = await client.delete("/api/gui/cron/jobs/job-2")
assert delete_resp.status == 200
delete_payload = await delete_resp.json()
assert delete_payload == {"ok": True, "job_id": "job-2", "deleted": True}
finally:
await client.close()
@pytest.mark.asyncio
async def test_cron_api_structured_errors(self):
client = await self._make_client(FakeCronService())
try:
invalid_json_resp = await client.post(
"/api/gui/cron/jobs",
data="not json",
headers={"Content-Type": "application/json"},
)
assert invalid_json_resp.status == 400
invalid_json_payload = await invalid_json_resp.json()
assert invalid_json_payload["error"]["code"] == "invalid_json"
invalid_job_resp = await client.post("/api/gui/cron/jobs", json={"prompt": "Hello"})
assert invalid_job_resp.status == 400
invalid_job_payload = await invalid_job_resp.json()
assert invalid_job_payload["error"]["code"] == "invalid_job"
bad_update_resp = await client.patch("/api/gui/cron/jobs/job-1", json={"schedule": "bad"})
assert bad_update_resp.status == 400
bad_update_payload = await bad_update_resp.json()
assert bad_update_payload["error"]["code"] == "invalid_job"
missing_resp = await client.get("/api/gui/cron/jobs/missing")
assert missing_resp.status == 404
missing_payload = await missing_resp.json()
assert missing_payload["error"]["code"] == "job_not_found"
missing_delete_resp = await client.delete("/api/gui/cron/jobs/missing")
assert missing_delete_resp.status == 404
missing_delete_payload = await missing_delete_resp.json()
assert missing_delete_payload["error"]["code"] == "job_not_found"
invalid_limit_resp = await client.get("/api/gui/cron/jobs/job-1/history?limit=abc")
assert invalid_limit_resp.status == 400
invalid_limit_payload = await invalid_limit_resp.json()
assert invalid_limit_payload["error"]["code"] == "invalid_pagination"
finally:
await client.close()
class TestCronService:
def test_real_cron_service_uses_cron_storage_and_output_history(self, tmp_path, monkeypatch):
monkeypatch.setattr(cron_jobs, "CRON_DIR", tmp_path / "cron")
monkeypatch.setattr(cron_jobs, "JOBS_FILE", tmp_path / "cron" / "jobs.json")
monkeypatch.setattr(cron_jobs, "OUTPUT_DIR", tmp_path / "cron" / "output")
service = CronService()
created = service.create_job({"prompt": "Summarize logs", "schedule": "every 1h", "name": "Ops summary"})
cron_jobs.save_job_output(created["job_id"], "First output line\nSecond output line")
cron_jobs.mark_job_run(created["job_id"], success=True)
cron_jobs.save_job_output(created["job_id"], "Newest output")
jobs_payload = service.list_jobs()
assert jobs_payload["count"] == 1
assert jobs_payload["jobs"][0]["name"] == "Ops summary"
job_detail = service.get_job(created["job_id"])
assert job_detail is not None
assert job_detail["schedule_display"] == "every 60m"
assert job_detail["last_status"] == "ok"
history = service.get_job_history(created["job_id"], limit=10)
assert history is not None
assert history["job_id"] == created["job_id"]
assert history["count"] == 1 or history["count"] == 2
assert history["history"][0]["output_available"] is True
assert history["history"][0]["filename"].endswith(".md")
assert history["history"][0]["source"] == "output_file"
assert history["history"][0]["output_preview"]

View File

@@ -0,0 +1,139 @@
"""Tests for the Hermes Web Console event bus and SSE helpers."""
from __future__ import annotations
import asyncio
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from gateway.web_console.event_bus import GuiEvent, GuiEventBus
from gateway.web_console.sse import SseMessage, format_sse_message, format_sse_ping, stream_sse
class TestGuiEventBus:
@pytest.mark.asyncio
async def test_subscribe_publish_preserves_order(self):
bus = GuiEventBus()
queue = await bus.subscribe("session-1")
first = GuiEvent(type="run.started", session_id="session-1", run_id="run-1", payload={"step": 1})
second = GuiEvent(type="run.output", session_id="session-1", run_id="run-1", payload={"step": 2})
third = GuiEvent(type="run.finished", session_id="session-1", run_id="run-1", payload={"step": 3})
await bus.publish("session-1", first)
await bus.publish("session-1", second)
await bus.publish("session-1", third)
assert await queue.get() is first
assert await queue.get() is second
assert await queue.get() is third
@pytest.mark.asyncio
async def test_unsubscribe_disconnect_stops_delivery(self):
bus = GuiEventBus()
queue = await bus.subscribe("session-1")
await bus.unsubscribe("session-1", queue)
await bus.publish(
"session-1",
GuiEvent(type="run.output", session_id="session-1", run_id="run-1", payload={"text": "hello"}),
)
assert await bus.subscriber_count("session-1") == 0
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(queue.get(), timeout=0.05)
@pytest.mark.asyncio
async def test_concurrent_publish_calls_preserve_order(self):
bus = GuiEventBus()
queue = await bus.subscribe("session-1")
events = [
GuiEvent(type="run.output", session_id="session-1", run_id="run-1", payload={"index": idx})
for idx in range(10)
]
await asyncio.gather(*(bus.publish("session-1", event) for event in events))
received = [await queue.get() for _ in events]
assert [event.payload["index"] for event in received] == list(range(10))
@pytest.mark.asyncio
async def test_channels_are_separate(self):
bus = GuiEventBus()
queue_a = await bus.subscribe("session-a")
queue_b = await bus.subscribe("session-b")
event_a = GuiEvent(type="run.output", session_id="session-a", run_id="run-a", payload={"msg": "a"})
event_b = GuiEvent(type="run.output", session_id="session-b", run_id="run-b", payload={"msg": "b"})
await bus.publish("session-a", event_a)
await bus.publish("session-b", event_b)
assert await queue_a.get() is event_a
assert await queue_b.get() is event_b
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(queue_a.get(), timeout=0.05)
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(queue_b.get(), timeout=0.05)
class TestSseHelpers:
def test_format_sse_message_includes_event_and_json_payload(self):
payload = {"session_id": "session-1", "value": 7}
encoded = format_sse_message(
SseMessage(data=payload, event="gui.event", id="evt-1", retry=5000)
)
assert encoded == (
b"event: gui.event\n"
b"id: evt-1\n"
b"retry: 5000\n"
b'data: {"session_id":"session-1","value":7}\n\n'
)
def test_format_sse_ping_emits_comment_frame(self):
assert format_sse_ping() == b": ping\n\n"
assert format_sse_ping("keepalive") == b": keepalive\n\n"
@pytest.mark.asyncio
async def test_stream_sse_writes_event_frames_and_headers(self):
async def event_source():
yield SseMessage(data={"hello": "world"}, event="gui.event", id="evt-1")
async def handler(request: web.Request) -> web.StreamResponse:
return await stream_sse(request, event_source(), keepalive_interval=0.1)
app = web.Application()
app.router.add_get("/stream", handler)
async with TestClient(TestServer(app)) as cli:
resp = await cli.get("/stream")
assert resp.status == 200
assert resp.headers["Content-Type"].startswith("text/event-stream")
text = await resp.text()
assert "event: gui.event" in text
assert "id: evt-1" in text
assert 'data: {"hello":"world"}' in text
@pytest.mark.asyncio
async def test_stream_sse_emits_keepalive_ping_before_next_event(self):
async def event_source():
await asyncio.sleep(0.02)
yield SseMessage(data={"step": 1}, event="gui.event")
async def handler(request: web.Request) -> web.StreamResponse:
return await stream_sse(request, event_source(), keepalive_interval=0.005, ping_comment="keepalive")
app = web.Application()
app.router.add_get("/stream", handler)
async with TestClient(TestServer(app)) as cli:
resp = await cli.get("/stream")
text = await resp.text()
assert ": keepalive" in text
assert 'data: {"step":1}' in text

View File

@@ -0,0 +1,224 @@
"""Tests for gateway admin web-console API routes."""
from __future__ import annotations
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from gateway.web_console.api.gateway_admin import GATEWAY_SERVICE_APP_KEY
from gateway.web_console.routes import register_web_console_routes
class FakeGatewayService:
def __init__(self) -> None:
self.approve_calls: list[tuple[str, str]] = []
self.revoke_calls: list[tuple[str, str]] = []
def get_overview(self) -> dict:
return {
"gateway": {"running": True, "pid": 4242, "state": "running", "exit_reason": None, "updated_at": "2026-03-30T21:48:00Z"},
"summary": {
"platform_count": 2,
"enabled_platforms": 1,
"configured_platforms": 1,
"connected_platforms": 1,
"pending_pairings": 1,
"approved_pairings": 2,
},
}
def get_platforms(self) -> list[dict]:
return [
{
"key": "discord",
"label": "Discord",
"enabled": True,
"configured": True,
"runtime_state": "connected",
"error_code": None,
"error_message": None,
"updated_at": "2026-03-30T21:49:00Z",
"home_channel": {"platform": "discord", "chat_id": "chan-1", "name": "Home"},
"auth": {"mode": "pairing", "allow_all": False, "allowlist_count": 0, "pairing_behavior": "pair"},
"pairing": {"pending_count": 1, "approved_count": 2},
"extra": {},
}
]
def get_pairing_state(self) -> dict:
return {
"pending": [
{"platform": "discord", "code": "ABC12345", "user_id": "u-1", "user_name": "Ada", "age_minutes": 3}
],
"approved": [
{"platform": "discord", "user_id": "u-2", "user_name": "Grace", "approved_at": 123.0}
],
"summary": {
"pending_count": 1,
"approved_count": 1,
"platforms_with_pending": ["discord"],
"platforms_with_approved": ["discord"],
},
}
def approve_pairing(self, *, platform: str, code: str) -> dict | None:
self.approve_calls.append((platform, code))
if platform == "discord" and code == "abc12345":
return {
"platform": "discord",
"code": "ABC12345",
"user": {"user_id": "u-1", "user_name": "Ada"},
}
return None
def revoke_pairing(self, *, platform: str, user_id: str) -> bool:
self.revoke_calls.append((platform, user_id))
return platform == "discord" and user_id == "u-2"
class FakeRunner:
def __init__(self) -> None:
self.restart_calls: list[tuple[bool, bool]] = []
def request_restart(self, *, detached: bool = False, via_service: bool = False) -> bool:
self.restart_calls.append((detached, via_service))
return True
class RestartCapableAdapter:
class _Handler:
def __init__(self, runner: FakeRunner) -> None:
self.__self__ = runner
def __init__(self, runner: FakeRunner) -> None:
self._message_handler = self._Handler(runner)
class TestGatewayAdminApiRoutes:
@staticmethod
async def _make_client(service: FakeGatewayService) -> TestClient:
app = web.Application()
app[GATEWAY_SERVICE_APP_KEY] = service
register_web_console_routes(app)
client = TestClient(TestServer(app))
await client.start_server()
return client
@pytest.mark.asyncio
async def test_overview_platforms_and_pairing_routes(self):
service = FakeGatewayService()
client = await self._make_client(service)
try:
overview_resp = await client.get("/api/gui/gateway/overview")
assert overview_resp.status == 200
overview_payload = await overview_resp.json()
assert overview_payload["ok"] is True
assert overview_payload["overview"]["gateway"]["pid"] == 4242
assert overview_payload["overview"]["summary"]["pending_pairings"] == 1
platforms_resp = await client.get("/api/gui/gateway/platforms")
assert platforms_resp.status == 200
platforms_payload = await platforms_resp.json()
assert platforms_payload["ok"] is True
assert platforms_payload["platforms"][0]["key"] == "discord"
assert platforms_payload["platforms"][0]["auth"]["mode"] == "pairing"
pairing_resp = await client.get("/api/gui/gateway/pairing")
assert pairing_resp.status == 200
pairing_payload = await pairing_resp.json()
assert pairing_payload["ok"] is True
assert pairing_payload["pairing"]["pending"][0]["code"] == "ABC12345"
assert pairing_payload["pairing"]["summary"]["approved_count"] == 1
finally:
await client.close()
@pytest.mark.asyncio
async def test_pairing_approve_and_revoke_routes(self):
service = FakeGatewayService()
client = await self._make_client(service)
try:
approve_resp = await client.post(
"/api/gui/gateway/pairing/approve",
json={"platform": "discord", "code": "abc12345"},
)
assert approve_resp.status == 200
approve_payload = await approve_resp.json()
assert approve_payload["ok"] is True
assert approve_payload["pairing"]["code"] == "ABC12345"
assert approve_payload["pairing"]["user"]["user_id"] == "u-1"
assert service.approve_calls == [("discord", "abc12345")]
revoke_resp = await client.post(
"/api/gui/gateway/pairing/revoke",
json={"platform": "discord", "user_id": "u-2"},
)
assert revoke_resp.status == 200
revoke_payload = await revoke_resp.json()
assert revoke_payload["ok"] is True
assert revoke_payload["pairing"]["revoked"] is True
assert revoke_payload["pairing"]["user_id"] == "u-2"
assert service.revoke_calls == [("discord", "u-2")]
finally:
await client.close()
@pytest.mark.asyncio
async def test_pairing_routes_return_structured_validation_and_not_found_errors(self):
service = FakeGatewayService()
client = await self._make_client(service)
try:
invalid_json_resp = await client.post(
"/api/gui/gateway/pairing/approve",
data="not json",
headers={"Content-Type": "application/json"},
)
assert invalid_json_resp.status == 400
invalid_json_payload = await invalid_json_resp.json()
assert invalid_json_payload["ok"] is False
assert invalid_json_payload["error"]["code"] == "invalid_json"
invalid_platform_resp = await client.post(
"/api/gui/gateway/pairing/approve",
json={"platform": "", "code": "abc12345"},
)
assert invalid_platform_resp.status == 400
invalid_platform_payload = await invalid_platform_resp.json()
assert invalid_platform_payload["error"]["code"] == "invalid_platform"
approve_missing_resp = await client.post(
"/api/gui/gateway/pairing/approve",
json={"platform": "discord", "code": "missing"},
)
assert approve_missing_resp.status == 404
approve_missing_payload = await approve_missing_resp.json()
assert approve_missing_payload["error"]["code"] == "pairing_not_found"
assert approve_missing_payload["error"]["code"] != ""
revoke_missing_resp = await client.post(
"/api/gui/gateway/pairing/revoke",
json={"platform": "discord", "user_id": "missing"},
)
assert revoke_missing_resp.status == 404
revoke_missing_payload = await revoke_missing_resp.json()
assert revoke_missing_payload["error"]["code"] == "paired_user_not_found"
finally:
await client.close()
@pytest.mark.asyncio
async def test_restart_route_requests_gateway_restart_when_runner_is_available(self):
service = FakeGatewayService()
runner = FakeRunner()
app = web.Application()
app[GATEWAY_SERVICE_APP_KEY] = service
register_web_console_routes(app, adapter=RestartCapableAdapter(runner))
client = TestClient(TestServer(app))
await client.start_server()
try:
resp = await client.post("/api/gui/gateway/restart", json={})
assert resp.status == 200
payload = await resp.json()
assert payload["ok"] is True
assert payload["accepted"] is True
assert runner.restart_calls == [(True, False)]
finally:
await client.close()

View File

@@ -0,0 +1,64 @@
"""Tests for the web console logs API."""
from __future__ import annotations
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from gateway.web_console.api.logs import LOG_SERVICE_APP_KEY
from gateway.web_console.routes import register_web_console_routes
class FakeLogService:
def get_logs(self, *, file_name=None, limit=200):
if file_name == "missing.log":
raise FileNotFoundError(file_name)
return {
"directory": "/tmp/hermes/logs",
"file": file_name or "gateway.log",
"available_files": ["gateway.log", "gateway.error.log"],
"line_count": min(2, limit),
"lines": ["line 1", "line 2"][:limit],
}
class TestLogsApi:
@staticmethod
async def _make_client(service: FakeLogService) -> TestClient:
app = web.Application()
app[LOG_SERVICE_APP_KEY] = service
register_web_console_routes(app)
client = TestClient(TestServer(app))
await client.start_server()
return client
@pytest.mark.asyncio
async def test_get_logs_route(self):
client = await self._make_client(FakeLogService())
try:
resp = await client.get("/api/gui/logs?file=gateway.error.log&limit=1")
assert resp.status == 200
payload = await resp.json()
assert payload["ok"] is True
assert payload["logs"]["file"] == "gateway.error.log"
assert payload["logs"]["line_count"] == 1
assert payload["logs"]["lines"] == ["line 1"]
finally:
await client.close()
@pytest.mark.asyncio
async def test_get_logs_route_validation_and_not_found(self):
client = await self._make_client(FakeLogService())
try:
invalid_limit_resp = await client.get("/api/gui/logs?limit=abc")
assert invalid_limit_resp.status == 400
assert (await invalid_limit_resp.json())["error"]["code"] == "invalid_limit"
missing_resp = await client.get("/api/gui/logs?file=missing.log")
assert missing_resp.status == 404
missing_payload = await missing_resp.json()
assert missing_payload["error"]["code"] == "log_not_found"
assert missing_payload["error"]["file"] == "missing.log"
finally:
await client.close()

View File

@@ -0,0 +1,281 @@
"""Tests for the web console memory and session-search APIs."""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from gateway.web_console.api.memory import MEMORY_SERVICE_APP_KEY
from gateway.web_console.routes import register_web_console_routes
from gateway.web_console.services import memory_service as memory_service_module
from gateway.web_console.services.memory_service import MemoryService
class FakeMemoryService:
def __init__(self) -> None:
self.payloads = {
"memory": {
"target": "memory",
"enabled": True,
"entries": ["Project prefers pytest."],
"entry_count": 1,
"usage": {"text": "1% — 22/2200 chars", "percent": 1, "current_chars": 22, "char_limit": 2200},
"path": "/tmp/MEMORY.md",
},
"user": {
"target": "user",
"enabled": True,
"entries": ["User likes concise answers."],
"entry_count": 1,
"usage": {"text": "2% — 30/1375 chars", "percent": 2, "current_chars": 30, "char_limit": 1375},
"path": "/tmp/USER.md",
},
}
self.search_payload = {
"success": True,
"query": "deploy OR docker",
"results": [{"session_id": "sess-1", "summary": "We fixed the deploy issue.", "source": "cli", "when": "today", "model": "hermes"}],
"count": 1,
"sessions_searched": 1,
}
def get_memory(self, *, target="memory"):
if target not in self.payloads:
raise ValueError("bad target")
return self.payloads[target]
def mutate_memory(self, *, action, target="memory", content=None, old_text=None):
if target == "disabled":
raise PermissionError("Local memory is disabled in config.")
if target not in self.payloads:
raise ValueError("bad target")
if content == "fail" or old_text == "missing":
return {
**self.payloads[target],
"success": False,
"error": "No entry matched 'missing'.",
"matches": ["candidate one"],
}
payload = dict(self.payloads[target])
payload["success"] = True
payload["message"] = f"{action} ok"
if action == "add" and content:
payload["entries"] = payload["entries"] + [content]
payload["entry_count"] = len(payload["entries"])
return payload
def search_sessions(self, *, query, role_filter=None, limit=3, current_session_id=None):
if query == "explode":
raise RuntimeError("boom")
if query == "offline":
return {"success": False, "error": "Session database not available."}
payload = dict(self.search_payload)
payload["query"] = query
payload["role_filter"] = role_filter
payload["limit"] = limit
payload["current_session_id"] = current_session_id
return payload
class TestMemoryApi:
@staticmethod
async def _make_client(service: FakeMemoryService) -> TestClient:
app = web.Application()
app[MEMORY_SERVICE_APP_KEY] = service
register_web_console_routes(app)
client = TestClient(TestServer(app))
await client.start_server()
return client
@pytest.mark.asyncio
async def test_memory_routes_return_structured_payloads(self):
client = await self._make_client(FakeMemoryService())
try:
memory_resp = await client.get("/api/gui/memory")
assert memory_resp.status == 200
memory_payload = await memory_resp.json()
assert memory_payload["ok"] is True
assert memory_payload["memory"]["target"] == "memory"
assert memory_payload["memory"]["entries"] == ["Project prefers pytest."]
profile_resp = await client.get("/api/gui/user-profile")
assert profile_resp.status == 200
profile_payload = await profile_resp.json()
assert profile_payload["ok"] is True
assert profile_payload["user_profile"]["target"] == "user"
assert profile_payload["user_profile"]["entries"] == ["User likes concise answers."]
add_resp = await client.post("/api/gui/memory", json={"target": "user", "content": "Prefers dark mode."})
assert add_resp.status == 200
add_payload = await add_resp.json()
assert add_payload["ok"] is True
assert add_payload["memory"]["target"] == "user"
assert add_payload["memory"]["message"] == "add ok"
replace_resp = await client.patch(
"/api/gui/memory",
json={"target": "memory", "old_text": "pytest", "content": "Project prefers pytest -q."},
)
assert replace_resp.status == 200
replace_payload = await replace_resp.json()
assert replace_payload["memory"]["message"] == "replace ok"
delete_resp = await client.delete("/api/gui/memory", json={"target": "memory", "old_text": "pytest"})
assert delete_resp.status == 200
delete_payload = await delete_resp.json()
assert delete_payload["memory"]["message"] == "remove ok"
finally:
await client.close()
@pytest.mark.asyncio
async def test_session_search_route_and_structured_errors(self):
client = await self._make_client(FakeMemoryService())
try:
search_resp = await client.get(
"/api/gui/session-search?query=deploy%20OR%20docker&role_filter=user,assistant&limit=2&current_session_id=sess-live"
)
assert search_resp.status == 200
search_payload = await search_resp.json()
assert search_payload["ok"] is True
assert search_payload["search"]["query"] == "deploy OR docker"
assert search_payload["search"]["count"] == 1
assert search_payload["search"]["role_filter"] == "user,assistant"
assert search_payload["search"]["limit"] == 2
assert search_payload["search"]["current_session_id"] == "sess-live"
missing_query_resp = await client.get("/api/gui/session-search")
assert missing_query_resp.status == 400
assert (await missing_query_resp.json())["error"]["code"] == "missing_query"
invalid_limit_resp = await client.get("/api/gui/session-search?query=deploy&limit=0")
assert invalid_limit_resp.status == 400
assert (await invalid_limit_resp.json())["error"]["code"] == "invalid_search"
unavailable_resp = await client.get("/api/gui/session-search?query=offline")
assert unavailable_resp.status == 503
assert (await unavailable_resp.json())["error"]["code"] == "search_failed"
failed_resp = await client.get("/api/gui/session-search?query=explode")
assert failed_resp.status == 500
assert (await failed_resp.json())["error"]["code"] == "search_failed"
finally:
await client.close()
@pytest.mark.asyncio
async def test_memory_routes_validate_payloads(self):
client = await self._make_client(FakeMemoryService())
try:
invalid_json_resp = await client.post(
"/api/gui/memory",
data="not json",
headers={"Content-Type": "application/json"},
)
assert invalid_json_resp.status == 400
assert (await invalid_json_resp.json())["error"]["code"] == "invalid_json"
failed_update_resp = await client.patch(
"/api/gui/memory",
json={"target": "memory", "old_text": "missing", "content": "fail"},
)
assert failed_update_resp.status == 400
failed_update_payload = await failed_update_resp.json()
assert failed_update_payload["error"]["code"] == "memory_update_failed"
assert failed_update_payload["error"]["matches"] == ["candidate one"]
finally:
await client.close()
class TestMemoryService:
def test_memory_service_formats_store_and_search_payloads(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
class FakeStore:
def __init__(self):
self.memory_entries = []
self.user_entries = []
self.memory_char_limit = 2200
self.user_char_limit = 1375
@staticmethod
def _path_for(target):
return tmp_path / ("USER.md" if target == "user" else "MEMORY.md")
def _char_count(self, target):
entries = self.user_entries if target == "user" else self.memory_entries
return len("\n§\n".join(entries)) if entries else 0
def _char_limit(self, target):
return self.user_char_limit if target == "user" else self.memory_char_limit
def add(self, target, content):
entries = self.user_entries if target == "user" else self.memory_entries
entries.append(content)
return {"success": True, "message": "Entry added."}
def replace(self, target, old_text, content):
entries = self.user_entries if target == "user" else self.memory_entries
for index, entry in enumerate(entries):
if old_text in entry:
entries[index] = content
return {"success": True, "message": "Entry replaced."}
return {"success": False, "error": f"No entry matched '{old_text}'."}
def remove(self, target, old_text):
entries = self.user_entries if target == "user" else self.memory_entries
for index, entry in enumerate(entries):
if old_text in entry:
entries.pop(index)
return {"success": True, "message": "Entry removed."}
return {"success": False, "error": f"No entry matched '{old_text}'."}
monkeypatch.setattr(
memory_service_module,
"load_config",
lambda: {
"memory": {
"memory_enabled": True,
"user_profile_enabled": True,
"memory_char_limit": 2200,
"user_char_limit": 1375,
}
},
)
monkeypatch.setattr(
memory_service_module,
"session_search",
lambda **kwargs: json.dumps(
{
"success": True,
"query": kwargs["query"],
"results": [{"session_id": "sess-real", "summary": "Found prior discussion."}],
"count": 1,
"sessions_searched": 1,
}
),
)
service = MemoryService(store=FakeStore(), db=object())
initial = service.get_memory(target="memory")
assert initial["entries"] == []
assert initial["enabled"] is True
assert initial["usage"]["char_limit"] == 2200
added = service.mutate_memory(action="add", target="memory", content="Remember the deploy flag.")
assert added["success"] is True
assert added["entries"] == ["Remember the deploy flag."]
assert added["path"].endswith("MEMORY.md")
profile = service.mutate_memory(action="add", target="user", content="User prefers terse updates.")
assert profile["success"] is True
assert profile["entries"] == ["User prefers terse updates."]
assert profile["usage"]["char_limit"] == 1375
replaced = service.mutate_memory(action="replace", target="memory", old_text="deploy", content="Remember the deploy flag loudly.")
assert replaced["entries"] == ["Remember the deploy flag loudly."]
search_payload = service.search_sessions(query="deploy", limit=2)
assert search_payload["success"] is True
assert search_payload["results"][0]["session_id"] == "sess-real"

View File

@@ -0,0 +1,258 @@
"""Tests for the web console sessions API."""
from __future__ import annotations
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from hermes_state import SessionDB
from gateway.web_console.api.sessions import SESSIONS_SERVICE_APP_KEY
from gateway.web_console.routes import register_web_console_routes
from gateway.web_console.services.session_service import SessionService
class FakeSessionService:
def __init__(self):
self.sessions = {
"sess-1": {
"session_id": "sess-1",
"title": "Session One",
"last_active": 123.0,
"source": "cli",
"workspace": None,
"model": "hermes-agent",
"token_summary": {"input": 1, "output": 2, "total": 3, "cache_read": 0, "cache_write": 0, "reasoning": 0},
"parent_session_id": None,
"has_tools": True,
"has_attachments": False,
"preview": "hello",
"message_count": 2,
"started_at": 100.0,
"ended_at": None,
"end_reason": None,
"system_prompt": "system",
"metadata": {
"user_id": "user-1",
"model_config": {"temperature": 0.2},
"billing_provider": None,
"billing_base_url": None,
"billing_mode": None,
"estimated_cost_usd": None,
"actual_cost_usd": None,
"cost_status": None,
"cost_source": None,
"pricing_version": None,
},
"recap": {"message_count": 2, "preview": "hello", "last_role": "assistant"},
}
}
self.transcripts = {
"sess-1": {
"session_id": "sess-1",
"items": [
{"id": 1, "type": "user_message", "role": "user", "content": "hello", "timestamp": 100.0},
{"id": 2, "type": "assistant_message", "role": "assistant", "content": "hi", "timestamp": 101.0},
],
}
}
def list_sessions(self, *, source=None, limit=20, offset=0):
items = list(self.sessions.values())
if source:
items = [item for item in items if item["source"] == source]
return items[offset:offset + limit]
def get_session_detail(self, session_id):
return self.sessions.get(session_id)
def get_transcript(self, session_id):
return self.transcripts.get(session_id)
def set_title(self, session_id, title):
session = self.sessions.get(session_id)
if session is None:
return None
if title == "bad title":
raise ValueError("Title is invalid")
session["title"] = title
return {"session_id": session_id, "title": title}
def resume_session(self, session_id):
session = self.sessions.get(session_id)
if session is None:
return None
return {
"session_id": session_id,
"status": "resumed",
"resume_supported": True,
"title": session["title"],
"conversation_history": [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
],
"session": session,
}
def delete_session(self, session_id):
return self.sessions.pop(session_id, None) is not None
class TestSessionsApi:
@staticmethod
async def _make_client(service: FakeSessionService) -> TestClient:
app = web.Application()
app[SESSIONS_SERVICE_APP_KEY] = service
register_web_console_routes(app)
client = TestClient(TestServer(app))
await client.start_server()
return client
@pytest.mark.asyncio
async def test_list_and_get_session_routes(self):
client = await self._make_client(FakeSessionService())
try:
list_resp = await client.get("/api/gui/sessions")
assert list_resp.status == 200
list_payload = await list_resp.json()
assert list_payload["ok"] is True
session_summary = list_payload["sessions"][0]
assert session_summary["session_id"] == "sess-1"
assert session_summary["title"] == "Session One"
assert session_summary["last_active"] == 123.0
assert session_summary["source"] == "cli"
assert session_summary["workspace"] is None
assert session_summary["model"] == "hermes-agent"
assert session_summary["token_summary"]["total"] == 3
assert session_summary["parent_session_id"] is None
assert session_summary["has_tools"] is True
assert session_summary["has_attachments"] is False
detail_resp = await client.get("/api/gui/sessions/sess-1")
assert detail_resp.status == 200
detail_payload = await detail_resp.json()
assert detail_payload["ok"] is True
assert detail_payload["session"]["title"] == "Session One"
assert detail_payload["session"]["recap"]["last_role"] == "assistant"
assert detail_payload["session"]["metadata"]["user_id"] == "user-1"
assert detail_payload["session"]["metadata"]["model_config"]["temperature"] == 0.2
finally:
await client.close()
@pytest.mark.asyncio
async def test_get_transcript_route(self):
client = await self._make_client(FakeSessionService())
try:
resp = await client.get("/api/gui/sessions/sess-1/transcript")
assert resp.status == 200
payload = await resp.json()
assert payload["ok"] is True
assert payload["session_id"] == "sess-1"
assert len(payload["items"]) == 2
assert payload["items"][0]["type"] == "user_message"
finally:
await client.close()
@pytest.mark.asyncio
async def test_title_resume_and_delete_routes(self):
client = await self._make_client(FakeSessionService())
try:
title_resp = await client.post("/api/gui/sessions/sess-1/title", json={"title": "Renamed"})
assert title_resp.status == 200
title_payload = await title_resp.json()
assert title_payload == {"ok": True, "session_id": "sess-1", "title": "Renamed"}
resume_resp = await client.post("/api/gui/sessions/sess-1/resume")
assert resume_resp.status == 200
resume_payload = await resume_resp.json()
assert resume_payload["ok"] is True
assert resume_payload["status"] == "resumed"
assert resume_payload["resume_supported"] is True
assert resume_payload["conversation_history"][0]["role"] == "user"
assert resume_payload["session"]["session_id"] == "sess-1"
delete_resp = await client.delete("/api/gui/sessions/sess-1")
assert delete_resp.status == 200
delete_payload = await delete_resp.json()
assert delete_payload == {"ok": True, "session_id": "sess-1", "deleted": True}
finally:
await client.close()
@pytest.mark.asyncio
async def test_invalid_pagination_returns_structured_error(self):
client = await self._make_client(FakeSessionService())
try:
resp = await client.get("/api/gui/sessions?limit=abc")
assert resp.status == 400
payload = await resp.json()
assert payload == {
"ok": False,
"error": {
"code": "invalid_pagination",
"message": "The 'limit' field must be an integer.",
},
}
finally:
await client.close()
@pytest.mark.asyncio
async def test_real_session_service_uses_sessiondb_storage(self, tmp_path):
db = SessionDB(tmp_path / "state.db")
db.create_session(
session_id="sess-real",
source="cli",
model="hermes-agent",
model_config={"temperature": 0.4},
system_prompt="system prompt",
user_id="user-real",
parent_session_id=None,
)
db.append_message("sess-real", "user", content="hello")
db.append_message("sess-real", "assistant", content="hi there")
db.set_session_title("sess-real", "Real Session")
db.update_token_counts("sess-real", input_tokens=5, output_tokens=7, model="hermes-agent")
service = SessionService(db=db)
sessions = service.list_sessions()
assert sessions[0]["session_id"] == "sess-real"
assert sessions[0]["token_summary"]["total"] == 12
detail = service.get_session_detail("sess-real")
assert detail["title"] == "Real Session"
assert detail["metadata"]["user_id"] == "user-real"
assert detail["metadata"]["model_config"]["temperature"] == 0.4
transcript = service.get_transcript("sess-real")
assert transcript["items"][0]["role"] == "user"
assert transcript["items"][1]["role"] == "assistant"
resumed = service.resume_session("sess-real")
assert resumed["status"] == "resumed"
assert resumed["conversation_history"][0]["role"] == "user"
db.close()
@pytest.mark.asyncio
async def test_missing_and_invalid_requests_are_structured(self):
client = await self._make_client(FakeSessionService())
try:
missing_resp = await client.get("/api/gui/sessions/missing")
assert missing_resp.status == 404
missing_payload = await missing_resp.json()
assert missing_payload["error"]["code"] == "session_not_found"
invalid_title_resp = await client.post(
"/api/gui/sessions/sess-1/title",
data="not json",
headers={"Content-Type": "application/json"},
)
assert invalid_title_resp.status == 400
invalid_title_payload = await invalid_title_resp.json()
assert invalid_title_payload["error"]["code"] == "invalid_json"
bad_title_resp = await client.post("/api/gui/sessions/sess-1/title", json={"title": "bad title"})
assert bad_title_resp.status == 400
bad_title_payload = await bad_title_resp.json()
assert bad_title_payload["error"]["code"] == "invalid_title"
finally:
await client.close()

View File

@@ -0,0 +1,108 @@
"""Tests for the web console settings and auth-status APIs."""
from __future__ import annotations
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from gateway.web_console.api.settings import SETTINGS_SERVICE_APP_KEY
from gateway.web_console.routes import register_web_console_routes
class FakeSettingsService:
def __init__(self) -> None:
self.settings = {
"model": "anthropic/claude-opus-4.1",
"auxiliary": {"vision": {"api_key": "***", "base_url": "https://example.test/v1"}},
"browser": {"record_sessions": False},
}
self.auth = {
"active_provider": "nous",
"resolved_provider": "nous",
"logged_in": True,
"active_status": {"logged_in": True, "has_refresh_token": True},
"providers": [
{
"provider": "nous",
"name": "Nous Portal",
"auth_type": "oauth_device_code",
"active": True,
"status": {"logged_in": True, "has_refresh_token": True},
}
],
}
self.update_calls: list[dict] = []
def get_settings(self) -> dict:
return self.settings
def update_settings(self, patch: dict) -> dict:
self.update_calls.append(patch)
if patch.get("bad"):
raise ValueError("patch rejected")
self.settings = {
**self.settings,
**patch,
}
return self.settings
def get_auth_status(self) -> dict:
return self.auth
class TestSettingsApi:
@staticmethod
async def _make_client(service: FakeSettingsService) -> TestClient:
app = web.Application()
app[SETTINGS_SERVICE_APP_KEY] = service
register_web_console_routes(app)
client = TestClient(TestServer(app))
await client.start_server()
return client
@pytest.mark.asyncio
async def test_get_settings_and_auth_status_routes(self):
client = await self._make_client(FakeSettingsService())
try:
settings_resp = await client.get("/api/gui/settings")
assert settings_resp.status == 200
settings_payload = await settings_resp.json()
assert settings_payload["ok"] is True
assert settings_payload["settings"]["model"] == "anthropic/claude-opus-4.1"
assert settings_payload["settings"]["auxiliary"]["vision"]["api_key"] == "***"
auth_resp = await client.get("/api/gui/auth-status")
assert auth_resp.status == 200
auth_payload = await auth_resp.json()
assert auth_payload["ok"] is True
assert auth_payload["auth"]["active_provider"] == "nous"
assert auth_payload["auth"]["providers"][0]["status"]["logged_in"] is True
finally:
await client.close()
@pytest.mark.asyncio
async def test_patch_settings_route_and_validation(self):
service = FakeSettingsService()
client = await self._make_client(service)
try:
patch_resp = await client.patch("/api/gui/settings", json={"browser": {"record_sessions": True}})
assert patch_resp.status == 200
patch_payload = await patch_resp.json()
assert patch_payload["ok"] is True
assert service.update_calls == [{"browser": {"record_sessions": True}}]
assert patch_payload["settings"]["browser"]["record_sessions"] is True
invalid_json_resp = await client.patch(
"/api/gui/settings",
data="not json",
headers={"Content-Type": "application/json"},
)
assert invalid_json_resp.status == 400
assert (await invalid_json_resp.json())["error"]["code"] == "invalid_json"
invalid_patch_resp = await client.patch("/api/gui/settings", json={"bad": True})
assert invalid_patch_resp.status == 400
assert (await invalid_patch_resp.json())["error"]["code"] == "invalid_patch"
finally:
await client.close()

View File

@@ -0,0 +1,314 @@
"""Tests for the web console skills API and skill service."""
from __future__ import annotations
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from hermes_state import SessionDB
from gateway.web_console.api.skills import SKILLS_SERVICE_APP_KEY
from gateway.web_console.routes import register_web_console_routes
from gateway.web_console.services.skill_service import SkillService
class FakeSkillService:
def __init__(self) -> None:
self.skills = {
"planner": {
"name": "planner",
"description": "Plan complex work.",
"category": "workflow",
"path": "workflow/planner/SKILL.md",
"content": "# Planner",
"source": "official",
"source_type": "hub",
"trust_level": "trusted",
"identifier": "official/workflow/planner",
"install_path": "/tmp/planner",
"scan_verdict": "allow",
"installed_at": "2026-03-30T00:00:00+00:00",
"updated_at": "2026-03-30T00:00:00+00:00",
"installed_metadata": {"author": "Hermes"},
"readiness_status": "available",
"setup_needed": False,
},
"blocked": {
"name": "blocked",
"description": "Needs setup first.",
"category": "workflow",
"path": "workflow/blocked/SKILL.md",
"content": "# Blocked",
"source": "local",
"source_type": "local",
"trust_level": "local",
"identifier": None,
"install_path": None,
"scan_verdict": None,
"installed_at": None,
"updated_at": None,
"installed_metadata": {},
"readiness_status": "setup_needed",
"setup_needed": True,
},
}
self.session_skills = {
"sess-1": {
"planner": {
"name": "planner",
"description": "Plan complex work.",
"path": "workflow/planner/SKILL.md",
"source": "official",
"source_type": "hub",
"trust_level": "trusted",
"readiness_status": "available",
"setup_needed": False,
"loaded_at": "2026-03-30T00:00:00+00:00",
}
}
}
def list_skills(self):
return {
"skills": [self.skills["planner"], self.skills["blocked"]],
"categories": ["workflow"],
"count": 2,
"hint": "Use the detail endpoint for full content.",
}
def get_skill(self, name: str):
if name == "missing":
raise FileNotFoundError(name)
if name == "blocked":
raise ValueError("Skill 'blocked' is disabled.")
return self.skills[name]
def load_skill_for_session(self, session_id: str, name: str):
if session_id == "missing-session":
raise LookupError("session_not_found")
if name == "missing":
raise FileNotFoundError(name)
if name == "blocked":
raise ValueError("Skill 'blocked' is disabled.")
loaded = self.session_skills.setdefault(session_id, {})
already_loaded = name in loaded
skill = loaded.get(name) or {
"name": name,
"description": self.skills[name]["description"],
"path": self.skills[name]["path"],
"source": self.skills[name]["source"],
"source_type": self.skills[name]["source_type"],
"trust_level": self.skills[name]["trust_level"],
"readiness_status": self.skills[name]["readiness_status"],
"setup_needed": self.skills[name]["setup_needed"],
"loaded_at": "2026-03-30T01:00:00+00:00",
}
loaded[name] = skill
return {"session_id": session_id, "skill": skill, "loaded": True, "already_loaded": already_loaded}
def list_session_skills(self, session_id: str):
if session_id == "missing-session":
raise LookupError("session_not_found")
skills = sorted(self.session_skills.get(session_id, {}).values(), key=lambda item: item["name"])
return {"session_id": session_id, "skills": skills, "count": len(skills)}
def unload_skill_for_session(self, session_id: str, name: str):
if session_id == "missing-session":
raise LookupError("session_not_found")
removed = self.session_skills.get(session_id, {}).pop(name, None) is not None
return {"session_id": session_id, "name": name, "removed": removed}
class TestSkillsApi:
@staticmethod
async def _make_client(service: FakeSkillService) -> TestClient:
app = web.Application()
app[SKILLS_SERVICE_APP_KEY] = service
register_web_console_routes(app)
client = TestClient(TestServer(app))
await client.start_server()
return client
@pytest.mark.asyncio
async def test_list_and_get_skill_routes(self):
client = await self._make_client(FakeSkillService())
try:
list_resp = await client.get("/api/gui/skills")
assert list_resp.status == 200
list_payload = await list_resp.json()
assert list_payload["ok"] is True
assert list_payload["count"] == 2
assert list_payload["categories"] == ["workflow"]
assert list_payload["skills"][0]["name"] == "planner"
assert list_payload["skills"][0]["source_type"] == "hub"
assert list_payload["skills"][0]["installed_metadata"] == {"author": "Hermes"}
detail_resp = await client.get("/api/gui/skills/planner")
assert detail_resp.status == 200
detail_payload = await detail_resp.json()
assert detail_payload["ok"] is True
assert detail_payload["skill"]["name"] == "planner"
assert detail_payload["skill"]["content"] == "# Planner"
assert detail_payload["skill"]["trust_level"] == "trusted"
finally:
await client.close()
@pytest.mark.asyncio
async def test_session_skill_load_list_and_unload_routes(self):
client = await self._make_client(FakeSkillService())
try:
load_resp = await client.post("/api/gui/skills/planner/load", json={"session_id": "sess-1"})
assert load_resp.status == 200
load_payload = await load_resp.json()
assert load_payload["ok"] is True
assert load_payload["session_id"] == "sess-1"
assert load_payload["skill"]["name"] == "planner"
assert load_payload["already_loaded"] is True
session_resp = await client.get("/api/gui/skills/session/sess-1")
assert session_resp.status == 200
session_payload = await session_resp.json()
assert session_payload["ok"] is True
assert session_payload["count"] == 1
assert session_payload["skills"][0]["name"] == "planner"
unload_resp = await client.delete("/api/gui/skills/session/sess-1/planner")
assert unload_resp.status == 200
unload_payload = await unload_resp.json()
assert unload_payload == {"ok": True, "session_id": "sess-1", "name": "planner", "removed": True}
session_after_resp = await client.get("/api/gui/skills/session/sess-1")
session_after_payload = await session_after_resp.json()
assert session_after_payload["count"] == 0
assert session_after_payload["skills"] == []
finally:
await client.close()
@pytest.mark.asyncio
async def test_skills_api_returns_structured_errors(self):
client = await self._make_client(FakeSkillService())
try:
invalid_json_resp = await client.post(
"/api/gui/skills/planner/load",
data="not json",
headers={"Content-Type": "application/json"},
)
assert invalid_json_resp.status == 400
invalid_json_payload = await invalid_json_resp.json()
assert invalid_json_payload["error"]["code"] == "invalid_json"
invalid_session_resp = await client.post("/api/gui/skills/planner/load", json={})
assert invalid_session_resp.status == 400
invalid_session_payload = await invalid_session_resp.json()
assert invalid_session_payload["error"]["code"] == "invalid_session_id"
missing_skill_resp = await client.get("/api/gui/skills/missing")
assert missing_skill_resp.status == 404
missing_skill_payload = await missing_skill_resp.json()
assert missing_skill_payload["error"]["code"] == "skill_not_found"
blocked_skill_resp = await client.get("/api/gui/skills/blocked")
assert blocked_skill_resp.status == 400
blocked_skill_payload = await blocked_skill_resp.json()
assert blocked_skill_payload["error"]["code"] == "skill_unavailable"
missing_session_resp = await client.get("/api/gui/skills/session/missing-session")
assert missing_session_resp.status == 404
missing_session_payload = await missing_session_resp.json()
assert missing_session_payload["error"]["code"] == "session_not_found"
finally:
await client.close()
class TestSkillService:
def test_real_service_tracks_loaded_skills_and_metadata(self, tmp_path, monkeypatch):
db = SessionDB(tmp_path / "state.db")
db.create_session(
session_id="sess-real",
source="cli",
model="hermes-agent",
model_config={"temperature": 0.1},
system_prompt="system prompt",
user_id="user-1",
parent_session_id=None,
)
service = SkillService(db=db)
monkeypatch.setattr(
SkillService,
"list_skills",
lambda self: {
"skills": [
{
"name": "planner",
"description": "Plan work.",
"category": "workflow",
"source": "builtin",
"source_type": "builtin",
"trust_level": "builtin",
}
],
"categories": ["workflow"],
"count": 1,
"hint": None,
},
)
monkeypatch.setattr(
SkillService,
"get_skill",
lambda self, name: {
"name": name,
"description": "Plan work.",
"path": "workflow/planner/SKILL.md",
"source": "builtin",
"source_type": "builtin",
"trust_level": "builtin",
"readiness_status": "available",
"setup_needed": False,
},
)
listing = service.list_skills()
assert listing["count"] == 1
assert listing["skills"][0]["name"] == "planner"
loaded = service.load_skill_for_session("sess-real", "planner")
assert loaded["loaded"] is True
assert loaded["already_loaded"] is False
assert loaded["skill"]["source_type"] == "builtin"
loaded_again = service.load_skill_for_session("sess-real", "planner")
assert loaded_again["already_loaded"] is True
session_skills = service.list_session_skills("sess-real")
assert session_skills["count"] == 1
assert session_skills["skills"][0]["name"] == "planner"
removed = service.unload_skill_for_session("sess-real", "planner")
assert removed == {"session_id": "sess-real", "name": "planner", "removed": True}
assert service.list_session_skills("sess-real")["skills"] == []
service.load_skill_for_session("sess-real", "planner")
monkeypatch.setattr(
SkillService,
"get_skill",
lambda self, name: {
"name": "planner",
"description": "Plan work.",
"path": "workflow/planner/SKILL.md",
"source": "builtin",
"source_type": "builtin",
"trust_level": "builtin",
"readiness_status": "available",
"setup_needed": False,
},
)
removed_via_alias = service.unload_skill_for_session("sess-real", "Planner")
assert removed_via_alias == {"session_id": "sess-real", "name": "planner", "removed": True}
assert service.list_session_skills("sess-real")["skills"] == []
with pytest.raises(LookupError):
service.list_session_skills("missing")
db.close()

View File

@@ -0,0 +1,274 @@
"""Tests for the web console workspace and process APIs."""
from __future__ import annotations
from pathlib import Path
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from gateway.web_console.api.workspace import WORKSPACE_SERVICE_APP_KEY
from gateway.web_console.routes import register_web_console_routes
from gateway.web_console.services.workspace_service import WorkspaceService
class FakeWorkspaceService:
def __init__(self) -> None:
self.rollback_requests: list[dict[str, object]] = []
self.killed: list[str] = []
def get_tree(self, *, path=None, depth=2, include_hidden=False):
if path == "missing":
raise FileNotFoundError("missing path")
if path == "bad":
raise ValueError("bad path")
return {
"workspace_root": "/workspace",
"tree": {
"name": "workspace",
"path": path or ".",
"type": "directory",
"children": [{"name": "src", "path": "src", "type": "directory", "children": []}],
"truncated": False,
},
}
def get_file(self, *, path, offset=1, limit=500):
if path == "missing.txt":
raise FileNotFoundError("missing file")
if path == "binary.bin":
raise ValueError("Binary files are not supported by this endpoint.")
return {
"workspace_root": "/workspace",
"file": {
"path": path,
"size": 12,
"line_count": 3,
"offset": offset,
"limit": limit,
"content": "line1\nline2",
"truncated": True,
"is_binary": False,
},
}
def search_workspace(self, *, query, path=None, limit=50, include_hidden=False, regex=False):
if query == "bad":
raise ValueError("bad query")
return {
"workspace_root": "/workspace",
"query": query,
"path": path or ".",
"matches": [{"path": "src/app.py", "line": 3, "content": "needle here"}],
"truncated": False,
"scanned_files": 1,
}
def diff_checkpoint(self, *, checkpoint_id, path=None):
if checkpoint_id == "missing":
raise FileNotFoundError("missing checkpoint")
return {
"workspace_root": "/workspace",
"working_dir": "/workspace",
"checkpoint_id": checkpoint_id,
"stat": "1 file changed",
"diff": "@@ -1 +1 @@",
}
def list_checkpoints(self, *, path=None):
return {
"workspace_root": "/workspace",
"working_dir": "/workspace",
"checkpoints": [{"hash": "abc123", "short_hash": "abc123", "reason": "auto"}],
}
def rollback(self, *, checkpoint_id, path=None, file_path=None):
if checkpoint_id == "missing":
raise FileNotFoundError("missing checkpoint")
payload = {"checkpoint_id": checkpoint_id, "path": path, "file_path": file_path}
self.rollback_requests.append(payload)
return {"success": True, "restored_to": checkpoint_id[:8], "file": file_path}
def list_processes(self):
return {
"processes": [
{
"session_id": "proc_123",
"command": "pytest",
"status": "running",
"pid": 42,
"cwd": "/workspace",
"output_preview": "running",
}
]
}
def get_process_log(self, process_id, *, offset=0, limit=200):
if process_id == "missing":
raise FileNotFoundError("missing process")
return {
"session_id": process_id,
"status": "running",
"output": "a\nb",
"total_lines": 2,
"showing": "2 lines",
}
def kill_process(self, process_id):
if process_id == "missing":
raise FileNotFoundError("missing process")
self.killed.append(process_id)
return {"status": "killed", "session_id": process_id}
class TestWorkspaceApi:
@staticmethod
async def _make_client(service: FakeWorkspaceService) -> TestClient:
app = web.Application()
app[WORKSPACE_SERVICE_APP_KEY] = service
register_web_console_routes(app)
client = TestClient(TestServer(app))
await client.start_server()
return client
@pytest.mark.asyncio
async def test_workspace_endpoints_return_structured_payloads(self):
client = await self._make_client(FakeWorkspaceService())
try:
tree_resp = await client.get("/api/gui/workspace/tree?path=src&depth=1")
assert tree_resp.status == 200
tree_payload = await tree_resp.json()
assert tree_payload["ok"] is True
assert tree_payload["tree"]["path"] == "src"
file_resp = await client.get("/api/gui/workspace/file?path=README.md&offset=2&limit=2")
assert file_resp.status == 200
file_payload = await file_resp.json()
assert file_payload["file"]["path"] == "README.md"
assert file_payload["file"]["offset"] == 2
assert file_payload["file"]["limit"] == 2
search_resp = await client.get("/api/gui/workspace/search?query=needle")
assert search_resp.status == 200
search_payload = await search_resp.json()
assert search_payload["matches"][0]["path"] == "src/app.py"
diff_resp = await client.get("/api/gui/workspace/diff?checkpoint_id=abc123")
assert diff_resp.status == 200
diff_payload = await diff_resp.json()
assert diff_payload["checkpoint_id"] == "abc123"
assert "@@" in diff_payload["diff"]
checkpoints_resp = await client.get("/api/gui/workspace/checkpoints")
assert checkpoints_resp.status == 200
checkpoints_payload = await checkpoints_resp.json()
assert checkpoints_payload["checkpoints"][0]["hash"] == "abc123"
finally:
await client.close()
@pytest.mark.asyncio
async def test_rollback_and_process_endpoints(self):
service = FakeWorkspaceService()
client = await self._make_client(service)
try:
rollback_resp = await client.post(
"/api/gui/workspace/rollback",
json={"checkpoint_id": "abc123", "file_path": "src/app.py"},
)
assert rollback_resp.status == 200
rollback_payload = await rollback_resp.json()
assert rollback_payload["ok"] is True
assert rollback_payload["result"]["restored_to"] == "abc123"
assert service.rollback_requests[0]["file_path"] == "src/app.py"
list_resp = await client.get("/api/gui/processes")
assert list_resp.status == 200
list_payload = await list_resp.json()
assert list_payload["processes"][0]["session_id"] == "proc_123"
log_resp = await client.get("/api/gui/processes/proc_123/log?offset=0&limit=10")
assert log_resp.status == 200
log_payload = await log_resp.json()
assert log_payload["session_id"] == "proc_123"
assert log_payload["total_lines"] == 2
kill_resp = await client.post("/api/gui/processes/proc_123/kill")
assert kill_resp.status == 200
kill_payload = await kill_resp.json()
assert kill_payload["result"]["status"] == "killed"
assert service.killed == ["proc_123"]
finally:
await client.close()
@pytest.mark.asyncio
async def test_workspace_api_returns_structured_errors(self):
client = await self._make_client(FakeWorkspaceService())
try:
missing_path_resp = await client.get("/api/gui/workspace/file")
assert missing_path_resp.status == 400
assert (await missing_path_resp.json())["error"]["code"] == "missing_path"
invalid_depth_resp = await client.get("/api/gui/workspace/tree?depth=abc")
assert invalid_depth_resp.status == 400
assert (await invalid_depth_resp.json())["error"]["code"] == "invalid_path"
missing_checkpoint_resp = await client.get("/api/gui/workspace/diff")
assert missing_checkpoint_resp.status == 400
assert (await missing_checkpoint_resp.json())["error"]["code"] == "missing_checkpoint_id"
invalid_json_resp = await client.post(
"/api/gui/workspace/rollback",
data="not json",
headers={"Content-Type": "application/json"},
)
assert invalid_json_resp.status == 400
assert (await invalid_json_resp.json())["error"]["code"] == "invalid_json"
missing_process_resp = await client.get("/api/gui/processes/missing/log")
assert missing_process_resp.status == 404
missing_process_payload = await missing_process_resp.json()
assert missing_process_payload["error"]["code"] == "process_not_found"
finally:
await client.close()
class TestWorkspaceService:
def test_workspace_service_reads_tree_file_and_searches(self, tmp_path: Path):
class StubCheckpointManager:
def list_checkpoints(self, working_dir):
return []
def get_working_dir_for_path(self, file_path):
return str(tmp_path)
def diff(self, working_dir, commit_hash):
return {"success": False, "error": "unused"}
def restore(self, working_dir, commit_hash, file_path=None):
return {"success": False, "error": "unused"}
class StubProcessRegistry:
def list_sessions(self):
return []
(tmp_path / "src").mkdir()
(tmp_path / "src" / "app.py").write_text("alpha\nneedle value\nomega\n", encoding="utf-8")
(tmp_path / "README.md").write_text("one\ntwo\nthree\n", encoding="utf-8")
service = WorkspaceService(
workspace_root=tmp_path,
checkpoint_manager=StubCheckpointManager(),
process_registry=StubProcessRegistry(),
)
tree = service.get_tree(path="src", depth=1)
assert tree["tree"]["type"] == "directory"
assert tree["tree"]["children"][0]["path"] == "src/app.py"
file_payload = service.get_file(path="README.md", offset=2, limit=2)
assert file_payload["file"]["content"] == "two\nthree"
assert file_payload["file"]["truncated"] is False
search_payload = service.search_workspace(query="needle")
assert search_payload["matches"] == [{"path": "src/app.py", "line": 2, "content": "needle value"}]

27
web_console/CHANGELOG.md Normal file
View File

@@ -0,0 +1,27 @@
# Changelog
All notable changes to the Hermes Web Console will be documented in this file.
## [Unreleased]
### Added
- **Missions Kanban Board**: New `/missions` overarching route providing an intuitive HTML5 drag-and-drop interface for managing agent tasks with Backlog, In Progress, Review, and Done columns.
- **Dashboard Command Center**: Live-polling overarching global interface tracking CPU limits, host memory footprint, active Cron Jobs, and background operations in real-time.
- **CLI Session Bridge**: Sessions viewer now imports and segregates interactions made natively in the CLI vs the Web UI via SQLite reads.
- **Rich Vision Input**: Added glow-visualized drag-and-drop dropzones over the main chat composer to securely facilitate image context streaming.
- **Workspace File @Mentions**: Introduced an elegant native popup autocomplete inside the chat composer. Type `@` to select local workspace files to be injected efficiently into context.
- **Portable Mode (Backend Agnostic)**: The UI now degrades gracefully when the Hermes core backend is down, exposing a red health banner instead of crashing the interface with 500s.
- **PWA Installation**: Fully initialized `manifest.json`, local `<link>` tags, generated icon sets, and an offline-ready `sw.js` Service Worker to run Hermes natively on any Desktop or device.
- **Universal CLI Command Parity**: Added full backend support and chat component slash dispatching for `/fast`, `/yolo`, `/reasoning`, and `/verbose` tracking core CLI parameters seamlessly.
- **Intelligent Autocomplete Registry**: Exposed the global static command registry to the Web UI via `/api/gui/commands` for unified composer auto-completion.
- **Dedicated Command Browser**: Added a new Commands route backed by the shared CLI registry, including usage hints, aliases, and parity badges (`Full`, `Partial`, `CLI only`).
- **Expanded Slash Coverage**: Added practical web-console dispatch for `/queue`, `/branch`, `/resume`, `/save`, `/approve`, `/deny`, `/history`, `/config`, `/platforms`, `/image`, `/paste`, `/restart`, `/update`, and `/sethome`.
- **Weixin (WeChat) Support**: Config Modal handles `token` and `account_id` structures mirroring the new native Gateway integrations.
- **Docker Strategy**: Created standalone `Dockerfile.frontend` and `Dockerfile.backend` setups composed via `docker-compose.yml` to instantly spin up the proxy architectures seamlessly.
### Fixed
- Re-architected Vitest `App.test.tsx` mock server payloads to cleanly yield `commands: []` bypassing fatal `flatMap` undefined array mapping crashes.
- Refined command-browser parity labeling so browser-native approximations like `/config`, `/history`, `/platforms`, `/voice`, `/update`, and `/restart` are marked **Partial** instead of overstating full CLI equivalence.
- Replaced deprecated `apple-mobile-web-app-capable` meta tags natively inside `index.html`.
- Implemented robust `ConnectionProvider` polling states preventing error-log flooding in DevTools when backend connectivity is severed.
- Adjusted CSS spacing within `MissionsPage.tsx` using `overflowX: 'auto'` to ensure the fourth boundary column isn't obscured out of frame relative to the Inspector pane.

View File

@@ -0,0 +1,34 @@
# Stage 1: Build
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Serve
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY --from=build /app/manifest.json /usr/share/nginx/html/manifest.json
COPY --from=build /app/sw.js /usr/share/nginx/html/sw.js
# Nginx config for SPA + reverse proxy to backend
RUN echo 'server { \
listen 80; \
root /usr/share/nginx/html; \
index index.html; \
location /api/ { \
proxy_pass http://hermes-backend:8765; \
proxy_http_version 1.1; \
proxy_set_header Upgrade $http_upgrade; \
proxy_set_header Connection "upgrade"; \
proxy_set_header Host $host; \
proxy_read_timeout 300s; \
} \
location / { \
try_files $uri $uri/ /index.html; \
} \
}' > /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

75
web_console/README.md Normal file
View File

@@ -0,0 +1,75 @@
<p align="center">
<img src="../assets/banner.png" alt="Hermes Agent" width="100%">
</p>
# Hermes Web Console 🖥️✨
A highly polished, modern web dashboard for **Hermes Agent**. The Web Console brings the raw power of the Hermes terminal and its core configurability directly to the browser, offering broad practical parity with the CLI while introducing intuitive drag-and-drop workflows for advanced agentic operations.
## ✨ Features
- **Live Streaming Parity**: Connects directly to the core Hermes API Event Stream (`message.assistant.delta`) to offer responsive typewriter streaming without delays.
- **Agentic IDE Sandbox**: Inspect live runtime logs and tools, and spawn native `xterm.js` terminal environments inside the drawer.
- **Dashboard Command Center**: Real-time observability dashboard streaming CPU, memory, Process, and Cron active metrics directly from the host.
- **CLI Session Bridge**: Seamlessly view and interact with CLI terminal sessions and memory straight from the web console.
- **Offline Portable Mode**: Fallback to local offline mode with graceful degradation when the backend is unreachable.
- **Missions Kanban**: Create, drag-and-drop, and monitor agentic missions on a comprehensive visual board.
- **Workspace Integration**: Mentioning files with `@` directly links to your file explorer context. Rich dropzones power native vision multi-modal interactions.
- **PWA Support**: Full manifest and service worker deployment for native standalone app-like installations across Desktop and Mobile.
- **Visual Configurations**: Avoid editing `config.yaml` manually. Setup complex hierarchies like drag-and-drop ordered **Fallback Providers**, multi-key **Credential Pools**, and isolated Messaging Gateways all from an organized UI.
- **Theme Persistence**: Dark, light, or completely custom skins. Changes are natively synchronized with your overarching Hermes profile.
- **Syntax Highlighting & Inline Diffs**: Unified, collapsible Git-style file diffs directly inside the chat interface letting you confidently review the agent's file modifications.
- **Shared Command Registry**: The composer autocomplete is powered by the same slash-command registry as the Hermes CLI via `/api/gui/commands`.
- **Command Browser**: A dedicated Commands page lets you browse canonical commands, aliases, usage hints, and parity badges (`Full`, `Partial`, `CLI only`).
- **Browser-Native Slash Flows**: The chat interface now supports practical web versions of many CLI commands including `/queue`, `/branch`, `/resume`, `/save`, `/platforms`, `/image`, `/paste`, `/fast`, `/yolo`, `/reasoning`, and `/verbose`.
## 🚀 Quick Start
The console acts as a client connected to the Hermes Local API Server.
Ensure you have your backend running:
```bash
hermes api start
```
### Running the UI (Development)
Navigate to the `web_console` directory:
```bash
cd web_console
npm install
npm run dev
```
Navigate to `http://localhost:5173` locally. Set your backend URL mapping inside the UI Settings if the API server resides on a custom port or remote network.
### Building for Production
Compile the production bundle cleanly:
```bash
npm run build
```
The optimized static assets will populate the `/dist` directory automatically compatible with most static web-farm configurations or native integrations back onto the python API router.
## ⌘ Slash Commands in the Web Console
The web console supports a large subset of Hermes slash commands directly inside chat. Highlights include:
- **Session**: `/new`, `/retry`, `/undo`, `/branch`, `/resume`, `/queue`, `/save`
- **Config**: `/model`, `/provider`, `/reasoning`, `/verbose`, `/fast`, `/yolo`
- **Gateway/Admin**: `/platforms`, `/sethome`, `/restart`, `/update`
- **Attachments**: `/image`, `/paste`
Some commands are intentionally marked **Partial** because browser behavior differs from terminal behavior. For the current source of truth, use the **Commands** page inside the app.
## 🛠️ Tech Stack
- **React.js 18** (Vite Compiler)
- **TypeScript** natively integrated for safe schema bindings.
- **xterm.js** offering completely native ANSI terminal playback.
- **react-markdown** & **PrismJS** for syntax-focused presentation.
---
*Part of the NousResearch / Hermes Agent Ecosystem.*

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 371 KiB

27
web_console/index.html Normal file
View File

@@ -0,0 +1,27 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#818cf8" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="description" content="AI Agent Control Surface — chat, monitor, and manage your Hermes agent." />
<link rel="manifest" href="/manifest.json" />
<title>Hermes Web Console</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}
</script>
</body>
</html>

26
web_console/manifest.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "Hermes Web Console",
"short_name": "Hermes",
"description": "AI Agent Control Surface — chat, monitor, and manage your Hermes agent from any device.",
"start_url": "/",
"display": "standalone",
"background_color": "#0a0a0f",
"theme_color": "#818cf8",
"orientation": "any",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["developer-tools", "productivity"],
"prefer_related_applications": false
}

5328
web_console/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
web_console/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "hermes-web-console",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@types/react-syntax-highlighter": "^15.5.13",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.1",
"recharts": "^3.8.1",
"rehype-highlight": "^7.0.2",
"rehype-katex": "^7.0.1",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^26.0.0",
"typescript": "^5.6.3",
"vite": "^6.2.0",
"vitest": "^3.0.7"
}
}

View File

@@ -0,0 +1,329 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { App } from './App';
import { PRIMARY_NAV_ITEMS } from './router';
class MockEventSource {
url: string;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: (() => void) | null = null;
constructor(url: string) {
this.url = url;
MockEventSource.lastInstance = this;
}
close() {
// no-op
}
static lastInstance: MockEventSource | null = null;
simulateMessage(data: unknown) {
const payload = { data: JSON.stringify(data) } as MessageEvent;
this.onmessage?.(payload);
}
}
describe('App shell', () => {
beforeEach(() => {
global.EventSource = MockEventSource as unknown as typeof EventSource;
MockEventSource.lastInstance = null;
window.location.hash = '';
localStorage.clear();
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.includes('/api/gui/human/pending')) {
return new Response(JSON.stringify({ ok: true, pending: [] }), { status: 200 });
}
if (url.includes('/api/gui/chat/send')) {
return new Response(
JSON.stringify({ ok: true, session_id: 'session-live', run_id: 'run-1', status: 'started' }),
{ status: 200 }
);
}
if (url.includes('/api/gui/user-profile')) {
return new Response(
JSON.stringify({ ok: true, user_profile: { target: 'user', enabled: true, entries: ['Likes dark mode.'], entry_count: 1, usage: { text: '1%', percent: 1, current_chars: 16, char_limit: 1375 }, path: '/tmp/USER.md' } }),
{ status: 200 }
);
}
if (url.includes('/api/gui/memory')) {
return new Response(
JSON.stringify({ ok: true, memory: { target: 'memory', enabled: true, entries: ['Test memory entry.'], entry_count: 1, usage: { text: '1%', percent: 1, current_chars: 18, char_limit: 2200 }, path: '/tmp/MEMORY.md' } }),
{ status: 200 }
);
}
if (url.includes('/api/gui/session-search')) {
return new Response(JSON.stringify({ ok: true, results: [] }), { status: 200 });
}
if (url.includes('/api/gui/skills')) {
return new Response(
JSON.stringify({ ok: true, skills: [{ name: 'writing-plans', description: 'Write plans.', source_type: 'builtin' }] }),
{ status: 200 }
);
}
if (url.includes('/api/gui/cron/jobs')) {
return new Response(
JSON.stringify({ ok: true, jobs: [{ job_id: 'cron-1', name: 'Morning summary', schedule: '0 9 * * *', paused: false }] }),
{ status: 200 }
);
}
if (url.includes('/api/gui/chat/backgrounds')) {
return new Response(
JSON.stringify({ ok: true, background_runs: [{ run_id: 'run-1', session_id: 'sess-1', status: 'running', prompt: 'Analyze log files', created_at: Date.now() / 1000 }] }),
{ status: 200 }
);
}
if (url.includes('/api/gui/workspace/tree')) {
return new Response(
JSON.stringify({
ok: true,
tree: {
name: 'workspace',
path: '.',
type: 'directory',
children: [{ name: 'src/app.py', path: 'src/app.py', type: 'file' }]
}
}),
{ status: 200 }
);
}
if (url.includes('/api/gui/workspace/file')) {
return new Response(JSON.stringify({ ok: true, path: 'src/app.py', content: 'def main():\n return 1\n' }), { status: 200 });
}
if (url.includes('/api/gui/workspace/diff')) {
return new Response(JSON.stringify({ ok: true, diff: '--- a\n+++ b\n@@\n-old\n+new' }), { status: 200 });
}
if (url.includes('/api/gui/workspace/checkpoints')) {
return new Response(JSON.stringify({ ok: true, checkpoints: [{ checkpoint_id: 'cp-1', label: 'before patch' }] }), { status: 200 });
}
if (url.includes('/api/gui/processes')) {
return new Response(JSON.stringify({ ok: true, processes: [{ process_id: 'proc-1', status: 'running' }] }), { status: 200 });
}
if (url.includes('/api/gui/gateway/platforms')) {
return new Response(
JSON.stringify({ ok: true, platforms: [{ key: 'telegram', label: 'Telegram', runtime_state: 'connected', enabled: true, configured: true }] }),
{ status: 200 }
);
}
if (url.includes('/api/gui/gateway/pairing')) {
return new Response(JSON.stringify({ ok: true, pairings: [] }), { status: 200 });
}
if (url.includes('/api/gui/gateway/overview')) {
return new Response(JSON.stringify({ ok: true, overview: { summary: { platform_count: 5, connected_platforms: 2, enabled_platforms: 3 } } }), { status: 200 });
}
if (url.includes('/api/gui/settings')) {
return new Response(
JSON.stringify({ ok: true, settings: { model: 'hermes-agent', provider: 'openai-codex', browser_mode: 'local', tts_provider: 'edge' } }),
{ status: 200 }
);
}
if (url.includes('/api/gui/logs')) {
return new Response(JSON.stringify({ ok: true, lines: ['[info] hello', '[info] world'] }), { status: 200 });
}
if (url.includes('/api/gui/sessions/') && url.endsWith('/transcript')) {
return new Response(
JSON.stringify({ ok: true, items: [{ role: 'user', content: 'hello' }, { role: 'assistant', content: 'hi' }] }),
{ status: 200 }
);
}
if (url.includes('/api/gui/sessions/')) {
return new Response(JSON.stringify({ ok: true, session: { title: 'Session One', recap: { preview: 'Loaded from API' } } }), {
status: 200
});
}
if (url.includes('/api/gui/sessions')) {
return new Response(
JSON.stringify({ ok: true, sessions: [{ session_id: 'sess-1', title: 'Session One', source: 'cli', last_active: 123 }] }),
{ status: 200 }
);
}
if (url.includes('/api/gui/commands')) {
return new Response(JSON.stringify({ ok: true, commands: [
{ name: 'help', description: 'Show available commands', category: 'Info', aliases: [], names: ['help'], args_hint: '', subcommands: [], cli_only: false, gateway_only: false },
{ name: 'model', description: 'Switch model for this session', category: 'Configuration', aliases: [], names: ['model'], args_hint: '[model]', subcommands: [], cli_only: false, gateway_only: false },
{ name: 'queue', description: 'Queue a prompt for the next turn', category: 'Session', aliases: ['q'], names: ['queue', 'q'], args_hint: '<prompt>', subcommands: [], cli_only: false, gateway_only: false }
] }), { status: 200 });
}
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}) as typeof fetch;
});
it('renders the primary navigation items', () => {
render(<App />);
const nav = screen.getByRole('navigation', { name: /Primary navigation/i });
for (const item of PRIMARY_NAV_ITEMS) {
expect(within(nav).getByRole('button', { name: new RegExp(item, 'i') })).toBeInTheDocument();
}
});
it('renders the chat page by default', () => {
render(<App />);
expect(screen.getByLabelText('Transcript')).toBeInTheDocument();
expect(screen.getByLabelText('Composer')).toBeInTheDocument();
});
it('switches route content when navigating to Sessions', async () => {
render(<App />);
const nav = screen.getByRole('navigation', { name: /Primary navigation/i });
fireEvent.click(within(nav).getByRole('button', { name: /Sessions/i }));
expect((await screen.findAllByText('Session One')).length).toBeGreaterThan(0);
});
it('switches route content when navigating to Workspace', async () => {
render(<App />);
const nav = screen.getByRole('navigation', { name: /Primary navigation/i });
fireEvent.click(within(nav).getByRole('button', { name: /Workspace/i }));
expect(await screen.findByLabelText('File tree')).toBeInTheDocument();
expect(screen.getByLabelText('Terminal panel')).toBeInTheDocument();
expect(screen.getByLabelText('Process panel')).toBeInTheDocument();
});
it('switches route content when navigating to Memory, Skills, and Automations', async () => {
render(<App />);
const nav = screen.getByRole('navigation', { name: /Primary navigation/i });
fireEvent.click(within(nav).getByRole('button', { name: /Memory/i }));
expect(await screen.findByText('Test memory entry.')).toBeInTheDocument();
fireEvent.click(within(nav).getByRole('button', { name: /Skills/i }));
const installedTab = await screen.findByRole('button', { name: /Installed & Local/i });
fireEvent.click(installedTab);
expect(await screen.findByText(/writing-plans/i)).toBeInTheDocument();
fireEvent.click(within(nav).getByRole('button', { name: /Background Jobs/i }));
expect(await screen.findByText(/Analyze log files/i)).toBeInTheDocument();
});
it('switches modal tabs when navigating inside Control Center', async () => {
render(<App />);
// Open Control Center first via title
fireEvent.click(screen.getByTitle('Control Center'));
// By default Settings form should be visible
expect(await screen.findByLabelText('Settings form')).toBeInTheDocument();
// Switch to Gateway tab
fireEvent.click(screen.getByRole('button', { name: /Messaging Gateway/i }));
expect(await screen.findByText(/Gateway Platforms/i)).toBeInTheDocument();
// Switch to Automations tab
fireEvent.click(screen.getByRole('button', { name: /Automations/i }));
expect(await screen.findByText(/Morning summary/i)).toBeInTheDocument();
});
it('opens SSE after sending a message and receives streaming events into transcript', async () => {
render(<App />);
// SSE is not created on mount; it opens after a send that returns a real session id.
const prompt = screen.getByPlaceholderText(/Message Hermes.../i);
await act(async () => {
fireEvent.change(prompt, { target: { value: 'Hello from test' } });
fireEvent.submit(screen.getByLabelText('Composer'));
});
// Wait for send to complete (Hermes is thinking indicator appears after POST resolves)
await waitFor(() => {
expect(screen.getByText(/Hermes is thinking/i)).toBeInTheDocument();
});
// SSE should now be open
const es = MockEventSource.lastInstance;
expect(es).not.toBeNull();
expect(es!.url).toContain('/api/gui/stream/session/session-live');
await act(async () => {
es!.simulateMessage({
type: 'tool.started',
session_id: 'session-live',
run_id: 'run-1',
payload: { tool_name: 'search_files', preview: 'search_files(pattern=*.py)' },
ts: Date.now() / 1000
});
});
await waitFor(() => {
expect(screen.getByText(/search_files\(pattern=\*\.py\)/)).toBeInTheDocument();
});
await act(async () => {
es!.simulateMessage({
type: 'message.assistant.completed',
session_id: 'session-live',
run_id: 'run-1',
payload: { content: 'Hermes completed analysis.' },
ts: Date.now() / 1000
});
});
});
it('toggles the inspector and drawer from the top bar', () => {
render(<App />);
const inspector = screen.getByLabelText('Inspector');
const drawer = screen.getByLabelText('Bottom drawer');
fireEvent.click(screen.getByTitle('Inspector Panel'));
expect(inspector.className).toContain('inspector-hidden');
fireEvent.click(screen.getByTitle('Terminal Drawer'));
expect(drawer.className).not.toContain('bottom-drawer-hidden');
});
it('changes inspector tabs', () => {
render(<App />);
fireEvent.click(screen.getByRole('button', { name: 'tools' }));
expect(screen.getByRole('button', { name: 'tools' }).className).toContain('panel-tab-active');
});
it('opens the command palette and prefills slash commands into chat', async () => {
render(<App />);
fireEvent.click(screen.getByTitle(/Command Palette/i));
expect(await screen.findByText(/Command Palette/i)).toBeInTheDocument();
const search = screen.getByPlaceholderText(/Search routes, actions, or commands/i);
fireEvent.change(search, { target: { value: 'model' } });
const runModelButton = screen.getAllByRole('button').find((button) => button.textContent?.includes('Run /model'));
expect(runModelButton).toBeTruthy();
fireEvent.click(runModelButton as HTMLButtonElement);
await waitFor(() => {
expect(screen.getByLabelText('Composer')).toBeInTheDocument();
const textarea = document.querySelector('#chat-prompt') as HTMLTextAreaElement | null;
expect(textarea?.value).toBe('/model ');
});
});
it('can pin actions from the command palette and show them in the pinned section', async () => {
render(<App />);
fireEvent.click(screen.getByTitle(/Command Palette/i));
expect(await screen.findByText(/Command Palette/i)).toBeInTheDocument();
const openUsageLabel = await screen.findByText(/Open Usage/i);
const openUsage = openUsageLabel.closest('[role="button"]') as HTMLElement;
const pinButton = within(openUsage).getByRole('button', { name: /☆ pin/i });
fireEvent.click(pinButton);
fireEvent.click(screen.getAllByRole('button', { name: '✕' })[0]);
fireEvent.click(screen.getByTitle(/Command Palette/i));
expect(await screen.findByText('Pinned')).toBeInTheDocument();
const pinnedHeading = screen.getByText('Pinned');
const pinnedSection = pinnedHeading.parentElement as HTMLElement;
expect(within(pinnedSection).getByText(/Open Usage/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,16 @@
import { AppShell } from '../components/layout/AppShell';
import { ErrorBoundary } from '../components/shared/ErrorBoundary';
import { ConnectionProvider } from '../lib/connectionContext';
import { ConnectGate } from '../components/connect/ConnectScreen';
export function App() {
return (
<ErrorBoundary>
<ConnectionProvider>
<ConnectGate>
<AppShell />
</ConnectGate>
</ConnectionProvider>
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,5 @@
import type { PropsWithChildren } from 'react';
export function AppProviders({ children }: PropsWithChildren) {
return children;
}

View File

@@ -0,0 +1,69 @@
import type { RouteDefinition } from '../lib/types';
export const ROUTES: readonly RouteDefinition[] = [
{
id: 'chat',
label: 'Chat',
description: 'Talk to Hermes and watch tool activity.',
headline: 'Chat with Hermes',
summary: 'Streaming chat, tool events, approvals, and session-aware interaction will live here.'
},
{
id: 'sessions',
label: 'Sessions',
description: 'Browse, inspect, and resume past work.',
headline: 'Session browser',
summary: 'Searchable history, transcript previews, exports, and resume actions belong on this page.'
},
{
id: 'workspace',
label: 'Workspace',
description: 'Inspect files, diffs, and processes.',
headline: 'Workspace operations',
summary: 'File trees, patches, checkpoints, rollback, and process logs will be surfaced here.'
},
{
id: 'usage',
label: 'Usage',
description: 'Analytics, token usage, and costs.',
headline: 'Usage Insights',
summary: 'Track your Hermes API usage.'
},
{
id: 'jobs',
label: 'Background Jobs',
description: 'Track background agents.',
headline: 'Background Jobs',
summary: 'Monitor status of dispatched background agents.'
},
{
id: 'skills',
label: 'Skills',
description: 'Browse, install, and manage skills.',
headline: 'Skills Hub',
summary: 'Enhance your agent with official and community skills or create your own.'
},
{
id: 'memory',
label: 'Memory',
description: 'View and edit long-term agent memory.',
headline: 'Agent Memory',
summary: 'What Hermes remembers about you and your workspace across sessions.'
},
{
id: 'missions',
label: 'Missions',
description: 'Kanban board for agent tasks.',
headline: 'Missions Board',
summary: 'Organize and track agent missions with drag-and-drop Kanban columns.'
},
{
id: 'commands',
label: 'Commands',
description: 'Browse shared Hermes slash commands and parity status.',
headline: 'Command Browser',
summary: 'See the canonical command registry, aliases, usage hints, and which commands are fully or partially supported in the web console.'
}
] as const;
export const PRIMARY_NAV_ITEMS = ROUTES.map((item) => item.label);

View File

@@ -0,0 +1,863 @@
:root {
--bg-dark: #09090b;
--bg-surface: #18181b;
--bg-surface-elevated: rgba(39, 39, 42, 0.6);
--border-light: rgba(255, 255, 255, 0.08);
--border-glow: rgba(129, 140, 248, 0.4);
--accent-primary: #818cf8;
--accent-secondary: #c084fc;
--text-main: #f4f4f5;
--text-muted: #a1a1aa;
--topbar-height: 56px;
color-scheme: dark;
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg-dark);
color: var(--text-main);
}
:root[data-theme="light"] {
--bg-dark: #f8fafc;
--bg-surface: #ffffff;
--bg-surface-elevated: rgba(255, 255, 255, 0.9);
--border-light: rgba(0, 0, 0, 0.1);
--border-glow: rgba(99, 102, 241, 0.3);
--accent-primary: #6366f1;
--accent-secondary: #8b5cf6;
--text-main: #0f172a;
--text-muted: #64748b;
color-scheme: light;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg-dark);
}
button,
textarea,
input,
select {
font: inherit;
}
/* ============================================================
APP SHELL
============================================================ */
.app-shell {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ============================================================
TOPBAR — unified header with brand + nav + actions
============================================================ */
.topbar {
height: var(--topbar-height);
padding: 0 20px;
border-bottom: 1px solid var(--border-light);
background: rgba(9, 9, 11, 0.92);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
position: sticky;
top: 0;
z-index: 50;
flex-shrink: 0;
}
.topbar-brand {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex-shrink: 0;
}
.topbar-logo {
font-size: 1.3rem;
filter: drop-shadow(0 0 6px rgba(129, 140, 248, 0.5));
}
.topbar h1 {
margin: 0;
font-size: 1.1rem;
font-weight: 700;
letter-spacing: -0.02em;
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.topbar-version {
font-size: 0.7rem;
color: #52525b;
font-weight: 500;
letter-spacing: 0.02em;
display: flex;
align-items: center;
gap: 4px;
}
.topbar-update-btn {
background: none;
border: none;
cursor: pointer;
color: inherit;
padding: 0 2px;
font-size: 0.75rem;
transition: opacity 0.2s;
}
.topbar-profile-badge {
padding: 2px 8px;
background: rgba(56, 189, 248, 0.12);
color: #38bdf8;
border-radius: 6px;
font-weight: 600;
font-size: 0.7rem;
letter-spacing: 0.02em;
}
/* Navigation tabs in header */
.topbar-nav {
display: flex;
align-items: center;
gap: 2px;
background: rgba(255, 255, 255, 0.03);
border-radius: 10px;
padding: 3px;
overflow-x: auto;
min-width: 0;
flex: 1;
max-width: 800px;
justify-content: flex-start;
}
.topbar-nav::-webkit-scrollbar {
display: none;
}
.topbar-nav-item {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
font-size: 0.82rem;
font-weight: 500;
transition: all 0.15s ease;
white-space: nowrap;
}
.topbar-nav-item:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text-main);
}
.topbar-nav-item--active {
background: rgba(129, 140, 248, 0.15);
color: var(--accent-primary);
box-shadow: 0 1px 8px rgba(129, 140, 248, 0.1);
}
.topbar-nav-icon {
font-size: 0.9rem;
line-height: 1;
}
.topbar-nav-label {
line-height: 1;
}
/* Action buttons */
.topbar-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.topbar-action-btn {
border: none;
background: transparent;
color: var(--text-muted);
font-size: 1.05rem;
padding: 7px;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.topbar-action-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text-main);
}
/* ============================================================
LAYOUT BODY — content + inspector (no sidebar)
============================================================ */
.layout-body {
display: flex;
flex: 1;
overflow: hidden;
min-height: 0;
}
/* ============================================================
CONTENT AREA
============================================================ */
.content {
padding: 20px;
overflow-y: auto;
min-height: 0;
display: flex;
flex-direction: column;
flex: 1;
}
/* ============================================================
INSPECTOR PANEL
============================================================ */
.inspector {
width: 280px;
flex-shrink: 0;
padding: 16px;
background: rgba(24, 24, 27, 0.5);
border-left: 1px solid var(--border-light);
overflow-y: auto;
min-height: 0;
}
.inspector-hidden {
width: 40px !important;
padding: 16px 4px !important;
overflow: hidden !important;
}
/* ============================================================
BOTTOM DRAWER
============================================================ */
.bottom-drawer {
margin: 0 12px 12px;
padding: 16px;
flex-shrink: 0;
border-radius: 14px;
border: 1px solid var(--border-light);
background: var(--bg-surface-elevated);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
}
.bottom-drawer-hidden {
display: none !important;
}
/* ============================================================
SHARED CARD STYLES
============================================================ */
.hero-card,
.panel-body,
.chat-panel,
.composer-card,
.side-card,
.run-status,
.message-card,
.sessions-card,
.workspace-card,
.feature-card {
border-radius: 14px;
border: 1px solid var(--border-light);
background: var(--bg-surface-elevated);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
.hero-card,
.chat-panel,
.composer-card,
.side-card,
.run-status,
.sessions-card,
.workspace-card,
.feature-card {
padding: 20px;
}
/* ============================================================
PANEL TABS
============================================================ */
.panel-tabs {
list-style: none;
margin: 0;
padding: 0;
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.panel-tab {
padding: 6px 10px;
text-transform: capitalize;
border: 1px solid transparent;
background: transparent;
color: var(--text-muted);
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
font-weight: 500;
font-size: 0.82rem;
}
.panel-tab:hover {
background: rgba(255, 255, 255, 0.05);
color: var(--text-main);
}
.panel-tab-active {
background: rgba(129, 140, 248, 0.15) !important;
border-color: var(--border-glow) !important;
color: var(--accent-primary);
}
.panel-body {
padding: 12px;
}
/* ============================================================
MUTED TEXT
============================================================ */
.topbar p,
.hero-card p,
.panel-body p,
.chat-panel-header p,
.message-card-content,
.side-card p,
.composer-label,
.sessions-card-header p,
.session-row span,
.session-preview-line,
.workspace-card-header p,
.workspace-static-row,
.workspace-pre,
.feature-card-header p,
.feature-list-item span,
.settings-field span {
color: var(--text-muted);
line-height: 1.6;
}
/* ============================================================
ICON BUTTON (generic)
============================================================ */
.icon-button {
border: none;
background: transparent;
color: var(--text-muted);
font-size: 1.1rem;
padding: 7px;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
}
.icon-button:hover {
background: rgba(255, 255, 255, 0.06);
color: var(--text-main);
}
/* ============================================================
NAV / SESSION / WORKSPACE ROW BUTTONS
============================================================ */
.nav-button,
.session-row,
.workspace-row,
.workspace-static-row {
width: 100%;
text-align: left;
padding: 12px 16px;
display: grid;
gap: 4px;
border: 1px solid transparent;
background: transparent;
color: var(--text-muted);
border-radius: 8px;
cursor: pointer;
transition: all 0.15s ease;
font-weight: 500;
}
.session-row:hover,
.workspace-row:hover {
background: var(--bg-surface-elevated);
border-color: rgba(255, 255, 255, 0.12);
}
.workspace-static-row,
.feature-list-item {
border: 1px solid var(--border-light);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
padding: 14px;
transition: all 0.15s ease;
}
.feature-list-item:hover {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.12);
}
.nav-button-active,
.panel-tab-active,
.session-row-active,
.workspace-row-active {
background: rgba(129, 140, 248, 0.15) !important;
border-color: var(--border-glow) !important;
color: var(--accent-primary);
box-shadow: 0 0 12px rgba(129, 140, 248, 0.08);
}
/* Primary action */
.primary-action {
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)) !important;
color: white !important;
border: none !important;
box-shadow: 0 4px 14px rgba(129, 140, 248, 0.2) !important;
}
.primary-action:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
/* ============================================================
STATUS PILL
============================================================ */
.status-pill {
border: 1px solid rgba(129, 140, 248, 0.3);
background: rgba(129, 140, 248, 0.1);
color: var(--accent-primary);
border-radius: 999px;
padding: 4px 12px;
font-size: 0.8rem;
font-weight: 500;
}
.eyebrow {
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--accent-primary);
font-size: 0.8rem;
}
/* ============================================================
CHAT LAYOUT
============================================================ */
.chat-layout {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
max-width: 900px;
margin: 0 auto;
width: 100%;
}
.chat-main-column {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.chat-main-column .chat-panel {
flex: 1;
min-height: 300px;
max-height: 600px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.chat-main-column .chat-panel .transcript-list {
flex: 1;
overflow-y: auto;
padding-right: 4px;
}
.chat-side-column {
display: grid;
gap: 12px;
align-content: start;
}
/* ============================================================
SESSIONS LAYOUT
============================================================ */
.sessions-layout,
.feature-page-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(240px, 0.8fr);
gap: 16px;
flex: 1;
min-height: 0;
}
/* ============================================================
WORKSPACE LAYOUT — fixed overlapping issue
============================================================ */
.workspace-layout {
display: grid;
grid-template-columns: 240px minmax(0, 1fr) 280px;
gap: 16px;
flex: 1;
min-height: 0;
overflow: hidden;
}
.workspace-column {
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
min-width: 0;
overflow-y: auto;
overflow-x: hidden;
}
.workspace-card {
min-width: 0;
overflow: hidden;
}
.workspace-card.file-viewer-card {
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1;
min-height: 0;
}
/* ============================================================
MESSAGES
============================================================ */
.transcript-list,
.event-list,
.session-preview-transcript {
display: grid;
gap: 10px;
list-style: none;
margin: 0;
padding: 0;
}
.message-card {
padding: 14px 18px;
border-radius: 14px;
margin-bottom: 2px;
animation: messageFadeIn 0.25s ease-out forwards;
}
@keyframes messageFadeIn {
0% { opacity: 0; transform: translateY(8px); }
100% { opacity: 1; transform: translateY(0); }
}
.message-card-title {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
color: var(--accent-primary);
margin-bottom: 8px;
}
.message-card-tool {
background: rgba(25, 35, 66, 0.96);
}
.message-card-system {
background: rgba(245, 158, 11, 0.05);
border-color: rgba(245, 158, 11, 0.15);
}
.message-card-system .message-card-title {
color: #fbbf24;
}
.message-card-user {
background: rgba(129, 140, 248, 0.05);
border-color: rgba(129, 140, 248, 0.15);
}
.message-card-user .message-card-title {
color: var(--text-muted);
}
/* ============================================================
COMPOSER
============================================================ */
.composer-toolbar,
.composer-actions,
.inline-actions,
.run-status,
.session-preview-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.composer-textarea,
.settings-field input {
width: 100%;
border-radius: 10px;
border: 1px solid var(--border-light);
background: rgba(0, 0, 0, 0.2);
color: var(--text-main);
padding: 14px;
font-size: 0.95rem;
line-height: 1.5;
transition: border-color 0.2s;
}
.composer-textarea:focus,
.settings-field input:focus {
outline: none;
border-color: var(--accent-primary);
box-shadow: 0 0 0 2px rgba(129, 140, 248, 0.12);
}
.composer-textarea {
resize: vertical;
min-height: 100px;
margin: 10px 0 14px;
}
/* ============================================================
MISC
============================================================ */
.event-list li,
.session-preview-line {
padding: 8px 12px;
border-radius: 10px;
background: rgba(122, 162, 255, 0.06);
}
.workspace-pre {
margin: 0;
white-space: pre-wrap;
overflow-x: auto;
}
/* ============================================================
FILE VIEWER — scrollable, editable code panel
============================================================ */
.file-viewer-body {
display: flex;
flex: 1;
min-height: 0;
overflow: auto;
background: rgba(0, 0, 0, 0.25);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.04);
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 0.82rem;
line-height: 1.6;
max-height: 50vh;
}
.file-viewer-gutter {
flex-shrink: 0;
padding: 12px 0;
text-align: right;
user-select: none;
color: rgba(148, 163, 184, 0.35);
background: rgba(0, 0, 0, 0.15);
border-right: 1px solid rgba(255, 255, 255, 0.06);
min-width: 44px;
}
.file-viewer-line-num {
padding: 0 10px;
font-size: 0.72rem;
line-height: 1.6;
}
.file-viewer-pre {
flex: 1;
margin: 0;
padding: 12px 16px;
white-space: pre;
overflow-x: auto;
color: #e2e8f0;
min-width: 0;
}
.file-viewer-textarea {
flex: 1;
margin: 0;
padding: 12px 16px;
white-space: pre;
overflow-x: auto;
color: #e2e8f0;
background: transparent;
border: none;
outline: none;
resize: none;
font-family: inherit;
font-size: inherit;
line-height: inherit;
min-width: 0;
width: 100%;
}
.file-viewer-textarea:focus {
background: rgba(129, 140, 248, 0.03);
}
.file-viewer-btn {
padding: 4px 12px;
border-radius: 8px;
border: 1px solid rgba(129, 140, 248, 0.2);
background: rgba(129, 140, 248, 0.08);
color: #a5b4fc;
font-size: 0.78rem;
cursor: pointer;
transition: all 0.15s;
}
.file-viewer-btn:hover {
background: rgba(129, 140, 248, 0.18);
border-color: rgba(129, 140, 248, 0.4);
}
.file-viewer-btn-save {
background: rgba(74, 222, 128, 0.1);
border-color: rgba(74, 222, 128, 0.25);
color: #4ade80;
}
.file-viewer-btn-save:hover {
background: rgba(74, 222, 128, 0.2);
border-color: rgba(74, 222, 128, 0.45);
}
.settings-grid {
display: grid;
gap: 12px;
margin-bottom: 16px;
}
.settings-field {
display: grid;
gap: 6px;
}
.chat-panel-header,
.sessions-card-header,
.workspace-card-header,
.feature-card-header {
margin-bottom: 14px;
}
.session-list,
.workspace-list,
.feature-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
}
button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/* ============================================================
SCROLLBAR
============================================================ */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
/* ============================================================
SELECT STYLING (for reasoning effort dropdown)
============================================================ */
select option {
background: var(--bg-surface);
color: var(--text-main);
}
/* ============================================================
STREAMING CURSOR
============================================================ */
@keyframes blink {
50% { opacity: 0; }
}
@keyframes fadeInDown {
0% { opacity: 0; transform: translateY(-6px); }
100% { opacity: 1; transform: translateY(0); }
}

View File

@@ -0,0 +1,121 @@
import { useEffect, useState } from 'react';
import { apiClient } from '../../lib/api';
interface BrowserStatus {
connected?: boolean;
cdp_url?: string;
mode?: string;
}
interface BrowserStatusResponse {
ok: boolean;
browser?: BrowserStatus;
}
export function BrowserControlPanel() {
const [status, setStatus] = useState<BrowserStatus>({});
const [cdpUrl, setCdpUrl] = useState('');
const [loading, setLoading] = useState(false);
const refreshStatus = async () => {
try {
const res = await apiClient.get<BrowserStatusResponse>('/browser/status');
if (res.ok && res.browser) setStatus(res.browser);
} catch { /* ignore */ }
};
useEffect(() => {
refreshStatus();
const pollId = setInterval(refreshStatus, 5000);
return () => clearInterval(pollId);
}, []);
const handleConnect = async () => {
setLoading(true);
try {
const body = cdpUrl.trim() ? { cdp_url: cdpUrl.trim() } : {};
await apiClient.post('/browser/connect', body);
await refreshStatus();
} finally {
setLoading(false);
}
};
const handleDisconnect = async () => {
setLoading(true);
try {
await apiClient.post('/browser/disconnect', {});
await refreshStatus();
} finally {
setLoading(false);
}
};
const isConnected = status.connected === true;
const btnStyle: React.CSSProperties = {
padding: '8px 16px', borderRadius: '10px', cursor: 'pointer', fontSize: '0.85rem',
border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(255,255,255,0.06)',
transition: 'all 0.2s',
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', padding: '4px' }}>
<h3 style={{ margin: 0, color: '#e2e8f0', fontSize: '1rem' }}>🌐 Browser</h3>
{/* Status indicator */}
<div style={{
padding: '12px 14px', borderRadius: '12px',
background: isConnected ? 'rgba(34, 197, 94, 0.08)' : 'rgba(239, 68, 68, 0.08)',
border: `1px solid ${isConnected ? 'rgba(34, 197, 94, 0.2)' : 'rgba(239, 68, 68, 0.2)'}`,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '1.2rem' }}>{isConnected ? '🟢' : '🔴'}</span>
<span style={{ color: isConnected ? '#86efac' : '#fca5a5', fontWeight: 600, fontSize: '0.9rem' }}>
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
{status.mode && (
<div style={{ marginTop: '6px', fontSize: '0.8rem', color: '#64748b' }}>
Mode: <span style={{ color: '#94a3b8' }}>{status.mode}</span>
</div>
)}
{status.cdp_url && (
<div style={{ marginTop: '2px', fontSize: '0.8rem', color: '#64748b' }}>
CDP: <code style={{ color: '#a5b4fc', fontSize: '0.75rem' }}>{status.cdp_url}</code>
</div>
)}
</div>
{/* Connect / Disconnect */}
{!isConnected ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
<input
type="text"
value={cdpUrl}
onChange={(e) => setCdpUrl(e.target.value)}
placeholder="CDP URL (optional)"
style={{
padding: '8px 12px', borderRadius: '10px',
border: '1px solid rgba(129,140,248,0.2)',
background: 'rgba(0,0,0,0.2)', color: 'white', fontSize: '0.85rem',
}}
/>
<button
type="button" onClick={handleConnect} disabled={loading}
style={{ ...btnStyle, color: '#86efac', borderColor: 'rgba(34,197,94,0.3)', background: 'rgba(34,197,94,0.1)' }}
>
{loading ? 'Connecting…' : '🔌 Connect'}
</button>
</div>
) : (
<button
type="button" onClick={handleDisconnect} disabled={loading}
style={{ ...btnStyle, color: '#fca5a5', borderColor: 'rgba(239,68,68,0.3)', background: 'rgba(239,68,68,0.1)' }}
>
{loading ? 'Disconnecting…' : '⏏ Disconnect'}
</button>
)}
</div>
);
}

View File

@@ -0,0 +1,57 @@
interface ApprovalPromptProps {
pending: Array<{ id: string; command?: string }>;
onApprove: (id: string, decision: 'once' | 'session' | 'always') => void;
onDeny: (id: string) => void;
}
export function ApprovalPrompt({ pending, onApprove, onDeny }: ApprovalPromptProps) {
if (pending.length === 0) return null;
const request = pending[0]; // Process one at a time for clarity
return (
<div style={{
position: 'fixed',
bottom: '140px',
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(239, 68, 68, 0.15)',
backdropFilter: 'blur(16px)',
border: '1px solid rgba(239, 68, 68, 0.3)',
boxShadow: '0 12px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(239, 68, 68, 0.1)',
padding: '24px',
borderRadius: '24px',
zIndex: 100,
minWidth: '500px',
display: 'flex',
flexDirection: 'column',
gap: '16px',
animation: 'modalScaleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1)',
}}>
<h3 style={{ margin: 0, color: '#f87171', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '1.2rem' }}></span> Action Required
</h3>
<p style={{ margin: 0, color: '#fca5a5', lineHeight: 1.5 }}>
Hermes wants to run the following command. Do you approve?
</p>
{request.command && (
<pre style={{ margin: 0, padding: '16px', background: 'rgba(0, 0, 0, 0.3)', color: '#f8f8f2', borderRadius: '12px', border: '1px solid rgba(255, 255, 255, 0.1)', overflowX: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-all', fontFamily: 'monospace', fontSize: '14px' }}>
{request.command}
</pre>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '8px', marginTop: '4px' }}>
<button onClick={() => onApprove(request.id, 'once')} type="button" style={{ background: 'rgba(239, 68, 68, 0.2)', color: '#fca5a5', border: '1px solid rgba(239, 68, 68, 0.3)', padding: '12px', borderRadius: '14px', cursor: 'pointer', fontWeight: 500, transition: 'all 0.2s' }}>
Approve Once
</button>
<button onClick={() => onApprove(request.id, 'session')} type="button" style={{ background: 'rgba(34, 197, 94, 0.2)', color: '#86efac', border: '1px solid rgba(34, 197, 94, 0.3)', padding: '12px', borderRadius: '14px', cursor: 'pointer', fontWeight: 500, transition: 'all 0.2s' }}>
Allow for Session
</button>
<button onClick={() => onDeny(request.id)} type="button" style={{ background: 'rgba(0, 0, 0, 0.3)', color: '#9ca3af', border: '1px solid rgba(255, 255, 255, 0.1)', padding: '12px', borderRadius: '14px', cursor: 'pointer', fontWeight: 500, transition: 'all 0.2s' }}>
Deny
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
import React from 'react';
import { UiState } from '../../lib/types';
import { closeArtifact } from '../../store/uiStore';
interface ArtifactViewerProps {
uiState: UiState;
setUiState: React.Dispatch<React.SetStateAction<UiState>>;
}
export function ArtifactViewer({ uiState, setUiState }: ArtifactViewerProps) {
if (!uiState.artifactOpen || !uiState.artifactContent) return null;
const { artifactType, artifactContent } = uiState;
const handleClose = () => {
setUiState(closeArtifact(uiState));
};
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 1000,
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
background: 'rgba(0,0,0,0.4)',
backdropFilter: 'blur(4px)'
}}>
<div style={{
width: '60vw',
height: '100vh',
background: '#1e1e2e',
boxShadow: '-4px 0 24px rgba(0,0,0,0.5)',
display: 'flex',
flexDirection: 'column',
animation: 'slideInRight 0.3s ease-out'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '16px 24px',
borderBottom: '1px solid rgba(255,255,255,0.1)',
background: 'rgba(0,0,0,0.2)'
}}>
<h2 style={{ margin: 0, fontSize: '1.2rem', color: '#e2e8f0', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span>🎨</span> Artifact Canvas
<span style={{
fontSize: '0.75rem',
background: '#818cf8',
color: '#fff',
padding: '2px 6px',
borderRadius: '4px',
textTransform: 'uppercase'
}}>{artifactType}</span>
</h2>
<button
onClick={handleClose}
style={{
background: 'transparent',
border: 'none',
color: '#94a3b8',
fontSize: '1.5rem',
cursor: 'pointer',
lineHeight: 1
}}
>
×
</button>
</div>
<div style={{ flex: 1, padding: '24px', overflow: 'auto', background: '#fff' }}>
{(artifactType === 'html' || artifactType === 'svg') ? (
<iframe
srcDoc={artifactContent}
style={{ width: '100%', height: '100%', border: 'none', background: '#fff' }}
title="Artifact Preview"
/>
) : (
<div style={{ whiteSpace: 'pre-wrap', color: '#000', fontFamily: 'monospace' }}>
{artifactContent}
</div>
)}
</div>
</div>
<style>{`
@keyframes slideInRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { useState } from 'react';
interface ClarifyPromptProps {
pending: Array<{ id: string; message?: string }>;
onClarify: (id: string, response: string) => void;
onDeny: (id: string) => void;
}
export function ClarifyPrompt({ pending, onClarify, onDeny }: ClarifyPromptProps) {
const [inputText, setInputText] = useState('');
if (pending.length === 0) return null;
const request = pending[0]; // Process one at a time
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (inputText.trim()) {
onClarify(request.id, inputText.trim());
setInputText('');
}
};
return (
<div style={{
position: 'fixed',
bottom: '140px',
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(56, 189, 248, 0.15)',
backdropFilter: 'blur(16px)',
border: '1px solid rgba(56, 189, 248, 0.3)',
boxShadow: '0 12px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(56, 189, 248, 0.1)',
padding: '20px 24px',
borderRadius: '20px',
zIndex: 100,
minWidth: '400px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
animation: 'modalScaleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1)',
}}>
<h3 style={{ margin: 0, color: '#38bdf8', display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '1.2rem' }}>💭</span> Question from Hermes
</h3>
<p style={{ margin: 0, color: '#bae6fd', lineHeight: 1.5 }}>
{request.message || 'The agent needs more information to continue.'}
</p>
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '8px', marginTop: '4px' }}>
<input
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
placeholder="Type your response..."
style={{ flex: 1, padding: '10px 14px', borderRadius: '12px', border: '1px solid rgba(56, 189, 248, 0.3)', background: 'rgba(0, 0, 0, 0.2)', color: 'white' }}
/>
<button type="submit" style={{ background: 'rgba(56, 189, 248, 0.2)', color: '#bae6fd', border: '1px solid rgba(56, 189, 248, 0.3)', padding: '10px 16px', borderRadius: '12px', cursor: 'pointer', fontWeight: 500 }}>
Reply
</button>
<button type="button" onClick={() => onDeny(request.id)} style={{ background: 'rgba(0, 0, 0, 0.3)', color: '#9ca3af', border: '1px solid rgba(255, 255, 255, 0.1)', padding: '10px 16px', borderRadius: '12px', cursor: 'pointer', fontWeight: 500 }}>
Cancel
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,720 @@
import { FormEvent, useEffect, useRef, useState } from 'react';
import { apiClient } from '../../lib/api';
interface WorkspaceTreeNode { name: string; path: string; type: 'file' | 'directory'; children?: WorkspaceTreeNode[]; }
interface WorkspaceTreeResponse { ok: boolean; tree: WorkspaceTreeNode; }
function flattenTree(node: WorkspaceTreeNode | undefined): string[] {
if (!node) return [];
if (node.type === 'file') return [node.path];
return (node.children ?? []).flatMap((child) => flattenTree(child));
}
export interface AttachedFile {
file: File;
previewUrl: string;
type: 'image' | 'audio' | 'other';
}
interface ComposerProps {
onSend(prompt: string, attachments: AttachedFile[], isBackground?: boolean, isQuickAsk?: boolean): Promise<void> | void;
onNewChat?: () => void;
reasoningEffort?: string;
onReasoningChange?: (value: string) => void;
}
interface CommandRegistryEntry {
name: string;
description: string;
aliases?: string[];
names?: string[];
}
interface CommandsResponse {
ok: boolean;
commands: CommandRegistryEntry[];
}
interface SlashCommandOption {
name: string;
description: string;
}
const FALLBACK_SLASH_COMMANDS: SlashCommandOption[] = [
{ name: '/new', description: 'Start a new session (fresh session ID + history)' },
{ name: '/reset', description: 'Start a new session (alias for /new)' },
{ name: '/background', description: 'Run a prompt in the background' },
{ name: '/bg', description: 'Run a prompt in the background (alias)' },
{ name: '/btw', description: 'Ephemeral side question (no tools, not persisted)' },
{ name: '/help', description: 'Show available commands' },
];
export function Composer({ onSend, onNewChat, reasoningEffort, onReasoningChange }: ComposerProps) {
const [prompt, setPrompt] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const [attachments, setAttachments] = useState<AttachedFile[]>([]);
const [isRecording, setIsRecording] = useState(false);
const [isBackground, setIsBackground] = useState(false);
const [isQuickAsk, setIsQuickAsk] = useState(false);
const [showSlashMenu, setShowSlashMenu] = useState(false);
const [slashFilter, setSlashFilter] = useState('');
const [selectedSlashIndex, setSelectedSlashIndex] = useState(0);
const [showMentionMenu, setShowMentionMenu] = useState(false);
const [mentionFilter, setMentionFilter] = useState('');
const [selectedMentionIndex, setSelectedMentionIndex] = useState(0);
const [workspaceFiles, setWorkspaceFiles] = useState<string[]>([]);
const [slashCommands, setSlashCommands] = useState<SlashCommandOption[]>(FALLBACK_SLASH_COMMANDS);
const fileInputRef = useRef<HTMLInputElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const menuRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
let cancelled = false;
apiClient.get<CommandsResponse>('/commands')
.then((res) => {
if (!res.ok || cancelled) return;
const next = res.commands.flatMap((command) => {
const names = command.names && command.names.length > 0
? command.names
: [command.name, ...(command.aliases ?? [])];
return names.map((entryName) => ({
name: `/${entryName}`,
description: command.description,
}));
});
if (next.length > 0) {
const deduped = next.filter((entry, index, arr) => arr.findIndex((other) => other.name === entry.name) === index);
setSlashCommands(deduped);
}
})
.catch(() => {
// Fallback list remains in place.
});
return () => {
cancelled = true;
};
}, []);
// Filtered commands for the slash menu
const filteredCommands = slashFilter
? slashCommands.filter(c => c.name.startsWith('/' + slashFilter))
: slashCommands;
const filteredMentions = mentionFilter
? workspaceFiles.filter(f => f.toLowerCase().includes(mentionFilter.toLowerCase())).slice(0, 50)
: workspaceFiles.slice(0, 50);
// Reset selection when filter changes
useEffect(() => {
setSelectedSlashIndex(0);
}, [slashFilter]);
useEffect(() => {
setSelectedMentionIndex(0);
}, [mentionFilter]);
const handleSubmit = async (event: FormEvent) => {
event.preventDefault();
setShowSlashMenu(false);
setShowMentionMenu(false);
let message = prompt.trim();
if (!message && attachments.length === 0) return;
const currentAttachments = [...attachments];
let bg = isBackground;
let qa = isQuickAsk;
// Intercept standalone slash commands
if (message.startsWith('/btw ') || message === '/btw') {
qa = true;
message = message.replace(/^\/btw\s*/, '');
} else if (message.startsWith('/bg ') || message === '/bg') {
bg = true;
message = message.replace(/^\/bg\s*/, '');
} else if (message.startsWith('/background ') || message === '/background') {
bg = true;
message = message.replace(/^\/background\s*/, '');
}
setPrompt('');
setAttachments([]);
await onSend(message || '(attached files)', currentAttachments, bg, qa);
};
const handleFileSelect = () => {
fileInputRef.current?.click();
};
const appendFiles = (filesLike: FileList | File[]) => {
const files = Array.from(filesLike);
if (files.length === 0) return;
const newAttachments: AttachedFile[] = files.map((file) => {
const type = file.type.startsWith('image/') ? 'image'
: file.type.startsWith('audio/') ? 'audio'
: 'other';
const previewUrl = type === 'image' ? URL.createObjectURL(file) : '';
return { file, previewUrl, type };
});
setAttachments((prev) => [...prev, ...newAttachments]);
};
const handleFilesChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
appendFiles(files);
e.target.value = ''; // Reset input
};
const pasteClipboardImage = async () => {
if (!navigator.clipboard || typeof navigator.clipboard.read !== 'function') {
return false;
}
try {
const items = await navigator.clipboard.read();
const pastedFiles: File[] = [];
for (const item of items) {
const imageType = item.types.find((type) => type.startsWith('image/'));
if (!imageType) continue;
const blob = await item.getType(imageType);
pastedFiles.push(new File([blob], `clipboard-${Date.now()}.${imageType.split('/')[1] || 'png'}`, { type: imageType }));
}
if (pastedFiles.length === 0) return false;
appendFiles(pastedFiles);
return true;
} catch {
return false;
}
};
useEffect(() => {
const handleOpenFilePicker = () => {
handleFileSelect();
};
const handlePrefillComposer = (event: Event) => {
const customEvent = event as CustomEvent<{ value?: string }>;
const value = customEvent.detail?.value ?? '';
setPrompt(value);
handlePromptChange(value);
requestAnimationFrame(() => textareaRef.current?.focus());
};
const handlePasteClipboard = async () => {
const success = await pasteClipboardImage();
window.dispatchEvent(new CustomEvent('hermes:composerPasteResult', { detail: { success } }));
};
window.addEventListener('hermes:composerOpenFilePicker', handleOpenFilePicker);
window.addEventListener('hermes:prefillComposer', handlePrefillComposer);
window.addEventListener('hermes:composerPasteClipboard', handlePasteClipboard);
return () => {
window.removeEventListener('hermes:composerOpenFilePicker', handleOpenFilePicker);
window.removeEventListener('hermes:prefillComposer', handlePrefillComposer);
window.removeEventListener('hermes:composerPasteClipboard', handlePasteClipboard);
};
}, []);
const removeAttachment = (index: number) => {
setAttachments((prev) => {
const removed = prev[index];
if (removed.previewUrl) URL.revokeObjectURL(removed.previewUrl);
return prev.filter((_, i) => i !== index);
});
};
const toggleRecording = async () => {
if (isRecording && mediaRecorderRef.current) {
mediaRecorderRef.current.stop();
setIsRecording(false);
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
chunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = () => {
stream.getTracks().forEach((t) => t.stop());
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
const file = new File([blob], `recording-${Date.now()}.webm`, { type: 'audio/webm' });
setAttachments((prev) => [...prev, { file, previewUrl: '', type: 'audio' }]);
};
mediaRecorderRef.current = recorder;
recorder.start();
setIsRecording(true);
} catch {
// Microphone permission denied
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const files = e.dataTransfer.files;
if (!files || files.length === 0) return;
const newAttachments: AttachedFile[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
const type = file.type.startsWith('image/') ? 'image'
: file.type.startsWith('audio/') ? 'audio'
: 'other';
const previewUrl = type === 'image' ? URL.createObjectURL(file) : '';
newAttachments.push({ file, previewUrl, type });
}
setAttachments((prev) => [...prev, ...newAttachments]);
};
const handleSlashSelect = (cmd: string) => {
setPrompt(cmd + ' ');
setShowSlashMenu(false);
setSlashFilter('');
};
const handleMentionSelect = (path: string) => {
const filename = path.split('/').pop() || path;
const replacement = `[${filename}](${path}) `;
const newPrompt = prompt.replace(/@\S*$/, replacement);
setPrompt(newPrompt);
setShowMentionMenu(false);
setMentionFilter('');
};
const handlePromptChange = (value: string) => {
setPrompt(value);
// Show slash menu when typing / at start
if (value.startsWith('/')) {
const parts = value.split(' ');
if (parts.length === 1) {
// Still typing the command name
setSlashFilter(value.slice(1));
setShowSlashMenu(true);
} else {
setShowSlashMenu(false);
}
} else {
setShowSlashMenu(false);
}
// Check for @mentions
const atMatch = value.match(/(?:^|\s)@(\S*)$/);
if (atMatch) {
setMentionFilter(atMatch[1]);
setShowMentionMenu(true);
if (workspaceFiles.length === 0) {
apiClient.get<WorkspaceTreeResponse>('/workspace/tree').then(res => {
if (res.ok) setWorkspaceFiles(flattenTree(res.tree));
}).catch(() => {});
}
} else {
setShowMentionMenu(false);
}
};
return (
<div style={{ width: '100%', padding: '0 20px 20px' }}>
<form
className="composer"
aria-label="Composer"
onSubmit={handleSubmit}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{
margin: '0 auto',
maxWidth: '800px',
position: 'relative',
display: 'flex',
flexDirection: 'column',
boxShadow: isDragOver ? '0 0 0 2px #c084fc' : 'none',
borderRadius: '24px',
transition: 'box-shadow 0.2s ease',
}}
>
{isDragOver && (
<div style={{
position: 'absolute',
inset: 0,
background: 'rgba(192, 132, 252, 0.1)',
backdropFilter: 'blur(4px)',
borderRadius: '24px',
zIndex: 50,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#c084fc',
fontSize: '1.2rem',
fontWeight: 600,
pointerEvents: 'none'
}}>
Drop files here...
</div>
)}
{/* Attachment Preview Strip */}
{attachments.length > 0 && (
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', paddingBottom: '12px', paddingLeft: '16px' }}>
{attachments.map((att, i) => (
<div key={i} style={{
position: 'relative',
background: 'rgba(255,255,255,0.08)',
borderRadius: '12px',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: att.type === 'image' ? '0' : '8px 12px',
maxWidth: '200px',
backdropFilter: 'blur(10px)'
}}>
{att.type === 'image' ? (
<img src={att.previewUrl} alt="preview" style={{ height: '64px', width: '64px', objectFit: 'cover', borderRadius: '12px' }} />
) : (
<span style={{ color: '#a5b4fc', fontSize: '0.85rem' }}>
{att.type === 'audio' ? '🎵' : '📄'} {att.file.name.slice(0, 20)}
</span>
)}
<button
type="button"
onClick={() => removeAttachment(i)}
style={{
position: 'absolute', top: '2px', right: '2px',
background: 'rgba(0,0,0,0.6)', color: 'white',
border: 'none', borderRadius: '50%',
width: '20px', height: '20px',
cursor: 'pointer', fontSize: '12px',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>×</button>
</div>
))}
</div>
)}
{/* Slash command autocomplete menu */}
{showSlashMenu && filteredCommands.length > 0 && (
<div
ref={menuRef}
style={{
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
maxHeight: '280px',
overflowY: 'auto',
background: 'rgba(24, 24, 27, 0.98)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: '12px',
padding: '6px',
marginBottom: '4px',
backdropFilter: 'blur(16px)',
boxShadow: '0 -8px 32px rgba(0,0,0,0.4)',
zIndex: 100,
}}
>
<div style={{ padding: '4px 10px 8px', fontSize: '0.7rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Slash Commands
</div>
{filteredCommands.map((cmd, i) => (
<button
key={cmd.name}
type="button"
onClick={() => handleSlashSelect(cmd.name)}
onMouseEnter={() => setSelectedSlashIndex(i)}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
width: '100%',
padding: '8px 10px',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
background: i === selectedSlashIndex ? 'rgba(129, 140, 248, 0.15)' : 'transparent',
color: '#f4f4f5',
textAlign: 'left',
transition: 'background 0.1s',
}}
>
<span style={{
fontFamily: 'monospace',
fontWeight: 600,
color: '#818cf8',
fontSize: '0.85rem',
minWidth: '120px',
}}>{cmd.name}</span>
<span style={{
color: '#71717a',
fontSize: '0.8rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>{cmd.description}</span>
</button>
))}
</div>
)}
{/* Mention autocomplete menu */}
{showMentionMenu && filteredMentions.length > 0 && (
<div
style={{
position: 'absolute',
bottom: '100%',
left: '40px',
maxWidth: '400px',
maxHeight: '280px',
overflowY: 'auto',
background: 'rgba(24, 24, 27, 0.98)',
border: '1px solid rgba(255,255,255,0.12)',
borderRadius: '12px',
padding: '6px',
marginBottom: '4px',
backdropFilter: 'blur(16px)',
boxShadow: '0 -8px 32px rgba(0,0,0,0.4)',
zIndex: 101,
}}
>
<div style={{ padding: '4px 10px 8px', fontSize: '0.7rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
File Mentions
</div>
{filteredMentions.map((path, i) => (
<button
key={path}
type="button"
onClick={() => handleMentionSelect(path)}
onMouseEnter={() => setSelectedMentionIndex(i)}
style={{
display: 'flex',
flexDirection: 'column',
gap: '2px',
width: '100%',
padding: '8px 10px',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
background: i === selectedMentionIndex ? 'rgba(129, 140, 248, 0.15)' : 'transparent',
textAlign: 'left',
transition: 'background 0.1s',
}}
>
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: '#f4f4f5' }}>
{path.split('/').pop()}
</span>
<span style={{ fontSize: '0.75rem', color: '#64748b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', width: '100%' }}>
{path}
</span>
</button>
))}
</div>
)}
<div style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: '24px', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: '8px', backdropFilter: 'blur(8px)' }}>
<input
ref={fileInputRef}
type="file"
accept="image/*,audio/*,.pdf,.txt,.md,.json,.csv"
multiple
style={{ display: 'none' }}
onChange={handleFilesChanged}
/>
<textarea
ref={textareaRef}
id="chat-prompt"
className="composer-textarea"
style={{ margin: 0, minHeight: '44px', maxHeight: '200px', border: 'none', background: 'transparent', boxShadow: 'none', padding: '4px 8px', fontSize: '1.05rem', resize: 'none', outline: 'none', color: '#f4f4f5' }}
placeholder={isQuickAsk ? "Ask a quick question without polluting conversation memory..." : (isBackground ? "Run silently in background..." : "Message Hermes... (type / for commands)")}
rows={1}
value={prompt}
onChange={(event) => {
handlePromptChange(event.target.value);
// Auto-resize
event.target.style.height = 'auto';
event.target.style.height = `${Math.min(event.target.scrollHeight, 200)}px`;
}}
onKeyDown={(e) => {
if (showSlashMenu && filteredCommands.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedSlashIndex(prev => Math.min(prev + 1, filteredCommands.length - 1));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedSlashIndex(prev => Math.max(prev - 1, 0));
return;
}
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
e.preventDefault();
handleSlashSelect(filteredCommands[selectedSlashIndex].name);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setShowSlashMenu(false);
return;
}
}
if (showMentionMenu && filteredMentions.length > 0) {
if (e.key === 'ArrowDown') {
e.preventDefault();
setSelectedMentionIndex(prev => Math.min(prev + 1, filteredMentions.length - 1));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedMentionIndex(prev => Math.max(prev - 1, 0));
return;
}
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
e.preventDefault();
handleMentionSelect(filteredMentions[selectedMentionIndex]);
return;
}
if (e.key === 'Escape') {
e.preventDefault();
setShowMentionMenu(false);
return;
}
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
/>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 4px' }}>
<div className="composer-toolbar" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<button type="button" className="icon-button" title="Attach file" onClick={handleFileSelect}>📎</button>
<button
type="button"
className="icon-button"
title={isRecording ? 'Stop recording' : 'Voice input'}
onClick={toggleRecording}
style={isRecording ? { background: 'rgba(239, 68, 68, 0.3)', color: '#f87171', animation: 'pulse 1.5s infinite' } : {}}
>
{isRecording ? '⏹' : '🎤'}
</button>
<button type="button" className="icon-button" title="New chat" onClick={onNewChat}></button>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
marginLeft: '8px',
background: isBackground ? 'rgba(74, 222, 128, 0.1)' : 'transparent',
border: `1px solid ${isBackground ? 'rgba(74, 222, 128, 0.3)' : 'transparent'}`,
padding: '4px 8px',
borderRadius: '12px',
cursor: 'pointer',
transition: 'all 0.2s ease',
userSelect: 'none'
}}
onClick={() => { setIsBackground(!isBackground); setIsQuickAsk(false); }}
>
<input
type="checkbox"
checked={isBackground}
onChange={() => {}}
style={{ cursor: 'pointer', accentColor: '#4ade80', width: '14px', height: '14px' }}
/>
<span style={{ fontSize: '0.8rem', color: isBackground ? '#4ade80' : '#a1a1aa', fontWeight: isBackground ? 600 : 400 }}>
Background
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
background: isQuickAsk ? 'rgba(192, 132, 252, 0.1)' : 'transparent',
border: `1px solid ${isQuickAsk ? 'rgba(192, 132, 252, 0.3)' : 'transparent'}`,
padding: '4px 8px',
borderRadius: '12px',
cursor: 'pointer',
transition: 'all 0.2s ease',
userSelect: 'none'
}}
onClick={() => { setIsQuickAsk(!isQuickAsk); setIsBackground(false); }}
>
<input
type="checkbox"
checked={isQuickAsk}
onChange={() => {}}
style={{ cursor: 'pointer', accentColor: '#c084fc', width: '14px', height: '14px' }}
/>
<span style={{ fontSize: '0.8rem', color: isQuickAsk ? '#c084fc' : '#a1a1aa', fontWeight: isQuickAsk ? 600 : 400 }}>
Quick Ask
</span>
</div>
{reasoningEffort !== undefined && onReasoningChange && (
<select
value={reasoningEffort}
onChange={(e) => onReasoningChange(e.target.value)}
title="Reasoning Effort (Supported by O-series / Copilot / etc)"
style={{
marginLeft: '8px',
padding: '4px 8px',
borderRadius: '12px',
background: 'transparent',
border: '1px solid rgba(255,255,255,0.1)',
color: '#a1a1aa',
fontSize: '0.8rem',
cursor: 'pointer',
outline: 'none'
}}
>
<option value="none">Reasoning: None</option>
<option value="minimal">Reasoning: Minimal</option>
<option value="low">Reasoning: Low</option>
<option value="medium">Reasoning: Medium</option>
<option value="high">Reasoning: High</option>
<option value="xhigh">Reasoning: XHigh</option>
</select>
)}
</div>
<div className="composer-actions">
<button type="submit" disabled={!prompt.trim() && attachments.length === 0} style={{
padding: '8px 20px',
borderRadius: '99px',
background: prompt.trim() || attachments.length > 0 ? (isQuickAsk ? '#c084fc' : (isBackground ? '#4ade80' : '#f4f4f5')) : 'rgba(255,255,255,0.1)',
color: prompt.trim() || attachments.length > 0 ? '#18181b' : 'rgba(255,255,255,0.4)',
border: 'none',
fontWeight: 500,
cursor: prompt.trim() || attachments.length > 0 ? 'pointer' : 'not-allowed',
transition: 'all 0.2s ease'
}}>
{isQuickAsk ? 'Ask' : (isBackground ? 'Dispatch' : 'Send')}
</button>
</div>
</div>
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,385 @@
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { apiClient } from '../../lib/api';
import { getBackendUrl } from '../../store/backendStore';
interface MessageCardProps {
role: 'user' | 'assistant' | 'tool' | 'system';
title: string;
content: string;
isStreaming?: boolean;
reasoning?: string;
isReasoningStreaming?: boolean;
messageIndex?: number;
onBranch?: (messageIndex: number) => void;
}
interface TtsResponse {
ok: boolean;
tts?: { success?: boolean; audio_file?: string; error?: string };
}
export function MessageCard({ role, title, content, isStreaming, reasoning, isReasoningStreaming, messageIndex, onBranch }: MessageCardProps) {
const [ttsLoading, setTtsLoading] = useState(false);
const [reasoningOpen, setReasoningOpen] = useState(false);
const [showActions, setShowActions] = useState(false);
const avatar = role === 'user' ? '👤' : role === 'assistant' ? '🤖' : role === 'tool' ? '⚙️' : '⚡';
const handleTts = async () => {
if (ttsLoading || !content.trim()) return;
setTtsLoading(true);
try {
const res = await apiClient.post<TtsResponse>('/media/tts', { text: content });
if (res.ok && res.tts?.audio_file) {
// Fetch the audio file as a blob since the backend returns a filesystem path
try {
const audioRes = await fetch(`${getBackendUrl()}/api/gui/workspace/file?path=${encodeURIComponent(res.tts.audio_file)}`);
if (audioRes.ok) {
const blob = await audioRes.blob();
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.onended = () => URL.revokeObjectURL(url);
audio.play().catch(() => {});
}
} catch {
// Try direct path as fallback
const audio = new Audio(res.tts.audio_file);
audio.play().catch(() => {});
}
}
} catch {
// TTS not available
} finally {
setTtsLoading(false);
}
};
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text).catch(() => {});
};
const handleBranch = () => {
if (onBranch && messageIndex !== undefined) {
onBranch(messageIndex);
}
};
const hasReasoning = !!(reasoning && reasoning.trim());
return (
<article
className={`message-card message-card-${role}`}
style={{ display: 'flex', gap: '16px', alignItems: 'flex-start', position: 'relative' }}
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => setShowActions(false)}
>
<div style={{ fontSize: '1.5rem', flexShrink: 0, opacity: 0.9 }}>{avatar}</div>
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
<div className="message-card-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{title}
{role === 'assistant' && content.length > 0 && (
<button
type="button"
onClick={handleTts}
disabled={ttsLoading}
title="Read aloud (TTS)"
style={{
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.1)',
color: ttsLoading ? '#475569' : '#a5b4fc',
padding: '2px 8px',
borderRadius: '6px',
cursor: ttsLoading ? 'default' : 'pointer',
fontSize: '0.75rem',
transition: 'all 0.2s',
}}
>
{ttsLoading ? '⏳' : '🔊'}
</button>
)}
</div>
{/* Collapsible Reasoning/Thinking Block */}
{(hasReasoning || isReasoningStreaming) && (
<div style={{
margin: '8px 0',
borderRadius: '10px',
background: 'rgba(251, 191, 36, 0.04)',
border: '1px solid rgba(251, 191, 36, 0.12)',
overflow: 'hidden',
transition: 'all 0.2s ease',
}}>
<button
type="button"
onClick={() => setReasoningOpen(!reasoningOpen)}
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
width: '100%',
padding: '8px 12px',
background: 'transparent',
border: 'none',
cursor: 'pointer',
color: '#fbbf24',
fontSize: '0.8rem',
fontWeight: 500,
textAlign: 'left',
transition: 'background 0.15s',
}}
>
<span style={{
display: 'inline-flex',
transition: 'transform 0.2s ease',
transform: reasoningOpen ? 'rotate(90deg)' : 'rotate(0deg)',
fontSize: '0.7rem',
}}></span>
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
💭 Thinking
{isReasoningStreaming && (
<span style={{
display: 'inline-block',
width: '6px', height: '6px',
borderRadius: '50%',
background: '#fbbf24',
animation: 'pulse 1.5s infinite',
}} />
)}
</span>
{reasoning && (
<span style={{
marginLeft: 'auto',
fontSize: '0.7rem',
color: '#92400e',
opacity: 0.6,
fontFamily: "'JetBrains Mono', monospace",
}}>
{reasoning.length.toLocaleString()} chars
</span>
)}
</button>
{reasoningOpen && (
<div style={{
padding: '0 12px 10px',
fontSize: '0.85rem',
lineHeight: 1.5,
color: '#d4a574',
fontStyle: 'italic',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: '400px',
overflowY: 'auto',
borderTop: '1px solid rgba(251, 191, 36, 0.08)',
}}>
{reasoning}
{isReasoningStreaming && (
<span style={{
display: 'inline-block',
width: '2px', height: '1em',
background: '#fbbf24',
marginLeft: '2px',
verticalAlign: 'text-bottom',
animation: 'blink 1s step-end infinite',
}} />
)}
</div>
)}
</div>
)}
<div className="message-card-content markdown-body" style={{ wordBreak: 'break-word', overflowX: 'auto' }}>
{role === 'assistant' || role === 'user' ? (
<>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
code(props) {
const { children, className, node, ...rest } = props;
const match = /language-(\w+)/.exec(className || '');
const isInline = !match && !String(children).includes('\n');
const language = match ? match[1] : '';
const contentString = String(children).replace(/\n$/, '');
if (!isInline && match) {
return (
<div className="code-block-wrapper" style={{ position: 'relative', marginTop: '12px', marginBottom: '12px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
background: '#1e1e1e',
padding: '4px 12px',
borderTopLeftRadius: '6px',
borderTopRightRadius: '6px',
fontSize: '0.75rem',
color: '#9cdcfe',
borderBottom: '1px solid #333'
}}>
<span>{language}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={() => handleCopy(contentString)}
style={{
background: 'transparent',
border: 'none',
color: '#d4d4d4',
cursor: 'pointer',
padding: '2px 6px',
fontSize: '0.75rem',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}
title="Copy to clipboard"
>
📋 Copy
</button>
{['html', 'svg', 'xml'].includes(language.toLowerCase()) && (
<button
onClick={() => {
window.dispatchEvent(new CustomEvent('hermes:openArtifact', {
detail: { type: language.toLowerCase(), content: contentString }
}));
}}
style={{
background: 'rgba(129, 140, 248, 0.2)',
border: '1px solid rgba(129, 140, 248, 0.4)',
color: '#a5b4fc',
cursor: 'pointer',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '0.75rem',
display: 'flex',
alignItems: 'center',
gap: '4px'
}}
title="Open in Canvas Overlay"
>
🎨 Open Canvas
</button>
)}
</div>
</div>
<SyntaxHighlighter
style={vscDarkPlus as any}
language={language}
PreTag="div"
customStyle={{ margin: 0, borderTopLeftRadius: 0, borderTopRightRadius: 0, fontSize: '0.85rem' }}
{...rest as any}
>
{contentString}
</SyntaxHighlighter>
</div>
);
}
return (
<code
style={{
background: 'rgba(255, 255, 255, 0.1)',
padding: '2px 4px',
borderRadius: '4px',
fontFamily: 'monospace',
fontSize: '0.85em',
}}
className={className}
{...rest as any}
>
{children}
</code>
);
},
a: ({ node, ...props }) => <a style={{ color: '#818cf8', textDecoration: 'underline' }} target="_blank" rel="noopener noreferrer" {...props as any} />,
p: ({ node, ...props }) => <p style={{ margin: '0 0 8px 0', lineHeight: 1.6 }} {...props as any} />,
ul: ({ node, ...props }) => <ul style={{ paddingLeft: '20px', margin: '0 0 12px 0' }} {...props as any} />,
ol: ({ node, ...props }) => <ol style={{ paddingLeft: '20px', margin: '0 0 12px 0' }} {...props as any} />,
li: ({ node, ...props }) => <li style={{ margin: '4px 0' }} {...props as any} />,
table: ({ node, ...props }) => (
<div style={{ overflowX: 'auto', marginBottom: '12px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }} {...props as any} />
</div>
),
th: ({ node, ...props }) => <th style={{ border: '1px solid rgba(255,255,255,0.1)', padding: '6px 12px', background: 'rgba(255,255,255,0.05)', textAlign: 'left' }} {...props as any} />,
td: ({ node, ...props }) => <td style={{ border: '1px solid rgba(255,255,255,0.1)', padding: '6px 12px' }} {...props as any} />
}}
>
{content || ' '}
</ReactMarkdown>
{isStreaming && (
<span className="streaming-cursor" style={{
display: 'inline-block',
width: '2px',
height: '1.1em',
background: '#818cf8',
marginLeft: '2px',
verticalAlign: 'text-bottom',
animation: 'blink 1s step-end infinite',
}} />
)}
</>
) : (
<div style={{ whiteSpace: 'pre-wrap', fontFamily: role === 'tool' ? 'monospace' : 'inherit', fontSize: role === 'tool' ? '0.85rem' : 'inherit' }}>
{content}
</div>
)}
</div>
</div>
{/* Hover action buttons */}
{showActions && (role === 'assistant' || role === 'user') && (
<div style={{
position: 'absolute',
top: '4px',
right: '4px',
display: 'flex',
gap: '4px',
opacity: 0.8,
}}>
{role === 'assistant' && onBranch && messageIndex !== undefined && (
<button
type="button"
onClick={handleBranch}
title="Branch conversation from here"
style={{
background: 'rgba(99, 102, 241, 0.15)',
border: '1px solid rgba(99, 102, 241, 0.25)',
color: '#a5b4fc',
padding: '3px 8px',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.7rem',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: '4px',
transition: 'all 0.15s',
}}
>
🌿 Branch
</button>
)}
<button
type="button"
onClick={() => handleCopy(content)}
title="Copy message"
style={{
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.1)',
color: '#94a3b8',
padding: '3px 8px',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '0.7rem',
transition: 'all 0.15s',
}}
>
📋
</button>
</div>
)}
</article>
);
}

View File

@@ -0,0 +1,29 @@
interface RunStatusBarProps {
status: string;
sessionId?: string;
model?: string;
}
function statusColor(status: string): string {
switch (status) {
case 'ready': return '#4ade80';
case 'running': case 'sending…': return '#60a5fa';
case 'completed': return '#a78bfa';
case 'error': case 'failed': case 'disconnected': return '#f87171';
case 'connecting': return '#fbbf24';
default: return '#94a3b8';
}
}
export function RunStatusBar({ status, sessionId = 'new session', model = 'hermes-agent' }: RunStatusBarProps) {
return (
<section className="run-status" aria-label="Run status">
<span className="status-pill" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ width: 10, height: 10, borderRadius: '50%', background: statusColor(status), display: 'inline-block', flexShrink: 0 }} />
{status}
</span>
<span className="status-pill">Session: {sessionId}</span>
<span className="status-pill">Model: {model}</span>
</section>
);
}

View File

@@ -0,0 +1,129 @@
import { useEffect, useState } from 'react';
import { apiClient } from '../../lib/api';
import { toastStore } from '../../store/toastStore';
interface Session {
session_id: string;
source: string;
created_at: number;
updated_at: number;
title?: string;
summary?: string;
}
interface SessionSidebarProps {
activeSessionId: string | null;
onSelectSession: (sessionId: string) => void;
onNewChat: () => void;
}
export function SessionSidebar({ activeSessionId, onSelectSession, onNewChat }: SessionSidebarProps) {
const [sessions, setSessions] = useState<Session[]>([]);
const [loading, setLoading] = useState(true);
const fetchSessions = async () => {
try {
const res = await apiClient.get<{ ok: boolean; sessions: Session[] }>('/sessions?limit=50');
if (res.ok) {
setSessions(res.sessions || []);
}
} catch (err) {
toastStore.error('Sessions Load Failed', err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSessions();
}, [activeSessionId]);
return (
<div style={{
width: '260px',
borderRight: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(0,0,0,0.2)',
display: 'flex',
flexDirection: 'column',
height: '100%',
flexShrink: 0
}}>
<div style={{ padding: '16px' }}>
<button
onClick={onNewChat}
style={{
width: '100%',
padding: '10px 16px',
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: '6px',
color: '#f8fafc',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
transition: 'background 0.2s',
fontWeight: 500
}}
onMouseOver={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.1)')}
onMouseOut={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.05)')}
>
<span></span> New Chat
</button>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '0 12px 16px', display: 'flex', flexDirection: 'column', gap: '4px' }}>
{loading ? (
<div style={{ textAlign: 'center', padding: '24px', color: '#64748b', fontSize: '0.875rem' }}>Loading...</div>
) : sessions.length === 0 ? (
<div style={{ textAlign: 'center', padding: '24px', color: '#64748b', fontSize: '0.875rem' }}>No recent chats</div>
) : (
sessions.map((s) => {
const isActive = s.session_id === activeSessionId;
const dateStr = s.updated_at ? new Date(s.updated_at * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '';
return (
<button
key={s.session_id}
onClick={() => onSelectSession(s.session_id)}
style={{
width: '100%',
textAlign: 'left',
background: isActive ? 'rgba(99, 102, 241, 0.15)' : 'transparent',
border: 'none',
borderRadius: '6px',
padding: '10px 12px',
cursor: 'pointer',
transition: 'background 0.2s',
position: 'relative'
}}
onMouseOver={(e) => {
if (!isActive) e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
}}
onMouseOut={(e) => {
if (!isActive) e.currentTarget.style.background = 'transparent';
}}
>
<div style={{
color: isActive ? '#818cf8' : '#e2e8f0',
fontSize: '0.875rem',
fontWeight: isActive ? 600 : 400,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
marginBottom: '4px'
}}>
{s.title || s.summary || 'New Chat'}
</div>
<div style={{ color: '#64748b', fontSize: '0.75rem', display: 'flex', justifyContent: 'space-between' }}>
<span>{dateStr}</span>
<span style={{ textTransform: 'capitalize' }}>{s.source}</span>
</div>
</button>
);
})
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
interface ToolTimelineProps {
events: string[];
}
export function ToolTimeline({ events }: ToolTimelineProps) {
return (
<section className="side-card" aria-label="Tool timeline">
<h3>Tool timeline</h3>
<ul className="event-list">
{events.map((event) => (
<li key={event}>{event}</li>
))}
</ul>
</section>
);
}

View File

@@ -0,0 +1,38 @@
import { MessageCard } from './MessageCard';
export interface TranscriptItem {
role: 'user' | 'assistant' | 'tool' | 'system';
title: string;
content: string;
isStreaming?: boolean;
reasoning?: string;
isReasoningStreaming?: boolean;
}
interface TranscriptProps {
items: TranscriptItem[];
sessionId?: string;
onBranch?: (messageIndex: number) => void;
}
export function Transcript({ items, sessionId, onBranch }: TranscriptProps) {
return (
<section className="chat-panel" aria-label="Transcript">
<div className="transcript-list">
{items.map((item, index) => (
<MessageCard
key={`${item.role}-${index}-${item.title}`}
role={item.role}
title={item.title}
content={item.content}
isStreaming={item.isStreaming}
reasoning={item.reasoning}
isReasoningStreaming={item.isReasoningStreaming}
messageIndex={index}
onBranch={onBranch}
/>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,55 @@
import React from 'react';
interface UsageMetrics {
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
estimated_cost?: number; // Optional, if backend provides it
}
interface UsageBarProps {
usage?: UsageMetrics;
}
function formatNumber(num: number | undefined): string {
if (num === undefined) return '0';
return num.toLocaleString();
}
export function UsageBar({ usage }: UsageBarProps) {
if (!usage || Object.keys(usage).length === 0) {
return null;
}
const { prompt_tokens, completion_tokens, total_tokens } = usage;
return (
<div
style={{
display: 'flex',
gap: '16px',
fontSize: '0.75rem',
color: '#94a3b8',
background: 'rgba(255, 255, 255, 0.03)',
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
padding: '8px 24px',
justifyContent: 'center',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span title="Prompt tokens"> In:</span>
<strong style={{ color: '#e2e8f0' }}>{formatNumber(prompt_tokens)}</strong>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span title="Completion tokens"> Out:</span>
<strong style={{ color: '#e2e8f0' }}>{formatNumber(completion_tokens)}</strong>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span title="Total tokens">📊 Total:</span>
<strong style={{ color: '#a5b4fc' }}>{formatNumber(total_tokens)}</strong>
</div>
</div>
);
}

View File

@@ -0,0 +1,267 @@
import { useState, useEffect, type ReactNode } from 'react';
import { isLocalMode, getBackendUrl, setBackendUrl, hasSavedBackendUrl } from '../../store/backendStore';
import { useConnection } from '../../lib/connectionContext';
/**
* ConnectGate — wraps the main app and shows a connect screen
* when running in hosted mode without a live backend connection.
*
* In local mode (dev/self-hosted), it always renders children directly.
*/
export function ConnectGate({ children }: { children: ReactNode }) {
const connection = useConnection();
// In local mode, skip the gate entirely — backwards compatible
if (isLocalMode()) {
return <>{children}</>;
}
// In hosted mode: show children if connected, connect screen if not
if (connection.online) {
return <>{children}</>;
}
return <ConnectScreen />;
}
function ConnectScreen() {
const connection = useConnection();
const [url, setUrl] = useState(() => getBackendUrl() || 'http://localhost:8642');
const [status, setStatus] = useState<'idle' | 'testing' | 'error' | 'success'>('idle');
const [errorMsg, setErrorMsg] = useState('');
// Auto-connect on mount if a saved URL exists
useEffect(() => {
if (hasSavedBackendUrl()) {
handleConnect();
}
}, []);
const handleConnect = async () => {
setStatus('testing');
setErrorMsg('');
const testUrl = url.replace(/\/+$/, '');
try {
const res = await fetch(`${testUrl}/api/gui/metrics/global`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) throw new Error(`Server responded with ${res.status}`);
const data = await res.json();
if (data.ok) {
setStatus('success');
setBackendUrl(testUrl);
// Trigger reconnect so the ConnectionProvider picks up the new URL
setTimeout(() => connection.reconnect(), 300);
} else {
throw new Error('Server responded but health check failed');
}
} catch (err) {
setStatus('error');
if (err instanceof TypeError && err.message.includes('fetch')) {
setErrorMsg('Cannot reach server. Is Hermes running? Check that your gateway is started.');
} else if (err instanceof DOMException && err.name === 'TimeoutError') {
setErrorMsg('Connection timed out. Make sure your Hermes backend is running on the specified port.');
} else {
setErrorMsg(err instanceof Error ? err.message : 'Connection failed');
}
}
};
return (
<div style={{
height: '100vh',
width: '100vw',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%)',
fontFamily: "'Inter', 'Segoe UI', system-ui, sans-serif",
}}>
{/* Ambient glow */}
<div style={{
position: 'absolute',
width: '600px',
height: '600px',
borderRadius: '50%',
background: 'radial-gradient(circle, rgba(99, 102, 241, 0.08) 0%, transparent 70%)',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
pointerEvents: 'none',
}} />
<div style={{
position: 'relative',
width: '100%',
maxWidth: '460px',
padding: '48px 40px',
background: 'rgba(15, 23, 42, 0.85)',
backdropFilter: 'blur(24px)',
WebkitBackdropFilter: 'blur(24px)',
border: '1px solid rgba(129, 140, 248, 0.15)',
borderRadius: '24px',
boxShadow: '0 25px 50px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.05)',
}}>
{/* Logo */}
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
<div style={{
fontSize: '3rem',
marginBottom: '12px',
filter: 'drop-shadow(0 0 20px rgba(99, 102, 241, 0.3))',
}}></div>
<h1 style={{
margin: 0,
fontSize: '1.75rem',
fontWeight: 700,
background: 'linear-gradient(135deg, #e2e8f0, #a5b4fc)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
letterSpacing: '-0.02em',
}}>Hermes Console</h1>
<p style={{
margin: '8px 0 0',
fontSize: '0.9rem',
color: '#64748b',
lineHeight: 1.5,
}}>
Connect to your local Hermes agent
</p>
</div>
{/* Connection form */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div>
<label style={{
display: 'block',
fontSize: '0.8rem',
fontWeight: 500,
color: '#94a3b8',
marginBottom: '6px',
textTransform: 'uppercase',
letterSpacing: '0.05em',
}}>Backend URL</label>
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') handleConnect(); }}
placeholder="http://localhost:8642"
style={{
width: '100%',
padding: '12px 16px',
borderRadius: '12px',
border: `1px solid ${status === 'error' ? 'rgba(239, 68, 68, 0.4)' : 'rgba(255, 255, 255, 0.1)'}`,
background: 'rgba(0, 0, 0, 0.3)',
color: '#e2e8f0',
fontSize: '0.95rem',
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
outline: 'none',
transition: 'border-color 0.2s, box-shadow 0.2s',
boxSizing: 'border-box',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(129, 140, 248, 0.5)';
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(129, 140, 248, 0.1)';
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.1)';
e.currentTarget.style.boxShadow = 'none';
}}
/>
</div>
<button
onClick={handleConnect}
disabled={status === 'testing'}
style={{
padding: '14px 24px',
borderRadius: '12px',
border: 'none',
background: status === 'testing'
? 'rgba(99, 102, 241, 0.3)'
: 'linear-gradient(135deg, #6366f1, #818cf8)',
color: '#fff',
fontWeight: 600,
fontSize: '1rem',
cursor: status === 'testing' ? 'wait' : 'pointer',
transition: 'all 0.2s',
boxShadow: status === 'testing' ? 'none' : '0 4px 14px rgba(99, 102, 241, 0.3)',
letterSpacing: '0.01em',
}}
onMouseEnter={(e) => {
if (status !== 'testing') {
e.currentTarget.style.transform = 'translateY(-1px)';
e.currentTarget.style.boxShadow = '0 6px 20px rgba(99, 102, 241, 0.4)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'none';
e.currentTarget.style.boxShadow = '0 4px 14px rgba(99, 102, 241, 0.3)';
}}
>
{status === 'testing' ? '⏳ Connecting…' : '⚡ Connect'}
</button>
{/* Status messages */}
{status === 'error' && (
<div style={{
padding: '12px 16px',
borderRadius: '10px',
background: 'rgba(239, 68, 68, 0.08)',
border: '1px solid rgba(239, 68, 68, 0.2)',
color: '#fca5a5',
fontSize: '0.85rem',
lineHeight: 1.5,
}}>
<strong>Connection failed.</strong> {errorMsg}
</div>
)}
{status === 'success' && (
<div style={{
padding: '12px 16px',
borderRadius: '10px',
background: 'rgba(74, 222, 128, 0.08)',
border: '1px solid rgba(74, 222, 128, 0.2)',
color: '#4ade80',
fontSize: '0.85rem',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}>
<span></span> Connected! Loading console
</div>
)}
</div>
{/* Help text */}
<div style={{
marginTop: '28px',
paddingTop: '20px',
borderTop: '1px solid rgba(255, 255, 255, 0.06)',
textAlign: 'center',
}}>
<p style={{ margin: 0, fontSize: '0.8rem', color: '#475569', lineHeight: 1.6 }}>
Make sure Hermes is running locally with the API server enabled.
</p>
<pre style={{
margin: '12px auto 0',
padding: '10px 16px',
borderRadius: '8px',
background: 'rgba(0, 0, 0, 0.3)',
border: '1px solid rgba(255, 255, 255, 0.06)',
color: '#94a3b8',
fontSize: '0.8rem',
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
display: 'inline-block',
textAlign: 'left',
}}>hermes --gui</pre>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,317 @@
import { useState } from 'react';
import { apiClient } from '../../lib/api';
interface CronJob {
id: string;
name: string;
schedule: string;
status: string;
prompt?: string;
deliver?: string;
}
interface CronListProps {
jobs: CronJob[];
onPause?: (id: string) => Promise<void>;
onResume?: (id: string) => Promise<void>;
onRun?: (id: string) => Promise<void>;
onDelete?: (id: string) => Promise<void>;
onCreate?: (data: { name: string; schedule: string; prompt: string }) => Promise<void>;
onUpdate?: (id: string, data: Partial<{ name: string; schedule: string; prompt: string; deliver: string }>) => Promise<void>;
}
interface HistoryEntry {
run_id?: string;
status?: string;
started_at?: string;
duration_s?: number;
output_preview?: string;
}
interface HistoryResponse {
ok: boolean;
history?: HistoryEntry[];
}
const STATUS_STYLES: Record<string, { bg: string; color: string; border: string; label: string }> = {
active: { bg: 'rgba(34, 197, 94, 0.15)', color: '#86efac', border: 'rgba(34, 197, 94, 0.3)', label: '● Active' },
paused: { bg: 'rgba(251, 191, 36, 0.15)', color: '#fde68a', border: 'rgba(251, 191, 36, 0.3)', label: '⏸ Paused' },
disabled: { bg: 'rgba(100, 116, 139, 0.15)', color: '#94a3b8', border: 'rgba(100, 116, 139, 0.3)', label: '○ Disabled' },
};
const DELIVERY_OPTIONS = [
{ value: '', label: 'Current session' },
{ value: 'telegram', label: 'Telegram' },
{ value: 'discord', label: 'Discord' },
{ value: 'slack', label: 'Slack' },
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'matrix', label: 'Matrix' },
];
export function CronList({ jobs, onPause, onResume, onRun, onDelete, onCreate, onUpdate }: CronListProps) {
const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState('');
const [newSchedule, setNewSchedule] = useState('');
const [newPrompt, setNewPrompt] = useState('');
const [expandedHistory, setExpandedHistory] = useState<string | null>(null);
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [editFields, setEditFields] = useState<{ name: string; schedule: string; prompt: string; deliver: string }>({ name: '', schedule: '', prompt: '', deliver: '' });
const handleCreate = async () => {
if (!newName.trim() || !newSchedule.trim() || !newPrompt.trim() || !onCreate) return;
await onCreate({ name: newName.trim(), schedule: newSchedule.trim(), prompt: newPrompt.trim() });
setNewName('');
setNewSchedule('');
setNewPrompt('');
setShowCreate(false);
};
const startEdit = (job: CronJob) => {
setEditingId(job.id);
setEditFields({
name: job.name,
schedule: job.schedule,
prompt: job.prompt || '',
deliver: job.deliver || '',
});
};
const saveEdit = async () => {
if (!editingId || !onUpdate) return;
await onUpdate(editingId, {
name: editFields.name.trim(),
schedule: editFields.schedule.trim(),
prompt: editFields.prompt.trim(),
deliver: editFields.deliver || undefined,
});
setEditingId(null);
};
const cancelEdit = () => setEditingId(null);
const btnStyle: React.CSSProperties = {
background: 'rgba(255, 255, 255, 0.06)',
border: '1px solid rgba(255, 255, 255, 0.1)',
color: '#a5b4fc',
padding: '6px 12px',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '0.8rem',
transition: 'all 0.2s',
};
const inputStyle: React.CSSProperties = {
padding: '10px 14px',
borderRadius: '10px',
border: '1px solid rgba(129, 140, 248, 0.3)',
background: 'rgba(0, 0, 0, 0.2)',
color: 'white',
fontSize: '0.9rem',
width: '100%',
};
return (
<section style={{
background: 'rgba(255, 255, 255, 0.03)',
border: '1px solid rgba(255, 255, 255, 0.06)',
borderRadius: '16px',
padding: '20px',
}} aria-label="Automations list">
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h2 style={{ margin: 0, color: '#e2e8f0', fontSize: '1.1rem' }}> Automations</h2>
<p style={{ margin: '4px 0 0', color: '#64748b', fontSize: '0.85rem' }}>Manage cron jobs and scheduled Hermes workflows.</p>
</div>
{onCreate && (
<button
type="button"
onClick={() => setShowCreate(!showCreate)}
style={{ ...btnStyle, background: 'rgba(129, 140, 248, 0.15)', borderColor: 'rgba(129, 140, 248, 0.3)' }}
>
{showCreate ? '✕ Cancel' : '+ New Job'}
</button>
)}
</div>
{/* Create form */}
{showCreate && (
<div style={{
display: 'flex', flexDirection: 'column', gap: '10px',
marginBottom: '16px', padding: '16px',
background: 'rgba(129, 140, 248, 0.06)',
border: '1px solid rgba(129, 140, 248, 0.15)',
borderRadius: '14px',
}}>
<input type="text" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="Job name (e.g. Morning summary)" style={inputStyle} />
<input type="text" value={newSchedule} onChange={(e) => setNewSchedule(e.target.value)} placeholder="Cron schedule (e.g. 0 9 * * *)" style={inputStyle} />
<textarea value={newPrompt} onChange={(e) => setNewPrompt(e.target.value)} placeholder="Prompt to execute..." rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
<button type="button" onClick={handleCreate} style={{ ...btnStyle, background: 'rgba(34, 197, 94, 0.2)', color: '#86efac', borderColor: 'rgba(34, 197, 94, 0.3)', padding: '10px 20px', alignSelf: 'flex-end' }}>
Create Job
</button>
</div>
)}
{/* Jobs list */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{jobs.map((job) => {
const style = STATUS_STYLES[job.status] || STATUS_STYLES.disabled;
const isEditing = editingId === job.id;
const isExpanded = expandedHistory === job.id;
return (
<div key={job.id}>
{/* Job card */}
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '14px 16px',
background: style.bg,
border: `1px solid ${style.border}`,
borderRadius: (isExpanded || isEditing) ? '14px 14px 0 0' : '14px',
transition: 'all 0.2s',
}}>
<div style={{ flex: 1 }}>
<strong style={{ color: '#e2e8f0', fontSize: '0.95rem' }}>{job.name}</strong>
<div style={{ display: 'flex', gap: '12px', marginTop: '4px', fontSize: '0.8rem', flexWrap: 'wrap' }}>
<code style={{ color: '#a5b4fc', background: 'rgba(0,0,0,0.2)', padding: '2px 6px', borderRadius: '4px' }}>{job.schedule}</code>
<span style={{ color: style.color }}>{style.label}</span>
{job.deliver && <span style={{ color: '#94a3b8' }}> {job.deliver}</span>}
</div>
</div>
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
{onRun && <button type="button" onClick={() => onRun(job.id)} style={btnStyle} title="Run now"></button>}
{onUpdate && (
<button type="button" onClick={() => isEditing ? cancelEdit() : startEdit(job)} style={{ ...btnStyle, color: isEditing ? '#fca5a5' : '#a5b4fc' }} title="Edit">
{isEditing ? '✕' : '✏️'}
</button>
)}
{job.status === 'active' && onPause && (
<button type="button" onClick={() => onPause(job.id)} style={btnStyle} title="Pause"></button>
)}
{job.status === 'paused' && onResume && (
<button type="button" onClick={() => onResume(job.id)} style={btnStyle} title="Resume"></button>
)}
{onDelete && (
<button type="button" onClick={() => onDelete(job.id)} style={{ ...btnStyle, color: '#fca5a5' }} title="Delete">🗑</button>
)}
<button type="button" onClick={async () => {
if (isExpanded) {
setExpandedHistory(null);
} else {
try {
const res = await apiClient.get<HistoryResponse>(`/cron/jobs/${job.id}/history`);
setHistoryEntries(res.ok && res.history ? res.history : []);
} catch { setHistoryEntries([]); }
setExpandedHistory(job.id);
}
}} style={btnStyle} title="Run history">📜</button>
</div>
</div>
{/* Edit panel */}
{isEditing && (
<div style={{
padding: '14px 16px',
background: 'rgba(129, 140, 248, 0.06)',
border: `1px solid ${style.border}`,
borderTop: 'none',
borderRadius: isExpanded ? '0' : '0 0 14px 14px',
}}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px', marginBottom: '10px' }}>
<label style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<span style={{ color: '#94a3b8', fontSize: '0.75rem' }}>Name</span>
<input
type="text"
value={editFields.name}
onChange={(e) => setEditFields(f => ({ ...f, name: e.target.value }))}
style={inputStyle}
/>
</label>
<label style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<span style={{ color: '#94a3b8', fontSize: '0.75rem' }}>Schedule</span>
<input
type="text"
value={editFields.schedule}
onChange={(e) => setEditFields(f => ({ ...f, schedule: e.target.value }))}
style={inputStyle}
/>
</label>
</div>
<label style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '10px' }}>
<span style={{ color: '#94a3b8', fontSize: '0.75rem' }}>Prompt</span>
<textarea
value={editFields.prompt}
onChange={(e) => setEditFields(f => ({ ...f, prompt: e.target.value }))}
rows={3}
style={{ ...inputStyle, resize: 'vertical' }}
/>
</label>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ color: '#94a3b8', fontSize: '0.75rem' }}>Deliver to</span>
<select
value={editFields.deliver}
onChange={(e) => setEditFields(f => ({ ...f, deliver: e.target.value }))}
style={{ ...inputStyle, width: 'auto', padding: '6px 10px' }}
>
{DELIVERY_OPTIONS.map(o => (
<option key={o.value} value={o.value} style={{ background: '#0f172a' }}>{o.label}</option>
))}
</select>
</label>
<div style={{ display: 'flex', gap: '8px' }}>
<button type="button" onClick={cancelEdit} style={btnStyle}>Cancel</button>
<button type="button" onClick={saveEdit} style={{ ...btnStyle, background: 'rgba(34, 197, 94, 0.2)', color: '#86efac', borderColor: 'rgba(34, 197, 94, 0.3)' }}>
Save Changes
</button>
</div>
</div>
</div>
)}
{/* History expansion */}
{isExpanded && (
<div style={{
padding: '10px 16px',
background: 'rgba(0,0,0,0.15)', borderRadius: '0 0 14px 14px',
border: `1px solid ${style.border}`, borderTop: 'none',
fontSize: '0.8rem', color: '#94a3b8',
}}>
{historyEntries.length === 0 ? (
<span>No run history yet.</span>
) : (
historyEntries.slice(0, 10).map((h, i) => (
<div key={h.run_id ?? i} style={{ display: 'flex', gap: '12px', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.04)', alignItems: 'center' }}>
<span style={{
color: h.status === 'success' ? '#86efac' : h.status === 'failed' ? '#fca5a5' : '#fde68a',
fontSize: '0.75rem',
padding: '1px 6px',
borderRadius: '4px',
background: h.status === 'success' ? 'rgba(34,197,94,0.1)' : h.status === 'failed' ? 'rgba(239,68,68,0.1)' : 'rgba(251,191,36,0.1)',
}}>
{h.status ?? 'unknown'}
</span>
{h.started_at && <span>{new Date(h.started_at).toLocaleString()}</span>}
{h.duration_s != null && <span style={{ color: '#64748b' }}>{h.duration_s.toFixed(1)}s</span>}
{h.output_preview && (
<span style={{ color: '#475569', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{h.output_preview}
</span>
)}
</div>
))
)}
</div>
)}
</div>
);
})}
{jobs.length === 0 && (
<p style={{ color: '#475569', textAlign: 'center', padding: '24px', fontSize: '0.9rem' }}>
No automations configured. Create one to schedule recurring agent tasks.
</p>
)}
</div>
</section>
);
}

View File

@@ -0,0 +1,148 @@
import { useEffect, useState } from 'react';
import { apiClient } from '../../lib/api';
import { TerminalHost } from './TerminalHost';
interface BackgroundProcess {
process_id: string;
command: string;
status: string;
started_at: number;
}
export function ProcessPanel() {
const [processes, setProcesses] = useState<BackgroundProcess[]>([]);
const [activeProcessId, setActiveProcessId] = useState<string | null>(null);
const [logs, setLogs] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
// Fetch process list
const fetchProcesses = async () => {
try {
const res = await apiClient.get<{ ok: boolean; processes: BackgroundProcess[] }>('/processes');
if (res.ok && res.processes) {
setProcesses(res.processes);
if (!activeProcessId && res.processes.length > 0) {
setActiveProcessId(res.processes[0].process_id);
}
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchProcesses();
const interval = setInterval(fetchProcesses, 3000);
return () => clearInterval(interval);
}, []);
// Fetch active process logs
useEffect(() => {
if (!activeProcessId) {
setLogs([]);
return;
}
let active = true;
const fetchLogs = async () => {
try {
const res = await apiClient.get<{ ok: boolean; log_lines: string[] }>(`/processes/${activeProcessId}/log?offset=0&limit=500`);
if (active && res.ok && res.log_lines) {
setLogs(res.log_lines);
}
} catch (err) {
console.error(err);
}
};
fetchLogs();
const interval = setInterval(fetchLogs, 1500);
return () => {
active = false;
clearInterval(interval);
};
}, [activeProcessId]);
const handleKill = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
try {
await apiClient.post(`/processes/${id}/kill`);
fetchProcesses();
} catch (err) {
console.error(err);
}
};
if (loading && processes.length === 0) {
return <div style={{ padding: '24px', color: '#94a3b8', textAlign: 'center' }}>Loading processes...</div>;
}
return (
<div style={{ display: 'flex', height: '100%', width: '100%' }}>
{/* Sidebar for list of processes */}
<div style={{ width: '300px', borderRight: '1px solid rgba(255,255,255,0.05)', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '12px 16px', background: 'rgba(0,0,0,0.2)', borderBottom: '1px solid rgba(255,255,255,0.05)', fontSize: '0.85rem', color: '#94a3b8' }}>
Background Processes
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
{processes.length === 0 ? (
<div style={{ padding: '24px', color: '#64748b', textAlign: 'center', fontSize: '0.875rem' }}>No processes running.</div>
) : (
processes.map(p => (
<div
key={p.process_id}
onClick={() => setActiveProcessId(p.process_id)}
style={{
padding: '12px 16px',
borderBottom: '1px solid rgba(255,255,255,0.05)',
background: activeProcessId === p.process_id ? 'rgba(56, 189, 248, 0.1)' : 'transparent',
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
gap: '6px'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: '#e2e8f0', fontFamily: 'monospace', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '180px' }}>
{p.command}
</span>
<span style={{ fontSize: '0.7rem', padding: '2px 6px', borderRadius: '4px', background: p.status === 'running' ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)', color: p.status === 'running' ? '#4ade80' : '#f87171' }}>
{p.status}
</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '0.75rem', color: '#64748b' }}>
{new Date(p.started_at * 1000).toLocaleTimeString()}
</span>
{p.status === 'running' && (
<button
onClick={(e) => handleKill(p.process_id, e)}
style={{ background: 'transparent', border: 'none', color: '#f87171', fontSize: '0.75rem', cursor: 'pointer', padding: '0' }}
>
Kill
</button>
)}
</div>
</div>
))
)}
</div>
</div>
{/* Main area for specific terminal output */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', position: 'relative' }}>
{activeProcessId ? (
<div style={{ position: 'absolute', inset: 0 }}>
<TerminalHost logs={logs} />
</div>
) : (
<div style={{ display: 'flex', height: '100%', alignItems: 'center', justifyContent: 'center', color: '#64748b', fontSize: '0.875rem' }}>
Select a process to view its output
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { useEffect, useRef } from 'react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
interface TerminalHostProps {
logs: string[];
}
export function TerminalHost({ logs }: TerminalHostProps) {
const terminalRef = useRef<HTMLDivElement>(null);
const termInstance = useRef<Terminal | null>(null);
const fitAddon = useRef<FitAddon | null>(null);
const processedLogsCount = useRef(0);
useEffect(() => {
if (!terminalRef.current) return;
const term = new Terminal({
theme: {
background: '#0f172a', // slate-900
foreground: '#f8fafc',
cursor: '#3b82f6',
},
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
fontSize: 13,
lineHeight: 1.2,
disableStdin: true,
cursorBlink: false,
convertEol: true,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.open(terminalRef.current);
fit.fit();
termInstance.current = term;
fitAddon.current = fit;
const resizeObserver = new ResizeObserver(() => {
fitAddon.current?.fit();
});
resizeObserver.observe(terminalRef.current);
return () => {
resizeObserver.disconnect();
term.dispose();
termInstance.current = null;
};
}, []);
useEffect(() => {
// Write new logs that haven't been written yet
if (termInstance.current) {
const newLogs = logs.slice(processedLogsCount.current);
if (newLogs.length > 0) {
for (const line of newLogs) {
termInstance.current.writeln(line);
}
processedLogsCount.current = logs.length;
}
}
}, [logs]);
// Handle case where logs array gets completely reset
useEffect(() => {
if (logs.length === 0 && termInstance.current) {
termInstance.current.clear();
processedLogsCount.current = 0;
}
}, [logs.length]);
return (
<div
ref={terminalRef}
style={{
width: '100%',
height: '100%',
overflow: 'hidden',
padding: '8px',
background: '#0f172a',
borderRadius: '6px'
}}
/>
);
}

View File

@@ -0,0 +1,134 @@
import { useEffect, useRef, useState } from 'react';
import { TerminalHost } from './TerminalHost';
import { openSessionEventStream, type GuiEvent } from '../../lib/events';
/**
* TerminalPanel renders a live feed of all tool executions from the agent's
* event stream. It subscribes to the current session's SSE and formats
* tool.started / tool.completed events as terminal-style log lines.
*/
export function TerminalPanel() {
const [lines, setLines] = useState<string[]>([]);
const [sessionId, setSessionId] = useState<string>('current');
const subRef = useRef<{ close(): void } | null>(null);
// Listen for the active session from ChatPage
useEffect(() => {
const handleSessionSync = (e: any) => {
if (e.detail?.sessionId) {
setSessionId(e.detail.sessionId);
}
};
window.addEventListener('hermes-session-sync', handleSessionSync);
// Request the current session
window.dispatchEvent(new CustomEvent('hermes-run-request-sync'));
return () => window.removeEventListener('hermes-session-sync', handleSessionSync);
}, []);
// Subscribe to the event stream and format tool events as terminal lines
useEffect(() => {
if (subRef.current) {
subRef.current.close();
}
const formatTimestamp = () => {
const now = new Date();
return `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`;
};
const handleEvent = (event: GuiEvent) => {
const ts = formatTimestamp();
if (event.type === 'run.started') {
setLines(prev => [...prev,
'',
`\x1b[1;36m━━━ Run Started ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m`,
`\x1b[90m${ts}\x1b[0m Run ID: \x1b[33m${event.run_id}\x1b[0m`,
]);
}
if (event.type === 'tool.started') {
const name = String(event.payload.tool_name ?? 'unknown');
const preview = String(event.payload.preview ?? '').slice(0, 120);
setLines(prev => [...prev,
`\x1b[90m${ts}\x1b[0m \x1b[1;34m▶\x1b[0m \x1b[1m${name}\x1b[0m${preview ? ` \x1b[90m${preview}\x1b[0m` : ''}`,
]);
}
if (event.type === 'tool.completed') {
const name = String(event.payload.tool_name ?? 'unknown');
const duration = event.payload.duration != null ? `${event.payload.duration}` : '';
const resultPreview = String(event.payload.result_preview ?? '').slice(0, 200);
const failed = event.payload.is_error === true;
const marker = failed ? '\x1b[1;31m✗\x1b[0m' : '\x1b[1;32m✓\x1b[0m';
const durationStr = duration ? ` \x1b[90m(${duration})\x1b[0m` : '';
setLines(prev => [...prev,
`\x1b[90m${ts}\x1b[0m ${marker} \x1b[1m${name}\x1b[0m${durationStr}`,
...(resultPreview ? [` \x1b[90m${resultPreview}\x1b[0m`] : []),
]);
}
if (event.type === 'run.completed' || event.type === 'run.failed') {
const ok = event.type === 'run.completed';
const statusLabel = ok ? '\x1b[1;32m✓ Run Completed\x1b[0m' : `\x1b[1;31m✗ Run Failed\x1b[0m`;
setLines(prev => [...prev,
`\x1b[90m${ts}\x1b[0m ${statusLabel}`,
`\x1b[1;36m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m`,
'',
]);
}
if (event.type === 'message.assistant.completed') {
const preview = String(event.payload.content ?? '').slice(0, 100);
if (preview) {
setLines(prev => [...prev,
`\x1b[90m${ts}\x1b[0m \x1b[35m◀\x1b[0m Assistant response (${preview.length} chars)`,
]);
}
}
};
subRef.current = openSessionEventStream(sessionId, handleEvent);
return () => {
subRef.current?.close();
subRef.current = null;
};
}, [sessionId]);
return (
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{
padding: '6px 16px',
background: 'rgba(0,0,0,0.2)',
borderBottom: '1px solid rgba(255,255,255,0.05)',
fontSize: '0.8rem',
color: '#94a3b8',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<span>Tool Execution Feed</span>
<button
onClick={() => setLines([])}
style={{
background: 'transparent',
border: '1px solid rgba(255,255,255,0.1)',
color: '#64748b',
padding: '2px 8px',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '0.75rem'
}}
>
Clear
</button>
</div>
<div style={{ flex: 1, position: 'relative' }}>
<div style={{ position: 'absolute', inset: 0 }}>
<TerminalHost logs={lines} />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,117 @@
interface PlatformCard {
id: string;
name: string;
status: string;
detail: string;
}
interface PlatformCardsProps {
platforms: PlatformCard[];
onConfigure?: (platformId: string) => void;
onToggle?: (platformId: string, action: 'start' | 'stop') => void;
}
const STATUS_STYLES: Record<string, { bg: string; color: string; border: string; label: string }> = {
connected: { bg: 'rgba(34, 197, 94, 0.15)', color: '#86efac', border: 'rgba(34, 197, 94, 0.3)', label: '● Connected' },
enabled: { bg: 'rgba(56, 189, 248, 0.15)', color: '#7dd3fc', border: 'rgba(56, 189, 248, 0.3)', label: '● Enabled' },
active: { bg: 'rgba(34, 197, 94, 0.15)', color: '#86efac', border: 'rgba(34, 197, 94, 0.3)', label: '● Active' },
configured: { bg: 'rgba(251, 191, 36, 0.15)', color: '#fde68a', border: 'rgba(251, 191, 36, 0.3)', label: '○ Configured' },
disabled: { bg: 'rgba(100, 116, 139, 0.15)', color: '#94a3b8', border: 'rgba(100, 116, 139, 0.3)', label: '○ Disabled' },
};
const PLATFORM_ICONS: Record<string, string> = {
telegram: '✈️',
discord: '🎮',
slack: '💬',
whatsapp: '📱',
matrix: '🔗',
mattermost: '🟣',
signal: '🔒',
email: '📧',
feishu: '🐦',
wecom: '🏢',
'api-server': '🌐',
};
export function PlatformCards({ platforms, onConfigure, onToggle }: PlatformCardsProps) {
return (
<section style={{
background: 'rgba(255, 255, 255, 0.03)',
border: '1px solid rgba(255, 255, 255, 0.06)',
borderRadius: '16px',
padding: '20px',
}} aria-label="Gateway platforms">
<div style={{ marginBottom: '16px' }}>
<h2 style={{ margin: 0, color: '#e2e8f0', fontSize: '1.1rem' }}>🌐 Gateway Platforms</h2>
<p style={{ margin: '4px 0 0', color: '#64748b', fontSize: '0.85rem' }}>Connected messaging and API platforms.</p>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: '12px' }}>
{platforms.map((platform) => {
const style = STATUS_STYLES[platform.status] || STATUS_STYLES.disabled;
const icon = PLATFORM_ICONS[(platform.id || platform.name || '').toLowerCase()] || '🔌';
return (
<div key={platform.id} style={{
background: style.bg,
border: `1px solid ${style.border}`,
borderRadius: '14px',
padding: '16px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
transition: 'all 0.2s',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '1.3rem' }}>{icon}</span>
<span style={{
fontSize: '0.75rem',
color: style.color,
padding: '2px 8px',
borderRadius: '20px',
background: 'rgba(0, 0, 0, 0.2)',
}}>
{style.label}
</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', flex: 1 }}>
<strong style={{ color: '#e2e8f0', fontSize: '0.95rem', textTransform: 'capitalize' }}>
{platform.name}
</strong>
<span style={{ color: '#94a3b8', fontSize: '0.8rem' }}>
{platform.detail}
</span>
</div>
<div style={{ display: 'flex', gap: '8px', marginTop: 'auto' }}>
{onConfigure && (
<button
type="button"
onClick={() => onConfigure(platform.id)}
style={{ flex: 1, padding: '6px', borderRadius: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#cbd5e1', cursor: 'pointer', fontSize: '0.8rem' }}
>
Configure
</button>
)}
{onToggle && (
<button
type="button"
onClick={() => onToggle(platform.id, platform.status === 'disabled' ? 'start' : 'stop')}
style={{ flex: 1, padding: '6px', borderRadius: '8px', background: platform.status === 'disabled' ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)', border: `1px solid ${platform.status === 'disabled' ? 'rgba(34,197,94,0.2)' : 'rgba(239,68,68,0.2)'}`, color: platform.status === 'disabled' ? '#86efac' : '#fca5a5', cursor: 'pointer', fontSize: '0.8rem' }}
>
{platform.status === 'disabled' ? 'Start' : 'Stop'}
</button>
)}
</div>
</div>
);
})}
</div>
{platforms.length === 0 && (
<p style={{ color: '#475569', textAlign: 'center', padding: '24px', fontSize: '0.9rem' }}>
No gateway platforms detected. Start the gateway with platform adapters enabled.
</p>
)}
</section>
);
}

View File

@@ -0,0 +1,188 @@
import { useState, useEffect } from 'react';
import { apiClient } from '../../lib/api';
import { toastStore } from '../../store/toastStore';
interface PlatformConfigModalProps {
platformId: string;
onClose: () => void;
onSaved: () => void;
}
export function PlatformConfigModal({ platformId, onClose, onSaved }: PlatformConfigModalProps) {
const [config, setConfig] = useState<Record<string, any>>({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
async function loadConfig() {
const res = await apiClient.get<any>(`/gateway/platforms/${platformId}/config`);
if (res?.ok) {
setConfig(res.config || {});
} else {
toastStore.error(`Failed to load ${platformId} configuration.`);
}
setLoading(false);
}
loadConfig();
}, [platformId]);
const handleSave = async () => {
setSaving(true);
const res = await apiClient.patch<any>(`/gateway/platforms/${platformId}/config`, config);
setSaving(false);
if (res?.ok) {
toastStore.success(`${platformId} configuration saved.`);
if (res.reload_required) {
toastStore.info('Please restart Hermes for changes to take effect.');
}
onSaved();
} else {
toastStore.error(res?.error?.message || `Failed to save ${platformId} configuration.`);
}
};
const getFieldsForPlatform = () => {
let fields = [
{ key: 'token', label: 'Bot Token', type: 'password', placeholder: 'Enter API Token' },
];
if (platformId === 'telegram') {
fields.push({ key: 'home_channel', label: 'Home Channel ID', type: 'text', placeholder: 'Optional' });
fields.push({ key: 'master_user', label: 'Master User ID', type: 'text', placeholder: 'Optional' });
fields.push({ key: 'webhook_url', label: 'Webhook URL (Overrides Polling)', type: 'text', placeholder: 'https://...' });
fields.push({ key: 'mention_behavior', label: 'Group Mention Behavior', type: 'text', placeholder: 'always | mentioned | regex' });
fields.push({ key: 'regex_trigger', label: 'Regex Trigger', type: 'text', placeholder: 'Optional regex pattern' });
} else if (platformId === 'discord') {
fields.push({ key: 'home_channel', label: 'Home Channel ID', type: 'text', placeholder: 'Optional' });
fields.push({ key: 'client_id', label: 'Client ID', type: 'text', placeholder: 'Optional' });
} else if (platformId === 'slack') {
fields.push({ key: 'app_token', label: 'App Token (xapp-...)', type: 'password', placeholder: 'Optional' });
fields.push({ key: 'signing_secret', label: 'Signing Secret', type: 'password', placeholder: 'Optional' });
} else if (platformId === 'feishu') {
fields = []; // Remove standard token
fields.push({ key: 'app_id', label: 'App ID', type: 'text', placeholder: 'cli_...' });
fields.push({ key: 'app_secret', label: 'App Secret', type: 'password', placeholder: 'Secret' });
fields.push({ key: 'verification_token', label: 'Verification Token', type: 'password', placeholder: 'Optional' });
fields.push({ key: 'encrypt_key', label: 'Encrypt Key', type: 'password', placeholder: 'Optional' });
} else if (platformId === 'weixin') {
fields = []; // Remove standard token
fields.push({ key: 'token', label: 'WeChat Token', type: 'password', placeholder: 'Token' });
fields.push({ key: 'account_id', label: 'Account ID', type: 'text', placeholder: 'gh_...' });
fields.push({ key: 'base_url', label: 'Base URL', type: 'text', placeholder: 'Optional (e.g. https://api.weixin.qq.com)' });
} else if (platformId === 'wecom') {
fields = []; // Remove standard token
fields.push({ key: 'bot_id', label: 'Bot ID', type: 'text', placeholder: 'Bot ID' });
fields.push({ key: 'secret', label: 'Secret', type: 'password', placeholder: 'Secret' });
} else if (platformId === 'mattermost') {
fields.push({ key: 'url', label: 'Mattermost URL', type: 'text', placeholder: 'https://...' });
fields.push({ key: 'bot_id', label: 'Bot ID', type: 'text', placeholder: 'Optional' });
fields.push({ key: 'webhook_port', label: 'Webhook Port', type: 'text', placeholder: 'Optional' });
} else if (platformId === 'matrix') {
fields.push({ key: 'homeserver', label: 'Homeserver URL', type: 'text', placeholder: 'https://matrix.org' });
fields.push({ key: 'username', label: 'Username', type: 'text', placeholder: '@bot:matrix.org' });
fields.push({ key: 'password', label: 'Password (if no token)', type: 'password', placeholder: 'Optional' });
} else if (platformId === 'homeassistant') {
fields.push({ key: 'url', label: 'Home Assistant URL', type: 'text', placeholder: 'https://...' });
fields.push({ key: 'agent_id', label: 'Conversation Agent ID', type: 'text', placeholder: 'Optional' });
} else if (platformId === 'signal') {
fields = [];
fields.push({ key: 'http_url', label: 'Signal-CLI HTTP URL', type: 'text', placeholder: 'http://127.0.0.1:8080' });
fields.push({ key: 'account', label: 'Phone Number Account', type: 'text', placeholder: '+1234567890' });
} else if (platformId === 'bluebubbles') {
fields = [];
fields.push({ key: 'server_url', label: 'Server URL', type: 'text', placeholder: 'https://...' });
fields.push({ key: 'password', label: 'Password', type: 'password', placeholder: 'Secret' });
} else if (platformId === 'email') {
fields = [];
fields.push({ key: 'address', label: 'Email Address', type: 'text', placeholder: 'bot@example.com' });
fields.push({ key: 'imap_host', label: 'IMAP Host', type: 'text', placeholder: 'imap.example.com' });
fields.push({ key: 'imap_user', label: 'IMAP User', type: 'text', placeholder: 'bot@example.com' });
fields.push({ key: 'imap_pass', label: 'IMAP Password', type: 'password', placeholder: 'Secret' });
fields.push({ key: 'smtp_host', label: 'SMTP Host', type: 'text', placeholder: 'smtp.example.com' });
fields.push({ key: 'smtp_user', label: 'SMTP User', type: 'text', placeholder: 'bot@example.com' });
fields.push({ key: 'smtp_pass', label: 'SMTP Password', type: 'password', placeholder: 'Secret' });
} else if (platformId === 'whatsapp' || platformId === 'sms' || platformId === 'webhook' || platformId === 'api_server' || platformId === 'cli') {
fields = [];
}
return fields;
};
const fields = getFieldsForPlatform();
const handleFieldChange = (key: string, value: any) => {
setConfig(prev => ({ ...prev, [key]: value }));
};
const inputStyle = {
width: '100%',
padding: '8px 12px',
borderRadius: '8px',
border: '1px solid rgba(255, 255, 255, 0.1)',
background: 'rgba(0, 0, 0, 0.2)',
color: '#e2e8f0',
fontSize: '0.9rem',
marginBottom: '12px'
};
return (
<div style={{
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
background: 'rgba(0, 0, 0, 0.6)', backdropFilter: 'blur(4px)',
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100
}}>
<div style={{
background: '#1e293b', border: '1px solid rgba(255, 255, 255, 0.1)',
borderRadius: '16px', padding: '24px', width: '400px', maxWidth: '90vw'
}}>
<h3 style={{ margin: '0 0 16px', color: '#e2e8f0', textTransform: 'capitalize' }}>
Configure {platformId}
</h3>
{loading ? (
<p style={{ color: '#94a3b8' }}>Loading configuration...</p>
) : (
<form onSubmit={e => { e.preventDefault(); handleSave(); }}>
{fields.map(field => (
<div key={field.key}>
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.85rem', color: '#94a3b8' }}>
{field.label}
</label>
<input
type={field.type}
value={config[field.key] || ''}
placeholder={field.placeholder}
onChange={e => handleFieldChange(field.key, e.target.value)}
style={inputStyle}
/>
</div>
))}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '16px' }}>
<button
type="button"
onClick={onClose}
style={{
padding: '8px 16px', borderRadius: '8px', background: 'transparent',
border: '1px solid rgba(255, 255, 255, 0.1)', color: '#e2e8f0', cursor: 'pointer'
}}
>
Cancel
</button>
<button
type="submit"
disabled={saving}
style={{
padding: '8px 16px', borderRadius: '8px', background: 'rgba(56, 189, 248, 0.2)',
border: '1px solid rgba(56, 189, 248, 0.3)', color: '#38bdf8', cursor: 'pointer'
}}
>
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</form>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,188 @@
import { useEffect, useState } from 'react';
import { apiClient } from '../../lib/api';
interface HumanRequest {
request_id: string;
type: 'approval' | 'clarification';
prompt?: string;
tool_name?: string;
command?: string;
question?: string;
choices?: string[];
status: 'pending' | 'resolved' | 'denied';
created_at: number;
}
export function HumanPanel() {
const [requests, setRequests] = useState<HumanRequest[]>([]);
const [loading, setLoading] = useState(true);
const [clarifyInput, setClarifyInput] = useState<{ [id: string]: string }>({});
const fetchPending = async () => {
try {
const res = await apiClient.get<{ ok: boolean; pending: HumanRequest[] }>('/human/pending');
if (res.ok) {
setRequests(res.pending || []);
}
} catch (err) {
console.error('Failed to load pending human requests:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPending();
const interval = setInterval(fetchPending, 3000);
return () => clearInterval(interval);
}, []);
const handleApprove = async (id: string, decision: 'once' | 'session' | 'always' = 'once') => {
try {
await apiClient.post('/human/approve', { request_id: id, decision });
fetchPending();
} catch (err) {
console.error(err);
}
};
const handleDeny = async (id: string) => {
try {
await apiClient.post('/human/deny', { request_id: id });
fetchPending();
} catch (err) {
console.error(err);
}
};
const handleClarify = async (id: string, response: string) => {
try {
await apiClient.post('/human/clarify', { request_id: id, response });
setClarifyInput(prev => {
const next = { ...prev };
delete next[id];
return next;
});
fetchPending();
} catch (err) {
console.error(err);
}
};
if (loading && requests.length === 0) {
return <div style={{ padding: '24px', color: '#94a3b8', textAlign: 'center' }}>Loading pending requests...</div>;
}
if (requests.length === 0) {
return <div style={{ padding: '24px', color: '#94a3b8', textAlign: 'center' }}>No pending approvals or questions.</div>;
}
return (
<div style={{ padding: '16px', color: '#f8fafc', display: 'flex', flexDirection: 'column', gap: '16px', overflowY: 'auto', height: '100%' }}>
<h3 style={{ margin: '0 0 8px 0', fontSize: '1rem', color: '#e2e8f0' }}>Pending Action Needed</h3>
{requests.map(req => (
<div key={req.request_id} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(234, 179, 8, 0.3)', padding: '12px', borderRadius: '6px' }}>
{req.type === 'approval' ? (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span style={{ fontWeight: 600, color: '#facc15' }}>Security Approval</span>
<span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>
{new Date(req.created_at * 1000).toLocaleTimeString()}
</span>
</div>
<div style={{ fontSize: '0.875rem', marginBottom: '8px' }}>
The agent wants to use <strong>{req.tool_name}</strong>.
</div>
{req.command && (
<div style={{ background: '#000', padding: '8px', borderRadius: '4px', fontFamily: 'monospace', fontSize: '0.8rem', color: '#4ade80', marginBottom: '12px', overflowX: 'auto' }}>
{req.command}
</div>
)}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
onClick={() => handleApprove(req.request_id, 'once')}
style={{ background: 'rgba(34, 197, 94, 0.2)', color: '#4ade80', border: '1px solid rgba(34, 197, 94, 0.3)', padding: '6px 12px', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem', flex: 1 }}
>
Approve Once
</button>
<button
onClick={() => handleApprove(req.request_id, 'session')}
style={{ background: 'rgba(34, 197, 94, 0.1)', color: '#4ade80', border: '1px solid rgba(34, 197, 94, 0.3)', padding: '6px 12px', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem', flex: 1 }}
>
Approve Session
</button>
<button
onClick={() => handleDeny(req.request_id)}
style={{ background: 'rgba(239, 68, 68, 0.2)', color: '#f87171', border: '1px solid rgba(239, 68, 68, 0.3)', padding: '6px 12px', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem', flex: 1 }}
>
Deny
</button>
</div>
</div>
) : (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
<span style={{ fontWeight: 600, color: '#60a5fa' }}>Clarification Needed</span>
<span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>
{new Date(req.created_at * 1000).toLocaleTimeString()}
</span>
</div>
<div style={{ fontSize: '0.875rem', marginBottom: '12px' }}>
{req.question}
</div>
{req.choices && req.choices.length > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{req.choices.map(choice => (
<button
key={choice}
onClick={() => handleClarify(req.request_id, choice)}
style={{ background: 'rgba(255,255,255,0.05)', color: '#e2e8f0', border: '1px solid rgba(255,255,255,0.1)', padding: '8px', borderRadius: '4px', cursor: 'pointer', fontSize: '0.85rem', textAlign: 'left' }}
>
{choice}
</button>
))}
<button
onClick={() => handleDeny(req.request_id)}
style={{ background: 'rgba(239, 68, 68, 0.1)', color: '#f87171', border: '1px dashed rgba(239, 68, 68, 0.3)', padding: '8px', borderRadius: '4px', cursor: 'pointer', fontSize: '0.85rem' }}
>
Cancel Action
</button>
</div>
) : (
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={clarifyInput[req.request_id] || ''}
onChange={e => setClarifyInput({ ...clarifyInput, [req.request_id]: e.target.value })}
placeholder="Type your answer..."
onKeyDown={e => {
if (e.key === 'Enter' && clarifyInput[req.request_id]) {
handleClarify(req.request_id, clarifyInput[req.request_id]);
}
}}
style={{ flex: 1, padding: '8px 12px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', borderRadius: '4px', fontSize: '0.875rem' }}
/>
<button
onClick={() => handleClarify(req.request_id, clarifyInput[req.request_id] || '')}
disabled={!clarifyInput[req.request_id]}
style={{ background: '#3b82f6', color: '#fff', border: 'none', padding: '0 16px', borderRadius: '4px', cursor: clarifyInput[req.request_id] ? 'pointer' : 'not-allowed', opacity: clarifyInput[req.request_id] ? 1 : 0.5 }}
>
Send
</button>
</div>
)}
</div>
)}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,147 @@
import { useEffect, useState } from 'react';
import { apiClient } from '../../lib/api';
interface RunDetail {
run_id: string;
session_id: string;
status: string;
model: string;
provider: string;
created_at: number;
updated_at: number;
usage?: Record<string, any>;
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
}
export function RunPanel() {
const [runId, setRunId] = useState<string | null>(null);
const [status, setStatus] = useState<string>('idle');
const [detail, setDetail] = useState<RunDetail | null>(null);
const [elapsed, setElapsed] = useState<number>(0);
// Listen to run sync events from ChatPage
useEffect(() => {
const handleSync = (e: CustomEvent) => {
const { runId: newRunId, status: newStatus } = e.detail;
if (newRunId !== runId) {
setRunId(newRunId);
setElapsed(0);
}
setStatus(newStatus || 'idle');
};
window.addEventListener('hermes-run-sync', handleSync as EventListener);
// Request an immediate sync
window.dispatchEvent(new CustomEvent('hermes-run-request-sync'));
return () => {
window.removeEventListener('hermes-run-sync', handleSync as EventListener);
};
}, [runId]);
// Fetch run details
useEffect(() => {
if (!runId) {
setDetail(null);
return;
}
const fetchDetail = async () => {
try {
const res = await apiClient.get<{ ok: boolean; run: RunDetail }>(`/chat/run/${runId}`);
if (res.ok) {
setDetail(res.run);
}
} catch (err) {
console.error('Failed to fetch run details:', err);
}
};
fetchDetail();
// Poll detail if running
if (status === 'running') {
const interval = setInterval(fetchDetail, 2000);
return () => clearInterval(interval);
}
}, [runId, status]);
// Elapsed timer
useEffect(() => {
if (status !== 'running') return;
const interval = setInterval(() => {
setElapsed((prev) => prev + 1);
}, 1000);
return () => clearInterval(interval);
}, [status]);
if (!runId && status === 'idle') {
return (
<div style={{ padding: '24px', color: '#94a3b8', textAlign: 'center' }}>
<div style={{ fontSize: '2rem', marginBottom: '16px' }}>😴</div>
<p>No active run.</p>
<p style={{ fontSize: '0.875rem' }}>Send a message to start a new run.</p>
</div>
);
}
const modelName = detail?.model || 'N/A';
const provider = detail?.provider || 'N/A';
// Format token counts
const pTokens = detail?.prompt_tokens ?? detail?.usage?.prompt_tokens ?? 0;
const cTokens = detail?.completion_tokens ?? detail?.usage?.completion_tokens ?? 0;
const tTokens = detail?.total_tokens ?? detail?.usage?.total_tokens ?? (pTokens + cTokens);
return (
<div style={{ padding: '16px', color: '#f8fafc' }}>
<h3 style={{ marginTop: 0, marginBottom: '12px', fontSize: '1.1rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
{status === 'running' ? '⏳ Running...' : status === 'failed' ? '❌ Failed' : '✅ Completed'}
</h3>
<div style={{ background: 'rgba(0,0,0,0.2)', padding: '12px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.05)', marginBottom: '16px' }}>
<div style={{ fontSize: '0.75rem', color: '#94a3b8', marginBottom: '4px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>Run ID</div>
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem' }}>{runId || 'N/A'}</div>
</div>
<div style={{ background: 'rgba(0,0,0,0.2)', padding: '12px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.05)', marginBottom: '16px' }}>
<div style={{ fontSize: '0.75rem', color: '#94a3b8', marginBottom: '4px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>Model & Provider</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontWeight: 500 }}>{modelName}</span>
<span style={{ fontSize: '0.8rem', background: 'rgba(99, 102, 241, 0.2)', color: '#818cf8', padding: '2px 6px', borderRadius: '4px' }}>
{provider}
</span>
</div>
</div>
{status === 'running' && (
<div style={{ background: 'rgba(0,0,0,0.2)', padding: '12px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.05)', marginBottom: '16px' }}>
<div style={{ fontSize: '0.75rem', color: '#94a3b8', marginBottom: '4px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>Elapsed Time</div>
<div style={{ fontSize: '1.25rem', fontFamily: 'monospace' }}>
{Math.floor(elapsed / 60)}:{(elapsed % 60).toString().padStart(2, '0')}
</div>
</div>
)}
{(status === 'completed' || tTokens > 0) && (
<div style={{ background: 'rgba(0,0,0,0.2)', padding: '12px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.05)' }}>
<div style={{ fontSize: '0.75rem', color: '#94a3b8', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>Run Token Usage</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', fontSize: '0.9rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#cbd5e1' }}>Prompt:</span>
<span style={{ fontFamily: 'monospace' }}>{pTokens.toLocaleString()}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: '#cbd5e1' }}>Completion:</span>
<span style={{ fontFamily: 'monospace' }}>{cTokens.toLocaleString()}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: '8px', fontWeight: 600 }}>
<span>Total:</span>
<span style={{ fontFamily: 'monospace' }}>{tTokens.toLocaleString()}</span>
</div>
</div>
</div>
)}
</div>
);
}

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