mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-27 01:11:40 +00:00
659 lines
19 KiB
TypeScript
659 lines
19 KiB
TypeScript
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,
|
|
Database,
|
|
Download,
|
|
Eye,
|
|
FileText,
|
|
Globe,
|
|
Heart,
|
|
KeyRound,
|
|
Loader2,
|
|
Menu,
|
|
MessageSquare,
|
|
Package,
|
|
Puzzle,
|
|
RotateCw,
|
|
Settings,
|
|
Shield,
|
|
Sparkles,
|
|
Star,
|
|
Terminal,
|
|
Wrench,
|
|
X,
|
|
Zap,
|
|
} from "lucide-react";
|
|
import { SelectionSwitcher, Typography } from "@nous-research/ui";
|
|
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 CronPage from "@/pages/CronPage";
|
|
import SkillsPage from "@/pages/SkillsPage";
|
|
import ChatPage from "@/pages/ChatPage";
|
|
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
|
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
|
import { useI18n } from "@/i18n";
|
|
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 (only with `hermes dashboard --tui`). */
|
|
const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
|
|
"/": RootRedirect,
|
|
"/sessions": SessionsPage,
|
|
"/analytics": AnalyticsPage,
|
|
"/logs": LogsPage,
|
|
"/cron": CronPage,
|
|
"/skills": SkillsPage,
|
|
"/config": ConfigPage,
|
|
"/env": EnvPage,
|
|
"/docs": DocsPage,
|
|
};
|
|
|
|
const BUILTIN_NAV_REST: NavItem[] = [
|
|
{
|
|
path: "/sessions",
|
|
labelKey: "sessions",
|
|
label: "Sessions",
|
|
icon: MessageSquare,
|
|
},
|
|
{
|
|
path: "/analytics",
|
|
labelKey: "analytics",
|
|
label: "Analytics",
|
|
icon: BarChart3,
|
|
},
|
|
{ path: "/logs", labelKey: "logs", label: "Logs", icon: FileText },
|
|
{ path: "/cron", labelKey: "cron", label: "Cron", icon: Clock },
|
|
{ path: "/skills", labelKey: "skills", label: "Skills", icon: Package },
|
|
{ path: "/config", labelKey: "config", label: "Config", icon: Settings },
|
|
{ path: "/env", labelKey: "keys", label: "Keys", icon: KeyRound },
|
|
{
|
|
path: "/docs",
|
|
labelKey: "documentation",
|
|
label: "Documentation",
|
|
icon: BookOpen,
|
|
},
|
|
];
|
|
|
|
const ICON_MAP: Record<string, ComponentType<{ className?: string }>> = {
|
|
Activity,
|
|
BarChart3,
|
|
Clock,
|
|
FileText,
|
|
KeyRound,
|
|
MessageSquare,
|
|
Package,
|
|
Settings,
|
|
Puzzle,
|
|
Sparkles,
|
|
Terminal,
|
|
Globe,
|
|
Database,
|
|
Shield,
|
|
Wrench,
|
|
Zap,
|
|
Heart,
|
|
Star,
|
|
Code,
|
|
Eye,
|
|
};
|
|
|
|
function resolveIcon(name: string): 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;
|
|
}
|
|
|
|
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 (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 (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 } = 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();
|
|
|
|
const builtinRoutes = useMemo(
|
|
() => ({
|
|
...BUILTIN_ROUTES_CORE,
|
|
...(embeddedChat ? { "/chat": ChatPage } : {}),
|
|
}),
|
|
[embeddedChat],
|
|
);
|
|
|
|
const builtinNav = useMemo(
|
|
() =>
|
|
embeddedChat ? [CHAT_NAV_ITEM, ...BUILTIN_NAV_REST] : BUILTIN_NAV_REST,
|
|
[embeddedChat],
|
|
);
|
|
|
|
const navItems = useMemo(
|
|
() => buildNavItems(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
|
|
type="button"
|
|
onClick={() => setMobileOpen(true)}
|
|
aria-label={t.app.openNavigation}
|
|
aria-expanded={mobileOpen}
|
|
aria-controls="app-sidebar"
|
|
className={cn(
|
|
"inline-flex h-8 w-8 items-center justify-center",
|
|
"text-midground/70 hover:text-midground transition-colors cursor-pointer",
|
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
|
)}
|
|
>
|
|
<Menu className="h-4 w-4" />
|
|
</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
|
|
type="button"
|
|
aria-label={t.app.closeNavigation}
|
|
onClick={closeMobile}
|
|
className={cn(
|
|
"lg:hidden fixed inset-0 z-40",
|
|
"bg-black/60 backdrop-blur-sm cursor-pointer",
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
<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 px-5",
|
|
"border-b border-current/20",
|
|
)}
|
|
>
|
|
<Typography
|
|
className="font-bold text-[1.125rem] leading-[0.95] tracking-[0.0525rem] text-midground"
|
|
style={{ mixBlendMode: "plus-lighter" }}
|
|
>
|
|
Hermes
|
|
<br />
|
|
Agent
|
|
</Typography>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={closeMobile}
|
|
aria-label={t.app.closeNavigation}
|
|
className={cn(
|
|
"lg:hidden inline-flex h-7 w-7 items-center justify-center",
|
|
"text-midground/70 hover:text-midground transition-colors cursor-pointer",
|
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
|
)}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
<PluginSlot name="header-left" />
|
|
|
|
<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">
|
|
{navItems.map(({ path, label, labelKey, icon: Icon }) => {
|
|
const navLabel = labelKey
|
|
? ((t.app.nav as Record<string, string>)[labelKey] ?? label)
|
|
: label;
|
|
return (
|
|
<li key={path}>
|
|
<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>
|
|
);
|
|
})}
|
|
</ul>
|
|
</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>
|
|
</div>
|
|
<PluginSlot name="post-main" />
|
|
</div>
|
|
</PageHeaderProvider>
|
|
</div>
|
|
</div>
|
|
|
|
<PluginSlot name="overlay" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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}>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleClick(action)}
|
|
disabled={disabled}
|
|
aria-busy={busy}
|
|
className={cn(
|
|
"group relative flex w-full items-center gap-3",
|
|
"px-5 py-1.5",
|
|
"font-mondwest text-[0.75rem] tracking-[0.1em]",
|
|
"text-left whitespace-nowrap transition-opacity cursor-pointer",
|
|
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-midground",
|
|
busy
|
|
? "text-midground opacity-100"
|
|
: "opacity-60 hover:opacity-100",
|
|
"disabled:cursor-not-allowed disabled:opacity-30",
|
|
)}
|
|
>
|
|
{isPending ? (
|
|
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" />
|
|
) : (
|
|
<Icon
|
|
className={cn(
|
|
"h-3.5 w-3.5 shrink-0",
|
|
isActionRunning && spin && "animate-spin",
|
|
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" }}
|
|
/>
|
|
)}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface NavItem {
|
|
icon: ComponentType<{ className?: string }>;
|
|
label: string;
|
|
labelKey?: string;
|
|
path: string;
|
|
}
|
|
|
|
interface SystemActionItem {
|
|
action: SystemAction;
|
|
icon: ComponentType<{ className?: string }>;
|
|
label: string;
|
|
runningLabel: string;
|
|
spin: boolean;
|
|
}
|