diff --git a/db/conversation.go b/db/conversation.go index 5a7edc5..7db9afd 100644 --- a/db/conversation.go +++ b/db/conversation.go @@ -209,6 +209,28 @@ func (d *DB) DeleteConversation(id int64) error { return err } +// DeleteConversations removes existing threads in one statement and returns the +// ids that were actually present. Child activities and trigger runs cascade. +func (d *DB) DeleteConversations(ids []int64) ([]int64, error) { + if len(ids) == 0 { + return []int64{}, nil + } + rows, err := d.Query(`DELETE FROM conversations WHERE id=ANY($1::bigint[]) RETURNING id`, ids) + if err != nil { + return nil, err + } + defer rows.Close() + deleted := make([]int64, 0, len(ids)) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, err + } + deleted = append(deleted, id) + } + return deleted, rows.Err() +} + // AppendConvActivity records one step of a conversation (human message or an agent // execution step) and returns its id. Mirrors ExplorationStore.AppendActivity but // keyed by conversation_id. Reuses the Activity struct (NodeID is ignored here). diff --git a/db/schema.sql b/db/schema.sql index 4c64c3f..b1a53fe 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -385,6 +385,7 @@ CREATE TABLE IF NOT EXISTS tasks ( timeout_seconds INTEGER NOT NULL DEFAULT 0, plan_heartbeat_seconds INTEGER NOT NULL DEFAULT 300, coverage_enabled BOOLEAN NOT NULL DEFAULT true, + pinned_at TIMESTAMPTZ, first_run_at TIMESTAMPTZ, deadline_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ, @@ -413,10 +414,13 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS queue_mode TEXT NOT NULL DEFAULT ''; -- 可选的任务名称;补旧库。空串=未命名,前端展示时回退到描述。 ALTER TABLE tasks ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT ''; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS category_id BIGINT REFERENCES task_categories(id) ON DELETE SET NULL; +ALTER TABLE tasks ADD COLUMN IF NOT EXISTS pinned_at TIMESTAMPTZ; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS active_llm_profile_id BIGINT REFERENCES llm_profiles(id) ON DELETE SET NULL; ALTER TABLE tasks ADD COLUMN IF NOT EXISTS llm_chain_revision BIGINT NOT NULL DEFAULT 0; CREATE INDEX IF NOT EXISTS idx_tasks_category ON tasks(category_id, created_at DESC) WHERE deleted_at IS NULL AND category_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_tasks_pinned ON tasks(pinned_at DESC) + WHERE deleted_at IS NULL AND pinned_at IS NOT NULL; -- Reusable task description/goal presets. nkey is the normalized, case-insensitive -- identity used to reject visually equivalent duplicate names. @@ -709,6 +713,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/task_metadata_test.go b/db/task_metadata_test.go new file mode 100644 index 0000000..85f327c --- /dev/null +++ b/db/task_metadata_test.go @@ -0,0 +1,104 @@ +package db + +import ( + "fmt" + "testing" + "time" +) + +func TestTaskPinOrderingAndRename(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + + suffix := time.Now().UnixNano() + first, err := d.CreateTask(fmt.Sprintf("task-pin-first-%d", suffix), "goal", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + second, err := d.CreateTask(fmt.Sprintf("task-pin-second-%d", suffix), "goal", nil, 0, 0) + if err != nil { + _ = d.DeleteTask(first.ID) + t.Fatal(err) + } + defer func() { + _ = d.DeleteTask(first.ID) + _ = d.DeleteTask(second.ID) + }() + + pinned := true + first, err = d.UpdateTask(first.ID, TaskPatch{Pinned: &pinned}) + if err != nil || first == nil || !first.Pinned || first.PinnedAt == nil { + t.Fatalf("pin first = %+v, %v", first, err) + } + firstPinnedAt := *first.PinnedAt + time.Sleep(5 * time.Millisecond) + second, err = d.UpdateTask(second.ID, TaskPatch{Pinned: &pinned}) + if err != nil || second == nil || !second.Pinned || second.PinnedAt == nil { + t.Fatalf("pin second = %+v, %v", second, err) + } + + name := "renamed pinned task" + first, err = d.UpdateTask(first.ID, TaskPatch{Name: &name, Pinned: &pinned}) + if err != nil || first == nil { + t.Fatal(err) + } + if first.Name != name || first.PinnedAt == nil || !first.PinnedAt.Equal(firstPinnedAt) { + t.Fatalf("repeat pin should preserve pin time: %+v (want %v)", first, firstPinnedAt) + } + + tasks, err := d.ListTasks() + if err != nil { + t.Fatal(err) + } + positions := map[int64]int{} + for index, task := range tasks { + positions[task.ID] = index + } + if positions[second.ID] >= positions[first.ID] { + t.Fatalf("newer pin must sort first: second=%d first=%d", positions[second.ID], positions[first.ID]) + } + + pinned = false + first, err = d.UpdateTask(first.ID, TaskPatch{Pinned: &pinned}) + if err != nil || first == nil || first.Pinned || first.PinnedAt != nil { + t.Fatalf("unpin first = %+v, %v", first, err) + } +} + +func TestDeleteConversationsReturnsExistingIDs(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + + first, err := d.CreateConversation("mainagent", "batch-delete-first", nil) + if err != nil { + t.Fatal(err) + } + second, err := d.CreateConversation("mainagent", "batch-delete-second", nil) + if err != nil { + _ = d.DeleteConversation(first.ID) + t.Fatal(err) + } + defer func() { + _ = d.DeleteConversation(first.ID) + _ = d.DeleteConversation(second.ID) + }() + + missing := int64(1<<62 - 1) + deleted, err := d.DeleteConversations([]int64{first.ID, missing, second.ID}) + if err != nil { + t.Fatal(err) + } + seen := make(map[int64]bool, len(deleted)) + for _, id := range deleted { + seen[id] = true + } + if !seen[first.ID] || !seen[second.ID] || seen[missing] || len(deleted) != 2 { + t.Fatalf("deleted=%v, want existing ids only", deleted) + } +} diff --git a/db/tasks.go b/db/tasks.go index 2e0d1a4..115ed0c 100644 --- a/db/tasks.go +++ b/db/tasks.go @@ -15,6 +15,8 @@ type Task struct { Name string `json:"name"` // 可选任务名称;空=未命名 CategoryID *int64 `json:"category_id,omitempty"` CategoryName string `json:"category_name,omitempty"` + Pinned bool `json:"pinned"` + PinnedAt *time.Time `json:"pinned_at,omitempty"` Description string `json:"description"` Goal string `json:"goal"` ExplorationID int64 `json:"exploration_id"` @@ -304,13 +306,14 @@ func insertTaskLLMProfiles(tx *sql.Tx, taskID int64, profileIDs []int64) error { const taskCols = `id, COALESCE(name,''), category_id, COALESCE((SELECT category.name FROM task_categories category WHERE category.id=tasks.category_id),''), -description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), first_run_at, deadline_at` +description, goal, exploration_id, status, paused, queued, queued_at, COALESCE(queue_mode,''), llm_profile_id, active_llm_profile_id, COALESCE(parent_ref,''), pinned_at, created_at, completed_at, COALESCE(timeout_seconds,0), COALESCE(plan_heartbeat_seconds,300), COALESCE(coverage_enabled,true), first_run_at, deadline_at` func scanTask(sc interface{ Scan(...any) error }) (*Task, error) { var t Task - if err := sc.Scan(&t.ID, &t.Name, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil { + if err := sc.Scan(&t.ID, &t.Name, &t.CategoryID, &t.CategoryName, &t.Description, &t.Goal, &t.ExplorationID, &t.Status, &t.Paused, &t.Queued, &t.QueuedAt, &t.QueueMode, &t.LLMProfileID, &t.ActiveLLMProfileID, &t.ParentRef, &t.PinnedAt, &t.CreatedAt, &t.CompletedAt, &t.TimeoutSeconds, &t.PlanHeartbeatSeconds, &t.CoverageEnabled, &t.FirstRunAt, &t.DeadlineAt); err != nil { return nil, err } + t.Pinned = t.PinnedAt != nil return &t, nil } @@ -320,11 +323,11 @@ func (d *DB) SetParentRef(id int64, parentRef string) error { return err } -// ListTasks returns alive tasks, newest first. +// ListTasks returns alive tasks with pinned tasks first, then newest ids. func (d *DB) ListTasks() ([]*Task, error) { - // 按 id 降序:id 是 BIGSERIAL,单调递增即插入顺序,最新的在最前。不用 created_at - // 是因为同一时刻创建的多个任务时间戳相同,排序不稳定,列表会频繁换位(见 List)。 - rows, err := d.Query(`SELECT ` + taskCols + ` FROM tasks WHERE deleted_at IS NULL ORDER BY id DESC`) + // id 是 BIGSERIAL,同一时刻创建的任务也有稳定且唯一的顺序。 + rows, err := d.Query(`SELECT ` + taskCols + ` FROM tasks WHERE deleted_at IS NULL +ORDER BY (pinned_at IS NOT NULL) DESC, pinned_at DESC NULLS LAST, id DESC`) if err != nil { return nil, err } @@ -349,6 +352,33 @@ func (d *DB) ListTasks() ([]*Task, error) { return out, nil } +// TaskPatch updates task list metadata without changing execution state. +type TaskPatch struct { + Name *string + Pinned *bool +} + +// UpdateTask applies a partial task name/pin mutation and returns the updated row. +// Re-pinning an already pinned task preserves its original position. +func (d *DB) UpdateTask(id int64, patch TaskPatch) (*Task, error) { + task, err := scanTask(d.QueryRow(`UPDATE tasks SET + name = CASE WHEN $2::boolean THEN $3 ELSE name END, + pinned_at = CASE + WHEN $4::boolean IS NULL THEN pinned_at + WHEN $4::boolean THEN COALESCE(pinned_at, now()) + ELSE NULL + END +WHERE id=$1 AND deleted_at IS NULL +RETURNING `+taskCols, id, patch.Name != nil, patch.Name, patch.Pinned)) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + return task, nil +} + // GetTask returns one alive task (nil if not found/deleted). func (d *DB) GetTask(id int64) (*Task, error) { t, err := scanTask(d.QueryRow(`SELECT `+taskCols+` FROM tasks WHERE id=$1 AND deleted_at IS NULL`, id)) 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/conversations.go b/server/conversations.go index e11b78c..77aacf5 100644 --- a/server/conversations.go +++ b/server/conversations.go @@ -203,6 +203,7 @@ func (s *Server) pgDeleteConversation(w http.ResponseWriter, r *http.Request) { if !ok { return } + s.cancelConversation(c.ID) if err := pg.DeleteConversation(c.ID); err != nil { writeErr(w, 500, err.Error()) return @@ -210,6 +211,79 @@ func (s *Server) pgDeleteConversation(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"deleted": c.ID}) } +const maxConversationDeleteBatch = 100 + +type conversationDeleteItem struct { + ID int64 `json:"id"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` +} + +func (s *Server) cancelConversation(id int64) { + busyKey := s.convBusyKey(id) + s.chatMu.Lock() + cancel := s.chatCancel[busyKey] + s.chatMu.Unlock() + if cancel != nil { + cancel(agent.AbortChatStoppedByUser) + } +} + +// pgDeleteConversationsBatch deletes up to 100 selected conversations in one +// database statement. Missing ids are reported per item so a stale list does not +// hide what was actually removed. +func (s *Server) pgDeleteConversationsBatch(w http.ResponseWriter, r *http.Request) { + pg := s.pg(w) + if pg == nil { + return + } + var request struct { + IDs []int64 `json:"ids"` + } + if !decodeConversationRequest(w, r, &request) { + return + } + ids := make([]int64, 0, len(request.IDs)) + seen := make(map[int64]struct{}, len(request.IDs)) + for _, id := range request.IDs { + if id <= 0 { + writeErr(w, http.StatusBadRequest, "对话 id 无效") + return + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if len(ids) == 0 || len(ids) > maxConversationDeleteBatch { + writeErr(w, http.StatusBadRequest, fmt.Sprintf("ids 数量必须为 1-%d", maxConversationDeleteBatch)) + return + } + for _, id := range ids { + s.cancelConversation(id) + } + deleted, err := pg.DeleteConversations(ids) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + deletedSet := make(map[int64]struct{}, len(deleted)) + for _, id := range deleted { + deletedSet[id] = struct{}{} + } + items := make([]conversationDeleteItem, 0, len(ids)) + for _, id := range ids { + _, ok := deletedSet[id] + item := conversationDeleteItem{ID: id, OK: ok} + if !ok { + item.Error = "conversation not found" + } + items = append(items, item) + } + writeJSON(w, http.StatusOK, map[string]any{"items": items}) +} + func (s *Server) convBusyKey(id int64) string { return "conv-" + strconv.FormatInt(id, 10) } // pgConversationMessages returns steps after ?since=cursor plus whether a turn is diff --git a/server/dto.go b/server/dto.go index 91a3558..8db7ba7 100644 --- a/server/dto.go +++ b/server/dto.go @@ -35,6 +35,8 @@ type TaskDTO struct { Name string `json:"name"` // 可选任务名称;空=未命名 CategoryID *int64 `json:"category_id,omitempty"` CategoryName string `json:"category_name,omitempty"` + Pinned bool `json:"pinned"` + PinnedAt string `json:"pinned_at,omitempty"` Description string `json:"description"` Goal string `json:"goal"` Status string `json:"status"` // created | running | paused | done | failed @@ -86,9 +88,11 @@ func taskDTO(t *Task, status string) TaskDTO { return TaskDTO{ ID: t.ID, ExplorationID: t.ExpID, - Name: t.Name, + Name: lifecycle.Name, CategoryID: lifecycle.CategoryID, CategoryName: lifecycle.CategoryName, + Pinned: lifecycle.PinnedAt > 0, + PinnedAt: completedRFC(lifecycle.PinnedAt), Description: t.Description, Goal: t.Goal, Status: status, diff --git a/server/manager.go b/server/manager.go index b6ba14a..7902ce5 100644 --- a/server/manager.go +++ b/server/manager.go @@ -32,6 +32,7 @@ type Task struct { Name string `json:"name"` // 可选任务名称;空=未命名 CategoryID *int64 `json:"category_id,omitempty"` CategoryName string `json:"category_name,omitempty"` + PinnedAt int64 `json:"pinned_at,omitempty"` Description string `json:"description"` Goal string `json:"goal"` CreatedAt int64 `json:"created_at"` @@ -77,6 +78,8 @@ type Task struct { // updateLifecycle instead of reading or writing the corresponding Task fields // directly after the task has been published by Manager. type taskLifecycleState struct { + Name string + PinnedAt int64 Status string Paused bool Queued bool @@ -102,6 +105,8 @@ func (t *Task) lifecycleSnapshot() taskLifecycleState { func (t *Task) lifecycleSnapshotLocked() taskLifecycleState { return taskLifecycleState{ + Name: t.Name, + PinnedAt: t.PinnedAt, Status: t.Status, Paused: t.Paused, Queued: t.Queued, @@ -124,6 +129,8 @@ func (t *Task) updateLifecycle(update func(*taskLifecycleState)) { t.lifecycleMu.Lock() state := t.lifecycleSnapshotLocked() update(&state) + t.Name = state.Name + t.PinnedAt = state.PinnedAt t.Status = state.Status t.Paused = state.Paused t.Queued = state.Queued @@ -698,6 +705,7 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter ID: strconv.FormatInt(pt.ID, 10), ExpID: pt.ExplorationID, Name: pt.Name, CategoryID: cloneInt64Ptr(pt.CategoryID), CategoryName: pt.CategoryName, + PinnedAt: unixOrZero(pt.PinnedAt), Description: pt.Description, Goal: pt.Goal, CreatedAt: pt.CreatedAt.Unix(), Paused: pt.Paused, Queued: pt.Queued, QueuedAt: unixNanoOrZero(pt.QueuedAt), QueueMode: pt.QueueMode, CompletedAt: unixOrZero(pt.CompletedAt), Status: pt.Status, ParentRef: pt.ParentRef, @@ -714,6 +722,32 @@ func taskFromPG(pt *pgdb.Task, store *pgdb.ExplorationStore, ic *intercept.Inter } } +// UpdateTaskMetadata changes list-only task metadata without interrupting any +// planner, main-agent, or worker call. +func (m *Manager) UpdateTaskMetadata(taskID string, patch pgdb.TaskPatch) (*Task, error) { + m.taskStateMu.Lock() + defer m.taskStateMu.Unlock() + id, err := strconv.ParseInt(taskID, 10, 64) + if err != nil || id <= 0 { + return nil, nil + } + updated, err := m.pg.UpdateTask(id, patch) + if err != nil || updated == nil { + return nil, err + } + m.mu.RLock() + task := m.tasks[taskID] + m.mu.RUnlock() + if task == nil { + return nil, nil + } + task.updateLifecycle(func(state *taskLifecycleState) { + state.Name = updated.Name + state.PinnedAt = unixOrZero(updated.PinnedAt) + }) + return task, nil +} + // CreateTask creates a task + its exploration and makes it active. // timeoutSeconds is the task-level wall-clock budget (0 = 不限时). func (m *Manager) CreateTask(description, goal string, llmProfileID *int64, timeoutSeconds, planHeartbeatSeconds int) (*Task, error) { @@ -1558,10 +1592,17 @@ func (m *Manager) List() []*Task { for _, t := range m.tasks { out = append(out, t) } - // 按 id 降序(最新插入在最前)。m.tasks 是 map,遍历顺序随机,每次轮询都要重排; - // 若按 CreatedAt 排,同一秒创建的任务时间戳相同,排序不稳定,会在列表里频繁换 - // 位置。id 由 BIGSERIAL 单调递增分配,唯一且稳定,顺序恒定。 + // 置顶任务优先,组内按置顶时间倒序;普通任务按 id 倒序。m.tasks 是 map, + // 每次轮询都必须重排,id 则为同刻创建任务提供稳定且唯一的兜底顺序。 sort.Slice(out, func(i, j int) bool { + iState := out[i].lifecycleSnapshot() + jState := out[j].lifecycleSnapshot() + if (iState.PinnedAt > 0) != (jState.PinnedAt > 0) { + return iState.PinnedAt > 0 + } + if iState.PinnedAt != jState.PinnedAt { + return iState.PinnedAt > jState.PinnedAt + } ai, _ := strconv.ParseInt(out[i].ID, 10, 64) aj, _ := strconv.ParseInt(out[j].ID, 10, 64) return ai > aj diff --git a/server/server.go b/server/server.go index 0e5ffb7..999fcb6 100644 --- a/server/server.go +++ b/server/server.go @@ -687,6 +687,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("PATCH /api/task-templates/{id}", s.pgUpdateTaskTemplate) mux.HandleFunc("DELETE /api/task-templates/{id}", s.pgDeleteTaskTemplate) mux.HandleFunc("GET /api/tasks/{id}", s.getTask) + mux.HandleFunc("PATCH /api/tasks/{id}", s.updateTaskMetadata) mux.HandleFunc("PATCH /api/tasks/{id}/category", s.updateTaskCategory) mux.HandleFunc("POST /api/tasks/control/batch", s.controlTasksBatch) mux.HandleFunc("GET /api/tasks/{id}/coverage", s.taskCoverage) @@ -791,6 +792,7 @@ func (s *Server) Handler() http.Handler { // conversations (chat page) mux.HandleFunc("GET /api/conversations", s.pgListConversations) mux.HandleFunc("POST /api/conversations", s.pgCreateConversation) + mux.HandleFunc("POST /api/conversations/delete/batch", s.pgDeleteConversationsBatch) mux.HandleFunc("PATCH /api/conversations/{id}", s.pgRenameConversation) mux.HandleFunc("PATCH /api/conversations/{id}/profile", s.pgUpdateConversation) mux.HandleFunc("DELETE /api/conversations/{id}", s.pgDeleteConversation) 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/task_metadata.go b/server/task_metadata.go new file mode 100644 index 0000000..9fec25d --- /dev/null +++ b/server/task_metadata.go @@ -0,0 +1,66 @@ +package server + +import ( + "errors" + "fmt" + "net/http" + "strings" + "unicode/utf8" + + "github.com/Autumn-27/artex/db" +) + +const ( + maxTaskNameRunes = 200 + maxTaskMetadataRequestSize = 16 << 10 +) + +// updateTaskMetadata changes list-only task metadata. It intentionally does not +// touch lifecycle state, scheduling, or the task's immutable description/goal. +func (s *Server) updateTaskMetadata(w http.ResponseWriter, r *http.Request) { + taskID := r.PathValue("id") + if _, ok := s.m.Task(taskID); !ok { + writeErr(w, http.StatusNotFound, "task not found") + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxTaskMetadataRequestSize) + var request struct { + Name *string `json:"name"` + Pinned *bool `json:"pinned"` + } + if err := decode(r, &request); err != nil { + var tooLarge *http.MaxBytesError + if errors.As(err, &tooLarge) { + writeErr(w, http.StatusRequestEntityTooLarge, "请求正文过大") + } else { + writeErr(w, http.StatusBadRequest, err.Error()) + } + return + } + if request.Name == nil && request.Pinned == nil { + writeErr(w, http.StatusBadRequest, "至少需要提供 name 或 pinned") + return + } + if request.Name != nil { + name := strings.TrimSpace(*request.Name) + if name == "" { + writeErr(w, http.StatusBadRequest, "任务名称不能为空") + return + } + if utf8.RuneCountInString(name) > maxTaskNameRunes { + writeErr(w, http.StatusBadRequest, fmt.Sprintf("任务名称最多 %d 个字符", maxTaskNameRunes)) + return + } + request.Name = &name + } + task, err := s.m.UpdateTaskMetadata(taskID, db.TaskPatch{Name: request.Name, Pinned: request.Pinned}) + if err != nil { + writeErr(w, http.StatusInternalServerError, err.Error()) + return + } + if task == nil { + writeErr(w, http.StatusNotFound, "task not found") + return + } + writeJSON(w, http.StatusOK, taskDTO(task, s.resolvedTaskStatus(task))) +} diff --git a/server/task_metadata_test.go b/server/task_metadata_test.go new file mode 100644 index 0000000..2ef209a --- /dev/null +++ b/server/task_metadata_test.go @@ -0,0 +1,113 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" +) + +func TestTaskMetadataPatchReturnsRenameAndPin(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer m.Close() + task, err := m.CreateTask("metadata patch", "goal", nil, 0, 0) + if err != nil { + t.Fatal(err) + } + taskID, _ := strconv.ParseInt(task.ID, 10, 64) + defer func() { _ = m.pg.DeleteTask(taskID) }() + s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir()) + token, err := signJWT(s.jwtKey) + if err != nil { + t.Fatal(err) + } + + body, _ := json.Marshal(map[string]any{"name": " renamed task ", "pinned": true}) + req := httptest.NewRequest(http.MethodPatch, "/api/tasks/"+task.ID, bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("patch status=%d body=%s", rec.Code, rec.Body.String()) + } + var updated struct { + Name string `json:"name"` + Pinned bool `json:"pinned"` + PinnedAt string `json:"pinned_at"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &updated); err != nil { + t.Fatal(err) + } + if updated.Name != "renamed task" || !updated.Pinned || updated.PinnedAt == "" { + t.Fatalf("unexpected task patch response: %+v", updated) + } + + body, _ = json.Marshal(map[string]any{"name": strings.Repeat("任", maxTaskNameRunes+1)}) + req = httptest.NewRequest(http.MethodPatch, "/api/tasks/"+task.ID, bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + rec = httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("overlong task name status=%d body=%s", rec.Code, rec.Body.String()) + } +} + +func TestConversationBatchDeleteReportsMissing(t *testing.T) { + m, err := NewManager(t.TempDir(), "") + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer m.Close() + s := New(context.Background(), m, t.TempDir(), t.TempDir(), t.TempDir()) + first, err := m.pg.CreateConversation("mainagent", "batch-http-first", nil) + if err != nil { + t.Fatal(err) + } + second, err := m.pg.CreateConversation("mainagent", "batch-http-second", nil) + if err != nil { + _ = m.pg.DeleteConversation(first.ID) + t.Fatal(err) + } + defer func() { + _ = m.pg.DeleteConversation(first.ID) + _ = m.pg.DeleteConversation(second.ID) + }() + token, err := signJWT(s.jwtKey) + if err != nil { + t.Fatal(err) + } + missing := int64(1<<62 - 1) + body, _ := json.Marshal(map[string]any{"ids": []int64{first.ID, missing, second.ID}}) + req := httptest.NewRequest(http.MethodPost, "/api/conversations/delete/batch", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + rec := httptest.NewRecorder() + s.Handler().ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("batch delete status=%d body=%s", rec.Code, rec.Body.String()) + } + var response struct { + Items []conversationDeleteItem `json:"items"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + if len(response.Items) != 3 || !response.Items[0].OK || response.Items[1].OK || !response.Items[2].OK { + t.Fatalf("batch delete response=%+v", response.Items) + } + for _, id := range []int64{first.ID, second.ID} { + if conversation, err := m.pg.GetConversation(id); err != nil || conversation != nil { + t.Fatalf("conversation %d still exists: %+v err=%v", id, conversation, err) + } + } + if response.Items[1].Error == "" { + t.Fatalf("missing conversation %s must include an error", fmt.Sprint(missing)) + } +} 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)/chat/page.tsx b/web/src/app/(main)/chat/page.tsx index ded6bce..04a4616 100644 --- a/web/src/app/(main)/chat/page.tsx +++ b/web/src/app/(main)/chat/page.tsx @@ -34,6 +34,7 @@ import { } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; import { DropdownMenu, DropdownMenuContent, @@ -761,6 +762,8 @@ function ConversationItem({ onCancelRename, onTogglePinned, onDelete, + selectedForDelete, + onSelectedForDeleteChange, }: { conv: Conversation; agent?: Agent; @@ -774,6 +777,8 @@ function ConversationItem({ onCancelRename: () => void; onTogglePinned: () => void; onDelete: () => void; + selectedForDelete: boolean; + onSelectedForDeleteChange: (checked: boolean) => void; }) { const [deleteOpen, setDeleteOpen] = React.useState(false); const renameInputRef = React.useRef(null); @@ -794,6 +799,12 @@ function ConversationItem({ active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50", )} > + onSelectedForDeleteChange(checked === true)} + aria-label={`选择对话「${conv.title || "新对话"}」`} + className="ml-1 shrink-0" + /> {renaming ? ( (null); const [renamingId, setRenamingId] = React.useState(null); const [renameText, setRenameText] = React.useState(""); + const [selectedConversationIds, setSelectedConversationIds] = React.useState>(() => new Set()); + const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false); + const [bulkDeleting, setBulkDeleting] = React.useState(false); // Pending text + uploaded attachments handed off from a draft that created a // conversation via the paperclip, keyed by the new conversation id (consumed once // by ChatView on mount; ids never repeat, so leftover entries are harmless). @@ -923,6 +937,15 @@ export default function ChatPage() { reloadConvs(); }, [reloadConvs]); + React.useEffect(() => { + setSelectedConversationIds((current) => { + if (current.size === 0) return current; + const live = new Set(convs.map((conversation) => conversation.id)); + const next = new Set([...current].filter((id) => live.has(id))); + return next.size === current.size ? current : next; + }); + }, [convs]); + // Restore the open conversation from the URL (?c=) on mount, so a refresh // returns to the same thread instead of the empty draft view. Runs after // hydration (not a lazy useState init) to avoid a server/client mismatch. @@ -948,16 +971,80 @@ export default function ChatPage() { // are task-specific and stay hidden from the chat page. const chatAgents = agents.filter((a) => !a.builtin || a.role === "assistant"); + const selectedConversationCount = selectedConversationIds.size; + const allConversationsSelected = convs.length > 0 && selectedConversationCount === convs.length; + const someConversationsSelected = selectedConversationCount > 0 && !allConversationsSelected; + let conversationHeaderChecked: boolean | "indeterminate" = false; + if (allConversationsSelected) conversationHeaderChecked = true; + else if (someConversationsSelected) conversationHeaderChecked = "indeterminate"; + + function toggleConversationSelected(id: number, checked: boolean) { + setSelectedConversationIds((current) => { + const next = new Set(current); + if (checked) next.add(id); + else next.delete(id); + return next; + }); + } + + function toggleAllConversations(checked: boolean) { + setSelectedConversationIds(checked ? new Set(convs.map((conversation) => conversation.id)) : new Set()); + } + async function del(id: number) { try { await api.deleteConversation(id); if (selectedId === id) setSelectedId(null); + setSelectedConversationIds((current) => { + if (!current.has(id)) return current; + const next = new Set(current); + next.delete(id); + return next; + }); reloadConvs(); } catch (e) { toast.error("删除失败:" + (e as Error).message); } } + async function deleteSelectedConversations() { + const ids = [...selectedConversationIds]; + if (ids.length === 0 || bulkDeleting) return; + setBulkDeleting(true); + const deleted = new Set(); + const failed: { id: number; error: string }[] = []; + try { + for (let offset = 0; offset < ids.length; offset += 100) { + const result = await api.deleteConversations(ids.slice(offset, offset + 100)); + for (const item of result.items) { + if (item.ok) deleted.add(item.id); + else failed.push({ id: item.id, error: item.error ?? "对话不存在" }); + } + } + if (deleted.has(selectedId ?? -1)) setSelectedId(null); + setSelectedConversationIds((current) => { + const next = new Set(current); + for (const id of deleted) next.delete(id); + return next; + }); + if (deleted.size > 0) toast.success(`已删除 ${deleted.size} 个对话`); + if (failed.length > 0) { + const details = failed + .slice(0, 3) + .map((item) => `#${item.id}(${item.error})`) + .join(";"); + toast.error(`${failed.length} 个对话删除失败:${details}${failed.length > 3 ? " 等" : ""}`); + } + setBulkDeleteOpen(false); + reloadConvs(); + } catch (error) { + toast.error(`批量删除失败:${(error as Error).message}`); + reloadConvs(); + } finally { + setBulkDeleting(false); + } + } + async function togglePinned(conversation: Conversation) { const pinned = conversationIsPinned(conversation); try { @@ -993,10 +1080,33 @@ export default function ChatPage() {
{/* left: conversation list */}
-
+
+ {convs.length > 0 && ( +
+ toggleAllConversations(checked === true)} + aria-label="选择全部对话" + /> + + {selectedConversationCount > 0 ? `已选 ${selectedConversationCount} 个` : `共 ${convs.length} 个`} + + {selectedConversationCount > 0 && ( + + )} +
+ )}
@@ -1016,6 +1126,8 @@ export default function ChatPage() { onCancelRename={() => setRenamingId(null)} onTogglePinned={() => void togglePinned(c)} onDelete={() => del(c.id)} + selectedForDelete={selectedConversationIds.has(c.id)} + onSelectedForDeleteChange={(checked) => toggleConversationSelected(c.id, checked)} /> ))}
@@ -1056,6 +1168,28 @@ export default function ChatPage() { )}
+ + + + 删除选中的 {selectedConversationCount} 个对话? + 对话消息和执行记录将一并删除,此操作不可撤销。 + + + 取消 + { + event.preventDefault(); + void deleteSelectedConversations(); + }} + > + {bulkDeleting && } + {bulkDeleting ? "删除中" : "确认删除"} + + + +
); } diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx index 07d9449..4a0dc59 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/findings-tab.tsx @@ -1,12 +1,15 @@ "use client"; import * as React from "react"; + import Link from "next/link"; -import { ArrowUpRightIcon, ChevronRightIcon } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { Card, CardContent } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; + +import { ArrowDownIcon, ArrowUpIcon, ArrowUpRightIcon, ChevronRightIcon } from "lucide-react"; +import { toast } from "sonner"; + import { StatusBadge } from "@/components/status-badge"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent } from "@/components/ui/card"; import { Select, SelectContent, @@ -15,9 +18,21 @@ import { SelectTrigger, } from "@/components/ui/select"; import { api } from "@/lib/api"; -import { toast } from "sonner"; +import { useStoredSortPreference } from "@/lib/sort-preference"; import { statusMeta } from "@/lib/status"; import type { Finding, FindingStatus } from "@/lib/types"; +import { cn } from "@/lib/utils"; + +type FindingSortField = "time"; + +const FINDING_SORT_FIELDS: readonly FindingSortField[] = ["time"]; +const FINDING_SORT_PREFERENCE_KEY = "artex_task_findings_sort"; + +function findingLabel(finding: Finding): string { + if (finding.name?.trim()) return finding.name; + if (finding.vulnclass?.trim()) return finding.vulnclass; + return "未分类"; +} const FINDING_STATUSES: FindingStatus[] = [ "pending", @@ -57,9 +72,7 @@ function Row({
- - {f.name || f.vulnclass || "未分类"} - + {findingLabel(f)} {f.inherited && f.source_task_id && ( 来源 #{f.source_task_id} · 只读 @@ -147,6 +160,12 @@ function Row({ export function FindingsTab({ taskId }: { taskId: string }) { const [findings, setFindings] = React.useState([]); + const [sortPreference, setSortPreference] = useStoredSortPreference( + FINDING_SORT_PREFERENCE_KEY, + FINDING_SORT_FIELDS, + "time", + "desc", + ); React.useEffect(() => { let active = true; @@ -156,7 +175,9 @@ export function FindingsTab({ taskId }: { taskId: string }) { .then((fs) => { if (active) setFindings(fs); }) - .catch(() => {}); + .catch(() => { + // Keep the last successful snapshot during transient refresh failures. + }); }; load(); const t = setInterval(load, 3000); @@ -189,13 +210,35 @@ export function FindingsTab({ taskId }: { taskId: string }) { const items = findings .filter((f) => f.task_id === taskId || f.inherited) .sort((a, b) => { - const order = { critical: 0, high: 1, medium: 2, low: 3 }; - return order[a.severity] - order[b.severity]; + const compared = Date.parse(a.ts) - Date.parse(b.ts); + if (compared !== 0) return sortPreference.direction === "asc" ? compared : -compared; + return b.id.localeCompare(a.id, undefined, { numeric: true }); }); return ( +
+ 漏洞 + +
{items.map((f) => ( ))} diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx index ad385f9..bf8eea9 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/sessions-tab.tsx @@ -430,24 +430,34 @@ function SessionItem({ ); } +function truncateWorkerAssetLabel(value: string, maxChars = 30): string { + const chars = Array.from(value.trim()); + if (chars.length <= maxChars) return chars.join(""); + return `${chars.slice(0, Math.max(0, maxChars - 1)).join("")}…`; +} + function WorkerAssetBadge({ assets }: { assets: IntentAsset[] }) { - const first = assets[0]; - const firstLabel = first.label.trim() ? first.label : `#${first.asset_id}`; + const displayAssets = assets.filter( + (asset) => asset.type === "root_domain" || asset.type === "subdomain" || asset.type === "ip", + ); + if (displayAssets.length === 0) return null; + + const first = displayAssets[0]; + const firstRawLabel = first.label.trim() || `#${first.asset_id}`; + const firstLabel = truncateWorkerAssetLabel(firstRawLabel); return ( - + 当前资产:{firstLabel} - {assets.length > 1 && +{assets.length - 1}} + {displayAssets.length > 1 && +{displayAssets.length - 1}}
- {assets.map((asset) => ( + {displayAssets.map((asset) => (
-
- {asset.label.trim() ? asset.label : `#${asset.asset_id}`} -
+
{asset.label.trim() || `#${asset.asset_id}`}
{taskAssetTypeLabel(asset.type)} · {taskAssetSourceLabel(asset.source)} {asset.inherited ? ` · 来源任务 #${asset.source_task_id}` : ""} diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index caf4ce8..f7050e2 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -5,16 +5,34 @@ 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 { + ArrowDownIcon, + ArrowUpDownIcon, + ArrowUpIcon, ChevronRightIcon, EyeIcon, FolderInputIcon, - FolderKanbanIcon, + GripVerticalIcon, Loader2Icon, PaperclipIcon, PauseIcon, + PencilIcon, + PinIcon, + PinOffIcon, PlayIcon, PlusIcon, + SaveIcon, SearchIcon, SlidersHorizontalIcon, StarIcon, @@ -64,6 +82,7 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty"; import { Field, FieldContent, @@ -74,7 +93,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, @@ -91,6 +112,7 @@ import { Switch } from "@/components/ui/switch"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Textarea } from "@/components/ui/textarea"; import { api } from "@/lib/api"; +import { type SortDirection, useStoredSortPreference } from "@/lib/sort-preference"; import type { ChatAttachment, Company, @@ -101,6 +123,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 { @@ -219,12 +242,66 @@ function taskControlAction(status: TaskStatus): "pause" | "resume" | null { return null; } +type TaskSortField = "id" | "created" | "duration" | "status"; + +const TASK_SORT_FIELDS: readonly TaskSortField[] = ["id", "created", "duration", "status"]; +const TASK_SORT_PREFERENCE_KEY = "artex_task_list_sort"; + +const TASK_STATUS_RANK = new Map(STATUS_OPTIONS.map((option, index) => [option.value, index])); + +function taskIsPinned(task: Task): boolean { + return task.pinned ?? Boolean(task.pinned_at); +} + +function compareTaskIDs(left: string, right: string): number { + return left.localeCompare(right, undefined, { numeric: true, sensitivity: "base" }); +} + +function taskCreatedUnix(task: Task): number { + if (task.created_unix) return task.created_unix; + const parsed = Date.parse(task.created_at); + return Number.isNaN(parsed) ? 0 : Math.floor(parsed / 1000); +} + +function compareTasks(left: Task, right: Task, field: TaskSortField, direction: SortDirection, nowSec: number): number { + const leftPinned = taskIsPinned(left); + const rightPinned = taskIsPinned(right); + if (leftPinned !== rightPinned) return leftPinned ? -1 : 1; + if (leftPinned && left.pinned_at !== right.pinned_at) { + return (right.pinned_at ?? "").localeCompare(left.pinned_at ?? ""); + } + + let compared = 0; + switch (field) { + case "created": + compared = taskCreatedUnix(left) - taskCreatedUnix(right); + break; + case "duration": + compared = taskDuration(left, nowSec) - taskDuration(right, nowSec); + break; + case "status": + compared = (TASK_STATUS_RANK.get(left.status) ?? 0) - (TASK_STATUS_RANK.get(right.status) ?? 0); + break; + default: + compared = compareTaskIDs(left.id, right.id); + } + if (compared !== 0) return direction === "asc" ? compared : -compared; + return -compareTaskIDs(left.id, right.id); +} + export default function TasksPage() { const [tasks, setTasks] = React.useState([]); const [categories, setCategories] = React.useState([]); const [query, setQuery] = React.useState(""); const [statusFilter, setStatusFilter] = React.useState("all"); const [categoryFilter, setCategoryFilter] = React.useState("all"); + const [sortPreference, setSortPreference] = useStoredSortPreference( + TASK_SORT_PREFERENCE_KEY, + TASK_SORT_FIELDS, + "id", + "desc", + ); + const { field: sortField, direction: sortDirection } = sortPreference; const [page, setPage] = React.useState(1); const [pageSize, setPageSize] = React.useState(20); const [nowSec, setNowSec] = React.useState(() => Math.floor(Date.now() / 1000)); @@ -253,15 +330,30 @@ export default function TasksPage() { }); }, [tasks, query, statusFilter, categoryFilter]); - // reset to page 1 whenever filters change + const sortNowSec = sortField === "duration" ? nowSec : 0; + const ordered = React.useMemo(() => { + return [...filtered].sort((left, right) => compareTasks(left, right, sortField, sortDirection, sortNowSec)); + }, [filtered, sortDirection, sortField, sortNowSec]); + + const sortTasksBy = React.useCallback( + (field: TaskSortField) => { + setSortPreference((current) => { + if (current.field !== field) return { field, direction: "desc" }; + return { field, direction: current.direction === "asc" ? "desc" : "asc" }; + }); + }, + [setSortPreference], + ); + + // reset to page 1 whenever filters or ordering change // biome-ignore lint/correctness/useExhaustiveDependencies: both filters intentionally reset pagination. React.useEffect(() => { setPage(1); - }, [query, statusFilter, categoryFilter]); + }, [query, statusFilter, categoryFilter, sortField, sortDirection]); const paginated = React.useMemo( - () => filtered.slice((page - 1) * pageSize, page * pageSize), - [filtered, page, pageSize], + () => ordered.slice((page - 1) * pageSize, page * pageSize), + [ordered, page, pageSize], ); // 多选删除:选择跨翻页/筛选保留,只在任务真的消失(被删或后端不再返回)时收敛。 @@ -353,6 +445,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_VALUE) return; if (!categories.some((category) => String(category.id) === categoryFilter)) setCategoryFilter("all"); @@ -409,6 +515,37 @@ export default function TasksPage() { [load], ); + const renameTask = React.useCallback( + async (task: Task, name: string) => { + try { + await api.renameTask(task.id, name); + toast.success(`任务 #${task.id} 已重命名`); + lastRef.current = ""; + load(); + } catch (error) { + toast.error(`重命名失败:${(error as Error).message}`); + throw error; + } + }, + [load], + ); + + const toggleTaskPinned = React.useCallback( + async (task: Task) => { + const pinned = taskIsPinned(task); + try { + await api.pinTask(task.id, !pinned); + toast.success(pinned ? `任务 #${task.id} 已取消置顶` : `任务 #${task.id} 已置顶`); + lastRef.current = ""; + load(); + } catch (error) { + toast.error(`${pinned ? "取消置顶" : "置顶"}失败:${(error as Error).message}`); + throw error; + } + }, + [load], + ); + // deleteTasks 逐个删除所选任务:后端没有批量接口,且单次删除会连带清理资产/流量/文件, // 串行执行以免一次性打爆后端;成功的从选中集移除,失败的保留以便重试。 const deleteTasks = React.useCallback( @@ -654,12 +791,17 @@ export default function TasksPage() { )} - +
@@ -682,14 +824,41 @@ export default function TasksPage() { aria-label="选择本页全部任务" /> - ID + 名称 描述 目标 - 状态 + 目标进度 - 创建时间 - 运行时长 + + Token 操作 @@ -706,6 +875,8 @@ export default function TasksPage() { nowSec={task.status === "running" ? nowSec : 0} onDelete={deleteTask} onControl={controlTask} + onRename={renameTask} + onTogglePinned={toggleTaskPinned} selected={selectedIds.has(task.id)} onSelectedChange={toggleSelected} /> @@ -728,6 +899,56 @@ export default function TasksPage() { ); } +function taskSortIcon(active: boolean, direction: SortDirection) { + if (!active) { + return ; + } + if (direction === "asc") return ; + return ; +} + +function SortableTaskHead({ + field, + label, + activeField, + direction, + align = "left", + className, + onSort, +}: { + field: TaskSortField; + label: string; + activeField: TaskSortField; + direction: SortDirection; + align?: "left" | "right"; + className?: string; + onSort: (field: TaskSortField) => void; +}) { + const active = activeField === field; + let ariaSort: React.AriaAttributes["aria-sort"] = "none"; + if (active) ariaSort = direction === "asc" ? "ascending" : "descending"; + + let actionLabel = `按${label}倒序排序`; + if (active) actionLabel = `${label}当前${direction === "asc" ? "正序" : "倒序"},点击切换排序方向`; + + return ( + + + + ); +} + function ConcurrencySettingsDialog() { const [open, setOpen] = React.useState(false); const [enabled, setEnabled] = React.useState(false); @@ -823,6 +1044,8 @@ const TaskRow = React.memo(function TaskRow({ nowSec, onDelete, onControl, + onRename, + onTogglePinned, selected, onSelectedChange, }: { @@ -830,6 +1053,8 @@ const TaskRow = React.memo(function TaskRow({ nowSec: number; onDelete: (id: string, options: DeleteTaskOptions) => Promise; onControl: (id: string, action: "pause" | "resume") => Promise; + onRename: (task: Task, name: string) => Promise; + onTogglePinned: (task: Task) => Promise; selected: boolean; onSelectedChange: (id: string, checked: boolean) => void; }) { @@ -847,14 +1072,9 @@ const TaskRow = React.memo(function TaskRow({
- - {task.name?.trim() ? task.name : 未命名} - + {task.active && } + {taskIsPinned(task) && }
@@ -919,6 +1139,7 @@ const TaskRow = React.memo(function TaskRow({ +
@@ -926,6 +1147,144 @@ const TaskRow = React.memo(function TaskRow({ ); }); +function TaskNameEditor({ + task, + onRename, +}: { + task: Task; + onRename: (task: Task, name: string) => Promise; +}) { + const [editing, setEditing] = React.useState(false); + const [name, setName] = React.useState(task.name ?? ""); + const [saving, setSaving] = React.useState(false); + const inputRef = React.useRef(null); + const cancelOnBlurRef = React.useRef(false); + + React.useEffect(() => { + if (!editing) setName(task.name ?? ""); + }, [editing, task.name]); + + async function finishEditing() { + if (cancelOnBlurRef.current) { + cancelOnBlurRef.current = false; + setName(task.name ?? ""); + setEditing(false); + return; + } + + const next = name.trim(); + if (!next || next === task.name?.trim()) { + setName(task.name ?? ""); + setEditing(false); + return; + } + + setSaving(true); + try { + await onRename(task, next); + setEditing(false); + } catch { + requestAnimationFrame(() => inputRef.current?.focus()); + } finally { + setSaving(false); + } + } + + if (editing) { + return ( +
+ event.currentTarget.select()} + onChange={(event) => setName(event.target.value)} + onBlur={() => void finishEditing()} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + event.currentTarget.blur(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancelOnBlurRef.current = true; + event.currentTarget.blur(); + } + }} + /> + {saving && } +
+ ); + } + + return ( +
+ + {task.name?.trim() ? task.name : 未命名} + + +
+ ); +} + +function taskPinIcon(pinning: boolean, pinned: boolean) { + if (pinning) return ; + if (pinned) return ; + return ; +} + +function TaskPinAction({ + task, + onTogglePinned, +}: { + task: Task; + onTogglePinned: (task: Task) => Promise; +}) { + const [pinning, setPinning] = React.useState(false); + const pinned = taskIsPinned(task); + + return ( + + ); +} + // 三态图标分开写而非嵌套三元:仓库 Biome 基线禁 noNestedTernary。 function taskControlIcon(pending: boolean, action: "pause" | "resume" | null) { if (pending) return ; @@ -1597,62 +1956,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 ( <> @@ -1662,108 +2217,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)/function/traffic/page.tsx b/web/src/app/(main)/function/traffic/page.tsx index 9ccf7d0..b7623ff 100644 --- a/web/src/app/(main)/function/traffic/page.tsx +++ b/web/src/app/(main)/function/traffic/page.tsx @@ -3,14 +3,18 @@ import * as React from "react"; import { + CheckIcon, ChevronLeftIcon, ChevronRightIcon, + CopyIcon, ListChecksIcon, Loader2Icon, RadioTowerIcon, SearchIcon, Trash2Icon, + WrapTextIcon, } from "lucide-react"; +import { toast } from "sonner"; import { AlertDialog, @@ -32,6 +36,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@/components/ui/sheet"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { api } from "@/lib/api"; import type { TrafficDetail, TrafficExchange, TrafficHost, TrafficResp } from "@/lib/types"; import { cn } from "@/lib/utils"; @@ -163,37 +168,122 @@ function MarkupBody({ body }: { body: string }) { return parts; } -function HighlightedBody({ body }: { body: string }) { +type BodyFormat = "json" | "markup" | "plain"; + +function detectBodyFormat(body: string): BodyFormat { const trimmed = body.trimStart(); - if (trimmed.startsWith("{") || trimmed.startsWith("[")) return ; - if (trimmed.startsWith("<")) return ; + if (trimmed.startsWith("{") || trimmed.startsWith("[")) return "json"; + if (trimmed.startsWith("<")) return "markup"; + return "plain"; +} + +function HighlightedBody({ body, format }: { body: string; format: BodyFormat }) { + if (format === "json") return ; + if (format === "markup") return ; return body; } function HttpCodeBlock({ raw }: { raw: string }) { + const [wrapLines, setWrapLines] = React.useState(true); + const [copied, setCopied] = React.useState(false); const value = raw || "(空)"; const lines = value.replaceAll("\r\n", "\n").split("\n"); const separator = lines.indexOf(""); - const headerLines = separator >= 0 ? lines.slice(0, separator) : lines; const body = separator >= 0 ? lines.slice(separator + 1).join("\n") : ""; + const bodyFormat = detectBodyFormat(body); + + React.useEffect(() => { + if (!copied) return; + const timer = window.setTimeout(() => setCopied(false), 1500); + return () => window.clearTimeout(timer); + }, [copied]); + + const copyPacket = async () => { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + } catch { + toast.error("复制失败,请使用 Ctrl/Cmd+A 后复制"); + } + }; + + const renderLine = (line: string, index: number) => { + if (index === 0) return ; + if (separator < 0 || index < separator) return ; + if (index === separator) return null; + return ; + }; return ( -
-      
-        {headerLines.map((line, index) => (
-          
-            {index === 0 ?  : }
-            {index < headerLines.length - 1 ? "\n" : null}
-          
+    
+
+ + + + + {wrapLines ? "关闭自动换行" : "开启自动换行"} + + + + + + {copied ? "已复制" : "复制报文"} + +
+ {/* biome-ignore lint/a11y/useSemanticElements: textarea cannot preserve line numbers and syntax-highlighting markup. */} +
{ + if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "a") return; + event.preventDefault(); + const selection = window.getSelection(); + if (!selection) return; + const range = document.createRange(); + range.selectNodeContents(event.currentTarget); + selection.removeAllRanges(); + selection.addRange(range); + }} + > + {lines.map((line, index) => ( +
+ + {renderLine(line, index)} + +
))} - {separator >= 0 && ( - <> - {"\n\n"} - - - )} -
-
+
+
); } 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/api.ts b/web/src/lib/api.ts index 706efb0..9bb02e9 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -215,6 +215,9 @@ export const api = { coverage_enabled: input.coverageEnabled ?? true, // 默认开;false=关闭资产覆盖度功能 }), taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)), + updateTask: (id: string, input: { name?: string; pinned?: boolean }) => patch(`/tasks/${id}`, input), + renameTask: (id: string, name: string) => patch(`/tasks/${id}`, { name }), + pinTask: (id: string, pinned: boolean) => patch(`/tasks/${id}`, { pinned }), createTaskCategory: (name: string) => post("/task-categories", { name }), renameTaskCategory: (id: number, name: string) => patch(`/task-categories/${id}`, { name }), deleteTaskCategory: (id: number) => del<{ deleted: number }>(`/task-categories/${id}`), @@ -704,6 +707,8 @@ export const api = { updateConversationProfile: (id: number, llm_profile_id: number | null) => patch<{ ok: boolean }>(`/conversations/${id}/profile`, { llm_profile_id }), deleteConversation: (id: number) => del<{ deleted: number }>(`/conversations/${id}`), + deleteConversations: (ids: number[]) => + post<{ items: { id: number; ok: boolean; error?: string }[] }>("/conversations/delete/batch", { ids }), // Incremental tail: steps after `since` (id ASC) — live poll + post-send fetch. conversationMessages: (id: number, since = 0) => get<{ items: Activity[]; cursor: number; running: boolean }>(`/conversations/${id}/messages?since=${since}`).then( 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/mock/handler.ts b/web/src/lib/mock/handler.ts index 8dfb02d..6e9b4fd 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -233,6 +233,16 @@ function sortMockConversations() { }); } +function sortMockTasks() { + mockTasks.sort((a, b) => { + const aPinned = a.pinned_at ? 1 : 0; + const bPinned = b.pinned_at ? 1 : 0; + if (aPinned !== bPinned) return bPinned - aPinned; + if (a.pinned_at !== b.pinned_at) return (b.pinned_at ?? "").localeCompare(a.pinned_at ?? ""); + return b.id.localeCompare(a.id, undefined, { numeric: true }); + }); +} + function normalizedTemplateName(value: unknown): string { return String(value ?? "") .trim() @@ -268,7 +278,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (path === "/auth/change-password") return { ok: true }; // ── tasks ── - if (path === "/tasks" && m === "GET") return { tasks: mockTasks, active: mockActiveTask }; + if (path === "/tasks" && m === "GET") { + sortMockTasks(); + return { tasks: mockTasks, active: mockActiveTask }; + } if (path === "/tasks" && m === "POST") { let suffix = 1; while (mockTasks.some((item) => item.id === `t-new-${suffix}`)) suffix++; @@ -436,6 +449,17 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (index >= 0) mockTaskTemplates.splice(index, 1); return { deleted: id }; } + if (seg[0] === "tasks" && seg.length === 2 && m === "PATCH") { + const task = mockTasks.find((item) => item.id === seg[1]); + if (!task) throw new Error("任务不存在"); + if (typeof b.name === "string") task.name = b.name.trim(); + if (typeof b.pinned === "boolean") { + task.pinned = b.pinned; + task.pinned_at = b.pinned ? (task.pinned_at ?? new Date().toISOString()) : null; + } + sortMockTasks(); + return structuredClone(task); + } if (seg[0] === "tasks" && seg.length === 2 && m === "DELETE") { const id = seg[1]; const index = mockTasks.findIndex((item) => item.id === id); @@ -1187,6 +1211,18 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (index >= 0) mockConversations.splice(index, 1); return { deleted: id }; } + if (path === "/conversations/delete/batch" && m === "POST") { + const ids = Array.isArray(b.ids) + ? [...new Set(b.ids.map(Number).filter((id) => Number.isInteger(id) && id > 0))] + : []; + const items = ids.map((id) => { + const index = mockConversations.findIndex((item) => item.id === id); + if (index < 0) return { id, ok: false, error: "conversation not found" }; + mockConversations.splice(index, 1); + return { id, ok: true }; + }); + return { items }; + } if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 3 && m === "GET") { const items = D.conversationMessages[Number(seg[1])] ?? []; return { items, cursor: items.length ? items[items.length - 1].seq : 0, running: false }; diff --git a/web/src/lib/sort-preference.ts b/web/src/lib/sort-preference.ts new file mode 100644 index 0000000..20342f4 --- /dev/null +++ b/web/src/lib/sort-preference.ts @@ -0,0 +1,47 @@ +"use client"; + +import * as React from "react"; + +import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client"; + +export type SortDirection = "asc" | "desc"; + +export interface SortPreference { + field: Field; + direction: SortDirection; +} + +export function useStoredSortPreference( + key: string, + fields: readonly Field[], + defaultField: Field, + defaultDirection: SortDirection, +) { + const [preference, setPreference] = React.useState>({ + field: defaultField, + direction: defaultDirection, + }); + const [hydrated, setHydrated] = React.useState(false); + + React.useEffect(() => { + const raw = getLocalStorageValue(key); + if (raw) { + try { + const parsed = JSON.parse(raw) as Partial>; + const field = fields.find((candidate) => candidate === parsed.field); + const direction = parsed.direction === "asc" || parsed.direction === "desc" ? parsed.direction : null; + if (field && direction) setPreference({ field, direction }); + } catch { + // Ignore malformed or legacy preferences and retain the current default. + } + } + setHydrated(true); + }, [fields, key]); + + React.useEffect(() => { + if (!hydrated) return; + setLocalStorageValue(key, JSON.stringify(preference)); + }, [hydrated, key, preference]); + + return [preference, setPreference] as const; +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 339e892..59c4b3c 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -9,6 +9,8 @@ export interface Task { name?: string; // 可选任务名称;空/缺省=未命名,展示时回退到描述 category_id?: number; category_name?: string; + pinned?: boolean; + pinned_at?: string | null; description: string; goal: string; status: TaskStatus; @@ -952,6 +954,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 ----