feat: 支持会话级漏洞复测与运行状态展示

This commit is contained in:
ji ruo committed 2026-09-10 16:06:08 +08:00
1 parent 8dae851b9b
commit 82c1ed9cbb
30 files changed
+2080 -80

No files matched your search

+81 -44
View File
@@ -46,8 +46,10 @@ import {
} from "@/components/ui/dropdown-menu";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { mergeActivities } from "@/lib/activity-merge";
import { api } from "@/lib/api";
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
import type { Activity, Agent, ChatAttachment, Conversation, LLMProfile } from "@/lib/types";
@@ -166,7 +168,7 @@ function Composer({
))}
</div>
)}
<div className="flex flex-wrap items-end gap-2">
<div className="flex flex-wrap items-center gap-2">
{leftSlot ? <div className="w-full sm:w-auto">{leftSlot}</div> : null}
{onPickFiles && (
<>
@@ -243,7 +245,7 @@ function LLMProfileRow({
const label = current ? current.name : `默认${activeDefault ? `(${activeDefault.name})` : ""}`;
return (
<div className="flex items-center gap-1 px-1 pb-1 pt-0.5">
<div className="flex min-w-0 flex-wrap items-center gap-1 px-3 pb-3">
<ZapIcon className="text-muted-foreground/50 size-3 shrink-0" />
<span className="text-muted-foreground/70 text-xs">{label}</span>
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
@@ -364,23 +366,25 @@ function DraftChat({
const agentPicker = (
<Select value={agentKey} onValueChange={setAgentKey}>
<SelectTrigger size="sm" className="w-full sm:w-40">
<SelectTrigger className="w-full sm:w-40">
<SelectValue placeholder="选择 Agent…" />
</SelectTrigger>
<SelectContent>
{agents.map((a) => (
<SelectItem key={a.key} value={a.key}>
<span className="flex items-center gap-2">
<Bot className="size-3.5" />
{a.name}
{!a.builtin && (
<Badge variant="outline" className="px-1 py-0 text-[9px]">
自定义
</Badge>
)}
</span>
</SelectItem>
))}
<SelectGroup>
{agents.map((a) => (
<SelectItem key={a.key} value={a.key}>
<span className="flex items-center gap-2">
<Bot className="size-3.5" />
{a.name}
{!a.builtin && (
<Badge variant="outline" className="px-1 py-0 text-[9px]">
自定义
</Badge>
)}
</span>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
@@ -490,12 +494,12 @@ function ChatView({
.conversationHistory(conv.id, 0, HISTORY_PAGE)
.then((r) => {
if (!live) return;
setMessages(r.items);
cursorRef.current = r.cursor; // newest id → incremental-tail anchor
setMessages((current) => mergeActivities(r.items, current));
cursorRef.current = Math.max(cursorRef.current, r.cursor);
earliestRef.current = r.items.length ? r.items[0].seq : 0;
hasMoreRef.current = r.hasMore;
setHasMore(r.hasMore);
setRunning(r.running);
setRunning((current) => current || r.running);
})
.catch(() => {
/* The empty state remains usable when history loading fails. */
@@ -505,28 +509,40 @@ function ChatView({
};
}, [conv.id]);
// A trigger or another tab may start a turn while this conversation is open.
// A false list snapshot must not stop the tail before its final messages load.
React.useEffect(() => {
if (conv.running) setRunning(true);
}, [conv.running]);
// poll while a turn is running: pull new steps after the cursor.
React.useEffect(() => {
if (!running) return;
let live = true;
let timer: ReturnType<typeof setTimeout>;
const tick = async () => {
let keepPolling = true;
try {
const r = await api.conversationMessages(conv.id, cursorRef.current);
if (!live) return;
if (r.items.length) {
setMessages((prev) => [...prev, ...r.items]);
cursorRef.current = r.cursor;
setMessages((prev) => mergeActivities(prev, r.items));
}
cursorRef.current = Math.max(cursorRef.current, r.cursor);
keepPolling = r.running;
setRunning(r.running);
if (!r.running) onTitleMaybeChanged(); // first-turn auto-title landed
} catch {
/* transient — keep polling */
} finally {
// Slow responses must not overlap another poll with the same cursor.
if (live && keepPolling) timer = setTimeout(() => void tick(), 1000);
}
};
const h = setInterval(tick, 1000);
void tick();
return () => {
live = false;
clearInterval(h);
clearTimeout(timer);
};
}, [running, conv.id, onTitleMaybeChanged]);
@@ -549,10 +565,7 @@ function ChatView({
try {
const r = await api.conversationHistory(conv.id, earliestRef.current, HISTORY_PAGE);
if (r.items.length) {
setMessages((prev) => {
const seen = new Set(prev.map((a) => a.seq));
return [...r.items.filter((a) => !seen.has(a.seq)), ...prev];
});
setMessages((prev) => mergeActivities(r.items, prev));
earliestRef.current = r.items[0].seq;
}
hasMoreRef.current = r.hasMore;
@@ -647,11 +660,9 @@ function ChatView({
setAttachments([]);
try {
await api.sendConversationMessage(conv.id, msg, atts.length ? atts : undefined);
// The live loop pulls the persisted human turn immediately. Sharing that
// fetch avoids racing a separate post-send request against the poller.
setRunning(true);
// pull the just-persisted human turn immediately.
const r = await api.conversationMessages(conv.id, cursorRef.current);
setMessages((prev) => [...prev, ...r.items]);
cursorRef.current = r.cursor;
} catch (e) {
toast.error("发送失败:" + (e as Error).message);
setInput(msg); // restore so the user doesn't lose their text
@@ -840,6 +851,12 @@ const ConversationItem = React.memo(function ConversationItem({
<div className="flex min-w-0 items-center gap-1.5">
{pinned && <PinIcon className="text-primary size-3 shrink-0" aria-label="已置顶" />}
<div className="truncate text-sm">{conv.title || "新对话"}</div>
{conv.running ? (
<Badge variant="secondary" className="shrink-0 gap-1" title="Agent 正在运行">
<Spinner className="size-3" aria-hidden="true" />
运行中
</Badge>
) : null}
</div>
<div className="text-muted-foreground flex min-w-0 items-center gap-1 text-[11px]">
<Bot className="size-3 shrink-0" />
@@ -926,11 +943,15 @@ export default function ChatPage() {
Record<number, { input?: string; attachments?: ChatAttachment[] }>
>({});
const reloadConvs = React.useCallback(() => {
api
.conversations()
.then(setConvs)
.catch(() => setConvs([]));
const conversationListSeq = React.useRef(0);
const reloadConvs = React.useCallback(async () => {
const seq = ++conversationListSeq.current;
try {
const items = await api.conversations();
if (seq === conversationListSeq.current) setConvs(items);
} catch {
// Preserve the selected transcript and list on a transient poll failure.
}
}, []);
React.useEffect(() => {
api
@@ -945,7 +966,23 @@ export default function ChatPage() {
.catch(() => {
/* The conversation remains usable without profile labels. */
});
reloadConvs();
}, []);
// One list poll supplies runtime state for all sidebar rows, including the
// unselected ones. Await completion so slow requests do not overlap.
React.useEffect(() => {
let disposed = false;
let timer: ReturnType<typeof setTimeout>;
async function poll() {
await reloadConvs();
if (!disposed) timer = setTimeout(() => void poll(), 2000);
}
void poll();
return () => {
disposed = true;
conversationListSeq.current++;
clearTimeout(timer);
};
}, [reloadConvs]);
React.useEffect(() => {
@@ -1023,7 +1060,7 @@ export default function ChatPage() {
next.delete(id);
return next;
});
reloadConvs();
void reloadConvs();
} catch (e) {
toast.error("删除失败:" + (e as Error).message);
}
@@ -1063,10 +1100,10 @@ export default function ChatPage() {
// Fully successful → return to the clean list; keep selection mode on if
// some failed so the user can retry the remaining ones.
if (failed.length === 0) setSelectionMode(false);
reloadConvs();
void reloadConvs();
} catch (error) {
toast.error(`批量删除失败:${(error as Error).message}`);
reloadConvs();
void reloadConvs();
} finally {
setBulkDeleting(false);
}
@@ -1077,7 +1114,7 @@ export default function ChatPage() {
const pinned = conversationIsPinned(conversation);
try {
await api.pinConversation(conversation.id, !pinned);
reloadConvs();
void reloadConvs();
} catch (e) {
toast.error(`${pinned ? "取消置顶" : "置顶"}失败:${(e as Error).message}`);
}
@@ -1096,7 +1133,7 @@ export default function ChatPage() {
if (!title) return;
try {
await api.renameConversation(id, title);
reloadConvs();
void reloadConvs();
} catch (e) {
toast.error("重命名失败:" + (e as Error).message);
}
@@ -1226,7 +1263,7 @@ export default function ChatPage() {
return [...prev.slice(0, insertAt), c, ...prev.slice(insertAt)];
});
setSelectedId(c.id);
reloadConvs();
void reloadConvs();
}}
/>
)}
@@ -9,6 +9,7 @@ import {
ChevronRightIcon,
FileTextIcon,
FlaskConicalIcon,
RotateCcwIcon,
ShieldAlertIcon,
Trash2Icon,
} from "lucide-react";
@@ -32,9 +33,10 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { statusMeta } from "@/lib/status";
import type { Finding, FindingStatus, Severity } from "@/lib/types";
import type { ActiveFindingRetest, Finding, FindingStatus, Severity } from "@/lib/types";
import { cn } from "@/lib/utils";
export const SEVERITIES: Severity[] = ["critical", "high", "medium", "low"];
@@ -44,6 +46,7 @@ export const FINDING_STATUSES: FindingStatus[] = [
"in_progress",
"confirmed",
"resolved",
"fixed",
"false_positive",
"ignored",
"duplicate",
@@ -98,6 +101,8 @@ interface FindingsTableProps {
saving: boolean;
onSave: (finding: Finding) => void;
onStatusChange: (finding: Finding, next: FindingStatus) => void;
onRetest: (finding: Finding) => void;
activeRetests: Record<string, ActiveFindingRetest>;
onDeepen: (finding: Finding) => void;
onDelete: (finding: Finding) => void;
/** 全选框的无障碍标签,平铺视图与分组视图措辞不同。 */
@@ -105,7 +110,7 @@ interface FindingsTableProps {
}
// FindingsTable 是发现列表的表格主体,平铺视图与按任务分组视图共用同一份行渲染
// (勾选 / 行内展开 / 行内改名与改状态 / 深入 / 删除),差异只在外层容器与分页。
// (勾选 / 行内展开 / 行内改名与改状态 / 复测 / 深入 / 删除),差异只在外层容器与分页。
export function FindingsTable({
items,
selectedIds,
@@ -119,6 +124,8 @@ export function FindingsTable({
saving,
onSave,
onStatusChange,
onRetest,
activeRetests,
onDeepen,
onDelete,
selectAllLabel = "选择当前页全部",
@@ -133,9 +140,8 @@ export function FindingsTable({
}
return (
/* table-fixed:列宽由表头锁定,展开行那个 colSpan 单元格再宽也只能在固定宽度内
换行/内部滚动,不会把整张表撑出横向滚动条。 */
<Table className="table-fixed">
/* 固定列宽保证展开内容不撑开表格;窄屏只在表格内部横向滚动。 */
<Table className="min-w-[60rem] table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-8">
@@ -148,17 +154,18 @@ export function FindingsTable({
<TableHead className="w-8" />
<TableHead className="w-20">严重度</TableHead>
<TableHead>漏洞名称</TableHead>
<TableHead className="w-52">资产</TableHead>
<TableHead className="w-44">资产</TableHead>
<TableHead className="w-28">状态</TableHead>
<TableHead className="w-36 max-w-[9rem]">所属任务</TableHead>
<TableHead className="w-32">时间</TableHead>
<TableHead className="w-32 text-right">操作</TableHead>
<TableHead className="w-32">所属任务</TableHead>
<TableHead className="w-24">时间</TableHead>
<TableHead className="w-48">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((f) => {
const rowKey = findingRowKey(f);
const open = expandedKey === rowKey;
const retest = f.finding_id ? activeRetests[f.finding_id] : undefined;
return (
<React.Fragment key={rowKey}>
<TableRow
@@ -207,13 +214,13 @@ export function FindingsTable({
<span className="truncate text-xs text-muted-foreground">{f.summary}</span>
</div>
</TableCell>
<TableCell className="w-52">
<TableCell>
{f.assets && f.assets.length > 0 ? (
<div className="flex flex-wrap gap-1">
{f.assets.slice(0, 3).map((a) => (
<code
key={a.id}
className="max-w-[12rem] truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
className="max-w-full truncate rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
title={`${a.type} · ${a.label}`}
>
{a.label}
@@ -245,7 +252,7 @@ export function FindingsTable({
<StatusBadge domain="finding" value={f.status} dot />
)}
</TableCell>
<TableCell className="w-36 max-w-[9rem]">
<TableCell>
{f.task_id ? (
<Link
href={`/function/tasks/detail?id=${f.task_id}`}
@@ -261,8 +268,22 @@ export function FindingsTable({
)}
</TableCell>
<TableCell className="text-xs text-muted-foreground tabular-nums">{fmtTime(f.ts)}</TableCell>
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-1">
<TableCell onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-1">
{retest ? (
<Button asChild size="sm" variant="ghost">
<Link href={`/chat?c=${retest.conversation_id}`} title="查看正在进行的复测会话">
<Spinner data-icon="inline-start" />
复测中
</Link>
</Button>
) : null}
{!retest && f.finding_id && !f.inherited ? (
<Button size="sm" variant="ghost" onClick={() => onRetest(f)} title="在独立会话中复测该漏洞">
<RotateCcwIcon data-icon="inline-start" />
复测
</Button>
) : null}
{f.finding_id && f.task_id && (
<Button size="sm" variant="ghost" onClick={() => onDeepen(f)}>
<FlaskConicalIcon data-icon="inline-start" />
@@ -22,6 +22,7 @@ import {
import { StatusBadge } from "@/components/status-badge";
import { CopyButton } from "@/components/copy-button";
import { Markdown } from "@/components/markdown";
import { FindingRetestPanel } from "@/components/finding-retest-panel";
import { FindingLineageView } from "./lineage";
import { statusMeta } from "@/lib/status";
import { api } from "@/lib/api";
@@ -33,6 +34,7 @@ const FINDING_STATUSES: FindingStatus[] = [
"in_progress",
"confirmed",
"resolved",
"fixed",
"false_positive",
"ignored",
"duplicate",
@@ -193,6 +195,7 @@ function FindingDetailInner() {
</p>
</CardContent>
</Card>
<FindingRetestPanel key={id} findingId={id} readOnly={finding.inherited} onCompleted={load} />
<Card>
<CardHeader>
<CardTitle className="text-sm">证据 / PoC</CardTitle>
+69 -3
View File
@@ -17,6 +17,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { FindingRetestDialog } from "@/components/finding-retest-dialog";
import { StatusBadge } from "@/components/status-badge";
import { TablePagination } from "@/components/table-pagination";
import { Button } from "@/components/ui/button";
@@ -40,7 +41,15 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { api } from "@/lib/api";
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
import { statusMeta } from "@/lib/status";
import type { Finding, FindingAssetNode, FindingGroup, FindingStats, FindingStatus, Severity } from "@/lib/types";
import type {
ActiveFindingRetest,
Finding,
FindingAssetNode,
FindingGroup,
FindingStats,
FindingStatus,
Severity,
} from "@/lib/types";
import { cn } from "@/lib/utils";
import { AssetTree, assetPathOf } from "./_components/asset-tree";
@@ -145,12 +154,44 @@ export default function FindingsPage() {
const [page, setPage] = React.useState(1);
const [pageSize, setPageSize] = React.useState(10);
const [deepenFinding, setDeepenFinding] = React.useState<Finding | null>(null);
const [retestFinding, setRetestFinding] = React.useState<Finding | null>(null);
const [activeRetests, setActiveRetests] = React.useState<Record<string, ActiveFindingRetest>>({});
const activeRetestFingerprint = Object.values(activeRetests)
.map((item) => item.id)
.join(",");
const retestRefreshVersion = React.useRef(0);
const [deepenDescription, setDeepenDescription] = React.useState("");
const [deepening, setDeepening] = React.useState(false);
const filterFingerprint = JSON.stringify([severity, status, vulnclass, task, sort, query]);
const activeFilterFingerprint = React.useRef(filterFingerprint);
activeFilterFingerprint.current = filterFingerprint;
// 一个轻量请求覆盖所有行/视图,避免逐行拉取完整复测历史;等待上一轮完成再轮询。
React.useEffect(() => {
let disposed = false;
let failed = false;
let timer: ReturnType<typeof setTimeout>;
async function refreshRetests() {
const version = retestRefreshVersion.current;
try {
const rows = await api.activeFindingRetests();
if (disposed || version !== retestRefreshVersion.current) return;
setActiveRetests(Object.fromEntries(rows.map((item) => [item.finding_id, item])));
failed = false;
} catch (error) {
if (!disposed && !failed) toast.error(`加载复测状态失败:${(error as Error).message}`);
failed = true;
} finally {
if (!disposed) timer = setTimeout(() => void refreshRetests(), 3000);
}
}
void refreshRetests();
return () => {
disposed = true;
clearTimeout(timer);
};
}, []);
React.useEffect(() => {
const raw = getLocalStorageValue(FINDING_LIST_PREFERENCE_KEY);
if (raw) {
@@ -482,14 +523,16 @@ export default function FindingsPage() {
// refreshAfterMutation 主动重拉),不做轮询。
React.useEffect(() => {
if (!preferencesHydrated || view !== "asset") return;
void activeRetestFingerprint; // 复测结束可能改变状态筛选下的资产计数。
void loadAssetTree();
}, [loadAssetTree, preferencesHydrated, view]);
}, [activeRetestFingerprint, loadAssetTree, preferencesHydrated, view]);
// 只轮询当前视图:平铺视图刷当前页,分组视图刷组头与每个已展开的组(其分页彼此独立)。
// 资产视图只查一次(见下面的 return),它的左树是导航结构,没必要每 5 秒重算。
// 等偏好水合后再发首个请求,否则会先按默认视图/筛选白拉一次。
React.useEffect(() => {
if (!preferencesHydrated) return;
void activeRetestFingerprint; // 包括不定时轮询的资产视图,也在复测结束后刷新处置状态。
const refresh = () => {
if (view === "flat" || view === "asset") {
if (!flatStateRef.current.loading) void loadFlat();
@@ -506,7 +549,7 @@ export default function FindingsPage() {
if (view === "asset") return;
const timer = setInterval(refresh, 5000);
return () => clearInterval(timer);
}, [loadFlat, loadGroup, preferencesHydrated, refreshGroups, view]);
}, [activeRetestFingerprint, loadFlat, loadGroup, preferencesHydrated, refreshGroups, view]);
React.useEffect(() => {
const lastPage = Math.max(1, Math.ceil(groupTotal / pageSize));
@@ -737,6 +780,8 @@ export default function FindingsPage() {
saving,
onSave: saveEdit,
onStatusChange: updateStatus,
onRetest: setRetestFinding,
activeRetests,
onDeepen: openDeepen,
onDelete: deleteFinding,
};
@@ -1083,6 +1128,27 @@ export default function FindingsPage() {
)}
</div>
{retestFinding?.finding_id ? (
<FindingRetestDialog
key={retestFinding.finding_id}
findingId={retestFinding.finding_id}
findingName={retestFinding.name || retestFinding.vulnclass || retestFinding.summary}
onStarted={(retest) => {
const findingId = retestFinding.finding_id;
if (!findingId || retest.conversation_id == null || !["pending", "running"].includes(retest.status)) return;
retestRefreshVersion.current++;
const active: ActiveFindingRetest = {
id: retest.id,
finding_id: findingId,
conversation_id: retest.conversation_id,
status: retest.status === "pending" ? "pending" : "running",
};
setActiveRetests((current) => ({ ...current, [findingId]: active }));
}}
onClose={() => setRetestFinding(null)}
/>
) : null}
<Dialog
open={deepenFinding !== null}
onOpenChange={(open) => {
@@ -39,6 +39,7 @@ const FINDING_STATUSES: FindingStatus[] = [
"in_progress",
"confirmed",
"resolved",
"fixed",
"false_positive",
"ignored",
"duplicate",
@@ -0,0 +1,165 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowUpRightIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import { FindingRetestPanel } from "@/components/finding-retest-panel";
import { StatusBadge } from "@/components/status-badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import { api } from "@/lib/api";
import type { Finding, FindingsPage } from "@/lib/types";
const PAGE_SIZE = 20;
function findingLabel(finding: Finding) {
return finding.name?.trim() || finding.vulnclass.trim() || "未分类";
}
export function RetestsTab({ taskId }: { taskId: string }) {
const [page, setPage] = React.useState(1);
const [data, setData] = React.useState<FindingsPage | null>(null);
const [selectedId, setSelectedId] = React.useState("");
const [error, setError] = React.useState("");
const [refreshVersion, setRefreshVersion] = React.useState(0);
const refresh = React.useCallback(() => setRefreshVersion((version) => version + 1), []);
// Only this task's own findings, including those without graph nodes.
// Fetch history only for the selection, and wait for each poll to finish.
React.useEffect(() => {
let disposed = false;
let timer: ReturnType<typeof setTimeout>;
async function load() {
try {
const result = await api.findingsPage({ task: taskId, page, pageSize: PAGE_SIZE, sort: "time" });
if (disposed) return;
const lastPage = Math.max(1, Math.ceil(result.total / PAGE_SIZE));
if (page > lastPage) {
setPage(lastPage);
return;
}
setData(result);
setSelectedId((current) =>
result.items.some((finding) => finding.id === current) ? current : (result.items[0]?.id ?? ""),
);
setError("");
} catch (e) {
if (!disposed) setError((e as Error).message);
} finally {
if (!disposed) timer = setTimeout(() => void load(), 3000);
}
}
void refreshVersion; // Refresh triage immediately after a retest completes.
void load();
return () => {
disposed = true;
clearTimeout(timer);
};
}, [taskId, page, refreshVersion]);
const loaded = data?.page === page;
const findings = loaded ? data.items : [];
const selected = findings.find((finding) => finding.id === selectedId);
return (
<div className="flex flex-col gap-4">
{error ? (
<Alert variant="destructive">
<AlertDescription>
加载任务漏洞失败:{error}
<Button variant="outline" size="sm" onClick={refresh}>
重试
</Button>
</AlertDescription>
</Alert>
) : null}
<div className="grid items-start gap-4 lg:grid-cols-[minmax(16rem,22rem)_minmax(0,1fr)]">
<Card className="min-w-0">
<CardHeader>
<CardTitle>选择漏洞{data ? ` · ${data.total}` : ""}</CardTitle>
<CardDescription>查看本任务漏洞的复测记录,或发起新的复测。</CardDescription>
</CardHeader>
<CardContent className="flex max-h-[32rem] flex-col gap-2 overflow-y-auto">
{!loaded && !error ? <Skeleton className="h-24 w-full" /> : null}
{findings.map((finding) => (
<Button
key={finding.id}
variant={finding.id === selectedId ? "secondary" : "ghost"}
className="h-auto w-full shrink-0 flex-col items-start gap-2 whitespace-normal py-3 text-left"
aria-label={`选择漏洞:${findingLabel(finding)}`}
aria-pressed={finding.id === selectedId}
onClick={() => setSelectedId(finding.id)}
>
<span className="line-clamp-2 break-words">{findingLabel(finding)}</span>
<span className="flex flex-wrap items-center gap-2">
<StatusBadge domain="severity" value={finding.severity} dot />
<StatusBadge domain="finding" value={finding.status} dot />
</span>
</Button>
))}
{loaded && findings.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyTitle>暂无可复测漏洞</EmptyTitle>
<EmptyDescription>本任务发现漏洞后,可在这里手动发起复测。</EmptyDescription>
</EmptyHeader>
</Empty>
) : null}
</CardContent>
{data && data.total > PAGE_SIZE ? (
<CardFooter className="justify-between gap-2">
<Button
variant="outline"
size="icon-sm"
aria-label="上一页漏洞"
disabled={page === 1}
onClick={() => setPage(page - 1)}
>
<ChevronLeftIcon />
</Button>
<span className="text-muted-foreground text-xs">
第 {page} / {Math.ceil(data.total / PAGE_SIZE)} 页
</span>
<Button
variant="outline"
size="icon-sm"
aria-label="下一页漏洞"
disabled={page * PAGE_SIZE >= data.total}
onClick={() => setPage(page + 1)}
>
<ChevronRightIcon />
</Button>
</CardFooter>
) : null}
</Card>
{selected ? (
<div className="flex min-w-0 flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-start justify-between gap-2">
<h2 className="min-w-0 flex-1 break-words font-medium">{findingLabel(selected)}</h2>
<Button asChild variant="ghost" size="sm">
<Link href={`/function/findings/detail?id=${selected.finding_id || selected.id}`}>
漏洞详情 <ArrowUpRightIcon data-icon="inline-end" />
</Link>
</Button>
</div>
<p className="line-clamp-3 break-words text-muted-foreground text-sm">{selected.summary}</p>
</div>
<FindingRetestPanel
key={selected.id}
findingId={selected.finding_id || selected.id}
findingName={findingLabel(selected)}
onCompleted={refresh}
/>
</div>
) : null}
</div>
</div>
);
}
@@ -46,6 +46,7 @@ import { GraphTab } from "./_tabs/graph-tab";
import { InterceptTab } from "./_tabs/intercept-tab";
import { OverviewTab } from "./_tabs/overview-tab";
import { ReportTab } from "./_tabs/report-tab";
import { RetestsTab } from "./_tabs/retests-tab";
import { SessionsTab } from "./_tabs/sessions-tab";
const TABS = [
@@ -53,6 +54,7 @@ const TABS = [
{ value: "overview", label: "总览" },
{ value: "graph", label: "探索链路" },
{ value: "findings", label: "发现" },
{ value: "retests", label: "复测" },
{ value: "assets", label: "测试资产" },
{ value: "coverage", label: "资产覆盖图" },
{ value: "intercept", label: "拦截审批" },
@@ -425,6 +427,9 @@ function TaskDetailInner() {
<TabsContent value="findings" className="mt-0">
<FindingsTab taskId={id} />
</TabsContent>
<TabsContent value="retests" className="mt-0">
<RetestsTab key={id} taskId={id} />
</TabsContent>
<TabsContent value="assets" className="mt-0">
<AssetsTab taskId={id} />
</TabsContent>
@@ -0,0 +1,92 @@
"use client";
import * as React from "react";
import { RotateCcwIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Spinner } from "@/components/ui/spinner";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import type { FindingRetest } from "@/lib/types";
interface FindingRetestDialogProps {
findingId: string;
findingName?: string;
onClose: () => void;
onStarted?: (retest: FindingRetest) => void;
}
// 仅在打开时挂载,关闭后清空说明;列表与详情共用提交锁及错误处理,启动后留在当前页。
export function FindingRetestDialog({ findingId, findingName, onClose, onStarted }: FindingRetestDialogProps) {
const notesId = React.useId();
const [notes, setNotes] = React.useState("");
const [submitting, setSubmitting] = React.useState(false);
const submitLock = React.useRef(false);
async function start() {
if (submitLock.current) return;
submitLock.current = true;
setSubmitting(true);
try {
const result = await api.startFindingRetest(findingId, notes.trim());
onStarted?.(result.retest);
onClose();
toast.success(result.created ? "复测已启动,可点击「复测中」查看会话" : "该漏洞正在复测,可查看已有会话");
} catch (e) {
toast.error(`发起复测失败:${(e as Error).message}`);
} finally {
submitLock.current = false;
setSubmitting(false);
}
}
return (
<Dialog open onOpenChange={(open) => !open && !submitLock.current && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>复测漏洞 #{findingId}</DialogTitle>
<DialogDescription className="break-words">
{findingName ? <span className="mb-2 block">{findingName}</span> : null}
复测 Agent
将读取原证据和测试约束,在独立会话中执行针对性验证。复测成功完成且确认修复后,漏洞状态自动改为「已修复」,其他结论保留原状态。
</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field data-disabled={submitting}>
<FieldLabel htmlFor={notesId}>补充说明(可选)</FieldLabel>
<Textarea
id={notesId}
value={notes}
maxLength={4000}
rows={4}
disabled={submitting}
onChange={(e) => setNotes(e.target.value)}
placeholder="例如:使用原测试账号验证原接口;修复版本为 v2。"
/>
<FieldDescription>可补充修复版本、测试条件或本次限制。</FieldDescription>
</Field>
</FieldGroup>
<DialogFooter>
<Button variant="outline" disabled={submitting} onClick={onClose}>
取消
</Button>
<Button disabled={submitting} onClick={() => void start()}>
{submitting ? <Spinner data-icon="inline-start" /> : <RotateCcwIcon data-icon="inline-start" />}
{submitting ? "正在创建…" : "开始复测"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+187
View File
@@ -0,0 +1,187 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { RotateCcwIcon } from "lucide-react";
import { FindingRetestDialog } from "@/components/finding-retest-dialog";
import { Markdown } from "@/components/markdown";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@/components/ui/empty";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
import { api } from "@/lib/api";
import type { FindingRetest } from "@/lib/types";
const statusLabels = {
pending: "等待启动",
running: "复测中",
completed: "已完成",
failed: "复测失败",
stopped: "已停止",
};
const verdictLabels = { reproduced: "仍可复现", fixed: "已修复", inconclusive: "无法确认" };
function active(r: FindingRetest) {
return r.status === "pending" || r.status === "running";
}
export function FindingRetestPanel({
findingId,
findingName,
readOnly = false,
onCompleted,
}: {
findingId: string;
findingName?: string;
readOnly?: boolean;
onCompleted?: () => void;
}) {
const [items, setItems] = React.useState<FindingRetest[] | null>(null);
const [error, setError] = React.useState("");
const [open, setOpen] = React.useState(false);
const requestSeq = React.useRef(0);
const previousItems = React.useRef<FindingRetest[]>([]);
const load = React.useCallback(async () => {
const seq = ++requestSeq.current;
try {
const rows = await api.findingRetests(findingId);
if (seq !== requestSeq.current) return;
const completed = previousItems.current.some(
(item) => active(item) && rows.some((row) => row.id === item.id && !active(row)),
);
previousItems.current = rows;
setItems(rows);
setError("");
if (completed) onCompleted?.();
} catch (e) {
if (seq === requestSeq.current) setError((e as Error).message);
}
}, [findingId, onCompleted]);
React.useEffect(() => {
void load();
return () => {
requestSeq.current++;
};
}, [load]);
React.useEffect(() => {
if (error || !items?.some(active)) return;
const timer = setTimeout(() => void load(), 3000);
return () => clearTimeout(timer);
}, [error, items, load]);
const running = items?.find(active);
return (
<Card>
<CardHeader className="flex flex-row flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-1.5">
<CardTitle>漏洞复测</CardTitle>
<CardDescription>在独立会话中验证当前状态,保留每次复测的结论与证据。</CardDescription>
</div>
{running?.conversation_id != null ? (
<Button asChild variant="outline" size="sm">
<Link href={`/chat?c=${running.conversation_id}`} title="查看正在进行的复测会话">
<Spinner data-icon="inline-start" aria-hidden="true" />
复测中
</Link>
</Button>
) : null}
{!running && !readOnly ? (
<Button size="sm" onClick={() => setOpen(true)} disabled={items === null || !!error}>
<RotateCcwIcon data-icon="inline-start" />
发起复测
</Button>
) : null}
</CardHeader>
<CardContent className="flex flex-col gap-3">
{error ? (
<Alert variant="destructive">
<AlertDescription>
加载复测记录失败:{error}
<Button variant="outline" size="sm" onClick={() => void load()}>
重试
</Button>
</AlertDescription>
</Alert>
) : null}
{!error && items === null ? <Skeleton className="h-16 w-full" /> : null}
{!error && items?.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyTitle>暂无复测记录</EmptyTitle>
<EmptyDescription>修复部署完成后,可发起复测并比较新旧证据。</EmptyDescription>
</EmptyHeader>
</Empty>
) : null}
{!error && items
? items.map((item) => (
<div key={item.id} className="flex min-w-0 flex-col gap-2 rounded-lg border p-3">
<div className="flex flex-wrap items-center gap-2">
<Badge
variant={item.status === "completed" && item.verdict === "reproduced" ? "destructive" : "secondary"}
>
{item.status === "completed" && item.verdict
? verdictLabels[item.verdict]
: statusLabels[item.status]}
</Badge>
<span className="text-muted-foreground text-xs">
#{item.id} · {new Date(item.created_at).toLocaleString("zh-CN")}
</span>
{item.conversation_id != null ? (
<Button asChild variant="ghost" size="sm" className="ml-auto">
<Link href={`/chat?c=${item.conversation_id}`}>查看会话</Link>
</Button>
) : (
<span className="text-muted-foreground text-xs">会话已删除</span>
)}
</div>
{item.status === "completed" && item.summary ? (
<p className="whitespace-pre-wrap break-words text-sm">{item.summary}</p>
) : null}
{item.error ? (
<p className="whitespace-pre-wrap break-words text-destructive text-sm">{item.error}</p>
) : null}
{item.notes ? (
<p className="whitespace-pre-wrap break-words text-muted-foreground text-xs">
补充说明:{item.notes}
</p>
) : null}
{item.status === "completed" && item.evidence ? (
<details className="min-w-0">
<summary className="cursor-pointer text-sm">复测证据</summary>
<div className="mt-3 overflow-x-auto">
<Markdown text={item.evidence} />
</div>
</details>
) : null}
</div>
))
: null}
</CardContent>
{open ? (
<FindingRetestDialog
findingId={findingId}
findingName={findingName}
onClose={() => setOpen(false)}
onStarted={(retest) => {
requestSeq.current++;
const rows = [retest, ...previousItems.current.filter((item) => item.id !== retest.id)];
previousItems.current = rows;
setItems(rows);
setError("");
void load();
}}
/>
) : null}
</Card>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { mergeActivities } from "./activity-merge.ts";
import assert from "node:assert/strict";
import test from "node:test";
const activity = (seq, kind = "tool_use", extra = {}) => ({
seq,
kind,
worker: "auto",
ts: "2026-09-10T07:00:00Z",
summary: String(seq),
...extra,
});
test("overlapping poll responses render tool 198 only once", () => {
const first = [activity(197), activity(198)];
const second = [activity(198), activity(199, "tool_result")];
const merged = mergeActivities(mergeActivities([], first), second);
assert.deepEqual(
merged.map((item) => item.seq),
[197, 198, 199],
);
assert.deepEqual(mergeActivities(merged, second), merged);
});
test("a late response and overlapping older page preserve newer messages", () => {
let rows = mergeActivities([activity(198)], [activity(199), activity(200)]);
rows = mergeActivities(rows, [activity(198), activity(199)]);
rows = mergeActivities([activity(196), activity(197), activity(198)], rows);
assert.deepEqual(
rows.map((item) => item.seq),
[196, 197, 198, 199, 200],
);
});
test("replayed results and duplicate entries do not double-count usage", () => {
const result = activity(201, "result", { input_tokens: 120, output_tokens: 30 });
const rows = mergeActivities([result, result], [result, result]);
assert.equal(rows.length, 1);
assert.equal(
rows.reduce((sum, item) => sum + item.input_tokens, 0),
120,
);
});
test("merging does not mutate either response and keeps distinct IDs", () => {
const first = Object.freeze([Object.freeze(activity(199))]);
const second = Object.freeze([Object.freeze(activity(198)), Object.freeze(activity(200))]);
assert.deepEqual(
mergeActivities(first, second).map((item) => item.seq),
[198, 199, 200],
);
assert.equal(first.length, 1);
assert.equal(second.length, 2);
});
+10
View File
@@ -0,0 +1,10 @@
import type { Activity } from "./types";
// History and live responses can overlap or arrive out of order. A persisted
// activity's seq is its identity; replay must not duplicate rows or token totals.
export function mergeActivities(current: Activity[], incoming: Activity[]): Activity[] {
const bySeq = new Map<number, Activity>();
for (const activity of current) bySeq.set(activity.seq, activity);
for (const activity of incoming) bySeq.set(activity.seq, activity);
return [...bySeq.values()].sort((left, right) => left.seq - right.seq);
}
+8
View File
@@ -6,6 +6,7 @@
import { MOCK } from "@/lib/mock/enabled";
import { mockHandle } from "@/lib/mock/handler";
import type {
ActiveFindingRetest,
Activity,
Agent,
AgentDetail,
@@ -33,6 +34,7 @@ import type {
FindingDeepenResponse,
FindingGroupsPage,
FindingQuery,
FindingRetest,
FindingStats,
FindingStatus,
FindingsPage,
@@ -537,6 +539,12 @@ export const api = {
) => patch<Finding>(`/exploration/findings/${id}`, fields),
// 删除漏洞:移除 findings 记录 + 来源探索节点(从发现列表/任务发现 Tab/探索图一并消失)。
deleteFinding: (id: string) => del<{ deleted: boolean; id: number }>(`/exploration/findings/${id}`),
findingRetests: (id: string) =>
get<{ retests: FindingRetest[] }>(`/exploration/findings/${encodeURIComponent(id)}/retests`).then((r) => arr(r.retests)),
activeFindingRetests: () =>
get<{ retests: ActiveFindingRetest[] }>("/exploration/findings/retests/active").then((r) => arr(r.retests)),
startFindingRetest: (id: string, notes: string) =>
post<{ retest: FindingRetest; created: boolean }>(`/exploration/findings/${encodeURIComponent(id)}/retests`, { notes }),
deepenFinding: (id: string, description: string) =>
post<FindingDeepenResponse>(`/exploration/findings/${id}/deepen`, { description }),
intents: (task?: string) => get<TaskNode[]>(`/exploration/intents${tq(task)}`).then(arr),
+4
View File
@@ -3058,6 +3058,10 @@ export const llmConfig = {
// ── Agents ───────────────────────────────────────────────────────────────────
export const agents: Agent[] = [
{
id: "1001", key: "retester", name: "漏洞复测", role: "assistant", builtin: false, enabled: true,
description: "从漏洞详情手动启动,保存独立复测结论", max_turns: 0, mcp_count: 0, skill_count: 0, tool_count: 2,
},
{
id: "1",
key: "goals",
+73 -5
View File
@@ -16,6 +16,7 @@ import type {
Company,
CompanyScopeRule,
Conversation,
FindingRetest,
IntentAsset,
ScopeRow,
Task,
@@ -39,6 +40,32 @@ const mockLLMRecords = structuredClone(D.llmRecords);
const mockTaskTemplates = structuredClone(D.taskTemplates);
const mockTaskCategories = structuredClone(D.taskCategories);
const mockConversations = structuredClone(D.conversations);
const mockRetests: FindingRetest[] = [];
const mockRetestMessages: Record<number, Activity[]> = {};
function advanceMockRetests() {
for (const retest of mockRetests) {
if (retest.status !== "running" || retest.conversation_id == null) continue;
if (Date.now() - Date.parse(retest.created_at) < 15000) continue;
retest.status = "completed";
retest.verdict = "inconclusive";
retest.summary = "演示环境未执行真实验证,无法确认漏洞当前状态。";
retest.evidence = "### 演示记录\n\n已关联原漏洞。此环境未连接真实 Agent,也未向目标发送请求;请在实际部署中执行复测。";
retest.finished_at = new Date().toISOString();
mockRetestMessages[retest.conversation_id].push({
seq: 3, worker: "retester", ts: retest.finished_at, kind: "text", summary: retest.summary, detail: retest.evidence,
});
}
}
function stopMockRetest(conversationID: number) {
for (const retest of mockRetests) {
if (retest.conversation_id !== conversationID || !["pending", "running"].includes(retest.status)) continue;
retest.status = "stopped";
retest.finished_at = new Date().toISOString();
retest.error = "演示复测已停止";
}
}
const mockIntents = structuredClone(D.intents);
const mockCompanies = structuredClone(D.companies);
const mockAssets = structuredClone(D.assets);
@@ -837,6 +864,7 @@ function parseBody(body?: BodyInit | null): Record<string, unknown> {
}
export async function mockHandle<T>(method: string, rawPath: string, body?: BodyInit | null): Promise<T> {
advanceMockRetests();
await delay();
const [path, qs] = rawPath.split("?");
const q = new URLSearchParams(qs ?? "");
@@ -1700,6 +1728,35 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
page_size: pageSize,
};
}
if (path === "/exploration/findings/retests/active" && m === "GET") {
return { retests: mockRetests
.filter((item) => ["pending", "running"].includes(item.status) && item.conversation_id != null)
.map((item) => ({ id: item.id, finding_id: D.findings[item.finding_id - 1].id, conversation_id: item.conversation_id, status: item.status })) };
}
if (seg[0] === "exploration" && seg[1] === "findings" && seg[3] === "retests") {
const finding = mockFindings.find((item) => item.id === seg[2]);
if (!finding) throw new Error("漏洞不存在");
const findingID = D.findings.findIndex((item) => item.id === finding.id) + 1;
if (m === "GET") return { retests: structuredClone(mockRetests.filter((item) => item.finding_id === findingID)) };
if (m === "POST") {
const existing = mockRetests.find((item) => item.finding_id === findingID && ["pending", "running"].includes(item.status));
if (existing) return { retest: structuredClone(existing), created: false };
const now = new Date().toISOString();
const conversationID = mockConversations.reduce((max, item) => Math.max(max, item.id), 0) + 1;
mockConversations.unshift({ id: conversationID, agent_key: "retester", title: `复测 #${finding.id} · ${finding.name || finding.vulnclass}`, pinned: false, created_at: now, updated_at: now });
const retest: FindingRetest = {
id: mockRetests.length + 1, finding_id: findingID, conversation_id: conversationID,
status: "running", verdict: "", notes: String(b.notes ?? ""), summary: "", evidence: "",
error: "", created_at: now, started_at: now, finished_at: null,
};
mockRetests.unshift(retest);
mockRetestMessages[conversationID] = [
{ seq: 1, worker: "retester", ts: now, kind: "user", summary: `请复测漏洞 #${finding.id}`, detail: retest.notes },
{ seq: 2, worker: "retester", ts: now, kind: "text", summary: "演示复测进行中(未向目标发送请求)", detail: "演示复测进行中(未向目标发送请求)" },
];
return { retest: structuredClone(retest), created: true };
}
}
if (seg[0] === "exploration" && seg[1] === "findings" && seg[3] === "deepen" && m === "POST") {
const finding = mockFindings.find((candidate) => candidate.id === seg[2]);
const description = String(b.description ?? "").trim();
@@ -1887,7 +1944,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
// ── conversations ──
if (path === "/conversations" && m === "GET") {
sortMockConversations();
return { conversations: structuredClone(mockConversations) };
return { conversations: structuredClone(mockConversations.map((conversation) => ({
...conversation,
running: mockRetests.some((item) => item.conversation_id === conversation.id && ["pending", "running"].includes(item.status)),
}))) };
}
if (path === "/conversations" && m === "POST") {
const now = new Date().toISOString();
@@ -1918,8 +1978,10 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
}
if (seg[0] === "conversations" && seg.length === 2 && m === "DELETE") {
const id = Number(seg[1]);
stopMockRetest(id);
const index = mockConversations.findIndex((item) => item.id === id);
if (index >= 0) mockConversations.splice(index, 1);
for (const retest of mockRetests) if (retest.conversation_id === id) retest.conversation_id = null;
return { deleted: id };
}
if (path === "/conversations/delete/batch" && m === "POST") {
@@ -1930,21 +1992,27 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
const index = mockConversations.findIndex((item) => item.id === id);
if (index < 0) return { id, ok: false, error: "conversation not found" };
mockConversations.splice(index, 1);
stopMockRetest(id);
for (const retest of mockRetests) if (retest.conversation_id === id) retest.conversation_id = null;
return { id, ok: true };
});
return { items };
}
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 3 && m === "GET") {
const items = D.conversationMessages[Number(seg[1])] ?? [];
return { items, cursor: items.length ? items[items.length - 1].seq : 0, running: false };
const items = mockRetestMessages[Number(seg[1])] ?? D.conversationMessages[Number(seg[1])] ?? [];
const running = mockRetests.some((item) => item.conversation_id === Number(seg[1]) && ["pending", "running"].includes(item.status));
return { items, cursor: items.length ? items[items.length - 1].seq : 0, running };
}
if (seg[0] === "conversations" && seg[2] === "messages" && seg.length === 4) {
const msgs = D.conversationMessages[Number(seg[1])] ?? [];
const msgs = mockRetestMessages[Number(seg[1])] ?? D.conversationMessages[Number(seg[1])] ?? [];
const a = msgs.find((x) => x.seq === Number(seg[3]));
return { detail: a?.detail ?? a?.summary ?? "" };
}
if (seg[0] === "conversations" && seg[2] === "messages" && m === "POST") return { status: "ok" };
if (seg[0] === "conversations" && seg[2] === "stop") return { status: "stopped" };
if (seg[0] === "conversations" && seg[2] === "stop") {
stopMockRetest(Number(seg[1]));
return { status: "stopped" };
}
// ── tools ──
if (path === "/tools" && m === "GET") return { tools: D.tools };
+1
View File
@@ -79,6 +79,7 @@ const finding: Record<string, StatusMeta> = {
in_progress: { label: "处理中", tone: "blue" },
confirmed: { label: "已确认", tone: "red" },
resolved: { label: "已处理", tone: "green" },
fixed: { label: "已修复", tone: "green" },
false_positive: { label: "误报", tone: "slate" },
ignored: { label: "忽略", tone: "neutral" },
duplicate: { label: "重复", tone: "neutral" },
+25 -1
View File
@@ -443,12 +443,13 @@ export interface TaskConstraint {
// ---- Findings ----
export type Severity = "critical" | "high" | "medium" | "low";
// 漏洞处置状态:待处理 / 处理中 / 已确认 / 已处理 / 误报 / 忽略 / 重复 / 风险接受。
// 漏洞处置状态:待处理 / 处理中 / 已确认 / 已处理 / 已修复 / 误报 / 忽略 / 重复 / 风险接受。
export type FindingStatus =
| "pending"
| "in_progress"
| "confirmed"
| "resolved"
| "fixed"
| "false_positive"
| "ignored"
| "duplicate"
@@ -681,8 +682,31 @@ export interface AgentTrigger {
}
// ---- Conversations (chat page) ----
export interface ActiveFindingRetest {
id: number;
finding_id: string;
conversation_id: number;
status: "pending" | "running";
}
export interface FindingRetest {
id: number;
finding_id: number;
conversation_id: number | null;
status: "pending" | "running" | "completed" | "failed" | "stopped";
verdict: "" | "reproduced" | "fixed" | "inconclusive";
notes: string;
summary: string;
evidence: string;
error: string;
created_at: string;
started_at: string | null;
finished_at: string | null;
}
export interface Conversation {
id: number;
running?: boolean; // live server state, returned with the conversation list
agent_key: string;
title: string;
llm_profile_id?: number;