feat: 优化内容模板变量引用与换行展示

- 内容模板直接引用上游参数并自动维护运行参数

- 支持 Enjoy 变量标签编辑及中文变量显示

- 保留试运行与聊天运行页的模板输出换行
This commit is contained in:
2026-08-11 21:41:58 +08:00
parent fe41d62b8a
commit 765006747a
12 changed files with 419 additions and 67 deletions

View File

@@ -12,8 +12,8 @@ import 'vue3-json-viewer/dist/vue3-json-viewer.css';
withDefaults( withDefaults(
defineProps<{ defineProps<{
value: unknown;
plain?: boolean; plain?: boolean;
value: unknown;
}>(), }>(),
{ {
plain: false, plain: false,
@@ -49,4 +49,8 @@ watch(
border: none; border: none;
border-radius: 0; border-radius: 0;
} }
.res-container :deep(.jv-item.jv-string) {
white-space: pre-wrap;
}
</style> </style>

View File

@@ -69,7 +69,7 @@ function makeItem(item: any, index: number) {
:src="`${item}`" :src="`${item}`"
class="h-3/5 w-full" class="h-3/5 w-full"
></audio> ></audio>
<div v-if="contentType === 'text'"> <div v-if="contentType === 'text'" class="whitespace-pre-wrap">
{{ typeof item === 'string' ? item : JSON.stringify(item) }} {{ typeof item === 'string' ? item : JSON.stringify(item) }}
</div> </div>
<div v-if="contentType === 'other' || contentType === 'file'"> <div v-if="contentType === 'other' || contentType === 'file'">

View File

@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
@@ -44,4 +45,21 @@ describe('workflow final output', () => {
expect(wrapper.text()).toContain('复制全部'); expect(wrapper.text()).toContain('复制全部');
expect(wrapper.findAll('button')).toHaveLength(3); expect(wrapper.findAll('button')).toHaveLength(3);
}); });
it('renders scalar line breaks in the workflow chat result', async () => {
const wrapper = mount(WorkflowFinalOutput, {
props: {
output: {
output: '问题:你是谁\n答案你好',
},
},
});
await nextTick();
const textOutput = wrapper.get('.workflow-final-output__text');
expect(textOutput.text()).toContain('问题:你是谁');
expect(textOutput.text()).toContain('答案:你好');
expect(textOutput.find('br').exists()).toBe(true);
});
}); });

View File

@@ -0,0 +1,21 @@
import { mount } from '@vue/test-utils';
import { describe, expect, it } from 'vitest';
import ExecResultItem from '../ExecResultItem.vue';
describe('execResultItem', () => {
it('preserves line breaks for text results', () => {
const wrapper = mount(ExecResultItem, {
props: {
contentType: 'text',
defName: 'output',
result: ['问题:你是谁\n答案你好'],
resultCount: 1,
},
});
const textResult = wrapper.get('.whitespace-pre-wrap');
expect(textResult.text()).toContain('问题:你是谁\n答案你好');
});
});

View File

