diff --git a/cron/scheduler.py b/cron/scheduler.py index 4d2202f06..d1fac6d49 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -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 diff --git a/docs/plans/2026-03-30-hermes-web-console-api-and-event-schema.md b/docs/plans/2026-03-30-hermes-web-console-api-and-event-schema.md new file mode 100644 index 000000000..bdfa5a87c --- /dev/null +++ b/docs/plans/2026-03-30-hermes-web-console-api-and-event-schema.md @@ -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 ` 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 +} +``` diff --git a/docs/plans/2026-03-30-hermes-web-console-frontend-architecture-and-wireframes.md b/docs/plans/2026-03-30-hermes-web-console-frontend-architecture-and-wireframes.md new file mode 100644 index 000000000..344d23bbe --- /dev/null +++ b/docs/plans/2026-03-30-hermes-web-console-frontend-architecture-and-wireframes.md @@ -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 diff --git a/docs/plans/2026-03-30-hermes-web-console-v1-implementation-plan.md b/docs/plans/2026-03-30-hermes-web-console-v1-implementation-plan.md new file mode 100644 index 000000000..8a2e8c1f7 --- /dev/null +++ b/docs/plans/2026-03-30-hermes-web-console-v1-implementation-plan.md @@ -0,0 +1,1395 @@ +# Hermes Web Console v1 Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** Build a fully featured browser-based Hermes GUI that exposes chat, sessions, tool activity, workspace operations, approvals, memory, skills, cron, gateway/platform administration, settings, and observability on top of the existing Hermes runtime. + +**Architecture:** Keep the existing OpenAI-compatible API server and add a Hermes-native web-console backend under `gateway/web_console/` with structured REST + SSE endpoints. Build a dedicated React/TypeScript SPA under `web_console/`, compile it into static assets served by aiohttp, and reuse existing Hermes runtime/session/gateway/storage services instead of inventing parallel state stores. + +**Tech Stack:** aiohttp, Python 3.11+, existing Hermes gateway/runtime/session stores, React 19, TypeScript 5, Vite, TanStack Router, TanStack Query, Zustand, Vitest, Playwright, pytest, SSE. + +--- + +## 0. Repository baseline and guiding decisions + +### Existing surfaces to build on + +- OpenAI-compatible API server already exists in `gateway/platforms/api_server.py`. +- Minimal browser UI already exists in `gateway/platforms/api_server_ui.py`. +- Session persistence already exists in Hermes state/session storage and docs. +- Gateway runtime health/status already exists in `gateway/status.py`. +- Cron, pairing, skills, memory, tools, and config systems already exist. + +### Decisions for this implementation + +1. Keep `/v1/*` endpoints unchanged for compatibility. +2. Add Hermes-native endpoints under `/api/gui/*`. +3. Add SSE endpoint(s) under `/api/gui/stream/*` for run events. +4. Serve built SPA assets from `/app/*`, with `/` redirecting to `/app/` when GUI is enabled. +5. Continue using local file-backed/runtime-backed stores (`config.yaml`, `.env`, auth store, session DB, gateway state, cron outputs) as source of truth. +6. Never expose raw secrets in GUI responses. +7. Default GUI binding remains localhost-only unless the user intentionally configures remote access. + +--- + +## 1. Target file layout + +### Backend files to create + +- Create: `gateway/web_console/__init__.py` +- Create: `gateway/web_console/app.py` +- Create: `gateway/web_console/routes.py` +- Create: `gateway/web_console/security.py` +- Create: `gateway/web_console/sse.py` +- Create: `gateway/web_console/event_bus.py` +- Create: `gateway/web_console/static.py` +- Create: `gateway/web_console/state.py` +- Create: `gateway/web_console/api/__init__.py` +- Create: `gateway/web_console/api/chat.py` +- Create: `gateway/web_console/api/sessions.py` +- Create: `gateway/web_console/api/workspace.py` +- Create: `gateway/web_console/api/approvals.py` +- Create: `gateway/web_console/api/memory.py` +- Create: `gateway/web_console/api/skills.py` +- Create: `gateway/web_console/api/cron.py` +- Create: `gateway/web_console/api/gateway_admin.py` +- Create: `gateway/web_console/api/settings.py` +- Create: `gateway/web_console/api/logs.py` +- Create: `gateway/web_console/api/browser.py` +- Create: `gateway/web_console/api/media.py` +- Create: `gateway/web_console/services/__init__.py` +- Create: `gateway/web_console/services/chat_service.py` +- Create: `gateway/web_console/services/session_service.py` +- Create: `gateway/web_console/services/workspace_service.py` +- Create: `gateway/web_console/services/approval_service.py` +- Create: `gateway/web_console/services/memory_service.py` +- Create: `gateway/web_console/services/skill_service.py` +- Create: `gateway/web_console/services/cron_service.py` +- Create: `gateway/web_console/services/gateway_service.py` +- Create: `gateway/web_console/services/settings_service.py` +- Create: `gateway/web_console/services/log_service.py` +- Create: `gateway/web_console/services/browser_service.py` +- Create: `gateway/web_console/schemas.py` + +### Existing backend files to modify + +- Modify: `gateway/platforms/api_server.py` +- Modify: `gateway/run.py` +- Modify: `run_agent.py` +- Modify: `hermes_cli/config.py` +- Modify: `pyproject.toml` +- Modify: `README.md` + +### Frontend app files to create + +- Create: `web_console/package.json` +- Create: `web_console/tsconfig.json` +- Create: `web_console/vite.config.ts` +- Create: `web_console/index.html` +- Create: `web_console/src/main.tsx` +- Create: `web_console/src/app/App.tsx` +- Create: `web_console/src/app/router.tsx` +- Create: `web_console/src/app/providers.tsx` +- Create: `web_console/src/app/theme.css` +- Create: `web_console/src/lib/api.ts` +- Create: `web_console/src/lib/events.ts` +- Create: `web_console/src/lib/types.ts` +- Create: `web_console/src/lib/utils.ts` +- Create: `web_console/src/store/uiStore.ts` +- Create: `web_console/src/store/sessionStore.ts` +- Create: `web_console/src/store/runStore.ts` +- Create: `web_console/src/store/workspaceStore.ts` +- Create: `web_console/src/components/layout/AppShell.tsx` +- Create: `web_console/src/components/layout/TopBar.tsx` +- Create: `web_console/src/components/layout/Sidebar.tsx` +- Create: `web_console/src/components/layout/Inspector.tsx` +- Create: `web_console/src/components/layout/BottomDrawer.tsx` +- Create: `web_console/src/components/chat/Transcript.tsx` +- Create: `web_console/src/components/chat/Composer.tsx` +- Create: `web_console/src/components/chat/MessageCard.tsx` +- Create: `web_console/src/components/chat/ToolTimeline.tsx` +- Create: `web_console/src/components/chat/ApprovalPrompt.tsx` +- Create: `web_console/src/components/chat/ClarifyPrompt.tsx` +- Create: `web_console/src/components/chat/RunStatusBar.tsx` +- Create: `web_console/src/components/sessions/SessionList.tsx` +- Create: `web_console/src/components/sessions/SessionPreview.tsx` +- Create: `web_console/src/components/workspace/FileTree.tsx` +- Create: `web_console/src/components/workspace/FileViewer.tsx` +- Create: `web_console/src/components/workspace/DiffViewer.tsx` +- Create: `web_console/src/components/workspace/CheckpointList.tsx` +- Create: `web_console/src/components/workspace/TerminalPanel.tsx` +- Create: `web_console/src/components/workspace/ProcessPanel.tsx` +- Create: `web_console/src/components/memory/MemoryList.tsx` +- Create: `web_console/src/components/skills/SkillList.tsx` +- Create: `web_console/src/components/cron/CronList.tsx` +- Create: `web_console/src/components/gateway/PlatformCards.tsx` +- Create: `web_console/src/components/settings/SettingsForm.tsx` +- Create: `web_console/src/components/logs/LogViewer.tsx` +- Create: `web_console/src/pages/ChatPage.tsx` +- Create: `web_console/src/pages/SessionsPage.tsx` +- Create: `web_console/src/pages/WorkspacePage.tsx` +- Create: `web_console/src/pages/AutomationsPage.tsx` +- Create: `web_console/src/pages/MemoryPage.tsx` +- Create: `web_console/src/pages/SkillsPage.tsx` +- Create: `web_console/src/pages/GatewayPage.tsx` +- Create: `web_console/src/pages/SettingsPage.tsx` +- Create: `web_console/src/pages/LogsPage.tsx` + +### Tests to create + +- Create: `tests/gateway/test_api_server_gui_mount.py` +- Create: `tests/web_console/test_event_bus.py` +- Create: `tests/web_console/test_chat_api.py` +- Create: `tests/web_console/test_sessions_api.py` +- Create: `tests/web_console/test_workspace_api.py` +- Create: `tests/web_console/test_approvals_api.py` +- Create: `tests/web_console/test_memory_api.py` +- Create: `tests/web_console/test_skills_api.py` +- Create: `tests/web_console/test_cron_api.py` +- Create: `tests/web_console/test_gateway_admin_api.py` +- Create: `tests/web_console/test_settings_api.py` +- Create: `tests/web_console/test_logs_api.py` +- Create: `tests/web_console/test_browser_api.py` +- Create: `tests/web_console/test_static_assets.py` +- Create: `web_console/src/**/*.test.tsx` +- Create: `web_console/playwright/chat.spec.ts` +- Create: `web_console/playwright/workspace.spec.ts` +- Create: `web_console/playwright/cron.spec.ts` + +--- + +## 2. Delivery phases + +1. Backend foundation and event model +2. Frontend shell and chat console MVP +3. Sessions, approvals, clarifications, and logs +4. Workspace, diffs, terminal, processes, checkpoints +5. Memory, skills, todos, subagents, session search +6. Cron, gateway/platform admin, pairing, delivery state +7. Settings, providers, auth status, plugins, skins, browser/media +8. Hardening, tests, docs, packaging, release gating + +--- + +## 3. Task-by-task implementation plan + +### Task 1: Add GUI config flags and serving mode + +**Objective:** Define how Hermes enables, binds, and secures the web console. + +**Files:** +- Modify: `hermes_cli/config.py` +- Modify: `pyproject.toml` +- Test: `tests/web_console/test_settings_api.py` + +**Step 1: Write failing tests for GUI config defaults** + +Add tests asserting defaults for: +- `gui.enabled` +- `gui.host` +- `gui.port` +- `gui.mount_path` +- `gui.require_api_key` +- `gui.open_browser` + +**Step 2: Run test to verify failure** + +Run: `python3 -m pytest tests/web_console/test_settings_api.py -q` +Expected: FAIL — missing GUI config keys. + +**Step 3: Add config defaults** + +Add a new section to `DEFAULT_CONFIG` in `hermes_cli/config.py`: + +```python +after_gui = { + "enabled": False, + "host": "127.0.0.1", + "port": 8642, + "mount_path": "/app", + "require_api_key": False, + "open_browser": False, + "dev_server_url": "", +} +``` + +Merge it under a top-level `gui` key. + +**Step 4: Add optional frontend packaging/build hints** + +Update `pyproject.toml` docs/comments and optional dev dependencies if needed, but keep runtime Python dependency footprint unchanged. + +**Step 5: Run tests** + +Run: `python3 -m pytest tests/web_console/test_settings_api.py -q` +Expected: PASS. + +**Step 6: Commit** + +```bash +git add hermes_cli/config.py pyproject.toml tests/web_console/test_settings_api.py +git commit -m "feat: add hermes gui config defaults" +``` + +### Task 2: Create a GUI backend package and router skeleton + +**Objective:** Establish a single backend mounting point for the GUI. + +**Files:** +- Create: `gateway/web_console/__init__.py` +- Create: `gateway/web_console/app.py` +- Create: `gateway/web_console/routes.py` +- Create: `gateway/web_console/static.py` +- Modify: `gateway/platforms/api_server.py` +- Test: `tests/gateway/test_api_server_gui_mount.py` + +**Step 1: Write failing tests for GUI route mounting** + +Test that when GUI is enabled the aiohttp app exposes: +- `/app/` +- `/api/gui/health` +- `/api/gui/meta` + +**Step 2: Run test to verify failure** + +Run: `python3 -m pytest tests/gateway/test_api_server_gui_mount.py -q` +Expected: FAIL — routes not found. + +**Step 3: Create backend package skeleton** + +Minimal example for `gateway/web_console/routes.py`: + +```python +from aiohttp import web + + +def register_gui_routes(app: web.Application) -> None: + app.router.add_get("/api/gui/health", lambda request: web.json_response({"status": "ok"})) + app.router.add_get("/api/gui/meta", lambda request: web.json_response({"product": "hermes-web-console"})) +``` + +**Step 4: Add GUI mount helper to API server** + +In `gateway/platforms/api_server.py`, call a helper such as: + +```python +from gateway.web_console.app import maybe_register_web_console +``` + +Then in `_register_routes(app)`: + +```python +maybe_register_web_console(app, adapter=self) +``` + +**Step 5: Run tests** + +Run: `python3 -m pytest tests/gateway/test_api_server_gui_mount.py -q` +Expected: PASS. + +**Step 6: Commit** + +```bash +git add gateway/web_console gateway/platforms/api_server.py tests/gateway/test_api_server_gui_mount.py +git commit -m "feat: mount hermes web console routes" +``` + +### Task 3: Define the GUI event bus and SSE transport + +**Objective:** Create one canonical streaming channel for run state, tools, approvals, clarifications, and system events. + +**Files:** +- Create: `gateway/web_console/event_bus.py` +- Create: `gateway/web_console/sse.py` +- Create: `gateway/web_console/state.py` +- Test: `tests/web_console/test_event_bus.py` + +**Step 1: Write failing tests for publish/subscribe behavior** + +Test that: +- subscribers receive events in order +- subscribers can disconnect cleanly +- events are namespaced by session id / run id + +**Step 2: Run test to verify failure** + +Run: `python3 -m pytest tests/web_console/test_event_bus.py -q` +Expected: FAIL — module missing. + +**Step 3: Implement minimal in-process event bus** + +Suggested interface: + +```python +@dataclass +class GuiEvent: + type: str + session_id: str + run_id: str | None + payload: dict[str, Any] + ts: float +``` + +Methods: +- `subscribe(channel: str) -> asyncio.Queue[GuiEvent]` +- `unsubscribe(channel: str, queue: asyncio.Queue) -> None` +- `publish(channel: str, event: GuiEvent) -> None` + +**Step 4: Implement SSE response helper** + +Support `text/event-stream` with: +- ping/keepalive +- JSON event payloads +- event type field + +**Step 5: Run tests** + +Run: `python3 -m pytest tests/web_console/test_event_bus.py -q` +Expected: PASS. + +**Step 6: Commit** + +```bash +git add gateway/web_console/event_bus.py gateway/web_console/sse.py gateway/web_console/state.py tests/web_console/test_event_bus.py +git commit -m "feat: add web console event bus and sse transport" +``` + +### Task 4: Instrument agent runs for GUI events + +**Objective:** Feed the GUI with live run state from the existing Hermes runtime. + +**Files:** +- Modify: `run_agent.py` +- Modify: `gateway/run.py` +- Create: `gateway/web_console/services/chat_service.py` +- Test: `tests/web_console/test_chat_api.py` + +**Step 1: Write failing tests for emitted run events** + +Test expected event sequence for one prompt: +- `run.started` +- `message.user` +- `tool.started` / `tool.finished` as applicable +- `message.assistant.delta` (optional) +- `message.assistant.completed` +- `run.completed` + +**Step 2: Run test to verify failure** + +Run: `python3 -m pytest tests/web_console/test_chat_api.py -q` +Expected: FAIL — no emitted GUI events. + +**Step 3: Add GUI event callback plumbing** + +In `run_agent.py`, add optional callback hooks on agent lifecycle: +- run start/end +- assistant delta/final +- tool call start/end +- approval request +- clarification request +- background result + +Keep callback optional and no-op when GUI is not active. + +**Step 4: Bridge gateway/CLI runtime state into chat service** + +Create a wrapper that runs Hermes and publishes GUI events to the bus. + +**Step 5: Run tests** + +Run: `python3 -m pytest tests/web_console/test_chat_api.py -q` +Expected: PASS. + +**Step 6: Commit** + +```bash +git add run_agent.py gateway/run.py gateway/web_console/services/chat_service.py tests/web_console/test_chat_api.py +git commit -m "feat: publish gui events from hermes runs" +``` + +### Task 5: Add chat endpoints for sessions, runs, retry, undo, and stop + +**Objective:** Make chat control first-class in the GUI backend. + +**Files:** +- Create: `gateway/web_console/api/chat.py` +- Modify: `gateway/web_console/routes.py` +- Test: `tests/web_console/test_chat_api.py` + +**Step 1: Write failing tests for chat endpoints** + +Endpoints to cover: +- `POST /api/gui/chat/send` +- `POST /api/gui/chat/stop` +- `POST /api/gui/chat/retry` +- `POST /api/gui/chat/undo` +- `GET /api/gui/chat/run/{run_id}` + +**Step 2: Run test to verify failure** + +Run: `python3 -m pytest tests/web_console/test_chat_api.py -q` +Expected: FAIL — routes missing. + +**Step 3: Implement endpoints with thin service layer** + +Response shape example: + +```json +{ + "ok": true, + "session_id": "sess_123", + "run_id": "run_123", + "status": "started" +} +``` + +**Step 4: Run tests** + +Run: `python3 -m pytest tests/web_console/test_chat_api.py -q` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add gateway/web_console/api/chat.py gateway/web_console/routes.py tests/web_console/test_chat_api.py +git commit -m "feat: add web console chat control api" +``` + +### Task 6: Add sessions API backed by existing Hermes storage + +**Objective:** Let the GUI browse, search, preview, resume, title, export, and delete sessions. + +**Files:** +- Create: `gateway/web_console/api/sessions.py` +- Create: `gateway/web_console/services/session_service.py` +- Test: `tests/web_console/test_sessions_api.py` + +**Step 1: Write failing tests for session list/preview/resume actions** + +Cover: +- `GET /api/gui/sessions` +- `GET /api/gui/sessions/{session_id}` +- `GET /api/gui/sessions/{session_id}/transcript` +- `POST /api/gui/sessions/{session_id}/title` +- `POST /api/gui/sessions/{session_id}/resume` +- `DELETE /api/gui/sessions/{session_id}` + +**Step 2: Run test to verify failure** + +Run: `python3 -m pytest tests/web_console/test_sessions_api.py -q` +Expected: FAIL. + +**Step 3: Reuse existing session/state storage** + +Do not create a new database. Wrap the existing session read/search/title/delete logic exposed by Hermes internals. + +**Step 4: Include lineage and recap fields** + +Session list items should expose: +- `session_id` +- `title` +- `last_active` +- `source` +- `workspace` +- `model` +- `token_summary` +- `parent_session_id` +- `has_tools` +- `has_attachments` + +**Step 5: Run tests** + +Run: `python3 -m pytest tests/web_console/test_sessions_api.py -q` +Expected: PASS. + +**Step 6: Commit** + +```bash +git add gateway/web_console/api/sessions.py gateway/web_console/services/session_service.py tests/web_console/test_sessions_api.py +git commit -m "feat: add sessions api for web console" +``` + +### Task 7: Add approvals and clarifications APIs + +**Objective:** Convert human-in-the-loop runtime pauses into GUI-native actions. + +**Files:** +- Create: `gateway/web_console/api/approvals.py` +- Create: `gateway/web_console/services/approval_service.py` +- Modify: `gateway/web_console/services/chat_service.py` +- Test: `tests/web_console/test_approvals_api.py` + +**Step 1: Write failing tests for approval and clarification lifecycle** + +Cover: +- approval request appears in pending list +- submit approve/deny decisions +- submit clarify responses +- timeout/expired request states + +**Step 2: Run test to verify failure** + +Run: `python3 -m pytest tests/web_console/test_approvals_api.py -q` +Expected: FAIL. + +**Step 3: Wrap existing callbacks into resumable service state** + +Expose endpoints: +- `GET /api/gui/human/pending` +- `POST /api/gui/human/approve` +- `POST /api/gui/human/deny` +- `POST /api/gui/human/clarify` + +**Step 4: Add secure secret/passphrase submission path** + +Never return stored values in API responses. Return only masked metadata. + +**Step 5: Run tests** + +Run: `python3 -m pytest tests/web_console/test_approvals_api.py -q` +Expected: PASS. + +**Step 6: Commit** + +```bash +git add gateway/web_console/api/approvals.py gateway/web_console/services/approval_service.py tests/web_console/test_approvals_api.py +git commit -m "feat: add human approval and clarification api" +``` + +### Task 8: Add workspace, diff, process, and rollback APIs + +**Objective:** Expose Hermes coding/operator workflow in the GUI. + +**Files:** +- Create: `gateway/web_console/api/workspace.py` +- Create: `gateway/web_console/services/workspace_service.py` +- Test: `tests/web_console/test_workspace_api.py` + +**Step 1: Write failing tests for workspace actions** + +Cover: +- list files / tree +- read file +- search files +- get diff/patch preview +- list checkpoints +- rollback workspace +- rollback file +- list processes +- process logs/kill + +**Step 2: Run test to verify failure** + +Run: `python3 -m pytest tests/web_console/test_workspace_api.py -q` +Expected: FAIL. + +**Step 3: Wrap existing file/process/checkpoint tools safely** + +Expose read-only defaults and explicit mutating endpoints. + +Example endpoints: +- `GET /api/gui/workspace/tree` +- `GET /api/gui/workspace/file` +- `GET /api/gui/workspace/search` +- `GET /api/gui/workspace/diff` +- `GET /api/gui/workspace/checkpoints` +- `POST /api/gui/workspace/rollback` +- `GET /api/gui/processes` +- `GET /api/gui/processes/{id}/log` +- `POST /api/gui/processes/{id}/kill` + +**Step 4: Run tests** + +Run: `python3 -m pytest tests/web_console/test_workspace_api.py -q` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add gateway/web_console/api/workspace.py gateway/web_console/services/workspace_service.py tests/web_console/test_workspace_api.py +git commit -m "feat: add workspace and rollback api" +``` + +### Task 9: Add memory and session-search APIs + +**Objective:** Surface durable memory and past-conversation recall. + +**Files:** +- Create: `gateway/web_console/api/memory.py` +- Create: `gateway/web_console/services/memory_service.py` +- Test: `tests/web_console/test_memory_api.py` + +**Step 1: Write failing tests for memory CRUD and session search** + +Cover: +- list memory entries +- add/replace/remove memory +- list user profile entries +- run session search + +**Step 2: Run test to verify failure** + +Run: `python3 -m pytest tests/web_console/test_memory_api.py -q` +Expected: FAIL. + +**Step 3: Implement thin wrappers over existing memory/session_search logic** + +Endpoints: +- `GET /api/gui/memory` +- `POST /api/gui/memory` +- `PATCH /api/gui/memory` +- `DELETE /api/gui/memory` +- `GET /api/gui/user-profile` +- `GET /api/gui/session-search` + +**Step 4: Run tests** + +Run: `python3 -m pytest tests/web_console/test_memory_api.py -q` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add gateway/web_console/api/memory.py gateway/web_console/services/memory_service.py tests/web_console/test_memory_api.py +git commit -m "feat: add memory and session search api" +``` + +### Task 10: Add skills API and session skill-loading controls + +**Objective:** Turn skills into a manageable GUI surface. + +**Files:** +- Create: `gateway/web_console/api/skills.py` +- Create: `gateway/web_console/services/skill_service.py` +- Test: `tests/web_console/test_skills_api.py` + +**Step 1: Write failing tests for skill list/view/manage endpoints** + +Cover: +- list installed skills +- get skill details +- install/remove/update if supported in current environment +- mark skill as loaded for session draft state + +**Step 2: Run test to verify failure** + +Run: `python3 -m pytest tests/web_console/test_skills_api.py -q` +Expected: FAIL. + +**Step 3: Implement wrappers over existing skills hub/service commands** + +Keep writes capability-gated if skill management requires extra permissions. + +**Step 4: Run tests** + +Run: `python3 -m pytest tests/web_console/test_skills_api.py -q` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add gateway/web_console/api/skills.py gateway/web_console/services/skill_service.py tests/web_console/test_skills_api.py +git commit -m "feat: add skills api for web console" +``` + +### Task 11: Add cron API and run history + +**Objective:** Make scheduled automations manageable from the GUI. + +**Files:** +- Create: `gateway/web_console/api/cron.py` +- Create: `gateway/web_console/services/cron_service.py` +- Test: `tests/web_console/test_cron_api.py` + +**Step 1: Write failing tests for cron CRUD** + +Cover: +- list jobs +- create job +- update job +- pause/resume/remove +- run-now +- fetch run history/output metadata + +**Step 2: Run test to verify failure** + +Run: `python3 -m pytest tests/web_console/test_cron_api.py -q` +Expected: FAIL. + +**Step 3: Implement wrappers over existing cronjob logic** + +**Step 4: Run tests** + +Run: `python3 -m pytest tests/web_console/test_cron_api.py -q` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add gateway/web_console/api/cron.py gateway/web_console/services/cron_service.py tests/web_console/test_cron_api.py +git commit -m "feat: add cron management api" +``` + +### Task 12: Add gateway/platform admin and pairing APIs + +**Objective:** Expose Hermes as a multi-platform operations console. + +**Files:** +- Create: `gateway/web_console/api/gateway_admin.py` +- Create: `gateway/web_console/services/gateway_service.py` +- Test: `tests/web_console/test_gateway_admin_api.py` + +**Step 1: Write failing tests for gateway overview endpoints** + +Cover: +- current gateway state +- platform list/status +- pairing list/approve/revoke +- service command endpoints if enabled +- delivery/home target read/update + +**Step 2: Run test to verify failure** + +Run: `python3 -m pytest tests/web_console/test_gateway_admin_api.py -q` +Expected: FAIL. + +**Step 3: Implement admin wrappers with safe capability checks** + +Do not silently allow service control on unsupported OS/runtime; return structured capability metadata. + +**Step 4: Run tests** + +Run: `python3 -m pytest tests/web_console/test_gateway_admin_api.py -q` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add gateway/web_console/api/gateway_admin.py gateway/web_console/services/gateway_service.py tests/web_console/test_gateway_admin_api.py +git commit -m "feat: add gateway admin api" +``` + +### Task 13: Add settings, auth-status, logs, browser, and media APIs + +**Objective:** Complete the backend surface needed for a full control plane. + +**Files:** +- Create: `gateway/web_console/api/settings.py` +- Create: `gateway/web_console/api/logs.py` +- Create: `gateway/web_console/api/browser.py` +- Create: `gateway/web_console/api/media.py` +- Create: `gateway/web_console/services/settings_service.py` +- Create: `gateway/web_console/services/log_service.py` +- Create: `gateway/web_console/services/browser_service.py` +- Test: `tests/web_console/test_settings_api.py` +- Test: `tests/web_console/test_logs_api.py` +- Test: `tests/web_console/test_browser_api.py` + +**Step 1: Write failing tests** + +Cover: +- current model/provider/auth status +- config sections safe read/update +- toolsets by platform +- logs tail/filter +- browser status/connect/disconnect metadata +- media upload/transcription/TTS metadata stubs + +**Step 2: Run tests to verify failure** + +Run: `python3 -m pytest tests/web_console/test_settings_api.py tests/web_console/test_logs_api.py tests/web_console/test_browser_api.py -q` +Expected: FAIL. + +**Step 3: Implement service wrappers** + +Protect secret-bearing fields by masking or omitting values. + +**Step 4: Run tests** + +Run: `python3 -m pytest tests/web_console/test_settings_api.py tests/web_console/test_logs_api.py tests/web_console/test_browser_api.py -q` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add gateway/web_console/api/settings.py gateway/web_console/api/logs.py gateway/web_console/api/browser.py gateway/web_console/api/media.py gateway/web_console/services/settings_service.py gateway/web_console/services/log_service.py gateway/web_console/services/browser_service.py tests/web_console/test_settings_api.py tests/web_console/test_logs_api.py tests/web_console/test_browser_api.py +git commit -m "feat: add settings logs browser and media api" +``` + +### Task 14: Create the frontend app scaffold + +**Objective:** Add a standalone SPA source tree for Hermes Web Console. + +**Files:** +- Create: `web_console/package.json` +- Create: `web_console/tsconfig.json` +- Create: `web_console/vite.config.ts` +- Create: `web_console/index.html` +- Create: `web_console/src/main.tsx` +- Create: `web_console/src/app/App.tsx` +- Create: `web_console/src/app/router.tsx` +- Create: `web_console/src/app/providers.tsx` +- Create: `web_console/src/app/theme.css` + +**Step 1: Write failing frontend smoke test** + +Add a Vitest render test that mounts `App` and expects navigation labels: +- Chat +- Sessions +- Workspace +- Automations +- Memory +- Skills +- Gateway +- Settings +- Logs + +**Step 2: Run test to verify failure** + +Run: `cd web_console && npm test -- --run` +Expected: FAIL — app missing. + +**Step 3: Create the scaffold** + +Use React + TypeScript + Vite, with one app shell and placeholder routes. + +**Step 4: Run frontend tests** + +Run: `cd web_console && npm test -- --run` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add web_console +git commit -m "feat: scaffold hermes web console frontend" +``` + +### Task 15: Build the application shell, router, stores, and API client + +**Objective:** Create the reusable frontend foundation. + +**Files:** +- Create: `web_console/src/lib/api.ts` +- Create: `web_console/src/lib/events.ts` +- Create: `web_console/src/lib/types.ts` +- Create: `web_console/src/lib/utils.ts` +- Create: `web_console/src/store/uiStore.ts` +- Create: `web_console/src/store/sessionStore.ts` +- Create: `web_console/src/store/runStore.ts` +- Create: `web_console/src/store/workspaceStore.ts` +- Create: `web_console/src/components/layout/*.tsx` + +**Step 1: Write failing tests for shell behavior** + +Test: +- nav renders +- route changes work +- right inspector toggles +- bottom drawer opens + +**Step 2: Run tests to verify failure** + +Run: `cd web_console && npm test -- --run` +Expected: FAIL. + +**Step 3: Implement app shell and stores** + +Include: +- top bar with model/provider/run state +- sidebar nav +- inspector tabs +- bottom drawer for terminal/logs/processes + +**Step 4: Run tests** + +Run: `cd web_console && npm test -- --run` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add web_console/src +git commit -m "feat: add web console shell stores and api client" +``` + +### Task 16: Build the Chat page with transcript, composer, tool timeline, approvals, and streaming + +**Objective:** Deliver the browser experience that matches Hermes core value. + +**Files:** +- Create: `web_console/src/pages/ChatPage.tsx` +- Create: `web_console/src/components/chat/*.tsx` +- Test: `web_console/src/components/chat/*.test.tsx` +- Test: `web_console/playwright/chat.spec.ts` + +**Step 1: Write failing component tests** + +Cover: +- transcript rendering for user/assistant/tool/system messages +- composer send +- tool timeline expand/collapse +- approval modal appears +- clarification form appears +- stop button dispatches action + +**Step 2: Run tests to verify failure** + +Run: `cd web_console && npm test -- --run` +Expected: FAIL. + +**Step 3: Implement Chat page** + +Must include: +- transcript stream +- image attach control +- multiline composer +- stop/retry/undo/new chat actions +- run status bar +- pending approval/clarify surfaces + +**Step 4: Add Playwright smoke flow** + +Flow: +- open app +- start chat +- verify streamed content placeholder / mocked event flow + +**Step 5: Run tests** + +Run: +- `cd web_console && npm test -- --run` +- `cd web_console && npm run test:e2e` +Expected: PASS. + +**Step 6: Commit** + +```bash +git add web_console/src/pages/ChatPage.tsx web_console/src/components/chat web_console/playwright/chat.spec.ts +git commit -m "feat: implement web console chat experience" +``` + +### Task 17: Build Sessions page and transcript preview + +**Objective:** Expose Hermes durable history visually. + +**Files:** +- Create: `web_console/src/pages/SessionsPage.tsx` +- Create: `web_console/src/components/sessions/*.tsx` +- Test: `web_console/src/components/sessions/*.test.tsx` + +**Step 1: Write failing tests** + +Cover: +- session list rendering +- filter/search input +- preview panel +- resume/export/delete actions + +**Step 2: Run tests to verify failure** + +Run: `cd web_console && npm test -- --run` +Expected: FAIL. + +**Step 3: Implement Sessions page** + +**Step 4: Run tests** + +Run: `cd web_console && npm test -- --run` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add web_console/src/pages/SessionsPage.tsx web_console/src/components/sessions +git commit -m "feat: add sessions browser to web console" +``` + +### Task 18: Build Workspace page with file tree, viewer, diff, checkpoints, terminal, and processes + +**Objective:** Make the GUI viable for development and ops workflows. + +**Files:** +- Create: `web_console/src/pages/WorkspacePage.tsx` +- Create: `web_console/src/components/workspace/*.tsx` +- Test: `web_console/src/components/workspace/*.test.tsx` +- Test: `web_console/playwright/workspace.spec.ts` + +**Step 1: Write failing tests** + +Cover: +- file tree render/select +- file contents view +- diff view +- checkpoint list +- terminal panel +- process panel + +**Step 2: Run tests to verify failure** + +Run: `cd web_console && npm test -- --run` +Expected: FAIL. + +**Step 3: Implement Workspace page** + +**Step 4: Run tests** + +Run: +- `cd web_console && npm test -- --run` +- `cd web_console && npm run test:e2e` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add web_console/src/pages/WorkspacePage.tsx web_console/src/components/workspace web_console/playwright/workspace.spec.ts +git commit -m "feat: add workspace tools to web console" +``` + +### Task 19: Build Memory, Skills, and Automations pages + +**Objective:** Surface Hermes-native long-term systems. + +**Files:** +- Create: `web_console/src/pages/MemoryPage.tsx` +- Create: `web_console/src/pages/SkillsPage.tsx` +- Create: `web_console/src/pages/AutomationsPage.tsx` +- Create: `web_console/src/components/memory/*.tsx` +- Create: `web_console/src/components/skills/*.tsx` +- Create: `web_console/src/components/cron/*.tsx` +- Test: `web_console/src/components/**/*.test.tsx` +- Test: `web_console/playwright/cron.spec.ts` + +**Step 1: Write failing tests** + +Cover: +- memory list + edit +- session search results +- skills list + inspect +- cron list + create dialog + +**Step 2: Run tests to verify failure** + +Run: `cd web_console && npm test -- --run` +Expected: FAIL. + +**Step 3: Implement the three pages** + +**Step 4: Run tests** + +Run: +- `cd web_console && npm test -- --run` +- `cd web_console && npm run test:e2e` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add web_console/src/pages/MemoryPage.tsx web_console/src/pages/SkillsPage.tsx web_console/src/pages/AutomationsPage.tsx web_console/src/components/memory web_console/src/components/skills web_console/src/components/cron web_console/playwright/cron.spec.ts +git commit -m "feat: add memory skills and automations pages" +``` + +### Task 20: Build Gateway, Settings, and Logs pages + +**Objective:** Turn the GUI into a true Hermes control plane. + +**Files:** +- Create: `web_console/src/pages/GatewayPage.tsx` +- Create: `web_console/src/pages/SettingsPage.tsx` +- Create: `web_console/src/pages/LogsPage.tsx` +- Create: `web_console/src/components/gateway/*.tsx` +- Create: `web_console/src/components/settings/*.tsx` +- Create: `web_console/src/components/logs/*.tsx` +- Test: `web_console/src/components/**/*.test.tsx` + +**Step 1: Write failing tests** + +Cover: +- platform cards render statuses +- pairing actions render +- settings form renders sections +- logs viewer supports tabs/filtering + +**Step 2: Run tests to verify failure** + +Run: `cd web_console && npm test -- --run` +Expected: FAIL. + +**Step 3: Implement the three pages** + +**Step 4: Run tests** + +Run: `cd web_console && npm test -- --run` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add web_console/src/pages/GatewayPage.tsx web_console/src/pages/SettingsPage.tsx web_console/src/pages/LogsPage.tsx web_console/src/components/gateway web_console/src/components/settings web_console/src/components/logs +git commit -m "feat: add gateway settings and logs pages" +``` + +### Task 21: Serve built frontend assets from aiohttp and support local dev mode + +**Objective:** Integrate the SPA into the existing Hermes API server cleanly. + +**Files:** +- Modify: `gateway/web_console/static.py` +- Modify: `gateway/web_console/app.py` +- Modify: `gateway/platforms/api_server.py` +- Create: `tests/web_console/test_static_assets.py` + +**Step 1: Write failing tests for static asset serving** + +Cover: +- serves `index.html` from `/app/` +- serves hashed JS/CSS assets +- SPA fallback works for nested routes like `/app/sessions` + +**Step 2: Run tests to verify failure** + +Run: `python3 -m pytest tests/web_console/test_static_assets.py -q` +Expected: FAIL. + +**Step 3: Implement static serving** + +Support two modes: +- production build from `gateway/web_console/static_dist/` +- development proxy to `config.gui.dev_server_url` + +**Step 4: Run tests** + +Run: `python3 -m pytest tests/web_console/test_static_assets.py -q` +Expected: PASS. + +**Step 5: Commit** + +```bash +git add gateway/web_console/static.py gateway/web_console/app.py gateway/platforms/api_server.py tests/web_console/test_static_assets.py +git commit -m "feat: serve hermes web console assets" +``` + +### Task 22: Add docs, operator guidance, and packaging scripts + +**Objective:** Make the GUI discoverable and maintainable. + +**Files:** +- Modify: `README.md` +- Create: `website/docs/user-guide/features/web-console.md` +- Create: `website/docs/developer-guide/web-console-architecture.md` +- Modify: `package.json` +- Modify: `web_console/package.json` + +**Step 1: Write failing docs/build checklist** + +Create a checklist in the PR description or local notes for: +- dev mode +- prod build +- start server and open GUI +- auth/approval flow + +**Step 2: Add scripts** + +Examples: + +```json +{ + "scripts": { + "gui:install": "cd web_console && npm install", + "gui:dev": "cd web_console && npm run dev", + "gui:build": "cd web_console && npm run build", + "gui:test": "cd web_console && npm test -- --run" + } +} +``` + +**Step 3: Write docs** + +Document: +- architecture +- local development +- how `/v1` and `/api/gui` differ +- security model +- release/build flow + +**Step 4: Verify docs and scripts** + +Run: +- `npm run gui:build` +- `python3 -m pytest tests/web_console -q` + +Expected: PASS. + +**Step 5: Commit** + +```bash +git add README.md website/docs/user-guide/features/web-console.md website/docs/developer-guide/web-console-architecture.md package.json web_console/package.json +git commit -m "docs: add hermes web console documentation" +``` + +### Task 23: End-to-end stabilization and release gate + +**Objective:** Ensure the GUI is production-ready enough for an initial release. + +**Files:** +- Modify: any files touched in prior tasks +- Test: all GUI/backend/frontend tests + +**Step 1: Run backend tests** + +Run: `python3 -m pytest tests/gateway/test_api_server_gui_mount.py tests/web_console -q` +Expected: PASS. + +**Step 2: Run frontend unit tests** + +Run: `cd web_console && npm test -- --run` +Expected: PASS. + +**Step 3: Run Playwright tests** + +Run: `cd web_console && npm run test:e2e` +Expected: PASS. + +**Step 4: Run full smoke test manually** + +Manual checklist: +- open `/app/` +- start a chat +- watch streamed tool event(s) +- approve/deny a request +- open a session +- inspect a file/diff +- list cron jobs +- view gateway state +- browse logs + +**Step 5: Commit** + +```bash +git add -A +git commit -m "feat: ship hermes web console v1" +``` + +--- + +## 4. API contracts that must exist by the end + +### Core endpoints + +- `GET /api/gui/health` +- `GET /api/gui/meta` +- `GET /api/gui/stream/session/{session_id}` +- `POST /api/gui/chat/send` +- `POST /api/gui/chat/stop` +- `POST /api/gui/chat/retry` +- `POST /api/gui/chat/undo` +- `GET /api/gui/sessions` +- `GET /api/gui/sessions/{session_id}` +- `GET /api/gui/sessions/{session_id}/transcript` +- `POST /api/gui/sessions/{session_id}/title` +- `DELETE /api/gui/sessions/{session_id}` +- `GET /api/gui/workspace/tree` +- `GET /api/gui/workspace/file` +- `GET /api/gui/workspace/search` +- `GET /api/gui/workspace/diff` +- `GET /api/gui/workspace/checkpoints` +- `POST /api/gui/workspace/rollback` +- `GET /api/gui/processes` +- `GET /api/gui/processes/{process_id}/log` +- `POST /api/gui/processes/{process_id}/kill` +- `GET /api/gui/human/pending` +- `POST /api/gui/human/approve` +- `POST /api/gui/human/deny` +- `POST /api/gui/human/clarify` +- `GET /api/gui/memory` +- `GET /api/gui/user-profile` +- `GET /api/gui/session-search` +- `GET /api/gui/skills` +- `GET /api/gui/skills/{name}` +- `GET /api/gui/cron/jobs` +- `POST /api/gui/cron/jobs` +- `PATCH /api/gui/cron/jobs/{job_id}` +- `POST /api/gui/cron/jobs/{job_id}/run` +- `GET /api/gui/gateway/overview` +- `GET /api/gui/gateway/platforms` +- `GET /api/gui/gateway/pairing` +- `POST /api/gui/gateway/pairing/approve` +- `POST /api/gui/gateway/pairing/revoke` +- `GET /api/gui/settings` +- `PATCH /api/gui/settings` +- `GET /api/gui/logs` +- `GET /api/gui/browser/status` +- `POST /api/gui/browser/connect` +- `POST /api/gui/browser/disconnect` +- `POST /api/gui/media/upload` +- `POST /api/gui/media/transcribe` +- `POST /api/gui/media/tts` + +--- + +## 5. Event types that must exist by the end + +- `run.started` +- `run.completed` +- `run.failed` +- `message.user` +- `message.assistant.delta` +- `message.assistant.completed` +- `tool.started` +- `tool.stdout` +- `tool.completed` +- `tool.failed` +- `approval.requested` +- `approval.resolved` +- `clarify.requested` +- `clarify.resolved` +- `session.updated` +- `todo.updated` +- `subagent.started` +- `subagent.updated` +- `subagent.completed` +- `background_task.started` +- `background_task.completed` +- `process.updated` +- `checkpoint.created` +- `rollback.completed` + +--- + +## 6. Acceptance criteria for “fully featured Hermes GUI” + +The build is complete when a user can: + +1. Open a browser to Hermes and land in a real app shell, not a single chat card. +2. Start, stop, retry, undo, and resume sessions. +3. See live tool activity during a run. +4. Approve risky actions and answer clarifications inline. +5. Browse sessions, inspect transcripts, and export/delete them. +6. Inspect files, diffs, checkpoints, processes, and terminal output. +7. View and edit memory, search past sessions, and inspect skills. +8. Create and manage cron jobs. +9. Monitor gateway/platform health, pairing requests, and service state. +10. Adjust settings for models/providers/toolsets/voice/browser/theme. +11. Read logs and recover from failures without dropping to CLI for routine workflows. + +--- + +## 7. Recommended implementation order if you want maximum value early + +1. Tasks 1-5 +2. Task 14-16 +3. Task 6-7 +4. Task 21 +5. Task 8 +6. Task 9-11 +7. Task 12-13 +8. Task 17-20 +9. Task 22-23 + +This gets a working, inspectable chat console into users’ hands quickly while preserving a path to the full control plane. + +--- + +## 8. Final handoff note + +Plan complete and saved. Ready to execute using subagent-driven-development — dispatch a fresh subagent per task, review spec compliance after each task, then perform code-quality review before continuing. diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 46b7c6836..d6c403c98 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -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: diff --git a/gateway/platforms/api_server_ui.py b/gateway/platforms/api_server_ui.py new file mode 100644 index 000000000..6e68b74f0 --- /dev/null +++ b/gateway/platforms/api_server_ui.py @@ -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 = ''' + + + + + Hermes Browser UI + + + +
+ + +
+
+
+ Browser Chat +

