Compare commits
1 Commits
fix/561-ss
...
burn/web-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e0f24db3f |
@@ -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
|
||||
|
||||
712
docs/plans/2026-03-30-hermes-web-console-api-and-event-schema.md
Normal file
712
docs/plans/2026-03-30-hermes-web-console-api-and-event-schema.md
Normal file
@@ -0,0 +1,712 @@
|
||||
# Hermes Web Console API and Event Schema
|
||||
|
||||
Date: 2026-03-30
|
||||
|
||||
## Purpose
|
||||
|
||||
This document defines the Hermes-native backend contract for a fully featured GUI. The existing `/v1/*` OpenAI-compatible API remains unchanged. The GUI uses `/api/gui/*` plus SSE endpoints for structured, inspectable state.
|
||||
|
||||
## Design rules
|
||||
|
||||
1. Never leak raw secrets.
|
||||
2. Keep `/v1/*` compatibility untouched.
|
||||
3. Prefer thin wrappers around existing Hermes runtime/services.
|
||||
4. Return capability metadata when actions are unsupported.
|
||||
5. Use one event envelope format everywhere.
|
||||
|
||||
## API namespaces
|
||||
|
||||
- `/api/gui/health`
|
||||
- `/api/gui/meta`
|
||||
- `/api/gui/chat/*`
|
||||
- `/api/gui/sessions/*`
|
||||
- `/api/gui/workspace/*`
|
||||
- `/api/gui/processes/*`
|
||||
- `/api/gui/human/*`
|
||||
- `/api/gui/memory/*`
|
||||
- `/api/gui/user-profile/*`
|
||||
- `/api/gui/session-search`
|
||||
- `/api/gui/skills/*`
|
||||
- `/api/gui/cron/*`
|
||||
- `/api/gui/gateway/*`
|
||||
- `/api/gui/settings/*`
|
||||
- `/api/gui/logs/*`
|
||||
- `/api/gui/browser/*`
|
||||
- `/api/gui/media/*`
|
||||
- `/api/gui/stream/*`
|
||||
|
||||
## Common envelope
|
||||
|
||||
### Success envelope
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Error envelope
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "not_found",
|
||||
"message": "Session not found",
|
||||
"details": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Capability envelope
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"supported": false,
|
||||
"reason": "gateway_service_control_unavailable",
|
||||
"details": {
|
||||
"platform": "darwin"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication model
|
||||
|
||||
Default:
|
||||
- localhost-only bind
|
||||
- GUI respects API server bearer auth when configured
|
||||
|
||||
Headers:
|
||||
- `Authorization: Bearer <token>` when `API_SERVER_KEY` or GUI auth is enabled
|
||||
|
||||
## Core resource shapes
|
||||
|
||||
### Session summary
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "sess_123",
|
||||
"title": "Build Hermes GUI",
|
||||
"source": "cli",
|
||||
"workspace": "/home/glitch/.hermes/hermes-agent",
|
||||
"model": "gpt-5.4",
|
||||
"provider": "openai-codex",
|
||||
"last_active": "2026-03-30T02:10:00Z",
|
||||
"token_summary": {
|
||||
"input": 12000,
|
||||
"output": 3400,
|
||||
"total": 15400
|
||||
},
|
||||
"parent_session_id": null,
|
||||
"has_tools": true,
|
||||
"has_attachments": false,
|
||||
"has_subagents": true
|
||||
}
|
||||
```
|
||||
|
||||
### Transcript item
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "msg_123",
|
||||
"type": "assistant_message",
|
||||
"role": "assistant",
|
||||
"content": "Done — I gave Hermes a browser GUI.",
|
||||
"created_at": "2026-03-30T02:10:00Z",
|
||||
"metadata": {
|
||||
"run_id": "run_123",
|
||||
"tool_call_ids": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tool timeline item
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "tool_123",
|
||||
"tool_name": "search_files",
|
||||
"status": "completed",
|
||||
"started_at": "2026-03-30T02:10:02Z",
|
||||
"completed_at": "2026-03-30T02:10:03Z",
|
||||
"duration_ms": 940,
|
||||
"arguments_preview": {
|
||||
"pattern": "api_server",
|
||||
"path": "/home/glitch/.hermes/hermes-agent"
|
||||
},
|
||||
"result_preview": {
|
||||
"match_count": 12
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
### Pending human request
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "human_123",
|
||||
"kind": "approval",
|
||||
"session_id": "sess_123",
|
||||
"run_id": "run_123",
|
||||
"title": "Approve terminal command",
|
||||
"prompt": "Allow Hermes to run git status in /repo?",
|
||||
"choices": [
|
||||
"approve_once",
|
||||
"approve_session",
|
||||
"approve_always",
|
||||
"deny"
|
||||
],
|
||||
"expires_at": "2026-03-30T02:15:00Z",
|
||||
"sensitive": false
|
||||
}
|
||||
```
|
||||
|
||||
### Workspace checkpoint
|
||||
|
||||
```json
|
||||
{
|
||||
"checkpoint_id": "cp_123",
|
||||
"label": "Before destructive file patch",
|
||||
"created_at": "2026-03-30T02:11:00Z",
|
||||
"session_id": "sess_123",
|
||||
"run_id": "run_123",
|
||||
"file_count": 3
|
||||
}
|
||||
```
|
||||
|
||||
### Cron job summary
|
||||
|
||||
```json
|
||||
{
|
||||
"job_id": "cron_123",
|
||||
"name": "Morning summary",
|
||||
"schedule": "0 9 * * *",
|
||||
"deliver": "telegram",
|
||||
"paused": false,
|
||||
"next_run_at": "2026-03-31T09:00:00Z",
|
||||
"last_run_at": "2026-03-30T09:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Platform summary
|
||||
|
||||
```json
|
||||
{
|
||||
"platform": "telegram",
|
||||
"enabled": true,
|
||||
"configured": true,
|
||||
"connected": true,
|
||||
"error": null,
|
||||
"home_channel": "12345678",
|
||||
"allowed_mode": "pair"
|
||||
}
|
||||
```
|
||||
|
||||
## Endpoint definitions
|
||||
|
||||
### GET /api/gui/health
|
||||
|
||||
Returns GUI/backend health.
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"status": "ok",
|
||||
"product": "hermes-web-console"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/gui/meta
|
||||
|
||||
Returns version/build/runtime metadata.
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"product": "hermes-web-console",
|
||||
"version": "0.1.0",
|
||||
"gui_mount_path": "/app",
|
||||
"api_base": "/api/gui",
|
||||
"stream_base": "/api/gui/stream",
|
||||
"v1_base": "/v1",
|
||||
"features": {
|
||||
"workspace": true,
|
||||
"gateway_admin": true,
|
||||
"voice": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/gui/chat/send
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "sess_123",
|
||||
"conversation": "browser-chat",
|
||||
"message": "Inspect the API server and summarize its routes.",
|
||||
"instructions": "Be concise.",
|
||||
"attachments": [],
|
||||
"model": "hermes-agent"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": {
|
||||
"session_id": "sess_123",
|
||||
"run_id": "run_987",
|
||||
"status": "started"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/gui/chat/stop
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": "run_987"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/gui/chat/retry
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "sess_123"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/gui/chat/undo
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "sess_123"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/gui/sessions
|
||||
|
||||
Query params:
|
||||
- `q`
|
||||
- `source`
|
||||
- `workspace`
|
||||
- `model`
|
||||
- `has_tools`
|
||||
- `limit`
|
||||
- `offset`
|
||||
|
||||
### GET /api/gui/sessions/{session_id}
|
||||
|
||||
Returns metadata, recap, lineage.
|
||||
|
||||
### GET /api/gui/sessions/{session_id}/transcript
|
||||
|
||||
Returns normalized transcript and tool timeline.
|
||||
|
||||
### POST /api/gui/sessions/{session_id}/title
|
||||
|
||||
```json
|
||||
{
|
||||
"title": "Hermes Web Console planning"
|
||||
}
|
||||
```
|
||||
|
||||
### DELETE /api/gui/sessions/{session_id}
|
||||
|
||||
Deletes a session if supported by Hermes storage.
|
||||
|
||||
### GET /api/gui/workspace/tree
|
||||
|
||||
Query params:
|
||||
- `root`
|
||||
- `depth`
|
||||
|
||||
### GET /api/gui/workspace/file
|
||||
|
||||
Query params:
|
||||
- `path`
|
||||
|
||||
### GET /api/gui/workspace/search
|
||||
|
||||
Query params:
|
||||
- `pattern`
|
||||
- `path`
|
||||
- `file_glob`
|
||||
|
||||
### GET /api/gui/workspace/diff
|
||||
|
||||
Query params:
|
||||
- `path`
|
||||
- `session_id`
|
||||
- `run_id`
|
||||
|
||||
### GET /api/gui/workspace/checkpoints
|
||||
|
||||
Query params:
|
||||
- `workspace`
|
||||
- `session_id`
|
||||
|
||||
### POST /api/gui/workspace/rollback
|
||||
|
||||
```json
|
||||
{
|
||||
"checkpoint_id": "cp_123",
|
||||
"path": null
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/gui/processes
|
||||
|
||||
Returns background process summaries.
|
||||
|
||||
### GET /api/gui/processes/{process_id}/log
|
||||
|
||||
Query params:
|
||||
- `offset`
|
||||
- `limit`
|
||||
|
||||
### POST /api/gui/processes/{process_id}/kill
|
||||
|
||||
Kills a tracked process.
|
||||
|
||||
### GET /api/gui/human/pending
|
||||
|
||||
Returns pending approvals and clarifications.
|
||||
|
||||
### POST /api/gui/human/approve
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "human_123",
|
||||
"scope": "once"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/gui/human/deny
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "human_123"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/gui/human/clarify
|
||||
|
||||
```json
|
||||
{
|
||||
"request_id": "human_124",
|
||||
"response": "A web app you can open in a browser"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/gui/memory
|
||||
|
||||
Query params:
|
||||
- `target=memory|user`
|
||||
|
||||
### POST /api/gui/memory
|
||||
|
||||
```json
|
||||
{
|
||||
"target": "memory",
|
||||
"action": "add",
|
||||
"content": "User prefers concise terminal-renderable responses."
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/gui/session-search
|
||||
|
||||
Query params:
|
||||
- `query`
|
||||
- `limit`
|
||||
|
||||
### GET /api/gui/skills
|
||||
|
||||
Query params:
|
||||
- `category`
|
||||
|
||||
### GET /api/gui/skills/{name}
|
||||
|
||||
Returns skill markdown + metadata.
|
||||
|
||||
### POST /api/gui/skills/{name}/install
|
||||
|
||||
Optional admin action.
|
||||
|
||||
### GET /api/gui/cron/jobs
|
||||
|
||||
### POST /api/gui/cron/jobs
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Morning summary",
|
||||
"prompt": "Summarize the latest issues.",
|
||||
"schedule": "0 9 * * *",
|
||||
"deliver": "telegram",
|
||||
"skills": ["github-issues"]
|
||||
}
|
||||
```
|
||||
|
||||
### PATCH /api/gui/cron/jobs/{job_id}
|
||||
|
||||
### POST /api/gui/cron/jobs/{job_id}/run
|
||||
|
||||
### POST /api/gui/cron/jobs/{job_id}/pause
|
||||
|
||||
### POST /api/gui/cron/jobs/{job_id}/resume
|
||||
|
||||
### DELETE /api/gui/cron/jobs/{job_id}
|
||||
|
||||
### GET /api/gui/gateway/overview
|
||||
|
||||
Returns:
|
||||
- runtime state
|
||||
- PID
|
||||
- platform summary list
|
||||
- delivery/home configuration summary
|
||||
|
||||
### GET /api/gui/gateway/platforms
|
||||
|
||||
Returns all platform config/status cards.
|
||||
|
||||
### GET /api/gui/gateway/pairing
|
||||
|
||||
Returns pending codes and approved identities.
|
||||
|
||||
### POST /api/gui/gateway/pairing/approve
|
||||
|
||||
```json
|
||||
{
|
||||
"platform": "telegram",
|
||||
"code": "AB12CD34"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/gui/gateway/pairing/revoke
|
||||
|
||||
```json
|
||||
{
|
||||
"platform": "telegram",
|
||||
"user_id": "123456789"
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/gui/settings
|
||||
|
||||
Returns masked config snapshot split by sections.
|
||||
|
||||
### PATCH /api/gui/settings
|
||||
|
||||
Accepts partial updates for safe editable sections.
|
||||
|
||||
### GET /api/gui/logs
|
||||
|
||||
Query params:
|
||||
- `kind=errors|gateway|cron|tool_debug`
|
||||
- `offset`
|
||||
- `limit`
|
||||
- `follow`
|
||||
|
||||
### GET /api/gui/browser/status
|
||||
|
||||
Returns browser backend info, live connection state, recording paths if available.
|
||||
|
||||
### POST /api/gui/browser/connect
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "live_chrome"
|
||||
}
|
||||
```
|
||||
|
||||
### POST /api/gui/browser/disconnect
|
||||
|
||||
### POST /api/gui/media/upload
|
||||
|
||||
Accepts multipart uploads and returns normalized attachment metadata.
|
||||
|
||||
### POST /api/gui/media/transcribe
|
||||
|
||||
Accepts uploaded audio reference and returns transcription.
|
||||
|
||||
### POST /api/gui/media/tts
|
||||
|
||||
Accepts text and returns generated media path metadata.
|
||||
|
||||
## SSE transport
|
||||
|
||||
### Endpoint
|
||||
|
||||
- `GET /api/gui/stream/session/{session_id}`
|
||||
|
||||
Optional query params:
|
||||
- `run_id`
|
||||
- `history=1` to replay recent buffered events
|
||||
|
||||
### Event envelope
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "evt_123",
|
||||
"type": "tool.started",
|
||||
"session_id": "sess_123",
|
||||
"run_id": "run_987",
|
||||
"ts": "2026-03-30T02:10:02.123Z",
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
### Required event types
|
||||
|
||||
#### run.started
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "run.started",
|
||||
"payload": {
|
||||
"message_id": "msg_1",
|
||||
"title": "Inspect API server routes"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### message.assistant.delta
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "message.assistant.delta",
|
||||
"payload": {
|
||||
"message_id": "msg_2",
|
||||
"delta": "The API server currently exposes"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### tool.started
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "tool.started",
|
||||
"payload": {
|
||||
"tool_call_id": "tool_1",
|
||||
"tool_name": "search_files",
|
||||
"arguments": {
|
||||
"pattern": "api_server"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### tool.completed
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "tool.completed",
|
||||
"payload": {
|
||||
"tool_call_id": "tool_1",
|
||||
"duration_ms": 944,
|
||||
"summary": "12 matches"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### approval.requested
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "approval.requested",
|
||||
"payload": {
|
||||
"request_id": "human_1",
|
||||
"title": "Approve terminal command",
|
||||
"prompt": "Allow Hermes to run git status?",
|
||||
"choices": ["approve_once", "approve_session", "approve_always", "deny"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### clarify.requested
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "clarify.requested",
|
||||
"payload": {
|
||||
"request_id": "human_2",
|
||||
"title": "Need clarification",
|
||||
"prompt": "What kind of GUI do you want?",
|
||||
"choices": [
|
||||
"A local desktop app",
|
||||
"A web app you can open in a browser",
|
||||
"A terminal UI (TUI)",
|
||||
"Something minimal: just a chat window"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### todo.updated
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "todo.updated",
|
||||
"payload": {
|
||||
"items": [
|
||||
{
|
||||
"id": "inspect",
|
||||
"content": "Inspect Hermes repo",
|
||||
"status": "completed"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### subagent.completed
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "subagent.completed",
|
||||
"payload": {
|
||||
"subagent_id": "sub_1",
|
||||
"title": "Inspect gateway features",
|
||||
"summary": "Found pairing, status, and gateway config surfaces."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security rules
|
||||
|
||||
1. Secrets from `.env`, auth stores, passwords, or secret prompts must never be returned.
|
||||
2. Sensitive settings responses should return:
|
||||
- `present: true|false`
|
||||
- masked preview only when already displayed in CLI-equivalent UX
|
||||
3. Service control endpoints should require capability checks and explicit confirmation in UI.
|
||||
4. Remote GUI access should require auth if `host != 127.0.0.1` or if an API key is configured.
|
||||
|
||||
## Versioning
|
||||
|
||||
Add a `schema_version` field in `/api/gui/meta` and bump it whenever:
|
||||
- event shape changes
|
||||
- endpoint response shape changes
|
||||
- required fields change
|
||||
|
||||
Suggested initial value:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
1395
docs/plans/2026-03-30-hermes-web-console-v1-implementation-plan.md
Normal file
1395
docs/plans/2026-03-30-hermes-web-console-v1-implementation-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
496
gateway/platforms/api_server_ui.py
Normal file
496
gateway/platforms/api_server_ui.py
Normal file
@@ -0,0 +1,496 @@
|
||||
import json
|
||||
|
||||
|
||||
def get_api_server_ui_html(
|
||||
api_base_url: str = "/v1",
|
||||
requires_api_key: bool = False,
|
||||
default_model: str = "hermes-agent",
|
||||
) -> str:
|
||||
"""Return a lightweight single-file browser UI for the local API server."""
|
||||
config = {
|
||||
"apiBaseUrl": api_base_url,
|
||||
"requiresApiKey": requires_api_key,
|
||||
"defaultModel": default_model,
|
||||
}
|
||||
config_json = json.dumps(config)
|
||||
template = '''<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Hermes Browser UI</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0b1020;
|
||||
--panel: rgba(18, 25, 45, 0.92);
|
||||
--panel-2: rgba(28, 37, 66, 0.95);
|
||||
--border: rgba(140, 170, 255, 0.2);
|
||||
--text: #edf2ff;
|
||||
--muted: #9ca9c8;
|
||||
--accent: #7aa2ff;
|
||||
--accent-2: #5eead4;
|
||||
--danger: #fb7185;
|
||||
--shadow: 0 18px 40px rgba(0,0,0,0.35);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(122,162,255,0.18), transparent 30%),
|
||||
linear-gradient(180deg, #08101f 0%, #090d18 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
.shell {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
.sidebar { padding: 20px; display: flex; flex-direction: column; gap: 16px; }
|
||||
.brand h1 { margin: 0; font-size: 1.5rem; }
|
||||
.brand p, .hint, .status-line { color: var(--muted); margin: 0; line-height: 1.45; }
|
||||
.status-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(94, 234, 212, 0.08);
|
||||
color: var(--accent-2);
|
||||
border: 1px solid rgba(94, 234, 212, 0.2);
|
||||
font-size: 0.92rem;
|
||||
width: fit-content;
|
||||
}
|
||||
.status-chip.offline {
|
||||
color: #fda4af;
|
||||
background: rgba(251, 113, 133, 0.08);
|
||||
border-color: rgba(251, 113, 133, 0.22);
|
||||
}
|
||||
label { display: block; font-size: 0.92rem; margin-bottom: 6px; color: #cbd5f5; }
|
||||
input, textarea, button { font: inherit; }
|
||||
input, textarea {
|
||||
width: 100%;
|
||||
background: rgba(8, 15, 31, 0.9);
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(140, 170, 255, 0.18);
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
input:focus, textarea:focus {
|
||||
border-color: rgba(122,162,255,0.75);
|
||||
box-shadow: 0 0 0 3px rgba(122,162,255,0.15);
|
||||
}
|
||||
textarea { min-height: 96px; resize: vertical; }
|
||||
.controls { display: flex; flex-direction: column; gap: 14px; }
|
||||
.row { display: flex; gap: 10px; }
|
||||
.row > * { flex: 1; }
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
padding: 12px 14px;
|
||||
cursor: pointer;
|
||||
background: linear-gradient(135deg, var(--accent), #8b5cf6);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
transition: transform 0.12s ease, opacity 0.2s ease;
|
||||
}
|
||||
button:hover { transform: translateY(-1px); }
|
||||
button:disabled { cursor: wait; opacity: 0.65; transform: none; }
|
||||
button.secondary {
|
||||
background: rgba(122, 162, 255, 0.08);
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(140, 170, 255, 0.18);
|
||||
}
|
||||
.chat {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
min-height: calc(100vh - 48px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.chat-header {
|
||||
padding: 20px 22px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
.messages {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
background: linear-gradient(180deg, rgba(10,15,28,0.25), rgba(10,15,28,0.05));
|
||||
}
|
||||
.message {
|
||||
border: 1px solid rgba(140,170,255,0.14);
|
||||
border-radius: 18px;
|
||||
padding: 14px 16px;
|
||||
max-width: min(90%, 880px);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.18);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.message.user { align-self: flex-end; background: rgba(30,58,138,0.6); }
|
||||
.message.assistant { align-self: flex-start; background: rgba(22,33,62,0.92); }
|
||||
.message.tool { align-self: flex-start; background: rgba(16,42,67,0.92); }
|
||||
.message.system { align-self: center; background: rgba(76,29,149,0.22); color: #ddd6fe; }
|
||||
.meta { font-size: 0.78rem; color: var(--muted); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
.composer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 18px 20px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
background: rgba(9, 14, 26, 0.9);
|
||||
}
|
||||
.composer-actions { display: flex; gap: 10px; justify-content: space-between; align-items: center; }
|
||||
.composer-actions .right { display: flex; gap: 10px; }
|
||||
.footer-note { color: var(--muted); font-size: 0.86rem; }
|
||||
code {
|
||||
background: rgba(255,255,255,0.08);
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
@media (max-width: 960px) {
|
||||
.shell { grid-template-columns: 1fr; padding: 16px; }
|
||||
.chat { min-height: 75vh; }
|
||||
.message { max-width: 100%; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<aside class="panel sidebar">
|
||||
<div class="brand">
|
||||
<h1>Hermes</h1>
|
||||
<p>Local browser GUI for the built-in API server.</p>
|
||||
</div>
|
||||
|
||||
<div id="health-chip" class="status-chip">Checking connection…</div>
|
||||
<p id="status-line" class="status-line">Point your browser at this server and chat with Hermes directly.</p>
|
||||
|
||||
<div class="controls">
|
||||
<div>
|
||||
<label for="conversation">Conversation ID</label>
|
||||
<input id="conversation" placeholder="browser-chat" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="model">Model label</label>
|
||||
<input id="model" placeholder="hermes-agent" />
|
||||
</div>
|
||||
|
||||
<div id="api-key-wrap" class="hidden">
|
||||
<label for="api-key">API key</label>
|
||||
<input id="api-key" type="password" placeholder="Bearer token for this server" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="instructions">Optional instructions</label>
|
||||
<textarea id="instructions" placeholder="You are Hermes in browser mode. Be concise."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<button id="new-chat" class="secondary" type="button">New chat</button>
|
||||
<button id="save-settings" class="secondary" type="button">Save settings</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="hint">This UI talks to <code>/v1/responses</code> on the same server and keeps state by conversation name.</p>
|
||||
</aside>
|
||||
|
||||
<main class="panel chat">
|
||||
<div class="chat-header">
|
||||
<div>
|
||||
<strong>Browser Chat</strong>
|
||||
<p class="hint">Hermes can use tools through the existing backend. Tool calls appear inline.</p>
|
||||
</div>
|
||||
<div class="footer-note" id="usage">No messages yet.</div>
|
||||
</div>
|
||||
|
||||
<section id="messages" class="messages"></section>
|
||||
|
||||
<form id="composer" class="composer">
|
||||
<textarea id="prompt" placeholder="Ask Hermes anything… Shift+Enter for a newline."></textarea>
|
||||
<div class="composer-actions">
|
||||
<div class="footer-note" id="composer-note">Ready.</div>
|
||||
<div class="right">
|
||||
<button id="clear-chat" class="secondary" type="button">Clear messages</button>
|
||||
<button id="send" type="submit">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const CONFIG = __CONFIG_JSON__;
|
||||
const storageKey = 'hermes-browser-ui';
|
||||
const state = {
|
||||
busy: false,
|
||||
conversation: null,
|
||||
usage: null,
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const els = {
|
||||
apiKeyWrap: $('api-key-wrap'),
|
||||
apiKey: $('api-key'),
|
||||
composer: $('composer'),
|
||||
composerNote: $('composer-note'),
|
||||
conversation: $('conversation'),
|
||||
healthChip: $('health-chip'),
|
||||
instructions: $('instructions'),
|
||||
messages: $('messages'),
|
||||
model: $('model'),
|
||||
newChat: $('new-chat'),
|
||||
prompt: $('prompt'),
|
||||
saveSettings: $('save-settings'),
|
||||
send: $('send'),
|
||||
statusLine: $('status-line'),
|
||||
usage: $('usage'),
|
||||
clearChat: $('clear-chat'),
|
||||
};
|
||||
|
||||
function randomConversationId() {
|
||||
if (window.crypto && crypto.randomUUID) {
|
||||
return `chat-${crypto.randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
return `chat-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
function loadSettings() {
|
||||
try {
|
||||
const saved = JSON.parse(localStorage.getItem(storageKey) || '{}');
|
||||
els.conversation.value = saved.conversation || randomConversationId();
|
||||
els.instructions.value = saved.instructions || '';
|
||||
els.model.value = saved.model || CONFIG.defaultModel;
|
||||
els.apiKey.value = saved.apiKey || '';
|
||||
} catch (err) {
|
||||
els.conversation.value = randomConversationId();
|
||||
els.model.value = CONFIG.defaultModel;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings() {
|
||||
const payload = {
|
||||
conversation: els.conversation.value.trim(),
|
||||
instructions: els.instructions.value,
|
||||
model: els.model.value.trim(),
|
||||
apiKey: els.apiKey.value,
|
||||
};
|
||||
localStorage.setItem(storageKey, JSON.stringify(payload));
|
||||
setComposerNote('Settings saved locally in your browser.');
|
||||
}
|
||||
|
||||
function setComposerNote(text, isError = false) {
|
||||
els.composerNote.textContent = text;
|
||||
els.composerNote.style.color = isError ? 'var(--danger)' : 'var(--muted)';
|
||||
}
|
||||
|
||||
function setBusy(busy) {
|
||||
state.busy = busy;
|
||||
els.send.disabled = busy;
|
||||
els.prompt.disabled = busy;
|
||||
els.conversation.disabled = busy;
|
||||
els.model.disabled = busy;
|
||||
els.instructions.disabled = busy;
|
||||
els.apiKey.disabled = busy;
|
||||
els.newChat.disabled = busy;
|
||||
els.saveSettings.disabled = busy;
|
||||
els.clearChat.disabled = busy;
|
||||
setComposerNote(busy ? 'Hermes is thinking…' : 'Ready.');
|
||||
}
|
||||
|
||||
function addMessage(role, content, meta = '') {
|
||||
state.messages.push({ role, content, meta });
|
||||
renderMessages();
|
||||
}
|
||||
|
||||
function renderMessages() {
|
||||
els.messages.innerHTML = '';
|
||||
if (!state.messages.length) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'message system';
|
||||
empty.textContent = 'Start a conversation to bring Hermes into the browser.';
|
||||
els.messages.appendChild(empty);
|
||||
}
|
||||
for (const msg of state.messages) {
|
||||
const node = document.createElement('article');
|
||||
node.className = `message ${msg.role}`;
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'meta';
|
||||
meta.textContent = msg.meta || msg.role;
|
||||
const body = document.createElement('div');
|
||||
body.textContent = msg.content;
|
||||
node.append(meta, body);
|
||||
els.messages.appendChild(node);
|
||||
}
|
||||
els.messages.scrollTop = els.messages.scrollHeight;
|
||||
}
|
||||
|
||||
function updateUsage(usage) {
|
||||
state.usage = usage || null;
|
||||
if (!usage) {
|
||||
els.usage.textContent = 'No messages yet.';
|
||||
return;
|
||||
}
|
||||
const total = usage.total_tokens ?? 0;
|
||||
const input = usage.input_tokens ?? 0;
|
||||
const output = usage.output_tokens ?? 0;
|
||||
els.usage.textContent = `Tokens: total ${total} · in ${input} · out ${output}`;
|
||||
}
|
||||
|
||||
function extractAssistantText(output) {
|
||||
if (!Array.isArray(output)) return '';
|
||||
const messageItem = [...output].reverse().find((item) => item.type === 'message');
|
||||
if (!messageItem || !Array.isArray(messageItem.content)) return '';
|
||||
return messageItem.content
|
||||
.filter((part) => part && part.type === 'output_text')
|
||||
.map((part) => part.text || '')
|
||||
.join('\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function summarizeToolItems(output) {
|
||||
if (!Array.isArray(output)) return [];
|
||||
const items = [];
|
||||
for (const item of output) {
|
||||
if (item.type === 'function_call') {
|
||||
items.push(`Tool call: ${item.name || 'unknown'}\n${item.arguments || ''}`.trim());
|
||||
} else if (item.type === 'function_call_output') {
|
||||
items.push(`Tool result (${item.call_id || 'call'}):\n${String(item.output || '')}`.trim());
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
async function checkHealth() {
|
||||
try {
|
||||
const response = await fetch('/health');
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const payload = await response.json();
|
||||
els.healthChip.textContent = 'Connected';
|
||||
els.healthChip.classList.remove('offline');
|
||||
els.statusLine.textContent = `Server status: ${payload.status} · platform: ${payload.platform}`;
|
||||
} catch (error) {
|
||||
els.healthChip.textContent = 'Offline';
|
||||
els.healthChip.classList.add('offline');
|
||||
els.statusLine.textContent = `Health check failed: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function resetConversation(clearVisuals = true) {
|
||||
const nextId = randomConversationId();
|
||||
els.conversation.value = nextId;
|
||||
state.conversation = nextId;
|
||||
if (clearVisuals) {
|
||||
state.messages = [];
|
||||
renderMessages();
|
||||
updateUsage(null);
|
||||
}
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
async function sendMessage(promptText) {
|
||||
const conversation = els.conversation.value.trim() || randomConversationId();
|
||||
const instructions = els.instructions.value.trim();
|
||||
const model = els.model.value.trim() || CONFIG.defaultModel;
|
||||
const apiKey = els.apiKey.value.trim();
|
||||
els.conversation.value = conversation;
|
||||
state.conversation = conversation;
|
||||
|
||||
addMessage('user', promptText, `conversation ${conversation}`);
|
||||
setBusy(true);
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${CONFIG.apiBaseUrl}/responses`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
input: promptText,
|
||||
instructions: instructions || undefined,
|
||||
conversation,
|
||||
store: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({ error: { message: 'Invalid JSON response' } }));
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.error?.message || `Request failed with HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
for (const toolText of summarizeToolItems(data.output)) {
|
||||
addMessage('tool', toolText, 'tool activity');
|
||||
}
|
||||
addMessage('assistant', extractAssistantText(data.output) || '(No message returned)', data.id || 'assistant');
|
||||
updateUsage(data.usage);
|
||||
saveSettings();
|
||||
} catch (error) {
|
||||
addMessage('system', error.message || String(error), 'request failed');
|
||||
setComposerNote(error.message || String(error), true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
els.composer.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const promptText = els.prompt.value.trim();
|
||||
if (!promptText || state.busy) return;
|
||||
els.prompt.value = '';
|
||||
await sendMessage(promptText);
|
||||
});
|
||||
|
||||
els.prompt.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
els.composer.requestSubmit();
|
||||
}
|
||||
});
|
||||
|
||||
els.newChat.addEventListener('click', () => resetConversation(true));
|
||||
els.clearChat.addEventListener('click', () => {
|
||||
state.messages = [];
|
||||
renderMessages();
|
||||
updateUsage(null);
|
||||
setComposerNote('Messages cleared in this browser tab.');
|
||||
});
|
||||
els.saveSettings.addEventListener('click', saveSettings);
|
||||
|
||||
if (CONFIG.requiresApiKey) {
|
||||
els.apiKeyWrap.classList.remove('hidden');
|
||||
}
|
||||
|
||||
loadSettings();
|
||||
state.conversation = els.conversation.value.trim();
|
||||
renderMessages();
|
||||
checkHealth();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
'''
|
||||
return template.replace('__CONFIG_JSON__', config_json)
|
||||
5
gateway/web_console/__init__.py
Normal file
5
gateway/web_console/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Hermes Web Console backend package."""
|
||||
|
||||
from .app import maybe_register_web_console
|
||||
|
||||
__all__ = ["maybe_register_web_console"]
|
||||
87
gateway/web_console/api/__init__.py
Normal file
87
gateway/web_console/api/__init__.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""API route registration helpers for the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .approvals import register_approval_api_routes
|
||||
from .browser import register_browser_api_routes
|
||||
from .chat import register_chat_api_routes
|
||||
from .commands import register_commands_api_routes
|
||||
from .cron import register_cron_api_routes
|
||||
from .gateway_admin import register_gateway_admin_api_routes
|
||||
from .logs import register_logs_api_routes
|
||||
from .media import register_media_api_routes
|
||||
from .metrics import register_metrics_api_routes
|
||||
from .memory import register_memory_api_routes
|
||||
from .models_api import register_models_api_routes
|
||||
from .sessions import register_sessions_api_routes
|
||||
from .settings import register_settings_api_routes
|
||||
from .skills import register_skills_api_routes
|
||||
from .tools import register_tools_api_routes
|
||||
from .version import register_version_api_routes
|
||||
from .workspace import register_workspace_api_routes
|
||||
from .profiles import register_profiles_api_routes
|
||||
from .plugins import register_plugins_api_routes
|
||||
from .mcp import register_mcp_api_routes
|
||||
from .usage import register_usage_api_routes
|
||||
from .missions import register_missions_api_routes
|
||||
from .credentials import register_credentials_api_routes
|
||||
from .system import register_system_api_routes
|
||||
|
||||
|
||||
def register_web_console_api_routes(app: web.Application) -> None:
|
||||
"""Register modular web-console API routes on an aiohttp application."""
|
||||
register_approval_api_routes(app)
|
||||
register_browser_api_routes(app)
|
||||
register_chat_api_routes(app)
|
||||
register_commands_api_routes(app)
|
||||
register_cron_api_routes(app)
|
||||
register_gateway_admin_api_routes(app)
|
||||
register_logs_api_routes(app)
|
||||
register_media_api_routes(app)
|
||||
register_metrics_api_routes(app)
|
||||
register_memory_api_routes(app)
|
||||
register_models_api_routes(app)
|
||||
register_sessions_api_routes(app)
|
||||
register_settings_api_routes(app)
|
||||
register_skills_api_routes(app)
|
||||
register_tools_api_routes(app)
|
||||
register_version_api_routes(app)
|
||||
register_workspace_api_routes(app)
|
||||
register_profiles_api_routes(app)
|
||||
register_plugins_api_routes(app)
|
||||
register_mcp_api_routes(app)
|
||||
register_usage_api_routes(app)
|
||||
register_missions_api_routes(app)
|
||||
register_credentials_api_routes(app)
|
||||
register_system_api_routes(app)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"register_web_console_api_routes",
|
||||
"register_chat_api_routes",
|
||||
"register_browser_api_routes",
|
||||
"register_commands_api_routes",
|
||||
"register_cron_api_routes",
|
||||
"register_gateway_admin_api_routes",
|
||||
"register_logs_api_routes",
|
||||
"register_media_api_routes",
|
||||
"register_metrics_api_routes",
|
||||
"register_memory_api_routes",
|
||||
"register_models_api_routes",
|
||||
"register_sessions_api_routes",
|
||||
"register_settings_api_routes",
|
||||
"register_skills_api_routes",
|
||||
"register_tools_api_routes",
|
||||
"register_version_api_routes",
|
||||
"register_approval_api_routes",
|
||||
"register_workspace_api_routes",
|
||||
"register_profiles_api_routes",
|
||||
"register_plugins_api_routes",
|
||||
"register_mcp_api_routes",
|
||||
"register_usage_api_routes",
|
||||
"register_missions_api_routes",
|
||||
"register_credentials_api_routes",
|
||||
"register_system_api_routes",
|
||||
]
|
||||
137
gateway/web_console/api/approvals.py
Normal file
137
gateway/web_console/api/approvals.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Human approval and clarification API routes for the Hermes Web Console."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from gateway.web_console.services.approval_service import ApprovalService
|
||||
|
||||
HUMAN_SERVICE_APP_KEY = web.AppKey("hermes_web_console_human_service", ApprovalService)
|
||||
|
||||
|
||||
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
|
||||
payload: dict[str, Any] = {"ok": False, "error": {"code": code, "message": message}}
|
||||
payload["error"].update(extra)
|
||||
return web.json_response(payload, status=status)
|
||||
|
||||
|
||||
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
|
||||
try:
|
||||
data = await request.json()
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _get_human_service(request: web.Request) -> ApprovalService:
|
||||
return request.app[HUMAN_SERVICE_APP_KEY]
|
||||
|
||||
|
||||
async def handle_list_pending(request: web.Request) -> web.Response:
|
||||
service = _get_human_service(request)
|
||||
return web.json_response({"ok": True, "pending": service.list_pending()})
|
||||
|
||||
|
||||
async def handle_approve(request: web.Request) -> web.Response:
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
request_id = data.get("request_id")
|
||||
decision = data.get("decision") or data.get("scope") or "once"
|
||||
if not isinstance(request_id, str) or not request_id:
|
||||
return _json_error(status=400, code="invalid_request_id", message="The 'request_id' field must be a non-empty string.")
|
||||
if decision not in {"once", "session", "always"}:
|
||||
return _json_error(status=400, code="invalid_decision", message="The approval decision must be one of: once, session, always.")
|
||||
|
||||
service = _get_human_service(request)
|
||||
resolved = service.resolve_approval(request_id, decision)
|
||||
if resolved is None:
|
||||
return _json_error(status=404, code="request_not_found", message="No pending approval request was found.", request_id=request_id)
|
||||
return web.json_response({"ok": True, "request": resolved})
|
||||
|
||||
|
||||
async def handle_deny(request: web.Request) -> web.Response:
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
request_id = data.get("request_id")
|
||||
if not isinstance(request_id, str) or not request_id:
|
||||
return _json_error(status=400, code="invalid_request_id", message="The 'request_id' field must be a non-empty string.")
|
||||
|
||||
service = _get_human_service(request)
|
||||
resolved = service.deny_request(request_id)
|
||||
if resolved is None:
|
||||
return _json_error(status=404, code="request_not_found", message="No pending human request was found.", request_id=request_id)
|
||||
return web.json_response({"ok": True, "request": resolved})
|
||||
|
||||
|
||||
async def handle_clarify(request: web.Request) -> web.Response:
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
request_id = data.get("request_id")
|
||||
response = data.get("response")
|
||||
if not isinstance(request_id, str) or not request_id:
|
||||
return _json_error(status=400, code="invalid_request_id", message="The 'request_id' field must be a non-empty string.")
|
||||
if not isinstance(response, str):
|
||||
return _json_error(status=400, code="invalid_response", message="The 'response' field must be a string.")
|
||||
|
||||
service = _get_human_service(request)
|
||||
resolved = service.resolve_clarification(request_id, response)
|
||||
if resolved is None:
|
||||
return _json_error(status=404, code="request_not_found", message="No pending clarification request was found.", request_id=request_id)
|
||||
return web.json_response({"ok": True, "request": resolved})
|
||||
|
||||
|
||||
async def handle_get_yolo(request: web.Request) -> web.Response:
|
||||
from tools.approval import is_session_yolo_enabled
|
||||
|
||||
session_id = request.query.get("session_id", "")
|
||||
if not session_id:
|
||||
return _json_error(status=400, code="invalid_session_id", message="The 'session_id' query parameter is required.")
|
||||
return web.json_response({"ok": True, "session_id": session_id, "enabled": is_session_yolo_enabled(session_id)})
|
||||
|
||||
|
||||
async def handle_set_yolo(request: web.Request) -> web.Response:
|
||||
from tools.approval import disable_session_yolo, enable_session_yolo, is_session_yolo_enabled
|
||||
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
|
||||
session_id = data.get("session_id")
|
||||
enabled = data.get("enabled")
|
||||
if not isinstance(session_id, str) or not session_id:
|
||||
return _json_error(status=400, code="invalid_session_id", message="The 'session_id' field must be a non-empty string.")
|
||||
if not isinstance(enabled, bool):
|
||||
return _json_error(status=400, code="invalid_enabled", message="The 'enabled' field must be a boolean.")
|
||||
|
||||
if enabled:
|
||||
enable_session_yolo(session_id)
|
||||
else:
|
||||
disable_session_yolo(session_id)
|
||||
|
||||
return web.json_response({"ok": True, "session_id": session_id, "enabled": is_session_yolo_enabled(session_id)})
|
||||
|
||||
|
||||
def register_approval_api_routes(app: web.Application) -> None:
|
||||
if app.get(HUMAN_SERVICE_APP_KEY) is None:
|
||||
app[HUMAN_SERVICE_APP_KEY] = ApprovalService()
|
||||
|
||||
from gateway.web_console.api.chat import CHAT_SERVICE_APP_KEY
|
||||
|
||||
existing_chat_service = app.get(CHAT_SERVICE_APP_KEY)
|
||||
if existing_chat_service is not None:
|
||||
existing_chat_service.human_service = app[HUMAN_SERVICE_APP_KEY]
|
||||
|
||||
app.router.add_get("/api/gui/human/pending", handle_list_pending)
|
||||
app.router.add_post("/api/gui/human/approve", handle_approve)
|
||||
app.router.add_post("/api/gui/human/deny", handle_deny)
|
||||
app.router.add_post("/api/gui/human/clarify", handle_clarify)
|
||||
app.router.add_get("/api/gui/human/yolo", handle_get_yolo)
|
||||
app.router.add_post("/api/gui/human/yolo", handle_set_yolo)
|
||||
66
gateway/web_console/api/browser.py
Normal file
66
gateway/web_console/api/browser.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Browser API routes for the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from gateway.web_console.services.browser_service import BrowserService
|
||||
|
||||
BROWSER_SERVICE_APP_KEY = web.AppKey("hermes_web_console_browser_service", BrowserService)
|
||||
|
||||
|
||||
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
|
||||
payload: dict[str, Any] = {"ok": False, "error": {"code": code, "message": message}}
|
||||
payload["error"].update(extra)
|
||||
return web.json_response(payload, status=status)
|
||||
|
||||
|
||||
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
|
||||
if request.content_type == "application/json":
|
||||
try:
|
||||
data = await request.json()
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
return {}
|
||||
|
||||
|
||||
def _get_browser_service(request: web.Request) -> BrowserService:
|
||||
service = request.app.get(BROWSER_SERVICE_APP_KEY)
|
||||
if service is None:
|
||||
service = BrowserService()
|
||||
request.app[BROWSER_SERVICE_APP_KEY] = service
|
||||
return service
|
||||
|
||||
|
||||
async def handle_browser_status(request: web.Request) -> web.Response:
|
||||
service = _get_browser_service(request)
|
||||
return web.json_response({"ok": True, "browser": service.get_status()})
|
||||
|
||||
|
||||
async def handle_browser_connect(request: web.Request) -> web.Response:
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
service = _get_browser_service(request)
|
||||
try:
|
||||
browser = service.connect(data.get("cdp_url"))
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_cdp_url", message=str(exc))
|
||||
return web.json_response({"ok": True, "browser": browser})
|
||||
|
||||
|
||||
async def handle_browser_disconnect(request: web.Request) -> web.Response:
|
||||
service = _get_browser_service(request)
|
||||
return web.json_response({"ok": True, "browser": service.disconnect()})
|
||||
|
||||
|
||||
def register_browser_api_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/gui/browser/status", handle_browser_status)
|
||||
app.router.add_post("/api/gui/browser/connect", handle_browser_connect)
|
||||
app.router.add_post("/api/gui/browser/disconnect", handle_browser_disconnect)
|
||||
621
gateway/web_console/api/chat.py
Normal file
621
gateway/web_console/api/chat.py
Normal file
@@ -0,0 +1,621 @@
|
||||
"""Chat API routes for the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from gateway.web_console.api.approvals import HUMAN_SERVICE_APP_KEY
|
||||
from gateway.web_console.services.chat_service import ChatService
|
||||
from gateway.web_console.state import create_web_console_state, get_web_console_state
|
||||
|
||||
CHAT_SERVICE_APP_KEY = web.AppKey("hermes_web_console_chat_service", ChatService)
|
||||
CHAT_STATE_APP_KEY = web.AppKey("hermes_web_console_chat_state", object)
|
||||
CHAT_TASKS_APP_KEY = web.AppKey("hermes_web_console_chat_tasks", set)
|
||||
CHAT_CLEANUP_REGISTERED_APP_KEY = web.AppKey("hermes_web_console_chat_cleanup_registered", bool)
|
||||
|
||||
|
||||
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
|
||||
payload: dict[str, Any] = {
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
payload["error"].update(extra)
|
||||
return web.json_response(payload, status=status)
|
||||
|
||||
|
||||
def _get_chat_service(request: web.Request) -> ChatService:
|
||||
service = request.app.get(CHAT_SERVICE_APP_KEY)
|
||||
if service is None:
|
||||
state = request.app.get(CHAT_STATE_APP_KEY)
|
||||
if state is None:
|
||||
state = create_web_console_state()
|
||||
request.app[CHAT_STATE_APP_KEY] = state
|
||||
human_service = request.app.get(HUMAN_SERVICE_APP_KEY)
|
||||
service = ChatService(state=state, human_service=human_service)
|
||||
request.app[CHAT_SERVICE_APP_KEY] = service
|
||||
return service
|
||||
|
||||
|
||||
def _get_chat_tasks(app: web.Application) -> set[asyncio.Task[Any]]:
|
||||
tasks = app.get(CHAT_TASKS_APP_KEY)
|
||||
if tasks is None:
|
||||
tasks = set()
|
||||
app[CHAT_TASKS_APP_KEY] = tasks
|
||||
return tasks
|
||||
|
||||
|
||||
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
|
||||
try:
|
||||
data = await request.json()
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _build_run_metadata(
|
||||
*,
|
||||
session_id: str,
|
||||
run_id: str,
|
||||
prompt: str,
|
||||
conversation_history: list[dict[str, Any]] | None,
|
||||
ephemeral_system_prompt: str | None,
|
||||
runtime_context: dict[str, Any] | None,
|
||||
status: str,
|
||||
source_run_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
timestamp = time.time()
|
||||
|
||||
# Try to resolve the active model configurations to populate Run panel metadata
|
||||
active_model = "unknown"
|
||||
active_provider = "unknown"
|
||||
try:
|
||||
from gateway.run import _resolve_gateway_model, _resolve_runtime_agent_kwargs
|
||||
active_model = _resolve_gateway_model()
|
||||
active_provider = _resolve_runtime_agent_kwargs().get("provider", "unknown")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
metadata: dict[str, Any] = {
|
||||
"session_id": session_id,
|
||||
"run_id": run_id,
|
||||
"prompt": prompt,
|
||||
"status": status,
|
||||
"model": active_model,
|
||||
"provider": active_provider,
|
||||
"created_at": timestamp,
|
||||
"updated_at": timestamp,
|
||||
"observed": True,
|
||||
}
|
||||
if conversation_history:
|
||||
metadata["conversation_history"] = list(conversation_history)
|
||||
if ephemeral_system_prompt is not None:
|
||||
metadata["ephemeral_system_prompt"] = ephemeral_system_prompt
|
||||
if runtime_context:
|
||||
metadata["runtime_context"] = dict(runtime_context)
|
||||
if source_run_id is not None:
|
||||
metadata["source_run_id"] = source_run_id
|
||||
return metadata
|
||||
|
||||
|
||||
async def _execute_tracked_run(
|
||||
*,
|
||||
service: ChatService,
|
||||
session_id: str,
|
||||
run_id: str,
|
||||
prompt: str,
|
||||
conversation_history: list[dict[str, Any]] | None,
|
||||
ephemeral_system_prompt: str | None,
|
||||
runtime_context: dict[str, Any] | None,
|
||||
) -> None:
|
||||
state = service.state
|
||||
|
||||
try:
|
||||
result = await service.run_chat(
|
||||
prompt=prompt,
|
||||
session_id=session_id,
|
||||
conversation_history=conversation_history,
|
||||
ephemeral_system_prompt=ephemeral_system_prompt,
|
||||
run_id=run_id,
|
||||
runtime_context=runtime_context,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
state.update_run(run_id, status="cancelled", updated_at=time.time())
|
||||
raise
|
||||
except Exception as exc:
|
||||
state.update_run(
|
||||
run_id,
|
||||
status="failed",
|
||||
updated_at=time.time(),
|
||||
error=str(exc),
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
return
|
||||
|
||||
update: dict[str, Any] = {
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
if result.get("failed"):
|
||||
update.update(
|
||||
{
|
||||
"status": "failed",
|
||||
"error": result.get("error") or "Run failed",
|
||||
}
|
||||
)
|
||||
else:
|
||||
update.update(
|
||||
{
|
||||
"status": "completed" if result.get("completed", True) else "running",
|
||||
"completed": result.get("completed", True),
|
||||
"final_response": ChatService._extract_assistant_text(dict(result or {})),
|
||||
"prompt_tokens": result.get("prompt_tokens"),
|
||||
"completion_tokens": result.get("completion_tokens"),
|
||||
"total_tokens": result.get("total_tokens"),
|
||||
}
|
||||
)
|
||||
state.update_run(run_id, **update)
|
||||
|
||||
|
||||
def _start_tracked_run(
|
||||
request: web.Request,
|
||||
*,
|
||||
service: ChatService,
|
||||
session_id: str,
|
||||
prompt: str,
|
||||
conversation_history: list[dict[str, Any]] | None,
|
||||
ephemeral_system_prompt: str | None,
|
||||
runtime_context: dict[str, Any] | None,
|
||||
source_run_id: str | None = None,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
run_id = str(uuid.uuid4())
|
||||
metadata = _build_run_metadata(
|
||||
session_id=session_id,
|
||||
run_id=run_id,
|
||||
prompt=prompt,
|
||||
conversation_history=conversation_history,
|
||||
ephemeral_system_prompt=ephemeral_system_prompt,
|
||||
runtime_context=runtime_context,
|
||||
status="started",
|
||||
source_run_id=source_run_id,
|
||||
)
|
||||
service.state.record_run(run_id, metadata)
|
||||
|
||||
task = asyncio.create_task(
|
||||
_execute_tracked_run(
|
||||
service=service,
|
||||
session_id=session_id,
|
||||
run_id=run_id,
|
||||
prompt=prompt,
|
||||
conversation_history=conversation_history,
|
||||
ephemeral_system_prompt=ephemeral_system_prompt,
|
||||
runtime_context=runtime_context,
|
||||
)
|
||||
)
|
||||
tasks = _get_chat_tasks(request.app)
|
||||
tasks.add(task)
|
||||
task.add_done_callback(tasks.discard)
|
||||
return run_id, metadata
|
||||
|
||||
|
||||
async def handle_chat_send(request: web.Request) -> web.Response:
|
||||
"""Start a chat run and immediately return tracked run metadata."""
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(
|
||||
status=400,
|
||||
code="invalid_json",
|
||||
message="Request body must be a valid JSON object.",
|
||||
)
|
||||
|
||||
prompt = data.get("prompt")
|
||||
if not isinstance(prompt, str) or not prompt.strip():
|
||||
return _json_error(
|
||||
status=400,
|
||||
code="invalid_prompt",
|
||||
message="The 'prompt' field must be a non-empty string.",
|
||||
)
|
||||
|
||||
session_id = data.get("session_id")
|
||||
if session_id is None:
|
||||
session_id = str(uuid.uuid4())
|
||||
elif not isinstance(session_id, str) or not session_id:
|
||||
return _json_error(
|
||||
status=400,
|
||||
code="invalid_session_id",
|
||||
message="The 'session_id' field must be a non-empty string when provided.",
|
||||
)
|
||||
conversation_history = data.get("conversation_history")
|
||||
if conversation_history is not None and not isinstance(conversation_history, list):
|
||||
return _json_error(
|
||||
status=400,
|
||||
code="invalid_conversation_history",
|
||||
message="The 'conversation_history' field must be a list when provided.",
|
||||
)
|
||||
if conversation_history is None:
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
conversation_history = db.get_messages_as_conversation(session_id)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("Failed to load history from DB: %s", e)
|
||||
conversation_history = []
|
||||
|
||||
runtime_context = data.get("runtime_context")
|
||||
if runtime_context is not None and not isinstance(runtime_context, dict):
|
||||
return _json_error(
|
||||
status=400,
|
||||
code="invalid_runtime_context",
|
||||
message="The 'runtime_context' field must be an object when provided.",
|
||||
)
|
||||
|
||||
ephemeral_system_prompt = data.get("ephemeral_system_prompt")
|
||||
if ephemeral_system_prompt is not None and not isinstance(ephemeral_system_prompt, str):
|
||||
return _json_error(
|
||||
status=400,
|
||||
code="invalid_ephemeral_system_prompt",
|
||||
message="The 'ephemeral_system_prompt' field must be a string when provided.",
|
||||
)
|
||||
|
||||
service = _get_chat_service(request)
|
||||
run_id, _ = _start_tracked_run(
|
||||
request,
|
||||
service=service,
|
||||
session_id=session_id,
|
||||
prompt=prompt,
|
||||
conversation_history=conversation_history,
|
||||
ephemeral_system_prompt=ephemeral_system_prompt,
|
||||
runtime_context=runtime_context,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"session_id": session_id,
|
||||
"run_id": run_id,
|
||||
"status": "started",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def handle_chat_background(request: web.Request) -> web.Response:
|
||||
"""Start a detached chat run in a new ephemeral session."""
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
|
||||
prompt = data.get("prompt")
|
||||
if not isinstance(prompt, str) or not prompt.strip():
|
||||
return _json_error(status=400, code="invalid_prompt", message="The 'prompt' field must be a non-empty string.")
|
||||
|
||||
# Always generate a new session ID for background runs so they don't pollute the caller's active session
|
||||
bg_session_id = str(uuid.uuid4())
|
||||
|
||||
runtime_context = data.get("runtime_context", {})
|
||||
runtime_context["is_background"] = True
|
||||
ephemeral_system_prompt = data.get("ephemeral_system_prompt")
|
||||
|
||||
service = _get_chat_service(request)
|
||||
# Note: We do NOT pass conversation_history. Background tasks execute freshly.
|
||||
run_id, _ = _start_tracked_run(
|
||||
request,
|
||||
service=service,
|
||||
session_id=bg_session_id,
|
||||
prompt=prompt,
|
||||
conversation_history=[],
|
||||
ephemeral_system_prompt=ephemeral_system_prompt,
|
||||
runtime_context=runtime_context,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"session_id": bg_session_id,
|
||||
"run_id": run_id,
|
||||
"status": "started",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def handle_chat_get_backgrounds(request: web.Request) -> web.Response:
|
||||
"""Return all tracked runs that are marked as background jobs."""
|
||||
service = _get_chat_service(request)
|
||||
runs = list(service.state.runs.values())
|
||||
bg_runs = [r for r in runs if r.get("runtime_context", {}).get("is_background")]
|
||||
bg_runs.reverse() # Newest first
|
||||
return web.json_response({"ok": True, "background_runs": bg_runs})
|
||||
|
||||
|
||||
async def handle_chat_btw(request: web.Request) -> web.Response:
|
||||
"""Start a quick-ask chat run that does not update session memory."""
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
|
||||
prompt = data.get("prompt")
|
||||
if not isinstance(prompt, str) or not prompt.strip():
|
||||
return _json_error(status=400, code="invalid_prompt", message="The 'prompt' field must be a non-empty string.")
|
||||
|
||||
session_id = data.get("session_id")
|
||||
if session_id is None:
|
||||
session_id = str(uuid.uuid4())
|
||||
elif not isinstance(session_id, str) or not session_id:
|
||||
return _json_error(status=400, code="invalid_session_id", message="The 'session_id' field must be a non-empty string when provided.")
|
||||
|
||||
conversation_history = data.get("conversation_history")
|
||||
if conversation_history is not None and not isinstance(conversation_history, list):
|
||||
return _json_error(
|
||||
status=400,
|
||||
code="invalid_conversation_history",
|
||||
message="The 'conversation_history' field must be a list when provided.",
|
||||
)
|
||||
if conversation_history is None:
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
conversation_history = db.get_messages_as_conversation(session_id)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("Failed to load history from DB: %s", e)
|
||||
conversation_history = []
|
||||
|
||||
runtime_context = data.get("runtime_context", {})
|
||||
runtime_context["quick_ask"] = True
|
||||
|
||||
ephemeral_system_prompt = data.get("ephemeral_system_prompt")
|
||||
|
||||
service = _get_chat_service(request)
|
||||
run_id, _ = _start_tracked_run(
|
||||
request,
|
||||
service=service,
|
||||
session_id=session_id,
|
||||
prompt=prompt,
|
||||
conversation_history=conversation_history,
|
||||
ephemeral_system_prompt=ephemeral_system_prompt,
|
||||
runtime_context=runtime_context,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"session_id": session_id,
|
||||
"run_id": run_id,
|
||||
"status": "started_btw",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def handle_chat_stop(request: web.Request) -> web.Response:
|
||||
"""Return stop capability metadata for a known run."""
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(
|
||||
status=400,
|
||||
code="invalid_json",
|
||||
message="Request body must be a valid JSON object.",
|
||||
)
|
||||
|
||||
run_id = data.get("run_id")
|
||||
if not isinstance(run_id, str) or not run_id:
|
||||
return _json_error(
|
||||
status=400,
|
||||
code="invalid_run_id",
|
||||
message="The 'run_id' field must be a non-empty string.",
|
||||
)
|
||||
|
||||
service = _get_chat_service(request)
|
||||
run = service.state.get_run(run_id)
|
||||
if run is None:
|
||||
return _json_error(
|
||||
status=404,
|
||||
code="run_not_found",
|
||||
message="No tracked run was found for the provided run_id.",
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"supported": False,
|
||||
"action": "stop",
|
||||
"run_id": run_id,
|
||||
"session_id": run["session_id"],
|
||||
"status": run.get("status"),
|
||||
"stop_requested": False,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def handle_chat_retry(request: web.Request) -> web.Response:
|
||||
"""Retry a previously observed run by reusing its tracked input metadata."""
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(
|
||||
status=400,
|
||||
code="invalid_json",
|
||||
message="Request body must be a valid JSON object.",
|
||||
)
|
||||
|
||||
run_id = data.get("run_id")
|
||||
if not isinstance(run_id, str) or not run_id:
|
||||
return _json_error(
|
||||
status=400,
|
||||
code="invalid_run_id",
|
||||
message="The 'run_id' field must be a non-empty string.",
|
||||
)
|
||||
|
||||
service = _get_chat_service(request)
|
||||
existing_run = service.state.get_run(run_id)
|
||||
if existing_run is None:
|
||||
return _json_error(
|
||||
status=404,
|
||||
code="run_not_found",
|
||||
message="No tracked run was found for the provided run_id.",
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
new_run_id, _ = _start_tracked_run(
|
||||
request,
|
||||
service=service,
|
||||
session_id=existing_run["session_id"],
|
||||
prompt=existing_run["prompt"],
|
||||
conversation_history=existing_run.get("conversation_history"),
|
||||
ephemeral_system_prompt=existing_run.get("ephemeral_system_prompt"),
|
||||
runtime_context=existing_run.get("runtime_context"),
|
||||
source_run_id=run_id,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"session_id": existing_run["session_id"],
|
||||
"run_id": new_run_id,
|
||||
"retried_from_run_id": run_id,
|
||||
"status": "started",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def handle_chat_undo(request: web.Request) -> web.Response:
|
||||
"""Return structured undo capability metadata."""
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(
|
||||
status=400,
|
||||
code="invalid_json",
|
||||
message="Request body must be a valid JSON object.",
|
||||
)
|
||||
|
||||
session_id = data.get("session_id")
|
||||
run_id = data.get("run_id")
|
||||
if session_id is not None and not isinstance(session_id, str):
|
||||
return _json_error(
|
||||
status=400,
|
||||
code="invalid_session_id",
|
||||
message="The 'session_id' field must be a string when provided.",
|
||||
)
|
||||
if run_id is not None and not isinstance(run_id, str):
|
||||
return _json_error(
|
||||
status=400,
|
||||
code="invalid_run_id",
|
||||
message="The 'run_id' field must be a string when provided.",
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"supported": False,
|
||||
"action": "undo",
|
||||
"session_id": session_id,
|
||||
"run_id": run_id,
|
||||
"status": "unavailable",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def handle_chat_run(request: web.Request) -> web.Response:
|
||||
"""Return tracked metadata for a previously observed run."""
|
||||
run_id = request.match_info["run_id"]
|
||||
service = _get_chat_service(request)
|
||||
run = service.state.get_run(run_id)
|
||||
if run is None:
|
||||
return _json_error(
|
||||
status=404,
|
||||
code="run_not_found",
|
||||
message="No tracked run was found for the provided run_id.",
|
||||
run_id=run_id,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"run": run,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def handle_chat_compress(request: web.Request) -> web.Response:
|
||||
"""Manually trigger context compression for a session."""
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
|
||||
session_id = data.get("session_id")
|
||||
if not isinstance(session_id, str) or not session_id:
|
||||
return _json_error(status=400, code="invalid_session_id", message="The 'session_id' field must be a non-empty string.")
|
||||
|
||||
focus_topic = data.get("focus_topic") # Optional guided compression topic
|
||||
|
||||
from run_agent import AIAgent
|
||||
|
||||
# Initialize agent for context loading
|
||||
# Run in executor to not block async loop if it's slow
|
||||
def _do_compress():
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
session_db = SessionDB()
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("SessionDB unavailable: %s", e)
|
||||
session_db = None
|
||||
|
||||
agent = AIAgent(session_id=session_id, session_db=session_db)
|
||||
if len(agent.messages) < agent.context_compressor.protect_first_n + agent.context_compressor.protect_last_n + 1:
|
||||
return {"ok": True, "compressed": False, "reason": "not_enough_messages"}
|
||||
|
||||
orig_len = len(agent.messages)
|
||||
compress_kwargs = {}
|
||||
if focus_topic:
|
||||
compress_kwargs["focus_topic"] = focus_topic
|
||||
agent.messages = agent.context_compressor.compress(agent.messages, **compress_kwargs)
|
||||
|
||||
if len(agent.messages) < orig_len:
|
||||
agent._flush_messages_to_session_db()
|
||||
return {"ok": True, "compressed": True, "original_length": orig_len, "new_length": len(agent.messages), "focus_topic": focus_topic or None}
|
||||
else:
|
||||
return {"ok": True, "compressed": False, "reason": "no_reduction"}
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
result = await loop.run_in_executor(None, _do_compress)
|
||||
return web.json_response(result)
|
||||
|
||||
|
||||
async def _cleanup_chat_tasks(app: web.Application) -> None:
|
||||
tasks = list(_get_chat_tasks(app))
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
for task in tasks:
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
|
||||
|
||||
def register_chat_api_routes(app: web.Application) -> None:
|
||||
"""Register web-console chat API routes on an aiohttp application."""
|
||||
_get_chat_tasks(app)
|
||||
if app.get(CHAT_STATE_APP_KEY) is None:
|
||||
app[CHAT_STATE_APP_KEY] = create_web_console_state()
|
||||
if app.get(CHAT_SERVICE_APP_KEY) is None:
|
||||
app[CHAT_SERVICE_APP_KEY] = ChatService(
|
||||
state=app[CHAT_STATE_APP_KEY],
|
||||
human_service=app.get(HUMAN_SERVICE_APP_KEY),
|
||||
)
|
||||
if not app.get(CHAT_CLEANUP_REGISTERED_APP_KEY):
|
||||
app.on_cleanup.append(_cleanup_chat_tasks)
|
||||
app[CHAT_CLEANUP_REGISTERED_APP_KEY] = True
|
||||
|
||||
app.router.add_post("/api/gui/chat/send", handle_chat_send)
|
||||
app.router.add_post("/api/gui/chat/background", handle_chat_background)
|
||||
app.router.add_post("/api/gui/chat/btw", handle_chat_btw)
|
||||
app.router.add_post("/api/gui/chat/compress", handle_chat_compress)
|
||||
app.router.add_get("/api/gui/chat/backgrounds", handle_chat_get_backgrounds)
|
||||
app.router.add_post("/api/gui/chat/stop", handle_chat_stop)
|
||||
app.router.add_post("/api/gui/chat/retry", handle_chat_retry)
|
||||
app.router.add_post("/api/gui/chat/undo", handle_chat_undo)
|
||||
app.router.add_get("/api/gui/chat/run/{run_id}", handle_chat_run)
|
||||
34
gateway/web_console/api/commands.py
Normal file
34
gateway/web_console/api/commands.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Command registry API routes for the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from hermes_cli.commands import COMMAND_REGISTRY
|
||||
|
||||
|
||||
def _command_entry(command) -> dict[str, object]:
|
||||
aliases = list(command.aliases)
|
||||
names = [command.name, *aliases]
|
||||
return {
|
||||
"name": command.name,
|
||||
"description": command.description,
|
||||
"category": command.category,
|
||||
"aliases": aliases,
|
||||
"names": names,
|
||||
"args_hint": command.args_hint,
|
||||
"subcommands": list(command.subcommands),
|
||||
"cli_only": command.cli_only,
|
||||
"gateway_only": command.gateway_only,
|
||||
"gateway_config_gate": command.gateway_config_gate,
|
||||
}
|
||||
|
||||
|
||||
async def handle_list_commands(request: web.Request) -> web.Response:
|
||||
"""GET /api/gui/commands — expose the shared Hermes slash-command registry."""
|
||||
commands = [_command_entry(command) for command in COMMAND_REGISTRY]
|
||||
return web.json_response({"ok": True, "commands": commands})
|
||||
|
||||
|
||||
def register_commands_api_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/gui/commands", handle_list_commands)
|
||||
324
gateway/web_console/api/credentials.py
Normal file
324
gateway/web_console/api/credentials.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""Credential Pool API routes for the Hermes Web Console backend.
|
||||
|
||||
Provides:
|
||||
GET /api/gui/credentials/pool — list all credential pool entries for a provider
|
||||
POST /api/gui/credentials/pool — add a new credential to the pool
|
||||
DELETE /api/gui/credentials/pool/:entry_id — remove a credential from the pool
|
||||
POST /api/gui/credentials/device-auth — initiate device code auth flow (Codex/ChatGPT)
|
||||
POST /api/gui/credentials/device-auth/poll — poll for device code completion
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from aiohttp import web
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_list_pool(request: web.Request) -> web.Response:
|
||||
"""GET /api/gui/credentials/pool?provider=openai-codex — list credential pool entries."""
|
||||
provider = request.query.get("provider", "openai-codex")
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import read_credential_pool, _load_auth_store
|
||||
raw_entries = read_credential_pool(provider)
|
||||
|
||||
active_token = None
|
||||
if provider == "openai-codex":
|
||||
try:
|
||||
from hermes_cli.auth import _read_codex_tokens
|
||||
codex_store = _read_codex_tokens()
|
||||
active_token = codex_store.get("tokens", {}).get("access_token")
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
store = _load_auth_store()
|
||||
provider_data = store.get("providers", {}).get(provider, {})
|
||||
if isinstance(provider_data, dict):
|
||||
active_token = provider_data.get("access_token")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
entries = []
|
||||
if isinstance(raw_entries, list):
|
||||
for entry in raw_entries:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
entries.append({
|
||||
"id": entry.get("id", ""),
|
||||
"label": entry.get("label", ""),
|
||||
"auth_type": entry.get("auth_type", ""),
|
||||
"source": entry.get("source", ""),
|
||||
"priority": entry.get("priority", 0),
|
||||
"last_status": entry.get("last_status"),
|
||||
"last_status_at": entry.get("last_status_at"),
|
||||
"last_error_code": entry.get("last_error_code"),
|
||||
"last_error_reason": entry.get("last_error_reason"),
|
||||
"request_count": entry.get("request_count", 0),
|
||||
"has_token": bool(entry.get("access_token")),
|
||||
"is_active": bool(active_token and entry.get("access_token") == active_token),
|
||||
})
|
||||
|
||||
return web.json_response({"ok": True, "provider": provider, "entries": entries})
|
||||
except Exception as exc:
|
||||
logger.warning("list_pool failed: %s", exc)
|
||||
return web.json_response({"ok": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
async def handle_delete_pool_entry(request: web.Request) -> web.Response:
|
||||
"""DELETE /api/gui/credentials/pool/:entry_id — remove a credential."""
|
||||
entry_id = request.match_info.get("entry_id", "")
|
||||
provider = request.query.get("provider", "openai-codex")
|
||||
|
||||
if not entry_id:
|
||||
return web.json_response({"ok": False, "error": "entry_id is required"}, status=400)
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import read_credential_pool, write_credential_pool
|
||||
raw_entries = read_credential_pool(provider)
|
||||
if not isinstance(raw_entries, list):
|
||||
return web.json_response({"ok": False, "error": "No pool found"}, status=404)
|
||||
|
||||
filtered = [e for e in raw_entries if isinstance(e, dict) and e.get("id") != entry_id]
|
||||
if len(filtered) == len(raw_entries):
|
||||
return web.json_response({"ok": False, "error": "Entry not found"}, status=404)
|
||||
|
||||
write_credential_pool(provider, filtered)
|
||||
return web.json_response({"ok": True, "removed": entry_id})
|
||||
except Exception as exc:
|
||||
logger.warning("delete_pool_entry failed: %s", exc)
|
||||
return web.json_response({"ok": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
async def handle_activate_pool_entry(request: web.Request) -> web.Response:
|
||||
"""POST /api/gui/credentials/pool/:entry_id/activate — set credential as active."""
|
||||
entry_id = request.match_info.get("entry_id", "")
|
||||
provider = request.query.get("provider", "openai-codex")
|
||||
|
||||
if not entry_id:
|
||||
return web.json_response({"ok": False, "error": "entry_id is required"}, status=400)
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import read_credential_pool
|
||||
raw_entries = read_credential_pool(provider)
|
||||
if not isinstance(raw_entries, list):
|
||||
return web.json_response({"ok": False, "error": "No pool found"}, status=404)
|
||||
|
||||
entry = next((e for e in raw_entries if isinstance(e, dict) and e.get("id") == entry_id), None)
|
||||
if not entry:
|
||||
return web.json_response({"ok": False, "error": "Entry not found"}, status=404)
|
||||
|
||||
if provider == "openai-codex":
|
||||
from hermes_cli.auth import _save_codex_tokens
|
||||
_save_codex_tokens(
|
||||
{
|
||||
"access_token": entry.get("access_token"),
|
||||
"refresh_token": entry.get("refresh_token")
|
||||
},
|
||||
last_refresh=entry.get("last_refresh") or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
)
|
||||
|
||||
# Switch the active provider to openai-codex natively
|
||||
try:
|
||||
from hermes_cli.config import load_config, save_config
|
||||
from hermes_cli.model_switch import detect_provider_for_model
|
||||
cfg = load_config()
|
||||
cfg["provider"] = "openai-codex"
|
||||
|
||||
old_model = cfg.get("model", {})
|
||||
old_model_name = old_model if isinstance(old_model, str) else old_model.get("default", "")
|
||||
|
||||
if old_model_name:
|
||||
detected, _ = detect_provider_for_model(old_model_name, "openai-codex")
|
||||
if detected not in ("openai", "openai-codex", "custom"):
|
||||
if "model" not in cfg:
|
||||
cfg["model"] = {}
|
||||
if isinstance(cfg["model"], dict):
|
||||
cfg["model"]["default"] = "gpt-5.4"
|
||||
cfg["model"]["name"] = "gpt-5.4"
|
||||
|
||||
save_config(cfg)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to update config.yaml during codex activation: %s", exc)
|
||||
else:
|
||||
from hermes_cli.auth import _save_provider_state, _load_auth_store, _save_auth_store
|
||||
store = _load_auth_store()
|
||||
_save_provider_state(store, provider, {
|
||||
"access_token": entry.get("access_token"),
|
||||
"refresh_token": entry.get("refresh_token"),
|
||||
"last_refresh": entry.get("last_refresh"),
|
||||
"auth_mode": entry.get("source", "oauth"),
|
||||
"source": "gui",
|
||||
})
|
||||
_save_auth_store(store)
|
||||
return web.json_response({"ok": True, "activated": entry_id})
|
||||
except Exception as exc:
|
||||
logger.warning("activate_pool_entry failed: %s", exc)
|
||||
return web.json_response({"ok": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
# --- Device Code Auth Flow ---
|
||||
|
||||
# In-flight device code sessions (keyed by device_code)
|
||||
_device_sessions: dict[str, dict] = {}
|
||||
|
||||
|
||||
async def handle_device_auth_start(request: web.Request) -> web.Response:
|
||||
"""POST /api/gui/credentials/device-auth — start a Codex device code flow.
|
||||
|
||||
Returns the user_code and verification_uri for the user to authorize in browser.
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
|
||||
provider = body.get("provider", "openai-codex")
|
||||
|
||||
if provider != "openai-codex":
|
||||
return web.json_response(
|
||||
{"ok": False, "error": f"Device auth not supported for {provider}"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import _codex_device_code_request
|
||||
result = _codex_device_code_request()
|
||||
|
||||
# Store the session for polling
|
||||
device_auth_id = result.get("device_auth_id", "")
|
||||
# OpenAI doesn't return these in the new schema, so we hardcode them
|
||||
verification_uri = "https://auth.openai.com/codex/device"
|
||||
user_code = result.get("user_code", "")
|
||||
interval = int(result.get("interval", "5"))
|
||||
|
||||
_device_sessions[device_auth_id] = {
|
||||
"provider": provider,
|
||||
"device_code": device_auth_id,
|
||||
"user_code": user_code,
|
||||
"verification_uri": verification_uri,
|
||||
"verification_uri_complete": verification_uri,
|
||||
"expires_in": 900, # 15 min default
|
||||
"interval": interval,
|
||||
"started_at": time.time(),
|
||||
}
|
||||
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"device_code": device_auth_id,
|
||||
"user_code": user_code,
|
||||
"verification_uri": verification_uri,
|
||||
"verification_uri_complete": verification_uri,
|
||||
"expires_in": 900,
|
||||
"interval": interval,
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning("device_auth_start failed: %s", exc)
|
||||
return web.json_response({"ok": False, "error": str(exc)}, status=500)
|
||||
|
||||
|
||||
async def handle_device_auth_poll(request: web.Request) -> web.Response:
|
||||
"""POST /api/gui/credentials/device-auth/poll — poll for device auth completion.
|
||||
|
||||
Body: {"device_code": "..."}
|
||||
Returns: {"status": "pending|complete|expired|error", ...}
|
||||
"""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"ok": False, "error": "Invalid JSON"}, status=400)
|
||||
|
||||
device_code = body.get("device_code", "")
|
||||
label = body.get("label", "")
|
||||
|
||||
session = _device_sessions.get(device_code)
|
||||
if not session:
|
||||
return web.json_response(
|
||||
{"ok": False, "status": "error", "error": "Unknown device code session"},
|
||||
status=404,
|
||||
)
|
||||
|
||||
# Check expiration
|
||||
elapsed = time.time() - session["started_at"]
|
||||
if elapsed > session["expires_in"]:
|
||||
_device_sessions.pop(device_code, None)
|
||||
return web.json_response({"ok": True, "status": "expired"})
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import _codex_device_code_poll
|
||||
poll_result = _codex_device_code_poll(device_code, session["user_code"])
|
||||
|
||||
if poll_result is None:
|
||||
return web.json_response({"ok": True, "status": "pending"})
|
||||
|
||||
# Success! We got tokens
|
||||
access_token = poll_result.get("access_token", "")
|
||||
refresh_token = poll_result.get("refresh_token", "")
|
||||
|
||||
# Auto-label from JWT
|
||||
if not label:
|
||||
try:
|
||||
from hermes_cli.auth import _decode_jwt_claims
|
||||
claims = _decode_jwt_claims(access_token)
|
||||
label = claims.get("email") or claims.get("preferred_username") or "ChatGPT account"
|
||||
except Exception:
|
||||
label = "ChatGPT account"
|
||||
|
||||
# Add to credential pool
|
||||
import uuid
|
||||
entry_id = uuid.uuid4().hex[:6]
|
||||
from hermes_cli.auth import read_credential_pool, write_credential_pool
|
||||
existing = read_credential_pool("openai-codex")
|
||||
if not isinstance(existing, list):
|
||||
existing = []
|
||||
|
||||
new_entry = {
|
||||
"id": entry_id,
|
||||
"label": label,
|
||||
"auth_type": "oauth",
|
||||
"source": "chatgpt",
|
||||
"priority": len(existing),
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"last_refresh": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"request_count": 0,
|
||||
}
|
||||
existing.append(new_entry)
|
||||
write_credential_pool("openai-codex", existing)
|
||||
|
||||
# Also persist to the legacy provider state for backward compatibility
|
||||
try:
|
||||
from hermes_cli.auth import _save_codex_tokens
|
||||
_save_codex_tokens({
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
}, last_refresh=new_entry["last_refresh"])
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to save codex tokens: %s", exc)
|
||||
|
||||
_device_sessions.pop(device_code, None)
|
||||
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"status": "complete",
|
||||
"entry_id": entry_id,
|
||||
"label": label,
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning("device_auth_poll failed: %s", exc)
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"status": "error",
|
||||
"error": str(exc),
|
||||
})
|
||||
|
||||
|
||||
def register_credentials_api_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/gui/credentials/pool", handle_list_pool)
|
||||
app.router.add_delete("/api/gui/credentials/pool/{entry_id}", handle_delete_pool_entry)
|
||||
app.router.add_post("/api/gui/credentials/pool/{entry_id}/activate", handle_activate_pool_entry)
|
||||
app.router.add_post("/api/gui/credentials/device-auth", handle_device_auth_start)
|
||||
app.router.add_post("/api/gui/credentials/device-auth/poll", handle_device_auth_poll)
|
||||
190
gateway/web_console/api/cron.py
Normal file
190
gateway/web_console/api/cron.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""Cron API routes for the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from gateway.web_console.services.cron_service import CronService
|
||||
|
||||
CRON_SERVICE_APP_KEY = web.AppKey("hermes_web_console_cron_service", CronService)
|
||||
|
||||
|
||||
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
|
||||
payload: dict[str, Any] = {
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
payload["error"].update(extra)
|
||||
return web.json_response(payload, status=status)
|
||||
|
||||
|
||||
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
|
||||
try:
|
||||
data = await request.json()
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _get_cron_service(request: web.Request) -> CronService:
|
||||
service = request.app.get(CRON_SERVICE_APP_KEY)
|
||||
if service is None:
|
||||
service = CronService()
|
||||
request.app[CRON_SERVICE_APP_KEY] = service
|
||||
return service
|
||||
|
||||
|
||||
def _parse_non_negative_int(value: str, *, field_name: str) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"The '{field_name}' field must be an integer.")
|
||||
if parsed < 0:
|
||||
raise ValueError(f"The '{field_name}' field must be >= 0.")
|
||||
return parsed
|
||||
|
||||
|
||||
def _job_not_found(job_id: str) -> web.Response:
|
||||
return _json_error(
|
||||
status=404,
|
||||
code="job_not_found",
|
||||
message="No cron job was found for the provided job_id.",
|
||||
job_id=job_id,
|
||||
)
|
||||
|
||||
|
||||
async def handle_list_jobs(request: web.Request) -> web.Response:
|
||||
service = _get_cron_service(request)
|
||||
include_disabled = request.query.get("include_disabled", "true").lower() not in {"0", "false", "no"}
|
||||
payload = service.list_jobs(include_disabled=include_disabled)
|
||||
return web.json_response({"ok": True, **payload})
|
||||
|
||||
|
||||
async def handle_create_job(request: web.Request) -> web.Response:
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
|
||||
service = _get_cron_service(request)
|
||||
try:
|
||||
job = service.create_job(data)
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_job", message=str(exc))
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="job_create_failed", message=str(exc))
|
||||
return web.json_response({"ok": True, "job": job})
|
||||
|
||||
|
||||
async def handle_get_job(request: web.Request) -> web.Response:
|
||||
service = _get_cron_service(request)
|
||||
job_id = request.match_info["job_id"]
|
||||
job = service.get_job(job_id)
|
||||
if job is None:
|
||||
return _job_not_found(job_id)
|
||||
return web.json_response({"ok": True, "job": job})
|
||||
|
||||
|
||||
async def handle_update_job(request: web.Request) -> web.Response:
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
|
||||
service = _get_cron_service(request)
|
||||
job_id = request.match_info["job_id"]
|
||||
try:
|
||||
job = service.update_job(job_id, data)
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_job", message=str(exc), job_id=job_id)
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="job_update_failed", message=str(exc), job_id=job_id)
|
||||
if job is None:
|
||||
return _job_not_found(job_id)
|
||||
return web.json_response({"ok": True, "job": job})
|
||||
|
||||
|
||||
async def handle_run_job(request: web.Request) -> web.Response:
|
||||
service = _get_cron_service(request)
|
||||
job_id = request.match_info["job_id"]
|
||||
job = service.run_job(job_id)
|
||||
if job is None:
|
||||
return _job_not_found(job_id)
|
||||
return web.json_response({"ok": True, "job": job, "queued": True})
|
||||
|
||||
|
||||
async def handle_pause_job(request: web.Request) -> web.Response:
|
||||
data = await _read_json_body(request)
|
||||
if request.can_read_body and request.content_length not in (None, 0) and data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
|
||||
reason = None if data is None else data.get("reason")
|
||||
service = _get_cron_service(request)
|
||||
job_id = request.match_info["job_id"]
|
||||
try:
|
||||
job = service.pause_job(job_id, reason=reason)
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_job", message=str(exc), job_id=job_id)
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="job_pause_failed", message=str(exc), job_id=job_id)
|
||||
if job is None:
|
||||
return _job_not_found(job_id)
|
||||
return web.json_response({"ok": True, "job": job})
|
||||
|
||||
|
||||
async def handle_resume_job(request: web.Request) -> web.Response:
|
||||
service = _get_cron_service(request)
|
||||
job_id = request.match_info["job_id"]
|
||||
job = service.resume_job(job_id)
|
||||
if job is None:
|
||||
return _job_not_found(job_id)
|
||||
return web.json_response({"ok": True, "job": job})
|
||||
|
||||
|
||||
async def handle_delete_job(request: web.Request) -> web.Response:
|
||||
service = _get_cron_service(request)
|
||||
job_id = request.match_info["job_id"]
|
||||
deleted = service.delete_job(job_id)
|
||||
if not deleted:
|
||||
return _job_not_found(job_id)
|
||||
return web.json_response({"ok": True, "job_id": job_id, "deleted": True})
|
||||
|
||||
|
||||
async def handle_job_history(request: web.Request) -> web.Response:
|
||||
service = _get_cron_service(request)
|
||||
job_id = request.match_info["job_id"]
|
||||
try:
|
||||
limit = _parse_non_negative_int(request.query.get("limit", "20"), field_name="limit")
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_pagination", message=str(exc))
|
||||
|
||||
try:
|
||||
payload = service.get_job_history(job_id, limit=limit)
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_pagination", message=str(exc), job_id=job_id)
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="job_history_failed", message=str(exc), job_id=job_id)
|
||||
if payload is None:
|
||||
return _job_not_found(job_id)
|
||||
return web.json_response({"ok": True, **payload})
|
||||
|
||||
|
||||
def register_cron_api_routes(app: web.Application) -> None:
|
||||
if app.get(CRON_SERVICE_APP_KEY) is None:
|
||||
app[CRON_SERVICE_APP_KEY] = CronService()
|
||||
|
||||
app.router.add_get("/api/gui/cron/jobs", handle_list_jobs)
|
||||
app.router.add_post("/api/gui/cron/jobs", handle_create_job)
|
||||
app.router.add_get("/api/gui/cron/jobs/{job_id}", handle_get_job)
|
||||
app.router.add_patch("/api/gui/cron/jobs/{job_id}", handle_update_job)
|
||||
app.router.add_post("/api/gui/cron/jobs/{job_id}/run", handle_run_job)
|
||||
app.router.add_post("/api/gui/cron/jobs/{job_id}/pause", handle_pause_job)
|
||||
app.router.add_post("/api/gui/cron/jobs/{job_id}/resume", handle_resume_job)
|
||||
app.router.add_delete("/api/gui/cron/jobs/{job_id}", handle_delete_job)
|
||||
app.router.add_get("/api/gui/cron/jobs/{job_id}/history", handle_job_history)
|
||||
345
gateway/web_console/api/gateway_admin.py
Normal file
345
gateway/web_console/api/gateway_admin.py
Normal file
@@ -0,0 +1,345 @@
|
||||
"""Gateway admin API routes for the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from gateway.web_console.services.gateway_service import GatewayService
|
||||
from gateway.web_console.services.settings_service import SettingsService
|
||||
from hermes_cli.config import save_env_value
|
||||
|
||||
GATEWAY_SERVICE_APP_KEY = web.AppKey("hermes_web_console_gateway_service", GatewayService)
|
||||
SETTINGS_SERVICE_APP_KEY = web.AppKey("hermes_web_console_settings_service", SettingsService)
|
||||
|
||||
|
||||
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
|
||||
payload: dict[str, Any] = {"ok": False, "error": {"code": code, "message": message}}
|
||||
payload["error"].update(extra)
|
||||
return web.json_response(payload, status=status)
|
||||
|
||||
|
||||
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
|
||||
try:
|
||||
data = await request.json()
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _get_gateway_service(request: web.Request) -> GatewayService:
|
||||
service = request.app.get(GATEWAY_SERVICE_APP_KEY)
|
||||
if service is None:
|
||||
service = GatewayService()
|
||||
request.app[GATEWAY_SERVICE_APP_KEY] = service
|
||||
return service
|
||||
|
||||
def _get_settings_service(request: web.Request) -> SettingsService:
|
||||
service = request.app.get(SETTINGS_SERVICE_APP_KEY)
|
||||
if service is None:
|
||||
service = SettingsService()
|
||||
request.app[SETTINGS_SERVICE_APP_KEY] = service
|
||||
return service
|
||||
|
||||
|
||||
def _require_non_empty_string(data: dict[str, Any], field_name: str) -> str | None:
|
||||
value = data.get(field_name)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
return value.strip()
|
||||
|
||||
|
||||
async def handle_gateway_overview(request: web.Request) -> web.Response:
|
||||
service = _get_gateway_service(request)
|
||||
return web.json_response({"ok": True, "overview": service.get_overview()})
|
||||
|
||||
|
||||
async def handle_gateway_platforms(request: web.Request) -> web.Response:
|
||||
service = _get_gateway_service(request)
|
||||
return web.json_response({"ok": True, "platforms": service.get_platforms()})
|
||||
|
||||
|
||||
async def handle_gateway_pairing(request: web.Request) -> web.Response:
|
||||
service = _get_gateway_service(request)
|
||||
return web.json_response({"ok": True, "pairing": service.get_pairing_state()})
|
||||
|
||||
|
||||
async def handle_gateway_pairing_approve(request: web.Request) -> web.Response:
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
|
||||
platform = _require_non_empty_string(data, "platform")
|
||||
if platform is None:
|
||||
return _json_error(status=400, code="invalid_platform", message="The 'platform' field must be a non-empty string.")
|
||||
|
||||
code = _require_non_empty_string(data, "code")
|
||||
if code is None:
|
||||
return _json_error(status=400, code="invalid_code", message="The 'code' field must be a non-empty string.")
|
||||
|
||||
service = _get_gateway_service(request)
|
||||
approved = service.approve_pairing(platform=platform, code=code)
|
||||
if approved is None:
|
||||
return _json_error(
|
||||
status=404,
|
||||
code="pairing_not_found",
|
||||
message="No pending pairing request was found for that platform/code.",
|
||||
platform=platform.lower(),
|
||||
pairing_code=code.upper(),
|
||||
)
|
||||
|
||||
return web.json_response({"ok": True, "pairing": approved})
|
||||
|
||||
|
||||
async def handle_gateway_pairing_revoke(request: web.Request) -> web.Response:
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
|
||||
platform = _require_non_empty_string(data, "platform")
|
||||
if platform is None:
|
||||
return _json_error(status=400, code="invalid_platform", message="The 'platform' field must be a non-empty string.")
|
||||
|
||||
user_id = _require_non_empty_string(data, "user_id")
|
||||
if user_id is None:
|
||||
return _json_error(status=400, code="invalid_user_id", message="The 'user_id' field must be a non-empty string.")
|
||||
|
||||
service = _get_gateway_service(request)
|
||||
revoked = service.revoke_pairing(platform=platform, user_id=user_id)
|
||||
if not revoked:
|
||||
return _json_error(
|
||||
status=404,
|
||||
code="paired_user_not_found",
|
||||
message="No approved pairing entry was found for that platform/user.",
|
||||
platform=platform.lower(),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"pairing": {
|
||||
"platform": platform.lower(),
|
||||
"user_id": user_id,
|
||||
"revoked": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
async def handle_gateway_platform_config_get(request: web.Request) -> web.Response:
|
||||
platform_name = request.match_info.get("name")
|
||||
if not platform_name:
|
||||
return _json_error(status=400, code="missing_platform", message="Platform name is required.")
|
||||
|
||||
settings_service = _get_settings_service(request)
|
||||
settings = settings_service.get_settings()
|
||||
platforms_config = settings.get("platforms", {})
|
||||
platform_config = platforms_config.get(platform_name, {})
|
||||
|
||||
if "home_channel" in platform_config and isinstance(platform_config["home_channel"], dict):
|
||||
platform_config["home_channel"] = platform_config["home_channel"].get("chat_id", "")
|
||||
|
||||
from hermes_cli.config import get_env_value
|
||||
|
||||
env_map = {
|
||||
"telegram": "TELEGRAM_BOT_TOKEN",
|
||||
"discord": "DISCORD_BOT_TOKEN",
|
||||
"slack": "SLACK_BOT_TOKEN",
|
||||
"mattermost": "MATTERMOST_TOKEN",
|
||||
"matrix": "MATRIX_ACCESS_TOKEN",
|
||||
"homeassistant": "HASS_TOKEN",
|
||||
}
|
||||
|
||||
env_var = env_map.get(platform_name.lower())
|
||||
if env_var:
|
||||
val = get_env_value(env_var)
|
||||
if val:
|
||||
platform_config["token"] = val
|
||||
|
||||
if platform_name.lower() == "feishu":
|
||||
for key, env_var in [
|
||||
("app_id", "FEISHU_APP_ID"),
|
||||
("app_secret", "FEISHU_APP_SECRET"),
|
||||
("encrypt_key", "FEISHU_ENCRYPT_KEY"),
|
||||
("verification_token", "FEISHU_VERIFICATION_TOKEN"),
|
||||
]:
|
||||
val = get_env_value(env_var)
|
||||
if val:
|
||||
platform_config[key] = val
|
||||
elif platform_name.lower() == "wecom":
|
||||
for key, env_var in [
|
||||
("bot_id", "WECOM_BOT_ID"),
|
||||
("secret", "WECOM_SECRET"),
|
||||
]:
|
||||
val = get_env_value(env_var)
|
||||
if val:
|
||||
platform_config[key] = val
|
||||
|
||||
return web.json_response({"ok": True, "config": platform_config})
|
||||
|
||||
|
||||
async def handle_gateway_platform_config_patch(request: web.Request) -> web.Response:
|
||||
platform_name = request.match_info.get("name")
|
||||
if not platform_name:
|
||||
return _json_error(status=400, code="missing_platform", message="Platform name is required.")
|
||||
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
|
||||
settings_service = _get_settings_service(request)
|
||||
|
||||
# Platform-specific env extraction
|
||||
if platform_name.lower() == "feishu":
|
||||
for key, env_var in [
|
||||
("app_id", "FEISHU_APP_ID"),
|
||||
("app_secret", "FEISHU_APP_SECRET"),
|
||||
("encrypt_key", "FEISHU_ENCRYPT_KEY"),
|
||||
("verification_token", "FEISHU_VERIFICATION_TOKEN"),
|
||||
]:
|
||||
if key in data:
|
||||
save_env_value(env_var, data.pop(key))
|
||||
elif platform_name.lower() == "wecom":
|
||||
for key, env_var in [
|
||||
("bot_id", "WECOM_BOT_ID"),
|
||||
("secret", "WECOM_SECRET"),
|
||||
]:
|
||||
if key in data:
|
||||
save_env_value(env_var, data.pop(key))
|
||||
|
||||
home_channel_val = data.get("home_channel")
|
||||
if home_channel_val is not None:
|
||||
if isinstance(home_channel_val, str):
|
||||
if home_channel_val.strip():
|
||||
data["home_channel"] = {
|
||||
"platform": platform_name.lower(),
|
||||
"chat_id": home_channel_val,
|
||||
"name": "Home"
|
||||
}
|
||||
else:
|
||||
data.pop("home_channel", None)
|
||||
|
||||
# We must patch the token separately via env vars if it exists so it goes to .env
|
||||
token = data.pop("token", None)
|
||||
if token is not None:
|
||||
# Standardize env var names based on common conventions
|
||||
env_map = {
|
||||
"telegram": "TELEGRAM_BOT_TOKEN",
|
||||
"discord": "DISCORD_BOT_TOKEN",
|
||||
"slack": "SLACK_BOT_TOKEN",
|
||||
"mattermost": "MATTERMOST_TOKEN",
|
||||
"matrix": "MATRIX_ACCESS_TOKEN",
|
||||
"homeassistant": "HASS_TOKEN",
|
||||
}
|
||||
env_var = env_map.get(platform_name.lower())
|
||||
if env_var:
|
||||
save_env_value(env_var, token)
|
||||
else:
|
||||
# Fallback to saving in config if no standard env var
|
||||
data["token"] = token
|
||||
|
||||
patch_payload = {
|
||||
"platforms": {
|
||||
platform_name: data
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
updated = settings_service.update_settings(patch_payload)
|
||||
except Exception as exc:
|
||||
return _json_error(status=400, code="invalid_patch", message=str(exc))
|
||||
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"config": updated.get("platforms", {}).get(platform_name, {}),
|
||||
"reload_required": True
|
||||
})
|
||||
|
||||
async def handle_gateway_platform_start(request: web.Request) -> web.Response:
|
||||
platform_name = request.match_info.get("name")
|
||||
if not platform_name:
|
||||
return _json_error(status=400, code="missing_platform", message="Platform name is required.")
|
||||
|
||||
settings_service = _get_settings_service(request)
|
||||
patch_payload = {
|
||||
"platforms": {
|
||||
platform_name: {"enabled": True}
|
||||
}
|
||||
}
|
||||
try:
|
||||
settings_service.update_settings(patch_payload)
|
||||
except Exception as exc:
|
||||
return _json_error(status=400, code="invalid_patch", message=str(exc))
|
||||
|
||||
return web.json_response({"ok": True, "reload_required": True})
|
||||
|
||||
async def handle_gateway_platform_stop(request: web.Request) -> web.Response:
|
||||
platform_name = request.match_info.get("name")
|
||||
if not platform_name:
|
||||
return _json_error(status=400, code="missing_platform", message="Platform name is required.")
|
||||
|
||||
settings_service = _get_settings_service(request)
|
||||
patch_payload = {
|
||||
"platforms": {
|
||||
platform_name: {"enabled": False}
|
||||
}
|
||||
}
|
||||
try:
|
||||
settings_service.update_settings(patch_payload)
|
||||
except Exception as exc:
|
||||
return _json_error(status=400, code="invalid_patch", message=str(exc))
|
||||
|
||||
return web.json_response({"ok": True, "reload_required": True})
|
||||
|
||||
|
||||
async def handle_gateway_restart(request: web.Request) -> web.Response:
|
||||
from gateway.web_console.routes import ADAPTER_APP_KEY
|
||||
|
||||
adapter = request.app.get(ADAPTER_APP_KEY)
|
||||
message_handler = getattr(adapter, "_message_handler", None)
|
||||
runner = getattr(message_handler, "__self__", None)
|
||||
request_restart = getattr(runner, "request_restart", None)
|
||||
if not callable(request_restart):
|
||||
return _json_error(
|
||||
status=501,
|
||||
code="restart_unsupported",
|
||||
message="Gateway restart is not available from this web-console host.",
|
||||
)
|
||||
|
||||
try:
|
||||
accepted = bool(request_restart(detached=True, via_service=False))
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="restart_failed", message=str(exc))
|
||||
|
||||
if not accepted:
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"accepted": False,
|
||||
"message": "Gateway restart is already in progress.",
|
||||
})
|
||||
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"accepted": True,
|
||||
"message": "Gateway restart requested. Active runs will drain before restart.",
|
||||
})
|
||||
|
||||
|
||||
def register_gateway_admin_api_routes(app: web.Application) -> None:
|
||||
if app.get(GATEWAY_SERVICE_APP_KEY) is None:
|
||||
app[GATEWAY_SERVICE_APP_KEY] = GatewayService()
|
||||
|
||||
app.router.add_get("/api/gui/gateway/overview", handle_gateway_overview)
|
||||
app.router.add_get("/api/gui/gateway/platforms", handle_gateway_platforms)
|
||||
app.router.add_get("/api/gui/gateway/platforms/{name}/config", handle_gateway_platform_config_get)
|
||||
app.router.add_patch("/api/gui/gateway/platforms/{name}/config", handle_gateway_platform_config_patch)
|
||||
app.router.add_post("/api/gui/gateway/platforms/{name}/start", handle_gateway_platform_start)
|
||||
app.router.add_post("/api/gui/gateway/platforms/{name}/stop", handle_gateway_platform_stop)
|
||||
app.router.add_post("/api/gui/gateway/restart", handle_gateway_restart)
|
||||
app.router.add_get("/api/gui/gateway/pairing", handle_gateway_pairing)
|
||||
app.router.add_post("/api/gui/gateway/pairing/approve", handle_gateway_pairing_approve)
|
||||
app.router.add_post("/api/gui/gateway/pairing/revoke", handle_gateway_pairing_revoke)
|
||||
50
gateway/web_console/api/logs.py
Normal file
50
gateway/web_console/api/logs.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""Logs API routes for the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from gateway.web_console.services.log_service import LogService
|
||||
|
||||
LOG_SERVICE_APP_KEY = web.AppKey("hermes_web_console_log_service", LogService)
|
||||
|
||||
|
||||
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
|
||||
payload: dict[str, Any] = {"ok": False, "error": {"code": code, "message": message}}
|
||||
payload["error"].update(extra)
|
||||
return web.json_response(payload, status=status)
|
||||
|
||||
|
||||
def _get_log_service(request: web.Request) -> LogService:
|
||||
service = request.app.get(LOG_SERVICE_APP_KEY)
|
||||
if service is None:
|
||||
service = LogService()
|
||||
request.app[LOG_SERVICE_APP_KEY] = service
|
||||
return service
|
||||
|
||||
|
||||
async def handle_get_logs(request: web.Request) -> web.Response:
|
||||
file_name = request.query.get("file") or None
|
||||
limit_raw = request.query.get("limit", "200")
|
||||
try:
|
||||
limit = int(limit_raw)
|
||||
except (TypeError, ValueError):
|
||||
return _json_error(status=400, code="invalid_limit", message="The 'limit' field must be an integer.")
|
||||
if limit < 0:
|
||||
return _json_error(status=400, code="invalid_limit", message="The 'limit' field must be >= 0.")
|
||||
|
||||
service = _get_log_service(request)
|
||||
try:
|
||||
logs = service.get_logs(file_name=file_name, limit=limit)
|
||||
except FileNotFoundError:
|
||||
return _json_error(status=404, code="log_not_found", message="The requested log file was not found.", file=file_name)
|
||||
return web.json_response({"ok": True, "logs": logs})
|
||||
|
||||
|
||||
def register_logs_api_routes(app: web.Application) -> None:
|
||||
if app.get(LOG_SERVICE_APP_KEY) is None:
|
||||
app[LOG_SERVICE_APP_KEY] = LogService()
|
||||
|
||||
app.router.add_get("/api/gui/logs", handle_get_logs)
|
||||
52
gateway/web_console/api/mcp.py
Normal file
52
gateway/web_console/api/mcp.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import logging
|
||||
from aiohttp import web
|
||||
|
||||
logger = logging.getLogger("mcp_api")
|
||||
|
||||
async def handle_get_mcp_servers(request: web.Request) -> web.Response:
|
||||
"""List all configured MCP servers and their connection status."""
|
||||
try:
|
||||
from tools.mcp_tool import get_mcp_status
|
||||
status_list = get_mcp_status()
|
||||
return web.json_response({"servers": status_list})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch MCP servers: {e}")
|
||||
return web.json_response({"servers": []})
|
||||
|
||||
async def handle_reload_mcp_servers(request: web.Request) -> web.Response:
|
||||
"""Shutdown and reload MCP servers from config."""
|
||||
try:
|
||||
from tools.mcp_tool import shutdown_mcp_servers, discover_mcp_tools, _servers, _lock
|
||||
logger.info("Reloading MCP servers from GUI request...")
|
||||
|
||||
old_servers = set()
|
||||
if _lock:
|
||||
with _lock:
|
||||
old_servers = set(_servers.keys())
|
||||
|
||||
# Disconnect all existing servers
|
||||
shutdown_mcp_servers()
|
||||
|
||||
# Reconnect using updated config
|
||||
new_tools = discover_mcp_tools()
|
||||
|
||||
new_servers = set()
|
||||
if _lock:
|
||||
with _lock:
|
||||
new_servers = set(_servers.keys())
|
||||
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"tools_count": len(new_tools),
|
||||
"servers_count": len(new_servers),
|
||||
"added": list(new_servers - old_servers),
|
||||
"removed": list(old_servers - new_servers),
|
||||
"reconnected": list(new_servers & old_servers)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reload MCP servers: {e}")
|
||||
return web.json_response({"success": False, "error": str(e)}, status=500)
|
||||
|
||||
def register_mcp_api_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/gui/mcp/servers", handle_get_mcp_servers)
|
||||
app.router.add_post("/api/gui/mcp/reload", handle_reload_mcp_servers)
|
||||
131
gateway/web_console/api/media.py
Normal file
131
gateway/web_console/api/media.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Media API routes for the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from hermes_cli.config import ensure_hermes_home, get_hermes_home
|
||||
|
||||
|
||||
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
|
||||
payload: dict[str, Any] = {"ok": False, "error": {"code": code, "message": message}}
|
||||
payload["error"].update(extra)
|
||||
return web.json_response(payload, status=status)
|
||||
|
||||
|
||||
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
|
||||
try:
|
||||
data = await request.json()
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _load_tool_module(module_name: str, file_name: str) -> ModuleType:
|
||||
module_path = Path(__file__).resolve().parents[3] / "tools" / file_name
|
||||
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"Could not load tool module from {module_path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _media_upload_dir() -> Path:
|
||||
ensure_hermes_home()
|
||||
target = get_hermes_home() / "uploads" / "web_console"
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
return target
|
||||
|
||||
|
||||
async def handle_media_upload(request: web.Request) -> web.Response:
|
||||
if not request.content_type.startswith("multipart/"):
|
||||
return _json_error(status=400, code="invalid_upload", message="Upload requests must use multipart/form-data.")
|
||||
reader = await request.multipart()
|
||||
part = await reader.next()
|
||||
if part is None or part.name != "file":
|
||||
return _json_error(status=400, code="missing_file", message="The upload must include a 'file' field.")
|
||||
|
||||
filename = part.filename or f"upload-{uuid.uuid4().hex}"
|
||||
safe_name = Path(filename).name or f"upload-{uuid.uuid4().hex}"
|
||||
destination = _media_upload_dir() / safe_name
|
||||
if destination.exists():
|
||||
destination = _media_upload_dir() / f"{destination.stem}-{uuid.uuid4().hex[:8]}{destination.suffix}"
|
||||
|
||||
size = 0
|
||||
with destination.open("wb") as handle:
|
||||
while True:
|
||||
chunk = await part.read_chunk()
|
||||
if not chunk:
|
||||
break
|
||||
size += len(chunk)
|
||||
handle.write(chunk)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"ok": True,
|
||||
"media": {
|
||||
"file_path": str(destination),
|
||||
"filename": destination.name,
|
||||
"content_type": part.headers.get("Content-Type"),
|
||||
"size": size,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def handle_media_transcribe(request: web.Request) -> web.Response:
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
|
||||
file_path = data.get("file_path")
|
||||
if not isinstance(file_path, str) or not file_path.strip():
|
||||
return _json_error(status=400, code="invalid_file_path", message="The 'file_path' field must be a non-empty string.")
|
||||
|
||||
transcription_tools = _load_tool_module("hermes_web_console_transcription_tools", "transcription_tools.py")
|
||||
result = transcription_tools.transcribe_audio(file_path.strip(), model=data.get("model"))
|
||||
status = 200 if result.get("success") else 400
|
||||
return web.json_response({"ok": bool(result.get("success")), "transcription": result}, status=status)
|
||||
|
||||
|
||||
async def handle_media_tts(request: web.Request) -> web.Response:
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
|
||||
text = data.get("text")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
return _json_error(status=400, code="invalid_text", message="The 'text' field must be a non-empty string.")
|
||||
|
||||
output_path = data.get("output_path")
|
||||
if output_path is not None and not isinstance(output_path, str):
|
||||
return _json_error(status=400, code="invalid_output_path", message="The 'output_path' field must be a string when provided.")
|
||||
|
||||
tts_tool = _load_tool_module("hermes_web_console_tts_tool", "tts_tool.py")
|
||||
raw_result = tts_tool.text_to_speech_tool(text=text, output_path=output_path)
|
||||
try:
|
||||
result = json.loads(raw_result)
|
||||
except (TypeError, ValueError):
|
||||
result = {"success": False, "error": "TTS returned an invalid payload.", "raw_result": raw_result}
|
||||
status = 200 if result.get("success") else 400
|
||||
return web.json_response({"ok": bool(result.get("success")), "tts": result}, status=status)
|
||||
|
||||
|
||||
async def handle_media_transcribe_upload(request: web.Request) -> web.Response:
|
||||
return _json_error(status=501, code="not_implemented", message="Use /api/gui/media/upload followed by /api/gui/media/transcribe.")
|
||||
|
||||
|
||||
def register_media_api_routes(app: web.Application) -> None:
|
||||
app.router.add_post("/api/gui/media/upload", handle_media_upload)
|
||||
app.router.add_post("/api/gui/media/transcribe", handle_media_transcribe)
|
||||
app.router.add_post("/api/gui/media/tts", handle_media_tts)
|
||||
195
gateway/web_console/api/memory.py
Normal file
195
gateway/web_console/api/memory.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""GUI backend API routes for Memory and session search."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from gateway.web_console.services.memory_service import MemoryService
|
||||
|
||||
MEMORY_SERVICE_APP_KEY = web.AppKey("hermes_web_console_memory_service", MemoryService)
|
||||
|
||||
|
||||
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
|
||||
payload: dict[str, Any] = {"ok": False, "error": {"code": code, "message": message}}
|
||||
payload["error"].update(extra)
|
||||
return web.json_response(payload, status=status)
|
||||
|
||||
|
||||
def _get_memory_service(request: web.Request) -> MemoryService:
|
||||
service = request.app.get(MEMORY_SERVICE_APP_KEY)
|
||||
if service is None:
|
||||
service = MemoryService()
|
||||
request.app[MEMORY_SERVICE_APP_KEY] = service
|
||||
return service
|
||||
|
||||
|
||||
async def handle_get_memory(request: web.Request) -> web.Response:
|
||||
"""GET /api/gui/memory — return structured memory entries."""
|
||||
service = _get_memory_service(request)
|
||||
try:
|
||||
payload = service.get_memory(target="memory")
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="memory_error", message=str(exc))
|
||||
return web.json_response({"ok": True, "memory": payload})
|
||||
|
||||
|
||||
async def handle_get_user_profile(request: web.Request) -> web.Response:
|
||||
"""GET /api/gui/user-profile — return structured user profile entries."""
|
||||
service = _get_memory_service(request)
|
||||
try:
|
||||
payload = service.get_memory(target="user")
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="memory_error", message=str(exc))
|
||||
return web.json_response({"ok": True, "user_profile": payload})
|
||||
|
||||
|
||||
async def handle_add_memory(request: web.Request) -> web.Response:
|
||||
"""POST /api/gui/memory — add an entry to memory or user profile."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be valid JSON.")
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a JSON object.")
|
||||
|
||||
target = data.get("target", "memory")
|
||||
content = data.get("content")
|
||||
|
||||
service = _get_memory_service(request)
|
||||
try:
|
||||
result = service.mutate_memory(action="add", target=target, content=content)
|
||||
except PermissionError as exc:
|
||||
return _json_error(status=403, code="memory_disabled", message=str(exc))
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_target", message=str(exc))
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="memory_error", message=str(exc))
|
||||
|
||||
return web.json_response({"ok": True, "memory": result})
|
||||
|
||||
|
||||
async def handle_update_memory(request: web.Request) -> web.Response:
|
||||
"""PATCH /api/gui/memory — replace an entry in memory or user profile."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be valid JSON.")
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a JSON object.")
|
||||
|
||||
target = data.get("target", "memory")
|
||||
content = data.get("content")
|
||||
old_text = data.get("old_text")
|
||||
|
||||
service = _get_memory_service(request)
|
||||
try:
|
||||
result = service.mutate_memory(action="replace", target=target, content=content, old_text=old_text)
|
||||
except PermissionError as exc:
|
||||
return _json_error(status=403, code="memory_disabled", message=str(exc))
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_target", message=str(exc))
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="memory_error", message=str(exc))
|
||||
|
||||
if not result.get("success"):
|
||||
extra: dict[str, Any] = {}
|
||||
if "matches" in result:
|
||||
extra["matches"] = result["matches"]
|
||||
return _json_error(
|
||||
status=400,
|
||||
code="memory_update_failed",
|
||||
message=result.get("error", "Update failed."),
|
||||
**extra,
|
||||
)
|
||||
|
||||
return web.json_response({"ok": True, "memory": result})
|
||||
|
||||
|
||||
async def handle_delete_memory(request: web.Request) -> web.Response:
|
||||
"""DELETE /api/gui/memory — remove an entry from memory or user profile."""
|
||||
try:
|
||||
data = await request.json()
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be valid JSON.")
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a JSON object.")
|
||||
|
||||
target = data.get("target", "memory")
|
||||
old_text = data.get("old_text")
|
||||
|
||||
service = _get_memory_service(request)
|
||||
try:
|
||||
result = service.mutate_memory(action="remove", target=target, old_text=old_text)
|
||||
except PermissionError as exc:
|
||||
return _json_error(status=403, code="memory_disabled", message=str(exc))
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_target", message=str(exc))
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="memory_error", message=str(exc))
|
||||
|
||||
return web.json_response({"ok": True, "memory": result})
|
||||
|
||||
|
||||
async def handle_session_search(request: web.Request) -> web.Response:
|
||||
"""GET /api/gui/session-search — search past sessions."""
|
||||
query = request.query.get("query")
|
||||
if not query or not query.strip():
|
||||
return _json_error(status=400, code="missing_query", message="The 'query' parameter is required.")
|
||||
|
||||
role_filter = request.query.get("role_filter")
|
||||
current_session_id = request.query.get("current_session_id")
|
||||
|
||||
limit_raw = request.query.get("limit")
|
||||
limit = 3
|
||||
if limit_raw is not None:
|
||||
try:
|
||||
limit = int(limit_raw)
|
||||
if limit < 1:
|
||||
raise ValueError()
|
||||
except (ValueError, TypeError):
|
||||
return _json_error(status=400, code="invalid_search", message="The 'limit' parameter must be a positive integer.")
|
||||
|
||||
service = _get_memory_service(request)
|
||||
try:
|
||||
result = service.search_sessions(
|
||||
query=query.strip(),
|
||||
role_filter=role_filter,
|
||||
limit=limit,
|
||||
current_session_id=current_session_id,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
return _json_error(status=500, code="search_failed", message=str(exc))
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="search_failed", message=str(exc))
|
||||
|
||||
if not result.get("success", True):
|
||||
return _json_error(
|
||||
status=503,
|
||||
code="search_failed",
|
||||
message=result.get("error", "Session search failed."),
|
||||
)
|
||||
|
||||
return web.json_response({"ok": True, "search": result})
|
||||
|
||||
|
||||
def register_memory_api_routes(app: web.Application) -> None:
|
||||
if app.get(MEMORY_SERVICE_APP_KEY) is None:
|
||||
try:
|
||||
app[MEMORY_SERVICE_APP_KEY] = MemoryService()
|
||||
except Exception:
|
||||
# MemoryService may fail to initialize in test environments
|
||||
# where the memory tool module is not available.
|
||||
pass
|
||||
|
||||
app.router.add_get("/api/gui/memory", handle_get_memory)
|
||||
app.router.add_post("/api/gui/memory", handle_add_memory)
|
||||
app.router.add_patch("/api/gui/memory", handle_update_memory)
|
||||
app.router.add_delete("/api/gui/memory", handle_delete_memory)
|
||||
app.router.add_get("/api/gui/user-profile", handle_get_user_profile)
|
||||
app.router.add_get("/api/gui/session-search", handle_session_search)
|
||||
46
gateway/web_console/api/metrics.py
Normal file
46
gateway/web_console/api/metrics.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Metrics API routes for the Hermes Web Console backend."""
|
||||
|
||||
import logging
|
||||
import psutil
|
||||
import time
|
||||
from aiohttp import web
|
||||
|
||||
logger = logging.getLogger("metrics_api")
|
||||
|
||||
async def handle_get_metrics_global(request: web.Request) -> web.Response:
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
from agent.insights import InsightsEngine
|
||||
from tools.process_registry import process_registry
|
||||
|
||||
cron_jobs_count = 0
|
||||
try:
|
||||
from cron.scheduler import scheduler
|
||||
cron_jobs_count = len(scheduler.get_jobs()) if hasattr(scheduler, "get_jobs") else 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db = SessionDB()
|
||||
engine = InsightsEngine(db)
|
||||
|
||||
# Fetch current day usage as a representative metric snippet
|
||||
report = engine.generate(days=1)
|
||||
db.close()
|
||||
|
||||
metrics = {
|
||||
"token_usage_today": report.get("total_tokens", 0),
|
||||
"cost_today": report.get("estimated_cost_usd", 0.0),
|
||||
"active_processes": len([s for s in process_registry.list_sessions() if s.get("status") == "running"]),
|
||||
"cron_jobs": cron_jobs_count,
|
||||
"cpu_percent": psutil.cpu_percent(interval=None),
|
||||
"memory_percent": psutil.virtual_memory().percent,
|
||||
"uptime_seconds": time.time() - psutil.boot_time(),
|
||||
}
|
||||
|
||||
return web.json_response({"ok": True, "metrics": metrics})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch global metrics: {e}")
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
def register_metrics_api_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/gui/metrics/global", handle_get_metrics_global)
|
||||
44
gateway/web_console/api/missions.py
Normal file
44
gateway/web_console/api/missions.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""GUI backend API routes for Missions Kanban Board."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
def get_missions_file_path() -> Path:
|
||||
return get_hermes_home() / "missions.json"
|
||||
|
||||
async def handle_get_missions(request: web.Request) -> web.Response:
|
||||
"""Retrieve missions.json content."""
|
||||
missions_file = get_missions_file_path()
|
||||
|
||||
if not missions_file.exists():
|
||||
return web.json_response({"ok": True, "columns": None})
|
||||
|
||||
try:
|
||||
content = missions_file.read_text(encoding="utf-8")
|
||||
data = json.loads(content)
|
||||
return web.json_response({"ok": True, "columns": data})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
async def handle_update_missions(request: web.Request) -> web.Response:
|
||||
"""Overwrite missions.json content entirely."""
|
||||
try:
|
||||
data = await request.json()
|
||||
columns = data.get("columns", [])
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response({"ok": False, "error": "Invalid JSON"}, status=400)
|
||||
|
||||
missions_file = get_missions_file_path()
|
||||
try:
|
||||
missions_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
missions_file.write_text(json.dumps(columns, indent=2), encoding="utf-8")
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
def register_missions_api_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/gui/missions", handle_get_missions)
|
||||
app.router.add_post("/api/gui/missions", handle_update_missions)
|
||||
235
gateway/web_console/api/models_api.py
Normal file
235
gateway/web_console/api/models_api.py
Normal file
@@ -0,0 +1,235 @@
|
||||
"""Models API routes for the Hermes Web Console backend.
|
||||
|
||||
Provides:
|
||||
GET /api/gui/models/catalog — authenticated providers with curated models
|
||||
GET /api/gui/models/active — currently active model + provider
|
||||
POST /api/gui/models/switch — live model/provider switch (session or global)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from aiohttp import web
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _read_model_config() -> dict:
|
||||
"""Read model/provider/base_url/providers from config.yaml."""
|
||||
config_path = get_hermes_home() / "config.yaml"
|
||||
result = {
|
||||
"model": "",
|
||||
"provider": "openrouter",
|
||||
"base_url": "",
|
||||
"api_key": "",
|
||||
"user_providers": None,
|
||||
}
|
||||
try:
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
cfg = yaml.safe_load(f) or {}
|
||||
model_cfg = cfg.get("model", {})
|
||||
if isinstance(model_cfg, dict):
|
||||
result["model"] = model_cfg.get("name", "") or model_cfg.get("default", "")
|
||||
result["provider"] = model_cfg.get("provider", "openrouter")
|
||||
result["base_url"] = model_cfg.get("base_url", "")
|
||||
elif isinstance(model_cfg, str):
|
||||
result["model"] = model_cfg
|
||||
result["user_providers"] = cfg.get("providers")
|
||||
except Exception:
|
||||
logger.debug("Failed to read model config", exc_info=True)
|
||||
return result
|
||||
|
||||
|
||||
async def handle_get_models_catalog(request: web.Request) -> web.Response:
|
||||
"""GET /api/gui/models/catalog — authenticated providers with curated models."""
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
from hermes_cli.providers import get_label
|
||||
|
||||
cfg = _read_model_config()
|
||||
current_model = cfg["model"]
|
||||
current_provider = cfg["provider"]
|
||||
|
||||
try:
|
||||
providers = list_authenticated_providers(
|
||||
current_provider=current_provider,
|
||||
user_providers=cfg["user_providers"],
|
||||
max_models=20,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("list_authenticated_providers failed: %s", exc)
|
||||
providers = []
|
||||
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"current_model": current_model,
|
||||
"current_provider": current_provider,
|
||||
"current_provider_label": get_label(current_provider),
|
||||
"providers": providers,
|
||||
})
|
||||
|
||||
|
||||
async def handle_get_models_active(request: web.Request) -> web.Response:
|
||||
"""GET /api/gui/models/active — current model + provider + metadata."""
|
||||
from hermes_cli.providers import get_label
|
||||
from agent.models_dev import get_model_info, get_model_capabilities
|
||||
|
||||
cfg = _read_model_config()
|
||||
current_model = cfg["model"]
|
||||
current_provider = cfg["provider"]
|
||||
provider_label = get_label(current_provider)
|
||||
|
||||
# Try to get rich metadata from models.dev
|
||||
metadata: dict = {}
|
||||
try:
|
||||
mi = get_model_info(current_provider, current_model)
|
||||
if mi:
|
||||
metadata["context_window"] = mi.context_window or 0
|
||||
metadata["max_output"] = mi.max_output or 0
|
||||
if mi.has_cost_data():
|
||||
metadata["cost"] = mi.format_cost()
|
||||
metadata["capabilities"] = mi.format_capabilities()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"model": current_model,
|
||||
"provider": current_provider,
|
||||
"provider_label": provider_label,
|
||||
**metadata,
|
||||
})
|
||||
|
||||
|
||||
async def handle_post_models_switch(request: web.Request) -> web.Response:
|
||||
"""POST /api/gui/models/switch — live model/provider switch."""
|
||||
from hermes_cli.model_switch import switch_model as _switch_model
|
||||
from hermes_cli.providers import determine_api_mode
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return web.json_response({"ok": False, "error": "Invalid JSON body"}, status=400)
|
||||
|
||||
model_input = body.get("model", "").strip()
|
||||
explicit_provider = body.get("provider", "").strip()
|
||||
persist_global = body.get("global", False)
|
||||
|
||||
if not model_input and not explicit_provider:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "Provide 'model' and/or 'provider'"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
cfg = _read_model_config()
|
||||
|
||||
result = _switch_model(
|
||||
raw_input=model_input,
|
||||
current_provider=cfg["provider"],
|
||||
current_model=cfg["model"],
|
||||
current_base_url=cfg["base_url"],
|
||||
current_api_key=cfg["api_key"],
|
||||
is_global=persist_global,
|
||||
explicit_provider=explicit_provider,
|
||||
user_providers=cfg["user_providers"],
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
return web.json_response({
|
||||
"ok": False,
|
||||
"error": result.error_message,
|
||||
}, status=400)
|
||||
|
||||
# Validate provider compatibility (specifically for Codex)
|
||||
codex_compatible = ("openai", "openai-codex", "custom")
|
||||
# Guard 1: Currently on Codex — new model must be OpenAI-compatible
|
||||
if cfg.get("provider") == "openai-codex":
|
||||
if result.target_provider not in codex_compatible:
|
||||
return web.json_response({
|
||||
"ok": False,
|
||||
"error": f"The '{result.new_model}' model is not supported when using Codex with a ChatGPT account. Please select an OpenAI model or change your active provider to {result.target_provider}."
|
||||
}, status=400)
|
||||
# Guard 2: Switching TO Codex — resolved model must be OpenAI-compatible
|
||||
if result.target_provider == "openai-codex":
|
||||
if cfg.get("provider") not in codex_compatible and result.target_provider not in codex_compatible:
|
||||
return web.json_response({
|
||||
"ok": False,
|
||||
"error": f"Cannot switch to Codex with model '{result.new_model}' (resolved provider: {result.target_provider}). Only OpenAI models are compatible with Codex."
|
||||
}, status=400)
|
||||
|
||||
# Persist to config.yaml if global
|
||||
if persist_global:
|
||||
try:
|
||||
config_path = get_hermes_home() / "config.yaml"
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
file_cfg = yaml.safe_load(f) or {}
|
||||
else:
|
||||
file_cfg = {}
|
||||
|
||||
# Ensure model is a dict before setting properties
|
||||
model_cfg = file_cfg.get("model", {})
|
||||
if isinstance(model_cfg, str):
|
||||
model_cfg = {"default": model_cfg}
|
||||
elif not isinstance(model_cfg, dict):
|
||||
model_cfg = {}
|
||||
|
||||
model_cfg["default"] = result.new_model
|
||||
model_cfg["provider"] = result.target_provider
|
||||
model_cfg["name"] = result.new_model # Keep name for backward compatibility in UI
|
||||
if result.base_url:
|
||||
model_cfg["base_url"] = result.base_url
|
||||
|
||||
file_cfg["model"] = model_cfg
|
||||
|
||||
from hermes_cli.config import save_config
|
||||
save_config(file_cfg)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to persist model switch: %s", exc)
|
||||
|
||||
# Build rich response
|
||||
response: dict = {
|
||||
"ok": True,
|
||||
"new_model": result.new_model,
|
||||
"provider": result.target_provider,
|
||||
"provider_label": result.provider_label or result.target_provider,
|
||||
"provider_changed": result.provider_changed,
|
||||
"is_global": persist_global,
|
||||
}
|
||||
|
||||
# Add model metadata
|
||||
mi = result.model_info
|
||||
if mi:
|
||||
if mi.context_window:
|
||||
response["context_window"] = mi.context_window
|
||||
if mi.max_output:
|
||||
response["max_output"] = mi.max_output
|
||||
if mi.has_cost_data():
|
||||
response["cost"] = mi.format_cost()
|
||||
response["capabilities"] = mi.format_capabilities()
|
||||
|
||||
# Cache status
|
||||
cache_enabled = (
|
||||
("openrouter" in (result.base_url or "").lower()
|
||||
and "claude" in result.new_model.lower())
|
||||
or result.api_mode == "anthropic_messages"
|
||||
)
|
||||
response["cache_enabled"] = cache_enabled
|
||||
|
||||
if result.warning_message:
|
||||
response["warning"] = result.warning_message
|
||||
|
||||
if result.resolved_via_alias:
|
||||
response["resolved_via_alias"] = result.resolved_via_alias
|
||||
|
||||
return web.json_response(response)
|
||||
|
||||
|
||||
def register_models_api_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/gui/models/catalog", handle_get_models_catalog)
|
||||
app.router.add_get("/api/gui/models/active", handle_get_models_active)
|
||||
app.router.add_post("/api/gui/models/switch", handle_post_models_switch)
|
||||
14
gateway/web_console/api/plugins.py
Normal file
14
gateway/web_console/api/plugins.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from aiohttp import web
|
||||
from hermes_cli.plugins import get_plugin_manager, discover_plugins
|
||||
|
||||
async def handle_get_plugins(request: web.Request) -> web.Response:
|
||||
# Ensure plugins are discovered
|
||||
discover_plugins()
|
||||
|
||||
manager = get_plugin_manager()
|
||||
plugins = manager.list_plugins()
|
||||
return web.json_response({"ok": True, "plugins": plugins})
|
||||
|
||||
|
||||
def register_plugins_api_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/gui/plugins", handle_get_plugins)
|
||||
146
gateway/web_console/api/profiles.py
Normal file
146
gateway/web_console/api/profiles.py
Normal file
@@ -0,0 +1,146 @@
|
||||
from aiohttp import web
|
||||
from dataclasses import asdict
|
||||
from hermes_cli.profiles import (
|
||||
list_profiles,
|
||||
create_profile,
|
||||
delete_profile,
|
||||
get_active_profile,
|
||||
set_active_profile,
|
||||
export_profile,
|
||||
import_profile,
|
||||
)
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
async def handle_get_profiles(request: web.Request) -> web.Response:
|
||||
profiles = list_profiles()
|
||||
active_profile = get_active_profile()
|
||||
|
||||
# Convert ProfileInfo dataclasses to dicts
|
||||
profile_list = []
|
||||
for p in profiles:
|
||||
pd = asdict(p)
|
||||
pd["path"] = str(pd["path"])
|
||||
if pd["alias_path"]:
|
||||
pd["alias_path"] = str(pd["alias_path"])
|
||||
pd["is_active"] = p.name == active_profile
|
||||
profile_list.append(pd)
|
||||
|
||||
return web.json_response({"ok": True, "profiles": profile_list, "active_profile": active_profile})
|
||||
|
||||
|
||||
async def handle_post_profile(request: web.Request) -> web.Response:
|
||||
data = await request.json()
|
||||
name = data.get("name")
|
||||
if not name:
|
||||
return web.json_response({"ok": False, "error": "Profile name is required"}, status=400)
|
||||
|
||||
try:
|
||||
new_dir = create_profile(
|
||||
name=name,
|
||||
clone_from=data.get("clone_from"),
|
||||
clone_all=data.get("clone_all", False),
|
||||
clone_config=data.get("clone_config", False),
|
||||
no_alias=data.get("no_alias", False),
|
||||
)
|
||||
return web.json_response({"ok": True, "path": str(new_dir)})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=400)
|
||||
|
||||
|
||||
async def handle_delete_profile(request: web.Request) -> web.Response:
|
||||
name = request.match_info.get("name")
|
||||
if not name:
|
||||
return web.json_response({"ok": False, "error": "Profile name is required"}, status=400)
|
||||
|
||||
try:
|
||||
deleted_dir = delete_profile(name, yes=True)
|
||||
return web.json_response({"ok": True, "path": str(deleted_dir)})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=400)
|
||||
|
||||
|
||||
async def handle_set_active_profile(request: web.Request) -> web.Response:
|
||||
data = await request.json()
|
||||
name = data.get("name")
|
||||
if not name:
|
||||
return web.json_response({"ok": False, "error": "Profile name is required"}, status=400)
|
||||
|
||||
try:
|
||||
set_active_profile(name)
|
||||
return web.json_response({"ok": True})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=400)
|
||||
|
||||
async def handle_export_profile(request: web.Request) -> web.Response:
|
||||
name = request.match_info.get("name")
|
||||
if not name:
|
||||
return web.json_response({"ok": False, "error": "Profile name is required"}, status=400)
|
||||
|
||||
fd, temp_path = tempfile.mkstemp(suffix=".tar.gz")
|
||||
os.close(fd)
|
||||
|
||||
try:
|
||||
export_profile(name, temp_path)
|
||||
|
||||
with open(temp_path, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
response = web.Response(body=data)
|
||||
response.headers['Content-Disposition'] = f'attachment; filename="profile_{name}.tar.gz"'
|
||||
response.headers['Content-Type'] = 'application/gzip'
|
||||
return response
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=400)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
async def handle_import_profile(request: web.Request) -> web.Response:
|
||||
try:
|
||||
reader = await request.multipart()
|
||||
name = None
|
||||
file_data = None
|
||||
|
||||
while True:
|
||||
field = await reader.next()
|
||||
if field is None:
|
||||
break
|
||||
|
||||
if field.name == 'name':
|
||||
name = await field.read()
|
||||
name = name.decode('utf-8').strip()
|
||||
elif field.name == 'file':
|
||||
file_data = await field.read()
|
||||
|
||||
if not file_data:
|
||||
return web.json_response({"ok": False, "error": "No file data uploaded"}, status=400)
|
||||
|
||||
fd, temp_path = tempfile.mkstemp(suffix=".tar.gz")
|
||||
os.close(fd)
|
||||
|
||||
try:
|
||||
with open(temp_path, "wb") as f:
|
||||
f.write(file_data)
|
||||
|
||||
# If name is empty string or None, import_profile infers it
|
||||
import_name = name if name else None
|
||||
imported_dir = import_profile(temp_path, import_name)
|
||||
|
||||
return web.json_response({"ok": True, "path": str(imported_dir)})
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=400)
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
except Exception as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=400)
|
||||
|
||||
|
||||
def register_profiles_api_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/gui/profiles", handle_get_profiles)
|
||||
app.router.add_post("/api/gui/profiles", handle_post_profile)
|
||||
app.router.add_delete("/api/gui/profiles/{name}", handle_delete_profile)
|
||||
app.router.add_post("/api/gui/profiles/active", handle_set_active_profile)
|
||||
app.router.add_get("/api/gui/profiles/{name}/export", handle_export_profile)
|
||||
app.router.add_post("/api/gui/profiles/import", handle_import_profile)
|
||||
268
gateway/web_console/api/sessions.py
Normal file
268
gateway/web_console/api/sessions.py
Normal file
@@ -0,0 +1,268 @@
|
||||
"""Sessions API routes for the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from gateway.web_console.services.session_service import SessionService
|
||||
|
||||
SESSIONS_SERVICE_APP_KEY = web.AppKey("hermes_web_console_sessions_service", SessionService)
|
||||
|
||||
|
||||
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
|
||||
payload: dict[str, Any] = {
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
payload["error"].update(extra)
|
||||
return web.json_response(payload, status=status)
|
||||
|
||||
|
||||
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
|
||||
try:
|
||||
data = await request.json()
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _get_session_service(request: web.Request) -> SessionService:
|
||||
return request.app[SESSIONS_SERVICE_APP_KEY]
|
||||
|
||||
|
||||
def _parse_non_negative_int(value: str, *, field_name: str) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError(f"The '{field_name}' field must be an integer.")
|
||||
if parsed < 0:
|
||||
raise ValueError(f"The '{field_name}' field must be >= 0.")
|
||||
return parsed
|
||||
|
||||
|
||||
async def handle_list_sessions(request: web.Request) -> web.Response:
|
||||
service = _get_session_service(request)
|
||||
source = request.query.get("source") or None
|
||||
try:
|
||||
limit = _parse_non_negative_int(request.query.get("limit", "20"), field_name="limit")
|
||||
offset = _parse_non_negative_int(request.query.get("offset", "0"), field_name="offset")
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_pagination", message=str(exc))
|
||||
sessions = service.list_sessions(source=source, limit=limit, offset=offset)
|
||||
return web.json_response({"ok": True, "sessions": sessions})
|
||||
|
||||
|
||||
async def handle_get_session(request: web.Request) -> web.Response:
|
||||
service = _get_session_service(request)
|
||||
session_id = request.match_info["session_id"]
|
||||
session = service.get_session_detail(session_id)
|
||||
if session is None:
|
||||
return _json_error(status=404, code="session_not_found", message="No session was found for the provided session_id.", session_id=session_id)
|
||||
return web.json_response({"ok": True, "session": session})
|
||||
|
||||
|
||||
async def handle_get_transcript(request: web.Request) -> web.Response:
|
||||
service = _get_session_service(request)
|
||||
session_id = request.match_info["session_id"]
|
||||
transcript = service.get_transcript(session_id)
|
||||
if transcript is None:
|
||||
return _json_error(status=404, code="session_not_found", message="No session was found for the provided session_id.", session_id=session_id)
|
||||
return web.json_response({"ok": True, **transcript})
|
||||
|
||||
|
||||
async def handle_set_title(request: web.Request) -> web.Response:
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
title = data.get("title")
|
||||
if not isinstance(title, str):
|
||||
return _json_error(status=400, code="invalid_title", message="The 'title' field must be a string.")
|
||||
|
||||
service = _get_session_service(request)
|
||||
session_id = request.match_info["session_id"]
|
||||
try:
|
||||
result = service.set_title(session_id, title)
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_title", message=str(exc))
|
||||
if result is None:
|
||||
return _json_error(status=404, code="session_not_found", message="No session was found for the provided session_id.", session_id=session_id)
|
||||
return web.json_response({"ok": True, **result})
|
||||
|
||||
|
||||
async def handle_resume_session(request: web.Request) -> web.Response:
|
||||
service = _get_session_service(request)
|
||||
session_id = request.match_info["session_id"]
|
||||
result = service.resume_session(session_id)
|
||||
if result is None:
|
||||
return _json_error(status=404, code="session_not_found", message="No session was found for the provided session_id.", session_id=session_id)
|
||||
return web.json_response({"ok": True, **result})
|
||||
|
||||
|
||||
async def handle_delete_session(request: web.Request) -> web.Response:
|
||||
service = _get_session_service(request)
|
||||
session_id = request.match_info["session_id"]
|
||||
deleted = service.delete_session(session_id)
|
||||
if not deleted:
|
||||
return _json_error(status=404, code="session_not_found", message="No session was found for the provided session_id.", session_id=session_id)
|
||||
return web.json_response({"ok": True, "session_id": session_id, "deleted": True})
|
||||
|
||||
|
||||
async def handle_export_session(request: web.Request) -> web.Response:
|
||||
service = _get_session_service(request)
|
||||
session_id = request.match_info["session_id"]
|
||||
export_format = request.query.get("format", "md").lower()
|
||||
|
||||
resolved = service.db.resolve_session_id(session_id) or session_id
|
||||
exported = service.db.export_session(resolved)
|
||||
if not exported:
|
||||
return _json_error(status=404, code="session_not_found", message="No session was found for the provided session_id.", session_id=session_id)
|
||||
|
||||
if export_format == "json":
|
||||
# export JSON as an attachment
|
||||
def default_serializer(obj: Any) -> Any:
|
||||
try:
|
||||
return json.dumps(obj)
|
||||
except Exception:
|
||||
return str(obj)
|
||||
|
||||
response_text = json.dumps(exported, default=default_serializer, indent=2)
|
||||
response = web.Response(text=response_text, content_type="application/json")
|
||||
response.headers["Content-Disposition"] = f'attachment; filename="session_{resolved}.json"'
|
||||
return response
|
||||
|
||||
# Format as Markdown
|
||||
title = exported.get("title") or "Hermes Session"
|
||||
lines = [
|
||||
f"# {title}",
|
||||
f"**Session ID:** `{resolved}`",
|
||||
f"**Model:** `{exported.get('model', 'unknown')}`",
|
||||
f"**Started At:** {exported.get('started_at', 'unknown')}",
|
||||
""
|
||||
]
|
||||
|
||||
for msg in exported.get("messages", []):
|
||||
role = str(msg.get("role", "unknown")).capitalize()
|
||||
content = msg.get("content") or ""
|
||||
|
||||
if role == "System":
|
||||
lines.append("## System")
|
||||
lines.append(f"> {content}")
|
||||
lines.append("")
|
||||
elif role == "User":
|
||||
lines.append("## User")
|
||||
lines.append(content)
|
||||
lines.append("")
|
||||
elif role == "Assistant":
|
||||
lines.append("## Assistant")
|
||||
if content:
|
||||
lines.append(content)
|
||||
lines.append("")
|
||||
|
||||
tool_calls = msg.get("tool_calls")
|
||||
if tool_calls:
|
||||
for tc in tool_calls:
|
||||
fn = tc.get("function", {})
|
||||
fn_name = fn.get("name", "unknown")
|
||||
args = fn.get("arguments", "{}")
|
||||
try:
|
||||
args_formatted = json.dumps(json.loads(args), indent=2)
|
||||
except Exception:
|
||||
args_formatted = str(args)
|
||||
lines.append(f"**🔧 Tool Call:** `{fn_name}`")
|
||||
lines.append(f"```json\n{args_formatted}\n```")
|
||||
lines.append("")
|
||||
elif role == "Tool":
|
||||
tool_name = msg.get("tool_name") or msg.get("name") or "unknown"
|
||||
lines.append(f"## Tool Result: `{tool_name}`")
|
||||
lines.append(f"```\n{content}\n```")
|
||||
lines.append("")
|
||||
|
||||
md_content = "\n".join(lines)
|
||||
|
||||
if export_format == "txt":
|
||||
response = web.Response(text=md_content, content_type="text/plain")
|
||||
response.headers["Content-Disposition"] = f'attachment; filename="session_{resolved}.txt"'
|
||||
return response
|
||||
|
||||
# Default md
|
||||
response = web.Response(text=md_content, content_type="text/markdown")
|
||||
response.headers["Content-Disposition"] = f'attachment; filename="session_{resolved}.md"'
|
||||
return response
|
||||
|
||||
|
||||
async def handle_branch_session(request: web.Request) -> web.Response:
|
||||
"""POST /api/gui/sessions/{session_id}/branch — fork a session at a message index."""
|
||||
service = _get_session_service(request)
|
||||
session_id = request.match_info["session_id"]
|
||||
|
||||
data = await _read_json_body(request)
|
||||
at_message_index: int | None = None
|
||||
if data and "at_message_index" in data:
|
||||
try:
|
||||
at_message_index = int(data["at_message_index"])
|
||||
except (TypeError, ValueError):
|
||||
return _json_error(
|
||||
status=400,
|
||||
code="invalid_index",
|
||||
message="The 'at_message_index' field must be an integer.",
|
||||
)
|
||||
|
||||
result = service.branch_session(session_id, at_message_index=at_message_index)
|
||||
if result is None:
|
||||
return _json_error(
|
||||
status=404,
|
||||
code="session_not_found",
|
||||
message="No session was found for the provided session_id.",
|
||||
session_id=session_id,
|
||||
)
|
||||
return web.json_response({"ok": True, **result})
|
||||
|
||||
|
||||
async def handle_session_search(request: web.Request) -> web.Response:
|
||||
"""GET /api/gui/session-search?q=... — FTS5 full-text search across sessions."""
|
||||
query = request.query.get("q", "").strip()
|
||||
if not query:
|
||||
return web.json_response({"ok": True, "search": {"results": []}})
|
||||
|
||||
service = _get_session_service(request)
|
||||
try:
|
||||
results = service.db.search_messages(query, limit=20)
|
||||
formatted = []
|
||||
for r in results:
|
||||
formatted.append({
|
||||
"session_id": r.get("session_id", ""),
|
||||
"session_title": r.get("session_title") or r.get("session_id", "")[:12],
|
||||
"snippet": r.get("snippet") or r.get("content", "")[:120],
|
||||
"role": r.get("role", ""),
|
||||
})
|
||||
return web.json_response({"ok": True, "search": {"results": formatted}})
|
||||
except Exception as exc:
|
||||
return _json_error(
|
||||
status=500,
|
||||
code="search_error",
|
||||
message=f"Search failed: {exc}",
|
||||
)
|
||||
|
||||
|
||||
def register_sessions_api_routes(app: web.Application) -> None:
|
||||
if app.get(SESSIONS_SERVICE_APP_KEY) is None:
|
||||
app[SESSIONS_SERVICE_APP_KEY] = SessionService()
|
||||
|
||||
app.router.add_get("/api/gui/sessions", handle_list_sessions)
|
||||
app.router.add_get("/api/gui/session-search", handle_session_search)
|
||||
app.router.add_get("/api/gui/sessions/{session_id}", handle_get_session)
|
||||
app.router.add_get("/api/gui/sessions/{session_id}/transcript", handle_get_transcript)
|
||||
app.router.add_get("/api/gui/sessions/{session_id}/export", handle_export_session)
|
||||
app.router.add_post("/api/gui/sessions/{session_id}/title", handle_set_title)
|
||||
app.router.add_post("/api/gui/sessions/{session_id}/resume", handle_resume_session)
|
||||
app.router.add_post("/api/gui/sessions/{session_id}/branch", handle_branch_session)
|
||||
app.router.add_delete("/api/gui/sessions/{session_id}", handle_delete_session)
|
||||
|
||||
107
gateway/web_console/api/settings.py
Normal file
107
gateway/web_console/api/settings.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Settings API routes for the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
import os
|
||||
from gateway.web_console.services.settings_service import SettingsService
|
||||
from hermes_cli.config import save_env_value, OPTIONAL_ENV_VARS
|
||||
|
||||
SETTINGS_SERVICE_APP_KEY = web.AppKey("hermes_web_console_settings_service", SettingsService)
|
||||
|
||||
|
||||
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
|
||||
payload: dict[str, Any] = {"ok": False, "error": {"code": code, "message": message}}
|
||||
payload["error"].update(extra)
|
||||
return web.json_response(payload, status=status)
|
||||
|
||||
|
||||
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
|
||||
try:
|
||||
data = await request.json()
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _get_settings_service(request: web.Request) -> SettingsService:
|
||||
service = request.app.get(SETTINGS_SERVICE_APP_KEY)
|
||||
if service is None:
|
||||
service = SettingsService()
|
||||
request.app[SETTINGS_SERVICE_APP_KEY] = service
|
||||
return service
|
||||
|
||||
|
||||
async def handle_get_settings(request: web.Request) -> web.Response:
|
||||
service = _get_settings_service(request)
|
||||
return web.json_response({"ok": True, "settings": service.get_settings()})
|
||||
|
||||
|
||||
async def handle_patch_settings(request: web.Request) -> web.Response:
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
service = _get_settings_service(request)
|
||||
try:
|
||||
settings = service.update_settings(data)
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_patch", message=str(exc))
|
||||
return web.json_response({"ok": True, "settings": settings})
|
||||
|
||||
|
||||
async def handle_get_auth_status(request: web.Request) -> web.Response:
|
||||
service = _get_settings_service(request)
|
||||
return web.json_response({"ok": True, "auth": service.get_auth_status()})
|
||||
|
||||
|
||||
async def handle_patch_auth_keys(request: web.Request) -> web.Response:
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
|
||||
for k, v in data.items():
|
||||
if isinstance(v, str):
|
||||
save_env_value(k, v)
|
||||
elif v is None:
|
||||
# If they pass null, maybe they want to unset it?
|
||||
# save_env_value supports unsetting if value is empty string, let's pass empty string.
|
||||
save_env_value(k, "")
|
||||
|
||||
service = _get_settings_service(request)
|
||||
return web.json_response({"ok": True, "auth": service.get_auth_status()})
|
||||
|
||||
|
||||
async def handle_get_auth_schema(request: web.Request) -> web.Response:
|
||||
# Build a schema of env vars, categorizing them and providing descriptions
|
||||
schema = {}
|
||||
for key, meta in OPTIONAL_ENV_VARS.items():
|
||||
if meta.get("category") in ("provider", "tool", "messaging") or meta.get("category") is None:
|
||||
# Copy meta and add value status
|
||||
item = dict(meta)
|
||||
val = os.getenv(key, "").strip()
|
||||
# If the value is present and is not a placeholder, mask it
|
||||
if val and len(val) >= 4 and val.lower() not in ("null", "none", "placeholder", "changeme", "***"):
|
||||
item["value"] = "***" + val[-4:] if len(val) >= 8 else "***"
|
||||
item["configured"] = True
|
||||
else:
|
||||
item["value"] = ""
|
||||
item["configured"] = False
|
||||
schema[key] = item
|
||||
return web.json_response({"ok": True, "schema": schema})
|
||||
|
||||
|
||||
def register_settings_api_routes(app: web.Application) -> None:
|
||||
if app.get(SETTINGS_SERVICE_APP_KEY) is None:
|
||||
app[SETTINGS_SERVICE_APP_KEY] = SettingsService()
|
||||
|
||||
app.router.add_get("/api/gui/settings", handle_get_settings)
|
||||
app.router.add_patch("/api/gui/settings", handle_patch_settings)
|
||||
app.router.add_get("/api/gui/auth-status", handle_get_auth_status)
|
||||
app.router.add_patch("/api/gui/auth/keys", handle_patch_auth_keys)
|
||||
app.router.add_get("/api/gui/auth/schema", handle_get_auth_schema)
|
||||
290
gateway/web_console/api/skills.py
Normal file
290
gateway/web_console/api/skills.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""Skills API routes for the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from gateway.web_console.services.skill_service import SkillService
|
||||
|
||||
SKILLS_SERVICE_APP_KEY = web.AppKey("hermes_web_console_skills_service", SkillService)
|
||||
|
||||
|
||||
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
|
||||
payload: dict[str, Any] = {
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
payload["error"].update(extra)
|
||||
return web.json_response(payload, status=status)
|
||||
|
||||
|
||||
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
|
||||
try:
|
||||
data = await request.json()
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _get_skill_service(request: web.Request) -> SkillService:
|
||||
service = request.app.get(SKILLS_SERVICE_APP_KEY)
|
||||
if service is None:
|
||||
service = SkillService()
|
||||
request.app[SKILLS_SERVICE_APP_KEY] = service
|
||||
return service
|
||||
|
||||
|
||||
async def handle_list_skills(request: web.Request) -> web.Response:
|
||||
service = _get_skill_service(request)
|
||||
try:
|
||||
payload = service.list_skills()
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="skills_list_failed", message=str(exc))
|
||||
return web.json_response({"ok": True, **payload})
|
||||
|
||||
|
||||
async def handle_get_skill(request: web.Request) -> web.Response:
|
||||
service = _get_skill_service(request)
|
||||
name = request.match_info["name"]
|
||||
try:
|
||||
skill = service.get_skill(name)
|
||||
except FileNotFoundError:
|
||||
return _json_error(status=404, code="skill_not_found", message="No skill was found for the provided name.", name=name)
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="skill_unavailable", message=str(exc), name=name)
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="skill_lookup_failed", message=str(exc), name=name)
|
||||
return web.json_response({"ok": True, "skill": skill})
|
||||
|
||||
|
||||
async def handle_load_skill(request: web.Request) -> web.Response:
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
|
||||
session_id = data.get("session_id")
|
||||
if not isinstance(session_id, str) or not session_id.strip():
|
||||
return _json_error(status=400, code="invalid_session_id", message="The 'session_id' field must be a non-empty string.")
|
||||
|
||||
service = _get_skill_service(request)
|
||||
name = request.match_info["name"]
|
||||
try:
|
||||
payload = service.load_skill_for_session(session_id.strip(), name)
|
||||
except LookupError:
|
||||
return _json_error(status=404, code="session_not_found", message="No session was found for the provided session_id.", session_id=session_id)
|
||||
except FileNotFoundError:
|
||||
return _json_error(status=404, code="skill_not_found", message="No skill was found for the provided name.", name=name)
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="skill_unavailable", message=str(exc), name=name)
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="skill_load_failed", message=str(exc), name=name, session_id=session_id)
|
||||
return web.json_response({"ok": True, **payload})
|
||||
|
||||
|
||||
async def handle_list_session_skills(request: web.Request) -> web.Response:
|
||||
service = _get_skill_service(request)
|
||||
session_id = request.match_info["session_id"]
|
||||
try:
|
||||
payload = service.list_session_skills(session_id)
|
||||
except LookupError:
|
||||
return _json_error(status=404, code="session_not_found", message="No session was found for the provided session_id.", session_id=session_id)
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="session_skills_lookup_failed", message=str(exc), session_id=session_id)
|
||||
return web.json_response({"ok": True, **payload})
|
||||
|
||||
|
||||
async def handle_unload_skill(request: web.Request) -> web.Response:
|
||||
service = _get_skill_service(request)
|
||||
session_id = request.match_info["session_id"]
|
||||
name = request.match_info["name"]
|
||||
try:
|
||||
payload = service.unload_skill_for_session(session_id, name)
|
||||
except LookupError:
|
||||
return _json_error(status=404, code="session_not_found", message="No session was found for the provided session_id.", session_id=session_id)
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="skill_unload_failed", message=str(exc), session_id=session_id, name=name)
|
||||
return web.json_response({"ok": True, **payload})
|
||||
|
||||
|
||||
async def handle_hub_search(request: web.Request) -> web.Response:
|
||||
query = request.query.get("q", "")
|
||||
from tools.skills_hub import GitHubAuth, create_source_router, unified_search
|
||||
try:
|
||||
auth = GitHubAuth()
|
||||
sources = create_source_router(auth)
|
||||
results = unified_search(query, sources, source_filter="all", limit=20)
|
||||
payload = {"results": [{"name": r.name, "description": r.description, "source": r.source, "trust_level": r.trust_level, "identifier": r.identifier, "tags": r.tags} for r in results]}
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="hub_search_failed", message=str(exc))
|
||||
return web.json_response({"ok": True, **payload})
|
||||
|
||||
async def handle_hub_install(request: web.Request) -> web.Response:
|
||||
data = await _read_json_body(request)
|
||||
if not data or not data.get("identifier"):
|
||||
return _json_error(status=400, code="invalid_request", message="Missing identifier")
|
||||
identifier = data["identifier"]
|
||||
|
||||
try:
|
||||
from tools.skills_hub import GitHubAuth, create_source_router, ensure_hub_dirs, quarantine_bundle, install_from_quarantine, HubLockFile
|
||||
from tools.skills_guard import scan_skill, should_allow_install
|
||||
import shutil
|
||||
|
||||
ensure_hub_dirs()
|
||||
auth = GitHubAuth()
|
||||
sources = create_source_router(auth)
|
||||
|
||||
from hermes_cli.skills_hub import _resolve_source_meta_and_bundle, _resolve_short_name
|
||||
if "/" not in identifier:
|
||||
from rich.console import Console
|
||||
identifier = _resolve_short_name(identifier, sources, Console(quiet=True))
|
||||
if not identifier:
|
||||
return _json_error(status=404, code="skill_not_found", message="Skill not found or ambiguous")
|
||||
|
||||
meta, bundle, matched_source = _resolve_source_meta_and_bundle(identifier, sources)
|
||||
if not bundle:
|
||||
return _json_error(status=404, code="fetch_failed", message="Could not fetch bundle")
|
||||
|
||||
category = ""
|
||||
if bundle.source == "official":
|
||||
id_parts = bundle.identifier.split("/")
|
||||
if len(id_parts) >= 3:
|
||||
category = id_parts[1]
|
||||
|
||||
lock = HubLockFile()
|
||||
if lock.get_installed(bundle.name):
|
||||
return _json_error(status=409, code="already_installed", message="Skill is already installed")
|
||||
|
||||
q_path = quarantine_bundle(bundle)
|
||||
scan_source = getattr(bundle, "identifier", identifier)
|
||||
result = scan_skill(q_path, source=scan_source)
|
||||
allowed, reason = should_allow_install(result, force=False)
|
||||
|
||||
if not allowed:
|
||||
shutil.rmtree(q_path, ignore_errors=True)
|
||||
return _json_error(status=403, code="scan_failed", message=reason, scan_result=result.verdict)
|
||||
|
||||
install_dir = install_from_quarantine(q_path, bundle.name, category, bundle, result)
|
||||
|
||||
try:
|
||||
from agent.prompt_builder import clear_skills_system_prompt_cache
|
||||
clear_skills_system_prompt_cache(clear_snapshot=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return web.json_response({"ok": True, "installed": bundle.name, "path": str(install_dir)})
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="install_error", message=str(exc))
|
||||
|
||||
async def handle_skill_create(request: web.Request) -> web.Response:
|
||||
data = await _read_json_body(request)
|
||||
if not data or not data.get("name") or not data.get("content"):
|
||||
return _json_error(status=400, code="invalid_request", message="Missing name or content")
|
||||
|
||||
name = data["name"].strip()
|
||||
content = data["content"].strip()
|
||||
|
||||
import re
|
||||
if not re.match(r"^[a-zA-Z0-9_-]+$", name):
|
||||
return _json_error(status=400, code="invalid_name", message="Invalid skill name (alphanumeric, dash, underscore only)")
|
||||
|
||||
from tools.skills_hub import SKILLS_DIR
|
||||
skill_path = SKILLS_DIR / name
|
||||
if skill_path.exists():
|
||||
return _json_error(status=409, code="already_exists", message=f"Skill directory {name} already exists")
|
||||
|
||||
try:
|
||||
skill_path.mkdir(parents=True, exist_ok=True)
|
||||
(skill_path / "SKILL.md").write_text(content, encoding="utf-8")
|
||||
|
||||
try:
|
||||
from agent.prompt_builder import clear_skills_system_prompt_cache
|
||||
clear_skills_system_prompt_cache(clear_snapshot=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return web.json_response({"ok": True, "name": name})
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="create_error", message=str(exc))
|
||||
|
||||
async def handle_skill_config_vars(request: web.Request) -> web.Response:
|
||||
"""GET /api/gui/skills/config — list all skill config variables with current values."""
|
||||
try:
|
||||
from agent.skill_utils import discover_all_skill_config_vars, resolve_skill_config_values
|
||||
config_vars = discover_all_skill_config_vars()
|
||||
values = resolve_skill_config_values(config_vars)
|
||||
items = []
|
||||
for var in config_vars:
|
||||
items.append({
|
||||
"key": var["key"],
|
||||
"description": var["description"],
|
||||
"default": var.get("default"),
|
||||
"prompt": var.get("prompt"),
|
||||
"skill": var.get("skill"),
|
||||
"value": values.get(var["key"]),
|
||||
})
|
||||
return web.json_response({"ok": True, "config_vars": items, "count": len(items)})
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="config_vars_failed", message=str(exc))
|
||||
|
||||
|
||||
async def handle_skill_config_save(request: web.Request) -> web.Response:
|
||||
"""POST /api/gui/skills/config — save a skill config variable value."""
|
||||
data = await _read_json_body(request)
|
||||
if not data or "key" not in data or "value" not in data:
|
||||
return _json_error(status=400, code="invalid_request", message="Missing 'key' or 'value'")
|
||||
key = str(data["key"]).strip()
|
||||
value = data["value"]
|
||||
if not key:
|
||||
return _json_error(status=400, code="invalid_key", message="Config key must be non-empty")
|
||||
try:
|
||||
from agent.skill_utils import SKILL_CONFIG_PREFIX
|
||||
from hermes_constants import get_hermes_home
|
||||
import yaml
|
||||
|
||||
config_path = get_hermes_home() / "config.yaml"
|
||||
config = {}
|
||||
if config_path.exists():
|
||||
raw = config_path.read_text(encoding="utf-8")
|
||||
parsed = yaml.safe_load(raw)
|
||||
if isinstance(parsed, dict):
|
||||
config = parsed
|
||||
|
||||
# Navigate to the storage path: skills.config.<key>
|
||||
storage_key = f"{SKILL_CONFIG_PREFIX}.{key}"
|
||||
parts = storage_key.split(".")
|
||||
current = config
|
||||
for part in parts[:-1]:
|
||||
if part not in current or not isinstance(current.get(part), dict):
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
current[parts[-1]] = value
|
||||
|
||||
config_path.write_text(yaml.dump(config, default_flow_style=False), encoding="utf-8")
|
||||
return web.json_response({"ok": True, "key": key, "value": value})
|
||||
except Exception as exc:
|
||||
return _json_error(status=500, code="config_save_failed", message=str(exc))
|
||||
|
||||
|
||||
def register_skills_api_routes(app: web.Application) -> None:
|
||||
if app.get(SKILLS_SERVICE_APP_KEY) is None:
|
||||
app[SKILLS_SERVICE_APP_KEY] = SkillService()
|
||||
|
||||
app.router.add_get("/api/gui/skills", handle_list_skills)
|
||||
app.router.add_get("/api/gui/skills/config", handle_skill_config_vars)
|
||||
app.router.add_post("/api/gui/skills/config", handle_skill_config_save)
|
||||
app.router.add_get("/api/gui/skills/hub/search", handle_hub_search)
|
||||
app.router.add_post("/api/gui/skills/hub/install", handle_hub_install)
|
||||
app.router.add_post("/api/gui/skills/create", handle_skill_create)
|
||||
app.router.add_get("/api/gui/skills/{name}", handle_get_skill)
|
||||
app.router.add_post("/api/gui/skills/{name}/load", handle_load_skill)
|
||||
app.router.add_get("/api/gui/skills/session/{session_id}", handle_list_session_skills)
|
||||
app.router.add_delete("/api/gui/skills/session/{session_id}/{name}", handle_unload_skill)
|
||||
|
||||
178
gateway/web_console/api/system.py
Normal file
178
gateway/web_console/api/system.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""Backup and restore API routes for the Hermes Web Console.
|
||||
|
||||
Provides:
|
||||
GET /api/gui/system/backup — stream a zip backup of ~/.hermes/ to the browser
|
||||
POST /api/gui/system/restore — accept a zip upload and restore into ~/.hermes/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def handle_system_backup(request: web.Request) -> web.StreamResponse:
|
||||
"""Stream a zip backup of HERMES_HOME to the browser."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _create_backup_bytes() -> tuple[bytes, int, str]:
|
||||
from hermes_constants import get_default_hermes_root
|
||||
from hermes_cli.backup import _should_exclude, _EXCLUDED_DIRS
|
||||
|
||||
hermes_root = get_default_hermes_root()
|
||||
if not hermes_root.is_dir():
|
||||
raise FileNotFoundError(f"Hermes home not found: {hermes_root}")
|
||||
|
||||
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
||||
filename = f"hermes-backup-{stamp}.zip"
|
||||
|
||||
buf = io.BytesIO()
|
||||
file_count = 0
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
|
||||
for dirpath, dirnames, filenames in os.walk(hermes_root, followlinks=False):
|
||||
dp = Path(dirpath)
|
||||
rel_dir = dp.relative_to(hermes_root)
|
||||
dirnames[:] = [d for d in dirnames if d not in _EXCLUDED_DIRS]
|
||||
|
||||
for fname in filenames:
|
||||
fpath = dp / fname
|
||||
rel = fpath.relative_to(hermes_root)
|
||||
if _should_exclude(rel):
|
||||
continue
|
||||
try:
|
||||
zf.write(fpath, arcname=str(rel))
|
||||
file_count += 1
|
||||
except (PermissionError, OSError):
|
||||
continue
|
||||
|
||||
return buf.getvalue(), file_count, filename
|
||||
|
||||
try:
|
||||
data, file_count, filename = await loop.run_in_executor(None, _create_backup_bytes)
|
||||
except FileNotFoundError as e:
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=404)
|
||||
except Exception as e:
|
||||
logger.exception("Backup creation failed")
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
headers={
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Content-Length": str(len(data)),
|
||||
"X-Backup-Files": str(file_count),
|
||||
},
|
||||
)
|
||||
await response.prepare(request)
|
||||
await response.write(data)
|
||||
await response.write_eof()
|
||||
return response
|
||||
|
||||
|
||||
async def handle_system_restore(request: web.Request) -> web.Response:
|
||||
"""Accept a zip upload and restore it into HERMES_HOME."""
|
||||
reader = await request.multipart()
|
||||
if reader is None:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "Expected multipart/form-data with a 'file' part"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
zip_data = None
|
||||
while True:
|
||||
part = await reader.next()
|
||||
if part is None:
|
||||
break
|
||||
if part.name == "file":
|
||||
zip_data = await part.read(decode=False)
|
||||
break
|
||||
|
||||
if zip_data is None:
|
||||
return web.json_response(
|
||||
{"ok": False, "error": "No 'file' part found in upload"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _do_restore() -> dict:
|
||||
from hermes_constants import get_default_hermes_root
|
||||
from hermes_cli.backup import _validate_backup_zip, _detect_prefix
|
||||
|
||||
hermes_root = get_default_hermes_root()
|
||||
hermes_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
buf = io.BytesIO(zip_data)
|
||||
if not zipfile.is_zipfile(buf):
|
||||
return {"ok": False, "error": "Uploaded data is not a valid zip file"}
|
||||
|
||||
buf.seek(0)
|
||||
with zipfile.ZipFile(buf, "r") as zf:
|
||||
ok, reason = _validate_backup_zip(zf)
|
||||
if not ok:
|
||||
return {"ok": False, "error": reason}
|
||||
|
||||
prefix = _detect_prefix(zf)
|
||||
members = [n for n in zf.namelist() if not n.endswith("/")]
|
||||
|
||||
errors = []
|
||||
restored = 0
|
||||
|
||||
for member in members:
|
||||
if prefix and member.startswith(prefix):
|
||||
rel = member[len(prefix):]
|
||||
else:
|
||||
rel = member
|
||||
|
||||
if not rel:
|
||||
continue
|
||||
|
||||
target = hermes_root / rel
|
||||
|
||||
# Security: reject absolute paths and traversals
|
||||
try:
|
||||
target.resolve().relative_to(hermes_root.resolve())
|
||||
except ValueError:
|
||||
errors.append(f"{rel}: path traversal blocked")
|
||||
continue
|
||||
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(member) as src, open(target, "wb") as dst:
|
||||
dst.write(src.read())
|
||||
restored += 1
|
||||
except (PermissionError, OSError) as exc:
|
||||
errors.append(f"{rel}: {exc}")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"restored": restored,
|
||||
"total": len(members),
|
||||
"errors": errors[:20],
|
||||
}
|
||||
|
||||
try:
|
||||
result = await loop.run_in_executor(None, _do_restore)
|
||||
except Exception as e:
|
||||
logger.exception("Restore failed")
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
return web.json_response(result)
|
||||
|
||||
|
||||
def register_system_api_routes(app: web.Application) -> None:
|
||||
"""Register system backup/restore API routes."""
|
||||
app.router.add_get("/api/gui/system/backup", handle_system_backup)
|
||||
app.router.add_post("/api/gui/system/restore", handle_system_restore)
|
||||
113
gateway/web_console/api/tools.py
Normal file
113
gateway/web_console/api/tools.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Tools management API routes for the Hermes Web Console backend."""
|
||||
|
||||
import logging
|
||||
from aiohttp import web
|
||||
from tools.registry import registry
|
||||
|
||||
logger = logging.getLogger("tools_api")
|
||||
|
||||
|
||||
async def handle_list_tools(request: web.Request) -> web.Response:
|
||||
tools_list = []
|
||||
for name, tool in registry._tools.items():
|
||||
# ToolEntry doesn't have is_available(); check via check_fn
|
||||
available = True
|
||||
if tool.check_fn:
|
||||
try:
|
||||
available = bool(tool.check_fn())
|
||||
except Exception:
|
||||
available = False
|
||||
tools_list.append({
|
||||
"name": name,
|
||||
"toolset": tool.toolset,
|
||||
"description": tool.schema.get("description", ""),
|
||||
"parameters": tool.schema.get("parameters", {}),
|
||||
"requires_env": tool.requires_env,
|
||||
"is_available": available,
|
||||
})
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"tools": tools_list
|
||||
})
|
||||
|
||||
|
||||
async def handle_list_toolsets(request: web.Request) -> web.Response:
|
||||
"""List all toolsets with availability, tool counts, and enabled status."""
|
||||
try:
|
||||
from hermes_cli.tools_config import CONFIGURABLE_TOOLSETS, _toolset_has_keys
|
||||
from hermes_cli.config import load_config
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
config = load_config()
|
||||
|
||||
# Get currently enabled toolsets from platform_toolsets (CLI by default)
|
||||
platform = request.query.get("platform", "cli")
|
||||
platform_toolsets_config = config.get("platform_toolsets", {})
|
||||
enabled_ts = platform_toolsets_config.get(platform)
|
||||
|
||||
# If no explicit config, resolve from default toolset
|
||||
from hermes_cli.tools_config import _get_platform_tools
|
||||
enabled_toolset_keys = _get_platform_tools(config, platform)
|
||||
|
||||
toolsets = []
|
||||
for ts_key, ts_label, ts_desc in CONFIGURABLE_TOOLSETS:
|
||||
ts_tools = sorted(resolve_toolset(ts_key))
|
||||
ts_available = registry.is_toolset_available(ts_key)
|
||||
ts_has_keys = _toolset_has_keys(ts_key)
|
||||
toolsets.append({
|
||||
"key": ts_key,
|
||||
"label": ts_label,
|
||||
"description": ts_desc,
|
||||
"tools": ts_tools,
|
||||
"tool_count": len(ts_tools),
|
||||
"available": ts_available,
|
||||
"has_keys": ts_has_keys,
|
||||
"enabled": ts_key in enabled_toolset_keys,
|
||||
})
|
||||
|
||||
return web.json_response({"ok": True, "toolsets": toolsets, "platform": platform})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list toolsets: {e}")
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
|
||||
async def handle_toggle_toolset(request: web.Request) -> web.Response:
|
||||
"""Enable or disable a toolset for a platform."""
|
||||
try:
|
||||
import json
|
||||
data = await request.json()
|
||||
ts_key = data.get("toolset")
|
||||
enabled = data.get("enabled", True)
|
||||
platform = data.get("platform", "cli")
|
||||
|
||||
if not ts_key:
|
||||
return web.json_response({"ok": False, "error": "toolset key required"}, status=400)
|
||||
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.tools_config import _get_platform_tools, _save_platform_tools
|
||||
|
||||
config = load_config()
|
||||
current = _get_platform_tools(config, platform)
|
||||
|
||||
if enabled:
|
||||
current.add(ts_key)
|
||||
else:
|
||||
current.discard(ts_key)
|
||||
|
||||
_save_platform_tools(config, platform, current)
|
||||
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"toolset": ts_key,
|
||||
"enabled": enabled,
|
||||
"platform": platform,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to toggle toolset: {e}")
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
|
||||
def register_tools_api_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/gui/tools", handle_list_tools)
|
||||
app.router.add_get("/api/gui/toolsets", handle_list_toolsets)
|
||||
app.router.add_post("/api/gui/toolsets/toggle", handle_toggle_toolset)
|
||||
53
gateway/web_console/api/usage.py
Normal file
53
gateway/web_console/api/usage.py
Normal file
@@ -0,0 +1,53 @@
|
||||
import logging
|
||||
from aiohttp import web
|
||||
|
||||
logger = logging.getLogger("usage_api")
|
||||
|
||||
async def handle_get_usage_insights(request: web.Request) -> web.Response:
|
||||
try:
|
||||
days = int(request.query.get("days", 30))
|
||||
source = request.query.get("source")
|
||||
|
||||
from hermes_state import SessionDB
|
||||
from agent.insights import InsightsEngine
|
||||
|
||||
db = SessionDB()
|
||||
engine = InsightsEngine(db)
|
||||
report = engine.generate(days=days, source=source)
|
||||
db.close()
|
||||
|
||||
return web.json_response({"ok": True, "report": report})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch usage insights: {e}")
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
async def handle_get_session_usage(request: web.Request) -> web.Response:
|
||||
try:
|
||||
session_id = request.match_info.get("id")
|
||||
if not session_id:
|
||||
return web.json_response({"ok": False, "error": "Session ID required"}, status=400)
|
||||
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
session = db.get_session(session_id)
|
||||
if not session:
|
||||
return web.json_response({"ok": False, "error": "Not found"}, status=404)
|
||||
|
||||
return web.json_response({"ok": True, "session_usage": {
|
||||
"input_tokens": getattr(session, "input_tokens", 0) or 0,
|
||||
"output_tokens": getattr(session, "output_tokens", 0) or 0,
|
||||
"cache_read_tokens": getattr(session, "cache_read_tokens", 0) or 0,
|
||||
"cache_write_tokens": getattr(session, "cache_write_tokens", 0) or 0,
|
||||
"total_tokens": getattr(session, "total_tokens", 0) or 0,
|
||||
"estimated_cost_usd": getattr(session, "estimated_cost_usd", 0.0) or 0.0,
|
||||
}})
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch session usage: {e}")
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
def register_usage_api_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/gui/usage/insights", handle_get_usage_insights)
|
||||
app.router.add_get("/api/gui/usage/session/{id}", handle_get_session_usage)
|
||||
72
gateway/web_console/api/version.py
Normal file
72
gateway/web_console/api/version.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Version info API route for the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import aiohttp
|
||||
from aiohttp import web
|
||||
|
||||
logger = logging.getLogger("version_api")
|
||||
|
||||
async def handle_get_version(request: web.Request) -> web.Response:
|
||||
try:
|
||||
from hermes_cli import __version__, __release_date__
|
||||
except ImportError:
|
||||
__version__ = "unknown"
|
||||
__release_date__ = "unknown"
|
||||
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"version": __version__,
|
||||
"release_date": __release_date__,
|
||||
})
|
||||
|
||||
async def handle_check_update(request: web.Request) -> web.Response:
|
||||
"""Check PyPI for the latest version of hermes-agent."""
|
||||
try:
|
||||
from hermes_cli import __version__
|
||||
except ImportError:
|
||||
__version__ = "0.0.0"
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get("https://pypi.org/pypi/hermes-agent/json", timeout=5) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json()
|
||||
latest_version = data["info"]["version"]
|
||||
|
||||
# Basic comparison assuming semantic versioning like 0.6.1 vs 0.6.0
|
||||
# For simplicity, we just check if it's different.
|
||||
# Normally we'd use packaging.version.parse, but standard string comparison
|
||||
# or exact matching is often enough to say "update available" if __version__ != latest
|
||||
has_update = __version__ != "unknown" and latest_version != __version__
|
||||
|
||||
# Attempt to fetch changelog or description for the new version
|
||||
releases = data.get("releases", {})
|
||||
latest_release_info = releases.get(latest_version, [])
|
||||
changelog = ""
|
||||
if latest_release_info:
|
||||
# Extract some basic info, like if there's a Github release description
|
||||
# Actually PyPI JSON doesn't contain the markdown changelog for that release easily.
|
||||
# We'll just provide the project url if needed.
|
||||
pass
|
||||
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"current_version": __version__,
|
||||
"latest_version": latest_version,
|
||||
"has_update": has_update,
|
||||
"project_url": data["info"]["project_urls"].get("Homepage", "https://pypi.org/project/hermes-agent/")
|
||||
})
|
||||
return web.json_response({
|
||||
"ok": False,
|
||||
"error": f"Failed to fetch PyPI data: {resp.status}"
|
||||
}, status=502)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check for updates: {e}")
|
||||
return web.json_response({"ok": False, "error": str(e)}, status=500)
|
||||
|
||||
|
||||
def register_version_api_routes(app: web.Application) -> None:
|
||||
app.router.add_get("/api/gui/version", handle_get_version)
|
||||
app.router.add_get("/api/gui/version/check", handle_check_update)
|
||||
248
gateway/web_console/api/workspace.py
Normal file
248
gateway/web_console/api/workspace.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""Workspace and process API routes for the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from gateway.web_console.services.workspace_service import WorkspaceService
|
||||
|
||||
WORKSPACE_SERVICE_APP_KEY = web.AppKey("hermes_web_console_workspace_service", WorkspaceService)
|
||||
|
||||
|
||||
def _json_error(*, status: int, code: str, message: str, **extra: Any) -> web.Response:
|
||||
payload: dict[str, Any] = {
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": code,
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
payload["error"].update(extra)
|
||||
return web.json_response(payload, status=status)
|
||||
|
||||
|
||||
async def _read_json_body(request: web.Request) -> dict[str, Any] | None:
|
||||
try:
|
||||
data = await request.json()
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _parse_int(value: str | None, *, field_name: str, minimum: int | None = None) -> int:
|
||||
try:
|
||||
parsed = int(value) if value is not None else 0
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"The '{field_name}' field must be an integer.") from exc
|
||||
if minimum is not None and parsed < minimum:
|
||||
raise ValueError(f"The '{field_name}' field must be >= {minimum}.")
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_bool(value: str | None) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
return value.lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _get_workspace_service(request: web.Request) -> WorkspaceService:
|
||||
service = request.app.get(WORKSPACE_SERVICE_APP_KEY)
|
||||
if service is None:
|
||||
service = WorkspaceService()
|
||||
request.app[WORKSPACE_SERVICE_APP_KEY] = service
|
||||
return service
|
||||
|
||||
|
||||
async def handle_workspace_tree(request: web.Request) -> web.Response:
|
||||
service = _get_workspace_service(request)
|
||||
try:
|
||||
depth = _parse_int(request.query.get("depth", "2"), field_name="depth", minimum=0)
|
||||
result = service.get_tree(
|
||||
path=request.query.get("path"),
|
||||
depth=depth,
|
||||
include_hidden=_parse_bool(request.query.get("include_hidden")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_path", message=str(exc))
|
||||
except FileNotFoundError as exc:
|
||||
return _json_error(status=404, code="path_not_found", message=str(exc))
|
||||
return web.json_response({"ok": True, **result})
|
||||
|
||||
|
||||
async def handle_workspace_file(request: web.Request) -> web.Response:
|
||||
service = _get_workspace_service(request)
|
||||
path = request.query.get("path")
|
||||
if not path:
|
||||
return _json_error(status=400, code="missing_path", message="The 'path' query parameter is required.")
|
||||
try:
|
||||
offset = _parse_int(request.query.get("offset", "1"), field_name="offset", minimum=1)
|
||||
limit = _parse_int(request.query.get("limit", "500"), field_name="limit", minimum=1)
|
||||
result = service.get_file(path=path, offset=offset, limit=limit)
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_path", message=str(exc))
|
||||
except FileNotFoundError as exc:
|
||||
return _json_error(status=404, code="path_not_found", message=str(exc))
|
||||
return web.json_response({"ok": True, **result})
|
||||
|
||||
|
||||
async def handle_workspace_search(request: web.Request) -> web.Response:
|
||||
service = _get_workspace_service(request)
|
||||
query = request.query.get("query") or request.query.get("q")
|
||||
if not query:
|
||||
return _json_error(status=400, code="missing_query", message="The 'query' parameter is required.")
|
||||
try:
|
||||
limit = _parse_int(request.query.get("limit", "50"), field_name="limit", minimum=1)
|
||||
result = service.search_workspace(
|
||||
query=query,
|
||||
path=request.query.get("path"),
|
||||
limit=limit,
|
||||
include_hidden=_parse_bool(request.query.get("include_hidden")),
|
||||
regex=_parse_bool(request.query.get("regex")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_search", message=str(exc))
|
||||
except FileNotFoundError as exc:
|
||||
return _json_error(status=404, code="path_not_found", message=str(exc))
|
||||
return web.json_response({"ok": True, **result})
|
||||
|
||||
|
||||
async def handle_workspace_diff(request: web.Request) -> web.Response:
|
||||
service = _get_workspace_service(request)
|
||||
checkpoint_id = request.query.get("checkpoint_id") or request.query.get("checkpoint")
|
||||
if not checkpoint_id:
|
||||
return _json_error(status=400, code="missing_checkpoint_id", message="The 'checkpoint_id' parameter is required.")
|
||||
try:
|
||||
result = service.diff_checkpoint(checkpoint_id=checkpoint_id, path=request.query.get("path"))
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_checkpoint", message=str(exc))
|
||||
except FileNotFoundError as exc:
|
||||
return _json_error(status=404, code="checkpoint_not_found", message=str(exc))
|
||||
return web.json_response({"ok": True, **result})
|
||||
|
||||
|
||||
async def handle_workspace_checkpoints(request: web.Request) -> web.Response:
|
||||
service = _get_workspace_service(request)
|
||||
try:
|
||||
result = service.list_checkpoints(path=request.query.get("path"))
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_path", message=str(exc))
|
||||
except FileNotFoundError as exc:
|
||||
return _json_error(status=404, code="path_not_found", message=str(exc))
|
||||
return web.json_response({"ok": True, **result})
|
||||
|
||||
|
||||
async def handle_workspace_rollback(request: web.Request) -> web.Response:
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
checkpoint_id = data.get("checkpoint_id") or data.get("checkpoint")
|
||||
if not isinstance(checkpoint_id, str) or not checkpoint_id:
|
||||
return _json_error(status=400, code="missing_checkpoint_id", message="The 'checkpoint_id' field must be a non-empty string.")
|
||||
|
||||
service = _get_workspace_service(request)
|
||||
try:
|
||||
result = service.rollback(
|
||||
checkpoint_id=checkpoint_id,
|
||||
path=data.get("path"),
|
||||
file_path=data.get("file_path"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_rollback", message=str(exc))
|
||||
except FileNotFoundError as exc:
|
||||
return _json_error(status=404, code="checkpoint_not_found", message=str(exc))
|
||||
except RuntimeError as exc:
|
||||
return _json_error(status=500, code="rollback_failed", message=str(exc))
|
||||
return web.json_response({"ok": True, "result": result})
|
||||
|
||||
|
||||
async def handle_list_processes(request: web.Request) -> web.Response:
|
||||
service = _get_workspace_service(request)
|
||||
return web.json_response({"ok": True, **service.list_processes()})
|
||||
|
||||
|
||||
async def handle_process_log(request: web.Request) -> web.Response:
|
||||
service = _get_workspace_service(request)
|
||||
process_id = request.match_info["process_id"]
|
||||
try:
|
||||
offset = _parse_int(request.query.get("offset", "0"), field_name="offset", minimum=0)
|
||||
limit = _parse_int(request.query.get("limit", "200"), field_name="limit", minimum=1)
|
||||
result = service.get_process_log(process_id, offset=offset, limit=limit)
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_pagination", message=str(exc))
|
||||
except FileNotFoundError as exc:
|
||||
return _json_error(status=404, code="process_not_found", message=str(exc), process_id=process_id)
|
||||
return web.json_response({"ok": True, **result})
|
||||
|
||||
|
||||
async def handle_kill_process(request: web.Request) -> web.Response:
|
||||
service = _get_workspace_service(request)
|
||||
process_id = request.match_info["process_id"]
|
||||
try:
|
||||
result = service.kill_process(process_id)
|
||||
except FileNotFoundError as exc:
|
||||
return _json_error(status=404, code="process_not_found", message=str(exc), process_id=process_id)
|
||||
except RuntimeError as exc:
|
||||
return _json_error(status=500, code="process_kill_failed", message=str(exc), process_id=process_id)
|
||||
return web.json_response({"ok": True, "result": result})
|
||||
|
||||
|
||||
async def handle_workspace_file_save(request: web.Request) -> web.Response:
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
path = data.get("path")
|
||||
content = data.get("content")
|
||||
if not isinstance(path, str) or not path:
|
||||
return _json_error(status=400, code="missing_path", message="The 'path' field must be a non-empty string.")
|
||||
if not isinstance(content, str):
|
||||
return _json_error(status=400, code="missing_content", message="The 'content' field must be a string.")
|
||||
|
||||
service = _get_workspace_service(request)
|
||||
try:
|
||||
result = service.save_file(path=path, content=content)
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_path", message=str(exc))
|
||||
except OSError as exc:
|
||||
return _json_error(status=500, code="write_failed", message=str(exc))
|
||||
return web.json_response({"ok": True, **result})
|
||||
|
||||
|
||||
async def handle_workspace_exec(request: web.Request) -> web.Response:
|
||||
data = await _read_json_body(request)
|
||||
if data is None:
|
||||
return _json_error(status=400, code="invalid_json", message="Request body must be a valid JSON object.")
|
||||
command = data.get("command")
|
||||
if not isinstance(command, str) or not command.strip():
|
||||
return _json_error(status=400, code="missing_command", message="The 'command' field must be a non-empty string.")
|
||||
|
||||
service = _get_workspace_service(request)
|
||||
try:
|
||||
result = service.execute_sync(command)
|
||||
except ValueError as exc:
|
||||
return _json_error(status=400, code="invalid_command", message=str(exc))
|
||||
except RuntimeError as exc:
|
||||
return _json_error(status=500, code="execution_failed", message=str(exc))
|
||||
return web.json_response({"ok": True, **result})
|
||||
|
||||
|
||||
def register_workspace_api_routes(app: web.Application) -> None:
|
||||
if app.get(WORKSPACE_SERVICE_APP_KEY) is None:
|
||||
app[WORKSPACE_SERVICE_APP_KEY] = WorkspaceService()
|
||||
|
||||
app.router.add_get("/api/gui/workspace/tree", handle_workspace_tree)
|
||||
app.router.add_get("/api/gui/workspace/file", handle_workspace_file)
|
||||
app.router.add_post("/api/gui/workspace/file/save", handle_workspace_file_save)
|
||||
app.router.add_get("/api/gui/workspace/search", handle_workspace_search)
|
||||
app.router.add_get("/api/gui/workspace/diff", handle_workspace_diff)
|
||||
app.router.add_get("/api/gui/workspace/checkpoints", handle_workspace_checkpoints)
|
||||
app.router.add_post("/api/gui/workspace/rollback", handle_workspace_rollback)
|
||||
app.router.add_post("/api/gui/workspace/exec", handle_workspace_exec)
|
||||
app.router.add_get("/api/gui/processes", handle_list_processes)
|
||||
app.router.add_get("/api/gui/processes/{process_id}/log", handle_process_log)
|
||||
app.router.add_post("/api/gui/processes/{process_id}/kill", handle_kill_process)
|
||||
|
||||
12
gateway/web_console/app.py
Normal file
12
gateway/web_console/app.py
Normal file
@@ -0,0 +1,12 @@
|
||||
"""App-level helpers for mounting the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def maybe_register_web_console(app: Any, adapter: Any = None) -> None:
|
||||
"""Register the lightweight Hermes Web Console backend on an aiohttp app."""
|
||||
from .routes import register_web_console_routes
|
||||
|
||||
register_web_console_routes(app, adapter=adapter)
|
||||
58
gateway/web_console/event_bus.py
Normal file
58
gateway/web_console/event_bus.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""In-process GUI event bus primitives for the Hermes Web Console."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, DefaultDict
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GuiEvent:
|
||||
"""A single event emitted for consumption by the web console."""
|
||||
|
||||
type: str
|
||||
session_id: str
|
||||
run_id: str | None = None
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
ts: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
class GuiEventBus:
|
||||
"""A lightweight in-process pub/sub bus namespaced by channel."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._subscribers: DefaultDict[str, set[asyncio.Queue[GuiEvent]]] = defaultdict(set)
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def subscribe(self, channel: str) -> asyncio.Queue[GuiEvent]:
|
||||
"""Create and register a subscriber queue for a channel."""
|
||||
queue: asyncio.Queue[GuiEvent] = asyncio.Queue()
|
||||
async with self._lock:
|
||||
self._subscribers[channel].add(queue)
|
||||
return queue
|
||||
|
||||
async def unsubscribe(self, channel: str, queue: asyncio.Queue[GuiEvent]) -> None:
|
||||
"""Remove a subscriber queue from a channel if it is still registered."""
|
||||
async with self._lock:
|
||||
subscribers = self._subscribers.get(channel)
|
||||
if not subscribers:
|
||||
return
|
||||
subscribers.discard(queue)
|
||||
if not subscribers:
|
||||
self._subscribers.pop(channel, None)
|
||||
|
||||
async def publish(self, channel: str, event: GuiEvent) -> None:
|
||||
"""Publish an event to all current subscribers for a channel."""
|
||||
async with self._lock:
|
||||
subscribers = list(self._subscribers.get(channel, ()))
|
||||
print(f"DEBUG: Publishing {event.type} to channel {channel}. Subscribers: {len(subscribers)}", flush=True)
|
||||
for queue in subscribers:
|
||||
queue.put_nowait(event)
|
||||
|
||||
async def subscriber_count(self, channel: str) -> int:
|
||||
"""Return the current number of subscribers for a channel."""
|
||||
async with self._lock:
|
||||
return len(self._subscribers.get(channel, ()))
|
||||
90
gateway/web_console/routes.py
Normal file
90
gateway/web_console/routes.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Route handlers for the Hermes Web Console backend skeleton."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .api import register_web_console_api_routes
|
||||
from .sse import SseMessage, stream_sse
|
||||
from .state import get_web_console_state
|
||||
from .static import get_web_console_placeholder_html
|
||||
|
||||
ADAPTER_APP_KEY = web.AppKey("hermes_web_console_adapter", object)
|
||||
|
||||
|
||||
async def handle_gui_health(request: web.Request) -> web.Response:
|
||||
"""Return a simple health payload for the GUI backend."""
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "gui-backend",
|
||||
"product": "hermes-web-console",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def handle_gui_meta(request: web.Request) -> web.Response:
|
||||
"""Return minimal metadata for the GUI backend."""
|
||||
adapter = request.app.get(ADAPTER_APP_KEY)
|
||||
adapter_name = getattr(adapter, "name", None)
|
||||
return web.json_response(
|
||||
{
|
||||
"product": "hermes-web-console",
|
||||
"api_base_path": "/api/gui",
|
||||
"app_base_path": "/app/",
|
||||
"adapter": adapter_name,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def handle_app_root(request: web.Request) -> web.Response:
|
||||
"""Serve a simple placeholder page for the future web console frontend."""
|
||||
return web.Response(
|
||||
text=get_web_console_placeholder_html(),
|
||||
content_type="text/html",
|
||||
)
|
||||
|
||||
|
||||
async def _event_stream_generator(request: web.Request, session_id: str):
|
||||
state = get_web_console_state()
|
||||
queue = await state.event_bus.subscribe(session_id)
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
event = await asyncio.wait_for(queue.get(), timeout=15.0)
|
||||
except asyncio.TimeoutError:
|
||||
yield SseMessage(data={"ping": time.time()})
|
||||
continue
|
||||
payload = {
|
||||
"type": event.type,
|
||||
"session_id": event.session_id,
|
||||
"run_id": event.run_id,
|
||||
"payload": event.payload,
|
||||
"ts": event.ts,
|
||||
}
|
||||
yield SseMessage(data=payload)
|
||||
finally:
|
||||
await state.event_bus.unsubscribe(session_id, queue)
|
||||
|
||||
|
||||
async def handle_session_event_stream(request: web.Request) -> web.StreamResponse:
|
||||
"""GET /api/gui/stream/session/{session_id} — SSE event stream for a session."""
|
||||
session_id = request.match_info["session_id"]
|
||||
return await stream_sse(request, _event_stream_generator(request, session_id))
|
||||
|
||||
|
||||
def register_web_console_routes(app: web.Application, adapter: Any = None) -> None:
|
||||
"""Register the Hermes Web Console routes on an aiohttp application."""
|
||||
app[ADAPTER_APP_KEY] = adapter
|
||||
app.router.add_get("/api/gui/health", handle_gui_health)
|
||||
app.router.add_get("/api/gui/meta", handle_gui_meta)
|
||||
app.router.add_get(
|
||||
"/api/gui/stream/session/{session_id}", handle_session_event_stream
|
||||
)
|
||||
register_web_console_api_routes(app)
|
||||
app.router.add_get("/app/", handle_app_root)
|
||||
23
gateway/web_console/services/__init__.py
Normal file
23
gateway/web_console/services/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Service helpers for the Hermes Web Console backend."""
|
||||
|
||||
from .approval_service import ApprovalService
|
||||
from .browser_service import BrowserService
|
||||
from .chat_service import ChatService
|
||||
from .cron_service import CronService
|
||||
from .log_service import LogService
|
||||
from .memory_service import MemoryService
|
||||
from .session_service import SessionService
|
||||
from .settings_service import SettingsService
|
||||
from .skill_service import SkillService
|
||||
|
||||
__all__ = [
|
||||
"ApprovalService",
|
||||
"BrowserService",
|
||||
"ChatService",
|
||||
"CronService",
|
||||
"LogService",
|
||||
"MemoryService",
|
||||
"SessionService",
|
||||
"SettingsService",
|
||||
"SkillService",
|
||||
]
|
||||
198
gateway/web_console/services/approval_service.py
Normal file
198
gateway/web_console/services/approval_service.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Human approval and clarification coordination for the Hermes Web Console."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable
|
||||
|
||||
MAX_HUMAN_REQUESTS = 200
|
||||
TERMINAL_REQUEST_STATUSES = {"resolved", "expired"}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PendingHumanRequest:
|
||||
request_id: str
|
||||
kind: str
|
||||
session_id: str | None
|
||||
run_id: str | None
|
||||
title: str
|
||||
prompt: str
|
||||
choices: list[str] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
sensitive: bool = False
|
||||
created_at: float = field(default_factory=time.time)
|
||||
expires_at: float = 0.0
|
||||
status: str = "pending"
|
||||
response: Any = None
|
||||
response_queue: queue.Queue[Any] = field(default_factory=queue.Queue)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"request_id": self.request_id,
|
||||
"kind": self.kind,
|
||||
"session_id": self.session_id,
|
||||
"run_id": self.run_id,
|
||||
"title": self.title,
|
||||
"prompt": self.prompt,
|
||||
"choices": list(self.choices),
|
||||
"metadata": dict(self.metadata),
|
||||
"sensitive": self.sensitive,
|
||||
"created_at": self.created_at,
|
||||
"expires_at": self.expires_at,
|
||||
"status": self.status,
|
||||
"response": self.response,
|
||||
}
|
||||
|
||||
|
||||
class ApprovalService:
|
||||
"""Tracks pending human approval/clarification requests and resolves them."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._requests: dict[str, PendingHumanRequest] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _prune_requests_if_needed(self) -> None:
|
||||
while len(self._requests) > MAX_HUMAN_REQUESTS:
|
||||
evicted = False
|
||||
for request_id, request in list(self._requests.items()):
|
||||
if request.status in TERMINAL_REQUEST_STATUSES:
|
||||
del self._requests[request_id]
|
||||
evicted = True
|
||||
break
|
||||
if evicted:
|
||||
continue
|
||||
oldest_request_id = min(self._requests.items(), key=lambda item: item[1].created_at)[0]
|
||||
del self._requests[oldest_request_id]
|
||||
|
||||
def _create_request(
|
||||
self,
|
||||
*,
|
||||
kind: str,
|
||||
session_id: str | None,
|
||||
run_id: str | None,
|
||||
title: str,
|
||||
prompt: str,
|
||||
choices: list[str] | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
sensitive: bool = False,
|
||||
timeout: float = 60.0,
|
||||
) -> PendingHumanRequest:
|
||||
request = PendingHumanRequest(
|
||||
request_id=str(uuid.uuid4()),
|
||||
kind=kind,
|
||||
session_id=session_id,
|
||||
run_id=run_id,
|
||||
title=title,
|
||||
prompt=prompt,
|
||||
choices=list(choices or []),
|
||||
metadata=dict(metadata or {}),
|
||||
sensitive=sensitive,
|
||||
expires_at=time.time() + timeout,
|
||||
)
|
||||
with self._lock:
|
||||
self._requests[request.request_id] = request
|
||||
self._prune_requests_if_needed()
|
||||
return request
|
||||
|
||||
def _finish_request(self, request: PendingHumanRequest, *, status: str, response: Any) -> None:
|
||||
request.status = status
|
||||
request.response = response
|
||||
request.response_queue.put(response)
|
||||
|
||||
def list_pending(self) -> list[dict[str, Any]]:
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
pending = []
|
||||
for request in self._requests.values():
|
||||
if request.status == "pending" and request.expires_at <= now:
|
||||
request.status = "expired"
|
||||
request.response = None
|
||||
if request.status == "pending":
|
||||
pending.append(request.to_dict())
|
||||
pending.sort(key=lambda item: item["created_at"])
|
||||
return pending
|
||||
|
||||
def get_request(self, request_id: str) -> PendingHumanRequest | None:
|
||||
with self._lock:
|
||||
return self._requests.get(request_id)
|
||||
|
||||
def resolve_approval(self, request_id: str, decision: str) -> dict[str, Any] | None:
|
||||
with self._lock:
|
||||
request = self._requests.get(request_id)
|
||||
if request is None or request.kind != "approval" or request.status != "pending":
|
||||
return None
|
||||
self._finish_request(request, status="resolved", response=decision)
|
||||
return request.to_dict()
|
||||
|
||||
def resolve_clarification(self, request_id: str, response: str) -> dict[str, Any] | None:
|
||||
with self._lock:
|
||||
request = self._requests.get(request_id)
|
||||
if request is None or request.kind != "clarify" or request.status != "pending":
|
||||
return None
|
||||
self._finish_request(request, status="resolved", response=response)
|
||||
return request.to_dict()
|
||||
|
||||
def deny_request(self, request_id: str) -> dict[str, Any] | None:
|
||||
with self._lock:
|
||||
request = self._requests.get(request_id)
|
||||
if request is None or request.status != "pending":
|
||||
return None
|
||||
denied_value = "deny" if request.kind == "approval" else None
|
||||
self._finish_request(request, status="resolved", response=denied_value)
|
||||
return request.to_dict()
|
||||
|
||||
def create_approval_callback(self, *, session_id: str | None = None, run_id: str | None = None) -> Callable[..., str]:
|
||||
def callback(command: str, description: str, *, allow_permanent: bool = True) -> str:
|
||||
choices = ["once", "session", "deny"]
|
||||
if allow_permanent:
|
||||
choices.insert(2, "always")
|
||||
request = self._create_request(
|
||||
kind="approval",
|
||||
session_id=session_id,
|
||||
run_id=run_id,
|
||||
title="Approve dangerous command",
|
||||
prompt=description,
|
||||
choices=choices,
|
||||
metadata={"command": command, "description": description},
|
||||
timeout=60.0,
|
||||
)
|
||||
try:
|
||||
result = request.response_queue.get(timeout=60.0)
|
||||
return result if isinstance(result, str) else "deny"
|
||||
except queue.Empty:
|
||||
request.status = "expired"
|
||||
request.response = "deny"
|
||||
return "deny"
|
||||
|
||||
return callback
|
||||
|
||||
def create_clarify_callback(self, *, session_id: str | None = None, run_id: str | None = None) -> Callable[[str, list[str] | None], str]:
|
||||
def callback(question: str, choices: list[str] | None) -> str:
|
||||
request = self._create_request(
|
||||
kind="clarify",
|
||||
session_id=session_id,
|
||||
run_id=run_id,
|
||||
title="Clarification needed",
|
||||
prompt=question,
|
||||
choices=list(choices or []),
|
||||
timeout=120.0,
|
||||
)
|
||||
try:
|
||||
result = request.response_queue.get(timeout=120.0)
|
||||
return result if isinstance(result, str) else (
|
||||
"The user did not provide a response within the time limit. "
|
||||
"Use your best judgement to make the choice and proceed."
|
||||
)
|
||||
except queue.Empty:
|
||||
request.status = "expired"
|
||||
request.response = None
|
||||
return (
|
||||
"The user did not provide a response within the time limit. "
|
||||
"Use your best judgement to make the choice and proceed."
|
||||
)
|
||||
|
||||
return callback
|
||||
87
gateway/web_console/services/browser_service.py
Normal file
87
gateway/web_console/services/browser_service.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Browser service helpers for the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
from typing import Any, Callable
|
||||
|
||||
from hermes_cli.config import save_env_value
|
||||
|
||||
|
||||
class BrowserService:
|
||||
"""Thin wrapper around Hermes browser connection state."""
|
||||
|
||||
DEFAULT_CDP_URL = "http://localhost:9222"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
active_sessions_getter: Callable[[], dict[str, dict[str, str]]] | None = None,
|
||||
cleanup_all_browsers: Callable[[], None] | None = None,
|
||||
requirements_checker: Callable[[], bool] | None = None,
|
||||
cdp_resolver: Callable[[str], str] | None = None,
|
||||
env_writer: Callable[[str, str], None] = save_env_value,
|
||||
) -> None:
|
||||
self._active_sessions_getter = active_sessions_getter or (lambda: {})
|
||||
self._cleanup_all_browsers = cleanup_all_browsers or (lambda: None)
|
||||
self._requirements_checker = requirements_checker or (lambda: True)
|
||||
self._cdp_resolver = cdp_resolver or (lambda value: value)
|
||||
self._env_writer = env_writer
|
||||
|
||||
def get_status(self) -> dict[str, Any]:
|
||||
current = self._current_cdp_url()
|
||||
mode = "live_cdp" if current else ("browserbase" if self._has_browserbase_credentials() else "local")
|
||||
return {
|
||||
"mode": mode,
|
||||
"connected": bool(current),
|
||||
"cdp_url": current,
|
||||
"reachable": self._is_cdp_reachable(current) if current else None,
|
||||
"requirements_ok": self._requirements_checker(),
|
||||
"active_sessions": self._active_sessions_getter(),
|
||||
}
|
||||
|
||||
def connect(self, cdp_url: str | None = None) -> dict[str, Any]:
|
||||
target = (cdp_url or self.DEFAULT_CDP_URL).strip()
|
||||
if not target:
|
||||
raise ValueError("The 'cdp_url' field must be a non-empty string when provided.")
|
||||
resolved = self._cdp_resolver(target)
|
||||
self._cleanup_all_browsers()
|
||||
self._env_writer("BROWSER_CDP_URL", target)
|
||||
return {
|
||||
**self.get_status(),
|
||||
"requested_cdp_url": target,
|
||||
"cdp_url": resolved or target,
|
||||
"message": "Browser connected to a live Chrome CDP endpoint.",
|
||||
}
|
||||
|
||||
def disconnect(self) -> dict[str, Any]:
|
||||
self._cleanup_all_browsers()
|
||||
self._env_writer("BROWSER_CDP_URL", "")
|
||||
status = self.get_status()
|
||||
status["message"] = "Browser reverted to the default backend."
|
||||
return status
|
||||
|
||||
@staticmethod
|
||||
def _has_browserbase_credentials() -> bool:
|
||||
import os
|
||||
|
||||
return bool(os.getenv("BROWSERBASE_API_KEY", "").strip())
|
||||
|
||||
@staticmethod
|
||||
def _current_cdp_url() -> str:
|
||||
import os
|
||||
|
||||
return os.getenv("BROWSER_CDP_URL", "").strip()
|
||||
|
||||
@staticmethod
|
||||
def _is_cdp_reachable(cdp_url: str) -> bool:
|
||||
try:
|
||||
port = int(cdp_url.rsplit(":", 1)[-1].split("/")[0])
|
||||
except (TypeError, ValueError, IndexError):
|
||||
return False
|
||||
host = "127.0.0.1"
|
||||
try:
|
||||
with socket.create_connection((host, port), timeout=1):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
238
gateway/web_console/services/chat_service.py
Normal file
238
gateway/web_console/services/chat_service.py
Normal file
@@ -0,0 +1,238 @@
|
||||
"""Runtime wrapper for emitting Hermes Web Console GUI events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import threading
|
||||
import uuid
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from gateway.web_console.event_bus import GuiEvent
|
||||
from gateway.web_console.state import WebConsoleState, get_web_console_state
|
||||
from gateway.web_console.services.approval_service import ApprovalService
|
||||
|
||||
RuntimeRunner = Callable[..., Any]
|
||||
_TERMINAL_APPROVAL_CALLBACK_LOCK = threading.Lock()
|
||||
|
||||
|
||||
class ChatService:
|
||||
"""Minimal chat runtime wrapper that publishes GUI events for a session."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
state: WebConsoleState | None = None,
|
||||
runtime_runner: RuntimeRunner | None = None,
|
||||
human_service: ApprovalService | None = None,
|
||||
) -> None:
|
||||
self.state = state or get_web_console_state()
|
||||
self.runtime_runner = runtime_runner or self._default_runtime_runner
|
||||
self.human_service = human_service
|
||||
self._session_db = None
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
self._session_db = SessionDB()
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning("SessionDB unavailable: %s", e)
|
||||
|
||||
async def run_chat(
|
||||
self,
|
||||
*,
|
||||
prompt: str,
|
||||
session_id: str,
|
||||
conversation_history: list[dict[str, Any]] | None = None,
|
||||
ephemeral_system_prompt: str | None = None,
|
||||
run_id: str | None = None,
|
||||
runtime_context: Optional[Dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run a single chat turn and publish GUI events to the session channel."""
|
||||
run_id = run_id or str(uuid.uuid4())
|
||||
history = list(conversation_history or [])
|
||||
runtime_context = dict(runtime_context or {})
|
||||
loop = asyncio.get_running_loop()
|
||||
pending_tasks: list[asyncio.Task[Any]] = []
|
||||
|
||||
async def publish(event_type: str, payload: dict[str, Any] | None = None) -> None:
|
||||
await self.state.event_bus.publish(
|
||||
session_id,
|
||||
GuiEvent(
|
||||
type=event_type,
|
||||
session_id=session_id,
|
||||
run_id=run_id,
|
||||
payload=payload or {},
|
||||
),
|
||||
)
|
||||
|
||||
def runtime_gui_event_callback(event_type: str, payload: dict[str, Any] | None = None) -> None:
|
||||
# print(f"DEBUG: gui_event_callback -> {event_type}", flush=True)
|
||||
coro = publish(event_type, payload)
|
||||
try:
|
||||
running_loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
running_loop = None
|
||||
|
||||
if running_loop is loop:
|
||||
pending_tasks.append(loop.create_task(coro))
|
||||
return
|
||||
|
||||
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
||||
future.result()
|
||||
|
||||
await publish("run.started", {"prompt": prompt})
|
||||
await publish("message.user", {"content": prompt})
|
||||
|
||||
try:
|
||||
print("DEBUG: Waiting for run_in_executor...", flush=True)
|
||||
result = self.runtime_runner(
|
||||
prompt=prompt,
|
||||
session_id=session_id,
|
||||
conversation_history=history,
|
||||
ephemeral_system_prompt=ephemeral_system_prompt,
|
||||
gui_event_callback=runtime_gui_event_callback,
|
||||
run_id=run_id,
|
||||
**runtime_context,
|
||||
)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
print("DEBUG: run_in_executor returned!", flush=True)
|
||||
|
||||
if pending_tasks:
|
||||
print(f"DEBUG: Gathering {len(pending_tasks)} pending tasks...", flush=True)
|
||||
await asyncio.gather(*pending_tasks)
|
||||
print("DEBUG: Pending tasks gathered!", flush=True)
|
||||
|
||||
result_dict = dict(result or {})
|
||||
if result_dict.get("failed"):
|
||||
error_message = result_dict.get("error") or "Run failed"
|
||||
print(f"DEBUG: Run failed: {error_message}", flush=True)
|
||||
await publish("run.failed", {"error": error_message})
|
||||
return result_dict
|
||||
|
||||
print("DEBUG: Extracting assistant text...", flush=True)
|
||||
assistant_text = self._extract_assistant_text(result_dict)
|
||||
reasoning_text = self._extract_reasoning_text(result_dict)
|
||||
print("DEBUG: Publishing message.assistant.completed...", flush=True)
|
||||
completed_payload: dict[str, Any] = {"content": assistant_text}
|
||||
if reasoning_text:
|
||||
completed_payload["reasoning"] = reasoning_text
|
||||
await publish("message.assistant.completed", completed_payload)
|
||||
print("DEBUG: Publishing run.completed...", flush=True)
|
||||
await publish(
|
||||
"run.completed",
|
||||
{
|
||||
"completed": result_dict.get("completed", True),
|
||||
"final_response": assistant_text,
|
||||
"usage": result_dict.get("usage"),
|
||||
},
|
||||
)
|
||||
print("DEBUG: run_chat successfully completing!", flush=True)
|
||||
return result_dict
|
||||
except Exception as exc:
|
||||
print(f"DEBUG: Exception in run_chat: {exc}", flush=True)
|
||||
if pending_tasks:
|
||||
await asyncio.gather(*pending_tasks, return_exceptions=True)
|
||||
await publish("run.failed", {"error": str(exc), "error_type": type(exc).__name__})
|
||||
raise
|
||||
|
||||
async def _default_runtime_runner(
|
||||
self,
|
||||
*,
|
||||
prompt: str,
|
||||
session_id: str,
|
||||
conversation_history: list[dict[str, Any]] | None = None,
|
||||
ephemeral_system_prompt: str | None = None,
|
||||
gui_event_callback: Callable[[str, dict[str, Any] | None], None] | None = None,
|
||||
run_id: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""Run Hermes through the existing AIAgent path in a worker thread."""
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def _run() -> dict[str, Any]:
|
||||
import importlib
|
||||
from gateway.run import _resolve_gateway_model, _resolve_runtime_agent_kwargs
|
||||
from gateway.run import GatewayRunner
|
||||
from hermes_cli.models import resolve_fast_mode_overrides
|
||||
from run_agent import AIAgent
|
||||
|
||||
terminal_tool = importlib.import_module("tools.terminal_tool")
|
||||
|
||||
runtime_kwargs = _resolve_runtime_agent_kwargs()
|
||||
clarify_callback = None
|
||||
approval_callback = None
|
||||
if self.human_service is not None:
|
||||
clarify_callback = self.human_service.create_clarify_callback(session_id=session_id, run_id=run_id)
|
||||
approval_callback = self.human_service.create_approval_callback(session_id=session_id, run_id=run_id)
|
||||
|
||||
previous_approval_callback = getattr(terminal_tool, "_approval_callback", None)
|
||||
|
||||
def _execute_with_agent() -> dict[str, Any]:
|
||||
is_quick_ask = kwargs.get("quick_ask", False)
|
||||
active_model = _resolve_gateway_model()
|
||||
service_tier = GatewayRunner._load_service_tier()
|
||||
request_overrides = resolve_fast_mode_overrides(active_model) if service_tier else None
|
||||
agent = AIAgent(
|
||||
model=active_model,
|
||||
**runtime_kwargs,
|
||||
quiet_mode=True,
|
||||
verbose_logging=False,
|
||||
session_id=session_id,
|
||||
platform="web_console",
|
||||
service_tier=service_tier,
|
||||
request_overrides=request_overrides,
|
||||
ephemeral_system_prompt=ephemeral_system_prompt,
|
||||
clarify_callback=clarify_callback,
|
||||
gui_event_callback=gui_event_callback,
|
||||
skip_memory=True if is_quick_ask else runtime_kwargs.get("skip_memory", False),
|
||||
disabled_toolsets=["core"] if is_quick_ask else None,
|
||||
session_db=self._session_db,
|
||||
)
|
||||
return agent.run_conversation(
|
||||
user_message=prompt,
|
||||
conversation_history=conversation_history,
|
||||
task_id=run_id,
|
||||
)
|
||||
|
||||
if approval_callback is None:
|
||||
print("DEBUG: Executing without approval callback...", flush=True)
|
||||
return _execute_with_agent()
|
||||
|
||||
print("DEBUG: Acquiring _TERMINAL_APPROVAL_CALLBACK_LOCK...", flush=True)
|
||||
with _TERMINAL_APPROVAL_CALLBACK_LOCK:
|
||||
terminal_tool.set_approval_callback(approval_callback)
|
||||
try:
|
||||
print("DEBUG: Executing with agent inside lock...", flush=True)
|
||||
res = _execute_with_agent()
|
||||
print("DEBUG: Agent execution complete!", flush=True)
|
||||
return res
|
||||
finally:
|
||||
terminal_tool.set_approval_callback(previous_approval_callback)
|
||||
|
||||
return await loop.run_in_executor(None, _run)
|
||||
|
||||
@staticmethod
|
||||
def _extract_assistant_text(result: dict[str, Any]) -> str:
|
||||
final_response = result.get("final_response")
|
||||
if isinstance(final_response, str):
|
||||
return final_response
|
||||
|
||||
messages = result.get("messages") or []
|
||||
for message in reversed(messages):
|
||||
if message.get("role") == "assistant":
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _extract_reasoning_text(result: dict[str, Any]) -> str:
|
||||
"""Extract reasoning/thinking text from the last assistant message."""
|
||||
messages = result.get("messages") or []
|
||||
for message in reversed(messages):
|
||||
if message.get("role") == "assistant":
|
||||
reasoning = message.get("reasoning") or message.get("thinking")
|
||||
if isinstance(reasoning, str) and reasoning.strip():
|
||||
return reasoning
|
||||
return ""
|
||||
267
gateway/web_console/services/cron_service.py
Normal file
267
gateway/web_console/services/cron_service.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""Cron data access helpers for the Hermes Web Console."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import cron.jobs as cron_jobs
|
||||
|
||||
_CRON_THREAT_PATTERNS = [
|
||||
(r"ignore\s+(?:\w+\s+)*(?:previous|all|above|prior)\s+(?:\w+\s+)*instructions", "prompt_injection"),
|
||||
(r"do\s+not\s+tell\s+the\s+user", "deception_hide"),
|
||||
(r"system\s+prompt\s+override", "sys_prompt_override"),
|
||||
(r"disregard\s+(your|all|any)\s+(instructions|rules|guidelines)", "disregard_rules"),
|
||||
(r"curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)", "exfil_curl"),
|
||||
(r"wget\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)", "exfil_wget"),
|
||||
(r"cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass)", "read_secrets"),
|
||||
(r"authorized_keys", "ssh_backdoor"),
|
||||
(r"/etc/sudoers|visudo", "sudoers_mod"),
|
||||
(r"rm\s+-rf\s+/", "destructive_root_rm"),
|
||||
]
|
||||
|
||||
_CRON_INVISIBLE_CHARS = {
|
||||
"\u200b", "\u200c", "\u200d", "\u2060", "\ufeff",
|
||||
"\u202a", "\u202b", "\u202c", "\u202d", "\u202e",
|
||||
}
|
||||
|
||||
|
||||
def _scan_cron_prompt(prompt: str) -> str:
|
||||
for char in _CRON_INVISIBLE_CHARS:
|
||||
if char in prompt:
|
||||
return f"Blocked: prompt contains invisible unicode U+{ord(char):04X} (possible injection)."
|
||||
for pattern, pattern_id in _CRON_THREAT_PATTERNS:
|
||||
if re.search(pattern, prompt, re.IGNORECASE):
|
||||
return f"Blocked: prompt matches threat pattern '{pattern_id}'. Cron prompts must not contain injection or exfiltration payloads."
|
||||
return ""
|
||||
|
||||
|
||||
class CronService:
|
||||
"""Thin wrapper around Hermes cron storage and lifecycle helpers."""
|
||||
|
||||
@staticmethod
|
||||
def _normalize_skills(skill: str | None = None, skills: Any = None) -> list[str]:
|
||||
if skills is None:
|
||||
raw_items = [skill] if skill else []
|
||||
elif isinstance(skills, str):
|
||||
raw_items = [skills]
|
||||
else:
|
||||
raw_items = list(skills)
|
||||
|
||||
normalized: list[str] = []
|
||||
for item in raw_items:
|
||||
text = str(item or "").strip()
|
||||
if text and text not in normalized:
|
||||
normalized.append(text)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _serialize_job(job: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
if job is None:
|
||||
return None
|
||||
normalized = dict(job)
|
||||
normalized["job_id"] = normalized.get("id")
|
||||
normalized["skills"] = CronService._normalize_skills(normalized.get("skill"), normalized.get("skills"))
|
||||
normalized["skill"] = normalized["skills"][0] if normalized["skills"] else None
|
||||
normalized.setdefault("deliver", "local")
|
||||
normalized.setdefault("enabled", True)
|
||||
normalized.setdefault(
|
||||
"state",
|
||||
"scheduled" if normalized.get("enabled", True) else "paused",
|
||||
)
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _parse_repeat(repeat: Any) -> int | None:
|
||||
if repeat is None:
|
||||
return None
|
||||
if isinstance(repeat, bool) or not isinstance(repeat, int):
|
||||
raise ValueError("The 'repeat' field must be an integer or null.")
|
||||
if repeat < 1:
|
||||
raise ValueError("The 'repeat' field must be >= 1 when provided.")
|
||||
return repeat
|
||||
|
||||
@staticmethod
|
||||
def _parse_optional_text(value: Any, *, field_name: str, strip_trailing_slash: bool = False) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(f"The '{field_name}' field must be a string or null.")
|
||||
normalized = value.strip()
|
||||
if strip_trailing_slash:
|
||||
normalized = normalized.rstrip("/")
|
||||
return normalized or None
|
||||
|
||||
def _validate_prompt_and_skills(self, *, prompt: Any, skill: Any = None, skills: Any = None) -> tuple[str, list[str]]:
|
||||
if prompt is not None and not isinstance(prompt, str):
|
||||
raise ValueError("The 'prompt' field must be a string when provided.")
|
||||
canonical_skills = self._normalize_skills(skill if isinstance(skill, str) else None, skills)
|
||||
normalized_prompt = prompt if isinstance(prompt, str) else ""
|
||||
if normalized_prompt:
|
||||
scan_error = _scan_cron_prompt(normalized_prompt)
|
||||
if scan_error:
|
||||
raise ValueError(scan_error)
|
||||
return normalized_prompt, canonical_skills
|
||||
|
||||
def list_jobs(self, *, include_disabled: bool = True) -> dict[str, Any]:
|
||||
jobs = [self._serialize_job(job) for job in cron_jobs.list_jobs(include_disabled=include_disabled)]
|
||||
return {
|
||||
"jobs": jobs,
|
||||
"count": len(jobs),
|
||||
"include_disabled": include_disabled,
|
||||
}
|
||||
|
||||
def get_job(self, job_id: str) -> dict[str, Any] | None:
|
||||
return self._serialize_job(cron_jobs.get_job(job_id))
|
||||
|
||||
def create_job(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
schedule = payload.get("schedule")
|
||||
if not isinstance(schedule, str) or not schedule.strip():
|
||||
raise ValueError("The 'schedule' field must be a non-empty string.")
|
||||
|
||||
prompt, canonical_skills = self._validate_prompt_and_skills(
|
||||
prompt=payload.get("prompt"),
|
||||
skill=payload.get("skill"),
|
||||
skills=payload.get("skills"),
|
||||
)
|
||||
if not prompt.strip() and not canonical_skills:
|
||||
raise ValueError("Provide either a non-empty 'prompt' or at least one skill.")
|
||||
|
||||
name = self._parse_optional_text(payload.get("name"), field_name="name")
|
||||
deliver = self._parse_optional_text(payload.get("deliver"), field_name="deliver")
|
||||
model = self._parse_optional_text(payload.get("model"), field_name="model")
|
||||
provider = self._parse_optional_text(payload.get("provider"), field_name="provider")
|
||||
base_url = self._parse_optional_text(payload.get("base_url"), field_name="base_url", strip_trailing_slash=True)
|
||||
repeat = self._parse_repeat(payload.get("repeat"))
|
||||
|
||||
job = cron_jobs.create_job(
|
||||
prompt=prompt,
|
||||
schedule=schedule.strip(),
|
||||
name=name,
|
||||
repeat=repeat,
|
||||
deliver=deliver,
|
||||
skills=canonical_skills,
|
||||
model=model,
|
||||
provider=provider,
|
||||
base_url=base_url,
|
||||
)
|
||||
return self._serialize_job(job) or {}
|
||||
|
||||
def update_job(self, job_id: str, payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
existing = cron_jobs.get_job(job_id)
|
||||
if existing is None:
|
||||
return None
|
||||
|
||||
updates: dict[str, Any] = {}
|
||||
if "prompt" in payload or "skills" in payload or "skill" in payload:
|
||||
prompt, canonical_skills = self._validate_prompt_and_skills(
|
||||
prompt=payload.get("prompt", existing.get("prompt", "")),
|
||||
skill=payload.get("skill", existing.get("skill")),
|
||||
skills=payload.get("skills", existing.get("skills")),
|
||||
)
|
||||
if "prompt" in payload:
|
||||
updates["prompt"] = prompt
|
||||
if "skills" in payload or "skill" in payload:
|
||||
updates["skills"] = canonical_skills
|
||||
updates["skill"] = canonical_skills[0] if canonical_skills else None
|
||||
effective_prompt = updates.get("prompt", existing.get("prompt", ""))
|
||||
effective_skills = updates.get("skills", self._normalize_skills(existing.get("skill"), existing.get("skills")))
|
||||
if not str(effective_prompt).strip() and not effective_skills:
|
||||
raise ValueError("A cron job must keep either a non-empty prompt or at least one skill.")
|
||||
|
||||
if "name" in payload:
|
||||
updates["name"] = self._parse_optional_text(payload.get("name"), field_name="name") or existing.get("name")
|
||||
if "deliver" in payload:
|
||||
updates["deliver"] = self._parse_optional_text(payload.get("deliver"), field_name="deliver")
|
||||
if "model" in payload:
|
||||
updates["model"] = self._parse_optional_text(payload.get("model"), field_name="model")
|
||||
if "provider" in payload:
|
||||
updates["provider"] = self._parse_optional_text(payload.get("provider"), field_name="provider")
|
||||
if "base_url" in payload:
|
||||
updates["base_url"] = self._parse_optional_text(payload.get("base_url"), field_name="base_url", strip_trailing_slash=True)
|
||||
if "repeat" in payload:
|
||||
repeat_state = dict(existing.get("repeat") or {})
|
||||
repeat_state["times"] = self._parse_repeat(payload.get("repeat"))
|
||||
updates["repeat"] = repeat_state
|
||||
if "schedule" in payload:
|
||||
schedule_value = payload.get("schedule")
|
||||
if not isinstance(schedule_value, str) or not schedule_value.strip():
|
||||
raise ValueError("The 'schedule' field must be a non-empty string.")
|
||||
parsed_schedule = cron_jobs.parse_schedule(schedule_value.strip())
|
||||
updates["schedule"] = parsed_schedule
|
||||
updates["schedule_display"] = parsed_schedule.get("display", schedule_value.strip())
|
||||
if existing.get("state") != "paused":
|
||||
updates["enabled"] = True
|
||||
updates["state"] = "scheduled"
|
||||
|
||||
if not updates:
|
||||
raise ValueError("No updates were provided.")
|
||||
|
||||
updated = cron_jobs.update_job(job_id, updates)
|
||||
return self._serialize_job(updated)
|
||||
|
||||
def pause_job(self, job_id: str, *, reason: str | None = None) -> dict[str, Any] | None:
|
||||
if reason is not None and not isinstance(reason, str):
|
||||
raise ValueError("The 'reason' field must be a string when provided.")
|
||||
return self._serialize_job(cron_jobs.pause_job(job_id, reason=reason.strip() or None if isinstance(reason, str) else None))
|
||||
|
||||
def resume_job(self, job_id: str) -> dict[str, Any] | None:
|
||||
return self._serialize_job(cron_jobs.resume_job(job_id))
|
||||
|
||||
def run_job(self, job_id: str) -> dict[str, Any] | None:
|
||||
return self._serialize_job(cron_jobs.trigger_job(job_id))
|
||||
|
||||
def delete_job(self, job_id: str) -> bool:
|
||||
return cron_jobs.remove_job(job_id)
|
||||
|
||||
@staticmethod
|
||||
def _history_timestamp(output_file: Path) -> str:
|
||||
try:
|
||||
return datetime.strptime(output_file.stem, "%Y-%m-%d_%H-%M-%S").astimezone().isoformat()
|
||||
except ValueError:
|
||||
return datetime.fromtimestamp(output_file.stat().st_mtime).astimezone().isoformat()
|
||||
|
||||
def get_job_history(self, job_id: str, *, limit: int = 20) -> dict[str, Any] | None:
|
||||
job = self.get_job(job_id)
|
||||
if job is None:
|
||||
return None
|
||||
|
||||
if limit < 0:
|
||||
raise ValueError("The 'limit' field must be >= 0.")
|
||||
|
||||
output_dir = cron_jobs.OUTPUT_DIR / job_id
|
||||
entries: list[dict[str, Any]] = []
|
||||
if output_dir.exists():
|
||||
files = sorted(
|
||||
[path for path in output_dir.iterdir() if path.is_file()],
|
||||
key=lambda path: path.name,
|
||||
reverse=True,
|
||||
)
|
||||
for output_file in files[:limit]:
|
||||
text = output_file.read_text(encoding="utf-8", errors="replace")
|
||||
preview = text[:500]
|
||||
entries.append(
|
||||
{
|
||||
"run_id": output_file.stem,
|
||||
"job_id": job_id,
|
||||
"output_file": str(output_file),
|
||||
"filename": output_file.name,
|
||||
"created_at": self._history_timestamp(output_file),
|
||||
"size_bytes": output_file.stat().st_size,
|
||||
"output_preview": preview,
|
||||
"output_truncated": len(text) > len(preview),
|
||||
"output_available": True,
|
||||
"source": "output_file",
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"history": entries,
|
||||
"count": len(entries),
|
||||
"latest_run_at": job.get("last_run_at"),
|
||||
"latest_status": job.get("last_status"),
|
||||
"latest_error": job.get("last_error"),
|
||||
}
|
||||
197
gateway/web_console/services/gateway_service.py
Normal file
197
gateway/web_console/services/gateway_service.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""Gateway admin service helpers for the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Callable
|
||||
|
||||
from gateway.config import GatewayConfig, Platform, PlatformConfig, load_gateway_config
|
||||
from gateway.pairing import PairingStore
|
||||
from gateway.status import get_running_pid, read_runtime_status
|
||||
|
||||
_ALLOW_ALL_ENV_BY_PLATFORM: dict[Platform, str] = {
|
||||
Platform.TELEGRAM: "TELEGRAM_ALLOW_ALL_USERS",
|
||||
Platform.DISCORD: "DISCORD_ALLOW_ALL_USERS",
|
||||
Platform.WHATSAPP: "WHATSAPP_ALLOW_ALL_USERS",
|
||||
Platform.SLACK: "SLACK_ALLOW_ALL_USERS",
|
||||
Platform.SIGNAL: "SIGNAL_ALLOW_ALL_USERS",
|
||||
Platform.EMAIL: "EMAIL_ALLOW_ALL_USERS",
|
||||
Platform.SMS: "SMS_ALLOW_ALL_USERS",
|
||||
Platform.MATTERMOST: "MATTERMOST_ALLOW_ALL_USERS",
|
||||
Platform.MATRIX: "MATRIX_ALLOW_ALL_USERS",
|
||||
Platform.DINGTALK: "DINGTALK_ALLOW_ALL_USERS",
|
||||
}
|
||||
|
||||
_ALLOWLIST_ENV_BY_PLATFORM: dict[Platform, str] = {
|
||||
Platform.TELEGRAM: "TELEGRAM_ALLOWED_USERS",
|
||||
Platform.DISCORD: "DISCORD_ALLOWED_USERS",
|
||||
Platform.WHATSAPP: "WHATSAPP_ALLOWED_USERS",
|
||||
Platform.SLACK: "SLACK_ALLOWED_USERS",
|
||||
Platform.SIGNAL: "SIGNAL_ALLOWED_USERS",
|
||||
Platform.EMAIL: "EMAIL_ALLOWED_USERS",
|
||||
Platform.SMS: "SMS_ALLOWED_USERS",
|
||||
Platform.MATTERMOST: "MATTERMOST_ALLOWED_USERS",
|
||||
Platform.MATRIX: "MATRIX_ALLOWED_USERS",
|
||||
Platform.DINGTALK: "DINGTALK_ALLOWED_USERS",
|
||||
}
|
||||
|
||||
_EXEMPT_AUTH_PLATFORMS = {Platform.HOMEASSISTANT, Platform.WEBHOOK}
|
||||
|
||||
|
||||
class GatewayService:
|
||||
"""Thin wrapper around existing gateway runtime status and pairing behavior."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config_loader: Callable[[], GatewayConfig] = load_gateway_config,
|
||||
runtime_status_reader: Callable[[], dict[str, Any] | None] = read_runtime_status,
|
||||
running_pid_getter: Callable[[], int | None] = get_running_pid,
|
||||
pairing_store_factory: Callable[[], PairingStore] = PairingStore,
|
||||
) -> None:
|
||||
self._config_loader = config_loader
|
||||
self._runtime_status_reader = runtime_status_reader
|
||||
self._running_pid_getter = running_pid_getter
|
||||
self._pairing_store_factory = pairing_store_factory
|
||||
|
||||
def get_overview(self) -> dict[str, Any]:
|
||||
config = self._config_loader()
|
||||
runtime_status = self._runtime_status_reader() or {}
|
||||
running_pid = self._running_pid_getter()
|
||||
pairing_state = self.get_pairing_state()
|
||||
platforms = self._build_platform_rows(config=config, runtime_status=runtime_status, pairing_state=pairing_state)
|
||||
|
||||
return {
|
||||
"gateway": {
|
||||
"running": running_pid is not None,
|
||||
"pid": running_pid,
|
||||
"state": runtime_status.get("gateway_state") or ("running" if running_pid is not None else "stopped"),
|
||||
"exit_reason": runtime_status.get("exit_reason"),
|
||||
"updated_at": runtime_status.get("updated_at"),
|
||||
},
|
||||
"summary": {
|
||||
"platform_count": len(platforms),
|
||||
"enabled_platforms": sum(1 for platform in platforms if platform["enabled"]),
|
||||
"configured_platforms": sum(1 for platform in platforms if platform["configured"]),
|
||||
"connected_platforms": sum(1 for platform in platforms if platform["runtime_state"] == "connected"),
|
||||
"pending_pairings": pairing_state["summary"]["pending_count"],
|
||||
"approved_pairings": pairing_state["summary"]["approved_count"],
|
||||
},
|
||||
}
|
||||
|
||||
def get_platforms(self) -> list[dict[str, Any]]:
|
||||
config = self._config_loader()
|
||||
runtime_status = self._runtime_status_reader() or {}
|
||||
pairing_state = self.get_pairing_state()
|
||||
return self._build_platform_rows(config=config, runtime_status=runtime_status, pairing_state=pairing_state)
|
||||
|
||||
def get_pairing_state(self) -> dict[str, Any]:
|
||||
store = self._pairing_store_factory()
|
||||
pending = sorted(store.list_pending(), key=lambda item: (item.get("platform", ""), item.get("code", "")))
|
||||
approved = sorted(store.list_approved(), key=lambda item: (item.get("platform", ""), item.get("user_id", "")))
|
||||
return {
|
||||
"pending": pending,
|
||||
"approved": approved,
|
||||
"summary": {
|
||||
"pending_count": len(pending),
|
||||
"approved_count": len(approved),
|
||||
"platforms_with_pending": sorted({item["platform"] for item in pending}),
|
||||
"platforms_with_approved": sorted({item["platform"] for item in approved}),
|
||||
},
|
||||
}
|
||||
|
||||
def approve_pairing(self, *, platform: str, code: str) -> dict[str, Any] | None:
|
||||
store = self._pairing_store_factory()
|
||||
approved = store.approve_code(platform.strip().lower(), code.strip().upper())
|
||||
if approved is None:
|
||||
return None
|
||||
return {
|
||||
"platform": platform.strip().lower(),
|
||||
"code": code.strip().upper(),
|
||||
"user": approved,
|
||||
}
|
||||
|
||||
def revoke_pairing(self, *, platform: str, user_id: str) -> bool:
|
||||
store = self._pairing_store_factory()
|
||||
return store.revoke(platform.strip().lower(), user_id.strip())
|
||||
|
||||
def _build_platform_rows(
|
||||
self,
|
||||
*,
|
||||
config: GatewayConfig,
|
||||
runtime_status: dict[str, Any],
|
||||
pairing_state: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
runtime_platforms = runtime_status.get("platforms") or {}
|
||||
connected_platforms = set(config.get_connected_platforms())
|
||||
pending_counts = self._count_by_platform(pairing_state.get("pending") or [])
|
||||
approved_counts = self._count_by_platform(pairing_state.get("approved") or [])
|
||||
|
||||
rows: list[dict[str, Any]] = []
|
||||
for platform in sorted((item for item in Platform if item is not Platform.LOCAL), key=lambda item: item.value):
|
||||
platform_config = config.platforms.get(platform)
|
||||
runtime_platform = runtime_platforms.get(platform.value) or {}
|
||||
allowlist = self._parse_allowlist(os.getenv(_ALLOWLIST_ENV_BY_PLATFORM.get(platform, ""), ""))
|
||||
home_channel = platform_config.home_channel.to_dict() if platform_config and platform_config.home_channel else None
|
||||
auth_mode = self._auth_mode(platform=platform, config=config, allowlist=allowlist)
|
||||
rows.append(
|
||||
{
|
||||
"key": platform.value,
|
||||
"label": self._platform_label(platform),
|
||||
"enabled": bool(platform_config and platform_config.enabled),
|
||||
"configured": platform in connected_platforms,
|
||||
"runtime_state": runtime_platform.get("state") or "unknown",
|
||||
"error_code": runtime_platform.get("error_code"),
|
||||
"error_message": runtime_platform.get("error_message"),
|
||||
"updated_at": runtime_platform.get("updated_at"),
|
||||
"home_channel": home_channel,
|
||||
"auth": {
|
||||
"mode": auth_mode,
|
||||
"allow_all": self._platform_allow_all_enabled(platform),
|
||||
"allowlist_count": len(allowlist),
|
||||
"pairing_behavior": config.get_unauthorized_dm_behavior(platform),
|
||||
},
|
||||
"pairing": {
|
||||
"pending_count": pending_counts.get(platform.value, 0),
|
||||
"approved_count": approved_counts.get(platform.value, 0),
|
||||
},
|
||||
"extra": dict(platform_config.extra) if platform_config else {},
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
@staticmethod
|
||||
def _count_by_platform(entries: list[dict[str, Any]]) -> dict[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for item in entries:
|
||||
platform = item.get("platform")
|
||||
if not isinstance(platform, str) or not platform:
|
||||
continue
|
||||
counts[platform] = counts.get(platform, 0) + 1
|
||||
return counts
|
||||
|
||||
@staticmethod
|
||||
def _platform_label(platform: Platform) -> str:
|
||||
return platform.value.replace("_", " ").title()
|
||||
|
||||
@staticmethod
|
||||
def _parse_allowlist(raw: str) -> list[str]:
|
||||
return [item.strip() for item in raw.split(",") if item.strip()]
|
||||
|
||||
@staticmethod
|
||||
def _platform_allow_all_enabled(platform: Platform) -> bool:
|
||||
platform_var = _ALLOW_ALL_ENV_BY_PLATFORM.get(platform)
|
||||
if platform_var and os.getenv(platform_var, "").strip().lower() in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
def _auth_mode(self, *, platform: Platform, config: GatewayConfig, allowlist: list[str]) -> str:
|
||||
if platform in _EXEMPT_AUTH_PLATFORMS:
|
||||
return "external_auth"
|
||||
if self._platform_allow_all_enabled(platform):
|
||||
return "allow_all"
|
||||
if allowlist:
|
||||
return "allowlist"
|
||||
if config.get_unauthorized_dm_behavior(platform) == "pair":
|
||||
return "pairing"
|
||||
return "deny"
|
||||
69
gateway/web_console/services/log_service.py
Normal file
69
gateway/web_console/services/log_service.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Log service helpers for the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from hermes_cli.config import ensure_hermes_home, get_hermes_home
|
||||
|
||||
|
||||
class LogService:
|
||||
"""Thin wrapper for reading Hermes log files from ~/.hermes/logs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
hermes_home_getter: Callable[[], Path] = get_hermes_home,
|
||||
ensure_home: Callable[[], None] = ensure_hermes_home,
|
||||
) -> None:
|
||||
self._hermes_home_getter = hermes_home_getter
|
||||
self._ensure_home = ensure_home
|
||||
|
||||
def get_logs(self, *, file_name: str | None = None, limit: int = 200) -> dict[str, Any]:
|
||||
self._ensure_home()
|
||||
log_dir = self._hermes_home_getter() / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
files = self._list_log_files(log_dir)
|
||||
selected = self._resolve_selected_file(log_dir, files, file_name)
|
||||
lines = self._tail_lines(selected, limit=limit) if selected is not None else []
|
||||
return {
|
||||
"directory": str(log_dir),
|
||||
"file": selected.name if selected is not None else None,
|
||||
"available_files": [item.name for item in files],
|
||||
"line_count": len(lines),
|
||||
"lines": lines,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _list_log_files(log_dir: Path) -> list[Path]:
|
||||
return sorted(
|
||||
[item for item in log_dir.iterdir() if item.is_file()],
|
||||
key=lambda item: item.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _resolve_selected_file(log_dir: Path, files: list[Path], file_name: str | None) -> Path | None:
|
||||
if file_name:
|
||||
candidate = (log_dir / file_name).resolve()
|
||||
if candidate.parent != log_dir.resolve() or not candidate.is_file():
|
||||
raise FileNotFoundError(file_name)
|
||||
return candidate
|
||||
if not files:
|
||||
return None
|
||||
for preferred in ("gateway.log", "gateway.error.log"):
|
||||
for item in files:
|
||||
if item.name == preferred:
|
||||
return item
|
||||
return files[0]
|
||||
|
||||
@staticmethod
|
||||
def _tail_lines(path: Path, *, limit: int) -> list[str]:
|
||||
if limit <= 0:
|
||||
return []
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
except OSError:
|
||||
return []
|
||||
return content[-limit:]
|
||||
194
gateway/web_console/services/memory_service.py
Normal file
194
gateway/web_console/services/memory_service.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""Memory and session-search helpers for the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
def _load_module_from_tools(module_name: str):
|
||||
root = Path(__file__).resolve().parents[3]
|
||||
module_path = root / "tools" / f"{module_name}.py"
|
||||
cache_key = f"gateway.web_console._lazy_{module_name}"
|
||||
module = sys.modules.get(cache_key)
|
||||
if module is not None:
|
||||
return module
|
||||
|
||||
spec = importlib.util.spec_from_file_location(cache_key, module_path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise ImportError(f"Could not load {module_name} from {module_path}.")
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[cache_key] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _get_memory_store_class():
|
||||
return _load_module_from_tools("memory_tool").MemoryStore
|
||||
|
||||
|
||||
def session_search(**kwargs):
|
||||
return _load_module_from_tools("session_search_tool").session_search(**kwargs)
|
||||
|
||||
|
||||
class MemoryService:
|
||||
"""Thin wrapper around Hermes memory files and session-search logic."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
store: Any = None,
|
||||
db: SessionDB | None = None,
|
||||
) -> None:
|
||||
self._store = store
|
||||
self.db = db or SessionDB()
|
||||
|
||||
@staticmethod
|
||||
def _memory_config() -> dict[str, Any]:
|
||||
try:
|
||||
config = load_config()
|
||||
except Exception:
|
||||
config = {}
|
||||
memory_cfg = config.get("memory") or {}
|
||||
if not isinstance(memory_cfg, dict):
|
||||
return {}
|
||||
return memory_cfg
|
||||
|
||||
@classmethod
|
||||
def _target_enabled(cls, target: str) -> bool:
|
||||
memory_cfg = cls._memory_config()
|
||||
if target == "user":
|
||||
return bool(memory_cfg.get("user_profile_enabled", True))
|
||||
return bool(memory_cfg.get("memory_enabled", True))
|
||||
|
||||
@classmethod
|
||||
def _build_store(cls) -> Any:
|
||||
memory_cfg = cls._memory_config()
|
||||
memory_store_class = _get_memory_store_class()
|
||||
store = memory_store_class(
|
||||
memory_char_limit=int(memory_cfg.get("memory_char_limit", 2200) or 2200),
|
||||
user_char_limit=int(memory_cfg.get("user_char_limit", 1375) or 1375),
|
||||
)
|
||||
store.load_from_disk()
|
||||
return store
|
||||
|
||||
def _get_store(self) -> Any:
|
||||
if self._store is None:
|
||||
self._store = self._build_store()
|
||||
return self._store
|
||||
|
||||
@staticmethod
|
||||
def _validate_target(target: str) -> str:
|
||||
normalized = (target or "memory").strip().lower()
|
||||
if normalized in {"user-profile", "user_profile"}:
|
||||
normalized = "user"
|
||||
if normalized not in {"memory", "user"}:
|
||||
raise ValueError("The 'target' field must be 'memory' or 'user'.")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _usage_payload(usage: str) -> dict[str, Any]:
|
||||
percent = None
|
||||
current_chars = None
|
||||
char_limit = None
|
||||
raw = usage or ""
|
||||
if "—" in raw:
|
||||
percent_part, _, counts_part = raw.partition("—")
|
||||
percent_part = percent_part.strip().rstrip("%")
|
||||
counts_part = counts_part.strip().replace(" chars", "")
|
||||
try:
|
||||
percent = int(percent_part)
|
||||
except ValueError:
|
||||
percent = None
|
||||
if "/" in counts_part:
|
||||
current_raw, _, limit_raw = counts_part.partition("/")
|
||||
try:
|
||||
current_chars = int(current_raw.replace(",", "").strip())
|
||||
char_limit = int(limit_raw.replace(",", "").strip())
|
||||
except ValueError:
|
||||
current_chars = None
|
||||
char_limit = None
|
||||
return {
|
||||
"text": raw,
|
||||
"percent": percent,
|
||||
"current_chars": current_chars,
|
||||
"char_limit": char_limit,
|
||||
}
|
||||
|
||||
def get_memory(self, *, target: str = "memory") -> dict[str, Any]:
|
||||
target = self._validate_target(target)
|
||||
store = self._get_store()
|
||||
entries = list(store.user_entries if target == "user" else store.memory_entries)
|
||||
current_chars = store._char_count(target)
|
||||
char_limit = store._char_limit(target)
|
||||
usage_text = f"{int((current_chars / char_limit) * 100) if char_limit > 0 else 0}% — {current_chars:,}/{char_limit:,} chars"
|
||||
return {
|
||||
"target": target,
|
||||
"enabled": self._target_enabled(target),
|
||||
"entries": entries,
|
||||
"entry_count": len(entries),
|
||||
"usage": self._usage_payload(usage_text),
|
||||
"path": str(Path(store._path_for(target)).resolve()),
|
||||
}
|
||||
|
||||
def mutate_memory(
|
||||
self,
|
||||
*,
|
||||
action: str,
|
||||
target: str = "memory",
|
||||
content: str | None = None,
|
||||
old_text: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
target = self._validate_target(target)
|
||||
if not self._target_enabled(target):
|
||||
label = "user profile" if target == "user" else "memory"
|
||||
raise PermissionError(f"Local {label} is disabled in config.")
|
||||
|
||||
store = self._get_store()
|
||||
if action == "add":
|
||||
result = store.add(target, content or "")
|
||||
elif action == "replace":
|
||||
result = store.replace(target, old_text or "", content or "")
|
||||
elif action == "remove":
|
||||
result = store.remove(target, old_text or "")
|
||||
else:
|
||||
raise ValueError("Unsupported action.")
|
||||
|
||||
payload = self.get_memory(target=target)
|
||||
payload["message"] = result.get("message")
|
||||
payload["success"] = bool(result.get("success"))
|
||||
if not result.get("success"):
|
||||
payload["error"] = result.get("error")
|
||||
if "matches" in result:
|
||||
payload["matches"] = result["matches"]
|
||||
return payload
|
||||
|
||||
def search_sessions(
|
||||
self,
|
||||
*,
|
||||
query: str,
|
||||
role_filter: str | None = None,
|
||||
limit: int = 3,
|
||||
current_session_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
result = session_search(
|
||||
query=query,
|
||||
role_filter=role_filter,
|
||||
limit=limit,
|
||||
db=self.db,
|
||||
current_session_id=current_session_id,
|
||||
)
|
||||
try:
|
||||
payload = json.loads(result)
|
||||
except (TypeError, json.JSONDecodeError) as exc:
|
||||
raise RuntimeError("Session search returned invalid JSON.") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError("Session search returned an invalid payload.")
|
||||
return payload
|
||||
216
gateway/web_console/services/session_service.py
Normal file
216
gateway/web_console/services/session_service.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""Session data access helpers for the Hermes Web Console."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
class SessionService:
|
||||
"""Thin wrapper around Hermes session storage for GUI/API use."""
|
||||
|
||||
def __init__(self, db: SessionDB | None = None) -> None:
|
||||
self.db = db or SessionDB()
|
||||
|
||||
@staticmethod
|
||||
def _token_summary(session: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"input": session.get("input_tokens", 0) or 0,
|
||||
"output": session.get("output_tokens", 0) or 0,
|
||||
"total": (session.get("input_tokens", 0) or 0) + (session.get("output_tokens", 0) or 0),
|
||||
"cache_read": session.get("cache_read_tokens", 0) or 0,
|
||||
"cache_write": session.get("cache_write_tokens", 0) or 0,
|
||||
"reasoning": session.get("reasoning_tokens", 0) or 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _session_summary(session: dict[str, Any]) -> dict[str, Any]:
|
||||
preview = session.get("preview", "") or ""
|
||||
return {
|
||||
"session_id": session["id"],
|
||||
"title": session.get("title"),
|
||||
"last_active": session.get("last_active") or session.get("started_at"),
|
||||
"source": session.get("source"),
|
||||
"workspace": None,
|
||||
"model": session.get("model"),
|
||||
"token_summary": SessionService._token_summary(session),
|
||||
"parent_session_id": session.get("parent_session_id"),
|
||||
"has_tools": bool(session.get("tool_call_count", 0)),
|
||||
"has_attachments": False,
|
||||
"preview": preview,
|
||||
"message_count": session.get("message_count", 0) or 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_maybe(value: Any) -> Any:
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _transcript_item(message: dict[str, Any]) -> dict[str, Any]:
|
||||
tool_calls = message.get("tool_calls")
|
||||
has_tools = bool(tool_calls)
|
||||
if message.get("role") == "tool":
|
||||
item_type = "tool_result"
|
||||
elif has_tools:
|
||||
item_type = "assistant_tool_call"
|
||||
else:
|
||||
item_type = f"{message.get('role', 'unknown')}_message"
|
||||
return {
|
||||
"id": message.get("id"),
|
||||
"type": item_type,
|
||||
"role": message.get("role"),
|
||||
"content": message.get("content"),
|
||||
"timestamp": message.get("timestamp"),
|
||||
"tool_name": message.get("tool_name"),
|
||||
"tool_call_id": message.get("tool_call_id"),
|
||||
"tool_calls": tool_calls,
|
||||
"finish_reason": message.get("finish_reason"),
|
||||
}
|
||||
|
||||
def list_sessions(self, *, source: str | None = None, limit: int = 20, offset: int = 0) -> list[dict[str, Any]]:
|
||||
sessions = self.db.list_sessions_rich(source=source, limit=limit, offset=offset)
|
||||
return [self._session_summary(session) for session in sessions]
|
||||
|
||||
def get_session_detail(self, session_id: str) -> dict[str, Any] | None:
|
||||
resolved = self.db.resolve_session_id(session_id) or session_id
|
||||
exported = self.db.export_session(resolved)
|
||||
if not exported:
|
||||
return None
|
||||
summary = self._session_summary(exported)
|
||||
messages = exported.get("messages", [])
|
||||
summary.update(
|
||||
{
|
||||
"started_at": exported.get("started_at"),
|
||||
"ended_at": exported.get("ended_at"),
|
||||
"end_reason": exported.get("end_reason"),
|
||||
"system_prompt": exported.get("system_prompt"),
|
||||
"metadata": {
|
||||
"user_id": exported.get("user_id"),
|
||||
"model_config": SessionService._parse_json_maybe(exported.get("model_config")),
|
||||
"billing_provider": exported.get("billing_provider"),
|
||||
"billing_base_url": exported.get("billing_base_url"),
|
||||
"billing_mode": exported.get("billing_mode"),
|
||||
"estimated_cost_usd": exported.get("estimated_cost_usd"),
|
||||
"actual_cost_usd": exported.get("actual_cost_usd"),
|
||||
"cost_status": exported.get("cost_status"),
|
||||
"cost_source": exported.get("cost_source"),
|
||||
"pricing_version": exported.get("pricing_version"),
|
||||
},
|
||||
"recap": {
|
||||
"message_count": len(messages),
|
||||
"preview": summary.get("preview", ""),
|
||||
"last_role": messages[-1].get("role") if messages else None,
|
||||
},
|
||||
}
|
||||
)
|
||||
return summary
|
||||
|
||||
def get_transcript(self, session_id: str) -> dict[str, Any] | None:
|
||||
resolved = self.db.resolve_session_id(session_id) or session_id
|
||||
session = self.db.get_session(resolved)
|
||||
if not session:
|
||||
return None
|
||||
messages = self.db.get_messages(resolved)
|
||||
return {
|
||||
"session_id": resolved,
|
||||
"items": [self._transcript_item(message) for message in messages],
|
||||
}
|
||||
|
||||
def set_title(self, session_id: str, title: str) -> dict[str, Any] | None:
|
||||
resolved = self.db.resolve_session_id(session_id) or session_id
|
||||
if not self.db.set_session_title(resolved, title):
|
||||
return None
|
||||
session = self.db.get_session(resolved)
|
||||
return {
|
||||
"session_id": resolved,
|
||||
"title": session.get("title") if session else self.db.get_session_title(resolved),
|
||||
}
|
||||
|
||||
def resume_session(self, session_id: str) -> dict[str, Any] | None:
|
||||
resolved = self.db.resolve_session_id(session_id) or session_id
|
||||
detail = self.get_session_detail(resolved)
|
||||
if detail is None:
|
||||
return None
|
||||
conversation = self.db.get_messages_as_conversation(resolved)
|
||||
return {
|
||||
"session_id": detail["session_id"],
|
||||
"status": "resumed",
|
||||
"resume_supported": True,
|
||||
"title": detail.get("title"),
|
||||
"conversation_history": conversation,
|
||||
"session": detail,
|
||||
}
|
||||
|
||||
def branch_session(self, session_id: str, at_message_index: int | None = None) -> dict[str, Any] | None:
|
||||
"""Create a new session branched from an existing one.
|
||||
|
||||
Copies messages up to ``at_message_index`` (inclusive) into a fresh
|
||||
session. If *at_message_index* is None the entire history is copied.
|
||||
The new session's ``parent_session_id`` is set to the source session.
|
||||
"""
|
||||
import time
|
||||
import uuid
|
||||
|
||||
resolved = self.db.resolve_session_id(session_id) or session_id
|
||||
source = self.db.get_session(resolved)
|
||||
if not source:
|
||||
return None
|
||||
|
||||
messages = self.db.get_messages(resolved)
|
||||
if at_message_index is not None:
|
||||
messages = messages[: at_message_index + 1]
|
||||
|
||||
# Generate a new session id
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
branch_id = f"{ts}_branch_{uuid.uuid4().hex[:6]}"
|
||||
|
||||
# Create the branched session
|
||||
self.db.create_session(
|
||||
session_id=branch_id,
|
||||
source=source.get("source", "web_console"),
|
||||
model=source.get("model", ""),
|
||||
system_prompt=source.get("system_prompt", ""),
|
||||
parent_session_id=resolved,
|
||||
)
|
||||
|
||||
# Set a descriptive title
|
||||
source_title = source.get("title") or resolved
|
||||
branch_title = f"Branch of {source_title}"
|
||||
try:
|
||||
self.db.set_session_title(branch_id, branch_title)
|
||||
except ValueError:
|
||||
# Title conflict — append a suffix
|
||||
branch_title = f"Branch of {source_title} ({branch_id[:8]})"
|
||||
try:
|
||||
self.db.set_session_title(branch_id, branch_title)
|
||||
except ValueError:
|
||||
pass # Give up on title, keep the default
|
||||
|
||||
# Copy messages into the new session
|
||||
for msg in messages:
|
||||
self.db.append_message(
|
||||
session_id=branch_id,
|
||||
role=msg.get("role", "user"),
|
||||
content=msg.get("content", ""),
|
||||
tool_calls=msg.get("tool_calls"),
|
||||
tool_call_id=msg.get("tool_call_id"),
|
||||
tool_name=msg.get("tool_name"),
|
||||
)
|
||||
|
||||
return {
|
||||
"session_id": branch_id,
|
||||
"parent_session_id": resolved,
|
||||
"title": branch_title,
|
||||
"message_count": len(messages),
|
||||
}
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
resolved = self.db.resolve_session_id(session_id) or session_id
|
||||
return self.db.delete_session(resolved)
|
||||
138
gateway/web_console/services/settings_service.py
Normal file
138
gateway/web_console/services/settings_service.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Settings and auth-status service helpers for the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import re
|
||||
from typing import Any, Callable
|
||||
|
||||
from hermes_cli import auth as auth_module
|
||||
from hermes_cli.config import load_config, save_config
|
||||
|
||||
_SECRET_KEY_PATTERN = re.compile(
|
||||
r"(^|_)(api[_-]?key|token|secret|password|passwd|refresh[_-]?token|access[_-]?token|client[_-]?secret)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
class SettingsService:
|
||||
"""Thin wrapper around Hermes config and auth status helpers."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config_loader: Callable[[], dict[str, Any]] = load_config,
|
||||
config_saver: Callable[[dict[str, Any]], None] = save_config,
|
||||
auth_status_getter: Callable[[str | None], dict[str, Any]] = auth_module.get_auth_status,
|
||||
active_provider_getter: Callable[[], str | None] = auth_module.get_active_provider,
|
||||
provider_registry: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
self._config_loader = config_loader
|
||||
self._config_saver = config_saver
|
||||
self._auth_status_getter = auth_status_getter
|
||||
self._active_provider_getter = active_provider_getter
|
||||
self._provider_registry = dict(provider_registry or auth_module.PROVIDER_REGISTRY)
|
||||
|
||||
def get_settings(self) -> dict[str, Any]:
|
||||
config = self._config_loader()
|
||||
return self._sanitize_payload(config)
|
||||
|
||||
def update_settings(self, patch: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(patch, dict) or not patch:
|
||||
raise ValueError("Request body must include a non-empty JSON object.")
|
||||
current = self._config_loader()
|
||||
|
||||
old_provider = current.get("provider")
|
||||
new_provider = patch.get("provider")
|
||||
if "model" in patch and isinstance(patch["model"], dict) and "provider" in patch["model"]:
|
||||
new_provider = patch["model"]["provider"]
|
||||
|
||||
if new_provider and new_provider != old_provider:
|
||||
from hermes_cli.model_switch import detect_provider_for_model
|
||||
old_model = current.get("model", {})
|
||||
old_model_name = old_model if isinstance(old_model, str) else old_model.get("default", "")
|
||||
|
||||
if old_model_name:
|
||||
detected, _ = detect_provider_for_model(old_model_name, new_provider)
|
||||
is_compatible = False
|
||||
if new_provider == "openai-codex" and detected in ("openai", "openai-codex", "custom"):
|
||||
is_compatible = True
|
||||
elif detected in (new_provider, "custom"):
|
||||
is_compatible = True
|
||||
|
||||
if not is_compatible:
|
||||
if "model" not in patch:
|
||||
patch["model"] = {}
|
||||
if isinstance(patch["model"], dict):
|
||||
# Auto-populate a sensible default for Codex so the GUI isn't empty
|
||||
default_model = "gpt-5.4" if new_provider == "openai-codex" else ""
|
||||
patch["model"]["default"] = default_model
|
||||
patch["model"]["name"] = default_model
|
||||
|
||||
updated = self._deep_merge(copy.deepcopy(current), patch)
|
||||
self._config_saver(updated)
|
||||
return self._sanitize_payload(updated)
|
||||
|
||||
def get_auth_status(self) -> dict[str, Any]:
|
||||
active_provider = self._active_provider_getter()
|
||||
providers: list[dict[str, Any]] = []
|
||||
for provider_id, provider_config in sorted(self._provider_registry.items()):
|
||||
raw_status = dict(self._auth_status_getter(provider_id) or {})
|
||||
providers.append(
|
||||
{
|
||||
"provider": provider_id,
|
||||
"name": getattr(provider_config, "name", provider_id),
|
||||
"auth_type": getattr(provider_config, "auth_type", None),
|
||||
"active": provider_id == active_provider,
|
||||
"status": self._sanitize_payload(raw_status),
|
||||
}
|
||||
)
|
||||
|
||||
active_status = self._sanitize_payload(dict(self._auth_status_getter(active_provider) or {})) if active_provider else {"logged_in": False}
|
||||
resolved_provider = active_provider
|
||||
if resolved_provider is None:
|
||||
for entry in providers:
|
||||
if entry["status"].get("logged_in"):
|
||||
resolved_provider = entry["provider"]
|
||||
break
|
||||
|
||||
return {
|
||||
"active_provider": active_provider,
|
||||
"resolved_provider": resolved_provider,
|
||||
"logged_in": bool(active_status.get("logged_in")) if active_provider else any(entry["status"].get("logged_in") for entry in providers),
|
||||
"active_status": active_status,
|
||||
"providers": providers,
|
||||
}
|
||||
|
||||
def _sanitize_payload(self, value: Any, *, key_path: tuple[str, ...] = ()) -> Any:
|
||||
if isinstance(value, dict):
|
||||
sanitized: dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
key_text = str(key)
|
||||
if self._looks_secret_key(key_text):
|
||||
sanitized[key_text] = self._redacted_value(item)
|
||||
else:
|
||||
sanitized[key_text] = self._sanitize_payload(item, key_path=(*key_path, key_text))
|
||||
return sanitized
|
||||
if isinstance(value, list):
|
||||
return [self._sanitize_payload(item, key_path=key_path) for item in value]
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _deep_merge(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
|
||||
for key, value in patch.items():
|
||||
if isinstance(value, dict) and isinstance(base.get(key), dict):
|
||||
base[key] = SettingsService._deep_merge(dict(base[key]), value)
|
||||
else:
|
||||
base[key] = value
|
||||
return base
|
||||
|
||||
@staticmethod
|
||||
def _looks_secret_key(key: str) -> bool:
|
||||
return bool(_SECRET_KEY_PATTERN.search(key))
|
||||
|
||||
@staticmethod
|
||||
def _redacted_value(value: Any) -> str | None:
|
||||
if value in (None, ""):
|
||||
return None if value is None else ""
|
||||
return "***"
|
||||
197
gateway/web_console/services/skill_service.py
Normal file
197
gateway/web_console/services/skill_service.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""Skill data access helpers for the Hermes Web Console."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
class SkillService:
|
||||
"""Thin wrapper around Hermes skills discovery and session-scoped in-memory skill state."""
|
||||
|
||||
def __init__(self, db: SessionDB | None = None) -> None:
|
||||
self.db = db or SessionDB()
|
||||
self._session_loaded_skills: dict[str, dict[str, dict[str, Any]]] = {}
|
||||
|
||||
def _resolve_session_id(self, session_id: str) -> str:
|
||||
return self.db.resolve_session_id(session_id) or session_id
|
||||
|
||||
def _require_session(self, session_id: str) -> str:
|
||||
resolved = self._resolve_session_id(session_id)
|
||||
if not self.db.get_session(resolved):
|
||||
raise LookupError("session_not_found")
|
||||
return resolved
|
||||
|
||||
@staticmethod
|
||||
def _parse_tool_payload(raw_payload: str) -> dict[str, Any]:
|
||||
payload = json.loads(raw_payload)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("invalid_skill_payload")
|
||||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _build_source_indexes() -> tuple[dict[str, dict[str, Any]], set[str]]:
|
||||
from tools.skills_hub import HubLockFile
|
||||
from tools.skills_sync import _read_manifest
|
||||
|
||||
hub_entries = {entry["name"]: entry for entry in HubLockFile().list_installed()}
|
||||
builtin_names = set(_read_manifest())
|
||||
return hub_entries, builtin_names
|
||||
|
||||
@staticmethod
|
||||
def _merge_source_metadata(
|
||||
skill: dict[str, Any],
|
||||
*,
|
||||
hub_entries: dict[str, dict[str, Any]],
|
||||
builtin_names: set[str],
|
||||
) -> dict[str, Any]:
|
||||
merged = dict(skill)
|
||||
name = str(skill.get("name") or "")
|
||||
hub_entry = hub_entries.get(name)
|
||||
if hub_entry:
|
||||
merged.update(
|
||||
{
|
||||
"source_type": "hub",
|
||||
"source": hub_entry.get("source", "hub"),
|
||||
"trust_level": hub_entry.get("trust_level", "community"),
|
||||
"identifier": hub_entry.get("identifier"),
|
||||
"install_path": hub_entry.get("install_path"),
|
||||
"scan_verdict": hub_entry.get("scan_verdict"),
|
||||
"installed_at": hub_entry.get("installed_at"),
|
||||
"updated_at": hub_entry.get("updated_at"),
|
||||
"installed_metadata": hub_entry.get("metadata") or {},
|
||||
}
|
||||
)
|
||||
elif name in builtin_names:
|
||||
merged.update(
|
||||
{
|
||||
"source_type": "builtin",
|
||||
"source": "builtin",
|
||||
"trust_level": "builtin",
|
||||
"identifier": None,
|
||||
"install_path": None,
|
||||
"scan_verdict": None,
|
||||
"installed_at": None,
|
||||
"updated_at": None,
|
||||
"installed_metadata": {},
|
||||
}
|
||||
)
|
||||
else:
|
||||
merged.update(
|
||||
{
|
||||
"source_type": "local",
|
||||
"source": "local",
|
||||
"trust_level": "local",
|
||||
"identifier": None,
|
||||
"install_path": None,
|
||||
"scan_verdict": None,
|
||||
"installed_at": None,
|
||||
"updated_at": None,
|
||||
"installed_metadata": {},
|
||||
}
|
||||
)
|
||||
return merged
|
||||
|
||||
def list_skills(self) -> dict[str, Any]:
|
||||
from tools.skills_tool import skills_list
|
||||
|
||||
payload = self._parse_tool_payload(skills_list())
|
||||
if not payload.get("success"):
|
||||
raise RuntimeError(payload.get("error") or "Failed to list skills.")
|
||||
|
||||
hub_entries, builtin_names = self._build_source_indexes()
|
||||
skills = [
|
||||
self._merge_source_metadata(skill, hub_entries=hub_entries, builtin_names=builtin_names)
|
||||
for skill in payload.get("skills", [])
|
||||
]
|
||||
return {
|
||||
"skills": skills,
|
||||
"categories": payload.get("categories", []),
|
||||
"count": len(skills),
|
||||
"hint": payload.get("hint"),
|
||||
}
|
||||
|
||||
def get_skill(self, name: str) -> dict[str, Any]:
|
||||
from tools.skills_tool import skill_view
|
||||
|
||||
payload = self._parse_tool_payload(skill_view(name))
|
||||
if not payload.get("success"):
|
||||
error_message = str(payload.get("error") or "Failed to load skill.")
|
||||
lowered = error_message.lower()
|
||||
if "not found" in lowered:
|
||||
raise FileNotFoundError(error_message)
|
||||
raise ValueError(error_message)
|
||||
|
||||
hub_entries, builtin_names = self._build_source_indexes()
|
||||
return self._merge_source_metadata(payload, hub_entries=hub_entries, builtin_names=builtin_names)
|
||||
|
||||
def load_skill_for_session(self, session_id: str, name: str) -> dict[str, Any]:
|
||||
resolved_session_id = self._require_session(session_id)
|
||||
skill = self.get_skill(name)
|
||||
loaded_for_session = self._session_loaded_skills.setdefault(resolved_session_id, {})
|
||||
resolved_name = skill["name"]
|
||||
if resolved_name in loaded_for_session:
|
||||
return {
|
||||
"session_id": resolved_session_id,
|
||||
"skill": loaded_for_session[resolved_name],
|
||||
"loaded": True,
|
||||
"already_loaded": True,
|
||||
}
|
||||
|
||||
session_skill = {
|
||||
"name": resolved_name,
|
||||
"description": skill.get("description", ""),
|
||||
"path": skill.get("path"),
|
||||
"source": skill.get("source"),
|
||||
"source_type": skill.get("source_type"),
|
||||
"trust_level": skill.get("trust_level"),
|
||||
"readiness_status": skill.get("readiness_status"),
|
||||
"setup_needed": bool(skill.get("setup_needed", False)),
|
||||
"loaded_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
loaded_for_session[resolved_name] = session_skill
|
||||
return {
|
||||
"session_id": resolved_session_id,
|
||||
"skill": session_skill,
|
||||
"loaded": True,
|
||||
"already_loaded": False,
|
||||
}
|
||||
|
||||
def list_session_skills(self, session_id: str) -> dict[str, Any]:
|
||||
resolved_session_id = self._require_session(session_id)
|
||||
skills = list(self._session_loaded_skills.get(resolved_session_id, {}).values())
|
||||
skills.sort(key=lambda item: item["name"])
|
||||
return {
|
||||
"session_id": resolved_session_id,
|
||||
"skills": skills,
|
||||
"count": len(skills),
|
||||
}
|
||||
|
||||
def unload_skill_for_session(self, session_id: str, name: str) -> dict[str, Any]:
|
||||
resolved_session_id = self._require_session(session_id)
|
||||
loaded_for_session = self._session_loaded_skills.get(resolved_session_id, {})
|
||||
|
||||
canonical_name = name
|
||||
if canonical_name not in loaded_for_session:
|
||||
for loaded_name in loaded_for_session:
|
||||
if loaded_name.lower() == name.lower():
|
||||
canonical_name = loaded_name
|
||||
break
|
||||
|
||||
if canonical_name not in loaded_for_session:
|
||||
try:
|
||||
canonical_name = self.get_skill(name)["name"]
|
||||
except (FileNotFoundError, ValueError, LookupError):
|
||||
canonical_name = name
|
||||
|
||||
removed = loaded_for_session.pop(canonical_name, None)
|
||||
if not loaded_for_session and resolved_session_id in self._session_loaded_skills:
|
||||
self._session_loaded_skills.pop(resolved_session_id, None)
|
||||
return {
|
||||
"session_id": resolved_session_id,
|
||||
"name": canonical_name,
|
||||
"removed": removed is not None,
|
||||
}
|
||||
381
gateway/web_console/services/workspace_service.py
Normal file
381
gateway/web_console/services/workspace_service.py
Normal file
@@ -0,0 +1,381 @@
|
||||
"""Workspace and process helpers for the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from hermes_cli.config import get_project_root, load_config
|
||||
|
||||
_MAX_TREE_ENTRIES = 200
|
||||
_MAX_FILE_LINES = 2000
|
||||
_MAX_SEARCH_MATCHES = 200
|
||||
_MAX_SEARCH_FILE_BYTES = 1_000_000
|
||||
_MAX_SEARCH_FILES = 2000
|
||||
|
||||
|
||||
class WorkspaceService:
|
||||
"""Thin service layer for GUI workspace browsing and process inspection."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workspace_root: str | Path | None = None,
|
||||
checkpoint_manager: Any = None,
|
||||
process_registry: Any = None,
|
||||
) -> None:
|
||||
self.workspace_root = self._resolve_workspace_root(workspace_root)
|
||||
self.checkpoint_manager = checkpoint_manager
|
||||
self.process_registry = process_registry
|
||||
|
||||
@staticmethod
|
||||
def _resolve_workspace_root(workspace_root: str | Path | None) -> Path:
|
||||
if workspace_root is not None:
|
||||
return Path(workspace_root).expanduser().resolve()
|
||||
|
||||
configured_cwd = (os.getenv("TERMINAL_CWD") or "").strip()
|
||||
if configured_cwd and configured_cwd not in {".", "auto", "cwd"}:
|
||||
candidate = Path(configured_cwd).expanduser().resolve()
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
|
||||
return get_project_root().resolve()
|
||||
|
||||
@staticmethod
|
||||
def _build_checkpoint_manager() -> Any:
|
||||
from tools.checkpoint_manager import CheckpointManager
|
||||
|
||||
try:
|
||||
config = load_config()
|
||||
except Exception:
|
||||
config = {}
|
||||
checkpoint_cfg = config.get("checkpoints") or {}
|
||||
if isinstance(checkpoint_cfg, bool):
|
||||
enabled = checkpoint_cfg
|
||||
max_snapshots = 50
|
||||
else:
|
||||
enabled = bool(checkpoint_cfg.get("enabled", True))
|
||||
max_snapshots = int(checkpoint_cfg.get("max_snapshots", 50) or 50)
|
||||
return CheckpointManager(enabled=enabled, max_snapshots=max_snapshots)
|
||||
|
||||
@staticmethod
|
||||
def _get_default_process_registry() -> Any:
|
||||
from tools.process_registry import process_registry
|
||||
|
||||
return process_registry
|
||||
|
||||
def _get_checkpoint_manager(self) -> Any:
|
||||
if self.checkpoint_manager is None:
|
||||
self.checkpoint_manager = self._build_checkpoint_manager()
|
||||
return self.checkpoint_manager
|
||||
|
||||
def _get_process_registry(self) -> Any:
|
||||
if self.process_registry is None:
|
||||
self.process_registry = self._get_default_process_registry()
|
||||
return self.process_registry
|
||||
|
||||
def _resolve_path(self, path: str | None, *, allow_root: bool = True) -> Path:
|
||||
raw_path = (path or "").strip()
|
||||
candidate = self.workspace_root if not raw_path else (self.workspace_root / raw_path)
|
||||
resolved = candidate.expanduser().resolve()
|
||||
try:
|
||||
resolved.relative_to(self.workspace_root)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Path escapes the active workspace.") from exc
|
||||
if not allow_root and resolved == self.workspace_root:
|
||||
raise ValueError("This operation requires a path inside the workspace.")
|
||||
return resolved
|
||||
|
||||
def _relative_path(self, path: Path) -> str:
|
||||
if path == self.workspace_root:
|
||||
return "."
|
||||
return path.relative_to(self.workspace_root).as_posix()
|
||||
|
||||
@staticmethod
|
||||
def _is_binary_file(path: Path) -> bool:
|
||||
try:
|
||||
sample = path.read_bytes()[:4096]
|
||||
except OSError:
|
||||
return False
|
||||
return b"\x00" in sample
|
||||
|
||||
def _build_tree_node(
|
||||
self,
|
||||
path: Path,
|
||||
*,
|
||||
depth: int,
|
||||
include_hidden: bool,
|
||||
remaining: list[int],
|
||||
) -> dict[str, Any]:
|
||||
is_dir = path.is_dir()
|
||||
node: dict[str, Any] = {
|
||||
"name": path.name or self.workspace_root.name,
|
||||
"path": self._relative_path(path),
|
||||
"type": "directory" if is_dir else "file",
|
||||
}
|
||||
|
||||
if not is_dir:
|
||||
try:
|
||||
node["size"] = path.stat().st_size
|
||||
except OSError:
|
||||
node["size"] = None
|
||||
return node
|
||||
|
||||
if depth <= 0:
|
||||
node["children"] = []
|
||||
node["truncated"] = False
|
||||
return node
|
||||
|
||||
children: list[dict[str, Any]] = []
|
||||
truncated = False
|
||||
try:
|
||||
entries = sorted(
|
||||
path.iterdir(),
|
||||
key=lambda entry: (not entry.is_dir(), entry.name.lower()),
|
||||
)
|
||||
except OSError as exc:
|
||||
node["children"] = []
|
||||
node["error"] = str(exc)
|
||||
return node
|
||||
|
||||
for entry in entries:
|
||||
if not include_hidden and entry.name.startswith("."):
|
||||
continue
|
||||
if remaining[0] <= 0:
|
||||
truncated = True
|
||||
break
|
||||
remaining[0] -= 1
|
||||
children.append(
|
||||
self._build_tree_node(
|
||||
entry,
|
||||
depth=depth - 1,
|
||||
include_hidden=include_hidden,
|
||||
remaining=remaining,
|
||||
)
|
||||
)
|
||||
|
||||
node["children"] = children
|
||||
node["truncated"] = truncated
|
||||
return node
|
||||
|
||||
def get_tree(self, *, path: str | None = None, depth: int = 2, include_hidden: bool = False) -> dict[str, Any]:
|
||||
target = self._resolve_path(path)
|
||||
if not target.exists():
|
||||
raise FileNotFoundError("The requested workspace path does not exist.")
|
||||
if not target.is_dir():
|
||||
raise ValueError("The requested workspace path is not a directory.")
|
||||
safe_depth = max(0, min(int(depth), 6))
|
||||
remaining = [_MAX_TREE_ENTRIES]
|
||||
return {
|
||||
"workspace_root": str(self.workspace_root),
|
||||
"tree": self._build_tree_node(
|
||||
target,
|
||||
depth=safe_depth,
|
||||
include_hidden=include_hidden,
|
||||
remaining=remaining,
|
||||
),
|
||||
}
|
||||
|
||||
def get_file(self, *, path: str, offset: int = 1, limit: int = 500) -> dict[str, Any]:
|
||||
target = self._resolve_path(path, allow_root=False)
|
||||
if not target.exists():
|
||||
raise FileNotFoundError("The requested file does not exist.")
|
||||
if not target.is_file():
|
||||
raise ValueError("The requested path is not a file.")
|
||||
if self._is_binary_file(target):
|
||||
raise ValueError("Binary files are not supported by this endpoint.")
|
||||
|
||||
safe_offset = max(1, int(offset))
|
||||
safe_limit = max(1, min(int(limit), _MAX_FILE_LINES))
|
||||
text = target.read_text(encoding="utf-8", errors="replace")
|
||||
lines = text.splitlines()
|
||||
start = safe_offset - 1
|
||||
end = start + safe_limit
|
||||
selected = lines[start:end]
|
||||
return {
|
||||
"workspace_root": str(self.workspace_root),
|
||||
"file": {
|
||||
"path": self._relative_path(target),
|
||||
"size": target.stat().st_size,
|
||||
"line_count": len(lines),
|
||||
"offset": safe_offset,
|
||||
"limit": safe_limit,
|
||||
"content": "\n".join(selected),
|
||||
"truncated": end < len(lines),
|
||||
"is_binary": False,
|
||||
},
|
||||
}
|
||||
|
||||
def save_file(self, *, path: str, content: str) -> dict[str, Any]:
|
||||
"""Write *content* to the file at *path* inside the workspace."""
|
||||
target = self._resolve_path(path, allow_root=False)
|
||||
if target.is_dir():
|
||||
raise ValueError("The requested path is a directory, not a file.")
|
||||
if self._is_binary_file(target) and target.exists():
|
||||
raise ValueError("Binary files cannot be saved through this endpoint.")
|
||||
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(content, encoding="utf-8")
|
||||
return {
|
||||
"workspace_root": str(self.workspace_root),
|
||||
"file": {
|
||||
"path": self._relative_path(target),
|
||||
"size": target.stat().st_size,
|
||||
},
|
||||
}
|
||||
|
||||
def search_workspace(
|
||||
self,
|
||||
*,
|
||||
query: str,
|
||||
path: str | None = None,
|
||||
limit: int = 50,
|
||||
include_hidden: bool = False,
|
||||
regex: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if not query or not query.strip():
|
||||
raise ValueError("The search query must be a non-empty string.")
|
||||
|
||||
base = self._resolve_path(path)
|
||||
if not base.exists():
|
||||
raise FileNotFoundError("The requested search path does not exist.")
|
||||
if not base.is_dir():
|
||||
raise ValueError("The requested search path is not a directory.")
|
||||
|
||||
safe_limit = max(1, min(int(limit), _MAX_SEARCH_MATCHES))
|
||||
matcher = re.compile(query) if regex else None
|
||||
matches: list[dict[str, Any]] = []
|
||||
scanned_files = 0
|
||||
|
||||
for candidate in base.rglob("*"):
|
||||
if len(matches) >= safe_limit or scanned_files >= _MAX_SEARCH_FILES:
|
||||
break
|
||||
if candidate.is_dir():
|
||||
if not include_hidden and any(part.startswith(".") for part in candidate.relative_to(base).parts if part not in {"."}):
|
||||
continue
|
||||
continue
|
||||
if not include_hidden and any(part.startswith(".") for part in candidate.relative_to(base).parts):
|
||||
continue
|
||||
try:
|
||||
if candidate.stat().st_size > _MAX_SEARCH_FILE_BYTES or self._is_binary_file(candidate):
|
||||
continue
|
||||
scanned_files += 1
|
||||
with candidate.open("r", encoding="utf-8", errors="replace") as handle:
|
||||
for line_number, line in enumerate(handle, start=1):
|
||||
haystack = line.rstrip("\n")
|
||||
found = bool(matcher.search(haystack)) if matcher else query in haystack
|
||||
if found:
|
||||
matches.append(
|
||||
{
|
||||
"path": self._relative_path(candidate),
|
||||
"line": line_number,
|
||||
"content": haystack,
|
||||
}
|
||||
)
|
||||
if len(matches) >= safe_limit:
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
return {
|
||||
"workspace_root": str(self.workspace_root),
|
||||
"query": query,
|
||||
"path": self._relative_path(base),
|
||||
"matches": matches,
|
||||
"truncated": len(matches) >= safe_limit,
|
||||
"scanned_files": scanned_files,
|
||||
}
|
||||
|
||||
def list_checkpoints(self, *, path: str | None = None) -> dict[str, Any]:
|
||||
checkpoint_manager = self._get_checkpoint_manager()
|
||||
target = self._resolve_path(path)
|
||||
working_dir = target if target.is_dir() else Path(checkpoint_manager.get_working_dir_for_path(str(target)))
|
||||
checkpoints = checkpoint_manager.list_checkpoints(str(working_dir))
|
||||
return {
|
||||
"workspace_root": str(self.workspace_root),
|
||||
"working_dir": str(working_dir),
|
||||
"checkpoints": checkpoints,
|
||||
}
|
||||
|
||||
def diff_checkpoint(self, *, checkpoint_id: str, path: str | None = None) -> dict[str, Any]:
|
||||
if not checkpoint_id:
|
||||
raise ValueError("A checkpoint_id is required.")
|
||||
checkpoint_manager = self._get_checkpoint_manager()
|
||||
target = self._resolve_path(path)
|
||||
working_dir = target if target.is_dir() else Path(checkpoint_manager.get_working_dir_for_path(str(target)))
|
||||
result = checkpoint_manager.diff(str(working_dir), checkpoint_id)
|
||||
if not result.get("success"):
|
||||
raise FileNotFoundError(result.get("error") or "Checkpoint diff failed.")
|
||||
return {
|
||||
"workspace_root": str(self.workspace_root),
|
||||
"working_dir": str(working_dir),
|
||||
"checkpoint_id": checkpoint_id,
|
||||
"stat": result.get("stat", ""),
|
||||
"diff": result.get("diff", ""),
|
||||
}
|
||||
|
||||
def rollback(self, *, checkpoint_id: str, path: str | None = None, file_path: str | None = None) -> dict[str, Any]:
|
||||
if not checkpoint_id:
|
||||
raise ValueError("A checkpoint_id is required.")
|
||||
checkpoint_manager = self._get_checkpoint_manager()
|
||||
target = self._resolve_path(path)
|
||||
working_dir = target if target.is_dir() else Path(checkpoint_manager.get_working_dir_for_path(str(target)))
|
||||
|
||||
restore_file: str | None = None
|
||||
if file_path:
|
||||
restore_target = self._resolve_path(file_path, allow_root=False)
|
||||
restore_file = str(restore_target.relative_to(working_dir).as_posix())
|
||||
|
||||
result = checkpoint_manager.restore(str(working_dir), checkpoint_id, file_path=restore_file)
|
||||
if not result.get("success"):
|
||||
raise FileNotFoundError(result.get("error") or "Rollback failed.")
|
||||
return result
|
||||
|
||||
def list_processes(self) -> dict[str, Any]:
|
||||
return {"processes": self._get_process_registry().list_sessions()}
|
||||
|
||||
def get_process_log(self, process_id: str, *, offset: int = 0, limit: int = 200) -> dict[str, Any]:
|
||||
result = self._get_process_registry().read_log(process_id, offset=offset, limit=limit)
|
||||
if result.get("status") == "not_found":
|
||||
raise FileNotFoundError(result.get("error") or "Process not found.")
|
||||
return result
|
||||
|
||||
def kill_process(self, process_id: str) -> dict[str, Any]:
|
||||
result = self._get_process_registry().kill_process(process_id)
|
||||
if result.get("status") == "not_found":
|
||||
raise FileNotFoundError(result.get("error") or "Process not found.")
|
||||
if result.get("status") == "error":
|
||||
raise RuntimeError(result.get("error") or "Could not kill process.")
|
||||
return result
|
||||
|
||||
def execute_sync(self, command: str) -> dict[str, Any]:
|
||||
if not command or not command.strip():
|
||||
raise ValueError("Command cannot be empty.")
|
||||
import subprocess
|
||||
try:
|
||||
# Setting a 10s timeout so this doesn't hang the GUI thread if someone runs `sleep 100` or a repl.
|
||||
result = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=str(self.workspace_root),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
return {
|
||||
"cwd": str(self.workspace_root),
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"returncode": result.returncode
|
||||
}
|
||||
except subprocess.TimeoutExpired as e:
|
||||
return {
|
||||
"cwd": str(self.workspace_root),
|
||||
"stdout": e.stdout or "",
|
||||
"stderr": (e.stderr or "") + f"\n[Process timed out after 10s]",
|
||||
"returncode": -1
|
||||
}
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Command execution failed: {e}")
|
||||
107
gateway/web_console/sse.py
Normal file
107
gateway/web_console/sse.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Helpers for building server-sent event streams for the web console."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, AsyncIterable
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SseMessage:
|
||||
"""An SSE message payload."""
|
||||
|
||||
data: Any
|
||||
event: str | None = None
|
||||
id: str | None = None
|
||||
retry: int | None = None
|
||||
|
||||
|
||||
SSE_HEADERS = {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
|
||||
|
||||
def json_dumps(data: Any) -> str:
|
||||
"""Serialize data for SSE payload lines."""
|
||||
return json.dumps(data, separators=(",", ":"), ensure_ascii=False)
|
||||
|
||||
|
||||
def format_sse_message(message: SseMessage) -> bytes:
|
||||
"""Encode a single SSE message frame."""
|
||||
lines: list[str] = []
|
||||
|
||||
if message.event:
|
||||
lines.append(f"event: {message.event}")
|
||||
if message.id:
|
||||
lines.append(f"id: {message.id}")
|
||||
if message.retry is not None:
|
||||
lines.append(f"retry: {message.retry}")
|
||||
|
||||
payload = json_dumps(message.data)
|
||||
for line in payload.splitlines() or [payload]:
|
||||
lines.append(f"data: {line}")
|
||||
|
||||
return ("\n".join(lines) + "\n\n").encode("utf-8")
|
||||
|
||||
|
||||
def format_sse_ping(comment: str = "ping") -> bytes:
|
||||
"""Encode an SSE keepalive comment frame."""
|
||||
return f": {comment}\n\n".encode("utf-8")
|
||||
|
||||
|
||||
async def _safe_sse_write(response: web.StreamResponse, chunk: bytes) -> bool:
|
||||
"""Write an SSE chunk, returning False if the client disconnected."""
|
||||
try:
|
||||
await response.write(chunk)
|
||||
except (ConnectionResetError, RuntimeError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
async def stream_sse(
|
||||
request: web.Request,
|
||||
events: AsyncIterable[SseMessage],
|
||||
*,
|
||||
keepalive_interval: float = 15.0,
|
||||
ping_comment: str = "ping",
|
||||
) -> web.StreamResponse:
|
||||
"""Write an async iterable of SSE messages to the client with keepalives."""
|
||||
response = web.StreamResponse(status=200, headers=SSE_HEADERS)
|
||||
await response.prepare(request)
|
||||
|
||||
iterator = aiter(events)
|
||||
next_message_task: asyncio.Task[SseMessage] | None = None
|
||||
while True:
|
||||
if next_message_task is None:
|
||||
next_message_task = asyncio.create_task(anext(iterator))
|
||||
done, _pending = await asyncio.wait({next_message_task}, timeout=keepalive_interval)
|
||||
if not done:
|
||||
if not await _safe_sse_write(response, format_sse_ping(ping_comment)):
|
||||
next_message_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, StopAsyncIteration):
|
||||
await next_message_task
|
||||
break
|
||||
continue
|
||||
|
||||
try:
|
||||
message = next_message_task.result()
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
finally:
|
||||
next_message_task = None
|
||||
|
||||
if not await _safe_sse_write(response, format_sse_message(message)):
|
||||
break
|
||||
|
||||
with contextlib.suppress(ConnectionResetError, RuntimeError):
|
||||
await response.write_eof()
|
||||
|
||||
return response
|
||||
74
gateway/web_console/state.py
Normal file
74
gateway/web_console/state.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Shared state helpers for the Hermes Web Console backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from .event_bus import GuiEventBus
|
||||
|
||||
MAX_TRACKED_RUNS = 200
|
||||
TERMINAL_RUN_STATUSES = {"completed", "failed", "cancelled"}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WebConsoleState:
|
||||
"""Container for shared web console backend primitives."""
|
||||
|
||||
event_bus: GuiEventBus = field(default_factory=GuiEventBus)
|
||||
runs: OrderedDict[str, dict[str, Any]] = field(default_factory=OrderedDict)
|
||||
|
||||
def _evict_runs_if_needed(self) -> None:
|
||||
"""Evict oldest terminal runs first, then oldest active runs if still over limit."""
|
||||
while len(self.runs) > MAX_TRACKED_RUNS:
|
||||
evicted = False
|
||||
for existing_run_id, existing_run in list(self.runs.items()):
|
||||
if existing_run.get("status") in TERMINAL_RUN_STATUSES:
|
||||
del self.runs[existing_run_id]
|
||||
evicted = True
|
||||
break
|
||||
if evicted:
|
||||
continue
|
||||
self.runs.popitem(last=False)
|
||||
|
||||
def record_run(self, run_id: str, metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Store or replace the tracked metadata for a run."""
|
||||
self.runs[run_id] = dict(metadata)
|
||||
self.runs.move_to_end(run_id)
|
||||
self._evict_runs_if_needed()
|
||||
return self.runs[run_id]
|
||||
|
||||
def update_run(self, run_id: str, **metadata: Any) -> dict[str, Any] | None:
|
||||
"""Update tracked metadata for a run if it exists."""
|
||||
run = self.runs.get(run_id)
|
||||
if run is None:
|
||||
return None
|
||||
run.update(metadata)
|
||||
self.runs.move_to_end(run_id)
|
||||
self._evict_runs_if_needed()
|
||||
return run
|
||||
|
||||
def get_run(self, run_id: str) -> dict[str, Any] | None:
|
||||
"""Return tracked metadata for a run if it exists."""
|
||||
run = self.runs.get(run_id)
|
||||
if run is None:
|
||||
return None
|
||||
return dict(run)
|
||||
|
||||
|
||||
_shared_state: WebConsoleState | None = None
|
||||
|
||||
|
||||
def create_web_console_state() -> WebConsoleState:
|
||||
"""Create an isolated web console state container."""
|
||||
return WebConsoleState()
|
||||
|
||||
|
||||
def get_web_console_state() -> WebConsoleState:
|
||||
"""Return the shared web console state container."""
|
||||
global _shared_state
|
||||
|
||||
if _shared_state is None:
|
||||
_shared_state = create_web_console_state()
|
||||
return _shared_state
|
||||
52
gateway/web_console/static.py
Normal file
52
gateway/web_console/static.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Static placeholder content for the Hermes Web Console."""
|
||||
|
||||
|
||||
def get_web_console_placeholder_html() -> str:
|
||||
"""Return a minimal placeholder page for the GUI app shell."""
|
||||
return """<!doctype html>
|
||||
<html lang=\"en\">
|
||||
<head>
|
||||
<meta charset=\"utf-8\" />
|
||||
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />
|
||||
<title>Hermes Web Console</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: system-ui, sans-serif;
|
||||
background: #0b1020;
|
||||
color: #edf2ff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
main {
|
||||
max-width: 42rem;
|
||||
padding: 2rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 1rem;
|
||||
background: rgba(18, 25, 45, 0.92);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
}
|
||||
p {
|
||||
line-height: 1.5;
|
||||
color: #cbd5f5;
|
||||
}
|
||||
code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 0.35rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Hermes Web Console</h1>
|
||||
<p>This is the initial GUI backend placeholder mounted by the API server.</p>
|
||||
<p>Backend status endpoints are available at <code>/api/gui/health</code> and <code>/api/gui/meta</code>.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
86
run-gui.sh
Executable file
86
run-gui.sh
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# Hermes Web Console GUI Runner
|
||||
# ============================================================================
|
||||
# 1-line run command that boots both the Python backend Gateway
|
||||
# and the React frontend concurrently.
|
||||
#
|
||||
# Usage:
|
||||
# ./run-gui.sh
|
||||
# ============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
CYAN='\033[0;36m'
|
||||
YELLOW='\033[0;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}⚕ Starting Hermes Web Console...${NC}"
|
||||
echo -e "Press ${YELLOW}Ctrl+C${NC} at any time to shut down both servers safely."
|
||||
echo ""
|
||||
|
||||
# 1. Start the Python Backend
|
||||
echo -e "${GREEN}✓${NC} Initializing Hermes Backend Gateway..."
|
||||
if [ -f "$SCRIPT_DIR/venv/bin/activate" ]; then
|
||||
source "$SCRIPT_DIR/venv/bin/activate"
|
||||
else
|
||||
echo -e "${RED}✗ Virtual environment not found. Please run ./setup-gui.sh first.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the python gateway in the background
|
||||
python "$SCRIPT_DIR/gateway/run.py" &
|
||||
BACKEND_PID=$!
|
||||
echo -e " ↳ Backend PID: $BACKEND_PID"
|
||||
|
||||
# 2. Start the React Frontend
|
||||
echo -e "${GREEN}✓${NC} Initializing React Frontend..."
|
||||
cd "$SCRIPT_DIR/web_console"
|
||||
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo -e "${RED}✗ node_modules not found. Please run ./setup-gui.sh first.${NC}"
|
||||
kill $BACKEND_PID 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run vite dev server in the background
|
||||
npm run dev &
|
||||
FRONTEND_PID=$!
|
||||
echo -e " ↳ Frontend PID: $FRONTEND_PID"
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}★ Web Console is up and running! ★${NC}"
|
||||
echo -e "Backend running on port 8642. Frontend running on port 5173 (usually)."
|
||||
echo ""
|
||||
|
||||
# Handle Shutdown Gracefully
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo -e "${YELLOW}⚠ Shutting down Hermes Web Console...${NC}"
|
||||
kill $BACKEND_PID 2>/dev/null || true
|
||||
kill $FRONTEND_PID 2>/dev/null || true
|
||||
|
||||
# Wait for processes to exit
|
||||
wait $BACKEND_PID 2>/dev/null || true
|
||||
wait $FRONTEND_PID 2>/dev/null || true
|
||||
|
||||
# Just in case there are orphaned children processes
|
||||
pkill -P $BACKEND_PID 2>/dev/null || true
|
||||
pkill -P $FRONTEND_PID 2>/dev/null || true
|
||||
|
||||
echo -e "${GREEN}✓ Shutdown complete. Goodbye!${NC}"
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Trap termination signals
|
||||
trap cleanup SIGINT SIGTERM
|
||||
|
||||
# Wait indefinitely for the background tasks to prevent script from exiting
|
||||
wait
|
||||
63
setup-gui.sh
Executable file
63
setup-gui.sh
Executable file
@@ -0,0 +1,63 @@
|
||||
#!/bin/bash
|
||||
# ============================================================================
|
||||
# Hermes Web Console GUI Setup Script
|
||||
# ============================================================================
|
||||
# Quick setup for installing frontend dependencies and ensuring the base
|
||||
# Hermes agent environment is ready.
|
||||
#
|
||||
# Usage:
|
||||
# ./setup-gui.sh
|
||||
# ============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
CYAN='\033[0;36m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}⚕ Hermes Web Console Setup${NC}"
|
||||
echo ""
|
||||
|
||||
# 1. Base Hermes Setup Check
|
||||
if [ ! -d "venv" ]; then
|
||||
echo -e "${YELLOW}⚠${NC} Base Hermes environment (venv) not found."
|
||||
echo -e "${CYAN}→${NC} Running base setup script (setup-hermes.sh)..."
|
||||
bash "$SCRIPT_DIR/setup-hermes.sh"
|
||||
else
|
||||
echo -e "${GREEN}✓${NC} Base Hermes environment (venv) found."
|
||||
fi
|
||||
|
||||
# 2. Node.js Check
|
||||
echo -e "${CYAN}→${NC} Checking for Node.js..."
|
||||
if ! command -v node &> /dev/null; then
|
||||
echo -e "${RED}✗ Node.js is required but not installed.${NC}"
|
||||
echo "Please install Node.js (https://nodejs.org/) and run this script again."
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✓${NC} Node.js found ($(node -v))"
|
||||
|
||||
# 3. npm Check
|
||||
if ! command -v npm &> /dev/null; then
|
||||
echo -e "${RED}✗ npm is required but not installed.${NC}"
|
||||
echo "Please install npm to continue."
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✓${NC} npm found ($(npm -v))"
|
||||
|
||||
# 4. Install Frontend Dependencies
|
||||
echo -e "${CYAN}→${NC} Installing frontend dependencies in ./web_console ..."
|
||||
cd "$SCRIPT_DIR/web_console"
|
||||
npm install
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}✓ Setup complete!${NC}"
|
||||
echo ""
|
||||
echo "To run the Web Console GUI, simply use the following 1-line command:"
|
||||
echo -e "${CYAN} ./run-gui.sh${NC}"
|
||||
echo ""
|
||||
84
tests/gateway/test_api_server_gui_mount.py
Normal file
84
tests/gateway/test_api_server_gui_mount.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""Tests for Hermes Web Console routes mounted by the API server adapter."""
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
from gateway.platforms.api_server import APIServerAdapter, cors_middleware
|
||||
|
||||
|
||||
def _make_adapter() -> APIServerAdapter:
|
||||
return APIServerAdapter(PlatformConfig(enabled=True))
|
||||
|
||||
|
||||
def _create_app(adapter: APIServerAdapter) -> web.Application:
|
||||
app = web.Application(middlewares=[cors_middleware])
|
||||
adapter._register_routes(app)
|
||||
return app
|
||||
|
||||
|
||||
class TestGuiRoutes:
|
||||
@pytest.mark.asyncio
|
||||
async def test_gui_health_route_exists(self):
|
||||
adapter = _make_adapter()
|
||||
app = _create_app(adapter)
|
||||
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.get("/api/gui/health")
|
||||
assert resp.status == 200
|
||||
assert resp.content_type == "application/json"
|
||||
data = await resp.json()
|
||||
assert data == {
|
||||
"status": "ok",
|
||||
"service": "gui-backend",
|
||||
"product": "hermes-web-console",
|
||||
}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gui_meta_route_exists(self):
|
||||
adapter = _make_adapter()
|
||||
app = _create_app(adapter)
|
||||
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.get("/api/gui/meta")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["product"] == "hermes-web-console"
|
||||
assert data["api_base_path"] == "/api/gui"
|
||||
assert data["app_base_path"] == "/app/"
|
||||
assert data["adapter"] == adapter.name
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_app_placeholder_route_exists(self):
|
||||
adapter = _make_adapter()
|
||||
app = _create_app(adapter)
|
||||
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.get("/app/")
|
||||
assert resp.status == 200
|
||||
assert resp.content_type == "text/html"
|
||||
html = await resp.text()
|
||||
assert "Hermes Web Console" in html
|
||||
assert "/api/gui/health" in html
|
||||
assert "/api/gui/meta" in html
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_existing_routes_still_work_with_gui_mount(self):
|
||||
adapter = _make_adapter()
|
||||
app = _create_app(adapter)
|
||||
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
root_resp = await cli.get("/")
|
||||
health_resp = await cli.get("/health")
|
||||
models_resp = await cli.get("/v1/models")
|
||||
|
||||
assert root_resp.status == 200
|
||||
assert health_resp.status == 200
|
||||
assert models_resp.status == 200
|
||||
|
||||
health_data = await health_resp.json()
|
||||
models_data = await models_resp.json()
|
||||
|
||||
assert health_data["status"] == "ok"
|
||||
assert models_data["data"][0]["id"] == "hermes-agent"
|
||||
191
tests/web_console/test_approvals_api.py
Normal file
191
tests/web_console/test_approvals_api.py
Normal file
@@ -0,0 +1,191 @@
|
||||
"""Tests for human approval and clarification APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
import time
|
||||
|
||||
from gateway.web_console.api.approvals import HUMAN_SERVICE_APP_KEY
|
||||
from gateway.web_console.routes import register_web_console_routes
|
||||
from gateway.web_console.services.approval_service import ApprovalService
|
||||
from tools.approval import disable_session_yolo
|
||||
|
||||
|
||||
def _wait_for_pending(service: ApprovalService, kind: str, timeout: float = 1.0):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
pending = service.list_pending()
|
||||
for item in pending:
|
||||
if item["kind"] == kind:
|
||||
return item
|
||||
time.sleep(0.01)
|
||||
return None
|
||||
|
||||
|
||||
class TestApprovalServiceCallbacks:
|
||||
def test_approval_callback_round_trip(self):
|
||||
service = ApprovalService()
|
||||
callback = service.create_approval_callback(session_id="sess-1", run_id="run-1")
|
||||
result: dict[str, str] = {}
|
||||
|
||||
def worker():
|
||||
result["value"] = callback("rm -rf /tmp/test", "Delete temporary files")
|
||||
|
||||
thread = threading.Thread(target=worker)
|
||||
thread.start()
|
||||
pending = _wait_for_pending(service, "approval")
|
||||
assert pending is not None
|
||||
request_id = pending["request_id"]
|
||||
service.resolve_approval(request_id, "session")
|
||||
thread.join(timeout=2)
|
||||
assert result["value"] == "session"
|
||||
|
||||
def test_clarify_callback_round_trip(self):
|
||||
service = ApprovalService()
|
||||
callback = service.create_clarify_callback(session_id="sess-2", run_id="run-2")
|
||||
result: dict[str, str] = {}
|
||||
|
||||
def worker():
|
||||
result["value"] = callback("What kind of GUI?", ["web", "desktop"])
|
||||
|
||||
thread = threading.Thread(target=worker)
|
||||
thread.start()
|
||||
pending = _wait_for_pending(service, "clarify")
|
||||
assert pending is not None
|
||||
request_id = pending["request_id"]
|
||||
service.resolve_clarification(request_id, "web")
|
||||
thread.join(timeout=2)
|
||||
assert result["value"] == "web"
|
||||
|
||||
|
||||
class TestApprovalApiRoutes:
|
||||
@staticmethod
|
||||
async def _make_client(service: ApprovalService) -> TestClient:
|
||||
app = web.Application()
|
||||
app[HUMAN_SERVICE_APP_KEY] = service
|
||||
register_web_console_routes(app)
|
||||
client = TestClient(TestServer(app))
|
||||
await client.start_server()
|
||||
return client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pending_approve_and_clarify_routes(self):
|
||||
service = ApprovalService()
|
||||
approval_callback = service.create_approval_callback(session_id="sess-1", run_id="run-1")
|
||||
clarify_callback = service.create_clarify_callback(session_id="sess-2", run_id="run-2")
|
||||
|
||||
approval_result: dict[str, str] = {}
|
||||
clarify_result: dict[str, str] = {}
|
||||
|
||||
approval_thread = threading.Thread(target=lambda: approval_result.setdefault("value", approval_callback("git push", "Push to origin")))
|
||||
clarify_thread = threading.Thread(target=lambda: clarify_result.setdefault("value", clarify_callback("Choose a GUI", ["web", "desktop"])))
|
||||
approval_thread.start()
|
||||
clarify_thread.start()
|
||||
assert _wait_for_pending(service, "approval") is not None
|
||||
assert _wait_for_pending(service, "clarify") is not None
|
||||
|
||||
client = await self._make_client(service)
|
||||
try:
|
||||
pending_resp = await client.get("/api/gui/human/pending")
|
||||
assert pending_resp.status == 200
|
||||
pending_payload = await pending_resp.json()
|
||||
assert pending_payload["ok"] is True
|
||||
assert len(pending_payload["pending"]) == 2
|
||||
|
||||
approval_request = next(item for item in pending_payload["pending"] if item["kind"] == "approval")
|
||||
clarify_request = next(item for item in pending_payload["pending"] if item["kind"] == "clarify")
|
||||
|
||||
approve_resp = await client.post(
|
||||
"/api/gui/human/approve",
|
||||
json={"request_id": approval_request["request_id"], "decision": "always"},
|
||||
)
|
||||
assert approve_resp.status == 200
|
||||
approve_payload = await approve_resp.json()
|
||||
assert approve_payload["ok"] is True
|
||||
assert approve_payload["request"]["response"] == "always"
|
||||
|
||||
clarify_resp = await client.post(
|
||||
"/api/gui/human/clarify",
|
||||
json={"request_id": clarify_request["request_id"], "response": "web"},
|
||||
)
|
||||
assert clarify_resp.status == 200
|
||||
clarify_payload = await clarify_resp.json()
|
||||
assert clarify_payload["ok"] is True
|
||||
assert clarify_payload["request"]["response"] == "web"
|
||||
finally:
|
||||
approval_thread.join(timeout=2)
|
||||
clarify_thread.join(timeout=2)
|
||||
await client.close()
|
||||
|
||||
assert approval_result["value"] == "always"
|
||||
assert clarify_result["value"] == "web"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deny_and_invalid_requests_are_structured(self):
|
||||
service = ApprovalService()
|
||||
approval_callback = service.create_approval_callback(session_id="sess-3", run_id="run-3")
|
||||
result: dict[str, str] = {}
|
||||
thread = threading.Thread(target=lambda: result.setdefault("value", approval_callback("sudo rm", "Dangerous command")))
|
||||
thread.start()
|
||||
assert _wait_for_pending(service, "approval") is not None
|
||||
|
||||
client = await self._make_client(service)
|
||||
try:
|
||||
pending_payload = await (await client.get("/api/gui/human/pending")).json()
|
||||
approval_request = next(item for item in pending_payload["pending"] if item["kind"] == "approval")
|
||||
|
||||
deny_resp = await client.post("/api/gui/human/deny", json={"request_id": approval_request["request_id"]})
|
||||
assert deny_resp.status == 200
|
||||
deny_payload = await deny_resp.json()
|
||||
assert deny_payload["ok"] is True
|
||||
assert deny_payload["request"]["response"] == "deny"
|
||||
|
||||
invalid_json_resp = await client.post(
|
||||
"/api/gui/human/approve",
|
||||
data="not json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert invalid_json_resp.status == 400
|
||||
invalid_json_payload = await invalid_json_resp.json()
|
||||
assert invalid_json_payload["error"]["code"] == "invalid_json"
|
||||
|
||||
missing_resp = await client.post("/api/gui/human/clarify", json={"request_id": "missing", "response": "x"})
|
||||
assert missing_resp.status == 404
|
||||
missing_payload = await missing_resp.json()
|
||||
assert missing_payload["error"]["code"] == "request_not_found"
|
||||
finally:
|
||||
thread.join(timeout=2)
|
||||
await client.close()
|
||||
|
||||
assert result["value"] == "deny"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_yolo_routes_toggle_session_state(self):
|
||||
service = ApprovalService()
|
||||
client = await self._make_client(service)
|
||||
session_id = "sess-yolo"
|
||||
disable_session_yolo(session_id)
|
||||
|
||||
try:
|
||||
status_resp = await client.get(f"/api/gui/human/yolo?session_id={session_id}")
|
||||
assert status_resp.status == 200
|
||||
status_payload = await status_resp.json()
|
||||
assert status_payload == {"ok": True, "session_id": session_id, "enabled": False}
|
||||
|
||||
enable_resp = await client.post("/api/gui/human/yolo", json={"session_id": session_id, "enabled": True})
|
||||
assert enable_resp.status == 200
|
||||
enable_payload = await enable_resp.json()
|
||||
assert enable_payload == {"ok": True, "session_id": session_id, "enabled": True}
|
||||
|
||||
disable_resp = await client.post("/api/gui/human/yolo", json={"session_id": session_id, "enabled": False})
|
||||
assert disable_resp.status == 200
|
||||
disable_payload = await disable_resp.json()
|
||||
assert disable_payload == {"ok": True, "session_id": session_id, "enabled": False}
|
||||
finally:
|
||||
disable_session_yolo(session_id)
|
||||
await client.close()
|
||||
109
tests/web_console/test_browser_api.py
Normal file
109
tests/web_console/test_browser_api.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Tests for the web console browser API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from gateway.web_console.api.browser import BROWSER_SERVICE_APP_KEY
|
||||
from gateway.web_console.routes import register_web_console_routes
|
||||
|
||||
|
||||
class FakeBrowserService:
|
||||
def __init__(self) -> None:
|
||||
self.connect_calls: list[str | None] = []
|
||||
self.disconnect_calls = 0
|
||||
|
||||
def get_status(self) -> dict:
|
||||
return {
|
||||
"mode": "local",
|
||||
"connected": False,
|
||||
"cdp_url": "",
|
||||
"reachable": None,
|
||||
"requirements_ok": True,
|
||||
"active_sessions": {},
|
||||
}
|
||||
|
||||
def connect(self, cdp_url: str | None = None) -> dict:
|
||||
self.connect_calls.append(cdp_url)
|
||||
if cdp_url == "bad":
|
||||
raise ValueError("bad cdp url")
|
||||
return {
|
||||
"mode": "live_cdp",
|
||||
"connected": True,
|
||||
"cdp_url": cdp_url or "http://localhost:9222",
|
||||
"reachable": True,
|
||||
"requirements_ok": True,
|
||||
"active_sessions": {},
|
||||
"message": "Browser connected to a live Chrome CDP endpoint.",
|
||||
}
|
||||
|
||||
def disconnect(self) -> dict:
|
||||
self.disconnect_calls += 1
|
||||
return {
|
||||
"mode": "local",
|
||||
"connected": False,
|
||||
"cdp_url": "",
|
||||
"reachable": None,
|
||||
"requirements_ok": True,
|
||||
"active_sessions": {},
|
||||
"message": "Browser reverted to the default backend.",
|
||||
}
|
||||
|
||||
|
||||
class TestBrowserApi:
|
||||
@staticmethod
|
||||
async def _make_client(service: FakeBrowserService) -> TestClient:
|
||||
app = web.Application()
|
||||
app[BROWSER_SERVICE_APP_KEY] = service
|
||||
register_web_console_routes(app)
|
||||
client = TestClient(TestServer(app))
|
||||
await client.start_server()
|
||||
return client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_status_connect_and_disconnect_routes(self):
|
||||
service = FakeBrowserService()
|
||||
client = await self._make_client(service)
|
||||
try:
|
||||
status_resp = await client.get("/api/gui/browser/status")
|
||||
assert status_resp.status == 200
|
||||
status_payload = await status_resp.json()
|
||||
assert status_payload["ok"] is True
|
||||
assert status_payload["browser"]["mode"] == "local"
|
||||
|
||||
connect_resp = await client.post("/api/gui/browser/connect", json={"cdp_url": "http://localhost:9222"})
|
||||
assert connect_resp.status == 200
|
||||
connect_payload = await connect_resp.json()
|
||||
assert connect_payload["ok"] is True
|
||||
assert connect_payload["browser"]["connected"] is True
|
||||
assert service.connect_calls == ["http://localhost:9222"]
|
||||
|
||||
disconnect_resp = await client.post("/api/gui/browser/disconnect")
|
||||
assert disconnect_resp.status == 200
|
||||
disconnect_payload = await disconnect_resp.json()
|
||||
assert disconnect_payload["ok"] is True
|
||||
assert disconnect_payload["browser"]["connected"] is False
|
||||
assert service.disconnect_calls == 1
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browser_connect_validation(self):
|
||||
service = FakeBrowserService()
|
||||
client = await self._make_client(service)
|
||||
try:
|
||||
invalid_json_resp = await client.post(
|
||||
"/api/gui/browser/connect",
|
||||
data="not json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert invalid_json_resp.status == 400
|
||||
assert (await invalid_json_resp.json())["error"]["code"] == "invalid_json"
|
||||
|
||||
invalid_cdp_resp = await client.post("/api/gui/browser/connect", json={"cdp_url": "bad"})
|
||||
assert invalid_cdp_resp.status == 400
|
||||
assert (await invalid_cdp_resp.json())["error"]["code"] == "invalid_cdp_url"
|
||||
finally:
|
||||
await client.close()
|
||||
524
tests/web_console/test_chat_api.py
Normal file
524
tests/web_console/test_chat_api.py
Normal file
@@ -0,0 +1,524 @@
|
||||
"""Tests for the web console chat service, chat API routes, and run tracking."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
import gateway.web_console.api.chat as chat_api
|
||||
from gateway.web_console.api.chat import CHAT_SERVICE_APP_KEY
|
||||
from gateway.web_console.routes import register_web_console_routes
|
||||
from gateway.web_console.services.chat_service import ChatService
|
||||
from gateway.web_console.state import MAX_TRACKED_RUNS, create_web_console_state
|
||||
|
||||
|
||||
class TestChatService:
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_runtime_runner_passes_gui_callback_into_agent_path(self, monkeypatch):
|
||||
import gateway.run as gateway_run
|
||||
import run_agent
|
||||
|
||||
state = create_web_console_state()
|
||||
queue = await state.event_bus.subscribe("session-default")
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.gui_event_callback = kwargs.get("gui_event_callback")
|
||||
self.session_id = kwargs.get("session_id")
|
||||
assert self.gui_event_callback is not None
|
||||
|
||||
def run_conversation(self, user_message, conversation_history=None, task_id=None):
|
||||
self.gui_event_callback(
|
||||
"tool.started",
|
||||
{
|
||||
"tool_name": "search_files",
|
||||
"preview": "search_files(pattern=*.py)",
|
||||
"tool_args": {"pattern": "*.py"},
|
||||
},
|
||||
)
|
||||
self.gui_event_callback(
|
||||
"tool.completed",
|
||||
{
|
||||
"tool_name": "search_files",
|
||||
"duration": 0.01,
|
||||
"result_preview": "found 2 files",
|
||||
},
|
||||
)
|
||||
return {
|
||||
"final_response": f"Handled: {user_message}",
|
||||
"completed": True,
|
||||
"messages": [
|
||||
{"role": "assistant", "content": f"Handled: {user_message}"},
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda: "hermes-agent")
|
||||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {})
|
||||
monkeypatch.setattr(run_agent, "AIAgent", FakeAgent)
|
||||
|
||||
service = ChatService(state=state)
|
||||
result = await service.run_chat(prompt="Hello from default runner", session_id="session-default")
|
||||
|
||||
assert result["final_response"] == "Handled: Hello from default runner"
|
||||
events = [await queue.get() for _ in range(6)]
|
||||
assert [event.type for event in events] == [
|
||||
"run.started",
|
||||
"message.user",
|
||||
"tool.started",
|
||||
"tool.completed",
|
||||
"message.assistant.completed",
|
||||
"run.completed",
|
||||
]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_runtime_runner_wires_human_service_callbacks(self, monkeypatch):
|
||||
import importlib
|
||||
import gateway.run as gateway_run
|
||||
import run_agent
|
||||
from gateway.web_console.services.approval_service import ApprovalService
|
||||
|
||||
terminal_tool = importlib.import_module("tools.terminal_tool")
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self, *args, **kwargs):
|
||||
captured["clarify_callback"] = kwargs.get("clarify_callback")
|
||||
captured["gui_event_callback"] = kwargs.get("gui_event_callback")
|
||||
|
||||
def run_conversation(self, user_message, conversation_history=None, task_id=None):
|
||||
return {
|
||||
"final_response": f"Handled: {user_message}",
|
||||
"completed": True,
|
||||
"messages": [{"role": "assistant", "content": f"Handled: {user_message}"}],
|
||||
}
|
||||
|
||||
approvals = ApprovalService()
|
||||
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda: "hermes-agent")
|
||||
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {})
|
||||
monkeypatch.setattr(run_agent, "AIAgent", FakeAgent)
|
||||
|
||||
previous_approval_callback = terminal_tool._approval_callback
|
||||
service = ChatService(state=create_web_console_state(), human_service=approvals)
|
||||
result = await service.run_chat(prompt="Needs humans", session_id="session-human")
|
||||
|
||||
assert result["final_response"] == "Handled: Needs humans"
|
||||
assert captured["clarify_callback"] is not None
|
||||
assert captured["gui_event_callback"] is not None
|
||||
assert terminal_tool._approval_callback is previous_approval_callback
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_chat_publishes_expected_event_sequence(self):
|
||||
state = create_web_console_state()
|
||||
queue = await state.event_bus.subscribe("session-1")
|
||||
|
||||
def runtime_runner(**kwargs):
|
||||
gui_event_callback = kwargs["gui_event_callback"]
|
||||
gui_event_callback(
|
||||
"tool.started",
|
||||
{
|
||||
"tool_name": "search_files",
|
||||
"tool_args": {"pattern": "*.py"},
|
||||
"preview": "search_files(pattern=*.py)",
|
||||
},
|
||||
)
|
||||
gui_event_callback(
|
||||
"tool.completed",
|
||||
{
|
||||
"tool_name": "search_files",
|
||||
"duration": 0.01,
|
||||
"result_preview": "found 3 files",
|
||||
},
|
||||
)
|
||||
return {
|
||||
"final_response": "Done.",
|
||||
"completed": True,
|
||||
"messages": [
|
||||
{"role": "user", "content": kwargs["prompt"]},
|
||||
{"role": "assistant", "content": "Done."},
|
||||
],
|
||||
}
|
||||
|
||||
service = ChatService(state=state, runtime_runner=runtime_runner)
|
||||
|
||||
result = await service.run_chat(prompt="Find Python files", session_id="session-1")
|
||||
|
||||
assert result["final_response"] == "Done."
|
||||
|
||||
events = [await queue.get() for _ in range(6)]
|
||||
assert [event.type for event in events] == [
|
||||
"run.started",
|
||||
"message.user",
|
||||
"tool.started",
|
||||
"tool.completed",
|
||||
"message.assistant.completed",
|
||||
"run.completed",
|
||||
]
|
||||
assert events[0].payload == {"prompt": "Find Python files"}
|
||||
assert events[1].payload == {"content": "Find Python files"}
|
||||
assert events[2].payload["tool_name"] == "search_files"
|
||||
assert events[3].payload["tool_name"] == "search_files"
|
||||
assert events[4].payload == {"content": "Done."}
|
||||
assert events[5].payload["final_response"] == "Done."
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_chat_publishes_failed_event_for_failed_result(self):
|
||||
state = create_web_console_state()
|
||||
queue = await state.event_bus.subscribe("session-failed-result")
|
||||
|
||||
def runtime_runner(**kwargs):
|
||||
return {
|
||||
"failed": True,
|
||||
"error": "toolchain aborted",
|
||||
}
|
||||
|
||||
service = ChatService(state=state, runtime_runner=runtime_runner)
|
||||
result = await service.run_chat(prompt="Run something", session_id="session-failed-result")
|
||||
|
||||
assert result["failed"] is True
|
||||
events = [await queue.get() for _ in range(3)]
|
||||
assert [event.type for event in events] == [
|
||||
"run.started",
|
||||
"message.user",
|
||||
"run.failed",
|
||||
]
|
||||
assert events[-1].payload["error"] == "toolchain aborted"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_chat_publishes_failed_event_on_exception(self):
|
||||
state = create_web_console_state()
|
||||
queue = await state.event_bus.subscribe("session-2")
|
||||
|
||||
def runtime_runner(**kwargs):
|
||||
gui_event_callback = kwargs["gui_event_callback"]
|
||||
gui_event_callback(
|
||||
"tool.started",
|
||||
{
|
||||
"tool_name": "terminal",
|
||||
"preview": "terminal(command=false)",
|
||||
},
|
||||
)
|
||||
raise RuntimeError("boom")
|
||||
|
||||
service = ChatService(state=state, runtime_runner=runtime_runner)
|
||||
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
await service.run_chat(prompt="Run something", session_id="session-2")
|
||||
|
||||
events = [await queue.get() for _ in range(4)]
|
||||
assert [event.type for event in events] == [
|
||||
"run.started",
|
||||
"message.user",
|
||||
"tool.started",
|
||||
"run.failed",
|
||||
]
|
||||
assert events[-1].payload["error"] == "boom"
|
||||
assert events[-1].payload["error_type"] == "RuntimeError"
|
||||
|
||||
|
||||
class TestChatApiRoutes:
|
||||
@staticmethod
|
||||
async def _make_client(service: ChatService) -> TestClient:
|
||||
app = web.Application()
|
||||
app[CHAT_SERVICE_APP_KEY] = service
|
||||
register_web_console_routes(app)
|
||||
client = TestClient(TestServer(app))
|
||||
await client.start_server()
|
||||
return client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_and_get_run_return_tracked_metadata(self):
|
||||
state = create_web_console_state()
|
||||
|
||||
async def runtime_runner(**kwargs):
|
||||
await asyncio.sleep(0.01)
|
||||
return {
|
||||
"final_response": f"Echo: {kwargs['prompt']}",
|
||||
"completed": True,
|
||||
"messages": [{"role": "assistant", "content": f"Echo: {kwargs['prompt']}"}],
|
||||
}
|
||||
|
||||
service = ChatService(state=state, runtime_runner=runtime_runner)
|
||||
client = await self._make_client(service)
|
||||
|
||||
try:
|
||||
send_resp = await client.post(
|
||||
"/api/gui/chat/send",
|
||||
json={"session_id": "session-api", "prompt": "Hello API"},
|
||||
)
|
||||
assert send_resp.status == 200
|
||||
send_payload = await send_resp.json()
|
||||
assert send_payload["ok"] is True
|
||||
assert send_payload["session_id"] == "session-api"
|
||||
assert send_payload["status"] == "started"
|
||||
run_id = send_payload["run_id"]
|
||||
|
||||
run_resp = await client.get(f"/api/gui/chat/run/{run_id}")
|
||||
assert run_resp.status == 200
|
||||
run_payload = await run_resp.json()
|
||||
assert run_payload["ok"] is True
|
||||
assert run_payload["run"]["run_id"] == run_id
|
||||
assert run_payload["run"]["session_id"] == "session-api"
|
||||
assert run_payload["run"]["status"] in {"started", "completed"}
|
||||
|
||||
await asyncio.sleep(0.03)
|
||||
|
||||
completed_resp = await client.get(f"/api/gui/chat/run/{run_id}")
|
||||
completed_payload = await completed_resp.json()
|
||||
assert completed_payload["run"]["status"] == "completed"
|
||||
assert completed_payload["run"]["final_response"] == "Echo: Hello API"
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_creates_new_run_from_existing_metadata(self):
|
||||
state = create_web_console_state()
|
||||
seen_prompts: list[str] = []
|
||||
|
||||
async def runtime_runner(**kwargs):
|
||||
seen_prompts.append(kwargs["prompt"])
|
||||
return {
|
||||
"final_response": f"Handled: {kwargs['prompt']}",
|
||||
"completed": True,
|
||||
"messages": [{"role": "assistant", "content": f"Handled: {kwargs['prompt']}"}],
|
||||
}
|
||||
|
||||
service = ChatService(state=state, runtime_runner=runtime_runner)
|
||||
client = await self._make_client(service)
|
||||
|
||||
try:
|
||||
send_resp = await client.post(
|
||||
"/api/gui/chat/send",
|
||||
json={"session_id": "session-retry", "prompt": "Retry me"},
|
||||
)
|
||||
first_payload = await send_resp.json()
|
||||
first_run_id = first_payload["run_id"]
|
||||
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
retry_resp = await client.post("/api/gui/chat/retry", json={"run_id": first_run_id})
|
||||
assert retry_resp.status == 200
|
||||
retry_payload = await retry_resp.json()
|
||||
assert retry_payload["ok"] is True
|
||||
assert retry_payload["session_id"] == "session-retry"
|
||||
assert retry_payload["retried_from_run_id"] == first_run_id
|
||||
assert retry_payload["run_id"] != first_run_id
|
||||
assert retry_payload["status"] == "started"
|
||||
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
retried_run_resp = await client.get(f"/api/gui/chat/run/{retry_payload['run_id']}")
|
||||
retried_run_payload = await retried_run_resp.json()
|
||||
assert retried_run_payload["run"]["source_run_id"] == first_run_id
|
||||
assert retried_run_payload["run"]["prompt"] == "Retry me"
|
||||
assert seen_prompts == ["Retry me", "Retry me"]
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_session_id_and_undo_fields_return_validation_errors(self):
|
||||
state = create_web_console_state()
|
||||
service = ChatService(
|
||||
state=state,
|
||||
runtime_runner=lambda **kwargs: {
|
||||
"final_response": "ok",
|
||||
"completed": True,
|
||||
"messages": [{"role": "assistant", "content": "ok"}],
|
||||
},
|
||||
)
|
||||
client = await self._make_client(service)
|
||||
|
||||
try:
|
||||
send_resp = await client.post("/api/gui/chat/send", json={"prompt": "hello", "session_id": 123})
|
||||
assert send_resp.status == 400
|
||||
send_payload = await send_resp.json()
|
||||
assert send_payload == {
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "invalid_session_id",
|
||||
"message": "The 'session_id' field must be a non-empty string when provided.",
|
||||
},
|
||||
}
|
||||
|
||||
undo_resp = await client.post("/api/gui/chat/undo", json={"session_id": 123, "run_id": []})
|
||||
assert undo_resp.status == 400
|
||||
undo_payload = await undo_resp.json()
|
||||
assert undo_payload == {
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "invalid_session_id",
|
||||
"message": "The 'session_id' field must be a string when provided.",
|
||||
},
|
||||
}
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_json_requests_return_structured_errors(self):
|
||||
state = create_web_console_state()
|
||||
service = ChatService(
|
||||
state=state,
|
||||
runtime_runner=lambda **kwargs: {
|
||||
"final_response": "ok",
|
||||
"completed": True,
|
||||
"messages": [{"role": "assistant", "content": "ok"}],
|
||||
},
|
||||
)
|
||||
client = await self._make_client(service)
|
||||
|
||||
try:
|
||||
for path in (
|
||||
"/api/gui/chat/send",
|
||||
"/api/gui/chat/stop",
|
||||
"/api/gui/chat/retry",
|
||||
"/api/gui/chat/undo",
|
||||
):
|
||||
resp = await client.post(path, data="not json", headers={"Content-Type": "application/json"})
|
||||
assert resp.status == 400
|
||||
payload = await resp.json()
|
||||
assert payload == {
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "invalid_json",
|
||||
"message": "Request body must be a valid JSON object.",
|
||||
},
|
||||
}
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_tracking_is_bounded(self):
|
||||
state = create_web_console_state()
|
||||
for idx in range(MAX_TRACKED_RUNS):
|
||||
state.record_run(
|
||||
f"run-{idx}",
|
||||
{"run_id": f"run-{idx}", "prompt": f"prompt-{idx}", "status": "completed"},
|
||||
)
|
||||
|
||||
state.record_run(
|
||||
"active-run",
|
||||
{"run_id": "active-run", "prompt": "active", "status": "started"},
|
||||
)
|
||||
|
||||
assert len(state.runs) == MAX_TRACKED_RUNS
|
||||
assert state.get_run("run-0") is None
|
||||
assert state.get_run("active-run")["prompt"] == "active"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_run_tracking_remains_bounded_even_when_all_runs_are_active(self):
|
||||
state = create_web_console_state()
|
||||
for idx in range(MAX_TRACKED_RUNS + 3):
|
||||
state.record_run(
|
||||
f"active-{idx}",
|
||||
{"run_id": f"active-{idx}", "prompt": f"prompt-{idx}", "status": "started"},
|
||||
)
|
||||
|
||||
assert len(state.runs) == MAX_TRACKED_RUNS
|
||||
assert state.get_run("active-0") is None
|
||||
assert state.get_run(f"active-{MAX_TRACKED_RUNS + 2}")["prompt"] == f"prompt-{MAX_TRACKED_RUNS + 2}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_app_without_injected_chat_service_creates_isolated_local_state(self, monkeypatch):
|
||||
class FakeChatService(ChatService):
|
||||
async def run_chat(self, *, prompt, session_id, conversation_history=None, ephemeral_system_prompt=None, run_id=None, runtime_context=None):
|
||||
return {
|
||||
"final_response": f"Echo: {prompt}",
|
||||
"completed": True,
|
||||
"messages": [{"role": "assistant", "content": f"Echo: {prompt}"}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(chat_api, "ChatService", FakeChatService)
|
||||
|
||||
app_one = web.Application()
|
||||
register_web_console_routes(app_one)
|
||||
app_two = web.Application()
|
||||
register_web_console_routes(app_two)
|
||||
|
||||
client_one = TestClient(TestServer(app_one))
|
||||
client_two = TestClient(TestServer(app_two))
|
||||
await client_one.start_server()
|
||||
await client_two.start_server()
|
||||
|
||||
try:
|
||||
send_resp = await client_one.post(
|
||||
"/api/gui/chat/send",
|
||||
json={"session_id": "isolated-session", "prompt": "hello from app one"},
|
||||
)
|
||||
assert send_resp.status == 200
|
||||
send_payload = await send_resp.json()
|
||||
run_id = send_payload["run_id"]
|
||||
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
run_resp = await client_one.get(f"/api/gui/chat/run/{run_id}")
|
||||
assert run_resp.status == 200
|
||||
|
||||
missing_resp = await client_two.get(f"/api/gui/chat/run/{run_id}")
|
||||
assert missing_resp.status == 404
|
||||
finally:
|
||||
await client_one.close()
|
||||
await client_two.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_undo_and_not_found_run_responses_are_structured(self):
|
||||
state = create_web_console_state()
|
||||
service = ChatService(
|
||||
state=state,
|
||||
runtime_runner=lambda **kwargs: {
|
||||
"final_response": "ok",
|
||||
"completed": True,
|
||||
"messages": [{"role": "assistant", "content": "ok"}],
|
||||
},
|
||||
)
|
||||
client = await self._make_client(service)
|
||||
|
||||
try:
|
||||
send_resp = await client.post(
|
||||
"/api/gui/chat/send",
|
||||
json={"session_id": "session-stop", "prompt": "Stop test"},
|
||||
)
|
||||
send_payload = await send_resp.json()
|
||||
run_id = send_payload["run_id"]
|
||||
|
||||
stop_resp = await client.post("/api/gui/chat/stop", json={"run_id": run_id})
|
||||
assert stop_resp.status == 200
|
||||
stop_payload = await stop_resp.json()
|
||||
assert stop_payload["ok"] is True
|
||||
assert stop_payload["supported"] is False
|
||||
assert stop_payload["action"] == "stop"
|
||||
assert stop_payload["run_id"] == run_id
|
||||
assert stop_payload["session_id"] == "session-stop"
|
||||
assert stop_payload["status"] in {"started", "completed"}
|
||||
assert stop_payload["stop_requested"] is False
|
||||
|
||||
undo_resp = await client.post(
|
||||
"/api/gui/chat/undo",
|
||||
json={"session_id": "session-stop", "run_id": run_id},
|
||||
)
|
||||
assert undo_resp.status == 200
|
||||
undo_payload = await undo_resp.json()
|
||||
assert undo_payload == {
|
||||
"ok": True,
|
||||
"supported": False,
|
||||
"action": "undo",
|
||||
"session_id": "session-stop",
|
||||
"run_id": run_id,
|
||||
"status": "unavailable",
|
||||
}
|
||||
|
||||
missing_resp = await client.get("/api/gui/chat/run/does-not-exist")
|
||||
assert missing_resp.status == 404
|
||||
missing_payload = await missing_resp.json()
|
||||
assert missing_payload == {
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "run_not_found",
|
||||
"message": "No tracked run was found for the provided run_id.",
|
||||
"run_id": "does-not-exist",
|
||||
},
|
||||
}
|
||||
finally:
|
||||
await client.close()
|
||||
47
tests/web_console/test_commands_api.py
Normal file
47
tests/web_console/test_commands_api.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Tests for the web console commands API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from gateway.web_console.routes import register_web_console_routes
|
||||
|
||||
|
||||
class TestCommandsApi:
|
||||
@staticmethod
|
||||
async def _make_client() -> TestClient:
|
||||
app = web.Application()
|
||||
register_web_console_routes(app)
|
||||
client = TestClient(TestServer(app))
|
||||
await client.start_server()
|
||||
return client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_commands_registry_route_exposes_cli_registry(self):
|
||||
client = await self._make_client()
|
||||
try:
|
||||
resp = await client.get("/api/gui/commands")
|
||||
assert resp.status == 200
|
||||
payload = await resp.json()
|
||||
|
||||
assert payload["ok"] is True
|
||||
assert isinstance(payload["commands"], list)
|
||||
assert payload["commands"]
|
||||
|
||||
by_name = {entry["name"]: entry for entry in payload["commands"]}
|
||||
assert "new" in by_name
|
||||
assert "model" in by_name
|
||||
assert "commands" in by_name
|
||||
|
||||
new_entry = by_name["new"]
|
||||
assert "reset" in new_entry["aliases"]
|
||||
assert "reset" in new_entry["names"]
|
||||
assert new_entry["cli_only"] is False
|
||||
|
||||
commands_entry = by_name["commands"]
|
||||
assert commands_entry["gateway_only"] is True
|
||||
assert commands_entry["args_hint"] == "[page]"
|
||||
finally:
|
||||
await client.close()
|
||||
316
tests/web_console/test_cron_api.py
Normal file
316
tests/web_console/test_cron_api.py
Normal file
@@ -0,0 +1,316 @@
|
||||
"""Tests for the web console cron API and cron service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
import cron.jobs as cron_jobs
|
||||
from gateway.web_console.api.cron import CRON_SERVICE_APP_KEY
|
||||
from gateway.web_console.routes import register_web_console_routes
|
||||
from gateway.web_console.services.cron_service import CronService
|
||||
|
||||
|
||||
class FakeCronService:
|
||||
def __init__(self) -> None:
|
||||
self.jobs = {
|
||||
"job-1": {
|
||||
"job_id": "job-1",
|
||||
"id": "job-1",
|
||||
"name": "Morning report",
|
||||
"prompt": "Summarize overnight alerts",
|
||||
"skills": ["blogwatcher"],
|
||||
"skill": "blogwatcher",
|
||||
"schedule": {"kind": "interval", "minutes": 60, "display": "every 60m"},
|
||||
"schedule_display": "every 60m",
|
||||
"repeat": {"times": None, "completed": 2},
|
||||
"deliver": "local",
|
||||
"enabled": True,
|
||||
"state": "scheduled",
|
||||
"next_run_at": "2026-03-30T10:00:00+00:00",
|
||||
"last_run_at": "2026-03-30T09:00:00+00:00",
|
||||
"last_status": "ok",
|
||||
"last_error": None,
|
||||
"paused_at": None,
|
||||
"paused_reason": None,
|
||||
}
|
||||
}
|
||||
self.history = {
|
||||
"job-1": {
|
||||
"job_id": "job-1",
|
||||
"count": 1,
|
||||
"latest_run_at": "2026-03-30T09:00:00+00:00",
|
||||
"latest_status": "ok",
|
||||
"latest_error": None,
|
||||
"history": [
|
||||
{
|
||||
"run_id": "2026-03-30_09-00-00",
|
||||
"job_id": "job-1",
|
||||
"filename": "2026-03-30_09-00-00.md",
|
||||
"output_file": "/tmp/job-1/2026-03-30_09-00-00.md",
|
||||
"created_at": "2026-03-30T09:00:00+00:00",
|
||||
"size_bytes": 12,
|
||||
"output_preview": "all good",
|
||||
"output_truncated": False,
|
||||
"output_available": True,
|
||||
"source": "output_file",
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
def list_jobs(self, *, include_disabled: bool = True):
|
||||
jobs = list(self.jobs.values())
|
||||
if not include_disabled:
|
||||
jobs = [job for job in jobs if job.get("enabled", True)]
|
||||
return {"jobs": jobs, "count": len(jobs), "include_disabled": include_disabled}
|
||||
|
||||
def get_job(self, job_id: str):
|
||||
return self.jobs.get(job_id)
|
||||
|
||||
def create_job(self, payload):
|
||||
if not payload.get("schedule"):
|
||||
raise ValueError("The 'schedule' field must be a non-empty string.")
|
||||
if payload.get("schedule") == "bad":
|
||||
raise ValueError("Invalid schedule")
|
||||
job = {
|
||||
"job_id": "job-2",
|
||||
"id": "job-2",
|
||||
"name": payload.get("name") or "Created job",
|
||||
"prompt": payload.get("prompt", ""),
|
||||
"skills": payload.get("skills") or [],
|
||||
"skill": (payload.get("skills") or [None])[0],
|
||||
"schedule": {"kind": "interval", "minutes": 30, "display": payload["schedule"]},
|
||||
"schedule_display": payload["schedule"],
|
||||
"repeat": {"times": payload.get("repeat"), "completed": 0},
|
||||
"deliver": payload.get("deliver") or "local",
|
||||
"enabled": True,
|
||||
"state": "scheduled",
|
||||
"next_run_at": "2026-03-30T11:00:00+00:00",
|
||||
"last_run_at": None,
|
||||
"last_status": None,
|
||||
"last_error": None,
|
||||
"paused_at": None,
|
||||
"paused_reason": None,
|
||||
}
|
||||
self.jobs[job["job_id"]] = job
|
||||
self.history[job["job_id"]] = {
|
||||
"job_id": job["job_id"],
|
||||
"count": 0,
|
||||
"latest_run_at": None,
|
||||
"latest_status": None,
|
||||
"latest_error": None,
|
||||
"history": [],
|
||||
}
|
||||
return job
|
||||
|
||||
def update_job(self, job_id: str, payload):
|
||||
job = self.jobs.get(job_id)
|
||||
if job is None:
|
||||
return None
|
||||
if payload.get("schedule") == "bad":
|
||||
raise ValueError("Invalid schedule")
|
||||
if "name" in payload:
|
||||
job["name"] = payload["name"]
|
||||
if "prompt" in payload:
|
||||
job["prompt"] = payload["prompt"]
|
||||
if "schedule" in payload:
|
||||
job["schedule_display"] = payload["schedule"]
|
||||
return job
|
||||
|
||||
def run_job(self, job_id: str):
|
||||
job = self.jobs.get(job_id)
|
||||
if job is None:
|
||||
return None
|
||||
job["next_run_at"] = "2026-03-30T09:30:00+00:00"
|
||||
return job
|
||||
|
||||
def pause_job(self, job_id: str, *, reason: str | None = None):
|
||||
job = self.jobs.get(job_id)
|
||||
if job is None:
|
||||
return None
|
||||
job["enabled"] = False
|
||||
job["state"] = "paused"
|
||||
job["paused_reason"] = reason
|
||||
return job
|
||||
|
||||
def resume_job(self, job_id: str):
|
||||
job = self.jobs.get(job_id)
|
||||
if job is None:
|
||||
return None
|
||||
job["enabled"] = True
|
||||
job["state"] = "scheduled"
|
||||
job["paused_reason"] = None
|
||||
return job
|
||||
|
||||
def delete_job(self, job_id: str):
|
||||
return self.jobs.pop(job_id, None) is not None
|
||||
|
||||
def get_job_history(self, job_id: str, *, limit: int = 20):
|
||||
payload = self.history.get(job_id)
|
||||
if payload is None:
|
||||
return None
|
||||
copied = dict(payload)
|
||||
copied["history"] = copied["history"][:limit]
|
||||
copied["count"] = len(copied["history"])
|
||||
return copied
|
||||
|
||||
|
||||
class TestCronApi:
|
||||
@staticmethod
|
||||
async def _make_client(service: FakeCronService) -> TestClient:
|
||||
app = web.Application()
|
||||
app[CRON_SERVICE_APP_KEY] = service
|
||||
register_web_console_routes(app)
|
||||
client = TestClient(TestServer(app))
|
||||
await client.start_server()
|
||||
return client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cron_job_crud_and_history_routes(self):
|
||||
client = await self._make_client(FakeCronService())
|
||||
try:
|
||||
list_resp = await client.get("/api/gui/cron/jobs")
|
||||
assert list_resp.status == 200
|
||||
list_payload = await list_resp.json()
|
||||
assert list_payload["ok"] is True
|
||||
assert list_payload["count"] == 1
|
||||
assert list_payload["jobs"][0]["job_id"] == "job-1"
|
||||
|
||||
create_resp = await client.post(
|
||||
"/api/gui/cron/jobs",
|
||||
json={
|
||||
"name": "Lunch report",
|
||||
"prompt": "Summarize the morning",
|
||||
"schedule": "every 30m",
|
||||
"deliver": "local",
|
||||
"skills": ["blogwatcher"],
|
||||
},
|
||||
)
|
||||
assert create_resp.status == 200
|
||||
create_payload = await create_resp.json()
|
||||
assert create_payload["ok"] is True
|
||||
assert create_payload["job"]["job_id"] == "job-2"
|
||||
assert create_payload["job"]["name"] == "Lunch report"
|
||||
|
||||
detail_resp = await client.get("/api/gui/cron/jobs/job-1")
|
||||
assert detail_resp.status == 200
|
||||
detail_payload = await detail_resp.json()
|
||||
assert detail_payload["job"]["schedule_display"] == "every 60m"
|
||||
assert detail_payload["job"]["last_status"] == "ok"
|
||||
|
||||
patch_resp = await client.patch(
|
||||
"/api/gui/cron/jobs/job-1",
|
||||
json={"name": "Updated report", "prompt": "Use new instructions", "schedule": "every 2h"},
|
||||
)
|
||||
assert patch_resp.status == 200
|
||||
patch_payload = await patch_resp.json()
|
||||
assert patch_payload["job"]["name"] == "Updated report"
|
||||
assert patch_payload["job"]["prompt"] == "Use new instructions"
|
||||
assert patch_payload["job"]["schedule_display"] == "every 2h"
|
||||
|
||||
run_resp = await client.post("/api/gui/cron/jobs/job-1/run")
|
||||
assert run_resp.status == 200
|
||||
run_payload = await run_resp.json()
|
||||
assert run_payload["queued"] is True
|
||||
assert run_payload["job"]["next_run_at"] == "2026-03-30T09:30:00+00:00"
|
||||
|
||||
pause_resp = await client.post("/api/gui/cron/jobs/job-1/pause", json={"reason": "maintenance"})
|
||||
assert pause_resp.status == 200
|
||||
pause_payload = await pause_resp.json()
|
||||
assert pause_payload["job"]["state"] == "paused"
|
||||
assert pause_payload["job"]["paused_reason"] == "maintenance"
|
||||
|
||||
resume_resp = await client.post("/api/gui/cron/jobs/job-1/resume")
|
||||
assert resume_resp.status == 200
|
||||
resume_payload = await resume_resp.json()
|
||||
assert resume_payload["job"]["state"] == "scheduled"
|
||||
assert resume_payload["job"]["enabled"] is True
|
||||
|
||||
history_resp = await client.get("/api/gui/cron/jobs/job-1/history?limit=1")
|
||||
assert history_resp.status == 200
|
||||
history_payload = await history_resp.json()
|
||||
assert history_payload["ok"] is True
|
||||
assert history_payload["job_id"] == "job-1"
|
||||
assert history_payload["count"] == 1
|
||||
assert history_payload["history"][0]["output_preview"] == "all good"
|
||||
|
||||
delete_resp = await client.delete("/api/gui/cron/jobs/job-2")
|
||||
assert delete_resp.status == 200
|
||||
delete_payload = await delete_resp.json()
|
||||
assert delete_payload == {"ok": True, "job_id": "job-2", "deleted": True}
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cron_api_structured_errors(self):
|
||||
client = await self._make_client(FakeCronService())
|
||||
try:
|
||||
invalid_json_resp = await client.post(
|
||||
"/api/gui/cron/jobs",
|
||||
data="not json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert invalid_json_resp.status == 400
|
||||
invalid_json_payload = await invalid_json_resp.json()
|
||||
assert invalid_json_payload["error"]["code"] == "invalid_json"
|
||||
|
||||
invalid_job_resp = await client.post("/api/gui/cron/jobs", json={"prompt": "Hello"})
|
||||
assert invalid_job_resp.status == 400
|
||||
invalid_job_payload = await invalid_job_resp.json()
|
||||
assert invalid_job_payload["error"]["code"] == "invalid_job"
|
||||
|
||||
bad_update_resp = await client.patch("/api/gui/cron/jobs/job-1", json={"schedule": "bad"})
|
||||
assert bad_update_resp.status == 400
|
||||
bad_update_payload = await bad_update_resp.json()
|
||||
assert bad_update_payload["error"]["code"] == "invalid_job"
|
||||
|
||||
missing_resp = await client.get("/api/gui/cron/jobs/missing")
|
||||
assert missing_resp.status == 404
|
||||
missing_payload = await missing_resp.json()
|
||||
assert missing_payload["error"]["code"] == "job_not_found"
|
||||
|
||||
missing_delete_resp = await client.delete("/api/gui/cron/jobs/missing")
|
||||
assert missing_delete_resp.status == 404
|
||||
missing_delete_payload = await missing_delete_resp.json()
|
||||
assert missing_delete_payload["error"]["code"] == "job_not_found"
|
||||
|
||||
invalid_limit_resp = await client.get("/api/gui/cron/jobs/job-1/history?limit=abc")
|
||||
assert invalid_limit_resp.status == 400
|
||||
invalid_limit_payload = await invalid_limit_resp.json()
|
||||
assert invalid_limit_payload["error"]["code"] == "invalid_pagination"
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
class TestCronService:
|
||||
def test_real_cron_service_uses_cron_storage_and_output_history(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(cron_jobs, "CRON_DIR", tmp_path / "cron")
|
||||
monkeypatch.setattr(cron_jobs, "JOBS_FILE", tmp_path / "cron" / "jobs.json")
|
||||
monkeypatch.setattr(cron_jobs, "OUTPUT_DIR", tmp_path / "cron" / "output")
|
||||
|
||||
service = CronService()
|
||||
created = service.create_job({"prompt": "Summarize logs", "schedule": "every 1h", "name": "Ops summary"})
|
||||
|
||||
cron_jobs.save_job_output(created["job_id"], "First output line\nSecond output line")
|
||||
cron_jobs.mark_job_run(created["job_id"], success=True)
|
||||
cron_jobs.save_job_output(created["job_id"], "Newest output")
|
||||
|
||||
jobs_payload = service.list_jobs()
|
||||
assert jobs_payload["count"] == 1
|
||||
assert jobs_payload["jobs"][0]["name"] == "Ops summary"
|
||||
|
||||
job_detail = service.get_job(created["job_id"])
|
||||
assert job_detail is not None
|
||||
assert job_detail["schedule_display"] == "every 60m"
|
||||
assert job_detail["last_status"] == "ok"
|
||||
|
||||
history = service.get_job_history(created["job_id"], limit=10)
|
||||
assert history is not None
|
||||
assert history["job_id"] == created["job_id"]
|
||||
assert history["count"] == 1 or history["count"] == 2
|
||||
assert history["history"][0]["output_available"] is True
|
||||
assert history["history"][0]["filename"].endswith(".md")
|
||||
assert history["history"][0]["source"] == "output_file"
|
||||
assert history["history"][0]["output_preview"]
|
||||
139
tests/web_console/test_event_bus.py
Normal file
139
tests/web_console/test_event_bus.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Tests for the Hermes Web Console event bus and SSE helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from gateway.web_console.event_bus import GuiEvent, GuiEventBus
|
||||
from gateway.web_console.sse import SseMessage, format_sse_message, format_sse_ping, stream_sse
|
||||
|
||||
|
||||
class TestGuiEventBus:
|
||||
@pytest.mark.asyncio
|
||||
async def test_subscribe_publish_preserves_order(self):
|
||||
bus = GuiEventBus()
|
||||
queue = await bus.subscribe("session-1")
|
||||
|
||||
first = GuiEvent(type="run.started", session_id="session-1", run_id="run-1", payload={"step": 1})
|
||||
second = GuiEvent(type="run.output", session_id="session-1", run_id="run-1", payload={"step": 2})
|
||||
third = GuiEvent(type="run.finished", session_id="session-1", run_id="run-1", payload={"step": 3})
|
||||
|
||||
await bus.publish("session-1", first)
|
||||
await bus.publish("session-1", second)
|
||||
await bus.publish("session-1", third)
|
||||
|
||||
assert await queue.get() is first
|
||||
assert await queue.get() is second
|
||||
assert await queue.get() is third
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsubscribe_disconnect_stops_delivery(self):
|
||||
bus = GuiEventBus()
|
||||
queue = await bus.subscribe("session-1")
|
||||
|
||||
await bus.unsubscribe("session-1", queue)
|
||||
await bus.publish(
|
||||
"session-1",
|
||||
GuiEvent(type="run.output", session_id="session-1", run_id="run-1", payload={"text": "hello"}),
|
||||
)
|
||||
|
||||
assert await bus.subscriber_count("session-1") == 0
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(queue.get(), timeout=0.05)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_publish_calls_preserve_order(self):
|
||||
bus = GuiEventBus()
|
||||
queue = await bus.subscribe("session-1")
|
||||
|
||||
events = [
|
||||
GuiEvent(type="run.output", session_id="session-1", run_id="run-1", payload={"index": idx})
|
||||
for idx in range(10)
|
||||
]
|
||||
|
||||
await asyncio.gather(*(bus.publish("session-1", event) for event in events))
|
||||
|
||||
received = [await queue.get() for _ in events]
|
||||
assert [event.payload["index"] for event in received] == list(range(10))
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channels_are_separate(self):
|
||||
bus = GuiEventBus()
|
||||
queue_a = await bus.subscribe("session-a")
|
||||
queue_b = await bus.subscribe("session-b")
|
||||
|
||||
event_a = GuiEvent(type="run.output", session_id="session-a", run_id="run-a", payload={"msg": "a"})
|
||||
event_b = GuiEvent(type="run.output", session_id="session-b", run_id="run-b", payload={"msg": "b"})
|
||||
|
||||
await bus.publish("session-a", event_a)
|
||||
await bus.publish("session-b", event_b)
|
||||
|
||||
assert await queue_a.get() is event_a
|
||||
assert await queue_b.get() is event_b
|
||||
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(queue_a.get(), timeout=0.05)
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(queue_b.get(), timeout=0.05)
|
||||
|
||||
|
||||
class TestSseHelpers:
|
||||
def test_format_sse_message_includes_event_and_json_payload(self):
|
||||
payload = {"session_id": "session-1", "value": 7}
|
||||
|
||||
encoded = format_sse_message(
|
||||
SseMessage(data=payload, event="gui.event", id="evt-1", retry=5000)
|
||||
)
|
||||
|
||||
assert encoded == (
|
||||
b"event: gui.event\n"
|
||||
b"id: evt-1\n"
|
||||
b"retry: 5000\n"
|
||||
b'data: {"session_id":"session-1","value":7}\n\n'
|
||||
)
|
||||
|
||||
def test_format_sse_ping_emits_comment_frame(self):
|
||||
assert format_sse_ping() == b": ping\n\n"
|
||||
assert format_sse_ping("keepalive") == b": keepalive\n\n"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_sse_writes_event_frames_and_headers(self):
|
||||
async def event_source():
|
||||
yield SseMessage(data={"hello": "world"}, event="gui.event", id="evt-1")
|
||||
|
||||
async def handler(request: web.Request) -> web.StreamResponse:
|
||||
return await stream_sse(request, event_source(), keepalive_interval=0.1)
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get("/stream", handler)
|
||||
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.get("/stream")
|
||||
assert resp.status == 200
|
||||
assert resp.headers["Content-Type"].startswith("text/event-stream")
|
||||
text = await resp.text()
|
||||
assert "event: gui.event" in text
|
||||
assert "id: evt-1" in text
|
||||
assert 'data: {"hello":"world"}' in text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_sse_emits_keepalive_ping_before_next_event(self):
|
||||
async def event_source():
|
||||
await asyncio.sleep(0.02)
|
||||
yield SseMessage(data={"step": 1}, event="gui.event")
|
||||
|
||||
async def handler(request: web.Request) -> web.StreamResponse:
|
||||
return await stream_sse(request, event_source(), keepalive_interval=0.005, ping_comment="keepalive")
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_get("/stream", handler)
|
||||
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.get("/stream")
|
||||
text = await resp.text()
|
||||
assert ": keepalive" in text
|
||||
assert 'data: {"step":1}' in text
|
||||
224
tests/web_console/test_gateway_admin_api.py
Normal file
224
tests/web_console/test_gateway_admin_api.py
Normal file
@@ -0,0 +1,224 @@
|
||||
"""Tests for gateway admin web-console API routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from gateway.web_console.api.gateway_admin import GATEWAY_SERVICE_APP_KEY
|
||||
from gateway.web_console.routes import register_web_console_routes
|
||||
|
||||
|
||||
class FakeGatewayService:
|
||||
def __init__(self) -> None:
|
||||
self.approve_calls: list[tuple[str, str]] = []
|
||||
self.revoke_calls: list[tuple[str, str]] = []
|
||||
|
||||
def get_overview(self) -> dict:
|
||||
return {
|
||||
"gateway": {"running": True, "pid": 4242, "state": "running", "exit_reason": None, "updated_at": "2026-03-30T21:48:00Z"},
|
||||
"summary": {
|
||||
"platform_count": 2,
|
||||
"enabled_platforms": 1,
|
||||
"configured_platforms": 1,
|
||||
"connected_platforms": 1,
|
||||
"pending_pairings": 1,
|
||||
"approved_pairings": 2,
|
||||
},
|
||||
}
|
||||
|
||||
def get_platforms(self) -> list[dict]:
|
||||
return [
|
||||
{
|
||||
"key": "discord",
|
||||
"label": "Discord",
|
||||
"enabled": True,
|
||||
"configured": True,
|
||||
"runtime_state": "connected",
|
||||
"error_code": None,
|
||||
"error_message": None,
|
||||
"updated_at": "2026-03-30T21:49:00Z",
|
||||
"home_channel": {"platform": "discord", "chat_id": "chan-1", "name": "Home"},
|
||||
"auth": {"mode": "pairing", "allow_all": False, "allowlist_count": 0, "pairing_behavior": "pair"},
|
||||
"pairing": {"pending_count": 1, "approved_count": 2},
|
||||
"extra": {},
|
||||
}
|
||||
]
|
||||
|
||||
def get_pairing_state(self) -> dict:
|
||||
return {
|
||||
"pending": [
|
||||
{"platform": "discord", "code": "ABC12345", "user_id": "u-1", "user_name": "Ada", "age_minutes": 3}
|
||||
],
|
||||
"approved": [
|
||||
{"platform": "discord", "user_id": "u-2", "user_name": "Grace", "approved_at": 123.0}
|
||||
],
|
||||
"summary": {
|
||||
"pending_count": 1,
|
||||
"approved_count": 1,
|
||||
"platforms_with_pending": ["discord"],
|
||||
"platforms_with_approved": ["discord"],
|
||||
},
|
||||
}
|
||||
|
||||
def approve_pairing(self, *, platform: str, code: str) -> dict | None:
|
||||
self.approve_calls.append((platform, code))
|
||||
if platform == "discord" and code == "abc12345":
|
||||
return {
|
||||
"platform": "discord",
|
||||
"code": "ABC12345",
|
||||
"user": {"user_id": "u-1", "user_name": "Ada"},
|
||||
}
|
||||
return None
|
||||
|
||||
def revoke_pairing(self, *, platform: str, user_id: str) -> bool:
|
||||
self.revoke_calls.append((platform, user_id))
|
||||
return platform == "discord" and user_id == "u-2"
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
def __init__(self) -> None:
|
||||
self.restart_calls: list[tuple[bool, bool]] = []
|
||||
|
||||
def request_restart(self, *, detached: bool = False, via_service: bool = False) -> bool:
|
||||
self.restart_calls.append((detached, via_service))
|
||||
return True
|
||||
|
||||
|
||||
class RestartCapableAdapter:
|
||||
class _Handler:
|
||||
def __init__(self, runner: FakeRunner) -> None:
|
||||
self.__self__ = runner
|
||||
|
||||
def __init__(self, runner: FakeRunner) -> None:
|
||||
self._message_handler = self._Handler(runner)
|
||||
|
||||
|
||||
class TestGatewayAdminApiRoutes:
|
||||
@staticmethod
|
||||
async def _make_client(service: FakeGatewayService) -> TestClient:
|
||||
app = web.Application()
|
||||
app[GATEWAY_SERVICE_APP_KEY] = service
|
||||
register_web_console_routes(app)
|
||||
client = TestClient(TestServer(app))
|
||||
await client.start_server()
|
||||
return client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_overview_platforms_and_pairing_routes(self):
|
||||
service = FakeGatewayService()
|
||||
client = await self._make_client(service)
|
||||
try:
|
||||
overview_resp = await client.get("/api/gui/gateway/overview")
|
||||
assert overview_resp.status == 200
|
||||
overview_payload = await overview_resp.json()
|
||||
assert overview_payload["ok"] is True
|
||||
assert overview_payload["overview"]["gateway"]["pid"] == 4242
|
||||
assert overview_payload["overview"]["summary"]["pending_pairings"] == 1
|
||||
|
||||
platforms_resp = await client.get("/api/gui/gateway/platforms")
|
||||
assert platforms_resp.status == 200
|
||||
platforms_payload = await platforms_resp.json()
|
||||
assert platforms_payload["ok"] is True
|
||||
assert platforms_payload["platforms"][0]["key"] == "discord"
|
||||
assert platforms_payload["platforms"][0]["auth"]["mode"] == "pairing"
|
||||
|
||||
pairing_resp = await client.get("/api/gui/gateway/pairing")
|
||||
assert pairing_resp.status == 200
|
||||
pairing_payload = await pairing_resp.json()
|
||||
assert pairing_payload["ok"] is True
|
||||
assert pairing_payload["pairing"]["pending"][0]["code"] == "ABC12345"
|
||||
assert pairing_payload["pairing"]["summary"]["approved_count"] == 1
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pairing_approve_and_revoke_routes(self):
|
||||
service = FakeGatewayService()
|
||||
client = await self._make_client(service)
|
||||
try:
|
||||
approve_resp = await client.post(
|
||||
"/api/gui/gateway/pairing/approve",
|
||||
json={"platform": "discord", "code": "abc12345"},
|
||||
)
|
||||
assert approve_resp.status == 200
|
||||
approve_payload = await approve_resp.json()
|
||||
assert approve_payload["ok"] is True
|
||||
assert approve_payload["pairing"]["code"] == "ABC12345"
|
||||
assert approve_payload["pairing"]["user"]["user_id"] == "u-1"
|
||||
assert service.approve_calls == [("discord", "abc12345")]
|
||||
|
||||
revoke_resp = await client.post(
|
||||
"/api/gui/gateway/pairing/revoke",
|
||||
json={"platform": "discord", "user_id": "u-2"},
|
||||
)
|
||||
assert revoke_resp.status == 200
|
||||
revoke_payload = await revoke_resp.json()
|
||||
assert revoke_payload["ok"] is True
|
||||
assert revoke_payload["pairing"]["revoked"] is True
|
||||
assert revoke_payload["pairing"]["user_id"] == "u-2"
|
||||
assert service.revoke_calls == [("discord", "u-2")]
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pairing_routes_return_structured_validation_and_not_found_errors(self):
|
||||
service = FakeGatewayService()
|
||||
client = await self._make_client(service)
|
||||
try:
|
||||
invalid_json_resp = await client.post(
|
||||
"/api/gui/gateway/pairing/approve",
|
||||
data="not json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert invalid_json_resp.status == 400
|
||||
invalid_json_payload = await invalid_json_resp.json()
|
||||
assert invalid_json_payload["ok"] is False
|
||||
assert invalid_json_payload["error"]["code"] == "invalid_json"
|
||||
|
||||
invalid_platform_resp = await client.post(
|
||||
"/api/gui/gateway/pairing/approve",
|
||||
json={"platform": "", "code": "abc12345"},
|
||||
)
|
||||
assert invalid_platform_resp.status == 400
|
||||
invalid_platform_payload = await invalid_platform_resp.json()
|
||||
assert invalid_platform_payload["error"]["code"] == "invalid_platform"
|
||||
|
||||
approve_missing_resp = await client.post(
|
||||
"/api/gui/gateway/pairing/approve",
|
||||
json={"platform": "discord", "code": "missing"},
|
||||
)
|
||||
assert approve_missing_resp.status == 404
|
||||
approve_missing_payload = await approve_missing_resp.json()
|
||||
assert approve_missing_payload["error"]["code"] == "pairing_not_found"
|
||||
assert approve_missing_payload["error"]["code"] != ""
|
||||
|
||||
revoke_missing_resp = await client.post(
|
||||
"/api/gui/gateway/pairing/revoke",
|
||||
json={"platform": "discord", "user_id": "missing"},
|
||||
)
|
||||
assert revoke_missing_resp.status == 404
|
||||
revoke_missing_payload = await revoke_missing_resp.json()
|
||||
assert revoke_missing_payload["error"]["code"] == "paired_user_not_found"
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_route_requests_gateway_restart_when_runner_is_available(self):
|
||||
service = FakeGatewayService()
|
||||
runner = FakeRunner()
|
||||
app = web.Application()
|
||||
app[GATEWAY_SERVICE_APP_KEY] = service
|
||||
register_web_console_routes(app, adapter=RestartCapableAdapter(runner))
|
||||
client = TestClient(TestServer(app))
|
||||
await client.start_server()
|
||||
try:
|
||||
resp = await client.post("/api/gui/gateway/restart", json={})
|
||||
assert resp.status == 200
|
||||
payload = await resp.json()
|
||||
assert payload["ok"] is True
|
||||
assert payload["accepted"] is True
|
||||
assert runner.restart_calls == [(True, False)]
|
||||
finally:
|
||||
await client.close()
|
||||
64
tests/web_console/test_logs_api.py
Normal file
64
tests/web_console/test_logs_api.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Tests for the web console logs API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from gateway.web_console.api.logs import LOG_SERVICE_APP_KEY
|
||||
from gateway.web_console.routes import register_web_console_routes
|
||||
|
||||
|
||||
class FakeLogService:
|
||||
def get_logs(self, *, file_name=None, limit=200):
|
||||
if file_name == "missing.log":
|
||||
raise FileNotFoundError(file_name)
|
||||
return {
|
||||
"directory": "/tmp/hermes/logs",
|
||||
"file": file_name or "gateway.log",
|
||||
"available_files": ["gateway.log", "gateway.error.log"],
|
||||
"line_count": min(2, limit),
|
||||
"lines": ["line 1", "line 2"][:limit],
|
||||
}
|
||||
|
||||
|
||||
class TestLogsApi:
|
||||
@staticmethod
|
||||
async def _make_client(service: FakeLogService) -> TestClient:
|
||||
app = web.Application()
|
||||
app[LOG_SERVICE_APP_KEY] = service
|
||||
register_web_console_routes(app)
|
||||
client = TestClient(TestServer(app))
|
||||
await client.start_server()
|
||||
return client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_logs_route(self):
|
||||
client = await self._make_client(FakeLogService())
|
||||
try:
|
||||
resp = await client.get("/api/gui/logs?file=gateway.error.log&limit=1")
|
||||
assert resp.status == 200
|
||||
payload = await resp.json()
|
||||
assert payload["ok"] is True
|
||||
assert payload["logs"]["file"] == "gateway.error.log"
|
||||
assert payload["logs"]["line_count"] == 1
|
||||
assert payload["logs"]["lines"] == ["line 1"]
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_logs_route_validation_and_not_found(self):
|
||||
client = await self._make_client(FakeLogService())
|
||||
try:
|
||||
invalid_limit_resp = await client.get("/api/gui/logs?limit=abc")
|
||||
assert invalid_limit_resp.status == 400
|
||||
assert (await invalid_limit_resp.json())["error"]["code"] == "invalid_limit"
|
||||
|
||||
missing_resp = await client.get("/api/gui/logs?file=missing.log")
|
||||
assert missing_resp.status == 404
|
||||
missing_payload = await missing_resp.json()
|
||||
assert missing_payload["error"]["code"] == "log_not_found"
|
||||
assert missing_payload["error"]["file"] == "missing.log"
|
||||
finally:
|
||||
await client.close()
|
||||
281
tests/web_console/test_memory_api.py
Normal file
281
tests/web_console/test_memory_api.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""Tests for the web console memory and session-search APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from gateway.web_console.api.memory import MEMORY_SERVICE_APP_KEY
|
||||
from gateway.web_console.routes import register_web_console_routes
|
||||
from gateway.web_console.services import memory_service as memory_service_module
|
||||
from gateway.web_console.services.memory_service import MemoryService
|
||||
|
||||
|
||||
class FakeMemoryService:
|
||||
def __init__(self) -> None:
|
||||
self.payloads = {
|
||||
"memory": {
|
||||
"target": "memory",
|
||||
"enabled": True,
|
||||
"entries": ["Project prefers pytest."],
|
||||
"entry_count": 1,
|
||||
"usage": {"text": "1% — 22/2200 chars", "percent": 1, "current_chars": 22, "char_limit": 2200},
|
||||
"path": "/tmp/MEMORY.md",
|
||||
},
|
||||
"user": {
|
||||
"target": "user",
|
||||
"enabled": True,
|
||||
"entries": ["User likes concise answers."],
|
||||
"entry_count": 1,
|
||||
"usage": {"text": "2% — 30/1375 chars", "percent": 2, "current_chars": 30, "char_limit": 1375},
|
||||
"path": "/tmp/USER.md",
|
||||
},
|
||||
}
|
||||
self.search_payload = {
|
||||
"success": True,
|
||||
"query": "deploy OR docker",
|
||||
"results": [{"session_id": "sess-1", "summary": "We fixed the deploy issue.", "source": "cli", "when": "today", "model": "hermes"}],
|
||||
"count": 1,
|
||||
"sessions_searched": 1,
|
||||
}
|
||||
|
||||
def get_memory(self, *, target="memory"):
|
||||
if target not in self.payloads:
|
||||
raise ValueError("bad target")
|
||||
return self.payloads[target]
|
||||
|
||||
def mutate_memory(self, *, action, target="memory", content=None, old_text=None):
|
||||
if target == "disabled":
|
||||
raise PermissionError("Local memory is disabled in config.")
|
||||
if target not in self.payloads:
|
||||
raise ValueError("bad target")
|
||||
if content == "fail" or old_text == "missing":
|
||||
return {
|
||||
**self.payloads[target],
|
||||
"success": False,
|
||||
"error": "No entry matched 'missing'.",
|
||||
"matches": ["candidate one"],
|
||||
}
|
||||
payload = dict(self.payloads[target])
|
||||
payload["success"] = True
|
||||
payload["message"] = f"{action} ok"
|
||||
if action == "add" and content:
|
||||
payload["entries"] = payload["entries"] + [content]
|
||||
payload["entry_count"] = len(payload["entries"])
|
||||
return payload
|
||||
|
||||
def search_sessions(self, *, query, role_filter=None, limit=3, current_session_id=None):
|
||||
if query == "explode":
|
||||
raise RuntimeError("boom")
|
||||
if query == "offline":
|
||||
return {"success": False, "error": "Session database not available."}
|
||||
payload = dict(self.search_payload)
|
||||
payload["query"] = query
|
||||
payload["role_filter"] = role_filter
|
||||
payload["limit"] = limit
|
||||
payload["current_session_id"] = current_session_id
|
||||
return payload
|
||||
|
||||
|
||||
class TestMemoryApi:
|
||||
@staticmethod
|
||||
async def _make_client(service: FakeMemoryService) -> TestClient:
|
||||
app = web.Application()
|
||||
app[MEMORY_SERVICE_APP_KEY] = service
|
||||
register_web_console_routes(app)
|
||||
client = TestClient(TestServer(app))
|
||||
await client.start_server()
|
||||
return client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_routes_return_structured_payloads(self):
|
||||
client = await self._make_client(FakeMemoryService())
|
||||
try:
|
||||
memory_resp = await client.get("/api/gui/memory")
|
||||
assert memory_resp.status == 200
|
||||
memory_payload = await memory_resp.json()
|
||||
assert memory_payload["ok"] is True
|
||||
assert memory_payload["memory"]["target"] == "memory"
|
||||
assert memory_payload["memory"]["entries"] == ["Project prefers pytest."]
|
||||
|
||||
profile_resp = await client.get("/api/gui/user-profile")
|
||||
assert profile_resp.status == 200
|
||||
profile_payload = await profile_resp.json()
|
||||
assert profile_payload["ok"] is True
|
||||
assert profile_payload["user_profile"]["target"] == "user"
|
||||
assert profile_payload["user_profile"]["entries"] == ["User likes concise answers."]
|
||||
|
||||
add_resp = await client.post("/api/gui/memory", json={"target": "user", "content": "Prefers dark mode."})
|
||||
assert add_resp.status == 200
|
||||
add_payload = await add_resp.json()
|
||||
assert add_payload["ok"] is True
|
||||
assert add_payload["memory"]["target"] == "user"
|
||||
assert add_payload["memory"]["message"] == "add ok"
|
||||
|
||||
replace_resp = await client.patch(
|
||||
"/api/gui/memory",
|
||||
json={"target": "memory", "old_text": "pytest", "content": "Project prefers pytest -q."},
|
||||
)
|
||||
assert replace_resp.status == 200
|
||||
replace_payload = await replace_resp.json()
|
||||
assert replace_payload["memory"]["message"] == "replace ok"
|
||||
|
||||
delete_resp = await client.delete("/api/gui/memory", json={"target": "memory", "old_text": "pytest"})
|
||||
assert delete_resp.status == 200
|
||||
delete_payload = await delete_resp.json()
|
||||
assert delete_payload["memory"]["message"] == "remove ok"
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_search_route_and_structured_errors(self):
|
||||
client = await self._make_client(FakeMemoryService())
|
||||
try:
|
||||
search_resp = await client.get(
|
||||
"/api/gui/session-search?query=deploy%20OR%20docker&role_filter=user,assistant&limit=2¤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"
|
||||
258
tests/web_console/test_sessions_api.py
Normal file
258
tests/web_console/test_sessions_api.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""Tests for the web console sessions API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from hermes_state import SessionDB
|
||||
from gateway.web_console.api.sessions import SESSIONS_SERVICE_APP_KEY
|
||||
from gateway.web_console.routes import register_web_console_routes
|
||||
from gateway.web_console.services.session_service import SessionService
|
||||
|
||||
|
||||
class FakeSessionService:
|
||||
def __init__(self):
|
||||
self.sessions = {
|
||||
"sess-1": {
|
||||
"session_id": "sess-1",
|
||||
"title": "Session One",
|
||||
"last_active": 123.0,
|
||||
"source": "cli",
|
||||
"workspace": None,
|
||||
"model": "hermes-agent",
|
||||
"token_summary": {"input": 1, "output": 2, "total": 3, "cache_read": 0, "cache_write": 0, "reasoning": 0},
|
||||
"parent_session_id": None,
|
||||
"has_tools": True,
|
||||
"has_attachments": False,
|
||||
"preview": "hello",
|
||||
"message_count": 2,
|
||||
"started_at": 100.0,
|
||||
"ended_at": None,
|
||||
"end_reason": None,
|
||||
"system_prompt": "system",
|
||||
"metadata": {
|
||||
"user_id": "user-1",
|
||||
"model_config": {"temperature": 0.2},
|
||||
"billing_provider": None,
|
||||
"billing_base_url": None,
|
||||
"billing_mode": None,
|
||||
"estimated_cost_usd": None,
|
||||
"actual_cost_usd": None,
|
||||
"cost_status": None,
|
||||
"cost_source": None,
|
||||
"pricing_version": None,
|
||||
},
|
||||
"recap": {"message_count": 2, "preview": "hello", "last_role": "assistant"},
|
||||
}
|
||||
}
|
||||
self.transcripts = {
|
||||
"sess-1": {
|
||||
"session_id": "sess-1",
|
||||
"items": [
|
||||
{"id": 1, "type": "user_message", "role": "user", "content": "hello", "timestamp": 100.0},
|
||||
{"id": 2, "type": "assistant_message", "role": "assistant", "content": "hi", "timestamp": 101.0},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
def list_sessions(self, *, source=None, limit=20, offset=0):
|
||||
items = list(self.sessions.values())
|
||||
if source:
|
||||
items = [item for item in items if item["source"] == source]
|
||||
return items[offset:offset + limit]
|
||||
|
||||
def get_session_detail(self, session_id):
|
||||
return self.sessions.get(session_id)
|
||||
|
||||
def get_transcript(self, session_id):
|
||||
return self.transcripts.get(session_id)
|
||||
|
||||
def set_title(self, session_id, title):
|
||||
session = self.sessions.get(session_id)
|
||||
if session is None:
|
||||
return None
|
||||
if title == "bad title":
|
||||
raise ValueError("Title is invalid")
|
||||
session["title"] = title
|
||||
return {"session_id": session_id, "title": title}
|
||||
|
||||
def resume_session(self, session_id):
|
||||
session = self.sessions.get(session_id)
|
||||
if session is None:
|
||||
return None
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": "resumed",
|
||||
"resume_supported": True,
|
||||
"title": session["title"],
|
||||
"conversation_history": [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi"},
|
||||
],
|
||||
"session": session,
|
||||
}
|
||||
|
||||
def delete_session(self, session_id):
|
||||
return self.sessions.pop(session_id, None) is not None
|
||||
|
||||
|
||||
class TestSessionsApi:
|
||||
@staticmethod
|
||||
async def _make_client(service: FakeSessionService) -> TestClient:
|
||||
app = web.Application()
|
||||
app[SESSIONS_SERVICE_APP_KEY] = service
|
||||
register_web_console_routes(app)
|
||||
client = TestClient(TestServer(app))
|
||||
await client.start_server()
|
||||
return client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_and_get_session_routes(self):
|
||||
client = await self._make_client(FakeSessionService())
|
||||
try:
|
||||
list_resp = await client.get("/api/gui/sessions")
|
||||
assert list_resp.status == 200
|
||||
list_payload = await list_resp.json()
|
||||
assert list_payload["ok"] is True
|
||||
session_summary = list_payload["sessions"][0]
|
||||
assert session_summary["session_id"] == "sess-1"
|
||||
assert session_summary["title"] == "Session One"
|
||||
assert session_summary["last_active"] == 123.0
|
||||
assert session_summary["source"] == "cli"
|
||||
assert session_summary["workspace"] is None
|
||||
assert session_summary["model"] == "hermes-agent"
|
||||
assert session_summary["token_summary"]["total"] == 3
|
||||
assert session_summary["parent_session_id"] is None
|
||||
assert session_summary["has_tools"] is True
|
||||
assert session_summary["has_attachments"] is False
|
||||
|
||||
detail_resp = await client.get("/api/gui/sessions/sess-1")
|
||||
assert detail_resp.status == 200
|
||||
detail_payload = await detail_resp.json()
|
||||
assert detail_payload["ok"] is True
|
||||
assert detail_payload["session"]["title"] == "Session One"
|
||||
assert detail_payload["session"]["recap"]["last_role"] == "assistant"
|
||||
assert detail_payload["session"]["metadata"]["user_id"] == "user-1"
|
||||
assert detail_payload["session"]["metadata"]["model_config"]["temperature"] == 0.2
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_transcript_route(self):
|
||||
client = await self._make_client(FakeSessionService())
|
||||
try:
|
||||
resp = await client.get("/api/gui/sessions/sess-1/transcript")
|
||||
assert resp.status == 200
|
||||
payload = await resp.json()
|
||||
assert payload["ok"] is True
|
||||
assert payload["session_id"] == "sess-1"
|
||||
assert len(payload["items"]) == 2
|
||||
assert payload["items"][0]["type"] == "user_message"
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_title_resume_and_delete_routes(self):
|
||||
client = await self._make_client(FakeSessionService())
|
||||
try:
|
||||
title_resp = await client.post("/api/gui/sessions/sess-1/title", json={"title": "Renamed"})
|
||||
assert title_resp.status == 200
|
||||
title_payload = await title_resp.json()
|
||||
assert title_payload == {"ok": True, "session_id": "sess-1", "title": "Renamed"}
|
||||
|
||||
resume_resp = await client.post("/api/gui/sessions/sess-1/resume")
|
||||
assert resume_resp.status == 200
|
||||
resume_payload = await resume_resp.json()
|
||||
assert resume_payload["ok"] is True
|
||||
assert resume_payload["status"] == "resumed"
|
||||
assert resume_payload["resume_supported"] is True
|
||||
assert resume_payload["conversation_history"][0]["role"] == "user"
|
||||
assert resume_payload["session"]["session_id"] == "sess-1"
|
||||
|
||||
delete_resp = await client.delete("/api/gui/sessions/sess-1")
|
||||
assert delete_resp.status == 200
|
||||
delete_payload = await delete_resp.json()
|
||||
assert delete_payload == {"ok": True, "session_id": "sess-1", "deleted": True}
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_pagination_returns_structured_error(self):
|
||||
client = await self._make_client(FakeSessionService())
|
||||
try:
|
||||
resp = await client.get("/api/gui/sessions?limit=abc")
|
||||
assert resp.status == 400
|
||||
payload = await resp.json()
|
||||
assert payload == {
|
||||
"ok": False,
|
||||
"error": {
|
||||
"code": "invalid_pagination",
|
||||
"message": "The 'limit' field must be an integer.",
|
||||
},
|
||||
}
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_session_service_uses_sessiondb_storage(self, tmp_path):
|
||||
db = SessionDB(tmp_path / "state.db")
|
||||
db.create_session(
|
||||
session_id="sess-real",
|
||||
source="cli",
|
||||
model="hermes-agent",
|
||||
model_config={"temperature": 0.4},
|
||||
system_prompt="system prompt",
|
||||
user_id="user-real",
|
||||
parent_session_id=None,
|
||||
)
|
||||
db.append_message("sess-real", "user", content="hello")
|
||||
db.append_message("sess-real", "assistant", content="hi there")
|
||||
db.set_session_title("sess-real", "Real Session")
|
||||
db.update_token_counts("sess-real", input_tokens=5, output_tokens=7, model="hermes-agent")
|
||||
|
||||
service = SessionService(db=db)
|
||||
sessions = service.list_sessions()
|
||||
assert sessions[0]["session_id"] == "sess-real"
|
||||
assert sessions[0]["token_summary"]["total"] == 12
|
||||
|
||||
detail = service.get_session_detail("sess-real")
|
||||
assert detail["title"] == "Real Session"
|
||||
assert detail["metadata"]["user_id"] == "user-real"
|
||||
assert detail["metadata"]["model_config"]["temperature"] == 0.4
|
||||
|
||||
transcript = service.get_transcript("sess-real")
|
||||
assert transcript["items"][0]["role"] == "user"
|
||||
assert transcript["items"][1]["role"] == "assistant"
|
||||
|
||||
resumed = service.resume_session("sess-real")
|
||||
assert resumed["status"] == "resumed"
|
||||
assert resumed["conversation_history"][0]["role"] == "user"
|
||||
|
||||
db.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_and_invalid_requests_are_structured(self):
|
||||
client = await self._make_client(FakeSessionService())
|
||||
try:
|
||||
missing_resp = await client.get("/api/gui/sessions/missing")
|
||||
assert missing_resp.status == 404
|
||||
missing_payload = await missing_resp.json()
|
||||
assert missing_payload["error"]["code"] == "session_not_found"
|
||||
|
||||
invalid_title_resp = await client.post(
|
||||
"/api/gui/sessions/sess-1/title",
|
||||
data="not json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert invalid_title_resp.status == 400
|
||||
invalid_title_payload = await invalid_title_resp.json()
|
||||
assert invalid_title_payload["error"]["code"] == "invalid_json"
|
||||
|
||||
bad_title_resp = await client.post("/api/gui/sessions/sess-1/title", json={"title": "bad title"})
|
||||
assert bad_title_resp.status == 400
|
||||
bad_title_payload = await bad_title_resp.json()
|
||||
assert bad_title_payload["error"]["code"] == "invalid_title"
|
||||
finally:
|
||||
await client.close()
|
||||
108
tests/web_console/test_settings_api.py
Normal file
108
tests/web_console/test_settings_api.py
Normal file
@@ -0,0 +1,108 @@
|
||||
"""Tests for the web console settings and auth-status APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from gateway.web_console.api.settings import SETTINGS_SERVICE_APP_KEY
|
||||
from gateway.web_console.routes import register_web_console_routes
|
||||
|
||||
|
||||
class FakeSettingsService:
|
||||
def __init__(self) -> None:
|
||||
self.settings = {
|
||||
"model": "anthropic/claude-opus-4.1",
|
||||
"auxiliary": {"vision": {"api_key": "***", "base_url": "https://example.test/v1"}},
|
||||
"browser": {"record_sessions": False},
|
||||
}
|
||||
self.auth = {
|
||||
"active_provider": "nous",
|
||||
"resolved_provider": "nous",
|
||||
"logged_in": True,
|
||||
"active_status": {"logged_in": True, "has_refresh_token": True},
|
||||
"providers": [
|
||||
{
|
||||
"provider": "nous",
|
||||
"name": "Nous Portal",
|
||||
"auth_type": "oauth_device_code",
|
||||
"active": True,
|
||||
"status": {"logged_in": True, "has_refresh_token": True},
|
||||
}
|
||||
],
|
||||
}
|
||||
self.update_calls: list[dict] = []
|
||||
|
||||
def get_settings(self) -> dict:
|
||||
return self.settings
|
||||
|
||||
def update_settings(self, patch: dict) -> dict:
|
||||
self.update_calls.append(patch)
|
||||
if patch.get("bad"):
|
||||
raise ValueError("patch rejected")
|
||||
self.settings = {
|
||||
**self.settings,
|
||||
**patch,
|
||||
}
|
||||
return self.settings
|
||||
|
||||
def get_auth_status(self) -> dict:
|
||||
return self.auth
|
||||
|
||||
|
||||
class TestSettingsApi:
|
||||
@staticmethod
|
||||
async def _make_client(service: FakeSettingsService) -> TestClient:
|
||||
app = web.Application()
|
||||
app[SETTINGS_SERVICE_APP_KEY] = service
|
||||
register_web_console_routes(app)
|
||||
client = TestClient(TestServer(app))
|
||||
await client.start_server()
|
||||
return client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_settings_and_auth_status_routes(self):
|
||||
client = await self._make_client(FakeSettingsService())
|
||||
try:
|
||||
settings_resp = await client.get("/api/gui/settings")
|
||||
assert settings_resp.status == 200
|
||||
settings_payload = await settings_resp.json()
|
||||
assert settings_payload["ok"] is True
|
||||
assert settings_payload["settings"]["model"] == "anthropic/claude-opus-4.1"
|
||||
assert settings_payload["settings"]["auxiliary"]["vision"]["api_key"] == "***"
|
||||
|
||||
auth_resp = await client.get("/api/gui/auth-status")
|
||||
assert auth_resp.status == 200
|
||||
auth_payload = await auth_resp.json()
|
||||
assert auth_payload["ok"] is True
|
||||
assert auth_payload["auth"]["active_provider"] == "nous"
|
||||
assert auth_payload["auth"]["providers"][0]["status"]["logged_in"] is True
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_settings_route_and_validation(self):
|
||||
service = FakeSettingsService()
|
||||
client = await self._make_client(service)
|
||||
try:
|
||||
patch_resp = await client.patch("/api/gui/settings", json={"browser": {"record_sessions": True}})
|
||||
assert patch_resp.status == 200
|
||||
patch_payload = await patch_resp.json()
|
||||
assert patch_payload["ok"] is True
|
||||
assert service.update_calls == [{"browser": {"record_sessions": True}}]
|
||||
assert patch_payload["settings"]["browser"]["record_sessions"] is True
|
||||
|
||||
invalid_json_resp = await client.patch(
|
||||
"/api/gui/settings",
|
||||
data="not json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert invalid_json_resp.status == 400
|
||||
assert (await invalid_json_resp.json())["error"]["code"] == "invalid_json"
|
||||
|
||||
invalid_patch_resp = await client.patch("/api/gui/settings", json={"bad": True})
|
||||
assert invalid_patch_resp.status == 400
|
||||
assert (await invalid_patch_resp.json())["error"]["code"] == "invalid_patch"
|
||||
finally:
|
||||
await client.close()
|
||||
314
tests/web_console/test_skills_api.py
Normal file
314
tests/web_console/test_skills_api.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""Tests for the web console skills API and skill service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from hermes_state import SessionDB
|
||||
from gateway.web_console.api.skills import SKILLS_SERVICE_APP_KEY
|
||||
from gateway.web_console.routes import register_web_console_routes
|
||||
from gateway.web_console.services.skill_service import SkillService
|
||||
|
||||
|
||||
class FakeSkillService:
|
||||
def __init__(self) -> None:
|
||||
self.skills = {
|
||||
"planner": {
|
||||
"name": "planner",
|
||||
"description": "Plan complex work.",
|
||||
"category": "workflow",
|
||||
"path": "workflow/planner/SKILL.md",
|
||||
"content": "# Planner",
|
||||
"source": "official",
|
||||
"source_type": "hub",
|
||||
"trust_level": "trusted",
|
||||
"identifier": "official/workflow/planner",
|
||||
"install_path": "/tmp/planner",
|
||||
"scan_verdict": "allow",
|
||||
"installed_at": "2026-03-30T00:00:00+00:00",
|
||||
"updated_at": "2026-03-30T00:00:00+00:00",
|
||||
"installed_metadata": {"author": "Hermes"},
|
||||
"readiness_status": "available",
|
||||
"setup_needed": False,
|
||||
},
|
||||
"blocked": {
|
||||
"name": "blocked",
|
||||
"description": "Needs setup first.",
|
||||
"category": "workflow",
|
||||
"path": "workflow/blocked/SKILL.md",
|
||||
"content": "# Blocked",
|
||||
"source": "local",
|
||||
"source_type": "local",
|
||||
"trust_level": "local",
|
||||
"identifier": None,
|
||||
"install_path": None,
|
||||
"scan_verdict": None,
|
||||
"installed_at": None,
|
||||
"updated_at": None,
|
||||
"installed_metadata": {},
|
||||
"readiness_status": "setup_needed",
|
||||
"setup_needed": True,
|
||||
},
|
||||
}
|
||||
self.session_skills = {
|
||||
"sess-1": {
|
||||
"planner": {
|
||||
"name": "planner",
|
||||
"description": "Plan complex work.",
|
||||
"path": "workflow/planner/SKILL.md",
|
||||
"source": "official",
|
||||
"source_type": "hub",
|
||||
"trust_level": "trusted",
|
||||
"readiness_status": "available",
|
||||
"setup_needed": False,
|
||||
"loaded_at": "2026-03-30T00:00:00+00:00",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def list_skills(self):
|
||||
return {
|
||||
"skills": [self.skills["planner"], self.skills["blocked"]],
|
||||
"categories": ["workflow"],
|
||||
"count": 2,
|
||||
"hint": "Use the detail endpoint for full content.",
|
||||
}
|
||||
|
||||
def get_skill(self, name: str):
|
||||
if name == "missing":
|
||||
raise FileNotFoundError(name)
|
||||
if name == "blocked":
|
||||
raise ValueError("Skill 'blocked' is disabled.")
|
||||
return self.skills[name]
|
||||
|
||||
def load_skill_for_session(self, session_id: str, name: str):
|
||||
if session_id == "missing-session":
|
||||
raise LookupError("session_not_found")
|
||||
if name == "missing":
|
||||
raise FileNotFoundError(name)
|
||||
if name == "blocked":
|
||||
raise ValueError("Skill 'blocked' is disabled.")
|
||||
loaded = self.session_skills.setdefault(session_id, {})
|
||||
already_loaded = name in loaded
|
||||
skill = loaded.get(name) or {
|
||||
"name": name,
|
||||
"description": self.skills[name]["description"],
|
||||
"path": self.skills[name]["path"],
|
||||
"source": self.skills[name]["source"],
|
||||
"source_type": self.skills[name]["source_type"],
|
||||
"trust_level": self.skills[name]["trust_level"],
|
||||
"readiness_status": self.skills[name]["readiness_status"],
|
||||
"setup_needed": self.skills[name]["setup_needed"],
|
||||
"loaded_at": "2026-03-30T01:00:00+00:00",
|
||||
}
|
||||
loaded[name] = skill
|
||||
return {"session_id": session_id, "skill": skill, "loaded": True, "already_loaded": already_loaded}
|
||||
|
||||
def list_session_skills(self, session_id: str):
|
||||
if session_id == "missing-session":
|
||||
raise LookupError("session_not_found")
|
||||
skills = sorted(self.session_skills.get(session_id, {}).values(), key=lambda item: item["name"])
|
||||
return {"session_id": session_id, "skills": skills, "count": len(skills)}
|
||||
|
||||
def unload_skill_for_session(self, session_id: str, name: str):
|
||||
if session_id == "missing-session":
|
||||
raise LookupError("session_not_found")
|
||||
removed = self.session_skills.get(session_id, {}).pop(name, None) is not None
|
||||
return {"session_id": session_id, "name": name, "removed": removed}
|
||||
|
||||
|
||||
class TestSkillsApi:
|
||||
@staticmethod
|
||||
async def _make_client(service: FakeSkillService) -> TestClient:
|
||||
app = web.Application()
|
||||
app[SKILLS_SERVICE_APP_KEY] = service
|
||||
register_web_console_routes(app)
|
||||
client = TestClient(TestServer(app))
|
||||
await client.start_server()
|
||||
return client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_and_get_skill_routes(self):
|
||||
client = await self._make_client(FakeSkillService())
|
||||
try:
|
||||
list_resp = await client.get("/api/gui/skills")
|
||||
assert list_resp.status == 200
|
||||
list_payload = await list_resp.json()
|
||||
assert list_payload["ok"] is True
|
||||
assert list_payload["count"] == 2
|
||||
assert list_payload["categories"] == ["workflow"]
|
||||
assert list_payload["skills"][0]["name"] == "planner"
|
||||
assert list_payload["skills"][0]["source_type"] == "hub"
|
||||
assert list_payload["skills"][0]["installed_metadata"] == {"author": "Hermes"}
|
||||
|
||||
detail_resp = await client.get("/api/gui/skills/planner")
|
||||
assert detail_resp.status == 200
|
||||
detail_payload = await detail_resp.json()
|
||||
assert detail_payload["ok"] is True
|
||||
assert detail_payload["skill"]["name"] == "planner"
|
||||
assert detail_payload["skill"]["content"] == "# Planner"
|
||||
assert detail_payload["skill"]["trust_level"] == "trusted"
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_skill_load_list_and_unload_routes(self):
|
||||
client = await self._make_client(FakeSkillService())
|
||||
try:
|
||||
load_resp = await client.post("/api/gui/skills/planner/load", json={"session_id": "sess-1"})
|
||||
assert load_resp.status == 200
|
||||
load_payload = await load_resp.json()
|
||||
assert load_payload["ok"] is True
|
||||
assert load_payload["session_id"] == "sess-1"
|
||||
assert load_payload["skill"]["name"] == "planner"
|
||||
assert load_payload["already_loaded"] is True
|
||||
|
||||
session_resp = await client.get("/api/gui/skills/session/sess-1")
|
||||
assert session_resp.status == 200
|
||||
session_payload = await session_resp.json()
|
||||
assert session_payload["ok"] is True
|
||||
assert session_payload["count"] == 1
|
||||
assert session_payload["skills"][0]["name"] == "planner"
|
||||
|
||||
unload_resp = await client.delete("/api/gui/skills/session/sess-1/planner")
|
||||
assert unload_resp.status == 200
|
||||
unload_payload = await unload_resp.json()
|
||||
assert unload_payload == {"ok": True, "session_id": "sess-1", "name": "planner", "removed": True}
|
||||
|
||||
session_after_resp = await client.get("/api/gui/skills/session/sess-1")
|
||||
session_after_payload = await session_after_resp.json()
|
||||
assert session_after_payload["count"] == 0
|
||||
assert session_after_payload["skills"] == []
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skills_api_returns_structured_errors(self):
|
||||
client = await self._make_client(FakeSkillService())
|
||||
try:
|
||||
invalid_json_resp = await client.post(
|
||||
"/api/gui/skills/planner/load",
|
||||
data="not json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert invalid_json_resp.status == 400
|
||||
invalid_json_payload = await invalid_json_resp.json()
|
||||
assert invalid_json_payload["error"]["code"] == "invalid_json"
|
||||
|
||||
invalid_session_resp = await client.post("/api/gui/skills/planner/load", json={})
|
||||
assert invalid_session_resp.status == 400
|
||||
invalid_session_payload = await invalid_session_resp.json()
|
||||
assert invalid_session_payload["error"]["code"] == "invalid_session_id"
|
||||
|
||||
missing_skill_resp = await client.get("/api/gui/skills/missing")
|
||||
assert missing_skill_resp.status == 404
|
||||
missing_skill_payload = await missing_skill_resp.json()
|
||||
assert missing_skill_payload["error"]["code"] == "skill_not_found"
|
||||
|
||||
blocked_skill_resp = await client.get("/api/gui/skills/blocked")
|
||||
assert blocked_skill_resp.status == 400
|
||||
blocked_skill_payload = await blocked_skill_resp.json()
|
||||
assert blocked_skill_payload["error"]["code"] == "skill_unavailable"
|
||||
|
||||
missing_session_resp = await client.get("/api/gui/skills/session/missing-session")
|
||||
assert missing_session_resp.status == 404
|
||||
missing_session_payload = await missing_session_resp.json()
|
||||
assert missing_session_payload["error"]["code"] == "session_not_found"
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
class TestSkillService:
|
||||
def test_real_service_tracks_loaded_skills_and_metadata(self, tmp_path, monkeypatch):
|
||||
db = SessionDB(tmp_path / "state.db")
|
||||
db.create_session(
|
||||
session_id="sess-real",
|
||||
source="cli",
|
||||
model="hermes-agent",
|
||||
model_config={"temperature": 0.1},
|
||||
system_prompt="system prompt",
|
||||
user_id="user-1",
|
||||
parent_session_id=None,
|
||||
)
|
||||
|
||||
service = SkillService(db=db)
|
||||
|
||||
monkeypatch.setattr(
|
||||
SkillService,
|
||||
"list_skills",
|
||||
lambda self: {
|
||||
"skills": [
|
||||
{
|
||||
"name": "planner",
|
||||
"description": "Plan work.",
|
||||
"category": "workflow",
|
||||
"source": "builtin",
|
||||
"source_type": "builtin",
|
||||
"trust_level": "builtin",
|
||||
}
|
||||
],
|
||||
"categories": ["workflow"],
|
||||
"count": 1,
|
||||
"hint": None,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
SkillService,
|
||||
"get_skill",
|
||||
lambda self, name: {
|
||||
"name": name,
|
||||
"description": "Plan work.",
|
||||
"path": "workflow/planner/SKILL.md",
|
||||
"source": "builtin",
|
||||
"source_type": "builtin",
|
||||
"trust_level": "builtin",
|
||||
"readiness_status": "available",
|
||||
"setup_needed": False,
|
||||
},
|
||||
)
|
||||
|
||||
listing = service.list_skills()
|
||||
assert listing["count"] == 1
|
||||
assert listing["skills"][0]["name"] == "planner"
|
||||
|
||||
loaded = service.load_skill_for_session("sess-real", "planner")
|
||||
assert loaded["loaded"] is True
|
||||
assert loaded["already_loaded"] is False
|
||||
assert loaded["skill"]["source_type"] == "builtin"
|
||||
|
||||
loaded_again = service.load_skill_for_session("sess-real", "planner")
|
||||
assert loaded_again["already_loaded"] is True
|
||||
|
||||
session_skills = service.list_session_skills("sess-real")
|
||||
assert session_skills["count"] == 1
|
||||
assert session_skills["skills"][0]["name"] == "planner"
|
||||
|
||||
removed = service.unload_skill_for_session("sess-real", "planner")
|
||||
assert removed == {"session_id": "sess-real", "name": "planner", "removed": True}
|
||||
assert service.list_session_skills("sess-real")["skills"] == []
|
||||
|
||||
service.load_skill_for_session("sess-real", "planner")
|
||||
monkeypatch.setattr(
|
||||
SkillService,
|
||||
"get_skill",
|
||||
lambda self, name: {
|
||||
"name": "planner",
|
||||
"description": "Plan work.",
|
||||
"path": "workflow/planner/SKILL.md",
|
||||
"source": "builtin",
|
||||
"source_type": "builtin",
|
||||
"trust_level": "builtin",
|
||||
"readiness_status": "available",
|
||||
"setup_needed": False,
|
||||
},
|
||||
)
|
||||
removed_via_alias = service.unload_skill_for_session("sess-real", "Planner")
|
||||
assert removed_via_alias == {"session_id": "sess-real", "name": "planner", "removed": True}
|
||||
assert service.list_session_skills("sess-real")["skills"] == []
|
||||
|
||||
with pytest.raises(LookupError):
|
||||
service.list_session_skills("missing")
|
||||
|
||||
db.close()
|
||||
274
tests/web_console/test_workspace_api.py
Normal file
274
tests/web_console/test_workspace_api.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""Tests for the web console workspace and process APIs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import TestClient, TestServer
|
||||
|
||||
from gateway.web_console.api.workspace import WORKSPACE_SERVICE_APP_KEY
|
||||
from gateway.web_console.routes import register_web_console_routes
|
||||
from gateway.web_console.services.workspace_service import WorkspaceService
|
||||
|
||||
|
||||
class FakeWorkspaceService:
|
||||
def __init__(self) -> None:
|
||||
self.rollback_requests: list[dict[str, object]] = []
|
||||
self.killed: list[str] = []
|
||||
|
||||
def get_tree(self, *, path=None, depth=2, include_hidden=False):
|
||||
if path == "missing":
|
||||
raise FileNotFoundError("missing path")
|
||||
if path == "bad":
|
||||
raise ValueError("bad path")
|
||||
return {
|
||||
"workspace_root": "/workspace",
|
||||
"tree": {
|
||||
"name": "workspace",
|
||||
"path": path or ".",
|
||||
"type": "directory",
|
||||
"children": [{"name": "src", "path": "src", "type": "directory", "children": []}],
|
||||
"truncated": False,
|
||||
},
|
||||
}
|
||||
|
||||
def get_file(self, *, path, offset=1, limit=500):
|
||||
if path == "missing.txt":
|
||||
raise FileNotFoundError("missing file")
|
||||
if path == "binary.bin":
|
||||
raise ValueError("Binary files are not supported by this endpoint.")
|
||||
return {
|
||||
"workspace_root": "/workspace",
|
||||
"file": {
|
||||
"path": path,
|
||||
"size": 12,
|
||||
"line_count": 3,
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"content": "line1\nline2",
|
||||
"truncated": True,
|
||||
"is_binary": False,
|
||||
},
|
||||
}
|
||||
|
||||
def search_workspace(self, *, query, path=None, limit=50, include_hidden=False, regex=False):
|
||||
if query == "bad":
|
||||
raise ValueError("bad query")
|
||||
return {
|
||||
"workspace_root": "/workspace",
|
||||
"query": query,
|
||||
"path": path or ".",
|
||||
"matches": [{"path": "src/app.py", "line": 3, "content": "needle here"}],
|
||||
"truncated": False,
|
||||
"scanned_files": 1,
|
||||
}
|
||||
|
||||
def diff_checkpoint(self, *, checkpoint_id, path=None):
|
||||
if checkpoint_id == "missing":
|
||||
raise FileNotFoundError("missing checkpoint")
|
||||
return {
|
||||
"workspace_root": "/workspace",
|
||||
"working_dir": "/workspace",
|
||||
"checkpoint_id": checkpoint_id,
|
||||
"stat": "1 file changed",
|
||||
"diff": "@@ -1 +1 @@",
|
||||
}
|
||||
|
||||
def list_checkpoints(self, *, path=None):
|
||||
return {
|
||||
"workspace_root": "/workspace",
|
||||
"working_dir": "/workspace",
|
||||
"checkpoints": [{"hash": "abc123", "short_hash": "abc123", "reason": "auto"}],
|
||||
}
|
||||
|
||||
def rollback(self, *, checkpoint_id, path=None, file_path=None):
|
||||
if checkpoint_id == "missing":
|
||||
raise FileNotFoundError("missing checkpoint")
|
||||
payload = {"checkpoint_id": checkpoint_id, "path": path, "file_path": file_path}
|
||||
self.rollback_requests.append(payload)
|
||||
return {"success": True, "restored_to": checkpoint_id[:8], "file": file_path}
|
||||
|
||||
def list_processes(self):
|
||||
return {
|
||||
"processes": [
|
||||
{
|
||||
"session_id": "proc_123",
|
||||
"command": "pytest",
|
||||
"status": "running",
|
||||
"pid": 42,
|
||||
"cwd": "/workspace",
|
||||
"output_preview": "running",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
def get_process_log(self, process_id, *, offset=0, limit=200):
|
||||
if process_id == "missing":
|
||||
raise FileNotFoundError("missing process")
|
||||
return {
|
||||
"session_id": process_id,
|
||||
"status": "running",
|
||||
"output": "a\nb",
|
||||
"total_lines": 2,
|
||||
"showing": "2 lines",
|
||||
}
|
||||
|
||||
def kill_process(self, process_id):
|
||||
if process_id == "missing":
|
||||
raise FileNotFoundError("missing process")
|
||||
self.killed.append(process_id)
|
||||
return {"status": "killed", "session_id": process_id}
|
||||
|
||||
|
||||
class TestWorkspaceApi:
|
||||
@staticmethod
|
||||
async def _make_client(service: FakeWorkspaceService) -> TestClient:
|
||||
app = web.Application()
|
||||
app[WORKSPACE_SERVICE_APP_KEY] = service
|
||||
register_web_console_routes(app)
|
||||
client = TestClient(TestServer(app))
|
||||
await client.start_server()
|
||||
return client
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_endpoints_return_structured_payloads(self):
|
||||
client = await self._make_client(FakeWorkspaceService())
|
||||
try:
|
||||
tree_resp = await client.get("/api/gui/workspace/tree?path=src&depth=1")
|
||||
assert tree_resp.status == 200
|
||||
tree_payload = await tree_resp.json()
|
||||
assert tree_payload["ok"] is True
|
||||
assert tree_payload["tree"]["path"] == "src"
|
||||
|
||||
file_resp = await client.get("/api/gui/workspace/file?path=README.md&offset=2&limit=2")
|
||||
assert file_resp.status == 200
|
||||
file_payload = await file_resp.json()
|
||||
assert file_payload["file"]["path"] == "README.md"
|
||||
assert file_payload["file"]["offset"] == 2
|
||||
assert file_payload["file"]["limit"] == 2
|
||||
|
||||
search_resp = await client.get("/api/gui/workspace/search?query=needle")
|
||||
assert search_resp.status == 200
|
||||
search_payload = await search_resp.json()
|
||||
assert search_payload["matches"][0]["path"] == "src/app.py"
|
||||
|
||||
diff_resp = await client.get("/api/gui/workspace/diff?checkpoint_id=abc123")
|
||||
assert diff_resp.status == 200
|
||||
diff_payload = await diff_resp.json()
|
||||
assert diff_payload["checkpoint_id"] == "abc123"
|
||||
assert "@@" in diff_payload["diff"]
|
||||
|
||||
checkpoints_resp = await client.get("/api/gui/workspace/checkpoints")
|
||||
assert checkpoints_resp.status == 200
|
||||
checkpoints_payload = await checkpoints_resp.json()
|
||||
assert checkpoints_payload["checkpoints"][0]["hash"] == "abc123"
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rollback_and_process_endpoints(self):
|
||||
service = FakeWorkspaceService()
|
||||
client = await self._make_client(service)
|
||||
try:
|
||||
rollback_resp = await client.post(
|
||||
"/api/gui/workspace/rollback",
|
||||
json={"checkpoint_id": "abc123", "file_path": "src/app.py"},
|
||||
)
|
||||
assert rollback_resp.status == 200
|
||||
rollback_payload = await rollback_resp.json()
|
||||
assert rollback_payload["ok"] is True
|
||||
assert rollback_payload["result"]["restored_to"] == "abc123"
|
||||
assert service.rollback_requests[0]["file_path"] == "src/app.py"
|
||||
|
||||
list_resp = await client.get("/api/gui/processes")
|
||||
assert list_resp.status == 200
|
||||
list_payload = await list_resp.json()
|
||||
assert list_payload["processes"][0]["session_id"] == "proc_123"
|
||||
|
||||
log_resp = await client.get("/api/gui/processes/proc_123/log?offset=0&limit=10")
|
||||
assert log_resp.status == 200
|
||||
log_payload = await log_resp.json()
|
||||
assert log_payload["session_id"] == "proc_123"
|
||||
assert log_payload["total_lines"] == 2
|
||||
|
||||
kill_resp = await client.post("/api/gui/processes/proc_123/kill")
|
||||
assert kill_resp.status == 200
|
||||
kill_payload = await kill_resp.json()
|
||||
assert kill_payload["result"]["status"] == "killed"
|
||||
assert service.killed == ["proc_123"]
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workspace_api_returns_structured_errors(self):
|
||||
client = await self._make_client(FakeWorkspaceService())
|
||||
try:
|
||||
missing_path_resp = await client.get("/api/gui/workspace/file")
|
||||
assert missing_path_resp.status == 400
|
||||
assert (await missing_path_resp.json())["error"]["code"] == "missing_path"
|
||||
|
||||
invalid_depth_resp = await client.get("/api/gui/workspace/tree?depth=abc")
|
||||
assert invalid_depth_resp.status == 400
|
||||
assert (await invalid_depth_resp.json())["error"]["code"] == "invalid_path"
|
||||
|
||||
missing_checkpoint_resp = await client.get("/api/gui/workspace/diff")
|
||||
assert missing_checkpoint_resp.status == 400
|
||||
assert (await missing_checkpoint_resp.json())["error"]["code"] == "missing_checkpoint_id"
|
||||
|
||||
invalid_json_resp = await client.post(
|
||||
"/api/gui/workspace/rollback",
|
||||
data="not json",
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
assert invalid_json_resp.status == 400
|
||||
assert (await invalid_json_resp.json())["error"]["code"] == "invalid_json"
|
||||
|
||||
missing_process_resp = await client.get("/api/gui/processes/missing/log")
|
||||
assert missing_process_resp.status == 404
|
||||
missing_process_payload = await missing_process_resp.json()
|
||||
assert missing_process_payload["error"]["code"] == "process_not_found"
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
|
||||
class TestWorkspaceService:
|
||||
def test_workspace_service_reads_tree_file_and_searches(self, tmp_path: Path):
|
||||
class StubCheckpointManager:
|
||||
def list_checkpoints(self, working_dir):
|
||||
return []
|
||||
|
||||
def get_working_dir_for_path(self, file_path):
|
||||
return str(tmp_path)
|
||||
|
||||
def diff(self, working_dir, commit_hash):
|
||||
return {"success": False, "error": "unused"}
|
||||
|
||||
def restore(self, working_dir, commit_hash, file_path=None):
|
||||
return {"success": False, "error": "unused"}
|
||||
|
||||
class StubProcessRegistry:
|
||||
def list_sessions(self):
|
||||
return []
|
||||
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "app.py").write_text("alpha\nneedle value\nomega\n", encoding="utf-8")
|
||||
(tmp_path / "README.md").write_text("one\ntwo\nthree\n", encoding="utf-8")
|
||||
|
||||
service = WorkspaceService(
|
||||
workspace_root=tmp_path,
|
||||
checkpoint_manager=StubCheckpointManager(),
|
||||
process_registry=StubProcessRegistry(),
|
||||
)
|
||||
|
||||
tree = service.get_tree(path="src", depth=1)
|
||||
assert tree["tree"]["type"] == "directory"
|
||||
assert tree["tree"]["children"][0]["path"] == "src/app.py"
|
||||
|
||||
file_payload = service.get_file(path="README.md", offset=2, limit=2)
|
||||
assert file_payload["file"]["content"] == "two\nthree"
|
||||
assert file_payload["file"]["truncated"] is False
|
||||
|
||||
search_payload = service.search_workspace(query="needle")
|
||||
assert search_payload["matches"] == [{"path": "src/app.py", "line": 2, "content": "needle value"}]
|
||||
27
web_console/CHANGELOG.md
Normal file
27
web_console/CHANGELOG.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to the Hermes Web Console will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **Missions Kanban Board**: New `/missions` overarching route providing an intuitive HTML5 drag-and-drop interface for managing agent tasks with Backlog, In Progress, Review, and Done columns.
|
||||
- **Dashboard Command Center**: Live-polling overarching global interface tracking CPU limits, host memory footprint, active Cron Jobs, and background operations in real-time.
|
||||
- **CLI Session Bridge**: Sessions viewer now imports and segregates interactions made natively in the CLI vs the Web UI via SQLite reads.
|
||||
- **Rich Vision Input**: Added glow-visualized drag-and-drop dropzones over the main chat composer to securely facilitate image context streaming.
|
||||
- **Workspace File @Mentions**: Introduced an elegant native popup autocomplete inside the chat composer. Type `@` to select local workspace files to be injected efficiently into context.
|
||||
- **Portable Mode (Backend Agnostic)**: The UI now degrades gracefully when the Hermes core backend is down, exposing a red health banner instead of crashing the interface with 500s.
|
||||
- **PWA Installation**: Fully initialized `manifest.json`, local `<link>` tags, generated icon sets, and an offline-ready `sw.js` Service Worker to run Hermes natively on any Desktop or device.
|
||||
- **Universal CLI Command Parity**: Added full backend support and chat component slash dispatching for `/fast`, `/yolo`, `/reasoning`, and `/verbose` tracking core CLI parameters seamlessly.
|
||||
- **Intelligent Autocomplete Registry**: Exposed the global static command registry to the Web UI via `/api/gui/commands` for unified composer auto-completion.
|
||||
- **Dedicated Command Browser**: Added a new Commands route backed by the shared CLI registry, including usage hints, aliases, and parity badges (`Full`, `Partial`, `CLI only`).
|
||||
- **Expanded Slash Coverage**: Added practical web-console dispatch for `/queue`, `/branch`, `/resume`, `/save`, `/approve`, `/deny`, `/history`, `/config`, `/platforms`, `/image`, `/paste`, `/restart`, `/update`, and `/sethome`.
|
||||
- **Weixin (WeChat) Support**: Config Modal handles `token` and `account_id` structures mirroring the new native Gateway integrations.
|
||||
- **Docker Strategy**: Created standalone `Dockerfile.frontend` and `Dockerfile.backend` setups composed via `docker-compose.yml` to instantly spin up the proxy architectures seamlessly.
|
||||
|
||||
### Fixed
|
||||
- Re-architected Vitest `App.test.tsx` mock server payloads to cleanly yield `commands: []` bypassing fatal `flatMap` undefined array mapping crashes.
|
||||
- Refined command-browser parity labeling so browser-native approximations like `/config`, `/history`, `/platforms`, `/voice`, `/update`, and `/restart` are marked **Partial** instead of overstating full CLI equivalence.
|
||||
- Replaced deprecated `apple-mobile-web-app-capable` meta tags natively inside `index.html`.
|
||||
- Implemented robust `ConnectionProvider` polling states preventing error-log flooding in DevTools when backend connectivity is severed.
|
||||
- Adjusted CSS spacing within `MissionsPage.tsx` using `overflowX: 'auto'` to ensure the fourth boundary column isn't obscured out of frame relative to the Inspector pane.
|
||||
34
web_console/Dockerfile.frontend
Normal file
34
web_console/Dockerfile.frontend
Normal file
@@ -0,0 +1,34 @@
|
||||
# Stage 1: Build
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY --from=build /app/manifest.json /usr/share/nginx/html/manifest.json
|
||||
COPY --from=build /app/sw.js /usr/share/nginx/html/sw.js
|
||||
|
||||
# Nginx config for SPA + reverse proxy to backend
|
||||
RUN echo 'server { \
|
||||
listen 80; \
|
||||
root /usr/share/nginx/html; \
|
||||
index index.html; \
|
||||
location /api/ { \
|
||||
proxy_pass http://hermes-backend:8765; \
|
||||
proxy_http_version 1.1; \
|
||||
proxy_set_header Upgrade $http_upgrade; \
|
||||
proxy_set_header Connection "upgrade"; \
|
||||
proxy_set_header Host $host; \
|
||||
proxy_read_timeout 300s; \
|
||||
} \
|
||||
location / { \
|
||||
try_files $uri $uri/ /index.html; \
|
||||
} \
|
||||
}' > /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
75
web_console/README.md
Normal file
75
web_console/README.md
Normal file
@@ -0,0 +1,75 @@
|
||||
<p align="center">
|
||||
<img src="../assets/banner.png" alt="Hermes Agent" width="100%">
|
||||
</p>
|
||||
|
||||
# Hermes Web Console 🖥️✨
|
||||
|
||||
A highly polished, modern web dashboard for **Hermes Agent**. The Web Console brings the raw power of the Hermes terminal and its core configurability directly to the browser, offering broad practical parity with the CLI while introducing intuitive drag-and-drop workflows for advanced agentic operations.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **Live Streaming Parity**: Connects directly to the core Hermes API Event Stream (`message.assistant.delta`) to offer responsive typewriter streaming without delays.
|
||||
- **Agentic IDE Sandbox**: Inspect live runtime logs and tools, and spawn native `xterm.js` terminal environments inside the drawer.
|
||||
- **Dashboard Command Center**: Real-time observability dashboard streaming CPU, memory, Process, and Cron active metrics directly from the host.
|
||||
- **CLI Session Bridge**: Seamlessly view and interact with CLI terminal sessions and memory straight from the web console.
|
||||
- **Offline Portable Mode**: Fallback to local offline mode with graceful degradation when the backend is unreachable.
|
||||
- **Missions Kanban**: Create, drag-and-drop, and monitor agentic missions on a comprehensive visual board.
|
||||
- **Workspace Integration**: Mentioning files with `@` directly links to your file explorer context. Rich dropzones power native vision multi-modal interactions.
|
||||
- **PWA Support**: Full manifest and service worker deployment for native standalone app-like installations across Desktop and Mobile.
|
||||
- **Visual Configurations**: Avoid editing `config.yaml` manually. Setup complex hierarchies like drag-and-drop ordered **Fallback Providers**, multi-key **Credential Pools**, and isolated Messaging Gateways all from an organized UI.
|
||||
- **Theme Persistence**: Dark, light, or completely custom skins. Changes are natively synchronized with your overarching Hermes profile.
|
||||
- **Syntax Highlighting & Inline Diffs**: Unified, collapsible Git-style file diffs directly inside the chat interface letting you confidently review the agent's file modifications.
|
||||
- **Shared Command Registry**: The composer autocomplete is powered by the same slash-command registry as the Hermes CLI via `/api/gui/commands`.
|
||||
- **Command Browser**: A dedicated Commands page lets you browse canonical commands, aliases, usage hints, and parity badges (`Full`, `Partial`, `CLI only`).
|
||||
- **Browser-Native Slash Flows**: The chat interface now supports practical web versions of many CLI commands including `/queue`, `/branch`, `/resume`, `/save`, `/platforms`, `/image`, `/paste`, `/fast`, `/yolo`, `/reasoning`, and `/verbose`.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
The console acts as a client connected to the Hermes Local API Server.
|
||||
|
||||
Ensure you have your backend running:
|
||||
```bash
|
||||
hermes api start
|
||||
```
|
||||
|
||||
### Running the UI (Development)
|
||||
|
||||
Navigate to the `web_console` directory:
|
||||
|
||||
```bash
|
||||
cd web_console
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
Navigate to `http://localhost:5173` locally. Set your backend URL mapping inside the UI Settings if the API server resides on a custom port or remote network.
|
||||
|
||||
### Building for Production
|
||||
|
||||
Compile the production bundle cleanly:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
The optimized static assets will populate the `/dist` directory automatically compatible with most static web-farm configurations or native integrations back onto the python API router.
|
||||
|
||||
## ⌘ Slash Commands in the Web Console
|
||||
|
||||
The web console supports a large subset of Hermes slash commands directly inside chat. Highlights include:
|
||||
|
||||
- **Session**: `/new`, `/retry`, `/undo`, `/branch`, `/resume`, `/queue`, `/save`
|
||||
- **Config**: `/model`, `/provider`, `/reasoning`, `/verbose`, `/fast`, `/yolo`
|
||||
- **Gateway/Admin**: `/platforms`, `/sethome`, `/restart`, `/update`
|
||||
- **Attachments**: `/image`, `/paste`
|
||||
|
||||
Some commands are intentionally marked **Partial** because browser behavior differs from terminal behavior. For the current source of truth, use the **Commands** page inside the app.
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
- **React.js 18** (Vite Compiler)
|
||||
- **TypeScript** natively integrated for safe schema bindings.
|
||||
- **xterm.js** offering completely native ANSI terminal playback.
|
||||
- **react-markdown** & **PrismJS** for syntax-focused presentation.
|
||||
|
||||
---
|
||||
|
||||
*Part of the NousResearch / Hermes Agent Ecosystem.*
|
||||
BIN
web_console/icons/icon-192.png
Normal file
BIN
web_console/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 371 KiB |
BIN
web_console/icons/icon-512.png
Normal file
BIN
web_console/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 371 KiB |
27
web_console/index.html
Normal file
27
web_console/index.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#818cf8" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="description" content="AI Agent Control Surface — chat, monitor, and manage your Hermes agent." />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<title>Hermes Web Console</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script>
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
26
web_console/manifest.json
Normal file
26
web_console/manifest.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "Hermes Web Console",
|
||||
"short_name": "Hermes",
|
||||
"description": "AI Agent Control Surface — chat, monitor, and manage your Hermes agent from any device.",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0a0a0f",
|
||||
"theme_color": "#818cf8",
|
||||
"orientation": "any",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
}
|
||||
],
|
||||
"categories": ["developer-tools", "productivity"],
|
||||
"prefer_related_applications": false
|
||||
}
|
||||
5328
web_console/package-lock.json
generated
Normal file
5328
web_console/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
web_console/package.json
Normal file
37
web_console/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "hermes-web-console",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"recharts": "^3.8.1",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-math": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"jsdom": "^26.0.0",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.2.0",
|
||||
"vitest": "^3.0.7"
|
||||
}
|
||||
}
|
||||
329
web_console/src/app/App.test.tsx
Normal file
329
web_console/src/app/App.test.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { App } from './App';
|
||||
import { PRIMARY_NAV_ITEMS } from './router';
|
||||
|
||||
class MockEventSource {
|
||||
url: string;
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockEventSource.lastInstance = this;
|
||||
}
|
||||
|
||||
close() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
static lastInstance: MockEventSource | null = null;
|
||||
|
||||
simulateMessage(data: unknown) {
|
||||
const payload = { data: JSON.stringify(data) } as MessageEvent;
|
||||
this.onmessage?.(payload);
|
||||
}
|
||||
}
|
||||
|
||||
describe('App shell', () => {
|
||||
beforeEach(() => {
|
||||
global.EventSource = MockEventSource as unknown as typeof EventSource;
|
||||
MockEventSource.lastInstance = null;
|
||||
window.location.hash = '';
|
||||
localStorage.clear();
|
||||
|
||||
global.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/api/gui/human/pending')) {
|
||||
return new Response(JSON.stringify({ ok: true, pending: [] }), { status: 200 });
|
||||
}
|
||||
if (url.includes('/api/gui/chat/send')) {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, session_id: 'session-live', run_id: 'run-1', status: 'started' }),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (url.includes('/api/gui/user-profile')) {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, user_profile: { target: 'user', enabled: true, entries: ['Likes dark mode.'], entry_count: 1, usage: { text: '1%', percent: 1, current_chars: 16, char_limit: 1375 }, path: '/tmp/USER.md' } }),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (url.includes('/api/gui/memory')) {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, memory: { target: 'memory', enabled: true, entries: ['Test memory entry.'], entry_count: 1, usage: { text: '1%', percent: 1, current_chars: 18, char_limit: 2200 }, path: '/tmp/MEMORY.md' } }),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (url.includes('/api/gui/session-search')) {
|
||||
return new Response(JSON.stringify({ ok: true, results: [] }), { status: 200 });
|
||||
}
|
||||
if (url.includes('/api/gui/skills')) {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, skills: [{ name: 'writing-plans', description: 'Write plans.', source_type: 'builtin' }] }),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (url.includes('/api/gui/cron/jobs')) {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, jobs: [{ job_id: 'cron-1', name: 'Morning summary', schedule: '0 9 * * *', paused: false }] }),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (url.includes('/api/gui/chat/backgrounds')) {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, background_runs: [{ run_id: 'run-1', session_id: 'sess-1', status: 'running', prompt: 'Analyze log files', created_at: Date.now() / 1000 }] }),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (url.includes('/api/gui/workspace/tree')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
tree: {
|
||||
name: 'workspace',
|
||||
path: '.',
|
||||
type: 'directory',
|
||||
children: [{ name: 'src/app.py', path: 'src/app.py', type: 'file' }]
|
||||
}
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (url.includes('/api/gui/workspace/file')) {
|
||||
return new Response(JSON.stringify({ ok: true, path: 'src/app.py', content: 'def main():\n return 1\n' }), { status: 200 });
|
||||
}
|
||||
if (url.includes('/api/gui/workspace/diff')) {
|
||||
return new Response(JSON.stringify({ ok: true, diff: '--- a\n+++ b\n@@\n-old\n+new' }), { status: 200 });
|
||||
}
|
||||
if (url.includes('/api/gui/workspace/checkpoints')) {
|
||||
return new Response(JSON.stringify({ ok: true, checkpoints: [{ checkpoint_id: 'cp-1', label: 'before patch' }] }), { status: 200 });
|
||||
}
|
||||
if (url.includes('/api/gui/processes')) {
|
||||
return new Response(JSON.stringify({ ok: true, processes: [{ process_id: 'proc-1', status: 'running' }] }), { status: 200 });
|
||||
}
|
||||
if (url.includes('/api/gui/gateway/platforms')) {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, platforms: [{ key: 'telegram', label: 'Telegram', runtime_state: 'connected', enabled: true, configured: true }] }),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (url.includes('/api/gui/gateway/pairing')) {
|
||||
return new Response(JSON.stringify({ ok: true, pairings: [] }), { status: 200 });
|
||||
}
|
||||
if (url.includes('/api/gui/gateway/overview')) {
|
||||
return new Response(JSON.stringify({ ok: true, overview: { summary: { platform_count: 5, connected_platforms: 2, enabled_platforms: 3 } } }), { status: 200 });
|
||||
}
|
||||
if (url.includes('/api/gui/settings')) {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, settings: { model: 'hermes-agent', provider: 'openai-codex', browser_mode: 'local', tts_provider: 'edge' } }),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (url.includes('/api/gui/logs')) {
|
||||
return new Response(JSON.stringify({ ok: true, lines: ['[info] hello', '[info] world'] }), { status: 200 });
|
||||
}
|
||||
if (url.includes('/api/gui/sessions/') && url.endsWith('/transcript')) {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, items: [{ role: 'user', content: 'hello' }, { role: 'assistant', content: 'hi' }] }),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (url.includes('/api/gui/sessions/')) {
|
||||
return new Response(JSON.stringify({ ok: true, session: { title: 'Session One', recap: { preview: 'Loaded from API' } } }), {
|
||||
status: 200
|
||||
});
|
||||
}
|
||||
if (url.includes('/api/gui/sessions')) {
|
||||
return new Response(
|
||||
JSON.stringify({ ok: true, sessions: [{ session_id: 'sess-1', title: 'Session One', source: 'cli', last_active: 123 }] }),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
if (url.includes('/api/gui/commands')) {
|
||||
return new Response(JSON.stringify({ ok: true, commands: [
|
||||
{ name: 'help', description: 'Show available commands', category: 'Info', aliases: [], names: ['help'], args_hint: '', subcommands: [], cli_only: false, gateway_only: false },
|
||||
{ name: 'model', description: 'Switch model for this session', category: 'Configuration', aliases: [], names: ['model'], args_hint: '[model]', subcommands: [], cli_only: false, gateway_only: false },
|
||||
{ name: 'queue', description: 'Queue a prompt for the next turn', category: 'Session', aliases: ['q'], names: ['queue', 'q'], args_hint: '<prompt>', subcommands: [], cli_only: false, gateway_only: false }
|
||||
] }), { status: 200 });
|
||||
}
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
});
|
||||
|
||||
it('renders the primary navigation items', () => {
|
||||
render(<App />);
|
||||
|
||||
const nav = screen.getByRole('navigation', { name: /Primary navigation/i });
|
||||
for (const item of PRIMARY_NAV_ITEMS) {
|
||||
expect(within(nav).getByRole('button', { name: new RegExp(item, 'i') })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders the chat page by default', () => {
|
||||
render(<App />);
|
||||
|
||||
expect(screen.getByLabelText('Transcript')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Composer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches route content when navigating to Sessions', async () => {
|
||||
render(<App />);
|
||||
|
||||
const nav = screen.getByRole('navigation', { name: /Primary navigation/i });
|
||||
fireEvent.click(within(nav).getByRole('button', { name: /Sessions/i }));
|
||||
|
||||
expect((await screen.findAllByText('Session One')).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('switches route content when navigating to Workspace', async () => {
|
||||
render(<App />);
|
||||
|
||||
const nav = screen.getByRole('navigation', { name: /Primary navigation/i });
|
||||
fireEvent.click(within(nav).getByRole('button', { name: /Workspace/i }));
|
||||
|
||||
expect(await screen.findByLabelText('File tree')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Terminal panel')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Process panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches route content when navigating to Memory, Skills, and Automations', async () => {
|
||||
render(<App />);
|
||||
const nav = screen.getByRole('navigation', { name: /Primary navigation/i });
|
||||
|
||||
fireEvent.click(within(nav).getByRole('button', { name: /Memory/i }));
|
||||
expect(await screen.findByText('Test memory entry.')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(nav).getByRole('button', { name: /Skills/i }));
|
||||
|
||||
const installedTab = await screen.findByRole('button', { name: /Installed & Local/i });
|
||||
fireEvent.click(installedTab);
|
||||
|
||||
expect(await screen.findByText(/writing-plans/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(nav).getByRole('button', { name: /Background Jobs/i }));
|
||||
expect(await screen.findByText(/Analyze log files/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('switches modal tabs when navigating inside Control Center', async () => {
|
||||
render(<App />);
|
||||
|
||||
// Open Control Center first via title
|
||||
fireEvent.click(screen.getByTitle('Control Center'));
|
||||
|
||||
// By default Settings form should be visible
|
||||
expect(await screen.findByLabelText('Settings form')).toBeInTheDocument();
|
||||
|
||||
// Switch to Gateway tab
|
||||
fireEvent.click(screen.getByRole('button', { name: /Messaging Gateway/i }));
|
||||
expect(await screen.findByText(/Gateway Platforms/i)).toBeInTheDocument();
|
||||
|
||||
// Switch to Automations tab
|
||||
fireEvent.click(screen.getByRole('button', { name: /Automations/i }));
|
||||
expect(await screen.findByText(/Morning summary/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens SSE after sending a message and receives streaming events into transcript', async () => {
|
||||
render(<App />);
|
||||
|
||||
// SSE is not created on mount; it opens after a send that returns a real session id.
|
||||
const prompt = screen.getByPlaceholderText(/Message Hermes.../i);
|
||||
await act(async () => {
|
||||
fireEvent.change(prompt, { target: { value: 'Hello from test' } });
|
||||
fireEvent.submit(screen.getByLabelText('Composer'));
|
||||
});
|
||||
|
||||
// Wait for send to complete (Hermes is thinking indicator appears after POST resolves)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Hermes is thinking/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// SSE should now be open
|
||||
const es = MockEventSource.lastInstance;
|
||||
expect(es).not.toBeNull();
|
||||
expect(es!.url).toContain('/api/gui/stream/session/session-live');
|
||||
|
||||
await act(async () => {
|
||||
es!.simulateMessage({
|
||||
type: 'tool.started',
|
||||
session_id: 'session-live',
|
||||
run_id: 'run-1',
|
||||
payload: { tool_name: 'search_files', preview: 'search_files(pattern=*.py)' },
|
||||
ts: Date.now() / 1000
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/search_files\(pattern=\*\.py\)/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
es!.simulateMessage({
|
||||
type: 'message.assistant.completed',
|
||||
session_id: 'session-live',
|
||||
run_id: 'run-1',
|
||||
payload: { content: 'Hermes completed analysis.' },
|
||||
ts: Date.now() / 1000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles the inspector and drawer from the top bar', () => {
|
||||
render(<App />);
|
||||
|
||||
const inspector = screen.getByLabelText('Inspector');
|
||||
const drawer = screen.getByLabelText('Bottom drawer');
|
||||
|
||||
fireEvent.click(screen.getByTitle('Inspector Panel'));
|
||||
expect(inspector.className).toContain('inspector-hidden');
|
||||
|
||||
fireEvent.click(screen.getByTitle('Terminal Drawer'));
|
||||
expect(drawer.className).not.toContain('bottom-drawer-hidden');
|
||||
});
|
||||
|
||||
it('changes inspector tabs', () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'tools' }));
|
||||
expect(screen.getByRole('button', { name: 'tools' }).className).toContain('panel-tab-active');
|
||||
});
|
||||
|
||||
it('opens the command palette and prefills slash commands into chat', async () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByTitle(/Command Palette/i));
|
||||
expect(await screen.findByText(/Command Palette/i)).toBeInTheDocument();
|
||||
|
||||
const search = screen.getByPlaceholderText(/Search routes, actions, or commands/i);
|
||||
fireEvent.change(search, { target: { value: 'model' } });
|
||||
|
||||
const runModelButton = screen.getAllByRole('button').find((button) => button.textContent?.includes('Run /model'));
|
||||
expect(runModelButton).toBeTruthy();
|
||||
fireEvent.click(runModelButton as HTMLButtonElement);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Composer')).toBeInTheDocument();
|
||||
const textarea = document.querySelector('#chat-prompt') as HTMLTextAreaElement | null;
|
||||
expect(textarea?.value).toBe('/model ');
|
||||
});
|
||||
});
|
||||
|
||||
it('can pin actions from the command palette and show them in the pinned section', async () => {
|
||||
render(<App />);
|
||||
|
||||
fireEvent.click(screen.getByTitle(/Command Palette/i));
|
||||
expect(await screen.findByText(/Command Palette/i)).toBeInTheDocument();
|
||||
|
||||
const openUsageLabel = await screen.findByText(/Open Usage/i);
|
||||
const openUsage = openUsageLabel.closest('[role="button"]') as HTMLElement;
|
||||
const pinButton = within(openUsage).getByRole('button', { name: /☆ pin/i });
|
||||
fireEvent.click(pinButton);
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', { name: '✕' })[0]);
|
||||
fireEvent.click(screen.getByTitle(/Command Palette/i));
|
||||
|
||||
expect(await screen.findByText('Pinned')).toBeInTheDocument();
|
||||
const pinnedHeading = screen.getByText('Pinned');
|
||||
const pinnedSection = pinnedHeading.parentElement as HTMLElement;
|
||||
expect(within(pinnedSection).getByText(/Open Usage/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
16
web_console/src/app/App.tsx
Normal file
16
web_console/src/app/App.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { AppShell } from '../components/layout/AppShell';
|
||||
import { ErrorBoundary } from '../components/shared/ErrorBoundary';
|
||||
import { ConnectionProvider } from '../lib/connectionContext';
|
||||
import { ConnectGate } from '../components/connect/ConnectScreen';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ConnectionProvider>
|
||||
<ConnectGate>
|
||||
<AppShell />
|
||||
</ConnectGate>
|
||||
</ConnectionProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
5
web_console/src/app/providers.tsx
Normal file
5
web_console/src/app/providers.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
export function AppProviders({ children }: PropsWithChildren) {
|
||||
return children;
|
||||
}
|
||||
69
web_console/src/app/router.tsx
Normal file
69
web_console/src/app/router.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { RouteDefinition } from '../lib/types';
|
||||
|
||||
export const ROUTES: readonly RouteDefinition[] = [
|
||||
{
|
||||
id: 'chat',
|
||||
label: 'Chat',
|
||||
description: 'Talk to Hermes and watch tool activity.',
|
||||
headline: 'Chat with Hermes',
|
||||
summary: 'Streaming chat, tool events, approvals, and session-aware interaction will live here.'
|
||||
},
|
||||
{
|
||||
id: 'sessions',
|
||||
label: 'Sessions',
|
||||
description: 'Browse, inspect, and resume past work.',
|
||||
headline: 'Session browser',
|
||||
summary: 'Searchable history, transcript previews, exports, and resume actions belong on this page.'
|
||||
},
|
||||
{
|
||||
id: 'workspace',
|
||||
label: 'Workspace',
|
||||
description: 'Inspect files, diffs, and processes.',
|
||||
headline: 'Workspace operations',
|
||||
summary: 'File trees, patches, checkpoints, rollback, and process logs will be surfaced here.'
|
||||
},
|
||||
{
|
||||
id: 'usage',
|
||||
label: 'Usage',
|
||||
description: 'Analytics, token usage, and costs.',
|
||||
headline: 'Usage Insights',
|
||||
summary: 'Track your Hermes API usage.'
|
||||
},
|
||||
{
|
||||
id: 'jobs',
|
||||
label: 'Background Jobs',
|
||||
description: 'Track background agents.',
|
||||
headline: 'Background Jobs',
|
||||
summary: 'Monitor status of dispatched background agents.'
|
||||
},
|
||||
{
|
||||
id: 'skills',
|
||||
label: 'Skills',
|
||||
description: 'Browse, install, and manage skills.',
|
||||
headline: 'Skills Hub',
|
||||
summary: 'Enhance your agent with official and community skills or create your own.'
|
||||
},
|
||||
{
|
||||
id: 'memory',
|
||||
label: 'Memory',
|
||||
description: 'View and edit long-term agent memory.',
|
||||
headline: 'Agent Memory',
|
||||
summary: 'What Hermes remembers about you and your workspace across sessions.'
|
||||
},
|
||||
{
|
||||
id: 'missions',
|
||||
label: 'Missions',
|
||||
description: 'Kanban board for agent tasks.',
|
||||
headline: 'Missions Board',
|
||||
summary: 'Organize and track agent missions with drag-and-drop Kanban columns.'
|
||||
},
|
||||
{
|
||||
id: 'commands',
|
||||
label: 'Commands',
|
||||
description: 'Browse shared Hermes slash commands and parity status.',
|
||||
headline: 'Command Browser',
|
||||
summary: 'See the canonical command registry, aliases, usage hints, and which commands are fully or partially supported in the web console.'
|
||||
}
|
||||
] as const;
|
||||
|
||||
export const PRIMARY_NAV_ITEMS = ROUTES.map((item) => item.label);
|
||||
863
web_console/src/app/theme.css
Normal file
863
web_console/src/app/theme.css
Normal file
@@ -0,0 +1,863 @@
|
||||
:root {
|
||||
--bg-dark: #09090b;
|
||||
--bg-surface: #18181b;
|
||||
--bg-surface-elevated: rgba(39, 39, 42, 0.6);
|
||||
--border-light: rgba(255, 255, 255, 0.08);
|
||||
--border-glow: rgba(129, 140, 248, 0.4);
|
||||
--accent-primary: #818cf8;
|
||||
--accent-secondary: #c084fc;
|
||||
--text-main: #f4f4f5;
|
||||
--text-muted: #a1a1aa;
|
||||
--topbar-height: 56px;
|
||||
color-scheme: dark;
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
--bg-dark: #f8fafc;
|
||||
--bg-surface: #ffffff;
|
||||
--bg-surface-elevated: rgba(255, 255, 255, 0.9);
|
||||
--border-light: rgba(0, 0, 0, 0.1);
|
||||
--border-glow: rgba(99, 102, 241, 0.3);
|
||||
--accent-primary: #6366f1;
|
||||
--accent-secondary: #8b5cf6;
|
||||
--text-main: #0f172a;
|
||||
--text-muted: #64748b;
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-dark);
|
||||
}
|
||||
|
||||
button,
|
||||
textarea,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
APP SHELL
|
||||
============================================================ */
|
||||
|
||||
.app-shell {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
TOPBAR — unified header with brand + nav + actions
|
||||
============================================================ */
|
||||
|
||||
.topbar {
|
||||
height: var(--topbar-height);
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
background: rgba(9, 9, 11, 0.92);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar-logo {
|
||||
font-size: 1.3rem;
|
||||
filter: drop-shadow(0 0 6px rgba(129, 140, 248, 0.5));
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.topbar-version {
|
||||
font-size: 0.7rem;
|
||||
color: #52525b;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.topbar-update-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
padding: 0 2px;
|
||||
font-size: 0.75rem;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.topbar-profile-badge {
|
||||
padding: 2px 8px;
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
color: #38bdf8;
|
||||
border-radius: 6px;
|
||||
font-weight: 600;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* Navigation tabs in header */
|
||||
.topbar-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 10px;
|
||||
padding: 3px;
|
||||
overflow-x: auto;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
max-width: 800px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
|
||||
.topbar-nav::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.topbar-nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar-nav-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.topbar-nav-item--active {
|
||||
background: rgba(129, 140, 248, 0.15);
|
||||
color: var(--accent-primary);
|
||||
box-shadow: 0 1px 8px rgba(129, 140, 248, 0.1);
|
||||
}
|
||||
|
||||
.topbar-nav-icon {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.topbar-nav-label {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.topbar-action-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.05rem;
|
||||
padding: 7px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.topbar-action-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
LAYOUT BODY — content + inspector (no sidebar)
|
||||
============================================================ */
|
||||
|
||||
.layout-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
CONTENT AREA
|
||||
============================================================ */
|
||||
|
||||
.content {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
INSPECTOR PANEL
|
||||
============================================================ */
|
||||
|
||||
.inspector {
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
padding: 16px;
|
||||
background: rgba(24, 24, 27, 0.5);
|
||||
border-left: 1px solid var(--border-light);
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.inspector-hidden {
|
||||
width: 40px !important;
|
||||
padding: 16px 4px !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
BOTTOM DRAWER
|
||||
============================================================ */
|
||||
|
||||
.bottom-drawer {
|
||||
margin: 0 12px 12px;
|
||||
padding: 16px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-light);
|
||||
background: var(--bg-surface-elevated);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.bottom-drawer-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
SHARED CARD STYLES
|
||||
============================================================ */
|
||||
|
||||
.hero-card,
|
||||
.panel-body,
|
||||
.chat-panel,
|
||||
.composer-card,
|
||||
.side-card,
|
||||
.run-status,
|
||||
.message-card,
|
||||
.sessions-card,
|
||||
.workspace-card,
|
||||
.feature-card {
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--border-light);
|
||||
background: var(--bg-surface-elevated);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.chat-panel,
|
||||
.composer-card,
|
||||
.side-card,
|
||||
.run-status,
|
||||
.sessions-card,
|
||||
.workspace-card,
|
||||
.feature-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
PANEL TABS
|
||||
============================================================ */
|
||||
|
||||
.panel-tabs {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel-tab {
|
||||
padding: 6px 10px;
|
||||
text-transform: capitalize;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-weight: 500;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.panel-tab:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.panel-tab-active {
|
||||
background: rgba(129, 140, 248, 0.15) !important;
|
||||
border-color: var(--border-glow) !important;
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
MUTED TEXT
|
||||
============================================================ */
|
||||
|
||||
.topbar p,
|
||||
.hero-card p,
|
||||
.panel-body p,
|
||||
.chat-panel-header p,
|
||||
.message-card-content,
|
||||
.side-card p,
|
||||
.composer-label,
|
||||
.sessions-card-header p,
|
||||
.session-row span,
|
||||
.session-preview-line,
|
||||
.workspace-card-header p,
|
||||
.workspace-static-row,
|
||||
.workspace-pre,
|
||||
.feature-card-header p,
|
||||
.feature-list-item span,
|
||||
.settings-field span {
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
ICON BUTTON (generic)
|
||||
============================================================ */
|
||||
|
||||
.icon-button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.1rem;
|
||||
padding: 7px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
NAV / SESSION / WORKSPACE ROW BUTTONS
|
||||
============================================================ */
|
||||
|
||||
.nav-button,
|
||||
.session-row,
|
||||
.workspace-row,
|
||||
.workspace-static-row {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 12px 16px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.session-row:hover,
|
||||
.workspace-row:hover {
|
||||
background: var(--bg-surface-elevated);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.workspace-static-row,
|
||||
.feature-list-item {
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 14px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.feature-list-item:hover {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.nav-button-active,
|
||||
.panel-tab-active,
|
||||
.session-row-active,
|
||||
.workspace-row-active {
|
||||
background: rgba(129, 140, 248, 0.15) !important;
|
||||
border-color: var(--border-glow) !important;
|
||||
color: var(--accent-primary);
|
||||
box-shadow: 0 0 12px rgba(129, 140, 248, 0.08);
|
||||
}
|
||||
|
||||
/* Primary action */
|
||||
.primary-action {
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)) !important;
|
||||
color: white !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 4px 14px rgba(129, 140, 248, 0.2) !important;
|
||||
}
|
||||
|
||||
.primary-action:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
STATUS PILL
|
||||
============================================================ */
|
||||
|
||||
.status-pill {
|
||||
border: 1px solid rgba(129, 140, 248, 0.3);
|
||||
background: rgba(129, 140, 248, 0.1);
|
||||
color: var(--accent-primary);
|
||||
border-radius: 999px;
|
||||
padding: 4px 12px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--accent-primary);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
CHAT LAYOUT
|
||||
============================================================ */
|
||||
|
||||
.chat-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chat-main-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.chat-main-column .chat-panel {
|
||||
flex: 1;
|
||||
min-height: 300px;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-main-column .chat-panel .transcript-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.chat-side-column {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
SESSIONS LAYOUT
|
||||
============================================================ */
|
||||
|
||||
.sessions-layout,
|
||||
.feature-page-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(240px, 0.8fr);
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
WORKSPACE LAYOUT — fixed overlapping issue
|
||||
============================================================ */
|
||||
|
||||
.workspace-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 240px minmax(0, 1fr) 280px;
|
||||
gap: 16px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.workspace-card {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workspace-card.file-viewer-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
MESSAGES
|
||||
============================================================ */
|
||||
|
||||
.transcript-list,
|
||||
.event-list,
|
||||
.session-preview-transcript {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.message-card {
|
||||
padding: 14px 18px;
|
||||
border-radius: 14px;
|
||||
margin-bottom: 2px;
|
||||
animation: messageFadeIn 0.25s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes messageFadeIn {
|
||||
0% { opacity: 0; transform: translateY(8px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.message-card-title {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
font-weight: 600;
|
||||
color: var(--accent-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.message-card-tool {
|
||||
background: rgba(25, 35, 66, 0.96);
|
||||
}
|
||||
|
||||
.message-card-system {
|
||||
background: rgba(245, 158, 11, 0.05);
|
||||
border-color: rgba(245, 158, 11, 0.15);
|
||||
}
|
||||
|
||||
.message-card-system .message-card-title {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.message-card-user {
|
||||
background: rgba(129, 140, 248, 0.05);
|
||||
border-color: rgba(129, 140, 248, 0.15);
|
||||
}
|
||||
|
||||
.message-card-user .message-card-title {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
COMPOSER
|
||||
============================================================ */
|
||||
|
||||
.composer-toolbar,
|
||||
.composer-actions,
|
||||
.inline-actions,
|
||||
.run-status,
|
||||
.session-preview-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.composer-textarea,
|
||||
.settings-field input {
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-light);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: var(--text-main);
|
||||
padding: 14px;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.composer-textarea:focus,
|
||||
.settings-field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 2px rgba(129, 140, 248, 0.12);
|
||||
}
|
||||
|
||||
.composer-textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
margin: 10px 0 14px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
MISC
|
||||
============================================================ */
|
||||
|
||||
.event-list li,
|
||||
.session-preview-line {
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(122, 162, 255, 0.06);
|
||||
}
|
||||
|
||||
.workspace-pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
FILE VIEWER — scrollable, editable code panel
|
||||
============================================================ */
|
||||
|
||||
.file-viewer-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.6;
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.file-viewer-gutter {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 0;
|
||||
text-align: right;
|
||||
user-select: none;
|
||||
color: rgba(148, 163, 184, 0.35);
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||||
min-width: 44px;
|
||||
}
|
||||
|
||||
.file-viewer-line-num {
|
||||
padding: 0 10px;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.file-viewer-pre {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
color: #e2e8f0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-viewer-textarea {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
padding: 12px 16px;
|
||||
white-space: pre;
|
||||
overflow-x: auto;
|
||||
color: #e2e8f0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
resize: none;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.file-viewer-textarea:focus {
|
||||
background: rgba(129, 140, 248, 0.03);
|
||||
}
|
||||
|
||||
.file-viewer-btn {
|
||||
padding: 4px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(129, 140, 248, 0.2);
|
||||
background: rgba(129, 140, 248, 0.08);
|
||||
color: #a5b4fc;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.file-viewer-btn:hover {
|
||||
background: rgba(129, 140, 248, 0.18);
|
||||
border-color: rgba(129, 140, 248, 0.4);
|
||||
}
|
||||
|
||||
.file-viewer-btn-save {
|
||||
background: rgba(74, 222, 128, 0.1);
|
||||
border-color: rgba(74, 222, 128, 0.25);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.file-viewer-btn-save:hover {
|
||||
background: rgba(74, 222, 128, 0.2);
|
||||
border-color: rgba(74, 222, 128, 0.45);
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.settings-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-panel-header,
|
||||
.sessions-card-header,
|
||||
.workspace-card-header,
|
||||
.feature-card-header {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.session-list,
|
||||
.workspace-list,
|
||||
.feature-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
SCROLLBAR
|
||||
============================================================ */
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
SELECT STYLING (for reasoning effort dropdown)
|
||||
============================================================ */
|
||||
|
||||
select option {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
STREAMING CURSOR
|
||||
============================================================ */
|
||||
|
||||
@keyframes blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes fadeInDown {
|
||||
0% { opacity: 0; transform: translateY(-6px); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
121
web_console/src/components/browser/BrowserControlPanel.tsx
Normal file
121
web_console/src/components/browser/BrowserControlPanel.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiClient } from '../../lib/api';
|
||||
|
||||
interface BrowserStatus {
|
||||
connected?: boolean;
|
||||
cdp_url?: string;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
interface BrowserStatusResponse {
|
||||
ok: boolean;
|
||||
browser?: BrowserStatus;
|
||||
}
|
||||
|
||||
export function BrowserControlPanel() {
|
||||
const [status, setStatus] = useState<BrowserStatus>({});
|
||||
const [cdpUrl, setCdpUrl] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const refreshStatus = async () => {
|
||||
try {
|
||||
const res = await apiClient.get<BrowserStatusResponse>('/browser/status');
|
||||
if (res.ok && res.browser) setStatus(res.browser);
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refreshStatus();
|
||||
const pollId = setInterval(refreshStatus, 5000);
|
||||
return () => clearInterval(pollId);
|
||||
}, []);
|
||||
|
||||
const handleConnect = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const body = cdpUrl.trim() ? { cdp_url: cdpUrl.trim() } : {};
|
||||
await apiClient.post('/browser/connect', body);
|
||||
await refreshStatus();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiClient.post('/browser/disconnect', {});
|
||||
await refreshStatus();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isConnected = status.connected === true;
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
padding: '8px 16px', borderRadius: '10px', cursor: 'pointer', fontSize: '0.85rem',
|
||||
border: '1px solid rgba(255,255,255,0.1)', background: 'rgba(255,255,255,0.06)',
|
||||
transition: 'all 0.2s',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', padding: '4px' }}>
|
||||
<h3 style={{ margin: 0, color: '#e2e8f0', fontSize: '1rem' }}>🌐 Browser</h3>
|
||||
|
||||
{/* Status indicator */}
|
||||
<div style={{
|
||||
padding: '12px 14px', borderRadius: '12px',
|
||||
background: isConnected ? 'rgba(34, 197, 94, 0.08)' : 'rgba(239, 68, 68, 0.08)',
|
||||
border: `1px solid ${isConnected ? 'rgba(34, 197, 94, 0.2)' : 'rgba(239, 68, 68, 0.2)'}`,
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '1.2rem' }}>{isConnected ? '🟢' : '🔴'}</span>
|
||||
<span style={{ color: isConnected ? '#86efac' : '#fca5a5', fontWeight: 600, fontSize: '0.9rem' }}>
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
{status.mode && (
|
||||
<div style={{ marginTop: '6px', fontSize: '0.8rem', color: '#64748b' }}>
|
||||
Mode: <span style={{ color: '#94a3b8' }}>{status.mode}</span>
|
||||
</div>
|
||||
)}
|
||||
{status.cdp_url && (
|
||||
<div style={{ marginTop: '2px', fontSize: '0.8rem', color: '#64748b' }}>
|
||||
CDP: <code style={{ color: '#a5b4fc', fontSize: '0.75rem' }}>{status.cdp_url}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Connect / Disconnect */}
|
||||
{!isConnected ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={cdpUrl}
|
||||
onChange={(e) => setCdpUrl(e.target.value)}
|
||||
placeholder="CDP URL (optional)"
|
||||
style={{
|
||||
padding: '8px 12px', borderRadius: '10px',
|
||||
border: '1px solid rgba(129,140,248,0.2)',
|
||||
background: 'rgba(0,0,0,0.2)', color: 'white', fontSize: '0.85rem',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button" onClick={handleConnect} disabled={loading}
|
||||
style={{ ...btnStyle, color: '#86efac', borderColor: 'rgba(34,197,94,0.3)', background: 'rgba(34,197,94,0.1)' }}
|
||||
>
|
||||
{loading ? 'Connecting…' : '🔌 Connect'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button" onClick={handleDisconnect} disabled={loading}
|
||||
style={{ ...btnStyle, color: '#fca5a5', borderColor: 'rgba(239,68,68,0.3)', background: 'rgba(239,68,68,0.1)' }}
|
||||
>
|
||||
{loading ? 'Disconnecting…' : '⏏ Disconnect'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
web_console/src/components/chat/ApprovalPrompt.tsx
Normal file
57
web_console/src/components/chat/ApprovalPrompt.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
interface ApprovalPromptProps {
|
||||
pending: Array<{ id: string; command?: string }>;
|
||||
onApprove: (id: string, decision: 'once' | 'session' | 'always') => void;
|
||||
onDeny: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ApprovalPrompt({ pending, onApprove, onDeny }: ApprovalPromptProps) {
|
||||
if (pending.length === 0) return null;
|
||||
|
||||
const request = pending[0]; // Process one at a time for clarity
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '140px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgba(239, 68, 68, 0.15)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.3)',
|
||||
boxShadow: '0 12px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(239, 68, 68, 0.1)',
|
||||
padding: '24px',
|
||||
borderRadius: '24px',
|
||||
zIndex: 100,
|
||||
minWidth: '500px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '16px',
|
||||
animation: 'modalScaleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}>
|
||||
<h3 style={{ margin: 0, color: '#f87171', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '1.2rem' }}>⚠️</span> Action Required
|
||||
</h3>
|
||||
<p style={{ margin: 0, color: '#fca5a5', lineHeight: 1.5 }}>
|
||||
Hermes wants to run the following command. Do you approve?
|
||||
</p>
|
||||
|
||||
{request.command && (
|
||||
<pre style={{ margin: 0, padding: '16px', background: 'rgba(0, 0, 0, 0.3)', color: '#f8f8f2', borderRadius: '12px', border: '1px solid rgba(255, 255, 255, 0.1)', overflowX: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-all', fontFamily: 'monospace', fontSize: '14px' }}>
|
||||
{request.command}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '8px', marginTop: '4px' }}>
|
||||
<button onClick={() => onApprove(request.id, 'once')} type="button" style={{ background: 'rgba(239, 68, 68, 0.2)', color: '#fca5a5', border: '1px solid rgba(239, 68, 68, 0.3)', padding: '12px', borderRadius: '14px', cursor: 'pointer', fontWeight: 500, transition: 'all 0.2s' }}>
|
||||
Approve Once
|
||||
</button>
|
||||
<button onClick={() => onApprove(request.id, 'session')} type="button" style={{ background: 'rgba(34, 197, 94, 0.2)', color: '#86efac', border: '1px solid rgba(34, 197, 94, 0.3)', padding: '12px', borderRadius: '14px', cursor: 'pointer', fontWeight: 500, transition: 'all 0.2s' }}>
|
||||
Allow for Session
|
||||
</button>
|
||||
<button onClick={() => onDeny(request.id)} type="button" style={{ background: 'rgba(0, 0, 0, 0.3)', color: '#9ca3af', border: '1px solid rgba(255, 255, 255, 0.1)', padding: '12px', borderRadius: '14px', cursor: 'pointer', fontWeight: 500, transition: 'all 0.2s' }}>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
web_console/src/components/chat/ArtifactViewer.tsx
Normal file
98
web_console/src/components/chat/ArtifactViewer.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import { UiState } from '../../lib/types';
|
||||
import { closeArtifact } from '../../store/uiStore';
|
||||
|
||||
interface ArtifactViewerProps {
|
||||
uiState: UiState;
|
||||
setUiState: React.Dispatch<React.SetStateAction<UiState>>;
|
||||
}
|
||||
|
||||
export function ArtifactViewer({ uiState, setUiState }: ArtifactViewerProps) {
|
||||
if (!uiState.artifactOpen || !uiState.artifactContent) return null;
|
||||
|
||||
const { artifactType, artifactContent } = uiState;
|
||||
|
||||
const handleClose = () => {
|
||||
setUiState(closeArtifact(uiState));
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
background: 'rgba(0,0,0,0.4)',
|
||||
backdropFilter: 'blur(4px)'
|
||||
}}>
|
||||
<div style={{
|
||||
width: '60vw',
|
||||
height: '100vh',
|
||||
background: '#1e1e2e',
|
||||
boxShadow: '-4px 0 24px rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
animation: 'slideInRight 0.3s ease-out'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.1)',
|
||||
background: 'rgba(0,0,0,0.2)'
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: '1.2rem', color: '#e2e8f0', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span>🎨</span> Artifact Canvas
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
background: '#818cf8',
|
||||
color: '#fff',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '4px',
|
||||
textTransform: 'uppercase'
|
||||
}}>{artifactType}</span>
|
||||
</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#94a3b8',
|
||||
fontSize: '1.5rem',
|
||||
cursor: 'pointer',
|
||||
lineHeight: 1
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, padding: '24px', overflow: 'auto', background: '#fff' }}>
|
||||
{(artifactType === 'html' || artifactType === 'svg') ? (
|
||||
<iframe
|
||||
srcDoc={artifactContent}
|
||||
style={{ width: '100%', height: '100%', border: 'none', background: '#fff' }}
|
||||
title="Artifact Preview"
|
||||
/>
|
||||
) : (
|
||||
<div style={{ whiteSpace: 'pre-wrap', color: '#000', fontFamily: 'monospace' }}>
|
||||
{artifactContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes slideInRight {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
web_console/src/components/chat/ClarifyPrompt.tsx
Normal file
66
web_console/src/components/chat/ClarifyPrompt.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
interface ClarifyPromptProps {
|
||||
pending: Array<{ id: string; message?: string }>;
|
||||
onClarify: (id: string, response: string) => void;
|
||||
onDeny: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ClarifyPrompt({ pending, onClarify, onDeny }: ClarifyPromptProps) {
|
||||
const [inputText, setInputText] = useState('');
|
||||
if (pending.length === 0) return null;
|
||||
|
||||
const request = pending[0]; // Process one at a time
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (inputText.trim()) {
|
||||
onClarify(request.id, inputText.trim());
|
||||
setInputText('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '140px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgba(56, 189, 248, 0.15)',
|
||||
backdropFilter: 'blur(16px)',
|
||||
border: '1px solid rgba(56, 189, 248, 0.3)',
|
||||
boxShadow: '0 12px 40px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(56, 189, 248, 0.1)',
|
||||
padding: '20px 24px',
|
||||
borderRadius: '20px',
|
||||
zIndex: 100,
|
||||
minWidth: '400px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '12px',
|
||||
animation: 'modalScaleIn 0.3s cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
}}>
|
||||
<h3 style={{ margin: 0, color: '#38bdf8', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '1.2rem' }}>💭</span> Question from Hermes
|
||||
</h3>
|
||||
<p style={{ margin: 0, color: '#bae6fd', lineHeight: 1.5 }}>
|
||||
{request.message || 'The agent needs more information to continue.'}
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '8px', marginTop: '4px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={inputText}
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
placeholder="Type your response..."
|
||||
style={{ flex: 1, padding: '10px 14px', borderRadius: '12px', border: '1px solid rgba(56, 189, 248, 0.3)', background: 'rgba(0, 0, 0, 0.2)', color: 'white' }}
|
||||
/>
|
||||
<button type="submit" style={{ background: 'rgba(56, 189, 248, 0.2)', color: '#bae6fd', border: '1px solid rgba(56, 189, 248, 0.3)', padding: '10px 16px', borderRadius: '12px', cursor: 'pointer', fontWeight: 500 }}>
|
||||
Reply
|
||||
</button>
|
||||
<button type="button" onClick={() => onDeny(request.id)} style={{ background: 'rgba(0, 0, 0, 0.3)', color: '#9ca3af', border: '1px solid rgba(255, 255, 255, 0.1)', padding: '10px 16px', borderRadius: '12px', cursor: 'pointer', fontWeight: 500 }}>
|
||||
Cancel
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
720
web_console/src/components/chat/Composer.tsx
Normal file
720
web_console/src/components/chat/Composer.tsx
Normal file
@@ -0,0 +1,720 @@
|
||||
import { FormEvent, useEffect, useRef, useState } from 'react';
|
||||
import { apiClient } from '../../lib/api';
|
||||
|
||||
interface WorkspaceTreeNode { name: string; path: string; type: 'file' | 'directory'; children?: WorkspaceTreeNode[]; }
|
||||
interface WorkspaceTreeResponse { ok: boolean; tree: WorkspaceTreeNode; }
|
||||
|
||||
function flattenTree(node: WorkspaceTreeNode | undefined): string[] {
|
||||
if (!node) return [];
|
||||
if (node.type === 'file') return [node.path];
|
||||
return (node.children ?? []).flatMap((child) => flattenTree(child));
|
||||
}
|
||||
|
||||
export interface AttachedFile {
|
||||
file: File;
|
||||
previewUrl: string;
|
||||
type: 'image' | 'audio' | 'other';
|
||||
}
|
||||
|
||||
interface ComposerProps {
|
||||
onSend(prompt: string, attachments: AttachedFile[], isBackground?: boolean, isQuickAsk?: boolean): Promise<void> | void;
|
||||
onNewChat?: () => void;
|
||||
reasoningEffort?: string;
|
||||
onReasoningChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
interface CommandRegistryEntry {
|
||||
name: string;
|
||||
description: string;
|
||||
aliases?: string[];
|
||||
names?: string[];
|
||||
}
|
||||
|
||||
interface CommandsResponse {
|
||||
ok: boolean;
|
||||
commands: CommandRegistryEntry[];
|
||||
}
|
||||
|
||||
interface SlashCommandOption {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const FALLBACK_SLASH_COMMANDS: SlashCommandOption[] = [
|
||||
{ name: '/new', description: 'Start a new session (fresh session ID + history)' },
|
||||
{ name: '/reset', description: 'Start a new session (alias for /new)' },
|
||||
{ name: '/background', description: 'Run a prompt in the background' },
|
||||
{ name: '/bg', description: 'Run a prompt in the background (alias)' },
|
||||
{ name: '/btw', description: 'Ephemeral side question (no tools, not persisted)' },
|
||||
{ name: '/help', description: 'Show available commands' },
|
||||
];
|
||||
|
||||
export function Composer({ onSend, onNewChat, reasoningEffort, onReasoningChange }: ComposerProps) {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [attachments, setAttachments] = useState<AttachedFile[]>([]);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isBackground, setIsBackground] = useState(false);
|
||||
const [isQuickAsk, setIsQuickAsk] = useState(false);
|
||||
const [showSlashMenu, setShowSlashMenu] = useState(false);
|
||||
const [slashFilter, setSlashFilter] = useState('');
|
||||
const [selectedSlashIndex, setSelectedSlashIndex] = useState(0);
|
||||
|
||||
const [showMentionMenu, setShowMentionMenu] = useState(false);
|
||||
const [mentionFilter, setMentionFilter] = useState('');
|
||||
const [selectedMentionIndex, setSelectedMentionIndex] = useState(0);
|
||||
const [workspaceFiles, setWorkspaceFiles] = useState<string[]>([]);
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommandOption[]>(FALLBACK_SLASH_COMMANDS);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
apiClient.get<CommandsResponse>('/commands')
|
||||
.then((res) => {
|
||||
if (!res.ok || cancelled) return;
|
||||
const next = res.commands.flatMap((command) => {
|
||||
const names = command.names && command.names.length > 0
|
||||
? command.names
|
||||
: [command.name, ...(command.aliases ?? [])];
|
||||
return names.map((entryName) => ({
|
||||
name: `/${entryName}`,
|
||||
description: command.description,
|
||||
}));
|
||||
});
|
||||
|
||||
if (next.length > 0) {
|
||||
const deduped = next.filter((entry, index, arr) => arr.findIndex((other) => other.name === entry.name) === index);
|
||||
setSlashCommands(deduped);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback list remains in place.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Filtered commands for the slash menu
|
||||
const filteredCommands = slashFilter
|
||||
? slashCommands.filter(c => c.name.startsWith('/' + slashFilter))
|
||||
: slashCommands;
|
||||
|
||||
const filteredMentions = mentionFilter
|
||||
? workspaceFiles.filter(f => f.toLowerCase().includes(mentionFilter.toLowerCase())).slice(0, 50)
|
||||
: workspaceFiles.slice(0, 50);
|
||||
|
||||
// Reset selection when filter changes
|
||||
useEffect(() => {
|
||||
setSelectedSlashIndex(0);
|
||||
}, [slashFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedMentionIndex(0);
|
||||
}, [mentionFilter]);
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setShowSlashMenu(false);
|
||||
setShowMentionMenu(false);
|
||||
let message = prompt.trim();
|
||||
if (!message && attachments.length === 0) return;
|
||||
const currentAttachments = [...attachments];
|
||||
let bg = isBackground;
|
||||
let qa = isQuickAsk;
|
||||
|
||||
// Intercept standalone slash commands
|
||||
if (message.startsWith('/btw ') || message === '/btw') {
|
||||
qa = true;
|
||||
message = message.replace(/^\/btw\s*/, '');
|
||||
} else if (message.startsWith('/bg ') || message === '/bg') {
|
||||
bg = true;
|
||||
message = message.replace(/^\/bg\s*/, '');
|
||||
} else if (message.startsWith('/background ') || message === '/background') {
|
||||
bg = true;
|
||||
message = message.replace(/^\/background\s*/, '');
|
||||
}
|
||||
|
||||
setPrompt('');
|
||||
setAttachments([]);
|
||||
await onSend(message || '(attached files)', currentAttachments, bg, qa);
|
||||
};
|
||||
|
||||
const handleFileSelect = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const appendFiles = (filesLike: FileList | File[]) => {
|
||||
const files = Array.from(filesLike);
|
||||
if (files.length === 0) return;
|
||||
const newAttachments: AttachedFile[] = files.map((file) => {
|
||||
const type = file.type.startsWith('image/') ? 'image'
|
||||
: file.type.startsWith('audio/') ? 'audio'
|
||||
: 'other';
|
||||
const previewUrl = type === 'image' ? URL.createObjectURL(file) : '';
|
||||
return { file, previewUrl, type };
|
||||
});
|
||||
setAttachments((prev) => [...prev, ...newAttachments]);
|
||||
};
|
||||
|
||||
const handleFilesChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
appendFiles(files);
|
||||
e.target.value = ''; // Reset input
|
||||
};
|
||||
|
||||
const pasteClipboardImage = async () => {
|
||||
if (!navigator.clipboard || typeof navigator.clipboard.read !== 'function') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const items = await navigator.clipboard.read();
|
||||
const pastedFiles: File[] = [];
|
||||
for (const item of items) {
|
||||
const imageType = item.types.find((type) => type.startsWith('image/'));
|
||||
if (!imageType) continue;
|
||||
const blob = await item.getType(imageType);
|
||||
pastedFiles.push(new File([blob], `clipboard-${Date.now()}.${imageType.split('/')[1] || 'png'}`, { type: imageType }));
|
||||
}
|
||||
if (pastedFiles.length === 0) return false;
|
||||
appendFiles(pastedFiles);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpenFilePicker = () => {
|
||||
handleFileSelect();
|
||||
};
|
||||
const handlePrefillComposer = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ value?: string }>;
|
||||
const value = customEvent.detail?.value ?? '';
|
||||
setPrompt(value);
|
||||
handlePromptChange(value);
|
||||
requestAnimationFrame(() => textareaRef.current?.focus());
|
||||
};
|
||||
const handlePasteClipboard = async () => {
|
||||
const success = await pasteClipboardImage();
|
||||
window.dispatchEvent(new CustomEvent('hermes:composerPasteResult', { detail: { success } }));
|
||||
};
|
||||
window.addEventListener('hermes:composerOpenFilePicker', handleOpenFilePicker);
|
||||
window.addEventListener('hermes:prefillComposer', handlePrefillComposer);
|
||||
window.addEventListener('hermes:composerPasteClipboard', handlePasteClipboard);
|
||||
return () => {
|
||||
window.removeEventListener('hermes:composerOpenFilePicker', handleOpenFilePicker);
|
||||
window.removeEventListener('hermes:prefillComposer', handlePrefillComposer);
|
||||
window.removeEventListener('hermes:composerPasteClipboard', handlePasteClipboard);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const removeAttachment = (index: number) => {
|
||||
setAttachments((prev) => {
|
||||
const removed = prev[index];
|
||||
if (removed.previewUrl) URL.revokeObjectURL(removed.previewUrl);
|
||||
return prev.filter((_, i) => i !== index);
|
||||
});
|
||||
};
|
||||
|
||||
const toggleRecording = async () => {
|
||||
if (isRecording && mediaRecorderRef.current) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream, { mimeType: 'audio/webm' });
|
||||
chunksRef.current = [];
|
||||
recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
|
||||
const file = new File([blob], `recording-${Date.now()}.webm`, { type: 'audio/webm' });
|
||||
setAttachments((prev) => [...prev, { file, previewUrl: '', type: 'audio' }]);
|
||||
};
|
||||
mediaRecorderRef.current = recorder;
|
||||
recorder.start();
|
||||
setIsRecording(true);
|
||||
} catch {
|
||||
// Microphone permission denied
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragOver(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const newAttachments: AttachedFile[] = [];
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
const type = file.type.startsWith('image/') ? 'image'
|
||||
: file.type.startsWith('audio/') ? 'audio'
|
||||
: 'other';
|
||||
const previewUrl = type === 'image' ? URL.createObjectURL(file) : '';
|
||||
newAttachments.push({ file, previewUrl, type });
|
||||
}
|
||||
setAttachments((prev) => [...prev, ...newAttachments]);
|
||||
};
|
||||
|
||||
const handleSlashSelect = (cmd: string) => {
|
||||
setPrompt(cmd + ' ');
|
||||
setShowSlashMenu(false);
|
||||
setSlashFilter('');
|
||||
};
|
||||
|
||||
const handleMentionSelect = (path: string) => {
|
||||
const filename = path.split('/').pop() || path;
|
||||
const replacement = `[${filename}](${path}) `;
|
||||
const newPrompt = prompt.replace(/@\S*$/, replacement);
|
||||
setPrompt(newPrompt);
|
||||
setShowMentionMenu(false);
|
||||
setMentionFilter('');
|
||||
};
|
||||
|
||||
const handlePromptChange = (value: string) => {
|
||||
setPrompt(value);
|
||||
// Show slash menu when typing / at start
|
||||
if (value.startsWith('/')) {
|
||||
const parts = value.split(' ');
|
||||
if (parts.length === 1) {
|
||||
// Still typing the command name
|
||||
setSlashFilter(value.slice(1));
|
||||
setShowSlashMenu(true);
|
||||
} else {
|
||||
setShowSlashMenu(false);
|
||||
}
|
||||
} else {
|
||||
setShowSlashMenu(false);
|
||||
}
|
||||
|
||||
// Check for @mentions
|
||||
const atMatch = value.match(/(?:^|\s)@(\S*)$/);
|
||||
if (atMatch) {
|
||||
setMentionFilter(atMatch[1]);
|
||||
setShowMentionMenu(true);
|
||||
if (workspaceFiles.length === 0) {
|
||||
apiClient.get<WorkspaceTreeResponse>('/workspace/tree').then(res => {
|
||||
if (res.ok) setWorkspaceFiles(flattenTree(res.tree));
|
||||
}).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
setShowMentionMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', padding: '0 20px 20px' }}>
|
||||
<form
|
||||
className="composer"
|
||||
aria-label="Composer"
|
||||
onSubmit={handleSubmit}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
style={{
|
||||
margin: '0 auto',
|
||||
maxWidth: '800px',
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
boxShadow: isDragOver ? '0 0 0 2px #c084fc' : 'none',
|
||||
borderRadius: '24px',
|
||||
transition: 'box-shadow 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{isDragOver && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'rgba(192, 132, 252, 0.1)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
borderRadius: '24px',
|
||||
zIndex: 50,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#c084fc',
|
||||
fontSize: '1.2rem',
|
||||
fontWeight: 600,
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
Drop files here...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachment Preview Strip */}
|
||||
{attachments.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', paddingBottom: '12px', paddingLeft: '16px' }}>
|
||||
{attachments.map((att, i) => (
|
||||
<div key={i} style={{
|
||||
position: 'relative',
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: att.type === 'image' ? '0' : '8px 12px',
|
||||
maxWidth: '200px',
|
||||
backdropFilter: 'blur(10px)'
|
||||
}}>
|
||||
{att.type === 'image' ? (
|
||||
<img src={att.previewUrl} alt="preview" style={{ height: '64px', width: '64px', objectFit: 'cover', borderRadius: '12px' }} />
|
||||
) : (
|
||||
<span style={{ color: '#a5b4fc', fontSize: '0.85rem' }}>
|
||||
{att.type === 'audio' ? '🎵' : '📄'} {att.file.name.slice(0, 20)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAttachment(i)}
|
||||
style={{
|
||||
position: 'absolute', top: '2px', right: '2px',
|
||||
background: 'rgba(0,0,0,0.6)', color: 'white',
|
||||
border: 'none', borderRadius: '50%',
|
||||
width: '20px', height: '20px',
|
||||
cursor: 'pointer', fontSize: '12px',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}
|
||||
>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Slash command autocomplete menu */}
|
||||
{showSlashMenu && filteredCommands.length > 0 && (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
maxHeight: '280px',
|
||||
overflowY: 'auto',
|
||||
background: 'rgba(24, 24, 27, 0.98)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: '12px',
|
||||
padding: '6px',
|
||||
marginBottom: '4px',
|
||||
backdropFilter: 'blur(16px)',
|
||||
boxShadow: '0 -8px 32px rgba(0,0,0,0.4)',
|
||||
zIndex: 100,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '4px 10px 8px', fontSize: '0.7rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
Slash Commands
|
||||
</div>
|
||||
{filteredCommands.map((cmd, i) => (
|
||||
<button
|
||||
key={cmd.name}
|
||||
type="button"
|
||||
onClick={() => handleSlashSelect(cmd.name)}
|
||||
onMouseEnter={() => setSelectedSlashIndex(i)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
background: i === selectedSlashIndex ? 'rgba(129, 140, 248, 0.15)' : 'transparent',
|
||||
color: '#f4f4f5',
|
||||
textAlign: 'left',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 600,
|
||||
color: '#818cf8',
|
||||
fontSize: '0.85rem',
|
||||
minWidth: '120px',
|
||||
}}>{cmd.name}</span>
|
||||
<span style={{
|
||||
color: '#71717a',
|
||||
fontSize: '0.8rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>{cmd.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mention autocomplete menu */}
|
||||
{showMentionMenu && filteredMentions.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: '40px',
|
||||
maxWidth: '400px',
|
||||
maxHeight: '280px',
|
||||
overflowY: 'auto',
|
||||
background: 'rgba(24, 24, 27, 0.98)',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: '12px',
|
||||
padding: '6px',
|
||||
marginBottom: '4px',
|
||||
backdropFilter: 'blur(16px)',
|
||||
boxShadow: '0 -8px 32px rgba(0,0,0,0.4)',
|
||||
zIndex: 101,
|
||||
}}
|
||||
>
|
||||
<div style={{ padding: '4px 10px 8px', fontSize: '0.7rem', color: '#64748b', textTransform: 'uppercase', letterSpacing: '0.06em' }}>
|
||||
File Mentions
|
||||
</div>
|
||||
{filteredMentions.map((path, i) => (
|
||||
<button
|
||||
key={path}
|
||||
type="button"
|
||||
onClick={() => handleMentionSelect(path)}
|
||||
onMouseEnter={() => setSelectedMentionIndex(i)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '2px',
|
||||
width: '100%',
|
||||
padding: '8px 10px',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
background: i === selectedMentionIndex ? 'rgba(129, 140, 248, 0.15)' : 'transparent',
|
||||
textAlign: 'left',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 600, color: '#f4f4f5' }}>
|
||||
{path.split('/').pop()}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.75rem', color: '#64748b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', width: '100%' }}>
|
||||
{path}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: '24px', padding: '12px 16px', display: 'flex', flexDirection: 'column', gap: '8px', backdropFilter: 'blur(8px)' }}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,audio/*,.pdf,.txt,.md,.json,.csv"
|
||||
multiple
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFilesChanged}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id="chat-prompt"
|
||||
className="composer-textarea"
|
||||
style={{ margin: 0, minHeight: '44px', maxHeight: '200px', border: 'none', background: 'transparent', boxShadow: 'none', padding: '4px 8px', fontSize: '1.05rem', resize: 'none', outline: 'none', color: '#f4f4f5' }}
|
||||
placeholder={isQuickAsk ? "Ask a quick question without polluting conversation memory..." : (isBackground ? "Run silently in background..." : "Message Hermes... (type / for commands)")}
|
||||
rows={1}
|
||||
value={prompt}
|
||||
onChange={(event) => {
|
||||
handlePromptChange(event.target.value);
|
||||
// Auto-resize
|
||||
event.target.style.height = 'auto';
|
||||
event.target.style.height = `${Math.min(event.target.scrollHeight, 200)}px`;
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (showSlashMenu && filteredCommands.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedSlashIndex(prev => Math.min(prev + 1, filteredCommands.length - 1));
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedSlashIndex(prev => Math.max(prev - 1, 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
handleSlashSelect(filteredCommands[selectedSlashIndex].name);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowSlashMenu(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (showMentionMenu && filteredMentions.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSelectedMentionIndex(prev => Math.min(prev + 1, filteredMentions.length - 1));
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedMentionIndex(prev => Math.max(prev - 1, 0));
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab' || (e.key === 'Enter' && !e.shiftKey)) {
|
||||
e.preventDefault();
|
||||
handleMentionSelect(filteredMentions[selectedMentionIndex]);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setShowMentionMenu(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 4px' }}>
|
||||
<div className="composer-toolbar" style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<button type="button" className="icon-button" title="Attach file" onClick={handleFileSelect}>📎</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
title={isRecording ? 'Stop recording' : 'Voice input'}
|
||||
onClick={toggleRecording}
|
||||
style={isRecording ? { background: 'rgba(239, 68, 68, 0.3)', color: '#f87171', animation: 'pulse 1.5s infinite' } : {}}
|
||||
>
|
||||
{isRecording ? '⏹' : '🎤'}
|
||||
</button>
|
||||
<button type="button" className="icon-button" title="New chat" onClick={onNewChat}>✨</button>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
marginLeft: '8px',
|
||||
background: isBackground ? 'rgba(74, 222, 128, 0.1)' : 'transparent',
|
||||
border: `1px solid ${isBackground ? 'rgba(74, 222, 128, 0.3)' : 'transparent'}`,
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
onClick={() => { setIsBackground(!isBackground); setIsQuickAsk(false); }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isBackground}
|
||||
onChange={() => {}}
|
||||
style={{ cursor: 'pointer', accentColor: '#4ade80', width: '14px', height: '14px' }}
|
||||
/>
|
||||
<span style={{ fontSize: '0.8rem', color: isBackground ? '#4ade80' : '#a1a1aa', fontWeight: isBackground ? 600 : 400 }}>
|
||||
Background
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
background: isQuickAsk ? 'rgba(192, 132, 252, 0.1)' : 'transparent',
|
||||
border: `1px solid ${isQuickAsk ? 'rgba(192, 132, 252, 0.3)' : 'transparent'}`,
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
userSelect: 'none'
|
||||
}}
|
||||
onClick={() => { setIsQuickAsk(!isQuickAsk); setIsBackground(false); }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isQuickAsk}
|
||||
onChange={() => {}}
|
||||
style={{ cursor: 'pointer', accentColor: '#c084fc', width: '14px', height: '14px' }}
|
||||
/>
|
||||
<span style={{ fontSize: '0.8rem', color: isQuickAsk ? '#c084fc' : '#a1a1aa', fontWeight: isQuickAsk ? 600 : 400 }}>
|
||||
Quick Ask
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{reasoningEffort !== undefined && onReasoningChange && (
|
||||
<select
|
||||
value={reasoningEffort}
|
||||
onChange={(e) => onReasoningChange(e.target.value)}
|
||||
title="Reasoning Effort (Supported by O-series / Copilot / etc)"
|
||||
style={{
|
||||
marginLeft: '8px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '12px',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
color: '#a1a1aa',
|
||||
fontSize: '0.8rem',
|
||||
cursor: 'pointer',
|
||||
outline: 'none'
|
||||
}}
|
||||
>
|
||||
<option value="none">Reasoning: None</option>
|
||||
<option value="minimal">Reasoning: Minimal</option>
|
||||
<option value="low">Reasoning: Low</option>
|
||||
<option value="medium">Reasoning: Medium</option>
|
||||
<option value="high">Reasoning: High</option>
|
||||
<option value="xhigh">Reasoning: XHigh</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="composer-actions">
|
||||
<button type="submit" disabled={!prompt.trim() && attachments.length === 0} style={{
|
||||
padding: '8px 20px',
|
||||
borderRadius: '99px',
|
||||
background: prompt.trim() || attachments.length > 0 ? (isQuickAsk ? '#c084fc' : (isBackground ? '#4ade80' : '#f4f4f5')) : 'rgba(255,255,255,0.1)',
|
||||
color: prompt.trim() || attachments.length > 0 ? '#18181b' : 'rgba(255,255,255,0.4)',
|
||||
border: 'none',
|
||||
fontWeight: 500,
|
||||
cursor: prompt.trim() || attachments.length > 0 ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 0.2s ease'
|
||||
}}>
|
||||
{isQuickAsk ? 'Ask' : (isBackground ? 'Dispatch' : 'Send')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
385
web_console/src/components/chat/MessageCard.tsx
Normal file
385
web_console/src/components/chat/MessageCard.tsx
Normal file
@@ -0,0 +1,385 @@
|
||||
import { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { apiClient } from '../../lib/api';
|
||||
import { getBackendUrl } from '../../store/backendStore';
|
||||
|
||||
interface MessageCardProps {
|
||||
role: 'user' | 'assistant' | 'tool' | 'system';
|
||||
title: string;
|
||||
content: string;
|
||||
isStreaming?: boolean;
|
||||
reasoning?: string;
|
||||
isReasoningStreaming?: boolean;
|
||||
messageIndex?: number;
|
||||
onBranch?: (messageIndex: number) => void;
|
||||
}
|
||||
|
||||
interface TtsResponse {
|
||||
ok: boolean;
|
||||
tts?: { success?: boolean; audio_file?: string; error?: string };
|
||||
}
|
||||
|
||||
export function MessageCard({ role, title, content, isStreaming, reasoning, isReasoningStreaming, messageIndex, onBranch }: MessageCardProps) {
|
||||
const [ttsLoading, setTtsLoading] = useState(false);
|
||||
const [reasoningOpen, setReasoningOpen] = useState(false);
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
const avatar = role === 'user' ? '👤' : role === 'assistant' ? '🤖' : role === 'tool' ? '⚙️' : '⚡';
|
||||
|
||||
const handleTts = async () => {
|
||||
if (ttsLoading || !content.trim()) return;
|
||||
setTtsLoading(true);
|
||||
try {
|
||||
const res = await apiClient.post<TtsResponse>('/media/tts', { text: content });
|
||||
if (res.ok && res.tts?.audio_file) {
|
||||
// Fetch the audio file as a blob since the backend returns a filesystem path
|
||||
try {
|
||||
const audioRes = await fetch(`${getBackendUrl()}/api/gui/workspace/file?path=${encodeURIComponent(res.tts.audio_file)}`);
|
||||
if (audioRes.ok) {
|
||||
const blob = await audioRes.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const audio = new Audio(url);
|
||||
audio.onended = () => URL.revokeObjectURL(url);
|
||||
audio.play().catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
// Try direct path as fallback
|
||||
const audio = new Audio(res.tts.audio_file);
|
||||
audio.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// TTS not available
|
||||
} finally {
|
||||
setTtsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (text: string) => {
|
||||
navigator.clipboard.writeText(text).catch(() => {});
|
||||
};
|
||||
|
||||
const handleBranch = () => {
|
||||
if (onBranch && messageIndex !== undefined) {
|
||||
onBranch(messageIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const hasReasoning = !!(reasoning && reasoning.trim());
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`message-card message-card-${role}`}
|
||||
style={{ display: 'flex', gap: '16px', alignItems: 'flex-start', position: 'relative' }}
|
||||
onMouseEnter={() => setShowActions(true)}
|
||||
onMouseLeave={() => setShowActions(false)}
|
||||
>
|
||||
<div style={{ fontSize: '1.5rem', flexShrink: 0, opacity: 0.9 }}>{avatar}</div>
|
||||
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden' }}>
|
||||
<div className="message-card-title" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{title}
|
||||
{role === 'assistant' && content.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTts}
|
||||
disabled={ttsLoading}
|
||||
title="Read aloud (TTS)"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
color: ttsLoading ? '#475569' : '#a5b4fc',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '6px',
|
||||
cursor: ttsLoading ? 'default' : 'pointer',
|
||||
fontSize: '0.75rem',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
{ttsLoading ? '⏳' : '🔊'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collapsible Reasoning/Thinking Block */}
|
||||
{(hasReasoning || isReasoningStreaming) && (
|
||||
<div style={{
|
||||
margin: '8px 0',
|
||||
borderRadius: '10px',
|
||||
background: 'rgba(251, 191, 36, 0.04)',
|
||||
border: '1px solid rgba(251, 191, 36, 0.12)',
|
||||
overflow: 'hidden',
|
||||
transition: 'all 0.2s ease',
|
||||
}}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReasoningOpen(!reasoningOpen)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: '#fbbf24',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 500,
|
||||
textAlign: 'left',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
transition: 'transform 0.2s ease',
|
||||
transform: reasoningOpen ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||
fontSize: '0.7rem',
|
||||
}}>▶</span>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
💭 Thinking
|
||||
{isReasoningStreaming && (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: '6px', height: '6px',
|
||||
borderRadius: '50%',
|
||||
background: '#fbbf24',
|
||||
animation: 'pulse 1.5s infinite',
|
||||
}} />
|
||||
)}
|
||||
</span>
|
||||
{reasoning && (
|
||||
<span style={{
|
||||
marginLeft: 'auto',
|
||||
fontSize: '0.7rem',
|
||||
color: '#92400e',
|
||||
opacity: 0.6,
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
}}>
|
||||
{reasoning.length.toLocaleString()} chars
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{reasoningOpen && (
|
||||
<div style={{
|
||||
padding: '0 12px 10px',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.5,
|
||||
color: '#d4a574',
|
||||
fontStyle: 'italic',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
maxHeight: '400px',
|
||||
overflowY: 'auto',
|
||||
borderTop: '1px solid rgba(251, 191, 36, 0.08)',
|
||||
}}>
|
||||
{reasoning}
|
||||
{isReasoningStreaming && (
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: '2px', height: '1em',
|
||||
background: '#fbbf24',
|
||||
marginLeft: '2px',
|
||||
verticalAlign: 'text-bottom',
|
||||
animation: 'blink 1s step-end infinite',
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="message-card-content markdown-body" style={{ wordBreak: 'break-word', overflowX: 'auto' }}>
|
||||
{role === 'assistant' || role === 'user' ? (
|
||||
<>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
code(props) {
|
||||
const { children, className, node, ...rest } = props;
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const isInline = !match && !String(children).includes('\n');
|
||||
const language = match ? match[1] : '';
|
||||
const contentString = String(children).replace(/\n$/, '');
|
||||
|
||||
if (!isInline && match) {
|
||||
return (
|
||||
<div className="code-block-wrapper" style={{ position: 'relative', marginTop: '12px', marginBottom: '12px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
background: '#1e1e1e',
|
||||
padding: '4px 12px',
|
||||
borderTopLeftRadius: '6px',
|
||||
borderTopRightRadius: '6px',
|
||||
fontSize: '0.75rem',
|
||||
color: '#9cdcfe',
|
||||
borderBottom: '1px solid #333'
|
||||
}}>
|
||||
<span>{language}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
onClick={() => handleCopy(contentString)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#d4d4d4',
|
||||
cursor: 'pointer',
|
||||
padding: '2px 6px',
|
||||
fontSize: '0.75rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
📋 Copy
|
||||
</button>
|
||||
{['html', 'svg', 'xml'].includes(language.toLowerCase()) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('hermes:openArtifact', {
|
||||
detail: { type: language.toLowerCase(), content: contentString }
|
||||
}));
|
||||
}}
|
||||
style={{
|
||||
background: 'rgba(129, 140, 248, 0.2)',
|
||||
border: '1px solid rgba(129, 140, 248, 0.4)',
|
||||
color: '#a5b4fc',
|
||||
cursor: 'pointer',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.75rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px'
|
||||
}}
|
||||
title="Open in Canvas Overlay"
|
||||
>
|
||||
🎨 Open Canvas
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
style={vscDarkPlus as any}
|
||||
language={language}
|
||||
PreTag="div"
|
||||
customStyle={{ margin: 0, borderTopLeftRadius: 0, borderTopRightRadius: 0, fontSize: '0.85rem' }}
|
||||
{...rest as any}
|
||||
>
|
||||
{contentString}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<code
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.1)',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '4px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85em',
|
||||
}}
|
||||
className={className}
|
||||
{...rest as any}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
a: ({ node, ...props }) => <a style={{ color: '#818cf8', textDecoration: 'underline' }} target="_blank" rel="noopener noreferrer" {...props as any} />,
|
||||
p: ({ node, ...props }) => <p style={{ margin: '0 0 8px 0', lineHeight: 1.6 }} {...props as any} />,
|
||||
ul: ({ node, ...props }) => <ul style={{ paddingLeft: '20px', margin: '0 0 12px 0' }} {...props as any} />,
|
||||
ol: ({ node, ...props }) => <ol style={{ paddingLeft: '20px', margin: '0 0 12px 0' }} {...props as any} />,
|
||||
li: ({ node, ...props }) => <li style={{ margin: '4px 0' }} {...props as any} />,
|
||||
table: ({ node, ...props }) => (
|
||||
<div style={{ overflowX: 'auto', marginBottom: '12px' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }} {...props as any} />
|
||||
</div>
|
||||
),
|
||||
th: ({ node, ...props }) => <th style={{ border: '1px solid rgba(255,255,255,0.1)', padding: '6px 12px', background: 'rgba(255,255,255,0.05)', textAlign: 'left' }} {...props as any} />,
|
||||
td: ({ node, ...props }) => <td style={{ border: '1px solid rgba(255,255,255,0.1)', padding: '6px 12px' }} {...props as any} />
|
||||
}}
|
||||
>
|
||||
{content || ' '}
|
||||
</ReactMarkdown>
|
||||
{isStreaming && (
|
||||
<span className="streaming-cursor" style={{
|
||||
display: 'inline-block',
|
||||
width: '2px',
|
||||
height: '1.1em',
|
||||
background: '#818cf8',
|
||||
marginLeft: '2px',
|
||||
verticalAlign: 'text-bottom',
|
||||
animation: 'blink 1s step-end infinite',
|
||||
}} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div style={{ whiteSpace: 'pre-wrap', fontFamily: role === 'tool' ? 'monospace' : 'inherit', fontSize: role === 'tool' ? '0.85rem' : 'inherit' }}>
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hover action buttons */}
|
||||
{showActions && (role === 'assistant' || role === 'user') && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '4px',
|
||||
right: '4px',
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
opacity: 0.8,
|
||||
}}>
|
||||
{role === 'assistant' && onBranch && messageIndex !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBranch}
|
||||
title="Branch conversation from here"
|
||||
style={{
|
||||
background: 'rgba(99, 102, 241, 0.15)',
|
||||
border: '1px solid rgba(99, 102, 241, 0.25)',
|
||||
color: '#a5b4fc',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.7rem',
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
🌿 Branch
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy(content)}
|
||||
title="Copy message"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
color: '#94a3b8',
|
||||
padding: '3px 8px',
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.7rem',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
29
web_console/src/components/chat/RunStatusBar.tsx
Normal file
29
web_console/src/components/chat/RunStatusBar.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
interface RunStatusBarProps {
|
||||
status: string;
|
||||
sessionId?: string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case 'ready': return '#4ade80';
|
||||
case 'running': case 'sending…': return '#60a5fa';
|
||||
case 'completed': return '#a78bfa';
|
||||
case 'error': case 'failed': case 'disconnected': return '#f87171';
|
||||
case 'connecting': return '#fbbf24';
|
||||
default: return '#94a3b8';
|
||||
}
|
||||
}
|
||||
|
||||
export function RunStatusBar({ status, sessionId = 'new session', model = 'hermes-agent' }: RunStatusBarProps) {
|
||||
return (
|
||||
<section className="run-status" aria-label="Run status">
|
||||
<span className="status-pill" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ width: 10, height: 10, borderRadius: '50%', background: statusColor(status), display: 'inline-block', flexShrink: 0 }} />
|
||||
{status}
|
||||
</span>
|
||||
<span className="status-pill">Session: {sessionId}</span>
|
||||
<span className="status-pill">Model: {model}</span>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
129
web_console/src/components/chat/SessionSidebar.tsx
Normal file
129
web_console/src/components/chat/SessionSidebar.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiClient } from '../../lib/api';
|
||||
import { toastStore } from '../../store/toastStore';
|
||||
|
||||
interface Session {
|
||||
session_id: string;
|
||||
source: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
interface SessionSidebarProps {
|
||||
activeSessionId: string | null;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
onNewChat: () => void;
|
||||
}
|
||||
|
||||
export function SessionSidebar({ activeSessionId, onSelectSession, onNewChat }: SessionSidebarProps) {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchSessions = async () => {
|
||||
try {
|
||||
const res = await apiClient.get<{ ok: boolean; sessions: Session[] }>('/sessions?limit=50');
|
||||
if (res.ok) {
|
||||
setSessions(res.sessions || []);
|
||||
}
|
||||
} catch (err) {
|
||||
toastStore.error('Sessions Load Failed', err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSessions();
|
||||
}, [activeSessionId]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '260px',
|
||||
borderRight: '1px solid rgba(255,255,255,0.08)',
|
||||
background: 'rgba(0,0,0,0.2)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
flexShrink: 0
|
||||
}}>
|
||||
<div style={{ padding: '16px' }}>
|
||||
<button
|
||||
onClick={onNewChat}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px 16px',
|
||||
background: 'rgba(255,255,255,0.05)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: '6px',
|
||||
color: '#f8fafc',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '8px',
|
||||
transition: 'background 0.2s',
|
||||
fontWeight: 500
|
||||
}}
|
||||
onMouseOver={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.1)')}
|
||||
onMouseOut={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.05)')}
|
||||
>
|
||||
<span>✏️</span> New Chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', padding: '0 12px 16px', display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '24px', color: '#64748b', fontSize: '0.875rem' }}>Loading...</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '24px', color: '#64748b', fontSize: '0.875rem' }}>No recent chats</div>
|
||||
) : (
|
||||
sessions.map((s) => {
|
||||
const isActive = s.session_id === activeSessionId;
|
||||
const dateStr = s.updated_at ? new Date(s.updated_at * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '';
|
||||
return (
|
||||
<button
|
||||
key={s.session_id}
|
||||
onClick={() => onSelectSession(s.session_id)}
|
||||
style={{
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
background: isActive ? 'rgba(99, 102, 241, 0.15)' : 'transparent',
|
||||
border: 'none',
|
||||
borderRadius: '6px',
|
||||
padding: '10px 12px',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 0.2s',
|
||||
position: 'relative'
|
||||
}}
|
||||
onMouseOver={(e) => {
|
||||
if (!isActive) e.currentTarget.style.background = 'rgba(255,255,255,0.05)';
|
||||
}}
|
||||
onMouseOut={(e) => {
|
||||
if (!isActive) e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
color: isActive ? '#818cf8' : '#e2e8f0',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
{s.title || s.summary || 'New Chat'}
|
||||
</div>
|
||||
<div style={{ color: '#64748b', fontSize: '0.75rem', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>{dateStr}</span>
|
||||
<span style={{ textTransform: 'capitalize' }}>{s.source}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
web_console/src/components/chat/ToolTimeline.tsx
Normal file
16
web_console/src/components/chat/ToolTimeline.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
interface ToolTimelineProps {
|
||||
events: string[];
|
||||
}
|
||||
|
||||
export function ToolTimeline({ events }: ToolTimelineProps) {
|
||||
return (
|
||||
<section className="side-card" aria-label="Tool timeline">
|
||||
<h3>Tool timeline</h3>
|
||||
<ul className="event-list">
|
||||
{events.map((event) => (
|
||||
<li key={event}>{event}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
38
web_console/src/components/chat/Transcript.tsx
Normal file
38
web_console/src/components/chat/Transcript.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { MessageCard } from './MessageCard';
|
||||
|
||||
export interface TranscriptItem {
|
||||
role: 'user' | 'assistant' | 'tool' | 'system';
|
||||
title: string;
|
||||
content: string;
|
||||
isStreaming?: boolean;
|
||||
reasoning?: string;
|
||||
isReasoningStreaming?: boolean;
|
||||
}
|
||||
|
||||
interface TranscriptProps {
|
||||
items: TranscriptItem[];
|
||||
sessionId?: string;
|
||||
onBranch?: (messageIndex: number) => void;
|
||||
}
|
||||
|
||||
export function Transcript({ items, sessionId, onBranch }: TranscriptProps) {
|
||||
return (
|
||||
<section className="chat-panel" aria-label="Transcript">
|
||||
<div className="transcript-list">
|
||||
{items.map((item, index) => (
|
||||
<MessageCard
|
||||
key={`${item.role}-${index}-${item.title}`}
|
||||
role={item.role}
|
||||
title={item.title}
|
||||
content={item.content}
|
||||
isStreaming={item.isStreaming}
|
||||
reasoning={item.reasoning}
|
||||
isReasoningStreaming={item.isReasoningStreaming}
|
||||
messageIndex={index}
|
||||
onBranch={onBranch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
55
web_console/src/components/chat/UsageBar.tsx
Normal file
55
web_console/src/components/chat/UsageBar.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
|
||||
interface UsageMetrics {
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
estimated_cost?: number; // Optional, if backend provides it
|
||||
}
|
||||
|
||||
interface UsageBarProps {
|
||||
usage?: UsageMetrics;
|
||||
}
|
||||
|
||||
function formatNumber(num: number | undefined): string {
|
||||
if (num === undefined) return '0';
|
||||
return num.toLocaleString();
|
||||
}
|
||||
|
||||
export function UsageBar({ usage }: UsageBarProps) {
|
||||
if (!usage || Object.keys(usage).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { prompt_tokens, completion_tokens, total_tokens } = usage;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
fontSize: '0.75rem',
|
||||
color: '#94a3b8',
|
||||
background: 'rgba(255, 255, 255, 0.03)',
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
|
||||
padding: '8px 24px',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span title="Prompt tokens">⬆️ In:</span>
|
||||
<strong style={{ color: '#e2e8f0' }}>{formatNumber(prompt_tokens)}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span title="Completion tokens">⬇️ Out:</span>
|
||||
<strong style={{ color: '#e2e8f0' }}>{formatNumber(completion_tokens)}</strong>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span title="Total tokens">📊 Total:</span>
|
||||
<strong style={{ color: '#a5b4fc' }}>{formatNumber(total_tokens)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
web_console/src/components/connect/ConnectScreen.tsx
Normal file
267
web_console/src/components/connect/ConnectScreen.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useState, useEffect, type ReactNode } from 'react';
|
||||
import { isLocalMode, getBackendUrl, setBackendUrl, hasSavedBackendUrl } from '../../store/backendStore';
|
||||
import { useConnection } from '../../lib/connectionContext';
|
||||
|
||||
/**
|
||||
* ConnectGate — wraps the main app and shows a connect screen
|
||||
* when running in hosted mode without a live backend connection.
|
||||
*
|
||||
* In local mode (dev/self-hosted), it always renders children directly.
|
||||
*/
|
||||
export function ConnectGate({ children }: { children: ReactNode }) {
|
||||
const connection = useConnection();
|
||||
|
||||
// In local mode, skip the gate entirely — backwards compatible
|
||||
if (isLocalMode()) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// In hosted mode: show children if connected, connect screen if not
|
||||
if (connection.online) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return <ConnectScreen />;
|
||||
}
|
||||
|
||||
function ConnectScreen() {
|
||||
const connection = useConnection();
|
||||
const [url, setUrl] = useState(() => getBackendUrl() || 'http://localhost:8642');
|
||||
const [status, setStatus] = useState<'idle' | 'testing' | 'error' | 'success'>('idle');
|
||||
const [errorMsg, setErrorMsg] = useState('');
|
||||
|
||||
// Auto-connect on mount if a saved URL exists
|
||||
useEffect(() => {
|
||||
if (hasSavedBackendUrl()) {
|
||||
handleConnect();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleConnect = async () => {
|
||||
setStatus('testing');
|
||||
setErrorMsg('');
|
||||
|
||||
const testUrl = url.replace(/\/+$/, '');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${testUrl}/api/gui/metrics/global`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Server responded with ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (data.ok) {
|
||||
setStatus('success');
|
||||
setBackendUrl(testUrl);
|
||||
// Trigger reconnect so the ConnectionProvider picks up the new URL
|
||||
setTimeout(() => connection.reconnect(), 300);
|
||||
} else {
|
||||
throw new Error('Server responded but health check failed');
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
if (err instanceof TypeError && err.message.includes('fetch')) {
|
||||
setErrorMsg('Cannot reach server. Is Hermes running? Check that your gateway is started.');
|
||||
} else if (err instanceof DOMException && err.name === 'TimeoutError') {
|
||||
setErrorMsg('Connection timed out. Make sure your Hermes backend is running on the specified port.');
|
||||
} else {
|
||||
setErrorMsg(err instanceof Error ? err.message : 'Connection failed');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%)',
|
||||
fontFamily: "'Inter', 'Segoe UI', system-ui, sans-serif",
|
||||
}}>
|
||||
{/* Ambient glow */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
width: '600px',
|
||||
height: '600px',
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(99, 102, 241, 0.08) 0%, transparent 70%)',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
|
||||
<div style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxWidth: '460px',
|
||||
padding: '48px 40px',
|
||||
background: 'rgba(15, 23, 42, 0.85)',
|
||||
backdropFilter: 'blur(24px)',
|
||||
WebkitBackdropFilter: 'blur(24px)',
|
||||
border: '1px solid rgba(129, 140, 248, 0.15)',
|
||||
borderRadius: '24px',
|
||||
boxShadow: '0 25px 50px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.05)',
|
||||
}}>
|
||||
{/* Logo */}
|
||||
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
||||
<div style={{
|
||||
fontSize: '3rem',
|
||||
marginBottom: '12px',
|
||||
filter: 'drop-shadow(0 0 20px rgba(99, 102, 241, 0.3))',
|
||||
}}>⚡</div>
|
||||
<h1 style={{
|
||||
margin: 0,
|
||||
fontSize: '1.75rem',
|
||||
fontWeight: 700,
|
||||
background: 'linear-gradient(135deg, #e2e8f0, #a5b4fc)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
letterSpacing: '-0.02em',
|
||||
}}>Hermes Console</h1>
|
||||
<p style={{
|
||||
margin: '8px 0 0',
|
||||
fontSize: '0.9rem',
|
||||
color: '#64748b',
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
Connect to your local Hermes agent
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Connection form */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<div>
|
||||
<label style={{
|
||||
display: 'block',
|
||||
fontSize: '0.8rem',
|
||||
fontWeight: 500,
|
||||
color: '#94a3b8',
|
||||
marginBottom: '6px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
}}>Backend URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handleConnect(); }}
|
||||
placeholder="http://localhost:8642"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '12px',
|
||||
border: `1px solid ${status === 'error' ? 'rgba(239, 68, 68, 0.4)' : 'rgba(255, 255, 255, 0.1)'}`,
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
color: '#e2e8f0',
|
||||
fontSize: '0.95rem',
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s, box-shadow 0.2s',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(129, 140, 248, 0.5)';
|
||||
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(129, 140, 248, 0.1)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(255, 255, 255, 0.1)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleConnect}
|
||||
disabled={status === 'testing'}
|
||||
style={{
|
||||
padding: '14px 24px',
|
||||
borderRadius: '12px',
|
||||
border: 'none',
|
||||
background: status === 'testing'
|
||||
? 'rgba(99, 102, 241, 0.3)'
|
||||
: 'linear-gradient(135deg, #6366f1, #818cf8)',
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
fontSize: '1rem',
|
||||
cursor: status === 'testing' ? 'wait' : 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
boxShadow: status === 'testing' ? 'none' : '0 4px 14px rgba(99, 102, 241, 0.3)',
|
||||
letterSpacing: '0.01em',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (status !== 'testing') {
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 6px 20px rgba(99, 102, 241, 0.4)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'none';
|
||||
e.currentTarget.style.boxShadow = '0 4px 14px rgba(99, 102, 241, 0.3)';
|
||||
}}
|
||||
>
|
||||
{status === 'testing' ? '⏳ Connecting…' : '⚡ Connect'}
|
||||
</button>
|
||||
|
||||
{/* Status messages */}
|
||||
{status === 'error' && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderRadius: '10px',
|
||||
background: 'rgba(239, 68, 68, 0.08)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
color: '#fca5a5',
|
||||
fontSize: '0.85rem',
|
||||
lineHeight: 1.5,
|
||||
}}>
|
||||
<strong>Connection failed.</strong> {errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<div style={{
|
||||
padding: '12px 16px',
|
||||
borderRadius: '10px',
|
||||
background: 'rgba(74, 222, 128, 0.08)',
|
||||
border: '1px solid rgba(74, 222, 128, 0.2)',
|
||||
color: '#4ade80',
|
||||
fontSize: '0.85rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}>
|
||||
<span>✓</span> Connected! Loading console…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Help text */}
|
||||
<div style={{
|
||||
marginTop: '28px',
|
||||
paddingTop: '20px',
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<p style={{ margin: 0, fontSize: '0.8rem', color: '#475569', lineHeight: 1.6 }}>
|
||||
Make sure Hermes is running locally with the API server enabled.
|
||||
</p>
|
||||
<pre style={{
|
||||
margin: '12px auto 0',
|
||||
padding: '10px 16px',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
color: '#94a3b8',
|
||||
fontSize: '0.8rem',
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
display: 'inline-block',
|
||||
textAlign: 'left',
|
||||
}}>hermes --gui</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
317
web_console/src/components/cron/CronList.tsx
Normal file
317
web_console/src/components/cron/CronList.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import { useState } from 'react';
|
||||
import { apiClient } from '../../lib/api';
|
||||
|
||||
interface CronJob {
|
||||
id: string;
|
||||
name: string;
|
||||
schedule: string;
|
||||
status: string;
|
||||
prompt?: string;
|
||||
deliver?: string;
|
||||
}
|
||||
|
||||
interface CronListProps {
|
||||
jobs: CronJob[];
|
||||
onPause?: (id: string) => Promise<void>;
|
||||
onResume?: (id: string) => Promise<void>;
|
||||
onRun?: (id: string) => Promise<void>;
|
||||
onDelete?: (id: string) => Promise<void>;
|
||||
onCreate?: (data: { name: string; schedule: string; prompt: string }) => Promise<void>;
|
||||
onUpdate?: (id: string, data: Partial<{ name: string; schedule: string; prompt: string; deliver: string }>) => Promise<void>;
|
||||
}
|
||||
|
||||
interface HistoryEntry {
|
||||
run_id?: string;
|
||||
status?: string;
|
||||
started_at?: string;
|
||||
duration_s?: number;
|
||||
output_preview?: string;
|
||||
}
|
||||
|
||||
interface HistoryResponse {
|
||||
ok: boolean;
|
||||
history?: HistoryEntry[];
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, { bg: string; color: string; border: string; label: string }> = {
|
||||
active: { bg: 'rgba(34, 197, 94, 0.15)', color: '#86efac', border: 'rgba(34, 197, 94, 0.3)', label: '● Active' },
|
||||
paused: { bg: 'rgba(251, 191, 36, 0.15)', color: '#fde68a', border: 'rgba(251, 191, 36, 0.3)', label: '⏸ Paused' },
|
||||
disabled: { bg: 'rgba(100, 116, 139, 0.15)', color: '#94a3b8', border: 'rgba(100, 116, 139, 0.3)', label: '○ Disabled' },
|
||||
};
|
||||
|
||||
const DELIVERY_OPTIONS = [
|
||||
{ value: '', label: 'Current session' },
|
||||
{ value: 'telegram', label: 'Telegram' },
|
||||
{ value: 'discord', label: 'Discord' },
|
||||
{ value: 'slack', label: 'Slack' },
|
||||
{ value: 'whatsapp', label: 'WhatsApp' },
|
||||
{ value: 'matrix', label: 'Matrix' },
|
||||
];
|
||||
|
||||
export function CronList({ jobs, onPause, onResume, onRun, onDelete, onCreate, onUpdate }: CronListProps) {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newSchedule, setNewSchedule] = useState('');
|
||||
const [newPrompt, setNewPrompt] = useState('');
|
||||
const [expandedHistory, setExpandedHistory] = useState<string | null>(null);
|
||||
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editFields, setEditFields] = useState<{ name: string; schedule: string; prompt: string; deliver: string }>({ name: '', schedule: '', prompt: '', deliver: '' });
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim() || !newSchedule.trim() || !newPrompt.trim() || !onCreate) return;
|
||||
await onCreate({ name: newName.trim(), schedule: newSchedule.trim(), prompt: newPrompt.trim() });
|
||||
setNewName('');
|
||||
setNewSchedule('');
|
||||
setNewPrompt('');
|
||||
setShowCreate(false);
|
||||
};
|
||||
|
||||
const startEdit = (job: CronJob) => {
|
||||
setEditingId(job.id);
|
||||
setEditFields({
|
||||
name: job.name,
|
||||
schedule: job.schedule,
|
||||
prompt: job.prompt || '',
|
||||
deliver: job.deliver || '',
|
||||
});
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editingId || !onUpdate) return;
|
||||
await onUpdate(editingId, {
|
||||
name: editFields.name.trim(),
|
||||
schedule: editFields.schedule.trim(),
|
||||
prompt: editFields.prompt.trim(),
|
||||
deliver: editFields.deliver || undefined,
|
||||
});
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const cancelEdit = () => setEditingId(null);
|
||||
|
||||
const btnStyle: React.CSSProperties = {
|
||||
background: 'rgba(255, 255, 255, 0.06)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
color: '#a5b4fc',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem',
|
||||
transition: 'all 0.2s',
|
||||
};
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
padding: '10px 14px',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid rgba(129, 140, 248, 0.3)',
|
||||
background: 'rgba(0, 0, 0, 0.2)',
|
||||
color: 'white',
|
||||
fontSize: '0.9rem',
|
||||
width: '100%',
|
||||
};
|
||||
|
||||
return (
|
||||
<section style={{
|
||||
background: 'rgba(255, 255, 255, 0.03)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
borderRadius: '16px',
|
||||
padding: '20px',
|
||||
}} aria-label="Automations list">
|
||||
<div style={{ marginBottom: '16px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<h2 style={{ margin: 0, color: '#e2e8f0', fontSize: '1.1rem' }}>⏰ Automations</h2>
|
||||
<p style={{ margin: '4px 0 0', color: '#64748b', fontSize: '0.85rem' }}>Manage cron jobs and scheduled Hermes workflows.</p>
|
||||
</div>
|
||||
{onCreate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreate(!showCreate)}
|
||||
style={{ ...btnStyle, background: 'rgba(129, 140, 248, 0.15)', borderColor: 'rgba(129, 140, 248, 0.3)' }}
|
||||
>
|
||||
{showCreate ? '✕ Cancel' : '+ New Job'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create form */}
|
||||
{showCreate && (
|
||||
<div style={{
|
||||
display: 'flex', flexDirection: 'column', gap: '10px',
|
||||
marginBottom: '16px', padding: '16px',
|
||||
background: 'rgba(129, 140, 248, 0.06)',
|
||||
border: '1px solid rgba(129, 140, 248, 0.15)',
|
||||
borderRadius: '14px',
|
||||
}}>
|
||||
<input type="text" value={newName} onChange={(e) => setNewName(e.target.value)} placeholder="Job name (e.g. Morning summary)" style={inputStyle} />
|
||||
<input type="text" value={newSchedule} onChange={(e) => setNewSchedule(e.target.value)} placeholder="Cron schedule (e.g. 0 9 * * *)" style={inputStyle} />
|
||||
<textarea value={newPrompt} onChange={(e) => setNewPrompt(e.target.value)} placeholder="Prompt to execute..." rows={3} style={{ ...inputStyle, resize: 'vertical' }} />
|
||||
<button type="button" onClick={handleCreate} style={{ ...btnStyle, background: 'rgba(34, 197, 94, 0.2)', color: '#86efac', borderColor: 'rgba(34, 197, 94, 0.3)', padding: '10px 20px', alignSelf: 'flex-end' }}>
|
||||
Create Job
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Jobs list */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{jobs.map((job) => {
|
||||
const style = STATUS_STYLES[job.status] || STATUS_STYLES.disabled;
|
||||
const isEditing = editingId === job.id;
|
||||
const isExpanded = expandedHistory === job.id;
|
||||
|
||||
return (
|
||||
<div key={job.id}>
|
||||
{/* Job card */}
|
||||
<div style={{
|
||||
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
|
||||
padding: '14px 16px',
|
||||
background: style.bg,
|
||||
border: `1px solid ${style.border}`,
|
||||
borderRadius: (isExpanded || isEditing) ? '14px 14px 0 0' : '14px',
|
||||
transition: 'all 0.2s',
|
||||
}}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<strong style={{ color: '#e2e8f0', fontSize: '0.95rem' }}>{job.name}</strong>
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '4px', fontSize: '0.8rem', flexWrap: 'wrap' }}>
|
||||
<code style={{ color: '#a5b4fc', background: 'rgba(0,0,0,0.2)', padding: '2px 6px', borderRadius: '4px' }}>{job.schedule}</code>
|
||||
<span style={{ color: style.color }}>{style.label}</span>
|
||||
{job.deliver && <span style={{ color: '#94a3b8' }}>→ {job.deliver}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '6px', flexShrink: 0 }}>
|
||||
{onRun && <button type="button" onClick={() => onRun(job.id)} style={btnStyle} title="Run now">▶️</button>}
|
||||
{onUpdate && (
|
||||
<button type="button" onClick={() => isEditing ? cancelEdit() : startEdit(job)} style={{ ...btnStyle, color: isEditing ? '#fca5a5' : '#a5b4fc' }} title="Edit">
|
||||
{isEditing ? '✕' : '✏️'}
|
||||
</button>
|
||||
)}
|
||||
{job.status === 'active' && onPause && (
|
||||
<button type="button" onClick={() => onPause(job.id)} style={btnStyle} title="Pause">⏸</button>
|
||||
)}
|
||||
{job.status === 'paused' && onResume && (
|
||||
<button type="button" onClick={() => onResume(job.id)} style={btnStyle} title="Resume">▶</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button type="button" onClick={() => onDelete(job.id)} style={{ ...btnStyle, color: '#fca5a5' }} title="Delete">🗑️</button>
|
||||
)}
|
||||
<button type="button" onClick={async () => {
|
||||
if (isExpanded) {
|
||||
setExpandedHistory(null);
|
||||
} else {
|
||||
try {
|
||||
const res = await apiClient.get<HistoryResponse>(`/cron/jobs/${job.id}/history`);
|
||||
setHistoryEntries(res.ok && res.history ? res.history : []);
|
||||
} catch { setHistoryEntries([]); }
|
||||
setExpandedHistory(job.id);
|
||||
}
|
||||
}} style={btnStyle} title="Run history">📜</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Edit panel */}
|
||||
{isEditing && (
|
||||
<div style={{
|
||||
padding: '14px 16px',
|
||||
background: 'rgba(129, 140, 248, 0.06)',
|
||||
border: `1px solid ${style.border}`,
|
||||
borderTop: 'none',
|
||||
borderRadius: isExpanded ? '0' : '0 0 14px 14px',
|
||||
}}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '10px', marginBottom: '10px' }}>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<span style={{ color: '#94a3b8', fontSize: '0.75rem' }}>Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={editFields.name}
|
||||
onChange={(e) => setEditFields(f => ({ ...f, name: e.target.value }))}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<span style={{ color: '#94a3b8', fontSize: '0.75rem' }}>Schedule</span>
|
||||
<input
|
||||
type="text"
|
||||
value={editFields.schedule}
|
||||
onChange={(e) => setEditFields(f => ({ ...f, schedule: e.target.value }))}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '10px' }}>
|
||||
<span style={{ color: '#94a3b8', fontSize: '0.75rem' }}>Prompt</span>
|
||||
<textarea
|
||||
value={editFields.prompt}
|
||||
onChange={(e) => setEditFields(f => ({ ...f, prompt: e.target.value }))}
|
||||
rows={3}
|
||||
style={{ ...inputStyle, resize: 'vertical' }}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ color: '#94a3b8', fontSize: '0.75rem' }}>Deliver to</span>
|
||||
<select
|
||||
value={editFields.deliver}
|
||||
onChange={(e) => setEditFields(f => ({ ...f, deliver: e.target.value }))}
|
||||
style={{ ...inputStyle, width: 'auto', padding: '6px 10px' }}
|
||||
>
|
||||
{DELIVERY_OPTIONS.map(o => (
|
||||
<option key={o.value} value={o.value} style={{ background: '#0f172a' }}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button type="button" onClick={cancelEdit} style={btnStyle}>Cancel</button>
|
||||
<button type="button" onClick={saveEdit} style={{ ...btnStyle, background: 'rgba(34, 197, 94, 0.2)', color: '#86efac', borderColor: 'rgba(34, 197, 94, 0.3)' }}>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History expansion */}
|
||||
{isExpanded && (
|
||||
<div style={{
|
||||
padding: '10px 16px',
|
||||
background: 'rgba(0,0,0,0.15)', borderRadius: '0 0 14px 14px',
|
||||
border: `1px solid ${style.border}`, borderTop: 'none',
|
||||
fontSize: '0.8rem', color: '#94a3b8',
|
||||
}}>
|
||||
{historyEntries.length === 0 ? (
|
||||
<span>No run history yet.</span>
|
||||
) : (
|
||||
historyEntries.slice(0, 10).map((h, i) => (
|
||||
<div key={h.run_id ?? i} style={{ display: 'flex', gap: '12px', padding: '6px 0', borderBottom: '1px solid rgba(255,255,255,0.04)', alignItems: 'center' }}>
|
||||
<span style={{
|
||||
color: h.status === 'success' ? '#86efac' : h.status === 'failed' ? '#fca5a5' : '#fde68a',
|
||||
fontSize: '0.75rem',
|
||||
padding: '1px 6px',
|
||||
borderRadius: '4px',
|
||||
background: h.status === 'success' ? 'rgba(34,197,94,0.1)' : h.status === 'failed' ? 'rgba(239,68,68,0.1)' : 'rgba(251,191,36,0.1)',
|
||||
}}>
|
||||
{h.status ?? 'unknown'}
|
||||
</span>
|
||||
{h.started_at && <span>{new Date(h.started_at).toLocaleString()}</span>}
|
||||
{h.duration_s != null && <span style={{ color: '#64748b' }}>{h.duration_s.toFixed(1)}s</span>}
|
||||
{h.output_preview && (
|
||||
<span style={{ color: '#475569', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{h.output_preview}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{jobs.length === 0 && (
|
||||
<p style={{ color: '#475569', textAlign: 'center', padding: '24px', fontSize: '0.9rem' }}>
|
||||
No automations configured. Create one to schedule recurring agent tasks.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
148
web_console/src/components/drawer/ProcessPanel.tsx
Normal file
148
web_console/src/components/drawer/ProcessPanel.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiClient } from '../../lib/api';
|
||||
import { TerminalHost } from './TerminalHost';
|
||||
|
||||
interface BackgroundProcess {
|
||||
process_id: string;
|
||||
command: string;
|
||||
status: string;
|
||||
started_at: number;
|
||||
}
|
||||
|
||||
export function ProcessPanel() {
|
||||
const [processes, setProcesses] = useState<BackgroundProcess[]>([]);
|
||||
const [activeProcessId, setActiveProcessId] = useState<string | null>(null);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Fetch process list
|
||||
const fetchProcesses = async () => {
|
||||
try {
|
||||
const res = await apiClient.get<{ ok: boolean; processes: BackgroundProcess[] }>('/processes');
|
||||
if (res.ok && res.processes) {
|
||||
setProcesses(res.processes);
|
||||
if (!activeProcessId && res.processes.length > 0) {
|
||||
setActiveProcessId(res.processes[0].process_id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProcesses();
|
||||
const interval = setInterval(fetchProcesses, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Fetch active process logs
|
||||
useEffect(() => {
|
||||
if (!activeProcessId) {
|
||||
setLogs([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
const fetchLogs = async () => {
|
||||
try {
|
||||
const res = await apiClient.get<{ ok: boolean; log_lines: string[] }>(`/processes/${activeProcessId}/log?offset=0&limit=500`);
|
||||
if (active && res.ok && res.log_lines) {
|
||||
setLogs(res.log_lines);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLogs();
|
||||
const interval = setInterval(fetchLogs, 1500);
|
||||
return () => {
|
||||
active = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [activeProcessId]);
|
||||
|
||||
const handleKill = async (id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await apiClient.post(`/processes/${id}/kill`);
|
||||
fetchProcesses();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && processes.length === 0) {
|
||||
return <div style={{ padding: '24px', color: '#94a3b8', textAlign: 'center' }}>Loading processes...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', height: '100%', width: '100%' }}>
|
||||
{/* Sidebar for list of processes */}
|
||||
<div style={{ width: '300px', borderRight: '1px solid rgba(255,255,255,0.05)', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: '12px 16px', background: 'rgba(0,0,0,0.2)', borderBottom: '1px solid rgba(255,255,255,0.05)', fontSize: '0.85rem', color: '#94a3b8' }}>
|
||||
Background Processes
|
||||
</div>
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
{processes.length === 0 ? (
|
||||
<div style={{ padding: '24px', color: '#64748b', textAlign: 'center', fontSize: '0.875rem' }}>No processes running.</div>
|
||||
) : (
|
||||
processes.map(p => (
|
||||
<div
|
||||
key={p.process_id}
|
||||
onClick={() => setActiveProcessId(p.process_id)}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
background: activeProcessId === p.process_id ? 'rgba(56, 189, 248, 0.1)' : 'transparent',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '6px'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: '#e2e8f0', fontFamily: 'monospace', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', maxWidth: '180px' }}>
|
||||
{p.command}
|
||||
</span>
|
||||
<span style={{ fontSize: '0.7rem', padding: '2px 6px', borderRadius: '4px', background: p.status === 'running' ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)', color: p.status === 'running' ? '#4ade80' : '#f87171' }}>
|
||||
{p.status}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '0.75rem', color: '#64748b' }}>
|
||||
{new Date(p.started_at * 1000).toLocaleTimeString()}
|
||||
</span>
|
||||
{p.status === 'running' && (
|
||||
<button
|
||||
onClick={(e) => handleKill(p.process_id, e)}
|
||||
style={{ background: 'transparent', border: 'none', color: '#f87171', fontSize: '0.75rem', cursor: 'pointer', padding: '0' }}
|
||||
>
|
||||
Kill
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main area for specific terminal output */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', position: 'relative' }}>
|
||||
{activeProcessId ? (
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<TerminalHost logs={logs} />
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', height: '100%', alignItems: 'center', justifyContent: 'center', color: '#64748b', fontSize: '0.875rem' }}>
|
||||
Select a process to view its output
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
web_console/src/components/drawer/TerminalHost.tsx
Normal file
88
web_console/src/components/drawer/TerminalHost.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
interface TerminalHostProps {
|
||||
logs: string[];
|
||||
}
|
||||
|
||||
export function TerminalHost({ logs }: TerminalHostProps) {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const termInstance = useRef<Terminal | null>(null);
|
||||
const fitAddon = useRef<FitAddon | null>(null);
|
||||
const processedLogsCount = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current) return;
|
||||
|
||||
const term = new Terminal({
|
||||
theme: {
|
||||
background: '#0f172a', // slate-900
|
||||
foreground: '#f8fafc',
|
||||
cursor: '#3b82f6',
|
||||
},
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.2,
|
||||
disableStdin: true,
|
||||
cursorBlink: false,
|
||||
convertEol: true,
|
||||
});
|
||||
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
|
||||
term.open(terminalRef.current);
|
||||
fit.fit();
|
||||
|
||||
termInstance.current = term;
|
||||
fitAddon.current = fit;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.current?.fit();
|
||||
});
|
||||
resizeObserver.observe(terminalRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
term.dispose();
|
||||
termInstance.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Write new logs that haven't been written yet
|
||||
if (termInstance.current) {
|
||||
const newLogs = logs.slice(processedLogsCount.current);
|
||||
if (newLogs.length > 0) {
|
||||
for (const line of newLogs) {
|
||||
termInstance.current.writeln(line);
|
||||
}
|
||||
processedLogsCount.current = logs.length;
|
||||
}
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
// Handle case where logs array gets completely reset
|
||||
useEffect(() => {
|
||||
if (logs.length === 0 && termInstance.current) {
|
||||
termInstance.current.clear();
|
||||
processedLogsCount.current = 0;
|
||||
}
|
||||
}, [logs.length]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={terminalRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
overflow: 'hidden',
|
||||
padding: '8px',
|
||||
background: '#0f172a',
|
||||
borderRadius: '6px'
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
134
web_console/src/components/drawer/TerminalPanel.tsx
Normal file
134
web_console/src/components/drawer/TerminalPanel.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { TerminalHost } from './TerminalHost';
|
||||
import { openSessionEventStream, type GuiEvent } from '../../lib/events';
|
||||
|
||||
/**
|
||||
* TerminalPanel renders a live feed of all tool executions from the agent's
|
||||
* event stream. It subscribes to the current session's SSE and formats
|
||||
* tool.started / tool.completed events as terminal-style log lines.
|
||||
*/
|
||||
export function TerminalPanel() {
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
const [sessionId, setSessionId] = useState<string>('current');
|
||||
const subRef = useRef<{ close(): void } | null>(null);
|
||||
|
||||
// Listen for the active session from ChatPage
|
||||
useEffect(() => {
|
||||
const handleSessionSync = (e: any) => {
|
||||
if (e.detail?.sessionId) {
|
||||
setSessionId(e.detail.sessionId);
|
||||
}
|
||||
};
|
||||
window.addEventListener('hermes-session-sync', handleSessionSync);
|
||||
// Request the current session
|
||||
window.dispatchEvent(new CustomEvent('hermes-run-request-sync'));
|
||||
return () => window.removeEventListener('hermes-session-sync', handleSessionSync);
|
||||
}, []);
|
||||
|
||||
// Subscribe to the event stream and format tool events as terminal lines
|
||||
useEffect(() => {
|
||||
if (subRef.current) {
|
||||
subRef.current.close();
|
||||
}
|
||||
|
||||
const formatTimestamp = () => {
|
||||
const now = new Date();
|
||||
return `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}:${now.getSeconds().toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleEvent = (event: GuiEvent) => {
|
||||
const ts = formatTimestamp();
|
||||
|
||||
if (event.type === 'run.started') {
|
||||
setLines(prev => [...prev,
|
||||
'',
|
||||
`\x1b[1;36m━━━ Run Started ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m`,
|
||||
`\x1b[90m${ts}\x1b[0m Run ID: \x1b[33m${event.run_id}\x1b[0m`,
|
||||
]);
|
||||
}
|
||||
|
||||
if (event.type === 'tool.started') {
|
||||
const name = String(event.payload.tool_name ?? 'unknown');
|
||||
const preview = String(event.payload.preview ?? '').slice(0, 120);
|
||||
setLines(prev => [...prev,
|
||||
`\x1b[90m${ts}\x1b[0m \x1b[1;34m▶\x1b[0m \x1b[1m${name}\x1b[0m${preview ? ` \x1b[90m${preview}\x1b[0m` : ''}`,
|
||||
]);
|
||||
}
|
||||
|
||||
if (event.type === 'tool.completed') {
|
||||
const name = String(event.payload.tool_name ?? 'unknown');
|
||||
const duration = event.payload.duration != null ? `${event.payload.duration}` : '';
|
||||
const resultPreview = String(event.payload.result_preview ?? '').slice(0, 200);
|
||||
const failed = event.payload.is_error === true;
|
||||
const marker = failed ? '\x1b[1;31m✗\x1b[0m' : '\x1b[1;32m✓\x1b[0m';
|
||||
const durationStr = duration ? ` \x1b[90m(${duration})\x1b[0m` : '';
|
||||
setLines(prev => [...prev,
|
||||
`\x1b[90m${ts}\x1b[0m ${marker} \x1b[1m${name}\x1b[0m${durationStr}`,
|
||||
...(resultPreview ? [` \x1b[90m${resultPreview}\x1b[0m`] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
if (event.type === 'run.completed' || event.type === 'run.failed') {
|
||||
const ok = event.type === 'run.completed';
|
||||
const statusLabel = ok ? '\x1b[1;32m✓ Run Completed\x1b[0m' : `\x1b[1;31m✗ Run Failed\x1b[0m`;
|
||||
setLines(prev => [...prev,
|
||||
`\x1b[90m${ts}\x1b[0m ${statusLabel}`,
|
||||
`\x1b[1;36m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\x1b[0m`,
|
||||
'',
|
||||
]);
|
||||
}
|
||||
|
||||
if (event.type === 'message.assistant.completed') {
|
||||
const preview = String(event.payload.content ?? '').slice(0, 100);
|
||||
if (preview) {
|
||||
setLines(prev => [...prev,
|
||||
`\x1b[90m${ts}\x1b[0m \x1b[35m◀\x1b[0m Assistant response (${preview.length} chars)`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
subRef.current = openSessionEventStream(sessionId, handleEvent);
|
||||
|
||||
return () => {
|
||||
subRef.current?.close();
|
||||
subRef.current = null;
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{
|
||||
padding: '6px 16px',
|
||||
background: 'rgba(0,0,0,0.2)',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.05)',
|
||||
fontSize: '0.8rem',
|
||||
color: '#94a3b8',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span>Tool Execution Feed</span>
|
||||
<button
|
||||
onClick={() => setLines([])}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
color: '#64748b',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.75rem'
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div style={{ flex: 1, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
<TerminalHost logs={lines} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
117
web_console/src/components/gateway/PlatformCards.tsx
Normal file
117
web_console/src/components/gateway/PlatformCards.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
interface PlatformCard {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface PlatformCardsProps {
|
||||
platforms: PlatformCard[];
|
||||
onConfigure?: (platformId: string) => void;
|
||||
onToggle?: (platformId: string, action: 'start' | 'stop') => void;
|
||||
}
|
||||
|
||||
const STATUS_STYLES: Record<string, { bg: string; color: string; border: string; label: string }> = {
|
||||
connected: { bg: 'rgba(34, 197, 94, 0.15)', color: '#86efac', border: 'rgba(34, 197, 94, 0.3)', label: '● Connected' },
|
||||
enabled: { bg: 'rgba(56, 189, 248, 0.15)', color: '#7dd3fc', border: 'rgba(56, 189, 248, 0.3)', label: '● Enabled' },
|
||||
active: { bg: 'rgba(34, 197, 94, 0.15)', color: '#86efac', border: 'rgba(34, 197, 94, 0.3)', label: '● Active' },
|
||||
configured: { bg: 'rgba(251, 191, 36, 0.15)', color: '#fde68a', border: 'rgba(251, 191, 36, 0.3)', label: '○ Configured' },
|
||||
disabled: { bg: 'rgba(100, 116, 139, 0.15)', color: '#94a3b8', border: 'rgba(100, 116, 139, 0.3)', label: '○ Disabled' },
|
||||
};
|
||||
|
||||
const PLATFORM_ICONS: Record<string, string> = {
|
||||
telegram: '✈️',
|
||||
discord: '🎮',
|
||||
slack: '💬',
|
||||
whatsapp: '📱',
|
||||
matrix: '🔗',
|
||||
mattermost: '🟣',
|
||||
signal: '🔒',
|
||||
email: '📧',
|
||||
feishu: '🐦',
|
||||
wecom: '🏢',
|
||||
'api-server': '🌐',
|
||||
};
|
||||
|
||||
export function PlatformCards({ platforms, onConfigure, onToggle }: PlatformCardsProps) {
|
||||
return (
|
||||
<section style={{
|
||||
background: 'rgba(255, 255, 255, 0.03)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
borderRadius: '16px',
|
||||
padding: '20px',
|
||||
}} aria-label="Gateway platforms">
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<h2 style={{ margin: 0, color: '#e2e8f0', fontSize: '1.1rem' }}>🌐 Gateway Platforms</h2>
|
||||
<p style={{ margin: '4px 0 0', color: '#64748b', fontSize: '0.85rem' }}>Connected messaging and API platforms.</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(240px, 1fr))', gap: '12px' }}>
|
||||
{platforms.map((platform) => {
|
||||
const style = STATUS_STYLES[platform.status] || STATUS_STYLES.disabled;
|
||||
const icon = PLATFORM_ICONS[(platform.id || platform.name || '').toLowerCase()] || '🔌';
|
||||
return (
|
||||
<div key={platform.id} style={{
|
||||
background: style.bg,
|
||||
border: `1px solid ${style.border}`,
|
||||
borderRadius: '14px',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
transition: 'all 0.2s',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '1.3rem' }}>{icon}</span>
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
color: style.color,
|
||||
padding: '2px 8px',
|
||||
borderRadius: '20px',
|
||||
background: 'rgba(0, 0, 0, 0.2)',
|
||||
}}>
|
||||
{style.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', flex: 1 }}>
|
||||
<strong style={{ color: '#e2e8f0', fontSize: '0.95rem', textTransform: 'capitalize' }}>
|
||||
{platform.name}
|
||||
</strong>
|
||||
<span style={{ color: '#94a3b8', fontSize: '0.8rem' }}>
|
||||
{platform.detail}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', marginTop: 'auto' }}>
|
||||
{onConfigure && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onConfigure(platform.id)}
|
||||
style={{ flex: 1, padding: '6px', borderRadius: '8px', background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.1)', color: '#cbd5e1', cursor: 'pointer', fontSize: '0.8rem' }}
|
||||
>
|
||||
Configure
|
||||
</button>
|
||||
)}
|
||||
{onToggle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(platform.id, platform.status === 'disabled' ? 'start' : 'stop')}
|
||||
style={{ flex: 1, padding: '6px', borderRadius: '8px', background: platform.status === 'disabled' ? 'rgba(34,197,94,0.1)' : 'rgba(239,68,68,0.1)', border: `1px solid ${platform.status === 'disabled' ? 'rgba(34,197,94,0.2)' : 'rgba(239,68,68,0.2)'}`, color: platform.status === 'disabled' ? '#86efac' : '#fca5a5', cursor: 'pointer', fontSize: '0.8rem' }}
|
||||
>
|
||||
{platform.status === 'disabled' ? 'Start' : 'Stop'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{platforms.length === 0 && (
|
||||
<p style={{ color: '#475569', textAlign: 'center', padding: '24px', fontSize: '0.9rem' }}>
|
||||
No gateway platforms detected. Start the gateway with platform adapters enabled.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
188
web_console/src/components/gateway/PlatformConfigModal.tsx
Normal file
188
web_console/src/components/gateway/PlatformConfigModal.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiClient } from '../../lib/api';
|
||||
import { toastStore } from '../../store/toastStore';
|
||||
|
||||
interface PlatformConfigModalProps {
|
||||
platformId: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export function PlatformConfigModal({ platformId, onClose, onSaved }: PlatformConfigModalProps) {
|
||||
const [config, setConfig] = useState<Record<string, any>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadConfig() {
|
||||
const res = await apiClient.get<any>(`/gateway/platforms/${platformId}/config`);
|
||||
if (res?.ok) {
|
||||
setConfig(res.config || {});
|
||||
} else {
|
||||
toastStore.error(`Failed to load ${platformId} configuration.`);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
loadConfig();
|
||||
}, [platformId]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
const res = await apiClient.patch<any>(`/gateway/platforms/${platformId}/config`, config);
|
||||
setSaving(false);
|
||||
if (res?.ok) {
|
||||
toastStore.success(`${platformId} configuration saved.`);
|
||||
if (res.reload_required) {
|
||||
toastStore.info('Please restart Hermes for changes to take effect.');
|
||||
}
|
||||
onSaved();
|
||||
} else {
|
||||
toastStore.error(res?.error?.message || `Failed to save ${platformId} configuration.`);
|
||||
}
|
||||
};
|
||||
|
||||
const getFieldsForPlatform = () => {
|
||||
let fields = [
|
||||
{ key: 'token', label: 'Bot Token', type: 'password', placeholder: 'Enter API Token' },
|
||||
];
|
||||
|
||||
if (platformId === 'telegram') {
|
||||
fields.push({ key: 'home_channel', label: 'Home Channel ID', type: 'text', placeholder: 'Optional' });
|
||||
fields.push({ key: 'master_user', label: 'Master User ID', type: 'text', placeholder: 'Optional' });
|
||||
fields.push({ key: 'webhook_url', label: 'Webhook URL (Overrides Polling)', type: 'text', placeholder: 'https://...' });
|
||||
fields.push({ key: 'mention_behavior', label: 'Group Mention Behavior', type: 'text', placeholder: 'always | mentioned | regex' });
|
||||
fields.push({ key: 'regex_trigger', label: 'Regex Trigger', type: 'text', placeholder: 'Optional regex pattern' });
|
||||
} else if (platformId === 'discord') {
|
||||
fields.push({ key: 'home_channel', label: 'Home Channel ID', type: 'text', placeholder: 'Optional' });
|
||||
fields.push({ key: 'client_id', label: 'Client ID', type: 'text', placeholder: 'Optional' });
|
||||
} else if (platformId === 'slack') {
|
||||
fields.push({ key: 'app_token', label: 'App Token (xapp-...)', type: 'password', placeholder: 'Optional' });
|
||||
fields.push({ key: 'signing_secret', label: 'Signing Secret', type: 'password', placeholder: 'Optional' });
|
||||
} else if (platformId === 'feishu') {
|
||||
fields = []; // Remove standard token
|
||||
fields.push({ key: 'app_id', label: 'App ID', type: 'text', placeholder: 'cli_...' });
|
||||
fields.push({ key: 'app_secret', label: 'App Secret', type: 'password', placeholder: 'Secret' });
|
||||
fields.push({ key: 'verification_token', label: 'Verification Token', type: 'password', placeholder: 'Optional' });
|
||||
fields.push({ key: 'encrypt_key', label: 'Encrypt Key', type: 'password', placeholder: 'Optional' });
|
||||
} else if (platformId === 'weixin') {
|
||||
fields = []; // Remove standard token
|
||||
fields.push({ key: 'token', label: 'WeChat Token', type: 'password', placeholder: 'Token' });
|
||||
fields.push({ key: 'account_id', label: 'Account ID', type: 'text', placeholder: 'gh_...' });
|
||||
fields.push({ key: 'base_url', label: 'Base URL', type: 'text', placeholder: 'Optional (e.g. https://api.weixin.qq.com)' });
|
||||
} else if (platformId === 'wecom') {
|
||||
fields = []; // Remove standard token
|
||||
fields.push({ key: 'bot_id', label: 'Bot ID', type: 'text', placeholder: 'Bot ID' });
|
||||
fields.push({ key: 'secret', label: 'Secret', type: 'password', placeholder: 'Secret' });
|
||||
} else if (platformId === 'mattermost') {
|
||||
fields.push({ key: 'url', label: 'Mattermost URL', type: 'text', placeholder: 'https://...' });
|
||||
fields.push({ key: 'bot_id', label: 'Bot ID', type: 'text', placeholder: 'Optional' });
|
||||
fields.push({ key: 'webhook_port', label: 'Webhook Port', type: 'text', placeholder: 'Optional' });
|
||||
} else if (platformId === 'matrix') {
|
||||
fields.push({ key: 'homeserver', label: 'Homeserver URL', type: 'text', placeholder: 'https://matrix.org' });
|
||||
fields.push({ key: 'username', label: 'Username', type: 'text', placeholder: '@bot:matrix.org' });
|
||||
fields.push({ key: 'password', label: 'Password (if no token)', type: 'password', placeholder: 'Optional' });
|
||||
} else if (platformId === 'homeassistant') {
|
||||
fields.push({ key: 'url', label: 'Home Assistant URL', type: 'text', placeholder: 'https://...' });
|
||||
fields.push({ key: 'agent_id', label: 'Conversation Agent ID', type: 'text', placeholder: 'Optional' });
|
||||
} else if (platformId === 'signal') {
|
||||
fields = [];
|
||||
fields.push({ key: 'http_url', label: 'Signal-CLI HTTP URL', type: 'text', placeholder: 'http://127.0.0.1:8080' });
|
||||
fields.push({ key: 'account', label: 'Phone Number Account', type: 'text', placeholder: '+1234567890' });
|
||||
} else if (platformId === 'bluebubbles') {
|
||||
fields = [];
|
||||
fields.push({ key: 'server_url', label: 'Server URL', type: 'text', placeholder: 'https://...' });
|
||||
fields.push({ key: 'password', label: 'Password', type: 'password', placeholder: 'Secret' });
|
||||
} else if (platformId === 'email') {
|
||||
fields = [];
|
||||
fields.push({ key: 'address', label: 'Email Address', type: 'text', placeholder: 'bot@example.com' });
|
||||
fields.push({ key: 'imap_host', label: 'IMAP Host', type: 'text', placeholder: 'imap.example.com' });
|
||||
fields.push({ key: 'imap_user', label: 'IMAP User', type: 'text', placeholder: 'bot@example.com' });
|
||||
fields.push({ key: 'imap_pass', label: 'IMAP Password', type: 'password', placeholder: 'Secret' });
|
||||
fields.push({ key: 'smtp_host', label: 'SMTP Host', type: 'text', placeholder: 'smtp.example.com' });
|
||||
fields.push({ key: 'smtp_user', label: 'SMTP User', type: 'text', placeholder: 'bot@example.com' });
|
||||
fields.push({ key: 'smtp_pass', label: 'SMTP Password', type: 'password', placeholder: 'Secret' });
|
||||
} else if (platformId === 'whatsapp' || platformId === 'sms' || platformId === 'webhook' || platformId === 'api_server' || platformId === 'cli') {
|
||||
fields = [];
|
||||
}
|
||||
|
||||
return fields;
|
||||
};
|
||||
|
||||
const fields = getFieldsForPlatform();
|
||||
|
||||
const handleFieldChange = (key: string, value: any) => {
|
||||
setConfig(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const inputStyle = {
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
background: 'rgba(0, 0, 0, 0.2)',
|
||||
color: '#e2e8f0',
|
||||
fontSize: '0.9rem',
|
||||
marginBottom: '12px'
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
|
||||
background: 'rgba(0, 0, 0, 0.6)', backdropFilter: 'blur(4px)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#1e293b', border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: '16px', padding: '24px', width: '400px', maxWidth: '90vw'
|
||||
}}>
|
||||
<h3 style={{ margin: '0 0 16px', color: '#e2e8f0', textTransform: 'capitalize' }}>
|
||||
Configure {platformId}
|
||||
</h3>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ color: '#94a3b8' }}>Loading configuration...</p>
|
||||
) : (
|
||||
<form onSubmit={e => { e.preventDefault(); handleSave(); }}>
|
||||
{fields.map(field => (
|
||||
<div key={field.key}>
|
||||
<label style={{ display: 'block', marginBottom: '6px', fontSize: '0.85rem', color: '#94a3b8' }}>
|
||||
{field.label}
|
||||
</label>
|
||||
<input
|
||||
type={field.type}
|
||||
value={config[field.key] || ''}
|
||||
placeholder={field.placeholder}
|
||||
onChange={e => handleFieldChange(field.key, e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px', marginTop: '16px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: '8px', background: 'transparent',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)', color: '#e2e8f0', cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: '8px 16px', borderRadius: '8px', background: 'rgba(56, 189, 248, 0.2)',
|
||||
border: '1px solid rgba(56, 189, 248, 0.3)', color: '#38bdf8', cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
web_console/src/components/inspector/HumanPanel.tsx
Normal file
188
web_console/src/components/inspector/HumanPanel.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiClient } from '../../lib/api';
|
||||
|
||||
interface HumanRequest {
|
||||
request_id: string;
|
||||
type: 'approval' | 'clarification';
|
||||
prompt?: string;
|
||||
tool_name?: string;
|
||||
command?: string;
|
||||
question?: string;
|
||||
choices?: string[];
|
||||
status: 'pending' | 'resolved' | 'denied';
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export function HumanPanel() {
|
||||
const [requests, setRequests] = useState<HumanRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [clarifyInput, setClarifyInput] = useState<{ [id: string]: string }>({});
|
||||
|
||||
const fetchPending = async () => {
|
||||
try {
|
||||
const res = await apiClient.get<{ ok: boolean; pending: HumanRequest[] }>('/human/pending');
|
||||
if (res.ok) {
|
||||
setRequests(res.pending || []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load pending human requests:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPending();
|
||||
const interval = setInterval(fetchPending, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleApprove = async (id: string, decision: 'once' | 'session' | 'always' = 'once') => {
|
||||
try {
|
||||
await apiClient.post('/human/approve', { request_id: id, decision });
|
||||
fetchPending();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeny = async (id: string) => {
|
||||
try {
|
||||
await apiClient.post('/human/deny', { request_id: id });
|
||||
fetchPending();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClarify = async (id: string, response: string) => {
|
||||
try {
|
||||
await apiClient.post('/human/clarify', { request_id: id, response });
|
||||
setClarifyInput(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
fetchPending();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading && requests.length === 0) {
|
||||
return <div style={{ padding: '24px', color: '#94a3b8', textAlign: 'center' }}>Loading pending requests...</div>;
|
||||
}
|
||||
|
||||
if (requests.length === 0) {
|
||||
return <div style={{ padding: '24px', color: '#94a3b8', textAlign: 'center' }}>No pending approvals or questions.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px', color: '#f8fafc', display: 'flex', flexDirection: 'column', gap: '16px', overflowY: 'auto', height: '100%' }}>
|
||||
<h3 style={{ margin: '0 0 8px 0', fontSize: '1rem', color: '#e2e8f0' }}>Pending Action Needed</h3>
|
||||
|
||||
{requests.map(req => (
|
||||
<div key={req.request_id} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(234, 179, 8, 0.3)', padding: '12px', borderRadius: '6px' }}>
|
||||
|
||||
{req.type === 'approval' ? (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ fontWeight: 600, color: '#facc15' }}>Security Approval</span>
|
||||
<span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>
|
||||
{new Date(req.created_at * 1000).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '0.875rem', marginBottom: '8px' }}>
|
||||
The agent wants to use <strong>{req.tool_name}</strong>.
|
||||
</div>
|
||||
|
||||
{req.command && (
|
||||
<div style={{ background: '#000', padding: '8px', borderRadius: '4px', fontFamily: 'monospace', fontSize: '0.8rem', color: '#4ade80', marginBottom: '12px', overflowX: 'auto' }}>
|
||||
{req.command}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
onClick={() => handleApprove(req.request_id, 'once')}
|
||||
style={{ background: 'rgba(34, 197, 94, 0.2)', color: '#4ade80', border: '1px solid rgba(34, 197, 94, 0.3)', padding: '6px 12px', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem', flex: 1 }}
|
||||
>
|
||||
Approve Once
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleApprove(req.request_id, 'session')}
|
||||
style={{ background: 'rgba(34, 197, 94, 0.1)', color: '#4ade80', border: '1px solid rgba(34, 197, 94, 0.3)', padding: '6px 12px', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem', flex: 1 }}
|
||||
>
|
||||
Approve Session
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeny(req.request_id)}
|
||||
style={{ background: 'rgba(239, 68, 68, 0.2)', color: '#f87171', border: '1px solid rgba(239, 68, 68, 0.3)', padding: '6px 12px', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem', flex: 1 }}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '8px' }}>
|
||||
<span style={{ fontWeight: 600, color: '#60a5fa' }}>Clarification Needed</span>
|
||||
<span style={{ fontSize: '0.75rem', color: '#94a3b8' }}>
|
||||
{new Date(req.created_at * 1000).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '0.875rem', marginBottom: '12px' }}>
|
||||
{req.question}
|
||||
</div>
|
||||
|
||||
{req.choices && req.choices.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{req.choices.map(choice => (
|
||||
<button
|
||||
key={choice}
|
||||
onClick={() => handleClarify(req.request_id, choice)}
|
||||
style={{ background: 'rgba(255,255,255,0.05)', color: '#e2e8f0', border: '1px solid rgba(255,255,255,0.1)', padding: '8px', borderRadius: '4px', cursor: 'pointer', fontSize: '0.85rem', textAlign: 'left' }}
|
||||
>
|
||||
{choice}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => handleDeny(req.request_id)}
|
||||
style={{ background: 'rgba(239, 68, 68, 0.1)', color: '#f87171', border: '1px dashed rgba(239, 68, 68, 0.3)', padding: '8px', borderRadius: '4px', cursor: 'pointer', fontSize: '0.85rem' }}
|
||||
>
|
||||
Cancel Action
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={clarifyInput[req.request_id] || ''}
|
||||
onChange={e => setClarifyInput({ ...clarifyInput, [req.request_id]: e.target.value })}
|
||||
placeholder="Type your answer..."
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && clarifyInput[req.request_id]) {
|
||||
handleClarify(req.request_id, clarifyInput[req.request_id]);
|
||||
}
|
||||
}}
|
||||
style={{ flex: 1, padding: '8px 12px', background: 'rgba(0,0,0,0.3)', border: '1px solid rgba(255,255,255,0.1)', color: '#fff', borderRadius: '4px', fontSize: '0.875rem' }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleClarify(req.request_id, clarifyInput[req.request_id] || '')}
|
||||
disabled={!clarifyInput[req.request_id]}
|
||||
style={{ background: '#3b82f6', color: '#fff', border: 'none', padding: '0 16px', borderRadius: '4px', cursor: clarifyInput[req.request_id] ? 'pointer' : 'not-allowed', opacity: clarifyInput[req.request_id] ? 1 : 0.5 }}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
web_console/src/components/inspector/RunPanel.tsx
Normal file
147
web_console/src/components/inspector/RunPanel.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiClient } from '../../lib/api';
|
||||
|
||||
interface RunDetail {
|
||||
run_id: string;
|
||||
session_id: string;
|
||||
status: string;
|
||||
model: string;
|
||||
provider: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
usage?: Record<string, any>;
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
total_tokens?: number;
|
||||
}
|
||||
|
||||
export function RunPanel() {
|
||||
const [runId, setRunId] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<string>('idle');
|
||||
const [detail, setDetail] = useState<RunDetail | null>(null);
|
||||
const [elapsed, setElapsed] = useState<number>(0);
|
||||
|
||||
// Listen to run sync events from ChatPage
|
||||
useEffect(() => {
|
||||
const handleSync = (e: CustomEvent) => {
|
||||
const { runId: newRunId, status: newStatus } = e.detail;
|
||||
if (newRunId !== runId) {
|
||||
setRunId(newRunId);
|
||||
setElapsed(0);
|
||||
}
|
||||
setStatus(newStatus || 'idle');
|
||||
};
|
||||
window.addEventListener('hermes-run-sync', handleSync as EventListener);
|
||||
|
||||
// Request an immediate sync
|
||||
window.dispatchEvent(new CustomEvent('hermes-run-request-sync'));
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('hermes-run-sync', handleSync as EventListener);
|
||||
};
|
||||
}, [runId]);
|
||||
|
||||
// Fetch run details
|
||||
useEffect(() => {
|
||||
if (!runId) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
const fetchDetail = async () => {
|
||||
try {
|
||||
const res = await apiClient.get<{ ok: boolean; run: RunDetail }>(`/chat/run/${runId}`);
|
||||
if (res.ok) {
|
||||
setDetail(res.run);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch run details:', err);
|
||||
}
|
||||
};
|
||||
|
||||
fetchDetail();
|
||||
// Poll detail if running
|
||||
if (status === 'running') {
|
||||
const interval = setInterval(fetchDetail, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [runId, status]);
|
||||
|
||||
// Elapsed timer
|
||||
useEffect(() => {
|
||||
if (status !== 'running') return;
|
||||
const interval = setInterval(() => {
|
||||
setElapsed((prev) => prev + 1);
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [status]);
|
||||
|
||||
if (!runId && status === 'idle') {
|
||||
return (
|
||||
<div style={{ padding: '24px', color: '#94a3b8', textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '2rem', marginBottom: '16px' }}>😴</div>
|
||||
<p>No active run.</p>
|
||||
<p style={{ fontSize: '0.875rem' }}>Send a message to start a new run.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const modelName = detail?.model || 'N/A';
|
||||
const provider = detail?.provider || 'N/A';
|
||||
|
||||
// Format token counts
|
||||
const pTokens = detail?.prompt_tokens ?? detail?.usage?.prompt_tokens ?? 0;
|
||||
const cTokens = detail?.completion_tokens ?? detail?.usage?.completion_tokens ?? 0;
|
||||
const tTokens = detail?.total_tokens ?? detail?.usage?.total_tokens ?? (pTokens + cTokens);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '16px', color: '#f8fafc' }}>
|
||||
<h3 style={{ marginTop: 0, marginBottom: '12px', fontSize: '1.1rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{status === 'running' ? '⏳ Running...' : status === 'failed' ? '❌ Failed' : '✅ Completed'}
|
||||
</h3>
|
||||
|
||||
<div style={{ background: 'rgba(0,0,0,0.2)', padding: '12px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.05)', marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '0.75rem', color: '#94a3b8', marginBottom: '4px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>Run ID</div>
|
||||
<div style={{ fontFamily: 'monospace', fontSize: '0.9rem' }}>{runId || 'N/A'}</div>
|
||||
</div>
|
||||
|
||||
<div style={{ background: 'rgba(0,0,0,0.2)', padding: '12px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.05)', marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '0.75rem', color: '#94a3b8', marginBottom: '4px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>Model & Provider</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ fontWeight: 500 }}>{modelName}</span>
|
||||
<span style={{ fontSize: '0.8rem', background: 'rgba(99, 102, 241, 0.2)', color: '#818cf8', padding: '2px 6px', borderRadius: '4px' }}>
|
||||
{provider}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{status === 'running' && (
|
||||
<div style={{ background: 'rgba(0,0,0,0.2)', padding: '12px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.05)', marginBottom: '16px' }}>
|
||||
<div style={{ fontSize: '0.75rem', color: '#94a3b8', marginBottom: '4px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>Elapsed Time</div>
|
||||
<div style={{ fontSize: '1.25rem', fontFamily: 'monospace' }}>
|
||||
{Math.floor(elapsed / 60)}:{(elapsed % 60).toString().padStart(2, '0')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === 'completed' || tTokens > 0) && (
|
||||
<div style={{ background: 'rgba(0,0,0,0.2)', padding: '12px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.05)' }}>
|
||||
<div style={{ fontSize: '0.75rem', color: '#94a3b8', marginBottom: '8px', textTransform: 'uppercase', letterSpacing: '0.5px' }}>Run Token Usage</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', fontSize: '0.9rem' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#cbd5e1' }}>Prompt:</span>
|
||||
<span style={{ fontFamily: 'monospace' }}>{pTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#cbd5e1' }}>Completion:</span>
|
||||
<span style={{ fontFamily: 'monospace' }}>{cTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', borderTop: '1px solid rgba(255,255,255,0.1)', paddingTop: '8px', fontWeight: 600 }}>
|
||||
<span>Total:</span>
|
||||
<span style={{ fontFamily: 'monospace' }}>{tTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user