@@ -11,7 +11,21 @@ vi.mock('@easyflow-core/preferences', () => ({
}), }),
})); }));
describe('ChatTimeMarkdown', () => { describe('chatTimeMarkdown', () => {
it('renders a single source line break as a visible line break', async () => {
const wrapper = mount(ChatTimeMarkdown, {
props: {
content: '问题:你是谁\n答案你好',
},
});
await nextTick();
expect(wrapper.text()).toContain('问题:你是谁');
expect(wrapper.text()).toContain('答案:你好');
expect(wrapper.find('br').exists()).toBe(true);
});
it('renders mermaid code fences with the shared mermaid block', async () => { it('renders mermaid code fences with the shared mermaid block', async () => {
const wrapper = mount(ChatTimeMarkdown, { const wrapper = mount(ChatTimeMarkdown, {
props: { props: {

View File

@@ -20,7 +20,9 @@
findBackspaceTokenRange, findBackspaceTokenRange,
findTokenRangeAtCursor, findTokenRangeAtCursor,
flattenParameterCandidates, flattenParameterCandidates,
formatParamToken,
getTokenRanges, getTokenRanges,
type TokenSyntax,
} from '../utils/paramToken'; } from '../utils/paramToken';
const { const {
@@ -30,6 +32,7 @@
rows = 3, rows = 3,
placeholder = '', placeholder = '',
disabled = false, disabled = false,
tokenSyntax = 'mustache',
class: className = '', class: className = '',
style = '', style = '',
...rest ...rest
@@ -40,6 +43,7 @@
rows?: number; rows?: number;
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
tokenSyntax?: TokenSyntax;
class?: string; class?: string;
style?: string; style?: string;
[key: string]: any; [key: string]: any;
@@ -169,10 +173,13 @@
}); });
} }
function createTokenDecorations(metaMap: Map<string, TokenMeta>): Extension { function createTokenDecorations(
metaMap: Map<string, TokenMeta>,
syntax: TokenSyntax
): Extension {
const buildDecorations = (source: string) => { const buildDecorations = (source: string) => {
const decorations = []; const decorations = [];
for (const range of getTokenRanges(source)) { for (const range of getTokenRanges(source, syntax)) {
const tokenMeta = metaMap.get(range.key); const tokenMeta = metaMap.get(range.key);
const displayName = const displayName =
!tokenMeta || tokenMeta.variant === 'invalid' !tokenMeta || tokenMeta.variant === 'invalid'
@@ -299,12 +306,15 @@
if (!editorView || disabled) { if (!editorView || disabled) {
return; return;
} }
insertTextAtSelection(`{{${paramName}}}`); insertTextAtSelection(formatParamToken(paramName, tokenSyntax));
emitChange(editorView.state.doc.toString()); emitChange(editorView.state.doc.toString());
triggerObject?.hide?.(); triggerObject?.hide?.();
} }
function moveCursorAcrossToken(direction: 'left' | 'right') { function moveCursorAcrossToken(
direction: 'left' | 'right',
syntax: TokenSyntax
) {
return (view: EditorView) => { return (view: EditorView) => {
const selection = view.state.selection.main; const selection = view.state.selection.main;
if (!selection.empty) { if (!selection.empty) {
@@ -317,11 +327,13 @@
direction === 'left' direction === 'left'
? findTokenRangeAtCursor(source, cursor, { ? findTokenRangeAtCursor(source, cursor, {
includeStart: false, includeStart: false,
includeEnd: true includeEnd: true,
syntax
}) })
: findTokenRangeAtCursor(source, cursor, { : findTokenRangeAtCursor(source, cursor, {
includeStart: true, includeStart: true,
includeEnd: false includeEnd: false,
syntax
}); });
if (!range) { if (!range) {
@@ -341,13 +353,17 @@
}; };
} }
function deleteTokenBackward(view: EditorView) { function deleteTokenBackward(view: EditorView, syntax: TokenSyntax) {
const selection = view.state.selection.main; const selection = view.state.selection.main;
if (!selection.empty) { if (!selection.empty) {
return false; return false;
} }
const range = findBackspaceTokenRange(view.state.doc.toString(), selection.head); const range = findBackspaceTokenRange(
view.state.doc.toString(),
selection.head,
syntax
);
if (!range) { if (!range) {
return false; return false;
} }
@@ -366,16 +382,21 @@
return true; return true;
} }
function deleteTokenForward(view: EditorView) { function deleteTokenForward(view: EditorView, syntax: TokenSyntax) {
const selection = view.state.selection.main; const selection = view.state.selection.main;
if (!selection.empty) { if (!selection.empty) {
return false; return false;
} }
const range = findTokenRangeAtCursor(view.state.doc.toString(), selection.head, { const range = findTokenRangeAtCursor(
view.state.doc.toString(),
selection.head,
{
includeStart: true, includeStart: true,
includeEnd: false includeEnd: false,
}); syntax
}
);
if (!range) { if (!range) {
return false; return false;
} }
@@ -398,23 +419,26 @@
return text.replace(/\r\n|\r|\n/g, ' '); return text.replace(/\r\n|\r|\n/g, ' ');
} }
function createEditorKeymap(singleLine: boolean) { function createEditorKeymap(
singleLine: boolean,
syntax: TokenSyntax
) {
const entries = [ const entries = [
{ {
key: 'ArrowLeft', key: 'ArrowLeft',
run: moveCursorAcrossToken('left') run: moveCursorAcrossToken('left', syntax)
}, },
{ {
key: 'ArrowRight', key: 'ArrowRight',
run: moveCursorAcrossToken('right') run: moveCursorAcrossToken('right', syntax)
}, },
{ {
key: 'Backspace', key: 'Backspace',
run: deleteTokenBackward run: (view: EditorView) => deleteTokenBackward(view, syntax)
}, },
{ {
key: 'Delete', key: 'Delete',
run: deleteTokenForward run: (view: EditorView) => deleteTokenForward(view, syntax)
}, },
...defaultKeymap, ...defaultKeymap,
...historyKeymap ...historyKeymap
@@ -528,11 +552,11 @@
} }
}), }),
eventCompartment.of(createDomEventHandlers(isSingleLine)), eventCompartment.of(createDomEventHandlers(isSingleLine)),
keymapCompartment.of(createEditorKeymap(isSingleLine)), keymapCompartment.of(createEditorKeymap(isSingleLine, tokenSyntax)),
editableCompartment.of([EditorView.editable.of(!disabled), EditorState.readOnly.of(disabled)]), editableCompartment.of([EditorView.editable.of(!disabled), EditorState.readOnly.of(disabled)]),
placeholderCompartment.of(placeholder ? cmPlaceholder(placeholder) : []), placeholderCompartment.of(placeholder ? cmPlaceholder(placeholder) : []),
lineModeCompartment.of(createLineModeExtension(isSingleLine)), lineModeCompartment.of(createLineModeExtension(isSingleLine)),
tokenCompartment.of(createTokenDecorations(tokenMetaMap)), tokenCompartment.of(createTokenDecorations(tokenMetaMap, tokenSyntax)),
themeCompartment.of(createEditorTheme(isSingleLine)) themeCompartment.of(createEditorTheme(isSingleLine))
] ]
}) })
@@ -585,7 +609,9 @@
return; return;
} }
editorView.dispatch({ editorView.dispatch({
effects: keymapCompartment.reconfigure(createEditorKeymap(isSingleLine)) effects: keymapCompartment.reconfigure(
createEditorKeymap(isSingleLine, tokenSyntax)
)
}); });
}); });
@@ -603,7 +629,9 @@
return; return;
} }
editorView.dispatch({ editorView.dispatch({
effects: tokenCompartment.reconfigure(createTokenDecorations(tokenMetaMap)) effects: tokenCompartment.reconfigure(
createTokenDecorations(tokenMetaMap, tokenSyntax)
)
}); });
}); });

