mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(dashboard): reskin extension points for themes and plugins (#14776)
Themes and plugins can now pull off arbitrary dashboard reskins (cockpit
HUD, retro terminal, etc.) without touching core code.
Themes gain four new fields:
- layoutVariant: standard | cockpit | tiled — shell layout selector
- assets: {bg, hero, logo, crest, sidebar, header, custom: {...}} —
artwork URLs exposed as --theme-asset-* CSS vars
- customCSS: raw CSS injected as a scoped <style> tag on theme apply
(32 KiB cap, cleaned up on theme switch)
- componentStyles: per-component CSS-var overrides (clipPath,
borderImage, background, boxShadow, ...) for card/header/sidebar/
backdrop/tab/progress/badge/footer/page
Plugin manifests gain three new fields:
- tab.override: replaces a built-in route instead of adding a tab
- tab.hidden: register component + slots without adding a nav entry
- slots: declares shell slots the plugin populates
10 named shell slots: backdrop, header-left/right/banner, sidebar,
pre-main, post-main, footer-left/right, overlay. Plugins register via
window.__HERMES_PLUGINS__.registerSlot(name, slot, Component). A
<PluginSlot> React helper is exported on the plugin SDK.
Ships a full demo at plugins/strike-freedom-cockpit/ — theme YAML +
slot-only plugin that reproduces a Gundam cockpit dashboard: MS-STATUS
sidebar with live telemetry, COMPASS crest in header, notched card
corners via componentStyles, scanline overlay via customCSS, gold/cyan
palette, Orbitron typography.
Validation:
- 15 new tests in test_web_server.py covering every extended field
- tests/hermes_cli/: 2615 passed (3 pre-existing unrelated failures)
- tsc -b --noEmit: clean
- vite build: 418 kB bundle, ~2 kB delta for slots/theme extensions
Co-authored-by: Teknium <p@nousresearch.com>
This commit is contained in:
parent
470389e6a3
commit
f593c367be
17 changed files with 1576 additions and 40 deletions
219
web/src/App.tsx
219
web/src/App.tsx
|
|
@ -36,8 +36,23 @@ import SkillsPage from "@/pages/SkillsPage";
|
|||
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
|
||||
import { ThemeSwitcher } from "@/components/ThemeSwitcher";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { usePlugins } from "@/plugins";
|
||||
import { PluginSlot, usePlugins } from "@/plugins";
|
||||
import type { RegisteredPlugin } from "@/plugins";
|
||||
import { useTheme } from "@/themes";
|
||||
|
||||
/** Built-in route → default page component. Used both for standard routing
|
||||
* and for resolving plugin `tab.override` values. Keys must match the
|
||||
* `path` in `BUILTIN_NAV` so `/path` lookups stay consistent. */
|
||||
const BUILTIN_ROUTES: Record<string, React.ComponentType> = {
|
||||
"/": StatusPage,
|
||||
"/sessions": SessionsPage,
|
||||
"/analytics": AnalyticsPage,
|
||||
"/logs": LogsPage,
|
||||
"/cron": CronPage,
|
||||
"/skills": SkillsPage,
|
||||
"/config": ConfigPage,
|
||||
"/env": EnvPage,
|
||||
};
|
||||
|
||||
const BUILTIN_NAV: NavItem[] = [
|
||||
{ path: "/", labelKey: "status", label: "Status", icon: Activity },
|
||||
|
|
@ -98,6 +113,13 @@ function buildNavItems(
|
|||
const items = [...builtIn];
|
||||
|
||||
for (const { manifest } of plugins) {
|
||||
// Plugins that replace a built-in route don't add a new tab entry —
|
||||
// they reuse the existing tab. The nav just lights up the original
|
||||
// built-in entry when the user visits `/`.
|
||||
if (manifest.tab.override) continue;
|
||||
// Hidden plugins register their component + slots but skip the nav.
|
||||
if (manifest.tab.hidden) continue;
|
||||
|
||||
const pluginItem: NavItem = {
|
||||
path: manifest.tab.path,
|
||||
label: manifest.label,
|
||||
|
|
@ -123,19 +145,89 @@ function buildNavItems(
|
|||
return items;
|
||||
}
|
||||
|
||||
/** Build the final route table, letting plugins override built-in pages.
|
||||
*
|
||||
* Returns (path, Component, key) tuples. Plugins with `tab.override`
|
||||
* win over both built-ins and other plugins (last registration wins if
|
||||
* two plugins claim the same override, but we warn in dev). Plugins with
|
||||
* a regular `tab.path` register alongside built-ins as standalone
|
||||
* routes. */
|
||||
function buildRoutes(
|
||||
plugins: RegisteredPlugin[],
|
||||
): Array<{ key: string; path: string; Component: React.ComponentType }> {
|
||||
const overrides = new Map<string, RegisteredPlugin>();
|
||||
const addons: RegisteredPlugin[] = [];
|
||||
|
||||
for (const p of plugins) {
|
||||
if (p.manifest.tab.override) {
|
||||
overrides.set(p.manifest.tab.override, p);
|
||||
} else {
|
||||
addons.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
const routes: Array<{
|
||||
key: string;
|
||||
path: string;
|
||||
Component: React.ComponentType;
|
||||
}> = [];
|
||||
|
||||
for (const [path, Component] of Object.entries(BUILTIN_ROUTES)) {
|
||||
const override = overrides.get(path);
|
||||
if (override) {
|
||||
routes.push({
|
||||
key: `override:${override.manifest.name}`,
|
||||
path,
|
||||
Component: override.component,
|
||||
});
|
||||
} else {
|
||||
routes.push({ key: `builtin:${path}`, path, Component });
|
||||
}
|
||||
}
|
||||
|
||||
for (const addon of addons) {
|
||||
// Don't double-register a plugin that shadows a built-in path via
|
||||
// `tab.path` — `override` is the supported mechanism for that.
|
||||
if (BUILTIN_ROUTES[addon.manifest.tab.path]) continue;
|
||||
routes.push({
|
||||
key: `plugin:${addon.manifest.name}`,
|
||||
path: addon.manifest.tab.path,
|
||||
Component: addon.component,
|
||||
});
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const { t } = useI18n();
|
||||
const { plugins } = usePlugins();
|
||||
const { theme } = useTheme();
|
||||
|
||||
const navItems = useMemo(
|
||||
() => buildNavItems(BUILTIN_NAV, plugins),
|
||||
[plugins],
|
||||
);
|
||||
const routes = useMemo(() => buildRoutes(plugins), [plugins]);
|
||||
|
||||
const layoutVariant = theme.layoutVariant ?? "standard";
|
||||
const showSidebar = layoutVariant === "cockpit";
|
||||
// Tiled layout drops the 1600px clamp so pages can use the full viewport;
|
||||
// standard + cockpit keep the centered reading width.
|
||||
const mainMaxWidth = layoutVariant === "tiled" ? "max-w-none" : "max-w-[1600px]";
|
||||
|
||||
return (
|
||||
<div className="text-midground font-mondwest bg-black min-h-screen flex flex-col uppercase antialiased overflow-x-hidden">
|
||||
<div
|
||||
data-layout-variant={layoutVariant}
|
||||
className="text-midground font-mondwest bg-black min-h-screen flex flex-col uppercase antialiased overflow-x-hidden"
|
||||
>
|
||||
<SelectionSwitcher />
|
||||
<Backdrop />
|
||||
{/* Themes can style backdrop chrome via `componentStyles.backdrop.*`
|
||||
CSS vars read by <Backdrop />. Plugins can also inject full
|
||||
components into the backdrop layer via the `backdrop` slot —
|
||||
useful for scanlines, parallax stars, hero artwork, etc. */}
|
||||
<PluginSlot name="backdrop" />
|
||||
|
||||
<header
|
||||
className={cn(
|
||||
|
|
@ -143,8 +235,17 @@ export default function App() {
|
|||
"border-b border-current/20",
|
||||
"bg-background-base/90 backdrop-blur-sm",
|
||||
)}
|
||||
style={{
|
||||
// Themes can tweak header chrome (background, border-image,
|
||||
// clip-path) via these CSS vars. Unset vars compute to the
|
||||
// property's initial value, so themes opt in per-property.
|
||||
background: "var(--component-header-background)",
|
||||
borderImage: "var(--component-header-border-image)",
|
||||
clipPath: "var(--component-header-clip-path)",
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto flex h-12 max-w-[1600px]">
|
||||
<div className={cn("mx-auto flex h-12", mainMaxWidth)}>
|
||||
<PluginSlot name="header-left" />
|
||||
<div className="min-w-0 flex-1 overflow-x-auto scrollbar-none">
|
||||
<Grid
|
||||
className="h-full !border-t-0 !border-b-0"
|
||||
|
|
@ -180,6 +281,9 @@ export default function App() {
|
|||
: "opacity-60 hover:opacity-100",
|
||||
)
|
||||
}
|
||||
style={{
|
||||
clipPath: "var(--component-tab-clip-path)",
|
||||
}}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
|
|
@ -214,6 +318,7 @@ export default function App() {
|
|||
|
||||
<Grid className="h-full shrink-0 !border-t-0 !border-b-0">
|
||||
<Cell className="flex items-center gap-2 !p-0 !px-2 sm:!px-4">
|
||||
<PluginSlot name="header-right" />
|
||||
<ThemeSwitcher />
|
||||
<LanguageSwitcher />
|
||||
<Typography
|
||||
|
|
@ -227,50 +332,92 @@ export default function App() {
|
|||
</div>
|
||||
</header>
|
||||
|
||||
<main className="relative z-2 mx-auto w-full max-w-[1600px] flex-1 px-3 sm:px-6 pt-16 sm:pt-20 pb-4 sm:pb-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<StatusPage />} />
|
||||
<Route path="/sessions" element={<SessionsPage />} />
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/logs" element={<LogsPage />} />
|
||||
<Route path="/cron" element={<CronPage />} />
|
||||
<Route path="/skills" element={<SkillsPage />} />
|
||||
<Route path="/config" element={<ConfigPage />} />
|
||||
<Route path="/env" element={<EnvPage />} />
|
||||
{/* Full-width banner slot under the nav, outside the main clamp —
|
||||
useful for marquee/alert/status strips themes want to show
|
||||
above page content. */}
|
||||
<PluginSlot name="header-banner" />
|
||||
|
||||
{plugins.map(({ manifest, component: PluginComponent }) => (
|
||||
<Route
|
||||
key={manifest.name}
|
||||
path={manifest.tab.path}
|
||||
element={<PluginComponent />}
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-2 mx-auto w-full flex-1 px-3 sm:px-6 pt-16 sm:pt-20 pb-4 sm:pb-8",
|
||||
mainMaxWidth,
|
||||
showSidebar && "flex gap-4 sm:gap-6",
|
||||
)}
|
||||
>
|
||||
{showSidebar && (
|
||||
<aside
|
||||
className={cn(
|
||||
"w-[260px] shrink-0 border-r border-current/20 pr-3 sm:pr-4",
|
||||
"hidden lg:block",
|
||||
)}
|
||||
style={{
|
||||
background: "var(--component-sidebar-background)",
|
||||
clipPath: "var(--component-sidebar-clip-path)",
|
||||
borderImage: "var(--component-sidebar-border-image)",
|
||||
}}
|
||||
>
|
||||
<PluginSlot
|
||||
name="sidebar"
|
||||
fallback={
|
||||
<div className="p-4 text-xs opacity-60 font-mondwest tracking-wide">
|
||||
{/* Cockpit layout with no sidebar plugin — rare but valid;
|
||||
the space still exists so the grid doesn't shift when
|
||||
a plugin loads asynchronously. */}
|
||||
sidebar slot empty
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</main>
|
||||
<main className="min-w-0 flex-1">
|
||||
<PluginSlot name="pre-main" />
|
||||
<Routes>
|
||||
{routes.map(({ key, path, Component }) => (
|
||||
<Route key={key} path={path} element={<Component />} />
|
||||
))}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
<PluginSlot name="post-main" />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<footer className="relative z-2 border-t border-current/20">
|
||||
<Grid className="mx-auto max-w-[1600px] !border-t-0 !border-b-0">
|
||||
<Grid className={cn("mx-auto !border-t-0 !border-b-0", mainMaxWidth)}>
|
||||
<Cell className="flex items-center !px-3 sm:!px-6 !py-3">
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] opacity-60"
|
||||
>
|
||||
{t.app.footer.name}
|
||||
</Typography>
|
||||
<PluginSlot
|
||||
name="footer-left"
|
||||
fallback={
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.7rem] sm:text-[0.8rem] tracking-[0.12em] opacity-60"
|
||||
>
|
||||
{t.app.footer.name}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell className="flex items-center justify-end !px-3 sm:!px-6 !py-3">
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.6rem] sm:text-[0.7rem] tracking-[0.15em] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
{t.app.footer.org}
|
||||
</Typography>
|
||||
<PluginSlot
|
||||
name="footer-right"
|
||||
fallback={
|
||||
<Typography
|
||||
mondwest
|
||||
className="text-[0.6rem] sm:text-[0.7rem] tracking-[0.15em] text-midground"
|
||||
style={{ mixBlendMode: "plus-lighter" }}
|
||||
>
|
||||
{t.app.footer.org}
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</Cell>
|
||||
</Grid>
|
||||
</footer>
|
||||
|
||||
{/* Fixed-position overlay plugins (scanlines, vignettes, etc.) render
|
||||
above everything else. Each plugin is responsible for its own
|
||||
pointer-events and z-index. */}
|
||||
<PluginSlot name="overlay" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue