From 4a0efe8879eef2db2a7e07901183461c208b267c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Fri, 31 Jul 2026 14:51:07 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=8C=E5=96=84=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E6=B5=81=E5=9B=BE=E7=89=87=E5=8F=82=E6=95=B0=E6=B5=81=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 图片参数使用 Object 类型并过滤上游图片引用 - 试运行支持上传、URL 和素材库图片输入 - 将模型图片能力映射到运行配置 --- .../java/tech/easyflow/ai/entity/Model.java | 3 + .../src/views/ai/resource/ChooseResource.vue | 14 +- .../core/DefinedParameterItem.svelte | 42 +++- .../components/core/RefParameterItem.svelte | 3 + .../components/core/RefParameterList.svelte | 3 + .../components/utils/refOptionFilter.test.ts | 49 +++- .../src/components/utils/refOptionFilter.ts | 37 +++ .../components/utils/useRefOptions.svelte.ts | 14 +- .../packages/tinyflow-ui/src/types.ts | 1 + .../src/utils/workflowNodeFields.test.ts | 95 +++++++ .../src/utils/workflowNodeFields.ts | 90 ++++++- .../src/views/ai/resource/ChooseResource.vue | 14 +- .../ai/workflow/components/WorkflowForm.vue | 28 +-- .../workflow/components/WorkflowFormItem.vue | 19 +- .../components/WorkflowImageInput.vue | 231 ++++++++++++++++++ .../__tests__/workflowImageValue.test.ts | 64 +++++ .../components/workflowFormParameters.ts | 54 ++++ .../workflow/components/workflowImageValue.ts | 213 ++++++++++++++++ 18 files changed, 924 insertions(+), 50 deletions(-) create mode 100644 easyflow-ui-usercenter/app/src/views/ai/workflow/components/WorkflowImageInput.vue create mode 100644 easyflow-ui-usercenter/app/src/views/ai/workflow/components/__tests__/workflowImageValue.test.ts create mode 100644 easyflow-ui-usercenter/app/src/views/ai/workflow/components/workflowFormParameters.ts create mode 100644 easyflow-ui-usercenter/app/src/views/ai/workflow/components/workflowImageValue.ts diff --git a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/Model.java b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/Model.java index 2f4ddef3..8ac2a9f1 100644 --- a/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/Model.java +++ b/easyflow-modules/easyflow-module-ai/src/main/java/tech/easyflow/ai/entity/Model.java @@ -116,6 +116,7 @@ public class Model extends ModelBase { } ollamaChatConfig.setModel(checkAndGetModelName()); ollamaChatConfig.setProvider(getModelProvider().getProviderName()); + ollamaChatConfig.setSupportImage(getSupportImage()); ollamaChatConfig.setSupportImageBase64Only(getSupportImageB64Only()); return new OllamaChatModel(ollamaChatConfig); case "deepseek": @@ -128,6 +129,7 @@ public class Model extends ModelBase { deepseekConfig.setSupportThinking(Boolean.TRUE); deepseekConfig.setThinkingProtocol("deepseek"); deepseekConfig.setNeedReasoningContentForToolMessage(Boolean.TRUE); + deepseekConfig.setSupportImage(getSupportImage()); deepseekConfig.setSupportImageBase64Only(getSupportImageB64Only()); if (getSupportTool() != null) { deepseekConfig.setSupportToolMessage(getSupportTool()); @@ -140,6 +142,7 @@ public class Model extends ModelBase { openAIChatConfig.setApiKey(checkAndGetApiKey()); openAIChatConfig.setModel(checkAndGetModelName()); openAIChatConfig.setRequestPath(checkAndGetRequestPath()); + openAIChatConfig.setSupportImage(getSupportImage()); openAIChatConfig.setSupportImageBase64Only(getSupportImageB64Only()); if (getSupportTool() != null) { openAIChatConfig.setSupportToolMessage(getSupportTool()); diff --git a/easyflow-ui-admin/app/src/views/ai/resource/ChooseResource.vue b/easyflow-ui-admin/app/src/views/ai/resource/ChooseResource.vue index f093e8f4..d61670f0 100644 --- a/easyflow-ui-admin/app/src/views/ai/resource/ChooseResource.vue +++ b/easyflow-ui-admin/app/src/views/ai/resource/ChooseResource.vue @@ -1,5 +1,5 @@ + + + + diff --git a/easyflow-ui-usercenter/app/src/views/ai/workflow/components/__tests__/workflowImageValue.test.ts b/easyflow-ui-usercenter/app/src/views/ai/workflow/components/__tests__/workflowImageValue.test.ts new file mode 100644 index 00000000..2174a7cc --- /dev/null +++ b/easyflow-ui-usercenter/app/src/views/ai/workflow/components/__tests__/workflowImageValue.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildWorkflowImageValueFromResource, + buildWorkflowImageValueFromUpload, + buildWorkflowImageValueFromUrl, + hasWorkflowImageValue, + normalizeWorkflowImageValue, + validateWorkflowImageFile, +} from '../workflowImageValue'; + +describe('workflowImageValue', () => { + it('支持 URL、上传和素材图片值', () => { + expect(buildWorkflowImageValueFromUrl('https://example.com/a.png')).toEqual({ + sourceType: 'url', + url: 'https://example.com/a.png', + }); + expect( + buildWorkflowImageValueFromUpload( + new File(['image'], 'a.png', { type: 'image/png' }), + '/files/a.png', + ), + ).toMatchObject({ sourceType: 'upload', fileName: 'a.png' }); + expect( + buildWorkflowImageValueFromResource({ + resourceName: 'asset', + resourceType: 0, + resourceUrl: '/files/asset.webp', + suffix: 'webp', + }), + ).toMatchObject({ sourceType: 'resource', fileName: 'asset.webp' }); + }); + + it('兼容旧值并校验必填', () => { + expect(normalizeWorkflowImageValue('https://example.com/a.png')).toEqual({ + sourceType: 'url', + url: 'https://example.com/a.png', + }); + expect( + hasWorkflowImageValue({ + fileName: 'legacy.jpg', + filePath: '/files/legacy.jpg', + }), + ).toBe(true); + expect(hasWorkflowImageValue(undefined)).toBe(false); + }); + + it('拒绝超限和非图片文件', () => { + expect(() => + validateWorkflowImageFile( + new File( + [new Uint8Array(10 * 1024 * 1024 + 1)], + 'oversized.png', + { type: 'image/png' }, + ), + ), + ).toThrow('单张图片不能超过 10 MiB'); + expect(() => + validateWorkflowImageFile( + new File(['text'], 'note.txt', { type: 'text/plain' }), + ), + ).toThrow('仅支持 PNG、JPEG、WebP、GIF、BMP 图片'); + }); +}); diff --git a/easyflow-ui-usercenter/app/src/views/ai/workflow/components/workflowFormParameters.ts b/easyflow-ui-usercenter/app/src/views/ai/workflow/components/workflowFormParameters.ts new file mode 100644 index 00000000..577f5466 --- /dev/null +++ b/easyflow-ui-usercenter/app/src/views/ai/workflow/components/workflowFormParameters.ts @@ -0,0 +1,54 @@ +const SUPPORTED_CONTENT_TYPES = ['text', 'image', 'video', 'audio', 'other']; + +export function resolveWorkflowFormParameters(workflowParams: any) { + const schema = Array.isArray(workflowParams?.startFormSchema) + ? workflowParams.startFormSchema + : []; + if (schema.length === 0) { + return workflowParams?.parameters || []; + } + const parameterMap = new Map( + (Array.isArray(workflowParams?.parameters) + ? workflowParams.parameters + : [] + ).map((parameter: any) => [ + String(parameter?.name || '').trim(), + parameter, + ]), + ); + return schema.map((field: any) => { + const type = String(field.type || '').trim() || 'text'; + const rawContentType = String(field.contentType || '').trim(); + const parameter = parameterMap.get(String(field.key || '').trim()) as + | any + | undefined; + const parameterContentType = String(parameter?.contentType || '').trim(); + const contentType = + type === 'file' + ? 'file' + : parameterContentType === 'image' + ? 'image' + : SUPPORTED_CONTENT_TYPES.includes(rawContentType) + ? rawContentType + : 'text'; + return { + name: field.key, + formLabel: field.label || field.key, + formDescription: field.description || '', + formPlaceholder: field.placeholder || '', + required: Boolean(field.required), + defaultValue: field.defaultValue, + enums: Array.isArray(field.options) ? field.options : [], + contentType, + formType: type === 'text' || contentType === 'file' ? 'input' : type, + dataType: + type === 'checkbox' + ? 'Array' + : contentType === 'file' + ? 'File' + : contentType === 'image' + ? 'Object' + : 'String', + }; + }); +} diff --git a/easyflow-ui-usercenter/app/src/views/ai/workflow/components/workflowImageValue.ts b/easyflow-ui-usercenter/app/src/views/ai/workflow/components/workflowImageValue.ts new file mode 100644 index 00000000..fa0da6eb --- /dev/null +++ b/easyflow-ui-usercenter/app/src/views/ai/workflow/components/workflowImageValue.ts @@ -0,0 +1,213 @@ +export type WorkflowImageSourceType = 'resource' | 'upload' | 'url'; + +export type WorkflowImageValue = + | { + sourceType: 'url'; + url: string; + } + | { + sourceType: 'resource' | 'upload'; + fileName: string; + filePath: string; + contentType?: string; + size?: number; + url?: string; + }; + +export interface WorkflowImageResourceLike { + fileSize?: number | string; + resourceName?: string; + resourceType?: number | string; + resourceUrl?: string; + suffix?: string; +} + +export const WORKFLOW_IMAGE_LIMITS = { + maxSize: 10 * 1024 * 1024, + accept: '.png,.jpg,.jpeg,.webp,.gif,.bmp', +} as const; + +const ALLOWED_MIME_TYPES = new Set([ + 'image/bmp', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/webp', +]); +const ALLOWED_EXTENSIONS = new Set(['bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp']); + +export function buildWorkflowImageValueFromUpload( + file: File, + path: string, +): WorkflowImageValue { + validateWorkflowImageFile(file); + const filePath = String(path || '').trim(); + if (!filePath) { + throw new Error('上传结果缺少图片路径'); + } + return { + sourceType: 'upload', + fileName: file.name, + filePath, + contentType: file.type || undefined, + size: file.size, + url: filePath, + }; +} + +export function buildWorkflowImageValueFromResource( + resource: WorkflowImageResourceLike, +): WorkflowImageValue { + const filePath = String(resource?.resourceUrl || '').trim(); + if (!filePath) { + throw new Error('图片素材缺少 resourceUrl'); + } + if ( + resource.resourceType !== undefined && + Number(resource.resourceType) !== 0 + ) { + throw new Error('请选择图片素材'); + } + const suffix = String(resource?.suffix || '').trim().toLowerCase(); + if (suffix && !ALLOWED_EXTENSIONS.has(suffix)) { + throw new Error('仅支持 PNG、JPEG、WebP、GIF、BMP 图片'); + } + const size = toNumber(resource?.fileSize); + validateWorkflowImageSize(size); + const resourceName = String(resource?.resourceName || '').trim(); + const fallbackName = filePath.split('/').pop()?.split('?')[0] || 'image'; + return { + sourceType: 'resource', + fileName: + resourceName && suffix + ? `${resourceName}.${suffix}` + : resourceName || fallbackName, + filePath, + contentType: suffixToMimeType(suffix), + size, + url: filePath, + }; +} + +export function buildWorkflowImageValueFromUrl(url: string): WorkflowImageValue { + const normalized = String(url || '').trim(); + let parsed: URL; + try { + parsed = new URL(normalized); + } catch { + throw new Error('请输入有效的图片 URL'); + } + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error('图片 URL 仅支持 HTTP/HTTPS'); + } + return { + sourceType: 'url', + url: normalized, + }; +} + +export function normalizeWorkflowImageValue( + value: unknown, +): WorkflowImageValue | undefined { + if (typeof value === 'string' && value.trim()) { + try { + return buildWorkflowImageValueFromUrl(value); + } catch { + return undefined; + } + } + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + const candidate = value as Record; + const filePath = String(candidate.filePath || '').trim(); + const sourceType = String( + candidate.sourceType || (filePath ? 'upload' : 'url'), + ) as WorkflowImageSourceType; + if (sourceType === 'url') { + try { + return buildWorkflowImageValueFromUrl(String(candidate.url || '')); + } catch { + return undefined; + } + } + if (!['resource', 'upload'].includes(sourceType)) { + return undefined; + } + const fileName = String(candidate.fileName || '').trim(); + if (!fileName || !filePath) { + return undefined; + } + const size = toNumber(candidate.size as number | string | undefined); + try { + validateWorkflowImageSize(size); + } catch { + return undefined; + } + return { + sourceType, + fileName, + filePath, + contentType: String(candidate.contentType || '').trim() || undefined, + size, + url: String(candidate.url || '').trim() || undefined, + }; +} + +export function hasWorkflowImageValue(value: unknown): boolean { + return normalizeWorkflowImageValue(value) !== undefined; +} + +export function getWorkflowImagePreviewUrl(value: unknown): string { + const image = normalizeWorkflowImageValue(value); + return image?.sourceType === 'url' + ? image.url + : image?.url || image?.filePath || ''; +} + +export function validateWorkflowImageFile(file: File) { + const extension = file.name.split('.').pop()?.toLowerCase() || ''; + if ( + !ALLOWED_MIME_TYPES.has(file.type.toLowerCase()) && + !ALLOWED_EXTENSIONS.has(extension) + ) { + throw new Error('仅支持 PNG、JPEG、WebP、GIF、BMP 图片'); + } + validateWorkflowImageSize(file.size); +} + +export function formatWorkflowImageSize(size?: number): string { + if (!size || size <= 0 || Number.isNaN(size)) { + return ''; + } + if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(1)} KB`; + } + return `${(size / (1024 * 1024)).toFixed(1)} MB`; +} + +function validateWorkflowImageSize(size?: number) { + if (size !== undefined && size > WORKFLOW_IMAGE_LIMITS.maxSize) { + throw new Error('单张图片不能超过 10 MiB'); + } +} + +function suffixToMimeType(suffix: string): string | undefined { + if (!suffix) { + return undefined; + } + return suffix === 'jpg' || suffix === 'jpeg' + ? 'image/jpeg' + : `image/${suffix}`; +} + +function toNumber(value?: number | string): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +}