mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Dashboard themes now control typography and layout, not just colors. Each built-in theme picks its own fonts, base size, radius, and density so switching produces visible changes beyond hue. Schema additions (per theme): - typography — fontSans, fontMono, fontDisplay, fontUrl, baseSize, lineHeight, letterSpacing. fontUrl is injected as <link> on switch so Google/Bunny/self-hosted stylesheets all work. - layout — radius (any CSS length) and density (compact | comfortable | spacious, multiplies Tailwind spacing). - colorOverrides (optional) — pin individual shadcn tokens that would otherwise derive from the palette. Built-in themes are now distinct beyond palette: - default — system stack, 15px, 0.5rem radius, comfortable - midnight — Inter + JetBrains Mono, 14px, 0.75rem, comfortable - ember — Spectral (serif) + IBM Plex Mono, 15px, 0.25rem - mono — IBM Plex Sans + Mono, 13px, 0 radius, compact - cyberpunk— Share Tech Mono everywhere, 14px, 0 radius, compact - rose — Fraunces (serif) + DM Mono, 16px, 1rem, spacious Also fixes two bugs: 1. Custom user themes silently fell back to default. ThemeProvider only applied BUILTIN_THEMES[name], so YAML files in ~/.hermes/dashboard-themes/ showed in the picker but did nothing. Server now ships the full normalised definition; client applies it. 2. Docs documented a 21-token flat colors schema that never matched the code (applyPalette reads a 3-layer palette). Rewrote the Themes section against the actual shape. Implementation: - web/src/themes/types.ts: extend DashboardTheme with typography, layout, colorOverrides; ThemeListEntry carries optional definition. - web/src/themes/presets.ts: 6 built-ins with distinct typography+layout. - web/src/themes/context.tsx: applyTheme() writes palette+typography+ layout+overrides as CSS vars, injects fontUrl stylesheet, fixes the fallback-to-default bug via resolveTheme(name). - web/src/index.css: html/body/code read the new theme-font vars; --radius-sm/md/lg/xl derive from --theme-radius; --spacing scales with --theme-spacing-mul so Tailwind utilities shift with density. - hermes_cli/web_server.py: _normalise_theme_definition() parses loose YAML (bare hex strings, partial blocks) into the canonical wire shape; /api/dashboard/themes ships full definitions for user themes. - tests/hermes_cli/test_web_server.py: 16 new tests covering the normaliser and discovery (rejection cases, clamping, defaults). - website/docs/user-guide/features/web-dashboard.md: rewrite Themes section with real schema, per-model tables, full YAML example.
514 lines
15 KiB
TypeScript
514 lines
15 KiB
TypeScript
const BASE = "";
|
|
|
|
import type { DashboardTheme } from "@/themes/types";
|
|
|
|
// Ephemeral session token for protected endpoints.
|
|
// Injected into index.html by the server — never fetched via API.
|
|
declare global {
|
|
interface Window {
|
|
__HERMES_SESSION_TOKEN__?: string;
|
|
}
|
|
}
|
|
let _sessionToken: string | null = null;
|
|
|
|
export async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
|
// Inject the session token into all /api/ requests.
|
|
const headers = new Headers(init?.headers);
|
|
const token = window.__HERMES_SESSION_TOKEN__;
|
|
if (token && !headers.has("Authorization")) {
|
|
headers.set("Authorization", `Bearer ${token}`);
|
|
}
|
|
const res = await fetch(`${BASE}${url}`, { ...init, headers });
|
|
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 injected = window.__HERMES_SESSION_TOKEN__;
|
|
if (injected) {
|
|
_sessionToken = injected;
|
|
return _sessionToken;
|
|
}
|
|
throw new Error("Session token not available — page must be served by the Hermes dashboard server");
|
|
}
|
|
|
|
export const api = {
|
|
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
|
|
getSessions: (limit = 20, offset = 0) =>
|
|
fetchJSON<PaginatedSessions>(`/api/sessions?limit=${limit}&offset=${offset}`),
|
|
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"),
|
|
getModelInfo: () => fetchJSON<ModelInfoResponse>("/api/model/info"),
|
|
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)}`),
|
|
|
|
// OAuth provider management
|
|
getOAuthProviders: () =>
|
|
fetchJSON<OAuthProvidersResponse>("/api/providers/oauth"),
|
|
disconnectOAuthProvider: async (providerId: string) => {
|
|
const token = await getSessionToken();
|
|
return fetchJSON<{ ok: boolean; provider: string }>(
|
|
`/api/providers/oauth/${encodeURIComponent(providerId)}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
},
|
|
);
|
|
},
|
|
startOAuthLogin: async (providerId: string) => {
|
|
const token = await getSessionToken();
|
|
return fetchJSON<OAuthStartResponse>(
|
|
`/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: "{}",
|
|
},
|
|
);
|
|
},
|
|
submitOAuthCode: async (providerId: string, sessionId: string, code: string) => {
|
|
const token = await getSessionToken();
|
|
return fetchJSON<OAuthSubmitResponse>(
|
|
`/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
|
|
{
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({ session_id: sessionId, code }),
|
|
},
|
|
);
|
|
},
|
|
pollOAuthSession: (providerId: string, sessionId: string) =>
|
|
fetchJSON<OAuthPollResponse>(
|
|
`/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`,
|
|
),
|
|
cancelOAuthSession: async (sessionId: string) => {
|
|
const token = await getSessionToken();
|
|
return fetchJSON<{ ok: boolean }>(
|
|
`/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
|
|
{
|
|
method: "DELETE",
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
},
|
|
);
|
|
},
|
|
|
|
// Gateway / update actions
|
|
restartGateway: () =>
|
|
fetchJSON<ActionResponse>("/api/gateway/restart", { method: "POST" }),
|
|
updateHermes: () =>
|
|
fetchJSON<ActionResponse>("/api/hermes/update", { method: "POST" }),
|
|
getActionStatus: (name: string, lines = 200) =>
|
|
fetchJSON<ActionStatusResponse>(
|
|
`/api/actions/${encodeURIComponent(name)}/status?lines=${lines}`,
|
|
),
|
|
|
|
// Dashboard plugins
|
|
getPlugins: () =>
|
|
fetchJSON<PluginManifestResponse[]>("/api/dashboard/plugins"),
|
|
rescanPlugins: () =>
|
|
fetchJSON<{ ok: boolean; count: number }>("/api/dashboard/plugins/rescan"),
|
|
|
|
// Dashboard themes
|
|
getThemes: () =>
|
|
fetchJSON<DashboardThemesResponse>("/api/dashboard/themes"),
|
|
setTheme: (name: string) =>
|
|
fetchJSON<{ ok: boolean; theme: string }>("/api/dashboard/theme", {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ name }),
|
|
}),
|
|
};
|
|
|
|
export interface ActionResponse {
|
|
name: string;
|
|
ok: boolean;
|
|
pid: number;
|
|
}
|
|
|
|
export interface ActionStatusResponse {
|
|
exit_code: number | null;
|
|
lines: string[];
|
|
name: string;
|
|
pid: number | null;
|
|
running: boolean;
|
|
}
|
|
|
|
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_health_url: 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 PaginatedSessions {
|
|
sessions: SessionInfo[];
|
|
total: number;
|
|
limit: number;
|
|
offset: number;
|
|
}
|
|
|
|
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;
|
|
api_calls: number;
|
|
}
|
|
|
|
export interface AnalyticsModelEntry {
|
|
model: string;
|
|
input_tokens: number;
|
|
output_tokens: number;
|
|
estimated_cost: number;
|
|
sessions: number;
|
|
api_calls: number;
|
|
}
|
|
|
|
export interface AnalyticsSkillEntry {
|
|
skill: string;
|
|
view_count: number;
|
|
manage_count: number;
|
|
total_count: number;
|
|
percentage: number;
|
|
last_used_at: number | null;
|
|
}
|
|
|
|
export interface AnalyticsSkillsSummary {
|
|
total_skill_loads: number;
|
|
total_skill_edits: number;
|
|
total_skill_actions: number;
|
|
distinct_skills_used: 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;
|
|
total_api_calls: number;
|
|
};
|
|
skills: {
|
|
summary: AnalyticsSkillsSummary;
|
|
top_skills: AnalyticsSkillEntry[];
|
|
};
|
|
}
|
|
|
|
export interface CronJob {
|
|
id: string;
|
|
name?: string;
|
|
prompt: string;
|
|
schedule: { kind: string; expr: string; display: string };
|
|
schedule_display: string;
|
|
enabled: boolean;
|
|
state: string;
|
|
deliver?: string;
|
|
last_run_at?: string | null;
|
|
next_run_at?: string | null;
|
|
last_error?: string | null;
|
|
}
|
|
|
|
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[];
|
|
}
|
|
|
|
// ── Model info types ──────────────────────────────────────────────────
|
|
|
|
export interface ModelInfoResponse {
|
|
model: string;
|
|
provider: string;
|
|
auto_context_length: number;
|
|
config_context_length: number;
|
|
effective_context_length: number;
|
|
capabilities: {
|
|
supports_tools?: boolean;
|
|
supports_vision?: boolean;
|
|
supports_reasoning?: boolean;
|
|
context_window?: number;
|
|
max_output_tokens?: number;
|
|
model_family?: string;
|
|
};
|
|
}
|
|
|
|
// ── OAuth provider types ────────────────────────────────────────────────
|
|
|
|
export interface OAuthProviderStatus {
|
|
logged_in: boolean;
|
|
source?: string | null;
|
|
source_label?: string | null;
|
|
token_preview?: string | null;
|
|
expires_at?: string | null;
|
|
has_refresh_token?: boolean;
|
|
last_refresh?: string | null;
|
|
error?: string;
|
|
}
|
|
|
|
export interface OAuthProvider {
|
|
id: string;
|
|
name: string;
|
|
/** "pkce" (browser redirect + paste code), "device_code" (show code + URL),
|
|
* or "external" (delegated to a separate CLI like Claude Code or Qwen). */
|
|
flow: "pkce" | "device_code" | "external";
|
|
cli_command: string;
|
|
docs_url: string;
|
|
status: OAuthProviderStatus;
|
|
}
|
|
|
|
export interface OAuthProvidersResponse {
|
|
providers: OAuthProvider[];
|
|
}
|
|
|
|
/** Discriminated union — the shape of /start depends on the flow. */
|
|
export type OAuthStartResponse =
|
|
| {
|
|
session_id: string;
|
|
flow: "pkce";
|
|
auth_url: string;
|
|
expires_in: number;
|
|
}
|
|
| {
|
|
session_id: string;
|
|
flow: "device_code";
|
|
user_code: string;
|
|
verification_url: string;
|
|
expires_in: number;
|
|
poll_interval: number;
|
|
};
|
|
|
|
export interface OAuthSubmitResponse {
|
|
ok: boolean;
|
|
status: "approved" | "error";
|
|
message?: string;
|
|
}
|
|
|
|
export interface OAuthPollResponse {
|
|
session_id: string;
|
|
status: "pending" | "approved" | "denied" | "expired" | "error";
|
|
error_message?: string | null;
|
|
expires_at?: number | null;
|
|
}
|
|
|
|
// ── Dashboard theme types ──────────────────────────────────────────────
|
|
|
|
export interface DashboardThemeSummary {
|
|
description: string;
|
|
label: string;
|
|
name: string;
|
|
/** Full theme definition for user themes; undefined for built-ins
|
|
* (which the frontend already has locally). */
|
|
definition?: DashboardTheme;
|
|
}
|
|
|
|
export interface DashboardThemesResponse {
|
|
active: string;
|
|
themes: DashboardThemeSummary[];
|
|
}
|
|
|
|
// ── Dashboard plugin types ─────────────────────────────────────────────
|
|
|
|
export interface PluginManifestResponse {
|
|
name: string;
|
|
label: string;
|
|
description: string;
|
|
icon: string;
|
|
version: string;
|
|
tab: { path: string; position: string };
|
|
entry: string;
|
|
css?: string | null;
|
|
has_api: boolean;
|
|
source: string;
|
|
}
|