发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
18 changed files with 924 additions and 50 deletions
Showing only changes of commit 4a0efe8879 - Show all commits

View File

@@ -116,6 +116,7 @@ public class Model extends ModelBase {
} }
ollamaChatConfig.setModel(checkAndGetModelName()); ollamaChatConfig.setModel(checkAndGetModelName());
ollamaChatConfig.setProvider(getModelProvider().getProviderName()); ollamaChatConfig.setProvider(getModelProvider().getProviderName());
ollamaChatConfig.setSupportImage(getSupportImage());
ollamaChatConfig.setSupportImageBase64Only(getSupportImageB64Only()); ollamaChatConfig.setSupportImageBase64Only(getSupportImageB64Only());
return new OllamaChatModel(ollamaChatConfig); return new OllamaChatModel(ollamaChatConfig);
case "deepseek": case "deepseek":
@@ -128,6 +129,7 @@ public class Model extends ModelBase {
deepseekConfig.setSupportThinking(Boolean.TRUE); deepseekConfig.setSupportThinking(Boolean.TRUE);
deepseekConfig.setThinkingProtocol("deepseek"); deepseekConfig.setThinkingProtocol("deepseek");
deepseekConfig.setNeedReasoningContentForToolMessage(Boolean.TRUE); deepseekConfig.setNeedReasoningContentForToolMessage(Boolean.TRUE);
deepseekConfig.setSupportImage(getSupportImage());
deepseekConfig.setSupportImageBase64Only(getSupportImageB64Only()); deepseekConfig.setSupportImageBase64Only(getSupportImageB64Only());
if (getSupportTool() != null) { if (getSupportTool() != null) {
deepseekConfig.setSupportToolMessage(getSupportTool()); deepseekConfig.setSupportToolMessage(getSupportTool());
@@ -140,6 +142,7 @@ public class Model extends ModelBase {
openAIChatConfig.setApiKey(checkAndGetApiKey()); openAIChatConfig.setApiKey(checkAndGetApiKey());
openAIChatConfig.setModel(checkAndGetModelName()); openAIChatConfig.setModel(checkAndGetModelName());
openAIChatConfig.setRequestPath(checkAndGetRequestPath()); openAIChatConfig.setRequestPath(checkAndGetRequestPath());
openAIChatConfig.setSupportImage(getSupportImage());
openAIChatConfig.setSupportImageBase64Only(getSupportImageB64Only()); openAIChatConfig.setSupportImageBase64Only(getSupportImageB64Only());
if (getSupportTool() != null) { if (getSupportTool() != null) {
openAIChatConfig.setSupportToolMessage(getSupportTool()); openAIChatConfig.setSupportToolMessage(getSupportTool());

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { EasyFlowPanelModal } from '@easyflow/common-ui'; import { EasyFlowPanelModal } from '@easyflow/common-ui';
@@ -18,6 +18,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
resourceType: {
type: Number,
default: undefined,
},
}); });
const emit = defineEmits(['choose']); const emit = defineEmits(['choose']);
@@ -26,6 +30,12 @@ const pageDataRef = ref();
const dialogVisible = ref(false); const dialogVisible = ref(false);
const chooseResources = ref([]); const chooseResources = ref([]);
const currentChoose = ref<any>({}); const currentChoose = ref<any>({});
const pageUrl = computed(() => {
const baseUrl = '/api/v1/resource/page';
return props.resourceType === undefined
? baseUrl
: `${baseUrl}?resourceType=${props.resourceType}`;
});
function openDialog() { function openDialog() {
dialogVisible.value = true; dialogVisible.value = true;
} }
@@ -54,7 +64,7 @@ watch(
> >
<PageData <PageData
ref="pageDataRef" ref="pageDataRef"
page-url="/api/v1/resource/page" :page-url="pageUrl"
:page-size="8" :page-size="8"
:page-sizes="[8, 12, 16, 20]" :page-sizes="[8, 12, 16, 20]"
> >

View File

@@ -36,6 +36,9 @@
let isStartNodeInputParam = $derived.by(() => { let isStartNodeInputParam = $derived.by(() => {
return node?.current?.type === START_NODE_TYPE && param.refType === 'input'; return node?.current?.type === START_NODE_TYPE && param.refType === 'input';
}); });
let isImageStartParam = $derived.by(() => {
return isStartNodeInputParam && param.contentType === 'image';
});
let availableFormTypes = $derived.by(() => { let availableFormTypes = $derived.by(() => {
if (isSystemStartParam) { if (isSystemStartParam) {
return startFormTypes.filter((item) => item.value === 'input' || item.value === 'textarea'); return startFormTypes.filter((item) => item.value === 'input' || item.value === 'textarea');
@@ -117,7 +120,15 @@
} else if (key === 'required') { } else if (key === 'required') {
patch = { required: Boolean(value) }; patch = { required: Boolean(value) };
} else if (key === 'formType') { } else if (key === 'formType') {
patch = { type: toStartFormFieldType(value) }; const type = toStartFormFieldType(value);
patch = {
type,
...(type === 'file'
? { contentType: 'file' }
: param.contentType === 'file'
? { contentType: 'text' }
: {})
};
} else if (key === 'formLabel') { } else if (key === 'formLabel') {
patch = { label: value }; patch = { label: value };
} else if (key === 'formDescription') { } else if (key === 'formDescription') {
@@ -127,7 +138,16 @@
} else if (key === 'enums') { } else if (key === 'enums') {
patch = { options: Array.isArray(value) ? value : [] }; patch = { options: Array.isArray(value) ? value : [] };
} else if (key === 'contentType') { } else if (key === 'contentType') {
patch = { type: value === 'file' ? 'file' : 'text' }; patch = {
contentType: value,
...(value === 'file'
? { type: 'file' }
: value === 'image'
? { type: 'text' }
: param.contentType === 'file' || param.formType === 'file'
? { type: 'text' }
: {})
};
} }
if (Object.keys(patch).length === 0) { if (Object.keys(patch).length === 0) {
return; return;
@@ -249,10 +269,11 @@
数据内容: 数据内容:
<Select items={contentTypes} style="width: 100%" defaultValue={["text"]} <Select items={contentTypes} style="width: 100%" defaultValue={["text"]}
value={param.contentType ? [param.contentType] : []} value={param.contentType ? [param.contentType] : []}
disabled={param.systemReserved === true || isStartNodeInputParam} disabled={param.systemReserved === true}
onSelect={updateContentType} onSelect={updateContentType}
/> />
</div> </div>
{#if !isImageStartParam}
<div class="input-more-item"> <div class="input-more-item">
输入方式: 输入方式:
<Select items={availableFormTypes} style="width: 100%" defaultValue={["input"]} <Select items={availableFormTypes} style="width: 100%" defaultValue={["input"]}
@@ -260,6 +281,7 @@
onSelect={updateFormType} onSelect={updateFormType}
/> />
</div> </div>
{/if}
{#if param.formType === "radio" || param.formType === "checkbox" || param.formType === "select" } {#if param.formType === "radio" || param.formType === "checkbox" || param.formType === "select" }
<div class="input-more-item"> <div class="input-more-item">

View File

@@ -26,6 +26,7 @@
showContentType = false, showContentType = false,
fixedNumberMin, fixedNumberMin,
fixedNumberMax, fixedNumberMax,
acceptedContentTypes = [],
loopOutputAggregation = false loopOutputAggregation = false
}: { }: {
parameter: Parameter, parameter: Parameter,
@@ -35,6 +36,7 @@
showContentType?: boolean, showContentType?: boolean,
fixedNumberMin?: number, fixedNumberMin?: number,
fixedNumberMax?: number, fixedNumberMax?: number,
acceptedContentTypes?: string[],
loopOutputAggregation?: boolean, loopOutputAggregation?: boolean,
} = $props(); } = $props();
@@ -158,6 +160,7 @@
}; };
let selectItems = useRefOptions( let selectItems = useRefOptions(
() => useChildrenOnly === true, () => useChildrenOnly === true,
() => acceptedContentTypes,
() => param.ref || '' () => param.ref || ''
); );
let sourceDataType = $derived.by(() => { let sourceDataType = $derived.by(() => {

View File

@@ -10,6 +10,7 @@
showContentType = false, showContentType = false,
fixedNumberMin, fixedNumberMin,
fixedNumberMax, fixedNumberMax,
acceptedContentTypes = [],
loopOutputAggregation = false loopOutputAggregation = false
}: { }: {
noneParameterText?: string; noneParameterText?: string;
@@ -18,6 +19,7 @@
showContentType?: boolean, showContentType?: boolean,
fixedNumberMin?: number, fixedNumberMin?: number,
fixedNumberMax?: number, fixedNumberMax?: number,
acceptedContentTypes?: string[],
loopOutputAggregation?: boolean, loopOutputAggregation?: boolean,
} = $props(); } = $props();
@@ -45,6 +47,7 @@
{showContentType} {showContentType}
{fixedNumberMin} {fixedNumberMin}
{fixedNumberMax} {fixedNumberMax}
{acceptedContentTypes}
{loopOutputAggregation} {loopOutputAggregation}
/> />
{:else } {:else }

View File

@@ -1,6 +1,53 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { filterRefOptionsByDataType } from './refOptionFilter'; import {
filterRefOptionsByContentType,
filterRefOptionsByDataType,
} from './refOptionFilter';
describe('filterRefOptionsByContentType', () => {
const options = [
{
label: '开始节点',
selectable: false,
value: 'start',
children: [
{
label: '图片',
selectable: true,
value: 'start.image',
contentType: 'image',
},
{
label: '问题',
selectable: true,
value: 'start.user_input',
contentType: 'text',
},
],
},
];
it('图片输入仅展示图片类型引用', () => {
const filtered = filterRefOptionsByContentType(options, ['image']);
expect(filtered[0]?.children).toHaveLength(1);
expect(filtered[0]?.children[0]?.value).toBe('start.image');
});
it('保留当前已选择的旧版非图片引用', () => {
const filtered = filterRefOptionsByContentType(
options,
['image'],
'start.user_input',
);
expect(filtered[0]?.children.map((item: any) => item.value)).toEqual([
'start.image',
'start.user_input',
]);
});
});
describe('filterRefOptionsByDataType', () => { describe('filterRefOptionsByDataType', () => {
const options = [ const options = [

View File

@@ -1,3 +1,40 @@
/**
* 按内容类型过滤引用选项,并保留当前已选择的旧引用。
*/
export function filterRefOptionsByContentType(
options: any[],
acceptedContentTypes: string[],
currentRef = '',
): any[] {
if (!acceptedContentTypes.length) {
return options;
}
const accepted = new Set(acceptedContentTypes);
return options
.map((option) => {
const children = Array.isArray(option.children)
? filterRefOptionsByContentType(
option.children,
acceptedContentTypes,
currentRef,
)
: [];
const selectable =
option.selectable === true &&
(accepted.has(String(option.contentType || '')) ||
String(option.value || '') === currentRef);
if (!selectable && children.length === 0) {
return undefined;
}
return {
...option,
selectable,
children,
};
})
.filter(Boolean);
}
function isAcceptedDataType(dataType: string, acceptedDataTypes: string[]) { function isAcceptedDataType(dataType: string, acceptedDataTypes: string[]) {
const normalized = dataType.trim().toLowerCase(); const normalized = dataType.trim().toLowerCase();
return acceptedDataTypes.some((acceptedDataType) => { return acceptedDataTypes.some((acceptedDataType) => {

View File

@@ -9,6 +9,7 @@ import {
isArrayDataType, isArrayDataType,
projectParameterDataType, projectParameterDataType,
} from '../../utils/loopScope'; } from '../../utils/loopScope';
import { filterRefOptionsByContentType } from './refOptionFilter';
const fillRefNodeIds = ( const fillRefNodeIds = (
refNodeIds: string[], refNodeIds: string[],
@@ -53,6 +54,7 @@ const getChildren = (
return { return {
label: pathLabel, label: pathLabel,
dataType, dataType,
contentType: param.contentType,
value: parentId + '.' + param.name, value: parentId + '.' + param.name,
selectable: true, selectable: true,
nodeType: nodeType, nodeType: nodeType,
@@ -102,6 +104,7 @@ const nodeToOptions = (
children.push({ children.push({
label, label,
dataType: projectParameterDataType(parameter), dataType: projectParameterDataType(parameter),
contentType: parameter.contentType,
value: node.id + '.' + parameter.name, value: node.id + '.' + parameter.name,
selectable: true, selectable: true,
nodeType: nodeType, nodeType: nodeType,
@@ -153,6 +156,7 @@ const nodeToOptions = (
export const useRefOptions: any = ( export const useRefOptions: any = (
useChildrenOnly: boolean | (() => boolean) = false, useChildrenOnly: boolean | (() => boolean) = false,
acceptedContentTypes: string[] | (() => string[]) = [],
currentRef: string | (() => string) = '', currentRef: string | (() => string) = '',
) => { ) => {
const currentNodeId = getCurrentNodeId(); const currentNodeId = getCurrentNodeId();
@@ -162,6 +166,10 @@ export const useRefOptions: any = (
typeof useChildrenOnly === 'function' typeof useChildrenOnly === 'function'
? useChildrenOnly() ? useChildrenOnly()
: useChildrenOnly; : useChildrenOnly;
const getAcceptedContentTypes = () =>
typeof acceptedContentTypes === 'function'
? acceptedContentTypes()
: acceptedContentTypes;
const getCurrentRef = () => const getCurrentRef = () =>
typeof currentRef === 'function' ? currentRef() : currentRef; typeof currentRef === 'function' ? currentRef() : currentRef;
@@ -207,7 +215,11 @@ export const useRefOptions: any = (
} }
} }
const items = resultOptions; const items = filterRefOptionsByContentType(
resultOptions,
getAcceptedContentTypes(),
getCurrentRef(),
);
const stack = [...items]; const stack = [...items];
let selected; let selected;
const currentValue = getCurrentRef(); const currentValue = getCurrentRef();

View File

@@ -19,6 +19,7 @@ export type SelectItem = {
icon?: string; icon?: string;
nodeType?: string; nodeType?: string;
dataType?: string; dataType?: string;
contentType?: string;
displayLabel?: string; displayLabel?: string;
pathLabel?: string; pathLabel?: string;
itemTypeLabel?: string; itemTypeLabel?: string;

View File

@@ -84,6 +84,7 @@ describe('workflow node fields', () => {
key: 'file_field', key: 'file_field',
label: '文件字段', label: '文件字段',
type: 'file', type: 'file',
contentType: 'file',
placeholder: '请选择文件', placeholder: '请选择文件',
}); });
expect((updated.parameters as any[]).find((item) => item.name === 'file_field')) expect((updated.parameters as any[]).find((item) => item.name === 'file_field'))
@@ -110,6 +111,7 @@ describe('workflow node fields', () => {
expect(updated.startFormSchema?.find((item: any) => item.key === 'attachments')) expect(updated.startFormSchema?.find((item: any) => item.key === 'attachments'))
.toMatchObject({ .toMatchObject({
type: 'file', type: 'file',
contentType: 'file',
placeholder: '请选择文件', placeholder: '请选择文件',
}); });
expect((updated.parameters as any[]).find((item) => item.name === 'attachments')) expect((updated.parameters as any[]).find((item) => item.name === 'attachments'))
@@ -149,6 +151,99 @@ describe('workflow node fields', () => {
expect(nextParameter?.id).toBe(previousParameter?.id); expect(nextParameter?.id).toBe(previousParameter?.id);
}); });
it('preserves resource content type independently from input type', () => {
for (const contentType of ['image', 'video', 'audio', 'other'] as const) {
const key = `preview_${contentType}`;
const initial = createInitialWorkflowData();
const appended = appendStartFormField(
initial.nodes[0]?.data as Record<string, any>,
{
key,
label: `资源-${contentType}`,
type: 'text',
},
);
const updated = updateStartFormField(appended, key, {
contentType,
});
expect(
updated.startFormSchema?.find((item: any) => item.key === key),
).toMatchObject({
type: 'text',
contentType,
});
expect(
(updated.parameters as any[]).find((item) => item.name === key),
).toMatchObject({
dataType: contentType === 'image' ? 'Object' : 'String',
contentType,
formType: 'input',
});
}
});
it('reconciles a legacy text schema with its image parameter', () => {
const initial = createInitialWorkflowData();
const startData = initial.nodes[0]?.data as Record<string, any>;
const appended = appendStartFormField(startData, {
key: 'legacy_image',
label: '图片',
type: 'text',
});
const imageParameterData = {
...appended,
parameters: (appended.parameters as any[]).map((parameter) =>
parameter.name === 'legacy_image'
? {
...parameter,
contentType: 'image',
dataType: 'Object',
}
: parameter,
),
};
const normalized = normalizeStartNodeData(imageParameterData);
expect(
normalized.startFormSchema?.find(
(field: any) => field.key === 'legacy_image',
),
).toMatchObject({
contentType: 'image',
type: 'text',
});
expect(
(normalized.parameters as any[]).find(
(parameter) => parameter.name === 'legacy_image',
),
).toMatchObject({
contentType: 'image',
dataType: 'Object',
});
const changedToText = updateStartFormField(normalized, 'legacy_image', {
contentType: 'text',
});
expect(
changedToText.startFormSchema?.find(
(field: any) => field.key === 'legacy_image',
),
).toMatchObject({
contentType: 'text',
});
expect(
(changedToText.parameters as any[]).find(
(parameter) => parameter.name === 'legacy_image',
),
).toMatchObject({
contentType: 'text',
dataType: 'String',
});
});
it('keeps start fields unchanged when renaming to an existing key', () => { it('keeps start fields unchanged when renaming to an existing key', () => {
const initial = createInitialWorkflowData(); const initial = createInitialWorkflowData();
const withTopic = appendStartFormField( const withTopic = appendStartFormField(

View File

@@ -43,6 +43,14 @@ export type StartFormFieldType =
| 'select' | 'select'
| 'file'; | 'file';
export type StartFormContentType =
| 'text'
| 'image'
| 'video'
| 'audio'
| 'file'
| 'other';
export type StartFormMeta = { export type StartFormMeta = {
title: string; title: string;
description: string; description: string;
@@ -54,6 +62,7 @@ export type StartFormFieldSchema = {
key: string; key: string;
label: string; label: string;
type: StartFormFieldType; type: StartFormFieldType;
contentType: StartFormContentType;
required: boolean; required: boolean;
placeholder?: string; placeholder?: string;
description?: string; description?: string;
@@ -70,6 +79,14 @@ const START_FORM_FIELD_TYPE_SET = new Set<StartFormFieldType>([
'select', 'select',
'file', 'file',
]); ]);
const START_FORM_CONTENT_TYPE_SET = new Set<StartFormContentType>([
'text',
'image',
'video',
'audio',
'file',
'other',
]);
const OPTION_FIELD_TYPE_SET = new Set<StartFormFieldType>([ const OPTION_FIELD_TYPE_SET = new Set<StartFormFieldType>([
'radio', 'radio',
'checkbox', 'checkbox',
@@ -126,6 +143,14 @@ function isOptionFieldType(value: unknown): value is StartFormFieldType {
return OPTION_FIELD_TYPE_SET.has(asString(value).trim() as StartFormFieldType); return OPTION_FIELD_TYPE_SET.has(asString(value).trim() as StartFormFieldType);
} }
function normalizeStartFormContentType(
value: unknown,
fallback: StartFormContentType = 'text',
): StartFormContentType {
const normalized = trimString(value) as StartFormContentType;
return START_FORM_CONTENT_TYPE_SET.has(normalized) ? normalized : fallback;
}
function cloneParameter(parameter: Parameter): Parameter { function cloneParameter(parameter: Parameter): Parameter {
return { return {
...parameter, ...parameter,
@@ -511,20 +536,22 @@ function parameterTypeToStartFormFieldType(parameter?: Parameter | null): StartF
return 'text'; return 'text';
} }
function fieldTypeToDataType(type: StartFormFieldType) { function fieldTypeToDataType(
type: StartFormFieldType,
contentType: StartFormContentType,
) {
if (type === 'checkbox') { if (type === 'checkbox') {
return 'Array'; return 'Array';
} }
if (type === 'file') { if (type === 'file') {
return 'File'; return 'File';
} }
if (contentType === 'image') {
return 'Object';
}
return 'String'; return 'String';
} }
function fieldTypeToContentType(type: StartFormFieldType) {
return type === 'file' ? 'file' : 'text';
}
function fieldTypeToFormType(type: StartFormFieldType) { function fieldTypeToFormType(type: StartFormFieldType) {
if (type === 'text') { if (type === 'text') {
return 'input'; return 'input';
@@ -550,10 +577,30 @@ function normalizeStartFormField(
: fallbackType; : fallbackType;
const isSystemField = const isSystemField =
field?.systemReserved === true || key === SYSTEM_START_PARAM_NAME; field?.systemReserved === true || key === SYSTEM_START_PARAM_NAME;
const parameterContentType = normalizeStartFormContentType(
existingParameter?.contentType,
requestedType === 'file' ? 'file' : 'text',
);
const fieldContentType = normalizeStartFormContentType(
field?.contentType ?? existingParameter?.contentType,
requestedType === 'file' ? 'file' : 'text',
);
// 兼容旧工作流:图片参数已更新,但同名表单 Schema 仍保留为 text。
const requestedContentType =
parameterContentType === 'image' && fieldContentType === 'text'
? 'image'
: fieldContentType;
const contentType = isSystemField
? 'text'
: requestedType === 'file' || requestedContentType === 'file'
? 'file'
: requestedContentType;
const type = isSystemField const type = isSystemField
? requestedType === 'text' ? requestedType === 'text'
? 'text' ? 'text'
: 'textarea' : 'textarea'
: contentType === 'file'
? 'file'
: requestedType; : requestedType;
const options = isOptionFieldType(type) const options = isOptionFieldType(type)
? ensureStringArray(field?.options || existingParameter?.enums) ? ensureStringArray(field?.options || existingParameter?.enums)
@@ -572,6 +619,7 @@ function normalizeStartFormField(
trimString(existingParameter?.displayName) || trimString(existingParameter?.displayName) ||
key, key,
type, type,
contentType,
required: isSystemField ? true : Boolean(field?.required ?? existingParameter?.required), required: isSystemField ? true : Boolean(field?.required ?? existingParameter?.required),
placeholder: placeholder:
trimString(field?.placeholder) || trimString(existingParameter?.formPlaceholder), trimString(field?.placeholder) || trimString(existingParameter?.formPlaceholder),
@@ -590,6 +638,7 @@ function parameterToStartFormField(parameter?: Parameter | null) {
key: trimString(parameter?.name), key: trimString(parameter?.name),
label: trimString(parameter?.formLabel), label: trimString(parameter?.formLabel),
type: parameterTypeToStartFormFieldType(parameter), type: parameterTypeToStartFormFieldType(parameter),
contentType: normalizeStartFormContentType(parameter?.contentType),
required: Boolean(parameter?.required), required: Boolean(parameter?.required),
placeholder: trimString(parameter?.formPlaceholder), placeholder: trimString(parameter?.formPlaceholder),
description: trimString(parameter?.formDescription), description: trimString(parameter?.formDescription),
@@ -624,10 +673,10 @@ function startFormFieldToParameter(
...cloneParameter(existingParameter || {}), ...cloneParameter(existingParameter || {}),
id: trimString(existingParameter?.id) || trimString(field.id), id: trimString(existingParameter?.id) || trimString(field.id),
name: field.key, name: field.key,
dataType: fieldTypeToDataType(field.type), dataType: fieldTypeToDataType(field.type, field.contentType),
refType: 'input', refType: 'input',
required: Boolean(field.required), required: Boolean(field.required),
contentType: fieldTypeToContentType(field.type), contentType: field.contentType,
formType: fieldTypeToFormType(field.type), formType: fieldTypeToFormType(field.type),
formLabel, formLabel,
formDescription: asString(field.description), formDescription: asString(field.description),
@@ -705,6 +754,7 @@ export function normalizeStartFormSchema(
normalizeSystemStartFormType(systemParameter?.formType) === 'input' normalizeSystemStartFormType(systemParameter?.formType) === 'input'
? 'text' ? 'text'
: 'textarea', : 'textarea',
contentType: 'text',
required: true, required: true,
placeholder: trimString(systemParameter?.formPlaceholder), placeholder: trimString(systemParameter?.formPlaceholder),
description: trimString(systemParameter?.formDescription), description: trimString(systemParameter?.formDescription),
@@ -754,13 +804,19 @@ export function createCustomStartFormField(
field?: Partial<StartFormFieldSchema> | null, field?: Partial<StartFormFieldSchema> | null,
existingKeys: string[] = [], existingKeys: string[] = [],
): StartFormFieldSchema { ): StartFormFieldSchema {
const type = normalizeStartFormFieldUiType(field?.type, 'text'); const requestedType = normalizeStartFormFieldUiType(field?.type, 'text');
const contentType = normalizeStartFormContentType(
field?.contentType,
requestedType === 'file' ? 'file' : 'text',
);
const type = contentType === 'file' ? 'file' : requestedType;
const key = trimString(field?.key) || buildUniqueStartFormFieldKey(type, existingKeys); const key = trimString(field?.key) || buildUniqueStartFormFieldKey(type, existingKeys);
return { return {
id: ensureStartFormFieldId(field?.id), id: ensureStartFormFieldId(field?.id),
key, key,
label: trimString(field?.label) || getDefaultStartFormFieldLabel(type), label: trimString(field?.label) || getDefaultStartFormFieldLabel(type),
type, type,
contentType,
required: Boolean(field?.required), required: Boolean(field?.required),
placeholder: placeholder:
trimString(field?.placeholder) || trimString(field?.placeholder) ||
@@ -841,8 +897,24 @@ export function updateStartFormField(
type: nextType, type: nextType,
}; };
}); });
const nextParameters = currentParameters.map((parameter) => {
const parameterId = trimString(parameter.id);
const parameterName = trimString(parameter.name);
const matchedField = nextSchema.find(
(field) =>
(parameterId && trimString(field.id) === parameterId) ||
field.key === parameterName,
);
return matchedField
? {
...parameter,
contentType: matchedField.contentType,
}
: parameter;
});
return normalizeStartNodeData({ return normalizeStartNodeData({
...currentData, ...currentData,
parameters: nextParameters,
startFormSchema: nextSchema, startFormSchema: nextSchema,
}); });
} }

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { ElButton, ElDialog } from 'element-plus'; import { ElButton, ElDialog } from 'element-plus';
@@ -16,6 +16,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
resourceType: {
type: Number,
default: undefined,
},
}); });
const emit = defineEmits(['choose']); const emit = defineEmits(['choose']);
@@ -24,6 +28,12 @@ const pageDataRef = ref();
const dialogVisible = ref(false); const dialogVisible = ref(false);
const chooseResources = ref([]); const chooseResources = ref([]);
const currentChoose = ref<any>({}); const currentChoose = ref<any>({});
const pageUrl = computed(() => {
const baseUrl = '/userCenter/resource/page';
return props.resourceType === undefined
? baseUrl
: `${baseUrl}?resourceType=${props.resourceType}`;
});
function openDialog() { function openDialog() {
dialogVisible.value = true; dialogVisible.value = true;
} }
@@ -55,7 +65,7 @@ watch(
> >
<PageData <PageData
ref="pageDataRef" ref="pageDataRef"
page-url="/userCenter/resource/page" :page-url="pageUrl"
:page-size="8" :page-size="8"
:page-sizes="[8, 12, 16, 20]" :page-sizes="[8, 12, 16, 20]"
> >

View File

@@ -9,6 +9,7 @@ import { ElButton, ElForm, ElFormItem } from 'element-plus';
import { api } from '#/api/request'; import { api } from '#/api/request';
import { $t } from '#/locales'; import { $t } from '#/locales';
import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue'; import WorkflowFormItem from '#/views/ai/workflow/components/WorkflowFormItem.vue';
import { resolveWorkflowFormParameters } from '#/views/ai/workflow/components/workflowFormParameters';
export type WorkflowFormProps = { export type WorkflowFormProps = {
onAsyncExecute?: (values: any) => void; onAsyncExecute?: (values: any) => void;
@@ -48,30 +49,9 @@ const startFormMeta = computed(() => {
submitText: String(meta.submitText || '').trim() || $t('button.run'), submitText: String(meta.submitText || '').trim() || $t('button.run'),
}; };
}); });
const parameters = computed(() => { const parameters = computed(() =>
const schema = Array.isArray(props.workflowParams?.startFormSchema) resolveWorkflowFormParameters(props.workflowParams),
? props.workflowParams.startFormSchema );
: [];
if (schema.length === 0) {
return props.workflowParams.parameters || [];
}
return schema.map((field: any) => {
const type = String(field.type || '').trim() || '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: type === 'file' ? 'file' : 'text',
formType: type === 'text' ? 'input' : type === 'file' ? 'input' : type,
dataType:
type === 'checkbox' ? 'Array' : type === 'file' ? 'File' : 'String',
};
});
});
watch( watch(
parameters, parameters,
(items) => { (items) => {

View File

@@ -11,6 +11,9 @@ import {
import { $t } from '#/locales'; import { $t } from '#/locales';
import ChooseResource from '#/views/ai/resource/ChooseResource.vue'; import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
import WorkflowFileInput from '#/views/ai/workflow/components/WorkflowFileInput.vue'; import WorkflowFileInput from '#/views/ai/workflow/components/WorkflowFileInput.vue';
import WorkflowImageInput from '#/views/ai/workflow/components/WorkflowImageInput.vue';
import { hasWorkflowImageValue } from './workflowImageValue';
const props = defineProps({ const props = defineProps({
parameters: { parameters: {
@@ -37,7 +40,7 @@ function getContentType(item: any) {
return 'text'; return 'text';
} }
function isResource(contentType: any) { function isResource(contentType: any) {
return ['audio', 'image', 'video'].includes(contentType); return ['audio', 'video'].includes(contentType);
} }
function isFileContentType(contentType: any) { function isFileContentType(contentType: any) {
return contentType === 'file'; return contentType === 'file';
@@ -61,6 +64,14 @@ function buildRules(item: any) {
{ {
required: true, required: true,
validator: (_rule: any, value: any, callback: any) => { validator: (_rule: any, value: any, callback: any) => {
if (getContentType(item) === 'image') {
callback(
hasWorkflowImageValue(value)
? undefined
: new Error($t('message.required')),
);
return;
}
if (Array.isArray(value)) { if (Array.isArray(value)) {
callback(value.length > 0 ? undefined : new Error($t('message.required'))); callback(value.length > 0 ? undefined : new Error($t('message.required')));
return; return;
@@ -144,6 +155,12 @@ function choose(data: any, propName: string) {
@update:model-value="(val) => updateParam(item.name, val)" @update:model-value="(val) => updateParam(item.name, val)"
/> />
</template> </template>
<template v-if="getContentType(item) === 'image'">
<WorkflowImageInput
:model-value="runParams[item.name]"
@update:model-value="(val) => updateParam(item.name, val)"
/>
</template>
<template v-if="isResource(getContentType(item))"> <template v-if="isResource(getContentType(item))">
<ElInput <ElInput
:model-value="runParams[item.name]" :model-value="runParams[item.name]"

View File

@@ -0,0 +1,231 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { ElButton, ElImage, ElInput, ElMessage } from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
import ChooseResource from '#/views/ai/resource/ChooseResource.vue';
import {
buildWorkflowImageValueFromResource,
buildWorkflowImageValueFromUpload,
buildWorkflowImageValueFromUrl,
formatWorkflowImageSize,
getWorkflowImagePreviewUrl,
normalizeWorkflowImageValue,
validateWorkflowImageFile,
WORKFLOW_IMAGE_LIMITS,
} from './workflowImageValue';
const props = defineProps({
modelValue: {
type: [String, Object],
default: undefined,
},
});
const emit = defineEmits(['update:modelValue']);
const uploadLoading = ref(false);
const fileInputRef = ref<HTMLInputElement | null>(null);
const urlInput = ref('');
const currentImage = computed(() =>
normalizeWorkflowImageValue(props.modelValue),
);
const previewUrl = computed(() =>
getWorkflowImagePreviewUrl(props.modelValue),
);
watch(
() => props.modelValue,
(value) => {
const image = normalizeWorkflowImageValue(value);
urlInput.value = image?.sourceType === 'url' ? image.url : '';
},
{ immediate: true },
);
function applyUrl() {
try {
emit('update:modelValue', buildWorkflowImageValueFromUrl(urlInput.value));
} catch (error: any) {
ElMessage.error(error?.message || '图片 URL 无效');
}
}
function triggerSelectFile() {
if (!uploadLoading.value) {
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 {
validateWorkflowImageFile(file);
const response = await api.upload('/api/v1/commons/upload', { file }, {});
emit(
'update:modelValue',
buildWorkflowImageValueFromUpload(file, response?.data?.path),
);
} catch (error: any) {
ElMessage.error(error?.message || '图片上传失败');
console.error('工作流图片上传失败', error);
} finally {
uploadLoading.value = false;
input.value = '';
}
}
function handleChooseResource(resource: any) {
try {
emit(
'update:modelValue',
buildWorkflowImageValueFromResource(resource || {}),
);
} catch (error: any) {
ElMessage.error(error?.message || '图片素材选择失败');
}
}
function clearImage() {
urlInput.value = '';
emit('update:modelValue', undefined);
}
</script>
<template>
<div class="workflow-image-input">
<input
ref="fileInputRef"
class="workflow-image-input__native"
type="file"
:accept="WORKFLOW_IMAGE_LIMITS.accept"
@change="handleNativeFileChange"
/>
<div class="workflow-image-input__hint">
支持 PNGJPEGWebPGIFBMP单张不超过 10 MiB
</div>
<div v-if="currentImage" class="workflow-image-input__preview">
<ElImage
class="workflow-image-input__thumbnail"
:src="previewUrl"
fit="cover"
:preview-src-list="previewUrl ? [previewUrl] : []"
preview-teleported
/>
<div class="workflow-image-input__summary">
<div class="workflow-image-input__name">
{{
currentImage.sourceType === 'url'
? currentImage.url
: currentImage.fileName
}}
</div>
<div class="workflow-image-input__meta">
{{
currentImage.sourceType === 'url'
? '图片 URL'
: formatWorkflowImageSize(currentImage.size) || '图片文件'
}}
</div>
</div>
</div>
<ElInput
v-model="urlInput"
clearable
placeholder="输入 HTTP/HTTPS 图片 URL"
@keyup.enter="applyUrl"
>
<template #append>
<ElButton @click="applyUrl">使用 URL</ElButton>
</template>
</ElInput>
<div class="workflow-image-input__actions">
<ElButton
type="primary"
plain
:loading="uploadLoading"
@click="triggerSelectFile"
>
{{ currentImage ? '替换图片' : $t('button.upload') }}
</ElButton>
<ChooseResource
attr-name="image"
:resource-type="0"
@choose="handleChooseResource"
/>
<ElButton v-if="currentImage" text type="danger" @click="clearImage">
清空
</ElButton>
</div>
</div>
</template>
<style scoped>
.workflow-image-input {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.workflow-image-input__native {
display: none;
}
.workflow-image-input__hint,
.workflow-image-input__meta {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.5;
}
.workflow-image-input__preview {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
border: 1px solid var(--el-border-color-light);
border-radius: var(--el-border-radius-base);
background: var(--el-fill-color-blank);
}
.workflow-image-input__thumbnail {
width: 96px;
height: 72px;
flex: none;
border-radius: var(--el-border-radius-small);
background: var(--el-fill-color-light);
}
.workflow-image-input__summary {
min-width: 0;
flex: 1;
}
.workflow-image-input__name {
overflow: hidden;
color: var(--el-text-color-primary);
font-size: 13px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.workflow-image-input__actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
</style>

View File

@@ -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 图片');
});
});

View File

@@ -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',
};
});
}

View File

@@ -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<string, unknown>;
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;
}