@
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;
|
coordinator_prompt?: string | null;
|
||||||
is_public?: boolean;
|
is_public?: boolean;
|
||||||
is_mine?: boolean;
|
is_mine?: boolean;
|
||||||
|
owner_name?: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
@@ -88,6 +89,7 @@ function toChain(s: ServerChain): BusinessChain {
|
|||||||
isPublic: s.is_public ?? false,
|
isPublic: s.is_public ?? false,
|
||||||
// 后端缺省时按「我的」处理(向后兼容旧响应)。
|
// 后端缺省时按「我的」处理(向后兼容旧响应)。
|
||||||
isMine: s.is_mine ?? true,
|
isMine: s.is_mine ?? true,
|
||||||
|
ownerName: s.owner_name ?? undefined,
|
||||||
createdAt: s.created_at ?? undefined,
|
createdAt: s.created_at ?? undefined,
|
||||||
updatedAt: s.updated_at ?? undefined,
|
updatedAt: s.updated_at ?? undefined,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
LayersIcon,
|
LayersIcon,
|
||||||
Loader2Icon,
|
Loader2Icon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
|
UserIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -227,6 +228,8 @@ function ChainRow({
|
|||||||
onTogglePublic: () => void;
|
onTogglePublic: () => void;
|
||||||
}) {
|
}) {
|
||||||
const isMine = chain.isMine !== false;
|
const isMine = chain.isMine !== false;
|
||||||
|
// 配置者用户名:后端返回 email,展示时取 @ 前的本地部分更像「用户名」。
|
||||||
|
const ownerName = chain.ownerName ? chain.ownerName.split("@")[0] : "";
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-border bg-card p-5 transition-colors hover:border-primary/40">
|
<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>
|
||||||
</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 && (
|
{chain.description && (
|
||||||
<div className="mt-1.5 line-clamp-2 text-sm text-muted-foreground">{chain.description}</div>
|
<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;
|
isPublic?: boolean;
|
||||||
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
|
/** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */
|
||||||
isMine?: boolean;
|
isMine?: boolean;
|
||||||
|
/** 配置者(owner)的可读用户名(后端解析的 email);解析失败时为空。 */
|
||||||
|
ownerName?: string;
|
||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
*
|
*
|
||||||
* 详见 `docs/roundtable-dag-orchestration.md` §2(Phase 2,分层编辑器)。
|
* 详见 `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 { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
@@ -113,6 +113,7 @@ import {
|
|||||||
} from "@/roundtable-planning/lib/chain-stages";
|
} from "@/roundtable-planning/lib/chain-stages";
|
||||||
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
|
import { getAvatarTypeFor } from "@/roundtable-planning/lib/roundtable-constants";
|
||||||
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
|
import { DagFlowChart } from "@/roundtable-planning/components/DagFlowChart";
|
||||||
|
import { ChainFlowDiagram } from "@/roundtable-planning/components/ChainFlowDiagram";
|
||||||
import { AgentDetailSheet } from "@/roundtable-planning/components/AgentDetailSheet";
|
import { AgentDetailSheet } from "@/roundtable-planning/components/AgentDetailSheet";
|
||||||
|
|
||||||
const POOL_PREFIX = "pool::";
|
const POOL_PREFIX = "pool::";
|
||||||
@@ -161,6 +162,8 @@ export default function BusinessChainEditorPage() {
|
|||||||
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
|
const [activeDrag, setActiveDrag] = useState<ActiveDrag>(null);
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
const [flowOpen, setFlowOpen] = 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 [detailAgent, setDetailAgent] = useState<{ id: string; name?: string } | null>(null);
|
||||||
const showDetail = useCallback((id: string, name?: string) => {
|
const showDetail = useCallback((id: string, name?: string) => {
|
||||||
@@ -205,6 +208,16 @@ export default function BusinessChainEditorPage() {
|
|||||||
[seatNameById],
|
[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 () => {
|
const refreshChains = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -252,6 +265,7 @@ export default function BusinessChainEditorPage() {
|
|||||||
setDescription(chain.description ?? "");
|
setDescription(chain.description ?? "");
|
||||||
setCoordinatorPrompt(chain.coordinatorPrompt ?? "");
|
setCoordinatorPrompt(chain.coordinatorPrompt ?? "");
|
||||||
setStageGroups(chainToStageGroups(chain));
|
setStageGroups(chainToStageGroups(chain));
|
||||||
|
setStep(1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const startNewChain = useCallback(() => {
|
const startNewChain = useCallback(() => {
|
||||||
@@ -262,6 +276,7 @@ export default function BusinessChainEditorPage() {
|
|||||||
setDescription("");
|
setDescription("");
|
||||||
setCoordinatorPrompt("");
|
setCoordinatorPrompt("");
|
||||||
setStageGroups([]);
|
setStageGroups([]);
|
||||||
|
setStep(1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ─── URL 参数 chainId:从「业务链条」弹窗点「设置」进来时自动切到该链条 ──────
|
// ─── URL 参数 chainId:从「业务链条」弹窗点「设置」进来时自动切到该链条 ──────
|
||||||
@@ -615,6 +630,100 @@ export default function BusinessChainEditorPage() {
|
|||||||
// ─── Render ──────────────────────────────────────────────────────────
|
// ─── Render ──────────────────────────────────────────────────────────
|
||||||
const triggerLabel = currentChainId ? title || "未命名链条" : "+ 新建链条…";
|
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 (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col bg-muted/40 text-foreground">
|
<div className="flex h-full min-h-0 flex-col bg-muted/40 text-foreground">
|
||||||
{/* 顶栏 */}
|
{/* 顶栏 */}
|
||||||
@@ -786,94 +895,70 @@ export default function BusinessChainEditorPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* 标题 + 描述 */}
|
{/* 步骤导航:① 基本信息 → ② 链条编排(点击可切换)。 */}
|
||||||
<section className="shrink-0 border-b border-border bg-card px-6 py-4">
|
<nav className="flex shrink-0 items-center justify-center gap-3 border-b border-border bg-card px-6 py-2.5">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
{([
|
||||||
<div
|
{ n: 1 as const, label: "基本信息" },
|
||||||
className={cn(
|
{ n: 2 as const, label: "链条编排" },
|
||||||
"flex items-stretch overflow-hidden border border-border bg-card transition focus-within:border-primary focus-within:ring-1 focus-within:ring-primary/30",
|
]).map((s, i) => (
|
||||||
R_CTRL,
|
<Fragment key={s.n}>
|
||||||
)}
|
{i > 0 && <span className="h-px w-10 bg-border" />}
|
||||||
>
|
<button
|
||||||
<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">
|
type="button"
|
||||||
标题
|
onClick={() => setStep(s.n)}
|
||||||
</span>
|
className={cn(
|
||||||
<Input
|
"flex items-center gap-2 border px-3 py-1 text-sm font-medium transition",
|
||||||
value={title}
|
R_CTRL,
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
step === s.n
|
||||||
placeholder="如:应急方案三段链"
|
? "border-primary bg-primary/10 text-primary"
|
||||||
maxLength={120}
|
: "border-border bg-card text-muted-foreground hover:text-foreground",
|
||||||
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>
|
<span
|
||||||
|
|
||||||
<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 才能在它基础上优化"
|
|
||||||
}
|
|
||||||
className={cn(
|
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",
|
"flex size-5 items-center justify-center rounded-full text-[11px] font-bold tabular-nums",
|
||||||
R_CTRL,
|
step === s.n ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{optimizingCoordinator ? (
|
{s.n}
|
||||||
<Loader2 className="size-3 animate-spin" />
|
</span>
|
||||||
) : (
|
{s.label}
|
||||||
<Wand2 className="size-3" />
|
</button>
|
||||||
)}
|
</Fragment>
|
||||||
{optimizingCoordinator ? "优化中…" : "AI 优化"}
|
))}
|
||||||
</button>
|
</nav>
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* 双栏拖拽编辑器 */}
|
{/* 步骤一:左=基本信息,右=精美流程图预览(有编排内容时)。无内容则居中单列。 */}
|
||||||
|
{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
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
@@ -975,10 +1060,11 @@ export default function BusinessChainEditorPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Phase 7:分层 DAG 只读流程图预览(§2.2 底部)。空编排时不渲染。
|
{/* Phase 7:分层 DAG 只读流程图预览(§2.2 底部)。空编排时不渲染。
|
||||||
改为弹窗查看:底栏一个入口按钮,点击后在 Dialog 内展示完整流程图。 */}
|
改为弹窗查看:底栏一个入口按钮,点击后在 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">
|
<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 className="flex items-center gap-2 text-[11px] font-semibold text-muted-foreground">
|
||||||
<span>编排预览</span>
|
<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">
|
{step === 2 && (
|
||||||
分层编排:同一 Stage 内的席位并行研讨,Stage 之间串行。全单席位 = 线性链。保存后自动同步至圆桌。
|
<footer className="shrink-0 select-none border-t border-border bg-card px-6 py-3 text-center text-[11px] text-muted-foreground">
|
||||||
</footer>
|
分层编排:同一 Stage 内的席位并行研讨,Stage 之间串行。全单席位 = 线性链。保存后自动同步至圆桌。
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
|||||||
from langchain_core.messages import HumanMessage, SystemMessage
|
from langchain_core.messages import HumanMessage, SystemMessage
|
||||||
from pydantic import BaseModel, Field
|
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.config.app_config import AppConfig
|
||||||
from deerflow.models import create_chat_model
|
from deerflow.models import create_chat_model
|
||||||
from deerflow.runtime.user_context import get_effective_user_id
|
from deerflow.runtime.user_context import get_effective_user_id
|
||||||
@@ -61,6 +61,10 @@ class ChainResponse(BaseModel):
|
|||||||
is_public: bool = False
|
is_public: bool = False
|
||||||
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
|
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
|
||||||
is_mine: bool = True
|
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
|
created_at: datetime | str | None = None
|
||||||
updated_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()
|
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):
|
def _get_store(request: Request):
|
||||||
store = getattr(request.app.state, "roundtable_chain_store", None)
|
store = getattr(request.app.state, "roundtable_chain_store", None)
|
||||||
if store is None:
|
if store is None:
|
||||||
@@ -211,6 +240,7 @@ async def list_chains(request: Request, scope: str = "mine") -> ChainListRespons
|
|||||||
store = _get_store(request)
|
store = _get_store(request)
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
rows = await store.list_chains(user_id, scope=normalized_scope)
|
rows = await store.list_chains(user_id, scope=normalized_scope)
|
||||||
|
await _enrich_owner_names(rows)
|
||||||
return ChainListResponse(chains=[ChainResponse(**r) for r in 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,
|
"coordinator_prompt": body.coordinator_prompt,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
await _enrich_owner_names([row])
|
||||||
return ChainResponse(**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)
|
row = await store.get_chain(chain_id, user_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="Chain not found")
|
raise HTTPException(status_code=404, detail="Chain not found")
|
||||||
|
await _enrich_owner_names([row])
|
||||||
return ChainResponse(**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)
|
row = await store.update_chain(chain_id, user_id, **fields)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise HTTPException(status_code=404, detail="Chain not found")
|
raise HTTPException(status_code=404, detail="Chain not found")
|
||||||
|
await _enrich_owner_names([row])
|
||||||
return ChainResponse(**row)
|
return ChainResponse(**row)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
@@ -59,6 +59,8 @@ class RoundtableChainRepository:
|
|||||||
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
|
# 发布状态 + 是否本人所有(前端据此显示「公共」徽标、决定能否编辑 / 发布)。
|
||||||
"is_public": bool(getattr(row, "is_public", False)),
|
"is_public": bool(getattr(row, "is_public", False)),
|
||||||
"is_mine": (current_user_id is None) or (row.user_id == current_user_id),
|
"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,
|
"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,
|
"updated_at": row.updated_at.isoformat() if isinstance(row.updated_at, datetime) else row.updated_at,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user