- 业务链条配置页流程图从底部内嵌改为按钮 + 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>
1416 lines
53 KiB
TypeScript
1416 lines
53 KiB
TypeScript
/**
|
||
* 业务链条配置页面(路由 `/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>
|
||
);
|
||
}
|