876 lines
35 KiB
TypeScript
876 lines
35 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 } from "react";
|
|
import {
|
|
Cpu,
|
|
FileIcon,
|
|
HelpCircle,
|
|
Loader2,
|
|
Network,
|
|
Paperclip,
|
|
Pause,
|
|
Play,
|
|
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`;
|
|
}
|
|
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
|
|
/** 后台挂起入口 —— 暂时隐藏,恢复时改为 true。 */
|
|
const SHOW_BACKGROUND_SUSPEND = false;
|
|
|
|
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 { ParallelSeatStrip } from "@/roundtable-planning/components/ParallelSeatStrip";
|
|
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
|
|
import { useAutoScroll } from "@/roundtable-planning/hooks/useAutoScroll";
|
|
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 { 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[];
|
|
// ─── 并行显示(§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;
|
|
onResumeRoundtable: () => void;
|
|
onHandleInjectToRoundtable: (files?: File[]) => void;
|
|
onSubmitClarification: (answer: string) => void;
|
|
onSavePersona: (id: number) => void;
|
|
onProceedToStep3: () => void;
|
|
/** 「后台挂起」:把当前研讨转入后端自动跑到结果绘制完成。 */
|
|
onBackgroundSuspend: () => void;
|
|
onTriggerToast: (msg: string) => void;
|
|
}
|
|
|
|
export function Step2Panel(props: Step2PanelProps) {
|
|
const {
|
|
step2RoundtableDialogues,
|
|
isInitializing,
|
|
isRoundTableRunning,
|
|
isOrchestrating,
|
|
pendingClarification,
|
|
clarificationDraft,
|
|
customAgentInput,
|
|
hasConsensus,
|
|
agentConfigs,
|
|
selectedAgentId,
|
|
roles,
|
|
parallelBatch,
|
|
selectedParallelSeats,
|
|
isParallelRunning,
|
|
onToggleParallelSeat,
|
|
orchestrationMode,
|
|
orchestrationPlan,
|
|
dagNodeStates,
|
|
agentNameToRole,
|
|
selectedModel,
|
|
availableModels,
|
|
modelsLoading,
|
|
setSelectedModel,
|
|
activeArtifactThreadId,
|
|
seatStubMessages,
|
|
openArtifactFromStep,
|
|
openArtifactPreview,
|
|
setClarificationDraft,
|
|
setCustomAgentInput,
|
|
setAgentConfigs,
|
|
setSelectedAgentId,
|
|
onPauseRoundtable,
|
|
onResumeRoundtable,
|
|
onHandleInjectToRoundtable,
|
|
onSubmitClarification,
|
|
onSavePersona,
|
|
onProceedToStep3,
|
|
onBackgroundSuspend,
|
|
onTriggerToast,
|
|
} = props;
|
|
|
|
const step2ScrollRef = useRef<HTMLDivElement>(null);
|
|
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
|
|
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
|
|
|
|
// 并行进入/退出、或切换主区显示席位时,布局在「平铺列表 ↔ 两列/单列网格」之间切换,
|
|
// 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)──────────────────────────────────
|
|
const showDagFlow =
|
|
orchestrationMode === "dag" && (orchestrationPlan?.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],
|
|
);
|
|
// 总控 / 共识 / 报告里程碑状态:由节点状态 + 编排/共识标志派生。
|
|
const { dagCoordinatorState, dagConsensusState } = useMemo(() => {
|
|
const states = Object.values(dagNodeStates);
|
|
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 };
|
|
}, [dagNodeStates, isOrchestrating, hasConsensus]);
|
|
|
|
// Phase 7:DAG 流程图弹窗开关(点按钮查看,不再内联占空间)。
|
|
const [dagFlowOpen, setDagFlowOpen] = useState(false);
|
|
|
|
// 干预研审:记录指令并自动恢复会商,右上角按钮状态由 isRoundTableRunning 驱动
|
|
const [pauseFlash, setPauseFlash] = useState(false);
|
|
|
|
// 干预消息携带的本地附件(点「干预并发送」时连同指令一起上传)。
|
|
const [injectFiles, setInjectFiles] = useState<File[]>([]);
|
|
const injectFileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const handlePickInjectFiles = useCallback(
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const picked = Array.from(e.target.files ?? []);
|
|
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}`))];
|
|
});
|
|
}
|
|
e.target.value = "";
|
|
},
|
|
[],
|
|
);
|
|
|
|
const removeInjectFile = useCallback((idx: number) => {
|
|
setInjectFiles((prev) => prev.filter((_, i) => i !== idx));
|
|
}, []);
|
|
|
|
const handleInject = useCallback(() => {
|
|
onHandleInjectToRoundtable(injectFiles.length > 0 ? injectFiles : undefined);
|
|
setInjectFiles([]);
|
|
setPauseFlash(true);
|
|
window.setTimeout(() => setPauseFlash(false), 1600);
|
|
}, [onHandleInjectToRoundtable, injectFiles]);
|
|
|
|
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-[calc(10px*var(--ui-font-scale,1))] 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
|
|
type="button"
|
|
onClick={onResumeRoundtable}
|
|
className={`bg-emerald-100 hover:bg-emerald-200 text-emerald-800 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-emerald-200/40 shadow-sm ${
|
|
pauseFlash
|
|
? "ring-2 ring-emerald-400 ring-offset-1 animate-pulse"
|
|
: ""
|
|
}`}
|
|
>
|
|
<Play className="w-3.5 h-3.5 fill-emerald-800" />
|
|
恢复会商
|
|
</button>
|
|
) : (
|
|
<button
|
|
type="button"
|
|
onClick={onPauseRoundtable}
|
|
className="bg-amber-100 hover:bg-amber-200 text-amber-800 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-amber-200/40 shadow-sm"
|
|
>
|
|
<Pause className="w-3.5 h-3.5" />
|
|
暂停会商
|
|
</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">
|
|
{/* Phase 7:dag 模式 —— 流程图改成「点按钮看弹窗」,不再内联占空间(§7)。 */}
|
|
{showDagFlow && orchestrationPlan && (
|
|
<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">
|
|
分层编排执行中 · 共 {orchestrationPlan.stages.length} 个阶段
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setDagFlowOpen(true)}
|
|
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" />
|
|
查看编排流程图
|
|
</button>
|
|
</div>
|
|
)}
|
|
{showDagFlow && orchestrationPlan && (
|
|
<Dialog open={dagFlowOpen} onOpenChange={setDagFlowOpen}>
|
|
<DialogContent className="flex max-h-[88vh] w-[94vw] flex-col gap-0 sm:max-w-[1100px]">
|
|
<DialogHeader className="shrink-0">
|
|
<DialogTitle className="flex items-center gap-2">
|
|
<Network className="size-5 text-primary" />
|
|
分层编排流程图
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="min-h-0 flex-1 overflow-auto">
|
|
<DagFlowChart
|
|
stages={orchestrationPlan.stages}
|
|
resolveSeat={resolveDagSeat}
|
|
seatStates={dagNodeStates}
|
|
coordinatorState={dagCoordinatorState}
|
|
consensusState={dagConsensusState}
|
|
showReportMilestone={false}
|
|
/>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
)}
|
|
{/* 并行批次头像条(§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}
|
|
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>
|
|
)}
|
|
{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}
|
|
resolveAgentName={resolveAgentName}
|
|
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
|
|
onFileCardClick={(path) => {
|
|
if (msg.agentThreadId) openArtifactPreview(msg.agentThreadId, path);
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="py-6 text-center text-xs text-slate-400">
|
|
{seat.displayName} · 等待输出…
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
step2RoundtableDialogues.map((msg) => (
|
|
<Step2Message
|
|
key={msg.id}
|
|
msg={msg}
|
|
resolveAgentName={resolveAgentName}
|
|
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-[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-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 flex-col gap-2">
|
|
{/* 已选附件预览(点「干预并发送」时随指令上传) */}
|
|
{injectFiles.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{injectFiles.map((file, idx) => (
|
|
<span
|
|
key={`${file.name}-${file.size}-${idx}`}
|
|
className="inline-flex items-center gap-1.5 bg-white border border-slate-200 rounded-lg pl-2 pr-1 py-1 text-[11px] text-slate-600 max-w-[200px]"
|
|
>
|
|
<FileIcon className="w-3 h-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={() => removeInjectFile(idx)}
|
|
className="shrink-0 p-0.5 rounded hover:bg-slate-200 text-slate-400 hover:text-slate-600 transition"
|
|
title="移除"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="text"
|
|
value={customAgentInput}
|
|
onChange={(e) => setCustomAgentInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") handleInject();
|
|
}}
|
|
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"
|
|
/>
|
|
|
|
<input
|
|
ref={injectFileInputRef}
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
onChange={handlePickInjectFiles}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => injectFileInputRef.current?.click()}
|
|
title="上传参考文件(席位/总控将读取文件内容辅助研讨)"
|
|
className="bg-white border border-slate-200 hover:bg-slate-50 text-slate-500 hover:text-primary p-2.5 rounded-xl transition shrink-0 cursor-pointer active:scale-95"
|
|
>
|
|
<Paperclip className="w-4 h-4" />
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={handleInject}
|
|
title="记录干预指令并立即恢复会商执行"
|
|
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>
|
|
|
|
{SHOW_BACKGROUND_SUSPEND && (
|
|
<>
|
|
<div className="h-6 w-[1px] bg-slate-200 shrink-0" />
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onBackgroundSuspend}
|
|
title="把当前研讨转入后台自动跑到结果绘制完成;可离开页面,进度在历史记录里查看"
|
|
className="bg-white border border-slate-200 hover:bg-slate-50 text-slate-700 text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 cursor-pointer dark:bg-secondary dark:text-foreground dark:border-border"
|
|
>
|
|
后台挂起
|
|
</button>
|
|
</>
|
|
)}
|
|
|
|
<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" />
|
|
达成共识生成全案成果 >
|
|
</button>
|
|
</div>
|
|
</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。
|
|
*/
|
|
export interface Step2MessageProps {
|
|
msg: Step2Dialogue;
|
|
/** 把派活 chip 的 agent id 解析成中文席位名。 */
|
|
resolveAgentName?: (idOrName: string) => string;
|
|
onArtifactStepClick?: (
|
|
step: NonNullable<Step2Dialogue["steps"]>[number],
|
|
) => void;
|
|
/** 点击气泡下方 `present_files` 文件卡时打开沙箱(走真实 HTTP 路径)。 */
|
|
onFileCardClick?: (path: string) => void;
|
|
}
|
|
|
|
export function Step2Message({
|
|
msg,
|
|
resolveAgentName,
|
|
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}
|
|
resolveAgentName={resolveAgentName}
|
|
/>
|
|
)}
|
|
{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}
|
|
/>
|
|
)}
|
|
</MessageBubble>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 收集气泡里所有"写了文件"的产出路径,按出现顺序去重,聚合到一行文件卡片。
|
|
*
|
|
* 三类来源都算:
|
|
* - `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,
|
|
}: {
|
|
files: string[];
|
|
onClick?: (path: string) => void;
|
|
disabled: boolean;
|
|
}) {
|
|
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)}
|
|
/>
|
|
))}
|
|
</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="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 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>
|
|
<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>
|
|
<span className="roundtable-chat-meta text-slate-400">点击预览</span>
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|