import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router"; 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 { McpHttpAuth, McpServerCreate, SkillInfo, SkillHubResult, } from "@/lib/api"; import { buildMcpServerCreate, emptyMcpServerDraft, type McpServerDraft, type McpTransport, } from "@/lib/mcp-server-create"; 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(emptyMcpServerDraft); // ── 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 = () => { let entry: McpServerCreate; try { entry = buildMcpServerCreate(mcpDraft); } catch (error) { showToast( error instanceof Error ? error.message : "Invalid MCP server", "error", ); return; } setMcpServers((prev) => [ ...prev.filter((server) => server.name !== entry.name), entry, ]); setMcpDraft(emptyMcpServerDraft()); }; const removeMcp = (n: string) => setMcpServers((prev) => prev.filter((s) => s.name !== n)); const setMcpTransport = (transport: McpTransport) => { setMcpDraft((draft) => transport === "http" ? { ...draft, transport, command: "", args: "", env: "" } : { ...draft, transport, url: "", httpAuth: "none", bearerToken: "", }, ); }; const setMcpHttpAuth = (httpAuth: McpHttpAuth) => { setMcpDraft((draft) => ({ ...draft, httpAuth, bearerToken: httpAuth === "header" ? draft.bearerToken : "", })); }; 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: null, 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" && (

MCP servers

Add MCP servers to give this profile access to external tools and data.

{mcpServers.length} configured

Add server

) => setMcpDraft({ ...mcpDraft, name: e.target.value }) } />
{( [ ["http", "HTTP/SSE"], ["stdio", "stdio"], ] as const ).map(([value, label]) => ( ))}
{mcpDraft.transport === "http" ? ( <>
) => setMcpDraft({ ...mcpDraft, url: e.target.value }) } />
{( [ ["none", "None"], ["header", "Bearer token"], ["oauth", "OAuth"], ] as const ).map(([value, label]) => ( ))}
{mcpDraft.httpAuth === "header" && (
) => setMcpDraft({ ...mcpDraft, bearerToken: e.target.value, }) } />

Stored in the new profile's .env; config.yaml keeps only an environment-variable reference.

)} {mcpDraft.httpAuth === "oauth" && (

After creating the profile, open its MCP page and use Authenticate to complete OAuth.

)} ) : ( <>
) => setMcpDraft({ ...mcpDraft, command: e.target.value, }) } />
) => setMcpDraft({ ...mcpDraft, args: e.target.value }) } />