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

This commit is contained in:
Brooklyn Nicholson
2026-05-02 13:38:49 -05:00
parent 5e4473df96
commit a66303eaef
124 changed files with 19499 additions and 426 deletions
-48
View File
@@ -1,48 +0,0 @@
# Hermes Agent — Web UI
Browser-based dashboard for managing Hermes Agent configuration, API keys, and monitoring active sessions.
## Stack
- **Vite** + **React 19** + **TypeScript**
- **Tailwind CSS v4** with custom dark theme
- **shadcn/ui**-style components (hand-rolled, no CLI dependency)
## Development
```bash
# Start the backend API server
cd ../
python -m hermes_cli.main web --no-open
# In another terminal, start the Vite dev server (with HMR + API proxy)
cd web/
npm run dev
```
The Vite dev server proxies `/api` requests to `http://127.0.0.1:9119` (the FastAPI backend).
## Build
```bash
npm run build
```
This outputs to `../hermes_cli/web_dist/`, which the FastAPI server serves as a static SPA. The built assets are included in the Python package via `pyproject.toml` package-data.
## Structure
```
src/
├── components/ui/ # Reusable UI primitives (Card, Badge, Button, Input, etc.)
├── lib/
│ ├── api.ts # API client — typed fetch wrappers for all backend endpoints
│ └── utils.ts # cn() helper for Tailwind class merging
├── pages/
│ ├── StatusPage # Agent status, active/recent sessions
│ ├── ConfigPage # Dynamic config editor (reads schema from backend)
│ └── EnvPage # API key management with save/clear
├── App.tsx # Main layout and navigation
├── main.tsx # React entry point
└── index.css # Tailwind imports and theme variables
```
-23
View File
@@ -1,23 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
-13
View File
@@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hermes Agent - Dashboard</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-5911
View File
File diff suppressed because it is too large Load Diff
-52
View File
@@ -1,52 +0,0 @@
{
"name": "web",
"private": true,
"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.10.0",
"@observablehq/plot": "^0.6.17",
"@react-three/fiber": "^9.6.0",
"@tailwindcss/vite": "^4.2.1",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"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",
"react-router-dom": "^7.14.1",
"tailwind-merge": "^3.5.0",
"tailwindcss": "^4.2.1",
"unicode-animations": "^1.0.3"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.2.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"three": "^0.180.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^7.3.1"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-809
View File
@@ -1,809 +0,0 @@
import {
useCallback,
useEffect,
useMemo,
useState,
type ComponentType,
type ReactNode,
} from "react";
import {
Routes,
Route,
NavLink,
Navigate,
useLocation,
useNavigate,
} from "react-router-dom";
import {
Activity,
BarChart3,
BookOpen,
Clock,
Code,
Cpu,
Database,
Download,
Eye,
FileText,
Globe,
Heart,
KeyRound,
Menu,
MessageSquare,
Package,
Puzzle,
RotateCw,
Settings,
Shield,
Sparkles,
Star,
Terminal,
Users,
Wrench,
X,
Zap,
} from "lucide-react";
import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { SelectionSwitcher } from "@nous-research/ui/ui/components/selection-switcher";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Typography } from "@/components/NouiTypography";
import { cn } from "@/lib/utils";
import { Backdrop } from "@/components/Backdrop";
import { SidebarFooter } from "@/components/SidebarFooter";
import { SidebarStatusStrip } from "@/components/SidebarStatusStrip";
import { PageHeaderProvider } from "@/contexts/PageHeaderProvider";
import { useSystemActions } from "@/contexts/useSystemActions";
import type { SystemAction } from "@/contexts/system-actions-context";
import ConfigPage from "@/pages/ConfigPage";
import DocsPage from "@/pages/DocsPage";
import EnvPage from "@/pages/EnvPage";
import SessionsPage from "@/pages/SessionsPage";
import LogsPage from "@/pages/LogsPage";
import AnalyticsPage from "@/pages/AnalyticsPage";
import ModelsPage from "@/pages/ModelsPage";
import CronPage from "@/pages/CronPage";
import ProfilesPage from "@/pages/ProfilesPage";
import SkillsPage from "@/pages/SkillsPage";
import PluginsPage from "@/pages/PluginsPage";
import ChatPage from "@/pages/ChatPage";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
import { useI18n } from "@/i18n";
import type { Translations } from "@/i18n/types";
import { PluginPage, PluginSlot, usePlugins } from "@/plugins";
import type { PluginManifest } from "@/plugins";
import { useTheme } from "@/themes";
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
function RootRedirect() {
return <Navigate to="/sessions" replace />;
}
const CHAT_NAV_ITEM: NavItem = {
path: "/chat",
labelKey: "chat",
label: "Chat",
icon: Terminal,
};
/**
* Built-in routes except /chat. Chat is rendered persistently (outside
* <Routes>) when embedded — see the persistent chat host block rendered
* inline near the bottom of this file — so the PTY child, WebSocket,
* and xterm instance survive when the user visits another tab and comes
* back. A `display:none` toggle hides the terminal without unmounting.
* Routing still owns the URL so /chat deep-links, browser back/forward,
* and nav highlight keep working.
*/
const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
"/": RootRedirect,
"/sessions": SessionsPage,
"/analytics": AnalyticsPage,
"/models": ModelsPage,
"/logs": LogsPage,
"/cron": CronPage,
"/skills": SkillsPage,
"/plugins": PluginsPage,
"/profiles": ProfilesPage,
"/config": ConfigPage,
"/env": EnvPage,
"/docs": DocsPage,
};
// Route placeholder for /chat. The persistent ChatPage host (rendered
// outside <Routes> when embedded chat is on) paints on top; this empty
// element just claims the path so the `*` catch-all redirect doesn't
// fire when the user navigates to /chat.
function ChatRouteSink() {
return null;
}
const BUILTIN_NAV_REST: NavItem[] = [
{
path: "/sessions",
labelKey: "sessions",
label: "Sessions",
icon: MessageSquare,
},
{
path: "/analytics",
labelKey: "analytics",
label: "Analytics",
icon: BarChart3,
},
{
path: "/models",
labelKey: "models",
label: "Models",
icon: Cpu,
},
{ 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: "/plugins", labelKey: "plugins", label: "Plugins", icon: Puzzle },
{ path: "/profiles", labelKey: "profiles", label: "Profiles", icon: Users },
{ path: "/config", labelKey: "config", label: "Config", icon: Settings },
{ path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound },
{
path: "/docs",
labelKey: "documentation",
label: "Documentation",
icon: BookOpen,
},
];
const ICON_MAP: Record<string, ComponentType<{ className?: string }>> = {
Activity,
BarChart3,
Clock,
Cpu,
FileText,
KeyRound,
MessageSquare,
Package,
Settings,
Puzzle,
Sparkles,
Terminal,
Globe,
Database,
Shield,
Users,
Wrench,
Zap,
Heart,
Star,
Code,
Eye,
};
function resolveIcon(name: string): ComponentType<{ className?: string }> {
return ICON_MAP[name] ?? Puzzle;
}
function buildNavItems(
builtIn: NavItem[],
manifests: PluginManifest[],
): NavItem[] {
const items = [...builtIn];
for (const manifest of manifests) {
if (manifest.tab.override) continue;
if (manifest.tab.hidden) continue;
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;
}
/** Split merged nav into built-in sidebar entries vs plugin tabs, preserving plugin order hints. */
function partitionSidebarNav(
builtIn: NavItem[],
manifests: PluginManifest[],
): { coreItems: NavItem[]; pluginItems: NavItem[] } {
const merged = buildNavItems(builtIn, manifests);
const builtinPaths = new Set(builtIn.map((i) => i.path));
const coreItems: NavItem[] = [];
const pluginItems: NavItem[] = [];
for (const item of merged) {
if (builtinPaths.has(item.path)) coreItems.push(item);
else pluginItems.push(item);
}
return { coreItems, pluginItems };
}
function buildRoutes(
builtinRoutes: Record<string, ComponentType>,
manifests: PluginManifest[],
): Array<{
key: string;
path: string;
element: ReactNode;
}> {
const byOverride = new Map<string, PluginManifest>();
const addons: PluginManifest[] = [];
for (const m of manifests) {
if (m.tab.override) {
byOverride.set(m.tab.override, m);
} else {
addons.push(m);
}
}
const routes: Array<{
key: string;
path: string;
element: ReactNode;
}> = [];
for (const [path, Component] of Object.entries(builtinRoutes)) {
const om = byOverride.get(path);
if (om) {
routes.push({
key: `override:${om.name}`,
path,
element: <PluginPage name={om.name} />,
});
} else {
routes.push({ key: `builtin:${path}`, path, element: <Component /> });
}
}
for (const m of addons) {
if (m.tab.hidden) continue;
if (m.tab.path === "/plugins") continue;
if (builtinRoutes[m.tab.path]) continue;
routes.push({
key: `plugin:${m.name}`,
path: m.tab.path,
element: <PluginPage name={m.name} />,
});
}
for (const m of manifests) {
if (!m.tab.hidden) continue;
if (m.tab.path === "/plugins") continue;
if (builtinRoutes[m.tab.path] || m.tab.override) continue;
routes.push({
key: `plugin:hidden:${m.name}`,
path: m.tab.path,
element: <PluginPage name={m.name} />,
});
}
return routes;
}
export default function App() {
const { t } = useI18n();
const { pathname } = useLocation();
const { manifests, loading: pluginsLoading } = usePlugins();
const { theme } = useTheme();
const [mobileOpen, setMobileOpen] = useState(false);
const closeMobile = useCallback(() => setMobileOpen(false), []);
const isDocsRoute = pathname === "/docs" || pathname === "/docs/";
const normalizedPath = pathname.replace(/\/$/, "") || "/";
const isChatRoute = normalizedPath === "/chat";
const embeddedChat = isDashboardEmbeddedChatEnabled();
// A plugin can replace the built-in /chat page via `tab.override: "/chat"`
// in its manifest. When one does, `buildRoutes` already swaps the route
// element for <PluginPage /> — but we also have to suppress the
// persistent ChatPage host below, or the plugin's page and the built-in
// terminal would paint on top of each other. The override is niche
// (nothing ships overriding /chat today) but it's an advertised
// extension point, so preserve the pre-persistence contract: when a
// plugin owns /chat, the built-in chat UI is entirely absent.
//
// Waiting on `pluginsLoading` is load-bearing: manifests arrive
// asynchronously from /api/dashboard/plugins, so on initial render
// `chatOverriddenByPlugin` is always false. Without the loading
// gate, the persistent host would mount, spawn a PTY, and THEN get
// yanked out from under the user when the plugin's manifest resolves
// — killing the session mid-paint. Delaying host mount by the
// plugin-load window (typically <50ms, worst case 2s safety timeout)
// is the cheaper trade-off.
const chatOverriddenByPlugin = useMemo(
() => manifests.some((m) => m.tab.override === "/chat"),
[manifests],
);
const builtinRoutes = useMemo(
() => ({
...BUILTIN_ROUTES_CORE,
...(embeddedChat ? { "/chat": ChatRouteSink } : {}),
}),
[embeddedChat],
);
const builtinNav = useMemo(
() =>
embeddedChat ? [CHAT_NAV_ITEM, ...BUILTIN_NAV_REST] : BUILTIN_NAV_REST,
[embeddedChat],
);
const sidebarNav = useMemo(
() => partitionSidebarNav(builtinNav, manifests),
[builtinNav, manifests],
);
const routes = useMemo(
() => buildRoutes(builtinRoutes, manifests),
[builtinRoutes, manifests],
);
const pluginTabMeta = useMemo(
() =>
manifests
.filter((m) => !m.tab.hidden)
.map((m) => ({
path: m.tab.override ?? m.tab.path,
label: m.label,
})),
[manifests],
);
const layoutVariant = theme.layoutVariant ?? "standard";
useEffect(() => {
if (!mobileOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setMobileOpen(false);
};
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [mobileOpen]);
useEffect(() => {
const mql = window.matchMedia("(min-width: 1024px)");
const onChange = (e: MediaQueryListEvent) => {
if (e.matches) setMobileOpen(false);
};
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
return (
<div
data-layout-variant={layoutVariant}
className="font-mondwest flex h-dvh max-h-dvh min-h-0 flex-col overflow-hidden bg-black uppercase text-midground antialiased"
>
<SelectionSwitcher />
<Backdrop />
<PluginSlot name="backdrop" />
<header
className={cn(
"lg:hidden fixed top-0 left-0 right-0 z-40 h-12",
"flex items-center gap-2 px-3",
"border-b border-current/20",
"bg-background-base/90 backdrop-blur-sm",
)}
style={{
background: "var(--component-header-background)",
borderImage: "var(--component-header-border-image)",
clipPath: "var(--component-header-clip-path)",
}}
>
<Button
ghost
size="icon"
onClick={() => setMobileOpen(true)}
aria-label={t.app.openNavigation}
aria-expanded={mobileOpen}
aria-controls="app-sidebar"
className="text-midground/70 hover:text-midground"
>
<Menu />
</Button>
<Typography
className="font-bold text-[0.95rem] leading-[0.95] tracking-[0.05em] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
{t.app.brand}
</Typography>
</header>
{mobileOpen && (
<Button
ghost
aria-label={t.app.closeNavigation}
onClick={closeMobile}
className={cn(
"lg:hidden fixed inset-0 z-40 p-0 block",
"bg-black/60 backdrop-blur-sm",
)}
/>
)}
<PluginSlot name="header-banner" />
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden pt-12 lg:pt-0">
<div className="flex min-h-0 min-w-0 flex-1">
<aside
id="app-sidebar"
aria-label={t.app.navigation}
className={cn(
"fixed top-0 left-0 z-50 flex h-dvh max-h-dvh w-64 min-h-0 flex-col",
"border-r border-current/20",
"bg-background-base/95 backdrop-blur-sm",
"transition-transform duration-200 ease-out",
mobileOpen ? "translate-x-0" : "-translate-x-full",
"lg:sticky lg:top-0 lg:translate-x-0 lg:shrink-0",
)}
style={{
background: "var(--component-sidebar-background)",
clipPath: "var(--component-sidebar-clip-path)",
borderImage: "var(--component-sidebar-border-image)",
}}
>
<div
className={cn(
"flex h-14 shrink-0 items-center justify-between gap-2",
"border-b border-current/20",
)}
>
<div className="flex items-center gap-2">
<PluginSlot name="header-left" />
<Typography
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
Hermes
<br />
Agent
</Typography>
</div>
<Button
ghost
size="icon"
onClick={closeMobile}
aria-label={t.app.closeNavigation}
className="lg:hidden text-midground/70 hover:text-midground"
>
<X />
</Button>
</div>
<nav
className="min-h-0 w-full flex-1 overflow-y-auto overflow-x-hidden border-t border-current/10 py-2"
aria-label={t.app.navigation}
>
<ul className="flex flex-col">
{sidebarNav.coreItems.map((item) => (
<SidebarNavLink
closeMobile={closeMobile}
item={item}
key={item.path}
t={t}
/>
))}
</ul>
{sidebarNav.pluginItems.length > 0 && (
<div
aria-labelledby="hermes-sidebar-plugin-nav-heading"
className="flex flex-col border-t border-current/10 pb-2"
role="group"
>
<span
className={cn(
"px-5 pt-2.5 pb-1",
"font-mondwest text-[0.6rem] tracking-[0.15em] uppercase opacity-30",
)}
id="hermes-sidebar-plugin-nav-heading"
>
{t.app.pluginNavSection}
</span>
<ul className="flex flex-col">
{sidebarNav.pluginItems.map((item) => (
<SidebarNavLink
closeMobile={closeMobile}
item={item}
key={item.path}
t={t}
/>
))}
</ul>
</div>
)}
</nav>
<SidebarSystemActions onNavigate={closeMobile} />
<div
className={cn(
"flex shrink-0 items-center justify-between gap-2",
"px-3 py-2",
"border-t border-current/20",
)}
>
<div className="flex min-w-0 items-center gap-2">
<PluginSlot name="header-right" />
<ThemeSwitcher dropUp />
<LanguageSwitcher />
</div>
</div>
<SidebarFooter />
</aside>
<PageHeaderProvider pluginTabs={pluginTabMeta}>
<div
className={cn(
"relative z-2 flex min-w-0 min-h-0 flex-1 flex-col",
"px-3 sm:px-6",
isChatRoute
? "pb-3 pt-1 sm:pb-4 sm:pt-2 lg:pt-4"
: "pt-2 sm:pt-4 lg:pt-6 pb-4 sm:pb-8",
isDocsRoute && "min-h-0 flex-1",
)}
>
<PluginSlot name="pre-main" />
<div
className={cn(
"w-full min-w-0",
(isDocsRoute || isChatRoute) &&
"min-h-0 flex flex-1 flex-col",
)}
>
<Routes>
{routes.map(({ key, path, element }) => (
<Route key={key} path={path} element={element} />
))}
<Route
path="*"
element={<Navigate to="/sessions" replace />}
/>
</Routes>
{embeddedChat &&
!chatOverriddenByPlugin &&
(pluginsLoading ? (
isChatRoute ? (
<div
className="flex min-h-0 min-w-0 flex-1 items-center justify-center"
aria-busy="true"
aria-live="polite"
>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner />
<span>Loading chat</span>
</div>
</div>
) : null
) : (
<div
data-chat-active={isChatRoute ? "true" : "false"}
className={cn(
"min-h-0 min-w-0",
isChatRoute ? "flex flex-1 flex-col" : "hidden",
)}
aria-hidden={!isChatRoute}
>
<ChatPage isActive={isChatRoute} />
</div>
))}
</div>
<PluginSlot name="post-main" />
</div>
</PageHeaderProvider>
</div>
</div>
<PluginSlot name="overlay" />
</div>
);
}
function SidebarNavLink({ closeMobile, item, t }: SidebarNavLinkProps) {
const { path, label, labelKey, icon: Icon } = item;
const navLabel = labelKey
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
: label;
return (
<li>
<NavLink
to={path}
end={path === "/sessions"}
onClick={closeMobile}
className={({ isActive }) =>
cn(
"group relative flex items-center gap-3",
"px-5 py-2.5",
"font-mondwest 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",
)
}
style={{
clipPath: "var(--component-tab-clip-path)",
}}
>
{({ isActive }) => (
<>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{navLabel}</span>
<span
aria-hidden
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
/>
{isActive && (
<span
aria-hidden
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }}
/>
)}
</>
)}
</NavLink>
</li>
);
}
function SidebarSystemActions({ onNavigate }: { onNavigate: () => void }) {
const { t } = useI18n();
const navigate = useNavigate();
const { activeAction, isBusy, isRunning, pendingAction, runAction } =
useSystemActions();
const items: SystemActionItem[] = [
{
action: "restart",
icon: RotateCw,
label: t.status.restartGateway,
runningLabel: t.status.restartingGateway,
spin: true,
},
{
action: "update",
icon: Download,
label: t.status.updateHermes,
runningLabel: t.status.updatingHermes,
spin: false,
},
];
const handleClick = (action: SystemAction) => {
if (isBusy) return;
void runAction(action);
navigate("/sessions");
onNavigate();
};
return (
<div
className={cn(
"shrink-0 flex flex-col",
"border-t border-current/10",
"py-1",
)}
>
<span
className={cn(
"px-5 pt-0.5 pb-0.5",
"font-mondwest text-[0.6rem] tracking-[0.15em] uppercase opacity-30",
)}
>
{t.app.system}
</span>
<SidebarStatusStrip />
<ul className="flex flex-col">
{items.map(({ action, icon: Icon, label, runningLabel, spin }) => {
const isPending = pendingAction === action;
const isActionRunning =
activeAction === action && isRunning && !isPending;
const busy = isPending || isActionRunning;
const displayLabel = isActionRunning ? runningLabel : label;
const disabled = isBusy && !busy;
return (
<li key={action}>
<ListItem
onClick={() => handleClick(action)}
disabled={disabled}
aria-busy={busy}
active={busy}
className={cn(
"gap-3 px-5 py-1.5 whitespace-nowrap",
"font-mondwest text-[0.75rem] tracking-[0.1em]",
"transition-opacity",
busy
? "text-midground opacity-100"
: "opacity-60 hover:opacity-100",
"disabled:opacity-30",
)}
>
{isPending ? (
<Spinner className="shrink-0 text-[0.875rem]" />
) : isActionRunning && spin ? (
<Spinner className="shrink-0 text-[0.875rem]" />
) : (
<Icon
className={cn(
"h-3.5 w-3.5 shrink-0",
isActionRunning && !spin && "animate-pulse",
)}
/>
)}
<span className="truncate">{displayLabel}</span>
<span
aria-hidden
className="absolute inset-y-0.5 left-1.5 right-1.5 bg-midground opacity-0 pointer-events-none transition-opacity duration-200 group-hover:opacity-5"
/>
{busy && (
<span
aria-hidden
className="absolute left-0 top-0 bottom-0 w-px bg-midground"
style={{ mixBlendMode: "plus-lighter" }}
/>
)}
</ListItem>
</li>
);
})}
</ul>
</div>
);
}
interface NavItem {
icon: ComponentType<{ className?: string }>;
label: string;
labelKey?: string;
path: string;
}
interface SidebarNavLinkProps {
closeMobile: () => void;
item: NavItem;
t: Translations;
}
interface SystemActionItem {
action: SystemAction;
icon: ComponentType<{ className?: string }>;
label: string;
runningLabel: string;
spin: boolean;
}
-151
View File
@@ -1,151 +0,0 @@
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Switch } from "@nous-research/ui/ui/components/switch";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
function FieldHint({ schema, schemaKey }: { schema: Record<string, unknown>; schemaKey: string }) {
const keyPath = schemaKey.includes(".") ? schemaKey : "";
const description = schema.description ? String(schema.description) : "";
if (!keyPath && !description) return null;
return (
<div className="flex flex-col gap-0.5">
{keyPath && <span className="text-[10px] font-mono text-muted-foreground/50">{keyPath}</span>}
{description && <span className="text-xs text-muted-foreground/70">{description}</span>}
</div>
);
}
export function AutoField({
schemaKey,
schema,
value,
onChange,
}: AutoFieldProps) {
const rawLabel = schemaKey.split(".").pop() ?? schemaKey;
const label = rawLabel.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
if (schema.type === "boolean") {
return (
<div className="flex items-center justify-between gap-4">
<div className="flex flex-col gap-0.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
</div>
<Switch checked={!!value} onCheckedChange={onChange} />
</div>
);
}
if (schema.type === "select") {
const options = (schema.options as string[]) ?? [];
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Select value={String(value ?? "")} onValueChange={(v) => onChange(v)}>
{options.map((opt) => (
<SelectOption key={opt} value={opt}>
{opt || "(none)"}
</SelectOption>
))}
</Select>
</div>
);
}
if (schema.type === "number") {
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Input
type="number"
value={value === undefined || value === null ? "" : String(value)}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
onChange(0);
return;
}
const n = Number(raw);
if (!Number.isNaN(n)) {
onChange(n);
}
}}
/>
</div>
);
}
if (schema.type === "text") {
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<textarea
className="flex min-h-[80px] w-full border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={String(value ?? "")}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
if (schema.type === "list") {
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Input
value={Array.isArray(value) ? value.join(", ") : String(value ?? "")}
onChange={(e) =>
onChange(
e.target.value
.split(",")
.map((s) => s.trim())
.filter(Boolean),
)
}
placeholder="comma-separated values"
/>
</div>
);
}
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
const obj = value as Record<string, unknown>;
return (
<div className="grid gap-3 border border-border p-3">
<Label className="text-xs font-medium">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
{Object.entries(obj).map(([subKey, subVal]) => (
<div key={subKey} className="grid gap-1">
<Label className="text-xs text-muted-foreground">{subKey}</Label>
<Input
value={String(subVal ?? "")}
onChange={(e) => onChange({ ...obj, [subKey]: e.target.value })}
className="text-xs"
/>
</div>
))}
</div>
);
}
return (
<div className="grid gap-1.5">
<Label className="text-sm">{label}</Label>
<FieldHint schema={schema} schemaKey={schemaKey} />
<Input value={String(value ?? "")} onChange={(e) => onChange(e.target.value)} />
</div>
);
}
interface AutoFieldProps {
schemaKey: string;
schema: Record<string, unknown>;
value: unknown;
onChange: (v: unknown) => void;
}
-91
View File
@@ -1,91 +0,0 @@
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={
{
// Themes can override the filler background by setting
// `assets.bg` — the <img> hides itself when a CSS bg is set
// so the two don't double-darken. CSS var fallbacks keep the
// default behaviour unchanged when no theme customises these.
mixBlendMode:
"var(--component-backdrop-filler-blend-mode, difference)",
opacity: "var(--component-backdrop-filler-opacity, 0.033)",
backgroundImage: "var(--theme-asset-bg)",
backgroundSize: "var(--component-backdrop-background-size, cover)",
backgroundPosition:
"var(--component-backdrop-background-position, center)",
} as unknown as React.CSSProperties
}
>
<img
alt=""
className="h-[150dvh] w-auto min-w-[100dvw] object-cover object-top-left invert theme-default-filler"
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))",
}}
/>
)}
</>
);
}
-384
View File
@@ -1,384 +0,0 @@
/**
* ChatSidebar — structured-events panel that sits next to the xterm.js
* terminal in the dashboard Chat tab.
*
* Two WebSockets, one per concern:
*
* 1. **JSON-RPC sidecar** (`GatewayClient` → /api/ws) — drives the
* sidebar's own slot of the dashboard's in-process gateway. Owns
* the model badge / picker / connection state / error banner.
* Independent of the PTY pane's session by design — those are the
* pieces the sidebar needs to be able to drive directly (model
* switch via slash.exec, etc.).
*
* 2. **Event subscriber** (/api/events?channel=…) — passive, receives
* every dispatcher emit from the PTY-side `tui_gateway.entry` that
* the dashboard fanned out. This is how `tool.start/progress/
* complete` from the agent loop reach the sidebar even though the
* PTY child runs three processes deep from us. The `channel` id
* ties this listener to the same chat tab's PTY child — see
* `ChatPage.tsx` for where the id is generated.
*
* Best-effort throughout: WS failures show in the badge / banner, the
* terminal pane keeps working unimpaired.
*/
import { Button } from "@nous-research/ui/ui/components/button";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Card } from "@/components/ui/card";
import { ModelPickerDialog } from "@/components/ModelPickerDialog";
import { ToolCall, type ToolEntry } from "@/components/ToolCall";
import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient";
import { cn } from "@/lib/utils";
import { AlertCircle, ChevronDown, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
interface SessionInfo {
cwd?: string;
model?: string;
provider?: string;
credential_warning?: string;
}
interface RpcEnvelope {
method?: string;
params?: { type?: string; payload?: unknown };
}
const TOOL_LIMIT = 20;
const STATE_LABEL: Record<ConnectionState, string> = {
idle: "idle",
connecting: "connecting",
open: "live",
closed: "closed",
error: "error",
};
const STATE_TONE: Record<
ConnectionState,
"secondary" | "warning" | "success" | "destructive"
> = {
idle: "secondary",
connecting: "warning",
open: "success",
closed: "secondary",
error: "destructive",
};
interface ChatSidebarProps {
channel: string;
className?: string;
}
export function ChatSidebar({ channel, className }: ChatSidebarProps) {
// `version` bumps on reconnect; gw is derived so we never call setState
// for it inside an effect (React 19's set-state-in-effect rule). The
// counter is the dependency on purpose — it's not read in the memo body,
// it's the signal that says "rebuild the client".
const [version, setVersion] = useState(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
const gw = useMemo(() => new GatewayClient(), [version]);
const [state, setState] = useState<ConnectionState>("idle");
const [sessionId, setSessionId] = useState<string | null>(null);
const [info, setInfo] = useState<SessionInfo>({});
const [tools, setTools] = useState<ToolEntry[]>([]);
const [modelOpen, setModelOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const offState = gw.onState(setState);
const offSessionInfo = gw.on<SessionInfo>("session.info", (ev) => {
if (ev.session_id) {
setSessionId(ev.session_id);
}
if (ev.payload) {
setInfo((prev) => ({ ...prev, ...ev.payload }));
}
});
const offError = gw.on<{ message?: string }>("error", (ev) => {
const message = ev.payload?.message;
if (message) {
setError(message);
}
});
// Adopt whichever session the gateway hands us. session.create on the
// sidecar is independent of the PTY pane's session by design — we
// only need a sid to drive the model picker's slash.exec calls.
gw.connect()
.then(() => {
if (cancelled) {
return;
}
return gw.request<{ session_id: string }>("session.create", {});
})
.then((created) => {
if (cancelled || !created?.session_id) {
return;
}
setSessionId(created.session_id);
})
.catch((e: Error) => {
if (!cancelled) {
setError(e.message);
}
});
return () => {
cancelled = true;
offState();
offSessionInfo();
offError();
gw.close();
};
}, [gw]);
// Event subscriber WebSocket — receives the rebroadcast of every
// dispatcher emit from the PTY child's gateway. See /api/pub +
// /api/events in hermes_cli/web_server.py for the broadcast hop.
//
// Failures (auth/loopback rejection, server too old to expose the
// endpoint, transient drops) surface in the same banner as the
// JSON-RPC sidecar so the sidebar matches its documented best-effort
// UX and the user always has a reconnect affordance.
useEffect(() => {
const token = window.__HERMES_SESSION_TOKEN__;
if (!token || !channel) {
return;
}
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const qs = new URLSearchParams({ token, channel });
const ws = new WebSocket(
`${proto}//${window.location.host}/api/events?${qs.toString()}`,
);
// `unmounting` suppresses the banner during cleanup — `ws.close()`
// from the effect's return fires a close event with code 1005 that
// would otherwise look like an unexpected drop.
const DISCONNECTED = "events feed disconnected — tool calls may not appear";
let unmounting = false;
const surface = (msg: string) => !unmounting && setError(msg);
ws.addEventListener("error", () => surface(DISCONNECTED));
ws.addEventListener("close", (ev) => {
if (ev.code === 4401 || ev.code === 4403) {
surface(`events feed rejected (${ev.code}) — reload the page`);
} else if (ev.code !== 1000) {
surface(DISCONNECTED);
}
});
ws.addEventListener("message", (ev) => {
let frame: RpcEnvelope;
try {
frame = JSON.parse(ev.data);
} catch {
return;
}
if (frame.method !== "event" || !frame.params) {
return;
}
const { type, payload } = frame.params;
if (type === "tool.start") {
const p = payload as
| { tool_id?: string; name?: string; context?: string }
| undefined;
const toolId = p?.tool_id;
if (!toolId) {
return;
}
setTools((prev) =>
[
...prev,
{
kind: "tool" as const,
id: `tool-${toolId}-${prev.length}`,
tool_id: toolId,
name: p?.name ?? "tool",
context: p?.context,
status: "running" as const,
startedAt: Date.now(),
},
].slice(-TOOL_LIMIT),
);
} else if (type === "tool.progress") {
const p = payload as
| { name?: string; preview?: string }
| undefined;
if (!p?.name || !p.preview) {
return;
}
setTools((prev) =>
prev.map((t) =>
t.status === "running" && t.name === p.name
? { ...t, preview: p.preview }
: t,
),
);
} else if (type === "tool.complete") {
const p = payload as
| {
tool_id?: string;
summary?: string;
error?: string;
inline_diff?: string;
}
| undefined;
if (!p?.tool_id) {
return;
}
setTools((prev) =>
prev.map((t) =>
t.tool_id === p.tool_id
? {
...t,
status: p.error ? "error" : "done",
summary: p.summary,
error: p.error,
inline_diff: p.inline_diff,
completedAt: Date.now(),
}
: t,
),
);
}
});
return () => {
unmounting = true;
ws.close();
};
}, [channel, version]);
const reconnect = useCallback(() => {
setError(null);
setTools([]);
setVersion((v) => v + 1);
}, []);
// Picker hands us a fully-formed slash command (e.g. "/model anthropic/...").
// Fire-and-forget through `slash.exec`; the TUI pane will render the result
// via PTY, so the sidebar doesn't need to surface output of its own.
const onModelSubmit = useCallback(
(slashCommand: string) => {
if (!sessionId) {
return;
}
void gw.request("slash.exec", {
session_id: sessionId,
command: slashCommand,
});
setModelOpen(false);
},
[gw, sessionId],
);
const canPickModel = state === "open" && !!sessionId;
const modelLabel = (info.model ?? "—").split("/").slice(-1)[0] ?? "—";
const banner = error ?? info.credential_warning ?? null;
return (
<aside
className={cn(
"flex h-full w-full min-w-0 shrink-0 flex-col gap-3 normal-case lg:w-80",
className,
)}
>
<Card className="flex items-center justify-between gap-2 px-3 py-2">
<div className="min-w-0">
<div className="text-xs uppercase tracking-wider text-muted-foreground">
model
</div>
<Button
ghost
size="sm"
disabled={!canPickModel}
onClick={() => setModelOpen(true)}
suffix={
canPickModel ? (
<ChevronDown className="opacity-60" />
) : undefined
}
className="self-start min-w-0 px-0 py-0 normal-case tracking-normal text-sm font-medium hover:underline disabled:no-underline"
title={info.model ?? "switch model"}
>
<span className="truncate">{modelLabel}</span>
</Button>
</div>
<Badge tone={STATE_TONE[state]}>{STATE_LABEL[state]}</Badge>
</Card>
{banner && (
<Card className="flex items-start gap-2 border-destructive/40 bg-destructive/5 px-3 py-2 text-xs">
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
<div className="min-w-0 flex-1">
<div className="wrap-break-word text-destructive">{banner}</div>
{error && (
<Button
size="sm"
outlined
className="mt-1"
onClick={reconnect}
prefix={<RefreshCw />}
>
reconnect
</Button>
)}
</div>
</Card>
)}
<Card className="flex min-h-0 flex-1 flex-col px-2 py-2">
<div className="px-1 pb-2 text-xs uppercase tracking-wider text-muted-foreground">
tools
</div>
<div className="flex min-h-0 flex-1 flex-col gap-1.5 overflow-y-auto pr-1">
{tools.length === 0 ? (
<div className="px-2 py-4 text-center text-xs text-muted-foreground">
no tool calls yet
</div>
) : (
tools.map((t) => <ToolCall key={t.id} tool={t} />)
)}
</div>
</Card>
{modelOpen && canPickModel && sessionId && (
<ModelPickerDialog
gw={gw}
sessionId={sessionId}
onClose={() => setModelOpen(false)}
onSubmit={onModelSubmit}
/>
)}
</aside>
);
}
@@ -1,40 +0,0 @@
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useI18n } from "@/i18n";
export function DeleteConfirmDialog({
cancelLabel,
confirmLabel,
description,
loading,
onCancel,
onConfirm,
open,
title,
}: DeleteConfirmDialogProps) {
const { t } = useI18n();
return (
<ConfirmDialog
open={open}
onCancel={onCancel}
onConfirm={onConfirm}
title={title}
description={description}
loading={loading}
destructive
confirmLabel={confirmLabel ?? t.common.delete}
cancelLabel={cancelLabel ?? t.common.cancel}
/>
);
}
interface DeleteConfirmDialogProps {
cancelLabel?: string;
confirmLabel?: string;
description?: string;
loading: boolean;
onCancel: () => void;
onConfirm: () => void;
open: boolean;
title: string;
}
-36
View File
@@ -1,36 +0,0 @@
import { Button } from "@nous-research/ui/ui/components/button";
import { Typography } from "@/components/NouiTypography";
import { useI18n } from "@/i18n/context";
/**
* Compact language toggle — shows a clickable flag that switches between
* English and Chinese. Persists choice to localStorage.
*/
export function LanguageSwitcher() {
const { locale, setLocale, t } = useI18n();
const toggle = () => setLocale(locale === "en" ? "zh" : "en");
return (
<Button
ghost
onClick={toggle}
title={t.language.switchTo}
aria-label={t.language.switchTo}
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-muted-foreground hover:text-foreground"
>
<span className="inline-flex items-center gap-1.5">
<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>
</span>
</Button>
);
}
-369
View File
@@ -1,369 +0,0 @@
import { useMemo, type ReactNode } from "react";
/**
* Lightweight markdown renderer for LLM output.
* Handles: code blocks, inline code, bold, italic, headers, links, lists, horizontal rules.
* NOT a full CommonMark parser — optimized for typical assistant message patterns.
*
* `streaming` renders a blinking caret at the tail of the last block so it
* appears to hug the final character instead of wrapping onto a new line
* after a block element (paragraph/list/code/…).
*/
export function Markdown({
content,
highlightTerms,
streaming,
}: {
content: string;
highlightTerms?: string[];
streaming?: boolean;
}) {
const blocks = useMemo(() => parseBlocks(content), [content]);
const caret = streaming ? <StreamingCaret /> : null;
return (
<div className="text-sm text-foreground leading-relaxed space-y-2">
{blocks.map((block, i) => (
<Block
key={i}
block={block}
highlightTerms={highlightTerms}
caret={caret && i === blocks.length - 1 ? caret : null}
/>
))}
{blocks.length === 0 && caret}
</div>
);
}
function StreamingCaret() {
return (
<span
aria-hidden
className="inline-block w-[0.5em] h-[1em] ml-0.5 align-[-0.15em] bg-foreground/50 animate-pulse"
/>
);
}
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type BlockNode =
| { type: "code"; lang: string; content: string }
| { type: "heading"; level: number; content: string }
| { type: "hr" }
| { type: "list"; ordered: boolean; items: string[] }
| { type: "paragraph"; content: string };
/* ------------------------------------------------------------------ */
/* Block parser */
/* ------------------------------------------------------------------ */
function parseBlocks(text: string): BlockNode[] {
const lines = text.split("\n");
const blocks: BlockNode[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Fenced code block
const fenceMatch = line.match(/^```(\w*)/);
if (fenceMatch) {
const lang = fenceMatch[1] || "";
const codeLines: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("```")) {
codeLines.push(lines[i]);
i++;
}
i++; // skip closing ```
blocks.push({ type: "code", lang, content: codeLines.join("\n") });
continue;
}
// Heading
const headingMatch = line.match(/^(#{1,4})\s+(.+)/);
if (headingMatch) {
blocks.push({
type: "heading",
level: headingMatch[1].length,
content: headingMatch[2],
});
i++;
continue;
}
// Horizontal rule
if (/^[-*_]{3,}\s*$/.test(line)) {
blocks.push({ type: "hr" });
i++;
continue;
}
// Unordered list
if (/^[-*+]\s/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^[-*+]\s/.test(lines[i])) {
items.push(lines[i].replace(/^[-*+]\s/, ""));
i++;
}
blocks.push({ type: "list", ordered: false, items });
continue;
}
// Ordered list
if (/^\d+[.)]\s/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^\d+[.)]\s/.test(lines[i])) {
items.push(lines[i].replace(/^\d+[.)]\s/, ""));
i++;
}
blocks.push({ type: "list", ordered: true, items });
continue;
}
// Empty line
if (line.trim() === "") {
i++;
continue;
}
// Paragraph — collect consecutive non-empty, non-special lines
const paraLines: string[] = [];
while (
i < lines.length &&
lines[i].trim() !== "" &&
!lines[i].match(/^```/) &&
!lines[i].match(/^#{1,4}\s/) &&
!lines[i].match(/^[-*+]\s/) &&
!lines[i].match(/^\d+[.)]\s/) &&
!lines[i].match(/^[-*_]{3,}\s*$/)
) {
paraLines.push(lines[i]);
i++;
}
if (paraLines.length > 0) {
blocks.push({ type: "paragraph", content: paraLines.join("\n") });
}
}
return blocks;
}
/* ------------------------------------------------------------------ */
/* Block renderer */
/* ------------------------------------------------------------------ */
function Block({
block,
highlightTerms,
caret,
}: {
block: BlockNode;
highlightTerms?: string[];
caret?: ReactNode;
}) {
switch (block.type) {
case "code":
return (
<pre className="bg-secondary/60 border border-border px-3 py-2.5 text-xs font-mono leading-relaxed overflow-x-auto">
<code>
{block.content}
{caret}
</code>
</pre>
);
case "heading": {
const Tag = `h${Math.min(block.level, 4)}` as "h1" | "h2" | "h3" | "h4";
const sizes: Record<string, string> = {
h1: "text-base font-bold",
h2: "text-sm font-bold",
h3: "text-sm font-semibold",
h4: "text-sm font-medium",
};
return (
<Tag className={sizes[Tag]}>
<InlineContent text={block.content} highlightTerms={highlightTerms} />
{caret}
</Tag>
);
}
case "hr":
return (
<>
<hr className="border-border" />
{caret}
</>
);
case "list": {
const Tag = block.ordered ? "ol" : "ul";
const last = block.items.length - 1;
return (
<Tag
className={`space-y-0.5 ${block.ordered ? "list-decimal" : "list-disc"} pl-5 text-sm`}
>
{block.items.map((item, i) => (
<li key={i}>
<InlineContent text={item} highlightTerms={highlightTerms} />
{i === last ? caret : null}
</li>
))}
</Tag>
);
}
case "paragraph":
return (
<p>
<InlineContent text={block.content} highlightTerms={highlightTerms} />
{caret}
</p>
);
}
}
/* ------------------------------------------------------------------ */
/* Inline parser + renderer */
/* ------------------------------------------------------------------ */
type InlineNode =
| { type: "text"; content: string }
| { type: "code"; content: string }
| { type: "bold"; content: string }
| { type: "italic"; content: string }
| { type: "link"; text: string; href: string }
| { type: "br" };
function parseInline(text: string): InlineNode[] {
const nodes: InlineNode[] = [];
// Pattern priority: code > link > bold > italic > bare URL > line break
const pattern =
/(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(\bhttps?:\/\/[^\s<>)\]]+)|(\n)/g;
let lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = pattern.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push({ type: "text", content: text.slice(lastIndex, match.index) });
}
if (match[1]) {
// Inline code
nodes.push({ type: "code", content: match[1].slice(1, -1) });
} else if (match[2]) {
// [text](url) link
nodes.push({ type: "link", text: match[3], href: match[4] });
} else if (match[5]) {
// **bold**
nodes.push({ type: "bold", content: match[6] });
} else if (match[7]) {
// *italic*
nodes.push({ type: "italic", content: match[8] });
} else if (match[9]) {
// Bare URL
nodes.push({ type: "link", text: match[9], href: match[9] });
} else if (match[10]) {
// Line break within paragraph
nodes.push({ type: "br" });
}
lastIndex = match.index + match[0].length;
}
if (lastIndex < text.length) {
nodes.push({ type: "text", content: text.slice(lastIndex) });
}
return nodes;
}
function InlineContent({
text,
highlightTerms,
}: {
text: string;
highlightTerms?: string[];
}) {
const nodes = useMemo(() => parseInline(text), [text]);
return (
<>
{nodes.map((node, i) => {
switch (node.type) {
case "text":
return (
<HighlightedText
key={i}
text={node.content}
terms={highlightTerms}
/>
);
case "code":
return (
<code
key={i}
className="bg-secondary/60 px-1.5 py-0.5 text-xs font-mono text-primary/90"
>
{node.content}
</code>
);
case "bold":
return (
<strong key={i} className="font-semibold">
<HighlightedText text={node.content} terms={highlightTerms} />
</strong>
);
case "italic":
return (
<em key={i}>
<HighlightedText text={node.content} terms={highlightTerms} />
</em>
);
case "link":
return (
<a
key={i}
href={node.href}
target="_blank"
rel="noreferrer"
className="text-primary underline underline-offset-2 decoration-primary/30 hover:decoration-primary/60 transition-colors"
>
{node.text}
</a>
);
case "br":
return <br key={i} />;
}
})}
</>
);
}
/** Highlight search terms within a plain text string. */
function HighlightedText({ text, terms }: { text: string; terms?: string[] }) {
if (!terms || terms.length === 0) return <>{text}</>;
// Build a regex that matches any of the search terms (case-insensitive)
const escaped = terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
const regex = new RegExp(`(${escaped.join("|")})`, "gi");
const parts = text.split(regex);
return (
<>
{parts.map((part, i) =>
regex.test(part) ? (
<mark key={i} className="bg-warning/30 text-warning px-0.5">
{part}
</mark>
) : (
<span key={i}>{part}</span>
),
)}
</>
);
}
-112
View File
@@ -1,112 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { Brain, Eye, Gauge, Lightbulb, Wrench } from "lucide-react";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { api } from "@/lib/api";
import type { ModelInfoResponse } from "@/lib/api";
import { formatTokenCount } from "@/lib/format";
interface ModelInfoCardProps {
/** Current model string from config state — used to detect changes */
currentModel: string;
/** Bumped after config saves to trigger re-fetch */
refreshKey?: number;
}
export function ModelInfoCard({
currentModel,
refreshKey = 0,
}: ModelInfoCardProps) {
const [info, setInfo] = useState<ModelInfoResponse | null>(null);
const [loading, setLoading] = useState(false);
const lastFetchKeyRef = useRef("");
useEffect(() => {
if (!currentModel) return;
// Re-fetch when model changes OR when refreshKey bumps (after save)
const fetchKey = `${currentModel}:${refreshKey}`;
if (fetchKey === lastFetchKeyRef.current) return;
lastFetchKeyRef.current = fetchKey;
setLoading(true);
api
.getModelInfo()
.then(setInfo)
.catch(() => setInfo(null))
.finally(() => setLoading(false));
}, [currentModel, refreshKey]);
if (loading) {
return (
<div className="flex items-center gap-2 py-2 text-xs text-muted-foreground">
<Spinner className="text-xs" />
Loading model info
</div>
);
}
if (!info || !info.model || info.effective_context_length <= 0) return null;
const caps = info.capabilities;
const hasCaps = caps && Object.keys(caps).length > 0;
return (
<div className="border border-border/60 bg-muted/30 px-3 py-2.5 space-y-2">
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Gauge className="h-3.5 w-3.5" />
<span className="font-medium">Context Window</span>
</div>
<div className="flex items-center gap-2">
<span className="font-mono font-semibold text-foreground">
{formatTokenCount(info.effective_context_length)}
</span>
{info.config_context_length > 0 ? (
<span className="text-amber-500/80 text-[10px]">
(override auto: {formatTokenCount(info.auto_context_length)})
</span>
) : (
<span className="text-muted-foreground/60 text-[10px]">
auto-detected
</span>
)}
</div>
</div>
{hasCaps && caps.max_output_tokens && caps.max_output_tokens > 0 && (
<div className="flex items-center gap-4 text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<Lightbulb className="h-3.5 w-3.5" />
<span className="font-medium">Max Output</span>
</div>
<span className="font-mono font-semibold text-foreground">
{formatTokenCount(caps.max_output_tokens)}
</span>
</div>
)}
{hasCaps && (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{caps.supports_tools && (
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
<Wrench className="h-2.5 w-2.5" /> Tools
</span>
)}
{caps.supports_vision && (
<span className="inline-flex items-center gap-1 bg-blue-500/10 px-2 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<Eye className="h-2.5 w-2.5" /> Vision
</span>
)}
{caps.supports_reasoning && (
<span className="inline-flex items-center gap-1 bg-purple-500/10 px-2 py-0.5 text-[10px] font-medium text-purple-600 dark:text-purple-400">
<Brain className="h-2.5 w-2.5" /> Reasoning
</span>
)}
{caps.model_family && (
<span className="inline-flex items-center gap-1 bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{caps.model_family}
</span>
)}
</div>
)}
</div>
);
}
-451
View File
@@ -1,451 +0,0 @@
import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Input } from "@/components/ui/input";
import type { GatewayClient } from "@/lib/gatewayClient";
import { Check, Search, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
/**
* Two-stage model picker modal.
*
* Mirrors ui-tui/src/components/modelPicker.tsx:
* Stage 1: pick provider (authenticated providers only)
* Stage 2: pick model within that provider
*
* Two invocation modes:
*
* 1. Chat-session mode (ChatSidebar) — pass `gw` + `sessionId`. The picker
* loads options via `model.options` JSON-RPC and emits the result as a
* slash command string (`/model <model> --provider <slug> [--global]`)
* through `onSubmit`, which the ChatPage pipes to `slashExec`.
*
* 2. Standalone mode (ModelsPage, Config settings) — pass a `loader` and
* `onApply`. The picker fetches options via the REST endpoint and calls
* `onApply(provider, model, persistGlobal)` instead of emitting a slash
* command. This lets the Models page reuse the same UI without
* requiring an open chat PTY.
*/
interface ModelOptionProvider {
name: string;
slug: string;
models?: string[];
total_models?: number;
is_current?: boolean;
warning?: string;
}
interface ModelOptionsResponse {
model?: string;
provider?: string;
providers?: ModelOptionProvider[];
}
interface Props {
/** Chat-mode: when present, picker emits a slash command via onSubmit. */
gw?: GatewayClient;
sessionId?: string;
onSubmit?(slashCommand: string): void;
/** Standalone-mode: when present (and onSubmit absent), picker calls onApply. */
loader?(): Promise<ModelOptionsResponse>;
onApply?(args: {
provider: string;
model: string;
persistGlobal: boolean;
}): Promise<void> | void;
onClose(): void;
title?: string;
/** If true, hides "Persist globally" checkbox — always saves to config.yaml. */
alwaysGlobal?: boolean;
}
export function ModelPickerDialog(props: Props) {
const {
gw,
sessionId,
onSubmit,
loader,
onApply,
onClose,
title = "Switch Model",
alwaysGlobal = false,
} = props;
const standalone = !!loader && !!onApply;
const [providers, setProviders] = useState<ModelOptionProvider[]>([]);
const [currentModel, setCurrentModel] = useState("");
const [currentProviderSlug, setCurrentProviderSlug] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedSlug, setSelectedSlug] = useState("");
const [selectedModel, setSelectedModel] = useState("");
const [query, setQuery] = useState("");
const [persistGlobal, setPersistGlobal] = useState(alwaysGlobal);
const [applying, setApplying] = useState(false);
const closedRef = useRef(false);
// Load providers + models on open.
useEffect(() => {
closedRef.current = false;
const promise = standalone
? (loader as () => Promise<ModelOptionsResponse>)()
: (gw as GatewayClient).request<ModelOptionsResponse>(
"model.options",
sessionId ? { session_id: sessionId } : {},
);
promise
.then((r) => {
if (closedRef.current) return;
const next = r?.providers ?? [];
setProviders(next);
setCurrentModel(String(r?.model ?? ""));
setCurrentProviderSlug(String(r?.provider ?? ""));
setSelectedSlug(
(next.find((p) => p.is_current) ?? next[0])?.slug ?? "",
);
setSelectedModel("");
setLoading(false);
})
.catch((e) => {
if (closedRef.current) return;
setError(e instanceof Error ? e.message : String(e));
setLoading(false);
});
return () => {
closedRef.current = true;
};
// Deliberately omit props from deps — stable for the dialog's lifetime.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Esc closes.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
const selectedProvider = useMemo(
() => providers.find((p) => p.slug === selectedSlug) ?? null,
[providers, selectedSlug],
);
const models = useMemo(
() => selectedProvider?.models ?? [],
[selectedProvider],
);
const needle = query.trim().toLowerCase();
const filteredProviders = useMemo(
() =>
!needle
? providers
: providers.filter(
(p) =>
p.name.toLowerCase().includes(needle) ||
p.slug.toLowerCase().includes(needle) ||
(p.models ?? []).some((m) => m.toLowerCase().includes(needle)),
),
[providers, needle],
);
const filteredModels = useMemo(
() =>
!needle ? models : models.filter((m) => m.toLowerCase().includes(needle)),
[models, needle],
);
const canConfirm = !!selectedProvider && !!selectedModel && !applying;
const confirm = async () => {
if (!canConfirm || !selectedProvider) return;
if (standalone && onApply) {
setApplying(true);
try {
await onApply({
provider: selectedProvider.slug,
model: selectedModel,
persistGlobal,
});
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setApplying(false);
}
} else if (onSubmit) {
const global = persistGlobal ? " --global" : "";
onSubmit(
`/model ${selectedModel} --provider ${selectedProvider.slug}${global}`,
);
onClose();
}
};
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={(e) => e.target === e.currentTarget && onClose()}
role="dialog"
aria-modal="true"
aria-labelledby="model-picker-title"
>
<div className="relative w-full max-w-3xl max-h-[80vh] border border-border bg-card shadow-2xl flex flex-col">
<Button
ghost
size="icon"
onClick={onClose}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<h2
id="model-picker-title"
className="font-display text-base tracking-wider uppercase"
>
{title}
</h2>
<p className="text-xs text-muted-foreground mt-1 font-mono">
current: {currentModel || "(unknown)"}
{currentProviderSlug && ` · ${currentProviderSlug}`}
</p>
</header>
<div className="px-5 pt-3 pb-2 border-b border-border">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
autoFocus
placeholder="Filter providers and models…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-7 h-8 text-sm"
/>
</div>
</div>
<div className="flex-1 min-h-0 grid grid-cols-[200px_1fr] overflow-hidden">
<ProviderColumn
loading={loading}
error={error}
providers={filteredProviders}
total={providers.length}
selectedSlug={selectedSlug}
query={needle}
onSelect={(slug) => {
setSelectedSlug(slug);
setSelectedModel("");
}}
/>
<ModelColumn
provider={selectedProvider}
models={filteredModels}
allModels={models}
selectedModel={selectedModel}
currentModel={currentModel}
currentProviderSlug={currentProviderSlug}
onSelect={setSelectedModel}
onConfirm={(m) => {
setSelectedModel(m);
// Confirm on next tick so state settles.
window.setTimeout(confirm, 0);
}}
/>
</div>
<footer className="border-t border-border p-3 flex items-center justify-between gap-3 flex-wrap">
{alwaysGlobal ? (
<span className="text-xs text-muted-foreground">
Saves to config.yaml applies to new sessions.
</span>
) : (
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
<input
type="checkbox"
checked={persistGlobal}
onChange={(e) => setPersistGlobal(e.target.checked)}
className="cursor-pointer"
/>
Persist globally (otherwise this session only)
</label>
)}
<div className="flex items-center gap-2 ml-auto">
<Button outlined onClick={onClose} disabled={applying}>
Cancel
</Button>
<Button onClick={confirm} disabled={!canConfirm}>
{applying ? <Spinner /> : "Switch"}
</Button>
</div>
</footer>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Provider column */
/* ------------------------------------------------------------------ */
function ProviderColumn({
loading,
error,
providers,
total,
selectedSlug,
query,
onSelect,
}: {
loading: boolean;
error: string | null;
providers: ModelOptionProvider[];
total: number;
selectedSlug: string;
query: string;
onSelect(slug: string): void;
}) {
return (
<div className="border-r border-border overflow-y-auto">
{loading && (
<div className="flex items-center gap-2 p-4 text-xs text-muted-foreground">
<Spinner className="text-xs" /> loading
</div>
)}
{error && <div className="p-4 text-xs text-destructive">{error}</div>}
{!loading && !error && providers.length === 0 && (
<div className="p-4 text-xs text-muted-foreground italic">
{query
? "no matches"
: total === 0
? "no authenticated providers"
: "no matches"}
</div>
)}
{providers.map((p) => {
const active = p.slug === selectedSlug;
return (
<ListItem
key={p.slug}
active={active}
onClick={() => onSelect(p.slug)}
className={`items-start text-xs border-l-2 ${
active ? "border-l-primary" : "border-l-transparent"
}`}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium truncate">{p.name}</span>
{p.is_current && <CurrentTag />}
</div>
<div className="text-[0.65rem] text-muted-foreground/80 font-mono truncate">
{p.slug} · {p.total_models ?? p.models?.length ?? 0} models
</div>
</div>
</ListItem>
);
})}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Model column */
/* ------------------------------------------------------------------ */
function ModelColumn({
provider,
models,
allModels,
selectedModel,
currentModel,
currentProviderSlug,
onSelect,
onConfirm,
}: {
provider: ModelOptionProvider | null;
models: string[];
allModels: string[];
selectedModel: string;
currentModel: string;
currentProviderSlug: string;
onSelect(model: string): void;
onConfirm(model: string): void;
}) {
if (!provider) {
return (
<div className="overflow-y-auto">
<div className="p-4 text-xs text-muted-foreground italic">
pick a provider
</div>
</div>
);
}
return (
<div className="overflow-y-auto">
{provider.warning && (
<div className="p-3 text-xs text-destructive border-b border-border">
{provider.warning}
</div>
)}
{models.length === 0 ? (
<div className="p-4 text-xs text-muted-foreground italic">
{allModels.length
? "no models match your filter"
: "no models listed for this provider"}
</div>
) : (
models.map((m) => {
const active = m === selectedModel;
const isCurrent =
m === currentModel && provider.slug === currentProviderSlug;
return (
<ListItem
key={m}
active={active}
onClick={() => onSelect(m)}
onDoubleClick={() => onConfirm(m)}
className="px-3 py-1.5 text-xs font-mono"
>
<Check
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
/>
<span className="flex-1 truncate">{m}</span>
{isCurrent && <CurrentTag />}
</ListItem>
);
})
)}
</div>
);
}
function CurrentTag() {
return (
<span className="text-[0.6rem] uppercase tracking-wider text-primary/80 shrink-0">
current
</span>
);
}
-63
View File
@@ -1,63 +0,0 @@
import { forwardRef, type ElementType, type HTMLAttributes, type ReactNode } from "react";
import { cn } from "@/lib/utils";
type TypographyProps = HTMLAttributes<HTMLElement> & {
as?: ElementType;
children?: ReactNode;
compressed?: boolean;
courier?: boolean;
expanded?: boolean;
mondwest?: boolean;
mono?: boolean;
sans?: boolean;
variant?: "sm" | "md" | "lg" | "xl";
};
const variantClasses: Record<NonNullable<TypographyProps["variant"]>, string> = {
sm: "leading-[1.4] text-[.9375rem] tracking-[0.1875rem]",
md: "text-[2.625rem] leading-[1] tracking-[0.0525rem]",
lg: "text-[2.625rem] leading-[1] tracking-[0.0525rem]",
xl: "text-[4.5rem] leading-[1] tracking-[0.135rem]",
};
export const Typography = forwardRef<HTMLElement, TypographyProps>(function Typography(
{
as: Component = "span",
className,
compressed,
courier,
expanded,
mondwest,
mono,
sans,
variant,
...props
},
ref,
) {
const hasFontVariant = compressed || courier || expanded || mondwest || mono || sans;
return (
<Component
className={cn(
compressed && "font-compressed",
courier && "font-courier",
expanded && "font-expanded",
mondwest && "font-mondwest tracking-[0.1875rem]",
mono && "font-mono",
(!hasFontVariant || sans) && "font-sans",
variant && variantClasses[variant],
className,
)}
ref={ref}
{...props}
/>
);
});
export const H2 = forwardRef<HTMLHeadingElement, Omit<TypographyProps, "as">>(function H2(
{ className, variant = "lg", ...props },
ref,
) {
return <Typography as="h2" className={cn("font-bold", className)} variant={variant} ref={ref} {...props} />;
});
-373
View File
@@ -1,373 +0,0 @@
import { useEffect, useRef, useState } from "react";
import { ExternalLink, X, Check } from "lucide-react";
import { Button } from "@nous-research/ui/ui/components/button";
import { CopyButton } from "@nous-research/ui/ui/components/command-block";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { H2 } from "@/components/NouiTypography";
import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
import { Input } from "@/components/ui/input";
import { useI18n } from "@/i18n";
interface Props {
provider: OAuthProvider;
onClose: () => void;
onSuccess: (msg: string) => void;
onError: (msg: string) => void;
}
type Phase =
| "idle"
| "starting"
| "awaiting_user"
| "submitting"
| "polling"
| "approved"
| "error";
export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
const [phase, setPhase] = useState<Phase>("starting");
const [start, setStart] = useState<OAuthStartResponse | null>(null);
const [pkceCode, setPkceCode] = useState("");
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
const isMounted = useRef(true);
const pollTimer = useRef<number | null>(null);
const { t } = useI18n();
// Initiate flow on mount
useEffect(() => {
isMounted.current = true;
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(`Failed to start login: ${e}`);
});
return () => {
isMounted.current = false;
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Tick the countdown
useEffect(() => {
if (secondsLeft === null) return;
if (phase === "approved" || phase === "error") return;
const tick = window.setInterval(() => {
if (!isMounted.current) return;
setSecondsLeft((s) => {
if (s !== null && s <= 1) {
setPhase("error");
setErrorMsg(t.oauth.sessionExpired);
return 0;
}
return s !== null && s > 0 ? s - 1 : 0;
});
}, 1000);
return () => window.clearInterval(tick);
}, [secondsLeft, phase, t]);
// Device-code: poll backend every 2s
useEffect(() => {
if (!start || start.flow !== "device_code" || phase !== "polling") return;
const sid = start.session_id;
pollTimer.current = window.setInterval(async () => {
try {
const resp = await api.pollOAuthSession(provider.id, sid);
if (!isMounted.current) return;
if (resp.status === "approved") {
setPhase("approved");
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);
}
} catch (e) {
if (!isMounted.current) return;
setPhase("error");
setErrorMsg(`Polling failed: ${e}`);
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
}
}, 2000);
return () => {
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
};
}, [start, phase, provider.id, provider.name, onSuccess, onClose]);
const handleSubmitPkceCode = async () => {
if (!start || start.flow !== "pkce") return;
if (!pkceCode.trim()) return;
setPhase("submitting");
setErrorMsg(null);
try {
const resp = await api.submitOAuthCode(
provider.id,
start.session_id,
pkceCode.trim(),
);
if (!isMounted.current) return;
if (resp.ok && resp.status === "approved") {
setPhase("approved");
onSuccess(`${provider.name} connected`);
window.setTimeout(() => isMounted.current && onClose(), 1500);
} else {
setPhase("error");
setErrorMsg(resp.message || "Token exchange failed");
}
} catch (e) {
if (!isMounted.current) return;
setPhase("error");
setErrorMsg(`Submit failed: ${e}`);
}
};
const handleClose = async () => {
if (start && phase !== "approved" && phase !== "error") {
try {
await api.cancelOAuthSession(start.session_id);
} catch {
// ignore
}
}
onClose();
};
const handleBackdrop = (e: React.MouseEvent) => {
if (e.target === e.currentTarget) handleClose();
};
const fmtTime = (s: number | null) => {
if (s === null) return "";
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}:${String(r).padStart(2, "0")}`;
};
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={handleBackdrop}
role="dialog"
aria-modal="true"
aria-labelledby="oauth-modal-title"
>
<div className="relative w-full max-w-md border border-border bg-card shadow-2xl">
<Button
ghost
size="icon"
onClick={handleClose}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label={t.common.close}
>
<X />
</Button>
<div className="p-6 flex flex-col gap-4">
<div>
<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>
)}
</div>
{phase === "starting" && (
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
<Spinner />
{t.oauth.initiatingLogin}
</div>
)}
{start?.flow === "pkce" && phase === "awaiting_user" && (
<>
<ol className="text-sm space-y-2 list-decimal list-inside text-muted-foreground">
<li>{t.oauth.pkceStep1}</li>
<li>{t.oauth.pkceStep2}</li>
<li>{t.oauth.pkceStep3}</li>
</ol>
<div className="flex flex-col gap-2">
<Input
value={pkceCode}
onChange={(e) => setPkceCode(e.target.value)}
placeholder={t.oauth.pasteCode}
onKeyDown={(e) => e.key === "Enter" && handleSubmitPkceCode()}
autoFocus
/>
<div className="flex items-center gap-2 justify-between">
<a
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"
>
<ExternalLink className="h-3 w-3" />
{t.oauth.reOpenAuth}
</a>
<Button
onClick={handleSubmitPkceCode}
disabled={!pkceCode.trim()}
>
{t.oauth.submitCode}
</Button>
</div>
</div>
</>
)}
{phase === "submitting" && (
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
<Spinner />
{t.oauth.exchangingCode}
</div>
)}
{start?.flow === "device_code" && phase === "polling" && (
<>
<p className="text-sm text-muted-foreground">
{t.oauth.enterCodePrompt}
</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
}
</code>
<CopyButton
text={
(
start as Extract<
OAuthStartResponse,
{ flow: "device_code" }
>
).user_code
}
/>
</div>
<a
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"
>
<ExternalLink className="h-3 w-3" />
{t.oauth.reOpenVerification}
</a>
<div className="flex items-center gap-2 text-xs text-muted-foreground border-t border-border pt-3">
<Spinner className="text-xs" />
{t.oauth.waitingAuth}
</div>
</>
)}
{phase === "approved" && (
<div className="flex items-center gap-3 py-6 text-sm text-success">
<Check className="h-5 w-5" />
{t.oauth.connectedClosing}
</div>
)}
{phase === "error" && (
<>
<div className="border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{errorMsg || t.oauth.loginFailed}
</div>
<div className="flex justify-end gap-2">
<Button outlined onClick={handleClose}>
{t.common.close}
</Button>
<Button
onClick={() => {
if (start?.session_id) {
api.cancelOAuthSession(start.session_id).catch(() => {});
}
setErrorMsg(null);
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}`);
});
}}
>
{t.common.retry}
</Button>
</div>
</>
)}
</div>
</div>
</div>
);
}
-271
View File
@@ -1,271 +0,0 @@
import { useEffect, useState, useCallback, useRef } from "react";
import {
ShieldCheck,
ShieldOff,
ExternalLink,
RefreshCw,
LogOut,
Terminal,
LogIn,
} from "lucide-react";
import { api, type OAuthProvider } from "@/lib/api";
import { Button } from "@nous-research/ui/ui/components/button";
import { CopyButton } from "@nous-research/ui/ui/components/command-block";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { OAuthLoginModal } from "@/components/OAuthLoginModal";
import { useI18n } from "@/i18n";
interface Props {
onError?: (msg: string) => void;
onSuccess?: (msg: string) => void;
}
function formatExpiresAt(
expiresAt: string | null | undefined,
expiresInTemplate: string,
): string | null {
if (!expiresAt) return null;
try {
const dt = new Date(expiresAt);
if (Number.isNaN(dt.getTime())) return null;
const now = Date.now();
const diff = dt.getTime() - now;
if (diff < 0) return "expired";
const mins = Math.floor(diff / 60_000);
if (mins < 60) return expiresInTemplate.replace("{time}", `${mins}m`);
const hours = Math.floor(mins / 60);
if (hours < 24) return expiresInTemplate.replace("{time}", `${hours}h`);
const days = Math.floor(hours / 24);
return expiresInTemplate.replace("{time}", `${days}d`);
} catch {
return null;
}
}
export function OAuthProvidersCard({ onError, onSuccess }: Props) {
const [providers, setProviders] = useState<OAuthProvider[] | null>(null);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const [loginFor, setLoginFor] = useState<OAuthProvider | null>(null);
const { t } = useI18n();
const onErrorRef = useRef(onError);
onErrorRef.current = onError;
const refresh = useCallback(() => {
setLoading(true);
api
.getOAuthProviders()
.then((resp) => setProviders(resp.providers))
.catch((e) => onErrorRef.current?.(`Failed to load providers: ${e}`))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const handleDisconnect = async (provider: OAuthProvider) => {
if (!confirm(`${t.oauth.disconnect} ${provider.name}?`)) {
return;
}
setBusyId(provider.id);
try {
await api.disconnectOAuthProvider(provider.id);
onSuccess?.(`${provider.name} ${t.oauth.disconnect.toLowerCase()}ed`);
refresh();
} catch (e) {
onError?.(`${t.oauth.disconnect} failed: ${e}`);
} finally {
setBusyId(null);
}
};
const connectedCount =
providers?.filter((p) => p.status.logged_in).length ?? 0;
const totalCount = providers?.length ?? 0;
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{t.oauth.providerLogins}
</CardTitle>
</div>
<Button
size="sm"
outlined
onClick={refresh}
disabled={loading}
prefix={loading ? <Spinner /> : <RefreshCw />}
>
{t.common.refresh}
</Button>
</div>
<CardDescription>
{t.oauth.description
.replace("{connected}", String(connectedCount))
.replace("{total}", String(totalCount))}
</CardDescription>
</CardHeader>
<CardContent>
{loading && providers === null && (
<div className="flex items-center justify-center py-8">
<Spinner className="text-xl text-primary" />
</div>
)}
{providers && providers.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-8">
{t.oauth.noProviders}
</p>
)}
<div className="flex flex-col divide-y divide-border">
{providers?.map((p) => {
const expiresLabel = formatExpiresAt(
p.status.expires_at,
t.oauth.expiresIn,
);
const isBusy = busyId === p.id;
return (
<div
key={p.id}
className="flex items-center justify-between gap-4 py-3"
>
<div className="flex items-start gap-3 min-w-0 flex-1">
{p.status.logged_in ? (
<ShieldCheck className="h-5 w-5 text-success shrink-0 mt-0.5" />
) : (
<ShieldOff className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
)}
<div className="flex flex-col min-w-0 gap-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{p.name}</span>
<Badge
tone="outline"
className="text-[11px] uppercase tracking-wide"
>
{t.oauth.flowLabels[p.flow]}
</Badge>
{p.status.logged_in && (
<Badge tone="success" className="text-[11px]">
{t.oauth.connected}
</Badge>
)}
{expiresLabel === "expired" && (
<Badge tone="destructive" className="text-[11px]">
{t.oauth.expired}
</Badge>
)}
{expiresLabel && expiresLabel !== "expired" && (
<Badge tone="outline" className="text-[11px]">
{expiresLabel}
</Badge>
)}
</div>
{p.status.logged_in && p.status.token_preview && (
<code className="text-xs font-mono-ui truncate">
<span className="opacity-50">token </span>
{p.status.token_preview}
{p.status.source_label && (
<span className="opacity-40">
{" "}
· {p.status.source_label}
</span>
)}
</code>
)}
{!p.status.logged_in && (
<span className="text-xs text-muted-foreground/80">
{t.oauth.notConnected.split("{command}")[0]}
<code className="text-foreground bg-secondary/40 px-1">
{p.cli_command}
</code>
{t.oauth.notConnected.split("{command}")[1]}
</span>
)}
{p.status.error && (
<span className="text-xs text-destructive">
{p.status.error}
</span>
)}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{p.docs_url && (
<a
href={p.docs_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex"
title={`Open ${p.name} docs`}
>
<Button ghost size="icon">
<ExternalLink />
</Button>
</a>
)}
{!p.status.logged_in && p.flow !== "external" && (
<Button
size="sm"
onClick={() => setLoginFor(p)}
prefix={<LogIn />}
>
{t.oauth.login}
</Button>
)}
{!p.status.logged_in && (
<CopyButton
text={p.cli_command}
label={t.oauth.cli}
copiedLabel={t.oauth.copied}
/>
)}
{p.status.logged_in && p.flow !== "external" && (
<Button
size="sm"
outlined
onClick={() => handleDisconnect(p)}
disabled={isBusy}
prefix={isBusy ? <Spinner /> : <LogOut />}
>
{t.oauth.disconnect}
</Button>
)}
{p.status.logged_in && p.flow === "external" && (
<span className="text-[11px] text-muted-foreground italic px-2">
<Terminal className="h-3 w-3 inline mr-0.5" />
{t.oauth.managedExternally}
</span>
)}
</div>
</div>
);
})}
</div>
</CardContent>
{loginFor && (
<OAuthLoginModal
provider={loginFor}
onClose={() => {
setLoginFor(null);
refresh();
}}
onSuccess={(msg) => onSuccess?.(msg)}
onError={(msg) => onError?.(msg)}
/>
)}
</Card>
);
}
-97
View File
@@ -1,97 +0,0 @@
import { AlertTriangle, Radio, Wifi, WifiOff } from "lucide-react";
import type { PlatformStatus } from "@/lib/api";
import { isoTimeAgo } from "@/lib/utils";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useI18n } from "@/i18n";
export function PlatformsCard({ platforms }: PlatformsCardProps) {
const { t } = useI18n();
const platformStateBadge: Record<
string,
{ tone: "success" | "warning" | "destructive"; label: string }
> = {
connected: { tone: "success", label: t.status.connected },
disconnected: { tone: "warning", label: t.status.disconnected },
fatal: { tone: "destructive", label: t.status.error },
};
return (
<Card>
<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>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{platforms.map(([name, info]) => {
const display = platformStateBadge[info.state] ?? {
tone: "outline" as const,
label: info.state,
};
const IconComponent =
info.state === "connected"
? Wifi
: info.state === "fatal"
? AlertTriangle
: WifiOff;
return (
<div
key={name}
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"
}`}
/>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-medium capitalize truncate">
{name}
</span>
{info.error_message && (
<span className="text-xs text-destructive">
{info.error_message}
</span>
)}
{info.updated_at && (
<span className="text-xs text-muted-foreground">
{t.status.lastUpdate}: {isoTimeAgo(info.updated_at)}
</span>
)}
</div>
</div>
<Badge
tone={display.tone}
className="shrink-0 self-start sm:self-center"
>
{display.tone === "success" && (
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
)}
{display.label}
</Badge>
</div>
);
})}
</CardContent>
</Card>
);
}
interface PlatformsCardProps {
platforms: [string, PlatformStatus][];
}
-40
View File
@@ -1,40 +0,0 @@
import { Typography } from "@/components/NouiTypography";
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
import { cn } from "@/lib/utils";
import { useI18n } from "@/i18n";
export function SidebarFooter() {
const status = useSidebarStatus();
const { t } = useI18n();
return (
<div
className={cn(
"flex shrink-0 items-center justify-between gap-2",
"px-5 py-2.5",
"border-t border-current/10",
)}
>
<Typography
mondwest
className="font-mono-ui text-[0.7rem] tabular-nums tracking-[0.1em] text-muted-foreground/70 lowercase"
>
{status?.version != null ? `v${status.version}` : "—"}
</Typography>
<a
href="https://nousresearch.com"
target="_blank"
rel="noopener noreferrer"
className={cn(
"font-mondwest text-[0.65rem] tracking-[0.15em] text-midground",
"transition-opacity hover:opacity-90",
"focus-visible:rounded-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40",
)}
style={{ mixBlendMode: "plus-lighter" }}
>
{t.app.footer.org}
</a>
</div>
);
}
-70
View File
@@ -1,70 +0,0 @@
import { Link } from "react-router-dom";
import type { StatusResponse } from "@/lib/api";
import { useSidebarStatus } from "@/hooks/useSidebarStatus";
import { cn } from "@/lib/utils";
import { useI18n } from "@/i18n";
/** Gateway + session summary for the System sidebar block (no separate strip chrome). */
export function SidebarStatusStrip() {
const status = useSidebarStatus();
const { t } = useI18n();
if (status === null) {
return (
<div className="px-5 py-1.5" aria-hidden>
<div className="h-2 w-[80%] max-w-full animate-pulse rounded-sm bg-midground/10" />
</div>
);
}
const gw = gatewayLine(status, t);
const { activeSessionsLabel, gatewayStatusLabel } = t.app;
return (
<Link
to="/sessions"
title={t.app.statusOverview}
className={cn(
"block text-left",
"px-5 pb-2 pt-0.5",
"text-muted-foreground/70",
"transition-colors hover:text-muted-foreground/90",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground/40",
"focus-visible:ring-inset",
)}
>
<div className="flex flex-col gap-1 font-mondwest text-[0.55rem] leading-snug tracking-[0.12em]">
<p className="break-words">
<span className="text-muted-foreground/50">{gatewayStatusLabel}</span>{" "}
<span className={cn("font-medium", gw.tone)}>{gw.label}</span>
</p>
<p className="break-words">
<span className="text-muted-foreground/50">{activeSessionsLabel}</span>{" "}
<span className="tabular-nums text-muted-foreground/70">
{status.active_sessions}
</span>
</p>
</div>
</Link>
);
}
function gatewayLine(
status: StatusResponse,
t: ReturnType<typeof useI18n>["t"],
): { label: string; tone: string } {
const g = t.app.gatewayStrip;
const byState: Record<string, { label: string; tone: string }> = {
running: { label: g.running, tone: "text-success" },
starting: { label: g.starting, tone: "text-warning" },
startup_failed: { label: g.failed, tone: "text-destructive" },
stopped: { label: g.stopped, tone: "text-muted-foreground" },
};
if (status.gateway_state && byState[status.gateway_state]) {
return byState[status.gateway_state];
}
return status.gateway_running
? { label: g.running, tone: "text-success" }
: { label: g.off, tone: "text-muted-foreground" };
}
-171
View File
@@ -1,171 +0,0 @@
import type { GatewayClient } from "@/lib/gatewayClient";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { ChevronRight } from "lucide-react";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
/**
* Slash-command autocomplete popover, rendered above the composer in ChatPage.
* Mirrors the completion UX of the Ink TUI — type `/`, see matching commands,
* arrow keys or click to select, Tab to apply, Enter to submit.
*
* The parent owns all keyboard handling via `ref.handleKey`, which returns
* true when the popover consumed the event, so the composer's Enter/arrow
* logic stays in one place.
*/
export interface CompletionItem {
display: string;
text: string;
meta?: string;
}
export interface SlashPopoverHandle {
/** Returns true if the key was consumed by the popover. */
handleKey(e: React.KeyboardEvent<HTMLTextAreaElement>): boolean;
}
interface Props {
input: string;
gw: GatewayClient | null;
onApply(nextInput: string): void;
}
interface CompletionResponse {
items?: CompletionItem[];
replace_from?: number;
}
const DEBOUNCE_MS = 60;
export const SlashPopover = forwardRef<SlashPopoverHandle, Props>(
function SlashPopover({ input, gw, onApply }, ref) {
const [items, setItems] = useState<CompletionItem[]>([]);
const [selected, setSelected] = useState(0);
const [replaceFrom, setReplaceFrom] = useState(1);
const lastInputRef = useRef<string>("");
// Debounced completion fetch. We never clear `items` in the effect body
// (doing so would flag react-hooks/set-state-in-effect); instead the
// render guard below hides stale items once the input stops matching.
useEffect(() => {
const trimmed = input ?? "";
if (!gw || !trimmed.startsWith("/") || trimmed === lastInputRef.current) {
if (!trimmed.startsWith("/")) lastInputRef.current = "";
return;
}
lastInputRef.current = trimmed;
const timer = window.setTimeout(async () => {
if (lastInputRef.current !== trimmed) return;
try {
const r = await gw.request<CompletionResponse>("complete.slash", {
text: trimmed,
});
if (lastInputRef.current !== trimmed) return;
setItems(r?.items ?? []);
setReplaceFrom(r?.replace_from ?? 1);
setSelected(0);
} catch {
if (lastInputRef.current === trimmed) setItems([]);
}
}, DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [input, gw]);
const apply = useCallback(
(item: CompletionItem) => {
onApply(input.slice(0, replaceFrom) + item.text);
},
[input, replaceFrom, onApply],
);
// Only consume keys when the popover is actually visible. Stale items from
// a previous slash prefix are ignored once the user deletes the "/".
const visible = items.length > 0 && input.startsWith("/");
useImperativeHandle(
ref,
() => ({
handleKey: (e) => {
if (!visible) return false;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelected((s) => (s + 1) % items.length);
return true;
case "ArrowUp":
e.preventDefault();
setSelected((s) => (s - 1 + items.length) % items.length);
return true;
case "Tab": {
e.preventDefault();
const item = items[selected];
if (item) apply(item);
return true;
}
case "Escape":
e.preventDefault();
setItems([]);
return true;
default:
return false;
}
},
}),
[visible, items, selected, apply],
);
if (!visible) return null;
return (
<div
className="absolute bottom-full left-0 right-0 mb-2 max-h-64 overflow-y-auto rounded-md border border-border bg-popover shadow-xl text-sm"
role="listbox"
>
{items.map((it, i) => {
const active = i === selected;
return (
<ListItem
key={`${it.text}-${i}`}
active={active}
role="option"
aria-selected={active}
onMouseEnter={() => setSelected(i)}
onClick={() => apply(it)}
className="px-3 py-1.5"
>
<ChevronRight
className={`h-3 w-3 shrink-0 ${active ? "text-primary" : "text-transparent"}`}
/>
<span className="font-mono text-xs shrink-0 truncate">
{it.display}
</span>
{it.meta && (
<span className="text-[0.7rem] text-muted-foreground/70 truncate ml-auto">
{it.meta}
</span>
)}
</ListItem>
);
})}
</div>
);
},
);
-174
View File
@@ -1,174 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Palette, Check } from "lucide-react";
import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Typography } from "@/components/NouiTypography";
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.
*
* When placed at the bottom of a container (e.g. the sidebar rail), pass
* `dropUp` so the menu opens above the trigger instead of clipping below
* the viewport.
*/
export function ThemeSwitcher({ dropUp = false }: ThemeSwitcherProps) {
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
ghost
onClick={() => setOpen((o) => !o)}
className="px-2 py-1 normal-case tracking-normal font-normal text-xs text-muted-foreground hover:text-foreground"
title={t.theme?.switchTheme ?? "Switch theme"}
aria-label={t.theme?.switchTheme ?? "Switch theme"}
aria-expanded={open}
aria-haspopup="listbox"
>
<span className="inline-flex items-center gap-1.5">
<Palette className="h-3.5 w-3.5" />
<Typography
mondwest
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
>
{label}
</Typography>
</span>
</Button>
{open && (
<div
role="listbox"
aria-label={t.theme?.title ?? "Theme"}
className={cn(
"absolute z-50 min-w-[240px]",
dropUp ? "left-0 bottom-full mb-1" : "right-0 top-full mt-1",
"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 (
<ListItem
key={th.name}
active={isActive}
role="option"
aria-selected={isActive}
onClick={() => {
setTheme(th.name);
close();
}}
className="gap-3"
>
{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",
)}
/>
</ListItem>
);
})}
</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"
/>
);
}
interface ThemeSwitcherProps {
dropUp?: boolean;
}
-40
View File
@@ -1,40 +0,0 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
export function Toast({ toast }: { toast: { message: string; type: "success" | "error" } | null }) {
const [visible, setVisible] = useState(false);
const [current, setCurrent] = useState(toast);
useEffect(() => {
if (toast) {
setCurrent(toast);
setVisible(true);
} else {
setVisible(false);
const timer = setTimeout(() => setCurrent(null), 200);
return () => clearTimeout(timer);
}
}, [toast]);
if (!current) return null;
// Portal to document.body so the toast escapes any ancestor stacking context
// (e.g. <main> has `relative z-2`, which would trap z-50 below the header's z-40).
return createPortal(
<div
role="status"
aria-live="polite"
className={`fixed top-16 right-4 z-50 border px-4 py-2.5 font-courier text-xs tracking-wider uppercase backdrop-blur-sm ${
current.type === "success"
? "bg-success/15 text-success border-success/30"
: "bg-destructive/15 text-destructive border-destructive/30"
}`}
style={{
animation: visible ? "toast-in 200ms ease-out forwards" : "toast-out 200ms ease-in forwards",
}}
>
{current.message}
</div>,
document.body,
);
}
-228
View File
@@ -1,228 +0,0 @@
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import {
AlertCircle,
Check,
ChevronDown,
ChevronRight,
Zap,
} from "lucide-react";
import { useEffect, useState } from "react";
/**
* Expandable tool call row — the web equivalent of Ink's ToolTrail node.
*
* Renders one `tool.start` + `tool.complete` pair (plus any `tool.progress`
* in between) as a single collapsible item in the transcript:
*
* ▸ ● read_file(path=/foo) 2.3s
*
* Click the header to reveal a preformatted body with context (args), the
* streaming preview (while running), and the final summary or error. Error
* rows auto-expand so failures aren't silently collapsed.
*/
export interface ToolEntry {
kind: "tool";
id: string;
tool_id: string;
name: string;
context?: string;
preview?: string;
summary?: string;
error?: string;
inline_diff?: string;
status: "running" | "done" | "error";
startedAt: number;
completedAt?: number;
}
const STATUS_TONE: Record<ToolEntry["status"], string> = {
running: "border-primary/40 bg-primary/[0.04]",
done: "border-border bg-muted/20",
error: "border-destructive/50 bg-destructive/[0.04]",
};
const BULLET_TONE: Record<ToolEntry["status"], string> = {
running: "text-primary",
done: "text-primary/80",
error: "text-destructive",
};
const TICK_MS = 500;
export function ToolCall({ tool }: { tool: ToolEntry }) {
// `open` is derived: errors default-expanded, everything else collapsed.
// `null` means "follow the default"; any explicit bool is the user's override.
// This lets a running tool flip to expanded automatically when it errors,
// without mirroring state in an effect.
const [userOverride, setUserOverride] = useState<boolean | null>(null);
const open = userOverride ?? tool.status === "error";
// Tick `now` while the tool is running so the elapsed label updates live.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (tool.status !== "running") return;
const id = window.setInterval(() => setNow(() => Date.now()), TICK_MS);
return () => window.clearInterval(id);
}, [tool.status]);
// Historical tools (hydrated from session.resume) signal missing timestamps
// with `startedAt === 0`; we hide the elapsed badge for those rather than
// rendering a misleading "0ms".
const hasTimestamps = tool.startedAt > 0;
const elapsed = hasTimestamps
? fmtElapsed((tool.completedAt ?? now) - tool.startedAt)
: null;
const hasBody = !!(
tool.context ||
tool.preview ||
tool.summary ||
tool.error ||
tool.inline_diff
);
const Chevron = open ? ChevronDown : ChevronRight;
return (
<div
className={`rounded-md border overflow-hidden ${STATUS_TONE[tool.status]}`}
>
<ListItem
onClick={() => setUserOverride(!open)}
disabled={!hasBody}
aria-expanded={open}
className="px-2.5 py-1.5 text-xs hover:bg-foreground/2 disabled:cursor-default"
>
{hasBody ? (
<Chevron className="h-3 w-3 shrink-0 text-muted-foreground" />
) : (
<span className="w-3 shrink-0" />
)}
<Zap className={`h-3 w-3 shrink-0 ${BULLET_TONE[tool.status]}`} />
<span className="font-mono font-medium shrink-0">{tool.name}</span>
<span className="font-mono text-muted-foreground/80 truncate min-w-0 flex-1">
{tool.context ?? ""}
</span>
{tool.status === "running" && (
<span
className="inline-block h-2 w-2 rounded-full bg-primary animate-pulse shrink-0"
title="running"
/>
)}
{tool.status === "error" && (
<AlertCircle
className="h-3 w-3 shrink-0 text-destructive"
aria-label="error"
/>
)}
{tool.status === "done" && (
<Check
className="h-3 w-3 shrink-0 text-primary/80"
aria-label="done"
/>
)}
{elapsed && (
<span className="font-mono text-[0.65rem] text-muted-foreground tabular-nums shrink-0">
{elapsed}
</span>
)}
</ListItem>
{open && hasBody && (
<div className="border-t border-border/60 px-3 py-2 space-y-2 text-xs font-mono">
{tool.context && <Section label="context">{tool.context}</Section>}
{tool.preview && tool.status === "running" && (
<Section label="streaming">
{tool.preview}
<span className="inline-block w-1.5 h-3 align-middle bg-foreground/40 ml-0.5 animate-pulse" />
</Section>
)}
{tool.inline_diff && (
<Section label="diff">
<pre className="whitespace-pre overflow-x-auto text-[0.7rem] leading-snug">
{colorizeDiff(tool.inline_diff)}
</pre>
</Section>
)}
{tool.summary && (
<Section label="result">
<span className="text-foreground/90 whitespace-pre-wrap">
{tool.summary}
</span>
</Section>
)}
{tool.error && (
<Section label="error" tone="error">
<span className="text-destructive whitespace-pre-wrap">
{tool.error}
</span>
</Section>
)}
</div>
)}
</div>
);
}
function Section({
label,
children,
tone,
}: {
label: string;
children: React.ReactNode;
tone?: "error";
}) {
return (
<div className="flex gap-3">
<span
className={`uppercase tracking-wider text-[0.6rem] shrink-0 w-14 pt-0.5 ${
tone === "error" ? "text-destructive/80" : "text-muted-foreground/60"
}`}
>
{label}
</span>
<div className="flex-1 min-w-0 text-muted-foreground">{children}</div>
</div>
);
}
function fmtElapsed(ms: number): string {
const sec = Math.max(0, ms) / 1000;
if (sec < 1) return `${Math.round(ms)}ms`;
if (sec < 10) return `${sec.toFixed(1)}s`;
if (sec < 60) return `${Math.round(sec)}s`;
const m = Math.floor(sec / 60);
const s = Math.round(sec % 60);
return s ? `${m}m ${s}s` : `${m}m`;
}
/** Colorize unified-diff lines for the inline diff section. */
function colorizeDiff(diff: string): React.ReactNode {
return diff.split("\n").map((line, i) => (
<div key={i} className={diffLineClass(line)}>
{line || "\u00A0"}
</div>
));
}
function diffLineClass(line: string): string {
if (line.startsWith("+") && !line.startsWith("+++"))
return "text-emerald-500 dark:text-emerald-400";
if (line.startsWith("-") && !line.startsWith("---"))
return "text-destructive";
if (line.startsWith("@@")) return "text-primary";
return "text-muted-foreground/80";
}
-52
View File
@@ -1,52 +0,0 @@
import { cn } from "@/lib/utils";
/**
* Themed card primitive. Themes can restyle every card without touching
* call sites by setting CSS vars under the `card` component-style bucket:
*
* componentStyles:
* card:
* clipPath: "polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px)"
* border: "1px solid var(--color-ring)"
* background: "linear-gradient(180deg, var(--color-card) 0%, transparent 100%)"
* boxShadow: "0 0 0 1px var(--color-ring) inset, 0 0 24px -8px var(--warm-glow)"
*
* All properties are optional — vars that aren't set compute to their
* CSS initial value, so the default shadcn-y card keeps looking normal
* for themes that don't override anything.
*/
const CARD_STYLE: React.CSSProperties = {
clipPath: "var(--component-card-clip-path)",
borderImage: "var(--component-card-border-image)",
background: "var(--component-card-background)",
boxShadow: "var(--component-card-box-shadow)",
};
export function Card({ className, style, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn(
"border border-border bg-card/80 text-card-foreground w-full",
className,
)}
style={{ ...CARD_STYLE, ...style }}
{...props}
/>
);
}
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col gap-1.5 p-4 border-b border-border", className)} {...props} />;
}
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
return <h3 className={cn("font-expanded text-sm font-bold tracking-[0.08em] uppercase blend-lighter", className)} {...props} />;
}
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn("font-mondwest text-xs text-muted-foreground", className)} {...props} />;
}
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("p-4", className)} {...props} />;
}
-136
View File
@@ -1,136 +0,0 @@
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { AlertTriangle } from "lucide-react";
import { Button } from "@nous-research/ui/ui/components/button";
import { cn } from "@/lib/utils";
export function ConfirmDialog({
cancelLabel = "Cancel",
confirmLabel = "Confirm",
description,
destructive = false,
loading = false,
onCancel,
onConfirm,
open,
title,
}: ConfirmDialogProps) {
const dialogRef = useRef<HTMLDivElement>(null);
// Focus the confirm button when opened; trap ESC to cancel.
useEffect(() => {
if (!open) return;
const prevActive = document.activeElement as HTMLElement | null;
dialogRef.current
?.querySelector<HTMLButtonElement>("[data-confirm]")
?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
onCancel();
}
};
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
prevActive?.focus?.();
};
}, [open, onCancel]);
if (!open) return null;
return createPortal(
<div
role="dialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
aria-describedby={description ? "confirm-dialog-desc" : undefined}
onClick={(e) => {
if (e.target === e.currentTarget) onCancel();
}}
className={cn(
"fixed inset-0 z-50 flex items-center justify-center",
"bg-black/60 backdrop-blur-sm",
"animate-[fade-in_150ms_ease-out]",
)}
>
<div
ref={dialogRef}
className={cn(
"relative w-full max-w-md mx-4",
"border border-border bg-card shadow-lg",
"animate-[dialog-in_180ms_ease-out]",
)}
>
<div className="flex items-start gap-3 p-4 border-b border-border">
{destructive && (
<div
aria-hidden
className="mt-0.5 shrink-0 text-destructive"
>
<AlertTriangle className="h-4 w-4" />
</div>
)}
<div className="flex-1 min-w-0 flex flex-col gap-1">
<h2
id="confirm-dialog-title"
className="font-expanded text-sm font-bold tracking-[0.08em] uppercase blend-lighter"
>
{title}
</h2>
{description && (
<p
id="confirm-dialog-desc"
className="font-mondwest text-xs text-muted-foreground leading-relaxed"
>
{description}
</p>
)}
</div>
</div>
<div className="flex items-center justify-end gap-2 p-3">
<Button
type="button"
outlined
onClick={onCancel}
disabled={loading}
>
{cancelLabel}
</Button>
<Button
data-confirm
type="button"
destructive={destructive}
onClick={onConfirm}
disabled={loading}
>
{loading ? "…" : confirmLabel}
</Button>
</div>
</div>
</div>,
document.body,
);
}
interface ConfirmDialogProps {
cancelLabel?: string;
confirmLabel?: string;
description?: string;
destructive?: boolean;
loading?: boolean;
onCancel: () => void;
onConfirm: () => void;
open: boolean;
title: string;
}
-16
View File
@@ -1,16 +0,0 @@
import { cn } from "@/lib/utils";
export function Input({ className, ...props }: React.InputHTMLAttributes<HTMLInputElement>) {
return (
<input
className={cn(
"flex h-9 w-full border border-border bg-background/40 px-3 py-1 font-courier text-sm transition-colors",
"placeholder:text-muted-foreground",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
);
}
-13
View File
@@ -1,13 +0,0 @@
import { cn } from "@/lib/utils";
export function Label({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) {
return (
<label
className={cn(
"font-mondwest text-xs tracking-[0.1em] uppercase leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
/>
);
}
-19
View File
@@ -1,19 +0,0 @@
import { cn } from "@/lib/utils";
export function Separator({
className,
orientation = "horizontal",
...props
}: React.HTMLAttributes<HTMLDivElement> & { orientation?: "horizontal" | "vertical" }) {
return (
<div
role="separator"
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
className,
)}
{...props}
/>
);
}
-106
View File
@@ -1,106 +0,0 @@
import { useLayoutEffect, useMemo, useState, type ReactNode } from "react";
import { useLocation } from "react-router-dom";
import { PageHeaderContext } from "./page-header-context";
import { resolvePageTitle } from "@/lib/resolve-page-title";
import { cn } from "@/lib/utils";
import { useI18n } from "@/i18n";
export function PageHeaderProvider({
children,
pluginTabs,
}: {
children: ReactNode;
pluginTabs: { path: string; label: string }[];
}) {
const { pathname } = useLocation();
const { t } = useI18n();
const [titleOverride, setTitleOverride] = useState<string | null>(null);
const [afterTitle, setAfterTitle] = useState<ReactNode>(null);
const [end, setEnd] = useState<ReactNode>(null);
// Clear any per-page title / toolbar slots when the path changes. Child routes
// re-fill these on mount via usePageHeader.
/* eslint-disable react-hooks/set-state-in-effect */
useLayoutEffect(() => {
setTitleOverride(null);
setAfterTitle(null);
setEnd(null);
}, [pathname]);
/* eslint-enable react-hooks/set-state-in-effect */
const defaultTitle = useMemo(
() => resolvePageTitle(pathname, t, pluginTabs),
[pathname, t, pluginTabs],
);
const displayTitle = titleOverride ?? defaultTitle;
const isChatRoute = pathname === "/chat" || pathname === "/chat/";
const value = useMemo(
() => ({
setAfterTitle,
setEnd,
setTitle: setTitleOverride,
}),
[],
);
return (
<PageHeaderContext.Provider value={value}>
<div className="flex min-h-0 w-full min-w-0 flex-1 flex-col overflow-hidden">
<header
className={cn(
"z-1 w-full shrink-0",
"box-border h-14 min-h-14",
"border-b border-current/20",
"bg-background-base/40 backdrop-blur-sm",
"overflow-hidden",
"sm:min-h-0",
)}
role="banner"
>
<div
className={cn(
"flex h-full w-full min-w-0 flex-1 gap-2 px-3 py-2 sm:gap-3 sm:px-6 sm:py-0",
isChatRoute
? "flex-row items-center"
: "flex-col justify-center sm:flex-row sm:items-center",
)}
>
<div className="flex min-w-0 flex-1 items-center gap-2 sm:gap-3">
<h1
className="font-expanded min-w-0 truncate text-sm font-bold tracking-[0.08em] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
{displayTitle}
</h1>
{afterTitle}
</div>
{end ? (
<div
className={cn(
"flex min-w-0 justify-end sm:max-w-md sm:flex-1",
isChatRoute ? "w-auto shrink-0" : "w-full",
)}
>
{end}
</div>
) : null}
</div>
</header>
<main
className={cn(
"min-h-0 w-full min-w-0 flex-1 flex flex-col",
isChatRoute
? "overflow-hidden"
: "overflow-y-auto overflow-x-hidden [scrollbar-gutter:stable]",
)}
>
{children}
</main>
</div>
</PageHeaderContext.Provider>
);
}
-120
View File
@@ -1,120 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { ActionStatusResponse } from "@/lib/api";
import { Toast } from "@/components/Toast";
import { useI18n } from "@/i18n";
import {
SystemActionsContext,
type SystemAction,
} from "./system-actions-context";
const ACTION_NAMES: Record<SystemAction, string> = {
restart: "gateway-restart",
update: "hermes-update",
};
export function SystemActionsProvider({
children,
}: {
children: React.ReactNode;
}) {
const [pendingAction, setPendingAction] = useState<SystemAction | null>(null);
const [activeAction, setActiveAction] = useState<SystemAction | null>(null);
const [actionStatus, setActionStatus] = useState<ActionStatusResponse | null>(
null,
);
const [toast, setToast] = useState<ToastState | null>(null);
const { t } = useI18n();
useEffect(() => {
if (!toast) return;
const timer = setTimeout(() => setToast(null), 4000);
return () => clearTimeout(timer);
}, [toast]);
useEffect(() => {
if (!activeAction) return;
const name = ACTION_NAMES[activeAction];
let cancelled = false;
const poll = async () => {
try {
const resp = await api.getActionStatus(name);
if (cancelled) return;
setActionStatus(resp);
if (!resp.running) {
const ok = resp.exit_code === 0;
setToast({
type: ok ? "success" : "error",
message: ok
? t.status.actionFinished
: `${t.status.actionFailed} (exit ${resp.exit_code ?? "?"})`,
});
return;
}
} catch {
// transient fetch error; keep polling
}
if (!cancelled) setTimeout(poll, 1500);
};
poll();
return () => {
cancelled = true;
};
}, [activeAction, t.status.actionFinished, t.status.actionFailed]);
const runAction = useCallback(
async (action: SystemAction) => {
setPendingAction(action);
setActionStatus(null);
try {
if (action === "restart") {
await api.restartGateway();
} else {
await api.updateHermes();
}
setActiveAction(action);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
setToast({
type: "error",
message: `${t.status.actionFailed}: ${detail}`,
});
} finally {
setPendingAction(null);
}
},
[t.status.actionFailed],
);
const dismissLog = useCallback(() => {
setActiveAction(null);
setActionStatus(null);
}, []);
const isRunning = activeAction !== null && actionStatus?.running !== false;
const isBusy = pendingAction !== null || isRunning;
return (
<SystemActionsContext.Provider
value={{
actionStatus,
activeAction,
dismissLog,
isBusy,
isRunning,
pendingAction,
runAction,
}}
>
{children}
<Toast toast={toast} />
</SystemActionsContext.Provider>
);
}
interface ToastState {
message: string;
type: "success" | "error";
}
-12
View File
@@ -1,12 +0,0 @@
import { createContext } from "react";
import type { ReactNode } from "react";
export interface PageHeaderContextValue {
setAfterTitle: (node: ReactNode) => void;
setEnd: (node: ReactNode) => void;
setTitle: (title: string | null) => void;
}
export const PageHeaderContext = createContext<PageHeaderContextValue | null>(
null,
);
@@ -1,18 +0,0 @@
import { createContext } from "react";
import type { ActionStatusResponse } from "@/lib/api";
export const SystemActionsContext = createContext<SystemActionsState | null>(
null,
);
export type SystemAction = "restart" | "update";
export interface SystemActionsState {
actionStatus: ActionStatusResponse | null;
activeAction: SystemAction | null;
dismissLog: () => void;
isBusy: boolean;
isRunning: boolean;
pendingAction: SystemAction | null;
runAction: (action: SystemAction) => Promise<void>;
}
-10
View File
@@ -1,10 +0,0 @@
import { useContext } from "react";
import { PageHeaderContext, type PageHeaderContextValue } from "./page-header-context";
export function usePageHeader(): PageHeaderContextValue {
const ctx = useContext(PageHeaderContext);
if (!ctx) {
throw new Error("usePageHeader must be used within a PageHeaderProvider");
}
return ctx;
}
-15
View File
@@ -1,15 +0,0 @@
import { useContext } from "react";
import {
SystemActionsContext,
type SystemActionsState,
} from "./system-actions-context";
export function useSystemActions(): SystemActionsState {
const ctx = useContext(SystemActionsContext);
if (!ctx) {
throw new Error(
"useSystemActions must be used within a SystemActionsProvider",
);
}
return ctx;
}
-41
View File
@@ -1,41 +0,0 @@
import { useCallback, useState } from "react";
export function useConfirmDelete<TId>({
onDelete,
}: {
onDelete: (id: TId) => Promise<void>;
}) {
const [pendingId, setPendingId] = useState<TId | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const requestDelete = useCallback((id: TId) => {
setPendingId(id);
}, []);
const cancel = useCallback(() => {
if (!isDeleting) setPendingId(null);
}, [isDeleting]);
const confirm = useCallback(async () => {
if (pendingId === null) return;
const id = pendingId;
setIsDeleting(true);
try {
await onDelete(id);
setPendingId(null);
} catch {
// Dialog stays open; caller can surface errors in onDelete before rethrowing
} finally {
setIsDeleting(false);
}
}, [pendingId, onDelete]);
return {
cancel,
confirm,
isDeleting,
isOpen: pendingId !== null,
pendingId,
requestDelete,
} as const;
}
-27
View File
@@ -1,27 +0,0 @@
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { StatusResponse } from "@/lib/api";
const POLL_MS = 10_000;
/**
* Light-weight status poll for the app shell (sidebar). The Status page uses
* its own faster interval; we keep this slower to avoid duplicate load.
*/
export function useSidebarStatus() {
const [status, setStatus] = useState<StatusResponse | null>(null);
useEffect(() => {
const load = () => {
api
.getStatus()
.then(setStatus)
.catch(() => {});
};
load();
const id = setInterval(load, POLL_MS);
return () => clearInterval(id);
}, []);
return status;
}
-15
View File
@@ -1,15 +0,0 @@
import { useCallback, useState } from "react";
export function useToast(duration = 3000) {
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
const showToast = useCallback(
(message: string, type: "success" | "error") => {
setToast({ message, type });
setTimeout(() => setToast(null), duration);
},
[duration],
);
return { toast, showToast };
}
-58
View File
@@ -1,58 +0,0 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
import type { Locale, Translations } from "./types";
import { en } from "./en";
import { zh } from "./zh";
const TRANSLATIONS: Record<Locale, Translations> = { en, zh };
const STORAGE_KEY = "hermes-locale";
function getInitialLocale(): Locale {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "en" || stored === "zh") return stored;
} catch {
// SSR or privacy mode
}
return "en";
}
interface I18nContextValue {
locale: Locale;
setLocale: (l: Locale) => void;
t: Translations;
}
const I18nContext = createContext<I18nContextValue>({
locale: "en",
setLocale: () => {},
t: en,
});
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>(getInitialLocale);
const setLocale = useCallback((l: Locale) => {
setLocaleState(l);
try {
localStorage.setItem(STORAGE_KEY, l);
} catch {
// ignore
}
}, []);
const value: I18nContextValue = {
locale,
setLocale,
t: TRANSLATIONS[locale],
};
return (
<I18nContext.Provider value={value}>
{children}
</I18nContext.Provider>
);
}
export function useI18n() {
return useContext(I18nContext);
}
-429
View File
@@ -1,429 +0,0 @@
import type { Translations } from "./types";
export const en: Translations = {
common: {
save: "Save",
saving: "Saving...",
cancel: "Cancel",
close: "Close",
confirm: "Confirm",
delete: "Delete",
refresh: "Refresh",
retry: "Retry",
search: "Search...",
loading: "Loading...",
create: "Create",
creating: "Creating...",
set: "Set",
replace: "Replace",
clear: "Clear",
live: "Live",
off: "Off",
enabled: "enabled",
disabled: "disabled",
active: "active",
inactive: "inactive",
unknown: "unknown",
untitled: "Untitled",
none: "None",
form: "Form",
noResults: "No results",
of: "of",
page: "Page",
msgs: "msgs",
tools: "tools",
match: "match",
other: "Other",
configured: "configured",
removed: "removed",
failedToToggle: "Failed to toggle",
failedToRemove: "Failed to remove",
failedToReveal: "Failed to reveal",
collapse: "Collapse",
expand: "Expand",
general: "General",
messaging: "Messaging",
pluginLoadFailed:
"Could not load this plugins script. Check the Network tab (dashboard-plugins/…) and the servers plugin path.",
pluginNotRegistered:
"The plugins script did not call register(), or the script errored. Open the browser console for details.",
},
app: {
brand: "Hermes Agent",
brandShort: "HA",
closeNavigation: "Close navigation",
closeModelTools: "Close model and tools",
footer: {
org: "Nous Research",
},
activeSessionsLabel: "Active Sessions:",
gatewayStatusLabel: "Gateway Status:",
gatewayStrip: {
failed: "Start failed",
off: "Off",
running: "Running",
starting: "Starting",
stopped: "Stopped",
},
nav: {
analytics: "Analytics",
chat: "Chat",
config: "Config",
cron: "Cron",
documentation: "Documentation",
keys: "Keys",
logs: "Logs",
models: "Models",
profiles: "profiles : multi agents",
plugins: "Plugins",
sessions: "Sessions",
skills: "Skills",
},
modelToolsSheetSubtitle: "& tools",
modelToolsSheetTitle: "Model",
navigation: "Navigation",
openDocumentation: "Open documentation in a new tab",
openNavigation: "Open navigation",
pluginNavSection: "Plugins",
sessionsActiveCount: "{count} active",
statusOverview: "Status overview",
system: "System",
webUi: "Web UI",
},
status: {
actionFailed: "Action failed",
actionFinished: "Finished",
actions: "Actions",
agent: "Agent",
activeSessions: "Active Sessions",
connected: "Connected",
connectedPlatforms: "Connected Platforms",
disconnected: "Disconnected",
error: "Error",
failed: "Failed",
gateway: "Gateway",
gatewayFailedToStart: "Gateway failed to start",
lastUpdate: "Last update",
noneRunning: "None",
notRunning: "Not running",
pid: "PID",
platformDisconnected: "disconnected",
platformError: "error",
recentSessions: "Recent Sessions",
restartGateway: "Restart Gateway",
restartingGateway: "Restarting gateway…",
running: "Running",
runningRemote: "Running (remote)",
startFailed: "Start failed",
starting: "Starting",
startedInBackground: "Started in background — check logs for progress",
stopped: "Stopped",
updateHermes: "Update Hermes",
updatingHermes: "Updating Hermes…",
waitingForOutput: "Waiting for output…",
},
sessions: {
title: "Sessions",
searchPlaceholder: "Search message content...",
noSessions: "No sessions yet",
noMatch: "No sessions match your search",
startConversation: "Start a conversation to see it here",
noMessages: "No messages",
untitledSession: "Untitled session",
deleteSession: "Delete session",
confirmDeleteTitle: "Delete session?",
confirmDeleteMessage:
"This permanently removes the conversation and all of its messages. This cannot be undone.",
sessionDeleted: "Session deleted",
failedToDelete: "Failed to delete session",
resumeInChat: "Resume in Chat",
previousPage: "Previous page",
nextPage: "Next page",
roles: {
user: "User",
assistant: "Assistant",
system: "System",
tool: "Tool",
},
},
analytics: {
period: "Period:",
totalTokens: "Total Tokens",
totalSessions: "Total Sessions",
apiCalls: "API Calls",
dailyTokenUsage: "Daily Token Usage",
dailyBreakdown: "Daily Breakdown",
perModelBreakdown: "Per-Model Breakdown",
topSkills: "Top Skills",
skill: "Skill",
loads: "Agent Loaded",
edits: "Agent Managed",
lastUsed: "Last Used",
input: "Input",
output: "Output",
total: "Total",
noUsageData: "No usage data for this period",
startSession: "Start a session to see analytics here",
date: "Date",
model: "Model",
tokens: "Tokens",
perDayAvg: "/day avg",
acrossModels: "across {count} models",
inOut: "{input} in / {output} out",
},
models: {
modelsUsed: "Models Used",
estimatedCost: "Est. Cost",
tokens: "tokens",
sessions: "sessions",
avgPerSession: "avg/session",
apiCalls: "API calls",
toolCalls: "tool calls",
noModelsData: "No model usage data for this period",
startSession: "Start a session to see model data here",
},
logs: {
title: "Logs",
autoRefresh: "Auto-refresh",
file: "File",
level: "Level",
component: "Component",
lines: "Lines",
noLogLines: "No log lines found",
},
cron: {
confirmDeleteMessage:
"This removes the job from the schedule. This cannot be undone.",
confirmDeleteTitle: "Delete scheduled job?",
newJob: "New Cron Job",
nameOptional: "Name (optional)",
namePlaceholder: "e.g. Daily summary",
prompt: "Prompt",
promptPlaceholder: "What should the agent do on each run?",
schedule: "Schedule (cron expression)",
schedulePlaceholder: "0 9 * * *",
deliverTo: "Deliver to",
scheduledJobs: "Scheduled Jobs",
noJobs: "No cron jobs configured. Create one above.",
last: "Last",
next: "Next",
pause: "Pause",
resume: "Resume",
triggerNow: "Trigger now",
delivery: {
local: "Local",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
email: "Email",
},
},
profiles: {
newProfile: "New Profile",
name: "Name",
namePlaceholder: "e.g. coder, writer, etc.",
nameRequired: "Name is required",
nameRule:
"Lowercase letters, digits, _ and - only; must start with a letter or digit; up to 64 characters.",
invalidName: "Invalid profile name",
cloneFromDefault: "Clone config from default profile",
allProfiles: "Profiles",
noProfiles: "No profiles found.",
defaultBadge: "default",
hasEnv: "env",
model: "Model",
skills: "Skills",
rename: "Rename",
editSoul: "Edit SOUL.md",
soulSection: "SOUL.md (personality / system prompt)",
soulPlaceholder: "# How this agent should behave…",
saveSoul: "Save SOUL",
soulSaved: "SOUL.md saved",
openInTerminal: "Copy CLI command",
commandCopied: "Copied to clipboard",
copyFailed: "Could not copy",
confirmDeleteTitle: "Delete profile?",
confirmDeleteMessage:
"This permanently deletes profile '{name}' — config, keys, memories, sessions, skills, cron jobs. Cannot be undone.",
created: "Created",
deleted: "Deleted",
renamed: "Renamed",
},
pluginsPage: {
contextEngineLabel: "Context engine",
dashboardSlots: "Dashboard slots",
disableRuntime: "Disable",
enableAfterInstall: "Enable after install",
enableRuntime: "Enable",
forceReinstall: "Force reinstall (delete existing folder first)",
headline:
"Discover, install, enable, and update Hermes plugins (`hermes plugins` parity).",
identifierLabel: "Git URL or owner/repo",
inactive: "inactive",
installBtn: "Install from Git",
installHeading: "Install from GitHub / Git URL",
installHint: "Use owner/repo shorthand or a full https:// or git@ clone URL.",
memoryProviderLabel: "Memory provider",
missingEnvWarn: "Set these in Keys before the plugin can run:",
noDashboardTab: "No dashboard tab",
openTab: "Open",
orphanHeading: "Dashboard-only extensions (no agent plugin.yaml match)",
pluginListHeading: "Installed plugins",
providerDefaults: "built-in / default",
providersHeading: "Runtime provider plugins",
providersHint:
"Writes memory.provider (empty = built-in) and context.engine to config.yaml. Takes effect next session.",
refreshDashboard: "Rescan dashboard extensions",
removeConfirm: "Remove this plugin from ~/.hermes/plugins/?",
removeHint: "Only user-installed plugins under ~/.hermes/plugins can be removed.",
rescanHeading: "SPA plugin registry",
rescanHint: "Rescan after adding files on disk so the dashboard sidebar picks up new manifests.",
runtimeHeading: "Gateway runtime (YAML plugins)",
saveProviders: "Save provider settings",
savedProviders: "Provider settings saved.",
sourceBadge: "Source",
authRequired: "Auth required",
authRequiredHint: "Run this command to authenticate:",
updateGit: "Git pull",
versionBadge: "Version",
showInSidebar: "Show in sidebar",
hideFromSidebar: "Hide from sidebar",
},
skills: {
title: "Skills",
searchPlaceholder: "Search skills and toolsets...",
enabledOf: "{enabled}/{total} enabled",
all: "All",
categories: "Categories",
filters: "Filters",
noSkills: "No skills found. Skills are loaded from ~/.hermes/skills/",
noSkillsMatch: "No skills match your search or filter.",
skillCount: "{count} skill{s}",
resultCount: "{count} result{s}",
noDescription: "No description available.",
toolsets: "Toolsets",
toolsetLabel: "{name} toolset",
noToolsetsMatch: "No toolsets match the search.",
setupNeeded: "Setup needed",
disabledForCli: "Disabled for CLI",
more: "+{count} more",
},
config: {
configPath: "~/.hermes/config.yaml",
filters: "Filters",
sections: "Sections",
exportConfig: "Export config as JSON",
importConfig: "Import config from JSON",
resetDefaults: "Reset to defaults",
resetScopeTooltip: "Reset {scope} to defaults",
confirmResetScope: "Reset all {scope} settings to their defaults? This only updates the form — changes aren't written to config.yaml until you press Save.",
resetScopeToast: "{scope} reset to defaults — review and Save to persist",
rawYaml: "Raw YAML Configuration",
searchResults: "Search Results",
fields: "field{s}",
noFieldsMatch: 'No fields match "{query}"',
configSaved: "Configuration saved",
yamlConfigSaved: "YAML config saved",
failedToSave: "Failed to save",
failedToSaveYaml: "Failed to save YAML",
failedToLoadRaw: "Failed to load raw config",
configImported: "Config imported — review and save",
invalidJson: "Invalid JSON file",
categories: {
general: "General",
agent: "Agent",
terminal: "Terminal",
display: "Display",
delegation: "Delegation",
memory: "Memory",
compression: "Compression",
security: "Security",
browser: "Browser",
voice: "Voice",
tts: "Text-to-Speech",
stt: "Speech-to-Text",
logging: "Logging",
discord: "Discord",
auxiliary: "Auxiliary",
},
},
env: {
changesNote: "Changes are saved to disk immediately. Active sessions pick up new keys automatically.",
confirmClearMessage:
"The stored value for this variable will be removed from your .env file. This cannot be undone from the UI.",
confirmClearTitle: "Clear this key?",
description: "Manage API keys and secrets stored in",
hideAdvanced: "Hide Advanced",
showAdvanced: "Show Advanced",
llmProviders: "LLM Providers",
providersConfigured: "{configured} of {total} providers configured",
getKey: "Get key",
notConfigured: "{count} not configured",
notSet: "Not set",
keysCount: "{count} key{s}",
enterValue: "Enter value...",
replaceCurrentValue: "Replace current value ({preview})",
showValue: "Show real value",
hideValue: "Hide value",
},
oauth: {
title: "Provider Logins (OAuth)",
providerLogins: "Provider Logins (OAuth)",
description: "{connected} of {total} OAuth providers connected. Login flows currently run via the CLI; click Copy command and paste into a terminal to set up.",
connected: "Connected",
expired: "Expired",
notConnected: "Not connected. Run {command} in a terminal.",
runInTerminal: "in a terminal.",
noProviders: "No OAuth-capable providers detected.",
login: "Login",
disconnect: "Disconnect",
managedExternally: "Managed externally",
copied: "Copied ✓",
cli: "CLI",
copyCliCommand: "Copy CLI command (for external / fallback)",
connect: "Connect",
sessionExpires: "Session expires in {time}",
initiatingLogin: "Initiating login flow…",
exchangingCode: "Exchanging code for tokens…",
connectedClosing: "Connected! Closing…",
loginFailed: "Login failed.",
sessionExpired: "Session expired. Click Retry to start a new login.",
reOpenAuth: "Re-open auth page",
reOpenVerification: "Re-open verification page",
submitCode: "Submit code",
pasteCode: "Paste authorization code (with #state suffix is fine)",
waitingAuth: "Waiting for you to authorize in the browser…",
enterCodePrompt: "A new tab opened. Enter this code if prompted:",
pkceStep1: "A new tab opened to claude.ai. Sign in and click Authorize.",
pkceStep2: "Copy the authorization code shown after authorizing.",
pkceStep3: "Paste it below and submit.",
flowLabels: {
pkce: "Browser login (PKCE)",
device_code: "Device code",
external: "External CLI",
},
expiresIn: "expires in {time}",
},
language: {
switchTo: "Switch to Chinese",
},
theme: {
title: "Theme",
switchTheme: "Switch theme",
},
};
-2
View File
@@ -1,2 +0,0 @@
export { I18nProvider, useI18n } from "./context";
export type { Locale, Translations } from "./types";
-436
View File
@@ -1,436 +0,0 @@
export type Locale = "en" | "zh";
export interface Translations {
// ── Common ──
common: {
save: string;
saving: string;
cancel: string;
close: string;
confirm: string;
delete: string;
refresh: string;
retry: string;
search: string;
loading: string;
create: string;
creating: string;
set: string;
replace: string;
clear: string;
live: string;
off: string;
enabled: string;
disabled: string;
active: string;
inactive: string;
unknown: string;
untitled: string;
none: string;
form: string;
noResults: string;
of: string;
page: string;
msgs: string;
tools: string;
match: string;
other: string;
configured: string;
removed: string;
failedToToggle: string;
failedToRemove: string;
failedToReveal: string;
collapse: string;
expand: string;
general: string;
messaging: string;
pluginLoadFailed: string;
pluginNotRegistered: string;
};
// ── App shell ──
app: {
brand: string;
brandShort: string;
closeNavigation: string;
closeModelTools: string;
footer: {
org: string;
};
activeSessionsLabel: string;
gatewayStatusLabel: string;
gatewayStrip: {
failed: string;
off: string;
running: string;
starting: string;
stopped: string;
};
nav: {
analytics: string;
chat: string;
config: string;
cron: string;
documentation: string;
keys: string;
logs: string;
models: string;
profiles: string;
plugins: string;
sessions: string;
skills: string;
};
modelToolsSheetSubtitle: string;
modelToolsSheetTitle: string;
navigation: string;
openDocumentation: string;
openNavigation: string;
pluginNavSection: string;
sessionsActiveCount: string;
statusOverview: string;
system: string;
webUi: string;
};
// ── Status page ──
status: {
actionFailed: string;
actionFinished: string;
actions: string;
agent: string;
connected: string;
connectedPlatforms: string;
disconnected: string;
error: string;
failed: string;
gateway: string;
gatewayFailedToStart: string;
lastUpdate: string;
noneRunning: string;
notRunning: string;
pid: string;
platformDisconnected: string;
platformError: string;
activeSessions: string;
recentSessions: string;
restartGateway: string;
restartingGateway: string;
running: string;
runningRemote: string;
startFailed: string;
starting: string;
startedInBackground: string;
stopped: string;
updateHermes: string;
updatingHermes: string;
waitingForOutput: string;
};
// ── Sessions page ──
sessions: {
title: string;
searchPlaceholder: string;
noSessions: string;
noMatch: string;
startConversation: string;
noMessages: string;
untitledSession: string;
deleteSession: string;
confirmDeleteTitle: string;
confirmDeleteMessage: string;
sessionDeleted: string;
failedToDelete: string;
resumeInChat: string;
previousPage: string;
nextPage: string;
roles: {
user: string;
assistant: string;
system: string;
tool: string;
};
};
// ── Analytics page ──
analytics: {
period: string;
totalTokens: string;
totalSessions: string;
apiCalls: string;
dailyTokenUsage: string;
dailyBreakdown: string;
perModelBreakdown: string;
topSkills: string;
skill: string;
loads: string;
edits: string;
lastUsed: string;
input: string;
output: string;
total: string;
noUsageData: string;
startSession: string;
date: string;
model: string;
tokens: string;
perDayAvg: string;
acrossModels: string;
inOut: string;
};
// ── Models page ──
models: {
modelsUsed: string;
estimatedCost: string;
tokens: string;
sessions: string;
avgPerSession: string;
apiCalls: string;
toolCalls: string;
noModelsData: string;
startSession: string;
};
// ── Logs page ──
logs: {
title: string;
autoRefresh: string;
file: string;
level: string;
component: string;
lines: string;
noLogLines: string;
};
// ── Cron page ──
cron: {
confirmDeleteMessage: string;
confirmDeleteTitle: string;
newJob: string;
nameOptional: string;
namePlaceholder: string;
prompt: string;
promptPlaceholder: string;
schedule: string;
schedulePlaceholder: string;
deliverTo: string;
scheduledJobs: string;
noJobs: string;
last: string;
next: string;
pause: string;
resume: string;
triggerNow: string;
delivery: {
local: string;
telegram: string;
discord: string;
slack: string;
email: string;
};
};
// ── Plugins page ──
pluginsPage: {
contextEngineLabel: string;
dashboardSlots: string;
disableRuntime: string;
enableAfterInstall: string;
enableRuntime: string;
forceReinstall: string;
headline: string;
identifierLabel: string;
inactive: string;
installBtn: string;
installHeading: string;
installHint: string;
memoryProviderLabel: string;
missingEnvWarn: string;
noDashboardTab: string;
openTab: string;
orphanHeading: string;
pluginListHeading: string;
providerDefaults: string;
providersHeading: string;
providersHint: string;
refreshDashboard: string;
removeConfirm: string;
removeHint: string;
rescanHeading: string;
rescanHint: string;
runtimeHeading: string;
saveProviders: string;
savedProviders: string;
sourceBadge: string;
authRequired: string;
authRequiredHint: string;
updateGit: string;
versionBadge: string;
showInSidebar: string;
hideFromSidebar: string;
};
// ── Profiles page ──
profiles: {
newProfile: string;
name: string;
namePlaceholder: string;
nameRequired: string;
nameRule: string;
invalidName: string;
cloneFromDefault: string;
allProfiles: string;
noProfiles: string;
defaultBadge: string;
hasEnv: string;
model: string;
skills: string;
rename: string;
editSoul: string;
soulSection: string;
soulPlaceholder: string;
saveSoul: string;
soulSaved: string;
openInTerminal: string;
commandCopied: string;
copyFailed: string;
confirmDeleteTitle: string;
confirmDeleteMessage: string;
created: string;
deleted: string;
renamed: string;
};
// ── Skills page ──
skills: {
title: string;
searchPlaceholder: string;
enabledOf: string;
all: string;
categories: string;
filters: string;
noSkills: string;
noSkillsMatch: string;
skillCount: string;
resultCount: string;
noDescription: string;
toolsets: string;
toolsetLabel: string;
noToolsetsMatch: string;
setupNeeded: string;
disabledForCli: string;
more: string;
};
// ── Config page ──
config: {
configPath: string;
filters: string;
sections: string;
exportConfig: string;
importConfig: string;
resetDefaults: string;
resetScopeTooltip: string;
confirmResetScope: string;
resetScopeToast: string;
rawYaml: string;
searchResults: string;
fields: string;
noFieldsMatch: string;
configSaved: string;
yamlConfigSaved: string;
failedToSave: string;
failedToSaveYaml: string;
failedToLoadRaw: string;
configImported: string;
invalidJson: string;
categories: {
general: string;
agent: string;
terminal: string;
display: string;
delegation: string;
memory: string;
compression: string;
security: string;
browser: string;
voice: string;
tts: string;
stt: string;
logging: string;
discord: string;
auxiliary: string;
};
};
// ── Env / Keys page ──
env: {
changesNote: string;
confirmClearMessage: string;
confirmClearTitle: string;
description: string;
enterValue: string;
getKey: string;
hideAdvanced: string;
hideValue: string;
keysCount: string;
llmProviders: string;
notConfigured: string;
notSet: string;
providersConfigured: string;
replaceCurrentValue: string;
showAdvanced: string;
showValue: string;
};
// ── OAuth ──
oauth: {
title: string;
providerLogins: string;
description: string;
connected: string;
expired: string;
notConnected: string;
runInTerminal: string;
noProviders: string;
login: string;
disconnect: string;
managedExternally: string;
copied: string;
cli: string;
copyCliCommand: string;
connect: string;
sessionExpires: string;
initiatingLogin: string;
exchangingCode: string;
connectedClosing: string;
loginFailed: string;
sessionExpired: string;
reOpenAuth: string;
reOpenVerification: string;
submitCode: string;
pasteCode: string;
waitingAuth: string;
enterCodePrompt: string;
pkceStep1: string;
pkceStep2: string;
pkceStep3: string;
flowLabels: {
pkce: string;
device_code: string;
external: string;
};
expiresIn: string;
};
// ── Language switcher ──
language: {
switchTo: string;
};
// ── Theme switcher ──
theme: {
title: string;
switchTheme: string;
};
}
-424
View File
@@ -1,424 +0,0 @@
import type { Translations } from "./types";
export const zh: Translations = {
common: {
save: "保存",
saving: "保存中...",
cancel: "取消",
close: "关闭",
confirm: "确认",
delete: "删除",
refresh: "刷新",
retry: "重试",
search: "搜索...",
loading: "加载中...",
create: "创建",
creating: "创建中...",
set: "设置",
replace: "替换",
clear: "清除",
live: "在线",
off: "离线",
enabled: "已启用",
disabled: "已禁用",
active: "活跃",
inactive: "未激活",
unknown: "未知",
untitled: "无标题",
none: "无",
form: "表单",
noResults: "无结果",
of: "/",
page: "页",
msgs: "消息",
tools: "工具",
match: "匹配",
other: "其他",
configured: "已配置",
removed: "已移除",
failedToToggle: "切换失败",
failedToRemove: "移除失败",
failedToReveal: "显示失败",
collapse: "折叠",
expand: "展开",
general: "通用",
messaging: "消息平台",
pluginLoadFailed:
"无法加载此插件的脚本。请检查网络请求(dashboard-plugins/…)以及服务器上的插件路径。",
pluginNotRegistered: "插件脚本未调用 register(),或执行出错。请打开浏览器控制台查看详情。",
},
app: {
brand: "Hermes Agent",
brandShort: "HA",
closeNavigation: "关闭导航",
closeModelTools: "关闭模型与工具",
footer: {
org: "Nous Research",
},
activeSessionsLabel: "活跃会话:",
gatewayStatusLabel: "网关状态:",
gatewayStrip: {
failed: "启动失败",
off: "关闭",
running: "运行中",
starting: "启动中",
stopped: "已停止",
},
nav: {
analytics: "分析",
chat: "对话",
config: "配置",
cron: "定时任务",
documentation: "文档",
keys: "密钥",
logs: "日志",
models: "模型",
profiles: "多Agent配置",
plugins: "插件管理",
sessions: "会话",
skills: "技能",
},
modelToolsSheetSubtitle: "与工具",
modelToolsSheetTitle: "模型",
navigation: "导航",
openDocumentation: "在新标签页中打开文档",
openNavigation: "打开导航",
pluginNavSection: "插件",
sessionsActiveCount: "{count} 个活跃",
statusOverview: "状态概览",
system: "系统",
webUi: "管理面板",
},
status: {
actionFailed: "操作失败",
actionFinished: "已完成",
actions: "操作",
agent: "代理",
activeSessions: "活跃会话",
connected: "已连接",
connectedPlatforms: "已连接平台",
disconnected: "已断开",
error: "错误",
failed: "失败",
gateway: "网关",
gatewayFailedToStart: "网关启动失败",
lastUpdate: "最后更新",
noneRunning: "无",
notRunning: "未运行",
pid: "进程",
platformDisconnected: "已断开",
platformError: "错误",
recentSessions: "最近会话",
restartGateway: "重启网关",
restartingGateway: "正在重启网关…",
running: "运行中",
runningRemote: "运行中(远程)",
startFailed: "启动失败",
starting: "启动中",
startedInBackground: "已在后台启动 — 请查看日志",
stopped: "已停止",
updateHermes: "更新 Hermes",
updatingHermes: "正在更新 Hermes…",
waitingForOutput: "等待输出…",
},
sessions: {
title: "会话",
searchPlaceholder: "搜索消息内容...",
noSessions: "暂无会话",
noMatch: "没有匹配的会话",
startConversation: "开始对话后将显示在此处",
noMessages: "暂无消息",
untitledSession: "无标题会话",
deleteSession: "删除会话",
confirmDeleteTitle: "删除会话?",
confirmDeleteMessage: "此操作将永久删除对话及其所有消息,无法恢复。",
sessionDeleted: "会话已删除",
failedToDelete: "删除会话失败",
resumeInChat: "在对话中继续",
previousPage: "上一页",
nextPage: "下一页",
roles: {
user: "用户",
assistant: "助手",
system: "系统",
tool: "工具",
},
},
analytics: {
period: "时间范围:",
totalTokens: "总 Token 数",
totalSessions: "总会话数",
apiCalls: "API 调用",
dailyTokenUsage: "每日 Token 用量",
dailyBreakdown: "每日明细",
perModelBreakdown: "模型用量明细",
topSkills: "常用技能",
skill: "技能",
loads: "代理加载",
edits: "代理管理",
lastUsed: "最近使用",
input: "输入",
output: "输出",
total: "总计",
noUsageData: "该时间段暂无使用数据",
startSession: "开始会话后将在此显示分析数据",
date: "日期",
model: "模型",
tokens: "Token",
perDayAvg: "/天 平均",
acrossModels: "共 {count} 个模型",
inOut: "输入 {input} / 输出 {output}",
},
models: {
modelsUsed: "使用模型数",
estimatedCost: "预估费用",
tokens: "Token",
sessions: "会话",
avgPerSession: "平均/会话",
apiCalls: "API 调用",
toolCalls: "工具调用",
noModelsData: "该时间段暂无模型使用数据",
startSession: "开始会话后将在此显示模型数据",
},
logs: {
title: "日志",
autoRefresh: "自动刷新",
file: "文件",
level: "级别",
component: "组件",
lines: "行数",
noLogLines: "未找到日志记录",
},
cron: {
confirmDeleteMessage: "将从此计划移除该任务,此操作无法撤销。",
confirmDeleteTitle: "删除定时任务?",
newJob: "新建定时任务",
nameOptional: "名称(可选)",
namePlaceholder: "例如:每日总结",
prompt: "提示词",
promptPlaceholder: "代理每次运行时应执行什么操作?",
schedule: "调度表达式(cron",
schedulePlaceholder: "0 9 * * *",
deliverTo: "投递至",
scheduledJobs: "已调度任务",
noJobs: "暂无定时任务。在上方创建一个。",
last: "上次",
next: "下次",
pause: "暂停",
resume: "恢复",
triggerNow: "立即触发",
delivery: {
local: "本地",
telegram: "Telegram",
discord: "Discord",
slack: "Slack",
email: "邮件",
},
},
profiles: {
newProfile: "新建多Agent配置",
name: "名称",
namePlaceholder: "例如:coder, writer 等",
nameRequired: "名称必填",
nameRule:
"仅允许小写字母、数字、下划线和短横线;首字符必须是字母或数字;最多 64 个字符。",
invalidName: "多Agent配置名称非法",
cloneFromDefault: "从默认多Agent配置克隆配置",
allProfiles: "多Agent配置列表",
noProfiles: "暂无多Agent配置。",
defaultBadge: "默认",
hasEnv: "已配置 env",
model: "模型",
skills: "技能",
rename: "重命名",
editSoul: "编辑 SOUL.md",
soulSection: "SOUL.md(人格 / 系统提示词)",
soulPlaceholder: "# 这个代理应当如何工作……",
saveSoul: "保存 SOUL",
soulSaved: "SOUL.md 已保存",
openInTerminal: "复制 CLI 命令",
commandCopied: "已复制到剪贴板",
copyFailed: "复制失败",
confirmDeleteTitle: "删除多Agent配置?",
confirmDeleteMessage:
"将永久删除多Agent配置 '{name}' — 包括配置、密钥、记忆、会话、技能、定时任务。此操作无法撤销。",
created: "已创建",
deleted: "已删除",
renamed: "已重命名",
},
pluginsPage: {
contextEngineLabel: "上下文引擎",
dashboardSlots: "面板插槽",
disableRuntime: "禁用",
enableAfterInstall: "安装后启用",
enableRuntime: "启用",
forceReinstall: "强制重装(先删除已有目录)",
headline: "发现、安装、启用和更新 Hermes 插件(对齐 `hermes plugins` CLI)。",
identifierLabel: "Git 地址或 owner/repo",
inactive: "未启用",
installBtn: "从 Git 安装",
installHeading: "从 GitHub / Git 地址安装",
installHint: "使用 owner/repo 简写或完整的 https:// / git@ 克隆地址。",
memoryProviderLabel: "记忆提供方",
missingEnvWarn: "在「密钥」页面设置以下变量后再运行插件:",
noDashboardTab: "无仪表盘标签",
openTab: "打开",
orphanHeading: "仅仪表盘扩展(无匹配的 agent plugin.yaml",
pluginListHeading: "已安装插件",
providerDefaults: "内置 / 默认",
providersHeading: "运行时提供方插件",
providersHint:
"写入 config.yamlmemory.provider(留空为内置)、context.engine。下次会话生效。",
refreshDashboard: "重新扫描仪表盘扩展",
removeConfirm: "从 ~/.hermes/plugins/ 删除此插件?",
removeHint: "仅可移除用户安装在 ~/.hermes/plugins 下的插件。",
rescanHeading: "SPA 插件注册表",
rescanHint: "在磁盘新增文件后扫描,使侧边栏载入新 manifest。",
runtimeHeading: "网关运行时(YAML 插件)",
saveProviders: "保存提供方设置",
savedProviders: "提供方设置已保存。",
sourceBadge: "来源",
authRequired: "需要认证",
authRequiredHint: "运行此命令以完成认证:",
updateGit: "git pull",
versionBadge: "版本",
showInSidebar: "在侧边栏显示",
hideFromSidebar: "从侧边栏隐藏",
},
skills: {
title: "技能",
searchPlaceholder: "搜索技能和工具集...",
enabledOf: "已启用 {enabled}/{total}",
all: "全部",
categories: "分类",
filters: "筛选",
noSkills: "未找到技能。技能从 ~/.hermes/skills/ 加载",
noSkillsMatch: "没有匹配的技能。",
skillCount: "{count} 个技能",
resultCount: "{count} 个结果",
noDescription: "暂无描述。",
toolsets: "工具集",
toolsetLabel: "{name} 工具集",
noToolsetsMatch: "没有匹配的工具集。",
setupNeeded: "需要配置",
disabledForCli: "CLI 已禁用",
more: "还有 {count} 个",
},
config: {
configPath: "~/.hermes/config.yaml",
filters: "筛选",
sections: "分类",
exportConfig: "导出配置为 JSON",
importConfig: "从 JSON 导入配置",
resetDefaults: "恢复默认值",
resetScopeTooltip: "将{scope}恢复为默认值",
confirmResetScope: "确定要将{scope}的所有设置恢复为默认值吗?此操作仅更新表单,在按下「保存」按钮前不会写入 config.yaml。",
resetScopeToast: "{scope}已恢复为默认值 — 请检查并保存以生效",
rawYaml: "原始 YAML 配置",
searchResults: "搜索结果",
fields: "个字段",
noFieldsMatch: '没有匹配"{query}"的字段',
configSaved: "配置已保存",
yamlConfigSaved: "YAML 配置已保存",
failedToSave: "保存失败",
failedToSaveYaml: "YAML 保存失败",
failedToLoadRaw: "加载原始配置失败",
configImported: "配置已导入 — 请检查后保存",
invalidJson: "无效的 JSON 文件",
categories: {
general: "通用",
agent: "代理",
terminal: "终端",
display: "显示",
delegation: "委托",
memory: "记忆",
compression: "压缩",
security: "安全",
browser: "浏览器",
voice: "语音",
tts: "文字转语音",
stt: "语音转文字",
logging: "日志",
discord: "Discord",
auxiliary: "辅助",
},
},
env: {
changesNote: "更改会立即保存到磁盘。活跃会话将自动获取新密钥。",
confirmClearMessage: "该变量的已存值将从 .env 文件中删除。无法在此界面撤销。",
confirmClearTitle: "清除此密钥?",
description: "管理存储在以下位置的 API 密钥和凭据",
hideAdvanced: "隐藏高级选项",
showAdvanced: "显示高级选项",
llmProviders: "LLM 提供商",
providersConfigured: "已配置 {configured}/{total} 个提供商",
getKey: "获取密钥",
notConfigured: "{count} 个未配置",
notSet: "未设置",
keysCount: "{count} 个密钥",
enterValue: "输入值...",
replaceCurrentValue: "替换当前值({preview}",
showValue: "显示实际值",
hideValue: "隐藏值",
},
oauth: {
title: "提供商登录(OAuth",
providerLogins: "提供商登录(OAuth",
description: "已连接 {connected}/{total} 个 OAuth 提供商。登录流程目前通过 CLI 运行;点击「复制命令」并粘贴到终端中进行设置。",
connected: "已连接",
expired: "已过期",
notConnected: "未连接。在终端中运行 {command}。",
runInTerminal: "在终端中。",
noProviders: "未检测到支持 OAuth 的提供商。",
login: "登录",
disconnect: "断开连接",
managedExternally: "外部管理",
copied: "已复制 ✓",
cli: "CLI",
copyCliCommand: "复制 CLI 命令(用于外部/备用方式)",
connect: "连接",
sessionExpires: "会话将在 {time} 后过期",
initiatingLogin: "正在启动登录流程…",
exchangingCode: "正在交换令牌…",
connectedClosing: "已连接!正在关闭…",
loginFailed: "登录失败。",
sessionExpired: "会话已过期。点击重试以开始新的登录。",
reOpenAuth: "重新打开授权页面",
reOpenVerification: "重新打开验证页面",
submitCode: "提交代码",
pasteCode: "粘贴授权代码(包含 #state 后缀也可以)",
waitingAuth: "等待您在浏览器中授权…",
enterCodePrompt: "已在新标签页中打开。如果需要,请输入以下代码:",
pkceStep1: "已在新标签页打开 claude.ai。请登录并点击「授权」。",
pkceStep2: "复制授权后显示的授权代码。",
pkceStep3: "将代码粘贴到下方并提交。",
flowLabels: {
pkce: "浏览器登录(PKCE",
device_code: "设备代码",
external: "外部 CLI",
},
expiresIn: "{time}后过期",
},
language: {
switchTo: "切换到英文",
},
theme: {
title: "主题",
switchTheme: "切换主题",
},
};
-229
View File
@@ -1,229 +0,0 @@
@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';
/* ------------------------------------------------------------------ */
/* JetBrains Mono — bundled for the embedded TUI (/chat tab). */
/* Gives the terminal a proper monospace font even on systems where */
/* the user doesn't have one installed locally; xterm.js picks it up */
/* via ChatPage's `fontFamily` option. */
/* Apache-2.0. */
/* ------------------------------------------------------------------ */
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts-terminal/JetBrainsMono-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts-terminal/JetBrainsMono-Bold.woff2') format('woff2');
}
@font-face {
font-family: 'JetBrains Mono';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('/fonts-terminal/JetBrainsMono-Italic.woff2') format('woff2');
}
/* ------------------------------------------------------------------ */
/* 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. */
/* ------------------------------------------------------------------ */
: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;
/* Consumed by <Backdrop />; also theme-switchable. */
--warm-glow: rgba(255, 189, 56, 0.35);
--noise-opacity-mul: 1;
/* Typography tokens — rewritten by ThemeProvider. Defaults match the
system stack so themes that don't override look native. */
--theme-font-sans: system-ui, -apple-system, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
--theme-font-mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo,
Consolas, monospace;
--theme-font-display: var(--theme-font-sans);
--theme-base-size: 15px;
--theme-line-height: 1.55;
--theme-letter-spacing: 0;
/* Layout tokens. */
--radius: 0.5rem;
--theme-radius: 0.5rem;
--theme-spacing-mul: 1;
--theme-density: comfortable;
}
/* Theme tokens cascade into the document root so every descendant inherits
the font stack, base size, and letter spacing without explicit calls. */
html {
font-family: var(--theme-font-sans);
font-size: var(--theme-base-size);
line-height: var(--theme-line-height);
letter-spacing: var(--theme-letter-spacing);
height: 100dvh;
max-height: 100dvh;
overflow: hidden;
}
body {
font-family: var(--theme-font-sans);
min-height: 0;
height: 100%;
margin: 0;
overflow: hidden;
}
code, kbd, pre, samp, .font-mono, .font-mono-ui {
font-family: var(--theme-font-mono);
}
/* Density: scale the shadcn spacing utilities via a multiplier. The DS
components use `p-N` / `gap-N` / `space-*` classes which resolve against
Tailwind's spacing scale; multiplying `--spacing` at :root scales them
all proportionally in Tailwind v4. */
@theme inline {
--spacing: calc(0.25rem * var(--theme-spacing-mul, 1));
}
#root {
min-height: 0;
height: 100%;
max-height: 100%;
overflow: hidden;
}
/* 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: #ffffff;
--color-success: #4ade80;
--color-warning: #ffbd38;
--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);
--radius-sm: calc(var(--theme-radius) - 4px);
--radius-md: calc(var(--theme-radius) - 2px);
--radius-lg: var(--theme-radius);
--radius-xl: calc(var(--theme-radius) + 4px);
}
/* Toast animations used by `components/Toast.tsx`. */
@keyframes toast-in {
from { opacity: 0; transform: translateX(16px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes toast-out {
from { opacity: 1; transform: translateX(0); }
to { opacity: 0; transform: translateX(16px); }
}
/* Generic fade + dialog entrance used by popovers and confirm dialogs. */
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes dialog-in {
from { opacity: 0; transform: translateY(4px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* 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;
}
/* System UI-monospace stack — distinct from `font-courier` (Courier
Prime), used for dense data readouts where the display font would
break the grid. Routes through the theme's mono stack so themes
with a different monospace (JetBrains Mono, IBM Plex Mono, etc.)
still apply here. */
.font-mono-ui {
font-family: var(--theme-font-mono);
}
/* Subtle grain overlay for badges. */
.grain {
position: relative;
}
.grain::after {
content: '';
position: absolute;
inset: 0;
opacity: 0.12;
pointer-events: none;
background: repeating-conic-gradient(currentColor 0% 25%, #0000 0% 50%) 0 0 /
2px 2px;
}
/* When a theme provides `assets.bg`, the backdrop's <div> renders it as
a CSS background; the default filler <img> is hidden to prevent
double-compositing. Unset → initial → empty, so the :not() selector
matches and the default image stays visible. */
:root:not([style*="--theme-asset-bg:"]) .theme-default-filler {
display: block;
}
:root[style*="--theme-asset-bg:"] .theme-default-filler {
display: none;
}
-784
View File
@@ -1,784 +0,0 @@
const BASE = "";
import type { DashboardTheme } from "@/themes/types";
// Ephemeral session token for protected endpoints.
// Injected into index.html by the server — never fetched via API.
declare global {
interface Window {
__HERMES_SESSION_TOKEN__?: string;
}
}
let _sessionToken: string | null = null;
const SESSION_HEADER = "X-Hermes-Session-Token";
function setSessionHeader(headers: Headers, token: string): void {
if (!headers.has(SESSION_HEADER)) {
headers.set(SESSION_HEADER, token);
}
}
export async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
// Inject the session token into all /api/ requests.
const headers = new Headers(init?.headers);
const token = window.__HERMES_SESSION_TOKEN__;
if (token) {
setSessionHeader(headers, token);
}
const res = await fetch(`${BASE}${url}`, { ...init, headers });
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(`${res.status}: ${text}`);
}
return res.json();
}
async function getSessionToken(): Promise<string> {
if (_sessionToken) return _sessionToken;
const injected = window.__HERMES_SESSION_TOKEN__;
if (injected) {
_sessionToken = injected;
return _sessionToken;
}
throw new Error("Session token not available — page must be served by the Hermes dashboard server");
}
export const api = {
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
getSessions: (limit = 20, offset = 0) =>
fetchJSON<PaginatedSessions>(`/api/sessions?limit=${limit}&offset=${offset}`),
getSessionMessages: (id: string) =>
fetchJSON<SessionMessagesResponse>(`/api/sessions/${encodeURIComponent(id)}/messages`),
deleteSession: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
method: "DELETE",
}),
getLogs: (params: { file?: string; lines?: number; level?: string; component?: string }) => {
const qs = new URLSearchParams();
if (params.file) qs.set("file", params.file);
if (params.lines) qs.set("lines", String(params.lines));
if (params.level && params.level !== "ALL") qs.set("level", params.level);
if (params.component && params.component !== "all") qs.set("component", params.component);
return fetchJSON<LogsResponse>(`/api/logs?${qs.toString()}`);
},
getAnalytics: (days: number) =>
fetchJSON<AnalyticsResponse>(`/api/analytics/usage?days=${days}`),
getModelsAnalytics: (days: number) =>
fetchJSON<ModelsAnalyticsResponse>(`/api/analytics/models?days=${days}`),
getConfig: () => fetchJSON<Record<string, unknown>>("/api/config"),
getDefaults: () => fetchJSON<Record<string, unknown>>("/api/config/defaults"),
getSchema: () => fetchJSON<{ fields: Record<string, unknown>; category_order: string[] }>("/api/config/schema"),
getModelInfo: () => fetchJSON<ModelInfoResponse>("/api/model/info"),
getModelOptions: () => fetchJSON<ModelOptionsResponse>("/api/model/options"),
getAuxiliaryModels: () => fetchJSON<AuxiliaryModelsResponse>("/api/model/auxiliary"),
setModelAssignment: (body: ModelAssignmentRequest) =>
fetchJSON<ModelAssignmentResponse>("/api/model/set", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
saveConfig: (config: Record<string, unknown>) =>
fetchJSON<{ ok: boolean }>("/api/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ config }),
}),
getConfigRaw: () => fetchJSON<{ yaml: string }>("/api/config/raw"),
saveConfigRaw: (yaml_text: string) =>
fetchJSON<{ ok: boolean }>("/api/config/raw", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ yaml_text }),
}),
getEnvVars: () => fetchJSON<Record<string, EnvVarInfo>>("/api/env"),
setEnvVar: (key: string, value: string) =>
fetchJSON<{ ok: boolean }>("/api/env", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key, value }),
}),
deleteEnvVar: (key: string) =>
fetchJSON<{ ok: boolean }>("/api/env", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ key }),
}),
revealEnvVar: async (key: string) => {
const token = await getSessionToken();
return fetchJSON<{ key: string; value: string }>("/api/env/reveal", {
method: "POST",
headers: {
"Content-Type": "application/json",
[SESSION_HEADER]: token,
},
body: JSON.stringify({ key }),
});
},
// Cron jobs
getCronJobs: () => fetchJSON<CronJob[]>("/api/cron/jobs"),
createCronJob: (job: { prompt: string; schedule: string; name?: string; deliver?: string }) =>
fetchJSON<CronJob>("/api/cron/jobs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(job),
}),
pauseCronJob: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/pause`, { method: "POST" }),
resumeCronJob: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/resume`, { method: "POST" }),
triggerCronJob: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}/trigger`, { method: "POST" }),
deleteCronJob: (id: string) =>
fetchJSON<{ ok: boolean }>(`/api/cron/jobs/${id}`, { method: "DELETE" }),
// Profiles (minimal)
getProfiles: () =>
fetchJSON<{ profiles: ProfileInfo[] }>("/api/profiles"),
createProfile: (body: { name: string; clone_from_default: boolean }) =>
fetchJSON<{ ok: boolean; name: string; path: string }>("/api/profiles", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
renameProfile: (name: string, newName: string) =>
fetchJSON<{ ok: boolean; name: string; path: string }>(
`/api/profiles/${encodeURIComponent(name)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ new_name: newName }),
},
),
deleteProfile: (name: string) =>
fetchJSON<{ ok: boolean }>(
`/api/profiles/${encodeURIComponent(name)}`,
{ method: "DELETE" },
),
getProfileSetupCommand: (name: string) =>
fetchJSON<{ command: string }>(
`/api/profiles/${encodeURIComponent(name)}/setup-command`,
),
getProfileSoul: (name: string) =>
fetchJSON<{ content: string; exists: boolean }>(
`/api/profiles/${encodeURIComponent(name)}/soul`,
),
updateProfileSoul: (name: string, content: string) =>
fetchJSON<{ ok: boolean }>(
`/api/profiles/${encodeURIComponent(name)}/soul`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
},
),
// Skills & Toolsets
getSkills: () => fetchJSON<SkillInfo[]>("/api/skills"),
toggleSkill: (name: string, enabled: boolean) =>
fetchJSON<{ ok: boolean }>("/api/skills/toggle", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, enabled }),
}),
getToolsets: () => fetchJSON<ToolsetInfo[]>("/api/tools/toolsets"),
// Session search (FTS5)
searchSessions: (q: string) =>
fetchJSON<SessionSearchResponse>(`/api/sessions/search?q=${encodeURIComponent(q)}`),
// OAuth provider management
getOAuthProviders: () =>
fetchJSON<OAuthProvidersResponse>("/api/providers/oauth"),
disconnectOAuthProvider: async (providerId: string) => {
const token = await getSessionToken();
return fetchJSON<{ ok: boolean; provider: string }>(
`/api/providers/oauth/${encodeURIComponent(providerId)}`,
{
method: "DELETE",
headers: { [SESSION_HEADER]: token },
},
);
},
startOAuthLogin: async (providerId: string) => {
const token = await getSessionToken();
return fetchJSON<OAuthStartResponse>(
`/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
[SESSION_HEADER]: token,
},
body: "{}",
},
);
},
submitOAuthCode: async (providerId: string, sessionId: string, code: string) => {
const token = await getSessionToken();
return fetchJSON<OAuthSubmitResponse>(
`/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
[SESSION_HEADER]: token,
},
body: JSON.stringify({ session_id: sessionId, code }),
},
);
},
pollOAuthSession: (providerId: string, sessionId: string) =>
fetchJSON<OAuthPollResponse>(
`/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`,
),
cancelOAuthSession: async (sessionId: string) => {
const token = await getSessionToken();
return fetchJSON<{ ok: boolean }>(
`/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
{
method: "DELETE",
headers: { [SESSION_HEADER]: token },
},
);
},
// Gateway / update actions
restartGateway: () =>
fetchJSON<ActionResponse>("/api/gateway/restart", { method: "POST" }),
updateHermes: () =>
fetchJSON<ActionResponse>("/api/hermes/update", { method: "POST" }),
getActionStatus: (name: string, lines = 200) =>
fetchJSON<ActionStatusResponse>(
`/api/actions/${encodeURIComponent(name)}/status?lines=${lines}`,
),
// Dashboard plugins
getPlugins: () =>
fetchJSON<PluginManifestResponse[]>("/api/dashboard/plugins"),
rescanPlugins: () =>
fetchJSON<{ ok: boolean; count: number }>("/api/dashboard/plugins/rescan"),
getPluginsHub: () => fetchJSON<PluginsHubResponse>("/api/dashboard/plugins/hub"),
installAgentPlugin: (body: AgentPluginInstallRequest) =>
fetchJSON<AgentPluginInstallResponse>("/api/dashboard/agent-plugins/install", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...body }),
}),
enableAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/enable`,
{ method: "POST" },
),
disableAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string; unchanged?: boolean }>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/disable`,
{ method: "POST" },
),
updateAgentPlugin: (name: string) =>
fetchJSON<AgentPluginUpdateResponse>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}/update`,
{ method: "POST" },
),
removeAgentPlugin: (name: string) =>
fetchJSON<{ ok: boolean; name: string }>(
`/api/dashboard/agent-plugins/${encodeURIComponent(name)}`,
{ method: "DELETE" },
),
savePluginProviders: (body: PluginProvidersPutRequest) =>
fetchJSON<{ ok: boolean }>("/api/dashboard/plugin-providers", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
setPluginVisibility: (name: string, hidden: boolean) =>
fetchJSON<{ ok: boolean; name: string; hidden: boolean }>(
`/api/dashboard/plugins/${encodeURIComponent(name)}/visibility`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ hidden }),
},
),
// Dashboard themes
getThemes: () =>
fetchJSON<DashboardThemesResponse>("/api/dashboard/themes"),
setTheme: (name: string) =>
fetchJSON<{ ok: boolean; theme: string }>("/api/dashboard/theme", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
}),
};
export interface ActionResponse {
name: string;
ok: boolean;
pid: number;
}
export interface ActionStatusResponse {
exit_code: number | null;
lines: string[];
name: string;
pid: number | null;
running: boolean;
}
export interface PlatformStatus {
error_code?: string;
error_message?: string;
state: string;
updated_at: string;
}
export interface StatusResponse {
active_sessions: number;
config_path: string;
config_version: number;
env_path: string;
gateway_exit_reason: string | null;
gateway_health_url: string | null;
gateway_pid: number | null;
gateway_platforms: Record<string, PlatformStatus>;
gateway_running: boolean;
gateway_state: string | null;
gateway_updated_at: string | null;
hermes_home: string;
latest_config_version: number;
release_date: string;
version: string;
}
export interface SessionInfo {
id: string;
source: string | null;
model: string | null;
title: string | null;
started_at: number;
ended_at: number | null;
last_active: number;
is_active: boolean;
message_count: number;
tool_call_count: number;
input_tokens: number;
output_tokens: number;
preview: string | null;
}
export interface PaginatedSessions {
sessions: SessionInfo[];
total: number;
limit: number;
offset: number;
}
export interface EnvVarInfo {
is_set: boolean;
redacted_value: string | null;
description: string;
url: string | null;
category: string;
is_password: boolean;
tools: string[];
advanced: boolean;
}
export interface SessionMessage {
role: "user" | "assistant" | "system" | "tool";
content: string | null;
tool_calls?: Array<{
id: string;
function: { name: string; arguments: string };
}>;
tool_name?: string;
tool_call_id?: string;
timestamp?: number;
}
export interface SessionMessagesResponse {
session_id: string;
messages: SessionMessage[];
}
export interface LogsResponse {
file: string;
lines: string[];
}
export interface AnalyticsDailyEntry {
day: string;
input_tokens: number;
output_tokens: number;
cache_read_tokens: number;
reasoning_tokens: number;
estimated_cost: number;
actual_cost: number;
sessions: number;
api_calls: number;
}
export interface AnalyticsModelEntry {
model: string;
input_tokens: number;
output_tokens: number;
estimated_cost: number;
sessions: number;
api_calls: number;
}
export interface AnalyticsSkillEntry {
skill: string;
view_count: number;
manage_count: number;
total_count: number;
percentage: number;
last_used_at: number | null;
}
export interface AnalyticsSkillsSummary {
total_skill_loads: number;
total_skill_edits: number;
total_skill_actions: number;
distinct_skills_used: number;
}
export interface AnalyticsResponse {
daily: AnalyticsDailyEntry[];
by_model: AnalyticsModelEntry[];
totals: {
total_input: number;
total_output: number;
total_cache_read: number;
total_reasoning: number;
total_estimated_cost: number;
total_actual_cost: number;
total_sessions: number;
total_api_calls: number;
};
skills: {
summary: AnalyticsSkillsSummary;
top_skills: AnalyticsSkillEntry[];
};
}
export interface ProfileInfo {
name: string;
path: string;
is_default: boolean;
model: string | null;
provider: string | null;
has_env: boolean;
skill_count: number;
}
export interface ModelsAnalyticsModelEntry {
model: string;
provider: string;
input_tokens: number;
output_tokens: number;
cache_read_tokens: number;
reasoning_tokens: number;
estimated_cost: number;
actual_cost: number;
sessions: number;
api_calls: number;
tool_calls: number;
last_used_at: number;
avg_tokens_per_session: number;
capabilities: {
supports_tools?: boolean;
supports_vision?: boolean;
supports_reasoning?: boolean;
context_window?: number;
max_output_tokens?: number;
model_family?: string;
};
}
export interface ModelsAnalyticsResponse {
models: ModelsAnalyticsModelEntry[];
totals: {
distinct_models: number;
total_input: number;
total_output: number;
total_cache_read: number;
total_reasoning: number;
total_estimated_cost: number;
total_actual_cost: number;
total_sessions: number;
total_api_calls: number;
};
period_days: number;
}
export interface CronJob {
id: string;
name?: string;
prompt: string;
schedule: { kind: string; expr: string; display: string };
schedule_display: string;
enabled: boolean;
state: string;
deliver?: string;
last_run_at?: string | null;
next_run_at?: string | null;
last_error?: string | null;
}
export interface SkillInfo {
name: string;
description: string;
category: string;
enabled: boolean;
}
export interface ToolsetInfo {
name: string;
label: string;
description: string;
enabled: boolean;
configured: boolean;
tools: string[];
}
export interface SessionSearchResult {
session_id: string;
snippet: string;
role: string | null;
source: string | null;
model: string | null;
session_started: number | null;
}
export interface SessionSearchResponse {
results: SessionSearchResult[];
}
// ── Model info types ──────────────────────────────────────────────────
export interface ModelInfoResponse {
model: string;
provider: string;
auto_context_length: number;
config_context_length: number;
effective_context_length: number;
capabilities: {
supports_tools?: boolean;
supports_vision?: boolean;
supports_reasoning?: boolean;
context_window?: number;
max_output_tokens?: number;
model_family?: string;
};
}
// ── Model options / assignment types ──────────────────────────────────
export interface ModelOptionProvider {
name: string;
slug: string;
models?: string[];
total_models?: number;
is_current?: boolean;
is_user_defined?: boolean;
source?: string;
warning?: string;
}
export interface ModelOptionsResponse {
model?: string;
provider?: string;
providers?: ModelOptionProvider[];
}
export interface AuxiliaryTaskAssignment {
task: string;
provider: string;
model: string;
base_url: string;
}
export interface AuxiliaryModelsResponse {
tasks: AuxiliaryTaskAssignment[];
main: { provider: string; model: string };
}
export interface ModelAssignmentRequest {
scope: "main" | "auxiliary";
provider: string;
model: string;
/** For auxiliary: task slot name, "" for all, "__reset__" to reset all. */
task?: string;
}
export interface ModelAssignmentResponse {
ok: boolean;
scope?: string;
provider?: string;
model?: string;
tasks?: string[];
reset?: boolean;
}
// ── OAuth provider types ────────────────────────────────────────────────
export interface OAuthProviderStatus {
logged_in: boolean;
source?: string | null;
source_label?: string | null;
token_preview?: string | null;
expires_at?: string | null;
has_refresh_token?: boolean;
last_refresh?: string | null;
error?: string;
}
export interface OAuthProvider {
id: string;
name: string;
/** "pkce" (browser redirect + paste code), "device_code" (show code + URL),
* or "external" (delegated to a separate CLI like Claude Code or Qwen). */
flow: "pkce" | "device_code" | "external";
cli_command: string;
docs_url: string;
status: OAuthProviderStatus;
}
export interface OAuthProvidersResponse {
providers: OAuthProvider[];
}
/** Discriminated union — the shape of /start depends on the flow. */
export type OAuthStartResponse =
| {
session_id: string;
flow: "pkce";
auth_url: string;
expires_in: number;
}
| {
session_id: string;
flow: "device_code";
user_code: string;
verification_url: string;
expires_in: number;
poll_interval: number;
};
export interface OAuthSubmitResponse {
ok: boolean;
status: "approved" | "error";
message?: string;
}
export interface OAuthPollResponse {
session_id: string;
status: "pending" | "approved" | "denied" | "expired" | "error";
error_message?: string | null;
expires_at?: number | null;
}
// ── Dashboard theme types ──────────────────────────────────────────────
export interface DashboardThemeSummary {
description: string;
label: string;
name: string;
/** Full theme definition for user themes; undefined for built-ins
* (which the frontend already has locally). */
definition?: DashboardTheme;
}
export interface DashboardThemesResponse {
active: string;
themes: DashboardThemeSummary[];
}
// ── Dashboard plugin types ─────────────────────────────────────────────
export interface PluginManifestResponse {
name: string;
label: string;
description: string;
icon: string;
version: string;
tab: {
path: string;
position?: string;
override?: string;
hidden?: boolean;
};
slots?: string[];
entry: string;
css?: string | null;
has_api: boolean;
source: string;
}
export interface HubAgentPluginRow {
name: string;
version: string;
description: string;
source: string;
runtime_status: "disabled" | "enabled" | "inactive";
has_dashboard_manifest: boolean;
dashboard_manifest: PluginManifestResponse | null;
path: string;
can_remove: boolean;
can_update_git: boolean;
auth_required: boolean;
auth_command: string;
user_hidden: boolean;
}
export interface PluginsHubProviders {
memory_provider: string;
memory_options: Array<{ name: string; description: string }>;
context_engine: string;
context_options: Array<{ name: string; description: string }>;
}
export interface PluginsHubResponse {
plugins: HubAgentPluginRow[];
orphan_dashboard_plugins: PluginManifestResponse[];
providers: PluginsHubProviders;
}
export interface AgentPluginInstallRequest {
identifier: string;
force?: boolean;
enable?: boolean;
}
export interface AgentPluginInstallResponse {
ok: boolean;
plugin_name?: string;
warnings?: string[];
missing_env?: string[];
after_install_path?: string | null;
enabled?: boolean;
error?: string;
}
export interface AgentPluginUpdateResponse {
ok: boolean;
name?: string;
output?: string;
unchanged?: boolean;
error?: string;
}
export interface PluginProvidersPutRequest {
memory_provider?: string;
context_engine?: string;
}
-15
View File
@@ -1,15 +0,0 @@
declare global {
interface Window {
/** Set true by the server only for `hermes dashboard --tui` (or HERMES_DASHBOARD_TUI=1). */
__HERMES_DASHBOARD_EMBEDDED_CHAT__?: boolean;
/** @deprecated Older injected name; treated as on when true. */
__HERMES_DASHBOARD_TUI__?: boolean;
}
}
/** True only when the dashboard was started with embedded TUI Chat (`hermes dashboard --tui`). */
export function isDashboardEmbeddedChatEnabled(): boolean {
if (typeof window === "undefined") return false;
if (window.__HERMES_DASHBOARD_EMBEDDED_CHAT__ === true) return true;
return window.__HERMES_DASHBOARD_TUI__ === true;
}
-9
View File
@@ -1,9 +0,0 @@
/**
* Format a token count as a human-readable string (e.g. 1M, 128K, 4096).
* Strips trailing ".0" for clean round numbers.
*/
export function formatTokenCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n % 1_000 === 0 ? 0 : 1)}K`;
return String(n);
}
-235
View File
@@ -1,235 +0,0 @@
/**
* Browser WebSocket client for the tui_gateway JSON-RPC protocol.
*
* Speaks the exact same newline-delimited JSON-RPC dialect that the Ink TUI
* drives over stdio. The server-side transport abstraction
* (tui_gateway/transport.py + ws.py) routes the same dispatcher's writes
* onto either stdout or a WebSocket depending on how the client connected.
*
* const gw = new GatewayClient()
* await gw.connect()
* const { session_id } = await gw.request<{ session_id: string }>("session.create")
* gw.on("message.delta", (ev) => console.log(ev.payload?.text))
* await gw.request("prompt.submit", { session_id, text: "hi" })
*/
export type GatewayEventName =
| "gateway.ready"
| "session.info"
| "message.start"
| "message.delta"
| "message.complete"
| "thinking.delta"
| "reasoning.delta"
| "reasoning.available"
| "status.update"
| "tool.start"
| "tool.progress"
| "tool.complete"
| "tool.generating"
| "clarify.request"
| "approval.request"
| "sudo.request"
| "secret.request"
| "background.complete"
| "error"
| "skin.changed"
| (string & {});
export interface GatewayEvent<P = unknown> {
type: GatewayEventName;
session_id?: string;
payload?: P;
}
export type ConnectionState =
| "idle"
| "connecting"
| "open"
| "closed"
| "error";
interface Pending {
resolve: (v: unknown) => void;
reject: (e: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
/** Wildcard listener key: subscribe to every event regardless of type. */
const ANY = "*";
export class GatewayClient {
private ws: WebSocket | null = null;
private reqId = 0;
private pending = new Map<string, Pending>();
private listeners = new Map<string, Set<(ev: GatewayEvent) => void>>();
private _state: ConnectionState = "idle";
private stateListeners = new Set<(s: ConnectionState) => void>();
get state(): ConnectionState {
return this._state;
}
private setState(s: ConnectionState) {
if (this._state === s) return;
this._state = s;
for (const cb of this.stateListeners) cb(s);
}
onState(cb: (s: ConnectionState) => void): () => void {
this.stateListeners.add(cb);
cb(this._state);
return () => this.stateListeners.delete(cb);
}
/** Subscribe to a specific event type. Returns an unsubscribe function. */
on<P = unknown>(
type: GatewayEventName,
cb: (ev: GatewayEvent<P>) => void,
): () => void {
let set = this.listeners.get(type);
if (!set) {
set = new Set();
this.listeners.set(type, set);
}
set.add(cb as (ev: GatewayEvent) => void);
return () => set!.delete(cb as (ev: GatewayEvent) => void);
}
/** Subscribe to every event (fires after type-specific listeners). */
onAny(cb: (ev: GatewayEvent) => void): () => void {
return this.on(ANY as GatewayEventName, cb);
}
async connect(token?: string): Promise<void> {
if (this._state === "open" || this._state === "connecting") return;
this.setState("connecting");
const resolved = token ?? window.__HERMES_SESSION_TOKEN__ ?? "";
if (!resolved) {
this.setState("error");
throw new Error(
"Session token not available — page must be served by the Hermes dashboard",
);
}
const scheme = location.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(
`${scheme}//${location.host}/api/ws?token=${encodeURIComponent(resolved)}`,
);
this.ws = ws;
// Register message + close BEFORE awaiting open — the server emits
// `gateway.ready` immediately after accept, so a listener attached
// after the open promise resolves can race past it and drop the
// initial skin payload.
ws.addEventListener("message", (ev) => {
try {
this.dispatch(JSON.parse(ev.data));
} catch {
/* malformed frame — ignore */
}
});
ws.addEventListener("close", () => {
this.setState("closed");
this.rejectAllPending(new Error("WebSocket closed"));
});
await new Promise<void>((resolve, reject) => {
const onOpen = () => {
ws.removeEventListener("error", onError);
this.setState("open");
resolve();
};
const onError = () => {
ws.removeEventListener("open", onOpen);
this.setState("error");
reject(new Error("WebSocket connection failed"));
};
ws.addEventListener("open", onOpen, { once: true });
ws.addEventListener("error", onError, { once: true });
});
}
close() {
this.ws?.close();
this.ws = null;
}
private dispatch(msg: Record<string, unknown>) {
const id = msg.id as string | undefined;
if (id !== undefined && this.pending.has(id)) {
const p = this.pending.get(id)!;
this.pending.delete(id);
clearTimeout(p.timer);
const err = msg.error as { message?: string } | undefined;
if (err) p.reject(new Error(err.message ?? "request failed"));
else p.resolve(msg.result);
return;
}
if (msg.method !== "event") return;
const params = (msg.params ?? {}) as GatewayEvent;
if (typeof params.type !== "string") return;
for (const cb of this.listeners.get(params.type) ?? []) cb(params);
for (const cb of this.listeners.get(ANY) ?? []) cb(params);
}
private rejectAllPending(err: Error) {
for (const p of this.pending.values()) {
clearTimeout(p.timer);
p.reject(err);
}
this.pending.clear();
}
/** Send a JSON-RPC request. Rejects on error response or timeout. */
request<T = unknown>(
method: string,
params: Record<string, unknown> = {},
timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
): Promise<T> {
if (!this.ws || this._state !== "open") {
return Promise.reject(
new Error(`gateway not connected (state=${this._state})`),
);
}
const id = `w${++this.reqId}`;
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
if (this.pending.delete(id)) {
reject(new Error(`request timed out: ${method}`));
}
}, timeoutMs);
this.pending.set(id, {
resolve: (v) => resolve(v as T),
reject,
timer,
});
try {
this.ws!.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }));
} catch (e) {
clearTimeout(timer);
this.pending.delete(id);
reject(e instanceof Error ? e : new Error(String(e)));
}
});
}
}
declare global {
interface Window {
__HERMES_SESSION_TOKEN__?: string;
}
}
-23
View File
@@ -1,23 +0,0 @@
export function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
const parts = path.split(".");
let cur: unknown = obj;
for (const p of parts) {
if (cur == null || typeof cur !== "object") return undefined;
cur = (cur as Record<string, unknown>)[p];
}
return cur;
}
export function setNestedValue(obj: Record<string, unknown>, path: string, value: unknown): Record<string, unknown> {
const clone = structuredClone(obj);
const parts = path.split(".");
let cur: Record<string, unknown> = clone;
for (let i = 0; i < parts.length - 1; i++) {
if (cur[parts[i]] == null || typeof cur[parts[i]] !== "object") {
cur[parts[i]] = {};
}
cur = cur[parts[i]] as Record<string, unknown>;
}
cur[parts[parts.length - 1]] = value;
return clone;
}
-34
View File
@@ -1,34 +0,0 @@
import type { Translations } from "@/i18n/types";
const BUILTIN: Record<string, keyof Translations["app"]["nav"]> = {
"/chat": "chat",
"/sessions": "sessions",
"/analytics": "analytics",
"/logs": "logs",
"/cron": "cron",
"/skills": "skills",
"/plugins": "plugins",
"/config": "config",
"/env": "keys",
"/docs": "documentation",
};
export function resolvePageTitle(
pathname: string,
t: Translations,
pluginTabs: { path: string; label: string }[],
): string {
const normalized = pathname.replace(/\/$/, "") || "/";
if (normalized === "/") {
return t.app.nav.sessions;
}
const plugin = pluginTabs.find((p) => p.path === normalized);
if (plugin) {
return plugin.label;
}
const key = BUILTIN[normalized];
if (key) {
return t.app.nav[key];
}
return t.app.webUi;
}
-163
View File
@@ -1,163 +0,0 @@
/**
* Slash command execution pipeline for the web chat.
*
* Mirrors the Ink TUI's createSlashHandler.ts:
*
* 1. Parse the command into `name` + `arg`.
* 2. Try `slash.exec` — covers every registry-backed command the terminal
* UI knows about (/help, /resume, /compact, /model, …). Output is
* rendered into the transcript.
* 3. If `slash.exec` errors (command rejected, unknown, or needs client
* behaviour), fall back to `command.dispatch` which returns a typed
* directive: `exec` | `plugin` | `alias` | `skill` | `send`.
* 4. Each directive is dispatched to the appropriate callback.
*
* Keeping the pipeline here (instead of inline in ChatPage) lets future
* clients (SwiftUI, Android) implement the same logic by reading the same
* contract.
*/
import type { GatewayClient } from "@/lib/gatewayClient";
export interface SlashExecResponse {
output?: string;
warning?: string;
}
export type CommandDispatchResponse =
| { type: "exec" | "plugin"; output?: string }
| { type: "alias"; target: string }
| { type: "skill"; name: string; message?: string }
| { type: "send"; message: string };
export interface SlashExecCallbacks {
/** Render a transcript system message. */
sys(text: string): void;
/** Submit a user message to the agent (prompt.submit). */
send(message: string): Promise<void> | void;
}
export interface SlashExecOptions {
/** Raw command including the leading slash (e.g. "/model opus-4.6"). */
command: string;
/** Session id. If empty the call is still issued — some commands are session-less. */
sessionId: string;
gw: GatewayClient;
callbacks: SlashExecCallbacks;
}
export type SlashExecResult = "done" | "sent" | "error";
/**
* Run a slash command. Returns the terminal state so callers can decide
* whether to clear the composer, queue retries, etc.
*/
export async function executeSlash({
command,
sessionId,
gw,
callbacks: { sys, send },
}: SlashExecOptions): Promise<SlashExecResult> {
const { name, arg } = parseSlash(command);
if (!name) {
sys("empty slash command");
return "error";
}
// Primary dispatcher.
try {
const r = await gw.request<SlashExecResponse>("slash.exec", {
command: command.replace(/^\/+/, ""),
session_id: sessionId,
});
const body = r?.output || `/${name}: no output`;
sys(r?.warning ? `warning: ${r.warning}\n${body}` : body);
return "done";
} catch {
/* fall through to command.dispatch */
}
try {
const d = parseCommandDispatch(
await gw.request<unknown>("command.dispatch", {
name,
arg,
session_id: sessionId,
}),
);
if (!d) {
sys("error: invalid response: command.dispatch");
return "error";
}
switch (d.type) {
case "exec":
case "plugin":
sys(d.output ?? "(no output)");
return "done";
case "alias":
return executeSlash({
command: `/${d.target}${arg ? ` ${arg}` : ""}`,
sessionId,
gw,
callbacks: { sys, send },
});
case "skill":
case "send": {
const msg = d.message?.trim() ?? "";
if (!msg) {
sys(
`/${name}: ${d.type === "skill" ? "skill payload missing message" : "empty message"}`,
);
return "error";
}
if (d.type === "skill") sys(`⚡ loading skill: ${d.name}`);
await send(msg);
return "sent";
}
}
} catch (err) {
sys(`error: ${err instanceof Error ? err.message : String(err)}`);
return "error";
}
}
export function parseSlash(command: string): { name: string; arg: string } {
const m = command.replace(/^\/+/, "").match(/^(\S+)\s*(.*)$/);
return m ? { name: m[1], arg: m[2].trim() } : { name: "", arg: "" };
}
function parseCommandDispatch(raw: unknown): CommandDispatchResponse | null {
if (!raw || typeof raw !== "object") return null;
const r = raw as Record<string, unknown>;
const str = (v: unknown) => (typeof v === "string" ? v : undefined);
switch (r.type) {
case "exec":
case "plugin":
return { type: r.type, output: str(r.output) };
case "alias":
return typeof r.target === "string"
? { type: "alias", target: r.target }
: null;
case "skill":
return typeof r.name === "string"
? { type: "skill", name: r.name, message: str(r.message) }
: null;
case "send":
return typeof r.message === "string"
? { type: "send", message: r.message }
: null;
default:
return null;
}
}
-26
View File
@@ -1,26 +0,0 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/** Relative time from a Unix epoch timestamp (seconds). */
export function timeAgo(ts: number): string {
const delta = Date.now() / 1000 - ts;
if (delta < 60) return "just now";
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
if (delta < 172800) return "yesterday";
return `${Math.floor(delta / 86400)}d ago`;
}
/** Relative time from an ISO-8601 timestamp string. */
export function isoTimeAgo(iso: string): string {
const delta = (Date.now() - new Date(iso).getTime()) / 1000;
if (delta < 0 || Number.isNaN(delta)) return "unknown";
if (delta < 60) return "just now";
if (delta < 3600) return `${Math.floor(delta / 60)}m ago`;
if (delta < 86400) return `${Math.floor(delta / 3600)}h ago`;
return `${Math.floor(delta / 86400)}d ago`;
}
-24
View File
@@ -1,24 +0,0 @@
import { createRoot } from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import "./index.css";
import App from "./App";
import { SystemActionsProvider } from "./contexts/SystemActions";
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>
<ThemeProvider>
<SystemActionsProvider>
<App />
</SystemActionsProvider>
</ThemeProvider>
</I18nProvider>
</BrowserRouter>,
);
-543
View File
@@ -1,543 +0,0 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
import {
ArrowDown,
ArrowUp,
ArrowUpDown,
BarChart3,
Brain,
Cpu,
RefreshCw,
TrendingUp,
} from "lucide-react";
import { api } from "@/lib/api";
import type {
AnalyticsResponse,
AnalyticsDailyEntry,
AnalyticsModelEntry,
AnalyticsSkillEntry,
} from "@/lib/api";
import { timeAgo } from "@/lib/utils";
import { Button } from "@nous-research/ui/ui/components/button";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Stats } from "@nous-research/ui/ui/components/stats";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
const PERIODS = [
{ label: "7d", days: 7 },
{ label: "30d", days: 30 },
{ label: "90d", days: 90 },
] as const;
const CHART_HEIGHT_PX = 160;
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
function formatDate(day: string): string {
try {
const d = new Date(day + "T00:00:00");
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
} catch {
return day;
}
}
// ---------------------------------------------------------------------------
// Sorting
// ---------------------------------------------------------------------------
function useTableSort<T>(
data: T[],
defaultKey: keyof T & string,
defaultDir: "asc" | "desc" = "desc",
) {
const [sortKey, setSortKey] = useState<string>(defaultKey);
const [sortDir, setSortDir] = useState<"asc" | "desc">(defaultDir);
const sorted = useMemo(() => {
return [...data].sort((a, b) => {
const aVal = a[sortKey as keyof T];
const bVal = b[sortKey as keyof T];
// Nulls always last regardless of direction
if (aVal === null || aVal === undefined) return 1;
if (bVal === null || bVal === undefined) return -1;
if (aVal === bVal) return 0;
const cmp = aVal > bVal ? 1 : -1;
return sortDir === "asc" ? cmp : -cmp;
});
}, [data, sortKey, sortDir]);
const toggle = useCallback(
(key: string) => {
if (key === sortKey) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("desc");
}
},
[sortKey],
);
return { sorted, sortKey, sortDir, toggle };
}
function SortHeader({
label,
col,
sortKey,
sortDir,
toggle,
className,
}: {
label: string;
col: string;
sortKey: string;
sortDir: "asc" | "desc";
toggle: (key: string) => void;
className?: string;
}) {
const active = col === sortKey;
return (
<th
onClick={() => toggle(col)}
className={`cursor-pointer select-none ${className ?? ""}`}
>
<span className="inline-flex items-center gap-1.5 rounded px-1 -mx-1 py-0.5 hover:bg-muted/40 transition-colors">
{label}
{active ? (
sortDir === "asc" ? (
<ArrowUp className="h-3.5 w-3.5 text-foreground/80 shrink-0" />
) : (
<ArrowDown className="h-3.5 w-3.5 text-foreground/80 shrink-0" />
)
) : (
<ArrowUpDown className="h-3 w-3 text-muted-foreground/40 shrink-0" />
)}
</span>
</th>
);
}
function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
const { t } = useI18n();
if (daily.length === 0) return null;
const maxTokens = Math.max(
...daily.map((d) => d.input_tokens + d.output_tokens),
1,
);
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<BarChart3 className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{t.analytics.dailyTokenUsage}
</CardTitle>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 bg-[#ffe6cb]" />
{t.analytics.input}
</div>
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 bg-emerald-500" />
{t.analytics.output}
</div>
</div>
</CardHeader>
<CardContent>
<div
className="flex items-end gap-[2px]"
style={{ height: CHART_HEIGHT_PX }}
>
{daily.map((d) => {
const total = d.input_tokens + d.output_tokens;
const inputH = Math.round(
(d.input_tokens / maxTokens) * CHART_HEIGHT_PX,
);
const outputH = Math.round(
(d.output_tokens / maxTokens) * CHART_HEIGHT_PX,
);
return (
<div
key={d.day}
className="flex-1 min-w-0 group relative flex flex-col justify-end"
style={{ height: CHART_HEIGHT_PX }}
>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none">
<div className="bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap">
<div className="font-medium">{formatDate(d.day)}</div>
<div>
{t.analytics.input}: {formatTokens(d.input_tokens)}
</div>
<div>
{t.analytics.output}: {formatTokens(d.output_tokens)}
</div>
<div>
{t.analytics.total}: {formatTokens(total)}
</div>
</div>
</div>
<div
className="w-full bg-[#ffe6cb]/70"
style={{ height: Math.max(inputH, total > 0 ? 1 : 0) }}
/>
<div
className="w-full bg-emerald-500/70"
style={{
height: Math.max(outputH, d.output_tokens > 0 ? 1 : 0),
}}
/>
</div>
);
})}
</div>
<div className="flex justify-between mt-2 text-[10px] text-muted-foreground">
<span>{daily.length > 0 ? formatDate(daily[0].day) : ""}</span>
{daily.length > 2 && (
<span>{formatDate(daily[Math.floor(daily.length / 2)].day)}</span>
)}
<span>
{daily.length > 1 ? formatDate(daily[daily.length - 1].day) : ""}
</span>
</div>
</CardContent>
</Card>
);
}
function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
const { t } = useI18n();
const { sorted, sortKey, sortDir, toggle } = useTableSort(daily, "day", "desc");
if (daily.length === 0) return null;
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{t.analytics.dailyBreakdown}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<SortHeader label={t.analytics.date} col="day" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
<SortHeader label={t.sessions.title} col="sessions" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
<SortHeader label={t.analytics.input} col="input_tokens" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
<SortHeader label={t.analytics.output} col="output_tokens" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 pl-4 font-medium" />
</tr>
</thead>
<tbody>
{sorted.map((d) => (
<tr
key={d.day}
className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
>
<td className="py-2 pr-4 font-medium">
{formatDate(d.day)}
</td>
<td className="text-right py-2 px-4 text-muted-foreground">
{d.sessions}
</td>
<td className="text-right py-2 px-4">
<span className="text-[#ffe6cb]">
{formatTokens(d.input_tokens)}
</span>
</td>
<td className="text-right py-2 pl-4">
<span className="text-emerald-400">
{formatTokens(d.output_tokens)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
const { t } = useI18n();
const { sorted, sortKey, sortDir, toggle } = useTableSort(models, "input_tokens", "desc");
if (models.length === 0) return null;
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Cpu className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{t.analytics.perModelBreakdown}
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<SortHeader label={t.analytics.model} col="model" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
<SortHeader label={t.sessions.title} col="sessions" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
<SortHeader label={t.analytics.tokens} col="input_tokens" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 pl-4 font-medium" />
</tr>
</thead>
<tbody>
{sorted.map((m) => (
<tr
key={m.model}
className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
>
<td className="py-2 pr-4">
<span className="font-mono-ui text-xs">{m.model}</span>
</td>
<td className="text-right py-2 px-4 text-muted-foreground">
{m.sessions}
</td>
<td className="text-right py-2 pl-4">
<span className="text-[#ffe6cb]">
{formatTokens(m.input_tokens)}
</span>
{" / "}
<span className="text-emerald-400">
{formatTokens(m.output_tokens)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
function SkillTable({ skills }: { skills: AnalyticsSkillEntry[] }) {
const { t } = useI18n();
const { sorted, sortKey, sortDir, toggle } = useTableSort(skills, "total_count", "desc");
if (skills.length === 0) return null;
return (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Brain className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{t.analytics.topSkills}</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-muted-foreground text-xs">
<SortHeader label={t.analytics.skill} col="skill" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-left py-2 pr-4 font-medium" />
<SortHeader label={t.analytics.loads} col="view_count" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
<SortHeader label={t.analytics.edits} col="manage_count" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
<SortHeader label={t.analytics.total} col="total_count" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 px-4 font-medium" />
<SortHeader label={t.analytics.lastUsed} col="last_used_at" sortKey={sortKey} sortDir={sortDir} toggle={toggle} className="text-right py-2 pl-4 font-medium" />
</tr>
</thead>
<tbody>
{sorted.map((skill) => (
<tr
key={skill.skill}
className="border-b border-border/50 hover:bg-secondary/20 transition-colors"
>
<td className="py-2 pr-4">
<span className="font-mono-ui text-xs">{skill.skill}</span>
</td>
<td className="text-right py-2 px-4 text-muted-foreground">
{skill.view_count}
</td>
<td className="text-right py-2 px-4 text-muted-foreground">
{skill.manage_count}
</td>
<td className="text-right py-2 px-4">{skill.total_count}</td>
<td className="text-right py-2 pl-4 text-muted-foreground">
{skill.last_used_at ? timeAgo(skill.last_used_at) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
export default function AnalyticsPage() {
const [days, setDays] = useState(30);
const [data, setData] = useState<AnalyticsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
const load = useCallback(() => {
setLoading(true);
setError(null);
api
.getAnalytics(days)
.then(setData)
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}, [days]);
useLayoutEffect(() => {
const periodLabel =
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
setAfterTitle(
<span className="flex items-center gap-2">
{loading && <Spinner className="shrink-0 text-base text-primary" />}
<Badge tone="secondary" className="text-[10px]">
{periodLabel}
</Badge>
</span>,
);
setEnd(
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-2">
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
>
{p.label}
</Button>
))}
</div>
<Button
type="button"
size="sm"
outlined
onClick={load}
disabled={loading}
prefix={loading ? <Spinner /> : <RefreshCw />}
>
{t.common.refresh}
</Button>
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [days, loading, load, setAfterTitle, setEnd, t.common.refresh]);
useEffect(() => {
load();
}, [load]);
return (
<div className="flex flex-col gap-6">
<PluginSlot name="analytics:top" />
{loading && !data && (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
)}
{error && (
<Card>
<CardContent className="py-6">
<p className="text-sm text-destructive text-center">{error}</p>
</CardContent>
</Card>
)}
{data && (
<>
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardContent className="py-6">
<Stats
items={[
{
label: t.analytics.totalTokens,
value: formatTokens(
data.totals.total_input + data.totals.total_output,
),
},
{
label: t.analytics.input,
value: formatTokens(data.totals.total_input),
},
{
label: t.analytics.output,
value: formatTokens(data.totals.total_output),
},
{
label: t.analytics.totalSessions,
value: `${data.totals.total_sessions} (~${(data.totals.total_sessions / days).toFixed(1)}${t.analytics.perDayAvg})`,
},
{
label: t.analytics.apiCalls,
value: String(
data.totals.total_api_calls ??
data.daily.reduce((sum, d) => sum + d.sessions, 0),
),
},
]}
/>
</CardContent>
</Card>
<TokenBarChart daily={data.daily} />
</div>
<DailyTable daily={data.daily} />
<ModelTable models={data.by_model} />
<SkillTable skills={data.skills.top_skills} />
</>
)}
{data &&
data.daily.length === 0 &&
data.by_model.length === 0 &&
data.skills.top_skills.length === 0 && (
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center text-muted-foreground">
<BarChart3 className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">{t.analytics.noUsageData}</p>
<p className="text-xs mt-1 text-muted-foreground/60">
{t.analytics.startSession}
</p>
</div>
</CardContent>
</Card>
)}
<PluginSlot name="analytics:bottom" />
</div>
);
}
-834
View File
@@ -1,834 +0,0 @@
/**
* ChatPage — embeds `hermes --tui` inside the dashboard.
*
* <div host> (dashboard chrome) .
* └─ <div wrapper> (rounded, dark bg, padded — the "terminal window" .
* look that gives the page a distinct visual identity) .
* └─ @xterm/xterm Terminal (WebGL renderer, Unicode 11 widths) .
* │ onData keystrokes → WebSocket → PTY master .
* │ onResize terminal resize → `\x1b[RESIZE:cols;rows]` .
* │ write(data) PTY output bytes → VT100 parser .
* ▼ .
* WebSocket /api/pty?token=<session> .
* ▼ .
* FastAPI pty_ws (hermes_cli/web_server.py) .
* ▼ .
* POSIX PTY → `node ui-tui/dist/entry.js` → tui_gateway + AIAgent .
*/
import { FitAddon } from "@xterm/addon-fit";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Terminal } from "@xterm/xterm";
import "@xterm/xterm/css/xterm.css";
import { Button } from "@nous-research/ui/ui/components/button";
import { Typography } from "@/components/NouiTypography";
import { cn } from "@/lib/utils";
import { Copy, PanelRight, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useSearchParams } from "react-router-dom";
import { ChatSidebar } from "@/components/ChatSidebar";
import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
function buildWsUrl(
token: string,
resume: string | null,
channel: string,
): string {
const proto = window.location.protocol === "https:" ? "wss:" : "ws:";
const qs = new URLSearchParams({ token, channel });
if (resume) qs.set("resume", resume);
return `${proto}//${window.location.host}/api/pty?${qs.toString()}`;
}
// Channel id ties this chat tab's PTY child (publisher) to its sidebar
// (subscriber). Generated once per mount so a tab refresh starts a fresh
// channel — the previous PTY child terminates with the old WS, and its
// channel auto-evicts when no subscribers remain.
function generateChannelId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `chat-${Math.random().toString(36).slice(2)}-${Date.now().toString(36)}`;
}
// Colors for the terminal body. Matches the dashboard's dark teal canvas
// with cream foreground — we intentionally don't pick monokai or a loud
// theme, because the TUI's skin engine already paints the content; the
// terminal chrome just needs to sit quietly inside the dashboard.
const TERMINAL_THEME = {
background: "#0d2626",
foreground: "#f0e6d2",
cursor: "#f0e6d2",
cursorAccent: "#0d2626",
selectionBackground: "#f0e6d244",
};
/**
* CSS width for xterm font tiers.
*
* Prefer the terminal host's `clientWidth` — Chrome DevTools device mode often
* keeps `window.innerWidth` at the full desktop value while the *drawn* layout
* is phone-sized, which made us pick desktop font sizes (~14px) and look huge.
*/
function terminalTierWidthPx(host: HTMLElement | null): number {
if (typeof window === "undefined") return 1280;
const fromHost = host?.clientWidth ?? 0;
if (fromHost > 2) return Math.round(fromHost);
const doc = document.documentElement?.clientWidth ?? 0;
const vv = window.visualViewport;
const inner = window.innerWidth;
const vvw = vv?.width ?? inner;
const layout = Math.min(inner, vvw, doc > 0 ? doc : inner);
return Math.max(1, Math.round(layout));
}
function terminalFontSizeForWidth(layoutWidthPx: number): number {
if (layoutWidthPx < 300) return 7;
if (layoutWidthPx < 360) return 8;
if (layoutWidthPx < 420) return 9;
if (layoutWidthPx < 520) return 10;
if (layoutWidthPx < 720) return 11;
if (layoutWidthPx < 1024) return 12;
return 14;
}
function terminalLineHeightForWidth(layoutWidthPx: number): number {
return layoutWidthPx < 1024 ? 1.02 : 1.15;
}
export default function ChatPage({ isActive = true }: { isActive?: boolean }) {
const hostRef = useRef<HTMLDivElement | null>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const wsRef = useRef<WebSocket | null>(null);
// Exposed to the main metrics-sync effect so it can refit the terminal
// the moment `isActive` flips back to true (display:none → display:flex
// collapses the host's box, so ResizeObserver never fires on return).
const syncMetricsRef = useRef<(() => void) | null>(null);
const [searchParams] = useSearchParams();
// Lazy-init: the missing-token check happens at construction so the effect
// body doesn't have to setState (React 19's set-state-in-effect rule).
const [banner, setBanner] = useState<string | null>(() =>
typeof window !== "undefined" && !window.__HERMES_SESSION_TOKEN__
? "Session token unavailable. Open this page through `hermes dashboard`, not directly."
: null,
);
const [copyState, setCopyState] = useState<"idle" | "copied">("idle");
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Raw state for the mobile side-sheet + a derived value that force-
// closes whenever the chat tab isn't active. The *derived* value is
// what side-effects (body-scroll lock, keydown listener, portal render)
// key on — that way switching to another tab triggers the effect's
// cleanup, releasing the scroll-lock on /sessions etc. Returning to
// /chat re-runs the effect (derived flips back to true) and re-locks.
// Keying on the raw state would leak the body.overflow="hidden" across
// tabs because the dep wouldn't change on tab switch.
const [mobilePanelOpenRaw, setMobilePanelOpenRaw] = useState(false);
const mobilePanelOpen = isActive && mobilePanelOpenRaw;
const { setEnd } = usePageHeader();
const { t } = useI18n();
const closeMobilePanel = useCallback(() => setMobilePanelOpenRaw(false), []);
const modelToolsLabel = useMemo(
() => `${t.app.modelToolsSheetTitle} ${t.app.modelToolsSheetSubtitle}`,
[t.app.modelToolsSheetSubtitle, t.app.modelToolsSheetTitle],
);
const [portalRoot] = useState<HTMLElement | null>(() =>
typeof document !== "undefined" ? document.body : null,
);
const [narrow, setNarrow] = useState(() =>
typeof window !== "undefined"
? window.matchMedia("(max-width: 1023px)").matches
: false,
);
const resumeRef = useRef<string | null>(searchParams.get("resume"));
const channel = useMemo(() => generateChannelId(), []);
useEffect(() => {
const mql = window.matchMedia("(max-width: 1023px)");
const sync = () => setNarrow(mql.matches);
sync();
mql.addEventListener("change", sync);
return () => mql.removeEventListener("change", sync);
}, []);
useEffect(() => {
if (!mobilePanelOpen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeMobilePanel();
};
document.addEventListener("keydown", onKey);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = prevOverflow;
};
}, [mobilePanelOpen, closeMobilePanel]);
useEffect(() => {
const mql = window.matchMedia("(min-width: 1024px)");
const onChange = (e: MediaQueryListEvent) => {
if (e.matches) setMobilePanelOpenRaw(false);
};
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, []);
useEffect(() => {
// When hidden (non-chat tab) we must not register the header button —
// another page owns the header's end slot at that point.
if (!isActive) {
setEnd(null);
return;
}
if (!narrow) {
setEnd(null);
return;
}
setEnd(
<Button
ghost
onClick={() => setMobilePanelOpenRaw(true)}
aria-expanded={mobilePanelOpen}
aria-controls="chat-side-panel"
className={cn(
"shrink-0 rounded border border-current/20",
"px-2 py-1 text-[0.65rem] font-medium tracking-wide normal-case",
"text-midground/80 hover:text-midground hover:bg-midground/5",
)}
>
<span className="inline-flex items-center gap-1.5">
<PanelRight className="h-3 w-3 shrink-0" />
{modelToolsLabel}
</span>
</Button>,
);
return () => setEnd(null);
}, [isActive, narrow, mobilePanelOpen, modelToolsLabel, setEnd]);
const handleCopyLast = () => {
const ws = wsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
// Send the slash as a burst, wait long enough for Ink's tokenizer to
// emit a keypress event for each character (not coalesce them into a
// paste), then send Return as its own event. The timing here is
// empirical — 100ms is safely past Node's default stdin coalescing
// window and well inside UI responsiveness.
ws.send("/copy");
setTimeout(() => {
const s = wsRef.current;
if (s && s.readyState === WebSocket.OPEN) s.send("\r");
}, 100);
setCopyState("copied");
if (copyResetRef.current) clearTimeout(copyResetRef.current);
copyResetRef.current = setTimeout(() => setCopyState("idle"), 1500);
termRef.current?.focus();
};
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const token = window.__HERMES_SESSION_TOKEN__;
// Banner already initialised above; just bail before wiring xterm/WS.
if (!token) {
return;
}
const tierW0 = terminalTierWidthPx(host);
const term = new Terminal({
allowProposedApi: true,
cursorBlink: true,
fontFamily:
"'JetBrains Mono', 'Cascadia Mono', 'Fira Code', 'MesloLGS NF', 'Source Code Pro', Menlo, Consolas, 'DejaVu Sans Mono', monospace",
fontSize: terminalFontSizeForWidth(tierW0),
lineHeight: terminalLineHeightForWidth(tierW0),
letterSpacing: 0,
fontWeight: "400",
fontWeightBold: "700",
macOptionIsMeta: true,
scrollback: 0,
theme: TERMINAL_THEME,
});
termRef.current = term;
// --- Clipboard integration ---------------------------------------
//
// Three independent paths all route to the system clipboard:
//
// 1. **Selection → Ctrl+C (or Cmd+C on macOS).** Ink's own handler
// in useInputHandlers.ts turns Ctrl+C into a copy when the
// terminal has a selection, then emits an OSC 52 escape. Our
// OSC 52 handler below decodes that escape and writes to the
// browser clipboard — so the flow works just like it does in
// `hermes --tui`.
//
// 2. **Ctrl/Cmd+Shift+C.** Belt-and-suspenders shortcut that
// operates directly on xterm's selection, useful if the TUI
// ever stops listening (e.g. overlays / pickers) or if the user
// has selected with the mouse outside of Ink's selection model.
//
// 3. **Ctrl/Cmd+Shift+V.** Reads the system clipboard and feeds
// it to the terminal as keyboard input. xterm's paste() wraps
// it with bracketed-paste if the host has that mode enabled.
//
// OSC 52 reads (terminal asking to read the clipboard) are not
// supported — that would let any content the TUI renders exfiltrate
// the user's clipboard.
term.parser.registerOscHandler(52, (data) => {
// Format: "<targets>;<base64 | '?'>"
const semi = data.indexOf(";");
if (semi < 0) return false;
const payload = data.slice(semi + 1);
if (payload === "?" || payload === "") return false; // read/clear — ignore
try {
const binary = atob(payload);
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
const text = new TextDecoder("utf-8").decode(bytes);
navigator.clipboard.writeText(text).catch((err) => {
// Most common reason: the Clipboard API requires a user gesture.
// This can fail when the OSC 52 response arrives outside the
// original keydown event's activation. Log to aid debugging.
console.warn("[dashboard clipboard] OSC 52 write failed:", err.message);
});
} catch (e) {
console.warn("[dashboard clipboard] malformed OSC 52 payload");
}
return true;
});
const isMac =
typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
term.attachCustomKeyEventHandler((ev) => {
if (ev.type !== "keydown") return true;
// Copy: Cmd+C on macOS, Ctrl+Shift+C on other platforms. Bare Ctrl+C
// is reserved for SIGINT to the TUI child — matches xterm / gnome-terminal /
// konsole / Windows Terminal. Ctrl+Shift+C only copies if a selection exists;
// without a selection it passes through to the TUI so agents can still
// react to the keypress.
// Paste: Cmd+Shift+V on macOS, Ctrl+Shift+V on others.
const copyModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey;
const pasteModifier = isMac ? ev.metaKey : ev.ctrlKey && ev.shiftKey;
if (copyModifier && ev.key.toLowerCase() === "c") {
const sel = term.getSelection();
if (sel) {
// Direct writeText inside the keydown handler preserves the user
// gesture — async round-trips through OSC 52 can lose activation
// and fail with "Document is not focused".
navigator.clipboard.writeText(sel).catch((err) => {
console.warn("[dashboard clipboard] direct copy failed:", err.message);
});
// Clear xterm.js's highlight after copy (matches gnome-terminal).
term.clearSelection();
ev.preventDefault();
return false;
}
// No selection → fall through so the TUI receives Ctrl+Shift+C
// (or the bare ev if the user used a different modifier).
}
if (pasteModifier && ev.key.toLowerCase() === "v") {
navigator.clipboard
.readText()
.then((text) => {
if (text) term.paste(text);
})
.catch((err) => {
console.warn("[dashboard clipboard] paste failed:", err.message);
});
ev.preventDefault();
return false;
}
return true;
});
const fit = new FitAddon();
fitRef.current = fit;
term.loadAddon(fit);
const unicode11 = new Unicode11Addon();
term.loadAddon(unicode11);
term.unicode.activeVersion = "11";
term.loadAddon(new WebLinksAddon());
term.open(host);
// WebGL draws from a texture atlas sized with device pixels. On phones and
// in DevTools device mode that often produces *visually* much larger cells
// than `fontSize` suggests — users see "huge" text even at 79px settings.
// The canvas/DOM renderer tracks `fontSize` faithfully; use it for narrow
// hosts. Wide layouts still get WebGL for crisp box-drawing.
const useWebgl = terminalTierWidthPx(host) >= 768;
if (useWebgl) {
try {
const webgl = new WebglAddon();
webgl.onContextLoss(() => webgl.dispose());
term.loadAddon(webgl);
} catch (err) {
console.warn(
"[hermes-chat] WebGL renderer unavailable; falling back to default",
err,
);
}
}
// Initial fit + resize observer. fit.fit() reads the container's
// current bounding box and resizes the terminal grid to match.
//
// The subtle bit: the dashboard has CSS transitions on the container
// (backdrop fade-in, rounded corners settling as fonts load). If we
// call fit() at mount time, the bounding box we measure is often 1-2
// cell widths off from the final size. ResizeObserver *does* fire
// when the container settles, but if the pixel delta happens to be
// smaller than one cell's width, fit() computes the same integer
// (cols, rows) as before and doesn't emit onResize — so the PTY
// never learns the final size. Users see truncated long lines until
// they resize the browser window.
//
// We force one extra fit + explicit RESIZE send after two animation
// frames. rAF→rAF guarantees one layout commit between the two
// callbacks, giving CSS transitions and font metrics time to finalize
// before we take the authoritative measurement.
let hostSyncRaf = 0;
const scheduleHostSync = () => {
if (hostSyncRaf) return;
hostSyncRaf = requestAnimationFrame(() => {
hostSyncRaf = 0;
syncTerminalMetrics();
});
};
let metricsDebounce: ReturnType<typeof setTimeout> | null = null;
const syncTerminalMetrics = () => {
// display:none hosts have clientWidth/Height = 0, which fit() turns
// into a 1x1 terminal. Skip entirely while hidden; the visibility
// effect below runs another fit as soon as the tab is shown again.
if (!host.isConnected || host.clientWidth <= 0 || host.clientHeight <= 0) {
return;
}
const w = terminalTierWidthPx(host);
const nextSize = terminalFontSizeForWidth(w);
const nextLh = terminalLineHeightForWidth(w);
const fontChanged =
term.options.fontSize !== nextSize ||
term.options.lineHeight !== nextLh;
if (fontChanged) {
term.options.fontSize = nextSize;
term.options.lineHeight = nextLh;
}
try {
fit.fit();
} catch {
return;
}
if (fontChanged && term.rows > 0) {
try {
term.refresh(0, term.rows - 1);
} catch {
/* ignore */
}
}
if (
fontChanged &&
wsRef.current &&
wsRef.current.readyState === WebSocket.OPEN
) {
wsRef.current.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
}
};
syncMetricsRef.current = syncTerminalMetrics;
const scheduleSyncTerminalMetrics = () => {
if (metricsDebounce) clearTimeout(metricsDebounce);
metricsDebounce = setTimeout(() => {
metricsDebounce = null;
syncTerminalMetrics();
}, 60);
};
const ro = new ResizeObserver(() => scheduleHostSync());
ro.observe(host);
window.addEventListener("resize", scheduleSyncTerminalMetrics);
window.visualViewport?.addEventListener("resize", scheduleSyncTerminalMetrics);
window.visualViewport?.addEventListener("scroll", scheduleSyncTerminalMetrics);
scheduleHostSync();
requestAnimationFrame(() => scheduleHostSync());
// Double-rAF authoritative fit. On the second frame the layout has
// committed at least once since mount; fit.fit() then reads the
// stable container size. We always send a RESIZE escape afterwards
// (even if fit's cols/rows didn't change, so the PTY has the same
// dims registered as our JS state — prevents a drift where Ink
// thinks the terminal is one col bigger than what's on screen).
let settleRaf1 = 0;
let settleRaf2 = 0;
settleRaf1 = requestAnimationFrame(() => {
settleRaf1 = 0;
settleRaf2 = requestAnimationFrame(() => {
settleRaf2 = 0;
syncTerminalMetrics();
});
});
// WebSocket
const url = buildWsUrl(token, resumeRef.current, channel);
const ws = new WebSocket(url);
ws.binaryType = "arraybuffer";
wsRef.current = ws;
// Suppress banner/terminal side-effects when cleanup() calls `ws.close()`
// (React StrictMode remount, route change) so we never write to a
// disposed xterm or setState on an unmounted tree.
let unmounting = false;
ws.onopen = () => {
setBanner(null);
// Send the initial RESIZE immediately so Ink has *a* size to lay
// out against on its first paint. The double-rAF block above will
// follow up with the authoritative measurement — at worst Ink
// reflows once after the PTY boots, which is imperceptible.
ws.send(`\x1b[RESIZE:${term.cols};${term.rows}]`);
};
ws.onmessage = (ev) => {
if (typeof ev.data === "string") {
term.write(ev.data);
} else {
term.write(new Uint8Array(ev.data as ArrayBuffer));
}
};
ws.onclose = (ev) => {
wsRef.current = null;
if (unmounting) {
return;
}
if (ev.code === 4401) {
setBanner("Auth failed. Reload the page to refresh the session token.");
return;
}
if (ev.code === 4403) {
setBanner("Chat is only reachable from localhost.");
return;
}
if (ev.code === 1011) {
// Server already wrote an ANSI error frame.
return;
}
term.write("\r\n\x1b[90m[session ended]\x1b[0m\r\n");
};
// Keystrokes + mouse events → PTY, with cell-level dedup for motion.
//
// Ink enables `\x1b[?1003h` (any-motion tracking), which asks the
// terminal to report every mouse-move as an SGR mouse event even with
// no button held. xterm.js happily emits one report per pixel of
// mouse motion; without deduping, a casual mouse-over floods Ink with
// hundreds of redraw-triggering reports and the UI goes laggy
// (scrolling stutters, clicks land on stale positions by the time
// Ink finishes processing the motion backlog).
//
// We keep track of the last cell we reported a motion for. Press,
// release, and wheel events always pass through; motion events only
// pass through if the cell changed. Parsing is cheap — SGR reports
// are short literal strings.
// eslint-disable-next-line no-control-regex -- intentional ESC byte in xterm SGR mouse report parser
const SGR_MOUSE_RE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
let lastMotionCell = { col: -1, row: -1 };
let lastMotionCb = -1;
const onDataDisposable = term.onData((data) => {
if (ws.readyState !== WebSocket.OPEN) return;
const m = SGR_MOUSE_RE.exec(data);
if (m) {
const cb = parseInt(m[1], 10);
const col = parseInt(m[2], 10);
const row = parseInt(m[3], 10);
const released = m[4] === "m";
// Motion events have bit 0x20 (32) set in the button code.
// Wheel events have bit 0x40 (64); always forward wheel.
const isMotion = (cb & 0x20) !== 0 && (cb & 0x40) === 0;
const isWheel = (cb & 0x40) !== 0;
if (isMotion && !isWheel && !released) {
if (
col === lastMotionCell.col &&
row === lastMotionCell.row &&
cb === lastMotionCb
) {
return; // same cell + same button state; skip redundant report
}
lastMotionCell = { col, row };
lastMotionCb = cb;
} else {
// Non-motion event (press, release, wheel) — reset dedup state
// so the next motion after this always reports.
lastMotionCell = { col: -1, row: -1 };
lastMotionCb = -1;
}
}
ws.send(data);
});
const onResizeDisposable = term.onResize(({ cols, rows }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(`\x1b[RESIZE:${cols};${rows}]`);
}
});
term.focus();
return () => {
unmounting = true;
syncMetricsRef.current = null;
onDataDisposable.dispose();
onResizeDisposable.dispose();
if (metricsDebounce) clearTimeout(metricsDebounce);
window.removeEventListener("resize", scheduleSyncTerminalMetrics);
window.visualViewport?.removeEventListener(
"resize",
scheduleSyncTerminalMetrics,
);
window.visualViewport?.removeEventListener(
"scroll",
scheduleSyncTerminalMetrics,
);
ro.disconnect();
if (hostSyncRaf) cancelAnimationFrame(hostSyncRaf);
if (settleRaf1) cancelAnimationFrame(settleRaf1);
if (settleRaf2) cancelAnimationFrame(settleRaf2);
ws.close();
wsRef.current = null;
term.dispose();
termRef.current = null;
fitRef.current = null;
if (copyResetRef.current) {
clearTimeout(copyResetRef.current);
copyResetRef.current = null;
}
};
}, [channel]);
// When the user returns to the chat tab (isActive: false → true), the
// terminal host just transitioned from display:none to display:flex.
// ResizeObserver won't fire on that kind of style-driven box change —
// xterm thinks its grid is still whatever it was when the tab was
// hidden (or 0×0, if it was hidden before first fit). Force a refit
// after two animation frames so layout has committed.
//
// Focus handling: we only steal focus back into the terminal when
// nothing else inside ChatPage was holding it (typically the first
// activation after mount, where document.activeElement is <body>; or
// a return after the user had been typing in the terminal, where
// focus was already on the xterm textarea before the tab got hidden
// and has since fallen back to <body>). If the user had clicked
// into the sidebar (model picker, tool-call entry) before switching
// tabs, we must not yank focus away from wherever they left it when
// they come back — that's a surprise and an a11y foot-gun.
useEffect(() => {
if (!isActive) return;
let raf1 = 0;
let raf2 = 0;
raf1 = requestAnimationFrame(() => {
raf1 = 0;
raf2 = requestAnimationFrame(() => {
raf2 = 0;
syncMetricsRef.current?.();
const host = hostRef.current;
const active = typeof document !== "undefined"
? document.activeElement
: null;
const focusIsElsewhereInChatPage =
active !== null &&
active !== document.body &&
host !== null &&
!host.contains(active);
if (!focusIsElsewhereInChatPage) {
termRef.current?.focus();
}
});
});
return () => {
if (raf1) cancelAnimationFrame(raf1);
if (raf2) cancelAnimationFrame(raf2);
};
}, [isActive]);
// Layout:
// outer flex column — sits inside the dashboard's content area
// row split — terminal pane (flex-1) + sidebar (fixed width, lg+)
// terminal wrapper — rounded, dark, padded — the "terminal window"
// floating copy button — bottom-right corner, transparent with a
// subtle border; stays out of the way until hovered. Sends
// `/copy\n` to Ink, which emits OSC 52 → our clipboard handler.
// sidebar — ChatSidebar opens its own JSON-RPC sidecar; renders
// model badge, tool-call list, model picker. Best-effort: if the
// sidecar fails to connect the terminal pane keeps working.
//
// `normal-case` opts out of the dashboard's global `uppercase` rule on
// the root `<div>` in App.tsx — terminal output must preserve case.
//
// Mobile model/tools sheet is portaled to `document.body` so it stacks
// above the app sidebar (`z-50`) and mobile chrome (`z-40`). The main
// dashboard column uses `relative z-2`, which traps `position:fixed`
// descendants below those layers (see Toast.tsx).
const mobileModelToolsPortal =
isActive &&
narrow &&
portalRoot &&
createPortal(
<>
{mobilePanelOpen && (
<Button
ghost
aria-label={t.app.closeModelTools}
onClick={closeMobilePanel}
className={cn(
"fixed inset-0 z-[55] p-0 block",
"bg-black/60 backdrop-blur-sm",
)}
/>
)}
<div
id="chat-side-panel"
role="complementary"
aria-label={modelToolsLabel}
className={cn(
"font-mondwest fixed top-0 right-0 z-[60] flex h-dvh max-h-dvh w-64 min-w-0 flex-col antialiased",
"border-l border-current/20 text-midground",
"bg-background-base/95 backdrop-blur-sm",
"transition-transform duration-200 ease-out",
"[background:var(--component-sidebar-background)]",
"[clip-path:var(--component-sidebar-clip-path)]",
"[border-image:var(--component-sidebar-border-image)]",
mobilePanelOpen
? "translate-x-0"
: "pointer-events-none translate-x-full",
)}
>
<div
className={cn(
"flex h-14 shrink-0 items-center justify-between gap-2 border-b border-current/20 px-5",
)}
>
<Typography
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
style={{ mixBlendMode: "plus-lighter" }}
>
{t.app.modelToolsSheetTitle}
<br />
{t.app.modelToolsSheetSubtitle}
</Typography>
<Button
ghost
size="icon"
onClick={closeMobilePanel}
aria-label={t.app.closeModelTools}
className="text-midground/70 hover:text-midground"
>
<X />
</Button>
</div>
<div
className={cn(
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden",
"border-t border-current/10",
)}
>
<ChatSidebar channel={channel} />
</div>
</div>
</>,
portalRoot,
);
return (
<div className="flex min-h-0 flex-1 flex-col gap-2 normal-case">
<PluginSlot name="chat:top" />
{mobileModelToolsPortal}
{banner && (
<div className="border border-warning/50 bg-warning/10 text-warning px-3 py-2 text-xs tracking-wide">
{banner}
</div>
)}
<div className="flex min-h-0 flex-1 flex-col gap-2 lg:flex-row lg:gap-3">
<div
className={cn(
"relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-lg",
"p-2 sm:p-3",
)}
style={{
backgroundColor: TERMINAL_THEME.background,
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
}}
>
<div
ref={hostRef}
className="hermes-chat-xterm-host min-h-0 min-w-0 flex-1"
/>
<Button
ghost
onClick={handleCopyLast}
title="Copy last assistant response as raw markdown"
aria-label="Copy last assistant response"
className={cn(
"absolute z-10",
"rounded border border-current/30",
"bg-black/20 backdrop-blur-sm",
"opacity-60 hover:opacity-100 hover:border-current/60",
"transition-opacity duration-150 normal-case font-normal tracking-normal",
"bottom-2 right-2 px-2 py-1 text-[0.65rem] sm:bottom-3 sm:right-3 sm:px-2.5 sm:py-1.5 sm:text-xs",
"lg:bottom-4 lg:right-4",
)}
style={{ color: TERMINAL_THEME.foreground }}
>
<span className="inline-flex items-center gap-1.5">
<Copy className="h-3 w-3 shrink-0" />
<span className="hidden min-[400px]:inline tracking-wide">
{copyState === "copied" ? "copied" : "copy last response"}
</span>
</span>
</Button>
</div>
{!narrow && (
<div
id="chat-side-panel"
role="complementary"
aria-label={modelToolsLabel}
className="flex min-h-0 shrink-0 flex-col lg:h-full lg:w-80"
>
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">
<ChatSidebar channel={channel} />
</div>
</div>
)}
</div>
<PluginSlot name="chat:bottom" />
</div>
);
}
declare global {
interface Window {
__HERMES_SESSION_TOKEN__?: string;
}
}
-614
View File
@@ -1,614 +0,0 @@
import { useEffect, useLayoutEffect, useRef, useState, useMemo } from "react";
import {
Code,
Download,
FormInput,
RotateCcw,
Save,
Search,
Upload,
X,
Settings2,
FileText,
Settings,
Bot,
Monitor,
Palette,
Users,
Brain,
Package,
Lock,
Globe,
Mic,
Volume2,
Ear,
ClipboardList,
MessageCircle,
Wrench,
FileQuestion,
Filter,
} from "lucide-react";
import { api } from "@/lib/api";
import { getNestedValue, setNestedValue } from "@/lib/nested";
import { useToast } from "@/hooks/useToast";
import { Toast } from "@/components/Toast";
import { AutoField } from "@/components/AutoField";
import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
const CATEGORY_ICONS: Record<
string,
React.ComponentType<{ className?: string }>
> = {
general: Settings,
agent: Bot,
terminal: Monitor,
display: Palette,
delegation: Users,
memory: Brain,
compression: Package,
security: Lock,
browser: Globe,
voice: Mic,
tts: Volume2,
stt: Ear,
logging: ClipboardList,
discord: MessageCircle,
auxiliary: Wrench,
};
function CategoryIcon({
category,
className,
}: {
category: string;
className?: string;
}) {
const Icon = CATEGORY_ICONS[category] ?? FileQuestion;
return <Icon className={className ?? "h-4 w-4"} />;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function ConfigPage() {
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
const [schema, setSchema] = useState<Record<
string,
Record<string, unknown>
> | null>(null);
const [categoryOrder, setCategoryOrder] = useState<string[]>([]);
const [defaults, setDefaults] = useState<Record<string, unknown> | null>(
null,
);
const [saving, setSaving] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [yamlMode, setYamlMode] = useState(false);
const [yamlText, setYamlText] = useState("");
const [yamlLoading, setYamlLoading] = useState(false);
const [yamlSaving, setYamlSaving] = useState(false);
const [activeCategory, setActiveCategory] = useState<string>("");
const { toast, showToast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const { t } = useI18n();
const { setEnd } = usePageHeader();
useLayoutEffect(() => {
if (!config || !schema) {
setEnd(null);
return;
}
setEnd(
<div className="relative w-full min-w-0 sm:max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="h-8 pl-8 pr-7 text-xs"
placeholder={t.common.search}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{searchQuery && (
<Button
ghost
size="xs"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearchQuery("")}
aria-label={t.common.clear}
>
<X />
</Button>
)}
</div>,
);
return () => setEnd(null);
}, [config, schema, searchQuery, setEnd, t.common.clear, t.common.search]);
function prettyCategoryName(cat: string): string {
const key = cat as keyof typeof t.config.categories;
if (t.config.categories[key]) return t.config.categories[key];
return cat.charAt(0).toUpperCase() + cat.slice(1);
}
useEffect(() => {
api
.getConfig()
.then(setConfig)
.catch(() => {});
api
.getSchema()
.then((resp) => {
setSchema(resp.fields as Record<string, Record<string, unknown>>);
setCategoryOrder(resp.category_order ?? []);
})
.catch(() => {});
api
.getDefaults()
.then(setDefaults)
.catch(() => {});
}, []);
// Set active category when categories load
useEffect(() => {
if (categoryOrder.length > 0 && !activeCategory) {
setActiveCategory(categoryOrder[0]);
}
}, [categoryOrder, activeCategory]);
// Load YAML when switching to YAML mode
useEffect(() => {
if (yamlMode) {
setYamlLoading(true);
api
.getConfigRaw()
.then((resp) => setYamlText(resp.yaml))
.catch(() => showToast(t.config.failedToLoadRaw, "error"))
.finally(() => setYamlLoading(false));
}
}, [yamlMode]);
/* ---- Categories ---- */
const categories = useMemo(() => {
if (!schema) return [];
const allCats = [
...new Set(
Object.values(schema).map((s) => String(s.category ?? "general")),
),
];
const ordered = categoryOrder.filter((c) => allCats.includes(c));
const extra = allCats.filter((c) => !categoryOrder.includes(c)).sort();
return [...ordered, ...extra];
}, [schema, categoryOrder]);
/* ---- Category field counts ---- */
const categoryCounts = useMemo(() => {
if (!schema) return {};
const counts: Record<string, number> = {};
for (const s of Object.values(schema)) {
const cat = String(s.category ?? "general");
counts[cat] = (counts[cat] || 0) + 1;
}
return counts;
}, [schema]);
/* ---- Search ---- */
const isSearching = searchQuery.trim().length > 0;
const lowerSearch = searchQuery.toLowerCase();
const searchMatchedFields = useMemo(() => {
if (!isSearching || !schema) return [];
return Object.entries(schema).filter(([key, s]) => {
const label = key.split(".").pop() ?? key;
const humanLabel = label.replace(/_/g, " ");
return (
key.toLowerCase().includes(lowerSearch) ||
humanLabel.toLowerCase().includes(lowerSearch) ||
String(s.category ?? "")
.toLowerCase()
.includes(lowerSearch) ||
String(s.description ?? "")
.toLowerCase()
.includes(lowerSearch)
);
});
}, [isSearching, lowerSearch, schema]);
/* ---- Active tab fields ---- */
const activeFields = useMemo(() => {
if (!schema || isSearching) return [];
return Object.entries(schema).filter(
([, s]) => String(s.category ?? "general") === activeCategory,
);
}, [schema, activeCategory, isSearching]);
/* ---- Handlers ---- */
const handleSave = async () => {
if (!config) return;
setSaving(true);
try {
await api.saveConfig(config);
showToast(t.config.configSaved, "success");
} catch (e) {
showToast(`${t.config.failedToSave}: ${e}`, "error");
} finally {
setSaving(false);
}
};
const handleYamlSave = async () => {
setYamlSaving(true);
try {
await api.saveConfigRaw(yamlText);
showToast(t.config.yamlConfigSaved, "success");
api
.getConfig()
.then(setConfig)
.catch(() => {});
} catch (e) {
showToast(`${t.config.failedToSaveYaml}: ${e}`, "error");
} finally {
setYamlSaving(false);
}
};
const handleReset = () => {
if (!defaults || !config) return;
// Scope the reset to what the user is currently looking at:
// - search mode → the matched fields
// - form mode → the active category's fields
// Resetting the whole config here was a footgun (issue reported by @ykmfb001):
// the button sits next to the category tabs and users reasonably assumed
// "reset this tab", not "wipe my entire config.yaml".
const scopedFields = isSearching ? searchMatchedFields : activeFields;
if (scopedFields.length === 0) return;
const scopeLabel = isSearching
? t.config.searchResults
: prettyCategoryName(activeCategory);
const message = t.config.confirmResetScope.replace("{scope}", scopeLabel);
if (!window.confirm(message)) return;
let next: Record<string, unknown> = config;
for (const [key] of scopedFields) {
next = setNestedValue(next, key, getNestedValue(defaults, key));
}
setConfig(next);
showToast(
t.config.resetScopeToast.replace("{scope}", scopeLabel),
"success",
);
};
const handleExport = () => {
if (!config) return;
const blob = new Blob([JSON.stringify(config, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "hermes-config.json";
a.click();
URL.revokeObjectURL(url);
};
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
try {
const imported = JSON.parse(reader.result as string);
setConfig(imported);
showToast(t.config.configImported, "success");
} catch {
showToast(t.config.invalidJson, "error");
}
};
reader.readAsText(file);
};
/* ---- Loading ---- */
if (!config || !schema) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
/* ---- Render field list (shared between search & normal) ---- */
const renderFields = (
fields: [string, Record<string, unknown>][],
showCategory = false,
) => {
let lastSection = "";
let lastCat = "";
return fields.map(([key, s]) => {
const parts = key.split(".");
const section = parts.length > 1 ? parts[0] : "";
const cat = String(s.category ?? "general");
const showCatBadge = showCategory && cat !== lastCat;
const showSection =
!showCategory &&
section &&
section !== lastSection &&
section !== activeCategory;
lastSection = section;
lastCat = cat;
return (
<div key={key}>
{showCatBadge && (
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
<CategoryIcon
category={cat}
className="h-4 w-4 text-muted-foreground"
/>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{prettyCategoryName(cat)}
</span>
<div className="flex-1 border-t border-border" />
</div>
)}
{showSection && (
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{section.replace(/_/g, " ")}
</span>
<div className="flex-1 border-t border-border" />
</div>
)}
<div className="py-1">
<AutoField
schemaKey={key}
schema={s}
value={getNestedValue(config, key)}
onChange={(v) => setConfig(setNestedValue(config, key, v))}
/>
</div>
</div>
);
});
};
return (
<div className="flex flex-col gap-4">
<PluginSlot name="config:top" />
<Toast toast={toast} />
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<code className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5">
{t.config.configPath}
</code>
</div>
<div className="flex items-center gap-1.5">
<Button
ghost
size="icon"
onClick={handleExport}
title={t.config.exportConfig}
aria-label={t.config.exportConfig}
>
<Download />
</Button>
<Button
ghost
size="icon"
onClick={() => fileInputRef.current?.click()}
title={t.config.importConfig}
aria-label={t.config.importConfig}
>
<Upload />
</Button>
<input
ref={fileInputRef}
type="file"
accept=".json"
className="hidden"
onChange={handleImport}
/>
{!yamlMode &&
(() => {
const resetScopeLabel = isSearching
? t.config.searchResults
: prettyCategoryName(activeCategory);
const resetTitle = t.config.resetScopeTooltip.replace(
"{scope}",
resetScopeLabel,
);
return (
<Button
ghost
size="icon"
onClick={handleReset}
title={resetTitle}
aria-label={resetTitle}
>
<RotateCcw />
</Button>
);
})()}
<div className="w-px h-5 bg-border mx-1" />
<Button
size="sm"
outlined={!yamlMode}
onClick={() => setYamlMode(!yamlMode)}
prefix={yamlMode ? <FormInput /> : <Code />}
>
{yamlMode ? t.common.form : "YAML"}
</Button>
{yamlMode ? (
<Button
size="sm"
onClick={handleYamlSave}
disabled={yamlSaving}
prefix={<Save />}
>
{yamlSaving ? t.common.saving : t.common.save}
</Button>
) : (
<Button
size="sm"
onClick={handleSave}
disabled={saving}
prefix={<Save />}
>
{saving ? t.common.saving : t.common.save}
</Button>
)}
</div>
</div>
{yamlMode ? (
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm flex items-center gap-2">
<FileText className="h-4 w-4" />
{t.config.rawYaml}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{yamlLoading ? (
<div className="flex items-center justify-center py-12">
<Spinner className="text-xl text-primary" />
</div>
) : (
<textarea
className="flex min-h-[600px] w-full bg-transparent px-4 py-3 text-sm font-mono leading-relaxed placeholder:text-muted-foreground focus-visible:outline-none border-t border-border"
value={yamlText}
onChange={(e) => setYamlText(e.target.value)}
spellCheck={false}
/>
)}
</CardContent>
</Card>
) : (
<div className="flex flex-col sm:flex-row gap-4">
<aside aria-label={t.config.filters} className="sm:w-56 sm:shrink-0">
<div className="sm:sticky sm:top-4">
<div className="flex flex-col border border-border bg-muted/20">
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
<Filter className="h-3 w-3 text-muted-foreground" />
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
{t.config.filters}
</span>
</div>
<div className="hidden sm:block px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70">
{t.config.sections}
</div>
<div className="flex sm:flex-col gap-1 sm:gap-px p-2 sm:pt-1 overflow-x-auto sm:overflow-x-visible scrollbar-none sm:max-h-[calc(100vh-260px)] sm:overflow-y-auto">
{categories.map((cat) => {
const isActive = !isSearching && activeCategory === cat;
return (
<ListItem
key={cat}
active={isActive}
onClick={() => {
setSearchQuery("");
setActiveCategory(cat);
}}
className="rounded-sm whitespace-nowrap px-2 py-1 text-[11px]"
>
<CategoryIcon
category={cat}
className="h-3.5 w-3.5 shrink-0"
/>
<span className="flex-1 truncate">
{prettyCategoryName(cat)}
</span>
<span
className={`text-[10px] tabular-nums ${
isActive
? "text-foreground/60"
: "text-muted-foreground/50"
}`}
>
{categoryCounts[cat] || 0}
</span>
</ListItem>
);
})}
</div>
</div>
</div>
</aside>
<div className="flex-1 min-w-0">
{isSearching ? (
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Search className="h-4 w-4" />
{t.config.searchResults}
</CardTitle>
<Badge tone="secondary" className="text-[10px]">
{searchMatchedFields.length}{" "}
{t.config.fields.replace(
"{s}",
searchMatchedFields.length !== 1 ? "s" : "",
)}
</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-2 px-4 pb-4">
{searchMatchedFields.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
{t.config.noFieldsMatch.replace("{query}", searchQuery)}
</p>
) : (
renderFields(searchMatchedFields, true)
)}
</CardContent>
</Card>
) : (
/* Active category */
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<CategoryIcon
category={activeCategory}
className="h-4 w-4"
/>
{prettyCategoryName(activeCategory)}
</CardTitle>
<Badge tone="secondary" className="text-[10px]">
{activeFields.length}{" "}
{t.config.fields.replace(
"{s}",
activeFields.length !== 1 ? "s" : "",
)}
</Badge>
</div>
</CardHeader>
<CardContent className="grid gap-2 px-4 pb-4">
{renderFields(activeFields)}
</CardContent>
</Card>
)}
</div>
</div>
)}
<PluginSlot name="config:bottom" />
</div>
);
}
-352
View File
@@ -1,352 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { H2 } from "@/components/NouiTypography";
import { api } from "@/lib/api";
import type { CronJob } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useToast } from "@/hooks/useToast";
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
import { Toast } from "@/components/Toast";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
function formatTime(iso?: string | null): string {
if (!iso) return "—";
const d = new Date(iso);
return d.toLocaleString();
}
const STATUS_TONE: Record<string, "success" | "warning" | "destructive"> = {
enabled: "success",
scheduled: "success",
paused: "warning",
error: "destructive",
completed: "destructive",
};
export default function CronPage() {
const [jobs, setJobs] = useState<CronJob[]>([]);
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
const { t } = useI18n();
// New job form state
const [prompt, setPrompt] = useState("");
const [schedule, setSchedule] = useState("");
const [name, setName] = useState("");
const [deliver, setDeliver] = useState("local");
const [creating, setCreating] = useState(false);
const loadJobs = useCallback(() => {
api
.getCronJobs()
.then(setJobs)
.catch(() => showToast(t.common.loading, "error"))
.finally(() => setLoading(false));
}, [showToast, t.common.loading]);
useEffect(() => {
loadJobs();
}, [loadJobs]);
const handleCreate = async () => {
if (!prompt.trim() || !schedule.trim()) {
showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
return;
}
setCreating(true);
try {
await api.createCronJob({
prompt: prompt.trim(),
schedule: schedule.trim(),
name: name.trim() || undefined,
deliver,
});
showToast(t.common.create + " ✓", "success");
setPrompt("");
setSchedule("");
setName("");
setDeliver("local");
loadJobs();
} catch (e) {
showToast(`${t.config.failedToSave}: ${e}`, "error");
} finally {
setCreating(false);
}
};
const handlePauseResume = async (job: CronJob) => {
try {
const isPaused = job.state === "paused";
if (isPaused) {
await api.resumeCronJob(job.id);
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",
);
}
loadJobs();
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
}
};
const handleTrigger = async (job: CronJob) => {
try {
await api.triggerCronJob(job.id);
showToast(
`${t.cron.triggerNow}: "${job.name || job.prompt.slice(0, 30)}"`,
"success",
);
loadJobs();
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
}
};
const jobDelete = useConfirmDelete({
onDelete: useCallback(
async (id: string) => {
const job = jobs.find((j) => j.id === id);
try {
await api.deleteCronJob(id);
showToast(
`${t.common.delete}: "${job?.name || (job?.prompt ?? "").slice(0, 30) || id}"`,
"success",
);
loadJobs();
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
throw e;
}
},
[jobs, loadJobs, showToast, t.common.delete, t.status.error],
),
});
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
const pendingJob = jobDelete.pendingId
? jobs.find((j) => j.id === jobDelete.pendingId)
: null;
return (
<div className="flex flex-col gap-6">
<PluginSlot name="cron:top" />
<Toast toast={toast} />
<DeleteConfirmDialog
open={jobDelete.isOpen}
onCancel={jobDelete.cancel}
onConfirm={jobDelete.confirm}
title={t.cron.confirmDeleteTitle}
description={
pendingJob
? `"${pendingJob.name || pendingJob.prompt.slice(0, 40)}" — ${t.cron.confirmDeleteMessage}`
: t.cron.confirmDeleteMessage
}
loading={jobDelete.isDeleting}
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Plus className="h-4 w-4" />
{t.cron.newJob}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="cron-name">{t.cron.nameOptional}</Label>
<Input
id="cron-name"
placeholder={t.cron.namePlaceholder}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-prompt">{t.cron.prompt}</Label>
<textarea
id="cron-prompt"
className="flex min-h-[80px] w-full border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder={t.cron.promptPlaceholder}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="grid gap-2">
<Label htmlFor="cron-schedule">{t.cron.schedule}</Label>
<Input
id="cron-schedule"
placeholder={t.cron.schedulePlaceholder}
value={schedule}
onChange={(e) => setSchedule(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
<Select
id="cron-deliver"
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>
</Select>
</div>
<div className="flex items-end">
<Button
onClick={handleCreate}
disabled={creating}
prefix={<Plus />}
className="w-full"
>
{creating ? t.common.creating : t.common.create}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
<div className="flex flex-col gap-3">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<Clock className="h-4 w-4" />
{t.cron.scheduledJobs} ({jobs.length})
</H2>
{jobs.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{t.cron.noJobs}
</CardContent>
</Card>
)}
{jobs.map((job) => (
<Card key={job.id}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate">
{job.name ||
job.prompt.slice(0, 60) +
(job.prompt.length > 60 ? "..." : "")}
</span>
<Badge tone={STATUS_TONE[job.state] ?? "secondary"}>
{job.state}
</Badge>
{job.deliver && job.deliver !== "local" && (
<Badge tone="outline">{job.deliver}</Badge>
)}
</div>
{job.name && (
<p className="text-xs text-muted-foreground truncate mb-1">
{job.prompt.slice(0, 100)}
{job.prompt.length > 100 ? "..." : ""}
</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="font-mono">{job.schedule_display}</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>
)}
</div>
<div className="flex items-center gap-1 shrink-0">
<Button
ghost
size="icon"
title={job.state === "paused" ? t.cron.resume : t.cron.pause}
aria-label={
job.state === "paused" ? t.cron.resume : t.cron.pause
}
onClick={() => handlePauseResume(job)}
className={
job.state === "paused" ? "text-success" : "text-warning"
}
>
{job.state === "paused" ? <Play /> : <Pause />}
</Button>
<Button
ghost
size="icon"
title={t.cron.triggerNow}
aria-label={t.cron.triggerNow}
onClick={() => handleTrigger(job)}
>
<Zap />
</Button>
<Button
ghost
destructive
size="icon"
title={t.common.delete}
aria-label={t.common.delete}
onClick={() => jobDelete.requestDelete(job.id)}
>
<Trash2 />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
<PluginSlot name="cron:bottom" />
</div>
);
}
-61
View File
@@ -1,61 +0,0 @@
import { useLayoutEffect } from "react";
import { ExternalLink } from "lucide-react";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { cn } from "@/lib/utils";
import { PluginSlot } from "@/plugins";
export const HERMES_DOCS_URL = "https://hermes-agent.nousresearch.com/docs/";
const DS_BUTTON_OUTLINED_LINK_CN = cn(
"group relative inline-grid grid-cols-[auto_1fr_auto] items-center",
"px-[.9em_.75em] py-[1.25em] gap-2",
"leading-0 font-bold tracking-[0.2em] uppercase",
"text-midground bg-transparent shadow-midground",
"shadow-[inset_-1px_-1px_0_0_#00000080,inset_1px_1px_0_0_#ffffff80]",
);
export default function DocsPage() {
const { t } = useI18n();
const { setEnd } = usePageHeader();
useLayoutEffect(() => {
setEnd(
<a
href={HERMES_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className={DS_BUTTON_OUTLINED_LINK_CN}
>
<ExternalLink className="size-3.5" />
{t.app.openDocumentation}
</a>,
);
return () => {
setEnd(null);
};
}, [setEnd, t]);
return (
<div
className={cn(
"flex min-h-0 w-full min-w-0 flex-1 flex-col",
"pt-1 sm:pt-2",
)}
>
<PluginSlot name="docs:top" />
<iframe
title={t.app.nav.documentation}
src={HERMES_DOCS_URL}
className={cn(
"min-h-0 w-full min-w-0 flex-1",
"rounded-sm border border-current/20",
"bg-background",
)}
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
referrerPolicy="no-referrer-when-downgrade"
/>
<PluginSlot name="docs:bottom" />
</div>
);
}
-872
View File
@@ -1,872 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Eye,
EyeOff,
ExternalLink,
KeyRound,
MessageSquare,
Pencil,
Save,
Settings,
Trash2,
X,
Zap,
ChevronDown,
ChevronRight,
} from "lucide-react";
import { api } from "@/lib/api";
import type { EnvVarInfo } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { Toast } from "@/components/Toast";
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
import { useToast } from "@/hooks/useToast";
import { OAuthProvidersCard } from "@/components/OAuthProvidersCard";
import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
/* ------------------------------------------------------------------ */
/* Provider grouping */
/* ------------------------------------------------------------------ */
/** Map env-var key prefixes to a human-friendly provider name + ordering. */
const PROVIDER_GROUPS: { prefix: string; name: string; priority: number }[] = [
// Nous Portal first
{ prefix: "NOUS_", name: "Nous Portal", priority: 0 },
// Then alphabetical by display name
{ prefix: "ANTHROPIC_", name: "Anthropic", priority: 1 },
{ prefix: "DASHSCOPE_", name: "DashScope (Qwen)", priority: 2 },
{ prefix: "HERMES_QWEN_", name: "DashScope (Qwen)", priority: 2 },
{ prefix: "DEEPSEEK_", name: "DeepSeek", priority: 3 },
{ prefix: "GOOGLE_", name: "Gemini", priority: 4 },
{ prefix: "GEMINI_", name: "Gemini", priority: 4 },
{ prefix: "GLM_", name: "GLM / Z.AI", priority: 5 },
{ prefix: "ZAI_", name: "GLM / Z.AI", priority: 5 },
{ prefix: "Z_AI_", name: "GLM / Z.AI", priority: 5 },
{ prefix: "HF_", name: "Hugging Face", priority: 6 },
{ prefix: "KIMI_", name: "Kimi / Moonshot", priority: 7 },
{ prefix: "MINIMAX_CN_", name: "MiniMax (China)", priority: 9 },
{ prefix: "MINIMAX_", name: "MiniMax", priority: 8 },
{ prefix: "OPENCODE_GO_", name: "OpenCode Go", priority: 10 },
{ prefix: "OPENCODE_ZEN_", name: "OpenCode Zen", priority: 11 },
{ prefix: "OPENROUTER_", name: "OpenRouter", priority: 12 },
{ prefix: "XIAOMI_", name: "Xiaomi MiMo", priority: 13 },
];
function getProviderGroup(key: string): string {
for (const g of PROVIDER_GROUPS) {
if (key.startsWith(g.prefix)) return g.name;
}
return "Other";
}
function getProviderPriority(groupName: string): number {
const entry = PROVIDER_GROUPS.find((g) => g.name === groupName);
return entry?.priority ?? 99;
}
interface ProviderGroup {
name: string;
priority: number;
entries: [string, EnvVarInfo][];
hasAnySet: boolean;
}
const CATEGORY_META_ICONS: Record<string, typeof KeyRound> = {
provider: Zap,
tool: KeyRound,
messaging: MessageSquare,
setting: Settings,
};
/* ------------------------------------------------------------------ */
/* EnvVarRow — single key edit row */
/* ------------------------------------------------------------------ */
function EnvVarRow({
varKey,
info,
edits,
setEdits,
revealed,
saving,
onSave,
onClear,
onReveal,
onCancelEdit,
clearDialogOpen = false,
compact = false,
}: {
varKey: string;
info: EnvVarInfo;
edits: Record<string, string>;
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
revealed: Record<string, string>;
saving: string | null;
onSave: (key: string) => void;
onClear: (key: string) => void;
onReveal: (key: string) => void;
onCancelEdit: (key: string) => void;
clearDialogOpen?: boolean;
compact?: boolean;
}) {
const { t } = useI18n();
const isEditing = edits[varKey] !== undefined;
const isRevealed = !!revealed[varKey];
const displayValue = isRevealed
? revealed[varKey]
: (info.redacted_value ?? "---");
// Compact inline row for unset, non-editing keys (used inside provider groups)
if (compact && !info.is_set && !isEditing) {
return (
<div className="flex items-center justify-between gap-3 py-1.5 opacity-50 hover:opacity-100 transition-opacity">
<div className="flex items-center gap-2 min-w-0">
<span className="font-mono-ui text-[0.7rem] text-muted-foreground">
{varKey}
</span>
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">
{info.description}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{info.url && (
<a
href={info.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
>
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
<Button
size="sm"
outlined
prefix={<Pencil />}
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}
>
{t.common.set}
</Button>
</div>
</div>
);
}
// Non-compact unset row
if (!info.is_set && !isEditing) {
return (
<div className="flex items-center justify-between gap-3 border border-border/50 px-4 py-2.5 opacity-60 hover:opacity-100 transition-opacity">
<div className="flex items-center gap-3 min-w-0">
<Label className="font-mono-ui text-[0.7rem] text-muted-foreground">
{varKey}
</Label>
<span className="text-[0.65rem] text-muted-foreground/60 truncate hidden sm:block">
{info.description}
</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{info.url && (
<a
href={info.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
>
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
<Button
size="sm"
outlined
prefix={<Pencil />}
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}
>
{t.common.set}
</Button>
</div>
</div>
);
}
// Full expanded row for set keys or keys being edited
return (
<div className="grid gap-2 border border-border p-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<Label className="font-mono-ui text-[0.7rem]">{varKey}</Label>
<Badge tone={info.is_set ? "success" : "outline"}>
{info.is_set ? t.common.set : t.env.notSet}
</Badge>
</div>
{info.url && (
<a
href={info.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
>
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
</div>
<p className="text-xs text-muted-foreground">{info.description}</p>
{info.tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{info.tools.map((tool) => (
<Badge
key={tool}
tone="secondary"
className="text-[0.6rem] py-0 px-1.5"
>
{tool}
</Badge>
))}
</div>
)}
{!isEditing && (
<div className="flex items-center gap-2">
<div
className={`flex-1 border border-border px-3 py-2 font-mono-ui text-xs ${
isRevealed
? "bg-background text-foreground select-all"
: "bg-muted/30 text-muted-foreground"
}`}
>
{info.is_set ? displayValue : "---"}
</div>
{info.is_set && (
<Button
ghost
size="icon"
onClick={() => onReveal(varKey)}
title={isRevealed ? t.env.hideValue : t.env.showValue}
aria-label={isRevealed ? `Hide ${varKey}` : `Reveal ${varKey}`}
>
{isRevealed ? <EyeOff /> : <Eye />}
</Button>
)}
<Button
size="sm"
outlined
prefix={<Pencil />}
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}
>
{info.is_set ? t.common.replace : t.common.set}
</Button>
{info.is_set && (
<Button
size="sm"
outlined
destructive
prefix={<Trash2 />}
onClick={() => onClear(varKey)}
disabled={saving === varKey || clearDialogOpen}
>
{saving === varKey ? "..." : t.common.clear}
</Button>
)}
</div>
)}
{isEditing && (
<div className="flex items-center gap-2">
<Input
autoFocus
type="text"
value={edits[varKey]}
onChange={(e) =>
setEdits((prev) => ({ ...prev, [varKey]: e.target.value }))
}
placeholder={
info.is_set
? t.env.replaceCurrentValue.replace(
"{preview}",
info.redacted_value ?? "---",
)
: t.env.enterValue
}
className="flex-1 font-mono-ui text-xs"
/>
<Button
size="sm"
onClick={() => onSave(varKey)}
prefix={<Save />}
disabled={saving === varKey || !edits[varKey]}
>
{saving === varKey ? "..." : t.common.save}
</Button>
<Button
size="sm"
outlined
prefix={<X />}
onClick={() => onCancelEdit(varKey)}
>
{t.common.cancel}
</Button>
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* ProviderGroupCard — groups API key + base URL per provider */
/* ------------------------------------------------------------------ */
function ProviderGroupCard({
group,
edits,
setEdits,
revealed,
saving,
onSave,
onClear,
onReveal,
onCancelEdit,
clearDialogOpen = false,
}: {
group: ProviderGroup;
edits: Record<string, string>;
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
revealed: Record<string, string>;
saving: string | null;
onSave: (key: string) => void;
onClear: (key: string) => void;
onReveal: (key: string) => void;
onCancelEdit: (key: string) => void;
clearDialogOpen?: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const { t } = useI18n();
// Separate API keys from base URLs and other settings
const apiKeys = group.entries.filter(
([k]) => k.endsWith("_API_KEY") || k.endsWith("_TOKEN"),
);
const baseUrls = group.entries.filter(([k]) => k.endsWith("_BASE_URL"));
const other = group.entries.filter(
([k]) =>
!k.endsWith("_API_KEY") &&
!k.endsWith("_TOKEN") &&
!k.endsWith("_BASE_URL"),
);
const hasAnyConfigured = group.entries.some(([, info]) => info.is_set);
const configuredCount = group.entries.filter(
([, info]) => info.is_set,
).length;
// Get a representative URL for "Get key" link
const keyUrl = apiKeys.find(([, info]) => info.url)?.[1]?.url ?? null;
return (
<div className="border border-border">
{/* Header — always visible */}
<ListItem
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
className="justify-between gap-3 px-4 py-3 hover:bg-primary/5"
>
<div className="flex items-center gap-3 min-w-0">
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)}
<span className="font-semibold text-sm tracking-wide">
{group.name === "Other" ? t.common.other : group.name}
</span>
{hasAnyConfigured && (
<Badge tone="success" className="text-[0.6rem]">
{configuredCount} {t.common.set.toLowerCase()}
</Badge>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{keyUrl && (
<a
href={keyUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
</a>
)}
<span className="text-[0.65rem] text-muted-foreground/60">
{t.env.keysCount
.replace("{count}", String(group.entries.length))
.replace("{s}", group.entries.length !== 1 ? "s" : "")}
</span>
</div>
</ListItem>
{expanded && (
<div className="border-t border-border px-4 py-3 grid gap-2">
{apiKeys.map(([key, info]) => (
<EnvVarRow
key={key}
varKey={key}
info={info}
compact
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={onSave}
onClear={onClear}
onReveal={onReveal}
onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/>
))}
{baseUrls.map(([key, info]) => (
<EnvVarRow
key={key}
varKey={key}
info={info}
compact
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={onSave}
onClear={onClear}
onReveal={onReveal}
onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/>
))}
{other.map(([key, info]) => (
<EnvVarRow
key={key}
varKey={key}
info={info}
compact
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={onSave}
onClear={onClear}
onReveal={onReveal}
onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/>
))}
</div>
)}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Main page */
/* ------------------------------------------------------------------ */
export default function EnvPage() {
const [vars, setVars] = useState<Record<string, EnvVarInfo> | null>(null);
const [edits, setEdits] = useState<Record<string, string>>({});
const [revealed, setRevealed] = useState<Record<string, string>>({});
const [saving, setSaving] = useState<string | null>(null);
const [showAdvanced, setShowAdvanced] = useState(true); // Show all providers by default
const { toast, showToast } = useToast();
const { t } = useI18n();
useEffect(() => {
api
.getEnvVars()
.then(setVars)
.catch(() => {});
}, []);
const handleSave = async (key: string) => {
const value = edits[key];
if (!value) return;
setSaving(key);
try {
await api.setEnvVar(key, value);
setVars((prev) =>
prev
? {
...prev,
[key]: {
...prev[key],
is_set: true,
redacted_value: value.slice(0, 4) + "..." + value.slice(-4),
},
}
: prev,
);
setEdits((prev) => {
const n = { ...prev };
delete n[key];
return n;
});
setRevealed((prev) => {
const n = { ...prev };
delete n[key];
return n;
});
showToast(`${key} ${t.common.save.toLowerCase()}d`, "success");
} catch (e) {
showToast(`${t.config.failedToSave} ${key}: ${e}`, "error");
} finally {
setSaving(null);
}
};
const keyClear = useConfirmDelete({
onDelete: useCallback(
async (key: string) => {
setSaving(key);
try {
await api.deleteEnvVar(key);
setVars((prev) =>
prev
? {
...prev,
[key]: { ...prev[key], is_set: false, redacted_value: null },
}
: prev,
);
setEdits((prev) => {
const n = { ...prev };
delete n[key];
return n;
});
setRevealed((prev) => {
const n = { ...prev };
delete n[key];
return n;
});
showToast(`${key} ${t.common.removed}`, "success");
} catch (e) {
showToast(`${t.common.failedToRemove} ${key}: ${e}`, "error");
throw e;
} finally {
setSaving(null);
}
},
[showToast, t.common.removed, t.common.failedToRemove],
),
});
const handleReveal = async (key: string) => {
if (revealed[key]) {
setRevealed((prev) => {
const n = { ...prev };
delete n[key];
return n;
});
return;
}
try {
const resp = await api.revealEnvVar(key);
setRevealed((prev) => ({ ...prev, [key]: resp.value }));
} catch {
showToast(`${t.common.failedToReveal} ${key}`, "error");
}
};
const cancelEdit = (key: string) => {
setEdits((prev) => {
const n = { ...prev };
delete n[key];
return n;
});
};
/* ---- Build provider groups ---- */
const { providerGroups, nonProviderGrouped } = useMemo(() => {
if (!vars) return { providerGroups: [], nonProviderGrouped: [] };
const providerEntries = Object.entries(vars).filter(
([, info]) =>
info.category === "provider" && (showAdvanced || !info.advanced),
);
// Group by provider
const groupMap = new Map<string, [string, EnvVarInfo][]>();
for (const entry of providerEntries) {
const groupName = getProviderGroup(entry[0]);
if (!groupMap.has(groupName)) groupMap.set(groupName, []);
groupMap.get(groupName)!.push(entry);
}
const groups: ProviderGroup[] = Array.from(groupMap.entries())
.map(([name, entries]) => ({
name,
priority: getProviderPriority(name),
entries,
hasAnySet: entries.some(([, info]) => info.is_set),
}))
.sort((a, b) => a.priority - b.priority);
// Non-provider categories — use translated labels
const CATEGORY_META_LABELS: Record<string, string> = {
tool: t.app.nav.keys,
messaging: t.common.messaging,
setting: t.app.nav.config,
};
const otherCategories = ["tool", "messaging", "setting"];
const nonProvider = otherCategories.map((cat) => {
const entries = Object.entries(vars).filter(
([, info]) => info.category === cat && (showAdvanced || !info.advanced),
);
const setEntries = entries.filter(([, info]) => info.is_set);
const unsetEntries = entries.filter(([, info]) => !info.is_set);
return {
label: CATEGORY_META_LABELS[cat] ?? cat,
icon: CATEGORY_META_ICONS[cat] ?? KeyRound,
category: cat,
setEntries,
unsetEntries,
totalEntries: entries.length,
};
});
return { providerGroups: groups, nonProviderGrouped: nonProvider };
}, [vars, showAdvanced, t]);
if (!vars) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
const totalProviders = providerGroups.length;
const configuredProviders = providerGroups.filter((g) => g.hasAnySet).length;
const pendingClearKey = keyClear.pendingId;
const pendingKeyDescription =
pendingClearKey && vars ? vars[pendingClearKey]?.description : undefined;
return (
<div className="flex flex-col gap-6">
<PluginSlot name="env:top" />
<Toast toast={toast} />
<DeleteConfirmDialog
open={keyClear.isOpen}
onCancel={keyClear.cancel}
onConfirm={keyClear.confirm}
title={t.env.confirmClearTitle}
description={
pendingClearKey
? `${pendingClearKey}${pendingKeyDescription ? `${pendingKeyDescription}` : ""}. ${t.env.confirmClearMessage}`
: t.env.confirmClearMessage
}
loading={keyClear.isDeleting}
/>
<div className="flex items-center justify-between">
<div className="flex flex-col gap-1">
<p className="text-sm text-muted-foreground">
{t.env.description} <code>~/.hermes/.env</code>
</p>
<p className="text-[0.7rem] text-muted-foreground/70">
{t.env.changesNote}
</p>
</div>
<Button
size="sm"
outlined
onClick={() => setShowAdvanced(!showAdvanced)}
>
{showAdvanced ? t.env.hideAdvanced : t.env.showAdvanced}
</Button>
</div>
<OAuthProvidersCard
onError={(msg) => showToast(msg, "error")}
onSuccess={(msg) => showToast(msg, "success")}
/>
<Card>
<CardHeader className="border-b border-border bg-card">
<div className="flex items-center gap-2">
<Zap className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{t.env.llmProviders}</CardTitle>
</div>
<CardDescription>
{t.env.providersConfigured
.replace("{configured}", String(configuredProviders))
.replace("{total}", String(totalProviders))}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-0 p-0">
{providerGroups.map((group) => (
<ProviderGroupCard
key={group.name}
group={group}
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={handleSave}
onClear={keyClear.requestDelete}
onReveal={handleReveal}
onCancelEdit={cancelEdit}
clearDialogOpen={keyClear.isOpen}
/>
))}
</CardContent>
</Card>
{nonProviderGrouped.map(
({
label,
icon: Icon,
setEntries,
unsetEntries,
totalEntries,
category,
}) => {
if (totalEntries === 0) return null;
return (
<Card key={category}>
<CardHeader className="border-b border-border bg-card">
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">{label}</CardTitle>
</div>
<CardDescription>
{setEntries.length} {t.common.of} {totalEntries}{" "}
{t.common.configured}
</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 pt-4">
{setEntries.map(([key, info]) => (
<EnvVarRow
key={key}
varKey={key}
info={info}
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={handleSave}
onClear={keyClear.requestDelete}
onReveal={handleReveal}
onCancelEdit={cancelEdit}
clearDialogOpen={keyClear.isOpen}
/>
))}
{unsetEntries.length > 0 && (
<CollapsibleUnset
category={category}
unsetEntries={unsetEntries}
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={handleSave}
onClear={keyClear.requestDelete}
onReveal={handleReveal}
onCancelEdit={cancelEdit}
clearDialogOpen={keyClear.isOpen}
/>
)}
</CardContent>
</Card>
);
},
)}
<PluginSlot name="env:bottom" />
</div>
);
}
/* ------------------------------------------------------------------ */
/* CollapsibleUnset — for non-provider categories */
/* ------------------------------------------------------------------ */
function CollapsibleUnset({
category: _category,
unsetEntries,
edits,
setEdits,
revealed,
saving,
onSave,
onClear,
onReveal,
onCancelEdit,
clearDialogOpen = false,
}: {
category: string;
unsetEntries: [string, EnvVarInfo][];
edits: Record<string, string>;
setEdits: React.Dispatch<React.SetStateAction<Record<string, string>>>;
revealed: Record<string, string>;
saving: string | null;
onSave: (key: string) => void;
onClear: (key: string) => void;
onReveal: (key: string) => void;
onCancelEdit: (key: string) => void;
clearDialogOpen?: boolean;
}) {
const [collapsed, setCollapsed] = useState(true);
const { t } = useI18n();
return (
<>
<Button
ghost
size="sm"
prefix={collapsed ? <ChevronRight /> : <ChevronDown />}
onClick={() => setCollapsed(!collapsed)}
aria-expanded={!collapsed}
className="self-start mt-1 normal-case tracking-normal text-xs text-muted-foreground hover:text-foreground"
>
{t.env.notConfigured.replace("{count}", String(unsetEntries.length))}
</Button>
{!collapsed &&
unsetEntries.map(([key, info]) => (
<EnvVarRow
key={key}
varKey={key}
info={info}
edits={edits}
setEdits={setEdits}
revealed={revealed}
saving={saving}
onSave={onSave}
onClear={onClear}
onReveal={onReveal}
onCancelEdit={onCancelEdit}
clearDialogOpen={clearDialogOpen}
/>
))}
</>
);
}
-233
View File
@@ -1,233 +0,0 @@
import {
useEffect,
useLayoutEffect,
useState,
useCallback,
useRef,
} from "react";
import { FileText, RefreshCw } from "lucide-react";
import { api } from "@/lib/api";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { FilterGroup, Segmented } from "@nous-research/ui/ui/components/segmented";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Switch } from "@nous-research/ui/ui/components/switch";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
const FILES = ["agent", "errors", "gateway"] as const;
const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const;
const COMPONENTS = ["all", "gateway", "agent", "tools", "cli", "cron"] as const;
const LINE_COUNTS = [50, 100, 200, 500] as const;
function classifyLine(line: string): "error" | "warning" | "info" | "debug" {
const upper = line.toUpperCase();
if (
upper.includes("ERROR") ||
upper.includes("CRITICAL") ||
upper.includes("FATAL")
)
return "error";
if (upper.includes("WARNING") || upper.includes("WARN")) return "warning";
if (upper.includes("DEBUG")) return "debug";
return "info";
}
const LINE_COLORS: Record<string, string> = {
error: "text-destructive",
warning: "text-warning",
info: "text-foreground",
debug: "text-muted-foreground/60",
};
const toOptions = <T extends string>(values: readonly T[]) =>
values.map((v) => ({ value: v, label: v }));
export default function LogsPage() {
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL");
const [component, setComponent] =
useState<(typeof COMPONENTS)[number]>("all");
const [lineCount, setLineCount] = useState<(typeof LINE_COUNTS)[number]>(100);
const [autoRefresh, setAutoRefresh] = useState(false);
const [lines, setLines] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
const fetchLogs = useCallback(() => {
setLoading(true);
setError(null);
api
.getLogs({ file, lines: lineCount, level, component })
.then((resp) => {
setLines(resp.lines);
setTimeout(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, 50);
})
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}, [file, lineCount, level, component]);
useLayoutEffect(() => {
setAfterTitle(
<span className="flex items-center gap-2">
{loading && <Spinner className="shrink-0 text-base text-primary" />}
<Badge tone="secondary" className="text-[10px]">
{file} · {level} · {component}
</Badge>
</span>,
);
setEnd(
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-3">
<div className="flex items-center gap-2">
<Switch
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
id="logs-auto-refresh"
/>
<Label htmlFor="logs-auto-refresh" className="text-xs cursor-pointer">
{t.logs.autoRefresh}
</Label>
{autoRefresh && (
<Badge tone="success" className="text-[10px]">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
{t.common.live}
</Badge>
)}
</div>
<Button
type="button"
size="sm"
outlined
onClick={fetchLogs}
disabled={loading}
prefix={loading ? <Spinner /> : <RefreshCw />}
>
{t.common.refresh}
</Button>
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [
autoRefresh,
component,
file,
level,
loading,
setAfterTitle,
setEnd,
t.common.live,
t.common.refresh,
t.logs.autoRefresh,
fetchLogs,
]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(fetchLogs, 5000);
return () => clearInterval(interval);
}, [autoRefresh, fetchLogs]);
return (
<div className="flex flex-col gap-4">
<PluginSlot name="logs:top" />
<div
role="toolbar"
aria-label={t.logs.title}
className="flex flex-wrap items-center gap-x-6 gap-y-2"
>
<FilterGroup label={t.logs.file}>
<Segmented
value={file}
onChange={setFile}
options={toOptions(FILES)}
/>
</FilterGroup>
<FilterGroup label={t.logs.level}>
<Segmented
value={level}
onChange={setLevel}
options={toOptions(LEVELS)}
/>
</FilterGroup>
<FilterGroup label={t.logs.component}>
<Segmented
value={component}
onChange={setComponent}
options={toOptions(COMPONENTS)}
/>
</FilterGroup>
<FilterGroup label={t.logs.lines}>
<Segmented
value={String(lineCount)}
onChange={(v) =>
setLineCount(Number(v) as (typeof LINE_COUNTS)[number])
}
options={LINE_COUNTS.map((n) => ({
value: String(n),
label: String(n),
}))}
/>
</FilterGroup>
</div>
<Card>
<CardHeader className="py-3 px-4">
<CardTitle className="text-sm flex items-center gap-2">
<FileText className="h-4 w-4" />
{file}.log
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{error && (
<div className="bg-destructive/10 border-b border-destructive/20 p-3">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
<div
ref={scrollRef}
className="p-4 font-mono-ui text-xs leading-5 overflow-auto min-h-[400px] max-h-[calc(100vh-220px)]"
>
{lines.length === 0 && !loading && (
<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`}
>
{line}
</div>
);
})}
</div>
</CardContent>
</Card>
<PluginSlot name="logs:bottom" />
</div>
);
}
-817
View File
@@ -1,817 +0,0 @@
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
import {
Brain,
ChevronDown,
Cpu,
DollarSign,
Eye,
RefreshCw,
Settings2,
Star,
Wrench,
Zap,
} from "lucide-react";
import { api } from "@/lib/api";
import type {
AuxiliaryModelsResponse,
AuxiliaryTaskAssignment,
ModelsAnalyticsModelEntry,
ModelsAnalyticsResponse,
} from "@/lib/api";
import { timeAgo } from "@/lib/utils";
import { formatTokenCount } from "@/lib/format";
import { Button } from "@nous-research/ui/ui/components/button";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Stats } from "@nous-research/ui/ui/components/stats";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
import { ModelPickerDialog } from "@/components/ModelPickerDialog";
const PERIODS = [
{ label: "7d", days: 7 },
{ label: "30d", days: 30 },
{ label: "90d", days: 90 },
] as const;
// Must match _AUX_TASK_SLOTS in hermes_cli/web_server.py.
const AUX_TASKS: readonly { key: string; label: string; hint: string }[] = [
{ key: "vision", label: "Vision", hint: "Image analysis" },
{ key: "web_extract", label: "Web Extract", hint: "Page summarization" },
{ key: "compression", label: "Compression", hint: "Context compaction" },
{ key: "session_search", label: "Session Search", hint: "Recall queries" },
{ key: "skills_hub", label: "Skills Hub", hint: "Skill search" },
{ key: "approval", label: "Approval", hint: "Smart auto-approve" },
{ key: "mcp", label: "MCP", hint: "MCP tool routing" },
{ key: "title_generation", label: "Title Gen", hint: "Session titles" },
{ key: "curator", label: "Curator", hint: "Skill-usage review" },
] as const;
function formatTokens(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return String(n);
}
function formatCost(n: number): string {
if (n >= 1) return `$${n.toFixed(2)}`;
if (n >= 0.01) return `$${n.toFixed(3)}`;
if (n > 0) return `$${n.toFixed(4)}`;
return "$0";
}
/** Short model name: strip vendor prefix like "openrouter/" or "anthropic/". */
function shortModelName(model: string): string {
const slashIdx = model.indexOf("/");
if (slashIdx > 0) return model.slice(slashIdx + 1);
return model;
}
/** Extract vendor prefix from a model string like "anthropic/claude-opus-4.7" → "anthropic". */
function modelVendor(model: string, fallback?: string): string {
const slashIdx = model.indexOf("/");
if (slashIdx > 0) return model.slice(0, slashIdx);
return fallback || "";
}
function TokenBar({
input,
output,
cacheRead,
reasoning,
}: {
input: number;
output: number;
cacheRead: number;
reasoning: number;
}) {
const total = input + output + cacheRead + reasoning;
if (total === 0) return null;
const segments = [
{ value: cacheRead, color: "bg-blue-400/60", label: "Cache Read" },
{ value: reasoning, color: "bg-purple-400/60", label: "Reasoning" },
{ value: input, color: "bg-[#ffe6cb]/70", label: "Input" },
{ value: output, color: "bg-emerald-500/70", label: "Output" },
].filter((s) => s.value > 0);
return (
<div className="space-y-1">
<div className="flex h-2 w-full overflow-hidden rounded-sm bg-muted/30">
{segments.map((s, i) => (
<div
key={i}
className={`${s.color} transition-all duration-300`}
style={{ width: `${(s.value / total) * 100}%` }}
/>
))}
</div>
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground">
{segments.map((s, i) => (
<span key={i} className="flex items-center gap-1">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${s.color}`} />
{s.label} {formatTokens(s.value)}
</span>
))}
</div>
</div>
);
}
function CapabilityBadges({
capabilities,
}: {
capabilities: ModelsAnalyticsModelEntry["capabilities"];
}) {
const hasAny =
capabilities.supports_tools ||
capabilities.supports_vision ||
capabilities.supports_reasoning ||
capabilities.model_family;
if (!hasAny) return null;
return (
<div className="flex flex-wrap items-center gap-1.5">
{capabilities.supports_tools && (
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-600 dark:text-emerald-400">
<Wrench className="h-2.5 w-2.5" /> Tools
</span>
)}
{capabilities.supports_vision && (
<span className="inline-flex items-center gap-1 bg-blue-500/10 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<Eye className="h-2.5 w-2.5" /> Vision
</span>
)}
{capabilities.supports_reasoning && (
<span className="inline-flex items-center gap-1 bg-purple-500/10 px-1.5 py-0.5 text-[10px] font-medium text-purple-600 dark:text-purple-400">
<Brain className="h-2.5 w-2.5" /> Reasoning
</span>
)}
{capabilities.model_family && (
<span className="inline-flex items-center bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{capabilities.model_family}
</span>
)}
</div>
);
}
/* ──────────────────────────────────────────────────────────────────── */
/* Per-card "Use as" menu */
/* ──────────────────────────────────────────────────────────────────── */
function UseAsMenu({
provider,
model,
isMain,
mainAuxTask,
onAssigned,
}: {
provider: string;
model: string;
/** True when this card's model+provider match config.yaml's main slot. */
isMain: boolean;
/** If this model is assigned to a specific aux task, that task's key. */
mainAuxTask: string | null;
onAssigned(): void;
}) {
const [open, setOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const assign = async (
scope: "main" | "auxiliary",
task: string,
) => {
if (!provider || !model) {
setError("Missing provider/model");
return;
}
setBusy(true);
setError(null);
try {
await api.setModelAssignment({ scope, provider, model, task });
onAssigned();
setOpen(false);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
// Close on outside click.
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
if (target && !target.closest?.("[data-use-as-menu]")) setOpen(false);
};
window.addEventListener("mousedown", onDown);
return () => window.removeEventListener("mousedown", onDown);
}, [open]);
return (
<div className="relative" data-use-as-menu>
<Button
size="sm"
outlined
onClick={() => setOpen((v) => !v)}
disabled={busy}
className="text-[10px] h-6 px-2"
prefix={busy ? <Spinner /> : null}
>
Use as <ChevronDown className="h-3 w-3" />
</Button>
{open && (
<div className="absolute right-0 top-full mt-1 z-50 min-w-[220px] border border-border bg-card shadow-lg">
<button
type="button"
onClick={() => assign("main", "")}
disabled={busy}
className="flex w-full items-center justify-between px-3 py-2 text-xs hover:bg-muted/50 disabled:opacity-40"
>
<span className="flex items-center gap-2">
<Star className="h-3 w-3" />
Main model
</span>
{isMain && (
<span className="text-[9px] uppercase tracking-wider text-primary/80">
current
</span>
)}
</button>
<div className="border-t border-border/50 px-3 py-1.5 text-[9px] uppercase tracking-wider text-muted-foreground">
Auxiliary task
</div>
<button
type="button"
onClick={() => assign("auxiliary", "")}
disabled={busy}
className="flex w-full items-center justify-between px-3 py-1.5 text-xs hover:bg-muted/50 disabled:opacity-40"
>
<span>All auxiliary tasks</span>
</button>
{AUX_TASKS.map((t) => (
<button
key={t.key}
type="button"
onClick={() => assign("auxiliary", t.key)}
disabled={busy}
className="flex w-full items-center justify-between px-3 py-1.5 text-xs hover:bg-muted/50 disabled:opacity-40"
>
<span>{t.label}</span>
{mainAuxTask === t.key && (
<span className="text-[9px] uppercase tracking-wider text-primary/80">
current
</span>
)}
</button>
))}
{error && (
<div className="px-3 py-2 text-[10px] text-destructive border-t border-border/50">
{error}
</div>
)}
</div>
)}
</div>
);
}
/* ──────────────────────────────────────────────────────────────────── */
/* ModelCard */
/* ──────────────────────────────────────────────────────────────────── */
function ModelCard({
entry,
rank,
main,
aux,
onAssigned,
}: {
entry: ModelsAnalyticsModelEntry;
rank: number;
main: { provider: string; model: string } | null;
aux: AuxiliaryTaskAssignment[];
onAssigned(): void;
}) {
const { t } = useI18n();
const provider = entry.provider || modelVendor(entry.model);
const totalTokens = entry.input_tokens + entry.output_tokens;
const caps = entry.capabilities;
const isMain =
!!main &&
main.provider === provider &&
main.model === entry.model;
// First aux task currently using this model (if any).
const mainAuxTask =
aux.find(
(a) => a.provider === provider && a.model === entry.model,
)?.task ?? null;
return (
<Card className={isMain ? "ring-1 ring-primary/40" : undefined}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-muted-foreground/50 text-xs font-mono">
#{rank}
</span>
<CardTitle className="text-sm font-mono-ui truncate">
{shortModelName(entry.model)}
</CardTitle>
{isMain && (
<span className="inline-flex items-center gap-0.5 bg-primary/15 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider text-primary">
<Star className="h-2.5 w-2.5" /> main
</span>
)}
{mainAuxTask && (
<span className="inline-flex items-center bg-purple-500/10 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-wider text-purple-600 dark:text-purple-400">
aux · {mainAuxTask}
</span>
)}
</div>
<div className="flex items-center gap-2 mt-1">
{provider && (
<Badge tone="secondary" className="text-[9px]">
{provider}
</Badge>
)}
{caps.context_window && caps.context_window > 0 && (
<span className="text-[10px] text-muted-foreground">
{formatTokenCount(caps.context_window)} ctx
</span>
)}
{caps.max_output_tokens && caps.max_output_tokens > 0 && (
<span className="text-[10px] text-muted-foreground">
{formatTokenCount(caps.max_output_tokens)} out
</span>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<div className="text-right">
<div className="text-xs font-mono font-semibold">
{formatTokens(totalTokens)}
</div>
<div className="text-[10px] text-muted-foreground">
{t.models.tokens}
</div>
</div>
<UseAsMenu
provider={provider}
model={entry.model}
isMain={isMain}
mainAuxTask={mainAuxTask}
onAssigned={onAssigned}
/>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-0">
<TokenBar
input={entry.input_tokens}
output={entry.output_tokens}
cacheRead={entry.cache_read_tokens}
reasoning={entry.reasoning_tokens}
/>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="text-center">
<div className="font-mono font-semibold">{entry.sessions}</div>
<div className="text-[10px] text-muted-foreground">
{t.models.sessions}
</div>
</div>
<div className="text-center">
<div className="font-mono font-semibold">
{formatTokens(entry.avg_tokens_per_session)}
</div>
<div className="text-[10px] text-muted-foreground">
{t.models.avgPerSession}
</div>
</div>
<div className="text-center">
<div className="font-mono font-semibold">
{entry.api_calls > 0 ? formatTokens(entry.api_calls) : "—"}
</div>
<div className="text-[10px] text-muted-foreground">
{t.models.apiCalls}
</div>
</div>
</div>
<div className="flex items-center justify-between text-[10px] text-muted-foreground border-t border-border/30 pt-2">
<div className="flex items-center gap-3">
{entry.estimated_cost > 0 && (
<span className="flex items-center gap-0.5">
<DollarSign className="h-2.5 w-2.5" />
{formatCost(entry.estimated_cost)}
</span>
)}
{entry.tool_calls > 0 && (
<span className="flex items-center gap-0.5">
<Zap className="h-2.5 w-2.5" />
{entry.tool_calls} {t.models.toolCalls}
</span>
)}
</div>
{entry.last_used_at > 0 && (
<span>{timeAgo(entry.last_used_at)}</span>
)}
</div>
<CapabilityBadges capabilities={entry.capabilities} />
</CardContent>
</Card>
);
}
/* ──────────────────────────────────────────────────────────────────── */
/* Model Settings panel (top of page) */
/* ──────────────────────────────────────────────────────────────────── */
type PickerTarget =
| { kind: "main" }
| { kind: "aux"; task: string };
function ModelSettingsPanel({
aux,
refreshKey,
onSaved,
}: {
aux: AuxiliaryModelsResponse | null;
refreshKey: number;
onSaved(): void;
}) {
const [expanded, setExpanded] = useState(false);
const [picker, setPicker] = useState<PickerTarget | null>(null);
const [resetBusy, setResetBusy] = useState(false);
const mainProv = aux?.main.provider ?? "";
const mainModel = aux?.main.model ?? "";
const applyAssignment = async ({
scope,
task,
provider,
model,
}: {
scope: "main" | "auxiliary";
task: string;
provider: string;
model: string;
}) => {
await api.setModelAssignment({ scope, task, provider, model });
onSaved();
};
const resetAllAux = async () => {
if (!window.confirm("Reset every auxiliary task to 'auto'? This overrides any per-task overrides you've set.")) {
return;
}
setResetBusy(true);
try {
await api.setModelAssignment({
scope: "auxiliary",
task: "__reset__",
provider: "",
model: "",
});
onSaved();
} finally {
setResetBusy(false);
}
};
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Settings2 className="h-4 w-4 text-muted-foreground" />
<CardTitle className="text-sm">Model Settings</CardTitle>
<span className="text-[10px] text-muted-foreground">
applies to new sessions
</span>
</div>
<Button
size="sm"
outlined
onClick={() => setExpanded((v) => !v)}
className="text-xs"
>
{expanded ? "Hide auxiliary" : "Show auxiliary"}
<ChevronDown
className={`h-3 w-3 transition-transform ${expanded ? "rotate-180" : ""}`}
/>
</Button>
</div>
</CardHeader>
<CardContent className="space-y-3 pt-0">
{/* Main row */}
<div className="flex items-center justify-between gap-3 bg-muted/20 border border-border/50 px-3 py-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-0.5">
<Star className="h-3 w-3 text-primary" />
<span className="text-xs font-medium uppercase tracking-wider">
Main model
</span>
</div>
<div className="text-xs font-mono text-muted-foreground truncate">
{mainProv || "(unset)"}
{mainProv && mainModel && " · "}
{mainModel || "(unset)"}
</div>
</div>
<Button
size="sm"
onClick={() => setPicker({ kind: "main" })}
className="text-xs"
>
Change
</Button>
</div>
{/* Auxiliary rows */}
{expanded && (
<div className="space-y-1 border-t border-border/50 pt-3">
<div className="flex items-center justify-between pb-1">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
Auxiliary tasks
</div>
<Button
size="sm"
outlined
onClick={resetAllAux}
disabled={resetBusy}
className="text-[10px] h-6"
prefix={resetBusy ? <Spinner /> : null}
>
Reset all to auto
</Button>
</div>
<p className="text-[10px] text-muted-foreground/80 pb-2">
Auxiliary tasks handle side-jobs like vision, session search, and
compression. <span className="font-mono">auto</span> means
&quot;use the main model&quot;. Override per-task when you want a
cheap/fast model for a specific job.
</p>
{AUX_TASKS.map((t) => {
const cur = aux?.tasks.find((a) => a.task === t.key);
const isAuto =
!cur || cur.provider === "auto" || !cur.provider;
return (
<div
key={t.key}
className="flex items-center justify-between gap-3 px-3 py-1.5 border border-border/30 bg-card/50 hover:bg-muted/20 transition-colors"
>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2">
<span className="text-xs font-medium">{t.label}</span>
<span className="text-[10px] text-muted-foreground/60">
{t.hint}
</span>
</div>
<div className="text-[10px] font-mono text-muted-foreground truncate">
{isAuto
? "auto (use main model)"
: `${cur?.provider} · ${cur?.model || "(provider default)"}`}
</div>
</div>
<Button
size="sm"
outlined
onClick={() => setPicker({ kind: "aux", task: t.key })}
className="text-[10px] h-6"
>
Change
</Button>
</div>
);
})}
</div>
)}
{picker && (
<ModelPickerDialog
key={`picker-${refreshKey}`}
loader={api.getModelOptions}
alwaysGlobal
title={
picker.kind === "main"
? "Set Main Model"
: `Set Auxiliary: ${
AUX_TASKS.find((t) => t.key === picker.task)?.label ??
picker.task
}`
}
onApply={async ({ provider, model }) => {
await applyAssignment({
scope: picker.kind === "main" ? "main" : "auxiliary",
task: picker.kind === "main" ? "" : picker.task,
provider,
model,
});
}}
onClose={() => setPicker(null)}
/>
)}
</CardContent>
</Card>
);
}
/* ──────────────────────────────────────────────────────────────────── */
/* Page */
/* ──────────────────────────────────────────────────────────────────── */
export default function ModelsPage() {
const [days, setDays] = useState(30);
const [data, setData] = useState<ModelsAnalyticsResponse | null>(null);
const [aux, setAux] = useState<AuxiliaryModelsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [saveKey, setSaveKey] = useState(0);
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
const load = useCallback(() => {
setLoading(true);
setError(null);
Promise.all([
api.getModelsAnalytics(days),
api.getAuxiliaryModels().catch(() => null),
])
.then(([models, auxData]) => {
setData(models);
setAux(auxData);
})
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}, [days]);
const onAssigned = useCallback(() => {
// Reload aux state after any assignment change.
api
.getAuxiliaryModels()
.then(setAux)
.catch(() => {});
setSaveKey((k) => k + 1);
}, []);
useLayoutEffect(() => {
const periodLabel =
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
setAfterTitle(
<span className="flex items-center gap-2">
{loading && <Spinner className="shrink-0 text-base text-primary" />}
<Badge tone="secondary" className="text-[10px]">
{periodLabel}
</Badge>
</span>,
);
setEnd(
<div className="flex w-full min-w-0 flex-wrap items-center justify-end gap-2 sm:gap-2">
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
>
{p.label}
</Button>
))}
</div>
<Button
type="button"
size="sm"
outlined
onClick={load}
disabled={loading}
prefix={loading ? <Spinner /> : <RefreshCw />}
>
{t.common.refresh}
</Button>
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [days, loading, load, setAfterTitle, setEnd, t.common.refresh]);
useEffect(() => {
load();
}, [load]);
return (
<div className="flex flex-col gap-6">
<PluginSlot name="models:top" />
<ModelSettingsPanel
aux={aux}
refreshKey={saveKey}
onSaved={onAssigned}
/>
{loading && !data && (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
)}
{error && (
<Card>
<CardContent className="py-6">
<p className="text-sm text-destructive text-center">{error}</p>
</CardContent>
</Card>
)}
{data && (
<>
<Card>
<CardContent className="py-6">
<Stats
items={[
{
label: t.models.modelsUsed,
value: String(data.totals.distinct_models),
},
{
label: t.analytics.totalTokens,
value: formatTokens(
data.totals.total_input + data.totals.total_output,
),
},
{
label: t.analytics.input,
value: formatTokens(data.totals.total_input),
},
{
label: t.analytics.output,
value: formatTokens(data.totals.total_output),
},
{
label: t.models.estimatedCost,
value: formatCost(data.totals.total_estimated_cost),
},
{
label: t.analytics.totalSessions,
value: String(data.totals.total_sessions),
},
]}
/>
</CardContent>
</Card>
{data.models.length > 0 ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{data.models.map((m, i) => (
<ModelCard
key={`${m.model}:${m.provider}`}
entry={m}
rank={i + 1}
main={aux?.main ?? null}
aux={aux?.tasks ?? []}
onAssigned={onAssigned}
/>
))}
</div>
) : (
<Card>
<CardContent className="py-12">
<div className="flex flex-col items-center text-muted-foreground">
<Cpu className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">{t.models.noModelsData}</p>
<p className="text-xs mt-1 text-muted-foreground/60">
{t.models.startSession}
</p>
</div>
</CardContent>
</Card>
)}
</>
)}
<PluginSlot name="models:bottom" />
</div>
);
}
-581
View File
@@ -1,581 +0,0 @@
import { useCallback, useEffect, useState } from "react";
import { ExternalLink, RefreshCw, Puzzle, Trash2, Eye, EyeOff } from "lucide-react";
import type { Translations } from "@/i18n/types";
import { Link } from "react-router-dom";
import { api } from "@/lib/api";
import type { HubAgentPluginRow, PluginsHubResponse } from "@/lib/api";
import { Button } from "@nous-research/ui/ui/components/button";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Switch } from "@nous-research/ui/ui/components/switch";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { CommandBlock } from "@nous-research/ui/ui/components/command-block";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/useToast";
import { Toast } from "@/components/Toast";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
import { cn } from "@/lib/utils";
import { usePageHeader } from "@/contexts/usePageHeader";
/** Select value for built-in memory (`config` uses empty string). Never use `""` — UI Select maps empty value to an empty label. */
const MEMORY_PROVIDER_BUILTIN = "__hermes_memory_builtin__";
export default function PluginsPage() {
const [hub, setHub] = useState<PluginsHubResponse | null>(null);
const [loading, setLoading] = useState(true);
const [installId, setInstallId] = useState("");
const [installForce, setInstallForce] = useState(false);
const [installEnable, setInstallEnable] = useState(true);
const [installBusy, setInstallBusy] = useState(false);
const [rescanBusy, setRescanBusy] = useState(false);
const [memorySel, setMemorySel] = useState(MEMORY_PROVIDER_BUILTIN);
const [contextSel, setContextSel] = useState("compressor");
const [providerBusy, setProviderBusy] = useState(false);
const [rowBusy, setRowBusy] = useState<string | null>(null);
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setEnd } = usePageHeader();
const loadHub = useCallback(() => {
return api
.getPluginsHub()
.then((h) => {
setHub(h);
const p = h.providers;
setMemorySel(p.memory_provider ? p.memory_provider : MEMORY_PROVIDER_BUILTIN);
setContextSel(p.context_engine || "compressor");
})
.catch(() => showToast(t.common.loading, "error"));
}, [showToast, t.common.loading]);
useEffect(() => {
setLoading(true);
void loadHub().finally(() => setLoading(false));
}, [loadHub]);
useEffect(() => {
setEnd(
<Button
ghost
size="sm"
className="shrink-0 gap-2"
disabled={loading || rescanBusy}
onClick={() => void onRescan()}
>
{rescanBusy ? <Spinner /> : <RefreshCw className="h-3.5 w-3.5" />}
{t.pluginsPage.refreshDashboard}
</Button>,
);
return () => setEnd(null);
}, [loading, rescanBusy, setEnd, t.pluginsPage.refreshDashboard]);
const onInstall = async () => {
const id = installId.trim();
if (!id) {
showToast(t.pluginsPage.installHint, "error");
return;
}
setInstallBusy(true);
try {
const r = await api.installAgentPlugin({
identifier: id,
force: installForce,
enable: installEnable,
});
showToast(`${r.plugin_name ?? id} installed`, "success");
if ((r.warnings?.length ?? 0) > 0) showToast(r.warnings!.join(" "), "error");
if ((r.missing_env?.length ?? 0) > 0)
showToast(`${t.pluginsPage.missingEnvWarn} ${r.missing_env!.join(", ")}`, "error");
setInstallId("");
await loadHub();
} catch (e) {
showToast(e instanceof Error ? e.message : "Install failed", "error");
} finally {
setInstallBusy(false);
}
};
const onRescan = async () => {
setRescanBusy(true);
try {
const rc = await api.rescanPlugins();
showToast(
`${t.pluginsPage.refreshDashboard} (${rc.count})`,
"success",
);
await loadHub();
} catch (e) {
showToast(e instanceof Error ? e.message : "Rescan failed", "error");
} finally {
setRescanBusy(false);
}
};
const onSaveProviders = async () => {
setProviderBusy(true);
try {
await api.savePluginProviders({
memory_provider:
memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel,
context_engine: contextSel,
});
showToast(t.pluginsPage.savedProviders, "success");
await loadHub();
} catch (e) {
showToast(e instanceof Error ? e.message : "Save failed", "error");
} finally {
setProviderBusy(false);
}
};
const setRuntimeLoading = async (name: string, fn: () => Promise<unknown>) => {
setRowBusy(name);
try {
await fn();
await loadHub();
} catch (e) {
showToast(e instanceof Error ? e.message : "Failed", "error");
} finally {
setRowBusy(null);
}
};
const rows = hub?.plugins ?? [];
const providers = hub?.providers;
return (
<div className="flex flex-col gap-4">
<PluginSlot name="plugins:top" />
<div className={cn("flex w-full flex-col gap-8")}>
{providers && (
<Card>
<CardHeader>
<CardTitle>{t.pluginsPage.providersHeading}</CardTitle>
<p className="text-[0.7rem] tracking-[0.08em] text-midground/55 normal-case">
{t.pluginsPage.providersHint}
</p>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div className="grid gap-6 sm:grid-cols-2 max-w-full">
<div className="grid gap-2 min-w-0">
<Label htmlFor="mem-provider">{t.pluginsPage.memoryProviderLabel}</Label>
<Select
id="mem-provider"
className="w-full"
value={memorySel}
onValueChange={setMemorySel}
>
<SelectOption value={MEMORY_PROVIDER_BUILTIN}>
{`(${t.pluginsPage.providerDefaults})`}
</SelectOption>
{providers.memory_options.map((o) => (
<SelectOption key={o.name} value={o.name}>
{o.name}
</SelectOption>
))}
</Select>
</div>
<div className="grid gap-2 min-w-0">
<Label htmlFor="ctx-engine">{t.pluginsPage.contextEngineLabel}</Label>
<Select
id="ctx-engine"
className="w-full"
value={contextSel}
onValueChange={setContextSel}
>
<SelectOption value="compressor">compressor</SelectOption>
{providers.context_options
.filter((o) => o.name !== "compressor")
.map((o) => (
<SelectOption key={o.name} value={o.name}>
{o.name}
</SelectOption>
))}
</Select>
</div>
</div>
<Button
className="w-fit gap-2"
size="sm"
disabled={providerBusy}
onClick={() => void onSaveProviders()}
>
{providerBusy ? <Spinner /> : null}
{t.pluginsPage.saveProviders}
</Button>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>{t.pluginsPage.installHeading}</CardTitle>
<p className="text-[0.7rem] tracking-[0.08em] text-midground/55 normal-case">
{t.pluginsPage.installHint}
</p>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="install-url">{t.pluginsPage.identifierLabel}</Label>
<Input
className="normal-case font-sans lowercase"
id="install-url"
placeholder="owner/repo or https://..."
spellCheck={false}
value={installId}
onChange={(e) => setInstallId(e.target.value)}
/>
</div>
<div className="flex flex-wrap items-center gap-8">
<div className="flex items-center gap-3">
<Switch checked={installForce} onCheckedChange={setInstallForce} />
<span className="text-[0.7rem] tracking-[0.06em] text-midforeground/85 normal-case">
{t.pluginsPage.forceReinstall}
</span>
</div>
<div className="flex items-center gap-3">
<Switch checked={installEnable} onCheckedChange={setInstallEnable} />
<span className="text-[0.7rem] tracking-[0.06em] text-midforeground/85 normal-case">
{t.pluginsPage.enableAfterInstall}
</span>
</div>
</div>
<Button
className="w-fit gap-2"
size="sm"
disabled={installBusy}
onClick={() => void onInstall()}
>
{installBusy ? <Spinner /> : <Puzzle className="h-3.5 w-3.5" />}
{t.pluginsPage.installBtn}
</Button>
<p className="text-[0.65rem] tracking-[0.06em] text-midforeground/55 normal-case">
{t.pluginsPage.rescanHint}
</p>
<p className="text-[0.65rem] tracking-[0.06em] text-midforeground/55 normal-case">
{t.pluginsPage.removeHint}
</p>
</CardContent>
</Card>
<div className="flex flex-col gap-3">
<h3 className="font-mondwest text-[0.75rem] tracking-[0.12em] text-midground/85">
{t.pluginsPage.pluginListHeading}
</h3>
{loading ? (
<div className="flex items-center gap-2 py-8 text-[0.8rem] text-midforeground/65">
<Spinner />
<span>{t.common.loading}</span>
</div>
) : rows.length === 0 ? (
<p className="text-[0.75rem] text-midforeground/55 normal-case">{t.common.noResults}</p>
) : (
<ul className="flex flex-col gap-3">
{rows.map((row: HubAgentPluginRow) => (
<li key={row.name}>
<PluginRowCard
{...{ row, rowBusy, setRuntimeLoading, showToast, t }}
/>
</li>
))}
</ul>
)}
</div>
{(hub?.orphan_dashboard_plugins?.length ?? 0) > 0 ? (
<div className="flex flex-col gap-3 opacity-95">
<h3 className="font-mondwest text-[0.75rem] tracking-[0.12em] text-midforeground/85">
{t.pluginsPage.orphanHeading}
</h3>
<ul className="flex flex-col gap-2 rounded border border-current/15 p-4">
{hub!.orphan_dashboard_plugins.map((m) => (
<li className="text-[0.7rem] normal-case opacity-85" key={m.name}>
{m.label ?? m.name} {m.description || m.tab?.path}
{!m.tab?.hidden ? (
<Link className="ml-3 inline-flex items-center gap-1 underline" to={m.tab.path}>
<ExternalLink className="h-3 w-3 opacity-65" />
{t.pluginsPage.openTab}
</Link>
) : null}
</li>
))}
</ul>
</div>
) : null}
</div>
<Toast toast={toast} />
<PluginSlot name="plugins:bottom" />
</div>
);
}
interface PluginRowCardProps {
row: HubAgentPluginRow;
rowBusy: string | null;
setRuntimeLoading: (
name: string,
fn: () => Promise<unknown>,
) => Promise<void>;
showToast: (msg: string, variant: "success" | "error") => void;
t: Translations;
}
function PluginRowCard(props: PluginRowCardProps) {
const {
row,
rowBusy,
setRuntimeLoading,
showToast,
t,
} = props;
const dm = row.dashboard_manifest;
const tabPath = dm?.tab && !dm.tab.hidden ? dm.tab.override ?? dm.tab.path : null;
const busy = rowBusy === row.name;
const badgeTone =
row.runtime_status === "enabled"
? "success"
: row.runtime_status === "disabled"
? "destructive"
: "outline";
return (
<Card className={cn(busy ? "opacity-70" : undefined)}>
<CardContent className="flex flex-col gap-4 px-6 py-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-3">
<span className="truncate font-semibold">{row.name}</span>
<Badge tone="outline">
{t.pluginsPage.sourceBadge}: {row.source}
</Badge>
<Badge tone="outline">v{row.version || "—"}</Badge>
<Badge tone={badgeTone}>{row.runtime_status}</Badge>
{row.auth_required ? (
<Badge tone="destructive">{t.pluginsPage.authRequired}</Badge>
) : null}
</div>
{row.description ? (
<p className="mt-2 max-w-2xl text-[0.7rem] tracking-[0.06em] text-midforeground/75 normal-case">
{row.description}
</p>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2 shrink-0">
<Button
disabled={busy || row.runtime_status === "enabled"}
ghost
size="sm"
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.enableAgentPlugin(row.name);
showToast(t.pluginsPage.enableRuntime, "success");
});
}}
>
{t.pluginsPage.enableRuntime}
</Button>
<Button
disabled={busy || row.runtime_status === "disabled"}
ghost
size="sm"
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.disableAgentPlugin(row.name);
showToast(t.pluginsPage.disableRuntime, "success");
});
}}
>
{t.pluginsPage.disableRuntime}
</Button>
{tabPath ? (
<Link
className={cn(
"inline-flex items-center rounded-none px-3 py-1.5",
"border border-current/25 hover:bg-current/10",
"font-mondwest text-[0.65rem] tracking-[0.1em] uppercase",
)}
to={tabPath}
>
{t.pluginsPage.openTab}
</Link>
) : null}
{row.can_update_git ? (
<Button
disabled={busy}
ghost
size="sm"
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.updateAgentPlugin(row.name);
showToast(t.pluginsPage.updateGit, "success");
});
}}
>
{busy ? <Spinner /> : null}
{t.pluginsPage.updateGit}
</Button>
) : null}
{row.has_dashboard_manifest ? (
<Button
disabled={busy}
ghost
size="sm"
title={row.user_hidden ? t.pluginsPage.showInSidebar : t.pluginsPage.hideFromSidebar}
onClick={() => {
void setRuntimeLoading(row.name, async () => {
await api.setPluginVisibility(row.name, !row.user_hidden);
});
}}
>
{row.user_hidden ? (
<EyeOff className="h-3.5 w-3.5" />
) : (
<Eye className="h-3.5 w-3.5" />
)}
{row.user_hidden ? t.pluginsPage.showInSidebar : t.pluginsPage.hideFromSidebar}
</Button>
) : null}
{row.can_remove ? (
<Button
destructive
disabled={busy}
ghost
size="sm"
onClick={() => {
const ok =
typeof window !== "undefined"
? window.confirm(t.pluginsPage.removeConfirm)
: false;
if (!ok) return;
void setRuntimeLoading(row.name, async () => {
await api.removeAgentPlugin(row.name);
showToast(`${row.name} removed`, "success");
});
}}
>
{busy ? <Spinner /> : <Trash2 className="h-3.5 w-3.5" />}
</Button>
) : null}
</div>
</div>
{dm?.slots?.length ? (
<p className="text-[0.65rem] tracking-[0.05em] text-midforeground/55 normal-case">
{t.pluginsPage.dashboardSlots}: {dm.slots.join(", ")}
</p>
) : null}
{row.auth_required ? (
<CommandBlock
label={t.pluginsPage.authRequiredHint}
code={row.auth_command}
/>
) : null}
{!row.has_dashboard_manifest && !dm ? (
<p className="text-[0.65rem] italic text-midforeground/45 normal-case">
{t.pluginsPage.noDashboardTab}
</p>
) : null}
</CardContent>
</Card>
);
}
-444
View File
@@ -1,444 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { ChevronDown, Pencil, Plus, Terminal, Trash2, Users } from "lucide-react";
import { H2 } from "@/components/NouiTypography";
import { api } from "@/lib/api";
import type { ProfileInfo } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useToast } from "@/hooks/useToast";
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
import { Toast } from "@/components/Toast";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useI18n } from "@/i18n";
// Mirrors hermes_cli/profiles.py::_PROFILE_ID_RE so we can reject obviously
// invalid names (uppercase, spaces, …) before round-tripping a doomed POST.
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
export default function ProfilesPage() {
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
const { t } = useI18n();
// Create form
const [newName, setNewName] = useState("");
const [cloneFromDefault, setCloneFromDefault] = useState(true);
const [creating, setCreating] = useState(false);
// Inline rename state
const [renamingFrom, setRenamingFrom] = useState<string | null>(null);
const [renameTo, setRenameTo] = useState("");
// Inline SOUL editor state
const [editingSoulFor, setEditingSoulFor] = useState<string | null>(null);
const [soulText, setSoulText] = useState("");
const [soulSaving, setSoulSaving] = useState(false);
// Tracks the latest SOUL request so out-of-order responses don't overwrite
// newer state when the user switches profiles or closes the editor.
const activeSoulRequest = useRef<string | null>(null);
const load = useCallback(() => {
api
.getProfiles()
.then((res) => setProfiles(res.profiles))
.catch((e) => showToast(`${t.status.error}: ${e}`, "error"))
.finally(() => setLoading(false));
}, [showToast, t.status.error]);
useEffect(() => {
load();
}, [load]);
const handleCreate = async () => {
const name = newName.trim();
if (!name) {
showToast(t.profiles.nameRequired, "error");
return;
}
if (!PROFILE_NAME_RE.test(name)) {
showToast(`${t.profiles.invalidName}: ${t.profiles.nameRule}`, "error");
return;
}
setCreating(true);
try {
await api.createProfile({ name, clone_from_default: cloneFromDefault });
showToast(`${t.profiles.created}: ${name}`, "success");
setNewName("");
load();
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
} finally {
setCreating(false);
}
};
const handleRenameSubmit = async () => {
if (!renamingFrom) return;
const target = renameTo.trim();
if (!target || target === renamingFrom) {
setRenamingFrom(null);
setRenameTo("");
return;
}
if (!PROFILE_NAME_RE.test(target)) {
showToast(`${t.profiles.invalidName}: ${t.profiles.nameRule}`, "error");
return;
}
try {
await api.renameProfile(renamingFrom, target);
showToast(`${t.profiles.renamed}: ${renamingFrom}${target}`, "success");
setRenamingFrom(null);
setRenameTo("");
load();
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
}
};
const openSoulEditor = useCallback(
async (name: string) => {
if (editingSoulFor === name) {
activeSoulRequest.current = null;
setEditingSoulFor(null);
return;
}
setEditingSoulFor(name);
setSoulText("");
activeSoulRequest.current = name;
try {
const soul = await api.getProfileSoul(name);
if (activeSoulRequest.current === name) {
setSoulText(soul.content);
}
} catch (e) {
if (activeSoulRequest.current === name) {
showToast(`${t.status.error}: ${e}`, "error");
}
}
},
[editingSoulFor, showToast, t.status.error],
);
const handleSaveSoul = async (name: string) => {
setSoulSaving(true);
try {
await api.updateProfileSoul(name, soulText);
showToast(`${t.profiles.soulSaved}: ${name}`, "success");
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
} finally {
setSoulSaving(false);
}
};
const handleCopyTerminalCommand = async (name: string) => {
let cmd: string;
try {
const res = await api.getProfileSetupCommand(name);
cmd = res.command;
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
return;
}
try {
await navigator.clipboard.writeText(cmd);
showToast(`${t.profiles.commandCopied}: ${cmd}`, "success");
} catch {
showToast(`${t.profiles.copyFailed}: ${cmd}`, "error");
}
};
const profileDelete = useConfirmDelete<string>({
onDelete: useCallback(
async (name: string) => {
try {
await api.deleteProfile(name);
showToast(`${t.profiles.deleted}: ${name}`, "success");
load();
} catch (e) {
showToast(`${t.status.error}: ${e}`, "error");
throw e;
}
},
[load, showToast, t.profiles.deleted, t.status.error],
),
});
const pendingName = profileDelete.pendingId;
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
return (
// Profile names, model slugs, and paths are case-sensitive; opt out of
// the app shell's global ``uppercase`` so they render as the user typed.
// Children that explicitly opt back in (Badges, etc.) keep their casing.
<div className="flex flex-col gap-6 normal-case">
<Toast toast={toast} />
<DeleteConfirmDialog
open={profileDelete.isOpen}
onCancel={profileDelete.cancel}
onConfirm={profileDelete.confirm}
title={t.profiles.confirmDeleteTitle}
description={
pendingName
? t.profiles.confirmDeleteMessage.replace("{name}", pendingName)
: t.profiles.confirmDeleteMessage
}
loading={profileDelete.isDeleting}
/>
{/* Create new profile */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Plus className="h-4 w-4" />
{t.profiles.newProfile}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="profile-name">{t.profiles.name}</Label>
<Input
id="profile-name"
placeholder={t.profiles.namePlaceholder}
value={newName}
onChange={(e) => setNewName(e.target.value)}
aria-invalid={
newName.trim() !== "" &&
!PROFILE_NAME_RE.test(newName.trim())
}
/>
<p className="text-xs text-muted-foreground">
{t.profiles.nameRule}
</p>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={cloneFromDefault}
onChange={(e) => setCloneFromDefault(e.target.checked)}
/>
{t.profiles.cloneFromDefault}
</label>
<div>
<Button onClick={handleCreate} disabled={creating}>
<Plus className="h-3 w-3" />
{creating ? t.common.creating : t.common.create}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* List */}
<div className="flex flex-col gap-3">
<H2
variant="sm"
className="flex items-center gap-2 text-muted-foreground"
>
<Users className="h-4 w-4" />
{t.profiles.allProfiles} ({profiles.length})
</H2>
{profiles.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{t.profiles.noProfiles}
</CardContent>
</Card>
)}
{profiles.map((p) => {
const isRenaming = renamingFrom === p.name;
const isEditingSoul = editingSoulFor === p.name;
return (
<Card key={p.name}>
<CardContent className="flex items-center gap-4 py-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
{isRenaming ? (
<Input
autoFocus
value={renameTo}
onChange={(e) => setRenameTo(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleRenameSubmit();
if (e.key === "Escape") setRenamingFrom(null);
}}
aria-invalid={
renameTo.trim() !== "" &&
renameTo.trim() !== p.name &&
!PROFILE_NAME_RE.test(renameTo.trim())
}
className="max-w-xs"
/>
) : (
<span className="font-medium text-sm truncate">
{p.name}
</span>
)}
{p.is_default && (
<Badge tone="secondary">{t.profiles.defaultBadge}</Badge>
)}
{p.has_env && (
<Badge tone="outline">{t.profiles.hasEnv}</Badge>
)}
</div>
{isRenaming &&
(() => {
const trimmed = renameTo.trim();
const invalid =
trimmed !== "" &&
trimmed !== p.name &&
!PROFILE_NAME_RE.test(trimmed);
return (
<p
className={
"text-xs mb-1 " +
(invalid
? "text-destructive"
: "text-muted-foreground")
}
>
{invalid
? `${t.profiles.invalidName}: ${t.profiles.nameRule}`
: t.profiles.nameRule}
</p>
);
})()}
<div className="flex items-center gap-4 text-xs text-muted-foreground flex-wrap">
{p.model && (
<span>
{t.profiles.model}: {p.model}
{p.provider ? ` (${p.provider})` : ""}
</span>
)}
<span>
{t.profiles.skills}: {p.skill_count}
</span>
<span className="font-mono truncate max-w-[28rem]">
{p.path}
</span>
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{isRenaming ? (
<>
<Button
size="sm"
onClick={handleRenameSubmit}
>
{t.common.save}
</Button>
<Button
size="sm"
ghost
onClick={() => setRenamingFrom(null)}
>
{t.common.cancel}
</Button>
</>
) : (
<>
<Button
ghost
size="icon"
title={t.profiles.editSoul}
aria-label={t.profiles.editSoul}
onClick={() => openSoulEditor(p.name)}
>
{isEditingSoul ? (
<ChevronDown className="h-4 w-4" />
) : (
<span aria-hidden className="text-xs font-bold">
S
</span>
)}
</Button>
<Button
ghost
size="icon"
title={t.profiles.openInTerminal}
aria-label={t.profiles.openInTerminal}
onClick={() => handleCopyTerminalCommand(p.name)}
>
<Terminal className="h-4 w-4" />
</Button>
{!p.is_default && (
<Button
ghost
size="icon"
title={t.profiles.rename}
aria-label={t.profiles.rename}
onClick={() => {
setRenamingFrom(p.name);
setRenameTo(p.name);
}}
>
<Pencil className="h-4 w-4" />
</Button>
)}
{!p.is_default && (
<Button
ghost
size="icon"
title={t.common.delete}
aria-label={t.common.delete}
onClick={() => profileDelete.requestDelete(p.name)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</>
)}
</div>
</CardContent>
{isEditingSoul && (
<div className="border-t border-border px-4 pb-4 pt-3 flex flex-col gap-2">
<Label
htmlFor={`soul-editor-${p.name}`}
className="flex items-center gap-2 text-xs uppercase tracking-wider text-muted-foreground"
>
{t.profiles.soulSection}
</Label>
<textarea
id={`soul-editor-${p.name}`}
className="flex min-h-[180px] w-full border border-input bg-transparent px-3 py-2 text-sm font-mono shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
placeholder={t.profiles.soulPlaceholder}
value={soulText}
onChange={(e) => setSoulText(e.target.value)}
/>
<div>
<Button
size="sm"
onClick={() => handleSaveSoul(p.name)}
disabled={soulSaving}
>
{soulSaving ? t.common.saving : t.profiles.saveSoul}
</Button>
</div>
</div>
)}
</Card>
);
})}
</div>
</div>
);
}
-852
View File
@@ -1,852 +0,0 @@
import {
useEffect,
useLayoutEffect,
useState,
useCallback,
useRef,
} from "react";
import { useNavigate } from "react-router-dom";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
Database,
MessageSquare,
Search,
Trash2,
Clock,
Terminal,
Globe,
MessageCircle,
Hash,
X,
Play,
} from "lucide-react";
import { api } from "@/lib/api";
import type {
SessionInfo,
SessionMessage,
SessionSearchResult,
StatusResponse,
} from "@/lib/api";
import { timeAgo } from "@/lib/utils";
import { Markdown } from "@/components/Markdown";
import { PlatformsCard } from "@/components/PlatformsCard";
import { Toast } from "@/components/Toast";
import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { useConfirmDelete } from "@/hooks/useConfirmDelete";
import { Input } from "@/components/ui/input";
import { useSystemActions } from "@/contexts/useSystemActions";
import { useToast } from "@/hooks/useToast";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
import { isDashboardEmbeddedChatEnabled } from "@/lib/dashboard-flags";
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> =
{
cli: { icon: Terminal, color: "text-primary" },
telegram: { icon: MessageCircle, color: "text-[oklch(0.65_0.15_250)]" },
discord: { icon: Hash, color: "text-[oklch(0.65_0.15_280)]" },
slack: { icon: MessageSquare, color: "text-[oklch(0.7_0.15_155)]" },
whatsapp: { icon: Globe, color: "text-success" },
cron: { icon: Clock, color: "text-warning" },
};
/** Render an FTS5 snippet with highlighted matches.
* The backend wraps matches in >>> and <<< delimiters. */
function SnippetHighlight({ snippet }: { snippet: string }) {
const parts: React.ReactNode[] = [];
const regex = />>>(.*?)<<</g;
let last = 0;
let match: RegExpExecArray | null;
let i = 0;
while ((match = regex.exec(snippet)) !== null) {
if (match.index > last) {
parts.push(snippet.slice(last, match.index));
}
parts.push(
<mark key={i++} className="bg-warning/30 text-warning px-0.5">
{match[1]}
</mark>,
);
last = regex.lastIndex;
}
if (last < snippet.length) {
parts.push(snippet.slice(last));
}
return (
<p className="text-xs text-muted-foreground/80 truncate max-w-lg mt-0.5">
{parts}
</p>
);
}
function ToolCallBlock({
toolCall,
}: {
toolCall: { id: string; function: { name: string; arguments: string } };
}) {
const [open, setOpen] = useState(false);
const { t } = useI18n();
let args = toolCall.function.arguments;
try {
args = JSON.stringify(JSON.parse(args), null, 2);
} catch {
// keep as-is
}
return (
<div className="mt-2 border border-warning/20 bg-warning/5">
<ListItem
onClick={() => setOpen(!open)}
aria-label={`${open ? t.common.collapse : t.common.expand} tool call ${toolCall.function.name}`}
aria-expanded={open}
className="px-3 py-2 text-xs text-warning hover:bg-warning/10 hover:text-warning"
>
{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>
</ListItem>
{open && (
<pre className="border-t border-warning/20 px-3 py-2 text-xs text-warning/80 overflow-x-auto whitespace-pre-wrap font-mono">
{args}
</pre>
)}
</div>
);
}
function MessageBubble({
msg,
highlight,
}: {
msg: SessionMessage;
highlight?: string;
}) {
const { 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 style = ROLE_STYLES[msg.role] ?? ROLE_STYLES.system;
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 = (() => {
if (!highlight || !msg.content) return false;
const content = msg.content.toLowerCase();
const terms = highlight.toLowerCase().split(/\s+/).filter(Boolean);
return terms.some((term) => content.includes(term));
})();
// Split search query into terms for inline highlighting
const highlightTerms =
isHit && highlight ? highlight.split(/\s+/).filter(Boolean) : undefined;
return (
<div
className={`${style.bg} p-3 ${isHit ? "ring-1 ring-warning/40" : ""}`}
data-search-hit={isHit || undefined}
>
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs font-semibold ${style.text}`}>{label}</span>
{isHit && (
<Badge tone="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>
)}
</div>
{msg.content &&
(msg.role === "system" ? (
<div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
{msg.content}
</div>
) : (
<Markdown content={msg.content} highlightTerms={highlightTerms} />
))}
{msg.tool_calls && msg.tool_calls.length > 0 && (
<div className="mt-1">
{msg.tool_calls.map((tc) => (
<ToolCallBlock key={tc.id} toolCall={tc} />
))}
</div>
)}
</div>
);
}
/** Message list with auto-scroll to first search hit. */
function MessageList({
messages,
highlight,
}: {
messages: SessionMessage[];
highlight?: string;
}) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!highlight || !containerRef.current) return;
// Scroll to first hit after render
const timer = setTimeout(() => {
const hit = containerRef.current?.querySelector("[data-search-hit]");
if (hit) {
hit.scrollIntoView({ behavior: "smooth", block: "center" });
}
}, 50);
return () => clearTimeout(timer);
}, [messages, highlight]);
return (
<div
ref={containerRef}
className="flex flex-col gap-3 max-h-[600px] overflow-y-auto pr-2"
>
{messages.map((msg, i) => (
<MessageBubble key={i} msg={msg} highlight={highlight} />
))}
</div>
);
}
function SessionRow({
session,
snippet,
searchQuery,
isExpanded,
onToggle,
onDelete,
resumeInChatEnabled,
}: {
session: SessionInfo;
snippet?: string;
searchQuery?: string;
isExpanded: boolean;
onToggle: () => void;
onDelete: () => void;
resumeInChatEnabled: boolean;
}) {
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { t } = useI18n();
const navigate = useNavigate();
useEffect(() => {
if (isExpanded && messages === null && !loading) {
setLoading(true);
api
.getSessionMessages(session.id)
.then((resp) => setMessages(resp.messages))
.catch((err) => setError(String(err)))
.finally(() => setLoading(false));
}
}, [isExpanded, session.id, messages, loading]);
const sourceInfo = (session.source
? SOURCE_CONFIG[session.source]
: null) ?? { icon: Globe, color: "text-muted-foreground" };
const SourceIcon = sourceInfo.icon;
const hasTitle = session.title && session.title !== "Untitled";
return (
<div
className={`border overflow-hidden transition-colors ${
session.is_active
? "border-success/30 bg-success/[0.03]"
: "border-border"
}`}
>
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-secondary/30 transition-colors"
onClick={onToggle}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div className={`shrink-0 ${sourceInfo.color}`}>
<SourceIcon className="h-4 w-4" />
</div>
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-2">
<span
className={`text-sm truncate pr-2 ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
>
{hasTitle
? session.title
: session.preview
? session.preview.slice(0, 60)
: t.sessions.untitledSession}
</span>
{session.is_active && (
<Badge tone="success" className="text-[10px] shrink-0">
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
{t.common.live}
</Badge>
)}
</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="text-border">&#183;</span>
<span>
{session.message_count} {t.common.msgs}
</span>
{session.tool_call_count > 0 && (
<>
<span className="text-border">&#183;</span>
<span>
{session.tool_call_count} {t.common.tools}
</span>
</>
)}
<span className="text-border">&#183;</span>
<span>{timeAgo(session.last_active)}</span>
</div>
{snippet && <SnippetHighlight snippet={snippet} />}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge tone="outline" className="text-[10px]">
{session.source ?? "local"}
</Badge>
{resumeInChatEnabled && (
<Button
ghost
size="icon"
className="text-muted-foreground hover:text-success"
aria-label={t.sessions.resumeInChat}
title={t.sessions.resumeInChat}
onClick={(e) => {
e.stopPropagation();
navigate(`/chat?resume=${encodeURIComponent(session.id)}`);
}}
>
<Play />
</Button>
)}
<Button
ghost
destructive
size="icon"
aria-label={t.sessions.deleteSession}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 />
</Button>
</div>
</div>
{isExpanded && (
<div className="border-t border-border bg-background/50 p-4">
{loading && (
<div className="flex items-center justify-center py-8">
<Spinner className="text-xl text-primary" />
</div>
)}
{error && (
<p className="text-sm text-destructive py-4 text-center">{error}</p>
)}
{messages && messages.length === 0 && (
<p className="text-sm text-muted-foreground py-4 text-center">
{t.sessions.noMessages}
</p>
)}
{messages && messages.length > 0 && (
<MessageList messages={messages} highlight={searchQuery} />
)}
</div>
)}
</div>
);
}
export default function SessionsPage() {
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const PAGE_SIZE = 20;
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [expandedId, setExpandedId] = useState<string | null>(null);
const [searchResults, setSearchResults] = useState<
SessionSearchResult[] | null
>(null);
const [searching, setSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
const logScrollRef = useRef<HTMLPreElement | null>(null);
const [status, setStatus] = useState<StatusResponse | null>(null);
const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]);
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
const { activeAction, actionStatus, dismissLog } = useSystemActions();
const resumeInChatEnabled = isDashboardEmbeddedChatEnabled();
useLayoutEffect(() => {
if (loading) {
setAfterTitle(null);
setEnd(null);
return;
}
setAfterTitle(
<Badge tone="secondary" className="text-xs tabular-nums">
{total}
</Badge>,
);
setEnd(
<div className="relative w-full min-w-0 sm:max-w-xs">
{searching ? (
<Spinner className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[0.875rem] text-primary" />
) : (
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
)}
<Input
placeholder={t.sessions.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 pr-7 pl-8 text-xs"
/>
{search && (
<Button
ghost
size="xs"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
aria-label={t.common.clear}
>
<X />
</Button>
)}
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [
loading,
search,
searching,
setAfterTitle,
setEnd,
t.common.clear,
t.sessions.searchPlaceholder,
total,
]);
const loadSessions = useCallback((p: number) => {
setLoading(true);
api
.getSessions(PAGE_SIZE, p * PAGE_SIZE)
.then((resp) => {
setSessions(resp.sessions);
setTotal(resp.total);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
loadSessions(page);
}, [loadSessions, page]);
useEffect(() => {
const loadOverview = () => {
api
.getStatus()
.then(setStatus)
.catch(() => {});
api
.getSessions(50)
.then((r) => setOverviewSessions(r.sessions))
.catch(() => {});
};
loadOverview();
const id = setInterval(loadOverview, 5000);
return () => clearInterval(id);
}, []);
useEffect(() => {
const el = logScrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [actionStatus?.lines]);
// Debounced FTS search
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!search.trim()) {
setSearchResults(null);
setSearching(false);
return;
}
setSearching(true);
debounceRef.current = setTimeout(() => {
api
.searchSessions(search.trim())
.then((resp) => setSearchResults(resp.results))
.catch(() => setSearchResults(null))
.finally(() => setSearching(false));
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [search]);
const sessionDelete = useConfirmDelete({
onDelete: useCallback(
async (id: string) => {
try {
await api.deleteSession(id);
setSessions((prev) => prev.filter((s) => s.id !== id));
setTotal((prev) => prev - 1);
if (expandedId === id) setExpandedId(null);
showToast(t.sessions.sessionDeleted, "success");
} catch {
showToast(t.sessions.failedToDelete, "error");
throw new Error("delete failed");
}
},
[
expandedId,
showToast,
t.sessions.sessionDeleted,
t.sessions.failedToDelete,
],
),
});
const pendingSession = sessionDelete.pendingId
? sessions.find((s) => s.id === sessionDelete.pendingId)
: null;
// Build snippet map from search results (session_id → snippet)
const snippetMap = new Map<string, string>();
if (searchResults) {
for (const r of searchResults) {
snippetMap.set(r.session_id, r.snippet);
}
}
// When searching, filter sessions to those with FTS matches;
// when not searching, show all sessions
const filtered = searchResults
? sessions.filter((s) => snippetMap.has(s.id))
: sessions;
const platformEntries = status
? Object.entries(status.gateway_platforms ?? {})
: [];
const recentSessions = overviewSessions
.filter((s) => !s.is_active)
.slice(0, 5);
const alerts: { message: string; detail?: string }[] = [];
if (status) {
if (status.gateway_state === "startup_failed") {
alerts.push({
message: t.status.gatewayFailedToStart,
detail: status.gateway_exit_reason ?? undefined,
});
}
const failedPlatformEntries = platformEntries.filter(
([, info]) => info.state === "fatal" || info.state === "disconnected",
);
for (const [name, info] of failedPlatformEntries) {
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,
});
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
return (
<div className="flex flex-col gap-4">
<PluginSlot name="sessions:top" />
<Toast toast={toast} />
<DeleteConfirmDialog
open={sessionDelete.isOpen}
onCancel={sessionDelete.cancel}
onConfirm={sessionDelete.confirm}
title={t.sessions.confirmDeleteTitle}
description={
pendingSession?.title && pendingSession.title !== "Untitled"
? `"${pendingSession.title}" — ${t.sessions.confirmDeleteMessage}`
: t.sessions.confirmDeleteMessage
}
loading={sessionDelete.isDeleting}
/>
{alerts.length > 0 && (
<div className="border border-destructive/30 bg-destructive/[0.06] p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
<div className="flex flex-col gap-2 min-w-0">
{alerts.map((alert, i) => (
<div key={i}>
<p className="text-sm font-medium text-destructive">
{alert.message}
</p>
{alert.detail && (
<p className="text-xs text-destructive/70 mt-0.5">
{alert.detail}
</p>
)}
</div>
))}
</div>
</div>
</div>
)}
{activeAction && (
<div className="border border-border bg-background-base/50">
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
{actionStatus?.running ? (
<Spinner className="shrink-0 text-[0.875rem] text-warning" />
) : actionStatus?.exit_code === 0 ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
) : actionStatus !== null ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" />
) : (
<Spinner className="shrink-0 text-[0.875rem] text-muted-foreground" />
)}
<span className="text-xs font-mondwest tracking-[0.12em] truncate">
{activeAction === "restart"
? t.status.restartGateway
: t.status.updateHermes}
</span>
<Badge
tone={
actionStatus?.running
? "warning"
: actionStatus?.exit_code === 0
? "success"
: actionStatus
? "destructive"
: "outline"
}
className="text-[10px] shrink-0"
>
{actionStatus?.running
? t.status.running
: actionStatus?.exit_code === 0
? t.status.actionFinished
: actionStatus
? `${t.status.actionFailed} (${actionStatus.exit_code ?? "?"})`
: t.common.loading}
</Badge>
</div>
<Button
ghost
size="icon"
onClick={dismissLog}
className="shrink-0 opacity-60 hover:opacity-100"
aria-label={t.common.close}
>
<X />
</Button>
</div>
<pre
ref={logScrollRef}
className="max-h-72 overflow-auto px-3 py-2 font-mono-ui text-[11px] leading-relaxed whitespace-pre-wrap break-all"
>
{actionStatus?.lines && actionStatus.lines.length > 0
? actionStatus.lines.join("\n")
: t.status.waitingForOutput}
</pre>
</div>
)}
{platformEntries.length > 0 && status && (
<PlatformsCard platforms={platformEntries} />
)}
{recentSessions.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-base">
{t.status.recentSessions}
</CardTitle>
</div>
</CardHeader>
<CardContent className="grid gap-3">
{recentSessions.map((s) => (
<div
key={s.id}
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="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>
{s.preview && (
<span className="text-xs text-muted-foreground/70 truncate">
{s.preview}
</span>
)}
</div>
<Badge
tone="outline"
className="text-[10px] shrink-0 self-start sm:self-center"
>
<Database className="mr-1 h-3 w-3" />
{s.source ?? "local"}
</Badge>
</div>
))}
</CardContent>
</Card>
)}
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
<Clock className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm font-medium">
{search ? t.sessions.noMatch : t.sessions.noSessions}
</p>
{!search && (
<p className="text-xs mt-1 text-muted-foreground/60">
{t.sessions.startConversation}
</p>
)}
</div>
) : (
<>
<div className="flex flex-col gap-1.5">
{filtered.map((s) => (
<SessionRow
key={s.id}
session={s}
snippet={snippetMap.get(s.id)}
searchQuery={search || undefined}
isExpanded={expandedId === s.id}
onToggle={() =>
setExpandedId((prev) => (prev === s.id ? null : s.id))
}
onDelete={() => sessionDelete.requestDelete(s.id)}
resumeInChatEnabled={resumeInChatEnabled}
/>
))}
</div>
{!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}
</span>
<div className="flex items-center gap-1">
<Button
outlined
size="icon"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
aria-label={t.sessions.previousPage}
>
<ChevronLeft />
</Button>
<span className="text-xs text-muted-foreground px-2">
{t.common.page} {page + 1} {t.common.of}{" "}
{Math.ceil(total / PAGE_SIZE)}
</span>
<Button
outlined
size="icon"
disabled={(page + 1) * PAGE_SIZE >= total}
onClick={() => setPage((p) => p + 1)}
aria-label={t.sessions.nextPage}
>
<ChevronRight />
</Button>
</div>
</div>
)}
</>
)}
<PluginSlot name="sessions:bottom" />
</div>
);
}
-562
View File
@@ -1,562 +0,0 @@
import { useEffect, useLayoutEffect, useState, useMemo } from "react";
import {
Package,
Search,
Wrench,
X,
Cpu,
Globe,
Shield,
Eye,
Paintbrush,
Brain,
Blocks,
Code,
Zap,
Filter,
} from "lucide-react";
import { api } from "@/lib/api";
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
import { useToast } from "@/hooks/useToast";
import { Toast } from "@/components/Toast";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Switch } from "@nous-research/ui/ui/components/switch";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
import { PluginSlot } from "@/plugins";
/* ------------------------------------------------------------------ */
/* Types & helpers */
/* ------------------------------------------------------------------ */
const CATEGORY_LABELS: Record<string, string> = {
mlops: "MLOps",
"mlops/cloud": "MLOps / Cloud",
"mlops/evaluation": "MLOps / Evaluation",
"mlops/inference": "MLOps / Inference",
"mlops/models": "MLOps / Models",
"mlops/training": "MLOps / Training",
"mlops/vector-databases": "MLOps / Vector DBs",
mcp: "MCP",
"red-teaming": "Red Teaming",
ocr: "OCR",
p5js: "p5.js",
ai: "AI",
ux: "UX",
ui: "UI",
};
function prettyCategory(
raw: string | null | undefined,
generalLabel: string,
): string {
if (!raw) return generalLabel;
if (CATEGORY_LABELS[raw]) return CATEGORY_LABELS[raw];
return raw
.split(/[-_/]/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
const TOOLSET_ICONS: Record<
string,
React.ComponentType<{ className?: string }>
> = {
computer: Cpu,
web: Globe,
security: Shield,
vision: Eye,
design: Paintbrush,
ai: Brain,
integration: Blocks,
code: Code,
automation: Zap,
};
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;
}
return Wrench;
}
/* ------------------------------------------------------------------ */
/* Component */
/* ------------------------------------------------------------------ */
export default function SkillsPage() {
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [toolsets, setToolsets] = useState<ToolsetInfo[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [view, setView] = useState<"skills" | "toolsets">("skills");
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
useEffect(() => {
Promise.all([api.getSkills(), api.getToolsets()])
.then(([s, tsets]) => {
setSkills(s);
setToolsets(tsets);
})
.catch(() => showToast(t.common.loading, "error"))
.finally(() => setLoading(false));
}, []);
/* ---- Toggle skill ---- */
const handleToggleSkill = async (skill: SkillInfo) => {
setTogglingSkills((prev) => new Set(prev).add(skill.name));
try {
await api.toggleSkill(skill.name, !skill.enabled);
setSkills((prev) =>
prev.map((s) =>
s.name === skill.name ? { ...s, enabled: !s.enabled } : s,
),
);
showToast(
`${skill.name} ${skill.enabled ? t.common.disabled : t.common.enabled}`,
"success",
);
} catch {
showToast(`${t.common.failedToToggle} ${skill.name}`, "error");
} finally {
setTogglingSkills((prev) => {
const next = new Set(prev);
next.delete(skill.name);
return next;
});
}
};
/* ---- Derived data ---- */
const lowerSearch = search.toLowerCase();
const isSearching = search.trim().length > 0;
const searchMatchedSkills = useMemo(() => {
if (!isSearching) return [];
return skills.filter(
(s) =>
s.name.toLowerCase().includes(lowerSearch) ||
s.description.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));
return skills
.filter((s) =>
activeCategory === "__none__"
? !s.category
: s.category === activeCategory,
)
.sort((a, b) => a.name.localeCompare(b.name));
}, [skills, activeCategory, isSearching]);
const allCategories = useMemo(() => {
const cats = new Map<string, number>();
for (const s of skills) {
const key = s.category || "__none__";
cats.set(key, (cats.get(key) || 0) + 1);
}
return [...cats.entries()]
.sort((a, b) => {
if (a[0] === "__none__") return -1;
if (b[0] === "__none__") return 1;
return a[0].localeCompare(b[0]);
})
.map(([key, count]) => ({
key,
name: prettyCategory(key === "__none__" ? null : key, t.common.general),
count,
}));
}, [skills, t]);
const enabledCount = skills.filter((s) => s.enabled).length;
useLayoutEffect(() => {
if (loading) {
setAfterTitle(null);
setEnd(null);
return;
}
setAfterTitle(
<span className="whitespace-nowrap text-xs text-muted-foreground">
{t.skills.enabledOf
.replace("{enabled}", String(enabledCount))
.replace("{total}", String(skills.length))}
</span>,
);
setEnd(
<div className="relative w-full min-w-0 sm:max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="h-8 pl-8 pr-7 text-xs"
placeholder={t.common.search}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<Button
ghost
size="xs"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
aria-label={t.common.clear}
>
<X />
</Button>
)}
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [enabledCount, loading, search, setAfterTitle, setEnd, skills.length, t]);
const filteredToolsets = useMemo(() => {
return toolsets.filter(
(ts) =>
!search ||
ts.name.toLowerCase().includes(lowerSearch) ||
ts.label.toLowerCase().includes(lowerSearch) ||
ts.description.toLowerCase().includes(lowerSearch),
);
}, [toolsets, search, lowerSearch]);
/* ---- Loading ---- */
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
return (
<div className="flex flex-col gap-4">
<PluginSlot name="skills:top" />
<Toast toast={toast} />
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
<aside aria-label={t.skills.title} className="sm:w-56 sm:shrink-0">
<div className="sm:sticky sm:top-0">
<div
className={`
flex flex-col
border border-border bg-muted/20
`}
>
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
<Filter className="h-3 w-3 text-muted-foreground" />
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
{t.skills.filters}
</span>
</div>
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none p-2">
<PanelItem
icon={Package}
label={`${t.skills.all} (${skills.length})`}
active={view === "skills" && !isSearching}
onClick={() => {
setView("skills");
setActiveCategory(null);
setSearch("");
}}
/>
<PanelItem
icon={Wrench}
label={`${t.skills.toolsets} (${toolsets.length})`}
active={view === "toolsets"}
onClick={() => {
setView("toolsets");
setSearch("");
}}
/>
</div>
{view === "skills" &&
!isSearching &&
allCategories.length > 0 && (
<div className="hidden sm:flex flex-col border-t border-border">
<div className="px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70">
{t.skills.categories}
</div>
<div className="flex flex-col p-2 pt-1 gap-px max-h-[calc(100vh-340px)] overflow-y-auto">
{allCategories.map(({ key, name, count }) => {
const isActive = activeCategory === key;
return (
<ListItem
key={key}
active={isActive}
onClick={() =>
setActiveCategory(isActive ? null : key)
}
className="rounded-sm px-2 py-1 text-[11px]"
>
<span className="flex-1 truncate">{name}</span>
<span
className={`text-[10px] tabular-nums ${
isActive
? "text-foreground/60"
: "text-muted-foreground/50"
}`}
>
{count}
</span>
</ListItem>
);
})}
</div>
</div>
)}
</div>
</div>
</aside>
<div className="flex-1 min-w-0">
{isSearching ? (
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Search className="h-4 w-4" />
{t.skills.title}
</CardTitle>
<Badge tone="secondary" className="text-[10px]">
{t.skills.resultCount
.replace("{count}", String(searchMatchedSkills.length))
.replace(
"{s}",
searchMatchedSkills.length !== 1 ? "s" : "",
)}
</Badge>
</div>
</CardHeader>
<CardContent className="px-4 pb-4">
{searchMatchedSkills.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8">
{t.skills.noSkillsMatch}
</p>
) : (
<div className="grid gap-1">
{searchMatchedSkills.map((skill) => (
<SkillRow
key={skill.name}
skill={skill}
toggling={togglingSkills.has(skill.name)}
onToggle={() => handleToggleSkill(skill)}
noDescriptionLabel={t.skills.noDescription}
/>
))}
</div>
)}
</CardContent>
</Card>
) : view === "skills" ? (
/* Skills list */
<Card>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm flex items-center gap-2">
<Package className="h-4 w-4" />
{activeCategory
? prettyCategory(
activeCategory === "__none__" ? null : activeCategory,
t.common.general,
)
: t.skills.all}
</CardTitle>
<Badge tone="secondary" className="text-[10px]">
{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}
</p>
) : (
<div className="grid gap-1">
{activeSkills.map((skill) => (
<SkillRow
key={skill.name}
skill={skill}
toggling={togglingSkills.has(skill.name)}
onToggle={() => handleToggleSkill(skill)}
noDescriptionLabel={t.skills.noDescription}
/>
))}
</div>
)}
</CardContent>
</Card>
) : (
/* Toolsets grid */
<>
{filteredToolsets.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{t.skills.noToolsetsMatch}
</CardContent>
</Card>
) : (
<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;
return (
<Card key={ts.name} className="relative">
<CardContent className="py-4">
<div className="flex items-start gap-3">
<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>
<Badge
tone={ts.enabled ? "success" : "outline"}
className="text-[10px]"
>
{ts.enabled
? t.common.active
: t.common.inactive}
</Badge>
</div>
<p className="text-xs text-muted-foreground mb-2">
{ts.description}
</p>
{ts.enabled && !ts.configured && (
<p className="text-[10px] text-amber-300/80 mb-2">
{t.skills.setupNeeded}
</p>
)}
{ts.tools.length > 0 && (
<div className="flex flex-wrap gap-1">
{ts.tools.map((tool) => (
<Badge
key={tool}
tone="secondary"
className="text-[10px] font-mono"
>
{tool}
</Badge>
))}
</div>
)}
{ts.tools.length === 0 && (
<span className="text-[10px] text-muted-foreground/60">
{ts.enabled
? t.skills.toolsetLabel.replace(
"{name}",
ts.name,
)
: t.skills.disabledForCli}
</span>
)}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</>
)}
</div>
</div>
<PluginSlot name="skills:bottom" />
</div>
);
}
function SkillRow({
skill,
toggling,
onToggle,
noDescriptionLabel,
}: SkillRowProps) {
return (
<div className="group flex items-start gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40">
<div className="pt-0.5 shrink-0">
<Switch
checked={skill.enabled}
onCheckedChange={onToggle}
disabled={toggling}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span
className={`font-mono-ui text-sm ${
skill.enabled ? "text-foreground" : "text-muted-foreground"
}`}
>
{skill.name}
</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
{skill.description || noDescriptionLabel}
</p>
</div>
</div>
);
}
function PanelItem({ active, icon: Icon, label, onClick }: PanelItemProps) {
return (
<ListItem
active={active}
onClick={onClick}
className={cn(
"rounded-sm whitespace-nowrap px-2.5 py-1.5",
"font-mondwest text-[0.7rem] tracking-[0.08em] uppercase",
active && "bg-foreground/90 text-background hover:text-background",
)}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{label}</span>
</ListItem>
);
}
interface PanelItemProps {
active: boolean;
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
}
interface SkillRowProps {
noDescriptionLabel: string;
onToggle: () => void;
skill: SkillInfo;
toggling: boolean;
}
-64
View File
@@ -1,64 +0,0 @@
import { useSyncExternalStore } from "react";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import {
getPluginComponent,
getPluginLoadError,
onPluginRegistered,
} from "./registry";
import { useI18n } from "@/i18n";
import { cn } from "@/lib/utils";
import type { Translations } from "@/i18n/types";
/** Renders a plugin tab once its bundle has called `register()`. */
export function PluginPage({ name }: { name: string }) {
const { t } = useI18n();
// Subscribe in render (via useSyncExternalStore) so we never miss
// `register()` if the script loads before a useEffect would run.
const Component = useSyncExternalStore(
(onChange) => onPluginRegistered(onChange),
() => getPluginComponent(name) ?? null,
() => null,
);
const loadError = useSyncExternalStore(
(onChange) => onPluginRegistered(onChange),
() => getPluginLoadError(name) ?? null,
() => null,
);
if (Component) {
return <Component />;
}
if (loadError) {
const message = formatPluginError(loadError, t);
return (
<div
className={cn(
"max-w-lg p-4",
"font-mondwest text-sm tracking-[0.08em] text-midground/80",
)}
role="alert"
>
{message}
</div>
);
}
return (
<div
className={cn(
"flex items-center gap-2 p-4",
"font-mondwest text-sm tracking-[0.1em] text-midground/60",
)}
>
<Spinner className="shrink-0" />
<span>{t.common.loading}</span>
</div>
);
}
function formatPluginError(code: string, t: Translations): string {
if (code === "LOAD_FAILED") return t.common.pluginLoadFailed;
if (code === "NO_REGISTER") return t.common.pluginNotRegistered;
return code;
}
-6
View File
@@ -1,6 +0,0 @@
export { exposePluginSDK, getPluginComponent, onPluginRegistered, getRegisteredCount } from "./registry";
export { PluginPage } from "./PluginPage";
export { usePlugins } from "./usePlugins";
export { PluginSlot, KNOWN_SLOT_NAMES, registerSlot, getSlotEntries, onSlotRegistered, unregisterPluginSlots } from "./slots";
export type { KnownSlotName } from "./slots";
export type { PluginManifest, RegisteredPlugin } from "./types";
-149
View File
@@ -1,149 +0,0 @@
/**
* 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 { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsList, TabsTrigger } from "@nous-research/ui/ui/components/tabs";
import { useI18n } from "@/i18n";
import { registerSlot, PluginSlot } from "./slots";
// ---------------------------------------------------------------------------
// Plugin registry — plugins call register() to add their component.
// ---------------------------------------------------------------------------
type RegistryListener = () => void;
const _registered: Map<string, React.ComponentType> = new Map();
const _loadErrors: Map<string, string> = new Map();
const _listeners: Set<RegistryListener> = new Set();
function _notify() {
for (const fn of _listeners) {
try { fn(); } catch { /* ignore */ }
}
}
/** Re-run registry subscribers (e.g. after a plugin script onload, or dev HMR re-inject). */
export function notifyPluginRegistry() {
_notify();
}
/** Register a plugin component. Called by plugin JS bundles. */
function registerPlugin(name: string, component: React.ComponentType) {
_loadErrors.delete(name);
_registered.set(name, component);
_notify();
}
/** Get a registered component by plugin name. */
export function getPluginComponent(name: string): React.ComponentType | undefined {
return _registered.get(name);
}
export function getPluginLoadError(name: string): string | undefined {
return _loadErrors.get(name);
}
export function setPluginLoadError(name: string, message: string) {
_loadErrors.set(name, message);
_notify();
}
/** 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;
registerSlot: typeof registerSlot;
};
}
}
export function exposePluginSDK() {
window.__HERMES_PLUGINS__ = {
register: registerPlugin,
registerSlot,
};
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,
PluginSlot,
},
// Utilities
utils: { cn, timeAgo, isoTimeAgo },
// Hooks
useI18n,
};
}
-199
View File
@@ -1,199 +0,0 @@
/**
* Plugin slot registry.
*
* Plugins can inject components into named locations in the app shell
* (header-left, sidebar, backdrop, etc.) by calling
* `window.__HERMES_PLUGINS__.registerSlot(pluginName, slotName, Component)`
* from their JS bundle. Multiple plugins can populate the same slot — they
* render stacked in registration order.
*
* The canonical slot names are documented in `KNOWN_SLOT_NAMES` below. The
* registry accepts any string so plugin ecosystems can define their own
* slots; the shell only renders `<PluginSlot name="..." />` for the slots
* it knows about.
*/
import React, { Fragment, useEffect, useState } from "react";
/** Slot locations the built-in shell renders. Plugins declaring any of
* these in their manifest's `slots` field get wired in automatically.
*
* Shell-wide slots:
* - `backdrop` — rendered inside `<Backdrop />`, above the noise layer
* - `header-left` — injected before the Hermes brand in the top bar
* - `header-right` — injected before the theme/language switchers
* - `header-banner` — injected below the top nav bar, full-width
* - `sidebar` — the cockpit sidebar rail (only rendered when
* `layoutVariant === "cockpit"`)
* - `pre-main` — rendered above the route outlet (inside `<main>`)
* - `post-main` — rendered below the route outlet (inside `<main>`)
* - `footer-left` — replaces the left footer cell content
* - `footer-right` — replaces the right footer cell content
* - `overlay` — fixed-position layer above everything else;
* useful for chrome (scanlines, vignettes) the
* theme's customCSS can't achieve alone
*
* Page-scoped slots (rendered inside a specific built-in page — use these
* to inject widgets, cards, or toolbars into existing pages without
* overriding the whole route):
* - `sessions:top` — top of /sessions page (above session list)
* - `sessions:bottom` — bottom of /sessions page
* - `analytics:top` — top of /analytics page
* - `analytics:bottom` — bottom of /analytics page
* - `logs:top` — top of /logs page (above filter toolbar)
* - `logs:bottom` — bottom of /logs page (below log viewer)
* - `cron:top` — top of /cron page
* - `cron:bottom` — bottom of /cron page
* - `skills:top` — top of /skills page
* - `skills:bottom` — bottom of /skills page
* - `plugins:top` — top of /plugins page
* - `plugins:bottom` — bottom of /plugins page
* - `config:top` — top of /config page
* - `config:bottom` — bottom of /config page
* - `env:top` — top of /env (Keys) page
* - `env:bottom` — bottom of /env (Keys) page
* - `docs:top` — top of /docs page (above the docs iframe)
* - `docs:bottom` — bottom of /docs page
* - `chat:top` — top of /chat page (above the composer, when embedded chat is on)
* - `chat:bottom` — bottom of /chat page
*/
export const KNOWN_SLOT_NAMES = [
// Shell-wide
"backdrop",
"header-left",
"header-right",
"header-banner",
"sidebar",
"pre-main",
"post-main",
"footer-left",
"footer-right",
"overlay",
// Page-scoped
"sessions:top",
"sessions:bottom",
"analytics:top",
"analytics:bottom",
"logs:top",
"logs:bottom",
"cron:top",
"cron:bottom",
"skills:top",
"skills:bottom",
"plugins:top",
"plugins:bottom",
"config:top",
"config:bottom",
"env:top",
"env:bottom",
"docs:top",
"docs:bottom",
"chat:top",
"chat:bottom",
] as const;
export type KnownSlotName = (typeof KNOWN_SLOT_NAMES)[number];
type SlotListener = () => void;
interface SlotEntry {
plugin: string;
component: React.ComponentType;
}
/** Map<slotName, SlotEntry[]>. Entries are appended in registration order. */
const _slotRegistry: Map<string, SlotEntry[]> = new Map();
const _slotListeners: Set<SlotListener> = new Set();
function _notifySlots() {
for (const fn of _slotListeners) {
try {
fn();
} catch {
/* ignore */
}
}
}
/** Register a component for a slot. Called by plugin bundles via
* `window.__HERMES_PLUGINS__.registerSlot(...)`.
*
* If the same (plugin, slot) pair is registered twice, the later call
* replaces the earlier one — this matches how React HMR expects plugin
* re-mounts to behave. */
export function registerSlot(
plugin: string,
slot: string,
component: React.ComponentType,
): void {
const existing = _slotRegistry.get(slot) ?? [];
const filtered = existing.filter((e) => e.plugin !== plugin);
filtered.push({ plugin, component });
_slotRegistry.set(slot, filtered);
_notifySlots();
}
/** Read current entries for a slot. Returns a copy so callers can't mutate
* registry state. */
export function getSlotEntries(slot: string): SlotEntry[] {
return (_slotRegistry.get(slot) ?? []).slice();
}
/** Subscribe to registry changes. Returns an unsubscribe function. */
export function onSlotRegistered(fn: SlotListener): () => void {
_slotListeners.add(fn);
return () => {
_slotListeners.delete(fn);
};
}
/** Clear a specific plugin's slot registrations. Useful for HMR /
* plugin reload flows — not wired in by default. */
export function unregisterPluginSlots(plugin: string): void {
let changed = false;
for (const [slot, entries] of _slotRegistry.entries()) {
const kept = entries.filter((e) => e.plugin !== plugin);
if (kept.length !== entries.length) {
changed = true;
if (kept.length === 0) _slotRegistry.delete(slot);
else _slotRegistry.set(slot, kept);
}
}
if (changed) _notifySlots();
}
interface PluginSlotProps {
/** Slot identifier (e.g. `"sidebar"`, `"header-left"`). */
name: string;
/** Optional content rendered when no plugins have claimed the slot.
* Useful for built-in defaults the plugin would replace. */
fallback?: React.ReactNode;
}
/** Render all components registered for a given slot, stacked in order.
*
* Component re-renders when the slot registry changes so plugins that
* arrive after initial mount show up without a manual refresh. */
export function PluginSlot({ name, fallback }: PluginSlotProps) {
const [entries, setEntries] = useState<SlotEntry[]>(() => getSlotEntries(name));
useEffect(() => {
// Pick up anything registered between the initial `useState` call
// and the first effect tick, then subscribe for future changes.
setEntries(getSlotEntries(name));
const unsub = onSlotRegistered(() => setEntries(getSlotEntries(name)));
return unsub;
}, [name]);
if (entries.length === 0) {
return fallback ? React.createElement(Fragment, null, fallback) : null;
}
return React.createElement(
Fragment,
null,
...entries.map((entry) =>
React.createElement(entry.component, { key: entry.plugin }),
),
);
}
-31
View File
@@ -1,31 +0,0 @@
/** Types for the dashboard plugin system. */
import type { ComponentType } from "react";
export interface PluginManifest {
name: string;
label: string;
description: string;
icon: string;
version: string;
tab: {
path: string;
/** "end", "after:<pathSegment>", "before:<pathSegment>" (e.g. "after:skills" → after `/skills`) */
position?: string;
/** When set to a built-in route path, this plugin replaces that page instead of adding a new tab. */
override?: string;
/** When true, the plugin may register without a sidebar tab (slot-only, etc.). */
hidden?: boolean;
};
/** Declared for discovery; actual slots use registerSlot in the plugin bundle. */
slots?: string[];
entry: string;
css?: string | null;
has_api: boolean;
source: string;
}
export interface RegisteredPlugin {
manifest: PluginManifest;
component: ComponentType;
}
-123
View File
@@ -1,123 +0,0 @@
/**
* 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,
notifyPluginRegistry,
setPluginLoadError,
} 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;
const injectedScripts: HTMLScriptElement[] = [];
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. In dev, cache-bust so Vite HMR can clear the
// in-memory registry while the browser would otherwise never
// re-execute a previously cached <script> URL.
const baseUrl = `/dashboard-plugins/${manifest.name}/${manifest.entry}`;
const scriptSrc = import.meta.env.DEV
? `${baseUrl}?hermes_dv=${Date.now()}`
: baseUrl;
if (!import.meta.env.DEV) {
if (loadedScripts.current.has(baseUrl)) continue;
loadedScripts.current.add(baseUrl);
}
const script = document.createElement("script");
script.setAttribute("data-hermes-plugin", manifest.name);
script.src = scriptSrc;
script.async = true;
script.onerror = () => {
setPluginLoadError(manifest.name, "LOAD_FAILED");
console.warn(
`[plugins] Failed to load ${manifest.name} from ${scriptSrc} (open Network tab)`,
);
};
script.onload = () => {
notifyPluginRegistry();
queueMicrotask(() => {
if (getPluginComponent(manifest.name)) return;
setPluginLoadError(manifest.name, "NO_REGISTER");
});
};
document.body.appendChild(script);
injectedScripts.push(script);
}
// Give plugins a moment to load and register, then stop loading state.
const timeout = setTimeout(() => setLoading(false), 2000);
return () => {
clearTimeout(timeout);
if (import.meta.env.DEV) {
for (const el of injectedScripts) {
el.remove();
}
}
};
}, [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 };
}
-437
View File
@@ -1,437 +0,0 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { BUILTIN_THEMES, defaultTheme } from "./presets";
import type {
DashboardTheme,
ThemeAssets,
ThemeColorOverrides,
ThemeComponentStyles,
ThemeDensity,
ThemeLayer,
ThemeLayout,
ThemeLayoutVariant,
ThemePalette,
ThemeTypography,
} 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";
/** Tracks fontUrls we've already injected so multiple theme switches don't
* pile up <link> tags. Keyed by URL. */
const INJECTED_FONT_URLS = new Set<string>();
// ---------------------------------------------------------------------------
// CSS variable builders
// ---------------------------------------------------------------------------
/** 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,
): Record<string, string> {
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),
};
}
function paletteVars(palette: ThemePalette): Record<string, string> {
return {
...layerVars("background", palette.background),
...layerVars("midground", palette.midground),
...layerVars("foreground", palette.foreground),
"--warm-glow": palette.warmGlow,
"--noise-opacity-mul": String(palette.noiseOpacity),
};
}
const DENSITY_MULTIPLIERS: Record<ThemeDensity, string> = {
compact: "0.85",
comfortable: "1",
spacious: "1.2",
};
function typographyVars(typo: ThemeTypography): Record<string, string> {
return {
"--theme-font-sans": typo.fontSans,
"--theme-font-mono": typo.fontMono,
"--theme-font-display": typo.fontDisplay ?? typo.fontSans,
"--theme-base-size": typo.baseSize,
"--theme-line-height": typo.lineHeight,
"--theme-letter-spacing": typo.letterSpacing,
};
}
function layoutVars(layout: ThemeLayout): Record<string, string> {
return {
"--radius": layout.radius,
"--theme-radius": layout.radius,
"--theme-spacing-mul": DENSITY_MULTIPLIERS[layout.density] ?? "1",
"--theme-density": layout.density,
};
}
/** Map a color-overrides key (camelCase) to its `--color-*` CSS var. */
const OVERRIDE_KEY_TO_VAR: Record<keyof ThemeColorOverrides, string> = {
card: "--color-card",
cardForeground: "--color-card-foreground",
popover: "--color-popover",
popoverForeground: "--color-popover-foreground",
primary: "--color-primary",
primaryForeground: "--color-primary-foreground",
secondary: "--color-secondary",
secondaryForeground: "--color-secondary-foreground",
muted: "--color-muted",
mutedForeground: "--color-muted-foreground",
accent: "--color-accent",
accentForeground: "--color-accent-foreground",
destructive: "--color-destructive",
destructiveForeground: "--color-destructive-foreground",
success: "--color-success",
warning: "--color-warning",
border: "--color-border",
input: "--color-input",
ring: "--color-ring",
};
/** Keys we might have written on a previous theme — needed to know which
* properties to clear when a theme with fewer overrides replaces one
* with more. */
const ALL_OVERRIDE_VARS = Object.values(OVERRIDE_KEY_TO_VAR);
function overrideVars(
overrides: ThemeColorOverrides | undefined,
): Record<string, string> {
if (!overrides) return {};
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(overrides)) {
if (!value) continue;
const cssVar = OVERRIDE_KEY_TO_VAR[key as keyof ThemeColorOverrides];
if (cssVar) out[cssVar] = value;
}
return out;
}
// ---------------------------------------------------------------------------
// Asset + component-style + layout variant vars
// ---------------------------------------------------------------------------
/** Well-known named asset slots a theme may populate. Kept in sync with
* `_THEME_NAMED_ASSET_KEYS` in `hermes_cli/web_server.py`. */
const NAMED_ASSET_KEYS = ["bg", "hero", "logo", "crest", "sidebar", "header"] as const;
/** Component buckets mirrored from the backend's `_THEME_COMPONENT_BUCKETS`.
* Each bucket emits `--component-<bucket>-<kebab-prop>` CSS vars. */
const COMPONENT_BUCKETS = [
"card", "header", "footer", "sidebar", "tab",
"progress", "badge", "backdrop", "page",
] as const;
/** Camel → kebab (`clipPath` → `clip-path`). */
function toKebab(s: string): string {
return s.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`);
}
/** Build `--theme-asset-*` CSS vars from the assets block. Values are wrapped
* in `url(...)` when they look like a bare path/URL; raw CSS expressions
* (`linear-gradient(...)`, pre-wrapped `url(...)`, `none`) pass through. */
function assetVars(assets: ThemeAssets | undefined): Record<string, string> {
if (!assets) return {};
const out: Record<string, string> = {};
const wrap = (v: string): string => {
const trimmed = v.trim();
if (!trimmed) return "";
// Already a CSS image/gradient/url/none — don't re-wrap.
if (/^(url\(|linear-gradient|radial-gradient|conic-gradient|none$)/i.test(trimmed)) {
return trimmed;
}
// Bare path / http(s) URL / data: URL → wrap in url().
return `url("${trimmed.replace(/"/g, '\\"')}")`;
};
for (const key of NAMED_ASSET_KEYS) {
const val = assets[key];
if (typeof val === "string" && val.trim()) {
out[`--theme-asset-${key}`] = wrap(val);
out[`--theme-asset-${key}-raw`] = val;
}
}
if (assets.custom) {
for (const [key, val] of Object.entries(assets.custom)) {
if (typeof val !== "string" || !val.trim()) continue;
if (!/^[a-zA-Z0-9_-]+$/.test(key)) continue;
out[`--theme-asset-custom-${key}`] = wrap(val);
out[`--theme-asset-custom-${key}-raw`] = val;
}
}
return out;
}
/** Build `--component-<bucket>-<prop>` CSS vars from the componentStyles
* block. Values pass through untouched so themes can use any CSS expression. */
function componentStyleVars(
styles: ThemeComponentStyles | undefined,
): Record<string, string> {
if (!styles) return {};
const out: Record<string, string> = {};
for (const bucket of COMPONENT_BUCKETS) {
const props = (styles as Record<string, Record<string, string> | undefined>)[bucket];
if (!props) continue;
for (const [prop, value] of Object.entries(props)) {
if (typeof value !== "string" || !value.trim()) continue;
// Same guardrail as backend — camelCase or kebab-case alnum only.
if (!/^[a-zA-Z0-9_-]+$/.test(prop)) continue;
out[`--component-${bucket}-${toKebab(prop)}`] = value;
}
}
return out;
}
// Tracks keys we set on the previous theme so we can clear them when the
// next theme has fewer assets / component vars. Without this, switching
// from a richly-decorated theme to a plain one would leave stale vars.
let _PREV_DYNAMIC_VAR_KEYS: Set<string> = new Set();
/** ID for the injected <style> tag that carries a theme's customCSS.
* A single tag is reused + replaced on every theme switch. */
const CUSTOM_CSS_STYLE_ID = "hermes-theme-custom-css";
function applyCustomCSS(css: string | undefined) {
if (typeof document === "undefined") return;
let el = document.getElementById(CUSTOM_CSS_STYLE_ID) as HTMLStyleElement | null;
if (!css || !css.trim()) {
if (el) el.remove();
return;
}
if (!el) {
el = document.createElement("style");
el.id = CUSTOM_CSS_STYLE_ID;
el.setAttribute("data-hermes-theme-css", "true");
document.head.appendChild(el);
}
el.textContent = css;
}
function applyLayoutVariant(variant: ThemeLayoutVariant | undefined) {
if (typeof document === "undefined") return;
const root = document.documentElement;
const final: ThemeLayoutVariant = variant ?? "standard";
root.dataset.layoutVariant = final;
root.style.setProperty("--theme-layout-variant", final);
}
// ---------------------------------------------------------------------------
// Font stylesheet injection
// ---------------------------------------------------------------------------
function injectFontStylesheet(url: string | undefined) {
if (!url || typeof document === "undefined") return;
if (INJECTED_FONT_URLS.has(url)) return;
// Also skip if the page already has this href (e.g. SSR'd or persisted).
const existing = document.querySelector<HTMLLinkElement>(
`link[rel="stylesheet"][href="${CSS.escape(url)}"]`,
);
if (existing) {
INJECTED_FONT_URLS.add(url);
return;
}
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = url;
link.setAttribute("data-hermes-theme-font", "true");
document.head.appendChild(link);
INJECTED_FONT_URLS.add(url);
}
// ---------------------------------------------------------------------------
// Apply a full theme to :root
// ---------------------------------------------------------------------------
function applyTheme(theme: DashboardTheme) {
if (typeof document === "undefined") return;
const root = document.documentElement;
// Clear any overrides from a previous theme before applying the new set.
for (const cssVar of ALL_OVERRIDE_VARS) {
root.style.removeProperty(cssVar);
}
// Clear dynamic (asset/component) vars from the previous theme so the
// new one starts clean — otherwise stale notched clip-paths, hero URLs,
// etc. would bleed across theme switches.
for (const prevKey of _PREV_DYNAMIC_VAR_KEYS) {
root.style.removeProperty(prevKey);
}
const assetMap = assetVars(theme.assets);
const componentMap = componentStyleVars(theme.componentStyles);
_PREV_DYNAMIC_VAR_KEYS = new Set([
...Object.keys(assetMap),
...Object.keys(componentMap),
]);
const vars = {
...paletteVars(theme.palette),
...typographyVars(theme.typography),
...layoutVars(theme.layout),
...overrideVars(theme.colorOverrides),
...assetMap,
...componentMap,
};
for (const [k, v] of Object.entries(vars)) {
root.style.setProperty(k, v);
}
injectFontStylesheet(theme.typography.fontUrl);
applyCustomCSS(theme.customCSS);
applyLayoutVariant(theme.layoutVariant);
}
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export function ThemeProvider({ children }: { children: ReactNode }) {
/** Name of the currently active theme (built-in id or user YAML name). */
const [themeName, setThemeName] = useState<string>(() => {
if (typeof window === "undefined") return "default";
return window.localStorage.getItem(STORAGE_KEY) ?? "default";
});
/** All selectable themes (shown in the picker). Starts with just the
* built-ins; the API call below merges in user themes. */
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,
})),
);
/** Full definitions for user themes keyed by name — the API provides
* these so custom YAMLs apply without a client-side stub. */
const [userThemeDefs, setUserThemeDefs] = useState<
Record<string, DashboardTheme>
>({});
// Resolve a theme name to a full DashboardTheme, falling back to default
// only when neither a built-in nor a user theme is found.
const resolveTheme = useCallback(
(name: string): DashboardTheme => {
return (
BUILTIN_THEMES[name] ??
userThemeDefs[name] ??
defaultTheme
);
},
[userThemeDefs],
);
// Re-apply on every themeName change, or when user themes arrive from
// the API (since the active theme might be a user theme whose definition
// hadn't loaded yet on first render).
useEffect(() => {
applyTheme(resolveTheme(themeName));
}, [themeName, resolveTheme]);
// Load server-side themes (built-ins + user YAMLs) once on mount.
useEffect(() => {
let cancelled = false;
api
.getThemes()
.then((resp) => {
if (cancelled) return;
if (resp.themes?.length) {
setAvailableThemes(
resp.themes.map((t) => ({
name: t.name,
label: t.label,
description: t.description,
})),
);
// Index any definitions the server shipped (user themes).
const defs: Record<string, DashboardTheme> = {};
for (const entry of resp.themes) {
if (entry.definition) {
defs[entry.name] = entry.definition;
}
}
if (Object.keys(defs).length > 0) setUserThemeDefs(defs);
}
if (resp.active && resp.active !== themeName) {
setThemeName(resp.active);
window.localStorage.setItem(STORAGE_KEY, resp.active);
}
})
.catch(() => {});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const setTheme = useCallback(
(name: string) => {
// Accept any name the server told us exists OR any built-in.
const knownNames = new Set<string>([
...Object.keys(BUILTIN_THEMES),
...availableThemes.map((t) => t.name),
...Object.keys(userThemeDefs),
]);
const next = knownNames.has(name) ? name : "default";
setThemeName(next);
if (typeof window !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, next);
}
api.setTheme(next).catch(() => {});
},
[availableThemes, userThemeDefs],
);
const value = useMemo<ThemeContextValue>(
() => ({
theme: resolveTheme(themeName),
themeName,
availableThemes,
setTheme,
}),
[themeName, availableThemes, setTheme, resolveTheme],
);
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
View File
@@ -1,3 +0,0 @@
export { ThemeProvider, useTheme } from "./context";
export { BUILTIN_THEMES, defaultTheme } from "./presets";
export type { DashboardTheme, ThemeLayer, ThemeListResponse, ThemePalette } from "./types";
-193
View File
@@ -1,193 +0,0 @@
import type { DashboardTheme, ThemeTypography, ThemeLayout } from "./types";
/**
* Built-in dashboard themes.
*
* Each theme defines its own palette, typography, and layout so switching
* themes produces visible changes beyond just color — fonts, density, and
* corner-radius all shift to match the theme's personality.
*
* Theme names must stay in sync with the backend's
* `_BUILTIN_DASHBOARD_THEMES` list in `hermes_cli/web_server.py`.
*/
// ---------------------------------------------------------------------------
// Shared typography / layout presets
// ---------------------------------------------------------------------------
/** Default system stack — neutral, safe fallback for every platform. */
const SYSTEM_SANS =
'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
const SYSTEM_MONO =
'ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace';
const DEFAULT_TYPOGRAPHY: ThemeTypography = {
fontSans: SYSTEM_SANS,
fontMono: SYSTEM_MONO,
baseSize: "15px",
lineHeight: "1.55",
letterSpacing: "0",
};
const DEFAULT_LAYOUT: ThemeLayout = {
radius: "0.5rem",
density: "comfortable",
};
// ---------------------------------------------------------------------------
// Themes
// ---------------------------------------------------------------------------
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,
},
typography: DEFAULT_TYPOGRAPHY,
layout: DEFAULT_LAYOUT,
};
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,
},
typography: {
...DEFAULT_TYPOGRAPHY,
fontSans: `"Inter", ${SYSTEM_SANS}`,
fontMono: `"JetBrains Mono", ${SYSTEM_MONO}`,
fontUrl:
"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap",
letterSpacing: "-0.005em",
},
layout: {
...DEFAULT_LAYOUT,
radius: "0.75rem",
},
};
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,
},
typography: {
...DEFAULT_TYPOGRAPHY,
fontSans: `"Spectral", Georgia, "Times New Roman", serif`,
fontMono: `"IBM Plex Mono", ${SYSTEM_MONO}`,
fontUrl:
"https://fonts.googleapis.com/css2?family=Spectral:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;700&display=swap",
},
layout: {
...DEFAULT_LAYOUT,
radius: "0.25rem",
},
colorOverrides: {
destructive: "#c92d0f",
warning: "#f97316",
},
};
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,
},
typography: {
...DEFAULT_TYPOGRAPHY,
fontSans: `"IBM Plex Sans", ${SYSTEM_SANS}`,
fontMono: `"IBM Plex Mono", ${SYSTEM_MONO}`,
fontUrl:
"https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap",
},
layout: {
...DEFAULT_LAYOUT,
radius: "0",
},
};
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,
},
typography: {
...DEFAULT_TYPOGRAPHY,
fontSans: `"Share Tech Mono", "JetBrains Mono", ${SYSTEM_MONO}`,
fontMono: `"Share Tech Mono", "JetBrains Mono", ${SYSTEM_MONO}`,
fontUrl:
"https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=JetBrains+Mono:wght@400;700&display=swap",
},
layout: {
...DEFAULT_LAYOUT,
radius: "0",
},
colorOverrides: {
success: "#00ff88",
warning: "#ffd700",
destructive: "#ff0055",
},
};
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,
},
typography: {
...DEFAULT_TYPOGRAPHY,
fontSans: `"Fraunces", Georgia, serif`,
fontMono: `"DM Mono", ${SYSTEM_MONO}`,
fontUrl:
"https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600&family=DM+Mono:wght@400;500&display=swap",
},
layout: {
...DEFAULT_LAYOUT,
radius: "1rem",
},
};
export const BUILTIN_THEMES: Record<string, DashboardTheme> = {
default: defaultTheme,
midnight: midnightTheme,
ember: emberTheme,
mono: monoTheme,
cyberpunk: cyberpunkTheme,
rose: roseTheme,
};
-187
View File
@@ -1,187 +0,0 @@
/**
* Dashboard theme model.
*
* Themes customise three orthogonal layers:
*
* 1. `palette` — the 3-layer color triplet (background/midground/
* foreground) + warm-glow + noise opacity. The
* design-system cascade in `src/index.css` derives
* every shadcn-compat token (card, muted, border,
* primary, etc.) from this triplet via `color-mix()`.
* 2. `typography` — font families, base font size, line height,
* letter spacing. An optional `fontUrl` is injected
* as `<link rel="stylesheet">` so self-hosted and
* Google/Bunny/etc-hosted fonts both work.
* 3. `layout` — corner radius and density (spacing multiplier).
*
* Plus an optional `colorOverrides` escape hatch for themes that want to
* pin specific shadcn tokens to exact values (e.g. a pastel theme that
* needs a softer `destructive` red than the derived default).
*/
/** A color layer: hex base + alpha (01). */
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 (01.2) on the noise overlay. Lower for softer themes
* like Mono and Rosé, higher for grittier themes like Cyberpunk. */
noiseOpacity: number;
}
export interface ThemeTypography {
/** CSS font-family stack for sans-serif body copy. */
fontSans: string;
/** CSS font-family stack for monospace / code blocks. */
fontMono: string;
/** Optional display/heading font stack. Falls back to `fontSans`. */
fontDisplay?: string;
/** Optional external stylesheet URL (e.g. Google Fonts, Bunny Fonts,
* self-hosted .woff2 @font-face sheet). Injected as a <link> in <head>
* on theme switch. Same URL is never injected twice. */
fontUrl?: string;
/** Root font size (controls rem scale). Example: `"14px"`, `"16px"`. */
baseSize: string;
/** Default line-height. Example: `"1.5"`, `"1.65"`. */
lineHeight: string;
/** Default letter-spacing. Example: `"0"`, `"0.01em"`, `"-0.01em"`. */
letterSpacing: string;
}
export type ThemeDensity = "compact" | "comfortable" | "spacious";
export interface ThemeLayout {
/** Corner-radius token. Example: `"0"`, `"0.25rem"`, `"0.5rem"`,
* `"1rem"`. Maps to `--radius` and cascades into every component. */
radius: string;
/** Spacing multiplier. `compact` = 0.85, `comfortable` = 1.0 (default),
* `spacious` = 1.2. Applied via the `--spacing-mul` CSS var. */
density: ThemeDensity;
}
/** Overall layout variant the shell renders. `standard` = default single-
* column page layout. `cockpit` = reserves a left sidebar rail for a
* plugin slot (intended for HUD-style themes with persistent status panels).
* `tiled` = relaxes the main content max-width so pages can use the full
* viewport width. Themes set this; plugins react via CSS vars /
* `[data-layout-variant="..."]` selectors. */
export type ThemeLayoutVariant = "standard" | "cockpit" | "tiled";
/** Named hero/background assets a theme can populate. Each value is
* emitted as a CSS var (`--theme-asset-<name>`). The default shell
* consumes `bg` in `<Backdrop />` when present; other slots are
* plugin-facing — a cockpit sidebar plugin reads `--theme-asset-hero`
* to render its hero render without coupling to the theme name. */
export interface ThemeAssets {
/** Full-viewport background image URL, injected under the noise layer. */
bg?: string;
/** Hero render (Gundam, mascot, wallpaper) — for plugin sidebars/overlays. */
hero?: string;
/** Logo mark — header slot consumers use this. */
logo?: string;
/** Faction/brand crest — header-left decoration. */
crest?: string;
/** Secondary sidebar illustration. */
sidebar?: string;
/** Alternate header artwork. */
header?: string;
/** User-defined named assets. Keyed by [a-zA-Z0-9_-] only.
* Emitted as `--theme-asset-custom-<key>`. */
custom?: Record<string, string>;
}
/** Component-style override buckets. Each bucket's entries become CSS
* vars (`--component-<bucket>-<kebab-property>`) that shell components
* (Card, Backdrop, App header/footer, etc.) read. Values are plain CSS
* strings — we don't parse them, so themes can use `clip-path`,
* `border-image`, `background`, `box-shadow`, and anything else CSS
* accepts. */
export interface ThemeComponentStyles {
card?: Record<string, string>;
header?: Record<string, string>;
footer?: Record<string, string>;
sidebar?: Record<string, string>;
tab?: Record<string, string>;
progress?: Record<string, string>;
badge?: Record<string, string>;
backdrop?: Record<string, string>;
page?: Record<string, string>;
}
/** Optional hex overrides keyed by shadcn-compat token name (without the
* `--color-` prefix). Any key set here wins over the DS cascade. */
export interface ThemeColorOverrides {
card?: string;
cardForeground?: string;
popover?: string;
popoverForeground?: string;
primary?: string;
primaryForeground?: string;
secondary?: string;
secondaryForeground?: string;
muted?: string;
mutedForeground?: string;
accent?: string;
accentForeground?: string;
destructive?: string;
destructiveForeground?: string;
success?: string;
warning?: string;
border?: string;
input?: string;
ring?: string;
}
export interface DashboardTheme {
description: string;
label: string;
name: string;
palette: ThemePalette;
typography: ThemeTypography;
layout: ThemeLayout;
/** Overall shell layout. Defaults to `"standard"` when absent. */
layoutVariant?: ThemeLayoutVariant;
/** Named + custom asset URLs exposed as CSS vars on theme apply. */
assets?: ThemeAssets;
/** Raw CSS injected as a scoped `<style>` tag on theme apply, cleaned up
* on theme switch. Intended for selector-level chrome that's too
* expressive for componentStyles alone (e.g. `::before` pseudo-elements,
* complex animations, media queries). */
customCSS?: string;
/** Per-component CSS-var overrides. See `ThemeComponentStyles`. */
componentStyles?: ThemeComponentStyles;
colorOverrides?: ThemeColorOverrides;
}
/**
* Wire response shape for `GET /api/dashboard/themes`.
*
* The `themes` list is intentionally partial — built-in themes are fully
* defined in `presets.ts`; user themes carry their full definition so the
* client can apply them without a second round-trip.
*/
export interface ThemeListEntry {
description: string;
label: string;
name: string;
/** Full theme definition. Present for user-defined themes loaded from
* `~/.hermes/dashboard-themes/*.yaml`; undefined for built-ins (the
* client already has those in `BUILTIN_THEMES`). */
definition?: DashboardTheme;
}
export interface ThemeListResponse {
active: string;
themes: ThemeListEntry[];
}
-34
View File
@@ -1,34 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path aliases */
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
-7
View File
@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
-26
View File
@@ -1,26 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
-108
View File
@@ -1,108 +0,0 @@
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*"([^"]+)"/;
const EMBEDDED_RE =
/window\.__HERMES_DASHBOARD_EMBEDDED_CHAT__\s*=\s*(true|false)/;
const LEGACY_TUI_RE =
/window\.__HERMES_DASHBOARD_TUI__\s*=\s*(true|false)/;
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;
}
const embeddedMatch = html.match(EMBEDDED_RE);
const legacyMatch = html.match(LEGACY_TUI_RE);
const embeddedJs = embeddedMatch
? embeddedMatch[1]
: legacyMatch
? legacyMatch[1]
: "false";
return [
{
tag: "script",
injectTo: "head",
children:
`window.__HERMES_SESSION_TOKEN__="${match[1]}";` +
`window.__HERMES_DASHBOARD_EMBEDDED_CHAT__=${embeddedJs};`,
},
];
} 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(), hermesDevToken()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
// When @nous-research/ui is symlinked via `file:../../design-language`,
// Node's module resolution would pick up shared deps from
// design-language/node_modules/*, giving us two copies + breaking
// hooks (useRef-of-null), webgl contexts, etc. Force everything that
// exists in BOTH places to use the dashboard's copy.
//
// Don't list packages here that only exist in the DS (nanostores,
// @nanostores/react) — Vite dedupe errors out when it can't find
// them at the project root.
dedupe: [
"react",
"react-dom",
"@react-three/fiber",
"@observablehq/plot",
"three",
"leva",
"gsap",
],
},
build: {
outDir: "../hermes_cli/web_dist",
emptyOutDir: true,
},
server: {
proxy: {
"/api": {
target: BACKEND,
ws: true,
},
// Same host as `hermes dashboard` must serve these; Vite has no
// dashboard-plugins/* files, so without this, plugin scripts 404
// or receive index.html in dev.
"/dashboard-plugins": BACKEND,
},
},
});