数据模型/编辑器:链条加 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>
324 lines
13 KiB
TypeScript
324 lines
13 KiB
TypeScript
/**
|
||
* 业务链条选择面板(推荐弹窗「业务链条」模式的内容区)。
|
||
*
|
||
* 拉取当前用户的业务链条(`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>
|
||
);
|
||
}
|