Files
deerflow-code/frontend-web/src/roundtable-planning/components/BusinessChainPicker.tsx
T
董宏凯andClaude Opus 4.8 7b8c11852e feat(roundtable): 圆桌 Step2 分层 DAG 编排(并行/沙箱双模式 + 流程图 + 按需取全文)
数据模型/编辑器:链条加 stages 分层(前后端 + alembic 20260607_03);BusinessChainEditorPage 竖列 Stage 编辑 + 底部只读流程图预览;lib/dag-types + chain-stages 纯逻辑(含单测)。

调度(runDagOrchestration,方案 A 总控驱动):每个 stage 由总控经 agent_orchestration 派活给计划钉死的席位,批内 Promise.all 并行、批间 barrier 串行;末尾交回 runOrchestration 收口。抑制 leader 预广播 + 干净席位任务,根治『席位模仿派活』。

执行子模式:快速(双列·产物走正文·禁写文件 seat_parallel)/ 沙箱(单列·写 .md·沙箱跟随预览 seat);统一禁 bash(防 .py 脚本),提示词禁 .py。

显示:头像条 + 两列/单列 + ref 全量累积 + 节流 flush(仅选中渲染);DAG 流程图弹窗(按 stage 分列,节点随执行点亮,只到共识);沙箱跟随当前席位(防错位 + 修第一个席位自动开沙箱)。

按需取全文:新增 harness 只读工具 read_peer_delivery(从 run context 取网关注入的前序席位完整交付),席位默认看摘要、需要时取全文。

修:宽表溢出、并行自动下滑、data-lov-id Fragment 警告等。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 21:20:58 +08:00

