From 2bde314820ab0aeaa867c8144d028a3d0c409f0b Mon Sep 17 00:00:00 2001 From: mlogclub Date: Sun, 28 Jun 2026 00:08:55 +0800 Subject: [PATCH] feat: enhance flowgram editor with condition node handling, port normalization, and UI improvements --- .../_components/flowgram-editor-provider.tsx | 194 +++++++++++------- .../_components/flowgram-node-registries.tsx | 106 +++++++++- .../_components/flowgram-node-renderer.tsx | 49 ++--- .../_components/workflow-utils.test.mjs | 64 ++++++ .../_components/workflow-utils.ts | 92 ++++++++- 5 files changed, 394 insertions(+), 111 deletions(-) diff --git a/web/app/dashboard/ai-workflows/_components/flowgram-editor-provider.tsx b/web/app/dashboard/ai-workflows/_components/flowgram-editor-provider.tsx index a7ba0f5..e9e6820 100644 --- a/web/app/dashboard/ai-workflows/_components/flowgram-editor-provider.tsx +++ b/web/app/dashboard/ai-workflows/_components/flowgram-editor-provider.tsx @@ -3,7 +3,9 @@ import { useMemo } from "react" import { + type FreeLayoutPluginContext, type FreeLayoutProps, + type WorkflowNodeEntity, type WorkflowJSON, } from "@flowgram.ai/free-layout-editor" import { createFreeSnapPlugin } from "@flowgram.ai/free-snap-plugin" @@ -13,6 +15,10 @@ import type { AIWorkflowDefinition, AIWorkflowNodeSpec } from "@/lib/api/admin" import { FlowgramNodeRenderer } from "./flowgram-node-renderer" import { buildFlowgramNodeRegistries } from "./flowgram-node-registries" +import { + normalizeConditionPortsForFlowgram, + syncConditionBranchTargetsFromEdges, +} from "./workflow-utils" export function useFlowgramEditorProps({ definition, @@ -26,79 +32,121 @@ export function useFlowgramEditorProps({ onDefinitionChange?: (definition: AIWorkflowDefinition) => void }) { return useMemo( - () => ({ - background: true, - readonly, - initialData: definition as WorkflowJSON, - nodeRegistries: buildFlowgramNodeRegistries(nodeSpecs), - fromNodeJSON(_node, json) { - return json - }, - toNodeJSON(_node, json) { - return json - }, - materials: { - renderDefaultNode: FlowgramNodeRenderer, - }, - nodeEngine: { - enable: true, - }, - history: { - enable: !readonly, - enableChangeNode: !readonly, - }, - canDeleteNode: (_ctx, node) => { - const type = String(node.flowNodeType ?? "") - return type !== "start" && type !== "end" - }, - canDeleteLine: () => !readonly, - onContentChange: (ctx) => { - if (readonly) { - return - } - onDefinitionChange?.(ctx.document.toJSON() as AIWorkflowDefinition) - }, - onAllLayersRendered: (ctx) => { - void ctx.tools.fitView(false) - }, - getNodeDefaultRegistry(type) { - return { - type, - meta: { - defaultExpanded: true, - }, - } - }, - plugins: () => [ - createMinimapPlugin({ - disableLayer: true, - canvasStyle: { - canvasWidth: 150, - canvasHeight: 84, - canvasPadding: 48, - canvasBackground: "rgba(245, 245, 245, 1)", - canvasBorderRadius: 8, - viewportBackground: "rgba(235, 235, 235, 1)", - viewportBorderRadius: 4, - viewportBorderColor: "rgba(201, 201, 201, 1)", - viewportBorderWidth: 1, - viewportBorderDashLength: 2, - nodeColor: "rgba(255, 255, 255, 1)", - nodeBorderRadius: 2, - nodeBorderWidth: 0.145, - nodeBorderColor: "rgba(6, 7, 9, 0.10)", - overlayColor: "rgba(255, 255, 255, 0)", - }, - }), - createFreeSnapPlugin({ - edgeColor: "#00B2B2", - alignColor: "#00B2B2", - edgeLineWidth: 1, - alignLineWidth: 1, - alignCrossWidth: 8, - }), - ], - }), + () => { + const initialData = normalizeConditionPortsForFlowgram(definition) + return { + background: true, + readonly, + scroll: { + disableScrollBar: true, + }, + initialData: initialData as WorkflowJSON, + nodeRegistries: buildFlowgramNodeRegistries(nodeSpecs), + fromNodeJSON(_node, json) { + return json + }, + toNodeJSON(_node, json) { + return json + }, + materials: { + renderDefaultNode: FlowgramNodeRenderer, + }, + nodeEngine: { + enable: true, + }, + history: { + enable: !readonly, + enableChangeNode: !readonly, + }, + canDeleteNode: (_ctx, node) => { + const type = String(node.flowNodeType ?? "") + return type !== "start" && type !== "end" + }, + canDeleteLine: () => !readonly, + onContentChange: (ctx) => { + if (readonly) { + return + } + const next = normalizeConditionPortsForFlowgram( + syncConditionBranchTargetsFromEdges(ctx.document.toJSON() as AIWorkflowDefinition) + ) + onDefinitionChange?.(next) + }, + onAllLayersRendered: (ctx) => { + scrollToInitialNode(ctx) + }, + getNodeDefaultRegistry(type) { + return { + type, + meta: { + defaultExpanded: true, + }, + } + }, + plugins: () => [ + createMinimapPlugin({ + disableLayer: true, + canvasStyle: { + canvasWidth: 150, + canvasHeight: 84, + canvasPadding: 48, + canvasBackground: "rgba(245, 245, 245, 1)", + canvasBorderRadius: 8, + viewportBackground: "rgba(235, 235, 235, 1)", + viewportBorderRadius: 4, + viewportBorderColor: "rgba(201, 201, 201, 1)", + viewportBorderWidth: 1, + viewportBorderDashLength: 2, + nodeColor: "rgba(255, 255, 255, 1)", + nodeBorderRadius: 2, + nodeBorderWidth: 0.145, + nodeBorderColor: "rgba(6, 7, 9, 0.10)", + overlayColor: "rgba(255, 255, 255, 0)", + }, + }), + createFreeSnapPlugin({ + edgeColor: "#00B2B2", + alignColor: "#00B2B2", + edgeLineWidth: 1, + alignLineWidth: 1, + alignCrossWidth: 8, + }), + ], + } + }, [definition, nodeSpecs, onDefinitionChange, readonly] ) } + +function scrollToInitialNode(ctx: FreeLayoutPluginContext) { + const nodes = ctx.document.getAllNodes() + const startNode = nodes.find((node) => String(node.flowNodeType ?? "") === "start") + const targetNode = startNode ?? findLeftTopNode(nodes) + if (!targetNode) { + return + } + + window.requestAnimationFrame(() => { + const viewport = ctx.playground.config.getViewport(false) + void ctx.playground.scrollToView({ + bounds: targetNode.transform.bounds, + scrollDelta: { + x: Math.max(viewport.width / 2 - 250, 0), + y: Math.max(viewport.height / 2 - 180, 0), + }, + zoom: 1, + scrollToCenter: true, + }) + }) +} + +function findLeftTopNode(nodes: WorkflowNodeEntity[]) { + return [...nodes].sort((left, right) => { + const leftBounds = left.transform.bounds + const rightBounds = right.transform.bounds + if (leftBounds.left !== rightBounds.left) { + return leftBounds.left - rightBounds.left + } + return leftBounds.top - rightBounds.top + })[0] +} diff --git a/web/app/dashboard/ai-workflows/_components/flowgram-node-registries.tsx b/web/app/dashboard/ai-workflows/_components/flowgram-node-registries.tsx index 5626fc4..8fa21d0 100644 --- a/web/app/dashboard/ai-workflows/_components/flowgram-node-registries.tsx +++ b/web/app/dashboard/ai-workflows/_components/flowgram-node-registries.tsx @@ -1,6 +1,13 @@ import { Field, type WorkflowNodeRegistry } from "@flowgram.ai/free-layout-editor" +import { PlusIcon } from "lucide-react" +import { Button } from "@/components/ui/button" import type { AIWorkflowNodeSpec } from "@/lib/api/admin" +import { + createConditionBranchID, + normalizeNodeConfig, + type WorkflowConditionBranch, +} from "./workflow-utils" export function buildFlowgramNodeRegistries(nodeSpecs: AIWorkflowNodeSpec[]): WorkflowNodeRegistry[] { const seen = new Set() @@ -41,16 +48,20 @@ function FlowgramNodeForm({ nodeType: string fallbackTitle: string }) { + if (nodeType === "condition") { + return + } + return ( -
+
name="title"> {({ field }) => ( -
+
{field.value || fallbackTitle}
)} -
{nodeType}
+
{nodeType}
) } @@ -62,5 +73,94 @@ function defaultPortsForNodeType(type: string) { if (type === "end") { return [{ type: "input" as const }] } + if (type === "condition") { + return [{ type: "input" as const }] + } return [{ type: "input" as const }, { type: "output" as const }] } + +function ConditionNodeForm({ fallbackTitle }: { fallbackTitle: string }) { + return ( +
+ name="title"> + {({ field }) => ( +
+ {field.value || fallbackTitle} +
+ )} + + > name="config"> + {({ field }) => { + const config = normalizeNodeConfig(field.value) + const branches = ensureConditionBranches(config.branches ?? []) + const updateBranches = (nextBranches: WorkflowConditionBranch[]) => { + field.onChange({ + ...config, + branches: ensureConditionBranches(nextBranches), + }) + } + + return ( +
+
+ {branches.map((branch) => ( +
+ + {branch.name || (branch.default ? "默认分支" : branch.id)} + + {branch.default ? ( + + else + + ) : null} + + + +
+ ))} +
+ +
+ ) + }} + +
+ ) +} + +function ensureConditionBranches(branches: WorkflowConditionBranch[]) { + const normalized = branches.some((branch) => branch.default) + ? branches + : [...branches, { id: "default", name: "默认分支", targetNodeId: "", default: true }] + return [ + ...normalized.filter((branch) => !branch.default), + ...normalized.filter((branch) => branch.default).slice(0, 1), + ] +} diff --git a/web/app/dashboard/ai-workflows/_components/flowgram-node-renderer.tsx b/web/app/dashboard/ai-workflows/_components/flowgram-node-renderer.tsx index 031fa64..dbd74b9 100644 --- a/web/app/dashboard/ai-workflows/_components/flowgram-node-renderer.tsx +++ b/web/app/dashboard/ai-workflows/_components/flowgram-node-renderer.tsx @@ -5,54 +5,35 @@ import { WorkflowNodeRenderer, type WorkflowNodeProps, } from "@flowgram.ai/free-layout-editor" -import { - BotIcon, - CircleStopIcon, - DatabaseIcon, - GitBranchIcon, - MessageSquareTextIcon, - SendIcon, - UserRoundIcon, -} from "lucide-react" -import type { ComponentType } from "react" +import { useLayoutEffect } from "react" import { cn } from "@/lib/utils" -const iconByType: Record> = { - start: UserRoundIcon, - conversation_understanding: BotIcon, - reply_policy: MessageSquareTextIcon, - condition: GitBranchIcon, - knowledge_retrieve: DatabaseIcon, - answerability_gate: GitBranchIcon, - llm_reply: BotIcon, - human_confirm: UserRoundIcon, - create_ticket: MessageSquareTextIcon, - handoff_to_human: UserRoundIcon, - send_reply: SendIcon, - end: CircleStopIcon, -} - export function FlowgramNodeRenderer(props: WorkflowNodeProps) { const { selected, node, form } = useNodeRender() const nodeType = String(node.flowNodeType ?? "") - const Icon = iconByType[nodeType] ?? BotIcon + + useLayoutEffect(() => { + if (nodeType !== "condition") return + const frame = window.requestAnimationFrame(() => { + node.ports.updateDynamicPorts() + }) + return () => window.cancelAnimationFrame(frame) + }) return ( -
-
- -
- {form?.render()} -
+ {form?.render()}
) } diff --git a/web/app/dashboard/ai-workflows/_components/workflow-utils.test.mjs b/web/app/dashboard/ai-workflows/_components/workflow-utils.test.mjs index d121a59..ff5a55a 100644 --- a/web/app/dashboard/ai-workflows/_components/workflow-utils.test.mjs +++ b/web/app/dashboard/ai-workflows/_components/workflow-utils.test.mjs @@ -303,4 +303,68 @@ describe("workflow definition mutations", () => { const deleted = deleteConditionBranch(updated, "condition_1", "default") assert.deepEqual(plain(deleted.nodes[0].data.config.branches.map((branch) => branch.id)), ["vip"]) }) + + it("adds FlowGram source ports for condition edges without removing existing lines", async () => { + const { normalizeConditionPortsForFlowgram } = await loadModule() + const definition = { + schemaVersion: 2, + nodes: [ + workflowNode("condition_1", "condition", { x: 240, y: 0 }, { + config: { + branches: [ + { id: "vip", name: "VIP", targetNodeId: "vip_reply", condition: { operator: "eq" } }, + { id: "default", name: "默认", targetNodeId: "normal_reply", default: true }, + ], + }, + }), + workflowNode("vip_reply", "llm_reply", { x: 520, y: 0 }), + workflowNode("normal_reply", "llm_reply", { x: 520, y: 120 }), + ], + edges: [ + workflowEdge("condition_1", "vip_reply"), + workflowEdge("condition_1", "normal_reply"), + ], + } + + const next = normalizeConditionPortsForFlowgram(definition) + + assert.deepEqual(plain(next.nodes[0].data.portKeys), ["vip", "default"]) + assert.deepEqual(plain(next.nodes[0].data.ports), ["vip", "default"]) + assert.deepEqual(plain(next.edges), [ + workflowEdge("condition_1", "vip_reply", { sourcePortID: "vip" }), + workflowEdge("condition_1", "normal_reply", { sourcePortID: "default" }), + ]) + }) + + it("syncs branch targets from condition source ports while preserving unrelated edges", async () => { + const { syncConditionBranchTargetsFromEdges } = await loadModule() + const definition = { + schemaVersion: 2, + nodes: [ + workflowNode("condition_1", "condition", { x: 240, y: 0 }, { + config: { + branches: [ + { id: "vip", name: "VIP", targetNodeId: "", condition: { operator: "eq" } }, + { id: "default", name: "默认", targetNodeId: "", default: true }, + ], + }, + portKeys: ["vip", "default"], + ports: ["vip", "default"], + }), + workflowNode("vip_reply", "llm_reply", { x: 520, y: 0 }), + workflowNode("normal_reply", "llm_reply", { x: 520, y: 120 }), + ], + edges: [ + workflowEdge("condition_1", "vip_reply", { sourcePortID: "vip" }), + workflowEdge("condition_1", "normal_reply", { sourcePortID: "default" }), + workflowEdge("vip_reply", "normal_reply"), + ], + } + + const next = syncConditionBranchTargetsFromEdges(definition) + + assert.equal(next.nodes[0].data.config.branches[0].targetNodeId, "vip_reply") + assert.equal(next.nodes[0].data.config.branches[1].targetNodeId, "normal_reply") + assert.equal(next.edges.length, 3) + }) }) diff --git a/web/app/dashboard/ai-workflows/_components/workflow-utils.ts b/web/app/dashboard/ai-workflows/_components/workflow-utils.ts index 10d1a3f..4ee0c86 100644 --- a/web/app/dashboard/ai-workflows/_components/workflow-utils.ts +++ b/web/app/dashboard/ai-workflows/_components/workflow-utils.ts @@ -166,13 +166,16 @@ export function createWorkflowNodeFromSpec( position: WorkflowNodePosition ): AIWorkflowDefinition["nodes"][number] { const id = uniqueNodeId(existingNodes, spec.type) + const defaultConfig = spec.type === "condition" + ? { branches: [{ id: "default", name: "默认分支", targetNodeId: "", default: true }] } + : {} return { id, type: spec.type, meta: { position }, data: { title: spec.title || spec.type, - config: {}, + config: defaultConfig, inputsValues: spec.defaultInputs ?? {}, }, } @@ -247,6 +250,68 @@ export function deleteConditionBranch( }) } +export function normalizeConditionPortsForFlowgram( + definition: AIWorkflowDefinition +): AIWorkflowDefinition { + return { + ...definition, + nodes: definition.nodes.map((node) => { + if (node.type !== "condition") { + return node + } + const config = normalizeNodeConfig(node.data?.config) + const branches = ensureConditionBranches(config.branches ?? []) + return { + ...node, + data: { + ...(node.data ?? {}), + config: { ...config, branches }, + portKeys: branches.map((branch) => branch.id), + ports: branches.map((branch) => branch.id), + }, + } + }), + edges: definition.edges.map((edge) => { + const source = definition.nodes.find((node) => node.id === edge.sourceNodeID) + if (!source || source.type !== "condition" || edge.sourcePortID) { + return edge + } + const branch = findConditionBranchForTarget(source, edge.targetNodeID) + return branch ? { ...edge, sourcePortID: branch.id } : edge + }), + } +} + +export function syncConditionBranchTargetsFromEdges( + definition: AIWorkflowDefinition +): AIWorkflowDefinition { + return { + ...definition, + nodes: definition.nodes.map((node) => { + if (node.type !== "condition") { + return node + } + const config = normalizeNodeConfig(node.data?.config) + const branches = ensureConditionBranches(config.branches ?? []) + const nextBranches = branches.map((branch) => { + const edge = definition.edges.find((item) => ( + item.sourceNodeID === node.id && item.sourcePortID === branch.id + )) + return edge ? { ...branch, targetNodeId: edge.targetNodeID } : branch + }) + return { + ...node, + data: { + ...(node.data ?? {}), + config: { ...config, branches: nextBranches }, + portKeys: nextBranches.map((branch) => branch.id), + ports: nextBranches.map((branch) => branch.id), + }, + } + }), + } +} + export function normalizeNodeConfig(config: unknown): WorkflowNodeConfig { if (!config || typeof config !== "object" || Array.isArray(config)) { return {} @@ -263,6 +328,31 @@ export function normalizeNodeConfig(config: unknown): WorkflowNodeConfig { } as WorkflowNodeConfig } +function ensureConditionBranches(branches: WorkflowConditionBranch[]) { + if (branches.some((branch) => branch.default)) { + return orderConditionBranches(branches) + } + return orderConditionBranches([ + ...branches, + { id: "default", name: "默认分支", targetNodeId: "", default: true }, + ]) +} + +function orderConditionBranches(branches: WorkflowConditionBranch[]) { + return [ + ...branches.filter((branch) => !branch.default), + ...branches.filter((branch) => branch.default).slice(0, 1), + ] +} + +function findConditionBranchForTarget( + node: AIWorkflowDefinition["nodes"][number], + targetNodeId: string +) { + const branches = ensureConditionBranches(normalizeNodeConfig(node.data?.config).branches ?? []) + return branches.find((branch) => branch.targetNodeId === targetNodeId) +} + export function createConditionBranchID(existingBranches: WorkflowConditionBranch[]) { const existingIDs = new Set(existingBranches.map((branch) => branch.id)) for (let index = 1; index < 10000; index++) {