"use client"; import * as React from "react"; import { AlertTriangleIcon, ChevronRightIcon, FilePlusIcon, FileTextIcon, FolderIcon, FolderOpenIcon, FolderPlusIcon, PlusIcon, Trash2Icon, UploadIcon, } from "lucide-react"; import { useTranslations } from "next-intl"; import { toast } from "sonner"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { api } from "@/lib/api"; import type { Agent, MCPServer, MissingSkill, SkillCall, SkillItem } from "@/lib/types"; import { cn } from "@/lib/utils"; function fmtTime(ts: string | undefined, never: string) { if (!ts) return never; return new Date(ts).toLocaleString("ko-KR", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", }); } // ── Tree node ────────────────────────────────────────────────────────────── interface TreeNode { name: string; path: string; // relative to skill root, dirs WITHOUT trailing slash type: "file" | "dir"; children: TreeNode[]; } // Backend returns dirs as "scripts/" (trailing slash) and files as "scripts/a.py". function buildTree(entries: string[]): TreeNode[] { const root: TreeNode[] = []; const dirMap = new Map(); function ensureDir(dirPath: string, parentNodes: TreeNode[]): TreeNode[] { if (dirMap.has(dirPath)) return dirMap.get(dirPath)!.children; const parts = dirPath.split("/"); let nodes = root; let cur = ""; for (const part of parts) { cur = cur ? `${cur}/${part}` : part; if (!dirMap.has(cur)) { const d: TreeNode = { name: part, path: cur, type: "dir", children: [] }; nodes.push(d); dirMap.set(cur, d); } nodes = dirMap.get(cur)!.children; } return nodes; } for (const entry of [...entries].sort()) { if (entry.endsWith("/")) { // Explicit directory entry — ensure the node exists (may already be created) ensureDir(entry.slice(0, -1), root); } else { // File — ensure parent dirs exist, then push file node const parts = entry.split("/"); let nodes = root; let cur = ""; for (let i = 0; i < parts.length - 1; i++) { cur = cur ? `${cur}/${parts[i]}` : parts[i]; if (!dirMap.has(cur)) { const d: TreeNode = { name: parts[i], path: cur, type: "dir", children: [] }; nodes.push(d); dirMap.set(cur, d); } nodes = dirMap.get(cur)!.children; } const fname = parts[parts.length - 1]; if (fname) nodes.push({ name: fname, path: entry, type: "file", children: [] }); } } sortNodes(root); return root; } // sortNodes orders every level like a file explorer: directories before files, // then case-insensitive by name. Recurses into children. function sortNodes(nodes: TreeNode[]): void { nodes.sort((a, b) => { if (a.type !== b.type) return a.type === "dir" ? -1 : 1; return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); }); for (const n of nodes) { if (n.children.length > 0) sortNodes(n.children); } } // ── State types ─────────────────────────────────────────────────────────── type Selected = { skill: string; path: null } | { skill: string; path: string }; type Creating = { skill: string; inDir: string; kind: "file" | "dir"; } | null; type PendingDelete = | { kind: "skill"; skill: string } | { kind: "file"; skill: string; path: string } | { kind: "dir"; skill: string; path: string } | null; // ── Overview (empty-state) ────────────────────────────────────────────────── // Shown when nothing is selected: a library-wide snapshot from data already loaded // (the skill list carries per-skill usage; missing is fetched alongside). No extra // requests — this is pure aggregation over props. function SkillsOverview({ skills, missing, onSelect, }: { skills: SkillItem[]; missing: MissingSkill[]; onSelect: (name: string) => void; }) { const t = useTranslations("skillsPage"); const agg = React.useMemo(() => { const totalCalls = skills.reduce((n, s) => n + s.calls, 0); const used = skills.filter((s) => s.calls > 0); const ranked = [...used].sort((a, b) => b.calls - a.calls); const neverUsed = skills.filter((s) => s.calls === 0); const recent = skills .filter((s) => s.last_used) .sort((a, b) => (a.last_used! < b.last_used! ? 1 : -1)) .slice(0, 6); const missingCalls = missing.reduce((n, m) => n + m.calls, 0); return { totalCalls, usedCount: used.length, ranked, topCalls: ranked[0]?.calls ?? 0, neverUsed, recent, missingCalls, }; }, [skills, missing]); if (skills.length === 0) { return (

{t("emptyOverview")}

); } const stats: { label: string; value: React.ReactNode; hint?: string }[] = [ { label: t("statTotal"), value: skills.length, hint: t("statTotalHint", { count: agg.usedCount }) }, { label: t("statCalls"), value: agg.totalCalls }, { label: t("statUnused"), value: agg.neverUsed.length, hint: agg.neverUsed.length > 0 ? t("statUnusedHintSome") : t("statUnusedHintNone"), }, { label: t("statMissing"), value: agg.missingCalls, hint: missing.length > 0 ? t("statMissingHintSome", { count: missing.length }) : t("statMissingHintNone"), }, ]; return (

{t("overviewTitle")}

{t("overviewDesc")}

{/* 指标卡 */}
{stats.map((s) => (

{s.value}

{s.label}

{s.hint &&

{s.hint}

}
))}
{/* 调用排行 */}
{agg.ranked.length === 0 ? (

{t("rankingEmpty")}

) : (
{agg.ranked.slice(0, 8).map((s) => ( ))}
)}
{/* 最近调用 */}
{agg.recent.length === 0 ? (

{t("recentEmpty")}

) : (
{agg.recent.map((s) => ( ))}
)}
{/* 未使用(可清理 / 需曝光) */}
{agg.neverUsed.length === 0 ? (

{t("allUsed")}

) : (
{agg.neverUsed.map((s) => ( ))}
)}
); } // ── Page ────────────────────────────────────────────────────────────────── export default function SkillsPage() { const t = useTranslations("skillsPage"); const [skills, setSkills] = React.useState([]); const [agents, setAgents] = React.useState([]); const [selected, setSelected] = React.useState(null); const [visibility, setVisibility] = React.useState>({}); const [expanded, setExpanded] = React.useState>(new Set()); // file editor const [fileContent, setFileContent] = React.useState(""); const [fileLoading, setFileLoading] = React.useState(false); const [dirty, setDirty] = React.useState(false); const [saving, setSaving] = React.useState(false); // inline create — using a ref to avoid stale closure in onBlur const [creating, setCreating] = React.useState(null); const [newEntryName, setNewEntryName] = React.useState(""); const inlineRef = React.useRef(null); // cancelRef prevents onBlur from committing when Escape was pressed const cancelRef = React.useRef(false); // new skill dialog const [newOpen, setNewOpen] = React.useState(false); const [newName, setNewName] = React.useState(""); const [newDesc, setNewDesc] = React.useState(""); const [newLicense, setNewLicense] = React.useState(""); const [newCompat, setNewCompat] = React.useState(""); const [newInst, setNewInst] = React.useState(""); const [newMcps, setNewMcps] = React.useState([]); const [newVisibility, setNewVisibility] = React.useState([]); const [mcpOptions, setMcpOptions] = React.useState([]); const [creatingSkill, setCreatingSkill] = React.useState(false); const [uploading, setUploading] = React.useState(false); const uploadRef = React.useRef(null); // skill detail — MCP edit state (optimistic, rolls back on error) const [detailMcps, setDetailMcps] = React.useState([]); // delete confirmation const [pendingDelete, setPendingDelete] = React.useState(null); const [deleting, setDeleting] = React.useState(false); // 调用统计:列表页的次数/最近调用随 api.skills() 一起回来;选中某个 skill 时再拉它的 // 最近调用明细。missing = 被点名但不存在的 skill(想用但没有)。 const [usageCalls, setUsageCalls] = React.useState([]); const [usageLoading, setUsageLoading] = React.useState(false); const [missing, setMissing] = React.useState([]); // ── Data ────────────────────────────────────────────────────────────────── const load = React.useCallback(() => { api .agents() .then(setAgents) .catch(() => { /* 조회 실패는 무시하고 기본 상태를 유지한다 */ }); api .mcpServers() .then(setMcpOptions) .catch(() => { /* 조회 실패는 무시하고 기본 상태를 유지한다 */ }); api .missingSkills() .then(setMissing) .catch(() => { /* 조회 실패는 무시하고 기본 상태를 유지한다 */ }); api .skills() .then((ss) => { setSkills(ss); ss.forEach((s) => { void api .skillVisibility(s.name) .then((ids) => setVisibility((v) => ({ ...v, [s.name]: ids }))) .catch(() => { /* 가시성 조회 실패는 무시한다 */ }); }); }) .catch(() => { /* 조회 실패는 무시하고 기본 상태를 유지한다 */ }); }, []); React.useEffect(() => { load(); }, [load]); // ── Upload a .zip skill ─────────────────────────────────────────────────── async function uploadZip(file: File, overwrite = false) { setUploading(true); try { const r = await api.uploadSkill(file, overwrite); toast.success(t("installed", { name: r.name, files: r.files })); load(); } catch (e) { const msg = (e as Error).message; // offer overwrite when the skill already exists // (server_mgmt.go errMgmtSkillExistsPre 와 짝 맞춘 교차 스택 마커) if (!overwrite && msg.includes("이미 존재")) { if (window.confirm(t("overwriteConfirm", { msg }))) { await uploadZip(file, true); return; } } else { toast.error(t("uploadFailed", { msg })); } } finally { setUploading(false); } } function onUploadPick(e: React.ChangeEvent) { const f = e.target.files?.[0]; e.target.value = ""; // reset so picking the same file again re-fires if (f) void uploadZip(f); } React.useEffect(() => { if (creating) { // Small timeout so the element is actually in the DOM before focusing setTimeout(() => inlineRef.current?.focus(), 20); } }, [creating]); // Sync detail-panel MCP state when the selected skill changes. React.useEffect(() => { if (!selected || selected.path !== null) return; const sk = skills.find((s) => s.name === selected.skill); setDetailMcps(sk?.mcps ?? []); }, [selected, skills]); // Recent calls for the selected skill (detail panel only). React.useEffect(() => { if (!selected || selected.path !== null) { setUsageCalls([]); return; } const name = selected.skill; setUsageLoading(true); api .skillUsage(name, 20) .then((calls) => setUsageCalls(calls)) .catch(() => setUsageCalls([])) .finally(() => setUsageLoading(false)); }, [selected]); React.useEffect(() => { if (!selected || selected.path === null) { setFileContent(""); setDirty(false); return; } setFileLoading(true); api .readSkillFile(selected.skill, selected.path) .then((c) => { setFileContent(c); setDirty(false); }) .catch(() => toast.error(t("readFileFailed"))) .finally(() => setFileLoading(false)); }, [selected, t]); // ── Expand helpers ──────────────────────────────────────────────────────── function toggleExpanded(key: string) { setExpanded((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); } function ensureExpanded(skill: string, dirPath: string) { setExpanded((prev) => { const next = new Set(prev); next.add(skill); if (dirPath) { let cur = ""; for (const part of dirPath.split("/")) { cur = cur ? `${cur}/${part}` : part; next.add(`${skill}:${cur}`); } } return next; }); } // ── Inline create ───────────────────────────────────────────────────────── function startCreate(skill: string, inDir: string, kind: "file" | "dir") { cancelRef.current = false; ensureExpanded(skill, inDir); setCreating({ skill, inDir, kind }); setNewEntryName(""); } // Use a ref snapshot so commitCreate reads the latest creating value // without depending on potentially stale closure state. const creatingRef = React.useRef(null); React.useEffect(() => { creatingRef.current = creating; }, [creating]); const newEntryRef = React.useRef(""); React.useEffect(() => { newEntryRef.current = newEntryName; }, [newEntryName]); async function commitCreate() { if (cancelRef.current) { cancelRef.current = false; return; } const c = creatingRef.current; const name = newEntryRef.current.trim(); setCreating(null); setNewEntryName(""); if (!c || !name) return; const fullPath = c.inDir ? `${c.inDir}/${name}` : name; try { if (c.kind === "dir") { await api.createSkillDir(c.skill, fullPath); toast.success(t("folderCreated", { path: fullPath })); ensureExpanded(c.skill, fullPath); } else { await api.writeSkillFile(c.skill, fullPath, ""); toast.success(t("fileCreated", { path: fullPath })); setSelected({ skill: c.skill, path: fullPath }); ensureExpanded(c.skill, c.inDir); } load(); } catch (e) { toast.error(t("createFailed", { msg: (e as Error).message })); } } function cancelCreate() { cancelRef.current = true; setCreating(null); setNewEntryName(""); } async function deletePath(skill: string, path: string) { try { await api.deleteSkillPath(skill, path); toast.success(t("deleted", { path })); if (selected?.skill === skill && selected.path === path) setSelected(null); load(); } catch (e) { toast.error(t("deleteFailed", { msg: (e as Error).message })); } } async function deleteSkill(name: string) { try { await api.deleteSkill(name); toast.success(t("skillDeleted", { name })); if (selected?.skill === name) setSelected(null); load(); } catch (e) { toast.error(t("deleteFailed", { msg: (e as Error).message })); } } async function runPendingDelete() { const p = pendingDelete; if (!p) return; setDeleting(true); try { if (p.kind === "skill") await deleteSkill(p.skill); else await deletePath(p.skill, p.path); } finally { setDeleting(false); setPendingDelete(null); } } async function saveFile() { if (!selected || selected.path === null) return; setSaving(true); try { await api.writeSkillFile(selected.skill, selected.path, fileContent); toast.success(t("saved")); setDirty(false); } catch (e) { toast.error(t("saveFailed", { msg: (e as Error).message })); } finally { setSaving(false); } } async function toggleSkillMcp(skillName: string, mcpName: string, mcpOn: boolean) { const next = mcpOn ? [...detailMcps, mcpName] : detailMcps.filter((n) => n !== mcpName); setDetailMcps(next); try { await api.updateSkillMeta(skillName, { mcps: next }); toast.success(mcpOn ? t("mcpLinked", { name: mcpName }) : t("mcpUnlinked", { name: mcpName })); load(); } catch (e) { // roll back on error setDetailMcps(detailMcps); toast.error(t("opFailed", { msg: (e as Error).message })); } } async function toggleVisibility(skillName: string, agentId: string, agentName: string) { const on = (visibility[skillName] ?? []).includes(agentId); try { await api.toggleSkillVisibility(agentId, skillName, !on); toast.success(on ? t("visRevoked", { name: agentName }) : t("visGranted", { name: agentName })); const ids = await api.skillVisibility(skillName); setVisibility((v) => ({ ...v, [skillName]: ids })); } catch (e) { toast.error(t("opFailed", { msg: (e as Error).message })); } } async function createNewSkill() { if (!newName.trim()) { toast.error(t("needName")); return; } if (!newDesc.trim()) { toast.error(t("needDesc")); return; } setCreatingSkill(true); try { const name = newName.trim(); await api.createSkill({ name, description: newDesc.trim(), license: newLicense.trim() || undefined, compatibility: newCompat.trim() || undefined, mcps: newMcps.length ? newMcps : undefined, instructions: newInst.trim() || undefined, }); // apply initial visibility (fire-and-forget per agent; best-effort) await Promise.all(newVisibility.map((id) => api.toggleSkillVisibility(id, name, true))); toast.success(t("skillCreated")); setNewOpen(false); setNewName(""); setNewDesc(""); setNewLicense(""); setNewCompat(""); setNewInst(""); setNewMcps([]); setNewVisibility([]); load(); } catch (e) { toast.error(t("createFailed", { msg: (e as Error).message })); } finally { setCreatingSkill(false); } } // ── Inline input JSX helper (NOT a React component — avoids remount on re-render) ── // Defined as a plain function returning JSX so React never sees a new component type. function inlineInputJSX(indent: number) { return (
{creating?.kind === "dir" ? ( ) : ( )} setNewEntryName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { cancelRef.current = false; void commitCreate(); } if (e.key === "Escape") cancelCreate(); }} onBlur={() => { void commitCreate(); }} />
); } // ── Recursive tree renderer ─────────────────────────────────────────────── function renderTree(nodes: TreeNode[], skill: string, depth: number): React.ReactNode { // depth+1: the skill root sits at 8px (px-2); its children indent one step // further in so the tree reads as nested under the skill folder. const baseIndent = 8 + (depth + 1) * 14; return nodes.map((node) => { if (node.type === "dir") { const key = `${skill}:${node.path}`; const open = expanded.has(key); return (
toggleExpanded(key)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleExpanded(key); } }} > {open ? ( ) : ( )} {node.name} {/* Absolute so a long name can never push the actions out of view */}
{open && ( <> {renderTree(node.children, skill, depth + 1)} {creating?.skill === skill && creating.inDir === node.path && inlineInputJSX(baseIndent + 14)} )}
); } // file node const isSelected = selected?.skill === skill && selected.path === node.path; return (
setSelected({ skill, path: node.path })} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setSelected({ skill, path: node.path }); } }} > {node.name}
); }); } const selectedSkill = selected ? (skills.find((s) => s.name === selected.skill) ?? null) : null; return (

{t("title")}

{t("subtitle")}

{/* 缺口清单:agent 点名调用、但库里没有的 skill —— 直接是该补什么的依据。 */} {missing.length > 0 && (

{t("missingHint")}

{missing.map((m) => (
{m.skill} {t("callsCount", { count: m.calls })} {fmtTime(m.last_used, t("neverCalled"))}
))}
)}
{/* ── 左侧文件树 ── */}
{/* Radix viewport wraps children in a display:table div that grows with content — force it to block so long names truncate instead of widening the rows past the sidebar. */}
{skills.map((s) => { const isOpen = expanded.has(s.name); const tree = buildTree(s.files); const isSkillSelected = selected?.skill === s.name && selected.path === null; return (
{/* skill 根节点 */}
{ toggleExpanded(s.name); setSelected({ skill: s.name, path: null }); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleExpanded(s.name); setSelected({ skill: s.name, path: null }); } }} > {isOpen ? ( ) : ( )} {s.name} {s.calls > 0 && ( {s.calls} )}
{/* 展开:递归文件树 */} {isOpen && ( <> {renderTree(tree, s.name, 0)} {creating?.skill === s.name && creating.inDir === "" && inlineInputJSX(22)} )}
); })} {skills.length === 0 &&

{t("emptySidebar")}

}
{/* ── 右侧面板 ── */}
{!selected && ( setSelected({ skill: name, path: null })} /> )} {selected && selected.path === null && selectedSkill && (

{selectedSkill.name}

{selectedSkill.description && (

{selectedSkill.description}

)}
{selectedSkill.license && ( License: {selectedSkill.license} )} {selectedSkill.compatibility && ( {selectedSkill.compatibility} )}
{/* 左右分栏:配置(MCP/可见性)在左为主,调用统计在右为辅。 lg 以下放不下时用 flex-row-reverse 回落到单列——统计因 DOM 顺序在前, 窄屏时自然落到配置上方(与改版前的上下顺序一致)。 */}
{/* ── 右侧:调用统计 ── */}

{selectedSkill.calls}

{t("totalCalls")}

{selectedSkill.tasks}

{t("coveredTasks")}

{fmtTime(selectedSkill.last_used, t("neverCalled"))}

{t("recent")}

{selectedSkill.usage_agents.length > 0 && (
{t("callers")} {selectedSkill.usage_agents.map((k) => ( {k} ))}
)} {usageLoading ? (

{t("loadingCalls")}

) : usageCalls.length > 0 ? (
{t("recentCallsCount", { count: usageCalls.length })}
{usageCalls.map((c, i) => (
{fmtTime(c.ts, t("neverCalled"))} {c.agent_key || "—"} {c.task_id > 0 ? t("taskRef", { id: c.task_id }) : c.session_id ? t("sessionRef") : "—"}
))}
) : (

{t("noCallsYet")}

)}
{/* ── 左侧:关联 MCP + 可见性 ── */}
{mcpOptions.length === 0 ? (

{t("noMcp")}

) : (
{mcpOptions.map((m) => ( ))}
)}
{agents.map((a) => ( ))} {agents.length === 0 && {t("noAgents")}}
)} {selected && selected.path !== null && (
{selected.skill} / {selected.path}
{fileLoading ? (

{t("loading")}

) : (