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

48
web/README.md Normal file
View file

@ -0,0 +1,48 @@
# Hermes Agent — Web UI
Browser-based dashboard for managing Hermes Agent configuration, API keys, and monitoring active sessions.
## Stack
- **Vite** + **React 19** + **TypeScript**
- **Tailwind CSS v4** with custom dark theme
- **shadcn/ui**-style components (hand-rolled, no CLI dependency)
## Development
```bash
# Start the backend API server
cd ../
python -m hermes_cli.main web --no-open
# In another terminal, start the Vite dev server (with HMR + API proxy)
cd web/
npm run dev
```
The Vite dev server proxies `/api` requests to `http://127.0.0.1:9119` (the FastAPI backend).
## Build
```bash
npm run build
```
This outputs to `../hermes_cli/web_dist/`, which the FastAPI server serves as a static SPA. The built assets are included in the Python package via `pyproject.toml` package-data.
## Structure
```
src/
├── components/ui/ # Reusable UI primitives (Card, Badge, Button, Input, etc.)
├── lib/
│ ├── api.ts # API client — typed fetch wrappers for all backend endpoints
│ └── utils.ts # cn() helper for Tailwind class merging
├── pages/
│ ├── StatusPage # Agent status, active/recent sessions
│ ├── ConfigPage # Dynamic config editor (reads schema from backend)
│ └── EnvPage # API key management with save/clear
├── App.tsx # Main layout and navigation
├── main.tsx # React entry point
└── index.css # Tailwind imports and theme variables
```

23
web/eslint.config.js Normal file
View file

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
web/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hermes Agent</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3835
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

36
web/package.json Normal file
View file

@ -0,0 +1,36 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1"
}
}

BIN
web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

117
web/src/App.tsx Normal file
View file

@ -0,0 +1,117 @@
import { useState, useEffect } from "react";
import { Activity, BarChart3, Clock, FileText, KeyRound, MessageSquare, Package, Settings } from "lucide-react";
import StatusPage from "@/pages/StatusPage";
import ConfigPage from "@/pages/ConfigPage";
import EnvPage from "@/pages/EnvPage";
import SessionsPage from "@/pages/SessionsPage";
import LogsPage from "@/pages/LogsPage";
import AnalyticsPage from "@/pages/AnalyticsPage";
import CronPage from "@/pages/CronPage";
import SkillsPage from "@/pages/SkillsPage";
const NAV_ITEMS = [
{ id: "status", label: "Status", icon: Activity },
{ id: "sessions", label: "Sessions", icon: MessageSquare },
{ id: "analytics", label: "Analytics", icon: BarChart3 },
{ id: "logs", label: "Logs", icon: FileText },
{ id: "cron", label: "Cron", icon: Clock },
{ id: "skills", label: "Skills", icon: Package },
{ id: "config", label: "Config", icon: Settings },
{ id: "env", label: "Keys", icon: KeyRound },
] as const;
type PageId = (typeof NAV_ITEMS)[number]["id"];
const PAGE_COMPONENTS: Record<PageId, React.FC> = {
status: StatusPage,
sessions: SessionsPage,
analytics: AnalyticsPage,
logs: LogsPage,
cron: CronPage,
skills: SkillsPage,
config: ConfigPage,
env: EnvPage,
};
export default function App() {
const [page, setPage] = useState<PageId>("status");
const [animKey, setAnimKey] = useState(0);
useEffect(() => {
setAnimKey((k) => k + 1);
}, [page]);
const PageComponent = PAGE_COMPONENTS[page];
return (
<div className="flex min-h-screen flex-col bg-background text-foreground">
{/* Global grain + warm glow (matches landing page) */}
<div className="noise-overlay" />
<div className="warm-glow" />
{/* ---- Header with grid-border nav ---- */}
<header className="sticky top-0 z-40 border-b border-border bg-background/90 backdrop-blur-sm">
<div className="mx-auto flex h-12 max-w-[1400px] items-stretch">
{/* Brand */}
<div className="flex items-center border-r border-border px-5 shrink-0">
<span className="font-collapse text-xl font-bold tracking-wider uppercase blend-lighter">
Hermes<br className="hidden sm:inline" /><span className="sm:hidden"> </span>Agent
</span>
</div>
{/* Nav grid — Mondwest labels like the landing page nav */}
<nav className="flex items-stretch overflow-x-auto scrollbar-none">
{NAV_ITEMS.map(({ id, label, icon: Icon }) => (
<button
key={id}
type="button"
onClick={() => setPage(id)}
className={`group relative inline-flex items-center gap-1.5 border-r border-border px-4 py-2 font-display text-[0.8rem] tracking-[0.12em] uppercase whitespace-nowrap transition-colors cursor-pointer shrink-0 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
page === id
? "text-foreground"
: "text-muted-foreground hover:text-foreground"
}`}
>
<Icon className="h-3.5 w-3.5" />
{label}
{/* Hover highlight */}
<span className="absolute inset-0 bg-foreground pointer-events-none transition-opacity duration-150 group-hover:opacity-5 opacity-0" />
{/* Active indicator — dither bar */}
{page === id && (
<span className="absolute bottom-0 left-0 right-0 h-px bg-foreground" />
)}
</button>
))}
</nav>
{/* Version badge */}
<div className="ml-auto flex items-center px-4 text-muted-foreground">
<span className="font-display text-[0.7rem] tracking-[0.15em] uppercase opacity-50">
Web UI
</span>
</div>
</div>
</header>
<main
key={animKey}
className="relative z-2 mx-auto w-full max-w-[1400px] flex-1 px-6 py-8"
style={{ animation: "fade-in 150ms ease-out" }}
>
<PageComponent />
</main>
{/* ---- Footer ---- */}
<footer className="relative z-2 border-t border-border">
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-3">
<span className="font-display text-[0.8rem] tracking-[0.12em] uppercase opacity-50">
Hermes Agent
</span>
<span className="font-display text-[0.7rem] tracking-[0.15em] uppercase text-foreground/40">
Nous Research
</span>
</div>
</footer>
</div>
);
}

