mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-14 14:12:44 +00:00
Add dashboard memory provider switching
This commit is contained in:
parent
4e4a69cbf7
commit
4b184cbe54
5 changed files with 1714 additions and 243 deletions
|
|
@ -1070,6 +1070,28 @@ export const api = {
|
|||
|
||||
// ── Admin: Memory provider ──────────────────────────────────────────
|
||||
getMemory: () => fetchJSON<MemoryStatus>("/api/memory"),
|
||||
getMemoryProviderConfig: (provider: string) =>
|
||||
fetchJSON<MemoryProviderConfig>(
|
||||
`/api/memory/providers/${encodeURIComponent(provider)}/config`,
|
||||
),
|
||||
updateMemoryProviderConfig: (provider: string, values: Record<string, unknown>) =>
|
||||
fetchJSON<{ ok: boolean; active: string }>(
|
||||
`/api/memory/providers/${encodeURIComponent(provider)}/config`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ values }),
|
||||
},
|
||||
),
|
||||
setupMemoryProvider: (provider: string, values: Record<string, unknown> = {}) =>
|
||||
fetchJSON<MemoryProviderSetupResponse>(
|
||||
`/api/memory/providers/${encodeURIComponent(provider)}/setup`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ values }),
|
||||
},
|
||||
),
|
||||
setMemoryProvider: (provider: string) =>
|
||||
fetchJSON<{ ok: boolean; active: string }>("/api/memory/provider", {
|
||||
method: "PUT",
|
||||
|
|
@ -1524,7 +1546,10 @@ export interface CredentialPoolProvider {
|
|||
export interface MemoryProviderInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
available: boolean;
|
||||
configured: boolean;
|
||||
status: "ready" | "needs_config" | "unavailable" | "missing";
|
||||
setup?: MemoryProviderSetupInfo;
|
||||
}
|
||||
|
||||
export interface MemoryStatus {
|
||||
|
|
@ -1533,6 +1558,63 @@ export interface MemoryStatus {
|
|||
builtin_files: { memory: number; user: number };
|
||||
}
|
||||
|
||||
export interface MemoryProviderExternalDependency {
|
||||
name: string;
|
||||
install: string;
|
||||
check: string;
|
||||
}
|
||||
|
||||
export interface MemoryProviderSetupInfo {
|
||||
pip_dependencies: string[];
|
||||
external_dependencies: MemoryProviderExternalDependency[];
|
||||
required_env: string[];
|
||||
dependencies_installed: boolean;
|
||||
}
|
||||
|
||||
export interface MemoryProviderSetupResult {
|
||||
kind: string;
|
||||
name: string;
|
||||
status: string;
|
||||
command: string;
|
||||
returncode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export interface MemoryProviderSetupResponse {
|
||||
ok: boolean;
|
||||
provider: string;
|
||||
results: MemoryProviderSetupResult[];
|
||||
status?: MemoryProviderInfo | null;
|
||||
}
|
||||
|
||||
export interface MemoryProviderFieldOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface MemoryProviderField {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: "text" | "secret" | "select" | "boolean";
|
||||
description: string;
|
||||
placeholder: string;
|
||||
required: boolean;
|
||||
value: string | boolean;
|
||||
is_set: boolean;
|
||||
options: MemoryProviderFieldOption[];
|
||||
url: string;
|
||||
when?: Record<string, string | boolean | number> | null;
|
||||
}
|
||||
|
||||
export interface MemoryProviderConfig {
|
||||
name: string;
|
||||
label: string;
|
||||
fields: MemoryProviderField[];
|
||||
setup?: MemoryProviderSetupInfo;
|
||||
}
|
||||
|
||||
export interface HookEntry {
|
||||
event: string;
|
||||
matcher: string | null;
|
||||
|
|
@ -2317,7 +2399,7 @@ export interface HubAgentPluginRow {
|
|||
|
||||
export interface PluginsHubProviders {
|
||||
memory_provider: string;
|
||||
memory_options: Array<{ name: string; description: string }>;
|
||||
memory_options: MemoryProviderInfo[];
|
||||
context_engine: string;
|
||||
context_options: Array<{ name: string; description: string }>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,21 @@ import { ExternalLink, RefreshCw, Trash2, Eye, EyeOff } from "lucide-react";
|
|||
import type { Translations } from "@/i18n/types";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api } from "@/lib/api";
|
||||
import type { HubAgentPluginRow, PluginsHubResponse } 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 } from "@nous-research/ui/ui/components/command-block";
|
||||
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";
|
||||
|
|
@ -24,6 +32,252 @@ 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;
|
||||
|
||||
const MEMORY_STATUS_LABEL: Record<MemoryProviderInfo["status"], string> = {
|
||||
ready: "ready",
|
||||
needs_config: "needs setup",
|
||||
unavailable: "unavailable",
|
||||
missing: "missing",
|
||||
};
|
||||
|
||||
const MEMORY_STATUS_TONE: Record<MemoryProviderInfo["status"], "success" | "warning" | "destructive" | "secondary"> = {
|
||||
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<string, MemoryFormValue>) {
|
||||
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 (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[0.6875rem] text-muted-foreground">{label}</span>
|
||||
<CopyButton text={code} />
|
||||
</div>
|
||||
<div className="border border-border bg-background/40 px-3 py-2 font-mono text-[0.6875rem] leading-relaxed">
|
||||
<code className="break-all">{code}</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="grid gap-2 border border-border bg-background/20 p-3">
|
||||
<p className="text-muted-foreground">Setup results</p>
|
||||
{results.map((result, index) => {
|
||||
const detail = result.stderr || result.stdout;
|
||||
return (
|
||||
<div key={`${result.kind}-${result.name}-${index}`} className="grid gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"border px-2 py-0.5 font-mono text-[0.6875rem]",
|
||||
setupResultClass(result.status),
|
||||
)}
|
||||
>
|
||||
{setupResultLabel(result.status)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{result.name}
|
||||
{result.kind ? ` (${result.kind.replace(/_/g, " ")})` : ""}
|
||||
</span>
|
||||
</div>
|
||||
{result.command ? (
|
||||
<code className="block break-all border border-border bg-background/40 px-2 py-1 font-mono text-[0.6875rem]">
|
||||
{result.command}
|
||||
</code>
|
||||
) : null}
|
||||
{detail ? (
|
||||
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-words border border-border bg-background/40 px-2 py-1 font-mono text-[0.6875rem] text-muted-foreground">
|
||||
{detail}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<p className="border border-destructive/50 px-3 py-2 text-xs text-destructive">
|
||||
This provider is installed but unavailable. It may need local dependencies or a manual setup step before Hermes can activate it.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-3 border px-3 py-3 text-xs text-foreground",
|
||||
isBlocked ? "border-destructive/50" : "border-border",
|
||||
)}
|
||||
>
|
||||
<p className={isBlocked ? "text-destructive" : "text-muted-foreground"}>
|
||||
{needsDependencySetup
|
||||
? "Finish these setup steps before Hermes can activate this provider."
|
||||
: "Provider dependency setup completed."}
|
||||
</p>
|
||||
|
||||
{needsDependencySetup ? (
|
||||
<Button
|
||||
className="w-fit uppercase"
|
||||
disabled={installing}
|
||||
onClick={onInstall}
|
||||
size="sm"
|
||||
>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{installing ? <Spinner /> : null}
|
||||
{installing ? "Installing provider dependencies" : "Install provider dependencies"}
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{installing ? (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Spinner /> Running provider setup. This may take a minute…
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{results ? <MemoryProviderSetupResults results={results} /> : null}
|
||||
|
||||
{needsDependencySetup ? (
|
||||
<>
|
||||
{setup.external_dependencies.map((dep, index) => (
|
||||
<div key={`${dep.name || "dependency"}-${index}`} className="grid gap-2">
|
||||
<p className="text-muted-foreground">
|
||||
External dependency{dep.name ? `: ${dep.name}` : ""}
|
||||
</p>
|
||||
{dep.install ? (
|
||||
<SetupCommandBlock
|
||||
label={dep.name ? `Install ${dep.name}` : "Install dependency"}
|
||||
code={dep.install}
|
||||
/>
|
||||
) : null}
|
||||
{dep.check ? (
|
||||
<SetupCommandBlock
|
||||
label={dep.name ? `Verify ${dep.name}` : "Verify dependency"}
|
||||
code={dep.check}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{setup.pip_dependencies.length ? (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-muted-foreground">Python dependencies</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{setup.pip_dependencies.map((dep) => (
|
||||
<code
|
||||
key={dep}
|
||||
className="border border-border bg-background/40 px-2 py-1 font-mono text-[0.6875rem]"
|
||||
>
|
||||
{dep}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{setup.required_env.length && needsDependencySetup ? (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-muted-foreground">
|
||||
Required environment values. Fill the matching fields below, or set them in the Hermes environment.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{setup.required_env.map((envKey) => (
|
||||
<code
|
||||
key={envKey}
|
||||
className="border border-border bg-background/40 px-2 py-1 font-mono text-[0.6875rem]"
|
||||
>
|
||||
{envKey}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PluginsPage() {
|
||||
const [hub, setHub] = useState<PluginsHubResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -33,46 +287,83 @@ export default function PluginsPage() {
|
|||
const [installBusy, setInstallBusy] = useState(false);
|
||||
const [rescanBusy, setRescanBusy] = useState(false);
|
||||
const [memorySel, setMemorySel] = useState(MEMORY_PROVIDER_BUILTIN);
|
||||
const [memoryConfig, setMemoryConfig] = useState<MemoryProviderConfig | null>(null);
|
||||
const [memoryValues, setMemoryValues] = useState<Record<string, MemoryFormValue>>({});
|
||||
const [memoryConfigBusy, setMemoryConfigBusy] = useState(false);
|
||||
const [secretVisible, setSecretVisible] = useState<Record<string, boolean>>({});
|
||||
const [contextSel, setContextSel] = useState("compressor");
|
||||
const [providerBusy, setProviderBusy] = useState(false);
|
||||
const [memoryBusy, setMemoryBusy] = useState(false);
|
||||
const [memorySetupBusy, setMemorySetupBusy] = useState(false);
|
||||
const [memorySetupResults, setMemorySetupResults] = useState<MemoryProviderSetupResult[] | null>(null);
|
||||
const [contextBusy, setContextBusy] = useState(false);
|
||||
const [rowBusy, setRowBusy] = useState<string | null>(null);
|
||||
|
||||
const { toast, showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { setAfterTitle } = usePageHeader();
|
||||
|
||||
const loadHub = useCallback(() => {
|
||||
const loadHub = useCallback((memorySelection?: string) => {
|
||||
return api
|
||||
.getPluginsHub()
|
||||
.then((h) => {
|
||||
setHub(h);
|
||||
const p = h.providers;
|
||||
setMemorySel(p.memory_provider ? p.memory_provider : MEMORY_PROVIDER_BUILTIN);
|
||||
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(() => {
|
||||
setLoading(true);
|
||||
void loadHub().finally(() => setLoading(false));
|
||||
}, [loadHub]);
|
||||
|
||||
useEffect(() => {
|
||||
setAfterTitle(
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
disabled={loading || rescanBusy}
|
||||
onClick={() => void onRescan()}
|
||||
aria-label={t.pluginsPage.refreshDashboard}
|
||||
>
|
||||
{rescanBusy ? <Spinner /> : <RefreshCw />}
|
||||
</Button>,
|
||||
);
|
||||
return () => setAfterTitle(null);
|
||||
}, [loading, rescanBusy, setAfterTitle, t.pluginsPage.refreshDashboard]);
|
||||
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();
|
||||
|
|
@ -100,7 +391,7 @@ export default function PluginsPage() {
|
|||
}
|
||||
};
|
||||
|
||||
const onRescan = async () => {
|
||||
const onRescan = useCallback(async () => {
|
||||
setRescanBusy(true);
|
||||
try {
|
||||
const rc = await api.rescanPlugins();
|
||||
|
|
@ -114,22 +405,90 @@ export default function PluginsPage() {
|
|||
} finally {
|
||||
setRescanBusy(false);
|
||||
}
|
||||
};
|
||||
}, [loadHub, showToast, t.pluginsPage.refreshDashboard]);
|
||||
|
||||
const onSaveProviders = async () => {
|
||||
setProviderBusy(true);
|
||||
useEffect(() => {
|
||||
setAfterTitle(
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
disabled={loading || rescanBusy}
|
||||
onClick={() => void onRescan()}
|
||||
aria-label={t.pluginsPage.refreshDashboard}
|
||||
>
|
||||
{rescanBusy ? <Spinner /> : <RefreshCw />}
|
||||
</Button>,
|
||||
);
|
||||
return () => setAfterTitle(null);
|
||||
}, [loading, onRescan, rescanBusy, setAfterTitle, t.pluginsPage.refreshDashboard]);
|
||||
|
||||
const onSaveMemoryProvider = async () => {
|
||||
const provider = memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel;
|
||||
setMemoryBusy(true);
|
||||
try {
|
||||
await api.savePluginProviders({
|
||||
memory_provider:
|
||||
memorySel === MEMORY_PROVIDER_BUILTIN ? "" : memorySel,
|
||||
context_engine: contextSel,
|
||||
});
|
||||
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 {
|
||||
setProviderBusy(false);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -147,6 +506,15 @@ export default function PluginsPage() {
|
|||
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
|
@ -159,65 +527,230 @@ export default function PluginsPage() {
|
|||
<CardHeader>
|
||||
<CardTitle>{t.pluginsPage.providersHeading}</CardTitle>
|
||||
<p className="text-xs tracking-[0.08em] text-text-tertiary">
|
||||
{t.pluginsPage.providersHint}
|
||||
Configure memory providers and runtime context engine selection.
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-6">
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.35fr)_minmax(260px,0.65fr)]">
|
||||
<div className="flex flex-col gap-4 min-w-0">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Label htmlFor="mem-provider">{t.pluginsPage.memoryProviderLabel}</Label>
|
||||
{selectedMemoryName && selectedMemoryInfo && (
|
||||
<Badge tone={MEMORY_STATUS_TONE[selectedMemoryInfo.status]}>
|
||||
{MEMORY_STATUS_LABEL[selectedMemoryInfo.status]}
|
||||
</Badge>
|
||||
)}
|
||||
{selectedMemoryName && selectedMemoryName === providers.memory_provider && (
|
||||
<Badge tone="outline">active</Badge>
|
||||
)}
|
||||
{!selectedMemoryName && !providers.memory_provider && (
|
||||
<Badge tone="success">active</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2 max-w-full">
|
||||
<div className="grid gap-2 min-w-0">
|
||||
<Label htmlFor="mem-provider">{t.pluginsPage.memoryProviderLabel}</Label>
|
||||
|
||||
<Select
|
||||
id="mem-provider"
|
||||
className="w-full"
|
||||
value={memorySel}
|
||||
onValueChange={setMemorySel}
|
||||
>
|
||||
<SelectOption value={MEMORY_PROVIDER_BUILTIN}>
|
||||
{`(${t.pluginsPage.providerDefaults})`}
|
||||
</SelectOption>
|
||||
|
||||
{providers.memory_options.map((o) => (
|
||||
<SelectOption key={o.name} value={o.name}>
|
||||
{o.name}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 min-w-0">
|
||||
<Label htmlFor="ctx-engine">{t.pluginsPage.contextEngineLabel}</Label>
|
||||
|
||||
<Select
|
||||
id="ctx-engine"
|
||||
className="w-full"
|
||||
value={contextSel}
|
||||
onValueChange={setContextSel}
|
||||
>
|
||||
<SelectOption value="compressor">compressor</SelectOption>
|
||||
|
||||
{providers.context_options
|
||||
.filter((o) => o.name !== "compressor")
|
||||
.map((o) => (
|
||||
<SelectOption key={o.name} value={o.name}>
|
||||
{o.name}
|
||||
<Select
|
||||
id="mem-provider"
|
||||
className="w-full"
|
||||
value={memorySel}
|
||||
onValueChange={setMemorySel}
|
||||
>
|
||||
<SelectOption value={MEMORY_PROVIDER_BUILTIN}>
|
||||
{`(${t.pluginsPage.providerDefaults})`}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-fit uppercase"
|
||||
size="sm"
|
||||
disabled={providerBusy}
|
||||
onClick={() => void onSaveProviders()}
|
||||
prefix={providerBusy ? <Spinner /> : undefined}
|
||||
>
|
||||
{t.common.save}
|
||||
</Button>
|
||||
{providers.memory_options.map((o) => (
|
||||
<SelectOption key={o.name} value={o.name}>
|
||||
{o.name}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{!selectedMemoryName && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Hermes will use the built-in MEMORY.md and USER.md files.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{activeMemoryInfo?.status === "missing" && (
|
||||
<p className="border border-destructive/50 px-3 py-2 text-xs text-destructive">
|
||||
Active provider {providers.memory_provider} is no longer installed. Select another provider and save.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedMemoryName && selectedMemoryInfo?.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedMemoryInfo.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedMemoryName && selectedMemoryInfo && (
|
||||
<MemoryProviderSetupHint
|
||||
installing={memorySetupBusy}
|
||||
onInstall={() => void onSetupMemoryProvider()}
|
||||
provider={selectedMemoryInfo}
|
||||
results={memorySetupResults}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedMemoryName && selectedMemoryInfo?.status === "needs_config" && (
|
||||
<p className="border border-warning/50 px-3 py-2 text-xs text-warning">
|
||||
Provider dependencies are installed. Add the required credentials or self-hosted URL below, then save the provider.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedMemoryName && memoryConfigBusy && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner /> Loading provider settings…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedMemoryName && !memoryConfigBusy && visibleMemoryFields.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This provider does not expose dashboard settings.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{selectedMemoryName && !memoryConfigBusy && visibleMemoryFields.length > 0 && (
|
||||
<div className="grid gap-4 border border-border p-4">
|
||||
{visibleMemoryFields.map((field) => {
|
||||
const value = memoryValues[field.key];
|
||||
const secretIsVisible = !!secretVisible[field.key];
|
||||
return (
|
||||
<div key={field.key} className="grid gap-2 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Label htmlFor={`memory-${field.key}`}>{field.label}</Label>
|
||||
{field.required && <Badge tone="outline">required</Badge>}
|
||||
{field.kind === "secret" && field.is_set && !value && (
|
||||
<Badge tone="success">set</Badge>
|
||||
)}
|
||||
{field.url && (
|
||||
<a
|
||||
href={field.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs underline"
|
||||
>
|
||||
Open <ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{field.kind === "select" ? (
|
||||
<Select
|
||||
id={`memory-${field.key}`}
|
||||
className="w-full"
|
||||
value={String(value ?? "")}
|
||||
onValueChange={(next) =>
|
||||
setMemoryValues((current) => ({ ...current, [field.key]: next }))
|
||||
}
|
||||
>
|
||||
{field.options.map((option) => (
|
||||
<SelectOption key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
) : field.kind === "boolean" ? (
|
||||
<Switch
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={(next) =>
|
||||
setMemoryValues((current) => ({ ...current, [field.key]: next }))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id={`memory-${field.key}`}
|
||||
type={field.kind === "secret" && !secretIsVisible ? "password" : "text"}
|
||||
value={String(value ?? "")}
|
||||
placeholder={
|
||||
field.kind === "secret" && field.is_set
|
||||
? "Leave blank to keep existing value"
|
||||
: field.placeholder
|
||||
}
|
||||
onChange={(event) =>
|
||||
setMemoryValues((current) => ({
|
||||
...current,
|
||||
[field.key]: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
{field.kind === "secret" && (
|
||||
<Button
|
||||
ghost
|
||||
size="icon"
|
||||
aria-label={secretIsVisible ? "Hide secret" : "Show secret"}
|
||||
onClick={() =>
|
||||
setSecretVisible((current) => ({
|
||||
...current,
|
||||
[field.key]: !current[field.key],
|
||||
}))
|
||||
}
|
||||
>
|
||||
{secretIsVisible ? (
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-fit uppercase"
|
||||
size="sm"
|
||||
disabled={memoryBusy || memoryConfigBusy || memorySetupBusy}
|
||||
onClick={() => void onSaveMemoryProvider()}
|
||||
prefix={memoryBusy ? <Spinner /> : undefined}
|
||||
>
|
||||
Save memory provider
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid content-start gap-3 min-w-0">
|
||||
<Label htmlFor="ctx-engine">{t.pluginsPage.contextEngineLabel}</Label>
|
||||
|
||||
<Select
|
||||
id="ctx-engine"
|
||||
className="w-full"
|
||||
value={contextSel}
|
||||
onValueChange={setContextSel}
|
||||
>
|
||||
<SelectOption value="compressor">compressor</SelectOption>
|
||||
|
||||
{providers.context_options
|
||||
.filter((o) => o.name !== "compressor")
|
||||
.map((o) => (
|
||||
<SelectOption key={o.name} value={o.name}>
|
||||
{o.name}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
className="w-fit uppercase"
|
||||
size="sm"
|
||||
disabled={contextBusy}
|
||||
onClick={() => void onSaveContextEngine()}
|
||||
prefix={contextBusy ? <Spinner /> : undefined}
|
||||
>
|
||||
Save context engine
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import { api } from "@/lib/api";
|
|||
import type {
|
||||
StatusResponse,
|
||||
MemoryStatus,
|
||||
MemoryProviderInfo,
|
||||
CredentialPoolProvider,
|
||||
CheckpointsResponse,
|
||||
HooksResponse,
|
||||
|
|
@ -171,6 +172,23 @@ const HOOK_EVENTS_FALLBACK = [
|
|||
"on_session_end",
|
||||
];
|
||||
|
||||
const MEMORY_STATUS_LABEL: Record<MemoryProviderInfo["status"], string> = {
|
||||
ready: "ready",
|
||||
needs_config: "needs setup",
|
||||
unavailable: "unavailable",
|
||||
missing: "missing",
|
||||
};
|
||||
|
||||
const MEMORY_STATUS_TONE: Record<
|
||||
MemoryProviderInfo["status"],
|
||||
"success" | "warning" | "destructive" | "secondary"
|
||||
> = {
|
||||
ready: "success",
|
||||
needs_config: "warning",
|
||||
unavailable: "destructive",
|
||||
missing: "destructive",
|
||||
};
|
||||
|
||||
export default function SystemPage() {
|
||||
const { toast, showToast } = useToast();
|
||||
|
||||
|
|
@ -620,6 +638,9 @@ export default function SystemPage() {
|
|||
|
||||
const gatewayRunning = status?.gateway_running;
|
||||
const canUpdateHermes = status?.can_update_hermes !== false;
|
||||
const activeMemoryProvider = memory?.active
|
||||
? memory.providers.find((provider) => provider.name === memory.active)
|
||||
: null;
|
||||
const validEvents = hooks?.valid_events?.length
|
||||
? hooks.valid_events
|
||||
: HOOK_EVENTS_FALLBACK;
|
||||
|
|
@ -1077,15 +1098,28 @@ export default function SystemPage() {
|
|||
{memory?.active || "built-in only"}
|
||||
</span>
|
||||
</span>
|
||||
{activeMemoryProvider && (
|
||||
<Badge tone={MEMORY_STATUS_TONE[activeMemoryProvider.status]}>
|
||||
{MEMORY_STATUS_LABEL[activeMemoryProvider.status]}
|
||||
</Badge>
|
||||
)}
|
||||
<Link to="/plugins" className="underline">
|
||||
Change in Plugins →
|
||||
</Link>
|
||||
<span className="ml-auto">
|
||||
New credentials:{" "}
|
||||
<span className="font-mono">hermes memory setup</span>
|
||||
Provider setup:{" "}
|
||||
<Link to="/plugins" className="underline">
|
||||
configure in Plugins
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{activeMemoryProvider?.status === "missing" && (
|
||||
<p className="border border-destructive/50 px-3 py-2 text-xs text-destructive">
|
||||
The configured provider is no longer installed. Switch to built-in memory or configure another provider in Plugins.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Built-in files — MEMORY.md:{" "}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue