Author SHA1 Message Date
RuoJi6 05b72683aa fix: finding agent chat drawer races scroll and mock 2026-10-27 19:42:19 +08:00
RuoJi6 69eab0c033 fix: finding agent chat tests and IME enter
Test: (cd web && node --test src/lib/finding-agent-chat.test.cjs)
Build: ARTEX_SKIP_NPM_CI=1 ./build.sh
2026-10-27 19:09:52 +08:00
RuoJi6 8f9d981bdc feat: add finding detail agent chat drawer
PR-B: 漏洞详情页「问 Agent」右侧 Sheet,走所属任务主 Agent(api.chat/stopChat/SSE)。
2026-10-27 18:51:50 +08:00
RuoJi6 a94b67ffd6 Merge pull request #63 from RuoJi6/codex/merge-pr56-security-fixes
security: harden egress probe from PR56
2026-10-27 01:35:48 -07:00
4 changed files with 536 additions and 0 deletions

No files matched your search

@@ -9,6 +9,7 @@ import { ArrowLeftIcon, ArrowUpRightIcon, ShieldAlertIcon } from "lucide-react";
import { toast } from "sonner";
import { CopyButton } from "@/components/copy-button";
import { FindingAgentChatDrawer } from "@/components/finding-agent-chat-drawer";
import { FindingRetestPanel } from "@/components/finding-retest-panel";
import { FindingTrafficPanel } from "@/components/finding-traffic-panel";
import { Markdown } from "@/components/markdown";
@@ -160,6 +161,7 @@ function FindingDetailInner() {
{finding.inherited && finding.source_task_id && (
<Badge variant="outline">来源任务 #{finding.source_task_id} · 只读</Badge>
)}
<FindingAgentChatDrawer finding={finding} />
</div>
<TabsList>
<TabsTrigger value="overview">概览</TabsTrigger>
@@ -0,0 +1,431 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowUpIcon, Loader2Icon, MessageCircleQuestionIcon, SquareIcon } from "lucide-react";
import { toast } from "sonner";
import { Transcript } from "@/components/transcript";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Textarea } from "@/components/ui/textarea";
import { api, openSSE } from "@/lib/api";
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
import { MOCK } from "@/lib/mock/enabled";
import type { Activity, Finding } from "@/lib/types";
import {
buildFindingAgentContextMessage,
findingAgentUnavailableReason,
findingDisplayTitle,
} from "./finding-agent-chat";
const MAIN_HISTORY_LIMIT = 200;
type FindingAgentChatDrawerProps = {
finding: Finding;
};
function mainActivity(activity: Activity, segment: number): boolean {
if (activity.worker !== "mainagent") return false;
return activity.main_seg == null || activity.main_seg === segment;
}
function mergeActivities(current: Activity[], incoming: Activity[], segment: number): Activity[] {
const bySeq = new Map<number, Activity>();
for (const item of current) bySeq.set(item.seq, item);
for (const item of incoming) {
if (mainActivity(item, segment)) bySeq.set(item.seq, item);
}
return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
}
function LoadingActivity() {
return (
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2Icon className="size-4 animate-spin" />
正在加载主 Agent 对话…
</div>
);
}
function EmptyActivity({ available }: { available: boolean }) {
return (
<p className="text-muted-foreground text-sm">
{available ? "还没有对话。发送问题后,主 Agent 的回复会显示在这里。" : "暂无可显示的活动。"}
</p>
);
}
export function FindingAgentChatDrawer({ finding }: FindingAgentChatDrawerProps) {
const taskId = finding.task_id ?? "";
const [open, setOpen] = React.useState(false);
const [activities, setActivities] = React.useState<Activity[]>([]);
const [input, setInput] = React.useState("");
const [loading, setLoading] = React.useState(false);
const [sending, setSending] = React.useState(false);
const [running, setRunning] = React.useState(false);
const [stopping, setStopping] = React.useState(false);
const sendMode = useChatSendMode();
const [taskAvailable, setTaskAvailable] = React.useState<boolean | null>(null);
const [unavailableReason, setUnavailableReason] = React.useState("");
const eventSourceRef = React.useRef<EventSource | null>(null);
const snapshotRef = React.useRef(0);
const aliveRef = React.useRef(false);
const requestGenerationRef = React.useRef(0);
const sentContextRef = React.useRef(false);
const currentSegmentRef = React.useRef(0);
const contentRef = React.useRef<HTMLDivElement | null>(null);
const atBottomRef = React.useRef(true);
const findingIdRef = React.useRef(finding.id);
findingIdRef.current = finding.id;
const findingId = finding.id;
const inherited = Boolean(finding.inherited);
const canAsk = Boolean(taskId && !inherited);
const closeStream = React.useCallback(() => {
eventSourceRef.current?.close();
eventSourceRef.current = null;
}, []);
const refreshStatus = React.useCallback(
(generation = requestGenerationRef.current) => {
if (!taskId || !canAsk) return Promise.resolve();
return api
.chatStatus(taskId)
.then(({ running: nextRunning }) => {
if (aliveRef.current && requestGenerationRef.current === generation) setRunning(nextRunning);
})
.catch(() => undefined);
},
[canAsk, taskId],
);
React.useEffect(() => {
const generation = ++requestGenerationRef.current;
const isCurrent = () =>
aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId;
closeStream();
aliveRef.current = open;
if (!open) return;
sentContextRef.current = false;
setInput("");
setActivities([]);
setTaskAvailable(null);
setUnavailableReason(findingAgentUnavailableReason({ task_id: taskId, inherited }));
setRunning(false);
setLoading(false);
setSending(false);
setStopping(false);
snapshotRef.current = 0;
currentSegmentRef.current = 0;
atBottomRef.current = true;
if (!canAsk) {
return () => {
aliveRef.current = false;
requestGenerationRef.current += 1;
closeStream();
};
}
setLoading(true);
if (MOCK) {
api
.activity(taskId)
.then(({ items, cursor }) => {
if (!isCurrent()) return;
setTaskAvailable(true);
setActivities(mergeActivities([], items, currentSegmentRef.current));
snapshotRef.current = cursor;
})
.catch(() => {
if (!isCurrent()) return;
setTaskAvailable(false);
setUnavailableReason("任务不存在或已删除,无法连接到主 Agent。");
})
.finally(() => {
if (isCurrent()) setLoading(false);
});
} else {
api
.task(taskId)
.then(() => api.mainSessions(taskId))
.then((sessions) => {
if (!isCurrent()) return null;
const currentSegment = sessions.current ?? 0;
currentSegmentRef.current = currentSegment;
return api.activityHistory(taskId, `main:${currentSegment}`, 0, MAIN_HISTORY_LIMIT).then((history) => ({
currentSegment,
history,
}));
})
.then((result) => {
if (!result || !isCurrent()) return;
const { currentSegment, history } = result;
setTaskAvailable(true);
setActivities((current) => mergeActivities(current, history.items, currentSegment));
snapshotRef.current = history.snapshotCursor;
closeStream();
const stream = openSSE(
`/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=${snapshotRef.current}`,
);
if (!isCurrent()) {
stream.close();
return;
}
eventSourceRef.current = stream;
stream.onmessage = (event) => {
if (!isCurrent()) return;
try {
const activity = JSON.parse(event.data) as Activity;
setActivities((current) => mergeActivities(current, [activity], currentSegment));
} catch {
// Ignore malformed frames; the next history refresh still repairs the view.
}
};
stream.onerror = () => undefined; // EventSource reconnects automatically.
})
.catch(() => {
if (!isCurrent()) return;
setTaskAvailable(false);
setUnavailableReason("任务不存在或已删除,无法连接到主 Agent。");
})
.finally(() => {
if (isCurrent()) setLoading(false);
});
}
void refreshStatus(generation);
const statusTimer = window.setInterval(() => void refreshStatus(generation), 2000);
return () => {
aliveRef.current = false;
requestGenerationRef.current += 1;
window.clearInterval(statusTimer);
closeStream();
};
}, [canAsk, closeStream, findingId, inherited, open, refreshStatus, taskId]);
const viewport = React.useCallback(
() => (contentRef.current?.closest('[data-slot="scroll-area-viewport"]') as HTMLElement | null) ?? null,
[],
);
React.useEffect(() => {
if (!open) return;
const vp = viewport();
if (!vp) return;
const onScroll = () => {
atBottomRef.current = vp.scrollTop + vp.clientHeight >= vp.scrollHeight - 60;
};
vp.addEventListener("scroll", onScroll, { passive: true });
onScroll();
return () => vp.removeEventListener("scroll", onScroll);
}, [open, viewport]);
React.useLayoutEffect(() => {
if (!open) return;
const vp = viewport();
if (vp) {
vp.scrollTop = vp.scrollHeight;
atBottomRef.current = true;
}
}, [open, viewport]);
// biome-ignore lint/correctness/useExhaustiveDependencies: activity growth intentionally drives live-edge scrolling.
React.useLayoutEffect(() => {
if (!open || !atBottomRef.current) return;
const vp = viewport();
if (vp) vp.scrollTop = vp.scrollHeight;
}, [activities, open, running, viewport]);
React.useEffect(() => {
if (!open || typeof ResizeObserver === "undefined") return;
const el = contentRef.current;
if (!el) return;
const ro = new ResizeObserver(() => {
if (!atBottomRef.current) return;
const vp = viewport();
if (vp) vp.scrollTop = vp.scrollHeight;
});
ro.observe(el);
return () => ro.disconnect();
}, [open, viewport]);
const send = React.useCallback(async () => {
const message = input.trim();
if (!message || sending || running || !taskId || taskAvailable !== true || !canAsk) return;
const generation = requestGenerationRef.current;
setSending(true);
const payload = sentContextRef.current
? message
: `${buildFindingAgentContextMessage(finding)}\n\n用户问题:${message}`;
try {
const result = await api.chat(payload, taskId);
if (!aliveRef.current || requestGenerationRef.current !== generation || findingIdRef.current !== findingId)
return;
if (MOCK) {
const ts = new Date().toISOString();
const reply = result.reply?.trim() || "(demo)主 Agent 暂无回复。";
setActivities((current) =>
mergeActivities(
current,
[
{
seq: current.reduce((max, item) => Math.max(max, item.seq), 0) + 1,
worker: "mainagent",
ts,
kind: "user",
summary: message,
main_seg: currentSegmentRef.current,
},
{
seq: current.reduce((max, item) => Math.max(max, item.seq), 0) + 2,
worker: "mainagent",
ts,
kind: "text",
summary: reply,
main_seg: currentSegmentRef.current,
},
],
currentSegmentRef.current,
),
);
}
sentContextRef.current = true;
setInput("");
if (!MOCK) void refreshStatus(generation);
} catch (error) {
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
toast.error(`发送失败:${(error as Error).message || "请稍后重试"}`);
}
} finally {
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
setSending(false);
}
}
}, [canAsk, finding, findingId, input, refreshStatus, running, sending, taskAvailable, taskId]);
const stop = React.useCallback(async () => {
if (!taskId || stopping) return;
const generation = requestGenerationRef.current;
setStopping(true);
try {
await api.stopChat(taskId);
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
await refreshStatus(generation);
}
} catch (error) {
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
toast.error(`停止失败:${(error as Error).message || "请稍后重试"}`);
}
} finally {
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
setStopping(false);
}
}
}, [findingId, refreshStatus, stopping, taskId]);
const title = findingDisplayTitle(finding);
const canSend = canAsk && taskAvailable === true && !running && !sending && Boolean(input.trim());
let footerStatus = "Enter 发送,Shift+Enter 换行。";
if (sending) footerStatus = "正在发送…";
else if (running) footerStatus = "主 Agent 运行中,可停止当前执行。";
return (
<Sheet open={open} onOpenChange={setOpen}>
<Button variant="outline" size="sm" onClick={() => setOpen(true)} aria-label="问 Agent">
<MessageCircleQuestionIcon />问 Agent
</Button>
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-xl">
<SheetHeader className="border-b pr-12">
<SheetTitle className="truncate">问 Agent · {title}</SheetTitle>
<SheetDescription className="flex min-w-0 items-center gap-1">
所属任务:
{taskId ? (
<Link className="truncate text-primary hover:underline" href={`/function/tasks/detail?id=${taskId}`}>
{finding.task_description ?? `#${taskId}`}
</Link>
) : (
<span>无</span>
)}
</SheetDescription>
</SheetHeader>
{unavailableReason ? (
<Alert variant="destructive" className="m-4 mb-0">
<AlertDescription>{unavailableReason}</AlertDescription>
</Alert>
) : null}
<ScrollArea className="min-h-0 flex-1">
<div ref={contentRef} className="min-h-full p-4">
{loading ? <LoadingActivity /> : null}
{!loading && activities.length ? (
<Transcript
activity={activities}
live={running}
taskId={taskId}
chat
fetchDetail={(seq) => api.activityDetail(seq, taskId).then((r) => r.detail)}
/>
) : null}
{!loading && !activities.length ? <EmptyActivity available={taskAvailable === true} /> : null}
</div>
</ScrollArea>
<SheetFooter className="border-t bg-background p-3">
<div className="flex w-full items-end gap-2">
<Textarea
value={input}
onChange={(event) => setInput(event.target.value)}
onKeyDown={(event) => {
if (!shouldSubmitOnKey(event, sendMode)) return;
event.preventDefault();
void send();
}}
disabled={!canAsk || taskAvailable !== true || sending || running}
placeholder={running ? "主 Agent 正在运行…" : "描述复现现象或继续提问…"}
aria-label="向漏洞所属任务的主 Agent 提问"
className="max-h-32 min-h-10 resize-none"
/>
{running ? (
<Button
type="button"
variant="destructive"
size="icon"
onClick={() => void stop()}
disabled={stopping}
title="停止当前执行"
aria-label="停止当前执行"
>
{stopping ? <Loader2Icon className="animate-spin" /> : <SquareIcon />}
</Button>
) : (
<Button
type="button"
size="icon"
onClick={() => void send()}
disabled={!canSend}
title="发送消息"
aria-label="发送消息"
>
{sending ? <Loader2Icon className="animate-spin" /> : <ArrowUpIcon />}
</Button>
)}
</div>
<p className="w-full text-[11px] text-muted-foreground">{footerStatus}</p>
</SheetFooter>
</SheetContent>
</Sheet>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { mentionToken } from "../lib/chat-mentions";
import type { Finding } from "../lib/types";
type FindingContextFields = Finding & {
url?: string;
endpoint?: string;
entry_point?: string;
};
export function canAskFindingAgent(finding: Pick<Finding, "task_id" | "inherited"> | null | undefined): boolean {
return Boolean(finding?.task_id && !finding.inherited);
}
export function findingDisplayTitle(finding: Pick<Finding, "id" | "name" | "vulnclass">): string {
if (finding.name?.trim()) return finding.name;
if (finding.vulnclass?.trim()) return finding.vulnclass;
return `漏洞 #${finding.id}`;
}
export function findingAgentUnavailableReason(
finding: Pick<Finding, "task_id" | "inherited"> | null | undefined,
): string {
if (!finding?.task_id) return "无所属任务,无法连接到主 Agent。";
if (finding.inherited) return "继承漏洞只读,请到源任务提问。";
return "";
}
function truncate(value: string, max: number): string {
const compact = value.replace(/\s+/g, " ").trim();
return compact.length > max ? `${compact.slice(0, max)}…` : compact;
}
/**
* The finding mention is included once, in the first turn, so later user turns
* stay short while the task's main Agent still has an unambiguous finding anchor.
*/
export function buildFindingAgentContextMessage(finding: Finding): string {
const extended = finding as FindingContextFields;
const numericId = Number(finding.id);
const mention = mentionToken({
kind: "finding",
id: Number.isFinite(numericId) ? numericId : 0,
label: truncate(findingDisplayTitle(finding), 100),
description: truncate(finding.summary || "", 200),
});
const lines = [
"用户正在漏洞详情页复现并提问。",
mention,
`严重等级:${finding.severity};漏洞类型:${finding.vulnclass || "未分类"}。`,
];
const summary = truncate(finding.summary || "", 300);
if (summary) lines.push(`摘要:${summary}`);
const entryPoint = extended.url || extended.entry_point || extended.endpoint;
if (entryPoint) lines.push(`入口点:${truncate(entryPoint, 240)}`);
return lines.join("\n");
}
+47
View File
@@ -0,0 +1,47 @@
const assert = require("node:assert/strict");
const { createRequire } = require("node:module");
const test = require("node:test");
// Keep the tests runnable on Node 20, which does not load TypeScript natively.
const requireFromTest = createRequire(__filename);
requireFromTest("ts-node").register({
transpileOnly: true,
compilerOptions: { module: "CommonJS", moduleResolution: "Node10" },
});
const {
buildFindingAgentContextMessage,
canAskFindingAgent,
findingAgentUnavailableReason,
} = requireFromTest("../components/finding-agent-chat.ts");
const finding = (overrides = {}) => ({
id: "42",
vulnclass: "SQL 注入",
name: "搜索参数注入",
severity: "high",
status: "confirmed",
summary: "搜索参数可控制数据库查询。",
evidence: "large evidence omitted",
ts: "2026-10-27T00:00:00Z",
task_id: "task-7",
...overrides,
});
test("finding without task cannot ask its Agent", () => {
assert.equal(canAskFindingAgent(finding({ task_id: undefined })), false);
assert.match(findingAgentUnavailableReason(finding({ task_id: undefined })), /无所属任务/);
});
test("task-owned finding can ask and context contains a finding mention", () => {
assert.equal(canAskFindingAgent(finding()), true);
const context = buildFindingAgentContextMessage(finding());
assert.match(context, /用户正在漏洞详情页复现并提问/);
assert.match(context, /@\[漏洞#42 搜索参数注入\]/);
assert.match(context, /SQL 注入/);
});
test("inherited finding is read-only", () => {
assert.equal(canAskFindingAgent(finding({ inherited: true, source_task_id: "source-1" })), false);
assert.match(findingAgentUnavailableReason(finding({ inherited: true })), /继承漏洞只读/);
});