This commit is contained in:
pphua committed 2026-08-24 14:14:15 +08:00
1 parent e67ff26e90
commit 58f23a2773
61 files changed
+11611 -443

No files matched your search

+90 -1
View File
@@ -326,7 +326,7 @@ export default function FindingsPage() {
<SelectItem value="all">全部任务</SelectItem>
{(stats.tasks ?? []).map((t) => {
const id = String(t.id);
const label = t.description || `任务 #${id}(已删除)`;
const label = t.name || t.description || `任务 #${id}(已删除)`;
return (
<SelectItem key={id} value={id}>
<span className="flex w-full items-center gap-2">
@@ -361,9 +361,98 @@ export default function FindingsPage() {
</span>
</div>
<<<<<<< Updated upstream
<Card className="py-0">
<CardContent className="px-0">
{/* table-fixed:列宽由表头锁定,展开行那个 colSpan 单元格再宽也只能在固定宽度内
=======
<div className="flex flex-col gap-3">
{groups.map((group) => {
const key = findingGroupKey(group);
const groupOpen = expandedGroups.has(key);
const state = groupFindings[key] ?? {
items: [],
total: group.count,
page: 1,
pageSize: 10,
loaded: false,
loading: false,
};
const selectableIds = state.items
.map((finding) => finding.finding_id)
.filter((id): id is string => Boolean(id));
const selectedCount = selectableIds.filter((id) => selectedIds.has(id)).length;
let groupChecked: boolean | "indeterminate" = false;
if (selectableIds.length > 0 && selectedCount === selectableIds.length) {
groupChecked = true;
} else if (selectedCount > 0) {
groupChecked = "indeterminate";
}
return (
<Card key={key} className="gap-0 py-0">
<CardHeader className="px-4 py-3">
<div className="flex min-w-0 flex-wrap items-center gap-3">
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 text-left"
aria-expanded={groupOpen}
onClick={() => toggleGroup(key)}
>
<ChevronRightIcon
className={cn(
"size-4 shrink-0 text-muted-foreground transition-transform",
groupOpen && "rotate-90",
)}
/>
<div className="flex min-w-0 flex-col gap-1">
<CardTitle className="truncate text-sm">
{group.task_id === null
? "未关联 / 任务已删除"
: group.task_name
? `${group.task_name}(任务 #${group.task_id})`
: `任务 #${group.task_id}`}
</CardTitle>
<CardDescription className="truncate" title={group.task_description}>
{group.task_description || "来源任务不可用"}
</CardDescription>
</div>
</button>
<div className="flex flex-wrap items-center gap-2">
{group.task_status && <StatusBadge domain="task" value={group.task_status} dot />}
{SEVERITIES.map((level) => {
const count = group[level];
if (count === 0) return null;
return (
<span key={level} className="inline-flex items-center gap-1">
<StatusBadge domain="severity" value={level} dot />
<span className="text-xs tabular-nums text-muted-foreground">{count}</span>
</span>
);
})}
<span className="text-xs tabular-nums text-muted-foreground">{fmtTime(group.last_found_at)}</span>
{group.task_id !== null && (
<Button size="icon-sm" variant="ghost" asChild>
<Link
href={`/function/tasks/detail?id=${group.task_id}`}
aria-label={`查看任务 #${group.task_id}`}
>
<ArrowUpRightIcon />
</Link>
</Button>
)}
</div>
</div>
</CardHeader>
{groupOpen && (
<CardContent className="px-0">
{state.loading && !state.loaded ? (
<div className="flex min-h-36 items-center justify-center">
<Spinner />
</div>
) : (
<>
{/* table-fixed:列宽由表头锁定,展开行那个 colSpan 单元格再宽也只能在固定宽度内
>>>>>>> Stashed changes
换行/内部滚动,不会把整张表撑出横向滚动条。 */}
<Table className="table-fixed">
<TableHeader>
@@ -447,7 +447,7 @@ function FoldSheet({
}
// ---------------------------------------------------------------------------
function GraphInner({ taskId }: { taskId: string }) {
function GraphInner({ taskId, coverageEnabled = true }: { taskId: string; coverageEnabled?: boolean }) {
const [data, setData] = React.useState<{
nodes: CoverageGraphNode[];
edges: CoverageGraphEdge[];
@@ -470,12 +470,17 @@ function GraphInner({ taskId }: { taskId: string }) {
setLoading(true);
api
.taskCoverageGraph(taskId)
.then((g) => setData({ nodes: g.nodes ?? [], edges: g.edges ?? [] }))
.then((g) => {
// 资产覆盖度功能关闭(B1):仍出图(范围内资产/company 关联依旧可见),但抹平
// tested 状态——不显示测试进度、不做已测高亮。
const nodes = coverageEnabled ? (g.nodes ?? []) : (g.nodes ?? []).map((n) => ({ ...n, tested: false }));
setData({ nodes, edges: g.edges ?? [] });
})
.catch(() => {
/* 保留上一次数据 */
})
.finally(() => setLoading(false));
}, [taskId]);
}, [taskId, coverageEnabled]);
React.useEffect(() => {
fetchGraph();
@@ -630,8 +635,14 @@ function GraphInner({ taskId }: { taskId: string }) {
<div className="flex items-center justify-between gap-3">
{total > 0 ? (
<span className="text-muted-foreground">
范围内 <span className="text-foreground font-semibold tabular-nums">{inScope}</span> · 已测{" "}
<span className="font-semibold tabular-nums text-emerald-600 dark:text-emerald-400">{tested}</span>
范围内 <span className="text-foreground font-semibold tabular-nums">{inScope}</span>
{coverageEnabled && (
<>
{" "}
· 已测{" "}
<span className="font-semibold tabular-nums text-emerald-600 dark:text-emerald-400">{tested}</span>
</>
)}
</span>
) : (
<span className="text-muted-foreground">{loading ? "加载中…" : "暂无范围内资产(先锚定任务范围)"}</span>
@@ -655,12 +666,16 @@ function GraphInner({ taskId }: { taskId: string }) {
})}
</div>
<div className="border-border/60 text-muted-foreground flex flex-wrap gap-x-3 gap-y-1.5 border-t pt-2">
<span className="inline-flex items-center gap-1.5">
<span className="size-3 rounded-full bg-emerald-500" /> 已测(高亮)
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-3 rounded-full bg-neutral-400" /> 未测
</span>
{coverageEnabled && (
<>
<span className="inline-flex items-center gap-1.5">
<span className="size-3 rounded-full bg-emerald-500" /> 已测(高亮)
</span>
<span className="inline-flex items-center gap-1.5">
<span className="size-3 rounded-full bg-neutral-400" /> 未测
</span>
</>
)}
<span className="inline-flex items-center gap-1.5">
<span className="size-3 rounded-full border border-dashed border-neutral-400 bg-neutral-200" /> 范围外
</span>
@@ -684,12 +699,12 @@ function GraphInner({ taskId }: { taskId: string }) {
);
}
export function CoverageGraphTab({ taskId }: { taskId: string }) {
export function CoverageGraphTab({ taskId, coverageEnabled = true }: { taskId: string; coverageEnabled?: boolean }) {
return (
<Card>
<CardContent className="p-0">
<div className="h-[72vh] w-full overflow-hidden rounded-xl">
<GraphInner taskId={taskId} />
<GraphInner taskId={taskId} coverageEnabled={coverageEnabled} />
</div>
</CardContent>
</Card>
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
import { CheckIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
import { BotIcon, CheckIcon, RefreshCwIcon, ShieldAlertIcon, XIcon } from "lucide-react";
import { toast } from "sonner";
import { api } from "@/lib/api";
import type { InterceptApprovalRow } from "@/lib/types";
@@ -38,6 +38,30 @@ function StatusBadge({ status }: { status: string }) {
return <Badge variant="outline" className="text-muted-foreground">已超时</Badge>;
}
function MatchCell({ row }: { row: InterceptApprovalRow }) {
const isModel = !row.rule_id;
const reason = row.reason?.replace(/^\[模型\]\s*/, "").trim();
return (
<div className="space-y-0.5">
{row.rule_name ? (
<span className="text-xs text-muted-foreground">{row.rule_name}</span>
) : isModel ? (
<Badge variant="outline" className="gap-1 border-violet-400 text-violet-600">
<BotIcon className="h-3 w-3" />
模型判定
</Badge>
) : (
<span className="text-xs text-muted-foreground">—</span>
)}
{reason && (
<p className="line-clamp-1 text-[11px] text-muted-foreground" title={reason}>
{reason}
</p>
)}
</div>
);
}
function InputPreview({ input }: { input: Record<string, unknown> }) {
const full = JSON.stringify(input, null, 2);
const s = JSON.stringify(input);
@@ -153,7 +177,7 @@ export function InterceptTab({ taskId }: Props) {
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{row.rule_name || "—"}</TableCell>
<TableCell><MatchCell row={row} /></TableCell>
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
<TableCell className="text-right">
@@ -199,7 +223,7 @@ export function InterceptTab({ taskId }: Props) {
<TableCell>
<code className="rounded bg-muted px-1.5 py-0.5 text-xs font-mono">{row.tool_name}</code>
</TableCell>
<TableCell className="text-xs text-muted-foreground">{row.rule_name || "—"}</TableCell>
<TableCell><MatchCell row={row} /></TableCell>
<TableCell className="w-48 max-w-[12rem]"><InputPreview input={row.tool_input} /></TableCell>
<TableCell><StatusBadge status={row.status} /></TableCell>
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">{fmtTime(row.created_at)}</TableCell>
@@ -5,10 +5,24 @@ import {
ActivityIcon,
AlertTriangleIcon,
BugIcon,
CheckIcon,
ClockIcon,
<<<<<<< Updated upstream
=======
CoinsIcon,
ListChecksIcon,
PencilIcon,
PlusIcon,
>>>>>>> Stashed changes
RefreshCwIcon,
ShieldAlertIcon,
ShieldCheckIcon,
TargetIcon,
<<<<<<< Updated upstream
=======
Trash2Icon,
XIcon,
>>>>>>> Stashed changes
} from "lucide-react";
import {
Card,
@@ -21,7 +35,55 @@ import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { StatusBadge } from "@/components/status-badge";
import { api } from "@/lib/api";
<<<<<<< Updated upstream
import type { Task, Stats, TaskNode, Finding } from "@/lib/types";
=======
import type {
Finding,
ModelTokenStat,
Stats,
Task,
TaskConstraint,
TaskGoal,
TaskNode,
TaskScopeRow,
} from "@/lib/types";
// 紧凑格式化 token 数(12345 → 12.3k,2000000 → 2M)。
function fmtTokens(n: number): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1) + "M";
if (n >= 1000) return (n / 1000).toFixed(n >= 10000 ? 0 : 1) + "k";
return String(n);
}
// 缓存命中率 = 缓存读 / 输入(InputTokens 已含 cache_read 子集,故比值在 0–100%)。
function cacheHitRate(cacheRead: number, input: number): string {
if (input <= 0) return "—";
return Math.round((cacheRead / input) * 100) + "%";
}
// 测试范围一条的显示值:域名 / 网段 / 公司。
function scopeValue(row: TaskScopeRow): string {
if (row.domain) return row.domain;
if (row.net) return row.net;
if (row.company_id) return row.company_name || `企业 #${row.company_id}`;
return "—";
}
const SCOPE_KIND_LABELS: Record<TaskScopeRow["kind"], string> = {
company: "公司",
root_domain: "根域名",
subdomain: "子域名",
ip: "IP",
cidr: "网段",
};
const SCOPE_SOURCE_LABELS: Record<TaskScopeRow["source"], string> = {
auto: "自动",
agent: "Agent",
manual: "手动",
};
>>>>>>> Stashed changes
function StatCard({
label,
@@ -55,6 +117,7 @@ export function OverviewTab({ taskId }: { taskId: string }) {
const [intents, setIntents] = React.useState<TaskNode[]>([]);
const [findings, setFindings] = React.useState<Finding[]>([]);
const [coverage, setCoverage] = React.useState<{
enabled: boolean;
scope_rows: number;
denominator: number;
tested: number;
@@ -63,6 +126,203 @@ export function OverviewTab({ taskId }: { taskId: string }) {
} | null>(null);
// 正在重跑的意图 id(含 "__all__" 表示批量),用于禁用按钮 + 转圈。
const [rerunning, setRerunning] = React.useState<Set<string>>(new Set());
<<<<<<< Updated upstream
=======
// 测试范围列表 + 新增表单状态。
const [scope, setScope] = React.useState<TaskScopeRow[]>([]);
const [scopeKind, setScopeKind] = React.useState<TaskScopeRow["kind"]>("root_domain");
const [scopeValueInput, setScopeValueInput] = React.useState("");
const [scopeBusy, setScopeBusy] = React.useState(false);
const [scopeErr, setScopeErr] = React.useState("");
// 按模型的 token 用量(来自常开的 llm_usage 计量账本,逐次精确)。
const [modelTokens, setModelTokens] = React.useState<ModelTokenStat[]>([]);
// 目标管理:目标列表 + 新增表单 + 行内编辑状态。
const [goals, setGoals] = React.useState<TaskGoal[]>([]);
const [goalText, setGoalText] = React.useState("");
const [goalVuln, setGoalVuln] = React.useState("");
const [goalBusy, setGoalBusy] = React.useState(false);
const [goalErr, setGoalErr] = React.useState("");
const [editingGoalId, setEditingGoalId] = React.useState<string | null>(null);
const [editText, setEditText] = React.useState("");
const [editVuln, setEditVuln] = React.useState("");
// 约束管理:约束列表 + 新增表单 + 行内编辑状态。
const [constraints, setConstraints] = React.useState<TaskConstraint[]>([]);
const [conText, setConText] = React.useState("");
const [conKind, setConKind] = React.useState<TaskConstraint["kind"]>("deny");
const [conBusy, setConBusy] = React.useState(false);
const [conErr, setConErr] = React.useState("");
const [editingConId, setEditingConId] = React.useState<string | null>(null);
const [editConText, setEditConText] = React.useState("");
const [editConKind, setEditConKind] = React.useState<TaskConstraint["kind"]>("deny");
const loadTokens = React.useCallback(async () => {
try {
const resp = await api.tokensByModel(taskId);
setModelTokens(resp.models);
} catch {
// 忽略:无 PG 时接口报错,卡片自然为空
}
}, [taskId]);
const loadScope = React.useCallback(async () => {
try {
const resp = await api.taskScope(taskId);
setScope(resp.scope);
} catch {
// 忽略:无 asset store 时接口 503,范围卡片自然为空
}
}, [taskId]);
const loadGoals = React.useCallback(async () => {
try {
const resp = await api.taskGoals(taskId);
setGoals(resp.goals);
} catch {
// 忽略:瞬时错误,下次轮询重试
}
}, [taskId]);
const addGoal = async () => {
const text = goalText.trim();
if (!text) return;
setGoalBusy(true);
setGoalErr("");
try {
await api.addGoal(taskId, text, goalVuln.trim() || undefined);
setGoalText("");
setGoalVuln("");
await loadGoals();
} catch (e) {
setGoalErr(e instanceof Error ? e.message : "添加失败");
} finally {
setGoalBusy(false);
}
};
const startEditGoal = (g: TaskGoal) => {
setEditingGoalId(g.id);
setEditText(g.text);
setEditVuln(g.vulnclass ?? "");
};
const cancelEditGoal = () => {
setEditingGoalId(null);
setEditText("");
setEditVuln("");
};
const saveEditGoal = async (g: TaskGoal) => {
const text = editText.trim();
if (!text) return;
setGoalBusy(true);
setGoalErr("");
try {
await api.updateGoal(taskId, g.id, text, editVuln.trim() || undefined);
cancelEditGoal();
await loadGoals();
} catch (e) {
setGoalErr(e instanceof Error ? e.message : "保存失败");
} finally {
setGoalBusy(false);
}
};
const removeGoal = async (g: TaskGoal) => {
setGoals((prev) => prev.filter((x) => x.id !== g.id));
try {
await api.deleteGoal(taskId, g.id);
} catch {
await loadGoals(); // 删除失败:重新拉取还原
}
};
const loadConstraints = React.useCallback(async () => {
try {
const resp = await api.taskConstraints(taskId);
setConstraints(resp.constraints);
} catch {
// 忽略:瞬时错误,下次轮询重试
}
}, [taskId]);
const addConstraint = async () => {
const text = conText.trim();
if (!text) return;
setConBusy(true);
setConErr("");
try {
await api.addConstraint(taskId, text, conKind);
setConText("");
await loadConstraints();
} catch (e) {
setConErr(e instanceof Error ? e.message : "添加失败");
} finally {
setConBusy(false);
}
};
const startEditConstraint = (c: TaskConstraint) => {
setEditingConId(c.id);
setEditConText(c.text);
setEditConKind(c.kind);
};
const cancelEditConstraint = () => {
setEditingConId(null);
setEditConText("");
setEditConKind("deny");
};
const saveEditConstraint = async (c: TaskConstraint) => {
const text = editConText.trim();
if (!text) return;
setConBusy(true);
setConErr("");
try {
await api.updateConstraint(taskId, c.id, text, editConKind);
cancelEditConstraint();
await loadConstraints();
} catch (e) {
setConErr(e instanceof Error ? e.message : "保存失败");
} finally {
setConBusy(false);
}
};
const removeConstraint = async (c: TaskConstraint) => {
setConstraints((prev) => prev.filter((x) => x.id !== c.id));
try {
await api.deleteConstraint(taskId, c.id);
} catch {
await loadConstraints(); // 删除失败:重新拉取还原
}
};
const addScope = async () => {
const value = scopeValueInput.trim();
if (!value) return;
setScopeBusy(true);
setScopeErr("");
try {
await api.addTaskScope(taskId, scopeKind, value);
setScopeValueInput("");
await loadScope();
} catch (e) {
setScopeErr(e instanceof Error ? e.message : "添加失败");
} finally {
setScopeBusy(false);
}
};
const removeScope = async (row: TaskScopeRow) => {
setScope((prev) => prev.filter((s) => s.id !== row.id));
try {
await api.deleteTaskScope(taskId, row.id);
} catch {
await loadScope(); // 删除失败:重新拉取还原
}
};
>>>>>>> Stashed changes
const markRerun = (key: string, on: boolean) =>
setRerunning((prev) => {
@@ -133,12 +393,29 @@ export function OverviewTab({ taskId }: { taskId: string }) {
};
load();
<<<<<<< Updated upstream
const timer = setInterval(load, 3000);
=======
void loadScope();
void loadTokens();
void loadGoals();
void loadConstraints();
const timer = setInterval(() => {
void load();
void loadTokens();
void loadGoals();
void loadConstraints();
}, 3000);
>>>>>>> Stashed changes
return () => {
cancelled = true;
clearInterval(timer);
};
<<<<<<< Updated upstream
}, [taskId]);
=======
}, [taskId, loadScope, loadTokens, loadGoals, loadConstraints]);
>>>>>>> Stashed changes
const running = intents.filter((i) => i.state === "running");
const open = intents.filter((i) => i.state === "open");
@@ -172,7 +449,252 @@ export function OverviewTab({ taskId }: { taskId: string }) {
</div>
</CardContent>
</Card>
{coverage && coverage.scope_rows > 0 && (
{/* 目标管理:查看/新增/修改/删除本任务的探索目标。新增与修改会通知规划者并复活任务,
删除仅通知规划者(不复活)。目标 = 最终可交付/可核验的结果,不是攻击步骤或侦察动作。 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ListChecksIcon className="size-4 text-primary" /> 目标管理
<span className="text-muted-foreground text-xs font-normal">
(最终可核验的目标,共 {goals.length} 条;新增/修改会通知规划者并复活任务)
</span>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{/* 新增表单 */}
<div className="flex flex-wrap items-center gap-2">
<Input
className="h-7 min-w-56 flex-1 text-sm"
placeholder="新增目标,如『拿到管理员账号的越权访问』"
value={goalText}
onChange={(e) => setGoalText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addGoal();
}}
disabled={goalBusy}
/>
<Input
className="h-7 w-32 text-sm"
placeholder="漏洞类(可选)"
value={goalVuln}
onChange={(e) => setGoalVuln(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addGoal();
}}
disabled={goalBusy}
/>
<Button size="sm" variant="outline" disabled={goalBusy || !goalText.trim()} onClick={() => void addGoal()}>
<PlusIcon className="size-3.5" /> 添加
</Button>
{goalErr && <span className="text-xs text-red-500">{goalErr}</span>}
</div>
{/* 目标列表 */}
{goals.length > 0 ? (
<div className="flex flex-col gap-1.5">
{goals.map((g) =>
editingGoalId === g.id ? (
<div key={g.id} className="flex flex-wrap items-center gap-2 rounded-md border px-2.5 py-1.5">
<Input
className="h-7 min-w-56 flex-1 text-sm"
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void saveEditGoal(g);
if (e.key === "Escape") cancelEditGoal();
}}
disabled={goalBusy}
autoFocus
/>
<Input
className="h-7 w-32 text-sm"
placeholder="漏洞类(可选)"
value={editVuln}
onChange={(e) => setEditVuln(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void saveEditGoal(g);
if (e.key === "Escape") cancelEditGoal();
}}
disabled={goalBusy}
/>
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
disabled={goalBusy || !editText.trim()}
onClick={() => void saveEditGoal(g)}
>
<CheckIcon className="size-3.5 text-emerald-500" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
disabled={goalBusy}
onClick={cancelEditGoal}
>
<XIcon className="size-3.5" />
</Button>
</div>
) : (
<div key={g.id} className="flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
<StatusBadge domain="goal" value={g.state} />
<span className="min-w-0 flex-1 break-words">{g.text}</span>
{g.vulnclass && (
<span className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 text-xs">
{g.vulnclass}
</span>
)}
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
disabled={goalBusy}
onClick={() => startEditGoal(g)}
>
<PencilIcon className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
disabled={goalBusy}
onClick={() => void removeGoal(g)}
>
<Trash2Icon className="size-3.5 text-red-500" />
</Button>
</div>
),
)}
</div>
) : (
<p className="text-muted-foreground text-sm">暂无目标,添加后规划者会据此派发探索意图并判定达成。</p>
)}
</CardContent>
</Card>
{/* 操作约束管理:allow=允许 / deny=禁止。约束会在下一轮规划时注入 planner/worker 的系统
提示以框定探索边界(注入范围可在 系统设置 里按 planner/worker 开关)。改动不即时打断,
下一轮规划自然读到。 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<ShieldAlertIcon className="size-4 text-amber-500" /> 操作约束
<span className="text-muted-foreground text-xs font-normal">
(框定 planner/worker 的探索边界,共 {constraints.length} 条;改动下一轮规划生效)
</span>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{/* 新增表单 */}
<div className="flex flex-wrap items-center gap-2">
<NativeSelect
size="sm"
value={conKind}
onChange={(e) => setConKind(e.target.value as TaskConstraint["kind"])}
>
<NativeSelectOption value="deny">禁止</NativeSelectOption>
<NativeSelectOption value="allow">允许</NativeSelectOption>
</NativeSelect>
<Input
className="h-7 min-w-56 flex-1 text-sm"
placeholder="一条操作约束,如『仅测当前端口,不扫其他端口』"
value={conText}
onChange={(e) => setConText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void addConstraint();
}}
disabled={conBusy}
/>
<Button size="sm" variant="outline" disabled={conBusy || !conText.trim()} onClick={() => void addConstraint()}>
<PlusIcon className="size-3.5" /> 添加
</Button>
{conErr && <span className="text-xs text-red-500">{conErr}</span>}
</div>
{/* 约束列表 */}
{constraints.length > 0 ? (
<div className="flex flex-col gap-1.5">
{constraints.map((c) =>
editingConId === c.id ? (
<div key={c.id} className="flex flex-wrap items-center gap-2 rounded-md border px-2.5 py-1.5">
<NativeSelect
size="sm"
value={editConKind}
onChange={(e) => setEditConKind(e.target.value as TaskConstraint["kind"])}
>
<NativeSelectOption value="deny">禁止</NativeSelectOption>
<NativeSelectOption value="allow">允许</NativeSelectOption>
</NativeSelect>
<Input
className="h-7 min-w-56 flex-1 text-sm"
value={editConText}
onChange={(e) => setEditConText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void saveEditConstraint(c);
if (e.key === "Escape") cancelEditConstraint();
}}
disabled={conBusy}
autoFocus
/>
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
disabled={conBusy || !editConText.trim()}
onClick={() => void saveEditConstraint(c)}
>
<CheckIcon className="size-3.5 text-emerald-500" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
disabled={conBusy}
onClick={cancelEditConstraint}
>
<XIcon className="size-3.5" />
</Button>
</div>
) : (
<div key={c.id} className="flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
<span
className={`shrink-0 rounded px-1.5 py-0.5 text-xs ${
c.kind === "allow"
? "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400"
: "bg-red-500/15 text-red-600 dark:text-red-400"
}`}
>
{c.kind === "allow" ? "允许" : "禁止"}
</span>
<span className="min-w-0 flex-1 break-words">{c.text}</span>
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
disabled={conBusy}
onClick={() => startEditConstraint(c)}
>
<PencilIcon className="size-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-6 shrink-0 px-1.5"
disabled={conBusy}
onClick={() => void removeConstraint(c)}
>
<Trash2Icon className="size-3.5 text-red-500" />
</Button>
</div>
),
)}
</div>
) : (
<p className="text-muted-foreground text-sm">
暂无操作约束。建任务时会自动从描述/目标抽取;也可在此手动增删改,用来框定「允许/禁止做哪些操作」。
</p>
)}
</CardContent>
</Card>
{coverage && coverage.enabled && coverage.scope_rows > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
@@ -24,7 +24,12 @@ import { cn } from "@/lib/utils";
import { Transcript } from "@/components/transcript";
import { TodoPopover } from "@/components/todo-popover";
import { Button } from "@/components/ui/button";
<<<<<<< Updated upstream
import { Input } from "@/components/ui/input";
=======
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } from "@/components/ui/input-group";
import { Textarea } from "@/components/ui/textarea";
>>>>>>> Stashed changes
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Tooltip,
@@ -33,6 +38,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api, sseUrl } from "@/lib/api";
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
import { MOCK } from "@/lib/mock/enabled";
import type {
Activity,
@@ -44,6 +50,13 @@ import type {
TokenTotal,
} from "@/lib/types";
// resolutionLabel names the LLM a session runs on. The badge shows this alone and
// keeps the model id in its tooltip. Env-backed configs can arrive without a name,
// so fall back to the model id rather than rendering an empty badge.
function resolutionLabel(r: TaskLLMResolution): string {
return r.name || r.model || "未命名配置";
}
// fmtBytes renders a human file size for attachment chips (mirrors transcript.tsx).
function fmtBytes(n: number): string {
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
@@ -233,6 +246,14 @@ function SessionItem({
hasPending,
unread,
onClick,
<<<<<<< Updated upstream
=======
onPause,
onResume,
onCancel,
controlling,
deleted,
>>>>>>> Stashed changes
}: {
s: Session;
active: boolean;
@@ -240,13 +261,22 @@ function SessionItem({
hasPending?: boolean;
unread?: number;
onClick: () => void;
<<<<<<< Updated upstream
=======
onPause?: () => void;
onResume?: () => void;
onCancel?: () => void;
controlling?: boolean;
deleted?: boolean;
>>>>>>> Stashed changes
}) {
const icon =
s.role === "worker" ? (
statusIcon(s.status)
) : s.live ? (
<Loader2Icon className="size-3.5 animate-spin text-blue-500" />
) : null;
const icon = deleted ? (
<Trash2Icon className="size-3.5 text-destructive" />
) : s.role === "worker" ? (
statusIcon(s.status)
) : s.live ? (
<Loader2Icon className="size-3.5 animate-spin text-blue-500" />
) : null;
return (
<button
@@ -256,11 +286,91 @@ function SessionItem({
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
)}
>
<<<<<<< Updated upstream
{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={cn("min-w-0 flex-1 truncate text-sm font-medium", deleted && "text-muted-foreground line-through")}>
{displayTitle}
</span>
{deleted && (
<Badge variant="outline" className="shrink-0 border-destructive/40 text-destructive">
已删除
</Badge>
)}
{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="删除该意图(需填写原因,保留数据)"
aria-label="删除该意图(需填写原因,保留数据)"
className="text-destructive hover:text-destructive"
>
<Trash2Icon />
</Button>
</div>
>>>>>>> Stashed changes
)}
<span className="min-w-0 flex-1 truncate">{displayTitle}</span>
{hasPending && (
@@ -295,6 +405,13 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const [input, setInput] = React.useState("");
const [sending, setSending] = React.useState(false);
const [stopping, setStopping] = React.useState(false);
<<<<<<< Updated upstream
=======
const [mainChatRunning, setMainChatRunning] = React.useState<boolean | null>(null);
const [controllingIntent, setControllingIntent] = React.useState<string | null>(null);
const [cancelIntent, setCancelIntent] = React.useState<Session | null>(null);
const [cancelReason, setCancelReason] = React.useState("");
>>>>>>> Stashed changes
// 方式1 文件上传:选好的附件(已落到任务工作目录 uploads/),随下条消息一起发。
const [attachments, setAttachments] = React.useState<ChatAttachment[]>([]);
const [uploading, setUploading] = React.useState(false);
@@ -313,6 +430,50 @@ export function SessionsTab({ taskId }: { taskId: string }) {
if (fileInputRef.current) fileInputRef.current.value = "";
}
}
<<<<<<< Updated upstream
=======
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", reason?: string) => {
if (!session.intent_id || session.inherited || controllingIntent) return;
if (action === "cancel" && !reason?.trim()) {
toast.error("请填写删除原因");
return;
}
setControllingIntent(session.intent_id);
try {
await api.controlIntent(taskId, session.intent_id, action, reason);
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 {
// 删除 = 停止意图并附原因(不销毁意图与产出);保留会话,状态置为 stopped。
patchIntentState(session.intent_id, "stopped");
toast.success(`Worker #${session.intent_id} 已删除(原因已记录,规划者将据此重新规划)`);
setCancelReason("");
}
} catch (error) {
toast.error(`Worker 操作失败:${(error as Error).message}`);
} finally {
setControllingIntent(null);
setCancelIntent(null);
}
},
[controllingIntent, patchIntentState, taskId],
);
>>>>>>> Stashed changes
// SSE connection state — surfaced so a dropped realtime link is visible, never
// silently shown as "no messages".
const [sseLive, setSseLive] = React.useState(false);
@@ -627,21 +788,27 @@ export function SessionsTab({ taskId }: { taskId: string }) {
// For each worker session (intent), derive the display title from the intent
// payload summary. Also store the full TaskNode for the hover-JSON tooltip.
const sessionMeta = React.useMemo(() => {
const map = new Map<string, { title: string; json: unknown }>();
const map = new Map<string, { title: string; json: unknown; deleted: boolean; deleteReason: string }>();
for (const node of allIntents) {
let title = `Intent ${node.id}`;
let parsedPayload: unknown = node.payload;
let deleted = false;
let deleteReason = "";
if (node.payload) {
try {
const p = JSON.parse(node.payload);
parsedPayload = p;
if (p?.summary) title = String(p.summary);
if (p?.cancelled_by_user) {
deleted = true;
deleteReason = String(p.cancel_reason ?? "");
}
} catch {
title = node.payload.trim() || title;
}
}
const json = { ...node, payload: parsedPayload };
map.set(node.id, { title, json });
map.set(node.id, { title, json, deleted, deleteReason });
}
return map;
}, [allIntents]);
@@ -861,6 +1028,8 @@ export function SessionsTab({ taskId }: { taskId: string }) {
}
const mainLoaded = !!store.main?.loaded;
// 发送键位由系统设置决定(localStorage),默认 Enter 发送。
const sendMode = useChatSendMode();
// What the transcript pane should show for the active session.
const showLoader = !activeState || (activeState.loading && !activeState.loaded);
@@ -894,6 +1063,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
</span>
)}
</div>
<<<<<<< Updated upstream
{taskTokens && (
<div
className="mt-1 text-[11px] tabular-nums text-muted-foreground"
@@ -901,6 +1071,62 @@ export function SessionsTab({ taskId }: { taskId: string }) {
>
任务总计 · input {fmtTokens(taskTokens.input_tokens)} · cache{" "}
{fmtTokens(taskTokens.cache_read_tokens)} · output {fmtTokens(taskTokens.output_tokens)}
=======
<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", "system", "worker"] as const).map((role) => {
const items = grouped[role];
if (!items.length) return null;
const Meta = roleMeta[role];
return (
<div key={role} className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5 px-2 py-1 text-xs font-medium text-muted-foreground">
<Meta.icon className="size-3.5" />
{Meta.label}
</div>
{items.map((s) => {
const meta = s.role === "worker" ? sessionMeta.get(s.id) : undefined;
return (
<SessionItem
key={s.id}
s={s}
active={s.id === activeId}
displayTitle={meta?.title ?? s.title}
hasPending={hasPendingForSession(s)}
unread={store[keyForSession(s)]?.unread}
onClick={() => setActiveId(s.id)}
controlling={controllingIntent === s.intent_id}
deleted={meta?.deleted}
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"
>
{loadingOlderIntents ? (
<Loader2Icon className="size-3.5 animate-spin" />
) : (
<RotateCwIcon className="size-3.5" />
)}
加载更早的 Worker
</button>
)}
</div>
);
})}
{mainLoaded && !workerSessions.length && (
<div className="px-2 py-1 text-xs text-muted-foreground">暂无运行中的 Worker 会话。</div>
)}
>>>>>>> Stashed changes
</div>
)}
</div>
@@ -968,11 +1194,31 @@ export function SessionsTab({ taskId }: { taskId: string }) {
const titleEl = <span className="text-sm font-medium">{title}</span>;
return meta?.json ? (
<Tooltip>
<<<<<<< Updated upstream
<TooltipTrigger asChild>{titleEl}</TooltipTrigger>
<TooltipContent side="bottom" align="start" className="max-h-80 max-w-sm overflow-auto p-0">
<pre className="p-2 text-[10px] leading-relaxed">
{JSON.stringify(meta.json, null, 2)}
</pre>
=======
<TooltipTrigger asChild>
<Badge
variant="outline"
className="max-w-28 shrink-0 font-normal"
aria-label={
activeResolution.available ? `当前配置:${resolutionLabel(activeResolution)}` : "模型不可用"
}
>
<span className="truncate">
{activeResolution.available ? resolutionLabel(activeResolution) : "模型不可用"}
</span>
</Badge>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs [overflow-wrap:anywhere]">
{activeResolution.available
? [resolutionLabel(activeResolution), activeResolution.model].filter(Boolean).join(" / ")
: activeResolution.reason || "没有可用的 LLM 配置"}
>>>>>>> Stashed changes
</TooltipContent>
</Tooltip>
) : titleEl;
@@ -1005,8 +1251,33 @@ export function SessionsTab({ taskId }: { taskId: string }) {
)}
{isMain && <span>可交互</span>}
</div>
<<<<<<< Updated upstream
</div>
{/* Force Radix's internal viewport wrapper (display:table, sizes to content)
=======
{(() => {
const dm = active.role === "worker" ? sessionMeta.get(active.id) : undefined;
if (!dm?.deleted) return null;
return (
<div className="flex items-start gap-2 border-b border-destructive/30 bg-destructive/5 px-4 py-2.5 text-xs">
<Trash2Icon className="mt-0.5 size-3.5 shrink-0 text-destructive" />
<div className="min-w-0">
<span className="font-medium text-destructive">此意图已被用户删除</span>
<span className="text-muted-foreground">
(已停止执行,规划者已收到通知;意图与产出保留,可在下方查看历史)
</span>
{dm.deleteReason && (
<p className="mt-1 break-words text-foreground">
<span className="text-muted-foreground">删除原因:</span>
{dm.deleteReason}
</p>
)}
</div>
</div>
);
})()}
{/* Force Radix's internal viewport wrapper (display:table, sizes to content)
>>>>>>> Stashed changes
to block so wide/unbreakable steps (long commands, code, URLs) can't blow
out the width and defeat the truncation below — the transcript wraps to
the panel instead of overflowing horizontally. */}
@@ -1078,6 +1349,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
className="hidden"
onChange={(e) => void pickFiles(e.target.files)}
/>
<<<<<<< Updated upstream
<Button
size="icon"
variant="ghost"
@@ -1115,6 +1387,113 @@ export function SessionsTab({ taskId }: { taskId: string }) {
/>
</div>
)}
=======
<InputGroup className="min-h-9 has-disabled:opacity-100">
<InputGroupTextarea
rows={1}
aria-label="给主 Agent 发消息"
placeholder="给主 Agent 发消息,引导探索方向…"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (!shouldSubmitOnKey(e, sendMode)) return;
e.preventDefault();
if (!mainLive) send();
}}
disabled={mainLive}
className="max-h-36 min-h-9 overflow-y-auto"
/>
<InputGroupAddon align="block-end">
<InputGroupButton
size="icon-xs"
variant="ghost"
onClick={() => fileInputRef.current?.click()}
disabled={mainLive || uploading}
title="上传文件"
aria-label="上传文件"
>
{uploading ? <Loader2Icon className="animate-spin" /> : <PaperclipIcon />}
</InputGroupButton>
{mainLive ? (
<InputGroupButton
className="ml-auto"
size="icon-xs"
variant="destructive"
onClick={stop}
disabled={stopping}
title="停止当前执行"
aria-label="停止当前执行"
>
{stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />}
</InputGroupButton>
) : (
<InputGroupButton
className="ml-auto"
size="icon-xs"
variant="default"
onClick={send}
disabled={(!input.trim() && attachments.length === 0) || sending}
title="发送消息"
aria-label="发送消息"
>
{sending ? <Loader2Icon className="animate-spin" /> : <ArrowUpIcon />}
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</div>
) : (
<div className="flex items-center border-t px-4 py-2">
<TodoPopover
seq={latestTodoSeq}
fetchDetail={(seq) => api.activityDetail(seq, taskId).then((r) => r.detail ?? "")}
/>
</div>
)}
</div>
<AlertDialog
open={cancelIntent !== null}
onOpenChange={(open) => {
if (!open) {
setCancelIntent(null);
setCancelReason("");
}
}}
>
<AlertDialogContent className="max-w-[min(32rem,calc(100vw-2rem))]">
<AlertDialogHeader>
<AlertDialogTitle>删除 Worker #{cancelIntent?.intent_id}?</AlertDialogTitle>
<AlertDialogDescription className="break-words whitespace-normal">
删除会<strong>停止该意图</strong>(不再执行),并把删除原因作为一条事实挂到该意图上;意图、执行记录、已登记的事实和漏洞<strong>都会保留</strong>。规划者会收到「该意图由用户删除 + 原因」并据此重新规划。
</AlertDialogDescription>
</AlertDialogHeader>
<div className="grid gap-2 py-1">
<label htmlFor="cancel-reason" className="text-sm font-medium">
删除原因(必填)
</label>
<Textarea
id="cancel-reason"
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
placeholder="说明为什么删除这条意图,例如:方向判断错误 / 目标已失效 / 与其他意图重复…"
rows={3}
autoFocus
/>
</div>
<AlertDialogFooter>
<AlertDialogCancel>返回</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={!cancelIntent || controllingIntent !== null || !cancelReason.trim()}
onClick={() => cancelIntent && void controlWorker(cancelIntent, "cancel", cancelReason)}
>
{controllingIntent ? <Loader2Icon className="animate-spin" /> : <Trash2Icon />}
确认删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
>>>>>>> Stashed changes
</div>
</div>
</TooltipProvider>
@@ -125,8 +125,13 @@ function TaskDetailInner() {
<ArrowLeftIcon />
</Link>
</Button>
<<<<<<< Updated upstream
<h1 className="max-w-md truncate text-sm font-semibold" title={task.description}>
{task.description}
=======
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold" title={task.name || task.description}>
{task.name || task.description}
>>>>>>> Stashed changes
</h1>
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
{task.id}
@@ -182,7 +187,7 @@ function TaskDetailInner() {
<AssetsTab taskId={id} />
</TabsContent>
<TabsContent value="coverage" className="mt-0">
<CoverageGraphTab taskId={id} />
<CoverageGraphTab taskId={id} coverageEnabled={task?.coverage_enabled !== false} />
</TabsContent>
<TabsContent value="intercept" className="mt-0">
<InterceptTab taskId={id} />
+776
View File
@@ -2,6 +2,24 @@
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,
@@ -150,6 +168,17 @@ const STATUS_OPTIONS: { value: TaskStatus; label: string }[] = [
{ 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("");
@@ -164,6 +193,10 @@ export default function TasksPage() {
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)
@@ -214,6 +247,7 @@ export default function TasksPage() {
return () => clearInterval(i);
}, [hasRunning]);
<<<<<<< Updated upstream
const deleteTask = React.useCallback(async (id: string) => {
try {
await api.deleteTask(id);
@@ -223,6 +257,152 @@ export default function TasksPage() {
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>
@@ -279,6 +459,7 @@ export default function TasksPage() {
<TableHeader className="[&_tr]:border-t">
<TableRow>
<TableHead className="font-mono">ID</TableHead>
<TableHead>名称</TableHead>
<TableHead>描述</TableHead>
<TableHead>目标</TableHead>
<TableHead>状态</TableHead>
@@ -298,6 +479,12 @@ export default function TasksPage() {
// 带来的整表重渲染,只让在跑的那几行走时长。
nowSec={task.status === "running" ? nowSec : 0}
onDelete={deleteTask}
<<<<<<< Updated upstream
=======
onControl={controlTask}
selected={selectedIds.has(task.id)}
onSelectedChange={toggleSelected}
>>>>>>> Stashed changes
/>
))}
</TableBody>
@@ -323,10 +510,23 @@ 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">
@@ -335,14 +535,34 @@ const TaskRow = React.memo(function TaskRow({
</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 />
@@ -387,6 +607,7 @@ const TaskRow = React.memo(function TaskRow({
)}
</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}`}>
@@ -417,18 +638,349 @@ const TaskRow = React.memo(function TaskRow({
</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[]>([]);
@@ -436,6 +988,7 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
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);
@@ -479,14 +1032,31 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
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);
@@ -497,10 +1067,216 @@ function CreateTaskSheet({ onCreated }: { onCreated: () => void }) {
}
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 宽的右侧抽屉:整屏高度可滚动,长表单不再受弹窗高度限制。窄屏退化为全宽。