feat: 优化工作流字段化参数配置
- 开始节点固定 user_input 并区分系统入口与自定义参数 - LLM 与知识库节点切换为字段值加上游引用配置 - 单节点调试改为字段预览与上游引用输入模式
This commit is contained in:
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router';
|
||||
|
||||
import { usePreferences } from '@easyflow/preferences';
|
||||
import { getOptions, sortNodes } from '@easyflow/utils';
|
||||
import { Tinyflow } from '@tinyflow-ai/vue';
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -11,7 +12,6 @@ import {
|
||||
Close,
|
||||
Promotion,
|
||||
} from '@element-plus/icons-vue';
|
||||
import { Tinyflow } from '@tinyflow-ai/vue';
|
||||
import {
|
||||
ElButton,
|
||||
ElDrawer,
|
||||
@@ -37,6 +37,11 @@ import WorkflowSteps from '#/views/ai/workflow/components/WorkflowSteps.vue';
|
||||
|
||||
import { getCustomNode } from './customNode/index';
|
||||
import nodeNames from './customNode/nodeNames';
|
||||
import {
|
||||
createInitialWorkflowData,
|
||||
isWorkflowDataEmpty,
|
||||
normalizeWorkflowStartNodes,
|
||||
} from '../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
|
||||
|
||||
import '@tinyflow-ai/vue/dist/index.css';
|
||||
|
||||
@@ -348,9 +353,10 @@ async function handleSave(showMsg: boolean = false): Promise<boolean> {
|
||||
}
|
||||
saveLoading.value = true;
|
||||
try {
|
||||
const content = normalizeWorkflowStartNodes(tinyflowRef.value?.getData());
|
||||
const res = await api.post('/api/v1/workflow/update', {
|
||||
id: workflowId.value,
|
||||
content: tinyflowRef.value?.getData(),
|
||||
content,
|
||||
});
|
||||
if (res.errorCode === 0 && showMsg) {
|
||||
ElMessage.success(res.message);
|
||||
@@ -365,9 +371,12 @@ async function handleSave(showMsg: boolean = false): Promise<boolean> {
|
||||
async function getWorkflowInfo(workflowId: any) {
|
||||
return api.get(`/api/v1/workflow/detail?id=${workflowId}`).then((res) => {
|
||||
workflowInfo.value = res.data;
|
||||
tinyFlowData.value = workflowInfo.value.content
|
||||
const parsedContent = workflowInfo.value.content
|
||||
? JSON.parse(workflowInfo.value.content)
|
||||
: {};
|
||||
tinyFlowData.value = isWorkflowDataEmpty(parsedContent)
|
||||
? createInitialWorkflowData()
|
||||
: parsedContent;
|
||||
syncNavTitle(workflowInfo.value?.title || '');
|
||||
});
|
||||
}
|
||||
@@ -406,7 +415,7 @@ async function runCheck(
|
||||
stage: WorkflowCheckStage,
|
||||
silentPass: boolean = false,
|
||||
) {
|
||||
const content = tinyflowRef.value?.getData();
|
||||
const content = normalizeWorkflowStartNodes(tinyflowRef.value?.getData());
|
||||
if (!content) {
|
||||
ElMessage.error($t('aiWorkflow.checkContentEmpty'));
|
||||
return false;
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import type { FormInstance } from 'element-plus';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { Position } from '@element-plus/icons-vue';
|
||||
import { ElButton, ElForm, ElFormItem, ElMessage } from 'element-plus';
|
||||
import { ElAlert, ElButton, ElForm, ElFormItem, ElMessage } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import ShowJson from '#/components/json/ShowJson.vue';
|
||||
import { $t } from '#/locales';
|
||||
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
|
||||
import { buildSingleRunModel } from '../../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
|
||||
|
||||
interface Props {
|
||||
workflowId: any;
|
||||
@@ -22,6 +23,52 @@ const singleRunForm = ref<FormInstance>();
|
||||
const runParams = ref<any>({});
|
||||
const submitLoading = ref(false);
|
||||
const result = ref<any>('');
|
||||
const singleRunModel = computed(() => buildSingleRunModel(props.node));
|
||||
const isFieldMode = computed(() => singleRunModel.value.mode === 'fields');
|
||||
const singleRunParameters = computed(() => singleRunModel.value.parameters);
|
||||
const singleRunFields = computed(() => singleRunModel.value.fields);
|
||||
const parameterDisplayNameMap = computed(() => {
|
||||
return new Map(
|
||||
singleRunParameters.value.map((parameter: any) => [
|
||||
String(parameter.name || ''),
|
||||
String(parameter.displayName || parameter.formLabel || parameter.name || ''),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
function buildFieldSegments(value: string) {
|
||||
const source = String(value || '');
|
||||
const segments: Array<{ text: string; token: boolean }> = [];
|
||||
const regex = /\{\{\s*([^{}]+?)\s*}}/g;
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const match of source.matchAll(regex)) {
|
||||
const matchedText = match[0] || '';
|
||||
const tokenKey = String(match[1] || '').trim();
|
||||
const start = match.index ?? 0;
|
||||
if (start > lastIndex) {
|
||||
segments.push({
|
||||
text: source.slice(lastIndex, start),
|
||||
token: false,
|
||||
});
|
||||
}
|
||||
segments.push({
|
||||
text: parameterDisplayNameMap.value.get(tokenKey) || tokenKey,
|
||||
token: true,
|
||||
});
|
||||
lastIndex = start + matchedText.length;
|
||||
}
|
||||
|
||||
if (lastIndex < source.length) {
|
||||
segments.push({
|
||||
text: source.slice(lastIndex),
|
||||
token: false,
|
||||
});
|
||||
}
|
||||
|
||||
return segments.length > 0 ? segments : [{ text: '', token: false }];
|
||||
}
|
||||
|
||||
function submit() {
|
||||
singleRunForm.value?.validate((valid) => {
|
||||
if (valid) {
|
||||
@@ -48,9 +95,75 @@ function submit() {
|
||||
<template>
|
||||
<div>
|
||||
<ElForm label-position="top" ref="singleRunForm" :model="runParams">
|
||||
<template v-if="isFieldMode">
|
||||
<div class="single-run-section">
|
||||
<div class="single-run-section__title">字段值</div>
|
||||
<ElAlert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="当前节点字段按正式配置展示;下方只需填写这些字段引用到的上游值。"
|
||||
/>
|
||||
<div class="single-run-field-list">
|
||||
<div
|
||||
v-for="field in singleRunFields"
|
||||
:key="field.key"
|
||||
class="single-run-field-card"
|
||||
>
|
||||
<div class="single-run-field-card__label">
|
||||
{{ field.label }}
|
||||
</div>
|
||||
<div
|
||||
class="single-run-field-card__value"
|
||||
:class="{ 'is-multiline': field.multiline }"
|
||||
>
|
||||
<template v-if="field.value">
|
||||
<template
|
||||
v-for="(segment, index) in buildFieldSegments(field.value)"
|
||||
:key="`${field.key}-${index}`"
|
||||
>
|
||||
<span
|
||||
v-if="segment.token"
|
||||
class="single-run-token-chip"
|
||||
>
|
||||
{{ segment.text }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="single-run-field-card__text"
|
||||
>
|
||||
{{ segment.text }}
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
<span v-else class="single-run-field-card__placeholder">
|
||||
{{ field.placeholder || '未设置' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="single-run-section">
|
||||
<div class="single-run-section__title">上游引用</div>
|
||||
<ElAlert
|
||||
v-if="singleRunParameters.length === 0"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="当前字段没有引用上游参数,可直接运行。"
|
||||
/>
|
||||
<WorkflowFormItem
|
||||
v-else
|
||||
v-model:run-params="runParams"
|
||||
:parameters="node?.data.parameters || []"
|
||||
:parameters="singleRunParameters"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<WorkflowFormItem
|
||||
v-else
|
||||
v-model:run-params="runParams"
|
||||
:parameters="singleRunParameters"
|
||||
/>
|
||||
<ElFormItem>
|
||||
<ElButton
|
||||
@@ -68,4 +181,71 @@ function submit() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.single-run-section + .single-run-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.single-run-section__title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.single-run-field-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.single-run-field-card {
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.single-run-field-card__label {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.single-run-field-card__value {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 20px;
|
||||
line-height: 1.7;
|
||||
color: var(--el-text-color-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.single-run-field-card__value.is-multiline {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.single-run-field-card__text {
|
||||
white-space: inherit;
|
||||
}
|
||||
|
||||
.single-run-field-card__placeholder {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.single-run-token-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--el-color-primary-light-5);
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
import {onDestroy, onMount} from 'svelte';
|
||||
import {isInEditableElement} from '#components/utils/isInEditableElement';
|
||||
import {getAvailableNodes, type NodePaletteItem} from './utils/nodePalette';
|
||||
import {
|
||||
buildSequentialFieldBindingPatches,
|
||||
ensureStartNodeParameters,
|
||||
START_NODE_TYPE,
|
||||
} from '../utils/workflowNodeFields';
|
||||
|
||||
const { onInit }: { onInit: any; [key: string]: any } = $props();
|
||||
const svelteFlow = useSvelteFlow();
|
||||
@@ -142,6 +147,13 @@
|
||||
}
|
||||
} as Node;
|
||||
|
||||
if (newNode.type === START_NODE_TYPE) {
|
||||
newNode.data = {
|
||||
...(newNode.data || {}),
|
||||
parameters: ensureStartNodeParameters((newNode.data?.parameters as Array<any>) || [])
|
||||
};
|
||||
}
|
||||
|
||||
if (sourceNode) {
|
||||
if (connection?.sourceHandle === 'loop_handle') {
|
||||
newNode.parentId = sourceNode.id;
|
||||
@@ -173,6 +185,8 @@
|
||||
});
|
||||
store.addEdge(edge as Edge);
|
||||
}
|
||||
|
||||
applyAutoBindingsForNode(newNode.id);
|
||||
}
|
||||
|
||||
function closeNodePicker() {
|
||||
@@ -367,6 +381,47 @@
|
||||
const { getNodesFromSource } = useGetNodesFromSource();
|
||||
const { getNodeRelativePosition } = useGetNodeRelativePosition();
|
||||
const { ensureParentInNodesBefore } = useEnsureParentInNodesBefore();
|
||||
|
||||
function collectAffectedNodeIds(rootNodeIds: string[], edges: Edge[] = store.getEdges()) {
|
||||
const affectedNodeIds = new Set<string>();
|
||||
const visit = (nodeId: string) => {
|
||||
if (!nodeId || affectedNodeIds.has(nodeId)) {
|
||||
return;
|
||||
}
|
||||
affectedNodeIds.add(nodeId);
|
||||
edges
|
||||
.filter((edge) => edge.source === nodeId && edge.sourceHandle !== 'loop_handle')
|
||||
.forEach((edge) => {
|
||||
if (edge.target) {
|
||||
visit(edge.target);
|
||||
}
|
||||
});
|
||||
};
|
||||
rootNodeIds.forEach(visit);
|
||||
return Array.from(affectedNodeIds);
|
||||
}
|
||||
|
||||
function reconcileBindingsForNodes(
|
||||
nodeIds: string[],
|
||||
options?: {
|
||||
nodes?: Node[];
|
||||
edges?: Edge[];
|
||||
}
|
||||
) {
|
||||
const uniqueNodeIds = Array.from(new Set(nodeIds.filter((nodeId) => asString(nodeId).trim())));
|
||||
if (uniqueNodeIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
const nodes = options?.nodes || store.getNodes();
|
||||
const edges = options?.edges || store.getEdges();
|
||||
const patches = buildSequentialFieldBindingPatches(uniqueNodeIds, nodes, edges);
|
||||
patches.forEach(({ nodeId, patch }) => {
|
||||
store.updateNodeData(nodeId, patch);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const onconnectend = (event: any, state: any) => {
|
||||
if (!state.isValid) {
|
||||
if (state.toNode) {
|
||||
@@ -449,7 +504,11 @@
|
||||
const { getEdgesByTarget } = useGetEdgesByTarget();
|
||||
const onDelete = (params: any) => {
|
||||
const deleteEdges = params.edges as Edge[];
|
||||
const affectedRootNodeIds = new Set<string>();
|
||||
deleteEdges.forEach((edge) => {
|
||||
if (edge.target) {
|
||||
affectedRootNodeIds.add(edge.target);
|
||||
}
|
||||
if (edge.id === currentEdge?.id) {
|
||||
currentEdge = null;
|
||||
showEdgePanel = false;
|
||||
@@ -513,6 +572,11 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
if (affectedRootNodeIds.size > 0) {
|
||||
queueMicrotask(() => {
|
||||
reconcileBindingsForNodes(collectAffectedNodeIds(Array.from(affectedRootNodeIds)));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const { deleteEdge } = useDeleteEdge();
|
||||
@@ -525,9 +589,41 @@
|
||||
|
||||
|
||||
const onconnect = (event: any) => {
|
||||
// console.log('onconnect: ', event);
|
||||
const targetNodeId = asString(event?.target).trim();
|
||||
if (!targetNodeId) {
|
||||
return;
|
||||
}
|
||||
const sourceNodeId = asString(event?.source).trim();
|
||||
const projectedEdges = [...store.getEdges()];
|
||||
const hasSameEdge = projectedEdges.some((edge) =>
|
||||
edge.source === sourceNodeId
|
||||
&& edge.target === targetNodeId
|
||||
&& (edge.sourceHandle || '') === asString(event?.sourceHandle).trim()
|
||||
&& (edge.targetHandle || '') === asString(event?.targetHandle).trim()
|
||||
);
|
||||
|
||||
if (!hasSameEdge && sourceNodeId) {
|
||||
projectedEdges.push({
|
||||
id: asString(event?.id).trim() || `edge_${genShortId()}`,
|
||||
source: sourceNodeId,
|
||||
target: targetNodeId,
|
||||
sourceHandle: asString(event?.sourceHandle).trim() || undefined,
|
||||
targetHandle: asString(event?.targetHandle).trim() || undefined,
|
||||
} as Edge);
|
||||
}
|
||||
|
||||
reconcileBindingsForNodes(
|
||||
collectAffectedNodeIds([targetNodeId], projectedEdges),
|
||||
{
|
||||
edges: projectedEdges
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
function applyAutoBindingsForNode(nodeId: string) {
|
||||
reconcileBindingsForNodes([nodeId]);
|
||||
}
|
||||
|
||||
const { copyHandler, pasteHandler } = useCopyPasteHandler();
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
|
||||
@@ -76,10 +76,11 @@
|
||||
|
||||
<div class="input-item">
|
||||
<Input style="width: 100%;" value={param.name} placeholder="请输入参数名称"
|
||||
disabled={param.nameDisabled === true}
|
||||
oninput={updateName} />
|
||||
</div>
|
||||
<div class="input-item">
|
||||
<Checkbox checked={param.required} onchange={updateRequired} />
|
||||
<Checkbox checked={param.required} disabled={param.requiredDisabled === true} onchange={updateRequired} />
|
||||
</div>
|
||||
<div class="input-item">
|
||||
<FloatingTrigger placement="bottom" bind:this={triggerObject}>
|
||||
@@ -91,10 +92,16 @@
|
||||
</Button>
|
||||
{#snippet floating()}
|
||||
<div class="input-more-setting">
|
||||
{#if param.systemReserved}
|
||||
<div class="input-more-item">
|
||||
系统入口参数,当前不可编辑。
|
||||
</div>
|
||||
{/if}
|
||||
<div class="input-more-item">
|
||||
数据内容:
|
||||
<Select items={contentTypes} style="width: 100%" defaultValue={["text"]}
|
||||
value={param.contentType ? [param.contentType] : []}
|
||||
disabled={param.systemReserved === true}
|
||||
onSelect={updateContentType}
|
||||
/>
|
||||
</div>
|
||||
@@ -102,6 +109,7 @@
|
||||
输入方式:
|
||||
<Select items={startFormTypes} style="width: 100%" defaultValue={["input"]}
|
||||
value={param.formType ? [param.formType] : []}
|
||||
disabled={param.systemReserved === true}
|
||||
onSelect={updateFormType}
|
||||
/>
|
||||
</div>
|
||||
@@ -110,8 +118,8 @@
|
||||
<div class="input-more-item">
|
||||
数据选项:
|
||||
<Textarea rows={3} style="width: 100%;" onchange={(event)=>{
|
||||
updateParameter('enums', event.target?.value.trim().split("\n"))
|
||||
}} value={param.enums?.join("\n")} placeholder="一行一个选项" />
|
||||
updateParameter('enums', ((event.target as HTMLTextAreaElement)?.value || '').trim().split("\n"))
|
||||
}} disabled={param.systemReserved === true} value={param.enums?.join("\n")} placeholder="一行一个选项" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -119,25 +127,25 @@
|
||||
数据标题:
|
||||
<Textarea rows={1} style="width: 100%;" onchange={(event)=>{
|
||||
updateParamByEvent('formLabel', event)
|
||||
}} value={param.formLabel} />
|
||||
}} disabled={param.systemReserved === true} value={param.formLabel} />
|
||||
</div>
|
||||
|
||||
<div class="input-more-item">
|
||||
数据描述:
|
||||
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
|
||||
updateParamByEvent('formDescription', event)
|
||||
}} value={param.formDescription} />
|
||||
}} disabled={param.systemReserved === true} value={param.formDescription} />
|
||||
</div>
|
||||
|
||||
<div class="input-more-item">
|
||||
占位符:
|
||||
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
|
||||
updateParamByEvent('formPlaceholder', event)
|
||||
}} value={param.formPlaceholder} />
|
||||
}} disabled={param.systemReserved === true} value={param.formPlaceholder} />
|
||||
</div>
|
||||
|
||||
|
||||
<div class="input-more-item">
|
||||
<div class="input-more-item" style:display={param.deleteDisabled === true ? 'none' : 'flex'}>
|
||||
<Button onclick={handleDelete}>删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,5 +188,3 @@
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -3,11 +3,31 @@
|
||||
import {useNodesData} from '@xyflow/svelte';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
|
||||
const {
|
||||
parameters: manualParameters = undefined,
|
||||
emptyText = '无输入参数'
|
||||
}: {
|
||||
parameters?: Array<any>,
|
||||
emptyText?: string
|
||||
} = $props();
|
||||
|
||||
let currentNodeId = getCurrentNodeId();
|
||||
let node = useNodesData(currentNodeId);
|
||||
let parameters = $derived.by(() => {
|
||||
let currentParameters = $derived.by(() => {
|
||||
return [...node?.current?.data?.parameters as Array<any> || []];
|
||||
});
|
||||
let parameters = $derived.by(() => {
|
||||
if (Array.isArray(manualParameters)) {
|
||||
return [...manualParameters];
|
||||
}
|
||||
return currentParameters;
|
||||
});
|
||||
let parameterItems = $derived.by(() => {
|
||||
return parameters.map((param) => ({
|
||||
parameter: param,
|
||||
index: currentParameters.findIndex((item) => item?.id === param?.id)
|
||||
})).filter((item) => item.index >= 0);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -17,10 +37,10 @@
|
||||
<div class="input-header">必填</div>
|
||||
<div class="input-header"></div>
|
||||
{/if}
|
||||
{#each parameters as param, index (param.id)}
|
||||
<DefinedParameterItem parameter={param} index={index} />
|
||||
{#each parameterItems as item (item.parameter.id)}
|
||||
<DefinedParameterItem parameter={item.parameter} index={item.index} />
|
||||
{:else }
|
||||
<div class="none-params">无输入参数</div>
|
||||
<div class="none-params">{emptyText}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -52,5 +72,3 @@
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
showSourceHandle = true,
|
||||
showTargetHandle = true,
|
||||
titleHelp = '',
|
||||
wrapperClass = '',
|
||||
onCollapse
|
||||
}: {
|
||||
data: NodeProps['data'],
|
||||
@@ -43,6 +44,7 @@
|
||||
showSourceHandle?: boolean,
|
||||
showTargetHandle?: boolean,
|
||||
titleHelp?: string,
|
||||
wrapperClass?: string,
|
||||
onCollapse?: (key: string) => void,
|
||||
} = $props();
|
||||
|
||||
@@ -248,7 +250,7 @@
|
||||
</NodeToolbar>
|
||||
{/if}
|
||||
|
||||
<div class="tf-node-wrapper">
|
||||
<div class="tf-node-wrapper {wrapperClass}">
|
||||
<div class="tf-node-wrapper-body">
|
||||
<Collapse {items} activeKeys={activeKeys} onChange={(_,actionKeys) => {
|
||||
updateNodeData(id, {expand: actionKeys?.includes('key')})
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
column-gap: 4px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 318px;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
.none-params {
|
||||
@@ -78,4 +78,3 @@
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,173 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import { flushSync, mount, unmount } from 'svelte';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import ParamTokenEditor from './ParamTokenEditor.svelte';
|
||||
|
||||
type RenderResult = {
|
||||
host: HTMLDivElement;
|
||||
view: EditorView;
|
||||
destroy: () => Promise<void>;
|
||||
};
|
||||
|
||||
const PARAMS = [
|
||||
{
|
||||
name: 'start.user_input',
|
||||
displayName: '开始节点 > 用户问题',
|
||||
refType: 'input',
|
||||
},
|
||||
];
|
||||
|
||||
async function renderEditor(props: Record<string, unknown> = {}): Promise<RenderResult> {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
||||
const app = mount(ParamTokenEditor, {
|
||||
target: host,
|
||||
props: {
|
||||
mode: 'textarea',
|
||||
value: '',
|
||||
parameters: PARAMS,
|
||||
...props,
|
||||
},
|
||||
});
|
||||
flushSync();
|
||||
|
||||
const shell = host.querySelector('.param-token-editor-shell') as HTMLDivElement & {
|
||||
__paramTokenEditorView?: EditorView;
|
||||
};
|
||||
|
||||
if (!shell?.__paramTokenEditorView) {
|
||||
throw new Error('ParamTokenEditor view not found');
|
||||
}
|
||||
|
||||
return {
|
||||
host,
|
||||
view: shell.__paramTokenEditorView,
|
||||
destroy: async () => {
|
||||
await unmount(app);
|
||||
host.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function pressKey(view: EditorView, key: string) {
|
||||
view.focus();
|
||||
view.contentDOM.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||
flushSync();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('ParamTokenEditor', () => {
|
||||
it('should render chip text and parameter panel with displayName only', async () => {
|
||||
const { host, destroy } = await renderEditor({
|
||||
value: '前缀 {{start.user_input}}',
|
||||
});
|
||||
|
||||
expect(host.querySelector('.param-token-chip-text')?.textContent).toBe('开始节点 > 用户问题');
|
||||
expect(host.querySelector('.param-token-item-label')?.textContent).toBe('开始节点 > 用户问题');
|
||||
expect(host.textContent).not.toContain('start.user_input');
|
||||
|
||||
await destroy();
|
||||
});
|
||||
|
||||
it('should insert raw token text and move cursor after token', async () => {
|
||||
const oninput = vi.fn();
|
||||
const onchange = vi.fn();
|
||||
const { host, view, destroy } = await renderEditor({
|
||||
oninput,
|
||||
onchange,
|
||||
});
|
||||
|
||||
(host.querySelector('.param-token-item') as HTMLButtonElement).click();
|
||||
flushSync();
|
||||
|
||||
expect(view.state.doc.toString()).toBe('{{start.user_input}}');
|
||||
expect(view.state.selection.main.head).toBe('{{start.user_input}}'.length);
|
||||
expect(oninput).toHaveBeenCalled();
|
||||
expect(onchange).toHaveBeenCalledWith({
|
||||
target: {
|
||||
value: '{{start.user_input}}',
|
||||
},
|
||||
});
|
||||
|
||||
await destroy();
|
||||
});
|
||||
|
||||
it('should remove the whole token on backspace and delete at token boundaries', async () => {
|
||||
const token = '{{start.user_input}}';
|
||||
const { view, destroy } = await renderEditor({
|
||||
value: `A ${token} B`,
|
||||
});
|
||||
|
||||
view.dispatch({
|
||||
selection: {
|
||||
anchor: `A ${token}`.length,
|
||||
},
|
||||
});
|
||||
await pressKey(view, 'Backspace');
|
||||
expect(view.state.doc.toString()).toBe('A B');
|
||||
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: view.state.doc.length,
|
||||
insert: `A ${token} B`,
|
||||
},
|
||||
selection: {
|
||||
anchor: 2,
|
||||
},
|
||||
});
|
||||
flushSync();
|
||||
await pressKey(view, 'Delete');
|
||||
expect(view.state.doc.toString()).toBe('A B');
|
||||
|
||||
await destroy();
|
||||
});
|
||||
|
||||
it('should block newline insertion in single-line mode', async () => {
|
||||
const { view, destroy } = await renderEditor({
|
||||
mode: 'input',
|
||||
value: 'abc',
|
||||
});
|
||||
|
||||
await pressKey(view, 'Enter');
|
||||
expect(view.state.doc.toString()).toBe('abc');
|
||||
|
||||
await destroy();
|
||||
});
|
||||
|
||||
it('should render invalid token as disconnected parameter label without undefined hint', async () => {
|
||||
const { host, destroy } = await renderEditor({
|
||||
value: '{{missing.ref}}',
|
||||
});
|
||||
|
||||
expect(host.querySelector('.param-token-chip-text')?.textContent).toBe('ref(已断开连接)');
|
||||
expect(host.textContent).not.toContain('未定义参数');
|
||||
|
||||
await destroy();
|
||||
});
|
||||
|
||||
it('should prefer stored display name for disconnected references', async () => {
|
||||
const { host, destroy } = await renderEditor({
|
||||
value: '{{start.user_input}}',
|
||||
parameters: [
|
||||
{
|
||||
name: 'start.user_input',
|
||||
displayName: '开始节点 > 用户问题',
|
||||
resolved: false,
|
||||
disconnected: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(host.querySelector('.param-token-chip-text')?.textContent).toBe(
|
||||
'开始节点 > 用户问题(已断开连接)',
|
||||
);
|
||||
expect(host.querySelector('.param-token-invalid .param-token-chip-text')).not.toBeNull();
|
||||
|
||||
await destroy();
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,7 @@
|
||||
column-gap: 4px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 318px;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
.none-params {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<script lang="ts">
|
||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||
import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {Button, Heading, Select} from '../base';
|
||||
import RefParameterList from '../core/RefParameterList.svelte';
|
||||
import {type NodeProps, useNodesData, useStore, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {Heading, Select} from '../base';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||
import {getOptions} from '../utils/NodeUtils';
|
||||
import {onMount} from 'svelte';
|
||||
import OutputDefList from '../core/OutputDefList.svelte';
|
||||
import type {SelectItem} from '#types';
|
||||
import type {Parameter, SelectItem} from '#types';
|
||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||
import {
|
||||
buildEditorReferenceParameters,
|
||||
FIELD_BINDING_META_KEY,
|
||||
syncManagedParametersForFields,
|
||||
updateFieldBindingMeta,
|
||||
} from '../../utils/workflowNodeFields';
|
||||
|
||||
const { data, ...rest }: {
|
||||
data: NodeProps['data'],
|
||||
@@ -19,8 +24,14 @@
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
let currentNode = useNodesData(currentNodeId);
|
||||
const { addParameter } = useAddParameter();
|
||||
const { nodes, edges } = $derived(useStore());
|
||||
const editorParameters = $derived.by(() => {
|
||||
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
|
||||
return buildEditorReferenceParameters(
|
||||
currentNodeId,
|
||||
nodes || [],
|
||||
edges || [],
|
||||
(((currentNode?.current?.data?.parameters as Array<Parameter>) || data.parameters || []) as Array<Parameter>)
|
||||
);
|
||||
});
|
||||
|
||||
const options = getOptions();
|
||||
@@ -37,6 +48,22 @@
|
||||
});
|
||||
|
||||
const { updateNodeData } = useSvelteFlow();
|
||||
const syncFieldValue = (fieldName: 'keyword' | 'limit', nextValue: string) => {
|
||||
const currentData = ((currentNode?.current?.data as Record<string, any>) || data || {}) as Record<string, any>;
|
||||
const nextFieldValues = {
|
||||
keyword: fieldName === 'keyword' ? nextValue : (currentData.keyword || ''),
|
||||
limit: fieldName === 'limit' ? nextValue : (currentData.limit || ''),
|
||||
};
|
||||
updateNodeData(currentNodeId, {
|
||||
[fieldName]: nextValue,
|
||||
parameters: syncManagedParametersForFields(
|
||||
(currentData.parameters as Array<Parameter>) || [],
|
||||
editorParameters,
|
||||
nextFieldValues
|
||||
),
|
||||
[FIELD_BINDING_META_KEY]: updateFieldBindingMeta(currentData, fieldName, nextValue),
|
||||
});
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (!data.outputDefs || data.outputDefs.length === 0) {
|
||||
@@ -104,19 +131,6 @@
|
||||
d="M15.5 5C13.567 5 12 6.567 12 8.5C12 10.433 13.567 12 15.5 12C17.433 12 19 10.433 19 8.5C19 6.567 17.433 5 15.5 5ZM10 8.5C10 5.46243 12.4624 3 15.5 3C18.5376 3 21 5.46243 21 8.5C21 9.6575 20.6424 10.7315 20.0317 11.6175L22.7071 14.2929L21.2929 15.7071L18.6175 13.0317C17.7315 13.6424 16.6575 14 15.5 14C12.4624 14 10 11.5376 10 8.5ZM3 4H8V6H3V4ZM3 11H8V13H3V11ZM21 18V20H3V18H21Z"></path>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<div class="heading">
|
||||
<Heading level={3}>输入参数</Heading>
|
||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
||||
addParameter(currentNodeId)
|
||||
}}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<RefParameterList />
|
||||
|
||||
<Heading level={3} mt="10px">知识库设置</Heading>
|
||||
<div class="setting-title">知识库</div>
|
||||
<div class="setting-item">
|
||||
@@ -137,14 +151,9 @@
|
||||
placeholder="请输入关键字"
|
||||
style="width: 100%"
|
||||
parameters={editorParameters}
|
||||
value={data.keyword || ''}
|
||||
oninput={(e)=>{
|
||||
const newValue = e.target.value;
|
||||
updateNodeData(currentNodeId, ()=>{
|
||||
return {
|
||||
keyword: newValue
|
||||
}
|
||||
})
|
||||
value={String(data.keyword || '')}
|
||||
oninput={(e: any)=>{
|
||||
syncFieldValue('keyword', e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -169,15 +178,10 @@
|
||||
placeholder="搜索的数据条数"
|
||||
style="width: 100%"
|
||||
parameters={editorParameters}
|
||||
oninput={(e)=>{
|
||||
const newValue = e.target.value;
|
||||
updateNodeData(currentNodeId, ()=>{
|
||||
return {
|
||||
limit: newValue
|
||||
}
|
||||
})
|
||||
oninput={(e: any)=>{
|
||||
syncFieldValue('limit', e.target.value)
|
||||
}}
|
||||
value={data.limit || ''}
|
||||
value={String(data.limit || '')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,8 +9,14 @@
|
||||
import {getOptions} from '../utils/NodeUtils';
|
||||
import {onMount} from 'svelte';
|
||||
import OutputDefList from '../core/OutputDefList.svelte';
|
||||
import type {SelectItem} from '#types';
|
||||
import type {Parameter, SelectItem} from '#types';
|
||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||
import {
|
||||
buildEditorReferenceParameters,
|
||||
FIELD_BINDING_META_KEY,
|
||||
syncManagedParametersForFields,
|
||||
updateFieldBindingMeta,
|
||||
} from '../../utils/workflowNodeFields';
|
||||
|
||||
const { data, ...rest }: {
|
||||
data: NodeProps['data'],
|
||||
@@ -20,11 +26,14 @@
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
let currentNode = useNodesData(currentNodeId);
|
||||
const { addParameter } = useAddParameter();
|
||||
const { nodes } = $derived(useStore());
|
||||
const { nodes, edges } = $derived(useStore());
|
||||
const editorParameters = $derived.by(() => {
|
||||
const parameters = [
|
||||
...(((currentNode?.current?.data?.parameters as Array<any>) || data.parameters || []) as Array<any>)
|
||||
];
|
||||
const parameters = buildEditorReferenceParameters(
|
||||
currentNodeId,
|
||||
nodes || [],
|
||||
edges || [],
|
||||
(((currentNode?.current?.data?.parameters as Array<Parameter>) || data.parameters || []) as Array<Parameter>)
|
||||
);
|
||||
if (queryContextNodeIds.length > 0) {
|
||||
parameters.push({
|
||||
id: 'queryDataContext',
|
||||
@@ -109,6 +118,23 @@
|
||||
});
|
||||
|
||||
const { updateNodeData } = useSvelteFlow();
|
||||
const syncFieldValue = (fieldName: 'systemPrompt' | 'userPrompt', nextValue: string) => {
|
||||
const currentData = ((currentNode?.current?.data as Record<string, any>) || data || {}) as Record<string, any>;
|
||||
const nextFieldValues = {
|
||||
systemPrompt: fieldName === 'systemPrompt' ? nextValue : (currentData.systemPrompt || ''),
|
||||
userPrompt: fieldName === 'userPrompt' ? nextValue : (currentData.userPrompt || ''),
|
||||
};
|
||||
updateNodeData(currentNodeId, {
|
||||
[fieldName]: nextValue,
|
||||
parameters: syncManagedParametersForFields(
|
||||
(currentData.parameters as Array<Parameter>) || [],
|
||||
editorParameters,
|
||||
nextFieldValues
|
||||
),
|
||||
[FIELD_BINDING_META_KEY]: updateFieldBindingMeta(currentData, fieldName, nextValue),
|
||||
});
|
||||
};
|
||||
|
||||
const setOutType = (value: string) => {
|
||||
updateNodeData(currentNodeId, () => {
|
||||
return {
|
||||
@@ -175,7 +201,7 @@
|
||||
</script>
|
||||
|
||||
|
||||
<NodeWrapper {data} {...rest}>
|
||||
<NodeWrapper {data} {...rest} wrapperClass="tf-node-wrapper--llm">
|
||||
|
||||
{#snippet icon()}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
@@ -185,18 +211,6 @@
|
||||
{/snippet}
|
||||
|
||||
<div class="heading">
|
||||
<Heading level={3}>输入参数</Heading>
|
||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
||||
addParameter(currentNodeId)
|
||||
}}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<RefParameterList />
|
||||
|
||||
<div class="heading" style="padding-top: 10px">
|
||||
<Heading level={3}>图片识别</Heading>
|
||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
||||
addParameter(currentNodeId, "images")
|
||||
@@ -250,7 +264,7 @@
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={data.temperature ?? 0.7}
|
||||
oninput={(e) => updateNodeData(currentNodeId, { temperature: parseFloat(e.target.value) })}
|
||||
oninput={(e) => updateNodeData(currentNodeId, { temperature: parseFloat((e.target as HTMLInputElement).value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,7 +279,7 @@
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={data.topP ?? 0.9}
|
||||
oninput={(e) => updateNodeData(currentNodeId, { topP: parseFloat(e.target.value) })}
|
||||
oninput={(e) => updateNodeData(currentNodeId, { topP: parseFloat((e.target as HTMLInputElement).value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -280,7 +294,7 @@
|
||||
max="100"
|
||||
step="1"
|
||||
value={data.topK ?? 50}
|
||||
oninput={(e) => updateNodeData(currentNodeId, { topK: parseInt(e.target.value) })}
|
||||
oninput={(e) => updateNodeData(currentNodeId, { topK: parseInt((e.target as HTMLInputElement).value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,11 +312,9 @@
|
||||
placeholder="请输入系统提示词"
|
||||
style="width: 100%"
|
||||
parameters={editorParameters}
|
||||
value={data.systemPrompt || ''}
|
||||
oninput={(e)=>{
|
||||
updateNodeData(currentNodeId, {
|
||||
systemPrompt: e.target.value
|
||||
});
|
||||
value={String(data.systemPrompt || '')}
|
||||
oninput={(e: any)=>{
|
||||
syncFieldValue('systemPrompt', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -315,11 +327,9 @@
|
||||
placeholder="请输入用户提示词"
|
||||
style="width: 100%"
|
||||
parameters={editorParameters}
|
||||
value={data.userPrompt || ''}
|
||||
oninput={(e)=>{
|
||||
updateNodeData(currentNodeId, {
|
||||
userPrompt: e.target.value
|
||||
});
|
||||
value={String(data.userPrompt || '')}
|
||||
oninput={(e: any)=>{
|
||||
syncFieldValue('userPrompt', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -333,7 +343,7 @@
|
||||
label: 'JSON',
|
||||
value: 'json'
|
||||
}]} style="width: 100px;margin-left: auto" onSelect={(item)=>{
|
||||
setOutType(item.value);
|
||||
setOutType(String(item.value));
|
||||
}} value={data.outType ? [data.outType] : []} />
|
||||
</div>
|
||||
<OutputDefList />
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
import DefinedParameterList from '../core/DefinedParameterList.svelte';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||
import {useSvelteFlow} from '@xyflow/svelte';
|
||||
import {
|
||||
ensureStartNodeParameters,
|
||||
hasSystemStartParameter,
|
||||
isSystemStartParameter,
|
||||
} from '../../utils/workflowNodeFields';
|
||||
|
||||
const { data, ...rest }: {
|
||||
data: NodeProps['data'],
|
||||
@@ -14,6 +20,30 @@
|
||||
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
const { addParameter } = useAddParameter();
|
||||
const { updateNodeData } = useSvelteFlow();
|
||||
|
||||
$effect(() => {
|
||||
const currentParameters = (data.parameters as Array<any>) || [];
|
||||
if (!hasSystemStartParameter(currentParameters)) {
|
||||
return;
|
||||
}
|
||||
const parameters = ensureStartNodeParameters(currentParameters);
|
||||
if (JSON.stringify(currentParameters) !== JSON.stringify(parameters)) {
|
||||
updateNodeData(currentNodeId, {
|
||||
parameters
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let currentParameters = $derived.by(() => {
|
||||
return ((data.parameters as Array<any>) || []);
|
||||
});
|
||||
let systemParameters = $derived.by(() => {
|
||||
return currentParameters.filter((parameter) => isSystemStartParameter(parameter));
|
||||
});
|
||||
let customParameters = $derived.by(() => {
|
||||
return currentParameters.filter((parameter) => !isSystemStartParameter(parameter));
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -25,26 +55,51 @@
|
||||
d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 15C10.3431 15 9 13.6569 9 12C9 10.3431 10.3431 9 12 9C13.6569 9 15 10.3431 15 12C15 13.6569 13.6569 15 12 15Z"></path>
|
||||
</svg>
|
||||
{/snippet}
|
||||
<div class="param-section">
|
||||
<div class="heading">
|
||||
<Heading level={3}>输入参数</Heading>
|
||||
<Heading level={3}>系统入口</Heading>
|
||||
</div>
|
||||
<div class="section-description">固定入口参数,作为工作流默认输入来源。</div>
|
||||
<DefinedParameterList parameters={systemParameters} emptyText="暂无系统入口参数" />
|
||||
</div>
|
||||
|
||||
<div class="param-section">
|
||||
<div class="heading">
|
||||
<Heading level={3}>自定义参数</Heading>
|
||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
||||
addParameter(currentNodeId, "parameters", {refType: "input", name: "newParam"});
|
||||
addParameter(currentNodeId, "parameters", {refType: "input", name: "newParam", formType: "input", contentType: "text"});
|
||||
}}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<DefinedParameterList />
|
||||
<div class="section-description">这里添加额外输入参数,不影响默认入口参数。</div>
|
||||
<DefinedParameterList parameters={customParameters} emptyText="暂无自定义参数" />
|
||||
</div>
|
||||
</NodeWrapper>
|
||||
|
||||
<style lang="less">
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.param-section + .param-section {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--tf-border-color);
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--tf-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
:global(.input-btn-more) {
|
||||
border: 1px solid transparent;
|
||||
padding: 3px;
|
||||
@@ -55,4 +110,3 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export const genUuid = () => {
|
||||
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c: any) =>
|
||||
(
|
||||
c ^
|
||||
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
|
||||
((crypto.getRandomValues(new Uint8Array(1))[0] ?? 0) & (15 >> (c / 4)))
|
||||
).toString(16),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -63,14 +63,20 @@ describe('paramToken utils', () => {
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
dataType: undefined,
|
||||
displayName: 'input',
|
||||
name: 'input',
|
||||
resolved: false,
|
||||
},
|
||||
{
|
||||
dataType: undefined,
|
||||
displayName: 'docs',
|
||||
name: 'docs',
|
||||
resolved: true,
|
||||
},
|
||||
{
|
||||
dataType: undefined,
|
||||
displayName: 'runtimeInput',
|
||||
name: 'runtimeInput',
|
||||
resolved: true,
|
||||
},
|
||||
|
||||
@@ -2,6 +2,11 @@ export interface ParameterLike {
|
||||
name?: string;
|
||||
ref?: string;
|
||||
refType?: string;
|
||||
resolved?: boolean;
|
||||
disconnected?: boolean;
|
||||
displayName?: string;
|
||||
formLabel?: string;
|
||||
dataType?: string;
|
||||
children?: ParameterLike[];
|
||||
}
|
||||
|
||||
@@ -27,6 +32,9 @@ export type TokenPart =
|
||||
export interface ParameterCandidate {
|
||||
name: string;
|
||||
resolved: boolean;
|
||||
disconnected?: boolean;
|
||||
displayName?: string;
|
||||
dataType?: string;
|
||||
}
|
||||
|
||||
const TOKEN_PATTERN = /\{\{\s*([^{}]+?)\s*}}/g;
|
||||
@@ -46,6 +54,10 @@ function isParameterResolved(parameter?: ParameterLike): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof parameter.resolved === 'boolean') {
|
||||
return parameter.resolved;
|
||||
}
|
||||
|
||||
const refType = (parameter.refType || '').trim();
|
||||
if (refType === 'fixed' || refType === 'input') {
|
||||
return true;
|
||||
@@ -65,7 +77,13 @@ export function flattenParameterCandidates(
|
||||
const candidates: ParameterCandidate[] = [];
|
||||
const indexMap = new Map<string, number>();
|
||||
|
||||
const addCandidate = (name: string, resolved: boolean) => {
|
||||
const addCandidate = (
|
||||
name: string,
|
||||
resolved: boolean,
|
||||
disconnected: boolean,
|
||||
displayName?: string,
|
||||
dataType?: string,
|
||||
) => {
|
||||
const normalized = name.trim();
|
||||
if (!normalized) {
|
||||
return;
|
||||
@@ -76,13 +94,26 @@ export function flattenParameterCandidates(
|
||||
candidates.push({
|
||||
name: normalized,
|
||||
resolved,
|
||||
disconnected,
|
||||
displayName: displayName?.trim() || normalized,
|
||||
dataType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 同名参数只要有一个可解析,就视为可解析
|
||||
const existingCandidate = candidates[exists]!;
|
||||
if (resolved) {
|
||||
candidates[exists].resolved = true;
|
||||
existingCandidate.resolved = true;
|
||||
existingCandidate.disconnected = false;
|
||||
} else if (disconnected && !existingCandidate.resolved) {
|
||||
existingCandidate.disconnected = true;
|
||||
}
|
||||
if (!existingCandidate.displayName && displayName?.trim()) {
|
||||
existingCandidate.displayName = displayName.trim();
|
||||
}
|
||||
if (!existingCandidate.dataType && dataType) {
|
||||
existingCandidate.dataType = dataType;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,7 +130,18 @@ export function flattenParameterCandidates(
|
||||
|
||||
const currentPath = parentPath ? `${parentPath}.${rawName}` : rawName;
|
||||
const currentResolved = inheritedResolved && isParameterResolved(item);
|
||||
addCandidate(currentPath, currentResolved);
|
||||
const currentDisconnected = item?.disconnected === true;
|
||||
const displayName =
|
||||
item?.displayName?.trim() ||
|
||||
item?.formLabel?.trim() ||
|
||||
currentPath;
|
||||
addCandidate(
|
||||
currentPath,
|
||||
currentResolved,
|
||||
currentDisconnected,
|
||||
displayName,
|
||||
item?.dataType,
|
||||
);
|
||||
|
||||
if (item.children && item.children.length > 0) {
|
||||
walk(item.children, currentPath, currentResolved);
|
||||
|
||||
@@ -27,8 +27,9 @@ const getChildren = (
|
||||
const dataType = nodeIsChildren
|
||||
? `Array<${param.dataType || 'String'}>`
|
||||
: param.dataType || 'String';
|
||||
const label = param.formLabel || param.displayName || param.name;
|
||||
return {
|
||||
label: param.name,
|
||||
label,
|
||||
dataType: dataType,
|
||||
value: parentId + '.' + param.name,
|
||||
selectable: true,
|
||||
@@ -71,8 +72,10 @@ const nodeToOptions = (
|
||||
const dataType = nodeIsChildren
|
||||
? `Array<${parameter.dataType || 'String'}>`
|
||||
: parameter.dataType || 'String';
|
||||
const label =
|
||||
parameter.formLabel || parameter.displayName || parameter.name;
|
||||
children.push({
|
||||
label: parameter.name,
|
||||
label,
|
||||
dataType: dataType,
|
||||
value: node.id + '.' + parameter.name,
|
||||
selectable: true,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './types';
|
||||
export * from './Tinyflow';
|
||||
export * from './components/TinyflowComponent.svelte';
|
||||
export * from './utils/workflowNodeFields';
|
||||
export * from './utils/sanitize';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
.svelte-flow__nodes {
|
||||
.svelte-flow__node {
|
||||
box-sizing: border-box;
|
||||
cursor: default !important;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 5px;
|
||||
|
||||
@@ -114,6 +115,11 @@
|
||||
&-body {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
&--llm {
|
||||
min-width: 296px;
|
||||
max-width: 296px;
|
||||
}
|
||||
}
|
||||
|
||||
.svelte-flow__attribution a {
|
||||
|
||||
@@ -107,6 +107,7 @@ export type Parameter = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
nameDisabled?: boolean;
|
||||
displayName?: string;
|
||||
dataType?: string;
|
||||
dataTypeItems?: SelectItem[];
|
||||
dataTypeDisabled?: boolean;
|
||||
@@ -126,4 +127,7 @@ export type Parameter = {
|
||||
formDescription?: string;
|
||||
formPlaceholder?: string;
|
||||
formAttrs?: string;
|
||||
requiredDisabled?: boolean;
|
||||
systemReserved?: boolean;
|
||||
autoManaged?: boolean;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { Edge, Node } from '@xyflow/svelte';
|
||||
|
||||
import {
|
||||
buildAutoBindingPatch,
|
||||
buildSequentialFieldBindingPatches,
|
||||
buildFieldBindingPatch,
|
||||
buildEditorReferenceParameters,
|
||||
buildSingleRunModel,
|
||||
buildSingleRunParameters,
|
||||
createInitialWorkflowData,
|
||||
ensureStartNodeParameters,
|
||||
FIELD_BINDING_META_KEY,
|
||||
normalizeWorkflowStartNodes,
|
||||
} from './workflowNodeFields';
|
||||
|
||||
describe('workflow node fields', () => {
|
||||
it('creates initial workflow data with fixed start input', () => {
|
||||
const initial = createInitialWorkflowData();
|
||||
expect(initial.nodes).toHaveLength(1);
|
||||
expect(initial.nodes[0]?.type).toBe('startNode');
|
||||
|
||||
const parameters = ensureStartNodeParameters(
|
||||
(initial.nodes[0]?.data?.parameters || []) as any[],
|
||||
);
|
||||
expect(parameters).toHaveLength(1);
|
||||
expect(parameters[0]?.name).toBe('user_input');
|
||||
expect(parameters[0]?.systemReserved).toBe(true);
|
||||
expect(parameters[0]?.required).toBe(true);
|
||||
});
|
||||
|
||||
it('builds upstream reference candidates from start node', () => {
|
||||
const startNode: Node = {
|
||||
id: 'start_1',
|
||||
type: 'startNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: '流程开始',
|
||||
parameters: ensureStartNodeParameters(),
|
||||
},
|
||||
};
|
||||
const llmNode: Node = {
|
||||
id: 'llm_1',
|
||||
type: 'llmNode',
|
||||
position: { x: 120, y: 0 },
|
||||
data: {
|
||||
title: '大模型',
|
||||
parameters: [],
|
||||
},
|
||||
};
|
||||
const edges: Edge[] = [
|
||||
{ id: 'edge_1', source: 'start_1', target: 'llm_1' } as Edge,
|
||||
];
|
||||
|
||||
const parameters = buildEditorReferenceParameters(
|
||||
'llm_1',
|
||||
[startNode, llmNode],
|
||||
edges,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(parameters.some((item) => item.name === 'start_1.user_input')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
parameters.find((item) => item.name === 'start_1.user_input')
|
||||
?.displayName,
|
||||
).toBe('流程开始 > 用户问题');
|
||||
});
|
||||
|
||||
it('applies default binding to llm user prompt after connect', () => {
|
||||
const startNode: Node = {
|
||||
id: 'start_1',
|
||||
type: 'startNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: '流程开始',
|
||||
parameters: ensureStartNodeParameters(),
|
||||
},
|
||||
};
|
||||
const llmNode: Node = {
|
||||
id: 'llm_1',
|
||||
type: 'llmNode',
|
||||
position: { x: 120, y: 0 },
|
||||
data: {
|
||||
title: '大模型',
|
||||
parameters: [],
|
||||
userPrompt: '',
|
||||
},
|
||||
};
|
||||
const edges: Edge[] = [
|
||||
{ id: 'edge_1', source: 'start_1', target: 'llm_1' } as Edge,
|
||||
];
|
||||
|
||||
const patch = buildAutoBindingPatch(llmNode, [startNode, llmNode], edges);
|
||||
expect(patch?.userPrompt).toBe('{{start_1.user_input}}');
|
||||
expect((patch?.parameters as any[])?.[0]?.name).toBe('start_1.user_input');
|
||||
expect((patch?.[FIELD_BINDING_META_KEY] as any)?.userPrompt?.userModified)
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
it('does not auto-bind from legacy start nodes without user_input', () => {
|
||||
const legacyStartNode: Node = {
|
||||
id: 'start_legacy',
|
||||
type: 'startNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: '开始节点',
|
||||
parameters: [],
|
||||
},
|
||||
};
|
||||
const llmNode: Node = {
|
||||
id: 'llm_1',
|
||||
type: 'llmNode',
|
||||
position: { x: 120, y: 0 },
|
||||
data: {
|
||||
title: '大模型',
|
||||
parameters: [],
|
||||
userPrompt: '',
|
||||
},
|
||||
};
|
||||
const edges: Edge[] = [
|
||||
{ id: 'edge_1', source: 'start_legacy', target: 'llm_1' } as Edge,
|
||||
];
|
||||
|
||||
expect(
|
||||
buildAutoBindingPatch(llmNode, [legacyStartNode, llmNode], edges),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('clears auto-filled start bindings after disconnect', () => {
|
||||
const startNode: Node = {
|
||||
id: 'start_1',
|
||||
type: 'startNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: '流程开始',
|
||||
parameters: ensureStartNodeParameters(),
|
||||
},
|
||||
};
|
||||
const llmNode: Node = {
|
||||
id: 'llm_1',
|
||||
type: 'llmNode',
|
||||
position: { x: 120, y: 0 },
|
||||
data: {
|
||||
title: '大模型',
|
||||
userPrompt: '{{start_1.user_input}}',
|
||||
parameters: [
|
||||
{
|
||||
name: 'start_1.user_input',
|
||||
ref: 'start_1.user_input',
|
||||
refType: 'ref',
|
||||
autoManaged: true,
|
||||
},
|
||||
],
|
||||
[FIELD_BINDING_META_KEY]: {
|
||||
userPrompt: {
|
||||
autoFilledFrom: 'start_1.user_input',
|
||||
userModified: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const patch = buildFieldBindingPatch(llmNode, [startNode, llmNode], []);
|
||||
expect(patch?.userPrompt).toBe('');
|
||||
expect(patch?.parameters).toEqual([]);
|
||||
expect(patch?.[FIELD_BINDING_META_KEY]).toEqual({});
|
||||
});
|
||||
|
||||
it('removes managed param for disconnected manual upstream refs so token becomes invalid', () => {
|
||||
const startNode: Node = {
|
||||
id: 'start_1',
|
||||
type: 'startNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: '流程开始',
|
||||
parameters: ensureStartNodeParameters(),
|
||||
},
|
||||
};
|
||||
const knowledgeNode: Node = {
|
||||
id: 'knowledge_1',
|
||||
type: 'knowledgeNode',
|
||||
position: { x: 80, y: 0 },
|
||||
data: {
|
||||
title: '知识库',
|
||||
outputDefs: [
|
||||
{
|
||||
name: 'documents',
|
||||
dataType: 'String',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const llmNode: Node = {
|
||||
id: 'llm_1',
|
||||
type: 'llmNode',
|
||||
position: { x: 120, y: 0 },
|
||||
data: {
|
||||
title: '大模型',
|
||||
systemPrompt: '{{knowledge_1.documents}}',
|
||||
userPrompt: '',
|
||||
parameters: [
|
||||
{
|
||||
name: 'knowledge_1.documents',
|
||||
ref: 'knowledge_1.documents',
|
||||
refType: 'ref',
|
||||
autoManaged: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const connectedEdges: Edge[] = [
|
||||
{ id: 'edge_1', source: 'start_1', target: 'knowledge_1' } as Edge,
|
||||
{ id: 'edge_2', source: 'knowledge_1', target: 'llm_1' } as Edge,
|
||||
];
|
||||
|
||||
const connectedParameters = buildEditorReferenceParameters(
|
||||
'llm_1',
|
||||
[startNode, knowledgeNode, llmNode],
|
||||
connectedEdges,
|
||||
(llmNode.data?.parameters || []) as any[],
|
||||
);
|
||||
expect(
|
||||
connectedParameters.some((item) => item.name === 'knowledge_1.documents'),
|
||||
).toBe(true);
|
||||
|
||||
const patch = buildFieldBindingPatch(
|
||||
llmNode,
|
||||
[startNode, knowledgeNode, llmNode],
|
||||
[],
|
||||
);
|
||||
expect((patch?.parameters as any[])?.[0]?.name).toBe('knowledge_1.documents');
|
||||
expect((patch?.parameters as any[])?.[0]?.disconnected).toBe(true);
|
||||
expect((patch?.parameters as any[])?.[0]?.displayName).toBe('documents');
|
||||
expect(patch).not.toHaveProperty('systemPrompt');
|
||||
});
|
||||
|
||||
it('restores auto-filled user input binding after reconnect through upstream chain', () => {
|
||||
const startNode: Node = {
|
||||
id: 'start_1',
|
||||
type: 'startNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: '流程开始',
|
||||
parameters: ensureStartNodeParameters(),
|
||||
},
|
||||
};
|
||||
const knowledgeNode: Node = {
|
||||
id: 'knowledge_1',
|
||||
type: 'knowledgeNode',
|
||||
position: { x: 80, y: 0 },
|
||||
data: {
|
||||
title: '知识库',
|
||||
keyword: '',
|
||||
parameters: [],
|
||||
outputDefs: [
|
||||
{
|
||||
name: 'documents',
|
||||
dataType: 'String',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const llmNode: Node = {
|
||||
id: 'llm_1',
|
||||
type: 'llmNode',
|
||||
position: { x: 120, y: 0 },
|
||||
data: {
|
||||
title: '大模型',
|
||||
userPrompt: '',
|
||||
parameters: [],
|
||||
},
|
||||
};
|
||||
const edges: Edge[] = [
|
||||
{ id: 'edge_1', source: 'start_1', target: 'knowledge_1' } as Edge,
|
||||
{ id: 'edge_2', source: 'knowledge_1', target: 'llm_1' } as Edge,
|
||||
];
|
||||
|
||||
const knowledgePatch = buildFieldBindingPatch(
|
||||
knowledgeNode,
|
||||
[startNode, knowledgeNode, llmNode],
|
||||
edges,
|
||||
);
|
||||
const nextKnowledgeNode: Node = {
|
||||
...knowledgeNode,
|
||||
data: {
|
||||
...knowledgeNode.data,
|
||||
...knowledgePatch,
|
||||
},
|
||||
};
|
||||
|
||||
const llmPatch = buildFieldBindingPatch(
|
||||
llmNode,
|
||||
[startNode, nextKnowledgeNode, llmNode],
|
||||
edges,
|
||||
);
|
||||
expect(knowledgePatch?.keyword).toBe('{{start_1.user_input}}');
|
||||
expect(llmPatch?.userPrompt).toBe('{{start_1.user_input}}');
|
||||
});
|
||||
|
||||
it('applies reconnect patches sequentially so downstream nodes can restore in the same batch', () => {
|
||||
const startNode: Node = {
|
||||
id: 'start_1',
|
||||
type: 'startNode',
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
title: '流程开始',
|
||||
parameters: ensureStartNodeParameters(),
|
||||
},
|
||||
};
|
||||
const knowledgeNode: Node = {
|
||||
id: 'knowledge_1',
|
||||
type: 'knowledgeNode',
|
||||
position: { x: 80, y: 0 },
|
||||
data: {
|
||||
title: '知识库',
|
||||
keyword: '',
|
||||
parameters: [],
|
||||
outputDefs: [
|
||||
{
|
||||
name: 'documents',
|
||||
dataType: 'String',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const llmNode: Node = {
|
||||
id: 'llm_1',
|
||||
type: 'llmNode',
|
||||
position: { x: 120, y: 0 },
|
||||
data: {
|
||||
title: '大模型',
|
||||
userPrompt: '',
|
||||
parameters: [],
|
||||
},
|
||||
};
|
||||
const edges: Edge[] = [
|
||||
{ id: 'edge_1', source: 'start_1', target: 'knowledge_1' } as Edge,
|
||||
{ id: 'edge_2', source: 'knowledge_1', target: 'llm_1' } as Edge,
|
||||
];
|
||||
|
||||
const patches = buildSequentialFieldBindingPatches(
|
||||
['knowledge_1', 'llm_1'],
|
||||
[startNode, knowledgeNode, llmNode],
|
||||
edges,
|
||||
);
|
||||
|
||||
expect(patches).toHaveLength(2);
|
||||
expect(patches[0]).toMatchObject({
|
||||
nodeId: 'knowledge_1',
|
||||
patch: {
|
||||
keyword: '{{start_1.user_input}}',
|
||||
},
|
||||
});
|
||||
expect(patches[1]).toMatchObject({
|
||||
nodeId: 'llm_1',
|
||||
patch: {
|
||||
userPrompt: '{{start_1.user_input}}',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts only used parameters for llm single run', () => {
|
||||
const parameters = ensureStartNodeParameters().map((item) => ({
|
||||
...item,
|
||||
name: 'start_1.user_input',
|
||||
ref: 'start_1.user_input',
|
||||
formLabel: '流程开始 > 用户问题',
|
||||
displayName: '流程开始 > 用户问题',
|
||||
systemReserved: false,
|
||||
autoManaged: true,
|
||||
}));
|
||||
const result = buildSingleRunParameters({
|
||||
type: 'llmNode',
|
||||
data: {
|
||||
userPrompt: '请回答 {{start_1.user_input}}',
|
||||
systemPrompt: '系统',
|
||||
parameters,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.formLabel).toBe('流程开始 > 用户问题');
|
||||
expect(result[0]?.required).toBe(true);
|
||||
});
|
||||
|
||||
it('builds field-mode single run model for llm node', () => {
|
||||
const parameters = ensureStartNodeParameters().map((item) => ({
|
||||
...item,
|
||||
name: 'start_1.user_input',
|
||||
ref: 'start_1.user_input',
|
||||
formLabel: '流程开始 > 用户问题',
|
||||
displayName: '流程开始 > 用户问题',
|
||||
systemReserved: false,
|
||||
autoManaged: true,
|
||||
}));
|
||||
|
||||
const result = buildSingleRunModel({
|
||||
type: 'llmNode',
|
||||
data: {
|
||||
userPrompt: '请回答 {{start_1.user_input}}',
|
||||
systemPrompt: '你是助手',
|
||||
parameters,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.mode).toBe('fields');
|
||||
expect(result.fields.map((item) => item.key)).toEqual([
|
||||
'systemPrompt',
|
||||
'userPrompt',
|
||||
]);
|
||||
expect(result.parameters).toHaveLength(1);
|
||||
expect(result.parameters[0]?.formLabel).toBe('流程开始 > 用户问题');
|
||||
});
|
||||
|
||||
it('keeps legacy start node parameters unchanged during single run build', () => {
|
||||
const legacyParameters = [
|
||||
{
|
||||
id: 'legacy_1',
|
||||
name: 'legacy_input',
|
||||
refType: 'input',
|
||||
dataType: 'String',
|
||||
},
|
||||
];
|
||||
|
||||
const result = buildSingleRunParameters({
|
||||
type: 'startNode',
|
||||
data: {
|
||||
parameters: legacyParameters,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(legacyParameters);
|
||||
});
|
||||
|
||||
it('normalizes only start nodes that already contain fixed user_input', () => {
|
||||
const normalizedWorkflow = normalizeWorkflowStartNodes({
|
||||
nodes: [
|
||||
{
|
||||
id: 'start_new',
|
||||
type: 'startNode',
|
||||
data: {
|
||||
parameters: [
|
||||
{
|
||||
name: 'user_input',
|
||||
refType: 'input',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'start_legacy',
|
||||
type: 'startNode',
|
||||
data: {
|
||||
parameters: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
});
|
||||
|
||||
expect(normalizedWorkflow.nodes[0]?.data?.parameters?.[0]?.required).toBe(
|
||||
true,
|
||||
);
|
||||
expect(normalizedWorkflow.nodes[1]?.data?.parameters).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,858 @@
|
||||
import type { Edge, Node } from '@xyflow/svelte';
|
||||
|
||||
import type { Parameter } from '../types';
|
||||
import { getTokenRanges } from '../components/utils/paramToken';
|
||||
import { genShortId } from '../components/utils/IdGen';
|
||||
|
||||
export const START_NODE_TYPE = 'startNode';
|
||||
export const LLM_NODE_TYPE = 'llmNode';
|
||||
export const KNOWLEDGE_NODE_TYPE = 'knowledgeNode';
|
||||
export const SYSTEM_START_PARAM_NAME = 'user_input';
|
||||
export const SYSTEM_START_PARAM_LABEL = '用户问题';
|
||||
export const FIELD_BINDING_META_KEY = 'fieldBindingMeta';
|
||||
|
||||
export type SingleRunFieldDescriptor = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
multiline?: boolean;
|
||||
};
|
||||
|
||||
export type SingleRunModel =
|
||||
| {
|
||||
mode: 'parameters';
|
||||
parameters: Parameter[];
|
||||
fields: [];
|
||||
}
|
||||
| {
|
||||
mode: 'fields';
|
||||
parameters: Parameter[];
|
||||
fields: SingleRunFieldDescriptor[];
|
||||
};
|
||||
|
||||
type FieldBindingMeta = Record<
|
||||
string,
|
||||
{
|
||||
autoFilledFrom?: string;
|
||||
userModified?: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
function asString(value: unknown) {
|
||||
return value == null ? '' : String(value);
|
||||
}
|
||||
|
||||
function cloneParameter(parameter: Parameter): Parameter {
|
||||
return {
|
||||
...parameter,
|
||||
children: parameter.children?.map(cloneParameter),
|
||||
};
|
||||
}
|
||||
|
||||
function ensureParameterId(parameter: Parameter): Parameter {
|
||||
const cloned = cloneParameter(parameter);
|
||||
if (!cloned.id) {
|
||||
cloned.id = genShortId();
|
||||
}
|
||||
if (cloned.children?.length) {
|
||||
cloned.children = cloned.children.map(ensureParameterId);
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function getNodeTitle(node?: Node | null) {
|
||||
return asString(node?.data?.title).trim() || '节点';
|
||||
}
|
||||
|
||||
function getParameterLabel(parameter?: Parameter | null) {
|
||||
return (
|
||||
asString(parameter?.formLabel).trim() ||
|
||||
asString(parameter?.displayName).trim() ||
|
||||
asString(parameter?.name).trim() ||
|
||||
'参数'
|
||||
);
|
||||
}
|
||||
|
||||
function buildDisconnectedDisplayName(parameter: Parameter) {
|
||||
const displayName =
|
||||
asString(parameter.displayName).trim() ||
|
||||
asString(parameter.formLabel).trim();
|
||||
if (displayName) {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
const name = asString(parameter.name).trim();
|
||||
if (!name) {
|
||||
return '参数';
|
||||
}
|
||||
const segments = name.split('.');
|
||||
return segments[segments.length - 1] || name;
|
||||
}
|
||||
|
||||
function flattenNodeRefs(
|
||||
currentNodeId: string,
|
||||
edges: Edge[],
|
||||
refNodeIds: string[],
|
||||
visited: Set<string>,
|
||||
) {
|
||||
if (visited.has(currentNodeId)) {
|
||||
return;
|
||||
}
|
||||
visited.add(currentNodeId);
|
||||
|
||||
for (const edge of edges) {
|
||||
if (edge.target === currentNodeId && edge.source) {
|
||||
refNodeIds.push(edge.source);
|
||||
flattenNodeRefs(edge.source, edges, refNodeIds, visited);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function flattenOutputDefs(
|
||||
node: Node,
|
||||
parameters: Parameter[],
|
||||
parentPath = '',
|
||||
parentLabel = '',
|
||||
): Parameter[] {
|
||||
if (!parameters.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return parameters.flatMap((parameter) => {
|
||||
const rawName = asString(parameter.name).trim();
|
||||
if (!rawName) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const path = parentPath ? `${parentPath}.${rawName}` : rawName;
|
||||
const label = parentLabel
|
||||
? `${parentLabel}.${getParameterLabel(parameter)}`
|
||||
: getParameterLabel(parameter);
|
||||
const fullRef = `${node.id}.${path}`;
|
||||
const baseCandidate: Parameter = ensureParameterId({
|
||||
name: fullRef,
|
||||
ref: fullRef,
|
||||
refType: 'ref',
|
||||
dataType: parameter.dataType || 'String',
|
||||
displayName: `${getNodeTitle(node)} > ${label}`,
|
||||
formLabel: `${getNodeTitle(node)} > ${label}`,
|
||||
nameDisabled: true,
|
||||
dataTypeDisabled: true,
|
||||
deleteDisabled: true,
|
||||
autoManaged: true,
|
||||
});
|
||||
|
||||
const children = flattenOutputDefs(
|
||||
node,
|
||||
parameter.children || [],
|
||||
path,
|
||||
label,
|
||||
);
|
||||
|
||||
return [baseCandidate, ...children];
|
||||
});
|
||||
}
|
||||
|
||||
function getNodeReferenceParameters(node: Node): Parameter[] {
|
||||
if (node.type === START_NODE_TYPE) {
|
||||
const parameters = Array.isArray(node.data?.parameters)
|
||||
? (node.data.parameters as Parameter[])
|
||||
: [];
|
||||
return parameters
|
||||
.filter((parameter) => asString(parameter.name).trim())
|
||||
.map((parameter) =>
|
||||
ensureParameterId({
|
||||
...cloneParameter(parameter),
|
||||
name: `${node.id}.${asString(parameter.name).trim()}`,
|
||||
ref: `${node.id}.${asString(parameter.name).trim()}`,
|
||||
refType: 'ref',
|
||||
displayName: `${getNodeTitle(node)} > ${getParameterLabel(parameter)}`,
|
||||
formLabel: `${getNodeTitle(node)} > ${getParameterLabel(parameter)}`,
|
||||
nameDisabled: true,
|
||||
dataTypeDisabled: true,
|
||||
deleteDisabled: true,
|
||||
autoManaged: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const outputDefs = Array.isArray(node.data?.outputDefs)
|
||||
? (node.data.outputDefs as Parameter[])
|
||||
: [];
|
||||
return flattenOutputDefs(node, outputDefs);
|
||||
}
|
||||
|
||||
function uniqueParameters(parameters: Parameter[]) {
|
||||
const result: Parameter[] = [];
|
||||
const indexMap = new Map<string, number>();
|
||||
|
||||
for (const parameter of parameters) {
|
||||
const key = asString(parameter.name).trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
const existingIndex = indexMap.get(key);
|
||||
if (existingIndex == null) {
|
||||
indexMap.set(key, result.length);
|
||||
result.push(ensureParameterId(parameter));
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingParameter = result[existingIndex]!;
|
||||
result[existingIndex] = ensureParameterId({
|
||||
...existingParameter,
|
||||
...parameter,
|
||||
children:
|
||||
parameter.children?.length || existingParameter.children?.length
|
||||
? parameter.children || existingParameter.children
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function findParameterByName(parameters: Parameter[], name: string) {
|
||||
return parameters.find((parameter) => asString(parameter.name).trim() === name);
|
||||
}
|
||||
|
||||
function toManagedRefParameter(refPath: string, candidate?: Parameter): Parameter {
|
||||
return ensureParameterId({
|
||||
name: refPath,
|
||||
ref: candidate?.ref || refPath,
|
||||
refType: 'ref',
|
||||
dataType: candidate?.dataType || 'String',
|
||||
displayName: candidate?.displayName || candidate?.formLabel || refPath,
|
||||
formLabel: candidate?.formLabel || candidate?.displayName || refPath,
|
||||
description: candidate?.description,
|
||||
nameDisabled: true,
|
||||
dataTypeDisabled: true,
|
||||
deleteDisabled: true,
|
||||
autoManaged: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function createSystemStartParameter(): Parameter {
|
||||
return ensureParameterId({
|
||||
name: SYSTEM_START_PARAM_NAME,
|
||||
dataType: 'String',
|
||||
refType: 'input',
|
||||
required: true,
|
||||
contentType: 'text',
|
||||
formType: 'input',
|
||||
formLabel: SYSTEM_START_PARAM_LABEL,
|
||||
formPlaceholder: '请输入用户问题',
|
||||
displayName: `流程开始 > ${SYSTEM_START_PARAM_LABEL}`,
|
||||
nameDisabled: true,
|
||||
dataTypeDisabled: true,
|
||||
deleteDisabled: true,
|
||||
requiredDisabled: true,
|
||||
systemReserved: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function isSystemStartParameter(parameter?: Parameter | null) {
|
||||
if (!parameter) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
parameter.systemReserved === true ||
|
||||
(asString(parameter.name).trim() === SYSTEM_START_PARAM_NAME &&
|
||||
asString(parameter.refType).trim() === 'input')
|
||||
);
|
||||
}
|
||||
|
||||
export function hasSystemStartParameter(parameters?: Parameter[] | null) {
|
||||
return Array.isArray(parameters)
|
||||
? parameters.some((parameter) => isSystemStartParameter(parameter))
|
||||
: false;
|
||||
}
|
||||
|
||||
export function ensureStartNodeParameters(parameters?: Parameter[]) {
|
||||
const source = Array.isArray(parameters)
|
||||
? parameters.map(cloneParameter)
|
||||
: [];
|
||||
const fixed = createSystemStartParameter();
|
||||
const index = source.findIndex((parameter) => isSystemStartParameter(parameter));
|
||||
|
||||
if (index >= 0) {
|
||||
const existing = source[index]!;
|
||||
source[index] = ensureParameterId({
|
||||
...existing,
|
||||
...fixed,
|
||||
id: existing.id || fixed.id,
|
||||
});
|
||||
} else {
|
||||
source.unshift(fixed);
|
||||
}
|
||||
|
||||
const customParameters = source
|
||||
.filter((parameter) => !isSystemStartParameter(parameter))
|
||||
.map((parameter) => ensureParameterId(parameter));
|
||||
|
||||
return [source.find((parameter) => isSystemStartParameter(parameter))!, ...customParameters];
|
||||
}
|
||||
|
||||
export function createInitialWorkflowData() {
|
||||
return {
|
||||
nodes: [
|
||||
{
|
||||
id: `node_${genShortId()}`,
|
||||
type: START_NODE_TYPE,
|
||||
position: { x: 80, y: 180 },
|
||||
data: {
|
||||
title: '开始节点',
|
||||
parameters: ensureStartNodeParameters(),
|
||||
},
|
||||
} satisfies Node,
|
||||
],
|
||||
edges: [],
|
||||
viewport: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
zoom: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWorkflowStartNodes<T extends Record<string, any>>(data: T): T {
|
||||
if (!data || typeof data !== 'object' || !Array.isArray(data.nodes)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
const nextNodes = data.nodes.map((node) => {
|
||||
if (node?.type !== START_NODE_TYPE) {
|
||||
return node;
|
||||
}
|
||||
const currentParameters = Array.isArray(node.data?.parameters)
|
||||
? (node.data.parameters as Parameter[])
|
||||
: [];
|
||||
if (!hasSystemStartParameter(currentParameters)) {
|
||||
return node;
|
||||
}
|
||||
const normalizedParameters = ensureStartNodeParameters(currentParameters);
|
||||
if (JSON.stringify(currentParameters) === JSON.stringify(normalizedParameters)) {
|
||||
return node;
|
||||
}
|
||||
changed = true;
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...(node.data || {}),
|
||||
parameters: normalizedParameters,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
if (!changed) {
|
||||
return data;
|
||||
}
|
||||
return {
|
||||
...data,
|
||||
nodes: nextNodes,
|
||||
};
|
||||
}
|
||||
|
||||
export function isWorkflowDataEmpty(data: any) {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return true;
|
||||
}
|
||||
const nodes = Array.isArray(data.nodes) ? data.nodes : [];
|
||||
const edges = Array.isArray(data.edges) ? data.edges : [];
|
||||
return nodes.length === 0 && edges.length === 0;
|
||||
}
|
||||
|
||||
export function buildEditorReferenceParameters(
|
||||
currentNodeId: string,
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
existingParameters?: Parameter[],
|
||||
) {
|
||||
const refNodeIds: string[] = [];
|
||||
flattenNodeRefs(currentNodeId, edges, refNodeIds, new Set<string>());
|
||||
|
||||
const upstreamParameters = nodes
|
||||
.filter((node) => refNodeIds.includes(node.id))
|
||||
.flatMap((node) => getNodeReferenceParameters(node));
|
||||
|
||||
const upstreamNameSet = new Set(
|
||||
upstreamParameters.map((parameter) => asString(parameter.name).trim()),
|
||||
);
|
||||
|
||||
const disconnectedParameters = (existingParameters || [])
|
||||
.map((parameter) => ensureParameterId(parameter))
|
||||
.filter((parameter) => {
|
||||
const name = asString(parameter.name).trim();
|
||||
if (!name || upstreamNameSet.has(name)) {
|
||||
return false;
|
||||
}
|
||||
return parameter.autoManaged === true;
|
||||
})
|
||||
.map((parameter) =>
|
||||
ensureParameterId({
|
||||
...parameter,
|
||||
resolved: false,
|
||||
disconnected: true,
|
||||
displayName: buildDisconnectedDisplayName(parameter),
|
||||
formLabel: buildDisconnectedDisplayName(parameter),
|
||||
} as Parameter & { resolved: boolean; disconnected: boolean }),
|
||||
);
|
||||
|
||||
return uniqueParameters([...disconnectedParameters, ...upstreamParameters]);
|
||||
}
|
||||
|
||||
export function syncManagedParametersForFields(
|
||||
existingParameters: Parameter[] | undefined,
|
||||
candidateParameters: Parameter[],
|
||||
fieldValues: Record<string, string | undefined>,
|
||||
) {
|
||||
const currentParameters = Array.isArray(existingParameters)
|
||||
? existingParameters.map(ensureParameterId)
|
||||
: [];
|
||||
const tokenKeys = Array.from(
|
||||
new Set(
|
||||
Object.values(fieldValues)
|
||||
.flatMap((value) => getTokenRanges(asString(value)).map((token) => token.key))
|
||||
.filter((value) => value.trim().length > 0),
|
||||
),
|
||||
);
|
||||
const usedTokenKeySet = new Set(tokenKeys);
|
||||
const candidateKeySet = new Set(
|
||||
candidateParameters
|
||||
.map((parameter) => asString(parameter.name).trim())
|
||||
.filter((value) => value.length > 0),
|
||||
);
|
||||
const nextParameters = currentParameters
|
||||
.filter((parameter) => {
|
||||
if (parameter.autoManaged !== true) {
|
||||
return true;
|
||||
}
|
||||
const parameterName = asString(parameter.name).trim();
|
||||
return usedTokenKeySet.has(parameterName) && candidateKeySet.has(parameterName);
|
||||
})
|
||||
.map((parameter) => {
|
||||
if (parameter.autoManaged !== true) {
|
||||
return parameter;
|
||||
}
|
||||
const candidate = findParameterByName(
|
||||
candidateParameters,
|
||||
asString(parameter.name).trim(),
|
||||
) as (Parameter & { disconnected?: boolean }) | undefined;
|
||||
if (!candidate) {
|
||||
return parameter;
|
||||
}
|
||||
return {
|
||||
...parameter,
|
||||
displayName: candidate.displayName || parameter.displayName,
|
||||
formLabel: candidate.formLabel || parameter.formLabel,
|
||||
disconnected: candidate.disconnected === true ? true : undefined,
|
||||
} as Parameter;
|
||||
});
|
||||
|
||||
for (const tokenKey of tokenKeys) {
|
||||
if (!candidateKeySet.has(tokenKey)) {
|
||||
continue;
|
||||
}
|
||||
if (findParameterByName(nextParameters, tokenKey)) {
|
||||
continue;
|
||||
}
|
||||
const candidate = findParameterByName(candidateParameters, tokenKey);
|
||||
nextParameters.push(toManagedRefParameter(tokenKey, candidate));
|
||||
}
|
||||
|
||||
return uniqueParameters(nextParameters);
|
||||
}
|
||||
|
||||
export function toToken(refPath: string) {
|
||||
return `{{${refPath}}}`;
|
||||
}
|
||||
|
||||
function getFieldBindingMeta(data: Record<string, any>) {
|
||||
return ((data?.[FIELD_BINDING_META_KEY] || {}) as FieldBindingMeta) || {};
|
||||
}
|
||||
|
||||
export function updateFieldBindingMeta(
|
||||
currentData: Record<string, any>,
|
||||
fieldName: string,
|
||||
nextValue: string,
|
||||
) {
|
||||
const nextMeta: FieldBindingMeta = {
|
||||
...getFieldBindingMeta(currentData),
|
||||
};
|
||||
const currentFieldMeta = nextMeta[fieldName];
|
||||
if (!currentFieldMeta?.autoFilledFrom) {
|
||||
return nextMeta;
|
||||
}
|
||||
|
||||
nextMeta[fieldName] = {
|
||||
...currentFieldMeta,
|
||||
userModified: nextValue !== toToken(currentFieldMeta.autoFilledFrom),
|
||||
};
|
||||
return nextMeta;
|
||||
}
|
||||
|
||||
function resolveNearestStartNodeRef(
|
||||
targetNodeId: string,
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
) {
|
||||
const queue = [targetNodeId];
|
||||
const visited = new Set<string>();
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentId = queue.shift()!;
|
||||
if (visited.has(currentId)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(currentId);
|
||||
|
||||
for (const edge of edges) {
|
||||
if (edge.target !== currentId || !edge.source) {
|
||||
continue;
|
||||
}
|
||||
const sourceNode = nodes.find((node) => node.id === edge.source);
|
||||
if (!sourceNode) {
|
||||
continue;
|
||||
}
|
||||
if (sourceNode.type === START_NODE_TYPE) {
|
||||
const parameters = Array.isArray(sourceNode.data?.parameters)
|
||||
? ((sourceNode.data?.parameters as Parameter[]) || [])
|
||||
: [];
|
||||
const systemParam = parameters.find((parameter) =>
|
||||
isSystemStartParameter(parameter),
|
||||
);
|
||||
if (!systemParam) {
|
||||
continue;
|
||||
}
|
||||
return {
|
||||
refPath: `${sourceNode.id}.${SYSTEM_START_PARAM_NAME}`,
|
||||
displayName: `${getNodeTitle(sourceNode)} > ${getParameterLabel(systemParam)}`,
|
||||
};
|
||||
}
|
||||
queue.push(sourceNode.id);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function canApplyAutoBinding(
|
||||
fieldValue: string | undefined,
|
||||
fieldMeta: FieldBindingMeta[string] | undefined,
|
||||
expectedRefPath: string,
|
||||
) {
|
||||
if (fieldMeta?.userModified) {
|
||||
return false;
|
||||
}
|
||||
const currentValue = asString(fieldValue).trim();
|
||||
if (!currentValue) {
|
||||
return true;
|
||||
}
|
||||
return currentValue === toToken(expectedRefPath);
|
||||
}
|
||||
|
||||
function getSupportedFieldKeys(nodeType: string | undefined) {
|
||||
if (nodeType === LLM_NODE_TYPE) {
|
||||
return ['systemPrompt', 'userPrompt'];
|
||||
}
|
||||
if (nodeType === KNOWLEDGE_NODE_TYPE) {
|
||||
return ['keyword', 'limit'];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getSingleRunFieldDescriptors(
|
||||
node: Pick<Node, 'type' | 'data'>,
|
||||
): SingleRunFieldDescriptor[] {
|
||||
if (!node?.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (node.type === LLM_NODE_TYPE) {
|
||||
return [
|
||||
{
|
||||
key: 'systemPrompt',
|
||||
label: '系统提示词',
|
||||
value: asString((node.data as Record<string, any>).systemPrompt),
|
||||
placeholder: '未设置',
|
||||
multiline: true,
|
||||
},
|
||||
{
|
||||
key: 'userPrompt',
|
||||
label: '用户提示词',
|
||||
value: asString((node.data as Record<string, any>).userPrompt),
|
||||
placeholder: '未设置',
|
||||
multiline: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (node.type === KNOWLEDGE_NODE_TYPE) {
|
||||
return [
|
||||
{
|
||||
key: 'keyword',
|
||||
label: '关键词',
|
||||
value: asString((node.data as Record<string, any>).keyword),
|
||||
placeholder: '未设置',
|
||||
},
|
||||
{
|
||||
key: 'limit',
|
||||
label: '获取数据量',
|
||||
value: asString((node.data as Record<string, any>).limit),
|
||||
placeholder: '未设置',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function getAutoBindingFieldKeys(nodeType: string | undefined) {
|
||||
if (nodeType === LLM_NODE_TYPE) {
|
||||
return ['userPrompt'];
|
||||
}
|
||||
if (nodeType === KNOWLEDGE_NODE_TYPE) {
|
||||
return ['keyword'];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function buildFieldBindingPatch(
|
||||
node: Node,
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
) {
|
||||
if (!node?.id || !node?.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldKeys = getSupportedFieldKeys(node.type);
|
||||
if (fieldKeys.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (node.data || {}) as Record<string, any>;
|
||||
const startRef = resolveNearestStartNodeRef(node.id, nodes, edges);
|
||||
const patch: Record<string, any> = {};
|
||||
const nextFieldValues: Record<string, string | undefined> = Object.fromEntries(
|
||||
fieldKeys.map((fieldKey) => [fieldKey, asString(data[fieldKey])]),
|
||||
);
|
||||
const nextMeta: FieldBindingMeta = {
|
||||
...getFieldBindingMeta(data),
|
||||
};
|
||||
|
||||
const applyField = (fieldName: string) => {
|
||||
if (!startRef) {
|
||||
return;
|
||||
}
|
||||
const fieldMeta = nextMeta[fieldName];
|
||||
if (!canApplyAutoBinding(nextFieldValues[fieldName], fieldMeta, startRef.refPath)) {
|
||||
return;
|
||||
}
|
||||
const tokenValue = toToken(startRef.refPath);
|
||||
if (asString(nextFieldValues[fieldName]).trim() !== tokenValue) {
|
||||
patch[fieldName] = tokenValue;
|
||||
}
|
||||
nextFieldValues[fieldName] = tokenValue;
|
||||
nextMeta[fieldName] = {
|
||||
autoFilledFrom: startRef.refPath,
|
||||
userModified: false,
|
||||
};
|
||||
};
|
||||
|
||||
const clearField = (fieldName: string, fieldMeta: NonNullable<FieldBindingMeta[string]>) => {
|
||||
const currentValue = asString(nextFieldValues[fieldName]).trim();
|
||||
const autoTokenValue = toToken(fieldMeta.autoFilledFrom || '');
|
||||
if (currentValue && currentValue !== autoTokenValue) {
|
||||
return;
|
||||
}
|
||||
if (currentValue) {
|
||||
patch[fieldName] = '';
|
||||
}
|
||||
nextFieldValues[fieldName] = '';
|
||||
delete nextMeta[fieldName];
|
||||
};
|
||||
|
||||
for (const fieldName of getAutoBindingFieldKeys(node.type)) {
|
||||
const fieldMeta = nextMeta[fieldName];
|
||||
if (!fieldMeta?.autoFilledFrom || fieldMeta.userModified) {
|
||||
continue;
|
||||
}
|
||||
if (startRef?.refPath === fieldMeta.autoFilledFrom) {
|
||||
continue;
|
||||
}
|
||||
clearField(fieldName, fieldMeta);
|
||||
}
|
||||
|
||||
for (const fieldName of getAutoBindingFieldKeys(node.type)) {
|
||||
applyField(fieldName);
|
||||
}
|
||||
|
||||
const editorParameters = buildEditorReferenceParameters(
|
||||
node.id,
|
||||
nodes,
|
||||
edges,
|
||||
(data.parameters as Parameter[]) || [],
|
||||
);
|
||||
patch.parameters = syncManagedParametersForFields(
|
||||
(data.parameters as Parameter[]) || [],
|
||||
editorParameters,
|
||||
nextFieldValues,
|
||||
);
|
||||
patch[FIELD_BINDING_META_KEY] = nextMeta;
|
||||
|
||||
const currentParameters = JSON.stringify((data.parameters as Parameter[]) || []);
|
||||
if (JSON.stringify(patch.parameters) === currentParameters) {
|
||||
delete patch.parameters;
|
||||
}
|
||||
|
||||
if (
|
||||
JSON.stringify(nextMeta) === JSON.stringify(getFieldBindingMeta(data))
|
||||
) {
|
||||
delete patch[FIELD_BINDING_META_KEY];
|
||||
}
|
||||
|
||||
if (Object.keys(patch).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return patch;
|
||||
}
|
||||
|
||||
export function buildAutoBindingPatch(
|
||||
node: Node,
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
) {
|
||||
return buildFieldBindingPatch(node, nodes, edges);
|
||||
}
|
||||
|
||||
export function buildSequentialFieldBindingPatches(
|
||||
nodeIds: string[],
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
) {
|
||||
const uniqueNodeIds = Array.from(
|
||||
new Set(nodeIds.map((nodeId) => asString(nodeId).trim()).filter(Boolean)),
|
||||
);
|
||||
if (uniqueNodeIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const workingNodes = nodes.map((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...((node.data || {}) as Record<string, any>),
|
||||
},
|
||||
}));
|
||||
const patches: Array<{ nodeId: string; patch: Record<string, any> }> = [];
|
||||
|
||||
for (const nodeId of uniqueNodeIds) {
|
||||
const nodeIndex = workingNodes.findIndex((item) => item.id === nodeId);
|
||||
if (nodeIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
const currentNode = workingNodes[nodeIndex]!;
|
||||
const patch = buildFieldBindingPatch(currentNode, workingNodes, edges);
|
||||
if (!patch) {
|
||||
continue;
|
||||
}
|
||||
patches.push({ nodeId, patch });
|
||||
workingNodes[nodeIndex] = {
|
||||
...currentNode,
|
||||
data: {
|
||||
...((currentNode.data || {}) as Record<string, any>),
|
||||
...patch,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return patches;
|
||||
}
|
||||
|
||||
export function buildSingleRunParameters(node: Pick<Node, 'type' | 'data'> | null | undefined) {
|
||||
if (!node?.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (node.type === START_NODE_TYPE) {
|
||||
const parameters = Array.isArray(node.data.parameters)
|
||||
? ((node.data.parameters as Parameter[]) || [])
|
||||
: [];
|
||||
return hasSystemStartParameter(parameters)
|
||||
? ensureStartNodeParameters(parameters)
|
||||
: parameters;
|
||||
}
|
||||
|
||||
const parameters = Array.isArray(node.data.parameters)
|
||||
? (node.data.parameters as Parameter[]).map(ensureParameterId)
|
||||
: [];
|
||||
|
||||
const fieldKeys =
|
||||
node.type === LLM_NODE_TYPE
|
||||
? ['systemPrompt', 'userPrompt']
|
||||
: node.type === KNOWLEDGE_NODE_TYPE
|
||||
? ['keyword', 'limit']
|
||||
: [];
|
||||
|
||||
if (fieldKeys.length === 0) {
|
||||
return parameters;
|
||||
}
|
||||
|
||||
const usedTokenKeys = Array.from(
|
||||
new Set(
|
||||
fieldKeys.flatMap((fieldKey) =>
|
||||
getTokenRanges(asString((node.data as Record<string, any>)[fieldKey])).map(
|
||||
(token) => token.key,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return parameters.filter((parameter) =>
|
||||
usedTokenKeys.includes(asString(parameter.name).trim()),
|
||||
).map((parameter) =>
|
||||
ensureParameterId({
|
||||
...parameter,
|
||||
required: parameter.required ?? true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function buildSingleRunModel(
|
||||
node: Pick<Node, 'type' | 'data'> | null | undefined,
|
||||
): SingleRunModel {
|
||||
const parameters = buildSingleRunParameters(node);
|
||||
if (!node?.data) {
|
||||
return {
|
||||
mode: 'parameters',
|
||||
parameters,
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (node.type === START_NODE_TYPE) {
|
||||
return {
|
||||
mode: 'parameters',
|
||||
parameters,
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
|
||||
const fields = getSingleRunFieldDescriptors(node);
|
||||
if (fields.length === 0) {
|
||||
return {
|
||||
mode: 'parameters',
|
||||
parameters,
|
||||
fields: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'fields',
|
||||
parameters,
|
||||
fields,
|
||||
};
|
||||
}
|
||||
12
easyflow-ui-admin/packages/tinyflow-ui/vitest.config.ts
Normal file
12
easyflow-ui-admin/packages/tinyflow-ui/vitest.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte({ emitCss: false })],
|
||||
resolve: {
|
||||
conditions: ['browser']
|
||||
},
|
||||
test: {
|
||||
environment: 'happy-dom'
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,7 @@ interface BotInfo {
|
||||
displayPublishStatus?: string;
|
||||
created: string;
|
||||
createdBy: number;
|
||||
createdByName?: string;
|
||||
deptId: number;
|
||||
description: string;
|
||||
icon: string;
|
||||
|
||||
Reference in New Issue
Block a user