fix: 修复代码节点补全与参数同步边界

- 保持 main 参数运行协议并在失焦或销毁前刷新输出分析

- 按输入名称生成 JavaScript 和 Python 补全且隐藏旧版 _result

- 补充复杂签名与命名参数补全测试
This commit is contained in:
2026-08-03 17:27:00 +08:00
parent a75ae9a843
commit 7bcaf69fc0
6 changed files with 171 additions and 151 deletions

View File

@@ -19,7 +19,6 @@
import {genShortId} from '../utils/IdGen';
import {createCodeNodeScaffold} from '../utils/codeNodeScaffold';
import {
analyzeCodeNodeMainArgsMode,
analyzeCodeNodeOutputs,
reconcileInferredCodeNodeOutputs,
syncCodeNodeInputParameters,
@@ -144,11 +143,10 @@ Python 示例
};
let analysisTimer: ReturnType<typeof setTimeout> | undefined;
const scheduleOutputAnalysis = (code: string) => {
if (analysisTimer) {
clearTimeout(analysisTimer);
}
analysisTimer = setTimeout(() => {
let pendingAnalysisCode: string | undefined;
// mainArgsMode 是持久化运行契约,人工编辑代码时只分析输出,禁止按形参文本切换协议。
const applyOutputAnalysis = (code: string) => {
updateNodeData(currentNodeId, (node) => {
if (String(node.data.code || '') !== code) {
return {};
@@ -159,24 +157,40 @@ Python 示例
(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)
JSON.stringify(node.data.outputDefs || [])
) {
return {};
}
return {
outputDefs,
...(mainArgsMode ? { mainArgsMode } : {}),
};
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(() => {
analysisTimer = undefined;
const pendingCode = pendingAnalysisCode;
pendingAnalysisCode = undefined;
if (pendingCode !== undefined) {
applyOutputAnalysis(pendingCode);
}
}, 300);
};
@@ -211,9 +225,8 @@ Python 示例
});
onDestroy(() => {
if (analysisTimer) {
clearTimeout(analysisTimer);
}
// 页面切换前刷新最后一次分析,避免代码与输出参数持久化状态不一致。
flushOutputAnalysis();
});
</script>
@@ -272,6 +285,7 @@ Python 示例
});
scheduleOutputAnalysis(code);
}}
onchange={flushOutputAnalysis}
value={nodeData.code as string||""}
/>
</div>

View File

@@ -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');
}
});

View File

@@ -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 }",
},
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(inputs):\n condition = inputs.get('condition')\n value = inputs.get('value')\n if condition:\n return {'value': value}\n return {'value': None}",
insert: [
`def main(${argumentsText}):`,
` if ${conditionExpression}:`,
` return {'value': ${valueExpression}}`,
" return {'value': None}",
].join('\n'),
},
{
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}",
insert: [
`def main(${argumentsText}):`,
' result = []',
` for item in ${itemsExpression}:`,
' result.append(item)',
" return {'result': result}",
].join('\n'),
},
{
label: 'result-object',
detail: '返回对象',
insert:
"def main(inputs):\n return {'message': 'ok', 'data': inputs.get('data')}",
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,
]);

View File

@@ -112,6 +112,23 @@ export function isValidCodeNodeParameterName(name: string, rawEngine?: string) {
);
}
/**
* 按配置顺序提取可安全写入函数签名的唯一参数名称。
*/
export function getCodeNodeParameterNames(
rawEngine: string | undefined,
parameters: Array<Pick<Parameter, 'name'>>,
) {
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

View File

@@ -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',

View File

@@ -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) {