feat: 优化工作流字段化参数配置

- 开始节点固定 user_input 并区分系统入口与自定义参数

- LLM 与知识库节点切换为字段值加上游引用配置

- 单节点调试改为字段预览与上游引用输入模式
This commit is contained in:
2026-04-12 20:31:02 +08:00
parent 47655a728b
commit 8cfe5400fe
24 changed files with 2785 additions and 792 deletions

View File

@@ -37,6 +37,11 @@
import {onDestroy, onMount} from 'svelte';
import {isInEditableElement} from '#components/utils/isInEditableElement';
import {getAvailableNodes, type NodePaletteItem} from './utils/nodePalette';
import {
buildSequentialFieldBindingPatches,
ensureStartNodeParameters,
START_NODE_TYPE,
} from '../utils/workflowNodeFields';
const { onInit }: { onInit: any; [key: string]: any } = $props();
const svelteFlow = useSvelteFlow();
@@ -142,6 +147,13 @@
}
} as Node;
if (newNode.type === START_NODE_TYPE) {
newNode.data = {
...(newNode.data || {}),
parameters: ensureStartNodeParameters((newNode.data?.parameters as Array<any>) || [])
};
}
if (sourceNode) {
if (connection?.sourceHandle === 'loop_handle') {
newNode.parentId = sourceNode.id;
@@ -173,6 +185,8 @@
});
store.addEdge(edge as Edge);
}
applyAutoBindingsForNode(newNode.id);
}
function closeNodePicker() {
@@ -367,6 +381,47 @@
const { getNodesFromSource } = useGetNodesFromSource();
const { getNodeRelativePosition } = useGetNodeRelativePosition();
const { ensureParentInNodesBefore } = useEnsureParentInNodesBefore();
function collectAffectedNodeIds(rootNodeIds: string[], edges: Edge[] = store.getEdges()) {
const affectedNodeIds = new Set<string>();
const visit = (nodeId: string) => {
if (!nodeId || affectedNodeIds.has(nodeId)) {
return;
}
affectedNodeIds.add(nodeId);
edges
.filter((edge) => edge.source === nodeId && edge.sourceHandle !== 'loop_handle')
.forEach((edge) => {
if (edge.target) {
visit(edge.target);
}
});
};
rootNodeIds.forEach(visit);
return Array.from(affectedNodeIds);
}
function reconcileBindingsForNodes(
nodeIds: string[],
options?: {
nodes?: Node[];
edges?: Edge[];
}
) {
const uniqueNodeIds = Array.from(new Set(nodeIds.filter((nodeId) => asString(nodeId).trim())));
if (uniqueNodeIds.length === 0) {
return;
}
queueMicrotask(() => {
const nodes = options?.nodes || store.getNodes();
const edges = options?.edges || store.getEdges();
const patches = buildSequentialFieldBindingPatches(uniqueNodeIds, nodes, edges);
patches.forEach(({ nodeId, patch }) => {
store.updateNodeData(nodeId, patch);
});
});
}
const onconnectend = (event: any, state: any) => {
if (!state.isValid) {
if (state.toNode) {
@@ -449,7 +504,11 @@
const { getEdgesByTarget } = useGetEdgesByTarget();
const onDelete = (params: any) => {
const deleteEdges = params.edges as Edge[];
const affectedRootNodeIds = new Set<string>();
deleteEdges.forEach((edge) => {
if (edge.target) {
affectedRootNodeIds.add(edge.target);
}
if (edge.id === currentEdge?.id) {
currentEdge = null;
showEdgePanel = false;
@@ -513,6 +572,11 @@
}
}
});
if (affectedRootNodeIds.size > 0) {
queueMicrotask(() => {
reconcileBindingsForNodes(collectAffectedNodeIds(Array.from(affectedRootNodeIds)));
});
}
};
const { deleteEdge } = useDeleteEdge();
@@ -525,9 +589,41 @@
const onconnect = (event: any) => {
// console.log('onconnect: ', event);
const targetNodeId = asString(event?.target).trim();
if (!targetNodeId) {
return;
}
const sourceNodeId = asString(event?.source).trim();
const projectedEdges = [...store.getEdges()];
const hasSameEdge = projectedEdges.some((edge) =>
edge.source === sourceNodeId
&& edge.target === targetNodeId
&& (edge.sourceHandle || '') === asString(event?.sourceHandle).trim()
&& (edge.targetHandle || '') === asString(event?.targetHandle).trim()
);
if (!hasSameEdge && sourceNodeId) {
projectedEdges.push({
id: asString(event?.id).trim() || `edge_${genShortId()}`,
source: sourceNodeId,
target: targetNodeId,
sourceHandle: asString(event?.sourceHandle).trim() || undefined,
targetHandle: asString(event?.targetHandle).trim() || undefined,
} as Edge);
}
reconcileBindingsForNodes(
collectAffectedNodeIds([targetNodeId], projectedEdges),
{
edges: projectedEdges
}
);
};
function applyAutoBindingsForNode(nodeId: string) {
reconcileBindingsForNodes([nodeId]);
}
const { copyHandler, pasteHandler } = useCopyPasteHandler();
const handleKeyDown = (e: KeyboardEvent) => {

View File

@@ -76,10 +76,11 @@
<div class="input-item">
<Input style="width: 100%;" value={param.name} placeholder="请输入参数名称"
disabled={param.nameDisabled === true}
oninput={updateName} />
</div>
<div class="input-item">
<Checkbox checked={param.required} onchange={updateRequired} />
<Checkbox checked={param.required} disabled={param.requiredDisabled === true} onchange={updateRequired} />
</div>
<div class="input-item">
<FloatingTrigger placement="bottom" bind:this={triggerObject}>
@@ -91,10 +92,16 @@
</Button>
{#snippet floating()}
<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}
onSelect={updateContentType}
/>
</div>
@@ -102,6 +109,7 @@
输入方式:
<Select items={startFormTypes} style="width: 100%" defaultValue={["input"]}
value={param.formType ? [param.formType] : []}
disabled={param.systemReserved === true}
onSelect={updateFormType}
/>
</div>
@@ -110,8 +118,8 @@
<div class="input-more-item">
数据选项:
<Textarea rows={3} style="width: 100%;" onchange={(event)=>{
updateParameter('enums', event.target?.value.trim().split("\n"))
}} value={param.enums?.join("\n")} placeholder="一行一个选项" />
updateParameter('enums', ((event.target as HTMLTextAreaElement)?.value || '').trim().split("\n"))
}} disabled={param.systemReserved === true} value={param.enums?.join("\n")} placeholder="一行一个选项" />
</div>
{/if}
@@ -119,25 +127,25 @@
数据标题:
<Textarea rows={1} style="width: 100%;" onchange={(event)=>{
updateParamByEvent('formLabel', event)
}} value={param.formLabel} />
}} disabled={param.systemReserved === true} value={param.formLabel} />
</div>
<div class="input-more-item">
数据描述:
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
updateParamByEvent('formDescription', event)
}} value={param.formDescription} />
}} disabled={param.systemReserved === true} value={param.formDescription} />
</div>
<div class="input-more-item">
占位符:
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
updateParamByEvent('formPlaceholder', event)
}} value={param.formPlaceholder} />
}} disabled={param.systemReserved === true} value={param.formPlaceholder} />
</div>
<div class="input-more-item">
<div class="input-more-item" style:display={param.deleteDisabled === true ? 'none' : 'flex'}>
<Button onclick={handleDelete}>删除</Button>
</div>
</div>
@@ -180,5 +188,3 @@
}
</style>

