feat: 优化工作流字段化参数配置

- 开始节点固定 user_input 并区分系统入口与自定义参数

- LLM 与知识库节点切换为字段值加上游引用配置

- 单节点调试改为字段预览与上游引用输入模式
This commit is contained in:
2026-04-12 20:31:02 +08:00
parent 47655a728b
commit 8cfe5400fe
24 changed files with 2785 additions and 792 deletions

View File

@@ -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;

View File

@@ -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 <WorkflowFormItem
v-else
v-model:run-params="runParams" v-model:run-params="runParams"
:parameters="node?.data.parameters || []" :parameters="singleRunParameters"
/>
</div>
</template>
<WorkflowFormItem
v-else
v-model:run-params="runParams"
: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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 />

View File

@@ -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="param-section">
<div class="heading"> <div class="heading">
<Heading level={3}>输入参数</Heading> <Heading level={3}>系统入口</Heading>
</div>
<div class="section-description">固定入口参数,作为工作流默认输入来源。</div>
<DefinedParameterList parameters={systemParameters} emptyText="暂无系统入口参数" />
</div>
<div class="param-section">
<div class="heading">
<Heading level={3}>自定义参数</Heading>
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{ <Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
addParameter(currentNodeId, "parameters", {refType: "input", name: "newParam"}); addParameter(currentNodeId, "parameters", {refType: "input", name: "newParam", formType: "input", contentType: "text"});
}}> }}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path> <path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
</svg> </svg>
</Button> </Button>
</div> </div>
<DefinedParameterList /> <div class="section-description">这里添加额外输入参数,不影响默认入口参数。</div>
<DefinedParameterList parameters={customParameters} emptyText="暂无自定义参数" />
</div>
</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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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([]);
});
});

View File

@@ -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,
};
}

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

View File

@@ -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;