feat: 支持工作流文件节点多格式文档导出

- 补齐 md/html/pdf/docx 导出与统一渲染服务

- 收口文件生成节点配置与格式校验

- 修复 PDF 中文字体与 Markdown 渲染链路
This commit is contained in:
2026-04-19 15:23:23 +08:00
parent 51198ff492
commit a5aab86de2
33 changed files with 2144 additions and 102 deletions

View File

@@ -42,6 +42,16 @@
"fileGeneration": "FileGeneration",
"fileSettings": "FileSettings",
"fileDownloadURL": "FileDownloadURL",
"targetFormat": "TargetFormat",
"fileName": "FileName",
"documents": "Documents",
"count": "Count",
"fileNamePlaceholder": "For example: meeting-notes",
"templateStyle": "TemplateStyle",
"defaultTemplateStyle": "Default",
"plain_text": "Plain Text",
"markdown": "Markdown",
"html": "HTML",
"pluginSelect": "PluginSelect",
"saveData": "SaveData",
"saveDataset": "Write Data",
@@ -105,6 +115,9 @@
"fileGeneration": "Generate Word, PDF, HTML, etc. files for users to download",
"fileType": "Please select the type of file to generate",
"fileDownloadURL": "Generated file URL",
"targetFormat": "Choose the final document format to export",
"fileName": "Optional. The system will sanitize invalid characters and enforce the target extension",
"templateStyle": "Only the default style is available in the first version. Unknown values fall back automatically",
"plugin": "Select a predefined plugin",
"saveData": "Save data to data hub",
"saveDataset": "Write data into a managed table",

View File

@@ -42,6 +42,16 @@
"fileGeneration": "文件生成",
"fileSettings": "文件设置",
"fileDownloadURL": "文件下载地址",
"targetFormat": "输出文档类型",
"fileName": "文件名",
"documents": "文档列表",
"count": "数量",
"fileNamePlaceholder": "例如:会议纪要",
"templateStyle": "模板样式",
"defaultTemplateStyle": "默认样式",
"plain_text": "纯文本",
"markdown": "Markdown",
"html": "HTML",
"pluginSelect": "插件选择",
"saveData": "保存数据",
"saveDataset": "写入数据",
@@ -105,6 +115,9 @@
"fileGeneration": "生成 Word、PDF、HTML 等文件供用户下载",
"fileType": "请选择生成的文件类型",
"fileDownloadURL": "生成后的文件地址",
"targetFormat": "选择最终要导出的文档格式",
"fileName": "可选,系统会自动补齐目标扩展名并清洗非法字符",
"templateStyle": "首版仅提供默认样式,未知值会自动回退",
"plugin": "选择定义好的插件",
"saveData": "保存数据到数据中枢",
"saveDataset": "将数据写入已接入表",

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import {
getMakeFileNodeFormPatch,
getMakeFileTargetFormatOptions,
isMakeFileFormatCombinationAllowed,
resolveMakeFileNodeTargetFormat,
resolveMakeFileNodeTemplateStyle,
sanitizeMakeFileNodeFileName,
} from '../makeFileNode';
describe('makeFileNode helpers', () => {
it('清洗文件名中的非法字符与首尾空白', () => {
expect(sanitizeMakeFileNodeFileName(' a/b\\\\c:*?<>| ')).toBe('abc');
});
it('识别受支持与不受支持的格式组合', () => {
expect(isMakeFileFormatCombinationAllowed('markdown', 'pdf')).toBe(true);
expect(isMakeFileFormatCombinationAllowed('html', 'html')).toBe(true);
expect(isMakeFileFormatCombinationAllowed('html', 'md')).toBe(false);
expect(isMakeFileFormatCombinationAllowed('html', 'docx')).toBe(false);
});
it('按当前 targetFormat 回显目标格式', () => {
expect(resolveMakeFileNodeTargetFormat({ targetFormat: 'pdf' })).toBe('pdf');
expect(resolveMakeFileNodeTargetFormat({ targetFormat: 'html' })).toBe('html');
});
it('对未知 templateStyle 做默认回退', () => {
expect(resolveMakeFileNodeTemplateStyle({ templateStyle: 'fancy' })).toBe('default');
});
it('在 html 源格式下仅暴露 html 与 pdf 目标格式', () => {
expect(getMakeFileTargetFormatOptions('html').map((item) => item.value)).toEqual([
'html',
'pdf',
]);
});
it('在 sourceFormat 切换为 html 时自动修正非法目标格式', () => {
expect(
getMakeFileNodeFormPatch('sourceFormat', 'html', {
sourceFormat: 'markdown',
targetFormat: 'docx',
}),
).toEqual({
sourceFormat: 'html',
targetFormat: 'html',
});
});
});

