import { useCallback, useEffect, useMemo, useState } from "react"; import { ReactFlow, Background, Controls, MiniMap, addEdge, useNodesState, useEdgesState, type Connection, type Node, type Edge, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { Play, ShieldCheck, Sparkles, Trash2, Save, Workflow } from "lucide-react"; import { nodeTypes, type NodeStatus } from "./TypedNode"; import { Inspector } from "./Inspector"; import { NODE_KINDS, NODE_ORDER } from "./nodeCatalog"; import { exportDsl, validate, type Issue, type TaskDsl } from "../lib/dsl"; import type { RunPhase } from "../lib/run"; import { listKb, listChatModels, listAgents, saveAgent, deleteAgent, type Identity, type AgentInfo } from "../lib/api"; import { useTheme } from "../lib/theme"; import { Button, Input, cn, useToast } from "../ui"; let seq = 0; // buildExample:一张可直接运行的示例图(输入 → 检索本人 default 库 → Agent → 输出)。 function buildExample(): { nodes: Node[]; edges: Edge[] } { const mk = (id: string, kind: string, x: number, cfg: Record): Node => ({ id, type: "typed", position: { x, y: 140 }, data: { kind, label: NODE_KINDS[kind].label, config: { ...NODE_KINDS[kind].defaults, ...cfg }, status: "idle" as NodeStatus }, }); return { nodes: [ mk("ex-in", "input", 40, { text: "sundynix-agentix 的架构是怎样的?" }), mk("ex-rag", "retriever", 300, { kb: "default", topK: 4 }), mk("ex-agent", "agent", 560, { system: "你是知识库问答助手,依据检索到的资料严谨作答。" }), mk("ex-out", "output", 820, {}), ], edges: [ { id: "ex-e1", source: "ex-in", target: "ex-rag" }, { id: "ex-e2", source: "ex-rag", target: "ex-agent" }, { id: "ex-e3", source: "ex-agent", target: "ex-out" }, ], }; } // 编排 Studio:左(节点面板 + 我的编排) · 中画布 · 右检查器 · 顶工具栏。 export function StudioView({ onRun, phase, identity, readOnly = false, spaceId = "", spaceReadOnly = false }: { onRun: (dsl: TaskDsl) => void; phase: RunPhase; identity: Identity; readOnly?: boolean; spaceId?: string; spaceReadOnly?: boolean }) { const toast = useToast(); const { theme } = useTheme(); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [selId, setSelId] = useState(null); const [issues, setIssues] = useState(null); const [kbOpts, setKbOpts] = useState([]); const [modelOpts, setModelOpts] = useState([]); const [agents, setAgents] = useState([]); const [name, setName] = useState(""); useEffect(() => { listKb(identity).then((ks) => setKbOpts(ks.map((k) => k.name))).catch(() => {}); listChatModels().then(setModelOpts).catch(() => {}); }, [identity]); const refreshAgents = useCallback(() => { listAgents(identity).then(setAgents).catch(() => {}); }, [identity]); // 切换工作区(spaceId 变)后重新拉取该空间的编排(共享工作区隔离)。 useEffect(() => refreshAgents(), [refreshAgents, spaceId]); const dynamicOptions = useMemo(() => ({ kb: kbOpts, model: modelOpts }), [kbOpts, modelOpts]); const onConnect = useCallback((c: Connection) => setEdges((es) => addEdge(c, es)), [setEdges]); const addNode = useCallback( (kind: string) => { const id = `n${++seq}`; const k = NODE_KINDS[kind]; setNodes((ns) => [ ...ns, { id, type: "typed", position: { x: 120 + ((ns.length * 40) % 360), y: 60 + ns.length * 64 }, data: { kind, label: k.label, config: { ...k.defaults }, status: "idle" as NodeStatus }, }, ]); }, [setNodes], ); const patchNode = useCallback( (id: string, patch: Record) => setNodes((ns) => ns.map((n) => (n.id === id ? { ...n, data: { ...n.data, ...patch } } : n))), [setNodes], ); const deleteNode = useCallback( (id: string) => { setNodes((ns) => ns.filter((n) => n.id !== id)); setEdges((es) => es.filter((e) => e.source !== id && e.target !== id)); setSelId(null); }, [setNodes, setEdges], ); const loadGraph = useCallback( (g: { nodes?: Node[]; edges?: Edge[] }) => { // 防御:旧/损坏数据可能缺 position/type/data —— 全部兜底,避免 React Flow 崩成黑屏。 const safeNodes: Node[] = (g.nodes ?? []) .filter((n) => n && n.id) .map((n, i) => { const d = (n.data ?? {}) as { kind?: string; label?: string; config?: Record }; const kind = d.kind && NODE_KINDS[d.kind] ? d.kind : "output"; const pos = n.position && typeof n.position.x === "number" && typeof n.position.y === "number" ? n.position : { x: 100 + (i % 4) * 220, y: 80 + Math.floor(i / 4) * 120 }; return { id: n.id, type: "typed", position: pos, data: { kind, label: d.label || NODE_KINDS[kind].label, config: d.config ?? {}, status: "idle" as NodeStatus }, }; }); const ids = new Set(safeNodes.map((n) => n.id)); const safeEdges: Edge[] = (g.edges ?? []) .filter((e) => e && e.source && e.target && ids.has(e.source) && ids.has(e.target)) .map((e, i) => ({ ...e, id: e.id || `e${i}` })); setNodes(safeNodes); setEdges(safeEdges); setSelId(null); setIssues(null); for (const n of safeNodes) { const m = /^n(\d+)$/.exec(n.id); if (m) seq = Math.max(seq, Number(m[1])); } }, [setNodes, setEdges], ); const clear = () => { setNodes([]); setEdges([]); setSelId(null); setIssues(null); }; const save = async () => { if (spaceReadOnly) return toast.push("error", "当前工作区你是只读成员(viewer),无权保存编排"); const nm = name.trim(); if (!nm) return toast.push("error", "先填编排名"); if (nodes.length === 0) return toast.push("error", "画布为空"); try { await saveAgent(identity, nm, JSON.stringify({ nodes, edges })); toast.push("success", `已保存编排「${nm}」`); refreshAgents(); } catch (e) { toast.push("error", (e as Error).message); } }; const openAgent = (a: AgentInfo) => { try { loadGraph(JSON.parse(a.graph)); setName(a.name); } catch { toast.push("error", "编排数据损坏,无法载入"); } }; const removeAgent = async (nm: string) => { try { await deleteAgent(identity, nm); refreshAgents(); toast.push("success", `已删除「${nm}」`); } catch (e) { toast.push("error", (e as Error).message); } }; useEffect(() => { const status: NodeStatus = phase === "streaming" || phase === "submitting" ? "running" : phase === "done" ? "done" : phase === "error" ? "error" : "idle"; setNodes((ns) => ns.map((n) => ({ ...n, data: { ...n.data, status } }))); }, [phase, setNodes]); const run = useCallback(() => { if (readOnly) { toast.push("error", "当前身份为只读成员(viewer),无权在此租户运行编排"); return; } const found = validate(nodes, edges); setIssues(found); if (!found.some((i) => i.level === "error")) onRun(exportDsl(nodes, edges)); }, [nodes, edges, onRun, readOnly, toast]); const selected = useMemo(() => nodes.find((n) => n.id === selId) ?? null, [nodes, selId]); const running = phase === "submitting" || phase === "streaming"; return (
{/* 左:节点面板 + 我的编排 */}
节点
{NODE_ORDER.map((kind) => { const k = NODE_KINDS[kind]; return ( ); })}
空间编排 {agents.length > 0 && `(${agents.length})`}
    {agents.length === 0 &&
  • 保存后在此列出,同空间成员共享。
  • } {agents.map((a) => (
  • {(a.mine !== false && !spaceReadOnly) && ( )}
  • ))}
{/* 中画布 */}
setName(e.target.value)} placeholder="编排名" /> {nodes.length} 节点 · {edges.length} 连线 {issues && ( {issues.length === 0 ? ✓ 校验通过 : {issues.length} 项提示} )}
setSelId(n.id)} onPaneClick={() => setSelId(null)} deleteKeyCode={["Backspace", "Delete"]} fitView > {issues && issues.length > 0 && (
{issues.map((i, idx) => (
{i.level === "error" ? "✗" : "⚠"} {i.msg}
))}
)}
{/* 右检查器 */}
); }