@
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:
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)
|
||||
|
||||
|
||||
|
||||
+2
@@ -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,
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user