抽屉优化
This commit is contained in:
1 parent
7234874797
commit
d968727460
14 files changed
+669
-154
No files matched your search
@@ -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_skill ON skill_usage(skill, ts DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_skill_usage_task ON skill_usage(task_id);
|
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. 内置工具目录
|
-- H. 内置工具目录
|
||||||
-- =====================================================================
|
-- =====================================================================
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ type Tool struct {
|
|||||||
Kind string `json:"kind"` // builtin | command | script | http
|
Kind string `json:"kind"` // builtin | command | script | http
|
||||||
Exec json.RawMessage `json:"exec"` // 自定义工具执行规格(kind!=builtin)
|
Exec json.RawMessage `json:"exec"` // 自定义工具执行规格(kind!=builtin)
|
||||||
Deferred bool `json:"deferred"` // schema 延迟(走 SearchExtraTools/ExecuteExtraTool)
|
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`
|
const toolCols = `key, system, description, schema, agents, enabled, kind, exec, deferred`
|
||||||
|
|||||||
+14
-14
@@ -8,11 +8,11 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"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/agent"
|
||||||
"github.com/Autumn-27/artex/db"
|
"github.com/Autumn-27/artex/db"
|
||||||
"github.com/Autumn-27/artex/traffic"
|
"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:
|
// 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) {
|
if !contains(boundAgents, a.Key) {
|
||||||
continue // only defer names this agent is actually bound to
|
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
|
globalNames = append(globalNames, name) // advertised in the deferred block
|
||||||
globalSet[name] = true
|
globalSet[name] = true
|
||||||
if unlock != nil {
|
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
|
// 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).
|
// 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。"
|
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()
|
rows, err := pg.ListTools()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[tools] 读取工具表失败,按代码默认放行: %v", err)
|
log.Printf("[tools] 读取工具表失败,按代码默认放行: %v", err)
|
||||||
@@ -245,6 +245,14 @@ func wireTools(pg *db.DB, domainReg map[string]actool.CoreTool) {
|
|||||||
for _, t := range rows {
|
for _, t := range rows {
|
||||||
byKey[t.Key] = t
|
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]
|
out := tools[:0:0]
|
||||||
for _, t := range tools {
|
for _, t := range tools {
|
||||||
row, known := byKey[t.Name()]
|
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) {
|
if !row.Enabled || !contains(row.Agents, agentKey) {
|
||||||
continue // disabled globally or not bound to this agent → drop
|
continue // disabled globally or not bound to this agent → drop
|
||||||
}
|
}
|
||||||
var schema map[string]any
|
out = append(out, resolve(t, row))
|
||||||
if len(row.Schema) > 0 {
|
|
||||||
_ = json.Unmarshal(row.Schema, &schema)
|
|
||||||
}
|
|
||||||
out = append(out, agent.DecorateTool(t, row.Description, schema))
|
|
||||||
}
|
}
|
||||||
// inject: domain tools bound to this agent in the DB but absent from the
|
// 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
|
// 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 {
|
if !ok {
|
||||||
continue // not a domain tool; custom/host tools are injected via hostTools()
|
continue // not a domain tool; custom/host tools are injected via hostTools()
|
||||||
}
|
}
|
||||||
var schema map[string]any
|
out = append(out, resolve(inst, row))
|
||||||
if len(row.Schema) > 0 {
|
|
||||||
_ = json.Unmarshal(row.Schema, &schema)
|
|
||||||
}
|
|
||||||
out = append(out, agent.DecorateTool(inst, row.Description, schema))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// shell hints: user-defined kind="shell" tools are not callable — they are
|
// shell hints: user-defined kind="shell" tools are not callable — they are
|
||||||
|
|||||||
@@ -734,6 +734,16 @@ func (s *Server) pgListTools(w http.ResponseWriter, r *http.Request) {
|
|||||||
if ts == nil {
|
if ts == nil {
|
||||||
ts = []*db.Tool{}
|
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})
|
writeJSON(w, 200, map[string]any{"tools": ts})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,15 +5,27 @@ import * as React from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
DragOverlay,
|
||||||
|
type DragStartEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useDraggable,
|
||||||
|
useDroppable,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
FolderKanbanIcon,
|
GripVerticalIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
PaperclipIcon,
|
PaperclipIcon,
|
||||||
PauseIcon,
|
PauseIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
SaveIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
SlidersHorizontalIcon,
|
SlidersHorizontalIcon,
|
||||||
StarIcon,
|
StarIcon,
|
||||||
@@ -63,6 +75,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
FieldContent,
|
FieldContent,
|
||||||
@@ -73,7 +86,9 @@ import {
|
|||||||
FieldSet,
|
FieldSet,
|
||||||
} from "@/components/ui/field";
|
} from "@/components/ui/field";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { Label } from "@/components/ui/label";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
@@ -100,6 +115,7 @@ import type {
|
|||||||
TaskCategory,
|
TaskCategory,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// fmtBytes renders a human file size for the upload manifest.
|
// fmtBytes renders a human file size for the upload manifest.
|
||||||
function fmtBytes(n: number): string {
|
function fmtBytes(n: number): string {
|
||||||
@@ -343,6 +359,20 @@ export default function TasksPage() {
|
|||||||
load();
|
load();
|
||||||
}, [load, loadCategories]);
|
}, [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(() => {
|
React.useEffect(() => {
|
||||||
if (categoryFilter === "all" || categoryFilter === "uncategorized") return;
|
if (categoryFilter === "all" || categoryFilter === "uncategorized") return;
|
||||||
if (!categories.some((category) => String(category.id) === categoryFilter)) setCategoryFilter("all");
|
if (!categories.some((category) => String(category.id) === categoryFilter)) setCategoryFilter("all");
|
||||||
@@ -602,7 +632,12 @@ export default function TasksPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<ConcurrencySettingsDialog />
|
<ConcurrencySettingsDialog />
|
||||||
<CategoryManagementSheet categories={categories} onChanged={refreshCategoriesAndTasks} />
|
<CategoryManagementSheet
|
||||||
|
categories={categories}
|
||||||
|
tasks={tasks}
|
||||||
|
onChanged={refreshCategoriesAndTasks}
|
||||||
|
onTaskMoved={applyTaskCategoryMove}
|
||||||
|
/>
|
||||||
<CreateTaskSheet tasks={tasks} categories={categories} onCreated={refreshCategoriesAndTasks} />
|
<CreateTaskSheet tasks={tasks} categories={categories} onCreated={refreshCategoriesAndTasks} />
|
||||||
</div>
|
</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 [open, setOpen] = React.useState(false);
|
||||||
const [newName, setNewName] = React.useState("");
|
const [selectedView, setSelectedView] = React.useState<CategoryManagementView>("new");
|
||||||
const [editingID, setEditingID] = React.useState<number | null>(null);
|
const [draftName, setDraftName] = React.useState("");
|
||||||
const [editingName, setEditingName] = React.useState("");
|
|
||||||
const [saving, setSaving] = React.useState(false);
|
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() {
|
React.useEffect(() => {
|
||||||
const name = newName.trim();
|
if (open && !wasOpen.current) {
|
||||||
if (!name || saving) return;
|
const first = categories[0];
|
||||||
setSaving(true);
|
if (first) {
|
||||||
try {
|
setSelectedView(first.id);
|
||||||
await api.createTaskCategory(name);
|
setDraftName(first.name);
|
||||||
setNewName("");
|
} else if (tasks.some((task) => task.category_id == null)) {
|
||||||
toast.success("分类已创建");
|
setSelectedView("uncategorized");
|
||||||
onChanged();
|
setDraftName("");
|
||||||
} catch (error) {
|
} else {
|
||||||
toast.error(`创建分类失败:${(error as Error).message}`);
|
setSelectedView("new");
|
||||||
} finally {
|
setDraftName("");
|
||||||
setSaving(false);
|
}
|
||||||
}
|
}
|
||||||
}
|
wasOpen.current = open;
|
||||||
|
}, [categories, open, tasks]);
|
||||||
|
|
||||||
async function renameCategory() {
|
const selectedCategory = React.useMemo(
|
||||||
const name = editingName.trim();
|
() =>
|
||||||
if (!editingID || !name || saving) return;
|
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);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await api.renameTaskCategory(editingID, name);
|
if (selectedView === "new") {
|
||||||
setEditingID(null);
|
const created = await api.createTaskCategory(name);
|
||||||
setEditingName("");
|
setSelectedView(created.id);
|
||||||
toast.success("分类已更新");
|
setDraftName(created.name);
|
||||||
|
toast.success("分类已创建");
|
||||||
|
} else {
|
||||||
|
const updated = await api.renameTaskCategory(selectedView, name);
|
||||||
|
setDraftName(updated.name);
|
||||||
|
toast.success("分类已更新");
|
||||||
|
}
|
||||||
onChanged();
|
onChanged();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(`更新分类失败:${(error as Error).message}`);
|
toast.error(`${selectedView === "new" ? "创建" : "更新"}分类失败:${(error as Error).message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteCategory() {
|
async function deleteCategory() {
|
||||||
if (!deleting || saving) return;
|
if (!selectedCategory || deleting) return;
|
||||||
setSaving(true);
|
const deletedID = selectedCategory.id;
|
||||||
|
setDeleting(true);
|
||||||
try {
|
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("分类已删除,关联任务已移入未分类");
|
toast.success("分类已删除,关联任务已移入未分类");
|
||||||
setDeleting(null);
|
setDeleteOpen(false);
|
||||||
onChanged();
|
onChanged();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error(`删除分类失败:${(error as Error).message}`);
|
toast.error(`删除分类失败:${(error as Error).message}`);
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Sheet open={open} onOpenChange={setOpen}>
|
<Sheet open={open} onOpenChange={setOpen}>
|
||||||
@@ -1406,108 +1637,156 @@ function CategoryManagementSheet({ categories, onChanged }: { categories: TaskCa
|
|||||||
分类管理
|
分类管理
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="right" className="w-full sm:max-w-md">
|
<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>
|
<SheetHeader className="border-b px-6 py-5">
|
||||||
<SheetTitle>任务分类</SheetTitle>
|
<SheetTitle>任务分类管理</SheetTitle>
|
||||||
<SheetDescription>创建、重命名或删除全局分类。删除分类不会删除任务。</SheetDescription>
|
<SheetDescription>分类用于任务筛选与归档;修改不会影响任务执行,删除后任务会移入未分类。</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 pb-4">
|
<DndContext
|
||||||
<FieldGroup>
|
sensors={sensors}
|
||||||
<Field>
|
onDragStart={handleDragStart}
|
||||||
<FieldLabel htmlFor="new-task-category">新分类</FieldLabel>
|
onDragCancel={() => setActiveTaskID(null)}
|
||||||
<div className="flex gap-2">
|
onDragEnd={handleDragEnd}
|
||||||
<Input
|
>
|
||||||
id="new-task-category"
|
<div className="grid min-h-0 overflow-y-auto lg:grid-cols-[15rem_minmax(0,1fr)] lg:overflow-hidden">
|
||||||
value={newName}
|
<div className="flex min-h-0 flex-col border-b p-3 lg:border-r lg:border-b-0">
|
||||||
onChange={(event) => setNewName(event.target.value)}
|
<Button type="button" variant="outline" className="w-full" onClick={startNew}>
|
||||||
placeholder="例如:外网评估"
|
<PlusIcon data-icon="inline-start" />
|
||||||
maxLength={80}
|
新建分类
|
||||||
onKeyDown={(event) => {
|
</Button>
|
||||||
if (event.key === "Enter") void createCategory();
|
<ScrollArea className="mt-2 max-h-44 lg:max-h-none lg:flex-1">
|
||||||
}}
|
<div className="flex flex-col gap-1 pr-2">
|
||||||
/>
|
<CategoryDropTarget
|
||||||
<Button
|
value="uncategorized"
|
||||||
size="icon"
|
name="未分类"
|
||||||
onClick={() => void createCategory()}
|
count={uncategorizedCount}
|
||||||
disabled={!newName.trim() || saving}
|
selected={selectedView === "uncategorized"}
|
||||||
aria-label="创建分类"
|
disabled={movingTaskID != null}
|
||||||
>
|
onSelect={selectUncategorized}
|
||||||
{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}`}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
{categories.map((category) => (
|
||||||
<button
|
<CategoryDropTarget
|
||||||
type="button"
|
key={category.id}
|
||||||
className="min-w-0 flex-1 truncate text-left text-sm font-medium"
|
value={String(category.id)}
|
||||||
onClick={() => {
|
name={category.name}
|
||||||
setEditingID(category.id);
|
count={category.task_count}
|
||||||
setEditingName(category.name);
|
selected={selectedView === category.id}
|
||||||
}}
|
disabled={movingTaskID != null}
|
||||||
title="点击重命名"
|
onSelect={() => selectCategory(category)}
|
||||||
>
|
/>
|
||||||
{category.name}
|
))}
|
||||||
</button>
|
</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">
|
{selectedView !== "new" && (
|
||||||
{category.task_count} 个任务
|
<Field>
|
||||||
</span>
|
<div className="flex flex-wrap items-end justify-between gap-2">
|
||||||
{editingID === category.id && (
|
<div className="flex min-w-0 flex-col gap-1">
|
||||||
<Button
|
<FieldLabel>{selectedCategory ? "分类任务" : "未分类任务"}</FieldLabel>
|
||||||
size="icon-xs"
|
<FieldDescription>
|
||||||
variant="ghost"
|
{selectedCategory
|
||||||
onClick={() => void renameCategory()}
|
? `该分类包含 ${visibleTasks.length} 个任务。`
|
||||||
disabled={!editingName.trim() || saving}
|
: `当前有 ${visibleTasks.length} 个任务尚未分类。`}
|
||||||
aria-label="保存分类名称"
|
</FieldDescription>
|
||||||
>
|
</div>
|
||||||
{saving ? <Spinner /> : <CheckIcon />}
|
</div>
|
||||||
</Button>
|
{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
|
</FieldGroup>
|
||||||
size="icon-xs"
|
</ScrollArea>
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setDeleting(category)}
|
|
||||||
disabled={saving}
|
|
||||||
aria-label={`删除分类 ${category.name}`}
|
|
||||||
>
|
|
||||||
<Trash2Icon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</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>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
<AlertDialog open={deleting != null} onOpenChange={(next) => !next && setDeleting(null)}>
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>删除分类“{deleting?.name}”?</AlertDialogTitle>
|
<AlertDialogTitle>删除分类「{selectedCategory?.name || "未命名分类"}」?</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
分类删除后,其中 {deleting?.task_count ?? 0} 个任务会自动移入“未分类”,任务数据不会被删除。
|
分类删除后,其中 {selectedCategory?.task_count ?? 0} 个任务会自动移入“未分类”,任务数据不会被删除。
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={() => void deleteCategory()} disabled={saving}>
|
<AlertDialogAction
|
||||||
删除
|
variant="destructive"
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
void deleteCategory();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deleting && <Spinner data-icon="inline-start" />}
|
||||||
|
{deleting ? "删除中" : "删除"}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -415,8 +415,6 @@ function ProfileSheet({
|
|||||||
<SheetContent
|
<SheetContent
|
||||||
side="right"
|
side="right"
|
||||||
className="flex flex-col gap-0 p-0 data-[side=right]:min-w-[420px] data-[side=right]:sm:max-w-xl"
|
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">
|
<SheetHeader className="px-4">
|
||||||
<SheetTitle className="flex items-center gap-2">
|
<SheetTitle className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -313,6 +313,7 @@ function ToolEditor({
|
|||||||
function ToolGridCard({ tool, onClick }: { tool: Tool; onClick: () => void }) {
|
function ToolGridCard({ tool, onClick }: { tool: Tool; onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onClick}
|
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"
|
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>
|
||||||
)}
|
)}
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="ml-auto px-1.5 py-0 text-[10px] tabular-nums"
|
||||||
|
title={`累计调用 ${tool.calls ?? 0} 次`}
|
||||||
|
>
|
||||||
|
调用 {tool.calls ?? 0}
|
||||||
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground line-clamp-1 h-4 text-xs">
|
<p className="text-muted-foreground line-clamp-1 h-4 text-xs">
|
||||||
{tool.description || "(无描述)"}
|
{tool.description || "(无描述)"}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
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 { Button } from "@/components/ui/button"
|
||||||
import { XIcon } from "lucide-react"
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
@@ -50,7 +50,6 @@ function SheetContent({
|
|||||||
children,
|
children,
|
||||||
side = "right",
|
side = "right",
|
||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
onInteractOutside,
|
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||||
side?: "top" | "right" | "bottom" | "left"
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
@@ -62,18 +61,6 @@ function SheetContent({
|
|||||||
<SheetPrimitive.Content
|
<SheetPrimitive.Content
|
||||||
data-slot="sheet-content"
|
data-slot="sheet-content"
|
||||||
data-side={side}
|
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(
|
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",
|
"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
|
className
|
||||||
|
|||||||
@@ -2935,6 +2935,7 @@ export const tools: Tool[] = [
|
|||||||
agents: ["worker", "mainagent"],
|
agents: ["worker", "mainagent"],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
kind: "builtin",
|
kind: "builtin",
|
||||||
|
calls: 1842,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "report_finding",
|
key: "report_finding",
|
||||||
@@ -2953,6 +2954,7 @@ export const tools: Tool[] = [
|
|||||||
agents: ["worker"],
|
agents: ["worker"],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
kind: "builtin",
|
kind: "builtin",
|
||||||
|
calls: 96,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "upsert_asset",
|
key: "upsert_asset",
|
||||||
@@ -2962,6 +2964,7 @@ export const tools: Tool[] = [
|
|||||||
agents: ["worker"],
|
agents: ["worker"],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
kind: "builtin",
|
kind: "builtin",
|
||||||
|
calls: 417,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "list_goals",
|
key: "list_goals",
|
||||||
@@ -2971,6 +2974,7 @@ export const tools: Tool[] = [
|
|||||||
agents: ["planner"],
|
agents: ["planner"],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
kind: "builtin",
|
kind: "builtin",
|
||||||
|
calls: 238,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "nuclei_scan",
|
key: "nuclei_scan",
|
||||||
@@ -2985,6 +2989,7 @@ export const tools: Tool[] = [
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
kind: "command",
|
kind: "command",
|
||||||
exec: { command: "nuclei", args: ["-u", "{{target}}"] },
|
exec: { command: "nuclei", args: ["-u", "{{target}}"] },
|
||||||
|
calls: 31,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -935,6 +935,7 @@ export interface Tool {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
exec?: Record<string, any>; // 自定义工具执行规格(kind!=builtin)
|
exec?: Record<string, any>; // 自定义工具执行规格(kind!=builtin)
|
||||||
deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool)
|
deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool)
|
||||||
|
calls?: number; // persistent runtime invocation count (older APIs may omit it)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Stats ----
|
// ---- Stats ----
|
||||||
|
|||||||
Reference in new issue
Block a user