feat: 完成S02桥接能力并接通M09工作流文档解析闭环
- 新增统一文档解析桥接子域,封装 easy-agents 文档解析门面 - 支持工作流开始节点文件上传与素材选择的单文件对象输入 - DocNode 改为文档解析节点,PDF 走统一解析,非 PDF 保持默认读取
This commit is contained in:
@@ -26,7 +26,8 @@
|
||||
"result": "Result",
|
||||
"confirm": "For contents to be confirmed, please confirm first!",
|
||||
"completed": "Chain has been completed, please start a new one.",
|
||||
"fileContentExtraction": "FileContentExtraction",
|
||||
"fileContentExtraction": "Document Parsing",
|
||||
"documentInput": "FileInput",
|
||||
"documentAddress": "DocumentAddress",
|
||||
"parsedText": "ParsedText",
|
||||
"resourceSync": "ResourceSync",
|
||||
@@ -94,7 +95,8 @@
|
||||
"stageSave": "Save Check",
|
||||
"stagePreExecute": "Pre-execute Check",
|
||||
"descriptions": {
|
||||
"fileContentExtraction": "Extract text content from PDF or Word documents, etc",
|
||||
"fileContentExtraction": "Parse content from PDF, Word, and other document files",
|
||||
"documentInput": "Upload a file or choose one from resources",
|
||||
"documentAddress": "Document URL address",
|
||||
"parsedText": "Parsed text content",
|
||||
"resourceSync": "Download resource files and save to system resource library",
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"result": "执行结果",
|
||||
"confirm": "有待确认的内容,请先确认!",
|
||||
"completed": "流程已执行完毕,请重新发起。",
|
||||
"fileContentExtraction": "文件内容提取",
|
||||
"fileContentExtraction": "文档解析",
|
||||
"documentInput": "文件输入",
|
||||
"documentAddress": "文档地址",
|
||||
"parsedText": "解析后的文本",
|
||||
"resourceSync": "素材同步",
|
||||
@@ -94,7 +95,8 @@
|
||||
"stageSave": "保存校验",
|
||||
"stagePreExecute": "预执行校验",
|
||||
"descriptions": {
|
||||
"fileContentExtraction": "提取 PDF 或者 Word 等文件中的文字内容",
|
||||
"fileContentExtraction": "解析 PDF 或 Word 等文档内容",
|
||||
"documentInput": "上传文件或从素材库中选择文件",
|
||||
"documentAddress": "文档的url地址",
|
||||
"parsedText": "解析后的文本内容",
|
||||
"resourceSync": "下载素材文件并保存到系统素材库",
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { ElButton, ElLink, ElMessage } from 'element-plus';
|
||||
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
||||
|
||||
import {
|
||||
buildWorkflowFileValueFromResource,
|
||||
buildWorkflowFileValueFromUpload,
|
||||
formatWorkflowFileSize,
|
||||
isWorkflowFileValue,
|
||||
} from './workflowFileValue';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object as () => Record<string, any> | undefined,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const uploadLoading = ref(false);
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const currentFile = computed(() =>
|
||||
isWorkflowFileValue(props.modelValue) ? props.modelValue : undefined,
|
||||
);
|
||||
|
||||
function triggerSelectFile() {
|
||||
if (uploadLoading.value) {
|
||||
return;
|
||||
}
|
||||
fileInputRef.value?.click();
|
||||
}
|
||||
|
||||
async function handleNativeFileChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
uploadLoading.value = true;
|
||||
try {
|
||||
const res = await api.upload('/api/v1/commons/upload', { file }, {});
|
||||
const fileValue = buildWorkflowFileValueFromUpload(file, res?.data?.path);
|
||||
emit('update:modelValue', fileValue);
|
||||
} catch (error) {
|
||||
ElMessage.error('文件上传失败');
|
||||
console.error('工作流文件上传失败', error);
|
||||
} finally {
|
||||
uploadLoading.value = false;
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handleChooseResource(resource: any) {
|
||||
try {
|
||||
const fileValue = buildWorkflowFileValueFromResource(resource || {});
|
||||
emit('update:modelValue', fileValue);
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '素材文件选择失败');
|
||||
}
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
emit('update:modelValue', undefined);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workflow-file-input">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
class="workflow-file-input__native"
|
||||
type="file"
|
||||
@change="handleNativeFileChange"
|
||||
/>
|
||||
|
||||
<div v-if="currentFile" class="workflow-file-input__summary">
|
||||
<div class="workflow-file-input__name">
|
||||
{{ currentFile.fileName }}
|
||||
</div>
|
||||
<div class="workflow-file-input__meta">
|
||||
<span>{{ formatWorkflowFileSize(currentFile.size) }}</span>
|
||||
<ElLink
|
||||
v-if="currentFile.url || currentFile.filePath"
|
||||
:href="currentFile.url || currentFile.filePath"
|
||||
target="_blank"
|
||||
type="primary"
|
||||
>
|
||||
{{ $t('button.view') }}
|
||||
</ElLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workflow-file-input__actions">
|
||||
<ElButton
|
||||
type="primary"
|
||||
plain
|
||||
:loading="uploadLoading"
|
||||
@click="triggerSelectFile"
|
||||
>
|
||||
{{ currentFile ? $t('button.replace') : $t('button.upload') }}
|
||||
</ElButton>
|
||||
<ChooseResource attr-name="file" @choose="handleChooseResource" />
|
||||
<ElButton
|
||||
v-if="currentFile"
|
||||
text
|
||||
type="danger"
|
||||
@click="clearFile"
|
||||
>
|
||||
{{ $t('button.delete') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workflow-file-input {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.workflow-file-input__native {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.workflow-file-input__summary {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 10px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.workflow-file-input__name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.workflow-file-input__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.workflow-file-input__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
|
||||
import { $t } from '#/locales';
|
||||
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
|
||||
import WorkflowFileInput from '#/views/ai/workflow/components/WorkflowFileInput.vue';
|
||||
|
||||
const props = defineProps({
|
||||
parameters: {
|
||||
@@ -27,10 +28,19 @@ const props = defineProps({
|
||||
});
|
||||
const emit = defineEmits(['update:runParams']);
|
||||
function getContentType(item: any) {
|
||||
return item.contentType || 'text';
|
||||
if (item.contentType) {
|
||||
return item.contentType;
|
||||
}
|
||||
if (String(item.dataType || '').toLowerCase() === 'file') {
|
||||
return 'file';
|
||||
}
|
||||
return 'text';
|
||||
}
|
||||
function isResource(contentType: any) {
|
||||
return ['audio', 'file', 'image', 'video'].includes(contentType);
|
||||
return ['audio', 'image', 'video'].includes(contentType);
|
||||
}
|
||||
function isFileContentType(contentType: any) {
|
||||
return contentType === 'file';
|
||||
}
|
||||
function getCheckboxOptions(item: any) {
|
||||
if (item.enums) {
|
||||
@@ -105,6 +115,12 @@ function choose(data: any, propName: string) {
|
||||
:placeholder="item.formPlaceholder"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="isFileContentType(getContentType(item))">
|
||||
<WorkflowFileInput
|
||||
:model-value="runParams[item.name]"
|
||||
@update:model-value="(val) => updateParam(item.name, val)"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="isResource(getContentType(item))">
|
||||
<ElInput
|
||||
:model-value="runParams[item.name]"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildWorkflowFileValueFromResource,
|
||||
buildWorkflowFileValueFromUpload,
|
||||
formatWorkflowFileSize,
|
||||
isWorkflowFileValue,
|
||||
} from '../workflowFileValue';
|
||||
|
||||
describe('workflowFileValue', () => {
|
||||
it('从上传结果构建统一文件对象', () => {
|
||||
const file = new File(['demo'], 'demo.pdf', { type: 'application/pdf' });
|
||||
|
||||
const value = buildWorkflowFileValueFromUpload(file, 'https://example.com/demo.pdf');
|
||||
|
||||
expect(value).toEqual({
|
||||
fileName: 'demo.pdf',
|
||||
filePath: 'https://example.com/demo.pdf',
|
||||
contentType: 'application/pdf',
|
||||
size: 4,
|
||||
url: 'https://example.com/demo.pdf',
|
||||
});
|
||||
});
|
||||
|
||||
it('从素材对象构建统一文件对象', () => {
|
||||
const value = buildWorkflowFileValueFromResource({
|
||||
fileSize: '128',
|
||||
resourceName: 'manual',
|
||||
resourceUrl: 'https://example.com/manual.docx',
|
||||
suffix: 'docx',
|
||||
});
|
||||
|
||||
expect(value).toEqual({
|
||||
fileName: 'manual.docx',
|
||||
filePath: 'https://example.com/manual.docx',
|
||||
contentType: '',
|
||||
size: 128,
|
||||
url: 'https://example.com/manual.docx',
|
||||
});
|
||||
});
|
||||
|
||||
it('正确识别有效文件值并格式化大小', () => {
|
||||
expect(
|
||||
isWorkflowFileValue({
|
||||
fileName: 'demo.pdf',
|
||||
filePath: 'https://example.com/demo.pdf',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(isWorkflowFileValue({ fileName: 'demo.pdf' })).toBe(false);
|
||||
expect(formatWorkflowFileSize(2048)).toBe('2.0 KB');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* 工作流运行态单文件值。
|
||||
*/
|
||||
export interface WorkflowFileValue {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
contentType?: string;
|
||||
size?: number;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 素材对象最小字段约束。
|
||||
*/
|
||||
export interface WorkflowResourceLike {
|
||||
fileSize?: number | string;
|
||||
resourceName?: string;
|
||||
resourceUrl?: string;
|
||||
suffix?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于上传结果构建工作流文件值。
|
||||
*/
|
||||
export function buildWorkflowFileValueFromUpload(
|
||||
file: File,
|
||||
path: string,
|
||||
): WorkflowFileValue {
|
||||
const resolvedPath = String(path || '').trim();
|
||||
if (!resolvedPath) {
|
||||
throw new Error('上传结果缺少文件路径');
|
||||
}
|
||||
return {
|
||||
fileName: file.name,
|
||||
filePath: resolvedPath,
|
||||
contentType: file.type || '',
|
||||
size: file.size,
|
||||
url: resolvedPath,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于素材对象构建工作流文件值。
|
||||
*/
|
||||
export function buildWorkflowFileValueFromResource(
|
||||
resource: WorkflowResourceLike,
|
||||
): WorkflowFileValue {
|
||||
const resourceUrl = String(resource?.resourceUrl || '').trim();
|
||||
if (!resourceUrl) {
|
||||
throw new Error('素材缺少 resourceUrl');
|
||||
}
|
||||
const resourceName = String(resource?.resourceName || '').trim();
|
||||
const suffix = String(resource?.suffix || '').trim();
|
||||
const fallbackFileName = resourceUrl.split('/').pop()?.split('?')[0] || '';
|
||||
return {
|
||||
fileName:
|
||||
resourceName && suffix
|
||||
? `${resourceName}.${suffix}`
|
||||
: resourceName || fallbackFileName,
|
||||
filePath: resourceUrl,
|
||||
contentType: '',
|
||||
size: toNumber(resource?.fileSize),
|
||||
url: resourceUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为有效的工作流文件值。
|
||||
*/
|
||||
export function isWorkflowFileValue(value: unknown): value is WorkflowFileValue {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const candidate = value as Partial<WorkflowFileValue>;
|
||||
return Boolean(
|
||||
String(candidate.fileName || '').trim() &&
|
||||
String(candidate.filePath || '').trim(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 友好格式化文件大小。
|
||||
*/
|
||||
export function formatWorkflowFileSize(size?: number): string {
|
||||
if (!size || Number.isNaN(size) || size <= 0) {
|
||||
return '-';
|
||||
}
|
||||
if (size < 1024) {
|
||||
return `${size} B`;
|
||||
}
|
||||
if (size < 1024 * 1024) {
|
||||
return `${(size / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
if (size < 1024 * 1024 * 1024) {
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
return `${(size / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function toNumber(value?: number | string) {
|
||||
if (typeof value === 'number') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -13,12 +13,13 @@ export default {
|
||||
outputDefsAddEnable: false,
|
||||
parameters: [
|
||||
{
|
||||
name: 'fileUrl',
|
||||
name: 'file',
|
||||
nameDisabled: true,
|
||||
title: $t('aiWorkflow.documentAddress'),
|
||||
title: $t('aiWorkflow.documentInput'),
|
||||
dataType: 'File',
|
||||
contentType: 'file',
|
||||
required: true,
|
||||
description: $t('aiWorkflow.descriptions.documentAddress'),
|
||||
description: $t('aiWorkflow.descriptions.documentInput'),
|
||||
},
|
||||
],
|
||||
outputDefs: [
|
||||
|
||||
Reference in New Issue
Block a user