feat(tokens): 新增 llm_usage 计量账本,按模型统计 token + 仪表盘新旧版切换

- 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
This commit is contained in:
Autumn-27 committed 2026-08-20 11:16:48 -04:00
1 parent 6b79043d82
commit def7649baa
12 files changed
+892 -370

No files matched your search

+12
View File
@@ -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"`
+183
View File
@@ -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
}
+2
View File
@@ -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
}
+75 -24
View File
@@ -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)
}
}
+49
View File
@@ -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) {
+3
View File
@@ -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)
+2
View File
@@ -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)
+288 -306
View File
@@ -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 (
<span
className={cn(
"inline-block size-1.5 shrink-0 animate-pulse rounded-full bg-blue-400",
className,
)}
/>
);
return <span className={cn("inline-block size-1.5 shrink-0 animate-pulse rounded-full bg-blue-400", className)} />;
}
function SectionTitle({
@@ -157,6 +140,7 @@ export default function DashboardPage() {
const [activity, setActivity] = React.useState<Activity[]>([]);
const [tokens, setTokens] = React.useState<TokenTotal | null>(null);
const [convTokens, setConvTokens] = React.useState<ConvTokenSummary[]>([]);
const [usageStats, setUsageStats] = React.useState<UsageStats | null>(null);
const [assetCounts, setAssetCounts] = React.useState<Record<string, number>>({});
const [traffic, setTraffic] = React.useState<TrafficExchange[]>([]);
const [agents, setAgents] = React.useState<Agent[]>([]);
@@ -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<number | null, { input: number; output: number; cacheRead: number; cacheWrite: number; taskCount: number }>
>(() => {
const m = new Map<number | null, { input: number; output: number; cacheRead: number; cacheWrite: number; taskCount: number }>();
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<number | null | "all">("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<string, number>();
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<Map<number | null, Bucket>>(() => {
const m = new Map<number | null, Bucket>();
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<Map<number | null, Bucket>>(() => {
const m = new Map<number | null, Bucket>();
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<string, { input: number; output: number; cacheRead: number }>();
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<string, { input: number; output: number; cacheRead: number }>();
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 (
<div className="flex flex-1 flex-col gap-4 pb-6">
{/* ── Header ── */}
<div className="flex items-center justify-between">
<div>
@@ -451,7 +476,6 @@ export default function DashboardPage() {
{/* ── Row 1: 5 stat cards ── */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
{/* 活跃任务 */}
<Card className="gap-1">
<CardHeader className="pb-0">
@@ -459,25 +483,15 @@ export default function DashboardPage() {
<TargetIcon className="size-3" /> 活跃任务
</div>
<div className="flex items-baseline gap-1.5">
<span className="text-2xl font-semibold tabular-nums">
{tasksByStatus.running ?? 0}
</span>
<span className="text-2xl font-semibold tabular-nums">{tasksByStatus.running ?? 0}</span>
<span className="text-xs text-muted-foreground">/ {tasks.length}</span>
</div>
</CardHeader>
<CardContent className="flex flex-wrap gap-x-2.5 gap-y-0.5 text-[10px]">
{(tasksByStatus.running ?? 0) > 0 && (
<span className="text-blue-400">探索 {tasksByStatus.running}</span>
)}
{(tasksByStatus.paused ?? 0) > 0 && (
<span className="text-amber-400">暂停 {tasksByStatus.paused}</span>
)}
{(tasksByStatus.done ?? 0) > 0 && (
<span className="text-emerald-400">完成 {tasksByStatus.done}</span>
)}
{tasks.length === 0 && (
<span className="text-muted-foreground">暂无任务</span>
)}
{(tasksByStatus.running ?? 0) > 0 && <span className="text-blue-400">探索 {tasksByStatus.running}</span>}
{(tasksByStatus.paused ?? 0) > 0 && <span className="text-amber-400">暂停 {tasksByStatus.paused}</span>}
{(tasksByStatus.done ?? 0) > 0 && <span className="text-emerald-400">完成 {tasksByStatus.done}</span>}
{tasks.length === 0 && <span className="text-muted-foreground">暂无任务</span>}
</CardContent>
</Card>
@@ -487,9 +501,7 @@ export default function DashboardPage() {
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<BugIcon className="size-3" /> 确认发现
</div>
<div className="text-2xl font-semibold tabular-nums">
{findings.length}
</div>
<div className="text-2xl font-semibold tabular-nums">{findings.length}</div>
</CardHeader>
<CardContent className="flex flex-wrap gap-2.5 text-[10px]">
<span className="text-rose-500">严重 {findingsBySev.critical}</span>
@@ -505,13 +517,9 @@ export default function DashboardPage() {
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<NetworkIcon className="size-3" /> 资产节点
</div>
<div className="text-2xl font-semibold tabular-nums">
{totalAssets}
</div>
<div className="text-2xl font-semibold tabular-nums">{totalAssets}</div>
</CardHeader>
<CardContent className="text-[10px] text-muted-foreground">
跨任务共享
</CardContent>
<CardContent className="text-[10px] text-muted-foreground">跨任务共享</CardContent>
</Card>
{/* 流量交互 */}
@@ -520,9 +528,7 @@ export default function DashboardPage() {
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
<ActivityIcon className="size-3" /> 流量交互
</div>
<div className="text-2xl font-semibold tabular-nums">
{traffic.length}
</div>
<div className="text-2xl font-semibold tabular-nums">{traffic.length}</div>
</CardHeader>
<CardContent className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
{settings?.traffic_capture ? (
@@ -547,7 +553,8 @@ export default function DashboardPage() {
</div>
</CardHeader>
<CardContent className="text-[10px] text-muted-foreground">
入 {fmtTokens(displayedTokens.input)}(含缓存 {fmtTokens(displayedTokens.cacheRead)})· 出 {fmtTokens(displayedTokens.output)}
入 {fmtTokens(displayedTokens.input)}(含缓存 {fmtTokens(displayedTokens.cacheRead)})· 出{" "}
{fmtTokens(displayedTokens.output)}
</CardContent>
</Card>
</div>
@@ -559,8 +566,35 @@ export default function DashboardPage() {
<div className="flex items-center gap-1.5 text-xs font-semibold">
<ZapIcon className="size-3.5 text-muted-foreground" />
LLM Token 消耗
<span className="ml-1 text-[10px] font-normal text-muted-foreground">
{tasks.length} 个任务累计
{/* 数据源开关:旧版=activity 统计(含历史任务),新版=llm_usage 计量账本(更准,仅覆盖启用后) */}
<div className="ml-1 flex gap-0.5 rounded-md border bg-muted/30 p-0.5">
{(
[
{ v: "old", label: "旧版" },
{ v: "new", label: "新版" },
] as const
).map(({ v, label }) => (
<button
key={v}
onClick={() => setTokenVersion(v)}
title={
v === "new"
? "新版:来自 llm_usage 计量账本,逐次调用精确、含中断消耗;仅覆盖启用后的数据"
: "旧版:来自 activity 统计(含历史任务),中断消耗不计、无法精确到模型"
}
className={cn(
"rounded px-2 py-0.5 text-[9px] font-medium transition-colors",
tokenVersion === v
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{label}
</button>
))}
</div>
<span className="text-[10px] font-normal text-muted-foreground">
{tokenVersion === "new" ? "llm_usage" : "activity"}
</span>
</div>
{/* 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 (
<button
key={p.id}
@@ -594,12 +628,14 @@ export default function DashboardPage() {
>
{p.name}
{p.is_default && (
<span className={cn(
"rounded px-1 py-0 text-[8px]",
tokenTab === resolvedKey
? "bg-background/20 text-background"
: "bg-emerald-500/20 text-emerald-400",
)}>
<span
className={cn(
"rounded px-1 py-0 text-[8px]",
tokenTab === resolvedKey
? "bg-background/20 text-background"
: "bg-emerald-500/20 text-emerald-400",
)}
>
默认
</span>
)}
@@ -611,7 +647,6 @@ export default function DashboardPage() {
{/* Body: metrics left + bar chart right */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[220px_1fr]">
{/* Left: key metrics */}
<div className="flex flex-col gap-4">
{/* Total */}
@@ -620,9 +655,7 @@ export default function DashboardPage() {
<div className="mt-0.5 text-3xl font-bold tabular-nums tracking-tight">
{fmtTokens(displayedTokens.input + displayedTokens.output) || "—"}
</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">
{displayedTokens.taskCount} 个任务
</div>
<div className="mt-0.5 text-[10px] text-muted-foreground">{displayedTokens.taskCount} 个任务</div>
</div>
{/* Per-type bars */}
@@ -631,18 +664,31 @@ export default function DashboardPage() {
// input 已含缓存;拆成不重叠三段:未命中输入 + 缓存命中 + 输出 = 总量。
const total = displayedTokens.input + displayedTokens.output;
return [
{ label: "输入(未命中)", value: displayedTokens.input - displayedTokens.cacheRead, barColor: dailyTrendConfig.input.color!, text: "text-blue-400" },
{ label: "缓存命中", value: displayedTokens.cacheRead, barColor: dailyTrendConfig.cacheRead.color!, text: "text-emerald-400" },
{ label: "输出", value: displayedTokens.output, barColor: dailyTrendConfig.output.color!, text: "text-violet-400" },
{
label: "输入(未命中)",
value: displayedTokens.input - displayedTokens.cacheRead,
barColor: dailyTrendConfig.input.color!,
text: "text-blue-400",
},
{
label: "缓存命中",
value: displayedTokens.cacheRead,
barColor: dailyTrendConfig.cacheRead.color!,
text: "text-emerald-400",
},
{
label: "输出",
value: displayedTokens.output,
barColor: dailyTrendConfig.output.color!,
text: "text-violet-400",
},
].map(({ label, value, barColor, text }) => {
const pct = total > 0 ? (value / total) * 100 : 0;
return (
<div key={label}>
<div className="mb-1 flex items-center justify-between text-[10px]">
<span className="text-muted-foreground">{label}</span>
<span className={cn("font-mono font-semibold tabular-nums", text)}>
{fmtTokens(value)}
</span>
<span className={cn("font-mono font-semibold tabular-nums", text)}>{fmtTokens(value)}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
@@ -664,7 +710,9 @@ export default function DashboardPage() {
return (
<div className="flex items-center justify-between rounded-lg border bg-muted/20 px-3 py-2 text-[10px]">
<span className="text-muted-foreground">缓存命中率</span>
<span className={cn("font-semibold tabular-nums", hitPct > 50 ? "text-emerald-400" : "text-amber-400")}>
<span
className={cn("font-semibold tabular-nums", hitPct > 50 ? "text-emerald-400" : "text-amber-400")}
>
{hitPct}%
</span>
</div>
@@ -687,13 +735,15 @@ export default function DashboardPage() {
))}
</div>
<div className="flex gap-0.5 rounded-md border bg-muted/30 p-0.5">
{([
{ days: 7, label: "7天" },
{ days: 30, label: "30天" },
{ days: 90, label: "3月" },
{ days: 180, label: "6月" },
{ days: 365, label: "一年" },
] as const).map(({ days, label }) => (
{(
[
{ days: 7, label: "7天" },
{ days: 30, label: "30天" },
{ days: 90, label: "3月" },
{ days: 180, label: "6月" },
{ days: 365, label: "一年" },
] as const
).map(({ days, label }) => (
<button
key={days}
onClick={() => setTokenDays(days)}
@@ -711,16 +761,15 @@ export default function DashboardPage() {
</div>
{dailyTokenData.length === 0 ? (
<div className="flex flex-1 items-center justify-center rounded-lg border bg-muted/10 text-xs text-muted-foreground" style={{ minHeight: 180 }}>
<div
className="flex flex-1 items-center justify-center rounded-lg border bg-muted/10 text-xs text-muted-foreground"
style={{ minHeight: 180 }}
>
暂无数据
</div>
) : (
<ChartContainer config={dailyTrendConfig} className="h-[200px] w-full">
<BarChart
data={dailyTokenData}
margin={{ top: 4, right: 4, bottom: 0, left: 0 }}
maxBarSize={40}
>
<BarChart data={dailyTokenData} margin={{ top: 4, right: 4, bottom: 0, left: 0 }} maxBarSize={40}>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
@@ -744,17 +793,18 @@ export default function DashboardPage() {
indicator="line"
formatter={(value, name) => (
<div className="flex w-full items-center justify-between gap-4">
<span>{(dailyTrendConfig as Record<string, { label: string }>)[String(name)]?.label ?? String(name)}</span>
<span className="font-mono font-semibold tabular-nums">
{fmtTokens(Number(value))}
<span>
{(dailyTrendConfig as Record<string, { label: string }>)[String(name)]?.label ??
String(name)}
</span>
<span className="font-mono font-semibold tabular-nums">{fmtTokens(Number(value))}</span>
</div>
)}
/>
}
/>
<Bar dataKey="input" stackId="1" fill="var(--color-input)" radius={[0, 0, 0, 0]} />
<Bar dataKey="output" stackId="1" fill="var(--color-output)" radius={[0, 0, 0, 0]} />
<Bar dataKey="input" stackId="1" fill="var(--color-input)" radius={[0, 0, 0, 0]} />
<Bar dataKey="output" stackId="1" fill="var(--color-output)" radius={[0, 0, 0, 0]} />
<Bar dataKey="cacheRead" stackId="1" fill="var(--color-cacheRead)" radius={[4, 4, 0, 0]} />
</BarChart>
</ChartContainer>
@@ -765,7 +815,6 @@ export default function DashboardPage() {
{/* ── Row 3: 活动流 | 发现 ── */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
{/* 活动流 */}
<Card className="p-4">
<div className="mb-3 flex items-center justify-between">
@@ -787,27 +836,20 @@ export default function DashboardPage() {
</div>
<div className="mb-2.5 flex flex-wrap gap-1.5">
{(["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 (
<span
key={kind}
className="rounded border bg-muted/20 px-1.5 py-0.5 text-[9px] text-muted-foreground"
>
{kind} <strong className="text-foreground/70">{count}</strong>
</span>
);
},
)}
{(["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 (
<span key={kind} className="rounded border bg-muted/20 px-1.5 py-0.5 text-[9px] text-muted-foreground">
{kind} <strong className="text-foreground/70">{count}</strong>
</span>
);
})}
</div>
<div className="divide-y">
{recentActivity.length === 0 ? (
<div className="py-4 text-center text-xs text-muted-foreground">
暂无活动记录
</div>
<div className="py-4 text-center text-xs text-muted-foreground">暂无活动记录</div>
) : (
recentActivity.map((a) => (
<div key={a.seq} className="flex gap-2.5 py-2">
@@ -821,20 +863,13 @@ export default function DashboardPage() {
</span>
<div className="min-w-0 flex-1">
<div className="text-[11px] font-medium">{kindLabel(a)}</div>
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">
{a.summary}
</div>
<div className="mt-0.5 truncate text-[10px] text-muted-foreground">{a.summary}</div>
</div>
<div className="shrink-0 text-right">
<Badge
variant="outline"
className="px-1.5 py-0 text-[9px] text-muted-foreground"
>
<Badge variant="outline" className="px-1.5 py-0 text-[9px] text-muted-foreground">
{a.kind}
</Badge>
<div className="mt-0.5 text-[9px] tabular-nums text-muted-foreground">
{fmtRel(a.ts)}
</div>
<div className="mt-0.5 text-[9px] tabular-nums text-muted-foreground">{fmtRel(a.ts)}</div>
</div>
</div>
))
@@ -859,9 +894,7 @@ export default function DashboardPage() {
<div className="divide-y">
{recentFindings.length === 0 ? (
<div className="py-4 text-center text-xs text-muted-foreground">
暂无发现
</div>
<div className="py-4 text-center text-xs text-muted-foreground">暂无发现</div>
) : (
recentFindings.map((f) => (
<div key={f.id} className="flex items-start gap-2 py-2">
@@ -874,18 +907,14 @@ export default function DashboardPage() {
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-[11px] font-semibold">{f.vulnclass}</span>
{f.task_description && (
<span className="truncate text-[10px] text-muted-foreground">
{f.task_description}
</span>
<span className="truncate text-[10px] text-muted-foreground">{f.task_description}</span>
)}
</div>
<div className="mt-0.5 line-clamp-2 text-[10px] leading-snug text-muted-foreground">
{f.summary}
</div>
</div>
<span className="shrink-0 text-[9px] tabular-nums text-muted-foreground">
{fmtRel(f.ts)}
</span>
<span className="shrink-0 text-[9px] tabular-nums text-muted-foreground">{fmtRel(f.ts)}</span>
</div>
))
)}
@@ -901,9 +930,7 @@ export default function DashboardPage() {
任务
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground">
{tasks.length} 个任务
</span>
<span className="text-[10px] text-muted-foreground">{tasks.length} 个任务</span>
<Link
href="/function/tasks"
className="flex items-center gap-0.5 text-[10px] text-muted-foreground hover:text-foreground"
@@ -915,49 +942,32 @@ export default function DashboardPage() {
<table className="w-full border-collapse text-xs">
<thead>
<tr className="border-b">
{["任务", "状态", "引擎", "目标进度", "在途", "最近活动"].map(
(h) => (
<th
key={h}
className="px-4 py-2 text-left text-[9px] font-semibold uppercase tracking-widest text-muted-foreground first:pl-4"
>
{h}
</th>
),
)}
{["任务", "状态", "引擎", "目标进度", "在途", "最近活动"].map((h) => (
<th
key={h}
className="px-4 py-2 text-left text-[9px] font-semibold uppercase tracking-widest text-muted-foreground first:pl-4"
>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{sortedTasks.length === 0 ? (
<tr>
<td
colSpan={6}
className="px-4 py-6 text-center text-xs text-muted-foreground"
>
<td colSpan={6} className="px-4 py-6 text-center text-xs text-muted-foreground">
暂无任务
</td>
</tr>
) : (
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 (
<tr
key={t.id}
className="border-b last:border-0 transition-colors hover:bg-muted/30"
>
<tr key={t.id} className="border-b last:border-0 transition-colors hover:bg-muted/30">
<td className="max-w-xs px-4 py-3">
<Link
href={`/function/tasks/detail?id=${t.id}`}
className="group flex flex-col"
>
<span className="truncate font-medium group-hover:underline">
{t.description}
</span>
<span className="mt-0.5 truncate font-mono text-[10px] text-muted-foreground">
{t.id}
</span>
<Link href={`/function/tasks/detail?id=${t.id}`} className="group flex flex-col">
<span className="truncate font-medium group-hover:underline">{t.description}</span>
<span className="mt-0.5 truncate font-mono text-[10px] text-muted-foreground">{t.id}</span>
</Link>
</td>
<td className="px-4 py-3">
@@ -983,7 +993,7 @@ export default function DashboardPage() {
)}
</td>
<td className="px-4 py-3 tabular-nums">
{t.in_flight ?? 0 > 0 ? (
{(t.in_flight ?? 0 > 0) ? (
<span className="font-semibold">{t.in_flight}</span>
) : (
<span className="text-muted-foreground">—</span>
@@ -1002,7 +1012,6 @@ export default function DashboardPage() {
{/* ── Row 5: 资产分布 | 流量状态码 | 拦截 & 待审批 ── */}
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
{/* 资产分布 */}
<Card className="p-4">
<SectionTitle icon={NetworkIcon} sub="按类型">
@@ -1010,9 +1019,7 @@ export default function DashboardPage() {
</SectionTitle>
{assetByType.length === 0 ? (
<div className="py-6 text-center text-xs text-muted-foreground">
暂无资产数据
</div>
<div className="py-6 text-center text-xs text-muted-foreground">暂无资产数据</div>
) : (
<div className="flex flex-col gap-2">
{assetByType.map(([type, count]) => (
@@ -1032,9 +1039,7 @@ export default function DashboardPage() {
</div>
)}
<div className="mt-3 border-t pt-3 text-[10px] text-muted-foreground">
共 {totalAssets} 节点
</div>
<div className="mt-3 border-t pt-3 text-[10px] text-muted-foreground">共 {totalAssets} 节点</div>
</Card>
{/* 流量状态码 */}
@@ -1045,36 +1050,24 @@ export default function DashboardPage() {
{/* bar chart */}
{trafficByCodes.length === 0 ? (
<div className="py-6 text-center text-xs text-muted-foreground">
暂无流量数据
</div>
<div className="py-6 text-center text-xs text-muted-foreground">暂无流量数据</div>
) : (
<>
<div className="mb-3 flex items-end gap-2" style={{ height: 52 }}>
{trafficByCodes.map(({ code, n }) => (
<div key={code} className="flex flex-1 flex-col items-center gap-1">
<span className="text-[9px] tabular-nums text-muted-foreground">
{n}
</span>
<span className="text-[9px] tabular-nums text-muted-foreground">{n}</span>
<div
className={cn(
"w-full min-h-1 rounded-sm",
statusBg(code),
"opacity-80",
)}
className={cn("w-full min-h-1 rounded-sm", statusBg(code), "opacity-80")}
style={{ height: Math.max(4, (n / trafficMax) * 36) }}
/>
<span className={cn("text-[9px] font-mono", statusColor(code))}>
{code}
</span>
<span className={cn("text-[9px] font-mono", statusColor(code))}>{code}</span>
</div>
))}
</div>
<div className="border-t pt-2.5">
<div className="mb-1.5 text-[10px] text-muted-foreground">
最近请求
</div>
<div className="mb-1.5 text-[10px] text-muted-foreground">最近请求</div>
<div className="flex flex-col gap-1.5">
{recentTraffic.map((e) => (
<div key={e.id} className="flex items-center gap-1.5 text-[10px]">
@@ -1092,14 +1085,7 @@ export default function DashboardPage() {
{e.host}
{e.url.replace(/^https?:\/\/[^/]+/, "").substring(0, 30)}
</span>
<span
className={cn(
"shrink-0 font-mono text-[9px]",
statusColor(e.status),
)}
>
{e.status}
</span>
<span className={cn("shrink-0 font-mono text-[9px]", statusColor(e.status))}>{e.status}</span>
</div>
))}
</div>
@@ -1154,9 +1140,7 @@ export default function DashboardPage() {
{/* pending approvals */}
{pendingCount > 0 && (
<div className="mt-3">
<div className="mb-1.5 text-[10px] font-medium text-amber-400">
待审批 ({pendingCount})
</div>
<div className="mb-1.5 text-[10px] font-medium text-amber-400">待审批 ({pendingCount})</div>
<div className="flex flex-col gap-1.5">
{pending.slice(0, 3).map((p) => (
<Link
@@ -1166,9 +1150,7 @@ export default function DashboardPage() {
>
<div className="min-w-0">
<div className="text-[10px] font-medium">{p.tool_name}</div>
<div className="text-[9px] text-muted-foreground">
{p.agent_name}
</div>
<div className="text-[9px] text-muted-foreground">{p.agent_name}</div>
</div>
<ArrowUpRightIcon className="size-3 shrink-0 text-amber-400" />
</Link>
@@ -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<ModelTokenStat[]>([]);
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 (
<div className="flex flex-col gap-4">
@@ -255,6 +296,77 @@ export function OverviewTab({ taskId }: { taskId: string }) {
</CardContent>
</Card>
)}
{/* LLM Token 用量:按模型分组,数据来自 llm_records(需开启 LLM 录制)。 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<CoinsIcon className="size-4 text-amber-500" /> LLM Token 用量
<span className="text-muted-foreground text-xs font-normal">
(按模型统计{tokenTotals.calls > 0 ? `,共 ${tokenTotals.calls} 次调用` : ""})
</span>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{modelTokens.length > 0 ? (
<>
{/* 合计总览 */}
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-1 text-sm">
<span className="tabular-nums">
<span className="text-muted-foreground">输入 </span>
<span className="font-semibold">{fmtTokens(tokenTotals.input)}</span>
</span>
<span className="tabular-nums">
<span className="text-muted-foreground">输出 </span>
<span className="font-semibold">{fmtTokens(tokenTotals.output)}</span>
</span>
<span className="tabular-nums">
<span className="text-muted-foreground">缓存读 </span>
<span className="font-semibold">{fmtTokens(tokenTotals.cacheRead)}</span>
</span>
<span className="tabular-nums">
<span className="text-muted-foreground">缓存命中率 </span>
<span className="font-semibold text-emerald-500">
{cacheHitRate(tokenTotals.cacheRead, tokenTotals.input)}
</span>
</span>
</div>
{/* 按模型明细表 */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-muted-foreground border-b text-left text-xs">
<th className="py-1.5 pr-3 font-medium">模型</th>
<th className="py-1.5 pr-3 text-right font-medium">调用</th>
<th className="py-1.5 pr-3 text-right font-medium">输入</th>
<th className="py-1.5 pr-3 text-right font-medium">输出</th>
<th className="py-1.5 pr-3 text-right font-medium">缓存读</th>
<th className="py-1.5 text-right font-medium">命中率</th>
</tr>
</thead>
<tbody>
{modelTokens.map((m) => (
<tr key={m.model} className="border-b last:border-0">
<td className="max-w-[16rem] truncate py-1.5 pr-3 font-mono text-xs" title={m.model}>
{m.model}
</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{m.calls}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{fmtTokens(m.input_tokens)}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{fmtTokens(m.output_tokens)}</td>
<td className="py-1.5 pr-3 text-right tabular-nums">{fmtTokens(m.cache_read_tokens)}</td>
<td className="py-1.5 text-right tabular-nums text-emerald-500">
{cacheHitRate(m.cache_read_tokens, m.input_tokens)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
) : (
<p className="text-muted-foreground text-sm">暂无 LLM 用量(任务尚未产生调用,或记录仍在写入)。</p>
)}
</CardContent>
</Card>
{/* 测试范围:覆盖度分母 + 授权边界,可手动增删。 */}
<Card>
<CardHeader>
+8
View File
@@ -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<CoverageGraphData>(`/tasks/${id}/coverage-graph`),
// 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。
usageStats: (days = 365) => get<UsageStats>(`/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<LLMRecordDetail>(`/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)}`),
};
+97 -16
View File
@@ -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,
)
? []
: {};
}
+58 -21
View File
@@ -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<string, any>; // 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<string, any>; // 自定义工具执行规格(kind!=builtin)
deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool)
exec?: Record<string, any>; // 自定义工具执行规格(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;