From 93aa3e77b1707c46a5178c893e5afec4d14f4307 Mon Sep 17 00:00:00 2001 From: Yiwei Mao Date: Tue, 1 Jul 2025 11:47:52 +0800 Subject: [PATCH] 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 --- .../src/form-components/form-inputs/index.tsx | 4 +- .../src/form-components/form-item/index.tsx | 10 +- .../properties-edit/property-edit.tsx | 4 +- apps/demo-free-layout/src/initial-data.ts | 14 +++ apps/demo-free-layout/src/nodes/loop/index.ts | 3 +- .../src/nodes/loop/loop-form-render.tsx | 20 +++- .../materials/form-materials/bin/materials.ts | 2 +- .../src/components/batch-outputs/config.json | 12 ++ .../src/components/batch-outputs/index.tsx | 56 +++++++++ .../src/components/batch-outputs/styles.tsx | 14 +++ .../src/components/batch-outputs/types.ts | 17 +++ .../src/components/batch-outputs/use-list.ts | 81 +++++++++++++ .../components/dynamic-value-input/index.tsx | 1 + .../form-materials/src/components/index.ts | 1 + .../effects/provide-batch-outputs/index.ts | 1 - .../batch-outputs-plugin/config.json | 7 ++ .../batch-outputs-plugin/index.ts | 99 ++++++++++++++++ .../form-materials/src/form-plugins/index.ts | 1 + .../materials/form-materials/src/index.ts | 1 + .../form-core/src/form/models/form-model.ts | 4 +- .../src/form/types/form-meta.types.ts | 6 +- .../node/__tests__/form-model-v2.test.ts | 63 +++++----- .../node-engine/node/src/form-model-v2.ts | 24 ++-- packages/node-engine/node/src/form-plugin.ts | 76 ++++++++++-- packages/node-engine/node/src/types.ts | 7 ++ .../src/form-v2/create-provider-effect.ts | 8 +- .../create-variable-provider-plugin.ts | 23 ++-- .../plugins/node-variable-plugin/src/types.ts | 6 +- packages/plugins/variable-plugin/src/index.ts | 11 +- .../variable-core/src/ast/ast-node.ts | 26 ++--- .../ast/declaration/base-variable-field.ts | 10 +- .../ast/expression/wrap-array-expression.ts | 4 + .../src/scope/datas/scope-available-data.ts | 30 ++++- .../variable-core/src/scope/scope.ts | 42 ++++++- .../variable-layout/__mocks__/container.ts | 6 +- .../__mocks__/run-fixed-layout-test.ts | 2 + .../__mocks__/run-free-layout-test.ts | 2 + ...le-fix-layout-transform-empty.test.ts.snap | 109 ++++++++++++++++++ ...e-free-layout-transform-empty.test.ts.snap | 108 +++++++++++++++++ ...ariable-fix-layout-transform-empty.test.ts | 34 ++++++ ...riable-free-layout-transform-empty.test.ts | 11 +- .../src/chains/fixed-layout-scope-chain.ts | 4 +- .../src/chains/free-layout-scope-chain.ts | 30 ++--- .../src/flow-node-variable-data.ts | 20 +++- .../variable-layout/src/index.ts | 1 + .../src/scopes/global-scope.ts | 53 +-------- .../services/scope-chain-transform-service.ts | 85 +++++++++----- .../variable-layout/src/types.ts | 2 +- .../variable-layout/src/utils.ts | 11 ++ .../src/variable-layout-config.ts | 9 +- 50 files changed, 971 insertions(+), 204 deletions(-) create mode 100644 packages/materials/form-materials/src/components/batch-outputs/config.json create mode 100644 packages/materials/form-materials/src/components/batch-outputs/index.tsx create mode 100644 packages/materials/form-materials/src/components/batch-outputs/styles.tsx create mode 100644 packages/materials/form-materials/src/components/batch-outputs/types.ts create mode 100644 packages/materials/form-materials/src/components/batch-outputs/use-list.ts create mode 100644 packages/materials/form-materials/src/form-plugins/batch-outputs-plugin/config.json create mode 100644 packages/materials/form-materials/src/form-plugins/batch-outputs-plugin/index.ts create mode 100644 packages/materials/form-materials/src/form-plugins/index.ts create mode 100644 packages/variable-engine/variable-layout/__tests__/__snapshots__/variable-fix-layout-transform-empty.test.ts.snap create mode 100644 packages/variable-engine/variable-layout/__tests__/variable-fix-layout-transform-empty.test.ts create mode 100644 packages/variable-engine/variable-layout/src/utils.ts diff --git a/apps/demo-free-layout/src/form-components/form-inputs/index.tsx b/apps/demo-free-layout/src/form-components/form-inputs/index.tsx index 765a6f08..691fe36b 100644 --- a/apps/demo-free-layout/src/form-components/form-inputs/index.tsx +++ b/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, + }} /> diff --git a/apps/demo-free-layout/src/form-components/form-item/index.tsx b/apps/demo-free-layout/src/form-components/form-item/index.tsx index 698324e5..9d70ff03 100644 --- a/apps/demo-free-layout/src/form-components/form-item/index.tsx +++ b/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', + }), }} >
= (props) => { updateProperty('default', val)} - schema={value} + constantProps={{ + schema: value, + }} style={{ flexGrow: 1 }} /> } diff --git a/apps/demo-free-layout/src/initial-data.ts b/apps/demo-free-layout/src/initial-data.ts index 1f17e3cb..fd38b4d7 100644 --- a/apps/demo-free-layout/src/initial-data.ts +++ b/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: [ { diff --git a/apps/demo-free-layout/src/nodes/loop/index.ts b/apps/demo-free-layout/src/nodes/loop/index.ts index fad3936b..e9519989 100644 --- a/apps/demo-free-layout/src/nodes/loop/index.ts +++ b/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' })], }, }; diff --git a/apps/demo-free-layout/src/nodes/loop/loop-form-render.tsx b/apps/demo-free-layout/src/nodes/loop/loop-form-render.tsx index 97456965..420a287e 100644 --- a/apps/demo-free-layout/src/nodes/loop/loop-form-render.tsx +++ b/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) => { ); + const batchOutputs = ( + | undefined> name={`batchOutputs`}> + {({ field, fieldState }) => ( + + field.onChange(val)} + readonly={readonly} + hasError={Object.keys(fieldState?.errors || {}).length > 0} + /> + + + )} + + ); + if (isSidebar) { return ( <> {batchFor} + {batchOutputs} diff --git a/packages/materials/form-materials/bin/materials.ts b/packages/materials/form-materials/bin/materials.ts index 6ace84e8..783a8c9d 100644 --- a/packages/materials/form-materials/bin/materials.ts +++ b/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[] = []; diff --git a/packages/materials/form-materials/src/components/batch-outputs/config.json b/packages/materials/form-materials/src/components/batch-outputs/config.json new file mode 100644 index 00000000..42d25c63 --- /dev/null +++ b/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" + ] +} diff --git a/packages/materials/form-materials/src/components/batch-outputs/index.tsx b/packages/materials/form-materials/src/components/batch-outputs/index.tsx new file mode 100644 index 00000000..f2791a32 --- /dev/null +++ b/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 ( +
+ + {list.map((item) => ( + + update({ ...item, key: v })} + /> + + update({ + ...item, + value: { + type: 'ref', + content: v, + }, + }) + } + /> + +
+ ); +} diff --git a/packages/materials/form-materials/src/components/batch-outputs/styles.tsx b/packages/materials/form-materials/src/components/batch-outputs/styles.tsx new file mode 100644 index 00000000..07b5595e --- /dev/null +++ b/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; +`; diff --git a/packages/materials/form-materials/src/components/batch-outputs/types.ts b/packages/materials/form-materials/src/components/batch-outputs/types.ts new file mode 100644 index 00000000..d39c904a --- /dev/null +++ b/packages/materials/form-materials/src/components/batch-outputs/types.ts @@ -0,0 +1,17 @@ +import { IFlowRefValue } from '../../typings'; + +export type ValueType = Record; + +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; +} diff --git a/packages/materials/form-materials/src/components/batch-outputs/use-list.ts b/packages/materials/form-materials/src/components/batch-outputs/use-list.ts new file mode 100644 index 00000000..541a3a1a --- /dev/null +++ b/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([]); + + 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 }; +} diff --git a/packages/materials/form-materials/src/components/dynamic-value-input/index.tsx b/packages/materials/form-materials/src/components/dynamic-value-input/index.tsx index c84847d4..3d7f62c6 100644 --- a/packages/materials/form-materials/src/components/dynamic-value-input/index.tsx +++ b/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; }; } diff --git a/packages/materials/form-materials/src/components/index.ts b/packages/materials/form-materials/src/components/index.ts index 019f2934..036087d7 100644 --- a/packages/materials/form-materials/src/components/index.ts +++ b/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'; diff --git a/packages/materials/form-materials/src/effects/provide-batch-outputs/index.ts b/packages/materials/form-materials/src/effects/provide-batch-outputs/index.ts index c392cd4d..24f7d1b9 100644 --- a/packages/materials/form-materials/src/effects/provide-batch-outputs/index.ts +++ b/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, ctx) => [ ASTFactory.createVariableDeclaration({ key: `${ctx.node.id}`, diff --git a/packages/materials/form-materials/src/form-plugins/batch-outputs-plugin/config.json b/packages/materials/form-materials/src/form-plugins/batch-outputs-plugin/config.json new file mode 100644 index 00000000..e3c554e2 --- /dev/null +++ b/packages/materials/form-materials/src/form-plugins/batch-outputs-plugin/config.json @@ -0,0 +1,7 @@ +{ + "name": "batch-outputs-plugin", + "depMaterials": [ + "flow-value" + ], + "depPackages": [] +} diff --git a/packages/materials/form-materials/src/form-plugins/batch-outputs-plugin/index.ts b/packages/materials/form-materials/src/form-plugins/batch-outputs-plugin/index.ts new file mode 100644 index 00000000..75a00e19 --- /dev/null +++ b/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, ctx) => [ + ASTFactory.createVariableDeclaration({ + key: `${ctx.node.id}`, + meta: { + title: getNodeForm(ctx.node)?.getValueIn('title'), + icon: ctx.node.getNodeRegistry().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; + }, + }); + }, + }); diff --git a/packages/materials/form-materials/src/form-plugins/index.ts b/packages/materials/form-materials/src/form-plugins/index.ts new file mode 100644 index 00000000..661d5188 --- /dev/null +++ b/packages/materials/form-materials/src/form-plugins/index.ts @@ -0,0 +1 @@ +export { createBatchOutputsFormPlugin } from './batch-outputs-plugin'; diff --git a/packages/materials/form-materials/src/index.ts b/packages/materials/form-materials/src/index.ts index ef5b7a92..d1ea22af 100644 --- a/packages/materials/form-materials/src/index.ts +++ b/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'; diff --git a/packages/node-engine/form-core/src/form/models/form-model.ts b/packages/node-engine/form-core/src/form/models/form-model.ts index 1161cd43..ed086823 100644 --- a/packages/node-engine/form-core/src/form/models/form-model.ts +++ b/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; diff --git a/packages/node-engine/form-core/src/form/types/form-meta.types.ts b/packages/node-engine/form-core/src/form/types/form-meta.types.ts index 2047fb1d..a0541b00 100644 --- a/packages/node-engine/form-core/src/form/types/form-meta.types.ts +++ b/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 { } export type FormMetaGenerator = ( - params: FormMetaGeneratorParams, + params: FormMetaGeneratorParams ) => MaybePromise; export type FormMetaOrFormMetaGenerator = FormMetaGenerator | IFormMeta; diff --git a/packages/node-engine/node/__tests__/form-model-v2.test.ts b/packages/node-engine/node/__tests__/form-model-v2.test.ts index 0f3c0165..ab61bd70 100644 --- a/packages/node-engine/node/__tests__/form-model-v2.test.ts +++ b/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 }); diff --git a/packages/node-engine/node/src/form-model-v2.ts b/packages/node-engine/node/src/form-model-v2.ts index 43beeff6..42bbd9b0 100644 --- a/packages/node-engine/node/src/form-model-v2.ts +++ b/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; diff --git a/packages/node-engine/node/src/form-plugin.ts b/packages/node-engine/node/src/form-plugin.ts index 688ef76a..97155639 100644 --- a/packages/node-engine/node/src/form-plugin.ts +++ b/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 { + /** + * form plugin name, for debug use + */ + name?: string; + + /** + * setup formMeta + * @param ctx + * @returns + */ + onSetupFormMeta?: (ctx: FormPluginSetupMetaCtx, opts: Opts) => void; + /** * FormModel 初始化时执行 * @param ctx */ onInit?: (ctx: FormPluginCtx, opts: Opts) => void; - /** - * 同 FormMeta 中的effects 会与 FormMeta 中的effects 合并 - */ - effect?: Record; /** * FormModel 销毁时执行 - * @param ctx */ onDispose?: (ctx: FormPluginCtx, opts: Opts) => void; } @@ -33,10 +41,11 @@ export class FormPlugin 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 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 implements Disposable { } } -export function defineFormPluginCreator(name: string, config: FormPluginConfig) { +export type FormPluginCreator = (opts: Opts) => FormPlugin; + +export function defineFormPluginCreator(config: FormPluginConfig): FormPluginCreator { return function (opts: Opts) { - return new FormPlugin(name, config, opts); + return new FormPlugin(config, opts); }; } diff --git a/packages/node-engine/node/src/types.ts b/packages/node-engine/node/src/types.ts index e7fcf474..0b0ec1fb 100644 --- a/packages/node-engine/node/src/types.ts +++ b/packages/node-engine/node/src/types.ts @@ -138,6 +138,13 @@ export type FormPluginCtx = { formModel: FormModelV2; } & NodeContext; +export type FormPluginSetupMetaCtx = { + mergeEffect: (effect: Record) => void; + mergeValidate: (validate: Record) => void; + addFormatOnInit: (formatOnInit: FormMeta['formatOnInit']) => void; + addFormatOnSubmit: (formatOnSubmit: FormMeta['formatOnSubmit']) => void; +} & NodeContext; + export interface onFormValueChangeInPayload { value: TValue; prevValue: TValue; diff --git a/packages/plugins/node-variable-plugin/src/form-v2/create-provider-effect.ts b/packages/plugins/node-variable-plugin/src/form-v2/create-provider-effect.ts index e8452907..9a9c9cf7 100644 --- a/packages/plugins/node-variable-plugin/src/form-v2/create-provider-effect.ts +++ b/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, }, diff --git a/packages/plugins/node-variable-plugin/src/form-v2/create-variable-provider-plugin.ts b/packages/plugins/node-variable-plugin/src/form-v2/create-variable-provider-plugin.ts index 0917b1e7..bb8c9e99 100644 --- a/packages/plugins/node-variable-plugin/src/form-v2/create-variable-provider-plugin.ts +++ b/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 diff --git a/packages/plugins/node-variable-plugin/src/types.ts b/packages/plugins/node-variable-plugin/src/types.ts index 90332c94..a76aeb4f 100644 --- a/packages/plugins/node-variable-plugin/src/types.ts +++ b/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 { // 变量提供能力可复用 diff --git a/packages/plugins/variable-plugin/src/index.ts b/packages/plugins/variable-plugin/src/index.ts index 04d39d92..96877c80 100644 --- a/packages/plugins/variable-plugin/src/index.ts +++ b/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'; diff --git a/packages/variable-engine/variable-core/src/ast/ast-node.ts b/packages/variable-engine/variable-core/src/ast/ast-node.ts index f896182a..c738fc5a 100644 --- a/packages/variable-engine/variable-core/src/ast/ast-node.ts +++ b/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 /** * 节点的版本号,每 fireChange 一次 version + 1 */ - private _version: number = 0; + protected _version: number = 0; /** * 更新锁 @@ -109,8 +109,8 @@ export abstract class ASTNode 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 child.toDispose.push( Disposable.create(() => { this._children.delete(child); - }), + }) ); return child; @@ -204,7 +204,7 @@ export abstract class ASTNode 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 * @returns */ protected withBatchUpdate( - updater: (...args: ParamTypes) => ReturnType, + updater: (...args: ParamTypes) => ReturnType ) { return (...args: ParamTypes) => { // batchUpdate 里面套 batchUpdate 只能生效一次 @@ -281,7 +281,7 @@ export abstract class ASTNode */ subscribe( observer: ObserverOrNext, - { selector, debounceAnimation, triggerOnInit }: SubscribeConfig = {}, + { selector, debounceAnimation, triggerOnInit }: SubscribeConfig = {} ): Disposable { return subsToDisposable( this.value$ @@ -289,25 +289,25 @@ export abstract class ASTNode 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( - event: Omit, + event: Omit ) { this.scope.event.dispatch({ ...event, diff --git a/packages/variable-engine/variable-core/src/ast/declaration/base-variable-field.ts b/packages/variable-engine/variable-core/src/ast/declaration/base-variable-field.ts index 8e6dc0c9..28961f01 100644 --- a/packages/variable-engine/variable-core/src/ast/declaration/base-variable-field.ts +++ b/packages/variable-engine/variable-core/src/ast/declaration/base-variable-field.ts @@ -35,6 +35,10 @@ export abstract class BaseVariableField 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 extends ASTNode< return this._initializer; } + get hash(): string { + return `[${this._version}]${this.keyPath.join('.')}`; + } + /** * 解析 VariableDeclarationJSON 从而生成变量声明节点 */ @@ -96,7 +104,7 @@ export abstract class BaseVariableField 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 }); } /** diff --git a/packages/variable-engine/variable-core/src/ast/expression/wrap-array-expression.ts b/packages/variable-engine/variable-core/src/ast/expression/wrap-array-expression.ts index 65184051..5fb3d863 100644 --- a/packages/variable-engine/variable-core/src/ast/expression/wrap-array-expression.ts +++ b/packages/variable-engine/variable-core/src/ast/expression/wrap-array-expression.ts @@ -28,6 +28,7 @@ export class WrapArrayExpression extends BaseExpression refreshReturnType() { // 被遍历表达式的返回值 const childReturnTypeJSON = this.wrapFor?.returnType?.toJSON(); + this.updateChildNodeByKey('_returnType', { kind: ASTKind.Array, items: childReturnTypeJSON, @@ -51,9 +52,12 @@ export class WrapArrayExpression extends BaseExpression @postConstructAST() protected init() { + this.refreshReturnType = this.refreshReturnType.bind(this); + this.toDispose.push( this.subscribe(this.refreshReturnType, { selector: (curr) => curr.wrapFor?.returnType, + triggerOnInit: true, }) ); } diff --git a/packages/variable-engine/variable-core/src/scope/datas/scope-available-data.ts b/packages/variable-engine/variable-core/src/scope/datas/scope-available-data.ts index 7a1bfef3..09d227b4 100644 --- a/packages/variable-engine/variable-core/src/scope/datas/scope-available-data.ts +++ b/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) + ); + } } diff --git a/packages/variable-engine/variable-core/src/scope/scope.ts b/packages/variable-engine/variable-core/src/scope/scope.ts index d1432823..8c95a7ff 100644 --- a/packages/variable-engine/variable-core/src/scope/scope.ts +++ b/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 = Record> 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); + } } diff --git a/packages/variable-engine/variable-layout/__mocks__/container.ts b/packages/variable-engine/variable-layout/__mocks__/container.ts index 95878b81..a1bb2052 100644 --- a/packages/variable-engine/variable-layout/__mocks__/container.ts +++ b/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; } diff --git a/packages/variable-engine/variable-layout/__mocks__/run-fixed-layout-test.ts b/packages/variable-engine/variable-layout/__mocks__/run-fixed-layout-test.ts index 0a4bee85..88dbddb6 100644 --- a/packages/variable-engine/variable-layout/__mocks__/run-fixed-layout-test.ts +++ b/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); }); diff --git a/packages/variable-engine/variable-layout/__mocks__/run-free-layout-test.ts b/packages/variable-engine/variable-layout/__mocks__/run-free-layout-test.ts index b1bb6ee9..2863f159 100644 --- a/packages/variable-engine/variable-layout/__mocks__/run-free-layout-test.ts +++ b/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); }); diff --git a/packages/variable-engine/variable-layout/__tests__/__snapshots__/variable-fix-layout-transform-empty.test.ts.snap b/packages/variable-engine/variable-layout/__tests__/__snapshots__/variable-fix-layout-transform-empty.test.ts.snap new file mode 100644 index 00000000..2a9717ac --- /dev/null +++ b/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", +] +`; diff --git a/packages/variable-engine/variable-layout/__tests__/__snapshots__/variable-free-layout-transform-empty.test.ts.snap b/packages/variable-engine/variable-layout/__tests__/__snapshots__/variable-free-layout-transform-empty.test.ts.snap index 591d1479..8b028bef 100644 --- a/packages/variable-engine/variable-layout/__tests__/__snapshots__/variable-free-layout-transform-empty.test.ts.snap +++ b/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", +] +`; diff --git a/packages/variable-engine/variable-layout/__tests__/variable-fix-layout-transform-empty.test.ts b/packages/variable-engine/variable-layout/__tests__/variable-fix-layout-transform-empty.test.ts new file mode 100644 index 00000000..0b8b5bd1 --- /dev/null +++ b/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; + }); + }, +}); diff --git a/packages/variable-engine/variable-layout/__tests__/variable-free-layout-transform-empty.test.ts b/packages/variable-engine/variable-layout/__tests__/variable-free-layout-transform-empty.test.ts index 1bb566b6..0d1f5952 100644 --- a/packages/variable-engine/variable-layout/__tests__/variable-free-layout-transform-empty.test.ts +++ b/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; + }); + }, }); diff --git a/packages/variable-engine/variable-layout/src/chains/fixed-layout-scope-chain.ts b/packages/variable-engine/variable-layout/src/chains/fixed-layout-scope-chain.ts index 4817f6fa..7881a119 100644 --- a/packages/variable-engine/variable-layout/src/chains/fixed-layout-scope-chain.ts +++ b/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; diff --git a/packages/variable-engine/variable-layout/src/chains/free-layout-scope-chain.ts b/packages/variable-engine/variable-layout/src/chains/free-layout-scope-chain.ts index f92e1eba..d49391c0 100644 --- a/packages/variable-engine/variable-layout/src/chains/free-layout-scope-chain.ts +++ b/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(); 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); diff --git a/packages/variable-engine/variable-layout/src/flow-node-variable-data.ts b/packages/variable-engine/variable-layout/src/flow-node-variable-data.ts index 493a4675..f5f71f84 100644 --- a/packages/variable-engine/variable-layout/src/flow-node-variable-data.ts +++ b/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); + } } diff --git a/packages/variable-engine/variable-layout/src/index.ts b/packages/variable-engine/variable-layout/src/index.ts index ba431ecf..998c1c8b 100644 --- a/packages/variable-engine/variable-layout/src/index.ts +++ b/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'; diff --git a/packages/variable-engine/variable-layout/src/scopes/global-scope.ts b/packages/variable-engine/variable-layout/src/scopes/global-scope.ts index 8c2dce74..e47654f0 100644 --- a/packages/variable-engine/variable-layout/src/scopes/global-scope.ts +++ b/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) => { diff --git a/packages/variable-engine/variable-layout/src/services/scope-chain-transform-service.ts b/packages/variable-engine/variable-layout/src/services/scope-chain-transform-service.ts index 37fb5818..0a71eee3 100644 --- a/packages/variable-engine/variable-layout/src/services/scope-chain-transform-service.ts +++ b/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); } } diff --git a/packages/variable-engine/variable-layout/src/types.ts b/packages/variable-engine/variable-layout/src/types.ts index 80c4b679..caa08c73 100644 --- a/packages/variable-engine/variable-layout/src/types.ts +++ b/packages/variable-engine/variable-layout/src/types.ts @@ -19,4 +19,4 @@ export interface ScopeVirtualNode { export type ScopeChainNode = FlowNodeEntity | ScopeVirtualNode; // 节点内部的作用域 -export type FlowNodeScope = Scope; +export interface FlowNodeScope extends Scope {} diff --git a/packages/variable-engine/variable-layout/src/utils.ts b/packages/variable-engine/variable-layout/src/utils.ts new file mode 100644 index 00000000..422806d3 --- /dev/null +++ b/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(); +} diff --git a/packages/variable-engine/variable-layout/src/variable-layout-config.ts b/packages/variable-engine/variable-layout/src/variable-layout-config.ts index 94b43aa0..d80046ba 100644 --- a/packages/variable-engine/variable-layout/src/variable-layout-config.ts +++ b/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;