接口联调

This commit is contained in:
董宏凯 committed 2026-05-23 23:05:01 +08:00
1 parent 43df9175df
commit 8673eb5eca
148 files changed
+2050 -37963

No files matched your search

@@ -0,0 +1,69 @@
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
function filenameFromContentDisposition(header: string | null): string | null {
if (!header) return null;
const star = /filename\*=(?:UTF-8'')?([^;]+)/i.exec(header);
if (star) {
const raw = star[1].replace(/["']/g, "").trim();
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
const plain = /filename="?([^";\n]+)"?/i.exec(header);
return plain ? plain[1].trim() : null;
}
function sanitizeFallbackFilename(title: string | undefined): string {
const base = (title?.trim() || "document").replace(/[<>:"/\\|?*\x00-\x1f]+/g, "_");
return base.toLowerCase().endsWith(".docx") ? base : `${base}.docx`;
}
async function downloadBlobResponse(response: Response, fallbackFilename: string) {
if (!response.ok) {
const error = await response
.json()
.catch(() => ({ detail: "导出 Word 失败" }));
const detail = error.detail;
if (typeof detail === "string") throw new Error(detail);
throw new Error("导出 Word 失败");
}
const blob = await response.blob();
const filename =
filenameFromContentDisposition(response.headers.get("Content-Disposition")) ??
fallbackFilename;
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.rel = "noopener";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
}
/** 调用后端导出 AI 写作草稿为 Word,仅传正文 markdown,不传目录/大纲。 */
export async function downloadAIWritingDocx(options: {
title?: string;
draftMarkdown: string;
}): Promise<void> {
const draftMarkdown = options.draftMarkdown.trim();
if (!draftMarkdown) {
throw new Error("文档内容为空");
}
const response = await apiFetch(`${getBackendBaseURL()}/api/ai-writing/export/docx`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: options.title?.trim() || undefined,
draft_markdown: draftMarkdown,
}),
});
await downloadBlobResponse(response, sanitizeFallbackFilename(options.title));
}
@@ -25,7 +25,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
import { exportMarkdownToDocx } from "../../utils/export-docx";
import { downloadAIWritingDocx } from "../../api/writing-export";
interface ToolbarButtonProps {
onClick: () => void;
@@ -82,9 +82,12 @@ export function WritingToolbar({ editor, markdown, title }: WritingToolbarProps)
if (!canExport) return;
setExporting(true);
try {
await exportMarkdownToDocx(markdown ?? "", title);
await downloadAIWritingDocx({
title,
draftMarkdown: markdown ?? "",
});
} catch (err) {
console.error("[export-docx] failed", err);
console.error("[writing-export] failed", err);
toast.error("导出 Word 失败,请稍后重试");
} finally {
setExporting(false);
@@ -3,10 +3,27 @@ import { getBackendBaseURL } from "@/core/config";
export type ThreadIdMap = Record<string, string>;
export interface SeatReadyEvent {
agentName: string;
threadId: string;
displayName: string;
description: string;
}
export interface CoordinatorReadyEvent {
mainAgentName: string;
threadId: string;
}
export interface InitMultiAgentRequest {
agentNames: string[];
mainAgentName: string;
model?: string;
/** Fires the moment each sub-agent's thread is ready (real completion order). */
onSeatReady?: (event: SeatReadyEvent) => void;
/** Fires when the coordinator's thread + agent have been created. */
onCoordinatorReady?: (event: CoordinatorReadyEvent) => void;
signal?: AbortSignal;
}
export interface InitMultiAgentResponse {
@@ -15,6 +32,12 @@ export interface InitMultiAgentResponse {
thread_ids: ThreadIdMap;
}
/**
* Open the /api/multi-agent/init SSE stream. Fires per-seat / coordinator
* callbacks as events arrive so the UI can light up roles individually, and
* resolves with the final {main_agent_name, thread_ids} once init_done lands.
* If the server emits an "error" frame mid-stream, the promise rejects.
*/
export async function initMultiAgent(
payload: InitMultiAgentRequest,
): Promise<InitMultiAgentResponse> {
@@ -26,18 +49,114 @@ export async function initMultiAgent(
main_agent_name: payload.mainAgentName,
model: payload.model,
}),
signal: payload.signal,
});
if (!res.ok) {
if (!res.ok || !res.body) {
const text = await res.text().catch(() => "");
throw new Error(`init multi-agent failed (${res.status}): ${text}`);
}
return (await res.json()) as InitMultiAgentResponse;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let mainAgentName = "";
let threadIds: ThreadIdMap | null = null;
let aborted = false;
let errorDetail: string | null = null;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const rawLine of lines) {
const line = rawLine.trimEnd();
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (!data) continue;
let parsed: unknown;
try {
parsed = JSON.parse(data);
} catch {
continue;
}
if (!parsed || typeof parsed !== "object") continue;
const obj = parsed as Record<string, unknown>;
const event = typeof obj.event === "string" ? obj.event : "";
switch (event) {
case "seat_ready": {
payload.onSeatReady?.({
agentName: String(obj.agent_name ?? ""),
threadId: String(obj.thread_id ?? ""),
displayName: String(obj.display_name ?? ""),
description: String(obj.description ?? ""),
});
break;
}
case "coordinator_ready": {
payload.onCoordinatorReady?.({
mainAgentName: String(obj.main_agent_name ?? ""),
threadId: String(obj.thread_id ?? ""),
});
break;
}
case "init_done": {
mainAgentName = String(obj.main_agent_name ?? "");
threadIds = (obj.thread_ids ?? {}) as ThreadIdMap;
break;
}
case "error": {
errorDetail = String(obj.detail ?? "unknown init error");
break;
}
default:
// Unknown event — ignore for forward-compat.
break;
}
}
}
} catch (err) {
aborted = (err as Error)?.name === "AbortError" || payload.signal?.aborted === true;
if (!aborted) throw err;
}
if (errorDetail) {
throw new Error(`init multi-agent failed: ${errorDetail}`);
}
if (aborted) {
throw new DOMException("init aborted", "AbortError");
}
if (!threadIds || !mainAgentName) {
throw new Error("init multi-agent stream ended without init_done");
}
return {
status: "success",
main_agent_name: mainAgentName,
thread_ids: threadIds,
};
}
export type StreamAgentType = "leader" | "special";
export type LeaderDispatch = [string, string];
/**
* High-level phases the SSE stream goes through. Used by the UI to show
* "what the backend is doing" while the user waits — most of the time before
* a first token is upstream/LLM latency, which feels like a stall otherwise.
*
* - `connecting` — fetch sent, no frames yet
* - `connected` — got the `event: metadata` frame (run_id assigned)
* - `streaming` — first AI text chunk arrived; tokens flowing
* - `tool_calling` — model emitted a tool_call chunk (e.g. agent_orchestration,
* present_files); usually means the run is about to interrupt
* - `tool_result` — a tool result message came back; brief, may go back to streaming
*/
export type StreamPhase = "connecting" | "connected" | "streaming" | "tool_calling" | "tool_result";
export interface StreamMultiAgentRequest {
agentType: StreamAgentType;
dAgentThreadId: ThreadIdMap;
@@ -54,6 +173,25 @@ export interface StreamMultiAgentRequest {
* `messageId` is stable per AI message so callers can group deltas.
*/
onTextDelta?: (text: string, messageId: string) => void;
/**
* Fires whenever the inferred SSE phase changes. The UI can use this to
* show progressive status messages during the inevitable first-token wait.
* `name` is the tool name when phase is `tool_calling` (or empty otherwise).
*/
onPhaseChange?: (phase: StreamPhase, name?: string) => void;
}
export interface ClarificationPayload {
/** Raw question text from the model's ask_clarification args. */
question: string;
/** Optional category hint from the model (e.g. "missing_info"). */
clarificationType: string;
/** Free-text reason the model needs clarification. */
context: string;
/** Suggested answer chips (may be empty). */
options: unknown[];
/** Whether the user is allowed to type a custom answer. */
allowCustom: boolean;
}
export interface StreamMultiAgentResult {
@@ -65,6 +203,14 @@ export interface StreamMultiAgentResult {
content: string;
/** Echoed agent_name of the agent that produced this run. */
agentName: string | null;
/**
* Populated when the leader called ask_clarification — the run ended without
* dispatching, and the model is asking the user a question instead. When
* present, callers should render `content` (or `clarification.question` as
* a fallback) as a question and collect a user reply rather than treating
* an empty `dispatched` as consensus.
*/
clarification: ClarificationPayload | null;
}
/**
@@ -76,6 +222,11 @@ function isFinalStatusFrame(value: unknown): value is {
status: LeaderDispatch[] | string;
content?: string;
agent_name?: string;
question?: string;
clarification_type?: string;
clarification_context?: string;
options?: unknown[];
allow_custom?: boolean;
} {
if (!value || typeof value !== "object") return false;
if (!("status" in value) || !("content" in value)) return false;
@@ -125,6 +276,44 @@ function extractAiTextDelta(parsed: unknown): { text: string; id: string } | nul
return { text, id };
}
/**
* Trigger a browser download for a text blob. No-op outside the browser.
* Used by streamMultiAgent's dump-to-file debug mode.
*/
function downloadTextBlob(filename: string, text: string): void {
if (typeof window === "undefined" || typeof document === "undefined") return;
try {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.style.display = "none";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
// Defer revoke so the browser has a chance to start the download.
setTimeout(() => URL.revokeObjectURL(url), 60_000);
} catch (err) {
// Downloads must never break the actual stream — just log and continue.
console.warn("[stream] downloadTextBlob failed:", err);
}
}
/**
* Whether streamMultiAgent should auto-download the raw SSE transcript to a
* .txt when each call ends. Default ON for now because we're debugging the
* "sub-agent stream looks empty" issue and the browser console truncates
* large message logs. Toggle from devtools:
* window.__MULTI_AGENT_DUMP__ = false
*/
function isDumpEnabled(): boolean {
if (typeof window === "undefined") return false;
const flag = (window as unknown as { __MULTI_AGENT_DUMP__?: boolean }).__MULTI_AGENT_DUMP__;
// Default: enabled. Only disabled if explicitly set to false.
return flag !== false;
}
/**
* Open an SSE connection to /api/multi-agent/run/stream and resolve once the
* stream completes. The final frame (`{status: ...}`) is interpreted; every
@@ -168,66 +357,184 @@ export async function streamMultiAgent(
typeof window !== "undefined" &&
(window as unknown as { __MULTI_AGENT_DEBUG__?: boolean }).__MULTI_AGENT_DEBUG__ !== false;
const result: StreamMultiAgentResult = { dispatched: [], message: null, content: "", agentName: null };
// Raw SSE transcript buffer. Every raw line read from the stream (event:,
// data:, blank framing lines, anything) is appended verbatim so the dumped
// .txt is byte-for-byte what the frontend received off the socket. This is
// independent of parsing: even malformed/non-JSON frames end up in the file.
const dumpEnabled = isDumpEnabled();
const transcript: string[] = dumpEnabled
? [
`# multi-agent stream transcript`,
`# agent_type: ${req.agentType}`,
`# agent_name: ${req.agentName}`,
`# started_at: ${new Date().toISOString()}`,
`# new_message: ${req.newMessage.replace(/\n/g, " ⏎ ")}`,
`# model: ${req.model ?? "(default)"}`,
`# ---- raw SSE follows ----`,
"",
]
: [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const result: StreamMultiAgentResult = {
dispatched: [],
message: null,
content: "",
agentName: null,
clarification: null,
};
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
// Inferred SSE phase + a tiny helper so we only fire onPhaseChange when the
// value actually changes (the reader loop sees many frames per phase).
let currentPhase: StreamPhase = "connecting";
req.onPhaseChange?.("connecting");
const setPhase = (next: StreamPhase, name?: string) => {
if (next === currentPhase && !name) return;
currentPhase = next;
req.onPhaseChange?.(next, name);
};
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;
// Wrap the read loop so we always flush the transcript to disk, even on
// abort / network failure. Otherwise an interrupted run would lose the very
// evidence we want to inspect.
const finishDump = (reason: string) => {
if (!dumpEnabled) return;
transcript.push("");
transcript.push(`# ---- stream ended (${reason}) ----`);
transcript.push(`# ended_at: ${new Date().toISOString()}`);
transcript.push(`# frames_parsed: ${frameSeq}`);
transcript.push(`# final_content_length: ${result.content.length}`);
transcript.push(`# dispatched_count: ${result.dispatched.length}`);
const tsSafe = new Date().toISOString().replace(/[:.]/g, "-");
const nameSafe = req.agentName.replace(/[^a-zA-Z0-9_-]/g, "_");
const filename = `multi-agent_${req.agentType}_${nameSafe}_${tsSafe}.txt`;
downloadTextBlob(filename, transcript.join("\n"));
};
let parsed: unknown;
try {
parsed = JSON.parse(payload);
} catch {
if (debugEnabled) console.debug(debugLabel, `bad JSON in event=${lastEvent}:`, payload.slice(0, 200));
continue;
}
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
buffer += chunk;
frameSeq += 1;
if (debugEnabled) console.debug(debugLabel, `frame#${frameSeq} event=${lastEvent}`, parsed);
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
if (isFinalStatusFrame(parsed)) {
if (Array.isArray(parsed.status)) {
result.dispatched = parsed.status;
} else {
result.message = parsed.status;
for (const rawLine of lines) {
// Record EVERY raw line — including blank framing lines and event:
// headers — so the dumped transcript matches what the socket sent.
if (dumpEnabled) transcript.push(rawLine);
const line = rawLine.trimEnd();
if (!line) continue;
if (line.startsWith("event:")) {
lastEvent = line.slice(6).trim();
// First metadata frame = run accepted by upstream. Move phase out
// of "connecting" so the UI can switch from "调度中" to "已连接".
if (lastEvent === "metadata" && currentPhase === "connecting") {
setPhase("connected");
}
continue;
}
if (typeof parsed.content === "string") {
result.content = parsed.content;
}
if (typeof parsed.agent_name === "string") {
result.agentName = parsed.agent_name;
}
continue;
}
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (!payload) continue;
const delta = extractAiTextDelta(parsed);
if (delta) {
if (debugEnabled) console.debug(debugLabel, `→ delta (${delta.text.length} chars)`);
req.onTextDelta?.(delta.text, delta.id);
}
let parsed: unknown;
try {
parsed = JSON.parse(payload);
} catch {
if (debugEnabled) console.debug(debugLabel, `bad JSON in event=${lastEvent}:`, payload.slice(0, 200));
continue;
}
req.onUpstreamFrame?.(parsed);
frameSeq += 1;
if (debugEnabled) console.debug(debugLabel, `frame#${frameSeq} event=${lastEvent}`, parsed);
// Phase inference from LangGraph messages-tuple frames. We only flip
// to tool_calling/tool_result on chunks that clearly indicate them;
// everything else with non-empty AI text means we're streaming.
if (lastEvent === "messages" && Array.isArray(parsed) && parsed.length > 0) {
const chunk = parsed[0];
if (chunk && typeof chunk === "object") {
const chunkObj = chunk as Record<string, unknown>;
const chunkType = String(chunkObj.type ?? "").toLowerCase();
const toolCalls = chunkObj.tool_calls;
const toolCallChunks = chunkObj.tool_call_chunks;
const content = chunkObj.content;
const hasToolCall =
(Array.isArray(toolCalls) && toolCalls.length > 0) ||
(Array.isArray(toolCallChunks) && toolCallChunks.length > 0);
if (chunkType === "tool") {
setPhase("tool_result");
} else if (hasToolCall) {
// Pick the most informative name we can find in either field.
const firstCall = Array.isArray(toolCalls) && toolCalls.length > 0
? (toolCalls[0] as Record<string, unknown>)
: Array.isArray(toolCallChunks) && toolCallChunks.length > 0
? (toolCallChunks[0] as Record<string, unknown>)
: null;
const toolName = firstCall && typeof firstCall.name === "string" ? firstCall.name : "";
setPhase("tool_calling", toolName);
} else if (
(chunkType === "ai" || chunkType === "aimessagechunk" || chunkType === "aimessage") &&
((typeof content === "string" && content.length > 0) || (Array.isArray(content) && content.length > 0))
) {
setPhase("streaming");
}
}
}
if (isFinalStatusFrame(parsed)) {
if (Array.isArray(parsed.status)) {
result.dispatched = parsed.status;
} else {
result.message = parsed.status;
// "clarification" status carries the leader's ask_clarification
// payload. Extract the structured fields so the page can render
// a question (or chip choices) instead of mistaking the run for
// a consensus.
if (parsed.status === "clarification") {
result.clarification = {
question: typeof parsed.question === "string" ? parsed.question : "",
clarificationType:
typeof parsed.clarification_type === "string" ? parsed.clarification_type : "",
context:
typeof parsed.clarification_context === "string" ? parsed.clarification_context : "",
options: Array.isArray(parsed.options) ? parsed.options : [],
allowCustom: parsed.allow_custom !== false,
};
}
}
if (typeof parsed.content === "string") {
result.content = parsed.content;
}
if (typeof parsed.agent_name === "string") {
result.agentName = parsed.agent_name;
}
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}`);
if (debugEnabled) {
console.debug(debugLabel, `stream ended after ${frameSeq} frames; final content length=${result.content.length}`);
}
finishDump("normal");
return result;
} catch (err) {
// Flush whatever we got so the user can still inspect the transcript on
// abort / network drop. Then rethrow so callers' own handling runs.
const aborted = (err as Error)?.name === "AbortError" || req.signal?.aborted === true;
finishDump(aborted ? "aborted" : `error: ${(err as Error)?.message ?? String(err)}`);
throw err;
}
return result;
}
@@ -1,4 +1,6 @@
import React, { useState, useEffect, useRef, useCallback } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { HighFidelityReport } from "@/roundtable-planning/components/HighFidelityReport";
import { useTheme } from "@/contexts/ThemeContext";
import { cn } from "@/lib/utils";
@@ -6,7 +8,9 @@ import { useModels } from "@/core/models/hooks";
import {
initMultiAgent,
streamMultiAgent,
type ClarificationPayload,
type LeaderDispatch,
type StreamPhase,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import {
@@ -39,7 +43,8 @@ import {
Cpu,
CornerDownRight,
Star,
ShieldCheck
ShieldCheck,
Loader2,
} from "lucide-react";
// --- Custom Crisp SVGs to Match High Fidelity Avatars ---
@@ -306,6 +311,11 @@ export default function RoundtablePlanningPage() {
const [isInitializing, setIsInitializing] = useState<boolean>(false);
const [initError, setInitError] = useState<string | null>(null);
const [isOrchestrating, setIsOrchestrating] = useState<boolean>(false);
// When the coordinator calls ask_clarification, the leader run ends with
// this payload populated; the page renders a question card and only
// resumes orchestration once the user replies.
const [pendingClarification, setPendingClarification] = useState<ClarificationPayload | null>(null);
const [clarificationDraft, setClarificationDraft] = useState<string>("");
const orchestrationCancelRef = useRef<boolean>(false);
const step2InitRef = useRef<boolean>(false);
// Tracks every AbortController active in this Step 2 visit so the "停止" /
@@ -368,6 +378,38 @@ export default function RoundtablePlanningPage() {
]);
}, []);
// Translate an inferred SSE phase into a human-readable confidence label
// for the dialogue bubble. We keep the bubble's existing in-progress
// confidence ("调度中" / "正在输出…") when phase doesn't add information,
// and switch to a specific label when something specific is happening.
const phaseLabel = useCallback((phase: StreamPhase, agentType: "leader" | "special", toolName?: string): string => {
switch (phase) {
case "connecting":
return "调度中";
case "connected":
return "已连接,等待响应";
case "tool_calling":
if (toolName === "agent_orchestration") return "正在派活";
if (toolName === "ask_clarification") return "正在提问";
if (toolName === "present_files") return "正在交付文件";
return toolName ? `调用工具:${toolName}` : "调用工具";
case "tool_result":
return "处理工具结果";
case "streaming":
return agentType === "leader" ? "正在派活说明" : "正在输出";
default:
return agentType === "leader" ? "调度中" : "正在输出…";
}
}, []);
// Update only the confidence field of a streaming card by id. Used by the
// phase callback so we can change the label without touching content.
const setDialogueConfidence = useCallback((id: number, confidence: string) => {
setStep2RoundtableDialogues(prev =>
prev.map(d => (d.id === id ? { ...d, confidence } : d)),
);
}, []);
// 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: {
@@ -501,8 +543,10 @@ export default function RoundtablePlanningPage() {
safetyCounter += 1;
highlightSpeaker(2);
// Leader bubble — created BEFORE the call so users see "正在输出…" the
// moment the request is in flight, then text fills in as deltas arrive.
// Leader bubble — created BEFORE the call so users see "调度中" the
// moment the request is in flight, then phase changes flip the
// confidence label so the user sees what the backend is actually
// doing during the ~1-2s first-token wait.
const leaderBubbleId = appendStreamingDialogue({
sender: "总控协调",
avatarType: "blue",
@@ -523,6 +567,9 @@ export default function RoundtablePlanningPage() {
model: DEFAULT_MODEL,
signal: leaderAborter.signal,
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName) => {
setDialogueConfidence(leaderBubbleId, phaseLabel(phase, "leader", toolName));
},
});
} catch (err) {
activeAbortersRef.current.delete(leaderAborter);
@@ -536,6 +583,18 @@ export default function RoundtablePlanningPage() {
if (orchestrationCancelRef.current) break;
// Clarification branch — leader asked the user a question. Stop the
// auto loop, surface the question card, and wait for the user to
// reply before another leader cycle.
if (leaderRes.clarification) {
finalizeDialogue(leaderBubbleId, leaderRes.content, "等待用户澄清");
highlightSpeaker(null);
setPendingClarification(leaderRes.clarification);
setClarificationDraft("");
triggerToast("❓ 总控需要补充信息,请回答下方问题后继续");
break;
}
finalizeDialogue(
leaderBubbleId,
leaderRes.content,
@@ -549,52 +608,59 @@ export default function RoundtablePlanningPage() {
break;
}
// Dispatch all sub-agents in parallel; per-seat model preference
// 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()];
// 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;
// Dispatch sub-agents sequentially (one at a time). Running them in
// parallel meant N concurrent SSE streams each triggering a full-list
// setState on every token delta, which made the browser stutter badly
// when the leader 派活给多个席位。改为顺序后,同一时刻只有一个 SSE 在跑。
for (const [subName, task] of leaderRes.dispatched) {
if (orchestrationCancelRef.current) break;
const meta = AGENT_NAME_TO_ROLE[subName.toLowerCase()];
if (meta) markSpeakerStreaming(meta.speakerId);
const perSeatModel = meta ? agentConfigs[meta.speakerId]?.model : undefined;
const subBubbleId = appendStreamingDialogue({
sender: meta?.displayName ?? subName,
avatarType: meta?.avatarType ?? "blue",
initialContent: "",
confidence: "正在输出…",
const subBubbleId = appendStreamingDialogue({
sender: meta?.displayName ?? subName,
avatarType: meta?.avatarType ?? "blue",
initialContent: "",
confidence: "调度中",
});
const subAborter = new AbortController();
activeAbortersRef.current.add(subAborter);
let aborted = false;
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),
onPhaseChange: (phase, toolName) => {
setDialogueConfidence(subBubbleId, phaseLabel(phase, "special", toolName));
},
});
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;
if (orchestrationCancelRef.current) {
finalizeDialogue(subBubbleId, specRes.content || "(已停止)", "已停止");
aborted = true;
} else {
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);
}
}),
);
} catch (err) {
if (subAborter.signal.aborted || (err as Error).name === "AbortError") {
finalizeDialogue(subBubbleId, "(已停止)", "已停止");
aborted = true;
} else {
console.error(`[special:${subName}] error:`, err);
finalizeDialogue(subBubbleId, `(错误) ${(err as Error).message}`, "调用失败");
}
} finally {
activeAbortersRef.current.delete(subAborter);
if (meta) markSpeakerIdle(meta.speakerId);
}
if (aborted) break;
}
if (orchestrationCancelRef.current) break;
@@ -635,6 +701,12 @@ export default function RoundtablePlanningPage() {
abortAllInFlight();
step2InitRef.current = false;
setIsOrchestrating(false);
setPendingClarification(null);
setClarificationDraft("");
// Re-entering step 2 should start with the discussion nominally
// "running" so the suspend button comes back; the prior visit might
// have ended in a paused state.
setIsRoundTableRunning(true);
return;
}
@@ -654,6 +726,23 @@ export default function RoundtablePlanningPage() {
agentNames: ROUNDTABLE_AGENT_IDS,
mainAgentName: coord,
model: DEFAULT_MODEL,
// As each sub-agent's thread comes online, flip every role that
// shares the agentId to "在线". Some seats reuse the same agent
// (e.g. seat 8 reuses roundtable-summary) so we match on agentId
// rather than a single id.
onSeatReady: ({ agentName }) => {
if (cancelled) return;
const id = agentName.toLowerCase();
setRoles(prev =>
prev.map(r => (r.agentId === id ? { ...r, status: "在线" } : r)),
);
},
// Coordinator readiness is a no-op for the role list (the
// coordinator isn't rendered as a seat), but we keep the hook in
// case we want to drop a "系统" toast later.
onCoordinatorReady: () => {
if (cancelled) return;
},
});
if (cancelled) return;
setThreadIds(initRes.thread_ids);
@@ -851,11 +940,15 @@ export default function RoundtablePlanningPage() {
};
// Step 2 role statuses transition when entering roundtable!
// On Step 2 entry we set every agent seat to "正在上线" (spinner state);
// the /init SSE stream then flips each one to "在线" as its thread/agent
// actually finishes on the backend. Seat 1 (human commander) stays online
// throughout — it's not part of the bring-up.
useEffect(() => {
if (currentStep === 1) {
setRoles(makeRoles("等待激活", { 2: "在线" }));
} else if (currentStep === 2) {
setRoles(makeRoles("在线", { 8: "等待陈述" }));
setRoles(makeRoles("正在上线", { 1: "在线" }));
} else {
setRoles(makeRoles("已完成"));
}
@@ -887,6 +980,36 @@ export default function RoundtablePlanningPage() {
void runOrchestration(`用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`, threadIds, coordinatorName);
};
// Submit the user's answer to the coordinator's clarification question and
// resume orchestration. The reply is fed back as the next leader prompt on
// the same coordinator thread (no re-init needed).
const submitClarification = (rawAnswer: string) => {
const answer = rawAnswer.trim();
if (!answer) return;
if (!threadIds || !coordinatorName) {
triggerToast("⚠️ 圆桌尚未就绪,无法回复澄清");
return;
}
if (isOrchestrating) {
triggerToast("⏳ 当前轮次仍在进行中,请稍候");
return;
}
const question = pendingClarification?.question || "(澄清问题)";
appendDialogue({
sender: "用户 (澄清回复)",
avatarType: "human",
content: `🗣 回复总控提问:${answer}`,
confidence: "澄清回复",
});
setPendingClarification(null);
setClarificationDraft("");
void runOrchestration(
`用户已回复你刚才的澄清问题。\n问题:${question}\n用户回答:${answer}\n请基于该回答继续派活或给出结论。`,
threadIds,
coordinatorName,
);
};
return (
<div
className={cn(
@@ -1182,13 +1305,18 @@ export default function RoundtablePlanningPage() {
{roles.map((role) => {
const isActive = role.status === "在线" || role.status === "研讨中" || role.status === "已完成";
const isOngoing = role.status === "研讨中";
const isBootingUp = role.status === "正在上线";
return (
<div
<div
id={`role-item-${role.id}`}
key={role.id}
key={role.id}
className={`flex items-center justify-between p-2 rounded-xl transition ${
isActive ? "bg-slate-50 border border-slate-100" : "opacity-60"
isActive
? "bg-slate-50 border border-slate-100"
: isBootingUp
? "bg-amber-50/60 border border-amber-100"
: "opacity-60"
}`}
>
<div className="flex items-center gap-2.5">
@@ -1229,18 +1357,22 @@ export default function RoundtablePlanningPage() {
</span>
) : role.status === "已完成" ? (
<Check className="h-3.5 w-3.5 text-emerald-500 font-bold" />
) : isBootingUp ? (
<Loader2 className="h-3 w-3 text-amber-500 animate-spin" />
) : (
<span className="h-1.5 w-1.5 rounded-full bg-slate-300" />
)}
<span className={`text-[10px] font-semibold ${
isOngoing
? "text-[var(--brand-900)]"
isOngoing
? "text-[var(--brand-900)]"
: role.status === "在线"
? "text-emerald-600 font-bold"
: role.status === "已完成"
? "text-emerald-600"
: "text-slate-400"
: isBootingUp
? "text-amber-600"
: "text-slate-400"
}`}>
{role.status}
</span>
@@ -1608,23 +1740,33 @@ export default function RoundtablePlanningPage() {
>极速</button>
</div>
{isRoundTableRunning ? (
<button
{/* Suspend / resume button visibility:
- Show "挂起协商" only while a run is actually in flight
(isOrchestrating + still nominally running). When the
discussion finishes naturally there's nothing to
suspend, so the button hides; it'll come back the
next time orchestration starts (user "干预研审" or
clarification reply).
- Show "恢复会商" any time the user has explicitly paused
(!isRoundTableRunning) regardless of orchestration
state, so they can always recover from a manual pause. */}
{isRoundTableRunning && isOrchestrating ? (
<button
onClick={() => { setIsRoundTableRunning(false); triggerToast("⏸️ 会商模拟暂挂!智能体挂起..."); }}
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-amber-200/40 shadow-sm"
>
<Pause className="w-3.5 h-3.5" />
挂起协商
</button>
) : (
<button
) : !isRoundTableRunning ? (
<button
onClick={() => { setIsRoundTableRunning(true); triggerToast("▶️ 恢复大模型并行博弈与推理决策"); }}
className="bg-emerald-100 hover:bg-emerald-200 text-emerald-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-emerald-200/40 shadow-sm"
>
<Play className="w-3.5 h-3.5 fill-emerald-800" />
恢复会商
</button>
)}
) : null}
</div>
</div>
@@ -1995,17 +2137,165 @@ 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 whitespace-pre-wrap break-words">
{msg.content}
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words roundtable-md">
{/* Pre-streaming hint — when the bubble is created but
no AI token has arrived yet, show a small spinner
+ the current phase label so the user can see the
backend is doing something (调度中 → 已连接 →
正在派活/调用工具 → 流式输出). */}
{msg.streaming && !msg.content && (
<div className="flex items-center gap-2 py-1 text-[11px] text-slate-500 italic">
<Loader2 className="w-3 h-3 animate-spin text-primary" />
<span>{msg.confidence}</span>
</div>
)}
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
// Compact spacing for chat bubbles — react-markdown's
// default block margins are too tall for this UI.
p: ({ children }) => <p className="my-1 whitespace-pre-wrap">{children}</p>,
ul: ({ children }) => <ul className="list-disc pl-5 my-1 space-y-0.5">{children}</ul>,
ol: ({ children }) => <ol className="list-decimal pl-5 my-1 space-y-0.5">{children}</ol>,
li: ({ children }) => <li className="leading-snug">{children}</li>,
h1: ({ children }) => <h1 className="text-sm font-bold mt-2 mb-1">{children}</h1>,
h2: ({ children }) => <h2 className="text-sm font-bold mt-2 mb-1">{children}</h2>,
h3: ({ children }) => <h3 className="text-xs font-bold mt-1.5 mb-0.5">{children}</h3>,
h4: ({ children }) => <h4 className="text-xs font-bold mt-1 mb-0.5">{children}</h4>,
strong: ({ children }) => <strong className="font-bold text-slate-900">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
code: ({ children, className }) => {
const isBlock = /language-/.test(className ?? "");
if (isBlock) {
return (
<pre className="bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto text-[11px] font-mono">
<code>{children}</code>
</pre>
);
}
return (
<code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[11px] font-mono">
{children}
</code>
);
},
blockquote: ({ children }) => (
<blockquote className="border-l-2 border-slate-300 pl-2 my-1 text-slate-600 italic">
{children}
</blockquote>
),
a: ({ children, href }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline hover:opacity-80"
>
{children}
</a>
),
table: ({ children }) => (
<table className="my-1 border-collapse text-[11px]">{children}</table>
),
th: ({ children }) => (
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">
{children}
</th>
),
td: ({ children }) => (
<td className="border border-slate-200 px-1.5 py-0.5">{children}</td>
),
hr: () => <hr className="my-2 border-slate-200" />,
}}
>
{msg.content ?? ""}
</ReactMarkdown>
{msg.streaming && (
<span className="inline-block w-1 h-3 ml-0.5 bg-primary align-middle animate-pulse" />
)}
</p>
</div>
</div>
</div>
))}
</div>
{/* Clarification card — shown only when the coordinator asked
the user a question via ask_clarification. The auto-loop is
paused; replying resumes orchestration on the same threads. */}
{pendingClarification && (
<div className="shrink-0 px-4 pt-3 bg-amber-50 border-t-2 border-amber-300">
<div className="flex items-start gap-3">
<HelpCircle className="w-5 h-5 text-amber-600 shrink-0 mt-0.5" />
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs font-extrabold text-amber-900">
总控协调需要澄清
</span>
{pendingClarification.clarificationType && (
<span className="text-[9px] font-mono font-black uppercase text-amber-900 bg-amber-200 border border-amber-300 px-1.5 py-0.2 rounded">
{pendingClarification.clarificationType}
</span>
)}
</div>
{pendingClarification.context && (
<p className="text-[11px] text-amber-800 italic">
{pendingClarification.context}
</p>
)}
<p className="text-xs text-slate-800 leading-relaxed font-medium whitespace-pre-wrap break-words">
{pendingClarification.question}
</p>
{Array.isArray(pendingClarification.options) &&
pendingClarification.options.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{pendingClarification.options.map((opt, idx) => {
const label =
typeof opt === "string"
? opt
: opt && typeof opt === "object" && "label" in (opt as object)
? String((opt as { label: unknown }).label ?? "")
: String(opt ?? "");
if (!label) return null;
return (
<button
key={`${idx}-${label}`}
type="button"
onClick={() => submitClarification(label)}
className="text-[11px] font-bold px-3 py-1.5 rounded-full bg-white border border-amber-300 text-amber-900 hover:bg-amber-100 transition active:scale-95 cursor-pointer"
>
{label}
</button>
);
})}
</div>
)}
{pendingClarification.allowCustom !== false && (
<div className="flex items-center gap-2 pt-1">
<input
type="text"
value={clarificationDraft}
onChange={(e) => setClarificationDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submitClarification(clarificationDraft);
}}
placeholder="输入你的回答…"
className="flex-1 bg-white border border-amber-300 rounded-xl px-3 py-2 text-xs focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-200 font-medium"
/>
<button
type="button"
onClick={() => submitClarification(clarificationDraft)}
disabled={!clarificationDraft.trim()}
className="bg-amber-600 hover:bg-amber-700 disabled:bg-slate-300 disabled:cursor-not-allowed text-white text-xs font-bold px-4 py-2 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-amber-500/10"
>
回复
</button>
</div>
)}
</div>
</div>
</div>
)}
{/* Console control / Custom injection bar */}
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex items-center gap-3">
<input