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:
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>
|
||||
);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user