import { useCallback, useEffect, useState } from "react"; import { ExternalLink, RefreshCw, Trash2, Eye, EyeOff } from "lucide-react"; import type { Translations } from "@/i18n/types"; import { Link } from "react-router"; import { api } from "@/lib/api"; import type { HubAgentPluginRow, MemoryProviderConfig, MemoryProviderField, MemoryProviderInfo, MemoryProviderSetupInfo, MemoryProviderSetupResult, PluginsHubResponse, } from "@/lib/api"; import { Button } from "@nous-research/ui/ui/components/button"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; import { Switch } from "@nous-research/ui/ui/components/switch"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { CommandBlock, CopyButton } from "@nous-research/ui/ui/components/command-block"; import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card"; import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog"; import { Input } from "@nous-research/ui/ui/components/input"; import { Label } from "@nous-research/ui/ui/components/label"; import { useToast } from "@nous-research/ui/hooks/use-toast"; import { Toast } from "@nous-research/ui/ui/components/toast"; import { useI18n } from "@/i18n"; import { PluginSlot } from "@/plugins"; import { cn } from "@/lib/utils"; import { usePageHeader } from "@/contexts/usePageHeader"; /** Select value for built-in memory (`config` uses empty string). Never use `""` — UI Select maps empty value to an empty label. */ const MEMORY_PROVIDER_BUILTIN = "__hermes_memory_builtin__"; type MemoryFormValue = string | boolean | number; const MEMORY_STATUS_LABEL: Record = { ready: "ready", needs_config: "needs setup", unavailable: "unavailable", missing: "missing", }; const MEMORY_STATUS_TONE: Record = { ready: "success", needs_config: "warning", unavailable: "destructive", missing: "destructive", }; function fieldInitialValue(field: MemoryProviderField): MemoryFormValue { if (field.kind === "secret") return ""; if (field.kind === "boolean") return Boolean(field.value); return String(field.value ?? ""); } function fieldIsVisible(field: MemoryProviderField, values: Record) { if (!field.when) return true; return Object.entries(field.when).every(([key, expected]) => { const current = values[key]; return String(current ?? "") === String(expected); }); } function setupHasDetails(setup?: MemoryProviderSetupInfo) { if (!setup) return false; return Boolean( setup.external_dependencies?.length || setup.pip_dependencies?.length || setup.required_env?.length, ); } function setupHasInstallableSteps(setup?: MemoryProviderSetupInfo) { if (!setup) return false; return Boolean( setup.external_dependencies?.some((dep) => dep.install) || setup.pip_dependencies?.length, ); } function SetupCommandBlock({ code, label }: { code: string; label: string }) { return (
{label}
{code}
); } function setupResultLabel(status: string) { if (status === "already_installed") return "already installed"; if (status === "no_declared_steps") return "no declared setup"; return status.replace(/_/g, " "); } function setupResultClass(status: string) { if (status === "failed") return "border-destructive/50 text-destructive"; if (status === "installed" || status === "verified" || status === "already_installed") { return "border-success/50 text-success"; } if (status === "missing") return "border-warning/50 text-warning"; return "border-border text-muted-foreground"; } function MemoryProviderSetupResults({ results }: { results: MemoryProviderSetupResult[] }) { if (!results.length) return null; return (

Setup results

{results.map((result, index) => { const detail = result.stderr || result.stdout; return (
{setupResultLabel(result.status)} {result.name} {result.kind ? ` (${result.kind.replace(/_/g, " ")})` : ""}
{result.command ? ( {result.command} ) : null} {detail ? (
                {detail}
              
) : null}
); })}
); } function MemoryProviderSetupHint({ installing, onInstall, provider, results, }: { installing: boolean; onInstall: () => void; provider: MemoryProviderInfo; results: MemoryProviderSetupResult[] | null; }) { const setup = provider.setup; const hasDetails = setupHasDetails(setup); const hasInstallableSteps = setupHasInstallableSteps(setup); const dependenciesInstalled = setup?.dependencies_installed ?? !hasInstallableSteps; const hasResults = Boolean(results?.length); const needsDependencySetup = hasInstallableSteps && !dependenciesInstalled; const isBlocked = provider.status === "unavailable" && needsDependencySetup; const shouldShow = hasResults || needsDependencySetup || (provider.status === "unavailable" && hasDetails && !dependenciesInstalled); if (!shouldShow) return null; if (!hasDetails || !setup) { return (

This provider is installed but unavailable. It may need local dependencies or a manual setup step before Hermes can activate it.

); } return (

{needsDependencySetup ? "Finish these setup steps before Hermes can activate this provider." : "Provider dependency setup completed."}

{needsDependencySetup ? ( ) : null} {installing ? (
Running provider setup. This may take a minute…
) : null} {results ? : null} {needsDependencySetup ? ( <> {setup.external_dependencies.map((dep, index) => (

External dependency{dep.name ? `: ${dep.name}` : ""}

{dep.install ? ( ) : null} {dep.check ? ( ) : null}
))} {setup.pip_dependencies.length ? (

Python dependencies

{setup.pip_dependencies.map((dep) => ( {dep} ))}
) : null} ) : null} {setup.required_env.length && needsDependencySetup ? (

Required environment values. Fill the matching fields below, or set them in the Hermes environment.

{setup.required_env.map((envKey) => ( {envKey} ))}
) : null}
); } export default function PluginsPage() { const [hub, setHub] = useState(null); const [loading, setLoading] = useState(true); const [installId, setInstallId] = useState(""); const [installForce, setInstallForce] = useState(false); const [installEnable, setInstallEnable] = useState(true); const [installBusy, setInstallBusy] = useState(false); const [rescanBusy, setRescanBusy] = useState(false); const [memorySel, setMemorySel] = useState(MEMORY_PROVIDER_BUILTIN); const [memoryConfig, setMemoryConfig] = useState(null); const [memoryValues, setMemoryValues] = useState>({}); const [memoryConfigBusy, setMemoryConfigBusy] = useState(false); const [secretVisible, setSecretVisible] = useState>({}); const [contextSel, setContextSel] = useState("compressor"); const [memoryBusy, setMemoryBusy] = useState(false); const [memorySetupBusy, setMemorySetupBusy] = useState(false); const [memorySetupResults, setMemorySetupResults] = useState(null); const [contextBusy, setContextBusy] = useState(false); const [rowBusy, setRowBusy] = useState(null); const { toast, showToast } = useToast(); const { t } = useI18n(); const { setAfterTitle } = usePageHeader(); const loadHub = useCallback((memorySelection?: string) => { return api .getPluginsHub() .then((h) => { setHub(h); const p = h.providers; setMemorySel( memorySelection ?? (p.memory_provider ? p.memory_provider : MEMORY_PROVIDER_BUILTIN), ); setContextSel(p.context_engine || "compressor"); }) .catch(() => showToast(t.common.loading, "error")); }, [showToast, t.common.loading]); useEffect(() => { void loadHub().finally(() => setLoading(false)); }, [loadHub]); useEffect(() => { const provider = memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel; let cancelled = false; void Promise.resolve().then(() => { if (cancelled) return; setSecretVisible({}); setMemorySetupResults(null); if (!provider) { setMemoryConfig(null); setMemoryValues({}); setMemoryConfigBusy(false); return; } setMemoryConfigBusy(true); api .getMemoryProviderConfig(provider) .then((config) => { if (cancelled) return; setMemoryConfig(config); setMemoryValues( Object.fromEntries( config.fields.map((field) => [field.key, fieldInitialValue(field)]), ), ); }) .catch((e) => { if (!cancelled) { setMemoryConfig(null); setMemoryValues({}); showToast(e instanceof Error ? e.message : "Failed to load provider config", "error"); } }) .finally(() => { if (!cancelled) setMemoryConfigBusy(false); }); }); return () => { cancelled = true; }; }, [memorySel, showToast]); const onInstall = async () => { const id = installId.trim(); if (!id) { showToast(t.pluginsPage.installHint, "error"); return; } setInstallBusy(true); try { const r = await api.installAgentPlugin({ identifier: id, force: installForce, enable: installEnable, }); showToast(`${r.plugin_name ?? id} installed`, "success"); if ((r.warnings?.length ?? 0) > 0) showToast(r.warnings!.join(" "), "error"); if ((r.missing_env?.length ?? 0) > 0) showToast(`${t.pluginsPage.missingEnvWarn} ${r.missing_env!.join(", ")}`, "error"); setInstallId(""); await loadHub(); } catch (e) { showToast(e instanceof Error ? e.message : "Install failed", "error"); } finally { setInstallBusy(false); } }; const onRescan = useCallback(async () => { setRescanBusy(true); try { const rc = await api.rescanPlugins(); showToast( `${t.pluginsPage.refreshDashboard} (${rc.count})`, "success", ); await loadHub(); } catch (e) { showToast(e instanceof Error ? e.message : "Rescan failed", "error"); } finally { setRescanBusy(false); } }, [loadHub, showToast, t.pluginsPage.refreshDashboard]); useEffect(() => { setAfterTitle( , ); return () => setAfterTitle(null); }, [loading, onRescan, rescanBusy, setAfterTitle, t.pluginsPage.refreshDashboard]); const onSaveMemoryProvider = async () => { const provider = memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel; setMemoryBusy(true); try { if (!provider) { await api.setMemoryProvider(""); } else { const visibleValues = Object.fromEntries( Object.entries(memoryValues).filter(([key]) => { const field = memoryConfig?.fields.find((candidate) => candidate.key === key); return field ? fieldIsVisible(field, memoryValues) : true; }), ); await api.updateMemoryProviderConfig(provider, visibleValues); } showToast(t.pluginsPage.savedProviders, "success"); await loadHub(); } catch (e) { showToast(e instanceof Error ? e.message : "Save failed", "error"); } finally { setMemoryBusy(false); } }; const currentVisibleMemoryValues = () => Object.fromEntries( Object.entries(memoryValues).filter(([key]) => { const field = memoryConfig?.fields.find((candidate) => candidate.key === key); return field ? fieldIsVisible(field, memoryValues) : true; }), ); const onSetupMemoryProvider = async () => { const provider = memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel; if (!provider) return; setMemorySetupBusy(true); setMemorySetupResults(null); try { const result = await api.setupMemoryProvider(provider, currentVisibleMemoryValues()); setMemorySetupResults(result.results); const failed = result.results.filter((row) => row.status === "failed"); if (failed.length) { const names = Array.from(new Set(failed.map((row) => row.name))).join(", "); showToast(`Provider setup failed: ${names || provider}. See setup results below.`, "error"); } else { showToast("Provider setup finished", "success"); } await loadHub(provider); } catch (e) { showToast(e instanceof Error ? e.message : "Provider setup failed", "error"); } finally { setMemorySetupBusy(false); } }; const onSaveContextEngine = async () => { setContextBusy(true); try { await api.savePluginProviders({ context_engine: contextSel }); showToast(t.pluginsPage.savedProviders, "success"); await loadHub(); } catch (e) { showToast(e instanceof Error ? e.message : "Save failed", "error"); } finally { setContextBusy(false); } }; const setRuntimeLoading = async (name: string, fn: () => Promise) => { setRowBusy(name); try { await fn(); await loadHub(); } catch (e) { showToast(e instanceof Error ? e.message : "Failed", "error"); } finally { setRowBusy(null); } }; const rows = hub?.plugins ?? []; const providers = hub?.providers; const selectedMemoryName = memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel; const selectedMemoryInfo = selectedMemoryName ? providers?.memory_options.find((provider) => provider.name === selectedMemoryName) : null; const activeMemoryInfo = providers?.memory_provider ? providers.memory_options.find((provider) => provider.name === providers.memory_provider) : null; const visibleMemoryFields = memoryConfig?.fields.filter((field) => fieldIsVisible(field, memoryValues)) ?? []; return (
{providers && ( {t.pluginsPage.providersHeading}

Configure memory providers and runtime context engine selection.

{selectedMemoryName && selectedMemoryInfo && ( {MEMORY_STATUS_LABEL[selectedMemoryInfo.status]} )} {selectedMemoryName && selectedMemoryName === providers.memory_provider && ( active )} {!selectedMemoryName && !providers.memory_provider && ( active )}
{!selectedMemoryName && (

Hermes will use the built-in MEMORY.md and USER.md files.

)} {activeMemoryInfo?.status === "missing" && (

Active provider {providers.memory_provider} is no longer installed. Select another provider and save.

)} {selectedMemoryName && selectedMemoryInfo?.description && (

{selectedMemoryInfo.description}

)} {selectedMemoryName && selectedMemoryInfo && ( void onSetupMemoryProvider()} provider={selectedMemoryInfo} results={memorySetupResults} /> )} {selectedMemoryName && selectedMemoryInfo?.status === "needs_config" && (

Provider dependencies are installed. Add the required credentials or self-hosted URL below, then save the provider.

)} {selectedMemoryName && memoryConfigBusy && (
Loading provider settings…
)} {selectedMemoryName && !memoryConfigBusy && visibleMemoryFields.length === 0 && (

This provider does not expose dashboard settings.

)} {selectedMemoryName && !memoryConfigBusy && visibleMemoryFields.length > 0 && (
{visibleMemoryFields.map((field) => { const value = memoryValues[field.key]; const secretIsVisible = !!secretVisible[field.key]; return (
{field.required && required} {field.kind === "secret" && field.is_set && !value && ( set )} {field.url && ( Open )}
{field.kind === "select" ? ( ) : field.kind === "boolean" ? ( setMemoryValues((current) => ({ ...current, [field.key]: next })) } /> ) : (
setMemoryValues((current) => ({ ...current, [field.key]: event.target.value, })) } /> {field.kind === "secret" && ( )}
)} {field.description && (

{field.description}

)}
); })}
)}
)} {t.pluginsPage.installHeading}

