This commit is contained in:
pphua committed 2026-08-21 11:16:19 +08:00
1 parent 9793b522d9
commit b7e0b93023
46 files changed
+3632 -899

No files matched your search

+8 -9
View File
@@ -77,8 +77,8 @@ function LiveBadge() {
);
}
// Composer is the shared bottom input (textarea grows to a cap, Enter newlines,
// Ctrl/Cmd+Enter sends) — the same affordance across DraftChat and ChatView.
// Composer is the shared bottom input (textarea grows to a cap, Enter sends,
// Shift+Enter newlines) — the same affordance across DraftChat and ChatView.
function Composer({
value,
onChange,
@@ -112,11 +112,10 @@ function Composer({
const fileInputRef = React.useRef<HTMLInputElement>(null);
const atts = attachments ?? [];
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
// Enter 换行;只有 Ctrl/Cmd+Enter 才发送(避免长消息误发)。
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
onSend();
}
// Enter 发送;Shift+Enter 换行。isComposing 时是中文等输入法在选字,不拦截。
if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing) return;
e.preventDefault();
onSend();
}
return (
<div className="border-t p-3">
@@ -364,7 +363,7 @@ function DraftChat({
onChange={setInput}
onSend={send}
disabled={sending || uploading || !agentKey}
placeholder="输入消息,Enter 换行,Ctrl+Enter 发送"
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
leftSlot={agentPicker}
onPickFiles={pickFiles}
uploading={uploading}
@@ -692,7 +691,7 @@ function ChatView({
onChange={setInput}
onSend={send}
disabled={running}
placeholder={running ? "Agent 正在回复…" : "输入消息,Enter 换行,Ctrl+Enter 发送"}
placeholder={running ? "Agent 正在回复…" : "输入消息,Enter 发送,Shift+Enter 换行"}
running={running}
onStop={stop}
stopDisabled={stopping}
+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>
@@ -20,6 +20,7 @@ import {
SelectTrigger,
} from "@/components/ui/select";
import { StatusBadge } from "@/components/status-badge";
import { CopyButton } from "@/components/copy-button";
import { Markdown } from "@/components/markdown";
import { FindingLineageView } from "./lineage";
import { statusMeta } from "@/lib/status";
@@ -208,8 +209,11 @@ function FindingDetailInner() {
</Card>
{/* 证据下方:详细报告(Markdown 渲染) */}
<Card>
<CardHeader>
<CardHeader className="flex-row items-center justify-between">
<CardTitle className="text-sm">详细报告</CardTitle>
{finding.report && (
<CopyButton text={finding.report} successMessage="已复制详细报告" />
)}
</CardHeader>
<CardContent>
{finding.report ? (
+196 -8
View File
@@ -8,10 +8,22 @@ import {
ArrowUpRightIcon,
FileTextIcon,
Trash2Icon,
DownloadIcon,
} from "lucide-react";
import { StatusBadge } from "@/components/status-badge";
import { CopyButton } from "@/components/copy-button";
import { Markdown } from "@/components/markdown";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
AlertDialog,
AlertDialogAction,
@@ -101,6 +113,73 @@ export default function FindingsPage() {
const [page, setPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(20);
// 勾选导出:按 finding_id(独立表 id)记选中项,跨页保留。
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(() => new Set());
// 导出弹窗状态:范围(当前筛选/全部/选中) × 格式(md 单文件/md 分文件 zip/csv/json)。
const [exportOpen, setExportOpen] = React.useState(false);
const [exportScope, setExportScope] = React.useState<"filtered" | "all" | "selected">("filtered");
const [exportFormat, setExportFormat] = React.useState<"md-single" | "md-zip" | "csv" | "json">("md-single");
const [exporting, setExporting] = React.useState(false);
const toggleSelected = React.useCallback((id: string, checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (checked) next.add(id);
else next.delete(id);
return next;
});
}, []);
// 当前页可勾选的 finding_id(排除无 finding_id 的继承行)。
const pageSelectableIds = React.useMemo(
() => findings.map((f) => f.finding_id).filter((id): id is string => !!id),
[findings],
);
const pageSelectedCount = pageSelectableIds.filter((id) => selectedIds.has(id)).length;
let headerChecked: boolean | "indeterminate" = false;
if (pageSelectableIds.length > 0 && pageSelectedCount === pageSelectableIds.length) {
headerChecked = true;
} else if (pageSelectedCount > 0) {
headerChecked = "indeterminate";
}
const toggleSelectedPage = React.useCallback(
(checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of pageSelectableIds) {
if (checked) next.add(id);
else next.delete(id);
}
return next;
});
},
[pageSelectableIds],
);
// 打开导出弹窗时,若有勾选项则默认范围切到「选中」,否则「当前筛选」。
function openExport() {
setExportScope(selectedIds.size > 0 ? "selected" : "filtered");
setExportOpen(true);
}
async function doExport() {
setExporting(true);
try {
await api.exportFindings({
format: exportFormat,
scope: exportScope,
filters: { severity, status, vulnclass, task, sort },
ids: [...selectedIds],
});
setExportOpen(false);
toast.success("已开始下载导出文件");
} catch (e) {
toast.error("导出失败:" + (e as Error).message);
} finally {
setExporting(false);
}
}
// reset to page 1 whenever filters change
React.useEffect(() => {
setPage(1);
@@ -386,9 +465,19 @@ export default function FindingsPage() {
</SelectContent>
</Select>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">
共 {total} 条
</span>
<div className="ml-auto flex items-center gap-3">
{selectedIds.size > 0 && (
<span className="text-xs text-muted-foreground tabular-nums">
已选 {selectedIds.size} 条
</span>
)}
<span className="text-xs text-muted-foreground tabular-nums">
共 {total} 条
</span>
<Button size="sm" variant="outline" onClick={openExport}>
<DownloadIcon /> 导出
</Button>
</div>
</div>
<Card className="py-0">
@@ -398,6 +487,13 @@ export default function FindingsPage() {
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-8">
<Checkbox
checked={headerChecked}
onCheckedChange={(c) => toggleSelectedPage(c === true)}
aria-label="选择本页全部"
/>
</TableHead>
<TableHead className="w-8" />
<TableHead className="w-20">严重度</TableHead>
<TableHead>漏洞名称</TableHead>
@@ -417,6 +513,17 @@ export default function FindingsPage() {
className="cursor-pointer"
onClick={() => toggleRow(f)}
>
<TableCell onClick={(e) => e.stopPropagation()}>
{f.finding_id && (
<Checkbox
checked={selectedIds.has(f.finding_id)}
onCheckedChange={(c) =>
toggleSelected(f.finding_id as string, c === true)
}
aria-label="选择该漏洞"
/>
)}
</TableCell>
<TableCell>
<ChevronRightIcon
className={cn(
@@ -555,7 +662,7 @@ export default function FindingsPage() {
<TableRow className="hover:bg-transparent">
{/* whitespace-normal 覆盖 TableCell 默认的 nowrap,否则展开区文字
被强制单行、直接溢出单元格。 */}
<TableCell colSpan={8} className="bg-muted/30 whitespace-normal">
<TableCell colSpan={9} className="bg-muted/30 whitespace-normal">
<div className="flex flex-col gap-2 px-2 py-1">
{/* 行内编辑:名称/类别/严重等级,可改并保存(仅独立 finding 行)。 */}
{f.finding_id && edit && (
@@ -643,9 +750,20 @@ export default function FindingsPage() {
{/* 详细报告(Markdown):展开时按 finding_id 懒加载,免进详情页即可查看。 */}
{f.finding_id && (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileTextIcon className="size-3.5" />
详细报告
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-2">
<FileTextIcon className="size-3.5" />
详细报告
</span>
{reports[f.id]?.status === "done" &&
reports[f.id]?.text.trim() && (
<CopyButton
text={reports[f.id]?.text}
successMessage="已复制详细报告"
variant="ghost"
className="h-6 px-2 text-xs"
/>
)}
</div>
{(() => {
const rep = reports[f.id];
@@ -686,7 +804,7 @@ export default function FindingsPage() {
{findings.length === 0 && (
<TableRow>
<TableCell
colSpan={8}
colSpan={9}
className="py-12 text-center text-sm text-muted-foreground"
>
没有匹配的发现。
@@ -705,6 +823,76 @@ export default function FindingsPage() {
</CardContent>
</Card>
</div>
<Dialog open={exportOpen} onOpenChange={setExportOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>导出发现</DialogTitle>
<DialogDescription>
选择导出范围与格式,生成后浏览器会自动下载。
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-5 py-1">
<div className="flex flex-col gap-2">
<Label className="text-xs text-muted-foreground">导出范围</Label>
<RadioGroup
value={exportScope}
onValueChange={(v) => setExportScope(v as typeof exportScope)}
>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="filtered" /> 导出当前筛选结果(共 {total} 条)
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="all" /> 导出全部
</label>
<label
className={cn(
"flex items-center gap-2 text-sm",
selectedIds.size === 0 && "text-muted-foreground",
)}
>
<RadioGroupItem value="selected" disabled={selectedIds.size === 0} />
导出勾选的 {selectedIds.size} 条
</label>
</RadioGroup>
</div>
<div className="flex flex-col gap-2">
<Label className="text-xs text-muted-foreground">导出格式</Label>
<RadioGroup
value={exportFormat}
onValueChange={(v) => setExportFormat(v as typeof exportFormat)}
>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="md-single" /> Markdown 汇总报告(单个 .md 文件)
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="md-zip" /> Markdown 分文件(一漏洞一 .md,打包 .zip)
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="csv" /> CSV 表格(.csv)
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="json" /> JSON(.json)
</label>
</RadioGroup>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setExportOpen(false)} disabled={exporting}>
取消
</Button>
<Button
onClick={doExport}
disabled={exporting || (exportScope === "selected" && selectedIds.size === 0)}
>
<DownloadIcon /> {exporting ? "导出中…" : "导出"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1,27 +1,63 @@
"use client";
import * as React from "react";
import {
ActivityIcon,
AlertTriangleIcon,
BugIcon,
ClockIcon,
CoinsIcon,
PlusIcon,
RefreshCwIcon,
ShieldCheckIcon,
TargetIcon,
Trash2Icon,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { StatusBadge } from "@/components/status-badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Progress } from "@/components/ui/progress";
import { api } from "@/lib/api";
import type { Task, Stats, TaskNode, Finding } from "@/lib/types";
import type { Finding, 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 {
if (row.domain) return row.domain;
if (row.net) return row.net;
if (row.company_id) return `company #${row.company_id}`;
return "—";
}
const SCOPE_KIND_LABELS: Record<TaskScopeRow["kind"], string> = {
company: "公司",
root_domain: "根域名",
subdomain: "子域名",
ip: "IP",
cidr: "网段",
};
const SCOPE_SOURCE_LABELS: Record<TaskScopeRow["source"], string> = {
auto: "自动",
agent: "Agent",
manual: "手动",
};
function StatCard({
label,
@@ -42,9 +78,7 @@ function StatCard({
</CardDescription>
<CardTitle className="text-2xl tabular-nums">{value}</CardTitle>
</CardHeader>
{sub && (
<CardContent className="text-xs text-muted-foreground">{sub}</CardContent>
)}
{sub && <CardContent className="text-xs text-muted-foreground">{sub}</CardContent>}
</Card>
);
}
@@ -63,6 +97,57 @@ export function OverviewTab({ taskId }: { taskId: string }) {
} | null>(null);
// 正在重跑的意图 id(含 "__all__" 表示批量),用于禁用按钮 + 转圈。
const [rerunning, setRerunning] = React.useState<Set<string>>(new Set());
// 测试范围列表 + 新增表单状态。
const [scope, setScope] = React.useState<TaskScopeRow[]>([]);
const [scopeKind, setScopeKind] = React.useState<TaskScopeRow["kind"]>("root_domain");
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 {
const resp = await api.taskScope(taskId);
setScope(resp.scope);
} catch {
// 忽略:无 asset store 时接口 503,范围卡片自然为空
}
}, [taskId]);
const addScope = async () => {
const value = scopeValueInput.trim();
if (!value) return;
setScopeBusy(true);
setScopeErr("");
try {
await api.addTaskScope(taskId, scopeKind, value);
setScopeValueInput("");
await loadScope();
} catch (e) {
setScopeErr(e instanceof Error ? e.message : "添加失败");
} finally {
setScopeBusy(false);
}
};
const removeScope = async (row: TaskScopeRow) => {
setScope((prev) => prev.filter((s) => s.id !== row.id));
try {
await api.deleteTaskScope(taskId, row.id);
} catch {
await loadScope(); // 删除失败:重新拉取还原
}
};
const markRerun = (key: string, on: boolean) =>
setRerunning((prev) => {
@@ -77,9 +162,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
markRerun(id, true);
try {
await api.rerunIntent(taskId, id);
setIntents((prev) =>
prev.map((i) => (i.id === id ? { ...i, state: "open" } : i)),
);
setIntents((prev) => prev.map((i) => (i.id === id ? { ...i, state: "open" } : i)));
} catch {
// 失败忽略:下次轮询仍显示 blocked,用户可再点
} finally {
@@ -92,9 +175,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
markRerun("__all__", true);
try {
await api.rerunBlocked(taskId);
setIntents((prev) =>
prev.map((i) => (i.state === "blocked" ? { ...i, state: "open" } : i)),
);
setIntents((prev) => prev.map((i) => (i.state === "blocked" ? { ...i, state: "open" } : i)));
} catch {
// ignore
} finally {
@@ -107,13 +188,12 @@ export function OverviewTab({ taskId }: { taskId: string }) {
const load = async () => {
try {
const [tasksResp, statsResp, intentsResp, findingsResp] =
await Promise.all([
api.tasks(),
api.stats(taskId),
api.intents(taskId),
api.findings(taskId),
]);
const [tasksResp, statsResp, intentsResp, findingsResp] = await Promise.all([
api.tasks(),
api.stats(taskId),
api.intents(taskId),
api.findings(taskId),
]);
if (cancelled) return;
setTask(tasksResp.tasks.find((t) => t.id === taskId) ?? null);
setStats(statsResp);
@@ -133,20 +213,35 @@ export function OverviewTab({ taskId }: { taskId: string }) {
};
load();
const timer = setInterval(load, 3000);
void loadScope();
void loadTokens();
const timer = setInterval(() => {
void load();
void loadTokens();
}, 3000);
return () => {
cancelled = true;
clearInterval(timer);
};
}, [taskId]);
}, [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;
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">
@@ -160,15 +255,11 @@ export function OverviewTab({ taskId }: { taskId: string }) {
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<div className="text-xs font-medium text-muted-foreground">描述</div>
<p className="text-sm whitespace-pre-wrap break-words">
{task?.description?.trim() || "—"}
</p>
<p className="text-sm whitespace-pre-wrap break-words">{task?.description?.trim() || "—"}</p>
</div>
<div className="flex flex-col gap-1.5">
<div className="text-xs font-medium text-muted-foreground">目标</div>
<p className="text-sm whitespace-pre-wrap break-words">
{task?.goal?.trim() || "—"}
</p>
<p className="text-sm whitespace-pre-wrap break-words">{task?.goal?.trim() || "—"}</p>
</div>
</CardContent>
</Card>
@@ -205,6 +296,157 @@ 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>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldCheckIcon className="size-4 text-emerald-500" /> 测试范围
<span className="text-muted-foreground text-xs font-normal">
(覆盖度分母 + 授权边界,共 {scope.length} 条)
</span>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{/* 新增表单 */}
<div className="flex flex-wrap items-center gap-2">
<NativeSelect
size="sm"
value={scopeKind}
onChange={(e) => setScopeKind(e.target.value as TaskScopeRow["kind"])}
>
<NativeSelectOption value="root_domain">根域名</NativeSelectOption>
<NativeSelectOption value="subdomain">子域名</NativeSelectOption>
<NativeSelectOption value="ip">IP</NativeSelectOption>
<NativeSelectOption value="cidr">网段</NativeSelectOption>
<NativeSelectOption value="company">公司</NativeSelectOption>
</NativeSelect>
<Input
className="h-7 w-56 text-sm"
placeholder={
scopeKind === "company"
? "公司名或 id"
: scopeKind === "ip" || scopeKind === "cidr"
? "如 10.0.0.1 或 10.0.0.0/24"
: "如 example.com"
}
value={scopeValueInput}
onChange={(e) => setScopeValueInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addScope();
}}
disabled={scopeBusy}
/>
<Button
size="sm"
variant="outline"
disabled={scopeBusy || !scopeValueInput.trim()}
onClick={() => void addScope()}
>
<PlusIcon className="size-3.5" /> 添加
</Button>
{scopeErr && <span className="text-xs text-red-500">{scopeErr}</span>}
</div>
{/* 范围列表 */}
{scope.length > 0 ? (
<div className="flex flex-col gap-1.5">
{scope.map((row) => (
<div key={row.id} className="flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-xs">
{SCOPE_KIND_LABELS[row.kind]}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-xs">{scopeValue(row)}</span>
<span className="text-muted-foreground shrink-0 text-xs">{SCOPE_SOURCE_LABELS[row.source]}</span>
{row.task_id.toString() === taskId ? (
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
onClick={() => void removeScope(row)}
>
<Trash2Icon className="size-3.5 text-red-500" />
</Button>
) : (
<span className="text-muted-foreground shrink-0 text-xs">继承</span>
)}
</div>
))}
</div>
) : (
<p className="text-muted-foreground text-sm">暂无测试范围,添加后可作为资产覆盖度的分母。</p>
)}
</CardContent>
</Card>
{/* Heartbeat */}
<Card>
<CardHeader>
@@ -224,17 +466,13 @@ export function OverviewTab({ taskId }: { taskId: string }) {
</div>
<div>
<div className="text-xs text-muted-foreground">运行中 Worker</div>
<div className="mt-1 text-lg font-semibold tabular-nums">
{running.length}
</div>
<div className="mt-1 text-lg font-semibold tabular-nums">{running.length}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">最近活动</div>
<div className="mt-1 inline-flex items-center gap-1 text-sm">
<ClockIcon className="size-3.5" />
{task?.last_activity
? new Date(task.last_activity).toLocaleTimeString("zh-CN")
: "—"}
{task?.last_activity ? new Date(task.last_activity).toLocaleTimeString("zh-CN") : "—"}
</div>
</div>
<div>
@@ -270,9 +508,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
<span className="min-w-0 flex-1 truncate">{i.payload}</span>
</div>
))}
{running.length === 0 && (
<p className="text-sm text-muted-foreground">暂无进行中意图</p>
)}
{running.length === 0 && <p className="text-sm text-muted-foreground">暂无进行中意图</p>}
</CardContent>
</Card>
@@ -284,27 +520,19 @@ export function OverviewTab({ taskId }: { taskId: string }) {
</CardHeader>
<CardContent className="grid grid-cols-2 gap-3 text-sm">
<div>
<div className="text-2xl font-semibold tabular-nums text-red-600">
{taskFindings.length}
</div>
<div className="text-2xl font-semibold tabular-nums text-red-600">{taskFindings.length}</div>
<div className="text-xs text-muted-foreground">确认漏洞</div>
</div>
<div>
<div className="text-2xl font-semibold tabular-nums text-blue-600">
{running.length}
</div>
<div className="text-2xl font-semibold tabular-nums text-blue-600">{running.length}</div>
<div className="text-xs text-muted-foreground">执行中</div>
</div>
<div>
<div className="text-2xl font-semibold tabular-nums">
{open.length}
</div>
<div className="text-2xl font-semibold tabular-nums">{open.length}</div>
<div className="text-xs text-muted-foreground">frontier 待领</div>
</div>
<div>
<div className="text-2xl font-semibold tabular-nums text-red-600">
{blocked.length}
</div>
<div className="text-2xl font-semibold tabular-nums text-red-600">{blocked.length}</div>
<div className="text-xs text-muted-foreground">被拦意图</div>
</div>
</CardContent>
@@ -323,9 +551,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
<span className="min-w-0 flex-1 truncate">{f.summary}</span>
</div>
))}
{taskFindings.length === 0 && (
<p className="text-sm text-muted-foreground">暂无发现</p>
)}
{taskFindings.length === 0 && <p className="text-sm text-muted-foreground">暂无发现</p>}
</CardContent>
</Card>
</div>
@@ -337,19 +563,10 @@ export function OverviewTab({ taskId }: { taskId: string }) {
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="flex items-center gap-2 text-sm">
<AlertTriangleIcon className="size-4 text-red-500" /> 被拦/出错意图
<span className="text-xs font-normal text-muted-foreground">
(共 {blocked.length} 条,可重跑)
</span>
<span className="text-xs font-normal text-muted-foreground">(共 {blocked.length} 条,可重跑)</span>
</CardTitle>
<Button
size="sm"
variant="outline"
disabled={rerunning.has("__all__")}
onClick={() => void rerunAll()}
>
<RefreshCwIcon
className={`size-3.5 ${rerunning.has("__all__") ? "animate-spin" : ""}`}
/>
<Button size="sm" variant="outline" disabled={rerunning.has("__all__")} onClick={() => void rerunAll()}>
<RefreshCwIcon className={`size-3.5 ${rerunning.has("__all__") ? "animate-spin" : ""}`} />
全部重跑
</Button>
</CardHeader>
@@ -365,9 +582,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
disabled={rerunning.has(i.id)}
onClick={() => void rerunOne(i.id)}
>
<RefreshCwIcon
className={`size-3 ${rerunning.has(i.id) ? "animate-spin" : ""}`}
/>
<RefreshCwIcon className={`size-3 ${rerunning.has(i.id) ? "animate-spin" : ""}`} />
重跑
</Button>
</div>
@@ -383,24 +598,9 @@ export function OverviewTab({ taskId }: { taskId: string }) {
{/* Stat cards */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-3">
<StatCard
label="待领意图"
value={open.length}
icon={ShieldCheckIcon}
sub="frontier 开放"
/>
<StatCard
label="确认发现"
value={taskFindings.length}
icon={BugIcon}
sub="本任务"
/>
<StatCard
label="意图总数"
value={intents.length}
icon={AlertTriangleIcon}
sub="本任务全部意图"
/>
<StatCard label="待领意图" value={open.length} icon={ShieldCheckIcon} sub="frontier 开放" />
<StatCard label="确认发现" value={taskFindings.length} icon={BugIcon} sub="本任务" />
<StatCard label="意图总数" value={intents.length} icon={AlertTriangleIcon} sub="本任务全部意图" />
</div>
</div>
);
@@ -9,6 +9,7 @@ import { Markdown } from "@/components/markdown";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { api } from "@/lib/api";
import { copyText } from "@/lib/utils";
export function ReportTab({ taskId }: { taskId: string }) {
const [report, setReport] = React.useState<string>("");
@@ -34,12 +35,16 @@ export function ReportTab({ taskId }: { taskId: string }) {
};
}, [taskId]);
function copy() {
async function copy() {
if (!report) return;
navigator.clipboard?.writeText(report);
setCopied(true);
toast.success("已复制 Markdown");
setTimeout(() => setCopied(false), 1500);
const ok = await copyText(report);
if (ok) {
setCopied(true);
toast.success("已复制 Markdown");
setTimeout(() => setCopied(false), 1500);
} else {
toast.error("复制失败,请手动选择文本复制");
}
}
let content: React.ReactNode;
@@ -359,8 +359,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const [olderIntents, setOlderIntents] = React.useState<TaskNode[]>([]);
const [intentsHasMore, setIntentsHasMore] = React.useState(false);
const [loadingOlderIntents, setLoadingOlderIntents] = React.useState(false);
// Local-only chat messages overlaid onto the main-agent transcript.
const [chatExtra, setChatExtra] = React.useState<Activity[]>([]);
const [input, setInput] = React.useState("");
const [sending, setSending] = React.useState(false);
const [stopping, setStopping] = React.useState(false);
@@ -613,7 +611,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
// ID) resumes from the DB, so a dropped realtime link self-heals; seq-merge dedups.
React.useEffect(() => {
setStore({});
setChatExtra([]);
setSseLive(false);
setActiveId(MAIN_ID); // a stale worker id from the previous task must not leak in
snapshotRef.current = 0;
@@ -892,13 +889,11 @@ export function SessionsTab({ taskId }: { taskId: string }) {
// worker session shows only its intent's activity, led by the intent objective.
const activity = React.useMemo(() => {
const items = activeState?.items ?? [];
if (isMain) {
// chatExtra is an optimistic local echo — drop any entry the persisted (SSE)
// copy already covers so messages don't show twice after the backend round-trips.
const seen = new Set(items.map((a) => `${a.kind} ${a.summary}`));
const pending = chatExtra.filter((a) => !seen.has(`${a.kind} ${a.summary}`));
return [...items, ...pending].sort((a, b) => a.seq - b.seq);
}
// The main-agent console renders purely from server data: the human turn is
// persisted+broadcast by the backend BEFORE it returns, so it arrives over the
// same SSE stream (worker="mainagent") as every agent step — no client-side
// optimistic echo, hence no fabricated seq that could collide with real DB ids.
if (isMain) return items;
if (isPlanner || isSystem) return items;
// Worker session: the intent leads the transcript as a right-aligned "user"-style
// message (the task handed to this worker), followed by its execution steps.
@@ -918,7 +913,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
isPlanner,
isSystem,
activeState,
chatExtra,
active.id,
active.title,
active.last_activity,
@@ -1010,6 +1004,26 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const vp = viewport();
if (vp) vp.scrollTop = vp.scrollHeight;
}, [activity, viewport]);
// Lazy detail loads (AnswerBlock / ToolBlock / Markdown) grow the content AFTER the
// activity array settles, WITHOUT changing its reference — so the layout effects
// above never re-fire and a freshly opened session would leave its last message
// scrolled partly off-screen (the final answer expands from a one-line summary to
// full markdown below the fold). A ResizeObserver re-pins to the bottom on any
// height change while the user is still at the bottom, so opening the main agent
// lands on the last message fully shown. contentRef's div is always mounted, so the
// observer catches the transcript mounting + each detail expanding.
// biome-ignore lint/correctness/useExhaustiveDependencies: activeId intentionally rebinds the observer to the new session's content.
React.useEffect(() => {
const el = contentRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver(() => {
if (!atBottomRef.current) return;
const vp = viewport();
if (vp) vp.scrollTop = vp.scrollHeight;
});
ro.observe(el);
return () => ro.disconnect();
}, [activeId, viewport]);
function stop() {
if (stopping) return;
@@ -1021,36 +1035,19 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const text = input.trim();
const atts = attachments;
if ((!text && atts.length === 0) || sending) return;
const base = [...(store.main?.items ?? []), ...chatExtra];
const nextSeq = Math.max(0, ...base.map((a) => a.seq)) + 1;
const userMsg: Activity = {
seq: nextSeq,
worker: "mainagent",
ts: new Date().toISOString(),
kind: "user",
summary: text,
// 有附件时把 {text, attachments} 塞进 detail,乐观气泡直接渲染卡片,无需再拉取。
detail: atts.length > 0 ? JSON.stringify({ text, attachments: atts }) : undefined,
};
// optimistic echo of the human turn only; the agent's steps + final answer
// stream back live via SSE (worker="mainagent"), so we don't append the reply.
setChatExtra((prev) => [...prev, userMsg]);
// No optimistic echo: the backend persists+broadcasts the human turn before it
// returns, so it streams back over SSE (worker="mainagent") with its real DB
// seq — the transcript renders it from server data like every other step. Clear
// the composer eagerly for responsiveness; restore it if the send fails.
setInput("");
setAttachments([]);
setSending(true);
api
.chat(text, taskId, atts.length > 0 ? atts : undefined)
.catch(() => {
setChatExtra((prev) => [
...prev,
{
seq: nextSeq + 1,
worker: "mainagent",
ts: new Date().toISOString(),
kind: "text",
summary: "(发送失败,请稍后重试)",
},
]);
.catch((e) => {
setInput(text); // restore so the user doesn't lose their text / attachments
setAttachments(atts);
toast.error("发送失败:" + ((e as Error).message || "请稍后重试"));
})
.finally(() => setSending(false));
}
+341 -97
View File
@@ -172,7 +172,11 @@ function taskDuration(task: Task, nowSec: number): number {
return end > start ? end - start : 0;
}
function deleteSummary(result: DeleteTaskResult): string {
// deleteDetails takes only the countable part of the result so callers can pass an
// aggregate accumulated over a bulk delete.
type DeleteCounts = Omit<DeleteTaskResult, "deleted" | "cleanup_warning">;
function deleteDetails(result: DeleteCounts): string[] {
const details: string[] = [];
if (result.assets_deleted > 0) details.push(`删除资产 ${result.assets_deleted} 条`);
if (result.assets_detached > 0) details.push(`解除共享资产关联 ${result.assets_detached} 条`);
@@ -180,6 +184,11 @@ function deleteSummary(result: DeleteTaskResult): string {
if (result.files_deleted) details.push("删除任务文件");
if (result.findings_deleted > 0) details.push(`删除漏洞 ${result.findings_deleted} 条`);
if (result.llm_records_deleted > 0) details.push(`删除 LLM 请求/响应记录 ${result.llm_records_deleted} 条`);
return details;
}
function deleteSummary(result: DeleteTaskResult): string {
const details = deleteDetails(result);
return details.length > 0 ? `任务已删除(${details.join(",")})` : "任务已删除";
}
@@ -232,6 +241,48 @@ export default function TasksPage() {
[filtered, page, pageSize],
);
// 多选删除:选择跨翻页/筛选保留,只在任务真的消失(被删或后端不再返回)时收敛。
const [selectedIds, setSelectedIds] = React.useState<Set<string>>(() => new Set());
React.useEffect(() => {
setSelectedIds((prev) => {
if (prev.size === 0) return prev;
const live = new Set(tasks.map((t) => t.id));
const next = new Set([...prev].filter((id) => live.has(id)));
return next.size === prev.size ? prev : next;
});
}, [tasks]);
const toggleSelected = React.useCallback((id: string, checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
if (checked) next.add(id); else next.delete(id);
return next;
});
}, []);
const pageIds = React.useMemo(() => paginated.map((t) => t.id), [paginated]);
const pageSelectedCount = React.useMemo(
() => pageIds.filter((id) => selectedIds.has(id)).length,
[pageIds, selectedIds],
);
let headerChecked: boolean | "indeterminate" = false;
if (pageIds.length > 0 && pageSelectedCount === pageIds.length) {
headerChecked = true;
} else if (pageSelectedCount > 0) {
headerChecked = "indeterminate";
}
const toggleSelectedPage = React.useCallback((checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of pageIds) {
if (checked) next.add(id); else next.delete(id);
}
return next;
});
}, [pageIds]);
// lastRef holds the previous poll's serialized payload: the list is re-fetched every
// POLL_MS but usually comes back unchanged, and setTasks on an identical payload would
// re-render the whole page for nothing. Bail out when it matches.
@@ -284,6 +335,63 @@ export default function TasksPage() {
}
}, [load]);
// deleteTasks 逐个删除所选任务:后端没有批量接口,且单次删除会连带清理资产/流量/文件,
// 串行执行以免一次性打爆后端;成功的从选中集移除,失败的保留以便重试。
const deleteTasks = React.useCallback(async (
ids: string[],
options: DeleteTaskOptions,
onProgress: (done: number) => void,
) => {
const total: DeleteCounts = {
assets_deleted: 0,
assets_detached: 0,
traffic_deleted: 0,
files_deleted: false,
findings_deleted: 0,
llm_records_deleted: 0,
};
const deleted: string[] = [];
const failed: { id: string; message: string }[] = [];
const warnings: string[] = [];
for (const id of ids) {
try {
const r = await api.deleteTask(id, options);
total.assets_deleted += r.assets_deleted;
total.assets_detached += r.assets_detached;
total.traffic_deleted += r.traffic_deleted;
total.files_deleted = total.files_deleted || r.files_deleted;
total.findings_deleted += r.findings_deleted;
total.llm_records_deleted += r.llm_records_deleted;
if (r.cleanup_warning) warnings.push(`#${id}:${r.cleanup_warning}`);
deleted.push(id);
} catch (e) {
failed.push({ id, message: (e as Error).message });
}
onProgress(deleted.length + failed.length);
}
if (deleted.length > 0) {
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of deleted) next.delete(id);
return next;
});
const details = deleteDetails(total);
const summary = `已删除 ${deleted.length} 个任务` + (details.length > 0 ? `(${details.join(",")})` : "");
if (warnings.length > 0) {
toast.warning(`${summary};部分外部数据清理未完成:${warnings.join(";")}`);
} else {
toast.success(summary);
}
}
if (failed.length > 0) {
const head = failed.slice(0, 3).map((f) => `#${f.id}(${f.message})`).join(";");
toast.error(`${failed.length} 个任务删除失败:${head}${failed.length > 3 ? " 等" : ""}`);
}
load();
}, [load]);
return (
<Card>
<CardContent className="flex flex-col gap-4 px-0 pt-6">
@@ -325,6 +433,15 @@ export default function TasksPage() {
<span className="text-muted-foreground text-xs tabular-nums">
{filtered.length}/{tasks.length} 条
</span>
{selectedIds.size > 0 && (
<>
<span className="text-xs tabular-nums">已选 {selectedIds.size} 个</span>
<Button size="sm" variant="ghost" onClick={() => setSelectedIds(new Set())}>
取消选择
</Button>
<BulkDeleteTasksDialog ids={[...selectedIds]} onDelete={deleteTasks} />
</>
)}
<ConcurrencySettingsDialog />
<CreateTaskSheet tasks={tasks} onCreated={load} />
</div>
@@ -341,6 +458,13 @@ export default function TasksPage() {
<Table className="**:data-[slot='table-cell']:px-4 **:data-[slot='table-head']:px-4">
<TableHeader className="[&_tr]:border-t">
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={headerChecked}
onCheckedChange={(checked) => toggleSelectedPage(checked === true)}
aria-label="选择本页全部任务"
/>
</TableHead>
<TableHead className="font-mono">ID</TableHead>
<TableHead>描述</TableHead>
<TableHead>目标</TableHead>
@@ -361,6 +485,8 @@ export default function TasksPage() {
// 带来的整表重渲染,只让在跑的那几行走时长。
nowSec={task.status === "running" ? nowSec : 0}
onDelete={deleteTask}
selected={selectedIds.has(task.id)}
onSelectedChange={toggleSelected}
/>
))}
</TableBody>
@@ -461,13 +587,24 @@ const TaskRow = React.memo(function TaskRow({
task,
nowSec,
onDelete,
selected,
onSelectedChange,
}: {
task: Task;
nowSec: number;
onDelete: (id: string, options: DeleteTaskOptions) => Promise<void>;
selected: boolean;
onSelectedChange: (id: string, checked: boolean) => void;
}) {
return (
<TableRow className="group border-border/60">
<TableRow className="group border-border/60" data-state={selected ? "selected" : undefined}>
<TableCell>
<Checkbox
checked={selected}
onCheckedChange={(checked) => onSelectedChange(task.id, checked === true)}
aria-label={`选择任务 ${task.id}`}
/>
</TableCell>
<TableCell>
<code className="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">{task.id}</code>
</TableCell>
@@ -554,6 +691,117 @@ const deleteOptionKeys: (keyof DeleteTaskOptions)[] = [
"delete_llm_records",
];
// DeleteOptionFields renders the「同时清理关联数据」checkbox block shared by the
// single-task and bulk delete dialogs. idPrefix keeps the label/input ids unique when
// several dialogs live in the same table.
function DeleteOptionFields({
idPrefix,
options,
onOptionsChange,
disabled,
}: {
idPrefix: string;
options: DeleteTaskOptions;
onOptionsChange: React.Dispatch<React.SetStateAction<DeleteTaskOptions>>;
disabled: boolean;
}) {
const selectedCount = deleteOptionKeys.filter((key) => options[key]).length;
let allChecked: boolean | "indeterminate" = false;
if (selectedCount === deleteOptionKeys.length) {
allChecked = true;
} else if (selectedCount > 0) {
allChecked = "indeterminate";
}
const updateOption = (key: keyof DeleteTaskOptions, checked: boolean) => {
onOptionsChange((current) => ({ ...current, [key]: checked }));
};
const updateAllOptions = (checked: boolean) => {
onOptionsChange({
delete_assets: checked,
delete_traffic: checked,
delete_files: checked,
delete_findings: checked,
delete_llm_records: checked,
});
};
return (
<FieldSet disabled={disabled}>
<FieldLegend variant="label">同时清理关联数据</FieldLegend>
<FieldGroup className="gap-3">
<Field orientation="horizontal">
<Checkbox
id={`delete-all-${idPrefix}`}
checked={allChecked}
onCheckedChange={(checked) => updateAllOptions(checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-all-${idPrefix}`}>全部删除</FieldLabel>
<FieldDescription>选中下方全部关联数据,包括资产、流量、文件、漏洞和 LLM 请求/响应记录。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-assets-${idPrefix}`}
checked={options.delete_assets}
onCheckedChange={(checked) => updateOption("delete_assets", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-assets-${idPrefix}`}>关联资产</FieldLabel>
<FieldDescription>删除仅属于该任务的资产;共享资产只解除当前任务关联。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-traffic-${idPrefix}`}
checked={options.delete_traffic}
onCheckedChange={(checked) => updateOption("delete_traffic", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-traffic-${idPrefix}`}>关联流量</FieldLabel>
<FieldDescription>按关联资产的精确主机名删除;仍被其他任务引用的共享主机流量会保留。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-files-${idPrefix}`}
checked={options.delete_files}
onCheckedChange={(checked) => updateOption("delete_files", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-files-${idPrefix}`}>任务文件</FieldLabel>
<FieldDescription>删除该任务工作目录中的上传文件、命令输出和其他产物。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-findings-${idPrefix}`}
checked={options.delete_findings}
onCheckedChange={(checked) => updateOption("delete_findings", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-findings-${idPrefix}`}>关联漏洞</FieldLabel>
<FieldDescription>永久删除该任务产生的独立漏洞记录与漏洞报告。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-llm-records-${idPrefix}`}
checked={options.delete_llm_records}
onCheckedChange={(checked) => updateOption("delete_llm_records", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-llm-records-${idPrefix}`}>LLM 请求/响应记录</FieldLabel>
<FieldDescription>永久删除该任务录制的 LLM 请求、响应、Token 与错误详情。</FieldDescription>
</FieldContent>
</Field>
</FieldGroup>
</FieldSet>
);
}
function DeleteTaskDialog({
task,
onDelete,
@@ -565,29 +813,6 @@ function DeleteTaskDialog({
const [deleting, setDeleting] = React.useState(false);
const [options, setOptions] = React.useState<DeleteTaskOptions>(emptyDeleteOptions);
const selectedCount = deleteOptionKeys.filter((key) => options[key]).length;
const allSelected = selectedCount === deleteOptionKeys.length;
let allChecked: boolean | "indeterminate" = false;
if (allSelected) {
allChecked = true;
} else if (selectedCount > 0) {
allChecked = "indeterminate";
}
const updateOption = (key: keyof DeleteTaskOptions, checked: boolean) => {
setOptions((current) => ({ ...current, [key]: checked }));
};
const updateAllOptions = (checked: boolean) => {
setOptions({
delete_assets: checked,
delete_traffic: checked,
delete_files: checked,
delete_findings: checked,
delete_llm_records: checked,
});
};
const handleOpenChange = (next: boolean) => {
if (deleting) return;
setOpen(next);
@@ -621,77 +846,12 @@ function DeleteTaskDialog({
的执行记录与探索链路将被永久删除。
</AlertDialogDescription>
</AlertDialogHeader>
<FieldSet disabled={deleting}>
<FieldLegend variant="label">同时清理关联数据</FieldLegend>
<FieldGroup className="gap-3">
<Field orientation="horizontal">
<Checkbox
id={`delete-all-${task.id}`}
checked={allChecked}
onCheckedChange={(checked) => updateAllOptions(checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-all-${task.id}`}>全部删除</FieldLabel>
<FieldDescription>选中下方全部关联数据,包括资产、流量、文件、漏洞和 LLM 请求/响应记录。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-assets-${task.id}`}
checked={options.delete_assets}
onCheckedChange={(checked) => updateOption("delete_assets", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-assets-${task.id}`}>关联资产</FieldLabel>
<FieldDescription>删除仅属于该任务的资产;共享资产只解除当前任务关联。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-traffic-${task.id}`}
checked={options.delete_traffic}
onCheckedChange={(checked) => updateOption("delete_traffic", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-traffic-${task.id}`}>关联流量</FieldLabel>
<FieldDescription>按关联资产的精确主机名删除;仍被其他任务引用的共享主机流量会保留。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-files-${task.id}`}
checked={options.delete_files}
onCheckedChange={(checked) => updateOption("delete_files", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-files-${task.id}`}>任务文件</FieldLabel>
<FieldDescription>删除该任务工作目录中的上传文件、命令输出和其他产物。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-findings-${task.id}`}
checked={options.delete_findings}
onCheckedChange={(checked) => updateOption("delete_findings", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-findings-${task.id}`}>关联漏洞</FieldLabel>
<FieldDescription>永久删除该任务产生的独立漏洞记录与漏洞报告。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-llm-records-${task.id}`}
checked={options.delete_llm_records}
onCheckedChange={(checked) => updateOption("delete_llm_records", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-llm-records-${task.id}`}>LLM 请求/响应记录</FieldLabel>
<FieldDescription>永久删除该任务录制的 LLM 请求、响应、Token 与错误详情。</FieldDescription>
</FieldContent>
</Field>
</FieldGroup>
</FieldSet>
<DeleteOptionFields
idPrefix={task.id}
options={options}
onOptionsChange={setOptions}
disabled={deleting}
/>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
<AlertDialogAction
@@ -711,6 +871,90 @@ function DeleteTaskDialog({
);
}
// BulkDeleteTasksDialog deletes every checked task with one shared set of cleanup options.
// The backend has no batch endpoint, so deletion runs one task at a time (see deleteTasks)
// and the button shows live progress.
function BulkDeleteTasksDialog({
ids,
onDelete,
}: {
ids: string[];
onDelete: (
ids: string[],
options: DeleteTaskOptions,
onProgress: (done: number) => void,
) => Promise<void>;
}) {
const [open, setOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState(false);
const [done, setDone] = React.useState(0);
const [options, setOptions] = React.useState<DeleteTaskOptions>(emptyDeleteOptions);
const handleOpenChange = (next: boolean) => {
if (deleting) return;
setOpen(next);
if (next) {
setOptions(emptyDeleteOptions());
setDone(0);
}
};
const handleDelete = async () => {
setDeleting(true);
setDone(0);
try {
await onDelete(ids, options, setDone);
setOpen(false);
} finally {
setDeleting(false);
}
};
return (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline">
<Trash2Icon className="text-destructive" /> 删除所选 {ids.length}
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="max-h-[85vh] overflow-y-auto">
<AlertDialogHeader>
<AlertDialogTitle>确认删除所选 {ids.length} 个任务?</AlertDialogTitle>
<AlertDialogDescription>
这些任务的执行记录与探索链路将被永久删除,下方清理选项对所选任务统一生效。
</AlertDialogDescription>
</AlertDialogHeader>
<div className="text-muted-foreground flex flex-wrap gap-1 text-xs">
{ids.slice(0, 30).map((id) => (
<code key={id} className="bg-muted rounded px-1.5 py-0.5 font-mono">#{id}</code>
))}
{ids.length > 30 && <span className="self-center">…等 {ids.length} 个</span>}
</div>
<DeleteOptionFields
idPrefix="bulk"
options={options}
onOptionsChange={setOptions}
disabled={deleting}
/>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={(event) => {
event.preventDefault();
void handleDelete();
}}
>
{deleting && <Spinner data-icon="inline-start" />}
{deleting ? `删除中 ${done}/${ids.length}` : `删除 ${ids.length} 个任务`}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// CreateTaskSheet is the 新建任务 drawer. Its form state lives HERE, not in TasksPage: with
// 描述/目标 held by the page component every keystroke re-rendered the whole task table
// behind the drawer (plus its sticky column and 20 AlertDialog trees), which showed up as
@@ -872,7 +1116,7 @@ function CreateTaskSheet({ tasks, onCreated }: { tasks: Task[]; onCreated: () =>
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button size="sm" className="ml-auto">
<Button size="sm">
<PlusIcon /> 新建任务
</Button>
</SheetTrigger>
+50 -50
View File
@@ -29,26 +29,26 @@ import { api } from "@/lib/api";
import type { LLMPoolMember, LLMPoolStatus, LLMProfile } from "@/lib/types";
import { cn } from "@/lib/utils";
// 思考强度档位(仅在开关打开时生效)。
// 思考开关(thinking.type)与思考强度(reasoning_effort)是两个【互相独立】的字段,
// 各自单独设置——有些接口没有 thinking 字段、只靠强度参数就能激活思考,故需解耦。
// 存库空字符串 = 该字段【不发送】;Radix Select 不接受空 value,故 UI 用 "none"
// 哨兵表示不发送,存取时与 "" 互转(NONE / fromStore / toStore)。
const NONE = "none";
const fromStore = (v?: string) => (v ? v : NONE);
const toStore = (v: string) => (v === NONE ? "" : v);
const THINKING_TYPES: { value: string; label: string }[] = [
{ value: NONE, label: "不发送(默认)" },
{ value: "disabled", label: "关闭" },
{ value: "enabled", label: "开启" },
];
const EFFORT_LEVELS: { value: string; label: string }[] = [
{ value: "low", label: "低" },
{ value: "medium", label: "中" },
{ value: "high", label: "高" },
{ value: "max", label: "最大" },
{ value: NONE, label: "不发送(默认)" },
{ value: "low", label: "low" },
{ value: "medium", label: "medium" },
{ value: "high", label: "high" },
{ value: "xhigh", label: "xhigh" },
{ value: "max", label: "max" },
];
// 思考模式三态 ↔ 存库字符串 reasoning_effort:
// none→""(thinking / reasoning_effort 两个字段都【不发送】,兼容不支持该字段的模型,如 MiniMax)
// off →"off"(发送 thinking:{type:disabled},用于默认开启思考、需要显式关闭的模型)
// on →强度值(发送 thinking:{enabled} + reasoning_effort)
type ThinkMode = "none" | "off" | "on";
const THINK_MODES: { value: ThinkMode; label: string }[] = [
{ value: "none", label: "不发送(默认)" },
{ value: "off", label: "显式关闭" },
{ value: "on", label: "开启" },
];
const toStore = (mode: ThinkMode, effort: string) => (mode === "on" ? effort : mode === "off" ? "off" : "");
const modeFromStore = (v?: string): ThinkMode => (!v ? "none" : v === "off" ? "off" : "on");
const effortFromStore = (v?: string) => (v && v !== "off" ? v : "high");
function cooldownText(secs: number) {
if (secs <= 0) return "";
@@ -305,8 +305,8 @@ function ProfileSheet({
const [rps, setRps] = React.useState("0");
const [rpm, setRpm] = React.useState("0");
const [cw, setCw] = React.useState("0"); // 上下文窗口(K tokens);0=默认200K
const [thinkMode, setThinkMode] = React.useState<ThinkMode>("none");
const [effort, setEffort] = React.useState("high");
const [thinkingType, setThinkingType] = React.useState(NONE);
const [effort, setEffort] = React.useState(NONE);
const [priority, setPriority] = React.useState("0"); // 轮询顺位;越大越先
const [poolExclude, setPoolExclude] = React.useState(false);
const [testing, setTesting] = React.useState(false);
@@ -327,8 +327,8 @@ function ProfileSheet({
setRps(String(profile?.rate_per_second ?? 0));
setRpm(String(profile?.rate_per_minute ?? 0));
setCw(String(profile?.context_window_k ?? 0));
setThinkMode(modeFromStore(profile?.reasoning_effort));
setEffort(effortFromStore(profile?.reasoning_effort));
setThinkingType(fromStore(profile?.thinking_type));
setEffort(fromStore(profile?.reasoning_effort));
setPriority(String(profile?.priority ?? 0));
setPoolExclude(profile?.pool_exclude ?? false);
setApiKey("");
@@ -365,7 +365,7 @@ function ProfileSheet({
try {
// 用配置实际会跑的思考参数来测,这样不支持该字段的模型在这里就失败,
// 而不是等到跑任务时才炸。传 profile id:Key 输入框留空时用已存的 Key。
const r = await api.testLLM(format, model, baseUrl, apiKey, proxy, toStore(thinkMode, effort), profileId);
const r = await api.testLLM(format, model, baseUrl, apiKey, proxy, toStore(thinkingType), toStore(effort), profileId);
if (r.ok) toast.success(`连接成功 · ${r.latency_ms ?? "?"}ms · ${r.model ?? model}`);
else toast.error(`连接失败:${r.error ?? "未知"}`);
} catch (e) {
@@ -394,7 +394,8 @@ function ProfileSheet({
rate_per_second: Number(rps) || 0,
rate_per_minute: Number(rpm) || 0,
context_window_k: Number(cw) || 0,
reasoning_effort: toStore(thinkMode, effort),
thinking_type: toStore(thinkingType),
reasoning_effort: toStore(effort),
priority: Number(priority) || 0,
pool_exclude: poolExclude,
});
@@ -601,18 +602,18 @@ function ProfileSheet({
<div className="grid gap-3 rounded-lg border p-3">
<div className="flex items-center justify-between gap-4">
<div className="grid gap-0.5">
<Label className="text-sm">思考模式 · Extended Thinking</Label>
<Label className="text-sm">思考开关 · thinking.type</Label>
<p className="text-muted-foreground text-xs">
不发送=不带思考字段(兼容 MiniMax 等不支持该字段的模型);显式关闭=发
disabled(默认开思考的模型才需要);开启=先推理再作答。
控制是否发送 thinking 字段。不发送=不带该字段(兼容 MiniMax 等不支持
的模型);关闭=发 disabled;开启=发 enabled。与下面的强度互相独立。
</p>
</div>
<Select value={thinkMode} onValueChange={(v) => setThinkMode(v as ThinkMode)}>
<Select value={thinkingType} onValueChange={setThinkingType}>
<SelectTrigger className="w-32 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{THINK_MODES.map((o) => (
{THINKING_TYPES.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
@@ -620,28 +621,27 @@ function ProfileSheet({
</SelectContent>
</Select>
</div>
{thinkMode === "on" && (
<div className="flex items-center justify-between gap-4 border-t pt-3">
<div className="grid gap-0.5">
<Label className="text-sm">思考强度</Label>
<p className="text-muted-foreground text-xs">
推理投入档位,越高越深入(OpenAI reasoning_effort / Anthropic output_config.effort)。
</p>
</div>
<Select value={effort} onValueChange={setEffort}>
<SelectTrigger className="w-28 shrink-0">
<SelectValue placeholder="强度" />
</SelectTrigger>
<SelectContent>
{EFFORT_LEVELS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center justify-between gap-4 border-t pt-3">
<div className="grid gap-0.5">
<Label className="text-sm">思考强度 · reasoning_effort</Label>
<p className="text-muted-foreground text-xs">
独立的强度档位(OpenAI reasoning_effort / Anthropic output_config.effort)。
有些接口没有 thinking 字段、只靠强度即可激活思考,故可单独设置、不发送思考开关。
</p>
</div>
)}
<Select value={effort} onValueChange={setEffort}>
<SelectTrigger className="w-32 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EFFORT_LEVELS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</div>
+425 -62
View File
@@ -12,13 +12,25 @@ import {
PlusIcon,
Trash2Icon,
UploadIcon,
AlertTriangleIcon,
} from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import {
@@ -31,7 +43,17 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { api } from "@/lib/api";
import type { Agent, SkillItem, MCPServer } from "@/lib/types";
import type { Agent, SkillItem, MCPServer, SkillCall, MissingSkill } from "@/lib/types";
function fmtTime(ts?: string) {
if (!ts) return "从未调用";
return new Date(ts).toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
// ── Tree node ──────────────────────────────────────────────────────────────
interface TreeNode {
@@ -85,9 +107,22 @@ function buildTree(entries: string[]): TreeNode[] {
if (fname) nodes.push({ name: fname, path: entry, type: "file", children: [] });
}
}
sortNodes(root);
return root;
}
// sortNodes orders every level like a file explorer: directories before files,
// then case-insensitive by name. Recurses into children.
function sortNodes(nodes: TreeNode[]): void {
nodes.sort((a, b) => {
if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
});
for (const n of nodes) {
if (n.children.length > 0) sortNodes(n.children);
}
}
// ── State types ───────────────────────────────────────────────────────────
type Selected =
| { skill: string; path: null }
@@ -99,6 +134,164 @@ type Creating = {
kind: "file" | "dir";
} | null;
type PendingDelete =
| { kind: "skill"; skill: string }
| { kind: "file"; skill: string; path: string }
| { kind: "dir"; skill: string; path: string }
| null;
// ── Overview (empty-state) ──────────────────────────────────────────────────
// Shown when nothing is selected: a library-wide snapshot from data already loaded
// (the skill list carries per-skill usage; missing is fetched alongside). No extra
// requests — this is pure aggregation over props.
function SkillsOverview({
skills,
missing,
onSelect,
}: {
skills: SkillItem[];
missing: MissingSkill[];
onSelect: (name: string) => void;
}) {
const agg = React.useMemo(() => {
const totalCalls = skills.reduce((n, s) => n + s.calls, 0);
const used = skills.filter((s) => s.calls > 0);
const ranked = [...used].sort((a, b) => b.calls - a.calls);
const neverUsed = skills.filter((s) => s.calls === 0);
const recent = skills
.filter((s) => s.last_used)
.sort((a, b) => (a.last_used! < b.last_used! ? 1 : -1))
.slice(0, 6);
const missingCalls = missing.reduce((n, m) => n + m.calls, 0);
return {
totalCalls,
usedCount: used.length,
ranked,
topCalls: ranked[0]?.calls ?? 0,
neverUsed,
recent,
missingCalls,
};
}, [skills, missing]);
if (skills.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">暂无 Skill,点击左侧「新建」或「上传压缩包」开始</p>
</div>
);
}
const stats: { label: string; value: React.ReactNode; hint?: string }[] = [
{ label: "Skill 总数", value: skills.length, hint: `${agg.usedCount} 个被调用过` },
{ label: "累计调用", value: agg.totalCalls },
{ label: "未使用", value: agg.neverUsed.length, hint: agg.neverUsed.length > 0 ? "从未被任何 agent 加载" : "全部用过" },
{ label: "未命中调用", value: agg.missingCalls, hint: missing.length > 0 ? `${missing.length} 个不存在的 skill` : "无" },
];
return (
<div className="mx-auto w-full max-w-3xl space-y-6">
<div>
<h2 className="text-base font-semibold">技能库总览</h2>
<p className="text-muted-foreground text-sm">选择左侧的 Skill 查看详情与调用记录,或从这里快速了解整体使用情况。</p>
</div>
{/* 指标卡 */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{stats.map((s) => (
<div key={s.label} className="rounded-lg border p-3">
<p className="text-2xl font-semibold tabular-nums">{s.value}</p>
<p className="text-xs font-medium">{s.label}</p>
{s.hint && <p className="text-muted-foreground mt-0.5 text-[11px]">{s.hint}</p>}
</div>
))}
</div>
{/* 调用排行 */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">调用排行</Label>
{agg.ranked.length === 0 ? (
<p className="text-muted-foreground rounded-lg border border-dashed p-4 text-center text-xs">
还没有任何 Skill 调用记录。
</p>
) : (
<div className="space-y-1.5">
{agg.ranked.slice(0, 8).map((s) => (
<button
key={s.name}
type="button"
onClick={() => onSelect(s.name)}
className="group flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-left hover:bg-muted"
>
<span className="w-40 shrink-0 truncate font-mono text-xs" title={s.name}>{s.name}</span>
<span className="relative h-2 flex-1 overflow-hidden rounded-full bg-muted">
<span
className="absolute inset-y-0 left-0 rounded-full bg-primary/70"
style={{ width: `${agg.topCalls > 0 ? (s.calls / agg.topCalls) * 100 : 0}%` }}
/>
</span>
<span className="w-16 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{s.calls} 次
</span>
</button>
))}
</div>
)}
</div>
<div className="grid gap-6 sm:grid-cols-2">
{/* 最近调用 */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">最近调用</Label>
{agg.recent.length === 0 ? (
<p className="text-muted-foreground text-xs">暂无记录。</p>
) : (
<div className="space-y-1">
{agg.recent.map((s) => (
<button
key={s.name}
type="button"
onClick={() => onSelect(s.name)}
className="flex w-full items-center gap-2 rounded px-1.5 py-1 text-left hover:bg-muted"
>
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={s.name}>{s.name}</span>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground">{fmtTime(s.last_used)}</span>
</button>
))}
</div>
)}
</div>
{/* 未使用(可清理 / 需曝光) */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
未使用的 Skill
{agg.neverUsed.length > 0 && <span className="ml-1 font-normal">({agg.neverUsed.length})</span>}
</Label>
{agg.neverUsed.length === 0 ? (
<p className="text-muted-foreground text-xs">所有 Skill 都被调用过。</p>
) : (
<div className="flex flex-wrap gap-1.5">
{agg.neverUsed.map((s) => (
<button
key={s.name}
type="button"
onClick={() => onSelect(s.name)}
title={s.name}
>
<Badge variant="outline" className="max-w-[12rem] cursor-pointer truncate font-mono text-xs font-normal hover:bg-muted">
{s.name}
</Badge>
</button>
))}
</div>
)}
</div>
</div>
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────
export default function SkillsPage() {
const [skills, setSkills] = React.useState<SkillItem[]>([]);
@@ -137,10 +330,21 @@ export default function SkillsPage() {
// skill detail — MCP edit state (optimistic, rolls back on error)
const [detailMcps, setDetailMcps] = React.useState<string[]>([]);
// delete confirmation
const [pendingDelete, setPendingDelete] = React.useState<PendingDelete>(null);
const [deleting, setDeleting] = React.useState(false);
// 调用统计:列表页的次数/最近调用随 api.skills() 一起回来;选中某个 skill 时再拉它的
// 最近调用明细。missing = 被点名但不存在的 skill(想用但没有)。
const [usageCalls, setUsageCalls] = React.useState<SkillCall[]>([]);
const [usageLoading, setUsageLoading] = React.useState(false);
const [missing, setMissing] = React.useState<MissingSkill[]>([]);
// ── Data ──────────────────────────────────────────────────────────────────
const load = React.useCallback(() => {
api.agents().then(setAgents).catch(() => {});
api.mcpServers().then(setMcpOptions).catch(() => {});
api.missingSkills().then(setMissing).catch(() => {});
api.skills().then((ss) => {
setSkills(ss);
ss.forEach((s) =>
@@ -195,6 +399,17 @@ export default function SkillsPage() {
setDetailMcps(sk?.mcps ?? []);
}, [selected, skills]);
// Recent calls for the selected skill (detail panel only).
React.useEffect(() => {
if (!selected || selected.path !== null) { setUsageCalls([]); return; }
const name = selected.skill;
setUsageLoading(true);
api.skillUsage(name, 20)
.then((calls) => setUsageCalls(calls))
.catch(() => setUsageCalls([]))
.finally(() => setUsageLoading(false));
}, [selected]);
React.useEffect(() => {
if (!selected || selected.path === null) { setFileContent(""); setDirty(false); return; }
setFileLoading(true);
@@ -296,6 +511,19 @@ export default function SkillsPage() {
}
}
async function runPendingDelete() {
const p = pendingDelete;
if (!p) return;
setDeleting(true);
try {
if (p.kind === "skill") await deleteSkill(p.skill);
else await deletePath(p.skill, p.path);
} finally {
setDeleting(false);
setPendingDelete(null);
}
}
async function saveFile() {
if (!selected || selected.path === null) return;
setSaving(true);
@@ -392,7 +620,9 @@ export default function SkillsPage() {
// ── Recursive tree renderer ───────────────────────────────────────────────
function renderTree(nodes: TreeNode[], skill: string, depth: number): React.ReactNode {
const baseIndent = 8 + depth * 14;
// depth+1: the skill root sits at 8px (px-2); its children indent one step
// further in so the tree reads as nested under the skill folder.
const baseIndent = 8 + (depth + 1) * 14;
return nodes.map((node) => {
if (node.type === "dir") {
const key = `${skill}:${node.path}`;
@@ -400,7 +630,7 @@ export default function SkillsPage() {
return (
<div key={node.path}>
<div
className="group flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm hover:bg-muted"
className="group relative flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm hover:bg-muted"
style={{ paddingLeft: baseIndent }}
onClick={() => toggleExpanded(key)}
>
@@ -409,8 +639,9 @@ export default function SkillsPage() {
? <FolderOpenIcon className="size-3.5 shrink-0 text-amber-500" />
: <FolderIcon className="size-3.5 shrink-0 text-amber-500" />
}
<span className="flex-1 truncate">{node.name}</span>
<span className="ml-auto hidden shrink-0 items-center gap-0.5 group-hover:flex">
<span className="min-w-0 flex-1 truncate" title={node.path}>{node.name}</span>
{/* Absolute so a long name can never push the actions out of view */}
<span className="absolute inset-y-0 right-1 hidden items-center gap-0.5 rounded bg-muted pl-1 group-hover:flex">
<Button size="icon" variant="ghost" className="size-5" title="新建文件"
onClick={(e) => { e.stopPropagation(); startCreate(skill, node.path, "file"); }}>
<FilePlusIcon className="size-3 text-muted-foreground" />
@@ -420,7 +651,7 @@ export default function SkillsPage() {
<FolderPlusIcon className="size-3 text-muted-foreground" />
</Button>
<Button size="icon" variant="ghost" className="size-5" title="删除文件夹"
onClick={(e) => { e.stopPropagation(); deletePath(skill, node.path); }}>
onClick={(e) => { e.stopPropagation(); setPendingDelete({ kind: "dir", skill, path: node.path }); }}>
<Trash2Icon className="size-3 text-destructive" />
</Button>
</span>
@@ -442,19 +673,24 @@ export default function SkillsPage() {
<div
key={node.path}
className={cn(
"group flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm",
"group relative flex cursor-pointer select-none items-center gap-1 rounded py-0.5 pr-1 text-sm",
isSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
)}
style={{ paddingLeft: baseIndent + 16 }}
onClick={() => setSelected({ skill, path: node.path })}
>
<FileTextIcon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate font-mono text-xs">{node.name}</span>
<Button size="icon" variant="ghost" className="ml-auto hidden size-5 group-hover:flex"
title="删除文件"
onClick={(e) => { e.stopPropagation(); deletePath(skill, node.path); }}>
<Trash2Icon className="size-3 text-destructive" />
</Button>
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={node.path}>{node.name}</span>
<span className={cn(
"absolute inset-y-0 right-1 hidden items-center rounded pl-1 group-hover:flex",
isSelected ? "bg-accent" : "bg-muted",
)}>
<Button size="icon" variant="ghost" className="size-5"
title="删除文件"
onClick={(e) => { e.stopPropagation(); setPendingDelete({ kind: "file", skill, path: node.path }); }}>
<Trash2Icon className="size-3 text-destructive" />
</Button>
</span>
</div>
);
});
@@ -466,9 +702,36 @@ export default function SkillsPage() {
return (
<div data-content-padding="false" className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-col gap-0.5 border-b px-4 py-2.5 lg:px-6">
<h1 className="text-sm font-semibold leading-tight">Skill</h1>
<p className="text-muted-foreground text-xs">技能库 · agentskills.io 规范 · 按 Agent 授权可见</p>
<div className="flex items-center gap-3 border-b px-4 py-2.5 lg:px-6">
<div className="flex flex-col gap-0.5">
<h1 className="text-sm font-semibold leading-tight">Skill</h1>
<p className="text-muted-foreground text-xs">技能库 · agentskills.io 规范 · 按 Agent 授权可见</p>
</div>
{/* 缺口清单:agent 点名调用、但库里没有的 skill —— 直接是该补什么的依据。 */}
{missing.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<Button size="sm" variant="outline" className="ml-auto">
<AlertTriangleIcon className="size-3.5 text-amber-500" />
{missing.length} 个未命中调用
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-80">
<p className="mb-2 text-xs text-muted-foreground">
Agent 点名调用、但技能库里不存在的 skill。按被点名次数排序。
</p>
<div className="space-y-1">
{missing.map((m) => (
<div key={m.skill} className="flex items-center gap-2 text-sm">
<code className="min-w-0 flex-1 truncate font-mono text-xs" title={m.skill}>{m.skill}</code>
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">{m.calls} 次</span>
<span className="shrink-0 text-xs text-muted-foreground">{fmtTime(m.last_used)}</span>
</div>
))}
</div>
</PopoverContent>
</Popover>
)}
</div>
<div className="flex flex-1 overflow-hidden">
{/* ── 左侧文件树 ── */}
@@ -496,7 +759,10 @@ export default function SkillsPage() {
{uploading ? "上传中…" : "上传压缩包"}
</Button>
</div>
<ScrollArea className="flex-1">
{/* Radix viewport wraps children in a display:table div that grows with
content — force it to block so long names truncate instead of
widening the rows past the sidebar. */}
<ScrollArea className="flex-1 [&>[data-slot=scroll-area-viewport]>div]:!block">
<div className="p-1">
{skills.map((s) => {
const isOpen = expanded.has(s.name);
@@ -507,7 +773,7 @@ export default function SkillsPage() {
{/* skill 根节点 */}
<div
className={cn(
"group flex cursor-pointer select-none items-center gap-1 rounded px-2 py-1 text-sm",
"group relative flex cursor-pointer select-none items-center gap-1 rounded px-2 py-1 text-sm",
isSkillSelected ? "bg-accent text-accent-foreground" : "hover:bg-muted",
)}
onClick={() => {
@@ -520,8 +786,19 @@ export default function SkillsPage() {
? <FolderOpenIcon className="size-3.5 shrink-0 text-blue-500" />
: <FolderIcon className="size-3.5 shrink-0 text-blue-500" />
}
<span className="flex-1 truncate font-semibold">{s.name}</span>
<span className="ml-auto hidden shrink-0 items-center gap-0.5 group-hover:flex">
<span className="min-w-0 flex-1 truncate font-semibold" title={s.name}>{s.name}</span>
{s.calls > 0 && (
<span
className="shrink-0 rounded bg-muted px-1 text-[10px] tabular-nums text-muted-foreground"
title={`被调用 ${s.calls} 次 · 最近 ${fmtTime(s.last_used)}`}
>
{s.calls}
</span>
)}
<span className={cn(
"absolute inset-y-0 right-1 hidden items-center gap-0.5 rounded pl-1 group-hover:flex",
isSkillSelected ? "bg-accent" : "bg-muted",
)}>
<Button size="icon" variant="ghost" className="size-5" title="新建文件"
onClick={(e) => { e.stopPropagation(); startCreate(s.name, "", "file"); }}>
<FilePlusIcon className="size-3 text-muted-foreground" />
@@ -531,7 +808,7 @@ export default function SkillsPage() {
<FolderPlusIcon className="size-3 text-muted-foreground" />
</Button>
<Button size="icon" variant="ghost" className="size-5" title="删除 Skill"
onClick={(e) => { e.stopPropagation(); deleteSkill(s.name); }}>
onClick={(e) => { e.stopPropagation(); setPendingDelete({ kind: "skill", skill: s.name }); }}>
<Trash2Icon className="size-3 text-destructive" />
</Button>
</span>
@@ -559,13 +836,15 @@ export default function SkillsPage() {
{/* ── 右侧面板 ── */}
<div className="flex flex-1 flex-col overflow-auto p-4">
{!selected && (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">选择左侧 Skill 或文件</p>
</div>
<SkillsOverview
skills={skills}
missing={missing}
onSelect={(name) => setSelected({ skill: name, path: null })}
/>
)}
{selected && selected.path === null && selectedSkill && (
<div className="max-w-xl space-y-5">
<div className="max-w-5xl space-y-5">
<div>
<h2 className="font-mono text-base font-semibold">{selectedSkill.name}</h2>
{selectedSkill.description && (
@@ -581,45 +860,100 @@ export default function SkillsPage() {
</div>
</div>
{/* ── 关联 MCP ── */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
关联 MCP
<span className="ml-1 font-normal">(加载 Skill 时才披露/解锁其工具)</span>
</Label>
{mcpOptions.length === 0 ? (
<p className="text-xs text-muted-foreground">暂无 MCP,可在「MCP」页添加。</p>
) : (
<div className="flex flex-wrap gap-x-4 gap-y-2">
{mcpOptions.map((m) => (
<label key={m.id} className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={detailMcps.includes(m.name)}
onCheckedChange={(on) => toggleSkillMcp(selectedSkill.name, m.name, !!on)}
/>
{m.name}
</label>
))}
{/* 左右分栏:配置(MCP/可见性)在左为主,调用统计在右为辅。
lg 以下放不下时用 flex-row-reverse 回落到单列——统计因 DOM 顺序在前,
窄屏时自然落到配置上方(与改版前的上下顺序一致)。 */}
<div className="flex flex-col gap-6 lg:flex-row-reverse lg:items-start">
{/* ── 右侧:调用统计 ── */}
<div className="space-y-2 lg:w-80 lg:shrink-0">
<Label className="text-xs text-muted-foreground">调用统计</Label>
<div className="grid grid-cols-3 gap-2">
<div className="rounded-md border p-2">
<p className="text-lg font-semibold tabular-nums">{selectedSkill.calls}</p>
<p className="text-xs text-muted-foreground">总调用次数</p>
</div>
<div className="rounded-md border p-2">
<p className="text-lg font-semibold tabular-nums">{selectedSkill.tasks}</p>
<p className="text-xs text-muted-foreground">覆盖任务数</p>
</div>
<div className="rounded-md border p-2">
<p className="truncate text-sm font-medium" title={fmtTime(selectedSkill.last_used)}>
{fmtTime(selectedSkill.last_used)}
</p>
<p className="text-xs text-muted-foreground">最近调用</p>
</div>
</div>
)}
</div>
{/* ── 可见性 ── */}
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">可见性(按 Agent 授权)</Label>
<div className="space-y-2">
{agents.map((a) => (
<label key={a.key} className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={(visibility[selectedSkill.name] ?? []).includes(a.id)}
onCheckedChange={() => toggleVisibility(selectedSkill.name, a.id, a.name)}
/>
{a.name}
</label>
))}
{agents.length === 0 && (
<span className="text-xs text-muted-foreground">(暂无 Agent)</span>
{selectedSkill.usage_agents.length > 0 && (
<div className="flex flex-wrap items-center gap-1">
<span className="text-xs text-muted-foreground">调用方:</span>
{selectedSkill.usage_agents.map((k) => (
<Badge key={k} variant="secondary" className="text-xs font-normal">{k}</Badge>
))}
</div>
)}
{usageLoading ? (
<p className="text-xs text-muted-foreground">加载调用明细…</p>
) : usageCalls.length > 0 ? (
<div className="rounded-md border">
<div className="border-b px-2 py-1 text-xs text-muted-foreground">最近 {usageCalls.length} 次调用</div>
<div className="max-h-56 overflow-y-auto">
{usageCalls.map((c, i) => (
<div key={`${c.ts}-${i}`} className="flex items-center gap-2 border-b px-2 py-1 text-xs last:border-b-0">
<span className="tabular-nums text-muted-foreground">{fmtTime(c.ts)}</span>
<Badge variant="outline" className="font-normal">{c.agent_key || "—"}</Badge>
<span className="ml-auto text-muted-foreground">
{c.task_id > 0 ? `任务 #${c.task_id}` : c.session_id ? "对话会话" : "—"}
</span>
</div>
))}
</div>
</div>
) : (
<p className="text-xs text-muted-foreground">还没有调用记录。</p>
)}
</div>
{/* ── 左侧:关联 MCP + 可见性 ── */}
<div className="space-y-5 lg:min-w-0 lg:flex-1">
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">
关联 MCP
<span className="ml-1 font-normal">(加载 Skill 时才披露/解锁其工具)</span>
</Label>
{mcpOptions.length === 0 ? (
<p className="text-xs text-muted-foreground">暂无 MCP,可在「MCP」页添加。</p>
) : (
<div className="flex flex-wrap gap-x-4 gap-y-2">
{mcpOptions.map((m) => (
<label key={m.id} className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={detailMcps.includes(m.name)}
onCheckedChange={(on) => toggleSkillMcp(selectedSkill.name, m.name, !!on)}
/>
{m.name}
</label>
))}
</div>
)}
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">可见性(按 Agent 授权)</Label>
<div className="space-y-2">
{agents.map((a) => (
<label key={a.key} className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={(visibility[selectedSkill.name] ?? []).includes(a.id)}
onCheckedChange={() => toggleVisibility(selectedSkill.name, a.id, a.name)}
/>
{a.name}
</label>
))}
{agents.length === 0 && (
<span className="text-xs text-muted-foreground">(暂无 Agent)</span>
)}
</div>
</div>
</div>
</div>
</div>
@@ -649,6 +983,35 @@ export default function SkillsPage() {
</div>
</div>
{/* ── 删除二次确认 ── */}
<AlertDialog open={!!pendingDelete} onOpenChange={(o) => { if (!o) setPendingDelete(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{pendingDelete?.kind === "skill" && `删除 Skill「${pendingDelete.skill}」?`}
{pendingDelete?.kind === "dir" && `删除文件夹「${pendingDelete.path}」?`}
{pendingDelete?.kind === "file" && `删除文件「${pendingDelete.path}」?`}
</AlertDialogTitle>
<AlertDialogDescription>
{pendingDelete?.kind === "skill"
? "将删除该 Skill 的全部文件、MCP 关联与可见性配置。此操作不可撤销。"
: pendingDelete?.kind === "dir"
? "将一并删除该文件夹下的所有文件。此操作不可撤销。"
: "此操作不可撤销。"}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
<AlertDialogAction
disabled={deleting}
onClick={(e) => { e.preventDefault(); void runPendingDelete(); }}
>
{deleting ? "删除中…" : "删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* ── 新建 Skill 对话框 ── */}
<Sheet open={newOpen} onOpenChange={setNewOpen}>
<SheetContent side="right" className="flex w-full flex-col gap-0 data-[side=right]:sm:max-w-xl">