feat: 完善用户确认节点选项与输出流转

- 重构确认节点单选多选配置及输出参数契约

- 统一管理端、用户中心、匿名分享和公共接口恢复流程

- 增加保存校验、错误契约及交互测试
This commit is contained in:
2026-09-04 14:55:55 +08:00
parent 65c85180c2
commit 0968e3bfa5
51 changed files with 2465 additions and 1591 deletions

View File

@@ -106,7 +106,12 @@ export class Tinyflow {
if (!flow) {
return null;
}
return flow.toObject();
this.store.flushPendingEdits();
return {
...flow.toObject(),
nodes: this.store.getNodes(),
edges: this.store.getEdges(),
};
}
updateData(data: TinyflowData, options?: { preserveViewport?: boolean }) {

View File

@@ -15,6 +15,8 @@
variant = 'default',
showSelectedType = true,
selectedType,
disabled = false,
disabledReason,
...rest
}: {
items: SelectItem[],
@@ -26,46 +28,55 @@
variant?: 'default' | 'reference' | 'model'
showSelectedType?: boolean
selectedType?: string
disabled?: boolean
disabledReason?: string
[key: string]: any
} = $props();
let activeItemsState = $derived.by(() => {
const resultItems: SelectItem[] = [];
const fillResult = (items: SelectItem[]) => {
for (let item of items) {
if (value.length > 0) {
if (value.includes(item.value)) {
resultItems.push(item);
}
} else {
if (defaultValue.includes(item.value)) {
resultItems.push(item);
}
}
const flattenedItems: SelectItem[] = [];
const flatten = (sourceItems: SelectItem[]) => {
for (const item of sourceItems) {
flattenedItems.push(item);
if (item.children && item.children.length > 0) {
fillResult(item.children);
flatten(item.children);
}
}
};
fillResult(items);
return resultItems;
flatten(items);
const selectedValues = value.length > 0 ? value : defaultValue;
return selectedValues.flatMap((selectedValue) => {
const item = flattenedItems.find((candidate) => candidate.value === selectedValue);
return item ? [item] : [];
});
});
let triggerObject: any = $state();
let triggerButton: HTMLButtonElement | undefined = $state();
let hoveredItem: SelectItem | null = $state(null);
let isOpen = $state(false);
function closeMenu() {
triggerObject?.hide();
isOpen = false;
hoveredItem = null;
}
function handleKeydown(event: KeyboardEvent) {
if (event.key !== 'Escape' || !isOpen) {
return;
}
event.preventDefault();
event.stopPropagation();
closeMenu();
triggerButton?.focus();
}
function handlerOnSelect(item: SelectItem) {
if (item.selectable !== false) {
onSelect?.(item);
closeMenu();
if (!multiple) {
closeMenu();
}
} else {
if (variant === 'reference') {
hoveredItem = item;
@@ -89,8 +100,23 @@
{#snippet renderDefaultItems(items: SelectItem[], depth = 0)}
{#each items as item}
<button class="tf-select-default-item" style="padding-left: {10 + depth * 14}px" onclick={(e) => { e.stopPropagation(); handlerOnSelect(item); }}>
<button
type="button"
role="option"
class="tf-select-default-item {value.includes(item.value) ? 'active' : ''} {item.selectable === false ? 'disabled' : ''}"
style="padding-left: {10 + depth * 14}px"
aria-selected={value.includes(item.value)}
aria-disabled={item.selectable === false}
aria-label={item.disabledReason
? `${String(item.displayLabel || item.label)}${item.disabledReason}`
: undefined}
title={item.disabledReason || String(item.displayLabel || item.label)}
onclick={(e) => { e.stopPropagation(); handlerOnSelect(item); }}
>
<span class="tf-select-default-item-label">{item.label}</span>
{#if value.includes(item.value)}
<svg class="tf-select-default-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><polyline points="20 6 9 17 4 12"></polyline></svg>
{/if}
</button>
{#if item.children && item.children.length > 0}
<div class="tf-select-default-children">
@@ -205,7 +231,7 @@
{/each}
{/snippet}
<div {...rest} class="tf-select {rest['class']}">
<div {...rest} class="tf-select {rest['class']}" onkeydown={handleKeydown}>
<FloatingTrigger
bind:this={triggerObject}
onShow={() => isOpen = true}
@@ -213,8 +239,26 @@
syncWidth={true}
syncWidthMode={variant === 'default' ? 'equal' : 'min'}
>
<button class="tf-select-input nopan nodrag {isOpen ? 'active' : ''}" {...rest}>
<button
bind:this={triggerButton}
type="button"
class="tf-select-input nopan nodrag {isOpen ? 'active' : ''} {disabled ? 'disabled' : ''}"
{...rest}
{disabled}
title={disabled ? disabledReason : undefined}
aria-haspopup={variant === 'default' ? 'listbox' : undefined}
aria-expanded={isOpen}
>
<div class="tf-select-input-value">
{#if multiple && activeItemsState.length > 0}
{@const item = activeItemsState[0]}
<div class="tf-parameter-label-input">
<span class="tf-parameter-name" title={String(item.displayLabel || item.label)}>{item.displayLabel || item.label}</span>
{#if activeItemsState.length > 1}
<span class="tf-select-count">+{activeItemsState.length - 1}</span>
{/if}
</div>
{:else}
{#each activeItemsState as item, index (`${index}_${item.value}`)}
{#if !multiple}
{#if index === 0}
@@ -238,35 +282,13 @@
{/if}
</div>
{/if}
{:else}
<div class="tf-parameter-label-input">
{#if variant === 'reference' && item.nodeType && nodeIcons[item.nodeType]}
<span class="tf-select-item-icon-input">
{@html nodeIcons[item.nodeType]}
</span>
{:else if variant === 'model' && item.icon}
<span class="tf-select-item-icon-input-model">
{#if isMarkupIcon(item.icon)}
{@html item.icon}
{:else}
<img src={item.icon} alt="" />
{/if}
</span>
{/if}
<span class="tf-parameter-name" title={String(item.displayLabel || item.label)}>{item.displayLabel || item.label}</span>
{#if variant === 'reference' && showSelectedType && (selectedType ?? item.dataType)}
<span class="tf-parameter-type" title={selectedType ?? item.dataType}>{selectedType ?? item.dataType}</span>
{/if}
</div>
{#if index < activeItemsState.length - 1}
<span style="margin-right: 4px;">,</span>
{/if}
{/if}
{:else}
<div class="tf-select-input-placeholder">
{placeholder}
</div>
{/each}
{/if}
</div>
<div class="tf-select-input-arrow">
{#if variant === 'reference'}
@@ -284,7 +306,11 @@
{#snippet floating()}
{#if variant === 'default'}
<div class="tf-select-default-wrapper nopan nodrag nowheel">
<div
class="tf-select-default-wrapper nopan nodrag nowheel"
role="listbox"
aria-multiselectable={multiple}
>
{@render renderDefaultItems(items)}
</div>
{:else if variant === 'model'}
@@ -379,6 +405,46 @@
&:hover {
background: var(--tf-bg-hover);
}
&.active {
background: var(--tf-primary-soft-bg);
}
&.disabled {
color: var(--tf-text-muted);
cursor: help;
opacity: 0.72;
}
&.disabled:hover {
background: var(--tf-bg-surface);
}
}
.tf-select-default-item-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tf-select-default-check {
width: 14px;
height: 14px;
flex-shrink: 0;
color: var(--tf-primary-color);
}
.tf-select-input.disabled {
cursor: not-allowed;
opacity: 0.62;
}
.tf-select-count {
flex-shrink: 0;
color: var(--tf-text-secondary);
font-size: 11px;
}
.tf-select-default-children {

View File

@@ -1,177 +0,0 @@
<script lang="ts">
import {Input, MenuButton, Textarea} from '../base';
import {Button, FloatingTrigger, Select} from '../base/index.js';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {useNodesData, useSvelteFlow} from '@xyflow/svelte';
import {useRefOptions} from '../utils/useRefOptions.svelte';
import type {Parameter} from '#types';
import {confirmFormTypes, contentTypes} from '#consts';
const { parameter, index, dataKeyName, useChildrenOnly }: {
parameter: Parameter,
index: number,
dataKeyName: string,
useChildrenOnly?: boolean,
} = $props();
let currentNodeId = getCurrentNodeId();
let node = useNodesData(currentNodeId);
let param = $derived.by(() => {
return {
...parameter,
...(node?.current?.data?.[dataKeyName] as Array<Parameter>)[index]
};
});
const { updateNodeData } = useSvelteFlow();
const updateParam = (key: string, value: any) => {
updateNodeData(currentNodeId, (node) => {
let parameters = node.data?.[dataKeyName] as Array<Parameter>;
parameters[index] = {
...parameters[index],
[key]: value
};
return {
[dataKeyName]: parameters
};
});
};
const updateParamByEvent = (name: string, event: Event) => {
const newValue = (event.target as any).value;
updateParam(name, newValue);
};
const updateRef = (item: any) => {
const newValue = item.value;
updateParam('ref', newValue);
};
const updateFormType = (item: any) => {
const newValue = item.value;
updateParam('formType', newValue);
};
const updateContentType = (item: any) => {
const newValue = item.value;
updateParam('contentType', newValue);
};
// const updateRequired = (item: any) => {
// const newValue = item.target.checked;
// updateParam('required', newValue);
// };
let triggerObject: any;
const handleDelete = () => {
updateNodeData(currentNodeId, (node) => {
let parameters = node.data?.[dataKeyName] as Array<Parameter>;
parameters.splice(index, 1);
return {
[dataKeyName]: [...parameters]
};
});
triggerObject?.hide();
};
let selectItems = useRefOptions(() => useChildrenOnly === true);
</script>
<div class="input-item">
<Input style="width: 100%;" value={param.name} placeholder="请输入参数名称"
disabled={param.nameDisabled === true}
oninput={(event)=>updateParamByEvent('name', event)} />
</div>
<div class="input-item">
{#if param.refType === 'fixed'}
<Input value={param.value} placeholder="请输入参数值" oninput={(event)=>updateParamByEvent('value', event)} />
{:else if (param.refType !== 'input')}
<Select items={selectItems.current} style="width: 100%" defaultValue={["ref"]} value={[param.ref]} variant="reference"
expandAll
onSelect={updateRef} />
{/if}
</div>
<div class="input-item">
<FloatingTrigger placement="bottom" bind:this={triggerObject}>
<MenuButton />
{#snippet floating()}
<div class="input-more-setting">
<div class="input-more-item">
数据内容:
<Select items={contentTypes} style="width: 100%" defaultValue={["text"]}
value={param.contentType ? [param.contentType] : []}
onSelect={updateContentType}
/>
</div>
<div class="input-more-item">
确认方式:
<Select items={confirmFormTypes} style="width: 100%" defaultValue={["single"]}
value={param.formType ? [param.formType] : []}
onSelect={updateFormType}
/>
</div>
<div class="input-more-item">
数据标题:
<Textarea rows={1} style="width: 100%;" onchange={(event)=>{
updateParamByEvent('formLabel', event)
}} value={param.formLabel} />
</div>
<div class="input-more-item">
数据描述:
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
updateParamByEvent('formDescription', event)
}} value={param.formDescription} />
</div>
<!-- <label class="input-item-inline">-->
<!-- <span>是否必填:</span>-->
<!-- <input type="checkbox" checked={false} onchange={updateRequired} />-->
<!-- </label>-->
<div class="input-more-item">
<Button onclick={handleDelete}>删除</Button>
</div>
</div>
{/snippet}
</FloatingTrigger>
</div>
<style lang="less">
.input-item {
display: flex;
align-items: center;
}
.input-more-setting {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
background: var(--tf-bg-surface);
border: 1px solid var(--tf-border-color-strong);
border-radius: 5px;
width: 200px;
box-shadow: var(--tf-shadow-medium);
.input-more-item {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 12px;
color: var(--tf-text-secondary);
}
}
</style>

View File

@@ -1,66 +0,0 @@
<script lang="ts">
import {useNodesData} from '@xyflow/svelte';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import ConfirmParameterItem from './ConfirmParameterItem.svelte';
const {
noneParameterText = '无确认数据',
dataKeyName = 'parameters',
useChildrenOnly,
}: {
noneParameterText?: string;
dataKeyName?: string;
useChildrenOnly?: boolean,
} = $props();
let currentNodeId = getCurrentNodeId();
let node = useNodesData(currentNodeId);
let parameters = $derived.by(() => {
return [...node?.current?.data?.[dataKeyName] as Array<any> || []];
});
</script>
<div class="input-container">
{#if (parameters.length !== 0)}
<div class="input-header">参数名称</div>
<div class="input-header">参数值</div>
<div class="input-header"></div>
{/if}
{#each parameters as param, index (param.id)}
<ConfirmParameterItem parameter={param} index={index} {dataKeyName} {useChildrenOnly}/>
{:else }
<div class="none-params">{noneParameterText}</div>
{/each}
</div>
<style lang="less">
.input-container {
display: grid;
grid-template-columns: 40% 50% 10%;
row-gap: 5px;
column-gap: 3px;
.none-params {
font-size: 12px;
background: var(--tf-bg-muted);
height: 40px;
display: flex;
justify-content: center;
align-items: center;
border-radius: 5px;
width: calc(100% - 5px);
grid-column: 1 / -1; /* 从第一列开始到最后一列结束 */
}
.input-header {
font-size: 12px;
color: var(--tf-text-secondary);
}
}
</style>

View File

@@ -33,6 +33,7 @@
allowCopy = true,
allowDelete = true,
allowSetting = true,
allowAsyncSetting = true,
allowSettingOfCondition = true,
showSourceHandle = true,
showTargetHandle = true,
@@ -49,6 +50,7 @@
allowCopy?: boolean,
allowDelete?: boolean,
allowSetting?: boolean,
allowAsyncSetting?: boolean,
allowSettingOfCondition?: boolean,
showSourceHandle?: boolean,
showTargetHandle?: boolean,
@@ -268,15 +270,17 @@
</details>
{/if}
<label class="input-item-inline">
<span>异步执行:</span>
<input type="checkbox" checked={!!data.async} onchange={(event)=>{
const value = (event.target as any).checked;
updateNodeData(currentNodeId,{
async: value
})
}} />
</label>
{#if allowAsyncSetting}
<label class="input-item-inline">
<span>异步执行:</span>
<input type="checkbox" checked={!!data.async} onchange={(event)=>{
const value = (event.target as any).checked;
updateNodeData(currentNodeId,{
async: value
})
}} />
</label>
{/if}
<label class="input-item-inline">
<span>循环执行:</span>

View File

@@ -13,12 +13,14 @@
position,
dataKeyName,
placeholder = '请输入参数值',
readOnly = false,
onParametersChange,
}: {
parameter: Parameter,
position: number[],
dataKeyName: string,
placeholder?: string,
readOnly?: boolean,
onParametersChange?: ParameterChangeHandler,
} = $props();
@@ -99,7 +101,7 @@
};
let triggerObject: any;
let triggerObject: any = $state();
const handleDelete = () => {
updateNodeData(currentNodeId, (node) => {
const previousParameters = deepClone(
@@ -180,16 +182,26 @@
{#if position.length > 1}
<span class="output-branch-marker"></span>
{/if}
<Input style="width: 100%;" value={displayParameterName} placeholder={placeholder}
oninput={(e)=>{updateByEvent('name',e)}} disabled={currentParameter.nameDisabled === true} />
{#if readOnly}
<span class="readonly-value" title={displayParameterName}>{displayParameterName || '--'}</span>
{:else}
<Input style="width: 100%;" value={displayParameterName} placeholder={placeholder}
oninput={(e)=>{updateByEvent('name',e)}} disabled={currentParameter.nameDisabled === true} />
{/if}
</div>
</div>
<div class="input-item">
<Select items={currentParameter.dataTypeItems || parameterDataTypes} style="width: 100%" defaultValue={["String"]}
value={currentParameter.dataType ? [currentParameter.dataType]:[]}
disabled={currentParameter.dataTypeDisabled === true}
onSelect={updateDataType} />
{#if (currentParameter.dataType === "Object" || currentParameter.dataType === "Array") && currentParameter.addChildDisabled !== true}
{#if readOnly}
<span class="readonly-value readonly-value--type" title={currentParameter.dataType || 'String'}>
{currentParameter.dataType || 'String'}
</span>
{:else}
<Select items={currentParameter.dataTypeItems || parameterDataTypes} style="width: 100%" defaultValue={["String"]}
value={currentParameter.dataType ? [currentParameter.dataType]:[]}
disabled={currentParameter.dataTypeDisabled === true}
onSelect={updateDataType} />
{/if}
{#if !readOnly && (currentParameter.dataType === "Object" || currentParameter.dataType === "Array") && currentParameter.addChildDisabled !== true}
<Button class="input-btn-more" style="margin-left: auto" onclick={handleAddChildParameter}>
<svg style="transform: scaleY(-1)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
fill="currentColor">
@@ -199,37 +211,41 @@
</Button>
{/if}
</div>
<div class="input-item">
<FloatingTrigger placement="bottom" bind:this={triggerObject}>
<MenuButton />
{#snippet floating()}
<div class="input-more-setting">
<div class="input-more-item">
默认值:
<Textarea rows={1} style="width: 100%;"
value={currentParameter.defaultValue||''}
onchange={(event)=>{
updateByEvent( 'defaultValue', event)
}} />
</div>
<div class="input-more-item">
参数描述:
<Textarea rows={3} style="width: 100%;"
value={currentParameter.description||''}
onchange={(event)=>{
updateByEvent( 'description', event)
}} />
</div>
{#if currentParameter.deleteDisabled !== true}
{#if !readOnly}
<div class="input-item">
{#if currentParameter.settingsDisabled !== true}
<FloatingTrigger placement="bottom" bind:this={triggerObject}>
<MenuButton />
{#snippet floating()}
<div class="input-more-setting">
<div class="input-more-item">
<Button onclick={handleDelete}>删除</Button>
默认值:
<Textarea rows={1} style="width: 100%;"
value={currentParameter.defaultValue||''}
onchange={(event)=>{
updateByEvent( 'defaultValue', event)
}} />
</div>
{/if}
</div>
{/snippet}
</FloatingTrigger>
</div>
<div class="input-more-item">
参数描述:
<Textarea rows={3} style="width: 100%;"
value={currentParameter.description||''}
onchange={(event)=>{
updateByEvent( 'description', event)
}} />
</div>
{#if currentParameter.deleteDisabled !== true}
<div class="input-more-item">
<Button onclick={handleDelete}>删除</Button>
</div>
{/if}
</div>
{/snippet}
</FloatingTrigger>
{/if}
</div>
{/if}
<style lang="less">
@@ -238,6 +254,7 @@
display: flex;
align-items: center;
gap: 2px;
min-width: 0;
}
.output-name-shell {
@@ -261,6 +278,22 @@
opacity: 0.9;
}
.readonly-value {
display: block;
min-width: 0;
overflow: hidden;
font-size: 12px;
line-height: 24px;
color: var(--tf-text-primary);
text-overflow: ellipsis;
white-space: nowrap;
&--type {
color: var(--tf-text-secondary);
text-align: right;
}
}
.input-more-setting {
display: flex;
flex-direction: column;

View File

@@ -8,11 +8,13 @@
noneParameterText = '无输出参数',
dataKeyName = 'outputDefs',
placeholder = '请输入参数名称',
readOnly = false,
onParametersChange,
}: {
noneParameterText?: string;
dataKeyName?: string;
placeholder?: string;
readOnly?: boolean;
onParametersChange?: ParameterChangeHandler;
} = $props();
@@ -31,6 +33,7 @@
position={[...position, index]}
{dataKeyName}
{placeholder}
{readOnly}
{onParametersChange}
/>
{#if param.children}
@@ -44,11 +47,13 @@
{/snippet}
<div class="input-container">
<div class="input-container" class:input-container--readonly={readOnly}>
{#if (parameters.length !== 0)}
<div class="input-header">参数名称</div>
<div class="input-header">参数类型</div>
<div class="input-header"></div>
{#if !readOnly}
<div class="input-header"></div>
{/if}
{/if}
{@render parameterList(parameters || [], [])}
</div>
@@ -65,6 +70,11 @@
min-width: 0;
box-sizing: border-box;
&--readonly {
grid-template-columns: minmax(0, 1fr) auto;
column-gap: 16px;
}
.none-params {
font-size: 12px;
background: var(--tf-bg-muted);
@@ -82,6 +92,10 @@
font-size: 12px;
color: var(--tf-text-secondary);
min-width: 0;
&:nth-child(2) {
text-align: right;
}
}
}

View File

@@ -1,132 +1,461 @@
<svelte:options customElement={{ props: {} }} />
<script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte';
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte';
import {Button, Heading} from '../base';
import {Textarea} from '../base/index.js';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {useAddParameter} from '../utils/useAddParameter.svelte';
import {useSvelteFlow} from '@xyflow/svelte';
import {onMount, untrack} from 'svelte';
import type {TinyflowNodeData} from '#types';
import {Heading, Input, Textarea} from '../base';
import OutputDefList from '../core/OutputDefList.svelte';
import ConfirmParameterList from '../core/ConfirmParameterList.svelte';
import type {Parameter, TinyflowNodeData} from '#types';
import {deepEqual} from '#components/utils/deepEqual';
import NodeWrapper from '../core/NodeWrapper.svelte';
import {
createConfirmOption,
MAX_CONFIRM_OPTIONS,
normalizeConfirmNodeData,
validateConfirmOptions,
} from '../utils/confirmNode';
import {deepEqual} from '../utils/deepEqual';
import {getCurrentNodeId} from '../utils/NodeUtils';
import {useTinyflowStore} from '../../store/stores.svelte';
const {data, ...rest}: {
data: TinyflowNodeData;
[key: string]: any;
} = $props();
const { data, ...rest }: {
data: TinyflowNodeData,
[key: string]: any
} = $props();
const currentNodeId = getCurrentNodeId();
const {updateNodeData} = useSvelteFlow();
const store = useTinyflowStore();
const INPUT_COMMIT_DELAY_MS = 200;
const multiple = $derived(data.multiple === true);
let messageDraft = $state(untrack(() => String(data.message || '')));
let optionDrafts = $state<string[]>(
untrack(() => normalizeOptions(data.options)),
);
let messageDirty = false;
let optionsDirty = false;
let inputCommitTimer: ReturnType<typeof setTimeout> | undefined;
const options = $derived(optionDrafts);
const validations = $derived(validateConfirmOptions(options));
let draggedOptionIndex = $state<number | null>(null);
const currentNodeId = getCurrentNodeId();
const { addParameter } = useAddParameter();
const { updateNodeData } = useSvelteFlow();
function normalizeOptions(value: unknown) {
return Array.isArray(value)
? value.map((option) => typeof option === 'string' ? option : '')
: [];
}
$effect(() => {
if (data.confirms) {
const outputDefs = data.confirms.map((confirm: Parameter) => {
return {
// id?: string;
// name?: string;
// nameDisabled?: boolean;
// dataType?: string;
// dataTypeDisabled?: boolean;
// ref?: string;
// refType?: string;
// value?: string;
// description?: string;
// required?: boolean;
// defaultValue?: string;
// deleteDisabled?: boolean;
// addChildDisabled?: boolean;
// children?: Parameter[];
...confirm,
nameDisabled: true,
dataTypeDisabled: true,
dataType: confirm.formType === 'checkbox' || confirm.formType === 'select' ? 'Array' : 'String',
addChildDisabled: true
} as Parameter;
});
function sameOptions(left: string[], right: string[]) {
return left.length === right.length
&& left.every((option, index) => option === right[index]);
}
// 判断 outputDefs 与 data.outputDefs 是否完全一致
// 如果不判断,则会造成死循环更新
if (!deepEqual(outputDefs, data.outputDefs)) {
updateNodeData(currentNodeId, () => {
return {
outputDefs
};
});
}
}
$effect(() => {
const nextMessage = String(data.message || '');
const nextOptions = normalizeOptions(data.options);
untrack(() => {
if (!messageDirty && messageDraft !== nextMessage) {
messageDraft = nextMessage;
}
if (!optionsDirty && !sameOptions(optionDrafts, nextOptions)) {
optionDrafts = nextOptions;
}
});
});
$effect(() => {
const normalizedData = normalizeConfirmNodeData(data);
if (!deepEqual(normalizedData, data)) {
updateNodeData(currentNodeId, normalizedData, {replace: true});
}
});
function flushInputDraft() {
if (inputCommitTimer) {
clearTimeout(inputCommitTimer);
inputCommitTimer = undefined;
}
if (!messageDirty && !optionsDirty) return;
const patch: Record<string, unknown> = {};
if (messageDirty) patch.message = messageDraft;
if (optionsDirty) patch.options = [...optionDrafts];
messageDirty = false;
optionsDirty = false;
store.updateNodeData(currentNodeId, patch);
}
function scheduleInputCommit() {
if (inputCommitTimer) clearTimeout(inputCommitTimer);
inputCommitTimer = setTimeout(flushInputDraft, INPUT_COMMIT_DELAY_MS);
}
function updateMessage(content: string) {
messageDraft = content;
messageDirty = true;
scheduleInputCommit();
}
function replaceOptions(nextOptions: string[], immediate = false) {
optionDrafts = nextOptions;
optionsDirty = true;
if (immediate) {
flushInputDraft();
return;
}
scheduleInputCommit();
}
function updateOption(index: number, content: string) {
replaceOptions(options.map((option, optionIndex) =>
optionIndex === index ? content : option,
));
}
function addOption() {
if (options.length >= MAX_CONFIRM_OPTIONS) return;
replaceOptions([...options, createConfirmOption(options)], true);
}
function deleteOption(index: number) {
if (options.length <= 1) return;
replaceOptions(
options.filter((_, optionIndex) => optionIndex !== index),
true,
);
}
function moveOption(from: number, to: number) {
if (from === to || from < 0 || to < 0 || to >= options.length) return;
const nextOptions = [...options];
const [moved] = nextOptions.splice(from, 1);
nextOptions.splice(to, 0, moved);
replaceOptions(nextOptions, true);
}
function onGripKeydown(event: KeyboardEvent, index: number) {
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return;
event.preventDefault();
moveOption(index, index + (event.key === 'ArrowUp' ? -1 : 1));
}
onMount(() => {
const unregister = store.registerPendingEditFlusher(flushInputDraft);
return () => {
flushInputDraft();
unregister();
};
});
</script>
<NodeWrapper
{data}
{...rest}
allowAsyncSetting={false}
wrapperClass="tf-node-wrapper--confirm"
>
{#snippet icon()}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.3873 13.4975L17.9403 20.5117L13.2418 22.2218L10.6889 15.2076L6.79004 17.6529L8.4086 1.63318L19.9457 12.8646L15.3873 13.4975ZM15.3768 19.3163L12.6618 11.8568L15.6212 11.4459L9.98201 5.9561L9.19088 13.7863L11.7221 12.1988L14.4371 19.6583L15.3768 19.3163Z"></path>
</svg>
{/snippet}
<NodeWrapper {data} {...rest}>
<div class="confirm-card">
<section>
<Heading level={3} mb="8px">固定信息</Heading>
<div class="setting-title">提示内容</div>
<Textarea
class="confirm-message"
rows={3}
maxHeight="120px"
maxlength={2000}
placeholder="请输入用户需要确认的提示内容"
style="width: 100%"
value={messageDraft}
oninput={(event: Event) => updateMessage(
(event.target as HTMLTextAreaElement).value,
)}
onblur={flushInputDraft}
/>
</section>
{#snippet icon()}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path
d="M23 12L15.9289 19.0711L14.5147 17.6569L20.1716 12L14.5147 6.34317L15.9289 4.92896L23 12ZM3.82843 12L9.48528 17.6569L8.07107 19.0711L1 12L8.07107 4.92896L9.48528 6.34317L3.82843 12Z"></path>
</svg>
{/snippet}
<section>
<Heading level={3} mb="8px">交互选项</Heading>
<fieldset class="choice-mode">
<legend>选择方式</legend>
<label>
<input
type="radio"
name="confirm-mode-{currentNodeId}"
checked={!multiple}
onchange={() => updateNodeData(currentNodeId, {multiple: false})}
/>
<span>单选</span>
</label>
<label>
<input
type="radio"
name="confirm-mode-{currentNodeId}"
checked={multiple}
onchange={() => updateNodeData(currentNodeId, {multiple: true})}
/>
<span>多选</span>
</label>
</fieldset>
<div class="heading">
<Heading level={3}>确认数据</Heading>
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
addParameter(currentNodeId, 'confirms')
}}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
</svg>
</Button>
</div>
<ConfirmParameterList dataKeyName="confirms" noneParameterText="无确认数据" />
<Heading level={3} mt="10px">确认消息</Heading>
<div class="setting-title">消息内容</div>
<div class="setting-item">
<Textarea rows={5} placeholder="请输入用户需要确认的消息内容"
style="width: 100%" onchange={(e:any)=>{
updateNodeData(currentNodeId, ()=>{
return {
message: e.target.value
<div class="option-list nowheel">
{#each options as option, optionIndex (optionIndex)}
<div class="option-item">
<div
class="option-row"
role="group"
aria-label={`选项 ${optionIndex + 1}`}
ondragover={(event: DragEvent) => event.preventDefault()}
ondrop={(event: DragEvent) => {
event.preventDefault();
if (draggedOptionIndex !== null) {
moveOption(draggedOptionIndex, optionIndex);
}
})
}} value={String(data.message || '')} />
</div>
draggedOptionIndex = null;
}}
>
<button
type="button"
class="grip nodrag nopan"
draggable="true"
aria-label={`调整选项 ${optionIndex + 1} 顺序,方向键也可移动`}
ondragstart={() => draggedOptionIndex = optionIndex}
ondragend={() => draggedOptionIndex = null}
onkeydown={(event: KeyboardEvent) => onGripKeydown(event, optionIndex)}
></button>
<Input
value={option}
maxlength={200}
aria-label={`选项 ${optionIndex + 1} 内容`}
aria-invalid={Boolean(validations[optionIndex])}
placeholder="请输入选项内容"
oninput={(event: Event) => updateOption(
optionIndex,
(event.target as HTMLInputElement).value,
)}
onblur={flushInputDraft}
/>
<button
type="button"
class="icon-action nodrag nopan"
aria-label={`删除选项 ${option || optionIndex + 1}`}
disabled={options.length <= 1}
onclick={() => deleteOption(optionIndex)}
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path d="M4 7h16M9 7V4h6v3M7 7l1 13h8l1-13M10 11v5M14 11v5"></path>
</svg>
</button>
</div>
{#if validations[optionIndex]}
<div class="validation-message" role="alert">{validations[optionIndex]}</div>
{/if}
</div>
{/each}
</div>
<button
type="button"
class="text-action nodrag nopan"
disabled={options.length >= MAX_CONFIRM_OPTIONS}
onclick={addOption}
><span aria-hidden="true"></span> 添加选项</button>
</section>
<div class="heading">
<Heading level={3} mt="10px">输出参数</Heading>
</div>
<OutputDefList placeholder="" />
<section>
<Heading level={3} mb="10px">输出参数</Heading>
<OutputDefList />
</section>
</div>
</NodeWrapper>
<style>
.heading {
display: flex;
margin-bottom: 10px;
}
<style lang="less">
.confirm-card {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 0;
}
.setting-title {
font-size: 12px;
color: var(--tf-text-muted);
margin-bottom: 4px;
margin-top: 10px;
}
section {
min-width: 0;
}
.setting-item {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
gap: 10px;
}
.setting-title,
.choice-mode {
font-size: 12px;
line-height: 18px;
color: var(--tf-text-secondary);
}
.setting-title {
margin-bottom: 4px;
}
:global(.confirm-message) {
box-sizing: border-box;
min-height: 72px;
font-size: 13px;
line-height: 1.5;
resize: none;
}
.choice-mode {
display: flex;
gap: 16px;
align-items: center;
padding: 0;
margin: 0 0 8px;
border: 0;
}
.choice-mode legend {
float: left;
margin-right: 2px;
}
.choice-mode label {
display: inline-flex;
gap: 5px;
align-items: center;
color: var(--tf-text-primary);
cursor: pointer;
}
.choice-mode input {
width: 14px;
height: 14px;
margin: 0;
accent-color: var(--tf-primary-color);
}
.choice-mode input:focus-visible,
.grip:focus-visible,
.icon-action:focus-visible,
.text-action:focus-visible {
outline: 0;
border-radius: 5px;
box-shadow: var(--tf-focus-shadow);
}
.option-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 240px;
padding-right: 2px;
overflow-y: auto;
overscroll-behavior: contain;
}
.option-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.option-row {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) 28px;
gap: 6px;
align-items: center;
min-width: 0;
}
.option-row :global(.tf-input) {
box-sizing: border-box;
width: 100%;
min-width: 0;
height: 30px;
font-size: 12px;
}
.grip,
.icon-action,
.text-action {
padding: 0;
background: transparent;
border: 0;
}
.grip,
.icon-action {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--tf-text-muted);
}
.grip {
width: 18px;
height: 28px;
font-size: 16px;
cursor: grab;
}
.grip:active {
cursor: grabbing;
}
.icon-action {
width: 28px;
height: 28px;
cursor: pointer;
opacity: .56;
}
.icon-action svg {
display: block;
width: 16px;
height: 16px;
overflow: visible;
stroke: currentcolor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.option-row:hover .icon-action,
.option-row:focus-within .icon-action {
opacity: .72;
}
.icon-action:hover:not(:disabled) {
color: var(--tf-danger-color);
opacity: 1;
}
.icon-action:disabled,
.text-action:disabled {
cursor: not-allowed;
opacity: .35;
}
.validation-message {
padding-left: 24px;
font-size: 11px;
line-height: 16px;
color: var(--tf-danger-color);
}
.text-action {
display: inline-flex;
gap: 3px;
align-items: center;
margin-top: 6px;
font-size: 12px;
line-height: 20px;
color: var(--tf-primary-color);
cursor: pointer;
}
.text-action:hover:not(:disabled) {
color: var(--tf-primary-color-hover);
}
</style>

View File

@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest';
import {
buildConfirmOutputDefs,
createConfirmOption,
normalizeConfirmNodeData,
validateConfirmOptions,
} from './confirmNode';
describe('confirm node contract', () => {
it('maps the selection mode to one output with a default name', () => {
expect(buildConfirmOutputDefs(false)).toMatchObject([
{ name: 'selection', dataType: 'String' },
]);
expect(buildConfirmOutputDefs(true)).toMatchObject([
{ name: 'selection', dataType: 'Array<String>' },
]);
});
it('keeps the output name editable and locks its inferred type', () => {
expect(buildConfirmOutputDefs(false)[0]).toMatchObject({
addChildDisabled: true,
autoManaged: true,
dataTypeItems: [{ label: 'String', value: 'String' }],
dataTypeDisabled: true,
deleteDisabled: true,
settingsDisabled: true,
});
expect(buildConfirmOutputDefs(false)[0]).not.toHaveProperty('nameDisabled');
expect(buildConfirmOutputDefs(true)[0]).toMatchObject({
dataType: 'Array<String>',
dataTypeItems: [
{ label: 'Array<String>', value: 'Array<String>' },
],
});
});
it('preserves a configured output name when the selection mode changes', () => {
expect(buildConfirmOutputDefs(true, 'templateChoice')).toMatchObject([
{ name: 'templateChoice', dataType: 'Array<String>' },
]);
});
it('materializes the visible single-select default in node data', () => {
expect(normalizeConfirmNodeData({
message: '请选择会议纪要模板',
options: ['确认', '取消'],
})).toMatchObject({
multiple: false,
outputDefs: [{ name: 'selection', dataType: 'String' }],
});
});
it('removes data that is not part of the final confirm contract', () => {
const normalized = normalizeConfirmNodeData({
async: true,
confirms: [{ name: 'legacy' }],
fields: [{ key: 'legacy' }],
message: '请选择会议纪要模板',
multiple: false,
options: ['确认', '取消'],
outputDefs: [{ name: 'templateType', dataType: 'String' }],
parameters: [{ name: 'unused' }],
schemaVersion: 1,
unknownLegacySetting: true,
title: '用户确认',
});
expect(normalized).toMatchObject({
message: '请选择会议纪要模板',
multiple: false,
options: ['确认', '取消'],
outputDefs: [{ name: 'templateType', dataType: 'String' }],
title: '用户确认',
});
expect(normalized).not.toHaveProperty('confirms');
expect(normalized).not.toHaveProperty('async');
expect(normalized).not.toHaveProperty('fields');
expect(normalized).not.toHaveProperty('parameters');
expect(normalized).not.toHaveProperty('schemaVersion');
expect(normalized).not.toHaveProperty('unknownLegacySetting');
});
it('preserves common node settings that are effective at runtime', () => {
const normalized = normalizeConfirmNodeData({
condition: 'true',
description: '确认继续或选择内容',
expand: true,
joinMode: 'all',
loopEnable: true,
loopIntervalMs: 1000,
maxLoopCount: 2,
message: '请选择会议纪要模板',
multiple: false,
options: ['确认', '取消'],
outputDefs: [{ name: 'selection', dataType: 'String' }],
retryEnable: true,
retryIntervalMs: 1000,
maxRetryCount: 3,
title: '用户确认',
});
expect(normalized).toMatchObject({
condition: 'true',
expand: true,
joinMode: 'all',
loopEnable: true,
retryEnable: true,
title: '用户确认',
});
});
it('keeps invalid explicit modes visible to backend validation', () => {
expect(normalizeConfirmNodeData({
multiple: 'false',
outputDefs: [{ name: 'templateType', dataType: 'String' }],
}).multiple).toBe('false');
});
it('creates a unique option content after an option was removed', () => {
expect(createConfirmOption(['选项 2', '选项 3'])).toBe('选项 4');
});
it('reports empty and duplicate option contents', () => {
expect(validateConfirmOptions(['', '审议类', ' 审议类 '])).toEqual([
'请输入选项内容',
'选项内容不能重复',
'选项内容不能重复',
]);
});
});

View File

@@ -0,0 +1,102 @@
import type { Parameter } from '#types';
export const MAX_CONFIRM_OPTIONS = 100;
export const DEFAULT_CONFIRM_OUTPUT_NAME = 'selection';
export const CONFIRM_NODE_DATA_KEYS = new Set([
'condition',
'description',
'expand',
'joinMode',
'loopBreakCondition',
'loopEnable',
'loopIntervalMs',
'maxLoopCount',
'maxRetryCount',
'message',
'multiple',
'options',
'outputDefs',
'resetRetryCountAfterNormal',
'retryEnable',
'retryIntervalMs',
'title',
]);
export function createConfirmOption(existing: string[]) {
let sequence = existing.length + 1;
let option = `选项 ${sequence}`;
while (existing.includes(option)) {
sequence += 1;
option = `选项 ${sequence}`;
}
return option;
}
export function buildConfirmOutputDefs(
multiple: boolean,
outputName = DEFAULT_CONFIRM_OUTPUT_NAME,
): Parameter[] {
const dataType = multiple ? 'Array<String>' : 'String';
return [
{
id: 'confirm-selection',
name: outputName,
dataType,
dataTypeItems: [{ label: dataType, value: dataType }],
dataTypeDisabled: true,
addChildDisabled: true,
deleteDisabled: true,
settingsDisabled: true,
autoManaged: true,
},
];
}
export function normalizeConfirmNodeData(data: Record<string, any>) {
let nextData = data;
const mutableData = () => {
if (nextData === data) {
nextData = { ...data };
}
return nextData;
};
for (const key of Object.keys(nextData)) {
if (!CONFIRM_NODE_DATA_KEYS.has(key)) {
delete mutableData()[key];
}
}
if (nextData.multiple == null) {
mutableData().multiple = false;
}
const configuredOutputName = Array.isArray(nextData.outputDefs)
&& typeof nextData.outputDefs[0]?.name === 'string'
? nextData.outputDefs[0].name
: DEFAULT_CONFIRM_OUTPUT_NAME;
const outputDefs = buildConfirmOutputDefs(
nextData.multiple === true,
configuredOutputName,
);
if (JSON.stringify(nextData.outputDefs) !== JSON.stringify(outputDefs)) {
mutableData().outputDefs = outputDefs;
}
return nextData;
}
export function validateConfirmOptions(options: string[]) {
const counts = new Map<string, number>();
for (const option of options) {
const normalized = option.trim();
if (normalized) counts.set(normalized, (counts.get(normalized) || 0) + 1);
}
return options.map((option) => {
const normalized = option.trim();
if (!normalized) return '请输入选项内容';
if (counts.get(normalized)! > 1) return '选项内容不能重复';
return undefined;
});
}

View File

@@ -3,6 +3,7 @@ import type { Node } from '@xyflow/svelte';
import type { TinyflowOptions } from '#types';
import { DEFAULT_CODE_NODE_JAVASCRIPT } from './codeNodeScaffold';
import { buildConfirmOutputDefs } from './confirmNode';
export type NodePaletteItem = {
icon?: string;
@@ -101,6 +102,12 @@ const BUILT_IN_NODES: NodePaletteItem[] = [
sortNo: 900,
description: '确认继续或选择内容',
category: '输入输出',
extra: {
message: '请确认以下内容',
multiple: false,
options: ['选项一', '选项二'],
outputDefs: buildConfirmOutputDefs(false),
},
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M6 5.1438V16.0002H18.3391L6 5.1438ZM4 2.932C4 2.07155 5.01456 1.61285 5.66056 2.18123L21.6501 16.2494C22.3423 16.8584 21.9116 18.0002 20.9896 18.0002H6V22H4V2.932Z"></path></svg>',

View File

@@ -10,6 +10,7 @@ export const createStore = () => {
let edgesInternal = $state.raw([] as Edge[]);
let viewport = $state.raw({ ...DEFAULT_VIEWPORT } as Viewport);
let normalizeNode: TinyflowNodeNormalizer = (node) => node;
const pendingEditFlushers = new Set<() => void>();
const normalizeNodes = (nodes: Node[]) => nodes.map(normalizeNode);
@@ -39,6 +40,13 @@ export const createStore = () => {
setViewport: (v: Viewport) => {
viewport = v;
},
registerPendingEditFlusher: (flusher: () => void) => {
pendingEditFlushers.add(flusher);
return () => pendingEditFlushers.delete(flusher);
},
flushPendingEdits: () => {
[...pendingEditFlushers].forEach((flusher) => flusher());
},
getNode: (id: string) => nodesInternal.find((node) => node.id === id),
addNode: (node: Node) => {

View File

@@ -120,6 +120,12 @@
min-width: 296px;
max-width: 296px;
}
&--confirm {
width: 360px;
min-width: 360px;
max-width: 360px;
}
}
.svelte-flow__attribution a {

View File

@@ -85,4 +85,35 @@ describe('tinyflow store isolation', () => {
first.destroy();
second.destroy();
});
it('flushes pending node edits before exporting data', async () => {
const container = document.createElement('div');
document.body.append(container);
const tinyflow = new Tinyflow({
element: container,
data: {
nodes: [
{ id: 'confirm', position: { x: 0, y: 0 }, data: { message: '旧值' } },
],
edges: [],
},
});
await waitForRender();
const store = (tinyflow as unknown as {
store: {
registerPendingEditFlusher: (flusher: () => void) => () => boolean;
updateNodeData: (id: string, data: Record<string, unknown>) => void;
};
}).store;
const unregister = store.registerPendingEditFlusher(() => {
store.updateNodeData('confirm', { message: '最新值' });
});
expect(tinyflow.getData()?.nodes[0]?.data.message).toBe('最新值');
unregister();
tinyflow.destroy();
});
});

View File

@@ -25,6 +25,7 @@ export type SelectItem = {
itemTypeLabel?: string;
isCollection?: boolean;
tags?: string[];
disabledReason?: string;
children?: SelectItem[];
};
@@ -157,9 +158,11 @@ export type Parameter = {
required?: boolean;
defaultValue?: string;
deleteDisabled?: boolean;
settingsDisabled?: boolean;
addChildDisabled?: boolean;
children?: Parameter[];
enums?: string[];
options?: ParameterOption[];
formType?: string;
formLabel?: string;
formDescription?: string;
@@ -174,6 +177,11 @@ export type Parameter = {
flattenAggregation?: boolean;
};
export type ParameterOption = {
label: string;
value: string;
};
export type ParameterChangeHandler = (
previousParameters: Parameter[],
nextParameters: Parameter[],

View File

@@ -16,7 +16,7 @@ import {
FIELD_BINDING_META_KEY,
isStartFormFieldKeyAvailable,
normalizeStartNodeData,
normalizeWorkflowStartNodes,
normalizeWorkflowNodes,
renameStartFieldReferencesInNodes,
removeStartFormField,
syncManagedParametersForFields,
@@ -1395,7 +1395,7 @@ describe('workflow node fields', () => {
});
it('normalizes only start nodes that already contain fixed user_input', () => {
const normalizedWorkflow = normalizeWorkflowStartNodes({
const normalizedWorkflow = normalizeWorkflowNodes({
nodes: [
{
id: 'start_new',
@@ -1430,4 +1430,63 @@ describe('workflow node fields', () => {
).toBe('user_input');
expect(normalizedWorkflow.nodes[1]?.data?.parameters).toEqual([]);
});
it('removes retired confirm data without changing the current contract', () => {
const normalizedWorkflow = normalizeWorkflowNodes({
nodes: [
{
id: 'confirm_1',
type: 'confirmNode',
data: {
title: '用户确认',
message: '请选择会议纪要模板',
multiple: false,
options: ['确认', '取消'],
outputDefs: [{ name: 'selection', dataType: 'String' }],
confirms: [],
fields: [],
parameters: [{ name: 'unused' }],
schemaVersion: 1,
},
},
],
edges: [],
});
expect(normalizedWorkflow.nodes[0]?.data).toMatchObject({
title: '用户确认',
message: '请选择会议纪要模板',
multiple: false,
options: ['确认', '取消'],
outputDefs: [{ name: 'selection', dataType: 'String' }],
});
expect(normalizedWorkflow.nodes[0]?.data).not.toHaveProperty('confirms');
expect(normalizedWorkflow.nodes[0]?.data).not.toHaveProperty('fields');
expect(normalizedWorkflow.nodes[0]?.data).not.toHaveProperty('parameters');
expect(normalizedWorkflow.nodes[0]?.data).not.toHaveProperty(
'schemaVersion',
);
});
it('writes the visible single-select default into confirm node data', () => {
const normalizedWorkflow = normalizeWorkflowNodes({
nodes: [
{
id: 'confirm_1',
type: 'confirmNode',
data: {
message: '请选择会议纪要模板',
options: ['确认', '取消'],
outputDefs: [{ name: 'templateType', dataType: 'String' }],
},
},
],
edges: [],
});
expect(normalizedWorkflow.nodes[0]?.data).toMatchObject({
multiple: false,
outputDefs: [{ name: 'templateType', dataType: 'String' }],
});
});
});

View File

@@ -10,8 +10,10 @@ import {
buildLoopReferenceParameters,
buildLoopScopeParameters,
} from './loopScope';
import { normalizeConfirmNodeData } from '../components/utils/confirmNode';
export const START_NODE_TYPE = 'startNode';
export const CONFIRM_NODE_TYPE = 'confirmNode';
export const LLM_NODE_TYPE = 'llmNode';
export const KNOWLEDGE_NODE_TYPE = 'knowledgeNode';
export const SYSTEM_START_PARAM_NAME = 'user_input';
@@ -1147,7 +1149,7 @@ export function createInitialWorkflowData() {
};
}
export function normalizeWorkflowStartNodes<T extends Record<string, any>>(
export function normalizeWorkflowNodes<T extends Record<string, any>>(
data: T,
): T {
if (!data || typeof data !== 'object' || !Array.isArray(data.nodes)) {
@@ -1156,10 +1158,24 @@ export function normalizeWorkflowStartNodes<T extends Record<string, any>>(
let changed = false;
const nextNodes = data.nodes.map((node) => {
if (node?.type !== START_NODE_TYPE) {
if (!node?.data || typeof node.data !== 'object') {
return node;
}
const currentData = (node.data || {}) as Record<string, any>;
if (node.type === CONFIRM_NODE_TYPE) {
const nextData = normalizeConfirmNodeData(currentData);
if (nextData === currentData) {
return node;
}
changed = true;
return {
...node,
data: nextData,
};
}
if (node.type !== START_NODE_TYPE) {
return node;
}
const currentParameters = Array.isArray(currentData.parameters)
? (currentData.parameters as Parameter[])
: [];