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
+5
-1
@@ -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;
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user