Download web/src/plugins/usePlugins.ts from SaylorTwift/hermes-agent: direct link, hf CLI and curl.
- Browser
- Download file 7.12 kB
-
https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/web/src/plugins/usePlugins.ts
- Command line
-
hf download hf://SaylorTwift/hermes-agent/web/src/plugins/usePlugins.ts
-
curl -L -o usePlugins.ts https://huggingface.co/SaylorTwift/hermes-agent/resolve/main/web/src/plugins/usePlugins.ts
7.12 kB
| /** | |
| * 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, HERMES_BASE_PATH } from "@/lib/api"; | |
| import type { PluginManifest, RegisteredPlugin } from "./types"; | |
| import { | |
| getPluginComponent, | |
| onPluginRegistered, | |
| notifyPluginRegistry, | |
| setPluginLoadError, | |
| } from "./registry"; | |
| export const MANIFEST_CACHE_KEY = "hermes:plugin-manifests"; | |
| export function getCachedManifests(): PluginManifest[] | null { | |
| try { | |
| const raw = sessionStorage.getItem(MANIFEST_CACHE_KEY); | |
| if (!raw) return null; | |
| const parsed = JSON.parse(raw); | |
| return Array.isArray(parsed) ? (parsed as PluginManifest[]) : null; | |
| } catch { | |
| return null; | |
| } | |
| } | |
| export function cacheManifests(manifests: PluginManifest[]): void { | |
| try { | |
| sessionStorage.setItem(MANIFEST_CACHE_KEY, JSON.stringify(manifests)); | |
| } catch { | |
| // sessionStorage unavailable (private browsing, storage full, etc.) | |
| } | |
| } | |
| /** | |
| * Whether it is safe to skip the initial plugin-loading gate for a set of | |
| * cached manifests. | |
| * | |
| * App.tsx waits on `pluginsLoading` before mounting the persistent ChatPage | |
| * host: if a plugin overrides /chat (`tab.override === "/chat"`), mounting | |
| * the built-in chat first would spawn a PTY and then yank it out from under | |
| * the user when the plugin resolves. That gate is load-bearing — so we may | |
| * only seed `loading = false` from the cache when no cached manifest | |
| * declares a /chat override. Manifests are still seeded either way; only | |
| * the loading flag stays conservative. | |
| */ | |
| export function canSeedLoadedFromCache( | |
| cached: PluginManifest[] | null, | |
| ): boolean { | |
| if (cached === null) return false; | |
| return !cached.some((m) => m.tab?.override === "/chat"); | |
| } | |
| export function usePlugins() { | |
| // Lazy initialisers run once at mount — safe to read sessionStorage here. | |
| // This avoids the "cannot access ref during render" lint error that would | |
| // occur if we stored the cached value in a useRef and read .current in the | |
| // useState initial value expression. | |
| const [manifests, setManifests] = useState<PluginManifest[]>( | |
| () => getCachedManifests() ?? [], | |
| ); | |
| const [plugins, setPlugins] = useState<RegisteredPlugin[]>([]); | |
| // Start loading=false when the cache has manifests so plugin routes are | |
| // registered synchronously on the first render after a refresh. | |
| // The catch-all in App.tsx is only a safety net for the very first visit | |
| // (no cache yet). On subsequent visits this flag starts false immediately. | |
| // | |
| // Exception: if any cached manifest overrides /chat we must keep | |
| // loading=true — App.tsx's pluginsLoading gate around the persistent | |
| // ChatPage host is load-bearing (see canSeedLoadedFromCache). | |
| const [loading, setLoading] = useState<boolean>( | |
| () => !canSeedLoadedFromCache(getCachedManifests()), | |
| ); | |
| const loadedScripts = useRef<Set<string>>(new Set()); | |
| // Always re-fetch in the background to keep the cache fresh. | |
| // This handles: new plugins added, plugins removed, manifest changes. | |
| // setManifests(list) will update routes if the server list differs from cache. | |
| useEffect(() => { | |
| api | |
| .getPlugins() | |
| .then((list) => { | |
| cacheManifests(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 = `${HERMES_BASE_PATH}/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 = `${HERMES_BASE_PATH}/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; | |
| // SRI integrity verification — defense against compromised plugin | |
| // delivery. Plugin manifests can declare an integrity hash | |
| // (e.g. "sha384-...") which the browser verifies before executing. | |
| // Without this, a man-in-the-middle or compromised plugin server | |
| // can substitute the JS bundle silently. Opt-in: when no integrity | |
| // is declared in the manifest, behavior is unchanged. | |
| if (manifest.integrity && typeof manifest.integrity === "string") { | |
| script.integrity = manifest.integrity; | |
| script.crossOrigin = "anonymous"; | |
| } | |
| 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 }; | |
| } | |