324 lines
13 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.
/**
* 业务链条选择面板(推荐弹窗「业务链条」模式的内容区)。
*
* 拉取当前用户的业务链条(`listChains()`),渲染卡片列表:
* - 卡片标题(链条 title)+ 存活席位计数。
* - 标题下一排**智能体名 tag**:按链条顺序从左到右排列、不同颜色(`getAvatarTypeFor`
* → `chainTagClasses`),tag 之间用 `→`(ArrowRight)箭头连接表达发言次序。
* - 链条里有 agent 已被删除 → 该 tag 置灰删除线 + 提示;卡片只用**存活**席位,
* 存活不足 `CHAIN_MIN_SEATS` 则不可使用。
*
* 选中一条 → 高亮;底部「进入研讨」回传 `SeatSelectionResult{ mode:"chain" }`,
* 由父弹窗的 `onConfirm` 透传给主页面(席位顺序 = 链条顺序)。
*
* 详见 `docs/multi-agent-business-chain-dev.md` §6.2 / §7 阶段 3。
*/
import { Fragment, useEffect, useMemo, useState } from "react";
import {
ArrowRightIcon,
CheckIcon,
LayersIcon,
Loader2Icon,
SettingsIcon,
TriangleAlertIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { DialogFooter } from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { listAgents } from "@/core/agents";
import { filterRecommendCandidates } from "../api/recommend";
import { listChains } from "../api/chains";
import {
CHAIN_MIN_SEATS,
chainTagClasses,
type BusinessChain,
type SeatSelectionResult,
} from "../lib/business-chain";
import { buildPlanFromStages } from "../lib/dag-types";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
export interface BusinessChainPickerProps {
onConfirm: (result: SeatSelectionResult) => void;
onCancel: () => void;
/** 跳转到业务链条配置页(同时关闭弹窗)。 */
onGoConfig: () => void;
}
export function BusinessChainPicker({
onConfirm,
onCancel,
onGoConfig,
}: BusinessChainPickerProps) {
const [chains, setChains] = useState<BusinessChain[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
// null = 候选池未知(agent 列表加载失败)→ 不把任何席位判成「已删除」。
const [existingIds, setExistingIds] = useState<Set<string> | null>(null);
const [nameById, setNameById] = useState<Map<string, string>>(new Map());
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const [chainList, agents] = await Promise.all([
listChains(),
listAgents().catch(() => null), // agent 列表失败不阻断链条展示
]);
if (cancelled) return;
setChains(chainList);
if (agents) {
const candidates = filterRecommendCandidates(agents);
setExistingIds(new Set(candidates.map((c) => c.agent_id)));
setNameById(new Map(candidates.map((c) => [c.agent_id, c.name])));
} else {
setExistingIds(null);
}
} catch (err) {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
const isExisting = useMemo(
() => (agentId: string) => existingIds === null || existingIds.has(agentId),
[existingIds],
);
const selectedChain = useMemo(
() => chains.find((c) => c.id === selectedId) ?? null,
[chains, selectedId],
);
const survivingSeats = useMemo(
() => (selectedChain ? selectedChain.seats.filter((s) => isExisting(s.agent_id)) : []),
[selectedChain, isExisting],
);
const canUse = !!selectedChain && survivingSeats.length >= CHAIN_MIN_SEATS;
// 选中链条若有分层、且过滤到存活席位后**仍含并行 stage** → 走 dag。此时让用户在
// 「快速(并行双列禁写) / 沙箱(并行单列可写、沙箱跟随)」之间二选一(§DAG 执行子模式)。
const dagPlan = useMemo(() => {
if (!selectedChain?.stages || selectedChain.stages.length === 0) return null;
const surviveIds = survivingSeats.map((s) => s.agent_id);
const plan = buildPlanFromStages(selectedChain.stages, surviveIds);
return plan.stages.some((st) => st.agentIds.length > 1) ? plan : null;
}, [selectedChain, survivingSeats]);
const [dagExecMode, setDagExecMode] = useState<"fast" | "sandbox">("fast");
const handleUse = () => {
if (!canUse || !selectedChain) return;
const chainMeta = { id: selectedChain.id, title: selectedChain.title };
const seatNameById = new Map(selectedChain.seats.map((s) => [s.agent_id, s.name]));
const nameOf = (id: string) => nameById.get(id) ?? seatNameById.get(id) ?? id;
// 链条带分层、过滤存活后仍并行 → dag 编排(携带快速/沙箱子模式);否则退回线性 chain。
if (dagPlan) {
// dag:席位顺序按 stage 展平(与 plan 一致)。
const agents = dagPlan.stages
.flatMap((st) => st.agentIds)
.map((id) => ({ agent_id: id, name: nameOf(id) }));
onConfirm({ agents, mode: "dag", chain: chainMeta, plan: dagPlan, dagExecMode });
return;
}
const agents = survivingSeats.map((s) => ({ agent_id: s.agent_id, name: nameOf(s.agent_id) }));
onConfirm({ agents, mode: "chain", chain: chainMeta });
};
return (
<>
<section className="flex-1 min-h-0 flex flex-col overflow-hidden">
<div className="min-h-0 flex-1 overflow-y-auto custom-scrollbar pr-1 -mr-1">
{loading ? (
<div className="flex items-center gap-2 px-1 py-8 text-sm text-muted-foreground">
<Loader2Icon className="size-4 animate-spin" />
正在加载业务链条…
</div>
) : error ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
加载业务链条失败:{error}
</div>
) : chains.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3 px-6 py-10 text-center">
<LayersIcon className="size-10 text-primary/40" />
<div className="text-sm font-semibold">还没有业务链条</div>
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
业务链条是可复用的有序智能体编排。先去配置页面创建第一条链条,
之后即可在这里选择使用。
</p>
<Button variant="outline" size="sm" onClick={onGoConfig} className="gap-1.5">
<SettingsIcon className="size-4" />
去配置业务链条
</Button>
</div>
) : (
<div className="space-y-2.5 py-0.5">
{chains.map((chain) => (
<ChainCard
key={chain.id}
chain={chain}
selected={chain.id === selectedId}
isExisting={isExisting}
nameById={nameById}
onSelect={() => setSelectedId(chain.id)}
/>
))}
</div>
)}
</div>
</section>
<DialogFooter className="shrink-0 flex-row sm:justify-between items-center gap-2 flex-wrap border-t pt-3 mt-1">
<Button variant="ghost" size="sm" onClick={onGoConfig} className="h-8 gap-1.5 text-xs">
<SettingsIcon className="size-3.5" />
配置链条
</Button>
{/* dag 分层并行 → 选执行子模式(快速双列禁写 / 沙箱单列可写)。 */}
{dagPlan && (
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-muted-foreground">执行方式</span>
<div className="flex items-center gap-0.5 rounded-lg border border-border bg-muted/40 p-0.5">
<button
type="button"
onClick={() => setDagExecMode("fast")}
title="同 stage 席位并行执行,主区双列同时显示两个;并行期间禁止写文件(产物走正文)"
className={cn(
"rounded-md px-2.5 py-1 text-xs font-medium transition",
dagExecMode === "fast"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
快速 · 双列禁写
</button>
<button
type="button"
onClick={() => setDagExecMode("sandbox")}
title="同 stage 席位仍并行执行,但主区单列显示、允许写文件;点上方头像切换查看,沙箱跟随当前席位"
className={cn(
"rounded-md px-2.5 py-1 text-xs font-medium transition",
dagExecMode === "sandbox"
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
沙箱 · 单列可写
</button>
</div>
</div>
)}
<div className="flex items-center gap-2">
{selectedChain && !canUse && (
<span className="text-xs text-destructive">存活席位不足 {CHAIN_MIN_SEATS} 个</span>
)}
<Button variant="outline" onClick={onCancel}>
取消
</Button>
<Button onClick={handleUse} disabled={!canUse}>
进入研讨
</Button>
</div>
</DialogFooter>
</>
);
}
// ── 单张链条卡片 ─────────────────────────────────────────────────────────
function ChainCard({
chain,
selected,
isExisting,
nameById,
onSelect,
}: {
chain: BusinessChain;
selected: boolean;
isExisting: (agentId: string) => boolean;
nameById: Map<string, string>;
onSelect: () => void;
}) {
const survivingCount = chain.seats.filter((s) => isExisting(s.agent_id)).length;
const deletedCount = chain.seats.length - survivingCount;
const usable = survivingCount >= CHAIN_MIN_SEATS;
return (
<button
type="button"
onClick={onSelect}
className={cn(
"w-full rounded-xl border p-3 text-left transition-all",
selected
? "border-primary bg-primary/[0.06] ring-1 ring-primary/40"
: "border-border bg-card hover:border-primary/40 hover:bg-primary/[0.03]",
)}
>
{/* 标题行 */}
<div className="flex items-center gap-2">
<div className="truncate text-sm font-semibold">{chain.title || "未命名链条"}</div>
{selected && (
<span className="flex size-4 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
<CheckIcon className="size-3" />
</span>
)}
{chain.stages && chain.stages.length > 0 && (
<span className="shrink-0 rounded-full border border-amber-400/40 bg-amber-400/10 px-1.5 py-0.5 text-[10px] font-semibold text-amber-600 dark:text-amber-300">
分层并行
</span>
)}
<span className="ml-auto shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-muted-foreground">
{chain.seats.length} 席
</span>
</div>
{chain.description && (
<div className="mt-1 line-clamp-1 text-xs text-muted-foreground">{chain.description}</div>
)}
{/* 智能体名 tag(按顺序,← 箭头连接) */}
<div className="mt-2 flex flex-wrap items-center gap-1">
{chain.seats.map((seat, i) => {
const alive = isExisting(seat.agent_id);
const label = nameById.get(seat.agent_id) ?? seat.name ?? seat.agent_id;
return (
<Fragment key={seat.agent_id}>
{i > 0 && (
<ArrowRightIcon className="size-3 shrink-0 text-muted-foreground/50" />
)}
<span
className={cn(
"rounded border px-1.5 py-0.5 text-[11px] font-medium",
alive
? chainTagClasses(getAvatarTypeFor(seat.agent_id, i))
: "border-dashed border-border bg-muted text-muted-foreground/60 line-through",
)}
title={alive ? undefined : "该智能体已不存在"}
>
{label}
</span>
</Fragment>
);
})}
</div>
{/* 删除提示 */}
{deletedCount > 0 && (
<div className="mt-2 flex items-center gap-1 text-[11px] text-amber-600 dark:text-amber-400">
<TriangleAlertIcon className="size-3 shrink-0" />
{usable
? `${deletedCount} 个智能体已删除,将按存活 ${survivingCount} 个席位研讨`
: `${deletedCount} 个智能体已删除,存活不足 ${CHAIN_MIN_SEATS} 个,暂不可用`}
</div>
)}
</button>
);
}