mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
Replicate the `hermes tools` configurator in the dashboard Skills →
Toolsets view. Each toolset now opens a config drawer that covers the
full lifecycle the CLI offers: enable/disable, pick a provider/backend,
enter and save API keys, and run a provider's post-setup install hook
with a live log tail.
The toolset view was previously read+toggle only — the provider matrix
and key-status endpoints existed but the page never called them, and
there was no way to save a key or run a backend install (npm/pip/binary)
from the browser.
Backend:
- New CLI subcommand `hermes tools post-setup <KEY>` — non-interactive,
scriptable target that runs a provider's install hook (agent_browser,
camofox, cua_driver, kittentts, piper, ddgs, spotify, langfuse,
xai_grok). Validated against valid_post_setup_keys() so an arbitrary
key can't drive _run_post_setup.
- PUT /api/tools/toolsets/{name}/env — save API keys to ~/.hermes/.env
via save_env_value (same store the CLI writes), validated against the
toolset category's env-var allowlist; blank values skipped.
- POST /api/tools/toolsets/{name}/post-setup — spawn-action that runs
`hermes tools post-setup <key>`; frontend tails the log via the
existing /api/actions/tools-post-setup/status. Registered in
_ACTION_LOG_FILES.
Frontend:
- New ToolsetConfigDrawer component (provider radios, password key
inputs with saved-state, get-a-key links, Run-setup + live install
log). Toolset cards get a Configure button + the drawer also exposes
the enable toggle.
- api.ts: toggleToolset, getToolsetConfig, selectToolsetProvider,
saveToolsetEnv, runToolsetPostSetup + ToolsetConfig/Provider/EnvVar/
EnvResult types.
Validation: 56 admin-endpoint tests pass (10 new: env save w/ CLI
parity + allowlist reject + blank-skip, post-setup spawn validation,
auth gate); 232 web_server tests pass; web npm run build + eslint clean;
HTTP E2E exercises save-key (CLI reads it back) and spawn+poll
post-setup to exit 0.
448 lines
17 KiB
TypeScript
448 lines
17 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { Check, ExternalLink, Loader2, Terminal, X } from "lucide-react";
|
|
import { api } from "@/lib/api";
|
|
import type {
|
|
ToolsetConfig,
|
|
ToolsetInfo,
|
|
ToolsetProvider,
|
|
} from "@/lib/api";
|
|
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
|
import { Button } from "@nous-research/ui/ui/components/button";
|
|
import { Input } from "@nous-research/ui/ui/components/input";
|
|
import { Label } from "@nous-research/ui/ui/components/label";
|
|
import { Badge } from "@nous-research/ui/ui/components/badge";
|
|
import { Switch } from "@nous-research/ui/ui/components/switch";
|
|
import { Spinner } from "@nous-research/ui/ui/components/spinner";
|
|
import { Toast } from "@nous-research/ui/ui/components/toast";
|
|
import { cn, themedBody } from "@/lib/utils";
|
|
|
|
interface Props {
|
|
/** The toolset whose backends are being configured. */
|
|
toolset: ToolsetInfo;
|
|
onClose: () => void;
|
|
/** Called after a toggle/provider/key change so the parent grid refreshes. */
|
|
onChanged: () => void;
|
|
}
|
|
|
|
/**
|
|
* Full configuration surface for a single toolset's backends — the dashboard
|
|
* equivalent of selecting a toolset in the `hermes tools` curses UI: toggle
|
|
* the toolset on/off, pick a provider, enter API keys, and run a provider's
|
|
* post-setup install hook (npm/pip/binary) with a live log tail.
|
|
*/
|
|
export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) {
|
|
const { toast, showToast } = useToast();
|
|
const [config, setConfig] = useState<ToolsetConfig | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [enabled, setEnabled] = useState(toolset.enabled);
|
|
const [toggling, setToggling] = useState(false);
|
|
const [selecting, setSelecting] = useState<string | null>(null);
|
|
const [activeProvider, setActiveProvider] = useState<string | null>(null);
|
|
// Per-env-var draft input values, keyed by env var name.
|
|
const [drafts, setDrafts] = useState<Record<string, string>>({});
|
|
const [savingProvider, setSavingProvider] = useState<string | null>(null);
|
|
const [isSet, setIsSet] = useState<Record<string, boolean>>({});
|
|
|
|
// Post-setup install log tail state.
|
|
const [postSetupRunning, setPostSetupRunning] = useState(false);
|
|
const [postSetupLog, setPostSetupLog] = useState<string[]>([]);
|
|
const [postSetupKey, setPostSetupKey] = useState<string | null>(null);
|
|
// Bumped each time a post-setup is kicked off, to (re)trigger the poll
|
|
// effect below. Mirrors the SkillsPage HubBrowser action-poll pattern so
|
|
// the recursive timer lives inside the effect (lint-clean — no ref
|
|
// mutation, no self-referencing memo).
|
|
const [postSetupTrigger, setPostSetupTrigger] = useState(0);
|
|
|
|
const loadConfig = useCallback(() => {
|
|
// Promise-chain shape (not async/await with a leading synchronous
|
|
// setLoading) so callers in a useEffect don't trip
|
|
// react-hooks/set-state-in-effect — setState only fires inside the
|
|
// async .then/.catch/.finally callbacks.
|
|
return api
|
|
.getToolsetConfig(toolset.name)
|
|
.then((cfg) => {
|
|
setConfig(cfg);
|
|
setActiveProvider(cfg.active_provider);
|
|
const seed: Record<string, boolean> = {};
|
|
for (const p of cfg.providers) {
|
|
for (const e of p.env_vars) seed[e.key] = e.is_set;
|
|
}
|
|
setIsSet(seed);
|
|
})
|
|
.catch(() => showToast("Failed to load toolset config", "error"))
|
|
.finally(() => setLoading(false));
|
|
}, [toolset.name, showToast]);
|
|
|
|
useEffect(() => {
|
|
void loadConfig();
|
|
}, [loadConfig]);
|
|
|
|
// Poll the post-setup action's log until it exits. Driven by
|
|
// postSetupTrigger; the recursive timer + cleanup live entirely inside the
|
|
// effect (matches the SkillsPage HubBrowser pattern — lint-clean).
|
|
useEffect(() => {
|
|
if (postSetupTrigger === 0) return;
|
|
let cancelled = false;
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
const poll = async () => {
|
|
try {
|
|
const st = await api.getActionStatus("tools-post-setup", 300);
|
|
if (cancelled) return;
|
|
setPostSetupLog(st.lines);
|
|
if (st.running) {
|
|
timer = setTimeout(() => void poll(), 1200);
|
|
} else {
|
|
setPostSetupRunning(false);
|
|
const ok = st.exit_code === 0;
|
|
showToast(
|
|
ok ? "Post-setup complete" : "Post-setup finished with errors",
|
|
ok ? "success" : "error",
|
|
);
|
|
// Refresh — a backend may now report itself configured/available.
|
|
void loadConfig();
|
|
onChanged();
|
|
}
|
|
} catch {
|
|
if (!cancelled) {
|
|
setPostSetupRunning(false);
|
|
showToast("Lost track of the post-setup process", "error");
|
|
}
|
|
}
|
|
};
|
|
// Small delay so the spawned action has a log file to read.
|
|
timer = setTimeout(() => void poll(), 800);
|
|
return () => {
|
|
cancelled = true;
|
|
if (timer) clearTimeout(timer);
|
|
};
|
|
}, [postSetupTrigger, showToast, loadConfig, onChanged]);
|
|
|
|
const handleToggle = async (next: boolean) => {
|
|
setToggling(true);
|
|
try {
|
|
await api.toggleToolset(toolset.name, next);
|
|
setEnabled(next);
|
|
showToast(
|
|
`${toolset.label || toolset.name} ${next ? "enabled" : "disabled"}`,
|
|
"success",
|
|
);
|
|
onChanged();
|
|
} catch {
|
|
showToast("Failed to toggle toolset", "error");
|
|
} finally {
|
|
setToggling(false);
|
|
}
|
|
};
|
|
|
|
const handleSelectProvider = async (provider: ToolsetProvider) => {
|
|
setSelecting(provider.name);
|
|
try {
|
|
await api.selectToolsetProvider(toolset.name, provider.name);
|
|
setActiveProvider(provider.name);
|
|
showToast(`Provider set to ${provider.name}`, "success");
|
|
onChanged();
|
|
} catch (e) {
|
|
showToast(
|
|
e instanceof Error ? e.message : "Failed to select provider",
|
|
"error",
|
|
);
|
|
} finally {
|
|
setSelecting(null);
|
|
}
|
|
};
|
|
|
|
const handleSaveKeys = async (provider: ToolsetProvider) => {
|
|
const env: Record<string, string> = {};
|
|
for (const e of provider.env_vars) {
|
|
const v = drafts[e.key];
|
|
if (v && v.trim()) env[e.key] = v.trim();
|
|
}
|
|
if (Object.keys(env).length === 0) {
|
|
showToast("Enter at least one value to save", "error");
|
|
return;
|
|
}
|
|
setSavingProvider(provider.name);
|
|
try {
|
|
const res = await api.saveToolsetEnv(toolset.name, env);
|
|
setIsSet((prev) => ({ ...prev, ...res.is_set }));
|
|
// Clear saved drafts so the inputs reset to the "saved" placeholder.
|
|
setDrafts((prev) => {
|
|
const next = { ...prev };
|
|
for (const k of res.saved) delete next[k];
|
|
return next;
|
|
});
|
|
showToast(
|
|
res.saved.length
|
|
? `Saved ${res.saved.length} key${res.saved.length > 1 ? "s" : ""}`
|
|
: "Nothing to save",
|
|
"success",
|
|
);
|
|
onChanged();
|
|
} catch (e) {
|
|
showToast(
|
|
e instanceof Error ? e.message : "Failed to save keys",
|
|
"error",
|
|
);
|
|
} finally {
|
|
setSavingProvider(null);
|
|
}
|
|
};
|
|
|
|
const handleRunPostSetup = async (provider: ToolsetProvider) => {
|
|
if (!provider.post_setup) return;
|
|
setPostSetupRunning(true);
|
|
setPostSetupLog([]);
|
|
setPostSetupKey(provider.post_setup);
|
|
try {
|
|
await api.runToolsetPostSetup(toolset.name, provider.post_setup);
|
|
// Bump the trigger so the poll effect (re)starts tailing the log.
|
|
setPostSetupTrigger((n) => n + 1);
|
|
} catch (e) {
|
|
setPostSetupRunning(false);
|
|
showToast(
|
|
e instanceof Error ? e.message : "Failed to start post-setup",
|
|
"error",
|
|
);
|
|
}
|
|
};
|
|
|
|
const labelText = toolset.label?.trim() || toolset.name;
|
|
|
|
return createPortal(
|
|
<div
|
|
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
|
onMouseDown={(e) => {
|
|
if (e.target === e.currentTarget) onClose();
|
|
}}
|
|
>
|
|
<div
|
|
className={cn(
|
|
themedBody,
|
|
"relative w-full max-w-2xl max-h-[85vh] border border-border bg-card shadow-2xl flex flex-col",
|
|
)}
|
|
>
|
|
<Button
|
|
ghost
|
|
size="xs"
|
|
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
>
|
|
<X />
|
|
</Button>
|
|
|
|
{/* Header — toolset identity + enable toggle */}
|
|
<header className="p-5 pb-3 border-b border-border">
|
|
<div className="flex items-center gap-3 pr-8">
|
|
<span className="font-mondwest text-display text-base tracking-wider">
|
|
{labelText}
|
|
</span>
|
|
<Badge tone={enabled ? "success" : "outline"} className="text-xs">
|
|
{enabled ? "Active" : "Inactive"}
|
|
</Badge>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{toolset.description}
|
|
</p>
|
|
<div className="mt-3 flex items-center gap-2">
|
|
<Switch
|
|
checked={enabled}
|
|
onCheckedChange={(v) => void handleToggle(v)}
|
|
disabled={toggling}
|
|
aria-label="Enable toolset"
|
|
/>
|
|
<span className="text-xs text-muted-foreground">
|
|
{enabled ? "Enabled for the agent" : "Disabled"}
|
|
</span>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Body — provider matrix */}
|
|
<div className="flex-1 min-h-0 overflow-y-auto p-5 pt-4 space-y-4">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-10">
|
|
<Spinner />
|
|
</div>
|
|
) : !config?.has_category ? (
|
|
<p className="text-sm text-muted-foreground py-6 text-center">
|
|
This toolset has no configurable backends — toggle it on or off
|
|
above. It works with no provider selection or API keys.
|
|
</p>
|
|
) : config.providers.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground py-6 text-center">
|
|
No providers are available for this toolset in this install.
|
|
</p>
|
|
) : (
|
|
config.providers.map((provider) => {
|
|
const isActive = provider.name === activeProvider;
|
|
return (
|
|
<div
|
|
key={provider.name}
|
|
className={cn(
|
|
"border border-border p-3",
|
|
isActive && "border-emerald-500/60 bg-emerald-500/5",
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span className="font-medium text-sm">
|
|
{provider.name}
|
|
</span>
|
|
{provider.badge && (
|
|
<Badge tone="secondary" className="text-xs">
|
|
{provider.badge}
|
|
</Badge>
|
|
)}
|
|
{provider.requires_nous_auth && (
|
|
<Badge tone="outline" className="text-xs">
|
|
Nous Portal
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{isActive ? (
|
|
<Badge tone="success" className="text-xs shrink-0">
|
|
<Check className="h-3 w-3 mr-0.5" /> Selected
|
|
</Badge>
|
|
) : (
|
|
<Button
|
|
size="xs"
|
|
outlined
|
|
onClick={() => void handleSelectProvider(provider)}
|
|
disabled={selecting !== null}
|
|
>
|
|
{selecting === provider.name ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
"Select"
|
|
)}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{provider.tag && (
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{provider.tag}
|
|
</p>
|
|
)}
|
|
|
|
{/* API key inputs */}
|
|
{provider.env_vars.length > 0 && (
|
|
<div className="mt-3 space-y-2.5">
|
|
{provider.env_vars.map((ev) => (
|
|
<div key={ev.key} className="space-y-1">
|
|
<div className="flex items-center justify-between gap-2">
|
|
<Label
|
|
htmlFor={`env-${ev.key}`}
|
|
className="text-xs font-mono"
|
|
>
|
|
{ev.key}
|
|
</Label>
|
|
{isSet[ev.key] && (
|
|
<Badge tone="success" className="text-xs">
|
|
Saved
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<Input
|
|
id={`env-${ev.key}`}
|
|
type="password"
|
|
className="h-8 rounded-none text-xs font-mono"
|
|
placeholder={
|
|
isSet[ev.key]
|
|
? "•••••••• (saved — leave blank to keep)"
|
|
: ev.prompt || ev.key
|
|
}
|
|
value={drafts[ev.key] ?? ""}
|
|
onChange={(e) =>
|
|
setDrafts((prev) => ({
|
|
...prev,
|
|
[ev.key]: e.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
{ev.url && (
|
|
<a
|
|
href={ev.url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
<ExternalLink className="h-3 w-3" /> Get a key
|
|
</a>
|
|
)}
|
|
</div>
|
|
))}
|
|
<Button
|
|
size="xs"
|
|
onClick={() => void handleSaveKeys(provider)}
|
|
disabled={savingProvider !== null}
|
|
>
|
|
{savingProvider === provider.name ? (
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
) : (
|
|
"Save keys"
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Post-setup install hook */}
|
|
{provider.post_setup && (
|
|
<div className="mt-3 border-t border-border pt-3">
|
|
<p className="text-xs text-muted-foreground mb-1.5">
|
|
This backend needs a one-time install
|
|
{" "}
|
|
<span className="font-mono">
|
|
({provider.post_setup})
|
|
</span>
|
|
. Runs on this host — may take a few minutes.
|
|
</p>
|
|
<Button
|
|
size="xs"
|
|
outlined
|
|
onClick={() => void handleRunPostSetup(provider)}
|
|
disabled={postSetupRunning}
|
|
>
|
|
{postSetupRunning &&
|
|
postSetupKey === provider.post_setup ? (
|
|
<>
|
|
<Loader2 className="h-3 w-3 animate-spin mr-1" />
|
|
Installing…
|
|
</>
|
|
) : (
|
|
<>
|
|
<Terminal className="h-3 w-3 mr-1" /> Run setup
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
|
|
{/* Post-setup live log */}
|
|
{(postSetupRunning || postSetupLog.length > 0) && (
|
|
<div className="border border-border">
|
|
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-border bg-muted/30">
|
|
<Terminal className="h-3.5 w-3.5 text-muted-foreground" />
|
|
<span className="text-xs font-mono text-muted-foreground">
|
|
post-setup: {postSetupKey}
|
|
</span>
|
|
{postSetupRunning && (
|
|
<Loader2 className="h-3 w-3 animate-spin ml-auto text-muted-foreground" />
|
|
)}
|
|
</div>
|
|
<pre className="max-h-48 overflow-y-auto p-3 text-xs font-mono whitespace-pre-wrap text-text-secondary">
|
|
{postSetupLog.length ? postSetupLog.join("\n") : "Starting…"}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Toast toast={toast} />
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|