From 146c8e76f0da97c413cc15f53352e6eb4fbebe0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Sat, 13 Jun 2026 15:32:37 +0800 Subject: [PATCH] =?UTF-8?q?@=20feat(=E5=9C=86=E6=A1=8C):=20=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E9=93=BE=E6=9D=A1=E6=98=BE=E7=A4=BA=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E4=BA=BA=20+=20=E9=85=8D=E7=BD=AE=E9=A1=B5=E6=8B=86=E4=B8=A4?= =?UTF-8?q?=E6=AD=A5=E5=B9=B6=E6=96=B0=E5=A2=9E=E7=B2=BE=E7=BE=8E=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 列表/弹窗卡片显示「配置人」(后端把 owner user_id 解析为 email, ChainResponse 增 owner_id/owner_name,store 输出 owner_id) - 配置编辑器拆为两步:第一步基本信息(描述改多行文本),第二步链条编排 - 第一步有编排内容时,左侧基本信息 + 右侧 ChainFlowDiagram 流程图: 直角连线、点节点开详情抽屉、连线显示阶段目标(依赖)、滚轮缩放、 拖动平移、全屏 + 全屏右下角缩略图 Co-Authored-By: Claude Opus 4.8 (1M context) @ --- .../src/roundtable-planning/api/chains.ts | 2 + .../components/BusinessChainListDialog.tsx | 13 + .../components/ChainFlowDiagram.tsx | 462 ++++++++++++++++++ .../roundtable-planning/lib/business-chain.ts | 2 + .../pages/BusinessChainEditorPage.tsx | 268 ++++++---- .../app/gateway/routers/roundtable_chains.py | 35 +- .../persistence/roundtable_chains/sql.py | 2 + 7 files changed, 693 insertions(+), 91 deletions(-) create mode 100644 frontend-web/src/roundtable-planning/components/ChainFlowDiagram.tsx diff --git a/frontend-web/src/roundtable-planning/api/chains.ts b/frontend-web/src/roundtable-planning/api/chains.ts index 85d6a8ac..4be2479f 100644 --- a/frontend-web/src/roundtable-planning/api/chains.ts +++ b/frontend-web/src/roundtable-planning/api/chains.ts @@ -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, }; diff --git a/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx b/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx index 107d33d7..3f96a2cb 100644 --- a/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx +++ b/frontend-web/src/roundtable-planning/components/BusinessChainListDialog.tsx @@ -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 (
{/* 标题行 + 公共徽标 + 发布/设置按钮 */} @@ -274,6 +277,16 @@ function ChainRow({
+ {ownerName && ( +
+ + 配置人:{ownerName} +
+ )} + {chain.description && (
{chain.description}
)} diff --git a/frontend-web/src/roundtable-planning/components/ChainFlowDiagram.tsx b/frontend-web/src/roundtable-planning/components/ChainFlowDiagram.tsx new file mode 100644 index 00000000..c5a49ebb --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/ChainFlowDiagram.tsx @@ -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[] }> = []; + 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(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) => { + 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 = ( +
{ + 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 一起缩放平移 */} +
+ + + + + + + {layout.edges.map((edge) => ( + + ))} + + + {/* 节点卡片 */} + {layout.cols.flatMap((col) => + col.nodes.map((n) => { + const isCoordinator = n.id === COORDINATOR_ID; + return ( +
e.stopPropagation()} + onClick={() => { + if (isCoordinator) return; + // 全屏时先退出(详情抽屉 z-50 会被 z-[100] 全屏层盖住),再打开抽屉。 + if (fullscreen) setFullscreen(false); + onNodeClick?.(n.id, n.name); + }} + > +
+
{getAvatar(n.avatarType, "h-9 w-9")}
+
+
{n.name}
+
+ {isCoordinator ? "按阶段派活 · 综合收口" : n.description || n.id} +
+
+
+
+ ); + }), + )} + + {/* 依赖标签(步骤之间的连线上) */} + {layout.labels.map((l) => ( +
+
+ {l.text} +
+
+ ))} +
+ + {/* 工具条:缩小 / 放大 / 适应 / 全屏 */} +
+ zoomBy(1.2)}> + + + zoomBy(1 / 1.2)}> + + + + + + setFullscreen((f) => !f)} + > + {fullscreen ? : } + +
+ + {/* 缩放比例徽标 */} +
+ {Math.round(view.scale * 100)}% +
+ + {/* 全屏退出按钮(左上) */} + {fullscreen && ( + + )} + + {/* 缩略图:仅全屏时右下角 */} + {fullscreen && ( +
+
+ {/* 节点缩略块 */} + {layout.allNodes.map((n) => ( +
+ ))} + {/* 当前视口框 */} +
+
+
+ )} +
+ ); + + // 全屏:固定定位铺满视口(position:fixed 自带脱离文档流,无需 portal)。 + if (fullscreen) { + return
{canvas}
; + } + return
{canvas}
; +} + +// ── 工具按钮 ──────────────────────────────────────────────────────────────── + +function ToolButton({ + title, + onClick, + children, +}: { + title: string; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} diff --git a/frontend-web/src/roundtable-planning/lib/business-chain.ts b/frontend-web/src/roundtable-planning/lib/business-chain.ts index d550b4a6..414ea3cf 100644 --- a/frontend-web/src/roundtable-planning/lib/business-chain.ts +++ b/frontend-web/src/roundtable-planning/lib/business-chain.ts @@ -52,6 +52,8 @@ export interface BusinessChain { isPublic?: boolean; /** 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条为 false)。 */ isMine?: boolean; + /** 配置者(owner)的可读用户名(后端解析的 email);解析失败时为空。 */ + ownerName?: string; createdAt?: string; updatedAt?: string; } diff --git a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx index 283338e8..fcefdb7a 100644 --- a/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx +++ b/frontend-web/src/roundtable-planning/pages/BusinessChainEditorPage.tsx @@ -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(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 = ( +
+ {/* 标题 */} +
+ + 标题 + + 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" + /> +
+ + {/* 描述(多行文本,与总控提示词同款)。 */} +
+
+ 描述 + + 链条用途说明:解决什么问题、适用什么场景(可选) + +
+