Bläddra i källkod

feat(material): variable assign (#618)

Yiwei Mao 5 månader sedan
förälder
incheckning
a0ad1b833d

+ 1 - 0
apps/demo-free-layout/src/nodes/constants.ts

@@ -9,6 +9,7 @@ export enum WorkflowNodeType {
   LLM = 'llm',
   HTTP = 'http',
   Code = 'code',
+  Variable = 'variable',
   Condition = 'condition',
   Loop = 'loop',
   BlockStart = 'block-start',

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

@@ -4,6 +4,7 @@
  */
 
 import { FlowNodeRegistry } from '../typings';
+import { VariableNodeRegistry } from './variable';
 import { StartNodeRegistry } from './start';
 import { LoopNodeRegistry } from './loop';
 import { LLMNodeRegistry } from './llm';
@@ -31,4 +32,5 @@ export const nodeRegistries: FlowNodeRegistry[] = [
   CodeNodeRegistry,
   ContinueNodeRegistry,
   BreakNodeRegistry,
+  VariableNodeRegistry,
 ];

+ 36 - 0
apps/demo-free-layout/src/nodes/variable/form-meta.tsx

@@ -0,0 +1,36 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { FormMeta, FormRenderProps } from '@flowgram.ai/free-layout-editor';
+import { AssignRows, createInferAssignPlugin, DisplayOutputs } from '@flowgram.ai/form-materials';
+
+import { FormHeader, FormContent } from '../../form-components';
+import { VariableNodeJSON } from './types';
+import { defaultFormMeta } from '../default-form-meta';
+import { useIsSidebar } from '../../hooks';
+
+export const FormRender = ({ form }: FormRenderProps<VariableNodeJSON>) => {
+  const isSidebar = useIsSidebar();
+
+  return (
+    <>
+      <FormHeader />
+      <FormContent>
+        {isSidebar ? <AssignRows name="assign" /> : <DisplayOutputs displayFromScope />}
+      </FormContent>
+    </>
+  );
+};
+
+export const formMeta: FormMeta = {
+  render: (props) => <FormRender {...props} />,
+  effect: defaultFormMeta.effect,
+  plugins: [
+    createInferAssignPlugin({
+      assignKey: 'assign',
+      outputKey: 'outputs',
+    }),
+  ],
+};

+ 48 - 0
apps/demo-free-layout/src/nodes/variable/index.tsx

@@ -0,0 +1,48 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { nanoid } from 'nanoid';
+
+import { WorkflowNodeType } from '../constants';
+import { FlowNodeRegistry } from '../../typings';
+import iconVariable from '../../assets/icon-variable.png';
+import { formMeta } from './form-meta';
+
+let index = 0;
+
+export const VariableNodeRegistry: FlowNodeRegistry = {
+  type: WorkflowNodeType.Variable,
+  info: {
+    icon: iconVariable,
+    description: 'Variable Assign and Declaration',
+  },
+  meta: {
+    size: {
+      width: 360,
+      height: 390,
+    },
+  },
+  onAdd() {
+    return {
+      id: `variable_${nanoid(5)}`,
+      type: 'variable',
+      data: {
+        title: `Variable_${++index}`,
+        assign: [
+          {
+            operator: 'declare',
+            left: 'sum',
+            right: {
+              type: 'constant',
+              content: 0,
+              schema: { type: 'integer' },
+            },
+          },
+        ],
+      },
+    };
+  },
+  formMeta: formMeta,
+};

+ 15 - 0
apps/demo-free-layout/src/nodes/variable/types.tsx

@@ -0,0 +1,15 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { FlowNodeJSON } from '@flowgram.ai/free-layout-editor';
+import { IFlowValue, IJsonSchema } from '@flowgram.ai/form-materials';
+
+export interface VariableNodeJSON extends FlowNodeJSON {
+  data: {
+    title: string;
+    assign: AssignValueType[];
+    outputs: IJsonSchema<'object'>;
+  };
+}

+ 27 - 0
packages/materials/form-materials/src/components/assign-row/components/blur-input.tsx

@@ -0,0 +1,27 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import React, { useEffect, useState } from 'react';
+
+import Input, { InputProps } from '@douyinfe/semi-ui/lib/es/input';
+
+export function BlurInput(props: InputProps) {
+  const [value, setValue] = useState('');
+
+  useEffect(() => {
+    setValue(props.value as string);
+  }, [props.value]);
+
+  return (
+    <Input
+      {...props}
+      value={value}
+      onChange={(value) => {
+        setValue(value);
+      }}
+      onBlur={(e) => props.onChange?.(value, e)}
+    />
+  );
+}

+ 11 - 0
packages/materials/form-materials/src/components/assign-row/config.json

@@ -0,0 +1,11 @@
+{
+  "name": "assign-row",
+  "depMaterials": [
+    "flow-value",
+    "dynamic-value-input",
+    "variable-selector"
+  ],
+  "depPackages": [
+    "@douyinfe/semi-ui"
+  ]
+}

+ 84 - 0
packages/materials/form-materials/src/components/assign-row/index.tsx

@@ -0,0 +1,84 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import React from 'react';
+
+import { IconButton } from '@douyinfe/semi-ui';
+import { IconMinus } from '@douyinfe/semi-icons';
+
+import { IFlowConstantRefValue } from '@/typings';
+
+import { AssignRowProps } from './types';
+import { BlurInput } from './components/blur-input';
+import { VariableSelector } from '../variable-selector';
+import { DynamicValueInput } from '../dynamic-value-input';
+
+export function AssignRow(props: AssignRowProps) {
+  const {
+    value = {
+      operator: 'assign',
+    },
+    onChange,
+    onDelete,
+    readonly,
+  } = props;
+
+  return (
+    <div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
+      <div style={{ width: 150, minWidth: 150, maxWidth: 150 }}>
+        {value?.operator === 'assign' ? (
+          <VariableSelector
+            style={{ width: '100%', height: 26 }}
+            value={value?.left?.content}
+            config={{ placeholder: 'Select Left' }}
+            onChange={(v) =>
+              onChange?.({
+                ...value,
+                left: { type: 'ref', content: v },
+              })
+            }
+          />
+        ) : (
+          <BlurInput
+            style={{ height: 26 }}
+            size="small"
+            placeholder="Input Name"
+            value={value?.left}
+            onChange={(v) =>
+              onChange?.({
+                ...value,
+                left: v,
+              })
+            }
+          />
+        )}
+      </div>
+      <div style={{ flexGrow: 1 }}>
+        <DynamicValueInput
+          readonly={readonly}
+          value={value?.right as IFlowConstantRefValue | undefined}
+          onChange={(v) =>
+            onChange?.({
+              ...value,
+              right: v,
+            })
+          }
+        />
+      </div>
+      {onDelete && (
+        <div>
+          <IconButton
+            size="small"
+            theme="borderless"
+            icon={<IconMinus />}
+            onClick={() => onDelete?.()}
+          />
+        </div>
+      )}
+    </div>
+  );
+}
+
+export { AssignValueType } from './types';

+ 25 - 0
packages/materials/form-materials/src/components/assign-row/types.ts

@@ -0,0 +1,25 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { IFlowRefValue, IFlowValue } from '@/typings';
+
+export type AssignValueType =
+  | {
+      operator: 'assign';
+      left?: IFlowRefValue;
+      right?: IFlowValue;
+    }
+  | {
+      operator: 'declare';
+      left?: string;
+      right?: IFlowValue;
+    };
+
+export interface AssignRowProps {
+  value?: AssignValueType;
+  onChange?: (value?: AssignValueType) => void;
+  onDelete?: () => void;
+  readonly?: boolean;
+}

+ 11 - 0
packages/materials/form-materials/src/components/assign-rows/config.json

@@ -0,0 +1,11 @@
+{
+  "name": "assign-rows",
+  "depMaterials": [
+    "flow-value",
+    "dynamic-value-input",
+    "variable-selector"
+  ],
+  "depPackages": [
+    "@douyinfe/semi-ui"
+  ]
+}

+ 59 - 0
packages/materials/form-materials/src/components/assign-rows/index.tsx

@@ -0,0 +1,59 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import React from 'react';
+
+import { FieldArray, FieldArrayRenderProps } from '@flowgram.ai/editor';
+import { Button } from '@douyinfe/semi-ui';
+import { IconPlus } from '@douyinfe/semi-icons';
+
+import { AssignRow, AssignValueType } from '../assign-row';
+
+interface AssignRowsProps {
+  name: string;
+  readonly?: boolean;
+}
+
+export function AssignRows(props: AssignRowsProps) {
+  const { name, readonly } = props;
+
+  return (
+    <FieldArray name={name}>
+      {({ field }: FieldArrayRenderProps<AssignValueType | undefined>) => (
+        <>
+          {field.map((childField, index) => (
+            <AssignRow
+              key={childField.key}
+              readonly={readonly}
+              value={childField.value}
+              onChange={(value) => {
+                childField.onChange(value);
+              }}
+              onDelete={() => field.remove(index)}
+            />
+          ))}
+          <div style={{ display: 'flex', gap: 5 }}>
+            <Button
+              size="small"
+              theme="borderless"
+              icon={<IconPlus />}
+              onClick={() => field.append({ operator: 'assign' })}
+            >
+              Assign
+            </Button>
+            <Button
+              size="small"
+              theme="borderless"
+              icon={<IconPlus />}
+              onClick={() => field.append({ operator: 'declare' })}
+            >
+              Declaration
+            </Button>
+          </div>
+        </>
+      )}
+    </FieldArray>
+  );
+}

+ 7 - 1
packages/materials/form-materials/src/components/display-outputs/index.tsx

@@ -51,7 +51,13 @@ export function DisplayOutputs({ value, showIconInTree, displayFromScope }: Prop
   return (
     <DisplayOutputsWrapper>
       {childEntries.map(([key, schema]) => (
-        <DisplaySchemaTag key={key} title={key} value={schema} showIconInTree={showIconInTree} />
+        <DisplaySchemaTag
+          key={key}
+          title={key}
+          value={schema}
+          showIconInTree={showIconInTree}
+          warning={!schema}
+        />
       ))}
     </DisplayOutputsWrapper>
   );

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

@@ -23,3 +23,5 @@ export * from './display-outputs';
 export * from './display-schema-tag';
 export * from './display-flow-value';
 export * from './display-inputs-values';
+export * from './assign-rows';
+export * from './assign-row';

+ 1 - 1
packages/materials/form-materials/src/components/variable-selector/index.tsx

@@ -130,7 +130,7 @@ export const VariableSelector = ({
         showClear={false}
         arrowIcon={<IconChevronDownStroked size="small" />}
         triggerRender={triggerRender}
-        placeholder={config?.placeholder ?? 'Select Variable...'}
+        placeholder={config?.placeholder ?? 'Select Variable'}
       />
     </>
   );

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

@@ -5,3 +5,4 @@
 
 export * from './batch-outputs-plugin';
 export * from './infer-inputs-plugin';
+export * from './infer-assign-plugin';

+ 7 - 0
packages/materials/form-materials/src/form-plugins/infer-assign-plugin/config.json

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

+ 90 - 0
packages/materials/form-materials/src/form-plugins/infer-assign-plugin/index.ts

@@ -0,0 +1,90 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { set, uniqBy } from 'lodash';
+import { JsonSchemaUtils } from '@flowgram.ai/json-schema';
+import {
+  ASTFactory,
+  createEffectFromVariableProvider,
+  defineFormPluginCreator,
+  FlowNodeRegistry,
+  getNodeForm,
+  getNodeScope,
+} from '@flowgram.ai/editor';
+
+import { IFlowRefValue, IFlowValue } from '../../typings';
+
+type AssignValueType =
+  | {
+      operator: 'assign';
+      left?: IFlowRefValue;
+      right?: IFlowValue;
+    }
+  | {
+      operator: 'declare';
+      left?: string;
+      right?: IFlowValue;
+    };
+
+interface InputConfig {
+  assignKey: string;
+  outputKey: string;
+}
+
+export const createInferAssignPlugin = defineFormPluginCreator<InputConfig>({
+  onSetupFormMeta({ addFormatOnSubmit, mergeEffect }, { assignKey, outputKey }) {
+    if (!assignKey || !outputKey) {
+      return;
+    }
+
+    mergeEffect({
+      [assignKey]: createEffectFromVariableProvider({
+        parse: (value: AssignValueType[], ctx) => {
+          const declareRows = uniqBy(
+            value.filter((_v) => _v.operator === 'declare' && _v.left && _v.right),
+            'left'
+          );
+
+          return [
+            ASTFactory.createVariableDeclaration({
+              key: `${ctx.node.id}`,
+              meta: {
+                title: getNodeForm(ctx.node)?.getValueIn('title'),
+                icon: ctx.node.getNodeRegistry<FlowNodeRegistry>().info?.icon,
+              },
+              type: ASTFactory.createObject({
+                properties: declareRows.map((_v) =>
+                  ASTFactory.createProperty({
+                    key: _v.left as string,
+                    type:
+                      _v.right?.type === 'constant'
+                        ? JsonSchemaUtils.schemaToAST(_v.right?.schema || {})
+                        : undefined,
+                    initializer:
+                      _v.right?.type === 'ref'
+                        ? ASTFactory.createKeyPathExpression({
+                            keyPath: _v.right?.content || [],
+                          })
+                        : {},
+                  })
+                ),
+              }),
+            }),
+          ];
+        },
+      }),
+    });
+
+    addFormatOnSubmit((formData, ctx) => {
+      set(
+        formData,
+        outputKey,
+        JsonSchemaUtils.astToSchema(getNodeScope(ctx.node).output.variables?.[0]?.type)
+      );
+
+      return formData;
+    });
+  },
+});