mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
Merge branch 'main' into feat/dashboard-skill-analytics
This commit is contained in:
commit
720e1c65b2
1022 changed files with 157411 additions and 17823 deletions
|
|
@ -4,7 +4,7 @@
|
|||
<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>
|
||||
<title>Hermes Agent - Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
1995
web/package-lock.json
generated
1995
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,15 +4,23 @@
|
|||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"sync-assets": "rm -rf public/fonts public/ds-assets && cp -r node_modules/@nous-research/ui/dist/fonts public/fonts && cp -r node_modules/@nous-research/ui/dist/assets public/ds-assets",
|
||||
"predev": "npm run sync-assets",
|
||||
"prebuild": "npm run sync-assets",
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "^0.3.0",
|
||||
"@observablehq/plot": "^0.6.17",
|
||||
"@react-three/fiber": "^9.6.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"gsap": "^3.15.0",
|
||||
"leva": "^0.10.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
|
|
@ -30,6 +38,7 @@
|
|||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"three": "^0.180.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^7.3.1"
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
305
web/src/App.tsx
305
web/src/App.tsx
|
|
@ -1,5 +1,30 @@
|
|||
import { useMemo } from "react";
|
||||
import { Routes, Route, NavLink, Navigate } from "react-router-dom";
|
||||
import { Activity, BarChart3, Clock, FileText, KeyRound, MessageSquare, Package, Settings } from "lucide-react";
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Clock,
|
||||
FileText,
|
||||
KeyRound,
|
||||
MessageSquare,
|
||||
Package,
|
||||
Settings,
|
||||
Puzzle,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Globe,
|
||||
Database,
|
||||
Shield,
|
||||
Wrench,
|
||||
Zap,
|
||||
Heart,
|
||||
Star,
|
||||
Code,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { Cell, Grid, SelectionSwitcher, Typography } from "@nous-research/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Backdrop } from "@/components/Backdrop";
|
||||
import StatusPage from "@/pages/StatusPage";
|
||||
import ConfigPage from "@/pages/ConfigPage";
|
||||
import EnvPage from "@/pages/EnvPage";
|
||||
|
|
@ -9,73 +34,200 @@ import AnalyticsPage from "@/pages/AnalyticsPage";
|
|||
import CronPage from "@/pages/CronPage";
|
||||
import SkillsPage from "@/pages/SkillsPage";
|
||||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePlugins } from "@/plugins";
|
||||
import type { RegisteredPlugin } from "@/plugins";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ path: "/", labelKey: "status" as const, icon: Activity },
|
||||
{ path: "/sessions", labelKey: "sessions" as const, icon: MessageSquare },
|
||||
{ path: "/analytics", labelKey: "analytics" as const, icon: BarChart3 },
|
||||
{ path: "/logs", labelKey: "logs" as const, icon: FileText },
|
||||
{ path: "/cron", labelKey: "cron" as const, icon: Clock },
|
||||
{ path: "/skills", labelKey: "skills" as const, icon: Package },
|
||||
{ path: "/config", labelKey: "config" as const, icon: Settings },
|
||||
{ path: "/env", labelKey: "keys" as const, icon: KeyRound },
|
||||
] as const;
|
||||
const BUILTIN_NAV: NavItem[] = [
|
||||
{ path: "/", labelKey: "status", label: "Status", icon: Activity },
|
||||
{
|
||||
path: "/sessions",
|
||||
labelKey: "sessions",
|
||||
label: "Sessions",
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
path: "/analytics",
|
||||
labelKey: "analytics",
|
||||
label: "Analytics",
|
||||
icon: BarChart3,
|
||||
},
|
||||
{ path: "/logs", labelKey: "logs", label: "Logs", icon: FileText },
|
||||
{ path: "/cron", labelKey: "cron", label: "Cron", icon: Clock },
|
||||
{ path: "/skills", labelKey: "skills", label: "Skills", icon: Package },
|
||||
{ path: "/config", labelKey: "config", label: "Config", icon: Settings },
|
||||
{ path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound },
|
||||
];
|
||||
|
||||
// Plugins can reference any of these by name in their manifest — keeps bundle
|
||||
// size sane vs. importing the full lucide-react set.
|
||||
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Activity,
|
||||
BarChart3,
|
||||
Clock,
|
||||
FileText,
|
||||
KeyRound,
|
||||
MessageSquare,
|
||||
Package,
|
||||
Settings,
|
||||
Puzzle,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Globe,
|
||||
Database,
|
||||
Shield,
|
||||
Wrench,
|
||||
Zap,
|
||||
Heart,
|
||||
Star,
|
||||
Code,
|
||||
Eye,
|
||||
};
|
||||
|
||||
function resolveIcon(
|
||||
name: string,
|
||||
): React.ComponentType<{ className?: string }> {
|
||||
return ICON_MAP[name] ?? Puzzle;
|
||||
}
|
||||
|
||||
function buildNavItems(
|
||||
builtIn: NavItem[],
|
||||
plugins: RegisteredPlugin[],
|
||||
): NavItem[] {
|
||||
const items = [...builtIn];
|
||||
|
||||
for (const { manifest } of plugins) {
|
||||
const pluginItem: NavItem = {
|
||||
path: manifest.tab.path,
|
||||
label: manifest.label,
|
||||
icon: resolveIcon(manifest.icon),
|
||||
};
|
||||
|
||||
const pos = manifest.tab.position ?? "end";
|
||||
if (pos === "end") {
|
||||
items.push(pluginItem);
|
||||
} else if (pos.startsWith("after:")) {
|
||||
const target = "/" + pos.slice(6);
|
||||
const idx = items.findIndex((i) => i.path === target);
|
||||
items.splice(idx >= 0 ? idx + 1 : items.length, 0, pluginItem);
|
||||
} else if (pos.startsWith("before:")) {
|
||||
const target = "/" + pos.slice(7);
|
||||
const idx = items.findIndex((i) => i.path === target);
|
||||
items.splice(idx >= 0 ? idx : items.length, 0, pluginItem);
|
||||
} else {
|
||||
items.push(pluginItem);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { t } = useI18n();
|
||||
const { plugins } = usePlugins();
|
||||
|
||||
const navItems = useMemo(
|
||||
() => buildNavItems(BUILTIN_NAV, plugins),
|
||||
[plugins],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background text-foreground overflow-x-hidden">
|
||||
<div className="noise-overlay" />
|
||||
<div className="warm-glow" />
|
||||
<div className="text-midground font-mondwest bg-black min-h-screen flex flex-col uppercase antialiased overflow-x-hidden">
|
||||
<SelectionSwitcher />
|
||||
<Backdrop />
|
||||
|
||||
<header className="fixed top-0 left-0 right-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">
|
||||
<div className="flex items-center border-r border-border px-3 sm:px-5 shrink-0">
|
||||
<span className="font-collapse text-lg sm:text-xl font-bold tracking-wider uppercase blend-lighter">
|
||||
H<span className="hidden sm:inline">ermes </span>A<span className="hidden sm:inline">gent</span>
|
||||
</span>
|
||||
</div>
|
||||
<header
|
||||
className={cn(
|
||||
"fixed top-0 left-0 right-0 z-40",
|
||||
"border-b border-current/20",
|
||||
"bg-background-base/90 backdrop-blur-sm",
|
||||
)}
|
||||
>
|
||||
<div className="mx-auto flex h-12 max-w-[1600px]">
|
||||
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-none">
|
||||
<Grid
|
||||
className="h-full !border-t-0 !border-b-0"
|
||||
style={{
|
||||
gridTemplateColumns: `auto repeat(${navItems.length}, auto)`,
|
||||
}}
|
||||
>
|
||||
<Cell className="flex items-center !p-0 !px-3 sm:!px-5">
|
||||
<Typography
|
||||
className="font-bold text-[1.0625rem] sm:text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
Hermes
|
||||
<br />
|
||||
Agent
|
||||
</Typography>
|
||||
</Cell>
|
||||
|
||||
<nav className="flex items-stretch overflow-x-auto scrollbar-none">
|
||||
{NAV_ITEMS.map(({ path, labelKey, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={path}
|
||||
to={path}
|
||||
end={path === "/"}
|
||||
className={({ isActive }) =>
|
||||
`group relative inline-flex items-center gap-1 sm:gap-1.5 border-r border-border px-2.5 sm:px-4 py-2 font-display text-[0.65rem] sm: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 ${
|
||||
isActive
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon className="h-4 w-4 sm:h-3.5 sm:w-3.5 shrink-0" />
|
||||
<span className="hidden sm:inline">{t.app.nav[labelKey]}</span>
|
||||
<span className="absolute inset-0 bg-foreground pointer-events-none transition-opacity duration-150 group-hover:opacity-5 opacity-0" />
|
||||
{isActive && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-px bg-foreground" />
|
||||
{navItems.map(({ path, label, labelKey, icon: Icon }) => (
|
||||
<Cell key={path} className="relative !p-0">
|
||||
<NavLink
|
||||
to={path}
|
||||
end={path === "/"}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"group relative flex h-full w-full items-center gap-1.5",
|
||||
"px-2.5 sm:px-4 py-2",
|
||||
"font-mondwest text-[0.65rem] sm:text-[0.8rem] tracking-[0.12em]",
|
||||
"whitespace-nowrap transition-colors cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
isActive
|
||||
? "text-midground"
|
||||
: "opacity-60 hover:opacity-100",
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="hidden sm:inline">
|
||||
{labelKey
|
||||
? ((t.app.nav as Record<string, string>)[
|
||||
labelKey
|
||||
] ?? label)
|
||||
: label}
|
||||
</span>
|
||||
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute inset-1 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
|
||||
/>
|
||||
|
||||
{isActive && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute bottom-0 left-0 right-0 h-px bg-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 px-2 sm:px-4">
|
||||
<LanguageSwitcher />
|
||||
<span className="hidden sm:inline font-display text-[0.7rem] tracking-[0.15em] uppercase opacity-50">
|
||||
{t.app.webUi}
|
||||
</span>
|
||||
</NavLink>
|
||||
</Cell>
|
||||
))}
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
<Grid className="h-full shrink-0 !border-t-0 !border-b-0">
|
||||
<Cell className="flex items-center gap-2 !p-0 !px-2 sm:!px-4">
|
||||
<ThemeSwitcher />
|
||||
<LanguageSwitcher />
|
||||
<Typography
|
||||
mondwest
|
||||
className="hidden sm:inline text-[0.7rem] tracking-[0.15em] opacity-50"
|
||||
>
|
||||
{t.app.webUi}
|
||||
</Typography>
|
||||
</Cell>
|
||||
</Grid>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="relative z-2 mx-auto w-full max-w-[1400px] flex-1 px-3 sm:px-6 pt-16 sm:pt-20 pb-4 sm:pb-8">
|
||||
<main className="relative z-2 mx-auto w-full max-w-[1600px] flex-1 px-3 sm:px-6 pt-16 sm:pt-20 pb-4 sm:pb-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<StatusPage />} />
|
||||
<Route path="/sessions" element={<SessionsPage />} />
|
||||
|
|
@ -85,20 +237,47 @@ export default function App() {
|
|||
<Route path="/skills" element={<SkillsPage />} />
|
||||
<Route path="/config" element={<ConfigPage />} />
|
||||
<Route path="/env" element={<EnvPage />} />
|
||||
|
||||
{plugins.map(({ manifest, component: PluginComponent }) => (
|
||||
<Route
|
||||
key={manifest.name}
|
||||
path={manifest.tab.path}
|
||||
element={<PluginComponent />}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
<footer className="relative z-2 border-t border-border">
|
||||
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-3 sm:px-6 py-3">
|
||||
<span className="font-display text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] uppercase opacity-50">
|
||||
{t.app.footer.name}
|
||||
</span>
|
||||
<span className="font-display text-[0.6rem] sm:text-[0.7rem] tracking-[0.15em] uppercase text-foreground/40">
|
||||
{t.app.footer.org}
|
||||
</span>
|
||||
</div>
|
||||
<footer className="relative z-2 border-t border-current/20">
|
||||
<Grid className="mx-auto max-w-[1600px] !border-t-0 !border-b-0">
|
||||
<Cell className="flex items-center !px-3 sm:!px-6 !py-3">
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] opacity-60"
|
||||
>
|
||||
{t.app.footer.name}
|
||||
</Typography>
|
||||
</Cell>
|
||||
<Cell className="flex items-center justify-end !px-3 sm:!px-6 !py-3">
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.6rem] sm:text-[0.7rem] tracking-[0.15em] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
{t.app.footer.org}
|
||||
</Typography>
|
||||
</Cell>
|
||||
</Grid>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
labelKey?: string;
|
||||
path: string;
|
||||
}
|
||||
|
|
|
|||
77
web/src/components/Backdrop.tsx
Normal file
77
web/src/components/Backdrop.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { useGpuTier } from "@nous-research/ui/hooks/use-gpu-tier";
|
||||
|
||||
/**
|
||||
* Replicates the visual layer stack of `<Overlays dark />` from
|
||||
* `@nous-research/ui` without pulling in its leva / gsap / three peer deps.
|
||||
*
|
||||
* See `design-language/src/ui/components/overlays/index.tsx` for the source of
|
||||
* truth. Defaults match LENS_0 (the Hermes teal dark preset); the deep canvas
|
||||
* and the warm vignette both read theme-switchable CSS custom properties so
|
||||
* `ThemeProvider` can repaint the stack without remounting.
|
||||
*
|
||||
* z-1 bg = `var(--background-base)`, mix-blend-mode: difference
|
||||
* z-2 filler-bg jpeg, inverted, opacity 0.033, difference
|
||||
* z-99 warm top-left vignette (`var(--warm-glow)`), opacity 0.22, lighten
|
||||
* z-101 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`,
|
||||
* color-dodge) — gated on GPU tier
|
||||
*
|
||||
* `useGpuTier` returns 0 when WebGL is unavailable, the renderer is a
|
||||
* software rasterizer (SwiftShader/llvmpipe), or the user has
|
||||
* `prefers-reduced-motion: reduce` set. We skip the animated noise layer
|
||||
* in that case so low-power / accessibility-conscious sessions stay crisp,
|
||||
* mirroring the DS `<Noise />` component's own opt-out.
|
||||
*/
|
||||
export function Backdrop() {
|
||||
const gpuTier = useGpuTier();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-[1]"
|
||||
style={{
|
||||
backgroundColor: "var(--background-base)",
|
||||
mixBlendMode: "difference",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-[2]"
|
||||
style={{ mixBlendMode: "difference", opacity: 0.033 }}
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
className="h-[150dvh] w-auto min-w-[100dvw] object-cover object-top-left invert"
|
||||
fetchPriority="low"
|
||||
src="/ds-assets/filler-bg0.jpg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-[99]"
|
||||
style={{
|
||||
background:
|
||||
"radial-gradient(ellipse at 0% 0%, transparent 60%, var(--warm-glow) 100%)",
|
||||
mixBlendMode: "lighten",
|
||||
opacity: 0.22,
|
||||
}}
|
||||
/>
|
||||
|
||||
{gpuTier > 0 && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-[101]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"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\")",
|
||||
backgroundSize: "512px 512px",
|
||||
mixBlendMode: "color-dodge",
|
||||
opacity: "calc(0.55 * var(--noise-opacity-mul, 1))",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { Typography } from "@nous-research/ui";
|
||||
import { useI18n } from "@/i18n/context";
|
||||
|
||||
/**
|
||||
|
|
@ -17,11 +18,16 @@ export function LanguageSwitcher() {
|
|||
title={t.language.switchTo}
|
||||
aria-label={t.language.switchTo}
|
||||
>
|
||||
{/* Show the *other* language's flag as the clickable target */}
|
||||
<span className="text-base leading-none">{locale === "en" ? "🇨🇳" : "🇬🇧"}</span>
|
||||
<span className="hidden sm:inline font-display tracking-wide uppercase text-[0.65rem]">
|
||||
{locale === "en" ? "中文" : "EN"}
|
||||
{/* Show the *current* language's flag — tooltip advertises the click action */}
|
||||
<span className="text-base leading-none">
|
||||
{locale === "en" ? "🇬🇧" : "🇨🇳"}
|
||||
</span>
|
||||
<Typography
|
||||
mondwest
|
||||
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
|
||||
>
|
||||
{locale === "en" ? "EN" : "中文"}
|
||||
</Typography>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { ExternalLink, Copy, X, Check, Loader2 } from "lucide-react";
|
||||
import { H2 } from "@nous-research/ui";
|
||||
import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -12,9 +13,21 @@ interface Props {
|
|||
onError: (msg: string) => void;
|
||||
}
|
||||
|
||||
type Phase = "idle" | "starting" | "awaiting_user" | "submitting" | "polling" | "approved" | "error";
|
||||
type Phase =
|
||||
| "idle"
|
||||
| "starting"
|
||||
| "awaiting_user"
|
||||
| "submitting"
|
||||
| "polling"
|
||||
| "approved"
|
||||
| "error";
|
||||
|
||||
export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props) {
|
||||
export function OAuthLoginModal({
|
||||
provider,
|
||||
onClose,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: Props) {
|
||||
const [phase, setPhase] = useState<Phase>("starting");
|
||||
const [start, setStart] = useState<OAuthStartResponse | null>(null);
|
||||
const [pkceCode, setPkceCode] = useState("");
|
||||
|
|
@ -81,13 +94,15 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
if (!isMounted.current) return;
|
||||
if (resp.status === "approved") {
|
||||
setPhase("approved");
|
||||
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
||||
if (pollTimer.current !== null)
|
||||
window.clearInterval(pollTimer.current);
|
||||
onSuccess(`${provider.name} connected`);
|
||||
window.setTimeout(() => isMounted.current && onClose(), 1500);
|
||||
} else if (resp.status !== "pending") {
|
||||
setPhase("error");
|
||||
setErrorMsg(resp.error_message || `Login ${resp.status}`);
|
||||
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
||||
if (pollTimer.current !== null)
|
||||
window.clearInterval(pollTimer.current);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!isMounted.current) return;
|
||||
|
|
@ -107,7 +122,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
setPhase("submitting");
|
||||
setErrorMsg(null);
|
||||
try {
|
||||
const resp = await api.submitOAuthCode(provider.id, start.session_id, pkceCode.trim());
|
||||
const resp = await api.submitOAuthCode(
|
||||
provider.id,
|
||||
start.session_id,
|
||||
pkceCode.trim(),
|
||||
);
|
||||
if (!isMounted.current) return;
|
||||
if (resp.ok && resp.status === "approved") {
|
||||
setPhase("approved");
|
||||
|
|
@ -175,14 +194,24 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
</button>
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 id="oauth-modal-title" className="font-display text-base tracking-wider uppercase">
|
||||
<H2
|
||||
id="oauth-modal-title"
|
||||
variant="sm"
|
||||
mondwest
|
||||
className="tracking-wider uppercase"
|
||||
>
|
||||
{t.oauth.connect} {provider.name}
|
||||
</h2>
|
||||
{secondsLeft !== null && phase !== "approved" && phase !== "error" && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t.oauth.sessionExpires.replace("{time}", fmtTime(secondsLeft))}
|
||||
</p>
|
||||
)}
|
||||
</H2>
|
||||
{secondsLeft !== null &&
|
||||
phase !== "approved" &&
|
||||
phase !== "error" && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t.oauth.sessionExpires.replace(
|
||||
"{time}",
|
||||
fmtTime(secondsLeft),
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── starting ───────────────────────────────────── */}
|
||||
|
|
@ -211,7 +240,10 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
/>
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<a
|
||||
href={(start as Extract<OAuthStartResponse, { flow: "pkce" }>).auth_url}
|
||||
href={
|
||||
(start as Extract<OAuthStartResponse, { flow: "pkce" }>)
|
||||
.auth_url
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||
|
|
@ -219,7 +251,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
<ExternalLink className="h-3 w-3" />
|
||||
{t.oauth.reOpenAuth}
|
||||
</a>
|
||||
<Button onClick={handleSubmitPkceCode} disabled={!pkceCode.trim()} size="sm">
|
||||
<Button
|
||||
onClick={handleSubmitPkceCode}
|
||||
disabled={!pkceCode.trim()}
|
||||
size="sm"
|
||||
>
|
||||
{t.oauth.submitCode}
|
||||
</Button>
|
||||
</div>
|
||||
|
|
@ -243,23 +279,46 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
</p>
|
||||
<div className="flex items-center justify-between gap-2 border border-border bg-secondary/30 p-4">
|
||||
<code className="font-mono-ui text-2xl tracking-widest text-foreground">
|
||||
{(start as Extract<OAuthStartResponse, { flow: "device_code" }>).user_code}
|
||||
{
|
||||
(
|
||||
start as Extract<
|
||||
OAuthStartResponse,
|
||||
{ flow: "device_code" }
|
||||
>
|
||||
).user_code
|
||||
}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleCopyUserCode(
|
||||
(start as Extract<OAuthStartResponse, { flow: "device_code" }>).user_code,
|
||||
(
|
||||
start as Extract<
|
||||
OAuthStartResponse,
|
||||
{ flow: "device_code" }
|
||||
>
|
||||
).user_code,
|
||||
)
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{codeCopied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
{codeCopied ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<a
|
||||
href={(start as Extract<OAuthStartResponse, { flow: "device_code" }>).verification_url}
|
||||
href={
|
||||
(
|
||||
start as Extract<
|
||||
OAuthStartResponse,
|
||||
{ flow: "device_code" }
|
||||
>
|
||||
).verification_url
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||
|
|
@ -302,21 +361,36 @@ export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props
|
|||
setStart(null);
|
||||
setPkceCode("");
|
||||
setPhase("starting");
|
||||
api.startOAuthLogin(provider.id).then((resp) => {
|
||||
if (!isMounted.current) return;
|
||||
setStart(resp);
|
||||
setSecondsLeft(resp.expires_in);
|
||||
setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user");
|
||||
if (resp.flow === "pkce") {
|
||||
window.open(resp.auth_url, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
window.open(resp.verification_url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}).catch((e) => {
|
||||
if (!isMounted.current) return;
|
||||
setPhase("error");
|
||||
setErrorMsg(`${t.common.retry} failed: ${e}`);
|
||||
});
|
||||
api
|
||||
.startOAuthLogin(provider.id)
|
||||
.then((resp) => {
|
||||
if (!isMounted.current) return;
|
||||
setStart(resp);
|
||||
setSecondsLeft(resp.expires_in);
|
||||
setPhase(
|
||||
resp.flow === "device_code"
|
||||
? "polling"
|
||||
: "awaiting_user",
|
||||
);
|
||||
if (resp.flow === "pkce") {
|
||||
window.open(
|
||||
resp.auth_url,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
} else {
|
||||
window.open(
|
||||
resp.verification_url,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!isMounted.current) return;
|
||||
setPhase("error");
|
||||
setErrorMsg(`${t.common.retry} failed: ${e}`);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t.common.retry}
|
||||
|
|
|
|||
|
|
@ -158,11 +158,11 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
|||
)}
|
||||
</div>
|
||||
{p.status.logged_in && p.status.token_preview && (
|
||||
<code className="text-xs text-muted-foreground font-mono-ui truncate">
|
||||
token{" "}
|
||||
<span className="text-foreground">{p.status.token_preview}</span>
|
||||
<code className="text-xs font-mono-ui truncate">
|
||||
<span className="opacity-50">token{" "}</span>
|
||||
{p.status.token_preview}
|
||||
{p.status.source_label && (
|
||||
<span className="text-muted-foreground/70">
|
||||
<span className="opacity-40">
|
||||
{" "}· {p.status.source_label}
|
||||
</span>
|
||||
)}
|
||||
|
|
|
|||
168
web/src/components/ThemeSwitcher.tsx
Normal file
168
web/src/components/ThemeSwitcher.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Palette, Check } from "lucide-react";
|
||||
import { Typography } from "@nous-research/ui";
|
||||
import { BUILTIN_THEMES, useTheme } from "@/themes";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Compact theme picker mounted next to the language switcher in the header.
|
||||
* Each dropdown row shows a 3-stop swatch (background / midground / warm
|
||||
* glow) so users can preview the palette before committing. User-defined
|
||||
* themes from `~/.hermes/dashboard-themes/*.yaml` that aren't in
|
||||
* `BUILTIN_THEMES` render without swatches and apply the default palette.
|
||||
*/
|
||||
export function ThemeSwitcher() {
|
||||
const { themeName, availableThemes, setTheme } = useTheme();
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (
|
||||
wrapperRef.current &&
|
||||
!wrapperRef.current.contains(e.target as Node)
|
||||
) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
document.addEventListener("mousedown", onMouseDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onMouseDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open, close]);
|
||||
|
||||
const current = availableThemes.find((th) => th.name === themeName);
|
||||
const label = current?.label ?? themeName;
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className={cn(
|
||||
"group relative inline-flex items-center gap-1.5 px-2 py-1 text-xs",
|
||||
"text-muted-foreground hover:text-foreground transition-colors cursor-pointer",
|
||||
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
||||
)}
|
||||
title={t.theme?.switchTheme ?? "Switch theme"}
|
||||
aria-label={t.theme?.switchTheme ?? "Switch theme"}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
>
|
||||
<Palette className="h-3.5 w-3.5" />
|
||||
<Typography
|
||||
mondwest
|
||||
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label={t.theme?.title ?? "Theme"}
|
||||
className={cn(
|
||||
"absolute right-0 top-full mt-1 z-50 min-w-[240px]",
|
||||
"border border-current/20 bg-background-base/95 backdrop-blur-sm",
|
||||
"shadow-[0_12px_32px_-8px_rgba(0,0,0,0.6)]",
|
||||
)}
|
||||
>
|
||||
<div className="border-b border-current/20 px-3 py-2">
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.65rem] tracking-[0.15em] uppercase text-midground/70"
|
||||
>
|
||||
{t.theme?.title ?? "Theme"}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
{availableThemes.map((th) => {
|
||||
const isActive = th.name === themeName;
|
||||
const preset = BUILTIN_THEMES[th.name];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={th.name}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onClick={() => {
|
||||
setTheme(th.name);
|
||||
close();
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 px-3 py-2 text-left transition-colors cursor-pointer",
|
||||
"hover:bg-midground/10",
|
||||
isActive ? "text-midground" : "text-midground/60",
|
||||
)}
|
||||
>
|
||||
{preset ? (
|
||||
<ThemeSwatch theme={preset.name} />
|
||||
) : (
|
||||
<PlaceholderSwatch />
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<Typography
|
||||
mondwest
|
||||
className="truncate text-[0.75rem] tracking-wide uppercase"
|
||||
>
|
||||
{th.label}
|
||||
</Typography>
|
||||
{th.description && (
|
||||
<Typography className="truncate text-[0.65rem] normal-case tracking-normal text-midground/50">
|
||||
{th.description}
|
||||
</Typography>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Check
|
||||
className={cn(
|
||||
"h-3 w-3 shrink-0 text-midground",
|
||||
isActive ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeSwatch({ theme }: { theme: string }) {
|
||||
const preset = BUILTIN_THEMES[theme];
|
||||
if (!preset) return <PlaceholderSwatch />;
|
||||
const { background, midground, warmGlow } = preset.palette;
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="flex h-4 w-9 shrink-0 overflow-hidden border border-current/20"
|
||||
>
|
||||
<span className="flex-1" style={{ background: background.hex }} />
|
||||
<span className="flex-1" style={{ background: midground.hex }} />
|
||||
<span className="flex-1" style={{ background: warmGlow }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaceholderSwatch() {
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="h-4 w-9 shrink-0 border border-dashed border-current/20"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ 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"
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap font-mondwest text-xs tracking-[0.1em] uppercase transition-colors cursor-pointer"
|
||||
+ " disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHead
|
|||
}
|
||||
|
||||
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return <p className={cn("font-display text-xs text-muted-foreground", className)} {...props} />;
|
||||
return <p className={cn("font-mondwest text-xs text-muted-foreground", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ export function Label({ className, ...props }: React.LabelHTMLAttributes<HTMLLab
|
|||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"font-display text-xs tracking-[0.1em] uppercase leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
"font-mondwest text-xs tracking-[0.1em] uppercase leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export function TabsTrigger({
|
|||
<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",
|
||||
"relative inline-flex items-center justify-center whitespace-nowrap px-3 py-1.5 font-mondwest 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"
|
||||
|
|
|
|||
|
|
@ -280,4 +280,9 @@ export const en: Translations = {
|
|||
language: {
|
||||
switchTo: "Switch to Chinese",
|
||||
},
|
||||
|
||||
theme: {
|
||||
title: "Theme",
|
||||
switchTheme: "Switch theme",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -292,4 +292,10 @@ export interface Translations {
|
|||
language: {
|
||||
switchTo: string;
|
||||
};
|
||||
|
||||
// ── Theme switcher ──
|
||||
theme: {
|
||||
title: string;
|
||||
switchTheme: string;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -280,4 +280,9 @@ export const zh: Translations = {
|
|||
language: {
|
||||
switchTo: "切换到英文",
|
||||
},
|
||||
|
||||
theme: {
|
||||
title: "主题",
|
||||
switchTheme: "切换主题",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,132 +1,74 @@
|
|||
@import "tailwindcss";
|
||||
@import 'tailwindcss';
|
||||
@import '@nous-research/ui/styles/globals.css';
|
||||
|
||||
/* Scan the published design-system bundle so its utility classes survive
|
||||
Tailwind's JIT purge. */
|
||||
@source '../node_modules/@nous-research/ui/dist';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Hermes Agent — Design tokens */
|
||||
/* Matched to hermes-agent.nousresearch.com (dark teal theme) */
|
||||
/* Hermes Agent — Nous DS with the LENS_0 (Hermes teal) lens applied */
|
||||
/* statically. Mirrors nousnet-web/(hermes-agent)/layout.tsx so the */
|
||||
/* canonical Hermes palette is the default — teal canvas + cream */
|
||||
/* accent — without relying on leva/gsap at runtime. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/* --- 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; }
|
||||
:root {
|
||||
/* LENS_0 — from design-language/src/ui/components/overlays/index.tsx.
|
||||
These are the defaults for the `default` (Hermes Teal) dashboard theme;
|
||||
ThemeProvider rewrites them as inline styles when a user switches themes. */
|
||||
--foreground: color-mix(in srgb, #ffffff 0%, transparent);
|
||||
--foreground-base: #ffffff;
|
||||
--foreground-alpha: 0;
|
||||
--midground: color-mix(in srgb, #ffe6cb 100%, transparent);
|
||||
--midground-base: #ffe6cb;
|
||||
--midground-alpha: 1;
|
||||
--background: color-mix(in srgb, #041c1c 100%, transparent);
|
||||
--background-base: #041c1c;
|
||||
--background-alpha: 1;
|
||||
|
||||
@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;
|
||||
/* Consumed by <Backdrop />; also theme-switchable. */
|
||||
--warm-glow: rgba(255, 189, 56, 0.35);
|
||||
--noise-opacity-mul: 1;
|
||||
}
|
||||
|
||||
/* Nousnet's hermes-agent layout bumps `small` and `code` to readable
|
||||
dashboard sizes. Keep in sync. */
|
||||
small { font-size: 1.0625rem; }
|
||||
code { font-size: 0.875rem; }
|
||||
|
||||
/* Shadcn-compat tokens.
|
||||
The dashboard's page code predates the Nous DS and uses shadcn-style
|
||||
utility classes (bg-card, text-muted-foreground, border-border, etc.)
|
||||
extensively. Rather than rewrite every call site, we expose those
|
||||
tokens on top of the Nous palette so classes continue to resolve. */
|
||||
@theme inline {
|
||||
/* Remap foreground to midground so `text-foreground` / `bg-foreground`
|
||||
stay visible — in LENS_0, `--foreground` itself has alpha 0. */
|
||||
--color-foreground: var(--midground);
|
||||
|
||||
--color-card: color-mix(in srgb, var(--midground-base) 4%, var(--background-base));
|
||||
--color-card-foreground: var(--midground);
|
||||
--color-primary: var(--midground);
|
||||
--color-primary-foreground: var(--background-base);
|
||||
--color-secondary: color-mix(in srgb, var(--midground-base) 6%, var(--background-base));
|
||||
--color-secondary-foreground: var(--midground);
|
||||
--color-muted: color-mix(in srgb, var(--midground-base) 8%, var(--background-base));
|
||||
--color-muted-foreground: color-mix(in srgb, var(--midground-base) 55%, transparent);
|
||||
--color-accent: color-mix(in srgb, var(--midground-base) 10%, var(--background-base));
|
||||
--color-accent-foreground: var(--midground);
|
||||
--color-destructive: #fb2c36;
|
||||
--color-destructive-foreground: #fff;
|
||||
--color-destructive-foreground: #ffffff;
|
||||
--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;
|
||||
--color-border: color-mix(in srgb, var(--midground-base) 15%, transparent);
|
||||
--color-input: color-mix(in srgb, var(--midground-base) 15%, transparent);
|
||||
--color-ring: var(--midground);
|
||||
--color-popover: color-mix(in srgb, var(--midground-base) 4%, var(--background-base));
|
||||
--color-popover-foreground: var(--midground);
|
||||
}
|
||||
|
||||
/* ---- 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 {
|
||||
overflow-x: hidden;
|
||||
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);
|
||||
}
|
||||
::-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); }
|
||||
}
|
||||
/* Toast animations used by `components/Toast.tsx`. */
|
||||
@keyframes toast-in {
|
||||
from { opacity: 0; transform: translateX(16px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
|
|
@ -136,62 +78,38 @@ code {
|
|||
to { opacity: 0; transform: translateX(16px); }
|
||||
}
|
||||
|
||||
/* ---- Plus-lighter blend for headings ---- */
|
||||
/* Hide scrollbar utility — used by the header's overflow-x nav row. */
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Plus-lighter blend used by logos/titles for a subtle glow. */
|
||||
.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; }
|
||||
/* System UI-monospace stack — distinct from `font-courier` (Courier
|
||||
Prime), used for dense data readouts where the display font would
|
||||
break the grid. */
|
||||
.font-mono-ui {
|
||||
font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, monospace;
|
||||
}
|
||||
|
||||
/* ---- Subtle grain overlay for badges ---- */
|
||||
/* Subtle grain overlay for badges. */
|
||||
.grain {
|
||||
position: relative;
|
||||
}
|
||||
.grain::after {
|
||||
content: "";
|
||||
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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ declare global {
|
|||
}
|
||||
let _sessionToken: string | null = null;
|
||||
|
||||
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
export async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
// Inject the session token into all /api/ requests.
|
||||
const headers = new Headers(init?.headers);
|
||||
const token = window.__HERMES_SESSION_TOKEN__;
|
||||
|
|
@ -182,6 +182,22 @@ export const api = {
|
|||
},
|
||||
);
|
||||
},
|
||||
|
||||
// Dashboard plugins
|
||||
getPlugins: () =>
|
||||
fetchJSON<PluginManifestResponse[]>("/api/dashboard/plugins"),
|
||||
rescanPlugins: () =>
|
||||
fetchJSON<{ ok: boolean; count: number }>("/api/dashboard/plugins/rescan"),
|
||||
|
||||
// Dashboard themes
|
||||
getThemes: () =>
|
||||
fetchJSON<DashboardThemesResponse>("/api/dashboard/themes"),
|
||||
setTheme: (name: string) =>
|
||||
fetchJSON<{ ok: boolean; theme: string }>("/api/dashboard/theme", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
}),
|
||||
};
|
||||
|
||||
export interface PlatformStatus {
|
||||
|
|
@ -197,6 +213,7 @@ export interface StatusResponse {
|
|||
config_version: number;
|
||||
env_path: string;
|
||||
gateway_exit_reason: string | null;
|
||||
gateway_health_url: string | null;
|
||||
gateway_pid: number | null;
|
||||
gateway_platforms: Record<string, PlatformStatus>;
|
||||
gateway_running: boolean;
|
||||
|
|
@ -435,3 +452,31 @@ export interface OAuthPollResponse {
|
|||
error_message?: string | null;
|
||||
expires_at?: number | null;
|
||||
}
|
||||
|
||||
// ── Dashboard theme types ──────────────────────────────────────────────
|
||||
|
||||
export interface DashboardThemeSummary {
|
||||
description: string;
|
||||
label: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DashboardThemesResponse {
|
||||
active: string;
|
||||
themes: DashboardThemeSummary[];
|
||||
}
|
||||
|
||||
// ── Dashboard plugin types ─────────────────────────────────────────────
|
||||
|
||||
export interface PluginManifestResponse {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
version: string;
|
||||
tab: { path: string; position: string };
|
||||
entry: string;
|
||||
css?: string | null;
|
||||
has_api: boolean;
|
||||
source: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,11 +3,19 @@ import { BrowserRouter } from "react-router-dom";
|
|||
import "./index.css";
|
||||
import App from "./App";
|
||||
import { I18nProvider } from "./i18n";
|
||||
import { exposePluginSDK } from "./plugins";
|
||||
import { ThemeProvider } from "./themes";
|
||||
|
||||
// Expose the plugin SDK before rendering so plugins loaded via <script>
|
||||
// can access React, components, etc. immediately.
|
||||
exposePluginSDK();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<BrowserRouter>
|
||||
<I18nProvider>
|
||||
<App />
|
||||
<ThemeProvider>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</BrowserRouter>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
|
||||
import { H2 } from "@nous-research/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import type { CronJob } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
|
|
@ -82,10 +83,16 @@ export default function CronPage() {
|
|||
const isPaused = job.state === "paused";
|
||||
if (isPaused) {
|
||||
await api.resumeCronJob(job.id);
|
||||
showToast(`${t.cron.resume}: "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
showToast(
|
||||
`${t.cron.resume}: "${job.name || job.prompt.slice(0, 30)}"`,
|
||||
"success",
|
||||
);
|
||||
} else {
|
||||
await api.pauseCronJob(job.id);
|
||||
showToast(`${t.cron.pause}: "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
showToast(
|
||||
`${t.cron.pause}: "${job.name || job.prompt.slice(0, 30)}"`,
|
||||
"success",
|
||||
);
|
||||
}
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
|
|
@ -96,7 +103,10 @@ export default function CronPage() {
|
|||
const handleTrigger = async (job: CronJob) => {
|
||||
try {
|
||||
await api.triggerCronJob(job.id);
|
||||
showToast(`${t.cron.triggerNow}: "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
showToast(
|
||||
`${t.cron.triggerNow}: "${job.name || job.prompt.slice(0, 30)}"`,
|
||||
"success",
|
||||
);
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
showToast(`${t.status.error}: ${e}`, "error");
|
||||
|
|
@ -106,7 +116,10 @@ export default function CronPage() {
|
|||
const handleDelete = async (job: CronJob) => {
|
||||
try {
|
||||
await api.deleteCronJob(job.id);
|
||||
showToast(`${t.common.delete}: "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
showToast(
|
||||
`${t.common.delete}: "${job.name || job.prompt.slice(0, 30)}"`,
|
||||
"success",
|
||||
);
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
showToast(`${t.status.error}: ${e}`, "error");
|
||||
|
|
@ -174,16 +187,30 @@ export default function CronPage() {
|
|||
value={deliver}
|
||||
onValueChange={(v) => setDeliver(v)}
|
||||
>
|
||||
<SelectOption value="local">{t.cron.delivery.local}</SelectOption>
|
||||
<SelectOption value="telegram">{t.cron.delivery.telegram}</SelectOption>
|
||||
<SelectOption value="discord">{t.cron.delivery.discord}</SelectOption>
|
||||
<SelectOption value="slack">{t.cron.delivery.slack}</SelectOption>
|
||||
<SelectOption value="email">{t.cron.delivery.email}</SelectOption>
|
||||
<SelectOption value="local">
|
||||
{t.cron.delivery.local}
|
||||
</SelectOption>
|
||||
<SelectOption value="telegram">
|
||||
{t.cron.delivery.telegram}
|
||||
</SelectOption>
|
||||
<SelectOption value="discord">
|
||||
{t.cron.delivery.discord}
|
||||
</SelectOption>
|
||||
<SelectOption value="slack">
|
||||
{t.cron.delivery.slack}
|
||||
</SelectOption>
|
||||
<SelectOption value="email">
|
||||
{t.cron.delivery.email}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Button onClick={handleCreate} disabled={creating} className="w-full">
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{creating ? t.common.creating : t.common.create}
|
||||
</Button>
|
||||
|
|
@ -195,10 +222,13 @@ export default function CronPage() {
|
|||
|
||||
{/* Jobs list */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<H2
|
||||
variant="sm"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
{t.cron.scheduledJobs} ({jobs.length})
|
||||
</h2>
|
||||
</H2>
|
||||
|
||||
{jobs.length === 0 && (
|
||||
<Card>
|
||||
|
|
@ -215,7 +245,9 @@ export default function CronPage() {
|
|||
<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 ? "..." : "")}
|
||||
{job.name ||
|
||||
job.prompt.slice(0, 60) +
|
||||
(job.prompt.length > 60 ? "..." : "")}
|
||||
</span>
|
||||
<Badge variant={STATUS_VARIANT[job.state] ?? "secondary"}>
|
||||
{job.state}
|
||||
|
|
@ -226,16 +258,23 @@ export default function CronPage() {
|
|||
</div>
|
||||
{job.name && (
|
||||
<p className="text-xs text-muted-foreground truncate mb-1">
|
||||
{job.prompt.slice(0, 100)}{job.prompt.length > 100 ? "..." : ""}
|
||||
{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_display}</span>
|
||||
<span>{t.cron.last}: {formatTime(job.last_run_at)}</span>
|
||||
<span>{t.cron.next}: {formatTime(job.next_run_at)}</span>
|
||||
<span>
|
||||
{t.cron.last}: {formatTime(job.last_run_at)}
|
||||
</span>
|
||||
<span>
|
||||
{t.cron.next}: {formatTime(job.next_run_at)}
|
||||
</span>
|
||||
</div>
|
||||
{job.last_error && (
|
||||
<p className="text-xs text-destructive mt-1">{job.last_error}</p>
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{job.last_error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -245,7 +284,9 @@ export default function CronPage() {
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
title={job.state === "paused" ? t.cron.resume : t.cron.pause}
|
||||
aria-label={job.state === "paused" ? t.cron.resume : t.cron.pause}
|
||||
aria-label={
|
||||
job.state === "paused" ? t.cron.resume : t.cron.pause
|
||||
}
|
||||
onClick={() => handlePauseResume(job)}
|
||||
>
|
||||
{job.state === "paused" ? (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { FileText, RefreshCw, ChevronRight } from "lucide-react";
|
||||
import { H2 } from "@nous-research/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -15,7 +16,12 @@ 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("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";
|
||||
|
|
@ -54,7 +60,9 @@ function SidebarItem<T extends string>({
|
|||
}`}
|
||||
>
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
{isActive && <ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />}
|
||||
{isActive && (
|
||||
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -62,7 +70,8 @@ function SidebarItem<T extends string>({
|
|||
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 [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[]>([]);
|
||||
|
|
@ -104,7 +113,7 @@ export default function LogsPage() {
|
|||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-base font-semibold">{t.logs.title}</h1>
|
||||
<H2 variant="sm">{t.logs.title}</H2>
|
||||
{loading && (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
)}
|
||||
|
|
@ -123,7 +132,12 @@ export default function LogsPage() {
|
|||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={fetchLogs} className="text-xs h-7">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchLogs}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
{t.common.refresh}
|
||||
</Button>
|
||||
|
|
@ -131,23 +145,44 @@ export default function LogsPage() {
|
|||
</div>
|
||||
|
||||
{/* ═══════════════ Sidebar + Content ═══════════════ */}
|
||||
<div className="flex flex-col sm:flex-row gap-4" style={{ minHeight: "calc(100vh - 180px)" }}>
|
||||
<div
|
||||
className="flex flex-col sm:flex-row gap-4"
|
||||
style={{ minHeight: "calc(100vh - 180px)" }}
|
||||
>
|
||||
{/* ---- Sidebar ---- */}
|
||||
<div className="sm:w-44 sm:shrink-0">
|
||||
<div className="sm:sticky sm:top-[72px] flex flex-col gap-0.5">
|
||||
<SidebarHeading>{t.logs.file}</SidebarHeading>
|
||||
{FILES.map((f) => (
|
||||
<SidebarItem key={f} label={f} value={f} current={file} onChange={setFile} />
|
||||
<SidebarItem
|
||||
key={f}
|
||||
label={f}
|
||||
value={f}
|
||||
current={file}
|
||||
onChange={setFile}
|
||||
/>
|
||||
))}
|
||||
|
||||
<SidebarHeading>{t.logs.level}</SidebarHeading>
|
||||
{LEVELS.map((l) => (
|
||||
<SidebarItem key={l} label={l} value={l} current={level} onChange={setLevel} />
|
||||
<SidebarItem
|
||||
key={l}
|
||||
label={l}
|
||||
value={l}
|
||||
current={level}
|
||||
onChange={setLevel}
|
||||
/>
|
||||
))}
|
||||
|
||||
<SidebarHeading>{t.logs.component}</SidebarHeading>
|
||||
{COMPONENTS.map((c) => (
|
||||
<SidebarItem key={c} label={c} value={c} current={component} onChange={setComponent} />
|
||||
<SidebarItem
|
||||
key={c}
|
||||
label={c}
|
||||
value={c}
|
||||
current={component}
|
||||
onChange={setComponent}
|
||||
/>
|
||||
))}
|
||||
|
||||
<SidebarHeading>{t.logs.lines}</SidebarHeading>
|
||||
|
|
@ -157,7 +192,9 @@ export default function LogsPage() {
|
|||
label={String(n)}
|
||||
value={String(n)}
|
||||
current={String(lineCount)}
|
||||
onChange={(v) => setLineCount(Number(v) as (typeof LINE_COUNTS)[number])}
|
||||
onChange={(v) =>
|
||||
setLineCount(Number(v) as (typeof LINE_COUNTS)[number])
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -184,12 +221,17 @@ export default function LogsPage() {
|
|||
className="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">{t.logs.noLogLines}</p>
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
{t.logs.noLogLines}
|
||||
</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`}>
|
||||
<div
|
||||
key={i}
|
||||
className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,8 +13,13 @@ import {
|
|||
Hash,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { H2 } from "@nous-research/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import type { SessionInfo, SessionMessage, SessionSearchResult } 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";
|
||||
|
|
@ -22,14 +27,15 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
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" },
|
||||
};
|
||||
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. */
|
||||
|
|
@ -46,7 +52,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
|
|||
parts.push(
|
||||
<mark key={i++} className="bg-warning/30 text-warning px-0.5">
|
||||
{match[1]}
|
||||
</mark>
|
||||
</mark>,
|
||||
);
|
||||
last = regex.lastIndex;
|
||||
}
|
||||
|
|
@ -60,7 +66,11 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name: string; arguments: string } } }) {
|
||||
function ToolCallBlock({
|
||||
toolCall,
|
||||
}: {
|
||||
toolCall: { id: string; function: { name: string; arguments: string } };
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
|
|
@ -79,8 +89,14 @@ function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name:
|
|||
onClick={() => setOpen(!open)}
|
||||
aria-label={`${open ? t.common.collapse : t.common.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>
|
||||
{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 && (
|
||||
|
|
@ -92,18 +108,45 @@ function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name:
|
|||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ msg, highlight }: { msg: SessionMessage; highlight?: string }) {
|
||||
function MessageBubble({
|
||||
msg,
|
||||
highlight,
|
||||
}: {
|
||||
msg: SessionMessage;
|
||||
highlight?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const ROLE_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||
user: { bg: "bg-primary/10", text: "text-primary", label: t.sessions.roles.user },
|
||||
assistant: { bg: "bg-success/10", text: "text-success", label: t.sessions.roles.assistant },
|
||||
system: { bg: "bg-muted", text: "text-muted-foreground", label: t.sessions.roles.system },
|
||||
tool: { bg: "bg-warning/10", text: "text-warning", label: t.sessions.roles.tool },
|
||||
const ROLE_STYLES: Record<
|
||||
string,
|
||||
{ bg: string; text: string; label: string }
|
||||
> = {
|
||||
user: {
|
||||
bg: "bg-primary/10",
|
||||
text: "text-primary",
|
||||
label: t.sessions.roles.user,
|
||||
},
|
||||
assistant: {
|
||||
bg: "bg-success/10",
|
||||
text: "text-success",
|
||||
label: t.sessions.roles.assistant,
|
||||
},
|
||||
system: {
|
||||
bg: "bg-muted",
|
||||
text: "text-muted-foreground",
|
||||
label: t.sessions.roles.system,
|
||||
},
|
||||
tool: {
|
||||
bg: "bg-warning/10",
|
||||
text: "text-warning",
|
||||
label: t.sessions.roles.tool,
|
||||
},
|
||||
};
|
||||
|
||||
const style = ROLE_STYLES[msg.role] ?? ROLE_STYLES.system;
|
||||
const label = msg.tool_name ? `${t.sessions.roles.tool}: ${msg.tool_name}` : style.label;
|
||||
const label = msg.tool_name
|
||||
? `${t.sessions.roles.tool}: ${msg.tool_name}`
|
||||
: style.label;
|
||||
|
||||
// Check if any search term appears as a prefix of any word in content
|
||||
const isHit = (() => {
|
||||
|
|
@ -114,26 +157,35 @@ function MessageBubble({ msg, highlight }: { msg: SessionMessage; highlight?: st
|
|||
})();
|
||||
|
||||
// Split search query into terms for inline highlighting
|
||||
const highlightTerms = isHit && highlight
|
||||
? highlight.split(/\s+/).filter(Boolean)
|
||||
: undefined;
|
||||
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={`${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">{t.common.match}</Badge>
|
||||
<Badge variant="warning" className="text-[9px] py-0 px-1.5">
|
||||
{t.common.match}
|
||||
</Badge>
|
||||
)}
|
||||
{msg.timestamp && (
|
||||
<span className="text-[10px] text-muted-foreground">{timeAgo(msg.timestamp)}</span>
|
||||
<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.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) => (
|
||||
|
|
@ -146,7 +198,13 @@ function MessageBubble({ msg, highlight }: { msg: SessionMessage; highlight?: st
|
|||
}
|
||||
|
||||
/** Message list with auto-scroll to first search hit. */
|
||||
function MessageList({ messages, highlight }: { messages: SessionMessage[]; highlight?: string }) {
|
||||
function MessageList({
|
||||
messages,
|
||||
highlight,
|
||||
}: {
|
||||
messages: SessionMessage[];
|
||||
highlight?: string;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -162,7 +220,10 @@ function MessageList({ messages, highlight }: { messages: SessionMessage[]; high
|
|||
}, [messages, highlight]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col gap-3 max-h-[600px] overflow-y-auto pr-2">
|
||||
<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} />
|
||||
))}
|
||||
|
|
@ -201,16 +262,20 @@ function SessionRow({
|
|||
}
|
||||
}, [isExpanded, session.id, messages, loading]);
|
||||
|
||||
const sourceInfo = (session.source ? SOURCE_CONFIG[session.source] : null) ?? { icon: Globe, color: "text-muted-foreground" };
|
||||
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={`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}
|
||||
|
|
@ -221,8 +286,14 @@ function SessionRow({
|
|||
</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) : t.sessions.untitledSession)}
|
||||
<span
|
||||
className={`text-sm truncate pr-2 ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
|
||||
>
|
||||
{hasTitle
|
||||
? session.title
|
||||
: session.preview
|
||||
? session.preview.slice(0, 60)
|
||||
: t.sessions.untitledSession}
|
||||
</span>
|
||||
{session.is_active && (
|
||||
<Badge variant="success" className="text-[10px] shrink-0">
|
||||
|
|
@ -232,21 +303,25 @@ function SessionRow({
|
|||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="truncate max-w-[120px] sm:max-w-[180px]">{(session.model ?? t.common.unknown).split("/").pop()}</span>
|
||||
<span className="truncate max-w-[120px] sm:max-w-[180px]">
|
||||
{(session.model ?? t.common.unknown).split("/").pop()}
|
||||
</span>
|
||||
<span className="text-border">·</span>
|
||||
<span>{session.message_count} {t.common.msgs}</span>
|
||||
<span>
|
||||
{session.message_count} {t.common.msgs}
|
||||
</span>
|
||||
{session.tool_call_count > 0 && (
|
||||
<>
|
||||
<span className="text-border">·</span>
|
||||
<span>{session.tool_call_count} {t.common.tools}</span>
|
||||
<span>
|
||||
{session.tool_call_count} {t.common.tools}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-border">·</span>
|
||||
<span>{timeAgo(session.last_active)}</span>
|
||||
</div>
|
||||
{snippet && (
|
||||
<SnippetHighlight snippet={snippet} />
|
||||
)}
|
||||
{snippet && <SnippetHighlight snippet={snippet} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -280,7 +355,9 @@ function SessionRow({
|
|||
<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">{t.sessions.noMessages}</p>
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
{t.sessions.noMessages}
|
||||
</p>
|
||||
)}
|
||||
{messages && messages.length > 0 && (
|
||||
<MessageList messages={messages} highlight={searchQuery} />
|
||||
|
|
@ -299,7 +376,9 @@ export default function SessionsPage() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [searchResults, setSearchResults] = useState<SessionSearchResult[] | null>(null);
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
SessionSearchResult[] | null
|
||||
>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const { t } = useI18n();
|
||||
|
|
@ -383,7 +462,7 @@ export default function SessionsPage() {
|
|||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-base font-semibold">{t.sessions.title}</h1>
|
||||
<H2 variant="sm">{t.sessions.title}</H2>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{total}
|
||||
</Badge>
|
||||
|
|
@ -419,7 +498,9 @@ export default function SessionsPage() {
|
|||
{search ? t.sessions.noMatch : t.sessions.noSessions}
|
||||
</p>
|
||||
{!search && (
|
||||
<p className="text-xs mt-1 text-muted-foreground/60">{t.sessions.startConversation}</p>
|
||||
<p className="text-xs mt-1 text-muted-foreground/60">
|
||||
{t.sessions.startConversation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -444,7 +525,8 @@ export default function SessionsPage() {
|
|||
{!searchResults && total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} {t.common.of} {total}
|
||||
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)}{" "}
|
||||
{t.common.of} {total}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
|
|
@ -458,7 +540,8 @@ export default function SessionsPage() {
|
|||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground px-2">
|
||||
{t.common.page} {page + 1} {t.common.of} {Math.ceil(total / PAGE_SIZE)}
|
||||
{t.common.page} {page + 1} {t.common.of}{" "}
|
||||
{Math.ceil(total / PAGE_SIZE)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
Code,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { H2 } from "@nous-research/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
|
|
@ -46,7 +47,10 @@ const CATEGORY_LABELS: Record<string, string> = {
|
|||
ui: "UI",
|
||||
};
|
||||
|
||||
function prettyCategory(raw: string | null | undefined, generalLabel: string): string {
|
||||
function prettyCategory(
|
||||
raw: string | null | undefined,
|
||||
generalLabel: string,
|
||||
): string {
|
||||
if (!raw) return generalLabel;
|
||||
if (CATEGORY_LABELS[raw]) return CATEGORY_LABELS[raw];
|
||||
return raw
|
||||
|
|
@ -55,7 +59,10 @@ function prettyCategory(raw: string | null | undefined, generalLabel: string): s
|
|||
.join(" ");
|
||||
}
|
||||
|
||||
const TOOLSET_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
const TOOLSET_ICONS: Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
> = {
|
||||
computer: Cpu,
|
||||
web: Globe,
|
||||
security: Shield,
|
||||
|
|
@ -67,7 +74,9 @@ const TOOLSET_ICONS: Record<string, React.ComponentType<{ className?: string }>>
|
|||
automation: Zap,
|
||||
};
|
||||
|
||||
function toolsetIcon(name: string): React.ComponentType<{ className?: string }> {
|
||||
function toolsetIcon(
|
||||
name: string,
|
||||
): React.ComponentType<{ className?: string }> {
|
||||
const lower = name.toLowerCase();
|
||||
for (const [key, icon] of Object.entries(TOOLSET_ICONS)) {
|
||||
if (lower.includes(key)) return icon;
|
||||
|
|
@ -107,12 +116,12 @@ export default function SkillsPage() {
|
|||
await api.toggleSkill(skill.name, !skill.enabled);
|
||||
setSkills((prev) =>
|
||||
prev.map((s) =>
|
||||
s.name === skill.name ? { ...s, enabled: !s.enabled } : s
|
||||
)
|
||||
s.name === skill.name ? { ...s, enabled: !s.enabled } : s,
|
||||
),
|
||||
);
|
||||
showToast(
|
||||
`${skill.name} ${skill.enabled ? t.common.disabled : t.common.enabled}`,
|
||||
"success"
|
||||
"success",
|
||||
);
|
||||
} catch {
|
||||
showToast(`${t.common.failedToToggle} ${skill.name}`, "error");
|
||||
|
|
@ -135,16 +144,19 @@ export default function SkillsPage() {
|
|||
(s) =>
|
||||
s.name.toLowerCase().includes(lowerSearch) ||
|
||||
s.description.toLowerCase().includes(lowerSearch) ||
|
||||
(s.category ?? "").toLowerCase().includes(lowerSearch)
|
||||
(s.category ?? "").toLowerCase().includes(lowerSearch),
|
||||
);
|
||||
}, [skills, isSearching, lowerSearch]);
|
||||
|
||||
const activeSkills = useMemo(() => {
|
||||
if (isSearching) return [];
|
||||
if (!activeCategory) return [...skills].sort((a, b) => a.name.localeCompare(b.name));
|
||||
if (!activeCategory)
|
||||
return [...skills].sort((a, b) => a.name.localeCompare(b.name));
|
||||
return skills
|
||||
.filter((s) =>
|
||||
activeCategory === "__none__" ? !s.category : s.category === activeCategory
|
||||
activeCategory === "__none__"
|
||||
? !s.category
|
||||
: s.category === activeCategory,
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [skills, activeCategory, isSearching]);
|
||||
|
|
@ -161,7 +173,11 @@ export default function SkillsPage() {
|
|||
if (b[0] === "__none__") return 1;
|
||||
return a[0].localeCompare(b[0]);
|
||||
})
|
||||
.map(([key, count]) => ({ key, name: prettyCategory(key === "__none__" ? null : key, t.common.general), count }));
|
||||
.map(([key, count]) => ({
|
||||
key,
|
||||
name: prettyCategory(key === "__none__" ? null : key, t.common.general),
|
||||
count,
|
||||
}));
|
||||
}, [skills, t]);
|
||||
|
||||
const enabledCount = skills.filter((s) => s.enabled).length;
|
||||
|
|
@ -172,7 +188,7 @@ export default function SkillsPage() {
|
|||
!search ||
|
||||
ts.name.toLowerCase().includes(lowerSearch) ||
|
||||
ts.label.toLowerCase().includes(lowerSearch) ||
|
||||
ts.description.toLowerCase().includes(lowerSearch)
|
||||
ts.description.toLowerCase().includes(lowerSearch),
|
||||
);
|
||||
}, [toolsets, search, lowerSearch]);
|
||||
|
||||
|
|
@ -193,15 +209,20 @@ export default function SkillsPage() {
|
|||
<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">{t.skills.title}</h1>
|
||||
<H2 variant="sm">{t.skills.title}</H2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t.skills.enabledOf.replace("{enabled}", String(enabledCount)).replace("{total}", String(skills.length))}
|
||||
{t.skills.enabledOf
|
||||
.replace("{enabled}", String(enabledCount))
|
||||
.replace("{total}", String(skills.length))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════ Sidebar + Content ═══════════════ */}
|
||||
<div className="flex flex-col sm:flex-row gap-4" style={{ minHeight: "calc(100vh - 180px)" }}>
|
||||
<div
|
||||
className="flex flex-col sm:flex-row gap-4"
|
||||
style={{ minHeight: "calc(100vh - 180px)" }}
|
||||
>
|
||||
{/* ---- Sidebar ---- */}
|
||||
<div className="sm:w-52 sm:shrink-0">
|
||||
<div className="sm:sticky sm:top-[72px] flex flex-col gap-1">
|
||||
|
|
@ -229,7 +250,11 @@ export default function SkillsPage() {
|
|||
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none pb-1 sm:pb-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setView("skills"); setActiveCategory(null); setSearch(""); }}
|
||||
onClick={() => {
|
||||
setView("skills");
|
||||
setActiveCategory(null);
|
||||
setSearch("");
|
||||
}}
|
||||
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
|
||||
view === "skills" && !isSearching
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
|
|
@ -237,35 +262,48 @@ export default function SkillsPage() {
|
|||
}`}
|
||||
>
|
||||
<Package className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">{t.skills.all} ({skills.length})</span>
|
||||
{view === "skills" && !isSearching && <ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />}
|
||||
<span className="flex-1 truncate">
|
||||
{t.skills.all} ({skills.length})
|
||||
</span>
|
||||
{view === "skills" && !isSearching && (
|
||||
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Skill categories (nested under All Skills) */}
|
||||
{view === "skills" && !isSearching && allCategories.map(({ key, name, count }) => {
|
||||
const isActive = activeCategory === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(activeCategory === key ? null : key)}
|
||||
className={`group flex items-center gap-2 px-2.5 py-1 pl-7 text-left text-[11px] transition-colors cursor-pointer ${
|
||||
isActive
|
||||
? "text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1 truncate">{name}</span>
|
||||
<span className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{view === "skills" &&
|
||||
!isSearching &&
|
||||
allCategories.map(({ key, name, count }) => {
|
||||
const isActive = activeCategory === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setActiveCategory(activeCategory === key ? null : key)
|
||||
}
|
||||
className={`group flex items-center gap-2 px-2.5 py-1 pl-7 text-left text-[11px] transition-colors cursor-pointer ${
|
||||
isActive
|
||||
? "text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1 truncate">{name}</span>
|
||||
<span
|
||||
className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setView("toolsets"); setSearch(""); }}
|
||||
onClick={() => {
|
||||
setView("toolsets");
|
||||
setSearch("");
|
||||
}}
|
||||
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
|
||||
view === "toolsets"
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
|
|
@ -273,8 +311,12 @@ export default function SkillsPage() {
|
|||
}`}
|
||||
>
|
||||
<Wrench className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">{t.skills.toolsets} ({toolsets.length})</span>
|
||||
{view === "toolsets" && <ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />}
|
||||
<span className="flex-1 truncate">
|
||||
{t.skills.toolsets} ({toolsets.length})
|
||||
</span>
|
||||
{view === "toolsets" && (
|
||||
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -292,7 +334,12 @@ export default function SkillsPage() {
|
|||
{t.skills.title}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{t.skills.resultCount.replace("{count}", String(searchMatchedSkills.length)).replace("{s}", searchMatchedSkills.length !== 1 ? "s" : "")}
|
||||
{t.skills.resultCount
|
||||
.replace("{count}", String(searchMatchedSkills.length))
|
||||
.replace(
|
||||
"{s}",
|
||||
searchMatchedSkills.length !== 1 ? "s" : "",
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
|
@ -324,18 +371,26 @@ export default function SkillsPage() {
|
|||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
{activeCategory
|
||||
? prettyCategory(activeCategory === "__none__" ? null : activeCategory, t.common.general)
|
||||
? prettyCategory(
|
||||
activeCategory === "__none__" ? null : activeCategory,
|
||||
t.common.general,
|
||||
)
|
||||
: t.skills.all}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{activeSkills.length} {t.skills.skillCount.replace("{count}", String(activeSkills.length)).replace("{s}", activeSkills.length !== 1 ? "s" : "")}
|
||||
{activeSkills.length}{" "}
|
||||
{t.skills.skillCount
|
||||
.replace("{count}", String(activeSkills.length))
|
||||
.replace("{s}", activeSkills.length !== 1 ? "s" : "")}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
{activeSkills.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
{skills.length === 0 ? t.skills.noSkills : t.skills.noSkillsMatch}
|
||||
{skills.length === 0
|
||||
? t.skills.noSkills
|
||||
: t.skills.noSkillsMatch}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
|
|
@ -365,7 +420,9 @@ export default function SkillsPage() {
|
|||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredToolsets.map((ts) => {
|
||||
const TsIcon = toolsetIcon(ts.name);
|
||||
const labelText = ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || ts.name;
|
||||
const labelText =
|
||||
ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() ||
|
||||
ts.name;
|
||||
|
||||
return (
|
||||
<Card key={ts.name} className="relative">
|
||||
|
|
@ -374,12 +431,16 @@ export default function SkillsPage() {
|
|||
<TsIcon className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm">{labelText}</span>
|
||||
<span className="font-medium text-sm">
|
||||
{labelText}
|
||||
</span>
|
||||
<Badge
|
||||
variant={ts.enabled ? "success" : "outline"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{ts.enabled ? t.common.active : t.common.inactive}
|
||||
{ts.enabled
|
||||
? t.common.active
|
||||
: t.common.inactive}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
|
|
@ -405,7 +466,12 @@ export default function SkillsPage() {
|
|||
)}
|
||||
{ts.tools.length === 0 && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{ts.enabled ? t.skills.toolsetLabel.replace("{name}", ts.name) : t.skills.disabledForCli}
|
||||
{ts.enabled
|
||||
? t.skills.toolsetLabel.replace(
|
||||
"{name}",
|
||||
ts.name,
|
||||
)
|
||||
: t.skills.disabledForCli}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Wifi,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { Cell, Grid } from "@nous-research/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import type { PlatformStatus, SessionInfo, StatusResponse } from "@/lib/api";
|
||||
import { timeAgo, isoTimeAgo } from "@/lib/utils";
|
||||
|
|
@ -23,8 +24,14 @@ export default function StatusPage() {
|
|||
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
api.getStatus().then(setStatus).catch(() => {});
|
||||
api.getSessions(50).then((resp) => setSessions(resp.sessions)).catch(() => {});
|
||||
api
|
||||
.getStatus()
|
||||
.then(setStatus)
|
||||
.catch(() => {});
|
||||
api
|
||||
.getSessions(50)
|
||||
.then((resp) => setSessions(resp.sessions))
|
||||
.catch(() => {});
|
||||
};
|
||||
load();
|
||||
const interval = setInterval(load, 5000);
|
||||
|
|
@ -39,13 +46,19 @@ export default function StatusPage() {
|
|||
);
|
||||
}
|
||||
|
||||
const PLATFORM_STATE_BADGE: Record<string, { variant: "success" | "warning" | "destructive"; label: string }> = {
|
||||
const PLATFORM_STATE_BADGE: Record<
|
||||
string,
|
||||
{ variant: "success" | "warning" | "destructive"; label: string }
|
||||
> = {
|
||||
connected: { variant: "success", label: t.status.connected },
|
||||
disconnected: { variant: "warning", label: t.status.disconnected },
|
||||
fatal: { variant: "destructive", label: t.status.error },
|
||||
};
|
||||
|
||||
const GATEWAY_STATE_DISPLAY: Record<string, { badge: "success" | "warning" | "destructive" | "outline"; label: string }> = {
|
||||
const GATEWAY_STATE_DISPLAY: Record<
|
||||
string,
|
||||
{ badge: "success" | "warning" | "destructive" | "outline"; label: string }
|
||||
> = {
|
||||
running: { badge: "success", label: t.status.running },
|
||||
starting: { badge: "warning", label: t.status.starting },
|
||||
startup_failed: { badge: "destructive", label: t.status.failed },
|
||||
|
|
@ -53,14 +66,19 @@ export default function StatusPage() {
|
|||
};
|
||||
|
||||
function gatewayValue(): string {
|
||||
if (status!.gateway_running && status!.gateway_pid) return `${t.status.pid} ${status!.gateway_pid}`;
|
||||
if (status!.gateway_running && status!.gateway_health_url)
|
||||
return status!.gateway_health_url;
|
||||
if (status!.gateway_running && status!.gateway_pid)
|
||||
return `${t.status.pid} ${status!.gateway_pid}`;
|
||||
if (status!.gateway_running) return t.status.runningRemote;
|
||||
if (status!.gateway_state === "startup_failed") return t.status.startFailed;
|
||||
return t.status.notRunning;
|
||||
}
|
||||
|
||||
function gatewayBadge() {
|
||||
const info = status!.gateway_state ? GATEWAY_STATE_DISPLAY[status!.gateway_state] : null;
|
||||
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: t.status.running }
|
||||
|
|
@ -87,9 +105,14 @@ export default function StatusPage() {
|
|||
{
|
||||
icon: Activity,
|
||||
label: t.status.activeSessions,
|
||||
value: status.active_sessions > 0 ? `${status.active_sessions} ${t.status.running.toLowerCase()}` : t.status.noneRunning,
|
||||
value:
|
||||
status.active_sessions > 0
|
||||
? `${status.active_sessions} ${t.status.running.toLowerCase()}`
|
||||
: t.status.noneRunning,
|
||||
badgeText: status.active_sessions > 0 ? t.common.live : t.common.off,
|
||||
badgeVariant: (status.active_sessions > 0 ? "success" : "outline") as "success" | "outline",
|
||||
badgeVariant: (status.active_sessions > 0 ? "success" : "outline") as
|
||||
| "success"
|
||||
| "outline",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
@ -105,9 +128,14 @@ export default function StatusPage() {
|
|||
detail: status.gateway_exit_reason ?? undefined,
|
||||
});
|
||||
}
|
||||
const failedPlatforms = platforms.filter(([, info]) => info.state === "fatal" || info.state === "disconnected");
|
||||
const failedPlatforms = platforms.filter(
|
||||
([, info]) => info.state === "fatal" || info.state === "disconnected",
|
||||
);
|
||||
for (const [name, info] of failedPlatforms) {
|
||||
const stateLabel = info.state === "fatal" ? t.status.platformError : t.status.platformDisconnected;
|
||||
const stateLabel =
|
||||
info.state === "fatal"
|
||||
? t.status.platformError
|
||||
: t.status.platformDisconnected;
|
||||
alerts.push({
|
||||
message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${stateLabel}`,
|
||||
detail: info.error_message ?? undefined,
|
||||
|
|
@ -116,7 +144,6 @@ export default function StatusPage() {
|
|||
|
||||
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">
|
||||
|
|
@ -124,9 +151,13 @@ export default function StatusPage() {
|
|||
<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>
|
||||
<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>
|
||||
<p className="text-xs text-destructive/70 mt-0.5">
|
||||
{alert.detail}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -135,32 +166,41 @@ export default function StatusPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Grid className="border-b lg:!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">
|
||||
<Cell
|
||||
key={label}
|
||||
className="flex min-w-0 flex-col gap-2 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-medium">{label}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
</div>
|
||||
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold font-display">{value}</div>
|
||||
<div
|
||||
className="truncate text-2xl font-bold font-mondwest"
|
||||
title={value}
|
||||
>
|
||||
{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>
|
||||
{badgeText && (
|
||||
<Badge variant={badgeVariant} className="self-start">
|
||||
{badgeVariant === "success" && (
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
{badgeText}
|
||||
</Badge>
|
||||
)}
|
||||
</Cell>
|
||||
))}
|
||||
</div>
|
||||
</Grid>
|
||||
|
||||
{platforms.length > 0 && (
|
||||
<PlatformsCard platforms={platforms} platformStateBadge={PLATFORM_STATE_BADGE} />
|
||||
<PlatformsCard
|
||||
platforms={platforms}
|
||||
platformStateBadge={PLATFORM_STATE_BADGE}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeSessions.length > 0 && (
|
||||
|
|
@ -168,7 +208,9 @@ export default function StatusPage() {
|
|||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-success" />
|
||||
<CardTitle className="text-base">{t.status.activeSessions}</CardTitle>
|
||||
<CardTitle className="text-base">
|
||||
{t.status.activeSessions}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
|
|
@ -180,7 +222,9 @@ export default function StatusPage() {
|
|||
>
|
||||
<div className="flex flex-col gap-1 min-w-0 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">{s.title ?? t.common.untitled}</span>
|
||||
<span className="font-medium text-sm truncate">
|
||||
{s.title ?? t.common.untitled}
|
||||
</span>
|
||||
|
||||
<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" />
|
||||
|
|
@ -189,7 +233,11 @@ export default function StatusPage() {
|
|||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
<span className="font-mono-ui">{(s.model ?? t.common.unknown).split("/").pop()}</span> · {s.message_count} {t.common.msgs} · {timeAgo(s.last_active)}
|
||||
<span className="font-mono-ui">
|
||||
{(s.model ?? t.common.unknown).split("/").pop()}
|
||||
</span>{" "}
|
||||
· {s.message_count} {t.common.msgs} ·{" "}
|
||||
{timeAgo(s.last_active)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -203,7 +251,9 @@ export default function StatusPage() {
|
|||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{t.status.recentSessions}</CardTitle>
|
||||
<CardTitle className="text-base">
|
||||
{t.status.recentSessions}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
|
|
@ -214,10 +264,16 @@ export default function StatusPage() {
|
|||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
|
||||
>
|
||||
<div className="flex flex-col gap-1 min-w-0 w-full">
|
||||
<span className="font-medium text-sm truncate">{s.title ?? t.common.untitled}</span>
|
||||
<span className="font-medium text-sm truncate">
|
||||
{s.title ?? t.common.untitled}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
<span className="font-mono-ui">{(s.model ?? t.common.unknown).split("/").pop()}</span> · {s.message_count} {t.common.msgs} · {timeAgo(s.last_active)}
|
||||
<span className="font-mono-ui">
|
||||
{(s.model ?? t.common.unknown).split("/").pop()}
|
||||
</span>{" "}
|
||||
· {s.message_count} {t.common.msgs} ·{" "}
|
||||
{timeAgo(s.last_active)}
|
||||
</span>
|
||||
|
||||
{s.preview && (
|
||||
|
|
@ -227,7 +283,10 @@ export default function StatusPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
<Badge variant="outline" className="text-[10px] shrink-0 self-start sm:self-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] shrink-0 self-start sm:self-center"
|
||||
>
|
||||
<Database className="mr-1 h-3 w-3" />
|
||||
{s.source ?? "local"}
|
||||
</Badge>
|
||||
|
|
@ -248,7 +307,9 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
|
|||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{t.status.connectedPlatforms}</CardTitle>
|
||||
<CardTitle className="text-base">
|
||||
{t.status.connectedPlatforms}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
|
|
@ -258,7 +319,12 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
|
|||
variant: "outline" as const,
|
||||
label: info.state,
|
||||
};
|
||||
const IconComponent = info.state === "connected" ? Wifi : info.state === "fatal" ? AlertTriangle : WifiOff;
|
||||
const IconComponent =
|
||||
info.state === "connected"
|
||||
? Wifi
|
||||
: info.state === "fatal"
|
||||
? AlertTriangle
|
||||
: WifiOff;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -266,19 +332,25 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
|
|||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 w-full">
|
||||
<IconComponent className={`h-4 w-4 shrink-0 ${
|
||||
info.state === "connected"
|
||||
? "text-success"
|
||||
: info.state === "fatal"
|
||||
? "text-destructive"
|
||||
: "text-warning"
|
||||
}`} />
|
||||
<IconComponent
|
||||
className={`h-4 w-4 shrink-0 ${
|
||||
info.state === "connected"
|
||||
? "text-success"
|
||||
: info.state === "fatal"
|
||||
? "text-destructive"
|
||||
: "text-warning"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-sm font-medium capitalize truncate">{name}</span>
|
||||
<span className="text-sm font-medium capitalize truncate">
|
||||
{name}
|
||||
</span>
|
||||
|
||||
{info.error_message && (
|
||||
<span className="text-xs text-destructive">{info.error_message}</span>
|
||||
<span className="text-xs text-destructive">
|
||||
{info.error_message}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{info.updated_at && (
|
||||
|
|
@ -289,7 +361,10 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Badge variant={display.variant} className="shrink-0 self-start sm:self-center">
|
||||
<Badge
|
||||
variant={display.variant}
|
||||
className="shrink-0 self-start sm:self-center"
|
||||
>
|
||||
{display.variant === "success" && (
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
)}
|
||||
|
|
@ -305,5 +380,8 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
|
|||
|
||||
interface PlatformsCardProps {
|
||||
platforms: [string, PlatformStatus][];
|
||||
platformStateBadge: Record<string, { variant: "success" | "warning" | "destructive"; label: string }>;
|
||||
platformStateBadge: Record<
|
||||
string,
|
||||
{ variant: "success" | "warning" | "destructive"; label: string }
|
||||
>;
|
||||
}
|
||||
|
|
|
|||
3
web/src/plugins/index.ts
Normal file
3
web/src/plugins/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { exposePluginSDK, getPluginComponent, onPluginRegistered, getRegisteredCount } from "./registry";
|
||||
export { usePlugins } from "./usePlugins";
|
||||
export type { PluginManifest, RegisteredPlugin } from "./types";
|
||||
129
web/src/plugins/registry.ts
Normal file
129
web/src/plugins/registry.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
/**
|
||||
* Dashboard Plugin SDK + Registry
|
||||
*
|
||||
* Exposes React, UI components, hooks, and utilities on the window so
|
||||
* that plugin bundles can use them without bundling their own copies.
|
||||
*
|
||||
* Plugins call window.__HERMES_PLUGINS__.register(name, Component)
|
||||
* to register their tab component.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useContext,
|
||||
createContext,
|
||||
} from "react";
|
||||
import { api, fetchJSON } from "@/lib/api";
|
||||
import { cn, timeAgo, isoTimeAgo } from "@/lib/utils";
|
||||
import { Card, CardHeader, CardTitle, CardContent } 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, SelectOption } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin registry — plugins call register() to add their component.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RegistryListener = () => void;
|
||||
|
||||
const _registered: Map<string, React.ComponentType> = new Map();
|
||||
const _listeners: Set<RegistryListener> = new Set();
|
||||
|
||||
function _notify() {
|
||||
for (const fn of _listeners) {
|
||||
try { fn(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** Register a plugin component. Called by plugin JS bundles. */
|
||||
function registerPlugin(name: string, component: React.ComponentType) {
|
||||
_registered.set(name, component);
|
||||
_notify();
|
||||
}
|
||||
|
||||
/** Get a registered component by plugin name. */
|
||||
export function getPluginComponent(name: string): React.ComponentType | undefined {
|
||||
return _registered.get(name);
|
||||
}
|
||||
|
||||
/** Subscribe to registry changes (returns unsubscribe fn). */
|
||||
export function onPluginRegistered(fn: RegistryListener): () => void {
|
||||
_listeners.add(fn);
|
||||
return () => _listeners.delete(fn);
|
||||
}
|
||||
|
||||
/** Get current count of registered plugins. */
|
||||
export function getRegisteredCount(): number {
|
||||
return _registered.size;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Expose SDK + registry on window
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__HERMES_PLUGIN_SDK__: unknown;
|
||||
__HERMES_PLUGINS__: {
|
||||
register: typeof registerPlugin;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function exposePluginSDK() {
|
||||
window.__HERMES_PLUGINS__ = {
|
||||
register: registerPlugin,
|
||||
};
|
||||
|
||||
window.__HERMES_PLUGIN_SDK__ = {
|
||||
// React core — plugins use these instead of importing react
|
||||
React,
|
||||
hooks: {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useContext,
|
||||
createContext,
|
||||
},
|
||||
|
||||
// Hermes API client
|
||||
api,
|
||||
// Raw fetchJSON for plugin-specific endpoints
|
||||
fetchJSON,
|
||||
|
||||
// UI components (shadcn/ui primitives)
|
||||
components: {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Badge,
|
||||
Button,
|
||||
Input,
|
||||
Label,
|
||||
Select,
|
||||
SelectOption,
|
||||
Separator,
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
},
|
||||
|
||||
// Utilities
|
||||
utils: { cn, timeAgo, isoTimeAgo },
|
||||
|
||||
// Hooks
|
||||
useI18n,
|
||||
};
|
||||
}
|
||||
22
web/src/plugins/types.ts
Normal file
22
web/src/plugins/types.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
/** Types for the dashboard plugin system. */
|
||||
|
||||
export interface PluginManifest {
|
||||
name: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
version: string;
|
||||
tab: {
|
||||
path: string;
|
||||
position: string; // "end", "after:<tab>", "before:<tab>"
|
||||
};
|
||||
entry: string;
|
||||
css?: string | null;
|
||||
has_api: boolean;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface RegisteredPlugin {
|
||||
manifest: PluginManifest;
|
||||
component: React.ComponentType;
|
||||
}
|
||||
90
web/src/plugins/usePlugins.ts
Normal file
90
web/src/plugins/usePlugins.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* usePlugins hook — discovers and loads dashboard plugins.
|
||||
*
|
||||
* 1. Fetches plugin manifests from GET /api/dashboard/plugins
|
||||
* 2. Injects CSS <link> tags for plugins that declare css
|
||||
* 3. Loads plugin JS bundles via <script> tags
|
||||
* 4. Waits for plugins to call register() and resolves them
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { PluginManifest, RegisteredPlugin } from "./types";
|
||||
import { getPluginComponent, onPluginRegistered } from "./registry";
|
||||
|
||||
export function usePlugins() {
|
||||
const [manifests, setManifests] = useState<PluginManifest[]>([]);
|
||||
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const loadedScripts = useRef<Set<string>>(new Set());
|
||||
|
||||
// Fetch manifests on mount.
|
||||
useEffect(() => {
|
||||
api
|
||||
.getPlugins()
|
||||
.then((list) => {
|
||||
setManifests(list);
|
||||
if (list.length === 0) setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Load plugin assets when manifests arrive.
|
||||
useEffect(() => {
|
||||
if (manifests.length === 0) return;
|
||||
|
||||
for (const manifest of manifests) {
|
||||
// Inject CSS if specified.
|
||||
if (manifest.css) {
|
||||
const cssUrl = `/dashboard-plugins/${manifest.name}/${manifest.css}`;
|
||||
if (!document.querySelector(`link[href="${cssUrl}"]`)) {
|
||||
const link = document.createElement("link");
|
||||
link.rel = "stylesheet";
|
||||
link.href = cssUrl;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
|
||||
// Load JS bundle.
|
||||
const jsUrl = `/dashboard-plugins/${manifest.name}/${manifest.entry}`;
|
||||
if (loadedScripts.current.has(jsUrl)) continue;
|
||||
loadedScripts.current.add(jsUrl);
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = jsUrl;
|
||||
script.async = true;
|
||||
script.onerror = () => {
|
||||
console.warn(`[plugins] Failed to load ${manifest.name} from ${jsUrl}`);
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
|
||||
// Give plugins a moment to load and register, then stop loading state.
|
||||
const timeout = setTimeout(() => setLoading(false), 2000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [manifests]);
|
||||
|
||||
// Listen for plugin registrations and resolve them against manifests.
|
||||
useEffect(() => {
|
||||
function resolvePlugins() {
|
||||
const resolved: RegisteredPlugin[] = [];
|
||||
for (const manifest of manifests) {
|
||||
const component = getPluginComponent(manifest.name);
|
||||
if (component) {
|
||||
resolved.push({ manifest, component });
|
||||
}
|
||||
}
|
||||
setPlugins(resolved);
|
||||
// If all plugins registered, stop loading early.
|
||||
if (resolved.length === manifests.length && manifests.length > 0) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
resolvePlugins();
|
||||
const unsub = onPluginRegistered(resolvePlugins);
|
||||
return unsub;
|
||||
}, [manifests]);
|
||||
|
||||
return { plugins, manifests, loading };
|
||||
}
|
||||
124
web/src/themes/context.tsx
Normal file
124
web/src/themes/context.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { BUILTIN_THEMES, defaultTheme } from "./presets";
|
||||
import type { DashboardTheme, ThemeLayer, ThemePalette } from "./types";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
/** LocalStorage key — pre-applied before the React tree mounts to avoid
|
||||
* a visible flash of the default palette on theme-overridden installs. */
|
||||
const STORAGE_KEY = "hermes-dashboard-theme";
|
||||
|
||||
/** Turn a ThemeLayer into the two CSS expressions the DS consumes:
|
||||
* `--<name>` (color-mix'd with alpha) and `--<name>-base` (opaque hex). */
|
||||
function layerVars(name: "background" | "midground" | "foreground", layer: ThemeLayer) {
|
||||
const pct = Math.round(layer.alpha * 100);
|
||||
return {
|
||||
[`--${name}`]: `color-mix(in srgb, ${layer.hex} ${pct}%, transparent)`,
|
||||
[`--${name}-base`]: layer.hex,
|
||||
[`--${name}-alpha`]: String(layer.alpha),
|
||||
};
|
||||
}
|
||||
|
||||
/** Write a theme's palette to `document.documentElement` as inline styles.
|
||||
* Inline styles beat the `:root { }` rule in index.css, so this cascades
|
||||
* into every shadcn-compat token defined over the DS triplet. */
|
||||
function applyPalette(palette: ThemePalette) {
|
||||
const root = document.documentElement;
|
||||
const vars = {
|
||||
...layerVars("background", palette.background),
|
||||
...layerVars("midground", palette.midground),
|
||||
...layerVars("foreground", palette.foreground),
|
||||
"--warm-glow": palette.warmGlow,
|
||||
"--noise-opacity-mul": String(palette.noiseOpacity),
|
||||
};
|
||||
for (const [k, v] of Object.entries(vars)) {
|
||||
root.style.setProperty(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [themeName, setThemeName] = useState<string>(() => {
|
||||
if (typeof window === "undefined") return "default";
|
||||
return window.localStorage.getItem(STORAGE_KEY) ?? "default";
|
||||
});
|
||||
const [availableThemes, setAvailableThemes] = useState<
|
||||
Array<{ description: string; label: string; name: string }>
|
||||
>(() =>
|
||||
Object.values(BUILTIN_THEMES).map((t) => ({
|
||||
name: t.name,
|
||||
label: t.label,
|
||||
description: t.description,
|
||||
})),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const t = BUILTIN_THEMES[themeName] ?? defaultTheme;
|
||||
applyPalette(t.palette);
|
||||
}, [themeName]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.getThemes()
|
||||
.then((resp) => {
|
||||
if (cancelled) return;
|
||||
if (resp.themes?.length) setAvailableThemes(resp.themes);
|
||||
if (resp.active && resp.active !== themeName) {
|
||||
setThemeName(resp.active);
|
||||
window.localStorage.setItem(STORAGE_KEY, resp.active);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setTheme = useCallback((name: string) => {
|
||||
const next = BUILTIN_THEMES[name] ? name : "default";
|
||||
setThemeName(next);
|
||||
window.localStorage.setItem(STORAGE_KEY, next);
|
||||
api.setTheme(next).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({
|
||||
theme: BUILTIN_THEMES[themeName] ?? defaultTheme,
|
||||
themeName,
|
||||
availableThemes,
|
||||
setTheme,
|
||||
}),
|
||||
[themeName, availableThemes, setTheme],
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue>({
|
||||
theme: defaultTheme,
|
||||
themeName: "default",
|
||||
availableThemes: Object.values(BUILTIN_THEMES).map((t) => ({
|
||||
name: t.name,
|
||||
label: t.label,
|
||||
description: t.description,
|
||||
})),
|
||||
setTheme: () => {},
|
||||
});
|
||||
|
||||
interface ThemeContextValue {
|
||||
availableThemes: Array<{ description: string; label: string; name: string }>;
|
||||
setTheme: (name: string) => void;
|
||||
theme: DashboardTheme;
|
||||
themeName: string;
|
||||
}
|
||||
3
web/src/themes/index.ts
Normal file
3
web/src/themes/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { ThemeProvider, useTheme } from "./context";
|
||||
export { BUILTIN_THEMES, defaultTheme } from "./presets";
|
||||
export type { DashboardTheme, ThemeLayer, ThemeListResponse, ThemePalette } from "./types";
|
||||
100
web/src/themes/presets.ts
Normal file
100
web/src/themes/presets.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import type { DashboardTheme } from "./types";
|
||||
|
||||
/**
|
||||
* Built-in dashboard themes.
|
||||
*
|
||||
* The `default` theme mirrors LENS_0 (canonical Hermes teal) exactly — the
|
||||
* same triplet `src/index.css` declares on `:root`. Applying it should be a
|
||||
* visual no-op; other themes override the triplet + warm-glow and let the DS
|
||||
* cascade handle every derived surface.
|
||||
*
|
||||
* Theme names must stay in sync with the backend's
|
||||
* `_BUILTIN_DASHBOARD_THEMES` list in `hermes_cli/web_server.py`.
|
||||
*/
|
||||
|
||||
export const defaultTheme: DashboardTheme = {
|
||||
name: "default",
|
||||
label: "Hermes Teal",
|
||||
description: "Classic dark teal — the canonical Hermes look",
|
||||
palette: {
|
||||
background: { hex: "#041c1c", alpha: 1 },
|
||||
midground: { hex: "#ffe6cb", alpha: 1 },
|
||||
foreground: { hex: "#ffffff", alpha: 0 },
|
||||
warmGlow: "rgba(255, 189, 56, 0.35)",
|
||||
noiseOpacity: 1,
|
||||
},
|
||||
};
|
||||
|
||||
export const midnightTheme: DashboardTheme = {
|
||||
name: "midnight",
|
||||
label: "Midnight",
|
||||
description: "Deep blue-violet with cool accents",
|
||||
palette: {
|
||||
background: { hex: "#0a0a1f", alpha: 1 },
|
||||
midground: { hex: "#d4c8ff", alpha: 1 },
|
||||
foreground: { hex: "#ffffff", alpha: 0 },
|
||||
warmGlow: "rgba(167, 139, 250, 0.32)",
|
||||
noiseOpacity: 0.8,
|
||||
},
|
||||
};
|
||||
|
||||
export const emberTheme: DashboardTheme = {
|
||||
name: "ember",
|
||||
label: "Ember",
|
||||
description: "Warm crimson and bronze — forge vibes",
|
||||
palette: {
|
||||
background: { hex: "#1a0a06", alpha: 1 },
|
||||
midground: { hex: "#ffd8b0", alpha: 1 },
|
||||
foreground: { hex: "#ffffff", alpha: 0 },
|
||||
warmGlow: "rgba(249, 115, 22, 0.38)",
|
||||
noiseOpacity: 1,
|
||||
},
|
||||
};
|
||||
|
||||
export const monoTheme: DashboardTheme = {
|
||||
name: "mono",
|
||||
label: "Mono",
|
||||
description: "Clean grayscale — minimal and focused",
|
||||
palette: {
|
||||
background: { hex: "#0e0e0e", alpha: 1 },
|
||||
midground: { hex: "#eaeaea", alpha: 1 },
|
||||
foreground: { hex: "#ffffff", alpha: 0 },
|
||||
warmGlow: "rgba(255, 255, 255, 0.1)",
|
||||
noiseOpacity: 0.6,
|
||||
},
|
||||
};
|
||||
|
||||
export const cyberpunkTheme: DashboardTheme = {
|
||||
name: "cyberpunk",
|
||||
label: "Cyberpunk",
|
||||
description: "Neon green on black — matrix terminal",
|
||||
palette: {
|
||||
background: { hex: "#040608", alpha: 1 },
|
||||
midground: { hex: "#9bffcf", alpha: 1 },
|
||||
foreground: { hex: "#ffffff", alpha: 0 },
|
||||
warmGlow: "rgba(0, 255, 136, 0.22)",
|
||||
noiseOpacity: 1.2,
|
||||
},
|
||||
};
|
||||
|
||||
export const roseTheme: DashboardTheme = {
|
||||
name: "rose",
|
||||
label: "Rosé",
|
||||
description: "Soft pink and warm ivory — easy on the eyes",
|
||||
palette: {
|
||||
background: { hex: "#1a0f15", alpha: 1 },
|
||||
midground: { hex: "#ffd4e1", alpha: 1 },
|
||||
foreground: { hex: "#ffffff", alpha: 0 },
|
||||
warmGlow: "rgba(249, 168, 212, 0.3)",
|
||||
noiseOpacity: 0.9,
|
||||
},
|
||||
};
|
||||
|
||||
export const BUILTIN_THEMES: Record<string, DashboardTheme> = {
|
||||
default: defaultTheme,
|
||||
midnight: midnightTheme,
|
||||
ember: emberTheme,
|
||||
mono: monoTheme,
|
||||
cyberpunk: cyberpunkTheme,
|
||||
rose: roseTheme,
|
||||
};
|
||||
44
web/src/themes/types.ts
Normal file
44
web/src/themes/types.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
/**
|
||||
* Dashboard theme model.
|
||||
*
|
||||
* Unlike the pre-DS implementation (which overrode 21 shadcn tokens directly),
|
||||
* themes are now expressed in the Nous DS's own 3-triplet vocabulary —
|
||||
* `background`, `midground`, `foreground` — plus a warm-glow tint for the
|
||||
* vignette in <Backdrop />. All downstream shadcn-compat tokens
|
||||
* (`--color-card`, `--color-muted-foreground`, `--color-border`, etc.) are
|
||||
* defined in `src/index.css` as `color-mix()` expressions over the triplets,
|
||||
* so overriding the triplets at runtime cascades to every surface.
|
||||
*/
|
||||
|
||||
/** A color layer: hex base + alpha (0–1). */
|
||||
export interface ThemeLayer {
|
||||
alpha: number;
|
||||
hex: string;
|
||||
}
|
||||
|
||||
export interface ThemePalette {
|
||||
/** Deepest canvas color (typically near-black). */
|
||||
background: ThemeLayer;
|
||||
/** Primary text + accent. Most UI chrome reads this. */
|
||||
midground: ThemeLayer;
|
||||
/** Top-layer highlight. In LENS_0 this is white @ alpha 0 — invisible by
|
||||
* default but still drives `--color-ring`-style accents. */
|
||||
foreground: ThemeLayer;
|
||||
/** Warm vignette color for <Backdrop />, as an rgba() string. */
|
||||
warmGlow: string;
|
||||
/** Scalar multiplier (0–1.2) on the noise overlay. Lower for softer themes
|
||||
* like Mono and Rosé, higher for grittier themes like Cyberpunk. */
|
||||
noiseOpacity: number;
|
||||
}
|
||||
|
||||
export interface DashboardTheme {
|
||||
description: string;
|
||||
label: string;
|
||||
name: string;
|
||||
palette: ThemePalette;
|
||||
}
|
||||
|
||||
export interface ThemeListResponse {
|
||||
active: string;
|
||||
themes: Array<{ description: string; label: string; name: string }>;
|
||||
}
|
||||
|
|
@ -1,10 +1,58 @@
|
|||
import { defineConfig } from "vite";
|
||||
import { defineConfig, type Plugin } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
|
||||
const BACKEND = process.env.HERMES_DASHBOARD_URL ?? "http://127.0.0.1:9119";
|
||||
|
||||
/**
|
||||
* In production the Python `hermes dashboard` server injects a one-shot
|
||||
* session token into `index.html` (see `hermes_cli/web_server.py`). The
|
||||
* Vite dev server serves its own `index.html`, so unless we forward that
|
||||
* token, every protected `/api/*` call 401s.
|
||||
*
|
||||
* This plugin fetches the running dashboard's `index.html` on each dev page
|
||||
* load, scrapes the `window.__HERMES_SESSION_TOKEN__` assignment, and
|
||||
* re-injects it into the dev HTML. No-op in production builds.
|
||||
*/
|
||||
function hermesDevToken(): Plugin {
|
||||
const TOKEN_RE = /window\.__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"/;
|
||||
|
||||
return {
|
||||
name: "hermes:dev-session-token",
|
||||
apply: "serve",
|
||||
async transformIndexHtml() {
|
||||
try {
|
||||
const res = await fetch(BACKEND, { headers: { accept: "text/html" } });
|
||||
const html = await res.text();
|
||||
const match = html.match(TOKEN_RE);
|
||||
if (!match) {
|
||||
console.warn(
|
||||
`[hermes] Could not find session token in ${BACKEND} — ` +
|
||||
`is \`hermes dashboard\` running? /api calls will 401.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
return [
|
||||
{
|
||||
tag: "script",
|
||||
injectTo: "head",
|
||||
children: `window.__HERMES_SESSION_TOKEN__="${match[1]}";`,
|
||||
},
|
||||
];
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[hermes] Dashboard at ${BACKEND} unreachable — ` +
|
||||
`start it with \`hermes dashboard\` or set HERMES_DASHBOARD_URL. ` +
|
||||
`(${(err as Error).message})`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
plugins: [react(), tailwindcss(), hermesDevToken()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
|
|
@ -16,7 +64,7 @@ export default defineConfig({
|
|||
},
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:9119",
|
||||
"/api": BACKEND,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue