import * as React from "react"; import { useTranslation } from "react-i18next"; import { CheckCircle2, Copy, FileText, Link2, Pencil, Plus, Puzzle, RefreshCw, Search, Trash2, Wrench, X } from "lucide-react"; import { createSkill, deleteSkill, listAllSkills, listToolConnections, listTools, updateSkill } from "@/api"; import { translateApiError } from "@/api/errors"; import { ApiErrorState } from "@/components/shared/ApiErrorState"; import { EmptyState } from "@/components/shared/EmptyState"; import { LoadingSpinner } from "@/components/shared/LoadingSpinner"; import { MetricCard } from "@/components/shared/MetricCard"; import { PageHeader } from "@/components/shared/PageHeader"; import { SearchInput } from "@/components/shared/SearchInput"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog } from "@/components/ui/dialog"; import { Input, Textarea } from "@/components/ui/input"; import { Select } from "@/components/ui/select"; import { toast } from "@/components/ui/toaster"; import type { SkillDefinition, ToolConnection, ToolDefinition } from "@/types"; type ToolOption = { id: string; name: string; description: string; toolType: string; exposedTools: Array<{ name: string; description?: string; }>; }; type SkillFormState = { name: string; description: string; instruction: string; category: string; selectedToolIds: string[]; }; const emptyForm: SkillFormState = { name: "", description: "", instruction: "", category: "service", selectedToolIds: [], }; export function SkillsPage() { const { t } = useTranslation(); const [skills, setSkills] = React.useState([]); const [tools, setTools] = React.useState([]); const [search, setSearch] = React.useState(""); const [categoryFilter, setCategoryFilter] = React.useState("all"); const [createOpen, setCreateOpen] = React.useState(false); const [editOpen, setEditOpen] = React.useState(false); const [editingSkill, setEditingSkill] = React.useState(); const [form, setForm] = React.useState(emptyForm); const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); const [error, setError] = React.useState(); const toolById = React.useMemo(() => new Map(tools.map((tool) => [tool.id, tool])), [tools]); const categories = Array.from(new Set(skills.map((skill) => skill.category || "service"))).sort(); const filtered = skills .filter((skill) => skill.status !== "archived") .filter((skill) => { const toolNames = skill.toolIds.map((toolId) => toolOptionSearchText(toolById.get(toolId)) || toolId).join(" "); const text = `${skill.name} ${skill.description ?? ""} ${skill.category} ${skill.instruction} ${toolNames}`.toLowerCase(); return text.includes(search.toLowerCase()) && (categoryFilter === "all" || skill.category === categoryFilter); }) .sort((first, second) => first.name.localeCompare(second.name)); const boundToolsCount = filtered.reduce((count, skill) => count + skill.toolIds.length, 0); const load = React.useCallback(async () => { setLoading(true); setError(undefined); try { const [skillItems, toolItems, connectionItems] = await Promise.all([ listAllSkills(), listTools().catch(() => [] as ToolDefinition[]), listToolConnections().catch(() => [] as ToolConnection[]), ]); const connectionByTool = new Map(); connectionItems.forEach((connection) => { if (!connectionByTool.has(connection.tool_id)) { connectionByTool.set(connection.tool_id, connection); } }); const mappedTools = toolItems.map((tool) => ({ id: tool.id, name: tool.name, description: tool.description ?? tool.tool_type, toolType: tool.tool_type, exposedTools: getMcpExposedTools(connectionByTool.get(tool.id)), })); setSkills(skillItems); setTools(mappedTools); } catch (err) { setError(translateApiError(err)); } finally { setLoading(false); } }, [t]); React.useEffect(() => { void load(); }, [load]); function openCreate() { setForm(emptyForm); setCreateOpen(true); } function openEdit(skill: SkillDefinition) { setEditingSkill(skill); setForm(fromSkill(skill)); setEditOpen(true); } async function createSkillFromForm() { if (!form.name.trim()) return; setSaving(true); try { const created = await createSkill({ name: form.name.trim(), description: form.description.trim() || null, category: form.category, instruction: form.instruction.trim(), toolIds: form.selectedToolIds, }); setSkills((current) => [created, ...current]); setCreateOpen(false); toast.success(t("skills.created")); } catch (err) { toast.error(translateApiError(err)); } finally { setSaving(false); } } async function updateSkillFromForm() { if (!editingSkill || !form.name.trim()) return; setSaving(true); try { const updated = await updateSkill({ skillId: editingSkill.id, name: form.name.trim(), description: form.description.trim() || null, category: form.category, instruction: form.instruction.trim(), toolIds: form.selectedToolIds, }); setSkills((current) => current.map((skill) => (skill.id === updated.id ? updated : skill))); setEditingSkill(updated); setEditOpen(false); toast.success(t("skills.saved")); } catch (err) { toast.error(translateApiError(err)); } finally { setSaving(false); } } async function removeSkill(skill: SkillDefinition) { try { await deleteSkill(skill.id); setSkills((current) => current.filter((item) => item.id !== skill.id)); toast.success(t("skills.deleted")); } catch (err) { toast.error(translateApiError(err)); } } if (loading) return ; if (error) return void load()} />; return (
} />
{t("skills.title")} {filtered.length} / {skills.filter((skill) => skill.status !== "archived").length}
{t("skills.selectTools")}
set("name", event.target.value)} placeholder={t("skills.namePlaceholder")} /> set("description", event.target.value)} placeholder={t("skills.descPlaceholder")} />

{t("skills.instruction")}

{t("skills.instructionBuilderHint", "Write when to use the skill, what tools to call, and what output to return.")}

{form.instruction.trim().length} {t("common.characters", "characters")}