{t.pluginsPage.installHint}

setInstallId(e.target.value)} />
{t.pluginsPage.forceReinstall}
{t.pluginsPage.enableAfterInstall}

{t.pluginsPage.rescanHint}

{t.pluginsPage.removeHint}

{t.pluginsPage.pluginListHeading}

{loading ? (
{t.common.loading}
) : rows.length === 0 ? (

{t.common.noResults}

) : (
    {rows.map((row: HubAgentPluginRow) => (
  • ))}
)}
{(hub?.orphan_dashboard_plugins?.length ?? 0) > 0 ? (

{t.pluginsPage.orphanHeading}

    {hub!.orphan_dashboard_plugins.map((m) => (
  • {m.label ?? m.name} — {m.description || m.tab?.path} {!m.tab?.hidden ? ( {t.pluginsPage.openTab} ) : null}
  • ))}
) : null}
); } interface PluginRowCardProps { row: HubAgentPluginRow; rowBusy: string | null; setRuntimeLoading: ( name: string, fn: () => Promise, ) => Promise; showToast: (msg: string, variant: "success" | "error") => void; t: Translations; } function PluginRowCard(props: PluginRowCardProps) { const { row, rowBusy, setRuntimeLoading, showToast, t, } = props; const dm = row.dashboard_manifest; const tabPath = dm?.tab && !dm.tab.hidden ? dm.tab.override ?? dm.tab.path : null; const busy = rowBusy === row.name; const [confirmRemove, setConfirmRemove] = useState(false); const badgeTone = row.runtime_status === "enabled" ? "success" : row.runtime_status === "disabled" ? "destructive" : "outline"; return (
{row.name} {t.pluginsPage.sourceBadge}: {row.source} v{row.version || "—"} {row.runtime_status} {row.auth_required ? ( {t.pluginsPage.authRequired} ) : null}
{row.runtime_status === "enabled" ? ( ) : ( )} {tabPath ? ( {t.pluginsPage.openTab} ) : null} {row.can_update_git ? ( ) : null} {row.has_dashboard_manifest ? ( ) : null} {row.can_remove ? ( ) : null}
{row.description ? (

{row.description}

) : null} {dm?.slots?.length ? (

{t.pluginsPage.dashboardSlots}: {dm.slots.join(", ")}

) : null} {row.auth_required ? ( ) : null} {!row.has_dashboard_manifest && !dm ? (

{t.pluginsPage.noDashboardTab}

) : null}
setConfirmRemove(false)} onConfirm={() => { setConfirmRemove(false); void setRuntimeLoading(row.name, async () => { await api.removeAgentPlugin(row.name); showToast(`${row.name} removed`, "success"); }); }} title={t.pluginsPage.removeConfirm} description={`This will remove the "${row.name}" plugin from your agent.`} destructive confirmLabel={t.common.delete} />
); }