feat(tasks): add inherited context and resilient LLM execution

Add read-only task context inheritance, ordered task-level LLM failover, live Worker controls, cascading task cleanup, FIFO task concurrency, cancellation diagnostics, UI updates, tests, build script, and changelog.
This commit is contained in:
pphua committed 2026-08-20 18:34:24 +08:00
1 parent 7090b034f8
commit 9793b522d9
82 files changed
+11094 -1712

No files matched your search

@@ -6,9 +6,9 @@ import { usePathname } from "next/navigation";
import { Separator } from "@/components/ui/separator";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { useCurrentUser } from "@/hooks/use-current-user";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import { useCurrentUser } from "@/hooks/use-current-user";
import { AccountSwitcher } from "./sidebar/account-switcher";
import { LayoutControls } from "./sidebar/layout-controls";
@@ -62,7 +62,7 @@ export function MainContent({ children }: { children: ReactNode }) {
</div>
<div className="flex items-center gap-2">
{version && (
<span className="text-muted-foreground text-xs font-medium tabular-nums">版本 · {version}</span>
<span className="font-medium text-muted-foreground text-xs tabular-nums">版本 · {version}</span>
)}
<LayoutControls />
<ThemeSwitcher />
@@ -2,29 +2,20 @@
import * as React from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { toast } from "sonner";
import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon, Trash2Icon } from "lucide-react";
import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon } from "lucide-react";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
@@ -72,9 +63,9 @@ function FieldRow({
}
function FindingDetailInner() {
const router = useRouter();
const searchParams = useSearchParams();
const id = searchParams.get("id") ?? "";
const contextTaskId = searchParams.get("context_task") ?? "";
const [finding, setFinding] = React.useState<Finding | null>(null);
const [loaded, setLoaded] = React.useState(false);
const [tab, setTab] = React.useState("overview");
@@ -85,18 +76,18 @@ function FindingDetailInner() {
return;
}
api
.getFinding(id)
.getFinding(id, contextTaskId || undefined)
.then((f) => setFinding(f))
.catch(() => setFinding(null))
.finally(() => setLoaded(true));
}, [id]);
}, [contextTaskId, id]);
React.useEffect(() => {
load();
}, [load]);
const changeSeverity = React.useCallback(
async (next: Severity) => {
if (!finding || next === finding.severity) return;
if (!finding || finding.inherited || next === finding.severity) return;
const prev = finding.severity;
setFinding({ ...finding, severity: next });
try {
@@ -113,7 +104,7 @@ function FindingDetailInner() {
const changeStatus = React.useCallback(
async (next: FindingStatus) => {
if (!finding || next === finding.status) return;
if (!finding || finding.inherited || next === finding.status) return;
const prev = finding.status;
setFinding({ ...finding, status: next });
try {
@@ -128,18 +119,6 @@ function FindingDetailInner() {
[finding, id],
);
// deleteFinding 删除当前漏洞(需二次确认),成功后返回发现列表。
const deleteFinding = React.useCallback(async () => {
if (!id) return;
try {
await api.deleteFinding(id);
toast.success("已删除漏洞");
router.push("/function/findings");
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}, [id, router]);
if (!finding) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-10 text-center">
@@ -184,25 +163,11 @@ function FindingDetailInner() {
<Separator orientation="vertical" className="mx-1 h-4" />
<StatusBadge domain="severity" value={finding.severity} dot />
<StatusBadge domain="finding" value={finding.status} dot />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" className="ml-auto text-destructive">
<Trash2Icon /> 删除
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除该漏洞?</AlertDialogTitle>
<AlertDialogDescription className="break-words">
「<span className="break-all">{title}</span>」将被永久删除,同时从发现列表、任务发现 Tab 与探索图中移除,此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={deleteFinding}>删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{finding.inherited && finding.source_task_id && (
<Badge variant="outline">
来源任务 #{finding.source_task_id} · 只读
</Badge>
)}
</div>
<TabsList>
<TabsTrigger value="overview">概览</TabsTrigger>
@@ -271,48 +236,60 @@ function FindingDetailInner() {
</code>
</FieldRow>
{/* 严重等级(可改) */}
{/* 严重等级 */}
<FieldRow label="严重等级">
<Select
value={finding.severity}
onValueChange={(v) => changeSeverity(v as Severity)}
>
<SelectTrigger
size="sm"
className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0"
{finding.inherited ? (
<StatusBadge domain="severity" value={finding.severity} dot />
) : (
<Select
value={finding.severity}
onValueChange={(v) => changeSeverity(v as Severity)}
>
<StatusBadge domain="severity" value={finding.severity} dot />
</SelectTrigger>
<SelectContent position="popper" align="end">
{SEVERITIES.map((sv) => (
<SelectItem key={sv} value={sv}>
{statusMeta("severity", sv).label}
</SelectItem>
))}
</SelectContent>
</Select>
<SelectTrigger
size="sm"
className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0"
>
<StatusBadge domain="severity" value={finding.severity} dot />
</SelectTrigger>
<SelectContent position="popper" align="end">
<SelectGroup>
{SEVERITIES.map((sv) => (
<SelectItem key={sv} value={sv}>
{statusMeta("severity", sv).label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
</FieldRow>
{/* 处理状态(可改) */}
{/* 处理状态 */}
<FieldRow label="处理状态">
<Select
value={finding.status}
onValueChange={(v) => changeStatus(v as FindingStatus)}
>
<SelectTrigger
size="sm"
className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0"
{finding.inherited ? (
<StatusBadge domain="finding" value={finding.status} dot />
) : (
<Select
value={finding.status}
onValueChange={(v) => changeStatus(v as FindingStatus)}
>
<StatusBadge domain="finding" value={finding.status} dot />
</SelectTrigger>
<SelectContent position="popper" align="end">
{FINDING_STATUSES.map((st) => (
<SelectItem key={st} value={st}>
{statusMeta("finding", st).label}
</SelectItem>
))}
</SelectContent>
</Select>
<SelectTrigger
size="sm"
className="h-7 w-auto border-none px-1 shadow-none focus-visible:ring-0"
>
<StatusBadge domain="finding" value={finding.status} dot />
</SelectTrigger>
<SelectContent position="popper" align="end">
<SelectGroup>
{FINDING_STATUSES.map((st) => (
<SelectItem key={st} value={st}>
{statusMeta("finding", st).label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
</FieldRow>
{/* 漏洞类型 */}
@@ -152,7 +152,6 @@ const TABS: { key: string; label: string; icon: LucideIcon }[] = [
];
export function AssetsTab({ taskId }: { taskId: string }) {
// 服务端分页:只取当前分类的当前页,分类总数走 /assets/counts?task_id=,不做数量截断
const [rows, setRows] = React.useState<Asset[]>([]);
const [total, setTotal] = React.useState(0);
const [counts, setCounts] = React.useState<Record<string, number>>({});
@@ -161,7 +160,6 @@ export function AssetsTab({ taskId }: { taskId: string }) {
const [page, setPage] = React.useState(0);
const [size, setSize] = React.useState(50);
// 换分类/换页大小:回到第一页,并清掉旧行(各分类列不同,不能串用)
React.useEffect(() => {
setPage(0);
setRows([]);
@@ -171,17 +169,16 @@ export function AssetsTab({ taskId }: { taskId: string }) {
let alive = true;
const load = async () => {
try {
const [cur, c] = await Promise.all([
const [cur, nextCounts] = await Promise.all([
api.taskAssets(taskId, tab, size, page * size),
api.assetCounts(taskId),
]);
if (!alive) return;
setRows(cur.assets);
setTotal(cur.total);
setCounts(c ?? {});
setCounts(nextCounts ?? {});
setLoaded(true);
} catch {
// 保留上一次的数据
} finally {
if (alive) setLoaded(true);
}
};
@@ -190,7 +187,6 @@ export function AssetsTab({ taskId }: { taskId: string }) {
return () => { alive = false; clearInterval(timer); };
}, [taskId, tab, page, size]);
// 轮询期间资产变少时,别停在越界的空页上
React.useEffect(() => {
const maxPage = Math.max(0, Math.ceil(total / size) - 1);
if (page > maxPage) setPage(maxPage);
@@ -14,9 +14,9 @@ import {
Waypoints,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
@@ -26,6 +26,7 @@ import {
} from "@/components/ui/sheet";
import { api } from "@/lib/api";
import type { CoverageAssetRef, CoverageAssetRefs, CoverageGraphEdge, CoverageGraphNode } from "@/lib/types";
import { cn } from "@/lib/utils";
// 每个父节点下、同一类型的子节点默认展示的数量;超出折叠,"展示更多"每次再拉这么多。
const FOLD_LIMIT = 20;
@@ -274,10 +275,18 @@ function RefList({ title, items }: { title: string; items: CoverageAssetRef[] })
</h4>
<div className="flex flex-col gap-1">
{items.map((r) => (
<div key={`${r.kind}-${r.id}`} className="bg-muted/50 flex items-start gap-2 rounded-md px-2 py-1.5 text-xs">
<span className="text-muted-foreground shrink-0 font-mono">#{r.id}</span>
{r.state && <span className="text-muted-foreground shrink-0">{r.state}</span>}
<span className="text-foreground min-w-0 flex-1 break-words">{r.summary || "—"}</span>
<div
key={`${r.kind}-${r.id}`}
className="flex flex-wrap items-start gap-2 rounded-md bg-muted/50 px-2 py-1.5 text-xs"
>
<span className="shrink-0 font-mono text-muted-foreground">#{r.id}</span>
{r.state && <span className="shrink-0 text-muted-foreground">{r.state}</span>}
<span className="min-w-32 flex-1 break-words text-foreground">{r.summary || "—"}</span>
{r.inherited && r.source_task_id && (
<Badge variant="outline" className="shrink-0">
来源 #{r.source_task_id} · 只读
</Badge>
)}
</div>
))}
</div>
@@ -2,25 +2,15 @@
import * as React from "react";
import Link from "next/link";
import { ArrowUpRightIcon, ChevronRightIcon, Trash2Icon } from "lucide-react";
import { ArrowUpRightIcon, ChevronRightIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/status-badge";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
} from "@/components/ui/select";
@@ -42,12 +32,12 @@ const FINDING_STATUSES: FindingStatus[] = [
function Row({
f,
contextTaskId,
onStatus,
onDelete,
}: {
f: Finding;
contextTaskId: string;
onStatus: (f: Finding, next: FindingStatus) => void;
onDelete: (f: Finding) => void;
}) {
const [open, setOpen] = React.useState(false);
return (
@@ -65,10 +55,17 @@ function Row({
)}
/>
<StatusBadge domain="severity" value={f.severity} dot />
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium">
{f.name || f.vulnclass || "未分类"}
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-medium">
{f.name || f.vulnclass || "未分类"}
</span>
{f.inherited && f.source_task_id && (
<Badge variant="outline" className="shrink-0">
来源 #{f.source_task_id} · 只读
</Badge>
)}
</div>
<span className="truncate text-xs text-muted-foreground">
{f.summary}
</span>
@@ -92,7 +89,7 @@ function Row({
)}
</div>
)}
{f.finding_id ? (
{f.finding_id && !f.inherited ? (
<Select
value={f.status}
onValueChange={(v) => onStatus(f, v as FindingStatus)}
@@ -104,11 +101,13 @@ function Row({
<StatusBadge domain="finding" value={f.status} dot />
</SelectTrigger>
<SelectContent position="popper" align="end">
{FINDING_STATUSES.map((st) => (
<SelectItem key={st} value={st}>
{statusMeta("finding", st).label}
</SelectItem>
))}
<SelectGroup>
{FINDING_STATUSES.map((st) => (
<SelectItem key={st} value={st}>
{statusMeta("finding", st).label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : (
@@ -119,7 +118,11 @@ function Row({
</span>
{f.finding_id && (
<Link
href={`/function/findings/detail?id=${f.finding_id}`}
href={
f.inherited
? `/function/findings/detail?id=${f.finding_id}&context_task=${contextTaskId}`
: `/function/findings/detail?id=${f.finding_id}`
}
className="text-muted-foreground hover:text-primary inline-flex shrink-0 items-center gap-0.5 text-xs"
title="查看漏洞详情"
>
@@ -130,30 +133,8 @@ function Row({
</div>
{open && (
<div className="bg-muted/30 px-4 pb-4 pl-11">
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-xs font-medium text-muted-foreground">证据 / PoC</span>
{f.finding_id && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline" className="h-7 text-destructive">
<Trash2Icon /> 删除
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除该漏洞?</AlertDialogTitle>
<AlertDialogDescription className="break-words">
「<span className="break-all">{f.name || f.vulnclass || f.summary || `#${f.finding_id}`}</span>」将被永久删除,
同时从发现列表、任务发现 Tab 与探索图中移除,此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => onDelete(f)}>删除</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
<div className="mb-1 text-xs font-medium text-muted-foreground">
证据 / PoC
</div>
<pre className="overflow-auto rounded-md border bg-background p-3 font-mono text-xs whitespace-pre-wrap">
{f.evidence}
@@ -187,7 +168,7 @@ export function FindingsTab({ taskId }: { taskId: string }) {
const onStatus = React.useCallback(
async (f: Finding, next: FindingStatus) => {
if (!f.finding_id || next === f.status) return;
if (f.inherited || !f.finding_id || next === f.status) return;
const prev = f.status;
setFindings((cur) =>
cur.map((x) => (x.id === f.id ? { ...x, status: next } : x)),
@@ -205,19 +186,8 @@ export function FindingsTab({ taskId }: { taskId: string }) {
[],
);
const onDelete = React.useCallback(async (f: Finding) => {
if (!f.finding_id) return;
try {
await api.deleteFinding(f.finding_id);
setFindings((cur) => cur.filter((x) => x.id !== f.id));
toast.success("已删除漏洞");
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}, []);
const items = findings
.filter((f) => f.task_id === taskId)
.filter((f) => f.task_id === taskId || f.inherited)
.sort((a, b) => {
const order = { critical: 0, high: 1, medium: 2, low: 3 };
return order[a.severity] - order[b.severity];
@@ -227,11 +197,11 @@ export function FindingsTab({ taskId }: { taskId: string }) {
<Card className="overflow-hidden py-0">
<CardContent className="px-0">
{items.map((f) => (
<Row key={f.id} f={f} onStatus={onStatus} onDelete={onDelete} />
<Row key={f.id} f={f} contextTaskId={taskId} onStatus={onStatus} />
))}
{items.length === 0 && (
<p className="px-4 py-8 text-center text-sm text-muted-foreground">
本任务暂无确认发现。
本任务及直接关联任务暂无确认发现。
</p>
)}
</CardContent>
@@ -1,8 +1,11 @@
"use client";
import * as React from "react";
import { FileTextIcon, CopyIcon, CheckIcon } from "lucide-react";
import { CheckIcon, CopyIcon, FileTextIcon } from "lucide-react";
import { toast } from "sonner";
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";
@@ -39,6 +42,29 @@ export function ReportTab({ taskId }: { taskId: string }) {
setTimeout(() => setCopied(false), 1500);
}
let content: React.ReactNode;
if (loading) {
content = (
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-muted-foreground text-sm">
<FileTextIcon className="size-8 opacity-40" />
加载中…
</div>
);
} else if (report) {
content = (
<div className="max-h-[60vh] overflow-auto rounded-md border bg-muted/20 p-4">
<Markdown text={report} />
</div>
);
} else {
content = (
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-muted-foreground text-sm">
<FileTextIcon className="size-8 opacity-40" />
暂无报告
</div>
);
}
return (
<Card>
<CardHeader className="flex-row items-center justify-between">
@@ -53,23 +79,7 @@ export function ReportTab({ taskId }: { taskId: string }) {
)}
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-sm text-muted-foreground">
<FileTextIcon className="size-8 opacity-40" />
加载中…
</div>
) : report ? (
<pre className="max-h-[60vh] overflow-auto rounded-md border bg-muted/40 p-4 font-mono text-xs whitespace-pre-wrap">
{report}
</pre>
) : (
<div className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed py-16 text-sm text-muted-foreground">
<FileTextIcon className="size-8 opacity-40" />
暂无报告
</div>
)}
</CardContent>
<CardContent>{content}</CardContent>
</Card>
);
}
@@ -1,30 +1,46 @@
"use client";
import * as React from "react";
import {
RadioIcon,
BrainIcon,
UserIcon,
Loader2Icon,
ZapOffIcon,
SendIcon,
SquareIcon,
ClockIcon,
CircleCheckIcon,
CircleXIcon,
CircleSlashIcon,
ShieldAlertIcon,
WifiOffIcon,
RotateCwIcon,
CircleXIcon,
ClockIcon,
HistoryIcon,
Loader2Icon,
PaperclipIcon,
PauseIcon,
PlayIcon,
RadioIcon,
RotateCwIcon,
SendIcon,
ShieldAlertIcon,
SquareIcon,
UserIcon,
WifiOffIcon,
XIcon,
ZapOffIcon,
Trash2Icon,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { Transcript } from "@/components/transcript";
import { TodoPopover } from "@/components/todo-popover";
import { Transcript } from "@/components/transcript";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
@@ -43,6 +59,7 @@ import type {
TaskNode,
TokenTotal,
} from "@/lib/types";
import { cn } from "@/lib/utils";
// fmtBytes renders a human file size for attachment chips (mirrors transcript.tsx).
function fmtBytes(n: number): string {
@@ -63,6 +80,7 @@ function fmtBytes(n: number): string {
// so refresh / tab-switch / sleep / reconnect never drop the newest records.
const PAGE = 200; // history page size
const SYSTEM_SCAN_PAGE = 500; // generic activity pages scanned to recover sparse system audit events
const MAX_KEEP = 4000; // per-session in-memory cap; older pages re-fetched on scroll-up
const STREAM_WINDOW_MS = 5000; // "live" = activity seen within this window
@@ -98,6 +116,7 @@ function emptyState(): SessionState {
// sessionKeyOf routes an activity to its stable session key. worker="planner" covers
// BOTH the Goal Agent's round-0 decomposition and the Planner (single Plan session).
function sessionKeyOf(a: Activity): string {
if (a.worker === "system" || a.kind === "llm_switch" || a.kind === "llm_failover") return "system";
if (a.worker === "mainagent") return "main";
if (a.worker === "planner") return "plan";
if (a.intent_id) return `intent:${a.intent_id}`;
@@ -122,6 +141,8 @@ function statusIcon(status: SessionStatus) {
switch (status) {
case "running": // 执行中
return <Loader2Icon className="size-3.5 animate-spin text-blue-500" />;
case "paused":
return <PauseIcon className="size-3.5 text-amber-500" />;
case "pending": // 待领取(open intent)
return <ClockIcon className="size-3.5 text-muted-foreground" />;
case "done": // 完成
@@ -156,6 +177,7 @@ const roleMeta = {
mainagent: { label: "主 Agent", icon: UserIcon },
planner: { label: "规划 Planner", icon: BrainIcon },
worker: { label: "Workers", icon: RadioIcon },
system: { label: "系统审计", icon: HistoryIcon },
} as const;
// The main-agent session is the interactive entry point of this tab and has no
@@ -185,10 +207,24 @@ const PLANNER_SESSION: Session = {
last_activity: "",
};
// LLM provider switches are task-level audit events rather than agent output. The
// history endpoint has no "system" filter, so this fixed session is populated by
// scanning the generic incremental activity endpoint and then tailed by task SSE.
const SYSTEM_ID = "s-system";
const SYSTEM_SESSION: Session = {
id: SYSTEM_ID,
role: "system",
title: "系统事件 · LLM 故障转移",
status: "done",
live: false,
last_activity: "",
};
// keyForSession maps a UI Session → its stable store key (main | plan | intent:<id>).
function keyForSession(s: Session): string {
if (s.role === "mainagent") return "main";
if (s.role === "planner") return "plan";
if (s.role === "system") return "system";
return `intent:${s.intent_id}`;
}
@@ -203,6 +239,8 @@ function intentStatus(state: string): SessionStatus {
return "exhausted";
case "stopped":
return "stopped";
case "paused":
return "paused";
case "open": // 待领取,区别于执行中
return "pending";
default: // running
@@ -220,9 +258,11 @@ function intentToSession(n: TaskNode): Session {
role: "worker",
title: label || `Intent ${n.id}`,
status: state,
live: state === "running",
live: !n.inherited && state === "running",
last_activity: n.ts,
intent_id: n.id,
source_task_id: n.source_task_id,
inherited: n.inherited,
};
}
@@ -233,6 +273,10 @@ function SessionItem({
hasPending,
unread,
onClick,
onPause,
onResume,
onCancel,
controlling,
}: {
s: Session;
active: boolean;
@@ -240,6 +284,10 @@ function SessionItem({
hasPending?: boolean;
unread?: number;
onClick: () => void;
onPause?: () => void;
onResume?: () => void;
onCancel?: () => void;
controlling?: boolean;
}) {
const icon =
s.role === "worker" ? (
@@ -248,36 +296,57 @@ function SessionItem({
<Loader2Icon className="size-3.5 animate-spin text-blue-500" />
) : null;
const controllable = s.role === "worker" && !s.inherited && (s.status === "running" || s.status === "paused");
return (
<button
onClick={onClick}
<div
className={cn(
"flex w-full items-center gap-1 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
"group/session flex w-full items-center rounded-md transition-colors",
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
)}
>
{icon ?? <span className="size-3.5 shrink-0" />}
{s.intent_id && (
<span className="shrink-0 rounded bg-muted px-1 py-0.5 font-mono text-[10px] tabular-nums text-muted-foreground">
#{s.intent_id}
</span>
<button type="button" onClick={onClick} className="flex min-w-0 flex-1 items-center gap-1 px-2 py-1.5 text-left text-sm">
{icon ?? <span className="size-3.5 shrink-0" />}
{s.intent_id && (
<span className="shrink-0 rounded bg-muted px-1 py-0.5 font-mono text-[10px] tabular-nums text-muted-foreground">
#{s.intent_id}
</span>
)}
{s.inherited && s.source_task_id && (
<Badge variant="outline" className="shrink-0">
来源 #{s.source_task_id}
</Badge>
)}
<span className="min-w-0 flex-1 truncate">{displayTitle}</span>
{hasPending && <ShieldAlertIcon className="size-3.5 shrink-0 text-amber-500" />}
{!active && unread ? (
<span className="inline-flex min-w-4 items-center justify-center rounded-full bg-blue-500/15 px-1 text-[10px] font-medium tabular-nums text-blue-600 dark:text-blue-400">
{unread > 99 ? "99+" : unread}
</span>
) : null}
{s.live && (
<span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<span className="size-1 animate-pulse rounded-full bg-blue-500" />
实时
</span>
)}
</button>
{controllable && (
<div className="flex shrink-0 items-center gap-0.5 pr-1 opacity-100 sm:opacity-0 sm:transition-opacity sm:group-hover/session:opacity-100 sm:group-focus-within/session:opacity-100">
{s.status === "running" ? (
<Button type="button" variant="ghost" size="icon-xs" onClick={onPause} disabled={controlling} title="暂停 Worker" aria-label="暂停 Worker">
{controlling ? <Loader2Icon className="animate-spin" /> : <PauseIcon />}
</Button>
) : (
<Button type="button" variant="ghost" size="icon-xs" onClick={onResume} disabled={controlling} title="恢复 Worker" aria-label="恢复 Worker">
{controlling ? <Loader2Icon className="animate-spin" /> : <PlayIcon />}
</Button>
)}
<Button type="button" variant="ghost" size="icon-xs" onClick={onCancel} disabled={controlling} title="取消并清理 Worker 数据" aria-label="取消并清理 Worker 数据" className="text-destructive hover:text-destructive">
<Trash2Icon />
</Button>
</div>
)}
<span className="min-w-0 flex-1 truncate">{displayTitle}</span>
{hasPending && (
<ShieldAlertIcon className="size-3.5 shrink-0 text-amber-500" />
)}
{!active && unread ? (
<span className="inline-flex min-w-4 items-center justify-center rounded-full bg-blue-500/15 px-1 text-[10px] font-medium tabular-nums text-blue-600 dark:text-blue-400">
{unread > 99 ? "99+" : unread}
</span>
) : null}
{s.live && (
<span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<span className="size-1 animate-pulse rounded-full bg-blue-500" />
实时
</span>
)}
</button>
</div>
);
}
@@ -295,6 +364,8 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const [input, setInput] = React.useState("");
const [sending, setSending] = React.useState(false);
const [stopping, setStopping] = React.useState(false);
const [controllingIntent, setControllingIntent] = React.useState<string | null>(null);
const [cancelIntent, setCancelIntent] = React.useState<Session | null>(null);
// 方式1 文件上传:选好的附件(已落到任务工作目录 uploads/),随下条消息一起发。
const [attachments, setAttachments] = React.useState<ChatAttachment[]>([]);
const [uploading, setUploading] = React.useState(false);
@@ -313,6 +384,42 @@ export function SessionsTab({ taskId }: { taskId: string }) {
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
const patchIntentState = React.useCallback((intentId: string, state?: string) => {
const patch = (rows: TaskNode[]) =>
state ? rows.map((row) => (row.id === intentId ? { ...row, state } : row)) : rows.filter((row) => row.id !== intentId);
setIntents(patch);
setOlderIntents(patch);
}, []);
const controlWorker = React.useCallback(async (session: Session, action: "pause" | "resume" | "cancel") => {
if (!session.intent_id || session.inherited || controllingIntent) return;
setControllingIntent(session.intent_id);
try {
const result = await api.controlIntent(taskId, session.intent_id, action);
if (action === "pause") {
patchIntentState(session.intent_id, "paused");
toast.success(`Worker #${session.intent_id} 已暂停`);
} else if (action === "resume") {
patchIntentState(session.intent_id, "open");
toast.success(`Worker #${session.intent_id} 已恢复,等待重新领取`);
} else {
patchIntentState(session.intent_id);
if (activeId === session.id) setActiveId(MAIN_ID);
const deleted = result.deleted;
toast.success(
deleted
? `Worker #${session.intent_id} 已取消,清理 ${deleted.facts} 条事实、${deleted.findings} 个漏洞`
: `Worker #${session.intent_id} 已取消`,
);
}
} catch (error) {
toast.error("Worker 操作失败:" + (error as Error).message);
} finally {
setControllingIntent(null);
setCancelIntent(null);
}
}, [activeId, controllingIntent, patchIntentState, taskId]);
// SSE connection state — surfaced so a dropped realtime link is visible, never
// silently shown as "no messages".
const [sseLive, setSseLive] = React.useState(false);
@@ -326,6 +433,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const esRef = React.useRef<EventSource | null>(null);
const activeKeyRef = React.useRef("main"); // current session key (for SSE dispatch/unread)
const atBottomRef = React.useRef(true); // transcript pinned to bottom?
const llmToastSeqRef = React.useRef<Set<number>>(new Set());
// Per-key request token: a stale response for a key is ignored (guards fast
// latest/older interleaving). Writes are ALWAYS keyed, so a late response can only
// touch its own session cache — never the currently-viewed one (see §7.5).
@@ -353,6 +461,48 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const token = (reqTokenRef.current[key] ?? 0) + 1;
reqTokenRef.current[key] = token;
patchStore(key, (s) => ({ ...s, loading: true, error: undefined }));
if (key === "system") {
void (async () => {
let since = 0;
let systemItems: Activity[] = [];
for (;;) {
const page = await api.activity(taskId, { since, limit: SYSTEM_SCAN_PAGE });
if (reqTokenRef.current[key] !== token) return;
systemItems = mergeBySeq(
systemItems,
page.items.filter((item) => item.worker === "system" || item.kind === "llm_failover"),
);
if (page.items.length < SYSTEM_SCAN_PAGE || page.cursor <= since) break;
since = page.cursor;
}
patchStore(key, (s) => {
const items = mergeBySeq(systemItems, s.items);
return {
...s,
items,
loaded: true,
loading: false,
hasMore: false,
earliestSeq: items.length ? items[0].seq : 0,
unread: 0,
lastTs: items.length ? items[items.length - 1].ts : s.lastTs,
error: undefined,
};
});
})()
.catch((error) => {
if (reqTokenRef.current[key] !== token) return;
patchStore(key, (s) => ({
...s,
loading: false,
error: (error as Error).message || "加载失败",
}));
})
.finally(() => loadingKeysRef.current.delete(key));
return;
}
api
.activityHistory(taskId, key, 0, PAGE)
.then((r) => {
@@ -424,8 +574,10 @@ export function SessionsTab({ taskId }: { taskId: string }) {
.then((r) => {
if (alive) setTaskTokens(r.total);
})
.catch(() => {});
load();
.catch(() => {
// Polling is best-effort; the next interval retries automatically.
});
void load();
const t = setInterval(load, 5000);
return () => {
alive = false;
@@ -438,8 +590,10 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const load = () =>
api.interceptTask(taskId)
.then((rows) => { if (alive) setPendingIntercepts(rows.filter((r) => r.status === "pending")); })
.catch(() => {});
load();
.catch(() => {
// Polling is best-effort; the next interval retries automatically.
});
void load();
const t = setInterval(load, 5000);
return () => { alive = false; clearInterval(t); };
}, [taskId]);
@@ -490,9 +644,15 @@ export function SessionsTab({ taskId }: { taskId: string }) {
st.lastTs = st.items.length ? st.items[st.items.length - 1].ts : "";
}
buckets.main ??= { ...emptyState(), loaded: true };
buckets.system ??= { ...emptyState(), loaded: true };
setStore(buckets);
})
.catch(() => setStore({ main: { ...emptyState(), loaded: true } }));
.catch(() =>
setStore({
main: { ...emptyState(), loaded: true },
system: { ...emptyState(), loaded: true },
}),
);
return () => {
alive = false;
};
@@ -532,6 +692,17 @@ export function SessionsTab({ taskId }: { taskId: string }) {
} catch {
return; // ignore malformed frame
}
if ((a.kind === "llm_switch" || a.kind === "llm_failover") && !llmToastSeqRef.current.has(a.seq)) {
llmToastSeqRef.current.add(a.seq);
const transition = a.metadata?.llm_transition;
if (transition?.mode === "exhausted" || a.is_error) {
toast.error(a.summary, { id: `task-${taskId}-llm-${a.seq}` });
} else if (transition?.mode === "automatic") {
toast.success(a.summary, { id: `task-${taskId}-llm-${a.seq}` });
} else {
toast.info(a.summary, { id: `task-${taskId}-llm-${a.seq}` });
}
}
const k = sessionKeyOf(a);
setStore((prev) => {
const cur = prev[k] ?? emptyState();
@@ -569,8 +740,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
esRef.current?.close();
esRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskId]);
}, [taskId, patchStore]);
// ── worker (intent) session list — paged, poll first page lightly ───────────────
React.useEffect(() => {
@@ -583,8 +753,10 @@ export function SessionsTab({ taskId }: { taskId: string }) {
setIntents(r.items);
setIntentsHasMore(r.hasMore);
})
.catch(() => {});
load();
.catch(() => {
// Polling is best-effort; the next interval retries automatically.
});
void load();
const t = setInterval(load, 5000);
return () => {
active = false;
@@ -607,7 +779,9 @@ export function SessionsTab({ taskId }: { taskId: string }) {
});
setIntentsHasMore(r.hasMore);
})
.catch(() => {})
.catch(() => {
// A later manual retry can fetch this page again.
})
.finally(() => setLoadingOlderIntents(false));
}, [taskId, intents, olderIntents, loadingOlderIntents]);
@@ -650,6 +824,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
// Worker agent_name format: "work#N · #intentID". Main/planner match by role key.
const hasPendingForSession = React.useCallback(
(s: Session): boolean => {
if (s.inherited) return false;
if (!pendingIntercepts.length) return false;
if (s.role === "mainagent") return pendingIntercepts.some((r) => r.agent_name === "mainagent");
if (s.role === "planner") return pendingIntercepts.some((r) => r.agent_name === "planner");
@@ -680,6 +855,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
{ ...MAIN_SESSION, live: mainLive },
{ ...PLANNER_SESSION, live: plannerLive },
...workerSessions,
SYSTEM_SESSION,
],
[workerSessions, mainLive, plannerLive],
);
@@ -688,16 +864,19 @@ export function SessionsTab({ taskId }: { taskId: string }) {
mainagent: sessions.filter((s) => s.role === "mainagent"),
planner: sessions.filter((s) => s.role === "planner"),
worker: sessions.filter((s) => s.role === "worker"),
system: sessions.filter((s) => s.role === "system"),
};
const active = sessions.find((s) => s.id === activeId) ?? MAIN_SESSION;
const isMain = active.role === "mainagent";
const isPlanner = active.role === "planner";
const isSystem = active.role === "system";
const activeKey = keyForSession(active);
const activeState = store[activeKey];
// Keep the SSE dispatcher's notion of the active session current, and lazily load
// + clear unread whenever the active session changes.
// biome-ignore lint/correctness/useExhaustiveDependencies: cache updates must not reactivate the current session.
React.useEffect(() => {
activeKeyRef.current = activeKey;
const st = store[activeKey];
@@ -706,7 +885,6 @@ export function SessionsTab({ taskId }: { taskId: string }) {
} else if (st.unread) {
patchStore(activeKey, (s) => ({ ...s, unread: 0 }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeKey]);
// Main agent is the human↔orchestrator CONSOLE: only the conversation (user msgs +
@@ -721,7 +899,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const pending = chatExtra.filter((a) => !seen.has(`${a.kind} ${a.summary}`));
return [...items, ...pending].sort((a, b) => a.seq - b.seq);
}
if (isPlanner) 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.
const intentTitle = sessionMeta.get(active.id)?.title ?? active.title;
@@ -731,9 +909,23 @@ export function SessionsTab({ taskId }: { taskId: string }) {
ts: active.last_activity || "",
kind: "intent", // LLM-generated objective — rendered as a distinct (non-human) bubble
summary: intentTitle,
source_task_id: active.source_task_id,
inherited: active.inherited,
};
return [intentMsg, ...items];
}, [isMain, isPlanner, activeState, chatExtra, active.id, active.title, active.last_activity, sessionMeta]);
}, [
isMain,
isPlanner,
isSystem,
activeState,
chatExtra,
active.id,
active.title,
active.last_activity,
active.source_task_id,
active.inherited,
sessionMeta,
]);
// seq of this session's most-recent TodoWrite call — for the Todo popover.
const latestTodoSeq = React.useMemo(() => {
@@ -791,6 +983,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
null,
[],
);
// biome-ignore lint/correctness/useExhaustiveDependencies: activeId intentionally rebinds the scroll listener.
React.useEffect(() => {
const vp = viewport();
if (!vp) return;
@@ -802,6 +995,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
return () => vp.removeEventListener("scroll", onScroll);
}, [viewport, activeId, loadEarlier]);
// open/switch a session → jump to the latest (bottom)
// biome-ignore lint/correctness/useExhaustiveDependencies: activeId intentionally scrolls a newly selected session.
React.useLayoutEffect(() => {
const vp = viewport();
if (vp) {
@@ -810,6 +1004,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
}
}, [activeId, viewport]);
// new activity → stick to bottom only if the user is already pinned there
// biome-ignore lint/correctness/useExhaustiveDependencies: activity growth intentionally drives live-edge scrolling.
React.useLayoutEffect(() => {
if (!atBottomRef.current) return;
const vp = viewport();
@@ -819,7 +1014,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
function stop() {
if (stopping) return;
setStopping(true);
api.stopChat(taskId).finally(() => setStopping(false));
void api.stopChat(taskId).finally(() => setStopping(false));
}
function send() {
@@ -906,7 +1101,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
</div>
<ScrollArea type="auto" className="min-h-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
<div className="flex w-full flex-col gap-3 p-2">
{(["mainagent", "planner", "worker"] as const).map((role) => {
{(["mainagent", "planner", "system", "worker"] as const).map((role) => {
const items = grouped[role];
if (!items.length) return null;
const Meta = roleMeta[role];
@@ -927,11 +1122,16 @@ export function SessionsTab({ taskId }: { taskId: string }) {
hasPending={hasPendingForSession(s)}
unread={store[keyForSession(s)]?.unread}
onClick={() => setActiveId(s.id)}
controlling={controllingIntent === s.intent_id}
onPause={() => void controlWorker(s, "pause")}
onResume={() => void controlWorker(s, "resume")}
onCancel={() => setCancelIntent(s)}
/>
);
})}
{role === "worker" && intentsHasMore && (
<button
type="button"
onClick={loadOlderIntents}
disabled={loadingOlderIntents}
className="mt-0.5 flex items-center justify-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent/50"
@@ -977,6 +1177,11 @@ export function SessionsTab({ taskId }: { taskId: string }) {
</Tooltip>
) : titleEl;
})()}
{active.inherited && active.source_task_id && (
<Badge variant="outline">
来源任务 #{active.source_task_id} · 只读历史
</Badge>
)}
{active.live && (
<span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-[10px] font-medium text-blue-600 dark:text-blue-400">
<span className="size-1 animate-pulse rounded-full bg-blue-500" />
@@ -1070,48 +1275,66 @@ export function SessionsTab({ taskId }: { taskId: string }) {
))}
</div>
)}
<div className="flex items-end gap-2">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => void pickFiles(e.target.files)}
/>
<Button
size="icon"
variant="ghost"
onClick={() => fileInputRef.current?.click()}
disabled={mainLive || uploading}
title="上传文件"
>
{uploading ? <Loader2Icon className="animate-spin" /> : <PaperclipIcon />}
</Button>
<Textarea
className="max-h-40 min-h-9 flex-1 resize-none"
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => void pickFiles(e.target.files)}
/>
<InputGroup className="min-h-9 items-end has-disabled:opacity-100">
<InputGroupAddon align="inline-start" className="self-end">
<InputGroupButton
size="icon-xs"
variant="ghost"
onClick={() => fileInputRef.current?.click()}
disabled={mainLive || uploading}
title="上传文件"
aria-label="上传文件"
>
{uploading ? <Loader2Icon className="animate-spin" /> : <PaperclipIcon />}
</InputGroupButton>
</InputGroupAddon>
<InputGroupTextarea
rows={1}
placeholder="给主 Agent 发消息,Enter 换行,Ctrl+Enter 发送…"
aria-label="给主 Agent 发消息"
placeholder="给主 Agent 发消息,引导探索方向…"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
// Enter 换行;只有 Ctrl/Cmd+Enter 才发送。
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
if (!mainLive) send();
}
if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing) return;
e.preventDefault();
if (!mainLive) send();
}}
disabled={mainLive}
className="max-h-36 min-h-9 overflow-y-auto"
/>
{mainLive ? (
<Button size="icon" variant="destructive" onClick={stop} disabled={stopping} title="停止当前执行">
{stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />}
</Button>
) : (
<Button size="icon" onClick={send} disabled={(!input.trim() && attachments.length === 0) || sending}>
{sending ? <Loader2Icon className="animate-spin" /> : <SendIcon />}
</Button>
)}
</div>
<InputGroupAddon align="inline-end" className="self-end">
{mainLive ? (
<InputGroupButton
size="icon-xs"
variant="destructive"
onClick={stop}
disabled={stopping}
title="停止当前执行"
aria-label="停止当前执行"
>
{stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />}
</InputGroupButton>
) : (
<InputGroupButton
size="icon-xs"
variant="default"
onClick={send}
disabled={(!input.trim() && attachments.length === 0) || sending}
title="发送消息"
aria-label="发送消息"
>
{sending ? <Loader2Icon className="animate-spin" /> : <SendIcon />}
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</div>
) : (
<div className="flex items-center border-t px-4 py-2">
@@ -1122,6 +1345,27 @@ export function SessionsTab({ taskId }: { taskId: string }) {
</div>
)}
</div>
<AlertDialog open={cancelIntent !== null} onOpenChange={(open) => !open && setCancelIntent(null)}>
<AlertDialogContent className="max-w-[min(32rem,calc(100vw-2rem))]">
<AlertDialogHeader>
<AlertDialogTitle>取消 Worker #{cancelIntent?.intent_id}?</AlertDialogTitle>
<AlertDialogDescription className="break-words whitespace-normal">
取消后将删除该意图、执行记录,以及该意图直接登记的事实和漏洞;全局资产、流量和其他 Planner 意图不会删除。此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>返回</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={!cancelIntent || controllingIntent !== null}
onClick={() => cancelIntent && void controlWorker(cancelIntent, "cancel")}
>
{controllingIntent ? <Loader2Icon className="animate-spin" /> : <Trash2Icon />}
确认取消并清理
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TooltipProvider>
);
+232 -50
View File
@@ -9,15 +9,28 @@ import {
PauseIcon,
PlayIcon,
BrainIcon,
CheckIcon,
CircleAlertIcon,
} from "lucide-react";
import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { SidebarTrigger } from "@/components/ui/sidebar";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverDescription,
PopoverHeader,
PopoverTitle,
PopoverTrigger,
} from "@/components/ui/popover";
import { Spinner } from "@/components/ui/spinner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { StatusBadge } from "@/components/status-badge";
import { api } from "@/lib/api";
import type { Task } from "@/lib/types";
import type { LLMProfile, Task } from "@/lib/types";
import { SessionsTab } from "./_tabs/sessions-tab";
import { OverviewTab } from "./_tabs/overview-tab";
@@ -39,6 +52,152 @@ const TABS = [
{ value: "report", label: "报告" },
];
function taskProfileIDs(task: Task): string[] {
if (task.llm_profile_ids && task.llm_profile_ids.length > 0) {
return task.llm_profile_ids.map(String);
}
return task.llm_profile_id ? [String(task.llm_profile_id)] : [];
}
function TaskLLMControl({ task, profiles, onUpdated }: { task: Task; profiles: LLMProfile[]; onUpdated: () => void }) {
const [open, setOpen] = React.useState(false);
const [profileIDs, setProfileIDs] = React.useState<string[]>(() => taskProfileIDs(task));
const [activeProfileID, setActiveProfileID] = React.useState(
task.active_llm_profile_id ? String(task.active_llm_profile_id) : (taskProfileIDs(task)[0] ?? ""),
);
const [saving, setSaving] = React.useState(false);
const popoverContentRef = React.useRef<HTMLDivElement>(null);
const chain = taskProfileIDs(task);
const exhausted = task.llm_failover_state === "chain_exhausted";
const terminal = ["done", "failed", "timeout"].includes(task.status);
const editable = !terminal && (task.status === "running" || task.status === "paused" || exhausted);
// A null active profile on an exhausted, non-empty chain is a persisted end
// cursor. Keep the status display honest; choosing the first profile is only
// the editor's reset draft and does not mean it is currently active.
let activeID = chain[0] ?? "";
if (exhausted) activeID = "";
if (task.active_llm_profile_id) activeID = String(task.active_llm_profile_id);
const activeProfile = profiles.find((profile) => profile.id === activeID);
const currentLabel = exhausted
? "配置链已耗尽"
: activeProfile?.name ?? (activeID ? `配置 #${activeID}` : "跟随默认配置");
const activeIndex = chain.indexOf(activeID);
const backupCount = activeIndex >= 0 ? Math.max(0, chain.length - activeIndex - 1) : 0;
const currentTitle = [currentLabel, activeProfile?.model, backupCount > 0 ? `${backupCount} 个备用` : ""]
.filter(Boolean)
.join(" · ");
let editorDescription = "仅运行中、暂停或配置链耗尽的任务可以修改配置。";
if (editable) editorDescription = "调整顺序或当前配置后,将从下一次 LLM 调用开始生效。";
if (terminal) editorDescription = "已结束的任务仅支持查看配置。";
let saveLabel = "保存";
if (exhausted) saveLabel = "保存并重置";
if (saving) saveLabel = "保存中";
const syncDraft = React.useCallback(() => {
const next = taskProfileIDs(task);
setProfileIDs(next);
setActiveProfileID(task.active_llm_profile_id ? String(task.active_llm_profile_id) : (next[0] ?? ""));
}, [task]);
const handleOpenChange = (next: boolean) => {
setOpen(next);
if (next) syncDraft();
};
const handleProfileIDsChange = (next: string[]) => {
setProfileIDs(next);
setActiveProfileID((current) => (next.includes(current) ? current : (next[0] ?? "")));
};
const save = async () => {
if (!editable) return;
setSaving(true);
try {
const result = await api.updateTaskLLMProfiles(
task.id,
profileIDs.map(Number),
activeProfileID ? Number(activeProfileID) : undefined,
);
if (result.switch_event) {
toast.info(result.switch_event.summary, { id: `task-${task.id}-llm-${result.switch_event.seq}` });
} else {
toast.success(
result.reopened_intents > 0
? `LLM 配置已更新,并恢复 ${result.reopened_intents} 条额度阻塞意图`
: "LLM 配置已更新",
);
}
setOpen(false);
onUpdated();
} catch (error) {
toast.error("更新失败:" + (error as Error).message);
} finally {
setSaving(false);
}
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
size="sm"
variant={exhausted ? "destructive" : "outline"}
aria-label="查看或切换任务 LLM 配置"
title={currentTitle}
>
<BrainIcon data-icon="inline-start" />
<span className="hidden max-w-36 truncate lg:inline">{currentLabel}</span>
{backupCount > 0 && <span className="hidden text-muted-foreground xl:inline">+{backupCount}</span>}
</Button>
</PopoverTrigger>
<PopoverContent
ref={popoverContentRef}
align="start"
className="w-[min(28rem,calc(100vw-2rem))] gap-4 p-4"
>
<PopoverHeader>
<PopoverTitle>任务 LLM 配置链</PopoverTitle>
<PopoverDescription>{editorDescription}</PopoverDescription>
</PopoverHeader>
{exhausted && (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>配置链额度已耗尽</AlertTitle>
<AlertDescription>
{task.llm_failover_reason ?? "所有已选配置均被判定为额度不足。保存配置链可重置故障状态。"}
</AlertDescription>
</Alert>
)}
<TaskLLMProfileChain
profiles={profiles}
value={profileIDs}
onValueChange={handleProfileIDsChange}
activeProfileId={activeProfileID}
onActiveProfileChange={setActiveProfileID}
inputId="task-llm-profiles"
disabled={!editable || saving}
portalContainer={popoverContentRef}
/>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(false)}>
关闭
</Button>
{editable && (
<Button type="button" size="sm" onClick={save} disabled={saving}>
{saving && <Spinner data-icon="inline-start" />}
{saveLabel}
</Button>
)}
</div>
</PopoverContent>
</Popover>
);
}
function TaskDetailInner() {
const searchParams = useSearchParams();
const id = searchParams.get("id") ?? "";
@@ -47,42 +206,62 @@ function TaskDetailInner() {
const [loaded, setLoaded] = React.useState(false);
const [tab, setTab] = React.useState("sessions");
const [interceptPendingCount, setInterceptPendingCount] = React.useState(0);
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
React.useEffect(() => {
api
.llmProfiles()
.then(setProfiles)
.catch(() => setProfiles([]));
}, []);
React.useEffect(() => {
let alive = true;
const load = () =>
api.interceptTask(id)
.then((rows) => { if (alive) setInterceptPendingCount(rows.filter((r) => r.status === "pending").length); })
.catch(() => {});
load();
api
.interceptTask(id)
.then((rows) => {
if (alive) setInterceptPendingCount(rows.filter((r) => r.status === "pending").length);
})
.catch(() => {
// Polling is best-effort; the next interval retries automatically.
});
void load();
const t = setInterval(load, 5000);
return () => { alive = false; clearInterval(t); };
return () => {
alive = false;
clearInterval(t);
};
}, [id]);
const load = React.useCallback(() => {
Promise.all([
api.tasks(),
api.stats(id).catch(() => null),
])
Promise.all([api.tasks(), api.stats(id).catch(() => null)])
.then(([r, s]) => {
const base = r.tasks.find((t) => t.id === id) ?? null;
const at = (s as { active_task?: Partial<Task> & { paused?: boolean } } | null)?.active_task;
const at = s?.active_task;
if (base && at) {
base.in_flight = at.in_flight;
base.goals_total = at.goals_total;
base.goals_met = at.goals_met;
base.engine_mode = at.engine_mode;
base.engine_mode = s?.engine_mode ?? at.engine_mode;
base.paused = at.paused;
}
setTask(base);
setPaused(at?.paused ?? base?.paused ?? false);
})
.catch(() => {})
.catch(() => {
// Keep the last rendered task state during a transient poll failure.
})
.finally(() => setLoaded(true));
}, [id]);
React.useEffect(() => { load(); }, [load]);
React.useEffect(() => {
load();
const timer = setInterval(load, 5000);
return () => clearInterval(timer);
}, [load]);
async function togglePause() {
if (task && ["done", "failed", "timeout"].includes(task.status)) return;
const next = !paused;
try {
await api.controlTask(id, next ? "pause" : "resume");
@@ -108,14 +287,24 @@ function TaskDetailInner() {
);
}
const engineMode = paused ? "paused" : task.engine_mode ?? "idle";
const completed = task.status === "done";
const terminal = ["done", "failed", "timeout"].includes(task.status);
const engineMode = paused ? "paused" : (task.engine_mode ?? "idle");
let controlVariant: "default" | "secondary" | "outline" = "outline";
let controlIcon = <PauseIcon data-icon="inline-start" />;
let controlLabel = "暂停";
if (terminal) {
controlVariant = "secondary";
controlIcon = <CheckIcon data-icon="inline-start" />;
controlLabel = completed ? "已完成" : "已结束";
} else if (paused) {
controlVariant = "default";
controlIcon = <PlayIcon data-icon="inline-start" />;
controlLabel = "恢复";
}
return (
<Tabs
value={tab}
onValueChange={setTab}
className="flex flex-1 flex-col gap-0"
>
<Tabs value={tab} onValueChange={setTab} className="flex flex-1 flex-col gap-0">
{/* Top fixed area */}
<header className="sticky top-0 z-10 flex flex-col gap-2 border-b bg-background/95 px-4 py-2.5 backdrop-blur lg:px-6">
<div className="flex items-center gap-2">
@@ -125,43 +314,36 @@ function TaskDetailInner() {
<ArrowLeftIcon />
</Link>
</Button>
<h1 className="max-w-md truncate text-sm font-semibold" title={task.description}>
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold" title={task.description}>
{task.description}
</h1>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
<code className="hidden rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground sm:inline">
{task.id}
</code>
<Separator orientation="vertical" className="mx-1 h-4" />
{/* status pills */}
<span className="inline-flex items-center gap-1 rounded-md border px-2 py-0.5 text-xs">
<BrainIcon className="size-3.5 text-emerald-500" /> LLM 已配置
</span>
<StatusBadge domain="engine" value={engineMode} dot />
<div className="ml-auto">
<Button
size="sm"
variant={paused ? "default" : "outline"}
onClick={togglePause}
>
{paused ? <PlayIcon /> : <PauseIcon />}
{paused ? "恢复" : "暂停"}
</Button>
</div>
<Separator orientation="vertical" className="mx-1 hidden h-4 sm:block" />
<TaskLLMControl task={task} profiles={profiles} onUpdated={load} />
<StatusBadge domain={terminal ? "task" : "engine"} value={terminal ? task.status : engineMode} dot />
<Button size="sm" variant={controlVariant} onClick={togglePause} disabled={terminal}>
{controlIcon}
{controlLabel}
</Button>
</div>
<p className="truncate text-xs text-muted-foreground">{task.goal}</p>
{/* Tabs */}
<TabsList variant="default">
{TABS.map((t) => (
<TabsTrigger key={t.value} value={t.value}>
{t.label}
{t.value === "intercept" && interceptPendingCount > 0 && (
<span className="ml-1.5 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-semibold leading-none text-white">
{interceptPendingCount > 99 ? "99+" : interceptPendingCount}
</span>
)}
</TabsTrigger>
))}
</TabsList>
<div className="no-scrollbar min-w-0 overflow-x-auto">
<TabsList variant="default" className="min-w-max">
{TABS.map((t) => (
<TabsTrigger key={t.value} value={t.value}>
{t.label}
{t.value === "intercept" && interceptPendingCount > 0 && (
<span className="ml-1.5 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-500 px-1 text-[10px] font-semibold leading-none text-white">
{interceptPendingCount > 99 ? "99+" : interceptPendingCount}
</span>
)}
</TabsTrigger>
))}
</TabsList>
</div>
</header>
{/* Tab content */}
+436 -208
View File
@@ -17,6 +17,7 @@ import {
} from "lucide-react";
import { StatusBadge } from "@/components/status-badge";
import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain";
import {
AlertDialog,
AlertDialogAction,
@@ -41,8 +42,29 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
} from "@/components/ui/combobox";
import {
Field,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
@@ -71,13 +93,21 @@ import {
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TablePagination } from "@/components/table-pagination";
import { api } from "@/lib/api";
import type { Task, TaskStatus, LLMProfile, ChatAttachment } from "@/lib/types";
import type {
Task,
TaskStatus,
LLMProfile,
ChatAttachment,
DeleteTaskOptions,
DeleteTaskResult,
} from "@/lib/types";
// fmtBytes renders a human file size for the upload manifest.
function fmtBytes(n: number): string {
@@ -102,12 +132,10 @@ function appendUploads(desc: string, atts: ChatAttachment[]): string {
return `${head}${UPLOAD_MARKER} worker 可用 Read/Bash 按路径打开:\n${bullets}\n`;
}
// ACTIVE_PROFILE is the sentinel Select value for "use the global active profile".
const ACTIVE_PROFILE = "__active__";
// POLL_MS is the task-list refresh interval. Task state moves on the server (planner /
// worker), so the list has to be pulled; 10s is plenty for status / 进度 / token 变化.
const POLL_MS = 10_000;
const MAX_SOURCE_TASKS = 8;
// fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M).
function fmtTokens(n: number): string {
@@ -144,6 +172,17 @@ function taskDuration(task: Task, nowSec: number): number {
return end > start ? end - start : 0;
}
function deleteSummary(result: DeleteTaskResult): string {
const details: string[] = [];
if (result.assets_deleted > 0) details.push(`删除资产 ${result.assets_deleted} 条`);
if (result.assets_detached > 0) details.push(`解除共享资产关联 ${result.assets_detached} 条`);
if (result.traffic_deleted > 0) details.push(`删除流量 ${result.traffic_deleted} 条`);
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.length > 0 ? `任务已删除(${details.join(",")})` : "任务已删除";
}
// fmtDateTime renders a unix-seconds timestamp as a compact local date-time
// (MM-DD HH:mm), or "—" when unset.
function fmtDateTime(unix?: number): string {
@@ -185,6 +224,7 @@ export default function TasksPage() {
}, [tasks, query, statusFilter]);
// reset to page 1 whenever filters change
// biome-ignore lint/correctness/useExhaustiveDependencies: both filters intentionally reset pagination.
React.useEffect(() => { setPage(1); }, [query, statusFilter]);
const paginated = React.useMemo(
@@ -206,7 +246,9 @@ export default function TasksPage() {
lastRef.current = sig;
setTasks(next);
})
.catch(() => {});
.catch(() => {
// Polling is best-effort; the next interval retries automatically.
});
}, []);
React.useEffect(() => {
@@ -227,18 +269,20 @@ export default function TasksPage() {
return () => clearInterval(i);
}, [hasRunning]);
const deleteTask = React.useCallback(
async (id: string, opts: DeleteOpts) => {
try {
await api.deleteTask(id, opts);
toast.success("任务已删除");
load();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
const deleteTask = React.useCallback(async (id: string, options: DeleteTaskOptions) => {
try {
const result = await api.deleteTask(id, options);
if (result.cleanup_warning) {
toast.warning(`${deleteSummary(result)};部分外部数据清理未完成:${result.cleanup_warning}`);
} else {
toast.success(deleteSummary(result));
}
},
[load],
);
load();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
throw e;
}
}, [load]);
return (
<Card>
@@ -268,19 +312,21 @@ export default function TasksPage() {
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
<SelectGroup>
<SelectItem value="all">全部状态</SelectItem>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs tabular-nums">
{filtered.length}/{tasks.length} 条
</span>
<ConcurrencySettingsDialog />
<CreateTaskSheet onCreated={load} />
<CreateTaskSheet tasks={tasks} onCreated={load} />
</div>
{tasks.length === 0 ? (
@@ -332,19 +378,85 @@ export default function TasksPage() {
);
}
function ConcurrencySettingsDialog() {
const [open, setOpen] = React.useState(false);
const [enabled, setEnabled] = React.useState(false);
const [limit, setLimit] = React.useState("5");
const [loading, setLoading] = React.useState(false);
const [saving, setSaving] = React.useState(false);
React.useEffect(() => {
if (!open) return;
setLoading(true);
api.settings()
.then((settings) => {
setEnabled(!!settings.task_concurrency_enabled);
setLimit(String(settings.task_concurrency_limit ?? 5));
})
.catch(() => {
// Keep the dialog usable with defaults; reopening retries the request.
})
.finally(() => setLoading(false));
}, [open]);
async function save() {
const nextLimit = Math.max(1, Math.floor(Number(limit) || 5));
setSaving(true);
try {
await api.setSettings({ task_concurrency_enabled: enabled, task_concurrency_limit: nextLimit });
toast.success(enabled ? `已开启并发限制:最多同时运行 ${nextLimit} 个任务` : "已关闭任务并发限制");
setOpen(false);
} catch (error) {
toast.error(`保存失败:${(error as Error).message}`);
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="ml-auto" aria-label="任务并发设置">
<SlidersHorizontalIcon /> 并发设置
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>任务并发限制</DialogTitle>
<DialogDescription>
限制同时运行的任务数量。达到上限后,新任务会按创建顺序排队并在空位出现时自动启动。
</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-2">
<div className="flex items-center justify-between gap-4">
<div className="grid gap-1">
<Label htmlFor="task-concurrency-enabled">开启任务并发限制</Label>
<span className="text-muted-foreground text-xs">默认关闭</span>
</div>
<Switch id="task-concurrency-enabled" checked={enabled} onCheckedChange={setEnabled} disabled={loading} />
</div>
{enabled && (
<div className="grid gap-2">
<Label htmlFor="task-concurrency-limit">同时运行上限</Label>
<Input id="task-concurrency-limit" type="number" min={1} className="w-32" value={limit} onChange={(event) => setLimit(event.target.value)} disabled={loading} />
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild><Button variant="outline">取消</Button></DialogClose>
<Button onClick={save} disabled={loading || saving}>
{saving && <Loader2Icon className="animate-spin" />} 保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// TaskRow renders one row of the task table. Memoized so the per-second 运行时长 tick and
// the POLL_MS list refresh only re-render the rows whose data actually moved — a table page
// is 20 rows × (StatusBadge + Link + a Radix AlertDialog tree), far too heavy to rebuild
// wholesale on every parent render.
type DeleteOpts = { assets: boolean; findings: boolean; traffic: boolean; files: boolean };
const DELETE_EXTRAS: { key: keyof DeleteOpts; label: string; hint: string }[] = [
{ key: "assets", label: "删除关联的资产", hint: "仅本任务独有的资产;与其他任务共享的仅解除关联" },
{ key: "findings", label: "删除发现的漏洞", hint: "本任务记录的全部漏洞" },
{ key: "traffic", label: "删除测试的流量", hint: "按本任务涉及的 host 清除录制流量" },
{ key: "files", label: "删除测试过程中写的文件", hint: "本任务工作目录 tasks/<id>(产物 + 上传)" },
];
const TaskRow = React.memo(function TaskRow({
task,
nowSec,
@@ -352,14 +464,8 @@ const TaskRow = React.memo(function TaskRow({
}: {
task: Task;
nowSec: number;
onDelete: (id: string, opts: DeleteOpts) => void;
onDelete: (id: string, options: DeleteTaskOptions) => Promise<void>;
}) {
const [delOpts, setDelOpts] = React.useState<DeleteOpts>({
assets: false,
findings: false,
traffic: false,
files: false,
});
return (
<TableRow className="group border-border/60">
<TableCell>
@@ -425,159 +531,183 @@ const TaskRow = React.memo(function TaskRow({
进入 <ArrowRightIcon />
</Link>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="icon" variant="outline" aria-label="删除任务">
<Trash2Icon className="text-destructive" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除任务 #{task.id}?</AlertDialogTitle>
<AlertDialogDescription className="break-words">
{task.description ? (
<>
「
<span className="break-all">
{task.description.length > 80
? task.description.slice(0, 80) + "…"
: task.description}
</span>
」
</>
) : (
"该任务"
)}
的执行记录、会话与产物将被删除,此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
{/* 额外清理项:默认都不选,勾选后连带删除。全局资产图默认保留。 */}
<div className="grid gap-2 rounded-md border bg-muted/30 p-3">
<span className="text-xs font-medium text-muted-foreground">
同时删除以下关联数据(默认不删):
</span>
{DELETE_EXTRAS.map((opt) => (
<label key={opt.key} className="flex items-start gap-2 text-sm">
<Checkbox
className="mt-0.5"
checked={delOpts[opt.key]}
onCheckedChange={(v) =>
setDelOpts((s) => ({ ...s, [opt.key]: v === true }))
}
/>
<span className="grid gap-0.5">
<span>{opt.label}</span>
<span className="text-xs text-muted-foreground">{opt.hint}</span>
</span>
</label>
))}
</div>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => onDelete(task.id, delOpts)}>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeleteTaskDialog task={task} onDelete={onDelete} />
</div>
</TableCell>
</TableRow>
);
});
// ConcurrencySettingsDialog is the 齿轮 button left of 新建任务: toggle the
// simultaneous-running-task cap and pick the limit. Default off; default limit 5.
// Persisted globally via /api/settings — applies to tasks created afterwards.
function ConcurrencySettingsDialog() {
const emptyDeleteOptions = (): DeleteTaskOptions => ({
delete_assets: false,
delete_traffic: false,
delete_files: false,
delete_findings: false,
delete_llm_records: false,
});
const deleteOptionKeys: (keyof DeleteTaskOptions)[] = [
"delete_assets",
"delete_traffic",
"delete_files",
"delete_findings",
"delete_llm_records",
];
function DeleteTaskDialog({
task,
onDelete,
}: {
task: Task;
onDelete: (id: string, options: DeleteTaskOptions) => Promise<void>;
}) {
const [open, setOpen] = React.useState(false);
const [enabled, setEnabled] = React.useState(false);
const [limit, setLimit] = React.useState("5");
const [loading, setLoading] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [deleting, setDeleting] = React.useState(false);
const [options, setOptions] = React.useState<DeleteTaskOptions>(emptyDeleteOptions);
// 每次打开都拉取当前值,避免显示陈旧状态。
React.useEffect(() => {
if (!open) return;
setLoading(true);
api
.settings()
.then((s) => {
setEnabled(!!s.task_concurrency_enabled);
setLimit(String(s.task_concurrency_limit ?? 5));
})
.catch(() => {})
.finally(() => setLoading(false));
}, [open]);
async function save() {
const n = Math.max(1, Math.floor(Number(limit) || 5));
setSaving(true);
try {
await api.setSettings({ task_concurrency_enabled: enabled, task_concurrency_limit: n });
toast.success(enabled ? `已开启并发限制:最多同时运行 ${n} 个任务` : "已关闭任务并发限制");
setOpen(false);
} catch (e) {
toast.error("保存失败:" + (e as Error).message);
} finally {
setSaving(false);
}
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);
if (next) setOptions(emptyDeleteOptions());
};
const handleDelete = async () => {
setDeleting(true);
try {
await onDelete(task.id, options);
setOpen(false);
} finally {
setDeleting(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline" className="ml-auto" aria-label="任务并发设置">
<SlidersHorizontalIcon /> 并发设置
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogTrigger asChild>
<Button size="icon" variant="outline" aria-label="删除任务">
<Trash2Icon className="text-destructive" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>任务并发限制</DialogTitle>
<DialogDescription>
限制同时「运行中」的任务数量。关闭时不限制;开启后新建任务若已达上限,会进入「排队中」,有空位时自动开始。
</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-2">
<div className="flex items-center justify-between gap-4">
<div className="grid gap-1">
<Label htmlFor="conc-enabled">开启任务并发限制</Label>
<span className="text-muted-foreground text-xs">默认关闭(不限制并发)</span>
</div>
<Switch id="conc-enabled" checked={enabled} onCheckedChange={setEnabled} disabled={loading} />
</div>
{enabled && (
<div className="grid gap-2">
<Label htmlFor="conc-limit">并发数量(同时运行的任务上限)</Label>
<Input
id="conc-limit"
type="number"
min={1}
className="w-32"
value={limit}
onChange={(e) => setLimit(e.target.value)}
disabled={loading}
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除任务 #{task.id}?</AlertDialogTitle>
<AlertDialogDescription className="break-words">
{task.description ? (
<>「<span className="break-all">{task.description.length > 80 ? `${task.description.slice(0, 80)}…` : task.description}</span>」</>
) : "该任务"}
的执行记录与探索链路将被永久删除。
</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)}
/>
<span className="text-muted-foreground text-xs">开启后默认 5,最小 1。</span>
</div>
)}
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="outline">取消</Button>
</DialogClose>
<Button onClick={save} disabled={loading || saving}>
{saving ? <Loader2Icon className="animate-spin" /> : null}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<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>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={(event) => {
event.preventDefault();
void handleDelete();
}}
>
{deleting && <Spinner data-icon="inline-start" />}
{deleting ? "删除中" : "删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -585,12 +715,81 @@ function ConcurrencySettingsDialog() {
// 描述/目标 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
// input lag. Now typing only re-renders the drawer.
function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
function SourceTaskPicker({
tasks,
value,
onValueChange,
portalContainer,
}: {
tasks: Task[];
value: string[];
onValueChange: (value: string[]) => void;
portalContainer?: React.RefObject<HTMLElement | null>;
}) {
const tasksByID = React.useMemo(() => new Map(tasks.map((task) => [task.id, task])), [tasks]);
const taskIDs = React.useMemo(() => tasks.map((task) => task.id), [tasks]);
const atLimit = value.length >= MAX_SOURCE_TASKS;
const handleValueChange = (next: string[]) => {
onValueChange(next.slice(0, MAX_SOURCE_TASKS));
};
return (
<Combobox
items={taskIDs}
itemToStringValue={(taskID) => {
const task = tasksByID.get(taskID);
return task ? `${task.id} ${task.description} ${task.goal}` : taskID;
}}
multiple
value={value}
onValueChange={handleValueChange}
>
<ComboboxChips>
<ComboboxValue>
{value.map((taskID) => (
<ComboboxChip key={taskID}>#{taskID}</ComboboxChip>
))}
</ComboboxValue>
<ComboboxChipsInput
id="source-tasks"
placeholder={atLimit ? `最多关联 ${MAX_SOURCE_TASKS} 个任务` : "搜索任务 ID、描述或目标"}
disabled={atLimit}
/>
</ComboboxChips>
<ComboboxContent portalContainer={portalContainer}>
<ComboboxEmpty>没有匹配的任务</ComboboxEmpty>
<ComboboxList>
{(taskID) => {
const task = tasksByID.get(taskID);
return (
<ComboboxItem key={taskID} value={taskID} disabled={atLimit && !value.includes(taskID)}>
<div className="flex min-w-0 flex-1 items-center gap-2">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
#{taskID} · {task?.description ?? "未知任务"}
</p>
{task?.goal && <p className="text-muted-foreground truncate text-xs">{task.goal}</p>}
</div>
{task && <StatusBadge domain="task" value={task.status} />}
</div>
</ComboboxItem>
);
}}
</ComboboxList>
</ComboboxContent>
</Combobox>
);
}
function CreateTaskSheet({ tasks, onCreated }: { tasks: Task[]; onCreated: () => void }) {
const [open, setOpen] = React.useState(false);
const [description, setDescription] = React.useState("");
const [goal, setGoal] = React.useState("");
const [profiles, setProfiles] = React.useState<LLMProfile[]>([]);
const [llmProfile, setLlmProfile] = React.useState<string>(ACTIVE_PROFILE); // sentinel = active
const [sourceTaskIDs, setSourceTaskIDs] = React.useState<string[]>([]);
const [llmProfileIDs, setLLMProfileIDs] = React.useState<string[]>([]);
const [creating, setCreating] = React.useState(false);
const [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时
const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致)
const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行
@@ -599,6 +798,7 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
const [uploadCount, setUploadCount] = React.useState(0);
const draftIdRef = React.useRef<string>("");
const fileInputRef = React.useRef<HTMLInputElement>(null);
const sheetContentRef = React.useRef<HTMLDivElement>(null);
// load LLM profiles once for the create-task profile picker.
React.useEffect(() => {
@@ -633,15 +833,28 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
toast.error("请填写描述与目标");
return;
}
if (sourceTaskIDs.length > MAX_SOURCE_TASKS) {
toast.error(`最多关联 ${MAX_SOURCE_TASKS} 个来源任务`);
return;
}
setCreating(true);
try {
const pid = llmProfile === ACTIVE_PROFILE ? undefined : Number(llmProfile);
const timeoutSec = Math.max(0, Math.floor(Number(timeoutMin) || 0)) * 60;
const heartbeatSec = Math.max(10, Math.floor(Number(heartbeatMin) || 10)) * 60; // 下限 10min,与后端归一一致
await api.createTask(description.trim(), goal.trim(), pid, timeoutSec, seedFirstIntent, heartbeatSec);
await api.createTask({
description: description.trim(),
goal: goal.trim(),
llmProfileIds: llmProfileIDs.map(Number),
sourceTaskIds: sourceTaskIDs,
timeoutSeconds: timeoutSec,
seedFirstIntent,
planHeartbeatSeconds: heartbeatSec,
});
toast.success("任务已创建");
setDescription("");
setGoal("");
setLlmProfile(ACTIVE_PROFILE);
setSourceTaskIDs([]);
setLLMProfileIDs([]);
setTimeoutMin("");
setHeartbeatMin("10");
setSeedFirstIntent(false);
@@ -651,19 +864,22 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
onCreated();
} catch (e) {
toast.error("创建失败:" + (e as Error).message);
} finally {
setCreating(false);
}
}
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button size="sm">
<Button size="sm" className="ml-auto">
<PlusIcon /> 新建任务
</Button>
</SheetTrigger>
{/* 45vw 宽的右侧抽屉:整屏高度可滚动,长表单不再受弹窗高度限制。窄屏退化为全宽。
内容为 flex 列:头/脚固定,中间字段区 flex-1 独立滚动。 */}
<SheetContent
ref={sheetContentRef}
side="right"
className="w-full! max-w-none! gap-0 p-0 sm:w-[45vw]! sm:max-w-[45vw]!"
>
@@ -723,26 +939,31 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
onChange={(e) => setGoal(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="llm-profile">LLM 配置</Label>
<Select value={llmProfile} onValueChange={setLlmProfile}>
<SelectTrigger id="llm-profile">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ACTIVE_PROFILE}>使用激活配置(默认)</SelectItem>
{profiles.map((p) => (
<SelectItem key={p.id} value={String(p.id)}>
{p.name}
{p.is_default ? "(激活)" : ""} · {p.model}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
本任务的 planner/worker 使用所选配置运行;对话仍用激活配置。
</p>
</div>
<Field>
<FieldLabel htmlFor="source-tasks">关联任务</FieldLabel>
<SourceTaskPicker
tasks={tasks}
value={sourceTaskIDs}
onValueChange={setSourceTaskIDs}
portalContainer={sheetContentRef}
/>
<FieldDescription>
最多关联 {MAX_SOURCE_TASKS} 个任务。实时只读继承所选任务的持久化黑板、资产范围及相关流量;新任务写入独立黑板。
</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="llm-profiles">LLM 配置链</FieldLabel>
<TaskLLMProfileChain
profiles={profiles}
value={llmProfileIDs}
onValueChange={setLLMProfileIDs}
inputId="llm-profiles"
portalContainer={sheetContentRef}
/>
<FieldDescription>
按列表顺序故障转移;第一项为当前配置,仅在明确额度不足时切换下一项。
</FieldDescription>
</Field>
{/* 高级参数默认折叠:超时/心跳/首个意图,展开才占空间,常用路径保持清爽。 */}
<Collapsible>
@@ -785,8 +1006,12 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
</p>
</div>
<div className="grid gap-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={seedFirstIntent} onCheckedChange={(v) => setSeedFirstIntent(!!v)} />
<label htmlFor="seed-first-intent" className="flex items-center gap-2 text-sm">
<Checkbox
id="seed-first-intent"
checked={seedFirstIntent}
onCheckedChange={(v) => setSeedFirstIntent(!!v)}
/>
直接下发首个意图(描述+目标)
</label>
<p className="text-muted-foreground text-xs">
@@ -802,7 +1027,10 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
<SheetClose asChild>
<Button variant="outline">取消</Button>
</SheetClose>
<Button onClick={createTask}>创建</Button>
<Button onClick={createTask} disabled={creating || uploading}>
{creating && <Spinner data-icon="inline-start" />}
{creating ? "创建中" : "创建"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
+4 -3
View File
@@ -23,6 +23,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Markdown } from "@/components/markdown";
import { api } from "@/lib/api";
import { cn } from "@/lib/utils";
import type { Agent, AgentDetail, AgentTrigger, MCPServer, PromptVar, PromptVersion, Settings, SkillItem, Tool } from "@/lib/types";
@@ -397,9 +398,9 @@ export function AgentEditor({ agentKey, onSaved }: { agentKey: string; onSaved?:
<DialogTitle>渲染预览</DialogTitle>
<DialogDescription>所有 {`{{.Var}}`} 已由后端用示例值替换。</DialogDescription>
</DialogHeader>
<pre className="bg-muted max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-md p-3 font-mono text-xs">
{preview}
</pre>
<div className="max-h-[60vh] overflow-auto rounded-md border bg-muted/30 p-3">
<Markdown text={preview} />
</div>
</DialogContent>
</Dialog>
<Button size="sm" onClick={savePrompt}>
+18 -3
View File
@@ -31,6 +31,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/status-badge";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
@@ -235,7 +236,7 @@ function ExploreNode({ data, selected }: NodeProps<ExploreRFNode>) {
const Icon = meta.icon;
const showState = n.type === "goal" || n.type === "intent";
const showPriority = (n.type === "goal" || n.type === "intent") && n.priority > 0;
const isLive = n.type === "intent" && n.state === "running";
const isLive = !n.inherited && n.type === "intent" && n.state === "running";
return (
<div
@@ -273,7 +274,11 @@ function ExploreNode({ data, selected }: NodeProps<ExploreRFNode>) {
{nodeSummary(n) || meta.label}
</div>
<div className="flex items-center justify-between gap-2 px-3 pb-2.5">
{showState ? (
{n.inherited && n.source_task_id ? (
<Badge variant="outline" className="max-w-32 truncate" title={`继承自任务 #${n.source_task_id},只读`}>
来源 #{n.source_task_id}
</Badge>
) : showState ? (
<StatusBadge domain={n.type === "goal" ? "goal" : "intent"} value={n.state} dot className="px-1.5 py-0 text-[10px]" />
) : (
<span className="text-[10px] text-neutral-400 dark:text-neutral-500">{meta.label}</span>
@@ -334,6 +339,11 @@ function NodeDetailSheet({
<SheetTitle className="leading-tight">{meta.label}</SheetTitle>
<span className="text-muted-foreground font-mono text-xs">{node.id}</span>
</div>
{node.inherited && node.source_task_id && (
<Badge variant="outline" className="ml-auto shrink-0">
来源任务 #{node.source_task_id} · 只读
</Badge>
)}
</div>
</SheetHeader>
@@ -355,6 +365,11 @@ function NodeDetailSheet({
<DetailRow label="来源">
<span className="font-mono text-xs">{node.origin}</span>
</DetailRow>
{node.inherited && node.source_task_id && (
<DetailRow label="来源任务">
<span className="font-mono text-xs">#{node.source_task_id}(继承,只读)</span>
</DetailRow>
)}
<DetailRow label="时间">{new Date(node.ts).toLocaleString("zh-CN")}</DetailRow>
</section>
@@ -398,7 +413,7 @@ function ExplorationGraphInner({
React.useEffect(() => {
setSelected((cur) => (cur ? (nodes.find((n) => n.id === cur.id) ?? cur) : cur));
const liveTargets = new Set(
nodes.filter((n) => n.type === "intent" && n.state === "running").map((n) => n.id),
nodes.filter((n) => !n.inherited && n.type === "intent" && n.state === "running").map((n) => n.id),
);
const layout = computeLayout(nodes, edges);
setRfNodes((prev) => {
@@ -0,0 +1,223 @@
"use client";
import * as React from "react";
import { ArrowDownIcon, ArrowUpIcon, XIcon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxContent,
ComboboxEmpty,
ComboboxItem,
ComboboxList,
ComboboxValue,
} from "@/components/ui/combobox";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import type { LLMProfile } from "@/lib/types";
interface TaskLLMProfileChainProps {
profiles: LLMProfile[];
value: string[];
onValueChange: (value: string[]) => void;
activeProfileId?: string;
onActiveProfileChange?: (value: string) => void;
disabled?: boolean;
inputId?: string;
portalContainer?: React.RefObject<HTMLElement | null>;
}
function ProfileRoleBadge({ index, currentIndex }: { index: number; currentIndex: number }) {
if (index === currentIndex) return <Badge variant="default">当前</Badge>;
if (index < currentIndex) {
return (
<Badge variant="outline" title="当前游标之前的配置不会被自动故障转移选中">
已跳过
</Badge>
);
}
return <Badge variant="secondary">备用 {index - currentIndex}</Badge>;
}
export function TaskLLMProfileChain({
profiles,
value,
onValueChange,
activeProfileId,
onActiveProfileChange,
disabled = false,
inputId,
portalContainer,
}: TaskLLMProfileChainProps) {
const profilesByID = React.useMemo(() => new Map(profiles.map((profile) => [profile.id, profile])), [profiles]);
const itemIDs = React.useMemo(() => profiles.map((profile) => profile.id), [profiles]);
const profilesUnavailable = profiles.length === 0;
const currentProfileID = activeProfileId && value.includes(activeProfileId) ? activeProfileId : value[0];
const currentIndex = value.indexOf(currentProfileID);
// api_key_hint is display-only and is empty for valid keys shorter than four
// characters. The task API remains authoritative for profile validation.
const profileLabel = React.useCallback(
(id: string) => {
const profile = profilesByID.get(id);
return profile ? `${profile.name} ${profile.model}` : `配置 #${id}`;
},
[profilesByID],
);
const move = (index: number, offset: -1 | 1) => {
const target = index + offset;
if (target < 0 || target >= value.length) return;
const next = [...value];
[next[index], next[target]] = [next[target], next[index]];
onValueChange(next);
};
const remove = (id: string) => {
const next = value.filter((profileID) => profileID !== id);
onValueChange(next);
if (activeProfileId === id) onActiveProfileChange?.(next[0] ?? "");
};
const handleValueChange = (next: string[]) => {
onValueChange(next);
if (activeProfileId && !next.includes(activeProfileId)) {
onActiveProfileChange?.(next[0] ?? "");
}
};
return (
<div className="flex flex-col gap-3">
<Combobox
items={itemIDs}
itemToStringValue={profileLabel}
multiple
value={value}
onValueChange={handleValueChange}
disabled={disabled}
>
<ComboboxChips>
<ComboboxValue>
{value.map((id) => (
<ComboboxChip key={id}>{profilesByID.get(id)?.name ?? `#${id}`}</ComboboxChip>
))}
</ComboboxValue>
<ComboboxChipsInput
id={inputId}
placeholder={profiles.length > 0 ? "搜索并添加 LLM 配置" : "暂无可用 LLM 配置"}
disabled={disabled ? true : profilesUnavailable}
/>
</ComboboxChips>
<ComboboxContent portalContainer={portalContainer}>
<ComboboxEmpty>没有匹配的 LLM 配置</ComboboxEmpty>
<ComboboxList>
{(id) => {
const profile = profilesByID.get(id);
return (
<ComboboxItem key={id} value={id} disabled={!profile}>
<div className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 items-center gap-2">
<span className="truncate">
{profile?.name ?? `配置 #${id}`}
{profile?.is_default ? "(激活)" : ""}
</span>
</span>
{profile && <span className="truncate text-muted-foreground text-xs">{profile.model}</span>}
</div>
</ComboboxItem>
);
}}
</ComboboxList>
</ComboboxContent>
</Combobox>
{value.length === 0 ? (
<p className="text-muted-foreground text-xs">未指定配置时,任务跟随 Agent 或全局激活配置。</p>
) : (
<div className="flex flex-col divide-y rounded-lg border">
{value.map((id, index) => {
const profile = profilesByID.get(id);
return (
<div
key={id}
className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 px-2.5 py-2 sm:flex"
>
<span className="w-5 shrink-0 text-center text-muted-foreground text-xs tabular-nums">{index + 1}</span>
<div className="min-w-0 sm:flex-1">
<p className="truncate font-medium text-sm">{profile?.name ?? `配置 #${id}`}</p>
<div className="flex min-w-0 items-center gap-2">
<p className="truncate text-muted-foreground text-xs">{profile?.model ?? "配置已不可用"}</p>
</div>
</div>
<ProfileRoleBadge index={index} currentIndex={currentIndex} />
<div className="col-span-2 col-start-2 flex shrink-0 items-center gap-1 justify-self-end sm:col-auto sm:justify-self-auto">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="上移配置"
onClick={() => move(index, -1)}
disabled={disabled ? true : index === 0}
>
<ArrowUpIcon data-icon="inline-start" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="下移配置"
onClick={() => move(index, 1)}
disabled={disabled ? true : index === value.length - 1}
>
<ArrowDownIcon data-icon="inline-start" />
</Button>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="移除配置"
onClick={() => remove(id)}
disabled={disabled}
>
<XIcon data-icon="inline-start" />
</Button>
</div>
</div>
);
})}
</div>
)}
{onActiveProfileChange && value.length > 0 && (
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<span className="font-medium text-sm">当前配置</span>
<Select
value={activeProfileId && value.includes(activeProfileId) ? activeProfileId : value[0]}
onValueChange={onActiveProfileChange}
disabled={disabled}
>
<SelectTrigger size="sm" className="w-full sm:min-w-48 sm:max-w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{value.map((id) => {
const profile = profilesByID.get(id);
return (
<SelectItem key={id} value={id} disabled={!profile}>
{profile?.name ?? `配置 #${id}`}
</SelectItem>
);
})}
</SelectGroup>
</SelectContent>
</Select>
</div>
)}
</div>
);
}
+5 -2
View File
@@ -18,6 +18,7 @@ import {
import { toast } from "sonner";
import { api } from "@/lib/api";
import { Markdown } from "@/components/markdown";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { Activity } from "@/lib/types";
@@ -208,7 +209,7 @@ function InterceptCard({
}, [pendingId]);
async function decide(decision: "allowed" | "denied") {
if (!pendingId || deciding) return;
if (step.inherited || !pendingId || deciding) return;
setDeciding(true);
try {
await api.interceptDecide(pendingId, decision);
@@ -246,7 +247,9 @@ function InterceptCard({
</div>
</div>
{decided ? (
{step.inherited ? (
<Badge variant="outline">历史记录 · 只读</Badge>
) : decided ? (
<span className={
"shrink-0 rounded px-2 py-0.5 text-[11px] font-medium " +
(decided === "allowed"
+4 -4
View File
@@ -4,7 +4,7 @@ import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
import { CheckIcon, MinusIcon } from "lucide-react"
function Checkbox({
className,
@@ -21,10 +21,10 @@ function Checkbox({
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
className="group/checkbox-indicator grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
<CheckIcon className="group-data-[state=indeterminate]/checkbox-indicator:hidden" />
<MinusIcon className="hidden group-data-[state=indeterminate]/checkbox-indicator:block" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
+5 -2
View File
@@ -93,14 +93,17 @@ function ComboboxContent({
align = "start",
alignOffset = 0,
anchor,
portalContainer,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<
ComboboxPrimitive.Positioner.Props,
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
>) {
> & {
portalContainer?: ComboboxPrimitive.Portal.Props["container"]
}) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Portal container={portalContainer}>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
+54 -20
View File
@@ -6,7 +6,7 @@
import { MOCK } from "@/lib/mock/enabled";
import { mockHandle } from "@/lib/mock/handler";
import type {
Task, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Severity, FindingStatus,
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,
@@ -41,7 +41,19 @@ async function http<T>(path: string, init?: RequestInit): Promise<T> {
}
throw new Error("未授权");
}
if (!r.ok) throw new Error(`${init?.method ?? "GET"} ${path}: ${r.status}`);
if (!r.ok) {
const fallback = `${init?.method ?? "GET"} ${path}: ${r.status}`;
let message = fallback;
try {
const payload = (await r.json()) as { error?: unknown };
if (typeof payload.error === "string" && payload.error.trim()) {
message = payload.error.trim();
}
} catch {
// Keep the status-based fallback for empty or non-JSON error responses.
}
throw new Error(message);
}
if (r.status === 204) return undefined as T;
return r.json();
}
@@ -113,26 +125,45 @@ export const api = {
// ---- tasks ----
tasks: () => get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })),
createTask: (description: string, goal: string, llmProfileId?: number, timeoutSeconds?: number, seedFirstIntent?: boolean, planHeartbeatSeconds?: number) =>
createTask: (input: {
description: string;
goal: string;
llmProfileIds?: number[];
sourceTaskIds?: string[];
timeoutSeconds?: number;
seedFirstIntent?: boolean;
planHeartbeatSeconds?: number;
}) =>
post<Task>("/tasks", {
description,
goal,
llm_profile_id: llmProfileId ?? null,
timeout_seconds: timeoutSeconds ?? 0,
seed_first_intent: seedFirstIntent ?? false,
plan_heartbeat_seconds: planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
description: input.description,
goal: input.goal,
llm_profile_ids: input.llmProfileIds ?? [],
source_task_ids: input.sourceTaskIds ?? [],
timeout_seconds: input.timeoutSeconds ?? 0,
seed_first_intent: input.seedFirstIntent ?? false,
plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
}),
// 删除任务;opts 里的四个可选项默认不删:assets 关联资产、findings 发现漏洞、
// traffic 测试流量(按任务 host 清)、files 测试过程写的文件(tasks/<id> 目录)。
deleteTask: (
id: string,
opts?: { assets?: boolean; findings?: boolean; traffic?: boolean; files?: boolean },
) =>
del<{ deleted: string; assets_deleted?: number; findings_deleted?: number; traffic_deleted?: number; files_deleted?: boolean }>(
`/tasks/${id}`,
opts,
),
updateTaskLLMProfiles: (id: string, llmProfileIds: number[], activeLLMProfileId?: number) =>
put<{
id: string;
llm_profile_ids: number[];
active_llm_profile_id?: number;
llm_failover_state: string;
reopened_intents: number;
switch_event?: Activity;
}>(`/tasks/${id}/llm`, {
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 }),
controlIntent: (taskId: string, intentId: string, action: "pause" | "resume" | "cancel") =>
post<{
id: number;
state: "paused" | "open" | "cancelled";
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`),
// 批量重跑本任务全部 blocked 意图(一次网络/LLM 断连导致多条 blocked 时一键全部重试)。
@@ -257,7 +288,10 @@ export const api = {
return get<FindingsPage>(`/exploration/findings?${p.toString()}`);
},
findingStats: () => get<FindingStats>("/exploration/findings/stats"),
getFinding: (id: string) => get<Finding>(`/exploration/findings/${id}`),
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`),
+28 -6
View File
@@ -29,6 +29,10 @@ export const tasks: Task[] = [
paused: false, active: true, in_flight: 3, stalled: false,
goals_total: 5, goals_met: 2, engine_mode: "exploring",
llm_profile_id: 1,
llm_profile_ids: [1, 2],
active_llm_profile_id: 1,
llm_failover_state: "ready",
source_task_ids: [],
tokens: { input_tokens: 1284500, output_tokens: 96320, cache_read_tokens: 890400, cache_write_tokens: 132000 },
},
{
@@ -43,6 +47,10 @@ export const tasks: Task[] = [
paused: false, active: false, in_flight: 2, stalled: false,
goals_total: 4, goals_met: 1, engine_mode: "exploring",
llm_profile_id: 1,
llm_profile_ids: [1, 2],
active_llm_profile_id: 1,
llm_failover_state: "ready",
source_task_ids: ["t-acme-web"],
tokens: { input_tokens: 642300, output_tokens: 51200, cache_read_tokens: 401000, cache_write_tokens: 60000 },
},
{
@@ -57,6 +65,10 @@ export const tasks: Task[] = [
paused: true, active: false, in_flight: 0, stalled: false,
goals_total: 4, goals_met: 1, engine_mode: "paused",
llm_profile_id: 2,
llm_profile_ids: [2, 1],
active_llm_profile_id: 2,
llm_failover_state: "ready",
source_task_ids: ["t-acme-web", "t-acme-api"],
tokens: { input_tokens: 233000, output_tokens: 18700, cache_read_tokens: 120000, cache_write_tokens: 22000 },
},
{
@@ -73,6 +85,10 @@ export const tasks: Task[] = [
paused: false, active: false, in_flight: 0, stalled: false,
goals_total: 3, goals_met: 3, engine_mode: "idle",
llm_profile_id: 1,
llm_profile_ids: [1],
active_llm_profile_id: 1,
llm_failover_state: "ready",
source_task_ids: [],
tokens: { input_tokens: 512000, output_tokens: 40100, cache_read_tokens: 300000, cache_write_tokens: 41000 },
},
];
@@ -90,20 +106,26 @@ const grandTotal: TokenTotal = tasks.reduce(
);
// ── Stats ──────────────────────────────────────────────────────────────────
export function stats(taskId?: string): Stats & { active_task?: unknown } {
export function stats(
taskId?: string,
state: { tasks?: readonly Task[]; findings?: readonly Finding[]; activeTask?: string } = {},
): Stats & { active_task?: unknown } {
const taskList = state.tasks ?? tasks;
const findingList = state.findings ?? findings;
const base: Stats = {
assets: assets.length,
engine_mode: "exploring",
llm_configured: true,
roe_enabled: true,
findings_confirmed: findings.filter((f) => f.severity !== "low").length,
findings_confirmed: findingList.filter((f) => f.severity !== "low").length,
};
const t = taskId ? getTask(taskId) : getTask(ACTIVE_TASK);
const selectedTaskID = taskId ?? state.activeTask ?? ACTIVE_TASK;
const t = taskList.find((item) => item.id === selectedTaskID);
if (!t) return base;
return {
...base,
engine_mode: t.engine_mode ?? "idle",
findings_confirmed: findings.filter((f) => f.task_id === t.id).length,
findings_confirmed: findingList.filter((f) => f.task_id === t.id).length,
active_task: {
id: t.id, in_flight: t.in_flight, goals_total: t.goals_total,
goals_met: t.goals_met, engine_mode: t.engine_mode, paused: t.paused,
@@ -892,8 +914,8 @@ const llmTaskCounts: Record<string, number> = {};
for (const r of llmRecords) if (r.task_id) llmTaskCounts[r.task_id] = (llmTaskCounts[r.task_id] ?? 0) + 1;
export const llmTasks: LLMTask[] = Object.entries(llmTaskCounts).map(([task_id, count]) => ({ task_id, count }));
export function llmRecordDetail(id: number) {
const item = llmRecords.find((r) => r.id === id) ?? llmRecords[0];
export function llmRecordDetail(id: number, records = llmRecords) {
const item = records.find((r) => r.id === id) ?? records[0];
return {
...item,
request_body: JSON.stringify(
+162 -27
View File
@@ -2,10 +2,18 @@
// 未命中的一律返回安全默认([] / {} / {ok:true}),保证任何页面都不崩。
// 只在 NEXT_PUBLIC_MOCK=1 时经由 api.ts 的 http() 短路进入这里。
import type { Task } from "../types";
import * as D from "./data";
const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms));
// Requests mutate a runtime copy, never the exported fixtures. This keeps module
// initialization deterministic for tests/HMR while preserving state across mock calls.
const mockTasks = structuredClone(D.tasks);
const mockFindings = structuredClone(D.findings);
const mockLLMRecords = structuredClone(D.llmRecords);
let mockActiveTask = D.ACTIVE_TASK;
function parseBody(body?: BodyInit | null): Record<string, unknown> {
if (typeof body !== "string") return {};
try {
@@ -34,13 +42,107 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/auth/change-password") return { ok: true };
// ── tasks ──
if (path === "/tasks" && m === "GET") return { tasks: D.tasks, active: D.ACTIVE_TASK };
if (path === "/tasks" && m === "POST")
return { ...D.tasks[0], id: "t-new", description: String(b.description ?? "新任务"), goal: String(b.goal ?? ""), status: "created" };
if (seg[0] === "tasks" && seg.length === 2 && m === "DELETE") return { deleted: 1 };
if (path === "/tasks" && m === "GET") return { tasks: mockTasks, active: mockActiveTask };
if (path === "/tasks" && m === "POST") {
let suffix = 1;
while (mockTasks.some((item) => item.id === `t-new-${suffix}`)) suffix++;
const id = `t-new-${suffix}`;
const now = new Date();
const profileIDs = [...((b.llm_profile_ids as number[] | undefined) ?? [])];
const sourceTaskIDs = [...((b.source_task_ids as string[] | undefined) ?? [])];
const created: Task = {
id,
description: String(b.description ?? "新任务"),
goal: String(b.goal ?? ""),
status: "created",
created_at: now.toISOString(),
created_unix: Math.floor(now.getTime() / 1000),
paused: false,
active: true,
in_flight: 0,
stalled: false,
goals_total: 0,
goals_met: 0,
engine_mode: "idle",
tokens: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 },
llm_profile_id: profileIDs[0],
llm_profile_ids: profileIDs,
active_llm_profile_id: profileIDs[0],
llm_failover_state: profileIDs.length ? "ready" : "default",
source_task_ids: sourceTaskIDs,
};
for (const item of mockTasks) item.active = false;
mockTasks.unshift(created);
mockActiveTask = id;
return created;
}
if (seg[0] === "tasks" && seg.length === 2 && m === "DELETE") {
const id = seg[1];
const index = mockTasks.findIndex((item) => item.id === id);
if (index >= 0) mockTasks.splice(index, 1);
let findingsDeleted = 0;
if (b.delete_findings) {
for (let i = mockFindings.length - 1; i >= 0; i--) {
if (mockFindings[i].task_id !== id) continue;
mockFindings.splice(i, 1);
findingsDeleted++;
}
}
let llmRecordsDeleted = 0;
if (b.delete_llm_records) {
for (let i = mockLLMRecords.length - 1; i >= 0; i--) {
if (mockLLMRecords[i].task_id !== id) continue;
mockLLMRecords.splice(i, 1);
llmRecordsDeleted++;
}
}
if (mockActiveTask === id) {
const nextActive = mockTasks[0]?.id ?? "";
mockActiveTask = nextActive;
for (const item of mockTasks) item.active = item.id === nextActive;
}
return {
deleted: id,
assets_deleted: b.delete_assets ? 1 : 0,
assets_detached: 0,
traffic_deleted: b.delete_traffic ? 1 : 0,
files_deleted: Boolean(b.delete_files),
findings_deleted: findingsDeleted,
llm_records_deleted: llmRecordsDeleted,
};
}
if (seg[0] === "tasks" && seg[2] === "llm" && m === "PUT") {
const ids = [...((b.llm_profile_ids as number[] | undefined) ?? [])];
const activeID = typeof b.active_llm_profile_id === "number" ? b.active_llm_profile_id : ids[0];
const target = mockTasks.find((item) => item.id === seg[1]);
if (target) {
target.llm_profile_ids = ids;
target.llm_profile_id = ids[0];
target.active_llm_profile_id = activeID;
target.llm_failover_state = ids.length ? "ready" : "default";
target.llm_failover_reason = undefined;
}
return {
id: seg[1],
llm_profile_ids: ids,
active_llm_profile_id: activeID,
llm_failover_state: ids.length ? "ready" : "default",
reopened_intents: 0,
};
}
if (seg[0] === "tasks" && seg[2] === "control") return { id: seg[1], paused: b.action === "pause" };
if (seg[0] === "tasks" && seg[2] === "chat" && seg[3] === "stop") return { status: "stopped" };
if (path === "/active") return { active: String(b.id ?? D.ACTIVE_TASK) };
if (path === "/active") {
const id = String(b.id ?? mockActiveTask);
if (mockTasks.some((item) => item.id === id)) {
mockActiveTask = id;
for (const item of mockTasks) item.active = item.id === id;
}
return { active: mockActiveTask };
}
// ── 覆盖度 / 覆盖图 / 资产关联(任务维度)──
if (seg[0] === "tasks" && seg[2] === "coverage" && seg.length === 3) return D.coverage;
@@ -52,7 +154,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/workspace/read") return D.workspaceRead(q.get("path") ?? "");
// ── stats ──
if (path === "/stats") return D.stats(task);
if (path === "/stats") {
return D.stats(task, { tasks: mockTasks, findings: mockFindings, activeTask: mockActiveTask });
}
// ── assets ──
if (path === "/assets/counts") return D.assetCounts;
@@ -74,10 +178,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
// ── exploration ──
if (path === "/exploration/frontier") return D.frontier;
if (path === "/exploration/findings/stats") {
const vulnclasses = Array.from(new Set(D.findings.map((f) => f.vulnclass))).sort();
const vulnclasses = Array.from(new Set(mockFindings.map((f) => f.vulnclass))).sort();
// 「按任务」下拉:有漏洞的任务 + 描述 + 条数(mock 任务 id 是字符串,直接当 id 用)。
const taskMap = new Map<string, { description: string; count: number }>();
for (const f of D.findings) {
for (const f of mockFindings) {
if (!f.task_id) continue;
const cur = taskMap.get(f.task_id) ?? { description: f.task_description ?? "", count: 0 };
cur.count++;
@@ -85,12 +189,12 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
}
const tasks = Array.from(taskMap, ([id, v]) => ({ id, description: v.description, count: v.count }));
return {
total: D.findings.length,
pending: D.findings.filter((f) => f.status === "pending").length,
critical: D.findings.filter((f) => f.severity === "critical").length,
high: D.findings.filter((f) => f.severity === "high").length,
medium: D.findings.filter((f) => f.severity === "medium").length,
low: D.findings.filter((f) => f.severity === "low").length,
total: mockFindings.length,
pending: mockFindings.filter((f) => f.status === "pending").length,
critical: mockFindings.filter((f) => f.severity === "critical").length,
high: mockFindings.filter((f) => f.severity === "high").length,
medium: mockFindings.filter((f) => f.severity === "medium").length,
low: mockFindings.filter((f) => f.severity === "low").length,
vulnclasses,
tasks,
};
@@ -102,7 +206,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
seg.length === 3 &&
seg[2] !== "stats"
) {
const f = D.findings.find((x) => x.id === seg[2]);
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;
@@ -111,21 +215,42 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (typeof b.name === "string") f.name = b.name;
if (typeof b.vulnclass === "string") f.vulnclass = b.vulnclass;
}
return { ...f, finding_id: f.id };
const contextTaskId = q.get("context_task");
const contextTask = contextTaskId ? mockTasks.find((item) => item.id === contextTaskId) : undefined;
const inherited = !!(
contextTask &&
f.task_id &&
f.task_id !== contextTask.id &&
contextTask.source_task_ids?.includes(f.task_id)
);
return {
...f,
finding_id: f.id,
...(inherited ? { inherited: true, source_task_id: f.task_id } : {}),
};
}
if (path === "/exploration/findings") {
// finding_id=id:真后端用独立表行 id 作为状态/详情句柄,mock 里用自身 id 顶上。
// report 仅详情接口返回,列表剥掉(与后端一致)。
const withFid = (f: (typeof D.findings)[number]) => ({
const withFid = (f: (typeof mockFindings)[number]) => ({
...f,
report: undefined,
finding_id: f.id,
});
if (task) return D.findings.filter((f) => f.task_id === task).map(withFid);
if (task) {
const owner = mockTasks.find((item) => item.id === task);
const sources = new Set(owner?.source_task_ids ?? []);
return mockFindings
.filter((f) => f.task_id === task || (!!f.task_id && sources.has(f.task_id)))
.map((f) => ({
...withFid(f),
...(f.task_id !== task ? { inherited: true, source_task_id: f.task_id } : {}),
}));
}
// 全局:带 page/limit → 分页对象;否则裸数组(dashboard)。
if (!q.has("page") && !q.has("limit")) return D.findings.map(withFid);
if (!q.has("page") && !q.has("limit")) return mockFindings.map(withFid);
const sev = { critical: 4, high: 3, medium: 2, low: 1 } as const;
let list = D.findings.slice();
let list = mockFindings.slice();
const fSev = q.get("severity");
const fStatus = q.get("status");
const fVuln = q.get("vulnclass");
@@ -151,9 +276,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/exploration/intents") {
if (q.has("page")) {
const before = Number(q.get("before") ?? 0);
const limit = Number(q.get("limit") ?? 300);
const limit = Math.max(1, Number(q.get("limit") ?? 300));
let list = D.intents;
if (before > 0) list = list.filter((n) => Number(n.id.replace(/\D/g, "") || n.id) < before);
if (before > 0) list = list.filter((intent) => Number(intent.id.replace(/\D/g, "") || intent.id) < before);
return { items: list.slice(0, limit), has_more: list.length > limit };
}
return D.intents;
@@ -161,8 +286,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/exploration/tokens") return { workers: D.tokenWorkers, total: D.tokenTotal };
if (path === "/exploration/graph") return D.explorationGraph;
if (path === "/exploration/activity" && seg.length === 2) {
const items = D.activityForTask();
return { items, cursor: items.length ? items[items.length - 1].seq : 0 };
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);
return { items, cursor: items.length ? items[items.length - 1].seq : since };
}
if (seg[0] === "exploration" && seg[1] === "activity" && seg.length === 3) {
const a = D.activity.find((x) => x.seq === Number(seg[2]));
@@ -189,10 +316,18 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
if (path === "/commands" && m === "GET") return { commands: D.commandRecords, total: D.commandRecords.length };
// ── LLM ──
if (path === "/llm/records" && m === "GET") return { records: D.llmRecords, total: D.llmRecords.length };
if (path === "/llm/records" && m === "GET") return { records: mockLLMRecords, total: mockLLMRecords.length };
if (path === "/llm/records" && m === "DELETE") return { deleted: 0 };
if (path === "/llm/records/tasks") return { tasks: D.llmTasks };
if (seg[0] === "llm" && seg[1] === "records" && seg.length === 3 && m === "GET") return D.llmRecordDetail(Number(seg[2]));
if (path === "/llm/records/tasks") {
const counts = new Map<string, number>();
for (const record of mockLLMRecords) {
if (record.task_id) counts.set(record.task_id, (counts.get(record.task_id) ?? 0) + 1);
}
return { tasks: [...counts].map(([task_id, count]) => ({ task_id, count })) };
}
if (seg[0] === "llm" && seg[1] === "records" && seg.length === 3 && m === "GET") {
return D.llmRecordDetail(Number(seg[2]), mockLLMRecords);
}
if (path === "/llm" && m === "GET") return D.llmConfig;
if (path === "/llm" && m === "POST") return { ok: true };
if (path === "/llm/test") return { ok: true, latency_ms: 128, model: String(b.model ?? "claude-opus-4-8") };
+1
View File
@@ -47,6 +47,7 @@ interface StatusMeta {
const intent: Record<string, StatusMeta> = {
open: { label: "待领", tone: "slate" },
running: { label: "执行中", tone: "blue" },
paused: { label: "已暂停", tone: "amber" },
done: { label: "已完成", tone: "green" },
blocked: { label: "被拦截", tone: "red" },
exhausted: { label: "已穷尽", tone: "violet" },
+57 -2
View File
@@ -15,6 +15,7 @@ export interface Task {
completed_unix?: number; // completed_at as unix seconds (0/undef if unfinished)
last_activity_unix?: number; // unix seconds of the last activity (0/undef if none)
paused?: boolean;
queued?: boolean;
active?: boolean;
in_flight?: number;
last_activity?: string;
@@ -24,6 +25,30 @@ export interface Task {
engine_mode?: EngineMode;
tokens?: TokenTotal; // whole-task token consumption
llm_profile_id?: number; // LLM profile used; absent = default profile
llm_profile_ids?: number[]; // ordered task-level failover chain
active_llm_profile_id?: number; // profile used by the next LLM call
llm_failover_state?: "default" | "ready" | "chain_exhausted" | string;
llm_failover_reason?: string;
source_task_ids?: string[]; // directly related tasks inherited as read-only context
}
export interface DeleteTaskOptions {
delete_assets: boolean;
delete_traffic: boolean;
delete_files: boolean;
delete_findings: boolean;
delete_llm_records: boolean;
}
export interface DeleteTaskResult {
deleted: string;
assets_deleted: number;
assets_detached: number;
traffic_deleted: number;
files_deleted: boolean;
findings_deleted: number;
llm_records_deleted: number;
cleanup_warning?: string;
}
// ---- Asset graph (global, shared across tasks) ----
@@ -171,6 +196,8 @@ export interface CoverageAssetRef {
kind: string;
state: string;
summary: string;
source_task_id?: string;
inherited?: boolean;
}
export interface CoverageAssetRefs {
intents: CoverageAssetRef[];
@@ -234,6 +261,8 @@ export interface TaskNode {
state: string; // GoalState | IntentState | FindingState | HintState
origin: string;
ts: string;
source_task_id?: string;
inherited?: boolean;
}
// ---- Findings ----
@@ -271,6 +300,8 @@ export interface Finding {
param_id?: string;
task_id?: string;
task_description?: string;
source_task_id?: string;
inherited?: boolean;
assets?: FindingAsset[];
ts: string;
}
@@ -325,6 +356,8 @@ export type ActivityKind =
| "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)。
@@ -346,6 +379,11 @@ export interface Activity {
is_error?: boolean;
summary: string;
detail?: string;
metadata?: {
llm_transition?: LLMTransition;
};
source_task_id?: string;
inherited?: boolean;
// token usage (present only on kind='result')
input_tokens?: number;
output_tokens?: number;
@@ -353,6 +391,20 @@ export interface Activity {
cache_write_tokens?: number;
}
export interface LLMAuditProfile {
id: number;
name: string;
format: string;
model: string;
}
export interface LLMTransition {
mode: "automatic" | "manual" | "exhausted";
reason: string;
previous?: LLMAuditProfile;
next?: LLMAuditProfile;
}
// ---- Agent triggers (P3 调度,仅自定义 agent) ----
export interface AgentTrigger {
id: number;
@@ -394,8 +446,8 @@ export interface LogLine {
text: string;
}
export type SessionRole = "mainagent" | "planner" | "worker";
export type SessionStatus = "running" | "done" | "blocked" | "exhausted" | "pending" | "stopped";
export type SessionRole = "mainagent" | "planner" | "worker" | "system";
export type SessionStatus = "running" | "paused" | "done" | "blocked" | "exhausted" | "pending" | "stopped";
// Daily token aggregate bucket (GET /api/tokens/daily).
export interface DailyTokenBucket {
@@ -431,6 +483,8 @@ export interface Session {
live: boolean;
last_activity: string;
intent_id?: string;
source_task_id?: string;
inherited?: boolean;
}
// ---- Security ----
@@ -669,6 +723,7 @@ export interface Stats {
llm_configured: boolean;
roe_enabled: boolean;
findings_confirmed: number;
active_task?: Partial<Task>;
}
// ---- Intercept Rules ----