This commit is contained in:
董宏凯 committed 2026-06-02 21:26:21 +08:00
commit fdfb888be9
20 files changed
+2441 -24

No files matched your search

@@ -39,6 +39,7 @@ import AIWritingPage from "@/open-canvas/pages/AIWritingPage";
import StudioNotebooksPage from "./StudioNotebooksPage";
import StudioNotebookDetailPage from "./StudioNotebookDetailPage";
import RoundtablePlanningPage from "@/roundtable-planning/pages/RoundtablePlanningPage";
import BusinessChainEditorPage from "@/roundtable-planning/pages/BusinessChainEditorPage";
import "@/roundtable-planning/styles/roundtable-planning.css";
export function WorkspaceLayout({
@@ -250,6 +251,14 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="roundtable/chains"
element={
<WorkspaceLayout {...layoutProps}>
<BusinessChainEditorPage />
</WorkspaceLayout>
}
/>
<Route
path="scheduled"
element={<Navigate to="/page/workspace/scheduled/tasks" replace />}
@@ -0,0 +1,129 @@
/**
* Backend-persisted business-chain storage for /page/roundtable/planning.
*
* A business chain is a named, reusable, *ordered* list of agents. Persisted
* server-side (MySQL) bound to the logged-in user, so it follows the account
* across devices/sessions. Backend: `/api/roundtable-chains` (see
* `app/gateway/routers/roundtable_chains.py`).
*
* snake_case ⇄ camelCase mapping is collapsed at this boundary (mirroring
* `api/drafts.ts`) so callers stay clean.
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
/** Fields accepted when creating a chain. */
export interface ChainCreatePayload {
/** Optional stable id so the client can keep one id across create + updates. */
id?: string;
title: string;
description?: string;
seats: ChainSeat[];
}
/** Partial update — send only what changed. */
export interface ChainUpdatePayload {
title?: string;
description?: string;
seats?: ChainSeat[];
}
const BASE = () => `${getBackendBaseURL()}/api/roundtable-chains`;
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${ctx} failed (${res.status}): ${text}`);
}
return (await res.json()) as T;
}
// ── server ⇄ client field mapping ──────────────────────────────────────────
// Backend uses snake_case (created_at / updated_at); the rest of the frontend
// reads camelCase. `seats` is already camel-clean ({agent_id, name, description}).
interface ServerChain {
id: string;
title: string;
description: string | null;
seats: ChainSeat[];
created_at: string | null;
updated_at: string | null;
}
function toChain(s: ServerChain): BusinessChain {
return {
id: s.id,
title: s.title ?? "",
description: s.description ?? undefined,
seats: Array.isArray(s.seats) ? s.seats : [],
createdAt: s.created_at ?? undefined,
updatedAt: s.updated_at ?? undefined,
};
}
function fromCreate(p: ChainCreatePayload) {
return {
...(p.id ? { id: p.id } : {}),
title: p.title,
...(p.description !== undefined ? { description: p.description } : {}),
seats: p.seats,
};
}
function fromUpdate(p: ChainUpdatePayload) {
const out: Record<string, unknown> = {};
if (p.title !== undefined) out.title = p.title;
if (p.description !== undefined) out.description = p.description;
if (p.seats !== undefined) out.seats = p.seats;
return out;
}
// ── CRUD ────────────────────────────────────────────────────────────────────
/** List the current user's chains, newest first. */
export async function listChains(): Promise<BusinessChain[]> {
const res = await apiFetch(BASE(), { method: "GET" });
const data = await readJson<{ chains: ServerChain[] }>(res, "list chains");
return (data.chains ?? []).map(toChain);
}
/** Fetch one chain. Returns null on 404. */
export async function getChain(id: string): Promise<BusinessChain | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "GET" });
if (res.status === 404) return null;
const data = await readJson<ServerChain>(res, "get chain");
return toChain(data);
}
export async function createChain(payload: ChainCreatePayload): Promise<BusinessChain> {
const res = await apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fromCreate(payload)),
});
const data = await readJson<ServerChain>(res, "create chain");
return toChain(data);
}
export async function updateChain(id: string, patch: ChainUpdatePayload): Promise<BusinessChain | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(fromUpdate(patch)),
});
if (res.status === 404) return null;
const data = await readJson<ServerChain>(res, "update chain");
return toChain(data);
}
export async function deleteChain(id: string): Promise<void> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(id)}`, { method: "DELETE" });
if (!res.ok && res.status !== 404) {
const text = await res.text().catch(() => "");
throw new Error(`delete chain failed (${res.status}): ${text}`);
}
}
@@ -45,6 +45,15 @@ export interface DraftStep2Snapshot {
budgetLimit: number;
/** Per-seat stub AI messages for the sandbox `write-file:` virtual URL loader. */
seatStubMessages?: Record<string, unknown[]>;
/**
* Step 2 orchestration mode (2026-06 business chains). recommend = leader
* dispatches freely; chain = dispatch follows the saved business chain order.
* Optional — drafts saved before this won't carry it (hydrate defaults to
* "recommend").
*/
orchestrationMode?: "recommend" | "chain";
/** Source business chain (display only), when `orchestrationMode === "chain"`. */
chain?: { id: string; title: string } | null;
}
/** Lightweight metadata row returned by the list endpoint. */
@@ -0,0 +1,266 @@
/**
* 业务链条选择面板(推荐弹窗「业务链条」模式的内容区)。
*
* 拉取当前用户的业务链条(`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 { 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;
const handleUse = () => {
if (!canUse || !selectedChain) return;
const agents = survivingSeats.map((s) => ({
agent_id: s.agent_id,
name: nameById.get(s.agent_id) ?? s.name,
}));
onConfirm({
agents,
mode: "chain",
chain: { id: selectedChain.id, title: selectedChain.title },
});
};
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>
<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>
)}
<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>
);
}
@@ -1,10 +1,12 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
CheckIcon,
ChevronDownIcon,
ChevronUpIcon,
LayersIcon,
Loader2Icon,
RefreshCwIcon,
SparklesIcon,
@@ -29,7 +31,9 @@ import {
StreamingCursor,
} from "./MessageBubble";
import { MessageStepsCard } from "./MessageStepsCard";
import { BusinessChainPicker } from "./BusinessChainPicker";
import type { IntentSummary } from "../api/intent";
import type { SeatSelectionResult } from "../lib/business-chain";
import {
filterRecommendCandidates,
listRecommendHistory,
@@ -68,8 +72,12 @@ export interface RecommendAgentsDialogProps {
*/
draftId?: string | null;
onCancel: () => void;
/** Called with the user's final selection of agent ids (in display order). */
onConfirm: (agents: Array<{ agent_id: string; name: string }>) => void;
/**
* Called with the user's final seat selection + the orchestration mode.
* 智能推荐模式回传 `{ agents, mode: "recommend" }`;业务链条模式(阶段 3)
* 回传 `{ agents, mode: "chain", chain }`。
*/
onConfirm: (result: SeatSelectionResult) => void;
}
const MIN_SELECTED = 2;
@@ -189,6 +197,9 @@ export function RecommendAgentsDialog({
// 映射也作用于弹窗;浅色不挂,弹窗浅色外观不变。
const { theme } = useTheme();
const isDark = theme === "dark";
const navigate = useNavigate();
// 席位来源模式:智能推荐 / 业务链条。默认**业务链条**。
const [mode, setMode] = useState<"recommend" | "chain">("chain");
const [state, setState] = useState<InternalState>(INITIAL_STATE);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
@@ -442,18 +453,27 @@ export function RecommendAgentsDialog({
[intent, draftId, cancelInflight, analyze, applyHistory],
);
// Trigger the pipeline whenever the dialog opens. Closing it aborts any
// in-flight stream so the next open starts clean.
// 推荐管线只在「智能推荐」模式下跑一次。默认是业务链条模式 —— 此时不触发推荐
// 模型调用,等用户切到「智能推荐」tab 才启动;每次 open 内只跑一次(切 tab 来回
// 不重复跑),关闭时复位 latch。
const recommendStartedRef = useRef(false);
// 关闭 / 卸载时中止 in-flight 推荐流。
useEffect(() => {
if (!open) {
cancelInflight();
return;
}
void start(false);
return () => {
cancelInflight();
};
}, [open, start, cancelInflight]);
}, [cancelInflight]);
useEffect(() => {
if (!open) {
recommendStartedRef.current = false;
cancelInflight();
return;
}
if (mode === "recommend" && !recommendStartedRef.current) {
recommendStartedRef.current = true;
void start(false);
}
}, [open, mode, start, cancelInflight]);
const handleToggle = useCallback((agentId: string) => {
setSelectedIds((prev) => {
@@ -510,9 +530,14 @@ export function RecommendAgentsDialog({
for (const c of otherCards) {
if (selectedIds.has(c.agent_id)) ordered.push({ agent_id: c.agent_id, name: c.name });
}
onConfirm(ordered);
onConfirm({ agents: ordered, mode: "recommend" });
}, [canConfirm, recommendedCards, otherCards, selectedIds, onConfirm]);
const handleGoConfig = useCallback(() => {
onCancel();
navigate("/page/workspace/roundtable/chains");
}, [navigate, onCancel]);
const handleOpenChange = useCallback(
(next: boolean) => {
if (!next) onCancel();
@@ -534,10 +559,45 @@ export function RecommendAgentsDialog({
选择参会智能体
</DialogTitle>
<DialogDescription>
根据你的任务目标,系统为你推荐了最合适的智能体。你可以取消勾选,也可以从下方添加其他智能体。
{mode === "recommend"
? "根据你的任务目标,系统为你推荐了最合适的智能体。你可以取消勾选,也可以从下方添加其他智能体。"
: "从已配置的业务链条中选择一条,Step 2 将按链条顺序进行多智能体研讨。"}
</DialogDescription>
</DialogHeader>
{/* 模式选择:智能推荐 / 业务链条 */}
<div className="shrink-0 inline-flex self-start rounded-lg border border-border bg-muted/40 p-0.5 text-xs font-medium">
<button
type="button"
onClick={() => setMode("recommend")}
className={cn(
"flex items-center gap-1.5 rounded-md px-3 py-1.5 transition-colors",
mode === "recommend"
? "bg-card text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<SparklesIcon className="size-3.5" />
智能推荐
</button>
<button
type="button"
onClick={() => setMode("chain")}
className={cn(
"flex items-center gap-1.5 rounded-md px-3 py-1.5 transition-colors",
mode === "chain"
? "bg-card text-primary shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
<LayersIcon className="size-3.5" />
业务链条
</button>
</div>
{/* ===== 智能推荐模式(现状,零行为变更)===== */}
{mode === "recommend" && (
<>
{/* ============================================================
TOP: 模型生成内容
包含状态行 + 步骤卡 + 思考过程(`<think>`)+ 推荐理由 markdown。
@@ -684,6 +744,17 @@ export function RecommendAgentsDialog({
</Button>
</div>
</DialogFooter>
</>
)}
{/* ===== 业务链条模式 ===== */}
{mode === "chain" && (
<BusinessChainPicker
onConfirm={onConfirm}
onCancel={onCancel}
onGoConfig={handleGoConfig}
/>
)}
</DialogContent>
</Dialog>
);
@@ -104,6 +104,15 @@ export type Step2Snapshot = {
* 老草稿可能没有这字段,反序列化时按空对象兜底。
*/
seatStubMessages: Record<string, StubAiMessage[]>;
/**
* 本次 Step 2 编排模式(2026-06 业务链条)。recommend = 总控自由派活;
* chain = 按业务链条顺序派活。老草稿无此字段 → 加载时默认 recommend。
* 注:该字段是主页面 state,由页面在 getStep2Snapshot/hydrateStep2 里读写,
* step2 hook 的 hydrateFromDraft 不直接消费它。
*/
orchestrationMode?: "recommend" | "chain";
/** chain 模式来源链条(仅展示用,如「按业务链条 X 研讨」)。 */
chain?: { id: string; title: string } | null;
};
/**
@@ -119,11 +128,74 @@ const DEFAULT_AGENT_CONFIGS: Record<number, AgentConfig> = {
7: { model: "", weight: 80, temp: 0.4, prompt: "你负责对72小时里程碑执行路径规划、重排和资源调配演练序列。" },
};
/**
* chain 模式:为「链条第 i 个席位」构造**约束总控只能派给它**的 leader prompt。
* 后端不变 —— 靠 prompt 把派活目标钉死成链条下一席位,再由 `pickChainTask` 校验/纠偏。
*/
function buildChainLeaderTask(args: {
seedMessage: string;
position: number;
total: number;
seatAgentId: string;
seatName: string;
isFirst: boolean;
}): string {
const { seedMessage, position, total, seatAgentId, seatName, isFirst } = args;
const ordinal = `${position + 1}/${total}`;
if (isFirst) {
return [
"这是一次【按业务链条顺序】进行的多智能体圆桌研讨。任务背景如下:",
seedMessage,
"",
`本次研讨严格按预设链条顺序进行,共 ${total} 个席位,现在是第 ${ordinal} 个席位。`,
`请你作为总控,通过 agent_orchestration 技能**仅**向智能体「${seatName}」(agent_name: ${seatAgentId})派活,结合任务背景为它分配聚焦其职责的本轮任务。`,
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。",
].join("\n");
}
return [
`链条研讨继续,现在轮到第 ${ordinal} 个席位。`,
`请你作为总控,结合此前各席位已完成的交付,通过 agent_orchestration 技能**仅**向智能体「${seatName}」(agent_name: ${seatAgentId})派活,为它分配承接前序结论、聚焦其职责的本轮任务。`,
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。",
].join("\n");
}
/**
* 从 leader 终态里取出「派给当前链条席位」的任务文本,带纠偏:
* 1) 命中 agent_name === seatAgentId 的派活 → 用它的 task;
* 2) 总控派了活但写偏 id → 仍用它给出的 task 文本(强制跑正确席位);
* 3) 总控没派活只写了说明 → 用 leader 正文,再不行回退到模板任务。
* 无论哪种,调用方都强制对**链条席位**跑 special,确保游标严格推进。
*/
function pickChainTask(
dispatched: LeaderDispatch[],
leaderContent: string,
seatAgentId: string,
seatName: string,
): string {
const wanted = seatAgentId.toLowerCase();
const exact = dispatched.find(([name]) => name.toLowerCase() === wanted);
if (exact && exact[1]?.trim()) return exact[1];
const any = dispatched.find(([, task]) => task?.trim());
if (any && any[1]?.trim()) return any[1];
if (leaderContent?.trim()) {
return `请你作为「${seatName}」,基于以下总控说明与此前各席位的交付,从你的专业职责出发给出你的分析与交付:\n${leaderContent.trim()}`;
}
return `请你作为「${seatName}」,基于任务背景与此前各席位的交付,从你的专业职责出发给出你的分析与交付。`;
}
export interface UseStep2OrchestrationOptions {
/** 当前页面 currentStep,===2 触发自动 init。 */
currentStep: number;
/** 全局选中模型(顶栏「推理模型」下拉)。 */
selectedModel: string;
/**
* 编排模式:
* - `recommend`(默认)—— 总控自由派活循环(`runOrchestration`);
* - `chain` —— 总控**按业务链条顺序**逐位派活(`runChainOrchestration`)。
* 由推荐弹窗确认时的 `SeatSelectionResult.mode` 决定;chain 模式下席位顺序 =
* `selectedAgents` 的顺序。
*/
orchestrationMode: "recommend" | "chain";
/** 进入 Step 2 时拼装第一轮 leader prompt 的函数(读 Step 1 状态)。 */
buildInitialIntent: () => string;
/** 主页面统一的 toast。 */
@@ -224,6 +296,7 @@ export function useStep2Orchestration(
const {
currentStep,
selectedModel,
orchestrationMode,
buildInitialIntent,
triggerToast,
setRoles,
@@ -321,6 +394,15 @@ export function useStep2Orchestration(
const threadIdsRef = useRef<ThreadIdMap | null>(threadIds);
const coordinatorNameRef = useRef<string | null>(coordinatorName);
const isOrchestratingRef = useRef<boolean>(isOrchestrating);
// chain 编排:模式 + 当前选中席位(=链条顺序)。同样打到 ref,让编排函数 deps 收窄。
const orchestrationModeRef = useRef(orchestrationMode);
const selectedAgentsRef = useRef<SelectedAgent[]>(selectedAgents);
useEffect(() => {
orchestrationModeRef.current = orchestrationMode;
}, [orchestrationMode]);
useEffect(() => {
selectedAgentsRef.current = selectedAgents;
}, [selectedAgents]);
useEffect(() => {
selectedModelRef.current = selectedModel;
}, [selectedModel]);
@@ -1138,6 +1220,250 @@ export function useStep2Orchestration(
],
);
// ─── Chain orchestration loop ─────────────────────────────────────
// chain 模式:总控**按 selectedAgents(=链条)顺序**逐位派活,而非自由派活。
// 每位:(a) 约束 leader 只派给该席位 → (b) 对该席位跑 special;走完整链后 (c)
// 由 leader 收口产出最终结论。后端零改动 —— 顺序与约束全在前端 prompt + 校验。
// **不要并行 special**(与 runOrchestration 同一不变量)。
const runChainOrchestration = useCallback(
async (seedMessage: string, ids: ThreadIdMap, coord: string) => {
if (isOrchestratingRef.current) return;
setIsOrchestrating(true);
orchestrationCancelRef.current = false;
const chainSeats = selectedAgentsRef.current;
const total = chainSeats.length;
const leaderThreadId = resolveSeatThreadId(ids, coord);
let finishedAllSeats = true;
try {
for (let i = 0; i < total; i += 1) {
if (orchestrationCancelRef.current) {
finishedAllSeats = false;
break;
}
const seat = chainSeats[i];
const meta = agentNameToRoleRef.current[seat.agent_id];
const seatName = meta?.displayName ?? seat.name ?? seat.agent_id;
// ── (a) 总控按链条顺序派活给当前席位 ──
highlightSpeaker(null);
const leaderBubbleId = appendStreamingDialogue({
sender: "总控协调",
avatarType: "blue",
initialContent: "",
confidence: "链条派活中",
agentThreadId: leaderThreadId,
});
const leaderTask = buildChainLeaderTask({
seedMessage,
position: i,
total,
seatAgentId: seat.agent_id,
seatName,
isFirst: i === 0,
});
const leaderAborter = new AbortController();
activeAbortersRef.current.add(leaderAborter);
let leaderRes;
try {
leaderRes = await streamMultiAgent({
agentType: "leader",
dAgentThreadId: ids,
agentName: coord,
newMessage: leaderTask,
model: selectedModelRef.current || undefined,
signal: leaderAborter.signal,
onTextDelta: (text) => appendToDialogue(leaderBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName);
setDialogueConfidence(leaderBubbleId, label);
appendStepIfNew(leaderBubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
});
} catch (err) {
activeAbortersRef.current.delete(leaderAborter);
if (leaderAborter.signal.aborted || (err as Error).name === "AbortError") {
finalizeDialogue(leaderBubbleId, "(已停止)", "已停止");
finishedAllSeats = false;
break;
}
throw err;
}
activeAbortersRef.current.delete(leaderAborter);
if (orchestrationCancelRef.current) {
finalizeDialogue(leaderBubbleId, leaderRes.content || "(已停止)", "已停止");
finishedAllSeats = false;
break;
}
// 链条中途总控仍可能要求澄清 → 暂停链条,等用户回复后从头重跑(MVP)。
if (leaderRes.clarification) {
finalizeDialogue(leaderBubbleId, leaderRes.content, "等待用户澄清");
highlightSpeaker(null);
setPendingClarification(leaderRes.clarification);
setClarificationDraft("");
triggerToast("❓ 总控需要补充信息,请回答下方问题后继续");
finishedAllSeats = false;
break;
}
finalizeDialogue(leaderBubbleId, leaderRes.content, "派活说明");
if (leaderRes.content) setLastLeaderContent(leaderRes.content);
const task = pickChainTask(
leaderRes.dispatched,
leaderRes.content,
seat.agent_id,
seatName,
);
// ── (b) 当前席位交付(special,顺序、不并行)──
if (meta) markSpeakerStreaming(meta.speakerId);
const seatThreadId = resolveSeatThreadId(ids, seat.agent_id);
const perSeatModel = meta
? agentConfigsRef.current[meta.speakerId]?.model
: undefined;
const subBubbleId = appendStreamingDialogue({
sender: seatName,
avatarType: meta?.avatarType ?? "blue",
initialContent: "",
confidence: "调度中",
agentThreadId: seatThreadId,
});
const subAborter = new AbortController();
activeAbortersRef.current.add(subAborter);
let aborted = false;
try {
const specRes = await streamMultiAgent({
agentType: "special",
dAgentThreadId: ids,
agentName: seat.agent_id,
displayName: seatName,
newMessage: task,
model: perSeatModel || selectedModelRef.current || undefined,
signal: subAborter.signal,
onTextDelta: (text) => appendToDialogue(subBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "special", toolName);
setDialogueConfidence(subBubbleId, label);
appendStepIfNew(subBubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, seatThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, seatThreadId),
});
if (orchestrationCancelRef.current) {
finalizeDialogue(subBubbleId, specRes.content || "(已停止)", "已停止");
aborted = true;
} else {
finalizeDialogue(subBubbleId, specRes.content, "子智能体交付");
}
} catch (err) {
if (subAborter.signal.aborted || (err as Error).name === "AbortError") {
finalizeDialogue(subBubbleId, "(已停止)", "已停止");
aborted = true;
} else {
console.error(`[chain-special:${seat.agent_id}] error:`, err);
finalizeDialogue(subBubbleId, `(错误) ${(err as Error).message}`, "调用失败");
}
} finally {
activeAbortersRef.current.delete(subAborter);
if (meta) markSpeakerIdle(meta.speakerId);
}
if (aborted) {
finishedAllSeats = false;
break;
}
setConsensusPercentage(Math.min(95, Math.round(((i + 1) / total) * 90)));
}
// ── (c) 总控收口:综合全链交付,产出最终结论 ──
if (!orchestrationCancelRef.current && finishedAllSeats && total > 0) {
highlightSpeaker(null);
const closeoutBubbleId = appendStreamingDialogue({
sender: "总控协调",
avatarType: "blue",
initialContent: "",
confidence: "汇总链条结论",
agentThreadId: leaderThreadId,
});
const closeoutAborter = new AbortController();
activeAbortersRef.current.add(closeoutAborter);
try {
const closeoutRes = await streamMultiAgent({
agentType: "leader",
dAgentThreadId: ids,
agentName: coord,
newMessage:
"各链条席位已按顺序完成发言,请你综合所有席位的交付,输出最终方案与结论。不要再派活、不要再向任何席位分配任务。",
model: selectedModelRef.current || undefined,
signal: closeoutAborter.signal,
onTextDelta: (text) => appendToDialogue(closeoutBubbleId, text),
onPhaseChange: (phase, toolName, args) => {
const label = phaseLabel(phase, "leader", toolName);
setDialogueConfidence(closeoutBubbleId, label);
appendStepIfNew(closeoutBubbleId, label, `${phase}:${toolName ?? ""}`, args);
handleArtifactToolPhase(phase, toolName, args, leaderThreadId);
},
onUpstreamFrame: (frame) => handleUpstreamFrame(frame, leaderThreadId),
});
finalizeDialogue(closeoutBubbleId, closeoutRes.content, "全案盖印");
if (closeoutRes.content) setLastLeaderContent(closeoutRes.content);
setConsensusPercentage(100);
setHasConsensus(true);
triggerToast("🎉 业务链条研讨完成!已生成全案结论");
} catch (err) {
if (closeoutAborter.signal.aborted || (err as Error).name === "AbortError") {
finalizeDialogue(closeoutBubbleId, "(已停止)", "已停止");
} else {
console.error("chain closeout error:", err);
finalizeDialogue(closeoutBubbleId, `(错误) ${(err as Error).message}`, "调用失败");
}
} finally {
activeAbortersRef.current.delete(closeoutAborter);
}
}
} catch (err) {
console.error("chain orchestration error:", err);
triggerToast(`❌ 链条研讨异常: ${(err as Error).message}`);
} finally {
highlightSpeaker(null);
clearAllStreamingSpeakers();
setIsOrchestrating(false);
}
},
[
appendStreamingDialogue,
appendToDialogue,
appendStepIfNew,
setDialogueConfidence,
phaseLabel,
finalizeDialogue,
highlightSpeaker,
markSpeakerStreaming,
markSpeakerIdle,
clearAllStreamingSpeakers,
triggerToast,
handleArtifactToolPhase,
handleUpstreamFrame,
resolveSeatThreadId,
],
);
// 按当前模式分发到对应编排函数。init / 恢复 / 干预 / 澄清回复统一走这里。
const runActiveOrchestration = useCallback(
(seedMessage: string, ids: ThreadIdMap, coord: string) =>
orchestrationModeRef.current === "chain"
? runChainOrchestration(seedMessage, ids, coord)
: runOrchestration(seedMessage, ids, coord),
[runOrchestration, runChainOrchestration],
);
// ─── Effects ─────────────────────────────────────────────────────
// Step 2 init —— 首次进入时建协调 thread + 各席位 thread,然后触发第一轮编排。
// 离开 Step 2 **不**销毁 threadIds,保证回来还能继续。
@@ -1190,7 +1516,7 @@ export function useStep2Orchestration(
confidence: "系统就绪",
});
triggerToast("✅ 多智能体圆桌初始化完成");
await runOrchestration(
await runActiveOrchestration(
buildInitialIntent(),
initRes.thread_ids,
initRes.main_agent_name,
@@ -1234,7 +1560,7 @@ export function useStep2Orchestration(
const pending = pendingInjectPromptRef.current;
if (pending) {
pendingInjectPromptRef.current = null;
void runOrchestration(
void runActiveOrchestration(
pending,
threadIdsRef.current,
coordinatorNameRef.current,
@@ -1242,7 +1568,7 @@ export function useStep2Orchestration(
return;
}
void runOrchestration(
void runActiveOrchestration(
"用户已恢复研讨,请继续上一轮的协调工作。",
threadIdsRef.current,
coordinatorNameRef.current,
@@ -1311,8 +1637,8 @@ export function useStep2Orchestration(
triggerToast("干预指令已发送,会商继续执行");
// 直接启动新一轮编排
void runOrchestration(prompt, threadIdsRef.current, coordinatorNameRef.current);
}, [customAgentInput, appendDialogue, abortAllInFlight, runOrchestration, triggerToast]);
void runActiveOrchestration(prompt, threadIdsRef.current, coordinatorNameRef.current);
}, [customAgentInput, appendDialogue, abortAllInFlight, runActiveOrchestration, triggerToast]);
const submitClarification = useCallback(
(rawAnswer: string) => {
@@ -1335,13 +1661,13 @@ export function useStep2Orchestration(
});
setPendingClarification(null);
setClarificationDraft("");
void runOrchestration(
void runActiveOrchestration(
`用户已回复你刚才的澄清问题。\n问题:${question}\n用户回答:${answer}\n请基于该回答继续派活或给出结论。`,
threadIdsRef.current,
coordinatorNameRef.current,
);
},
[appendDialogue, pendingClarification, runOrchestration, triggerToast],
[appendDialogue, pendingClarification, runActiveOrchestration, triggerToast],
);
// 预算滑块 —— 写死的演示分支,跟实际派活没有数据通路。删除或重构请同步
@@ -0,0 +1,72 @@
/**
* 业务链条(Business Chain)共享类型。
*
* 业务链条 = 一组**有序**的智能体编排模板,可命名、可复用、持久化在后端 MySQL
* (`/api/roundtable-chains`,按登录用户隔离)。在 Step 2 圆桌研讨里,总控会**按
* 链条顺序逐个派活**(见 `docs/multi-agent-business-chain-dev.md` §6)。
*
* 这里只放纯类型 / 常量,不含 React 状态或请求逻辑(CRUD 在 `api/chains.ts`)。
*/
/** 链条里的一个有序席位。`seats[0]` 是第一个发言者。 */
export interface ChainSeat {
agent_id: string;
/**
* 落库时的名称快照。展示时优先用实时 agent 数据(`listAgents()`),agent 被删
* 或改名时回退到此快照,避免链条卡片显示错乱。
*/
name: string;
/** 描述快照,同样作为实时数据的回退。 */
description?: string;
}
/** 一条可复用的业务链条(与后端 `/api/roundtable-chains` 记录对应)。 */
export interface BusinessChain {
id: string;
title: string;
description?: string;
/** 有序席位列表。 */
seats: ChainSeat[];
createdAt?: string;
updatedAt?: string;
}
/**
* 推荐弹窗(席位来源选择器)确认后回传给主页面的统一结果。
* 两种模式(智能推荐 / 业务链条)最终都产出**有序**的 `agents` + 一个 `mode`。
*/
export interface SeatSelectionResult {
/** 有序席位 —— 与现有 `handleConfirmAgents` 入参兼容。 */
agents: Array<{ agent_id: string; name: string }>;
/** 决定 Step 2 走哪种编排:自由派活 vs 按链条顺序派活。 */
mode: "recommend" | "chain";
/** chain 模式带上来源链条(用于草稿快照 / Step 2 展示链名)。 */
chain?: Pick<BusinessChain, "id" | "title">;
}
/** 链条席位数硬约束,与圆桌推荐弹窗的 `MIN_SELECTED`/`MAX_SELECTED` 对齐。 */
export const CHAIN_MIN_SEATS = 2;
export const CHAIN_MAX_SEATS = 8;
/**
* 把 `getAvatarTypeFor()` 产出的头像配色(blue/purple/emerald/amber/pink/human)
* 映射成 tag chip 的 tailwind 类(背景 + 文字 + 边框,含深色变体)。
* 配置页的智能体 tag 与链条选择卡片的席位 tag 复用同一套色,视觉统一。
*/
export function chainTagClasses(avatarType: string): string {
switch (avatarType) {
case "purple":
return "bg-purple-500/10 text-purple-700 border-purple-300 dark:text-purple-300 dark:border-purple-700/60";
case "emerald":
return "bg-emerald-500/10 text-emerald-700 border-emerald-300 dark:text-emerald-300 dark:border-emerald-700/60";
case "amber":
return "bg-amber-500/10 text-amber-700 border-amber-300 dark:text-amber-300 dark:border-amber-700/60";
case "pink":
return "bg-pink-500/10 text-pink-700 border-pink-300 dark:text-pink-300 dark:border-pink-700/60";
case "human":
return "bg-slate-500/10 text-slate-700 border-slate-300 dark:text-slate-300 dark:border-slate-600/60";
case "blue":
default:
return "bg-blue-500/10 text-blue-700 border-blue-300 dark:text-blue-300 dark:border-blue-700/60";
}
}
@@ -0,0 +1,708 @@
/**
* 业务链条配置页面(路由 `/page/workspace/roundtable/chains`)。
*
* 双栏编辑器:
* - 左栏:候选智能体列表(`listAgents()` + `filterRecommendCandidates` 复用,剔除
* intent/recommender/coordinator),支持**名称检索 + 标签筛选**(`TagSearchBar`,
* 服务端过滤),竖排卡片,可拖到右栏、也可点击快捷加入。
* - 右栏:当前链条的**有序**长条卡片(序号 + 名称 + 描述),可拖拽排序、删除单项;
* 左栏拖入时按落点插入到对应位置。
*
* 拖拽用 `@dnd-kit`:右栏是 `SortableContext`(席位互相排序),左栏卡片是
* `useDraggable`(pool→右栏插入)。落点计算在 `onDragEnd`。
*
* 视觉:直接遵循《浅红色 UI 规范》/《深蓝色 UI 规范》—— 用 app 语义 token(光红暗蓝
* 已内置)+ 规范小圆角(输入/按钮/tag 2px、卡片/分栏 4px)+ 克制阴影。**不**套圆桌页
* 的 `roundtable-planning` scope。
*
* 持久化走 `api/chains.ts`(后端 MySQL `/api/roundtable-chains`)。席位数约束
* 2 ≤ N ≤ 8、agent 去重,保存时校验(后端 `_validate_seats` 也兜底)。
*
* 详见 `docs/multi-agent-business-chain-dev.md` §7 阶段 1。
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { nanoid } from "nanoid";
import {
ArrowLeft,
GripVertical,
Layers,
ListOrdered,
Loader2,
Plus,
Save,
Trash2,
Users,
X,
} from "lucide-react";
import {
DndContext,
DragOverlay,
PointerSensor,
closestCenter,
useDraggable,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
type DragStartEvent,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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,
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 { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
const POOL_PREFIX = "pool::";
const SEAT_PREFIX = "seat::";
const SEATS_DROPPABLE_ID = "seats-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 [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 [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [seats, setSeats] = useState<ChainSeat[]>([]);
const [saving, setSaving] = useState(false);
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
const selectValue = currentChainId ?? "";
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(seats.map((s) => s.agent_id)), [seats]);
// ─── 链条列表(一次性)─────────────────────────────────────────────────
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);
setTitle(chain.title);
setDescription(chain.description ?? "");
setSeats(chain.seats.map((s) => ({ ...s })));
}, []);
const startNewChain = useCallback(() => {
setCurrentChainId(null);
setTitle("");
setDescription("");
setSeats([]);
}, []);
// ─── 席位增删改 ───────────────────────────────────────────────────────
const addAgentToSeats = useCallback(
(agentId: string, atIndex?: number) => {
const agent = poolById.get(agentId);
if (!agent) return;
setSeats((prev) => {
if (prev.some((s) => s.agent_id === agentId)) {
toast.info(`「${agent.name}」已在链条中`);
return prev;
}
if (prev.length >= CHAIN_MAX_SEATS) {
toast.warning(`链条最多 ${CHAIN_MAX_SEATS} 个席位`);
return prev;
}
const seat: ChainSeat = {
agent_id: agent.agent_id,
name: agent.name,
description: agent.description,
};
const next = [...prev];
const i = atIndex == null ? next.length : Math.max(0, Math.min(atIndex, next.length));
next.splice(i, 0, seat);
return next;
});
},
[poolById],
);
const removeSeat = useCallback((agentId: string) => {
setSeats((prev) => prev.filter((s) => s.agent_id !== agentId));
}, []);
// ─── 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 s = seats.find((x) => x.agent_id === agentId);
setActiveDrag({ kind: "seat", agentId, name: s?.name ?? agentId });
}
},
[poolById, seats],
);
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;
const overSeatIndex = overId.startsWith(SEAT_PREFIX)
? seats.findIndex((s) => SEAT_PREFIX + s.agent_id === overId)
: -1;
if (activeId.startsWith(POOL_PREFIX)) {
const agentId = activeId.slice(POOL_PREFIX.length);
const insertAt = overSeatIndex >= 0 ? overSeatIndex : seats.length;
addAgentToSeats(agentId, insertAt);
return;
}
if (activeId.startsWith(SEAT_PREFIX)) {
const fromIndex = seats.findIndex(
(s) => SEAT_PREFIX + s.agent_id === activeId,
);
if (fromIndex < 0) return;
const toIndex = overSeatIndex >= 0 ? overSeatIndex : seats.length - 1;
if (fromIndex === toIndex) return;
setSeats((prev) => arrayMove(prev, fromIndex, toIndex));
}
},
[seats, addAgentToSeats],
);
// ─── 保存 / 删除 ──────────────────────────────────────────────────────
const seatCountInvalid =
seats.length < CHAIN_MIN_SEATS || seats.length > CHAIN_MAX_SEATS;
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 {
const payloadSeats = seats.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 ?? "",
}));
if (currentChainId) {
const updated = await updateChain(currentChainId, {
title: title.trim(),
description: description.trim() || undefined,
seats: payloadSeats,
});
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,
});
setCurrentChainId(created.id);
toast.success("已创建业务链条");
}
await refreshChains();
} catch (err) {
toast.error(`保存失败:${err instanceof Error ? err.message : String(err)}`);
} finally {
setSaving(false);
}
}, [
title,
description,
seats,
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]);
// ─── Render ──────────────────────────────────────────────────────────
const seatItemIds = seats.map((s) => SEAT_PREFIX + s.agent_id);
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-5 py-2.5">
<Button
variant="ghost"
size="sm"
onClick={() => navigate("/page/workspace/roundtable/planning")}
className={cn("h-8 gap-1.5 px-2 text-muted-foreground hover:text-foreground", R_CTRL)}
>
<ArrowLeft className="size-4" />
返回圆桌
</Button>
<div className="h-4 w-px bg-border" />
<div className="flex items-center gap-2.5">
<span className={cn("flex size-8 items-center justify-center bg-primary/10 text-primary", R_CARD)}>
<Layers className="size-4" />
</span>
<div className="leading-tight">
<div className="text-sm font-semibold">业务链条配置</div>
<div className="text-xs text-muted-foreground">编排有序的智能体研讨链路</div>
</div>
</div>
{/* 链条选择 + 新建 */}
<div className="ml-2 flex items-center gap-2">
<select
value={selectValue}
onChange={(e) => {
const id = e.target.value;
if (!id) {
startNewChain();
return;
}
const chain = chains.find((c) => c.id === id);
if (chain) loadChain(chain);
}}
className={cn(
"h-8 border border-input bg-card px-2.5 text-sm text-foreground outline-none transition-colors focus:border-primary focus:ring-1 focus:ring-primary/30",
R_CTRL,
)}
>
<option value="">+ 新建链条…</option>
{chains.map((c) => (
<option key={c.id} value={c.id}>
{c.title || "未命名"}({c.seats.length} 席)
</option>
))}
</select>
<Button variant="outline" size="sm" onClick={startNewChain} className={cn("h-8 gap-1.5", R_CTRL)}>
<Plus className="size-4" />
新建
</Button>
</div>
<div className="ml-auto flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleDelete}
disabled={!currentChainId}
className={cn("h-8 gap-1.5 text-destructive hover:text-destructive", R_CTRL)}
>
<Trash2 className="size-4" />
删除
</Button>
<Button
size="sm"
onClick={handleSave}
disabled={!canSave}
className={cn("h-8 gap-1.5", R_CTRL)}
>
{saving ? <Loader2 className="size-4 animate-spin" /> : <Save className="size-4" />}
保存
</Button>
</div>
</header>
{/* 标题 + 描述 */}
<div className="flex shrink-0 flex-col gap-2.5 border-b border-border bg-card px-5 py-3 sm:flex-row sm:items-center">
<div className="flex items-center gap-2 sm:w-80">
<span className="shrink-0 text-xs font-medium text-muted-foreground">标题</span>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:应急方案三段链"
className={cn("h-8 bg-background", R_CTRL)}
maxLength={120}
/>
</div>
<div className="flex flex-1 items-center gap-2">
<span className="shrink-0 text-xs font-medium text-muted-foreground">描述</span>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="链条用途说明(可选)"
className={cn("h-8 bg-background", R_CTRL)}
maxLength={500}
/>
</div>
</div>
{/* 双栏拖拽编辑器 */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
>
<div className="grid min-h-0 flex-1 grid-cols-1 gap-4 overflow-hidden p-4 md:grid-cols-[360px_1fr]">
{/* 左栏:候选智能体 */}
<section className={cn("flex min-h-0 flex-col overflow-hidden border border-border bg-card", R_CARD, PANEL_SHADOW)}>
<div className="shrink-0 space-y-2.5 border-b border-border px-3.5 py-3">
<div className="flex items-center gap-2 text-xs font-semibold text-foreground">
<Users className="size-3.5 text-primary" />
候选智能体
<span className={cn("bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary tabular-nums", R_CTRL)}>
{pool.length}
</span>
<span className="ml-auto text-[11px] font-normal text-muted-foreground">
拖到右侧 / 点击加入
</span>
</div>
<TagSearchBar
targetType="agent"
onSearchChange={setSearch}
tagIds={filterTagIds}
onTagIdsChange={setFilterTagIds}
/>
</div>
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto p-2.5">
{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-6 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={() => addAgentToSeats(a.agent_id)}
/>
))
)}
</div>
</section>
{/* 右栏:有序席位 */}
<SeatsColumn
seats={seats}
seatItemIds={seatItemIds}
seatCountInvalid={seatCountInvalid}
onRemove={removeSeat}
/>
</div>
<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>
</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}
className={cn(
"group flex items-start gap-2 border p-2.5 transition-colors",
R_CTRL,
inChain
? "cursor-not-allowed border-dashed border-border bg-muted/40 opacity-60"
: "cursor-grab border-border bg-card hover:border-primary/50 hover:bg-primary/[0.03] active:cursor-grabbing",
isDragging && "opacity-30",
)}
{...(inChain ? {} : listeners)}
{...attributes}
>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{agent.name || agent.agent_id}</div>
{agent.description && (
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
{agent.description}
</div>
)}
</div>
{inChain ? (
<span className={cn("shrink-0 self-center bg-primary/10 px-1.5 py-0.5 text-[11px] font-medium text-primary", R_CTRL)}>
已加入
</span>
) : (
<button
type="button"
onClick={onAdd}
onPointerDown={(e) => e.stopPropagation()}
className={cn(
"shrink-0 self-center p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-primary hover:text-primary-foreground group-hover:opacity-100",
R_CTRL,
)}
title="加入链条"
>
<Plus className="size-4" />
</button>
)}
</div>
);
}
// ── 右栏(droppable 容器 + sortable 列表)───────────────────────────────
function SeatsColumn({
seats,
seatItemIds,
seatCountInvalid,
onRemove,
}: {
seats: ChainSeat[];
seatItemIds: string[];
seatCountInvalid: boolean;
onRemove: (agentId: string) => void;
}) {
const { setNodeRef, isOver } = useDroppable({ id: SEATS_DROPPABLE_ID });
return (
<section className={cn("flex min-h-0 flex-col overflow-hidden border border-border bg-card", R_CARD, PANEL_SHADOW)}>
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-3 text-xs font-semibold text-foreground">
<ListOrdered className="size-3.5 text-primary" />
链条席位
<span className={cn("bg-muted px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground", R_CTRL)}>
按顺序发言
</span>
<span
className={cn(
"ml-auto px-1.5 py-0.5 text-[11px] font-medium tabular-nums",
R_CTRL,
seatCountInvalid
? "bg-destructive/10 text-destructive"
: "bg-primary/10 text-primary",
)}
>
{seats.length} / {CHAIN_MAX_SEATS}
{seats.length < CHAIN_MIN_SEATS && ` · 至少 ${CHAIN_MIN_SEATS}`}
</span>
</div>
<div
ref={setNodeRef}
className={cn(
"min-h-0 flex-1 space-y-2 overflow-y-auto p-3.5 transition-colors",
isOver && "bg-primary/[0.03]",
)}
>
{seats.length === 0 ? (
<div className={cn("flex h-full flex-col items-center justify-center gap-2 border border-dashed border-border text-sm text-muted-foreground", R_CARD)}>
<Layers className="size-7 opacity-40" />
从左侧把智能体拖到这里,组成有序业务链条
</div>
) : (
<SortableContext items={seatItemIds} strategy={verticalListSortingStrategy}>
{seats.map((seat, index) => (
<SeatStrip
key={seat.agent_id}
seat={seat}
index={index}
onRemove={() => onRemove(seat.agent_id)}
/>
))}
</SortableContext>
)}
</div>
</section>
);
}
// ── 单个席位长条(sortable)─────────────────────────────────────────────
function SeatStrip({
seat,
index,
onRemove,
}: {
seat: ChainSeat;
index: number;
onRemove: () => void;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
useSortable({ id: SEAT_PREFIX + seat.agent_id });
const avatarType = getAvatarTypeFor(seat.agent_id, index);
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(
"flex items-center gap-3 border border-border bg-card p-2.5",
R_CTRL,
isDragging && "z-10 opacity-70 shadow-[0_4px_12px_rgba(15,23,42,0.12)]",
)}
>
<button
type="button"
className="shrink-0 cursor-grab touch-none text-muted-foreground/60 hover:text-foreground active:cursor-grabbing"
{...attributes}
{...listeners}
title="拖动排序"
>
<GripVertical className="size-4" />
</button>
<span
className={cn(
"flex size-6 shrink-0 items-center justify-center border text-[12px] font-semibold tabular-nums",
R_CTRL,
chainTagClasses(avatarType),
)}
>
{index + 1}
</span>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">{seat.name || seat.agent_id}</div>
{seat.description && (
<div className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">
{seat.description}
</div>
)}
</div>
<button
type="button"
onClick={onRemove}
className={cn("shrink-0 p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive", R_CTRL)}
title="移除"
>
<X className="size-4" />
</button>
</div>
);
}
@@ -23,6 +23,7 @@
* 6) 模型选择全局默认 `availableModels[0].name`,Persona 覆盖更高优先级
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Activity,
Check,
@@ -36,6 +37,7 @@ import {
Clock,
Cpu,
History,
ListOrdered,
Loader2,
Pencil,
Save,
@@ -69,10 +71,12 @@ import {
type RoleEntry,
type SelectedAgent,
} from "@/roundtable-planning/lib/roundtable-constants";
import type { SeatSelectionResult } from "@/roundtable-planning/lib/business-chain";
export default function RoundtablePlanningPage() {
const { theme } = useTheme();
const isDark = theme === "dark";
const navigate = useNavigate();
const { models: availableModels, isLoading: modelsLoading } = useModels();
@@ -81,6 +85,13 @@ export default function RoundtablePlanningPage() {
const [showToast, setShowToast] = useState<string | null>(null);
const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState<boolean>(false);
const [selectedModel, setSelectedModel] = useState<string>("");
// Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活。
// 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。
const [orchestrationMode, setOrchestrationMode] = useState<"recommend" | "chain">(
"recommend",
);
// chain 模式来源链条(仅展示/持久化用)。
const [chainMeta, setChainMeta] = useState<{ id: string; title: string } | null>(null);
const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true);
// 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在
@@ -154,6 +165,7 @@ export default function RoundtablePlanningPage() {
const step2 = useStep2Orchestration({
currentStep,
selectedModel,
orchestrationMode,
buildInitialIntent,
triggerToast,
setRoles,
@@ -205,9 +217,17 @@ export default function RoundtablePlanningPage() {
consensusPercentage: step2.consensusPercentage,
budgetLimit: step2.budgetLimit,
seatStubMessages: step2.seatStubMessages,
orchestrationMode,
chain: chainMeta,
}),
hydrateStep1: step1.hydrateFromDraft,
hydrateStep2: step2.hydrateFromDraft,
// 包一层:先把页面持有的编排模式 / 链条恢复出来(老草稿无字段 → recommend),
// 再交给 step2 hook 恢复其余 Step 2 状态。
hydrateStep2: (snapshot) => {
setOrchestrationMode(snapshot.orchestrationMode ?? "recommend");
setChainMeta(snapshot.chain ?? null);
step2.hydrateFromDraft(snapshot);
},
cancelAllStreams: () => {
step2.abortAllInFlight();
step1.stopIntentStream();
@@ -267,23 +287,32 @@ export default function RoundtablePlanningPage() {
// ─── 步骤间转场 handler ───────────────────────────────────
const handleConfirmAgents = useCallback(
(agents: Array<{ agent_id: string; name: string }>) => {
const normalized: SelectedAgent[] = agents.map(({ agent_id, name }) => ({
(result: SeatSelectionResult) => {
const normalized: SelectedAgent[] = result.agents.map(({ agent_id, name }) => ({
agent_id,
// 内置 agent 走 BUILTIN_AGENT_META;非内置用候选池传过来的 name。
name: BUILTIN_AGENT_META[agent_id]?.displayName ?? name ?? agent_id,
}));
step2.setSelectedAgents(normalized);
step2.resetInitGate();
// result.mode 决定 Step 2 编排:recommend = 总控自由派活;chain = 按链条顺序派活。
setOrchestrationMode(result.mode);
setChainMeta(result.mode === "chain" ? result.chain ?? null : null);
setIsRecommendDialogOpen(false);
setCurrentStep(2);
triggerToast("成功确认!已切换至第二阶段圆桌研讨系统");
triggerToast(
result.mode === "chain"
? `成功确认!将按业务链条「${result.chain?.title ?? ""}」顺序研讨`
: "成功确认!已切换至第二阶段圆桌研讨系统",
);
},
[step2, triggerToast],
);
const handleStartNewTask = useCallback(() => {
step2.resetStep2State();
setOrchestrationMode("recommend");
setChainMeta(null);
step1.stopIntentStream();
drafts.loadedFromDraftRef.current = false;
drafts.setCurrentDraftId(null);
@@ -353,6 +382,16 @@ export default function RoundtablePlanningPage() {
/>
<div className="flex items-center gap-2">
<button
id="business-chain-btn"
onClick={() => navigate("/page/workspace/roundtable/chains")}
title="配置可复用的业务链条(有序智能体编排)"
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<ListOrdered className="w-3.5 h-3.5 text-primary" />
业务链条
</button>
<button
id="new-task-btn"
onClick={handleStartNewTask}