mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 23:11:37 +08:00
Add a plugin system that lets plugins add new tabs to the dashboard.
Plugins live in ~/.hermes/plugins/<name>/dashboard/ alongside any
existing CLI/gateway plugin code.
Plugin structure:
plugins/<name>/dashboard/
manifest.json # name, label, icon, tab config, entry point
dist/index.js # pre-built JS bundle (IIFE, uses SDK globals)
plugin_api.py # optional FastAPI router mounted at /api/plugins/<name>/
Backend (hermes_cli/web_server.py):
- Plugin discovery: scans plugins/*/dashboard/manifest.json from user,
bundled, and project plugin directories
- GET /api/dashboard/plugins — returns discovered plugin manifests
- GET /api/dashboard/plugins/rescan — force re-discovery
- GET /dashboard-plugins/<name>/<path> — serves plugin static assets
with path traversal protection
- Optional API route mounting: imports plugin_api.py and mounts its
router under /api/plugins/<name>/
- Plugin API routes bypass session token auth (localhost-only)
Frontend (web/src/plugins/):
- Plugin SDK exposed on window.__HERMES_PLUGIN_SDK__ — provides React,
hooks, UI components (Card, Badge, Button, etc.), API client,
fetchJSON, theme/i18n hooks, and utilities
- Plugin registry on window.__HERMES_PLUGINS__.register(name, Component)
- usePlugins() hook: fetches manifests, loads JS/CSS, resolves components
- App.tsx dynamically adds nav items and routes for discovered plugins
- Icon resolution via static map of 20 common Lucide icons (no tree-
shaking penalty — bundle only +5KB over baseline)
Example plugin (plugins/example-dashboard/):
- Demonstrates SDK usage: Card components, backend API call, SDK reference
- Backend route: GET /api/plugins/example/hello
Tested: plugin discovery, static serving, API routes, path traversal
blocking, unknown plugin 404, bundle size (400KB vs 394KB baseline).
91 lines
2.9 KiB
TypeScript
91 lines
2.9 KiB
TypeScript
/**
|
|
* usePlugins hook — discovers and loads dashboard plugins.
|
|
*
|
|
* 1. Fetches plugin manifests from GET /api/dashboard/plugins
|
|
* 2. Injects CSS <link> tags for plugins that declare css
|
|
* 3. Loads plugin JS bundles via <script> tags
|
|
* 4. Waits for plugins to call register() and resolves them
|
|
*/
|
|
|
|
import { useState, useEffect, useRef } from "react";
|
|
import { api } from "@/lib/api";
|
|
import type { PluginManifest, RegisteredPlugin } from "./types";
|
|
import { getPluginComponent, onPluginRegistered } from "./registry";
|
|
|
|
export function usePlugins() {
|
|
const [manifests, setManifests] = useState<PluginManifest[]>([]);
|
|
const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const loadedScripts = useRef<Set<string>>(new Set());
|
|
|
|
// Fetch manifests on mount.
|
|
useEffect(() => {
|
|
api
|
|
.getPlugins()
|
|
.then((list) => {
|
|
setManifests(list);
|
|
if (list.length === 0) setLoading(false);
|
|
})
|
|
.catch(() => setLoading(false));
|
|
}, []);
|
|
|
|
// Load plugin assets when manifests arrive.
|
|
useEffect(() => {
|
|
if (manifests.length === 0) return;
|
|
|
|
for (const manifest of manifests) {
|
|
// Inject CSS if specified.
|
|
if (manifest.css) {
|
|
const cssUrl = `/dashboard-plugins/${manifest.name}/${manifest.css}`;
|
|
if (!document.querySelector(`link[href="${cssUrl}"]`)) {
|
|
const link = document.createElement("link");
|
|
link.rel = "stylesheet";
|
|
link.href = cssUrl;
|
|
document.head.appendChild(link);
|
|
}
|
|
}
|
|
|
|
// Load JS bundle.
|
|
const jsUrl = `/dashboard-plugins/${manifest.name}/${manifest.entry}`;
|
|
if (loadedScripts.current.has(jsUrl)) continue;
|
|
loadedScripts.current.add(jsUrl);
|
|
|
|
const script = document.createElement("script");
|
|
script.src = jsUrl;
|
|
script.async = true;
|
|
script.onerror = () => {
|
|
console.warn(`[plugins] Failed to load ${manifest.name} from ${jsUrl}`);
|
|
};
|
|
document.body.appendChild(script);
|
|
}
|
|
|
|
// Give plugins a moment to load and register, then stop loading state.
|
|
const timeout = setTimeout(() => setLoading(false), 2000);
|
|
return () => clearTimeout(timeout);
|
|
}, [manifests]);
|
|
|
|
// Listen for plugin registrations and resolve them against manifests.
|
|
useEffect(() => {
|
|
function resolvePlugins() {
|
|
const resolved: RegisteredPlugin[] = [];
|
|
for (const manifest of manifests) {
|
|
const component = getPluginComponent(manifest.name);
|
|
if (component) {
|
|
resolved.push({ manifest, component });
|
|
}
|
|
}
|
|
setPlugins(resolved);
|
|
// If all plugins registered, stop loading early.
|
|
if (resolved.length === manifests.length && manifests.length > 0) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
resolvePlugins();
|
|
const unsub = onPluginRegistered(resolvePlugins);
|
|
return unsub;
|
|
}, [manifests]);
|
|
|
|
return { plugins, manifests, loading };
|
|
}
|