View file

@ -0,0 +1,151 @@
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
function FieldHint({ schema, schemaKey }: { schema: Record<string, unknown>; schemaKey: string }) {
const keyPath = schemaKey.includes(".") ? schemaKey : "";
const description = schema.description ? String(schema.description) : "";
if (!keyPath && !description) return null;
return (
<div className="flex flex-col gap-0.5">
{keyPath && <span className="text-[10px] font-mono text-muted-foreground/50">{keyPath}</span>}
{description && <span className="text-xs text-muted-foreground/70">{description}</span>}
</div>
);
}
export function AutoField({
schemaKey,
schema,
value,
onChange,
}: AutoFieldProps) {
const rawLabel = schemaKey.split(".").pop() ?? schemaKey;
const label = rawLabel.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
if (schema.type === "boolean") {
return (
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-0.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
</div>
<Switch checked={!!value} onCheckedChange={onChange} />
</div>
);
}
if (schema.type === "select") {
const options = (schema.options as string[]) ?? [];
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Select value={String(value ?? "")} onChange={(e) => onChange(e.target.value)}>
{options.map((opt) => (
<option key={opt} value={opt}>
{opt || "(none)"}
</option>
))}
</Select>
</div>
);
}
if (schema.type === "number") {
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Input
type="number"
value={value === undefined || value === null ? "" : String(value)}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
onChange(0);
return;
}
const n = Number(raw);
if (!Number.isNaN(n)) {
onChange(n);
}
}}
/>
</div>
);
}
if (schema.type === "text") {
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<textarea
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"
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
if (schema.type === "list") {
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Input
value={Array.isArray(value) ? value.join(", ") : String(value ?? "")}
onChange={(e) =>
onChange(
e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
)
}
placeholder="comma-separated values"
/>
</div>
);
}
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
return (
<div className="grid gap-3 rounded-lg border border-border p-3">
<Label className="text-xs font-medium">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
{Object.entries(obj).map(([subKey, subVal]) => (
<div key={subKey} className="grid gap-1">
<Label className="text-xs text-muted-foreground">{subKey}</Label>
<Input
value={String(subVal ?? "")}
onChange={(e) => onChange({ ...obj, [subKey]: e.target.value })}
className="text-xs"
/>
</div>
))}
</div>
);
}
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Input value={String(value ?? "")} onChange={(e) => onChange(e.target.value)} />
</div>
);
}
interface AutoFieldProps {
schemaKey: string;
schema: Record<string, unknown>;
value: unknown;
onChange: (v: unknown) => void;
}

View file

@ -0,0 +1,279 @@
import { useMemo } from "react";
/**
* Lightweight markdown renderer for LLM output.
* Handles: code blocks, inline code, bold, italic, headers, links, lists, horizontal rules.
* NOT a full CommonMark parser optimized for typical assistant message patterns.
*/
export function Markdown({ content, highlightTerms }: { content: string; highlightTerms?: string[] }) {
const blocks = useMemo(() => parseBlocks(content), [content]);
return (
<div className="text-sm text-foreground leading-relaxed space-y-2">
{blocks.map((block, i) => (
<Block key={i} block={block} highlightTerms={highlightTerms} />
))}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type BlockNode =
| { type: "code"; lang: string; content: string }
| { type: "heading"; level: number; content: string }
| { type: "hr" }
| { type: "list"; ordered: boolean; items: string[] }
| { type: "paragraph"; content: string };
/* ------------------------------------------------------------------ */
/* Block parser */
/* ------------------------------------------------------------------ */
function parseBlocks(text: string): BlockNode[] {
const lines = text.split("\n");
const blocks: BlockNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Fenced code block
const fenceMatch = line.match(/^```(\w*)/);
if (fenceMatch) {
const lang = fenceMatch[1] || "";
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
i++; // skip closing ```
blocks.push({ type: "code", lang, content: codeLines.join("\n") });
continue;
}
// Heading
const headingMatch = line.match(/^(#{1,4})\s+(.+)/);
if (headingMatch) {
blocks.push({ type: "heading", level: headingMatch[1].length, content: headingMatch[2] });
i++;
continue;
}
// Horizontal rule
if (/^[-*_]{3,}\s*$/.test(line)) {
blocks.push({ type: "hr" });
i++;
continue;
}
// Unordered list
if (/^[-*+]\s/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^[-*+]\s/.test(lines[i])) {
items.push(lines[i].replace(/^[-*+]\s/, ""));
i++;
}
blocks.push({ type: "list", ordered: false, items });
continue;
}
// Ordered list
if (/^\d+[.)]\s/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^\d+[.)]\s/.test(lines[i])) {
items.push(lines[i].replace(/^\d+[.)]\s/, ""));
i++;
}
blocks.push({ type: "list", ordered: true, items });
continue;
}
// Empty line
if (line.trim() === "") {
i++;
continue;
}
// Paragraph — collect consecutive non-empty, non-special lines
const paraLines: string[] = [];
while (
i < lines.length &&
lines[i].trim() !== "" &&
!lines[i].match(/^```/) &&
!lines[i].match(/^#{1,4}\s/) &&
!lines[i].match(/^[-*+]\s/) &&
!lines[i].match(/^\d+[.)]\s/) &&
!lines[i].match(/^[-*_]{3,}\s*$/)
) {
paraLines.push(lines[i]);
i++;
}
if (paraLines.length > 0) {
blocks.push({ type: "paragraph", content: paraLines.join("\n") });
}
}
return blocks;
}
/* ------------------------------------------------------------------ */
/* Block renderer */
/* ------------------------------------------------------------------ */
function Block({ block, highlightTerms }: { block: BlockNode; highlightTerms?: string[] }) {
switch (block.type) {
case "code":
return (
<pre className="rounded-md bg-secondary/60 border border-border px-3 py-2.5 text-xs font-mono leading-relaxed overflow-x-auto">
<code>{block.content}</code>
</pre>
);
case "heading": {
const Tag = `h${Math.min(block.level, 4)}` as "h1" | "h2" | "h3" | "h4";
const sizes: Record<string, string> = {
h1: "text-base font-bold",
h2: "text-sm font-bold",
h3: "text-sm font-semibold",
h4: "text-sm font-medium",
};
return <Tag className={sizes[Tag]}><InlineContent text={block.content} highlightTerms={highlightTerms} /></Tag>;
}
case "hr":
return <hr className="border-border" />;
case "list": {
const Tag = block.ordered ? "ol" : "ul";
return (
<Tag className={`space-y-0.5 ${block.ordered ? "list-decimal" : "list-disc"} pl-5 text-sm`}>
{block.items.map((item, i) => (
<li key={i}><InlineContent text={item} highlightTerms={highlightTerms} /></li>
))}
</Tag>
);
}
case "paragraph":
return <p><InlineContent text={block.content} highlightTerms={highlightTerms} /></p>;
}
}
/* ------------------------------------------------------------------ */
/* Inline parser + renderer */
/* ------------------------------------------------------------------ */
type InlineNode =
| { type: "text"; content: string }
| { type: "code"; content: string }
| { type: "bold"; content: string }
| { type: "italic"; content: string }
| { type: "link"; text: string; href: string }
| { type: "br" };
function parseInline(text: string): InlineNode[] {
const nodes: InlineNode[] = [];
// Pattern priority: code > link > bold > italic > bare URL > line break
const pattern = /(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\bhttps?:\/\/[^\s<>)\]]+)|(\n)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push({ type: "text", content: text.slice(lastIndex, match.index) });
}
if (match[1]) {
// Inline code
nodes.push({ type: "code", content: match[1].slice(1, -1) });
} else if (match[2]) {
// [text](url) link
nodes.push({ type: "link", text: match[3], href: match[4] });
} else if (match[5]) {
// **bold**
nodes.push({ type: "bold", content: match[6] });
} else if (match[7]) {
// *italic*
nodes.push({ type: "italic", content: match[8] });
} else if (match[9]) {
// Bare URL
nodes.push({ type: "link", text: match[9], href: match[9] });
} else if (match[10]) {
// Line break within paragraph
nodes.push({ type: "br" });
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
nodes.push({ type: "text", content: text.slice(lastIndex) });
}
return nodes;
}
function InlineContent({ text, highlightTerms }: { text: string; highlightTerms?: string[] }) {
const nodes = useMemo(() => parseInline(text), [text]);
return (
<>
{nodes.map((node, i) => {
switch (node.type) {
case "text":
return <HighlightedText key={i} text={node.content} terms={highlightTerms} />;
case "code":
return (
<code key={i} className="rounded bg-secondary/60 px-1.5 py-0.5 text-xs font-mono text-primary/90">
{node.content}
</code>
);
case "bold":
return <strong key={i} className="font-semibold"><HighlightedText text={node.content} terms={highlightTerms} /></strong>;
case "italic":
return <em key={i}><HighlightedText text={node.content} terms={highlightTerms} /></em>;
case "link":
return (
<a
key={i}
href={node.href}
target="_blank"
rel="noreferrer"
className="text-primary underline underline-offset-2 decoration-primary/30 hover:decoration-primary/60 transition-colors"
>
{node.text}
</a>
);
case "br":
return <br key={i} />;
}
})}
</>
);
}
/** Highlight search terms within a plain text string. */
function HighlightedText({ text, terms }: { text: string; terms?: string[] }) {
if (!terms || terms.length === 0) return <>{text}</>;
// Build a regex that matches any of the search terms (case-insensitive)
const escaped = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const regex = new RegExp(`(${escaped.join("|")})`, "gi");
const parts = text.split(regex);
return (
<>
{parts.map((part, i) =>
regex.test(part) ? (
<mark key={i} className="bg-warning/30 text-warning rounded-sm px-0.5">{part}</mark>
) : (
<span key={i}>{part}</span>
)
)}
</>
);
}

View file

@ -0,0 +1,36 @@
import { useEffect, useState } from "react";
export function Toast({ toast }: { toast: { message: string; type: "success" | "error" } | null }) {
const [visible, setVisible] = useState(false);
const [current, setCurrent] = useState(toast);
useEffect(() => {
if (toast) {
setCurrent(toast);
setVisible(true);
} else {
setVisible(false);
const timer = setTimeout(() => setCurrent(null), 200);
return () => clearTimeout(timer);
}
}, [toast]);
if (!current) return null;
return (
<div
role="status"
aria-live="polite"
className={`fixed top-4 right-4 z-50 border px-4 py-2.5 font-courier text-xs tracking-wider uppercase backdrop-blur-sm ${
current.type === "success"
? "bg-success/15 text-success border-success/30"
: "bg-destructive/15 text-destructive border-destructive/30"
}`}
style={{
animation: visible ? "toast-in 200ms ease-out forwards" : "toast-out 200ms ease-in forwards",
}}
>
{current.message}
</div>
);
}

View file

@ -0,0 +1,29 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center border px-2 py-0.5 font-compressed text-[0.65rem] tracking-[0.15em] uppercase transition-colors",
{
variants: {
variant: {
default: "border-foreground/20 bg-foreground/10 text-foreground",
secondary: "border-border bg-secondary text-secondary-foreground",
destructive: "border-destructive/30 bg-destructive/15 text-destructive",
outline: "border-border text-muted-foreground",
success: "grain border-emerald-600/30 bg-emerald-950/70 text-emerald-400",
warning: "border-warning/30 bg-warning/15 text-warning",
},
},
defaultVariants: {
variant: "default",
},
},
);
export function Badge({
className,
variant,
...props
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}

View file

@ -0,0 +1,38 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-display text-xs tracking-[0.1em] uppercase transition-colors cursor-pointer"
+ " disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-foreground/90 text-background hover:bg-foreground",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-border bg-transparent hover:bg-foreground/10 hover:text-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-foreground/10 hover:text-foreground",
link: "text-foreground underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3 text-[0.65rem]",
lg: "h-10 px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
export function Button({
className,
variant,
size,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />;
}

View file

@ -0,0 +1,29 @@
import { cn } from "@/lib/utils";
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"border border-border bg-card/80 text-card-foreground",
className,
)}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col gap-1.5 p-4 border-b border-border", className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn("font-expanded text-sm font-bold tracking-[0.08em] uppercase blend-lighter", className)} {...props} />;
}
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("font-display text-xs text-muted-foreground", className)} {...props} />;
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-4", className)} {...props} />;
}

View file

@ -0,0 +1,16 @@
import { cn } from "@/lib/utils";
export function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
className={cn(
"flex h-9 w-full border border-border bg-background/40 px-3 py-1 font-courier text-sm transition-colors",
"placeholder:text-muted-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
}

View file

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
export function Label({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) {
return (
<label
className={cn(
"font-display text-xs tracking-[0.1em] uppercase leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
);
}

View file

@ -0,0 +1,15 @@
import { cn } from "@/lib/utils";
export function Select({ className, ...props }: React.SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select
className={cn(
"flex h-9 w-full border border-border bg-background/40 px-3 py-1 font-courier text-sm",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
}

View file

@ -0,0 +1,19 @@
import { cn } from "@/lib/utils";
export function Separator({
className,
orientation = "horizontal",
...props
}: React.HTMLAttributes<HTMLDivElement> & { orientation?: "horizontal" | "vertical" }) {
return (
<div
role="separator"
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
className,
)}
{...props}
/>
);
}

View file

@ -0,0 +1,37 @@
import { cn } from "@/lib/utils";
export function Switch({
checked,
onCheckedChange,
className,
disabled,
}: {
checked: boolean;
onCheckedChange: (v: boolean) => void;
className?: string;
disabled?: boolean;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center border border-border transition-colors",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30",
"disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-foreground/15 border-foreground/30" : "bg-background",
className,
)}
onClick={() => onCheckedChange(!checked)}
>
<span
className={cn(
"pointer-events-none block h-3.5 w-3.5 transition-transform",
checked ? "translate-x-4 bg-foreground" : "translate-x-0.5 bg-muted-foreground",
)}
/>
</button>
);
}

View file

@ -0,0 +1,51 @@
import { useState } from "react";
import { cn } from "@/lib/utils";
export function Tabs({
defaultValue,
children,
className,
}: {
defaultValue: string;
children: (active: string, setActive: (v: string) => void) => React.ReactNode;
className?: string;
}) {
const [active, setActive] = useState(defaultValue);
return <div className={cn("flex flex-col gap-4", className)}>{children(active, setActive)}</div>;
}
export function TabsList({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"inline-flex h-9 items-center justify-start border-b border-border text-muted-foreground",
className,
)}
{...props}
/>
);
}
export function TabsTrigger({
active,
value,
onClick,
className,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { active: boolean; value: string }) {
return (
<button
type="button"
className={cn(
"relative inline-flex items-center justify-center whitespace-nowrap px-3 py-1.5 font-display text-xs tracking-[0.1em] uppercase transition-all cursor-pointer",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
active
? "text-foreground after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-foreground"
: "hover:text-foreground",
className,
)}
onClick={onClick}
{...props}
/>
);
}

15
web/src/hooks/useToast.ts Normal file
View file

@ -0,0 +1,15 @@
import { useCallback, useState } from "react";
export function useToast(duration = 3000) {
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const showToast = useCallback(
(message: string, type: "success" | "error") => {
setToast({ message, type });
setTimeout(() => setToast(null), duration);
},
[duration],
);
return { toast, showToast };
}

197
web/src/index.css Normal file
View file

@ -0,0 +1,197 @@
@import "tailwindcss";
/* ------------------------------------------------------------------ */
/* Hermes Agent — Design tokens */
/* Matched to hermes-agent.nousresearch.com (dark teal theme) */
/* ------------------------------------------------------------------ */
/* --- Font faces --- */
@font-face { font-family: "Collapse"; src: url("/fonts/Collapse-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
@font-face { font-family: "Collapse"; src: url("/fonts/Collapse-Bold.woff2") format("woff2"); font-weight: 700; font-display: swap; }
@font-face { font-family: "Courier Prime"; src: url("/fonts/CourierPrime-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
@font-face { font-family: "Courier Prime"; src: url("/fonts/CourierPrime-Bold.woff2") format("woff2"); font-weight: 700; font-display: swap; }
@font-face { font-family: "RulesCompressed"; src: url("/fonts/RulesCompressed-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
@font-face { font-family: "RulesCompressed"; src: url("/fonts/RulesCompressed-Medium.woff2") format("woff2"); font-weight: 600; font-display: swap; }
@font-face { font-family: "RulesExpanded"; src: url("/fonts/RulesExpanded-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
@font-face { font-family: "RulesExpanded"; src: url("/fonts/RulesExpanded-Bold.woff2") format("woff2"); font-weight: 700; font-display: swap; }
@font-face { font-family: "Mondwest"; src: url("/fonts/Mondwest-Regular.woff2") format("woff2"); font-weight: 400; font-display: swap; }
@theme {
/* ---- Hermes palette (dark teal, from live site) ---- */
--color-background: #041C1C;
--color-foreground: #ffe6cb;
--color-card: #062424;
--color-card-foreground: #ffe6cb;
--color-primary: #ffe6cb;
--color-primary-foreground: #041C1C;
--color-secondary: #0a2e2e;
--color-secondary-foreground: #ffe6cb;
--color-muted: #083030;
--color-muted-foreground: #8aaa9a;
--color-accent: #0c3838;
--color-accent-foreground: #ffe6cb;
--color-destructive: #fb2c36;
--color-destructive-foreground: #fff;
--color-success: #4ade80;
--color-warning: #ffbd38;
--color-border: color-mix(in srgb, #ffe6cb 15%, transparent);
--color-input: color-mix(in srgb, #ffe6cb 15%, transparent);
--color-ring: #ffe6cb;
--color-popover: #062424;
--color-popover-foreground: #ffe6cb;
/* ---- Font stacks ---- */
--font-sans: "Mondwest", Arial, sans-serif;
--font-mono: "Courier Prime", "Courier New", monospace;
--font-display: "Mondwest", Arial, sans-serif;
--font-expanded: "RulesExpanded", Arial, sans-serif;
--font-compressed: "RulesCompressed", Arial, sans-serif;
}
/* ---- Global body ---- */
body {
margin: 0;
font-family: "Mondwest", Arial, sans-serif;
background: var(--color-background);
color: var(--color-foreground);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
/* ---- Selection ---- */
::selection {
background: var(--color-foreground);
color: var(--color-background);
}
/* ---- Scrollbars (thin, subtle) ---- */
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
*:hover {
scrollbar-color: color-mix(in srgb, var(--color-foreground) 15%, transparent) transparent;
}
html, body {
scrollbar-color: color-mix(in srgb, var(--color-foreground) 25%, transparent) transparent;
}
::-webkit-scrollbar { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--color-foreground) 20%, transparent);
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: color-mix(in srgb, var(--color-foreground) 35%, transparent);
}
/* ---- Hide scrollbar utility ---- */
.scrollbar-none {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-none::-webkit-scrollbar {
display: none;
}
/* ---- Code blocks ---- */
code {
font-family: "Courier Prime", "Courier New", monospace;
font-size: 0.85em;
padding: 0.15em 0.4em;
border-radius: 0;
background: color-mix(in srgb, var(--color-foreground) 8%, transparent);
}
/* ---- Dither texture ---- */
.dither {
background: repeating-conic-gradient(currentColor 0% 25%, #0000 0% 50%) 0 0 / 2px 2px;
}
/* ---- Blink cursor (only on group hover, like canonical) ---- */
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.blink {
display: none;
}
.group:hover .blink {
display: inline-block;
animation: blink 1s step-end infinite;
}
/* ---- Page transitions ---- */
@keyframes fade-in {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes toast-in {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes toast-out {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(16px); }
}
/* ---- Plus-lighter blend for headings ---- */
.blend-lighter {
mix-blend-mode: plus-lighter;
}
/* ---- Font utilities ---- */
.font-display { font-family: "Mondwest", Arial, sans-serif; }
.font-expanded { font-family: "RulesExpanded", Arial, sans-serif; }
.font-compressed { font-family: "RulesCompressed", Arial, sans-serif; }
.font-courier { font-family: "Courier Prime", "Courier New", monospace; }
.font-collapse { font-family: "Collapse", Arial, sans-serif; }
.font-mono-ui { font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, monospace; }
/* ---- Subtle grain overlay for badges ---- */
.grain {
position: relative;
}
.grain::after {
content: "";
position: absolute;
inset: 0;
opacity: 0.12;
pointer-events: none;
background: repeating-conic-gradient(currentColor 0% 25%, #0000 0% 50%) 0 0 / 2px 2px;
}
/* ---- Global noise grain (canonical: color-dodge, #eaeaea, high density) ---- */
.noise-overlay {
pointer-events: none;
position: fixed;
inset: 0;
z-index: 101;
mix-blend-mode: color-dodge;
opacity: 0.10;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' fill='%23eaeaea' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E");
background-size: 512px 512px;
}
/* ---- Vignette (canonical: top-left amber radial, lighten blend) ---- */
.warm-glow {
pointer-events: none;
position: fixed;
inset: 0;
z-index: 99;
mix-blend-mode: lighten;
opacity: 0.22;
background: radial-gradient(ellipse at 0% 0%, rgba(255,189,56,0.35) 0%, rgba(255,189,56,0) 60%);
}
/* ---- Reduced motion ---- */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}

260
web/src/lib/api.ts Normal file
View file

@ -0,0 +1,260 @@
const BASE = "";
// Ephemeral session token for protected endpoints (reveal).
// Fetched once on first reveal request and cached in memory.
let _sessionToken: string | null = null;
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${url}`, init);
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`${res.status}: ${text}`);
}
return res.json();
}
async function getSessionToken(): Promise<string> {
if (_sessionToken) return _sessionToken;
const resp = await fetchJSON<{ token: string }>("/api/auth/session-token");
_sessionToken = resp.token;
return _sessionToken;
}
export const api = {
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
getSessions: () => fetchJSON<SessionInfo[]>("/api/sessions"),
getSessionMessages: (id: string) =>
fetchJSON<SessionMessagesResponse>(`/api/sessions/${encodeURIComponent(id)}/messages`),
deleteSession: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
method: "DELETE",
}),
getLogs: (params: { file?: string; lines?: number; level?: string; component?: string }) => {
const qs = new URLSearchParams();
if (params.file) qs.set("file", params.file);
if (params.lines) qs.set("lines", String(params.lines));
if (params.level && params.level !== "ALL") qs.set("level", params.level);
if (params.component && params.component !== "all") qs.set("component", params.component);
return fetchJSON<LogsResponse>(`/api/logs?${qs.toString()}`);
},
getAnalytics: (days: number) =>
fetchJSON<AnalyticsResponse>(`/api/analytics/usage?days=${days}`),
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
saveConfig: (config: Record<string, unknown>) =>
fetchJSON<{ ok: boolean }>("/api/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ config }),
}),
getConfigRaw: () => fetchJSON<{ yaml: string }>("/api/config/raw"),
saveConfigRaw: (yaml_text: string) =>
fetchJSON<{ ok: boolean }>("/api/config/raw", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ yaml_text }),
}),
getEnvVars: () => fetchJSON<Record<string, EnvVarInfo>>("/api/env"),
setEnvVar: (key: string, value: string) =>
fetchJSON<{ ok: boolean }>("/api/env", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, value }),
}),
deleteEnvVar: (key: string) =>
fetchJSON<{ ok: boolean }>("/api/env", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
}),
revealEnvVar: async (key: string) => {
const token = await getSessionToken();
return fetchJSON<{ key: string; value: string }>("/api/env/reveal", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ key }),
});
},
// Cron jobs
getCronJobs: () => fetchJSON<CronJob[]>("/api/cron/jobs"),
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }) =>
fetchJSON<CronJob>("/api/cron/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(job),
}),
pauseCronJob: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/pause`, { method: "POST" }),
resumeCronJob: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/resume`, { method: "POST" }),
triggerCronJob: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/trigger`, { method: "POST" }),
deleteCronJob: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}`, { method: "DELETE" }),
// Skills & Toolsets
getSkills: () => fetchJSON<SkillInfo[]>("/api/skills"),
toggleSkill: (name: string, enabled: boolean) =>
fetchJSON<{ ok: boolean }>("/api/skills/toggle", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, enabled }),
}),
getToolsets: () => fetchJSON<ToolsetInfo[]>("/api/tools/toolsets"),
// Session search (FTS5)
searchSessions: (q: string) =>
fetchJSON<SessionSearchResponse>(`/api/sessions/search?q=${encodeURIComponent(q)}`),
};
export interface PlatformStatus {
error_code?: string;
error_message?: string;
state: string;
updated_at: string;
}
export interface StatusResponse {
active_sessions: number;
config_path: string;
config_version: number;
env_path: string;
gateway_exit_reason: string | null;
gateway_pid: number | null;
gateway_platforms: Record<string, PlatformStatus>;
gateway_running: boolean;
gateway_state: string | null;
gateway_updated_at: string | null;
hermes_home: string;
latest_config_version: number;
release_date: string;
version: string;
}
export interface SessionInfo {
id: string;
source: string | null;
model: string | null;
title: string | null;
started_at: number;
ended_at: number | null;
last_active: number;
is_active: boolean;
message_count: number;
tool_call_count: number;
input_tokens: number;
output_tokens: number;
preview: string | null;
}
export interface EnvVarInfo {
is_set: boolean;
redacted_value: string | null;
description: string;
url: string | null;
category: string;
is_password: boolean;
tools: string[];
advanced: boolean;
}
export interface SessionMessage {
role: "user" | "assistant" | "system" | "tool";
content: string | null;
tool_calls?: Array<{
id: string;
function: { name: string; arguments: string };
}>;
tool_name?: string;
tool_call_id?: string;
timestamp?: number;
}
export interface SessionMessagesResponse {
session_id: string;
messages: SessionMessage[];
}
export interface LogsResponse {
file: string;
lines: string[];
}
export interface AnalyticsDailyEntry {
day: string;
input_tokens: number;
output_tokens: number;
cache_read_tokens: number;
reasoning_tokens: number;
estimated_cost: number;
actual_cost: number;
sessions: number;
}
export interface AnalyticsModelEntry {
model: string;
input_tokens: number;
output_tokens: number;
estimated_cost: number;
sessions: number;
}
export interface AnalyticsResponse {
daily: AnalyticsDailyEntry[];
by_model: AnalyticsModelEntry[];
totals: {
total_input: number;
total_output: number;
total_cache_read: number;
total_reasoning: number;
total_estimated_cost: number;
total_actual_cost: number;
total_sessions: number;
};
}
export interface CronJob {
id: string;
name?: string;
prompt: string;
schedule: string;
status: "enabled" | "paused" | "error";
deliver?: string;
last_run_at?: string | null;
next_run_at?: string | null;
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[];
}

23
web/src/lib/nested.ts Normal file
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;
}

26
web/src/lib/utils.ts Normal file
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`;
}

10
web/src/main.tsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);

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][];
}

34
web/tsconfig.app.json Normal file
View file

@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
web/tsconfig.json Normal file
View file

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
web/tsconfig.node.json Normal file
View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

22
web/vite.config.ts Normal file
View file

@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
build: {
outDir: "../hermes_cli/web_dist",
emptyOutDir: true,
},
server: {
proxy: {
"/api": "http://127.0.0.1:9119",
},
},
});