feat: web UI dashboard for managing Hermes Agent (#8756)
* feat: web UI dashboard for managing Hermes Agent (salvage of #8204/#7621)
Adds an embedded web UI dashboard accessible via `hermes web`:
- Status page: agent version, active sessions, gateway status, connected platforms
- Config editor: schema-driven form with tabbed categories, import/export, reset
- API Keys page: set, clear, and view redacted values with category grouping
- Sessions, Skills, Cron, Logs, and Analytics pages
Backend:
- hermes_cli/web_server.py: FastAPI server with REST endpoints
- hermes_cli/config.py: reload_env() utility for hot-reloading .env
- hermes_cli/main.py: `hermes web` subcommand (--port, --host, --no-open)
- cli.py / commands.py: /reload slash command for .env hot-reload
- pyproject.toml: [web] optional dependency extra (fastapi + uvicorn)
- Both update paths (git + zip) auto-build web frontend when npm available
Frontend:
- Vite + React + TypeScript + Tailwind v4 SPA in web/
- shadcn/ui-style components, Nous design language
- Auto-refresh status page, toast notifications, masked password inputs
Security:
- Path traversal guard (resolve().is_relative_to()) on SPA file serving
- CORS localhost-only via allow_origin_regex
- Generic error messages (no internal leak), SessionDB handles closed properly
Tests: 47 tests covering reload_env, redact_key, API endpoints, schema
generation, path traversal, category merging, internal key stripping,
and full config round-trip.
Original work by @austinpickett (PR #1813), salvaged by @kshitijk4poor
(PR #7621 → #8204), re-salvaged onto current main with stale-branch
regressions removed.
* fix(web): clean up status page cards, always rebuild on `hermes web`
- Remove config version migration alert banner from status page
- Remove config version card (internal noise, not surfaced in TUI)
- Reorder status cards: Agent → Gateway → Active Sessions (3-col grid)
- `hermes web` now always rebuilds from source before serving,
preventing stale web_dist when editing frontend files
* feat(web): full-text search across session messages
- Add GET /api/sessions/search endpoint backed by FTS5
- Auto-append prefix wildcards so partial words match (e.g. 'nimb' → 'nimby')
- Debounced search (300ms) with spinner in the search icon slot
- Search results show FTS5 snippets with highlighted match delimiters
- Expanding a search hit auto-scrolls to the first matching message
- Matching messages get a warning ring + 'match' badge
- Inline term highlighting within Markdown (text, bold, italic, headings, lists)
- Clear button (x) on search input for quick reset
---------
Co-authored-by: emozilla <emozilla@nousresearch.com>
2026-04-12 22:26:28 -07:00
|
|
|
const BASE = "";
|
|
|
|
|
|
|
|
|
|
// Ephemeral session token for protected endpoints (reveal).
|
|
|
|
|
// Fetched once on first reveal request and cached in memory.
|
|
|
|
|
let _sessionToken: string | null = null;
|
|
|
|
|
|
|
|
|
|
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
|
|
|
|
const res = await fetch(`${BASE}${url}`, init);
|
|
|
|
|
if (!res.ok) {
|
|
|
|
|
const text = await res.text().catch(() => res.statusText);
|
|
|
|
|
throw new Error(`${res.status}: ${text}`);
|
|
|
|
|
}
|
|
|
|
|
return res.json();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function getSessionToken(): Promise<string> {
|
|
|
|
|
if (_sessionToken) return _sessionToken;
|
|
|
|
|
const resp = await fetchJSON<{ token: string }>("/api/auth/session-token");
|
|
|
|
|
_sessionToken = resp.token;
|
|
|
|
|
return _sessionToken;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const api = {
|
|
|
|
|
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
|
|
|
|
|
getSessions: () => fetchJSON<SessionInfo[]>("/api/sessions"),
|
|
|
|
|
getSessionMessages: (id: string) =>
|
|
|
|
|
fetchJSON<SessionMessagesResponse>(`/api/sessions/${encodeURIComponent(id)}/messages`),
|
|
|
|
|
deleteSession: (id: string) =>
|
|
|
|
|
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
}),
|
|
|
|
|
getLogs: (params: { file?: string; lines?: number; level?: string; component?: string }) => {
|
|
|
|
|
const qs = new URLSearchParams();
|
|
|
|
|
if (params.file) qs.set("file", params.file);
|
|
|
|
|
if (params.lines) qs.set("lines", String(params.lines));
|
|
|
|
|
if (params.level && params.level !== "ALL") qs.set("level", params.level);
|
|
|
|
|
if (params.component && params.component !== "all") qs.set("component", params.component);
|
|
|
|
|
return fetchJSON<LogsResponse>(`/api/logs?${qs.toString()}`);
|
|
|
|
|
},
|
|
|
|
|
getAnalytics: (days: number) =>
|
|
|
|
|
fetchJSON<AnalyticsResponse>(`/api/analytics/usage?days=${days}`),
|
|
|
|
|
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
|
|
|
|
|
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
|
|
|
|
|
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
|
|
|
|
|
saveConfig: (config: Record<string, unknown>) =>
|
|
|
|
|
fetchJSON<{ ok: boolean }>("/api/config", {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ config }),
|
|
|
|
|
}),
|
|
|
|
|
getConfigRaw: () => fetchJSON<{ yaml: string }>("/api/config/raw"),
|
|
|
|
|
saveConfigRaw: (yaml_text: string) =>
|
|
|
|
|
fetchJSON<{ ok: boolean }>("/api/config/raw", {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ yaml_text }),
|
|
|
|
|
}),
|
|
|
|
|
getEnvVars: () => fetchJSON<Record<string, EnvVarInfo>>("/api/env"),
|
|
|
|
|
setEnvVar: (key: string, value: string) =>
|
|
|
|
|
fetchJSON<{ ok: boolean }>("/api/env", {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ key, value }),
|
|
|
|
|
}),
|
|
|
|
|
deleteEnvVar: (key: string) =>
|
|
|
|
|
fetchJSON<{ ok: boolean }>("/api/env", {
|
|
|
|
|
method: "DELETE",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ key }),
|
|
|
|
|
}),
|
|
|
|
|
revealEnvVar: async (key: string) => {
|
|
|
|
|
const token = await getSessionToken();
|
|
|
|
|
return fetchJSON<{ key: string; value: string }>("/api/env/reveal", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
Authorization: `Bearer ${token}`,
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({ key }),
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// Cron jobs
|
|
|
|
|
getCronJobs: () => fetchJSON<CronJob[]>("/api/cron/jobs"),
|
|
|
|
|
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }) =>
|
|
|
|
|
fetchJSON<CronJob>("/api/cron/jobs", {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify(job),
|
|
|
|
|
}),
|
|
|
|
|
pauseCronJob: (id: string) =>
|
|
|
|
|
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/pause`, { method: "POST" }),
|
|
|
|
|
resumeCronJob: (id: string) =>
|
|
|
|
|
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/resume`, { method: "POST" }),
|
|
|
|
|
triggerCronJob: (id: string) =>
|
|
|
|
|
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/trigger`, { method: "POST" }),
|
|
|
|
|
deleteCronJob: (id: string) =>
|
|
|
|
|
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}`, { method: "DELETE" }),
|
|
|
|
|
|
|
|
|
|
// Skills & Toolsets
|
|
|
|
|
getSkills: () => fetchJSON<SkillInfo[]>("/api/skills"),
|
|
|
|
|
toggleSkill: (name: string, enabled: boolean) =>
|
|
|
|
|
fetchJSON<{ ok: boolean }>("/api/skills/toggle", {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ name, enabled }),
|
|
|
|
|
}),
|
|
|
|
|
getToolsets: () => fetchJSON<ToolsetInfo[]>("/api/tools/toolsets"),
|
|
|
|
|
|
|
|
|
|
// Session search (FTS5)
|
|
|
|
|
searchSessions: (q: string) =>
|
|
|
|
|
fetchJSON<SessionSearchResponse>(`/api/sessions/search?q=${encodeURIComponent(q)}`),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export interface PlatformStatus {
|
|
|
|
|
error_code?: string;
|
|
|
|
|
error_message?: string;
|
|
|
|
|
state: string;
|
|
|
|
|
updated_at: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface StatusResponse {
|
|
|
|
|
active_sessions: number;
|
|
|
|
|
config_path: string;
|
|
|
|
|
config_version: number;
|
|
|
|
|
env_path: string;
|
|
|
|
|
gateway_exit_reason: string | null;
|
|
|
|
|
gateway_pid: number | null;
|
|
|
|
|
gateway_platforms: Record<string, PlatformStatus>;
|
|
|
|
|
gateway_running: boolean;
|
|
|
|
|
gateway_state: string | null;
|
|
|
|
|
gateway_updated_at: string | null;
|
|
|
|
|
hermes_home: string;
|
|
|
|
|
latest_config_version: number;
|
|
|
|
|
release_date: string;
|
|
|
|
|
version: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SessionInfo {
|
|
|
|
|
id: string;
|
|
|
|
|
source: string | null;
|
|
|
|
|
model: string | null;
|
|
|
|
|
title: string | null;
|
|
|
|
|
started_at: number;
|
|
|
|
|
ended_at: number | null;
|
|
|
|
|
last_active: number;
|
|
|
|
|
is_active: boolean;
|
|
|
|
|
message_count: number;
|
|
|
|
|
tool_call_count: number;
|
|
|
|
|
input_tokens: number;
|
|
|
|
|
output_tokens: number;
|
|
|
|
|
preview: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface EnvVarInfo {
|
|
|
|
|
is_set: boolean;
|
|
|
|
|
redacted_value: string | null;
|
|
|
|
|
description: string;
|
|
|
|
|
url: string | null;
|
|
|
|
|
category: string;
|
|
|
|
|
is_password: boolean;
|
|
|
|
|
tools: string[];
|
|
|
|
|
advanced: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SessionMessage {
|
|
|
|
|
role: "user" | "assistant" | "system" | "tool";
|
|
|
|
|
content: string | null;
|
|
|
|
|
tool_calls?: Array<{
|
|
|
|
|
id: string;
|
|
|
|
|
function: { name: string; arguments: string };
|
|
|
|
|
}>;
|
|
|
|
|
tool_name?: string;
|
|
|
|
|
tool_call_id?: string;
|
|
|
|
|
timestamp?: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SessionMessagesResponse {
|
|
|
|
|
session_id: string;
|
|
|
|
|
messages: SessionMessage[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface LogsResponse {
|
|
|
|
|
file: string;
|
|
|
|
|
lines: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AnalyticsDailyEntry {
|
|
|
|
|
day: string;
|
|
|
|
|
input_tokens: number;
|
|
|
|
|
output_tokens: number;
|
|
|
|
|
cache_read_tokens: number;
|
|
|
|
|
reasoning_tokens: number;
|
|
|
|
|
estimated_cost: number;
|
|
|
|
|
actual_cost: number;
|
|
|
|
|
sessions: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AnalyticsModelEntry {
|
|
|
|
|
model: string;
|
|
|
|
|
input_tokens: number;
|
|
|
|
|
output_tokens: number;
|
|
|
|
|
estimated_cost: number;
|
|
|
|
|
sessions: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface AnalyticsResponse {
|
|
|
|
|
daily: AnalyticsDailyEntry[];
|
|
|
|
|
by_model: AnalyticsModelEntry[];
|
|
|
|
|
totals: {
|
|
|
|
|
total_input: number;
|
|
|
|
|
total_output: number;
|
|
|
|
|
total_cache_read: number;
|
|
|
|
|
total_reasoning: number;
|
|
|
|
|
total_estimated_cost: number;
|
|
|
|
|
total_actual_cost: number;
|
|
|
|
|
total_sessions: number;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface CronJob {
|
|
|
|
|
id: string;
|
|
|
|
|
name?: string;
|
|
|
|
|
prompt: string;
|
2026-04-13 12:01:12 +02:00
|
|
|
schedule: { kind: string; expr: string; display: string };
|
|
|
|
|
schedule_display: string;
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
state: string;
|
feat: web UI dashboard for managing Hermes Agent (#8756)
* feat: web UI dashboard for managing Hermes Agent (salvage of #8204/#7621)
Adds an embedded web UI dashboard accessible via `hermes web`:
- Status page: agent version, active sessions, gateway status, connected platforms
- Config editor: schema-driven form with tabbed categories, import/export, reset
- API Keys page: set, clear, and view redacted values with category grouping
- Sessions, Skills, Cron, Logs, and Analytics pages
Backend:
- hermes_cli/web_server.py: FastAPI server with REST endpoints
- hermes_cli/config.py: reload_env() utility for hot-reloading .env
- hermes_cli/main.py: `hermes web` subcommand (--port, --host, --no-open)
- cli.py / commands.py: /reload slash command for .env hot-reload
- pyproject.toml: [web] optional dependency extra (fastapi + uvicorn)
- Both update paths (git + zip) auto-build web frontend when npm available
Frontend:
- Vite + React + TypeScript + Tailwind v4 SPA in web/
- shadcn/ui-style components, Nous design language
- Auto-refresh status page, toast notifications, masked password inputs
Security:
- Path traversal guard (resolve().is_relative_to()) on SPA file serving
- CORS localhost-only via allow_origin_regex
- Generic error messages (no internal leak), SessionDB handles closed properly
Tests: 47 tests covering reload_env, redact_key, API endpoints, schema
generation, path traversal, category merging, internal key stripping,
and full config round-trip.
Original work by @austinpickett (PR #1813), salvaged by @kshitijk4poor
(PR #7621 → #8204), re-salvaged onto current main with stale-branch
regressions removed.
* fix(web): clean up status page cards, always rebuild on `hermes web`
- Remove config version migration alert banner from status page
- Remove config version card (internal noise, not surfaced in TUI)
- Reorder status cards: Agent → Gateway → Active Sessions (3-col grid)
- `hermes web` now always rebuilds from source before serving,
preventing stale web_dist when editing frontend files
* feat(web): full-text search across session messages
- Add GET /api/sessions/search endpoint backed by FTS5
- Auto-append prefix wildcards so partial words match (e.g. 'nimb' → 'nimby')
- Debounced search (300ms) with spinner in the search icon slot
- Search results show FTS5 snippets with highlighted match delimiters
- Expanding a search hit auto-scrolls to the first matching message
- Matching messages get a warning ring + 'match' badge
- Inline term highlighting within Markdown (text, bold, italic, headings, lists)
- Clear button (x) on search input for quick reset
---------
Co-authored-by: emozilla <emozilla@nousresearch.com>
2026-04-12 22:26:28 -07:00
|
|
|
deliver?: string;
|
|
|
|
|
last_run_at?: string | null;
|
|
|
|
|
next_run_at?: string | null;
|
2026-04-13 12:01:12 +02:00
|
|
|
last_error?: string | null;
|
feat: web UI dashboard for managing Hermes Agent (#8756)
* feat: web UI dashboard for managing Hermes Agent (salvage of #8204/#7621)
Adds an embedded web UI dashboard accessible via `hermes web`:
- Status page: agent version, active sessions, gateway status, connected platforms
- Config editor: schema-driven form with tabbed categories, import/export, reset
- API Keys page: set, clear, and view redacted values with category grouping
- Sessions, Skills, Cron, Logs, and Analytics pages
Backend:
- hermes_cli/web_server.py: FastAPI server with REST endpoints
- hermes_cli/config.py: reload_env() utility for hot-reloading .env
- hermes_cli/main.py: `hermes web` subcommand (--port, --host, --no-open)
- cli.py / commands.py: /reload slash command for .env hot-reload
- pyproject.toml: [web] optional dependency extra (fastapi + uvicorn)
- Both update paths (git + zip) auto-build web frontend when npm available
Frontend:
- Vite + React + TypeScript + Tailwind v4 SPA in web/
- shadcn/ui-style components, Nous design language
- Auto-refresh status page, toast notifications, masked password inputs
Security:
- Path traversal guard (resolve().is_relative_to()) on SPA file serving
- CORS localhost-only via allow_origin_regex
- Generic error messages (no internal leak), SessionDB handles closed properly
Tests: 47 tests covering reload_env, redact_key, API endpoints, schema
generation, path traversal, category merging, internal key stripping,
and full config round-trip.
Original work by @austinpickett (PR #1813), salvaged by @kshitijk4poor
(PR #7621 → #8204), re-salvaged onto current main with stale-branch
regressions removed.
* fix(web): clean up status page cards, always rebuild on `hermes web`
- Remove config version migration alert banner from status page
- Remove config version card (internal noise, not surfaced in TUI)
- Reorder status cards: Agent → Gateway → Active Sessions (3-col grid)
- `hermes web` now always rebuilds from source before serving,
preventing stale web_dist when editing frontend files
* feat(web): full-text search across session messages
- Add GET /api/sessions/search endpoint backed by FTS5
- Auto-append prefix wildcards so partial words match (e.g. 'nimb' → 'nimby')
- Debounced search (300ms) with spinner in the search icon slot
- Search results show FTS5 snippets with highlighted match delimiters
- Expanding a search hit auto-scrolls to the first matching message
- Matching messages get a warning ring + 'match' badge
- Inline term highlighting within Markdown (text, bold, italic, headings, lists)
- Clear button (x) on search input for quick reset
---------
Co-authored-by: emozilla <emozilla@nousresearch.com>
2026-04-12 22:26:28 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SkillInfo {
|
|
|
|
|
name: string;
|
|
|
|
|
description: string;
|
|
|
|
|
category: string;
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ToolsetInfo {
|
|
|
|
|
name: string;
|
|
|
|
|
label: string;
|
|
|
|
|
description: string;
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
configured: boolean;
|
|
|
|
|
tools: string[];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SessionSearchResult {
|
|
|
|
|
session_id: string;
|
|
|
|
|
snippet: string;
|
|
|
|
|
role: string | null;
|
|
|
|
|
source: string | null;
|
|
|
|
|
model: string | null;
|
|
|
|
|
session_started: number | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface SessionSearchResponse {
|
|
|
|
|
results: SessionSearchResult[];
|
|
|
|
|
}
|