feat(圆桌): 业务链条显示配置人 + 配置页拆两步并新增精美流程图

- 列表/弹窗卡片显示「配置人」(后端把 owner user_id 解析为 email,
  ChainResponse 增 owner_id/owner_name,store 输出 owner_id)
- 配置编辑器拆为两步:第一步基本信息(描述改多行文本),第二步链条编排
- 第一步有编排内容时,左侧基本信息 + 右侧 ChainFlowDiagram 流程图:
  直角连线、点节点开详情抽屉、连线显示阶段目标(依赖)、滚轮缩放、
  拖动平移、全屏 + 全屏右下角缩略图

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
董宏凯 committed 2026-06-13 15:32:37 +08:00
1 parent e9f44c8f0f
commit 146c8e76f0
7 files changed
+693 -91

No files matched your search

@@ -72,6 +72,7 @@ interface ServerChain {
coordinator_prompt?: string | null;
is_public?: boolean;
is_mine?: boolean;
owner_name?: string | null;
created_at: string | null;
updated_at: string | null;
}
@@ -88,6 +89,7 @@ function toChain(s: ServerChain): BusinessChain {
isPublic: s.is_public ?? false,
// 后端缺省时按「我的」处理(向后兼容旧响应)。
isMine: s.is_mine ?? true,
ownerName: s.owner_name ?? undefined,
createdAt: s.created_at ?? undefined,
updatedAt: s.updated_at ?? undefined,
};
@@ -16,6 +16,7 @@ import {
LayersIcon,
Loader2Icon,
SettingsIcon,
UserIcon,
} from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -227,6 +228,8 @@ function ChainRow({
onTogglePublic: () => void;
}) {
const isMine = chain.isMine !== false;
// 配置者用户名:后端返回 email,展示时取 @ 前的本地部分更像「用户名」。
const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "";
return (
<div className="rounded-2xl border border-border bg-card p-5 transition-colors hover:border-primary/40">
{/* 标题行 + 公共徽标 + 发布/设置按钮 */}
@@ -274,6 +277,16 @@ function ChainRow({
</div>
</div>
{ownerName && (
<div
className="mt-1.5 flex items-center gap-1 text-xs text-muted-foreground"
title={chain.ownerName}
>
<UserIcon className="size-3.5 shrink-0" />
<span className="truncate">配置人:{ownerName}</span>
</div>
)}
{chain.description && (
<div className="mt-1.5 line-clamp-2 text-sm text-muted-foreground">{chain.description}</div>
)}
@@ -0,0 +1,462 @@
/**
* 业务链条「精美流程图」(编辑器第一步右侧预览)。
*
* 把分层编排画成一张可交互的画布:
* - 列 = Stage(层间串行),列内节点 = 席位(同层并行);首列是「总控协调」。
* - 节点之间用**直角折线**(H→V→H 肘形)连接,末端带箭头。
* - 每个「步骤之间」的连线上挂一枚依赖标签 = 目标 Stage 的「本阶段目标」(用户填了才显示)。
* - 点击节点卡片 → `onNodeClick`(外层打开 `AgentDetailSheet` 抽屉看详情)。
* - 画布支持:鼠标滚轮缩放(以光标为锚点)、按住拖动平移、一键适应、全屏。
* - **全屏时**右下角显示缩略图(minimap):整图概览 + 当前视口框,点击可快速定位。
*
* 纯展示 / 交互组件,不持有业务数据;坐标布局在 `useMemo` 里一次算好,缩放平移只改
* 外层 transform,所以重绘极轻。席位配色复用 `getAvatarTypeFor` + `getAvatar`。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Crosshair,
Maximize2,
Minimize2,
Minus,
Plus,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { getAvatar } from "./Avatars";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
export interface ChainFlowSeat {
agent_id: string;
name: string;
description?: string;
}
export interface ChainFlowStage {
seats: ChainFlowSeat[];
/** 「本阶段目标」——作为进入本阶段那根连线的依赖标签;空 = 不显示。 */
goal?: string | null;
}
export interface ChainFlowDiagramProps {
stages: ChainFlowStage[];
/** 点击席位节点卡片(总控节点不触发)。 */
onNodeClick?: (agentId: string, name?: string) => void;
className?: string;
}
// ── 布局常量(世界坐标,单位 px)─────────────────────────────────────────────
const NODE_W = 188;
const NODE_H = 66;
const COL_GAP = 116;
const ROW_GAP = 22;
const PAD = 52;
const COORDINATOR_ID = "__coordinator__";
const MIN_SCALE = 0.25;
const MAX_SCALE = 2.6;
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
interface PlacedNode {
id: string;
name: string;
description?: string;
avatarType: string;
x: number;
y: number;
cy: number;
leftX: number;
rightX: number;
}
export function ChainFlowDiagram({ stages, onNodeClick, className }: ChainFlowDiagramProps) {
// ── 列 / 节点 / 连线 / 标签布局 ──────────────────────────────────────────
const layout = useMemo(() => {
// 列:总控 + 各 Stage。
const rawCols: Array<{ kind: "coordinator" | "stage"; goal: string | null; nodes: Omit<PlacedNode, "x" | "y" | "cy" | "leftX" | "rightX">[] }> = [];
rawCols.push({
kind: "coordinator",
goal: null,
nodes: [{ id: COORDINATOR_ID, name: "总控协调", avatarType: "human" }],
});
let order = 0;
for (const st of stages) {
const nodes = st.seats.map((s) => ({
id: s.agent_id,
name: s.name || s.agent_id,
description: s.description,
avatarType: getAvatarTypeFor(s.agent_id, order++),
}));
rawCols.push({ kind: "stage", goal: (st.goal ?? "").trim() || null, nodes });
}
const heights = rawCols.map((c) => c.nodes.length * NODE_H + Math.max(0, c.nodes.length - 1) * ROW_GAP);
const contentH = Math.max(NODE_H, ...heights);
const worldW = PAD * 2 + rawCols.length * NODE_W + Math.max(0, rawCols.length - 1) * COL_GAP;
const worldH = PAD * 2 + contentH;
const centerY = PAD + contentH / 2;
const cols = rawCols.map((c, k) => {
const x = PAD + k * (NODE_W + COL_GAP);
const h = heights[k];
const yStart = PAD + (contentH - h) / 2;
const nodes: PlacedNode[] = c.nodes.map((n, i) => {
const y = yStart + i * (NODE_H + ROW_GAP);
return { ...n, x, y, cy: y + NODE_H / 2, leftX: x, rightX: x + NODE_W };
});
return { ...c, nodes };
});
const edges: Array<{ id: string; d: string }> = [];
const labels: Array<{ id: string; x: number; y: number; text: string }> = [];
for (let k = 0; k < cols.length - 1; k++) {
const a = cols[k];
const b = cols[k + 1];
const midX = (a.nodes[0].rightX + b.nodes[0].leftX) / 2;
for (const na of a.nodes) {
for (const nb of b.nodes) {
edges.push({
id: `${k}:${na.id}->${nb.id}`,
d: `M ${na.rightX} ${na.cy} H ${midX} V ${nb.cy} H ${nb.leftX}`,
});
}
}
if (b.goal) labels.push({ id: `lbl-${k}`, x: midX, y: centerY, text: b.goal });
}
const allNodes = cols.flatMap((c) => c.nodes);
return { cols, edges, labels, worldW, worldH, allNodes };
}, [stages]);
// ── 视口 / 缩放平移状态 ──────────────────────────────────────────────────
const viewportRef = useRef<HTMLDivElement | null>(null);
const [vp, setVp] = useState({ w: 0, h: 0 });
const [view, setView] = useState({ scale: 1, tx: 0, ty: 0 });
const [fullscreen, setFullscreen] = useState(false);
const [grabbing, setGrabbing] = useState(false);
const panRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
// 视口尺寸跟踪(全屏切换会换挂载节点,故依赖 fullscreen 重新绑定)。
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const measure = () => setVp({ w: el.clientWidth, h: el.clientHeight });
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [fullscreen]);
// 适应视图:把整图缩放居中到视口内。
const fit = useCallback(() => {
if (!vp.w || !vp.h) return;
const s = clamp(Math.min(vp.w / layout.worldW, vp.h / layout.worldH) * 0.9, MIN_SCALE, 1.4);
setView({ scale: s, tx: (vp.w - layout.worldW * s) / 2, ty: (vp.h - layout.worldH * s) / 2 });
}, [vp.w, vp.h, layout.worldW, layout.worldH]);
// 视口尺寸 / 图结构 / 全屏变化时自动适应一次。
useEffect(() => {
fit();
}, [fit]);
// 全屏时 Esc 退出。
useEffect(() => {
if (!fullscreen) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setFullscreen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [fullscreen]);
// 滚轮缩放(以光标为锚点)。native 监听以便 preventDefault。
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = el.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
setView((v) => {
const next = clamp(v.scale * (e.deltaY < 0 ? 1.12 : 1 / 1.12), MIN_SCALE, MAX_SCALE);
const k = next / v.scale;
return { scale: next, tx: mx - k * (mx - v.tx), ty: my - k * (my - v.ty) };
});
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, [fullscreen]);
// 以视口中心为锚点的按钮缩放。
const zoomBy = useCallback(
(f: number) => {
setView((v) => {
const next = clamp(v.scale * f, MIN_SCALE, MAX_SCALE);
const k = next / v.scale;
const cx = vp.w / 2;
const cy = vp.h / 2;
return { scale: next, tx: cx - k * (cx - v.tx), ty: cy - k * (cy - v.ty) };
});
},
[vp.w, vp.h],
);
// ── 缩略图(minimap,仅全屏)─────────────────────────────────────────────
const MM_W = 196;
const MM_H = 132;
const mmScale = Math.min(MM_W / layout.worldW, MM_H / layout.worldH);
const mmW = layout.worldW * mmScale;
const mmH = layout.worldH * mmScale;
// 当前视口在世界坐标里的矩形(再乘 mmScale 投到缩略图)。
const viewRect = {
x: (-view.tx / view.scale) * mmScale,
y: (-view.ty / view.scale) * mmScale,
w: (vp.w / view.scale) * mmScale,
h: (vp.h / view.scale) * mmScale,
};
const onMinimapClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const wx = (e.clientX - rect.left) / mmScale;
const wy = (e.clientY - rect.top) / mmScale;
setView((v) => ({ ...v, tx: vp.w / 2 - wx * v.scale, ty: vp.h / 2 - wy * v.scale }));
},
[mmScale, vp.w, vp.h],
);
// ── 画布渲染 ─────────────────────────────────────────────────────────────
const canvas = (
<div
ref={viewportRef}
className={cn(
"relative h-full w-full overflow-hidden bg-muted/20 outline-none",
grabbing ? "cursor-grabbing" : "cursor-grab",
)}
style={{
backgroundImage:
"radial-gradient(circle, rgba(100,116,139,0.13) 1px, transparent 1px)",
backgroundSize: "22px 22px",
}}
onPointerDown={(e) => {
if (e.button !== 0) return;
panRef.current = { x: e.clientX, y: e.clientY, tx: view.tx, ty: view.ty };
setGrabbing(true);
e.currentTarget.setPointerCapture?.(e.pointerId);
}}
onPointerMove={(e) => {
const p = panRef.current;
if (!p) return;
setView((v) => ({ ...v, tx: p.tx + (e.clientX - p.x), ty: p.ty + (e.clientY - p.y) }));
}}
onPointerUp={() => {
panRef.current = null;
setGrabbing(false);
}}
onPointerCancel={() => {
panRef.current = null;
setGrabbing(false);
}}
>
{/* 世界层:所有节点 / 连线随 transform 一起缩放平移 */}
<div
className="absolute left-0 top-0 origin-top-left"
style={{
width: layout.worldW,
height: layout.worldH,
transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.scale})`,
}}
>
<svg
width={layout.worldW}
height={layout.worldH}
className="pointer-events-none absolute inset-0 overflow-visible text-muted-foreground/50"
>
<defs>
<marker
id="cfd-arrow"
markerWidth="9"
markerHeight="9"
refX="6.5"
refY="3"
orient="auto"
markerUnits="userSpaceOnUse"
>
<path d="M0,0 L6.5,3 L0,6 Z" fill="currentColor" />
</marker>
</defs>
{layout.edges.map((edge) => (
<path
key={edge.id}
d={edge.d}
fill="none"
stroke="currentColor"
strokeWidth={1.6}
markerEnd="url(#cfd-arrow)"
/>
))}
</svg>
{/* 节点卡片 */}
{layout.cols.flatMap((col) =>
col.nodes.map((n) => {
const isCoordinator = n.id === COORDINATOR_ID;
return (
<div
key={n.id}
className={cn("absolute", !isCoordinator && "cursor-pointer")}
style={{ left: n.x, top: n.y, width: NODE_W, height: NODE_H }}
onPointerDown={(e) => e.stopPropagation()}
onClick={() => {
if (isCoordinator) return;
// 全屏时先退出(详情抽屉 z-50 会被 z-[100] 全屏层盖住),再打开抽屉。
if (fullscreen) setFullscreen(false);
onNodeClick?.(n.id, n.name);
}}
>
<div
className={cn(
"flex h-full w-full items-center gap-2.5 rounded-xl border bg-card px-3 shadow-sm transition",
isCoordinator
? "border-primary/40 bg-primary/[0.06]"
: "border-border hover:-translate-y-0.5 hover:border-primary hover:shadow-md",
)}
>
<div className="shrink-0">{getAvatar(n.avatarType, "h-9 w-9")}</div>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-bold text-foreground">{n.name}</div>
<div className="truncate text-[10px] text-muted-foreground">
{isCoordinator ? "按阶段派活 · 综合收口" : n.description || n.id}
</div>
</div>
</div>
</div>
);
}),
)}
{/* 依赖标签(步骤之间的连线上) */}
{layout.labels.map((l) => (
<div
key={l.id}
className="absolute -translate-x-1/2 -translate-y-1/2"
style={{ left: l.x, top: l.y, maxWidth: COL_GAP + 40 }}
>
<div
className="line-clamp-3 rounded-md border border-primary/30 bg-card px-2 py-1 text-center text-[10px] font-medium leading-snug text-primary shadow-sm"
title={l.text}
>
{l.text}
</div>
</div>
))}
</div>
{/* 工具条:缩小 / 放大 / 适应 / 全屏 */}
<div className="absolute right-3 top-3 flex flex-col gap-1.5">
<ToolButton title="放大" onClick={() => zoomBy(1.2)}>
<Plus className="size-4" />
</ToolButton>
<ToolButton title="缩小" onClick={() => zoomBy(1 / 1.2)}>
<Minus className="size-4" />
</ToolButton>
<ToolButton title="适应窗口" onClick={fit}>
<Crosshair className="size-4" />
</ToolButton>
<ToolButton
title={fullscreen ? "退出全屏" : "全屏"}
onClick={() => setFullscreen((f) => !f)}
>
{fullscreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</ToolButton>
</div>
{/* 缩放比例徽标 */}
<div className="absolute bottom-3 left-3 select-none rounded-md border border-border bg-card/90 px-2 py-0.5 text-[10px] font-semibold tabular-nums text-muted-foreground shadow-sm">
{Math.round(view.scale * 100)}%
</div>
{/* 全屏退出按钮(左上) */}
{fullscreen && (
<button
type="button"
onClick={() => setFullscreen(false)}
title="退出全屏(Esc)"
className="absolute left-3 top-3 flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1.5 text-xs font-medium text-muted-foreground shadow-sm transition hover:bg-muted hover:text-foreground"
>
<X className="size-4" />
退出全屏
</button>
)}
{/* 缩略图:仅全屏时右下角 */}
{fullscreen && (
<div className="absolute bottom-3 right-3 rounded-lg border border-border bg-card/95 p-1.5 shadow-lg backdrop-blur">
<div
className="relative cursor-pointer overflow-hidden rounded bg-muted/40"
style={{ width: mmW, height: mmH }}
onClick={onMinimapClick}
>
{/* 节点缩略块 */}
{layout.allNodes.map((n) => (
<div
key={n.id}
className={cn(
"absolute rounded-[1px]",
n.id === COORDINATOR_ID ? "bg-primary/70" : "bg-foreground/35",
)}
style={{
left: n.x * mmScale,
top: n.y * mmScale,
width: NODE_W * mmScale,
height: NODE_H * mmScale,
}}
/>
))}
{/* 当前视口框 */}
<div
className="absolute border-2 border-primary bg-primary/10"
style={{
left: clamp(viewRect.x, 0, mmW),
top: clamp(viewRect.y, 0, mmH),
width: clamp(viewRect.w, 4, mmW),
height: clamp(viewRect.h, 4, mmH),
}}
/>
</div>
</div>
)}
</div>
);
// 全屏:固定定位铺满视口(position:fixed 自带脱离文档流,无需 portal)。
if (fullscreen) {
return <div className="fixed inset-0 z-[100] bg-background">{canvas}</div>;
}
return <div className={cn("relative", className)}>{canvas}</div>;
}
// ── 工具按钮 ────────────────────────────────────────────────────────────────
function ToolButton({
title,
onClick,
children,
}: {
title: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
onClick={onClick}
className="flex size-8 items-center justify-center rounded-md border border-border bg-card text-muted-foreground shadow-sm transition hover:bg-muted hover:text-foreground active:scale-95"
>
{children}
</button>
);
}
@@ -52,6 +52,8 @@ export interface BusinessChain {
isPublic?: boolean;
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
isMine?: boolean;
/** 配置者(owner)的可读用户名(后端解析的 email);解析失败时为空。 */
ownerName?: string;
createdAt?: string;
updatedAt?: string;
}
@@ -18,7 +18,7 @@
*
* 详见 `docs/roundtable-dag-orchestration.md` §2(Phase 2,分层编辑器)。
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { nanoid } from "nanoid";
@@ -113,6 +113,7 @@ import {
} from "@/roundtable-planning/lib/chain-stages";
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
import { ChainFlowDiagram } from "@/roundtable-planning/components/ChainFlowDiagram";
import { AgentDetailSheet } from "@/roundtable-planning/components/AgentDetailSheet";
const POOL_PREFIX = "pool::";
@@ -161,6 +162,8 @@ export default function BusinessChainEditorPage() {
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [flowOpen, setFlowOpen] = useState(false);
// 两步向导:1=基本信息(标题/描述/总控提示词),2=链条编排(双栏拖拽编辑器)。
const [step, setStep] = useState<1 | 2>(1);
// 详情抽屉:左栏点卡片本体、右栏点「查看详情」都打开它,展示该智能体详情。
const [detailAgent, setDetailAgent] = useState<{ id: string; name?: string } | null>(null);
const showDetail = useCallback((id: string, name?: string) => {
@@ -205,6 +208,16 @@ export default function BusinessChainEditorPage() {
[seatNameById],
);
// 步骤一右侧「精美流程图」的数据源:过滤空 stage,带上各阶段目标作为依赖标签。
const flowStages = useMemo(
() =>
stageGroups
.filter((g) => g.seats.length > 0)
.map((g) => ({ seats: g.seats, goal: g.goal ?? null })),
[stageGroups],
);
const hasFlowContent = flowStages.length > 0;
// ─── 链条列表(一次性)─────────────────────────────────────────────────
const refreshChains = useCallback(async () => {
try {
@@ -252,6 +265,7 @@ export default function BusinessChainEditorPage() {
setDescription(chain.description ?? "");
setCoordinatorPrompt(chain.coordinatorPrompt ?? "");
setStageGroups(chainToStageGroups(chain));
setStep(1);
}, []);
const startNewChain = useCallback(() => {
@@ -262,6 +276,7 @@ export default function BusinessChainEditorPage() {
setDescription("");
setCoordinatorPrompt("");
setStageGroups([]);
setStep(1);
}, []);
// ─── URL 参数 chainId:从「业务链条」弹窗点「设置」进来时自动切到该链条 ──────
@@ -615,6 +630,100 @@ export default function BusinessChainEditorPage() {
// ─── Render ──────────────────────────────────────────────────────────
const triggerLabel = currentChainId ? title || "未命名链条" : "+ 新建链条…";
// 步骤一左侧「基本信息」表单(标题 / 描述 / 总控提示词,描述与总控均为多行文本)。
// 抽成元素:无编排内容时居中单列,有内容时与右侧流程图并排(左列)。
const basicInfoForm = (
<div className="space-y-4">
{/* 标题 */}
<div
className={cn(
"flex items-stretch overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
R_CTRL,
)}
>
<span className="flex min-w-[64px] select-none items-center justify-center border-r border-border bg-muted/50 px-4 text-sm font-medium text-muted-foreground">
标题
</span>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:应急方案三段链"
maxLength={120}
className="h-auto rounded-none border-0 bg-transparent px-4 py-2.5 text-sm font-medium shadow-none focus-visible:ring-0 dark:border-0 dark:bg-transparent"
/>
</div>
{/* 描述(多行文本,与总控提示词同款)。 */}
<div
className={cn(
"overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
R_CTRL,
)}
>
<div className="flex items-center justify-between border-b border-border bg-muted/50 px-4 py-2">
<span className="select-none text-sm font-medium text-muted-foreground">描述</span>
<span className="select-none text-[10px] text-muted-foreground/70">
链条用途说明:解决什么问题、适用什么场景(可选)
</span>
</div>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={2000}
rows={3}
placeholder="这条链条解决什么问题、适用什么场景…"
className="w-full resize-y bg-card px-4 py-2.5 text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/60 focus:outline-none"
/>
</div>
{/* 总控编排提示:写给总控(协调)智能体的整体派活指引,仅分层 DAG 模式生效。 */}
<div
className={cn(
"overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
R_CTRL,
)}
>
<div className="flex items-center justify-between border-b border-border bg-muted/50 px-4 py-2">
<span className="select-none text-sm font-medium text-muted-foreground">总控提示词</span>
<div className="flex items-center gap-2">
<span className="select-none text-[10px] text-muted-foreground/70">
写给总控:怎么派活、做什么(仅分层研讨生效,可选)
</span>
<button
type="button"
onClick={handleOptimizeCoordinatorPrompt}
disabled={optimizingCoordinator || !coordinatorPrompt.trim()}
title={
coordinatorPrompt.trim()
? "让 AI 按链条结构重写这段提示词(更明确的派活方式、交付口径与总结要求)"
: "先写一版提示词,AI 才能在它基础上优化"
}
className={cn(
"inline-flex shrink-0 items-center gap-1 border border-primary/30 bg-primary/5 px-2 py-0.5 text-[10px] font-semibold text-primary transition hover:bg-primary/10 disabled:cursor-not-allowed disabled:opacity-50",
R_CTRL,
)}
>
{optimizingCoordinator ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Wand2 className="size-3" />
)}
{optimizingCoordinator ? "优化中…" : "AI 优化"}
</button>
</div>
</div>
<textarea
value={coordinatorPrompt}
onChange={(e) => setCoordinatorPrompt(e.target.value)}
maxLength={4000}
rows={4}
placeholder="例如:你是本次研讨的总控。请严格按阶段顺序派活,每阶段先明确产出目标再分配;要求各席位的交付聚焦本职、显式承接上一阶段结论;最终阶段做综合收口,给出可落地的全案。"
className="w-full resize-y bg-card px-4 py-2.5 text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/60 focus:outline-none"
/>
</div>
</div>
);
return (
<div className="flex h-full min-h-0 flex-col bg-muted/40 text-foreground">
{/* 顶栏 */}
@@ -786,94 +895,70 @@ export default function BusinessChainEditorPage() {
</div>
</header>
{/* 标题 + 描述 */}
<section className="shrink-0 border-b border-border bg-card px-6 py-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
className={cn(
"flex items-stretch overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
R_CTRL,
)}
>
<span className="flex min-w-[64px] select-none items-center justify-center border-r border-border bg-muted/50 px-4 text-sm font-medium text-muted-foreground">
标题
</span>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="如:应急方案三段链"
maxLength={120}
className="h-auto rounded-none border-0 bg-transparent px-4 py-2.5 text-sm font-medium shadow-none focus-visible:ring-0 dark:border-0 dark:bg-transparent"
/>
</div>
<div
className={cn(
"flex items-stretch overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
R_CTRL,
)}
>
<span className="flex min-w-[64px] select-none items-center justify-center border-r border-border bg-muted/50 px-4 text-sm font-medium text-muted-foreground">
描述
</span>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="链条用途说明(可选)"
maxLength={500}
className="h-auto rounded-none border-0 bg-transparent px-4 py-2.5 text-sm font-medium shadow-none focus-visible:ring-0 dark:border-0 dark:bg-transparent"
/>
</div>
</div>
{/* 总控编排提示:写给总控(协调)智能体的整体派活指引,仅分层 DAG 模式生效。 */}
<div
className={cn(
"mt-4 overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
R_CTRL,
)}
>
<div className="flex items-center justify-between border-b border-border bg-muted/50 px-4 py-2">
<span className="select-none text-sm font-medium text-muted-foreground">总控提示词</span>
<div className="flex items-center gap-2">
<span className="select-none text-[10px] text-muted-foreground/70">
写给总控:怎么派活、做什么(仅分层研讨生效,可选)
</span>
<button
type="button"
onClick={handleOptimizeCoordinatorPrompt}
disabled={optimizingCoordinator || !coordinatorPrompt.trim()}
title={
coordinatorPrompt.trim()
? "让 AI 按链条结构重写这段提示词(更明确的派活方式、交付口径与总结要求)"
: "先写一版提示词,AI 才能在它基础上优化"
}
{/* 步骤导航:① 基本信息 → ② 链条编排(点击可切换)。 */}
<nav className="flex shrink-0 items-center justify-center gap-3 border-b border-border bg-card px-6 py-2.5">
{([
{ n: 1 as const, label: "基本信息" },
{ n: 2 as const, label: "链条编排" },
]).map((s, i) => (
<Fragment key={s.n}>
{i > 0 && <span className="h-px w-10 bg-border" />}
<button
type="button"
onClick={() => setStep(s.n)}
className={cn(
"flex items-center gap-2 border px-3 py-1 text-sm font-medium transition",
R_CTRL,
step === s.n
? "border-primary bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:text-foreground",
)}
>
<span
className={cn(
"inline-flex shrink-0 items-center gap-1 border border-primary/30 bg-primary/5 px-2 py-0.5 text-[10px] font-semibold text-primary transition hover:bg-primary/10 disabled:cursor-not-allowed disabled:opacity-50",
R_CTRL,
"flex size-5 items-center justify-center rounded-full text-[11px] font-bold tabular-nums",
step === s.n ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
)}
>
{optimizingCoordinator ? (
<Loader2 className="size-3 animate-spin" />
) : (
<Wand2 className="size-3" />
)}
{optimizingCoordinator ? "优化中…" : "AI 优化"}
</button>
</div>
</div>
<textarea
value={coordinatorPrompt}
onChange={(e) => setCoordinatorPrompt(e.target.value)}
maxLength={4000}
rows={3}
placeholder="例如:你是本次研讨的总控。请严格按阶段顺序派活,每阶段先明确产出目标再分配;要求各席位的交付聚焦本职、显式承接上一阶段结论;最终阶段做综合收口,给出可落地的全案。"
className="w-full resize-y bg-card px-4 py-2.5 text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/60 focus:outline-none"
/>
</div>
</section>
{s.n}
</span>
{s.label}
</button>
</Fragment>
))}
</nav>
{/* 双栏拖拽编辑器 */}
{/* 步骤一:左=基本信息,右=精美流程图预览(有编排内容时)。无内容则居中单列。 */}
{step === 1 && (
<div className="flex min-h-0 flex-1 overflow-hidden">
{hasFlowContent ? (
<>
{/* 左:基本信息(这几个描述信息) */}
<section className="w-[400px] shrink-0 overflow-y-auto border-r border-border bg-card px-6 py-5">
{basicInfoForm}
</section>
{/* 右:编排流程图(直角连线 / 点节点看详情 / 连线显示依赖 / 缩放平移全屏) */}
<section className="relative min-w-0 flex-1">
<ChainFlowDiagram
stages={flowStages}
onNodeClick={showDetail}
className="h-full w-full"
/>
</section>
</>
) : (
<div className="mx-auto w-full max-w-3xl overflow-y-auto px-6 py-6">
{basicInfoForm}
<p className="mt-4 text-center text-xs text-muted-foreground">
还没有编排内容。点击上方「② 链条编排」去配置链条,保存后这里会显示流程图预览。
</p>
</div>
)}
</div>
)}
{/* 双栏拖拽编辑器(步骤二)*/}
{step === 2 && (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
@@ -975,10 +1060,11 @@ export default function BusinessChainEditorPage() {
) : null}
</DragOverlay>
</DndContext>
)}
{/* Phase 7:分层 DAG 只读流程图预览(§2.2 底部)。空编排时不渲染。
改为弹窗查看:底栏一个入口按钮,点击后在 Dialog 内展示完整流程图。 */}
{previewStages.length > 0 && (
{step === 2 && previewStages.length > 0 && (
<section className="shrink-0 flex items-center justify-between gap-2 border-t border-border bg-card px-6 py-2">
<span className="flex items-center gap-2 text-[11px] font-semibold text-muted-foreground">
<span>编排预览</span>
@@ -1028,10 +1114,12 @@ export default function BusinessChainEditorPage() {
}}
/>
{/* 页脚 */}
<footer className="shrink-0 select-none border-t border-border bg-card px-6 py-3 text-center text-[11px] text-muted-foreground">
分层编排:同一 Stage 内的席位并行研讨,Stage 之间串行。全单席位 = 线性链。保存后自动同步至圆桌。
</footer>
{/* 页脚(步骤二):分层编排说明。步骤切换走顶部步骤导航。 */}
{step === 2 && (
<footer className="shrink-0 select-none border-t border-border bg-card px-6 py-3 text-center text-[11px] text-muted-foreground">
分层编排:同一 Stage 内的席位并行研讨,Stage 之间串行。全单席位 = 线性链。保存后自动同步至圆桌。
</footer>
)}
</div>
);
}
@@ -23,7 +23,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import BaseModel, Field
from app.gateway.deps import get_config
from app.gateway.deps import get_config, get_local_provider
from deerflow.config.app_config import AppConfig
from deerflow.models import create_chat_model
from deerflow.runtime.user_context import get_effective_user_id
@@ -61,6 +61,10 @@ class ChainResponse(BaseModel):
is_public: bool = False
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
is_mine: bool = True
# 配置者(owner)的 user_id 及解析后的可读用户名(email)。owner_name 由路由层
# 用 LocalAuthProvider 解析;解析失败时为 None,前端回退到短 id 展示。
owner_id: str | None = None
owner_name: str | None = None
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
@@ -103,6 +107,31 @@ def _current_user_id(request: Request) -> str:
return get_effective_user_id()
async def _enrich_owner_names(rows: list[dict]) -> None:
"""把每行的 ``owner_id`` 解析成可读用户名(email),写入 ``owner_name``。
与 knowledge 路由的 ``_enrich_actor_names`` 同思路:批量解析、按 id 缓存、best-effort
(解析失败 / provider 不可用时 ``owner_name`` 留空,前端回退到短 id)。
"""
try:
provider = get_local_provider()
except Exception:
return
cache: dict[str, str | None] = {}
for row in rows:
uid = row.get("owner_id")
if not uid:
row["owner_name"] = None
continue
if uid not in cache:
try:
user = await provider.get_user(uid)
cache[uid] = user.email if user else None
except Exception:
cache[uid] = None
row["owner_name"] = cache[uid]
def _get_store(request: Request):
store = getattr(request.app.state, "roundtable_chain_store", None)
if store is None:
@@ -211,6 +240,7 @@ async def list_chains(request: Request, scope: str = "mine") -> ChainListRespons
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_chains(user_id, scope=normalized_scope)
await _enrich_owner_names(rows)
return ChainListResponse(chains=[ChainResponse(**r) for r in rows])
@@ -233,6 +263,7 @@ async def create_chain(request: Request, body: ChainCreateRequest) -> ChainRespo
"coordinator_prompt": body.coordinator_prompt,
},
)
await _enrich_owner_names([row])
return ChainResponse(**row)
@@ -243,6 +274,7 @@ async def get_chain(request: Request, chain_id: str) -> ChainResponse:
row = await store.get_chain(chain_id, user_id)
if row is None:
raise HTTPException(status_code=404, detail="Chain not found")
await _enrich_owner_names([row])
return ChainResponse(**row)
@@ -265,6 +297,7 @@ async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest
row = await store.update_chain(chain_id, user_id, **fields)
if row is None:
raise HTTPException(status_code=404, detail="Chain not found")
await _enrich_owner_names([row])
return ChainResponse(**row)
@@ -59,6 +59,8 @@ class RoundtableChainRepository:
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
"is_public": bool(getattr(row, "is_public", False)),
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
# 配置者(owner)的 user_id;路由层据此解析成可读用户名(owner_name)。
"owner_id": row.user_id,
"created_at": row.created_at.isoformat() if isinstance(row.created_at, datetime) else row.created_at,
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
}