mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
feat(dashboard): full tool backend configuration in the GUI (#40418)
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.
This commit is contained in:
parent
e6de6dd559
commit
2bf0a6e760
7 changed files with 866 additions and 0 deletions
|
|
@ -14740,12 +14740,36 @@ Examples:
|
|||
help="Platform to apply to (default: cli)",
|
||||
)
|
||||
|
||||
# hermes tools post-setup <key>
|
||||
tools_postsetup_p = tools_sub.add_parser(
|
||||
"post-setup",
|
||||
help="Run a provider's post-setup install hook (npm/pip/binary)",
|
||||
description=(
|
||||
"Run the install/bootstrap hook a tool backend declares — the\n"
|
||||
"same step `hermes tools` runs after you pick a provider that\n"
|
||||
"needs extra dependencies (browser Chromium, Camofox, cua-driver,\n"
|
||||
"KittenTTS/Piper, ddgs, Spotify, Langfuse, xAI). Stable,\n"
|
||||
"non-interactive target the dashboard spawns to drive backend\n"
|
||||
"setup. Keys: agent_browser, camofox, cua_driver, kittentts,\n"
|
||||
"piper, ddgs, spotify, langfuse, xai_grok."
|
||||
),
|
||||
)
|
||||
tools_postsetup_p.add_argument(
|
||||
"post_setup_key",
|
||||
metavar="KEY",
|
||||
help="Post-setup hook key (e.g. agent_browser, camofox, kittentts)",
|
||||
)
|
||||
|
||||
def cmd_tools(args):
|
||||
action = getattr(args, "tools_action", None)
|
||||
if action in {"list", "disable", "enable"}:
|
||||
from hermes_cli.tools_config import tools_disable_enable_command
|
||||
|
||||
tools_disable_enable_command(args)
|
||||
elif action == "post-setup":
|
||||
from hermes_cli.tools_config import run_post_setup_command
|
||||
|
||||
sys.exit(run_post_setup_command(args))
|
||||
else:
|
||||
_require_tty("tools")
|
||||
from hermes_cli.tools_config import tools_command
|
||||
|
|
|
|||
|
|
@ -1168,6 +1168,68 @@ def _run_post_setup(post_setup_key: str):
|
|||
_print_info(" xAI will remain inactive until credentials are configured.")
|
||||
|
||||
|
||||
def valid_post_setup_keys() -> Set[str]:
|
||||
"""Return the set of post-setup keys declared by any visible provider.
|
||||
|
||||
Collected from ``TOOL_CATEGORIES`` plus the plugin-registered web /
|
||||
image-gen / video-gen / browser providers (which can also carry a
|
||||
``post_setup``). This is the allowlist the ``hermes tools post-setup``
|
||||
command and the dashboard post-setup endpoint validate against, so a
|
||||
caller can't drive ``_run_post_setup`` with an arbitrary key.
|
||||
"""
|
||||
keys: Set[str] = set()
|
||||
for cat in TOOL_CATEGORIES.values():
|
||||
for prov in cat.get("providers", []):
|
||||
ps = prov.get("post_setup")
|
||||
if ps:
|
||||
keys.add(ps)
|
||||
# Plugin-registered providers can declare their own post_setup hooks.
|
||||
for builder in (
|
||||
_plugin_web_search_providers,
|
||||
_plugin_image_gen_providers,
|
||||
_plugin_video_gen_providers,
|
||||
_plugin_browser_providers,
|
||||
):
|
||||
try:
|
||||
for prov in builder():
|
||||
ps = prov.get("post_setup")
|
||||
if ps:
|
||||
keys.add(ps)
|
||||
except Exception: # pragma: no cover — defensive; plugins optional
|
||||
continue
|
||||
return keys
|
||||
|
||||
|
||||
def run_post_setup_command(args) -> int:
|
||||
"""``hermes tools post-setup <key>`` — non-interactive post-setup runner.
|
||||
|
||||
Runs the install/bootstrap hook a provider declares (npm install for
|
||||
browser/Camofox, pip install for kittentts/piper/ddgs, cua-driver fetch,
|
||||
etc.). This is the stable, scriptable target the dashboard spawns so the
|
||||
GUI can drive backend setup without re-implementing the install logic.
|
||||
Returns a process exit code (0 ok, 2 unknown key).
|
||||
"""
|
||||
key = getattr(args, "post_setup_key", None)
|
||||
if not key:
|
||||
_print_error("Usage: hermes tools post-setup <key>")
|
||||
return 2
|
||||
valid = valid_post_setup_keys()
|
||||
if key not in valid:
|
||||
_print_error(
|
||||
f"Unknown post-setup key: {key!r}. "
|
||||
f"Valid keys: {', '.join(sorted(valid)) or '(none)'}"
|
||||
)
|
||||
return 2
|
||||
_print_info(f"Running post-setup hook: {key}")
|
||||
try:
|
||||
_run_post_setup(key)
|
||||
except Exception as exc: # pragma: no cover — defensive
|
||||
_print_error(f"Post-setup failed: {exc}")
|
||||
return 1
|
||||
_print_success(f"Post-setup '{key}' complete")
|
||||
return 0
|
||||
|
||||
|
||||
# ─── Platform / Toolset Helpers ───────────────────────────────────────────────
|
||||
|
||||
def _get_enabled_platforms() -> List[str]:
|
||||
|
|
|
|||
|
|
@ -1145,6 +1145,7 @@ _ACTION_LOG_FILES: Dict[str, str] = {
|
|||
"prompt-size": "action-prompt-size.log",
|
||||
"dump": "action-dump.log",
|
||||
"config-migrate": "action-config-migrate.log",
|
||||
"tools-post-setup": "action-tools-post-setup.log",
|
||||
}
|
||||
|
||||
# ``name`` → most recently spawned Popen handle. Used so ``status`` can
|
||||
|
|
@ -7684,6 +7685,106 @@ async def select_toolset_provider(name: str, body: ToolsetProviderSelect):
|
|||
return {"ok": True, "name": name, "provider": body.provider}
|
||||
|
||||
|
||||
class ToolsetEnvUpdate(BaseModel):
|
||||
env: Dict[str, str]
|
||||
|
||||
|
||||
@app.put("/api/tools/toolsets/{name}/env")
|
||||
async def save_toolset_env(name: str, body: ToolsetEnvUpdate):
|
||||
"""Persist API keys for a toolset's provider env vars.
|
||||
|
||||
Writes each ``key: value`` to ``~/.hermes/.env`` via ``save_env_value`` —
|
||||
the same store ``hermes tools`` writes when it prompts for keys. Keys are
|
||||
validated against the env-var allowlist for the toolset's category (the
|
||||
union of every visible provider's ``env_vars``), so the GUI can't write an
|
||||
arbitrary env var through this endpoint. A blank value is treated as
|
||||
"leave unchanged" and skipped. Returns the saved/skipped key lists and the
|
||||
refreshed ``is_set`` status. Returns 400 for unknown toolset or env keys.
|
||||
"""
|
||||
from hermes_cli.tools_config import (
|
||||
TOOL_CATEGORIES,
|
||||
_get_effective_configurable_toolsets,
|
||||
_visible_providers,
|
||||
)
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
|
||||
valid_ts = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
|
||||
if name not in valid_ts:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
|
||||
|
||||
config = load_config()
|
||||
cat = TOOL_CATEGORIES.get(name)
|
||||
allowed: set[str] = set()
|
||||
if cat:
|
||||
for prov in _visible_providers(cat, config, force_fresh=True):
|
||||
for e in prov.get("env_vars", []):
|
||||
allowed.add(e["key"])
|
||||
|
||||
unknown = [k for k in body.env if k not in allowed]
|
||||
if unknown:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown env var(s) for toolset {name}: {', '.join(sorted(unknown))}",
|
||||
)
|
||||
|
||||
saved: List[str] = []
|
||||
skipped: List[str] = []
|
||||
for key, value in body.env.items():
|
||||
if value and value.strip():
|
||||
try:
|
||||
save_env_value(key, value.strip())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
saved.append(key)
|
||||
else:
|
||||
skipped.append(key)
|
||||
|
||||
status = {k: bool(get_env_value(k)) for k in allowed}
|
||||
return {"ok": True, "name": name, "saved": saved, "skipped": skipped, "is_set": status}
|
||||
|
||||
|
||||
class ToolsetPostSetup(BaseModel):
|
||||
key: str
|
||||
|
||||
|
||||
@app.post("/api/tools/toolsets/{name}/post-setup")
|
||||
async def run_toolset_post_setup(name: str, body: ToolsetPostSetup):
|
||||
"""Spawn a provider's post-setup install hook as a background action.
|
||||
|
||||
Post-setup hooks (npm install for browser/Camofox, pip install for
|
||||
KittenTTS/Piper/ddgs, cua-driver fetch, etc.) are long-running and
|
||||
text-output, so this follows the spawn-action pattern: it launches
|
||||
``hermes tools post-setup <key>`` and the frontend tails the log via
|
||||
``GET /api/actions/tools-post-setup/status``. The ``key`` is validated
|
||||
against the declared post-setup allowlist before spawning. Returns 400
|
||||
for unknown toolset or post-setup key.
|
||||
"""
|
||||
from hermes_cli.tools_config import (
|
||||
_get_effective_configurable_toolsets,
|
||||
valid_post_setup_keys,
|
||||
)
|
||||
|
||||
valid_ts = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
|
||||
if name not in valid_ts:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
|
||||
|
||||
if body.key not in valid_post_setup_keys():
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Unknown post-setup key: {body.key}"
|
||||
)
|
||||
|
||||
try:
|
||||
proc = _spawn_hermes_action(
|
||||
["tools", "post-setup", body.key], "tools-post-setup"
|
||||
)
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn tools post-setup")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to run post-setup: {exc}"
|
||||
)
|
||||
return {"ok": True, "pid": proc.pid, "name": "tools-post-setup", "key": body.key}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Raw YAML config endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -794,3 +794,132 @@ class TestDebugShareEndpoint:
|
|||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
class TestToolsConfigEndpoints:
|
||||
"""Provider selection, API-key save, and post-setup spawn for toolsets —
|
||||
the dashboard surface that replicates the `hermes tools` configurator."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self, _isolate_hermes_home):
|
||||
self.client, self.header = _client()
|
||||
|
||||
def test_list_toolsets_shape(self):
|
||||
r = self.client.get("/api/tools/toolsets")
|
||||
assert r.status_code == 200
|
||||
rows = r.json()
|
||||
assert isinstance(rows, list) and rows
|
||||
row = rows[0]
|
||||
for k in ("name", "label", "enabled", "configured", "tools"):
|
||||
assert k in row
|
||||
|
||||
def test_toolset_config_provider_matrix(self):
|
||||
# `web` has a TOOL_CATEGORIES entry → providers list populated.
|
||||
r = self.client.get("/api/tools/toolsets/web/config")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["has_category"] is True
|
||||
assert isinstance(body["providers"], list)
|
||||
|
||||
def test_unknown_toolset_config_400(self):
|
||||
r = self.client.get("/api/tools/toolsets/not_a_toolset/config")
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_save_env_writes_key_and_validates_allowlist(self):
|
||||
from hermes_cli.config import get_env_value
|
||||
|
||||
cfg = self.client.get("/api/tools/toolsets/web/config").json()
|
||||
# Find a real env-var key from the visible provider matrix.
|
||||
key = None
|
||||
for prov in cfg["providers"]:
|
||||
for e in prov.get("env_vars", []):
|
||||
key = e["key"]
|
||||
break
|
||||
if key:
|
||||
break
|
||||
if not key:
|
||||
pytest.skip("no env-var-bearing web provider in this build")
|
||||
|
||||
r = self.client.put(
|
||||
"/api/tools/toolsets/web/env", json={"env": {key: "test-secret-123"}}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert key in body["saved"]
|
||||
assert body["is_set"][key] is True
|
||||
# CLI-config parity: the key landed in the .env store the CLI reads.
|
||||
assert get_env_value(key) == "test-secret-123"
|
||||
|
||||
def test_save_env_rejects_unknown_key(self):
|
||||
r = self.client.put(
|
||||
"/api/tools/toolsets/web/env",
|
||||
json={"env": {"TOTALLY_BOGUS_KEY": "x"}},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_save_env_blank_value_skipped(self):
|
||||
cfg = self.client.get("/api/tools/toolsets/web/config").json()
|
||||
key = None
|
||||
for prov in cfg["providers"]:
|
||||
for e in prov.get("env_vars", []):
|
||||
key = e["key"]
|
||||
break
|
||||
if key:
|
||||
break
|
||||
if not key:
|
||||
pytest.skip("no env-var-bearing web provider in this build")
|
||||
r = self.client.put(
|
||||
"/api/tools/toolsets/web/env", json={"env": {key: " "}}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert key in r.json()["skipped"]
|
||||
|
||||
def test_post_setup_unknown_key_400(self):
|
||||
r = self.client.post(
|
||||
"/api/tools/toolsets/browser/post-setup", json={"key": "bogus"}
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_post_setup_unknown_toolset_400(self):
|
||||
r = self.client.post(
|
||||
"/api/tools/toolsets/not_a_toolset/post-setup",
|
||||
json={"key": "agent_browser"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_post_setup_spawns_action(self, monkeypatch):
|
||||
import hermes_cli.web_server as ws
|
||||
|
||||
spawned = {}
|
||||
|
||||
class _FakeProc:
|
||||
pid = 4321
|
||||
|
||||
def _fake_spawn(subcommand, name):
|
||||
spawned["subcommand"] = subcommand
|
||||
spawned["name"] = name
|
||||
return _FakeProc()
|
||||
|
||||
monkeypatch.setattr(ws, "_spawn_hermes_action", _fake_spawn)
|
||||
r = self.client.post(
|
||||
"/api/tools/toolsets/browser/post-setup",
|
||||
json={"key": "agent_browser"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["name"] == "tools-post-setup"
|
||||
assert body["pid"] == 4321
|
||||
assert spawned["subcommand"] == ["tools", "post-setup", "agent_browser"]
|
||||
|
||||
def test_endpoints_require_session_token(self):
|
||||
for method, path, payload in [
|
||||
("get", "/api/tools/toolsets/web/config", None),
|
||||
("put", "/api/tools/toolsets/web/env", {"env": {}}),
|
||||
("post", "/api/tools/toolsets/web/post-setup", {"key": "ddgs"}),
|
||||
]:
|
||||
fn = getattr(self.client, method)
|
||||
kwargs = {"headers": {self.header: "wrong-token"}}
|
||||
if payload is not None:
|
||||
kwargs["json"] = payload
|
||||
r = fn(path, **kwargs)
|
||||
assert r.status_code == 401, f"{method} {path} not gated"
|
||||
|
||||
|
|
|
|||
448
web/src/components/ToolsetConfigDrawer.tsx
Normal file
448
web/src/components/ToolsetConfigDrawer.tsx
Normal file
|
|
@ -0,0 +1,448 @@
|
|||
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,
|
||||
);
|
||||
}
|
||||
|
|
@ -513,6 +513,46 @@ export const api = {
|
|||
body: JSON.stringify({ name, enabled }),
|
||||
}),
|
||||
getToolsets: () => fetchJSON<ToolsetInfo[]>("/api/tools/toolsets"),
|
||||
toggleToolset: (name: string, enabled: boolean) =>
|
||||
fetchJSON<{ ok: boolean; name: string; enabled: boolean }>(
|
||||
`/api/tools/toolsets/${encodeURIComponent(name)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled }),
|
||||
},
|
||||
),
|
||||
getToolsetConfig: (name: string) =>
|
||||
fetchJSON<ToolsetConfig>(
|
||||
`/api/tools/toolsets/${encodeURIComponent(name)}/config`,
|
||||
),
|
||||
selectToolsetProvider: (name: string, provider: string) =>
|
||||
fetchJSON<{ ok: boolean; name: string; provider: string }>(
|
||||
`/api/tools/toolsets/${encodeURIComponent(name)}/provider`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ provider }),
|
||||
},
|
||||
),
|
||||
saveToolsetEnv: (name: string, env: Record<string, string>) =>
|
||||
fetchJSON<ToolsetEnvResult>(
|
||||
`/api/tools/toolsets/${encodeURIComponent(name)}/env`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ env }),
|
||||
},
|
||||
),
|
||||
runToolsetPostSetup: (name: string, key: string) =>
|
||||
fetchJSON<ActionResponse & { key: string }>(
|
||||
`/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key }),
|
||||
},
|
||||
),
|
||||
|
||||
// Session search (FTS5)
|
||||
searchSessions: (q: string) =>
|
||||
|
|
@ -1619,6 +1659,39 @@ export interface ToolsetInfo {
|
|||
tools: string[];
|
||||
}
|
||||
|
||||
export interface ToolsetProviderEnvVar {
|
||||
key: string;
|
||||
prompt: string;
|
||||
url: string | null;
|
||||
default: string | null;
|
||||
is_set: boolean;
|
||||
}
|
||||
|
||||
export interface ToolsetProvider {
|
||||
name: string;
|
||||
badge: string;
|
||||
tag: string;
|
||||
env_vars: ToolsetProviderEnvVar[];
|
||||
post_setup: string | null;
|
||||
requires_nous_auth: boolean;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface ToolsetConfig {
|
||||
name: string;
|
||||
has_category: boolean;
|
||||
providers: ToolsetProvider[];
|
||||
active_provider: string | null;
|
||||
}
|
||||
|
||||
export interface ToolsetEnvResult {
|
||||
ok: boolean;
|
||||
name: string;
|
||||
saved: string[];
|
||||
skipped: string[];
|
||||
is_set: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export interface SessionSearchResult {
|
||||
session_id: string;
|
||||
snippet: string;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import type {
|
|||
SkillHubPreview,
|
||||
SkillHubScan,
|
||||
} from "@/lib/api";
|
||||
import { ToolsetConfigDrawer } from "@/components/ToolsetConfigDrawer";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
|
||||
|
|
@ -127,6 +128,7 @@ export default function SkillsPage() {
|
|||
const [view, setView] = useState<"skills" | "toolsets" | "hub">("skills");
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
|
||||
const [configToolset, setConfigToolset] = useState<ToolsetInfo | null>(null);
|
||||
const { toast, showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const { setAfterTitle, setEnd } = usePageHeader();
|
||||
|
|
@ -166,6 +168,16 @@ export default function SkillsPage() {
|
|||
}
|
||||
};
|
||||
|
||||
/* ---- Refresh toolsets after a config change ---- */
|
||||
const refreshToolsets = async () => {
|
||||
try {
|
||||
const tsets = await api.getToolsets();
|
||||
setToolsets(tsets);
|
||||
} catch {
|
||||
/* non-fatal: the drawer already toasted on the failing write */
|
||||
}
|
||||
};
|
||||
|
||||
/* ---- Derived data ---- */
|
||||
const lowerSearch = search.toLowerCase();
|
||||
const isSearching = search.trim().length > 0;
|
||||
|
|
@ -508,6 +520,16 @@ export default function SkillsPage() {
|
|||
: t.skills.disabledForCli}
|
||||
</span>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
size="xs"
|
||||
outlined
|
||||
onClick={() => setConfigToolset(ts)}
|
||||
>
|
||||
<Wrench className="h-3 w-3 mr-1" />
|
||||
Configure
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -522,6 +544,13 @@ export default function SkillsPage() {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{configToolset && (
|
||||
<ToolsetConfigDrawer
|
||||
toolset={configToolset}
|
||||
onClose={() => setConfigToolset(null)}
|
||||
onChanged={() => void refreshToolsets()}
|
||||
/>
|
||||
)}
|
||||
<PluginSlot name="skills:bottom" />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue