Просмотр исходного кода

refactor(core): workflow group json (#535)

* feat(core): workflow json add groups field

* fix(core): test errors

* feat(core): workflow group save inside nodes json

* chore(demo): update initial data
Louis Young 5 месяцев назад
Родитель
Сommit
0cdb9bb59c

+ 148 - 158
apps/demo-free-layout/src/initial-data.ts

@@ -379,178 +379,164 @@ export const initialData: FlowDocumentJSON = {
           y: 730.2,
         },
       },
-      data: {},
-      blocks: [
-        {
-          id: 'llm_8--A3',
-          type: 'llm',
-          meta: {
-            position: {
-              x: 180,
-              y: 0,
-            },
+      data: {
+        parentID: 'root',
+        blockIDs: ['llm_8--A3', 'llm_vTyMa'],
+      },
+    },
+    {
+      id: 'llm_8--A3',
+      type: 'llm',
+      meta: {
+        position: {
+          x: 180,
+          y: 0,
+        },
+      },
+      data: {
+        title: 'LLM_1',
+        inputsValues: {
+          modelName: {
+            type: 'constant',
+            content: 'gpt-3.5-turbo',
           },
-          data: {
-            title: 'LLM_1',
-            inputsValues: {
-              modelName: {
-                type: 'constant',
-                content: 'gpt-3.5-turbo',
-              },
-              apiKey: {
-                type: 'constant',
-                content: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
-              },
-              apiHost: {
-                type: 'constant',
-                content: 'https://mock-ai-url/api/v3',
-              },
-              temperature: {
-                type: 'constant',
-                content: 0.5,
-              },
-              systemPrompt: {
-                type: 'template',
-                content: '# Role\nYou are an AI assistant.\n',
-              },
-              prompt: {
-                type: 'template',
-                content: '# User Input\nquery:{{start_0.query}}\nenable:{{start_0.enable}}',
-              },
+          apiKey: {
+            type: 'constant',
+            content: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+          },
+          apiHost: {
+            type: 'constant',
+            content: 'https://mock-ai-url/api/v3',
+          },
+          temperature: {
+            type: 'constant',
+            content: 0.5,
+          },
+          systemPrompt: {
+            type: 'constant',
+            content: '# Role\nYou are an AI assistant.\n',
+          },
+          prompt: {
+            type: 'constant',
+            content: '# User Input\nquery:{{start_0.query}}\nenable:{{start_0.enable}}',
+          },
+        },
+        inputs: {
+          type: 'object',
+          required: ['modelName', 'apiKey', 'apiHost', 'temperature', 'prompt'],
+          properties: {
+            modelName: {
+              type: 'string',
             },
-            inputs: {
-              type: 'object',
-              required: ['modelName', 'apiKey', 'apiHost', 'temperature', 'prompt'],
-              properties: {
-                modelName: {
-                  type: 'string',
-                },
-                apiKey: {
-                  type: 'string',
-                },
-                apiHost: {
-                  type: 'string',
-                },
-                temperature: {
-                  type: 'number',
-                },
-                systemPrompt: {
-                  type: 'string',
-                  extra: {
-                    formComponent: 'prompt-editor',
-                  },
-                },
-                prompt: {
-                  type: 'string',
-                  extra: {
-                    formComponent: 'prompt-editor',
-                  },
-                },
+            apiKey: {
+              type: 'string',
+            },
+            apiHost: {
+              type: 'string',
+            },
+            temperature: {
+              type: 'number',
+            },
+            systemPrompt: {
+              type: 'string',
+              extra: {
+                formComponent: 'prompt-editor',
               },
             },
-            outputs: {
-              type: 'object',
-              properties: {
-                result: {
-                  type: 'string',
-                },
+            prompt: {
+              type: 'string',
+              extra: {
+                formComponent: 'prompt-editor',
               },
             },
           },
         },
-        {
-          id: 'llm_vTyMa',
-          type: 'llm',
-          meta: {
-            position: {
-              x: 640,
-              y: 10,
+        outputs: {
+          type: 'object',
+          properties: {
+            result: {
+              type: 'string',
             },
           },
-          data: {
-            title: 'LLM_2',
-            inputsValues: {
-              modelName: {
-                type: 'constant',
-                content: 'gpt-3.5-turbo',
-              },
-              apiKey: {
-                type: 'constant',
-                content: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
-              },
-              apiHost: {
-                type: 'constant',
-                content: 'https://mock-ai-url/api/v3',
-              },
-              temperature: {
-                type: 'constant',
-                content: 0.5,
-              },
-              systemPrompt: {
-                type: 'template',
-                content: '# Role\nYou are an AI assistant.\n',
-              },
-              prompt: {
-                type: 'template',
-                content: '# LLM Input\nresult:{{llm_8--A3.result}}',
-              },
+        },
+      },
+    },
+    {
+      id: 'llm_vTyMa',
+      type: 'llm',
+      meta: {
+        position: {
+          x: 640,
+          y: 10,
+        },
+      },
+      data: {
+        title: 'LLM_2',
+        inputsValues: {
+          modelName: {
+            type: 'constant',
+            content: 'gpt-3.5-turbo',
+          },
+          apiKey: {
+            type: 'constant',
+            content: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
+          },
+          apiHost: {
+            type: 'constant',
+            content: 'https://mock-ai-url/api/v3',
+          },
+          temperature: {
+            type: 'constant',
+            content: 0.5,
+          },
+          systemPrompt: {
+            type: 'constant',
+            content: '# Role\nYou are an AI assistant.\n',
+          },
+          prompt: {
+            type: 'constant',
+            content: '# LLM Input\nresult:{{llm_8--A3.result}}',
+          },
+        },
+        inputs: {
+          type: 'object',
+          required: ['modelName', 'apiKey', 'apiHost', 'temperature', 'prompt'],
+          properties: {
+            modelName: {
+              type: 'string',
             },
-            inputs: {
-              type: 'object',
-              required: ['modelName', 'apiKey', 'apiHost', 'temperature', 'prompt'],
-              properties: {
-                modelName: {
-                  type: 'string',
-                },
-                apiKey: {
-                  type: 'string',
-                },
-                apiHost: {
-                  type: 'string',
-                },
-                temperature: {
-                  type: 'number',
-                },
-                systemPrompt: {
-                  type: 'string',
-                  extra: {
-                    formComponent: 'prompt-editor',
-                  },
-                },
-                prompt: {
-                  type: 'string',
-                  extra: {
-                    formComponent: 'prompt-editor',
-                  },
-                },
+            apiKey: {
+              type: 'string',
+            },
+            apiHost: {
+              type: 'string',
+            },
+            temperature: {
+              type: 'number',
+            },
+            systemPrompt: {
+              type: 'string',
+              extra: {
+                formComponent: 'prompt-editor',
               },
             },
-            outputs: {
-              type: 'object',
-              properties: {
-                result: {
-                  type: 'string',
-                },
+            prompt: {
+              type: 'string',
+              extra: {
+                formComponent: 'prompt-editor',
               },
             },
           },
         },
-      ],
-      edges: [
-        {
-          sourceNodeID: 'condition_0',
-          targetNodeID: 'llm_8--A3',
-          sourcePortID: 'if_f0rOAt',
-        },
-        {
-          sourceNodeID: 'llm_8--A3',
-          targetNodeID: 'llm_vTyMa',
-        },
-        {
-          sourceNodeID: 'llm_vTyMa',
-          targetNodeID: 'end_0',
+        outputs: {
+          type: 'object',
+          properties: {
+            result: {
+              type: 'string',
+            },
+          },
         },
-      ],
+      },
     },
   ],
   edges: [
@@ -564,13 +550,13 @@ export const initialData: FlowDocumentJSON = {
     },
     {
       sourceNodeID: 'condition_0',
-      targetNodeID: 'loop_Ycnsk',
-      sourcePortID: 'if_0',
+      targetNodeID: 'llm_8--A3',
+      sourcePortID: 'if_f0rOAt',
     },
     {
       sourceNodeID: 'condition_0',
-      targetNodeID: 'llm_8--A3',
-      sourcePortID: 'if_f0rOAt',
+      targetNodeID: 'loop_Ycnsk',
+      sourcePortID: 'if_0',
     },
     {
       sourceNodeID: 'llm_vTyMa',
@@ -580,5 +566,9 @@ export const initialData: FlowDocumentJSON = {
       sourceNodeID: 'loop_Ycnsk',
       targetNodeID: 'end_0',
     },
+    {
+      sourceNodeID: 'llm_8--A3',
+      targetNodeID: 'llm_vTyMa',
+    },
   ],
 };

+ 47 - 0
packages/canvas-engine/free-layout-core/src/utils/build-group-json.ts

@@ -0,0 +1,47 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { FlowNodeBaseType } from '@flowgram.ai/document';
+
+import { WorkflowJSON, WorkflowNodeJSON } from '../typings';
+
+interface WorkflowGroupJSON extends WorkflowNodeJSON {
+  data: {
+    parentID?: string;
+    blockIDs?: string[];
+  };
+}
+
+export const buildGroupJSON = (json: WorkflowJSON): WorkflowJSON => {
+  const { nodes, edges } = json;
+  const groupJSONs = nodes.filter(
+    (nodeJSON) => nodeJSON.type === FlowNodeBaseType.GROUP
+  ) as WorkflowGroupJSON[];
+
+  const nodeJSONMap = new Map<string, WorkflowNodeJSON>(nodes.map((n) => [n.id, n]));
+  const groupNodeJSONs = groupJSONs.map((groupJSON): WorkflowNodeJSON => {
+    const groupBlocks = (groupJSON.data.blockIDs ?? [])
+      .map((blockID) => nodeJSONMap.get(blockID))
+      .filter(Boolean) as WorkflowNodeJSON[];
+    const groupEdges = edges?.filter((edge) =>
+      groupBlocks.some((block) => block.id === edge.sourceNodeID || block.id === edge.targetNodeID)
+    );
+    const groupNodeJSON: WorkflowNodeJSON = {
+      ...groupJSON,
+      blocks: groupBlocks,
+      edges: groupEdges,
+    };
+    return groupNodeJSON;
+  });
+
+  const groupBlockSet = new Set(groupJSONs.map((groupJSON) => groupJSON.data.blockIDs).flat());
+  const processedNodes = nodes
+    .filter((nodeJSON) => !groupBlockSet.has(nodeJSON.id))
+    .concat(groupNodeJSONs);
+  return {
+    nodes: processedNodes,
+    edges,
+  };
+};

+ 1 - 0
packages/canvas-engine/free-layout-core/src/utils/index.ts

@@ -20,6 +20,7 @@ export { delay } from '@flowgram.ai/utils';
  */
 export { bindConfigEntity };
 
+export { buildGroupJSON } from './build-group-json';
 export * from './nanoid';
 export * from './compose';
 export * from './fit-view';

+ 20 - 10
packages/canvas-engine/free-layout-core/src/workflow-document.ts

@@ -27,7 +27,7 @@ import {
   WorkflowDocumentOptionsDefault,
 } from './workflow-document-option';
 import { getFlowNodeFormData } from './utils/flow-node-form-data';
-import { delay, fitView, getAntiOverlapPosition } from './utils';
+import { buildGroupJSON, delay, fitView, getAntiOverlapPosition } from './utils';
 import {
   type WorkflowContentChangeEvent,
   WorkflowContentChangeType,
@@ -646,10 +646,11 @@ export class WorkflowDocument extends FlowDocument {
    */
   toJSON(): WorkflowJSON {
     const rootJSON = this.toNodeJSON(this.root);
-    return {
+    const json = {
       nodes: rootJSON.blocks ?? [],
       edges: rootJSON.edges ?? [],
     };
+    return json;
   }
 
   dispose() {
@@ -673,11 +674,12 @@ export class WorkflowDocument extends FlowDocument {
     const { parent = this.root, isClone = false } = options ?? {};
     // 创建节点
     const containerID = this.getNodeSubCanvas(parent)?.canvasNode.id ?? parent.id;
-    const nodes = json.nodes.map((nodeJSON: WorkflowNodeJSON) =>
+    const processedJSON = buildGroupJSON(json);
+    const nodes = processedJSON.nodes.map((nodeJSON: WorkflowNodeJSON) =>
       this.createWorkflowNode(nodeJSON, isClone, containerID)
     );
     // 创建线条
-    const edges = json.edges
+    const edges = processedJSON.edges
       .map((edge) => this.createWorkflowLine(edge, containerID))
       .filter(Boolean) as WorkflowLineEntity[];
     return { nodes, edges };
@@ -691,18 +693,26 @@ export class WorkflowDocument extends FlowDocument {
   }
 
   private getNodeChildren(node: WorkflowNodeEntity): WorkflowNodeEntity[] {
-    if (!node) return [];
+    if (!node || node.flowNodeType === FlowNodeBaseType.GROUP) return [];
     const subCanvas = this.getNodeSubCanvas(node);
-    const childrenWithCanvas = subCanvas
-      ? subCanvas.canvasNode.collapsedChildren
-      : node.collapsedChildren;
-    // 过滤掉子画布的JSON数据
-    const children = childrenWithCanvas
+    // get real children
+    const realChildren = subCanvas ? subCanvas.canvasNode.blocks : node.blocks;
+    // filter sub canvas node
+    const childrenWithoutSubCanvas = realChildren
       .filter((child) => {
         const childMeta = child.getNodeMeta<WorkflowNodeMeta>();
         return !childMeta.subCanvas?.(node)?.isCanvas;
       })
       .filter(Boolean);
+    // flat group nodes
+    const children = childrenWithoutSubCanvas
+      .map((child) => {
+        if (child.flowNodeType === FlowNodeBaseType.GROUP) {
+          return [child, ...child.blocks];
+        }
+        return child;
+      })
+      .flat();
     return children;
   }
 

+ 74 - 96
packages/client/free-layout-editor/__mocks__/flow.mocks.ts

@@ -122,50 +122,35 @@ export const mockJSON: WorkflowJSON = {
       data: {
         title: 'LLM_Group',
         color: 'Violet',
+        parentID: 'root',
+        blockIDs: ['llm_0', 'llm_l_TcE'],
       },
-      blocks: [
-        {
-          id: 'llm_0',
-          type: 'llm',
-          meta: {
-            position: {
-              x: 640,
-              y: 0,
-            },
-          },
-          data: {
-            title: 'LLM_0',
-          },
-        },
-        {
-          id: 'llm_l_TcE',
-          type: 'llm',
-          meta: {
-            position: {
-              x: 180,
-              y: 0,
-            },
-          },
-          data: {
-            title: 'LLM_1',
-          },
-        },
-      ],
-      edges: [
-        {
-          sourceNodeID: 'llm_l_TcE',
-          targetNodeID: 'llm_0',
-        },
-        {
-          sourceNodeID: 'llm_0',
-          targetNodeID: 'end_0',
+    },
+    {
+      id: 'llm_0',
+      type: 'llm',
+      meta: {
+        position: {
+          x: 640,
+          y: 0,
         },
-        {
-          sourceNodeID: 'condition_0',
-          targetNodeID: 'llm_l_TcE',
-          sourcePortID: 'if_0',
+      },
+      data: {
+        title: 'LLM_0',
+      },
+    },
+    {
+      id: 'llm_l_TcE',
+      type: 'llm',
+      meta: {
+        position: {
+          x: 180,
+          y: 0,
         },
-      ],
+      },
+      data: {
+        title: 'LLM_1',
+      },
     },
   ],
   edges: [
@@ -191,6 +176,10 @@ export const mockJSON: WorkflowJSON = {
       sourceNodeID: 'loop_H8M3U',
       targetNodeID: 'end_0',
     },
+    {
+      sourceNodeID: 'llm_l_TcE',
+      targetNodeID: 'llm_0',
+    },
   ],
 };
 
@@ -214,8 +203,8 @@ export const mockJSON2: WorkflowJSON = {
       type: 'condition',
       meta: {
         position: {
-          x: 0,
-          y: 0,
+          x: 235.74542284219706,
+          y: -157.7680906713165,
         },
       },
       data: {
@@ -227,8 +216,8 @@ export const mockJSON2: WorkflowJSON = {
       type: 'end',
       meta: {
         position: {
-          x: 0,
-          y: 0,
+          x: 310.0959023539669,
+          y: 190.25,
         },
       },
       data: {
@@ -266,8 +255,8 @@ export const mockJSON2: WorkflowJSON = {
           type: 'llm changed',
           meta: {
             position: {
-              x: 6,
-              y: 0,
+              x: 9.626852659110725,
+              y: 121.49956408020925,
             },
           },
           data: {
@@ -287,8 +276,8 @@ export const mockJSON2: WorkflowJSON = {
       type: 'comment',
       meta: {
         position: {
-          x: 640,
-          y: 522.46875,
+          x: 300,
+          y: 486.2002234088928,
         },
       },
       data: {
@@ -304,57 +293,42 @@ export const mockJSON2: WorkflowJSON = {
       type: 'group',
       meta: {
         position: {
-          x: 1020,
-          y: 96.25,
+          x: 869.4856146469051,
+          y: 56.4254577157803,
         },
       },
       data: {
         title: 'LLM_Group changed',
         color: 'Violet',
+        parentID: 'root',
+        blockIDs: ['llm_0', 'llm_l_TcE'],
       },
-      blocks: [
-        {
-          id: 'llm_0',
-          type: 'llm',
-          meta: {
-            position: {
-              x: 640,
-              y: 0,
-            },
-          },
-          data: {
-            title: 'LLM_0 changed',
-          },
-        },
-        {
-          id: 'llm_l_TcE',
-          type: 'llm',
-          meta: {
-            position: {
-              x: 180,
-              y: 0,
-            },
-          },
-          data: {
-            title: 'LLM_1',
-          },
-        },
-      ],
-      edges: [
-        {
-          sourceNodeID: 'llm_l_TcE',
-          targetNodeID: 'llm_0',
-        },
-        {
-          sourceNodeID: 'llm_0',
-          targetNodeID: 'end_0',
+    },
+    {
+      id: 'llm_0',
+      type: 'llm',
+      meta: {
+        position: {
+          x: 640,
+          y: 0,
         },
-        {
-          sourceNodeID: 'condition_0',
-          targetNodeID: 'llm_l_TcE',
-          sourcePortID: 'if_0',
+      },
+      data: {
+        title: 'LLM_0 changed',
+      },
+    },
+    {
+      id: 'llm_l_TcE',
+      type: 'llm',
+      meta: {
+        position: {
+          x: 180,
+          y: 0,
         },
-      ],
+      },
+      data: {
+        title: 'LLM_1',
+      },
     },
   ],
   edges: [
@@ -380,6 +354,10 @@ export const mockJSON2: WorkflowJSON = {
       sourceNodeID: 'loop_H8M3U',
       targetNodeID: 'end_0',
     },
+    {
+      sourceNodeID: 'llm_l_TcE',
+      targetNodeID: 'llm_0',
+    },
   ],
 };
 export const mockSimpleJSON: WorkflowJSON = {
@@ -391,7 +369,7 @@ export const mockSimpleJSON: WorkflowJSON = {
         position: { x: 0, y: 0 },
       },
       data: {
-        title: 'start'
+        title: 'start',
       },
     },
     {
@@ -401,7 +379,7 @@ export const mockSimpleJSON: WorkflowJSON = {
         position: { x: 800, y: 0 },
       },
       data: {
-        title: 'end'
+        title: 'end',
       },
     },
   ],
@@ -422,7 +400,7 @@ export const mockSimpleJSON2: WorkflowJSON = {
         position: { x: 1, y: 1 },
       },
       data: {
-        title: 'start changed'
+        title: 'start changed',
       },
     },
     {
@@ -432,7 +410,7 @@ export const mockSimpleJSON2: WorkflowJSON = {
         position: { x: 801, y: 1 },
       },
       data: {
-        title: 'end changed'
+        title: 'end changed',
       },
     },
   ],

+ 8 - 6
packages/client/free-layout-editor/__tests__/free-layout-preset.test.ts

@@ -3,6 +3,8 @@
  * SPDX-License-Identifier: MIT
  */
 
+import React from 'react';
+
 import { describe, it, expect } from 'vitest';
 import { FlowDocument, FlowNodeFormData } from '@flowgram.ai/editor';
 
@@ -28,7 +30,7 @@ describe('free-layout-preset', () => {
         return json;
       },
       toNodeJSON(node, json) {
-        json.data.runningTimes = (json.data.runningTimes || 0) + 1;
+        json.data!.runningTimes = (json.data!.runningTimes || 0) + 1;
         return json;
       },
     });
@@ -44,13 +46,13 @@ describe('free-layout-preset', () => {
         {
           type: 'start',
           formMeta: {
-            render: () => undefined,
+            render: () => React.createElement('div', { className: 'start-node' }),
           },
         },
         {
           type: 'end',
           formMeta: {
-            render: () => undefined,
+            render: () => React.createElement('div', { className: 'end-node' }),
           },
         },
       ],
@@ -60,9 +62,9 @@ describe('free-layout-preset', () => {
     expect(flowDocument.toJSON()).toEqual(mockSimpleJSON);
     flowDocument.fromJSON(mockSimpleJSON2);
     expect(flowDocument.toJSON()).toEqual(mockSimpleJSON2);
-    const { formModel } = flowDocument.getNode('start_0').getData(FlowNodeFormData);
-    expect(formModel.getFormItemByPath('title').value).toEqual('start changed');
-    formModel.getFormItemByPath('title').value = 'start changed 2';
+    const { formModel } = flowDocument.getNode('start_0')!.getData(FlowNodeFormData);
+    expect(formModel.getFormItemByPath('title')!.value).toEqual('start changed');
+    formModel.getFormItemByPath('title')!.value = 'start changed 2';
     expect(formModel.toJSON()).toEqual({
       title: 'start changed 2',
     });

+ 16 - 1
packages/client/free-layout-editor/src/preset/node-serialize.ts

@@ -8,7 +8,12 @@ import {
   WorkflowDocument,
   WorkflowDocumentOptionsDefault,
 } from '@flowgram.ai/free-layout-core';
-import { FlowNodeEntity, FlowNodeFormData, type FlowNodeJSON } from '@flowgram.ai/editor';
+import {
+  FlowNodeBaseType,
+  FlowNodeEntity,
+  FlowNodeFormData,
+  type FlowNodeJSON,
+} from '@flowgram.ai/editor';
 
 import { FreeLayoutProps } from './free-layout-props';
 
@@ -58,5 +63,15 @@ export function toNodeJSON(opts: FreeLayoutProps, node: FlowNodeEntity): FlowNod
   } else {
     json = WorkflowDocumentOptionsDefault.toNodeJSON!(node);
   }
+  // 处理分组节点
+  if (node.flowNodeType === FlowNodeBaseType.GROUP) {
+    const parentID = node.parent?.id ?? FlowNodeBaseType.ROOT;
+    const blockIDs = node.blocks.map((block) => block.id) ?? [];
+    json.data = {
+      ...json.data,
+      parentID,
+      blockIDs,
+    };
+  }
   return opts.toNodeJSON ? opts.toNodeJSON(node, json) : json;
 }

+ 15 - 0
packages/runtime/interface/src/schema/group.ts

@@ -0,0 +1,15 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { WorkflowNodeSchema } from './node';
+
+export interface WorkflowGroupSchema extends WorkflowNodeSchema {
+  data: {
+    title?: string;
+    color?: string;
+    parentID: string;
+    blockIDs: string[];
+  };
+}

+ 2 - 0
packages/runtime/interface/src/schema/workflow.ts

@@ -4,9 +4,11 @@
  */
 
 import type { WorkflowNodeSchema } from './node';
+import { WorkflowGroupSchema } from './group';
 import type { WorkflowEdgeSchema } from './edge';
 
 export interface WorkflowSchema {
   nodes: WorkflowNodeSchema[];
   edges: WorkflowEdgeSchema[];
+  groups?: WorkflowGroupSchema[];
 }