feat(skills): track usage and missing dependencies
This commit is contained in:
1 parent
8dca38c4b9
commit
fd2ddbc9d7
5 files changed
+834
-63
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Autumn-27/norma/skill"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
)
|
||||
|
||||
// maxLedgerSkillName caps the skill name stored for a MISS — that string comes
|
||||
// straight from the model and is otherwise unbounded.
|
||||
const maxLedgerSkillName = 128
|
||||
|
||||
// meteredSkill wraps the Skill meta-tool to append one skill_usage row per call.
|
||||
// The metering lives here rather than in Registry.OnInvoke because OnInvoke only
|
||||
// receives the resolved Skill: it never fires for an unknown skill name (norma
|
||||
// returns a tool error before the hook) and it cannot see the caller's args. Both
|
||||
// matter — a miss tells you which procedure the agents wished existed.
|
||||
type meteredSkill struct {
|
||||
actool.CoreTool
|
||||
pg *db.DB
|
||||
reg *skill.Registry
|
||||
agentKey string
|
||||
ri agent.RunInfo
|
||||
}
|
||||
|
||||
// meterSkillTool returns t unchanged when there is no DB to meter into.
|
||||
func meterSkillTool(t actool.CoreTool, pg *db.DB, reg *skill.Registry, agentKey string, ri agent.RunInfo) actool.CoreTool {
|
||||
if pg == nil {
|
||||
return t
|
||||
}
|
||||
return &meteredSkill{CoreTool: t, pg: pg, reg: reg, agentKey: agentKey, ri: ri}
|
||||
}
|
||||
|
||||
func (m *meteredSkill) Call(ctx context.Context, input json.RawMessage, tc *actool.ToolContext) (actool.Result, error) {
|
||||
m.record(input)
|
||||
return m.CoreTool.Call(ctx, input, tc)
|
||||
}
|
||||
|
||||
// record appends the ledger row. Best-effort by design: a metering failure must
|
||||
// never turn into a failed skill invocation, so every error path just logs.
|
||||
func (m *meteredSkill) record(input json.RawMessage) {
|
||||
var in struct {
|
||||
Name string `json:"name"`
|
||||
Args string `json:"args"`
|
||||
}
|
||||
if err := json.Unmarshal(input, &in); err != nil {
|
||||
return // malformed call; norma will reject it too
|
||||
}
|
||||
name := strings.TrimSpace(in.Name)
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
// Resolve to the directory name so the ledger keys match agent_skill_visibility
|
||||
// and the skills page (a skill's display Name can differ from its directory).
|
||||
found := false
|
||||
if m.reg != nil {
|
||||
if s, ok := m.reg.Get(name); ok {
|
||||
found = true
|
||||
if s.Dir != "" {
|
||||
name = filepath.Base(s.Dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(name) > maxLedgerSkillName {
|
||||
name = name[:maxLedgerSkillName]
|
||||
}
|
||||
err := m.pg.InsertSkillUsage(&db.SkillUsage{
|
||||
Skill: name,
|
||||
AgentKey: m.agentKey,
|
||||
TaskID: m.ri.TaskID,
|
||||
ExplorationID: m.ri.ExplorationID,
|
||||
IntentID: m.ri.IntentID,
|
||||
SessionID: m.ri.SessionID,
|
||||
ArgsLen: len(in.Args),
|
||||
Found: found,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[skillusage] insert: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -12,13 +12,25 @@ import {
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
UploadIcon,
|
||||
AlertTriangleIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
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 {
|
||||
@@ -31,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 {
|
||||
@@ -85,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 }
|
||||
@@ -99,6 +134,164 @@ type Creating = {
|
||||
kind: "file" | "dir";
|
||||
} | null;
|
||||
|
||||
type PendingDelete =
|
||||
| { kind: "skill"; skill: string }
|
||||
| { kind: "file"; skill: string; path: string }
|
||||
| { kind: "dir"; skill: string; path: string }
|
||||
| null;
|
||||
|
||||
// ── Overview (empty-state) ──────────────────────────────────────────────────
|
||||
// Shown when nothing is selected: a library-wide snapshot from data already loaded
|
||||
// (the skill list carries per-skill usage; missing is fetched alongside). No extra
|
||||
// requests — this is pure aggregation over props.
|
||||
function SkillsOverview({
|
||||
skills,
|
||||
missing,
|
||||
onSelect,
|
||||
}: {
|
||||
skills: SkillItem[];
|
||||
missing: MissingSkill[];
|
||||
onSelect: (name: string) => void;
|
||||
}) {
|
||||
const 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[]>([]);
|
||||
@@ -137,10 +330,21 @@ export default function SkillsPage() {
|
||||
// skill detail — MCP edit state (optimistic, rolls back on error)
|
||||
const [detailMcps, setDetailMcps] = React.useState<string[]>([]);
|
||||
|
||||
// delete confirmation
|
||||
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) =>
|
||||
@@ -195,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);
|
||||
@@ -296,6 +511,19 @@ export default function SkillsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function runPendingDelete() {
|
||||
const p = pendingDelete;
|
||||
if (!p) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
if (p.kind === "skill") await deleteSkill(p.skill);
|
||||
else await deletePath(p.skill, p.path);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setPendingDelete(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFile() {
|
||||
if (!selected || selected.path === null) return;
|
||||
setSaving(true);
|
||||
@@ -392,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}`;
|
||||
@@ -400,7 +630,7 @@ export default function SkillsPage() {
|
||||
return (
|
||||
<div key={node.path}>
|
||||
<div
|
||||
className="group flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm hover:bg-muted"
|
||||
className="group relative flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm hover:bg-muted"
|
||||
style={{ paddingLeft: baseIndent }}
|
||||
onClick={() => toggleExpanded(key)}
|
||||
>
|
||||
@@ -409,8 +639,9 @@ export default function SkillsPage() {
|
||||
? <FolderOpenIcon className="size-3.5 shrink-0 text-amber-500" />
|
||||
: <FolderIcon className="size-3.5 shrink-0 text-amber-500" />
|
||||
}
|
||||
<span className="flex-1 truncate">{node.name}</span>
|
||||
<span className="ml-auto hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
||||
<span className="min-w-0 flex-1 truncate" title={node.path}>{node.name}</span>
|
||||
{/* Absolute so a long name can never push the actions out of view */}
|
||||
<span className="absolute inset-y-0 right-1 hidden items-center gap-0.5 rounded bg-muted pl-1 group-hover:flex">
|
||||
<Button size="icon" variant="ghost" className="size-5" title="新建文件"
|
||||
onClick={(e) => { e.stopPropagation(); startCreate(skill, node.path, "file"); }}>
|
||||
<FilePlusIcon className="size-3 text-muted-foreground" />
|
||||
@@ -420,7 +651,7 @@ export default function SkillsPage() {
|
||||
<FolderPlusIcon className="size-3 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-5" title="删除文件夹"
|
||||
onClick={(e) => { e.stopPropagation(); deletePath(skill, node.path); }}>
|
||||
onClick={(e) => { e.stopPropagation(); setPendingDelete({ kind: "dir", skill, path: node.path }); }}>
|
||||
<Trash2Icon className="size-3 text-destructive" />
|
||||
</Button>
|
||||
</span>
|
||||
@@ -442,19 +673,24 @@ export default function SkillsPage() {
|
||||
<div
|
||||
key={node.path}
|
||||
className={cn(
|
||||
"group flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm",
|
||||
"group relative flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm",
|
||||
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
|
||||
)}
|
||||
style={{ paddingLeft: baseIndent + 16 }}
|
||||
onClick={() => setSelected({ skill, path: node.path })}
|
||||
>
|
||||
<FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1 truncate font-mono text-xs">{node.name}</span>
|
||||
<Button size="icon" variant="ghost" className="ml-auto hidden size-5 group-hover:flex"
|
||||
title="删除文件"
|
||||
onClick={(e) => { e.stopPropagation(); deletePath(skill, node.path); }}>
|
||||
<Trash2Icon className="size-3 text-destructive" />
|
||||
</Button>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={node.path}>{node.name}</span>
|
||||
<span className={cn(
|
||||
"absolute inset-y-0 right-1 hidden items-center rounded pl-1 group-hover:flex",
|
||||
isSelected ? "bg-accent" : "bg-muted",
|
||||
)}>
|
||||
<Button size="icon" variant="ghost" className="size-5"
|
||||
title="删除文件"
|
||||
onClick={(e) => { e.stopPropagation(); setPendingDelete({ kind: "file", skill, path: node.path }); }}>
|
||||
<Trash2Icon className="size-3 text-destructive" />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -466,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">
|
||||
{/* ── 左侧文件树 ── */}
|
||||
@@ -496,7 +759,10 @@ export default function SkillsPage() {
|
||||
{uploading ? "上传中…" : "上传压缩包"}
|
||||
</Button>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
{/* Radix viewport wraps children in a display:table div that grows with
|
||||
content — force it to block so long names truncate instead of
|
||||
widening the rows past the sidebar. */}
|
||||
<ScrollArea className="flex-1 [&>[data-slot=scroll-area-viewport]>div]:!block">
|
||||
<div className="p-1">
|
||||
{skills.map((s) => {
|
||||
const isOpen = expanded.has(s.name);
|
||||
@@ -507,7 +773,7 @@ export default function SkillsPage() {
|
||||
{/* skill 根节点 */}
|
||||
<div
|
||||
className={cn(
|
||||
"group flex cursor-pointer select-none items-center gap-1 rounded px-2 py-1 text-sm",
|
||||
"group relative flex cursor-pointer select-none items-center gap-1 rounded px-2 py-1 text-sm",
|
||||
isSkillSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
|
||||
)}
|
||||
onClick={() => {
|
||||
@@ -520,8 +786,19 @@ export default function SkillsPage() {
|
||||
? <FolderOpenIcon className="size-3.5 shrink-0 text-blue-500" />
|
||||
: <FolderIcon className="size-3.5 shrink-0 text-blue-500" />
|
||||
}
|
||||
<span className="flex-1 truncate font-semibold">{s.name}</span>
|
||||
<span className="ml-auto hidden shrink-0 items-center gap-0.5 group-hover:flex">
|
||||
<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",
|
||||
)}>
|
||||
<Button size="icon" variant="ghost" className="size-5" title="新建文件"
|
||||
onClick={(e) => { e.stopPropagation(); startCreate(s.name, "", "file"); }}>
|
||||
<FilePlusIcon className="size-3 text-muted-foreground" />
|
||||
@@ -531,7 +808,7 @@ export default function SkillsPage() {
|
||||
<FolderPlusIcon className="size-3 text-muted-foreground" />
|
||||
</Button>
|
||||
<Button size="icon" variant="ghost" className="size-5" title="删除 Skill"
|
||||
onClick={(e) => { e.stopPropagation(); deleteSkill(s.name); }}>
|
||||
onClick={(e) => { e.stopPropagation(); setPendingDelete({ kind: "skill", skill: s.name }); }}>
|
||||
<Trash2Icon className="size-3 text-destructive" />
|
||||
</Button>
|
||||
</span>
|
||||
@@ -559,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 && (
|
||||
@@ -581,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>
|
||||
@@ -649,6 +983,35 @@ export default function SkillsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── 删除二次确认 ── */}
|
||||
<AlertDialog open={!!pendingDelete} onOpenChange={(o) => { if (!o) setPendingDelete(null); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{pendingDelete?.kind === "skill" && `删除 Skill「${pendingDelete.skill}」?`}
|
||||
{pendingDelete?.kind === "dir" && `删除文件夹「${pendingDelete.path}」?`}
|
||||
{pendingDelete?.kind === "file" && `删除文件「${pendingDelete.path}」?`}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingDelete?.kind === "skill"
|
||||
? "将删除该 Skill 的全部文件、MCP 关联与可见性配置。此操作不可撤销。"
|
||||
: pendingDelete?.kind === "dir"
|
||||
? "将一并删除该文件夹下的所有文件。此操作不可撤销。"
|
||||
: "此操作不可撤销。"}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleting}
|
||||
onClick={(e) => { e.preventDefault(); void runPendingDelete(); }}
|
||||
>
|
||||
{deleting ? "删除中…" : "删除"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* ── 新建 Skill 对话框 ── */}
|
||||
<Sheet open={newOpen} onOpenChange={setNewOpen}>
|
||||
<SheetContent side="right" className="flex w-full flex-col gap-0 data-[side=right]:sm:max-w-xl">
|
||||
|
||||
Reference in new issue
Block a user