feat(skills): 新增 Skill 调用统计(账本 + 页面总览/详情)
后端:
- 新表 skill_usage(无外键,任务/会话删除后统计保留),一次 Skill()
调用记一行,只记维度不记 args 正文;含 found 标记未命中调用
- 用 RunInfo 经 ctx 把 task/exploration/intent/session 传到工具装配层,
在 Skill meta-tool 外包一层做埋点(可记 args 长度与不存在的 skill)
- fsListSkills 返回附带调用次数/覆盖任务/最近调用;新增
GET /api/skills/missing 与 GET /api/skills/{name}/usage
前端(/system/skills):
- 树节点显示调用次数 badge;目录优先排序 + 子项缩进错开一级
- 空态改为技能库总览(指标卡 + 调用排行 + 最近/未使用)
- 选中 skill 的详情面板改为响应式左右分栏(配置在左、统计在右,
窄屏回落上下),含最近调用明细与未命中缺口清单
Claude-Session: https://claude.ai/code/session_01BNN7npEJs4X6LfTtgniMd8
This commit is contained in:
1 parent
43f40dd74f
commit
834e94535a
16 files changed
+831
-52
No files matched your search
@@ -12,6 +12,7 @@ import {
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
UploadIcon,
|
||||
AlertTriangleIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
@@ -29,6 +30,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -41,7 +43,17 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/lib/api";
|
||||
import type { Agent, SkillItem, MCPServer } from "@/lib/types";
|
||||
import type { Agent, SkillItem, MCPServer, SkillCall, MissingSkill } from "@/lib/types";
|
||||
|
||||
function fmtTime(ts?: string) {
|
||||
if (!ts) return "从未调用";
|
||||
return new Date(ts).toLocaleString("zh-CN", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tree node ──────────────────────────────────────────────────────────────
|
||||
interface TreeNode {
|
||||
@@ -95,9 +107,22 @@ function buildTree(entries: string[]): TreeNode[] {
|
||||
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 }
|
||||
@@ -115,6 +140,158 @@ type PendingDelete =
|
||||
| { 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 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 (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">暂无 Skill,点击左侧「新建」或「上传压缩包」开始</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stats: { label: string; value: React.ReactNode; hint?: string }[] = [
|
||||
{ label: "Skill 总数", value: skills.length, hint: `${agg.usedCount} 个被调用过` },
|
||||
{ label: "累计调用", value: agg.totalCalls },
|
||||
{ label: "未使用", value: agg.neverUsed.length, hint: agg.neverUsed.length > 0 ? "从未被任何 agent 加载" : "全部用过" },
|
||||
{ label: "未命中调用", value: agg.missingCalls, hint: missing.length > 0 ? `${missing.length} 个不存在的 skill` : "无" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-3xl space-y-6">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">技能库总览</h2>
|
||||
<p className="text-muted-foreground text-sm">选择左侧的 Skill 查看详情与调用记录,或从这里快速了解整体使用情况。</p>
|
||||
</div>
|
||||
|
||||
{/* 指标卡 */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} className="rounded-lg border p-3">
|
||||
<p className="text-2xl font-semibold tabular-nums">{s.value}</p>
|
||||
<p className="text-xs font-medium">{s.label}</p>
|
||||
{s.hint && <p className="text-muted-foreground mt-0.5 text-[11px]">{s.hint}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 调用排行 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">调用排行</Label>
|
||||
{agg.ranked.length === 0 ? (
|
||||
<p className="text-muted-foreground rounded-lg border border-dashed p-4 text-center text-xs">
|
||||
还没有任何 Skill 调用记录。
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{agg.ranked.slice(0, 8).map((s) => (
|
||||
<button
|
||||
key={s.name}
|
||||
type="button"
|
||||
onClick={() => onSelect(s.name)}
|
||||
className="group flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-left hover:bg-muted"
|
||||
>
|
||||
<span className="w-40 shrink-0 truncate font-mono text-xs" title={s.name}>{s.name}</span>
|
||||
<span className="relative h-2 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-primary/70"
|
||||
style={{ width: `${agg.topCalls > 0 ? (s.calls / agg.topCalls) * 100 : 0}%` }}
|
||||
/>
|
||||
</span>
|
||||
<span className="w-16 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
|
||||
{s.calls} 次
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
{/* 最近调用 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">最近调用</Label>
|
||||
{agg.recent.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">暂无记录。</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{agg.recent.map((s) => (
|
||||
<button
|
||||
key={s.name}
|
||||
type="button"
|
||||
onClick={() => onSelect(s.name)}
|
||||
className="flex w-full items-center gap-2 rounded px-1.5 py-1 text-left hover:bg-muted"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={s.name}>{s.name}</span>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">{fmtTime(s.last_used)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 未使用(可清理 / 需曝光) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
未使用的 Skill
|
||||
{agg.neverUsed.length > 0 && <span className="ml-1 font-normal">({agg.neverUsed.length})</span>}
|
||||
</Label>
|
||||
{agg.neverUsed.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">所有 Skill 都被调用过。</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{agg.neverUsed.map((s) => (
|
||||
<button
|
||||
key={s.name}
|
||||
type="button"
|
||||
onClick={() => onSelect(s.name)}
|
||||
title={s.name}
|
||||
>
|
||||
<Badge variant="outline" className="max-w-[12rem] cursor-pointer truncate font-mono text-xs font-normal hover:bg-muted">
|
||||
{s.name}
|
||||
</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────
|
||||
export default function SkillsPage() {
|
||||
const [skills, setSkills] = React.useState<SkillItem[]>([]);
|
||||
@@ -157,10 +334,17 @@ export default function SkillsPage() {
|
||||
const [pendingDelete, setPendingDelete] = React.useState<PendingDelete>(null);
|
||||
const [deleting, setDeleting] = React.useState(false);
|
||||
|
||||
// 调用统计:列表页的次数/最近调用随 api.skills() 一起回来;选中某个 skill 时再拉它的
|
||||
// 最近调用明细。missing = 被点名但不存在的 skill(想用但没有)。
|
||||
const [usageCalls, setUsageCalls] = React.useState<SkillCall[]>([]);
|
||||
const [usageLoading, setUsageLoading] = React.useState(false);
|
||||
const [missing, setMissing] = React.useState<MissingSkill[]>([]);
|
||||
|
||||
// ── 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) =>
|
||||
@@ -215,6 +399,17 @@ export default function SkillsPage() {
|
||||
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);
|
||||
@@ -425,7 +620,9 @@ export default function SkillsPage() {
|
||||
|
||||
// ── Recursive tree renderer ───────────────────────────────────────────────
|
||||
function renderTree(nodes: TreeNode[], skill: string, depth: number): React.ReactNode {
|
||||
const baseIndent = 8 + depth * 14;
|
||||
// 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}`;
|
||||
@@ -505,9 +702,36 @@ export default function SkillsPage() {
|
||||
|
||||
return (
|
||||
<div data-content-padding="false" className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex flex-col gap-0.5 border-b px-4 py-2.5 lg:px-6">
|
||||
<h1 className="text-sm font-semibold leading-tight">Skill</h1>
|
||||
<p className="text-muted-foreground text-xs">技能库 · agentskills.io 规范 · 按 Agent 授权可见</p>
|
||||
<div className="flex items-center gap-3 border-b px-4 py-2.5 lg:px-6">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<h1 className="text-sm font-semibold leading-tight">Skill</h1>
|
||||
<p className="text-muted-foreground text-xs">技能库 · agentskills.io 规范 · 按 Agent 授权可见</p>
|
||||
</div>
|
||||
{/* 缺口清单:agent 点名调用、但库里没有的 skill —— 直接是该补什么的依据。 */}
|
||||
{missing.length > 0 && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="ml-auto">
|
||||
<AlertTriangleIcon className="size-3.5 text-amber-500" />
|
||||
{missing.length} 个未命中调用
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-80">
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Agent 点名调用、但技能库里不存在的 skill。按被点名次数排序。
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{missing.map((m) => (
|
||||
<div key={m.skill} className="flex items-center gap-2 text-sm">
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-xs" title={m.skill}>{m.skill}</code>
|
||||
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">{m.calls} 次</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{fmtTime(m.last_used)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* ── 左侧文件树 ── */}
|
||||
@@ -563,6 +787,14 @@ export default function SkillsPage() {
|
||||
: <FolderIcon className="size-3.5 shrink-0 text-blue-500" />
|
||||
}
|
||||
<span className="min-w-0 flex-1 truncate font-semibold" title={s.name}>{s.name}</span>
|
||||
{s.calls > 0 && (
|
||||
<span
|
||||
className="shrink-0 rounded bg-muted px-1 text-[10px] tabular-nums text-muted-foreground"
|
||||
title={`被调用 ${s.calls} 次 · 最近 ${fmtTime(s.last_used)}`}
|
||||
>
|
||||
{s.calls}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(
|
||||
"absolute inset-y-0 right-1 hidden items-center gap-0.5 rounded pl-1 group-hover:flex",
|
||||
isSkillSelected ? "bg-accent" : "bg-muted",
|
||||
@@ -604,13 +836,15 @@ export default function SkillsPage() {
|
||||
{/* ── 右侧面板 ── */}
|
||||
<div className="flex flex-1 flex-col overflow-auto p-4">
|
||||
{!selected && (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">选择左侧 Skill 或文件</p>
|
||||
</div>
|
||||
<SkillsOverview
|
||||
skills={skills}
|
||||
missing={missing}
|
||||
onSelect={(name) => setSelected({ skill: name, path: null })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selected && selected.path === null && selectedSkill && (
|
||||
<div className="max-w-xl space-y-5">
|
||||
<div className="max-w-5xl space-y-5">
|
||||
<div>
|
||||
<h2 className="font-mono text-base font-semibold">{selectedSkill.name}</h2>
|
||||
{selectedSkill.description && (
|
||||
@@ -626,45 +860,100 @@ export default function SkillsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 关联 MCP ── */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
关联 MCP
|
||||
<span className="ml-1 font-normal">(加载 Skill 时才披露/解锁其工具)</span>
|
||||
</Label>
|
||||
{mcpOptions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">暂无 MCP,可在「MCP」页添加。</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
||||
{mcpOptions.map((m) => (
|
||||
<label key={m.id} className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={detailMcps.includes(m.name)}
|
||||
onCheckedChange={(on) => toggleSkillMcp(selectedSkill.name, m.name, !!on)}
|
||||
/>
|
||||
{m.name}
|
||||
</label>
|
||||
))}
|
||||
{/* 左右分栏:配置(MCP/可见性)在左为主,调用统计在右为辅。
|
||||
lg 以下放不下时用 flex-row-reverse 回落到单列——统计因 DOM 顺序在前,
|
||||
窄屏时自然落到配置上方(与改版前的上下顺序一致)。 */}
|
||||
<div className="flex flex-col gap-6 lg:flex-row-reverse lg:items-start">
|
||||
{/* ── 右侧:调用统计 ── */}
|
||||
<div className="space-y-2 lg:w-80 lg:shrink-0">
|
||||
<Label className="text-xs text-muted-foreground">调用统计</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="rounded-md border p-2">
|
||||
<p className="text-lg font-semibold tabular-nums">{selectedSkill.calls}</p>
|
||||
<p className="text-xs text-muted-foreground">总调用次数</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-2">
|
||||
<p className="text-lg font-semibold tabular-nums">{selectedSkill.tasks}</p>
|
||||
<p className="text-xs text-muted-foreground">覆盖任务数</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-2">
|
||||
<p className="truncate text-sm font-medium" title={fmtTime(selectedSkill.last_used)}>
|
||||
{fmtTime(selectedSkill.last_used)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">最近调用</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 可见性 ── */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">可见性(按 Agent 授权)</Label>
|
||||
<div className="space-y-2">
|
||||
{agents.map((a) => (
|
||||
<label key={a.key} className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={(visibility[selectedSkill.name] ?? []).includes(a.id)}
|
||||
onCheckedChange={() => toggleVisibility(selectedSkill.name, a.id, a.name)}
|
||||
/>
|
||||
{a.name}
|
||||
</label>
|
||||
))}
|
||||
{agents.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">(暂无 Agent)</span>
|
||||
{selectedSkill.usage_agents.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">调用方:</span>
|
||||
{selectedSkill.usage_agents.map((k) => (
|
||||
<Badge key={k} variant="secondary" className="text-xs font-normal">{k}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{usageLoading ? (
|
||||
<p className="text-xs text-muted-foreground">加载调用明细…</p>
|
||||
) : usageCalls.length > 0 ? (
|
||||
<div className="rounded-md border">
|
||||
<div className="border-b px-2 py-1 text-xs text-muted-foreground">最近 {usageCalls.length} 次调用</div>
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{usageCalls.map((c, i) => (
|
||||
<div key={`${c.ts}-${i}`} className="flex items-center gap-2 border-b px-2 py-1 text-xs last:border-b-0">
|
||||
<span className="tabular-nums text-muted-foreground">{fmtTime(c.ts)}</span>
|
||||
<Badge variant="outline" className="font-normal">{c.agent_key || "—"}</Badge>
|
||||
<span className="ml-auto text-muted-foreground">
|
||||
{c.task_id > 0 ? `任务 #${c.task_id}` : c.session_id ? "对话会话" : "—"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">还没有调用记录。</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── 左侧:关联 MCP + 可见性 ── */}
|
||||
<div className="space-y-5 lg:min-w-0 lg:flex-1">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
关联 MCP
|
||||
<span className="ml-1 font-normal">(加载 Skill 时才披露/解锁其工具)</span>
|
||||
</Label>
|
||||
{mcpOptions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">暂无 MCP,可在「MCP」页添加。</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-2">
|
||||
{mcpOptions.map((m) => (
|
||||
<label key={m.id} className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={detailMcps.includes(m.name)}
|
||||
onCheckedChange={(on) => toggleSkillMcp(selectedSkill.name, m.name, !!on)}
|
||||
/>
|
||||
{m.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">可见性(按 Agent 授权)</Label>
|
||||
<div className="space-y-2">
|
||||
{agents.map((a) => (
|
||||
<label key={a.key} className="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={(visibility[selectedSkill.name] ?? []).includes(a.id)}
|
||||
onCheckedChange={() => toggleVisibility(selectedSkill.name, a.id, a.name)}
|
||||
/>
|
||||
{a.name}
|
||||
</label>
|
||||
))}
|
||||
{agents.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">(暂无 Agent)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,9 @@ import type {
|
||||
PromptVersion,
|
||||
Settings,
|
||||
Severity,
|
||||
SkillCall,
|
||||
SkillItem,
|
||||
MissingSkill,
|
||||
SSProject,
|
||||
SSTask,
|
||||
Stats,
|
||||
@@ -776,6 +778,10 @@ export const api = {
|
||||
writeSkillFile: (name: string, file: string, content: string) =>
|
||||
put<{ ok: boolean }>(`/skills/${name}/files/${file}`, { content }),
|
||||
deleteSkillPath: (skill: string, path: string) => del<{ deleted: string }>(`/skills/${skill}/files/${path}`),
|
||||
skillUsage: (name: string, limit = 50) =>
|
||||
get<{ calls: SkillCall[] }>(`/skills/${name}/usage?limit=${limit}`).then((r) => arr(r.calls)),
|
||||
missingSkills: (limit = 20) =>
|
||||
get<{ missing: MissingSkill[] }>(`/skills/missing?limit=${limit}`).then((r) => arr(r.missing)),
|
||||
|
||||
// ---- visibility (MCP resource side) ---- (agent ids are strings per spec)
|
||||
resourceVisibility: (kind: string, id: number) =>
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
Activity, Agent, AgentDetail, Asset, Audit, Company, Conversation,
|
||||
ConvTokenSummary, DailyTokenBucket, Edge, Finding, InterceptApprovalRow,
|
||||
InterceptPending, InterceptRule, LLMPoolStatus, LLMProfile, MCPServer, MCPTool, PromptVar,
|
||||
PromptVersion, Settings, SkillItem, Stats, TaskNode, Task, TokenTotal,
|
||||
PromptVersion, Settings, SkillCall, SkillItem, MissingSkill, Stats, TaskNode, Task, TokenTotal,
|
||||
TokenUsage, Tool, TrafficResp, TrafficDetail, TrafficHost, LLMTask,
|
||||
} from "@/lib/types";
|
||||
|
||||
@@ -686,9 +686,19 @@ export const mcpToolsById: Record<number, MCPTool[]> = {
|
||||
|
||||
// ── Skills ───────────────────────────────────────────────────────────────────
|
||||
export const skills: SkillItem[] = [
|
||||
{ name: "api-recon", description: "对 REST/GraphQL API 做侦察与越权面枚举;发现新 API 端点时使用。", license: "MIT", mcps: [], files: ["SKILL.md", "scripts/enum.py"] },
|
||||
{ name: "playwright-cli", description: "用 Playwright 驱动浏览器做动态爬取与截图;需要渲染 JS 站点时使用。", mcps: ["playwright"], files: ["SKILL.md"] },
|
||||
{ name: "scopesentry", description: "从 ScopeSentry 拉取资产并归并到公司范围;批量导入资产时使用。", files: ["SKILL.md", "assets/mapping.md"] },
|
||||
{ name: "api-recon", description: "对 REST/GraphQL API 做侦察与越权面枚举;发现新 API 端点时使用。", license: "MIT", mcps: [], files: ["SKILL.md", "scripts/enum.py"], calls: 24, tasks: 6, usage_agents: ["worker", "planner"], last_used: "2026-08-20T09:12:00Z" },
|
||||
{ name: "playwright-cli", description: "用 Playwright 驱动浏览器做动态爬取与截图;需要渲染 JS 站点时使用。", mcps: ["playwright"], files: ["SKILL.md"], calls: 7, tasks: 3, usage_agents: ["worker"], last_used: "2026-08-19T21:40:00Z" },
|
||||
{ name: "scopesentry", description: "从 ScopeSentry 拉取资产并归并到公司范围;批量导入资产时使用。", files: ["SKILL.md", "assets/mapping.md"], calls: 0, tasks: 0, usage_agents: [] },
|
||||
];
|
||||
|
||||
export const skillCalls: SkillCall[] = [
|
||||
{ ts: "2026-08-20T09:12:00Z", agent_key: "worker", task_id: 42, session_id: "", args_len: 128 },
|
||||
{ ts: "2026-08-20T08:03:00Z", agent_key: "planner", task_id: 42, session_id: "", args_len: 0 },
|
||||
{ ts: "2026-08-19T17:55:00Z", agent_key: "worker", task_id: 37, session_id: "", args_len: 64 },
|
||||
];
|
||||
|
||||
export const missingSkills: MissingSkill[] = [
|
||||
{ skill: "jwt-forge", calls: 3, agents: ["worker"], last_used: "2026-08-20T07:20:00Z" },
|
||||
];
|
||||
|
||||
// ── Tools ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -467,6 +467,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
|
||||
// ── skills ──
|
||||
if (path === "/skills" && m === "GET") return { skills: D.skills };
|
||||
if (path === "/skills/missing") return { missing: D.missingSkills };
|
||||
if (seg[0] === "skills" && seg[2] === "usage") return { calls: D.skillCalls };
|
||||
if (path === "/skills" && m === "POST") return { name: String(b.name ?? "new-skill") };
|
||||
if (seg[0] === "skills" && seg[2] === "files" && seg.length === 3) return { files: ["SKILL.md"] };
|
||||
if (seg[0] === "skills" && seg[2] === "files" && seg.length >= 4)
|
||||
|
||||
@@ -745,6 +745,28 @@ export interface SkillItem {
|
||||
compatibility?: string; // optional: environment requirements
|
||||
mcps?: string[]; // MCP server names this skill unlocks on load
|
||||
files: string[]; // files in the skill directory
|
||||
// 调用统计(skill_usage 账本)。从未被调用过的 skill:calls=0、last_used 缺省。
|
||||
calls: number;
|
||||
tasks: number; // 加载过它的任务数(chat 会话不计入)
|
||||
usage_agents: string[]; // 加载过它的 agent key
|
||||
last_used?: string;
|
||||
}
|
||||
|
||||
// SkillCall 是一次 Skill() 调用(单个 skill 的最近调用列表)。
|
||||
export interface SkillCall {
|
||||
ts: string;
|
||||
agent_key: string;
|
||||
task_id: number; // 0 = 非任务场景(对话会话)
|
||||
session_id: string;
|
||||
args_len: number;
|
||||
}
|
||||
|
||||
// MissingSkill 是被点名但不存在的 skill —— "想用但没有"的缺口。
|
||||
export interface MissingSkill {
|
||||
skill: string;
|
||||
calls: number;
|
||||
agents: string[];
|
||||
last_used?: string;
|
||||
}
|
||||
|
||||
// ---- Tools (内置工具目录) ----
|
||||
|
||||
Reference in new issue
Block a user