抽出共用分析成果卡片,第一步展示第二、三步 Markdown 产物;智能体列表对齐岗位会商选择样式。第二步对话面板改为深蓝顶栏,第一步保留可配置返回按钮。 Co-authored-by: Cursor <cursoragent@cursor.com>
1233 lines
51 KiB
TypeScript
1233 lines
51 KiB
TypeScript
/**
|
||
* 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" />
|
||
达成共识生成全案成果 >
|
||
</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" />
|
||
达成共识生成全案成果 >
|
||
</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>
|
||
);
|
||
}
|