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>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { api } from "@/lib/api";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Agent, AgentDetail, AgentTrigger, MCPServer, PromptVar, PromptVersion, Settings, SkillItem, Tool } from "@/lib/types";
|
||||
@@ -397,9 +398,9 @@ export function AgentEditor({ agentKey, onSaved }: { agentKey: string; onSaved?:
|
||||
<DialogTitle>渲染预览</DialogTitle>
|
||||
<DialogDescription>所有 {`{{.Var}}`} 已由后端用示例值替换。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<pre className="bg-muted max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-md p-3 font-mono text-xs">
|
||||
{preview}
|
||||
</pre>
|
||||
<div className="max-h-[60vh] overflow-auto rounded-md border bg-muted/30 p-3">
|
||||
<Markdown text={preview} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button size="sm" onClick={savePrompt}>
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
@@ -235,7 +236,7 @@ function ExploreNode({ data, selected }: NodeProps<ExploreRFNode>) {
|
||||
const Icon = meta.icon;
|
||||
const showState = n.type === "goal" || n.type === "intent";
|
||||
const showPriority = (n.type === "goal" || n.type === "intent") && n.priority > 0;
|
||||
const isLive = n.type === "intent" && n.state === "running";
|
||||
const isLive = !n.inherited && n.type === "intent" && n.state === "running";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -273,7 +274,11 @@ function ExploreNode({ data, selected }: NodeProps<ExploreRFNode>) {
|
||||
{nodeSummary(n) || meta.label}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 px-3 pb-2.5">
|
||||
{showState ? (
|
||||
{n.inherited && n.source_task_id ? (
|
||||
<Badge variant="outline" className="max-w-32 truncate" title={`继承自任务 #${n.source_task_id},只读`}>
|
||||
来源 #{n.source_task_id}
|
||||
</Badge>
|
||||
) : showState ? (
|
||||
<StatusBadge domain={n.type === "goal" ? "goal" : "intent"} value={n.state} dot className="px-1.5 py-0 text-[10px]" />
|
||||
) : (
|
||||
<span className="text-[10px] text-neutral-400 dark:text-neutral-500">{meta.label}</span>
|
||||
@@ -334,6 +339,11 @@ function NodeDetailSheet({
|
||||
<SheetTitle className="leading-tight">{meta.label}</SheetTitle>
|
||||
<span className="text-muted-foreground font-mono text-xs">{node.id}</span>
|
||||
</div>
|
||||
{node.inherited && node.source_task_id && (
|
||||
<Badge variant="outline" className="ml-auto shrink-0">
|
||||
来源任务 #{node.source_task_id} · 只读
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
@@ -355,6 +365,11 @@ function NodeDetailSheet({
|
||||
<DetailRow label="来源">
|
||||
<span className="font-mono text-xs">{node.origin}</span>
|
||||
</DetailRow>
|
||||
{node.inherited && node.source_task_id && (
|
||||
<DetailRow label="来源任务">
|
||||
<span className="font-mono text-xs">#{node.source_task_id}(继承,只读)</span>
|
||||
</DetailRow>
|
||||
)}
|
||||
<DetailRow label="时间">{new Date(node.ts).toLocaleString("zh-CN")}</DetailRow>
|
||||
</section>
|
||||
|
||||
@@ -398,7 +413,7 @@ function ExplorationGraphInner({
|
||||
React.useEffect(() => {
|
||||
setSelected((cur) => (cur ? (nodes.find((n) => n.id === cur.id) ?? cur) : cur));
|
||||
const liveTargets = new Set(
|
||||
nodes.filter((n) => n.type === "intent" && n.state === "running").map((n) => n.id),
|
||||
nodes.filter((n) => !n.inherited && n.type === "intent" && n.state === "running").map((n) => n.id),
|
||||
);
|
||||
const layout = computeLayout(nodes, edges);
|
||||
setRfNodes((prev) => {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { ArrowDownIcon, ArrowUpIcon, XIcon } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
ComboboxChips,
|
||||
ComboboxChipsInput,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxValue,
|
||||
} from "@/components/ui/combobox";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import type { LLMProfile } from "@/lib/types";
|
||||
|
||||
interface TaskLLMProfileChainProps {
|
||||
profiles: LLMProfile[];
|
||||
value: string[];
|
||||
onValueChange: (value: string[]) => void;
|
||||
activeProfileId?: string;
|
||||
onActiveProfileChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
inputId?: string;
|
||||
portalContainer?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
function ProfileRoleBadge({ index, currentIndex }: { index: number; currentIndex: number }) {
|
||||
if (index === currentIndex) return <Badge variant="default">当前</Badge>;
|
||||
if (index < currentIndex) {
|
||||
return (
|
||||
<Badge variant="outline" title="当前游标之前的配置不会被自动故障转移选中">
|
||||
已跳过
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="secondary">备用 {index - currentIndex}</Badge>;
|
||||
}
|
||||
|
||||
export function TaskLLMProfileChain({
|
||||
profiles,
|
||||
value,
|
||||
onValueChange,
|
||||
activeProfileId,
|
||||
onActiveProfileChange,
|
||||
disabled = false,
|
||||
inputId,
|
||||
portalContainer,
|
||||
}: TaskLLMProfileChainProps) {
|
||||
const profilesByID = React.useMemo(() => new Map(profiles.map((profile) => [profile.id, profile])), [profiles]);
|
||||
const itemIDs = React.useMemo(() => profiles.map((profile) => profile.id), [profiles]);
|
||||
const profilesUnavailable = profiles.length === 0;
|
||||
const currentProfileID = activeProfileId && value.includes(activeProfileId) ? activeProfileId : value[0];
|
||||
const currentIndex = value.indexOf(currentProfileID);
|
||||
// api_key_hint is display-only and is empty for valid keys shorter than four
|
||||
// characters. The task API remains authoritative for profile validation.
|
||||
|
||||
const profileLabel = React.useCallback(
|
||||
(id: string) => {
|
||||
const profile = profilesByID.get(id);
|
||||
return profile ? `${profile.name} ${profile.model}` : `配置 #${id}`;
|
||||
},
|
||||
[profilesByID],
|
||||
);
|
||||
|
||||
const move = (index: number, offset: -1 | 1) => {
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= value.length) return;
|
||||
const next = [...value];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
onValueChange(next);
|
||||
};
|
||||
|
||||
const remove = (id: string) => {
|
||||
const next = value.filter((profileID) => profileID !== id);
|
||||
onValueChange(next);
|
||||
if (activeProfileId === id) onActiveProfileChange?.(next[0] ?? "");
|
||||
};
|
||||
|
||||
const handleValueChange = (next: string[]) => {
|
||||
onValueChange(next);
|
||||
if (activeProfileId && !next.includes(activeProfileId)) {
|
||||
onActiveProfileChange?.(next[0] ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Combobox
|
||||
items={itemIDs}
|
||||
itemToStringValue={profileLabel}
|
||||
multiple
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<ComboboxChips>
|
||||
<ComboboxValue>
|
||||
{value.map((id) => (
|
||||
<ComboboxChip key={id}>{profilesByID.get(id)?.name ?? `#${id}`}</ComboboxChip>
|
||||
))}
|
||||
</ComboboxValue>
|
||||
<ComboboxChipsInput
|
||||
id={inputId}
|
||||
placeholder={profiles.length > 0 ? "搜索并添加 LLM 配置" : "暂无可用 LLM 配置"}
|
||||
disabled={disabled ? true : profilesUnavailable}
|
||||
/>
|
||||
</ComboboxChips>
|
||||
<ComboboxContent portalContainer={portalContainer}>
|
||||
<ComboboxEmpty>没有匹配的 LLM 配置</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(id) => {
|
||||
const profile = profilesByID.get(id);
|
||||
return (
|
||||
<ComboboxItem key={id} value={id} disabled={!profile}>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate">
|
||||
{profile?.name ?? `配置 #${id}`}
|
||||
{profile?.is_default ? "(激活)" : ""}
|
||||
</span>
|
||||
</span>
|
||||
{profile && <span className="truncate text-muted-foreground text-xs">{profile.model}</span>}
|
||||
</div>
|
||||
</ComboboxItem>
|
||||
);
|
||||
}}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
|
||||
{value.length === 0 ? (
|
||||
<p className="text-muted-foreground text-xs">未指定配置时,任务跟随 Agent 或全局激活配置。</p>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y rounded-lg border">
|
||||
{value.map((id, index) => {
|
||||
const profile = profilesByID.get(id);
|
||||
return (
|
||||
<div
|
||||
key={id}
|
||||
className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-2 px-2.5 py-2 sm:flex"
|
||||
>
|
||||
<span className="w-5 shrink-0 text-center text-muted-foreground text-xs tabular-nums">{index + 1}</span>
|
||||
<div className="min-w-0 sm:flex-1">
|
||||
<p className="truncate font-medium text-sm">{profile?.name ?? `配置 #${id}`}</p>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<p className="truncate text-muted-foreground text-xs">{profile?.model ?? "配置已不可用"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ProfileRoleBadge index={index} currentIndex={currentIndex} />
|
||||
<div className="col-span-2 col-start-2 flex shrink-0 items-center gap-1 justify-self-end sm:col-auto sm:justify-self-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="上移配置"
|
||||
onClick={() => move(index, -1)}
|
||||
disabled={disabled ? true : index === 0}
|
||||
>
|
||||
<ArrowUpIcon data-icon="inline-start" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="下移配置"
|
||||
onClick={() => move(index, 1)}
|
||||
disabled={disabled ? true : index === value.length - 1}
|
||||
>
|
||||
<ArrowDownIcon data-icon="inline-start" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="移除配置"
|
||||
onClick={() => remove(id)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<XIcon data-icon="inline-start" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onActiveProfileChange && value.length > 0 && (
|
||||
<div className="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<span className="font-medium text-sm">当前配置</span>
|
||||
<Select
|
||||
value={activeProfileId && value.includes(activeProfileId) ? activeProfileId : value[0]}
|
||||
onValueChange={onActiveProfileChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-full sm:min-w-48 sm:max-w-64">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{value.map((id) => {
|
||||
const profile = profilesByID.get(id);
|
||||
return (
|
||||
<SelectItem key={id} value={id} disabled={!profile}>
|
||||
{profile?.name ?? `配置 #${id}`}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { toast } from "sonner";
|
||||
import { api } from "@/lib/api";
|
||||
import { Markdown } from "@/components/markdown";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { Activity } from "@/lib/types";
|
||||
|
||||
@@ -208,7 +209,7 @@ function InterceptCard({
|
||||
}, [pendingId]);
|
||||
|
||||
async function decide(decision: "allowed" | "denied") {
|
||||
if (!pendingId || deciding) return;
|
||||
if (step.inherited || !pendingId || deciding) return;
|
||||
setDeciding(true);
|
||||
try {
|
||||
await api.interceptDecide(pendingId, decision);
|
||||
@@ -246,7 +247,9 @@ function InterceptCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{decided ? (
|
||||
{step.inherited ? (
|
||||
<Badge variant="outline">历史记录 · 只读</Badge>
|
||||
) : decided ? (
|
||||
<span className={
|
||||
"shrink-0 rounded px-2 py-0.5 text-[11px] font-medium " +
|
||||
(decided === "allowed"
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { CheckIcon, MinusIcon } from "lucide-react"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
@@ -21,10 +21,10 @@ function Checkbox({
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
className="group/checkbox-indicator grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
>
|
||||
<CheckIcon
|
||||
/>
|
||||
<CheckIcon className="group-data-[state=indeterminate]/checkbox-indicator:hidden" />
|
||||
<MinusIcon className="hidden group-data-[state=indeterminate]/checkbox-indicator:block" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
|
||||
@@ -93,14 +93,17 @@ function ComboboxContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
portalContainer,
|
||||
...props
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<
|
||||
ComboboxPrimitive.Positioner.Props,
|
||||
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
|
||||
>) {
|
||||
> & {
|
||||
portalContainer?: ComboboxPrimitive.Portal.Props["container"]
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Portal container={portalContainer}>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
|
||||
+54
-20
@@ -6,7 +6,7 @@
|
||||
import { MOCK } from "@/lib/mock/enabled";
|
||||
import { mockHandle } from "@/lib/mock/handler";
|
||||
import type {
|
||||
Task, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Severity, FindingStatus,
|
||||
Task, DeleteTaskOptions, DeleteTaskResult, Stats, Asset, AssetNode, Edge, Company, TaskNode, Finding, Severity, FindingStatus,
|
||||
FindingsPage, FindingStats, FindingQuery, Activity,
|
||||
Audit, TrafficResp, TrafficDetail, TrafficHost, Settings, LLMProfile, Agent, AgentDetail, PromptVar,
|
||||
PromptVersion, MCPServer, MCPTool, SkillItem, TokenUsage, TokenTotal, DailyTokenBucket,
|
||||
@@ -41,7 +41,19 @@ async function http<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
throw new Error("未授权");
|
||||
}
|
||||
if (!r.ok) throw new Error(`${init?.method ?? "GET"} ${path}: ${r.status}`);
|
||||
if (!r.ok) {
|
||||
const fallback = `${init?.method ?? "GET"} ${path}: ${r.status}`;
|
||||
let message = fallback;
|
||||
try {
|
||||
const payload = (await r.json()) as { error?: unknown };
|
||||
if (typeof payload.error === "string" && payload.error.trim()) {
|
||||
message = payload.error.trim();
|
||||
}
|
||||
} catch {
|
||||
// Keep the status-based fallback for empty or non-JSON error responses.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
if (r.status === 204) return undefined as T;
|
||||
return r.json();
|
||||
}
|
||||
@@ -113,26 +125,45 @@ export const api = {
|
||||
|
||||
// ---- tasks ----
|
||||
tasks: () => get<{ tasks: Task[]; active: string }>("/tasks").then((r) => ({ tasks: arr(r.tasks), active: r.active ?? "" })),
|
||||
createTask: (description: string, goal: string, llmProfileId?: number, timeoutSeconds?: number, seedFirstIntent?: boolean, planHeartbeatSeconds?: number) =>
|
||||
createTask: (input: {
|
||||
description: string;
|
||||
goal: string;
|
||||
llmProfileIds?: number[];
|
||||
sourceTaskIds?: string[];
|
||||
timeoutSeconds?: number;
|
||||
seedFirstIntent?: boolean;
|
||||
planHeartbeatSeconds?: number;
|
||||
}) =>
|
||||
post<Task>("/tasks", {
|
||||
description,
|
||||
goal,
|
||||
llm_profile_id: llmProfileId ?? null,
|
||||
timeout_seconds: timeoutSeconds ?? 0,
|
||||
seed_first_intent: seedFirstIntent ?? false,
|
||||
plan_heartbeat_seconds: planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
|
||||
description: input.description,
|
||||
goal: input.goal,
|
||||
llm_profile_ids: input.llmProfileIds ?? [],
|
||||
source_task_ids: input.sourceTaskIds ?? [],
|
||||
timeout_seconds: input.timeoutSeconds ?? 0,
|
||||
seed_first_intent: input.seedFirstIntent ?? false,
|
||||
plan_heartbeat_seconds: input.planHeartbeatSeconds ?? 0, // 0 = 后端归一到默认 600(10min)
|
||||
}),
|
||||
// 删除任务;opts 里的四个可选项默认不删:assets 关联资产、findings 发现漏洞、
|
||||
// traffic 测试流量(按任务 host 清)、files 测试过程写的文件(tasks/<id> 目录)。
|
||||
deleteTask: (
|
||||
id: string,
|
||||
opts?: { assets?: boolean; findings?: boolean; traffic?: boolean; files?: boolean },
|
||||
) =>
|
||||
del<{ deleted: string; assets_deleted?: number; findings_deleted?: number; traffic_deleted?: number; files_deleted?: boolean }>(
|
||||
`/tasks/${id}`,
|
||||
opts,
|
||||
),
|
||||
updateTaskLLMProfiles: (id: string, llmProfileIds: number[], activeLLMProfileId?: number) =>
|
||||
put<{
|
||||
id: string;
|
||||
llm_profile_ids: number[];
|
||||
active_llm_profile_id?: number;
|
||||
llm_failover_state: string;
|
||||
reopened_intents: number;
|
||||
switch_event?: Activity;
|
||||
}>(`/tasks/${id}/llm`, {
|
||||
llm_profile_ids: llmProfileIds,
|
||||
active_llm_profile_id: activeLLMProfileId ?? null,
|
||||
}),
|
||||
deleteTask: (id: string, options: DeleteTaskOptions) =>
|
||||
del<DeleteTaskResult>(`/tasks/${id}`, options),
|
||||
controlTask: (id: string, action: "pause" | "resume") => post<{ id: string; paused: boolean }>(`/tasks/${id}/control`, { action }),
|
||||
controlIntent: (taskId: string, intentId: string, action: "pause" | "resume" | "cancel") =>
|
||||
post<{
|
||||
id: number;
|
||||
state: "paused" | "open" | "cancelled";
|
||||
deleted?: { intents: number; facts: number; findings: number; activities: number };
|
||||
}>(`/tasks/${taskId}/intents/${intentId}/control`, { action }),
|
||||
// 重跑一条没跑成功的意图(blocked/exhausted/stopped):置回 open,worker 会重新认领、从头再跑。
|
||||
rerunIntent: (taskId: string, intentId: string) => post<{ id: string; reopened: number }>(`/tasks/${taskId}/intents/${intentId}/rerun`),
|
||||
// 批量重跑本任务全部 blocked 意图(一次网络/LLM 断连导致多条 blocked 时一键全部重试)。
|
||||
@@ -257,7 +288,10 @@ export const api = {
|
||||
return get<FindingsPage>(`/exploration/findings?${p.toString()}`);
|
||||
},
|
||||
findingStats: () => get<FindingStats>("/exploration/findings/stats"),
|
||||
getFinding: (id: string) => get<Finding>(`/exploration/findings/${id}`),
|
||||
getFinding: (id: string, contextTaskId?: string) =>
|
||||
get<Finding>(
|
||||
`/exploration/findings/${id}${contextTaskId ? `?context_task=${encodeURIComponent(contextTaskId)}` : ""}`,
|
||||
),
|
||||
// 漏洞链路:该漏洞节点回溯到任务初始节点的子图(节点 + 关系)。
|
||||
findingLineage: (id: string) =>
|
||||
get<{ nodes: TaskNode[]; edges: Edge[] }>(`/exploration/findings/${id}/lineage`),
|
||||
|
||||
@@ -29,6 +29,10 @@ export const tasks: Task[] = [
|
||||
paused: false, active: true, in_flight: 3, stalled: false,
|
||||
goals_total: 5, goals_met: 2, engine_mode: "exploring",
|
||||
llm_profile_id: 1,
|
||||
llm_profile_ids: [1, 2],
|
||||
active_llm_profile_id: 1,
|
||||
llm_failover_state: "ready",
|
||||
source_task_ids: [],
|
||||
tokens: { input_tokens: 1284500, output_tokens: 96320, cache_read_tokens: 890400, cache_write_tokens: 132000 },
|
||||
},
|
||||
{
|
||||
@@ -43,6 +47,10 @@ export const tasks: Task[] = [
|
||||
paused: false, active: false, in_flight: 2, stalled: false,
|
||||
goals_total: 4, goals_met: 1, engine_mode: "exploring",
|
||||
llm_profile_id: 1,
|
||||
llm_profile_ids: [1, 2],
|
||||
active_llm_profile_id: 1,
|
||||
llm_failover_state: "ready",
|
||||
source_task_ids: ["t-acme-web"],
|
||||
tokens: { input_tokens: 642300, output_tokens: 51200, cache_read_tokens: 401000, cache_write_tokens: 60000 },
|
||||
},
|
||||
{
|
||||
@@ -57,6 +65,10 @@ export const tasks: Task[] = [
|
||||
paused: true, active: false, in_flight: 0, stalled: false,
|
||||
goals_total: 4, goals_met: 1, engine_mode: "paused",
|
||||
llm_profile_id: 2,
|
||||
llm_profile_ids: [2, 1],
|
||||
active_llm_profile_id: 2,
|
||||
llm_failover_state: "ready",
|
||||
source_task_ids: ["t-acme-web", "t-acme-api"],
|
||||
tokens: { input_tokens: 233000, output_tokens: 18700, cache_read_tokens: 120000, cache_write_tokens: 22000 },
|
||||
},
|
||||
{
|
||||
@@ -73,6 +85,10 @@ export const tasks: Task[] = [
|
||||
paused: false, active: false, in_flight: 0, stalled: false,
|
||||
goals_total: 3, goals_met: 3, engine_mode: "idle",
|
||||
llm_profile_id: 1,
|
||||
llm_profile_ids: [1],
|
||||
active_llm_profile_id: 1,
|
||||
llm_failover_state: "ready",
|
||||
source_task_ids: [],
|
||||
tokens: { input_tokens: 512000, output_tokens: 40100, cache_read_tokens: 300000, cache_write_tokens: 41000 },
|
||||
},
|
||||
];
|
||||
@@ -90,20 +106,26 @@ const grandTotal: TokenTotal = tasks.reduce(
|
||||
);
|
||||
|
||||
// ── Stats ──────────────────────────────────────────────────────────────────
|
||||
export function stats(taskId?: string): Stats & { active_task?: unknown } {
|
||||
export function stats(
|
||||
taskId?: string,
|
||||
state: { tasks?: readonly Task[]; findings?: readonly Finding[]; activeTask?: string } = {},
|
||||
): Stats & { active_task?: unknown } {
|
||||
const taskList = state.tasks ?? tasks;
|
||||
const findingList = state.findings ?? findings;
|
||||
const base: Stats = {
|
||||
assets: assets.length,
|
||||
engine_mode: "exploring",
|
||||
llm_configured: true,
|
||||
roe_enabled: true,
|
||||
findings_confirmed: findings.filter((f) => f.severity !== "low").length,
|
||||
findings_confirmed: findingList.filter((f) => f.severity !== "low").length,
|
||||
};
|
||||
const t = taskId ? getTask(taskId) : getTask(ACTIVE_TASK);
|
||||
const selectedTaskID = taskId ?? state.activeTask ?? ACTIVE_TASK;
|
||||
const t = taskList.find((item) => item.id === selectedTaskID);
|
||||
if (!t) return base;
|
||||
return {
|
||||
...base,
|
||||
engine_mode: t.engine_mode ?? "idle",
|
||||
findings_confirmed: findings.filter((f) => f.task_id === t.id).length,
|
||||
findings_confirmed: findingList.filter((f) => f.task_id === t.id).length,
|
||||
active_task: {
|
||||
id: t.id, in_flight: t.in_flight, goals_total: t.goals_total,
|
||||
goals_met: t.goals_met, engine_mode: t.engine_mode, paused: t.paused,
|
||||
@@ -892,8 +914,8 @@ const llmTaskCounts: Record<string, number> = {};
|
||||
for (const r of llmRecords) if (r.task_id) llmTaskCounts[r.task_id] = (llmTaskCounts[r.task_id] ?? 0) + 1;
|
||||
export const llmTasks: LLMTask[] = Object.entries(llmTaskCounts).map(([task_id, count]) => ({ task_id, count }));
|
||||
|
||||
export function llmRecordDetail(id: number) {
|
||||
const item = llmRecords.find((r) => r.id === id) ?? llmRecords[0];
|
||||
export function llmRecordDetail(id: number, records = llmRecords) {
|
||||
const item = records.find((r) => r.id === id) ?? records[0];
|
||||
return {
|
||||
...item,
|
||||
request_body: JSON.stringify(
|
||||
|
||||
+162
-27
@@ -2,10 +2,18 @@
|
||||
// 未命中的一律返回安全默认([] / {} / {ok:true}),保证任何页面都不崩。
|
||||
// 只在 NEXT_PUBLIC_MOCK=1 时经由 api.ts 的 http() 短路进入这里。
|
||||
|
||||
import type { Task } from "../types";
|
||||
import * as D from "./data";
|
||||
|
||||
const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Requests mutate a runtime copy, never the exported fixtures. This keeps module
|
||||
// initialization deterministic for tests/HMR while preserving state across mock calls.
|
||||
const mockTasks = structuredClone(D.tasks);
|
||||
const mockFindings = structuredClone(D.findings);
|
||||
const mockLLMRecords = structuredClone(D.llmRecords);
|
||||
let mockActiveTask = D.ACTIVE_TASK;
|
||||
|
||||
function parseBody(body?: BodyInit | null): Record<string, unknown> {
|
||||
if (typeof body !== "string") return {};
|
||||
try {
|
||||
@@ -34,13 +42,107 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/auth/change-password") return { ok: true };
|
||||
|
||||
// ── tasks ──
|
||||
if (path === "/tasks" && m === "GET") return { tasks: D.tasks, active: D.ACTIVE_TASK };
|
||||
if (path === "/tasks" && m === "POST")
|
||||
return { ...D.tasks[0], id: "t-new", description: String(b.description ?? "新任务"), goal: String(b.goal ?? ""), status: "created" };
|
||||
if (seg[0] === "tasks" && seg.length === 2 && m === "DELETE") return { deleted: 1 };
|
||||
if (path === "/tasks" && m === "GET") return { tasks: mockTasks, active: mockActiveTask };
|
||||
if (path === "/tasks" && m === "POST") {
|
||||
let suffix = 1;
|
||||
while (mockTasks.some((item) => item.id === `t-new-${suffix}`)) suffix++;
|
||||
const id = `t-new-${suffix}`;
|
||||
const now = new Date();
|
||||
const profileIDs = [...((b.llm_profile_ids as number[] | undefined) ?? [])];
|
||||
const sourceTaskIDs = [...((b.source_task_ids as string[] | undefined) ?? [])];
|
||||
const created: Task = {
|
||||
id,
|
||||
description: String(b.description ?? "新任务"),
|
||||
goal: String(b.goal ?? ""),
|
||||
status: "created",
|
||||
created_at: now.toISOString(),
|
||||
created_unix: Math.floor(now.getTime() / 1000),
|
||||
paused: false,
|
||||
active: true,
|
||||
in_flight: 0,
|
||||
stalled: false,
|
||||
goals_total: 0,
|
||||
goals_met: 0,
|
||||
engine_mode: "idle",
|
||||
tokens: { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0 },
|
||||
llm_profile_id: profileIDs[0],
|
||||
llm_profile_ids: profileIDs,
|
||||
active_llm_profile_id: profileIDs[0],
|
||||
llm_failover_state: profileIDs.length ? "ready" : "default",
|
||||
source_task_ids: sourceTaskIDs,
|
||||
};
|
||||
for (const item of mockTasks) item.active = false;
|
||||
mockTasks.unshift(created);
|
||||
mockActiveTask = id;
|
||||
return created;
|
||||
}
|
||||
if (seg[0] === "tasks" && seg.length === 2 && m === "DELETE") {
|
||||
const id = seg[1];
|
||||
const index = mockTasks.findIndex((item) => item.id === id);
|
||||
if (index >= 0) mockTasks.splice(index, 1);
|
||||
|
||||
let findingsDeleted = 0;
|
||||
if (b.delete_findings) {
|
||||
for (let i = mockFindings.length - 1; i >= 0; i--) {
|
||||
if (mockFindings[i].task_id !== id) continue;
|
||||
mockFindings.splice(i, 1);
|
||||
findingsDeleted++;
|
||||
}
|
||||
}
|
||||
|
||||
let llmRecordsDeleted = 0;
|
||||
if (b.delete_llm_records) {
|
||||
for (let i = mockLLMRecords.length - 1; i >= 0; i--) {
|
||||
if (mockLLMRecords[i].task_id !== id) continue;
|
||||
mockLLMRecords.splice(i, 1);
|
||||
llmRecordsDeleted++;
|
||||
}
|
||||
}
|
||||
|
||||
if (mockActiveTask === id) {
|
||||
const nextActive = mockTasks[0]?.id ?? "";
|
||||
mockActiveTask = nextActive;
|
||||
for (const item of mockTasks) item.active = item.id === nextActive;
|
||||
}
|
||||
return {
|
||||
deleted: id,
|
||||
assets_deleted: b.delete_assets ? 1 : 0,
|
||||
assets_detached: 0,
|
||||
traffic_deleted: b.delete_traffic ? 1 : 0,
|
||||
files_deleted: Boolean(b.delete_files),
|
||||
findings_deleted: findingsDeleted,
|
||||
llm_records_deleted: llmRecordsDeleted,
|
||||
};
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "llm" && m === "PUT") {
|
||||
const ids = [...((b.llm_profile_ids as number[] | undefined) ?? [])];
|
||||
const activeID = typeof b.active_llm_profile_id === "number" ? b.active_llm_profile_id : ids[0];
|
||||
const target = mockTasks.find((item) => item.id === seg[1]);
|
||||
if (target) {
|
||||
target.llm_profile_ids = ids;
|
||||
target.llm_profile_id = ids[0];
|
||||
target.active_llm_profile_id = activeID;
|
||||
target.llm_failover_state = ids.length ? "ready" : "default";
|
||||
target.llm_failover_reason = undefined;
|
||||
}
|
||||
return {
|
||||
id: seg[1],
|
||||
llm_profile_ids: ids,
|
||||
active_llm_profile_id: activeID,
|
||||
llm_failover_state: ids.length ? "ready" : "default",
|
||||
reopened_intents: 0,
|
||||
};
|
||||
}
|
||||
if (seg[0] === "tasks" && seg[2] === "control") return { id: seg[1], paused: b.action === "pause" };
|
||||
if (seg[0] === "tasks" && seg[2] === "chat" && seg[3] === "stop") return { status: "stopped" };
|
||||
if (path === "/active") return { active: String(b.id ?? D.ACTIVE_TASK) };
|
||||
if (path === "/active") {
|
||||
const id = String(b.id ?? mockActiveTask);
|
||||
if (mockTasks.some((item) => item.id === id)) {
|
||||
mockActiveTask = id;
|
||||
for (const item of mockTasks) item.active = item.id === id;
|
||||
}
|
||||
return { active: mockActiveTask };
|
||||
}
|
||||
|
||||
// ── 覆盖度 / 覆盖图 / 资产关联(任务维度)──
|
||||
if (seg[0] === "tasks" && seg[2] === "coverage" && seg.length === 3) return D.coverage;
|
||||
@@ -52,7 +154,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/workspace/read") return D.workspaceRead(q.get("path") ?? "");
|
||||
|
||||
// ── stats ──
|
||||
if (path === "/stats") return D.stats(task);
|
||||
if (path === "/stats") {
|
||||
return D.stats(task, { tasks: mockTasks, findings: mockFindings, activeTask: mockActiveTask });
|
||||
}
|
||||
|
||||
// ── assets ──
|
||||
if (path === "/assets/counts") return D.assetCounts;
|
||||
@@ -74,10 +178,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
// ── exploration ──
|
||||
if (path === "/exploration/frontier") return D.frontier;
|
||||
if (path === "/exploration/findings/stats") {
|
||||
const vulnclasses = Array.from(new Set(D.findings.map((f) => f.vulnclass))).sort();
|
||||
const vulnclasses = Array.from(new Set(mockFindings.map((f) => f.vulnclass))).sort();
|
||||
// 「按任务」下拉:有漏洞的任务 + 描述 + 条数(mock 任务 id 是字符串,直接当 id 用)。
|
||||
const taskMap = new Map<string, { description: string; count: number }>();
|
||||
for (const f of D.findings) {
|
||||
for (const f of mockFindings) {
|
||||
if (!f.task_id) continue;
|
||||
const cur = taskMap.get(f.task_id) ?? { description: f.task_description ?? "", count: 0 };
|
||||
cur.count++;
|
||||
@@ -85,12 +189,12 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
}
|
||||
const tasks = Array.from(taskMap, ([id, v]) => ({ id, description: v.description, count: v.count }));
|
||||
return {
|
||||
total: D.findings.length,
|
||||
pending: D.findings.filter((f) => f.status === "pending").length,
|
||||
critical: D.findings.filter((f) => f.severity === "critical").length,
|
||||
high: D.findings.filter((f) => f.severity === "high").length,
|
||||
medium: D.findings.filter((f) => f.severity === "medium").length,
|
||||
low: D.findings.filter((f) => f.severity === "low").length,
|
||||
total: mockFindings.length,
|
||||
pending: mockFindings.filter((f) => f.status === "pending").length,
|
||||
critical: mockFindings.filter((f) => f.severity === "critical").length,
|
||||
high: mockFindings.filter((f) => f.severity === "high").length,
|
||||
medium: mockFindings.filter((f) => f.severity === "medium").length,
|
||||
low: mockFindings.filter((f) => f.severity === "low").length,
|
||||
vulnclasses,
|
||||
tasks,
|
||||
};
|
||||
@@ -102,7 +206,7 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
seg.length === 3 &&
|
||||
seg[2] !== "stats"
|
||||
) {
|
||||
const f = D.findings.find((x) => x.id === seg[2]);
|
||||
const f = mockFindings.find((x) => x.id === seg[2]);
|
||||
if (!f) return {};
|
||||
if (m === "PATCH") {
|
||||
if (typeof b.status === "string") f.status = b.status as typeof f.status;
|
||||
@@ -111,21 +215,42 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (typeof b.name === "string") f.name = b.name;
|
||||
if (typeof b.vulnclass === "string") f.vulnclass = b.vulnclass;
|
||||
}
|
||||
return { ...f, finding_id: f.id };
|
||||
const contextTaskId = q.get("context_task");
|
||||
const contextTask = contextTaskId ? mockTasks.find((item) => item.id === contextTaskId) : undefined;
|
||||
const inherited = !!(
|
||||
contextTask &&
|
||||
f.task_id &&
|
||||
f.task_id !== contextTask.id &&
|
||||
contextTask.source_task_ids?.includes(f.task_id)
|
||||
);
|
||||
return {
|
||||
...f,
|
||||
finding_id: f.id,
|
||||
...(inherited ? { inherited: true, source_task_id: f.task_id } : {}),
|
||||
};
|
||||
}
|
||||
if (path === "/exploration/findings") {
|
||||
// finding_id=id:真后端用独立表行 id 作为状态/详情句柄,mock 里用自身 id 顶上。
|
||||
// report 仅详情接口返回,列表剥掉(与后端一致)。
|
||||
const withFid = (f: (typeof D.findings)[number]) => ({
|
||||
const withFid = (f: (typeof mockFindings)[number]) => ({
|
||||
...f,
|
||||
report: undefined,
|
||||
finding_id: f.id,
|
||||
});
|
||||
if (task) return D.findings.filter((f) => f.task_id === task).map(withFid);
|
||||
if (task) {
|
||||
const owner = mockTasks.find((item) => item.id === task);
|
||||
const sources = new Set(owner?.source_task_ids ?? []);
|
||||
return mockFindings
|
||||
.filter((f) => f.task_id === task || (!!f.task_id && sources.has(f.task_id)))
|
||||
.map((f) => ({
|
||||
...withFid(f),
|
||||
...(f.task_id !== task ? { inherited: true, source_task_id: f.task_id } : {}),
|
||||
}));
|
||||
}
|
||||
// 全局:带 page/limit → 分页对象;否则裸数组(dashboard)。
|
||||
if (!q.has("page") && !q.has("limit")) return D.findings.map(withFid);
|
||||
if (!q.has("page") && !q.has("limit")) return mockFindings.map(withFid);
|
||||
const sev = { critical: 4, high: 3, medium: 2, low: 1 } as const;
|
||||
let list = D.findings.slice();
|
||||
let list = mockFindings.slice();
|
||||
const fSev = q.get("severity");
|
||||
const fStatus = q.get("status");
|
||||
const fVuln = q.get("vulnclass");
|
||||
@@ -151,9 +276,9 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/exploration/intents") {
|
||||
if (q.has("page")) {
|
||||
const before = Number(q.get("before") ?? 0);
|
||||
const limit = Number(q.get("limit") ?? 300);
|
||||
const limit = Math.max(1, Number(q.get("limit") ?? 300));
|
||||
let list = D.intents;
|
||||
if (before > 0) list = list.filter((n) => Number(n.id.replace(/\D/g, "") || n.id) < before);
|
||||
if (before > 0) list = list.filter((intent) => Number(intent.id.replace(/\D/g, "") || intent.id) < before);
|
||||
return { items: list.slice(0, limit), has_more: list.length > limit };
|
||||
}
|
||||
return D.intents;
|
||||
@@ -161,8 +286,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/exploration/tokens") return { workers: D.tokenWorkers, total: D.tokenTotal };
|
||||
if (path === "/exploration/graph") return D.explorationGraph;
|
||||
if (path === "/exploration/activity" && seg.length === 2) {
|
||||
const items = D.activityForTask();
|
||||
return { items, cursor: items.length ? items[items.length - 1].seq : 0 };
|
||||
const since = Number(q.get("since") ?? 0);
|
||||
const limit = Math.max(1, Number(q.get("limit") ?? 300));
|
||||
const items = D.activityForTask().filter((item) => item.seq > since).slice(0, limit);
|
||||
return { items, cursor: items.length ? items[items.length - 1].seq : since };
|
||||
}
|
||||
if (seg[0] === "exploration" && seg[1] === "activity" && seg.length === 3) {
|
||||
const a = D.activity.find((x) => x.seq === Number(seg[2]));
|
||||
@@ -189,10 +316,18 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
|
||||
if (path === "/commands" && m === "GET") return { commands: D.commandRecords, total: D.commandRecords.length };
|
||||
|
||||
// ── LLM ──
|
||||
if (path === "/llm/records" && m === "GET") return { records: D.llmRecords, total: D.llmRecords.length };
|
||||
if (path === "/llm/records" && m === "GET") return { records: mockLLMRecords, total: mockLLMRecords.length };
|
||||
if (path === "/llm/records" && m === "DELETE") return { deleted: 0 };
|
||||
if (path === "/llm/records/tasks") return { tasks: D.llmTasks };
|
||||
if (seg[0] === "llm" && seg[1] === "records" && seg.length === 3 && m === "GET") return D.llmRecordDetail(Number(seg[2]));
|
||||
if (path === "/llm/records/tasks") {
|
||||
const counts = new Map<string, number>();
|
||||
for (const record of mockLLMRecords) {
|
||||
if (record.task_id) counts.set(record.task_id, (counts.get(record.task_id) ?? 0) + 1);
|
||||
}
|
||||
return { tasks: [...counts].map(([task_id, count]) => ({ task_id, count })) };
|
||||
}
|
||||
if (seg[0] === "llm" && seg[1] === "records" && seg.length === 3 && m === "GET") {
|
||||
return D.llmRecordDetail(Number(seg[2]), mockLLMRecords);
|
||||
}
|
||||
if (path === "/llm" && m === "GET") return D.llmConfig;
|
||||
if (path === "/llm" && m === "POST") return { ok: true };
|
||||
if (path === "/llm/test") return { ok: true, latency_ms: 128, model: String(b.model ?? "claude-opus-4-8") };
|
||||
|
||||
@@ -47,6 +47,7 @@ interface StatusMeta {
|
||||
const intent: Record<string, StatusMeta> = {
|
||||
open: { label: "待领", tone: "slate" },
|
||||
running: { label: "执行中", tone: "blue" },
|
||||
paused: { label: "已暂停", tone: "amber" },
|
||||
done: { label: "已完成", tone: "green" },
|
||||
blocked: { label: "被拦截", tone: "red" },
|
||||
exhausted: { label: "已穷尽", tone: "violet" },
|
||||
|
||||
+57
-2
@@ -15,6 +15,7 @@ export interface Task {
|
||||
completed_unix?: number; // completed_at as unix seconds (0/undef if unfinished)
|
||||
last_activity_unix?: number; // unix seconds of the last activity (0/undef if none)
|
||||
paused?: boolean;
|
||||
queued?: boolean;
|
||||
active?: boolean;
|
||||
in_flight?: number;
|
||||
last_activity?: string;
|
||||
@@ -24,6 +25,30 @@ export interface Task {
|
||||
engine_mode?: EngineMode;
|
||||
tokens?: TokenTotal; // whole-task token consumption
|
||||
llm_profile_id?: number; // LLM profile used; absent = default profile
|
||||
llm_profile_ids?: number[]; // ordered task-level failover chain
|
||||
active_llm_profile_id?: number; // profile used by the next LLM call
|
||||
llm_failover_state?: "default" | "ready" | "chain_exhausted" | string;
|
||||
llm_failover_reason?: string;
|
||||
source_task_ids?: string[]; // directly related tasks inherited as read-only context
|
||||
}
|
||||
|
||||
export interface DeleteTaskOptions {
|
||||
delete_assets: boolean;
|
||||
delete_traffic: boolean;
|
||||
delete_files: boolean;
|
||||
delete_findings: boolean;
|
||||
delete_llm_records: boolean;
|
||||
}
|
||||
|
||||
export interface DeleteTaskResult {
|
||||
deleted: string;
|
||||
assets_deleted: number;
|
||||
assets_detached: number;
|
||||
traffic_deleted: number;
|
||||
files_deleted: boolean;
|
||||
findings_deleted: number;
|
||||
llm_records_deleted: number;
|
||||
cleanup_warning?: string;
|
||||
}
|
||||
|
||||
// ---- Asset graph (global, shared across tasks) ----
|
||||
@@ -171,6 +196,8 @@ export interface CoverageAssetRef {
|
||||
kind: string;
|
||||
state: string;
|
||||
summary: string;
|
||||
source_task_id?: string;
|
||||
inherited?: boolean;
|
||||
}
|
||||
export interface CoverageAssetRefs {
|
||||
intents: CoverageAssetRef[];
|
||||
@@ -234,6 +261,8 @@ export interface TaskNode {
|
||||
state: string; // GoalState | IntentState | FindingState | HintState
|
||||
origin: string;
|
||||
ts: string;
|
||||
source_task_id?: string;
|
||||
inherited?: boolean;
|
||||
}
|
||||
|
||||
// ---- Findings ----
|
||||
@@ -271,6 +300,8 @@ export interface Finding {
|
||||
param_id?: string;
|
||||
task_id?: string;
|
||||
task_description?: string;
|
||||
source_task_id?: string;
|
||||
inherited?: boolean;
|
||||
assets?: FindingAsset[];
|
||||
ts: string;
|
||||
}
|
||||
@@ -325,6 +356,8 @@ export type ActivityKind =
|
||||
| "intent" // LLM-generated exploration objective leading a worker session (UI-synthesized)
|
||||
| "round" // planner round boundary marker (engine-emitted)
|
||||
| "usage" // live cumulative token usage (per model turn); not rendered
|
||||
| "llm_switch" // automatic/manual task-level LLM switch
|
||||
| "llm_failover" // task-level provider switch / chain exhaustion audit event
|
||||
| "intercept_request"; // user-approval request from the intercept layer
|
||||
|
||||
// ChatAttachment 是一次上传的文件:path 相对该会话/任务工作目录(即 agent 的 CWD)。
|
||||
@@ -346,6 +379,11 @@ export interface Activity {
|
||||
is_error?: boolean;
|
||||
summary: string;
|
||||
detail?: string;
|
||||
metadata?: {
|
||||
llm_transition?: LLMTransition;
|
||||
};
|
||||
source_task_id?: string;
|
||||
inherited?: boolean;
|
||||
// token usage (present only on kind='result')
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
@@ -353,6 +391,20 @@ export interface Activity {
|
||||
cache_write_tokens?: number;
|
||||
}
|
||||
|
||||
export interface LLMAuditProfile {
|
||||
id: number;
|
||||
name: string;
|
||||
format: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface LLMTransition {
|
||||
mode: "automatic" | "manual" | "exhausted";
|
||||
reason: string;
|
||||
previous?: LLMAuditProfile;
|
||||
next?: LLMAuditProfile;
|
||||
}
|
||||
|
||||
// ---- Agent triggers (P3 调度,仅自定义 agent) ----
|
||||
export interface AgentTrigger {
|
||||
id: number;
|
||||
@@ -394,8 +446,8 @@ export interface LogLine {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export type SessionRole = "mainagent" | "planner" | "worker";
|
||||
export type SessionStatus = "running" | "done" | "blocked" | "exhausted" | "pending" | "stopped";
|
||||
export type SessionRole = "mainagent" | "planner" | "worker" | "system";
|
||||
export type SessionStatus = "running" | "paused" | "done" | "blocked" | "exhausted" | "pending" | "stopped";
|
||||
|
||||
// Daily token aggregate bucket (GET /api/tokens/daily).
|
||||
export interface DailyTokenBucket {
|
||||
@@ -431,6 +483,8 @@ export interface Session {
|
||||
live: boolean;
|
||||
last_activity: string;
|
||||
intent_id?: string;
|
||||
source_task_id?: string;
|
||||
inherited?: boolean;
|
||||
}
|
||||
|
||||
// ---- Security ----
|
||||
@@ -669,6 +723,7 @@ export interface Stats {
|
||||
llm_configured: boolean;
|
||||
roe_enabled: boolean;
|
||||
findings_confirmed: number;
|
||||
active_task?: Partial<Task>;
|
||||
}
|
||||
|
||||
// ---- Intercept Rules ----
|
||||
|
||||
Reference in new issue
Block a user