From 06a8f64b5ed0c7adb24cd3b0d3bfa92244f1b7b9 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:42:12 -0400 Subject: [PATCH 01/17] =?UTF-8?q?fix(ui):=20=E4=BB=BB=E5=8A=A1=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E5=B9=B6=E5=8F=91=E8=AE=BE=E7=BD=AE=E6=8C=89=E9=92=AE?= =?UTF-8?q?=E7=B4=A7=E9=9D=A0=E6=96=B0=E5=BB=BA=E4=BB=BB=E5=8A=A1=E6=8C=89?= =?UTF-8?q?=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/app/(main)/function/tasks/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index ce6b5a9..46b4ee5 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -872,7 +872,7 @@ function CreateTaskSheet({ tasks, onCreated }: { tasks: Task[]; onCreated: () => return ( - From 56101b46e89996e1f0c655f203c466bb6a2f8e8e Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:15:53 -0400 Subject: [PATCH 02/17] =?UTF-8?q?feat(worker):=20=E6=9A=82=E5=81=9C/?= =?UTF-8?q?=E9=87=8D=E8=B7=91=E7=BB=AD=E6=8E=A5=E5=85=88=E5=89=8D=20LLM=20?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=EF=BC=8C=E4=B8=8D=E5=86=8D=E4=BB=8E=E5=A4=B4?= =?UTF-8?q?=E5=BC=80=E5=A7=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Worker 的 Execute 在创建 session 后尝试 Resume 先前的 transcript, 如果存在则续接对话而非重建全新会话,避免浪费已积累的 LLM 上下文。 Claude-Session: https://claude.ai/code/session_014TfZWeVGhjXqZD3vcj44mz --- agent/worker.go | 12 ++++++++++++ db/exploration.go | 10 +++++----- server/engine.go | 3 ++- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/agent/worker.go b/agent/worker.go index 47e25e9..cdbe63d 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -360,6 +360,18 @@ func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db. s := agentcore.NewSession(opts) defer s.Close() // release the session's background-task manager (temp dir + processes) + // Resume prior conversation if this intent was paused/blocked/exhausted and is + // being re-run. The transcript ID is deterministic per intent, so if a prior + // session exists the worker continues from where it left off instead of + // restarting from scratch. + if w.tx != nil { + _ = s.Resume(opts.SessionID) + if len(s.Messages()) > 0 { + seedUnlockFromHistory(s.Messages(), def.UnlockSkill) + input = "继续执行。" + } + } + // Budgets + settlement are owned by the SDK (MaxTurns/MaxDuration + Settlement): // on hit it runs a wrap-up turn and finishes with ReasonMaxTurns/ReasonTimeout. // We only add a HARD ctx backstop past the soft wall-clock budget, for the rare diff --git a/db/exploration.go b/db/exploration.go index be01753..dbcdb35 100644 --- a/db/exploration.go +++ b/db/exploration.go @@ -215,7 +215,7 @@ func (s *ExplorationStore) SetNodeState(id int64, state string) error { // ResetRunningIntents returns any intent left in 'running' back to 'open' so it // is re-claimed. Called on startup: a 'running' intent with no live worker (a // backend restart or crashed worker goroutine left it stuck) would otherwise spin -// forever in the UI. Workers re-run an intent from scratch, so reopening is safe. +// forever in the UI. Workers resume from their transcript, so reopening is safe. func (s *ExplorationStore) ResetRunningIntents() (int64, error) { res, err := s.db.Exec(`UPDATE exploration_nodes SET state='open', completed_at=NULL, blocked_reason=NULL WHERE exploration_id=$1 AND kind='intent' AND state='running'`, s.expID) @@ -227,9 +227,9 @@ WHERE exploration_id=$1 AND kind='intent' AND state='running'`, s.expID) } // ReopenIntent flips ONE not-successfully-finished intent (blocked/exhausted/stopped) -// back to 'open' so a worker re-claims and re-runs it from scratch (graph writes it -// already produced stay). done/open/running are left untouched. Returns whether a row -// changed (false = intent absent or not in a rerunnable state). Used by the "重跑" button. +// back to 'open' so a worker re-claims it (graph writes it already produced stay). +// The worker resumes from its prior LLM transcript instead of restarting from scratch. +// done/open/running are left untouched. Returns whether a row changed. Used by "重跑". func (s *ExplorationStore) ReopenIntent(id int64) (bool, error) { res, err := s.db.Exec(`UPDATE exploration_nodes SET state='open', completed_at=NULL, blocked_reason=NULL @@ -244,7 +244,7 @@ WHERE id=$1 AND exploration_id=$2 AND kind='intent' // ReopenBlockedIntents flips EVERY 'blocked' intent in this exploration back to 'open' // (batch rerun after e.g. an LLM/network outage that blocked many at once). Returns the -// number reopened. Workers re-run each from scratch; kept graph writes remain. +// number reopened. Workers resume from their transcripts; kept graph writes remain. func (s *ExplorationStore) ReopenBlockedIntents() (int64, error) { res, err := s.db.Exec(`UPDATE exploration_nodes SET state='open', completed_at=NULL, blocked_reason=NULL WHERE exploration_id=$1 AND kind='intent' AND state='blocked'`, s.expID) diff --git a/server/engine.go b/server/engine.go index c52e76b..4606a8f 100644 --- a/server/engine.go +++ b/server/engine.go @@ -926,7 +926,8 @@ func (e *Engine) runWorkerStep(ctx context.Context, t *Task, name string, worker return true } // if a pause cancelled this run mid-flight, return the intent to the frontier - // so it is re-claimed (and re-run from scratch) on resume — not marked done. + // so it is re-claimed on resume — the worker will resume the prior LLM + // conversation from its transcript instead of restarting from scratch. if ectx.Err() != nil && e.IsPaused(t.ID) { _ = t.Store.SetIntentState(intent.ID, "open") return true From 6b79043d822e116d74bbd7dcc2de45c4288f790c Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 20 Aug 2026 09:36:28 -0400 Subject: [PATCH 03/17] =?UTF-8?q?feat(tasks):=20=E6=80=BB=E8=A7=88?= =?UTF-8?q?=E9=A1=B5=E6=94=AF=E6=8C=81=E4=BB=BB=E5=8A=A1=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E7=9A=84=E6=9F=A5=E7=9C=8B/=E5=A2=9E?= =?UTF-8?q?=E5=88=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - db: 新增 DeleteTaskScope;AddAgentScope 加 source 参数区分 agent/manual - server: 新增 POST/DELETE /api/tasks/{id}/scope 端点 - web: 总览页新增「测试范围」卡片,可按类型手动增删,继承范围只读 Claude-Session: https://claude.ai/code/session_014TfZWeVGhjXqZD3vcj44mz --- agent/tools_insert.go | 2 +- db/exploration_sources_test.go | 6 +- db/task_scope.go | 21 +- server/server.go | 60 ++++ .../tasks/detail/_tabs/overview-tab.tsx | 274 ++++++++++------ web/src/lib/api.ts | 300 ++++++++++++------ web/src/lib/mock/handler.ts | 6 + web/src/lib/types.ts | 12 + 8 files changed, 488 insertions(+), 193 deletions(-) diff --git a/agent/tools_insert.go b/agent/tools_insert.go index 65f7cf2..f2b943c 100644 --- a/agent/tools_insert.go +++ b/agent/tools_insert.go @@ -382,7 +382,7 @@ func (t *ToolSet) addTaskScope() actool.CoreTool { var added []map[string]any errs := map[string]string{} for i, e := range items { - ts, err := t.as.AddAgentScope(t.taskID, strings.TrimSpace(e.Kind), e.Value, a.Reason) + ts, err := t.as.AddAgentScope(t.taskID, strings.TrimSpace(e.Kind), e.Value, a.Reason, "agent") if err != nil { errs[strconv.Itoa(i)] = err.Error() continue diff --git a/db/exploration_sources_test.go b/db/exploration_sources_test.go index 7c66fae..07ceb29 100644 --- a/db/exploration_sources_test.go +++ b/db/exploration_sources_test.go @@ -356,13 +356,13 @@ func TestTaskAssetContextUsesDirectSourceScopeAndAnchors(t *testing.T) { _ = d.DeleteTask(grand.ID) _, _ = assets.DeleteByIDs([]int64{sourceTestedID, sourceUntestedID, sourceAnchoredOnlyID, grandID}) }) - if _, err := assets.AddAgentScope(source.ID, "root_domain", sourceTestedDomain, "test"); err != nil { + if _, err := assets.AddAgentScope(source.ID, "root_domain", sourceTestedDomain, "test", "agent"); err != nil { t.Fatal(err) } - if _, err := assets.AddAgentScope(source.ID, "root_domain", sourceUntestedDomain, "test"); err != nil { + if _, err := assets.AddAgentScope(source.ID, "root_domain", sourceUntestedDomain, "test", "agent"); err != nil { t.Fatal(err) } - if _, err := assets.AddAgentScope(grand.ID, "root_domain", grandDomain, "test"); err != nil { + if _, err := assets.AddAgentScope(grand.ID, "root_domain", grandDomain, "test", "agent"); err != nil { t.Fatal(err) } if _, err := d.Exploration(source.ExplorationID).AddNode(KindFact, map[string]any{"summary": "tested source asset"}, 0, "confirmed", "worker", []int64{sourceTestedID}); err != nil { diff --git a/db/task_scope.go b/db/task_scope.go index 2c69dcd..af1fc4a 100644 --- a/db/task_scope.go +++ b/db/task_scope.go @@ -113,11 +113,15 @@ func (s *AssetStore) AddAutoScope(taskID int64, assetType, domain, rawURL, ip st return nil } -// AddAgentScope parses an agent-provided (kind,value) and records it (source='agent'). +// AddAgentScope parses a (kind,value) pair and records it as task scope. +// source is "agent" when called from an LLM tool, "manual" from the UI. // company: value = company name or id (must already exist). root_domain/subdomain: // value = a domain. ip/cidr: value = an IP or CIDR (bare IP → /32,/128). -func (s *AssetStore) AddAgentScope(taskID int64, kind, value, reason string) (TaskScope, error) { - ts := TaskScope{TaskID: taskID, Kind: kind, Source: "agent", Reason: reason} +func (s *AssetStore) AddAgentScope(taskID int64, kind, value, reason, source string) (TaskScope, error) { + if source == "" { + source = "agent" + } + ts := TaskScope{TaskID: taskID, Kind: kind, Source: source, Reason: reason} if taskID <= 0 { return ts, fmt.Errorf("需要 task_id") } @@ -184,6 +188,17 @@ func (s *AssetStore) AddAgentScope(taskID int64, kind, value, reason string) (Ta return ts, nil } +// DeleteTaskScope removes a single scope row by id, scoped to the given task. +// Returns whether a row was actually deleted. +func (s *AssetStore) DeleteTaskScope(taskID, scopeID int64) (bool, error) { + res, err := s.db.Exec(`DELETE FROM task_scope WHERE id=$1 AND task_id=$2`, scopeID, taskID) + if err != nil { + return false, err + } + n, _ := res.RowsAffected() + return n > 0, nil +} + // ListTaskScope returns all scope rows for a task. func (s *AssetStore) ListTaskScope(taskID int64) ([]TaskScope, error) { rows, err := s.db.Query(` diff --git a/server/server.go b/server/server.go index 2f44d26..ecaf168 100644 --- a/server/server.go +++ b/server/server.go @@ -647,6 +647,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/workspace/download", s.wsDownload) mux.HandleFunc("POST /api/workspace/upload", s.wsUpload) mux.HandleFunc("GET /api/tasks/{id}/scope", s.taskScopeList) + mux.HandleFunc("POST /api/tasks/{id}/scope", s.taskScopeAdd) + mux.HandleFunc("DELETE /api/tasks/{id}/scope/{sid}", s.taskScopeDelete) mux.HandleFunc("POST /api/tasks/{id}/control", s.control) mux.HandleFunc("PUT /api/tasks/{id}/llm", s.updateTaskLLMProfiles) mux.HandleFunc("POST /api/tasks/{id}/intents/{iid}/control", s.controlIntent) @@ -1634,6 +1636,64 @@ func (s *Server) taskScopeList(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"scope": rows}) } +func (s *Server) taskScopeAdd(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + as := s.m.Assets() + if as == nil { + writeErr(w, 503, "asset store 未启用") + return + } + var body struct { + Kind string `json:"kind"` + Value string `json:"value"` + Reason string `json:"reason"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, 400, "invalid JSON") + return + } + taskID, _ := strconv.ParseInt(t.ID, 10, 64) + ts, err := as.AddAgentScope(taskID, body.Kind, body.Value, body.Reason, "manual") + if err != nil { + writeErr(w, 400, err.Error()) + return + } + writeJSON(w, 200, ts) +} + +func (s *Server) taskScopeDelete(w http.ResponseWriter, r *http.Request) { + t, ok := s.m.Task(r.PathValue("id")) + if !ok { + writeErr(w, 404, "task not found") + return + } + as := s.m.Assets() + if as == nil { + writeErr(w, 503, "asset store 未启用") + return + } + taskID, _ := strconv.ParseInt(t.ID, 10, 64) + scopeID, err := strconv.ParseInt(r.PathValue("sid"), 10, 64) + if err != nil { + writeErr(w, 400, "invalid scope id") + return + } + deleted, err := as.DeleteTaskScope(taskID, scopeID) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + if !deleted { + writeErr(w, 404, "scope row not found") + return + } + writeJSON(w, 200, map[string]bool{"ok": true}) +} + func (s *Server) frontier(w http.ResponseWriter, r *http.Request) { t := s.m.ResolveTask(r.URL.Query().Get("task")) if t == nil { diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx index f5fd9de..49722b7 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx @@ -1,27 +1,49 @@ "use client"; import * as React from "react"; + import { ActivityIcon, AlertTriangleIcon, BugIcon, ClockIcon, + PlusIcon, RefreshCwIcon, ShieldCheckIcon, TargetIcon, + Trash2Icon, } from "lucide-react"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Progress } from "@/components/ui/progress"; + import { StatusBadge } from "@/components/status-badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; +import { Progress } from "@/components/ui/progress"; import { api } from "@/lib/api"; -import type { Task, Stats, TaskNode, Finding } from "@/lib/types"; +import type { Finding, Stats, Task, TaskNode, TaskScopeRow } from "@/lib/types"; + +// 测试范围一条的显示值:域名 / 网段 / 公司。 +function scopeValue(row: TaskScopeRow): string { + if (row.domain) return row.domain; + if (row.net) return row.net; + if (row.company_id) return `company #${row.company_id}`; + return "—"; +} + +const SCOPE_KIND_LABELS: Record = { + company: "公司", + root_domain: "根域名", + subdomain: "子域名", + ip: "IP", + cidr: "网段", +}; + +const SCOPE_SOURCE_LABELS: Record = { + auto: "自动", + agent: "Agent", + manual: "手动", +}; function StatCard({ label, @@ -42,9 +64,7 @@ function StatCard({ {value} - {sub && ( - {sub} - )} + {sub && {sub}} ); } @@ -63,6 +83,46 @@ export function OverviewTab({ taskId }: { taskId: string }) { } | null>(null); // 正在重跑的意图 id(含 "__all__" 表示批量),用于禁用按钮 + 转圈。 const [rerunning, setRerunning] = React.useState>(new Set()); + // 测试范围列表 + 新增表单状态。 + const [scope, setScope] = React.useState([]); + const [scopeKind, setScopeKind] = React.useState("root_domain"); + const [scopeValueInput, setScopeValueInput] = React.useState(""); + const [scopeBusy, setScopeBusy] = React.useState(false); + const [scopeErr, setScopeErr] = React.useState(""); + + const loadScope = React.useCallback(async () => { + try { + const resp = await api.taskScope(taskId); + setScope(resp.scope); + } catch { + // 忽略:无 asset store 时接口 503,范围卡片自然为空 + } + }, [taskId]); + + const addScope = async () => { + const value = scopeValueInput.trim(); + if (!value) return; + setScopeBusy(true); + setScopeErr(""); + try { + await api.addTaskScope(taskId, scopeKind, value); + setScopeValueInput(""); + await loadScope(); + } catch (e) { + setScopeErr(e instanceof Error ? e.message : "添加失败"); + } finally { + setScopeBusy(false); + } + }; + + const removeScope = async (row: TaskScopeRow) => { + setScope((prev) => prev.filter((s) => s.id !== row.id)); + try { + await api.deleteTaskScope(taskId, row.id); + } catch { + await loadScope(); // 删除失败:重新拉取还原 + } + }; const markRerun = (key: string, on: boolean) => setRerunning((prev) => { @@ -77,9 +137,7 @@ export function OverviewTab({ taskId }: { taskId: string }) { markRerun(id, true); try { await api.rerunIntent(taskId, id); - setIntents((prev) => - prev.map((i) => (i.id === id ? { ...i, state: "open" } : i)), - ); + setIntents((prev) => prev.map((i) => (i.id === id ? { ...i, state: "open" } : i))); } catch { // 失败忽略:下次轮询仍显示 blocked,用户可再点 } finally { @@ -92,9 +150,7 @@ export function OverviewTab({ taskId }: { taskId: string }) { markRerun("__all__", true); try { await api.rerunBlocked(taskId); - setIntents((prev) => - prev.map((i) => (i.state === "blocked" ? { ...i, state: "open" } : i)), - ); + setIntents((prev) => prev.map((i) => (i.state === "blocked" ? { ...i, state: "open" } : i))); } catch { // ignore } finally { @@ -107,13 +163,12 @@ export function OverviewTab({ taskId }: { taskId: string }) { const load = async () => { try { - const [tasksResp, statsResp, intentsResp, findingsResp] = - await Promise.all([ - api.tasks(), - api.stats(taskId), - api.intents(taskId), - api.findings(taskId), - ]); + const [tasksResp, statsResp, intentsResp, findingsResp] = await Promise.all([ + api.tasks(), + api.stats(taskId), + api.intents(taskId), + api.findings(taskId), + ]); if (cancelled) return; setTask(tasksResp.tasks.find((t) => t.id === taskId) ?? null); setStats(statsResp); @@ -133,20 +188,19 @@ export function OverviewTab({ taskId }: { taskId: string }) { }; load(); + void loadScope(); const timer = setInterval(load, 3000); return () => { cancelled = true; clearInterval(timer); }; - }, [taskId]); + }, [taskId, loadScope]); const running = intents.filter((i) => i.state === "running"); const open = intents.filter((i) => i.state === "open"); const blocked = intents.filter((i) => i.state === "blocked"); const taskFindings = findings.filter((f) => f.task_id === taskId); - const goalsPct = task?.goals_total - ? Math.round(((task.goals_met ?? 0) / task.goals_total) * 100) - : 0; + const goalsPct = task?.goals_total ? Math.round(((task.goals_met ?? 0) / task.goals_total) * 100) : 0; return (
@@ -160,15 +214,11 @@ export function OverviewTab({ taskId }: { taskId: string }) {
描述
-

- {task?.description?.trim() || "—"} -

+

{task?.description?.trim() || "—"}

目标
-

- {task?.goal?.trim() || "—"} -

+

{task?.goal?.trim() || "—"}

@@ -205,6 +255,86 @@ export function OverviewTab({ taskId }: { taskId: string }) { )} + {/* 测试范围:覆盖度分母 + 授权边界,可手动增删。 */} + + + + 测试范围 + + (覆盖度分母 + 授权边界,共 {scope.length} 条) + + + + + {/* 新增表单 */} +
+ setScopeKind(e.target.value as TaskScopeRow["kind"])} + > + 根域名 + 子域名 + IP + 网段 + 公司 + + setScopeValueInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") void addScope(); + }} + disabled={scopeBusy} + /> + + {scopeErr && {scopeErr}} +
+ {/* 范围列表 */} + {scope.length > 0 ? ( +
+ {scope.map((row) => ( +
+ + {SCOPE_KIND_LABELS[row.kind]} + + {scopeValue(row)} + {SCOPE_SOURCE_LABELS[row.source]} + {row.task_id.toString() === taskId ? ( + + ) : ( + 继承 + )} +
+ ))} +
+ ) : ( +

暂无测试范围,添加后可作为资产覆盖度的分母。

+ )} +
+
{/* Heartbeat */} @@ -224,17 +354,13 @@ export function OverviewTab({ taskId }: { taskId: string }) {
运行中 Worker
-
- {running.length} -
+
{running.length}
最近活动
- {task?.last_activity - ? new Date(task.last_activity).toLocaleTimeString("zh-CN") - : "—"} + {task?.last_activity ? new Date(task.last_activity).toLocaleTimeString("zh-CN") : "—"}
@@ -270,9 +396,7 @@ export function OverviewTab({ taskId }: { taskId: string }) { {i.payload}
))} - {running.length === 0 && ( -

暂无进行中意图

- )} + {running.length === 0 &&

暂无进行中意图

} @@ -284,27 +408,19 @@ export function OverviewTab({ taskId }: { taskId: string }) {
-
- {taskFindings.length} -
+
{taskFindings.length}
确认漏洞
-
- {running.length} -
+
{running.length}
执行中
-
- {open.length} -
+
{open.length}
frontier 待领
-
- {blocked.length} -
+
{blocked.length}
被拦意图
@@ -323,9 +439,7 @@ export function OverviewTab({ taskId }: { taskId: string }) { {f.summary} ))} - {taskFindings.length === 0 && ( -

暂无发现

- )} + {taskFindings.length === 0 &&

暂无发现

} @@ -337,19 +451,10 @@ export function OverviewTab({ taskId }: { taskId: string }) { 被拦/出错意图 - - (共 {blocked.length} 条,可重跑) - + (共 {blocked.length} 条,可重跑) - @@ -365,9 +470,7 @@ export function OverviewTab({ taskId }: { taskId: string }) { disabled={rerunning.has(i.id)} onClick={() => void rerunOne(i.id)} > - + 重跑 @@ -383,24 +486,9 @@ export function OverviewTab({ taskId }: { taskId: string }) { {/* Stat cards */}
- - - + + +
); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 9b8e611..63595d4 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -6,15 +6,59 @@ import { MOCK } from "@/lib/mock/enabled"; import { mockHandle } from "@/lib/mock/handler"; import type { - Task, DeleteTaskOptions, DeleteTaskResult, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Severity, FindingStatus, - FindingsPage, FindingStats, FindingQuery, Activity, - Audit, TrafficResp, TrafficDetail, TrafficHost, Settings, LLMProfile, Agent, AgentDetail, PromptVar, - PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket, - Tool, Conversation, AgentTrigger, ChatAttachment, - InterceptRule, InterceptPending, InterceptApprovalRow, - TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs, - WorkspaceListing, WorkspaceFile, - CommandRecord, LLMRecordItem, LLMRecordDetail, LLMTask, LLMPoolStatus, + Activity, + Agent, + AgentDetail, + AgentTrigger, + Asset, + AssetNode, + Audit, + ChatAttachment, + CommandRecord, + Company, + Conversation, + ConvTokenSummary, + CoverageAssetRefs, + CoverageGraphData, + DailyTokenBucket, + DeleteTaskOptions, + DeleteTaskResult, + Edge, + Finding, + FindingQuery, + FindingStats, + FindingStatus, + FindingsPage, + InterceptApprovalRow, + InterceptPending, + InterceptRule, + LLMPoolStatus, + LLMProfile, + LLMRecordDetail, + LLMRecordItem, + LLMTask, + MCPServer, + MCPTool, + PromptVar, + PromptVersion, + Settings, + Severity, + SkillItem, + SSProject, + SSTask, + Stats, + Task, + TaskAssetView, + TaskNode, + TaskScopeRow, + TokenTotal, + TokenUsage, + Tool, + TrafficDetail, + TrafficHost, + TrafficResp, + WorkspaceFile, + WorkspaceListing, } from "@/lib/types"; function getToken(): string | null { @@ -92,19 +136,21 @@ function mockReport(_task?: string): string { export function sseUrl(path: string): string { const base = process.env.NEXT_PUBLIC_SSE_BASE ?? - (typeof window !== "undefined" - ? `${window.location.protocol}//${window.location.hostname}:8787` - : ""); + (typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8787` : ""); const token = getToken(); const sep = path.includes("?") ? "&" : "?"; return token ? `${base}${path}${sep}token=${encodeURIComponent(token)}` : `${base}${path}`; } const get = (p: string) => http(p); -const post = (p: string, body?: unknown) => http(p, { method: "POST", body: body ? JSON.stringify(body) : undefined }); -const put = (p: string, body?: unknown) => http(p, { method: "PUT", body: body ? JSON.stringify(body) : undefined }); -const patch = (p: string, body?: unknown) => http(p, { method: "PATCH", body: body ? JSON.stringify(body) : undefined }); -const del = (p: string, body?: unknown) => http(p, { method: "DELETE", body: body ? JSON.stringify(body) : undefined }); +const post = (p: string, body?: unknown) => + http(p, { method: "POST", body: body ? JSON.stringify(body) : undefined }); +const put = (p: string, body?: unknown) => + http(p, { method: "PUT", body: body ? JSON.stringify(body) : undefined }); +const patch = (p: string, body?: unknown) => + http(p, { method: "PATCH", body: body ? JSON.stringify(body) : undefined }); +const del = (p: string, body?: unknown) => + http(p, { method: "DELETE", body: body ? JSON.stringify(body) : undefined }); // Go serializes nil slices as JSON null — coerce to []. const arr = (x: T[] | null | undefined): T[] => x ?? []; @@ -116,15 +162,14 @@ export const api = { // ---- auth ---- authStatus: () => get<{ initialized: boolean }>("/auth/status"), - login: (username: string, password: string) => - post<{ token: string }>("/auth/login", { username, password }), - initPassword: (password: string) => - post<{ token: string }>("/auth/init", { password }), + login: (username: string, password: string) => post<{ token: string }>("/auth/login", { username, password }), + initPassword: (password: string) => post<{ token: string }>("/auth/init", { password }), changePassword: (oldPassword: string, newPassword: string) => post<{ ok: boolean }>("/auth/change-password", { old_password: oldPassword, new_password: newPassword }), // ---- tasks ---- - tasks: () => get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })), + tasks: () => + get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })), createTask: (input: { description: string; goal: string; @@ -155,9 +200,9 @@ export const api = { llm_profile_ids: llmProfileIds, active_llm_profile_id: activeLLMProfileId ?? null, }), - deleteTask: (id: string, options: DeleteTaskOptions) => - del(`/tasks/${id}`, options), - controlTask: (id: string, action: "pause" | "resume") => post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }), + deleteTask: (id: string, options: DeleteTaskOptions) => del(`/tasks/${id}`, options), + controlTask: (id: string, action: "pause" | "resume") => + post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }), controlIntent: (taskId: string, intentId: string, action: "pause" | "resume" | "cancel") => post<{ id: number; @@ -165,7 +210,8 @@ export const api = { deleted?: { intents: number; facts: number; findings: number; activities: number }; }>(`/tasks/${taskId}/intents/${intentId}/control`, { action }), // 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。 - rerunIntent: (taskId: string, intentId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`), + rerunIntent: (taskId: string, intentId: string) => + post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`), // 批量重跑本任务全部 blocked 意图(一次网络/LLM 断连导致多条 blocked 时一键全部重试)。 rerunBlocked: (taskId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/rerun-blocked`), setActive: (id: string) => post<{ active: string }>("/active", { id }), @@ -181,23 +227,24 @@ export const api = { by_type: { type: string; total: number; tested: number }[]; }>(`/tasks/${id}/coverage`), // 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。 - taskCoverageGraph: (id: string) => - get(`/tasks/${id}/coverage-graph`), + taskCoverageGraph: (id: string) => get(`/tasks/${id}/coverage-graph`), + // 本任务测试范围列表(含继承自来源任务的范围)。 + taskScope: (id: string) => get<{ scope: TaskScopeRow[] }>(`/tasks/${id}/scope`), + // 手动新增一条测试范围(kind=company/root_domain/subdomain/ip/cidr)。 + addTaskScope: (id: string, kind: string, value: string, reason?: string) => + post(`/tasks/${id}/scope`, { kind, value, reason }), + // 删除本任务的一条测试范围。 + deleteTaskScope: (id: string, scopeId: number) => del<{ ok: boolean }>(`/tasks/${id}/scope/${scopeId}`), // 某资产在本任务里关联的意图 / 事实 / 发现(覆盖图节点抽屉用)。 - taskAssetRefs: (id: string, assetId: number) => - get(`/tasks/${id}/asset-refs?asset_id=${assetId}`), + taskAssetRefs: (id: string, assetId: number) => get(`/tasks/${id}/asset-refs?asset_id=${assetId}`), // ---- workspace file manager (workDir) ---- - workspaceList: (path = "") => - get(`/workspace/list?path=${encodeURIComponent(path)}`), - workspaceRead: (path: string) => - get(`/workspace/read?path=${encodeURIComponent(path)}`), + workspaceList: (path = "") => get(`/workspace/list?path=${encodeURIComponent(path)}`), + workspaceRead: (path: string) => get(`/workspace/read?path=${encodeURIComponent(path)}`), workspaceWrite: (path: string, content: string) => post<{ ok: boolean; path: string }>(`/workspace/write`, { path, content }), - workspaceMkdir: (path: string) => - post<{ ok: boolean; path: string }>(`/workspace/mkdir`, { path }), - workspaceDelete: (path: string) => - del<{ ok: boolean }>(`/workspace/delete?path=${encodeURIComponent(path)}`), + workspaceMkdir: (path: string) => post<{ ok: boolean; path: string }>(`/workspace/mkdir`, { path }), + workspaceDelete: (path: string) => del<{ ok: boolean }>(`/workspace/delete?path=${encodeURIComponent(path)}`), workspaceUpload: async (dir: string, files: File[]) => { if (MOCK) return { uploaded: files.length }; const fd = new FormData(); @@ -236,8 +283,9 @@ export const api = { // ---- assets ---- // Server-side paginated: pass limit/offset, get back the page + full match total. assets: (type = "", limit = 50, offset = 0) => - get<{ count: number; total: number; assets: Asset[] }>(`/assets?type=${type}&limit=${limit}&offset=${offset}`) - .then((r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 })), + get<{ count: number; total: number; assets: Asset[] }>(`/assets?type=${type}&limit=${limit}&offset=${offset}`).then( + (r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 }), + ), searchAssets: (dsl: string, type = "", limit = 50, offset = 0) => get<{ count: number; total: number; assets: Asset[] }>( `/assets?dsl=${encodeURIComponent(dsl)}${type ? `&type=${encodeURIComponent(type)}` : ""}&limit=${limit}&offset=${offset}`, @@ -257,18 +305,38 @@ export const api = { createCompany: (name: string, logo: string, scope: string) => post<{ id: number; created: boolean; scope_added?: number; scope_invalid?: number; scope_errors?: string[] }>( "/companies", - { name, logo, scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [] }, + { + name, + logo, + scope: scope.trim() + ? scope + .split("\n") + .map((s) => s.trim()) + .filter(Boolean) + : [], + }, ), addCompanyScope: (id: number, scope: string, reason = "") => - post<{ added: number; skipped: number; invalid: number; errors?: string[] }>( - `/companies/${id}/scope`, - { scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [], reason }, - ), + post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(`/companies/${id}/scope`, { + scope: scope.trim() + ? scope + .split("\n") + .map((s) => s.trim()) + .filter(Boolean) + : [], + reason, + }), updateCompanyScope: (id: number, scope: string, reason = "") => - post<{ added: number; skipped: number; invalid: number; errors?: string[] }>( - `/companies/${id}/scope`, - { scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [], reason, reset: true }, - ), + post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(`/companies/${id}/scope`, { + scope: scope.trim() + ? scope + .split("\n") + .map((s) => s.trim()) + .filter(Boolean) + : [], + reason, + reset: true, + }), deleteCompany: (id: number, deleteAssets = false) => http<{ deleted: number; assets_deleted: number }>(`/companies/${id}`, { method: "DELETE", @@ -293,12 +361,9 @@ export const api = { `/exploration/findings/${id}${contextTaskId ? `?context_task=${encodeURIComponent(contextTaskId)}` : ""}`, ), // 漏洞链路:该漏洞节点回溯到任务初始节点的子图(节点 + 关系)。 - findingLineage: (id: string) => - get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`), - setFindingStatus: (id: string, status: FindingStatus) => - patch(`/exploration/findings/${id}`, { status }), - setFindingSeverity: (id: string, severity: Severity) => - patch(`/exploration/findings/${id}`, { severity }), + findingLineage: (id: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`), + setFindingStatus: (id: string, status: FindingStatus) => patch(`/exploration/findings/${id}`, { status }), + setFindingSeverity: (id: string, severity: Severity) => patch(`/exploration/findings/${id}`, { severity }), // 一次保存漏洞的名称/类别/严重等级(发现列表行内编辑用),只传出现的字段。 updateFinding: ( id: string, @@ -312,10 +377,11 @@ export const api = { workers: arr(r.workers), total: r.total, })), - tokenDaily: (days = 30) => - get(`/tokens/daily?days=${days}`).then(arr), + tokenDaily: (days = 30) => get(`/tokens/daily?days=${days}`).then(arr), conversationTokens: () => - get("/tokens/conversations").then(arr).catch(() => [] as ConvTokenSummary[]), + get("/tokens/conversations") + .then(arr) + .catch(() => [] as ConvTokenSummary[]), explorationGraph: (task?: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/graph${tq(task)}`), activity: (task?: string, opts?: { intent?: string; since?: number; limit?: number }) => { const q = new URLSearchParams(); @@ -323,7 +389,10 @@ export const api = { if (opts?.intent) q.set("intent", opts.intent); if (opts?.since) q.set("since", String(opts.since)); if (opts?.limit) q.set("limit", String(opts.limit)); - return get<{ items: Activity[]; cursor: number }>(`/exploration/activity?${q.toString()}`).then((r) => ({ items: arr(r.items), cursor: r.cursor ?? 0 })); + return get<{ items: Activity[]; cursor: number }>(`/exploration/activity?${q.toString()}`).then((r) => ({ + items: arr(r.items), + cursor: r.cursor ?? 0, + })); }, activityDetail: (id: number, task?: string) => get<{ detail: string }>(`/exploration/activity/${id}${tq(task)}`), // Reverse-paginated session history. session = "main" | "plan" | "intent:". @@ -366,20 +435,21 @@ export const api = { (method && method !== "all" ? `&method=${encodeURIComponent(method)}` : "") + (q ? `&q=${encodeURIComponent(q)}` : ""), ), - trafficExchange: (id: string) => - get(`/traffic/exchange?id=${encodeURIComponent(id)}`), + trafficExchange: (id: string) => get(`/traffic/exchange?id=${encodeURIComponent(id)}`), trafficHosts: () => get<{ hosts: TrafficHost[] }>(`/traffic/hosts`), - trafficDeleteHost: (host: string) => - del<{ deleted: number }>(`/traffic?host=${encodeURIComponent(host)}`), - trafficDeleteHosts: (hosts: string[]) => - del<{ deleted: number }>(`/traffic/hosts`, { hosts }), + trafficDeleteHost: (host: string) => del<{ deleted: number }>(`/traffic?host=${encodeURIComponent(host)}`), + trafficDeleteHosts: (hosts: string[]) => del<{ deleted: number }>(`/traffic/hosts`, { hosts }), // ---- app settings (runtime toggles) ---- settings: () => get(`/settings`), setSettings: (patch: Partial) => put(`/settings`, patch), // Run a real "test" search with the given (or saved) config to verify it works. - testWebSearch: (patch: { web_search_backend?: string; web_search_proxy?: string; brave_search_api_key?: string; tavily_search_api_key?: string }) => - post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, patch), + testWebSearch: (patch: { + web_search_backend?: string; + web_search_proxy?: string; + brave_search_api_key?: string; + tavily_search_api_key?: string; + }) => post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, patch), report: async (task?: string) => { if (MOCK) return mockReport(task); const token = getToken(); @@ -393,7 +463,15 @@ export const api = { post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments }), // 方式1 文件上传:落到会话/任务工作目录 uploads/,返回可供 agent Read 的相对路径。 chatUpload: async (scope: "task" | "session" | "staging", id: string, files: File[]) => { - if (MOCK) return { attachments: files.map((f) => ({ name: f.name, path: `uploads/${f.name}`, size: f.size, abs: `/mock/${f.name}` })) }; + if (MOCK) + return { + attachments: files.map((f) => ({ + name: f.name, + path: `uploads/${f.name}`, + size: f.size, + abs: `/mock/${f.name}`, + })), + }; const fd = new FormData(); for (const f of files) fd.append("file", f); const token = getToken(); @@ -409,9 +487,29 @@ export const api = { gc: (ttl = 86400) => post<{ removed: number }>(`/gc?ttl=${ttl}`, {}), // ---- LLM ---- - getLLM: () => get<{ configured: boolean; provider: string; model: string; base_url: string; proxy?: string; key_set: boolean; rate_per_second?: number; rate_per_minute?: number; context_window_k?: number; reasoning_effort?: string }>("/llm"), - setLLM: (provider: string, model: string, base_url: string, api_key: string, rate_per_second = 0, rate_per_minute = 0, proxy = "", context_window_k = 0) => - post("/llm", { provider, model, base_url, proxy, api_key, rate_per_second, rate_per_minute, context_window_k }), + getLLM: () => + get<{ + configured: boolean; + provider: string; + model: string; + base_url: string; + proxy?: string; + key_set: boolean; + rate_per_second?: number; + rate_per_minute?: number; + context_window_k?: number; + reasoning_effort?: string; + }>("/llm"), + setLLM: ( + provider: string, + model: string, + base_url: string, + api_key: string, + rate_per_second = 0, + rate_per_minute = 0, + proxy = "", + context_window_k = 0, + ) => post("/llm", { provider, model, base_url, proxy, api_key, rate_per_second, rate_per_minute, context_window_k }), testLLM: ( provider: string, model: string, @@ -464,8 +562,7 @@ export const api = { // ---- agents ---- agents: () => get<{ agents: Agent[] }>("/agents").then((r) => arr(r.agents)), getAgent: (key: string) => get(`/agents/${key}`), - createAgent: (key: string, name: string, description = "") => - post("/agents", { key, name, description }), + createAgent: (key: string, name: string, description = "") => post("/agents", { key, name, description }), updateAgent: (key: string, name: string, description = "") => patch<{ ok: boolean }>(`/agents/${key}`, { name, description }), deleteAgent: (key: string) => del<{ deleted: string }>(`/agents/${key}`), @@ -497,7 +594,8 @@ export const api = { sendConversationMessage: (id: number, message: string, attachments?: ChatAttachment[]) => post<{ status: string }>(`/conversations/${id}/messages`, { message, attachments }), stopConversation: (id: number) => post<{ status: string }>(`/conversations/${id}/stop`, {}), - saveAgentPrompt: (key: string, template: string, note = "") => put<{ version: number }>(`/agents/${key}/prompt`, { template, note }), + saveAgentPrompt: (key: string, template: string, note = "") => + put<{ version: number }>(`/agents/${key}/prompt`, { template, note }), resetAgentPrompt: (key: string) => post<{ version: number }>(`/agents/${key}/prompt/reset`, {}), // 收尾提示词(超时/步数耗尽的 settlement 提示);prompt 空串=清除覆盖、用内置默认; // max_turns 省略则不动、传 0=用内置默认轮数 @@ -509,7 +607,10 @@ export const api = { saveAgentTaskTimeoutWrapup: (key: string, prompt: string, maxTurns?: number) => put<{ ok: boolean }>(`/agents/${key}/wrapup/task-timeout`, { prompt, max_turns: maxTurns }), resetAgentTaskTimeoutWrapup: (key: string) => - post<{ ok: boolean; task_timeout_wrapup_default: string; task_timeout_wrapup_max_turns_default: number }>(`/agents/${key}/wrapup/task-timeout/reset`, {}), + post<{ ok: boolean; task_timeout_wrapup_default: string; task_timeout_wrapup_max_turns_default: number }>( + `/agents/${key}/wrapup/task-timeout/reset`, + {}, + ), // P3 triggers (仅自定义 agent) agentTriggers: (key: string) => get<{ triggers: AgentTrigger[] }>(`/agents/${key}/triggers`).then((r) => arr(r.triggers)), @@ -531,12 +632,15 @@ export const api = { trigger_max_parallel?: number; }, ) => put<{ ok: boolean }>(`/agents/${key}/config`, patch), - agentPromptVersions: (key: string) => get<{ versions: PromptVersion[] }>(`/agents/${key}/prompts`).then((r) => arr(r.versions)), - agentVariables: (key: string) => get<{ variables: PromptVar[] }>(`/agents/${key}/variables`).then((r) => arr(r.variables)), + agentPromptVersions: (key: string) => + get<{ versions: PromptVersion[] }>(`/agents/${key}/prompts`).then((r) => arr(r.versions)), + agentVariables: (key: string) => + get<{ variables: PromptVar[] }>(`/agents/${key}/variables`).then((r) => arr(r.variables)), previewAgentPrompt: (key: string, template: string, sample?: Record) => post<{ rendered: string; error?: string }>(`/agents/${key}/prompt/preview`, { template, sample }), getAgentVisibility: (key: string) => get<{ mcp: number[]; skill: string[] }>(`/agents/${key}/visibility`), - setAgentVisibility: (key: string, mcp: number[], skill: string[]) => put<{ ok: boolean }>(`/agents/${key}/visibility`, { mcp, skill }), + setAgentVisibility: (key: string, mcp: number[], skill: string[]) => + put<{ ok: boolean }>(`/agents/${key}/visibility`, { mcp, skill }), // ---- tools (内置工具目录) ---- tools: () => get<{ tools: Tool[] }>("/tools").then((r) => arr(r.tools)), @@ -544,10 +648,13 @@ export const api = { put<{ ok: boolean }>(`/tools/${key}`, patch), resetTool: (key: string) => post<{ ok: boolean }>(`/tools/${key}/reset`, {}), // custom tools (自定义工具) - createCustomTool: (t: Pick) => - post<{ key: string }>("/tools/custom", t), - updateCustomTool: (key: string, t: Pick) => - put<{ ok: boolean }>(`/tools/custom/${key}`, t), + createCustomTool: ( + t: Pick, + ) => post<{ key: string }>("/tools/custom", t), + updateCustomTool: ( + key: string, + t: Pick, + ) => put<{ ok: boolean }>(`/tools/custom/${key}`, t), deleteCustomTool: (key: string) => del<{ deleted: string }>(`/tools/custom/${key}`), testCustomTool: (body: { kind: string; exec: Record; params: Record }) => post<{ output: string; is_error: boolean }>("/tools/custom/test", body), @@ -591,8 +698,14 @@ export const api = { // ---- skills (文件系统) ---- skills: () => get<{ skills: SkillItem[] }>("/skills").then((r) => arr(r.skills)), - createSkill: (s: { name: string; description: string; license?: string; compatibility?: string; mcps?: string[]; instructions?: string }) => - post<{ name: string }>("/skills", s), + createSkill: (s: { + name: string; + description: string; + license?: string; + compatibility?: string; + mcps?: string[]; + instructions?: string; + }) => post<{ name: string }>("/skills", s), // uploadSkill installs a skill from a .zip (multipart). Surfaces the backend // error text (e.g. 已存在 / 缺少 SKILL.md) so the UI can show a precise message. uploadSkill: async (file: File, overwrite = false): Promise<{ name: string; files: number }> => { @@ -610,20 +723,21 @@ export const api = { return body; }, deleteSkill: (name: string) => del<{ deleted: string }>(`/skills/${name}`), - updateSkillMeta: (name: string, meta: { mcps?: string[]; description?: string; license?: string; compatibility?: string }) => - put<{ ok: boolean }>(`/skills/${name}/meta`, meta), - createSkillDir: (skill: string, path: string) => - post<{ dir: string }>(`/skills/${skill}/dirs`, { path }), + updateSkillMeta: ( + name: string, + meta: { mcps?: string[]; description?: string; license?: string; compatibility?: string }, + ) => put<{ ok: boolean }>(`/skills/${name}/meta`, meta), + createSkillDir: (skill: string, path: string) => post<{ dir: string }>(`/skills/${skill}/dirs`, { path }), skillFiles: (name: string) => get<{ files: string[] }>(`/skills/${name}/files`).then((r) => r.files), readSkillFile: (name: string, file: string) => get<{ content: string; file: string }>(`/skills/${name}/files/${file}`).then((r) => r.content), writeSkillFile: (name: string, file: string, content: string) => put<{ ok: boolean }>(`/skills/${name}/files/${file}`, { content }), - deleteSkillPath: (skill: string, path: string) => - del<{ deleted: string }>(`/skills/${skill}/files/${path}`), + deleteSkillPath: (skill: string, path: string) => del<{ deleted: string }>(`/skills/${skill}/files/${path}`), // ---- visibility (MCP resource side) ---- (agent ids are strings per spec) - resourceVisibility: (kind: string, id: number) => get<{ agents: string[] }>(`/visibility/${kind}/${id}`).then((r) => arr(r.agents)), + resourceVisibility: (kind: string, id: number) => + get<{ agents: string[] }>(`/visibility/${kind}/${id}`).then((r) => arr(r.agents)), toggleVisibility: (agentId: string, kind: string, resourceId: number, visible: boolean) => post<{ ok: boolean }>("/visibility/toggle", { agent_id: agentId, kind, resource_id: resourceId, visible }), @@ -648,7 +762,8 @@ export const api = { interceptDecide: (id: number, decision: "allowed" | "denied") => post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }), interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)), - interceptTask: (taskId: string) => get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)), + interceptTask: (taskId: string) => + get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)), // ---- intercept tool-config (全局工具拦截范围) ---- interceptGetToolConfig: async (): Promise<{ enabled_tools: string[] }> => { @@ -696,6 +811,5 @@ export const api = { }, llmRecordDetail: (id: number) => get(`/llm/records/${id}`), llmTasks: () => get<{ tasks: LLMTask[] }>(`/llm/records/tasks`), - llmRecordsDeleteTask: (task: string) => - del<{ deleted: number }>(`/llm/records?task=${encodeURIComponent(task)}`), + llmRecordsDeleteTask: (task: string) => del<{ deleted: number }>(`/llm/records?task=${encodeURIComponent(task)}`), }; diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 1b57783..bd926e5 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -149,6 +149,12 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (seg[0] === "tasks" && seg[2] === "coverage-graph") return D.coverageGraph; if (seg[0] === "tasks" && seg[2] === "asset-refs") return D.assetRefsFor(Number(q.get("asset_id") ?? 0)); + // ── 任务测试范围(增删查)── + if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 3 && m === "GET") return { scope: [] }; + if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 3 && m === "POST") + return { id: Date.now(), task_id: Number(seg[1]), kind: b.kind, domain: b.value, source: "manual" }; + if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 4 && m === "DELETE") return { ok: true }; + // ── 工作空间文件管理器(demo:静态示例树;写/建/删走下方写兜底 {ok:true})── if (path === "/workspace/list") return D.workspaceList(q.get("path") ?? ""); if (path === "/workspace/read") return D.workspaceRead(q.get("path") ?? ""); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index ca761f7..cc5edf7 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -225,6 +225,18 @@ export interface WorkspaceFile { content?: string; } +// 任务测试范围的一条(覆盖度分母 + 授权边界)。 +export interface TaskScopeRow { + id: number; + task_id: number; + kind: "company" | "root_domain" | "subdomain" | "ip" | "cidr"; + company_id?: number; + domain?: string; + net?: string; + source: "auto" | "agent" | "manual"; + reason?: string; +} + // 公司资产范围规则的一条(归属唯一真值来源)。 export interface ScopeRow { id: number; From def7649baa248037ae6dc2799f1fd0c63b5b7db6 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:16:48 -0400 Subject: [PATCH 04/17] =?UTF-8?q?feat(tokens):=20=E6=96=B0=E5=A2=9E=20llm?= =?UTF-8?q?=5Fusage=20=E8=AE=A1=E9=87=8F=E8=B4=A6=E6=9C=AC=EF=BC=8C?= =?UTF-8?q?=E6=8C=89=E6=A8=A1=E5=9E=8B=E7=BB=9F=E8=AE=A1=20token=20+=20?= =?UTF-8?q?=E4=BB=AA=E8=A1=A8=E7=9B=98=E6=96=B0=E6=97=A7=E7=89=88=E5=88=87?= =?UTF-8?q?=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - llmrec: 拆分轻量计量与重量 body 录制——token+model+task+worker 永远写入 新表 llm_usage(不含 body,不受 llm_record 开关控制),body 仍受开关控制 - 修复 token 累加 bug:usage 从覆盖改为 Add(Anthropic 把 input/output 拆到 message_start/message_delta 两个事件,覆盖会把 input 冲成 0;OpenAI 不受影响) - 任务总览新增「LLM Token 用量」卡片:按模型统计 + 缓存命中率(缓存读/输入) - 仪表盘 Token 卡片加「新版/旧版」开关:新版走 llm_usage(逐次精确、含中断消耗、 真实每日消耗),旧版沿用 activity 统计 - llm_usage 随任务删除保留(历史计量账本) Claude-Session: https://claude.ai/code/session_014TfZWeVGhjXqZD3vcj44mz --- db/commands.go | 12 + db/llm_usage.go | 183 ++++++ db/tasks.go | 2 + llmrec/llmrec.go | 99 ++- server/commands.go | 49 ++ server/manager.go | 3 + server/server.go | 2 + web/src/app/(main)/dashboard/page.tsx | 594 +++++++++--------- .../tasks/detail/_tabs/overview-tab.tsx | 118 +++- web/src/lib/api.ts | 8 + web/src/lib/mock/handler.ts | 113 +++- web/src/lib/types.ts | 79 ++- 12 files changed, 892 insertions(+), 370 deletions(-) create mode 100644 db/llm_usage.go diff --git a/db/commands.go b/db/commands.go index 82b9c45..27172da 100644 --- a/db/commands.go +++ b/db/commands.go @@ -209,6 +209,18 @@ func (d *DB) ListLLMRecords(model, session, task string, page, size int) ([]LLMR return out, total, rows.Err() } +// ModelTokenStat is one model's aggregated token usage for a task, summed from the +// llm_usage metering ledger (see db/llm_usage.go). Calls is the number of LLM calls +// that hit this model. +type ModelTokenStat struct { + Model string `json:"model"` + Calls int `json:"calls"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheReadTokens int `json:"cache_read_tokens"` + CacheWriteTokens int `json:"cache_write_tokens"` +} + // LLMTask is one distinct task with its LLM-record count. type LLMTask struct { TaskID string `json:"task_id"` diff --git a/db/llm_usage.go b/db/llm_usage.go new file mode 100644 index 0000000..ecbf83a --- /dev/null +++ b/db/llm_usage.go @@ -0,0 +1,183 @@ +package db + +import "strconv" + +// LLMUsage is one lightweight LLM-call metering row — the always-on usage ledger, +// distinct from llm_records (which stores full request/response bodies and is a +// gated debug feature). One row per completion call, written on both success and +// error, so token accounting is complete even for interrupted/failed runs. Carries +// only the dimensions needed to slice token spend (model / profile / task / agent), +// never any prompt or response content. +type LLMUsage struct { + TaskID string `json:"task_id"` // task registry id (matches llm_records.task_id) + ExplorationID int64 `json:"exploration_id"` // exploration id parsed from the session (0 = unknown/non-task) + Worker string `json:"worker"` // agent lane: worker / planner / mainagent / goals + Model string `json:"model"` + ProfileName string `json:"profile_name"` + LatencyMs int `json:"latency_ms"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheRead int `json:"cache_read"` + CacheWrite int `json:"cache_write"` + Status string `json:"status"` // ok | error +} + +const llmUsageSchema = ` +CREATE TABLE IF NOT EXISTS llm_usage ( + id BIGSERIAL PRIMARY KEY, + ts TIMESTAMPTZ NOT NULL DEFAULT now(), + task_id TEXT, + exploration_id BIGINT, + worker TEXT, + model TEXT, + profile_name TEXT, + latency_ms INTEGER, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read INTEGER NOT NULL DEFAULT 0, + cache_write INTEGER NOT NULL DEFAULT 0, + status TEXT +); +CREATE INDEX IF NOT EXISTS idx_llm_usage_task ON llm_usage(task_id); +CREATE INDEX IF NOT EXISTS idx_llm_usage_model ON llm_usage(task_id, model); +CREATE INDEX IF NOT EXISTS idx_llm_usage_exp ON llm_usage(exploration_id); +` + +// EnsureLLMUsageTable creates the llm_usage metering table if it does not exist. +func (d *DB) EnsureLLMUsageTable() error { + _, err := d.Exec(llmUsageSchema) + return err +} + +// InsertLLMUsage appends one metering row. Best-effort: callers log and continue on +// error (a lost metering row must never break the LLM call). +func (d *DB) InsertLLMUsage(u *LLMUsage) error { + var expID any + if u.ExplorationID > 0 { + expID = u.ExplorationID + } + _, err := d.Exec(` +INSERT INTO llm_usage(task_id, exploration_id, worker, model, profile_name, latency_ms, input_tokens, output_tokens, cache_read, cache_write, status) +VALUES (NULLIF($1,''),$2,NULLIF($3,''),NULLIF($4,''),NULLIF($5,''),$6,$7,$8,$9,$10,$11)`, + u.TaskID, expID, u.Worker, u.Model, u.ProfileName, + u.LatencyMs, u.InputTokens, u.OutputTokens, u.CacheRead, u.CacheWrite, u.Status) + return err +} + +// TokenByModel aggregates a task's LLM token usage grouped by model, most-used +// first, from the always-on llm_usage ledger. taskID is the task registry id. +// Accurate even with per-agent model bindings, pool rotation/failover, and +// interrupted runs, since every call (success or error) is metered. +func (d *DB) TokenByModel(taskID string) ([]ModelTokenStat, error) { + rows, err := d.Query(` +SELECT COALESCE(NULLIF(model,''),'(unknown)') AS model, COUNT(*) AS calls, + COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0), + COALESCE(SUM(cache_read),0), COALESCE(SUM(cache_write),0) +FROM llm_usage +WHERE COALESCE(task_id,'') = $1 +GROUP BY model +ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC, model`, taskID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []ModelTokenStat{} + for rows.Next() { + var m ModelTokenStat + if err := rows.Scan(&m.Model, &m.Calls, &m.InputTokens, &m.OutputTokens, + &m.CacheReadTokens, &m.CacheWriteTokens); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +// ProfileUsage aggregates the whole ledger's token spend for one LLM profile +// (global, all tasks). Powers the dashboard's per-profile token card (new source). +type ProfileUsage struct { + ProfileName string `json:"profile_name"` + Calls int `json:"calls"` + Tasks int `json:"tasks"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheReadTokens int `json:"cache_read_tokens"` + CacheWriteTokens int `json:"cache_write_tokens"` +} + +// UsageByProfile returns global token spend grouped by profile name, most-used +// first. profile_name may be empty for calls made on env/non-persisted configs. +func (d *DB) UsageByProfile() ([]ProfileUsage, error) { + rows, err := d.Query(` +SELECT COALESCE(profile_name,'') AS profile_name, COUNT(*) AS calls, + COUNT(DISTINCT task_id) AS tasks, + COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0), + COALESCE(SUM(cache_read),0), COALESCE(SUM(cache_write),0) +FROM llm_usage +GROUP BY profile_name +ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []ProfileUsage{} + for rows.Next() { + var p ProfileUsage + if err := rows.Scan(&p.ProfileName, &p.Calls, &p.Tasks, + &p.InputTokens, &p.OutputTokens, &p.CacheReadTokens, &p.CacheWriteTokens); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// ProfileDayUsage is one (profile, UTC calendar day) token bucket for the daily +// chart. Unlike the activity-based chart, ts is the real call time, so this is +// actual per-day consumption rather than tokens bucketed by task creation date. +type ProfileDayUsage struct { + ProfileName string `json:"profile_name"` + Date string `json:"date"` // YYYY-MM-DD (UTC) + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheReadTokens int `json:"cache_read_tokens"` +} + +// UsageDaily returns per-(profile, day) token buckets for the past `days` days +// (default 365 when days<=0), so the dashboard can slice by profile + range. +func (d *DB) UsageDaily(days int) ([]ProfileDayUsage, error) { + if days <= 0 { + days = 365 + } + rows, err := d.Query(` +SELECT COALESCE(profile_name,'') AS profile_name, + to_char(ts AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS day, + COALESCE(SUM(input_tokens),0), COALESCE(SUM(output_tokens),0), COALESCE(SUM(cache_read),0) +FROM llm_usage +WHERE ts >= now() - ($1 * interval '1 day') +GROUP BY profile_name, day +ORDER BY day`, days) + if err != nil { + return nil, err + } + defer rows.Close() + out := []ProfileDayUsage{} + for rows.Next() { + var p ProfileDayUsage + if err := rows.Scan(&p.ProfileName, &p.Date, &p.InputTokens, &p.OutputTokens, &p.CacheReadTokens); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +// ParseExpID turns the exploration-id segment parsed from a session string into an +// int64 (0 when empty/non-numeric, e.g. chat sessions keyed by conversation id). +func ParseExpID(s string) int64 { + n, err := strconv.ParseInt(s, 10, 64) + if err != nil || n < 0 { + return 0 + } + return n +} diff --git a/db/tasks.go b/db/tasks.go index e5c4dc9..3740ccc 100644 --- a/db/tasks.go +++ b/db/tasks.go @@ -437,6 +437,8 @@ DELETE FROM assets a USING deletable d WHERE a.id=d.id`, id, expID) } result.LLMRecordsDeleted, _ = res.RowsAffected() } + // llm_usage (the token metering ledger) is intentionally NOT deleted with the + // task — it is kept as historical accounting even after the task is gone. if _, err := tx.Exec(`DELETE FROM tasks WHERE id=$1`, id); err != nil { return result, err } diff --git a/llmrec/llmrec.go b/llmrec/llmrec.go index cf0be46..d4c3d43 100644 --- a/llmrec/llmrec.go +++ b/llmrec/llmrec.go @@ -11,9 +11,9 @@ import ( "strings" "time" + "github.com/Autumn-27/artex/db" "github.com/Autumn-27/norma/llm" "github.com/Autumn-27/norma/transcript" - "github.com/Autumn-27/artex/db" ) type taskIDContextKey struct{} @@ -84,14 +84,15 @@ func parseSession(s string) (taskID, worker string) { // harness via transcript.WithSessionID; reading it per-call is race-free even when // planner and multiple workers share one Recorder instance. func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] { - // Recording off → passthrough with zero overhead: no request serialization, - // no response accumulation, no DB insert. - if r.enabled != nil && !r.enabled() { - return r.inner.Stream(ctx, req) - } + // Body recording (the heavy debug trace: full request/response) is gated by the + // llm_record setting. Lightweight usage metering always runs — it powers token + // stats and must be complete even for interrupted/failed runs, so it is NOT + // gated. Only body serialization + response accumulation are skipped when off. + recordBodies := r.enabled == nil || r.enabled() start := time.Now() session := transcript.SessionIDFrom(ctx) parsedID, worker := parseSession(session) + expID := db.ParseExpID(parsedID) taskID := TaskIDFrom(ctx) if taskID == "" { // Backward compatibility for non-task callers. For task calls, the task @@ -99,51 +100,101 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S taskID = parsedID } - // Serialize the request for storage. - reqBody := serializeRequest(req) + // Serialize the request only when storing bodies (this is the expensive part). + reqBody := "" + if recordBodies { + reqBody = serializeRequest(req) + } return func(yield func(llm.StreamEvent, error) bool) { var ( - textBuf strings.Builder + textBuf strings.Builder thinkingBuf strings.Builder - usage llm.Usage - stopReason string - streamErr error + usage llm.Usage + stopReason string + streamErr error ) + finish := func(err error) { + status := "ok" + if err != nil { + status = "error" + } + // Lightweight metering row — always written. + r.recordUsage(taskID, expID, worker, usage, int(time.Since(start).Milliseconds()), status) + // Heavy trace row — only when body recording is on. + if recordBodies { + r.record(req, session, taskID, worker, reqBody, start, textBuf.String(), thinkingBuf.String(), usage, stopReason, err) + } + } + for ev, err := range r.inner.Stream(ctx, req) { if err != nil { streamErr = err - // Record the failed call, then propagate. - r.record(req, session, taskID, worker, reqBody, start, textBuf.String(), thinkingBuf.String(), usage, "", err) + finish(streamErr) if !yield(ev, err) { return } return } - // Accumulate response content. + // Always track usage (cheap); accumulate text/thinking only for bodies. + // Anthropic (and the other providers) split token usage across events: + // message_start carries ONLY the input side (input + cache), message_delta + // ONLY the output. They must be FOLDED with Add — overwriting on delta + // would zero out the input already counted at start (mirrors the SDK's own + // llm.Accumulator; see norma/llm/accumulate.go). switch ev.Type { case llm.SETextDelta: - textBuf.WriteString(ev.Text) + if recordBodies { + textBuf.WriteString(ev.Text) + } case llm.SEThinkingDelta: - thinkingBuf.WriteString(ev.Text) + if recordBodies { + thinkingBuf.WriteString(ev.Text) + } + case llm.SEMessageStart: + usage.Add(ev.Usage) case llm.SEMessageDelta: if ev.StopReason != "" { stopReason = ev.StopReason } - usage = ev.Usage - case llm.SEMessageStart: - if ev.Usage.InputTokens > 0 { - usage = ev.Usage - } + usage.Add(ev.Usage) } if !yield(ev, err) { return } } - // Stream completed normally — record it. - r.record(req, session, taskID, worker, reqBody, start, textBuf.String(), thinkingBuf.String(), usage, stopReason, streamErr) + // Stream completed normally. + finish(streamErr) + } +} + +// recordUsage appends one lightweight metering row to llm_usage (no bodies). Skips +// zero-token calls with no model, which carry nothing worth metering. +func (r *Recorder) recordUsage(taskID string, expID int64, worker string, usage llm.Usage, latencyMs int, status string) { + if r.pg == nil { + return + } + if r.model == "" && usage.InputTokens == 0 && usage.OutputTokens == 0 && + usage.CacheReadTokens == 0 && usage.CacheWriteTokens == 0 { + return + } + err := r.pg.InsertLLMUsage(&db.LLMUsage{ + TaskID: taskID, + ExplorationID: expID, + Worker: worker, + Model: r.model, + ProfileName: r.prof, + LatencyMs: latencyMs, + InputTokens: usage.InputTokens, + OutputTokens: usage.OutputTokens, + CacheRead: usage.CacheReadTokens, + CacheWrite: usage.CacheWriteTokens, + Status: status, + }) + if err != nil { + log.Printf("[llmusage] insert: %v", err) } } diff --git a/server/commands.go b/server/commands.go index bcb35e0..12904a7 100644 --- a/server/commands.go +++ b/server/commands.go @@ -4,6 +4,8 @@ import ( "net/http" "strconv" "strings" + + "github.com/Autumn-27/artex/db" ) // pgListCommands returns tool executions (any tool) from the activity table. @@ -45,6 +47,53 @@ func (s *Server) pgListLLMRecords(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"records": records, "total": total}) } +// pgTokenByModel returns a task's LLM token usage grouped by model, from the +// always-on llm_usage metering ledger. Accurate even with per-agent model bindings, +// pool rotation/failover, and interrupted runs (every call is metered on success or +// error). No recording toggle required. +func (s *Server) pgTokenByModel(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(r.URL.Query().Get("task")) + if id == "" { + writeErr(w, 400, "missing task") + return + } + pg := s.m.PG() + if pg == nil { + writeJSON(w, 200, map[string]any{"models": []db.ModelTokenStat{}}) + return + } + models, err := pg.TokenByModel(id) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"models": models}) +} + +// pgUsageStats returns global llm_usage aggregates for the dashboard's "new" token +// view: per-profile totals (all-time) + per-(profile, day) buckets for the daily +// chart. Sourced from the always-on metering ledger, so it is accurate across +// per-agent bindings, pool rotation, and interrupted runs. +func (s *Server) pgUsageStats(w http.ResponseWriter, r *http.Request) { + pg := s.m.PG() + if pg == nil { + writeJSON(w, 200, map[string]any{"by_profile": []db.ProfileUsage{}, "daily": []db.ProfileDayUsage{}}) + return + } + days := atoiDefault(r.URL.Query().Get("days"), 365) + byProfile, err := pg.UsageByProfile() + if err != nil { + writeErr(w, 500, err.Error()) + return + } + daily, err := pg.UsageDaily(days) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"by_profile": byProfile, "daily": daily}) +} + // pgLLMTasks returns distinct recorded tasks with counts, for the page's task // picker (pick a task → filter the list, then delete its conversations). func (s *Server) pgLLMTasks(w http.ResponseWriter, r *http.Request) { diff --git a/server/manager.go b/server/manager.go index 5f8dc36..7779088 100644 --- a/server/manager.go +++ b/server/manager.go @@ -250,6 +250,9 @@ func NewManager(dir, proxyAddr string) (*Manager, error) { if err := pg.EnsureLLMRecordsTable(); err != nil { log.Printf("[llmrec] create table: %v", err) } + if err := pg.EnsureLLMUsageTable(); err != nil { + log.Printf("[llmusage] create table: %v", err) + } m := &Manager{dir: dir, pg: pg, assets: pg.Assets(), tasks: map[string]*Task{}, interceptor: intercept.New(pg)} if proxyAddr != "" { tr, err := traffic.Open(filepath.Join(dir, "traffic"), proxyAddr) diff --git a/server/server.go b/server/server.go index ecaf168..1c462e0 100644 --- a/server/server.go +++ b/server/server.go @@ -690,6 +690,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/exploration/tokens", s.tokenStats) mux.HandleFunc("GET /api/tokens/daily", s.tokenDailyStats) mux.HandleFunc("GET /api/tokens/conversations", s.conversationTokens) + mux.HandleFunc("GET /api/tokens/usage", s.pgUsageStats) // 全局 llm_usage 聚合(仪表盘新版视图) mux.HandleFunc("GET /api/audit", s.getAudit) mux.HandleFunc("POST /api/gc", s.gc) @@ -702,6 +703,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/llm/records", s.pgListLLMRecords) mux.HandleFunc("DELETE /api/llm/records", s.pgDeleteLLMRecords) mux.HandleFunc("GET /api/llm/records/tasks", s.pgLLMTasks) + mux.HandleFunc("GET /api/llm/records/by-model", s.pgTokenByModel) // 按模型聚合本任务 token 用量 mux.HandleFunc("GET /api/llm/records/{id}", s.pgGetLLMRecord) mux.HandleFunc("GET /api/settings", s.getSettings) mux.HandleFunc("PUT /api/settings", s.putSettings) diff --git a/web/src/app/(main)/dashboard/page.tsx b/web/src/app/(main)/dashboard/page.tsx index 9aaab90..881c0b9 100644 --- a/web/src/app/(main)/dashboard/page.tsx +++ b/web/src/app/(main)/dashboard/page.tsx @@ -1,7 +1,9 @@ "use client"; import * as React from "react"; + import Link from "next/link"; + import { ActivityIcon, ArrowUpRightIcon, @@ -13,29 +15,18 @@ import { TargetIcon, ZapIcon, } from "lucide-react"; -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip as RechartsTooltip, -} from "recharts"; -import { - ChartContainer, - ChartTooltip, - ChartTooltipContent, - type ChartConfig, -} from "@/components/ui/chart"; -import { cn } from "@/lib/utils"; +import { Bar, BarChart, CartesianGrid, Tooltip as RechartsTooltip, XAxis, YAxis } from "recharts"; + +import { StatusBadge } from "@/components/status-badge"; import { Badge } from "@/components/ui/badge"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "@/components/ui/chart"; import { Progress } from "@/components/ui/progress"; -import { StatusBadge } from "@/components/status-badge"; import { api } from "@/lib/api"; import type { Activity, Agent, + ConvTokenSummary, Finding, InterceptPending, LLMProfile, @@ -47,14 +38,15 @@ import type { TokenTotal, Tool, TrafficExchange, - ConvTokenSummary, + UsageStats, } from "@/lib/types"; +import { cn } from "@/lib/utils"; // ── chart constants ─────────────────────────────────────────────────────────── const dailyTrendConfig = { - input: { label: "输入", color: "hsl(217 91% 60%)" }, - output: { label: "输出", color: "hsl(263 70% 60%)" }, + input: { label: "输入", color: "hsl(217 91% 60%)" }, + output: { label: "输出", color: "hsl(263 70% 60%)" }, cacheRead: { label: "缓存读", color: "hsl(160 60% 45%)" }, } satisfies ChartConfig; @@ -62,9 +54,7 @@ const dailyTrendConfig = { function fmtRel(ts?: string | number): string { if (!ts) return "—"; - const ms = - Date.now() - - (typeof ts === "number" ? ts * 1000 : Date.parse(ts as string)); + const ms = Date.now() - (typeof ts === "number" ? ts * 1000 : Date.parse(ts as string)); const s = Math.floor(ms / 1000); if (s < 60) return `${s}s 前`; const m = Math.floor(s / 60); @@ -115,14 +105,7 @@ function statusBg(code: number): string { // ── sub-components ──────────────────────────────────────────────────────────── function LiveDot({ className }: { className?: string }) { - return ( - - ); + return ; } function SectionTitle({ @@ -157,6 +140,7 @@ export default function DashboardPage() { const [activity, setActivity] = React.useState([]); const [tokens, setTokens] = React.useState(null); const [convTokens, setConvTokens] = React.useState([]); + const [usageStats, setUsageStats] = React.useState(null); const [assetCounts, setAssetCounts] = React.useState>({}); const [traffic, setTraffic] = React.useState([]); const [agents, setAgents] = React.useState([]); @@ -195,7 +179,10 @@ export default function DashboardPage() { }; load(); const t = setInterval(load, 5000); - return () => { alive = false; clearInterval(t); }; + return () => { + alive = false; + clearInterval(t); + }; }, []); // slow poll: traffic, assets, system-static (every 15s) @@ -203,16 +190,16 @@ export default function DashboardPage() { let alive = true; const load = async () => { try { - const [traf, counts, agentList, mcpList, skillList, toolList, profileList] = - await Promise.all([ - api.traffic(0, 50), - api.assetCounts(), - api.agents(), - api.mcpServers(), - api.skills(), - api.tools(), - api.llmProfiles(), - ]); + const [traf, counts, agentList, mcpList, skillList, toolList, profileList, usage] = await Promise.all([ + api.traffic(0, 50), + api.assetCounts(), + api.agents(), + api.mcpServers(), + api.skills(), + api.tools(), + api.llmProfiles(), + api.usageStats(365), + ]); if (!alive) return; setTraffic(traf.exchanges ?? []); setAssetCounts(counts ?? {}); @@ -221,13 +208,17 @@ export default function DashboardPage() { setSkills(skillList); setTools(toolList); setLLMProfiles(profileList); + setUsageStats(usage); } catch { /* transient errors */ } }; load(); const t = setInterval(load, 15000); - return () => { alive = false; clearInterval(t); }; + return () => { + alive = false; + clearInterval(t); + }; }, []); // ── derived ─────────────────────────────────────────────────────────────── @@ -249,20 +240,13 @@ export default function DashboardPage() { const sortedTasks = React.useMemo( () => [...tasks] - .sort( - (a, b) => - (b.last_activity_unix ?? b.created_unix ?? 0) - - (a.last_activity_unix ?? a.created_unix ?? 0), - ) + .sort((a, b) => (b.last_activity_unix ?? b.created_unix ?? 0) - (a.last_activity_unix ?? a.created_unix ?? 0)) .slice(0, 5), [tasks], ); const recentFindings = React.useMemo( - () => - [...findings] - .sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts)) - .slice(0, 8), + () => [...findings].sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts)).slice(0, 8), [findings], ); @@ -275,10 +259,7 @@ export default function DashboardPage() { [activity], ); - const totalAssets = React.useMemo( - () => Object.values(assetCounts).reduce((a, b) => a + b, 0), - [assetCounts], - ); + const totalAssets = React.useMemo(() => Object.values(assetCounts).reduce((a, b) => a + b, 0), [assetCounts]); // asset type breakdown const assetByType = React.useMemo(() => { @@ -303,9 +284,7 @@ export default function DashboardPage() { }, [traffic]); const trafficMax = Math.max(...trafficByCodes.map((x) => x.n), 1); - const recentTraffic = [...traffic] - .sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts)) - .slice(0, 5); + const recentTraffic = [...traffic].sort((a, b) => Date.parse(b.ts) - Date.parse(a.ts)).slice(0, 5); // system const activeProfile = llmProfiles.find((p) => p.is_default); @@ -316,100 +295,147 @@ export default function DashboardPage() { // tasks whose llm_profile_id is null/undefined used the active default profile const defaultProfileId = activeProfile ? Number(activeProfile.id) : null; - const tokenByProfile = React.useMemo< - Map - >(() => { - const m = new Map(); - for (const t of tasks) { - // null means "used whatever was active" → bucket under defaultProfileId - const key = t.llm_profile_id != null ? t.llm_profile_id : defaultProfileId; - const prev = m.get(key) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, taskCount: 0 }; - m.set(key, { - input: prev.input + (t.tokens?.input_tokens ?? 0), - output: prev.output + (t.tokens?.output_tokens ?? 0), - cacheRead: prev.cacheRead + (t.tokens?.cache_read_tokens ?? 0), - cacheWrite: prev.cacheWrite + (t.tokens?.cache_write_tokens ?? 0), - taskCount: prev.taskCount + 1, - }); - } - // merge conversation (chat) usage into the same per-profile buckets — token sums - // only; taskCount stays a task count. - for (const c of convTokens) { - const key = c.llm_profile_id != null ? c.llm_profile_id : defaultProfileId; - const prev = m.get(key) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, taskCount: 0 }; - m.set(key, { - input: prev.input + c.input_tokens, - output: prev.output + c.output_tokens, - cacheRead: prev.cacheRead + c.cache_read_tokens, - cacheWrite: prev.cacheWrite + c.cache_write_tokens, - taskCount: prev.taskCount, - }); - } - return m; - }, [tasks, convTokens, defaultProfileId]); - + // 数据源开关:旧版 = activity(task.tokens + 会话),新版 = llm_usage 计量账本。 + const [tokenVersion, setTokenVersion] = React.useState<"old" | "new">("old"); // selected profile tab: "all" = 全部; number = specific profile id const [tokenTab, setTokenTab] = React.useState("all"); // day range for the daily bar chart const [tokenDays, setTokenDays] = React.useState<7 | 30 | 90 | 180 | 365>(30); + // profile 名 → id,用于把 llm_usage 的 profile_name 映射到现有 profile 分栏。 + const profileIdByName = React.useMemo(() => { + const m = new Map(); + for (const p of llmProfiles) m.set(p.name, Number(p.id)); + return m; + }, [llmProfiles]); + + type Bucket = { input: number; output: number; cacheRead: number; cacheWrite: number; taskCount: number }; + + // 旧版:按 profile 归桶(来自 activity 的 task.tokens + 会话用量)。 + const tokenByProfileOld = React.useMemo>(() => { + const m = new Map(); + const fold = (key: number | null, inp: number, out: number, cr: number, cw: number, addTask: boolean) => { + const prev = m.get(key) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, taskCount: 0 }; + m.set(key, { + input: prev.input + inp, + output: prev.output + out, + cacheRead: prev.cacheRead + cr, + cacheWrite: prev.cacheWrite + cw, + taskCount: prev.taskCount + (addTask ? 1 : 0), + }); + }; + for (const t of tasks) { + fold( + t.llm_profile_id ?? defaultProfileId, + t.tokens?.input_tokens ?? 0, + t.tokens?.output_tokens ?? 0, + t.tokens?.cache_read_tokens ?? 0, + t.tokens?.cache_write_tokens ?? 0, + true, + ); + } + for (const c of convTokens) { + fold( + c.llm_profile_id ?? defaultProfileId, + c.input_tokens, + c.output_tokens, + c.cache_read_tokens, + c.cache_write_tokens, + false, + ); + } + return m; + }, [tasks, convTokens, defaultProfileId]); + + // 新版:按 profile 归桶(来自 llm_usage 全局聚合,逐次调用精确)。 + const tokenByProfileNew = React.useMemo>(() => { + const m = new Map(); + for (const p of usageStats?.by_profile ?? []) { + // 未匹配到现有 profile(改名/删除/空名)→ 落到默认桶,仍计入「全部」。 + const key = profileIdByName.get(p.profile_name) ?? defaultProfileId; + const prev = m.get(key) ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, taskCount: 0 }; + m.set(key, { + input: prev.input + p.input_tokens, + output: prev.output + p.output_tokens, + cacheRead: prev.cacheRead + p.cache_read_tokens, + cacheWrite: prev.cacheWrite + p.cache_write_tokens, + taskCount: prev.taskCount + p.tasks, + }); + } + return m; + }, [usageStats, profileIdByName, defaultProfileId]); + + const tokenByProfile = tokenVersion === "new" ? tokenByProfileNew : tokenByProfileOld; + const displayedTokens = React.useMemo(() => { if (tokenTab === "all") { - // sum across all profiles - let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, taskCount = 0; + let input = 0, + output = 0, + cacheRead = 0, + cacheWrite = 0, + taskCount = 0; for (const v of tokenByProfile.values()) { - input += v.input; output += v.output; - cacheRead += v.cacheRead; cacheWrite += v.cacheWrite; + input += v.input; + output += v.output; + cacheRead += v.cacheRead; + cacheWrite += v.cacheWrite; taskCount += v.taskCount; } return { input, output, cacheRead, cacheWrite, taskCount }; } const v = tokenByProfile.get(tokenTab as number | null); - return v ? { input: v.input, output: v.output, cacheRead: v.cacheRead, cacheWrite: v.cacheWrite, taskCount: v.taskCount } - : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, taskCount: 0 }; + return v + ? { input: v.input, output: v.output, cacheRead: v.cacheRead, cacheWrite: v.cacheWrite, taskCount: v.taskCount } + : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, taskCount: 0 }; }, [tokenTab, tokenByProfile]); - // daily token data: tasks grouped by date, filtered by day range and selected profile - const dailyTokenData = React.useMemo(() => { + // 旧版每日:把任务/会话的总量按其创建日期归桶(近似,非真实每日消耗)。 + const dailyTokenDataOld = React.useMemo(() => { const now = new Date(); now.setDate(now.getDate() - tokenDays); const cutoff = now.toISOString().slice(0, 10); - const m = new Map(); + const fold = (date: string, inp: number, out: number, cr: number) => { + if (date < cutoff) return; + const prev = m.get(date) ?? { input: 0, output: 0, cacheRead: 0 }; + m.set(date, { input: prev.input + inp, output: prev.output + out, cacheRead: prev.cacheRead + cr }); + }; for (const t of tasks) { - // filter by profile tab when not "all" - if (tokenTab !== "all") { - const taskKey = t.llm_profile_id ?? defaultProfileId; - if (taskKey !== tokenTab) continue; - } - const date = t.created_at.slice(0, 10); - if (date < cutoff) continue; - const prev = m.get(date) ?? { input: 0, output: 0, cacheRead: 0 }; - m.set(date, { - input: prev.input + (t.tokens?.input_tokens ?? 0), - output: prev.output + (t.tokens?.output_tokens ?? 0), - cacheRead: prev.cacheRead + (t.tokens?.cache_read_tokens ?? 0), - }); + if (tokenTab !== "all" && (t.llm_profile_id ?? defaultProfileId) !== tokenTab) continue; + fold( + t.created_at.slice(0, 10), + t.tokens?.input_tokens ?? 0, + t.tokens?.output_tokens ?? 0, + t.tokens?.cache_read_tokens ?? 0, + ); } - // merge conversation usage by conversation created_at date, same profile filter. for (const c of convTokens) { - if (tokenTab !== "all") { - const convKey = c.llm_profile_id ?? defaultProfileId; - if (convKey !== tokenTab) continue; - } - const date = c.created_at.slice(0, 10); - if (date < cutoff) continue; - const prev = m.get(date) ?? { input: 0, output: 0, cacheRead: 0 }; - m.set(date, { - input: prev.input + c.input_tokens, - output: prev.output + c.output_tokens, - cacheRead: prev.cacheRead + c.cache_read_tokens, + if (tokenTab !== "all" && (c.llm_profile_id ?? defaultProfileId) !== tokenTab) continue; + fold(c.created_at.slice(0, 10), c.input_tokens, c.output_tokens, c.cache_read_tokens); + } + return [...m.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([date, v]) => ({ date: date.slice(5), ...v })); + }, [tasks, convTokens, tokenDays, tokenTab, defaultProfileId]); + + // 新版每日:来自 llm_usage 的真实每日消耗(ts 是实际调用时刻)。 + const dailyTokenDataNew = React.useMemo(() => { + const now = new Date(); + now.setDate(now.getDate() - tokenDays); + const cutoff = now.toISOString().slice(0, 10); + const m = new Map(); + for (const d of usageStats?.daily ?? []) { + if (d.date < cutoff) continue; + if (tokenTab !== "all" && (profileIdByName.get(d.profile_name) ?? defaultProfileId) !== tokenTab) continue; + const prev = m.get(d.date) ?? { input: 0, output: 0, cacheRead: 0 }; + m.set(d.date, { + input: prev.input + d.input_tokens, + output: prev.output + d.output_tokens, + cacheRead: prev.cacheRead + d.cache_read_tokens, }); } - return [...m.entries()] - .sort(([a], [b]) => a.localeCompare(b)) - .map(([date, v]) => ({ date: date.slice(5), ...v })); - }, [tasks, convTokens, tokenDays, tokenTab, defaultProfileId]); + return [...m.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([date, v]) => ({ date: date.slice(5), ...v })); + }, [usageStats, tokenDays, tokenTab, profileIdByName, defaultProfileId]); + + const dailyTokenData = tokenVersion === "new" ? dailyTokenDataNew : dailyTokenDataOld; // activity kind label function kindLabel(a: Activity): string { @@ -434,7 +460,6 @@ export default function DashboardPage() { return (
- {/* ── Header ── */}
@@ -451,7 +476,6 @@ export default function DashboardPage() { {/* ── Row 1: 5 stat cards ── */}
- {/* 活跃任务 */} @@ -459,25 +483,15 @@ export default function DashboardPage() { 活跃任务
- - {tasksByStatus.running ?? 0} - + {tasksByStatus.running ?? 0} / {tasks.length}
- {(tasksByStatus.running ?? 0) > 0 && ( - 探索 {tasksByStatus.running} - )} - {(tasksByStatus.paused ?? 0) > 0 && ( - 暂停 {tasksByStatus.paused} - )} - {(tasksByStatus.done ?? 0) > 0 && ( - 完成 {tasksByStatus.done} - )} - {tasks.length === 0 && ( - 暂无任务 - )} + {(tasksByStatus.running ?? 0) > 0 && 探索 {tasksByStatus.running}} + {(tasksByStatus.paused ?? 0) > 0 && 暂停 {tasksByStatus.paused}} + {(tasksByStatus.done ?? 0) > 0 && 完成 {tasksByStatus.done}} + {tasks.length === 0 && 暂无任务} @@ -487,9 +501,7 @@ export default function DashboardPage() {
确认发现
-
- {findings.length} -
+
{findings.length}
严重 {findingsBySev.critical} @@ -505,13 +517,9 @@ export default function DashboardPage() {
资产节点
-
- {totalAssets} -
+
{totalAssets}
- - 跨任务共享 - + 跨任务共享 {/* 流量交互 */} @@ -520,9 +528,7 @@ export default function DashboardPage() {
流量交互
-
- {traffic.length} -
+
{traffic.length}
{settings?.traffic_capture ? ( @@ -547,7 +553,8 @@ export default function DashboardPage() {
- 入 {fmtTokens(displayedTokens.input)}(含缓存 {fmtTokens(displayedTokens.cacheRead)})· 出 {fmtTokens(displayedTokens.output)} + 入 {fmtTokens(displayedTokens.input)}(含缓存 {fmtTokens(displayedTokens.cacheRead)})· 出{" "} + {fmtTokens(displayedTokens.output)}
@@ -559,8 +566,35 @@ export default function DashboardPage() {
LLM Token 消耗 - - {tasks.length} 个任务累计 + {/* 数据源开关:旧版=activity 统计(含历史任务),新版=llm_usage 计量账本(更准,仅覆盖启用后) */} +
+ {( + [ + { v: "old", label: "旧版" }, + { v: "new", label: "新版" }, + ] as const + ).map(({ v, label }) => ( + + ))} +
+ + {tokenVersion === "new" ? "llm_usage" : "activity"}
{/* Profile tabs */} @@ -579,7 +613,7 @@ export default function DashboardPage() { {llmProfiles.map((p) => { const key = Number(p.id); const hasData = tokenByProfile.has(key) || (p.is_default && tokenByProfile.has(defaultProfileId)); - const resolvedKey = tokenByProfile.has(key) ? key : (p.is_default ? defaultProfileId : key); + const resolvedKey = tokenByProfile.has(key) ? key : p.is_default ? defaultProfileId : key; return (
{dailyTokenData.length === 0 ? ( -
+
暂无数据
) : ( - + (
- {(dailyTrendConfig as Record)[String(name)]?.label ?? String(name)} - - {fmtTokens(Number(value))} + + {(dailyTrendConfig as Record)[String(name)]?.label ?? + String(name)} + {fmtTokens(Number(value))}
)} /> } /> - - + +
@@ -765,7 +815,6 @@ export default function DashboardPage() { {/* ── Row 3: 活动流 | 发现 ── */}
- {/* 活动流 */}
@@ -787,27 +836,20 @@ export default function DashboardPage() {
- {(["tool_use", "tool_result", "text", "thinking", "result"] as const).map( - (kind) => { - const count = activity.filter((a) => a.kind === kind).length; - if (count === 0) return null; - return ( - - {kind} {count} - - ); - }, - )} + {(["tool_use", "tool_result", "text", "thinking", "result"] as const).map((kind) => { + const count = activity.filter((a) => a.kind === kind).length; + if (count === 0) return null; + return ( + + {kind} {count} + + ); + })}
{recentActivity.length === 0 ? ( -
- 暂无活动记录 -
+
暂无活动记录
) : ( recentActivity.map((a) => (
@@ -821,20 +863,13 @@ export default function DashboardPage() {
{kindLabel(a)}
-
- {a.summary} -
+
{a.summary}
- + {a.kind} -
- {fmtRel(a.ts)} -
+
{fmtRel(a.ts)}
)) @@ -859,9 +894,7 @@ export default function DashboardPage() {
{recentFindings.length === 0 ? ( -
- 暂无发现 -
+
暂无发现
) : ( recentFindings.map((f) => (
@@ -874,18 +907,14 @@ export default function DashboardPage() {
{f.vulnclass} {f.task_description && ( - - {f.task_description} - + {f.task_description} )}
{f.summary}
- - {fmtRel(f.ts)} - + {fmtRel(f.ts)}
)) )} @@ -901,9 +930,7 @@ export default function DashboardPage() { 任务
- - {tasks.length} 个任务 - + {tasks.length} 个任务 - {["任务", "状态", "引擎", "目标进度", "在途", "最近活动"].map( - (h) => ( - - {h} - - ), - )} + {["任务", "状态", "引擎", "目标进度", "在途", "最近活动"].map((h) => ( + + {h} + + ))} {sortedTasks.length === 0 ? ( - + 暂无任务 ) : ( sortedTasks.map((t) => { - const goalsPct = t.goals_total - ? Math.round(((t.goals_met ?? 0) / t.goals_total) * 100) - : null; + const goalsPct = t.goals_total ? Math.round(((t.goals_met ?? 0) / t.goals_total) * 100) : null; return ( - + - - - {t.description} - - - {t.id} - + + {t.description} + {t.id} @@ -983,7 +993,7 @@ export default function DashboardPage() { )} - {t.in_flight ?? 0 > 0 ? ( + {(t.in_flight ?? 0 > 0) ? ( {t.in_flight} ) : ( — @@ -1002,7 +1012,6 @@ export default function DashboardPage() { {/* ── Row 5: 资产分布 | 流量状态码 | 拦截 & 待审批 ── */}
- {/* 资产分布 */} @@ -1010,9 +1019,7 @@ export default function DashboardPage() { {assetByType.length === 0 ? ( -
- 暂无资产数据 -
+
暂无资产数据
) : (
{assetByType.map(([type, count]) => ( @@ -1032,9 +1039,7 @@ export default function DashboardPage() {
)} -
- 共 {totalAssets} 节点 -
+
共 {totalAssets} 节点
{/* 流量状态码 */} @@ -1045,36 +1050,24 @@ export default function DashboardPage() { {/* bar chart */} {trafficByCodes.length === 0 ? ( -
- 暂无流量数据 -
+
暂无流量数据
) : ( <>
{trafficByCodes.map(({ code, n }) => (
- - {n} - + {n}
- - {code} - + {code}
))}
-
- 最近请求 -
+
最近请求
{recentTraffic.map((e) => (
@@ -1092,14 +1085,7 @@ export default function DashboardPage() { {e.host} {e.url.replace(/^https?:\/\/[^/]+/, "").substring(0, 30)} - - {e.status} - + {e.status}
))}
@@ -1154,9 +1140,7 @@ export default function DashboardPage() { {/* pending approvals */} {pendingCount > 0 && (
-
- 待审批 ({pendingCount}) -
+
待审批 ({pendingCount})
{pending.slice(0, 3).map((p) => (
{p.tool_name}
-
- {p.agent_name} -
+
{p.agent_name}
diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx index 49722b7..3c06a2b 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/overview-tab.tsx @@ -7,6 +7,7 @@ import { AlertTriangleIcon, BugIcon, ClockIcon, + CoinsIcon, PlusIcon, RefreshCwIcon, ShieldCheckIcon, @@ -21,7 +22,20 @@ import { Input } from "@/components/ui/input"; import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select"; import { Progress } from "@/components/ui/progress"; import { api } from "@/lib/api"; -import type { Finding, Stats, Task, TaskNode, TaskScopeRow } from "@/lib/types"; +import type { Finding, ModelTokenStat, Stats, Task, TaskNode, TaskScopeRow } from "@/lib/types"; + +// 紧凑格式化 token 数(12345 → 12.3k,2000000 → 2M)。 +function fmtTokens(n: number): string { + if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M"; + if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k"; + return String(n); +} + +// 缓存命中率 = 缓存读 / 输入(InputTokens 已含 cache_read 子集,故比值在 0–100%)。 +function cacheHitRate(cacheRead: number, input: number): string { + if (input <= 0) return "—"; + return Math.round((cacheRead / input) * 100) + "%"; +} // 测试范围一条的显示值:域名 / 网段 / 公司。 function scopeValue(row: TaskScopeRow): string { @@ -89,6 +103,17 @@ export function OverviewTab({ taskId }: { taskId: string }) { const [scopeValueInput, setScopeValueInput] = React.useState(""); const [scopeBusy, setScopeBusy] = React.useState(false); const [scopeErr, setScopeErr] = React.useState(""); + // 按模型的 token 用量(来自常开的 llm_usage 计量账本,逐次精确)。 + const [modelTokens, setModelTokens] = React.useState([]); + + const loadTokens = React.useCallback(async () => { + try { + const resp = await api.tokensByModel(taskId); + setModelTokens(resp.models); + } catch { + // 忽略:无 PG 时接口报错,卡片自然为空 + } + }, [taskId]); const loadScope = React.useCallback(async () => { try { @@ -189,18 +214,34 @@ export function OverviewTab({ taskId }: { taskId: string }) { load(); void loadScope(); - const timer = setInterval(load, 3000); + void loadTokens(); + const timer = setInterval(() => { + void load(); + void loadTokens(); + }, 3000); return () => { cancelled = true; clearInterval(timer); }; - }, [taskId, loadScope]); + }, [taskId, loadScope, loadTokens]); const running = intents.filter((i) => i.state === "running"); const open = intents.filter((i) => i.state === "open"); const blocked = intents.filter((i) => i.state === "blocked"); const taskFindings = findings.filter((f) => f.task_id === taskId); const goalsPct = task?.goals_total ? Math.round(((task.goals_met ?? 0) / task.goals_total) * 100) : 0; + // token 合计(跨全部模型),用于卡片头部总览。 + const tokenTotals = modelTokens.reduce( + (acc, m) => { + acc.input += m.input_tokens; + acc.output += m.output_tokens; + acc.cacheRead += m.cache_read_tokens; + acc.cacheWrite += m.cache_write_tokens; + acc.calls += m.calls; + return acc; + }, + { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, calls: 0 }, + ); return (
@@ -255,6 +296,77 @@ export function OverviewTab({ taskId }: { taskId: string }) { )} + {/* LLM Token 用量:按模型分组,数据来自 llm_records(需开启 LLM 录制)。 */} + + + + LLM Token 用量 + + (按模型统计{tokenTotals.calls > 0 ? `,共 ${tokenTotals.calls} 次调用` : ""}) + + + + + {modelTokens.length > 0 ? ( + <> + {/* 合计总览 */} +
+ + 输入 + {fmtTokens(tokenTotals.input)} + + + 输出 + {fmtTokens(tokenTotals.output)} + + + 缓存读 + {fmtTokens(tokenTotals.cacheRead)} + + + 缓存命中率 + + {cacheHitRate(tokenTotals.cacheRead, tokenTotals.input)} + + +
+ {/* 按模型明细表 */} +
+ + + + + + + + + + + + + {modelTokens.map((m) => ( + + + + + + + + + ))} + +
模型调用输入输出缓存读命中率
+ {m.model} + {m.calls}{fmtTokens(m.input_tokens)}{fmtTokens(m.output_tokens)}{fmtTokens(m.cache_read_tokens)} + {cacheHitRate(m.cache_read_tokens, m.input_tokens)} +
+
+ + ) : ( +

暂无 LLM 用量(任务尚未产生调用,或记录仍在写入)。

+ )} +
+
{/* 测试范围:覆盖度分母 + 授权边界,可手动增删。 */} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 63595d4..29e7455 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -39,6 +39,7 @@ import type { LLMTask, MCPServer, MCPTool, + ModelTokenStat, PromptVar, PromptVersion, Settings, @@ -57,6 +58,7 @@ import type { TrafficDetail, TrafficHost, TrafficResp, + UsageStats, WorkspaceFile, WorkspaceListing, } from "@/lib/types"; @@ -228,6 +230,8 @@ export const api = { }>(`/tasks/${id}/coverage`), // 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。 taskCoverageGraph: (id: string) => get(`/tasks/${id}/coverage-graph`), + // 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。 + usageStats: (days = 365) => get(`/tokens/usage?days=${days}`), // 本任务测试范围列表(含继承自来源任务的范围)。 taskScope: (id: string) => get<{ scope: TaskScopeRow[] }>(`/tasks/${id}/scope`), // 手动新增一条测试范围(kind=company/root_domain/subdomain/ip/cidr)。 @@ -812,4 +816,8 @@ export const api = { llmRecordDetail: (id: number) => get(`/llm/records/${id}`), llmTasks: () => get<{ tasks: LLMTask[] }>(`/llm/records/tasks`), llmRecordsDeleteTask: (task: string) => del<{ deleted: number }>(`/llm/records?task=${encodeURIComponent(task)}`), + // 按模型聚合本任务的 token 用量(来自常开的 llm_usage 计量账本,逐次调用精确, + // per-agent 绑定 / 轮询 / 中断消耗都覆盖)。 + tokensByModel: (task: string) => + get<{ models: ModelTokenStat[] }>(`/llm/records/by-model?task=${encodeURIComponent(task)}`), }; diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index bd926e5..655b848 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -155,6 +155,68 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re return { id: Date.now(), task_id: Number(seg[1]), kind: b.kind, domain: b.value, source: "manual" }; if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 4 && m === "DELETE") return { ok: true }; + // ── 全局 llm_usage 聚合(仪表盘新版视图,demo)── + if (path === "/tokens/usage") + return { + by_profile: [ + { + profile_name: "default", + calls: 60, + tasks: 4, + input_tokens: 1570000, + output_tokens: 110000, + cache_read_tokens: 1120000, + cache_write_tokens: 140000, + }, + ], + daily: [ + { + profile_name: "default", + date: "2026-08-18", + input_tokens: 520000, + output_tokens: 38000, + cache_read_tokens: 370000, + }, + { + profile_name: "default", + date: "2026-08-19", + input_tokens: 640000, + output_tokens: 45000, + cache_read_tokens: 460000, + }, + { + profile_name: "default", + date: "2026-08-20", + input_tokens: 410000, + output_tokens: 27000, + cache_read_tokens: 290000, + }, + ], + }; + + // ── 按模型 token 用量(demo:一条示例)── + if (path === "/llm/records/by-model") + return { + models: [ + { + model: "claude-opus-4-6", + calls: 42, + input_tokens: 1250000, + output_tokens: 86000, + cache_read_tokens: 940000, + cache_write_tokens: 120000, + }, + { + model: "claude-haiku-4-5", + calls: 18, + input_tokens: 320000, + output_tokens: 24000, + cache_read_tokens: 180000, + cache_write_tokens: 20000, + }, + ], + }; + // ── 工作空间文件管理器(demo:静态示例树;写/建/删走下方写兜底 {ok:true})── if (path === "/workspace/list") return D.workspaceList(q.get("path") ?? ""); if (path === "/workspace/read") return D.workspaceRead(q.get("path") ?? ""); @@ -206,18 +268,12 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re }; } // 单条 finding:GET 详情 / PATCH 改状态/严重度/名称/类别(demo 直接改内存对象)。 - if ( - seg[0] === "exploration" && - seg[1] === "findings" && - seg.length === 3 && - seg[2] !== "stats" - ) { + if (seg[0] === "exploration" && seg[1] === "findings" && seg.length === 3 && seg[2] !== "stats") { const f = mockFindings.find((x) => x.id === seg[2]); if (!f) return {}; if (m === "PATCH") { if (typeof b.status === "string") f.status = b.status as typeof f.status; - if (typeof b.severity === "string") - f.severity = b.severity as typeof f.severity; + if (typeof b.severity === "string") f.severity = b.severity as typeof f.severity; if (typeof b.name === "string") f.name = b.name; if (typeof b.vulnclass === "string") f.vulnclass = b.vulnclass; } @@ -294,7 +350,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (path === "/exploration/activity" && seg.length === 2) { const since = Number(q.get("since") ?? 0); const limit = Math.max(1, Number(q.get("limit") ?? 300)); - const items = D.activityForTask().filter((item) => item.seq > since).slice(0, limit); + const items = D.activityForTask() + .filter((item) => item.seq > since) + .slice(0, limit); return { items, cursor: items.length ? items[items.length - 1].seq : since }; } if (seg[0] === "exploration" && seg[1] === "activity" && seg.length === 3) { @@ -315,7 +373,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (path === "/settings" && m === "PUT") return { ...D.settings, ...b }; if (path === "/settings/web-search/test") return { ok: true, count: 5, backend: D.settings.web_search_backend }; if (path === "/settings/python/detect") return { python_interpreter: "/usr/bin/python3" }; - if (path === "/chat") return { reply: "(demo)我已把该建议注入为一条高优意图,work agent 会尽快执行。", mode: "hint" }; + if (path === "/chat") + return { reply: "(demo)我已把该建议注入为一条高优意图,work agent 会尽快执行。", mode: "hint" }; if (path === "/gc") return { removed: 0 }; // ── 工具执行历史 ── @@ -343,11 +402,20 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (path === "/llm/pool" && m === "GET") return D.llmPool; if (path === "/llm/pool/reset") return { ...D.llmPool, chain: D.llmPool.chain.map((c) => ({ ...c, state: "ok", fails: 0, cooldown_secs: 0 })) }; - if (seg[0] === "llm" && seg[1] === "profiles" && seg.length === 3 && m === "DELETE") return { deleted: Number(seg[2]) }; + if (seg[0] === "llm" && seg[1] === "profiles" && seg.length === 3 && m === "DELETE") + return { deleted: Number(seg[2]) }; // ── agents ── if (path === "/agents" && m === "GET") return { agents: D.agents }; - if (path === "/agents" && m === "POST") return { id: "9", key: String(b.key ?? "custom"), name: String(b.name ?? ""), role: "custom", builtin: false, enabled: true }; + if (path === "/agents" && m === "POST") + return { + id: "9", + key: String(b.key ?? "custom"), + name: String(b.name ?? ""), + role: "custom", + builtin: false, + enabled: true, + }; if (seg[0] === "agents" && seg.length === 2 && m === "GET") return D.agentDetail(seg[1]); if (seg[0] === "agents" && seg[2] === "triggers" && m === "GET") return { triggers: [] }; if (seg[0] === "agents" && seg[2] === "prompts") return { versions: D.agentDetail(seg[1]).versions }; @@ -358,7 +426,14 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re // ── conversations ── if (path === "/conversations" && m === "GET") return { conversations: D.conversations }; - if (path === "/conversations" && m === "POST") return { id: 3, agent_key: String(b.agent_key ?? "mainagent"), title: String(b.title ?? "新会话"), created_at: "2026-07-26T04:00:00Z", updated_at: "2026-07-26T04:00:00Z" }; + if (path === "/conversations" && m === "POST") + return { + id: 3, + agent_key: String(b.agent_key ?? "mainagent"), + title: String(b.title ?? "新会话"), + created_at: "2026-07-26T04:00:00Z", + updated_at: "2026-07-26T04:00:00Z", + }; 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 }; @@ -384,7 +459,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (seg[0] === "mcp" && seg.length === 2 && m === "DELETE") return { deleted: Number(seg[1]) }; // ── scopesentry(demo:未配置)── - if (path === "/sync/scopesentry/status") return { exists: false, configured: false, enabled: false, reachable: false, tools: [] }; + if (path === "/sync/scopesentry/status") + return { exists: false, configured: false, enabled: false, reachable: false, tools: [] }; if (path === "/sync/scopesentry/projects") return { projects: [], tag: {} }; if (path === "/sync/scopesentry/tasks") return { tasks: [] }; if (path === "/sync/scopesentry/sync") return { synced: {}, companies: null, warnings: null, errors: null }; @@ -401,7 +477,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re // ── intercept ── if (path === "/intercept/rules" && m === "GET") return { rules: D.interceptRules }; - if (seg[0] === "intercept" && seg[1] === "rules" && seg[3] === "toggle") return { ok: true, enabled: b.enabled ?? true }; + if (seg[0] === "intercept" && seg[1] === "rules" && seg[3] === "toggle") + return { ok: true, enabled: b.enabled ?? true }; if (path === "/intercept/pending" && m === "GET") return { pending: D.interceptPending }; if (seg[0] === "intercept" && seg[1] === "pending" && seg[3] === "decide") return { ok: true }; if (seg[0] === "intercept" && seg[1] === "pending" && seg.length === 3 && m === "GET") @@ -415,5 +492,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re if (["POST", "PUT", "PATCH", "DELETE"].includes(m)) return { ok: true }; // ── 读兜底:集合类给 [],其余 {} ── - return /(\/(tasks|profiles|conversations|rules|history|projects|tokens|agents|servers|skills|tools|findings|intents)s?$)|s$/.test(path) ? [] : {}; + return /(\/(tasks|profiles|conversations|rules|history|projects|tokens|agents|servers|skills|tools|findings|intents)s?$)|s$/.test( + path, + ) + ? [] + : {}; } diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index cc5edf7..995e893 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -242,9 +242,9 @@ export interface ScopeRow { id: number; company_id: number; kind: "domain" | "ip" | "cidr"; - domain?: string; // kind=domain 时有值 - net?: string; // kind=ip|cidr 时有值 - raw: string; // 原始用户输入,用于显示和回填 + domain?: string; // kind=domain 时有值 + net?: string; // kind=ip|cidr 时有值 + raw: string; // 原始用户输入,用于显示和回填 reason?: string; } @@ -365,11 +365,11 @@ export type ActivityKind = | "thinking" | "result" | "user" - | "intent" // LLM-generated exploration objective leading a worker session (UI-synthesized) - | "round" // planner round boundary marker (engine-emitted) - | "usage" // live cumulative token usage (per model turn); not rendered - | "llm_switch" // automatic/manual task-level LLM switch - | "llm_failover" // task-level provider switch / chain exhaustion audit event + | "intent" // LLM-generated exploration objective leading a worker session (UI-synthesized) + | "round" // planner round boundary marker (engine-emitted) + | "usage" // live cumulative token usage (per model turn); not rendered + | "llm_switch" // automatic/manual task-level LLM switch + | "llm_failover" // task-level provider switch / chain exhaustion audit event | "intercept_request"; // user-approval request from the intercept layer // ChatAttachment 是一次上传的文件:path 相对该会话/任务工作目录(即 agent 的 CWD)。 @@ -487,6 +487,43 @@ export interface TokenTotal { cache_write_tokens: number; } +// Global per-profile token spend from the llm_usage ledger (GET /api/tokens/usage). +export interface ProfileUsage { + profile_name: string; + calls: number; + tasks: number; + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; +} + +// One (profile, UTC day) token bucket for the dashboard's daily chart (new source). +export interface ProfileDayUsage { + profile_name: string; + date: string; // YYYY-MM-DD + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; +} + +// Response of GET /api/tokens/usage — the dashboard's "new" (llm_usage) token view. +export interface UsageStats { + by_profile: ProfileUsage[]; + daily: ProfileDayUsage[]; +} + +// Per-model token usage for one task (GET /api/llm/records/by-model), from the +// always-on llm_usage metering ledger. calls = number of LLM calls on this model. +export interface ModelTokenStat { + model: string; + calls: number; + input_tokens: number; + output_tokens: number; + cache_read_tokens: number; + cache_write_tokens: number; +} + export interface Session { id: string; role: SessionRole; @@ -702,12 +739,12 @@ export interface MCPTool { // Fields align with the agentskills.io open specification. // description covers both "what the skill does" and "when to use it". export interface SkillItem { - name: string; // unique key = directory name - description?: string; // required per spec; covers what + when to use - license?: string; // optional: SPDX identifier or free text + name: string; // unique key = directory name + description?: string; // required per spec; covers what + when to use + license?: string; // optional: SPDX identifier or free text compatibility?: string; // optional: environment requirements - mcps?: string[]; // MCP server names this skill unlocks on load - files: string[]; // files in the skill directory + mcps?: string[]; // MCP server names this skill unlocks on load + files: string[]; // files in the skill directory } // ---- Tools (内置工具目录) ---- @@ -720,12 +757,12 @@ export interface Tool { description: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any schema: Record; // full JSON-Schema (object with properties) - agents: string[]; // bound agent keys + agents: string[]; // bound agent keys enabled: boolean; kind?: "builtin" | "shell" | "command" | "script" | "http"; // 自定义工具类型 // eslint-disable-next-line @typescript-eslint/no-explicit-any - exec?: Record; // 自定义工具执行规格(kind!=builtin) - deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool) + exec?: Record; // 自定义工具执行规格(kind!=builtin) + deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool) } // ---- Stats ---- @@ -776,14 +813,14 @@ export interface InterceptPending { // InterceptApprovalRow enriches InterceptPending with conversation/task and rule context. export interface InterceptApprovalRow extends InterceptPending { - conv_title: string; // "" if no linked conversation - conv_agent_key: string; // "" if no linked conversation - rule_name: string; // "" if rule was deleted + conv_title: string; // "" if no linked conversation + conv_agent_key: string; // "" if no linked conversation + rule_name: string; // "" if rule was deleted } // ── 资产同步 (ScopeSentry 数据源) ────────────────────────────────────────────── export interface SSProject { - id: string; // MongoDB ObjectID — used as filter.project + id: string; // MongoDB ObjectID — used as filter.project name: string; logo?: string; AssetCount?: number; @@ -792,7 +829,7 @@ export interface SSProject { export interface SSTask { id: string; - name: string; // used as filter.task + name: string; // used as filter.task status?: number; progress?: number; creatTime?: string; From d14ce4d9f4aab75849b6cbd550867543df8824fe Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:18:24 -0400 Subject: [PATCH 05/17] =?UTF-8?q?feat(ui):=20skill=20=E6=A0=91=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E9=95=BF=E6=96=87=E4=BB=B6=E5=90=8D=E4=B8=8E=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E7=A1=AE=E8=AE=A4=EF=BC=8C=E4=BB=BB=E5=8A=A1=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=94=AF=E6=8C=81=E5=A4=9A=E9=80=89=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skill 管理: - 长文件名把行撑出侧栏导致操作按钮不可见,约束 ScrollArea viewport 内层并把按钮组改为绝对定位固定在右侧 - Skill / 文件夹 / 文件三处删除补上二次确认对话框 任务列表: - 表格新增多选列(本页全选 + 跨页保留选择) - 批量删除复用单任务的关联数据清理选项,串行执行并显示进度, 结果按成功/失败聚合提示 Claude-Session: https://claude.ai/code/session_01BNN7npEJs4X6LfTtgniMd8 --- web/src/app/(main)/function/tasks/page.tsx | 436 ++++++++++++++++----- web/src/app/(main)/system/skills/page.tsx | 106 ++++- 2 files changed, 430 insertions(+), 112 deletions(-) diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index 46b4ee5..bc60ba0 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -172,7 +172,11 @@ function taskDuration(task: Task, nowSec: number): number { return end > start ? end - start : 0; } -function deleteSummary(result: DeleteTaskResult): string { +// deleteDetails takes only the countable part of the result so callers can pass an +// aggregate accumulated over a bulk delete. +type DeleteCounts = Omit; + +function deleteDetails(result: DeleteCounts): string[] { const details: string[] = []; if (result.assets_deleted > 0) details.push(`删除资产 ${result.assets_deleted} 条`); if (result.assets_detached > 0) details.push(`解除共享资产关联 ${result.assets_detached} 条`); @@ -180,6 +184,11 @@ function deleteSummary(result: DeleteTaskResult): string { if (result.files_deleted) details.push("删除任务文件"); if (result.findings_deleted > 0) details.push(`删除漏洞 ${result.findings_deleted} 条`); if (result.llm_records_deleted > 0) details.push(`删除 LLM 请求/响应记录 ${result.llm_records_deleted} 条`); + return details; +} + +function deleteSummary(result: DeleteTaskResult): string { + const details = deleteDetails(result); return details.length > 0 ? `任务已删除(${details.join(",")})` : "任务已删除"; } @@ -232,6 +241,48 @@ export default function TasksPage() { [filtered, page, pageSize], ); + // 多选删除:选择跨翻页/筛选保留,只在任务真的消失(被删或后端不再返回)时收敛。 + const [selectedIds, setSelectedIds] = React.useState>(() => new Set()); + + React.useEffect(() => { + setSelectedIds((prev) => { + if (prev.size === 0) return prev; + const live = new Set(tasks.map((t) => t.id)); + const next = new Set([...prev].filter((id) => live.has(id))); + return next.size === prev.size ? prev : next; + }); + }, [tasks]); + + const toggleSelected = React.useCallback((id: string, checked: boolean) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (checked) next.add(id); else next.delete(id); + return next; + }); + }, []); + + const pageIds = React.useMemo(() => paginated.map((t) => t.id), [paginated]); + const pageSelectedCount = React.useMemo( + () => pageIds.filter((id) => selectedIds.has(id)).length, + [pageIds, selectedIds], + ); + let headerChecked: boolean | "indeterminate" = false; + if (pageIds.length > 0 && pageSelectedCount === pageIds.length) { + headerChecked = true; + } else if (pageSelectedCount > 0) { + headerChecked = "indeterminate"; + } + + const toggleSelectedPage = React.useCallback((checked: boolean) => { + setSelectedIds((prev) => { + const next = new Set(prev); + for (const id of pageIds) { + if (checked) next.add(id); else next.delete(id); + } + return next; + }); + }, [pageIds]); + // lastRef holds the previous poll's serialized payload: the list is re-fetched every // POLL_MS but usually comes back unchanged, and setTasks on an identical payload would // re-render the whole page for nothing. Bail out when it matches. @@ -284,6 +335,63 @@ export default function TasksPage() { } }, [load]); + // deleteTasks 逐个删除所选任务:后端没有批量接口,且单次删除会连带清理资产/流量/文件, + // 串行执行以免一次性打爆后端;成功的从选中集移除,失败的保留以便重试。 + const deleteTasks = React.useCallback(async ( + ids: string[], + options: DeleteTaskOptions, + onProgress: (done: number) => void, + ) => { + const total: DeleteCounts = { + assets_deleted: 0, + assets_detached: 0, + traffic_deleted: 0, + files_deleted: false, + findings_deleted: 0, + llm_records_deleted: 0, + }; + const deleted: string[] = []; + const failed: { id: string; message: string }[] = []; + const warnings: string[] = []; + + for (const id of ids) { + try { + const r = await api.deleteTask(id, options); + total.assets_deleted += r.assets_deleted; + total.assets_detached += r.assets_detached; + total.traffic_deleted += r.traffic_deleted; + total.files_deleted = total.files_deleted || r.files_deleted; + total.findings_deleted += r.findings_deleted; + total.llm_records_deleted += r.llm_records_deleted; + if (r.cleanup_warning) warnings.push(`#${id}:${r.cleanup_warning}`); + deleted.push(id); + } catch (e) { + failed.push({ id, message: (e as Error).message }); + } + onProgress(deleted.length + failed.length); + } + + if (deleted.length > 0) { + setSelectedIds((prev) => { + const next = new Set(prev); + for (const id of deleted) next.delete(id); + return next; + }); + const details = deleteDetails(total); + const summary = `已删除 ${deleted.length} 个任务` + (details.length > 0 ? `(${details.join(",")})` : ""); + if (warnings.length > 0) { + toast.warning(`${summary};部分外部数据清理未完成:${warnings.join(";")}`); + } else { + toast.success(summary); + } + } + if (failed.length > 0) { + const head = failed.slice(0, 3).map((f) => `#${f.id}(${f.message})`).join(";"); + toast.error(`${failed.length} 个任务删除失败:${head}${failed.length > 3 ? " 等" : ""}`); + } + load(); + }, [load]); + return ( @@ -325,6 +433,15 @@ export default function TasksPage() { {filtered.length}/{tasks.length} 条 + {selectedIds.size > 0 && ( + <> + 已选 {selectedIds.size} 个 + + + + )}
@@ -341,6 +458,13 @@ export default function TasksPage() { + + toggleSelectedPage(checked === true)} + aria-label="选择本页全部任务" + /> + ID 描述 目标 @@ -361,6 +485,8 @@ export default function TasksPage() { // 带来的整表重渲染,只让在跑的那几行走时长。 nowSec={task.status === "running" ? nowSec : 0} onDelete={deleteTask} + selected={selectedIds.has(task.id)} + onSelectedChange={toggleSelected} /> ))} @@ -461,13 +587,24 @@ const TaskRow = React.memo(function TaskRow({ task, nowSec, onDelete, + selected, + onSelectedChange, }: { task: Task; nowSec: number; onDelete: (id: string, options: DeleteTaskOptions) => Promise; + selected: boolean; + onSelectedChange: (id: string, checked: boolean) => void; }) { return ( - + + + onSelectedChange(task.id, checked === true)} + aria-label={`选择任务 ${task.id}`} + /> + {task.id} @@ -554,6 +691,117 @@ const deleteOptionKeys: (keyof DeleteTaskOptions)[] = [ "delete_llm_records", ]; +// DeleteOptionFields renders the「同时清理关联数据」checkbox block shared by the +// single-task and bulk delete dialogs. idPrefix keeps the label/input ids unique when +// several dialogs live in the same table. +function DeleteOptionFields({ + idPrefix, + options, + onOptionsChange, + disabled, +}: { + idPrefix: string; + options: DeleteTaskOptions; + onOptionsChange: React.Dispatch>; + disabled: boolean; +}) { + const selectedCount = deleteOptionKeys.filter((key) => options[key]).length; + let allChecked: boolean | "indeterminate" = false; + if (selectedCount === deleteOptionKeys.length) { + allChecked = true; + } else if (selectedCount > 0) { + allChecked = "indeterminate"; + } + + const updateOption = (key: keyof DeleteTaskOptions, checked: boolean) => { + onOptionsChange((current) => ({ ...current, [key]: checked })); + }; + + const updateAllOptions = (checked: boolean) => { + onOptionsChange({ + delete_assets: checked, + delete_traffic: checked, + delete_files: checked, + delete_findings: checked, + delete_llm_records: checked, + }); + }; + + return ( +
+ 同时清理关联数据 + + + updateAllOptions(checked === true)} + /> + + 全部删除 + 选中下方全部关联数据,包括资产、流量、文件、漏洞和 LLM 请求/响应记录。 + + + + updateOption("delete_assets", checked === true)} + /> + + 关联资产 + 删除仅属于该任务的资产;共享资产只解除当前任务关联。 + + + + updateOption("delete_traffic", checked === true)} + /> + + 关联流量 + 按关联资产的精确主机名删除;仍被其他任务引用的共享主机流量会保留。 + + + + updateOption("delete_files", checked === true)} + /> + + 任务文件 + 删除该任务工作目录中的上传文件、命令输出和其他产物。 + + + + updateOption("delete_findings", checked === true)} + /> + + 关联漏洞 + 永久删除该任务产生的独立漏洞记录与漏洞报告。 + + + + updateOption("delete_llm_records", checked === true)} + /> + + LLM 请求/响应记录 + 永久删除该任务录制的 LLM 请求、响应、Token 与错误详情。 + + + +
+ ); +} + function DeleteTaskDialog({ task, onDelete, @@ -565,29 +813,6 @@ function DeleteTaskDialog({ const [deleting, setDeleting] = React.useState(false); const [options, setOptions] = React.useState(emptyDeleteOptions); - const selectedCount = deleteOptionKeys.filter((key) => options[key]).length; - const allSelected = selectedCount === deleteOptionKeys.length; - let allChecked: boolean | "indeterminate" = false; - if (allSelected) { - allChecked = true; - } else if (selectedCount > 0) { - allChecked = "indeterminate"; - } - - const updateOption = (key: keyof DeleteTaskOptions, checked: boolean) => { - setOptions((current) => ({ ...current, [key]: checked })); - }; - - const updateAllOptions = (checked: boolean) => { - setOptions({ - delete_assets: checked, - delete_traffic: checked, - delete_files: checked, - delete_findings: checked, - delete_llm_records: checked, - }); - }; - const handleOpenChange = (next: boolean) => { if (deleting) return; setOpen(next); @@ -621,77 +846,12 @@ function DeleteTaskDialog({ 的执行记录与探索链路将被永久删除。 -
- 同时清理关联数据 - - - updateAllOptions(checked === true)} - /> - - 全部删除 - 选中下方全部关联数据,包括资产、流量、文件、漏洞和 LLM 请求/响应记录。 - - - - updateOption("delete_assets", checked === true)} - /> - - 关联资产 - 删除仅属于该任务的资产;共享资产只解除当前任务关联。 - - - - updateOption("delete_traffic", checked === true)} - /> - - 关联流量 - 按关联资产的精确主机名删除;仍被其他任务引用的共享主机流量会保留。 - - - - updateOption("delete_files", checked === true)} - /> - - 任务文件 - 删除该任务工作目录中的上传文件、命令输出和其他产物。 - - - - updateOption("delete_findings", checked === true)} - /> - - 关联漏洞 - 永久删除该任务产生的独立漏洞记录与漏洞报告。 - - - - updateOption("delete_llm_records", checked === true)} - /> - - LLM 请求/响应记录 - 永久删除该任务录制的 LLM 请求、响应、Token 与错误详情。 - - - -
+ 取消 void, + ) => Promise; +}) { + const [open, setOpen] = React.useState(false); + const [deleting, setDeleting] = React.useState(false); + const [done, setDone] = React.useState(0); + const [options, setOptions] = React.useState(emptyDeleteOptions); + + const handleOpenChange = (next: boolean) => { + if (deleting) return; + setOpen(next); + if (next) { + setOptions(emptyDeleteOptions()); + setDone(0); + } + }; + + const handleDelete = async () => { + setDeleting(true); + setDone(0); + try { + await onDelete(ids, options, setDone); + setOpen(false); + } finally { + setDeleting(false); + } + }; + + return ( + + + + + + + 确认删除所选 {ids.length} 个任务? + + 这些任务的执行记录与探索链路将被永久删除,下方清理选项对所选任务统一生效。 + + +
+ {ids.slice(0, 30).map((id) => ( + #{id} + ))} + {ids.length > 30 && …等 {ids.length} 个} +
+ + + 取消 + { + event.preventDefault(); + void handleDelete(); + }} + > + {deleting && } + {deleting ? `删除中 ${done}/${ids.length}` : `删除 ${ids.length} 个任务`} + + +
+
+ ); +} + // CreateTaskSheet is the 新建任务 drawer. Its form state lives HERE, not in TasksPage: with // 描述/目标 held by the page component every keystroke re-rendered the whole task table // behind the drawer (plus its sticky column and 20 AlertDialog trees), which showed up as diff --git a/web/src/app/(main)/system/skills/page.tsx b/web/src/app/(main)/system/skills/page.tsx index ab6366d..756ef4b 100644 --- a/web/src/app/(main)/system/skills/page.tsx +++ b/web/src/app/(main)/system/skills/page.tsx @@ -14,6 +14,16 @@ import { UploadIcon, } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -99,6 +109,12 @@ type Creating = { kind: "file" | "dir"; } | null; +type PendingDelete = + | { kind: "skill"; skill: string } + | { kind: "file"; skill: string; path: string } + | { kind: "dir"; skill: string; path: string } + | null; + // ── Page ────────────────────────────────────────────────────────────────── export default function SkillsPage() { const [skills, setSkills] = React.useState([]); @@ -137,6 +153,10 @@ export default function SkillsPage() { // skill detail — MCP edit state (optimistic, rolls back on error) const [detailMcps, setDetailMcps] = React.useState([]); + // delete confirmation + const [pendingDelete, setPendingDelete] = React.useState(null); + const [deleting, setDeleting] = React.useState(false); + // ── Data ────────────────────────────────────────────────────────────────── const load = React.useCallback(() => { api.agents().then(setAgents).catch(() => {}); @@ -296,6 +316,19 @@ export default function SkillsPage() { } } + async function runPendingDelete() { + const p = pendingDelete; + if (!p) return; + setDeleting(true); + try { + if (p.kind === "skill") await deleteSkill(p.skill); + else await deletePath(p.skill, p.path); + } finally { + setDeleting(false); + setPendingDelete(null); + } + } + async function saveFile() { if (!selected || selected.path === null) return; setSaving(true); @@ -400,7 +433,7 @@ export default function SkillsPage() { return (
toggleExpanded(key)} > @@ -409,8 +442,9 @@ export default function SkillsPage() { ? : } - {node.name} - + {node.name} + {/* Absolute so a long name can never push the actions out of view */} + @@ -442,19 +476,24 @@ export default function SkillsPage() {
setSelected({ skill, path: node.path })} > - {node.name} - + {node.name} +
); }); @@ -496,7 +535,10 @@ export default function SkillsPage() { {uploading ? "上传中…" : "上传压缩包"}
- + {/* Radix viewport wraps children in a display:table div that grows with + content — force it to block so long names truncate instead of + widening the rows past the sidebar. */} +
{skills.map((s) => { const isOpen = expanded.has(s.name); @@ -507,7 +549,7 @@ export default function SkillsPage() { {/* skill 根节点 */}
{ @@ -520,8 +562,11 @@ export default function SkillsPage() { ? : } - {s.name} - + {s.name} + @@ -649,6 +694,35 @@ export default function SkillsPage() {
+ {/* ── 删除二次确认 ── */} + { if (!o) setPendingDelete(null); }}> + + + + {pendingDelete?.kind === "skill" && `删除 Skill「${pendingDelete.skill}」?`} + {pendingDelete?.kind === "dir" && `删除文件夹「${pendingDelete.path}」?`} + {pendingDelete?.kind === "file" && `删除文件「${pendingDelete.path}」?`} + + + {pendingDelete?.kind === "skill" + ? "将删除该 Skill 的全部文件、MCP 关联与可见性配置。此操作不可撤销。" + : pendingDelete?.kind === "dir" + ? "将一并删除该文件夹下的所有文件。此操作不可撤销。" + : "此操作不可撤销。"} + + + + 取消 + { e.preventDefault(); void runPendingDelete(); }} + > + {deleting ? "删除中…" : "删除"} + + + + + {/* ── 新建 Skill 对话框 ── */} From ed96f256a4c3c02b65a2037dba18eaec72269dcf Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:21:07 -0400 Subject: [PATCH 06/17] =?UTF-8?q?fix(ui):=20=E6=8A=A5=E5=91=8A=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=E5=9C=A8=20HTTP=20=E9=9D=9E=E5=AE=89=E5=85=A8?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87=E4=B8=8B=E5=A4=B1=E6=95=88=EF=BC=8C?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20execCommand=20=E9=99=8D=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01Bs2rrsVHUBTxRusm9espwB --- .../tasks/detail/_tabs/report-tab.tsx | 42 ++++++++++++++++--- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/report-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/report-tab.tsx index 056b786..a9bb3e8 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/report-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/report-tab.tsx @@ -10,6 +10,34 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { api } from "@/lib/api"; +async function copyText(text: string): Promise { + // 优先使用异步剪贴板 API(仅在 HTTPS / localhost 等安全上下文可用) + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // 继续走降级方案 + } + } + // 降级:HTTP 等非安全上下文下用 execCommand + try { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + textarea.style.top = "0"; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(textarea); + return ok; + } catch { + return false; + } +} + export function ReportTab({ taskId }: { taskId: string }) { const [report, setReport] = React.useState(""); const [loading, setLoading] = React.useState(true); @@ -34,12 +62,16 @@ export function ReportTab({ taskId }: { taskId: string }) { }; }, [taskId]); - function copy() { + async function copy() { if (!report) return; - navigator.clipboard?.writeText(report); - setCopied(true); - toast.success("已复制 Markdown"); - setTimeout(() => setCopied(false), 1500); + const ok = await copyText(report); + if (ok) { + setCopied(true); + toast.success("已复制 Markdown"); + setTimeout(() => setCopied(false), 1500); + } else { + toast.error("复制失败,请手动选择文本复制"); + } } let content: React.ReactNode; From 43f40dd74f9af07f5486a819fbf4bc4c9f6a3f31 Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:06:46 -0400 Subject: [PATCH 07/17] =?UTF-8?q?feat(findings):=20=E5=8F=91=E7=8E=B0?= =?UTF-8?q?=E9=A1=B5=E6=94=AF=E6=8C=81=E5=AF=BC=E5=87=BA=EF=BC=88Markdown?= =?UTF-8?q?=20=E5=8D=95=E6=96=87=E4=BB=B6/=E5=88=86=E6=96=87=E4=BB=B6zip?= =?UTF-8?q?=E3=80=81CSV=E3=80=81JSON=EF=BC=8C=E6=8C=89=E7=AD=9B=E9=80=89/?= =?UTF-8?q?=E5=85=A8=E9=83=A8/=E5=8B=BE=E9=80=89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 GET /exploration/findings/export:按 scope(filtered|all|selected) × format(md-single|md-zip|csv|json) 导出,md-zip 用 archive/zip 后端打包 - db.ListFindingsForExport:带完整 report、不分页,按严重等级+时间排序 - report/findings.go:汇总/单条 Markdown、安全文件名、带 BOM 的 CSV 渲染 - 前端发现页加行勾选 + 导出弹窗(范围×格式);复用 lib/utils.copyText - 顺带修复报告复制在 HTTP 非安全上下文失效(execCommand 降级)+ 复用 CopyButton Claude-Session: https://claude.ai/code/session_01Bs2rrsVHUBTxRusm9espwB --- db/findings.go | 51 +++++ report/findings.go | 165 ++++++++++++++ server/server.go | 106 +++++++++ .../(main)/function/findings/detail/page.tsx | 6 +- web/src/app/(main)/function/findings/page.tsx | 204 +++++++++++++++++- .../tasks/detail/_tabs/report-tab.tsx | 29 +-- web/src/components/copy-button.tsx | 67 ++++++ web/src/lib/api.ts | 38 ++++ web/src/lib/utils.ts | 29 +++ 9 files changed, 658 insertions(+), 37 deletions(-) create mode 100644 report/findings.go create mode 100644 web/src/components/copy-button.tsx diff --git a/db/findings.go b/db/findings.go index 9dfe6e2..48f505c 100644 --- a/db/findings.go +++ b/db/findings.go @@ -210,6 +210,57 @@ func (d *DB) ListFindingsPage(f FindingFilter, page, pageSize int) ([]*DBFinding return out, total, err } +// ListFindingsForExport returns findings for the 发现 page 导出功能,携带完整 +// report 字段、不分页。ids 非空时按这批 finding id 精确导出(勾选导出),忽略 +// filter;ids 为空时按 filter 导出(导出当前筛选/全部)。结果按严重等级降序、 +// 再按时间倒序,与「导出汇总报告」的分组顺序一致。 +func (d *DB) ListFindingsForExport(f FindingFilter, ids []int64) ([]*DBFinding, error) { + const order = `ORDER BY CASE f.severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC, f.created_at DESC` + cols := findingSelectCols + `, COALESCE(f.report, '')` + + var q string + var args []any + if len(ids) > 0 { + ph := make([]string, len(ids)) + for i, id := range ids { + ph[i] = fmt.Sprintf("$%d", i+1) + args = append(args, id) + } + q = `SELECT ` + cols + ` + FROM findings f + LEFT JOIN tasks t ON f.task_id = t.id + WHERE f.id IN (` + strings.Join(ph, ",") + `) + ` + order + } else { + where, wargs := f.where() + q = `SELECT ` + cols + ` + FROM findings f + LEFT JOIN tasks t ON f.task_id = t.id` + where + ` + ` + order + args = wargs + } + + rows, err := d.Query(q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []*DBFinding + for rows.Next() { + f := &DBFinding{} + var aidsJSON string + if err := rows.Scan(&f.ID, &f.TaskID, &f.NodeID, &f.VulnClass, &f.Name, &f.Severity, + &f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt, + &f.TaskDescription, &f.Report); err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(aidsJSON), &f.AssetIDs) + out = append(out, f) + } + return out, rows.Err() +} + // FindingStats is the whole-table aggregate powering the 发现 page's stat cards // and vuln-class filter — computed server-side so it stays exact regardless of // pagination. diff --git a/report/findings.go b/report/findings.go new file mode 100644 index 0000000..265bfd3 --- /dev/null +++ b/report/findings.go @@ -0,0 +1,165 @@ +package report + +import ( + "bytes" + "encoding/csv" + "fmt" + "path" + "regexp" + "sort" + "strings" + "time" + + "github.com/Autumn-27/artex/db" +) + +// 发现页「导出」用的渲染:把一批 findings 表行渲染成汇总 Markdown、单条 Markdown、 +// 或 CSV。JSON 由 server 层直接用 DTO 序列化,不在此处。 + +// sortFindingsForExport 按严重等级降序、再按时间倒序排,与汇总报告的分组一致。 +func sortFindingsForExport(fs []*db.DBFinding) { + sort.SliceStable(fs, func(i, j int) bool { + ri, rj := sevRank[fs[i].Severity], sevRank[fs[j].Severity] + if ri != rj { + return ri < rj // sevRank 越小越严重 + } + return fs[i].CreatedAt.After(fs[j].CreatedAt) + }) +} + +// findingTitle 取漏洞可读标题:名称 → 类别 → 「未分类」。 +func findingTitle(f *db.DBFinding) string { + return nz(f.Name, nz(f.VulnClass, "未分类")) +} + +// FindingsMarkdown 把一批 findings 整合成一份汇总报告(摘要 + 按严重等级分组, +// 每条含类别/状态/所属任务/证据/详细报告)。 +func FindingsMarkdown(fs []*db.DBFinding, generatedAt time.Time) string { + items := append([]*db.DBFinding(nil), fs...) + sortFindingsForExport(items) + + var b strings.Builder + b.WriteString("# 漏洞发现汇总报告\n\n") + fmt.Fprintf(&b, "- **生成时间**:%s\n", generatedAt.Format("2006-01-02 15:04:05")) + fmt.Fprintf(&b, "- **发现总数**:%d 个\n\n", len(items)) + + // 摘要:各严重等级计数。 + counts := map[string]int{} + for _, f := range items { + counts[f.Severity]++ + } + b.WriteString("## 摘要\n\n") + b.WriteString("| 严重等级 | 数量 |\n| --- | --- |\n") + for _, s := range []struct{ key, label string }{ + {"critical", "严重"}, {"high", "高危"}, {"medium", "中危"}, {"low", "低危"}, + } { + fmt.Fprintf(&b, "| %s | %d |\n", s.label, counts[s.key]) + } + b.WriteString("\n") + + if len(items) == 0 { + b.WriteString("_无匹配的漏洞。_\n") + return b.String() + } + + b.WriteString("## 漏洞明细\n\n") + for i, f := range items { + fmt.Fprintf(&b, "### %d. [%s] %s\n\n", i+1, strings.ToUpper(nz(f.Severity, "info")), findingTitle(f)) + if f.VulnClass != "" { + fmt.Fprintf(&b, "- **类别**:%s\n", f.VulnClass) + } + fmt.Fprintf(&b, "- **状态**:%s\n", nz(f.Status, "pending")) + if desc := strings.TrimSpace(f.TaskDescription); desc != "" { + fmt.Fprintf(&b, "- **所属任务**:%s\n", desc) + } + fmt.Fprintf(&b, "- **发现时间**:%s\n\n", f.CreatedAt.Format("2006-01-02 15:04:05")) + if s := strings.TrimSpace(f.Summary); s != "" { + fmt.Fprintf(&b, "%s\n\n", s) + } + if e := strings.TrimSpace(f.Evidence); e != "" { + fmt.Fprintf(&b, "**证据:**\n\n```\n%s\n```\n\n", e) + } + if rep := strings.TrimSpace(f.Report); rep != "" { + b.WriteString("**详细报告:**\n\n") + b.WriteString(rep) + b.WriteString("\n\n") + } + b.WriteString("---\n\n") + } + return b.String() +} + +// SingleFindingMarkdown 渲染单条漏洞为一份独立 Markdown(用于「一漏洞一文件」打包)。 +func SingleFindingMarkdown(f *db.DBFinding, generatedAt time.Time) string { + var b strings.Builder + fmt.Fprintf(&b, "# [%s] %s\n\n", strings.ToUpper(nz(f.Severity, "info")), findingTitle(f)) + if f.VulnClass != "" { + fmt.Fprintf(&b, "- **类别**:%s\n", f.VulnClass) + } + fmt.Fprintf(&b, "- **严重等级**:%s\n", nz(f.Severity, "info")) + fmt.Fprintf(&b, "- **状态**:%s\n", nz(f.Status, "pending")) + if desc := strings.TrimSpace(f.TaskDescription); desc != "" { + fmt.Fprintf(&b, "- **所属任务**:%s\n", desc) + } + fmt.Fprintf(&b, "- **发现时间**:%s\n", f.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Fprintf(&b, "- **生成时间**:%s\n\n", generatedAt.Format("2006-01-02 15:04:05")) + if s := strings.TrimSpace(f.Summary); s != "" { + fmt.Fprintf(&b, "## 概述\n\n%s\n\n", s) + } + if e := strings.TrimSpace(f.Evidence); e != "" { + fmt.Fprintf(&b, "## 证据\n\n```\n%s\n```\n\n", e) + } + if rep := strings.TrimSpace(f.Report); rep != "" { + b.WriteString("## 详细报告\n\n") + b.WriteString(rep) + b.WriteString("\n") + } + return b.String() +} + +var unsafeFilenameChars = regexp.MustCompile(`[^\p{Han}\p{L}\p{N}._-]+`) + +// FindingFilename 为「一漏洞一文件」生成安全的 .md 文件名,形如 +// `critical_SQL注入_#123.md`。去掉路径分隔符与控制字符,避免 zip 内非法路径。 +func FindingFilename(f *db.DBFinding) string { + sev := nz(f.Severity, "info") + title := findingTitle(f) + name := fmt.Sprintf("%s_%s_#%d", sev, title, f.ID) + name = unsafeFilenameChars.ReplaceAllString(name, "_") + name = strings.Trim(name, "._") + if name == "" { + name = fmt.Sprintf("finding_%d", f.ID) + } + // 防御性:再剥一层路径,杜绝 zip slip。 + name = path.Base(name) + if len(name) > 120 { + name = name[:120] + } + return name + ".md" +} + +// FindingsCSV 把一批 findings 渲染成 CSV(带 UTF-8 BOM,便于 Excel 正确识别中文)。 +// 不含大段 report/evidence 全文,只放摘要类字段;需要全文用 Markdown/JSON 导出。 +func FindingsCSV(fs []*db.DBFinding) []byte { + items := append([]*db.DBFinding(nil), fs...) + sortFindingsForExport(items) + + var buf bytes.Buffer + buf.WriteString("\xEF\xBB\xBF") // UTF-8 BOM + w := csv.NewWriter(&buf) + _ = w.Write([]string{"ID", "名称", "类别", "严重等级", "状态", "所属任务", "发现时间", "概述"}) + for _, f := range items { + _ = w.Write([]string{ + fmt.Sprintf("%d", f.ID), + findingTitle(f), + f.VulnClass, + nz(f.Severity, "info"), + nz(f.Status, "pending"), + f.TaskDescription, + f.CreatedAt.Format("2006-01-02 15:04:05"), + strings.TrimSpace(f.Summary), + }) + } + w.Flush() + return buf.Bytes() +} diff --git a/server/server.go b/server/server.go index 1c462e0..64d8c01 100644 --- a/server/server.go +++ b/server/server.go @@ -1,6 +1,7 @@ package server import ( + "archive/zip" "context" "encoding/json" @@ -677,6 +678,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/exploration/frontier", s.frontier) mux.HandleFunc("GET /api/exploration/findings", s.findings) mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats) + mux.HandleFunc("GET /api/exploration/findings/export", s.findingsExport) mux.HandleFunc("GET /api/exploration/findings/{id}", s.getFinding) mux.HandleFunc("GET /api/exploration/findings/{id}/lineage", s.findingLineage) mux.HandleFunc("PATCH /api/exploration/findings/{id}", s.patchFinding) @@ -1895,6 +1897,110 @@ func (s *Server) getFinding(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, dto) } +// findingsExport 导出发现页的漏洞。 +// +// scope = filtered(沿用页面筛选)| all(全部)| selected(勾选的 ids) +// format = md-single(整合一份 .md)| md-zip(一漏洞一 .md,打包 zip) +// | csv | json +// ids = 逗号分隔的 finding id(scope=selected 时必填) +// 筛选参数 severity/status/vulnclass/task_id/sort 与列表接口一致(scope=filtered 用)。 +func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + scope := q.Get("scope") + format := q.Get("format") + + var ids []int64 + var filter db.FindingFilter + switch scope { + case "selected": + for _, part := range strings.Split(q.Get("ids"), ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + id, err := strconv.ParseInt(part, 10, 64) + if err != nil || id <= 0 { + writeErr(w, 400, "bad finding id: "+part) + return + } + ids = append(ids, id) + } + if len(ids) == 0 { + writeErr(w, 400, "no findings selected") + return + } + case "all": + // 空 filter = 不加任何条件。 + case "filtered", "": + filter = db.FindingFilter{ + Severity: normFilter(q.Get("severity")), + Status: normFilter(q.Get("status")), + VulnClass: normFilter(q.Get("vulnclass")), + TaskID: normFilter(q.Get("task_id")), + Sort: q.Get("sort"), + } + default: + writeErr(w, 400, "bad scope: "+scope) + return + } + + fs, err := s.m.pg.ListFindingsForExport(filter, ids) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + + now := time.Now() + stamp := now.Format("20060102-150405") + setDownload := func(contentType, filename string) { + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename)) + } + + switch format { + case "md-single": + setDownload("text/markdown; charset=utf-8", "findings-"+stamp+".md") + _, _ = w.Write([]byte(report.FindingsMarkdown(fs, now))) + case "md-zip": + setDownload("application/zip", "findings-"+stamp+".zip") + zw := zip.NewWriter(w) + used := map[string]int{} + for _, f := range fs { + base := report.FindingFilename(f) + name := base + // 去重:同名文件追加 -2、-3…… + if n := used[base]; n > 0 { + name = fmt.Sprintf("%s-%d.md", strings.TrimSuffix(base, ".md"), n+1) + } + used[base]++ + fw, werr := zw.Create(name) + if werr != nil { + log.Printf("[findings-export] zip create %s: %v", name, werr) + continue + } + _, _ = fw.Write([]byte(report.SingleFindingMarkdown(f, now))) + } + if cerr := zw.Close(); cerr != nil { + log.Printf("[findings-export] zip close: %v", cerr) + } + case "csv": + setDownload("text/csv; charset=utf-8", "findings-"+stamp+".csv") + _, _ = w.Write(report.FindingsCSV(fs)) + case "json": + assets := s.resolveFindingAssets(fs) + out := make([]FindingDTO, 0, len(fs)) + for _, f := range fs { + out = append(out, findingFromDB(f, assets)) + } + setDownload("application/json; charset=utf-8", "findings-"+stamp+".json") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + _ = enc.Encode(out) + default: + writeErr(w, 400, "bad format: "+format) + } +} + func findingProvenanceInTask(contextTask *Task, findingTaskID *int64) (sourceTaskID string, inherited, allowed bool) { if contextTask == nil || findingTaskID == nil { return "", false, false diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx index d5590e0..7d36f5c 100644 --- a/web/src/app/(main)/function/findings/detail/page.tsx +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -20,6 +20,7 @@ import { SelectTrigger, } from "@/components/ui/select"; import { StatusBadge } from "@/components/status-badge"; +import { CopyButton } from "@/components/copy-button"; import { Markdown } from "@/components/markdown"; import { FindingLineageView } from "./lineage"; import { statusMeta } from "@/lib/status"; @@ -208,8 +209,11 @@ function FindingDetailInner() { {/* 证据下方:详细报告(Markdown 渲染) */} - + 详细报告 + {finding.report && ( + + )} {finding.report ? ( diff --git a/web/src/app/(main)/function/findings/page.tsx b/web/src/app/(main)/function/findings/page.tsx index 38b9eda..03201b3 100644 --- a/web/src/app/(main)/function/findings/page.tsx +++ b/web/src/app/(main)/function/findings/page.tsx @@ -8,10 +8,22 @@ import { ArrowUpRightIcon, FileTextIcon, Trash2Icon, + DownloadIcon, } from "lucide-react"; import { StatusBadge } from "@/components/status-badge"; +import { CopyButton } from "@/components/copy-button"; import { Markdown } from "@/components/markdown"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { AlertDialog, AlertDialogAction, @@ -101,6 +113,73 @@ export default function FindingsPage() { const [page, setPage] = React.useState(1); const [pageSize, setPageSize] = React.useState(20); + // 勾选导出:按 finding_id(独立表 id)记选中项,跨页保留。 + const [selectedIds, setSelectedIds] = React.useState>(() => new Set()); + // 导出弹窗状态:范围(当前筛选/全部/选中) × 格式(md 单文件/md 分文件 zip/csv/json)。 + const [exportOpen, setExportOpen] = React.useState(false); + const [exportScope, setExportScope] = React.useState<"filtered" | "all" | "selected">("filtered"); + const [exportFormat, setExportFormat] = React.useState<"md-single" | "md-zip" | "csv" | "json">("md-single"); + const [exporting, setExporting] = React.useState(false); + + const toggleSelected = React.useCallback((id: string, checked: boolean) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (checked) next.add(id); + else next.delete(id); + return next; + }); + }, []); + + // 当前页可勾选的 finding_id(排除无 finding_id 的继承行)。 + const pageSelectableIds = React.useMemo( + () => findings.map((f) => f.finding_id).filter((id): id is string => !!id), + [findings], + ); + const pageSelectedCount = pageSelectableIds.filter((id) => selectedIds.has(id)).length; + let headerChecked: boolean | "indeterminate" = false; + if (pageSelectableIds.length > 0 && pageSelectedCount === pageSelectableIds.length) { + headerChecked = true; + } else if (pageSelectedCount > 0) { + headerChecked = "indeterminate"; + } + const toggleSelectedPage = React.useCallback( + (checked: boolean) => { + setSelectedIds((prev) => { + const next = new Set(prev); + for (const id of pageSelectableIds) { + if (checked) next.add(id); + else next.delete(id); + } + return next; + }); + }, + [pageSelectableIds], + ); + + // 打开导出弹窗时,若有勾选项则默认范围切到「选中」,否则「当前筛选」。 + function openExport() { + setExportScope(selectedIds.size > 0 ? "selected" : "filtered"); + setExportOpen(true); + } + + async function doExport() { + setExporting(true); + try { + await api.exportFindings({ + format: exportFormat, + scope: exportScope, + filters: { severity, status, vulnclass, task, sort }, + ids: [...selectedIds], + }); + setExportOpen(false); + toast.success("已开始下载导出文件"); + } catch (e) { + toast.error("导出失败:" + (e as Error).message); + } finally { + setExporting(false); + } + } + // reset to page 1 whenever filters change React.useEffect(() => { setPage(1); @@ -386,9 +465,19 @@ export default function FindingsPage() { - - 共 {total} 条 - +
+ {selectedIds.size > 0 && ( + + 已选 {selectedIds.size} 条 + + )} + + 共 {total} 条 + + +
@@ -398,6 +487,13 @@ export default function FindingsPage() {
+ + toggleSelectedPage(c === true)} + aria-label="选择本页全部" + /> + 严重度 漏洞名称 @@ -417,6 +513,17 @@ export default function FindingsPage() { className="cursor-pointer" onClick={() => toggleRow(f)} > + e.stopPropagation()}> + {f.finding_id && ( + + toggleSelected(f.finding_id as string, c === true) + } + aria-label="选择该漏洞" + /> + )} + {/* whitespace-normal 覆盖 TableCell 默认的 nowrap,否则展开区文字 被强制单行、直接溢出单元格。 */} - +
{/* 行内编辑:名称/类别/严重等级,可改并保存(仅独立 finding 行)。 */} {f.finding_id && edit && ( @@ -643,9 +750,20 @@ export default function FindingsPage() { {/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */} {f.finding_id && (
-
- - 详细报告 +
+ + + 详细报告 + + {reports[f.id]?.status === "done" && + reports[f.id]?.text.trim() && ( + + )}
{(() => { const rep = reports[f.id]; @@ -686,7 +804,7 @@ export default function FindingsPage() { {findings.length === 0 && ( 没有匹配的发现。 @@ -705,6 +823,76 @@ export default function FindingsPage() {
+ + + + + 导出发现 + + 选择导出范围与格式,生成后浏览器会自动下载。 + + + +
+
+ + setExportScope(v as typeof exportScope)} + > + + + + +
+ +
+ + setExportFormat(v as typeof exportFormat)} + > + + + + + +
+
+ + + + + +
+
); } diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/report-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/report-tab.tsx index a9bb3e8..e671149 100644 --- a/web/src/app/(main)/function/tasks/detail/_tabs/report-tab.tsx +++ b/web/src/app/(main)/function/tasks/detail/_tabs/report-tab.tsx @@ -9,34 +9,7 @@ import { Markdown } from "@/components/markdown"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { api } from "@/lib/api"; - -async function copyText(text: string): Promise { - // 优先使用异步剪贴板 API(仅在 HTTPS / localhost 等安全上下文可用) - if (navigator.clipboard && window.isSecureContext) { - try { - await navigator.clipboard.writeText(text); - return true; - } catch { - // 继续走降级方案 - } - } - // 降级:HTTP 等非安全上下文下用 execCommand - try { - const textarea = document.createElement("textarea"); - textarea.value = text; - textarea.style.position = "fixed"; - textarea.style.left = "-9999px"; - textarea.style.top = "0"; - document.body.appendChild(textarea); - textarea.focus(); - textarea.select(); - const ok = document.execCommand("copy"); - document.body.removeChild(textarea); - return ok; - } catch { - return false; - } -} +import { copyText } from "@/lib/utils"; export function ReportTab({ taskId }: { taskId: string }) { const [report, setReport] = React.useState(""); diff --git a/web/src/components/copy-button.tsx b/web/src/components/copy-button.tsx new file mode 100644 index 0000000..6949a9e --- /dev/null +++ b/web/src/components/copy-button.tsx @@ -0,0 +1,67 @@ +"use client"; + +import * as React from "react"; + +import { CheckIcon, CopyIcon } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { cn, copyText } from "@/lib/utils"; + +type CopyButtonProps = { + // 要复制的文本;为空则按钮禁用。 + text: string | null | undefined; + // 复制成功后的 toast 文案,默认「已复制」。 + successMessage?: string; + label?: React.ReactNode; + size?: React.ComponentProps["size"]; + variant?: React.ComponentProps["variant"]; + className?: string; +}; + +// CopyButton 统一的「复制到剪贴板」按钮:内置成功/失败反馈,并在 HTTP 非安全上下文 +// 下自动降级(见 copyText)。 +export function CopyButton({ + text, + successMessage = "已复制", + label = "复制", + size = "sm", + variant = "outline", + className, +}: CopyButtonProps) { + const [copied, setCopied] = React.useState(false); + const timer = React.useRef | null>(null); + + React.useEffect(() => { + return () => { + if (timer.current) clearTimeout(timer.current); + }; + }, []); + + async function handleCopy() { + if (!text) return; + const ok = await copyText(text); + if (ok) { + setCopied(true); + toast.success(successMessage); + if (timer.current) clearTimeout(timer.current); + timer.current = setTimeout(() => setCopied(false), 1500); + } else { + toast.error("复制失败,请手动选择文本复制"); + } + } + + return ( + + ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 29e7455..2263247 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -360,6 +360,44 @@ export const api = { return get(`/exploration/findings?${p.toString()}`); }, findingStats: () => get("/exploration/findings/stats"), + // exportFindings 触发发现页导出并下载文件。scope=selected 时传 ids(finding_id 列表); + // scope=filtered 时传当前筛选(沿用 FindingQuery 的筛选字段);scope=all 忽略筛选。 + exportFindings: async (opts: { + format: "md-single" | "md-zip" | "csv" | "json"; + scope: "filtered" | "all" | "selected"; + filters?: Omit; + ids?: string[]; + }) => { + const p = new URLSearchParams({ format: opts.format, scope: opts.scope }); + if (opts.scope === "selected") { + p.set("ids", (opts.ids ?? []).join(",")); + } else if (opts.scope === "filtered" && opts.filters) { + const f = opts.filters; + if (f.severity && f.severity !== "all") p.set("severity", f.severity); + if (f.status && f.status !== "all") p.set("status", f.status); + if (f.vulnclass && f.vulnclass !== "all") p.set("vulnclass", f.vulnclass); + if (f.task && f.task !== "all") p.set("task_id", f.task); + if (f.sort) p.set("sort", f.sort); + } + const token = getToken(); + const r = await fetch(`/api/exploration/findings/export?${p.toString()}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!r.ok) throw new Error(`export: ${r.status}`); + const blob = await r.blob(); + // 文件名优先取后端 Content-Disposition,取不到则用默认名。 + const disp = r.headers.get("Content-Disposition") ?? ""; + const m = disp.match(/filename="?([^"]+)"?/); + const filename = m?.[1] ?? `findings-export`; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + }, getFinding: (id: string, contextTaskId?: string) => get( `/exploration/findings/${id}${contextTaskId ? `?context_task=${encodeURIComponent(contextTaskId)}` : ""}`, diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index eb6d2c9..e5cbf1d 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -45,6 +45,35 @@ export function radixOverlayWasOpenAtPointerDown(): boolean { return overlayOpenAtLastPointerDown; } +// copyText 把文本写入剪贴板,返回是否成功。 +// 背景:navigator.clipboard 仅在安全上下文(HTTPS / localhost)可用;通过 IP + HTTP +// 访问时它为 undefined,此时降级到 execCommand("copy")。 +export async function copyText(text: string): Promise { + if (navigator.clipboard && window.isSecureContext) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // 继续走降级方案 + } + } + try { + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.left = "-9999px"; + textarea.style.top = "0"; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + const ok = document.execCommand("copy"); + document.body.removeChild(textarea); + return ok; + } catch { + return false; + } +} + export const getInitials = (str: string): string => { if (typeof str !== "string" || !str.trim()) return "?"; From 834e94535abfaad46475f985c24c3ce7581bf50c Mon Sep 17 00:00:00 2001 From: Autumn-27 <53844290+Autumn-27@users.noreply.github.com> Date: Thu, 20 Aug 2026 12:30:01 -0400 Subject: [PATCH 08/17] =?UTF-8?q?feat(skills):=20=E6=96=B0=E5=A2=9E=20Skil?= =?UTF-8?q?l=20=E8=B0=83=E7=94=A8=E7=BB=9F=E8=AE=A1=EF=BC=88=E8=B4=A6?= =?UTF-8?q?=E6=9C=AC=20+=20=E9=A1=B5=E9=9D=A2=E6=80=BB=E8=A7=88/=E8=AF=A6?= =?UTF-8?q?=E6=83=85=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - 新表 skill_usage(无外键,任务/会话删除后统计保留),一次 Skill() 调用记一行,只记维度不记 args 正文;含 found 标记未命中调用 - 用 RunInfo 经 ctx 把 task/exploration/intent/session 传到工具装配层, 在 Skill meta-tool 外包一层做埋点(可记 args 长度与不存在的 skill) - fsListSkills 返回附带调用次数/覆盖任务/最近调用;新增 GET /api/skills/missing 与 GET /api/skills/{name}/usage 前端(/system/skills): - 树节点显示调用次数 badge;目录优先排序 + 子项缩进错开一级 - 空态改为技能库总览(指标卡 + 调用排行 + 最近/未使用) - 选中 skill 的详情面板改为响应式左右分栏(配置在左、统计在右, 窄屏回落上下),含最近调用明细与未命中缺口清单 Claude-Session: https://claude.ai/code/session_01BNN7npEJs4X6LfTtgniMd8 --- agent/chat.go | 1 + agent/mainagent.go | 1 + agent/planner.go | 1 + agent/runinfo.go | 47 +++ agent/worker.go | 1 + db/schema.sql | 20 ++ db/skill_usage.go | 200 ++++++++++++ db/skill_usage_test.go | 116 +++++++ server/assembly.go | 6 +- server/server.go | 2 + server/server_mgmt.go | 59 +++- web/src/app/(main)/system/skills/page.tsx | 381 +++++++++++++++++++--- web/src/lib/api.ts | 6 + web/src/lib/mock/data.ts | 18 +- web/src/lib/mock/handler.ts | 2 + web/src/lib/types.ts | 22 ++ 16 files changed, 831 insertions(+), 52 deletions(-) create mode 100644 agent/runinfo.go create mode 100644 db/skill_usage.go create mode 100644 db/skill_usage_test.go diff --git a/agent/chat.go b/agent/chat.go index 86deccc..41be93b 100644 --- a/agent/chat.go +++ b/agent/chat.go @@ -92,6 +92,7 @@ func (c *ChatAgent) Chat(ctx context.Context, agentKey, sessionID, message strin // visible skills/MCP and lets the DB tools table filter/override. DefaultTools // have no tools-table rows, so they always pass through. base := actool.DefaultTools() + ctx = WithRunInfo(ctx, RunInfo{SessionID: sessionID}) tools, def, cleanup := AugmentTools(ctx, agentKey, base) defer cleanup() diff --git a/agent/mainagent.go b/agent/mainagent.go index 86b198b..b65d8c0 100644 --- a/agent/mainagent.go +++ b/agent/mainagent.go @@ -85,6 +85,7 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, as *db.AssetStore, t tsx.SetNotifyGoal(notifyGoal) // set_goals 新增目标 → 给 planner 记一条「人新增了目标:…」触发 // 领域工具 + 基础默认工具集(Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash) base := append(tsx.MainAgentTools(), actool.DefaultTools()...) + ctx = WithRunInfo(ctx, RunInfo{TaskID: taskID, ExplorationID: explorationID(ts)}) tools, def, cleanup := AugmentTools(ctx, "mainagent", base) defer cleanup() // 本任务的工作目录 /tasks/,先建好。 diff --git a/agent/planner.go b/agent/planner.go index b2757fe..d460e6c 100644 --- a/agent/planner.go +++ b/agent/planner.go @@ -273,6 +273,7 @@ func (p *Planner) Plan(ctx context.Context, taskID int64, as *db.AssetStore, ts } // 领域工具 + 基础默认工具集(Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash) base := append(tsx.PlannerTools(), actool.DefaultTools()...) + ctx = WithRunInfo(ctx, RunInfo{TaskID: taskID, ExplorationID: explorationID(ts)}) tools, def, cleanup := AugmentTools(ctx, "planner", base) defer cleanup() // 关键态势(刚完成的意图 + 预取的完整图)改放【本轮 user 输入】(见下方 input),system diff --git a/agent/runinfo.go b/agent/runinfo.go new file mode 100644 index 0000000..f006b6a --- /dev/null +++ b/agent/runinfo.go @@ -0,0 +1,47 @@ +package agent + +import ( + "context" + + "github.com/Autumn-27/artex/db" +) + +// RunInfo identifies WHICH run a tool call belongs to. Tool assembly only receives +// (ctx, agentKey) — the task/exploration ids live in the caller's arguments, not the +// ctx — so anything wired at assembly time (currently the Skill ledger in +// server/assembly.go) has no way to attribute a call to a task. Each run attaches +// its own RunInfo before calling AugmentTools; the wiring closure reads it once and +// captures it, so per-run attribution stays correct without threading parameters +// through the tool layer. Same pattern as TaskClock (see taskclock.go). +// +// Zero value = attribution unknown; every consumer must treat it as optional. +type RunInfo struct { + TaskID int64 // task registry id; 0 for non-task runs (chat sessions) + ExplorationID int64 // exploration id; 0 when unknown + IntentID int64 // worker's intent node; 0 for planner/mainagent/chat + SessionID string // chat conversation id; empty for task runs +} + +// explorationID reads a store's exploration id, tolerating a nil store (planner and +// worker runs can be driven without one in tests). +func explorationID(ts *db.ExplorationStore) int64 { + if ts == nil { + return 0 + } + return ts.ID() +} + +type runInfoKey struct{} + +// WithRunInfo attaches run attribution to ctx. +func WithRunInfo(ctx context.Context, ri RunInfo) context.Context { + return context.WithValue(ctx, runInfoKey{}, ri) +} + +// RunInfoFrom reads the RunInfo (zero value if none attached). +func RunInfoFrom(ctx context.Context) RunInfo { + if v, ok := ctx.Value(runInfoKey{}).(RunInfo); ok { + return v + } + return RunInfo{} +} diff --git a/agent/worker.go b/agent/worker.go index cdbe63d..258b05b 100644 --- a/agent/worker.go +++ b/agent/worker.go @@ -265,6 +265,7 @@ func (w *Worker) Execute(ctx context.Context, name string, taskID int64, as *db. // phase, Bash is hidden via Settlement.DisabledTools (no local gating needed). base := append(tsx.WorkerTools(), w.extraTools...) base = append(base, actool.DefaultTools()...) + ctx = WithRunInfo(ctx, RunInfo{TaskID: taskID, ExplorationID: explorationID(ts), IntentID: intent.ID}) tools, def, cleanup := AugmentTools(ctx, "worker", base) defer cleanup() diff --git a/db/schema.sql b/db/schema.sql index 90779f7..5cf002b 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -501,6 +501,26 @@ CREATE TABLE IF NOT EXISTS agent_skill_visibility ( ); CREATE INDEX IF NOT EXISTS idx_askv_skill ON agent_skill_visibility(skill_name); +-- Skill 调用账本(见 db/skill_usage.go)。一次 Skill() 调用一行,只记维度不记正文。 +-- 刻意不设外键:任务/会话删除后统计仍要保留(与 llm_usage 同理),skill 本身也只是 +-- 文件系统上的目录名,没有对应的表。 +CREATE TABLE IF NOT EXISTS skill_usage ( + id BIGSERIAL PRIMARY KEY, + ts TIMESTAMPTZ NOT NULL DEFAULT now(), + skill TEXT NOT NULL, + agent_key TEXT, + task_id BIGINT, + exploration_id BIGINT, + intent_id BIGINT, + session_id TEXT, + args_len INTEGER NOT NULL DEFAULT 0, + -- false = 模型点名了一个不存在的 skill(未命中)。这类行同样保留:它反映"想用但没有" + -- 的缺口,是补 skill 的依据。 + found BOOLEAN NOT NULL DEFAULT true +); +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); + -- ===================================================================== -- H. 内置工具目录 -- ===================================================================== diff --git a/db/skill_usage.go b/db/skill_usage.go new file mode 100644 index 0000000..61d3a63 --- /dev/null +++ b/db/skill_usage.go @@ -0,0 +1,200 @@ +package db + +import ( + "database/sql" + "strings" + "time" +) + +// SkillUsage is one Skill() invocation — the always-on skill call ledger. Written +// from the Skill meta-tool's OnInvoke hook (server/assembly.go), one row per load. +// Carries only dimensions (which skill, which agent, which task/session), never the +// caller's args text — args_len is kept so an "empty vs substantial context" split +// is still possible without storing prompt content, mirroring llm_usage. +// +// Rows deliberately outlive their task: skill_usage has no foreign keys, so deleting +// a task keeps its skill statistics intact (same rationale as llm_usage). +type SkillUsage struct { + Skill string `json:"skill"` // skill directory name (matches agent_skill_visibility.skill_name) + AgentKey string `json:"agent_key"` // worker / planner / mainagent / custom agent key + TaskID int64 `json:"task_id"` // 0 for non-task runs (chat sessions) + ExplorationID int64 `json:"exploration_id"` // 0 when unknown + IntentID int64 `json:"intent_id"` // worker's intent node; 0 for planner/mainagent/chat + SessionID string `json:"session_id"` // chat conversation id; empty for task runs + ArgsLen int `json:"args_len"` + Found bool `json:"found"` // false = the model named a skill that does not exist +} + +// InsertSkillUsage appends one ledger row. Best-effort: callers log and continue on +// error (a lost metering row must never break a skill invocation). +func (d *DB) InsertSkillUsage(u *SkillUsage) error { + _, err := d.Exec(` +INSERT INTO skill_usage(skill, agent_key, task_id, exploration_id, intent_id, session_id, args_len, found) +VALUES ($1, NULLIF($2,''), $3, $4, $5, NULLIF($6,''), $7, $8)`, + u.Skill, u.AgentKey, nullIfZero(u.TaskID), nullIfZero(u.ExplorationID), + nullIfZero(u.IntentID), u.SessionID, u.ArgsLen, u.Found) + return err +} + +func nullIfZero(v int64) any { + if v > 0 { + return v + } + return nil +} + +// SkillStat is one skill's aggregate usage, for the skills page. +type SkillStat struct { + Skill string `json:"skill"` + Calls int `json:"calls"` + Tasks int `json:"tasks"` // distinct tasks that loaded it (chat runs excluded) + Agents []string `json:"agents"` // agent keys that loaded it, most-used first + LastUsed *time.Time `json:"last_used"` // nil when never called +} + +// SkillStats aggregates the whole ledger grouped by skill, most-used first. Skills +// that were never invoked are absent — callers merge against the skill list on disk. +// Only resolved calls count; misses are reported separately by MissingSkillStats. +func (d *DB) SkillStats() ([]SkillStat, error) { + // agent keys come back as one comma-joined string rather than text[]: the pgx + // stdlib driver has no database/sql Scan target for arrays, and agent keys are + // [a-z0-9_-] so a comma join is unambiguous. + rows, err := d.Query(` +SELECT skill, COUNT(*) AS calls, + COUNT(DISTINCT task_id) AS tasks, + COALESCE(STRING_AGG(DISTINCT agent_key, ','), '') AS agents, + MAX(ts) AS last_used +FROM skill_usage +WHERE found +GROUP BY skill +ORDER BY COUNT(*) DESC, skill`) + if err != nil { + return nil, err + } + defer rows.Close() + out := []SkillStat{} + for rows.Next() { + var s SkillStat + var agents string + var lastUsed sql.NullTime + if err := rows.Scan(&s.Skill, &s.Calls, &s.Tasks, &agents, &lastUsed); err != nil { + return nil, err + } + s.Agents = []string{} + if agents != "" { + s.Agents = strings.Split(agents, ",") + } + if lastUsed.Valid { + t := lastUsed.Time + s.LastUsed = &t + } + out = append(out, s) + } + return out, rows.Err() +} + +// MissingSkillStats returns the skill names agents asked for that do not exist, +// most-requested first — the "wished it existed" gap list. Names come from the model +// so they are shown as-is (already length-capped at insert time). +func (d *DB) MissingSkillStats(limit int) ([]SkillStat, error) { + if limit <= 0 || limit > 200 { + limit = 20 + } + rows, err := d.Query(` +SELECT skill, COUNT(*) AS calls, + COALESCE(STRING_AGG(DISTINCT agent_key, ','), '') AS agents, + MAX(ts) AS last_used +FROM skill_usage +WHERE NOT found +GROUP BY skill +ORDER BY COUNT(*) DESC, skill +LIMIT $1`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []SkillStat{} + for rows.Next() { + var s SkillStat + var agents string + var lastUsed sql.NullTime + if err := rows.Scan(&s.Skill, &s.Calls, &agents, &lastUsed); err != nil { + return nil, err + } + s.Agents = []string{} + if agents != "" { + s.Agents = strings.Split(agents, ",") + } + if lastUsed.Valid { + t := lastUsed.Time + s.LastUsed = &t + } + out = append(out, s) + } + return out, rows.Err() +} + +// SkillCall is one row of a skill's recent-call list (detail panel). +type SkillCall struct { + TS time.Time `json:"ts"` + AgentKey string `json:"agent_key"` + TaskID int64 `json:"task_id"` + SessionID string `json:"session_id"` + ArgsLen int `json:"args_len"` +} + +// RecentSkillCalls returns the most recent invocations of one skill, newest first. +func (d *DB) RecentSkillCalls(skill string, limit int) ([]SkillCall, error) { + if limit <= 0 || limit > 200 { + limit = 50 + } + rows, err := d.Query(` +SELECT ts, COALESCE(agent_key,''), COALESCE(task_id,0), COALESCE(session_id,''), args_len +FROM skill_usage +WHERE skill = $1 AND found +ORDER BY ts DESC +LIMIT $2`, skill, limit) + if err != nil { + return nil, err + } + defer rows.Close() + out := []SkillCall{} + for rows.Next() { + var c SkillCall + if err := rows.Scan(&c.TS, &c.AgentKey, &c.TaskID, &c.SessionID, &c.ArgsLen); err != nil { + return nil, err + } + out = append(out, c) + } + return out, rows.Err() +} + +// SkillCallsByTask counts a task's skill loads, most-used first. Powers a per-task +// view of which procedures its agents actually reached for. +func (d *DB) SkillCallsByTask(taskID int64) ([]SkillStat, error) { + rows, err := d.Query(` +SELECT skill, COUNT(*) AS calls, MAX(ts) AS last_used +FROM skill_usage +WHERE task_id = $1 AND found +GROUP BY skill +ORDER BY COUNT(*) DESC, skill`, taskID) + if err != nil { + return nil, err + } + defer rows.Close() + out := []SkillStat{} + for rows.Next() { + var s SkillStat + var lastUsed sql.NullTime + if err := rows.Scan(&s.Skill, &s.Calls, &lastUsed); err != nil { + return nil, err + } + s.Agents = []string{} + if lastUsed.Valid { + t := lastUsed.Time + s.LastUsed = &t + } + out = append(out, s) + } + return out, rows.Err() +} diff --git a/db/skill_usage_test.go b/db/skill_usage_test.go new file mode 100644 index 0000000..4625696 --- /dev/null +++ b/db/skill_usage_test.go @@ -0,0 +1,116 @@ +package db + +import ( + "testing" +) + +// TestSkillUsageLedger writes a few rows and checks the three aggregates the +// skills page relies on: per-skill totals (resolved calls only), the miss list, +// and the recent-call list. Rows are namespaced by a unique skill name so the +// shared dev DB stays usable and the test cleans up after itself. +func TestSkillUsageLedger(t *testing.T) { + d, err := Open(testDSN(t)) + if err != nil { + t.Skipf("postgres unavailable (%v) — skipping", err) + } + defer d.Close() + + const ( + skillA = "zz-test-skill-a" + missing = "zz-test-skill-missing" + ) + cleanup := func() { + _, _ = d.Exec(`DELETE FROM skill_usage WHERE skill IN ($1,$2)`, skillA, missing) + } + cleanup() + defer cleanup() + + rows := []*SkillUsage{ + {Skill: skillA, AgentKey: "worker", TaskID: 991, ExplorationID: 5, IntentID: 7, ArgsLen: 12, Found: true}, + {Skill: skillA, AgentKey: "worker", TaskID: 991, ExplorationID: 5, ArgsLen: 0, Found: true}, + {Skill: skillA, AgentKey: "planner", TaskID: 992, ExplorationID: 6, Found: true}, + {Skill: skillA, AgentKey: "chatbot", SessionID: "conv-1", Found: true}, + {Skill: missing, AgentKey: "worker", TaskID: 991, Found: false}, + {Skill: missing, AgentKey: "worker", TaskID: 991, Found: false}, + } + for _, r := range rows { + if err := d.InsertSkillUsage(r); err != nil { + t.Fatalf("insert %s: %v", r.Skill, err) + } + } + + stats, err := d.SkillStats() + if err != nil { + t.Fatal(err) + } + var got *SkillStat + for i := range stats { + if stats[i].Skill == skillA { + got = &stats[i] + } + if stats[i].Skill == missing { + t.Fatalf("misses must not appear in SkillStats: %+v", stats[i]) + } + } + if got == nil { + t.Fatalf("skill %s absent from SkillStats", skillA) + } + if got.Calls != 4 { + t.Errorf("calls: want 4, got %d", got.Calls) + } + // 991 + 992; the chat row has a NULL task_id and COUNT(DISTINCT) skips NULLs. + if got.Tasks != 2 { + t.Errorf("tasks: want 2, got %d", got.Tasks) + } + if len(got.Agents) != 3 { + t.Errorf("agents: want 3 distinct, got %v", got.Agents) + } + if got.LastUsed == nil { + t.Error("last_used must be set") + } + + miss, err := d.MissingSkillStats(10) + if err != nil { + t.Fatal(err) + } + var missCalls int + for _, m := range miss { + if m.Skill == missing { + missCalls = m.Calls + } + } + if missCalls != 2 { + t.Errorf("missing calls: want 2, got %d", missCalls) + } + + calls, err := d.RecentSkillCalls(skillA, 10) + if err != nil { + t.Fatal(err) + } + if len(calls) != 4 { + t.Fatalf("recent calls: want 4, got %d", len(calls)) + } + // newest first + for i := 1; i < len(calls); i++ { + if calls[i].TS.After(calls[i-1].TS) { + t.Errorf("recent calls not newest-first at %d", i) + } + } + + byTask, err := d.SkillCallsByTask(991) + if err != nil { + t.Fatal(err) + } + var taskCalls int + for _, s := range byTask { + if s.Skill == skillA { + taskCalls = s.Calls + } + if s.Skill == missing { + t.Errorf("misses must not appear in SkillCallsByTask: %+v", s) + } + } + if taskCalls != 2 { + t.Errorf("task 991 calls: want 2, got %d", taskCalls) + } +} diff --git a/server/assembly.go b/server/assembly.go index 49fc162..426a9b6 100644 --- a/server/assembly.go +++ b/server/assembly.go @@ -137,7 +137,11 @@ func wireAgentAugment(pg *db.DB, skillDir string, hostTools func() ([]actool.Cor } return actool.RenderDeferredToolsBlock(reveal) } - extra = append(extra, reg.Tool()) + // Attribution for the usage ledger: ToolAugment only gets (ctx, agentKey), + // so the run's task/session ids ride in on the ctx (agent.RunInfo). Read it + // once here — this closure is rebuilt per run, so the captured value always + // belongs to this run. + extra = append(extra, meterSkillTool(reg.Tool(), pg, reg, a.Key, agent.RunInfoFrom(ctx))) } // host tools (traffic / orchestration / custom) — added to every agent's base; diff --git a/server/server.go b/server/server.go index 64d8c01..8962cc5 100644 --- a/server/server.go +++ b/server/server.go @@ -776,6 +776,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /api/skills", s.fsCreateSkill) mux.HandleFunc("POST /api/skills/upload", s.fsUploadSkill) mux.HandleFunc("DELETE /api/skills/{name}", s.fsDeleteSkill) + mux.HandleFunc("GET /api/skills/missing", s.fsMissingSkills) // 未命中(想调但不存在)的 skill 名 + mux.HandleFunc("GET /api/skills/{name}/usage", s.fsSkillUsage) // 单个 skill 的最近调用 mux.HandleFunc("PUT /api/skills/{name}/meta", s.fsUpdateSkillMeta) mux.HandleFunc("POST /api/skills/{name}/dirs", s.fsCreateDir) mux.HandleFunc("GET /api/skills/{name}/files", s.fsListFiles) diff --git a/server/server_mgmt.go b/server/server_mgmt.go index 9732b9d..8782d56 100644 --- a/server/server_mgmt.go +++ b/server/server_mgmt.go @@ -897,6 +897,12 @@ type skillFileNode struct { Compatibility string `json:"compatibility,omitempty"` MCPs []string `json:"mcps,omitempty"` Files []string `json:"files"` + // Usage ledger (db/skill_usage.go). Calls is 0 and LastUsed nil for a skill that + // was never invoked — the ledger only has rows for skills agents actually loaded. + Calls int `json:"calls"` + Tasks int `json:"tasks"` + Agents []string `json:"usage_agents"` + LastUsed *time.Time `json:"last_used,omitempty"` } func (s *Server) fsListSkills(w http.ResponseWriter, r *http.Request) { @@ -915,13 +921,28 @@ func (s *Server) fsListSkills(w http.ResponseWriter, r *http.Request) { writeErr(w, 500, err.Error()) return } + // Usage is best-effort decoration: a ledger read failure leaves the counts at + // zero rather than failing the skill list itself. + statBySkill := map[string]db.SkillStat{} + if s.m.pg != nil { + stats, err := s.m.pg.SkillStats() + if err != nil { + log.Printf("[skills] 读取调用统计失败: %v", err) + } + for _, st := range stats { + statBySkill[st.Skill] = st + } + } nodes := []skillFileNode{} for _, e := range entries { if !e.IsDir() { continue } dirName := e.Name() - node := skillFileNode{Name: dirName} + node := skillFileNode{Name: dirName, Agents: []string{}} + if st, ok := statBySkill[dirName]; ok { + node.Calls, node.Tasks, node.Agents, node.LastUsed = st.Calls, st.Tasks, st.Agents, st.LastUsed + } if meta, ok := metaByDir[dirName]; ok { node.Description = meta.Description node.License = meta.License @@ -937,6 +958,42 @@ func (s *Server) fsListSkills(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"skills": nodes}) } +// fsSkillUsage returns one skill's recent invocations, newest first (detail panel). +func (s *Server) fsSkillUsage(w http.ResponseWriter, r *http.Request) { + pg := s.pg(w) + if pg == nil { + return + } + name := r.PathValue("name") + if !validSkillName(name) { + writeErr(w, 400, "非法 skill 名") + return + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + calls, err := pg.RecentSkillCalls(name, limit) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"calls": calls}) +} + +// fsMissingSkills lists skill names agents asked for that do not exist — the gap +// list, i.e. which procedures are worth writing next. +func (s *Server) fsMissingSkills(w http.ResponseWriter, r *http.Request) { + pg := s.pg(w) + if pg == nil { + return + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + missing, err := pg.MissingSkillStats(limit) + if err != nil { + writeErr(w, 500, err.Error()) + return + } + writeJSON(w, 200, map[string]any{"missing": missing}) +} + // cleanStrs trims each string and drops empties. func cleanStrs(in []string) []string { out := make([]string, 0, len(in)) diff --git a/web/src/app/(main)/system/skills/page.tsx b/web/src/app/(main)/system/skills/page.tsx index 756ef4b..41539df 100644 --- a/web/src/app/(main)/system/skills/page.tsx +++ b/web/src/app/(main)/system/skills/page.tsx @@ -12,6 +12,7 @@ import { PlusIcon, Trash2Icon, UploadIcon, + AlertTriangleIcon, } from "lucide-react"; import { @@ -29,6 +30,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Checkbox } from "@/components/ui/checkbox"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Badge } from "@/components/ui/badge"; import { @@ -41,7 +43,17 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { cn } from "@/lib/utils"; import { api } from "@/lib/api"; -import type { Agent, SkillItem, MCPServer } from "@/lib/types"; +import type { Agent, SkillItem, MCPServer, SkillCall, MissingSkill } from "@/lib/types"; + +function fmtTime(ts?: string) { + if (!ts) return "从未调用"; + return new Date(ts).toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} // ── Tree node ────────────────────────────────────────────────────────────── interface TreeNode { @@ -95,9 +107,22 @@ function buildTree(entries: string[]): TreeNode[] { if (fname) nodes.push({ name: fname, path: entry, type: "file", children: [] }); } } + sortNodes(root); return root; } +// sortNodes orders every level like a file explorer: directories before files, +// then case-insensitive by name. Recurses into children. +function sortNodes(nodes: TreeNode[]): void { + nodes.sort((a, b) => { + if (a.type !== b.type) return a.type === "dir" ? -1 : 1; + return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); + }); + for (const n of nodes) { + if (n.children.length > 0) sortNodes(n.children); + } +} + // ── State types ─────────────────────────────────────────────────────────── type Selected = | { skill: string; path: null } @@ -115,6 +140,158 @@ type PendingDelete = | { kind: "dir"; skill: string; path: string } | null; +// ── Overview (empty-state) ────────────────────────────────────────────────── +// Shown when nothing is selected: a library-wide snapshot from data already loaded +// (the skill list carries per-skill usage; missing is fetched alongside). No extra +// requests — this is pure aggregation over props. +function SkillsOverview({ + skills, + missing, + onSelect, +}: { + skills: SkillItem[]; + missing: MissingSkill[]; + onSelect: (name: string) => void; +}) { + const agg = React.useMemo(() => { + const totalCalls = skills.reduce((n, s) => n + s.calls, 0); + const used = skills.filter((s) => s.calls > 0); + const ranked = [...used].sort((a, b) => b.calls - a.calls); + const neverUsed = skills.filter((s) => s.calls === 0); + const recent = skills + .filter((s) => s.last_used) + .sort((a, b) => (a.last_used! < b.last_used! ? 1 : -1)) + .slice(0, 6); + const missingCalls = missing.reduce((n, m) => n + m.calls, 0); + return { + totalCalls, + usedCount: used.length, + ranked, + topCalls: ranked[0]?.calls ?? 0, + neverUsed, + recent, + missingCalls, + }; + }, [skills, missing]); + + if (skills.length === 0) { + return ( +
+

暂无 Skill,点击左侧「新建」或「上传压缩包」开始

+
+ ); + } + + const stats: { label: string; value: React.ReactNode; hint?: string }[] = [ + { label: "Skill 总数", value: skills.length, hint: `${agg.usedCount} 个被调用过` }, + { label: "累计调用", value: agg.totalCalls }, + { label: "未使用", value: agg.neverUsed.length, hint: agg.neverUsed.length > 0 ? "从未被任何 agent 加载" : "全部用过" }, + { label: "未命中调用", value: agg.missingCalls, hint: missing.length > 0 ? `${missing.length} 个不存在的 skill` : "无" }, + ]; + + return ( +
+
+

技能库总览

+

选择左侧的 Skill 查看详情与调用记录,或从这里快速了解整体使用情况。

+
+ + {/* 指标卡 */} +
+ {stats.map((s) => ( +
+

{s.value}

+

{s.label}

+ {s.hint &&

{s.hint}

} +
+ ))} +
+ + {/* 调用排行 */} +
+ + {agg.ranked.length === 0 ? ( +

+ 还没有任何 Skill 调用记录。 +

+ ) : ( +
+ {agg.ranked.slice(0, 8).map((s) => ( + + ))} +
+ )} +
+ +
+ {/* 最近调用 */} +
+ + {agg.recent.length === 0 ? ( +

暂无记录。

+ ) : ( +
+ {agg.recent.map((s) => ( + + ))} +
+ )} +
+ + {/* 未使用(可清理 / 需曝光) */} +
+ + {agg.neverUsed.length === 0 ? ( +

所有 Skill 都被调用过。

+ ) : ( +
+ {agg.neverUsed.map((s) => ( + + ))} +
+ )} +
+
+
+ ); +} + // ── Page ────────────────────────────────────────────────────────────────── export default function SkillsPage() { const [skills, setSkills] = React.useState([]); @@ -157,10 +334,17 @@ export default function SkillsPage() { const [pendingDelete, setPendingDelete] = React.useState(null); const [deleting, setDeleting] = React.useState(false); + // 调用统计:列表页的次数/最近调用随 api.skills() 一起回来;选中某个 skill 时再拉它的 + // 最近调用明细。missing = 被点名但不存在的 skill(想用但没有)。 + const [usageCalls, setUsageCalls] = React.useState([]); + const [usageLoading, setUsageLoading] = React.useState(false); + const [missing, setMissing] = React.useState([]); + // ── Data ────────────────────────────────────────────────────────────────── const load = React.useCallback(() => { api.agents().then(setAgents).catch(() => {}); api.mcpServers().then(setMcpOptions).catch(() => {}); + api.missingSkills().then(setMissing).catch(() => {}); api.skills().then((ss) => { setSkills(ss); ss.forEach((s) => @@ -215,6 +399,17 @@ export default function SkillsPage() { setDetailMcps(sk?.mcps ?? []); }, [selected, skills]); + // Recent calls for the selected skill (detail panel only). + React.useEffect(() => { + if (!selected || selected.path !== null) { setUsageCalls([]); return; } + const name = selected.skill; + setUsageLoading(true); + api.skillUsage(name, 20) + .then((calls) => setUsageCalls(calls)) + .catch(() => setUsageCalls([])) + .finally(() => setUsageLoading(false)); + }, [selected]); + React.useEffect(() => { if (!selected || selected.path === null) { setFileContent(""); setDirty(false); return; } setFileLoading(true); @@ -425,7 +620,9 @@ export default function SkillsPage() { // ── Recursive tree renderer ─────────────────────────────────────────────── function renderTree(nodes: TreeNode[], skill: string, depth: number): React.ReactNode { - const baseIndent = 8 + depth * 14; + // depth+1: the skill root sits at 8px (px-2); its children indent one step + // further in so the tree reads as nested under the skill folder. + const baseIndent = 8 + (depth + 1) * 14; return nodes.map((node) => { if (node.type === "dir") { const key = `${skill}:${node.path}`; @@ -505,9 +702,36 @@ export default function SkillsPage() { return (
-
-

Skill

-

技能库 · agentskills.io 规范 · 按 Agent 授权可见

+
+
+

Skill

+

技能库 · agentskills.io 规范 · 按 Agent 授权可见

+
+ {/* 缺口清单:agent 点名调用、但库里没有的 skill —— 直接是该补什么的依据。 */} + {missing.length > 0 && ( + + + + + +

+ Agent 点名调用、但技能库里不存在的 skill。按被点名次数排序。 +

+
+ {missing.map((m) => ( +
+ {m.skill} + {m.calls} 次 + {fmtTime(m.last_used)} +
+ ))} +
+
+
+ )}
{/* ── 左侧文件树 ── */} @@ -563,6 +787,14 @@ export default function SkillsPage() { : } {s.name} + {s.calls > 0 && ( + + {s.calls} + + )}