feat(圆桌): Step3 三产物并行 + 大屏多页模式 + 后台挂起查看恢复流程 后台挂起 Step3: - engine 总结报告/结果绘制/大屏数据 asyncio.gather 三流并行,故障隔离 - request.state 回归修复(进程内虚拟 request 补 state),恢复后台报告 - ROUNDTABLE_JOB_GATEWAY 默认 inprocess;产物自愈重试(截断/缺失纠偏) - 大屏后端只产 report-json(dashboardJson),前端按当前主题渲染 结果绘制大屏多页模式(模式二): - report-template 模板渲染(report-json→大屏HTML),零截断、主题跟随 - 与模式一同款交互(流式+沙箱+可修改);沙箱新窗口打开按钮 最终方案展示:左侧席位栏+总控头像、交付/文件合并、收展动画、内联沙箱 后台挂起查看=恢复实时流程: - handleLoadDraft 返回 Promise,await hydrate 后订阅 streamJob SSE 跟随 - saveDraftNow 强制存(发起前+查看前各一次)防丢 第二步优化:总控判断串/并行(并行单列可写)、席位上下文+交付模板、 peerDeliveries、失败重试、共识标记、流程图小窗(缩放/拖动/并行批) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> @
502 lines
20 KiB
TypeScript
502 lines
20 KiB
TypeScript
/**
|
||
* 派活链路流程图弹窗(Phase 7)。
|
||
*
|
||
* 大弹窗 + 横向流程图:总控 ──▶ 席位1 ──▶ 席位2 ──▶ … ──▶[共识]──▶[报告绘制]。
|
||
* - 节点 = 智能体席位(头像 + 名称),按 `getAvatarTypeFor` 取不同颜色;
|
||
* - 正在发言的节点(state==='active')和它**左侧的连接线**带加载动画(脉冲光环 + 流动虚线);
|
||
* - 已完成节点打勾,未开始节点置灰;
|
||
* - footer 显示 轮次/状态 + 「查看」按钮 → 进入正在进行的任务(onView)。
|
||
*
|
||
* 本组件是**纯展示组件**:所有状态由 props 传入。Phase 8 会接 `streamJob` 把后台 SSE
|
||
* 进度喂进来;当前可由 `DispatchChainPreviewPage` 用假数据 + 定时器模拟驱动预览。
|
||
*
|
||
* 颜色 / 头像复用圆桌既有体系:`getAvatar`(Avatars.tsx)+ `chainTagClasses`
|
||
* (business-chain.ts)。连线动画 CSS 在 `styles/roundtable-planning.css` 全局段
|
||
* (Radix Dialog portal 到 body,不在 .roundtable-planning 作用域内,故用全局类 + 语义 token)。
|
||
*/
|
||
import { Fragment, useEffect, useRef, useState } from "react";
|
||
import { CheckIcon, ChevronRightIcon, EyeIcon, Loader2Icon, SendIcon } from "lucide-react";
|
||
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
import { getAvatar } from "./Avatars";
|
||
import { DagFlowChart } from "./DagFlowChart";
|
||
import { MarkdownContent } from "./MessageBubble";
|
||
import { chainTagClasses } from "../lib/business-chain";
|
||
import type { OrchestrationStage } from "../lib/dag-types";
|
||
import { getAvatarTypeFor } from "../lib/roundtable-constants";
|
||
import type {
|
||
ChainNodeState,
|
||
DispatchChainNode,
|
||
JobDialogue,
|
||
JobPhase,
|
||
JobStatus,
|
||
} from "../lib/job-types";
|
||
|
||
export interface DispatchChainModalProps {
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
/** 任务标题。 */
|
||
title?: string;
|
||
status: JobStatus;
|
||
phase: JobPhase;
|
||
/** 当前轮次(0..maxCycles)。 */
|
||
cycle: number;
|
||
/** 最大轮次(默认 8,对齐 MAX_CYCLES)。 */
|
||
maxCycles?: number;
|
||
/** 派活链路节点(横向排列)。 */
|
||
nodes: DispatchChainNode[];
|
||
/** 共识百分比(0..100)。 */
|
||
consensusPercentage: number;
|
||
/** 「查看」按钮回调 → 进入正在进行的任务 / 查看报告。 */
|
||
onView?: () => void;
|
||
/** 命中澄清时的问题文本(status==='awaiting_input')。 */
|
||
pendingClarification?: string | null;
|
||
/** 提交澄清回答 → 续跑。 */
|
||
onResume?: (answer: string) => void;
|
||
/** 停止/取消作业。 */
|
||
onCancel?: () => void;
|
||
/** 出错详情(status==='error')。 */
|
||
error?: string | null;
|
||
/** 研讨对话记录(leader 说明 + 各席位交付)。 */
|
||
dialogues?: JobDialogue[];
|
||
/** 编排模式;==="dag" 且 stages 非空时,流程图按 stage 分层(DAG)绘制,否则线性。 */
|
||
orchestrationMode?: "recommend" | "chain" | "dag";
|
||
/** dag 模式的分层 stages(按 stage 分列画流程图,stage 内并行席位纵向并列)。 */
|
||
stages?: ReadonlyArray<OrchestrationStage> | null;
|
||
}
|
||
|
||
const _NON_TERMINAL: ReadonlySet<JobStatus> = new Set<JobStatus>(["queued", "running", "awaiting_input"]);
|
||
|
||
const STATUS_LABEL: Record<JobStatus, string> = {
|
||
queued: "排队中",
|
||
running: "进行中",
|
||
awaiting_input: "等待澄清",
|
||
done: "已完成",
|
||
error: "出错",
|
||
cancelled: "已取消",
|
||
};
|
||
|
||
const PHASE_LABEL: Record<JobPhase, string> = {
|
||
initializing: "初始化席位…",
|
||
leader_thinking: "总控决策派活中…",
|
||
dispatching: "席位发言中…",
|
||
consensus: "共识达成",
|
||
// Step3 三产物(总结报告 / 结果绘制 / 大屏数据)并行生成,两个阶段值都代表
|
||
// 「并行进行中」(后端 gather 并发推进、phase 交错落库,不区分先后)。
|
||
summary_writing: "Step3 并行生成中(总结/绘制/大屏)…",
|
||
report_drawing: "Step3 并行生成中(总结/绘制/大屏)…",
|
||
report_done: "Step3 完成",
|
||
};
|
||
|
||
export function DispatchChainModal({
|
||
open,
|
||
onOpenChange,
|
||
title = "多智能体研讨",
|
||
status,
|
||
phase,
|
||
cycle,
|
||
maxCycles = 8,
|
||
nodes,
|
||
consensusPercentage,
|
||
onView,
|
||
pendingClarification,
|
||
onResume,
|
||
onCancel,
|
||
error,
|
||
dialogues,
|
||
orchestrationMode,
|
||
stages,
|
||
}: DispatchChainModalProps) {
|
||
const [answer, setAnswer] = useState("");
|
||
const convoRef = useRef<HTMLDivElement>(null);
|
||
|
||
// 新对话进来时自动滚到底部。
|
||
const dialogueCount = dialogues?.length ?? 0;
|
||
useEffect(() => {
|
||
const el = convoRef.current;
|
||
if (el) el.scrollTop = el.scrollHeight;
|
||
}, [dialogueCount]);
|
||
|
||
// 对话气泡的头像配色:总控固定蓝;席位优先取流程图节点色,回退按 agentId 派生。
|
||
const avatarForDialogue = (d: JobDialogue): string => {
|
||
if (d.role === "leader") return "blue";
|
||
const node = nodes.find((n) => n.agentId === d.agentId);
|
||
return node?.avatarType || (d.agentId ? getAvatarTypeFor(d.agentId, node?.order ? node.order - 1 : 0) : "blue");
|
||
};
|
||
// 总控节点:初始化 / 决策派活阶段高亮
|
||
const coordinatorState: ChainNodeState =
|
||
phase === "initializing" || phase === "leader_thinking" ? "active" : "done";
|
||
|
||
// 共识里程碑
|
||
const consensusDone =
|
||
consensusPercentage >= 100 ||
|
||
phase === "consensus" ||
|
||
phase === "summary_writing" ||
|
||
phase === "report_drawing" ||
|
||
phase === "report_done" ||
|
||
status === "done";
|
||
const consensusActive = phase === "consensus";
|
||
|
||
// Step3 三产物**并行**(2026-06-13:后端 gather 并发跑总结/绘制/大屏,phase 交错
|
||
// 落库不分先后)→ 统一成一组并行里程碑状态。
|
||
const step3Active = phase === "summary_writing" || phase === "report_drawing";
|
||
const step3Done = phase === "report_done" || status === "done";
|
||
const step3State: ChainNodeState = step3Done ? "done" : step3Active ? "active" : "pending";
|
||
|
||
// ── DAG(分层)流程图:mode==="dag" 且有 stages 时按 stage 分列绘制 ──────────────
|
||
const showDag = orchestrationMode === "dag" && (stages?.length ?? 0) > 0;
|
||
// 节点状态来自 dispatch_chain(agentId 小写 → state);resolveSeat 取名称 + 头像配色。
|
||
const dagSeatStates: Record<string, ChainNodeState> = {};
|
||
const nodeByAgent = new Map<string, DispatchChainNode>();
|
||
for (const n of nodes) {
|
||
dagSeatStates[n.agentId.toLowerCase()] = n.state;
|
||
nodeByAgent.set(n.agentId.toLowerCase(), n);
|
||
}
|
||
const resolveDagSeat = (agentId: string, order: number) => {
|
||
const node = nodeByAgent.get(agentId.toLowerCase());
|
||
return {
|
||
name: node?.name || agentId,
|
||
avatarType: node?.avatarType || getAvatarTypeFor(agentId, order),
|
||
};
|
||
};
|
||
const dagConsensusState: ChainNodeState = consensusDone
|
||
? "done"
|
||
: consensusActive
|
||
? "active"
|
||
: "pending";
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="flex max-h-[90vh] w-[94vw] flex-col gap-0 sm:max-w-[1240px]">
|
||
<DialogHeader className="shrink-0">
|
||
<DialogTitle className="flex items-center gap-2">
|
||
{status === "running" && (
|
||
<Loader2Icon className="size-5 shrink-0 animate-spin text-primary" />
|
||
)}
|
||
<span className="truncate">{title}</span>
|
||
<span
|
||
className={cn(
|
||
"ml-1 shrink-0 rounded-full px-2 py-0.5 text-[11px] font-semibold",
|
||
status === "running" && "bg-primary/10 text-primary",
|
||
status === "awaiting_input" &&
|
||
"bg-amber-500/10 text-amber-600 dark:text-amber-400",
|
||
status === "done" &&
|
||
"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||
status === "error" && "bg-destructive/10 text-destructive",
|
||
(status === "queued" || status === "cancelled") &&
|
||
"bg-muted text-muted-foreground",
|
||
)}
|
||
>
|
||
{STATUS_LABEL[status]}
|
||
</span>
|
||
</DialogTitle>
|
||
<DialogDescription>{PHASE_LABEL[phase]}</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{/* 流程图:dag 模式按 stage 分层(DAG);否则线性总控→席位→共识→报告 */}
|
||
{showDag ? (
|
||
// 分层 DAG 流程图:总控 → ┌Stage┐(并行席位纵向并列) → … → 🤝共识 → 📊结果绘制。
|
||
// 后台 Step3(总结报告 + 结果绘制)已恢复 → 显示「结果绘制」里程碑。
|
||
<DagFlowChart
|
||
stages={stages!}
|
||
resolveSeat={resolveDagSeat}
|
||
seatStates={dagSeatStates}
|
||
coordinatorState={coordinatorState}
|
||
consensusState={dagConsensusState}
|
||
summaryState={step3State}
|
||
reportState={step3State}
|
||
showReportMilestone
|
||
className="shrink-0"
|
||
/>
|
||
) : (
|
||
<div className="custom-scrollbar shrink-0 overflow-x-auto overflow-y-hidden py-8">
|
||
<div className="flex min-w-max items-start gap-0 px-6">
|
||
{/* 总控节点 */}
|
||
<FlowNode
|
||
label="总控协调"
|
||
avatarType="human"
|
||
state={coordinatorState}
|
||
/>
|
||
|
||
{/* 席位节点 */}
|
||
{nodes.map((node) => (
|
||
<Fragment key={node.agentId}>
|
||
<Connector
|
||
active={node.state === "active"}
|
||
done={node.state === "done"}
|
||
/>
|
||
<FlowNode
|
||
label={node.name}
|
||
avatarType={node.avatarType}
|
||
state={node.state}
|
||
order={node.order}
|
||
/>
|
||
</Fragment>
|
||
))}
|
||
|
||
{/* 共识里程碑 */}
|
||
<Connector active={consensusActive} done={consensusDone} />
|
||
<MilestoneNode
|
||
label="共识达成"
|
||
emoji="🤝"
|
||
state={
|
||
consensusActive ? "active" : consensusDone ? "done" : "pending"
|
||
}
|
||
/>
|
||
|
||
{/* Step3:总结报告 / 结果绘制 / 大屏多页 —— **并行**纵列(共识后三流齐跑) */}
|
||
<Connector active={step3Active} done={step3Done} />
|
||
<div className="flex shrink-0 flex-col items-center gap-1.5 rounded-2xl border border-dashed border-primary/30 bg-primary/[0.04] px-2 pb-2 pt-1.5">
|
||
<span className="text-[9px] font-bold tracking-widest text-primary/70">并行生成</span>
|
||
<MilestoneNode label="总结报告" emoji="📝" state={step3State} />
|
||
<MilestoneNode label="结果绘制" emoji="📊" state={step3State} />
|
||
<MilestoneNode label="大屏多页" emoji="🖥️" state={step3State} />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 研讨对话(leader 说明 + 各席位交付,真网关下即真模型真实对话) */}
|
||
{dialogues && dialogues.length > 0 && (
|
||
<div
|
||
ref={convoRef}
|
||
className="custom-scrollbar min-h-0 flex-1 overflow-y-auto border-t px-1 py-3"
|
||
>
|
||
<div className="space-y-3.5">
|
||
{dialogues.map((d) => (
|
||
<div key={d.id} className="flex gap-2.5">
|
||
<div className="shrink-0">{getAvatar(avatarForDialogue(d), "h-7 w-7")}</div>
|
||
<div className="min-w-0 flex-1">
|
||
<div className="mb-0.5 text-xs font-semibold text-foreground/80">{d.name}</div>
|
||
<div className="prose prose-sm max-w-none break-words text-sm leading-relaxed text-foreground/80 dark:prose-invert">
|
||
<MarkdownContent>{d.content || "…"}</MarkdownContent>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 出错详情 */}
|
||
{status === "error" && error && (
|
||
<div className="shrink-0 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* 澄清作答(status==='awaiting_input')→ resumeJob 续跑 */}
|
||
{status === "awaiting_input" && pendingClarification && onResume && (
|
||
<div className="shrink-0 pt-3">
|
||
<div className="rounded-xl border border-amber-300/50 bg-amber-50/60 p-3 dark:border-amber-700/40 dark:bg-amber-950/20">
|
||
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold text-amber-600 dark:text-amber-400">
|
||
<span className="inline-block size-1.5 rounded-full bg-amber-400 animate-pulse" />
|
||
总控需要补充信息,请回答后继续
|
||
</div>
|
||
<p className="mb-2 whitespace-pre-wrap text-sm text-foreground/80">{pendingClarification}</p>
|
||
<textarea
|
||
value={answer}
|
||
onChange={(e) => setAnswer(e.target.value)}
|
||
rows={2}
|
||
placeholder="输入你的回答…"
|
||
className="mb-2 w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary/30"
|
||
/>
|
||
<div className="flex justify-end">
|
||
<Button
|
||
size="sm"
|
||
disabled={!answer.trim()}
|
||
onClick={() => {
|
||
onResume(answer.trim());
|
||
setAnswer("");
|
||
}}
|
||
className="gap-1.5"
|
||
>
|
||
<SendIcon className="size-3.5" />
|
||
提交并继续
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<DialogFooter className="shrink-0 flex-row items-center justify-between gap-2 border-t pt-3 sm:justify-between">
|
||
<span className="text-xs text-muted-foreground">
|
||
轮次 {Math.min(cycle, maxCycles)}/{maxCycles}
|
||
<span className="mx-2 text-muted-foreground/40">·</span>
|
||
共识 {Math.round(consensusPercentage)}%
|
||
<span className="mx-2 text-muted-foreground/40">·</span>
|
||
{PHASE_LABEL[phase]}
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
{onCancel && _NON_TERMINAL.has(status) && (
|
||
<Button variant="outline" size="sm" onClick={onCancel} className="text-muted-foreground">
|
||
停止
|
||
</Button>
|
||
)}
|
||
{/* 「查看」对所有状态开放——运行中点击进入的是**只读 hydrate 视图**
|
||
(onView 先暂停本地编排再加载草稿,纯展示绝不 resume,不会与后台
|
||
作业双跑;草稿由后台逐轮回写,进入即可看到当前进度)。 */}
|
||
<Button onClick={onView} disabled={!onView} className="gap-1.5">
|
||
<EyeIcon className="size-4" />
|
||
{status === "done" ? "查看报告" : "查看"}
|
||
</Button>
|
||
</div>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
// ── 链路节点(席位 / 总控)─────────────────────────────────────────────────
|
||
|
||
function FlowNode({
|
||
label,
|
||
avatarType,
|
||
state,
|
||
order,
|
||
}: {
|
||
label: string;
|
||
avatarType: string;
|
||
state: ChainNodeState;
|
||
order?: number;
|
||
}) {
|
||
const tag = chainTagClasses(avatarType);
|
||
return (
|
||
<div className="flex w-24 shrink-0 flex-col items-center gap-2 text-center">
|
||
<div className="relative flex size-16 items-center justify-center">
|
||
{/* 活动光环:脉冲 ping */}
|
||
{state === "active" && (
|
||
<span className="absolute inset-0 animate-ping rounded-full bg-primary/30" />
|
||
)}
|
||
{/* 头像 + 状态描边 */}
|
||
<span
|
||
className={cn(
|
||
"relative flex size-16 items-center justify-center rounded-full border-2 transition-all",
|
||
state === "active" &&
|
||
"border-primary shadow-[0_0_0_4px_rgba(22,119,255,0.12)]",
|
||
state === "done" && "border-emerald-400/70",
|
||
state === "pending" && "border-border opacity-45 grayscale",
|
||
)}
|
||
>
|
||
{getAvatar(avatarType, "h-14 w-14")}
|
||
{/* 完成打勾 */}
|
||
{state === "done" && (
|
||
<span className="absolute -right-0.5 -top-0.5 flex size-5 items-center justify-center rounded-full bg-emerald-500 text-white shadow">
|
||
<CheckIcon className="size-3" />
|
||
</span>
|
||
)}
|
||
{/* 进行中转圈 */}
|
||
{state === "active" && (
|
||
<span className="absolute -right-0.5 -top-0.5 flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground shadow">
|
||
<Loader2Icon className="size-3 animate-spin" />
|
||
</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
<span
|
||
className={cn(
|
||
"max-w-full truncate rounded border px-1.5 py-0.5 text-[11px] font-medium",
|
||
state === "pending" ? "border-border bg-muted text-muted-foreground" : tag,
|
||
)}
|
||
title={label}
|
||
>
|
||
{order != null ? `${order}. ` : ""}
|
||
{label}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 里程碑节点(共识 / 报告绘制)─────────────────────────────────────────────
|
||
|
||
function MilestoneNode({
|
||
label,
|
||
emoji,
|
||
state,
|
||
}: {
|
||
label: string;
|
||
emoji: string;
|
||
state: ChainNodeState;
|
||
}) {
|
||
return (
|
||
<div className="flex w-24 shrink-0 flex-col items-center gap-2 text-center">
|
||
<div className="relative flex size-16 items-center justify-center">
|
||
{state === "active" && (
|
||
<span className="absolute inset-0 animate-ping rounded-full bg-primary/30" />
|
||
)}
|
||
<span
|
||
className={cn(
|
||
"relative flex size-16 items-center justify-center rounded-full border-2 text-2xl transition-all",
|
||
state === "active" &&
|
||
"border-primary bg-primary/5 shadow-[0_0_0_4px_rgba(22,119,255,0.12)]",
|
||
state === "done" && "border-emerald-400/70 bg-emerald-500/5",
|
||
state === "pending" && "border-border bg-muted opacity-45 grayscale",
|
||
)}
|
||
>
|
||
{emoji}
|
||
{state === "done" && (
|
||
<span className="absolute -right-0.5 -top-0.5 flex size-5 items-center justify-center rounded-full bg-emerald-500 text-white shadow">
|
||
<CheckIcon className="size-3" />
|
||
</span>
|
||
)}
|
||
{state === "active" && (
|
||
<span className="absolute -right-0.5 -top-0.5 flex size-5 items-center justify-center rounded-full bg-primary text-primary-foreground shadow">
|
||
<Loader2Icon className="size-3 animate-spin" />
|
||
</span>
|
||
)}
|
||
</span>
|
||
</div>
|
||
<span
|
||
className={cn(
|
||
"rounded border px-1.5 py-0.5 text-[11px] font-semibold",
|
||
state === "pending"
|
||
? "border-border bg-muted text-muted-foreground"
|
||
: state === "active"
|
||
? "border-primary/40 bg-primary/10 text-primary"
|
||
: "border-emerald-400/40 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
|
||
)}
|
||
>
|
||
{label}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 连接线(带流动动画)─────────────────────────────────────────────────────
|
||
|
||
function Connector({ active, done }: { active: boolean; done: boolean }) {
|
||
return (
|
||
<div className="flex h-16 w-16 shrink-0 items-center justify-center">
|
||
<div className="relative flex w-full items-center">
|
||
<span
|
||
className={cn(
|
||
"rt-chain-connector",
|
||
active && "rt-chain-connector--active",
|
||
done && !active && "rt-chain-connector--done",
|
||
)}
|
||
/>
|
||
<ChevronRightIcon
|
||
className={cn(
|
||
"absolute -right-1 size-4 transition-colors",
|
||
active
|
||
? "text-primary"
|
||
: done
|
||
? "text-primary/70"
|
||
: "text-muted-foreground/40",
|
||
)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|