From 7bcaf69fc0df624eea487e0f70b24fc363c23ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Mon, 3 Aug 2026 17:27:00 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E8=A1=A5=E5=85=A8=E4=B8=8E=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 保持 main 参数运行协议并在失焦或销毁前刷新输出分析 - 按输入名称生成 JavaScript 和 Python 补全且隐藏旧版 _result - 补充复杂签名与命名参数补全测试 --- .../src/components/nodes/CodeNode.svelte | 78 +++++++++------ .../components/utils/codeCompletion.test.ts | 38 +++++-- .../src/components/utils/codeCompletion.ts | 99 ++++++++++--------- .../src/components/utils/codeNodeScaffold.ts | 25 +++-- .../src/components/utils/codeNodeSync.test.ts | 52 +++++----- .../src/components/utils/codeNodeSync.ts | 30 ------ 6 files changed, 171 insertions(+), 151 deletions(-) diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/CodeNode.svelte b/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/CodeNode.svelte index d085d762..84fea251 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/CodeNode.svelte +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/nodes/CodeNode.svelte @@ -19,7 +19,6 @@ import {genShortId} from '../utils/IdGen'; import {createCodeNodeScaffold} from '../utils/codeNodeScaffold'; import { - analyzeCodeNodeMainArgsMode, analyzeCodeNodeOutputs, reconcileInferredCodeNodeOutputs, syncCodeNodeInputParameters, @@ -144,39 +143,54 @@ Python 示例 }; let analysisTimer: ReturnType | undefined; + let pendingAnalysisCode: string | undefined; + + // mainArgsMode 是持久化运行契约,人工编辑代码时只分析输出,禁止按形参文本切换协议。 + const applyOutputAnalysis = (code: string) => { + updateNodeData(currentNodeId, (node) => { + if (String(node.data.code || '') !== code) { + return {}; + } + const outputDefs = reconcileOutputs( + code, + String(node.data.engine || defaultEngine), + (node.data.parameters as Parameter[]) || [], + (node.data.outputDefs as Parameter[]) || [], + ); + if ( + JSON.stringify(outputDefs) === + JSON.stringify(node.data.outputDefs || []) + ) { + return {}; + } + return { outputDefs }; + }); + }; + + const flushOutputAnalysis = () => { + if (analysisTimer) { + clearTimeout(analysisTimer); + analysisTimer = undefined; + } + const code = pendingAnalysisCode; + pendingAnalysisCode = undefined; + if (code !== undefined) { + applyOutputAnalysis(code); + } + }; + const scheduleOutputAnalysis = (code: string) => { if (analysisTimer) { clearTimeout(analysisTimer); } + pendingAnalysisCode = code; analysisTimer = setTimeout(() => { - updateNodeData(currentNodeId, (node) => { - if (String(node.data.code || '') !== code) { - return {}; - } - const outputDefs = reconcileOutputs( - code, - String(node.data.engine || defaultEngine), - (node.data.parameters as Parameter[]) || [], - (node.data.outputDefs as Parameter[]) || [], - ); - const mainArgsMode = analyzeCodeNodeMainArgsMode( - code, - String(node.data.engine || defaultEngine), - (node.data.parameters as Parameter[]) || [], - ); - if ( - JSON.stringify(outputDefs) === - JSON.stringify(node.data.outputDefs || []) && - (!mainArgsMode || - mainArgsMode === node.data.mainArgsMode) - ) { - return {}; - } - return { - outputDefs, - ...(mainArgsMode ? { mainArgsMode } : {}), - }; - }); + analysisTimer = undefined; + const pendingCode = pendingAnalysisCode; + pendingAnalysisCode = undefined; + if (pendingCode !== undefined) { + applyOutputAnalysis(pendingCode); + } }, 300); }; @@ -211,9 +225,8 @@ Python 示例 }); onDestroy(() => { - if (analysisTimer) { - clearTimeout(analysisTimer); - } + // 页面切换前刷新最后一次分析,避免代码与输出参数持久化状态不一致。 + flushOutputAnalysis(); }); @@ -272,6 +285,7 @@ Python 示例 }); scheduleOutputAnalysis(code); }} + onchange={flushOutputAnalysis} value={nodeData.code as string||""} /> diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeCompletion.test.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeCompletion.test.ts index f6a20f46..8794efe9 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeCompletion.test.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeCompletion.test.ts @@ -25,9 +25,12 @@ describe('codeCompletion utils', () => { ]); expect(completions[0]).toMatchObject({ - label: '_result', - type: 'variable', + label: 'main', + type: 'snippet', + boost: 1000, }); + expect(String(completions[0].apply)).toContain('def main(question):'); + expect(completions.some((item) => item.label === '_result')).toBe(false); expect( completions.filter((item) => item.label === 'input.text').length, @@ -53,7 +56,10 @@ describe('codeCompletion utils', () => { }); it('should prefer main return snippets for javascript output', () => { - const completions = createBusinessCompletions('javascript', []); + const completions = createBusinessCompletions('javascript', [ + { name: 'data', resolved: true }, + { name: 'question', resolved: true }, + ]); const mainCompletion = completions.find((item) => item.label === 'main'); const resultCompletion = completions.find( (item) => item.label === 'result-object', @@ -64,15 +70,21 @@ describe('codeCompletion utils', () => { detail: '代码节点入口', boost: 1000, }); - expect(String(mainCompletion?.apply)).toContain('function main'); + expect(String(mainCompletion?.apply)).toContain( + 'function main(data, question)', + ); expect(String(mainCompletion?.apply)).toContain('return {'); + expect(String(mainCompletion?.apply)).not.toContain('_result'); expect(String(resultCompletion?.apply)).toBe( "return { message: 'ok', data };", ); }); it('should prefer main return snippets for python output', () => { - const completions = createBusinessCompletions('python', []); + const completions = createBusinessCompletions('python', [ + { name: 'data', resolved: true }, + { name: 'question', resolved: true }, + ]); const mainCompletion = completions.find((item) => item.label === 'main'); const resultCompletion = completions.find( (item) => item.label === 'result-object', @@ -83,21 +95,27 @@ describe('codeCompletion utils', () => { detail: '代码节点入口', boost: 1000, }); - expect(String(mainCompletion?.apply)).toContain('def main(inputs):'); + expect(String(mainCompletion?.apply)).toContain( + 'def main(data, question):', + ); expect(String(mainCompletion?.apply)).toContain('return {'); - expect(String(resultCompletion?.apply)).toContain('def main(inputs):'); + expect(String(mainCompletion?.apply)).not.toContain('_result'); expect(String(resultCompletion?.apply)).toContain( - "return {'message': 'ok', 'data': inputs.get('data')}", + 'def main(data, question):', + ); + expect(String(resultCompletion?.apply)).toContain( + "return {'message': 'ok', 'data': data}", ); }); - it('should provide standalone python snippets', () => { + it('should not expose the legacy object-input contract in python snippets', () => { const completions = createBusinessCompletions('python', []); const snippets = completions.filter((item) => item.type === 'snippet'); expect(snippets).toHaveLength(4); for (const snippet of snippets) { - expect(String(snippet.apply)).toMatch(/^def main\(inputs\):\n /); + expect(String(snippet.apply)).not.toContain('inputs'); + expect(String(snippet.apply)).not.toContain('_result'); } }); diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeCompletion.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeCompletion.ts index 7c6e9a4e..9f683d4a 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeCompletion.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeCompletion.ts @@ -4,6 +4,10 @@ import type { CompletionSource, } from '@codemirror/autocomplete'; import { syntaxTree } from '@codemirror/language'; +import { + createCodeNodeScaffold, + getCodeNodeParameterNames, +} from './codeNodeScaffold'; import type { ParameterCandidate } from './paramToken'; export type CodeEngine = 'javascript' | 'python'; @@ -30,12 +34,6 @@ const BLOCKED_NODE_NAMES = new Set([ ]); const JS_SNIPPETS: Array<{ label: string; insert: string; detail: string }> = [ - { - label: 'main', - detail: '代码节点入口', - insert: - 'function main({ input }) {\n return {\n output: input,\n };\n}', - }, { label: 'if-else', detail: '条件分支', @@ -55,36 +53,45 @@ const JS_SNIPPETS: Array<{ label: string; insert: string; detail: string }> = [ }, ]; -const PYTHON_SNIPPETS: Array<{ - label: string; - insert: string; - detail: string; -}> = [ - { - label: 'main', - detail: '代码节点入口', - insert: - "def main(inputs):\n return {\n 'output': inputs.get('input'),\n }", - }, - { - label: 'if-else', - detail: '条件分支', - insert: - "def main(inputs):\n condition = inputs.get('condition')\n value = inputs.get('value')\n if condition:\n return {'value': value}\n return {'value': None}", - }, - { - label: 'for-loop', - detail: '遍历数组', - insert: - "def main(inputs):\n result = []\n for item in inputs.get('items', []):\n result.append(item)\n return {'result': result}", - }, - { - label: 'result-object', - detail: '返回对象', - insert: - "def main(inputs):\n return {'message': 'ok', 'data': inputs.get('data')}", - }, -]; +function createPythonSnippets(parameterNames: string[]) { + const argumentsText = parameterNames.join(', '); + const conditionExpression = parameterNames[0] || 'False'; + const valueExpression = parameterNames[1] || parameterNames[0] || 'None'; + const itemsExpression = parameterNames[0] || '[]'; + const dataExpression = parameterNames[0] || 'None'; + + return [ + { + label: 'if-else', + detail: '条件分支', + insert: [ + `def main(${argumentsText}):`, + ` if ${conditionExpression}:`, + ` return {'value': ${valueExpression}}`, + " return {'value': None}", + ].join('\n'), + }, + { + label: 'for-loop', + detail: '遍历数组', + insert: [ + `def main(${argumentsText}):`, + ' result = []', + ` for item in ${itemsExpression}:`, + ' result.append(item)', + " return {'result': result}", + ].join('\n'), + }, + { + label: 'result-object', + detail: '返回对象', + insert: [ + `def main(${argumentsText}):`, + ` return {'message': 'ok', 'data': ${dataExpression}}`, + ].join('\n'), + }, + ]; +} export function normalizeCodeEngine(rawEngine?: string): CodeEngine { const normalized = (rawEngine || 'js').trim().toLowerCase(); @@ -111,11 +118,13 @@ export function createBusinessCompletions( targetEngine: CodeEngine, candidates: ParameterCandidate[], ): Completion[] { - const resultCompletion: Completion = { - label: '_result', - type: 'variable', - detail: '兼容旧版输出对象', - boost: 900, + const parameterNames = getCodeNodeParameterNames(targetEngine, candidates); + const mainCompletion: Completion = { + label: 'main', + type: 'snippet', + detail: '代码节点入口', + apply: createCodeNodeScaffold(targetEngine, candidates), + boost: 1000, }; const parameterCompletions: Completion[] = candidates.map((candidate) => ({ @@ -127,17 +136,19 @@ export function createBusinessCompletions( })); const snippetCompletions: Completion[] = ( - targetEngine === 'python' ? PYTHON_SNIPPETS : JS_SNIPPETS + targetEngine === 'python' + ? createPythonSnippets(parameterNames) + : JS_SNIPPETS ).map((snippet) => ({ label: snippet.label, type: 'snippet', detail: snippet.detail, apply: snippet.insert, - boost: snippet.label === 'main' ? 1000 : 500, + boost: 500, })); return dedupeCompletions([ - resultCompletion, + mainCompletion, ...parameterCompletions, ...snippetCompletions, ]); diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeScaffold.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeScaffold.ts index 370269ec..f9e0560c 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeScaffold.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeScaffold.ts @@ -112,6 +112,23 @@ export function isValidCodeNodeParameterName(name: string, rawEngine?: string) { ); } +/** + * 按配置顺序提取可安全写入函数签名的唯一参数名称。 + */ +export function getCodeNodeParameterNames( + rawEngine: string | undefined, + parameters: Array>, +) { + const engine = normalizeCodeNodeEngine(rawEngine); + return [ + ...new Set( + parameters + .map((parameter) => String(parameter.name || '').trim()) + .filter((name) => isValidCodeNodeParameterName(name, engine)), + ), + ]; +} + /** * 为新代码节点生成与当前运行时兼容的 main 脚手架。 */ @@ -121,13 +138,7 @@ export function createCodeNodeScaffold( outputDefs: Parameter[] = [], ) { const engine = normalizeCodeNodeEngine(rawEngine); - const parameterNames = [ - ...new Set( - parameters - .map((parameter) => String(parameter.name || '').trim()) - .filter((name) => isValidCodeNodeParameterName(name, engine)), - ), - ]; + const parameterNames = getCodeNodeParameterNames(engine, parameters); const outputNames = [ ...new Set( outputDefs diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeSync.test.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeSync.test.ts index 7479c83d..bfa9d147 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeSync.test.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeSync.test.ts @@ -5,7 +5,6 @@ import type { Parameter } from '#types'; import { createCodeNodeScaffold } from './codeNodeScaffold'; import { getAvailableNodes } from './nodePalette'; import { - analyzeCodeNodeMainArgsMode, analyzeCodeNodeOutputs, reconcileInferredCodeNodeOutputs, syncCodeNodeInputParameters, @@ -76,6 +75,7 @@ describe.each([ emptyCode: createCodeNodeScaffold('js'), mainWithData: 'function main(data)', mainWithQuery: 'function main(query)', + mainWithDefault: 'function main(data = null)', legacyCallWithData: '_result = main(data);', legacyCallWithQuery: '_result = main(query);', }, @@ -84,6 +84,7 @@ describe.each([ emptyCode: createCodeNodeScaffold('python'), mainWithData: 'def main(data):', mainWithQuery: 'def main(query):', + mainWithDefault: 'def main(data=None):', legacyCallWithData: '_result = main(data)', legacyCallWithQuery: '_result = main(query)', }, @@ -168,6 +169,28 @@ describe.each([ expect(result.code).toBe(manualCode); }); + it('does not overwrite a main signature with default values', () => { + const initial = syncCodeNodeInputParameters( + fixture.emptyCode, + fixture.engine, + [], + [{ id: 'input-1', name: 'data' }], + ); + const manualCode = initial.code.replace( + fixture.mainWithData, + fixture.mainWithDefault, + ); + const result = syncCodeNodeInputParameters( + manualCode, + fixture.engine, + initial.parameters, + [{ ...initial.parameters[0], name: 'query' }], + ); + + expect(result.synced).toBe(false); + expect(result.code).toBe(manualCode); + }); + it('keeps a compatible legacy explicit main call synchronized', () => { const initial = syncCodeNodeInputParameters( fixture.emptyCode, @@ -240,33 +263,6 @@ describe.each([ }); }); -describe('code node main argument mode', () => { - it.each([ - { - engine: 'js', - namedCode: 'function main(data) { return { data }; }', - objectCode: 'function main(inputs) { return { data: inputs.data }; }', - }, - { - engine: 'python', - namedCode: 'def main(data):\n return {"data": data}', - objectCode: 'def main(inputs):\n return {"data": inputs.get("data")}', - }, - ])( - 'recognizes named and legacy object contracts for $engine', - ({ engine, namedCode, objectCode }) => { - const parameters = [{ name: 'data', dataType: 'String' }]; - - expect(analyzeCodeNodeMainArgsMode(namedCode, engine, parameters)).toBe( - 'named', - ); - expect(analyzeCodeNodeMainArgsMode(objectCode, engine, parameters)).toBe( - 'object', - ); - }, - ); -}); - describe.each([ { engine: 'js', diff --git a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeSync.ts b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeSync.ts index dd4f3cbf..b57549bc 100644 --- a/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeSync.ts +++ b/easyflow-ui-admin/packages/tinyflow-ui/src/components/utils/codeNodeSync.ts @@ -27,8 +27,6 @@ export type CodeNodeSyncResult = { synced: boolean; }; -export type CodeNodeMainArgsMode = 'named' | 'object'; - export type InferredCodeNodeOutput = { dataType: CodeNodeDataType; name: string; @@ -407,34 +405,6 @@ export function syncCodeNodeInputParameters( }; } -/** - * 根据 main 简单形参与节点输入名称判断运行时调用方式。 - */ -export function analyzeCodeNodeMainArgsMode( - source: string, - rawEngine: string | undefined, - parameters: Parameter[], -): CodeNodeMainArgsMode | null { - const engine = normalizeCodeNodeEngine(rawEngine); - const document = parseDocument(source, engine); - const mainFunction = document && findMainFunction(document); - if (!document || !mainFunction) { - return null; - } - - const configuredNames = parameterNames(parameters, engine, 'name'); - const currentParams = readSimpleNames( - mainFunction.paramList, - source, - new Set(['VariableDefinition', 'VariableName']), - ); - return configuredNames && - currentParams && - arraysEqual(currentParams, configuredNames) - ? 'named' - : 'object'; -} - function parseQuotedString(rawValue: string) { const raw = rawValue.trim(); if (raw.length < 2) {