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

feat(materials): inputsValues and createInferInputsPlugin() (#565)

* feat: init input values

* feat: dynamic value input 样式优化

* feat: remove properties edit, add inputs values material
Yiwei Mao 5 месяцев назад
Родитель
Сommit
d8b7f32453
38 измененных файлов с 547 добавлено и 403 удалено
  1. 1 3
      apps/demo-fixed-layout/src/form-components/form-inputs/index.tsx
  2. 1 3
      apps/demo-free-layout/src/form-components/form-inputs/index.tsx
  3. 0 1
      apps/demo-free-layout/src/form-components/index.ts
  4. 0 143
      apps/demo-free-layout/src/form-components/properties-edit/index.tsx
  5. 0 84
      apps/demo-free-layout/src/form-components/properties-edit/property-edit.tsx
  6. 0 20
      apps/demo-free-layout/src/form-components/properties-edit/styles.tsx
  7. 22 35
      apps/demo-free-layout/src/nodes/end/form-meta.tsx
  8. 21 1
      apps/demo-free-layout/src/nodes/http/components/headers.tsx
  9. 21 1
      apps/demo-free-layout/src/nodes/http/components/params.tsx
  10. 6 0
      apps/demo-free-layout/src/nodes/http/form-render.tsx
  11. 5 0
      apps/demo-free-layout/src/nodes/http/index.tsx
  12. 1 1
      packages/materials/form-materials/bin/materials.ts
  13. 2 1
      packages/materials/form-materials/src/components/batch-outputs/config.json
  14. 4 12
      packages/materials/form-materials/src/components/batch-outputs/index.tsx
  15. 17 1
      packages/materials/form-materials/src/components/constant-input/index.tsx
  16. 1 0
      packages/materials/form-materials/src/components/constant-input/types.ts
  17. 58 9
      packages/materials/form-materials/src/components/dynamic-value-input/index.tsx
  18. 28 2
      packages/materials/form-materials/src/components/dynamic-value-input/styles.tsx
  19. 1 0
      packages/materials/form-materials/src/components/index.ts
  20. 12 0
      packages/materials/form-materials/src/components/inputs-values/config.json
  21. 56 0
      packages/materials/form-materials/src/components/inputs-values/index.tsx
  22. 19 0
      packages/materials/form-materials/src/components/inputs-values/styles.tsx
  23. 19 0
      packages/materials/form-materials/src/components/inputs-values/types.ts
  24. 15 8
      packages/materials/form-materials/src/components/type-selector/index.tsx
  25. 30 11
      packages/materials/form-materials/src/components/variable-selector/index.tsx
  26. 18 8
      packages/materials/form-materials/src/components/variable-selector/styles.tsx
  27. 0 1
      packages/materials/form-materials/src/effects/index.ts
  28. 0 5
      packages/materials/form-materials/src/effects/provide-batch-outputs/config.json
  29. 0 38
      packages/materials/form-materials/src/effects/provide-batch-outputs/index.ts
  30. 2 1
      packages/materials/form-materials/src/form-plugins/index.ts
  31. 7 0
      packages/materials/form-materials/src/form-plugins/infer-inputs-plugin/config.json
  32. 108 0
      packages/materials/form-materials/src/form-plugins/infer-inputs-plugin/index.ts
  33. 6 0
      packages/materials/form-materials/src/hooks/index.tsx
  34. 8 0
      packages/materials/form-materials/src/hooks/use-object-list/config.json
  35. 49 12
      packages/materials/form-materials/src/hooks/use-object-list/index.tsx
  36. 3 1
      packages/materials/form-materials/src/typings/flow-value/config.json
  37. 3 0
      packages/materials/form-materials/src/typings/flow-value/index.ts
  38. 3 1
      packages/node-engine/node/src/form-plugin.ts

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

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

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

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

+ 0 - 1
apps/demo-free-layout/src/form-components/index.ts

@@ -10,5 +10,4 @@ export * from './form-inputs';
 export * from './form-header';
 export * from './form-item';
 export * from './type-tag';
-export * from './properties-edit';
 export * from './value-display';

+ 0 - 143
apps/demo-free-layout/src/form-components/properties-edit/index.tsx

@@ -1,143 +0,0 @@
-/**
- * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
- * SPDX-License-Identifier: MIT
- */
-
-import React, { useState } from 'react';
-
-import { Button } from '@douyinfe/semi-ui';
-import { IconPlus } from '@douyinfe/semi-icons';
-
-import { JsonSchema } from '../../typings';
-import { useNodeRenderContext } from '../../hooks';
-import { PropertyEdit } from './property-edit';
-
-export interface PropertiesEditProps {
-  value?: Record<string, JsonSchema>;
-  onChange: (value: Record<string, JsonSchema>) => void;
-  useFx?: boolean;
-}
-
-export const PropertiesEdit: React.FC<PropertiesEditProps> = (props) => {
-  const value = (props.value || {}) as Record<string, JsonSchema>;
-  const { readonly } = useNodeRenderContext();
-  const [newProperty, updateNewPropertyFromCache] = useState<{ key: string; value: JsonSchema }>({
-    key: '',
-    value: { type: 'string' },
-  });
-  const [newPropertyVisible, setNewPropertyVisible] = useState<boolean>();
-  const clearCache = () => {
-    updateNewPropertyFromCache({ key: '', value: { type: 'string' } });
-    setNewPropertyVisible(false);
-  };
-  // 替换对象的key时,保持顺序
-  const replaceKeyAtPosition = (
-    obj: Record<string, any>,
-    oldKey: string,
-    newKey: string,
-    newValue: any
-  ) => {
-    const keys = Object.keys(obj);
-    const index = keys.indexOf(oldKey);
-
-    if (index === -1) {
-      // 如果 oldKey 不存在,直接添加到末尾
-      return { ...obj, [newKey]: newValue };
-    }
-
-    // 在原位置替换
-    const newKeys = [...keys.slice(0, index), newKey, ...keys.slice(index + 1)];
-
-    return newKeys.reduce((acc, key) => {
-      if (key === newKey) {
-        acc[key] = newValue;
-      } else {
-        acc[key] = obj[key];
-      }
-      return acc;
-    }, {} as Record<string, any>);
-  };
-  const updateProperty = (
-    propertyValue: JsonSchema,
-    propertyKey: string,
-    newPropertyKey?: string
-  ) => {
-    if (newPropertyKey) {
-      const orderedValue = replaceKeyAtPosition(value, propertyKey, newPropertyKey, propertyValue);
-      props.onChange(orderedValue);
-    } else {
-      const newValue = { ...value };
-      newValue[propertyKey] = propertyValue;
-      props.onChange(newValue);
-    }
-  };
-  const updateNewProperty = (
-    propertyValue: JsonSchema,
-    propertyKey: string,
-    newPropertyKey?: string
-  ) => {
-    // const newValue = { ...value }
-    if (newPropertyKey) {
-      if (!(newPropertyKey in value)) {
-        updateProperty(propertyValue, propertyKey, newPropertyKey);
-      }
-      clearCache();
-    } else {
-      updateNewPropertyFromCache({
-        key: newPropertyKey || propertyKey,
-        value: propertyValue,
-      });
-    }
-  };
-  return (
-    <>
-      {Object.keys(props.value || {}).map((key) => {
-        const property = (value[key] || {}) as JsonSchema;
-        return (
-          <PropertyEdit
-            key={key}
-            propertyKey={key}
-            useFx={props.useFx}
-            value={property}
-            disabled={readonly}
-            onChange={updateProperty}
-            onDelete={() => {
-              const newValue = { ...value };
-              delete newValue[key];
-              props.onChange(newValue);
-            }}
-          />
-        );
-      })}
-      {newPropertyVisible && (
-        <PropertyEdit
-          propertyKey={newProperty.key}
-          value={newProperty.value}
-          useFx={props.useFx}
-          onChange={updateNewProperty}
-          onDelete={() => {
-            const key = newProperty.key;
-            // after onblur
-            setTimeout(() => {
-              const newValue = { ...value };
-              delete newValue[key];
-              props.onChange(newValue);
-              clearCache();
-            }, 10);
-          }}
-        />
-      )}
-      {!readonly && (
-        <div>
-          <Button
-            theme="borderless"
-            icon={<IconPlus />}
-            onClick={() => setNewPropertyVisible(true)}
-          >
-            Add
-          </Button>
-        </div>
-      )}
-    </>
-  );
-};

+ 0 - 84
apps/demo-free-layout/src/form-components/properties-edit/property-edit.tsx

@@ -1,84 +0,0 @@
-/**
- * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
- * SPDX-License-Identifier: MIT
- */
-
-import React, { useState, useLayoutEffect } from 'react';
-
-import { VariableSelector, TypeSelector, DynamicValueInput } from '@flowgram.ai/form-materials';
-import { Input, Button } from '@douyinfe/semi-ui';
-import { IconCrossCircleStroked } from '@douyinfe/semi-icons';
-
-import { JsonSchema } from '../../typings';
-import { LeftColumn, Row } from './styles';
-
-export interface PropertyEditProps {
-  propertyKey: string;
-  value: JsonSchema;
-  useFx?: boolean;
-  disabled?: boolean;
-  onChange: (value: JsonSchema, propertyKey: string, newPropertyKey?: string) => void;
-  onDelete?: () => void;
-}
-
-export const PropertyEdit: React.FC<PropertyEditProps> = (props) => {
-  const { value, disabled } = props;
-  const [inputKey, updateKey] = useState(props.propertyKey);
-  const updateProperty = (key: keyof JsonSchema, val: any) => {
-    value[key] = val;
-    props.onChange(value, props.propertyKey);
-  };
-
-  const partialUpdateProperty = (val?: Partial<JsonSchema>) => {
-    props.onChange({ ...value, ...val }, props.propertyKey);
-  };
-
-  useLayoutEffect(() => {
-    updateKey(props.propertyKey);
-  }, [props.propertyKey]);
-  return (
-    <Row>
-      <LeftColumn>
-        <TypeSelector
-          value={value}
-          disabled={disabled}
-          style={{ position: 'absolute', top: 2, left: 4, zIndex: 1, padding: '0 5px', height: 20 }}
-          onChange={(val) => partialUpdateProperty(val)}
-        />
-        <Input
-          value={inputKey}
-          disabled={disabled}
-          size="small"
-          onChange={(v) => updateKey(v.trim())}
-          onBlur={() => {
-            if (inputKey !== '') {
-              props.onChange(value, props.propertyKey, inputKey);
-            } else {
-              updateKey(props.propertyKey);
-            }
-          }}
-          style={{ paddingLeft: 26 }}
-        />
-      </LeftColumn>
-      {
-        <DynamicValueInput
-          value={value.default}
-          onChange={(val) => updateProperty('default', val)}
-          constantProps={{
-            schema: value,
-          }}
-          style={{ flexGrow: 1 }}
-        />
-      }
-      {props.onDelete && !disabled && (
-        <Button
-          style={{ marginLeft: 5, position: 'relative', top: 2 }}
-          size="small"
-          theme="borderless"
-          icon={<IconCrossCircleStroked />}
-          onClick={props.onDelete}
-        />
-      )}
-    </Row>
-  );
-};

+ 0 - 20
apps/demo-free-layout/src/form-components/properties-edit/styles.tsx

@@ -1,20 +0,0 @@
-/**
- * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
- * SPDX-License-Identifier: MIT
- */
-
-import styled from 'styled-components';
-
-export const Row = styled.div`
-  display: flex;
-  justify-content: flex-start;
-  align-items: center;
-  font-size: 12px;
-  margin-bottom: 6px;
-`;
-
-export const LeftColumn = styled.div`
-  width: 120px;
-  margin-right: 5px;
-  position: relative;
-`;

+ 22 - 35
apps/demo-free-layout/src/nodes/end/form-meta.tsx

@@ -3,14 +3,12 @@
  * SPDX-License-Identifier: MIT
  */
 
-import { mapValues } from 'lodash-es';
-import { Field, FieldRenderProps, FormMeta } from '@flowgram.ai/free-layout-editor';
-import { IFlowValue } from '@flowgram.ai/form-materials';
+import { Field, FormMeta } from '@flowgram.ai/free-layout-editor';
+import { createInferInputsPlugin, IFlowValue, InputsValues } from '@flowgram.ai/form-materials';
 
 import { defaultFormMeta } from '../default-form-meta';
-import { JsonSchema } from '../../typings';
 import { useIsSidebar } from '../../hooks';
-import { FormHeader, FormContent, FormOutputs, PropertiesEdit } from '../../form-components';
+import { FormHeader, FormContent, FormOutputs } from '../../form-components';
 
 export const renderForm = () => {
   const isSidebar = useIsSidebar();
@@ -19,36 +17,13 @@ export const renderForm = () => {
       <>
         <FormHeader />
         <FormContent>
-          <Field
-            name="inputs.properties"
-            render={({
-              field: { value: propertiesSchemaValue, onChange: propertiesSchemaChange },
-            }: FieldRenderProps<Record<string, JsonSchema>>) => (
-              <Field<Record<string, IFlowValue>> name="inputsValues">
-                {({ field: { value: propertiesValue, onChange: propertiesValueChange } }) => {
-                  const onChange = (newProperties: Record<string, JsonSchema>) => {
-                    const newPropertiesValue = mapValues(newProperties, (v) => v.default);
-                    const newPropetiesSchema = mapValues(newProperties, (v) => {
-                      delete v.default;
-                      return v;
-                    });
-                    propertiesValueChange(newPropertiesValue);
-                    propertiesSchemaChange(newPropetiesSchema);
-                  };
-                  const value = mapValues(propertiesSchemaValue, (v, key) => ({
-                    ...v,
-                    default: propertiesValue?.[key],
-                  }));
-                  return (
-                    <>
-                      <PropertiesEdit value={value} onChange={onChange} useFx={true} />
-                    </>
-                  );
-                }}
-              </Field>
+          <Field<Record<string, IFlowValue | undefined> | undefined> name="inputsValues">
+            {({ field: { value, onChange } }) => (
+              <>
+                <InputsValues value={value} onChange={(_v) => onChange(_v)} />
+              </>
             )}
-          />
-          <FormOutputs name="inputs" />
+          </Field>
         </FormContent>
       </>
     );
@@ -57,7 +32,13 @@ export const renderForm = () => {
     <>
       <FormHeader />
       <FormContent>
-        <FormOutputs name="inputs" />
+        <Field<Record<string, IFlowValue | undefined> | undefined> name="inputsValues">
+          {({ field: { value, onChange } }) => (
+            <>
+              <InputsValues value={value} onChange={(_v) => onChange(_v)} />
+            </>
+          )}
+        </Field>
       </FormContent>
     </>
   );
@@ -66,4 +47,10 @@ export const renderForm = () => {
 export const formMeta: FormMeta = {
   ...defaultFormMeta,
   render: renderForm,
+  plugins: [
+    createInferInputsPlugin({
+      sourceKey: 'inputsValues',
+      targetKey: 'inputs',
+    }),
+  ],
 };

+ 21 - 1
apps/demo-free-layout/src/nodes/http/components/headers.tsx

@@ -3,6 +3,26 @@
  * SPDX-License-Identifier: MIT
  */
 
+import { Field } from '@flowgram.ai/free-layout-editor';
+import { IFlowConstantRefValue, InputsValues } from '@flowgram.ai/form-materials';
+
+import { useNodeRenderContext } from '../../../hooks';
+import { FormItem } from '../../../form-components';
+
 export function Headers() {
-  return <div>headers</div>;
+  const { readonly } = useNodeRenderContext();
+
+  return (
+    <FormItem name="headers" type="object" vertical>
+      <Field<Record<string, IFlowConstantRefValue> | undefined> name="headersValues">
+        {({ field }) => (
+          <InputsValues
+            value={field.value}
+            onChange={(value) => field.onChange(value)}
+            readonly={readonly}
+          />
+        )}
+      </Field>
+    </FormItem>
+  );
 }

+ 21 - 1
apps/demo-free-layout/src/nodes/http/components/params.tsx

@@ -3,6 +3,26 @@
  * SPDX-License-Identifier: MIT
  */
 
+import { Field } from '@flowgram.ai/free-layout-editor';
+import { IFlowConstantRefValue, InputsValues } from '@flowgram.ai/form-materials';
+
+import { useNodeRenderContext } from '../../../hooks';
+import { FormItem } from '../../../form-components';
+
 export function Params() {
-  return <div>params</div>;
+  const { readonly } = useNodeRenderContext();
+
+  return (
+    <FormItem name="params" type="object" vertical>
+      <Field<Record<string, IFlowConstantRefValue> | undefined> name="paramsValues">
+        {({ field }) => (
+          <InputsValues
+            value={field.value}
+            onChange={(value) => field.onChange(value)}
+            readonly={readonly}
+          />
+        )}
+      </Field>
+    </FormItem>
+  );
 }

+ 6 - 0
apps/demo-free-layout/src/nodes/http/form-render.tsx

@@ -9,6 +9,8 @@ import { Divider } from '@douyinfe/semi-ui';
 import { FormHeader, FormContent, FormOutputs } from '../../form-components';
 import { HTTPNodeJSON } from './types';
 import { Timeout } from './components/timeout';
+import { Params } from './components/params';
+import { Headers } from './components/headers';
 import { Body } from './components/body';
 import { Api } from './components/api';
 
@@ -18,6 +20,10 @@ export const FormRender = ({ form }: FormRenderProps<HTTPNodeJSON>) => (
     <FormContent>
       <Api />
       <Divider />
+      <Headers />
+      <Divider />
+      <Params />
+      <Divider />
       <Body />
       <Divider />
       <Timeout />

+ 5 - 0
apps/demo-free-layout/src/nodes/http/index.tsx

@@ -4,6 +4,7 @@
  */
 
 import { nanoid } from 'nanoid';
+import { createInferInputsPlugin } from '@flowgram.ai/form-materials';
 
 import { WorkflowNodeType } from '../constants';
 import { FlowNodeRegistry } from '../../typings';
@@ -52,5 +53,9 @@ export const HTTPNodeRegistry: FlowNodeRegistry = {
   formMeta: {
     render: (props) => <FormRender {...props} />,
     effect: defaultFormMeta.effect,
+    plugins: [
+      createInferInputsPlugin({ sourceKey: 'headersValues', targetKey: 'headers' }),
+      createInferInputsPlugin({ sourceKey: 'paramsValues', targetKey: 'params' }),
+    ],
   },
 };

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

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

+ 2 - 1
packages/materials/form-materials/src/components/batch-outputs/config.json

@@ -2,7 +2,8 @@
   "name": "batch-outputs",
   "depMaterials": [
     "flow-value",
-    "variable-selector"
+    "variable-selector",
+    "use-object-list"
   ],
   "depPackages": [
     "@douyinfe/semi-ui",

+ 4 - 12
packages/materials/form-materials/src/components/batch-outputs/index.tsx

@@ -8,15 +8,15 @@ 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 { useObjectList } from '../../hooks';
 import { UIRow, UIRows } from './styles';
 
 export function BatchOutputs(props: PropsType) {
   const { readonly, style } = props;
 
-  const { list, add, update, remove } = useList(props);
+  const { list, add, updateKey, updateValue, remove } = useObjectList(props);
 
   return (
     <div>
@@ -28,21 +28,13 @@ export function BatchOutputs(props: PropsType) {
               disabled={readonly}
               size="small"
               value={item.key}
-              onChange={(v) => update({ ...item, key: v })}
+              onChange={(v) => updateKey(item.id, v)}
             />
             <VariableSelector
               style={{ flexGrow: 1 }}
               readonly={readonly}
               value={item.value?.content}
-              onChange={(v) =>
-                update({
-                  ...item,
-                  value: {
-                    type: 'ref',
-                    content: v,
-                  },
-                })
-              }
+              onChange={(v) => updateValue(item.id, { type: 'ref', content: v })}
             />
             <Button
               disabled={readonly}

+ 17 - 1
packages/materials/form-materials/src/components/constant-input/index.tsx

@@ -65,7 +65,15 @@ const defaultStrategies: Strategy[] = [
 ];
 
 export function ConstantInput(props: PropsType) {
-  const { value, onChange, schema, strategies: extraStrategies, readonly, ...rest } = props;
+  const {
+    value,
+    onChange,
+    schema,
+    strategies: extraStrategies,
+    fallbackRenderer,
+    readonly,
+    ...rest
+  } = props;
 
   const strategies = useMemo(
     // user's extraStrategies first
@@ -80,6 +88,14 @@ export function ConstantInput(props: PropsType) {
   }, [strategies, schema]);
 
   if (!Renderer) {
+    if (fallbackRenderer) {
+      return React.createElement(fallbackRenderer, {
+        value,
+        onChange,
+        readonly,
+        ...rest,
+      });
+    }
     return <Input size="small" disabled placeholder="Unsupported type" />;
   }
 

+ 1 - 0
packages/materials/form-materials/src/components/constant-input/types.ts

@@ -19,5 +19,6 @@ export interface RendererProps<Value = any> {
 export interface PropsType extends RendererProps {
   schema: IJsonSchema;
   strategies?: Strategy[];
+  fallbackRenderer?: React.FC<RendererProps>;
   [key: string]: any;
 }

+ 58 - 9
packages/materials/form-materials/src/components/dynamic-value-input/index.tsx

@@ -3,16 +3,19 @@
  * SPDX-License-Identifier: MIT
  */
 
-import React, { useMemo } from 'react';
+import React, { useMemo, useState } from 'react';
 
+import { useScopeAvailable } from '@flowgram.ai/editor';
 import { IconButton } from '@douyinfe/semi-ui';
 import { IconSetting } from '@douyinfe/semi-icons';
 
 import { Strategy } from '../constant-input/types';
 import { ConstantInput } from '../constant-input';
+import { JsonSchemaUtils } from '../../utils';
 import { IFlowConstantRefValue } from '../../typings/flow-value';
-import { UIContainer, UIMain, UITrigger } from './styles';
+import { UIContainer, UIMain, UITrigger, UIType } from './styles';
 import { VariableSelector } from '../variable-selector';
+import { TypeSelector } from '../type-selector';
 import { IJsonSchema } from '../../typings';
 
 interface PropsType {
@@ -34,16 +37,50 @@ export function DynamicValueInput({
   onChange,
   readonly,
   style,
-  schema,
+  schema: schemaFromProps,
   constantProps,
 }: PropsType) {
+  const available = useScopeAvailable();
+  const refVariable = useMemo(() => {
+    if (value?.type === 'ref') {
+      return available.getByKeyPath(value.content);
+    }
+  }, [value, available]);
+
+  const [selectSchema, setSelectSchema] = useState(
+    schemaFromProps || constantProps?.schema || { type: 'string' }
+  );
+
+  const renderTypeSelector = () => {
+    if (schemaFromProps) {
+      return <TypeSelector value={schemaFromProps} readonly={true} />;
+    }
+
+    if (value?.type === 'ref') {
+      const schema = refVariable?.type ? JsonSchemaUtils.astToSchema(refVariable?.type) : undefined;
+
+      return <TypeSelector value={schema} readonly={true} />;
+    }
+
+    return (
+      <TypeSelector
+        value={selectSchema}
+        onChange={(_v) => setSelectSchema(_v || { type: 'string' })}
+        readonly={readonly}
+      />
+    );
+  };
+
   // When is number type, include integer as well
   const includeSchema = useMemo(() => {
-    if (schema?.type === 'number') {
-      return [schema, { type: 'integer' }];
+    if (!schemaFromProps) {
+      return;
     }
-    return schema;
-  }, [schema]);
+    if (schemaFromProps?.type === 'number') {
+      return [schemaFromProps, { type: 'integer' }];
+    }
+    return { ...schemaFromProps, extra: { ...schemaFromProps?.extra, weak: true } };
+  }, [schemaFromProps]);
 
   const renderMain = () => {
     if (value?.type === 'ref') {
@@ -59,12 +96,23 @@ export function DynamicValueInput({
       );
     }
 
+    const constantSchema = schemaFromProps || selectSchema || { type: 'string' };
+
     return (
       <ConstantInput
         value={value?.content}
-        onChange={(_v) => onChange({ type: 'constant', content: _v })}
-        schema={schema || { type: 'string' }}
+        onChange={(_v) => onChange({ type: 'constant', content: _v, schema: constantSchema })}
+        schema={constantSchema || { type: 'string' }}
         readonly={readonly}
+        strategies={[...(constantProps?.strategies || [])]}
+        fallbackRenderer={() => (
+          <VariableSelector
+            style={{ width: '100%' }}
+            onChange={(_v) => onChange(_v ? { type: 'ref', content: _v } : undefined)}
+            includeSchema={includeSchema}
+            readonly={readonly}
+          />
+        )}
         {...constantProps}
       />
     );
@@ -85,6 +133,7 @@ export function DynamicValueInput({
 
   return (
     <UIContainer style={style}>
+      <UIType>{renderTypeSelector()}</UIType>
       <UIMain>{renderMain()}</UIMain>
       <UITrigger>{renderTrigger()}</UITrigger>
     </UIContainer>

+ 28 - 2
packages/materials/form-materials/src/components/dynamic-value-input/styles.tsx

@@ -8,7 +8,12 @@ import styled from 'styled-components';
 export const UIContainer = styled.div`
   display: flex;
   align-items: center;
-  gap: 5px;
+  border-radius: 4px;
+  border: 1px solid var(--semi-color-border);
+
+  overflow: hidden;
+
+  background-color: var(--semi-color-fill-0);
 `;
 
 export const UIMain = styled.div`
@@ -20,7 +25,28 @@ export const UIMain = styled.div`
   & .semi-input-number,
   & .semi-select {
     width: 100%;
+    border: none;
+    border-radius: 0;
+  }
+
+  & .semi-input-wrapper {
+    border: none;
+    border-radius: 0;
+  }
+`;
+
+export const UIType = styled.div`
+  border-right: 1px solid #e5e5e5;
+
+  & .semi-button {
+    border-radius: 0;
   }
 `;
 
-export const UITrigger = styled.div``;
+export const UITrigger = styled.div`
+  border-left: 1px solid #e5e5e5;
+
+  & .semi-button {
+    border-radius: 0;
+  }
+`;

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

@@ -16,3 +16,4 @@ export * from './prompt-editor-with-variables';
 export * from './prompt-editor-with-inputs';
 export * from './code-editor';
 export * from './json-editor-with-variables';
+export * from './inputs-values';

+ 12 - 0
packages/materials/form-materials/src/components/inputs-values/config.json

@@ -0,0 +1,12 @@
+{
+  "name": "inputs-values",
+  "depMaterials": [
+    "flow-value",
+    "dynamic-value-input"
+  ],
+  "depPackages": [
+    "@douyinfe/semi-ui",
+    "@douyinfe/semi-icons",
+    "styled-components"
+  ]
+}

+ 56 - 0
packages/materials/form-materials/src/components/inputs-values/index.tsx

@@ -0,0 +1,56 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import React from 'react';
+
+import { Button, IconButton, Input } from '@douyinfe/semi-ui';
+import { IconDelete, IconPlus } from '@douyinfe/semi-icons';
+
+import { PropsType } from './types';
+import { DynamicValueInput } from '../dynamic-value-input';
+import { IFlowConstantRefValue, IFlowValue } from '../../typings';
+import { useObjectList } from '../../hooks';
+import { UIRow, UIRows } from './styles';
+
+export function InputsValues({ value, onChange, style, readonly }: PropsType) {
+  const { list, updateKey, updateValue, remove, add } = useObjectList<IFlowValue | undefined>({
+    value,
+    onChange,
+  });
+
+  return (
+    <div>
+      <UIRows style={style}>
+        {list.map((item) => (
+          <UIRow key={item.id}>
+            <Input
+              style={{ width: 100, minWidth: 100, maxWidth: 100 }}
+              disabled={readonly}
+              size="small"
+              value={item.key}
+              onChange={(v) => updateKey(item.id, v)}
+            />
+            <DynamicValueInput
+              style={{ flexGrow: 1 }}
+              readonly={readonly}
+              value={item.value as IFlowConstantRefValue}
+              onChange={(v) => updateValue(item.id, v)}
+            />
+            <IconButton
+              disabled={readonly}
+              theme="borderless"
+              icon={<IconDelete size="small" />}
+              size="small"
+              onClick={() => remove(item.id)}
+            />
+          </UIRow>
+        ))}
+      </UIRows>
+      <Button disabled={readonly} icon={<IconPlus />} size="small" onClick={add}>
+        Add
+      </Button>
+    </div>
+  );
+}

+ 19 - 0
packages/materials/form-materials/src/components/inputs-values/styles.tsx

@@ -0,0 +1,19 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+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;
+`;

+ 19 - 0
packages/materials/form-materials/src/components/inputs-values/types.ts

@@ -0,0 +1,19 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { Strategy } from '../constant-input/types';
+import { IFlowValue } from '../../typings';
+
+export interface PropsType {
+  value?: Record<string, IFlowValue | undefined>;
+  onChange: (value?: Record<string, IFlowValue | undefined>) => void;
+  readonly?: boolean;
+  hasError?: boolean;
+  style?: React.CSSProperties;
+  constantProps?: {
+    strategies?: Strategy[];
+    [key: string]: any;
+  };
+}

+ 15 - 8
packages/materials/form-materials/src/components/type-selector/index.tsx

@@ -5,14 +5,18 @@
 
 import React, { useMemo } from 'react';
 
-import { Button, Cascader } from '@douyinfe/semi-ui';
+import { Cascader, IconButton } from '@douyinfe/semi-ui';
 
 import { IJsonSchema } from '../../typings';
 import { ArrayIcons, VariableTypeIcons, getSchemaIcon, options } from './constants';
 
 interface PropTypes {
   value?: Partial<IJsonSchema>;
-  onChange: (value?: Partial<IJsonSchema>) => void;
+  onChange?: (value?: Partial<IJsonSchema>) => void;
+  readonly?: boolean;
+  /**
+   * @deprecated use readonly instead
+   */
   disabled?: boolean;
   style?: React.CSSProperties;
 }
@@ -36,24 +40,27 @@ export const parseTypeSelectValue = (value?: string[]): Partial<IJsonSchema> | u
 };
 
 export function TypeSelector(props: PropTypes) {
-  const { value, onChange, disabled, style } = props;
+  const { value, onChange, readonly, disabled, style } = props;
 
   const selectValue = useMemo(() => getTypeSelectValue(value), [value]);
 
   return (
     <Cascader
-      disabled={disabled}
+      disabled={readonly || disabled}
       size="small"
       triggerRender={() => (
-        <Button size="small" style={style}>
-          {getSchemaIcon(value)}
-        </Button>
+        <IconButton
+          size="small"
+          style={style}
+          disabled={readonly || disabled}
+          icon={getSchemaIcon(value)}
+        />
       )}
       treeData={options}
       value={selectValue}
       leafOnly={true}
       onChange={(value) => {
-        onChange(parseTypeSelectValue(value as string[]));
+        onChange?.(parseTypeSelectValue(value as string[]));
       }}
     />
   );

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

@@ -7,11 +7,12 @@ import React, { useMemo } from 'react';
 
 import { TriggerRenderProps } from '@douyinfe/semi-ui/lib/es/treeSelect';
 import { TreeNodeData } from '@douyinfe/semi-ui/lib/es/tree';
+import { Popover } from '@douyinfe/semi-ui';
 import { IconChevronDownStroked, IconIssueStroked } from '@douyinfe/semi-icons';
 
 import { IJsonSchema } from '../../typings/json-schema';
 import { useVariableTree } from './use-variable-tree';
-import { UIRootTitle, UITag, UITreeSelect, UIVarName } from './styles';
+import { UIPopoverContent, UIRootTitle, UITag, UITreeSelect, UIVarName } from './styles';
 
 interface PropTypes {
   value?: string[];
@@ -93,17 +94,35 @@ export const VariableSelector = ({
             );
           }
 
+          const rootIcon = renderIcon(_option.rootMeta?.icon || _option?.icon);
+
+          const rootTitle = (
+            <UIRootTitle>
+              {_option.rootMeta?.title ? `${_option.rootMeta?.title} -` : null}
+            </UIRootTitle>
+          );
+
           return (
-            <UITag
-              prefixIcon={renderIcon(_option.rootMeta?.icon || _option?.icon)}
-              closable={!readonly}
-              onClose={() => onChange(undefined)}
-            >
-              <UIRootTitle>
-                {_option.rootMeta?.title ? `${_option.rootMeta?.title} -` : null}
-              </UIRootTitle>
-              <UIVarName>{_option.label}</UIVarName>
-            </UITag>
+            <div>
+              <Popover
+                content={
+                  <UIPopoverContent>
+                    {rootIcon}
+                    {rootTitle}
+                    <UIVarName>{_option.keyPath.slice(1).join('.')}</UIVarName>
+                  </UIPopoverContent>
+                }
+              >
+                <UITag
+                  prefixIcon={rootIcon}
+                  closable={!readonly}
+                  onClose={() => onChange(undefined)}
+                >
+                  {rootTitle}
+                  <UIVarName $inSelector>{_option.label}</UIVarName>
+                </UITag>
+              </Popover>
+            </div>
           );
         }}
         showClear={false}

+ 18 - 8
packages/materials/form-materials/src/components/variable-selector/styles.tsx

@@ -3,7 +3,7 @@
  * SPDX-License-Identifier: MIT
  */
 
-import styled from 'styled-components';
+import styled, { css } from 'styled-components';
 import { Tag, TreeSelect } from '@douyinfe/semi-ui';
 
 export const UIRootTitle = styled.div`
@@ -15,11 +15,16 @@ export const UIRootTitle = styled.div`
   color: var(--semi-color-text-2);
 `;
 
-export const UIVarName = styled.div`
+export const UIVarName = styled.div<{ $inSelector?: boolean }>`
   overflow: hidden;
   text-overflow: ellipsis;
   white-space: nowrap;
-  min-width: 50%;
+
+  ${({ $inSelector }) =>
+    $inSelector &&
+    css`
+      min-width: 50%;
+    `}
 `;
 
 export const UITag = styled(Tag)`
@@ -34,18 +39,15 @@ export const UITag = styled(Tag)`
 
   &.semi-tag {
     margin: 0;
+    height: 22px;
   }
 `;
 
 export const UITreeSelect = styled(TreeSelect)<{ $error?: boolean }>`
   outline: ${({ $error }) => ($error ? '1px solid red' : 'none')};
 
-  height: 22px;
-  min-height: 22px;
-  line-height: 22px;
-
   & .semi-tree-select-selection {
-    padding: 0 2px;
+    padding: 0px;
     height: 22px;
   }
 
@@ -57,3 +59,11 @@ export const UITreeSelect = styled(TreeSelect)<{ $error?: boolean }>`
     padding-left: 10px;
   }
 `;
+
+export const UIPopoverContent = styled.div`
+  padding: 10px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: flex-start;
+  white-space: nowrap;
+`;

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

@@ -4,7 +4,6 @@
  */
 
 export * from './provide-batch-input';
-export * from './provide-batch-outputs';
 export * from './auto-rename-ref';
 export * from './provide-json-schema-outputs';
 export * from './sync-variable-title';

+ 0 - 5
packages/materials/form-materials/src/effects/provide-batch-outputs/config.json

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

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

@@ -1,38 +0,0 @@
-/**
- * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
- * SPDX-License-Identifier: MIT
- */
-
-import {
-  ASTFactory,
-  EffectOptions,
-  FlowNodeRegistry,
-  createEffectFromVariableProvider,
-  getNodeForm,
-} 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 || [],
-              }),
-            }),
-          })
-        ),
-      }),
-    }),
-  ],
-});

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

@@ -3,4 +3,5 @@
  * SPDX-License-Identifier: MIT
  */
 
-export { createBatchOutputsFormPlugin } from './batch-outputs-plugin';
+export * from './batch-outputs-plugin';
+export * from './infer-inputs-plugin';

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

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

+ 108 - 0
packages/materials/form-materials/src/form-plugins/infer-inputs-plugin/index.ts

@@ -0,0 +1,108 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+import { get, set } from 'lodash';
+import {
+  defineFormPluginCreator,
+  getNodePrivateScope,
+  getNodeScope,
+  Scope,
+} from '@flowgram.ai/editor';
+
+import { JsonSchemaUtils } from '../../utils';
+import { IFlowConstantValue, IFlowRefValue, IFlowTemplateValue, IJsonSchema } from '../../typings';
+
+interface InputConfig {
+  sourceKey: string;
+  targetKey: string;
+  scope?: 'private' | 'public';
+}
+
+export const createInferInputsPlugin = defineFormPluginCreator<InputConfig>({
+  onSetupFormMeta({ addFormatOnSubmit }, { sourceKey, targetKey, scope }) {
+    if (!sourceKey || !targetKey) {
+      return;
+    }
+
+    addFormatOnSubmit((formData, ctx) => {
+      set(
+        formData,
+        targetKey,
+        infer(
+          get(formData, sourceKey),
+          scope === 'private' ? getNodePrivateScope(ctx.node) : getNodeScope(ctx.node)
+        )
+      );
+
+      return formData;
+    });
+  },
+});
+
+function isRef(value: any): value is IFlowRefValue {
+  return (
+    value?.type === 'ref' && Array.isArray(value?.content) && typeof value?.content[0] === 'string'
+  );
+}
+
+function isTemplate(value: any): value is IFlowTemplateValue {
+  return value?.type === 'template' && typeof value?.content === 'string';
+}
+
+function isConstant(value: any): value is IFlowConstantValue {
+  return value?.type === 'constant' && typeof value?.content !== 'undefined';
+}
+
+const infer = (values: any, scope: Scope): IJsonSchema | undefined => {
+  if (typeof values === 'object') {
+    if (isConstant(values)) {
+      if (values?.schema) {
+        return values.schema;
+      }
+
+      if (typeof values.content === 'string') {
+        return {
+          type: 'string',
+        };
+      }
+
+      if (typeof values.content === 'number') {
+        return {
+          type: 'number',
+        };
+      }
+
+      if (typeof values.content === 'boolean') {
+        return {
+          type: 'boolean',
+        };
+      }
+    }
+
+    if (isRef(values)) {
+      const variable = scope.available.getByKeyPath(values?.content);
+      const schema = variable?.type ? JsonSchemaUtils.astToSchema(variable?.type) : undefined;
+
+      return schema;
+    }
+
+    if (isTemplate(values)) {
+      return {
+        type: 'string',
+      };
+    }
+
+    return {
+      type: 'object',
+      properties: Object.keys(values).reduce((acc, key) => {
+        const schema = infer(values[key], scope);
+        if (schema) {
+          acc[key] = schema;
+        }
+        return acc;
+      }, {} as Record<string, IJsonSchema>),
+    };
+  }
+};

+ 6 - 0
packages/materials/form-materials/src/hooks/index.tsx

@@ -0,0 +1,6 @@
+/**
+ * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
+ * SPDX-License-Identifier: MIT
+ */
+
+export * from './use-object-list';

+ 8 - 0
packages/materials/form-materials/src/hooks/use-object-list/config.json

@@ -0,0 +1,8 @@
+{
+  "name": "use-object-list",
+  "depMaterials": [],
+  "depPackages": [
+    "lodash",
+    "nanoid"
+  ]
+}

+ 49 - 12
packages/materials/form-materials/src/components/batch-outputs/use-list.ts → packages/materials/form-materials/src/hooks/use-object-list/index.tsx

@@ -5,17 +5,29 @@
 
 import { useEffect, useState } from 'react';
 
+import { nanoid } from 'nanoid';
 import { difference } from 'lodash';
 
-import { OutputItem, PropsType } from './types';
-
-let _id = 0;
 function genId() {
-  return _id++;
+  return nanoid();
+}
+
+interface ListItem<ValueType> {
+  id: string;
+  key?: string;
+  value?: ValueType;
 }
 
-export function useList({ value, onChange }: PropsType) {
-  const [list, setList] = useState<OutputItem[]>([]);
+type ObjectType<ValueType> = Record<string, ValueType | undefined>;
+
+export function useObjectList<ValueType>({
+  value,
+  onChange,
+}: {
+  value?: ObjectType<ValueType>;
+  onChange: (value?: ObjectType<ValueType>) => void;
+}) {
+  const [list, setList] = useState<ListItem<ValueType>[]>([]);
 
   useEffect(() => {
     setList((_prevList) => {
@@ -28,7 +40,7 @@ export function useList({ value, onChange }: PropsType) {
         .map((item) => ({
           id: item.id,
           key: item.key,
-          value: item.key ? value?.[item.key!] : undefined,
+          value: item.key ? value?.[item.key!] : item.value,
         }))
         .concat(
           addKeys.map((_key) => ({
@@ -49,11 +61,36 @@ export function useList({ value, onChange }: PropsType) {
     ]);
   };
 
-  const update = (item: OutputItem) => {
+  const updateValue = (itemId: string, value: ValueType) => {
+    setList((prevList) => {
+      const nextList = prevList.map((_item) => {
+        if (_item.id === itemId) {
+          return {
+            ..._item,
+            value,
+          };
+        }
+        return _item;
+      });
+
+      onChange(
+        Object.fromEntries(
+          nextList.filter((item) => item.key).map((item) => [item.key!, item.value])
+        )
+      );
+
+      return nextList;
+    });
+  };
+
+  const updateKey = (itemId: string, key: string) => {
     setList((prevList) => {
       const nextList = prevList.map((_item) => {
-        if (_item.id === item.id) {
-          return item;
+        if (_item.id === itemId) {
+          return {
+            ..._item,
+            key,
+          };
         }
         return _item;
       });
@@ -68,7 +105,7 @@ export function useList({ value, onChange }: PropsType) {
     });
   };
 
-  const remove = (itemId: number) => {
+  const remove = (itemId: string) => {
     setList((prevList) => {
       const nextList = prevList.filter((_item) => _item.id !== itemId);
 
@@ -82,5 +119,5 @@ export function useList({ value, onChange }: PropsType) {
     });
   };
 
-  return { list, add, update, remove };
+  return { list, add, updateKey, updateValue, remove };
 }

+ 3 - 1
packages/materials/form-materials/src/typings/flow-value/config.json

@@ -1,5 +1,7 @@
 {
   "name": "flow-value",
-  "depMaterials": [],
+  "depMaterials": [
+    "typings/json-schema"
+  ],
   "depPackages": []
 }

+ 3 - 0
packages/materials/form-materials/src/typings/flow-value/index.ts

@@ -3,9 +3,12 @@
  * SPDX-License-Identifier: MIT
  */
 
+import { IJsonSchema } from '../json-schema';
+
 export interface IFlowConstantValue {
   type: 'constant';
   content?: string | number | boolean;
+  schema?: IJsonSchema;
 }
 
 export interface IFlowRefValue {

+ 3 - 1
packages/node-engine/node/src/form-plugin.ts

@@ -122,7 +122,9 @@ export class FormPlugin<Opts = any> implements Disposable {
 
 export type FormPluginCreator<Opts> = (opts: Opts) => FormPlugin<Opts>;
 
-export function defineFormPluginCreator<Opts>(config: FormPluginConfig): FormPluginCreator<Opts> {
+export function defineFormPluginCreator<Opts>(
+  config: FormPluginConfig<Opts>
+): FormPluginCreator<Opts> {
   return function (opts: Opts) {
     return new FormPlugin(config, opts);
   };