init
This commit is contained in:
1 parent
9793b522d9
commit
b7e0b93023
46 files changed
+3632
-899
No files matched your search
@@ -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}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn, copyText } from "@/lib/utils";
|
||||
|
||||
type CopyButtonProps = {
|
||||
// 要复制的文本;为空则按钮禁用。
|
||||
text: string | null | undefined;
|
||||
// 复制成功后的 toast 文案,默认「已复制」。
|
||||
successMessage?: string;
|
||||
label?: React.ReactNode;
|
||||
size?: React.ComponentProps<typeof Button>["size"];
|
||||
variant?: React.ComponentProps<typeof Button>["variant"];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
// CopyButton 统一的「复制到剪贴板」按钮:内置成功/失败反馈,并在 HTTP 非安全上下文
|
||||
// 下自动降级(见 copyText)。
|
||||
export function CopyButton({
|
||||
text,
|
||||
successMessage = "已复制",
|
||||
label = "复制",
|
||||
size = "sm",
|
||||
variant = "outline",
|
||||
className,
|
||||
}: CopyButtonProps) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function handleCopy() {
|
||||
if (!text) return;
|
||||
const ok = await copyText(text);
|
||||
if (ok) {
|
||||
setCopied(true);
|
||||
toast.success(successMessage);
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setCopied(false), 1500);
|
||||
} else {
|
||||
toast.error("复制失败,请手动选择文本复制");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
size={size}
|
||||
variant={variant}
|
||||
className={cn(className)}
|
||||
disabled={!text}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
+264
-94
@@ -6,15 +6,63 @@
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import { mockHandle } from "@/lib/mock/handler";
|
||||
import type {
|
||||
Task, DeleteTaskOptions, DeleteTaskResult, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Severity, FindingStatus,
|
||||
FindingsPage, FindingStats, FindingQuery, Activity,
|
||||
Audit, TrafficResp, TrafficDetail, TrafficHost, Settings, LLMProfile, Agent, AgentDetail, PromptVar,
|
||||
PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket,
|
||||
Tool, Conversation, AgentTrigger, ChatAttachment,
|
||||
InterceptRule, InterceptPending, InterceptApprovalRow,
|
||||
TaskAssetView, SSProject, SSTask, ConvTokenSummary, CoverageGraphData, CoverageAssetRefs,
|
||||
WorkspaceListing, WorkspaceFile,
|
||||
CommandRecord, LLMRecordItem, LLMRecordDetail, LLMTask, LLMPoolStatus,
|
||||
Activity,
|
||||
Agent,
|
||||
AgentDetail,
|
||||
AgentTrigger,
|
||||
Asset,
|
||||
AssetNode,
|
||||
Audit,
|
||||
ChatAttachment,
|
||||
CommandRecord,
|
||||
Company,
|
||||
Conversation,
|
||||
ConvTokenSummary,
|
||||
CoverageAssetRefs,
|
||||
CoverageGraphData,
|
||||
DailyTokenBucket,
|
||||
DeleteTaskOptions,
|
||||
DeleteTaskResult,
|
||||
Edge,
|
||||
Finding,
|
||||
FindingQuery,
|
||||
FindingStats,
|
||||
FindingStatus,
|
||||
FindingsPage,
|
||||
InterceptApprovalRow,
|
||||
InterceptPending,
|
||||
InterceptRule,
|
||||
LLMPoolStatus,
|
||||
LLMProfile,
|
||||
LLMRecordDetail,
|
||||
LLMRecordItem,
|
||||
LLMTask,
|
||||
MCPServer,
|
||||
MCPTool,
|
||||
ModelTokenStat,
|
||||
PromptVar,
|
||||
PromptVersion,
|
||||
Settings,
|
||||
Severity,
|
||||
SkillCall,
|
||||
SkillItem,
|
||||
MissingSkill,
|
||||
SSProject,
|
||||
SSTask,
|
||||
Stats,
|
||||
Task,
|
||||
TaskAssetView,
|
||||
TaskNode,
|
||||
TaskScopeRow,
|
||||
TokenTotal,
|
||||
TokenUsage,
|
||||
Tool,
|
||||
TrafficDetail,
|
||||
TrafficHost,
|
||||
TrafficResp,
|
||||
UsageStats,
|
||||
WorkspaceFile,
|
||||
WorkspaceListing,
|
||||
} from "@/lib/types";
|
||||
|
||||
function getToken(): string | null {
|
||||
@@ -92,19 +140,21 @@ function mockReport(_task?: string): string {
|
||||
export function sseUrl(path: string): string {
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_SSE_BASE ??
|
||||
(typeof window !== "undefined"
|
||||
? `${window.location.protocol}//${window.location.hostname}:8787`
|
||||
: "");
|
||||
(typeof window !== "undefined" ? `${window.location.protocol}//${window.location.hostname}:8787` : "");
|
||||
const token = getToken();
|
||||
const sep = path.includes("?") ? "&" : "?";
|
||||
return token ? `${base}${path}${sep}token=${encodeURIComponent(token)}` : `${base}${path}`;
|
||||
}
|
||||
|
||||
const get = <T>(p: string) => http<T>(p);
|
||||
const post = <T>(p: string, body?: unknown) => http<T>(p, { method: "POST", body: body ? JSON.stringify(body) : undefined });
|
||||
const put = <T>(p: string, body?: unknown) => http<T>(p, { method: "PUT", body: body ? JSON.stringify(body) : undefined });
|
||||
const patch = <T>(p: string, body?: unknown) => http<T>(p, { method: "PATCH", body: body ? JSON.stringify(body) : undefined });
|
||||
const del = <T>(p: string, body?: unknown) => http<T>(p, { method: "DELETE", body: body ? JSON.stringify(body) : undefined });
|
||||
const post = <T>(p: string, body?: unknown) =>
|
||||
http<T>(p, { method: "POST", body: body ? JSON.stringify(body) : undefined });
|
||||
const put = <T>(p: string, body?: unknown) =>
|
||||
http<T>(p, { method: "PUT", body: body ? JSON.stringify(body) : undefined });
|
||||
const patch = <T>(p: string, body?: unknown) =>
|
||||
http<T>(p, { method: "PATCH", body: body ? JSON.stringify(body) : undefined });
|
||||
const del = <T>(p: string, body?: unknown) =>
|
||||
http<T>(p, { method: "DELETE", body: body ? JSON.stringify(body) : undefined });
|
||||
|
||||
// Go serializes nil slices as JSON null — coerce to [].
|
||||
const arr = <T>(x: T[] | null | undefined): T[] => x ?? [];
|
||||
@@ -116,15 +166,14 @@ export const api = {
|
||||
|
||||
// ---- auth ----
|
||||
authStatus: () => get<{ initialized: boolean }>("/auth/status"),
|
||||
login: (username: string, password: string) =>
|
||||
post<{ token: string }>("/auth/login", { username, password }),
|
||||
initPassword: (password: string) =>
|
||||
post<{ token: string }>("/auth/init", { password }),
|
||||
login: (username: string, password: string) => post<{ token: string }>("/auth/login", { username, password }),
|
||||
initPassword: (password: string) => post<{ token: string }>("/auth/init", { password }),
|
||||
changePassword: (oldPassword: string, newPassword: string) =>
|
||||
post<{ ok: boolean }>("/auth/change-password", { old_password: oldPassword, new_password: newPassword }),
|
||||
|
||||
// ---- tasks ----
|
||||
tasks: () => get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })),
|
||||
tasks: () =>
|
||||
get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })),
|
||||
createTask: (input: {
|
||||
description: string;
|
||||
goal: string;
|
||||
@@ -155,9 +204,9 @@ export const api = {
|
||||
llm_profile_ids: llmProfileIds,
|
||||
active_llm_profile_id: activeLLMProfileId ?? null,
|
||||
}),
|
||||
deleteTask: (id: string, options: DeleteTaskOptions) =>
|
||||
del<DeleteTaskResult>(`/tasks/${id}`, options),
|
||||
controlTask: (id: string, action: "pause" | "resume") => post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }),
|
||||
deleteTask: (id: string, options: DeleteTaskOptions) => del<DeleteTaskResult>(`/tasks/${id}`, options),
|
||||
controlTask: (id: string, action: "pause" | "resume") =>
|
||||
post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }),
|
||||
controlIntent: (taskId: string, intentId: string, action: "pause" | "resume" | "cancel") =>
|
||||
post<{
|
||||
id: number;
|
||||
@@ -165,7 +214,8 @@ export const api = {
|
||||
deleted?: { intents: number; facts: number; findings: number; activities: number };
|
||||
}>(`/tasks/${taskId}/intents/${intentId}/control`, { action }),
|
||||
// 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。
|
||||
rerunIntent: (taskId: string, intentId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`),
|
||||
rerunIntent: (taskId: string, intentId: string) =>
|
||||
post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`),
|
||||
// 批量重跑本任务全部 blocked 意图(一次网络/LLM 断连导致多条 blocked 时一键全部重试)。
|
||||
rerunBlocked: (taskId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/rerun-blocked`),
|
||||
setActive: (id: string) => post<{ active: string }>("/active", { id }),
|
||||
@@ -181,23 +231,26 @@ export const api = {
|
||||
by_type: { type: string; total: number; tested: number }[];
|
||||
}>(`/tasks/${id}/coverage`),
|
||||
// 资产覆盖图:范围内全部资产 + 连接用的根域名/公司节点,含 tested/in_scope。
|
||||
taskCoverageGraph: (id: string) =>
|
||||
get<CoverageGraphData>(`/tasks/${id}/coverage-graph`),
|
||||
taskCoverageGraph: (id: string) => get<CoverageGraphData>(`/tasks/${id}/coverage-graph`),
|
||||
// 全局 llm_usage 聚合(仪表盘新版 token 视图):按 profile 总量 + 按天分桶。
|
||||
usageStats: (days = 365) => get<UsageStats>(`/tokens/usage?days=${days}`),
|
||||
// 本任务测试范围列表(含继承自来源任务的范围)。
|
||||
taskScope: (id: string) => get<{ scope: TaskScopeRow[] }>(`/tasks/${id}/scope`),
|
||||
// 手动新增一条测试范围(kind=company/root_domain/subdomain/ip/cidr)。
|
||||
addTaskScope: (id: string, kind: string, value: string, reason?: string) =>
|
||||
post<TaskScopeRow>(`/tasks/${id}/scope`, { kind, value, reason }),
|
||||
// 删除本任务的一条测试范围。
|
||||
deleteTaskScope: (id: string, scopeId: number) => del<{ ok: boolean }>(`/tasks/${id}/scope/${scopeId}`),
|
||||
// 某资产在本任务里关联的意图 / 事实 / 发现(覆盖图节点抽屉用)。
|
||||
taskAssetRefs: (id: string, assetId: number) =>
|
||||
get<CoverageAssetRefs>(`/tasks/${id}/asset-refs?asset_id=${assetId}`),
|
||||
taskAssetRefs: (id: string, assetId: number) => get<CoverageAssetRefs>(`/tasks/${id}/asset-refs?asset_id=${assetId}`),
|
||||
|
||||
// ---- workspace file manager (workDir) ----
|
||||
workspaceList: (path = "") =>
|
||||
get<WorkspaceListing>(`/workspace/list?path=${encodeURIComponent(path)}`),
|
||||
workspaceRead: (path: string) =>
|
||||
get<WorkspaceFile>(`/workspace/read?path=${encodeURIComponent(path)}`),
|
||||
workspaceList: (path = "") => get<WorkspaceListing>(`/workspace/list?path=${encodeURIComponent(path)}`),
|
||||
workspaceRead: (path: string) => get<WorkspaceFile>(`/workspace/read?path=${encodeURIComponent(path)}`),
|
||||
workspaceWrite: (path: string, content: string) =>
|
||||
post<{ ok: boolean; path: string }>(`/workspace/write`, { path, content }),
|
||||
workspaceMkdir: (path: string) =>
|
||||
post<{ ok: boolean; path: string }>(`/workspace/mkdir`, { path }),
|
||||
workspaceDelete: (path: string) =>
|
||||
del<{ ok: boolean }>(`/workspace/delete?path=${encodeURIComponent(path)}`),
|
||||
workspaceMkdir: (path: string) => post<{ ok: boolean; path: string }>(`/workspace/mkdir`, { path }),
|
||||
workspaceDelete: (path: string) => del<{ ok: boolean }>(`/workspace/delete?path=${encodeURIComponent(path)}`),
|
||||
workspaceUpload: async (dir: string, files: File[]) => {
|
||||
if (MOCK) return { uploaded: files.length };
|
||||
const fd = new FormData();
|
||||
@@ -236,8 +289,9 @@ export const api = {
|
||||
// ---- assets ----
|
||||
// Server-side paginated: pass limit/offset, get back the page + full match total.
|
||||
assets: (type = "", limit = 50, offset = 0) =>
|
||||
get<{ count: number; total: number; assets: Asset[] }>(`/assets?type=${type}&limit=${limit}&offset=${offset}`)
|
||||
.then((r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 })),
|
||||
get<{ count: number; total: number; assets: Asset[] }>(`/assets?type=${type}&limit=${limit}&offset=${offset}`).then(
|
||||
(r) => ({ assets: r?.assets ?? [], total: r?.total ?? r?.count ?? 0 }),
|
||||
),
|
||||
searchAssets: (dsl: string, type = "", limit = 50, offset = 0) =>
|
||||
get<{ count: number; total: number; assets: Asset[] }>(
|
||||
`/assets?dsl=${encodeURIComponent(dsl)}${type ? `&type=${encodeURIComponent(type)}` : ""}&limit=${limit}&offset=${offset}`,
|
||||
@@ -257,18 +311,38 @@ export const api = {
|
||||
createCompany: (name: string, logo: string, scope: string) =>
|
||||
post<{ id: number; created: boolean; scope_added?: number; scope_invalid?: number; scope_errors?: string[] }>(
|
||||
"/companies",
|
||||
{ name, logo, scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [] },
|
||||
{
|
||||
name,
|
||||
logo,
|
||||
scope: scope.trim()
|
||||
? scope
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
},
|
||||
),
|
||||
addCompanyScope: (id: number, scope: string, reason = "") =>
|
||||
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(
|
||||
`/companies/${id}/scope`,
|
||||
{ scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [], reason },
|
||||
),
|
||||
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(`/companies/${id}/scope`, {
|
||||
scope: scope.trim()
|
||||
? scope
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
reason,
|
||||
}),
|
||||
updateCompanyScope: (id: number, scope: string, reason = "") =>
|
||||
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(
|
||||
`/companies/${id}/scope`,
|
||||
{ scope: scope.trim() ? scope.split("\n").map((s) => s.trim()).filter(Boolean) : [], reason, reset: true },
|
||||
),
|
||||
post<{ added: number; skipped: number; invalid: number; errors?: string[] }>(`/companies/${id}/scope`, {
|
||||
scope: scope.trim()
|
||||
? scope
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: [],
|
||||
reason,
|
||||
reset: true,
|
||||
}),
|
||||
deleteCompany: (id: number, deleteAssets = false) =>
|
||||
http<{ deleted: number; assets_deleted: number }>(`/companies/${id}`, {
|
||||
method: "DELETE",
|
||||
@@ -288,17 +362,52 @@ export const api = {
|
||||
return get<FindingsPage>(`/exploration/findings?${p.toString()}`);
|
||||
},
|
||||
findingStats: () => get<FindingStats>("/exploration/findings/stats"),
|
||||
// exportFindings 触发发现页导出并下载文件。scope=selected 时传 ids(finding_id 列表);
|
||||
// scope=filtered 时传当前筛选(沿用 FindingQuery 的筛选字段);scope=all 忽略筛选。
|
||||
exportFindings: async (opts: {
|
||||
format: "md-single" | "md-zip" | "csv" | "json";
|
||||
scope: "filtered" | "all" | "selected";
|
||||
filters?: Omit<FindingQuery, "page" | "pageSize">;
|
||||
ids?: string[];
|
||||
}) => {
|
||||
const p = new URLSearchParams({ format: opts.format, scope: opts.scope });
|
||||
if (opts.scope === "selected") {
|
||||
p.set("ids", (opts.ids ?? []).join(","));
|
||||
} else if (opts.scope === "filtered" && opts.filters) {
|
||||
const f = opts.filters;
|
||||
if (f.severity && f.severity !== "all") p.set("severity", f.severity);
|
||||
if (f.status && f.status !== "all") p.set("status", f.status);
|
||||
if (f.vulnclass && f.vulnclass !== "all") p.set("vulnclass", f.vulnclass);
|
||||
if (f.task && f.task !== "all") p.set("task_id", f.task);
|
||||
if (f.sort) p.set("sort", f.sort);
|
||||
}
|
||||
const token = getToken();
|
||||
const r = await fetch(`/api/exploration/findings/export?${p.toString()}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!r.ok) throw new Error(`export: ${r.status}`);
|
||||
const blob = await r.blob();
|
||||
// 文件名优先取后端 Content-Disposition,取不到则用默认名。
|
||||
const disp = r.headers.get("Content-Disposition") ?? "";
|
||||
const m = disp.match(/filename="?([^"]+)"?/);
|
||||
const filename = m?.[1] ?? `findings-export`;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
getFinding: (id: string, contextTaskId?: string) =>
|
||||
get<Finding>(
|
||||
`/exploration/findings/${id}${contextTaskId ? `?context_task=${encodeURIComponent(contextTaskId)}` : ""}`,
|
||||
),
|
||||
// 漏洞链路:该漏洞节点回溯到任务初始节点的子图(节点 + 关系)。
|
||||
findingLineage: (id: string) =>
|
||||
get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`),
|
||||
setFindingStatus: (id: string, status: FindingStatus) =>
|
||||
patch<Finding>(`/exploration/findings/${id}`, { status }),
|
||||
setFindingSeverity: (id: string, severity: Severity) =>
|
||||
patch<Finding>(`/exploration/findings/${id}`, { severity }),
|
||||
findingLineage: (id: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`),
|
||||
setFindingStatus: (id: string, status: FindingStatus) => patch<Finding>(`/exploration/findings/${id}`, { status }),
|
||||
setFindingSeverity: (id: string, severity: Severity) => patch<Finding>(`/exploration/findings/${id}`, { severity }),
|
||||
// 一次保存漏洞的名称/类别/严重等级(发现列表行内编辑用),只传出现的字段。
|
||||
updateFinding: (
|
||||
id: string,
|
||||
@@ -312,10 +421,11 @@ export const api = {
|
||||
workers: arr(r.workers),
|
||||
total: r.total,
|
||||
})),
|
||||
tokenDaily: (days = 30) =>
|
||||
get<DailyTokenBucket[]>(`/tokens/daily?days=${days}`).then(arr),
|
||||
tokenDaily: (days = 30) => get<DailyTokenBucket[]>(`/tokens/daily?days=${days}`).then(arr),
|
||||
conversationTokens: () =>
|
||||
get<ConvTokenSummary[]>("/tokens/conversations").then(arr).catch(() => [] as ConvTokenSummary[]),
|
||||
get<ConvTokenSummary[]>("/tokens/conversations")
|
||||
.then(arr)
|
||||
.catch(() => [] as ConvTokenSummary[]),
|
||||
explorationGraph: (task?: string) => get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/graph${tq(task)}`),
|
||||
activity: (task?: string, opts?: { intent?: string; since?: number; limit?: number }) => {
|
||||
const q = new URLSearchParams();
|
||||
@@ -323,7 +433,10 @@ export const api = {
|
||||
if (opts?.intent) q.set("intent", opts.intent);
|
||||
if (opts?.since) q.set("since", String(opts.since));
|
||||
if (opts?.limit) q.set("limit", String(opts.limit));
|
||||
return get<{ items: Activity[]; cursor: number }>(`/exploration/activity?${q.toString()}`).then((r) => ({ items: arr(r.items), cursor: r.cursor ?? 0 }));
|
||||
return get<{ items: Activity[]; cursor: number }>(`/exploration/activity?${q.toString()}`).then((r) => ({
|
||||
items: arr(r.items),
|
||||
cursor: r.cursor ?? 0,
|
||||
}));
|
||||
},
|
||||
activityDetail: (id: number, task?: string) => get<{ detail: string }>(`/exploration/activity/${id}${tq(task)}`),
|
||||
// Reverse-paginated session history. session = "main" | "plan" | "intent:<id>".
|
||||
@@ -366,20 +479,21 @@ export const api = {
|
||||
(method && method !== "all" ? `&method=${encodeURIComponent(method)}` : "") +
|
||||
(q ? `&q=${encodeURIComponent(q)}` : ""),
|
||||
),
|
||||
trafficExchange: (id: string) =>
|
||||
get<TrafficDetail>(`/traffic/exchange?id=${encodeURIComponent(id)}`),
|
||||
trafficExchange: (id: string) => get<TrafficDetail>(`/traffic/exchange?id=${encodeURIComponent(id)}`),
|
||||
trafficHosts: () => get<{ hosts: TrafficHost[] }>(`/traffic/hosts`),
|
||||
trafficDeleteHost: (host: string) =>
|
||||
del<{ deleted: number }>(`/traffic?host=${encodeURIComponent(host)}`),
|
||||
trafficDeleteHosts: (hosts: string[]) =>
|
||||
del<{ deleted: number }>(`/traffic/hosts`, { hosts }),
|
||||
trafficDeleteHost: (host: string) => del<{ deleted: number }>(`/traffic?host=${encodeURIComponent(host)}`),
|
||||
trafficDeleteHosts: (hosts: string[]) => del<{ deleted: number }>(`/traffic/hosts`, { hosts }),
|
||||
|
||||
// ---- app settings (runtime toggles) ----
|
||||
settings: () => get<Settings>(`/settings`),
|
||||
setSettings: (patch: Partial<Settings>) => put<Settings>(`/settings`, patch),
|
||||
// Run a real "test" search with the given (or saved) config to verify it works.
|
||||
testWebSearch: (patch: { web_search_backend?: string; web_search_proxy?: string; brave_search_api_key?: string; tavily_search_api_key?: string }) =>
|
||||
post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, patch),
|
||||
testWebSearch: (patch: {
|
||||
web_search_backend?: string;
|
||||
web_search_proxy?: string;
|
||||
brave_search_api_key?: string;
|
||||
tavily_search_api_key?: string;
|
||||
}) => post<{ ok: boolean; error?: string; count?: number; backend?: string }>(`/settings/web-search/test`, patch),
|
||||
report: async (task?: string) => {
|
||||
if (MOCK) return mockReport(task);
|
||||
const token = getToken();
|
||||
@@ -393,7 +507,15 @@ export const api = {
|
||||
post<{ reply: string; mode: string }>(`/chat${tq(task)}`, { message, attachments }),
|
||||
// 方式1 文件上传:落到会话/任务工作目录 uploads/,返回可供 agent Read 的相对路径。
|
||||
chatUpload: async (scope: "task" | "session" | "staging", id: string, files: File[]) => {
|
||||
if (MOCK) return { attachments: files.map((f) => ({ name: f.name, path: `uploads/${f.name}`, size: f.size, abs: `/mock/${f.name}` })) };
|
||||
if (MOCK)
|
||||
return {
|
||||
attachments: files.map((f) => ({
|
||||
name: f.name,
|
||||
path: `uploads/${f.name}`,
|
||||
size: f.size,
|
||||
abs: `/mock/${f.name}`,
|
||||
})),
|
||||
};
|
||||
const fd = new FormData();
|
||||
for (const f of files) fd.append("file", f);
|
||||
const token = getToken();
|
||||
@@ -409,15 +531,37 @@ export const api = {
|
||||
gc: (ttl = 86400) => post<{ removed: number }>(`/gc?ttl=${ttl}`, {}),
|
||||
|
||||
// ---- LLM ----
|
||||
getLLM: () => get<{ configured: boolean; provider: string; model: string; base_url: string; proxy?: string; key_set: boolean; rate_per_second?: number; rate_per_minute?: number; context_window_k?: number; reasoning_effort?: string }>("/llm"),
|
||||
setLLM: (provider: string, model: string, base_url: string, api_key: string, rate_per_second = 0, rate_per_minute = 0, proxy = "", context_window_k = 0) =>
|
||||
post("/llm", { provider, model, base_url, proxy, api_key, rate_per_second, rate_per_minute, context_window_k }),
|
||||
getLLM: () =>
|
||||
get<{
|
||||
configured: boolean;
|
||||
provider: string;
|
||||
model: string;
|
||||
base_url: string;
|
||||
proxy?: string;
|
||||
key_set: boolean;
|
||||
rate_per_second?: number;
|
||||
rate_per_minute?: number;
|
||||
context_window_k?: number;
|
||||
thinking_type?: string;
|
||||
reasoning_effort?: string;
|
||||
}>("/llm"),
|
||||
setLLM: (
|
||||
provider: string,
|
||||
model: string,
|
||||
base_url: string,
|
||||
api_key: string,
|
||||
rate_per_second = 0,
|
||||
rate_per_minute = 0,
|
||||
proxy = "",
|
||||
context_window_k = 0,
|
||||
) => post("/llm", { provider, model, base_url, proxy, api_key, rate_per_second, rate_per_minute, context_window_k }),
|
||||
testLLM: (
|
||||
provider: string,
|
||||
model: string,
|
||||
base_url: string,
|
||||
api_key: string,
|
||||
proxy = "",
|
||||
thinking_type = "",
|
||||
reasoning_effort = "",
|
||||
profile_id?: number,
|
||||
) =>
|
||||
@@ -427,6 +571,7 @@ export const api = {
|
||||
base_url,
|
||||
proxy,
|
||||
api_key,
|
||||
thinking_type,
|
||||
reasoning_effort,
|
||||
profile_id,
|
||||
}),
|
||||
@@ -442,7 +587,8 @@ export const api = {
|
||||
rate_per_second?: number;
|
||||
rate_per_minute?: number;
|
||||
context_window_k?: number;
|
||||
reasoning_effort?: string; // ""|"off"|"low"|"medium"|"high"|"max"
|
||||
thinking_type?: string; // ""(不发送)|"disabled"|"enabled"
|
||||
reasoning_effort?: string; // ""(不发送)|"low"|"medium"|"high"|"xhigh"|"max"
|
||||
priority?: number; // 轮询顺位,越大越先用
|
||||
pool_exclude?: boolean; // true=不作为故障转移目标
|
||||
}) => post<{ id: number }>("/llm/profiles", p),
|
||||
@@ -464,8 +610,7 @@ export const api = {
|
||||
// ---- agents ----
|
||||
agents: () => get<{ agents: Agent[] }>("/agents").then((r) => arr(r.agents)),
|
||||
getAgent: (key: string) => get<AgentDetail>(`/agents/${key}`),
|
||||
createAgent: (key: string, name: string, description = "") =>
|
||||
post<Agent>("/agents", { key, name, description }),
|
||||
createAgent: (key: string, name: string, description = "") => post<Agent>("/agents", { key, name, description }),
|
||||
updateAgent: (key: string, name: string, description = "") =>
|
||||
patch<{ ok: boolean }>(`/agents/${key}`, { name, description }),
|
||||
deleteAgent: (key: string) => del<{ deleted: string }>(`/agents/${key}`),
|
||||
@@ -497,7 +642,8 @@ export const api = {
|
||||
sendConversationMessage: (id: number, message: string, attachments?: ChatAttachment[]) =>
|
||||
post<{ status: string }>(`/conversations/${id}/messages`, { message, attachments }),
|
||||
stopConversation: (id: number) => post<{ status: string }>(`/conversations/${id}/stop`, {}),
|
||||
saveAgentPrompt: (key: string, template: string, note = "") => put<{ version: number }>(`/agents/${key}/prompt`, { template, note }),
|
||||
saveAgentPrompt: (key: string, template: string, note = "") =>
|
||||
put<{ version: number }>(`/agents/${key}/prompt`, { template, note }),
|
||||
resetAgentPrompt: (key: string) => post<{ version: number }>(`/agents/${key}/prompt/reset`, {}),
|
||||
// 收尾提示词(超时/步数耗尽的 settlement 提示);prompt 空串=清除覆盖、用内置默认;
|
||||
// max_turns 省略则不动、传 0=用内置默认轮数
|
||||
@@ -509,7 +655,10 @@ export const api = {
|
||||
saveAgentTaskTimeoutWrapup: (key: string, prompt: string, maxTurns?: number) =>
|
||||
put<{ ok: boolean }>(`/agents/${key}/wrapup/task-timeout`, { prompt, max_turns: maxTurns }),
|
||||
resetAgentTaskTimeoutWrapup: (key: string) =>
|
||||
post<{ ok: boolean; task_timeout_wrapup_default: string; task_timeout_wrapup_max_turns_default: number }>(`/agents/${key}/wrapup/task-timeout/reset`, {}),
|
||||
post<{ ok: boolean; task_timeout_wrapup_default: string; task_timeout_wrapup_max_turns_default: number }>(
|
||||
`/agents/${key}/wrapup/task-timeout/reset`,
|
||||
{},
|
||||
),
|
||||
// P3 triggers (仅自定义 agent)
|
||||
agentTriggers: (key: string) =>
|
||||
get<{ triggers: AgentTrigger[] }>(`/agents/${key}/triggers`).then((r) => arr(r.triggers)),
|
||||
@@ -531,12 +680,15 @@ export const api = {
|
||||
trigger_max_parallel?: number;
|
||||
},
|
||||
) => put<{ ok: boolean }>(`/agents/${key}/config`, patch),
|
||||
agentPromptVersions: (key: string) => get<{ versions: PromptVersion[] }>(`/agents/${key}/prompts`).then((r) => arr(r.versions)),
|
||||
agentVariables: (key: string) => get<{ variables: PromptVar[] }>(`/agents/${key}/variables`).then((r) => arr(r.variables)),
|
||||
agentPromptVersions: (key: string) =>
|
||||
get<{ versions: PromptVersion[] }>(`/agents/${key}/prompts`).then((r) => arr(r.versions)),
|
||||
agentVariables: (key: string) =>
|
||||
get<{ variables: PromptVar[] }>(`/agents/${key}/variables`).then((r) => arr(r.variables)),
|
||||
previewAgentPrompt: (key: string, template: string, sample?: Record<string, string>) =>
|
||||
post<{ rendered: string; error?: string }>(`/agents/${key}/prompt/preview`, { template, sample }),
|
||||
getAgentVisibility: (key: string) => get<{ mcp: number[]; skill: string[] }>(`/agents/${key}/visibility`),
|
||||
setAgentVisibility: (key: string, mcp: number[], skill: string[]) => put<{ ok: boolean }>(`/agents/${key}/visibility`, { mcp, skill }),
|
||||
setAgentVisibility: (key: string, mcp: number[], skill: string[]) =>
|
||||
put<{ ok: boolean }>(`/agents/${key}/visibility`, { mcp, skill }),
|
||||
|
||||
// ---- tools (内置工具目录) ----
|
||||
tools: () => get<{ tools: Tool[] }>("/tools").then((r) => arr(r.tools)),
|
||||
@@ -544,10 +696,13 @@ export const api = {
|
||||
put<{ ok: boolean }>(`/tools/${key}`, patch),
|
||||
resetTool: (key: string) => post<{ ok: boolean }>(`/tools/${key}/reset`, {}),
|
||||
// custom tools (自定义工具)
|
||||
createCustomTool: (t: Pick<Tool, "key" | "description" | "schema" | "agents" | "enabled" | "kind" | "exec" | "deferred">) =>
|
||||
post<{ key: string }>("/tools/custom", t),
|
||||
updateCustomTool: (key: string, t: Pick<Tool, "description" | "schema" | "agents" | "enabled" | "kind" | "exec" | "deferred">) =>
|
||||
put<{ ok: boolean }>(`/tools/custom/${key}`, t),
|
||||
createCustomTool: (
|
||||
t: Pick<Tool, "key" | "description" | "schema" | "agents" | "enabled" | "kind" | "exec" | "deferred">,
|
||||
) => post<{ key: string }>("/tools/custom", t),
|
||||
updateCustomTool: (
|
||||
key: string,
|
||||
t: Pick<Tool, "description" | "schema" | "agents" | "enabled" | "kind" | "exec" | "deferred">,
|
||||
) => put<{ ok: boolean }>(`/tools/custom/${key}`, t),
|
||||
deleteCustomTool: (key: string) => del<{ deleted: string }>(`/tools/custom/${key}`),
|
||||
testCustomTool: (body: { kind: string; exec: Record<string, unknown>; params: Record<string, unknown> }) =>
|
||||
post<{ output: string; is_error: boolean }>("/tools/custom/test", body),
|
||||
@@ -591,8 +746,14 @@ export const api = {
|
||||
|
||||
// ---- skills (文件系统) ----
|
||||
skills: () => get<{ skills: SkillItem[] }>("/skills").then((r) => arr(r.skills)),
|
||||
createSkill: (s: { name: string; description: string; license?: string; compatibility?: string; mcps?: string[]; instructions?: string }) =>
|
||||
post<{ name: string }>("/skills", s),
|
||||
createSkill: (s: {
|
||||
name: string;
|
||||
description: string;
|
||||
license?: string;
|
||||
compatibility?: string;
|
||||
mcps?: string[];
|
||||
instructions?: string;
|
||||
}) => post<{ name: string }>("/skills", s),
|
||||
// uploadSkill installs a skill from a .zip (multipart). Surfaces the backend
|
||||
// error text (e.g. 已存在 / 缺少 SKILL.md) so the UI can show a precise message.
|
||||
uploadSkill: async (file: File, overwrite = false): Promise<{ name: string; files: number }> => {
|
||||
@@ -610,20 +771,25 @@ export const api = {
|
||||
return body;
|
||||
},
|
||||
deleteSkill: (name: string) => del<{ deleted: string }>(`/skills/${name}`),
|
||||
updateSkillMeta: (name: string, meta: { mcps?: string[]; description?: string; license?: string; compatibility?: string }) =>
|
||||
put<{ ok: boolean }>(`/skills/${name}/meta`, meta),
|
||||
createSkillDir: (skill: string, path: string) =>
|
||||
post<{ dir: string }>(`/skills/${skill}/dirs`, { path }),
|
||||
updateSkillMeta: (
|
||||
name: string,
|
||||
meta: { mcps?: string[]; description?: string; license?: string; compatibility?: string },
|
||||
) => put<{ ok: boolean }>(`/skills/${name}/meta`, meta),
|
||||
createSkillDir: (skill: string, path: string) => post<{ dir: string }>(`/skills/${skill}/dirs`, { path }),
|
||||
skillFiles: (name: string) => get<{ files: string[] }>(`/skills/${name}/files`).then((r) => r.files),
|
||||
readSkillFile: (name: string, file: string) =>
|
||||
get<{ content: string; file: string }>(`/skills/${name}/files/${file}`).then((r) => r.content),
|
||||
writeSkillFile: (name: string, file: string, content: string) =>
|
||||
put<{ ok: boolean }>(`/skills/${name}/files/${file}`, { content }),
|
||||
deleteSkillPath: (skill: string, path: string) =>
|
||||
del<{ deleted: string }>(`/skills/${skill}/files/${path}`),
|
||||
deleteSkillPath: (skill: string, path: string) => del<{ deleted: string }>(`/skills/${skill}/files/${path}`),
|
||||
skillUsage: (name: string, limit = 50) =>
|
||||
get<{ calls: SkillCall[] }>(`/skills/${name}/usage?limit=${limit}`).then((r) => arr(r.calls)),
|
||||
missingSkills: (limit = 20) =>
|
||||
get<{ missing: MissingSkill[] }>(`/skills/missing?limit=${limit}`).then((r) => arr(r.missing)),
|
||||
|
||||
// ---- visibility (MCP resource side) ---- (agent ids are strings per spec)
|
||||
resourceVisibility: (kind: string, id: number) => get<{ agents: string[] }>(`/visibility/${kind}/${id}`).then((r) => arr(r.agents)),
|
||||
resourceVisibility: (kind: string, id: number) =>
|
||||
get<{ agents: string[] }>(`/visibility/${kind}/${id}`).then((r) => arr(r.agents)),
|
||||
toggleVisibility: (agentId: string, kind: string, resourceId: number, visible: boolean) =>
|
||||
post<{ ok: boolean }>("/visibility/toggle", { agent_id: agentId, kind, resource_id: resourceId, visible }),
|
||||
|
||||
@@ -648,7 +814,8 @@ export const api = {
|
||||
interceptDecide: (id: number, decision: "allowed" | "denied") =>
|
||||
post<{ ok: boolean }>(`/intercept/pending/${id}/decide`, { decision }),
|
||||
interceptHistory: () => get<{ items: InterceptApprovalRow[] }>("/intercept/history").then((r) => arr(r.items)),
|
||||
interceptTask: (taskId: string) => get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)),
|
||||
interceptTask: (taskId: string) =>
|
||||
get<{ items: InterceptApprovalRow[] }>(`/intercept/task/${taskId}`).then((r) => arr(r.items)),
|
||||
|
||||
// ---- intercept tool-config (全局工具拦截范围) ----
|
||||
interceptGetToolConfig: async (): Promise<{ enabled_tools: string[] }> => {
|
||||
@@ -696,6 +863,9 @@ export const api = {
|
||||
},
|
||||
llmRecordDetail: (id: number) => get<LLMRecordDetail>(`/llm/records/${id}`),
|
||||
llmTasks: () => get<{ tasks: LLMTask[] }>(`/llm/records/tasks`),
|
||||
llmRecordsDeleteTask: (task: string) =>
|
||||
del<{ deleted: number }>(`/llm/records?task=${encodeURIComponent(task)}`),
|
||||
llmRecordsDeleteTask: (task: string) => del<{ deleted: number }>(`/llm/records?task=${encodeURIComponent(task)}`),
|
||||
// 按模型聚合本任务的 token 用量(来自常开的 llm_usage 计量账本,逐次调用精确,
|
||||
// per-agent 绑定 / 轮询 / 中断消耗都覆盖)。
|
||||
tokensByModel: (task: string) =>
|
||||
get<{ models: ModelTokenStat[] }>(`/llm/records/by-model?task=${encodeURIComponent(task)}`),
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
Activity, Agent, AgentDetail, Asset, Audit, Company, Conversation,
|
||||
ConvTokenSummary, DailyTokenBucket, Edge, Finding, InterceptApprovalRow,
|
||||
InterceptPending, InterceptRule, LLMPoolStatus, LLMProfile, MCPServer, MCPTool, PromptVar,
|
||||
PromptVersion, Settings, SkillItem, Stats, TaskNode, Task, TokenTotal,
|
||||
PromptVersion, Settings, SkillCall, SkillItem, MissingSkill, Stats, TaskNode, Task, TokenTotal,
|
||||
TokenUsage, Tool, TrafficResp, TrafficDetail, TrafficHost, LLMTask,
|
||||
} from "@/lib/types";
|
||||
|
||||
@@ -617,14 +617,14 @@ export const audit: Audit = {
|
||||
|
||||
// ── LLM profiles ─────────────────────────────────────────────────────────────
|
||||
export const llmProfiles: LLMProfile[] = [
|
||||
{ id: "1", name: "Claude Opus 4.8", format: "anthropic", model: "claude-opus-4-8", api_key_hint: "…a3f2", rate_per_second: 0, rate_per_minute: 0, context_window_k: 1000, reasoning_effort: "high", is_default: true, priority: 0, pool_exclude: false },
|
||||
{ id: "2", name: "DeepSeek V4", format: "openai", base_url: "https://api.deepseek.com", model: "deepseek-v4-flash", api_key_hint: "…9c11", rate_per_second: 0, rate_per_minute: 60, context_window_k: 128, reasoning_effort: "", is_default: false, priority: 10, pool_exclude: false },
|
||||
{ id: "1", name: "Claude Opus 4.8", format: "anthropic", model: "claude-opus-4-8", api_key_hint: "…a3f2", rate_per_second: 0, rate_per_minute: 0, context_window_k: 1000, thinking_type: "enabled", reasoning_effort: "high", is_default: true, priority: 0, pool_exclude: false },
|
||||
{ id: "2", name: "DeepSeek V4", format: "openai", base_url: "https://api.deepseek.com", model: "deepseek-v4-flash", api_key_hint: "…9c11", rate_per_second: 0, rate_per_minute: 60, context_window_k: 128, thinking_type: "", reasoning_effort: "", is_default: false, priority: 10, pool_exclude: false },
|
||||
];
|
||||
|
||||
export const llmConfig = {
|
||||
configured: true, provider: "anthropic", model: "claude-opus-4-8",
|
||||
base_url: "", proxy: "", key_set: true,
|
||||
rate_per_second: 0, rate_per_minute: 0, context_window_k: 1000, reasoning_effort: "high",
|
||||
rate_per_second: 0, rate_per_minute: 0, context_window_k: 1000, thinking_type: "enabled", reasoning_effort: "high",
|
||||
};
|
||||
|
||||
// ── Agents ───────────────────────────────────────────────────────────────────
|
||||
@@ -686,9 +686,19 @@ export const mcpToolsById: Record<number, MCPTool[]> = {
|
||||
|
||||
// ── Skills ───────────────────────────────────────────────────────────────────
|
||||
export const skills: SkillItem[] = [
|
||||
{ name: "api-recon", description: "对 REST/GraphQL API 做侦察与越权面枚举;发现新 API 端点时使用。", license: "MIT", mcps: [], files: ["SKILL.md", "scripts/enum.py"] },
|
||||
{ name: "playwright-cli", description: "用 Playwright 驱动浏览器做动态爬取与截图;需要渲染 JS 站点时使用。", mcps: ["playwright"], files: ["SKILL.md"] },
|
||||
{ name: "scopesentry", description: "从 ScopeSentry 拉取资产并归并到公司范围;批量导入资产时使用。", files: ["SKILL.md", "assets/mapping.md"] },
|
||||
{ name: "api-recon", description: "对 REST/GraphQL API 做侦察与越权面枚举;发现新 API 端点时使用。", license: "MIT", mcps: [], files: ["SKILL.md", "scripts/enum.py"], calls: 24, tasks: 6, usage_agents: ["worker", "planner"], last_used: "2026-08-20T09:12:00Z" },
|
||||
{ name: "playwright-cli", description: "用 Playwright 驱动浏览器做动态爬取与截图;需要渲染 JS 站点时使用。", mcps: ["playwright"], files: ["SKILL.md"], calls: 7, tasks: 3, usage_agents: ["worker"], last_used: "2026-08-19T21:40:00Z" },
|
||||
{ name: "scopesentry", description: "从 ScopeSentry 拉取资产并归并到公司范围;批量导入资产时使用。", files: ["SKILL.md", "assets/mapping.md"], calls: 0, tasks: 0, usage_agents: [] },
|
||||
];
|
||||
|
||||
export const skillCalls: SkillCall[] = [
|
||||
{ ts: "2026-08-20T09:12:00Z", agent_key: "worker", task_id: 42, session_id: "", args_len: 128 },
|
||||
{ ts: "2026-08-20T08:03:00Z", agent_key: "planner", task_id: 42, session_id: "", args_len: 0 },
|
||||
{ ts: "2026-08-19T17:55:00Z", agent_key: "worker", task_id: 37, session_id: "", args_len: 64 },
|
||||
];
|
||||
|
||||
export const missingSkills: MissingSkill[] = [
|
||||
{ skill: "jwt-forge", calls: 3, agents: ["worker"], last_used: "2026-08-20T07:20:00Z" },
|
||||
];
|
||||
|
||||
// ── Tools ────────────────────────────────────────────────────────────────────
|
||||
|
||||
+105
-16
@@ -149,6 +149,74 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (seg[0] === "tasks" && seg[2] === "coverage-graph") return D.coverageGraph;
|
||||
if (seg[0] === "tasks" && seg[2] === "asset-refs") return D.assetRefsFor(Number(q.get("asset_id") ?? 0));
|
||||
|
||||
// ── 任务测试范围(增删查)──
|
||||
if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 3 && m === "GET") return { scope: [] };
|
||||
if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 3 && m === "POST")
|
||||
return { id: Date.now(), task_id: Number(seg[1]), kind: b.kind, domain: b.value, source: "manual" };
|
||||
if (seg[0] === "tasks" && seg[2] === "scope" && seg.length === 4 && m === "DELETE") return { ok: true };
|
||||
|
||||
// ── 全局 llm_usage 聚合(仪表盘新版视图,demo)──
|
||||
if (path === "/tokens/usage")
|
||||
return {
|
||||
by_profile: [
|
||||
{
|
||||
profile_name: "default",
|
||||
calls: 60,
|
||||
tasks: 4,
|
||||
input_tokens: 1570000,
|
||||
output_tokens: 110000,
|
||||
cache_read_tokens: 1120000,
|
||||
cache_write_tokens: 140000,
|
||||
},
|
||||
],
|
||||
daily: [
|
||||
{
|
||||
profile_name: "default",
|
||||
date: "2026-08-18",
|
||||
input_tokens: 520000,
|
||||
output_tokens: 38000,
|
||||
cache_read_tokens: 370000,
|
||||
},
|
||||
{
|
||||
profile_name: "default",
|
||||
date: "2026-08-19",
|
||||
input_tokens: 640000,
|
||||
output_tokens: 45000,
|
||||
cache_read_tokens: 460000,
|
||||
},
|
||||
{
|
||||
profile_name: "default",
|
||||
date: "2026-08-20",
|
||||
input_tokens: 410000,
|
||||
output_tokens: 27000,
|
||||
cache_read_tokens: 290000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ── 按模型 token 用量(demo:一条示例)──
|
||||
if (path === "/llm/records/by-model")
|
||||
return {
|
||||
models: [
|
||||
{
|
||||
model: "claude-opus-4-6",
|
||||
calls: 42,
|
||||
input_tokens: 1250000,
|
||||
output_tokens: 86000,
|
||||
cache_read_tokens: 940000,
|
||||
cache_write_tokens: 120000,
|
||||
},
|
||||
{
|
||||
model: "claude-haiku-4-5",
|
||||
calls: 18,
|
||||
input_tokens: 320000,
|
||||
output_tokens: 24000,
|
||||
cache_read_tokens: 180000,
|
||||
cache_write_tokens: 20000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
// ── 工作空间文件管理器(demo:静态示例树;写/建/删走下方写兜底 {ok:true})──
|
||||
if (path === "/workspace/list") return D.workspaceList(q.get("path") ?? "");
|
||||
if (path === "/workspace/read") return D.workspaceRead(q.get("path") ?? "");
|
||||
@@ -200,18 +268,12 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
};
|
||||
}
|
||||
// 单条 finding:GET 详情 / PATCH 改状态/严重度/名称/类别(demo 直接改内存对象)。
|
||||
if (
|
||||
seg[0] === "exploration" &&
|
||||
seg[1] === "findings" &&
|
||||
seg.length === 3 &&
|
||||
seg[2] !== "stats"
|
||||
) {
|
||||
if (seg[0] === "exploration" && seg[1] === "findings" && seg.length === 3 && seg[2] !== "stats") {
|
||||
const f = mockFindings.find((x) => x.id === seg[2]);
|
||||
if (!f) return {};
|
||||
if (m === "PATCH") {
|
||||
if (typeof b.status === "string") f.status = b.status as typeof f.status;
|
||||
if (typeof b.severity === "string")
|
||||
f.severity = b.severity as typeof f.severity;
|
||||
if (typeof b.severity === "string") f.severity = b.severity as typeof f.severity;
|
||||
if (typeof b.name === "string") f.name = b.name;
|
||||
if (typeof b.vulnclass === "string") f.vulnclass = b.vulnclass;
|
||||
}
|
||||
@@ -288,7 +350,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/exploration/activity" && seg.length === 2) {
|
||||
const since = Number(q.get("since") ?? 0);
|
||||
const limit = Math.max(1, Number(q.get("limit") ?? 300));
|
||||
const items = D.activityForTask().filter((item) => item.seq > since).slice(0, limit);
|
||||
const items = D.activityForTask()
|
||||
.filter((item) => item.seq > since)
|
||||
.slice(0, limit);
|
||||
return { items, cursor: items.length ? items[items.length - 1].seq : since };
|
||||
}
|
||||
if (seg[0] === "exploration" && seg[1] === "activity" && seg.length === 3) {
|
||||
@@ -309,7 +373,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/settings" && m === "PUT") return { ...D.settings, ...b };
|
||||
if (path === "/settings/web-search/test") return { ok: true, count: 5, backend: D.settings.web_search_backend };
|
||||
if (path === "/settings/python/detect") return { python_interpreter: "/usr/bin/python3" };
|
||||
if (path === "/chat") return { reply: "(demo)我已把该建议注入为一条高优意图,work agent 会尽快执行。", mode: "hint" };
|
||||
if (path === "/chat")
|
||||
return { reply: "(demo)我已把该建议注入为一条高优意图,work agent 会尽快执行。", mode: "hint" };
|
||||
if (path === "/gc") return { removed: 0 };
|
||||
|
||||
// ── 工具执行历史 ──
|
||||
@@ -337,11 +402,20 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/llm/pool" && m === "GET") return D.llmPool;
|
||||
if (path === "/llm/pool/reset")
|
||||
return { ...D.llmPool, chain: D.llmPool.chain.map((c) => ({ ...c, state: "ok", fails: 0, cooldown_secs: 0 })) };
|
||||
if (seg[0] === "llm" && seg[1] === "profiles" && seg.length === 3 && m === "DELETE") return { deleted: Number(seg[2]) };
|
||||
if (seg[0] === "llm" && seg[1] === "profiles" && seg.length === 3 && m === "DELETE")
|
||||
return { deleted: Number(seg[2]) };
|
||||
|
||||
// ── agents ──
|
||||
if (path === "/agents" && m === "GET") return { agents: D.agents };
|
||||
if (path === "/agents" && m === "POST") return { id: "9", key: String(b.key ?? "custom"), name: String(b.name ?? ""), role: "custom", builtin: false, enabled: true };
|
||||
if (path === "/agents" && m === "POST")
|
||||
return {
|
||||
id: "9",
|
||||
key: String(b.key ?? "custom"),
|
||||
name: String(b.name ?? ""),
|
||||
role: "custom",
|
||||
builtin: false,
|
||||
enabled: true,
|
||||
};
|
||||
if (seg[0] === "agents" && seg.length === 2 && m === "GET") return D.agentDetail(seg[1]);
|
||||
if (seg[0] === "agents" && seg[2] === "triggers" && m === "GET") return { triggers: [] };
|
||||
if (seg[0] === "agents" && seg[2] === "prompts") return { versions: D.agentDetail(seg[1]).versions };
|
||||
@@ -352,7 +426,14 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
|
||||
// ── conversations ──
|
||||
if (path === "/conversations" && m === "GET") return { conversations: D.conversations };
|
||||
if (path === "/conversations" && m === "POST") return { id: 3, agent_key: String(b.agent_key ?? "mainagent"), title: String(b.title ?? "新会话"), created_at: "2026-07-26T04:00:00Z", updated_at: "2026-07-26T04:00:00Z" };
|
||||
if (path === "/conversations" && m === "POST")
|
||||
return {
|
||||
id: 3,
|
||||
agent_key: String(b.agent_key ?? "mainagent"),
|
||||
title: String(b.title ?? "新会话"),
|
||||
created_at: "2026-07-26T04:00:00Z",
|
||||
updated_at: "2026-07-26T04:00:00Z",
|
||||
};
|
||||
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 3 && m === "GET") {
|
||||
const items = D.conversationMessages[Number(seg[1])] ?? [];
|
||||
return { items, cursor: items.length ? items[items.length - 1].seq : 0, running: false };
|
||||
@@ -378,13 +459,16 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (seg[0] === "mcp" && seg.length === 2 && m === "DELETE") return { deleted: Number(seg[1]) };
|
||||
|
||||
// ── scopesentry(demo:未配置)──
|
||||
if (path === "/sync/scopesentry/status") return { exists: false, configured: false, enabled: false, reachable: false, tools: [] };
|
||||
if (path === "/sync/scopesentry/status")
|
||||
return { exists: false, configured: false, enabled: false, reachable: false, tools: [] };
|
||||
if (path === "/sync/scopesentry/projects") return { projects: [], tag: {} };
|
||||
if (path === "/sync/scopesentry/tasks") return { tasks: [] };
|
||||
if (path === "/sync/scopesentry/sync") return { synced: {}, companies: null, warnings: null, errors: null };
|
||||
|
||||
// ── skills ──
|
||||
if (path === "/skills" && m === "GET") return { skills: D.skills };
|
||||
if (path === "/skills/missing") return { missing: D.missingSkills };
|
||||
if (seg[0] === "skills" && seg[2] === "usage") return { calls: D.skillCalls };
|
||||
if (path === "/skills" && m === "POST") return { name: String(b.name ?? "new-skill") };
|
||||
if (seg[0] === "skills" && seg[2] === "files" && seg.length === 3) return { files: ["SKILL.md"] };
|
||||
if (seg[0] === "skills" && seg[2] === "files" && seg.length >= 4)
|
||||
@@ -395,7 +479,8 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
|
||||
// ── intercept ──
|
||||
if (path === "/intercept/rules" && m === "GET") return { rules: D.interceptRules };
|
||||
if (seg[0] === "intercept" && seg[1] === "rules" && seg[3] === "toggle") return { ok: true, enabled: b.enabled ?? true };
|
||||
if (seg[0] === "intercept" && seg[1] === "rules" && seg[3] === "toggle")
|
||||
return { ok: true, enabled: b.enabled ?? true };
|
||||
if (path === "/intercept/pending" && m === "GET") return { pending: D.interceptPending };
|
||||
if (seg[0] === "intercept" && seg[1] === "pending" && seg[3] === "decide") return { ok: true };
|
||||
if (seg[0] === "intercept" && seg[1] === "pending" && seg.length === 3 && m === "GET")
|
||||
@@ -409,5 +494,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (["POST", "PUT", "PATCH", "DELETE"].includes(m)) return { ok: true };
|
||||
|
||||
// ── 读兜底:集合类给 [],其余 {} ──
|
||||
return /(\/(tasks|profiles|conversations|rules|history|projects|tokens|agents|servers|skills|tools|findings|intents)s?$)|s$/.test(path) ? [] : {};
|
||||
return /(\/(tasks|profiles|conversations|rules|history|projects|tokens|agents|servers|skills|tools|findings|intents)s?$)|s$/.test(
|
||||
path,
|
||||
)
|
||||
? []
|
||||
: {};
|
||||
}
|
||||
+95
-22
@@ -225,14 +225,26 @@ export interface WorkspaceFile {
|
||||
content?: string;
|
||||
}
|
||||
|
||||
// 任务测试范围的一条(覆盖度分母 + 授权边界)。
|
||||
export interface TaskScopeRow {
|
||||
id: number;
|
||||
task_id: number;
|
||||
kind: "company" | "root_domain" | "subdomain" | "ip" | "cidr";
|
||||
company_id?: number;
|
||||
domain?: string;
|
||||
net?: string;
|
||||
source: "auto" | "agent" | "manual";
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// 公司资产范围规则的一条(归属唯一真值来源)。
|
||||
export interface ScopeRow {
|
||||
id: number;
|
||||
company_id: number;
|
||||
kind: "domain" | "ip" | "cidr";
|
||||
domain?: string; // kind=domain 时有值
|
||||
net?: string; // kind=ip|cidr 时有值
|
||||
raw: string; // 原始用户输入,用于显示和回填
|
||||
domain?: string; // kind=domain 时有值
|
||||
net?: string; // kind=ip|cidr 时有值
|
||||
raw: string; // 原始用户输入,用于显示和回填
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
@@ -353,11 +365,11 @@ export type ActivityKind =
|
||||
| "thinking"
|
||||
| "result"
|
||||
| "user"
|
||||
| "intent" // LLM-generated exploration objective leading a worker session (UI-synthesized)
|
||||
| "round" // planner round boundary marker (engine-emitted)
|
||||
| "usage" // live cumulative token usage (per model turn); not rendered
|
||||
| "llm_switch" // automatic/manual task-level LLM switch
|
||||
| "llm_failover" // task-level provider switch / chain exhaustion audit event
|
||||
| "intent" // LLM-generated exploration objective leading a worker session (UI-synthesized)
|
||||
| "round" // planner round boundary marker (engine-emitted)
|
||||
| "usage" // live cumulative token usage (per model turn); not rendered
|
||||
| "llm_switch" // automatic/manual task-level LLM switch
|
||||
| "llm_failover" // task-level provider switch / chain exhaustion audit event
|
||||
| "intercept_request"; // user-approval request from the intercept layer
|
||||
|
||||
// ChatAttachment 是一次上传的文件:path 相对该会话/任务工作目录(即 agent 的 CWD)。
|
||||
@@ -475,6 +487,43 @@ export interface TokenTotal {
|
||||
cache_write_tokens: number;
|
||||
}
|
||||
|
||||
// Global per-profile token spend from the llm_usage ledger (GET /api/tokens/usage).
|
||||
export interface ProfileUsage {
|
||||
profile_name: string;
|
||||
calls: number;
|
||||
tasks: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
}
|
||||
|
||||
// One (profile, UTC day) token bucket for the dashboard's daily chart (new source).
|
||||
export interface ProfileDayUsage {
|
||||
profile_name: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
}
|
||||
|
||||
// Response of GET /api/tokens/usage — the dashboard's "new" (llm_usage) token view.
|
||||
export interface UsageStats {
|
||||
by_profile: ProfileUsage[];
|
||||
daily: ProfileDayUsage[];
|
||||
}
|
||||
|
||||
// Per-model token usage for one task (GET /api/llm/records/by-model), from the
|
||||
// always-on llm_usage metering ledger. calls = number of LLM calls on this model.
|
||||
export interface ModelTokenStat {
|
||||
model: string;
|
||||
calls: number;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_tokens: number;
|
||||
cache_write_tokens: number;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
role: SessionRole;
|
||||
@@ -574,7 +623,9 @@ export interface LLMProfile {
|
||||
rate_per_second: number;
|
||||
rate_per_minute: number;
|
||||
context_window_k?: number;
|
||||
// 思考模式: ""=默认(不发送) | "off"=关闭 | "low"/"medium"/"high"/"max"=开启并设强度
|
||||
// 思考开关(thinking.type): ""=不发送(默认) | "disabled"=关闭 | "enabled"=开启
|
||||
thinking_type?: string;
|
||||
// 思考强度: ""=不发送(默认) | "low"/"medium"/"high"/"xhigh"/"max"
|
||||
reasoning_effort?: string;
|
||||
is_default: boolean;
|
||||
// 轮询顺位:越大越先被选中。激活配置恒为链首,与本值无关。
|
||||
@@ -690,12 +741,34 @@ export interface MCPTool {
|
||||
// Fields align with the agentskills.io open specification.
|
||||
// description covers both "what the skill does" and "when to use it".
|
||||
export interface SkillItem {
|
||||
name: string; // unique key = directory name
|
||||
description?: string; // required per spec; covers what + when to use
|
||||
license?: string; // optional: SPDX identifier or free text
|
||||
name: string; // unique key = directory name
|
||||
description?: string; // required per spec; covers what + when to use
|
||||
license?: string; // optional: SPDX identifier or free text
|
||||
compatibility?: string; // optional: environment requirements
|
||||
mcps?: string[]; // MCP server names this skill unlocks on load
|
||||
files: string[]; // files in the skill directory
|
||||
mcps?: string[]; // MCP server names this skill unlocks on load
|
||||
files: string[]; // files in the skill directory
|
||||
// 调用统计(skill_usage 账本)。从未被调用过的 skill:calls=0、last_used 缺省。
|
||||
calls: number;
|
||||
tasks: number; // 加载过它的任务数(chat 会话不计入)
|
||||
usage_agents: string[]; // 加载过它的 agent key
|
||||
last_used?: string;
|
||||
}
|
||||
|
||||
// SkillCall 是一次 Skill() 调用(单个 skill 的最近调用列表)。
|
||||
export interface SkillCall {
|
||||
ts: string;
|
||||
agent_key: string;
|
||||
task_id: number; // 0 = 非任务场景(对话会话)
|
||||
session_id: string;
|
||||
args_len: number;
|
||||
}
|
||||
|
||||
// MissingSkill 是被点名但不存在的 skill —— "想用但没有"的缺口。
|
||||
export interface MissingSkill {
|
||||
skill: string;
|
||||
calls: number;
|
||||
agents: string[];
|
||||
last_used?: string;
|
||||
}
|
||||
|
||||
// ---- Tools (内置工具目录) ----
|
||||
@@ -708,12 +781,12 @@ export interface Tool {
|
||||
description: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
schema: Record<string, any>; // full JSON-Schema (object with properties)
|
||||
agents: string[]; // bound agent keys
|
||||
agents: string[]; // bound agent keys
|
||||
enabled: boolean;
|
||||
kind?: "builtin" | "shell" | "command" | "script" | "http"; // 自定义工具类型
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
exec?: Record<string, any>; // 自定义工具执行规格(kind!=builtin)
|
||||
deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool)
|
||||
exec?: Record<string, any>; // 自定义工具执行规格(kind!=builtin)
|
||||
deferred?: boolean; // schema 延迟(SearchExtraTools/ExecuteExtraTool)
|
||||
}
|
||||
|
||||
// ---- Stats ----
|
||||
@@ -764,14 +837,14 @@ export interface InterceptPending {
|
||||
|
||||
// InterceptApprovalRow enriches InterceptPending with conversation/task and rule context.
|
||||
export interface InterceptApprovalRow extends InterceptPending {
|
||||
conv_title: string; // "" if no linked conversation
|
||||
conv_agent_key: string; // "" if no linked conversation
|
||||
rule_name: string; // "" if rule was deleted
|
||||
conv_title: string; // "" if no linked conversation
|
||||
conv_agent_key: string; // "" if no linked conversation
|
||||
rule_name: string; // "" if rule was deleted
|
||||
}
|
||||
|
||||
// ── 资产同步 (ScopeSentry 数据源) ──────────────────────────────────────────────
|
||||
export interface SSProject {
|
||||
id: string; // MongoDB ObjectID — used as filter.project
|
||||
id: string; // MongoDB ObjectID — used as filter.project
|
||||
name: string;
|
||||
logo?: string;
|
||||
AssetCount?: number;
|
||||
@@ -780,7 +853,7 @@ export interface SSProject {
|
||||
|
||||
export interface SSTask {
|
||||
id: string;
|
||||
name: string; // used as filter.task
|
||||
name: string; // used as filter.task
|
||||
status?: number;
|
||||
progress?: number;
|
||||
creatTime?: string;
|
||||
|
||||
@@ -45,6 +45,35 @@ export function radixOverlayWasOpenAtPointerDown(): boolean {
|
||||
return overlayOpenAtLastPointerDown;
|
||||
}
|
||||
|
||||
// copyText 把文本写入剪贴板,返回是否成功。
|
||||
// 背景:navigator.clipboard 仅在安全上下文(HTTPS / localhost)可用;通过 IP + HTTP
|
||||
// 访问时它为 undefined,此时降级到 execCommand("copy")。
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// 继续走降级方案
|
||||
}
|
||||
}
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
textarea.style.top = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const getInitials = (str: string): string => {
|
||||
if (typeof str !== "string" || !str.trim()) return "?";
|
||||
|
||||
|
||||
Reference in new issue
Block a user