mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 09:13:49 +08:00
fix: finding agent chat drawer races scroll and mock
This commit is contained in:
1 parent
69eab0c033
commit
05b72683aa
1 file changed
+210
-73
@@ -15,11 +15,11 @@ import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetT
|
||||
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,
|
||||
canAskFindingAgent,
|
||||
findingAgentUnavailableReason,
|
||||
findingDisplayTitle,
|
||||
} from "./finding-agent-chat";
|
||||
@@ -76,130 +76,267 @@ export function FindingAgentChatDrawer({ finding }: FindingAgentChatDrawerProps)
|
||||
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(() => {
|
||||
if (!taskId || !canAskFindingAgent(finding)) return Promise.resolve();
|
||||
return api
|
||||
.chatStatus(taskId)
|
||||
.then(({ running: nextRunning }) => setRunning(nextRunning))
|
||||
.catch(() => undefined);
|
||||
}, [finding, taskId]);
|
||||
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(() => {
|
||||
if (!open) {
|
||||
aliveRef.current = false;
|
||||
closeStream();
|
||||
return;
|
||||
}
|
||||
const generation = ++requestGenerationRef.current;
|
||||
const isCurrent = () =>
|
||||
aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId;
|
||||
|
||||
closeStream();
|
||||
aliveRef.current = open;
|
||||
if (!open) return;
|
||||
|
||||
aliveRef.current = true;
|
||||
sentContextRef.current = false;
|
||||
setInput("");
|
||||
setActivities([]);
|
||||
setTaskAvailable(null);
|
||||
setUnavailableReason(findingAgentUnavailableReason(finding));
|
||||
setUnavailableReason(findingAgentUnavailableReason({ task_id: taskId, inherited }));
|
||||
setRunning(false);
|
||||
setLoading(false);
|
||||
setSending(false);
|
||||
setStopping(false);
|
||||
snapshotRef.current = 0;
|
||||
currentSegmentRef.current = 0;
|
||||
atBottomRef.current = true;
|
||||
|
||||
if (!canAskFindingAgent(finding))
|
||||
if (!canAsk) {
|
||||
return () => {
|
||||
aliveRef.current = false;
|
||||
requestGenerationRef.current += 1;
|
||||
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);
|
||||
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();
|
||||
};
|
||||
}, [closeStream, finding, open, refreshStatus, taskId]);
|
||||
}, [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 || !canAskFindingAgent(finding)) return;
|
||||
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 {
|
||||
await api.chat(payload, taskId);
|
||||
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("");
|
||||
void refreshStatus();
|
||||
if (!MOCK) void refreshStatus(generation);
|
||||
} catch (error) {
|
||||
toast.error(`发送失败:${(error as Error).message || "请稍后重试"}`);
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
toast.error(`发送失败:${(error as Error).message || "请稍后重试"}`);
|
||||
}
|
||||
} finally {
|
||||
setSending(false);
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
}, [finding, input, refreshStatus, running, sending, taskAvailable, taskId]);
|
||||
}, [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);
|
||||
await refreshStatus();
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
await refreshStatus(generation);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(`停止失败:${(error as Error).message || "请稍后重试"}`);
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
toast.error(`停止失败:${(error as Error).message || "请稍后重试"}`);
|
||||
}
|
||||
} finally {
|
||||
setStopping(false);
|
||||
if (aliveRef.current && requestGenerationRef.current === generation && findingIdRef.current === findingId) {
|
||||
setStopping(false);
|
||||
}
|
||||
}
|
||||
}, [refreshStatus, stopping, taskId]);
|
||||
}, [findingId, refreshStatus, stopping, taskId]);
|
||||
|
||||
const title = findingDisplayTitle(finding);
|
||||
const canSend =
|
||||
canAskFindingAgent(finding) && taskAvailable === true && !running && !sending && Boolean(input.trim());
|
||||
const canSend = canAsk && taskAvailable === true && !running && !sending && Boolean(input.trim());
|
||||
let footerStatus = "Enter 发送,Shift+Enter 换行。";
|
||||
if (sending) footerStatus = "正在发送…";
|
||||
else if (running) footerStatus = "主 Agent 运行中,可停止当前执行。";
|
||||
@@ -231,7 +368,7 @@ export function FindingAgentChatDrawer({ finding }: FindingAgentChatDrawerProps)
|
||||
) : null}
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="min-h-full p-4">
|
||||
<div ref={contentRef} className="min-h-full p-4">
|
||||
{loading ? <LoadingActivity /> : null}
|
||||
{!loading && activities.length ? (
|
||||
<Transcript
|
||||
@@ -256,7 +393,7 @@ export function FindingAgentChatDrawer({ finding }: FindingAgentChatDrawerProps)
|
||||
event.preventDefault();
|
||||
void send();
|
||||
}}
|
||||
disabled={!canAskFindingAgent(finding) || taskAvailable !== true || sending || running}
|
||||
disabled={!canAsk || taskAvailable !== true || sending || running}
|
||||
placeholder={running ? "主 Agent 正在运行…" : "描述复现现象或继续提问…"}
|
||||
aria-label="向漏洞所属任务的主 Agent 提问"
|
||||
className="max-h-32 min-h-10 resize-none"
|
||||
|
||||
Reference in new issue
Block a user