Files
deerflow-code/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx
T
董宏凯 d5f319d9cb @
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>
@
2026-06-13 10:36:46 +08:00

502 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 派活链路流程图弹窗(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>
);
}