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
This commit is contained in:
Yiwei Mao 2025-07-01 11:47:52 +08:00 committed by GitHub
parent 8fa571af9e
commit 93aa3e77b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 971 additions and 204 deletions

View File

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

View File

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

View File

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

View File

@ -130,6 +130,20 @@ export const initialData: FlowDocumentJSON = {
}, },
data: { data: {
title: 'Loop_1', 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: [ blocks: [
{ {

View File

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

View File

@ -1,6 +1,6 @@
import { FormRenderProps, FlowNodeJSON, Field } from '@flowgram.ai/free-layout-editor'; import { FormRenderProps, FlowNodeJSON, Field } from '@flowgram.ai/free-layout-editor';
import { SubCanvasRender } from '@flowgram.ai/free-container-plugin'; 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 { useIsSidebar, useNodeRenderContext } from '../../hooks';
import { FormHeader, FormContent, FormOutputs, FormItem, Feedback } from '../../form-components'; import { FormHeader, FormContent, FormOutputs, FormItem, Feedback } from '../../form-components';
@ -33,12 +33,30 @@ export const LoopFormRender = ({ form }: FormRenderProps<LoopNodeJSON>) => {
</Field> </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) { if (isSidebar) {
return ( return (
<> <>
<FormHeader /> <FormHeader />
<FormContent> <FormContent>
{batchFor} {batchFor}
{batchOutputs}
<FormOutputs /> <FormOutputs />
</FormContent> </FormContent>
</> </>

View File

@ -17,7 +17,7 @@ export interface Material {
[key: string]: any; // For other properties from config.json [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[] { export function listAllMaterials(): Material[] {
const _materials: Material[] = []; const _materials: Material[] = [];

View File

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

View File

@ -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>
);
}

View File

@ -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;
`;

View File

@ -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;
}

View File

@ -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 };
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
},
});
},
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,23 +1,31 @@
import { nanoid } from 'nanoid'; import { nanoid } from 'nanoid';
import { Disposable } from '@flowgram.ai/utils'; 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'; import { FormModelV2 } from './form-model-v2';
export interface FormPluginConfig<Opts = any> { export interface FormPluginConfig<Opts = any> {
/**
* form plugin name, for debug use
*/
name?: string;
/**
* setup formMeta
* @param ctx
* @returns
*/
onSetupFormMeta?: (ctx: FormPluginSetupMetaCtx, opts: Opts) => void;
/** /**
* FormModel * FormModel
* @param ctx * @param ctx
*/ */
onInit?: (ctx: FormPluginCtx, opts: Opts) => void; onInit?: (ctx: FormPluginCtx, opts: Opts) => void;
/**
* FormMeta effects FormMeta effects
*/
effect?: Record<string, EffectOptions[]>;
/** /**
* FormModel * FormModel
* @param ctx
*/ */
onDispose?: (ctx: FormPluginCtx, opts: Opts) => void; onDispose?: (ctx: FormPluginCtx, opts: Opts) => void;
} }
@ -33,10 +41,11 @@ export class FormPlugin<Opts = any> implements Disposable {
protected _formModel: FormModelV2; protected _formModel: FormModelV2;
constructor(name: string, config: FormPluginConfig, opts?: Opts) { constructor(config: FormPluginConfig, opts?: Opts) {
this.name = name; this.name = config?.name || '';
this.pluginId = `${name}__${nanoid()}`; this.pluginId = `${this.name}__${nanoid()}`;
this.config = config; this.config = config;
this.opts = opts; 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) { init(formModel: FormModelV2) {
this._formModel = formModel; this._formModel = formModel;
this.config?.onInit?.(this.ctx, this.opts); 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 function (opts: Opts) {
return new FormPlugin(name, config, opts); return new FormPlugin(config, opts);
}; };
} }

View File

@ -138,6 +138,13 @@ export type FormPluginCtx = {
formModel: FormModelV2; formModel: FormModelV2;
} & NodeContext; } & 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> { export interface onFormValueChangeInPayload<TValue = FieldValue, TFormValues = FieldValue> {
value: TValue; value: TValue;
prevValue: TValue; prevValue: TValue;

View File

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

View File

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

View File

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

View File

@ -1,3 +1,12 @@
export * from './create-variable-plugin'; export * from './create-variable-plugin';
export * from '@flowgram.ai/variable-core'; 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';

View File

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

View File

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

View File

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

View File

@ -6,7 +6,9 @@ import {
merge, merge,
share, share,
skip, skip,
startWith,
switchMap, switchMap,
tap,
} from 'rxjs'; } from 'rxjs';
import { flatten } from 'lodash'; import { flatten } from 'lodash';
import { shallowEqual } from 'fast-equals'; import { shallowEqual } from 'fast-equals';
@ -17,7 +19,7 @@ import { IVariableTable } from '../types';
import { type Scope } from '../scope'; import { type Scope } from '../scope';
import { subsToDisposable } from '../../utils/toDisposable'; import { subsToDisposable } from '../../utils/toDisposable';
import { createMemo } from '../../utils/memo'; import { createMemo } from '../../utils/memo';
import { Property, VariableDeclaration } from '../../ast'; import { BaseVariableField, VariableDeclaration } from '../../ast';
/** /**
* *
*/ */
@ -135,11 +137,35 @@ export class ScopeAvailableData {
* @param keyPath * @param keyPath
* @returns * @returns
*/ */
getByKeyPath(keyPath: string[] = []): VariableDeclaration | Property | undefined { getByKeyPath(keyPath: string[] = []): BaseVariableField | undefined {
// 检查变量是否在可访问范围内 // 检查变量是否在可访问范围内
if (!this.variableKeys.includes(keyPath[0])) { if (!this.variableKeys.includes(keyPath[0])) {
return; return;
} }
return this.globalVariableTable.getByKeyPath(keyPath); 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)
);
}
} }

View File

@ -2,7 +2,7 @@ import { DisposableCollection } from '@flowgram.ai/utils';
import { type VariableEngine } from '../variable-engine'; import { type VariableEngine } from '../variable-engine';
import { createMemo } from '../utils/memo'; 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'; import { ScopeAvailableData, ScopeEventData, ScopeOutputData } from './datas';
export interface IScopeConstructor { export interface IScopeConstructor {
@ -117,4 +117,44 @@ export class Scope<ScopeMeta extends Record<string, any> = Record<string, any>>
get disposed(): boolean { get disposed(): boolean {
return this.toDispose.disposed; 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);
}
} }

View File

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

View File

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

View File

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

View File

@ -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",
]
`;

View File

@ -107,3 +107,111 @@ exports[`Variable Free Layout > test sort 1`] = `
"base_3_private", "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",
]
`;

View File

@ -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;
});
},
});

View File

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

View File

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

View File

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

View File

@ -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 { type ASTNode, ASTNodeJSON } from '@flowgram.ai/variable-core';
import { FlowNodeEntity } from '@flowgram.ai/document'; import { FlowNodeEntity } from '@flowgram.ai/document';
import { EntityData } from '@flowgram.ai/core'; import { EntityData } from '@flowgram.ai/core';
@ -179,4 +179,22 @@ export class FlowNodeVariableData extends EntityData {
} }
return this._private; 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);
}
} }

