新版本流程编辑器

This commit is contained in:
mlogclub
2026-07-27 19:04:15 +08:00
parent d33960961a
commit e072141953
281 changed files with 25145 additions and 3405 deletions
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormRenderProps, FormMeta } from '@flowgram.ai/free-layout-editor';
import { Avatar } from '@douyinfe/semi-ui';
import { FlowNodeJSON } from '../../typings';
import iconEnd from '../../assets/icon-end.jpg';
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => (
<>
<div
style={{
width: 60,
height: 60,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Avatar
shape="circle"
style={{
width: 40,
height: 40,
borderRadius: '50%',
cursor: 'move',
}}
alt="Icon"
src={iconEnd}
/>
</div>
</>
);
export const formMeta: FormMeta<FlowNodeJSON> = {
render: renderForm,
};
@@ -0,0 +1,46 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowNodeRegistry } from '../../typings';
import iconStart from '../../assets/icon-start.jpg';
import { formMeta } from './form-meta';
import { WorkflowNodeType } from '../constants';
export const BlockEndNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.BlockEnd,
meta: {
isNodeEnd: true,
deleteDisable: true,
copyDisable: true,
sidebarDisabled: true,
nodePanelVisible: false,
defaultPorts: [{ type: 'input' }],
size: {
width: 100,
height: 100,
},
wrapperStyle: {
minWidth: 'unset',
width: '100%',
borderWidth: 2,
borderRadius: 12,
cursor: 'move',
},
},
info: {
icon: iconStart,
description: 'The final node of the block.',
},
/**
* Render node via formMeta
*/
formMeta,
/**
* Start Node cannot be added
*/
canAdd() {
return false;
},
};
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormRenderProps, FormMeta } from '@flowgram.ai/free-layout-editor';
import { Avatar } from '@douyinfe/semi-ui';
import { FlowNodeJSON } from '../../typings';
import iconStart from '../../assets/icon-start.jpg';
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => (
<>
<div
style={{
width: 60,
height: 60,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Avatar
shape="circle"
style={{
width: 40,
height: 40,
borderRadius: '50%',
cursor: 'move',
}}
alt="Icon"
src={iconStart}
/>
</div>
</>
);
export const formMeta: FormMeta<FlowNodeJSON> = {
render: renderForm,
};
@@ -0,0 +1,46 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowNodeRegistry } from '../../typings';
import iconStart from '../../assets/icon-start.jpg';
import { formMeta } from './form-meta';
import { WorkflowNodeType } from '../constants';
export const BlockStartNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.BlockStart,
meta: {
isStart: true,
deleteDisable: true,
copyDisable: true,
sidebarDisabled: true,
nodePanelVisible: false,
defaultPorts: [{ type: 'output' }],
size: {
width: 100,
height: 100,
},
wrapperStyle: {
minWidth: 'unset',
width: '100%',
borderWidth: 2,
borderRadius: 12,
cursor: 'move',
},
},
info: {
icon: iconStart,
description: 'The starting node of the block.',
},
/**
* Render node via formMeta
*/
formMeta,
/**
* Start Node cannot be added
*/
canAdd() {
return false;
},
};
@@ -0,0 +1,33 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormMeta } from '@flowgram.ai/free-layout-editor';
import { defaultFormMeta } from '../default-form-meta';
import { useIsSidebar } from '../../hooks';
import { FormHeader, FormContent } from '../../form-components';
export const renderForm = () => {
const isSidebar = useIsSidebar();
if (isSidebar) {
return (
<>
<FormHeader />
<FormContent />
</>
);
}
return (
<>
<FormHeader />
<FormContent />
</>
);
};
export const formMeta: FormMeta = {
...defaultFormMeta,
render: renderForm,
};
+44
View File
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { nanoid } from 'nanoid';
import { FlowNodeRegistry } from '../../typings';
import iconBreak from '../../assets/icon-break.jpg';
import { formMeta } from './form-meta';
import { WorkflowNodeType } from '../constants';
let index = 0;
export const BreakNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.Break,
meta: {
defaultPorts: [{ type: 'input' }],
sidebarDisabled: true,
size: {
width: 360,
height: 54,
},
expandable: false,
onlyInContainer: WorkflowNodeType.Loop,
},
info: {
icon: iconBreak,
description:
'The final node of the workflow, used to return the result information after the workflow is run.',
},
/**
* Render node via formMeta
*/
formMeta,
onAdd() {
return {
id: `break_${nanoid(5)}`,
type: 'break',
data: {
title: `Break_${++index}`,
},
};
},
};
@@ -0,0 +1,34 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Field } from '@flowgram.ai/free-layout-editor';
import { TypeScriptCodeEditor } from '@flowgram.ai/form-materials';
import { Divider } from '@douyinfe/semi-ui';
import { useIsSidebar, useNodeRenderContext } from '../../../hooks';
export function Code() {
const isSidebar = useIsSidebar();
const { readonly } = useNodeRenderContext();
if (!isSidebar) {
return null;
}
return (
<>
<Divider />
<Field<string> name="script.content">
{({ field }) => (
<TypeScriptCodeEditor
value={field.value}
onChange={(value) => field.onChange(value)}
readonly={readonly}
/>
)}
</Field>
</>
);
}
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Field } from '@flowgram.ai/free-layout-editor';
import { DisplayInputsValues, IFlowValue, InputsValues } from '@flowgram.ai/form-materials';
import { useIsSidebar, useNodeRenderContext } from '../../../hooks';
import { FormItem } from '../../../form-components';
export function Inputs() {
const isSidebar = useIsSidebar();
const { readonly } = useNodeRenderContext();
if (!isSidebar) {
return (
<Field<Record<string, IFlowValue | undefined> | undefined> name="inputsValues">
{({ field }) => <DisplayInputsValues value={field.value} />}
</Field>
);
}
return (
<FormItem name="inputs" type="object" vertical>
<Field<Record<string, IFlowValue | undefined> | undefined> name="inputsValues">
{({ field }) => (
<InputsValues
value={field.value}
onChange={(value) => field.onChange(value)}
readonly={readonly}
/>
)}
</Field>
</FormItem>
);
}
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Field } from '@flowgram.ai/free-layout-editor';
import { DisplayOutputs, IJsonSchema, JsonSchemaEditor } from '@flowgram.ai/form-materials';
import { Divider } from '@douyinfe/semi-ui';
import { useIsSidebar, useNodeRenderContext } from '../../../hooks';
import { FormItem } from '../../../form-components';
export function Outputs() {
const { readonly } = useNodeRenderContext();
const isSidebar = useIsSidebar();
if (!isSidebar) {
return (
<>
<Divider />
<Field<IJsonSchema> name="outputs">
{({ field }) => <DisplayOutputs value={field.value} />}
</Field>
</>
);
}
return (
<>
<Divider />
<FormItem name="outputs" type="object" vertical>
<Field<IJsonSchema> name="outputs">
{({ field }) => (
<JsonSchemaEditor
readonly={readonly}
value={field.value}
onChange={(value) => field.onChange(value)}
/>
)}
</Field>
</FormItem>
</>
);
}
@@ -0,0 +1,32 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormMeta, FormRenderProps } from '@flowgram.ai/free-layout-editor';
import { createInferInputsPlugin } from '@flowgram.ai/form-materials';
import { FormHeader, FormContent } from '../../form-components';
import { CodeNodeJSON } from './types';
import { Outputs } from './components/outputs';
import { Inputs } from './components/inputs';
import { Code } from './components/code';
import { defaultFormMeta } from '../default-form-meta';
export const FormRender = ({ form }: FormRenderProps<CodeNodeJSON>) => (
<>
<FormHeader />
<FormContent>
<Inputs />
<Code />
<Outputs />
</FormContent>
</>
);
export const formMeta: FormMeta = {
render: (props) => <FormRender {...props} />,
effect: defaultFormMeta.effect,
validate: defaultFormMeta.validate,
plugins: [createInferInputsPlugin({ sourceKey: 'inputsValues', targetKey: 'inputs' })],
};
+86
View File
@@ -0,0 +1,86 @@
/**
* 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 iconCode from '../../assets/icon-script.png';
import { formMeta } from './form-meta';
let index = 0;
const defaultCode = `// Here, you can retrieve input variables from the node using 'params' and output results using 'ret'.
// 'params' has been correctly injected into the environment.
// Here's an example of getting the value of the parameter named 'input' from the node input:
// const input = params.input;
// Here's an example of outputting a 'ret' object containing multiple data types:
// const ret = { "name": 'Xiaoming', "hobbies": ["Reading", "Traveling"] };
async function main({ params }) {
// Build the output object
const ret = {
key0: params.input + params.input, // Concatenate the input parameter 'input' twice
key1: ["hello", "world"], // Output an array
key2: { // Output an Object
key21: "hi"
},
};
return ret;
}`;
export const CodeNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.Code,
info: {
icon: iconCode,
description: 'Run the Script',
},
meta: {
size: {
width: 360,
height: 390,
},
},
onAdd() {
return {
id: `code_${nanoid(5)}`,
type: 'code',
data: {
title: `Code_${++index}`,
inputsValues: {
input: { type: 'constant', content: '' },
},
script: {
language: 'javascript',
content: defaultCode,
},
outputs: {
type: 'object',
properties: {
key0: {
type: 'string',
},
key1: {
type: 'array',
items: {
type: 'string',
},
},
key2: {
type: 'object',
properties: {
key21: {
type: 'string',
},
},
},
},
},
},
};
},
formMeta: formMeta,
};
+20
View File
@@ -0,0 +1,20 @@
/**
* 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 CodeNodeJSON extends FlowNodeJSON {
data: {
title: string;
inputsValues: Record<string, IFlowValue>;
inputs: IJsonSchema<'object'>;
outputs: IJsonSchema<'object'>;
script: {
language: 'javascript';
content: string;
};
};
}
@@ -0,0 +1,26 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { WorkflowNodeType } from '../constants';
import { FlowNodeRegistry } from '../../typings';
export const CommentNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.Comment,
meta: {
sidebarDisabled: true,
nodePanelVisible: false,
defaultPorts: [],
renderKey: WorkflowNodeType.Comment,
size: {
width: 240,
height: 150,
},
},
formMeta: {
render: () => <></>,
},
getInputPoints: () => [], // Comment 节点没有输入
getOutputPoints: () => [], // Comment 节点没有输出
};
@@ -0,0 +1,88 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useLayoutEffect } from 'react';
import { nanoid } from 'nanoid';
import { Field, FieldArray, I18n } from '@flowgram.ai/free-layout-editor';
import { ConditionRow, ConditionRowValueType } from '@flowgram.ai/form-materials';
import { Button } from '@douyinfe/semi-ui';
import { IconPlus, IconCrossCircleStroked } from '@douyinfe/semi-icons';
import { useNodeRenderContext } from '../../../hooks';
import { FormItem } from '../../../form-components';
import { Feedback } from '../../../form-components';
import { ConditionPort } from './styles';
interface ConditionValue {
key: string;
value?: ConditionRowValueType;
}
export function ConditionInputs() {
const { node, readonly } = useNodeRenderContext();
useLayoutEffect(() => {
window.requestAnimationFrame(() => {
node.ports.updateDynamicPorts();
});
}, [node]);
return (
<FieldArray name="conditions">
{({ field }) => (
<>
{field.map((child, index) => (
<Field<ConditionValue> key={child.name} name={child.name}>
{({ field: childField, fieldState: childState }) => (
<FormItem name="if" type="boolean" required={true} labelWidth={50}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<ConditionRow
readonly={readonly}
style={{ flexGrow: 1, overflow: 'hidden' }}
value={childField.value.value}
onChange={(v) => childField.onChange({ value: v, key: childField.value.key })}
/>
{!readonly && (
<Button
theme="borderless"
disabled={readonly}
icon={<IconCrossCircleStroked />}
onClick={() => field.delete(index)}
/>
)}
</div>
<Feedback errors={childState?.errors} invalid={childState?.invalid} />
<ConditionPort data-port-id={childField.value.key} data-port-type="output" />
</FormItem>
)}
</Field>
))}
<FormItem name="else" type="boolean" required={true} labelWidth={100}>
<ConditionPort data-port-id="else" data-port-type="output" />
</FormItem>
{!readonly && (
<div>
<Button
theme="borderless"
icon={<IconPlus />}
onClick={() =>
field.append({
key: `if_${nanoid(6)}`,
value: { type: 'expression', content: '' },
})
}
>
{I18n.t('Add')}
</Button>
</div>
)}
</>
)}
</FieldArray>
);
}
@@ -0,0 +1,12 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import styled from 'styled-components';
export const ConditionPort = styled.div`
position: absolute;
right: -12px;
top: 50%;
`;
@@ -0,0 +1,35 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormRenderProps, FormMeta, ValidateTrigger } from '@flowgram.ai/free-layout-editor';
import { autoRenameRefEffect } from '@flowgram.ai/form-materials';
import { FlowNodeJSON } from '../../typings';
import { FormHeader, FormContent } from '../../form-components';
import { ConditionInputs } from './condition-inputs';
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => (
<>
<FormHeader />
<FormContent>
<ConditionInputs />
</FormContent>
</>
);
export const formMeta: FormMeta<FlowNodeJSON> = {
render: renderForm,
validateTrigger: ValidateTrigger.onChange,
validate: {
title: ({ value }: { value: string }) => (value ? undefined : 'Title is required'),
'conditions.*': ({ value }) => {
if (!value?.value) return 'Condition is required';
return undefined;
},
},
effect: {
conditions: autoRenameRefEffect,
},
};
@@ -0,0 +1,50 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { nanoid } from 'nanoid';
import { FlowNodeRegistry } from '../../typings';
import iconCondition from '../../assets/icon-condition.svg';
import { formMeta } from './form-meta';
import { WorkflowNodeType } from '../constants';
export const ConditionNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.Condition,
info: {
icon: iconCondition,
description:
'Connect multiple downstream branches. Only the corresponding branch will be executed if the set conditions are met.',
},
meta: {
defaultPorts: [{ type: 'input' }],
// Condition Outputs use dynamic port
useDynamicPort: true,
expandable: false, // disable expanded
size: {
width: 360,
height: 210,
},
},
formMeta,
onAdd() {
return {
id: `condition_${nanoid(5)}`,
type: 'condition',
data: {
title: 'Condition',
conditions: [
{
key: `if_${nanoid(5)}`,
value: {},
},
{
key: `if_${nanoid(5)}`,
value: {},
},
],
},
};
},
};
+26
View File
@@ -0,0 +1,26 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
export enum WorkflowNodeType {
Start = 'start',
End = 'end',
LLM = 'llm',
HTTP = 'http',
Code = 'code',
Variable = 'variable',
Condition = 'condition',
MultiCondition = 'multi-condition',
Loop = 'loop',
BlockStart = 'block-start',
BlockEnd = 'block-end',
Comment = 'comment',
Continue = 'continue',
Break = 'break',
}
@@ -0,0 +1,33 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormMeta } from '@flowgram.ai/free-layout-editor';
import { defaultFormMeta } from '../default-form-meta';
import { useIsSidebar } from '../../hooks';
import { FormHeader, FormContent } from '../../form-components';
export const renderForm = () => {
const isSidebar = useIsSidebar();
if (isSidebar) {
return (
<>
<FormHeader />
<FormContent />
</>
);
}
return (
<>
<FormHeader />
<FormContent />
</>
);
};
export const formMeta: FormMeta = {
...defaultFormMeta,
render: renderForm,
};
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { nanoid } from 'nanoid';
import { FlowNodeRegistry } from '../../typings';
import iconContinue from '../../assets/icon-continue.jpg';
import { formMeta } from './form-meta';
import { WorkflowNodeType } from '../constants';
let index = 0;
export const ContinueNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.Continue,
meta: {
defaultPorts: [{ type: 'input' }],
sidebarDisabled: true,
size: {
width: 360,
height: 54,
},
expandable: false,
onlyInContainer: WorkflowNodeType.Loop,
},
info: {
icon: iconContinue,
description:
'The final node of the workflow, used to return the result information after the workflow is run.',
},
/**
* Render node via formMeta
*/
formMeta,
onAdd() {
return {
id: `continue_${nanoid(5)}`,
type: 'continue',
data: {
title: `Continue_${++index}`,
},
};
},
};
@@ -0,0 +1,77 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormRenderProps, FormMeta, ValidateTrigger } from '@flowgram.ai/free-layout-editor';
import {
autoRenameRefEffect,
provideJsonSchemaOutputs,
syncVariableTitle,
DisplayOutputs,
validateFlowValue,
validateWhenVariableSync,
listenRefSchemaChange,
} from '@flowgram.ai/form-materials';
import { Divider } from '@douyinfe/semi-ui';
import { FlowNodeJSON } from '../typings';
import { FormHeader, FormContent, FormInputs } from '../form-components';
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => (
<>
<FormHeader />
<FormContent>
<FormInputs />
<Divider />
<DisplayOutputs displayFromScope />
</FormContent>
</>
);
export const defaultFormMeta: FormMeta<FlowNodeJSON> = {
render: renderForm,
validateTrigger: ValidateTrigger.onChange,
/**
* Supported writing as:
* 1: validate as options: { title: () => {} , ... }
* 2: validate as dynamic function: (values, ctx) => ({ title: () => {}, ... })
*/
validate: {
title: ({ value }) => (value ? undefined : 'Title is required'),
'inputsValues.*': ({ value, context, formValues, name }) => {
const valuePropertyKey = name.replace(/^inputsValues\./, '');
const required = formValues.inputs?.required || [];
return validateFlowValue(value, {
node: context.node,
required: required.includes(valuePropertyKey),
errorMessages: {
required: `${valuePropertyKey} is required`,
},
});
},
},
/**
* Initialize (fromJSON) data transformation
* 初始化(fromJSON) 数据转换
* @param value
* @param ctx
*/
formatOnInit: (value, ctx) => value,
/**
* Save (toJSON) data transformation
* 保存(toJSON) 数据转换
* @param value
* @param ctx
*/
formatOnSubmit: (value, ctx) => value,
effect: {
title: syncVariableTitle,
outputs: provideJsonSchemaOutputs,
inputsValues: [...autoRenameRefEffect, ...validateWhenVariableSync({ scope: 'public' })],
'inputsValues.*': listenRefSchemaChange((params) => {
console.log(`[${params.context.node.id}][${params.name}] Schema Of Ref Updated`);
}),
},
};
@@ -0,0 +1,61 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Field, FormMeta } from '@flowgram.ai/free-layout-editor';
import {
createInferInputsPlugin,
DisplayInputsValues,
IFlowValue,
InputsValues,
} from '@flowgram.ai/form-materials';
import { defaultFormMeta } from '../default-form-meta';
import { useIsSidebar } from '../../hooks';
import { FormHeader, FormContent } from '../../form-components';
export const renderForm = () => {
const isSidebar = useIsSidebar();
if (isSidebar) {
return (
<>
<FormHeader />
<FormContent>
<Field<Record<string, IFlowValue | undefined> | undefined> name="inputsValues">
{({ field: { value, onChange } }) => (
<>
<InputsValues value={value} onChange={(_v) => onChange(_v)} />
</>
)}
</Field>
</FormContent>
</>
);
}
return (
<>
<FormHeader />
<FormContent>
<Field<Record<string, IFlowValue | undefined> | undefined> name="inputsValues">
{({ field: { value } }) => (
<>
<DisplayInputsValues value={value} />
</>
)}
</Field>
</FormContent>
</>
);
};
export const formMeta: FormMeta = {
...defaultFormMeta,
render: renderForm,
plugins: [
createInferInputsPlugin({
sourceKey: 'inputsValues',
targetKey: 'inputs',
}),
],
};
+38
View File
@@ -0,0 +1,38 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowNodeRegistry } from '../../typings';
import iconEnd from '../../assets/icon-end.jpg';
import { formMeta } from './form-meta';
import { WorkflowNodeType } from '../constants';
export const EndNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.End,
meta: {
deleteDisable: true,
copyDisable: true,
nodePanelVisible: false,
defaultPorts: [{ type: 'input' }],
size: {
width: 360,
height: 211,
},
},
info: {
icon: iconEnd,
description:
'The final node of the workflow, used to return the result information after the workflow is run.',
},
/**
* Render node via formMeta
*/
formMeta,
/**
* End Node cannot be added
*/
canAdd() {
return false;
},
};
+66
View File
@@ -0,0 +1,66 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import {
FlowNodeBaseType,
WorkflowNodeEntity,
PositionSchema,
FlowNodeTransformData,
nanoid,
} from '@flowgram.ai/free-layout-editor';
import { FlowNodeRegistry } from '../../typings';
let index = 0;
export const GroupNodeRegistry: FlowNodeRegistry = {
type: FlowNodeBaseType.GROUP,
meta: {
renderKey: FlowNodeBaseType.GROUP,
defaultPorts: [],
isContainer: true,
disableSideBar: true,
size: {
width: 560,
height: 400,
},
padding: () => ({
top: 80,
bottom: 40,
left: 65,
right: 65,
}),
selectable(node: WorkflowNodeEntity, mousePos?: PositionSchema): boolean {
if (!mousePos) {
return true;
}
const transform = node.getData<FlowNodeTransformData>(FlowNodeTransformData);
return !transform.bounds.contains(mousePos.x, mousePos.y);
},
expandable: false,
/**
* It cannot be added through the panel
* 不能通过面板添加
*/
nodePanelVisible: false,
},
formMeta: {
render: () => <></>,
},
onAdd() {
return {
type: FlowNodeBaseType.GROUP,
id: `group_${nanoid(5)}`,
meta: {
position: {
x: 0,
y: 0,
},
},
data: {
color: 'Green',
title: `Group_${++index}`,
},
};
},
};
@@ -0,0 +1,60 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Field } from '@flowgram.ai/free-layout-editor';
import { IFlowTemplateValue, PromptEditorWithVariables } from '@flowgram.ai/form-materials';
import { Select } from '@douyinfe/semi-ui';
import { useNodeRenderContext } from '../../../hooks';
import { FormItem } from '../../../form-components';
export function Api() {
const { readonly } = useNodeRenderContext();
return (
<div>
<FormItem name="API" required vertical type="string">
<div style={{ display: 'flex', gap: 5 }}>
<Field<string> name="api.method" defaultValue="GET">
{({ field }) => (
<Select
value={field.value}
onChange={(value) => {
field.onChange(value as string);
}}
style={{ width: 85, maxWidth: 85, minWidth: 85 }}
size="small"
disabled={readonly}
optionList={[
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
{ label: 'PUT', value: 'PUT' },
{ label: 'DELETE', value: 'DELETE' },
{ label: 'PATCH', value: 'PATCH' },
{ label: 'HEAD', value: 'HEAD' },
]}
/>
)}
</Field>
<Field<IFlowTemplateValue> name="api.url">
{({ field }) => (
<PromptEditorWithVariables
disableMarkdownHighlight
readonly={readonly}
style={{ flexGrow: 1 }}
placeholder="Input URL, use var by '{'"
value={field.value}
onChange={(value) => {
field.onChange(value!);
}}
/>
)}
</Field>
</div>
</FormItem>
</div>
);
}
@@ -0,0 +1,94 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Field } from '@flowgram.ai/free-layout-editor';
import {
IFlowTemplateValue,
JsonEditorWithVariables,
PromptEditorWithVariables,
} from '@flowgram.ai/form-materials';
import { Select } from '@douyinfe/semi-ui';
import { useNodeRenderContext } from '../../../hooks';
import { FormItem } from '../../../form-components';
const BODY_TYPE_OPTIONS = [
{
label: 'None',
value: 'none',
},
{
label: 'JSON',
value: 'JSON',
},
{
label: 'Raw Text',
value: 'raw-text',
},
];
export function Body() {
const { readonly } = useNodeRenderContext();
const renderBodyEditor = (bodyType: string) => {
switch (bodyType) {
case 'JSON':
return (
<Field<IFlowTemplateValue> name="body.json">
{({ field }) => (
<JsonEditorWithVariables
value={field.value?.content}
readonly={readonly}
activeLinePlaceholder="use var by '@'"
onChange={(value) => {
field.onChange({ type: 'template', content: value });
}}
/>
)}
</Field>
);
case 'raw-text':
return (
<Field<IFlowTemplateValue> name="body.rawText">
{({ field }) => (
<PromptEditorWithVariables
disableMarkdownHighlight
readonly={readonly}
style={{ flexGrow: 1 }}
placeholder="Input raw text, use var by '{'"
onChange={(value) => {
field.onChange(value!);
}}
/>
)}
</Field>
);
default:
return null;
}
};
return (
<Field<string> name="body.bodyType" defaultValue="JSON">
{({ field }) => (
<div style={{ marginTop: 5 }}>
<FormItem name="Body" vertical type="object">
<Select
value={field.value}
onChange={(value) => {
field.onChange(value as string);
}}
style={{ width: '100%', marginBottom: 10 }}
disabled={readonly}
size="small"
optionList={BODY_TYPE_OPTIONS}
/>
{renderBodyEditor(field.value)}
</FormItem>
</div>
)}
</Field>
);
}
@@ -0,0 +1,39 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Field } from '@flowgram.ai/free-layout-editor';
import { DisplayInputsValues, IFlowValue, InputsValues } from '@flowgram.ai/form-materials';
import { useIsSidebar, useNodeRenderContext } from '../../../hooks';
import { FormItem } from '../../../form-components';
export function Headers() {
const { readonly } = useNodeRenderContext();
const isSidebar = useIsSidebar();
if (!isSidebar) {
return (
<FormItem name="headers" type="object" vertical>
<Field<Record<string, IFlowValue | undefined> | undefined> name="headersValues">
{({ field }) => <DisplayInputsValues value={field.value} />}
</Field>
</FormItem>
);
}
return (
<FormItem name="headers" type="object" vertical>
<Field<Record<string, IFlowValue | undefined> | undefined> name="headersValues">
{({ field }) => (
<InputsValues
value={field.value}
onChange={(value) => field.onChange(value)}
readonly={readonly}
/>
)}
</Field>
</FormItem>
);
}
@@ -0,0 +1,39 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Field } from '@flowgram.ai/free-layout-editor';
import { DisplayInputsValues, IFlowValue, InputsValues } from '@flowgram.ai/form-materials';
import { useIsSidebar, useNodeRenderContext } from '../../../hooks';
import { FormItem } from '../../../form-components';
export function Params() {
const { readonly } = useNodeRenderContext();
const isSidebar = useIsSidebar();
if (!isSidebar) {
return (
<FormItem name="params" type="object" vertical>
<Field<Record<string, IFlowValue | undefined> | undefined> name="paramsValues">
{({ field }) => <DisplayInputsValues value={field.value} />}
</Field>
</FormItem>
);
}
return (
<FormItem name="params" type="object" vertical>
<Field<Record<string, IFlowValue | undefined> | undefined> name="paramsValues">
{({ field }) => (
<InputsValues
value={field.value}
onChange={(value) => field.onChange(value)}
readonly={readonly}
/>
)}
</Field>
</FormItem>
);
}
@@ -0,0 +1,51 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { Field } from '@flowgram.ai/free-layout-editor';
import { InputNumber } from '@douyinfe/semi-ui';
import { useNodeRenderContext } from '../../../hooks';
import { FormItem } from '../../../form-components';
export function Timeout() {
const { readonly } = useNodeRenderContext();
return (
<div>
<FormItem name="Timeout(ms)" required style={{ flex: 1 }} type="number">
<Field<number> name="timeout.timeout" defaultValue={10000}>
{({ field }) => (
<InputNumber
size="small"
value={field.value}
onChange={(value) => {
field.onChange(value as number);
}}
disabled={readonly}
style={{ width: '100%' }}
min={0}
/>
)}
</Field>
</FormItem>
<FormItem name="Retry Times" required type="number">
<Field<number> name="timeout.retryTimes" defaultValue={1}>
{({ field }) => (
<InputNumber
size="small"
value={field.value}
onChange={(value) => {
field.onChange(value as number);
}}
disabled={readonly}
style={{ width: '100%' }}
min={0}
/>
)}
</Field>
</FormItem>
</div>
);
}
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormMeta, FormRenderProps } from '@flowgram.ai/free-layout-editor';
import { createInferInputsPlugin, DisplayOutputs } from '@flowgram.ai/form-materials';
import { Divider } from '@douyinfe/semi-ui';
import { FormHeader, FormContent } 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';
import { defaultFormMeta } from '../default-form-meta';
export const FormRender = ({ form }: FormRenderProps<HTTPNodeJSON>) => (
<>
<FormHeader />
<FormContent>
<Api />
<Divider />
<Headers />
<Divider />
<Params />
<Divider />
<Body />
<Divider />
<Timeout />
<Divider />
<DisplayOutputs displayFromScope />
</FormContent>
</>
);
export const formMeta: FormMeta = {
render: (props) => <FormRender {...props} />,
effect: defaultFormMeta.effect,
plugins: [
createInferInputsPlugin({ sourceKey: 'headersValues', targetKey: 'headers' }),
createInferInputsPlugin({ sourceKey: 'paramsValues', targetKey: 'params' }),
],
};
+53
View File
@@ -0,0 +1,53 @@
/**
* 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 iconHTTP from '../../assets/icon-http.svg';
import { formMeta } from './form-meta';
let index = 0;
export const HTTPNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.HTTP,
info: {
icon: iconHTTP,
description: 'Call the HTTP API',
},
meta: {
size: {
width: 360,
height: 390,
},
},
onAdd() {
return {
id: `http_${nanoid(5)}`,
type: 'http',
data: {
title: `HTTP_${++index}`,
api: {
method: 'GET',
},
body: {
bodyType: 'JSON',
},
headers: {},
params: {},
outputs: {
type: 'object',
properties: {
body: { type: 'string' },
headers: { type: 'object' },
statusCode: { type: 'integer' },
},
},
},
};
},
formMeta: formMeta,
};
+36
View File
@@ -0,0 +1,36 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { IFlowConstantRefValue } from '@flowgram.ai/runtime-interface';
import { FlowNodeJSON } from '@flowgram.ai/free-layout-editor';
import { IFlowTemplateValue, IJsonSchema } from '@flowgram.ai/form-materials';
export interface HTTPNodeJSON extends FlowNodeJSON {
data: {
title: string;
outputs: IJsonSchema<'object'>;
api: {
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD';
url: IFlowTemplateValue;
};
headers: IJsonSchema<'object'>;
headersValues: Record<string, IFlowConstantRefValue>;
params: IJsonSchema<'object'>;
paramsValues: Record<string, IFlowConstantRefValue>;
body: {
bodyType: 'none' | 'form-data' | 'x-www-form-urlencoded' | 'raw-text' | 'JSON';
json?: IFlowTemplateValue;
formData?: IJsonSchema<'object'>;
formDataValues?: Record<string, IFlowConstantRefValue>;
rawText?: IFlowTemplateValue;
xWwwFormUrlencoded?: IJsonSchema<'object'>;
xWwwFormUrlencodedValues?: Record<string, IFlowConstantRefValue>;
};
timeout: {
retryTimes: number;
timeout: number;
};
};
}
+45
View File
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowNodeRegistry } from '../typings';
import { VariableNodeRegistry } from './variable';
import { StartNodeRegistry } from './start';
import { LoopNodeRegistry } from './loop';
import { LLMNodeRegistry } from './llm';
import { HTTPNodeRegistry } from './http';
import { GroupNodeRegistry } from './group';
import { EndNodeRegistry } from './end';
import { ContinueNodeRegistry } from './continue';
import { ConditionNodeRegistry } from './condition';
import { CommentNodeRegistry } from './comment';
import { CodeNodeRegistry } from './code';
import { BreakNodeRegistry } from './break';
import { BlockStartNodeRegistry } from './block-start';
import { BlockEndNodeRegistry } from './block-end';
import { MultiConditionNodeRegistry } from "./multi-condition";
export { WorkflowNodeType } from './constants';
export const nodeRegistries: FlowNodeRegistry[] = [
ConditionNodeRegistry,
StartNodeRegistry,
EndNodeRegistry,
LLMNodeRegistry,
LoopNodeRegistry,
CommentNodeRegistry,
BlockStartNodeRegistry,
BlockEndNodeRegistry,
HTTPNodeRegistry,
CodeNodeRegistry,
ContinueNodeRegistry,
BreakNodeRegistry,
VariableNodeRegistry,
GroupNodeRegistry,
MultiConditionNodeRegistry,
];
+97
View File
@@ -0,0 +1,97 @@
/**
* 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 iconLLM from '../../assets/icon-llm.jpg';
let index = 0;
export const LLMNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.LLM,
info: {
icon: iconLLM,
description:
'Call the large language model and use variables and prompt words to generate responses.',
},
meta: {
size: {
width: 360,
height: 390,
},
},
onAdd() {
return {
id: `llm_${nanoid(5)}`,
type: 'llm',
data: {
title: `LLM_${++index}`,
inputsValues: {
modelName: {
type: 'constant',
content: 'gpt-3.5-turbo',
},
apiKey: {
type: 'constant',
content: 'sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
},
apiHost: {
type: 'constant',
content: 'https://mock-ai-url/api/v3',
},
temperature: {
type: 'constant',
content: 0.5,
},
systemPrompt: {
type: 'template',
content: '# Role\nYou are an AI assistant.\n',
},
prompt: {
type: 'template',
content: '',
},
},
inputs: {
type: 'object',
required: ['modelName', 'apiKey', 'apiHost', 'temperature', 'prompt'],
properties: {
modelName: {
type: 'string',
},
apiKey: {
type: 'string',
},
apiHost: {
type: 'string',
},
temperature: {
type: 'number',
},
systemPrompt: {
type: 'string',
extra: {
formComponent: 'prompt-editor',
},
},
prompt: {
type: 'string',
extra: {
formComponent: 'prompt-editor',
},
},
},
},
outputs: {
type: 'object',
properties: {
result: { type: 'string' },
},
},
},
};
},
};
@@ -0,0 +1,96 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormRenderProps, FlowNodeJSON, Field, FormMeta } from '@flowgram.ai/free-layout-editor';
import { SubCanvasRender } from '@flowgram.ai/free-container-plugin';
import {
BatchOutputs,
BatchVariableSelector,
createBatchOutputsFormPlugin,
DisplayOutputs,
IFlowRefValue,
provideBatchInputEffect,
} from '@flowgram.ai/form-materials';
import { defaultFormMeta } from '../default-form-meta';
import { useIsSidebar, useNodeRenderContext } from '../../hooks';
import { FormHeader, FormContent, FormItem, Feedback } from '../../form-components';
interface LoopNodeJSON extends FlowNodeJSON {
data: {
loopFor: IFlowRefValue;
};
}
export const LoopFormRender = ({ form }: FormRenderProps<LoopNodeJSON>) => {
const isSidebar = useIsSidebar();
const { readonly } = useNodeRenderContext();
const formHeight = 115;
const loopFor = (
<Field<IFlowRefValue> name={`loopFor`}>
{({ field, fieldState }) => (
<FormItem name={'loopFor'} type={'array'} required>
<BatchVariableSelector
style={{ width: '100%' }}
value={field.value?.content}
onChange={(val) => field.onChange({ type: 'ref', content: val })}
readonly={readonly}
hasError={Object.keys(fieldState?.errors || {}).length > 0}
/>
<Feedback errors={fieldState?.errors} />
</FormItem>
)}
</Field>
);
const loopOutputs = (
<Field<Record<string, IFlowRefValue | undefined> | undefined> name={`loopOutputs`}>
{({ field, fieldState }) => (
<FormItem name="loopOutputs" type="object" vertical>
<BatchOutputs
style={{ width: '100%' }}
value={field.value}
onChange={(val) => field.onChange(val)}
readonly={readonly}
hasError={Object.keys(fieldState?.errors || {}).length > 0}
/>
<Feedback errors={fieldState?.errors} />
</FormItem>
)}
</Field>
);
if (isSidebar) {
return (
<>
<FormHeader />
<FormContent>
{loopFor}
{loopOutputs}
</FormContent>
</>
);
}
return (
<>
<FormHeader />
<FormContent>
{loopFor}
<SubCanvasRender offsetY={-formHeight} />
<DisplayOutputs displayFromScope />
</FormContent>
</>
);
};
export const formMeta: FormMeta = {
...defaultFormMeta,
render: LoopFormRender,
effect: {
loopFor: provideBatchInputEffect,
},
plugins: [createBatchOutputsFormPlugin({ outputKey: 'loopOutputs', inferTargetKey: 'outputs' })],
};
+114
View File
@@ -0,0 +1,114 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { nanoid } from 'nanoid';
import {
WorkflowNodeEntity,
PositionSchema,
FlowNodeTransformData,
} from '@flowgram.ai/free-layout-editor';
import { FlowNodeRegistry } from '../../typings';
import iconLoop from '../../assets/icon-loop.jpg';
import { formMeta } from './form-meta';
import { WorkflowNodeType } from '../constants';
let index = 0;
export const LoopNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.Loop,
info: {
icon: iconLoop,
description:
'Used to repeatedly execute a series of tasks by setting the number of iterations and logic.',
},
meta: {
/**
* Mark as subcanvas
* 子画布标记
*/
isContainer: true,
/**
* The subcanvas default size setting
* 子画布默认大小设置
*/
size: {
width: 424,
height: 244,
},
// autoResizeDisable: true,
/**
* The subcanvas padding setting
* 子画布 padding 设置
*/
padding: (transform) => {
if (!transform.isContainer) {
return {
top: 0,
bottom: 0,
left: 0,
right: 0,
};
}
return {
top: 120,
bottom: 80,
left: 80,
right: 80,
};
},
/**
* Controls the node selection status within the subcanvas
* 控制子画布内的节点选中状态
*/
selectable(node: WorkflowNodeEntity, mousePos?: PositionSchema): boolean {
if (!mousePos) {
return true;
}
const transform = node.getData<FlowNodeTransformData>(FlowNodeTransformData);
// 鼠标开始时所在位置不包括当前节点时才可选中
return !transform.bounds.contains(mousePos.x, mousePos.y);
},
// expandable: false, // disable expanded
wrapperStyle: {
minWidth: 'unset',
width: '100%',
},
// defaultPorts: [{ type: 'output', location: 'right' }, { type: 'input', location: 'left'}, { type: 'output', location: 'bottom', portID: 'bottom' }, { type: 'input', location: 'top', portID: 'top'}]
},
onAdd() {
return {
id: `loop_${nanoid(5)}`,
type: WorkflowNodeType.Loop,
data: {
title: `Loop_${++index}`,
},
blocks: [
{
id: `block_start_${nanoid(5)}`,
type: WorkflowNodeType.BlockStart,
meta: {
position: {
x: 32,
y: 0,
},
},
data: {},
},
{
id: `block_end_${nanoid(5)}`,
type: WorkflowNodeType.BlockEnd,
meta: {
position: {
x: 192,
y: 0,
},
},
data: {},
},
],
};
},
formMeta,
};
@@ -0,0 +1,185 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { useLayoutEffect } from 'react';
import { nanoid } from 'nanoid';
import { Field, FieldArray, I18n, WorkflowNodePortsData } from '@flowgram.ai/free-layout-editor';
import { ConditionRow, ConditionRowValueType } from '@flowgram.ai/form-materials';
import { Button, Select, Space } from '@douyinfe/semi-ui';
import { IconCrossCircleStroked, IconDelete, IconPlus } from '@douyinfe/semi-icons';
import { useNodeRenderContext, useIsSidebar } from '../../../hooks';
import { Feedback, FormItem } from '../../../form-components';
import { ConditionBranch, ConditionBranchLogic, ConditionPort } from './styles';
interface ConditionValue {
key: string;
value?: ConditionRowValueType;
}
interface BranchItem {
logic: string; // 'and' | 'or'
conditions: ConditionValue[];
}
export function ConditionInputs() {
const { node, readonly } = useNodeRenderContext();
const isSidebar = useIsSidebar();
useLayoutEffect(() => {
window.requestAnimationFrame(() => {
node.getData<WorkflowNodePortsData>(WorkflowNodePortsData).updateDynamicPorts();
});
}, [node]);
return (
<FieldArray name="branch">
{({ field: conditions }) => (
<>
{conditions.map((branch, index) => (
<Field<BranchItem> name={branch.name} key={branch.name}>
{({ field, fieldState }) => (
<FormItem
type="boolean"
labelWidth={100}
name={index === 0 ? I18n.t('IF') : I18n.t('ELSE-IF')}
vertical
required={index === 0}
>
<ConditionBranch>
{field.value.conditions.length > 1 && (
<ConditionBranchLogic>
<Select
size="small"
value={field.value.logic}
style={{ backgroundColor: 'var(--semi-color-bg-0)' }}
onChange={(v) =>
field.onChange({
...field.value,
logic: (v as string) ?? 'and',
})
}
>
<Select.Option value="and">{I18n.t('AND')}</Select.Option>
<Select.Option value="or">{I18n.t('OR')}</Select.Option>
</Select>
</ConditionBranchLogic>
)}
<div style={{ flex: 1 }}>
{field.value.conditions.map((condition, childIndex) => (
<Field<ConditionValue>
name={`${field.name}.conditions.${childIndex}`}
key={condition.key}
>
{({ field: conditionField }) => (
<Space align="center" style={{ padding: '6px 0', width: '100%' }}>
<div style={{ flex: 1 }}>
<ConditionRow
readonly={readonly}
value={conditionField.value.value}
onChange={(v) => {
conditionField.onChange({
value: v,
key: conditionField.value.key,
});
}}
/>
</div>
{/*remove current branch condition*/}
{isSidebar && !readonly && (
<Button
theme="borderless"
disabled={field.value?.conditions.length === 1}
icon={<IconCrossCircleStroked />}
onClick={() =>
field.onChange({
...field.value,
conditions: field.value.conditions.filter(
(i: ConditionValue) => i.key !== condition.key
),
})
}
/>
)}
</Space>
)}
</Field>
))}
</div>
<ConditionPort data-port-id={`${branch.name}`} data-port-type="output" />
</ConditionBranch>
{/* remove current branch and add new condition*/}
{isSidebar && !readonly && (
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
size="small"
theme="borderless"
icon={<IconPlus />}
onClick={() => {
field.onChange({
...field.value,
conditions: [
...field.value.conditions,
{
key: `condition_${nanoid(6)}`,
value: {},
},
],
});
}}
>
{I18n.t('Add condition')}
</Button>
<Button
disabled={conditions.value?.length === 1}
size="small"
theme="borderless"
icon={<IconDelete />}
onClick={() => conditions.remove(index)}
>
{I18n.t('Remove branch')}
</Button>
</div>
)}
<Feedback errors={fieldState?.errors} invalid={fieldState?.invalid} />
</FormItem>
)}
</Field>
))}
{/* else */}
<FormItem name={I18n.t('ELSE')} type="boolean" required={true} labelWidth={100}>
<ConditionPort data-port-id="else" data-port-type="output" />
</FormItem>
{!readonly && (
<div>
<Button
theme="borderless"
icon={<IconPlus />}
onClick={() =>
conditions.append({
logic: 'and',
conditions: [
{
key: `condition_${nanoid(6)}`,
value: {},
},
],
})
}
>
{I18n.t('Add branch')}
</Button>
</div>
)}
</>
)}
</FieldArray>
);
}
@@ -0,0 +1,43 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import styled from "styled-components";
export const ConditionPort = styled.div`
position: absolute;
right: -12px;
top: 50%;
`;
export const ConditionBranch = styled.div`
display: flex;
width: 100%;
align-items: stretch;
position: relative;
`;
export const ConditionBranchLogic = styled.div`
position: relative;
width: 80px;
display: flex;
align-items: center;
&::before {
content: '';
position: absolute;
width: 50%;
border: 1px solid var(--semi-color-tertiary-light-active);
border-radius: 6px 0 0 6px;
border-right: none;
left: 50%;
top: 32px;
bottom: 32px;
}
`;
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FormRenderProps, FormMeta, ValidateTrigger } from '@flowgram.ai/free-layout-editor';
import { autoRenameRefEffect } from '@flowgram.ai/form-materials';
import { FlowNodeJSON } from '../../typings';
import { FormHeader, FormContent } from '../../form-components';
import { ConditionInputs } from './condition-inputs';
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => (
<>
<FormHeader />
<FormContent>
<ConditionInputs />
</FormContent>
</>
);
export const formMeta: FormMeta<FlowNodeJSON> = {
render: renderForm,
validateTrigger: ValidateTrigger.onChange,
validate: {
title: ({ value }: { value: string }) => (value ? undefined : 'Title is required'),
'branch.*': ({ value }) => {
const haveEmptyCondition =
value.conditions.filter((item: any) => {
return Object.keys(item.value).length === 0;
}).length > 0;
if (haveEmptyCondition) return 'Condition is required';
return undefined;
},
},
effect: {
conditions: autoRenameRefEffect,
},
};
@@ -0,0 +1,57 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { nanoid } from 'nanoid';
import { FlowNodeRegistry } from '../../typings';
import { WorkflowNodeType } from '../constants';
import iconCondition from '../../assets/icon-condition.svg';
import { formMeta } from './form-meta';
let index = 0;
export const MultiConditionNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.MultiCondition,
info: {
icon: iconCondition,
description:
'Connect multiple downstream branches. Only the corresponding branch will be executed if the set conditions are met.',
},
meta: {
defaultPorts: [{ type: 'input' }],
// Condition Outputs use dynamic port
useDynamicPort: true,
expandable: false, // disable expanded
size: {
width: 360,
height: 210,
},
},
formMeta,
onAdd() {
return {
id: `multi_condition_${nanoid(5)}`,
type: 'condition',
data: {
title: `multi_condition_${++index}`,
branch: [
{
logic: 'and',
conditions: [
{
key: `condition_${nanoid(5)}`,
value: {},
},
],
},
],
},
};
},
};
@@ -0,0 +1,66 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import {
Field,
FieldRenderProps,
FormRenderProps,
FormMeta,
ValidateTrigger,
} from '@flowgram.ai/free-layout-editor';
import {
DisplayOutputs,
JsonSchemaEditor,
provideJsonSchemaOutputs,
syncVariableTitle,
} from '@flowgram.ai/form-materials';
import { FlowNodeJSON, JsonSchema } from '../../typings';
import { useIsSidebar } from '../../hooks';
import { FormHeader, FormContent } from '../../form-components';
export const renderForm = ({ form }: FormRenderProps<FlowNodeJSON>) => {
const isSidebar = useIsSidebar();
if (isSidebar) {
return (
<>
<FormHeader />
<FormContent>
<Field
name="outputs"
render={({ field: { value, onChange } }: FieldRenderProps<JsonSchema>) => (
<>
<JsonSchemaEditor
value={value}
onChange={(value) => onChange(value as JsonSchema)}
/>
</>
)}
/>
</FormContent>
</>
);
}
return (
<>
<FormHeader />
<FormContent>
<DisplayOutputs displayFromScope />
</FormContent>
</>
);
};
export const formMeta: FormMeta<FlowNodeJSON> = {
render: renderForm,
validateTrigger: ValidateTrigger.onChange,
validate: {
title: ({ value }: { value: string }) => (value ? undefined : 'Title is required'),
},
effect: {
title: syncVariableTitle,
outputs: provideJsonSchemaOutputs,
},
};
+39
View File
@@ -0,0 +1,39 @@
/**
* Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
* SPDX-License-Identifier: MIT
*/
import { FlowNodeRegistry } from '../../typings';
import iconStart from '../../assets/icon-start.jpg';
import { formMeta } from './form-meta';
import { WorkflowNodeType } from '../constants';
export const StartNodeRegistry: FlowNodeRegistry = {
type: WorkflowNodeType.Start,
meta: {
isStart: true,
deleteDisable: true,
copyDisable: true,
nodePanelVisible: false,
defaultPorts: [{ type: 'output' }],
size: {
width: 360,
height: 211,
},
},
info: {
icon: iconStart,
description:
'The starting node of the workflow, used to set the information needed to initiate the workflow.',
},
/**
* Render node via formMeta
*/
formMeta,
/**
* Start Node cannot be added
*/
canAdd() {
return false;
},
};
@@ -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',
}),
],
};
@@ -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,
};
@@ -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 { AssignValueType, IJsonSchema } from '@flowgram.ai/form-materials';
export interface VariableNodeJSON extends FlowNodeJSON {
data: {
title: string;
assign: AssignValueType[];
outputs: IJsonSchema<'object'>;
};
}