Преглед изворни кода

feat(variable): batch outputs (#426)

* feat: get by key path in flow-node-variable-data

* feat: scope chain transform service

* feat: form outputs plugin

* feat: base variable field

* feat: move set var get var to scope

* feat: batch outputs

* feat: form plugin create effect by func

* feat: batch output key configuration

* feat: merge effet api in form plugin

* feat: form plugin

* fix: variable layout test

* feat: simplify defineFormPluginCreator
Yiwei Mao пре 6 месеци
родитељ
комит
93aa3e77b1
50 измењених фајлова са 971 додато и 204 уклоњено
  1. 3 1
      apps/demo-free-layout/src/form-components/form-inputs/index.tsx
  2. 8 2
      apps/demo-free-layout/src/form-components/form-item/index.tsx
  3. 3 1
      apps/demo-free-layout/src/form-components/properties-edit/property-edit.tsx
  4. 14 0
      apps/demo-free-layout/src/initial-data.ts
  5. 2 1
      apps/demo-free-layout/src/nodes/loop/index.ts
  6. 19 1
      apps/demo-free-layout/src/nodes/loop/loop-form-render.tsx
  7. 1 1
      packages/materials/form-materials/bin/materials.ts
  8. 12 0
      packages/materials/form-materials/src/components/batch-outputs/config.json
  9. 56 0
      packages/materials/form-materials/src/components/batch-outputs/index.tsx
  10. 14 0
      packages/materials/form-materials/src/components/batch-outputs/styles.tsx
  11. 17 0
      packages/materials/form-materials/src/components/batch-outputs/types.ts
  12. 81 0
      packages/materials/form-materials/src/components/batch-outputs/use-list.ts
  13. 1 0
      packages/materials/form-materials/src/components/dynamic-value-input/index.tsx
  14. 1 0
      packages/materials/form-materials/src/components/index.ts
  15. 0 1
      packages/materials/form-materials/src/effects/provide-batch-outputs/index.ts
  16. 7 0
      packages/materials/form-materials/src/form-plugins/batch-outputs-plugin/config.json
  17. 99 0
      packages/materials/form-materials/src/form-plugins/batch-outputs-plugin/index.ts
  18. 1 0
      packages/materials/form-materials/src/form-plugins/index.ts
  19. 1 0
      packages/materials/form-materials/src/index.ts
  20. 2 2
      packages/node-engine/form-core/src/form/models/form-model.ts
  21. 3 3
      packages/node-engine/form-core/src/form/types/form-meta.types.ts
  22. 36 27
      packages/node-engine/node/__tests__/form-model-v2.test.ts
  23. 13 11
      packages/node-engine/node/src/form-model-v2.ts
  24. 65 11
      packages/node-engine/node/src/form-plugin.ts
  25. 7 0
      packages/node-engine/node/src/types.ts
  26. 3 5
      packages/plugins/node-variable-plugin/src/form-v2/create-provider-effect.ts
  27. 13 10
      packages/plugins/node-variable-plugin/src/form-v2/create-variable-provider-plugin.ts
  28. 2 4
      packages/plugins/node-variable-plugin/src/types.ts
  29. 10 1
      packages/plugins/variable-plugin/src/index.ts
  30. 13 13
      packages/variable-engine/variable-core/src/ast/ast-node.ts
  31. 9 1
      packages/variable-engine/variable-core/src/ast/declaration/base-variable-field.ts
  32. 4 0
      packages/variable-engine/variable-core/src/ast/expression/wrap-array-expression.ts
  33. 28 2
      packages/variable-engine/variable-core/src/scope/datas/scope-available-data.ts
  34. 41 1
      packages/variable-engine/variable-core/src/scope/scope.ts
  35. 5 1
      packages/variable-engine/variable-layout/__mocks__/container.ts
  36. 2 0
      packages/variable-engine/variable-layout/__mocks__/run-fixed-layout-test.ts
  37. 2 0
      packages/variable-engine/variable-layout/__mocks__/run-free-layout-test.ts
  38. 109 0
      packages/variable-engine/variable-layout/__tests__/__snapshots__/variable-fix-layout-transform-empty.test.ts.snap
  39. 108 0
      packages/variable-engine/variable-layout/__tests__/__snapshots__/variable-free-layout-transform-empty.test.ts.snap
  40. 34 0
      packages/variable-engine/variable-layout/__tests__/variable-fix-layout-transform-empty.test.ts
  41. 10 1
      packages/variable-engine/variable-layout/__tests__/variable-free-layout-transform-empty.test.ts
  42. 3 1
      packages/variable-engine/variable-layout/src/chains/fixed-layout-scope-chain.ts
  43. 16 14
      packages/variable-engine/variable-layout/src/chains/free-layout-scope-chain.ts
  44. 19 1
      packages/variable-engine/variable-layout/src/flow-node-variable-data.ts
  45. 1 0
      packages/variable-engine/variable-layout/src/index.ts
  46. 2 51
      packages/variable-engine/variable-layout/src/scopes/global-scope.ts
  47. 55 30
      packages/variable-engine/variable-layout/src/services/scope-chain-transform-service.ts
  48. 1 1
      packages/variable-engine/variable-layout/src/types.ts
  49. 11 0
      packages/variable-engine/variable-layout/src/utils.ts
  50. 4 5
      packages/variable-engine/variable-layout/src/variable-layout-config.ts

+ 3 - 1
apps/demo-free-layout/src/form-components/form-inputs/index.tsx

@@ -31,7 +31,9 @@ export function FormInputs() {
                     onChange={field.onChange}
                     readonly={readonly}
                     hasError={Object.keys(fieldState?.errors || {}).length > 0}
-                    schema={property}
+                    constantProps={{
+                      schema: property,
+                    }}
                   />
                   <Feedback errors={fieldState?.errors} warnings={fieldState?.warnings} />
                 </FormItem>

+ 8 - 2
apps/demo-free-layout/src/form-components/form-item/index.tsx

@@ -14,6 +14,7 @@ interface FormItemProps {
   required?: boolean;
   description?: string;
   labelWidth?: number;
+  vertical?: boolean;
 }
 export function FormItem({
   children,
@@ -22,6 +23,7 @@ export function FormItem({
   description,
   type,
   labelWidth,
+  vertical,
 }: FormItemProps): JSX.Element {
   const renderTitle = useCallback(
     (showTooltip?: boolean) => (
@@ -42,9 +44,13 @@ export function FormItem({
         width: '100%',
         position: 'relative',
         display: 'flex',
-        justifyContent: 'center',
-        alignItems: 'center',
         gap: 8,
+        ...(vertical
+          ? { flexDirection: 'column' }
+          : {
+              justifyContent: 'center',
+              alignItems: 'center',
+            }),
       }}
     >
       <div

+ 3 - 1
apps/demo-free-layout/src/form-components/properties-edit/property-edit.tsx

@@ -59,7 +59,9 @@ export const PropertyEdit: React.FC<PropertyEditProps> = (props) => {
         <DynamicValueInput
           value={value.default}
           onChange={(val) => updateProperty('default', val)}
-          schema={value}
+          constantProps={{
+            schema: value,
+          }}
           style={{ flexGrow: 1 }}
         />
       }

+ 14 - 0
apps/demo-free-layout/src/initial-data.ts

@@ -130,6 +130,20 @@ export const initialData: FlowDocumentJSON = {
       },
       data: {
         title: 'Loop_1',
+        batchFor: {
+          type: 'ref',
+          content: ['start_0', 'array_obj'],
+        },
+        batchOutputs: {
+          results: {
+            type: 'ref',
+            content: ['llm_6aSyo', 'result'],
+          },
+          indexList: {
+            type: 'ref',
+            content: ['loop_sGybT_locals', 'index'],
+          },
+        },
       },
       blocks: [
         {

+ 2 - 1
apps/demo-free-layout/src/nodes/loop/index.ts

@@ -4,7 +4,7 @@ import {
   PositionSchema,
   FlowNodeTransformData,
 } from '@flowgram.ai/free-layout-editor';
-import { provideBatchInputEffect } from '@flowgram.ai/form-materials';
+import { createBatchOutputsFormPlugin, provideBatchInputEffect } from '@flowgram.ai/form-materials';
 
 import { defaultFormMeta } from '../default-form-meta';
 import { FlowNodeRegistry } from '../../typings';
@@ -73,5 +73,6 @@ export const LoopNodeRegistry: FlowNodeRegistry = {
     effect: {
       batchFor: provideBatchInputEffect,
     },
+    plugins: [createBatchOutputsFormPlugin({ outputKey: 'batchOutputs' })],
   },
 };

+ 19 - 1
apps/demo-free-layout/src/nodes/loop/loop-form-render.tsx

@@ -1,6 +1,6 @@
 import { FormRenderProps, FlowNodeJSON, Field } from '@flowgram.ai/free-layout-editor';
 import { SubCanvasRender } from '@flowgram.ai/free-container-plugin';
-import { BatchVariableSelector, IFlowRefValue } from '@flowgram.ai/form-materials';
+import { BatchOutputs, BatchVariableSelector, IFlowRefValue } from '@flowgram.ai/form-materials';
 
 import { useIsSidebar, useNodeRenderContext } from '../../hooks';
 import { FormHeader, FormContent, FormOutputs, FormItem, Feedback } from '../../form-components';
@@ -33,12 +33,30 @@ export const LoopFormRender = ({ form }: FormRenderProps<LoopNodeJSON>) => {
     </Field>
   );
 
+  const batchOutputs = (
+    <Field<Record<string, IFlowRefValue | undefined> | undefined> name={`batchOutputs`}>
+      {({ field, fieldState }) => (
+        <FormItem name="batchOutputs" type="object" vertical>
+          <BatchOutputs
+            style={{ width: '100%' }}
+            value={field.value}
+            onChange={(val) => field.onChange(val)}
+            readonly={readonly}
+            hasError={Object.keys(fieldState?.errors || {}).length > 0}
+          />
+          <Feedback errors={fieldState?.errors} />
+        </FormItem>
+      )}
+    </Field>
+  );
+
   if (isSidebar) {
     return (
       <>
         <FormHeader />
         <FormContent>
           {batchFor}
+          {batchOutputs}
           <FormOutputs />
         </FormContent>
       </>

+ 1 - 1
packages/materials/form-materials/bin/materials.ts

@@ -17,7 +17,7 @@ export interface Material {
   [key: string]: any; // For other properties from config.json
 }
 
-const _types: string[] = ['components', 'effects', 'utils', 'typings'];
+const _types: string[] = ['components', 'effects', 'utils', 'typings', 'form-plugins'];
 
 export function listAllMaterials(): Material[] {
   const _materials: Material[] = [];

+ 12 - 0
packages/materials/form-materials/src/components/batch-outputs/config.json

@@ -0,0 +1,12 @@
+{
+  "name": "batch-outputs",
+  "depMaterials": [
+    "flow-value",
+    "variable-selector"
+  ],
+  "depPackages": [
+    "@douyinfe/semi-ui",
+    "@douyinfe/semi-icons",
+    "styled-components"
+  ]
+}

+ 56 - 0
packages/materials/form-materials/src/components/batch-outputs/index.tsx

@@ -0,0 +1,56 @@
+import React from 'react';
+
+import { Button, Input } from '@douyinfe/semi-ui';
+import { IconDelete, IconPlus } from '@douyinfe/semi-icons';
+
+import { useList } from './use-list';
+import { PropsType } from './types';
+import { VariableSelector } from '../variable-selector';
+import { UIRow, UIRows } from './styles';
+
+export function BatchOutputs(props: PropsType) {
+  const { readonly, style } = props;
+
+  const { list, add, update, remove } = useList(props);
+
+  return (
+    <div>
+      <UIRows style={style}>
+        {list.map((item) => (
+          <UIRow key={item.id}>
+            <Input
+              style={{ width: 100 }}
+              disabled={readonly}
+              size="small"
+              value={item.key}
+              onChange={(v) => update({ ...item, key: v })}
+            />
+            <VariableSelector
+              style={{ flexGrow: 1 }}
+              readonly={readonly}
+              value={item.value?.content}
+              onChange={(v) =>
+                update({
+                  ...item,
+                  value: {
+                    type: 'ref',
+                    content: v,
+                  },
+                })
+              }
+            />
+            <Button
+              disabled={readonly}
+              icon={<IconDelete />}
+              size="small"
+              onClick={() => remove(item.id)}
+            />
+          </UIRow>
+        ))}
+      </UIRows>
+      <Button disabled={readonly} icon={<IconPlus />} size="small" onClick={add}>
+        Add
+      </Button>
+    </div>
+  );
+}

+ 14 - 0
packages/materials/form-materials/src/components/batch-outputs/styles.tsx

@@ -0,0 +1,14 @@
+import styled from 'styled-components';
+
+export const UIRows = styled.div`
+  display: flex;
+  flex-direction: column;
+  gap: 10px;
+  margin-bottom: 10px;
+`;
+
+export const UIRow = styled.div`
+  display: flex;
+  align-items: center;
+  gap: 5px;
+`;

+ 17 - 0
packages/materials/form-materials/src/components/batch-outputs/types.ts

@@ -0,0 +1,17 @@
+import { IFlowRefValue } from '../../typings';
+
+export type ValueType = Record<string, IFlowRefValue | undefined>;
+
+export interface OutputItem {
+  id: number;
+  key?: string;
+  value?: IFlowRefValue;
+}
+
+export interface PropsType {
+  value?: ValueType;
+  onChange: (value?: ValueType) => void;
+  readonly?: boolean;
+  hasError?: boolean;
+  style?: React.CSSProperties;
+}

+ 81 - 0
packages/materials/form-materials/src/components/batch-outputs/use-list.ts

@@ -0,0 +1,81 @@
+import { useEffect, useState } from 'react';
+
+import { difference } from 'lodash';
+
+import { OutputItem, PropsType } from './types';
+
+let _id = 0;
+function genId() {
+  return _id++;
+}
+
+export function useList({ value, onChange }: PropsType) {
+  const [list, setList] = useState<OutputItem[]>([]);
+
+  useEffect(() => {
+    setList((_prevList) => {
+      const newKeys = Object.keys(value || {});
+      const oldKeys = _prevList.map((item) => item.key).filter(Boolean) as string[];
+      const addKeys = difference(newKeys, oldKeys);
+
+      return _prevList
+        .filter((item) => !item.key || newKeys.includes(item.key))
+        .map((item) => ({
+          id: item.id,
+          key: item.key,
+          value: item.key ? value?.[item.key!] : undefined,
+        }))
+        .concat(
+          addKeys.map((_key) => ({
+            id: genId(),
+            key: _key,
+            value: value?.[_key],
+          }))
+        );
+    });
+  }, [value]);
+
+  const add = () => {
+    setList((prevList) => [
+      ...prevList,
+      {
+        id: genId(),
+      },
+    ]);
+  };
+
+  const update = (item: OutputItem) => {
+    setList((prevList) => {
+      const nextList = prevList.map((_item) => {
+        if (_item.id === item.id) {
+          return item;
+        }
+        return _item;
+      });
+
+      onChange(
+        Object.fromEntries(
+          nextList.filter((item) => item.key).map((item) => [item.key!, item.value])
+        )
+      );
+
+      return nextList;
+    });
+  };
+
+  const remove = (itemId: number) => {
+    setList((prevList) => {
+      const nextList = prevList.filter((_item) => _item.id !== itemId);
+
+      onChange(
+        Object.fromEntries(
+          nextList.filter((item) => item.key).map((item) => [item.key!, item.value])
+        )
+      );
+
+      return nextList;
+    });
+  };
+
+  return { list, add, update, remove };
+}

+ 1 - 0
packages/materials/form-materials/src/components/dynamic-value-input/index.tsx

@@ -19,6 +19,7 @@ interface PropsType {
   schema?: IJsonSchema;
   constantProps?: {
     strategies?: Strategy[];
+    schema?: IJsonSchema; // set schema of constant input only
     [key: string]: any;
   };
 }

+ 1 - 0
packages/materials/form-materials/src/components/index.ts

@@ -5,3 +5,4 @@ export * from './batch-variable-selector';
 export * from './constant-input';
 export * from './dynamic-value-input';
 export * from './condition-row';
+export * from './batch-outputs';

+ 0 - 1
packages/materials/form-materials/src/effects/provide-batch-outputs/index.ts

@@ -9,7 +9,6 @@ import {
 import { IFlowRefValue } from '../../typings';
 
 export const provideBatchOutputsEffect: EffectOptions[] = createEffectFromVariableProvider({
-  private: true,
   parse: (value: Record<string, IFlowRefValue>, ctx) => [
     ASTFactory.createVariableDeclaration({
       key: `${ctx.node.id}`,

+ 7 - 0
packages/materials/form-materials/src/form-plugins/batch-outputs-plugin/config.json

@@ -0,0 +1,7 @@
+{
+  "name": "batch-outputs-plugin",
+  "depMaterials": [
+    "flow-value"
+  ],
+  "depPackages": []
+}

+ 99 - 0
packages/materials/form-materials/src/form-plugins/batch-outputs-plugin/index.ts

@@ -0,0 +1,99 @@
+import {
+  ASTFactory,
+  createEffectFromVariableProvider,
+  defineFormPluginCreator,
+  FlowNodeRegistry,
+  getNodeForm,
+  getNodePrivateScope,
+  getNodeScope,
+  ScopeChainTransformService,
+  type EffectOptions,
+  type FormPluginCreator,
+  FlowNodeScopeType,
+} from '@flowgram.ai/editor';
+
+import { IFlowRefValue } from '../../typings';
+
+export const provideBatchOutputsEffect: EffectOptions[] = createEffectFromVariableProvider({
+  parse: (value: Record<string, IFlowRefValue>, ctx) => [
+    ASTFactory.createVariableDeclaration({
+      key: `${ctx.node.id}`,
+      meta: {
+        title: getNodeForm(ctx.node)?.getValueIn('title'),
+        icon: ctx.node.getNodeRegistry<FlowNodeRegistry>().info?.icon,
+      },
+      type: ASTFactory.createObject({
+        properties: Object.entries(value).map(([_key, value]) =>
+          ASTFactory.createProperty({
+            key: _key,
+            initializer: ASTFactory.createWrapArrayExpression({
+              wrapFor: ASTFactory.createKeyPathExpression({
+                keyPath: value?.content || [],
+              }),
+            }),
+          })
+        ),
+      }),
+    }),
+  ],
+});
+
+/**
+ * Free Layout only right now
+ */
+export const createBatchOutputsFormPlugin: FormPluginCreator<{ outputKey: string }> =
+  defineFormPluginCreator({
+    name: 'batch-outputs-plugin',
+    onSetupFormMeta({ mergeEffect }, { outputKey }) {
+      mergeEffect({
+        [outputKey]: provideBatchOutputsEffect,
+      });
+    },
+    onInit(ctx, { outputKey }) {
+      const chainTransformService = ctx.node.getService(ScopeChainTransformService);
+
+      const batchNodeType = ctx.node.flowNodeType;
+
+      const transformerId = `${batchNodeType}-outputs`;
+
+      if (chainTransformService.hasTransformer(transformerId)) {
+        return;
+      }
+
+      chainTransformService.registerTransformer(transformerId, {
+        transformCovers: (covers, ctx) => {
+          const node = ctx.scope.meta?.node;
+
+          // Child Node's variable can cover parent
+          if (node?.parent?.flowNodeType === batchNodeType) {
+            return [...covers, getNodeScope(node.parent)];
+          }
+
+          return covers;
+        },
+        transformDeps(scopes, ctx) {
+          const scopeMeta = ctx.scope.meta;
+
+          if (scopeMeta?.type === FlowNodeScopeType.private) {
+            return scopes;
+          }
+
+          const node = scopeMeta?.node;
+
+          // Public of Loop Node depends on child Node
+          if (node?.flowNodeType === batchNodeType) {
+            // Get all child blocks
+            const childBlocks = node.blocks;
+
+            // public scope of all child blocks
+            return [
+              getNodePrivateScope(node),
+              ...childBlocks.map((_childBlock) => getNodeScope(_childBlock)),
+            ];
+          }
+
+          return scopes;
+        },
+      });
+    },
+  });

+ 1 - 0
packages/materials/form-materials/src/form-plugins/index.ts

@@ -0,0 +1 @@
+export { createBatchOutputsFormPlugin } from './batch-outputs-plugin';

+ 1 - 0
packages/materials/form-materials/src/index.ts

@@ -2,3 +2,4 @@ export * from './components';
 export * from './effects';
 export * from './utils';
 export * from './typings';
+export * from './form-plugins';

+ 2 - 2
packages/node-engine/form-core/src/form/models/form-model.ts

@@ -2,7 +2,7 @@ import { injectable } from 'inversify';
 import { DisposableCollection, Event, MaybePromise } from '@flowgram.ai/utils';
 import { type FlowNodeEntity } from '@flowgram.ai/document';
 
-import { FormFeedback, FormModelValid, IFormItem, IFormMeta } from '../types';
+import { FormFeedback, FormModelValid, IFormItem } from '../types';
 import { FormManager } from '../services/form-manager';
 import { type FormItem } from '.';
 
@@ -33,7 +33,7 @@ export abstract class FormModel {
    */
   abstract get formManager(): FormManager;
 
-  abstract get formMeta(): IFormMeta;
+  abstract get formMeta(): any;
 
   abstract get initialized(): boolean;
 

+ 3 - 3
packages/node-engine/form-core/src/form/types/form-meta.types.ts

@@ -1,6 +1,6 @@
+import { MaybePromise } from '@flowgram.ai/utils';
 import { FlowNodeEntity } from '@flowgram.ai/document';
 import { PlaygroundContext } from '@flowgram.ai/core';
-import { MaybePromise } from '@flowgram.ai/utils';
 
 import { type FormItemAbilityMeta } from './form-ability.types';
 
@@ -82,7 +82,7 @@ export interface IFormMeta {
   /**
    * 表单树结构root
    */
-  root: IFormItemMeta;
+  root?: IFormItemMeta;
   /**
    * 表单全局配置
    */
@@ -109,7 +109,7 @@ export interface FormMetaGeneratorParams<PlaygroundContext, FormValue = any> {
 }
 
 export type FormMetaGenerator<PlaygroundContext = any, FormValue = any> = (
-  params: FormMetaGeneratorParams<FormValue, FormValue>,
+  params: FormMetaGeneratorParams<FormValue, FormValue>
 ) => MaybePromise<IFormMeta>;
 
 export type FormMetaOrFormMetaGenerator = FormMetaGenerator | IFormMeta;

+ 36 - 27
packages/node-engine/node/__tests__/form-model-v2.test.ts

@@ -42,14 +42,15 @@ describe('FormModelV2', () => {
       });
 
       const formItem = formModelV2.getFormItemByPath('/');
-      expect(formItem.value).toEqual({
+      expect(formItem?.value).toEqual({
         a: 1,
         b: 2,
       });
 
-      formItem.value = { a: 3, b: 4 };
+      // @ts-expect-error
+      formItem?.value = { a: 3, b: 4 };
 
-      expect(formItem.value).toEqual({
+      expect(formItem?.value).toEqual({
         a: 3,
         b: 4,
       });
@@ -336,7 +337,8 @@ describe('FormModelV2', () => {
     });
     it('should call onInit when formModel init', () => {
       const mockInit = vi.fn();
-      const plugin = defineFormPluginCreator('test', {
+      const plugin = defineFormPluginCreator({
+        name: 'test',
         onInit: mockInit,
       })({ opt1: 1 });
       const formMeta = {
@@ -353,7 +355,8 @@ describe('FormModelV2', () => {
     });
     it('should call onDispose when formModel dispose', () => {
       const mockDispose = vi.fn();
-      const plugin = defineFormPluginCreator('test', {
+      const plugin = defineFormPluginCreator({
+        name: 'test',
         onDispose: mockDispose,
       })({ opt1: 1 });
       const formMeta = {
@@ -373,14 +376,17 @@ describe('FormModelV2', () => {
       const mockEffectPlugin = vi.fn();
       const mockEffectOrigin = vi.fn();
 
-      const plugin = defineFormPluginCreator('test', {
-        effect: {
-          a: [
-            {
-              event: DataEvent.onValueInitOrChange,
-              effect: mockEffectPlugin,
-            },
-          ],
+      const plugin = defineFormPluginCreator({
+        name: 'test',
+        onSetupFormMeta(ctx, opts) {
+          ctx.mergeEffect({
+            a: [
+              {
+                event: DataEvent.onValueInitOrChange,
+                effect: mockEffectPlugin,
+              },
+            ],
+          });
         },
       })({ opt1: 1 });
 
@@ -407,20 +413,23 @@ describe('FormModelV2', () => {
       const mockEffectOriginArrStar = vi.fn();
       const mockEffectPluginOther = vi.fn();
 
-      const plugin = defineFormPluginCreator('test', {
-        effect: {
-          'arr.*': [
-            {
-              event: DataEvent.onValueChange,
-              effect: mockEffectPluginArrStar,
-            },
-          ],
-          other: [
-            {
-              event: DataEvent.onValueChange,
-              effect: mockEffectPluginOther,
-            },
-          ],
+      const plugin = defineFormPluginCreator({
+        name: 'test',
+        onSetupFormMeta(ctx, opts) {
+          ctx.mergeEffect({
+            'arr.*': [
+              {
+                event: DataEvent.onValueChange,
+                effect: mockEffectPluginArrStar,
+              },
+            ],
+            other: [
+              {
+                event: DataEvent.onValueChange,
+                effect: mockEffectPluginOther,
+              },
+            ],
+          });
         },
       })({ opt1: 1 });
 

+ 13 - 11
packages/node-engine/node/src/form-model-v2.ts

@@ -26,12 +26,7 @@ import {
 import { FlowNodeEntity } from '@flowgram.ai/document';
 import { PlaygroundContext, PluginContext } from '@flowgram.ai/core';
 
-import {
-  convertGlobPath,
-  findMatchedInMap,
-  formFeedbacksToNodeCoreFormFeedbacks,
-  mergeEffectMap,
-} from './utils';
+import { convertGlobPath, findMatchedInMap, formFeedbacksToNodeCoreFormFeedbacks } from './utils';
 import {
   DataEvent,
   Effect,
@@ -133,8 +128,10 @@ export class FormModelV2 extends FormModel implements Disposable {
     return this._formControl;
   }
 
-  get formMeta() {
-    return this.node.getNodeRegistry().formMeta;
+  protected _formMeta: FormMeta;
+
+  get formMeta(): FormMeta {
+    return this._formMeta || (this.node.getNodeRegistry().formMeta as FormMeta);
   }
 
   get values() {
@@ -195,9 +192,6 @@ export class FormModelV2 extends FormModel implements Disposable {
     this.plugins = plugins;
     plugins.forEach((plugin) => {
       plugin.init(this);
-      if (plugin.config?.effect) {
-        mergeEffectMap(this.effectMap, plugin.config.effect);
-      }
     });
   }
 
@@ -209,6 +203,14 @@ export class FormModelV2 extends FormModel implements Disposable {
       formData.fireChange();
     });
 
+    (formMeta.plugins || [])?.forEach((_plugin) => {
+      if (_plugin.setupFormMeta) {
+        formMeta = _plugin.setupFormMeta(formMeta, this.nodeContext);
+      }
+    });
+
+    this._formMeta = formMeta;
+
     const { validateTrigger, validate, effect } = formMeta;
     if (effect) {
       this.effectMap = effect;

+ 65 - 11
packages/node-engine/node/src/form-plugin.ts

@@ -1,23 +1,31 @@
 import { nanoid } from 'nanoid';
 import { Disposable } from '@flowgram.ai/utils';
+import { type NodeFormContext } from '@flowgram.ai/form-core';
 
-import { EffectOptions, FormPluginCtx } from './types';
+import { type FormMeta, type FormPluginCtx, type FormPluginSetupMetaCtx } from './types';
 import { FormModelV2 } from './form-model-v2';
 
 export interface FormPluginConfig<Opts = any> {
   /**
-   * FormModel 初始化时执行
+   * form plugin name, for debug use
+   */
+  name?: string;
+
+  /**
+   * setup formMeta
    * @param ctx
+   * @returns
    */
-  onInit?: (ctx: FormPluginCtx, opts: Opts) => void;
+  onSetupFormMeta?: (ctx: FormPluginSetupMetaCtx, opts: Opts) => void;
 
   /**
-   * 同 FormMeta 中的effects 会与 FormMeta 中的effects 合并
+   * FormModel 初始化时执行
+   * @param ctx
    */
-  effect?: Record<string, EffectOptions[]>;
+  onInit?: (ctx: FormPluginCtx, opts: Opts) => void;
+
   /**
    * FormModel 销毁时执行
-   * @param ctx
    */
   onDispose?: (ctx: FormPluginCtx, opts: Opts) => void;
 }
@@ -33,10 +41,11 @@ export class FormPlugin<Opts = any> implements Disposable {
 
   protected _formModel: FormModelV2;
 
-  constructor(name: string, config: FormPluginConfig, opts?: Opts) {
-    this.name = name;
-    this.pluginId = `${name}__${nanoid()}`;
+  constructor(config: FormPluginConfig, opts?: Opts) {
+    this.name = config?.name || '';
+    this.pluginId = `${this.name}__${nanoid()}`;
     this.config = config;
+
     this.opts = opts;
   }
 
@@ -52,6 +61,49 @@ export class FormPlugin<Opts = any> implements Disposable {
     };
   }
 
+  setupFormMeta(formMeta: FormMeta, nodeContext: NodeFormContext): FormMeta {
+    const nextFormMeta: FormMeta = {
+      ...formMeta,
+    };
+
+    this.config.onSetupFormMeta?.(
+      {
+        mergeEffect: (effect) => {
+          nextFormMeta.effect = {
+            ...(nextFormMeta.effect || {}),
+            ...effect,
+          };
+        },
+        mergeValidate: (validate) => {
+          nextFormMeta.validate = {
+            ...(nextFormMeta.validate || {}),
+            ...validate,
+          };
+        },
+        addFormatOnInit: (formatOnInit) => {
+          if (!nextFormMeta.formatOnInit) {
+            nextFormMeta.formatOnInit = formatOnInit;
+            return;
+          }
+          const legacyFormatOnInit = nextFormMeta.formatOnInit;
+          nextFormMeta.formatOnInit = (v, c) => formatOnInit?.(legacyFormatOnInit(v, c), c);
+        },
+        addFormatOnSubmit: (formatOnSubmit) => {
+          if (!nextFormMeta.formatOnSubmit) {
+            nextFormMeta.formatOnSubmit = formatOnSubmit;
+            return;
+          }
+          const legacyFormatOnSubmit = nextFormMeta.formatOnSubmit;
+          nextFormMeta.formatOnSubmit = (v, c) => formatOnSubmit?.(legacyFormatOnSubmit(v, c), c);
+        },
+        ...nodeContext,
+      },
+      this.opts
+    );
+
+    return nextFormMeta;
+  }
+
   init(formModel: FormModelV2) {
     this._formModel = formModel;
     this.config?.onInit?.(this.ctx, this.opts);
@@ -64,8 +116,10 @@ export class FormPlugin<Opts = any> implements Disposable {
   }
 }
 
-export function defineFormPluginCreator<Opts>(name: string, config: FormPluginConfig) {
+export type FormPluginCreator<Opts> = (opts: Opts) => FormPlugin<Opts>;
+
+export function defineFormPluginCreator<Opts>(config: FormPluginConfig): FormPluginCreator<Opts> {
   return function (opts: Opts) {
-    return new FormPlugin(name, config, opts);
+    return new FormPlugin(config, opts);
   };
 }

+ 7 - 0
packages/node-engine/node/src/types.ts

@@ -138,6 +138,13 @@ export type FormPluginCtx = {
   formModel: FormModelV2;
 } & NodeContext;
 
+export type FormPluginSetupMetaCtx = {
+  mergeEffect: (effect: Record<string, EffectOptions[]>) => void;
+  mergeValidate: (validate: Record<FieldName, Validate>) => void;
+  addFormatOnInit: (formatOnInit: FormMeta['formatOnInit']) => void;
+  addFormatOnSubmit: (formatOnSubmit: FormMeta['formatOnSubmit']) => void;
+} & NodeContext;
+
 export interface onFormValueChangeInPayload<TValue = FieldValue, TFormValues = FieldValue> {
   value: TValue;
   prevValue: TValue;

+ 3 - 5
packages/plugins/node-variable-plugin/src/form-v2/create-provider-effect.ts

@@ -10,7 +10,7 @@ import { type VariableProviderAbilityOptions } from '../types';
  * @returns
  */
 export function createEffectFromVariableProvider(
-  options: VariableProviderAbilityOptions,
+  options: VariableProviderAbilityOptions
 ): EffectOptions[] {
   const getScope = (node: FlowNodeEntity): Scope => {
     const variableData: FlowNodeVariableData = node.getData(FlowNodeVariableData);
@@ -42,7 +42,7 @@ export function createEffectFromVariableProvider(
   return [
     {
       event: DataEvent.onValueInit,
-      effect: (params => {
+      effect: ((params) => {
         const { context } = params;
 
         const scope = getScope(context.node);
@@ -51,8 +51,6 @@ export function createEffectFromVariableProvider(
           scope,
           options,
           formItem: undefined,
-          // Hack: 新表单引擎暂时不支持 triggerSync
-          triggerSync: undefined as any,
         });
 
         if (disposable) {
@@ -65,7 +63,7 @@ export function createEffectFromVariableProvider(
     },
     {
       event: DataEvent.onValueChange,
-      effect: (params => {
+      effect: ((params) => {
         transformValueToAST(params);
       }) as Effect,
     },

+ 13 - 10
packages/plugins/node-variable-plugin/src/form-v2/create-variable-provider-plugin.ts

@@ -1,20 +1,23 @@
 import { DataEvent, defineFormPluginCreator } from '@flowgram.ai/node';
 
-export const createVariableProviderPlugin = defineFormPluginCreator('VariableProviderPlugin', {
+export const createVariableProviderPlugin = defineFormPluginCreator({
+  name: 'VariableProviderPlugin',
   onInit: (ctx, opts) => {
     // todo
     // console.log('>>> VariableProviderPlugin init', ctx, opts);
   },
-  effect: {
-    arr: [
-      {
-        event: DataEvent.onValueInitOrChange,
-        effect: () => {
-          // todo
-          // console.log('>>> VariableProviderPlugin effect triggered');
+  onSetupFormMeta({ mergeEffect }) {
+    mergeEffect({
+      arr: [
+        {
+          event: DataEvent.onValueInitOrChange,
+          effect: () => {
+            // todo
+            // console.log('>>> VariableProviderPlugin effect triggered');
+          },
         },
-      },
-    ],
+      ],
+    });
   },
   onDispose: (ctx, opts) => {
     // todo

+ 2 - 4
packages/plugins/node-variable-plugin/src/types.ts

@@ -3,9 +3,9 @@ import {
   type ASTNodeJSON,
   type VariableDeclarationJSON,
 } from '@flowgram.ai/variable-plugin';
+import { Disposable } from '@flowgram.ai/utils';
 import { FormItem } from '@flowgram.ai/form-core';
 import { FlowNodeEntity } from '@flowgram.ai/document';
-import { Disposable } from '@flowgram.ai/utils';
 
 export interface VariableAbilityCommonContext {
   node: FlowNodeEntity; // 节点
@@ -14,9 +14,7 @@ export interface VariableAbilityCommonContext {
   options: VariableAbilityOptions;
 }
 
-export interface VariableAbilityInitCtx extends VariableAbilityCommonContext {
-  triggerSync: () => void; // 触发变量同步
-}
+export interface VariableAbilityInitCtx extends VariableAbilityCommonContext {}
 
 export interface VariableAbilityOptions {
   // 变量提供能力可复用

+ 10 - 1
packages/plugins/variable-plugin/src/index.ts

@@ -1,3 +1,12 @@
 export * from './create-variable-plugin';
 export * from '@flowgram.ai/variable-core';
-export { FlowNodeVariableData, GlobalScope } from '@flowgram.ai/variable-layout';
+export {
+  FlowNodeVariableData,
+  GlobalScope,
+  ScopeChainTransformService,
+  getNodeScope,
+  getNodePrivateScope,
+  FlowNodeScopeType,
+  type FlowNodeScopeMeta,
+  type FlowNodeScope,
+} from '@flowgram.ai/variable-layout';

+ 13 - 13
packages/variable-engine/variable-core/src/ast/ast-node.ts

@@ -12,8 +12,8 @@ import { shallowEqual } from 'fast-equals';
 import { Disposable, DisposableCollection } from '@flowgram.ai/utils';
 
 import { subsToDisposable } from '../utils/toDisposable';
-import { type Scope } from '../scope';
 import { updateChildNodeHelper } from './utils/helpers';
+import { type Scope } from '../scope';
 import {
   type ASTNodeJSON,
   type ObserverOrNext,
@@ -73,7 +73,7 @@ export abstract class ASTNode<JSON extends ASTNodeJSON = any, InjectOpts = any>
   /**
    * 节点的版本号,每 fireChange 一次 version + 1
    */
-  private _version: number = 0;
+  protected _version: number = 0;
 
   /**
    * 更新锁
@@ -109,8 +109,8 @@ export abstract class ASTNode<JSON extends ASTNodeJSON = any, InjectOpts = any>
     Disposable.create(() => {
       // 子元素删除时,父元素触发更新
       this.parent?.fireChange();
-      this.children.forEach(child => child.dispose());
-    }),
+      this.children.forEach((child) => child.dispose());
+    })
   );
 
   /**
@@ -191,7 +191,7 @@ export abstract class ASTNode<JSON extends ASTNodeJSON = any, InjectOpts = any>
     child.toDispose.push(
       Disposable.create(() => {
         this._children.delete(child);
-      }),
+      })
     );
 
     return child;
@@ -204,7 +204,7 @@ export abstract class ASTNode<JSON extends ASTNodeJSON = any, InjectOpts = any>
   protected updateChildNodeByKey(keyInThis: keyof this, nextJSON?: ASTNodeJSON) {
     this.withBatchUpdate(updateChildNodeHelper).call(this, {
       getChildNode: () => this[keyInThis] as ASTNode,
-      updateChildNode: _node => ((this as any)[keyInThis] = _node),
+      updateChildNode: (_node) => ((this as any)[keyInThis] = _node),
       removeChildNode: () => ((this as any)[keyInThis] = undefined),
       nextJSON,
     });
@@ -216,7 +216,7 @@ export abstract class ASTNode<JSON extends ASTNodeJSON = any, InjectOpts = any>
    * @returns
    */
   protected withBatchUpdate<ParamTypes extends any[], ReturnType>(
-    updater: (...args: ParamTypes) => ReturnType,
+    updater: (...args: ParamTypes) => ReturnType
   ) {
     return (...args: ParamTypes) => {
       // batchUpdate 里面套 batchUpdate 只能生效一次
@@ -281,7 +281,7 @@ export abstract class ASTNode<JSON extends ASTNodeJSON = any, InjectOpts = any>
    */
   subscribe<Data = this>(
     observer: ObserverOrNext<Data>,
-    { selector, debounceAnimation, triggerOnInit }: SubscribeConfig<this, Data> = {},
+    { selector, debounceAnimation, triggerOnInit }: SubscribeConfig<this, Data> = {}
   ): Disposable {
     return subsToDisposable(
       this.value$
@@ -289,25 +289,25 @@ export abstract class ASTNode<JSON extends ASTNodeJSON = any, InjectOpts = any>
           map(() => (selector ? selector(this) : (this as any))),
           distinctUntilChanged(
             (a, b) => shallowEqual(a, b),
-            value => {
+            (value) => {
               if (value instanceof ASTNode) {
                 // 如果 value 是 ASTNode,则进行 hash 的比较
                 return value.hash;
               }
               return value;
-            },
+            }
           ),
           // 默认跳过 BehaviorSubject 第一次触发
           triggerOnInit ? tap(() => null) : skip(1),
           // 每个 animationFrame 内所有更新合并成一个
-          debounceAnimation ? debounceTime(0, animationFrameScheduler) : tap(() => null),
+          debounceAnimation ? debounceTime(0, animationFrameScheduler) : tap(() => null)
         )
-        .subscribe(observer),
+        .subscribe(observer)
     );
   }
 
   dispatchGlobalEvent<ActionType extends GlobalEventActionType = GlobalEventActionType>(
-    event: Omit<ActionType, 'ast'>,
+    event: Omit<ActionType, 'ast'>
   ) {
     this.scope.event.dispatch({
       ...event,

+ 9 - 1
packages/variable-engine/variable-core/src/ast/declaration/base-variable-field.ts

@@ -35,6 +35,10 @@ export abstract class BaseVariableField<VariableMeta = any> extends ASTNode<
     return getParentFields(this);
   }
 
+  get keyPath(): string[] {
+    return this.parentFields.reverse().map((_field) => _field.key);
+  }
+
   get meta(): VariableMeta {
     return this._meta;
   }
@@ -47,6 +51,10 @@ export abstract class BaseVariableField<VariableMeta = any> extends ASTNode<
     return this._initializer;
   }
 
+  get hash(): string {
+    return `[${this._version}]${this.keyPath.join('.')}`;
+  }
+
   /**
    * 解析 VariableDeclarationJSON 从而生成变量声明节点
    */
@@ -96,7 +104,7 @@ export abstract class BaseVariableField<VariableMeta = any> extends ASTNode<
    * @returns
    */
   onTypeChange(observer: (type: ASTNode | undefined) => void) {
-    return this.subscribe(observer, { selector: curr => curr.type });
+    return this.subscribe(observer, { selector: (curr) => curr.type });
   }
 
   /**

+ 4 - 0
packages/variable-engine/variable-core/src/ast/expression/wrap-array-expression.ts

@@ -28,6 +28,7 @@ export class WrapArrayExpression extends BaseExpression<WrapArrayExpressionJSON>
   refreshReturnType() {
     // 被遍历表达式的返回值
     const childReturnTypeJSON = this.wrapFor?.returnType?.toJSON();
+
     this.updateChildNodeByKey('_returnType', {
       kind: ASTKind.Array,
       items: childReturnTypeJSON,
@@ -51,9 +52,12 @@ export class WrapArrayExpression extends BaseExpression<WrapArrayExpressionJSON>
 
   @postConstructAST()
   protected init() {
+    this.refreshReturnType = this.refreshReturnType.bind(this);
+
     this.toDispose.push(
       this.subscribe(this.refreshReturnType, {
         selector: (curr) => curr.wrapFor?.returnType,
+        triggerOnInit: true,
       })
     );
   }

+ 28 - 2
packages/variable-engine/variable-core/src/scope/datas/scope-available-data.ts

@@ -6,7 +6,9 @@ import {
   merge,
   share,
   skip,
+  startWith,
   switchMap,
+  tap,
 } from 'rxjs';
 import { flatten } from 'lodash';
 import { shallowEqual } from 'fast-equals';
@@ -17,7 +19,7 @@ import { IVariableTable } from '../types';
 import { type Scope } from '../scope';
 import { subsToDisposable } from '../../utils/toDisposable';
 import { createMemo } from '../../utils/memo';
-import { Property, VariableDeclaration } from '../../ast';
+import { BaseVariableField, VariableDeclaration } from '../../ast';
 /**
  * 作用域可用变量
  */
@@ -135,11 +137,35 @@ export class ScopeAvailableData {
    * @param keyPath
    * @returns
    */
-  getByKeyPath(keyPath: string[] = []): VariableDeclaration | Property | undefined {
+  getByKeyPath(keyPath: string[] = []): BaseVariableField | undefined {
     // 检查变量是否在可访问范围内
     if (!this.variableKeys.includes(keyPath[0])) {
       return;
     }
     return this.globalVariableTable.getByKeyPath(keyPath);
   }
+
+  /**
+   * Track Variable Change (Includes type update and children update) By KeyPath
+   * @returns
+   */
+  trackByKeyPath(
+    keyPath: string[] = [],
+    cb: (variable?: BaseVariableField | undefined) => void,
+    opts?: {
+      triggerOnInit?: boolean;
+    }
+  ): Disposable {
+    const { triggerOnInit = true } = opts || {};
+
+    return subsToDisposable(
+      merge(this.anyVariableChange$, this.variables$)
+        .pipe(
+          triggerOnInit ? startWith() : tap(() => null),
+          map(() => this.getByKeyPath(keyPath)),
+          distinctUntilChanged((_prevNode, _node) => _prevNode?.hash !== _node?.hash)
+        )
+        .subscribe(cb)
+    );
+  }
 }

+ 41 - 1
packages/variable-engine/variable-core/src/scope/scope.ts

@@ -2,7 +2,7 @@ import { DisposableCollection } from '@flowgram.ai/utils';
 
 import { type VariableEngine } from '../variable-engine';
 import { createMemo } from '../utils/memo';
-import { ASTKind, MapNode } from '../ast';
+import { ASTKind, type ASTNode, type ASTNodeJSON, MapNode } from '../ast';
 import { ScopeAvailableData, ScopeEventData, ScopeOutputData } from './datas';
 
 export interface IScopeConstructor {
@@ -117,4 +117,44 @@ export class Scope<ScopeMeta extends Record<string, any> = Record<string, any>>
   get disposed(): boolean {
     return this.toDispose.disposed;
   }
+
+  /**
+   * Sets a variable in the Scope with the default key 'outputs'.
+   *
+   * @param json - The JSON value to store.
+   * @returns The updated AST node.
+   */
+  public setVar(json: ASTNodeJSON): ASTNode;
+
+  public setVar(arg1: string | ASTNodeJSON, arg2?: ASTNodeJSON): ASTNode {
+    if (typeof arg1 === 'string' && arg2 !== undefined) {
+      return this.ast.set(arg1, arg2);
+    }
+
+    if (typeof arg1 === 'object' && arg2 === undefined) {
+      return this.ast.set('outputs', arg1);
+    }
+
+    throw new Error('Invalid arguments');
+  }
+
+  /**
+   * Retrieves a variable from the Scope by key.
+   *
+   * @param key - The key of the variable to retrieve. Defaults to 'outputs'.
+   * @returns The value of the variable, or undefined if not found.
+   */
+  public getVar(key: string = 'outputs') {
+    return this.ast.get(key);
+  }
+
+  /**
+   * Clears a variable from the Scope by key.
+   *
+   * @param key - The key of the variable to clear. Defaults to 'outputs'.
+   * @returns The updated AST node.
+   */
+  public clearVar(key: string = 'outputs') {
+    return this.ast.remove(key);
+  }
 }

+ 5 - 1
packages/variable-engine/variable-layout/__mocks__/container.ts

@@ -24,10 +24,12 @@ import { WorkflowDocumentContainerModule, WorkflowLinesManager, WorkflowSimpleLi
 
 export interface TestConfig extends VariableLayoutConfig {
   enableGlobalScope?: boolean;
+  onInit?: (container: Container) => void;
+  runExtraTest?: (container: Container) => void
 }
 
 export function getContainer(layout: 'free' | 'fixed', config?: TestConfig): Container {
-  const { enableGlobalScope, ...layoutConfig } = config || {};
+  const { enableGlobalScope, onInit, runExtraTest, ...layoutConfig } = config || {};
 
   const container = createPlaygroundContainer() as Container;
   container.load(VariableContainerModule);
@@ -74,5 +76,7 @@ export function getContainer(layout: 'free' | 'fixed', config?: TestConfig): Con
   );
   document.registerNodeDatas(FlowNodeVariableData);
 
+  onInit?.(container);
+
   return container;
 }

+ 2 - 0
packages/variable-engine/variable-layout/__mocks__/run-fixed-layout-test.ts

@@ -95,6 +95,8 @@ export const runFixedLayoutTest = (testName:string, spec: FlowDocumentJSON, conf
     test('test sort', () => {
       expect(variableEngine.getAllScopes({ sort: true }).map(_scope => _scope.id)).toMatchSnapshot();
     });
+
+    config?.runExtraTest?.(container);
   });
 
 

+ 2 - 0
packages/variable-engine/variable-layout/__mocks__/run-free-layout-test.ts

@@ -95,6 +95,8 @@ export const runFreeLayoutTest = (testName: string, spec: WorkflowJSON, config?:
     test('test sort', () => {
       expect(variableEngine.getAllScopes({ sort: true }).map(_scope => _scope.id)).toMatchSnapshot();
     });
+
+    config?.runExtraTest?.(container);
   });
 
 

+ 109 - 0
packages/variable-engine/variable-layout/__tests__/__snapshots__/variable-fix-layout-transform-empty.test.ts.snap

@@ -0,0 +1,109 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`Variable Fixed Layout transform empty > test get Covers 1`] = `
+Map {
+  "start_0" => [],
+  "end_0" => [],
+  "base_1" => [],
+  "base_2" => [],
+  "loop_1" => [],
+  "base_in_loop_1" => [],
+  "base_in_loop_2" => [],
+  "base_in_loop_3" => [],
+  "base_3" => [],
+}
+`;
+
+exports[`Variable Fixed Layout transform empty > test get Covers After Init Private 1`] = `
+Map {
+  "start_0" => [],
+  "end_0" => [],
+  "base_1" => [],
+  "base_2" => [],
+  "loop_1" => [],
+  "base_in_loop_1" => [],
+  "base_in_loop_2" => [],
+  "base_in_loop_3" => [],
+  "base_3" => [],
+}
+`;
+
+exports[`Variable Fixed Layout transform empty > test get Deps 1`] = `
+Map {
+  "start_0" => [],
+  "end_0" => [],
+  "base_1" => [],
+  "base_2" => [],
+  "loop_1" => [],
+  "base_in_loop_1" => [],
+  "base_in_loop_2" => [],
+  "base_in_loop_3" => [],
+  "base_3" => [],
+}
+`;
+
+exports[`Variable Fixed Layout transform empty > test get Deps After Init Private 1`] = `
+Map {
+  "start_0" => [],
+  "end_0" => [],
+  "base_1" => [],
+  "base_2" => [],
+  "loop_1" => [],
+  "base_in_loop_1" => [],
+  "base_in_loop_2" => [],
+  "base_in_loop_3" => [],
+  "base_3" => [],
+}
+`;
+
+exports[`Variable Fixed Layout transform empty > test get private scope Covers 1`] = `
+Map {
+  "start_0_private" => [],
+  "end_0_private" => [],
+  "base_1_private" => [],
+  "base_2_private" => [],
+  "loop_1_private" => [],
+  "base_in_loop_1_private" => [],
+  "base_in_loop_2_private" => [],
+  "base_in_loop_3_private" => [],
+  "base_3_private" => [],
+}
+`;
+
+exports[`Variable Fixed Layout transform empty > test get private scope Deps 1`] = `
+Map {
+  "start_0_private" => [],
+  "end_0_private" => [],
+  "base_1_private" => [],
+  "base_2_private" => [],
+  "loop_1_private" => [],
+  "base_in_loop_1_private" => [],
+  "base_in_loop_2_private" => [],
+  "base_in_loop_3_private" => [],
+  "base_3_private" => [],
+}
+`;
+
+exports[`Variable Fixed Layout transform empty > test sort 1`] = `
+[
+  "start_0",
+  "testScope",
+  "end_0",
+  "base_1",
+  "base_2",
+  "loop_1",
+  "base_in_loop_1",
+  "base_in_loop_2",
+  "base_in_loop_3",
+  "base_3",
+  "start_0_private",
+  "end_0_private",
+  "base_1_private",
+  "base_2_private",
+  "loop_1_private",
+  "base_in_loop_1_private",
+  "base_in_loop_2_private",
+  "base_in_loop_3_private",
+  "base_3_private",
+]
+`;

+ 108 - 0
packages/variable-engine/variable-layout/__tests__/__snapshots__/variable-free-layout-transform-empty.test.ts.snap

@@ -107,3 +107,111 @@ exports[`Variable Free Layout > test sort 1`] = `
   "base_3_private",
 ]
 `;
+
+exports[`Variable Free Layout transform empty > test get Covers 1`] = `
+Map {
+  "start_0" => [],
+  "end_0" => [],
+  "base_1" => [],
+  "base_2" => [],
+  "loop_1" => [],
+  "base_in_loop_1" => [],
+  "base_in_loop_2" => [],
+  "base_in_loop_3" => [],
+  "base_3" => [],
+}
+`;
+
+exports[`Variable Free Layout transform empty > test get Covers After Init Private 1`] = `
+Map {
+  "start_0" => [],
+  "end_0" => [],
+  "base_1" => [],
+  "base_2" => [],
+  "loop_1" => [],
+  "base_in_loop_1" => [],
+  "base_in_loop_2" => [],
+  "base_in_loop_3" => [],
+  "base_3" => [],
+}
+`;
+
+exports[`Variable Free Layout transform empty > test get Deps 1`] = `
+Map {
+  "start_0" => [],
+  "end_0" => [],
+  "base_1" => [],
+  "base_2" => [],
+  "loop_1" => [],
+  "base_in_loop_1" => [],
+  "base_in_loop_2" => [],
+  "base_in_loop_3" => [],
+  "base_3" => [],
+}
+`;
+
+exports[`Variable Free Layout transform empty > test get Deps After Init Private 1`] = `
+Map {
+  "start_0" => [],
+  "end_0" => [],
+  "base_1" => [],
+  "base_2" => [],
+  "loop_1" => [],
+  "base_in_loop_1" => [],
+  "base_in_loop_2" => [],
+  "base_in_loop_3" => [],
+  "base_3" => [],
+}
+`;
+
+exports[`Variable Free Layout transform empty > test get private scope Covers 1`] = `
+Map {
+  "start_0_private" => [],
+  "end_0_private" => [],
+  "base_1_private" => [],
+  "base_2_private" => [],
+  "loop_1_private" => [],
+  "base_in_loop_1_private" => [],
+  "base_in_loop_2_private" => [],
+  "base_in_loop_3_private" => [],
+  "base_3_private" => [],
+}
+`;
+
+exports[`Variable Free Layout transform empty > test get private scope Deps 1`] = `
+Map {
+  "start_0_private" => [],
+  "end_0_private" => [],
+  "base_1_private" => [],
+  "base_2_private" => [],
+  "loop_1_private" => [],
+  "base_in_loop_1_private" => [],
+  "base_in_loop_2_private" => [],
+  "base_in_loop_3_private" => [],
+  "base_3_private" => [],
+}
+`;
+
+exports[`Variable Free Layout transform empty > test sort 1`] = `
+[
+  "testScope",
+  "start_0",
+  "end_0",
+  "base_1",
+  "base_2",
+  "loop_1",
+  "base_in_loop_1",
+  "base_in_loop_2",
+  "base_in_loop_3",
+  "base_3",
+  "start_0_private",
+  "end_0_private",
+  "base_1_private",
+  "base_2_private",
+  "loop_1_private",
+  "base_in_loop_1_private",
+  "base_in_loop_2_private",
+  "base_in_loop_3_private",
+  "base_3_private",
+]
+`;

+ 34 - 0
packages/variable-engine/variable-layout/__tests__/variable-fix-layout-transform-empty.test.ts

@@ -0,0 +1,34 @@
+import { test, expect } from 'vitest';
+
+import { ScopeChainTransformService } from '../src';
+import { runFixedLayoutTest } from '../__mocks__/run-fixed-layout-test';
+import { freeLayout1 } from '../__mocks__/free-layout-specs';
+
+runFixedLayoutTest('Variable Fixed Layout transform empty', freeLayout1, {
+  onInit(container) {
+    const transformService = container.get(ScopeChainTransformService);
+
+    transformService.registerTransformer('MOCK', {
+      transformCovers: (scopes) => scopes,
+      transformDeps: (scopes) => scopes,
+    });
+
+    // again transformer, prevent duplicated transformerId
+    transformService.registerTransformer('MOCK', {
+      transformCovers: () => [],
+      transformDeps: () => [],
+    });
+    transformService.registerTransformer('MOCK', {
+      transformCovers: () => [],
+      transformDeps: () => [],
+    });
+  },
+  runExtraTest: (container) => {
+    test('check has transformer', () => {
+      const transformService = container.get(ScopeChainTransformService);
+      expect(transformService.hasTransformer('MOCK')).to.be.true;
+      expect(transformService.hasTransformer('VARIABLE_LAYOUT_CONFIG')).to.be.false;
+      expect(transformService.hasTransformer('NOT_EXIST')).to.be.false;
+    });
+  },
+});

+ 10 - 1
packages/variable-engine/variable-layout/__tests__/variable-free-layout-transform-empty.test.ts

@@ -1,8 +1,17 @@
+import { test, expect } from 'vitest';
+
+import { ScopeChainTransformService } from '../src';
 import { runFreeLayoutTest } from '../__mocks__/run-free-layout-test';
 import { freeLayout1 } from '../__mocks__/free-layout-specs';
 
-runFreeLayoutTest('Variable Free Layout', freeLayout1, {
+runFreeLayoutTest('Variable Free Layout transform empty', freeLayout1, {
   // 模拟清空作用域
   transformCovers: () => [],
   transformDeps: () => [],
+  runExtraTest: (container) => {
+    test('check has transformer', () => {
+      const transformService = container.get(ScopeChainTransformService);
+      expect(transformService.hasTransformer('VARIABLE_LAYOUT_CONFIG')).to.be.true;
+    });
+  },
 });

+ 3 - 1
packages/variable-engine/variable-layout/src/chains/fixed-layout-scope-chain.ts

@@ -136,9 +136,11 @@ export class FixedLayoutScopeChain extends ScopeChain {
 
     // If scope is GlobalScope, return all scopes except GlobalScope
     if (GlobalScope.is(scope)) {
-      return this.variableEngine
+      const scopes = this.variableEngine
         .getAllScopes({ sort: true })
         .filter((_scope) => !GlobalScope.is(_scope));
+
+      return this.transformService.transformCovers(scopes, { scope });
     }
 
     const node = scope.meta.node;

+ 16 - 14
packages/variable-engine/variable-layout/src/chains/free-layout-scope-chain.ts

@@ -53,19 +53,19 @@ export class FreeLayoutScopeChain extends ScopeChain {
 
   // 获取同一层级所有输入节点
   protected getAllInputLayerNodes(curr: FlowNodeEntity): FlowNodeEntity[] {
-    const currParent = this.getParent(curr);
+    const currParent = this.getNodeParent(curr);
 
     return (curr.getData(WorkflowNodeLinesData)?.allInputNodes || []).filter(
-      (_node) => this.getParent(_node) === currParent
+      (_node) => this.getNodeParent(_node) === currParent
     );
   }
 
   // 获取同一层级所有输出节点
   protected getAllOutputLayerNodes(curr: FlowNodeEntity): FlowNodeEntity[] {
-    const currParent = this.getParent(curr);
+    const currParent = this.getNodeParent(curr);
 
     return (curr.getData(WorkflowNodeLinesData)?.allOutputNodes || []).filter(
-      (_node) => this.getParent(_node) === currParent
+      (_node) => this.getNodeParent(_node) === currParent
     );
   }
 
@@ -94,7 +94,7 @@ export class FreeLayoutScopeChain extends ScopeChain {
         deps.push(currVarData.private);
       }
 
-      curr = this.getParent(curr);
+      curr = this.getNodeParent(curr);
     }
 
     // If scope is GlobalScope, add globalScope to deps
@@ -110,9 +110,11 @@ export class FreeLayoutScopeChain extends ScopeChain {
   getCovers(scope: FlowNodeScope): FlowNodeScope[] {
     // If scope is GlobalScope, return all scopes except GlobalScope
     if (GlobalScope.is(scope)) {
-      return this.variableEngine
+      const scopes = this.variableEngine
         .getAllScopes({ sort: true })
         .filter((_scope) => !GlobalScope.is(_scope));
+
+      return this.transformService.transformCovers(scopes, { scope });
     }
 
     const { node } = scope.meta || {};
@@ -127,7 +129,7 @@ export class FreeLayoutScopeChain extends ScopeChain {
 
     if (isPrivate) {
       // private 只能覆盖其子节点
-      queue.push(...this.getChildren(node));
+      queue.push(...this.getNodeChildren(node));
     } else {
       // 否则覆盖其所有输出线的节点
       queue.push(...(this.getAllOutputLayerNodes(node) || []));
@@ -140,7 +142,7 @@ export class FreeLayoutScopeChain extends ScopeChain {
       const _node = queue.shift()!;
       const variableData: FlowNodeVariableData = _node.getData(FlowNodeVariableData);
       scopes.push(...variableData.allScopes);
-      const children = _node && this.getChildren(_node);
+      const children = _node && this.getNodeChildren(_node);
 
       if (children?.length) {
         queue.push(...children);
@@ -158,9 +160,9 @@ export class FreeLayoutScopeChain extends ScopeChain {
     return this.transformService.transformCovers(uniqScopes, { scope });
   }
 
-  getChildren(node: FlowNodeEntity): FlowNodeEntity[] {
-    if (this.configs?.getFreeChildren) {
-      return this.configs.getFreeChildren?.(node);
+  getNodeChildren(node: FlowNodeEntity): FlowNodeEntity[] {
+    if (this.configs?.getNodeChildren) {
+      return this.configs.getNodeChildren?.(node);
     }
     const nodeMeta = node.getNodeMeta<WorkflowNodeMeta>();
     const subCanvas = nodeMeta.subCanvas?.(node);
@@ -178,10 +180,10 @@ export class FreeLayoutScopeChain extends ScopeChain {
     return this.tree.getChildren(node);
   }
 
-  getParent(node: FlowNodeEntity): FlowNodeEntity | undefined {
+  getNodeParent(node: FlowNodeEntity): FlowNodeEntity | undefined {
     // 部分场景通过连线来表达父子关系,因此需要上层配置
-    if (this.configs?.getFreeParent) {
-      return this.configs.getFreeParent(node);
+    if (this.configs?.getNodeParent) {
+      return this.configs.getNodeParent(node);
     }
     let parent = node.document.originTree.getParent(node);
 

+ 19 - 1
packages/variable-engine/variable-layout/src/flow-node-variable-data.ts

@@ -1,4 +1,4 @@
-import { VariableEngine } from '@flowgram.ai/variable-core';
+import { BaseVariableField, VariableEngine } from '@flowgram.ai/variable-core';
 import { type ASTNode, ASTNodeJSON } from '@flowgram.ai/variable-core';
 import { FlowNodeEntity } from '@flowgram.ai/document';
 import { EntityData } from '@flowgram.ai/core';
@@ -179,4 +179,22 @@ export class FlowNodeVariableData extends EntityData {
     }
     return this._private;
   }
+
+  /**
+   * Find a variable field by key path in the public scope by scope chain.
+   * @param keyPath - The key path of the variable field.
+   * @returns The variable field, or undefined if not found.
+   */
+  getByKeyPath(keyPath: string[]): BaseVariableField | undefined {
+    return this.public.available.getByKeyPath(keyPath);
+  }
+
+  /**
+   * Find a variable field by key path in the private scope by scope chain.
+   * @param keyPath - The key path of the variable field.
+   * @returns The variable field, or undefined if not found.
+   */
+  getByKeyPathInPrivate(keyPath: string[]): BaseVariableField | undefined {
+    return this.private?.available.getByKeyPath(keyPath);
+  }
 }

+ 1 - 0
packages/variable-engine/variable-layout/src/index.ts

@@ -9,3 +9,4 @@ export {
 } from './types';
 export { GlobalScope, bindGlobalScope } from './scopes/global-scope';
 export { ScopeChainTransformService } from './services/scope-chain-transform-service';
+export { getNodeScope, getNodePrivateScope } from './utils';

+ 2 - 51
packages/variable-engine/variable-layout/src/scopes/global-scope.ts

@@ -1,62 +1,13 @@
 import { injectable, interfaces } from 'inversify';
-import { ASTNode, ASTNodeJSON, Scope, VariableEngine } from '@flowgram.ai/variable-core';
+import { Scope, VariableEngine } from '@flowgram.ai/variable-core';
 
 @injectable()
 export class GlobalScope extends Scope {
   static readonly ID = Symbol('GlobalScope');
 
-  static is(scope: Scope): scope is GlobalScope {
+  static is(scope: Scope) {
     return scope.id === GlobalScope.ID;
   }
-
-  /**
-   * Sets a variable in the Global Scope with the given key and JSON value.
-   *
-   * @param key - The key under which the variable will be stored.
-   * @param json - The JSON value to store.
-   * @returns The updated AST node.
-   */
-  public setVar(key: string, json: ASTNodeJSON): ASTNode;
-
-  /**
-   * Sets a variable in the Global Scope with the default key 'outputs'.
-   *
-   * @param json - The JSON value to store.
-   * @returns The updated AST node.
-   */
-  public setVar(json: ASTNodeJSON): ASTNode;
-
-  public setVar(arg1: string | ASTNodeJSON, arg2?: ASTNodeJSON): ASTNode {
-    if (typeof arg1 === 'string' && arg2 !== undefined) {
-      return this.ast.set(arg1, arg2);
-    }
-
-    if (typeof arg1 === 'object' && arg2 === undefined) {
-      return this.ast.set('outputs', arg1);
-    }
-
-    throw new Error('Invalid arguments');
-  }
-
-  /**
-   * Retrieves a variable from the Global Scope by key.
-   *
-   * @param key - The key of the variable to retrieve. Defaults to 'outputs'.
-   * @returns The value of the variable, or undefined if not found.
-   */
-  public getVar(key: string = 'outputs') {
-    return this.ast.get(key);
-  }
-
-  /**
-   * Clears a variable from the Global Scope by key.
-   *
-   * @param key - The key of the variable to clear. Defaults to 'outputs'.
-   * @returns The updated AST node.
-   */
-  public clearVar(key: string = 'outputs') {
-    return this.ast.remove(key);
-  }
 }
 
 export const bindGlobalScope = (bind: interfaces.Bind) => {

+ 55 - 30
packages/variable-engine/variable-layout/src/services/scope-chain-transform-service.ts

@@ -14,11 +14,14 @@ export interface TransformerContext {
 
 export type IScopeTransformer = (scopes: Scope[], ctx: TransformerContext) => Scope[];
 
+const passthrough: IScopeTransformer = (scopes, ctx) => scopes;
+
 @injectable()
 export class ScopeChainTransformService {
-  protected transformDepsFns: IScopeTransformer[] = [];
-
-  protected transformCoversFns: IScopeTransformer[] = [];
+  protected transformerMap: Map<
+    string,
+    { transformDeps: IScopeTransformer; transformCovers: IScopeTransformer }
+  > = new Map();
 
   @lazyInject(FlowDocument) document: FlowDocument;
 
@@ -29,43 +32,65 @@ export class ScopeChainTransformService {
     @inject(VariableLayoutConfig)
     protected configs?: VariableLayoutConfig
   ) {
-    if (this.configs?.transformDeps) {
-      this.transformDepsFns.push(this.configs.transformDeps);
-    }
-    if (this.configs?.transformCovers) {
-      this.transformCoversFns.push(this.configs.transformCovers);
+    if (this.configs?.transformDeps || this.configs?.transformCovers) {
+      this.transformerMap.set('VARIABLE_LAYOUT_CONFIG', {
+        transformDeps: this.configs.transformDeps || passthrough,
+        transformCovers: this.configs.transformCovers || passthrough,
+      });
     }
   }
 
-  registerTransformDeps(transformer: IScopeTransformer) {
-    this.transformDepsFns.push(transformer);
+  /**
+   * check if transformer registered
+   * @param transformerId used to identify transformer, prevent duplicated
+   * @returns
+   */
+  hasTransformer(transformerId: string) {
+    return this.transformerMap.has(transformerId);
   }
 
-  registerTransformCovers(transformer: IScopeTransformer) {
-    this.transformCoversFns.push(transformer);
+  /**
+   * register new transform function
+   * @param transformerId used to identify transformer, prevent duplicated transformer
+   * @param transformer
+   */
+  registerTransformer(
+    transformerId: string,
+    transformer: {
+      transformDeps: IScopeTransformer;
+      transformCovers: IScopeTransformer;
+    }
+  ) {
+    this.transformerMap.set(transformerId, transformer);
   }
 
   transformDeps(scopes: Scope[], { scope }: { scope: Scope }): Scope[] {
-    return this.transformDepsFns.reduce(
-      (scopes, transformer) =>
-        transformer(scopes, {
-          scope,
-          document: this.document,
-          variableEngine: this.variableEngine,
-        }),
-      scopes
-    );
+    return Array.from(this.transformerMap.values()).reduce((scopes, transformer) => {
+      if (!transformer.transformDeps) {
+        return scopes;
+      }
+
+      scopes = transformer.transformDeps(scopes, {
+        scope,
+        document: this.document,
+        variableEngine: this.variableEngine,
+      });
+      return scopes;
+    }, scopes);
   }
 
   transformCovers(scopes: Scope[], { scope }: { scope: Scope }): Scope[] {
-    return this.transformCoversFns.reduce(
-      (scopes, transformer) =>
-        transformer(scopes, {
-          scope,
-          document: this.document,
-          variableEngine: this.variableEngine,
-        }),
-      scopes
-    );
+    return Array.from(this.transformerMap.values()).reduce((scopes, transformer) => {
+      if (!transformer.transformCovers) {
+        return scopes;
+      }
+
+      scopes = transformer.transformCovers(scopes, {
+        scope,
+        document: this.document,
+        variableEngine: this.variableEngine,
+      });
+      return scopes;
+    }, scopes);
   }
 }

+ 1 - 1
packages/variable-engine/variable-layout/src/types.ts

@@ -19,4 +19,4 @@ export interface ScopeVirtualNode {
 export type ScopeChainNode = FlowNodeEntity | ScopeVirtualNode;
 
 // 节点内部的作用域
-export type FlowNodeScope = Scope<FlowNodeScopeMeta>;
+export interface FlowNodeScope extends Scope<FlowNodeScopeMeta> {}

+ 11 - 0
packages/variable-engine/variable-layout/src/utils.ts

@@ -0,0 +1,11 @@
+import { FlowNodeEntity } from '@flowgram.ai/document';
+
+import { FlowNodeVariableData } from './flow-node-variable-data';
+
+export function getNodeScope(node: FlowNodeEntity) {
+  return node.getData(FlowNodeVariableData).public;
+}
+
+export function getNodePrivateScope(node: FlowNodeEntity) {
+  return node.getData(FlowNodeVariableData).initPrivate();
+}

+ 4 - 5
packages/variable-engine/variable-layout/src/variable-layout-config.ts

@@ -12,19 +12,18 @@ export interface VariableLayoutConfig {
   isNodeChildrenPrivate?: (node: ScopeChainNode) => boolean;
 
   /**
-   * 用于自由画布场景,部分场景通过连线或者其他交互形式来表达节点之间的父子关系,需要可配置化
+   * 用于固定布局场景时:父子中间存在大量无用节点(如 inlineBlocks 等,需要配置化略过)
+   * 用于自由画布场景时:部分场景通过连线或者其他交互形式来表达节点之间的父子关系,需可配置化
    */
-  getFreeChildren?: (node: FlowNodeEntity) => FlowNodeEntity[];
-  getFreeParent?: (node: FlowNodeEntity) => FlowNodeEntity | undefined;
+  getNodeChildren?: (node: FlowNodeEntity) => FlowNodeEntity[];
+  getNodeParent?: (node: FlowNodeEntity) => FlowNodeEntity | undefined;
 
   /**
-   * @deprecated
    * 对依赖作用域进行微调
    */
   transformDeps?: IScopeTransformer;
 
   /**
-   * @deprecated
    * 对依赖作用域进行微调
    */
   transformCovers?: IScopeTransformer;