From 834e94535abfaad46475f985c24c3ce7581bf50c Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:30:01 -0400 Subject: [PATCH] =?UTF-8?q?feat(skills):=20=E6=96=B0=E5=A2=9E=20Skill=20?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E7=BB=9F=E8=AE=A1=EF=BC=88=E8=B4=A6=E6=9C=AC?= =?UTF-8?q?=20+=20=E9=A1=B5=E9=9D=A2=E6=80=BB=E8=A7=88/=E8=AF=A6=E6=83=85?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 新表 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 --- agent/chat.go | 1 + agent/mainagent.go | 1 + agent/planner.go | 1 + agent/runinfo.go | 47 +++ agent/worker.go | 1 + db/schema.sql | 20 ++ db/skill_usage.go | 200 ++++++++++++ db/skill_usage_test.go | 116 +++++++ server/assembly.go | 6 +- server/server.go | 2 + server/server_mgmt.go | 59 +++- web/src/app/(main)/system/skills/page.tsx | 381 +++++++++++++++++++--- web/src/lib/api.ts | 6 + web/src/lib/mock/data.ts | 18 +- web/src/lib/mock/handler.ts | 2 + web/src/lib/types.ts | 22 ++ 16 files changed, 831 insertions(+), 52 deletions(-) create mode 100644 agent/runinfo.go create mode 100644 db/skill_usage.go create mode 100644 db/skill_usage_test.go diff --git a/agent/chat.go b/agent/chat.go index 86deccc..41be93b 100644 --- a/agent/chat.go +++ b/agent/chat.go @@ -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() diff --git a/agent/mainagent.go b/agent/mainagent.go index 86b198b..b65d8c0 100644 --- a/agent/mainagent.go +++ b/agent/mainagent.go @@ -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() // 本任务的工作目录 /tasks/,先建好。 diff --git a/agent/planner.go b/agent/planner.go index b2757fe..d460e6c 100644 --- a/agent/planner.go +++ b/agent/planner.go @@ -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 diff --git a/agent/runinfo.go b/agent/runinfo.go new file mode 100644 index 0000000..f006b6a --- /dev/null +++ b/agent/runinfo.go @@ -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{} +} diff --git a/agent/worker.go b/agent/worker.go index cdbe63d..258b05b 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -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() diff --git a/db/schema.sql b/db/schema.sql index 90779f7..5cf002b 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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. 内置工具目录 -- ===================================================================== diff --git a/db/skill_usage.go b/db/skill_usage.go new file mode 100644 index 0000000..61d3a63 --- /dev/null +++ b/db/skill_usage.go @@ -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() +} diff --git a/db/skill_usage_test.go b/db/skill_usage_test.go new file mode 100644 index 0000000..4625696 --- /dev/null +++ b/db/skill_usage_test.go @@ -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) + } +} diff --git a/server/assembly.go b/server/assembly.go index 49fc162..426a9b6 100644 --- a/server/assembly.go +++ b/server/assembly.go @@ -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; diff --git a/server/server.go b/server/server.go index 64d8c01..8962cc5 100644 --- a/server/server.go +++ b/server/server.go @@ -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) diff --git a/server/server_mgmt.go b/server/server_mgmt.go index 9732b9d..8782d56 100644 --- a/server/server_mgmt.go +++ b/server/server_mgmt.go @@ -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)) diff --git a/web/src/app/(main)/system/skills/page.tsx b/web/src/app/(main)/system/skills/page.tsx index 756ef4b..41539df 100644 --- a/web/src/app/(main)/system/skills/page.tsx +++ b/web/src/app/(main)/system/skills/page.tsx @@ -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 ( +
+

暂无 Skill,点击左侧「新建」或「上传压缩包」开始

+
+ ); + } + + 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 ( +
+
+

技能库总览

+

选择左侧的 Skill 查看详情与调用记录,或从这里快速了解整体使用情况。

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

{s.value}

+

{s.label}

+ {s.hint &&

{s.hint}

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

+ 还没有任何 Skill 调用记录。 +

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

暂无记录。

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

所有 Skill 都被调用过。

+ ) : ( +
+ {agg.neverUsed.map((s) => ( + + ))} +
+ )} +
+
+
+ ); +} + // ── Page ────────────────────────────────────────────────────────────────── export default function SkillsPage() { const [skills, setSkills] = React.useState([]); @@ -157,10 +334,17 @@ export default function SkillsPage() { 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) => @@ -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 (
-
-

Skill

-

技能库 · agentskills.io 规范 · 按 Agent 授权可见

+
+
+

Skill

+

技能库 · agentskills.io 规范 · 按 Agent 授权可见

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

+ Agent 点名调用、但技能库里不存在的 skill。按被点名次数排序。 +

+
+ {missing.map((m) => ( +
+ {m.skill} + {m.calls} 次 + {fmtTime(m.last_used)} +
+ ))} +
+
+
+ )}
{/* ── 左侧文件树 ── */} @@ -563,6 +787,14 @@ export default function SkillsPage() { : } {s.name} + {s.calls > 0 && ( + + {s.calls} + + )}