业务链条
This commit is contained in:
1 parent
30410b0a93
commit
9ae6184dc1
20 files changed
+2440
-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`。
|
||||
*
|
||||
* 视觉:套 `roundtable-planning`(+`--dark`) 作用域,与圆桌主页面同一套浅红/深蓝皮肤
|
||||
* (`styles/roundtable-planning.css`,对齐 docs/浅红色UI规范 + ui规范-深蓝)。
|
||||
*
|
||||
* 持久化走 `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 { useTheme } from "@/contexts/ThemeContext";
|
||||
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";
|
||||
|
||||
type ActiveDrag =
|
||||
| { kind: "pool"; agentId: string; name: string }
|
||||
| { kind: "seat"; agentId: string; name: string }
|
||||
| null;
|
||||
|
||||
export default function BusinessChainEditorPage() {
|
||||
const navigate = useNavigate();
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
|
||||
// ─── 数据 ────────────────────────────────────────────────────────────
|
||||
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 } }),
|
||||
);
|
||||
|
||||
// 候选池按 agent_id 取元数据。注意 pool 是「当前筛选视图」——已加入但被筛掉
|
||||
// 的席位名称回退用 seat 自带快照(loadChain 写入),所以 poolById 缺项不影响保存。
|
||||
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;
|
||||
|
||||
// over 落在第几个席位之前(over 是席位则取其索引,否则追加到末尾)。
|
||||
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={cn(
|
||||
"roundtable-planning theme-transition flex h-full min-h-0 flex-col bg-[var(--rt-page-bg)] font-sans text-foreground",
|
||||
isDark && "roundtable-planning--dark",
|
||||
)}
|
||||
>
|
||||
{/* 顶栏 */}
|
||||
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-border bg-card px-5 py-3 shadow-[0_1px_3px_rgba(0,0,0,0.03)]">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate("/page/workspace/roundtable/planning")}
|
||||
className="gap-1.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
返回圆桌
|
||||
</Button>
|
||||
<div className="h-5 w-px bg-border" />
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Layers className="size-4" />
|
||||
</span>
|
||||
<div className="leading-tight">
|
||||
<div className="text-sm font-bold">业务链条配置</div>
|
||||
<div className="text-[11px] 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="h-8 rounded-md border border-input bg-card px-2.5 text-sm font-medium shadow-sm outline-none focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
|
||||
>
|
||||
<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="gap-1.5">
|
||||
<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="gap-1.5 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
删除
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
className="gap-1.5 bg-primary text-primary-foreground hover:opacity-90"
|
||||
>
|
||||
{saving ? <Loader2 className="size-4 animate-spin" /> : <Save className="size-4" />}
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 标题 + 描述 */}
|
||||
<div className="flex shrink-0 flex-col gap-2 border-b border-border bg-card/60 px-5 py-3 sm:flex-row sm:items-center">
|
||||
<div className="flex items-center gap-2 sm:w-72">
|
||||
<span className="shrink-0 text-xs font-semibold text-muted-foreground">标题</span>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="如:应急方案三段链"
|
||||
className="bg-card"
|
||||
maxLength={120}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<span className="shrink-0 text-xs font-semibold text-muted-foreground">描述</span>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="链条用途说明(可选)"
|
||||
className="bg-card"
|
||||
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="flex min-h-0 flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
|
||||
<div className="shrink-0 space-y-2.5 border-b border-border px-3.5 pb-3 pt-3">
|
||||
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<Users className="size-3.5 text-primary" />
|
||||
候选智能体
|
||||
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-bold text-primary tabular-nums">
|
||||
{pool.length}
|
||||
</span>
|
||||
<span className="ml-auto text-[10px] font-normal normal-case text-muted-foreground/70">
|
||||
拖到右侧 / 点击加入
|
||||
</span>
|
||||
</div>
|
||||
<TagSearchBar
|
||||
targetType="agent"
|
||||
onSearchChange={setSearch}
|
||||
tagIds={filterTagIds}
|
||||
onTagIdsChange={setFilterTagIds}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-2.5 custom-scrollbar">
|
||||
{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="flex items-center gap-2 rounded-lg border border-primary/40 bg-card px-3 py-2 text-sm font-semibold shadow-xl">
|
||||
<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 rounded-xl border p-2.5 transition-all",
|
||||
inChain
|
||||
? "cursor-not-allowed border-dashed border-border bg-muted/30 opacity-60"
|
||||
: "cursor-grab border-border bg-card hover:border-primary/40 hover:bg-primary/[0.04] hover:shadow-sm active:cursor-grabbing",
|
||||
isDragging && "opacity-30",
|
||||
)}
|
||||
{...(inChain ? {} : listeners)}
|
||||
{...attributes}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold">{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="shrink-0 self-center rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||
已加入
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="shrink-0 self-center rounded-lg p-1.5 text-muted-foreground opacity-0 transition-all hover:bg-primary hover:text-primary-foreground group-hover:opacity-100"
|
||||
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="flex min-h-0 flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-sm">
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border px-4 py-3 text-xs font-bold uppercase tracking-wide text-muted-foreground">
|
||||
<ListOrdered className="size-3.5 text-primary" />
|
||||
链条席位
|
||||
<span className="rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium normal-case text-muted-foreground">
|
||||
按顺序发言
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto rounded-full px-2 py-0.5 text-[11px] font-bold tabular-nums normal-case",
|
||||
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 custom-scrollbar transition-colors",
|
||||
isOver && "bg-primary/[0.04]",
|
||||
)}
|
||||
>
|
||||
{seats.length === 0 ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-border text-sm text-muted-foreground">
|
||||
<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}
|
||||
isLast={index === seats.length - 1}
|
||||
onRemove={() => onRemove(seat.agent_id)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 单个席位长条(sortable)─────────────────────────────────────────────
|
||||
|
||||
function SeatStrip({
|
||||
seat,
|
||||
index,
|
||||
isLast,
|
||||
onRemove,
|
||||
}: {
|
||||
seat: ChainSeat;
|
||||
index: number;
|
||||
isLast: boolean;
|
||||
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(
|
||||
"relative flex items-center gap-3 rounded-xl border border-border bg-card p-3 shadow-sm transition-shadow",
|
||||
isDragging && "z-10 opacity-70 shadow-lg ring-1 ring-primary/30",
|
||||
)}
|
||||
>
|
||||
{/* 顺序连接线(最后一个不画) */}
|
||||
{!isLast && (
|
||||
<span className="pointer-events-none absolute -bottom-2 left-[26px] h-2 w-px bg-border" />
|
||||
)}
|
||||
<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-7 shrink-0 items-center justify-center rounded-full border text-[12px] font-bold tabular-nums",
|
||||
chainTagClasses(avatarType),
|
||||
)}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold">{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="shrink-0 rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
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}
|
||||
|
||||
Reference in new issue
Block a user