diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx index 2b7181b8..dee1b995 100644 --- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx @@ -436,7 +436,6 @@ export default function RoundtablePlanningPage() { const [step2RoundtableDialogues, setStep2RoundtableDialogues] = useState([]); // Inputs for Step 2 conversation simulation controls - const [roundTableSpeed, setRoundTableSpeed] = useState("正常"); const [isRoundTableRunning, setIsRoundTableRunning] = useState(true); const [customAgentInput, setCustomAgentInput] = useState(""); // Whether the roundtable visualizer + constraint console panel is collapsed. @@ -465,6 +464,7 @@ export default function RoundtablePlanningPage() { // early-returned because the ref was already set; net result: the async // landed but its setState got skipped, leaving intentThreadId null forever). const step1InitGenRef = useRef(0); + const intentAborterRef = useRef(null); // Multi-agent orchestration state (lives only for one step-2 visit). const [threadIds, setThreadIds] = useState(null); @@ -516,6 +516,17 @@ export default function RoundtablePlanningPage() { activeAbortersRef.current.clear(); }, []); + const stopIntentStream = useCallback(() => { + if (intentAborterRef.current) { + try { + intentAborterRef.current.abort(); + } catch { + /* noop */ + } + intentAborterRef.current = null; + } + }, []); + // Toast auto-dismiss logic useEffect(() => { if (showToast) { @@ -524,6 +535,11 @@ export default function RoundtablePlanningPage() { } }, [showToast]); + useEffect(() => { + if (currentStep === 1) return; + stopIntentStream(); + }, [currentStep, stopIntentStream]); + // Auto-follow the streamed conversations: track scroll position, stop // following when the user scrolls up, expose a `回到底部` button that // re-engages auto-follow. Same hook for both steps. @@ -1172,6 +1188,7 @@ export default function RoundtablePlanningPage() { // Stop anything in flight before swapping state out. orchestrationCancelRef.current = true; abortAllInFlight(); + stopIntentStream(); setIsOrchestrating(false); setIsInitializing(false); setPendingClarification(null); @@ -1201,7 +1218,7 @@ export default function RoundtablePlanningPage() { // doesn't change and the effect wouldn't re-fire. runStep1Init(); triggerToast("✨ 已开始新任务,请在第一步与任务理解智能体澄清需求"); - }, [abortAllInFlight, runStep1Init]); + }, [abortAllInFlight, runStep1Init, stopIntentStream]); // ── Draft helpers ────────────────────────────────────────────────────── // Build a fresh snapshot of the current in-memory session. Called by every @@ -1307,6 +1324,7 @@ export default function RoundtablePlanningPage() { // Cancel anything in-flight before swapping state out from under it. orchestrationCancelRef.current = true; abortAllInFlight(); + stopIntentStream(); setIsOrchestrating(false); setIsInitializing(false); setPendingClarification(null); @@ -1347,7 +1365,7 @@ export default function RoundtablePlanningPage() { setCurrentStep(draft.furthestStep); triggerToast(`📂 已加载草稿:${draft.name}`); }, - [drafts, abortAllInFlight], + [drafts, abortAllInFlight, stopIntentStream], ); const handleDeleteDraft = useCallback( @@ -1470,11 +1488,14 @@ export default function RoundtablePlanningPage() { }; setIsIntentSending(true); + const aborter = new AbortController(); + intentAborterRef.current = aborter; try { const res = await streamIntent({ threadId: intentThreadId, message: trimmed, model: DEFAULT_MODEL, + signal: aborter.signal, onTextDelta: (delta) => { setInteractiveMessages(prev => prev.map(m => (m.id === agentMsgId ? { ...m, content: (m.content ?? "") + delta } : m)), @@ -1529,16 +1550,36 @@ export default function RoundtablePlanningPage() { triggerToast(`❌ 智能体调用失败: ${res.content}`); } } catch (err) { - console.error("intent stream failed:", err); - setInteractiveMessages(prev => - prev.map(m => - m.id === agentMsgId - ? { ...m, content: `(错误) ${(err as Error).message}`, streaming: false } as any - : m, - ), - ); - triggerToast(`❌ 发送失败: ${(err as Error).message}`); + if ((err as Error).name === "AbortError" || aborter.signal.aborted) { + setInteractiveMessages(prev => + prev.map(m => + m.id === agentMsgId + ? { + ...m, + content: (m.content ?? "").trim() + ? `${m.content ?? ""}\n\n(已暂停)` + : "(已暂停)", + streaming: false, + } as any + : m, + ), + ); + triggerToast("⏸️ 已暂停任务理解回复"); + } else { + console.error("intent stream failed:", err); + setInteractiveMessages(prev => + prev.map(m => + m.id === agentMsgId + ? { ...m, content: `(错误) ${(err as Error).message}`, streaming: false } as any + : m, + ), + ); + triggerToast(`❌ 发送失败: ${(err as Error).message}`); + } } finally { + if (intentAborterRef.current === aborter) { + intentAborterRef.current = null; + } setIsIntentSending(false); } }; @@ -2603,6 +2644,18 @@ export default function RoundtablePlanningPage() { {isIntentSending ? "发送中" : "发送"} + {isIntentSending && ( + + )} + - - - - {/* Suspend / resume button visibility: - Show "挂起协商" only while a run is actually in flight (isOrchestrating + still nominally running). When the