发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
12 changed files with 419 additions and 67 deletions
Showing only changes of commit 765006747a - Show all commits

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { describe, expect, it, vi } from 'vitest';
@@ -44,4 +45,21 @@ describe('workflow final output', () => {
expect(wrapper.text()).toContain('复制全部');
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 () => {
const wrapper = mount(ChatTimeMarkdown, {
props: {

View File

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

View File

@@ -170,4 +170,43 @@ describe('ParamTokenEditor', () => {
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">
import NodeWrapper from '../core/NodeWrapper.svelte';
import {type NodeProps, useSvelteFlow} from '@xyflow/svelte';
import {Button, Heading} from '../base';
import {Textarea} from '../base/index.js';
import RefParameterList from '../core/RefParameterList.svelte';
import {type NodeProps, useNodesData, useStore, useSvelteFlow} from '@xyflow/svelte';
import {Heading} from '../base';
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {useAddParameter} from '../utils/useAddParameter.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 }: {
data: TinyflowNodeData,
@@ -17,10 +20,30 @@
} = $props();
const currentNodeId = getCurrentNodeId();
let currentNode = useNodesData(currentNodeId);
const { addParameter } = useAddParameter();
const { nodes, edges } = $derived(useStore());
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(() => {
if (!data.outputDefs || data.outputDefs.length === 0) {
@@ -44,28 +67,21 @@
</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" mb="10px">模板内容</Heading>
<Heading level={3} mb="10px">模板内容</Heading>
<div class="setting-item">
<Textarea rows={10} placeholder="请输入模板内容" style="width: 100%" onchange={(e:any)=>{
updateNodeData(currentNodeId, ()=>{
return {
template: e.target.value
}
})
}} value={String(data.template || '')} />
<ParamTokenEditor
mode="textarea"
rows={10}
tokenSyntax="enjoy"
placeholder="请输入模板内容"
style="width: 100%"
parameters={editorParameters}
value={String(data.template || '')}
oninput={(e:any)=>{
syncTemplateValue(e.target.value)
}}
/>
</div>
@@ -91,4 +107,3 @@
}
</style>

View File

@@ -5,6 +5,8 @@ import {
findBackspaceTokenRange,
findTokenRangeAtCursor,
flattenParameterNames,
formatParamToken,
getTokenRanges,
insertTextAtCursor,
parseTokenParts,
splitTokenDisplay,
@@ -64,18 +66,21 @@ describe('paramToken utils', () => {
expect(result).toEqual([
{
dataType: undefined,
disconnected: false,
displayName: 'input',
name: 'input',
resolved: false,
},
{
dataType: undefined,
disconnected: false,
displayName: 'docs',
name: 'docs',
resolved: true,
},
{
dataType: undefined,
disconnected: false,
displayName: 'runtimeInput',
name: 'runtimeInput',
resolved: true,
@@ -178,4 +183,20 @@ describe('paramToken utils', () => {
expect(rightBoundary?.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;
}
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 {
return tokenKey.trim();
@@ -156,14 +171,16 @@ export function flattenParameterCandidates(
export function parseTokenParts(
content: string,
validParams: string[] = [],
syntax: TokenSyntax = 'mustache',
): TokenPart[] {
const source = content ?? '';
const validSet = new Set(validParams.map(normalizeTokenKey));
const parts: TokenPart[] = [];
const tokenPattern = getTokenPattern(syntax);
let lastIndex = 0;
TOKEN_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null = TOKEN_PATTERN.exec(source);
tokenPattern.lastIndex = 0;
let match: RegExpExecArray | null = tokenPattern.exec(source);
while (match) {
if (match.index > lastIndex) {
@@ -183,7 +200,7 @@ export function parseTokenParts(
});
lastIndex = match.index + rawToken.length;
match = TOKEN_PATTERN.exec(source);
match = tokenPattern.exec(source);
}
if (lastIndex < source.length) {
@@ -203,11 +220,15 @@ export function parseTokenParts(
return parts;
}
export function getTokenRanges(content: string): TokenRange[] {
export function getTokenRanges(
content: string,
syntax: TokenSyntax = 'mustache',
): TokenRange[] {
const source = content ?? '';
const ranges: TokenRange[] = [];
TOKEN_PATTERN.lastIndex = 0;
let match: RegExpExecArray | null = TOKEN_PATTERN.exec(source);
const tokenPattern = getTokenPattern(syntax);
tokenPattern.lastIndex = 0;
let match: RegExpExecArray | null = tokenPattern.exec(source);
while (match) {
const rawToken = match[0];
@@ -217,7 +238,7 @@ export function getTokenRanges(content: string): TokenRange[] {
text: rawToken,
key: normalizeTokenKey(match[1] || ''),
});
match = TOKEN_PATTERN.exec(source);
match = tokenPattern.exec(source);
}
return ranges;
@@ -229,6 +250,7 @@ export function findTokenRangeAtCursor(
options?: {
includeStart?: boolean;
includeEnd?: boolean;
syntax?: TokenSyntax;
},
): TokenRange | null {
if (!Number.isInteger(cursor) || cursor < 0) {
@@ -237,7 +259,7 @@ export function findTokenRangeAtCursor(
const includeStart = options?.includeStart ?? false;
const includeEnd = options?.includeEnd ?? false;
const ranges = getTokenRanges(content);
const ranges = getTokenRanges(content, options?.syntax);
for (const range of ranges) {
const leftValid = includeStart
? cursor >= range.start
@@ -254,10 +276,12 @@ export function findTokenRangeAtCursor(
export function findBackspaceTokenRange(
content: string,
cursor: number,
syntax: TokenSyntax = 'mustache',
): TokenRange | null {
return findTokenRangeAtCursor(content, cursor, {
includeStart: false,
includeEnd: true,
syntax,
});
}

View File

@@ -8,6 +8,7 @@ import {
buildSequentialFieldBindingPatches,
buildFieldBindingPatch,
buildEditorReferenceParameters,
buildEnjoyEditorReferenceParameters,
buildSingleRunModel,
buildSingleRunParameters,
createInitialWorkflowData,
@@ -18,6 +19,7 @@ import {
normalizeWorkflowStartNodes,
renameStartFieldReferencesInNodes,
removeStartFormField,
syncManagedParametersForFields,
updateStartFormField,
} from './workflowNodeFields';
@@ -666,6 +668,74 @@ describe('workflow node fields', () => {
).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', () => {
const knowledgeNode: Node = {
id: 'knowledge_1',

View File

@@ -1,7 +1,10 @@
import type { Edge, Node } from '@xyflow/svelte';
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 {
buildLoopReferenceParameters,
@@ -1250,10 +1253,103 @@ export function buildEditorReferenceParameters(
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(
existingParameters: Parameter[] | undefined,
candidateParameters: Parameter[],
fieldValues: Record<string, string | undefined>,
tokenSyntax: TokenSyntax = 'mustache',
) {
const currentParameters = Array.isArray(existingParameters)
? existingParameters.map(ensureParameterId)
@@ -1262,7 +1358,9 @@ export function syncManagedParametersForFields(
new Set(
Object.values(fieldValues)
.flatMap((value) =>
getTokenRanges(asString(value)).map((token) => token.key),
getTokenRanges(asString(value), tokenSyntax).map(
(token) => token.key,
),
)
.filter((value) => value.trim().length > 0),
),