feat: move dashboard to apps/ so we can share ws proto

This commit is contained in:
Brooklyn Nicholson
2026-05-02 13:38:49 -05:00
parent 5e4473df96
commit a66303eaef
124 changed files with 19499 additions and 426 deletions
+784
View File
@@ -0,0 +1,784 @@
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;
const SESSION_HEADER = "X-Hermes-Session-Token";
function setSessionHeader(headers: Headers, token: string): void {
if (!headers.has(SESSION_HEADER)) {
headers.set(SESSION_HEADER, token);
}
}
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) {
setSessionHeader(headers, 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}`),
getModelsAnalytics: (days: number) =>
fetchJSON<ModelsAnalyticsResponse>(`/api/analytics/models?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"),
getModelOptions: () => fetchJSON<ModelOptionsResponse>("/api/model/options"),
getAuxiliaryModels: () => fetchJSON<AuxiliaryModelsResponse>("/api/model/auxiliary"),
setModelAssignment: (body: ModelAssignmentRequest) =>
fetchJSON<ModelAssignmentResponse>("/api/model/set", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
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",
[SESSION_HEADER]: 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" }),
// Profiles (minimal)
getProfiles: () =>
fetchJSON<{ profiles: ProfileInfo[] }>("/api/profiles"),
createProfile: (body: { name: string; clone_from_default: boolean }) =>
fetchJSON<{ ok: boolean; name: string; path: string }>("/api/profiles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
renameProfile: (name: string, newName: string) =>
fetchJSON<{ ok: boolean; name: string; path: string }>(
`/api/profiles/${encodeURIComponent(name)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_name: newName }),
},
),
deleteProfile: (name: string) =>
fetchJSON<{ ok: boolean }>(
`/api/profiles/${encodeURIComponent(name)}`,
{ method: "DELETE" },
),
getProfileSetupCommand: (name: string) =>
fetchJSON<{ command: string }>(
`/api/profiles/${encodeURIComponent(name)}/setup-command`,
),
getProfileSoul: (name: string) =>
fetchJSON<{ content: string; exists: boolean }>(
`/api/profiles/${encodeURIComponent(name)}/soul`,
),
updateProfileSoul: (name: string, content: string) =>
fetchJSON<{ ok: boolean }>(
`/api/profiles/${encodeURIComponent(name)}/soul`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
},
),
// 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: { [SESSION_HEADER]: 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",
[SESSION_HEADER]: 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",
[SESSION_HEADER]: 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: { [SESSION_HEADER]: 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"),
getPluginsHub: () => fetchJSON<PluginsHubResponse>("/api/dashboard/plugins/hub"),
installAgentPlugin: (body: AgentPluginInstallRequest) =>
fetchJSON<AgentPluginInstallResponse>("/api/dashboard/agent-plugins/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...body }),
}),
enableAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/enable`,
{ method: "POST" },
),
disableAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/disable`,
{ method: "POST" },
),
updateAgentPlugin: (name: string) =>
fetchJSON<AgentPluginUpdateResponse>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/update`,
{ method: "POST" },
),
removeAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string }>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}`,
{ method: "DELETE" },
),
savePluginProviders: (body: PluginProvidersPutRequest) =>
fetchJSON<{ ok: boolean }>("/api/dashboard/plugin-providers", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
setPluginVisibility: (name: string, hidden: boolean) =>
fetchJSON<{ ok: boolean; name: string; hidden: boolean }>(
`/api/dashboard/plugins/${encodeURIComponent(name)}/visibility`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hidden }),
},
),
// 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 ProfileInfo {
name: string;
path: string;
is_default: boolean;
model: string | null;
provider: string | null;
has_env: boolean;
skill_count: number;
}
export interface ModelsAnalyticsModelEntry {
model: string;
provider: 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;
tool_calls: number;
last_used_at: number;
avg_tokens_per_session: number;
capabilities: {
supports_tools?: boolean;
supports_vision?: boolean;
supports_reasoning?: boolean;
context_window?: number;
max_output_tokens?: number;
model_family?: string;
};
}
export interface ModelsAnalyticsResponse {
models: ModelsAnalyticsModelEntry[];
totals: {
distinct_models: number;
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;
};
period_days: number;
}
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;
};
}
// ── Model options / assignment types ──────────────────────────────────
export interface ModelOptionProvider {
name: string;
slug: string;
models?: string[];
total_models?: number;
is_current?: boolean;
is_user_defined?: boolean;
source?: string;
warning?: string;
}
export interface ModelOptionsResponse {
model?: string;
provider?: string;
providers?: ModelOptionProvider[];
}
export interface AuxiliaryTaskAssignment {
task: string;
provider: string;
model: string;
base_url: string;
}
export interface AuxiliaryModelsResponse {
tasks: AuxiliaryTaskAssignment[];
main: { provider: string; model: string };
}
export interface ModelAssignmentRequest {
scope: "main" | "auxiliary";
provider: string;
model: string;
/** For auxiliary: task slot name, "" for all, "__reset__" to reset all. */
task?: string;
}
export interface ModelAssignmentResponse {
ok: boolean;
scope?: string;
provider?: string;
model?: string;
tasks?: string[];
reset?: boolean;
}
// ── 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;
override?: string;
hidden?: boolean;
};
slots?: string[];
entry: string;
css?: string | null;
has_api: boolean;
source: string;
}
export interface HubAgentPluginRow {
name: string;
version: string;
description: string;
source: string;
runtime_status: "disabled" | "enabled" | "inactive";
has_dashboard_manifest: boolean;
dashboard_manifest: PluginManifestResponse | null;
path: string;
can_remove: boolean;
can_update_git: boolean;
auth_required: boolean;
auth_command: string;
user_hidden: boolean;
}
export interface PluginsHubProviders {
memory_provider: string;
memory_options: Array<{ name: string; description: string }>;
context_engine: string;
context_options: Array<{ name: string; description: string }>;
}
export interface PluginsHubResponse {
plugins: HubAgentPluginRow[];
orphan_dashboard_plugins: PluginManifestResponse[];
providers: PluginsHubProviders;
}
export interface AgentPluginInstallRequest {
identifier: string;
force?: boolean;
enable?: boolean;
}
export interface AgentPluginInstallResponse {
ok: boolean;
plugin_name?: string;
warnings?: string[];
missing_env?: string[];
after_install_path?: string | null;
enabled?: boolean;
error?: string;
}
export interface AgentPluginUpdateResponse {
ok: boolean;
name?: string;
output?: string;
unchanged?: boolean;
error?: string;
}
export interface PluginProvidersPutRequest {
memory_provider?: string;
context_engine?: string;
}
+15
View File
@@ -0,0 +1,15 @@
declare global {
interface Window {
/** Set true by the server only for `hermes dashboard --tui` (or HERMES_DASHBOARD_TUI=1). */
__HERMES_DASHBOARD_EMBEDDED_CHAT__?: boolean;
/** @deprecated Older injected name; treated as on when true. */
__HERMES_DASHBOARD_TUI__?: boolean;
}
}
/** True only when the dashboard was started with embedded TUI Chat (`hermes dashboard --tui`). */
export function isDashboardEmbeddedChatEnabled(): boolean {
if (typeof window === "undefined") return false;
if (window.__HERMES_DASHBOARD_EMBEDDED_CHAT__ === true) return true;
return window.__HERMES_DASHBOARD_TUI__ === true;
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Format a token count as a human-readable string (e.g. 1M, 128K, 4096).
* Strips trailing ".0" for clean round numbers.
*/
export function formatTokenCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}K`;
return String(n);
}
+36
View File
@@ -0,0 +1,36 @@
import {
JsonRpcGatewayClient,
type ConnectionState,
type GatewayEvent,
type GatewayEventName,
} from "@hermes/shared";
export type { ConnectionState, GatewayEvent, GatewayEventName };
/**
* Browser wrapper for the shared tui_gateway JSON-RPC client.
*
* Dashboard resolves its token and host from the served page. Desktop uses the
* same shared protocol client, but supplies an absolute wsUrl from Electron.
*/
export class GatewayClient extends JsonRpcGatewayClient {
async connect(token?: string): Promise<void> {
const resolved = token ?? window.__HERMES_SESSION_TOKEN__ ?? "";
if (!resolved) {
throw new Error(
"Session token not available — page must be served by the Hermes dashboard",
);
}
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
await super.connect(
`${scheme}//${location.host}/api/ws?token=${encodeURIComponent(resolved)}`,
);
}
}
declare global {
interface Window {
__HERMES_SESSION_TOKEN__?: string;
}
}
+23
View File
@@ -0,0 +1,23 @@
export function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
const parts = path.split(".");
let cur: unknown = obj;
for (const p of parts) {
if (cur == null || typeof cur !== "object") return undefined;
cur = (cur as Record<string, unknown>)[p];
}
return cur;
}
export function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
const clone = structuredClone(obj);
const parts = path.split(".");
let cur: Record<string, unknown> = clone;
for (let i = 0; i < parts.length - 1; i++) {
if (cur[parts[i]] == null || typeof cur[parts[i]] !== "object") {
cur[parts[i]] = {};
}
cur = cur[parts[i]] as Record<string, unknown>;
}
cur[parts[parts.length - 1]] = value;
return clone;
}
@@ -0,0 +1,34 @@
import type { Translations } from "@/i18n/types";
const BUILTIN: Record<string, keyof Translations["app"]["nav"]> = {
"/chat": "chat",
"/sessions": "sessions",
"/analytics": "analytics",
"/logs": "logs",
"/cron": "cron",
"/skills": "skills",
"/plugins": "plugins",
"/config": "config",
"/env": "keys",
"/docs": "documentation",
};
export function resolvePageTitle(
pathname: string,
t: Translations,
pluginTabs: { path: string; label: string }[],
): string {
const normalized = pathname.replace(/\/$/, "") || "/";
if (normalized === "/") {
return t.app.nav.sessions;
}
const plugin = pluginTabs.find((p) => p.path === normalized);
if (plugin) {
return plugin.label;
}
const key = BUILTIN[normalized];
if (key) {
return t.app.nav[key];
}
return t.app.webUi;
}
+163
View File
@@ -0,0 +1,163 @@
/**
* Slash command execution pipeline for the web chat.
*
* Mirrors the Ink TUI's createSlashHandler.ts:
*
* 1. Parse the command into `name` + `arg`.
* 2. Try `slash.exec` — covers every registry-backed command the terminal
* UI knows about (/help, /resume, /compact, /model, …). Output is
* rendered into the transcript.
* 3. If `slash.exec` errors (command rejected, unknown, or needs client
* behaviour), fall back to `command.dispatch` which returns a typed
* directive: `exec` | `plugin` | `alias` | `skill` | `send`.
* 4. Each directive is dispatched to the appropriate callback.
*
* Keeping the pipeline here (instead of inline in ChatPage) lets future
* clients (SwiftUI, Android) implement the same logic by reading the same
* contract.
*/
import type { GatewayClient } from "@/lib/gatewayClient";
export interface SlashExecResponse {
output?: string;
warning?: string;
}
export type CommandDispatchResponse =
| { type: "exec" | "plugin"; output?: string }
| { type: "alias"; target: string }
| { type: "skill"; name: string; message?: string }
| { type: "send"; message: string };
export interface SlashExecCallbacks {
/** Render a transcript system message. */
sys(text: string): void;
/** Submit a user message to the agent (prompt.submit). */
send(message: string): Promise<void> | void;
}
export interface SlashExecOptions {
/** Raw command including the leading slash (e.g. "/model opus-4.6"). */
command: string;
/** Session id. If empty the call is still issued — some commands are session-less. */
sessionId: string;
gw: GatewayClient;
callbacks: SlashExecCallbacks;
}
export type SlashExecResult = "done" | "sent" | "error";
/**
* Run a slash command. Returns the terminal state so callers can decide
* whether to clear the composer, queue retries, etc.
*/
export async function executeSlash({
command,
sessionId,
gw,
callbacks: { sys, send },
}: SlashExecOptions): Promise<SlashExecResult> {
const { name, arg } = parseSlash(command);
if (!name) {
sys("empty slash command");
return "error";
}
// Primary dispatcher.
try {
const r = await gw.request<SlashExecResponse>("slash.exec", {
command: command.replace(/^\/+/, ""),
session_id: sessionId,
});
const body = r?.output || `/${name}: no output`;
sys(r?.warning ? `warning: ${r.warning}\n${body}` : body);
return "done";
} catch {
/* fall through to command.dispatch */
}
try {
const d = parseCommandDispatch(
await gw.request<unknown>("command.dispatch", {
name,
arg,
session_id: sessionId,
}),
);
if (!d) {
sys("error: invalid response: command.dispatch");
return "error";
}
switch (d.type) {
case "exec":
case "plugin":
sys(d.output ?? "(no output)");
return "done";
case "alias":
return executeSlash({
command: `/${d.target}${arg ? ` ${arg}` : ""}`,
sessionId,
gw,
callbacks: { sys, send },
});
case "skill":
case "send": {
const msg = d.message?.trim() ?? "";
if (!msg) {
sys(
`/${name}: ${d.type === "skill" ? "skill payload missing message" : "empty message"}`,
);
return "error";
}
if (d.type === "skill") sys(`⚡ loading skill: ${d.name}`);
await send(msg);
return "sent";
}
}
} catch (err) {
sys(`error: ${err instanceof Error ? err.message : String(err)}`);
return "error";
}
}
export function parseSlash(command: string): { name: string; arg: string } {
const m = command.replace(/^\/+/, "").match(/^(\S+)\s*(.*)$/);
return m ? { name: m[1], arg: m[2].trim() } : { name: "", arg: "" };
}
function parseCommandDispatch(raw: unknown): CommandDispatchResponse | null {
if (!raw || typeof raw !== "object") return null;
const r = raw as Record<string, unknown>;
const str = (v: unknown) => (typeof v === "string" ? v : undefined);
switch (r.type) {
case "exec":
case "plugin":
return { type: r.type, output: str(r.output) };
case "alias":
return typeof r.target === "string"
? { type: "alias", target: r.target }
: null;
case "skill":
return typeof r.name === "string"
? { type: "skill", name: r.name, message: str(r.message) }
: null;
case "send":
return typeof r.message === "string"
? { type: "send", message: r.message }
: null;
default:
return null;
}
}
+26
View File
@@ -0,0 +1,26 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Relative time from a Unix epoch timestamp (seconds). */
export function timeAgo(ts: number): string {
const delta = Date.now() / 1000 - ts;
if (delta < 60) return "just now";
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
if (delta < 172800) return "yesterday";
return `${Math.floor(delta / 86400)}d ago`;
}
/** Relative time from an ISO-8601 timestamp string. */
export function isoTimeAgo(iso: string): string {
const delta = (Date.now() - new Date(iso).getTime()) / 1000;
if (delta < 0 || Number.isNaN(delta)) return "unknown";
if (delta < 60) return "just now";
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
return `${Math.floor(delta / 86400)}d ago`;
}