接口联调

This commit is contained in:
fengxiangdebaozi committed 2026-05-23 17:23:15 +08:00
1 parent a85f1571a0
commit 87c9986b33
142 files changed
+38111 -73

No files matched your search

@@ -48,6 +48,12 @@ export interface StreamMultiAgentRequest {
signal?: AbortSignal;
/** Fires for every non-status data frame the upstream emits. */
onUpstreamFrame?: (frame: unknown) => void;
/**
* Fires for every AI text chunk parsed out of LangGraph messages-tuple frames.
* `text` is the incremental delta (concatenate to rebuild the message);
* `messageId` is stable per AI message so callers can group deltas.
*/
onTextDelta?: (text: string, messageId: string) => void;
}
export interface StreamMultiAgentResult {
@@ -84,6 +90,41 @@ function isFinalStatusFrame(value: unknown): value is {
return false;
}
/**
* Extract an AI text delta from a LangGraph `messages-tuple` data frame.
* Frames look like `[chunk, metadata]`; an AI text chunk carries `type` in
* {"ai","AIMessageChunk"} and a `content` that is either a string or a list of
* structured content blocks. Returns null when the frame is not an AI text
* chunk (e.g., tool calls, tool results, value snapshots).
*/
function extractAiTextDelta(parsed: unknown): { text: string; id: string } | null {
if (!Array.isArray(parsed) || parsed.length < 1) return null;
const chunk = parsed[0];
if (!chunk || typeof chunk !== "object") return null;
const obj = chunk as Record<string, unknown>;
const type = String(obj.type ?? "").toLowerCase();
// Accept any AI-shaped message chunk LangChain might emit across versions:
// "ai" (older), "AIMessageChunk" (newer streaming), or plain "AIMessage".
if (type !== "ai" && type !== "aimessagechunk" && type !== "aimessage") return null;
const content = obj.content;
let text = "";
if (typeof content === "string") {
text = content;
} else if (Array.isArray(content)) {
for (const item of content) {
if (typeof item === "string") text += item;
else if (item && typeof item === "object") {
const block = item as Record<string, unknown>;
const blockText = block.text;
if (typeof blockText === "string") text += blockText;
}
}
}
if (!text) return null;
const id = typeof obj.id === "string" ? obj.id : "";
return { text, id };
}
/**
* Open an SSE connection to /api/multi-agent/run/stream and resolve once the
* stream completes. The final frame (`{status: ...}`) is interpreted; every
@@ -115,6 +156,17 @@ export async function streamMultiAgent(
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
// Track the last `event:` line so logging can show which event a `data:`
// belongs to. The frontend doesn't actually dispatch by event name — every
// data frame goes through the same parser — but the label is invaluable
// for diagnosing why a sub-agent stream "looks empty".
let lastEvent = "";
let frameSeq = 0;
const debugLabel = `[stream:${req.agentType}:${req.agentName}]`;
const debugEnabled =
typeof window !== "undefined" &&
(window as unknown as { __MULTI_AGENT_DEBUG__?: boolean }).__MULTI_AGENT_DEBUG__ !== false;
const result: StreamMultiAgentResult = { dispatched: [], message: null, content: "", agentName: null };
@@ -128,6 +180,11 @@ export async function streamMultiAgent(
for (const rawLine of lines) {
const line = rawLine.trimEnd();
if (!line) continue;
if (line.startsWith("event:")) {
lastEvent = line.slice(6).trim();
continue;
}
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
@@ -136,9 +193,13 @@ export async function streamMultiAgent(
try {
parsed = JSON.parse(payload);
} catch {
if (debugEnabled) console.debug(debugLabel, `bad JSON in event=${lastEvent}:`, payload.slice(0, 200));
continue;
}
frameSeq += 1;
if (debugEnabled) console.debug(debugLabel, `frame#${frameSeq} event=${lastEvent}`, parsed);
if (isFinalStatusFrame(parsed)) {
if (Array.isArray(parsed.status)) {
result.dispatched = parsed.status;
@@ -154,9 +215,19 @@ export async function streamMultiAgent(
continue;
}
const delta = extractAiTextDelta(parsed);
if (delta) {
if (debugEnabled) console.debug(debugLabel, `→ delta (${delta.text.length} chars)`);
req.onTextDelta?.(delta.text, delta.id);
}
req.onUpstreamFrame?.(parsed);
}
}
if (debugEnabled) {
console.debug(debugLabel, `stream ended after ${frameSeq} frames; final content length=${result.content.length}`);
}
return result;
}
@@ -25,8 +25,9 @@ import {
Clock,
Play,
Pause,
ChevronRight,
Settings,
ChevronRight,
ChevronDown,
Settings,
Download,
Share2,
Check,
@@ -200,6 +201,10 @@ export default function RoundtablePlanningPage() {
const [consensusPercentage, setConsensusPercentage] = useState<number>(89.4);
const [selectedAgentId, setSelectedAgentId] = useState<number | null>(null);
const [activeSpeakerId, setActiveSpeakerId] = useState<number | null>(null);
// Multiple speakers can stream in parallel (one bubble per sub-agent). The Set
// tracks every currently-streaming speakerId so the avatar animation lights up
// for each of them concurrently instead of only the most recently set one.
const [activeSpeakerSet, setActiveSpeakerSet] = useState<Set<number>>(new Set());
// Custom presets for agent model, weight, temperature and prompt editors
const [agentConfigs, setAgentConfigs] = useState<{ [key: number]: { model: string; weight: number; prompt: string; temp: number } }>({
@@ -291,6 +296,9 @@ export default function RoundtablePlanningPage() {
const [roundTableSpeed, setRoundTableSpeed] = useState<string>("正常");
const [isRoundTableRunning, setIsRoundTableRunning] = useState<boolean>(true);
const [customAgentInput, setCustomAgentInput] = useState<string>("");
// Whether the roundtable visualizer + constraint console panel is collapsed.
// Collapsing gives the conversation log more vertical space.
const [isUpperPanelCollapsed, setIsUpperPanelCollapsed] = useState<boolean>(false);
// Multi-agent orchestration state (lives only for one step-2 visit).
const [threadIds, setThreadIds] = useState<ThreadIdMap | null>(null);
@@ -300,6 +308,17 @@ export default function RoundtablePlanningPage() {
const [isOrchestrating, setIsOrchestrating] = useState<boolean>(false);
const orchestrationCancelRef = useRef<boolean>(false);
const step2InitRef = useRef<boolean>(false);
// Tracks every AbortController active in this Step 2 visit so the "停止" /
// "挂起协商" button can abort all in-flight stream fetches at once.
const activeAbortersRef = useRef<Set<AbortController>>(new Set());
// Abort every in-flight streaming fetch. Safe to call multiple times.
const abortAllInFlight = useCallback(() => {
activeAbortersRef.current.forEach(ctrl => {
try { ctrl.abort(); } catch { /* noop */ }
});
activeAbortersRef.current.clear();
}, []);
// Toast auto-dismiss logic
useEffect(() => {
@@ -349,7 +368,62 @@ export default function RoundtablePlanningPage() {
]);
}, []);
// Highlight a speaker bubble + flip the role's status label.
// Streaming variant: append a placeholder card and return its id so subsequent
// text deltas can be appended in-place via appendToDialogue.
const appendStreamingDialogue = useCallback((card: {
sender: string;
avatarType: string;
initialContent?: string;
confidence?: string;
}): number => {
const id = Date.now() + Math.random();
setStep2RoundtableDialogues(prev => [
...prev,
{
id,
sender: card.sender,
avatarType: card.avatarType,
content: card.initialContent ?? "",
time: nowTime(),
confidence: card.confidence ?? "正在输出…",
streaming: true,
},
]);
return id;
}, []);
// Append an AI text delta to an existing streaming card (matched by id).
const appendToDialogue = useCallback((id: number, deltaText: string) => {
if (!deltaText) return;
setStep2RoundtableDialogues(prev =>
prev.map(d => (d.id === id ? { ...d, content: (d.content ?? "") + deltaText } : d)),
);
}, []);
// Finalize a streaming card with the official content + final confidence label.
// If the streamed content already matches, we still overwrite with the final
// text (some upstream paths emit trailing whitespace differently).
const finalizeDialogue = useCallback((
id: number,
finalContent: string,
confidence?: string,
) => {
setStep2RoundtableDialogues(prev =>
prev.map(d => {
if (d.id !== id) return d;
const next = finalContent && finalContent.trim().length > 0 ? finalContent : (d.content || "(无文本输出)");
return {
...d,
content: next,
confidence: confidence ?? d.confidence,
streaming: false,
};
}),
);
}, []);
// Highlight a speaker bubble + flip the role's status label. Single-speaker
// mode (used by the leader cycle and the legacy code paths).
const highlightSpeaker = useCallback((speakerId: number | null) => {
setActiveSpeakerId(speakerId);
setRoles(prev =>
@@ -365,6 +439,39 @@ export default function RoundtablePlanningPage() {
);
}, []);
// Add a speaker to the concurrent-streaming set so its avatar lights up
// while a streaming sub-agent run is in flight. Pair with markSpeakerIdle.
const markSpeakerStreaming = useCallback((speakerId: number) => {
setActiveSpeakerSet(prev => {
if (prev.has(speakerId)) return prev;
const next = new Set(prev);
next.add(speakerId);
return next;
});
setRoles(prev =>
prev.map(item => (item.id === speakerId ? { ...item, status: "研讨中" } : item)),
);
}, []);
const markSpeakerIdle = useCallback((speakerId: number) => {
setActiveSpeakerSet(prev => {
if (!prev.has(speakerId)) return prev;
const next = new Set(prev);
next.delete(speakerId);
return next;
});
setRoles(prev =>
prev.map(item => (item.id === speakerId && item.status === "研讨中" ? { ...item, status: "在线" } : item)),
);
}, []);
const clearAllStreamingSpeakers = useCallback(() => {
setActiveSpeakerSet(new Set());
setRoles(prev =>
prev.map(item => (item.status === "研讨中" ? { ...item, status: "在线" } : item)),
);
}, []);
// Pull the consolidated user intent out of step-1 history. Falls back to a
// generic prompt if the user never spoke.
const buildInitialIntent = useCallback(() => {
@@ -393,31 +500,47 @@ export default function RoundtablePlanningPage() {
while (!orchestrationCancelRef.current && safetyCounter < MAX_CYCLES) {
safetyCounter += 1;
highlightSpeaker(2);
appendDialogue({
// Leader bubble — created BEFORE the call so users see "正在输出…" the
// moment the request is in flight, then text fills in as deltas arrive.
const leaderBubbleId = appendStreamingDialogue({
sender: "总控协调",
avatarType: "blue",
content: `🧭 [总控派活] 正在分析当前进展并决定下一步:\n${nextLeaderTask.slice(0, 140)}${nextLeaderTask.length > 140 ? "..." : ""}`,
initialContent: "",
confidence: "调度中",
});
const leaderRes = await streamMultiAgent({
agentType: "leader",
dAgentThreadId: ids,
agentName: coord,
newMessage: nextLeaderTask,
model: DEFAULT_MODEL,
});
// Track this leader call's aborter so the stop button can kill it.
const leaderAborter = new AbortController();
activeAbortersRef.current.add(leaderAborter);
let leaderRes;
try {
leaderRes = await streamMultiAgent({
agentType: "leader",
dAgentThreadId: ids,
agentName: coord,
newMessage: nextLeaderTask,
model: DEFAULT_MODEL,
signal: leaderAborter.signal,
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
});
} catch (err) {
activeAbortersRef.current.delete(leaderAborter);
if (leaderAborter.signal.aborted || (err as Error).name === "AbortError") {
finalizeDialogue(leaderBubbleId, "(已停止)", "已停止");
break;
}
throw err;
}
activeAbortersRef.current.delete(leaderAborter);
if (orchestrationCancelRef.current) break;
if (leaderRes.content) {
appendDialogue({
sender: "总结陈述",
avatarType: "blue",
content: leaderRes.content,
confidence: leaderRes.dispatched.length === 0 ? "全案盖印" : "派活说明",
});
}
finalizeDialogue(
leaderBubbleId,
leaderRes.content,
leaderRes.dispatched.length === 0 ? "全案盖印" : "派活说明",
);
if (leaderRes.dispatched.length === 0) {
highlightSpeaker(null);
@@ -427,29 +550,49 @@ export default function RoundtablePlanningPage() {
}
// Dispatch all sub-agents in parallel; per-seat model preference
// overrides the default if the user picked one in the drawer.
// overrides the default if the user picked one in the drawer. Each
// sub-agent gets its own streaming bubble that fills independently.
await Promise.allSettled(
leaderRes.dispatched.map(async ([subName, task]) => {
if (orchestrationCancelRef.current) return;
const meta = AGENT_NAME_TO_ROLE[subName.toLowerCase()];
if (meta) highlightSpeaker(meta.speakerId);
// Add this speaker to the concurrent active set so its avatar
// lights up alongside any other sub-agents streaming in parallel.
if (meta) markSpeakerStreaming(meta.speakerId);
const perSeatModel = meta ? agentConfigs[meta.speakerId]?.model : undefined;
const specRes = await streamMultiAgent({
agentType: "special",
dAgentThreadId: ids,
agentName: subName,
newMessage: task,
model: perSeatModel || DEFAULT_MODEL,
});
if (orchestrationCancelRef.current) return;
appendDialogue({
const subBubbleId = appendStreamingDialogue({
sender: meta?.displayName ?? subName,
avatarType: meta?.avatarType ?? "blue",
content: specRes.content || `(无文本输出)`,
confidence: "子智能体交付",
initialContent: "",
confidence: "正在输出…",
});
const subAborter = new AbortController();
activeAbortersRef.current.add(subAborter);
try {
const specRes = await streamMultiAgent({
agentType: "special",
dAgentThreadId: ids,
agentName: subName,
newMessage: task,
model: perSeatModel || DEFAULT_MODEL,
signal: subAborter.signal,
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
});
if (orchestrationCancelRef.current) return;
finalizeDialogue(subBubbleId, specRes.content, "子智能体交付");
} catch (err) {
if (subAborter.signal.aborted || (err as Error).name === "AbortError") {
finalizeDialogue(subBubbleId, "(已停止)", "已停止");
} else {
console.error(`[special:${subName}] error:`, err);
finalizeDialogue(subBubbleId, `(错误) ${(err as Error).message}`, "调用失败");
}
} finally {
activeAbortersRef.current.delete(subAborter);
if (meta) markSpeakerIdle(meta.speakerId);
}
}),
);
@@ -469,9 +612,18 @@ export default function RoundtablePlanningPage() {
triggerToast(`❌ 研讨异常: ${(err as Error).message}`);
} finally {
highlightSpeaker(null);
clearAllStreamingSpeakers();
setIsOrchestrating(false);
}
}, [appendDialogue, highlightSpeaker, isOrchestrating, agentConfigs]);
}, [appendStreamingDialogue, appendToDialogue, finalizeDialogue, highlightSpeaker, markSpeakerStreaming, markSpeakerIdle, clearAllStreamingSpeakers, isOrchestrating, agentConfigs]);
// Public stop handler used by the pause/stop button and the step-2 teardown.
// Sets the cancel flag and aborts every in-flight stream so the loop can
// unwind immediately (rather than waiting for the current LLM call to finish).
const stopOrchestration = useCallback(() => {
orchestrationCancelRef.current = true;
abortAllInFlight();
}, [abortAllInFlight]);
// On first entry to step 2: init the coordinator + sub-agent threads, then
// fire the first leader cycle. Leaving step 2 resets so re-entering builds
@@ -480,6 +632,7 @@ export default function RoundtablePlanningPage() {
if (currentStep !== 2) {
// tear down: cancel any in-flight orchestration; reset bookkeeping.
orchestrationCancelRef.current = true;
abortAllInFlight();
step2InitRef.current = false;
setIsOrchestrating(false);
return;
@@ -531,12 +684,13 @@ export default function RoundtablePlanningPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentStep]);
// Pause/resume button — pausing cancels in-flight stream, resuming kicks off
// another leader cycle to continue the conversation.
// Pause/resume button — pausing aborts every in-flight stream so the run
// halts immediately rather than waiting for the current LLM call to finish.
// Resuming kicks off another leader cycle.
useEffect(() => {
if (currentStep !== 2) return;
if (!isRoundTableRunning) {
orchestrationCancelRef.current = true;
stopOrchestration();
return;
}
if (isOrchestrating || isInitializing || !threadIds || !coordinatorName) return;
@@ -1474,8 +1628,35 @@ export default function RoundtablePlanningPage() {
</div>
</div>
{/* Collapsed handle: when the upper panel is hidden, show a thin
bar at the top of the conversation area so the user can bring
it back. */}
{isUpperPanelCollapsed && (
<div className="shrink-0 px-6 py-2 border-b border-slate-100 bg-white flex items-center justify-center">
<button
onClick={() => setIsUpperPanelCollapsed(false)}
className="flex items-center gap-1.5 text-[11px] font-bold text-slate-500 hover:text-primary hover:bg-slate-50 px-3 py-1 rounded-lg transition cursor-pointer"
title="展开圆桌控制台"
>
<ChevronDown className="w-3.5 h-3.5" />
展开圆桌控制台
</button>
</div>
)}
{/* Split layout: Roundtable visualizer and Interactive Controllers */}
<div className="shrink-0 px-6 py-5 bg-gradient-to-b from-[var(--brand-200)]/10 to-transparent border-b border-slate-100 bg-white">
{!isUpperPanelCollapsed && (
<div className="shrink-0 px-6 py-5 bg-gradient-to-b from-[var(--brand-200)]/10 to-transparent border-b border-slate-100 bg-white relative">
{/* Close button — collapses this whole upper panel so the
conversation log takes the freed vertical space. */}
<button
onClick={() => setIsUpperPanelCollapsed(true)}
className="absolute top-3 right-4 flex items-center gap-1 text-[10px] font-bold text-slate-400 hover:text-primary hover:bg-slate-100 px-2 py-1 rounded-lg transition cursor-pointer z-10"
title="收起圆桌控制台"
>
<X className="w-3.5 h-3.5" />
收起
</button>
<div className="grid grid-cols-1 xl:grid-cols-12 gap-8 items-center max-w-7xl mx-auto">
{/* Left part: Roundtable circle diagram */}
@@ -1497,14 +1678,14 @@ export default function RoundtablePlanningPage() {
{/* Clickable Roundtable Agent Bubbles */}
{/* Role 3: 情报收集 (Top) */}
<button
<button
onClick={() => setSelectedAgentId(3)}
className={`absolute -top-3.5 left-1/2 -translate-x-1/2 transition-transform duration-300 hover:scale-[1.18] active:scale-95 bg-white p-0.5 rounded-full border shadow-md cursor-pointer ${selectedAgentId === 3 || activeSpeakerId === 3 ? 'ring-4 ring-purple-400 scale-[1.12]' : 'border-slate-200'}`}
className={`absolute -top-3.5 left-1/2 -translate-x-1/2 transition-transform duration-300 hover:scale-[1.18] active:scale-95 bg-white p-0.5 rounded-full border shadow-md cursor-pointer ${selectedAgentId === 3 || (activeSpeakerId === 3 || activeSpeakerSet.has(3)) ? 'ring-4 ring-purple-400 scale-[1.12]' : 'border-slate-200'}`}
title="点击配置 情报收集 智能体人设与LLM"
>
<PurpleRobotAvatar size="h-8.5 w-8.5" />
<span className="absolute -bottom-4.5 left-1/2 -translate-x-1/2 text-[8px] bg-purple-900 text-white font-extrabold px-1 rounded whitespace-nowrap shadow-sm">情报收集</span>
{(activeSpeakerId === 3 || selectedAgentId === 3) && (
{((activeSpeakerId === 3 || activeSpeakerSet.has(3)) || selectedAgentId === 3) && (
<span className="absolute -top-1 -right-1 flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-purple-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-purple-500"></span>
@@ -1515,12 +1696,12 @@ export default function RoundtablePlanningPage() {
{/* Role 4: 环境评估 (Top Right) */}
<button
onClick={() => setSelectedAgentId(4)}
className={`absolute top-5 -right-4 transition-transform duration-300 hover:scale-[1.18] active:scale-95 bg-white p-0.5 rounded-full border shadow-md cursor-pointer ${selectedAgentId === 4 || activeSpeakerId === 4 ? 'ring-4 ring-emerald-400 scale-[1.12]' : 'border-slate-200'}`}
className={`absolute top-5 -right-4 transition-transform duration-300 hover:scale-[1.18] active:scale-95 bg-white p-0.5 rounded-full border shadow-md cursor-pointer ${selectedAgentId === 4 || (activeSpeakerId === 4 || activeSpeakerSet.has(4)) ? 'ring-4 ring-emerald-400 scale-[1.12]' : 'border-slate-200'}`}
title="点击配置 环境评估 智能体人设与LLM"
>
<EmeraldRobotAvatar size="h-8.5 w-8.5" />
<span className="absolute -bottom-4.5 left-1/2 -translate-x-1/2 text-[8px] bg-emerald-900 text-white font-extrabold px-1 rounded whitespace-nowrap shadow-sm">环境评估</span>
{(activeSpeakerId === 4 || selectedAgentId === 4) && (
{((activeSpeakerId === 4 || activeSpeakerSet.has(4)) || selectedAgentId === 4) && (
<span className="absolute -top-1 -right-1 flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500"></span>
@@ -1531,12 +1712,12 @@ export default function RoundtablePlanningPage() {
{/* Human Participant: 方案设计 (Bottom Right) */}
<button
onClick={() => setSelectedAgentId(5)}
className={`absolute bottom-5 -right-4 transition-transform duration-300 hover:scale-[1.18] active:scale-95 bg-white p-0.5 rounded-full border shadow-md cursor-pointer ${selectedAgentId === 5 || activeSpeakerId === 5 ? 'ring-4 ring-primary/50 scale-[1.12]' : 'border-slate-200'}`}
className={`absolute bottom-5 -right-4 transition-transform duration-300 hover:scale-[1.18] active:scale-95 bg-white p-0.5 rounded-full border shadow-md cursor-pointer ${selectedAgentId === 5 || (activeSpeakerId === 5 || activeSpeakerSet.has(5)) ? 'ring-4 ring-primary/50 scale-[1.12]' : 'border-slate-200'}`}
title="点击查看 方案设计 角色参数"
>
<Man1Avatar size="h-8.5 w-8.5" />
<span className="absolute -bottom-4.5 left-1/2 -translate-x-1/2 text-[8px] bg-[var(--brand-950)] text-white font-extrabold px-1 rounded whitespace-nowrap shadow-sm">方案设计</span>
{(activeSpeakerId === 5 || selectedAgentId === 5) && (
{((activeSpeakerId === 5 || activeSpeakerSet.has(5)) || selectedAgentId === 5) && (
<span className="absolute -top-1 -right-1 flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary/60 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-[var(--brand-200)]0"></span>
@@ -1547,12 +1728,12 @@ export default function RoundtablePlanningPage() {
{/* Role 6: 风险审查 (Bottom) */}
<button
onClick={() => setSelectedAgentId(6)}
className={`absolute -bottom-3.5 left-1/2 -translate-x-1/2 transition-transform duration-300 hover:scale-[1.18] active:scale-95 bg-white p-0.5 rounded-full border shadow-md cursor-pointer ${selectedAgentId === 6 || activeSpeakerId === 6 ? 'ring-4 ring-amber-400 scale-[1.12]' : 'border-slate-200'}`}
className={`absolute -bottom-3.5 left-1/2 -translate-x-1/2 transition-transform duration-300 hover:scale-[1.18] active:scale-95 bg-white p-0.5 rounded-full border shadow-md cursor-pointer ${selectedAgentId === 6 || (activeSpeakerId === 6 || activeSpeakerSet.has(6)) ? 'ring-4 ring-amber-400 scale-[1.12]' : 'border-slate-200'}`}
title="点击配置 风险审查 智能体人设与LLM"
>
<AmberRobotAvatar size="h-8.5 w-8.5" />
<span className="absolute -bottom-4.5 left-1/2 -translate-x-1/2 text-[8px] bg-amber-900 text-white font-extrabold px-1 rounded whitespace-nowrap shadow-sm">风险审查</span>
{(activeSpeakerId === 6 || selectedAgentId === 6) && (
{((activeSpeakerId === 6 || activeSpeakerSet.has(6)) || selectedAgentId === 6) && (
<span className="absolute -top-1 -right-1 flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-amber-500"></span>
@@ -1563,12 +1744,12 @@ export default function RoundtablePlanningPage() {
{/* Role 7: 执行规划 (Bottom Left) */}
<button
onClick={() => setSelectedAgentId(7)}
className={`absolute bottom-5 -left-4 transition-transform duration-300 hover:scale-[1.18] active:scale-95 bg-white p-0.5 rounded-full border shadow-md cursor-pointer ${selectedAgentId === 7 || activeSpeakerId === 7 ? 'ring-4 ring-pink-400 scale-[1.12]' : 'border-slate-200'}`}
className={`absolute bottom-5 -left-4 transition-transform duration-300 hover:scale-[1.18] active:scale-95 bg-white p-0.5 rounded-full border shadow-md cursor-pointer ${selectedAgentId === 7 || (activeSpeakerId === 7 || activeSpeakerSet.has(7)) ? 'ring-4 ring-pink-400 scale-[1.12]' : 'border-slate-200'}`}
title="点击配置 执行规划 智能体人设与LLM"
>
<PinkRobotAvatar size="h-8.5 w-8.5" />
<span className="absolute -bottom-4.5 left-1/2 -translate-x-1/2 text-[8px] bg-pink-900 text-white font-extrabold px-1 rounded whitespace-nowrap shadow-sm">执行规划</span>
{(activeSpeakerId === 7 || selectedAgentId === 7) && (
{((activeSpeakerId === 7 || activeSpeakerSet.has(7)) || selectedAgentId === 7) && (
<span className="absolute -top-1 -right-1 flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-pink-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-pink-500"></span>
@@ -1579,12 +1760,12 @@ export default function RoundtablePlanningPage() {
{/* Role 2: 总结陈述 (Top Left) */}
<button
onClick={() => setSelectedAgentId(2)}
className={`absolute top-5 -left-4 transition-transform duration-300 hover:scale-[1.18] active:scale-95 bg-white p-0.5 rounded-full border shadow-md cursor-pointer ${selectedAgentId === 2 || activeSpeakerId === 2 ? 'ring-4 ring-primary/50 scale-[1.12]' : 'border-slate-200'}`}
className={`absolute top-5 -left-4 transition-transform duration-300 hover:scale-[1.18] active:scale-95 bg-white p-0.5 rounded-full border shadow-md cursor-pointer ${selectedAgentId === 2 || (activeSpeakerId === 2 || activeSpeakerSet.has(2)) ? 'ring-4 ring-primary/50 scale-[1.12]' : 'border-slate-200'}`}
title="点击配置 总结陈述 主持人"
>
<RobotAvatar bg="bg-[var(--brand-200)]" headColor="#cf1322" size="h-8.5 w-8.5" />
<span className="absolute -bottom-4.5 left-1/2 -translate-x-1/2 text-[8px] bg-[var(--brand-900)] text-white font-extrabold px-1 rounded whitespace-nowrap shadow-sm">总结陈述</span>
{(activeSpeakerId === 2 || selectedAgentId === 2) && (
{((activeSpeakerId === 2 || activeSpeakerSet.has(2)) || selectedAgentId === 2) && (
<span className="absolute -top-1 -right-1 flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-primary/60 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-[var(--brand-200)]0"></span>
@@ -1659,6 +1840,7 @@ export default function RoundtablePlanningPage() {
</div>
</div>
)}
{/* Selected Agent Persona Config Drawer / Drawer dialog box */}
{selectedAgentId && (
@@ -1791,9 +1973,9 @@ export default function RoundtablePlanningPage() {
)}
{/* Conversation Log inside Step 2 */}
<div
ref={step2ScrollRef}
className="flex-1 overflow-y-auto p-6 space-y-4 bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner max-h-[350px] min-h-[140px]"
<div
ref={step2ScrollRef}
className="flex-1 min-h-0 overflow-y-auto p-6 space-y-4 bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner"
>
{step2RoundtableDialogues.map((msg) => (
<div
@@ -1813,8 +1995,11 @@ export default function RoundtablePlanningPage() {
</div>
<span className="text-[10px] text-slate-400 font-mono font-medium">{msg.time}</span>
</div>
<p className="text-xs text-slate-700 leading-relaxed font-medium">
<p className="text-xs text-slate-700 leading-relaxed font-medium whitespace-pre-wrap break-words">
{msg.content}
{msg.streaming && (
<span className="inline-block w-1 h-3 ml-0.5 bg-primary align-middle animate-pulse" />
)}
</p>
</div>
</div>