View File

@@ -3,11 +3,31 @@
import {useNodesData} from '@xyflow/svelte';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
const {
parameters: manualParameters = undefined,
emptyText = '无输入参数'
}: {
parameters?: Array<any>,
emptyText?: string
} = $props();
let currentNodeId = getCurrentNodeId();
let node = useNodesData(currentNodeId);
let parameters = $derived.by(() => {
let currentParameters = $derived.by(() => {
return [...node?.current?.data?.parameters as Array<any> || []];
});
let parameters = $derived.by(() => {
if (Array.isArray(manualParameters)) {
return [...manualParameters];
}
return currentParameters;
});
let parameterItems = $derived.by(() => {
return parameters.map((param) => ({
parameter: param,
index: currentParameters.findIndex((item) => item?.id === param?.id)
})).filter((item) => item.index >= 0);
});
</script>
@@ -17,10 +37,10 @@
<div class="input-header">必填</div>
<div class="input-header"></div>
{/if}
{#each parameters as param, index (param.id)}
<DefinedParameterItem parameter={param} index={index} />
{#each parameterItems as item (item.parameter.id)}
<DefinedParameterItem parameter={item.parameter} index={item.index} />
{:else }
<div class="none-params">无输入参数</div>
<div class="none-params">{emptyText}</div>
{/each}
</div>
@@ -52,5 +72,3 @@
</style>

View File

@@ -28,6 +28,7 @@
showSourceHandle = true,
showTargetHandle = true,
titleHelp = '',
wrapperClass = '',
onCollapse
}: {
data: NodeProps['data'],
@@ -43,6 +44,7 @@
showSourceHandle?: boolean,
showTargetHandle?: boolean,
titleHelp?: string,
wrapperClass?: string,
onCollapse?: (key: string) => void,
} = $props();
@@ -248,7 +250,7 @@
</NodeToolbar>
{/if}
<div class="tf-node-wrapper">
<div class="tf-node-wrapper {wrapperClass}">
<div class="tf-node-wrapper-body">
<Collapse {items} activeKeys={activeKeys} onChange={(_,actionKeys) => {
updateNodeData(id, {expand: actionKeys?.includes('key')})

View File

@@ -54,7 +54,7 @@
column-gap: 4px;
align-items: center;
width: 100%;
min-width: 318px;
min-width: 0;
box-sizing: border-box;
.none-params {
@@ -78,4 +78,3 @@
}
</style>

View File

@@ -0,0 +1,173 @@
import type { EditorView } from '@codemirror/view';
import { flushSync, mount, unmount } from 'svelte';
import { afterEach, describe, expect, it, vi } from 'vitest';
import ParamTokenEditor from './ParamTokenEditor.svelte';
type RenderResult = {
host: HTMLDivElement;
view: EditorView;
destroy: () => Promise<void>;
};
const PARAMS = [
{
name: 'start.user_input',
displayName: '开始节点 > 用户问题',
refType: 'input',
},
];
async function renderEditor(props: Record<string, unknown> = {}): Promise<RenderResult> {
const host = document.createElement('div');
document.body.appendChild(host);
const app = mount(ParamTokenEditor, {
target: host,
props: {
mode: 'textarea',
value: '',
parameters: PARAMS,
...props,
},
});
flushSync();
const shell = host.querySelector('.param-token-editor-shell') as HTMLDivElement & {
__paramTokenEditorView?: EditorView;
};
if (!shell?.__paramTokenEditorView) {
throw new Error('ParamTokenEditor view not found');
}
return {
host,
view: shell.__paramTokenEditorView,
destroy: async () => {
await unmount(app);
host.remove();
},
};
}
async function pressKey(view: EditorView, key: string) {
view.focus();
view.contentDOM.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
flushSync();
}
afterEach(() => {
document.body.innerHTML = '';
});
describe('ParamTokenEditor', () => {
it('should render chip text and parameter panel with displayName only', async () => {
const { host, destroy } = await renderEditor({
value: '前缀 {{start.user_input}}',
});
expect(host.querySelector('.param-token-chip-text')?.textContent).toBe('开始节点 > 用户问题');
expect(host.querySelector('.param-token-item-label')?.textContent).toBe('开始节点 > 用户问题');
expect(host.textContent).not.toContain('start.user_input');
await destroy();
});
it('should insert raw token text and move cursor after token', async () => {
const oninput = vi.fn();
const onchange = vi.fn();
const { host, view, destroy } = await renderEditor({
oninput,
onchange,
});
(host.querySelector('.param-token-item') as HTMLButtonElement).click();
flushSync();
expect(view.state.doc.toString()).toBe('{{start.user_input}}');
expect(view.state.selection.main.head).toBe('{{start.user_input}}'.length);
expect(oninput).toHaveBeenCalled();
expect(onchange).toHaveBeenCalledWith({
target: {
value: '{{start.user_input}}',
},
});
await destroy();
});
it('should remove the whole token on backspace and delete at token boundaries', async () => {
const token = '{{start.user_input}}';
const { view, destroy } = await renderEditor({
value: `A ${token} B`,
});
view.dispatch({
selection: {
anchor: `A ${token}`.length,
},
});
await pressKey(view, 'Backspace');
expect(view.state.doc.toString()).toBe('A B');
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: `A ${token} B`,
},
selection: {
anchor: 2,
},
});
flushSync();
await pressKey(view, 'Delete');
expect(view.state.doc.toString()).toBe('A B');
await destroy();
});
it('should block newline insertion in single-line mode', async () => {
const { view, destroy } = await renderEditor({
mode: 'input',
value: 'abc',
});
await pressKey(view, 'Enter');
expect(view.state.doc.toString()).toBe('abc');
await destroy();
});
it('should render invalid token as disconnected parameter label without undefined hint', async () => {
const { host, destroy } = await renderEditor({
value: '{{missing.ref}}',
});
expect(host.querySelector('.param-token-chip-text')?.textContent).toBe('ref已断开连接');
expect(host.textContent).not.toContain('未定义参数');
await destroy();
});
it('should prefer stored display name for disconnected references', async () => {
const { host, destroy } = await renderEditor({
value: '{{start.user_input}}',
parameters: [
{
name: 'start.user_input',
displayName: '开始节点 > 用户问题',
resolved: false,
disconnected: true,
},
],
});
expect(host.querySelector('.param-token-chip-text')?.textContent).toBe(
'开始节点 > 用户问题(已断开连接)',
);
expect(host.querySelector('.param-token-invalid .param-token-chip-text')).not.toBeNull();
await destroy();
});
});

View File

@@ -46,7 +46,7 @@
column-gap: 4px;
align-items: center;
width: 100%;
min-width: 318px;
min-width: 0;
box-sizing: border-box;
.none-params {

View File

@@ -1,15 +1,20 @@
<script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte';
import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
import {Button, Heading, Select} from '../base';
import RefParameterList from '../core/RefParameterList.svelte';
import {type NodeProps, useNodesData, useStore, useSvelteFlow} from '@xyflow/svelte';
import {Heading, Select} from '../base';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {useAddParameter} from '../utils/useAddParameter.svelte';
import {getOptions} from '../utils/NodeUtils';
import {onMount} from 'svelte';
import OutputDefList from '../core/OutputDefList.svelte';
import type {SelectItem} from '#types';
import type {Parameter, SelectItem} from '#types';
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
import {
buildEditorReferenceParameters,
FIELD_BINDING_META_KEY,
syncManagedParametersForFields,
updateFieldBindingMeta,
} from '../../utils/workflowNodeFields';
const { data, ...rest }: {
data: NodeProps['data'],
@@ -19,8 +24,14 @@
const currentNodeId = getCurrentNodeId();
let currentNode = useNodesData(currentNodeId);
const { addParameter } = useAddParameter();
const { nodes, edges } = $derived(useStore());
const editorParameters = $derived.by(() => {
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
return buildEditorReferenceParameters(
currentNodeId,
nodes || [],
edges || [],
(((currentNode?.current?.data?.parameters as Array<Parameter>) || data.parameters || []) as Array<Parameter>)
);
});
const options = getOptions();
@@ -37,6 +48,22 @@
});
const { updateNodeData } = useSvelteFlow();
const syncFieldValue = (fieldName: 'keyword' | 'limit', nextValue: string) => {
const currentData = ((currentNode?.current?.data as Record<string, any>) || data || {}) as Record<string, any>;
const nextFieldValues = {
keyword: fieldName === 'keyword' ? nextValue : (currentData.keyword || ''),
limit: fieldName === 'limit' ? nextValue : (currentData.limit || ''),
};
updateNodeData(currentNodeId, {
[fieldName]: nextValue,
parameters: syncManagedParametersForFields(
(currentData.parameters as Array<Parameter>) || [],
editorParameters,
nextFieldValues
),
[FIELD_BINDING_META_KEY]: updateFieldBindingMeta(currentData, fieldName, nextValue),
});
};
$effect(() => {
if (!data.outputDefs || data.outputDefs.length === 0) {
@@ -104,19 +131,6 @@
d="M15.5 5C13.567 5 12 6.567 12 8.5C12 10.433 13.567 12 15.5 12C17.433 12 19 10.433 19 8.5C19 6.567 17.433 5 15.5 5ZM10 8.5C10 5.46243 12.4624 3 15.5 3C18.5376 3 21 5.46243 21 8.5C21 9.6575 20.6424 10.7315 20.0317 11.6175L22.7071 14.2929L21.2929 15.7071L18.6175 13.0317C17.7315 13.6424 16.6575 14 15.5 14C12.4624 14 10 11.5376 10 8.5ZM3 4H8V6H3V4ZM3 11H8V13H3V11ZM21 18V20H3V18H21Z"></path>
</svg>
{/snippet}
<div class="heading">
<Heading level={3}>输入参数</Heading>
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
addParameter(currentNodeId)
}}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
</svg>
</Button>
</div>
<RefParameterList />
<Heading level={3} mt="10px">知识库设置</Heading>
<div class="setting-title">知识库</div>
<div class="setting-item">
@@ -137,14 +151,9 @@
placeholder="请输入关键字"
style="width: 100%"
parameters={editorParameters}
value={data.keyword || ''}
oninput={(e)=>{
const newValue = e.target.value;
updateNodeData(currentNodeId, ()=>{
return {
keyword: newValue
}
})
value={String(data.keyword || '')}
oninput={(e: any)=>{
syncFieldValue('keyword', e.target.value)
}}
/>
</div>
@@ -169,15 +178,10 @@
placeholder="搜索的数据条数"
style="width: 100%"
parameters={editorParameters}
oninput={(e)=>{
const newValue = e.target.value;
updateNodeData(currentNodeId, ()=>{
return {
limit: newValue
}
})
oninput={(e: any)=>{
syncFieldValue('limit', e.target.value)
}}
value={data.limit || ''}
value={String(data.limit || '')}
/>
</div>

View File

@@ -9,8 +9,14 @@
import {getOptions} from '../utils/NodeUtils';
import {onMount} from 'svelte';
import OutputDefList from '../core/OutputDefList.svelte';
import type {SelectItem} from '#types';
import type {Parameter, SelectItem} from '#types';
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
import {
buildEditorReferenceParameters,
FIELD_BINDING_META_KEY,
syncManagedParametersForFields,
updateFieldBindingMeta,
} from '../../utils/workflowNodeFields';
const { data, ...rest }: {
data: NodeProps['data'],
@@ -20,11 +26,14 @@
const currentNodeId = getCurrentNodeId();
let currentNode = useNodesData(currentNodeId);
const { addParameter } = useAddParameter();
const { nodes } = $derived(useStore());
const { nodes, edges } = $derived(useStore());
const editorParameters = $derived.by(() => {
const parameters = [
...(((currentNode?.current?.data?.parameters as Array<any>) || data.parameters || []) as Array<any>)
];
const parameters = buildEditorReferenceParameters(
currentNodeId,
nodes || [],
edges || [],
(((currentNode?.current?.data?.parameters as Array<Parameter>) || data.parameters || []) as Array<Parameter>)
);
if (queryContextNodeIds.length > 0) {
parameters.push({
id: 'queryDataContext',
@@ -109,6 +118,23 @@
});
const { updateNodeData } = useSvelteFlow();
const syncFieldValue = (fieldName: 'systemPrompt' | 'userPrompt', nextValue: string) => {
const currentData = ((currentNode?.current?.data as Record<string, any>) || data || {}) as Record<string, any>;
const nextFieldValues = {
systemPrompt: fieldName === 'systemPrompt' ? nextValue : (currentData.systemPrompt || ''),
userPrompt: fieldName === 'userPrompt' ? nextValue : (currentData.userPrompt || ''),
};
updateNodeData(currentNodeId, {
[fieldName]: nextValue,
parameters: syncManagedParametersForFields(
(currentData.parameters as Array<Parameter>) || [],
editorParameters,
nextFieldValues
),
[FIELD_BINDING_META_KEY]: updateFieldBindingMeta(currentData, fieldName, nextValue),
});
};
const setOutType = (value: string) => {
updateNodeData(currentNodeId, () => {
return {
@@ -175,7 +201,7 @@
</script>
<NodeWrapper {data} {...rest}>
<NodeWrapper {data} {...rest} wrapperClass="tf-node-wrapper--llm">
{#snippet icon()}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
@@ -185,18 +211,6 @@
{/snippet}
<div class="heading">
<Heading level={3}>输入参数</Heading>
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
addParameter(currentNodeId)
}}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
</svg>
</Button>
</div>
<RefParameterList />
<div class="heading" style="padding-top: 10px">
<Heading level={3}>图片识别</Heading>
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
addParameter(currentNodeId, "images")
@@ -250,7 +264,7 @@
max="1"
step="0.1"
value={data.temperature ?? 0.7}
oninput={(e) => updateNodeData(currentNodeId, { temperature: parseFloat(e.target.value) })}
oninput={(e) => updateNodeData(currentNodeId, { temperature: parseFloat((e.target as HTMLInputElement).value) })}
/>
</div>
</div>
@@ -265,7 +279,7 @@
max="1"
step="0.1"
value={data.topP ?? 0.9}
oninput={(e) => updateNodeData(currentNodeId, { topP: parseFloat(e.target.value) })}
oninput={(e) => updateNodeData(currentNodeId, { topP: parseFloat((e.target as HTMLInputElement).value) })}
/>
</div>
</div>
@@ -280,7 +294,7 @@
max="100"
step="1"
value={data.topK ?? 50}
oninput={(e) => updateNodeData(currentNodeId, { topK: parseInt(e.target.value) })}
oninput={(e) => updateNodeData(currentNodeId, { topK: parseInt((e.target as HTMLInputElement).value) })}
/>
</div>
</div>
@@ -298,11 +312,9 @@
placeholder="请输入系统提示词"
style="width: 100%"
parameters={editorParameters}
value={data.systemPrompt || ''}
oninput={(e)=>{
updateNodeData(currentNodeId, {
systemPrompt: e.target.value
});
value={String(data.systemPrompt || '')}
oninput={(e: any)=>{
syncFieldValue('systemPrompt', e.target.value);
}}
/>
</div>
@@ -315,11 +327,9 @@
placeholder="请输入用户提示词"
style="width: 100%"
parameters={editorParameters}
value={data.userPrompt || ''}
oninput={(e)=>{
updateNodeData(currentNodeId, {
userPrompt: e.target.value
});
value={String(data.userPrompt || '')}
oninput={(e: any)=>{
syncFieldValue('userPrompt', e.target.value);
}}
/>
</div>
@@ -333,7 +343,7 @@
label: 'JSON',
value: 'json'
}]} style="width: 100px;margin-left: auto" onSelect={(item)=>{
setOutType(item.value);
setOutType(String(item.value));
}} value={data.outType ? [data.outType] : []} />
</div>
<OutputDefList />

View File

@@ -6,6 +6,12 @@
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,
isSystemStartParameter,
} from '../../utils/workflowNodeFields';
const { data, ...rest }: {
data: NodeProps['data'],
@@ -14,6 +20,30 @@
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
});
}
});
let currentParameters = $derived.by(() => {
return ((data.parameters as Array<any>) || []);
});
let systemParameters = $derived.by(() => {
return currentParameters.filter((parameter) => isSystemStartParameter(parameter));
});
let customParameters = $derived.by(() => {
return currentParameters.filter((parameter) => !isSystemStartParameter(parameter));
});
</script>
@@ -25,26 +55,51 @@
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="heading">
<Heading level={3}>输入参数</Heading>
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
addParameter(currentNodeId, "parameters", {refType: "input", name: "newParam"});
}}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
</svg>
</Button>
<div class="param-section">
<div class="heading">
<Heading level={3}>系统入口</Heading>
</div>
<div class="section-description">固定入口参数,作为工作流默认输入来源。</div>
<DefinedParameterList parameters={systemParameters} emptyText="暂无系统入口参数" />
</div>
<div class="param-section">
<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"});
}}>
<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>
<DefinedParameterList parameters={customParameters} emptyText="暂无自定义参数" />
</div>
<DefinedParameterList />
</NodeWrapper>
<style lang="less">
.heading {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.param-section + .param-section {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--tf-border-color);
}
.section-description {
margin-bottom: 10px;
font-size: 12px;
color: var(--tf-text-muted);
line-height: 1.5;
}
:global(.input-btn-more) {
border: 1px solid transparent;
padding: 3px;
@@ -55,4 +110,3 @@
}
}
</style>

View File

@@ -10,7 +10,7 @@ export const genUuid = () => {
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c: any) =>
(
c ^
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
((crypto.getRandomValues(new Uint8Array(1))[0] ?? 0) & (15 >> (c / 4)))
).toString(16),
);
};

View File

@@ -63,14 +63,20 @@ describe('paramToken utils', () => {
expect(result).toEqual([
{
dataType: undefined,
displayName: 'input',
name: 'input',
resolved: false,
},
{
dataType: undefined,
displayName: 'docs',
name: 'docs',
resolved: true,
},
{
dataType: undefined,
displayName: 'runtimeInput',
name: 'runtimeInput',
resolved: true,
},

View File

@@ -2,6 +2,11 @@ export interface ParameterLike {
name?: string;
ref?: string;
refType?: string;
resolved?: boolean;
disconnected?: boolean;
displayName?: string;
formLabel?: string;
dataType?: string;
children?: ParameterLike[];
}
@@ -27,6 +32,9 @@ export type TokenPart =
export interface ParameterCandidate {
name: string;
resolved: boolean;
disconnected?: boolean;
displayName?: string;
dataType?: string;
}
const TOKEN_PATTERN = /\{\{\s*([^{}]+?)\s*}}/g;
@@ -46,6 +54,10 @@ function isParameterResolved(parameter?: ParameterLike): boolean {
return false;
}
if (typeof parameter.resolved === 'boolean') {
return parameter.resolved;
}
const refType = (parameter.refType || '').trim();
if (refType === 'fixed' || refType === 'input') {
return true;
@@ -65,7 +77,13 @@ export function flattenParameterCandidates(
const candidates: ParameterCandidate[] = [];
const indexMap = new Map<string, number>();
const addCandidate = (name: string, resolved: boolean) => {
const addCandidate = (
name: string,
resolved: boolean,
disconnected: boolean,
displayName?: string,
dataType?: string,
) => {
const normalized = name.trim();
if (!normalized) {
return;
@@ -76,13 +94,26 @@ export function flattenParameterCandidates(
candidates.push({
name: normalized,
resolved,
disconnected,
displayName: displayName?.trim() || normalized,
dataType,
});
return;
}
// 同名参数只要有一个可解析,就视为可解析
const existingCandidate = candidates[exists]!;
if (resolved) {
candidates[exists].resolved = true;
existingCandidate.resolved = true;
existingCandidate.disconnected = false;
} else if (disconnected && !existingCandidate.resolved) {
existingCandidate.disconnected = true;
}
if (!existingCandidate.displayName && displayName?.trim()) {
existingCandidate.displayName = displayName.trim();
}
if (!existingCandidate.dataType && dataType) {
existingCandidate.dataType = dataType;
}
};
@@ -99,7 +130,18 @@ export function flattenParameterCandidates(
const currentPath = parentPath ? `${parentPath}.${rawName}` : rawName;
const currentResolved = inheritedResolved && isParameterResolved(item);
addCandidate(currentPath, currentResolved);
const currentDisconnected = item?.disconnected === true;
const displayName =
item?.displayName?.trim() ||
item?.formLabel?.trim() ||
currentPath;
addCandidate(
currentPath,
currentResolved,
currentDisconnected,
displayName,
item?.dataType,
);
if (item.children && item.children.length > 0) {
walk(item.children, currentPath, currentResolved);

View File

@@ -27,8 +27,9 @@ const getChildren = (
const dataType = nodeIsChildren
? `Array<${param.dataType || 'String'}>`
: param.dataType || 'String';
const label = param.formLabel || param.displayName || param.name;
return {
label: param.name,
label,
dataType: dataType,
value: parentId + '.' + param.name,
selectable: true,
@@ -71,8 +72,10 @@ const nodeToOptions = (
const dataType = nodeIsChildren
? `Array<${parameter.dataType || 'String'}>`
: parameter.dataType || 'String';
const label =
parameter.formLabel || parameter.displayName || parameter.name;
children.push({
label: parameter.name,
label,
dataType: dataType,
value: node.id + '.' + parameter.name,
selectable: true,