发布 v1.10 #5
@@ -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>
|
||||||
|
|||||||
@@ -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'">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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答案:你好');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import {mount} from '@vue/test-utils';
|
import { mount } from '@vue/test-utils';
|
||||||
import {nextTick} from 'vue';
|
import { nextTick } from 'vue';
|
||||||
|
|
||||||
import {describe, expect, it, vi} from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import ChatTimeMarkdown from '../ChatTimeMarkdown.vue';
|
import ChatTimeMarkdown from '../ChatTimeMarkdown.vue';
|
||||||
|
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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(
|
||||||
includeStart: true,
|
view.state.doc.toString(),
|
||||||
includeEnd: false
|
selection.head,
|
||||||
});
|
{
|
||||||
|
includeStart: true,
|
||||||
|
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)
|
||||||
|
)
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user