feat: 完成工作流多文件文档解析闭环
- 支持文档解析节点批量解析并收口为 documents 轻量输出 - 收口引用树、节点输出展示与旧工作流固定输出兼容 - 修复共享按钮点击事件,恢复多个节点加号交互
This commit is contained in:
@@ -24,14 +24,37 @@ export default {
|
||||
],
|
||||
outputDefs: [
|
||||
{
|
||||
name: 'content',
|
||||
title: $t('aiWorkflow.parsedText'),
|
||||
dataType: 'String',
|
||||
name: 'documents',
|
||||
title: $t('aiWorkflow.documents'),
|
||||
dataType: 'Array',
|
||||
dataTypeDisabled: true,
|
||||
required: true,
|
||||
parametersAddEnable: false,
|
||||
description: $t('aiWorkflow.descriptions.parsedText'),
|
||||
description: $t('aiWorkflow.documents'),
|
||||
deleteDisabled: true,
|
||||
nameDisabled: true,
|
||||
children: [
|
||||
{
|
||||
name: 'fileName',
|
||||
title: $t('aiWorkflow.fileName'),
|
||||
dataType: 'String',
|
||||
dataTypeDisabled: true,
|
||||
required: true,
|
||||
parametersAddEnable: false,
|
||||
deleteDisabled: true,
|
||||
nameDisabled: true,
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
title: $t('aiWorkflow.parsedText'),
|
||||
dataType: 'String',
|
||||
dataTypeDisabled: true,
|
||||
required: true,
|
||||
parametersAddEnable: false,
|
||||
deleteDisabled: true,
|
||||
nameDisabled: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { flushSync, mount, unmount } from 'svelte';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import Button from './button.svelte';
|
||||
import MenuButton from './menu-button.svelte';
|
||||
|
||||
describe('Button', () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('转发 onclick 到原生按钮', async () => {
|
||||
const handleClick = vi.fn();
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
||||
const app = mount(Button, {
|
||||
target: host,
|
||||
props: {
|
||||
onclick: handleClick,
|
||||
},
|
||||
});
|
||||
flushSync();
|
||||
|
||||
(host.querySelector('button') as HTMLButtonElement).click();
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
|
||||
await unmount(app);
|
||||
host.remove();
|
||||
});
|
||||
|
||||
it('通过 MenuButton 转发 onclick 到原生按钮', async () => {
|
||||
const handleClick = vi.fn();
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
||||
const app = mount(MenuButton, {
|
||||
target: host,
|
||||
props: {
|
||||
onclick: handleClick,
|
||||
},
|
||||
});
|
||||
flushSync();
|
||||
|
||||
(host.querySelector('button') as HTMLButtonElement).click();
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
|
||||
await unmount(app);
|
||||
host.remove();
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,16 @@
|
||||
import type {MyHTMLButtonAttributes} from './types';
|
||||
import type {Snippet} from 'svelte';
|
||||
|
||||
const { children, primary, ...rest }: MyHTMLButtonAttributes & {
|
||||
const { children, primary, onclick, ...rest }: MyHTMLButtonAttributes & {
|
||||
children?: Snippet;
|
||||
primary?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
<button type="button" {...rest} class="tf-btn {primary?'tf-btn-primary':''} nopan nodrag {rest.class}">
|
||||
<button
|
||||
type="button"
|
||||
{...rest}
|
||||
onclick={onclick}
|
||||
class="tf-btn {primary?'tf-btn-primary':''} nopan nodrag {rest.class}"
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
|
||||
@@ -182,11 +182,14 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"></polyline></svg>
|
||||
</span>
|
||||
{/if}
|
||||
<span class="tf-parameter-name">{item.label}</span>
|
||||
<span class="tf-parameter-name">{item.displayLabel || item.label}</span>
|
||||
</div>
|
||||
{#if item.dataType}
|
||||
<span class="tf-parameter-type">{item.dataType}</span>
|
||||
{/if}
|
||||
{#if item.itemTypeLabel}
|
||||
<span class="tf-parameter-meta">{item.itemTypeLabel}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -768,4 +771,11 @@
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tf-parameter-meta {
|
||||
color: var(--tf-text-muted);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -40,6 +40,13 @@
|
||||
});
|
||||
|
||||
const { updateNodeData } = useSvelteFlow();
|
||||
const displayParameterName = $derived.by(() => {
|
||||
const baseName = currentParameter.name || '';
|
||||
if (!baseName) {
|
||||
return '';
|
||||
}
|
||||
return baseName;
|
||||
});
|
||||
|
||||
const updateAttribute = (key: string, value: any) => {
|
||||
updateNodeData(currentNodeId, (node) => {
|
||||
@@ -138,11 +145,13 @@
|
||||
|
||||
|
||||
<div class="input-item">
|
||||
{#if (position.length > 1)}
|
||||
{#each position as p} {/each}
|
||||
{/if}
|
||||
<Input style="width: 100%;" value={currentParameter.name} placeholder={placeholder}
|
||||
oninput={(e)=>{updateByEvent('name',e)}} disabled={currentParameter.nameDisabled === true} />
|
||||
<div class="output-name-shell" class:output-name-shell--child={position.length > 1}>
|
||||
{#if position.length > 1}
|
||||
<span class="output-branch-marker"></span>
|
||||
{/if}
|
||||
<Input style="width: 100%;" value={displayParameterName} placeholder={placeholder}
|
||||
oninput={(e)=>{updateByEvent('name',e)}} disabled={currentParameter.nameDisabled === true} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-item">
|
||||
<Select items={currentParameter.dataTypeItems || parameterDataTypes} style="width: 100%" defaultValue={["String"]}
|
||||
@@ -200,6 +209,27 @@
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.output-name-shell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
|
||||
&--child {
|
||||
padding-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.output-branch-marker {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--tf-border-color);
|
||||
border-bottom: 1px solid var(--tf-border-color);
|
||||
border-bottom-left-radius: 6px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.input-more-setting {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -221,4 +251,3 @@
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Parameter } from '#types';
|
||||
|
||||
/**
|
||||
* 解析开始节点参数在引用选择器中的展示名。
|
||||
*
|
||||
* 系统主问题字段始终保持“用户问题”口径;普通自定义字段优先展示参数名,
|
||||
* 避免文件类型字段退化为默认“文件字段”标签。
|
||||
*/
|
||||
export const getStartNodeParameterLabel = (parameter: Parameter) => {
|
||||
const name = String(parameter?.name || '').trim();
|
||||
if (name === 'user_input') {
|
||||
return (
|
||||
String(parameter?.formLabel || parameter?.displayName || '用户问题').trim()
|
||||
|| '用户问题'
|
||||
);
|
||||
}
|
||||
return (
|
||||
name
|
||||
|| String(parameter?.formLabel || parameter?.displayName || '参数').trim()
|
||||
|| '参数'
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type Edge, type Node, useNodesData, useStore } from '@xyflow/svelte';
|
||||
import type { Parameter } from '#types';
|
||||
import { getCurrentNodeId, getOptions } from '#components/utils/NodeUtils';
|
||||
import { getStartNodeParameterLabel } from '#components/utils/startNodeParameterLabel';
|
||||
import { nodeIcons } from '../../consts';
|
||||
|
||||
const fillRefNodeIds = (
|
||||
@@ -21,24 +22,39 @@ const getChildren = (
|
||||
parentId: string,
|
||||
nodeIsChildren: boolean,
|
||||
nodeType: string,
|
||||
parentPathLabel = '',
|
||||
parentIsCollection = false,
|
||||
) => {
|
||||
if (!params || params.length === 0) return [];
|
||||
return params.map((param: any) => {
|
||||
const isCollection = param.dataType === 'Array' && param.children && param.children.length > 0;
|
||||
const childBaseLabel = param.formLabel || param.displayName || param.name;
|
||||
const normalizedChildLabel = String(childBaseLabel || '').trim();
|
||||
const pathLabel = !parentPathLabel
|
||||
? normalizedChildLabel
|
||||
: parentIsCollection
|
||||
? `${parentPathLabel}.[].${normalizedChildLabel}`
|
||||
: `${parentPathLabel}.${normalizedChildLabel}`;
|
||||
const dataType = nodeIsChildren
|
||||
? `Array<${param.dataType || 'String'}>`
|
||||
: param.dataType || 'String';
|
||||
const label = param.formLabel || param.displayName || param.name;
|
||||
return {
|
||||
label,
|
||||
label: pathLabel,
|
||||
dataType: dataType,
|
||||
value: parentId + '.' + param.name,
|
||||
selectable: true,
|
||||
nodeType: nodeType,
|
||||
displayLabel: pathLabel,
|
||||
pathLabel,
|
||||
itemTypeLabel: parentIsCollection ? '数组项字段' : undefined,
|
||||
isCollection,
|
||||
children: getChildren(
|
||||
param.children,
|
||||
parentId + '.' + param.name,
|
||||
nodeIsChildren,
|
||||
nodeType,
|
||||
pathLabel,
|
||||
isCollection,
|
||||
),
|
||||
};
|
||||
});
|
||||
@@ -72,8 +88,7 @@ const nodeToOptions = (
|
||||
const dataType = nodeIsChildren
|
||||
? `Array<${parameter.dataType || 'String'}>`
|
||||
: parameter.dataType || 'String';
|
||||
const label =
|
||||
parameter.formLabel || parameter.displayName || parameter.name;
|
||||
const label = getStartNodeParameterLabel(parameter);
|
||||
children.push({
|
||||
label,
|
||||
dataType: dataType,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from './types';
|
||||
export * from './Tinyflow';
|
||||
export * from './components/TinyflowComponent.svelte';
|
||||
export * from './utils/workflowNodeFields';
|
||||
export * from './utils/sanitize';
|
||||
|
||||
@@ -15,6 +15,9 @@ export type SelectItem = {
|
||||
nodeType?: string;
|
||||
dataType?: string;
|
||||
displayLabel?: string;
|
||||
pathLabel?: string;
|
||||
itemTypeLabel?: string;
|
||||
isCollection?: boolean;
|
||||
tags?: string[];
|
||||
children?: SelectItem[];
|
||||
};
|
||||
@@ -115,6 +118,9 @@ export type Parameter = {
|
||||
name?: string;
|
||||
nameDisabled?: boolean;
|
||||
displayName?: string;
|
||||
pathLabel?: string;
|
||||
itemTypeLabel?: string;
|
||||
isCollection?: boolean;
|
||||
dataType?: string;
|
||||
dataTypeItems?: SelectItem[];
|
||||
dataTypeDisabled?: boolean;
|
||||
|
||||
@@ -359,8 +359,12 @@ describe('workflow node fields', () => {
|
||||
|
||||
expect(documentsParameter?.displayName).toBe('知识库 > documents');
|
||||
expect(documentsParameter?.formLabel).toBe('知识库 > documents');
|
||||
expect(contentParameter?.displayName).toBe('知识库 > documents.content');
|
||||
expect(contentParameter?.formLabel).toBe('知识库 > documents.content');
|
||||
expect(documentsParameter?.pathLabel).toBe('documents');
|
||||
expect(documentsParameter?.isCollection).toBe(true);
|
||||
expect(contentParameter?.displayName).toBe('知识库 > documents.[].content');
|
||||
expect(contentParameter?.formLabel).toBe('知识库 > documents.[].content');
|
||||
expect(contentParameter?.pathLabel).toBe('documents.[].content');
|
||||
expect(contentParameter?.itemTypeLabel).toBe('数组项字段');
|
||||
});
|
||||
|
||||
it('uses document node child outputs for reference display', () => {
|
||||
@@ -385,14 +389,6 @@ describe('workflow node fields', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'contents',
|
||||
formLabel: '逐文件内容',
|
||||
},
|
||||
{
|
||||
name: 'count',
|
||||
formLabel: '数量',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -419,13 +415,9 @@ describe('workflow node fields', () => {
|
||||
expect(parameters.find((item) => item.name === 'doc_1.documents')?.displayName)
|
||||
.toBe('文档解析 > documents');
|
||||
expect(parameters.find((item) => item.name === 'doc_1.documents.fileName')?.displayName)
|
||||
.toBe('文档解析 > documents.fileName');
|
||||
.toBe('文档解析 > documents.[].fileName');
|
||||
expect(parameters.find((item) => item.name === 'doc_1.documents.content')?.displayName)
|
||||
.toBe('文档解析 > documents.content');
|
||||
expect(parameters.find((item) => item.name === 'doc_1.contents')?.displayName)
|
||||
.toBe('文档解析 > contents');
|
||||
expect(parameters.find((item) => item.name === 'doc_1.count')?.displayName)
|
||||
.toBe('文档解析 > count');
|
||||
.toBe('文档解析 > documents.[].content');
|
||||
});
|
||||
|
||||
it('applies default binding to llm user prompt after connect', () => {
|
||||
|
||||
@@ -160,6 +160,38 @@ function getReferenceParameterLabel(parameter?: Parameter | null) {
|
||||
return asString(parameter?.name).trim() || getParameterLabel(parameter);
|
||||
}
|
||||
|
||||
function isCollectionOutputParameter(parameter?: Parameter | null) {
|
||||
const dataType = asString(parameter?.dataType).trim();
|
||||
return (
|
||||
!!parameter?.children?.length &&
|
||||
(
|
||||
dataType === 'Array' ||
|
||||
dataType.length === 0
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function getCollectionDisplayName(label: string) {
|
||||
return label.endsWith('.[]') ? label : `${label}.[]`;
|
||||
}
|
||||
|
||||
function buildCollectionAwareLabel(
|
||||
referenceLabel: string,
|
||||
parentLabel: string,
|
||||
parentIsCollection: boolean,
|
||||
) {
|
||||
const normalizedReferenceLabel = asString(referenceLabel).trim();
|
||||
if (!normalizedReferenceLabel) {
|
||||
return parentLabel;
|
||||
}
|
||||
if (!parentLabel) {
|
||||
return normalizedReferenceLabel;
|
||||
}
|
||||
return parentIsCollection
|
||||
? `${parentLabel}.[].${normalizedReferenceLabel}`
|
||||
: `${parentLabel}.${normalizedReferenceLabel}`;
|
||||
}
|
||||
|
||||
function buildDisconnectedDisplayName(parameter: Parameter) {
|
||||
const displayName =
|
||||
asString(parameter.displayName).trim() ||
|
||||
@@ -200,6 +232,7 @@ function flattenOutputDefs(
|
||||
parameters: Parameter[],
|
||||
parentPath = '',
|
||||
parentLabel = '',
|
||||
parentIsCollection = false,
|
||||
): Parameter[] {
|
||||
if (!parameters.length) {
|
||||
return [];
|
||||
@@ -212,17 +245,21 @@ function flattenOutputDefs(
|
||||
}
|
||||
|
||||
const path = parentPath ? `${parentPath}.${rawName}` : rawName;
|
||||
const label = parentLabel
|
||||
? `${parentLabel}.${getReferenceParameterLabel(parameter)}`
|
||||
: getReferenceParameterLabel(parameter);
|
||||
const referenceLabel = getReferenceParameterLabel(parameter);
|
||||
const label = buildCollectionAwareLabel(referenceLabel, parentLabel, parentIsCollection);
|
||||
const displayName = `${getNodeTitle(node)} > ${label}`;
|
||||
const isCollection = isCollectionOutputParameter(parameter);
|
||||
const fullRef = `${node.id}.${path}`;
|
||||
const baseCandidate: Parameter = ensureParameterId({
|
||||
name: fullRef,
|
||||
ref: fullRef,
|
||||
refType: 'ref',
|
||||
dataType: parameter.dataType || 'String',
|
||||
displayName: `${getNodeTitle(node)} > ${label}`,
|
||||
formLabel: `${getNodeTitle(node)} > ${label}`,
|
||||
displayName,
|
||||
formLabel: displayName,
|
||||
pathLabel: label,
|
||||
itemTypeLabel: parentIsCollection ? '数组项字段' : undefined,
|
||||
isCollection,
|
||||
nameDisabled: true,
|
||||
dataTypeDisabled: true,
|
||||
deleteDisabled: true,
|
||||
@@ -234,6 +271,7 @@ function flattenOutputDefs(
|
||||
parameter.children || [],
|
||||
path,
|
||||
label,
|
||||
isCollection,
|
||||
);
|
||||
|
||||
return [baseCandidate, ...children];
|
||||
@@ -1227,7 +1265,7 @@ export function renameStartFieldReferencesInNodes(
|
||||
startNodeId: string,
|
||||
currentKey: string,
|
||||
nextKey: string,
|
||||
) {
|
||||
): Node[] {
|
||||
const normalizedStartNodeId = trimString(startNodeId);
|
||||
const normalizedCurrentKey = trimString(currentKey);
|
||||
const normalizedNextKey = trimString(nextKey);
|
||||
|
||||
Reference in New Issue
Block a user