View File

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

View File

@ -1,62 +1,13 @@
import { injectable, interfaces } from 'inversify'; import { injectable, interfaces } from 'inversify';
import { ASTNode, ASTNodeJSON, Scope, VariableEngine } from '@flowgram.ai/variable-core'; import { Scope, VariableEngine } from '@flowgram.ai/variable-core';
@injectable() @injectable()
export class GlobalScope extends Scope { export class GlobalScope extends Scope {
static readonly ID = Symbol('GlobalScope'); static readonly ID = Symbol('GlobalScope');
static is(scope: Scope): scope is GlobalScope { static is(scope: Scope) {
return scope.id === GlobalScope.ID; 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) => { export const bindGlobalScope = (bind: interfaces.Bind) => {

View File

@ -14,11 +14,14 @@ export interface TransformerContext {
export type IScopeTransformer = (scopes: Scope[], ctx: TransformerContext) => Scope[]; export type IScopeTransformer = (scopes: Scope[], ctx: TransformerContext) => Scope[];
const passthrough: IScopeTransformer = (scopes, ctx) => scopes;
@injectable() @injectable()
export class ScopeChainTransformService { export class ScopeChainTransformService {
protected transformDepsFns: IScopeTransformer[] = []; protected transformerMap: Map<
string,
protected transformCoversFns: IScopeTransformer[] = []; { transformDeps: IScopeTransformer; transformCovers: IScopeTransformer }
> = new Map();
@lazyInject(FlowDocument) document: FlowDocument; @lazyInject(FlowDocument) document: FlowDocument;
@ -29,43 +32,65 @@ export class ScopeChainTransformService {
@inject(VariableLayoutConfig) @inject(VariableLayoutConfig)
protected configs?: VariableLayoutConfig protected configs?: VariableLayoutConfig
) { ) {
if (this.configs?.transformDeps) { if (this.configs?.transformDeps || this.configs?.transformCovers) {
this.transformDepsFns.push(this.configs.transformDeps); this.transformerMap.set('VARIABLE_LAYOUT_CONFIG', {
} transformDeps: this.configs.transformDeps || passthrough,
if (this.configs?.transformCovers) { transformCovers: this.configs.transformCovers || passthrough,
this.transformCoversFns.push(this.configs.transformCovers); });
} }
} }
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[] { transformDeps(scopes: Scope[], { scope }: { scope: Scope }): Scope[] {
return this.transformDepsFns.reduce( return Array.from(this.transformerMap.values()).reduce((scopes, transformer) => {
(scopes, transformer) => if (!transformer.transformDeps) {
transformer(scopes, { return scopes;
scope, }
document: this.document,
variableEngine: this.variableEngine, scopes = transformer.transformDeps(scopes, {
}), scope,
scopes document: this.document,
); variableEngine: this.variableEngine,
});
return scopes;
}, scopes);
} }
transformCovers(scopes: Scope[], { scope }: { scope: Scope }): Scope[] { transformCovers(scopes: Scope[], { scope }: { scope: Scope }): Scope[] {
return this.transformCoversFns.reduce( return Array.from(this.transformerMap.values()).reduce((scopes, transformer) => {
(scopes, transformer) => if (!transformer.transformCovers) {
transformer(scopes, { return scopes;
scope, }
document: this.document,
variableEngine: this.variableEngine, scopes = transformer.transformCovers(scopes, {
}), scope,
scopes document: this.document,
); variableEngine: this.variableEngine,
});
return scopes;
}, scopes);
} }
} }

View File

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

View File

@ -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();
}

View File

@ -12,19 +12,18 @@ export interface VariableLayoutConfig {
isNodeChildrenPrivate?: (node: ScopeChainNode) => boolean; isNodeChildrenPrivate?: (node: ScopeChainNode) => boolean;
/** /**
* 线 * inlineBlocks
* 线
*/ */
getFreeChildren?: (node: FlowNodeEntity) => FlowNodeEntity[]; getNodeChildren?: (node: FlowNodeEntity) => FlowNodeEntity[];
getFreeParent?: (node: FlowNodeEntity) => FlowNodeEntity | undefined; getNodeParent?: (node: FlowNodeEntity) => FlowNodeEntity | undefined;
/** /**
* @deprecated
* *
*/ */
transformDeps?: IScopeTransformer; transformDeps?: IScopeTransformer;
/** /**
* @deprecated
* *
*/ */
transformCovers?: IScopeTransformer; transformCovers?: IScopeTransformer;