Files
deerflow-code/frontend-web/src/roundtable-planning/components/Step2Panel.tsx
T
2026-05-28 18:29:10 +08:00

555 lines
21 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` 即可。
*
* 状态全部来自 `useStep2Orchestration` —— 本组件不调任何后端、不持久化。
*/
import { useRef } from "react";
import {
Cpu,
FileIcon,
HelpCircle,
Loader2,
Pause,
Play,
Sparkles,
} from "lucide-react";
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
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 { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
import type {
AgentConfig,
Step2Dialogue,
} from "@/roundtable-planning/hooks/useStep2Orchestration";
import type {
RoleEntry,
} from "@/roundtable-planning/lib/roundtable-constants";
import { splitInlineReasoning } from "@/roundtable-planning/lib/reasoning";
import type { ClarificationPayload } from "@/roundtable-planning/api/multi-agent";
interface ModelOption {
name: string;
display_name?: string;
}
export interface Step2PanelProps {
// ─── State ────────────────────────────────────────────────────
step2RoundtableDialogues: Step2Dialogue[];
isInitializing: boolean;
isRoundTableRunning: boolean;
isOrchestrating: boolean;
pendingClarification: ClarificationPayload | null;
clarificationDraft: string;
customAgentInput: string;
hasConsensus: boolean;
agentConfigs: Record<number, AgentConfig>;
selectedAgentId: number | null;
roles: RoleEntry[];
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 ──────────────────────────────────────────────────
setIsRoundTableRunning: React.Dispatch<React.SetStateAction<boolean>>;
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 ─────────────────────────────────────────────────
onSubmitClarification: (answer: string) => void;
onInjectToRoundtable: () => void;
onSavePersona: (id: number) => void;
onProceedToStep3: () => void;
onTriggerToast: (msg: string) => void;
}
export function Step2Panel(props: Step2PanelProps) {
const {
step2RoundtableDialogues,
isInitializing,
isRoundTableRunning,
isOrchestrating,
pendingClarification,
clarificationDraft,
customAgentInput,
hasConsensus,
agentConfigs,
selectedAgentId,
roles,
selectedModel,
availableModels,
modelsLoading,
setSelectedModel,
activeArtifactThreadId,
seatStubMessages,
openArtifactFromStep,
openArtifactPreview,
setIsRoundTableRunning,
setClarificationDraft,
setCustomAgentInput,
setAgentConfigs,
setSelectedAgentId,
onSubmitClarification,
onInjectToRoundtable,
onSavePersona,
onProceedToStep3,
onTriggerToast,
} = props;
const step2ScrollRef = useRef<HTMLDivElement>(null);
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
const isSandboxStreaming =
isOrchestrating ||
step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true);
const chatBody = (
<>
{/* Headline */}
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm">
<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-[10px] text-slate-400 font-medium">
分布式领域专家实时动态博弈与纳什协商收敛中
</p>
</div>
</div>
<div className="flex items-center gap-2.5 shrink-0">
<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>
{isRoundTableRunning ? (
<button
onClick={() => {
setIsRoundTableRunning(false);
onTriggerToast("⏸️ 会商已暂停");
}}
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-amber-200/40 shadow-sm"
>
<Pause className="w-3.5 h-3.5" />
暂停会商
</button>
) : (
<button
onClick={() => {
setIsRoundTableRunning(true);
onTriggerToast("▶️ 恢复大模型并行博弈与推理决策");
}}
className="bg-emerald-100 hover:bg-emerald-200 text-emerald-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-emerald-200/40 shadow-sm"
>
<Play className="w-3.5 h-3.5 fill-emerald-800" />
恢复会商
</button>
)}
</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">
<div
ref={step2ScrollRef}
className="flex-1 min-h-0 overflow-y-auto p-6 space-y-4 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>
)}
{step2RoundtableDialogues.map((msg) => (
<Step2Message
key={msg.id}
msg={msg}
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
onFileCardClick={(path) => {
if (msg.agentThreadId) {
openArtifactPreview(msg.agentThreadId, path);
}
}}
/>
))}
</div>
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
</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-[9px] font-mono font-black uppercase text-amber-900 bg-amber-200 border border-amber-300 px-1.5 py-0.2 rounded">
{pendingClarification.clarificationType}
</span>
)}
</div>
{pendingClarification.context && (
<p className="text-[11px] text-amber-800 italic">{pendingClarification.context}</p>
)}
<p className="text-xs text-slate-800 leading-relaxed font-medium whitespace-pre-wrap break-words">
{pendingClarification.question}
</p>
{Array.isArray(pendingClarification.options) &&
pendingClarification.options.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{pendingClarification.options.map((opt, idx) => {
const label =
typeof opt === "string"
? opt
: opt && typeof opt === "object" && "label" in (opt as object)
? String((opt as { label: unknown }).label ?? "")
: String(opt ?? "");
if (!label) return null;
return (
<button
key={`${idx}-${label}`}
type="button"
onClick={() => onSubmitClarification(label)}
className="text-[11px] font-bold px-3 py-1.5 rounded-full bg-white border border-amber-300 text-amber-900 hover:bg-amber-100 transition active:scale-95 cursor-pointer"
>
{label}
</button>
);
})}
</div>
)}
{pendingClarification.allowCustom !== false && (
<div className="flex items-center gap-2 pt-1">
<input
type="text"
value={clarificationDraft}
onChange={(e) => setClarificationDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmitClarification(clarificationDraft);
}}
placeholder="输入你的回答…"
className="flex-1 bg-white border border-amber-300 rounded-xl px-3 py-2 text-xs focus:outline-none focus:border-amber-500 focus:ring-1 focus:ring-amber-200 font-medium"
/>
<button
type="button"
onClick={() => 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>
)}
{/* Console + intervention bar */}
<div className="shrink-0 p-4 bg-slate-50 border-t border-slate-100 flex items-center gap-3">
<input
type="text"
value={customAgentInput}
onChange={(e) => setCustomAgentInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onInjectToRoundtable();
}}
placeholder="作为指挥官,您可以随时插话输入指令,对智能体博弈朝向实施人工干预..."
className="flex-1 bg-white border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 font-medium"
/>
<button
onClick={onInjectToRoundtable}
className="bg-primary hover:bg-[var(--brand-900)] text-white text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95 shadow-md shadow-primary/10"
>
干预研审
</button>
<div className="h-6 w-[1px] bg-slate-200 shrink-0" />
<button
type="button"
onClick={onProceedToStep3}
disabled={!hasConsensus}
aria-disabled={!hasConsensus}
title={hasConsensus ? undefined : "圆桌尚未达成共识,请等待总控自然收敛"}
className={`text-xs font-black px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 shadow-md shadow-emerald-500/10 ${
hasConsensus
? "bg-emerald-600 hover:bg-emerald-700 text-white cursor-pointer"
: "bg-emerald-600/40 text-white cursor-not-allowed"
}`}
>
<Sparkles className="w-3.5 h-3.5 text-emerald-100" />
达成共识生成全案成果 &gt;
</button>
</div>
</>
);
const activeSeatMessages = activeArtifactThreadId
? seatStubMessages[activeArtifactThreadId]
: undefined;
return (
<Step2SandboxLayout
activeThreadId={activeArtifactThreadId}
isStreaming={isSandboxStreaming}
seatMessages={activeSeatMessages}
>
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">{chatBody}</div>
</Step2SandboxLayout>
);
}
/**
* 单条 Step 2 对话气泡。包含:
* - ChainOfThought 折叠卡(若 msg.steps 非空)
* - 流式无内容的 loading 占位
* - markdown 正文 + 流式 cursor
*
* 「插话干预」气泡有特殊红底,通过 containerClassName 传给 MessageBubble。
*/
interface Step2MessageProps {
msg: Step2Dialogue;
onArtifactStepClick?: (
step: NonNullable<Step2Dialogue["steps"]>[number],
) => void;
/** 点击气泡下方 `present_files` 文件卡时打开沙箱(走真实 HTTP 路径)。 */
onFileCardClick?: (path: string) => void;
}
function Step2Message({
msg,
onArtifactStepClick,
onFileCardClick,
}: 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);
return (
<MessageBubble
avatar={getAvatar(msg.avatarType, "h-9 w-9 shrink-0")}
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}
/>
)}
{reasoning && (
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
)}
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words roundtable-md">
{isStreaming && !hasContent && <MessageLoadingPlaceholder label={msg.confidence} />}
<MarkdownContent>{content}</MarkdownContent>
{isStreaming && <StreamingCursor />}
</div>
{presentedFiles.length > 0 && (
<PresentFilesRow
files={presentedFiles}
onClick={onFileCardClick}
disabled={!msg.agentThreadId}
/>
)}
</MessageBubble>
);
}
/**
* 扫描所有 `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<string>();
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 (
<div className="mt-2 flex flex-wrap gap-2">
{files.map((path) => (
<PresentFileCard
key={path}
path={path}
disabled={disabled || !onClick}
onClick={() => onClick?.(path)}
/>
))}
</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,
}: {
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 (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={disabled ? "席位线程未就绪,暂无法预览" : path}
className="flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-primary/50 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
>
<div className="flex items-start gap-2">
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span
className="truncate text-[12px] font-medium text-slate-700"
title={filename}
>
{filename}
</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-slate-500">
{typeLabel}
</span>
<span className="text-[10px] text-slate-400">点击预览</span>
</div>
</button>
);
}