Add dashboard memory provider switching

This commit is contained in:
Shannon Sands 2026-07-03 10:32:07 +10:00 committed by Teknium
parent 4e4a69cbf7
commit 4b184cbe54
5 changed files with 1714 additions and 243 deletions

File diff suppressed because it is too large Load diff

View file

@ -391,12 +391,149 @@ class TestWebServerEndpoints:
fields = self._provider_field_map(data)
assert fields["mode"]["kind"] == "select"
assert fields["mode"]["value"] == "cloud"
assert {opt["value"] for opt in fields["mode"]["options"]} == {"cloud", "local_external"}
assert fields["api_url"]["value"] == "https://api.hindsight.vectorize.io"
assert {opt["value"] for opt in fields["mode"]["options"]} >= {
"cloud",
"local_external",
}
assert fields["api_url"]["kind"] == "text"
assert fields["api_url"]["value"]
assert fields["bank_id"]["value"] == "hermes"
assert fields["recall_budget"]["value"] == "mid"
assert fields["api_key"]["kind"] == "secret"
assert fields["api_key"]["is_set"] is False
assert fields["api_key"]["required"] is False
def test_get_memory_provider_config_loads_dynamic_plugin_schema(self):
resp = self.client.get("/api/memory/providers/honcho/config")
assert resp.status_code == 200
data = resp.json()
fields = self._provider_field_map(data)
assert fields["api_key"]["kind"] == "secret"
assert fields["api_key"]["url"] == "https://app.honcho.dev"
assert fields["baseUrl"]["kind"] == "text"
def test_all_listed_memory_provider_configs_fetch(self):
resp = self.client.get("/api/memory")
assert resp.status_code == 200
providers = resp.json()["providers"]
assert providers
failures = []
for provider in providers:
config_resp = self.client.get(
f"/api/memory/providers/{provider['name']}/config"
)
if config_resp.status_code != 200:
failures.append((provider["name"], config_resp.status_code, config_resp.text))
assert failures == []
def test_memory_provider_payloads_include_manifest_setup_hints(self):
resp = self.client.get("/api/memory")
assert resp.status_code == 200
providers = {row["name"]: row for row in resp.json()["providers"]}
byterover_setup = providers["byterover"]["setup"]
assert byterover_setup["external_dependencies"] == [
{
"name": "brv",
"install": "curl -fsSL https://byterover.dev/install.sh | sh",
"check": "brv --version",
}
]
retaindb_setup = providers["retaindb"]["setup"]
assert "requests" in retaindb_setup["pip_dependencies"]
assert "RETAINDB_API_KEY" in retaindb_setup["required_env"]
assert isinstance(byterover_setup["dependencies_installed"], bool)
config_resp = self.client.get("/api/memory/providers/byterover/config")
assert config_resp.status_code == 200
assert config_resp.json()["setup"]["external_dependencies"] == byterover_setup["external_dependencies"]
def test_memory_status_reports_honcho_needs_config_after_dependency_setup(self, monkeypatch):
import hermes_cli.web_server as web_server
original_dependency_importable = web_server._dependency_importable
monkeypatch.setattr(
web_server,
"_dependency_importable",
lambda dep: True if dep == "honcho-ai" else original_dependency_importable(dep),
)
resp = self.client.get("/api/memory")
assert resp.status_code == 200
providers = {row["name"]: row for row in resp.json()["providers"]}
assert providers["honcho"]["setup"]["dependencies_installed"] is True
assert providers["honcho"]["status"] == "needs_config"
def test_post_memory_provider_setup_runs_declared_external_install(self, monkeypatch):
import subprocess
import hermes_cli.web_server as web_server
calls = []
check_count = 0
def fake_run(command, **kwargs):
nonlocal check_count
calls.append((command, kwargs))
if command == ["brv", "--version"]:
check_count += 1
if check_count == 1:
raise FileNotFoundError("brv")
return subprocess.CompletedProcess(
command,
0,
stdout="brv 1.0.0",
stderr="",
)
if command == "curl -fsSL https://byterover.dev/install.sh | sh":
assert kwargs["shell"] is True
return subprocess.CompletedProcess(command, 0, stdout="installed", stderr="")
raise AssertionError(f"Unexpected command: {command}")
monkeypatch.setattr(web_server.subprocess, "run", fake_run)
resp = self.client.post("/api/memory/providers/byterover/setup", json={"values": {}})
assert resp.status_code == 200
data = resp.json()
assert data["provider"] == "byterover"
assert data["ok"] is True
assert [result["status"] for result in data["results"]] == [
"missing",
"installed",
"verified",
]
assert [call[0] for call in calls[:3]] == [
["brv", "--version"],
"curl -fsSL https://byterover.dev/install.sh | sh",
["brv", "--version"],
]
assert calls[-1][0] == ["brv", "--version"]
def test_post_unknown_memory_provider_setup_returns_404(self):
resp = self.client.post("/api/memory/providers/nope/setup", json={"values": {}})
assert resp.status_code == 404
def test_post_memory_provider_setup_persists_values_without_activation(self):
from hermes_cli.config import load_config, load_env
resp = self.client.post(
"/api/memory/providers/retaindb/setup",
json={"values": {"api_key": "retain-test-key", "project": "default"}},
)
assert resp.status_code == 200
assert resp.json()["provider"] == "retaindb"
assert load_env()["RETAINDB_API_KEY"] == "retain-test-key"
assert load_config().get("memory", {}).get("provider") != "retaindb"
def test_put_memory_provider_config_writes_config_and_secret(self):
from hermes_constants import get_hermes_home
@ -416,25 +553,24 @@ class TestWebServerEndpoints:
)
assert resp.status_code == 200
assert resp.json() == {"ok": True}
assert resp.json() == {"ok": True, "active": "hindsight"}
assert load_config()["memory"]["provider"] == "hindsight"
assert load_env()["HINDSIGHT_API_KEY"] == "hs-test-key"
config_path = get_hermes_home() / "hindsight" / "config.json"
provider_config = json.loads(config_path.read_text(encoding="utf-8"))
assert provider_config == {
"mode": "local_external",
"api_url": "http://localhost:8888",
"bank_id": "ben-bank",
"recall_budget": "high",
}
assert provider_config["mode"] == "local_external"
assert provider_config["api_url"] == "http://localhost:8888"
assert provider_config["bank_id"] == "ben-bank"
assert provider_config["recall_budget"] == "high"
assert "api_key" not in provider_config
def test_put_memory_provider_config_rejects_unsupported_select_value(self):
resp = self.client.put(
"/api/memory/providers/hindsight/config",
json={
"values": {
"mode": "local_embedded",
"mode": "spaceship",
"api_url": "http://localhost:8888",
"bank_id": "hermes",
"recall_budget": "mid",
@ -480,6 +616,77 @@ class TestWebServerEndpoints:
assert fields["api_key"]["value"] == ""
assert "secret-value" not in json.dumps(data)
def test_get_memory_status_reports_ready_and_missing_provider(self):
from hermes_cli.config import load_config, save_config
self.client.put(
"/api/memory/providers/hindsight/config",
json={
"values": {
"mode": "cloud",
"api_url": "https://api.hindsight.vectorize.io",
"api_key": "secret-value",
"bank_id": "hermes",
"recall_budget": "mid",
}
},
)
resp = self.client.get("/api/memory")
assert resp.status_code == 200
providers = {row["name"]: row for row in resp.json()["providers"]}
assert providers["hindsight"]["configured"] is True
assert providers["hindsight"]["status"] == "ready"
assert "available" in providers["hindsight"]
config = load_config()
config.setdefault("memory", {})["provider"] = "not-installed"
save_config(config)
resp = self.client.get("/api/memory")
assert resp.status_code == 200
providers = {row["name"]: row for row in resp.json()["providers"]}
assert providers["not-installed"]["status"] == "missing"
assert providers["not-installed"]["available"] is False
config = load_config()
config.setdefault("memory", {})["provider"] = "builtin"
save_config(config)
resp = self.client.get("/api/memory")
assert resp.status_code == 200
assert resp.json()["active"] == ""
assert "builtin" not in {row["name"] for row in resp.json()["providers"]}
def test_set_memory_provider_rejects_unready_and_clears_builtin(self):
from hermes_cli.config import load_config
resp = self.client.put("/api/memory/provider", json={"provider": "supermemory"})
assert resp.status_code == 400
resp = self.client.put("/api/memory/provider", json={"provider": "built-in"})
assert resp.status_code == 200
assert resp.json() == {"ok": True, "active": ""}
assert load_config()["memory"]["provider"] == ""
def test_dashboard_plugin_providers_rejects_unready_memory_provider(self):
resp = self.client.put(
"/api/dashboard/plugin-providers",
json={"memory_provider": "supermemory"},
)
assert resp.status_code == 400
def test_dashboard_plugin_providers_accepts_builtin_alias(self):
from hermes_cli.config import load_config
resp = self.client.put(
"/api/dashboard/plugin-providers",
json={"memory_provider": "built-in"},
)
assert resp.status_code == 200
assert load_config()["memory"]["provider"] == ""
def test_get_moa_models_returns_provider_model_slots(self):
resp = self.client.get("/api/model/moa")
assert resp.status_code == 200

View file

@ -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 }>;
}

View file

@ -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>
)}

View file

@ -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:{" "}