feat: 优化工作流字段化参数配置
- 开始节点固定 user_input 并区分系统入口与自定义参数 - LLM 与知识库节点切换为字段值加上游引用配置 - 单节点调试改为字段预览与上游引用输入模式
This commit is contained in:
@@ -4,6 +4,7 @@ import { useRoute } from 'vue-router';
|
|||||||
|
|
||||||
import { usePreferences } from '@easyflow/preferences';
|
import { usePreferences } from '@easyflow/preferences';
|
||||||
import { getOptions, sortNodes } from '@easyflow/utils';
|
import { getOptions, sortNodes } from '@easyflow/utils';
|
||||||
|
import { Tinyflow } from '@tinyflow-ai/vue';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -11,7 +12,6 @@ import {
|
|||||||
Close,
|
Close,
|
||||||
Promotion,
|
Promotion,
|
||||||
} from '@element-plus/icons-vue';
|
} from '@element-plus/icons-vue';
|
||||||
import { Tinyflow } from '@tinyflow-ai/vue';
|
|
||||||
import {
|
import {
|
||||||
ElButton,
|
ElButton,
|
||||||
ElDrawer,
|
ElDrawer,
|
||||||
@@ -37,6 +37,11 @@ import WorkflowSteps from '#/views/ai/workflow/components/WorkflowSteps.vue';
|
|||||||
|
|
||||||
import { getCustomNode } from './customNode/index';
|
import { getCustomNode } from './customNode/index';
|
||||||
import nodeNames from './customNode/nodeNames';
|
import nodeNames from './customNode/nodeNames';
|
||||||
|
import {
|
||||||
|
createInitialWorkflowData,
|
||||||
|
isWorkflowDataEmpty,
|
||||||
|
normalizeWorkflowStartNodes,
|
||||||
|
} from '../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
|
||||||
|
|
||||||
import '@tinyflow-ai/vue/dist/index.css';
|
import '@tinyflow-ai/vue/dist/index.css';
|
||||||
|
|
||||||
@@ -348,9 +353,10 @@ async function handleSave(showMsg: boolean = false): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
saveLoading.value = true;
|
saveLoading.value = true;
|
||||||
try {
|
try {
|
||||||
|
const content = normalizeWorkflowStartNodes(tinyflowRef.value?.getData());
|
||||||
const res = await api.post('/api/v1/workflow/update', {
|
const res = await api.post('/api/v1/workflow/update', {
|
||||||
id: workflowId.value,
|
id: workflowId.value,
|
||||||
content: tinyflowRef.value?.getData(),
|
content,
|
||||||
});
|
});
|
||||||
if (res.errorCode === 0 && showMsg) {
|
if (res.errorCode === 0 && showMsg) {
|
||||||
ElMessage.success(res.message);
|
ElMessage.success(res.message);
|
||||||
@@ -365,9 +371,12 @@ async function handleSave(showMsg: boolean = false): Promise<boolean> {
|
|||||||
async function getWorkflowInfo(workflowId: any) {
|
async function getWorkflowInfo(workflowId: any) {
|
||||||
return api.get(`/api/v1/workflow/detail?id=${workflowId}`).then((res) => {
|
return api.get(`/api/v1/workflow/detail?id=${workflowId}`).then((res) => {
|
||||||
workflowInfo.value = res.data;
|
workflowInfo.value = res.data;
|
||||||
tinyFlowData.value = workflowInfo.value.content
|
const parsedContent = workflowInfo.value.content
|
||||||
? JSON.parse(workflowInfo.value.content)
|
? JSON.parse(workflowInfo.value.content)
|
||||||
: {};
|
: {};
|
||||||
|
tinyFlowData.value = isWorkflowDataEmpty(parsedContent)
|
||||||
|
? createInitialWorkflowData()
|
||||||
|
: parsedContent;
|
||||||
syncNavTitle(workflowInfo.value?.title || '');
|
syncNavTitle(workflowInfo.value?.title || '');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -406,7 +415,7 @@ async function runCheck(
|
|||||||
stage: WorkflowCheckStage,
|
stage: WorkflowCheckStage,
|
||||||
silentPass: boolean = false,
|
silentPass: boolean = false,
|
||||||
) {
|
) {
|
||||||
const content = tinyflowRef.value?.getData();
|
const content = normalizeWorkflowStartNodes(tinyflowRef.value?.getData());
|
||||||
if (!content) {
|
if (!content) {
|
||||||
ElMessage.error($t('aiWorkflow.checkContentEmpty'));
|
ElMessage.error($t('aiWorkflow.checkContentEmpty'));
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { FormInstance } from 'element-plus';
|
import type { FormInstance } from 'element-plus';
|
||||||
|
|
||||||
import { ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import { Position } from '@element-plus/icons-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 { api } from '#/api/request';
|
||||||
import ShowJson from '#/components/json/ShowJson.vue';
|
import ShowJson from '#/components/json/ShowJson.vue';
|
||||||
import { $t } from '#/locales';
|
import { $t } from '#/locales';
|
||||||
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
|
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
|
||||||
|
import { buildSingleRunModel } from '../../../../../../packages/tinyflow-ui/src/utils/workflowNodeFields';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
workflowId: any;
|
workflowId: any;
|
||||||
@@ -22,6 +23,52 @@ const singleRunForm = ref<FormInstance>();
|
|||||||
const runParams = ref<any>({});
|
const runParams = ref<any>({});
|
||||||
const submitLoading = ref(false);
|
const submitLoading = ref(false);
|
||||||
const result = ref<any>('');
|
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() {
|
function submit() {
|
||||||
singleRunForm.value?.validate((valid) => {
|
singleRunForm.value?.validate((valid) => {
|
||||||
if (valid) {
|
if (valid) {
|
||||||
@@ -48,9 +95,75 @@ function submit() {
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<ElForm label-position="top" ref="singleRunForm" :model="runParams">
|
<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="singleRunParameters"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
<WorkflowFormItem
|
<WorkflowFormItem
|
||||||
|
v-else
|
||||||
v-model:run-params="runParams"
|
v-model:run-params="runParams"
|
||||||
:parameters="node?.data.parameters || []"
|
:parameters="singleRunParameters"
|
||||||
/>
|
/>
|
||||||
<ElFormItem>
|
<ElFormItem>
|
||||||
<ElButton
|
<ElButton
|
||||||
@@ -68,4 +181,71 @@ function submit() {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</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 {onDestroy, onMount} from 'svelte';
|
||||||
import {isInEditableElement} from '#components/utils/isInEditableElement';
|
import {isInEditableElement} from '#components/utils/isInEditableElement';
|
||||||
import {getAvailableNodes, type NodePaletteItem} from './utils/nodePalette';
|
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 { onInit }: { onInit: any; [key: string]: any } = $props();
|
||||||
const svelteFlow = useSvelteFlow();
|
const svelteFlow = useSvelteFlow();
|
||||||
@@ -142,6 +147,13 @@
|
|||||||
}
|
}
|
||||||
} as Node;
|
} as Node;
|
||||||
|
|
||||||
|
if (newNode.type === START_NODE_TYPE) {
|
||||||
|
newNode.data = {
|
||||||
|
...(newNode.data || {}),
|
||||||
|
parameters: ensureStartNodeParameters((newNode.data?.parameters as Array<any>) || [])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (sourceNode) {
|
if (sourceNode) {
|
||||||
if (connection?.sourceHandle === 'loop_handle') {
|
if (connection?.sourceHandle === 'loop_handle') {
|
||||||
newNode.parentId = sourceNode.id;
|
newNode.parentId = sourceNode.id;
|
||||||
@@ -173,6 +185,8 @@
|
|||||||
});
|
});
|
||||||
store.addEdge(edge as Edge);
|
store.addEdge(edge as Edge);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyAutoBindingsForNode(newNode.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeNodePicker() {
|
function closeNodePicker() {
|
||||||
@@ -367,6 +381,47 @@
|
|||||||
const { getNodesFromSource } = useGetNodesFromSource();
|
const { getNodesFromSource } = useGetNodesFromSource();
|
||||||
const { getNodeRelativePosition } = useGetNodeRelativePosition();
|
const { getNodeRelativePosition } = useGetNodeRelativePosition();
|
||||||
const { ensureParentInNodesBefore } = useEnsureParentInNodesBefore();
|
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) => {
|
const onconnectend = (event: any, state: any) => {
|
||||||
if (!state.isValid) {
|
if (!state.isValid) {
|
||||||
if (state.toNode) {
|
if (state.toNode) {
|
||||||
@@ -449,7 +504,11 @@
|
|||||||
const { getEdgesByTarget } = useGetEdgesByTarget();
|
const { getEdgesByTarget } = useGetEdgesByTarget();
|
||||||
const onDelete = (params: any) => {
|
const onDelete = (params: any) => {
|
||||||
const deleteEdges = params.edges as Edge[];
|
const deleteEdges = params.edges as Edge[];
|
||||||
|
const affectedRootNodeIds = new Set<string>();
|
||||||
deleteEdges.forEach((edge) => {
|
deleteEdges.forEach((edge) => {
|
||||||
|
if (edge.target) {
|
||||||
|
affectedRootNodeIds.add(edge.target);
|
||||||
|
}
|
||||||
if (edge.id === currentEdge?.id) {
|
if (edge.id === currentEdge?.id) {
|
||||||
currentEdge = null;
|
currentEdge = null;
|
||||||
showEdgePanel = false;
|
showEdgePanel = false;
|
||||||
@@ -513,6 +572,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
if (affectedRootNodeIds.size > 0) {
|
||||||
|
queueMicrotask(() => {
|
||||||
|
reconcileBindingsForNodes(collectAffectedNodeIds(Array.from(affectedRootNodeIds)));
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const { deleteEdge } = useDeleteEdge();
|
const { deleteEdge } = useDeleteEdge();
|
||||||
@@ -525,9 +589,41 @@
|
|||||||
|
|
||||||
|
|
||||||
const onconnect = (event: any) => {
|
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 { copyHandler, pasteHandler } = useCopyPasteHandler();
|
||||||
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
|||||||
@@ -76,10 +76,11 @@
|
|||||||
|
|
||||||
<div class="input-item">
|
<div class="input-item">
|
||||||
<Input style="width: 100%;" value={param.name} placeholder="请输入参数名称"
|
<Input style="width: 100%;" value={param.name} placeholder="请输入参数名称"
|
||||||
|
disabled={param.nameDisabled === true}
|
||||||
oninput={updateName} />
|
oninput={updateName} />
|
||||||
</div>
|
</div>
|
||||||
<div class="input-item">
|
<div class="input-item">
|
||||||
<Checkbox checked={param.required} onchange={updateRequired} />
|
<Checkbox checked={param.required} disabled={param.requiredDisabled === true} onchange={updateRequired} />
|
||||||
</div>
|
</div>
|
||||||
<div class="input-item">
|
<div class="input-item">
|
||||||
<FloatingTrigger placement="bottom" bind:this={triggerObject}>
|
<FloatingTrigger placement="bottom" bind:this={triggerObject}>
|
||||||
@@ -91,10 +92,16 @@
|
|||||||
</Button>
|
</Button>
|
||||||
{#snippet floating()}
|
{#snippet floating()}
|
||||||
<div class="input-more-setting">
|
<div class="input-more-setting">
|
||||||
|
{#if param.systemReserved}
|
||||||
|
<div class="input-more-item">
|
||||||
|
系统入口参数,当前不可编辑。
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<div class="input-more-item">
|
<div class="input-more-item">
|
||||||
数据内容:
|
数据内容:
|
||||||
<Select items={contentTypes} style="width: 100%" defaultValue={["text"]}
|
<Select items={contentTypes} style="width: 100%" defaultValue={["text"]}
|
||||||
value={param.contentType ? [param.contentType] : []}
|
value={param.contentType ? [param.contentType] : []}
|
||||||
|
disabled={param.systemReserved === true}
|
||||||
onSelect={updateContentType}
|
onSelect={updateContentType}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -102,6 +109,7 @@
|
|||||||
输入方式:
|
输入方式:
|
||||||
<Select items={startFormTypes} style="width: 100%" defaultValue={["input"]}
|
<Select items={startFormTypes} style="width: 100%" defaultValue={["input"]}
|
||||||
value={param.formType ? [param.formType] : []}
|
value={param.formType ? [param.formType] : []}
|
||||||
|
disabled={param.systemReserved === true}
|
||||||
onSelect={updateFormType}
|
onSelect={updateFormType}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,8 +118,8 @@
|
|||||||
<div class="input-more-item">
|
<div class="input-more-item">
|
||||||
数据选项:
|
数据选项:
|
||||||
<Textarea rows={3} style="width: 100%;" onchange={(event)=>{
|
<Textarea rows={3} style="width: 100%;" onchange={(event)=>{
|
||||||
updateParameter('enums', event.target?.value.trim().split("\n"))
|
updateParameter('enums', ((event.target as HTMLTextAreaElement)?.value || '').trim().split("\n"))
|
||||||
}} value={param.enums?.join("\n")} placeholder="一行一个选项" />
|
}} disabled={param.systemReserved === true} value={param.enums?.join("\n")} placeholder="一行一个选项" />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -119,25 +127,25 @@
|
|||||||
数据标题:
|
数据标题:
|
||||||
<Textarea rows={1} style="width: 100%;" onchange={(event)=>{
|
<Textarea rows={1} style="width: 100%;" onchange={(event)=>{
|
||||||
updateParamByEvent('formLabel', event)
|
updateParamByEvent('formLabel', event)
|
||||||
}} value={param.formLabel} />
|
}} disabled={param.systemReserved === true} value={param.formLabel} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-more-item">
|
<div class="input-more-item">
|
||||||
数据描述:
|
数据描述:
|
||||||
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
|
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
|
||||||
updateParamByEvent('formDescription', event)
|
updateParamByEvent('formDescription', event)
|
||||||
}} value={param.formDescription} />
|
}} disabled={param.systemReserved === true} value={param.formDescription} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-more-item">
|
<div class="input-more-item">
|
||||||
占位符:
|
占位符:
|
||||||
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
|
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
|
||||||
updateParamByEvent('formPlaceholder', event)
|
updateParamByEvent('formPlaceholder', event)
|
||||||
}} value={param.formPlaceholder} />
|
}} disabled={param.systemReserved === true} value={param.formPlaceholder} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<div class="input-more-item">
|
<div class="input-more-item" style:display={param.deleteDisabled === true ? 'none' : 'flex'}>
|
||||||
<Button onclick={handleDelete}>删除</Button>
|
<Button onclick={handleDelete}>删除</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -180,5 +188,3 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,31 @@
|
|||||||
import {useNodesData} from '@xyflow/svelte';
|
import {useNodesData} from '@xyflow/svelte';
|
||||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||||
|
|
||||||
|
const {
|
||||||
|
parameters: manualParameters = undefined,
|
||||||
|
emptyText = '无输入参数'
|
||||||
|
}: {
|
||||||
|
parameters?: Array<any>,
|
||||||
|
emptyText?: string
|
||||||
|
} = $props();
|
||||||
|
|
||||||
let currentNodeId = getCurrentNodeId();
|
let currentNodeId = getCurrentNodeId();
|
||||||
let node = useNodesData(currentNodeId);
|
let node = useNodesData(currentNodeId);
|
||||||
let parameters = $derived.by(() => {
|
let currentParameters = $derived.by(() => {
|
||||||
return [...node?.current?.data?.parameters as Array<any> || []];
|
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>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
@@ -17,10 +37,10 @@
|
|||||||
<div class="input-header">必填</div>
|
<div class="input-header">必填</div>
|
||||||
<div class="input-header"></div>
|
<div class="input-header"></div>
|
||||||
{/if}
|
{/if}
|
||||||
{#each parameters as param, index (param.id)}
|
{#each parameterItems as item (item.parameter.id)}
|
||||||
<DefinedParameterItem parameter={param} index={index} />
|
<DefinedParameterItem parameter={item.parameter} index={item.index} />
|
||||||
{:else }
|
{:else }
|
||||||
<div class="none-params">无输入参数</div>
|
<div class="none-params">{emptyText}</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -52,5 +72,3 @@
|
|||||||
|
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
showSourceHandle = true,
|
showSourceHandle = true,
|
||||||
showTargetHandle = true,
|
showTargetHandle = true,
|
||||||
titleHelp = '',
|
titleHelp = '',
|
||||||
|
wrapperClass = '',
|
||||||
onCollapse
|
onCollapse
|
||||||
}: {
|
}: {
|
||||||
data: NodeProps['data'],
|
data: NodeProps['data'],
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
showSourceHandle?: boolean,
|
showSourceHandle?: boolean,
|
||||||
showTargetHandle?: boolean,
|
showTargetHandle?: boolean,
|
||||||
titleHelp?: string,
|
titleHelp?: string,
|
||||||
|
wrapperClass?: string,
|
||||||
onCollapse?: (key: string) => void,
|
onCollapse?: (key: string) => void,
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -248,7 +250,7 @@
|
|||||||
</NodeToolbar>
|
</NodeToolbar>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="tf-node-wrapper">
|
<div class="tf-node-wrapper {wrapperClass}">
|
||||||
<div class="tf-node-wrapper-body">
|
<div class="tf-node-wrapper-body">
|
||||||
<Collapse {items} activeKeys={activeKeys} onChange={(_,actionKeys) => {
|
<Collapse {items} activeKeys={activeKeys} onChange={(_,actionKeys) => {
|
||||||
updateNodeData(id, {expand: actionKeys?.includes('key')})
|
updateNodeData(id, {expand: actionKeys?.includes('key')})
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
column-gap: 4px;
|
column-gap: 4px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 318px;
|
min-width: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
.none-params {
|
.none-params {
|
||||||
@@ -78,4 +78,3 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</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;
|
column-gap: 4px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 318px;
|
min-width: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
.none-params {
|
.none-params {
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||||
import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
import {type NodeProps, useNodesData, useStore, useSvelteFlow} from '@xyflow/svelte';
|
||||||
import {Button, Heading, Select} from '../base';
|
import {Heading, Select} from '../base';
|
||||||
import RefParameterList from '../core/RefParameterList.svelte';
|
|
||||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||||
import {getOptions} from '../utils/NodeUtils';
|
import {getOptions} from '../utils/NodeUtils';
|
||||||
import {onMount} from 'svelte';
|
import {onMount} from 'svelte';
|
||||||
import OutputDefList from '../core/OutputDefList.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 ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||||
|
import {
|
||||||
|
buildEditorReferenceParameters,
|
||||||
|
FIELD_BINDING_META_KEY,
|
||||||
|
syncManagedParametersForFields,
|
||||||
|
updateFieldBindingMeta,
|
||||||
|
} from '../../utils/workflowNodeFields';
|
||||||
|
|
||||||
const { data, ...rest }: {
|
const { data, ...rest }: {
|
||||||
data: NodeProps['data'],
|
data: NodeProps['data'],
|
||||||
@@ -19,8 +24,14 @@
|
|||||||
const currentNodeId = getCurrentNodeId();
|
const currentNodeId = getCurrentNodeId();
|
||||||
let currentNode = useNodesData(currentNodeId);
|
let currentNode = useNodesData(currentNodeId);
|
||||||
const { addParameter } = useAddParameter();
|
const { addParameter } = useAddParameter();
|
||||||
|
const { nodes, edges } = $derived(useStore());
|
||||||
const editorParameters = $derived.by(() => {
|
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();
|
const options = getOptions();
|
||||||
@@ -37,6 +48,22 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { updateNodeData } = useSvelteFlow();
|
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(() => {
|
$effect(() => {
|
||||||
if (!data.outputDefs || data.outputDefs.length === 0) {
|
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>
|
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>
|
</svg>
|
||||||
{/snippet}
|
{/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>
|
<Heading level={3} mt="10px">知识库设置</Heading>
|
||||||
<div class="setting-title">知识库</div>
|
<div class="setting-title">知识库</div>
|
||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
@@ -137,14 +151,9 @@
|
|||||||
placeholder="请输入关键字"
|
placeholder="请输入关键字"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
parameters={editorParameters}
|
parameters={editorParameters}
|
||||||
value={data.keyword || ''}
|
value={String(data.keyword || '')}
|
||||||
oninput={(e)=>{
|
oninput={(e: any)=>{
|
||||||
const newValue = e.target.value;
|
syncFieldValue('keyword', e.target.value)
|
||||||
updateNodeData(currentNodeId, ()=>{
|
|
||||||
return {
|
|
||||||
keyword: newValue
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,15 +178,10 @@
|
|||||||
placeholder="搜索的数据条数"
|
placeholder="搜索的数据条数"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
parameters={editorParameters}
|
parameters={editorParameters}
|
||||||
oninput={(e)=>{
|
oninput={(e: any)=>{
|
||||||
const newValue = e.target.value;
|
syncFieldValue('limit', e.target.value)
|
||||||
updateNodeData(currentNodeId, ()=>{
|
|
||||||
return {
|
|
||||||
limit: newValue
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}}
|
}}
|
||||||
value={data.limit || ''}
|
value={String(data.limit || '')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,14 @@
|
|||||||
import {getOptions} from '../utils/NodeUtils';
|
import {getOptions} from '../utils/NodeUtils';
|
||||||
import {onMount} from 'svelte';
|
import {onMount} from 'svelte';
|
||||||
import OutputDefList from '../core/OutputDefList.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 ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||||
|
import {
|
||||||
|
buildEditorReferenceParameters,
|
||||||
|
FIELD_BINDING_META_KEY,
|
||||||
|
syncManagedParametersForFields,
|
||||||
|
updateFieldBindingMeta,
|
||||||
|
} from '../../utils/workflowNodeFields';
|
||||||
|
|
||||||
const { data, ...rest }: {
|
const { data, ...rest }: {
|
||||||
data: NodeProps['data'],
|
data: NodeProps['data'],
|
||||||
@@ -20,11 +26,14 @@
|
|||||||
const currentNodeId = getCurrentNodeId();
|
const currentNodeId = getCurrentNodeId();
|
||||||
let currentNode = useNodesData(currentNodeId);
|
let currentNode = useNodesData(currentNodeId);
|
||||||
const { addParameter } = useAddParameter();
|
const { addParameter } = useAddParameter();
|
||||||
const { nodes } = $derived(useStore());
|
const { nodes, edges } = $derived(useStore());
|
||||||
const editorParameters = $derived.by(() => {
|
const editorParameters = $derived.by(() => {
|
||||||
const parameters = [
|
const parameters = buildEditorReferenceParameters(
|
||||||
...(((currentNode?.current?.data?.parameters as Array<any>) || data.parameters || []) as Array<any>)
|
currentNodeId,
|
||||||
];
|
nodes || [],
|
||||||
|
edges || [],
|
||||||
|
(((currentNode?.current?.data?.parameters as Array<Parameter>) || data.parameters || []) as Array<Parameter>)
|
||||||
|
);
|
||||||
if (queryContextNodeIds.length > 0) {
|
if (queryContextNodeIds.length > 0) {
|
||||||
parameters.push({
|
parameters.push({
|
||||||
id: 'queryDataContext',
|
id: 'queryDataContext',
|
||||||
@@ -109,6 +118,23 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { updateNodeData } = useSvelteFlow();
|
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) => {
|
const setOutType = (value: string) => {
|
||||||
updateNodeData(currentNodeId, () => {
|
updateNodeData(currentNodeId, () => {
|
||||||
return {
|
return {
|
||||||
@@ -175,7 +201,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
<NodeWrapper {data} {...rest}>
|
<NodeWrapper {data} {...rest} wrapperClass="tf-node-wrapper--llm">
|
||||||
|
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||||
@@ -185,18 +211,6 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<div class="heading">
|
<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>
|
<Heading level={3}>图片识别</Heading>
|
||||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
||||||
addParameter(currentNodeId, "images")
|
addParameter(currentNodeId, "images")
|
||||||
@@ -250,7 +264,7 @@
|
|||||||
max="1"
|
max="1"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={data.temperature ?? 0.7}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -265,7 +279,7 @@
|
|||||||
max="1"
|
max="1"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={data.topP ?? 0.9}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,7 +294,7 @@
|
|||||||
max="100"
|
max="100"
|
||||||
step="1"
|
step="1"
|
||||||
value={data.topK ?? 50}
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -298,11 +312,9 @@
|
|||||||
placeholder="请输入系统提示词"
|
placeholder="请输入系统提示词"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
parameters={editorParameters}
|
parameters={editorParameters}
|
||||||
value={data.systemPrompt || ''}
|
value={String(data.systemPrompt || '')}
|
||||||
oninput={(e)=>{
|
oninput={(e: any)=>{
|
||||||
updateNodeData(currentNodeId, {
|
syncFieldValue('systemPrompt', e.target.value);
|
||||||
systemPrompt: e.target.value
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -315,11 +327,9 @@
|
|||||||
placeholder="请输入用户提示词"
|
placeholder="请输入用户提示词"
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
parameters={editorParameters}
|
parameters={editorParameters}
|
||||||
value={data.userPrompt || ''}
|
value={String(data.userPrompt || '')}
|
||||||
oninput={(e)=>{
|
oninput={(e: any)=>{
|
||||||
updateNodeData(currentNodeId, {
|
syncFieldValue('userPrompt', e.target.value);
|
||||||
userPrompt: e.target.value
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -333,7 +343,7 @@
|
|||||||
label: 'JSON',
|
label: 'JSON',
|
||||||
value: 'json'
|
value: 'json'
|
||||||
}]} style="width: 100px;margin-left: auto" onSelect={(item)=>{
|
}]} style="width: 100px;margin-left: auto" onSelect={(item)=>{
|
||||||
setOutType(item.value);
|
setOutType(String(item.value));
|
||||||
}} value={data.outType ? [data.outType] : []} />
|
}} value={data.outType ? [data.outType] : []} />
|
||||||
</div>
|
</div>
|
||||||
<OutputDefList />
|
<OutputDefList />
|
||||||
|
|||||||
@@ -6,6 +6,12 @@
|
|||||||
import DefinedParameterList from '../core/DefinedParameterList.svelte';
|
import DefinedParameterList from '../core/DefinedParameterList.svelte';
|
||||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||||
|
import {useSvelteFlow} from '@xyflow/svelte';
|
||||||
|
import {
|
||||||
|
ensureStartNodeParameters,
|
||||||
|
hasSystemStartParameter,
|
||||||
|
isSystemStartParameter,
|
||||||
|
} from '../../utils/workflowNodeFields';
|
||||||
|
|
||||||
const { data, ...rest }: {
|
const { data, ...rest }: {
|
||||||
data: NodeProps['data'],
|
data: NodeProps['data'],
|
||||||
@@ -14,6 +20,30 @@
|
|||||||
|
|
||||||
const currentNodeId = getCurrentNodeId();
|
const currentNodeId = getCurrentNodeId();
|
||||||
const { addParameter } = useAddParameter();
|
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>
|
</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>
|
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>
|
</svg>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<div class="heading">
|
<div class="param-section">
|
||||||
<Heading level={3}>输入参数</Heading>
|
<div class="heading">
|
||||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
<Heading level={3}>系统入口</Heading>
|
||||||
addParameter(currentNodeId, "parameters", {refType: "input", name: "newParam"});
|
</div>
|
||||||
}}>
|
<div class="section-description">固定入口参数,作为工作流默认输入来源。</div>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
<DefinedParameterList parameters={systemParameters} emptyText="暂无系统入口参数" />
|
||||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
</div>
|
||||||
</svg>
|
|
||||||
</Button>
|
<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", 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>
|
||||||
|
<div class="section-description">这里添加额外输入参数,不影响默认入口参数。</div>
|
||||||
|
<DefinedParameterList parameters={customParameters} emptyText="暂无自定义参数" />
|
||||||
</div>
|
</div>
|
||||||
<DefinedParameterList />
|
|
||||||
</NodeWrapper>
|
</NodeWrapper>
|
||||||
|
|
||||||
<style lang="less">
|
<style lang="less">
|
||||||
|
|
||||||
.heading {
|
.heading {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
margin-bottom: 10px;
|
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) {
|
:global(.input-btn-more) {
|
||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
@@ -55,4 +110,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const genUuid = () => {
|
|||||||
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c: any) =>
|
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c: any) =>
|
||||||
(
|
(
|
||||||
c ^
|
c ^
|
||||||
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
|
((crypto.getRandomValues(new Uint8Array(1))[0] ?? 0) & (15 >> (c / 4)))
|
||||||
).toString(16),
|
).toString(16),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -63,14 +63,20 @@ describe('paramToken utils', () => {
|
|||||||
|
|
||||||
expect(result).toEqual([
|
expect(result).toEqual([
|
||||||
{
|
{
|
||||||
|
dataType: undefined,
|
||||||
|
displayName: 'input',
|
||||||
name: 'input',
|
name: 'input',
|
||||||
resolved: false,
|
resolved: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
dataType: undefined,
|
||||||
|
displayName: 'docs',
|
||||||
name: 'docs',
|
name: 'docs',
|
||||||
resolved: true,
|
resolved: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
dataType: undefined,
|
||||||
|
displayName: 'runtimeInput',
|
||||||
name: 'runtimeInput',
|
name: 'runtimeInput',
|
||||||
resolved: true,
|
resolved: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ export interface ParameterLike {
|
|||||||
name?: string;
|
name?: string;
|
||||||
ref?: string;
|
ref?: string;
|
||||||
refType?: string;
|
refType?: string;
|
||||||
|
resolved?: boolean;
|
||||||
|
disconnected?: boolean;
|
||||||
|
displayName?: string;
|
||||||
|
formLabel?: string;
|
||||||
|
dataType?: string;
|
||||||
children?: ParameterLike[];
|
children?: ParameterLike[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +32,9 @@ export type TokenPart =
|
|||||||
export interface ParameterCandidate {
|
export interface ParameterCandidate {
|
||||||
name: string;
|
name: string;
|
||||||
resolved: boolean;
|
resolved: boolean;
|
||||||
|
disconnected?: boolean;
|
||||||
|
displayName?: string;
|
||||||
|
dataType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOKEN_PATTERN = /\{\{\s*([^{}]+?)\s*}}/g;
|
const TOKEN_PATTERN = /\{\{\s*([^{}]+?)\s*}}/g;
|
||||||
@@ -46,6 +54,10 @@ function isParameterResolved(parameter?: ParameterLike): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof parameter.resolved === 'boolean') {
|
||||||
|
return parameter.resolved;
|
||||||
|
}
|
||||||
|
|
||||||
const refType = (parameter.refType || '').trim();
|
const refType = (parameter.refType || '').trim();
|
||||||
if (refType === 'fixed' || refType === 'input') {
|
if (refType === 'fixed' || refType === 'input') {
|
||||||
return true;
|
return true;
|
||||||
@@ -65,7 +77,13 @@ export function flattenParameterCandidates(
|
|||||||
const candidates: ParameterCandidate[] = [];
|
const candidates: ParameterCandidate[] = [];
|
||||||
const indexMap = new Map<string, number>();
|
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();
|
const normalized = name.trim();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
return;
|
return;
|
||||||
@@ -76,13 +94,26 @@ export function flattenParameterCandidates(
|
|||||||
candidates.push({
|
candidates.push({
|
||||||
name: normalized,
|
name: normalized,
|
||||||
resolved,
|
resolved,
|
||||||
|
disconnected,
|
||||||
|
displayName: displayName?.trim() || normalized,
|
||||||
|
dataType,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同名参数只要有一个可解析,就视为可解析
|
// 同名参数只要有一个可解析,就视为可解析
|
||||||
|
const existingCandidate = candidates[exists]!;
|
||||||
if (resolved) {
|
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 currentPath = parentPath ? `${parentPath}.${rawName}` : rawName;
|
||||||
const currentResolved = inheritedResolved && isParameterResolved(item);
|
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) {
|
if (item.children && item.children.length > 0) {
|
||||||
walk(item.children, currentPath, currentResolved);
|
walk(item.children, currentPath, currentResolved);
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ const getChildren = (
|
|||||||
const dataType = nodeIsChildren
|
const dataType = nodeIsChildren
|
||||||
? `Array<${param.dataType || 'String'}>`
|
? `Array<${param.dataType || 'String'}>`
|
||||||
: param.dataType || 'String';
|
: param.dataType || 'String';
|
||||||
|
const label = param.formLabel || param.displayName || param.name;
|
||||||
return {
|
return {
|
||||||
label: param.name,
|
label,
|
||||||
dataType: dataType,
|
dataType: dataType,
|
||||||
value: parentId + '.' + param.name,
|
value: parentId + '.' + param.name,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
@@ -71,8 +72,10 @@ const nodeToOptions = (
|
|||||||
const dataType = nodeIsChildren
|
const dataType = nodeIsChildren
|
||||||
? `Array<${parameter.dataType || 'String'}>`
|
? `Array<${parameter.dataType || 'String'}>`
|
||||||
: parameter.dataType || 'String';
|
: parameter.dataType || 'String';
|
||||||
|
const label =
|
||||||
|
parameter.formLabel || parameter.displayName || parameter.name;
|
||||||
children.push({
|
children.push({
|
||||||
label: parameter.name,
|
label,
|
||||||
dataType: dataType,
|
dataType: dataType,
|
||||||
value: node.id + '.' + parameter.name,
|
value: node.id + '.' + parameter.name,
|
||||||
selectable: true,
|
selectable: true,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './Tinyflow';
|
export * from './Tinyflow';
|
||||||
export * from './components/TinyflowComponent.svelte';
|
export * from './components/TinyflowComponent.svelte';
|
||||||
|
export * from './utils/workflowNodeFields';
|
||||||
export * from './utils/sanitize';
|
export * from './utils/sanitize';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
.svelte-flow__nodes {
|
.svelte-flow__nodes {
|
||||||
.svelte-flow__node {
|
.svelte-flow__node {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
cursor: default !important;
|
||||||
border: 3px solid transparent;
|
border: 3px solid transparent;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
|
|
||||||
@@ -114,6 +115,11 @@
|
|||||||
&-body {
|
&-body {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--llm {
|
||||||
|
min-width: 296px;
|
||||||
|
max-width: 296px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.svelte-flow__attribution a {
|
.svelte-flow__attribution a {
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ export type Parameter = {
|
|||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
nameDisabled?: boolean;
|
nameDisabled?: boolean;
|
||||||
|
displayName?: string;
|
||||||
dataType?: string;
|
dataType?: string;
|
||||||
dataTypeItems?: SelectItem[];
|
dataTypeItems?: SelectItem[];
|
||||||
dataTypeDisabled?: boolean;
|
dataTypeDisabled?: boolean;
|
||||||
@@ -126,4 +127,7 @@ export type Parameter = {
|
|||||||
formDescription?: string;
|
formDescription?: string;
|
||||||
formPlaceholder?: string;
|
formPlaceholder?: string;
|
||||||
formAttrs?: 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;
|
displayPublishStatus?: string;
|
||||||
created: string;
|
created: string;
|
||||||
createdBy: number;
|
createdBy: number;
|
||||||
|
createdByName?: string;
|
||||||
deptId: number;
|
deptId: number;
|
||||||
description: string;
|
description: string;
|
||||||
icon: string;
|
icon: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user