From 8cfe5400fe29ae0ce1b2132d9531f2ef0dba0372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Sun, 12 Apr 2026 20:31:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E5=AD=97=E6=AE=B5=E5=8C=96=E5=8F=82=E6=95=B0=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 开始节点固定 user_input 并区分系统入口与自定义参数 - LLM 与知识库节点切换为字段值加上游引用配置 - 单节点调试改为字段预览与上游引用输入模式 --- .../src/views/ai/workflow/WorkflowDesign.vue | 17 +- .../ai/workflow/components/SingleRun.vue | 188 ++- .../src/components/TinyflowCore.svelte | 98 +- .../core/DefinedParameterItem.svelte | 24 +- .../core/DefinedParameterList.svelte | 30 +- .../src/components/core/NodeWrapper.svelte | 4 +- .../src/components/core/OutputDefList.svelte | 3 +- .../components/core/ParamTokenEditor.svelte | 1400 +++++++++-------- .../components/core/ParamTokenEditor.test.ts | 173 ++ .../components/core/RefParameterList.svelte | 2 +- .../src/components/nodes/KnowledgeNode.svelte | 72 +- .../src/components/nodes/LLMNode.svelte | 74 +- .../src/components/nodes/StartNode.svelte | 76 +- .../tinyflow-ui/src/components/utils/IdGen.ts | 2 +- .../src/components/utils/paramToken.test.ts | 6 + .../src/components/utils/paramToken.ts | 48 +- .../components/utils/useRefOptions.svelte.ts | 7 +- .../packages/tinyflow-ui/src/index.ts | 1 + .../tinyflow-ui/src/styles/tinyflow.less | 6 + .../packages/tinyflow-ui/src/types.ts | 4 + .../src/utils/workflowNodeFields.test.ts | 471 ++++++ .../src/utils/workflowNodeFields.ts | 858 ++++++++++ .../packages/tinyflow-ui/vitest.config.ts | 12 + easyflow-ui-admin/packages/types/src/bot.ts | 1 + 24 files changed, 2785 insertions(+), 792 deletions(-) create mode 100644 easyflow-ui-admin/packages/tinyflow-ui/src/components/core/ParamTokenEditor.test.ts create mode 100644 easyflow-ui-admin/packages/tinyflow-ui/src/utils/workflowNodeFields.test.ts create mode 100644 easyflow-ui-admin/packages/tinyflow-ui/src/utils/workflowNodeFields.ts create mode 100644 easyflow-ui-admin/packages/tinyflow-ui/vitest.config.ts diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue index c00893f..5387ebe 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue +++ b/easyflow-ui-admin/app/src/views/ai/workflow/WorkflowDesign.vue @@ -4,6 +4,7 @@ import { useRoute } from 'vue-router'; import { usePreferences } from '@easyflow/preferences'; import { getOptions, sortNodes } from '@easyflow/utils'; +import { Tinyflow } from '@tinyflow-ai/vue'; import { ArrowLeft, @@ -11,7 +12,6 @@ import { Close, Promotion, } from '@element-plus/icons-vue'; -import { Tinyflow } from '@tinyflow-ai/vue'; import { ElButton, ElDrawer, @@ -37,6 +37,11 @@ import WorkflowSteps from '#/views/ai/workflow/components/WorkflowSteps.vue'; import { getCustomNode } from './customNode/index'; import nodeNames from './customNode/nodeNames'; +import { + createInitialWorkflowData, + isWorkflowDataEmpty, + normalizeWorkflowStartNodes, +} from '../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields'; import '@tinyflow-ai/vue/dist/index.css'; @@ -348,9 +353,10 @@ async function handleSave(showMsg: boolean = false): Promise { } saveLoading.value = true; try { + const content = normalizeWorkflowStartNodes(tinyflowRef.value?.getData()); const res = await api.post('/api/v1/workflow/update', { id: workflowId.value, - content: tinyflowRef.value?.getData(), + content, }); if (res.errorCode === 0 && showMsg) { ElMessage.success(res.message); @@ -365,9 +371,12 @@ async function handleSave(showMsg: boolean = false): Promise { async function getWorkflowInfo(workflowId: any) { return api.get(`/api/v1/workflow/detail?id=${workflowId}`).then((res) => { workflowInfo.value = res.data; - tinyFlowData.value = workflowInfo.value.content + const parsedContent = workflowInfo.value.content ? JSON.parse(workflowInfo.value.content) : {}; + tinyFlowData.value = isWorkflowDataEmpty(parsedContent) + ? createInitialWorkflowData() + : parsedContent; syncNavTitle(workflowInfo.value?.title || ''); }); } @@ -406,7 +415,7 @@ async function runCheck( stage: WorkflowCheckStage, silentPass: boolean = false, ) { - const content = tinyflowRef.value?.getData(); + const content = normalizeWorkflowStartNodes(tinyflowRef.value?.getData()); if (!content) { ElMessage.error($t('aiWorkflow.checkContentEmpty')); return false; diff --git a/easyflow-ui-admin/app/src/views/ai/workflow/components/SingleRun.vue b/easyflow-ui-admin/app/src/views/ai/workflow/components/SingleRun.vue index 34837ad..43cb358 100644 --- a/easyflow-ui-admin/app/src/views/ai/workflow/components/SingleRun.vue +++ b/easyflow-ui-admin/app/src/views/ai/workflow/components/SingleRun.vue @@ -1,15 +1,16 @@ @@ -17,10 +37,10 @@
必填
{/if} - {#each parameters as param, index (param.id)} - + {#each parameterItems as item (item.parameter.id)} + {:else } -
无输入参数
+
{emptyText}
{/each} @@ -52,5 +72,3 @@ - - diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/NodeWrapper.svelte b/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/NodeWrapper.svelte index 8d518a8..7c12517 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/NodeWrapper.svelte +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/NodeWrapper.svelte @@ -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 @@ {/if} -
+
{ updateNodeData(id, {expand: actionKeys?.includes('key')}) diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/OutputDefList.svelte b/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/OutputDefList.svelte index 4e3c927..276d993 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/OutputDefList.svelte +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/OutputDefList.svelte @@ -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 @@ } - diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/ParamTokenEditor.svelte b/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/ParamTokenEditor.svelte index 4b1da99..48f003a 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/ParamTokenEditor.svelte +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/ParamTokenEditor.svelte @@ -1,552 +1,689 @@ -
- {#if showInlineHint} -
- {#if unresolvedTokensInEditor.length > 0} -
- - - 未映射参数:{unresolvedTokensInEditor.join('、')} - -
- {/if} - {#if undefinedTokensInEditor.length > 0} -
- - - 未定义参数:{undefinedTokensInEditor.join('、')} - -
- {/if} -
- {/if} +
+
+
-
- - - {#if mode === 'input'} - - {:else} - - {/if} - -
- - + {#snippet floating()} +
+ {#if hasParams} + {#each pickerCandidates as candidate} + - {#snippet floating()} -
- {#if hasParams} - {#each paramCandidates as candidate} - - {/each} - {:else} -
请先在输入参数中定义参数
- {/if} -
- {/snippet} - -
+ + {candidate.displayName || candidate.name} + + {#if !candidate.resolved} + + + + + + {/if} + + {/each} + {:else} +
当前没有可引用的上游变量
+ {/if} +
+ {/snippet} +
+
diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/ParamTokenEditor.test.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/ParamTokenEditor.test.ts new file mode 100644 index 0000000..62a0218 --- /dev/null +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/ParamTokenEditor.test.ts @@ -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; +}; + +const PARAMS = [ + { + name: 'start.user_input', + displayName: '开始节点 > 用户问题', + refType: 'input', + }, +]; + +async function renderEditor(props: Record = {}): Promise { + 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(); + }); +}); diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/RefParameterList.svelte b/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/RefParameterList.svelte index db21204..4cb9ac0 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/RefParameterList.svelte +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/core/RefParameterList.svelte @@ -46,7 +46,7 @@ column-gap: 4px; align-items: center; width: 100%; - min-width: 318px; + min-width: 0; box-sizing: border-box; .none-params { diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/KnowledgeNode.svelte b/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/KnowledgeNode.svelte index 5918345..1b6f0b0 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/KnowledgeNode.svelte +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/KnowledgeNode.svelte @@ -1,15 +1,20 @@ - + {#snippet icon()} @@ -185,18 +211,6 @@ {/snippet}
- 输入参数 - -
- - -
图片识别
@@ -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) })} />
@@ -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) })} />
@@ -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); }} /> @@ -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); }} /> @@ -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] : []} /> diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/StartNode.svelte b/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/StartNode.svelte index 9cbf43f..0fc785d 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/StartNode.svelte +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/StartNode.svelte @@ -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) || []; + 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) || []); + }); + let systemParameters = $derived.by(() => { + return currentParameters.filter((parameter) => isSystemStartParameter(parameter)); + }); + let customParameters = $derived.by(() => { + return currentParameters.filter((parameter) => !isSystemStartParameter(parameter)); + }); @@ -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"> {/snippet} -
- 输入参数 - +
+
+ 系统入口 +
+
固定入口参数,作为工作流默认输入来源。
+ +
+ +
+
+ 自定义参数 + +
+
这里添加额外输入参数,不影响默认入口参数。
+
- - diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/IdGen.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/IdGen.ts index 0ff8cdc..b3cedc6 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/IdGen.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/IdGen.ts @@ -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), ); }; diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/paramToken.test.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/paramToken.test.ts index 52db6c9..e42a8bf 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/paramToken.test.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/paramToken.test.ts @@ -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, }, diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/paramToken.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/paramToken.ts index e05e8dd..fcda607 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/paramToken.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/paramToken.ts @@ -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(); - 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); diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/useRefOptions.svelte.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/useRefOptions.svelte.ts index 924caf2..b26da2e 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/useRefOptions.svelte.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/useRefOptions.svelte.ts @@ -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, diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/index.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/index.ts index 33ee682..0e7cd9e 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/index.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/index.ts @@ -1,4 +1,5 @@ export * from './types'; export * from './Tinyflow'; export * from './components/TinyflowComponent.svelte'; +export * from './utils/workflowNodeFields'; export * from './utils/sanitize'; diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/styles/tinyflow.less b/easyflow-ui-admin/packages/tinyflow-ui/src/styles/tinyflow.less index 055cc6a..dbe70ad 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/styles/tinyflow.less +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/styles/tinyflow.less @@ -1,6 +1,7 @@ .svelte-flow__nodes { .svelte-flow__node { box-sizing: border-box; + cursor: default !important; border: 3px solid transparent; border-radius: 5px; @@ -114,6 +115,11 @@ &-body { padding: 10px; } + + &--llm { + min-width: 296px; + max-width: 296px; + } } .svelte-flow__attribution a { diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/types.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/types.ts index 906afb7..997571e 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/types.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/types.ts @@ -107,6 +107,7 @@ export type Parameter = { id?: string; name?: string; nameDisabled?: boolean; + displayName?: string; dataType?: string; dataTypeItems?: SelectItem[]; dataTypeDisabled?: boolean; @@ -126,4 +127,7 @@ export type Parameter = { formDescription?: string; formPlaceholder?: string; formAttrs?: string; + requiredDisabled?: boolean; + systemReserved?: boolean; + autoManaged?: boolean; }; diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/utils/workflowNodeFields.test.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/utils/workflowNodeFields.test.ts new file mode 100644 index 0000000..6a6c3da --- /dev/null +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/utils/workflowNodeFields.test.ts @@ -0,0 +1,471 @@ +import { describe, expect, it } from 'vitest'; + +import type { Edge, Node } from '@xyflow/svelte'; + +import { + buildAutoBindingPatch, + buildSequentialFieldBindingPatches, + buildFieldBindingPatch, + buildEditorReferenceParameters, + buildSingleRunModel, + buildSingleRunParameters, + createInitialWorkflowData, + ensureStartNodeParameters, + FIELD_BINDING_META_KEY, + normalizeWorkflowStartNodes, +} from './workflowNodeFields'; + +describe('workflow node fields', () => { + it('creates initial workflow data with fixed start input', () => { + const initial = createInitialWorkflowData(); + expect(initial.nodes).toHaveLength(1); + expect(initial.nodes[0]?.type).toBe('startNode'); + + const parameters = ensureStartNodeParameters( + (initial.nodes[0]?.data?.parameters || []) as any[], + ); + expect(parameters).toHaveLength(1); + expect(parameters[0]?.name).toBe('user_input'); + expect(parameters[0]?.systemReserved).toBe(true); + expect(parameters[0]?.required).toBe(true); + }); + + it('builds upstream reference candidates from start node', () => { + const startNode: Node = { + id: 'start_1', + type: 'startNode', + position: { x: 0, y: 0 }, + data: { + title: '流程开始', + parameters: ensureStartNodeParameters(), + }, + }; + 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, + [], + ); + + expect(parameters.some((item) => item.name === 'start_1.user_input')).toBe( + true, + ); + expect( + parameters.find((item) => item.name === 'start_1.user_input') + ?.displayName, + ).toBe('流程开始 > 用户问题'); + }); + + it('applies default binding to llm user prompt after connect', () => { + const startNode: Node = { + id: 'start_1', + type: 'startNode', + position: { x: 0, y: 0 }, + data: { + title: '流程开始', + parameters: ensureStartNodeParameters(), + }, + }; + const llmNode: Node = { + id: 'llm_1', + type: 'llmNode', + position: { x: 120, y: 0 }, + data: { + title: '大模型', + parameters: [], + userPrompt: '', + }, + }; + const edges: Edge[] = [ + { id: 'edge_1', source: 'start_1', target: 'llm_1' } as Edge, + ]; + + const patch = buildAutoBindingPatch(llmNode, [startNode, llmNode], edges); + expect(patch?.userPrompt).toBe('{{start_1.user_input}}'); + expect((patch?.parameters as any[])?.[0]?.name).toBe('start_1.user_input'); + expect((patch?.[FIELD_BINDING_META_KEY] as any)?.userPrompt?.userModified) + .toBe(false); + }); + + it('does not auto-bind from legacy start nodes without user_input', () => { + const legacyStartNode: Node = { + id: 'start_legacy', + type: 'startNode', + position: { x: 0, y: 0 }, + data: { + title: '开始节点', + parameters: [], + }, + }; + const llmNode: Node = { + id: 'llm_1', + type: 'llmNode', + position: { x: 120, y: 0 }, + data: { + title: '大模型', + parameters: [], + userPrompt: '', + }, + }; + const edges: Edge[] = [ + { id: 'edge_1', source: 'start_legacy', target: 'llm_1' } as Edge, + ]; + + expect( + buildAutoBindingPatch(llmNode, [legacyStartNode, llmNode], edges), + ).toBeNull(); + }); + + it('clears auto-filled start bindings after disconnect', () => { + const startNode: Node = { + id: 'start_1', + type: 'startNode', + position: { x: 0, y: 0 }, + data: { + title: '流程开始', + parameters: ensureStartNodeParameters(), + }, + }; + const llmNode: Node = { + id: 'llm_1', + type: 'llmNode', + position: { x: 120, y: 0 }, + data: { + title: '大模型', + userPrompt: '{{start_1.user_input}}', + parameters: [ + { + name: 'start_1.user_input', + ref: 'start_1.user_input', + refType: 'ref', + autoManaged: true, + }, + ], + [FIELD_BINDING_META_KEY]: { + userPrompt: { + autoFilledFrom: 'start_1.user_input', + userModified: false, + }, + }, + }, + }; + + const patch = buildFieldBindingPatch(llmNode, [startNode, llmNode], []); + expect(patch?.userPrompt).toBe(''); + expect(patch?.parameters).toEqual([]); + expect(patch?.[FIELD_BINDING_META_KEY]).toEqual({}); + }); + + it('removes managed param for disconnected manual upstream refs so token becomes invalid', () => { + const startNode: Node = { + id: 'start_1', + type: 'startNode', + position: { x: 0, y: 0 }, + data: { + title: '流程开始', + parameters: ensureStartNodeParameters(), + }, + }; + const knowledgeNode: Node = { + id: 'knowledge_1', + type: 'knowledgeNode', + position: { x: 80, y: 0 }, + data: { + title: '知识库', + outputDefs: [ + { + name: 'documents', + dataType: 'String', + }, + ], + }, + }; + const llmNode: Node = { + id: 'llm_1', + type: 'llmNode', + position: { x: 120, y: 0 }, + data: { + title: '大模型', + systemPrompt: '{{knowledge_1.documents}}', + userPrompt: '', + parameters: [ + { + name: 'knowledge_1.documents', + ref: 'knowledge_1.documents', + refType: 'ref', + autoManaged: true, + }, + ], + }, + }; + + const connectedEdges: Edge[] = [ + { id: 'edge_1', source: 'start_1', target: 'knowledge_1' } as Edge, + { id: 'edge_2', source: 'knowledge_1', target: 'llm_1' } as Edge, + ]; + + const connectedParameters = buildEditorReferenceParameters( + 'llm_1', + [startNode, knowledgeNode, llmNode], + connectedEdges, + (llmNode.data?.parameters || []) as any[], + ); + expect( + connectedParameters.some((item) => item.name === 'knowledge_1.documents'), + ).toBe(true); + + const patch = buildFieldBindingPatch( + llmNode, + [startNode, knowledgeNode, llmNode], + [], + ); + expect((patch?.parameters as any[])?.[0]?.name).toBe('knowledge_1.documents'); + expect((patch?.parameters as any[])?.[0]?.disconnected).toBe(true); + expect((patch?.parameters as any[])?.[0]?.displayName).toBe('documents'); + expect(patch).not.toHaveProperty('systemPrompt'); + }); + + it('restores auto-filled user input binding after reconnect through upstream chain', () => { + const startNode: Node = { + id: 'start_1', + type: 'startNode', + position: { x: 0, y: 0 }, + data: { + title: '流程开始', + parameters: ensureStartNodeParameters(), + }, + }; + const knowledgeNode: Node = { + id: 'knowledge_1', + type: 'knowledgeNode', + position: { x: 80, y: 0 }, + data: { + title: '知识库', + keyword: '', + parameters: [], + outputDefs: [ + { + name: 'documents', + dataType: 'String', + }, + ], + }, + }; + const llmNode: Node = { + id: 'llm_1', + type: 'llmNode', + position: { x: 120, y: 0 }, + data: { + title: '大模型', + userPrompt: '', + parameters: [], + }, + }; + const edges: Edge[] = [ + { id: 'edge_1', source: 'start_1', target: 'knowledge_1' } as Edge, + { id: 'edge_2', source: 'knowledge_1', target: 'llm_1' } as Edge, + ]; + + const knowledgePatch = buildFieldBindingPatch( + knowledgeNode, + [startNode, knowledgeNode, llmNode], + edges, + ); + const nextKnowledgeNode: Node = { + ...knowledgeNode, + data: { + ...knowledgeNode.data, + ...knowledgePatch, + }, + }; + + const llmPatch = buildFieldBindingPatch( + llmNode, + [startNode, nextKnowledgeNode, llmNode], + edges, + ); + expect(knowledgePatch?.keyword).toBe('{{start_1.user_input}}'); + expect(llmPatch?.userPrompt).toBe('{{start_1.user_input}}'); + }); + + it('applies reconnect patches sequentially so downstream nodes can restore in the same batch', () => { + const startNode: Node = { + id: 'start_1', + type: 'startNode', + position: { x: 0, y: 0 }, + data: { + title: '流程开始', + parameters: ensureStartNodeParameters(), + }, + }; + const knowledgeNode: Node = { + id: 'knowledge_1', + type: 'knowledgeNode', + position: { x: 80, y: 0 }, + data: { + title: '知识库', + keyword: '', + parameters: [], + outputDefs: [ + { + name: 'documents', + dataType: 'String', + }, + ], + }, + }; + const llmNode: Node = { + id: 'llm_1', + type: 'llmNode', + position: { x: 120, y: 0 }, + data: { + title: '大模型', + userPrompt: '', + parameters: [], + }, + }; + const edges: Edge[] = [ + { id: 'edge_1', source: 'start_1', target: 'knowledge_1' } as Edge, + { id: 'edge_2', source: 'knowledge_1', target: 'llm_1' } as Edge, + ]; + + const patches = buildSequentialFieldBindingPatches( + ['knowledge_1', 'llm_1'], + [startNode, knowledgeNode, llmNode], + edges, + ); + + expect(patches).toHaveLength(2); + expect(patches[0]).toMatchObject({ + nodeId: 'knowledge_1', + patch: { + keyword: '{{start_1.user_input}}', + }, + }); + expect(patches[1]).toMatchObject({ + nodeId: 'llm_1', + patch: { + userPrompt: '{{start_1.user_input}}', + }, + }); + }); + + it('extracts only used parameters for llm single run', () => { + const parameters = ensureStartNodeParameters().map((item) => ({ + ...item, + name: 'start_1.user_input', + ref: 'start_1.user_input', + formLabel: '流程开始 > 用户问题', + displayName: '流程开始 > 用户问题', + systemReserved: false, + autoManaged: true, + })); + const result = buildSingleRunParameters({ + type: 'llmNode', + data: { + userPrompt: '请回答 {{start_1.user_input}}', + systemPrompt: '系统', + parameters, + }, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.formLabel).toBe('流程开始 > 用户问题'); + expect(result[0]?.required).toBe(true); + }); + + it('builds field-mode single run model for llm node', () => { + const parameters = ensureStartNodeParameters().map((item) => ({ + ...item, + name: 'start_1.user_input', + ref: 'start_1.user_input', + formLabel: '流程开始 > 用户问题', + displayName: '流程开始 > 用户问题', + systemReserved: false, + autoManaged: true, + })); + + const result = buildSingleRunModel({ + type: 'llmNode', + data: { + userPrompt: '请回答 {{start_1.user_input}}', + systemPrompt: '你是助手', + parameters, + }, + }); + + expect(result.mode).toBe('fields'); + expect(result.fields.map((item) => item.key)).toEqual([ + 'systemPrompt', + 'userPrompt', + ]); + expect(result.parameters).toHaveLength(1); + expect(result.parameters[0]?.formLabel).toBe('流程开始 > 用户问题'); + }); + + it('keeps legacy start node parameters unchanged during single run build', () => { + const legacyParameters = [ + { + id: 'legacy_1', + name: 'legacy_input', + refType: 'input', + dataType: 'String', + }, + ]; + + const result = buildSingleRunParameters({ + type: 'startNode', + data: { + parameters: legacyParameters, + }, + }); + + expect(result).toEqual(legacyParameters); + }); + + it('normalizes only start nodes that already contain fixed user_input', () => { + const normalizedWorkflow = normalizeWorkflowStartNodes({ + nodes: [ + { + id: 'start_new', + type: 'startNode', + data: { + parameters: [ + { + name: 'user_input', + refType: 'input', + required: false, + }, + ], + }, + }, + { + id: 'start_legacy', + type: 'startNode', + data: { + parameters: [], + }, + }, + ], + edges: [], + }); + + expect(normalizedWorkflow.nodes[0]?.data?.parameters?.[0]?.required).toBe( + true, + ); + expect(normalizedWorkflow.nodes[1]?.data?.parameters).toEqual([]); + }); +}); diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/utils/workflowNodeFields.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/utils/workflowNodeFields.ts new file mode 100644 index 0000000..e080136 --- /dev/null +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/utils/workflowNodeFields.ts @@ -0,0 +1,858 @@ +import type { Edge, Node } from '@xyflow/svelte'; + +import type { Parameter } from '../types'; +import { getTokenRanges } from '../components/utils/paramToken'; +import { genShortId } from '../components/utils/IdGen'; + +export const START_NODE_TYPE = 'startNode'; +export const LLM_NODE_TYPE = 'llmNode'; +export const KNOWLEDGE_NODE_TYPE = 'knowledgeNode'; +export const SYSTEM_START_PARAM_NAME = 'user_input'; +export const SYSTEM_START_PARAM_LABEL = '用户问题'; +export const FIELD_BINDING_META_KEY = 'fieldBindingMeta'; + +export type SingleRunFieldDescriptor = { + key: string; + label: string; + value: string; + placeholder?: string; + multiline?: boolean; +}; + +export type SingleRunModel = + | { + mode: 'parameters'; + parameters: Parameter[]; + fields: []; + } + | { + mode: 'fields'; + parameters: Parameter[]; + fields: SingleRunFieldDescriptor[]; + }; + +type FieldBindingMeta = Record< + string, + { + autoFilledFrom?: string; + userModified?: boolean; + } +>; + +function asString(value: unknown) { + return value == null ? '' : String(value); +} + +function cloneParameter(parameter: Parameter): Parameter { + return { + ...parameter, + children: parameter.children?.map(cloneParameter), + }; +} + +function ensureParameterId(parameter: Parameter): Parameter { + const cloned = cloneParameter(parameter); + if (!cloned.id) { + cloned.id = genShortId(); + } + if (cloned.children?.length) { + cloned.children = cloned.children.map(ensureParameterId); + } + return cloned; +} + +function getNodeTitle(node?: Node | null) { + return asString(node?.data?.title).trim() || '节点'; +} + +function getParameterLabel(parameter?: Parameter | null) { + return ( + asString(parameter?.formLabel).trim() || + asString(parameter?.displayName).trim() || + asString(parameter?.name).trim() || + '参数' + ); +} + +function buildDisconnectedDisplayName(parameter: Parameter) { + const displayName = + asString(parameter.displayName).trim() || + asString(parameter.formLabel).trim(); + if (displayName) { + return displayName; + } + + const name = asString(parameter.name).trim(); + if (!name) { + return '参数'; + } + const segments = name.split('.'); + return segments[segments.length - 1] || name; +} + +function flattenNodeRefs( + currentNodeId: string, + edges: Edge[], + refNodeIds: string[], + visited: Set, +) { + if (visited.has(currentNodeId)) { + return; + } + visited.add(currentNodeId); + + for (const edge of edges) { + if (edge.target === currentNodeId && edge.source) { + refNodeIds.push(edge.source); + flattenNodeRefs(edge.source, edges, refNodeIds, visited); + } + } +} + +function flattenOutputDefs( + node: Node, + parameters: Parameter[], + parentPath = '', + parentLabel = '', +): Parameter[] { + if (!parameters.length) { + return []; + } + + return parameters.flatMap((parameter) => { + const rawName = asString(parameter.name).trim(); + if (!rawName) { + return []; + } + + const path = parentPath ? `${parentPath}.${rawName}` : rawName; + const label = parentLabel + ? `${parentLabel}.${getParameterLabel(parameter)}` + : getParameterLabel(parameter); + const fullRef = `${node.id}.${path}`; + const baseCandidate: Parameter = ensureParameterId({ + name: fullRef, + ref: fullRef, + refType: 'ref', + dataType: parameter.dataType || 'String', + displayName: `${getNodeTitle(node)} > ${label}`, + formLabel: `${getNodeTitle(node)} > ${label}`, + nameDisabled: true, + dataTypeDisabled: true, + deleteDisabled: true, + autoManaged: true, + }); + + const children = flattenOutputDefs( + node, + parameter.children || [], + path, + label, + ); + + return [baseCandidate, ...children]; + }); +} + +function getNodeReferenceParameters(node: Node): Parameter[] { + if (node.type === START_NODE_TYPE) { + const parameters = Array.isArray(node.data?.parameters) + ? (node.data.parameters as Parameter[]) + : []; + return parameters + .filter((parameter) => asString(parameter.name).trim()) + .map((parameter) => + ensureParameterId({ + ...cloneParameter(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)}`, + nameDisabled: true, + dataTypeDisabled: true, + deleteDisabled: true, + autoManaged: true, + }), + ); + } + + const outputDefs = Array.isArray(node.data?.outputDefs) + ? (node.data.outputDefs as Parameter[]) + : []; + return flattenOutputDefs(node, outputDefs); +} + +function uniqueParameters(parameters: Parameter[]) { + const result: Parameter[] = []; + const indexMap = new Map(); + + for (const parameter of parameters) { + const key = asString(parameter.name).trim(); + if (!key) { + continue; + } + const existingIndex = indexMap.get(key); + if (existingIndex == null) { + indexMap.set(key, result.length); + result.push(ensureParameterId(parameter)); + continue; + } + + const existingParameter = result[existingIndex]!; + result[existingIndex] = ensureParameterId({ + ...existingParameter, + ...parameter, + children: + parameter.children?.length || existingParameter.children?.length + ? parameter.children || existingParameter.children + : undefined, + }); + } + + return result; +} + +function findParameterByName(parameters: Parameter[], name: string) { + return parameters.find((parameter) => asString(parameter.name).trim() === name); +} + +function toManagedRefParameter(refPath: string, candidate?: Parameter): Parameter { + return ensureParameterId({ + name: refPath, + ref: candidate?.ref || refPath, + refType: 'ref', + dataType: candidate?.dataType || 'String', + displayName: candidate?.displayName || candidate?.formLabel || refPath, + formLabel: candidate?.formLabel || candidate?.displayName || refPath, + description: candidate?.description, + nameDisabled: true, + dataTypeDisabled: true, + deleteDisabled: true, + autoManaged: true, + }); +} + +export function createSystemStartParameter(): Parameter { + return ensureParameterId({ + 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}`, + nameDisabled: true, + dataTypeDisabled: true, + deleteDisabled: true, + requiredDisabled: true, + systemReserved: true, + }); +} + +export function isSystemStartParameter(parameter?: Parameter | null) { + if (!parameter) { + return false; + } + return ( + parameter.systemReserved === true || + (asString(parameter.name).trim() === SYSTEM_START_PARAM_NAME && + asString(parameter.refType).trim() === 'input') + ); +} + +export function hasSystemStartParameter(parameters?: Parameter[] | null) { + return Array.isArray(parameters) + ? parameters.some((parameter) => isSystemStartParameter(parameter)) + : false; +} + +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, + }); + } else { + source.unshift(fixed); + } + + const customParameters = source + .filter((parameter) => !isSystemStartParameter(parameter)) + .map((parameter) => ensureParameterId(parameter)); + + return [source.find((parameter) => isSystemStartParameter(parameter))!, ...customParameters]; +} + +export function createInitialWorkflowData() { + return { + nodes: [ + { + id: `node_${genShortId()}`, + type: START_NODE_TYPE, + position: { x: 80, y: 180 }, + data: { + title: '开始节点', + parameters: ensureStartNodeParameters(), + }, + } satisfies Node, + ], + edges: [], + viewport: { + x: 0, + y: 0, + zoom: 1, + }, + }; +} + +export function normalizeWorkflowStartNodes>(data: T): T { + if (!data || typeof data !== 'object' || !Array.isArray(data.nodes)) { + return data; + } + + let changed = false; + const nextNodes = data.nodes.map((node) => { + if (node?.type !== START_NODE_TYPE) { + return node; + } + const currentParameters = Array.isArray(node.data?.parameters) + ? (node.data.parameters as Parameter[]) + : []; + if (!hasSystemStartParameter(currentParameters)) { + return node; + } + const normalizedParameters = ensureStartNodeParameters(currentParameters); + if (JSON.stringify(currentParameters) === JSON.stringify(normalizedParameters)) { + return node; + } + changed = true; + return { + ...node, + data: { + ...(node.data || {}), + parameters: normalizedParameters, + }, + }; + }); + + if (!changed) { + return data; + } + return { + ...data, + nodes: nextNodes, + }; +} + +export function isWorkflowDataEmpty(data: any) { + if (!data || typeof data !== 'object') { + return true; + } + const nodes = Array.isArray(data.nodes) ? data.nodes : []; + const edges = Array.isArray(data.edges) ? data.edges : []; + return nodes.length === 0 && edges.length === 0; +} + +export function buildEditorReferenceParameters( + currentNodeId: string, + nodes: Node[], + edges: Edge[], + existingParameters?: Parameter[], +) { + const refNodeIds: string[] = []; + flattenNodeRefs(currentNodeId, edges, refNodeIds, new Set()); + + const upstreamParameters = nodes + .filter((node) => refNodeIds.includes(node.id)) + .flatMap((node) => getNodeReferenceParameters(node)); + + const upstreamNameSet = new Set( + upstreamParameters.map((parameter) => asString(parameter.name).trim()), + ); + + const disconnectedParameters = (existingParameters || []) + .map((parameter) => ensureParameterId(parameter)) + .filter((parameter) => { + const name = asString(parameter.name).trim(); + if (!name || upstreamNameSet.has(name)) { + return false; + } + return parameter.autoManaged === true; + }) + .map((parameter) => + ensureParameterId({ + ...parameter, + resolved: false, + disconnected: true, + displayName: buildDisconnectedDisplayName(parameter), + formLabel: buildDisconnectedDisplayName(parameter), + } as Parameter & { resolved: boolean; disconnected: boolean }), + ); + + return uniqueParameters([...disconnectedParameters, ...upstreamParameters]); +} + +export function syncManagedParametersForFields( + existingParameters: Parameter[] | undefined, + candidateParameters: Parameter[], + fieldValues: Record, +) { + const currentParameters = Array.isArray(existingParameters) + ? existingParameters.map(ensureParameterId) + : []; + const tokenKeys = Array.from( + new Set( + Object.values(fieldValues) + .flatMap((value) => getTokenRanges(asString(value)).map((token) => token.key)) + .filter((value) => value.trim().length > 0), + ), + ); + const usedTokenKeySet = new Set(tokenKeys); + const candidateKeySet = new Set( + candidateParameters + .map((parameter) => asString(parameter.name).trim()) + .filter((value) => value.length > 0), + ); + const nextParameters = currentParameters + .filter((parameter) => { + if (parameter.autoManaged !== true) { + return true; + } + const parameterName = asString(parameter.name).trim(); + return usedTokenKeySet.has(parameterName) && candidateKeySet.has(parameterName); + }) + .map((parameter) => { + if (parameter.autoManaged !== true) { + return parameter; + } + const candidate = findParameterByName( + candidateParameters, + asString(parameter.name).trim(), + ) as (Parameter & { disconnected?: boolean }) | undefined; + if (!candidate) { + return parameter; + } + return { + ...parameter, + displayName: candidate.displayName || parameter.displayName, + formLabel: candidate.formLabel || parameter.formLabel, + disconnected: candidate.disconnected === true ? true : undefined, + } as Parameter; + }); + + for (const tokenKey of tokenKeys) { + if (!candidateKeySet.has(tokenKey)) { + continue; + } + if (findParameterByName(nextParameters, tokenKey)) { + continue; + } + const candidate = findParameterByName(candidateParameters, tokenKey); + nextParameters.push(toManagedRefParameter(tokenKey, candidate)); + } + + return uniqueParameters(nextParameters); +} + +export function toToken(refPath: string) { + return `{{${refPath}}}`; +} + +function getFieldBindingMeta(data: Record) { + return ((data?.[FIELD_BINDING_META_KEY] || {}) as FieldBindingMeta) || {}; +} + +export function updateFieldBindingMeta( + currentData: Record, + fieldName: string, + nextValue: string, +) { + const nextMeta: FieldBindingMeta = { + ...getFieldBindingMeta(currentData), + }; + const currentFieldMeta = nextMeta[fieldName]; + if (!currentFieldMeta?.autoFilledFrom) { + return nextMeta; + } + + nextMeta[fieldName] = { + ...currentFieldMeta, + userModified: nextValue !== toToken(currentFieldMeta.autoFilledFrom), + }; + return nextMeta; +} + +function resolveNearestStartNodeRef( + targetNodeId: string, + nodes: Node[], + edges: Edge[], +) { + const queue = [targetNodeId]; + const visited = new Set(); + + while (queue.length > 0) { + const currentId = queue.shift()!; + if (visited.has(currentId)) { + continue; + } + visited.add(currentId); + + for (const edge of edges) { + if (edge.target !== currentId || !edge.source) { + continue; + } + const sourceNode = nodes.find((node) => node.id === edge.source); + if (!sourceNode) { + continue; + } + if (sourceNode.type === START_NODE_TYPE) { + const parameters = Array.isArray(sourceNode.data?.parameters) + ? ((sourceNode.data?.parameters as Parameter[]) || []) + : []; + const systemParam = parameters.find((parameter) => + isSystemStartParameter(parameter), + ); + if (!systemParam) { + continue; + } + return { + refPath: `${sourceNode.id}.${SYSTEM_START_PARAM_NAME}`, + displayName: `${getNodeTitle(sourceNode)} > ${getParameterLabel(systemParam)}`, + }; + } + queue.push(sourceNode.id); + } + } + + return null; +} + +function canApplyAutoBinding( + fieldValue: string | undefined, + fieldMeta: FieldBindingMeta[string] | undefined, + expectedRefPath: string, +) { + if (fieldMeta?.userModified) { + return false; + } + const currentValue = asString(fieldValue).trim(); + if (!currentValue) { + return true; + } + return currentValue === toToken(expectedRefPath); +} + +function getSupportedFieldKeys(nodeType: string | undefined) { + if (nodeType === LLM_NODE_TYPE) { + return ['systemPrompt', 'userPrompt']; + } + if (nodeType === KNOWLEDGE_NODE_TYPE) { + return ['keyword', 'limit']; + } + return []; +} + +function getSingleRunFieldDescriptors( + node: Pick, +): SingleRunFieldDescriptor[] { + if (!node?.data) { + return []; + } + + if (node.type === LLM_NODE_TYPE) { + return [ + { + key: 'systemPrompt', + label: '系统提示词', + value: asString((node.data as Record).systemPrompt), + placeholder: '未设置', + multiline: true, + }, + { + key: 'userPrompt', + label: '用户提示词', + value: asString((node.data as Record).userPrompt), + placeholder: '未设置', + multiline: true, + }, + ]; + } + + if (node.type === KNOWLEDGE_NODE_TYPE) { + return [ + { + key: 'keyword', + label: '关键词', + value: asString((node.data as Record).keyword), + placeholder: '未设置', + }, + { + key: 'limit', + label: '获取数据量', + value: asString((node.data as Record).limit), + placeholder: '未设置', + }, + ]; + } + + return []; +} + +function getAutoBindingFieldKeys(nodeType: string | undefined) { + if (nodeType === LLM_NODE_TYPE) { + return ['userPrompt']; + } + if (nodeType === KNOWLEDGE_NODE_TYPE) { + return ['keyword']; + } + return []; +} + +export function buildFieldBindingPatch( + node: Node, + nodes: Node[], + edges: Edge[], +) { + if (!node?.id || !node?.data) { + return null; + } + + const fieldKeys = getSupportedFieldKeys(node.type); + if (fieldKeys.length === 0) { + return null; + } + + const data = (node.data || {}) as Record; + const startRef = resolveNearestStartNodeRef(node.id, nodes, edges); + const patch: Record = {}; + const nextFieldValues: Record = Object.fromEntries( + fieldKeys.map((fieldKey) => [fieldKey, asString(data[fieldKey])]), + ); + const nextMeta: FieldBindingMeta = { + ...getFieldBindingMeta(data), + }; + + const applyField = (fieldName: string) => { + if (!startRef) { + return; + } + const fieldMeta = nextMeta[fieldName]; + if (!canApplyAutoBinding(nextFieldValues[fieldName], fieldMeta, startRef.refPath)) { + return; + } + const tokenValue = toToken(startRef.refPath); + if (asString(nextFieldValues[fieldName]).trim() !== tokenValue) { + patch[fieldName] = tokenValue; + } + nextFieldValues[fieldName] = tokenValue; + nextMeta[fieldName] = { + autoFilledFrom: startRef.refPath, + userModified: false, + }; + }; + + const clearField = (fieldName: string, fieldMeta: NonNullable) => { + const currentValue = asString(nextFieldValues[fieldName]).trim(); + const autoTokenValue = toToken(fieldMeta.autoFilledFrom || ''); + if (currentValue && currentValue !== autoTokenValue) { + return; + } + if (currentValue) { + patch[fieldName] = ''; + } + nextFieldValues[fieldName] = ''; + delete nextMeta[fieldName]; + }; + + for (const fieldName of getAutoBindingFieldKeys(node.type)) { + const fieldMeta = nextMeta[fieldName]; + if (!fieldMeta?.autoFilledFrom || fieldMeta.userModified) { + continue; + } + if (startRef?.refPath === fieldMeta.autoFilledFrom) { + continue; + } + clearField(fieldName, fieldMeta); + } + + for (const fieldName of getAutoBindingFieldKeys(node.type)) { + applyField(fieldName); + } + + const editorParameters = buildEditorReferenceParameters( + node.id, + nodes, + edges, + (data.parameters as Parameter[]) || [], + ); + patch.parameters = syncManagedParametersForFields( + (data.parameters as Parameter[]) || [], + editorParameters, + nextFieldValues, + ); + patch[FIELD_BINDING_META_KEY] = nextMeta; + + const currentParameters = JSON.stringify((data.parameters as Parameter[]) || []); + if (JSON.stringify(patch.parameters) === currentParameters) { + delete patch.parameters; + } + + if ( + JSON.stringify(nextMeta) === JSON.stringify(getFieldBindingMeta(data)) + ) { + delete patch[FIELD_BINDING_META_KEY]; + } + + if (Object.keys(patch).length === 0) { + return null; + } + + return patch; +} + +export function buildAutoBindingPatch( + node: Node, + nodes: Node[], + edges: Edge[], +) { + return buildFieldBindingPatch(node, nodes, edges); +} + +export function buildSequentialFieldBindingPatches( + nodeIds: string[], + nodes: Node[], + edges: Edge[], +) { + const uniqueNodeIds = Array.from( + new Set(nodeIds.map((nodeId) => asString(nodeId).trim()).filter(Boolean)), + ); + if (uniqueNodeIds.length === 0) { + return []; + } + + const workingNodes = nodes.map((node) => ({ + ...node, + data: { + ...((node.data || {}) as Record), + }, + })); + const patches: Array<{ nodeId: string; patch: Record }> = []; + + for (const nodeId of uniqueNodeIds) { + const nodeIndex = workingNodes.findIndex((item) => item.id === nodeId); + if (nodeIndex < 0) { + continue; + } + const currentNode = workingNodes[nodeIndex]!; + const patch = buildFieldBindingPatch(currentNode, workingNodes, edges); + if (!patch) { + continue; + } + patches.push({ nodeId, patch }); + workingNodes[nodeIndex] = { + ...currentNode, + data: { + ...((currentNode.data || {}) as Record), + ...patch, + }, + }; + } + + return patches; +} + +export function buildSingleRunParameters(node: Pick | null | undefined) { + if (!node?.data) { + return []; + } + + if (node.type === START_NODE_TYPE) { + const parameters = Array.isArray(node.data.parameters) + ? ((node.data.parameters as Parameter[]) || []) + : []; + return hasSystemStartParameter(parameters) + ? ensureStartNodeParameters(parameters) + : parameters; + } + + const parameters = Array.isArray(node.data.parameters) + ? (node.data.parameters as Parameter[]).map(ensureParameterId) + : []; + + const fieldKeys = + node.type === LLM_NODE_TYPE + ? ['systemPrompt', 'userPrompt'] + : node.type === KNOWLEDGE_NODE_TYPE + ? ['keyword', 'limit'] + : []; + + if (fieldKeys.length === 0) { + return parameters; + } + + const usedTokenKeys = Array.from( + new Set( + fieldKeys.flatMap((fieldKey) => + getTokenRanges(asString((node.data as Record)[fieldKey])).map( + (token) => token.key, + ), + ), + ), + ); + + return parameters.filter((parameter) => + usedTokenKeys.includes(asString(parameter.name).trim()), + ).map((parameter) => + ensureParameterId({ + ...parameter, + required: parameter.required ?? true, + }), + ); +} + +export function buildSingleRunModel( + node: Pick | null | undefined, +): SingleRunModel { + const parameters = buildSingleRunParameters(node); + if (!node?.data) { + return { + mode: 'parameters', + parameters, + fields: [], + }; + } + + if (node.type === START_NODE_TYPE) { + return { + mode: 'parameters', + parameters, + fields: [], + }; + } + + const fields = getSingleRunFieldDescriptors(node); + if (fields.length === 0) { + return { + mode: 'parameters', + parameters, + fields: [], + }; + } + + return { + mode: 'fields', + parameters, + fields, + }; +} diff --git a/easyflow-ui-admin/packages/tinyflow-ui/vitest.config.ts b/easyflow-ui-admin/packages/tinyflow-ui/vitest.config.ts new file mode 100644 index 0000000..d23f103 --- /dev/null +++ b/easyflow-ui-admin/packages/tinyflow-ui/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + plugins: [svelte({ emitCss: false })], + resolve: { + conditions: ['browser'] + }, + test: { + environment: 'happy-dom' + } +}); diff --git a/easyflow-ui-admin/packages/types/src/bot.ts b/easyflow-ui-admin/packages/types/src/bot.ts index f4bb719..3121980 100644 --- a/easyflow-ui-admin/packages/types/src/bot.ts +++ b/easyflow-ui-admin/packages/types/src/bot.ts @@ -7,6 +7,7 @@ interface BotInfo { displayPublishStatus?: string; created: string; createdBy: number; + createdByName?: string; deptId: number; description: string; icon: string;