Files
ARTEX/web/src/app/(main)/function/tasks/page.tsx
T
2026-08-24 14:14:15 +08:00

1429 lines
57 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import * as React from "react";
import Link from "next/link";
<<<<<<< Updated upstream
=======
import {
ChevronRightIcon,
EyeIcon,
Loader2Icon,
PaperclipIcon,
PauseIcon,
PlayIcon,
PlusIcon,
SearchIcon,
SlidersHorizontalIcon,
StarIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
>>>>>>> Stashed changes
import { toast } from "sonner";
import {
PlusIcon,
Trash2Icon,
ArrowRightIcon,
StarIcon,
SearchIcon,
XIcon,
ChevronRightIcon,
PaperclipIcon,
Loader2Icon,
} from "lucide-react";
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 { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Sheet,
SheetClose,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Select,
SelectContent,
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";
// fmtBytes renders a human file size for the upload manifest.
function fmtBytes(n: number): string {
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(1)} KB`;
return `${n} B`;
}
// UPLOAD_MARKER labels the auto-appended block of uploaded-file paths inside the task
// description, so re-uploads append under the same block instead of adding a new header.
const UPLOAD_MARKER = "【上传文件(绝对路径)】";
// appendUploads folds newly-uploaded files' ABSOLUTE paths into the description as a
// Read/Bash-friendly manifest — the worker opens them by path. Keeps one marked block:
// first upload adds the header, later uploads append bullets under it.
function appendUploads(desc: string, atts: ChatAttachment[]): string {
const bullets = atts.map((a) => `- ${a.abs ?? a.path}(${fmtBytes(a.size)})`).join("\n");
if (desc.includes(UPLOAD_MARKER)) {
return `${desc.replace(/\s*$/, "")}\n${bullets}\n`;
}
const head = desc.trim() ? `${desc.replace(/\s*$/, "")}\n\n` : "";
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;
// fmtTokens renders a compact token count (1234 → 1.2k, 2_000_000 → 2M).
function fmtTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M";
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k";
return String(n);
}
// fmtDuration renders a run duration in seconds as a compact human string.
function fmtDuration(sec: number): string {
if (sec <= 0) return "—";
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
// taskDuration is the task's run duration in seconds: created → now while running,
// created → completion for a finished task, else created → last activity. 0 when it
// never ran (no activity yet).
function taskDuration(task: Task, nowSec: number): number {
const start = task.created_unix ?? 0;
if (!start) return 0;
const end =
task.status === "running"
? nowSec
: task.completed_unix && task.completed_unix > 0
? task.completed_unix
: task.last_activity_unix ?? 0;
return end > start ? end - start : 0;
}
// fmtDateTime renders a unix-seconds timestamp as a compact local date-time
// (MM-DD HH:mm), or "—" when unset.
function fmtDateTime(unix?: number): string {
if (!unix || unix <= 0) return "—";
const d = new Date(unix * 1000);
const p = (n: number) => String(n).padStart(2, "0");
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
const STATUS_OPTIONS: { value: TaskStatus; label: string }[] = [
{ value: "created", label: "已创建" },
{ value: "running", label: "运行中" },
{ value: "paused", label: "已暂停" },
{ value: "done", label: "已完成" },
{ value: "failed", label: "失败" },
{ value: "timeout", label: "已超时" },
];
// 可暂停 = 非终态且未暂停,与后端 applyTaskControlWithCause 的门控一致
// (done/failed/timeout 为终态);paused 才可继续。行内按钮与批量控制共用此判断,
// 两边不会出现一个可点、另一个不可点的分歧。
const PAUSABLE_STATUSES = new Set<TaskStatus>(["created", "queued", "running"]);
function taskControlAction(status: TaskStatus): "pause" | "resume" | null {
if (status === "paused") return "resume";
if (PAUSABLE_STATUSES.has(status)) return "pause";
return null;
}
export default function TasksPage() {
const [tasks, setTasks] = React.useState<Task[]>([]);
const [query, setQuery] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState<TaskStatus | "all">("all");
const [page, setPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(20);
const [nowSec, setNowSec] = React.useState(() => Math.floor(Date.now() / 1000));
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
return tasks.filter((t) => {
if (statusFilter !== "all" && t.status !== statusFilter) return false;
if (!q) return true;
return (
<<<<<<< Updated upstream
=======
(t.name ?? "").toLowerCase().includes(q) ||
>>>>>>> Stashed changes
t.description.toLowerCase().includes(q) ||
t.goal.toLowerCase().includes(q) ||
t.id.toLowerCase().includes(q)
);
});
}, [tasks, query, statusFilter]);
// reset to page 1 whenever filters change
React.useEffect(() => { setPage(1); }, [query, statusFilter]);
const paginated = React.useMemo(
() => filtered.slice((page - 1) * pageSize, page * pageSize),
[filtered, page, pageSize],
);
// lastRef holds the previous poll's serialized payload: the list is re-fetched every
// POLL_MS but usually comes back unchanged, and setTasks on an identical payload would
// re-render the whole page for nothing. Bail out when it matches.
const lastRef = React.useRef<string>("");
const load = React.useCallback(() => {
api.tasks()
.then((r) => {
const next = r.tasks.map((t) => (t.id === r.active ? { ...t, active: true } : t));
const sig = JSON.stringify(next);
if (sig === lastRef.current) return;
lastRef.current = sig;
setTasks(next);
})
.catch(() => {});
}, []);
React.useEffect(() => {
load();
const i = setInterval(load, POLL_MS);
return () => clearInterval(i);
}, [load]);
// 只在确有 running 任务时才每秒 tick——其余情况「运行时长」是静态值,空转的 tick 会白白
// 重渲染整张表。
const hasRunning = React.useMemo(() => tasks.some((t) => t.status === "running"), [tasks]);
// tick every second so running tasks' 运行时长 counts up live.
React.useEffect(() => {
if (!hasRunning) return;
setNowSec(Math.floor(Date.now() / 1000));
const i = setInterval(() => setNowSec(Math.floor(Date.now() / 1000)), 1000);
return () => clearInterval(i);
}, [hasRunning]);
<<<<<<< Updated upstream
const deleteTask = React.useCallback(async (id: string) => {
try {
await api.deleteTask(id);
toast.success("任务已删除(全局资产图保留)");
load();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
}, [load]);
=======
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();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
throw e;
}
},
[load],
);
// controlTask 是行内暂停/继续:批量走 controlTasksBatch,单行走单任务接口,省去
// 「先勾选再点批量」。清空 lastRef 让下一次轮询即使负载相同也照单接收,否则状态
// 回写会被去重挡掉、按钮看起来没反应。
const controlTask = React.useCallback(
async (id: string, action: "pause" | "resume") => {
try {
const result = await api.controlTask(id, action);
toast.success(
action === "pause" ? `任务 #${id} 已暂停` : `任务 #${id} 已继续${result.queued ? ",已进入队列" : ""}`,
);
} catch (e) {
toast.error(`${action === "pause" ? "暂停" : "继续"}失败:${(e as Error).message}`);
} finally {
// 无论成败都刷新:失败多半是状态已变化,重新拉取才能让按钮回到正确形态。
lastRef.current = "";
load();
}
},
[load],
);
// deleteTasks 逐个删除所选任务:后端没有批量接口,且单次删除会连带清理资产/流量/文件,
// 串行执行以免一次性打爆后端;成功的从选中集移除,失败的保留以便重试。
const deleteTasks = React.useCallback(
async (ids: string[], options: DeleteTaskOptions, onProgress: (done: number) => void) => {
const total: DeleteCounts = {
assets_deleted: 0,
assets_detached: 0,
traffic_deleted: 0,
files_deleted: false,
findings_deleted: 0,
llm_records_deleted: 0,
};
const deleted: string[] = [];
const failed: { id: string; message: string }[] = [];
const warnings: string[] = [];
for (const id of ids) {
try {
const r = await api.deleteTask(id, options);
total.assets_deleted += r.assets_deleted;
total.assets_detached += r.assets_detached;
total.traffic_deleted += r.traffic_deleted;
total.files_deleted = total.files_deleted || r.files_deleted;
total.findings_deleted += r.findings_deleted;
total.llm_records_deleted += r.llm_records_deleted;
if (r.cleanup_warning) warnings.push(`#${id}:${r.cleanup_warning}`);
deleted.push(id);
} catch (e) {
failed.push({ id, message: (e as Error).message });
}
onProgress(deleted.length + failed.length);
}
if (deleted.length > 0) {
setSelectedIds((prev) => {
const next = new Set(prev);
for (const id of deleted) next.delete(id);
return next;
});
const details = deleteDetails(total);
const summary = `已删除 ${deleted.length} 个任务` + (details.length > 0 ? `(${details.join(",")})` : "");
if (warnings.length > 0) {
toast.warning(`${summary};部分外部数据清理未完成:${warnings.join(";")}`);
} else {
toast.success(summary);
}
}
if (failed.length > 0) {
const head = failed
.slice(0, 3)
.map((f) => `#${f.id}(${f.message})`)
.join(";");
toast.error(`${failed.length} 个任务删除失败:${head}${failed.length > 3 ? " 等" : ""}`);
}
load();
},
[load],
);
const selectedTasks = React.useMemo(() => tasks.filter((task) => selectedIds.has(task.id)), [tasks, selectedIds]);
const pausableTaskIDs = React.useMemo(
() => selectedTasks.filter((task) => taskControlAction(task.status) === "pause").map((task) => task.id),
[selectedTasks],
);
const resumableTaskIDs = React.useMemo(
() => selectedTasks.filter((task) => taskControlAction(task.status) === "resume").map((task) => task.id),
[selectedTasks],
);
const controlSelectedTasks = React.useCallback(
async (action: "pause" | "resume", ids: string[]) => {
if (ids.length === 0 || batchControlling) return;
if (ids.length > 100) {
toast.error("一次最多控制 100 个任务");
return;
}
setBatchControlling(action);
try {
const result = await api.controlTasksBatch(ids, action);
const succeeded = result.items.filter((item) => item.ok);
const failed = result.items.filter((item) => !item.ok);
if (succeeded.length > 0) {
toast.success(
action === "pause"
? `已暂停 ${succeeded.length} 个任务`
: `已继续 ${succeeded.length} 个任务${succeeded.some((item) => item.queued) ? ",部分任务已进入队列" : ""}`,
);
}
if (failed.length > 0) {
const details = failed
.slice(0, 3)
.map((item) => `#${item.id}(${item.error || "状态已变化"})`)
.join(";");
toast.error(`${failed.length} 个任务操作失败:${details}${failed.length > 3 ? " 等" : ""}`);
}
lastRef.current = "";
load();
} catch (error) {
toast.error(`${action === "pause" ? "批量暂停" : "批量继续"}失败:${(error as Error).message}`);
} finally {
setBatchControlling(null);
}
},
[batchControlling, load],
);
>>>>>>> Stashed changes
return (
<Card>
<CardContent className="flex flex-col gap-4 px-0 pt-6">
<div className="flex flex-wrap items-center gap-2 px-4 lg:px-6">
<div className="relative w-full sm:max-w-xs">
<SearchIcon className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2" />
<Input
placeholder="搜索描述 / 目标 / ID"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-8"
/>
{query && (
<button
type="button"
onClick={() => setQuery("")}
aria-label="清除搜索"
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-2 -translate-y-1/2"
>
<XIcon className="size-4" />
</button>
)}
</div>
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as TaskStatus | "all")}>
<SelectTrigger className="w-36">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
{STATUS_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
{s.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-muted-foreground text-xs tabular-nums">
{filtered.length}/{tasks.length} 条
</span>
<CreateTaskSheet onCreated={load} />
</div>
{tasks.length === 0 ? (
<div className="text-muted-foreground mx-4 flex items-center justify-center rounded-lg border border-dashed py-20 text-sm lg:mx-6">
暂无任务,点击右上角「新建任务」开始。
</div>
) : filtered.length === 0 ? (
<div className="text-muted-foreground mx-4 flex items-center justify-center rounded-lg border border-dashed py-20 text-sm lg:mx-6">
没有匹配的任务。
</div>
) : (
<Table className="**:data-[slot='table-cell']:px-4 **:data-[slot='table-head']:px-4">
<TableHeader className="[&_tr]:border-t">
<TableRow>
<TableHead className="font-mono">ID</TableHead>
<TableHead>名称</TableHead>
<TableHead>描述</TableHead>
<TableHead>目标</TableHead>
<TableHead>状态</TableHead>
<TableHead className="text-center">目标进度</TableHead>
<TableHead className="text-right">创建时间</TableHead>
<TableHead className="text-right">运行时长</TableHead>
<TableHead className="text-right">Token</TableHead>
<TableHead className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))]">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginated.map((task) => (
<TaskRow
key={task.id}
task={task}
// running 任务才吃 nowSec;其余行传 0 —— props 不变,memo 就能拦下每秒 tick
// 带来的整表重渲染,只让在跑的那几行走时长。
nowSec={task.status === "running" ? nowSec : 0}
onDelete={deleteTask}
<<<<<<< Updated upstream
=======
onControl={controlTask}
selected={selectedIds.has(task.id)}
onSelectedChange={toggleSelected}
>>>>>>> Stashed changes
/>
))}
</TableBody>
</Table>
)}
<TablePagination
page={page}
pageSize={pageSize}
total={filtered.length}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
</CardContent>
</Card>
);
}
// 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.
const TaskRow = React.memo(function TaskRow({
task,
nowSec,
onDelete,
<<<<<<< Updated upstream
}: {
task: Task;
nowSec: number;
onDelete: (id: string) => void;
=======
onControl,
selected,
onSelectedChange,
}: {
task: Task;
nowSec: number;
onDelete: (id: string, options: DeleteTaskOptions) => Promise<void>;
onControl: (id: string, action: "pause" | "resume") => Promise<void>;
selected: boolean;
onSelectedChange: (id: string, checked: boolean) => void;
>>>>>>> Stashed changes
}) {
return (
<TableRow className="group border-border/60">
<TableCell>
<code className="bg-muted rounded px-1.5 py-0.5 font-mono text-xs">{task.id}</code>
</TableCell>
<TableCell className="font-medium">
<div className="flex max-w-xs items-center gap-2">
<<<<<<< Updated upstream
<span className="truncate" title={task.description}>
{task.description}
</span>
{task.active && (
<StarIcon className="size-4 shrink-0 fill-amber-400 text-amber-400" />
)}
=======
<Link
href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`}
className="min-w-0 truncate rounded-sm hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={task.name || task.description}
>
{task.name || <span className="text-muted-foreground">未命名</span>}
</Link>
{task.active && <StarIcon className="size-4 shrink-0 fill-amber-400 text-amber-400" />}
>>>>>>> Stashed changes
</div>
</TableCell>
<TableCell className="text-muted-foreground max-w-xs">
<Link
href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`}
className="block truncate rounded-sm hover:text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={task.description}
>
{task.description}
</Link>
</TableCell>
<TableCell className="text-muted-foreground max-w-xs truncate">{task.goal}</TableCell>
<TableCell>
<StatusBadge domain="task" value={task.status} dot />
</TableCell>
<TableCell className="text-muted-foreground text-center text-xs tabular-nums">
{typeof task.goals_total === "number" && task.goals_total > 0
? `${task.goals_met}/${task.goals_total}`
: <span className="text-muted-foreground">—</span>}
</TableCell>
<TableCell className="text-muted-foreground text-right text-xs whitespace-nowrap tabular-nums">
{fmtDateTime(task.created_unix)}
</TableCell>
<TableCell className="text-right text-xs whitespace-nowrap tabular-nums">
{(() => {
const secs = taskDuration(task, nowSec);
if (secs <= 0) return <span className="text-muted-foreground">—</span>;
return (
<span className={task.status === "running" ? "text-foreground" : "text-muted-foreground"}>
{fmtDuration(secs)}
</span>
);
})()}
</TableCell>
<TableCell
className="text-right text-xs whitespace-nowrap tabular-nums"
title={
task.tokens
? `输入 ${task.tokens.input_tokens} · 缓存 ${task.tokens.cache_read_tokens} · 输出 ${task.tokens.output_tokens}`
: undefined
}
>
{task.tokens ? (
<span className="text-muted-foreground">
入 <span className="text-foreground">{fmtTokens(task.tokens.input_tokens)}</span>
{" · 缓 "}
<span className="text-foreground">{fmtTokens(task.tokens.cache_read_tokens)}</span>
{" · 出 "}
<span className="text-foreground">{fmtTokens(task.tokens.output_tokens)}</span>
</span>
) : (
"—"
)}
</TableCell>
<TableCell className="sticky right-0 z-10 bg-card text-right shadow-[-1px_0_0_0_hsl(var(--border))] group-hover:bg-muted/50">
<<<<<<< Updated upstream
<div className="flex items-center justify-end gap-2">
<Button asChild size="sm">
<Link href={`/function/tasks/detail?id=${task.id}`}>
进入 <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>
{task.description
? `「${task.description.length > 80 ? task.description.slice(0, 80) + "…" : task.description}」`
: "该任务"}
的执行记录、会话与产物将被删除,此操作不可撤销(全局资产图保留)。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction onClick={() => onDelete(task.id)}>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
=======
<div className="flex items-center justify-end gap-0.5">
<Button size="icon" variant="ghost" asChild aria-label="查看任务详情" title="查看任务详情">
<Link href={`/function/tasks/detail?id=${encodeURIComponent(task.id)}`}>
<EyeIcon />
</Link>
</Button>
<TaskControlButton task={task} onControl={onControl} />
<DeleteTaskDialog task={task} onDelete={onDelete} />
>>>>>>> Stashed changes
</div>
</TableCell>
</TableRow>
);
});
<<<<<<< Updated upstream
=======
// 三态图标分开写而非嵌套三元:仓库 Biome 基线禁 noNestedTernary。
function taskControlIcon(pending: boolean, action: "pause" | "resume" | null) {
if (pending) return <Loader2Icon className="animate-spin" />;
if (action === "resume") return <PlayIcon />;
return <PauseIcon />;
}
// TaskControlButton 是行内的暂停/继续开关。终态任务渲染为 disabled 而不是隐藏,
// 这样各行操作列宽度一致,按钮位置不会随状态跳动。
function TaskControlButton({
task,
onControl,
}: {
task: Task;
onControl: (id: string, action: "pause" | "resume") => Promise<void>;
}) {
const [pending, setPending] = React.useState(false);
const action = taskControlAction(task.status);
const label = action === "resume" ? "继续任务" : "暂停任务";
return (
<Button
size="icon"
variant="ghost"
aria-label={label}
title={action ? label : "该状态不可暂停或继续"}
disabled={!action || pending}
onClick={async () => {
if (!action) return;
setPending(true);
try {
await onControl(task.id, action);
} finally {
setPending(false);
}
}}
>
{taskControlIcon(pending, action)}
</Button>
);
}
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",
];
// DeleteOptionFields renders the「同时清理关联数据」checkbox block shared by the
// single-task and bulk delete dialogs. idPrefix keeps the label/input ids unique when
// several dialogs live in the same table.
function DeleteOptionFields({
idPrefix,
options,
onOptionsChange,
disabled,
}: {
idPrefix: string;
options: DeleteTaskOptions;
onOptionsChange: React.Dispatch<React.SetStateAction<DeleteTaskOptions>>;
disabled: boolean;
}) {
const selectedCount = deleteOptionKeys.filter((key) => options[key]).length;
let allChecked: boolean | "indeterminate" = false;
if (selectedCount === deleteOptionKeys.length) {
allChecked = true;
} else if (selectedCount > 0) {
allChecked = "indeterminate";
}
const updateOption = (key: keyof DeleteTaskOptions, checked: boolean) => {
onOptionsChange((current) => ({ ...current, [key]: checked }));
};
const updateAllOptions = (checked: boolean) => {
onOptionsChange({
delete_assets: checked,
delete_traffic: checked,
delete_files: checked,
delete_findings: checked,
delete_llm_records: checked,
});
};
return (
<FieldSet disabled={disabled}>
<FieldLegend variant="label">同时清理关联数据</FieldLegend>
<FieldGroup className="gap-3">
<Field orientation="horizontal">
<Checkbox
id={`delete-all-${idPrefix}`}
checked={allChecked}
onCheckedChange={(checked) => updateAllOptions(checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-all-${idPrefix}`}>全部删除</FieldLabel>
<FieldDescription>选中下方全部关联数据,包括资产、流量、文件、漏洞和 LLM 请求/响应记录。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-assets-${idPrefix}`}
checked={options.delete_assets}
onCheckedChange={(checked) => updateOption("delete_assets", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-assets-${idPrefix}`}>关联资产</FieldLabel>
<FieldDescription>删除仅属于该任务的资产;共享资产只解除当前任务关联。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-traffic-${idPrefix}`}
checked={options.delete_traffic}
onCheckedChange={(checked) => updateOption("delete_traffic", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-traffic-${idPrefix}`}>关联流量</FieldLabel>
<FieldDescription>按关联资产的精确主机名删除;仍被其他任务引用的共享主机流量会保留。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-files-${idPrefix}`}
checked={options.delete_files}
onCheckedChange={(checked) => updateOption("delete_files", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-files-${idPrefix}`}>任务文件</FieldLabel>
<FieldDescription>删除该任务工作目录中的上传文件、命令输出和其他产物。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-findings-${idPrefix}`}
checked={options.delete_findings}
onCheckedChange={(checked) => updateOption("delete_findings", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-findings-${idPrefix}`}>关联漏洞</FieldLabel>
<FieldDescription>永久删除该任务产生的独立漏洞记录与漏洞报告。</FieldDescription>
</FieldContent>
</Field>
<Field orientation="horizontal">
<Checkbox
id={`delete-llm-records-${idPrefix}`}
checked={options.delete_llm_records}
onCheckedChange={(checked) => updateOption("delete_llm_records", checked === true)}
/>
<FieldContent>
<FieldLabel htmlFor={`delete-llm-records-${idPrefix}`}>LLM 请求/响应记录</FieldLabel>
<FieldDescription>永久删除该任务录制的 LLM 请求、响应、Token 与错误详情。</FieldDescription>
</FieldContent>
</Field>
</FieldGroup>
</FieldSet>
);
}
function DeleteTaskDialog({
task,
onDelete,
}: {
task: Task;
onDelete: (id: string, options: DeleteTaskOptions) => Promise<void>;
}) {
const [open, setOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState(false);
const [options, setOptions] = React.useState<DeleteTaskOptions>(emptyDeleteOptions);
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 (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<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>
<DeleteOptionFields idPrefix={task.id} options={options} onOptionsChange={setOptions} disabled={deleting} />
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={(event) => {
event.preventDefault();
void handleDelete();
}}
>
{deleting && <Spinner data-icon="inline-start" />}
{deleting ? "删除中" : "删除"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
// BulkDeleteTasksDialog deletes every checked task with one shared set of cleanup options.
// The backend has no batch endpoint, so deletion runs one task at a time (see deleteTasks)
// and the button shows live progress.
function BulkDeleteTasksDialog({
ids,
onDelete,
}: {
ids: string[];
onDelete: (ids: string[], options: DeleteTaskOptions, onProgress: (done: number) => void) => Promise<void>;
}) {
const [open, setOpen] = React.useState(false);
const [deleting, setDeleting] = React.useState(false);
const [done, setDone] = React.useState(0);
const [options, setOptions] = React.useState<DeleteTaskOptions>(emptyDeleteOptions);
const handleOpenChange = (next: boolean) => {
if (deleting) return;
setOpen(next);
if (next) {
setOptions(emptyDeleteOptions());
setDone(0);
}
};
const handleDelete = async () => {
setDeleting(true);
setDone(0);
try {
await onDelete(ids, options, setDone);
setOpen(false);
} finally {
setDeleting(false);
}
};
return (
<AlertDialog open={open} onOpenChange={handleOpenChange}>
<AlertDialogTrigger asChild>
<Button size="sm" variant="outline">
<Trash2Icon className="text-destructive" /> 删除所选 {ids.length}
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="max-h-[85vh] overflow-y-auto">
<AlertDialogHeader>
<AlertDialogTitle>确认删除所选 {ids.length} 个任务?</AlertDialogTitle>
<AlertDialogDescription>
这些任务的执行记录与探索链路将被永久删除,下方清理选项对所选任务统一生效。
</AlertDialogDescription>
</AlertDialogHeader>
<div className="text-muted-foreground flex flex-wrap gap-1 text-xs">
{ids.slice(0, 30).map((id) => (
<code key={id} className="bg-muted rounded px-1.5 py-0.5 font-mono">
#{id}
</code>
))}
{ids.length > 30 && <span className="self-center">…等 {ids.length} 个</span>}
</div>
<DeleteOptionFields idPrefix="bulk" options={options} onOptionsChange={setOptions} disabled={deleting} />
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>取消</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={(event) => {
event.preventDefault();
void handleDelete();
}}
>
{deleting && <Spinner data-icon="inline-start" />}
{deleting ? `删除中 ${done}/${ids.length}` : `删除 ${ids.length} 个任务`}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
>>>>>>> Stashed changes
// CreateTaskSheet is the 新建任务 drawer. Its form state lives HERE, not in TasksPage: with
// 描述/目标 held by the page component every keystroke re-rendered the whole task table
// behind the drawer (plus its sticky column and 20 AlertDialog trees), which showed up as
// input lag. Now typing only re-renders the drawer.
function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
const [open, setOpen] = React.useState(false);
const [name, setName] = React.useState("");
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 [timeoutMin, setTimeoutMin] = React.useState(""); // 任务级超时(分钟);空/0 = 不限时
const [heartbeatMin, setHeartbeatMin] = React.useState("10"); // planner 心跳(分钟);默认10,下限10(与后端一致)
const [seedFirstIntent, setSeedFirstIntent] = React.useState(false); // 创建时下发种子意图,worker 免等首轮 planner 直接开跑;默认关闭,走标准先规划再执行
const [coverageEnabled, setCoverageEnabled] = React.useState(true); // 资产覆盖度功能;默认开。关闭=不计算/展示覆盖度、不累积范围、隐藏范围类工具(company 关联不受影响)
// 方式1 文件上传:建任务前把文件暂存到 drafts/<draftId>/uploads/,拿回绝对路径追加进描述。
const [uploading, setUploading] = React.useState(false);
const [uploadCount, setUploadCount] = React.useState(0);
const draftIdRef = React.useRef<string>("");
const fileInputRef = React.useRef<HTMLInputElement>(null);
// load LLM profiles once for the create-task profile picker.
React.useEffect(() => {
api.llmProfiles().then(setProfiles).catch(() => setProfiles([]));
}, []);
// pickFiles uploads the chosen files into this draft's staging dir and appends their
// absolute paths to the description; the task's agents open them by path via Read/Bash.
async function pickFiles(files: FileList | null) {
if (!files || files.length === 0) return;
// crypto.randomUUID 仅在安全上下文可用(https/localhost);经 IP+http 访问时降级。
if (!draftIdRef.current) {
draftIdRef.current =
globalThis.crypto?.randomUUID?.() ??
`d${Date.now().toString(36)}${Math.random().toString(36).slice(2, 10)}`;
}
setUploading(true);
try {
const r = await api.chatUpload("staging", draftIdRef.current, Array.from(files));
setDescription((prev) => appendUploads(prev, r.attachments));
setUploadCount((n) => n + r.attachments.length);
} catch (e) {
toast.error("上传失败:" + (e as Error).message);
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = ""; // allow re-picking the same file
}
}
async function createTask() {
if (!description.trim() || !goal.trim()) {
toast.error("请填写描述与目标");
return;
}
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,与后端归一一致
<<<<<<< Updated upstream
await api.createTask(description.trim(), goal.trim(), pid, timeoutSec, seedFirstIntent, heartbeatSec);
=======
await api.createTask({
name: name.trim(),
description: description.trim(),
goal: goal.trim(),
llmProfileIds: llmProfileIDs.map(Number),
sourceTaskIds: sourceTaskIDs,
companyIds: companyIDs,
timeoutSeconds: timeoutSec,
seedFirstIntent,
planHeartbeatSeconds: heartbeatSec,
coverageEnabled,
});
>>>>>>> Stashed changes
toast.success("任务已创建");
setName("");
setDescription("");
setGoal("");
setLlmProfile(ACTIVE_PROFILE);
setTimeoutMin("");
setHeartbeatMin("10");
setSeedFirstIntent(false);
setCoverageEnabled(true);
setUploadCount(0);
draftIdRef.current = "";
setOpen(false);
onCreated();
} catch (e) {
toast.error("创建失败:" + (e as Error).message);
}
}
return (
<<<<<<< Updated upstream
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button size="sm" className="ml-auto">
<PlusIcon /> 新建任务
=======
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button size="sm">
<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]!"
>
<SheetHeader className="border-b p-6">
<SheetTitle>新建任务</SheetTitle>
<SheetDescription>填写测试对象与目标,高级参数可按需展开。</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid gap-5">
<TaskTemplateControls
description={description}
goal={goal}
selectedTemplateID={selectedTemplateID}
onSelectedTemplateIDChange={setSelectedTemplateID}
onApply={(template) => {
setDescription(template.description);
setGoal(template.goal);
setUploadCount(0);
}}
portalContainer={sheetContentRef}
/>
<div className="grid gap-2">
<Label htmlFor="name">名称(可选)</Label>
<Input
id="name"
placeholder="给任务起个便于识别的名字,例如:Acme 官网渗透"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">描述</Label>
<Textarea
id="description"
className="min-h-32"
placeholder="测试对象与背景,例如:测试 example.com 这个站点"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
{/* 上传文件(可多选):暂存到 drafts/,把绝对路径追加进上方描述,worker 据此 Read/Bash 打开。 */}
<div className="flex flex-wrap items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => void pickFiles(e.target.files)}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? <Loader2Icon className="animate-spin" /> : <PaperclipIcon />}
上传文件
</Button>
<span className="text-muted-foreground text-xs">
{uploadCount > 0
? `已上传 ${uploadCount} 个文件,绝对路径已追加到描述末尾(可编辑)`
: "可多选;上传后把文件的绝对路径追加到描述,供 worker 用 Read/Bash 打开"}
</span>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="goal">目标</Label>
<Textarea
id="goal"
className="min-h-32"
placeholder="要达成什么,例如:拿下后台管理权限、获取服务器权限"
value={goal}
onChange={(e) => setGoal(e.target.value)}
/>
</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="task-companies">关联企业资产范围</FieldLabel>
<CompanyPicker
companies={companies}
value={companyIDs}
onValueChange={setCompanyIDs}
portalContainer={sheetContentRef}
/>
<FieldDescription>
将所选企业的域名、IP、CIDR、ICP 和企业关键词作为 Agent
可选范围上下文;不会自动生成意图或强制改变执行目标。
</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>
<CollapsibleTrigger className="group flex w-full items-center gap-2 border-t pt-4 text-sm font-medium">
<ChevronRightIcon className="text-muted-foreground size-4 transition-transform group-data-[state=open]:rotate-90" />
高级设置
<span className="text-muted-foreground ml-auto text-xs font-normal">超时 · 心跳 · 首个意图</span>
</CollapsibleTrigger>
<CollapsibleContent className="grid gap-5 pt-5">
<div className="grid gap-2">
<Label htmlFor="timeout-min">任务超时(分钟,可选)</Label>
<Input
id="timeout-min"
type="number"
min={0}
className="w-40"
placeholder="留空 = 不限时"
value={timeoutMin}
onChange={(e) => setTimeoutMin(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
到点后触发优雅收尾(各 agent 写回 + planner 终局判定),任务进入 timeout 终态。
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="heartbeat-min">planner 心跳(分钟)</Label>
<Input
id="heartbeat-min"
type="number"
min={10}
className="w-40"
placeholder="默认 10"
value={heartbeatMin}
onChange={(e) => setHeartbeatMin(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
距上轮规划结束/任务开始满该时长且期间无触发,自动触发一轮规划(兜底卡死 + 唤醒去监督在跑的
worker)。下限 10 分钟。
</p>
</div>
<div className="grid gap-2">
<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">
开启后创建即把「描述+目标」作为一条意图下发,worker 免等首轮规划直接开跑,跑完再由 planner
接手判定/补充。CTF 等常一个 work 直接解决的场景推荐开启;关闭则走标准的先规划再执行。
</p>
</div>
<div className="grid gap-2">
<label htmlFor="coverage-enabled" className="flex items-center gap-2 text-sm">
<Checkbox
id="coverage-enabled"
checked={coverageEnabled}
onCheckedChange={(v) => setCoverageEnabled(!!v)}
/>
资产覆盖度功能
</label>
<p className="text-muted-foreground text-xs">
默认开启:计算并展示测试覆盖度、态势图显示测试进度、自动累积测试范围。关闭后不再计算/展示覆盖度,
态势图仅展示资产不显示进度,agent 也不再获得范围类工具。关闭不影响「关联企业资产范围」。
</p>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
<SheetFooter className="flex-row justify-end gap-2 border-t p-4">
<SheetClose asChild>
<Button variant="outline">取消</Button>
</SheetClose>
<Button onClick={createTask} disabled={creating || uploading}>
{creating && <Spinner data-icon="inline-start" />}
{creating ? "创建中" : "创建"}
>>>>>>> Stashed changes
</Button>
</SheetTrigger>
{/* 45vw 宽的右侧抽屉:整屏高度可滚动,长表单不再受弹窗高度限制。窄屏退化为全宽。
内容为 flex 列:头/脚固定,中间字段区 flex-1 独立滚动。 */}
<SheetContent
side="right"
className="w-full! max-w-none! gap-0 p-0 sm:w-[45vw]! sm:max-w-[45vw]!"
>
<SheetHeader className="border-b p-6">
<SheetTitle>新建任务</SheetTitle>
<SheetDescription>填写测试对象与目标,高级参数可按需展开。</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-6">
<div className="grid gap-5">
<div className="grid gap-2">
<Label htmlFor="description">描述</Label>
<Textarea
id="description"
className="min-h-32"
placeholder="测试对象与背景,例如:测试 example.com 这个站点"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
{/* 上传文件(可多选):暂存到 drafts/,把绝对路径追加进上方描述,worker 据此 Read/Bash 打开。 */}
<div className="flex flex-wrap items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => void pickFiles(e.target.files)}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2Icon className="animate-spin" />
) : (
<PaperclipIcon />
)}
上传文件
</Button>
<span className="text-muted-foreground text-xs">
{uploadCount > 0
? `已上传 ${uploadCount} 个文件,绝对路径已追加到描述末尾(可编辑)`
: "可多选;上传后把文件的绝对路径追加到描述,供 worker 用 Read/Bash 打开"}
</span>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="goal">目标</Label>
<Textarea
id="goal"
className="min-h-32"
placeholder="要达成什么,例如:拿下后台管理权限、获取服务器权限"
value={goal}
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>
{/* 高级参数默认折叠:超时/心跳/首个意图,展开才占空间,常用路径保持清爽。 */}
<Collapsible>
<CollapsibleTrigger className="group flex w-full items-center gap-2 border-t pt-4 text-sm font-medium">
<ChevronRightIcon className="text-muted-foreground size-4 transition-transform group-data-[state=open]:rotate-90" />
高级设置
<span className="text-muted-foreground ml-auto text-xs font-normal">
超时 · 心跳 · 首个意图
</span>
</CollapsibleTrigger>
<CollapsibleContent className="grid gap-5 pt-5">
<div className="grid gap-2">
<Label htmlFor="timeout-min">任务超时(分钟,可选)</Label>
<Input
id="timeout-min"
type="number"
min={0}
className="w-40"
placeholder="留空 = 不限时"
value={timeoutMin}
onChange={(e) => setTimeoutMin(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
到点后触发优雅收尾(各 agent 写回 + planner 终局判定),任务进入 timeout 终态。
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="heartbeat-min">planner 心跳(分钟)</Label>
<Input
id="heartbeat-min"
type="number"
min={10}
className="w-40"
placeholder="默认 10"
value={heartbeatMin}
onChange={(e) => setHeartbeatMin(e.target.value)}
/>
<p className="text-muted-foreground text-xs">
距上轮规划结束/任务开始满该时长且期间无触发,自动触发一轮规划(兜底卡死 + 唤醒去监督在跑的 worker)。下限 10 分钟。
</p>
</div>
<div className="grid gap-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={seedFirstIntent} onCheckedChange={(v) => setSeedFirstIntent(!!v)} />
直接下发首个意图(描述+目标)
</label>
<p className="text-muted-foreground text-xs">
开启后创建即把「描述+目标」作为一条意图下发,worker 免等首轮规划直接开跑,跑完再由 planner 接手判定/补充。CTF 等常一个 work 直接解决的场景推荐开启;关闭则走标准的先规划再执行。
</p>
</div>
</CollapsibleContent>
</Collapsible>
</div>
</div>
<SheetFooter className="flex-row justify-end gap-2 border-t p-4">
<SheetClose asChild>
<Button variant="outline">取消</Button>
</SheetClose>
<Button onClick={createTask}>创建</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}