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:
Autumn-27 committed 2026-08-20 12:30:01 -04:00
1 parent 43f40dd74f
commit 834e94535a
16 files changed
+831 -52

No files matched your search

+1
View File
@@ -92,6 +92,7 @@ func (c *ChatAgent) Chat(ctx context.Context, agentKey, sessionID, message strin
// visible skills/MCP and lets the DB tools table filter/override. DefaultTools
// have no tools-table rows, so they always pass through.
base := actool.DefaultTools()
ctx = WithRunInfo(ctx, RunInfo{SessionID: sessionID})
tools, def, cleanup := AugmentTools(ctx, agentKey, base)
defer cleanup()
+1
View File
@@ -85,6 +85,7 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t
tsx.SetNotifyGoal(notifyGoal) // set_goals 新增目标 → 给 planner 记一条「人新增了目标:…」触发
// 领域工具 + 基础默认工具集(Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash)
base := append(tsx.MainAgentTools(), actool.DefaultTools()...)
ctx = WithRunInfo(ctx, RunInfo{TaskID: taskID, ExplorationID: explorationID(ts)})
tools, def, cleanup := AugmentTools(ctx, "mainagent", base)
defer cleanup()
// 本任务的工作目录 <workDir>/tasks/<taskID>,先建好。
+1
View File
@@ -273,6 +273,7 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts
}
// 领域工具 + 基础默认工具集(Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash)
base := append(tsx.PlannerTools(), actool.DefaultTools()...)
ctx = WithRunInfo(ctx, RunInfo{TaskID: taskID, ExplorationID: explorationID(ts)})
tools, def, cleanup := AugmentTools(ctx, "planner", base)
defer cleanup()
// 关键态势(刚完成的意图 + 预取的完整图)改放【本轮 user 输入】(见下方 input),system
+47
View File
@@ -0,0 +1,47 @@
package agent
import (
"context"
"github.com/Autumn-27/artex/db"
)
// RunInfo identifies WHICH run a tool call belongs to. Tool assembly only receives
// (ctx, agentKey) — the task/exploration ids live in the caller's arguments, not the
// ctx — so anything wired at assembly time (currently the Skill ledger in
// server/assembly.go) has no way to attribute a call to a task. Each run attaches
// its own RunInfo before calling AugmentTools; the wiring closure reads it once and
// captures it, so per-run attribution stays correct without threading parameters
// through the tool layer. Same pattern as TaskClock (see taskclock.go).
//
// Zero value = attribution unknown; every consumer must treat it as optional.
type RunInfo struct {
TaskID int64 // task registry id; 0 for non-task runs (chat sessions)
ExplorationID int64 // exploration id; 0 when unknown
IntentID int64 // worker's intent node; 0 for planner/mainagent/chat
SessionID string // chat conversation id; empty for task runs
}
// explorationID reads a store's exploration id, tolerating a nil store (planner and
// worker runs can be driven without one in tests).
func explorationID(ts *db.ExplorationStore) int64 {
if ts == nil {
return 0
}
return ts.ID()
}
type runInfoKey struct{}
// WithRunInfo attaches run attribution to ctx.
func WithRunInfo(ctx context.Context, ri RunInfo) context.Context {
return context.WithValue(ctx, runInfoKey{}, ri)
}
// RunInfoFrom reads the RunInfo (zero value if none attached).
func RunInfoFrom(ctx context.Context) RunInfo {
if v, ok := ctx.Value(runInfoKey{}).(RunInfo); ok {
return v
}
return RunInfo{}
}
+1
View File
@@ -265,6 +265,7 @@ func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db.
// phase, Bash is hidden via Settlement.DisabledTools (no local gating needed).
base := append(tsx.WorkerTools(), w.extraTools...)
base = append(base, actool.DefaultTools()...)
ctx = WithRunInfo(ctx, RunInfo{TaskID: taskID, ExplorationID: explorationID(ts), IntentID: intent.ID})
tools, def, cleanup := AugmentTools(ctx, "worker", base)
defer cleanup()
+20
View File
@@ -501,6 +501,26 @@ CREATE TABLE IF NOT EXISTS agent_skill_visibility (
);
CREATE INDEX IF NOT EXISTS idx_askv_skill ON agent_skill_visibility(skill_name);
-- Skill 调用账本(见 db/skill_usage.go)。一次 Skill() 调用一行,只记维度不记正文。
-- 刻意不设外键:任务/会话删除后统计仍要保留(与 llm_usage 同理),skill 本身也只是
-- 文件系统上的目录名,没有对应的表。
CREATE TABLE IF NOT EXISTS skill_usage (
id BIGSERIAL PRIMARY KEY,
ts TIMESTAMPTZ NOT NULL DEFAULT now(),
skill TEXT NOT NULL,
agent_key TEXT,
task_id BIGINT,
exploration_id BIGINT,
intent_id BIGINT,
session_id TEXT,
args_len INTEGER NOT NULL DEFAULT 0,
-- false = 模型点名了一个不存在的 skill(未命中)。这类行同样保留:它反映"想用但没有"
-- 的缺口,是补 skill 的依据。
found BOOLEAN NOT NULL DEFAULT true
);
CREATE INDEX IF NOT EXISTS idx_skill_usage_skill ON skill_usage(skill, ts DESC);
CREATE INDEX IF NOT EXISTS idx_skill_usage_task ON skill_usage(task_id);
-- =====================================================================
-- H. 内置工具目录
-- =====================================================================
+200
View File
@@ -0,0 +1,200 @@
package db
import (
"database/sql"
"strings"
"time"
)
// SkillUsage is one Skill() invocation — the always-on skill call ledger. Written
// from the Skill meta-tool's OnInvoke hook (server/assembly.go), one row per load.
// Carries only dimensions (which skill, which agent, which task/session), never the
// caller's args text — args_len is kept so an "empty vs substantial context" split
// is still possible without storing prompt content, mirroring llm_usage.
//
// Rows deliberately outlive their task: skill_usage has no foreign keys, so deleting
// a task keeps its skill statistics intact (same rationale as llm_usage).
type SkillUsage struct {
Skill string `json:"skill"` // skill directory name (matches agent_skill_visibility.skill_name)
AgentKey string `json:"agent_key"` // worker / planner / mainagent / custom agent key
TaskID int64 `json:"task_id"` // 0 for non-task runs (chat sessions)
ExplorationID int64 `json:"exploration_id"` // 0 when unknown
IntentID int64 `json:"intent_id"` // worker's intent node; 0 for planner/mainagent/chat
SessionID string `json:"session_id"` // chat conversation id; empty for task runs
ArgsLen int `json:"args_len"`
Found bool `json:"found"` // false = the model named a skill that does not exist
}
// InsertSkillUsage appends one ledger row. Best-effort: callers log and continue on
// error (a lost metering row must never break a skill invocation).
func (d *DB) InsertSkillUsage(u *SkillUsage) error {
_, err := d.Exec(`
INSERT INTO skill_usage(skill, agent_key, task_id, exploration_id, intent_id, session_id, args_len, found)
VALUES ($1, NULLIF($2,''), $3, $4, $5, NULLIF($6,''), $7, $8)`,
u.Skill, u.AgentKey, nullIfZero(u.TaskID), nullIfZero(u.ExplorationID),
nullIfZero(u.IntentID), u.SessionID, u.ArgsLen, u.Found)
return err
}
func nullIfZero(v int64) any {
if v > 0 {
return v
}
return nil
}
// SkillStat is one skill's aggregate usage, for the skills page.
type SkillStat struct {
Skill string `json:"skill"`
Calls int `json:"calls"`
Tasks int `json:"tasks"` // distinct tasks that loaded it (chat runs excluded)
Agents []string `json:"agents"` // agent keys that loaded it, most-used first
LastUsed *time.Time `json:"last_used"` // nil when never called
}
// SkillStats aggregates the whole ledger grouped by skill, most-used first. Skills
// that were never invoked are absent — callers merge against the skill list on disk.
// Only resolved calls count; misses are reported separately by MissingSkillStats.
func (d *DB) SkillStats() ([]SkillStat, error) {
// agent keys come back as one comma-joined string rather than text[]: the pgx
// stdlib driver has no database/sql Scan target for arrays, and agent keys are
// [a-z0-9_-] so a comma join is unambiguous.
rows, err := d.Query(`
SELECT skill, COUNT(*) AS calls,
COUNT(DISTINCT task_id) AS tasks,
COALESCE(STRING_AGG(DISTINCT agent_key, ','), '') AS agents,
MAX(ts) AS last_used
FROM skill_usage
WHERE found
GROUP BY skill
ORDER BY COUNT(*) DESC, skill`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []SkillStat{}
for rows.Next() {
var s SkillStat
var agents string
var lastUsed sql.NullTime
if err := rows.Scan(&s.Skill, &s.Calls, &s.Tasks, &agents, &lastUsed); err != nil {
return nil, err
}
s.Agents = []string{}
if agents != "" {
s.Agents = strings.Split(agents, ",")
}
if lastUsed.Valid {
t := lastUsed.Time
s.LastUsed = &t
}
out = append(out, s)
}
return out, rows.Err()
}
// MissingSkillStats returns the skill names agents asked for that do not exist,
// most-requested first — the "wished it existed" gap list. Names come from the model
// so they are shown as-is (already length-capped at insert time).
func (d *DB) MissingSkillStats(limit int) ([]SkillStat, error) {
if limit <= 0 || limit > 200 {
limit = 20
}
rows, err := d.Query(`
SELECT skill, COUNT(*) AS calls,
COALESCE(STRING_AGG(DISTINCT agent_key, ','), '') AS agents,
MAX(ts) AS last_used
FROM skill_usage
WHERE NOT found
GROUP BY skill
ORDER BY COUNT(*) DESC, skill
LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []SkillStat{}
for rows.Next() {
var s SkillStat
var agents string
var lastUsed sql.NullTime
if err := rows.Scan(&s.Skill, &s.Calls, &agents, &lastUsed); err != nil {
return nil, err
}
s.Agents = []string{}
if agents != "" {
s.Agents = strings.Split(agents, ",")
}
if lastUsed.Valid {
t := lastUsed.Time
s.LastUsed = &t
}
out = append(out, s)
}
return out, rows.Err()
}
// SkillCall is one row of a skill's recent-call list (detail panel).
type SkillCall struct {
TS time.Time `json:"ts"`
AgentKey string `json:"agent_key"`
TaskID int64 `json:"task_id"`
SessionID string `json:"session_id"`
ArgsLen int `json:"args_len"`
}
// RecentSkillCalls returns the most recent invocations of one skill, newest first.
func (d *DB) RecentSkillCalls(skill string, limit int) ([]SkillCall, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := d.Query(`
SELECT ts, COALESCE(agent_key,''), COALESCE(task_id,0), COALESCE(session_id,''), args_len
FROM skill_usage
WHERE skill = $1 AND found
ORDER BY ts DESC
LIMIT $2`, skill, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []SkillCall{}
for rows.Next() {
var c SkillCall
if err := rows.Scan(&c.TS, &c.AgentKey, &c.TaskID, &c.SessionID, &c.ArgsLen); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// SkillCallsByTask counts a task's skill loads, most-used first. Powers a per-task
// view of which procedures its agents actually reached for.
func (d *DB) SkillCallsByTask(taskID int64) ([]SkillStat, error) {
rows, err := d.Query(`
SELECT skill, COUNT(*) AS calls, MAX(ts) AS last_used
FROM skill_usage
WHERE task_id = $1 AND found
GROUP BY skill
ORDER BY COUNT(*) DESC, skill`, taskID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []SkillStat{}
for rows.Next() {
var s SkillStat
var lastUsed sql.NullTime
if err := rows.Scan(&s.Skill, &s.Calls, &lastUsed); err != nil {
return nil, err
}
s.Agents = []string{}
if lastUsed.Valid {
t := lastUsed.Time
s.LastUsed = &t
}
out = append(out, s)
}
return out, rows.Err()
}
+116
View File
@@ -0,0 +1,116 @@
package db
import (
"testing"
)
// TestSkillUsageLedger writes a few rows and checks the three aggregates the
// skills page relies on: per-skill totals (resolved calls only), the miss list,
// and the recent-call list. Rows are namespaced by a unique skill name so the
// shared dev DB stays usable and the test cleans up after itself.
func TestSkillUsageLedger(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
defer d.Close()
const (
skillA = "zz-test-skill-a"
missing = "zz-test-skill-missing"
)
cleanup := func() {
_, _ = d.Exec(`DELETE FROM skill_usage WHERE skill IN ($1,$2)`, skillA, missing)
}
cleanup()
defer cleanup()
rows := []*SkillUsage{
{Skill: skillA, AgentKey: "worker", TaskID: 991, ExplorationID: 5, IntentID: 7, ArgsLen: 12, Found: true},
{Skill: skillA, AgentKey: "worker", TaskID: 991, ExplorationID: 5, ArgsLen: 0, Found: true},
{Skill: skillA, AgentKey: "planner", TaskID: 992, ExplorationID: 6, Found: true},
{Skill: skillA, AgentKey: "chatbot", SessionID: "conv-1", Found: true},
{Skill: missing, AgentKey: "worker", TaskID: 991, Found: false},
{Skill: missing, AgentKey: "worker", TaskID: 991, Found: false},
}
for _, r := range rows {
if err := d.InsertSkillUsage(r); err != nil {
t.Fatalf("insert %s: %v", r.Skill, err)
}
}
stats, err := d.SkillStats()
if err != nil {
t.Fatal(err)
}
var got *SkillStat
for i := range stats {
if stats[i].Skill == skillA {
got = &stats[i]
}
if stats[i].Skill == missing {
t.Fatalf("misses must not appear in SkillStats: %+v", stats[i])
}
}
if got == nil {
t.Fatalf("skill %s absent from SkillStats", skillA)
}
if got.Calls != 4 {
t.Errorf("calls: want 4, got %d", got.Calls)
}
// 991 + 992; the chat row has a NULL task_id and COUNT(DISTINCT) skips NULLs.
if got.Tasks != 2 {
t.Errorf("tasks: want 2, got %d", got.Tasks)
}
if len(got.Agents) != 3 {
t.Errorf("agents: want 3 distinct, got %v", got.Agents)
}
if got.LastUsed == nil {
t.Error("last_used must be set")
}
miss, err := d.MissingSkillStats(10)
if err != nil {
t.Fatal(err)
}
var missCalls int
for _, m := range miss {
if m.Skill == missing {
missCalls = m.Calls
}
}
if missCalls != 2 {
t.Errorf("missing calls: want 2, got %d", missCalls)
}
calls, err := d.RecentSkillCalls(skillA, 10)
if err != nil {
t.Fatal(err)
}
if len(calls) != 4 {
t.Fatalf("recent calls: want 4, got %d", len(calls))
}
// newest first
for i := 1; i < len(calls); i++ {
if calls[i].TS.After(calls[i-1].TS) {
t.Errorf("recent calls not newest-first at %d", i)
}
}
byTask, err := d.SkillCallsByTask(991)
if err != nil {
t.Fatal(err)
}
var taskCalls int
for _, s := range byTask {
if s.Skill == skillA {
taskCalls = s.Calls
}
if s.Skill == missing {
t.Errorf("misses must not appear in SkillCallsByTask: %+v", s)
}
}
if taskCalls != 2 {
t.Errorf("task 991 calls: want 2, got %d", taskCalls)
}
}
+5 -1
View File
@@ -137,7 +137,11 @@ func wireAgentAugment(pg *db.DB, skillDir string, hostTools func() ([]actool.Cor
}
return actool.RenderDeferredToolsBlock(reveal)
}
extra = append(extra, reg.Tool())
// Attribution for the usage ledger: ToolAugment only gets (ctx, agentKey),
// so the run's task/session ids ride in on the ctx (agent.RunInfo). Read it
// once here — this closure is rebuilt per run, so the captured value always
// belongs to this run.
extra = append(extra, meterSkillTool(reg.Tool(), pg, reg, a.Key, agent.RunInfoFrom(ctx)))
}
// host tools (traffic / orchestration / custom) — added to every agent's base;
+2
View File
@@ -776,6 +776,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/skills", s.fsCreateSkill)
mux.HandleFunc("POST /api/skills/upload", s.fsUploadSkill)
mux.HandleFunc("DELETE /api/skills/{name}", s.fsDeleteSkill)
mux.HandleFunc("GET /api/skills/missing", s.fsMissingSkills) // 未命中(想调但不存在)的 skill 名
mux.HandleFunc("GET /api/skills/{name}/usage", s.fsSkillUsage) // 单个 skill 的最近调用
mux.HandleFunc("PUT /api/skills/{name}/meta", s.fsUpdateSkillMeta)
mux.HandleFunc("POST /api/skills/{name}/dirs", s.fsCreateDir)
mux.HandleFunc("GET /api/skills/{name}/files", s.fsListFiles)
+58 -1
View File
@@ -897,6 +897,12 @@ type skillFileNode struct {
Compatibility string `json:"compatibility,omitempty"`
MCPs []string `json:"mcps,omitempty"`
Files []string `json:"files"`
// Usage ledger (db/skill_usage.go). Calls is 0 and LastUsed nil for a skill that
// was never invoked — the ledger only has rows for skills agents actually loaded.
Calls int `json:"calls"`
Tasks int `json:"tasks"`
Agents []string `json:"usage_agents"`
LastUsed *time.Time `json:"last_used,omitempty"`
}
func (s *Server) fsListSkills(w http.ResponseWriter, r *http.Request) {
@@ -915,13 +921,28 @@ func (s *Server) fsListSkills(w http.ResponseWriter, r *http.Request) {
writeErr(w, 500, err.Error())
return
}
// Usage is best-effort decoration: a ledger read failure leaves the counts at
// zero rather than failing the skill list itself.
statBySkill := map[string]db.SkillStat{}
if s.m.pg != nil {
stats, err := s.m.pg.SkillStats()
if err != nil {
log.Printf("[skills] 读取调用统计失败: %v", err)
}
for _, st := range stats {
statBySkill[st.Skill] = st
}
}
nodes := []skillFileNode{}
for _, e := range entries {
if !e.IsDir() {
continue
}
dirName := e.Name()
node := skillFileNode{Name: dirName}
node := skillFileNode{Name: dirName, Agents: []string{}}
if st, ok := statBySkill[dirName]; ok {
node.Calls, node.Tasks, node.Agents, node.LastUsed = st.Calls, st.Tasks, st.Agents, st.LastUsed
}
if meta, ok := metaByDir[dirName]; ok {
node.Description = meta.Description
node.License = meta.License
@@ -937,6 +958,42 @@ func (s *Server) fsListSkills(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, map[string]any{"skills": nodes})
}
// fsSkillUsage returns one skill's recent invocations, newest first (detail panel).
func (s *Server) fsSkillUsage(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
name := r.PathValue("name")
if !validSkillName(name) {
writeErr(w, 400, "非法 skill 名")
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
calls, err := pg.RecentSkillCalls(name, limit)
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"calls": calls})
}
// fsMissingSkills lists skill names agents asked for that do not exist — the gap
// list, i.e. which procedures are worth writing next.
func (s *Server) fsMissingSkills(w http.ResponseWriter, r *http.Request) {
pg := s.pg(w)
if pg == nil {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
missing, err := pg.MissingSkillStats(limit)
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, map[string]any{"missing": missing})
}
// cleanStrs trims each string and drops empties.
func cleanStrs(in []string) []string {
out := make([]string, 0, len(in))
+335 -46
View File
@@ -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>
+6
View File
@@ -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) =>
+14 -4
View File
@@ -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 ────────────────────────────────────────────────────────────────────
+2
View File
@@ -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)
+22
View File
@@ -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 (内置工具目录) ----