From d9687274601205278624db56fe5fb47dcaac2aef Mon Sep 17 00:00:00 2001 From: pphua Date: Tue, 25 Aug 2026 12:05:26 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8A=BD=E5=B1=89=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/schema.sql | 15 + db/tool_usage.go | 46 ++ db/tool_usage_test.go | 44 ++ db/tools.go | 1 + server/assembly.go | 28 +- server/server_mgmt.go | 10 + server/tool_usage.go | 48 ++ server/tool_usage_test.go | 73 +++ web/src/app/(main)/function/tasks/page.tsx | 527 ++++++++++++++++----- web/src/app/(main)/system/llm/page.tsx | 2 - web/src/app/(main)/system/tools/page.tsx | 8 + web/src/components/ui/sheet.tsx | 15 +- web/src/lib/mock/data.ts | 5 + web/src/lib/types.ts | 1 + 14 files changed, 669 insertions(+), 154 deletions(-) create mode 100644 db/tool_usage.go create mode 100644 db/tool_usage_test.go create mode 100644 server/tool_usage.go create mode 100644 server/tool_usage_test.go diff --git a/db/schema.sql b/db/schema.sql index 59b25ec..6738ea7 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -697,6 +697,21 @@ CREATE TABLE IF NOT EXISTS skill_usage ( 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); +-- 工具调用账本(见 db/tool_usage.go)。一次实际 CoreTool.Call 一行,只记归属维度, +-- 不保存工具参数或返回内容。刻意不设外键,任务、会话或自定义工具删除后仍保留统计。 +CREATE TABLE IF NOT EXISTS tool_usage ( + id BIGSERIAL PRIMARY KEY, + ts TIMESTAMPTZ NOT NULL DEFAULT now(), + tool_key TEXT NOT NULL, + agent_key TEXT, + task_id BIGINT, + exploration_id BIGINT, + intent_id BIGINT, + session_id TEXT +); +CREATE INDEX IF NOT EXISTS idx_tool_usage_tool ON tool_usage(tool_key, ts DESC); +CREATE INDEX IF NOT EXISTS idx_tool_usage_task ON tool_usage(task_id); + -- ===================================================================== -- H. 内置工具目录 -- ===================================================================== diff --git a/db/tool_usage.go b/db/tool_usage.go new file mode 100644 index 0000000..8c2632b --- /dev/null +++ b/db/tool_usage.go @@ -0,0 +1,46 @@ +package db + +// ToolUsage is one catalog tool invocation. It stores attribution dimensions only: +// tool arguments and results are deliberately excluded from the ledger. +type ToolUsage struct { + ToolKey string `json:"tool_key"` + AgentKey string `json:"agent_key"` + TaskID int64 `json:"task_id"` + ExplorationID int64 `json:"exploration_id"` + IntentID int64 `json:"intent_id"` + SessionID string `json:"session_id"` +} + +// InsertToolUsage appends one ledger row. Runtime callers treat metering as +// best-effort so a statistics failure never interrupts the tool itself. +func (d *DB) InsertToolUsage(u *ToolUsage) error { + _, err := d.Exec(` +INSERT INTO tool_usage(tool_key, agent_key, task_id, exploration_id, intent_id, session_id) +VALUES ($1, NULLIF($2,''), $3, $4, $5, NULLIF($6,''))`, + u.ToolKey, u.AgentKey, nullIfZero(u.TaskID), nullIfZero(u.ExplorationID), + nullIfZero(u.IntentID), u.SessionID) + return err +} + +// ToolUsageCounts returns invocation totals keyed by catalog tool key. Entries +// without calls are absent; API callers merge the result into the tools catalog. +func (d *DB) ToolUsageCounts() (map[string]int, error) { + rows, err := d.Query(` +SELECT tool_key, COUNT(*) +FROM tool_usage +GROUP BY tool_key`) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]int{} + for rows.Next() { + var key string + var calls int + if err := rows.Scan(&key, &calls); err != nil { + return nil, err + } + out[key] = calls + } + return out, rows.Err() +} diff --git a/db/tool_usage_test.go b/db/tool_usage_test.go new file mode 100644 index 0000000..3f8e580 --- /dev/null +++ b/db/tool_usage_test.go @@ -0,0 +1,44 @@ +package db + +import "testing" + +func TestToolUsageLedger(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) - skipping", err) + } + defer d.Close() + + const ( + toolA = "zz_test_tool_usage_a" + toolB = "zz_test_tool_usage_b" + ) + cleanup := func() { + _, _ = d.Exec(`DELETE FROM tool_usage WHERE tool_key IN ($1,$2)`, toolA, toolB) + } + cleanup() + defer cleanup() + + rows := []*ToolUsage{ + {ToolKey: toolA, AgentKey: "worker", TaskID: 991, ExplorationID: 5, IntentID: 7}, + {ToolKey: toolA, AgentKey: "planner", TaskID: 992, ExplorationID: 6}, + {ToolKey: toolA, AgentKey: "chatbot", SessionID: "conv-1"}, + {ToolKey: toolB, AgentKey: "worker", TaskID: 991}, + } + for _, row := range rows { + if err := d.InsertToolUsage(row); err != nil { + t.Fatalf("insert %s: %v", row.ToolKey, err) + } + } + + counts, err := d.ToolUsageCounts() + if err != nil { + t.Fatal(err) + } + if got := counts[toolA]; got != 3 { + t.Errorf("%s calls: want 3, got %d", toolA, got) + } + if got := counts[toolB]; got != 1 { + t.Errorf("%s calls: want 1, got %d", toolB, got) + } +} diff --git a/db/tools.go b/db/tools.go index 5e36d4c..f85e7c2 100644 --- a/db/tools.go +++ b/db/tools.go @@ -19,6 +19,7 @@ type Tool struct { Kind string `json:"kind"` // builtin | command | script | http Exec json.RawMessage `json:"exec"` // 自定义工具执行规格(kind!=builtin) Deferred bool `json:"deferred"` // schema 延迟(走 SearchExtraTools/ExecuteExtraTool) + Calls int `json:"calls"` // runtime ledger aggregate; not stored in tools } const toolCols = `key, system, description, schema, agents, enabled, kind, exec, deferred` diff --git a/server/assembly.go b/server/assembly.go index 426a9b6..a8b2d41 100644 --- a/server/assembly.go +++ b/server/assembly.go @@ -8,11 +8,11 @@ import ( "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" "github.com/Autumn-27/artex/traffic" + "github.com/Autumn-27/norma/skill" + actool "github.com/Autumn-27/norma/tool" ) // wireAgentAugment connects the PG agent_visibility table into the agent runtime: @@ -155,7 +155,7 @@ func wireAgentAugment(pg *db.DB, skillDir string, hostTools func() ([]actool.Cor if !contains(boundAgents, a.Key) { continue // only defer names this agent is actually bound to } - allNames = append(allNames, name) // schema withheld from the prompt + allNames = append(allNames, name) // schema withheld from the prompt globalNames = append(globalNames, name) // advertised in the deferred block globalSet[name] = true if unlock != nil { @@ -235,7 +235,7 @@ func wireTools(pg *db.DB, domainReg map[string]actool.CoreTool) { // interactive_shell is on, so Bash points at shell_open for interactive programs // without ever referencing a tool that isn't injected (§14.1/§14.2). const bashInteractiveShellNote = "\n\n需要【交互输入】的程序(msfconsole / ssh 交互登录 / mysql、psql、python 等 REPL / 密码或 yes/no 提示 / nc 反弹 shell)不要用 Bash(它没有 stdin、会卡住),改用 shell_open 开交互会话(用完 shell_close)。一次性、非交互命令仍用 Bash。" - agent.ToolResolve = func(_ context.Context, agentKey string, tools []actool.CoreTool) []actool.CoreTool { + agent.ToolResolve = func(ctx context.Context, agentKey string, tools []actool.CoreTool) []actool.CoreTool { rows, err := pg.ListTools() if err != nil { log.Printf("[tools] 读取工具表失败,按代码默认放行: %v", err) @@ -245,6 +245,14 @@ func wireTools(pg *db.DB, domainReg map[string]actool.CoreTool) { for _, t := range rows { byKey[t.Key] = t } + runInfo := agent.RunInfoFrom(ctx) + resolve := func(t actool.CoreTool, row *db.Tool) actool.CoreTool { + var schema map[string]any + if len(row.Schema) > 0 { + _ = json.Unmarshal(row.Schema, &schema) + } + return meterTool(agent.DecorateTool(t, row.Description, schema), pg, row.Key, agentKey, runInfo) + } out := tools[:0:0] for _, t := range tools { row, known := byKey[t.Name()] @@ -255,11 +263,7 @@ func wireTools(pg *db.DB, domainReg map[string]actool.CoreTool) { if !row.Enabled || !contains(row.Agents, agentKey) { continue // disabled globally or not bound to this agent → drop } - var schema map[string]any - if len(row.Schema) > 0 { - _ = json.Unmarshal(row.Schema, &schema) - } - out = append(out, agent.DecorateTool(t, row.Description, schema)) + out = append(out, resolve(t, row)) } // inject: domain tools bound to this agent in the DB but absent from the // incoming list. Covers agents (Auto, custom) whose base only has DefaultTools @@ -279,11 +283,7 @@ func wireTools(pg *db.DB, domainReg map[string]actool.CoreTool) { if !ok { continue // not a domain tool; custom/host tools are injected via hostTools() } - var schema map[string]any - if len(row.Schema) > 0 { - _ = json.Unmarshal(row.Schema, &schema) - } - out = append(out, agent.DecorateTool(inst, row.Description, schema)) + out = append(out, resolve(inst, row)) } } // shell hints: user-defined kind="shell" tools are not callable — they are diff --git a/server/server_mgmt.go b/server/server_mgmt.go index fbe2179..3b2fad7 100644 --- a/server/server_mgmt.go +++ b/server/server_mgmt.go @@ -734,6 +734,16 @@ func (s *Server) pgListTools(w http.ResponseWriter, r *http.Request) { if ts == nil { ts = []*db.Tool{} } + // Usage is best-effort decoration. Runtime tool resolution keeps using the plain + // catalog query, so agent assembly never pays for this aggregate. + counts, countErr := pg.ToolUsageCounts() + if countErr != nil { + log.Printf("[tools] 读取调用统计失败: %v", countErr) + } else { + for _, tool := range ts { + tool.Calls = counts[tool.Key] + } + } writeJSON(w, 200, map[string]any{"tools": ts}) } diff --git a/server/tool_usage.go b/server/tool_usage.go new file mode 100644 index 0000000..5f9e7c8 --- /dev/null +++ b/server/tool_usage.go @@ -0,0 +1,48 @@ +package server + +import ( + "context" + "encoding/json" + "log" + + actool "github.com/Autumn-27/norma/tool" + + "github.com/Autumn-27/artex/agent" + "github.com/Autumn-27/artex/db" +) + +type toolUsageRecorder interface { + InsertToolUsage(*db.ToolUsage) error +} + +// meteredTool records one row immediately before a catalog tool is invoked. It +// embeds the resolved tool so schema overrides, defaults and permission behavior +// remain unchanged. +type meteredTool struct { + actool.CoreTool + recorder toolUsageRecorder + toolKey string + agentKey string + ri agent.RunInfo +} + +func meterTool(t actool.CoreTool, recorder toolUsageRecorder, toolKey, agentKey string, ri agent.RunInfo) actool.CoreTool { + if recorder == nil { + return t + } + return &meteredTool{CoreTool: t, recorder: recorder, toolKey: toolKey, agentKey: agentKey, ri: ri} +} + +func (m *meteredTool) Call(ctx context.Context, input json.RawMessage, tc *actool.ToolContext) (actool.Result, error) { + if err := m.recorder.InsertToolUsage(&db.ToolUsage{ + ToolKey: m.toolKey, + AgentKey: m.agentKey, + TaskID: m.ri.TaskID, + ExplorationID: m.ri.ExplorationID, + IntentID: m.ri.IntentID, + SessionID: m.ri.SessionID, + }); err != nil { + log.Printf("[toolusage] insert %s: %v", m.toolKey, err) + } + return m.CoreTool.Call(ctx, input, tc) +} diff --git a/server/tool_usage_test.go b/server/tool_usage_test.go new file mode 100644 index 0000000..2976a9b --- /dev/null +++ b/server/tool_usage_test.go @@ -0,0 +1,73 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "testing" + + actool "github.com/Autumn-27/norma/tool" + + "github.com/Autumn-27/artex/agent" + "github.com/Autumn-27/artex/db" +) + +type recordingToolUsage struct { + rows []*db.ToolUsage + err error +} + +func (r *recordingToolUsage) InsertToolUsage(row *db.ToolUsage) error { + copy := *row + r.rows = append(r.rows, ©) + return r.err +} + +func TestMeterToolRecordsAttributionAndDelegates(t *testing.T) { + calls := 0 + base := actool.Build(actool.Spec{ + Name: "record_fact", + Run: func(context.Context, json.RawMessage, *actool.ToolContext) (actool.Result, error) { + calls++ + return actool.Result{}, nil + }, + }) + recorder := &recordingToolUsage{} + ri := agent.RunInfo{TaskID: 42, ExplorationID: 8, IntentID: 9, SessionID: "session-1"} + wrapped := meterTool(base, recorder, "record_fact", "worker", ri) + + if _, err := wrapped.Call(context.Background(), json.RawMessage(`{"secret":"not persisted"}`), nil); err != nil { + t.Fatal(err) + } + if calls != 1 { + t.Fatalf("delegate calls: want 1, got %d", calls) + } + if len(recorder.rows) != 1 { + t.Fatalf("usage rows: want 1, got %d", len(recorder.rows)) + } + got := recorder.rows[0] + if got.ToolKey != "record_fact" || got.AgentKey != "worker" || got.TaskID != 42 || + got.ExplorationID != 8 || got.IntentID != 9 || got.SessionID != "session-1" { + t.Fatalf("unexpected attribution: %+v", got) + } +} + +func TestMeterToolFailureDoesNotBreakInvocation(t *testing.T) { + calls := 0 + base := actool.Build(actool.Spec{ + Name: "list_facts", + Run: func(context.Context, json.RawMessage, *actool.ToolContext) (actool.Result, error) { + calls++ + return actool.Result{}, nil + }, + }) + recorder := &recordingToolUsage{err: errors.New("ledger unavailable")} + wrapped := meterTool(base, recorder, "list_facts", "worker", agent.RunInfo{}) + + if _, err := wrapped.Call(context.Background(), nil, nil); err != nil { + t.Fatalf("metering error leaked into tool call: %v", err) + } + if calls != 1 { + t.Fatalf("delegate calls: want 1, got %d", calls) + } +} diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index 155b365..e26497b 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -5,15 +5,27 @@ import * as React from "react"; import Link from "next/link"; import { - CheckIcon, + DndContext, + type DragEndEvent, + DragOverlay, + type DragStartEvent, + KeyboardSensor, + PointerSensor, + useDraggable, + useDroppable, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { ChevronRightIcon, EyeIcon, - FolderKanbanIcon, + GripVerticalIcon, Loader2Icon, PaperclipIcon, PauseIcon, PlayIcon, PlusIcon, + SaveIcon, SearchIcon, SlidersHorizontalIcon, StarIcon, @@ -63,6 +75,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"; import { Field, FieldContent, @@ -73,7 +86,9 @@ import { FieldSet, } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; +import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemMedia, ItemTitle } from "@/components/ui/item"; import { Label } from "@/components/ui/label"; +import { ScrollArea } from "@/components/ui/scroll-area"; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Sheet, @@ -100,6 +115,7 @@ import type { TaskCategory, TaskStatus, } from "@/lib/types"; +import { cn } from "@/lib/utils"; // fmtBytes renders a human file size for the upload manifest. function fmtBytes(n: number): string { @@ -343,6 +359,20 @@ export default function TasksPage() { load(); }, [load, loadCategories]); + const applyTaskCategoryMove = React.useCallback( + (taskID: string, category: TaskCategory | null) => { + setTasks((current) => { + const next = current.map((task) => + task.id === taskID ? { ...task, category_id: category?.id, category_name: category?.name } : task, + ); + lastRef.current = JSON.stringify(next); + return next; + }); + loadCategories(); + }, + [loadCategories], + ); + React.useEffect(() => { if (categoryFilter === "all" || categoryFilter === "uncategorized") return; if (!categories.some((category) => String(category.id) === categoryFilter)) setCategoryFilter("all"); @@ -602,7 +632,12 @@ export default function TasksPage() { )} - + @@ -1341,62 +1376,258 @@ function CompanyPicker({ ); } -function CategoryManagementSheet({ categories, onChanged }: { categories: TaskCategory[]; onChanged: () => void }) { +type CategoryManagementView = number | "uncategorized" | "new"; + +function CategoryDropTarget({ + value, + name, + count, + selected, + disabled, + onSelect, +}: { + value: string; + name: string; + count: number; + selected: boolean; + disabled: boolean; + onSelect: () => void; +}) { + const { isOver, setNodeRef } = useDroppable({ id: `category:${value}`, disabled }); + + return ( + + ); +} + +function DraggableCategoryTask({ task, disabled, moving }: { task: Task; disabled: boolean; moving: boolean }) { + const { attributes, isDragging, listeners, setNodeRef } = useDraggable({ + id: `task:${task.id}`, + disabled, + }); + + return ( + + + {moving ? ( + + ) : ( + + )} + + + + + {task.name?.trim() || task.description || `任务 #${task.id}`} + + + + #{task.id} · {task.description} + + + + + + + ); +} + +function CategoryTaskDragPreview({ task }: { task: Task }) { + return ( + + + + + + + {task.name?.trim() || task.description || `任务 #${task.id}`} + + #{task.id} + + + ); +} + +function CategoryManagementSheet({ + categories, + tasks, + onChanged, + onTaskMoved, +}: { + categories: TaskCategory[]; + tasks: Task[]; + onChanged: () => void; + onTaskMoved: (taskID: string, category: TaskCategory | null) => void; +}) { const [open, setOpen] = React.useState(false); - const [newName, setNewName] = React.useState(""); - const [editingID, setEditingID] = React.useState(null); - const [editingName, setEditingName] = React.useState(""); + const [selectedView, setSelectedView] = React.useState("new"); + const [draftName, setDraftName] = React.useState(""); const [saving, setSaving] = React.useState(false); - const [deleting, setDeleting] = React.useState(null); + const [deleteOpen, setDeleteOpen] = React.useState(false); + const [deleting, setDeleting] = React.useState(false); + const [movingTaskID, setMovingTaskID] = React.useState(null); + const [activeTaskID, setActiveTaskID] = React.useState(null); + const wasOpen = React.useRef(false); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor), + ); - async function createCategory() { - const name = newName.trim(); - if (!name || saving) return; - setSaving(true); - try { - await api.createTaskCategory(name); - setNewName(""); - toast.success("分类已创建"); - onChanged(); - } catch (error) { - toast.error(`创建分类失败:${(error as Error).message}`); - } finally { - setSaving(false); + React.useEffect(() => { + if (open && !wasOpen.current) { + const first = categories[0]; + if (first) { + setSelectedView(first.id); + setDraftName(first.name); + } else if (tasks.some((task) => task.category_id == null)) { + setSelectedView("uncategorized"); + setDraftName(""); + } else { + setSelectedView("new"); + setDraftName(""); + } } - } + wasOpen.current = open; + }, [categories, open, tasks]); - async function renameCategory() { - const name = editingName.trim(); - if (!editingID || !name || saving) return; + const selectedCategory = React.useMemo( + () => + typeof selectedView === "number" ? (categories.find((category) => category.id === selectedView) ?? null) : null, + [categories, selectedView], + ); + + const uncategorizedCount = React.useMemo(() => tasks.filter((task) => task.category_id == null).length, [tasks]); + + const visibleTasks = React.useMemo(() => { + if (selectedView === "uncategorized") return tasks.filter((task) => task.category_id == null); + if (typeof selectedView === "number") return tasks.filter((task) => task.category_id === selectedView); + return []; + }, [selectedView, tasks]); + + const activeTask = React.useMemo(() => tasks.find((task) => task.id === activeTaskID) ?? null, [activeTaskID, tasks]); + + const selectCategory = (category: TaskCategory) => { + setSelectedView(category.id); + setDraftName(category.name); + }; + + const selectUncategorized = () => { + setSelectedView("uncategorized"); + setDraftName(""); + }; + + const startNew = () => { + setSelectedView("new"); + setDraftName(""); + }; + + async function saveCategory() { + const name = draftName.trim(); + if (!name || saving || selectedView === "uncategorized") return; setSaving(true); try { - await api.renameTaskCategory(editingID, name); - setEditingID(null); - setEditingName(""); - toast.success("分类已更新"); + if (selectedView === "new") { + const created = await api.createTaskCategory(name); + setSelectedView(created.id); + setDraftName(created.name); + toast.success("分类已创建"); + } else { + const updated = await api.renameTaskCategory(selectedView, name); + setDraftName(updated.name); + toast.success("分类已更新"); + } onChanged(); } catch (error) { - toast.error(`更新分类失败:${(error as Error).message}`); + toast.error(`${selectedView === "new" ? "创建" : "更新"}分类失败:${(error as Error).message}`); } finally { setSaving(false); } } async function deleteCategory() { - if (!deleting || saving) return; - setSaving(true); + if (!selectedCategory || deleting) return; + const deletedID = selectedCategory.id; + setDeleting(true); try { - await api.deleteTaskCategory(deleting.id); + await api.deleteTaskCategory(deletedID); + const next = categories.find((category) => category.id !== deletedID); + if (next) selectCategory(next); + else selectUncategorized(); toast.success("分类已删除,关联任务已移入未分类"); - setDeleting(null); + setDeleteOpen(false); onChanged(); } catch (error) { toast.error(`删除分类失败:${(error as Error).message}`); } finally { - setSaving(false); + setDeleting(false); } } + async function moveTask(task: Task, destination: string) { + if (movingTaskID) return; + const category = + destination === "uncategorized" ? null : (categories.find((item) => item.id === Number(destination)) ?? null); + if (destination !== "uncategorized" && !category) { + toast.error("目标分类不存在,请刷新后重试"); + return; + } + if (task.category_id === category?.id || (task.category_id == null && category == null)) return; + + setMovingTaskID(task.id); + try { + await api.updateTaskCategory(task.id, category?.id); + onTaskMoved(task.id, category); + toast.success(`任务 #${task.id} 已移至「${category?.name ?? "未分类"}」`); + } catch (error) { + toast.error(`移动任务失败:${(error as Error).message}`); + } finally { + setMovingTaskID(null); + } + } + + function handleDragStart(event: DragStartEvent) { + const id = String(event.active.id); + setActiveTaskID(id.startsWith("task:") ? id.slice("task:".length) : null); + } + + function handleDragEnd(event: DragEndEvent) { + const activeID = String(event.active.id); + const taskID = activeID.startsWith("task:") ? activeID.slice("task:".length) : null; + setActiveTaskID(null); + if (!taskID || !event.over) return; + const destination = String(event.over.id); + if (!destination.startsWith("category:")) return; + const task = tasks.find((item) => item.id === taskID); + if (!task) return; + void moveTask(task, destination.slice("category:".length)); + } + + const saveLabel = saving ? "保存中" : selectedView === "new" ? "创建分类" : "保存修改"; + return ( <> @@ -1406,108 +1637,156 @@ function CategoryManagementSheet({ categories, onChanged }: { categories: TaskCa 分类管理 - - - 任务分类 - 创建、重命名或删除全局分类。删除分类不会删除任务。 + + + 任务分类管理 + 分类用于任务筛选与归档;修改不会影响任务执行,删除后任务会移入未分类。 -
- - - 新分类 -
- setNewName(event.target.value)} - placeholder="例如:外网评估" - maxLength={80} - onKeyDown={(event) => { - if (event.key === "Enter") void createCategory(); - }} - /> - -
-
-
-
- {categories.length === 0 &&

暂无分类

} - {categories.map((category) => ( -
- - {editingID === category.id ? ( - setEditingName(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") void renameCategory(); - if (event.key === "Escape") setEditingID(null); - }} - aria-label={`重命名分类 ${category.name}`} + setActiveTaskID(null)} + onDragEnd={handleDragEnd} + > +
+
+ + +
+ - ) : ( - + {categories.map((category) => ( + selectCategory(category)} + /> + ))} +
+
+
+ + + {selectedView !== "uncategorized" && ( + + 分类名称 + setDraftName(event.target.value)} + placeholder="例如:外网评估" + maxLength={80} + onKeyDown={(event) => { + if (event.key === "Enter") void saveCategory(); + }} + /> + + {selectedCategory + ? `当前有 ${selectedCategory.task_count} 个任务使用该分类。重命名后会同步更新任务列表。` + : "创建后可在新建任务和任务列表筛选中使用。"} + + )} - - {category.task_count} 个任务 - - {editingID === category.id && ( - + {selectedView !== "new" && ( + +
+
+ {selectedCategory ? "分类任务" : "未分类任务"} + + {selectedCategory + ? `该分类包含 ${visibleTasks.length} 个任务。` + : `当前有 ${visibleTasks.length} 个任务尚未分类。`} + +
+
+ {visibleTasks.length === 0 ? ( + + + {selectedCategory ? "该分类暂无任务" : "暂无未分类任务"} + 此处将在任务归入后显示内容。 + + + ) : ( + + {visibleTasks.map((task) => ( + + ))} + + )} +
)} - -
- ))} + +
-
+ {activeTask ? : null} + + + {selectedCategory && ( + + )} + + {selectedView !== "uncategorized" && ( + + )} + - !next && setDeleting(null)}> + - 删除分类“{deleting?.name}”? + 删除分类「{selectedCategory?.name || "未命名分类"}」? - 分类删除后,其中 {deleting?.task_count ?? 0} 个任务会自动移入“未分类”,任务数据不会被删除。 + 分类删除后,其中 {selectedCategory?.task_count ?? 0} 个任务会自动移入“未分类”,任务数据不会被删除。 - 取消 - void deleteCategory()} disabled={saving}> - 删除 + 取消 + { + event.preventDefault(); + void deleteCategory(); + }} + > + {deleting && } + {deleting ? "删除中" : "删除"} diff --git a/web/src/app/(main)/system/llm/page.tsx b/web/src/app/(main)/system/llm/page.tsx index 2a5db5b..b08c6ef 100644 --- a/web/src/app/(main)/system/llm/page.tsx +++ b/web/src/app/(main)/system/llm/page.tsx @@ -415,8 +415,6 @@ function ProfileSheet({ e.preventDefault()} > diff --git a/web/src/app/(main)/system/tools/page.tsx b/web/src/app/(main)/system/tools/page.tsx index d8a320c..a3ca873 100644 --- a/web/src/app/(main)/system/tools/page.tsx +++ b/web/src/app/(main)/system/tools/page.tsx @@ -313,6 +313,7 @@ function ToolEditor({ function ToolGridCard({ tool, onClick }: { tool: Tool; onClick: () => void }) { return (

{tool.description || "(无描述)"} diff --git a/web/src/components/ui/sheet.tsx b/web/src/components/ui/sheet.tsx index ce2b56a..8d0e1d3 100644 --- a/web/src/components/ui/sheet.tsx +++ b/web/src/components/ui/sheet.tsx @@ -3,7 +3,7 @@ import * as React from "react" import { Dialog as SheetPrimitive } from "radix-ui" -import { cn, radixOverlayWasOpenAtPointerDown } from "@/lib/utils" +import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { XIcon } from "lucide-react" @@ -50,7 +50,6 @@ function SheetContent({ children, side = "right", showCloseButton = true, - onInteractOutside, ...props }: React.ComponentProps & { side?: "top" | "right" | "bottom" | "left" @@ -62,18 +61,6 @@ function SheetContent({ { - const target = e.detail.originalEvent.target as Element | null - const onOverlay = !!target?.closest?.("[data-slot='sheet-overlay']") - if (!onOverlay || radixOverlayWasOpenAtPointerDown()) e.preventDefault() - onInteractOutside?.(e) - }} className={cn( "fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10", className diff --git a/web/src/lib/mock/data.ts b/web/src/lib/mock/data.ts index 4035f93..51a0007 100644 --- a/web/src/lib/mock/data.ts +++ b/web/src/lib/mock/data.ts @@ -2935,6 +2935,7 @@ export const tools: Tool[] = [ agents: ["worker", "mainagent"], enabled: true, kind: "builtin", + calls: 1842, }, { key: "report_finding", @@ -2953,6 +2954,7 @@ export const tools: Tool[] = [ agents: ["worker"], enabled: true, kind: "builtin", + calls: 96, }, { key: "upsert_asset", @@ -2962,6 +2964,7 @@ export const tools: Tool[] = [ agents: ["worker"], enabled: true, kind: "builtin", + calls: 417, }, { key: "list_goals", @@ -2971,6 +2974,7 @@ export const tools: Tool[] = [ agents: ["planner"], enabled: true, kind: "builtin", + calls: 238, }, { key: "nuclei_scan", @@ -2985,6 +2989,7 @@ export const tools: Tool[] = [ enabled: true, kind: "command", exec: { command: "nuclei", args: ["-u", "{{target}}"] }, + calls: 31, }, ]; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 238939d..6377d97 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -935,6 +935,7 @@ export interface Tool { // eslint-disable-next-line @typescript-eslint/no-explicit-any exec?: Record; // 自定义工具执行规格(kind!=builtin) deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool) + calls?: number; // persistent runtime invocation count (older APIs may omit it) } // ---- Stats ----