Files
deerflow-code/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx
T
董宏凯andClaude Opus 4.8 834df64650 feat(roundtable): 业务链条流程图改弹窗 + 每阶段「本阶段目标」注入总控
- 业务链条配置页流程图从底部内嵌改为按钮 + Dialog 弹窗查看
- 每个 Stage 新增可选「本阶段目标」文本框,告诉总控本阶段要产出
  什么、如何依赖上一阶段产出(仅分层 DAG 生效)
- StageGroups 重构为 StageGroup[]({seats, goal?}),goal 随 stage
  在增删改/重排中自动跟随;新增 stageGoals 持久化(与 stages 同下标对齐)
- 运行时注入:buildDagStageLeaderTask(总控派活,主)+ pickDagSeatTask
  (席位任务,兜底)
- 后端 roundtable_chains 加 stage_goals 列 + 校验 + Alembic 20260608_04
- 老链条/草稿无该字段时不注入,行为不变

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:10:46 +08:00

1416 lines
53 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.
/**
* 业务链条配置页面(路由 `/page/workspace/roundtable/chains`)。
*
* 双栏编辑器:
* - 左栏:候选智能体列表(`listAgents()` + `filterRecommendCandidates` 复用,剔除
* intent/recommender/coordinator),支持**名称检索 + 标签筛选**(`TagSearchBar`,
* 服务端过滤),竖排卡片,可拖到右栏、也可点击快捷加入。
* - 右栏:**分层编排** —— 把席位分到若干 **Stage**(竖向分组):同一 Stage 内并行、
* Stage 之间串行。可拖席位跨 Stage、整段 Stage 上下移、新建 / 删除 Stage;也提供
* 按钮(并入上/下一阶段、独立成阶段、移除)做无拖拽兜底。全单席位 Stage = 线性链。
*
* 拖拽用 `@dnd-kit`:每个 Stage 是一个 droppable,Stage 内席位 `SortableContext`;
* 左栏卡片 `useDraggable`(pool→某 Stage / 新建 Stage 区插入)。落点计算在 `onDragEnd`。
*
* 持久化走 `api/chains.ts`:`seats` = 各 Stage 展平的有序席位;`stages` = 分层(全单
* 席位时存 null,即线性,老行为不变)。席位数约束 2 ≤ N ≤ 8、agent 去重。分层操作的
* 纯逻辑在 `lib/chain-stages.ts`(可单测)。
*
* 详见 `docs/roundtable-dag-orchestration.md` §2(Phase 2,分层编辑器)。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { nanoid } from "nanoid";
import {
ArrowLeft,
Bot,
ChevronDown,
ChevronUp,
GitBranch,
Globe,
GripVertical,
Layers,
Loader2,
Network,
Plus,
Save,
SplitSquareVertical,
Trash2,
UserPlus,
X,
} from "lucide-react";
import {
DndContext,
DragOverlay,
PointerSensor,
closestCenter,
useDraggable,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { listAgents } from "@/core/agents";
import {
filterRecommendCandidates,
type RecommendCandidate,
} from "@/roundtable-planning/api/recommend";
import {
createChain,
deleteChain,
listChains,
setChainPublic,
updateChain,
} from "@/roundtable-planning/api/chains";
import {
CHAIN_MAX_SEATS,
CHAIN_MIN_SEATS,
chainTagClasses,
type BusinessChain,
type ChainSeat,
} from "@/roundtable-planning/lib/business-chain";
import {
addEmptyStage,
addSeatAsNewStage,
addSeatToStage,
chainToStageGroups,
countSeats,
deleteStage as deleteStageGroup,
findStageIndexOfSeat,
moveSeatToStage,
moveStage,
removeSeat as removeSeatFromGroups,
seatExistsInGroups,
setStageGoal,
splitSeatToNewStage,
stageGroupsToSeats,
stageGroupsToStageGoals,
stageGroupsToStagePayload,
type StageGroup,
type StageGroups,
} from "@/roundtable-planning/lib/chain-stages";
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
const POOL_PREFIX = "pool::";
const SEAT_PREFIX = "seat::";
const STAGE_PREFIX = "stage::";
const NEWSTAGE_DROPPABLE_ID = "newstage-droppable";
// 规范圆角(§4):输入/按钮/tag 2px,卡片/分栏 4px。app 默认 --radius=12px 偏大,
// 这里用 arbitrary 值精确命中规范。
const R_CARD = "rounded-[4px]";
const R_CTRL = "rounded-[2px]";
// 规范阴影(§3):克制的浅投影,深色下几乎不可见。
const PANEL_SHADOW = "shadow-[0_1px_2px_rgba(15,23,42,0.05)]";
type ActiveDrag =
| { kind: "pool"; agentId: string; name: string }
| { kind: "seat"; agentId: string; name: string }
| null;
export default function BusinessChainEditorPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// ─── 数据 ────────────────────────────────────────────────────────────
const [pool, setPool] = useState<RecommendCandidate[]>([]);
const [poolLoading, setPoolLoading] = useState(true);
const [chains, setChains] = useState<BusinessChain[]>([]);
// 左栏检索/筛选 —— 服务端过滤(listAgents 支持 search + tag_ids)。
const [search, setSearch] = useState("");
const [filterTagIds, setFilterTagIds] = useState<string[]>([]);
// ─── 当前编辑中的链条 ─────────────────────────────────────────────────
const [currentChainId, setCurrentChainId] = useState<string | null>(null);
// 当前编辑链条的发布状态 / 是否本人所有(发布按钮 + 只读保护用)。
const [currentChainPublic, setCurrentChainPublic] = useState(false);
const [currentChainMine, setCurrentChainMine] = useState(true);
const [publishing, setPublishing] = useState(false);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
// 分层编排是唯一数据源;落库时展平为 seats + 折叠为 stages。
const [stageGroups, setStageGroups] = useState<StageGroups>([]);
const [saving, setSaving] = useState(false);
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [flowOpen, setFlowOpen] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
const poolById = useMemo(() => {
const m = new Map<string, RecommendCandidate>();
for (const a of pool) m.set(a.agent_id, a);
return m;
}, [pool]);
const seatIdSet = useMemo(
() => new Set(stageGroups.flatMap((g) => g.seats).map((s) => s.agent_id)),
[stageGroups],
);
const seatCount = useMemo(() => countSeats(stageGroups), [stageGroups]);
// ─── Phase 7 只读流程图预览(§2.2 底部 / §7)──────────────────────────────
// 把当前分层(去掉空 stage)映射成 DagFlowChart 的 stages,并提供席位名/配色解析。
const previewStages = useMemo(
() =>
stageGroups
.filter((g) => g.seats.length > 0)
.map((g, i) => ({ id: `stage-${i}`, agentIds: g.seats.map((s) => s.agent_id) })),
[stageGroups],
);
const seatNameById = useMemo(() => {
const m = new Map<string, string>();
for (const g of stageGroups)
for (const s of g.seats) m.set(s.agent_id.toLowerCase(), s.name || s.agent_id);
return m;
}, [stageGroups]);
const resolvePreviewSeat = useCallback(
(agentId: string, order: number) => ({
name: seatNameById.get(agentId.toLowerCase()) ?? agentId,
avatarType: getAvatarTypeFor(agentId, order),
}),
[seatNameById],
);
// ─── 链条列表(一次性)─────────────────────────────────────────────────
const refreshChains = useCallback(async () => {
try {
setChains(await listChains());
} catch (err) {
console.warn("[chains] refresh list failed:", err);
}
}, []);
useEffect(() => {
void refreshChains();
}, [refreshChains]);
// ─── 候选池:随 search / tagIds 服务端过滤 ────────────────────────────
useEffect(() => {
let cancelled = false;
setPoolLoading(true);
void (async () => {
try {
const agents = await listAgents({
search: search || undefined,
tagIds: filterTagIds.length ? filterTagIds : undefined,
});
if (cancelled) return;
setPool(filterRecommendCandidates(agents));
} catch (err) {
if (cancelled) return;
toast.error(
`加载候选智能体失败:${err instanceof Error ? err.message : String(err)}`,
);
} finally {
if (!cancelled) setPoolLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [search, filterTagIds]);
// ─── 链条选择 / 新建 ──────────────────────────────────────────────────
const loadChain = useCallback((chain: BusinessChain) => {
setCurrentChainId(chain.id);
setCurrentChainPublic(chain.isPublic ?? false);
setCurrentChainMine(chain.isMine !== false);
setTitle(chain.title);
setDescription(chain.description ?? "");
setStageGroups(chainToStageGroups(chain));
}, []);
const startNewChain = useCallback(() => {
setCurrentChainId(null);
setCurrentChainPublic(false);
setCurrentChainMine(true);
setTitle("");
setDescription("");
setStageGroups([]);
}, []);
// ─── URL 参数 chainId:从「业务链条」弹窗点「设置」进来时自动切到该链条 ──────
// 只在链条列表加载完后应用一次(用 ref 闸门避免覆盖用户后续的手动切换)。
const appliedChainParamRef = useRef(false);
useEffect(() => {
if (appliedChainParamRef.current) return;
const chainId = searchParams.get("chainId");
if (!chainId) return;
if (chains.length === 0) return; // 等 refreshChains 拉回列表
const chain = chains.find((c) => c.id === chainId);
if (chain) loadChain(chain);
appliedChainParamRef.current = true; // 命中或链条已不存在,都不再重试
}, [chains, searchParams, loadChain]);
// ─── 席位增删改(全部委托给 lib/chain-stages 纯函数)────────────────────
const toChainSeat = useCallback(
(agentId: string): ChainSeat | null => {
const agent = poolById.get(agentId);
if (!agent) return null;
return { agent_id: agent.agent_id, name: agent.name, description: agent.description };
},
[poolById],
);
/** pool 点击加入:末尾追加一个新单席位 Stage(线性追加,默认行为)。 */
const addAgentAsNewStage = useCallback(
(agentId: string) => {
const seat = toChainSeat(agentId);
if (!seat) return;
setStageGroups((prev) => {
if (seatExistsInGroups(prev, agentId)) {
toast.info(`「${seat.name}」已在链条中`);
return prev;
}
if (countSeats(prev) >= CHAIN_MAX_SEATS) {
toast.warning(`链条最多 ${CHAIN_MAX_SEATS} 个席位`);
return prev;
}
return addSeatAsNewStage(prev, seat);
});
},
[toChainSeat],
);
const removeSeat = useCallback((agentId: string) => {
setStageGroups((prev) => removeSeatFromGroups(prev, agentId));
}, []);
const mergeSeatToStage = useCallback((agentId: string, targetStageIdx: number) => {
setStageGroups((prev) => moveSeatToStage(prev, agentId, targetStageIdx));
}, []);
const setGoal = useCallback((stageIdx: number, goal: string) => {
setStageGroups((prev) => setStageGoal(prev, stageIdx, goal));
}, []);
const splitSeat = useCallback((agentId: string) => {
setStageGroups((prev) => splitSeatToNewStage(prev, agentId));
}, []);
const reorderStage = useCallback((stageIdx: number, dir: -1 | 1) => {
setStageGroups((prev) => moveStage(prev, stageIdx, dir));
}, []);
const appendEmptyStage = useCallback(() => {
setStageGroups((prev) => addEmptyStage(prev));
}, []);
const removeStage = useCallback((stageIdx: number) => {
setStageGroups((prev) => {
const stage = prev[stageIdx];
if (stage && stage.seats.length > 0) {
if (!window.confirm(`删除 Stage ${stageIdx + 1} 会移除其中 ${stage.seats.length} 个席位,确认?`)) {
return prev;
}
}
return deleteStageGroup(prev, stageIdx);
});
}, []);
// ─── DnD ─────────────────────────────────────────────────────────────
const onDragStart = useCallback(
(e: DragStartEvent) => {
const id = String(e.active.id);
if (id.startsWith(POOL_PREFIX)) {
const agentId = id.slice(POOL_PREFIX.length);
const a = poolById.get(agentId);
setActiveDrag({ kind: "pool", agentId, name: a?.name ?? agentId });
} else if (id.startsWith(SEAT_PREFIX)) {
const agentId = id.slice(SEAT_PREFIX.length);
const a = poolById.get(agentId);
setActiveDrag({ kind: "seat", agentId, name: a?.name ?? agentId });
}
},
[poolById],
);
const onDragEnd = useCallback(
(e: DragEndEvent) => {
setActiveDrag(null);
const activeId = String(e.active.id);
const overId = e.over ? String(e.over.id) : null;
if (!overId) return;
// 解析落点 → 目标 Stage 下标 + 该 Stage 内插入位置;-1 表示「新建 Stage 区」。
let targetStage = -1;
let atIndex: number | undefined;
if (overId === NEWSTAGE_DROPPABLE_ID) {
targetStage = -1;
} else if (overId.startsWith(STAGE_PREFIX)) {
targetStage = Number(overId.slice(STAGE_PREFIX.length));
} else if (overId.startsWith(SEAT_PREFIX)) {
const overAgent = overId.slice(SEAT_PREFIX.length);
targetStage = findStageIndexOfSeat(stageGroups, overAgent);
atIndex = stageGroups[targetStage]?.seats.findIndex((s) => s.agent_id === overAgent);
}
const toNewStage = overId === NEWSTAGE_DROPPABLE_ID;
if (activeId.startsWith(POOL_PREFIX)) {
const agentId = activeId.slice(POOL_PREFIX.length);
if (seatIdSet.has(agentId)) return;
const seat = toChainSeat(agentId);
if (!seat) return;
if (seatCount >= CHAIN_MAX_SEATS) {
toast.warning(`链条最多 ${CHAIN_MAX_SEATS} 个席位`);
return;
}
setStageGroups((prev) =>
toNewStage || targetStage < 0
? addSeatAsNewStage(prev, seat)
: addSeatToStage(prev, seat, targetStage, atIndex),
);
return;
}
if (activeId.startsWith(SEAT_PREFIX)) {
const agentId = activeId.slice(SEAT_PREFIX.length);
setStageGroups((prev) => {
if (toNewStage) {
// 拖到「新建 Stage」区 = 抽出独立成最后一个 Stage。
const withEmpty = addEmptyStage(prev);
return moveSeatToStage(withEmpty, agentId, withEmpty.length - 1);
}
if (targetStage < 0) return prev;
return moveSeatToStage(prev, agentId, targetStage, atIndex);
});
}
},
[stageGroups, seatIdSet, seatCount, toChainSeat],
);
// ─── 保存 / 删除 ──────────────────────────────────────────────────────
const seatCountInvalid = seatCount < CHAIN_MIN_SEATS || seatCount > CHAIN_MAX_SEATS;
const hasEmptyStage = stageGroups.some((g) => g.seats.length === 0);
const canSave = title.trim().length > 0 && !seatCountInvalid && !saving;
const handleSave = useCallback(async () => {
if (!title.trim()) {
toast.warning("请先填写链条标题");
return;
}
if (seatCountInvalid) {
toast.warning(`链条席位数须在 ${CHAIN_MIN_SEATS}–${CHAIN_MAX_SEATS} 之间`);
return;
}
setSaving(true);
try {
// 展平 + 折叠:seats 用候选池最新 name/description 覆盖快照;stages 全单席位 → null。
const flat = stageGroupsToSeats(stageGroups);
const payloadSeats = flat.map((s) => ({
agent_id: s.agent_id,
name: poolById.get(s.agent_id)?.name ?? s.name,
description: poolById.get(s.agent_id)?.description ?? s.description ?? "",
}));
const stages = stageGroupsToStagePayload(stageGroups);
// 每阶段目标,与 stages 同下标对齐(无并行时同样为 null)。
const stageGoals = stageGroupsToStageGoals(stageGroups);
if (currentChainId) {
const updated = await updateChain(currentChainId, {
title: title.trim(),
description: description.trim() || undefined,
seats: payloadSeats,
stages,
stageGoals,
});
if (!updated) {
toast.error("链条已不存在,可能被删除");
startNewChain();
await refreshChains();
return;
}
toast.success("已保存");
} else {
const id = nanoid();
const created = await createChain({
id,
title: title.trim(),
description: description.trim() || undefined,
seats: payloadSeats,
stages,
stageGoals,
});
setCurrentChainId(created.id);
toast.success("已创建业务链条");
}
await refreshChains();
} catch (err) {
toast.error(`保存失败:${err instanceof Error ? err.message : String(err)}`);
} finally {
setSaving(false);
}
}, [
title,
description,
stageGroups,
seatCountInvalid,
currentChainId,
poolById,
refreshChains,
startNewChain,
]);
const handleDelete = useCallback(async () => {
if (!currentChainId) {
startNewChain();
return;
}
if (!window.confirm(`确认删除业务链条「${title || "未命名"}」?此操作不可恢复。`)) {
return;
}
try {
await deleteChain(currentChainId);
toast.success("已删除");
startNewChain();
await refreshChains();
} catch (err) {
toast.error(`删除失败:${err instanceof Error ? err.message : String(err)}`);
}
}, [currentChainId, title, startNewChain, refreshChains]);
// 发布 / 取消发布:把当前链条设为公共(所有账号可见 / 选用)或收回。
const handleTogglePublish = useCallback(async () => {
if (!currentChainId) {
toast.warning("请先保存链条再发布");
return;
}
const next = !currentChainPublic;
setPublishing(true);
try {
const updated = await setChainPublic(currentChainId, next);
if (!updated) {
toast.error("链条已不存在,可能被删除");
startNewChain();
await refreshChains();
return;
}
setCurrentChainPublic(updated.isPublic ?? next);
toast.success(next ? "已发布为公共,所有账号都能查看 / 选用" : "已取消发布");
await refreshChains();
} catch (err) {
toast.error(`${next ? "发布" : "取消发布"}失败:${err instanceof Error ? err.message : String(err)}`);
} finally {
setPublishing(false);
}
}, [currentChainId, currentChainPublic, startNewChain, refreshChains]);
// ─── Render ──────────────────────────────────────────────────────────
const triggerLabel = currentChainId ? title || "未命名链条" : "+ 新建链条…";
return (
<div className="flex h-full min-h-0 flex-col bg-muted/40 text-foreground">
{/* 顶栏 */}
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border bg-card px-6 py-3">
<button
type="button"
onClick={() => navigate("/page/workspace/roundtable/planning")}
className={cn(
"flex items-center gap-1.5 border border-border bg-card px-3 py-1.5 text-sm font-medium text-muted-foreground transition hover:bg-muted hover:text-foreground active:scale-[0.98]",
R_CTRL,
)}
>
<ArrowLeft className="size-4" />
返回圆桌
</button>
{/* 品牌标识 */}
<div className="flex items-center gap-2.5">
<div
className={cn(
"flex size-8 select-none items-center justify-center bg-gradient-to-tr from-primary to-primary/40 text-sm font-bold text-primary-foreground shadow-sm",
R_CARD,
)}
>
🪶
</div>
<div className="leading-tight">
<div className="text-base font-bold text-foreground">业务链条配置</div>
<div className="text-[11px] font-medium tracking-wide text-muted-foreground">
分层编排:同层并行 · 层间串行
</div>
</div>
</div>
{/* 链条选择下拉 */}
<div className="relative ml-2">
<button
type="button"
onClick={() => setDropdownOpen((o) => !o)}
className={cn(
"flex min-w-[150px] items-center justify-between gap-3 border border-border bg-card px-3 py-1.5 text-sm text-foreground transition hover:border-primary/50 focus:outline-none focus:ring-1 focus:ring-primary/30",
R_CTRL,
)}
>
<span className="truncate font-medium">{triggerLabel}</span>
<ChevronDown className="size-4 text-muted-foreground" />
</button>
{dropdownOpen && (
<>
<div className="fixed inset-0 z-30" onClick={() => setDropdownOpen(false)} />
<div
className={cn(
"absolute left-0 z-40 mt-1 max-h-[280px] w-[260px] overflow-y-auto border border-border bg-card py-1 shadow-lg",
R_CARD,
)}
>
<div className="border-b border-border px-3 py-1 text-xs font-semibold text-muted-foreground">
切换已有链条
</div>
{chains.length === 0 ? (
<div className="px-3 py-2 text-xs text-muted-foreground">暂无已保存链条</div>
) : (
chains.map((c) => (
<button
key={c.id}
type="button"
onClick={() => {
loadChain(c);
setDropdownOpen(false);
}}
className={cn(
"flex w-full items-center justify-between px-3 py-2 text-xs transition hover:bg-primary/5",
c.id === currentChainId
? "bg-primary/5 font-bold text-primary"
: "text-muted-foreground",
)}
>
<span className="truncate">
{c.title || "未命名链条"}({c.seats.length} 席{c.stages?.length ? " · 分层" : ""})
</span>
{c.id === currentChainId && (
<span className={cn("ml-2 shrink-0 bg-primary/15 px-1.5 py-0.5 text-[10px] text-primary", R_CTRL)}>
当前
</span>
)}
</button>
))
)}
<div className="mt-1 border-t border-border pt-1">
<button
type="button"
onClick={() => {
startNewChain();
setDropdownOpen(false);
}}
className="flex w-full items-center gap-1.5 px-3 py-2 text-xs font-medium text-primary transition hover:bg-primary/5"
>
<Plus className="size-3.5" />
新建另一条链路
</button>
</div>
</div>
</>
)}
</div>
{/* 快捷新建 */}
<button
type="button"
onClick={startNewChain}
className={cn(
"flex items-center gap-1.5 border border-border bg-card px-3 py-1.5 text-sm text-muted-foreground transition hover:bg-muted hover:text-foreground active:scale-[0.98]",
R_CTRL,
)}
>
<Plus className="size-4" />
新建
</button>
{/* 右侧操作 */}
<div className="ml-auto flex items-center gap-2">
{/* 发布 / 取消发布:仅已保存且本人所有的链条。发布后所有账号可在「公共」筛选查看 / 选用。 */}
{currentChainId && currentChainMine && (
<button
type="button"
onClick={handleTogglePublish}
disabled={publishing}
title={currentChainPublic ? "已发布为公共,点击收回为私有" : "发布为公共,所有账号都能查看 / 选用"}
className={cn(
"flex items-center gap-1.5 border px-3 py-1.5 text-sm font-medium transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60",
currentChainPublic
? "border-emerald-400/50 bg-emerald-500/10 text-emerald-600 hover:bg-emerald-500/15 dark:text-emerald-400"
: "border-border bg-card text-muted-foreground hover:bg-muted hover:text-foreground",
R_CTRL,
)}
>
{publishing ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Globe className="size-4" />
)}
{currentChainPublic ? "已发布" : "发布"}
</button>
)}
<button
type="button"
onClick={handleDelete}
className={cn(
"flex items-center gap-1.5 border border-primary/30 bg-card px-3 py-1.5 text-sm font-medium text-primary transition hover:bg-primary/5 active:scale-[0.98]",
R_CTRL,
)}
>
<Trash2 className="size-4" />
删除
</button>
<button
type="button"
onClick={handleSave}
disabled={!canSave}
className={cn(
"flex items-center gap-1.5 bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground shadow-sm transition hover:bg-primary/90 active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-50",
R_CTRL,
)}
>
{saving ? <Loader2 className="size-4 animate-spin" /> : <Save className="size-4" />}
保存
</button>
</div>
</header>
{/* 标题 + 描述 */}
<section className="shrink-0 border-b border-border bg-card px-6 py-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
className={cn(
"flex items-stretch overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
R_CTRL,
)}
>
<span className="flex min-w-[64px] select-none items-center justify-center border-r border-border bg-muted/50 px-4 text-sm font-medium text-muted-foreground">
标题
</span>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:应急方案三段链"
maxLength={120}
className="h-auto rounded-none border-0 bg-transparent px-4 py-2.5 text-sm font-medium shadow-none focus-visible:ring-0 dark:border-0 dark:bg-transparent"
/>
</div>
<div
className={cn(
"flex items-stretch overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
R_CTRL,
)}
>
<span className="flex min-w-[64px] select-none items-center justify-center border-r border-border bg-muted/50 px-4 text-sm font-medium text-muted-foreground">
描述
</span>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="链条用途说明(可选)"
maxLength={500}
className="h-auto rounded-none border-0 bg-transparent px-4 py-2.5 text-sm font-medium shadow-none focus-visible:ring-0 dark:border-0 dark:bg-transparent"
/>
</div>
</div>
</section>
{/* 双栏拖拽编辑器 */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<main className="grid min-h-0 flex-1 grid-cols-1 gap-6 overflow-hidden px-6 py-6 lg:grid-cols-12">
{/* 左栏:候选智能体 */}
<section
className={cn(
"flex min-h-0 flex-col overflow-hidden border border-border bg-card lg:col-span-4",
R_CARD,
PANEL_SHADOW,
)}
>
<div className="flex shrink-0 items-center justify-between border-b border-border bg-muted/40 px-5 py-4">
<div className="flex items-center gap-2">
<span className={cn("flex size-5 items-center justify-center bg-primary/10", R_CTRL)}>
<UserPlus className="size-3.5 text-primary" />
</span>
<h2 className="text-sm font-bold tracking-wide text-foreground">候选智能体</h2>
<span
className={cn(
"min-w-[20px] border border-primary/30 bg-primary/10 px-2 py-0.5 text-center text-xs font-bold text-primary tabular-nums",
"rounded-full",
)}
>
{pool.length}
</span>
</div>
<span className="text-[11px] font-medium text-muted-foreground">拖到右侧 / 点击加入</span>
</div>
<div className="shrink-0 border-b border-border px-4 py-3">
<TagSearchBar
targetType="agent"
onSearchChange={setSearch}
tagIds={filterTagIds}
onTagIdsChange={setFilterTagIds}
/>
</div>
<div className="min-h-0 flex-1 space-y-2.5 overflow-y-auto p-4">
{poolLoading ? (
<div className="flex items-center gap-2 px-2 py-6 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
正在加载候选智能体…
</div>
) : pool.length === 0 ? (
<div className="px-2 py-12 text-center text-sm text-muted-foreground">
{search || filterTagIds.length
? "没有匹配的智能体,换个关键词或标签试试。"
: `候选池为空,请先在「智能体」里创建至少 ${CHAIN_MIN_SEATS} 个智能体。`}
</div>
) : (
pool.map((a) => (
<PoolAgentCard
key={a.agent_id}
agent={a}
inChain={seatIdSet.has(a.agent_id)}
onAdd={() => addAgentAsNewStage(a.agent_id)}
/>
))
)}
</div>
</section>
{/* 右栏:分层 Stage */}
<StagesColumn
stageGroups={stageGroups}
seatCount={seatCount}
seatCountInvalid={seatCountInvalid}
hasEmptyStage={hasEmptyStage}
onAddStage={appendEmptyStage}
onRemoveStage={removeStage}
onReorderStage={reorderStage}
onRemoveSeat={removeSeat}
onMergeSeat={mergeSeatToStage}
onSplitSeat={splitSeat}
onStageGoalChange={setGoal}
/>
</main>
<DragOverlay>
{activeDrag ? (
<div
className={cn(
"flex items-center gap-2 border border-primary/50 bg-card px-3 py-2 text-sm font-medium text-foreground shadow-[0_4px_12px_rgba(15,23,42,0.12)]",
R_CTRL,
)}
>
<GripVertical className="size-4 text-primary" />
{activeDrag.name}
</div>
) : null}
</DragOverlay>
</DndContext>
{/* Phase 7:分层 DAG 只读流程图预览(§2.2 底部)。空编排时不渲染。
改为弹窗查看:底栏一个入口按钮,点击后在 Dialog 内展示完整流程图。 */}
{previewStages.length > 0 && (
<section className="shrink-0 flex items-center justify-between gap-2 border-t border-border bg-card px-6 py-2">
<span className="flex items-center gap-2 text-[11px] font-semibold text-muted-foreground">
<span>编排预览</span>
<span className="text-muted-foreground/50">
总控派活 → 各 Stage(串行)→ 共识 → 结果绘制
</span>
</span>
<button
type="button"
onClick={() => setFlowOpen(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>
</section>
)}
{/* 编排流程图弹窗 */}
{previewStages.length > 0 && (
<Dialog open={flowOpen} onOpenChange={setFlowOpen}>
<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 pt-2">
<DagFlowChart
stages={previewStages}
resolveSeat={resolvePreviewSeat}
showReportMilestone={false}
/>
</div>
</DialogContent>
</Dialog>
)}
{/* 页脚 */}
<footer className="shrink-0 select-none border-t border-border bg-card px-6 py-3 text-center text-[11px] text-muted-foreground">
分层编排:同一 Stage 内的席位并行研讨,Stage 之间串行。全单席位 = 线性链。保存后自动同步至圆桌。
</footer>
</div>
);
}
// ── 左栏候选卡片(draggable)─────────────────────────────────────────────
function PoolAgentCard({
agent,
inChain,
onAdd,
}: {
agent: RecommendCandidate;
inChain: boolean;
onAdd: () => void;
}) {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: POOL_PREFIX + agent.agent_id,
disabled: inChain,
});
return (
<div
ref={setNodeRef}
onClick={inChain ? undefined : onAdd}
className={cn(
"group relative flex items-start gap-3 border p-3.5 transition-all duration-150",
R_CARD,
inChain
? "cursor-default border-primary/30 bg-primary/[0.03]"
: "cursor-pointer border-border bg-card hover:border-primary hover:shadow-[0_2px_8px_rgba(0,0,0,0.04)]",
isDragging && "opacity-30",
)}
{...(inChain ? {} : listeners)}
{...attributes}
>
<div
className={cn(
"mt-0.5 flex size-6 shrink-0 items-center justify-center transition-colors",
R_CARD,
inChain
? "bg-primary/10 text-primary"
: "bg-muted text-muted-foreground group-hover:bg-primary/10 group-hover:text-primary",
)}
>
<Bot className="size-3.5" />
</div>
<div className="min-w-0 flex-1 pr-16">
<div className="truncate text-xs font-bold leading-tight text-foreground transition-colors group-hover:text-primary">
{agent.name || agent.agent_id}
</div>
{agent.description && (
<div className="mt-1 line-clamp-2 text-[11px] font-medium leading-normal text-muted-foreground">
{agent.description}
</div>
)}
</div>
<div className="absolute right-3.5 top-3.5 shrink-0 select-none">
{inChain ? (
<span className={cn("border border-primary/30 bg-primary/10 px-2 py-1 text-[10px] font-bold text-primary", R_CTRL)}>
已加入
</span>
) : (
<span
className={cn(
"border border-transparent bg-muted px-2 py-1 text-[10px] font-semibold text-muted-foreground transition-all group-hover:border-primary/30 group-hover:bg-primary/10 group-hover:text-primary",
R_CTRL,
)}
>
点击加入
</span>
)}
</div>
</div>
);
}
// ── 右栏(分层 Stage 容器)───────────────────────────────────────────────
function StagesColumn({
stageGroups,
seatCount,
seatCountInvalid,
hasEmptyStage,
onAddStage,
onRemoveStage,
onReorderStage,
onRemoveSeat,
onMergeSeat,
onSplitSeat,
onStageGoalChange,
}: {
stageGroups: StageGroups;
seatCount: number;
seatCountInvalid: boolean;
hasEmptyStage: boolean;
onAddStage: () => void;
onRemoveStage: (stageIdx: number) => void;
onReorderStage: (stageIdx: number, dir: -1 | 1) => void;
onRemoveSeat: (agentId: string) => void;
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
onSplitSeat: (agentId: string) => void;
onStageGoalChange: (stageIdx: number, goal: string) => void;
}) {
// 跨 Stage 的全局序号(头像编号):每个 Stage 起始累加。
const stageStartIndex = useMemo(() => {
const out: number[] = [];
let acc = 0;
for (const g of stageGroups) {
out.push(acc);
acc += g.seats.length;
}
return out;
}, [stageGroups]);
// 是否含并行 stage → 决定提示「目标仅对含并行的分层链条生效」。
const hasParallelStage = stageGroups.some((g) => g.seats.length > 1);
return (
<section
className={cn(
"flex min-h-0 flex-col overflow-hidden border border-border bg-card lg:col-span-8",
R_CARD,
PANEL_SHADOW,
)}
>
<div className="flex shrink-0 items-center justify-between border-b border-border bg-muted/40 px-5 py-4">
<div className="flex items-center gap-2">
<span className={cn("flex size-5 items-center justify-center bg-primary/10", R_CTRL)}>
<Layers className="size-3.5 text-primary" />
</span>
<h2 className="text-sm font-bold tracking-wide text-foreground">链条编排</h2>
<span className={cn("border border-border bg-muted px-2 py-0.5 text-[10px] font-bold text-muted-foreground", R_CTRL)}>
同层并行 · 层间串行
</span>
</div>
<div className="flex select-none items-center gap-2 text-xs">
<button
type="button"
onClick={onAddStage}
className={cn(
"flex items-center gap-1 border border-primary/30 bg-primary/5 px-2 py-1 font-medium text-primary transition hover:bg-primary/10",
R_CTRL,
)}
>
<Plus className="size-3.5" />
新建 Stage
</button>
<span
className={cn(
"border px-2 py-0.5 font-bold tabular-nums",
R_CTRL,
seatCountInvalid
? "border-destructive/30 bg-destructive/10 text-destructive"
: "border-primary/30 bg-primary/10 text-primary",
)}
>
{seatCount} / {CHAIN_MAX_SEATS}
{seatCount < CHAIN_MIN_SEATS && ` · 至少 ${CHAIN_MIN_SEATS}`}
</span>
</div>
</div>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-5">
{seatCount === 0 && stageGroups.length === 0 ? (
<div
className={cn(
"m-1 flex h-full flex-col items-center justify-center border border-dashed border-border bg-card/50 px-8 py-16 text-center",
R_CARD,
)}
>
<div className="mb-3 flex size-12 items-center justify-center rounded-full bg-primary/10 text-primary">
<Layers className="size-6" />
</div>
<h3 className="mb-1 text-sm font-bold text-foreground">暂无席位</h3>
<p className="max-w-sm text-xs leading-relaxed text-muted-foreground">
从左侧「候选智能体」点击或拖拽加入。每次加入是一个串行 Stage;把席位拖到同一个
Stage 里即可让它们并行。
</p>
</div>
) : (
<>
{stageGroups.map((group, stageIdx) => (
<StageSection
key={stageIdx}
stageIdx={stageIdx}
group={group}
stageCount={stageGroups.length}
startIndex={stageStartIndex[stageIdx]}
hasParallelStage={hasParallelStage}
onRemoveStage={() => onRemoveStage(stageIdx)}
onReorderStage={(dir) => onReorderStage(stageIdx, dir)}
onRemoveSeat={onRemoveSeat}
onMergeSeat={onMergeSeat}
onSplitSeat={onSplitSeat}
onGoalChange={(goal) => onStageGoalChange(stageIdx, goal)}
/>
))}
<NewStageDropZone hasEmptyStage={hasEmptyStage} onAddStage={onAddStage} />
</>
)}
</div>
</section>
);
}
// ── 单个 Stage(droppable 容器 + Stage 内 sortable 列表)──────────────────
function StageSection({
stageIdx,
group,
stageCount,
startIndex,
hasParallelStage,
onRemoveStage,
onReorderStage,
onRemoveSeat,
onMergeSeat,
onSplitSeat,
onGoalChange,
}: {
stageIdx: number;
group: StageGroup;
stageCount: number;
startIndex: number;
/** 整条链是否含并行 stage(决定目标输入框的提示语,目标仅 dag 模式生效)。 */
hasParallelStage: boolean;
onRemoveStage: () => void;
onReorderStage: (dir: -1 | 1) => void;
onRemoveSeat: (agentId: string) => void;
onMergeSeat: (agentId: string, targetStageIdx: number) => void;
onSplitSeat: (agentId: string) => void;
onGoalChange: (goal: string) => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: STAGE_PREFIX + stageIdx });
const seats = group.seats;
const isParallel = seats.length > 1;
const isFirstStage = stageIdx === 0;
const isLastStage = stageIdx === stageCount - 1;
const seatItemIds = seats.map((s) => SEAT_PREFIX + s.agent_id);
return (
<div className={cn("border border-border bg-muted/20", R_CARD, PANEL_SHADOW)}>
{/* Stage 头 */}
<div className="flex items-center justify-between gap-2 border-b border-border bg-muted/40 px-3 py-2">
<div className="flex items-center gap-2">
<span
className={cn(
"flex items-center gap-1 border px-2 py-0.5 text-[11px] font-bold tabular-nums",
R_CTRL,
"border-primary/30 bg-primary/10 text-primary",
)}
>
<GitBranch className="size-3" />
Stage {stageIdx + 1}
</span>
<span
className={cn(
"border px-1.5 py-0.5 text-[10px] font-semibold",
R_CTRL,
isParallel
? "border-amber-400/40 bg-amber-400/10 text-amber-600 dark:text-amber-300"
: "border-border bg-muted text-muted-foreground",
)}
>
{isParallel ? `并行 ${seats.length}` : "串行"}
</span>
</div>
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={() => onReorderStage(-1)}
disabled={isFirstStage}
title="上移 Stage"
className={cn(
"p-1 transition",
R_CTRL,
isFirstStage
? "pointer-events-none text-muted-foreground/30"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<ChevronUp className="size-3.5" />
</button>
<button
type="button"
onClick={() => onReorderStage(1)}
disabled={isLastStage}
title="下移 Stage"
className={cn(
"p-1 transition",
R_CTRL,
isLastStage
? "pointer-events-none text-muted-foreground/30"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<ChevronDown className="size-3.5" />
</button>
<button
type="button"
onClick={onRemoveStage}
title="删除 Stage"
className={cn("ml-0.5 p-1 text-muted-foreground/60 transition-colors hover:bg-primary/10 hover:text-primary", R_CTRL)}
>
<Trash2 className="size-3.5" />
</button>
</div>
</div>
{/* 本阶段目标:告诉总控本阶段要产出什么、如何依赖上一阶段产出(仅含并行的分层链条生效)。 */}
{seats.length > 0 && (
<div className="border-b border-border bg-muted/10 px-2.5 py-2">
<textarea
value={group.goal ?? ""}
onChange={(e) => onGoalChange(e.target.value)}
maxLength={500}
rows={2}
placeholder={
isFirstStage
? "本阶段目标(可选):本阶段要产出什么,供后续阶段承接"
: "本阶段目标(可选):本阶段要产出什么、如何依赖上一阶段产出"
}
className={cn(
"w-full resize-y bg-card px-2.5 py-1.5 text-[11px] leading-relaxed text-foreground placeholder:text-muted-foreground/60",
"border border-border focus:border-primary/40 focus:outline-none",
R_CTRL,
)}
/>
{!hasParallelStage && (group.goal?.trim()?.length ?? 0) > 0 && (
<p className="mt-1 text-[10px] text-amber-600 dark:text-amber-400">
提示:当前链条全为串行,目标仅在含并行 Stage 的分层(DAG)研讨中生效。
</p>
)}
</div>
)}
{/* Stage 内席位 */}
<div
ref={setNodeRef}
className={cn("space-y-2 p-2.5 transition-colors", isOver && "bg-primary/[0.04]")}
>
{seats.length === 0 ? (
<div className={cn("border border-dashed border-border bg-card/40 px-3 py-4 text-center text-[11px] text-muted-foreground", R_CARD)}>
空 Stage —— 把席位拖到此处,或删除该 Stage。
</div>
) : (
<SortableContext items={seatItemIds} strategy={verticalListSortingStrategy}>
{seats.map((seat, i) => (
<SeatStrip
key={seat.agent_id}
seat={seat}
globalIndex={startIndex + i}
stageIdx={stageIdx}
isFirstStage={isFirstStage}
isLastStage={isLastStage}
canSplit={isParallel}
onRemove={() => onRemoveSeat(seat.agent_id)}
onMergeUp={() => onMergeSeat(seat.agent_id, stageIdx - 1)}
onMergeDown={() => onMergeSeat(seat.agent_id, stageIdx + 1)}
onSplit={() => onSplitSeat(seat.agent_id)}
/>
))}
</SortableContext>
)}
</div>
</div>
);
}
// ── 单个席位长条(sortable)─────────────────────────────────────────────
function SeatStrip({
seat,
globalIndex,
isFirstStage,
isLastStage,
canSplit,
onRemove,
onMergeUp,
onMergeDown,
onSplit,
}: {
seat: ChainSeat;
globalIndex: number;
stageIdx: number;
isFirstStage: boolean;
isLastStage: boolean;
canSplit: boolean;
onRemove: () => void;
onMergeUp: () => void;
onMergeDown: () => void;
onSplit: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: SEAT_PREFIX + seat.agent_id });
const avatarType = getAvatarTypeFor(seat.agent_id, globalIndex);
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(
"flex items-stretch overflow-hidden border border-border bg-card",
R_CARD,
PANEL_SHADOW,
isDragging && "z-10 opacity-70 shadow-[0_4px_12px_rgba(15,23,42,0.12)]",
)}
>
{/* 拖拽手柄 */}
<button
type="button"
className="flex w-7 shrink-0 cursor-grab touch-none items-center justify-center border-r border-border bg-muted/40 text-muted-foreground/60 transition-colors hover:text-foreground active:cursor-grabbing"
{...attributes}
{...listeners}
title="拖动到其它 Stage / 排序"
>
<GripVertical className="size-3.5" />
</button>
{/* 序号 */}
<div className="flex shrink-0 select-none items-center pl-2.5">
<span
className={cn(
"flex size-6 items-center justify-center border text-xs font-bold tabular-nums",
R_CTRL,
chainTagClasses(avatarType),
)}
>
{globalIndex + 1}
</span>
</div>
{/* 详情 */}
<div className="flex min-w-0 flex-1 flex-col justify-center px-2.5 py-2">
<div className="truncate text-xs font-bold text-foreground">{seat.name || seat.agent_id}</div>
{seat.description && (
<div className="mt-0.5 line-clamp-1 text-[11px] font-medium text-muted-foreground">
{seat.description}
</div>
)}
</div>
{/* 控制:并入上一阶段 / 独立成阶段 / 并入下一阶段 / 移除 */}
<div className="flex shrink-0 items-center gap-0.5 border-l border-border px-2">
<button
type="button"
onClick={onMergeUp}
disabled={isFirstStage}
title="并入上一阶段(与其并行)"
className={cn(
"p-1 transition",
R_CTRL,
isFirstStage
? "pointer-events-none text-muted-foreground/30"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<ChevronUp className="size-3.5" />
</button>
<button
type="button"
onClick={onSplit}
disabled={!canSplit}
title="从本阶段拆出,独立成新阶段"
className={cn(
"p-1 transition",
R_CTRL,
!canSplit
? "pointer-events-none text-muted-foreground/30"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<SplitSquareVertical className="size-3.5" />
</button>
<button
type="button"
onClick={onMergeDown}
disabled={isLastStage}
title="并入下一阶段(与其并行)"
className={cn(
"p-1 transition",
R_CTRL,
isLastStage
? "pointer-events-none text-muted-foreground/30"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<ChevronDown className="size-3.5" />
</button>
<button
type="button"
onClick={onRemove}
title="移出此席位"
className={cn(
"ml-0.5 p-1 text-muted-foreground/60 transition-colors hover:bg-primary/10 hover:text-primary",
R_CTRL,
)}
>
<X className="size-3.5" />
</button>
</div>
</div>
);
}
// ── 「新建 Stage」落区(拖席位到此 = 独立成最后一个 Stage)──────────────────
function NewStageDropZone({
hasEmptyStage,
onAddStage,
}: {
hasEmptyStage: boolean;
onAddStage: () => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: NEWSTAGE_DROPPABLE_ID });
return (
<button
type="button"
ref={setNodeRef}
onClick={onAddStage}
disabled={hasEmptyStage}
className={cn(
"flex w-full items-center justify-center gap-1.5 border border-dashed px-3 py-3 text-xs font-medium transition",
R_CARD,
isOver
? "border-primary bg-primary/[0.06] text-primary"
: "border-border bg-card/40 text-muted-foreground hover:border-primary/40 hover:text-primary",
hasEmptyStage && "cursor-not-allowed opacity-50 hover:border-border hover:text-muted-foreground",
)}
title={hasEmptyStage ? "已有空 Stage,先填充它" : "新建一个串在最后的 Stage"}
>
<Plus className="size-3.5" />
新建 Stage(串在最后) · 或把席位拖到此处独立成阶段
</button>
);
}