feat: 完成工作流开始节点开场表单
- 增加开始节点 startFormMeta/startFormSchema 配置与运行参数解析 - 统一 Admin/UserCenter 开场表单渲染与文件集合输入 - 补充开始表单校验、引用迁移和前端工具测试
This commit is contained in:
@@ -71,6 +71,47 @@
|
||||
const onRunTest = options.onRunTest;
|
||||
|
||||
const { updateEdgeData } = useUpdateEdgeData();
|
||||
const pendingParentRepairs = new Set<string>();
|
||||
|
||||
function scheduleOrphanParentRepair(nodeId: string) {
|
||||
if (!nodeId || pendingParentRepairs.has(nodeId)) {
|
||||
return;
|
||||
}
|
||||
pendingParentRepairs.add(nodeId);
|
||||
queueMicrotask(() => {
|
||||
pendingParentRepairs.delete(nodeId);
|
||||
const currentNode = getNode(nodeId);
|
||||
const parentId = asString(currentNode?.parentId).trim();
|
||||
if (!currentNode || !parentId || getNode(parentId)) {
|
||||
return;
|
||||
}
|
||||
svelteFlow.updateNode(nodeId, {
|
||||
parentId: undefined
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function getResolvedParentId(node: null | Node | undefined) {
|
||||
const parentId = asString(node?.parentId).trim();
|
||||
if (!parentId) {
|
||||
return undefined;
|
||||
}
|
||||
if (getNode(parentId)) {
|
||||
return parentId;
|
||||
}
|
||||
if (node?.id) {
|
||||
scheduleOrphanParentRepair(node.id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function repairOrphanParentNodes() {
|
||||
store.getNodes().forEach((node) => {
|
||||
if (asString(node.parentId).trim() && !getResolvedParentId(node)) {
|
||||
scheduleOrphanParentRepair(node.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getEventClientPosition(event: any) {
|
||||
if (typeof event?.clientX === 'number' && typeof event?.clientY === 'number') {
|
||||
@@ -353,24 +394,27 @@
|
||||
const isValidConnection = (conn: any) => {
|
||||
const sourceNode = getNode(conn.source)!;
|
||||
const targetNode = getNode(conn.target)!;
|
||||
const sourceParentId = getResolvedParentId(sourceNode);
|
||||
const targetParentId = getResolvedParentId(targetNode);
|
||||
|
||||
// 阻止循环节点连接到父级节点 或者 父级节点连接到子级节点
|
||||
if (conn.sourceHandle === 'loop_handle' || sourceNode.parentId) {
|
||||
if (conn.sourceHandle === 'loop_handle' || sourceParentId) {
|
||||
const edges = svelteFlow.getEdges();
|
||||
for (let edge of edges) {
|
||||
if (edge.target === conn.target) {
|
||||
const edgeSourceNode = getNode(edge.source) as Node;
|
||||
if (conn.sourceHandle === 'loop_handle' && edgeSourceNode.parentId !== sourceNode.id) {
|
||||
const edgeSourceParentId = getResolvedParentId(edgeSourceNode);
|
||||
if (conn.sourceHandle === 'loop_handle' && edgeSourceParentId !== sourceNode.id) {
|
||||
return false;
|
||||
}
|
||||
if (sourceNode.parentId && edgeSourceNode.parentId !== sourceNode.parentId) {
|
||||
if (sourceParentId && edgeSourceParentId !== sourceParentId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceNode.parentId && targetNode.parentId && targetNode.parentId !== sourceNode.id) {
|
||||
if (!sourceParentId && targetParentId && targetParentId !== sourceNode.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -446,12 +490,14 @@
|
||||
closeNodePicker();
|
||||
|
||||
const toNode = state.toNode as Node;
|
||||
if (toNode.parentId) {
|
||||
const targetParentId = getResolvedParentId(toNode);
|
||||
if (targetParentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fromNode = state.fromNode as Node;
|
||||
const fromHande = state.fromHandle as any;
|
||||
const sourceParentId = getResolvedParentId(fromNode);
|
||||
|
||||
const newNode = {
|
||||
position: { ...toNode.position }
|
||||
@@ -459,8 +505,8 @@
|
||||
|
||||
if (fromHande.id === 'loop_handle') {
|
||||
newNode.parentId = fromNode.id;
|
||||
} else if (fromNode.parentId) {
|
||||
newNode.parentId = fromNode.parentId;
|
||||
} else if (sourceParentId) {
|
||||
newNode.parentId = sourceParentId;
|
||||
}
|
||||
|
||||
if (newNode.parentId) {
|
||||
@@ -514,10 +560,11 @@
|
||||
showEdgePanel = false;
|
||||
}
|
||||
const targetNode = getNode(edge.target) as Node;
|
||||
if (targetNode && targetNode.parentId) {
|
||||
const targetParentId = getResolvedParentId(targetNode as Node);
|
||||
if (targetNode && targetParentId) {
|
||||
const nodeEdges = getEdgesByTarget(edge.target);
|
||||
// const loopNode = getNode(targetNode.parentId) as Node;
|
||||
const { x, y } = getNodeRelativePosition(targetNode.parentId);
|
||||
const { x, y } = getNodeRelativePosition(targetParentId);
|
||||
if (nodeEdges.length === 0) {
|
||||
svelteFlow.updateNode(targetNode.id, {
|
||||
parentId: undefined,
|
||||
@@ -543,7 +590,7 @@
|
||||
for (let i = 0; i < nodeEdges.length; i++) {
|
||||
const edge = nodeEdges[i];
|
||||
const sourceNode = getNode(edge.source) as Node;
|
||||
if (sourceNode.parentId || sourceNode.type === 'loopNode') {
|
||||
if (getResolvedParentId(sourceNode) || sourceNode.type === 'loopNode') {
|
||||
hasSameParent = true;
|
||||
break;
|
||||
}
|
||||
@@ -680,6 +727,7 @@
|
||||
|
||||
onMount(() => {
|
||||
store.updateEdges((edges) => edges.map((edge) => ensureEdgeVisualDefaults(edge)));
|
||||
repairOrphanParentNodes();
|
||||
if (!readonly) {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('paste', handleGlobalPaste);
|
||||
|
||||
@@ -4,7 +4,15 @@
|
||||
import {useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {contentTypes, startFormTypes} from '#consts';
|
||||
import type {Parameter} from '#types';
|
||||
import {store} from '#store/stores.svelte';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {
|
||||
renameStartFieldReferencesInNodes,
|
||||
removeStartFormField,
|
||||
START_NODE_TYPE,
|
||||
SYSTEM_START_PARAM_NAME,
|
||||
updateStartFormField,
|
||||
} from '../../utils/workflowNodeFields';
|
||||
|
||||
const { parameter, index }: {
|
||||
parameter: Parameter,
|
||||
@@ -20,10 +28,108 @@
|
||||
...(node?.current?.data?.parameters as Array<Parameter>)[index]
|
||||
};
|
||||
});
|
||||
let isSystemStartParam = $derived.by(() => {
|
||||
return param.systemReserved === true && param.name === SYSTEM_START_PARAM_NAME;
|
||||
});
|
||||
let isStartNodeInputParam = $derived.by(() => {
|
||||
return node?.current?.type === START_NODE_TYPE && param.refType === 'input';
|
||||
});
|
||||
let availableFormTypes = $derived.by(() => {
|
||||
if (isSystemStartParam) {
|
||||
return startFormTypes.filter((item) => item.value === 'input' || item.value === 'textarea');
|
||||
}
|
||||
return startFormTypes;
|
||||
});
|
||||
let displayFormTypeValue = $derived.by(() => {
|
||||
if (isStartNodeInputParam && param.contentType === 'file') {
|
||||
return ['file'];
|
||||
}
|
||||
return param.formType ? [param.formType] : [];
|
||||
});
|
||||
let displayParamName = $derived.by(() => {
|
||||
if (isSystemStartParam) {
|
||||
return '用户问题';
|
||||
}
|
||||
return param.name;
|
||||
});
|
||||
|
||||
const { updateNodeData } = useSvelteFlow();
|
||||
|
||||
const toStartFormFieldType = (value: string) => {
|
||||
if (value === 'input') {
|
||||
return 'text';
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const trimString = (value: unknown) => {
|
||||
return value == null ? '' : String(value).trim();
|
||||
};
|
||||
|
||||
const applyStartFieldPatch = (fieldKey: string, patch: Record<string, any>) => {
|
||||
const currentFieldId = trimString(param.id);
|
||||
store.updateNodes((nodes) => {
|
||||
const edges = store.getEdges();
|
||||
let nextFieldKey = fieldKey;
|
||||
const nextNodes = nodes.map((currentNode) => {
|
||||
if (currentNode.id !== currentNodeId) {
|
||||
return currentNode;
|
||||
}
|
||||
const nextData = updateStartFormField(currentNode.data as Record<string, any>, fieldKey, patch);
|
||||
const nextSchema = Array.isArray(nextData.startFormSchema) ? nextData.startFormSchema : [];
|
||||
const matchedField = nextSchema.find((field) => {
|
||||
return (currentFieldId && trimString(field?.id) === currentFieldId)
|
||||
|| trimString(field?.key) === fieldKey;
|
||||
});
|
||||
nextFieldKey = trimString(matchedField?.key) || fieldKey;
|
||||
return {
|
||||
...currentNode,
|
||||
data: {
|
||||
...((currentNode.data || {}) as Record<string, any>),
|
||||
...nextData
|
||||
}
|
||||
};
|
||||
});
|
||||
return renameStartFieldReferencesInNodes(
|
||||
nextNodes,
|
||||
edges,
|
||||
currentNodeId,
|
||||
fieldKey,
|
||||
nextFieldKey
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const updateParameter = (key: string, value: any) => {
|
||||
if (isStartNodeInputParam) {
|
||||
const fieldKey = param.name || '';
|
||||
if (!fieldKey) {
|
||||
return;
|
||||
}
|
||||
let patch: Record<string, any> = {};
|
||||
if (key === 'name') {
|
||||
patch = { key: value };
|
||||
} else if (key === 'required') {
|
||||
patch = { required: Boolean(value) };
|
||||
} else if (key === 'formType') {
|
||||
patch = { type: toStartFormFieldType(value) };
|
||||
} else if (key === 'formLabel') {
|
||||
patch = { label: value };
|
||||
} else if (key === 'formDescription') {
|
||||
patch = { description: value };
|
||||
} else if (key === 'formPlaceholder') {
|
||||
patch = { placeholder: value };
|
||||
} else if (key === 'enums') {
|
||||
patch = { options: Array.isArray(value) ? value : [] };
|
||||
} else if (key === 'contentType') {
|
||||
patch = { type: value === 'file' ? 'file' : 'text' };
|
||||
}
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return;
|
||||
}
|
||||
applyStartFieldPatch(fieldKey, patch);
|
||||
return;
|
||||
}
|
||||
updateNodeData(currentNodeId, (node) => {
|
||||
let parameters = node.data.parameters as Array<Parameter>;
|
||||
(parameters[index] as any)[key] = value;
|
||||
@@ -51,6 +157,9 @@
|
||||
const updateFormType = (item: any) => {
|
||||
const newValue = item.value;
|
||||
updateParameter('formType', newValue);
|
||||
if (isSystemStartParam) {
|
||||
updateParameter('contentType', 'text');
|
||||
}
|
||||
};
|
||||
|
||||
const updateContentType = (item: any) => {
|
||||
@@ -61,6 +170,13 @@
|
||||
|
||||
let triggerObject: any;
|
||||
const handleDelete = () => {
|
||||
if (isStartNodeInputParam) {
|
||||
updateNodeData(currentNodeId, (node) => {
|
||||
return removeStartFormField(node.data as Record<string, any>, param.name);
|
||||
});
|
||||
triggerObject?.hide();
|
||||
return;
|
||||
}
|
||||
updateNodeData(currentNodeId, (node) => {
|
||||
let parameters = node.data.parameters as Array<Parameter>;
|
||||
parameters.splice(index, 1);
|
||||
@@ -75,7 +191,7 @@
|
||||
|
||||
|
||||
<div class="input-item">
|
||||
<Input style="width: 100%;" value={param.name} placeholder="请输入参数名称"
|
||||
<Input style="width: 100%;" value={displayParamName} placeholder="请输入参数名称"
|
||||
disabled={param.nameDisabled === true}
|
||||
oninput={updateName} />
|
||||
</div>
|
||||
@@ -94,22 +210,21 @@
|
||||
<div class="input-more-setting">
|
||||
{#if param.systemReserved}
|
||||
<div class="input-more-item">
|
||||
系统入口参数,当前不可编辑。
|
||||
系统入口参数,名称和必填规则固定,可调整展示标题、说明、占位符和输入方式。
|
||||
</div>
|
||||
{/if}
|
||||
<div class="input-more-item">
|
||||
数据内容:
|
||||
<Select items={contentTypes} style="width: 100%" defaultValue={["text"]}
|
||||
value={param.contentType ? [param.contentType] : []}
|
||||
disabled={param.systemReserved === true}
|
||||
disabled={param.systemReserved === true || isStartNodeInputParam}
|
||||
onSelect={updateContentType}
|
||||
/>
|
||||
</div>
|
||||
<div class="input-more-item">
|
||||
输入方式:
|
||||
<Select items={startFormTypes} style="width: 100%" defaultValue={["input"]}
|
||||
value={param.formType ? [param.formType] : []}
|
||||
disabled={param.systemReserved === true}
|
||||
<Select items={availableFormTypes} style="width: 100%" defaultValue={["input"]}
|
||||
value={displayFormTypeValue}
|
||||
onSelect={updateFormType}
|
||||
/>
|
||||
</div>
|
||||
@@ -127,21 +242,21 @@
|
||||
数据标题:
|
||||
<Textarea rows={1} style="width: 100%;" onchange={(event)=>{
|
||||
updateParamByEvent('formLabel', event)
|
||||
}} disabled={param.systemReserved === true} value={param.formLabel} />
|
||||
}} value={param.formLabel} />
|
||||
</div>
|
||||
|
||||
<div class="input-more-item">
|
||||
数据描述:
|
||||
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
|
||||
updateParamByEvent('formDescription', event)
|
||||
}} disabled={param.systemReserved === true} value={param.formDescription} />
|
||||
}} value={param.formDescription} />
|
||||
</div>
|
||||
|
||||
<div class="input-more-item">
|
||||
占位符:
|
||||
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
|
||||
updateParamByEvent('formPlaceholder', event)
|
||||
}} disabled={param.systemReserved === true} value={param.formPlaceholder} />
|
||||
}} value={param.formPlaceholder} />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
} from '@xyflow/svelte';
|
||||
import {Button, Collapse, FloatingTrigger, Input, Textarea} from '../base';
|
||||
import {type Snippet} from 'svelte';
|
||||
import {onDestroy, onMount} from 'svelte';
|
||||
import {useDeleteNode} from '../utils/useDeleteNode.svelte';
|
||||
import {useCopyNode} from '../utils/useCopyNode.svelte';
|
||||
import {getOptions} from '../utils/NodeUtils';
|
||||
@@ -73,6 +74,38 @@
|
||||
options.onNodeExecute?.(getNode(id)!);
|
||||
};
|
||||
let currentNodeId = getCurrentNodeId();
|
||||
let wrapperElement: HTMLDivElement | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let resizeFrame = 0;
|
||||
|
||||
const scheduleUpdateNodeInternals = () => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
if (resizeFrame) {
|
||||
cancelAnimationFrame(resizeFrame);
|
||||
}
|
||||
resizeFrame = requestAnimationFrame(() => {
|
||||
updateNodeInternals(id);
|
||||
});
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
scheduleUpdateNodeInternals();
|
||||
if (typeof ResizeObserver !== 'undefined' && wrapperElement) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
scheduleUpdateNodeInternals();
|
||||
});
|
||||
resizeObserver.observe(wrapperElement);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (resizeFrame) {
|
||||
cancelAnimationFrame(resizeFrame);
|
||||
}
|
||||
resizeObserver?.disconnect();
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -250,7 +283,7 @@
|
||||
</NodeToolbar>
|
||||
{/if}
|
||||
|
||||
<div class="tf-node-wrapper {wrapperClass}">
|
||||
<div class="tf-node-wrapper {wrapperClass}" bind:this={wrapperElement}>
|
||||
<div class="tf-node-wrapper-body">
|
||||
<Collapse {items} activeKeys={activeKeys} onChange={(_,actionKeys) => {
|
||||
updateNodeData(id, {expand: actionKeys?.includes('key')})
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script lang="ts">
|
||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||
import {Heading} from '../base';
|
||||
import {Heading, Input, Textarea} from '../base';
|
||||
import {Button} from '../base/index.js';
|
||||
import {type NodeProps} from '@xyflow/svelte';
|
||||
import DefinedParameterList from '../core/DefinedParameterList.svelte';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||
import {useSvelteFlow} from '@xyflow/svelte';
|
||||
import {
|
||||
ensureStartNodeParameters,
|
||||
hasSystemStartParameter,
|
||||
appendStartFormField,
|
||||
isSystemStartParameter,
|
||||
normalizeStartNodeData,
|
||||
normalizeStartFormMeta,
|
||||
} from '../../utils/workflowNodeFields';
|
||||
|
||||
const { data, ...rest }: {
|
||||
@@ -19,25 +19,39 @@
|
||||
} = $props();
|
||||
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
const { addParameter } = useAddParameter();
|
||||
const { updateNodeData } = useSvelteFlow();
|
||||
|
||||
$effect(() => {
|
||||
const currentParameters = (data.parameters as Array<any>) || [];
|
||||
if (!hasSystemStartParameter(currentParameters)) {
|
||||
return;
|
||||
}
|
||||
const parameters = ensureStartNodeParameters(currentParameters);
|
||||
if (JSON.stringify(currentParameters) !== JSON.stringify(parameters)) {
|
||||
updateNodeData(currentNodeId, {
|
||||
parameters
|
||||
});
|
||||
const normalizedData = normalizeStartNodeData((data || {}) as Record<string, any>, {
|
||||
allowLegacyParametersOnly: true
|
||||
});
|
||||
const nextData = {
|
||||
...(data || {}),
|
||||
...normalizedData,
|
||||
};
|
||||
if (JSON.stringify(data || {}) !== JSON.stringify(nextData)) {
|
||||
updateNodeData(currentNodeId, nextData);
|
||||
}
|
||||
});
|
||||
|
||||
const updateStartFormMeta = (key: 'title' | 'description' | 'submitText', value: string) => {
|
||||
updateNodeData(currentNodeId, (node) => {
|
||||
const normalizedMeta = normalizeStartFormMeta(node.data?.startFormMeta as Record<string, any>);
|
||||
return {
|
||||
startFormMeta: {
|
||||
...normalizedMeta,
|
||||
[key]: value,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
let currentParameters = $derived.by(() => {
|
||||
return ((data.parameters as Array<any>) || []);
|
||||
});
|
||||
let startFormMeta = $derived.by(() => {
|
||||
return normalizeStartFormMeta((data.startFormMeta as Record<string, any>) || {});
|
||||
});
|
||||
let systemParameters = $derived.by(() => {
|
||||
return currentParameters.filter((parameter) => isSystemStartParameter(parameter));
|
||||
});
|
||||
@@ -55,11 +69,48 @@
|
||||
d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 15C10.3431 15 9 13.6569 9 12C9 10.3431 10.3431 9 12 9C13.6569 9 15 10.3431 15 12C15 13.6569 13.6569 15 12 15Z"></path>
|
||||
</svg>
|
||||
{/snippet}
|
||||
<div class="param-section">
|
||||
<div class="heading">
|
||||
<Heading level={3}>开场表单</Heading>
|
||||
</div>
|
||||
<div class="section-description">配置开始问答的标题、说明和提交按钮文案。</div>
|
||||
<div class="meta-form">
|
||||
<div class="meta-form-item">
|
||||
<div class="meta-label">标题</div>
|
||||
<Input
|
||||
style="width: 100%;"
|
||||
value={startFormMeta.title}
|
||||
placeholder="请输入表单标题"
|
||||
oninput={(event) => updateStartFormMeta('title', (event.target as HTMLInputElement)?.value || '')}
|
||||
/>
|
||||
</div>
|
||||
<div class="meta-form-item">
|
||||
<div class="meta-label">说明</div>
|
||||
<Textarea
|
||||
rows={2}
|
||||
style="width: 100%;"
|
||||
value={startFormMeta.description}
|
||||
placeholder="请输入表单说明"
|
||||
oninput={(event) => updateStartFormMeta('description', (event.target as HTMLTextAreaElement)?.value || '')}
|
||||
/>
|
||||
</div>
|
||||
<div class="meta-form-item">
|
||||
<div class="meta-label">提交按钮</div>
|
||||
<Input
|
||||
style="width: 100%;"
|
||||
value={startFormMeta.submitText}
|
||||
placeholder="请输入按钮文案"
|
||||
oninput={(event) => updateStartFormMeta('submitText', (event.target as HTMLInputElement)?.value || '')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-section">
|
||||
<div class="heading">
|
||||
<Heading level={3}>系统入口</Heading>
|
||||
</div>
|
||||
<div class="section-description">固定入口参数,作为工作流默认输入来源。</div>
|
||||
<div class="section-description">固定主问题字段,名称与必填规则固定,可调整展示方式与提示文案。</div>
|
||||
<DefinedParameterList parameters={systemParameters} emptyText="暂无系统入口参数" />
|
||||
</div>
|
||||
|
||||
@@ -67,14 +118,20 @@
|
||||
<div class="heading">
|
||||
<Heading level={3}>自定义参数</Heading>
|
||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
||||
addParameter(currentNodeId, "parameters", {refType: "input", name: "newParam", formType: "input", contentType: "text"});
|
||||
updateNodeData(currentNodeId, (node) => {
|
||||
return appendStartFormField(node.data as Record<string, any>, {
|
||||
label: '新字段',
|
||||
type: 'text',
|
||||
placeholder: '请输入内容',
|
||||
});
|
||||
});
|
||||
}}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="section-description">这里添加额外输入参数,不影响默认入口参数。</div>
|
||||
<div class="section-description">这里添加额外收集字段,字段顺序按列表顺序保存,首版不提供拖拽排序。</div>
|
||||
<DefinedParameterList parameters={customParameters} emptyText="暂无自定义参数" />
|
||||
</div>
|
||||
</NodeWrapper>
|
||||
@@ -93,6 +150,23 @@
|
||||
border-top: 1px solid var(--tf-border-color);
|
||||
}
|
||||
|
||||
.meta-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.meta-form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 12px;
|
||||
color: var(--tf-text-secondary);
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -82,6 +82,7 @@ export const startFormTypes = [
|
||||
{ label: '下拉菜单', value: 'select' },
|
||||
{ label: '单选', value: 'radio' },
|
||||
{ label: '多选', value: 'checkbox' },
|
||||
{ label: '文件上传', value: 'file' },
|
||||
];
|
||||
|
||||
export const confirmFormTypes = [
|
||||
|
||||
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { Edge, Node } from '@xyflow/svelte';
|
||||
|
||||
import {
|
||||
appendStartFormField,
|
||||
buildAutoBindingPatch,
|
||||
buildSequentialFieldBindingPatches,
|
||||
buildFieldBindingPatch,
|
||||
@@ -12,7 +13,11 @@ import {
|
||||
createInitialWorkflowData,
|
||||
ensureStartNodeParameters,
|
||||
FIELD_BINDING_META_KEY,
|
||||
normalizeStartNodeData,
|
||||
normalizeWorkflowStartNodes,
|
||||
renameStartFieldReferencesInNodes,
|
||||
removeStartFormField,
|
||||
updateStartFormField,
|
||||
} from './workflowNodeFields';
|
||||
|
||||
describe('workflow node fields', () => {
|
||||
@@ -28,6 +33,246 @@ describe('workflow node fields', () => {
|
||||
expect(parameters[0]?.name).toBe('user_input');
|
||||
expect(parameters[0]?.systemReserved).toBe(true);
|
||||
expect(parameters[0]?.required).toBe(true);
|
||||
expect(initial.nodes[0]?.data?.startFormMeta).toMatchObject({
|
||||
title: '开始问答',
|
||||
submitText: '开始',
|
||||
});
|
||||
expect(initial.nodes[0]?.data?.startFormSchema?.[0]).toMatchObject({
|
||||
key: 'user_input',
|
||||
type: 'textarea',
|
||||
systemReserved: true,
|
||||
required: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('appends custom start form field into schema source of truth', () => {
|
||||
const initial = createInitialWorkflowData();
|
||||
const startNode = initial.nodes[0]!;
|
||||
const nextData = appendStartFormField(startNode.data as Record<string, any>, {
|
||||
type: 'select',
|
||||
options: ['售前', '售后'],
|
||||
});
|
||||
|
||||
expect(nextData.startFormSchema).toHaveLength(2);
|
||||
expect(nextData.startFormSchema?.[1]).toMatchObject({
|
||||
key: 'select_field',
|
||||
label: '下拉字段',
|
||||
type: 'select',
|
||||
options: ['售前', '售后'],
|
||||
});
|
||||
expect((nextData.parameters as any[])?.[1]).toMatchObject({
|
||||
name: 'select_field',
|
||||
formLabel: '下拉字段',
|
||||
formType: 'select',
|
||||
enums: ['售前', '售后'],
|
||||
});
|
||||
});
|
||||
|
||||
it('updates generated field key when switching field type', () => {
|
||||
const initial = createInitialWorkflowData();
|
||||
const appended = appendStartFormField(initial.nodes[0]?.data as Record<string, any>, {
|
||||
type: 'text',
|
||||
});
|
||||
|
||||
const updated = updateStartFormField(appended, 'text_field', {
|
||||
type: 'file',
|
||||
placeholder: '请选择文件',
|
||||
});
|
||||
expect(updated.startFormSchema?.find((item: any) => item.key === 'file_field'))
|
||||
.toMatchObject({
|
||||
key: 'file_field',
|
||||
label: '文件字段',
|
||||
type: 'file',
|
||||
placeholder: '请选择文件',
|
||||
});
|
||||
expect((updated.parameters as any[]).find((item) => item.name === 'file_field'))
|
||||
.toMatchObject({
|
||||
name: 'file_field',
|
||||
dataType: 'File',
|
||||
contentType: 'file',
|
||||
});
|
||||
});
|
||||
|
||||
it('updates and removes custom start form fields through schema source of truth', () => {
|
||||
const initial = createInitialWorkflowData();
|
||||
const appended = appendStartFormField(initial.nodes[0]?.data as Record<string, any>, {
|
||||
key: 'attachments',
|
||||
label: '附件',
|
||||
type: 'text',
|
||||
placeholder: '请输入内容',
|
||||
});
|
||||
|
||||
const updated = updateStartFormField(appended, 'attachments', {
|
||||
type: 'file',
|
||||
placeholder: '请选择文件',
|
||||
});
|
||||
expect(updated.startFormSchema?.find((item: any) => item.key === 'attachments'))
|
||||
.toMatchObject({
|
||||
type: 'file',
|
||||
placeholder: '请选择文件',
|
||||
});
|
||||
expect((updated.parameters as any[]).find((item) => item.name === 'attachments'))
|
||||
.toMatchObject({
|
||||
dataType: 'File',
|
||||
contentType: 'file',
|
||||
});
|
||||
|
||||
const removed = removeStartFormField(updated, 'attachments');
|
||||
expect(removed.startFormSchema).toHaveLength(1);
|
||||
expect((removed.parameters as any[]).map((item) => item.name)).toEqual([
|
||||
'user_input',
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps custom start field parameter id stable when renaming key', () => {
|
||||
const initial = createInitialWorkflowData();
|
||||
const appended = appendStartFormField(initial.nodes[0]?.data as Record<string, any>, {
|
||||
type: 'text',
|
||||
});
|
||||
const previousField = appended.startFormSchema?.find((item: any) => item.key === 'text_field');
|
||||
const previousParameter = (appended.parameters as any[]).find(
|
||||
(item) => item.name === 'text_field',
|
||||
);
|
||||
|
||||
const updated = updateStartFormField(appended, 'text_field', {
|
||||
key: 'topic',
|
||||
label: '主题',
|
||||
});
|
||||
const nextField = updated.startFormSchema?.find((item: any) => item.key === 'topic');
|
||||
const nextParameter = (updated.parameters as any[]).find(
|
||||
(item) => item.name === 'topic',
|
||||
);
|
||||
|
||||
expect(previousField?.id).toBeTruthy();
|
||||
expect(nextField?.id).toBe(previousField?.id);
|
||||
expect(nextParameter?.id).toBe(previousParameter?.id);
|
||||
});
|
||||
|
||||
it('renames downstream token and managed references when start field key changes', () => {
|
||||
const initialStartData = appendStartFormField(
|
||||
createInitialWorkflowData().nodes[0]?.data as Record<string, any>,
|
||||
{
|
||||
type: 'text',
|
||||
},
|
||||
);
|
||||
const startNode: Node = {
|
||||
id: 'start_1',
|
||||
type: 'startNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: '开始节点',
|
||||
...initialStartData,
|
||||
},
|
||||
};
|
||||
const renamedStartData = updateStartFormField(
|
||||
startNode.data as Record<string, any>,
|
||||
'text_field',
|
||||
{
|
||||
key: 'topic',
|
||||
label: '主题',
|
||||
},
|
||||
);
|
||||
const llmNode: Node = {
|
||||
id: 'llm_1',
|
||||
type: 'llmNode',
|
||||
position: { x: 120, y: 0 },
|
||||
data: {
|
||||
title: '大模型',
|
||||
userPrompt: '请围绕 {{start_1.text_field}} 生成内容',
|
||||
parameters: [
|
||||
{
|
||||
id: 'param_1',
|
||||
name: 'start_1.text_field',
|
||||
ref: 'start_1.text_field',
|
||||
refType: 'ref',
|
||||
autoManaged: true,
|
||||
formLabel: '开始节点 > 文本字段',
|
||||
displayName: '开始节点 > 文本字段',
|
||||
},
|
||||
],
|
||||
[FIELD_BINDING_META_KEY]: {
|
||||
userPrompt: {
|
||||
autoFilledFrom: 'start_1.text_field',
|
||||
userModified: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const edges: Edge[] = [
|
||||
{ id: 'edge_1', source: 'start_1', target: 'llm_1' } as Edge,
|
||||
];
|
||||
|
||||
const nextNodes = renameStartFieldReferencesInNodes(
|
||||
[
|
||||
{
|
||||
...startNode,
|
||||
data: {
|
||||
...(startNode.data as Record<string, any>),
|
||||
...renamedStartData,
|
||||
},
|
||||
},
|
||||
llmNode,
|
||||
],
|
||||
edges,
|
||||
'start_1',
|
||||
'text_field',
|
||||
'topic',
|
||||
);
|
||||
const nextLlmNode = nextNodes.find((node) => node.id === 'llm_1')!;
|
||||
const nextParameter = (nextLlmNode.data?.parameters as any[])?.[0];
|
||||
|
||||
expect(nextLlmNode.data?.userPrompt).toBe('请围绕 {{start_1.topic}} 生成内容');
|
||||
expect(nextParameter?.name).toBe('start_1.topic');
|
||||
expect(nextParameter?.ref).toBe('start_1.topic');
|
||||
expect(nextParameter?.displayName).toBe('开始节点 > topic');
|
||||
expect((nextLlmNode.data as any)?.[FIELD_BINDING_META_KEY]?.userPrompt).toMatchObject({
|
||||
autoFilledFrom: 'start_1.topic',
|
||||
userModified: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses custom start parameter name for reference display', () => {
|
||||
const startData = appendStartFormField(
|
||||
createInitialWorkflowData().nodes[0]?.data as Record<string, any>,
|
||||
{
|
||||
key: 'topic_name',
|
||||
label: '下拉字段',
|
||||
type: 'select',
|
||||
options: ['A', 'B'],
|
||||
},
|
||||
);
|
||||
const startNode: Node = {
|
||||
id: 'start_1',
|
||||
type: 'startNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: '开始节点',
|
||||
...startData,
|
||||
},
|
||||
};
|
||||
const llmNode: Node = {
|
||||
id: 'llm_1',
|
||||
type: 'llmNode',
|
||||
position: { x: 120, y: 0 },
|
||||
data: {
|
||||
title: '大模型',
|
||||
parameters: [],
|
||||
},
|
||||
};
|
||||
const edges: Edge[] = [
|
||||
{ id: 'edge_1', source: 'start_1', target: 'llm_1' } as Edge,
|
||||
];
|
||||
|
||||
const parameters = buildEditorReferenceParameters(
|
||||
'llm_1',
|
||||
[startNode, llmNode],
|
||||
edges,
|
||||
[],
|
||||
);
|
||||
const topicParameter = parameters.find((item) => item.name === 'start_1.topic_name');
|
||||
|
||||
expect(topicParameter?.displayName).toBe('开始节点 > topic_name');
|
||||
expect(topicParameter?.formLabel).toBe('开始节点 > topic_name');
|
||||
});
|
||||
|
||||
it('builds upstream reference candidates from start node', () => {
|
||||
@@ -69,6 +314,120 @@ describe('workflow node fields', () => {
|
||||
).toBe('流程开始 > 用户问题');
|
||||
});
|
||||
|
||||
it('uses output parameter name for reference display', () => {
|
||||
const knowledgeNode: Node = {
|
||||
id: 'knowledge_1',
|
||||
type: 'knowledgeNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: '知识库',
|
||||
outputDefs: [
|
||||
{
|
||||
name: 'documents',
|
||||
formLabel: '文档列表',
|
||||
children: [
|
||||
{
|
||||
name: 'content',
|
||||
formLabel: '正文内容',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const llmNode: Node = {
|
||||
id: 'llm_1',
|
||||
type: 'llmNode',
|
||||
position: { x: 120, y: 0 },
|
||||
data: {
|
||||
title: '大模型',
|
||||
parameters: [],
|
||||
},
|
||||
};
|
||||
const edges: Edge[] = [
|
||||
{ id: 'edge_1', source: 'knowledge_1', target: 'llm_1' } as Edge,
|
||||
];
|
||||
|
||||
const parameters = buildEditorReferenceParameters(
|
||||
'llm_1',
|
||||
[knowledgeNode, llmNode],
|
||||
edges,
|
||||
[],
|
||||
);
|
||||
const documentsParameter = parameters.find((item) => item.name === 'knowledge_1.documents');
|
||||
const contentParameter = parameters.find((item) => item.name === 'knowledge_1.documents.content');
|
||||
|
||||
expect(documentsParameter?.displayName).toBe('知识库 > documents');
|
||||
expect(documentsParameter?.formLabel).toBe('知识库 > documents');
|
||||
expect(contentParameter?.displayName).toBe('知识库 > documents.content');
|
||||
expect(contentParameter?.formLabel).toBe('知识库 > documents.content');
|
||||
});
|
||||
|
||||
it('uses document node child outputs for reference display', () => {
|
||||
const documentNode: Node = {
|
||||
id: 'doc_1',
|
||||
type: 'document-node',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: '文档解析',
|
||||
outputDefs: [
|
||||
{
|
||||
name: 'documents',
|
||||
formLabel: '文档列表',
|
||||
children: [
|
||||
{
|
||||
name: 'fileName',
|
||||
formLabel: '文件名',
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
formLabel: '正文内容',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'contents',
|
||||
formLabel: '逐文件内容',
|
||||
},
|
||||
{
|
||||
name: 'count',
|
||||
formLabel: '数量',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const llmNode: Node = {
|
||||
id: 'llm_1',
|
||||
type: 'llmNode',
|
||||
position: { x: 120, y: 0 },
|
||||
data: {
|
||||
title: '大模型',
|
||||
parameters: [],
|
||||
},
|
||||
};
|
||||
const edges: Edge[] = [
|
||||
{ id: 'edge_1', source: 'doc_1', target: 'llm_1' } as Edge,
|
||||
];
|
||||
|
||||
const parameters = buildEditorReferenceParameters(
|
||||
'llm_1',
|
||||
[documentNode, llmNode],
|
||||
edges,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(parameters.find((item) => item.name === 'doc_1.documents')?.displayName)
|
||||
.toBe('文档解析 > documents');
|
||||
expect(parameters.find((item) => item.name === 'doc_1.documents.fileName')?.displayName)
|
||||
.toBe('文档解析 > documents.fileName');
|
||||
expect(parameters.find((item) => item.name === 'doc_1.documents.content')?.displayName)
|
||||
.toBe('文档解析 > documents.content');
|
||||
expect(parameters.find((item) => item.name === 'doc_1.contents')?.displayName)
|
||||
.toBe('文档解析 > contents');
|
||||
expect(parameters.find((item) => item.name === 'doc_1.count')?.displayName)
|
||||
.toBe('文档解析 > count');
|
||||
});
|
||||
|
||||
it('applies default binding to llm user prompt after connect', () => {
|
||||
const startNode: Node = {
|
||||
id: 'start_1',
|
||||
@@ -436,6 +795,86 @@ describe('workflow node fields', () => {
|
||||
expect(result).toEqual(legacyParameters);
|
||||
});
|
||||
|
||||
it('normalizes start node schema from parameters', () => {
|
||||
const normalized = normalizeStartNodeData({
|
||||
parameters: [
|
||||
{
|
||||
id: 'system_1',
|
||||
name: 'user_input',
|
||||
refType: 'input',
|
||||
required: true,
|
||||
formType: 'input',
|
||||
formLabel: '问题',
|
||||
formPlaceholder: '请输入问题',
|
||||
},
|
||||
{
|
||||
id: 'file_1',
|
||||
name: 'attachments',
|
||||
refType: 'input',
|
||||
dataType: 'File',
|
||||
contentType: 'file',
|
||||
formLabel: '附件',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(normalized.startFormSchema).toHaveLength(2);
|
||||
expect(normalized.startFormSchema[0]).toMatchObject({
|
||||
key: 'user_input',
|
||||
type: 'text',
|
||||
systemReserved: true,
|
||||
required: true,
|
||||
});
|
||||
expect(normalized.startFormSchema[1]).toMatchObject({
|
||||
key: 'attachments',
|
||||
type: 'file',
|
||||
required: false,
|
||||
});
|
||||
expect(normalized.parameters[1]).toMatchObject({
|
||||
name: 'attachments',
|
||||
contentType: 'file',
|
||||
dataType: 'File',
|
||||
});
|
||||
});
|
||||
|
||||
it('forces invalid user_input schema back to required text input', () => {
|
||||
const normalized = normalizeStartNodeData({
|
||||
startFormSchema: [
|
||||
{
|
||||
key: 'user_input',
|
||||
label: '主问题',
|
||||
type: 'radio',
|
||||
required: false,
|
||||
options: ['A', 'B'],
|
||||
},
|
||||
],
|
||||
startFormMeta: {
|
||||
title: '',
|
||||
description: '',
|
||||
submitText: '',
|
||||
},
|
||||
});
|
||||
|
||||
expect(normalized.startFormSchema[0]).toMatchObject({
|
||||
key: 'user_input',
|
||||
type: 'textarea',
|
||||
required: true,
|
||||
systemReserved: true,
|
||||
});
|
||||
expect(normalized.startFormMeta).toMatchObject({
|
||||
title: '开始问答',
|
||||
description: '请先补充必要信息,再开始执行工作流。',
|
||||
submitText: '开始',
|
||||
});
|
||||
expect(normalized.parameters[0]).toMatchObject({
|
||||
name: 'user_input',
|
||||
formType: 'textarea',
|
||||
required: true,
|
||||
contentType: 'text',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes only start nodes that already contain fixed user_input', () => {
|
||||
const normalizedWorkflow = normalizeWorkflowStartNodes({
|
||||
nodes: [
|
||||
@@ -466,6 +905,9 @@ describe('workflow node fields', () => {
|
||||
expect(normalizedWorkflow.nodes[0]?.data?.parameters?.[0]?.required).toBe(
|
||||
true,
|
||||
);
|
||||
expect(normalizedWorkflow.nodes[0]?.data?.startFormSchema?.[0]?.key).toBe(
|
||||
'user_input',
|
||||
);
|
||||
expect(normalizedWorkflow.nodes[1]?.data?.parameters).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,66 @@ export type SingleRunModel =
|
||||
fields: SingleRunFieldDescriptor[];
|
||||
};
|
||||
|
||||
export type StartFormFieldType =
|
||||
| 'text'
|
||||
| 'textarea'
|
||||
| 'radio'
|
||||
| 'checkbox'
|
||||
| 'select'
|
||||
| 'file';
|
||||
|
||||
export type StartFormMeta = {
|
||||
title: string;
|
||||
description: string;
|
||||
submitText: string;
|
||||
};
|
||||
|
||||
export type StartFormFieldSchema = {
|
||||
id?: string;
|
||||
key: string;
|
||||
label: string;
|
||||
type: StartFormFieldType;
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
defaultValue?: string | string[];
|
||||
options: string[];
|
||||
systemReserved?: boolean;
|
||||
};
|
||||
|
||||
const START_FORM_FIELD_TYPE_SET = new Set<StartFormFieldType>([
|
||||
'text',
|
||||
'textarea',
|
||||
'radio',
|
||||
'checkbox',
|
||||
'select',
|
||||
'file',
|
||||
]);
|
||||
const OPTION_FIELD_TYPE_SET = new Set<StartFormFieldType>([
|
||||
'radio',
|
||||
'checkbox',
|
||||
'select',
|
||||
]);
|
||||
const DEFAULT_START_FORM_TITLE = '开始问答';
|
||||
const DEFAULT_START_FORM_DESCRIPTION = '请先补充必要信息,再开始执行工作流。';
|
||||
const DEFAULT_START_FORM_SUBMIT_TEXT = '开始';
|
||||
const START_FORM_DEFAULT_FIELD_KEY_BY_TYPE: Record<StartFormFieldType, string> = {
|
||||
text: 'text_field',
|
||||
textarea: 'textarea_field',
|
||||
radio: 'radio_field',
|
||||
checkbox: 'checkbox_field',
|
||||
select: 'select_field',
|
||||
file: 'file_field',
|
||||
};
|
||||
const START_FORM_DEFAULT_FIELD_LABEL_BY_TYPE: Record<StartFormFieldType, string> = {
|
||||
text: '文本字段',
|
||||
textarea: '长文本字段',
|
||||
radio: '单选字段',
|
||||
checkbox: '多选字段',
|
||||
select: '下拉字段',
|
||||
file: '文件字段',
|
||||
};
|
||||
|
||||
type FieldBindingMeta = Record<
|
||||
string,
|
||||
{
|
||||
@@ -43,6 +103,25 @@ function asString(value: unknown) {
|
||||
return value == null ? '' : String(value);
|
||||
}
|
||||
|
||||
function trimString(value: unknown) {
|
||||
return asString(value).trim();
|
||||
}
|
||||
|
||||
function ensureStringArray(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
return value.map((item) => asString(item).trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function isSupportedStartFormFieldType(value: unknown): value is StartFormFieldType {
|
||||
return START_FORM_FIELD_TYPE_SET.has(asString(value).trim() as StartFormFieldType);
|
||||
}
|
||||
|
||||
function isOptionFieldType(value: unknown): value is StartFormFieldType {
|
||||
return OPTION_FIELD_TYPE_SET.has(asString(value).trim() as StartFormFieldType);
|
||||
}
|
||||
|
||||
function cloneParameter(parameter: Parameter): Parameter {
|
||||
return {
|
||||
...parameter,
|
||||
@@ -74,6 +153,13 @@ function getParameterLabel(parameter?: Parameter | null) {
|
||||
);
|
||||
}
|
||||
|
||||
function getReferenceParameterLabel(parameter?: Parameter | null) {
|
||||
if (isSystemStartParameter(parameter)) {
|
||||
return getParameterLabel(parameter);
|
||||
}
|
||||
return asString(parameter?.name).trim() || getParameterLabel(parameter);
|
||||
}
|
||||
|
||||
function buildDisconnectedDisplayName(parameter: Parameter) {
|
||||
const displayName =
|
||||
asString(parameter.displayName).trim() ||
|
||||
@@ -127,8 +213,8 @@ function flattenOutputDefs(
|
||||
|
||||
const path = parentPath ? `${parentPath}.${rawName}` : rawName;
|
||||
const label = parentLabel
|
||||
? `${parentLabel}.${getParameterLabel(parameter)}`
|
||||
: getParameterLabel(parameter);
|
||||
? `${parentLabel}.${getReferenceParameterLabel(parameter)}`
|
||||
: getReferenceParameterLabel(parameter);
|
||||
const fullRef = `${node.id}.${path}`;
|
||||
const baseCandidate: Parameter = ensureParameterId({
|
||||
name: fullRef,
|
||||
@@ -167,8 +253,8 @@ function getNodeReferenceParameters(node: Node): Parameter[] {
|
||||
name: `${node.id}.${asString(parameter.name).trim()}`,
|
||||
ref: `${node.id}.${asString(parameter.name).trim()}`,
|
||||
refType: 'ref',
|
||||
displayName: `${getNodeTitle(node)} > ${getParameterLabel(parameter)}`,
|
||||
formLabel: `${getNodeTitle(node)} > ${getParameterLabel(parameter)}`,
|
||||
displayName: `${getNodeTitle(node)} > ${getReferenceParameterLabel(parameter)}`,
|
||||
formLabel: `${getNodeTitle(node)} > ${getReferenceParameterLabel(parameter)}`,
|
||||
nameDisabled: true,
|
||||
dataTypeDisabled: true,
|
||||
deleteDisabled: true,
|
||||
@@ -233,17 +319,115 @@ function toManagedRefParameter(refPath: string, candidate?: Parameter): Paramete
|
||||
});
|
||||
}
|
||||
|
||||
export function createSystemStartParameter(): Parameter {
|
||||
function normalizeSystemStartFormType(value: unknown): 'input' | 'textarea' {
|
||||
return trimString(value) === 'input' ? 'input' : 'textarea';
|
||||
}
|
||||
|
||||
function normalizeStartFormFieldUiType(
|
||||
value: unknown,
|
||||
fallback: StartFormFieldType = 'text',
|
||||
): StartFormFieldType {
|
||||
const normalized = trimString(value);
|
||||
if (normalized === 'input') {
|
||||
return 'text';
|
||||
}
|
||||
if (isSupportedStartFormFieldType(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getDefaultStartFormFieldKeyBase(type: StartFormFieldType) {
|
||||
return START_FORM_DEFAULT_FIELD_KEY_BY_TYPE[type];
|
||||
}
|
||||
|
||||
function getDefaultStartFormFieldLabel(type: StartFormFieldType) {
|
||||
return START_FORM_DEFAULT_FIELD_LABEL_BY_TYPE[type];
|
||||
}
|
||||
|
||||
function buildUniqueStartFormFieldKey(
|
||||
type: StartFormFieldType,
|
||||
existingKeys: string[],
|
||||
) {
|
||||
const normalizedExistingKeys = new Set(
|
||||
existingKeys.map((item) => trimString(item)).filter(Boolean),
|
||||
);
|
||||
const baseKey = getDefaultStartFormFieldKeyBase(type);
|
||||
if (!normalizedExistingKeys.has(baseKey)) {
|
||||
return baseKey;
|
||||
}
|
||||
let index = 2;
|
||||
while (normalizedExistingKeys.has(`${baseKey}_${index}`)) {
|
||||
index += 1;
|
||||
}
|
||||
return `${baseKey}_${index}`;
|
||||
}
|
||||
|
||||
function isAutoGeneratedStartFormFieldKey(key: unknown) {
|
||||
const normalizedKey = trimString(key);
|
||||
if (!normalizedKey) {
|
||||
return false;
|
||||
}
|
||||
if (/^field_[A-Za-z0-9]+$/.test(normalizedKey)) {
|
||||
return true;
|
||||
}
|
||||
return Object.values(START_FORM_DEFAULT_FIELD_KEY_BY_TYPE).some((baseKey) => {
|
||||
return normalizedKey === baseKey || new RegExp(`^${baseKey}_[0-9]+$`).test(normalizedKey);
|
||||
});
|
||||
}
|
||||
|
||||
function isDefaultStartFormFieldLabel(label: unknown) {
|
||||
const normalizedLabel = trimString(label);
|
||||
if (!normalizedLabel) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedLabel === '新字段') {
|
||||
return true;
|
||||
}
|
||||
return Object.values(START_FORM_DEFAULT_FIELD_LABEL_BY_TYPE).includes(normalizedLabel);
|
||||
}
|
||||
|
||||
function ensureStartFormFieldId(
|
||||
fieldId: unknown,
|
||||
existingParameter?: Parameter | null,
|
||||
) {
|
||||
return trimString(fieldId) || trimString(existingParameter?.id) || genShortId();
|
||||
}
|
||||
|
||||
function createDefaultStartFormMeta(): StartFormMeta {
|
||||
return {
|
||||
title: DEFAULT_START_FORM_TITLE,
|
||||
description: DEFAULT_START_FORM_DESCRIPTION,
|
||||
submitText: DEFAULT_START_FORM_SUBMIT_TEXT,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeStartFormMeta(meta?: Partial<StartFormMeta> | null): StartFormMeta {
|
||||
const fallback = createDefaultStartFormMeta();
|
||||
return {
|
||||
title: trimString(meta?.title) || fallback.title,
|
||||
description: trimString(meta?.description) || fallback.description,
|
||||
submitText: trimString(meta?.submitText) || fallback.submitText,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSystemStartParameter(parameter?: Parameter | null): Parameter {
|
||||
const fallbackLabel = SYSTEM_START_PARAM_LABEL;
|
||||
const formLabel = trimString(parameter?.formLabel) || fallbackLabel;
|
||||
const formType = normalizeSystemStartFormType(parameter?.formType);
|
||||
return ensureParameterId({
|
||||
...cloneParameter(parameter || {}),
|
||||
name: SYSTEM_START_PARAM_NAME,
|
||||
dataType: 'String',
|
||||
refType: 'input',
|
||||
required: true,
|
||||
contentType: 'text',
|
||||
formType: 'input',
|
||||
formLabel: SYSTEM_START_PARAM_LABEL,
|
||||
formPlaceholder: '请输入用户问题',
|
||||
displayName: `流程开始 > ${SYSTEM_START_PARAM_LABEL}`,
|
||||
formType,
|
||||
formLabel,
|
||||
formDescription: asString(parameter?.formDescription),
|
||||
formPlaceholder: trimString(parameter?.formPlaceholder) || '请输入用户问题',
|
||||
defaultValue: asString(parameter?.defaultValue),
|
||||
displayName: `流程开始 > ${formLabel}`,
|
||||
nameDisabled: true,
|
||||
dataTypeDisabled: true,
|
||||
deleteDisabled: true,
|
||||
@@ -252,6 +436,399 @@ export function createSystemStartParameter(): Parameter {
|
||||
});
|
||||
}
|
||||
|
||||
function parameterTypeToStartFormFieldType(parameter?: Parameter | null): StartFormFieldType {
|
||||
if (
|
||||
trimString(parameter?.contentType) === 'file' ||
|
||||
trimString(parameter?.dataType).toLowerCase() === 'file'
|
||||
) {
|
||||
return 'file';
|
||||
}
|
||||
const formType = trimString(parameter?.formType);
|
||||
if (formType === 'textarea') {
|
||||
return 'textarea';
|
||||
}
|
||||
if (formType === 'select') {
|
||||
return 'select';
|
||||
}
|
||||
if (formType === 'radio') {
|
||||
return 'radio';
|
||||
}
|
||||
if (formType === 'checkbox') {
|
||||
return 'checkbox';
|
||||
}
|
||||
return 'text';
|
||||
}
|
||||
|
||||
function fieldTypeToDataType(type: StartFormFieldType) {
|
||||
if (type === 'checkbox') {
|
||||
return 'Array';
|
||||
}
|
||||
if (type === 'file') {
|
||||
return 'File';
|
||||
}
|
||||
return 'String';
|
||||
}
|
||||
|
||||
function fieldTypeToContentType(type: StartFormFieldType) {
|
||||
return type === 'file' ? 'file' : 'text';
|
||||
}
|
||||
|
||||
function fieldTypeToFormType(type: StartFormFieldType) {
|
||||
if (type === 'text') {
|
||||
return 'input';
|
||||
}
|
||||
if (type === 'file') {
|
||||
return 'input';
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
function normalizeStartFormField(
|
||||
field: Partial<StartFormFieldSchema> | null | undefined,
|
||||
existingParameter?: Parameter,
|
||||
): StartFormFieldSchema | null {
|
||||
const key = trimString(field?.key) || trimString(existingParameter?.name);
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackType = parameterTypeToStartFormFieldType(existingParameter);
|
||||
const requestedType = isSupportedStartFormFieldType(field?.type)
|
||||
? field?.type
|
||||
: fallbackType;
|
||||
const isSystemField =
|
||||
field?.systemReserved === true || key === SYSTEM_START_PARAM_NAME;
|
||||
const type = isSystemField
|
||||
? requestedType === 'text'
|
||||
? 'text'
|
||||
: 'textarea'
|
||||
: requestedType;
|
||||
const options = isOptionFieldType(type)
|
||||
? ensureStringArray(field?.options || existingParameter?.enums)
|
||||
: [];
|
||||
|
||||
const defaultValue = Array.isArray(field?.defaultValue)
|
||||
? ensureStringArray(field?.defaultValue)
|
||||
: asString(field?.defaultValue ?? existingParameter?.defaultValue);
|
||||
|
||||
return {
|
||||
id: ensureStartFormFieldId(field?.id, existingParameter),
|
||||
key,
|
||||
label:
|
||||
trimString(field?.label) ||
|
||||
trimString(existingParameter?.formLabel) ||
|
||||
trimString(existingParameter?.displayName) ||
|
||||
key,
|
||||
type,
|
||||
required: isSystemField ? true : Boolean(field?.required ?? existingParameter?.required),
|
||||
placeholder:
|
||||
trimString(field?.placeholder) || trimString(existingParameter?.formPlaceholder),
|
||||
description:
|
||||
trimString(field?.description) || trimString(existingParameter?.formDescription),
|
||||
defaultValue,
|
||||
options,
|
||||
systemReserved: isSystemField,
|
||||
};
|
||||
}
|
||||
|
||||
function parameterToStartFormField(parameter?: Parameter | null) {
|
||||
return normalizeStartFormField(
|
||||
{
|
||||
id: trimString(parameter?.id),
|
||||
key: trimString(parameter?.name),
|
||||
label: trimString(parameter?.formLabel),
|
||||
type: parameterTypeToStartFormFieldType(parameter),
|
||||
required: Boolean(parameter?.required),
|
||||
placeholder: trimString(parameter?.formPlaceholder),
|
||||
description: trimString(parameter?.formDescription),
|
||||
defaultValue: parameter?.defaultValue,
|
||||
options: parameter?.enums || [],
|
||||
systemReserved: isSystemStartParameter(parameter),
|
||||
},
|
||||
parameter || undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function startFormFieldToParameter(
|
||||
field: StartFormFieldSchema,
|
||||
existingParameter?: Parameter,
|
||||
): Parameter {
|
||||
if (field.systemReserved || field.key === SYSTEM_START_PARAM_NAME) {
|
||||
return normalizeSystemStartParameter({
|
||||
...existingParameter,
|
||||
id: trimString(existingParameter?.id) || trimString(field.id),
|
||||
formLabel: field.label,
|
||||
formDescription: field.description,
|
||||
formPlaceholder: field.placeholder,
|
||||
formType: field.type === 'text' ? 'input' : 'textarea',
|
||||
defaultValue: Array.isArray(field.defaultValue)
|
||||
? field.defaultValue.join('\n')
|
||||
: asString(field.defaultValue),
|
||||
});
|
||||
}
|
||||
|
||||
const formLabel = trimString(field.label) || field.key;
|
||||
return ensureParameterId({
|
||||
...cloneParameter(existingParameter || {}),
|
||||
id: trimString(existingParameter?.id) || trimString(field.id),
|
||||
name: field.key,
|
||||
dataType: fieldTypeToDataType(field.type),
|
||||
refType: 'input',
|
||||
required: Boolean(field.required),
|
||||
contentType: fieldTypeToContentType(field.type),
|
||||
formType: fieldTypeToFormType(field.type),
|
||||
formLabel,
|
||||
formDescription: asString(field.description),
|
||||
formPlaceholder: asString(field.placeholder),
|
||||
defaultValue: Array.isArray(field.defaultValue)
|
||||
? field.defaultValue.join('\n')
|
||||
: asString(field.defaultValue),
|
||||
enums: isOptionFieldType(field.type) ? ensureStringArray(field.options) : [],
|
||||
displayName: `流程开始 > ${formLabel}`,
|
||||
systemReserved: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeStartFormSchema(
|
||||
schema?: Partial<StartFormFieldSchema>[] | null,
|
||||
parameters?: Parameter[] | null,
|
||||
) {
|
||||
const parameterCandidates = Array.isArray(parameters)
|
||||
? parameters.map(ensureParameterId)
|
||||
: [];
|
||||
const parameterMap = new Map(
|
||||
parameterCandidates
|
||||
.map((parameter) => [trimString(parameter.name), parameter] as const)
|
||||
.filter(([key]) => key.length > 0),
|
||||
);
|
||||
const parameterIdMap = new Map(
|
||||
parameterCandidates
|
||||
.map((parameter) => [trimString(parameter.id), parameter] as const)
|
||||
.filter(([key]) => key.length > 0),
|
||||
);
|
||||
|
||||
const source = Array.isArray(schema) && schema.length > 0
|
||||
? schema
|
||||
: parameterCandidates
|
||||
.map((parameter) => parameterToStartFormField(parameter))
|
||||
.filter(Boolean);
|
||||
|
||||
const result: StartFormFieldSchema[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const item of source) {
|
||||
const fieldId = trimString((item as Partial<StartFormFieldSchema>)?.id);
|
||||
const key =
|
||||
trimString((item as Partial<StartFormFieldSchema>)?.key) ||
|
||||
trimString((item as any)?.name);
|
||||
if (!key || seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeStartFormField(
|
||||
item as Partial<StartFormFieldSchema>,
|
||||
(fieldId ? parameterIdMap.get(fieldId) : undefined) || parameterMap.get(key),
|
||||
);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized.key);
|
||||
if (normalized.key === SYSTEM_START_PARAM_NAME) {
|
||||
result.unshift(normalized);
|
||||
} else {
|
||||
result.push(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
if (!seen.has(SYSTEM_START_PARAM_NAME)) {
|
||||
const systemParameter = parameterCandidates.find((parameter) =>
|
||||
isSystemStartParameter(parameter),
|
||||
);
|
||||
result.unshift(
|
||||
normalizeStartFormField(
|
||||
{
|
||||
id: trimString(systemParameter?.id),
|
||||
key: SYSTEM_START_PARAM_NAME,
|
||||
label: trimString(systemParameter?.formLabel) || SYSTEM_START_PARAM_LABEL,
|
||||
type:
|
||||
normalizeSystemStartFormType(systemParameter?.formType) === 'input'
|
||||
? 'text'
|
||||
: 'textarea',
|
||||
required: true,
|
||||
placeholder: trimString(systemParameter?.formPlaceholder),
|
||||
description: trimString(systemParameter?.formDescription),
|
||||
defaultValue: asString(systemParameter?.defaultValue),
|
||||
options: [],
|
||||
systemReserved: true,
|
||||
},
|
||||
systemParameter,
|
||||
)!,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function createCustomStartFormField(
|
||||
field?: Partial<StartFormFieldSchema> | null,
|
||||
existingKeys: string[] = [],
|
||||
): StartFormFieldSchema {
|
||||
const type = normalizeStartFormFieldUiType(field?.type, 'text');
|
||||
const key = trimString(field?.key) || buildUniqueStartFormFieldKey(type, existingKeys);
|
||||
return {
|
||||
id: ensureStartFormFieldId(field?.id),
|
||||
key,
|
||||
label: trimString(field?.label) || getDefaultStartFormFieldLabel(type),
|
||||
type,
|
||||
required: Boolean(field?.required),
|
||||
placeholder:
|
||||
trimString(field?.placeholder) ||
|
||||
(type === 'file' ? '请选择文件' : '请输入内容'),
|
||||
description: trimString(field?.description),
|
||||
defaultValue: Array.isArray(field?.defaultValue)
|
||||
? ensureStringArray(field?.defaultValue)
|
||||
: asString(field?.defaultValue),
|
||||
options: isOptionFieldType(type) ? ensureStringArray(field?.options) : [],
|
||||
systemReserved: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function appendStartFormField(
|
||||
data?: Record<string, any> | null,
|
||||
field?: Partial<StartFormFieldSchema> | null,
|
||||
) {
|
||||
const currentData = (data || {}) as Record<string, any>;
|
||||
const currentParameters = Array.isArray(currentData.parameters)
|
||||
? (currentData.parameters as Parameter[])
|
||||
: [];
|
||||
const schema = normalizeStartFormSchema(currentData.startFormSchema, currentParameters);
|
||||
const nextField = createCustomStartFormField(
|
||||
field,
|
||||
schema.map((item) => item.key),
|
||||
);
|
||||
return normalizeStartNodeData({
|
||||
...currentData,
|
||||
startFormSchema: [...schema, nextField],
|
||||
});
|
||||
}
|
||||
|
||||
export function updateStartFormField(
|
||||
data: Record<string, any> | null | undefined,
|
||||
currentKey: string,
|
||||
patch: Partial<StartFormFieldSchema>,
|
||||
) {
|
||||
const currentData = (data || {}) as Record<string, any>;
|
||||
const currentParameters = Array.isArray(currentData.parameters)
|
||||
? (currentData.parameters as Parameter[])
|
||||
: [];
|
||||
const schema = normalizeStartFormSchema(currentData.startFormSchema, currentParameters);
|
||||
const nextSchema = schema.map((field) => {
|
||||
if (field.key !== currentKey) {
|
||||
return field;
|
||||
}
|
||||
const nextType = normalizeStartFormFieldUiType(patch.type, field.type);
|
||||
const shouldAutoRenameKey =
|
||||
!trimString(patch.key) &&
|
||||
patch.type != null &&
|
||||
isAutoGeneratedStartFormFieldKey(field.key);
|
||||
const shouldAutoRenameLabel =
|
||||
!trimString(patch.label) &&
|
||||
patch.type != null &&
|
||||
isDefaultStartFormFieldLabel(field.label);
|
||||
return {
|
||||
...field,
|
||||
...patch,
|
||||
key: trimString(patch.key) || (
|
||||
shouldAutoRenameKey
|
||||
? buildUniqueStartFormFieldKey(
|
||||
nextType,
|
||||
schema
|
||||
.filter((item) => item.key !== field.key)
|
||||
.map((item) => item.key),
|
||||
)
|
||||
: field.key
|
||||
),
|
||||
label: trimString(patch.label) || (
|
||||
shouldAutoRenameLabel ? getDefaultStartFormFieldLabel(nextType) : field.label
|
||||
),
|
||||
type: nextType,
|
||||
};
|
||||
});
|
||||
return normalizeStartNodeData({
|
||||
...currentData,
|
||||
startFormSchema: nextSchema,
|
||||
});
|
||||
}
|
||||
|
||||
export function removeStartFormField(
|
||||
data?: Record<string, any> | null,
|
||||
key?: string | null,
|
||||
) {
|
||||
const currentKey = trimString(key);
|
||||
if (!currentKey || currentKey === SYSTEM_START_PARAM_NAME) {
|
||||
return normalizeStartNodeData(data || {});
|
||||
}
|
||||
const currentData = (data || {}) as Record<string, any>;
|
||||
const currentParameters = Array.isArray(currentData.parameters)
|
||||
? (currentData.parameters as Parameter[])
|
||||
: [];
|
||||
const schema = normalizeStartFormSchema(currentData.startFormSchema, currentParameters);
|
||||
return normalizeStartNodeData({
|
||||
...currentData,
|
||||
startFormSchema: schema.filter((field) => field.key !== currentKey),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeStartNodeData(
|
||||
data?: Record<string, any> | null,
|
||||
options?: {
|
||||
allowLegacyParametersOnly?: boolean;
|
||||
},
|
||||
) {
|
||||
const currentData = (data || {}) as Record<string, any>;
|
||||
const currentParameters = Array.isArray(currentData.parameters)
|
||||
? (currentData.parameters as Parameter[])
|
||||
: [];
|
||||
const hasSchema = Array.isArray(currentData.startFormSchema);
|
||||
const hasSystemParameter = hasSystemStartParameter(currentParameters);
|
||||
|
||||
if (!hasSchema && !hasSystemParameter && options?.allowLegacyParametersOnly) {
|
||||
return {
|
||||
parameters: currentParameters.map(ensureParameterId),
|
||||
startFormMeta: currentData.startFormMeta,
|
||||
startFormSchema: currentData.startFormSchema,
|
||||
};
|
||||
}
|
||||
|
||||
const schema = normalizeStartFormSchema(currentData.startFormSchema, currentParameters);
|
||||
const parameterMap = new Map(
|
||||
currentParameters
|
||||
.map((parameter) => [trimString(parameter.name), parameter] as const)
|
||||
.filter(([key]) => key.length > 0),
|
||||
);
|
||||
const parameterIdMap = new Map(
|
||||
currentParameters
|
||||
.map((parameter) => [trimString(parameter.id), parameter] as const)
|
||||
.filter(([key]) => key.length > 0),
|
||||
);
|
||||
const parameters = schema.map((field) =>
|
||||
startFormFieldToParameter(
|
||||
field,
|
||||
(trimString(field.id) ? parameterIdMap.get(trimString(field.id)) : undefined) ||
|
||||
parameterMap.get(field.key),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
parameters,
|
||||
startFormMeta: normalizeStartFormMeta(currentData.startFormMeta),
|
||||
startFormSchema: schema,
|
||||
};
|
||||
}
|
||||
|
||||
export function createSystemStartParameter(): Parameter {
|
||||
return normalizeSystemStartParameter();
|
||||
}
|
||||
|
||||
export function isSystemStartParameter(parameter?: Parameter | null) {
|
||||
if (!parameter) {
|
||||
return false;
|
||||
@@ -273,18 +850,12 @@ export function ensureStartNodeParameters(parameters?: Parameter[]) {
|
||||
const source = Array.isArray(parameters)
|
||||
? parameters.map(cloneParameter)
|
||||
: [];
|
||||
const fixed = createSystemStartParameter();
|
||||
const index = source.findIndex((parameter) => isSystemStartParameter(parameter));
|
||||
|
||||
if (index >= 0) {
|
||||
const existing = source[index]!;
|
||||
source[index] = ensureParameterId({
|
||||
...existing,
|
||||
...fixed,
|
||||
id: existing.id || fixed.id,
|
||||
});
|
||||
source[index] = normalizeSystemStartParameter(source[index]!);
|
||||
} else {
|
||||
source.unshift(fixed);
|
||||
source.unshift(createSystemStartParameter());
|
||||
}
|
||||
|
||||
const customParameters = source
|
||||
@@ -295,6 +866,14 @@ export function ensureStartNodeParameters(parameters?: Parameter[]) {
|
||||
}
|
||||
|
||||
export function createInitialWorkflowData() {
|
||||
const startData = normalizeStartNodeData(
|
||||
{
|
||||
parameters: ensureStartNodeParameters(),
|
||||
},
|
||||
{
|
||||
allowLegacyParametersOnly: false,
|
||||
},
|
||||
);
|
||||
return {
|
||||
nodes: [
|
||||
{
|
||||
@@ -303,7 +882,7 @@ export function createInitialWorkflowData() {
|
||||
position: { x: 80, y: 180 },
|
||||
data: {
|
||||
title: '开始节点',
|
||||
parameters: ensureStartNodeParameters(),
|
||||
...startData,
|
||||
},
|
||||
} satisfies Node,
|
||||
],
|
||||
@@ -326,22 +905,29 @@ export function normalizeWorkflowStartNodes<T extends Record<string, any>>(data:
|
||||
if (node?.type !== START_NODE_TYPE) {
|
||||
return node;
|
||||
}
|
||||
const currentParameters = Array.isArray(node.data?.parameters)
|
||||
? (node.data.parameters as Parameter[])
|
||||
const currentData = (node.data || {}) as Record<string, any>;
|
||||
const currentParameters = Array.isArray(currentData.parameters)
|
||||
? (currentData.parameters as Parameter[])
|
||||
: [];
|
||||
if (!hasSystemStartParameter(currentParameters)) {
|
||||
const shouldNormalize =
|
||||
Array.isArray(currentData.startFormSchema) ||
|
||||
hasSystemStartParameter(currentParameters);
|
||||
if (!shouldNormalize) {
|
||||
return node;
|
||||
}
|
||||
const normalizedParameters = ensureStartNodeParameters(currentParameters);
|
||||
if (JSON.stringify(currentParameters) === JSON.stringify(normalizedParameters)) {
|
||||
const normalizedData = normalizeStartNodeData(currentData);
|
||||
const nextData = {
|
||||
...currentData,
|
||||
...normalizedData,
|
||||
};
|
||||
if (JSON.stringify(currentData) === JSON.stringify(nextData)) {
|
||||
return node;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...(node.data || {}),
|
||||
parameters: normalizedParameters,
|
||||
...nextData,
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -563,6 +1149,148 @@ function getSupportedFieldKeys(nodeType: string | undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function replaceStartFieldReferenceValue(
|
||||
value: unknown,
|
||||
oldRefPath: string,
|
||||
newRefPath: string,
|
||||
): unknown {
|
||||
if (typeof value === 'string') {
|
||||
const oldToken = toToken(oldRefPath);
|
||||
const newToken = toToken(newRefPath);
|
||||
if (value === oldRefPath) {
|
||||
return newRefPath;
|
||||
}
|
||||
if (!value.includes(oldToken)) {
|
||||
return value;
|
||||
}
|
||||
return value.split(oldToken).join(newToken);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
let changed = false;
|
||||
const nextValue = value.map((item) => {
|
||||
const nextItem = replaceStartFieldReferenceValue(item, oldRefPath, newRefPath);
|
||||
if (nextItem !== item) {
|
||||
changed = true;
|
||||
}
|
||||
return nextItem;
|
||||
});
|
||||
return changed ? nextValue : value;
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
let changed = false;
|
||||
const nextValue = Object.fromEntries(
|
||||
Object.entries(value).map(([key, itemValue]) => {
|
||||
const nextItemValue = replaceStartFieldReferenceValue(
|
||||
itemValue,
|
||||
oldRefPath,
|
||||
newRefPath,
|
||||
);
|
||||
if (nextItemValue !== itemValue) {
|
||||
changed = true;
|
||||
}
|
||||
return [key, nextItemValue];
|
||||
}),
|
||||
);
|
||||
return changed ? nextValue : value;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function collectDownstreamNodeIds(rootNodeId: string, edges: Edge[]) {
|
||||
const nodeIds = new Set<string>();
|
||||
|
||||
const visit = (nodeId: string) => {
|
||||
if (!nodeId || nodeIds.has(nodeId)) {
|
||||
return;
|
||||
}
|
||||
nodeIds.add(nodeId);
|
||||
edges
|
||||
.filter((edge) => edge.source === nodeId && edge.sourceHandle !== 'loop_handle')
|
||||
.forEach((edge) => {
|
||||
if (edge.target) {
|
||||
visit(edge.target);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
visit(rootNodeId);
|
||||
nodeIds.delete(rootNodeId);
|
||||
return Array.from(nodeIds);
|
||||
}
|
||||
|
||||
export function renameStartFieldReferencesInNodes(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
startNodeId: string,
|
||||
currentKey: string,
|
||||
nextKey: string,
|
||||
) {
|
||||
const normalizedStartNodeId = trimString(startNodeId);
|
||||
const normalizedCurrentKey = trimString(currentKey);
|
||||
const normalizedNextKey = trimString(nextKey);
|
||||
if (
|
||||
!normalizedStartNodeId ||
|
||||
!normalizedCurrentKey ||
|
||||
!normalizedNextKey ||
|
||||
normalizedCurrentKey === normalizedNextKey
|
||||
) {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
const oldRefPath = `${normalizedStartNodeId}.${normalizedCurrentKey}`;
|
||||
const newRefPath = `${normalizedStartNodeId}.${normalizedNextKey}`;
|
||||
|
||||
const nextNodes = nodes.map((node) => {
|
||||
if (node.id === normalizedStartNodeId) {
|
||||
return node;
|
||||
}
|
||||
const nextData = replaceStartFieldReferenceValue(
|
||||
node.data,
|
||||
oldRefPath,
|
||||
newRefPath,
|
||||
) as Record<string, any> | undefined;
|
||||
if (nextData === node.data) {
|
||||
return node;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
data: nextData,
|
||||
};
|
||||
});
|
||||
|
||||
const affectedNodeIds = collectDownstreamNodeIds(normalizedStartNodeId, edges);
|
||||
if (affectedNodeIds.length === 0) {
|
||||
return nextNodes;
|
||||
}
|
||||
|
||||
const patches = buildSequentialFieldBindingPatches(
|
||||
affectedNodeIds,
|
||||
nextNodes,
|
||||
edges,
|
||||
);
|
||||
if (patches.length === 0) {
|
||||
return nextNodes;
|
||||
}
|
||||
|
||||
const patchMap = new Map(patches.map((item) => [item.nodeId, item.patch] as const));
|
||||
return nextNodes.map((node) => {
|
||||
const patch = patchMap.get(node.id);
|
||||
if (!patch) {
|
||||
return node;
|
||||
}
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...((node.data || {}) as Record<string, any>),
|
||||
...patch,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getSingleRunFieldDescriptors(
|
||||
node: Pick<Node, 'type' | 'data'>,
|
||||
): SingleRunFieldDescriptor[] {
|
||||
@@ -778,12 +1506,15 @@ export function buildSingleRunParameters(node: Pick<Node, 'type' | 'data'> | nul
|
||||
}
|
||||
|
||||
if (node.type === START_NODE_TYPE) {
|
||||
const parameters = Array.isArray(node.data.parameters)
|
||||
? ((node.data.parameters as Parameter[]) || [])
|
||||
const normalizedData = normalizeStartNodeData(
|
||||
(node.data || {}) as Record<string, any>,
|
||||
{
|
||||
allowLegacyParametersOnly: true,
|
||||
},
|
||||
);
|
||||
return Array.isArray(normalizedData.parameters)
|
||||
? (normalizedData.parameters as Parameter[])
|
||||
: [];
|
||||
return hasSystemStartParameter(parameters)
|
||||
? ensureStartNodeParameters(parameters)
|
||||
: parameters;
|
||||
}
|
||||
|
||||
const parameters = Array.isArray(node.data.parameters)
|
||||
|
||||
Reference in New Issue
Block a user