mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 15:01:34 +08:00
feat: add sidebar
This commit is contained in:
64
web/src/plugins/PluginPage.tsx
Normal file
64
web/src/plugins/PluginPage.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
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",
|
||||
)}
|
||||
>
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin" aria-hidden />
|
||||
<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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
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";
|
||||
|
||||
@@ -37,6 +37,7 @@ import { registerSlot, PluginSlot } from "./slots";
|
||||
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() {
|
||||
@@ -45,8 +46,14 @@ function _notify() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 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();
|
||||
}
|
||||
@@ -56,6 +63,15 @@ export function getPluginComponent(name: string): React.ComponentType | undefine
|
||||
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);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
/** Types for the dashboard plugin system. */
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
export interface PluginManifest {
|
||||
name: string;
|
||||
label: string;
|
||||
@@ -8,21 +10,14 @@ export interface PluginManifest {
|
||||
version: string;
|
||||
tab: {
|
||||
path: string;
|
||||
position: string; // "end", "after:<tab>", "before:<tab>"
|
||||
/** When set to a built-in route path (e.g. `"/"`, `"/sessions"`), this
|
||||
* plugin's component replaces the built-in page at that route rather
|
||||
* than adding a new tab. Useful for themes that want a custom home
|
||||
* page without losing the rest of the dashboard. */
|
||||
/** "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 registers its component and slot contributors
|
||||
* without adding a tab to the nav. Used by slot-only plugins (e.g. a
|
||||
* plugin that just injects a header crest). */
|
||||
/** When true, the plugin may register without a sidebar tab (slot-only, etc.). */
|
||||
hidden?: boolean;
|
||||
};
|
||||
/** Named shell slots this plugin populates. Mirrored by the backend's
|
||||
* manifest discovery; used purely as a documentation/discovery aid —
|
||||
* actual slot registration happens when the plugin's JS bundle calls
|
||||
* `window.__HERMES_PLUGINS__.registerSlot(name, slot, Component)`. */
|
||||
/** Declared for discovery; actual slots use registerSlot in the plugin bundle. */
|
||||
slots?: string[];
|
||||
entry: string;
|
||||
css?: string | null;
|
||||
@@ -32,5 +27,5 @@ export interface PluginManifest {
|
||||
|
||||
export interface RegisteredPlugin {
|
||||
manifest: PluginManifest;
|
||||
component: React.ComponentType;
|
||||
component: ComponentType;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,12 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { PluginManifest, RegisteredPlugin } from "./types";
|
||||
import { getPluginComponent, onPluginRegistered } from "./registry";
|
||||
import {
|
||||
getPluginComponent,
|
||||
onPluginRegistered,
|
||||
notifyPluginRegistry,
|
||||
setPluginLoadError,
|
||||
} from "./registry";
|
||||
|
||||
export function usePlugins() {
|
||||
const [manifests, setManifests] = useState<PluginManifest[]>([]);
|
||||
@@ -33,6 +38,8 @@ export function usePlugins() {
|
||||
useEffect(() => {
|
||||
if (manifests.length === 0) return;
|
||||
|
||||
const injectedScripts: HTMLScriptElement[] = [];
|
||||
|
||||
for (const manifest of manifests) {
|
||||
// Inject CSS if specified.
|
||||
if (manifest.css) {
|
||||
@@ -45,23 +52,49 @@ export function usePlugins() {
|
||||
}
|
||||
}
|
||||
|
||||
// Load JS bundle.
|
||||
const jsUrl = `/dashboard-plugins/${manifest.name}/${manifest.entry}`;
|
||||
if (loadedScripts.current.has(jsUrl)) continue;
|
||||
loadedScripts.current.add(jsUrl);
|
||||
// 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.src = jsUrl;
|
||||
script.setAttribute("data-hermes-plugin", manifest.name);
|
||||
script.src = scriptSrc;
|
||||
script.async = true;
|
||||
script.onerror = () => {
|
||||
console.warn(`[plugins] Failed to load ${manifest.name} from ${jsUrl}`);
|
||||
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);
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user