import { useEffect, useLayoutEffect, useState, useMemo } from "react"; import { Package, Search, Wrench, X, Cpu, Globe, Shield, Eye, Paintbrush, Brain, Blocks, Code, Zap, Filter, } from "lucide-react"; import { api } from "@/lib/api"; import type { SkillInfo, ToolsetInfo } from "@/lib/api"; import { useToast } from "@/hooks/useToast"; import { Toast } from "@/components/Toast"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Button } from "@nous-research/ui/ui/components/button"; import { ListItem } from "@nous-research/ui/ui/components/list-item"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { Switch } from "@nous-research/ui/ui/components/switch"; import { cn } from "@/lib/utils"; import { Input } from "@/components/ui/input"; import { useI18n } from "@/i18n"; import { usePageHeader } from "@/contexts/usePageHeader"; import { PluginSlot } from "@/plugins"; /* ------------------------------------------------------------------ */ /* Types & helpers */ /* ------------------------------------------------------------------ */ const CATEGORY_LABELS: Record = { mlops: "MLOps", "mlops/cloud": "MLOps / Cloud", "mlops/evaluation": "MLOps / Evaluation", "mlops/inference": "MLOps / Inference", "mlops/models": "MLOps / Models", "mlops/training": "MLOps / Training", "mlops/vector-databases": "MLOps / Vector DBs", mcp: "MCP", "red-teaming": "Red Teaming", ocr: "OCR", p5js: "p5.js", ai: "AI", ux: "UX", ui: "UI", }; function prettyCategory( raw: string | null | undefined, generalLabel: string, ): string { if (!raw) return generalLabel; if (CATEGORY_LABELS[raw]) return CATEGORY_LABELS[raw]; return raw .split(/[-_/]/) .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .join(" "); } const TOOLSET_ICONS: Record< string, React.ComponentType<{ className?: string }> > = { computer: Cpu, web: Globe, security: Shield, vision: Eye, design: Paintbrush, ai: Brain, integration: Blocks, code: Code, automation: Zap, }; function toolsetIcon( name: string, ): React.ComponentType<{ className?: string }> { const lower = name.toLowerCase(); for (const [key, icon] of Object.entries(TOOLSET_ICONS)) { if (lower.includes(key)) return icon; } return Wrench; } /* ------------------------------------------------------------------ */ /* Component */ /* ------------------------------------------------------------------ */ export default function SkillsPage() { const [skills, setSkills] = useState([]); const [toolsets, setToolsets] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(""); const [view, setView] = useState<"skills" | "toolsets">("skills"); const [activeCategory, setActiveCategory] = useState(null); const [togglingSkills, setTogglingSkills] = useState>(new Set()); const { toast, showToast } = useToast(); const { t } = useI18n(); const { setAfterTitle, setEnd } = usePageHeader(); useEffect(() => { Promise.all([api.getSkills(), api.getToolsets()]) .then(([s, tsets]) => { setSkills(s); setToolsets(tsets); }) .catch(() => showToast(t.common.loading, "error")) .finally(() => setLoading(false)); }, []); /* ---- Toggle skill ---- */ const handleToggleSkill = async (skill: SkillInfo) => { setTogglingSkills((prev) => new Set(prev).add(skill.name)); try { await api.toggleSkill(skill.name, !skill.enabled); setSkills((prev) => prev.map((s) => s.name === skill.name ? { ...s, enabled: !s.enabled } : s, ), ); showToast( `${skill.name} ${skill.enabled ? t.common.disabled : t.common.enabled}`, "success", ); } catch { showToast(`${t.common.failedToToggle} ${skill.name}`, "error"); } finally { setTogglingSkills((prev) => { const next = new Set(prev); next.delete(skill.name); return next; }); } }; /* ---- Derived data ---- */ const lowerSearch = search.toLowerCase(); const isSearching = search.trim().length > 0; const searchMatchedSkills = useMemo(() => { if (!isSearching) return []; return skills.filter( (s) => s.name.toLowerCase().includes(lowerSearch) || s.description.toLowerCase().includes(lowerSearch) || (s.category ?? "").toLowerCase().includes(lowerSearch), ); }, [skills, isSearching, lowerSearch]); const activeSkills = useMemo(() => { if (isSearching) return []; if (!activeCategory) return [...skills].sort((a, b) => a.name.localeCompare(b.name)); return skills .filter((s) => activeCategory === "__none__" ? !s.category : s.category === activeCategory, ) .sort((a, b) => a.name.localeCompare(b.name)); }, [skills, activeCategory, isSearching]); const allCategories = useMemo(() => { const cats = new Map(); for (const s of skills) { const key = s.category || "__none__"; cats.set(key, (cats.get(key) || 0) + 1); } return [...cats.entries()] .sort((a, b) => { if (a[0] === "__none__") return -1; if (b[0] === "__none__") return 1; return a[0].localeCompare(b[0]); }) .map(([key, count]) => ({ key, name: prettyCategory(key === "__none__" ? null : key, t.common.general), count, })); }, [skills, t]); const enabledCount = skills.filter((s) => s.enabled).length; useLayoutEffect(() => { if (loading) { setAfterTitle(null); setEnd(null); return; } setAfterTitle( {t.skills.enabledOf .replace("{enabled}", String(enabledCount)) .replace("{total}", String(skills.length))} , ); setEnd(
setSearch(e.target.value)} /> {search && ( )}
, ); return () => { setAfterTitle(null); setEnd(null); }; }, [enabledCount, loading, search, setAfterTitle, setEnd, skills.length, t]); const filteredToolsets = useMemo(() => { return toolsets.filter( (ts) => !search || ts.name.toLowerCase().includes(lowerSearch) || ts.label.toLowerCase().includes(lowerSearch) || ts.description.toLowerCase().includes(lowerSearch), ); }, [toolsets, search, lowerSearch]); /* ---- Loading ---- */ if (loading) { return (
); } return (
{isSearching ? (
{t.skills.title} {t.skills.resultCount .replace("{count}", String(searchMatchedSkills.length)) .replace( "{s}", searchMatchedSkills.length !== 1 ? "s" : "", )}
{searchMatchedSkills.length === 0 ? (

{t.skills.noSkillsMatch}

) : (
{searchMatchedSkills.map((skill) => ( handleToggleSkill(skill)} noDescriptionLabel={t.skills.noDescription} /> ))}
)}
) : view === "skills" ? ( /* Skills list */
{activeCategory ? prettyCategory( activeCategory === "__none__" ? null : activeCategory, t.common.general, ) : t.skills.all} {t.skills.skillCount .replace("{count}", String(activeSkills.length)) .replace("{s}", activeSkills.length !== 1 ? "s" : "")}
{activeSkills.length === 0 ? (

{skills.length === 0 ? t.skills.noSkills : t.skills.noSkillsMatch}

) : (
{activeSkills.map((skill) => ( handleToggleSkill(skill)} noDescriptionLabel={t.skills.noDescription} /> ))}
)}
) : ( /* Toolsets grid */ <> {filteredToolsets.length === 0 ? ( {t.skills.noToolsetsMatch} ) : (
{filteredToolsets.map((ts) => { const TsIcon = toolsetIcon(ts.name); const labelText = ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || ts.name; return (
{labelText} {ts.enabled ? t.common.active : t.common.inactive}

{ts.description}

{ts.enabled && !ts.configured && (

{t.skills.setupNeeded}

)} {ts.tools.length > 0 && (
{ts.tools.map((tool) => ( {tool} ))}
)} {ts.tools.length === 0 && ( {ts.enabled ? t.skills.toolsetLabel.replace( "{name}", ts.name, ) : t.skills.disabledForCli} )}
); })}
)} )}
); } function SkillRow({ skill, toggling, onToggle, noDescriptionLabel, }: SkillRowProps) { return (
{skill.name}

{skill.description || noDescriptionLabel}

); } function PanelItem({ active, icon: Icon, label, onClick }: PanelItemProps) { return ( {label} ); } interface PanelItemProps { active: boolean; icon: React.ComponentType<{ className?: string }>; label: string; onClick: () => void; } interface SkillRowProps { noDescriptionLabel: string; onToggle: () => void; skill: SkillInfo; toggling: boolean; }