Hermes can use tools through the existing backend. Tool calls appear inline.

+
+ +
+ +
+ +
+ +
+ +
+ + +
+
+
+
+
+ + + + +''' + return template.replace('__CONFIG_JSON__', config_json) diff --git a/gateway/web_console/__init__.py b/gateway/web_console/__init__.py new file mode 100644 index 000000000..38f51757d --- /dev/null +++ b/gateway/web_console/__init__.py @@ -0,0 +1,5 @@ +"""Hermes Web Console backend package.""" + +from .app import maybe_register_web_console + +__all__ = ["maybe_register_web_console"] diff --git a/gateway/web_console/api/__init__.py b/gateway/web_console/api/__init__.py new file mode 100644 index 000000000..1fa67d965 --- /dev/null +++ b/gateway/web_console/api/__init__.py @@ -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", +] diff --git a/gateway/web_console/api/approvals.py b/gateway/web_console/api/approvals.py new file mode 100644 index 000000000..7846faa65 --- /dev/null +++ b/gateway/web_console/api/approvals.py @@ -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) diff --git a/gateway/web_console/api/browser.py b/gateway/web_console/api/browser.py new file mode 100644 index 000000000..6591e88cb --- /dev/null +++ b/gateway/web_console/api/browser.py @@ -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) diff --git a/gateway/web_console/api/chat.py b/gateway/web_console/api/chat.py new file mode 100644 index 000000000..01638b51f --- /dev/null +++ b/gateway/web_console/api/chat.py @@ -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) diff --git a/gateway/web_console/api/commands.py b/gateway/web_console/api/commands.py new file mode 100644 index 000000000..1a553b699 --- /dev/null +++ b/gateway/web_console/api/commands.py @@ -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) \ No newline at end of file diff --git a/gateway/web_console/api/credentials.py b/gateway/web_console/api/credentials.py new file mode 100644 index 000000000..99b94d0b8 --- /dev/null +++ b/gateway/web_console/api/credentials.py @@ -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) diff --git a/gateway/web_console/api/cron.py b/gateway/web_console/api/cron.py new file mode 100644 index 000000000..f3795d570 --- /dev/null +++ b/gateway/web_console/api/cron.py @@ -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) diff --git a/gateway/web_console/api/gateway_admin.py b/gateway/web_console/api/gateway_admin.py new file mode 100644 index 000000000..26c7b8d2f --- /dev/null +++ b/gateway/web_console/api/gateway_admin.py @@ -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) diff --git a/gateway/web_console/api/logs.py b/gateway/web_console/api/logs.py new file mode 100644 index 000000000..b9158b450 --- /dev/null +++ b/gateway/web_console/api/logs.py @@ -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) diff --git a/gateway/web_console/api/mcp.py b/gateway/web_console/api/mcp.py new file mode 100644 index 000000000..b3025ae95 --- /dev/null +++ b/gateway/web_console/api/mcp.py @@ -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) diff --git a/gateway/web_console/api/media.py b/gateway/web_console/api/media.py new file mode 100644 index 000000000..ad5582f7d --- /dev/null +++ b/gateway/web_console/api/media.py @@ -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) diff --git a/gateway/web_console/api/memory.py b/gateway/web_console/api/memory.py new file mode 100644 index 000000000..c6fa305b2 --- /dev/null +++ b/gateway/web_console/api/memory.py @@ -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) diff --git a/gateway/web_console/api/metrics.py b/gateway/web_console/api/metrics.py new file mode 100644 index 000000000..02fa0b841 --- /dev/null +++ b/gateway/web_console/api/metrics.py @@ -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) diff --git a/gateway/web_console/api/missions.py b/gateway/web_console/api/missions.py new file mode 100644 index 000000000..82d15dd81 --- /dev/null +++ b/gateway/web_console/api/missions.py @@ -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) diff --git a/gateway/web_console/api/models_api.py b/gateway/web_console/api/models_api.py new file mode 100644 index 000000000..ad79c790b --- /dev/null +++ b/gateway/web_console/api/models_api.py @@ -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) diff --git a/gateway/web_console/api/plugins.py b/gateway/web_console/api/plugins.py new file mode 100644 index 000000000..768b03761 --- /dev/null +++ b/gateway/web_console/api/plugins.py @@ -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) diff --git a/gateway/web_console/api/profiles.py b/gateway/web_console/api/profiles.py new file mode 100644 index 000000000..28c1227c0 --- /dev/null +++ b/gateway/web_console/api/profiles.py @@ -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) diff --git a/gateway/web_console/api/sessions.py b/gateway/web_console/api/sessions.py new file mode 100644 index 000000000..313f63929 --- /dev/null +++ b/gateway/web_console/api/sessions.py @@ -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) + diff --git a/gateway/web_console/api/settings.py b/gateway/web_console/api/settings.py new file mode 100644 index 000000000..5efd12790 --- /dev/null +++ b/gateway/web_console/api/settings.py @@ -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) diff --git a/gateway/web_console/api/skills.py b/gateway/web_console/api/skills.py new file mode 100644 index 000000000..d051e1718 --- /dev/null +++ b/gateway/web_console/api/skills.py @@ -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. + 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) + diff --git a/gateway/web_console/api/system.py b/gateway/web_console/api/system.py new file mode 100644 index 000000000..d6b9a735a --- /dev/null +++ b/gateway/web_console/api/system.py @@ -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) diff --git a/gateway/web_console/api/tools.py b/gateway/web_console/api/tools.py new file mode 100644 index 000000000..869cd4e78 --- /dev/null +++ b/gateway/web_console/api/tools.py @@ -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) diff --git a/gateway/web_console/api/usage.py b/gateway/web_console/api/usage.py new file mode 100644 index 000000000..d6cc7a70a --- /dev/null +++ b/gateway/web_console/api/usage.py @@ -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) diff --git a/gateway/web_console/api/version.py b/gateway/web_console/api/version.py new file mode 100644 index 000000000..3d522b98f --- /dev/null +++ b/gateway/web_console/api/version.py @@ -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) diff --git a/gateway/web_console/api/workspace.py b/gateway/web_console/api/workspace.py new file mode 100644 index 000000000..3ec19a460 --- /dev/null +++ b/gateway/web_console/api/workspace.py @@ -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) + diff --git a/gateway/web_console/app.py b/gateway/web_console/app.py new file mode 100644 index 000000000..b5c7ae98d --- /dev/null +++ b/gateway/web_console/app.py @@ -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) diff --git a/gateway/web_console/event_bus.py b/gateway/web_console/event_bus.py new file mode 100644 index 000000000..b2cc21209 --- /dev/null +++ b/gateway/web_console/event_bus.py @@ -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, ())) diff --git a/gateway/web_console/routes.py b/gateway/web_console/routes.py new file mode 100644 index 000000000..4c08de168 --- /dev/null +++ b/gateway/web_console/routes.py @@ -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) diff --git a/gateway/web_console/services/__init__.py b/gateway/web_console/services/__init__.py new file mode 100644 index 000000000..93af8e175 --- /dev/null +++ b/gateway/web_console/services/__init__.py @@ -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", +] diff --git a/gateway/web_console/services/approval_service.py b/gateway/web_console/services/approval_service.py new file mode 100644 index 000000000..0099de588 --- /dev/null +++ b/gateway/web_console/services/approval_service.py @@ -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 diff --git a/gateway/web_console/services/browser_service.py b/gateway/web_console/services/browser_service.py new file mode 100644 index 000000000..735c65852 --- /dev/null +++ b/gateway/web_console/services/browser_service.py @@ -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 diff --git a/gateway/web_console/services/chat_service.py b/gateway/web_console/services/chat_service.py new file mode 100644 index 000000000..3438b4ba6 --- /dev/null +++ b/gateway/web_console/services/chat_service.py @@ -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 "" diff --git a/gateway/web_console/services/cron_service.py b/gateway/web_console/services/cron_service.py new file mode 100644 index 000000000..750206ef9 --- /dev/null +++ b/gateway/web_console/services/cron_service.py @@ -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"), + } diff --git a/gateway/web_console/services/gateway_service.py b/gateway/web_console/services/gateway_service.py new file mode 100644 index 000000000..6b0a2b49f --- /dev/null +++ b/gateway/web_console/services/gateway_service.py @@ -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" diff --git a/gateway/web_console/services/log_service.py b/gateway/web_console/services/log_service.py new file mode 100644 index 000000000..53cf746a4 --- /dev/null +++ b/gateway/web_console/services/log_service.py @@ -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:] diff --git a/gateway/web_console/services/memory_service.py b/gateway/web_console/services/memory_service.py new file mode 100644 index 000000000..c25f5c8e9 --- /dev/null +++ b/gateway/web_console/services/memory_service.py @@ -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 diff --git a/gateway/web_console/services/session_service.py b/gateway/web_console/services/session_service.py new file mode 100644 index 000000000..7bd1cdda1 --- /dev/null +++ b/gateway/web_console/services/session_service.py @@ -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) diff --git a/gateway/web_console/services/settings_service.py b/gateway/web_console/services/settings_service.py new file mode 100644 index 000000000..be2000452 --- /dev/null +++ b/gateway/web_console/services/settings_service.py @@ -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 "***" diff --git a/gateway/web_console/services/skill_service.py b/gateway/web_console/services/skill_service.py new file mode 100644 index 000000000..5690d7ae2 --- /dev/null +++ b/gateway/web_console/services/skill_service.py @@ -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, + } diff --git a/gateway/web_console/services/workspace_service.py b/gateway/web_console/services/workspace_service.py new file mode 100644 index 000000000..078cac057 --- /dev/null +++ b/gateway/web_console/services/workspace_service.py @@ -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}") diff --git a/gateway/web_console/sse.py b/gateway/web_console/sse.py new file mode 100644 index 000000000..a1cce8154 --- /dev/null +++ b/gateway/web_console/sse.py @@ -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 diff --git a/gateway/web_console/state.py b/gateway/web_console/state.py new file mode 100644 index 000000000..d45c091ee --- /dev/null +++ b/gateway/web_console/state.py @@ -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 diff --git a/gateway/web_console/static.py b/gateway/web_console/static.py new file mode 100644 index 000000000..c178cb7c0 --- /dev/null +++ b/gateway/web_console/static.py @@ -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 """ + + + + + Hermes Web Console + + + +
+

Hermes Web Console

+

This is the initial GUI backend placeholder mounted by the API server.

+

Backend status endpoints are available at /api/gui/health and /api/gui/meta.

+
+ + +""" diff --git a/run-gui.sh b/run-gui.sh new file mode 100755 index 000000000..c29f988c3 --- /dev/null +++ b/run-gui.sh @@ -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 diff --git a/setup-gui.sh b/setup-gui.sh new file mode 100755 index 000000000..2fd884ea6 --- /dev/null +++ b/setup-gui.sh @@ -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 "" diff --git a/tests/gateway/test_api_server_gui_mount.py b/tests/gateway/test_api_server_gui_mount.py new file mode 100644 index 000000000..20825a574 --- /dev/null +++ b/tests/gateway/test_api_server_gui_mount.py @@ -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" diff --git a/tests/web_console/test_approvals_api.py b/tests/web_console/test_approvals_api.py new file mode 100644 index 000000000..f04e703f0 --- /dev/null +++ b/tests/web_console/test_approvals_api.py @@ -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() diff --git a/tests/web_console/test_browser_api.py b/tests/web_console/test_browser_api.py new file mode 100644 index 000000000..61a09cdd1 --- /dev/null +++ b/tests/web_console/test_browser_api.py @@ -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() diff --git a/tests/web_console/test_chat_api.py b/tests/web_console/test_chat_api.py new file mode 100644 index 000000000..ad687d4cd --- /dev/null +++ b/tests/web_console/test_chat_api.py @@ -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() diff --git a/tests/web_console/test_commands_api.py b/tests/web_console/test_commands_api.py new file mode 100644 index 000000000..19a72f655 --- /dev/null +++ b/tests/web_console/test_commands_api.py @@ -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() \ No newline at end of file diff --git a/tests/web_console/test_cron_api.py b/tests/web_console/test_cron_api.py new file mode 100644 index 000000000..8e7679a2a --- /dev/null +++ b/tests/web_console/test_cron_api.py @@ -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"] diff --git a/tests/web_console/test_event_bus.py b/tests/web_console/test_event_bus.py new file mode 100644 index 000000000..3fa06194b --- /dev/null +++ b/tests/web_console/test_event_bus.py @@ -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 diff --git a/tests/web_console/test_gateway_admin_api.py b/tests/web_console/test_gateway_admin_api.py new file mode 100644 index 000000000..b544160d8 --- /dev/null +++ b/tests/web_console/test_gateway_admin_api.py @@ -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() diff --git a/tests/web_console/test_logs_api.py b/tests/web_console/test_logs_api.py new file mode 100644 index 000000000..99b8a1525 --- /dev/null +++ b/tests/web_console/test_logs_api.py @@ -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() diff --git a/tests/web_console/test_memory_api.py b/tests/web_console/test_memory_api.py new file mode 100644 index 000000000..bb342120f --- /dev/null +++ b/tests/web_console/test_memory_api.py @@ -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¤t_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" diff --git a/tests/web_console/test_sessions_api.py b/tests/web_console/test_sessions_api.py new file mode 100644 index 000000000..1d7bb058e --- /dev/null +++ b/tests/web_console/test_sessions_api.py @@ -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() diff --git a/tests/web_console/test_settings_api.py b/tests/web_console/test_settings_api.py new file mode 100644 index 000000000..f12afecb3 --- /dev/null +++ b/tests/web_console/test_settings_api.py @@ -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() diff --git a/tests/web_console/test_skills_api.py b/tests/web_console/test_skills_api.py new file mode 100644 index 000000000..fd1fb86d6 --- /dev/null +++ b/tests/web_console/test_skills_api.py @@ -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() diff --git a/tests/web_console/test_workspace_api.py b/tests/web_console/test_workspace_api.py new file mode 100644 index 000000000..ccdefd3ca --- /dev/null +++ b/tests/web_console/test_workspace_api.py @@ -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"}] diff --git a/web_console/CHANGELOG.md b/web_console/CHANGELOG.md new file mode 100644 index 000000000..6017bf899 --- /dev/null +++ b/web_console/CHANGELOG.md @@ -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 `` 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. diff --git a/web_console/Dockerfile.frontend b/web_console/Dockerfile.frontend new file mode 100644 index 000000000..c868bf498 --- /dev/null +++ b/web_console/Dockerfile.frontend @@ -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;"] diff --git a/web_console/README.md b/web_console/README.md new file mode 100644 index 000000000..f37d387f3 --- /dev/null +++ b/web_console/README.md @@ -0,0 +1,75 @@ +

+ Hermes Agent +

+ +# 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.* diff --git a/web_console/icons/icon-192.png b/web_console/icons/icon-192.png new file mode 100644 index 000000000..df149a636 Binary files /dev/null and b/web_console/icons/icon-192.png differ diff --git a/web_console/icons/icon-512.png b/web_console/icons/icon-512.png new file mode 100644 index 000000000..df149a636 Binary files /dev/null and b/web_console/icons/icon-512.png differ diff --git a/web_console/index.html b/web_console/index.html new file mode 100644 index 000000000..138cd4567 --- /dev/null +++ b/web_console/index.html @@ -0,0 +1,27 @@ + + + + + + + + + + + Hermes Web Console + + + + + +
+ + + + diff --git a/web_console/manifest.json b/web_console/manifest.json new file mode 100644 index 000000000..0e6e0eb29 --- /dev/null +++ b/web_console/manifest.json @@ -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 +} diff --git a/web_console/package-lock.json b/web_console/package-lock.json new file mode 100644 index 000000000..a30576b76 --- /dev/null +++ b/web_console/package-lock.json @@ -0,0 +1,5328 @@ +{ + "name": "hermes-web-console", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hermes-web-console", + "version": "0.1.0", + "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" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-syntax-highlighter": { + "version": "15.5.13", + "resolved": "https://registry.npmjs.org/@types/react-syntax-highlighter/-/react-syntax-highlighter-15.5.13.tgz", + "integrity": "sha512-uLGJ87j6Sz8UaBAooU0T6lWJ0dBmjZgN1PZTrj05TNql2/XpC6+4HhMT5syIdFUUt+FASfCeLLv4kBygNU+8qA==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.13", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.13.tgz", + "integrity": "sha512-BL2sTuHOdy0YT1lYieUxTw/QMtPBC3pmlJC6xk8BBYVv6vcw3SGdKemQ+Xsx9ik2F/lYDO9tqsFQH1r9PFuHKw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001782", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz", + "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.329", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz", + "integrity": "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/highlightjs-vue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", + "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==", + "license": "CC0-1.0" + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/katex": { + "version": "0.16.44", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.44.tgz", + "integrity": "sha512-EkxoDTk8ufHqHlf9QxGwcxeLkWRR3iOuYfRpfORgYfqc8s13bgb+YtRY59NK5ZpRaCwq1kqA6a5lpX8C/eLphQ==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "license": "MIT", + "peer": true + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-syntax-highlighter": { + "version": "16.1.1", + "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", + "integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "highlight.js": "^10.4.1", + "highlightjs-vue": "^1.0.0", + "lowlight": "^1.17.0", + "prismjs": "^1.30.0", + "refractor": "^5.0.0" + }, + "engines": { + "node": ">= 16.20.2" + }, + "peerDependencies": { + "react": ">= 0.14.0" + } + }, + "node_modules/react-syntax-highlighter/node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/react-syntax-highlighter/node_modules/lowlight": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz", + "integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==", + "license": "MIT", + "dependencies": { + "fault": "^1.0.0", + "highlight.js": "~10.7.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/refractor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz", + "integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/prismjs": "^1.0.0", + "hastscript": "^9.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/rehype-highlight": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz", + "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-text": "^4.0.0", + "lowlight": "^3.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/web_console/package.json b/web_console/package.json new file mode 100644 index 000000000..db9f021bd --- /dev/null +++ b/web_console/package.json @@ -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" + } +} diff --git a/web_console/src/app/App.test.tsx b/web_console/src/app/App.test.tsx new file mode 100644 index 000000000..465d9b6e6 --- /dev/null +++ b/web_console/src/app/App.test.tsx @@ -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: '', 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(); + + 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(); + + expect(screen.getByLabelText('Transcript')).toBeInTheDocument(); + expect(screen.getByLabelText('Composer')).toBeInTheDocument(); + }); + + it('switches route content when navigating to Sessions', async () => { + render(); + + 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(); + + 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(); + 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(); + + // 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(); + + // 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(); + + 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(); + + 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(); + + 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(); + + 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(); + }); +}); diff --git a/web_console/src/app/App.tsx b/web_console/src/app/App.tsx new file mode 100644 index 000000000..4f5384551 --- /dev/null +++ b/web_console/src/app/App.tsx @@ -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 ( + + + + + + + + ); +} diff --git a/web_console/src/app/providers.tsx b/web_console/src/app/providers.tsx new file mode 100644 index 000000000..5e33d3c69 --- /dev/null +++ b/web_console/src/app/providers.tsx @@ -0,0 +1,5 @@ +import type { PropsWithChildren } from 'react'; + +export function AppProviders({ children }: PropsWithChildren) { + return children; +} diff --git a/web_console/src/app/router.tsx b/web_console/src/app/router.tsx new file mode 100644 index 000000000..6a19d4e93 --- /dev/null +++ b/web_console/src/app/router.tsx @@ -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); diff --git a/web_console/src/app/theme.css b/web_console/src/app/theme.css new file mode 100644 index 000000000..a2714468b --- /dev/null +++ b/web_console/src/app/theme.css @@ -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); } +} diff --git a/web_console/src/components/browser/BrowserControlPanel.tsx b/web_console/src/components/browser/BrowserControlPanel.tsx new file mode 100644 index 000000000..2aa7f22f3 --- /dev/null +++ b/web_console/src/components/browser/BrowserControlPanel.tsx @@ -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({}); + const [cdpUrl, setCdpUrl] = useState(''); + const [loading, setLoading] = useState(false); + + const refreshStatus = async () => { + try { + const res = await apiClient.get('/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 ( +
+

🌐 Browser

+ + {/* Status indicator */} +
+
+ {isConnected ? '🟢' : '🔴'} + + {isConnected ? 'Connected' : 'Disconnected'} + +
+ {status.mode && ( +
+ Mode: {status.mode} +
+ )} + {status.cdp_url && ( +
+ CDP: {status.cdp_url} +
+ )} +
+ + {/* Connect / Disconnect */} + {!isConnected ? ( +
+ 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', + }} + /> + +
+ ) : ( + + )} +
+ ); +} diff --git a/web_console/src/components/chat/ApprovalPrompt.tsx b/web_console/src/components/chat/ApprovalPrompt.tsx new file mode 100644 index 000000000..33de4756f --- /dev/null +++ b/web_console/src/components/chat/ApprovalPrompt.tsx @@ -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 ( +
+

+ ⚠️ Action Required +

+

+ Hermes wants to run the following command. Do you approve? +

+ + {request.command && ( +
+          {request.command}
+        
+ )} + +
+ + + +
+
+ ); +} diff --git a/web_console/src/components/chat/ArtifactViewer.tsx b/web_console/src/components/chat/ArtifactViewer.tsx new file mode 100644 index 000000000..c6cf7953e --- /dev/null +++ b/web_console/src/components/chat/ArtifactViewer.tsx @@ -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>; +} + +export function ArtifactViewer({ uiState, setUiState }: ArtifactViewerProps) { + if (!uiState.artifactOpen || !uiState.artifactContent) return null; + + const { artifactType, artifactContent } = uiState; + + const handleClose = () => { + setUiState(closeArtifact(uiState)); + }; + + return ( +
+
+
+

+ 🎨 Artifact Canvas + {artifactType} +

+ +
+ +
+ {(artifactType === 'html' || artifactType === 'svg') ? ( +