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 === 3 ? ( <> {/* Step 3 时只保留「参与与结论贡献」面板;原文件里其它两个 hidden 块未启用, @@ -849,41 +875,67 @@ function LeftSidebar({
- ) : ( - <> - {/* 任务概览 */} -
-
- -

任务概览

-
-
-
- - 当前节点 - -
- 初始任务 - - {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 {