发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
10 changed files with 2107 additions and 70 deletions
Showing only changes of commit 46398d1365 - Show all commits

View File

@@ -5,14 +5,21 @@
import {useNodesData, useSvelteFlow} from '@xyflow/svelte';
import {parameterDataTypes} from '#consts';
import {genShortId} from '../utils/IdGen';
import type {Parameter} from '#types';
import type {Parameter, ParameterChangeHandler} from '#types';
import {deepClone} from '../utils/deepClone';
const { parameter, position, dataKeyName, placeholder = '请输入参数值' }: {
const {
parameter,
position,
dataKeyName,
placeholder = '请输入参数值',
onParametersChange,
}: {
parameter: Parameter,
position: number[],
dataKeyName: string,
placeholder?: string,
onParametersChange?: ParameterChangeHandler,
} = $props();
@@ -50,9 +57,12 @@
const updateAttribute = (key: string, value: any) => {
updateNodeData(currentNodeId, (node) => {
const parameters = node.data?.[dataKeyName] as Array<Parameter>;
if (parameters && position.length > 0) {
let params = parameters as Parameter[];
const previousParameters = deepClone(
(node.data?.[dataKeyName] as Array<Parameter>) || []
);
const nextParameters = deepClone(previousParameters);
if (nextParameters.length > 0 && position.length > 0) {
let params = nextParameters;
for (let i = 0; i < position.length; i++) {
const pos = position[i];
if (i == position.length - 1) {
@@ -66,7 +76,12 @@
}
}
return {
[dataKeyName]: [...deepClone(parameters)]
[dataKeyName]: nextParameters,
...onParametersChange?.(
previousParameters,
nextParameters,
node.data
)
};
});
};
@@ -87,9 +102,12 @@
let triggerObject: any;
const handleDelete = () => {
updateNodeData(currentNodeId, (node) => {
let parameters = node.data?.[dataKeyName] as Array<Parameter>;
if (parameters && position.length > 0) {
let params = parameters as Array<Parameter>;
const previousParameters = deepClone(
(node.data?.[dataKeyName] as Array<Parameter>) || []
);
const nextParameters = deepClone(previousParameters);
if (nextParameters.length > 0 && position.length > 0) {
let params = nextParameters;
for (let i = 0; i < position.length; i++) {
const pos = position[i];
if (i == position.length - 1) {
@@ -100,7 +118,12 @@
}
}
return {
[dataKeyName]: [...deepClone(parameters)]
[dataKeyName]: nextParameters,
...onParametersChange?.(
previousParameters,
nextParameters,
node.data
)
};
});
triggerObject?.hide();
@@ -109,9 +132,12 @@
const handleAddChildParameter = () => {
updateNodeData(currentNodeId, (node) => {
let parameters = node.data?.[dataKeyName] as Array<Parameter>;
if (parameters && position.length > 0) {
let params = parameters as Array<Parameter>;
const previousParameters = deepClone(
(node.data?.[dataKeyName] as Array<Parameter>) || []
);
const nextParameters = deepClone(previousParameters);
if (nextParameters.length > 0 && position.length > 0) {
let params = nextParameters;
for (let i = 0; i < position.length; i++) {
const pos = position[i];
if (i == position.length - 1) {
@@ -137,7 +163,12 @@
}
return {
[dataKeyName]: [...deepClone(parameters)]
[dataKeyName]: nextParameters,
...onParametersChange?.(
previousParameters,
nextParameters,
node.data
)
};
});
};

View File

@@ -1,17 +1,19 @@
<script lang="ts">
import {useNodesData} from '@xyflow/svelte';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import type {Parameter} from '#types';
import type {Parameter, ParameterChangeHandler} from '#types';
import OutputDefItem from './OutputDefItem.svelte';
const {
noneParameterText = '无输出参数',
dataKeyName = 'outputDefs',
placeholder = '请输入参数名称',
onParametersChange,
}: {
noneParameterText?: string;
dataKeyName?: string;
placeholder?: string
placeholder?: string;
onParametersChange?: ParameterChangeHandler;
} = $props();
let currentNodeId = getCurrentNodeId();
@@ -24,7 +26,13 @@
{#snippet parameterList(params: Parameter[], position: number[])}
{#each params as param, index (`${param.id}_${param.children ? param.children.length : 0}`)}
<OutputDefItem parameter={param} position={[...position, index]} {dataKeyName} {placeholder} />
<OutputDefItem
parameter={param}
position={[...position, index]}
{dataKeyName}
{placeholder}
{onParametersChange}
/>
{#if param.children}
{@render parameterList(param.children, [...position, index])}
{/if}

View File

@@ -6,7 +6,8 @@
import {contentTypes, parameterRefTypes} from '#consts';
import {useRefOptions} from '../utils/useRefOptions.svelte';
import {onMount} from 'svelte';
import type {Parameter} from '#types';
import type {Parameter, ParameterChangeHandler} from '#types';
import {deepClone} from '../utils/deepClone';
import {
isArrayDataType,
resolveLoopOutputDataType,
@@ -27,7 +28,8 @@
fixedNumberMin,
fixedNumberMax,
acceptedContentTypes = [],
loopOutputAggregation = false
loopOutputAggregation = false,
onParametersChange
}: {
parameter: Parameter,
index: number,
@@ -38,6 +40,7 @@
fixedNumberMax?: number,
acceptedContentTypes?: string[],
loopOutputAggregation?: boolean,
onParametersChange?: ParameterChangeHandler,
} = $props();
@@ -54,13 +57,21 @@
const updateParams = (patch: Partial<Parameter>) => {
updateNodeData(currentNodeId, (node) => {
let parameters = node.data?.[dataKeyName] as Array<Parameter>;
parameters[index] = {
...parameters[index],
const previousParameters = deepClone(
(node.data?.[dataKeyName] as Array<Parameter>) || []
);
const nextParameters = deepClone(previousParameters);
nextParameters[index] = {
...nextParameters[index],
...patch
};
return {
[dataKeyName]: [...parameters]
[dataKeyName]: nextParameters,
...onParametersChange?.(
previousParameters,
nextParameters,
node.data
)
};
});
};
@@ -150,10 +161,18 @@
let triggerObject: any;
const handleDelete = () => {
updateNodeData(currentNodeId, (node) => {
let parameters = node.data?.[dataKeyName] as Array<Parameter>;
parameters.splice(index, 1);
const previousParameters = deepClone(
(node.data?.[dataKeyName] as Array<Parameter>) || []
);
const nextParameters = deepClone(previousParameters);
nextParameters.splice(index, 1);
return {
[dataKeyName]: [...parameters]
[dataKeyName]: nextParameters,
...onParametersChange?.(
previousParameters,
nextParameters,
node.data
)
};
});
triggerObject?.hide();

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import {useNodesData} from '@xyflow/svelte';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import type {ParameterChangeHandler} from '#types';
import RefParameterItem from './RefParameterItem.svelte';
const {
@@ -11,7 +12,8 @@
fixedNumberMin,
fixedNumberMax,
acceptedContentTypes = [],
loopOutputAggregation = false
loopOutputAggregation = false,
onParametersChange
}: {
noneParameterText?: string;
dataKeyName?: string;
@@ -21,6 +23,7 @@
fixedNumberMax?: number,
acceptedContentTypes?: string[],
loopOutputAggregation?: boolean,
onParametersChange?: ParameterChangeHandler,
} = $props();
let currentNodeId = getCurrentNodeId();
@@ -49,6 +52,7 @@
{fixedNumberMax}
{acceptedContentTypes}
{loopOutputAggregation}
{onParametersChange}
/>
{:else }
<div class="none-params">{noneParameterText}</div>

View File

@@ -8,9 +8,22 @@
import {getCurrentNodeId, getOptions} from '#components/utils/NodeUtils';
import {useAddParameter} from '../utils/useAddParameter.svelte';
import OutputDefList from '../core/OutputDefList.svelte';
import {onMount} from 'svelte';
import type {SelectItem, TinyflowNodeData} from '#types';
import {onDestroy, onMount} from 'svelte';
import type {
Parameter,
ParameterChangeHandler,
SelectItem,
TinyflowNodeData,
} from '#types';
import CodeScriptEditor from '../core/CodeScriptEditor.svelte';
import {genShortId} from '../utils/IdGen';
import {createCodeNodeScaffold} from '../utils/codeNodeScaffold';
import {
analyzeCodeNodeOutputs,
reconcileInferredCodeNodeOutputs,
syncCodeNodeInputParameters,
syncCodeNodeOutputDefinitions,
} from '../utils/codeNodeSync';
const { data, ...rest }: {
data: TinyflowNodeData,
@@ -22,32 +35,36 @@
let currentNode = useNodesData(currentNodeId);
const { addParameter } = useAddParameter();
const { updateNodeData } = useSvelteFlow();
let syncHint = $state('');
const codeNodeHelp = `代码如何返回结果
- JavaScript 定义 main 函数并返回对象Python 定义 main 函数并返回 dict系统会自动执行
- main 接收由输入参数组成的对象
- 输入参数会按名称传入 main例如输入参数 data 对应 main(data)
- JavaScript 的 main 返回对象Python 的 main 返回 dict
- 保留 _result = main(...),运行时会读取 _result 作为节点结果。
输出参数如何配置
- 在“输出参数”中新增字段(如 answer、score
- 字段名与 main 返回对象的 key 保持一致
- return 中的静态字段会自动补全到“输出参数”,并推断 String、Number、Boolean、File、Object、Array
- 在“输出参数”中新增或改名,也会同步 main 的 return 字段
- 下游节点可引用代码节点ID.输出参数名。
JavaScript 示例
function main({ input }) {
return { answer: input, score: 95 };
function main(data) {
return { answer: data, score: 95 };
}
_result = main(data);
Python 示例
def main(inputs):
return {'answer': inputs.get('input'), 'score': 95}
def main(data):
return {'answer': data, 'score': 95}
_result = main(data)
- 输出参数配置answer(String)、score(Number)
- 结束节点输出参数可引用代码节点ID.answer、代码节点ID.score
兼容说明
- 历史 JavaScript、Python 的 _result 写法仍然支持。`;
- 结束节点输出参数可引用代码节点ID.answer、代码节点ID.score`;
const nodeData = $derived.by(() => {
return (currentNode?.current?.data || data) as TinyflowNodeData;
});
const editorParameters = $derived.by(() => {
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
return (nodeData.parameters as Parameter[]) || [];
});
let engines = $state<SelectItem[]>([
@@ -58,30 +75,132 @@ Python 示例
return firstAvailable?.value || 'js';
});
const codePlaceholder = $derived.by(() => {
const engine = String(data.engine || defaultEngine).trim().toLowerCase();
const engine = String(nodeData.engine || defaultEngine).trim().toLowerCase();
if (engine === 'python' || engine === 'py') {
return "请输入代码例如def main(inputs):\n return {'output': inputs.get('input')}";
return "请输入代码例如def main(data):\n return {'output': data}\n\n_result = main(data)";
}
return '请输入代码例如function main({ input }) { return { output: input }; }';
return '请输入代码例如function main(data) { return { output: data }; }\n_result = main(data);';
});
const reconcileOutputs = (
code: string,
engine: string,
parameters: Parameter[],
outputDefs: Parameter[],
) => {
const inferred = analyzeCodeNodeOutputs(code, engine, parameters);
return inferred
? reconcileInferredCodeNodeOutputs(
outputDefs,
inferred,
genShortId
)
: outputDefs;
};
const handleInputParametersChange: ParameterChangeHandler = (
previousParameters,
nextParameters,
currentData,
) => {
const engine = String(currentData.engine || defaultEngine);
const syncResult = syncCodeNodeInputParameters(
String(currentData.code || ''),
engine,
previousParameters,
nextParameters,
);
syncHint = syncResult.synced ? '' : (syncResult.reason || '');
return {
code: syncResult.code,
parameters: syncResult.parameters,
outputDefs: reconcileOutputs(
syncResult.code,
engine,
syncResult.parameters,
(currentData.outputDefs as Parameter[]) || [],
),
};
};
const handleOutputDefinitionsChange: ParameterChangeHandler = (
previousParameters,
nextParameters,
currentData,
) => {
const syncResult = syncCodeNodeOutputDefinitions(
String(currentData.code || ''),
String(currentData.engine || defaultEngine),
previousParameters,
nextParameters,
);
syncHint = syncResult.synced ? '' : (syncResult.reason || '');
return {
code: syncResult.code,
outputDefs: syncResult.outputDefs,
};
};
let analysisTimer: ReturnType<typeof setTimeout> | undefined;
const scheduleOutputAnalysis = (code: string) => {
if (analysisTimer) {
clearTimeout(analysisTimer);
}
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[]) || [],
);
if (
JSON.stringify(outputDefs) ===
JSON.stringify(node.data.outputDefs || [])
) {
return {};
}
return { outputDefs };
});
}, 300);
};
const updateEngine = (engine: string) => {
syncHint = '';
updateNodeData(currentNodeId, (node) => {
const patch: TinyflowNodeData = { engine };
if (node.data.codeScaffoldManaged === true) {
patch.code = createCodeNodeScaffold(
engine,
(node.data.parameters as Parameter[]) || [],
(node.data.outputDefs as Parameter[]) || [],
);
}
return patch;
});
};
onMount(async () => {
const codeEngines = await options.provider?.codeEngine?.();
if (codeEngines && codeEngines.length > 0) {
engines = codeEngines;
}
const currentEngine = data.engine;
const currentEngine = nodeData.engine;
const currentEngineSupported = engines.some((item) => item.value === currentEngine && item.selectable !== false);
if (!currentEngine || !currentEngineSupported) {
updateNodeData(currentNodeId, () => {
return {
engine: defaultEngine
};
});
updateEngine(String(defaultEngine));
}
});
onDestroy(() => {
if (analysisTimer) {
clearTimeout(analysisTimer);
}
});
</script>
@@ -109,19 +228,14 @@ Python 示例
</svg>
</Button>
</div>
<RefParameterList />
<RefParameterList onParametersChange={handleInputParametersChange} />
<Heading level={3} mt="10px">代码</Heading>
<div class="setting-title">执行引擎</div>
<div class="setting-item">
<Select items={engines} style="width: 100%" placeholder="请选择执行引擎" onSelect={(item)=>{
const newValue = item.value;
updateNodeData(currentNodeId, ()=>{
return {
engine: newValue
}
})
}} value={data.engine ? [data.engine] : [defaultEngine]} />
updateEngine(String(item.value));
}} value={nodeData.engine ? [nodeData.engine] : [defaultEngine]} />
</div>
<div class="setting-title">执行代码</div>
@@ -131,18 +245,25 @@ Python 示例
rows={10}
placeholder={codePlaceholder}
style="width: 100%"
engine={(data.engine as string) || (defaultEngine as string)}
engine={(nodeData.engine as string) || (defaultEngine as string)}
parameters={editorParameters}
oninput={(e:any)=>{
const code = String(e.target.value || '');
syncHint = '';
updateNodeData(currentNodeId, ()=>{
return {
code: e.target.value
code,
codeScaffoldManaged: false
}
})
});
scheduleOutputAnalysis(code);
}}
value={data.code as string||""}
value={nodeData.code as string||""}
/>
</div>
{#if syncHint}
<div class="sync-hint" role="status">{syncHint}</div>
{/if}
<div class="heading">
@@ -155,7 +276,7 @@ Python 示例
</svg>
</Button>
</div>
<OutputDefList />
<OutputDefList onParametersChange={handleOutputDefinitionsChange} />
</NodeWrapper>
@@ -180,4 +301,11 @@ Python 示例
gap: 10px;
}
.sync-hint {
color: var(--tf-warning-soft-text);
font-size: 12px;
line-height: 18px;
margin: -4px 0 8px;
}
</style>

View File

@@ -0,0 +1,168 @@
import type { Parameter } from '#types';
export type CodeNodeEngine = 'javascript' | 'python';
const PYTHON_RESERVED_WORDS = new Set([
'False',
'None',
'True',
'and',
'as',
'assert',
'async',
'await',
'break',
'class',
'continue',
'def',
'del',
'elif',
'else',
'except',
'finally',
'for',
'from',
'global',
'if',
'import',
'in',
'is',
'lambda',
'nonlocal',
'not',
'or',
'pass',
'raise',
'return',
'try',
'while',
'with',
'yield',
]);
const JAVASCRIPT_RESERVED_WORDS = new Set([
'await',
'break',
'case',
'catch',
'class',
'const',
'continue',
'debugger',
'default',
'delete',
'do',
'else',
'enum',
'export',
'extends',
'false',
'finally',
'for',
'function',
'if',
'implements',
'import',
'in',
'instanceof',
'interface',
'let',
'new',
'null',
'package',
'private',
'protected',
'public',
'return',
'static',
'super',
'switch',
'this',
'throw',
'true',
'try',
'typeof',
'var',
'void',
'while',
'with',
'yield',
]);
/**
* 将代码节点执行引擎别名归一化为编辑器使用的语言名称。
*/
export function normalizeCodeNodeEngine(rawEngine?: string): CodeNodeEngine {
const normalized = (rawEngine || 'js').trim().toLowerCase();
return normalized === 'python' || normalized === 'py'
? 'python'
: 'javascript';
}
/**
* 判断参数名称能否安全写入目标语言的函数签名。
*/
export function isValidCodeNodeParameterName(name: string, rawEngine?: string) {
const engine = normalizeCodeNodeEngine(rawEngine);
if (engine === 'python') {
return /^[A-Za-z_]\w*$/.test(name) && !PYTHON_RESERVED_WORDS.has(name);
}
return (
/^[A-Za-z_$][\w$]*$/.test(name) && !JAVASCRIPT_RESERVED_WORDS.has(name)
);
}
/**
* 为新代码节点生成与当前运行时兼容的 main 脚手架。
*/
export function createCodeNodeScaffold(
rawEngine?: string,
parameters: Parameter[] = [],
outputDefs: Parameter[] = [],
) {
const engine = normalizeCodeNodeEngine(rawEngine);
const parameterNames = [
...new Set(
parameters
.map((parameter) => String(parameter.name || '').trim())
.filter((name) => isValidCodeNodeParameterName(name, engine)),
),
];
const outputNames = [
...new Set(
outputDefs
.map((parameter) => String(parameter.name || '').trim())
.filter(Boolean),
),
];
const argumentsText = parameterNames.join(', ');
if (engine === 'python') {
const outputLines = outputNames.map(
(name) => ` ${JSON.stringify(name)}: None,`,
);
return [
`def main(${argumentsText}):`,
' return {',
...outputLines,
' }',
'',
`_result = main(${argumentsText})`,
].join('\n');
}
const outputLines = outputNames.map(
(name) => ` ${JSON.stringify(name)}: null,`,
);
return [
`function main(${argumentsText}) {`,
' return {',
...outputLines,
' };',
'}',
'',
`_result = main(${argumentsText});`,
].join('\n');
}
export const DEFAULT_CODE_NODE_JAVASCRIPT = createCodeNodeScaffold('js');

View File

@@ -0,0 +1,544 @@
import { describe, expect, it } from 'vitest';
import type { Parameter } from '#types';
import { createCodeNodeScaffold } from './codeNodeScaffold';
import { getAvailableNodes } from './nodePalette';
import {
analyzeCodeNodeOutputs,
reconcileInferredCodeNodeOutputs,
syncCodeNodeInputParameters,
syncCodeNodeOutputDefinitions,
} from './codeNodeSync';
describe('code node scaffold', () => {
it('is attached to newly created code nodes', () => {
const codeNode = getAvailableNodes().find(
(node) => node.type === 'codeNode',
);
expect(codeNode?.extra).toMatchObject({
code: createCodeNodeScaffold('js'),
codeScaffoldManaged: true,
codeScaffoldVersion: 1,
engine: 'js',
});
});
it('creates an explicit javascript main call', () => {
expect(
createCodeNodeScaffold('js', [{ name: 'data' }], [{ name: 'result' }]),
).toBe(
[
'function main(data) {',
' return {',
' "result": null,',
' };',
'}',
'',
'_result = main(data);',
].join('\n'),
);
});
it('creates an explicit python main call', () => {
expect(
createCodeNodeScaffold(
'python',
[{ name: 'data' }],
[{ name: 'result' }],
),
).toBe(
[
'def main(data):',
' return {',
' "result": None,',
' }',
'',
'_result = main(data)',
].join('\n'),
);
});
it('does not generate invalid or duplicate function parameters', () => {
const code = createCodeNodeScaffold('python', [
{ name: 'data' },
{ name: 'class' },
{ name: 'data' },
]);
expect(code).toContain('def main(data):');
expect(code).toContain('_result = main(data)');
});
});
describe.each([
{
engine: 'js',
emptyCode: createCodeNodeScaffold('js'),
mainWithData: 'function main(data)',
callWithData: '_result = main(data);',
mainWithQuery: 'function main(query)',
callWithQuery: '_result = main(query);',
},
{
engine: 'python',
emptyCode: createCodeNodeScaffold('python'),
mainWithData: 'def main(data):',
callWithData: '_result = main(data)',
mainWithQuery: 'def main(query):',
callWithQuery: '_result = main(query)',
},
])('code node input sync for $engine', (fixture) => {
it('syncs a newly configured input name to main and its call', () => {
const blankParameter: Parameter = {
id: 'input-1',
name: '',
dataType: 'String',
};
const blankResult = syncCodeNodeInputParameters(
fixture.emptyCode,
fixture.engine,
[],
[blankParameter],
);
expect(blankResult.synced).toBe(false);
expect(blankResult.code).toBe(fixture.emptyCode);
const namedResult = syncCodeNodeInputParameters(
blankResult.code,
fixture.engine,
blankResult.parameters,
[{ ...blankResult.parameters[0], name: 'data' }],
);
expect(namedResult.synced).toBe(true);
expect(namedResult.code).toContain(fixture.mainWithData);
expect(namedResult.code).toContain(fixture.callWithData);
expect(namedResult.parameters[0].codeSyncName).toBe('data');
});
it('keeps the previous binding while the user clears and renames it', () => {
const initial = syncCodeNodeInputParameters(
fixture.emptyCode,
fixture.engine,
[],
[{ id: 'input-1', name: 'data', dataType: 'String' }],
);
const cleared = syncCodeNodeInputParameters(
initial.code,
fixture.engine,
initial.parameters,
[{ ...initial.parameters[0], name: '' }],
);
const renamed = syncCodeNodeInputParameters(
cleared.code,
fixture.engine,
cleared.parameters,
[{ ...cleared.parameters[0], name: 'query' }],
);
expect(cleared.synced).toBe(false);
expect(cleared.parameters[0].codeSyncName).toBe('data');
expect(renamed.synced).toBe(true);
expect(renamed.code).toContain(fixture.mainWithQuery);
expect(renamed.code).toContain(fixture.callWithQuery);
});
it('does not overwrite a user-managed main signature', () => {
const initial = syncCodeNodeInputParameters(
fixture.emptyCode,
fixture.engine,
[],
[{ id: 'input-1', name: 'data' }],
);
const manualCode = initial.code.replace(
fixture.mainWithData,
fixture.engine === 'python'
? 'def main(custom):'
: 'function main(custom)',
);
const result = syncCodeNodeInputParameters(
manualCode,
fixture.engine,
initial.parameters,
[{ ...initial.parameters[0], name: 'query' }],
);
expect(result.synced).toBe(false);
expect(result.code).toBe(manualCode);
});
it('does not partially update multiple explicit main calls', () => {
const initial = syncCodeNodeInputParameters(
fixture.emptyCode,
fixture.engine,
[],
[{ id: 'input-1', name: 'data' }],
);
const duplicatedCall = [
initial.code,
fixture.engine === 'python'
? '_result = main(data)'
: '_result = main(data);',
].join('\n');
const result = syncCodeNodeInputParameters(
duplicatedCall,
fixture.engine,
initial.parameters,
[{ ...initial.parameters[0], name: 'query' }],
);
expect(result.synced).toBe(false);
expect(result.code).toBe(duplicatedCall);
});
it('syncs input ordering and deletion', () => {
const initial = syncCodeNodeInputParameters(
fixture.emptyCode,
fixture.engine,
[],
[
{ id: 'input-1', name: 'data' },
{ id: 'input-2', name: 'question' },
],
);
const reordered = syncCodeNodeInputParameters(
initial.code,
fixture.engine,
initial.parameters,
[initial.parameters[1], initial.parameters[0]],
);
const deleted = syncCodeNodeInputParameters(
reordered.code,
fixture.engine,
reordered.parameters,
[reordered.parameters[0]],
);
expect(reordered.code).toContain('main(question, data)');
expect(deleted.code).toContain('main(question)');
expect(deleted.code).not.toContain('main(question, data)');
});
});
describe.each([
{
engine: 'js',
emptyCode: createCodeNodeScaffold('js'),
expectedEntry: '"answer": null',
},
{
engine: 'python',
emptyCode: createCodeNodeScaffold('python'),
expectedEntry: '"answer": None',
},
])('code node output sync for $engine', (fixture) => {
it('adds a configured output name to the return object', () => {
const blankOutput: Parameter = {
id: 'output-1',
name: '',
dataType: 'String',
};
const blankResult = syncCodeNodeOutputDefinitions(
fixture.emptyCode,
fixture.engine,
[],
[blankOutput],
);
const namedResult = syncCodeNodeOutputDefinitions(
blankResult.code,
fixture.engine,
blankResult.outputDefs,
[{ ...blankResult.outputDefs[0], name: 'answer' }],
);
expect(namedResult.synced).toBe(true);
expect(namedResult.code).toContain(fixture.expectedEntry);
expect(namedResult.outputDefs[0].codeSyncName).toBe('answer');
});
it('renames the matching return key', () => {
const initial = syncCodeNodeOutputDefinitions(
fixture.emptyCode,
fixture.engine,
[],
[{ id: 'output-1', name: 'answer', dataType: 'String' }],
);
const renamed = syncCodeNodeOutputDefinitions(
initial.code,
fixture.engine,
initial.outputDefs,
[{ ...initial.outputDefs[0], name: 'message' }],
);
expect(renamed.synced).toBe(true);
expect(renamed.code).toContain(
fixture.engine === 'python' ? '"message": None' : '"message": null',
);
expect(renamed.code).not.toContain(fixture.expectedEntry);
});
it('adds the renamed key when the previous return key is absent', () => {
const renamed = syncCodeNodeOutputDefinitions(
fixture.emptyCode,
fixture.engine,
[
{
id: 'output-1',
name: 'answer',
dataType: 'String',
codeSyncName: 'answer',
},
],
[
{
id: 'output-1',
name: 'message',
dataType: 'String',
codeSyncName: 'answer',
},
],
);
expect(renamed.code).toContain(
fixture.engine === 'python' ? '"message": None' : '"message": null',
);
});
});
describe('code node output inference', () => {
it.each([
{
engine: 'js',
code: [
'function main(data) {',
' const answer = data;',
' return { answer };',
'}',
'_result = main(data);',
].join('\n'),
},
{
engine: 'python',
code: [
'def main(data):',
' answer = data',
' return {"answer": answer}',
'_result = main(data)',
].join('\n'),
},
])(
'preserves the return expression when renaming for $engine',
({ code, engine }) => {
const renamed = syncCodeNodeOutputDefinitions(
code,
engine,
[
{
id: 'output-1',
name: 'answer',
dataType: 'String',
codeSyncName: 'answer',
},
],
[
{
id: 'output-1',
name: 'message',
dataType: 'String',
codeSyncName: 'answer',
},
],
);
expect(renamed.code).toContain('"message": answer');
},
);
it('infers the python return fields used by the executable example', () => {
const code = [
'import json',
'def main(data):',
' r1 = data + "111"',
' r2 = "222"',
' return {',
' "r1": r1,',
' "r2": r2,',
' }',
'_result = main(data)',
].join('\n');
expect(
analyzeCodeNodeOutputs(code, 'python', [
{ name: 'data', dataType: 'String' },
]),
).toEqual([
{ name: 'r1', dataType: 'String' },
{ name: 'r2', dataType: 'String' },
]);
});
it('infers supported python primitive and container types', () => {
const code = [
'def main(data, count, file):',
' text = f"{data}-ok"',
' total = count + 1',
' return {',
' "text": text,',
' "total": total,',
' "ok": True,',
' "file": file,',
' "objectValue": {},',
' "arrayValue": [],',
' }',
'_result = main(data, count, file)',
].join('\n');
expect(
analyzeCodeNodeOutputs(code, 'python', [
{ name: 'data', dataType: 'String' },
{ name: 'count', dataType: 'Number' },
{ name: 'file', dataType: 'File' },
]),
).toEqual([
{ name: 'text', dataType: 'String' },
{ name: 'total', dataType: 'Number' },
{ name: 'ok', dataType: 'Boolean' },
{ name: 'file', dataType: 'File' },
{ name: 'objectValue', dataType: 'Object' },
{ name: 'arrayValue', dataType: 'Array' },
]);
});
it('infers supported javascript primitive and container types', () => {
const code = [
'function main(data, count, file) {',
' const text = data + "x";',
' const total = count + 1;',
' return {',
' text,',
' total,',
' ok: true,',
' file,',
' objectValue: {},',
' arrayValue: [],',
' };',
'}',
'_result = main(data, count, file);',
].join('\n');
expect(
analyzeCodeNodeOutputs(code, 'js', [
{ name: 'data', dataType: 'String' },
{ name: 'count', dataType: 'Number' },
{ name: 'file', dataType: 'File' },
]),
).toEqual([
{ name: 'text', dataType: 'String' },
{ name: 'total', dataType: 'Number' },
{ name: 'ok', dataType: 'Boolean' },
{ name: 'file', dataType: 'File' },
{ name: 'objectValue', dataType: 'Object' },
{ name: 'arrayValue', dataType: 'Array' },
]);
});
it('falls back to String for a static key with a dynamic value', () => {
const code = [
'function main(data) {',
' return { answer: externalCall(data) };',
'}',
'_result = main(data);',
].join('\n');
expect(analyzeCodeNodeOutputs(code, 'js', [])).toEqual([
{ name: 'answer', dataType: 'String' },
]);
});
it('does not infer assignment types across complex control flow', () => {
const code = [
'function main(data) {',
' let value = 1;',
' if (data) {',
' value = true;',
' }',
' return { value };',
'}',
'_result = main(data);',
].join('\n');
expect(analyzeCodeNodeOutputs(code, 'js', [])).toEqual([
{ name: 'value', dataType: 'String' },
]);
});
it.each([
{
engine: 'js',
code: [
'function main(data) {',
' return { fixed: 1, ...data };',
'}',
'_result = main(data);',
].join('\n'),
},
{
engine: 'python',
code: [
'def main(data):',
' return {"fixed": 1, **data}',
'_result = main(data)',
].join('\n'),
},
])(
'does not infer fields from a spread return object for $engine',
({ code, engine }) => {
expect(analyzeCodeNodeOutputs(code, engine, [])).toBeNull();
},
);
it('preserves manual output types and removes stale inferred fields', () => {
const outputs = reconcileInferredCodeNodeOutputs(
[
{
id: 'manualized',
name: 'answer',
dataType: 'Number',
codeInferred: true,
codeInferredDataType: 'String',
},
{
id: 'stale',
name: 'oldField',
dataType: 'String',
codeInferred: true,
codeInferredDataType: 'String',
},
],
[
{ name: 'answer', dataType: 'String' },
{ name: 'newField', dataType: 'Boolean' },
],
() => 'generated',
);
expect(outputs).toEqual([
{
id: 'manualized',
name: 'answer',
dataType: 'Number',
},
{
id: 'generated',
name: 'newField',
dataType: 'Boolean',
codeInferred: true,
codeInferredDataType: 'Boolean',
codeSyncName: 'newField',
},
]);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@ import type { Node } from '@xyflow/svelte';
import type { TinyflowOptions } from '#types';
import { DEFAULT_CODE_NODE_JAVASCRIPT } from './codeNodeScaffold';
export type NodePaletteItem = {
icon?: string;
title: string;
@@ -76,6 +78,12 @@ const BUILT_IN_NODES: NodePaletteItem[] = [
sortNo: 700,
description: '动态执行代码',
category: '逻辑',
extra: {
code: DEFAULT_CODE_NODE_JAVASCRIPT,
codeScaffoldManaged: true,
codeScaffoldVersion: 1,
engine: 'js',
},
},
{
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M2 4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V20C22 20.5523 21.5523 21 21 21H3C2.44772 21 2 20.5523 2 20V4ZM4 5V19H20V5H4ZM7 8H17V11H15V10H13V14H14.5V16H9.5V14H11V10H9V11H7V8Z"></path></svg>',

View File

@@ -135,10 +135,7 @@ export type TinyflowOptions = {
onRunTest?: () => void | Promise<void>;
hiddenNodes?: string[] | (() => string[]);
onDataChange?: (data: TinyflowData) => void;
onDataCommit?: (
data: TinyflowData,
reason: TinyflowDataCommitReason,
) => void;
onDataCommit?: (data: TinyflowData, reason: TinyflowDataCommitReason) => void;
};
export type Parameter = {
@@ -171,5 +168,14 @@ export type Parameter = {
requiredDisabled?: boolean;
systemReserved?: boolean;
autoManaged?: boolean;
codeInferred?: boolean;
codeInferredDataType?: string;
codeSyncName?: string;
flattenAggregation?: boolean;
};
export type ParameterChangeHandler = (
previousParameters: Parameter[],
nextParameters: Parameter[],
nodeData: TinyflowNodeData,
) => Partial<TinyflowNodeData> | void;