From d986bb0c6de6bcffda4981e83652284d481a90f4 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 09:18:32 -0700 Subject: [PATCH] feat(dashboard): full-featured profile builder (model + skills + MCPs) (#39084) * feat(profiles): extend create endpoint for full profile-builder (model + MCPs + skills) Backend foundation for the dashboard profile builder. Extends POST /api/profiles to accept, in one call, everything a profile needs beyond name/clone: - mcp_servers[] -> written into the new profile's config.yaml - keep_skills[] -> replace-semantics: disable every seeded skill not kept - hub_skills[] -> async install via 'hermes -p skills install ' All applied best-effort AFTER the profile dir exists, so a hiccup in any one never 500s the create. Model/MCP/keep-skills writes are profile-scoped via the HERMES_HOME context override (same mechanism as the existing _write_profile_model). Hub installs go through a subprocess scoped with -p because skills_hub.SKILLS_DIR is import-time-bound and the runtime override can't redirect it. Adds two helpers (_write_profile_mcp_servers, _disable_unselected_skills) and a TestClient test asserting all four paths land in the NEW profile's config and the hub spawn is scoped to it. Design doc at docs/design/profile-builder.md. * feat(dashboard): full-featured profile builder page Adds a dedicated /profiles/new builder that composes everything a profile needs into one stepped create flow, reusing the existing Models/Skills/MCP data paths instead of duplicating them: - Identity name + description - Model provider+model picker (api.getModelOptions) - Skills keep-which-built-in/optional (replace semantics, default = full bundle) + skills-hub search/add (api.getSkills, searchSkillsHub) - MCPs add HTTP/stdio servers inline - Review blueprint -> single POST /api/profiles create Nothing writes until Create; the one call commits model+MCPs+skill selection and spawns hub-skill installs (reported in the success toast). ProfilesPage header gets a 'Build' button (full builder) alongside 'Create' (quick modal). Route is page-only (not in the sidebar nav). Verified with vite build (2258 modules, green). --- docs/design/profile-builder.md | 144 +++++++ hermes_cli/web_server.py | 153 ++++++- tests/hermes_cli/test_web_server.py | 77 ++++ web/src/App.tsx | 2 + web/src/lib/api.ts | 13 +- web/src/pages/ProfileBuilderPage.tsx | 611 +++++++++++++++++++++++++++ web/src/pages/ProfilesPage.tsx | 30 +- 7 files changed, 1019 insertions(+), 11 deletions(-) create mode 100644 docs/design/profile-builder.md create mode 100644 web/src/pages/ProfileBuilderPage.tsx diff --git a/docs/design/profile-builder.md b/docs/design/profile-builder.md new file mode 100644 index 00000000000..26da98ffeb0 --- /dev/null +++ b/docs/design/profile-builder.md @@ -0,0 +1,144 @@ +# Profile Builder — Dashboard-Native, Full-Featured Profile Creation + +Status: design proposal (not yet implemented) +Author: drafted for Teknium +Supersedes: PR #31781 (prompt_toolkit `hermes profile wizard`) + +## Why this, not the CLI wizard + +PR #31781 added a keyboard-driven `hermes profile wizard` in the terminal. +The decision is to **not** build the profile-creation experience in the CLI. +The dashboard already owns mature, separate pages for every element a profile +needs, and a profile is just a HERMES_HOME directory — so the dashboard is the +right home for a full-featured builder, and it can reuse everything that +already exists. + +A profile = a full `~/.hermes/profiles//` directory with its own: +- `config.yaml` — holds `model`/`provider`, `mcp_servers`, enabled skills +- `skills/` — physical SKILL.md files (built-in seed + optional + hub installs) +- `.env` — secrets +- `SOUL.md` / `USER.md` — identity + +So per-profile scoping of Model, MCPs, and Skills is **native** — no data-model +change needed. The gap is purely UX: creation today is a thin modal +(name + clone + model + description), and you can only compose skills/MCPs +*after* the profile exists, by visiting other pages and remembering to scope +them. + +## What already exists (reuse, don't rebuild) + +| Element | Existing page | Existing API | Profile-scopable? | +|---|---|---|---| +| Name / Description | ProfilesPage create modal | `POST /api/profiles` (`create_profile`) | yes (args) | +| Model + Provider | ModelsPage | `_write_profile_model(profile_dir, …)` | yes — HERMES_HOME override, already wired into create endpoint | +| MCPs | McpPage | `mcp_config._save_mcp_server` + `/api/mcp/catalog` | yes — wrap with HERMES_HOME override | +| Skills (built-in/optional) | SkillsPage | `GET /api/skills`, `/api/skills/toggle` | yes — config write | +| Skills (hub) | SkillsPage | `/api/skills/hub/search`, `/api/skills/hub/install` | **only via subprocess** — see seam #1 | + +## Two architectural seams found while grounding this design + +These are load-bearing — they change the implementation, not just the polish. + +### Seam #1 — hub-skill install cannot use the HERMES_HOME override + +`tools/skills_hub.py` binds `SKILLS_DIR = HERMES_HOME / "skills"` at **module +import time**. The context-local `set_hermes_home_override()` swap (which makes +`_write_profile_model` and the MCP write land in the target profile) does NOT +retroactively rebind that already-imported module global. So a data-layer wrap +of hub install would write into the dashboard's *own* active profile, not the +new one. + +The correct mechanism is the existing subprocess path: `_spawn_hermes_action` +runs `python -m hermes_cli.main `, and `_apply_profile_override()` +re-reads `sys.argv` at import in the fresh child. Prepend `-p `: + +```python +_spawn_hermes_action(["-p", profile, "skills", "install", identifier], "skills-install") +``` + +A fresh subprocess re-imports `skills_hub` with the profile's HERMES_HOME bound +from the start, so `SKILLS_DIR` resolves to `/skills/`. Correct by +construction. + +### Seam #2 — hub installs are async, so create cannot be fully atomic + +Built-in/optional skill enabling and MCP writes are **synchronous config ops** +and can be part of the create call. Hub installs are long-running git fetches +spawned detached (`_spawn_hermes_action` returns a PID immediately). So the +create flow is: + +1. `create_profile()` — make the dir (synchronous) +2. write model (synchronous, HERMES_HOME override) +3. write selected MCP servers (synchronous, HERMES_HOME override) +4. seed/enable selected built-in + optional skills (synchronous) +5. spawn `hermes -p skills install ` per hub skill (async, returns PIDs) + +Steps 1–4 commit before the response; step 5 returns a list of action PIDs the +UI polls (same pattern as today's SkillsPage hub install). The builder's +"Review → Create" returns `{ok, name, path, hub_installs: [{id, pid}]}` and the +final screen shows live install progress for the hub skills. + +## Proposed backend change (small, follows existing patterns) + +Extend `ProfileCreate` and the create endpoint — no new endpoints, no rewrite: + +```python +class ProfileCreate(BaseModel): + name: str + clone_from_default: bool = False + clone_all: bool = False + no_skills: bool = False + description: Optional[str] = None + provider: Optional[str] = None + model: Optional[str] = None + # NEW — all optional, all best-effort post-create (profile already exists) + mcp_servers: List[MCPServerCreate] = [] # synchronous, HERMES_HOME override + builtin_skills: List[str] = [] # synchronous enable/seed + hub_skills: List[str] = [] # async spawn, returns PIDs +``` + +The endpoint already does best-effort post-create steps (`seed_profile_skills`, +`_write_profile_model`). Add two more best-effort blocks (MCP write, hub-skill +spawn) in the same style — a failure in any of them must not 500 the create, +since the profile dir already exists and the user can fix it from the relevant +page afterward. Mirror `_write_profile_model`'s HERMES_HOME-override helper for +the MCP write (`_write_profile_mcp_servers(profile_dir, servers)`). + +## Proposed frontend — dedicated builder page `/profiles/new` + +A full page (not the cramped modal), stepped, each step reusing the existing +page's component + API, targeted at the new profile: + +``` +① Identity Name + Description (+ optional clone-from existing profile) +② Model Provider + model picker (reuse ModelsPage picker) +③ Skills Tabs: Built-in · Optional · Hub-search + multi-select; "Start from default bundle" preset button +④ MCPs Tabs: Catalog browse · Manual add (reuse McpPage form) +⑤ Review Blueprint preview → Create + → progress screen for async hub installs +``` + +Nothing writes to disk until ⑤. + +## Open product decisions (need Teknium) + +1. **Skills seeding default.** Fresh profiles auto-seed the default bundle + today. In the builder, should the skill step **replace** the bundle (pick + exactly what you want; offer a "start from default bundle" preset) or + **augment** it? Recommendation: replace + preset button. + +2. **Page vs richer modal.** Dedicated `/profiles/new` page (room to grow: + SOUL editing, multi-agent fleets later) vs a bigger create modal on + ProfilesPage. Recommendation: dedicated page — matches "full-featured / way + more options." + +## Verification plan (when built) + +- Backend E2E with isolated HERMES_HOME: POST a full create body + (name + model + 2 MCPs + 3 builtin skills + 1 hub skill), assert the new + profile dir has the model in config.yaml, both MCP servers in config.yaml, + the builtin skills enabled, and a spawned PID for the hub skill. Negative: + a bad MCP entry must not 500 the create. +- `cd web && npm run build` (no JS test suite in web/). +- Targeted: `pytest tests/ -k profile_create`. diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 45a7ea51634..001c3fed1d1 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -7422,6 +7422,21 @@ class ProfileCreate(BaseModel): clone_from: Optional[str] = None provider: Optional[str] = None model: Optional[str] = None + # Profile-builder additions — all optional, all applied best-effort AFTER + # the profile directory exists, so a hiccup in any of them never 500s the + # create (the user can fix it from the relevant dashboard page afterward). + # MCP servers to write into the new profile's config.yaml. + mcp_servers: List["MCPServerCreate"] = [] + # Built-in / optional skills to KEEP active. When this list is non-empty, + # the builder uses "replace" semantics: the bundle is seeded, then every + # seeded skill NOT in this list is added to the profile's disabled list. + # Empty list = leave the seeded bundle untouched (legacy behaviour). + keep_skills: List[str] = [] + # Skills-hub identifiers to install into the new profile. Installed async + # via a subprocess scoped to the profile (`hermes -p skills install`) + # because skills_hub.SKILLS_DIR is import-time-bound and the HERMES_HOME + # override can't redirect it. Returns spawned PIDs for the UI to poll. + hub_skills: List[str] = [] class ProfileRename(BaseModel): @@ -7567,6 +7582,94 @@ def _write_profile_model(profile_dir: Path, provider: str, model: str) -> None: reset_hermes_home_override(token) +def _write_profile_mcp_servers(profile_dir: Path, servers: List["MCPServerCreate"]) -> int: + """Write MCP server entries into a specific profile's config.yaml. + + Scopes ``load_config``/``save_config`` to ``profile_dir`` via the + context-local HERMES_HOME override (same mechanism as + ``_write_profile_model``) so the entries land in the target profile's + config rather than the dashboard process's active profile. + + Mirrors the per-server shape the ``POST /api/mcp/servers`` endpoint builds, + but batched so the whole profile-create write is a single config save. + Returns the number of servers written. + """ + from hermes_constants import set_hermes_home_override, reset_hermes_home_override + + written = 0 + token = set_hermes_home_override(str(profile_dir)) + try: + cfg = load_config() + mcp = cfg.setdefault("mcp_servers", {}) + for server in servers: + name = (server.name or "").strip() + if not name: + continue + entry: Dict[str, Any] = {} + if server.url: + entry["url"] = server.url + if server.command: + entry["command"] = server.command + if server.args: + entry["args"] = list(server.args) + if server.env: + entry["env"] = dict(server.env) + if server.auth: + entry["auth"] = server.auth + if not entry: + # Nothing usable to write (neither url nor command) — skip + # rather than persist an empty, unusable server stanza. + continue + mcp[name] = entry + written += 1 + if written: + save_config(cfg) + elif not mcp: + # We created an empty mcp_servers dict but wrote nothing — don't + # leave a stray empty key in the new profile's config. + cfg.pop("mcp_servers", None) + save_config(cfg) + finally: + reset_hermes_home_override(token) + return written + + +def _disable_unselected_skills(profile_dir: Path, keep: List[str]) -> int: + """Disable every installed skill in ``profile_dir`` not in ``keep``. + + Profiles manage skill activation via a *disabled* list — all installed + skills are active by default and users opt out. The builder's skill step + uses "replace" semantics: the user picks exactly which seeded built-in / + optional skills stay active, and everything else gets added to the disabled + list. (Hub skills are installed separately via subprocess and are active on + install.) Scoped to the profile via the HERMES_HOME override. Returns the + number of skills newly disabled. + """ + from hermes_constants import set_hermes_home_override, reset_hermes_home_override + from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills + + keep_set = {s.strip() for s in keep if s and s.strip()} + disabled_count = 0 + token = set_hermes_home_override(str(profile_dir)) + try: + installed: List[str] = [] + skills_root = profile_dir / "skills" + if skills_root.is_dir(): + for md in skills_root.rglob("SKILL.md"): + installed.append(md.parent.name) + cfg = load_config() + disabled = get_disabled_skills(cfg) + for name in installed: + if name not in keep_set and name not in disabled: + disabled.add(name) + disabled_count += 1 + if disabled_count: + save_disabled_skills(cfg, disabled) + finally: + reset_hermes_home_override(token) + return disabled_count + + @app.get("/api/profiles") async def list_profiles_endpoint(): from hermes_cli import profiles as profiles_mod @@ -7633,7 +7736,55 @@ async def create_profile_endpoint(body: ProfileCreate): except Exception: _log.exception("Setting model for new profile %s failed", body.name) - return {"ok": True, "name": body.name, "path": str(path), "model_set": model_set} + # Optional MCP servers. Best-effort, same rationale as model assignment. + mcp_written = 0 + if body.mcp_servers: + try: + mcp_written = _write_profile_mcp_servers(path, body.mcp_servers) + except Exception: + _log.exception("Writing MCP servers for new profile %s failed", body.name) + + # Optional "keep" skill selection — replace semantics. When the builder + # sends an explicit keep list, disable every seeded skill not in it. + # Best-effort. Skipped when keep_skills is empty (legacy: keep the bundle). + skills_disabled = 0 + if body.keep_skills: + try: + skills_disabled = _disable_unselected_skills(path, body.keep_skills) + except Exception: + _log.exception("Applying skill selection for new profile %s failed", body.name) + + # Optional skills-hub installs. Spawned async, scoped to the new profile + # via `-p ` (a fresh subprocess re-binds skills_hub.SKILLS_DIR to the + # profile's HERMES_HOME at import). Returns PIDs for the UI to poll. + hub_installs: List[Dict[str, Any]] = [] + for identifier in body.hub_skills: + ident = (identifier or "").strip() + if not ident: + continue + try: + proc = _spawn_hermes_action( + ["-p", body.name, "skills", "install", ident], + "skills-install", + ) + hub_installs.append({"identifier": ident, "pid": proc.pid}) + except Exception: + _log.exception( + "Spawning hub-skill install %s for new profile %s failed", + ident, + body.name, + ) + hub_installs.append({"identifier": ident, "pid": None}) + + return { + "ok": True, + "name": body.name, + "path": str(path), + "model_set": model_set, + "mcp_written": mcp_written, + "skills_disabled": skills_disabled, + "hub_installs": hub_installs, + } @app.get("/api/profiles/active") diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 6a0a1b5b0a2..76cbd59efdc 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2425,6 +2425,83 @@ class TestNewEndpoints: profiles = {p["name"]: p for p in self.client.get("/api/profiles").json()["profiles"]} assert profiles["fresh"]["skill_count"] == 1 + def test_profiles_create_builder_fields_model_mcp_and_keep_skills(self, monkeypatch): + """Profile-builder create: model + MCP servers + keep-skills selection + all land in the NEW profile's config, and hub installs are spawned + scoped to that profile via ``-p ``.""" + from hermes_constants import ( + get_hermes_home, + set_hermes_home_override, + reset_hermes_home_override, + ) + from hermes_cli.config import load_config + from hermes_cli.skills_config import get_disabled_skills + import hermes_cli.profiles as profiles_mod + import hermes_cli.web_server as web_server + + monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None) + + # Seed two known skills so keep-skills "replace" has something to act on. + def fake_seed(profile_dir, quiet=False): + for skill in ("keep-me", "drop-me"): + d = profile_dir / "skills" / "custom" / skill + d.mkdir(parents=True) + (d / "SKILL.md").write_text(f"---\nname: {skill}\n---\n", encoding="utf-8") + return {"copied": ["keep-me", "drop-me"]} + + monkeypatch.setattr(profiles_mod, "seed_profile_skills", fake_seed) + + # Capture hub-install spawns instead of launching real subprocesses. + spawned = [] + + class _FakeProc: + pid = 4321 + + def fake_spawn(subcommand, name): + spawned.append((list(subcommand), name)) + return _FakeProc() + + monkeypatch.setattr(web_server, "_spawn_hermes_action", fake_spawn) + + resp = self.client.post( + "/api/profiles", + json={ + "name": "builder", + "provider": "openrouter", + "model": "anthropic/claude-sonnet-4.6", + "mcp_servers": [ + {"name": "ctx7", "url": "https://mcp.context7.com/mcp"}, + {"name": "bogus"}, # no url/command -> must be skipped, no 500 + ], + "keep_skills": ["keep-me"], + "hub_skills": ["someuser/some-skill"], + }, + ) + + assert resp.status_code == 200 + data = resp.json() + assert data["model_set"] is True + assert data["mcp_written"] == 1 # bogus skipped + assert data["skills_disabled"] == 1 # drop-me disabled, keep-me kept + assert data["hub_installs"] == [{"identifier": "someuser/some-skill", "pid": 4321}] + + # Hub install was scoped to the new profile. + assert spawned == [(["-p", "builder", "skills", "install", "someuser/some-skill"], "skills-install")] + + # Verify the writes landed in the NEW profile's config, not the root. + prof_dir = get_hermes_home() / "profiles" / "builder" + token = set_hermes_home_override(str(prof_dir)) + try: + cfg = load_config() + assert cfg["model"]["default"] == "anthropic/claude-sonnet-4.6" + assert cfg["model"]["provider"] == "openrouter" + assert sorted((cfg.get("mcp_servers") or {}).keys()) == ["ctx7"] + disabled = get_disabled_skills(cfg) + assert "drop-me" in disabled + assert "keep-me" not in disabled + finally: + reset_hermes_home_override(token) + def test_profile_open_terminal_uses_macos_terminal(self, monkeypatch): from hermes_constants import get_hermes_home import hermes_cli.web_server as web_server diff --git a/web/src/App.tsx b/web/src/App.tsx index aef3148b747..affc0991e99 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -74,6 +74,7 @@ import AnalyticsPage from "@/pages/AnalyticsPage"; import ModelsPage from "@/pages/ModelsPage"; import CronPage from "@/pages/CronPage"; import ProfilesPage from "@/pages/ProfilesPage"; +import ProfileBuilderPage from "@/pages/ProfileBuilderPage"; import SkillsPage from "@/pages/SkillsPage"; import PluginsPage from "@/pages/PluginsPage"; import McpPage from "@/pages/McpPage"; @@ -136,6 +137,7 @@ const BUILTIN_ROUTES_CORE: Record = { "/webhooks": WebhooksPage, "/system": SystemPage, "/profiles": ProfilesPage, + "/profiles/new": ProfileBuilderPage, "/config": ConfigPage, "/env": EnvPage, "/docs": DocsPage, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 8385c300ac7..2d7a76f7773 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -439,8 +439,19 @@ export const api = { description?: string; provider?: string; model?: string; + mcp_servers?: McpServerCreate[]; + keep_skills?: string[]; + hub_skills?: string[]; }) => - fetchJSON<{ ok: boolean; name: string; path: string; model_set?: boolean }>("/api/profiles", { + fetchJSON<{ + ok: boolean; + name: string; + path: string; + model_set?: boolean; + mcp_written?: number; + skills_disabled?: number; + hub_installs?: Array<{ identifier: string; pid: number | null }>; + }>("/api/profiles", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), diff --git a/web/src/pages/ProfileBuilderPage.tsx b/web/src/pages/ProfileBuilderPage.tsx new file mode 100644 index 00000000000..4747878be8e --- /dev/null +++ b/web/src/pages/ProfileBuilderPage.tsx @@ -0,0 +1,611 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { H2 } from "@nous-research/ui/ui/components/typography/h2"; +import { Card, CardContent } from "@nous-research/ui/ui/components/card"; +import { Badge } from "@nous-research/ui/ui/components/badge"; +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 { Checkbox } from "@nous-research/ui/ui/components/checkbox"; +import { Toast } from "@nous-research/ui/ui/components/toast"; +import { useToast } from "@nous-research/ui/hooks/use-toast"; +import { api } from "@/lib/api"; +import type { McpServerCreate, SkillInfo, SkillHubResult } from "@/lib/api"; +import { cn } from "@/lib/utils"; + +// Profile name rule mirrors the backend (`^[a-z0-9][a-z0-9_-]{0,63}$`). +const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/; + +type StepId = "identity" | "model" | "skills" | "mcp" | "review"; + +const STEPS: { id: StepId; label: string }[] = [ + { id: "identity", label: "Identity" }, + { id: "model", label: "Model" }, + { id: "skills", label: "Skills" }, + { id: "mcp", label: "MCPs" }, + { id: "review", label: "Review" }, +]; + +interface ModelChoice { + provider: string; + model: string; + label: string; +} + +/** + * Dashboard-native, full-featured profile builder. + * + * Composes the same elements the standalone Models / Skills / MCP pages + * manage — Name, Description, Model+Provider, Skills (built-in/optional + + * hub), MCP servers — into one stepped create flow. Nothing is written to + * disk until "Create profile" on the final step; the single POST /api/profiles + * call commits model + MCPs + skill selection synchronously and spawns any + * hub-skill installs (which the success toast reports as in-progress). + * + * Skills use REPLACE semantics: the default bundle is seeded server-side, then + * every seeded skill the user did NOT keep is disabled. The "Start from full + * bundle" toggle keeps everything (sends no keep list). + */ +export default function ProfileBuilderPage() { + const navigate = useNavigate(); + const { toast, showToast } = useToast(); + + const [step, setStep] = useState("identity"); + + // ── Step 1: identity ────────────────────────────────────────────── + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + + // ── Step 2: model ───────────────────────────────────────────────── + const [modelChoices, setModelChoices] = useState(null); + const [modelChoice, setModelChoice] = useState(""); // `${provider}\u0000${model}` + const [modelFilter, setModelFilter] = useState(""); + const modelLoading = useRef(false); + + // ── Step 3: skills ──────────────────────────────────────────────── + const [skills, setSkills] = useState(null); + // keepAll = true: don't send a keep list (full bundle stays active). + const [keepAll, setKeepAll] = useState(true); + const [keptSkills, setKeptSkills] = useState>(new Set()); + const [skillFilter, setSkillFilter] = useState(""); + const skillsLoading = useRef(false); + // Hub search + const [hubQuery, setHubQuery] = useState(""); + const [hubResults, setHubResults] = useState([]); + const [hubSearching, setHubSearching] = useState(false); + const [hubSkills, setHubSkills] = useState([]); + + // ── Step 4: MCPs ────────────────────────────────────────────────── + const [mcpServers, setMcpServers] = useState([]); + const [mcpDraft, setMcpDraft] = useState<{ + name: string; + url: string; + command: string; + args: string; + }>({ name: "", url: "", command: "", args: "" }); + + // ── Submit ──────────────────────────────────────────────────────── + const [creating, setCreating] = useState(false); + + const nameValid = PROFILE_NAME_RE.test(name.trim()); + + // Lazy-load model choices when the model step is first shown. + const loadModels = useCallback(() => { + if (modelChoices !== null || modelLoading.current) return; + modelLoading.current = true; + api + .getModelOptions() + .then((res) => { + const flat: ModelChoice[] = []; + for (const prov of res.providers ?? []) { + for (const m of prov.models ?? []) { + flat.push({ provider: prov.slug, model: m, label: `${prov.name} · ${m}` }); + } + } + setModelChoices(flat); + }) + .catch(() => setModelChoices([])) + .finally(() => { + modelLoading.current = false; + }); + }, [modelChoices]); + + const loadSkills = useCallback(() => { + if (skills !== null || skillsLoading.current) return; + skillsLoading.current = true; + api + .getSkills() + .then((res) => { + setSkills(res); + // Default keep = all currently-enabled skills (matches the seeded set). + setKeptSkills(new Set(res.filter((s) => s.enabled).map((s) => s.name))); + }) + .catch(() => setSkills([])) + .finally(() => { + skillsLoading.current = false; + }); + }, [skills]); + + useEffect(() => { + if (step === "model") loadModels(); + if (step === "skills") loadSkills(); + }, [step, loadModels, loadSkills]); + + const runHubSearch = useCallback(() => { + const q = hubQuery.trim(); + if (!q) return; + setHubSearching(true); + api + .searchSkillsHub(q, "all", 20) + .then((res) => setHubResults(res.results ?? [])) + .catch(() => setHubResults([])) + .finally(() => setHubSearching(false)); + }, [hubQuery]); + + const toggleKeep = (skillName: string) => { + setKeptSkills((prev) => { + const next = new Set(prev); + if (next.has(skillName)) next.delete(skillName); + else next.add(skillName); + return next; + }); + }; + + const addHubSkill = (r: SkillHubResult) => { + setHubSkills((prev) => + prev.some((x) => x.identifier === r.identifier) ? prev : [...prev, r], + ); + }; + const removeHubSkill = (identifier: string) => + setHubSkills((prev) => prev.filter((x) => x.identifier !== identifier)); + + const addMcpDraft = () => { + const n = mcpDraft.name.trim(); + if (!n) { + showToast("MCP server needs a name", "error"); + return; + } + if (!mcpDraft.url.trim() && !mcpDraft.command.trim()) { + showToast("Give the MCP server a URL or a command", "error"); + return; + } + const entry: McpServerCreate = { name: n }; + if (mcpDraft.url.trim()) entry.url = mcpDraft.url.trim(); + if (mcpDraft.command.trim()) { + entry.command = mcpDraft.command.trim(); + const args = mcpDraft.args.trim(); + if (args) entry.args = args.split(/\s+/); + } + setMcpServers((prev) => [...prev.filter((s) => s.name !== n), entry]); + setMcpDraft({ name: "", url: "", command: "", args: "" }); + }; + const removeMcp = (n: string) => + setMcpServers((prev) => prev.filter((s) => s.name !== n)); + + const filteredModels = useMemo(() => { + if (!modelChoices) return []; + const f = modelFilter.trim().toLowerCase(); + if (!f) return modelChoices; + return modelChoices.filter((c) => c.label.toLowerCase().includes(f)); + }, [modelChoices, modelFilter]); + + const filteredSkills = useMemo(() => { + if (!skills) return []; + const f = skillFilter.trim().toLowerCase(); + if (!f) return skills; + return skills.filter( + (s) => + s.name.toLowerCase().includes(f) || + (s.description || "").toLowerCase().includes(f) || + (s.category || "").toLowerCase().includes(f), + ); + }, [skills, skillFilter]); + + const pickedModel = useMemo( + () => + modelChoice + ? modelChoices?.find((c) => `${c.provider}\u0000${c.model}` === modelChoice) + : undefined, + [modelChoice, modelChoices], + ); + + const handleCreate = async () => { + const n = name.trim(); + if (!PROFILE_NAME_RE.test(n)) { + showToast("Invalid profile name (lowercase, digits, - and _)", "error"); + setStep("identity"); + return; + } + setCreating(true); + try { + const res = await api.createProfile({ + name: n, + clone_from_default: false, + description: description.trim() || undefined, + provider: pickedModel?.provider, + model: pickedModel?.model, + mcp_servers: mcpServers.length ? mcpServers : undefined, + keep_skills: keepAll ? undefined : Array.from(keptSkills), + hub_skills: hubSkills.length ? hubSkills.map((s) => s.identifier) : undefined, + }); + const pending = (res.hub_installs ?? []).filter((h) => h.pid).length; + showToast( + pending + ? `Profile "${n}" created — ${pending} hub skill${pending === 1 ? "" : "s"} installing` + : `Profile "${n}" created`, + "success", + ); + navigate("/profiles"); + } catch (e) { + showToast(`Create failed: ${e}`, "error"); + } finally { + setCreating(false); + } + }; + + const stepIndex = STEPS.findIndex((s) => s.id === step); + const canAdvance = step !== "identity" || nameValid; + + return ( +
+
+

New profile

+ +
+ + {/* Stepper */} +
+ {STEPS.map((s, i) => ( + + ))} +
+ + + + {step === "identity" && ( +
+
+ + ) => setName(e.target.value)} + /> + {name && !nameValid && ( +

+ Lowercase letters, digits, hyphens and underscores; must start with a letter or digit. +

+ )} +
+
+ + ) => + setDescription(e.target.value) + } + /> +
+
+ )} + + {step === "model" && ( +
+

+ Pick the model+provider for this profile. Skip to use the default. +

+ ) => + setModelFilter(e.target.value) + } + /> + {modelChoices === null ? ( +

Loading models…

+ ) : ( +
+ + {filteredModels.map((c) => { + const key = `${c.provider}\u0000${c.model}`; + return ( + + ); + })} +
+ )} +
+ )} + + {step === "skills" && ( +
+ + {!keepAll && ( +
+

+ Choose which built-in / optional skills to keep active. Unchecked skills are disabled in the new profile. +

+ ) => + setSkillFilter(e.target.value) + } + /> + {skills === null ? ( +

Loading skills…

+ ) : ( +
+ {filteredSkills.map((s) => ( + + ))} +
+ )} +
+ )} + + {/* Skills hub */} +
+ +
+ ) => + setHubQuery(e.target.value) + } + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === "Enter") runHubSearch(); + }} + /> + +
+ {hubResults.length > 0 && ( +
+ {hubResults.map((r) => ( +
+ + {r.name} + + {r.source} + + {r.description && ( + + {r.description} + + )} + + +
+ ))} +
+ )} + {hubSkills.length > 0 && ( +
+ {hubSkills.map((r) => ( + + {r.name} + + + ))} +
+ )} +
+
+ )} + + {step === "mcp" && ( +
+

+ Add MCP servers for this profile. HTTP servers take a URL; stdio servers take a command + args. +

+
+ ) => + setMcpDraft({ ...mcpDraft, name: e.target.value }) + } + /> + ) => + setMcpDraft({ ...mcpDraft, url: e.target.value }) + } + /> + ) => + setMcpDraft({ ...mcpDraft, command: e.target.value }) + } + /> + ) => + setMcpDraft({ ...mcpDraft, args: e.target.value }) + } + /> +
+ + {mcpServers.length > 0 && ( +
+ {mcpServers.map((s) => ( +
+ + {s.name}{" "} + + {s.url || `${s.command} ${(s.args || []).join(" ")}`} + + + +
+ ))} +
+ )} +
+ )} + + {step === "review" && ( +
+ + + + + {!keepAll && hubSkills.length > 0 && ( +

+ Hub: {hubSkills.map((s) => s.name).join(", ")} +

+ )} + {keepAll && hubSkills.length > 0 && ( + s.name).join(", ")} + /> + )} + s.name).join(", ") : "None"} + /> +
+ )} +
+
+ + {/* Nav buttons */} +
+ + {step === "review" ? ( + + ) : ( + + )} +
+ + +
+ ); +} + +function ReviewRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/web/src/pages/ProfilesPage.tsx b/web/src/pages/ProfilesPage.tsx index add35a15383..463e5a4120b 100644 --- a/web/src/pages/ProfilesPage.tsx +++ b/web/src/pages/ProfilesPage.tsx @@ -6,6 +6,7 @@ import { useRef, useState, } from "react"; +import { useNavigate } from "react-router-dom"; import { AlignLeft, Check, @@ -246,6 +247,7 @@ export default function ProfilesPage() { const { toast, showToast } = useToast(); const { t } = useI18n(); const { setEnd } = usePageHeader(); + const navigate = useNavigate(); // Locale strings with English fallbacks. The enriched keys are optional in // the i18n type so untranslated locales don't break the build — they render @@ -722,21 +724,31 @@ export default function ProfilesPage() { : base; })(); - // Put "Create" button in page header + // Put "Build" (full builder) + "Create" (quick modal) buttons in header useLayoutEffect(() => { setEnd( - , +
+ + +
, ); return () => { setEnd(null); }; - }, [setEnd, t.common.create, loading]); + }, [setEnd, t.common.create, loading, navigate]); const cloning = cloneAll || cloneFromDefault;