feat: 优化工作流字段化参数配置
- 开始节点固定 user_input 并区分系统入口与自定义参数 - LLM 与知识库节点切换为字段值加上游引用配置 - 单节点调试改为字段预览与上游引用输入模式
This commit is contained in:
@@ -37,6 +37,11 @@
|
||||
import {onDestroy, onMount} from 'svelte';
|
||||
import {isInEditableElement} from '#components/utils/isInEditableElement';
|
||||
import {getAvailableNodes, type NodePaletteItem} from './utils/nodePalette';
|
||||
import {
|
||||
buildSequentialFieldBindingPatches,
|
||||
ensureStartNodeParameters,
|
||||
START_NODE_TYPE,
|
||||
} from '../utils/workflowNodeFields';
|
||||
|
||||
const { onInit }: { onInit: any; [key: string]: any } = $props();
|
||||
const svelteFlow = useSvelteFlow();
|
||||
@@ -142,6 +147,13 @@
|
||||
}
|
||||
} as Node;
|
||||
|
||||
if (newNode.type === START_NODE_TYPE) {
|
||||
newNode.data = {
|
||||
...(newNode.data || {}),
|
||||
parameters: ensureStartNodeParameters((newNode.data?.parameters as Array<any>) || [])
|
||||
};
|
||||
}
|
||||
|
||||
if (sourceNode) {
|
||||
if (connection?.sourceHandle === 'loop_handle') {
|
||||
newNode.parentId = sourceNode.id;
|
||||
@@ -173,6 +185,8 @@
|
||||
});
|
||||
store.addEdge(edge as Edge);
|
||||
}
|
||||
|
||||
applyAutoBindingsForNode(newNode.id);
|
||||
}
|
||||
|
||||
function closeNodePicker() {
|
||||
@@ -367,6 +381,47 @@
|
||||
const { getNodesFromSource } = useGetNodesFromSource();
|
||||
const { getNodeRelativePosition } = useGetNodeRelativePosition();
|
||||
const { ensureParentInNodesBefore } = useEnsureParentInNodesBefore();
|
||||
|
||||
function collectAffectedNodeIds(rootNodeIds: string[], edges: Edge[] = store.getEdges()) {
|
||||
const affectedNodeIds = new Set<string>();
|
||||
const visit = (nodeId: string) => {
|
||||
if (!nodeId || affectedNodeIds.has(nodeId)) {
|
||||
return;
|
||||
}
|
||||
affectedNodeIds.add(nodeId);
|
||||
edges
|
||||
.filter((edge) => edge.source === nodeId && edge.sourceHandle !== 'loop_handle')
|
||||
.forEach((edge) => {
|
||||
if (edge.target) {
|
||||
visit(edge.target);
|
||||
}
|
||||
});
|
||||
};
|
||||
rootNodeIds.forEach(visit);
|
||||
return Array.from(affectedNodeIds);
|
||||
}
|
||||
|
||||
function reconcileBindingsForNodes(
|
||||
nodeIds: string[],
|
||||
options?: {
|
||||
nodes?: Node[];
|
||||
edges?: Edge[];
|
||||
}
|
||||
) {
|
||||
const uniqueNodeIds = Array.from(new Set(nodeIds.filter((nodeId) => asString(nodeId).trim())));
|
||||
if (uniqueNodeIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
queueMicrotask(() => {
|
||||
const nodes = options?.nodes || store.getNodes();
|
||||
const edges = options?.edges || store.getEdges();
|
||||
const patches = buildSequentialFieldBindingPatches(uniqueNodeIds, nodes, edges);
|
||||
patches.forEach(({ nodeId, patch }) => {
|
||||
store.updateNodeData(nodeId, patch);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const onconnectend = (event: any, state: any) => {
|
||||
if (!state.isValid) {
|
||||
if (state.toNode) {
|
||||
@@ -449,7 +504,11 @@
|
||||
const { getEdgesByTarget } = useGetEdgesByTarget();
|
||||
const onDelete = (params: any) => {
|
||||
const deleteEdges = params.edges as Edge[];
|
||||
const affectedRootNodeIds = new Set<string>();
|
||||
deleteEdges.forEach((edge) => {
|
||||
if (edge.target) {
|
||||
affectedRootNodeIds.add(edge.target);
|
||||
}
|
||||
if (edge.id === currentEdge?.id) {
|
||||
currentEdge = null;
|
||||
showEdgePanel = false;
|
||||
@@ -513,6 +572,11 @@
|
||||
}
|
||||
}
|
||||
});
|
||||
if (affectedRootNodeIds.size > 0) {
|
||||
queueMicrotask(() => {
|
||||
reconcileBindingsForNodes(collectAffectedNodeIds(Array.from(affectedRootNodeIds)));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const { deleteEdge } = useDeleteEdge();
|
||||
@@ -525,9 +589,41 @@
|
||||
|
||||
|
||||
const onconnect = (event: any) => {
|
||||
// console.log('onconnect: ', event);
|
||||
const targetNodeId = asString(event?.target).trim();
|
||||
if (!targetNodeId) {
|
||||
return;
|
||||
}
|
||||
const sourceNodeId = asString(event?.source).trim();
|
||||
const projectedEdges = [...store.getEdges()];
|
||||
const hasSameEdge = projectedEdges.some((edge) =>
|
||||
edge.source === sourceNodeId
|
||||
&& edge.target === targetNodeId
|
||||
&& (edge.sourceHandle || '') === asString(event?.sourceHandle).trim()
|
||||
&& (edge.targetHandle || '') === asString(event?.targetHandle).trim()
|
||||
);
|
||||
|
||||
if (!hasSameEdge && sourceNodeId) {
|
||||
projectedEdges.push({
|
||||
id: asString(event?.id).trim() || `edge_${genShortId()}`,
|
||||
source: sourceNodeId,
|
||||
target: targetNodeId,
|
||||
sourceHandle: asString(event?.sourceHandle).trim() || undefined,
|
||||
targetHandle: asString(event?.targetHandle).trim() || undefined,
|
||||
} as Edge);
|
||||
}
|
||||
|
||||
reconcileBindingsForNodes(
|
||||
collectAffectedNodeIds([targetNodeId], projectedEdges),
|
||||
{
|
||||
edges: projectedEdges
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
function applyAutoBindingsForNode(nodeId: string) {
|
||||
reconcileBindingsForNodes([nodeId]);
|
||||
}
|
||||
|
||||
const { copyHandler, pasteHandler } = useCopyPasteHandler();
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
|
||||
@@ -76,10 +76,11 @@
|
||||
|
||||
<div class="input-item">
|
||||
<Input style="width: 100%;" value={param.name} placeholder="请输入参数名称"
|
||||
disabled={param.nameDisabled === true}
|
||||
oninput={updateName} />
|
||||
</div>
|
||||
<div class="input-item">
|
||||
<Checkbox checked={param.required} onchange={updateRequired} />
|
||||
<Checkbox checked={param.required} disabled={param.requiredDisabled === true} onchange={updateRequired} />
|
||||
</div>
|
||||
<div class="input-item">
|
||||
<FloatingTrigger placement="bottom" bind:this={triggerObject}>
|
||||
@@ -91,10 +92,16 @@
|
||||
</Button>
|
||||
{#snippet floating()}
|
||||
<div class="input-more-setting">
|
||||
{#if param.systemReserved}
|
||||
<div class="input-more-item">
|
||||
系统入口参数,当前不可编辑。
|
||||
</div>
|
||||
{/if}
|
||||
<div class="input-more-item">
|
||||
数据内容:
|
||||
<Select items={contentTypes} style="width: 100%" defaultValue={["text"]}
|
||||
value={param.contentType ? [param.contentType] : []}
|
||||
disabled={param.systemReserved === true}
|
||||
onSelect={updateContentType}
|
||||
/>
|
||||
</div>
|
||||
@@ -102,6 +109,7 @@
|
||||
输入方式:
|
||||
<Select items={startFormTypes} style="width: 100%" defaultValue={["input"]}
|
||||
value={param.formType ? [param.formType] : []}
|
||||
disabled={param.systemReserved === true}
|
||||
onSelect={updateFormType}
|
||||
/>
|
||||
</div>
|
||||
@@ -110,8 +118,8 @@
|
||||
<div class="input-more-item">
|
||||
数据选项:
|
||||
<Textarea rows={3} style="width: 100%;" onchange={(event)=>{
|
||||
updateParameter('enums', event.target?.value.trim().split("\n"))
|
||||
}} value={param.enums?.join("\n")} placeholder="一行一个选项" />
|
||||
updateParameter('enums', ((event.target as HTMLTextAreaElement)?.value || '').trim().split("\n"))
|
||||
}} disabled={param.systemReserved === true} value={param.enums?.join("\n")} placeholder="一行一个选项" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -119,25 +127,25 @@
|
||||
数据标题:
|
||||
<Textarea rows={1} style="width: 100%;" onchange={(event)=>{
|
||||
updateParamByEvent('formLabel', event)
|
||||
}} value={param.formLabel} />
|
||||
}} disabled={param.systemReserved === true} value={param.formLabel} />
|
||||
</div>
|
||||
|
||||
<div class="input-more-item">
|
||||
数据描述:
|
||||
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
|
||||
updateParamByEvent('formDescription', event)
|
||||
}} value={param.formDescription} />
|
||||
}} disabled={param.systemReserved === true} value={param.formDescription} />
|
||||
</div>
|
||||
|
||||
<div class="input-more-item">
|
||||
占位符:
|
||||
<Textarea rows={2} style="width: 100%;" onchange={(event)=>{
|
||||
updateParamByEvent('formPlaceholder', event)
|
||||
}} value={param.formPlaceholder} />
|
||||
}} disabled={param.systemReserved === true} value={param.formPlaceholder} />
|
||||
</div>
|
||||
|
||||
|
||||
<div class="input-more-item">
|
||||
<div class="input-more-item" style:display={param.deleteDisabled === true ? 'none' : 'flex'}>
|
||||
<Button onclick={handleDelete}>删除</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -180,5 +188,3 @@
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -3,11 +3,31 @@
|
||||
import {useNodesData} from '@xyflow/svelte';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
|
||||
const {
|
||||
parameters: manualParameters = undefined,
|
||||
emptyText = '无输入参数'
|
||||
}: {
|
||||
parameters?: Array<any>,
|
||||
emptyText?: string
|
||||
} = $props();
|
||||
|
||||
let currentNodeId = getCurrentNodeId();
|
||||
let node = useNodesData(currentNodeId);
|
||||
let parameters = $derived.by(() => {
|
||||
let currentParameters = $derived.by(() => {
|
||||
return [...node?.current?.data?.parameters as Array<any> || []];
|
||||
});
|
||||
let parameters = $derived.by(() => {
|
||||
if (Array.isArray(manualParameters)) {
|
||||
return [...manualParameters];
|
||||
}
|
||||
return currentParameters;
|
||||
});
|
||||
let parameterItems = $derived.by(() => {
|
||||
return parameters.map((param) => ({
|
||||
parameter: param,
|
||||
index: currentParameters.findIndex((item) => item?.id === param?.id)
|
||||
})).filter((item) => item.index >= 0);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -17,10 +37,10 @@
|
||||
<div class="input-header">必填</div>
|
||||
<div class="input-header"></div>
|
||||
{/if}
|
||||
{#each parameters as param, index (param.id)}
|
||||
<DefinedParameterItem parameter={param} index={index} />
|
||||
{#each parameterItems as item (item.parameter.id)}
|
||||
<DefinedParameterItem parameter={item.parameter} index={item.index} />
|
||||
{:else }
|
||||
<div class="none-params">无输入参数</div>
|
||||
<div class="none-params">{emptyText}</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -52,5 +72,3 @@
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
showSourceHandle = true,
|
||||
showTargetHandle = true,
|
||||
titleHelp = '',
|
||||
wrapperClass = '',
|
||||
onCollapse
|
||||
}: {
|
||||
data: NodeProps['data'],
|
||||
@@ -43,6 +44,7 @@
|
||||
showSourceHandle?: boolean,
|
||||
showTargetHandle?: boolean,
|
||||
titleHelp?: string,
|
||||
wrapperClass?: string,
|
||||
onCollapse?: (key: string) => void,
|
||||
} = $props();
|
||||
|
||||
@@ -248,7 +250,7 @@
|
||||
</NodeToolbar>
|
||||
{/if}
|
||||
|
||||
<div class="tf-node-wrapper">
|
||||
<div class="tf-node-wrapper {wrapperClass}">
|
||||
<div class="tf-node-wrapper-body">
|
||||
<Collapse {items} activeKeys={activeKeys} onChange={(_,actionKeys) => {
|
||||
updateNodeData(id, {expand: actionKeys?.includes('key')})
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
column-gap: 4px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 318px;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
.none-params {
|
||||
@@ -78,4 +78,3 @@
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,173 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import { flushSync, mount, unmount } from 'svelte';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import ParamTokenEditor from './ParamTokenEditor.svelte';
|
||||
|
||||
type RenderResult = {
|
||||
host: HTMLDivElement;
|
||||
view: EditorView;
|
||||
destroy: () => Promise<void>;
|
||||
};
|
||||
|
||||
const PARAMS = [
|
||||
{
|
||||
name: 'start.user_input',
|
||||
displayName: '开始节点 > 用户问题',
|
||||
refType: 'input',
|
||||
},
|
||||
];
|
||||
|
||||
async function renderEditor(props: Record<string, unknown> = {}): Promise<RenderResult> {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
||||
const app = mount(ParamTokenEditor, {
|
||||
target: host,
|
||||
props: {
|
||||
mode: 'textarea',
|
||||
value: '',
|
||||
parameters: PARAMS,
|
||||
...props,
|
||||
},
|
||||
});
|
||||
flushSync();
|
||||
|
||||
const shell = host.querySelector('.param-token-editor-shell') as HTMLDivElement & {
|
||||
__paramTokenEditorView?: EditorView;
|
||||
};
|
||||
|
||||
if (!shell?.__paramTokenEditorView) {
|
||||
throw new Error('ParamTokenEditor view not found');
|
||||
}
|
||||
|
||||
return {
|
||||
host,
|
||||
view: shell.__paramTokenEditorView,
|
||||
destroy: async () => {
|
||||
await unmount(app);
|
||||
host.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function pressKey(view: EditorView, key: string) {
|
||||
view.focus();
|
||||
view.contentDOM.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true }));
|
||||
flushSync();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('ParamTokenEditor', () => {
|
||||
it('should render chip text and parameter panel with displayName only', async () => {
|
||||
const { host, destroy } = await renderEditor({
|
||||
value: '前缀 {{start.user_input}}',
|
||||
});
|
||||
|
||||
expect(host.querySelector('.param-token-chip-text')?.textContent).toBe('开始节点 > 用户问题');
|
||||
expect(host.querySelector('.param-token-item-label')?.textContent).toBe('开始节点 > 用户问题');
|
||||
expect(host.textContent).not.toContain('start.user_input');
|
||||
|
||||
await destroy();
|
||||
});
|
||||
|
||||
it('should insert raw token text and move cursor after token', async () => {
|
||||
const oninput = vi.fn();
|
||||
const onchange = vi.fn();
|
||||
const { host, view, destroy } = await renderEditor({
|
||||
oninput,
|
||||
onchange,
|
||||
});
|
||||
|
||||
(host.querySelector('.param-token-item') as HTMLButtonElement).click();
|
||||
flushSync();
|
||||
|
||||
expect(view.state.doc.toString()).toBe('{{start.user_input}}');
|
||||
expect(view.state.selection.main.head).toBe('{{start.user_input}}'.length);
|
||||
expect(oninput).toHaveBeenCalled();
|
||||
expect(onchange).toHaveBeenCalledWith({
|
||||
target: {
|
||||
value: '{{start.user_input}}',
|
||||
},
|
||||
});
|
||||
|
||||
await destroy();
|
||||
});
|
||||
|
||||
it('should remove the whole token on backspace and delete at token boundaries', async () => {
|
||||
const token = '{{start.user_input}}';
|
||||
const { view, destroy } = await renderEditor({
|
||||
value: `A ${token} B`,
|
||||
});
|
||||
|
||||
view.dispatch({
|
||||
selection: {
|
||||
anchor: `A ${token}`.length,
|
||||
},
|
||||
});
|
||||
await pressKey(view, 'Backspace');
|
||||
expect(view.state.doc.toString()).toBe('A B');
|
||||
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: view.state.doc.length,
|
||||
insert: `A ${token} B`,
|
||||
},
|
||||
selection: {
|
||||
anchor: 2,
|
||||
},
|
||||
});
|
||||
flushSync();
|
||||
await pressKey(view, 'Delete');
|
||||
expect(view.state.doc.toString()).toBe('A B');
|
||||
|
||||
await destroy();
|
||||
});
|
||||
|
||||
it('should block newline insertion in single-line mode', async () => {
|
||||
const { view, destroy } = await renderEditor({
|
||||
mode: 'input',
|
||||
value: 'abc',
|
||||
});
|
||||
|
||||
await pressKey(view, 'Enter');
|
||||
expect(view.state.doc.toString()).toBe('abc');
|
||||
|
||||
await destroy();
|
||||
});
|
||||
|
||||
it('should render invalid token as disconnected parameter label without undefined hint', async () => {
|
||||
const { host, destroy } = await renderEditor({
|
||||
value: '{{missing.ref}}',
|
||||
});
|
||||
|
||||
expect(host.querySelector('.param-token-chip-text')?.textContent).toBe('ref(已断开连接)');
|
||||
expect(host.textContent).not.toContain('未定义参数');
|
||||
|
||||
await destroy();
|
||||
});
|
||||
|
||||
it('should prefer stored display name for disconnected references', async () => {
|
||||
const { host, destroy } = await renderEditor({
|
||||
value: '{{start.user_input}}',
|
||||
parameters: [
|
||||
{
|
||||
name: 'start.user_input',
|
||||
displayName: '开始节点 > 用户问题',
|
||||
resolved: false,
|
||||
disconnected: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(host.querySelector('.param-token-chip-text')?.textContent).toBe(
|
||||
'开始节点 > 用户问题(已断开连接)',
|
||||
);
|
||||
expect(host.querySelector('.param-token-invalid .param-token-chip-text')).not.toBeNull();
|
||||
|
||||
await destroy();
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,7 @@
|
||||
column-gap: 4px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 318px;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
.none-params {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<script lang="ts">
|
||||
import NodeWrapper from '../core/NodeWrapper.svelte';
|
||||
import {type NodeProps, useNodesData, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {Button, Heading, Select} from '../base';
|
||||
import RefParameterList from '../core/RefParameterList.svelte';
|
||||
import {type NodeProps, useNodesData, useStore, useSvelteFlow} from '@xyflow/svelte';
|
||||
import {Heading, Select} from '../base';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||
import {getOptions} from '../utils/NodeUtils';
|
||||
import {onMount} from 'svelte';
|
||||
import OutputDefList from '../core/OutputDefList.svelte';
|
||||
import type {SelectItem} from '#types';
|
||||
import type {Parameter, SelectItem} from '#types';
|
||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||
import {
|
||||
buildEditorReferenceParameters,
|
||||
FIELD_BINDING_META_KEY,
|
||||
syncManagedParametersForFields,
|
||||
updateFieldBindingMeta,
|
||||
} from '../../utils/workflowNodeFields';
|
||||
|
||||
const { data, ...rest }: {
|
||||
data: NodeProps['data'],
|
||||
@@ -19,8 +24,14 @@
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
let currentNode = useNodesData(currentNodeId);
|
||||
const { addParameter } = useAddParameter();
|
||||
const { nodes, edges } = $derived(useStore());
|
||||
const editorParameters = $derived.by(() => {
|
||||
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
|
||||
return buildEditorReferenceParameters(
|
||||
currentNodeId,
|
||||
nodes || [],
|
||||
edges || [],
|
||||
(((currentNode?.current?.data?.parameters as Array<Parameter>) || data.parameters || []) as Array<Parameter>)
|
||||
);
|
||||
});
|
||||
|
||||
const options = getOptions();
|
||||
@@ -37,6 +48,22 @@
|
||||
});
|
||||
|
||||
const { updateNodeData } = useSvelteFlow();
|
||||
const syncFieldValue = (fieldName: 'keyword' | 'limit', nextValue: string) => {
|
||||
const currentData = ((currentNode?.current?.data as Record<string, any>) || data || {}) as Record<string, any>;
|
||||
const nextFieldValues = {
|
||||
keyword: fieldName === 'keyword' ? nextValue : (currentData.keyword || ''),
|
||||
limit: fieldName === 'limit' ? nextValue : (currentData.limit || ''),
|
||||
};
|
||||
updateNodeData(currentNodeId, {
|
||||
[fieldName]: nextValue,
|
||||
parameters: syncManagedParametersForFields(
|
||||
(currentData.parameters as Array<Parameter>) || [],
|
||||
editorParameters,
|
||||
nextFieldValues
|
||||
),
|
||||
[FIELD_BINDING_META_KEY]: updateFieldBindingMeta(currentData, fieldName, nextValue),
|
||||
});
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
if (!data.outputDefs || data.outputDefs.length === 0) {
|
||||
@@ -104,19 +131,6 @@
|
||||
d="M15.5 5C13.567 5 12 6.567 12 8.5C12 10.433 13.567 12 15.5 12C17.433 12 19 10.433 19 8.5C19 6.567 17.433 5 15.5 5ZM10 8.5C10 5.46243 12.4624 3 15.5 3C18.5376 3 21 5.46243 21 8.5C21 9.6575 20.6424 10.7315 20.0317 11.6175L22.7071 14.2929L21.2929 15.7071L18.6175 13.0317C17.7315 13.6424 16.6575 14 15.5 14C12.4624 14 10 11.5376 10 8.5ZM3 4H8V6H3V4ZM3 11H8V13H3V11ZM21 18V20H3V18H21Z"></path>
|
||||
</svg>
|
||||
{/snippet}
|
||||
|
||||
<div class="heading">
|
||||
<Heading level={3}>输入参数</Heading>
|
||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
||||
addParameter(currentNodeId)
|
||||
}}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<RefParameterList />
|
||||
|
||||
<Heading level={3} mt="10px">知识库设置</Heading>
|
||||
<div class="setting-title">知识库</div>
|
||||
<div class="setting-item">
|
||||
@@ -137,14 +151,9 @@
|
||||
placeholder="请输入关键字"
|
||||
style="width: 100%"
|
||||
parameters={editorParameters}
|
||||
value={data.keyword || ''}
|
||||
oninput={(e)=>{
|
||||
const newValue = e.target.value;
|
||||
updateNodeData(currentNodeId, ()=>{
|
||||
return {
|
||||
keyword: newValue
|
||||
}
|
||||
})
|
||||
value={String(data.keyword || '')}
|
||||
oninput={(e: any)=>{
|
||||
syncFieldValue('keyword', e.target.value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -169,15 +178,10 @@
|
||||
placeholder="搜索的数据条数"
|
||||
style="width: 100%"
|
||||
parameters={editorParameters}
|
||||
oninput={(e)=>{
|
||||
const newValue = e.target.value;
|
||||
updateNodeData(currentNodeId, ()=>{
|
||||
return {
|
||||
limit: newValue
|
||||
}
|
||||
})
|
||||
oninput={(e: any)=>{
|
||||
syncFieldValue('limit', e.target.value)
|
||||
}}
|
||||
value={data.limit || ''}
|
||||
value={String(data.limit || '')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,8 +9,14 @@
|
||||
import {getOptions} from '../utils/NodeUtils';
|
||||
import {onMount} from 'svelte';
|
||||
import OutputDefList from '../core/OutputDefList.svelte';
|
||||
import type {SelectItem} from '#types';
|
||||
import type {Parameter, SelectItem} from '#types';
|
||||
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
|
||||
import {
|
||||
buildEditorReferenceParameters,
|
||||
FIELD_BINDING_META_KEY,
|
||||
syncManagedParametersForFields,
|
||||
updateFieldBindingMeta,
|
||||
} from '../../utils/workflowNodeFields';
|
||||
|
||||
const { data, ...rest }: {
|
||||
data: NodeProps['data'],
|
||||
@@ -20,11 +26,14 @@
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
let currentNode = useNodesData(currentNodeId);
|
||||
const { addParameter } = useAddParameter();
|
||||
const { nodes } = $derived(useStore());
|
||||
const { nodes, edges } = $derived(useStore());
|
||||
const editorParameters = $derived.by(() => {
|
||||
const parameters = [
|
||||
...(((currentNode?.current?.data?.parameters as Array<any>) || data.parameters || []) as Array<any>)
|
||||
];
|
||||
const parameters = buildEditorReferenceParameters(
|
||||
currentNodeId,
|
||||
nodes || [],
|
||||
edges || [],
|
||||
(((currentNode?.current?.data?.parameters as Array<Parameter>) || data.parameters || []) as Array<Parameter>)
|
||||
);
|
||||
if (queryContextNodeIds.length > 0) {
|
||||
parameters.push({
|
||||
id: 'queryDataContext',
|
||||
@@ -109,6 +118,23 @@
|
||||
});
|
||||
|
||||
const { updateNodeData } = useSvelteFlow();
|
||||
const syncFieldValue = (fieldName: 'systemPrompt' | 'userPrompt', nextValue: string) => {
|
||||
const currentData = ((currentNode?.current?.data as Record<string, any>) || data || {}) as Record<string, any>;
|
||||
const nextFieldValues = {
|
||||
systemPrompt: fieldName === 'systemPrompt' ? nextValue : (currentData.systemPrompt || ''),
|
||||
userPrompt: fieldName === 'userPrompt' ? nextValue : (currentData.userPrompt || ''),
|
||||
};
|
||||
updateNodeData(currentNodeId, {
|
||||
[fieldName]: nextValue,
|
||||
parameters: syncManagedParametersForFields(
|
||||
(currentData.parameters as Array<Parameter>) || [],
|
||||
editorParameters,
|
||||
nextFieldValues
|
||||
),
|
||||
[FIELD_BINDING_META_KEY]: updateFieldBindingMeta(currentData, fieldName, nextValue),
|
||||
});
|
||||
};
|
||||
|
||||
const setOutType = (value: string) => {
|
||||
updateNodeData(currentNodeId, () => {
|
||||
return {
|
||||
@@ -175,7 +201,7 @@
|
||||
</script>
|
||||
|
||||
|
||||
<NodeWrapper {data} {...rest}>
|
||||
<NodeWrapper {data} {...rest} wrapperClass="tf-node-wrapper--llm">
|
||||
|
||||
{#snippet icon()}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
@@ -185,18 +211,6 @@
|
||||
{/snippet}
|
||||
|
||||
<div class="heading">
|
||||
<Heading level={3}>输入参数</Heading>
|
||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
||||
addParameter(currentNodeId)
|
||||
}}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
<RefParameterList />
|
||||
|
||||
<div class="heading" style="padding-top: 10px">
|
||||
<Heading level={3}>图片识别</Heading>
|
||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
||||
addParameter(currentNodeId, "images")
|
||||
@@ -250,7 +264,7 @@
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={data.temperature ?? 0.7}
|
||||
oninput={(e) => updateNodeData(currentNodeId, { temperature: parseFloat(e.target.value) })}
|
||||
oninput={(e) => updateNodeData(currentNodeId, { temperature: parseFloat((e.target as HTMLInputElement).value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,7 +279,7 @@
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={data.topP ?? 0.9}
|
||||
oninput={(e) => updateNodeData(currentNodeId, { topP: parseFloat(e.target.value) })}
|
||||
oninput={(e) => updateNodeData(currentNodeId, { topP: parseFloat((e.target as HTMLInputElement).value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -280,7 +294,7 @@
|
||||
max="100"
|
||||
step="1"
|
||||
value={data.topK ?? 50}
|
||||
oninput={(e) => updateNodeData(currentNodeId, { topK: parseInt(e.target.value) })}
|
||||
oninput={(e) => updateNodeData(currentNodeId, { topK: parseInt((e.target as HTMLInputElement).value) })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -298,11 +312,9 @@
|
||||
placeholder="请输入系统提示词"
|
||||
style="width: 100%"
|
||||
parameters={editorParameters}
|
||||
value={data.systemPrompt || ''}
|
||||
oninput={(e)=>{
|
||||
updateNodeData(currentNodeId, {
|
||||
systemPrompt: e.target.value
|
||||
});
|
||||
value={String(data.systemPrompt || '')}
|
||||
oninput={(e: any)=>{
|
||||
syncFieldValue('systemPrompt', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -315,11 +327,9 @@
|
||||
placeholder="请输入用户提示词"
|
||||
style="width: 100%"
|
||||
parameters={editorParameters}
|
||||
value={data.userPrompt || ''}
|
||||
oninput={(e)=>{
|
||||
updateNodeData(currentNodeId, {
|
||||
userPrompt: e.target.value
|
||||
});
|
||||
value={String(data.userPrompt || '')}
|
||||
oninput={(e: any)=>{
|
||||
syncFieldValue('userPrompt', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -333,7 +343,7 @@
|
||||
label: 'JSON',
|
||||
value: 'json'
|
||||
}]} style="width: 100px;margin-left: auto" onSelect={(item)=>{
|
||||
setOutType(item.value);
|
||||
setOutType(String(item.value));
|
||||
}} value={data.outType ? [data.outType] : []} />
|
||||
</div>
|
||||
<OutputDefList />
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
import DefinedParameterList from '../core/DefinedParameterList.svelte';
|
||||
import {getCurrentNodeId} from '#components/utils/NodeUtils';
|
||||
import {useAddParameter} from '../utils/useAddParameter.svelte';
|
||||
import {useSvelteFlow} from '@xyflow/svelte';
|
||||
import {
|
||||
ensureStartNodeParameters,
|
||||
hasSystemStartParameter,
|
||||
isSystemStartParameter,
|
||||
} from '../../utils/workflowNodeFields';
|
||||
|
||||
const { data, ...rest }: {
|
||||
data: NodeProps['data'],
|
||||
@@ -14,6 +20,30 @@
|
||||
|
||||
const currentNodeId = getCurrentNodeId();
|
||||
const { addParameter } = useAddParameter();
|
||||
const { updateNodeData } = useSvelteFlow();
|
||||
|
||||
$effect(() => {
|
||||
const currentParameters = (data.parameters as Array<any>) || [];
|
||||
if (!hasSystemStartParameter(currentParameters)) {
|
||||
return;
|
||||
}
|
||||
const parameters = ensureStartNodeParameters(currentParameters);
|
||||
if (JSON.stringify(currentParameters) !== JSON.stringify(parameters)) {
|
||||
updateNodeData(currentNodeId, {
|
||||
parameters
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
let currentParameters = $derived.by(() => {
|
||||
return ((data.parameters as Array<any>) || []);
|
||||
});
|
||||
let systemParameters = $derived.by(() => {
|
||||
return currentParameters.filter((parameter) => isSystemStartParameter(parameter));
|
||||
});
|
||||
let customParameters = $derived.by(() => {
|
||||
return currentParameters.filter((parameter) => !isSystemStartParameter(parameter));
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
@@ -25,26 +55,51 @@
|
||||
d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM12 15C10.3431 15 9 13.6569 9 12C9 10.3431 10.3431 9 12 9C13.6569 9 15 10.3431 15 12C15 13.6569 13.6569 15 12 15Z"></path>
|
||||
</svg>
|
||||
{/snippet}
|
||||
<div class="heading">
|
||||
<Heading level={3}>输入参数</Heading>
|
||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
||||
addParameter(currentNodeId, "parameters", {refType: "input", name: "newParam"});
|
||||
}}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11 11V5H13V11H19V13H13V19H11V13H5V11H11Z"></path>
|
||||
</svg>
|
||||
</Button>
|
||||
<div class="param-section">
|
||||
<div class="heading">
|
||||
<Heading level={3}>系统入口</Heading>
|
||||
</div>
|
||||
<div class="section-description">固定入口参数,作为工作流默认输入来源。</div>
|
||||
<DefinedParameterList parameters={systemParameters} emptyText="暂无系统入口参数" />
|
||||
</div>
|
||||
|
||||
<div class="param-section">
|
||||
<div class="heading">
|
||||
<Heading level={3}>自定义参数</Heading>
|
||||
<Button class="input-btn-more" style="margin-left: auto" onclick={()=>{
|
||||
addParameter(currentNodeId, "parameters", {refType: "input", name: "newParam", 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>
|
||||
<DefinedParameterList />
|
||||
</NodeWrapper>
|
||||
|
||||
<style lang="less">
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.param-section + .param-section {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--tf-border-color);
|
||||
}
|
||||
|
||||
.section-description {
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--tf-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
:global(.input-btn-more) {
|
||||
border: 1px solid transparent;
|
||||
padding: 3px;
|
||||
@@ -55,4 +110,3 @@
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export const genUuid = () => {
|
||||
return '10000000-1000-4000-8000-100000000000'.replace(/[018]/g, (c: any) =>
|
||||
(
|
||||
c ^
|
||||
(crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))
|
||||
((crypto.getRandomValues(new Uint8Array(1))[0] ?? 0) & (15 >> (c / 4)))
|
||||
).toString(16),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -63,14 +63,20 @@ describe('paramToken utils', () => {
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
dataType: undefined,
|
||||
displayName: 'input',
|
||||
name: 'input',
|
||||
resolved: false,
|
||||
},
|
||||
{
|
||||
dataType: undefined,
|
||||
displayName: 'docs',
|
||||
name: 'docs',
|
||||
resolved: true,
|
||||
},
|
||||
{
|
||||
dataType: undefined,
|
||||
displayName: 'runtimeInput',
|
||||
name: 'runtimeInput',
|
||||
resolved: true,
|
||||
},
|
||||
|
||||
@@ -2,6 +2,11 @@ export interface ParameterLike {
|
||||
name?: string;
|
||||
ref?: string;
|
||||
refType?: string;
|
||||
resolved?: boolean;
|
||||
disconnected?: boolean;
|
||||
displayName?: string;
|
||||
formLabel?: string;
|
||||
dataType?: string;
|
||||
children?: ParameterLike[];
|
||||
}
|
||||
|
||||
@@ -27,6 +32,9 @@ export type TokenPart =
|
||||
export interface ParameterCandidate {
|
||||
name: string;
|
||||
resolved: boolean;
|
||||
disconnected?: boolean;
|
||||
displayName?: string;
|
||||
dataType?: string;
|
||||
}
|
||||
|
||||
const TOKEN_PATTERN = /\{\{\s*([^{}]+?)\s*}}/g;
|
||||
@@ -46,6 +54,10 @@ function isParameterResolved(parameter?: ParameterLike): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof parameter.resolved === 'boolean') {
|
||||
return parameter.resolved;
|
||||
}
|
||||
|
||||
const refType = (parameter.refType || '').trim();
|
||||
if (refType === 'fixed' || refType === 'input') {
|
||||
return true;
|
||||
@@ -65,7 +77,13 @@ export function flattenParameterCandidates(
|
||||
const candidates: ParameterCandidate[] = [];
|
||||
const indexMap = new Map<string, number>();
|
||||
|
||||
const addCandidate = (name: string, resolved: boolean) => {
|
||||
const addCandidate = (
|
||||
name: string,
|
||||
resolved: boolean,
|
||||
disconnected: boolean,
|
||||
displayName?: string,
|
||||
dataType?: string,
|
||||
) => {
|
||||
const normalized = name.trim();
|
||||
if (!normalized) {
|
||||
return;
|
||||
@@ -76,13 +94,26 @@ export function flattenParameterCandidates(
|
||||
candidates.push({
|
||||
name: normalized,
|
||||
resolved,
|
||||
disconnected,
|
||||
displayName: displayName?.trim() || normalized,
|
||||
dataType,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 同名参数只要有一个可解析,就视为可解析
|
||||
const existingCandidate = candidates[exists]!;
|
||||
if (resolved) {
|
||||
candidates[exists].resolved = true;
|
||||
existingCandidate.resolved = true;
|
||||
existingCandidate.disconnected = false;
|
||||
} else if (disconnected && !existingCandidate.resolved) {
|
||||
existingCandidate.disconnected = true;
|
||||
}
|
||||
if (!existingCandidate.displayName && displayName?.trim()) {
|
||||
existingCandidate.displayName = displayName.trim();
|
||||
}
|
||||
if (!existingCandidate.dataType && dataType) {
|
||||
existingCandidate.dataType = dataType;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,7 +130,18 @@ export function flattenParameterCandidates(
|
||||
|
||||
const currentPath = parentPath ? `${parentPath}.${rawName}` : rawName;
|
||||
const currentResolved = inheritedResolved && isParameterResolved(item);
|
||||
addCandidate(currentPath, currentResolved);
|
||||
const currentDisconnected = item?.disconnected === true;
|
||||
const displayName =
|
||||
item?.displayName?.trim() ||
|
||||
item?.formLabel?.trim() ||
|
||||
currentPath;
|
||||
addCandidate(
|
||||
currentPath,
|
||||
currentResolved,
|
||||
currentDisconnected,
|
||||
displayName,
|
||||
item?.dataType,
|
||||
);
|
||||
|
||||
if (item.children && item.children.length > 0) {
|
||||
walk(item.children, currentPath, currentResolved);
|
||||
|
||||
@@ -27,8 +27,9 @@ const getChildren = (
|
||||
const dataType = nodeIsChildren
|
||||
? `Array<${param.dataType || 'String'}>`
|
||||
: param.dataType || 'String';
|
||||
const label = param.formLabel || param.displayName || param.name;
|
||||
return {
|
||||
label: param.name,
|
||||
label,
|
||||
dataType: dataType,
|
||||
value: parentId + '.' + param.name,
|
||||
selectable: true,
|
||||
@@ -71,8 +72,10 @@ const nodeToOptions = (
|
||||
const dataType = nodeIsChildren
|
||||
? `Array<${parameter.dataType || 'String'}>`
|
||||
: parameter.dataType || 'String';
|
||||
const label =
|
||||
parameter.formLabel || parameter.displayName || parameter.name;
|
||||
children.push({
|
||||
label: parameter.name,
|
||||
label,
|
||||
dataType: dataType,
|
||||
value: node.id + '.' + parameter.name,
|
||||
selectable: true,
|
||||
|
||||
Reference in New Issue
Block a user