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>
This commit is contained in:
Teknium 2026-04-12 22:26:28 -07:00 committed by GitHub
parent c052cf0eea
commit e2a9b5369f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 10187 additions and 3 deletions

View file

@ -0,0 +1,370 @@
import { useEffect, useState, useCallback } from "react";
import {
BarChart3,
Coins,
Cpu,
Database,
Hash,
TrendingUp,
} from "lucide-react";
import { api } from "@/lib/api";
import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
const PERIODS = [
{ label: "7d", days: 7 },
{ label: "30d", days: 30 },
{ label: "90d", days: 90 },
] as const;
const CHART_HEIGHT_PX = 160;
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
function formatCost(n: number): string {
if (n < 0.01) return `$${n.toFixed(4)}`;
return `$${n.toFixed(2)}`;
}
/** Pick the best cost value: actual > estimated > 0 */
function bestCost(entry: { estimated_cost: number; actual_cost?: number }): number {
if (entry.actual_cost && entry.actual_cost > 0) return entry.actual_cost;
return entry.estimated_cost;
}
function formatDate(day: string): string {
try {
const d = new Date(day + "T00:00:00");
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
} catch {
return day;
}
}
function SummaryCard({
icon: Icon,
label,
value,
sub,
}: {
icon: React.ComponentType<{ className?: string }>;
label: string;
value: string;
sub?: string;
}) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{label}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{sub && <p className="text-xs text-muted-foreground mt-1">{sub}</p>}
</CardContent>
</Card>
);
}
function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
if (daily.length === 0) return null;
const maxTokens = Math.max(...daily.map((d) => d.input_tokens + d.output_tokens), 1);
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Daily Token Usage</CardTitle>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 rounded-sm bg-[#ffe6cb]" />
Input
</div>
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 rounded-sm bg-emerald-500" />
Output
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-end gap-[2px]" style={{ height: CHART_HEIGHT_PX }}>
{daily.map((d) => {
const total = d.input_tokens + d.output_tokens;
const inputH = Math.round((d.input_tokens / maxTokens) * CHART_HEIGHT_PX);
const outputH = Math.round((d.output_tokens / maxTokens) * CHART_HEIGHT_PX);
const cacheReadPct = d.cache_read_tokens > 0
? Math.round((d.cache_read_tokens / (d.input_tokens + d.cache_read_tokens)) * 100)
: 0;
return (
<div
key={d.day}
className="flex-1 min-w-0 group relative flex flex-col justify-end"
style={{ height: CHART_HEIGHT_PX }}
>
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none">
<div className="rounded-md bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap">
<div className="font-medium">{formatDate(d.day)}</div>
<div>Input: {formatTokens(d.input_tokens)}</div>
<div>Output: {formatTokens(d.output_tokens)}</div>
{cacheReadPct > 0 && <div>Cache hit: {cacheReadPct}%</div>}
<div>Total: {formatTokens(total)}</div>
{bestCost(d) > 0 && <div>Cost: {formatCost(bestCost(d))}</div>}
</div>
</div>
{/* Input bar */}
<div
className="w-full bg-[#ffe6cb]/70"
style={{ height: Math.max(inputH, total > 0 ? 1 : 0) }}
/>
{/* Output bar */}
<div
className="w-full bg-emerald-500/70"
style={{ height: Math.max(outputH, d.output_tokens > 0 ? 1 : 0) }}
/>
</div>
);
})}
</div>
{/* X-axis labels */}
<div className="flex justify-between mt-2 text-[10px] text-muted-foreground">
<span>{daily.length > 0 ? formatDate(daily[0].day) : ""}</span>
{daily.length > 2 && (
<span>{formatDate(daily[Math.floor(daily.length / 2)].day)}</span>
)}
<span>{daily.length > 1 ? formatDate(daily[daily.length - 1].day) : ""}</span>
</div>
</CardContent>
</Card>
);
}
function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
if (daily.length === 0) return null;
const sorted = [...daily].reverse();
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Daily Breakdown</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<th className="text-left py-2 pr-4 font-medium">Date</th>
<th className="text-right py-2 px-4 font-medium">Sessions</th>
<th className="text-right py-2 px-4 font-medium">Input</th>
<th className="text-right py-2 px-4 font-medium">Output</th>
<th className="text-right py-2 px-4 font-medium">Cache Hit</th>
<th className="text-right py-2 pl-4 font-medium">Cost</th>
</tr>
</thead>
<tbody>
{sorted.map((d) => {
const cost = bestCost(d);
const cacheHitPct = d.cache_read_tokens > 0 && d.input_tokens > 0
? Math.round((d.cache_read_tokens / d.input_tokens) * 100)
: 0;
return (
<tr key={d.day} className="border-b border-border/50 hover:bg-secondary/20 transition-colors">
<td className="py-2 pr-4 font-medium">{formatDate(d.day)}</td>
<td className="text-right py-2 px-4 text-muted-foreground">{d.sessions}</td>
<td className="text-right py-2 px-4">
<span className="text-[#ffe6cb]">{formatTokens(d.input_tokens)}</span>
</td>
<td className="text-right py-2 px-4">
<span className="text-emerald-400">{formatTokens(d.output_tokens)}</span>
</td>
<td className="text-right py-2 px-4 text-muted-foreground">
{cacheHitPct > 0 ? `${cacheHitPct}%` : "—"}
</td>
<td className="text-right py-2 pl-4 text-muted-foreground">
{cost > 0 ? formatCost(cost) : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
if (models.length === 0) return null;
const sorted = [...models].sort(
(a, b) => b.input_tokens + b.output_tokens - (a.input_tokens + a.output_tokens),
);
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Cpu className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Per-Model Breakdown</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<th className="text-left py-2 pr-4 font-medium">Model</th>
<th className="text-right py-2 px-4 font-medium">Sessions</th>
<th className="text-right py-2 px-4 font-medium">Tokens</th>
<th className="text-right py-2 pl-4 font-medium">Cost</th>
</tr>
</thead>
<tbody>
{sorted.map((m) => (
<tr key={m.model} className="border-b border-border/50 hover:bg-secondary/20 transition-colors">
<td className="py-2 pr-4">
<span className="font-mono-ui text-xs">{m.model}</span>
</td>
<td className="text-right py-2 px-4 text-muted-foreground">{m.sessions}</td>
<td className="text-right py-2 px-4">
<span className="text-[#ffe6cb]">{formatTokens(m.input_tokens)}</span>
{" / "}
<span className="text-emerald-400">{formatTokens(m.output_tokens)}</span>
</td>
<td className="text-right py-2 pl-4 text-muted-foreground">
{m.estimated_cost > 0 ? formatCost(m.estimated_cost) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
export default function AnalyticsPage() {
const [days, setDays] = useState(30);
const [data, setData] = useState<AnalyticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
setLoading(true);
setError(null);
api
.getAnalytics(days)
.then(setData)
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}, [days]);
useEffect(() => {
load();
}, [load]);
return (
<div className="flex flex-col gap-6">
{/* Period selector */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground font-medium">Period:</span>
{PERIODS.map((p) => (
<Button
key={p.label}
variant={days === p.days ? "default" : "outline"}
size="sm"
className="text-xs h-7"
onClick={() => setDays(p.days)}
>
{p.label}
</Button>
))}
</div>
{loading && !data && (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)}
{error && (
<Card>
<CardContent className="py-6">
<p className="text-sm text-destructive text-center">{error}</p>
</CardContent>
</Card>
)}
{data && (
<>
{/* Summary cards — matches hermes's token model */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<SummaryCard
icon={Hash}
label="Total Tokens"
value={formatTokens(data.totals.total_input + data.totals.total_output)}
sub={`${formatTokens(data.totals.total_input)} in / ${formatTokens(data.totals.total_output)} out`}
/>
<SummaryCard
icon={Database}
label="Cache Hit"
value={data.totals.total_cache_read > 0
? `${Math.round((data.totals.total_cache_read / (data.totals.total_input + data.totals.total_cache_read)) * 100)}%`
: "—"}
sub={`${formatTokens(data.totals.total_cache_read)} tokens from cache`}
/>
<SummaryCard
icon={Coins}
label="Total Cost"
value={formatCost(
data.totals.total_actual_cost > 0
? data.totals.total_actual_cost
: data.totals.total_estimated_cost
)}
sub={data.totals.total_actual_cost > 0 ? "actual" : `estimated · last ${days}d`}
/>
<SummaryCard
icon={BarChart3}
label="Total Sessions"
value={String(data.totals.total_sessions)}
sub={`~${(data.totals.total_sessions / days).toFixed(1)}/day avg`}
/>
</div>
{/* Bar chart */}
<TokenBarChart daily={data.daily} />
{/* Tables */}
<DailyTable daily={data.daily} />
<ModelTable models={data.by_model} />
</>
)}
{data && data.daily.length === 0 && data.by_model.length === 0 && (
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center text-muted-foreground">
<BarChart3 className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">No usage data for this period</p>
<p className="text-xs mt-1 text-muted-foreground/60">Start a session to see analytics here</p>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View file

@ -0,0 +1,451 @@
import { useEffect, useRef, useState, useMemo } from "react";
import {
Code,
Download,
FormInput,
RotateCcw,
Save,
Search,
Upload,
X,
ChevronRight,
Settings2,
FileText,
} from "lucide-react";
import { api } from "@/lib/api";
import { getNestedValue, setNestedValue } from "@/lib/nested";
import { useToast } from "@/hooks/useToast";
import { Toast } from "@/components/Toast";
import { AutoField } from "@/components/AutoField";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const CATEGORY_ICONS: Record<string, string> = {
general: "⚙️",
agent: "🤖",
terminal: "💻",
display: "🎨",
delegation: "👥",
memory: "🧠",
compression: "📦",
security: "🔒",
browser: "🌐",
voice: "🎙️",
tts: "🔊",
stt: "👂",
logging: "📋",
discord: "💬",
auxiliary: "🔧",
};
function prettyCategoryName(cat: string): string {
if (cat === "tts") return "Text-to-Speech";
if (cat === "stt") return "Speech-to-Text";
return cat.charAt(0).toUpperCase() + cat.slice(1);
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function ConfigPage() {
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
const [schema, setSchema] = useState<Record<string, Record<string, unknown>> | null>(null);
const [categoryOrder, setCategoryOrder] = useState<string[]>([]);
const [defaults, setDefaults] = useState<Record<string, unknown> | null>(null);
const [saving, setSaving] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [yamlMode, setYamlMode] = useState(false);
const [yamlText, setYamlText] = useState("");
const [yamlLoading, setYamlLoading] = useState(false);
const [yamlSaving, setYamlSaving] = useState(false);
const [activeCategory, setActiveCategory] = useState<string>("");
const { toast, showToast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
api.getConfig().then(setConfig).catch(() => {});
api
.getSchema()
.then((resp) => {
setSchema(resp.fields as Record<string, Record<string, unknown>>);
setCategoryOrder(resp.category_order ?? []);
})
.catch(() => {});
api.getDefaults().then(setDefaults).catch(() => {});
}, []);
// Set active category when categories load
useEffect(() => {
if (categoryOrder.length > 0 && !activeCategory) {
setActiveCategory(categoryOrder[0]);
}
}, [categoryOrder, activeCategory]);
// Load YAML when switching to YAML mode
useEffect(() => {
if (yamlMode) {
setYamlLoading(true);
api
.getConfigRaw()
.then((resp) => setYamlText(resp.yaml))
.catch(() => showToast("Failed to load raw config", "error"))
.finally(() => setYamlLoading(false));
}
}, [yamlMode]);
/* ---- Categories ---- */
const categories = useMemo(() => {
if (!schema) return [];
const allCats = [...new Set(Object.values(schema).map((s) => String(s.category ?? "general")))];
const ordered = categoryOrder.filter((c) => allCats.includes(c));
const extra = allCats.filter((c) => !categoryOrder.includes(c)).sort();
return [...ordered, ...extra];
}, [schema, categoryOrder]);
/* ---- Category field counts ---- */
const categoryCounts = useMemo(() => {
if (!schema) return {};
const counts: Record<string, number> = {};
for (const s of Object.values(schema)) {
const cat = String(s.category ?? "general");
counts[cat] = (counts[cat] || 0) + 1;
}
return counts;
}, [schema]);
/* ---- Search ---- */
const isSearching = searchQuery.trim().length > 0;
const lowerSearch = searchQuery.toLowerCase();
const searchMatchedFields = useMemo(() => {
if (!isSearching || !schema) return [];
return Object.entries(schema).filter(([key, s]) => {
const label = key.split(".").pop() ?? key;
const humanLabel = label.replace(/_/g, " ");
return (
key.toLowerCase().includes(lowerSearch) ||
humanLabel.toLowerCase().includes(lowerSearch) ||
String(s.category ?? "").toLowerCase().includes(lowerSearch) ||
String(s.description ?? "").toLowerCase().includes(lowerSearch)
);
});
}, [isSearching, lowerSearch, schema]);
/* ---- Active tab fields ---- */
const activeFields = useMemo(() => {
if (!schema || isSearching) return [];
return Object.entries(schema).filter(
([, s]) => String(s.category ?? "general") === activeCategory
);
}, [schema, activeCategory, isSearching]);
/* ---- Handlers ---- */
const handleSave = async () => {
if (!config) return;
setSaving(true);
try {
await api.saveConfig(config);
showToast("Configuration saved", "success");
} catch (e) {
showToast(`Failed to save: ${e}`, "error");
} finally {
setSaving(false);
}
};
const handleYamlSave = async () => {
setYamlSaving(true);
try {
await api.saveConfigRaw(yamlText);
showToast("YAML config saved", "success");
api.getConfig().then(setConfig).catch(() => {});
} catch (e) {
showToast(`Failed to save YAML: ${e}`, "error");
} finally {
setYamlSaving(false);
}
};
const handleReset = () => {
if (defaults) setConfig(structuredClone(defaults));
};
const handleExport = () => {
if (!config) return;
const blob = new Blob([JSON.stringify(config, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "hermes-config.json";
a.click();
URL.revokeObjectURL(url);
};
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const imported = JSON.parse(reader.result as string);
setConfig(imported);
showToast("Config imported — review and save", "success");
} catch {
showToast("Invalid JSON file", "error");
}
};
reader.readAsText(file);
};
/* ---- Loading ---- */
if (!config || !schema) {
return (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
/* ---- Render field list (shared between search & normal) ---- */
const renderFields = (fields: [string, Record<string, unknown>][], showCategory = false) => {
let lastSection = "";
let lastCat = "";
return fields.map(([key, s]) => {
const parts = key.split(".");
const section = parts.length > 1 ? parts[0] : "";
const cat = String(s.category ?? "general");
const showCatBadge = showCategory && cat !== lastCat;
const showSection = !showCategory && section && section !== lastSection && section !== activeCategory;
lastSection = section;
lastCat = cat;
return (
<div key={key}>
{showCatBadge && (
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
<span className="text-base">{CATEGORY_ICONS[cat] || "📄"}</span>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{prettyCategoryName(cat)}
</span>
<div className="flex-1 border-t border-border" />
</div>
)}
{showSection && (
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{section.replace(/_/g, " ")}
</span>
<div className="flex-1 border-t border-border" />
</div>
)}
<div className="py-1">
<AutoField
schemaKey={key}
schema={s}
value={getNestedValue(config, key)}
onChange={(v) => setConfig(setNestedValue(config, key, v))}
/>
</div>
</div>
);
});
};
return (
<div className="flex flex-col gap-4">
<Toast toast={toast} />
{/* ═══════════════ Header Bar ═══════════════ */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<code className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5 rounded">
~/.hermes/config.yaml
</code>
</div>
<div className="flex items-center gap-1.5">
<Button variant="ghost" size="sm" onClick={handleExport} title="Export config as JSON" aria-label="Export config">
<Download className="h-3.5 w-3.5" />
</Button>
<Button variant="ghost" size="sm" onClick={() => fileInputRef.current?.click()} title="Import config from JSON" aria-label="Import config">
<Upload className="h-3.5 w-3.5" />
</Button>
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
<Button variant="ghost" size="sm" onClick={handleReset} title="Reset to defaults" aria-label="Reset to defaults">
<RotateCcw className="h-3.5 w-3.5" />
</Button>
<div className="w-px h-5 bg-border mx-1" />
<Button
variant={yamlMode ? "default" : "outline"}
size="sm"
onClick={() => setYamlMode(!yamlMode)}
className="gap-1.5"
>
{yamlMode ? (
<>
<FormInput className="h-3.5 w-3.5" />
Form
</>
) : (
<>
<Code className="h-3.5 w-3.5" />
YAML
</>
)}
</Button>
{yamlMode ? (
<Button size="sm" onClick={handleYamlSave} disabled={yamlSaving} className="gap-1.5">
<Save className="h-3.5 w-3.5" />
{yamlSaving ? "Saving..." : "Save"}
</Button>
) : (
<Button size="sm" onClick={handleSave} disabled={saving} className="gap-1.5">
<Save className="h-3.5 w-3.5" />
{saving ? "Saving..." : "Save"}
</Button>
)}
</div>
</div>
{/* ═══════════════ YAML Mode ═══════════════ */}
{yamlMode ? (
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm flex items-center gap-2">
<FileText className="h-4 w-4" />
Raw YAML Configuration
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{yamlLoading ? (
<div className="flex items-center justify-center py-12">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : (
<textarea
className="flex min-h-[600px] w-full bg-transparent px-4 py-3 text-sm font-mono leading-relaxed placeholder:text-muted-foreground focus-visible:outline-none border-t border-border"
value={yamlText}
onChange={(e) => setYamlText(e.target.value)}
spellCheck={false}
/>
)}
</CardContent>
</Card>
) : (
/* ═══════════════ Form Mode ═══════════════ */
<div className="flex gap-4" style={{ minHeight: "calc(100vh - 180px)" }}>
{/* ---- Sidebar ---- */}
<div className="w-52 shrink-0">
<div className="sticky top-[72px] flex flex-col gap-1">
{/* Search */}
<div className="relative mb-2">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="pl-8 h-8 text-xs"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearchQuery("")}
>
<X className="h-3 w-3" />
</button>
)}
</div>
{/* Category nav */}
{categories.map((cat) => {
const isActive = !isSearching && activeCategory === cat;
return (
<button
key={cat}
type="button"
onClick={() => {
setSearchQuery("");
setActiveCategory(cat);
}}
className={`group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span className="text-sm leading-none">{CATEGORY_ICONS[cat] || "📄"}</span>
<span className="flex-1 truncate">{prettyCategoryName(cat)}</span>
<span className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}>
{categoryCounts[cat] || 0}
</span>
{isActive && (
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
)}
</button>
);
})}
</div>
</div>
{/* ---- Content ---- */}
<div className="flex-1 min-w-0">
{isSearching ? (
/* Search results */
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Search className="h-4 w-4" />
Search Results
</CardTitle>
<Badge variant="secondary" className="text-[10px]">
{searchMatchedFields.length} field{searchMatchedFields.length !== 1 ? "s" : ""}
</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-2 px-4 pb-4">
{searchMatchedFields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
No fields match "<span className="text-foreground">{searchQuery}</span>"
</p>
) : (
renderFields(searchMatchedFields, true)
)}
</CardContent>
</Card>
) : (
/* Active category */
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<span className="text-base">{CATEGORY_ICONS[activeCategory] || "📄"}</span>
{prettyCategoryName(activeCategory)}
</CardTitle>
<Badge variant="secondary" className="text-[10px]">
{activeFields.length} field{activeFields.length !== 1 ? "s" : ""}
</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-2 px-4 pb-4">
{renderFields(activeFields)}
</CardContent>
</Card>
)}
</div>
</div>
)}
</div>
);
}

279
web/src/pages/CronPage.tsx Normal file
View file

@ -0,0 +1,279 @@
import { useEffect, useState } from "react";
import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
import { api } from "@/lib/api";
import type { CronJob } from "@/lib/api";
import { useToast } from "@/hooks/useToast";
import { Toast } from "@/components/Toast";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select } from "@/components/ui/select";
function formatTime(iso?: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return d.toLocaleString();
}
const STATUS_VARIANT: Record<string, "success" | "warning" | "destructive"> = {
enabled: "success",
paused: "warning",
error: "destructive",
};
export default function CronPage() {
const [jobs, setJobs] = useState<CronJob[]>([]);
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
// New job form state
const [prompt, setPrompt] = useState("");
const [schedule, setSchedule] = useState("");
const [name, setName] = useState("");
const [deliver, setDeliver] = useState("local");
const [creating, setCreating] = useState(false);
const loadJobs = () => {
api
.getCronJobs()
.then(setJobs)
.catch(() => showToast("Failed to load cron jobs", "error"))
.finally(() => setLoading(false));
};
useEffect(() => {
loadJobs();
}, []);
const handleCreate = async () => {
if (!prompt.trim() || !schedule.trim()) {
showToast("Prompt and schedule are required", "error");
return;
}
setCreating(true);
try {
await api.createCronJob({
prompt: prompt.trim(),
schedule: schedule.trim(),
name: name.trim() || undefined,
deliver,
});
showToast("Cron job created", "success");
setPrompt("");
setSchedule("");
setName("");
setDeliver("local");
loadJobs();
} catch (e) {
showToast(`Failed to create job: ${e}`, "error");
} finally {
setCreating(false);
}
};
const handlePauseResume = async (job: CronJob) => {
try {
if (job.status === "paused") {
await api.resumeCronJob(job.id);
showToast(`Resumed "${job.name || job.prompt.slice(0, 30)}"`, "success");
} else {
await api.pauseCronJob(job.id);
showToast(`Paused "${job.name || job.prompt.slice(0, 30)}"`, "success");
}
loadJobs();
} catch (e) {
showToast(`Action failed: ${e}`, "error");
}
};
const handleTrigger = async (job: CronJob) => {
try {
await api.triggerCronJob(job.id);
showToast(`Triggered "${job.name || job.prompt.slice(0, 30)}"`, "success");
loadJobs();
} catch (e) {
showToast(`Trigger failed: ${e}`, "error");
}
};
const handleDelete = async (job: CronJob) => {
try {
await api.deleteCronJob(job.id);
showToast(`Deleted "${job.name || job.prompt.slice(0, 30)}"`, "success");
loadJobs();
} catch (e) {
showToast(`Delete failed: ${e}`, "error");
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
{/* Create new job form */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Plus className="h-4 w-4" />
New Cron Job
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="cron-name">Name (optional)</Label>
<Input
id="cron-name"
placeholder="e.g. Daily summary"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-prompt">Prompt</Label>
<textarea
id="cron-prompt"
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder="What should the agent do on each run?"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="grid gap-2">
<Label htmlFor="cron-schedule">Schedule (cron expression)</Label>
<Input
id="cron-schedule"
placeholder="0 9 * * *"
value={schedule}
onChange={(e) => setSchedule(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-deliver">Deliver to</Label>
<Select
id="cron-deliver"
value={deliver}
onChange={(e) => setDeliver(e.target.value)}
>
<option value="local">Local</option>
<option value="telegram">Telegram</option>
<option value="discord">Discord</option>
<option value="slack">Slack</option>
<option value="email">Email</option>
</Select>
</div>
<div className="flex items-end">
<Button onClick={handleCreate} disabled={creating} className="w-full">
<Plus className="h-3 w-3" />
{creating ? "Creating..." : "Create"}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Jobs list */}
<div className="flex flex-col gap-3">
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Clock className="h-4 w-4" />
Scheduled Jobs ({jobs.length})
</h2>
{jobs.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No cron jobs configured. Create one above.
</CardContent>
</Card>
)}
{jobs.map((job) => (
<Card key={job.id}>
<CardContent className="flex items-center gap-4 py-4">
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate">
{job.name || job.prompt.slice(0, 60) + (job.prompt.length > 60 ? "..." : "")}
</span>
<Badge variant={STATUS_VARIANT[job.status] ?? "secondary"}>
{job.status}
</Badge>
{job.deliver && job.deliver !== "local" && (
<Badge variant="outline">{job.deliver}</Badge>
)}
</div>
{job.name && (
<p className="text-xs text-muted-foreground truncate mb-1">
{job.prompt.slice(0, 100)}{job.prompt.length > 100 ? "..." : ""}
</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="font-mono">{job.schedule}</span>
<span>Last: {formatTime(job.last_run_at)}</span>
<span>Next: {formatTime(job.next_run_at)}</span>
</div>
{job.error && (
<p className="text-xs text-destructive mt-1">{job.error}</p>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
<Button
variant="ghost"
size="icon"
title={job.status === "paused" ? "Resume" : "Pause"}
aria-label={job.status === "paused" ? "Resume job" : "Pause job"}
onClick={() => handlePauseResume(job)}
>
{job.status === "paused" ? (
<Play className="h-4 w-4 text-success" />
) : (
<Pause className="h-4 w-4 text-warning" />
)}
</Button>
<Button
variant="ghost"
size="icon"
title="Trigger now"
aria-label="Trigger job now"
onClick={() => handleTrigger(job)}
>
<Zap className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
title="Delete"
aria-label="Delete job"
onClick={() => handleDelete(job)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

614
web/src/pages/EnvPage.tsx Normal file
View file

@ -0,0 +1,614 @@
import { useEffect, useState, useMemo } from "react";
import {
Eye,
EyeOff,
ExternalLink,
KeyRound,
MessageSquare,
Pencil,
Save,
Settings,
Trash2,
X,
Zap,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { api } from "@/lib/api";
import type { EnvVarInfo } from "@/lib/api";
import { useToast } from "@/hooks/useToast";
import { Toast } from "@/components/Toast";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
/* ------------------------------------------------------------------ */
/* Provider grouping */
/* ------------------------------------------------------------------ */
/** Map env-var key prefixes to a human-friendly provider name + ordering. */
const PROVIDER_GROUPS: { prefix: string; name: string; priority: number }[] = [
// Nous Portal first
{ prefix: "NOUS_", name: "Nous Portal", priority: 0 },
// Then alphabetical by display name
{ prefix: "ANTHROPIC_", name: "Anthropic", priority: 1 },
{ prefix: "DASHSCOPE_", name: "DashScope (Qwen)", priority: 2 },
{ prefix: "HERMES_QWEN_", name: "DashScope (Qwen)", priority: 2 },
{ prefix: "DEEPSEEK_", name: "DeepSeek", priority: 3 },
{ prefix: "GOOGLE_", name: "Gemini", priority: 4 },
{ prefix: "GEMINI_", name: "Gemini", priority: 4 },
{ prefix: "GLM_", name: "GLM / Z.AI", priority: 5 },
{ prefix: "ZAI_", name: "GLM / Z.AI", priority: 5 },
{ prefix: "Z_AI_", name: "GLM / Z.AI", priority: 5 },
{ prefix: "HF_", name: "Hugging Face", priority: 6 },
{ prefix: "KIMI_", name: "Kimi / Moonshot", priority: 7 },
{ prefix: "MINIMAX_CN_", name: "MiniMax (China)", priority: 9 },
{ prefix: "MINIMAX_", name: "MiniMax", priority: 8 },
{ prefix: "OPENCODE_GO_", name: "OpenCode Go", priority: 10 },
{ prefix: "OPENCODE_ZEN_", name: "OpenCode Zen", priority: 11 },
{ prefix: "OPENROUTER_", name: "OpenRouter", priority: 12 },
{ prefix: "XIAOMI_", name: "Xiaomi MiMo", priority: 13 },
];
function getProviderGroup(key: string): string {
for (const g of PROVIDER_GROUPS) {
if (key.startsWith(g.prefix)) return g.name;
}
return "Other";
}
function getProviderPriority(groupName: string): number {
const entry = PROVIDER_GROUPS.find((g) => g.name === groupName);
return entry?.priority ?? 99;
}
interface ProviderGroup {
name: string;
priority: number;
entries: [string, EnvVarInfo][];
hasAnySet: boolean;
}
const CATEGORY_META: Record<string, { label: string; icon: typeof KeyRound }> = {
provider: { label: "LLM Providers", icon: Zap },
tool: { label: "Tool API Keys", icon: KeyRound },
messaging: { label: "Messaging Platforms", icon: MessageSquare },
setting: { label: "Agent Settings", icon: Settings },
};
/* ------------------------------------------------------------------ */
/* EnvVarRow — single key edit row */
/* ------------------------------------------------------------------ */
function EnvVarRow({
varKey,
info,
edits,
setEdits,
revealed,
saving,
onSave,
onClear,
onReveal,
onCancelEdit,
compact = false,
}: {
varKey: string;
info: EnvVarInfo;
edits: Record<string, string>;
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
revealed: Record<string, string>;
saving: string | null;
onSave: (key: string) => void;
onClear: (key: string) => void;
onReveal: (key: string) => void;
onCancelEdit: (key: string) => void;
compact?: boolean;
}) {
const isEditing = edits[varKey] !== undefined;
const isRevealed = !!revealed[varKey];
const displayValue = isRevealed ? revealed[varKey] : (info.redacted_value ?? "---");
// Compact inline row for unset, non-editing keys (used inside provider groups)
if (compact && !info.is_set && !isEditing) {
return (
<div className="flex items-center justify-between gap-3 py-1.5 opacity-50 hover:opacity-100 transition-opacity">
<div className="flex items-center gap-2 min-w-0">
<span className="font-mono-ui text-[0.7rem] text-muted-foreground">{varKey}</span>
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">{info.description}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{info.url && (
<a href={info.url} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
Get key <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
<Button size="sm" variant="outline" className="h-6 text-[0.6rem] px-2"
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
<Pencil className="h-2.5 w-2.5" />
Set
</Button>
</div>
</div>
);
}
// Non-compact unset row
if (!info.is_set && !isEditing) {
return (
<div className="flex items-center justify-between gap-3 border border-border/50 px-4 py-2.5 opacity-60 hover:opacity-100 transition-opacity">
<div className="flex items-center gap-3 min-w-0">
<Label className="font-mono-ui text-[0.7rem] text-muted-foreground">{varKey}</Label>
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">{info.description}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{info.url && (
<a href={info.url} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
Get key <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
<Button size="sm" variant="outline" className="h-7 text-[0.6rem]"
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
<Pencil className="h-3 w-3" />
Set
</Button>
</div>
</div>
);
}
// Full expanded row for set keys or keys being edited
return (
<div className="grid gap-2 border border-border p-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<Label className="font-mono-ui text-[0.7rem]">{varKey}</Label>
<Badge variant={info.is_set ? "success" : "outline"}>
{info.is_set ? "Set" : "Not set"}
</Badge>
</div>
{info.url && (
<a href={info.url} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
Get key <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</div>
<p className="text-xs text-muted-foreground">{info.description}</p>
{info.tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{info.tools.map((tool) => (
<Badge key={tool} variant="secondary" className="text-[0.6rem] py-0 px-1.5">{tool}</Badge>
))}
</div>
)}
{!isEditing && (
<div className="flex items-center gap-2">
<div className={`flex-1 border border-border px-3 py-2 font-mono-ui text-xs ${
isRevealed ? "bg-background text-foreground select-all" : "bg-muted/30 text-muted-foreground"
}`}>
{info.is_set ? displayValue : "---"}
</div>
{info.is_set && (
<Button size="sm" variant="ghost" onClick={() => onReveal(varKey)}
title={isRevealed ? "Hide value" : "Show real value"}
aria-label={isRevealed ? `Hide ${varKey}` : `Reveal ${varKey}`}>
{isRevealed
? <EyeOff className="h-4 w-4" />
: <Eye className="h-4 w-4" />}
</Button>
)}
<Button size="sm" variant="outline"
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
<Pencil className="h-3 w-3" />
{info.is_set ? "Replace" : "Set"}
</Button>
{info.is_set && (
<Button size="sm" variant="ghost"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onClear(varKey)} disabled={saving === varKey}>
<Trash2 className="h-3 w-3" />
{saving === varKey ? "..." : "Clear"}
</Button>
)}
</div>
)}
{isEditing && (
<div className="flex items-center gap-2">
<Input autoFocus type="text" value={edits[varKey]}
onChange={(e) => setEdits((prev) => ({ ...prev, [varKey]: e.target.value }))}
placeholder={info.is_set ? `Replace current value (${info.redacted_value ?? "---"})` : "Enter value..."}
className="flex-1 font-mono-ui text-xs" />
<Button size="sm" onClick={() => onSave(varKey)}
disabled={saving === varKey || !edits[varKey]}>
<Save className="h-3 w-3" />
{saving === varKey ? "..." : "Save"}
</Button>
<Button size="sm" variant="ghost" onClick={() => onCancelEdit(varKey)}>
<X className="h-3 w-3" /> Cancel
</Button>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* ProviderGroupCard — groups API key + base URL per provider */
/* ------------------------------------------------------------------ */
function ProviderGroupCard({
group,
edits,
setEdits,
revealed,
saving,
onSave,
onClear,
onReveal,
onCancelEdit,
}: {
group: ProviderGroup;
edits: Record<string, string>;
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
revealed: Record<string, string>;
saving: string | null;
onSave: (key: string) => void;
onClear: (key: string) => void;
onReveal: (key: string) => void;
onCancelEdit: (key: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
// Separate API keys from base URLs and other settings
const apiKeys = group.entries.filter(([k]) => k.endsWith("_API_KEY") || k.endsWith("_TOKEN"));
const baseUrls = group.entries.filter(([k]) => k.endsWith("_BASE_URL"));
const other = group.entries.filter(([k]) => !k.endsWith("_API_KEY") && !k.endsWith("_TOKEN") && !k.endsWith("_BASE_URL"));
const hasAnyConfigured = group.entries.some(([, info]) => info.is_set);
const configuredCount = group.entries.filter(([, info]) => info.is_set).length;
// Get a representative URL for "Get key" link
const keyUrl = apiKeys.find(([, info]) => info.url)?.[1]?.url ?? null;
return (
<div className="border border-border">
{/* Header — always visible */}
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="flex w-full items-center justify-between gap-3 px-4 py-3 cursor-pointer hover:bg-primary/5 transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
{expanded ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
<span className="font-semibold text-sm tracking-wide">{group.name}</span>
{hasAnyConfigured && (
<Badge variant="success" className="text-[0.6rem]">
{configuredCount} set
</Badge>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{keyUrl && (
<a href={keyUrl} target="_blank" rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
onClick={(e) => e.stopPropagation()}>
Get key <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
<span className="text-[0.65rem] text-muted-foreground/60">
{group.entries.length} key{group.entries.length !== 1 ? "s" : ""}
</span>
</div>
</button>
{/* Expanded content */}
{expanded && (
<div className="border-t border-border px-4 py-3 grid gap-2">
{/* API keys first (most important) */}
{apiKeys.map(([key, info]) => (
<EnvVarRow
key={key} varKey={key} info={info} compact
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
/>
))}
{/* Base URLs (secondary) */}
{baseUrls.map(([key, info]) => (
<EnvVarRow
key={key} varKey={key} info={info} compact
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
/>
))}
{/* Anything else */}
{other.map(([key, info]) => (
<EnvVarRow
key={key} varKey={key} info={info} compact
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
/>
))}
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Main page */
/* ------------------------------------------------------------------ */
export default function EnvPage() {
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null);
const [edits, setEdits] = useState<Record<string, string>>({});
const [revealed, setRevealed] = useState<Record<string, string>>({});
const [saving, setSaving] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(true); // Show all providers by default
const { toast, showToast } = useToast();
useEffect(() => {
api.getEnvVars().then(setVars).catch(() => {});
}, []);
const handleSave = async (key: string) => {
const value = edits[key];
if (!value) return;
setSaving(key);
try {
await api.setEnvVar(key, value);
setVars((prev) =>
prev
? {
...prev,
[key]: { ...prev[key], is_set: true, redacted_value: value.slice(0, 4) + "..." + value.slice(-4) },
}
: prev,
);
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
showToast(`${key} saved`, "success");
} catch (e) {
showToast(`Failed to save ${key}: ${e}`, "error");
} finally {
setSaving(null);
}
};
const handleClear = async (key: string) => {
setSaving(key);
try {
await api.deleteEnvVar(key);
setVars((prev) =>
prev
? { ...prev, [key]: { ...prev[key], is_set: false, redacted_value: null } }
: prev,
);
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
showToast(`${key} removed`, "success");
} catch (e) {
showToast(`Failed to remove ${key}: ${e}`, "error");
} finally {
setSaving(null);
}
};
const handleReveal = async (key: string) => {
if (revealed[key]) {
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
return;
}
try {
const resp = await api.revealEnvVar(key);
setRevealed((prev) => ({ ...prev, [key]: resp.value }));
} catch {
showToast(`Failed to reveal ${key}`, "error");
}
};
const cancelEdit = (key: string) => {
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
};
/* ---- Build provider groups ---- */
const { providerGroups, nonProviderGrouped } = useMemo(() => {
if (!vars) return { providerGroups: [], nonProviderGrouped: [] };
const providerEntries = Object.entries(vars).filter(
([, info]) => info.category === "provider" && (showAdvanced || !info.advanced),
);
// Group by provider
const groupMap = new Map<string, [string, EnvVarInfo][]>();
for (const entry of providerEntries) {
const groupName = getProviderGroup(entry[0]);
if (!groupMap.has(groupName)) groupMap.set(groupName, []);
groupMap.get(groupName)!.push(entry);
}
const groups: ProviderGroup[] = Array.from(groupMap.entries())
.map(([name, entries]) => ({
name,
priority: getProviderPriority(name),
entries,
hasAnySet: entries.some(([, info]) => info.is_set),
}))
.sort((a, b) => a.priority - b.priority);
// Non-provider categories
const otherCategories = ["tool", "messaging", "setting"];
const nonProvider = otherCategories.map((cat) => {
const entries = Object.entries(vars).filter(
([, info]) => info.category === cat && (showAdvanced || !info.advanced),
);
const setEntries = entries.filter(([, info]) => info.is_set);
const unsetEntries = entries.filter(([, info]) => !info.is_set);
return {
...CATEGORY_META[cat],
category: cat,
setEntries,
unsetEntries,
totalEntries: entries.length,
};
});
return { providerGroups: groups, nonProviderGrouped: nonProvider };
}, [vars, showAdvanced]);
if (!vars) {
return (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
const totalProviders = providerGroups.length;
const configuredProviders = providerGroups.filter((g) => g.hasAnySet).length;
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<p className="text-sm text-muted-foreground">
Manage API keys and secrets stored in <code>~/.hermes/.env</code>
</p>
<p className="text-[0.7rem] text-muted-foreground/70">
Changes are saved to disk immediately. Active sessions pick up new keys automatically.
</p>
</div>
<Button variant="ghost" size="sm" onClick={() => setShowAdvanced(!showAdvanced)}>
{showAdvanced ? "Hide Advanced" : "Show Advanced"}
</Button>
</div>
{/* ═══════════════ LLM Providers (grouped) ═══════════════ */}
<Card>
<CardHeader className="sticky top-14 z-10 bg-card border-b border-border">
<div className="flex items-center gap-2">
<Zap className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">LLM Providers</CardTitle>
</div>
<CardDescription>
{configuredProviders} of {totalProviders} providers configured
</CardDescription>
</CardHeader>
<CardContent className="grid gap-0 p-0">
{providerGroups.map((group) => (
<ProviderGroupCard
key={group.name}
group={group}
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={handleSave} onClear={handleClear} onReveal={handleReveal} onCancelEdit={cancelEdit}
/>
))}
</CardContent>
</Card>
{/* ═══════════════ Other categories (flat) ═══════════════ */}
{nonProviderGrouped.map(({ label, icon: Icon, setEntries, unsetEntries, totalEntries, category }) => {
if (totalEntries === 0) return null;
return (
<Card key={category}>
<CardHeader className="sticky top-14 z-10 bg-card border-b border-border">
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{label}</CardTitle>
</div>
<CardDescription>
{setEntries.length} of {totalEntries} configured
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 pt-4">
{setEntries.map(([key, info]) => (
<EnvVarRow
key={key} varKey={key} info={info}
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={handleSave} onClear={handleClear} onReveal={handleReveal} onCancelEdit={cancelEdit}
/>
))}
{unsetEntries.length > 0 && (
<CollapsibleUnset
category={category}
unsetEntries={unsetEntries}
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={handleSave} onClear={handleClear} onReveal={handleReveal} onCancelEdit={cancelEdit}
/>
)}
</CardContent>
</Card>
);
})}
</div>
);
}
/* ------------------------------------------------------------------ */
/* CollapsibleUnset — for non-provider categories */
/* ------------------------------------------------------------------ */
function CollapsibleUnset({
category: _category,
unsetEntries,
edits,
setEdits,
revealed,
saving,
onSave,
onClear,
onReveal,
onCancelEdit,
}: {
category: string;
unsetEntries: [string, EnvVarInfo][];
edits: Record<string, string>;
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
revealed: Record<string, string>;
saving: string | null;
onSave: (key: string) => void;
onClear: (key: string) => void;
onReveal: (key: string) => void;
onCancelEdit: (key: string) => void;
}) {
const [collapsed, setCollapsed] = useState(true);
return (
<>
<button
type="button"
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer pt-1"
onClick={() => setCollapsed(!collapsed)}
>
{collapsed
? <ChevronRight className="h-3 w-3" />
: <ChevronDown className="h-3 w-3" />}
<span>{unsetEntries.length} not configured</span>
</button>
{!collapsed && unsetEntries.map(([key, info]) => (
<EnvVarRow
key={key} varKey={key} info={info}
edits={edits} setEdits={setEdits} revealed={revealed} saving={saving}
onSave={onSave} onClear={onClear} onReveal={onReveal} onCancelEdit={onCancelEdit}
/>
))}
</>
);
}

175
web/src/pages/LogsPage.tsx Normal file
View file

@ -0,0 +1,175 @@
import { useEffect, useState, useCallback, useRef } from "react";
import { FileText, RefreshCw } from "lucide-react";
import { api } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
const FILES = ["agent", "errors", "gateway"] as const;
const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const;
const COMPONENTS = ["all", "gateway", "agent", "tools", "cli", "cron"] as const;
const LINE_COUNTS = [50, 100, 200, 500] as const;
function classifyLine(line: string): "error" | "warning" | "info" | "debug" {
const upper = line.toUpperCase();
if (upper.includes("ERROR") || upper.includes("CRITICAL") || upper.includes("FATAL")) return "error";
if (upper.includes("WARNING") || upper.includes("WARN")) return "warning";
if (upper.includes("DEBUG")) return "debug";
return "info";
}
const LINE_COLORS: Record<string, string> = {
error: "text-destructive",
warning: "text-warning",
info: "text-foreground",
debug: "text-muted-foreground/60",
};
function FilterBar<T extends string>({
label,
options,
value,
onChange,
}: {
label: string;
options: readonly T[];
value: T;
onChange: (v: T) => void;
}) {
return (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground font-medium w-20 shrink-0">{label}</span>
<div className="flex gap-1 flex-wrap">
{options.map((opt) => (
<Button
key={opt}
variant={value === opt ? "default" : "outline"}
size="sm"
className="text-xs h-7 px-2.5"
onClick={() => onChange(opt)}
>
{opt}
</Button>
))}
</div>
</div>
);
}
export default function LogsPage() {
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL");
const [component, setComponent] = useState<(typeof COMPONENTS)[number]>("all");
const [lineCount, setLineCount] = useState<(typeof LINE_COUNTS)[number]>(100);
const [autoRefresh, setAutoRefresh] = useState(false);
const [lines, setLines] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const fetchLogs = useCallback(() => {
setLoading(true);
setError(null);
api
.getLogs({ file, lines: lineCount, level, component })
.then((resp) => {
setLines(resp.lines);
// Auto-scroll to bottom
setTimeout(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, 50);
})
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}, [file, lineCount, level, component]);
// Initial load + refetch on filter change
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
// Auto-refresh polling
useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(fetchLogs, 5000);
return () => clearInterval(interval);
}, [autoRefresh, fetchLogs]);
return (
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Logs</CardTitle>
{loading && (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
)}
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Switch
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
<Label className="text-xs">Auto-refresh</Label>
{autoRefresh && (
<Badge variant="success" className="text-[10px]">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
Live
</Badge>
)}
</div>
<Button variant="outline" size="sm" onClick={fetchLogs} className="text-xs h-7">
<RefreshCw className="h-3 w-3 mr-1" />
Refresh
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 mb-4">
<FilterBar label="File" options={FILES} value={file} onChange={setFile} />
<FilterBar label="Level" options={LEVELS} value={level} onChange={setLevel} />
<FilterBar label="Component" options={COMPONENTS} value={component} onChange={setComponent} />
<FilterBar
label="Lines"
options={LINE_COUNTS.map(String) as unknown as readonly string[]}
value={String(lineCount)}
onChange={(v) => setLineCount(Number(v) as (typeof LINE_COUNTS)[number])}
/>
</div>
{error && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3 mb-4">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<div
ref={scrollRef}
className="border border-border bg-background p-4 font-mono-ui text-xs leading-5 overflow-auto max-h-[600px] min-h-[200px]"
>
{lines.length === 0 && !loading && (
<p className="text-muted-foreground text-center py-8">No log lines found</p>
)}
{lines.map((line, i) => {
const cls = classifyLine(line);
return (
<div key={i} className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1 rounded`}>
{line}
</div>
);
})}
</div>
</CardContent>
</Card>
</div>
);
}

View file

@ -0,0 +1,429 @@
import { useEffect, useState, useCallback, useRef } from "react";
import {
ChevronDown,
ChevronRight,
MessageSquare,
Search,
Trash2,
Clock,
Terminal,
Globe,
MessageCircle,
Hash,
X,
} from "lucide-react";
import { api } from "@/lib/api";
import type { SessionInfo, SessionMessage, SessionSearchResult } from "@/lib/api";
import { timeAgo } from "@/lib/utils";
import { Markdown } from "@/components/Markdown";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
const ROLE_STYLES: Record<string, { bg: string; text: string; label: string }> = {
user: { bg: "bg-primary/10", text: "text-primary", label: "User" },
assistant: { bg: "bg-success/10", text: "text-success", label: "Assistant" },
system: { bg: "bg-muted", text: "text-muted-foreground", label: "System" },
tool: { bg: "bg-warning/10", text: "text-warning", label: "Tool" },
};
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> = {
cli: { icon: Terminal, color: "text-primary" },
telegram: { icon: MessageCircle, color: "text-[oklch(0.65_0.15_250)]" },
discord: { icon: Hash, color: "text-[oklch(0.65_0.15_280)]" },
slack: { icon: MessageSquare, color: "text-[oklch(0.7_0.15_155)]" },
whatsapp: { icon: Globe, color: "text-success" },
cron: { icon: Clock, color: "text-warning" },
};
/** Render an FTS5 snippet with highlighted matches.
* The backend wraps matches in >>> and <<< delimiters. */
function SnippetHighlight({ snippet }: { snippet: string }) {
const parts: React.ReactNode[] = [];
const regex = />>>(.*?)<<</g;
let last = 0;
let match: RegExpExecArray | null;
let i = 0;
while ((match = regex.exec(snippet)) !== null) {
if (match.index > last) {
parts.push(snippet.slice(last, match.index));
}
parts.push(
<mark key={i++} className="bg-warning/30 text-warning rounded-sm px-0.5">
{match[1]}
</mark>
);
last = regex.lastIndex;
}
if (last < snippet.length) {
parts.push(snippet.slice(last));
}
return (
<p className="text-xs text-muted-foreground/80 truncate max-w-lg mt-0.5">
{parts}
</p>
);
}
function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name: string; arguments: string } } }) {
const [open, setOpen] = useState(false);
let args = toolCall.function.arguments;
try {
args = JSON.stringify(JSON.parse(args), null, 2);
} catch {
// keep as-is
}
return (
<div className="mt-2 rounded-md border border-warning/20 bg-warning/5">
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-warning cursor-pointer hover:bg-warning/10 transition-colors"
onClick={() => setOpen(!open)}
aria-label={`${open ? "Collapse" : "Expand"} tool call ${toolCall.function.name}`}
>
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
<span className="font-mono-ui font-medium">{toolCall.function.name}</span>
<span className="text-warning/50 ml-auto">{toolCall.id}</span>
</button>
{open && (
<pre className="border-t border-warning/20 px-3 py-2 text-xs text-warning/80 overflow-x-auto whitespace-pre-wrap font-mono">
{args}
</pre>
)}
</div>
);
}
function MessageBubble({ msg, highlight }: { msg: SessionMessage; highlight?: string }) {
const style = ROLE_STYLES[msg.role] ?? ROLE_STYLES.system;
const label = msg.tool_name ? `Tool: ${msg.tool_name}` : style.label;
// Check if any search term appears as a prefix of any word in content
const isHit = (() => {
if (!highlight || !msg.content) return false;
const content = msg.content.toLowerCase();
const terms = highlight.toLowerCase().split(/\s+/).filter(Boolean);
return terms.some((term) => content.includes(term));
})();
// Split search query into terms for inline highlighting
const highlightTerms = isHit && highlight
? highlight.split(/\s+/).filter(Boolean)
: undefined;
return (
<div className={`${style.bg} p-3 ${isHit ? "ring-1 ring-warning/40" : ""}`} data-search-hit={isHit || undefined}>
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-semibold ${style.text}`}>{label}</span>
{isHit && (
<Badge variant="warning" className="text-[9px] py-0 px-1.5">match</Badge>
)}
{msg.timestamp && (
<span className="text-[10px] text-muted-foreground">{timeAgo(msg.timestamp)}</span>
)}
</div>
{msg.content && (
msg.role === "system"
? <div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">{msg.content}</div>
: <Markdown content={msg.content} highlightTerms={highlightTerms} />
)}
{msg.tool_calls && msg.tool_calls.length > 0 && (
<div className="mt-1">
{msg.tool_calls.map((tc) => (
<ToolCallBlock key={tc.id} toolCall={tc} />
))}
</div>
)}
</div>
);
}
/** Message list with auto-scroll to first search hit. */
function MessageList({ messages, highlight }: { messages: SessionMessage[]; highlight?: string }) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!highlight || !containerRef.current) return;
// Scroll to first hit after render
const timer = setTimeout(() => {
const hit = containerRef.current?.querySelector("[data-search-hit]");
if (hit) {
hit.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, 50);
return () => clearTimeout(timer);
}, [messages, highlight]);
return (
<div ref={containerRef} className="flex flex-col gap-3 max-h-[600px] overflow-y-auto pr-2">
{messages.map((msg, i) => (
<MessageBubble key={i} msg={msg} highlight={highlight} />
))}
</div>
);
}
function SessionRow({
session,
snippet,
searchQuery,
isExpanded,
onToggle,
onDelete,
}: {
session: SessionInfo;
snippet?: string;
searchQuery?: string;
isExpanded: boolean;
onToggle: () => void;
onDelete: () => void;
}) {
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (isExpanded && messages === null && !loading) {
setLoading(true);
api
.getSessionMessages(session.id)
.then((resp) => setMessages(resp.messages))
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}
}, [isExpanded, session.id, messages, loading]);
const sourceInfo = (session.source ? SOURCE_CONFIG[session.source] : null) ?? { icon: Globe, color: "text-muted-foreground" };
const SourceIcon = sourceInfo.icon;
const hasTitle = session.title && session.title !== "Untitled";
return (
<div className={`border overflow-hidden transition-colors ${
session.is_active
? "border-success/30 bg-success/[0.03]"
: "border-border"
}`}>
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-secondary/30 transition-colors"
onClick={onToggle}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className={`shrink-0 ${sourceInfo.color}`}>
<SourceIcon className="h-4 w-4" />
</div>
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-sm truncate pr-2 ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}>
{hasTitle ? session.title : (session.preview ? session.preview.slice(0, 60) : "Untitled session")}
</span>
{session.is_active && (
<Badge variant="success" className="text-[10px] shrink-0">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
Live
</Badge>
)}
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="truncate max-w-[180px]">{(session.model ?? "unknown").split("/").pop()}</span>
<span className="text-border">&#183;</span>
<span>{session.message_count} msgs</span>
{session.tool_call_count > 0 && (
<>
<span className="text-border">&#183;</span>
<span>{session.tool_call_count} tools</span>
</>
)}
<span className="text-border">&#183;</span>
<span>{timeAgo(session.last_active)}</span>
</div>
{snippet && (
<SnippetHighlight snippet={snippet} />
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant="outline" className="text-[10px]">
{session.source ?? "local"}
</Badge>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
aria-label="Delete session"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{isExpanded && (
<div className="border-t border-border bg-background/50 p-4">
{loading && (
<div className="flex items-center justify-center py-8">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)}
{error && (
<p className="text-sm text-destructive py-4 text-center">{error}</p>
)}
{messages && messages.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">No messages</p>
)}
{messages && messages.length > 0 && (
<MessageList messages={messages} highlight={searchQuery} />
)}
</div>
)}
</div>
);
}
export default function SessionsPage() {
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [searchResults, setSearchResults] = useState<SessionSearchResult[] | null>(null);
const [searching, setSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const loadSessions = useCallback(() => {
api
.getSessions()
.then(setSessions)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
loadSessions();
}, [loadSessions]);
// Debounced FTS search
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!search.trim()) {
setSearchResults(null);
setSearching(false);
return;
}
setSearching(true);
debounceRef.current = setTimeout(() => {
api
.searchSessions(search.trim())
.then((resp) => setSearchResults(resp.results))
.catch(() => setSearchResults(null))
.finally(() => setSearching(false));
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [search]);
const handleDelete = async (id: string) => {
try {
await api.deleteSession(id);
setSessions((prev) => prev.filter((s) => s.id !== id));
if (expandedId === id) setExpandedId(null);
} catch {
// ignore
}
};
// Build snippet map from search results (session_id → snippet)
const snippetMap = new Map<string, string>();
if (searchResults) {
for (const r of searchResults) {
snippetMap.set(r.session_id, r.snippet);
}
}
// When searching, filter sessions to those with FTS matches;
// when not searching, show all sessions
const filtered = searchResults
? sessions.filter((s) => snippetMap.has(s.id))
: sessions;
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
return (
<div className="flex flex-col gap-4">
{/* Header outside card for lighter feel */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-muted-foreground" />
<h1 className="text-base font-semibold">Sessions</h1>
<Badge variant="secondary" className="text-xs">
{sessions.length}
</Badge>
</div>
<div className="relative w-64">
{searching ? (
<div className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 animate-spin rounded-full border-[1.5px] border-primary border-t-transparent" />
) : (
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
)}
<Input
placeholder="Search message content..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8 pr-7 h-8 text-xs"
/>
{search && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground cursor-pointer"
onClick={() => setSearch("")}
>
<X className="h-3 w-3" />
</button>
)}
</div>
</div>
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Clock className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">
{search ? "No sessions match your search" : "No sessions yet"}
</p>
{!search && (
<p className="text-xs mt-1 text-muted-foreground/60">Start a conversation to see it here</p>
)}
</div>
) : (
<div className="flex flex-col gap-1.5">
{filtered.map((s) => (
<SessionRow
key={s.id}
session={s}
snippet={snippetMap.get(s.id)}
searchQuery={search || undefined}
isExpanded={expandedId === s.id}
onToggle={() =>
setExpandedId((prev) => (prev === s.id ? null : s.id))
}
onDelete={() => handleDelete(s.id)}
/>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,439 @@
import { useEffect, useState, useMemo } from "react";
import {
Package,
Search,
Wrench,
ChevronDown,
ChevronRight,
Filter,
X,
} from "lucide-react";
import { api } from "@/lib/api";
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
import { useToast } from "@/hooks/useToast";
import { Toast } from "@/components/Toast";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
/* ------------------------------------------------------------------ */
/* Types & helpers */
/* ------------------------------------------------------------------ */
interface CategoryGroup {
name: string; // display name
key: string; // raw key (or "__none__")
skills: SkillInfo[];
enabledCount: number;
}
const CATEGORY_LABELS: Record<string, string> = {
mlops: "MLOps",
"mlops/cloud": "MLOps / Cloud",
"mlops/evaluation": "MLOps / Evaluation",
"mlops/inference": "MLOps / Inference",
"mlops/models": "MLOps / Models",
"mlops/training": "MLOps / Training",
"mlops/vector-databases": "MLOps / Vector DBs",
mcp: "MCP",
"red-teaming": "Red Teaming",
ocr: "OCR",
p5js: "p5.js",
ai: "AI",
ux: "UX",
ui: "UI",
};
function prettyCategory(raw: string | null | undefined): string {
if (!raw) return "General";
if (CATEGORY_LABELS[raw]) return CATEGORY_LABELS[raw];
return raw
.split(/[-_/]/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function SkillsPage() {
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [toolsets, setToolsets] = useState<ToolsetInfo[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
// Start collapsed by default
const [collapsedCategories, setCollapsedCategories] = useState<Set<string> | "all">("all");
const { toast, showToast } = useToast();
useEffect(() => {
Promise.all([api.getSkills(), api.getToolsets()])
.then(([s, t]) => {
setSkills(s);
setToolsets(t);
})
.catch(() => showToast("Failed to load skills/toolsets", "error"))
.finally(() => setLoading(false));
}, []);
/* ---- Toggle skill ---- */
const handleToggleSkill = async (skill: SkillInfo) => {
setTogglingSkills((prev) => new Set(prev).add(skill.name));
try {
await api.toggleSkill(skill.name, !skill.enabled);
setSkills((prev) =>
prev.map((s) =>
s.name === skill.name ? { ...s, enabled: !s.enabled } : s
)
);
showToast(
`${skill.name} ${skill.enabled ? "disabled" : "enabled"}`,
"success"
);
} catch {
showToast(`Failed to toggle ${skill.name}`, "error");
} finally {
setTogglingSkills((prev) => {
const next = new Set(prev);
next.delete(skill.name);
return next;
});
}
};
/* ---- Derived data ---- */
const lowerSearch = search.toLowerCase();
const filteredSkills = useMemo(() => {
return skills.filter((s) => {
const matchesSearch =
!search ||
s.name.toLowerCase().includes(lowerSearch) ||
s.description.toLowerCase().includes(lowerSearch) ||
(s.category ?? "").toLowerCase().includes(lowerSearch);
const matchesCategory =
!activeCategory ||
(activeCategory === "__none__" ? !s.category : s.category === activeCategory);
return matchesSearch && matchesCategory;
});
}, [skills, search, lowerSearch, activeCategory]);
const categoryGroups: CategoryGroup[] = useMemo(() => {
const map = new Map<string, SkillInfo[]>();
for (const s of filteredSkills) {
const key = s.category || "__none__";
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(s);
}
// Sort: General first, then alphabetical
const entries = [...map.entries()].sort((a, b) => {
if (a[0] === "__none__") return -1;
if (b[0] === "__none__") return 1;
return a[0].localeCompare(b[0]);
});
return entries.map(([key, list]) => ({
key,
name: prettyCategory(key === "__none__" ? null : key),
skills: list.sort((a, b) => a.name.localeCompare(b.name)),
enabledCount: list.filter((s) => s.enabled).length,
}));
}, [filteredSkills]);
const allCategories = useMemo(() => {
const cats = new Map<string, number>();
for (const s of skills) {
const key = s.category || "__none__";
cats.set(key, (cats.get(key) || 0) + 1);
}
return [...cats.entries()]
.sort((a, b) => {
if (a[0] === "__none__") return -1;
if (b[0] === "__none__") return 1;
return a[0].localeCompare(b[0]);
})
.map(([key, count]) => ({ key, name: prettyCategory(key === "__none__" ? null : key), count }));
}, [skills]);
const enabledCount = skills.filter((s) => s.enabled).length;
const filteredToolsets = useMemo(() => {
return toolsets.filter(
(t) =>
!search ||
t.name.toLowerCase().includes(lowerSearch) ||
t.label.toLowerCase().includes(lowerSearch) ||
t.description.toLowerCase().includes(lowerSearch)
);
}, [toolsets, search, lowerSearch]);
const isCollapsed = (key: string): boolean => {
if (collapsedCategories === "all") return true;
return collapsedCategories.has(key);
};
const toggleCollapse = (key: string) => {
setCollapsedCategories((prev) => {
if (prev === "all") {
// Switching from "all collapsed" → expand just this one
const allKeys = new Set(categoryGroups.map((g) => g.key));
allKeys.delete(key);
return allKeys;
}
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
/* ---- Loading ---- */
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
{/* ═══════════════ Header + Search ═══════════════ */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-muted-foreground" />
<h1 className="text-base font-semibold">Skills</h1>
<span className="text-xs text-muted-foreground">
{enabledCount}/{skills.length} enabled
</span>
</div>
</div>
{/* ═══════════════ Search + Category Filter ═══════════════ */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="pl-9"
placeholder="Search skills and toolsets..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
>
<X className="h-4 w-4" />
</button>
)}
</div>
</div>
{/* Category pills */}
{allCategories.length > 1 && (
<div className="flex items-center gap-2 flex-wrap">
<Filter className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<button
type="button"
className={`inline-flex items-center px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
!activeCategory
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
}`}
onClick={() => setActiveCategory(null)}
>
All ({skills.length})
</button>
{allCategories.map(({ key, name, count }) => (
<button
key={key}
type="button"
className={`inline-flex items-center px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
activeCategory === key
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
}`}
onClick={() =>
setActiveCategory(activeCategory === key ? null : key)
}
>
{name}
<span className="ml-1 opacity-60">{count}</span>
</button>
))}
</div>
)}
{/* ═══════════════ Skills by Category ═══════════════ */}
<section className="flex flex-col gap-3">
{filteredSkills.length === 0 ? (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
{skills.length === 0
? "No skills found. Skills are loaded from ~/.hermes/skills/"
: "No skills match your search or filter."}
</CardContent>
</Card>
) : (
categoryGroups.map(({ key, name, skills: catSkills, enabledCount: catEnabled }) => {
const collapsed = isCollapsed(key);
return (
<Card key={key}>
<CardHeader
className="cursor-pointer select-none py-3 px-4"
onClick={() => toggleCollapse(key)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{collapsed ? (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
<CardTitle className="text-sm font-medium">{name}</CardTitle>
<Badge variant="secondary" className="text-[10px] font-normal">
{catSkills.length} skill{catSkills.length !== 1 ? "s" : ""}
</Badge>
</div>
<Badge
variant={catEnabled === catSkills.length ? "success" : "outline"}
className="text-[10px]"
>
{catEnabled}/{catSkills.length} enabled
</Badge>
</div>
</CardHeader>
{collapsed ? (
/* Peek: show first few skill names so collapsed isn't blank */
<div className="px-4 pb-3 flex items-center min-h-[28px]">
<p className="text-xs text-muted-foreground/60 truncate leading-normal">
{catSkills.slice(0, 4).map((s) => s.name).join(", ")}
{catSkills.length > 4 && `, +${catSkills.length - 4} more`}
</p>
</div>
) : (
<CardContent className="pt-0 px-4 pb-3">
<div className="grid gap-1">
{catSkills.map((skill) => (
<div
key={skill.name}
className="group flex items-start gap-3 rounded-md px-3 py-2.5 transition-colors hover:bg-muted/40"
>
<div className="pt-0.5 shrink-0">
<Switch
checked={skill.enabled}
onCheckedChange={() => handleToggleSkill(skill)}
disabled={togglingSkills.has(skill.name)}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span
className={`font-mono-ui text-sm ${
skill.enabled
? "text-foreground"
: "text-muted-foreground"
}`}
>
{skill.name}
</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{skill.description || "No description available."}
</p>
</div>
</div>
))}
</div>
</CardContent>
)}
</Card>
);
})
)}
</section>
{/* ═══════════════ Toolsets ═══════════════ */}
<section className="flex flex-col gap-4">
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
<Wrench className="h-4 w-4" />
Toolsets ({filteredToolsets.length})
</h2>
{filteredToolsets.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
No toolsets match the search.
</CardContent>
</Card>
) : (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{filteredToolsets.map((ts) => {
// Strip emoji prefix from label for cleaner display
const labelText = ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || ts.name;
const emoji = ts.label.match(/^[\p{Emoji}]+/u)?.[0] || "🔧";
return (
<Card key={ts.name} className="relative overflow-hidden">
<CardContent className="py-4">
<div className="flex items-start gap-3">
<div className="text-2xl shrink-0 leading-none mt-0.5">{emoji}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm">{labelText}</span>
<Badge
variant={ts.enabled ? "success" : "outline"}
className="text-[10px]"
>
{ts.enabled ? "active" : "inactive"}
</Badge>
</div>
<p className="text-xs text-muted-foreground mb-2">
{ts.description}
</p>
{ts.enabled && !ts.configured && (
<p className="text-[10px] text-amber-300/80 mb-2">
Setup needed
</p>
)}
{ts.tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{ts.tools.map((tool) => (
<Badge
key={tool}
variant="secondary"
className="text-[10px] font-mono"
>
{tool}
</Badge>
))}
</div>
)}
{ts.tools.length === 0 && (
<span className="text-[10px] text-muted-foreground/60">
{ts.enabled ? `${ts.name} toolset` : "Disabled for CLI"}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</section>
</div>
);
}

View file

@ -0,0 +1,303 @@
import { useEffect, useState } from "react";
import {
Activity,
AlertTriangle,
Clock,
Cpu,
Database,
Radio,
Wifi,
WifiOff,
} from "lucide-react";
import { api } from "@/lib/api";
import type { PlatformStatus, SessionInfo, StatusResponse } from "@/lib/api";
import { timeAgo, isoTimeAgo } from "@/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
const PLATFORM_STATE_BADGE: Record<string, { variant: "success" | "warning" | "destructive"; label: string }> = {
connected: { variant: "success", label: "Connected" },
disconnected: { variant: "warning", label: "Disconnected" },
fatal: { variant: "destructive", label: "Error" },
};
const GATEWAY_STATE_DISPLAY: Record<string, { badge: "success" | "warning" | "destructive" | "outline"; label: string }> = {
running: { badge: "success", label: "Running" },
starting: { badge: "warning", label: "Starting" },
startup_failed: { badge: "destructive", label: "Failed" },
stopped: { badge: "outline", label: "Stopped" },
};
function gatewayValue(status: StatusResponse): string {
if (status.gateway_running) return `PID ${status.gateway_pid}`;
if (status.gateway_state === "startup_failed") return "Start failed";
return "Not running";
}
function gatewayBadge(status: StatusResponse) {
const info = status.gateway_state ? GATEWAY_STATE_DISPLAY[status.gateway_state] : null;
if (info) return info;
return status.gateway_running
? { badge: "success" as const, label: "Running" }
: { badge: "outline" as const, label: "Off" };
}
export default function StatusPage() {
const [status, setStatus] = useState<StatusResponse | null>(null);
const [sessions, setSessions] = useState<SessionInfo[]>([]);
useEffect(() => {
const load = () => {
api.getStatus().then(setStatus).catch(() => {});
api.getSessions().then(setSessions).catch(() => {});
};
load();
const interval = setInterval(load, 5000);
return () => clearInterval(interval);
}, []);
if (!status) {
return (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
const gwBadge = gatewayBadge(status);
const items = [
{
icon: Cpu,
label: "Agent",
value: `v${status.version}`,
badgeText: "Live",
badgeVariant: "success" as const,
},
{
icon: Radio,
label: "Gateway",
value: gatewayValue(status),
badgeText: gwBadge.label,
badgeVariant: gwBadge.badge,
},
{
icon: Activity,
label: "Active Sessions",
value: status.active_sessions > 0 ? `${status.active_sessions} running` : "None",
badgeText: status.active_sessions > 0 ? "Live" : "Off",
badgeVariant: (status.active_sessions > 0 ? "success" : "outline") as "success" | "outline",
},
];
const platforms = Object.entries(status.gateway_platforms ?? {});
const activeSessions = sessions.filter((s) => s.is_active);
const recentSessions = sessions.filter((s) => !s.is_active).slice(0, 5);
// Collect alerts that need attention
const alerts: { message: string; detail?: string }[] = [];
if (status.gateway_state === "startup_failed") {
alerts.push({
message: "Gateway failed to start",
detail: status.gateway_exit_reason ?? undefined,
});
}
const failedPlatforms = platforms.filter(([, info]) => info.state === "fatal" || info.state === "disconnected");
for (const [name, info] of failedPlatforms) {
alerts.push({
message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${info.state === "fatal" ? "error" : "disconnected"}`,
detail: info.error_message ?? undefined,
});
}
return (
<div className="flex flex-col gap-6">
{/* Alert banner — breaks grid monotony for critical states */}
{alerts.length > 0 && (
<div className="border border-destructive/30 bg-destructive/[0.06] p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col gap-2 min-w-0">
{alerts.map((alert, i) => (
<div key={i}>
<p className="text-sm font-medium text-destructive">{alert.message}</p>
{alert.detail && (
<p className="text-xs text-destructive/70 mt-0.5">{alert.detail}</p>
)}
</div>
))}
</div>
</div>
</div>
)}
<div className="grid gap-4 sm:grid-cols-3">
{items.map(({ icon: Icon, label, value, badgeText, badgeVariant }) => (
<Card key={label}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{label}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-display">{value}</div>
{badgeText && (
<Badge variant={badgeVariant} className="mt-2">
{badgeVariant === "success" && (
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
)}
{badgeText}
</Badge>
)}
</CardContent>
</Card>
))}
</div>
{platforms.length > 0 && (
<PlatformsCard platforms={platforms} />
)}
{activeSessions.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Activity className="h-5 w-5 text-success" />
<CardTitle className="text-base">Active Sessions</CardTitle>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{activeSessions.map((s) => (
<div
key={s.id}
className="flex items-center justify-between border border-border p-3"
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{s.title ?? "Untitled"}</span>
<Badge variant="success" className="text-[10px]">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
Live
</Badge>
</div>
<span className="text-xs text-muted-foreground">
<span className="font-mono-ui">{s.model ?? "unknown"}</span> · {s.message_count} msgs · {timeAgo(s.last_active)}
</span>
</div>
</div>
))}
</CardContent>
</Card>
)}
{recentSessions.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Recent Sessions</CardTitle>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{recentSessions.map((s) => (
<div
key={s.id}
className="flex items-center justify-between border border-border p-3"
>
<div className="flex flex-col gap-1">
<span className="font-medium text-sm">{s.title ?? "Untitled"}</span>
<span className="text-xs text-muted-foreground">
<span className="font-mono-ui">{s.model ?? "unknown"}</span> · {s.message_count} msgs · {timeAgo(s.last_active)}
</span>
{s.preview && (
<span className="text-xs text-muted-foreground/70 truncate max-w-md">
{s.preview}
</span>
)}
</div>
<Badge variant="outline" className="text-[10px]">
<Database className="mr-1 h-3 w-3" />
{s.source ?? "local"}
</Badge>
</div>
))}
</CardContent>
</Card>
)}
</div>
);
}
function PlatformsCard({ platforms }: PlatformsCardProps) {
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Radio className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">Connected Platforms</CardTitle>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{platforms.map(([name, info]) => {
const display = PLATFORM_STATE_BADGE[info.state] ?? {
variant: "outline" as const,
label: info.state,
};
const IconComponent = info.state === "connected" ? Wifi : info.state === "fatal" ? AlertTriangle : WifiOff;
return (
<div
key={name}
className="flex items-center justify-between border border-border p-3"
>
<div className="flex items-center gap-3">
<IconComponent className={`h-4 w-4 ${
info.state === "connected"
? "text-success"
: info.state === "fatal"
? "text-destructive"
: "text-warning"
}`} />
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium capitalize">{name}</span>
{info.error_message && (
<span className="text-xs text-destructive">{info.error_message}</span>
)}
{info.updated_at && (
<span className="text-xs text-muted-foreground">
Last update: {isoTimeAgo(info.updated_at)}
</span>
)}
</div>
</div>
<Badge variant={display.variant}>
{display.variant === "success" && (
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
)}
{display.label}
</Badge>
</div>
);
})}
</CardContent>
</Card>
);
}
interface PlatformsCardProps {
platforms: [string, PlatformStatus][];
}