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

+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;