抽屉优化

This commit is contained in:
pphua committed 2026-08-25 12:05:26 +08:00
1 parent 7234874797
commit d968727460
14 files changed
+669 -154

No files matched your search

+15
View File
@@ -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. 内置工具目录
-- =====================================================================
+46
View File
@@ -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()
}
+44
View File
@@ -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)
}
}
+1
View File
@@ -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`
+14 -14
View File
@@ -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
+10
View File
@@ -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})
}
+48
View File
@@ -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)
}
+73
View File
@@ -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, &copy)
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)
}
}
+403 -124
View File
@@ -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() {
</>
)}
<ConcurrencySettingsDialog />
<CategoryManagementSheet categories={categories} onChanged={refreshCategoriesAndTasks} />
<CategoryManagementSheet
categories={categories}
tasks={tasks}
onChanged={refreshCategoriesAndTasks}
onTaskMoved={applyTaskCategoryMove}
/>
<CreateTaskSheet tasks={tasks} categories={categories} onCreated={refreshCategoriesAndTasks} />
</div>
@@ -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 (
<button
ref={setNodeRef}
type="button"
className={cn(
"min-w-0 rounded-md border border-transparent px-2.5 py-2 text-left transition-colors",
selected ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
isOver && "border-primary bg-primary/10 text-foreground",
)}
onClick={onSelect}
>
<span className="block truncate font-medium text-sm">{name}</span>
<span className="block truncate text-muted-foreground text-xs">{isOver ? "松开以移动" : `${count} 个任务`}</span>
</button>
);
}
function DraggableCategoryTask({ task, disabled, moving }: { task: Task; disabled: boolean; moving: boolean }) {
const { attributes, isDragging, listeners, setNodeRef } = useDraggable({
id: `task:${task.id}`,
disabled,
});
return (
<Item ref={setNodeRef} variant="outline" size="sm" className={cn(isDragging && "opacity-40")}>
<ItemMedia className="group-has-data-[slot=item-description]/item:self-center group-has-data-[slot=item-description]/item:translate-y-0">
{moving ? (
<Spinner />
) : (
<Button
type="button"
size="icon-xs"
variant="ghost"
className="touch-none cursor-grab active:cursor-grabbing"
disabled={disabled}
{...listeners}
{...attributes}
aria-label={`拖动任务 #${task.id}`}
title="拖动任务"
>
<GripVerticalIcon />
</Button>
)}
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="w-full min-w-0">
<Link href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`} className="truncate hover:underline">
{task.name?.trim() || task.description || `任务 #${task.id}`}
</Link>
</ItemTitle>
<ItemDescription className="line-clamp-1">
#{task.id} · {task.description}
</ItemDescription>
</ItemContent>
<ItemActions>
<StatusBadge domain="task" value={task.status} />
</ItemActions>
</Item>
);
}
function CategoryTaskDragPreview({ task }: { task: Task }) {
return (
<Item variant="outline" size="sm" className="w-80 bg-background shadow-lg">
<ItemMedia className="group-has-data-[slot=item-description]/item:self-center group-has-data-[slot=item-description]/item:translate-y-0">
<GripVerticalIcon className="size-4 text-muted-foreground" />
</ItemMedia>
<ItemContent className="min-w-0">
<ItemTitle className="w-full min-w-0 truncate">
{task.name?.trim() || task.description || `任务 #${task.id}`}
</ItemTitle>
<ItemDescription className="line-clamp-1">#{task.id}</ItemDescription>
</ItemContent>
</Item>
);
}
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<number | null>(null);
const [editingName, setEditingName] = React.useState("");
const [selectedView, setSelectedView] = React.useState<CategoryManagementView>("new");
const [draftName, setDraftName] = React.useState("");
const [saving, setSaving] = React.useState(false);
const [deleting, setDeleting] = React.useState<TaskCategory | null>(null);
const [deleteOpen, setDeleteOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState(false);
const [movingTaskID, setMovingTaskID] = React.useState<string | null>(null);
const [activeTaskID, setActiveTaskID] = React.useState<string | null>(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 (
<>
<Sheet open={open} onOpenChange={setOpen}>
@@ -1406,108 +1637,156 @@ function CategoryManagementSheet({ categories, onChanged }: { categories: TaskCa
分类管理
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-full sm:max-w-md">
<SheetHeader>
<SheetTitle>任务分类</SheetTitle>
<SheetDescription>创建、重命名或删除全局分类。删除分类不会删除任务。</SheetDescription>
<SheetContent className="grid h-full w-full! max-w-none! grid-rows-[auto_minmax(0,1fr)_auto] gap-0 overflow-hidden p-0 sm:w-[48rem]! sm:max-w-[48rem]!">
<SheetHeader className="border-b px-6 py-5">
<SheetTitle>任务分类管理</SheetTitle>
<SheetDescription>分类用于任务筛选与归档;修改不会影响任务执行,删除后任务会移入未分类。</SheetDescription>
</SheetHeader>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4">
<FieldGroup>
<Field>
<FieldLabel htmlFor="new-task-category">新分类</FieldLabel>
<div className="flex gap-2">
<Input
id="new-task-category"
value={newName}
onChange={(event) => setNewName(event.target.value)}
placeholder="例如:外网评估"
maxLength={80}
onKeyDown={(event) => {
if (event.key === "Enter") void createCategory();
}}
/>
<Button
size="icon"
onClick={() => void createCategory()}
disabled={!newName.trim() || saving}
aria-label="创建分类"
>
{saving && editingID == null && deleting == null ? <Spinner /> : <PlusIcon />}
</Button>
</div>
</Field>
</FieldGroup>
<div className="flex flex-col gap-2">
{categories.length === 0 && <p className="py-8 text-center text-sm text-muted-foreground">暂无分类</p>}
{categories.map((category) => (
<div key={category.id} className="flex min-w-0 items-center gap-2 rounded-md border p-2">
<FolderKanbanIcon className="size-4 shrink-0 text-muted-foreground" />
{editingID === category.id ? (
<Input
className="min-w-0 flex-1"
value={editingName}
maxLength={80}
onChange={(event) => setEditingName(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") void renameCategory();
if (event.key === "Escape") setEditingID(null);
}}
aria-label={`重命名分类 ${category.name}`}
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragCancel={() => setActiveTaskID(null)}
onDragEnd={handleDragEnd}
>
<div className="grid min-h-0 overflow-y-auto lg:grid-cols-[15rem_minmax(0,1fr)] lg:overflow-hidden">
<div className="flex min-h-0 flex-col border-b p-3 lg:border-r lg:border-b-0">
<Button type="button" variant="outline" className="w-full" onClick={startNew}>
<PlusIcon data-icon="inline-start" />
新建分类
</Button>
<ScrollArea className="mt-2 max-h-44 lg:max-h-none lg:flex-1">
<div className="flex flex-col gap-1 pr-2">
<CategoryDropTarget
value="uncategorized"
name="未分类"
count={uncategorizedCount}
selected={selectedView === "uncategorized"}
disabled={movingTaskID != null}
onSelect={selectUncategorized}
/>
) : (
<button
type="button"
className="min-w-0 flex-1 truncate text-left text-sm font-medium"
onClick={() => {
setEditingID(category.id);
setEditingName(category.name);
}}
title="点击重命名"
>
{category.name}
</button>
{categories.map((category) => (
<CategoryDropTarget
key={category.id}
value={String(category.id)}
name={category.name}
count={category.task_count}
selected={selectedView === category.id}
disabled={movingTaskID != null}
onSelect={() => selectCategory(category)}
/>
))}
</div>
</ScrollArea>
</div>
<ScrollArea className="min-h-0">
<FieldGroup className="p-6">
{selectedView !== "uncategorized" && (
<Field>
<FieldLabel htmlFor="task-category-name">分类名称</FieldLabel>
<Input
id="task-category-name"
value={draftName}
onChange={(event) => setDraftName(event.target.value)}
placeholder="例如:外网评估"
maxLength={80}
onKeyDown={(event) => {
if (event.key === "Enter") void saveCategory();
}}
/>
<FieldDescription>
{selectedCategory
? `当前有 ${selectedCategory.task_count} 个任务使用该分类。重命名后会同步更新任务列表。`
: "创建后可在新建任务和任务列表筛选中使用。"}
</FieldDescription>
</Field>
)}
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{category.task_count} 个任务
</span>
{editingID === category.id && (
<Button
size="icon-xs"
variant="ghost"
onClick={() => void renameCategory()}
disabled={!editingName.trim() || saving}
aria-label="保存分类名称"
>
{saving ? <Spinner /> : <CheckIcon />}
</Button>
{selectedView !== "new" && (
<Field>
<div className="flex flex-wrap items-end justify-between gap-2">
<div className="flex min-w-0 flex-col gap-1">
<FieldLabel>{selectedCategory ? "分类任务" : "未分类任务"}</FieldLabel>
<FieldDescription>
{selectedCategory
? `该分类包含 ${visibleTasks.length} 个任务。`
: `当前有 ${visibleTasks.length} 个任务尚未分类。`}
</FieldDescription>
</div>
</div>
{visibleTasks.length === 0 ? (
<Empty className="min-h-36 border">
<EmptyHeader>
<EmptyTitle>{selectedCategory ? "该分类暂无任务" : "暂无未分类任务"}</EmptyTitle>
<EmptyDescription>此处将在任务归入后显示内容。</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<ItemGroup className="gap-2">
{visibleTasks.map((task) => (
<DraggableCategoryTask
key={task.id}
task={task}
moving={movingTaskID === task.id}
disabled={movingTaskID != null}
/>
))}
</ItemGroup>
)}
</Field>
)}
<Button
size="icon-xs"
variant="ghost"
onClick={() => setDeleting(category)}
disabled={saving}
aria-label={`删除分类 ${category.name}`}
>
<Trash2Icon />
</Button>
</div>
))}
</FieldGroup>
</ScrollArea>
</div>
</div>
<DragOverlay>{activeTask ? <CategoryTaskDragPreview task={activeTask} /> : null}</DragOverlay>
</DndContext>
<SheetFooter className="border-t px-6 py-4 sm:flex-row sm:items-center">
{selectedCategory && (
<Button
type="button"
variant="destructive"
className="sm:mr-auto"
disabled={saving || deleting}
onClick={() => setDeleteOpen(true)}
>
<Trash2Icon data-icon="inline-start" />
删除分类
</Button>
)}
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
关闭
</Button>
{selectedView !== "uncategorized" && (
<Button
type="button"
disabled={!draftName.trim() || saving || deleting}
onClick={() => void saveCategory()}
>
{saving ? <Spinner data-icon="inline-start" /> : <SaveIcon data-icon="inline-start" />}
{saveLabel}
</Button>
)}
</SheetFooter>
</SheetContent>
</Sheet>
<AlertDialog open={deleting != null} onOpenChange={(next) => !next && setDeleting(null)}>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>删除分类“{deleting?.name}”?</AlertDialogTitle>
<AlertDialogTitle>删除分类「{selectedCategory?.name || "未命名分类"}」?</AlertDialogTitle>
<AlertDialogDescription>
分类删除后,其中 {deleting?.task_count ?? 0} 个任务会自动移入“未分类”,任务数据不会被删除。
分类删除后,其中 {selectedCategory?.task_count ?? 0} 个任务会自动移入“未分类”,任务数据不会被删除。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => void deleteCategory()} disabled={saving}>
删除
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={(event) => {
event.preventDefault();
void deleteCategory();
}}
>
{deleting && <Spinner data-icon="inline-start" />}
{deleting ? "删除中" : "删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
-2
View File
@@ -415,8 +415,6 @@ function ProfileSheet({
<SheetContent
side="right"
className="flex flex-col gap-0 p-0 data-[side=right]:min-w-[420px] data-[side=right]:sm:max-w-xl"
// 表单填到一半时点外面不该丢内容(Esc / ✕ 仍可关闭)。
onInteractOutside={(e) => e.preventDefault()}
>
<SheetHeader className="px-4">
<SheetTitle className="flex items-center gap-2">
+8
View File
@@ -313,6 +313,7 @@ function ToolEditor({
function ToolGridCard({ tool, onClick }: { tool: Tool; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="hover:border-primary/50 hover:bg-muted/40 focus-visible:ring-ring flex flex-col gap-2 rounded-lg border p-4 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
@@ -337,6 +338,13 @@ function ToolGridCard({ tool, onClick }: { tool: Tool; onClick: () => void }) {
已停用
</Badge>
)}
<Badge
variant="secondary"
className="ml-auto px-1.5 py-0 text-[10px] tabular-nums"
title={`累计调用 ${tool.calls ?? 0} 次`}
>
调用 {tool.calls ?? 0}
</Badge>
</div>
<p className="text-muted-foreground line-clamp-1 h-4 text-xs">
{tool.description || "(无描述)"}
+1 -14
View File
@@ -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<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
@@ -62,18 +61,6 @@ function SheetContent({
<SheetPrimitive.Content
data-slot="sheet-content"
data-side={side}
// 关闭抽屉的唯一条件:点击的是遮罩(灰色背景)本身,且此刻没有任何 Radix 弹层
// (Select 下拉等)开着。其余"外部交互"一律挡掉(Esc、右上角 ✕ 仍可关):
// · 点弹层里的选项 → target 不是遮罩 → 挡;
// · 弹层开着时点抽屉外/遮罩想收起它 → 有弹层开着 → 挡(只收弹层,不关抽屉);
// · 弹层收起时焦点移动被 Radix 误判为焦点移出 → target 不是遮罩 → 挡。
// (onInteractOutside 在指针/焦点两条路径都会触发。)调用方仍可追加逻辑。
onInteractOutside={(e) => {
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
+5
View File
@@ -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,
},
];
+1
View File
@@ -935,6 +935,7 @@ export interface Tool {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
exec?: Record<string, any>; // 自定义工具执行规格(kind!=builtin)
deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool)
calls?: number; // persistent runtime invocation count (older APIs may omit it)
}
// ---- Stats ----