View File

@@ -170,4 +170,43 @@ describe('ParamTokenEditor', () => {
await destroy(); await destroy();
}); });
it('should render and insert Enjoy parameter tokens', async () => {
const onchange = vi.fn();
const { host, view, destroy } = await renderEditor({
tokenSyntax: 'enjoy',
value: '申请人:#(申请人)',
parameters: [
{
name: '申请人',
refType: 'ref',
ref: 'llm.output',
},
],
onchange,
});
expect(host.querySelector('.param-token-chip-text')?.textContent).toBe(
'申请人',
);
view.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: '',
},
});
(host.querySelector('.param-token-item') as HTMLButtonElement).click();
flushSync();
expect(view.state.doc.toString()).toBe('#(申请人)');
expect(onchange).toHaveBeenCalledWith({
target: {
value: '#(申请人)',
},
});
await destroy();
});
}); });

View File

@@ -2,14 +2,17 @@
<script lang="ts"> <script lang="ts">
import NodeWrapper from '../core/NodeWrapper.svelte'; import NodeWrapper from '../core/NodeWrapper.svelte';
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte'; import {type NodeProps, useNodesData, useStore, useSvelteFlow} from '@xyflow/svelte';
import {Button, Heading} from '../base'; import {Heading} from '../base';
import {Textarea} from '../base/index.js'; import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
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 OutputDefList from '../core/OutputDefList.svelte'; import OutputDefList from '../core/OutputDefList.svelte';
import type {TinyflowNodeData} from '#types'; import type {Parameter, TinyflowNodeData} from '#types';
import {
buildEnjoyEditorReferenceParameters,
syncManagedParametersForFields,
} from '../../utils/workflowNodeFields';
const { data, ...rest }: { const { data, ...rest }: {
data: TinyflowNodeData, data: TinyflowNodeData,
@@ -17,10 +20,30 @@
} = $props(); } = $props();
const currentNodeId = getCurrentNodeId(); const currentNodeId = getCurrentNodeId();
let currentNode = useNodesData(currentNodeId);
const { addParameter } = useAddParameter(); const { addParameter } = useAddParameter();
const { nodes, edges } = $derived(useStore());
const { updateNodeData } = useSvelteFlow(); const { updateNodeData } = useSvelteFlow();
const editorParameters = $derived.by(() => {
return buildEnjoyEditorReferenceParameters(
currentNodeId,
nodes || [],
edges || [],
(((currentNode?.current?.data?.parameters as Array<Parameter>) || data.parameters || []) as Array<Parameter>)
);
});
const syncTemplateValue = (nextValue: string) => {
const currentData = ((currentNode?.current?.data as Record<string, any>) || data || {}) as Record<string, any>;
updateNodeData(currentNodeId, {
template: nextValue,
parameters: syncManagedParametersForFields(
(currentData.parameters as Array<Parameter>) || [],
editorParameters,
{ template: nextValue },
'enjoy'
)
});
};
$effect(() => { $effect(() => {
if (!data.outputDefs || data.outputDefs.length === 0) { if (!data.outputDefs || data.outputDefs.length === 0) {
@@ -44,28 +67,21 @@
</svg> </svg>
{/snippet} {/snippet}
<div class="heading"> <Heading level={3} mb="10px">模板内容</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" mb="10px">模板内容</Heading>
<div class="setting-item"> <div class="setting-item">
<Textarea rows={10} placeholder="请输入模板内容" style="width: 100%" onchange={(e:any)=>{ <ParamTokenEditor
updateNodeData(currentNodeId, ()=>{ mode="textarea"
return { rows={10}
template: e.target.value tokenSyntax="enjoy"
} placeholder="请输入模板内容"
}) style="width: 100%"
}} value={String(data.template || '')} /> parameters={editorParameters}
value={String(data.template || '')}
oninput={(e:any)=>{
syncTemplateValue(e.target.value)
}}
/>
</div> </div>
@@ -91,4 +107,3 @@
} }
</style> </style>

View File

@@ -5,6 +5,8 @@ import {
findBackspaceTokenRange, findBackspaceTokenRange,
findTokenRangeAtCursor, findTokenRangeAtCursor,
flattenParameterNames, flattenParameterNames,
formatParamToken,
getTokenRanges,
insertTextAtCursor, insertTextAtCursor,
parseTokenParts, parseTokenParts,
splitTokenDisplay, splitTokenDisplay,
@@ -64,18 +66,21 @@ describe('paramToken utils', () => {
expect(result).toEqual([ expect(result).toEqual([
{ {
dataType: undefined, dataType: undefined,
disconnected: false,
displayName: 'input', displayName: 'input',
name: 'input', name: 'input',
resolved: false, resolved: false,
}, },
{ {
dataType: undefined, dataType: undefined,
disconnected: false,
displayName: 'docs', displayName: 'docs',
name: 'docs', name: 'docs',
resolved: true, resolved: true,
}, },
{ {
dataType: undefined, dataType: undefined,
disconnected: false,
displayName: 'runtimeInput', displayName: 'runtimeInput',
name: 'runtimeInput', name: 'runtimeInput',
resolved: true, resolved: true,
@@ -178,4 +183,20 @@ describe('paramToken utils', () => {
expect(rightBoundary?.key).toBe('docs'); expect(rightBoundary?.key).toBe('docs');
expect(leftBoundary?.key).toBe('docs'); expect(leftBoundary?.key).toBe('docs');
}); });
it('should format and parse Enjoy parameter tokens', () => {
expect(formatParamToken('申请人', 'enjoy')).toBe('#(申请人)');
expect(getTokenRanges('申请:#(申请人)', 'enjoy')).toEqual([
{
start: 3,
end: 9,
text: '#(申请人)',
key: '申请人',
},
]);
});
it('should keep complex Enjoy expressions as editable text', () => {
expect(getTokenRanges('#(申请人 ?? 被申请人)', 'enjoy')).toEqual([]);
});
}); });

View File

@@ -37,7 +37,22 @@ export interface ParameterCandidate {
dataType?: string; dataType?: string;
} }
const TOKEN_PATTERN = /\{\{\s*([^{}]+?)\s*}}/g; export type TokenSyntax = 'enjoy' | 'mustache';
const MUSTACHE_TOKEN_PATTERN = /\{\{\s*([^{}]+?)\s*}}/g;
const ENJOY_TOKEN_PATTERN =
/#\(\s*([A-Za-z_$\u4E00-\u9FA5][A-Za-z0-9_$\u4E00-\u9FA5]*(?:\.[A-Za-z_$\u4E00-\u9FA5][A-Za-z0-9_$\u4E00-\u9FA5]*)*)\s*\)/g;
function getTokenPattern(syntax: TokenSyntax): RegExp {
return syntax === 'enjoy' ? ENJOY_TOKEN_PATTERN : MUSTACHE_TOKEN_PATTERN;
}
export function formatParamToken(
tokenKey: string,
syntax: TokenSyntax = 'mustache',
): string {
return syntax === 'enjoy' ? `#(${tokenKey})` : `{{${tokenKey}}}`;
}
export function normalizeTokenKey(tokenKey: string): string { export function normalizeTokenKey(tokenKey: string): string {
return tokenKey.trim(); return tokenKey.trim();
@@ -156,14 +171,16 @@ export function flattenParameterCandidates(
export function parseTokenParts( export function parseTokenParts(
content: string, content: string,
validParams: string[] = [], validParams: string[] = [],
syntax: TokenSyntax = 'mustache',
): TokenPart[] { ): TokenPart[] {
const source = content ?? ''; const source = content ?? '';
const validSet = new Set(validParams.map(normalizeTokenKey)); const validSet = new Set(validParams.map(normalizeTokenKey));
const parts: TokenPart[] = []; const parts: TokenPart[] = [];
const tokenPattern = getTokenPattern(syntax);
let lastIndex = 0; let lastIndex = 0;
TOKEN_PATTERN.lastIndex = 0; tokenPattern.lastIndex = 0;
let match: RegExpExecArray | null = TOKEN_PATTERN.exec(source); let match: RegExpExecArray | null = tokenPattern.exec(source);
while (match) { while (match) {
if (match.index > lastIndex) { if (match.index > lastIndex) {
@@ -183,7 +200,7 @@ export function parseTokenParts(
}); });
lastIndex = match.index + rawToken.length; lastIndex = match.index + rawToken.length;
match = TOKEN_PATTERN.exec(source); match = tokenPattern.exec(source);
} }
if (lastIndex < source.length) { if (lastIndex < source.length) {
@@ -203,11 +220,15 @@ export function parseTokenParts(
return parts; return parts;
} }
export function getTokenRanges(content: string): TokenRange[] { export function getTokenRanges(
content: string,
syntax: TokenSyntax = 'mustache',
): TokenRange[] {
const source = content ?? ''; const source = content ?? '';
const ranges: TokenRange[] = []; const ranges: TokenRange[] = [];
TOKEN_PATTERN.lastIndex = 0; const tokenPattern = getTokenPattern(syntax);
let match: RegExpExecArray | null = TOKEN_PATTERN.exec(source); tokenPattern.lastIndex = 0;
let match: RegExpExecArray | null = tokenPattern.exec(source);
while (match) { while (match) {
const rawToken = match[0]; const rawToken = match[0];
@@ -217,7 +238,7 @@ export function getTokenRanges(content: string): TokenRange[] {
text: rawToken, text: rawToken,
key: normalizeTokenKey(match[1] || ''), key: normalizeTokenKey(match[1] || ''),
}); });
match = TOKEN_PATTERN.exec(source); match = tokenPattern.exec(source);
} }
return ranges; return ranges;
@@ -229,6 +250,7 @@ export function findTokenRangeAtCursor(
options?: { options?: {
includeStart?: boolean; includeStart?: boolean;
includeEnd?: boolean; includeEnd?: boolean;
syntax?: TokenSyntax;
}, },
): TokenRange | null { ): TokenRange | null {
if (!Number.isInteger(cursor) || cursor < 0) { if (!Number.isInteger(cursor) || cursor < 0) {
@@ -237,7 +259,7 @@ export function findTokenRangeAtCursor(
const includeStart = options?.includeStart ?? false; const includeStart = options?.includeStart ?? false;
const includeEnd = options?.includeEnd ?? false; const includeEnd = options?.includeEnd ?? false;
const ranges = getTokenRanges(content); const ranges = getTokenRanges(content, options?.syntax);
for (const range of ranges) { for (const range of ranges) {
const leftValid = includeStart const leftValid = includeStart
? cursor >= range.start ? cursor >= range.start
@@ -254,10 +276,12 @@ export function findTokenRangeAtCursor(
export function findBackspaceTokenRange( export function findBackspaceTokenRange(
content: string, content: string,
cursor: number, cursor: number,
syntax: TokenSyntax = 'mustache',
): TokenRange | null { ): TokenRange | null {
return findTokenRangeAtCursor(content, cursor, { return findTokenRangeAtCursor(content, cursor, {
includeStart: false, includeStart: false,
includeEnd: true, includeEnd: true,
syntax,
}); });
} }

View File

@@ -8,6 +8,7 @@ import {
buildSequentialFieldBindingPatches, buildSequentialFieldBindingPatches,
buildFieldBindingPatch, buildFieldBindingPatch,
buildEditorReferenceParameters, buildEditorReferenceParameters,
buildEnjoyEditorReferenceParameters,
buildSingleRunModel, buildSingleRunModel,
buildSingleRunParameters, buildSingleRunParameters,
createInitialWorkflowData, createInitialWorkflowData,
@@ -18,6 +19,7 @@ import {
normalizeWorkflowStartNodes, normalizeWorkflowStartNodes,
renameStartFieldReferencesInNodes, renameStartFieldReferencesInNodes,
removeStartFormField, removeStartFormField,
syncManagedParametersForFields,
updateStartFormField, updateStartFormField,
} from './workflowNodeFields'; } from './workflowNodeFields';
@@ -666,6 +668,74 @@ describe('workflow node fields', () => {
).toBe('流程开始 > 用户问题'); ).toBe('流程开始 > 用户问题');
}); });
it('builds stable Enjoy aliases and syncs direct upstream references', () => {
const llmNode: Node = {
id: 'node_llm',
type: 'llmNode',
position: { x: 0, y: 0 },
data: {
title: '大模型',
outputDefs: [{ name: 'output', dataType: 'String' }],
},
};
const templateNode: Node = {
id: 'node_template',
type: 'templateNode',
position: { x: 120, y: 0 },
data: {
title: '内容模板',
parameters: [],
},
};
const edges: Edge[] = [
{
id: 'edge_1',
source: 'node_llm',
target: 'node_template',
} as Edge,
];
const candidates = buildEnjoyEditorReferenceParameters(
'node_template',
[llmNode, templateNode],
edges,
[],
);
const candidate = candidates.find(
(parameter) => parameter.ref === 'node_llm.output',
);
expect(candidate?.name).toMatch(/^ref_[A-Za-z0-9_]+$/);
expect(candidate?.displayName).toBe('大模型 > output');
const parameters = syncManagedParametersForFields(
[],
candidates,
{ template: `#(${candidate?.name})` },
'enjoy',
);
expect(parameters).toHaveLength(1);
expect(parameters[0]).toMatchObject({
name: candidate?.name,
ref: 'node_llm.output',
refType: 'ref',
autoManaged: true,
});
const rebuiltCandidates = buildEnjoyEditorReferenceParameters(
'node_template',
[llmNode, templateNode],
edges,
parameters,
);
expect(
rebuiltCandidates.find(
(parameter) => parameter.ref === 'node_llm.output',
)?.name,
).toBe(candidate?.name);
});
it('uses output parameter name for reference display', () => { it('uses output parameter name for reference display', () => {
const knowledgeNode: Node = { const knowledgeNode: Node = {
id: 'knowledge_1', id: 'knowledge_1',

View File

@@ -1,7 +1,10 @@
import type { Edge, Node } from '@xyflow/svelte'; import type { Edge, Node } from '@xyflow/svelte';
import type { Parameter } from '../types'; import type { Parameter } from '../types';
import { getTokenRanges } from '../components/utils/paramToken'; import {
getTokenRanges,
type TokenSyntax,
} from '../components/utils/paramToken';
import { genShortId } from '../components/utils/IdGen'; import { genShortId } from '../components/utils/IdGen';
import { import {
buildLoopReferenceParameters, buildLoopReferenceParameters,
@@ -1250,10 +1253,103 @@ export function buildEditorReferenceParameters(
return uniqueParameters([...disconnectedParameters, ...upstreamParameters]); return uniqueParameters([...disconnectedParameters, ...upstreamParameters]);
} }
function toEnjoyParameterName(refPath: string) {
let encoded = '';
for (const char of refPath) {
if (/^[A-Za-z0-9]$/.test(char)) {
encoded += char;
} else if (char === '_') {
encoded += '__';
} else {
encoded += `_${char.codePointAt(0)?.toString(16)}_`;
}
}
return `ref_${encoded}`;
}
export function buildEnjoyEditorReferenceParameters(
currentNodeId: string,
nodes: Node[],
edges: Edge[],
existingParameters?: Parameter[],
) {
const currentParameters = Array.isArray(existingParameters)
? existingParameters.map(ensureParameterId)
: [];
const upstreamParameters = buildEditorReferenceParameters(
currentNodeId,
nodes,
edges,
[],
);
const upstreamRefSet = new Set(
upstreamParameters
.map((parameter) => trimString(parameter.ref || parameter.name))
.filter(Boolean),
);
const existingManagedByRef = new Map(
currentParameters
.filter((parameter) => parameter.autoManaged === true)
.map((parameter) => [trimString(parameter.ref), parameter] as const)
.filter(([refPath]) => refPath.length > 0),
);
const directParameters = upstreamParameters.map((parameter) => {
const refPath = trimString(parameter.ref || parameter.name);
const existingParameter = existingManagedByRef.get(refPath);
return ensureParameterId({
...parameter,
name:
trimString(existingParameter?.name) ||
toEnjoyParameterName(refPath),
ref: refPath,
});
});
const disconnectedParameters = currentParameters
.filter(
(parameter) =>
parameter.autoManaged === true &&
trimString(parameter.ref) &&
!upstreamRefSet.has(trimString(parameter.ref)),
)
.map((parameter) =>
ensureParameterId({
...parameter,
resolved: false,
disconnected: true,
displayName: buildDisconnectedDisplayName(parameter),
formLabel: buildDisconnectedDisplayName(parameter),
} as Parameter & { resolved: boolean; disconnected: boolean }),
);
const legacyParameters = currentParameters
.filter((parameter) => parameter.autoManaged !== true)
.map((parameter) => {
const refPath = trimString(parameter.ref);
const disconnected =
parameter.refType === 'ref' &&
refPath.length > 0 &&
!upstreamRefSet.has(refPath);
return ensureParameterId({
...parameter,
resolved: !disconnected,
disconnected: disconnected || undefined,
displayName:
parameter.displayName || parameter.formLabel || parameter.name,
} as Parameter & { resolved: boolean; disconnected?: boolean });
});
return uniqueParameters([
...legacyParameters,
...disconnectedParameters,
...directParameters,
]);
}
export function syncManagedParametersForFields( export function syncManagedParametersForFields(
existingParameters: Parameter[] | undefined, existingParameters: Parameter[] | undefined,
candidateParameters: Parameter[], candidateParameters: Parameter[],
fieldValues: Record<string, string | undefined>, fieldValues: Record<string, string | undefined>,
tokenSyntax: TokenSyntax = 'mustache',
) { ) {
const currentParameters = Array.isArray(existingParameters) const currentParameters = Array.isArray(existingParameters)
? existingParameters.map(ensureParameterId) ? existingParameters.map(ensureParameterId)
@@ -1262,7 +1358,9 @@ export function syncManagedParametersForFields(
new Set( new Set(
Object.values(fieldValues) Object.values(fieldValues)
.flatMap((value) => .flatMap((value) =>
getTokenRanges(asString(value)).map((token) => token.key), getTokenRanges(asString(value), tokenSyntax).map(
(token) => token.key,
),
) )
.filter((value) => value.trim().length > 0), .filter((value) => value.trim().length > 0),
), ),