From 8f9d981bdc1e40b86e6513a0656ac0557b5cdca8 Mon Sep 17 00:00:00 2001 From: RuoJi6 Date: Tue, 27 Oct 2026 18:51:50 +0800 Subject: [PATCH 1/3] feat: add finding detail agent chat drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-B: 漏洞详情页「问 Agent」右侧 Sheet,走所属任务主 Agent(api.chat/stopChat/SSE)。 --- .../(main)/function/findings/detail/page.tsx | 2 + .../components/finding-agent-chat-drawer.tsx | 293 ++++++++++++++++++ web/src/components/finding-agent-chat.ts | 56 ++++ web/src/lib/finding-agent-chat.test.cjs | 38 +++ 4 files changed, 389 insertions(+) create mode 100644 web/src/components/finding-agent-chat-drawer.tsx create mode 100644 web/src/components/finding-agent-chat.ts create mode 100644 web/src/lib/finding-agent-chat.test.cjs diff --git a/web/src/app/(main)/function/findings/detail/page.tsx b/web/src/app/(main)/function/findings/detail/page.tsx index d4a640a..fdc4ab7 100644 --- a/web/src/app/(main)/function/findings/detail/page.tsx +++ b/web/src/app/(main)/function/findings/detail/page.tsx @@ -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 && ( 来源任务 #{finding.source_task_id} · 只读 )} + 概览 diff --git a/web/src/components/finding-agent-chat-drawer.tsx b/web/src/components/finding-agent-chat-drawer.tsx new file mode 100644 index 0000000..5898c87 --- /dev/null +++ b/web/src/components/finding-agent-chat-drawer.tsx @@ -0,0 +1,293 @@ +"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 type { Activity, Finding } from "@/lib/types"; + +import { + buildFindingAgentContextMessage, + canAskFindingAgent, + 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(); + 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 ( +
+ + 正在加载主 Agent 对话… +
+ ); +} + +function EmptyActivity({ available }: { available: boolean }) { + return ( +

+ {available ? "还没有对话。发送问题后,主 Agent 的回复会显示在这里。" : "暂无可显示的活动。"} +

+ ); +} + +export function FindingAgentChatDrawer({ finding }: FindingAgentChatDrawerProps) { + const taskId = finding.task_id ?? ""; + const [open, setOpen] = React.useState(false); + const [activities, setActivities] = React.useState([]); + 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 [taskAvailable, setTaskAvailable] = React.useState(null); + const [unavailableReason, setUnavailableReason] = React.useState(""); + const eventSourceRef = React.useRef(null); + const snapshotRef = React.useRef(0); + const aliveRef = React.useRef(false); + const sentContextRef = React.useRef(false); + + const closeStream = React.useCallback(() => { + eventSourceRef.current?.close(); + eventSourceRef.current = null; + }, []); + + const refreshStatus = React.useCallback(() => { + if (!taskId || !canAskFindingAgent(finding)) return Promise.resolve(); + return api + .chatStatus(taskId) + .then(({ running: nextRunning }) => setRunning(nextRunning)) + .catch(() => undefined); + }, [finding, taskId]); + + React.useEffect(() => { + if (!open) { + aliveRef.current = false; + closeStream(); + return; + } + + aliveRef.current = true; + sentContextRef.current = false; + setInput(""); + setActivities([]); + setTaskAvailable(null); + setUnavailableReason(findingAgentUnavailableReason(finding)); + setRunning(false); + setLoading(false); + snapshotRef.current = 0; + + if (!canAskFindingAgent(finding)) + return () => { + aliveRef.current = false; + closeStream(); + }; + + setLoading(true); + api + .task(taskId) + .then(() => api.mainSessions(taskId)) + .then((sessions) => { + if (!aliveRef.current) return null; + const currentSegment = sessions.current ?? 0; + return api.activityHistory(taskId, `main:${currentSegment}`, 0, MAIN_HISTORY_LIMIT).then((history) => ({ + currentSegment, + history, + })); + }) + .then((result) => { + if (!result || !aliveRef.current) return; + const { currentSegment, history } = result; + setTaskAvailable(true); + setActivities((current) => mergeActivities(current, history.items, currentSegment)); + snapshotRef.current = history.snapshotCursor; + const stream = openSSE( + `/api/exploration/activity/stream?task=${encodeURIComponent(taskId)}&since=${snapshotRef.current}`, + ); + eventSourceRef.current = stream; + stream.onmessage = (event) => { + if (!aliveRef.current) 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 (!aliveRef.current) return; + setTaskAvailable(false); + setUnavailableReason("任务不存在或已删除,无法连接到主 Agent。"); + }) + .finally(() => { + if (aliveRef.current) setLoading(false); + }); + + void refreshStatus(); + const statusTimer = window.setInterval(() => void refreshStatus(), 2000); + return () => { + aliveRef.current = false; + window.clearInterval(statusTimer); + closeStream(); + }; + }, [closeStream, finding, open, refreshStatus, taskId]); + + const send = React.useCallback(async () => { + const message = input.trim(); + if (!message || sending || running || !taskId || taskAvailable !== true || !canAskFindingAgent(finding)) return; + setSending(true); + const payload = sentContextRef.current + ? message + : `${buildFindingAgentContextMessage(finding)}\n\n用户问题:${message}`; + try { + await api.chat(payload, taskId); + sentContextRef.current = true; + setInput(""); + void refreshStatus(); + } catch (error) { + toast.error(`发送失败:${(error as Error).message || "请稍后重试"}`); + } finally { + setSending(false); + } + }, [finding, input, refreshStatus, running, sending, taskAvailable, taskId]); + + const stop = React.useCallback(async () => { + if (!taskId || stopping) return; + setStopping(true); + try { + await api.stopChat(taskId); + await refreshStatus(); + } catch (error) { + toast.error(`停止失败:${(error as Error).message || "请稍后重试"}`); + } finally { + setStopping(false); + } + }, [refreshStatus, stopping, taskId]); + + const title = findingDisplayTitle(finding); + const canSend = + canAskFindingAgent(finding) && taskAvailable === true && !running && !sending && Boolean(input.trim()); + let footerStatus = "Enter 发送,Shift+Enter 换行。"; + if (sending) footerStatus = "正在发送…"; + else if (running) footerStatus = "主 Agent 运行中,可停止当前执行。"; + + return ( + + + + + 问 Agent · {title} + + 所属任务: + {taskId ? ( + + {finding.task_description ?? `#${taskId}`} + + ) : ( + 无 + )} + + + + {unavailableReason ? ( + + {unavailableReason} + + ) : null} + + +
+ {loading ? : null} + {!loading && activities.length ? ( + api.activityDetail(seq, taskId).then((r) => r.detail)} + /> + ) : null} + {!loading && !activities.length ? : null} +
+
+ + +
+