From ff90cac755a33e433b561168be39be9b22a9cd55 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?=
Date: Wed, 27 May 2026 15:30:51 +0800
Subject: [PATCH 1/4] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=BF=AE=E6=94=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../components/Step2Panel.tsx | 31 +-
.../pages/RoundtablePlanningPage.tsx | 544 +++++++++++++-----
2 files changed, 432 insertions(+), 143 deletions(-)
diff --git a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx
index 4fbbb315..b872c93c 100644
--- a/frontend-web/src/roundtable-planning/components/Step2Panel.tsx
+++ b/frontend-web/src/roundtable-planning/components/Step2Panel.tsx
@@ -2,7 +2,7 @@
* Step 2 主面板 —— 多智能体圆桌研讨。
*
* 包含 4 块:
- * 1) 顶部 Headline + 挂起/恢复按钮
+ * 1) 顶部 Headline + 推理模型下拉 + 挂起/恢复按钮
* 2) Persona 抽屉(点击席位头像后展开,见 `PersonaDrawer.tsx`)
* 3) 对话流(初始化占位 + 气泡列表 + ChainOfThought 折叠卡 + markdown)
* 4) 底部澄清条 + 用户干预输入 + 「达成共识」入口
@@ -68,6 +68,7 @@ export interface Step2PanelProps {
selectedModel: string;
availableModels: ModelOption[];
modelsLoading: boolean;
+ setSelectedModel: React.Dispatch>;
// ─── Setters ──────────────────────────────────────────────────
setIsRoundTableRunning: React.Dispatch>;
setClarificationDraft: React.Dispatch>;
@@ -100,6 +101,7 @@ export function Step2Panel(props: Step2PanelProps) {
selectedModel,
availableModels,
modelsLoading,
+ setSelectedModel,
setIsRoundTableRunning,
setClarificationDraft,
setCustomAgentInput,
@@ -129,7 +131,32 @@ export function Step2Panel(props: Step2PanelProps) {
-
+
+
{isRoundTableRunning && isOrchestrating ? (
>
- ) : (
- <>
- {/* 任务概览 */}
-
-
-
-
任务概览
-
-
-
-
- 当前节点
-
-
- 初始任务
-
- {currentStep === 1 ? "待澄清" : currentStep === 2 ? "研讨中" : "已规划交付"}
-
-
-
- 围绕某复杂任务制定规划方案
-
+ ) : currentStep === 2 ? (
+ sidebarExpanded ? (
+ <>
+
setSidebarExpanded((v) => !v),
+ }}
+ />
+
+
+
+
+
参与角色
+
+
+ {roles.map((role) => (
+
+ ))}
-
- {/* 参与角色 */}
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+
+ {roles.map((role) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+ ) : (
+ <>
+
+
@@ -896,32 +948,317 @@ function LeftSidebar({
- {/* 人工参与 */}
-
-
-
-
-
- 用户
-
-
-
-
-
-
- 在线
-
-
-
+
>
)}
);
}
+function TaskOverviewCard({ currentStep }: { currentStep: number }) {
+ return (
+
+
+
+
任务概览
+
+
+
+
+ 当前节点
+
+
+ 初始任务
+
+ {currentStep === 1 ? "待澄清" : currentStep === 2 ? "研讨中" : "已规划交付"}
+
+
+
+ 围绕某复杂任务制定规划方案
+
+
+
+
+ );
+}
+
+function HumanParticipationCard() {
+ return (
+
+
+
+
+
+ 用户
+
+
+
+
+
+
+ 在线
+
+
+
+ );
+}
+
+interface ModelSelectorCardProps {
+ selectedModel: string;
+ setSelectedModel: React.Dispatch
>;
+ availableModels: ReadonlyArray<{ name: string; display_name?: string }>;
+ modelsLoading: boolean;
+}
+
+function ModelSelectorCard({
+ selectedModel,
+ setSelectedModel,
+ availableModels,
+ modelsLoading,
+}: ModelSelectorCardProps) {
+ return (
+
+
+
+
推理模型
+
+
+
+ );
+}
+
+interface IntentSummaryCardProps {
+ intentReady: ReturnType["intentReady"];
+ title?: string;
+ /** Step 2 侧栏: 收起时仅展示目标,展开后显示约束与假设。 */
+ collapsible?: boolean;
+ /** 侧栏整体收起按钮,放在标题栏右侧。 */
+ sidebarToggle?: {
+ expanded: boolean;
+ onToggle: () => void;
+ };
+}
+
+function IntentSummaryCard({
+ intentReady,
+ title = "当前意图摘要",
+ collapsible = false,
+ sidebarToggle,
+}: IntentSummaryCardProps) {
+ const [expanded, setExpanded] = useState(!collapsible);
+ const showFullDetail = !collapsible || expanded;
+
+ return (
+
+
+
+
+
{title}
+
+ {sidebarToggle && (
+
+ )}
+
+
+
+ {intentReady ? (
+ <>
+
+ -
+ •
+
+ 目标: {intentReady.objective}
+
+
+
+ {collapsible && (
+
+ )}
+ {showFullDetail && (
+
+ {intentReady.constraints.map((c, i) => (
+ -
+ •
+
+ 约束: {c}
+
+
+ ))}
+ {(intentReady.assumptions ?? []).map((a, i) => (
+ -
+ •
+
+ 假设: {a}
+
+
+ ))}
+
+ )}
+ >
+ ) : (
+
+ 等待任务理解智能体澄清完成...
+
+ )}
+
+
+ );
+}
+
+function IntentSectionIcon({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function CollapsedSidebarSection({
+ title,
+ children,
+ className,
+}: {
+ title: string;
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function renderRoleAvatar(
+ role: RoleEntry,
+ agentNameToRole: Record<
+ string,
+ { speakerId: number; avatarType: string; displayName: string }
+ >,
+ size: string,
+) {
+ if (role.type === "human") {
+ return ;
+ }
+ return getAvatar(
+ role.agentId ? agentNameToRole[role.agentId]?.avatarType ?? "blue" : "blue",
+ size,
+ );
+}
+
+function RoleAvatarBadge({
+ role,
+ agentNameToRole,
+ size = "h-7 w-7",
+ showSpeakingAnimation = false,
+}: {
+ role: RoleEntry;
+ agentNameToRole: Record<
+ string,
+ { speakerId: number; avatarType: string; displayName: string }
+ >;
+ size?: string;
+ showSpeakingAnimation?: boolean;
+}) {
+ const isOngoing = role.status === "研讨中";
+ const animate = showSpeakingAnimation && isOngoing;
+
+ return (
+
+ {animate && (
+
+ )}
+
+ {renderRoleAvatar(role, agentNameToRole, size)}
+
+
+ );
+}
+
function RoleRow({
role,
agentNameToRole,
@@ -949,14 +1286,7 @@ function RoleRow({
}`}
>
- {role.type === "human" ? (
-
- ) : (
- getAvatar(
- role.agentId ? agentNameToRole[role.agentId]?.avatarType ?? "blue" : "blue",
- "h-7 w-7",
- )
- )}
+
{role.name}
{role.type === "agent" && (
@@ -1024,89 +1354,21 @@ function RightSidebar({
timelineEntries,
triggerToast,
}: RightSidebarProps) {
- if (currentStep === 3) return null;
+ // Step 2 侧栏内容已迁至左侧;Step 3 沿用原逻辑不展示右侧栏。
+ if (currentStep !== 1) return null;
return (
diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts
index 4a661971..a3b45de2 100644
--- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts
+++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts
@@ -280,6 +280,9 @@ export function useStep2Orchestration(
const orchestrationCancelRef = useRef(false);
const step2InitRef = useRef(false);
const activeAbortersRef = useRef>(new Set());
+ /** 干预研审:暂停当前轮次后待执行的 leader prompt。 */
+ const pendingInjectPromptRef = useRef(null);
+ const [injectFlushToken, setInjectFlushToken] = useState(0);
// selectedModel / agentConfigs / agentNameToRole / threadIds / coordinatorName
// 等等被 runOrchestration 在循环里反复读取。把它们打到 ref 上,可以让
// runOrchestration 的 useCallback deps 大幅收窄,避免每次模型切换、agentConfigs
@@ -1043,7 +1046,11 @@ export function useStep2Orchestration(
useEffect(() => {
if (currentStep !== 2) return;
if (!isRoundTableRunning) {
- stopOrchestration();
+ // 干预研审会先写入 pending 再置 false;此时 stop 已在 handler 里触发,
+ // 避免 effect 再次 stop 误杀随后 flush 启动的 inject 编排。
+ if (!pendingInjectPromptRef.current) {
+ stopOrchestration();
+ }
return;
}
if (
@@ -1061,6 +1068,21 @@ export function useStep2Orchestration(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isRoundTableRunning]);
+ // 干预研审:当前轮次停止后 flush 待执行的 inject 编排(编排期间 UI 仍为「恢复会商」)。
+ useEffect(() => {
+ if (isOrchestrating) return;
+ const pending = pendingInjectPromptRef.current;
+ if (!pending) return;
+ if (!threadIdsRef.current || !coordinatorNameRef.current) return;
+ pendingInjectPromptRef.current = null;
+ triggerToast("您的人工纠偏指令已同步至圆桌节点!");
+ void runOrchestration(
+ pending,
+ threadIdsRef.current,
+ coordinatorNameRef.current,
+ );
+ }, [isOrchestrating, injectFlushToken, runOrchestration, triggerToast]);
+
// ─── Public handlers ─────────────────────────────────────────────
const handleInjectToRoundtable = useCallback(() => {
if (!customAgentInput.trim()) return;
@@ -1068,28 +1090,31 @@ export function useStep2Orchestration(
triggerToast("⚠️ 圆桌尚未就绪,请稍候");
return;
}
- if (isOrchestratingRef.current) {
- triggerToast("⏳ 当前轮次仍在进行中,请等待或暂停后再发送");
- return;
- }
const userText = customAgentInput.trim();
setCustomAgentInput("");
+ const prompt = `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`;
+ pendingInjectPromptRef.current = prompt;
+
+ // 与 Step2Panel 右上角「暂停会商 / 恢复会商」联动:立即进入挂起态。
+ setIsRoundTableRunning(false);
+ stopOrchestration();
+
appendDialogue({
sender: "用户 (插话干预)",
avatarType: "human",
content: `💡 (人工指令介入) ${userText}`,
confidence: "最高指令",
});
- triggerToast("您的人工纠偏指令已同步至圆桌节点!");
- void runOrchestration(
- `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`,
- threadIdsRef.current,
- coordinatorNameRef.current,
- );
- }, [appendDialogue, customAgentInput, runOrchestration, triggerToast]);
+ if (isOrchestratingRef.current) {
+ triggerToast("⏸️ 会商已暂停,当前轮次结束后将处理您的干预指令");
+ } else {
+ triggerToast("⏸️ 会商已暂停,正在处理您的干预指令…");
+ }
+ setInjectFlushToken((n) => n + 1);
+ }, [appendDialogue, customAgentInput, stopOrchestration, triggerToast]);
const submitClarification = useCallback(
(rawAnswer: string) => {
@@ -1237,6 +1262,7 @@ export function useStep2Orchestration(
setArtifacts([]);
seatStubMessagesRef.current = {};
setSeatStubMessages({});
+ pendingInjectPromptRef.current = null;
deselectArtifact();
step2InitRef.current = false;
}, [abortAllInFlight, deselectArtifact, setArtifacts]);
diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
index a598f0cc..63f8e4e1 100644
--- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
+++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
@@ -351,37 +351,41 @@ export default function RoundtablePlanningPage() {
新建任务
-
+ {currentStep !== 2 && (
+ <>
+
-
+
+ >
+ )}
@@ -377,9 +386,15 @@ interface Step2MessageProps {
onArtifactStepClick?: (
step: NonNullable
[number],
) => void;
+ /** 点击气泡下方 `present_files` 文件卡时打开沙箱(走真实 HTTP 路径)。 */
+ onFileCardClick?: (path: string) => void;
}
-function Step2Message({ msg, onArtifactStepClick }: Step2MessageProps) {
+function Step2Message({
+ msg,
+ onArtifactStepClick,
+ onFileCardClick,
+}: Step2MessageProps) {
const isInjected = msg.sender === "用户 (插话干预)";
const isStreaming = msg.streaming === true;
// 抽出 `...` 作 reasoning;剩下的是要渲染的正文。
@@ -388,6 +403,7 @@ function Step2Message({ msg, onArtifactStepClick }: Step2MessageProps) {
const rawContent = msg.content ?? "";
const { content, reasoning } = splitInlineReasoning(rawContent);
const hasContent = content.length > 0;
+ const presentedFiles = collectPresentedFiles(msg.steps);
return (
{content}
{isStreaming && }
+
+ {presentedFiles.length > 0 && (
+
+ )}
);
}
+
+/**
+ * 扫描所有 `tool_calling:present_files` 步骤里的 `filepaths`,按出现顺序去重。
+ * 同一气泡可能多次 present_files(总控分批交付),都聚合到一行卡片里。
+ */
+function collectPresentedFiles(
+ steps: Step2Dialogue["steps"],
+): string[] {
+ if (!Array.isArray(steps) || steps.length === 0) return [];
+ const out: string[] = [];
+ const seen = new Set();
+ for (const step of steps) {
+ if (step.key !== "tool_calling:present_files") continue;
+ const fps = step.args?.filepaths;
+ if (!Array.isArray(fps)) continue;
+ for (const fp of fps) {
+ if (typeof fp !== "string" || !fp || seen.has(fp)) continue;
+ seen.add(fp);
+ out.push(fp);
+ }
+ }
+ return out;
+}
+
+function PresentFilesRow({
+ files,
+ onClick,
+ disabled,
+}: {
+ files: string[];
+ onClick?: (path: string) => void;
+ disabled: boolean;
+}) {
+ return (
+
+ {files.map((path) => (
+
onClick?.(path)}
+ />
+ ))}
+
+ );
+}
+
+const FILE_EXT_LABELS: Record = {
+ md: "Markdown",
+ json: "JSON",
+ csv: "CSV",
+ txt: "TXT",
+ py: "Python",
+ js: "JS",
+ ts: "TS",
+ tsx: "TSX",
+ jsx: "JSX",
+ html: "HTML",
+ css: "CSS",
+ xml: "XML",
+ yaml: "YAML",
+ yml: "YAML",
+ pdf: "PDF",
+ png: "PNG",
+ jpg: "JPG",
+ jpeg: "JPEG",
+ svg: "SVG",
+ zip: "ZIP",
+};
+
+function PresentFileCard({
+ path,
+ disabled,
+ onClick,
+}: {
+ path: string;
+ disabled: boolean;
+ onClick: () => void;
+}) {
+ const filename = path.split("/").pop() ?? path;
+ const ext = filename.split(".").pop()?.toLowerCase() ?? "";
+ const typeLabel = FILE_EXT_LABELS[ext] ?? (ext.toUpperCase() || "FILE");
+ return (
+
+ );
+}
diff --git a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts
index a3b45de2..7758b912 100644
--- a/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts
+++ b/frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts
@@ -91,6 +91,12 @@ export type Step2Snapshot = {
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
+ /**
+ * 每个 seat thread 的 stub ai messages —— 加载草稿后点击历史 write_file 步骤,
+ * 沙箱要能复读 args.content。不存这部分 stub 的话,虚拟 URL 解析会拿不到内容。
+ * 老草稿可能没有这字段,反序列化时按空对象兜底。
+ */
+ seatStubMessages: Record;
};
/**
@@ -514,6 +520,39 @@ export function useStep2Orchestration(
],
);
+ /**
+ * 兜底:从 LangGraph messages-tuple 帧里扫描完整 tool_calls,把 args 持续灌进 stub。
+ * 与 `handleArtifactToolPhase` 相比:
+ * - 后者依赖 `onPhaseChange` 在 phase==="tool_calling" 时回调,首次出现 path 即触发自动打开;
+ * - 前者覆盖「phase 已切到 tool_result / streaming 后,后续帧又带回完整 tool_calls」的边界,
+ * 保证沙箱 MD 始终拿到 args.content 的最新版本(LangGraph 在收口阶段仍可能再发一次)。
+ * 不做自动打开 —— 那是 handleArtifactToolPhase 的职责;这里只做 args 同步。
+ */
+ const handleUpstreamFrame = useCallback(
+ (frame: unknown, seatThreadId: string | undefined) => {
+ if (!seatThreadId) return;
+ if (!Array.isArray(frame) || frame.length === 0) return;
+ const chunk = frame[0];
+ if (!chunk || typeof chunk !== "object") return;
+ const obj = chunk as Record;
+ const toolCalls = obj.tool_calls;
+ if (!Array.isArray(toolCalls) || toolCalls.length === 0) return;
+ for (const tc of toolCalls) {
+ if (!tc || typeof tc !== "object") continue;
+ const tcObj = tc as Record;
+ const name = typeof tcObj.name === "string" ? tcObj.name : "";
+ if (name !== "write_file" && name !== "str_replace") continue;
+ const tcArgs = tcObj.args;
+ if (!tcArgs || typeof tcArgs !== "object") continue;
+ const argsObj = tcArgs as Record;
+ const path = argsObj.path;
+ if (typeof path !== "string" || !path) continue;
+ upsertSeatToolCall(seatThreadId, name, argsObj);
+ }
+ },
+ [upsertSeatToolCall],
+ );
+
// ─── Dialogue / Speaker helpers ────────────────────────────────────
const appendDialogue = useCallback(
(card: {
@@ -890,6 +929,8 @@ export function useStep2Orchestration(
seatThreadId,
);
},
+ onUpstreamFrame: (frame) =>
+ handleUpstreamFrame(frame, seatThreadId),
});
if (orchestrationCancelRef.current) {
finalizeDialogue(
@@ -958,6 +999,7 @@ export function useStep2Orchestration(
clearAllStreamingSpeakers,
triggerToast,
handleArtifactToolPhase,
+ handleUpstreamFrame,
resolveSeatThreadId,
],
);
@@ -1236,6 +1278,9 @@ export function useStep2Orchestration(
setHasConsensus(snapshot.hasConsensus);
setConsensusPercentage(snapshot.consensusPercentage);
setBudgetLimit(snapshot.budgetLimit);
+ const restoredStubs = snapshot.seatStubMessages ?? {};
+ seatStubMessagesRef.current = restoredStubs;
+ setSeatStubMessages(restoredStubs);
// 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上,
// 调用方需要的话再 resetInitGate()。
step2InitRef.current = true;
diff --git a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
index 63f8e4e1..0b2f6842 100644
--- a/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
+++ b/frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
@@ -194,6 +194,7 @@ export default function RoundtablePlanningPage() {
hasConsensus: step2.hasConsensus,
consensusPercentage: step2.consensusPercentage,
budgetLimit: step2.budgetLimit,
+ seatStubMessages: step2.seatStubMessages,
}),
hydrateStep1: step1.hydrateFromDraft,
hydrateStep2: step2.hydrateFromDraft,
@@ -494,6 +495,7 @@ export default function RoundtablePlanningPage() {
activeArtifactThreadId={step2.activeArtifactThreadId}
seatStubMessages={step2.seatStubMessages}
openArtifactFromStep={step2.openArtifactFromStep}
+ openArtifactPreview={step2.openArtifactPreview}
setIsRoundTableRunning={step2.setIsRoundTableRunning}
setClarificationDraft={step2.setClarificationDraft}
setCustomAgentInput={step2.setCustomAgentInput}
diff --git a/frontend-web/src/roundtable-planning/utils/drafts.ts b/frontend-web/src/roundtable-planning/utils/drafts.ts
index ce0b97c2..bcb4a6f3 100644
--- a/frontend-web/src/roundtable-planning/utils/drafts.ts
+++ b/frontend-web/src/roundtable-planning/utils/drafts.ts
@@ -35,6 +35,11 @@ export interface DraftStep2Snapshot {
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
+ /**
+ * Per-seat stub AI messages used by the sandbox `write-file:` virtual URL
+ * loader. Older drafts won't have this — callers should default to `{}`.
+ */
+ seatStubMessages?: Record;
}
export interface DraftRecord {