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] =?UTF-8?q?feat(tokens):=20=E6=96=B0=E5=A2=9E=20llm=5Fusag?= =?UTF-8?q?e=20=E8=AE=A1=E9=87=8F=E8=B4=A6=E6=9C=AC=EF=BC=8C=E6=8C=89?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=BB=9F=E8=AE=A1=20token=20+=20=E4=BB=AA?= =?UTF-8?q?=E8=A1=A8=E7=9B=98=E6=96=B0=E6=97=A7=E7=89=88=E5=88=87=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;