Files
deerflow-code/frontend-web/src/roundtable-planning/components/Step2Panel.tsx
T
董宏凯andCursor 5e1765d889 feat: 完善 8BF 会商第一步产物与智能体列表,第二步对话区对齐第一步样式
抽出共用分析成果卡片,第一步展示第二、三步 Markdown 产物;智能体列表对齐岗位会商选择样式。第二步对话面板改为深蓝顶栏,第一步保留可配置返回按钮。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 12:12:35 +08:00

1233 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Step 2 主面板 —— 多智能体圆桌研讨。
*
* 包含 4 块:
* 1) 顶部 Headline + 推理模型下拉 + 暂停/恢复按钮 + 后台挂起按钮(查看历史时隐藏)
* 2) Persona 抽屉(点击席位头像后展开,见 `PersonaDrawer.tsx`)
* 3) 对话流(初始化占位 + 气泡列表 + ChainOfThought 折叠卡 + markdown)
* 4) 底部澄清条 + 用户干预输入 + 「达成共识」入口
*
* 气泡通用渲染部分(头像 / header / loading 占位 / 流式 cursor / markdown 渲染)
* 复用 `MessageBubble.tsx` 暴露的原子件,与 Step 1 保持一致。
*
* 圆桌可视化 + 博弈边界调控台目前**未启用**(原代码用 `false &&` 隐藏),
* 这里保留同样的占位逻辑,后续要恢复时改 `SHOW_VISUALIZER` 即可。
* 「后台挂起」按钮同理,改 `SHOW_BACKGROUND_SUSPEND` 即可恢复。
*
* 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。
*/
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
Cpu,
FileIcon,
HelpCircle,
Loader2,
Network,
Paperclip,
Sparkles,
X,
} from "lucide-react";
/** 人类可读的文件大小(B / KB / MB)。 */
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
/** 后台挂起入口 —— 后端 _run_dag 已支持业务链分层并行,放出该入口。 */
const SHOW_BACKGROUND_SUSPEND = true;
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
import { useLocalSettings } from "@/core/settings";
import { cn } from "@/lib/utils";
import type { MessageMetric } from "@/components/workspace/messages/use-message-metrics";
import { DialogueSpeedMeter } from "@/roundtable-planning/components/DialogueSpeedMeter";
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
import { TaskArtifactSubmitButton } from "@/roundtable-planning/components/TaskArtifactSubmitButton";
import { getAvatar } from "@/roundtable-planning/components/Avatars";
import {
MarkdownContent,
MessageBubble,
MessageLoadingPlaceholder,
ReasoningBlock,
StreamingCursor,
} from "@/roundtable-planning/components/MessageBubble";
import { MessageStepsCard } from "@/roundtable-planning/components/MessageStepsCard";
import { SubtaskCard } from "@/components/workspace/messages/subtask-card";
import { useSubtask } from "@/core/tasks/context";
import { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeatStrip";
import { DagFlowMiniPanel } from "@/roundtable-planning/components/DagFlowMiniPanel";
import { useArtifacts } from "@/components/workspace/artifacts";
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import { useDialogueSpeedMetrics } from "@/roundtable-planning/hooks/useDialogueSpeedMetrics";
import {
seatFilterForSender,
seatFilterLabel,
senderMatchesSeatFilter,
type SeatFilter,
} from "@/roundtable-planning/lib/seat-filter";
import type {
AgentConfig,
ParallelSeatView,
Step2Dialogue,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import type { OrchestrationPlan } from "@/roundtable-planning/lib/dag-types";
import type { ChainNodeState } from "@/roundtable-planning/lib/job-types";
import {
getAvatarTypeFor,
type RoleEntry,
} from "@/roundtable-planning/lib/roundtable-constants";
import { desensitize } from "@/lib/desensitize";
import { useDesensitizeSubtree } from "@/roundtable-planning/lib/use-desensitize-subtree";
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
import type { TaskArtifactSubmissionController } from "@/roundtable-planning/lib/task-artifact-submission";
interface ModelOption {
name: string;
display_name?: string;
}
export interface Step2PanelProps {
/** 独立业务页需要与第一步完全相同的输入组件(8BF 专用,默认不改变原会商)。 */
useStep1InputAppearance?: boolean;
/** 8BF 第二步对话区标题,与第一步「业务链 · 意图识别」同一套顶栏。 */
headerTitle?: string;
/** 8BF 第二步顶栏右侧附加操作(如返回)。 */
headerRightSlot?: ReactNode;
// ─── State ────────────────────────────────────────────────────
step2RoundtableDialogues: Step2Dialogue[];
isInitializing: boolean;
isOrchestrating: boolean;
pendingClarification: ClarificationPayload | null;
clarificationDraft: string;
customAgentInput: string;
hasConsensus: boolean;
agentConfigs: Record<number, AgentConfig>;
selectedAgentId: number | null;
roles: RoleEntry[];
// ─── 按席位筛选会商记录(状态在页面层,与左侧角色卡片共用)──────────────
/** 当前筛选标识;null = 显示全部。 */
seatFilter: SeatFilter | null;
setSeatFilter: React.Dispatch<React.SetStateAction<SeatFilter | null>>;
// ─── 并行显示(§5)────────────────────────────────────────────
parallelBatch: ParallelSeatView[];
selectedParallelSeats: string[];
isParallelRunning: boolean;
onToggleParallelSeat: (agentId: string) => void;
// ─── Phase 7 DAG 流程图执行态总览(§7)─────────────────────────
/** 当前编排模式;=== "dag" 且 plan 非空时,顶部显示 DAG 流程图缩略。 */
orchestrationMode: "recommend" | "chain" | "dag";
/** dag 模式的分层计划(流程图按 stage 分列渲染)。 */
orchestrationPlan: OrchestrationPlan | null;
/** agentId(小写) → 节点状态;驱动流程图节点 active/done。 */
dagNodeStates: Record<string, ChainNodeState>;
/** agent id(小写)→ 席位元数据,用于把派活 chip 的 id 显示成中文席位名。 */
agentNameToRole: Record<
string,
{ speakerId: number; avatarType: string; displayName: string }
>;
selectedModel: string;
availableModels: ModelOption[];
modelsLoading: boolean;
setSelectedModel: React.Dispatch<React.SetStateAction<string>>;
activeArtifactThreadId: string | null;
seatStubMessages: Record<string, unknown[]>;
openArtifactFromStep: (
dialogue: Step2Dialogue,
step: NonNullable<Step2Dialogue["steps"]>[number],
) => void;
/** 主聊天里 `present_files` 会在气泡下方渲染卡片;Step 2 沿用同一交互。 */
openArtifactPreview: (threadId: string, path: string) => void;
// ─── Setters ──────────────────────────────────────────────────
setClarificationDraft: React.Dispatch<React.SetStateAction<string>>;
setCustomAgentInput: React.Dispatch<React.SetStateAction<string>>;
setAgentConfigs: React.Dispatch<
React.SetStateAction<Record<number, AgentConfig>>
>;
setSelectedAgentId: React.Dispatch<React.SetStateAction<number | null>>;
// ─── Handlers ─────────────────────────────────────────────────
onPauseRoundtable: () => void;
onHandleInjectToRoundtable: (files?: File[]) => void;
onSubmitClarification: (answer: string) => void;
onSavePersona: (id: number) => void;
onProceedToStep3: () => void;
/** 「后台挂起」:把当前研讨转入后端自动跑到结果绘制完成。 */
onBackgroundSuspend: () => void;
/** 是否在「查看历史记录」(从草稿/后台作业加载)—— 为 true 时隐藏「后台挂起」按钮。 */
isViewingHistory?: boolean;
onTriggerToast: (msg: string) => void;
/** 任务深链页面注入的外部过程产物提交控制器。 */
taskArtifactSubmission?: TaskArtifactSubmissionController;
}
export function Step2Panel(props: Step2PanelProps) {
const {
useStep1InputAppearance = false,
headerTitle,
headerRightSlot,
step2RoundtableDialogues,
isInitializing,
isOrchestrating,
pendingClarification,
clarificationDraft,
customAgentInput,
hasConsensus,
agentConfigs,
selectedAgentId,
roles,
seatFilter,
setSeatFilter,
parallelBatch,
selectedParallelSeats,
isParallelRunning,
onToggleParallelSeat,
orchestrationMode,
orchestrationPlan,
dagNodeStates,
agentNameToRole,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
activeArtifactThreadId,
seatStubMessages,
openArtifactFromStep,
openArtifactPreview,
setClarificationDraft,
setCustomAgentInput,
setAgentConfigs,
setSelectedAgentId,
onPauseRoundtable,
onHandleInjectToRoundtable,
onSubmitClarification,
onSavePersona,
onProceedToStep3,
onBackgroundSuspend,
isViewingHistory,
onTriggerToast,
taskArtifactSubmission,
} = props;
const step2ScrollRef = useRef<HTMLDivElement>(null);
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
// 度量始终计算(成本低、便于中途开启时也有时长基线),仅展示按开关门控。
const [localSettings] = useLocalSettings();
const speedMeterEnabled = localSettings.display.streamSpeedMeterEnabled;
const dialogueMetrics = useDialogueSpeedMetrics(step2RoundtableDialogues);
// ─── 按席位筛选会商记录 ──────────────────────────────────────────────────
// 筛选状态在页面层(与左侧「参与角色 / 人工参与」卡片共用)。点卡片或点气泡头像
// 都把目标归一化成同一 SeatFilter 标识(见 lib/seat-filter),命中 = 标识相等。
// 仅在单列(非并行)视图启用;并行进行时主区另有「头像条选席位」机制,互不干扰。
const toggleSeatFilter = useCallback(
(filter: SeatFilter) =>
setSeatFilter((cur) => (cur === filter ? null : filter)),
[setSeatFilter],
);
const visibleDialogues = useMemo(
() =>
seatFilter
? step2RoundtableDialogues.filter((d) =>
senderMatchesSeatFilter(d.sender, seatFilter),
)
: step2RoundtableDialogues,
[seatFilter, step2RoundtableDialogues],
);
// 选中的席位若已不在对话里(切换草稿 / 重置后),自动清除筛选,避免卡在空列表。
useEffect(() => {
if (
seatFilter &&
!step2RoundtableDialogues.some((d) =>
senderMatchesSeatFilter(d.sender, seatFilter),
)
) {
setSeatFilter(null);
}
}, [seatFilter, step2RoundtableDialogues, setSeatFilter]);
// 并行进入/退出、或切换主区显示席位时,布局在「平铺列表 ↔ 两列/单列网格」之间切换,
// scrollHeight 会突变、scrollTop 被浏览器 clamp → useAutoScroll 误判成「用户上滚」而停跟随
// (表现为并行时自动下滑失效)。这里在这些布局切换点**重新拉到底部 + 恢复自动跟随**。
useEffect(() => {
scrollStep2ToBottom();
}, [isParallelRunning, selectedParallelSeats, scrollStep2ToBottom]);
const resolveAgentName = useCallback(
(idOrName: string) =>
agentNameToRole[idOrName.toLowerCase()]?.displayName ?? idOrName,
[agentNameToRole],
);
// 并行两列:按 bubbleId 取席位流式气泡。固定 2 列(窄屏 1 列,见 CSS 断点)。
const dialogueById = useMemo(() => {
const m = new Map<number, Step2Dialogue>();
for (const d of step2RoundtableDialogues) m.set(d.id, d);
return m;
}, [step2RoundtableDialogues]);
// 主区两列要显示的席位:取被选中的(最多 2),回退到批次最先两个。
const parallelColumns = useMemo(() => {
if (!isParallelRunning) return [];
const pick = selectedParallelSeats.length > 0
? selectedParallelSeats
: parallelBatch.slice(0, 2).map((s) => s.agentId);
return pick
.map((agentId) => parallelBatch.find((s) => s.agentId === agentId))
.filter((s): s is ParallelSeatView => Boolean(s))
.slice(0, 2);
}, [isParallelRunning, selectedParallelSeats, parallelBatch]);
// ─── Phase 7 DAG 流程图执行态总览(§7)──────────────────────────────────
// 优化7(2026-06-13):推荐模式总控判定的**并行批**也能看流程图——把当前并行批
// 构造成一个临时单 stage 计划喂给小窗(dag 模式仍用真实 orchestrationPlan)。
const effectivePlan = useMemo<OrchestrationPlan | null>(() => {
if (orchestrationMode === "dag" && (orchestrationPlan?.stages.length ?? 0) > 0) {
return orchestrationPlan;
}
if (isParallelRunning && parallelBatch.length > 1) {
return {
mode: "dag",
stages: [{ id: "recommend-parallel", agentIds: parallelBatch.map((s) => s.agentId) }],
finalSynthesis: true,
} as OrchestrationPlan;
}
return null;
}, [orchestrationMode, orchestrationPlan, isParallelRunning, parallelBatch]);
const showDagFlow = (effectivePlan?.stages.length ?? 0) > 0;
const resolveDagSeat = useCallback(
(agentId: string, order: number) => {
const meta = agentNameToRole[agentId.toLowerCase()];
return {
name: meta?.displayName ?? agentId,
avatarType: meta?.avatarType ?? getAvatarTypeFor(agentId, order),
};
},
[agentNameToRole],
);
// 有效节点状态(2026-06-13):live 编排时 dagNodeStates 实时推进;**查看聊天记录**
// (加载草稿 hydrate)时它是空的 → 从对话推导——已有「子智能体交付」内容的席位
// 点亮为 done,其余 pending。live 状态优先覆盖推导值(active 实时转圈不受影响)。
const effectiveDagNodeStates = useMemo(() => {
const merged: Record<string, ChainNodeState> = {};
const deliveredNames = new Set(
step2RoundtableDialogues
.filter(
(d) => d.confidence === "子智能体交付" && String(d.content ?? "").trim().length > 0,
)
.map((d) => String(d.sender ?? "")),
);
for (const [aid, meta] of Object.entries(agentNameToRole)) {
if (deliveredNames.has(meta.displayName)) merged[aid.toLowerCase()] = "done";
}
// 推荐并行批:running 席位点亮为 active、done 席位为 done(小窗实时转圈/打勾)。
for (const s of parallelBatch) {
merged[s.agentId.toLowerCase()] = s.status === "done" ? "done" : "active";
}
for (const [aid, st] of Object.entries(dagNodeStates)) merged[aid.toLowerCase()] = st;
return merged;
}, [dagNodeStates, step2RoundtableDialogues, agentNameToRole, parallelBatch]);
// 总控 / 共识 / 报告里程碑状态:由有效节点状态 + 编排/共识标志派生。
const { dagCoordinatorState, dagConsensusState } = useMemo(() => {
const states = Object.values(effectiveDagNodeStates);
const anyActive = states.includes("active");
const anyStarted = states.some((s) => s !== "pending");
const allDone = states.length > 0 && states.every((s) => s === "done");
const coordinator: ChainNodeState = anyActive
? "done"
: isOrchestrating
? "active"
: anyStarted
? "done"
: "pending";
const consensus: ChainNodeState = hasConsensus
? "done"
: allDone
? "active"
: "pending";
return { dagCoordinatorState: coordinator, dagConsensusState: consensus };
}, [effectiveDagNodeStates, isOrchestrating, hasConsensus]);
// DAG 流程图小窗开关(2026-06-12:弹窗 → 消息列表右上角竖向小窗,直角连线)。
// DAG 编排一启动自动打开一次;沙箱打开时自动让位关闭;按钮可随时手动开/关。
const [dagFlowOpen, setDagFlowOpen] = useState(false);
const { open: artifactsOpenForFlow } = useArtifacts();
const dagFlowAutoOpenedRef = useRef(false);
useEffect(() => {
if (!showDagFlow) {
dagFlowAutoOpenedRef.current = false;
return;
}
if (!dagFlowAutoOpenedRef.current) {
dagFlowAutoOpenedRef.current = true;
// 沙箱已占着右侧就不抢(保持关闭,按钮可开)。
if (!artifactsOpenForFlow) setDagFlowOpen(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showDagFlow]);
// 沙箱打开(出现产物预览)→ 自动关闭小窗腾出注意力;**边沿触发**,
// 不会在沙箱保持打开期间反复压制用户手动重开的小窗。
const prevArtifactsOpenRef = useRef(artifactsOpenForFlow);
useEffect(() => {
if (artifactsOpenForFlow && !prevArtifactsOpenRef.current) setDagFlowOpen(false);
prevArtifactsOpenRef.current = artifactsOpenForFlow;
}, [artifactsOpenForFlow]);
// 干预消息携带的本地附件(点「干预并发送」时连同指令一起上传)。
const [injectFiles, setInjectFiles] = useState<File[]>([]);
const addInjectFiles = useCallback(
(picked: File[]) => {
if (picked.length > 0) {
setInjectFiles((prev) => {
const seen = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
});
}
},
[],
);
const removeInjectFile = useCallback((idx: number) => {
setInjectFiles((prev) => prev.filter((_, i) => i !== idx));
}, []);
const handleInject = useCallback(() => {
onHandleInjectToRoundtable(injectFiles.length > 0 ? injectFiles : undefined);
setInjectFiles([]);
}, [onHandleInjectToRoundtable, injectFiles]);
const isSandboxStreaming =
isOrchestrating ||
step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true);
// 顶部「会商运行」状态在等待用户输入时仍可能保持 true,不能据此把发送键误显示成暂停。
// 仅在初始化或实际流式输出时,沿用第一步输入框的暂停态。
const isStep2Responding = isInitializing || isSandboxStreaming;
// 顶部标题栏整片脱敏(消息列表正文不在此容器,保持原文)。
const headerDzRef = useDesensitizeSubtree<HTMLDivElement>();
const chatBody = (
<>
{/* Headline */}
<div
ref={headerDzRef}
className={
useStep1InputAppearance
? "shrink-0 flex items-center justify-between gap-3"
: "shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm"
}
>
{useStep1InputAppearance ? (
<div className="position-roundtable-intent-header-title">
<span aria-hidden="true" />
<h2>{headerTitle || "多智能体会商"}</h2>
</div>
) : (
<div className="flex items-center gap-2">
<Cpu className="w-5 h-5 text-primary" strokeWidth={2.5} />
<div>
<h3 className="text-sm font-extrabold text-slate-900">多智能体圆桌会商中心</h3>
<p className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400 font-medium">
分布式领域专家实时动态博弈与纳什协商收敛中
</p>
</div>
</div>
)}
<div
className={
useStep1InputAppearance
? "position-roundtable-intent-header-actions"
: "flex items-center gap-2.5 shrink-0"
}
>
{!useStep1InputAppearance && (
<select
id="step2-global-model-select"
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={modelsLoading || availableModels.length === 0}
title={
modelsLoading
? "正在加载模型列表…"
: availableModels.length === 0
? "未在 config.yaml 中配置任何模型"
: "切换全局推理模型"
}
className="max-w-[220px] bg-slate-50/50 border border-slate-200 rounded-xl px-3 py-1.5 text-xs font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
>
{availableModels.length === 0 && (
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
)}
{availableModels.map((m) => (
<option key={m.name} value={m.name}>
{m.display_name && m.display_name !== m.name
? `${m.display_name} (${m.name})`
: m.name}
</option>
))}
</select>
)}
{/* 后台挂起:查看历史记录时隐藏(isViewingHistory),
重新从第一步进入第二步研讨时(isViewingHistory 为假)正常显示。 */}
{SHOW_BACKGROUND_SUSPEND && !isViewingHistory && (
<button
type="button"
onClick={onBackgroundSuspend}
title="把当前研讨转入后台自动跑到结果绘制完成;可离开页面,进度在历史记录里查看"
className={
useStep1InputAppearance
? "eightbf-roundtable__suspend-button"
: "bg-slate-100 hover:bg-slate-200 text-slate-700 text-[calc(10px*var(--ui-font-scale,1))] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-slate-200/60 shadow-sm dark:bg-secondary dark:text-foreground dark:border-border"
}
>
{!useStep1InputAppearance && <Network className="w-3.5 h-3.5" />}
后台挂起
</button>
)}
{headerRightSlot}
</div>
</div>
{/* Persona drawer — 点圆桌头像后展开。当前圆桌可视化用 `false &&` 隐藏,
所以入口只剩 LeftSidebar 或将来重新启用的可视化按钮。 */}
{selectedAgentId && (
<PersonaDrawer
seatId={selectedAgentId}
roles={roles}
agentConfigs={agentConfigs}
setAgentConfigs={setAgentConfigs}
selectedModel={selectedModel}
availableModels={availableModels}
modelsLoading={modelsLoading}
onClose={() => setSelectedAgentId(null)}
onSave={onSavePersona}
/>
)}
{/* Conversation log */}
<div className="relative flex-1 min-h-0 flex flex-col">
{/* 流程图小窗开关条(dag 编排 / 推荐并行批都显示)。 */}
{showDagFlow && effectivePlan && (
<div className="shrink-0 flex items-center justify-between gap-2 border-t border-slate-150 bg-slate-50/60 px-6 py-2">
<span className="text-xs font-medium text-slate-500">
{orchestrationMode === "dag"
? `分层编排执行中 · 共 ${effectivePlan.stages.length} 个阶段`
: `并行执行中 · ${effectivePlan.stages[0]?.agentIds.length ?? 0} 个席位`}
</span>
<button
type="button"
onClick={() => setDagFlowOpen((v) => !v)}
className="inline-flex items-center gap-1.5 rounded-lg border border-primary/30 bg-primary/5 px-2.5 py-1 text-xs font-semibold text-primary transition hover:bg-primary/10"
>
<Network className="size-3.5" />
{dagFlowOpen ? "收起流程图" : "编排流程图"}
</button>
</div>
)}
{/* 流程图小窗:消息列表右上角(开关条下方)悬浮,竖向直角连线流程图。
支持滚轮缩放/拖动平移/移动窗口/边缘缩放(组件内自管几何)。
沙箱打开时自动关闭(见上方互斥 effect),点按钮可随时重开。 */}
{showDagFlow && effectivePlan && dagFlowOpen && (
<DagFlowMiniPanel
stages={effectivePlan.stages}
resolveSeat={resolveDagSeat}
seatStates={effectiveDagNodeStates}
coordinatorState={dagCoordinatorState}
consensusState={dagConsensusState}
onClose={() => setDagFlowOpen(false)}
className="z-30"
/>
)}
{/* 并行批次头像条(§5.2):消息列表上方,点头像切换主区两列显示的席位。 */}
{isParallelRunning && (
<div className="shrink-0 px-6 py-2 bg-white border-t border-slate-150">
<ParallelSeatStrip
batch={parallelBatch}
selectedSeats={selectedParallelSeats}
onToggleSeat={onToggleParallelSeat}
/>
</div>
)}
<div
ref={step2ScrollRef}
// overflow-x-hidden:消息列表只竖向滚。否则 `overflow-y-auto` 单写时按 CSS 规范
// 另一轴会被计算成 auto,任何子元素溢出一两像素都会让整列横向可滚 → 鼠标移到右侧
// 竖滚条滑块时极易误触发横向拖动、抓不住滑块。宽内容(代码块/表格)在各自气泡内
// 已有 max-w-full + overflow-x-auto 自带横滚,故此处裁掉横向不影响查看。
className={cn(
"flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-6 space-y-4",
useStep1InputAppearance
? "eightbf-roundtable-step2-log"
: "bg-white border-t border-slate-150 border-b border-slate-150 shadow-inner",
)}
>
{isInitializing && step2RoundtableDialogues.length === 0 && (
<div className="flex h-full flex-col items-center justify-center gap-3 py-12 text-slate-500">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
<div className="text-sm font-medium text-slate-700">正在初始化圆桌...</div>
<div className="text-xs text-slate-400">正在为各席位建立独立线程,请稍候</div>
</div>
)}
{isParallelRunning && parallelColumns.length > 0 ? (
// 并行进行时:主区固定 2 列(窄屏降 1 列),每列渲染一个被选中席位的消息流。
// 未选中席位的气泡仍在 step2RoundtableDialogues 内存里(只是此刻不渲染),
// 切换头像即可立刻看到其全部输出(§6 全接收/惰性渲染的基础)。
<div
className={
parallelColumns.length > 1
? "grid grid-cols-1 gap-4 lg:grid-cols-2"
: "grid grid-cols-1 gap-4"
}
>
{parallelColumns.map((seat) => {
const msg = dialogueById.get(seat.bubbleId);
return (
<div
key={seat.agentId}
className="flex min-w-0 flex-col rounded-xl border border-slate-200 bg-slate-50/40 p-3"
>
{msg ? (
<Step2Message
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
resolveAgentName={resolveAgentName}
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) openArtifactPreview(msg.agentThreadId, path);
}}
taskArtifactSubmission={taskArtifactSubmission}
/>
) : (
<div className="py-6 text-center text-xs text-slate-400">
{desensitize(seat.displayName)} · 等待输出…
</div>
)}
</div>
);
})}
</div>
) : (
<>
{/* 席位筛选提示条:点卡片 / 头像只看某席位发言时出现,可一键显示全部。 */}
{seatFilter && (
<div className="sticky top-0 z-10 mb-1 flex items-center justify-between gap-2 rounded-lg border border-primary/20 bg-[var(--brand-200)]/20 px-3 py-1.5 backdrop-blur-sm">
<span className="roundtable-chat-meta font-medium text-slate-600">
仅显示{" "}
<span className="font-bold text-primary">
{desensitize(seatFilterLabel(seatFilter))}
</span>{" "}
的发言
</span>
<button
type="button"
onClick={() => setSeatFilter(null)}
className="roundtable-chat-meta inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-0.5 font-semibold text-primary transition hover:bg-primary/10"
>
<X className="size-3.5" />
显示全部
</button>
</div>
)}
{seatFilter && visibleDialogues.length === 0 ? (
<div className="py-10 text-center text-xs text-slate-400">
没有匹配「{desensitize(seatFilterLabel(seatFilter))}」的消息
</div>
) : (
visibleDialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
metric={speedMeterEnabled ? dialogueMetrics.get(msg.id) : undefined}
isAvatarSelected={
seatFilter !== null &&
senderMatchesSeatFilter(msg.sender, seatFilter)
}
onAvatarClick={() =>
toggleSeatFilter(seatFilterForSender(msg.sender))
}
resolveAgentName={resolveAgentName}
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) {
openArtifactPreview(msg.agentThreadId, path);
}
}}
taskArtifactSubmission={taskArtifactSubmission}
/>
))
)}
</>
)}
</div>
{!step2AtBottom && (
<ScrollToBottomButton
onClick={scrollStep2ToBottom}
className="!bottom-1/2 translate-y-1/2"
/>
)}
</div>
{/* Clarification card */}
{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-[calc(9px*var(--ui-font-scale,1))] 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="roundtable-chat-message text-amber-800 italic">{pendingClarification.context}</p>
)}
<p className="roundtable-chat-message text-slate-800 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={() => onSubmitClarification(label)}
className="roundtable-chat-message 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-end gap-2 pt-1">
<AutoGrowTextarea
value={clarificationDraft}
onChange={(e) => setClarificationDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmitClarification(clarificationDraft);
}
}}
placeholder="输入你的回答…(Enter 发送 / Shift+Enter 换行)"
className="flex-1 bg-white border border-amber-300 rounded-xl px-3 py-2 text-xs leading-relaxed focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-200 font-medium"
maxHeight={280}
/>
<button
type="button"
onClick={() => onSubmitClarification(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>
)}
{useStep1InputAppearance ? (
/* 8BF 直接复用第一步 PositionIntentInput 的 DOM、图标和深蓝框体;仅替换成会商干预的状态与动作。 */
<div className="position-roundtable-intent-page eightbf-roundtable-step2-input [&_#suggest-followup-btn]:hidden [&_#skip-question-btn]:hidden [&_#save-draft-btn]:hidden">
<PositionIntentInput
value={customAgentInput}
onValueChange={setCustomAgentInput}
attachedFiles={injectFiles}
onAddFiles={addInjectFiles}
onRemoveFile={removeInjectFile}
charactersLeft={Math.max(0, 500 - customAgentInput.length)}
isSending={isStep2Responding}
canSend={customAgentInput.trim().length > 0 || injectFiles.length > 0}
pendingClarification={false}
onSend={handleInject}
onStop={onPauseRoundtable}
onFollowUp={() => undefined}
onSkip={() => undefined}
onEnterRoundtable={() => undefined}
enterButtonLabel=""
intentReady={null}
hideEnterRoundtable
onManualSave={() => undefined}
onTriggerToast={() => undefined}
modelOptions={availableModels.map((model) => ({
label: model.display_name && model.display_name !== model.name
? `${model.display_name} (${model.name})`
: model.name,
value: model.name,
}))}
selectedModel={selectedModel}
onSelectedModelChange={setSelectedModel}
footerAction={(
/* 与第一步「确认意图并启动业务链」复用相同的稳定 id 和样式规则。 */
<button
id="enter-roundtable-btn"
type="button"
onClick={onProceedToStep3}
disabled={!hasConsensus}
aria-disabled={!hasConsensus}
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
className={cn(
"flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] px-3 text-xs font-black transition active:scale-[0.98] shadow-md shadow-emerald-500/10",
hasConsensus
? "cursor-pointer bg-emerald-600 text-white hover:bg-emerald-700"
: "cursor-not-allowed bg-emerald-600/40 text-white",
)}
>
<Sparkles className="size-3.5 text-emerald-100" />
达成共识生成全案成果 &gt;
</button>
)}
/>
</div>
) : (
<Step2LegacyComposer
value={customAgentInput}
onValueChange={setCustomAgentInput}
files={injectFiles}
onAddFiles={addInjectFiles}
onRemoveFile={removeInjectFile}
onSend={handleInject}
hasConsensus={hasConsensus}
onProceed={onProceedToStep3}
/>
)}
</>
);
const activeSeatMessages = activeArtifactThreadId
? seatStubMessages[activeArtifactThreadId]
: undefined;
return (
<Step2SandboxLayout
activeThreadId={activeArtifactThreadId}
isStreaming={isSandboxStreaming}
seatMessages={activeSeatMessages}
taskArtifactSubmission={taskArtifactSubmission}
>
<div
className={cn(
"flex h-full min-h-0 flex-col",
useStep1InputAppearance
? "position-roundtable-intent-panel eightbf-roundtable-intent-panel eightbf-roundtable-step2-chat"
: "bg-slate-50/20",
)}
>
{chatBody}
</div>
</Step2SandboxLayout>
);
}
/** 原通用会商的输入区。8BF 使用第一步复用组件时,其他会商页面保持此既有结构。 */
function Step2LegacyComposer({
value,
onValueChange,
files,
onAddFiles,
onRemoveFile,
onSend,
hasConsensus,
onProceed,
}: {
value: string;
onValueChange: React.Dispatch<React.SetStateAction<string>>;
files: File[];
onAddFiles: (files: File[]) => void;
onRemoveFile: (index: number) => void;
onSend: () => void;
hasConsensus: boolean;
onProceed: () => void;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<div className="shrink-0 flex flex-col gap-2 border-t border-slate-100 bg-slate-50 p-4">
{files.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{files.map((file, index) => (
<span
key={`${file.name}-${file.size}-${index}`}
className="inline-flex max-w-[200px] items-center gap-1.5 rounded-lg border border-slate-200 bg-white py-1 pl-2 pr-1 text-[11px] text-slate-600"
>
<FileIcon className="size-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>{file.name}</span>
<span className="shrink-0 text-slate-400">{formatFileSize(file.size)}</span>
<button
type="button"
onClick={() => onRemoveFile(index)}
className="shrink-0 rounded p-0.5 text-slate-400 transition hover:bg-slate-200 hover:text-slate-600"
title="移除"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
<div className="flex items-end gap-3">
<AutoGrowTextarea
value={value}
onChange={(event) => onValueChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
onSend();
}
}}
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预...(Enter 发送 / Shift+Enter 换行)"
className="min-h-[48px] flex-1 rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-xs font-medium leading-relaxed focus:border-primary focus:outline-none focus:ring-1 focus:ring-[var(--brand-200)]/50"
maxHeight={280}
/>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
onAddFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
title="上传参考文件(席位/总控将读取文件内容辅助研讨)"
className="shrink-0 cursor-pointer rounded-xl border border-slate-200 bg-white p-2.5 text-slate-500 transition hover:bg-slate-50 hover:text-primary active:scale-95"
>
<Paperclip className="size-4" />
</button>
<button
type="button"
onClick={onSend}
title="记录干预指令并立即恢复会商执行"
className="shrink-0 cursor-pointer rounded-xl bg-primary px-4 py-2.5 text-xs font-bold text-white shadow-md shadow-primary/10 transition hover:bg-[var(--brand-900)] active:scale-95"
>
干预并发送
</button>
<button
type="button"
onClick={onProceed}
disabled={!hasConsensus}
aria-disabled={!hasConsensus}
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
className={cn(
"flex shrink-0 items-center gap-1 rounded-xl px-4 py-2.5 text-xs font-black shadow-md shadow-emerald-500/10 transition active:scale-95",
hasConsensus
? "cursor-pointer bg-emerald-600 text-white hover:bg-emerald-700"
: "cursor-not-allowed bg-emerald-600/40 text-white",
)}
>
<Sparkles className="size-3.5 text-emerald-100" />
达成共识生成全案成果 &gt;
</button>
</div>
</div>
);
}
/**
* 单条 Step 2 对话气泡。包含:
* - ChainOfThought 折叠卡(若 msg.steps 非空)
* - 流式无内容的 loading 占位
* - markdown 正文 + 流式 cursor
*
* 「插话干预」气泡有特殊红底,通过 containerClassName 传给 MessageBubble。
*/
export interface Step2MessageProps {
msg: Step2Dialogue;
/**
* 本条气泡的「回答速度」度量(tok/s + 时长)。仅对流式过的模型发言有值;
* 由调用方按全局开关 streamSpeedMeterEnabled 决定是否传入(关则传 undefined)。
*/
metric?: MessageMetric;
/**
* 点击头像的回调 —— 传入则头像变为可点的「按席位筛选」开关。不传(并行两列等
* 场景)则头像为纯展示,保持原样。
*/
onAvatarClick?: () => void;
/** 当前筛选是否正命中本气泡的席位(命中则头像高亮)。 */
isAvatarSelected?: boolean;
/** 把派活 chip 的 agent id 解析成中文席位名。 */
resolveAgentName?: (idOrName: string) => string;
onArtifactStepClick?: (
step: NonNullable<Step2Dialogue["steps"]>[number],
) => void;
/** 点击气泡下方 `present_files` 文件卡时打开沙箱(走真实 HTTP 路径)。 */
onFileCardClick?: (path: string) => void;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}
export function Step2Message({
msg,
metric,
onAvatarClick,
isAvatarSelected = false,
resolveAgentName,
onArtifactStepClick,
onFileCardClick,
taskArtifactSubmission,
}: Step2MessageProps) {
const isInjected = msg.sender === "用户 (插话干预)";
const isStreaming = msg.streaming === true;
// 抽出 `<think>...</think>` 作 reasoning;剩下的是要渲染的正文。
// 用户「插话干预」/「澄清回复」这类系统注入的气泡内容里基本不会有 think 标签,
// 但 splitInlineReasoning 对空 reasoning 文本返回 null,放心套用就好。
const rawContent = msg.content ?? "";
const { content, reasoning } = splitInlineReasoning(rawContent);
const hasContent = content.length > 0;
const presentedFiles = collectPresentedFiles(msg.steps);
const avatarInner = getAvatar(msg.avatarType, "h-9 w-9 shrink-0");
// 传了 onAvatarClick → 头像变成「按席位筛选」开关:点亮当前筛选席位,带光圈高亮。
const avatar = onAvatarClick ? (
<button
type="button"
onClick={onAvatarClick}
aria-pressed={isAvatarSelected}
title={
isAvatarSelected
? "取消筛选,显示全部发言"
: `只看 ${desensitize(msg.sender)} 的发言`
}
className={cn(
"shrink-0 cursor-pointer rounded-full transition hover:opacity-80 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
isAvatarSelected && "ring-2 ring-primary ring-offset-1 ring-offset-white",
)}
>
{avatarInner}
</button>
) : (
avatarInner
);
return (
<MessageBubble
avatar={avatar}
senderName={msg.sender}
badge={msg.confidence}
time={msg.time}
containerClassName={
isInjected
? "bg-[var(--brand-200)]/30 p-3 rounded-2xl border border-red-100/50"
: undefined
}
>
{Array.isArray(msg.steps) && msg.steps.length > 0 && (
<MessageStepsCard
steps={msg.steps}
streaming={isStreaming}
onArtifactStepClick={onArtifactStepClick}
resolveAgentName={resolveAgentName}
/>
)}
{/* 「多智能体问答」模式:本席位派生的子智能体任务卡片(复用主聊天 SubtaskCard,
从共享 SubtaskContext 取状态)。同时发起多个 task → 多张卡片纵向堆叠。 */}
{Array.isArray(msg.subtaskIds) && msg.subtaskIds.length > 0 && (
<div className="flex w-full flex-col gap-2">
{msg.subtaskIds.map((taskId) => (
<RoundtableSubtaskCard key={taskId} taskId={taskId} isLoading={isStreaming} />
))}
</div>
)}
{reasoning && (
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
)}
<div className="roundtable-chat-message text-slate-700 font-medium break-words">
{isStreaming && !hasContent && <MessageLoadingPlaceholder label={msg.confidence} />}
<MarkdownContent>{content}</MarkdownContent>
{isStreaming && <StreamingCursor />}
</div>
{presentedFiles.length > 0 && (
<PresentFilesRow
files={presentedFiles}
onClick={onFileCardClick}
disabled={!msg.agentThreadId}
threadId={msg.agentThreadId}
taskArtifactSubmission={taskArtifactSubmission}
/>
)}
{metric && <DialogueSpeedMeter metric={metric} />}
</MessageBubble>
);
}
/**
* 子智能体任务卡片包裹层:子任务内容是**实时态**,存放在共享 SubtaskContext(主聊天同一份);
* 草稿回显 / 历史记录等场景气泡上虽留有 subtaskId,但 context 里已无对应 Subtask —— 此时
* 返回 null 不渲染(而非让 SubtaskCard 的 `useSubtask(id)!` 取到 undefined 崩溃)。
*/
function RoundtableSubtaskCard({
taskId,
isLoading,
}: {
taskId: string;
isLoading: boolean;
}) {
const task = useSubtask(taskId);
if (!task) return null;
return <SubtaskCard taskId={taskId} isLoading={isLoading} />;
}
/**
* 收集气泡里所有"写了文件"的产出路径,按出现顺序去重,聚合到一行文件卡片。
*
* 三类来源都算:
* - `present_files`:总控呈现文件,args.filepaths 是数组(可一次多个);
* - `write_file` / `str_replace`:子智能体被后端 excluded_tools 禁用了 present_files,
* 只能 write_file 写文件,单文件 path 在 args.path —— 也聚合进卡片,保证"凡是写了
* 文件就出可点的卡片",而不是只有 present_files 才出。
*/
function collectPresentedFiles(
steps: Step2Dialogue["steps"],
): string[] {
if (!Array.isArray(steps) || steps.length === 0) return [];
const out: string[] = [];
const seen = new Set<string>();
const add = (fp: unknown) => {
if (typeof fp !== "string" || !fp || seen.has(fp)) return;
seen.add(fp);
out.push(fp);
};
for (const step of steps) {
if (step.key === "tool_calling:present_files") {
const fps = step.args?.filepaths;
if (Array.isArray(fps)) fps.forEach(add);
continue;
}
if (
step.key === "tool_calling:write_file" ||
step.key === "tool_calling:str_replace"
) {
add(step.args?.path);
}
}
return out;
}
function PresentFilesRow({
files,
onClick,
disabled,
threadId,
taskArtifactSubmission,
}: {
files: string[];
onClick?: (path: string) => void;
disabled: boolean;
threadId?: string;
taskArtifactSubmission?: TaskArtifactSubmissionController;
}) {
return (
<div className="mt-2 flex min-w-0 flex-wrap gap-2">
{files.map((path) => (
<PresentFileCard
key={path}
path={path}
disabled={disabled || !onClick}
onClick={() => onClick?.(path)}
submitButton={threadId ? (
<TaskArtifactSubmitButton
controller={taskArtifactSubmission}
target={taskArtifactSubmission?.targetFor({ threadId, path }) ?? { threadId, path }}
/>
) : null}
/>
))}
</div>
);
}
const FILE_EXT_LABELS: Record<string, string> = {
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,
submitButton,
}: {
path: string;
disabled: boolean;
onClick: () => void;
submitButton?: ReactNode;
}) {
const filename = path.split("/").pop() ?? path;
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
const typeLabel = FILE_EXT_LABELS[ext] ?? (ext.toUpperCase() || "FILE");
return (
<div className="box-border flex w-[12.5rem] max-w-full min-w-0 flex-col gap-1 overflow-hidden rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-primary/50 hover:bg-slate-50">
<button
type="button"
onClick={onClick}
disabled={disabled}
title={disabled ? "席位线程未就绪,暂无法预览" : path}
className="flex min-w-0 flex-col gap-1 text-left disabled:cursor-not-allowed disabled:opacity-60"
>
<div className="flex min-w-0 items-start gap-2">
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span
className="roundtable-chat-message min-w-0 flex-1 truncate font-medium text-slate-700"
title={filename}
>
{filename}
</span>
</div>
</button>
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="roundtable-chat-meta rounded bg-slate-100 px-1.5 py-0.5 font-mono font-semibold text-slate-500">
{typeLabel}
</span>
{submitButton}
</div>
</div>
);
}