发布 v1.10 #5
@@ -5,14 +5,21 @@
|
|||||||
import {useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
import {useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
||||||
import {parameterDataTypes} from '#consts';
|
import {parameterDataTypes} from '#consts';
|
||||||
import {genShortId} from '../utils/IdGen';
|
import {genShortId} from '../utils/IdGen';
|
||||||
import type {Parameter} from '#types';
|
import type {Parameter, ParameterChangeHandler} from '#types';
|
||||||
import {deepClone} from '../utils/deepClone';
|
import {deepClone} from '../utils/deepClone';
|
||||||
|
|
||||||
const { parameter, position, dataKeyName, placeholder = '请输入参数值' }: {
|
const {
|
||||||
|
parameter,
|
||||||
|
position,
|
||||||
|
dataKeyName,
|
||||||
|
placeholder = '请输入参数值',
|
||||||
|
onParametersChange,
|
||||||
|
}: {
|
||||||
parameter: Parameter,
|
parameter: Parameter,
|
||||||
position: number[],
|
position: number[],
|
||||||
dataKeyName: string,
|
dataKeyName: string,
|
||||||
placeholder?: string,
|
placeholder?: string,
|
||||||
|
onParametersChange?: ParameterChangeHandler,
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
|
||||||
@@ -50,9 +57,12 @@
|
|||||||
|
|
||||||
const updateAttribute = (key: string, value: any) => {
|
const updateAttribute = (key: string, value: any) => {
|
||||||
updateNodeData(currentNodeId, (node) => {
|
updateNodeData(currentNodeId, (node) => {
|
||||||
const parameters = node.data?.[dataKeyName] as Array<Parameter>;
|
const previousParameters = deepClone(
|
||||||
if (parameters && position.length > 0) {
|
(node.data?.[dataKeyName] as Array<Parameter>) || []
|
||||||
let params = parameters as Parameter[];
|
);
|
||||||
|
const nextParameters = deepClone(previousParameters);
|
||||||
|
if (nextParameters.length > 0 && position.length > 0) {
|
||||||
|
let params = nextParameters;
|
||||||
for (let i = 0; i < position.length; i++) {
|
for (let i = 0; i < position.length; i++) {
|
||||||
const pos = position[i];
|
const pos = position[i];
|
||||||
if (i == position.length - 1) {
|
if (i == position.length - 1) {
|
||||||
@@ -66,7 +76,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
[dataKeyName]: [...deepClone(parameters)]
|
[dataKeyName]: nextParameters,
|
||||||
|
...onParametersChange?.(
|
||||||
|
previousParameters,
|
||||||
|
nextParameters,
|
||||||
|
node.data
|
||||||
|
)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -87,9 +102,12 @@
|
|||||||
let triggerObject: any;
|
let triggerObject: any;
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
updateNodeData(currentNodeId, (node) => {
|
updateNodeData(currentNodeId, (node) => {
|
||||||
let parameters = node.data?.[dataKeyName] as Array<Parameter>;
|
const previousParameters = deepClone(
|
||||||
if (parameters && position.length > 0) {
|
(node.data?.[dataKeyName] as Array<Parameter>) || []
|
||||||
let params = parameters 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++) {
|
for (let i = 0; i < position.length; i++) {
|
||||||
const pos = position[i];
|
const pos = position[i];
|
||||||
if (i == position.length - 1) {
|
if (i == position.length - 1) {
|
||||||
@@ -100,7 +118,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
[dataKeyName]: [...deepClone(parameters)]
|
[dataKeyName]: nextParameters,
|
||||||
|
...onParametersChange?.(
|
||||||
|
previousParameters,
|
||||||
|
nextParameters,
|
||||||
|
node.data
|
||||||
|
)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
triggerObject?.hide();
|
triggerObject?.hide();
|
||||||
@@ -109,9 +132,12 @@
|
|||||||
|
|
||||||
const handleAddChildParameter = () => {
|
const handleAddChildParameter = () => {
|
||||||
updateNodeData(currentNodeId, (node) => {
|
updateNodeData(currentNodeId, (node) => {
|
||||||
let parameters = node.data?.[dataKeyName] as Array<Parameter>;
|
const previousParameters = deepClone(
|
||||||
if (parameters && position.length > 0) {
|
(node.data?.[dataKeyName] as Array<Parameter>) || []
|
||||||
let params = parameters 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++) {
|
for (let i = 0; i < position.length; i++) {
|
||||||
const pos = position[i];
|
const pos = position[i];
|
||||||
if (i == position.length - 1) {
|
if (i == position.length - 1) {
|
||||||
@@ -137,7 +163,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
[dataKeyName]: [...deepClone(parameters)]
|
[dataKeyName]: nextParameters,
|
||||||
|
...onParametersChange?.(
|
||||||
|
previousParameters,
|
||||||
|
nextParameters,
|
||||||
|
node.data
|
||||||
|
)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {useNodesData} from '@xyflow/svelte';
|
import {useNodesData} from '@xyflow/svelte';
|
||||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||||
import type {Parameter} from '#types';
|
import type {Parameter, ParameterChangeHandler} from '#types';
|
||||||
import OutputDefItem from './OutputDefItem.svelte';
|
import OutputDefItem from './OutputDefItem.svelte';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
noneParameterText = '无输出参数',
|
noneParameterText = '无输出参数',
|
||||||
dataKeyName = 'outputDefs',
|
dataKeyName = 'outputDefs',
|
||||||
placeholder = '请输入参数名称',
|
placeholder = '请输入参数名称',
|
||||||
|
onParametersChange,
|
||||||
}: {
|
}: {
|
||||||
noneParameterText?: string;
|
noneParameterText?: string;
|
||||||
dataKeyName?: string;
|
dataKeyName?: string;
|
||||||
placeholder?: string
|
placeholder?: string;
|
||||||
|
onParametersChange?: ParameterChangeHandler;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let currentNodeId = getCurrentNodeId();
|
let currentNodeId = getCurrentNodeId();
|
||||||
@@ -24,7 +26,13 @@
|
|||||||
|
|
||||||
{#snippet parameterList(params: Parameter[], position: number[])}
|
{#snippet parameterList(params: Parameter[], position: number[])}
|
||||||
{#each params as param, index (`${param.id}_${param.children ? param.children.length : 0}`)}
|
{#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}
|
{#if param.children}
|
||||||
{@render parameterList(param.children, [...position, index])}
|
{@render parameterList(param.children, [...position, index])}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
import {contentTypes, parameterRefTypes} from '#consts';
|
import {contentTypes, parameterRefTypes} from '#consts';
|
||||||
import {useRefOptions} from '../utils/useRefOptions.svelte';
|
import {useRefOptions} from '../utils/useRefOptions.svelte';
|
||||||
import {onMount} from 'svelte';
|
import {onMount} from 'svelte';
|
||||||
import type {Parameter} from '#types';
|
import type {Parameter, ParameterChangeHandler} from '#types';
|
||||||
|
import {deepClone} from '../utils/deepClone';
|
||||||
import {
|
import {
|
||||||
isArrayDataType,
|
isArrayDataType,
|
||||||
resolveLoopOutputDataType,
|
resolveLoopOutputDataType,
|
||||||
@@ -27,7 +28,8 @@
|
|||||||
fixedNumberMin,
|
fixedNumberMin,
|
||||||
fixedNumberMax,
|
fixedNumberMax,
|
||||||
acceptedContentTypes = [],
|
acceptedContentTypes = [],
|
||||||
loopOutputAggregation = false
|
loopOutputAggregation = false,
|
||||||
|
onParametersChange
|
||||||
}: {
|
}: {
|
||||||
parameter: Parameter,
|
parameter: Parameter,
|
||||||
index: number,
|
index: number,
|
||||||
@@ -38,6 +40,7 @@
|
|||||||
fixedNumberMax?: number,
|
fixedNumberMax?: number,
|
||||||
acceptedContentTypes?: string[],
|
acceptedContentTypes?: string[],
|
||||||
loopOutputAggregation?: boolean,
|
loopOutputAggregation?: boolean,
|
||||||
|
onParametersChange?: ParameterChangeHandler,
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
|
||||||
@@ -54,13 +57,21 @@
|
|||||||
|
|
||||||
const updateParams = (patch: Partial<Parameter>) => {
|
const updateParams = (patch: Partial<Parameter>) => {
|
||||||
updateNodeData(currentNodeId, (node) => {
|
updateNodeData(currentNodeId, (node) => {
|
||||||
let parameters = node.data?.[dataKeyName] as Array<Parameter>;
|
const previousParameters = deepClone(
|
||||||
parameters[index] = {
|
(node.data?.[dataKeyName] as Array<Parameter>) || []
|
||||||
...parameters[index],
|
);
|
||||||
|
const nextParameters = deepClone(previousParameters);
|
||||||
|
nextParameters[index] = {
|
||||||
|
...nextParameters[index],
|
||||||
...patch
|
...patch
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
[dataKeyName]: [...parameters]
|
[dataKeyName]: nextParameters,
|
||||||
|
...onParametersChange?.(
|
||||||
|
previousParameters,
|
||||||
|
nextParameters,
|
||||||
|
node.data
|
||||||
|
)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -150,10 +161,18 @@
|
|||||||
let triggerObject: any;
|
let triggerObject: any;
|
||||||
const handleDelete = () => {
|
const handleDelete = () => {
|
||||||
updateNodeData(currentNodeId, (node) => {
|
updateNodeData(currentNodeId, (node) => {
|
||||||
let parameters = node.data?.[dataKeyName] as Array<Parameter>;
|
const previousParameters = deepClone(
|
||||||
parameters.splice(index, 1);
|
(node.data?.[dataKeyName] as Array<Parameter>) || []
|
||||||
|
);
|
||||||
|
const nextParameters = deepClone(previousParameters);
|
||||||
|
nextParameters.splice(index, 1);
|
||||||
return {
|
return {
|
||||||
[dataKeyName]: [...parameters]
|
[dataKeyName]: nextParameters,
|
||||||
|
...onParametersChange?.(
|
||||||
|
previousParameters,
|
||||||
|
nextParameters,
|
||||||
|
node.data
|
||||||
|
)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
triggerObject?.hide();
|
triggerObject?.hide();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {useNodesData} from '@xyflow/svelte';
|
import {useNodesData} from '@xyflow/svelte';
|
||||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||||
|
import type {ParameterChangeHandler} from '#types';
|
||||||
import RefParameterItem from './RefParameterItem.svelte';
|
import RefParameterItem from './RefParameterItem.svelte';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -11,7 +12,8 @@
|
|||||||
fixedNumberMin,
|
fixedNumberMin,
|
||||||
fixedNumberMax,
|
fixedNumberMax,
|
||||||
acceptedContentTypes = [],
|
acceptedContentTypes = [],
|
||||||
loopOutputAggregation = false
|
loopOutputAggregation = false,
|
||||||
|
onParametersChange
|
||||||
}: {
|
}: {
|
||||||
noneParameterText?: string;
|
noneParameterText?: string;
|
||||||
dataKeyName?: string;
|
dataKeyName?: string;
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
fixedNumberMax?: number,
|
fixedNumberMax?: number,
|
||||||
acceptedContentTypes?: string[],
|
acceptedContentTypes?: string[],
|
||||||
loopOutputAggregation?: boolean,
|
loopOutputAggregation?: boolean,
|
||||||
|
onParametersChange?: ParameterChangeHandler,
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let currentNodeId = getCurrentNodeId();
|
let currentNodeId = getCurrentNodeId();
|
||||||
@@ -49,6 +52,7 @@
|
|||||||
{fixedNumberMax}
|
{fixedNumberMax}
|
||||||
{acceptedContentTypes}
|
{acceptedContentTypes}
|
||||||
{loopOutputAggregation}
|
{loopOutputAggregation}
|
||||||
|
{onParametersChange}
|
||||||
/>
|
/>
|
||||||
{:else }
|
{:else }
|
||||||
<div class="none-params">{noneParameterText}</div>
|
<div class="none-params">{noneParameterText}</div>
|
||||||
|
|||||||
@@ -8,9 +8,22 @@
|
|||||||
import {getCurrentNodeId, getOptions} from '#components/utils/NodeUtils';
|
import {getCurrentNodeId, getOptions} from '#components/utils/NodeUtils';
|
||||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||||
import OutputDefList from '../core/OutputDefList.svelte';
|
import OutputDefList from '../core/OutputDefList.svelte';
|
||||||
import {onMount} from 'svelte';
|
import {onDestroy, onMount} from 'svelte';
|
||||||
import type {SelectItem, TinyflowNodeData} from '#types';
|
import type {
|
||||||
|
Parameter,
|
||||||
|
ParameterChangeHandler,
|
||||||
|
SelectItem,
|
||||||
|
TinyflowNodeData,
|
||||||
|
} from '#types';
|
||||||
import CodeScriptEditor from '../core/CodeScriptEditor.svelte';
|
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 }: {
|
const { data, ...rest }: {
|
||||||
data: TinyflowNodeData,
|
data: TinyflowNodeData,
|
||||||
@@ -22,32 +35,36 @@
|
|||||||
let currentNode = useNodesData(currentNodeId);
|
let currentNode = useNodesData(currentNodeId);
|
||||||
const { addParameter } = useAddParameter();
|
const { addParameter } = useAddParameter();
|
||||||
const { updateNodeData } = useSvelteFlow();
|
const { updateNodeData } = useSvelteFlow();
|
||||||
|
let syncHint = $state('');
|
||||||
const codeNodeHelp = `代码如何返回结果
|
const codeNodeHelp = `代码如何返回结果
|
||||||
- JavaScript 定义 main 函数并返回对象;Python 定义 main 函数并返回 dict,系统会自动执行。
|
- 输入参数会按名称传入 main,例如输入参数 data 对应 main(data)。
|
||||||
- main 接收由输入参数组成的对象。
|
- JavaScript 的 main 返回对象;Python 的 main 返回 dict。
|
||||||
|
- 保留 _result = main(...),运行时会读取 _result 作为节点结果。
|
||||||
|
|
||||||
输出参数如何配置
|
输出参数如何配置
|
||||||
- 在“输出参数”中新增字段(如 answer、score)。
|
- return 中的静态字段会自动补全到“输出参数”,并推断 String、Number、Boolean、File、Object、Array。
|
||||||
- 字段名与 main 返回对象的 key 保持一致。
|
- 在“输出参数”中新增或改名,也会同步 main 的 return 字段。
|
||||||
- 下游节点可引用:代码节点ID.输出参数名。
|
- 下游节点可引用:代码节点ID.输出参数名。
|
||||||
|
|
||||||
JavaScript 示例
|
JavaScript 示例
|
||||||
function main({ input }) {
|
function main(data) {
|
||||||
return { answer: input, score: 95 };
|
return { answer: data, score: 95 };
|
||||||
}
|
}
|
||||||
|
_result = main(data);
|
||||||
|
|
||||||
Python 示例
|
Python 示例
|
||||||
def main(inputs):
|
def main(data):
|
||||||
return {'answer': inputs.get('input'), 'score': 95}
|
return {'answer': data, 'score': 95}
|
||||||
|
_result = main(data)
|
||||||
|
|
||||||
- 输出参数配置:answer(String)、score(Number)
|
- 输出参数配置:answer(String)、score(Number)
|
||||||
- 结束节点输出参数可引用:代码节点ID.answer、代码节点ID.score
|
- 结束节点输出参数可引用:代码节点ID.answer、代码节点ID.score`;
|
||||||
|
|
||||||
兼容说明
|
|
||||||
- 历史 JavaScript、Python 的 _result 写法仍然支持。`;
|
|
||||||
|
|
||||||
|
const nodeData = $derived.by(() => {
|
||||||
|
return (currentNode?.current?.data || data) as TinyflowNodeData;
|
||||||
|
});
|
||||||
const editorParameters = $derived.by(() => {
|
const editorParameters = $derived.by(() => {
|
||||||
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
|
return (nodeData.parameters as Parameter[]) || [];
|
||||||
});
|
});
|
||||||
|
|
||||||
let engines = $state<SelectItem[]>([
|
let engines = $state<SelectItem[]>([
|
||||||
@@ -58,30 +75,132 @@ Python 示例
|
|||||||
return firstAvailable?.value || 'js';
|
return firstAvailable?.value || 'js';
|
||||||
});
|
});
|
||||||
const codePlaceholder = $derived.by(() => {
|
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') {
|
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 () => {
|
onMount(async () => {
|
||||||
const codeEngines = await options.provider?.codeEngine?.();
|
const codeEngines = await options.provider?.codeEngine?.();
|
||||||
if (codeEngines && codeEngines.length > 0) {
|
if (codeEngines && codeEngines.length > 0) {
|
||||||
engines = codeEngines;
|
engines = codeEngines;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentEngine = data.engine;
|
const currentEngine = nodeData.engine;
|
||||||
const currentEngineSupported = engines.some((item) => item.value === currentEngine && item.selectable !== false);
|
const currentEngineSupported = engines.some((item) => item.value === currentEngine && item.selectable !== false);
|
||||||
if (!currentEngine || !currentEngineSupported) {
|
if (!currentEngine || !currentEngineSupported) {
|
||||||
updateNodeData(currentNodeId, () => {
|
updateEngine(String(defaultEngine));
|
||||||
return {
|
|
||||||
engine: defaultEngine
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (analysisTimer) {
|
||||||
|
clearTimeout(analysisTimer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -109,19 +228,14 @@ Python 示例
|
|||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<RefParameterList />
|
<RefParameterList onParametersChange={handleInputParametersChange} />
|
||||||
|
|
||||||
<Heading level={3} mt="10px">代码</Heading>
|
<Heading level={3} mt="10px">代码</Heading>
|
||||||
<div class="setting-title">执行引擎</div>
|
<div class="setting-title">执行引擎</div>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<Select items={engines} style="width: 100%" placeholder="请选择执行引擎" onSelect={(item)=>{
|
<Select items={engines} style="width: 100%" placeholder="请选择执行引擎" onSelect={(item)=>{
|
||||||
const newValue = item.value;
|
updateEngine(String(item.value));
|
||||||
updateNodeData(currentNodeId, ()=>{
|
}} value={nodeData.engine ? [nodeData.engine] : [defaultEngine]} />
|
||||||
return {
|
|
||||||
engine: newValue
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}} value={data.engine ? [data.engine] : [defaultEngine]} />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="setting-title">执行代码</div>
|
<div class="setting-title">执行代码</div>
|
||||||
@@ -131,18 +245,25 @@ Python 示例
|
|||||||
rows={10}
|
rows={10}
|
||||||
placeholder={codePlaceholder}
|
placeholder={codePlaceholder}
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
engine={(data.engine as string) || (defaultEngine as string)}
|
engine={(nodeData.engine as string) || (defaultEngine as string)}
|
||||||
parameters={editorParameters}
|
parameters={editorParameters}
|
||||||
oninput={(e:any)=>{
|
oninput={(e:any)=>{
|
||||||
|
const code = String(e.target.value || '');
|
||||||
|
syncHint = '';
|
||||||
updateNodeData(currentNodeId, ()=>{
|
updateNodeData(currentNodeId, ()=>{
|
||||||
return {
|
return {
|
||||||
code: e.target.value
|
code,
|
||||||
|
codeScaffoldManaged: false
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
scheduleOutputAnalysis(code);
|
||||||
}}
|
}}
|
||||||
value={data.code as string||""}
|
value={nodeData.code as string||""}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{#if syncHint}
|
||||||
|
<div class="sync-hint" role="status">{syncHint}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
<div class="heading">
|
<div class="heading">
|
||||||
@@ -155,7 +276,7 @@ Python 示例
|
|||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<OutputDefList />
|
<OutputDefList onParametersChange={handleOutputDefinitionsChange} />
|
||||||
|
|
||||||
</NodeWrapper>
|
</NodeWrapper>
|
||||||
|
|
||||||
@@ -180,4 +301,11 @@ Python 示例
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sync-hint {
|
||||||
|
color: var(--tf-warning-soft-text);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 18px;
|
||||||
|
margin: -4px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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
@@ -2,6 +2,8 @@ import type { Node } from '@xyflow/svelte';
|
|||||||
|
|
||||||
import type { TinyflowOptions } from '#types';
|
import type { TinyflowOptions } from '#types';
|
||||||
|
|
||||||
|
import { DEFAULT_CODE_NODE_JAVASCRIPT } from './codeNodeScaffold';
|
||||||
|
|
||||||
export type NodePaletteItem = {
|
export type NodePaletteItem = {
|
||||||
icon?: string;
|
icon?: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -76,6 +78,12 @@ const BUILT_IN_NODES: NodePaletteItem[] = [
|
|||||||
sortNo: 700,
|
sortNo: 700,
|
||||||
description: '动态执行代码',
|
description: '动态执行代码',
|
||||||
category: '逻辑',
|
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>',
|
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>',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type {Snippet} from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import type {Node, useSvelteFlow} from '@xyflow/svelte';
|
import type { Node, useSvelteFlow } from '@xyflow/svelte';
|
||||||
|
|
||||||
export type TinyflowData = Partial<
|
export type TinyflowData = Partial<
|
||||||
ReturnType<ReturnType<typeof useSvelteFlow>['toObject']>
|
ReturnType<ReturnType<typeof useSvelteFlow>['toObject']>
|
||||||
@@ -135,10 +135,7 @@ export type TinyflowOptions = {
|
|||||||
onRunTest?: () => void | Promise<void>;
|
onRunTest?: () => void | Promise<void>;
|
||||||
hiddenNodes?: string[] | (() => string[]);
|
hiddenNodes?: string[] | (() => string[]);
|
||||||
onDataChange?: (data: TinyflowData) => void;
|
onDataChange?: (data: TinyflowData) => void;
|
||||||
onDataCommit?: (
|
onDataCommit?: (data: TinyflowData, reason: TinyflowDataCommitReason) => void;
|
||||||
data: TinyflowData,
|
|
||||||
reason: TinyflowDataCommitReason,
|
|
||||||
) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Parameter = {
|
export type Parameter = {
|
||||||
@@ -171,5 +168,14 @@ export type Parameter = {
|
|||||||
requiredDisabled?: boolean;
|
requiredDisabled?: boolean;
|
||||||
systemReserved?: boolean;
|
systemReserved?: boolean;
|
||||||
autoManaged?: boolean;
|
autoManaged?: boolean;
|
||||||
|
codeInferred?: boolean;
|
||||||
|
codeInferredDataType?: string;
|
||||||
|
codeSyncName?: string;
|
||||||
flattenAggregation?: boolean;
|
flattenAggregation?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ParameterChangeHandler = (
|
||||||
|
previousParameters: Parameter[],
|
||||||
|
nextParameters: Parameter[],
|
||||||
|
nodeData: TinyflowNodeData,
|
||||||
|
) => Partial<TinyflowNodeData> | void;
|
||||||
|
|||||||
Reference in New Issue
Block a user