View File

@@ -2,6 +2,117 @@ import { $t } from '#/locales';
import nodeNames from './nodeNames';
const INVALID_FILE_NAME_PATTERN = /[\\/:*?"<>|\u0000-\u001f\u007f]/g;
export const makeFileTargetFormatValues = ['md', 'html', 'pdf', 'docx'] as const;
export const makeFileSourceFormatValues = ['plain_text', 'markdown', 'html'] as const;
const DEFAULT_TARGET_FORMAT = 'docx';
const DEFAULT_SOURCE_FORMAT = 'markdown';
const DEFAULT_TEMPLATE_STYLE = 'default';
type MakeFileTargetFormat = (typeof makeFileTargetFormatValues)[number];
type MakeFileSourceFormat = (typeof makeFileSourceFormatValues)[number];
function isMakeFileTargetFormat(value: unknown): value is MakeFileTargetFormat {
return typeof value === 'string' && makeFileTargetFormatValues.includes(value as MakeFileTargetFormat);
}
function isMakeFileSourceFormat(value: unknown): value is MakeFileSourceFormat {
return typeof value === 'string' && makeFileSourceFormatValues.includes(value as MakeFileSourceFormat);
}
function getConfiguredTargetFormat(data?: Record<string, any>) {
const candidate = data?.targetFormat;
return isMakeFileTargetFormat(candidate) ? candidate : DEFAULT_TARGET_FORMAT;
}
export function sanitizeMakeFileNodeFileName(value?: string) {
return (value ?? '').replace(INVALID_FILE_NAME_PATTERN, '').trim();
}
export function isMakeFileFormatCombinationAllowed(
sourceFormat = DEFAULT_SOURCE_FORMAT,
targetFormat = 'docx',
) {
return !(
sourceFormat === 'html' &&
(targetFormat === 'md' || targetFormat === 'docx')
);
}
export function resolveMakeFileNodeSourceFormat(data?: Record<string, any>): MakeFileSourceFormat {
return isMakeFileSourceFormat(data?.sourceFormat) ? data.sourceFormat : DEFAULT_SOURCE_FORMAT;
}
export function resolveMakeFileNodeTargetFormat(data?: Record<string, any>): MakeFileTargetFormat {
const sourceFormat = resolveMakeFileNodeSourceFormat(data);
const targetFormat = getConfiguredTargetFormat(data);
if (isMakeFileFormatCombinationAllowed(sourceFormat, targetFormat)) {
return targetFormat;
}
return sourceFormat === 'html' ? 'html' : DEFAULT_TARGET_FORMAT;
}
export function resolveMakeFileNodeTemplateStyle(data?: Record<string, any>) {
return data?.templateStyle === DEFAULT_TEMPLATE_STYLE ? DEFAULT_TEMPLATE_STYLE : DEFAULT_TEMPLATE_STYLE;
}
export function getMakeFileTargetFormatOptions(sourceFormat = DEFAULT_SOURCE_FORMAT) {
return makeFileTargetFormatValues
.filter((targetFormat) => isMakeFileFormatCombinationAllowed(sourceFormat, targetFormat))
.map((value) => ({
label: value,
value,
}));
}
export function getMakeFileNodeFormPatch(
name: string,
nextValue: string | number | boolean | undefined,
data?: Record<string, any>,
) {
if (name === 'sourceFormat') {
const sourceFormat = isMakeFileSourceFormat(nextValue) ? nextValue : DEFAULT_SOURCE_FORMAT;
const currentTargetFormat = getConfiguredTargetFormat(data);
if (isMakeFileFormatCombinationAllowed(sourceFormat, currentTargetFormat)) {
return { sourceFormat };
}
return {
sourceFormat,
targetFormat: sourceFormat === 'html' ? 'html' : DEFAULT_TARGET_FORMAT,
};
}
if (name === 'targetFormat') {
const sourceFormat = resolveMakeFileNodeSourceFormat(data);
const targetFormat = isMakeFileTargetFormat(nextValue) ? nextValue : DEFAULT_TARGET_FORMAT;
return {
targetFormat: isMakeFileFormatCombinationAllowed(sourceFormat, targetFormat)
? targetFormat
: sourceFormat === 'html'
? 'html'
: DEFAULT_TARGET_FORMAT,
};
}
if (name === 'templateStyle') {
return { templateStyle: resolveMakeFileNodeTemplateStyle({ templateStyle: nextValue }) };
}
return { [name]: nextValue };
}
function handleFileNameInput(event: Event) {
const target = event.target as HTMLInputElement | null;
if (!target) {
return;
}
const sanitized = sanitizeMakeFileNodeFileName(target.value);
if (target.value !== sanitized) {
target.value = sanitized;
}
}
export default {
[nodeNames.makeFileNode]: {
title: $t('aiWorkflow.fileGeneration'),
@@ -9,8 +120,8 @@ export default {
description: $t('aiWorkflow.descriptions.fileGeneration'),
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M14 13.5V8C14 5.79086 12.2091 4 10 4C7.79086 4 6 5.79086 6 8V13.5C6 17.0899 8.91015 20 12.5 20C16.0899 20 19 17.0899 19 13.5V4H21V13.5C21 18.1944 17.1944 22 12.5 22C7.80558 22 4 18.1944 4 13.5V8C4 4.68629 6.68629 2 10 2C13.3137 2 16 4.68629 16 8V13.5C16 15.433 14.433 17 12.5 17C10.567 17 9 15.433 9 13.5V8H11V13.5C11 14.3284 11.6716 15 12.5 15C13.3284 15 14 14.3284 14 13.5Z"></path></svg>',
sortNo: 802,
parametersAddEnable: true,
outputDefsAddEnable: true,
parametersAddEnable: false,
outputDefsAddEnable: false,
forms: [
{
type: 'heading',
@@ -18,16 +129,42 @@ export default {
},
{
type: 'select',
label: $t('documentCollection.splitterDoc.fileType'),
description: $t('aiWorkflow.descriptions.fileType'),
name: 'suffix',
defaultValue: 'docx',
label: $t('aiWorkflow.targetFormat'),
description: $t('aiWorkflow.descriptions.targetFormat'),
name: 'targetFormat',
defaultValue: DEFAULT_TARGET_FORMAT,
resolveValue: resolveMakeFileNodeTargetFormat,
resolveOptions: (data: Record<string, any>) =>
getMakeFileTargetFormatOptions(resolveMakeFileNodeSourceFormat(data)),
onValueChange: (value: string | number | boolean | undefined, data: Record<string, any>) =>
getMakeFileNodeFormPatch('targetFormat', value, data),
},
{
type: 'input',
label: $t('aiWorkflow.fileName'),
description: $t('aiWorkflow.descriptions.fileName'),
name: 'fileName',
placeholder: $t('aiWorkflow.fileNamePlaceholder'),
attrs: {
maxlength: 128,
oninput: handleFileNameInput,
},
},
{
type: 'select',
label: $t('aiWorkflow.templateStyle'),
description: $t('aiWorkflow.descriptions.templateStyle'),
name: 'templateStyle',
defaultValue: DEFAULT_TEMPLATE_STYLE,
resolveValue: resolveMakeFileNodeTemplateStyle,
options: [
{
label: 'docx',
value: 'docx',
label: $t('aiWorkflow.defaultTemplateStyle'),
value: DEFAULT_TEMPLATE_STYLE,
},
],
onValueChange: (value: string | number | boolean | undefined, data: Record<string, any>) =>
getMakeFileNodeFormPatch('templateStyle', value, data),
},
],
parameters: [

View File

@@ -4,10 +4,9 @@
import {Button, Chosen, Heading, Input, Select, Textarea} from '../base';
import RefParameterList from '../core/RefParameterList.svelte';
import {getCurrentNodeId} from '#components/utils/NodeUtils';
import {useAddParameter} from '../utils/useAddParameter.svelte';
import {fillParameterId, useAddParameter} from '../utils/useAddParameter.svelte';
import {getOptions} from '../utils/NodeUtils';
import OutputDefList from '../core/OutputDefList.svelte';
import {fillParameterId} from '../utils/useAddParameter.svelte.js';
import ParamTokenEditor from '../core/ParamTokenEditor.svelte';
const { data, ...rest }: {
@@ -23,15 +22,31 @@
const editorParameters = $derived.by(() => {
return (currentNode?.current?.data?.parameters as Array<any>) || data.parameters || [];
});
const currentNodeData = $derived.by(() => {
return (currentNode?.current?.data as Record<string, any>) || (data as Record<string, any>) || {};
});
const updateNodeData = (data: Record<string, any>) => {
updateNodeDataInner(currentNodeId, data);
};
const updateNodeDataByEvent = (name: string, event: Event) => {
updateNodeData({
[name]: (event.target as any)?.value
});
const resolveFormValue = (form: any) => {
return form.resolveValue?.(currentNodeData) ?? currentNodeData?.[form.name] ?? form.defaultValue;
};
const resolveFormOptions = (form: any) => {
return form.resolveOptions?.(currentNodeData) ?? form.options ?? [];
};
const updateFormValue = (form: any, nextValue: any) => {
const patch = form.onValueChange?.(nextValue, currentNodeData) ?? {
[form.name]: nextValue
};
updateNodeData(patch);
};
const updateNodeDataByEvent = (form: any, event: Event) => {
updateFormValue(form, (event.target as any)?.value);
};
const node = {
@@ -118,21 +133,21 @@
mode="input"
placeholder={form.placeholder}
style="width: 100%"
value={data[form.name] || form.defaultValue}
value={resolveFormValue(form)}
parameters={editorParameters}
{...form.attrs}
oninput={(e)=>{
updateNodeDataByEvent(form.name,e)
oninput={(e:any)=>{
updateNodeDataByEvent(form,e)
}}
/>
{:else}
<Input
placeholder={form.placeholder}
style="width: 100%"
value={data[form.name] || form.defaultValue}
value={resolveFormValue(form)}
{...form.attrs}
onchange={(e)=>{
updateNodeDataByEvent(form.name,e)
updateNodeDataByEvent(form,e)
}}
/>
{/if}
@@ -146,11 +161,11 @@
rows={3}
placeholder={form.placeholder}
style="width: 100%"
value={data[form.name] || form.defaultValue}
value={resolveFormValue(form)}
parameters={editorParameters}
{...form.attrs}
oninput={(e)=>{
updateNodeDataByEvent(form.name,e)
oninput={(e:any)=>{
updateNodeDataByEvent(form,e)
}}
/>
{:else}
@@ -158,10 +173,10 @@
rows={3}
placeholder={form.placeholder}
style="width: 100%"
value={data[form.name] || form.defaultValue}
value={resolveFormValue(form)}
{...form.attrs}
onchange={(e)=>{
updateNodeDataByEvent(form.name,e)
updateNodeDataByEvent(form,e)
}}
/>
{/if}
@@ -176,19 +191,16 @@
type="range"
{...form.attrs}
value={data[form.name] ?? form.defaultValue}
oninput={(e) => updateNodeData({ [form.name]: parseFloat(e.target.value) })}
oninput={(e:any) => updateNodeData({ [form.name]: parseFloat(e.target.value) })}
/>
</div>
</div>
{:else if form.type === 'select'}
<div class="setting-title">{form.label}</div>
<div class="setting-item">
<Select items={form.options||[]} style="width: 100%" placeholder={form.placeholder} onSelect={(item)=>{
const newValue = item.value;
updateNodeData({
[form.name]: newValue
})
}} value={data[form.name] ? [data[form.name]] : [form.defaultValue]} />
<Select items={resolveFormOptions(form)} style="width: 100%" placeholder={form.placeholder} onSelect={(item)=>{
updateFormValue(form, item.value)
}} value={[resolveFormValue(form)]} />
</div>
{:else if form.type === 'chosen'}
<div class="setting-title">{form.label}</div>

View File

@@ -46,6 +46,12 @@ export type CustomNodeForm = {
defaultValue?: string | number | boolean;
attrs?: Record<string, any>;
options?: SelectItem[];
resolveValue?: (data: Record<string, any>) => string | number | boolean | undefined;
resolveOptions?: (data: Record<string, any>) => SelectItem[];
onValueChange?: (
value: string | number | boolean | undefined,
data: Record<string, any>,
) => Record<string, any> | void;
templateSupport?: boolean;
chosen?: {
labelDataKey: string;
@@ -66,6 +72,7 @@ export type CustomNode = {
icon?: string;
sortNo?: number;
group?: 'base' | 'tools';
renderFirst?: boolean;
rootClass?: string;
rootStyle?: string;
parameters?: Parameter[];