chore: support set more image constrints (#8896)

This commit is contained in:
YuTengjing 2025-08-22 22:04:51 +08:00 committed by GitHub
parent c7e94e7446
commit ccc733dac5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 601 additions and 283 deletions

View file

@ -12,6 +12,7 @@ import { useFileStore } from '@/store/file';
import { FileUploadStatus } from '@/types/files/upload';
import { useDragAndDrop } from '../hooks/useDragAndDrop';
import { useUploadFilesValidation } from '../hooks/useUploadFilesValidation';
import { useConfigPanelStyles } from '../style';
// ======== Business Types ======== //
@ -19,6 +20,7 @@ import { useConfigPanelStyles } from '../style';
export interface ImageUploadProps {
// Callback when URL changes
className?: string; // Image URL
maxFileSize?: number;
onChange?: (url?: string) => void;
style?: React.CSSProperties;
value?: string | null;
@ -414,212 +416,221 @@ SuccessDisplay.displayName = 'SuccessDisplay';
// ======== Main Component ======== //
const ImageUpload: FC<ImageUploadProps> = memo(({ value, onChange, style, className }) => {
const inputRef = useRef<HTMLInputElement>(null);
const uploadWithProgress = useFileStore((s) => s.uploadWithProgress);
const [uploadState, setUploadState] = useState<UploadState | null>(null);
const { t } = useTranslation('components');
const { message } = App.useApp();
const ImageUpload: FC<ImageUploadProps> = memo(
({ value, onChange, style, className, maxFileSize }) => {
const inputRef = useRef<HTMLInputElement>(null);
const uploadWithProgress = useFileStore((s) => s.uploadWithProgress);
const [uploadState, setUploadState] = useState<UploadState | null>(null);
const { t } = useTranslation('components');
const { message } = App.useApp();
const { validateFiles } = useUploadFilesValidation(undefined, maxFileSize);
// Cleanup blob URLs to prevent memory leaks
useEffect(() => {
return () => {
if (uploadState?.previewUrl && isLocalBlobUrl(uploadState.previewUrl)) {
URL.revokeObjectURL(uploadState.previewUrl);
// Cleanup blob URLs to prevent memory leaks
useEffect(() => {
return () => {
if (uploadState?.previewUrl && isLocalBlobUrl(uploadState.previewUrl)) {
URL.revokeObjectURL(uploadState.previewUrl);
}
};
}, [uploadState?.previewUrl]);
const handleFileSelect = () => {
inputRef.current?.click();
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file using unified validation hook
if (!validateFiles([file])) return;
// Create preview URL
const previewUrl = URL.createObjectURL(file);
// Set initial upload state
setUploadState({
previewUrl,
progress: 0,
status: 'pending',
});
try {
// Start upload
const result = await uploadWithProgress({
file,
onStatusUpdate: (updateData) => {
if (updateData.type === 'updateFile') {
setUploadState((prev) => {
if (!prev) return null;
const fileStatus = updateData.value.status;
if (!fileStatus) return prev;
return {
...prev,
error: fileStatus === 'error' ? 'Upload failed' : undefined,
progress: updateData.value.uploadState?.progress || 0,
status: fileStatus,
};
});
} else if (updateData.type === 'removeFile') {
// Handle file removal
setUploadState(null);
}
},
skipCheckFileType: true,
});
if (result?.url) {
// Upload successful
onChange?.(result.url);
}
} catch {
// Upload failed
setUploadState((prev) =>
prev
? {
...prev,
error: 'Upload failed',
status: 'error',
}
: null,
);
} finally {
// Cleanup
if (isLocalBlobUrl(previewUrl)) {
URL.revokeObjectURL(previewUrl);
}
// Clear upload state after a delay to show completion
setTimeout(() => {
setUploadState(null);
}, 1000);
}
};
}, [uploadState?.previewUrl]);
const handleFileSelect = () => {
inputRef.current?.click();
};
const handleDelete = () => {
onChange?.(undefined);
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const handleDrop = async (files: File[]) => {
// Show warning if multiple files detected
if (files.length > 1) {
message.warning(t('ImageUpload.actions.dropMultipleFiles'));
}
// Create preview URL
const previewUrl = URL.createObjectURL(file);
// Take the first image file
const file = files[0];
// Set initial upload state
setUploadState({
previewUrl,
progress: 0,
status: 'pending',
});
// Validate file using unified validation hook
if (!validateFiles([file])) return;
try {
// Start upload
const result = await uploadWithProgress({
file,
onStatusUpdate: (updateData) => {
if (updateData.type === 'updateFile') {
setUploadState((prev) => {
if (!prev) return null;
// Create preview URL
const previewUrl = URL.createObjectURL(file);
const fileStatus = updateData.value.status;
if (!fileStatus) return prev;
return {
...prev,
error: fileStatus === 'error' ? 'Upload failed' : undefined,
progress: updateData.value.uploadState?.progress || 0,
status: fileStatus,
};
});
} else if (updateData.type === 'removeFile') {
// Handle file removal
setUploadState(null);
}
},
skipCheckFileType: true,
// Set initial upload state
setUploadState({
previewUrl,
progress: 0,
status: 'pending',
});
if (result?.url) {
// Upload successful
onChange?.(result.url);
}
} catch {
// Upload failed
setUploadState((prev) =>
prev
? {
...prev,
error: 'Upload failed',
status: 'error',
try {
// Start upload using the same logic as handleFileChange
const result = await uploadWithProgress({
file,
onStatusUpdate: (updateData) => {
if (updateData.type === 'updateFile') {
setUploadState((prev) => {
if (!prev) return null;
const fileStatus = updateData.value.status;
if (!fileStatus) return prev;
return {
...prev,
error: fileStatus === 'error' ? 'Upload failed' : undefined,
progress: updateData.value.uploadState?.progress || 0,
status: fileStatus,
};
});
} else if (updateData.type === 'removeFile') {
setUploadState(null);
}
: null,
);
} finally {
// Cleanup
if (isLocalBlobUrl(previewUrl)) {
URL.revokeObjectURL(previewUrl);
},
skipCheckFileType: true,
});
if (result?.url) {
// Upload successful
onChange?.(result.url);
}
} catch {
// Upload failed
setUploadState((prev) =>
prev
? {
...prev,
error: 'Upload failed',
status: 'error',
}
: null,
);
} finally {
// Cleanup
if (isLocalBlobUrl(previewUrl)) {
URL.revokeObjectURL(previewUrl);
}
// Clear upload state after a delay to show completion
setTimeout(() => {
setUploadState(null);
}, 1000);
}
};
// Clear upload state after a delay to show completion
setTimeout(() => {
setUploadState(null);
}, 1000);
}
};
const handleDelete = () => {
onChange?.(undefined);
};
const handleDrop = async (files: File[]) => {
// Show warning if multiple files detected
if (files.length > 1) {
message.warning(t('ImageUpload.actions.dropMultipleFiles'));
}
// Take the first image file
const file = files[0];
// Create preview URL
const previewUrl = URL.createObjectURL(file);
// Set initial upload state
setUploadState({
previewUrl,
progress: 0,
status: 'pending',
const { isDragOver, dragHandlers } = useDragAndDrop({
accept: 'image/*',
onDrop: handleDrop,
});
try {
// Start upload using the same logic as handleFileChange
const result = await uploadWithProgress({
file,
onStatusUpdate: (updateData) => {
if (updateData.type === 'updateFile') {
setUploadState((prev) => {
if (!prev) return null;
// Determine which view to render
const hasImage = Boolean(value);
const isUploading = Boolean(uploadState);
const fileStatus = updateData.value.status;
if (!fileStatus) return prev;
return {
...prev,
error: fileStatus === 'error' ? 'Upload failed' : undefined,
progress: updateData.value.uploadState?.progress || 0,
status: fileStatus,
};
});
} else if (updateData.type === 'removeFile') {
setUploadState(null);
}
},
skipCheckFileType: true,
});
if (result?.url) {
// Upload successful
onChange?.(result.url);
}
} catch {
// Upload failed
setUploadState((prev) =>
prev
? {
...prev,
error: 'Upload failed',
status: 'error',
}
: null,
);
} finally {
// Cleanup
if (isLocalBlobUrl(previewUrl)) {
URL.revokeObjectURL(previewUrl);
}
// Clear upload state after a delay to show completion
setTimeout(() => {
setUploadState(null);
}, 1000);
}
};
const { isDragOver, dragHandlers } = useDragAndDrop({
accept: 'image/*',
onDrop: handleDrop,
});
// Determine which view to render
const hasImage = Boolean(value);
const isUploading = Boolean(uploadState);
return (
<div className={className} {...dragHandlers} style={style}>
{/* Hidden file input */}
<input
accept="image/*"
onChange={handleFileChange}
onClick={(e) => {
// Reset value to allow re-selecting the same file
e.currentTarget.value = '';
}}
ref={inputRef}
style={{ display: 'none' }}
type="file"
/>
{/* Conditional rendering based on state */}
{isUploading && uploadState ? (
<UploadingDisplay previewUrl={uploadState.previewUrl} progress={uploadState.progress} />
) : hasImage ? (
<SuccessDisplay
imageUrl={value!}
isDragOver={isDragOver}
onChangeImage={handleFileSelect}
onDelete={handleDelete}
return (
<div className={className} {...dragHandlers} style={style}>
{/* Hidden file input */}
<input
accept="image/*"
onChange={handleFileChange}
onClick={(e) => {
// Reset value to allow re-selecting the same file
e.currentTarget.value = '';
}}
ref={inputRef}
style={{ display: 'none' }}
type="file"
/>
) : (
<Placeholder isDragOver={isDragOver} onClick={handleFileSelect} />
)}
</div>
);
});
{/* Conditional rendering based on state */}
{isUploading && uploadState ? (
<UploadingDisplay previewUrl={uploadState.previewUrl} progress={uploadState.progress} />
) : hasImage ? (
<SuccessDisplay
imageUrl={value!}
isDragOver={isDragOver}
onChangeImage={handleFileSelect}
onDelete={handleDelete}
/>
) : (
<Placeholder isDragOver={isDragOver} onClick={handleFileSelect} />
)}
</div>
);
},
);
ImageUpload.displayName = 'ImageUpload';

View file

@ -5,14 +5,14 @@ import { useGenerationConfigParam } from '@/store/image/slices/generationConfig/
import ImageUpload from './ImageUpload';
const ImageUrl = memo(() => {
const { value: imageUrl, setValue } = useGenerationConfigParam('imageUrl');
const { value: imageUrl, setValue, maxFileSize } = useGenerationConfigParam('imageUrl');
// Extract the first URL from the array for single image display
const handleChange = (url?: string) => {
setValue(url ?? null);
};
return <ImageUpload onChange={handleChange} value={imageUrl} />;
return <ImageUpload maxFileSize={maxFileSize} onChange={handleChange} value={imageUrl} />;
});
export default ImageUrl;

View file

@ -2,18 +2,42 @@ import { memo } from 'react';
import { useGenerationConfigParam } from '@/store/image/slices/generationConfig/hooks';
import ImageUpload from './ImageUpload';
import MultiImagesUpload from './MultiImagesUpload';
const ImageUrlsUpload = memo(() => {
const { value, setValue } = useGenerationConfigParam('imageUrls');
const { value, setValue, maxCount, maxFileSize } = useGenerationConfigParam('imageUrls');
// When maxCount is 1, use ImageUpload for single image upload
if (maxCount === 1) {
const handleSingleChange = (url?: string) => {
setValue(url ? [url] : []);
};
return (
<ImageUpload
maxFileSize={maxFileSize}
onChange={handleSingleChange}
value={value?.[0] ?? null}
/>
);
}
// Otherwise use MultiImagesUpload for multiple images
const handleChange = (urls: string[]) => {
// Directly set the URLs to the store
// The store will handle URL to path conversion when needed
setValue(urls);
};
return <MultiImagesUpload onChange={handleChange} value={value} />;
return (
<MultiImagesUpload
maxCount={maxCount}
maxFileSize={maxFileSize}
onChange={handleChange}
value={value}
/>
);
});
export default ImageUrlsUpload;

View file

@ -7,30 +7,33 @@ import Image from 'next/image';
import React, { type FC, memo, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUploadFilesValidation } from '../../hooks/useUploadFilesValidation';
// ======== Types ======== //
/**
*
* - url: 现有图片的远程URL
* - file: 新选择的文件
* - previewUrl: 本地文件的预览URLblob URL
* url的是现有图片file的是待上传文件
* Unified image item data structure
* - url: Remote URL of existing image
* - file: Newly selected file that needs to be uploaded
* - previewUrl: Preview URL for local file (blob URL)
* Items with url are existing images, items with file are files to be uploaded
*/
export interface ImageItem {
// 现有图片的URL
// URL of existing image
file?: File;
id: string;
// 新选择的文件
// Newly selected file
previewUrl?: string;
url?: string; // 本地文件的预览URL仅在file存在时使用
url?: string; // Preview URL for local file, only used when file exists
}
interface ImageManageModalProps {
images: string[];
// 现有图片URL数组
maxCount?: number;
// Array of existing image URLs
onClose: () => void;
onComplete: (imageItems: ImageItem[]) => void;
open: boolean; // 统一的完成回调
open: boolean; // Unified completion callback
}
// ======== Utils ======== //
@ -194,19 +197,20 @@ const useStyles = createStyles(({ css, token }) => ({
// ======== Main Component ======== //
const ImageManageModal: FC<ImageManageModalProps> = memo(
({ open, images, onClose, onComplete }) => {
({ open, images, maxCount, onClose, onComplete }) => {
const { styles } = useStyles();
const { t } = useTranslation('components');
const inputRef = useRef<HTMLInputElement>(null);
const { validateFiles } = useUploadFilesValidation(maxCount);
// 使用统一的数据结构管理所有图片
// Use unified data structure to manage all images
const [imageItems, setImageItems] = useState<ImageItem[]>([]);
const [selectedIndex, setSelectedIndex] = useState<number>(0);
// Modal 打开时初始化状态
// Initialize state when modal opens
useEffect(() => {
if (open) {
// 将现有URL转换为ImageItem格式
// Convert existing URLs to ImageItem format
const initialItems: ImageItem[] = images.map((url) => ({
id: generateId(),
url,
@ -253,7 +257,7 @@ const ImageManageModal: FC<ImageManageModalProps> = memo(
const newItems = imageItems.filter((_, i) => i !== index);
setImageItems(newItems);
// 调整选中索引
// Adjust selected index
if (selectedIndex >= newItems.length) {
setSelectedIndex(Math.max(0, newItems.length - 1));
}
@ -272,18 +276,23 @@ const ImageManageModal: FC<ImageManageModalProps> = memo(
const files = event.target.files;
if (!files || files.length === 0) return;
// 创建新的ImageItem为每个文件生成一次性的预览URL
// Validate files, pass current image count
if (!validateFiles(Array.from(files), imageItems.length)) {
return;
}
// Create new ImageItem, generate one-time preview URL for each file
const newItems: ImageItem[] = Array.from(files).map((file) => ({
file,
id: generateId(),
previewUrl: URL.createObjectURL(file), // 只创建一次
previewUrl: URL.createObjectURL(file), // Create only once
}));
setImageItems((prev) => [...prev, ...newItems]);
};
const handleComplete = () => {
// 直接传递当前完整状态给父组件处理
// Directly pass current complete state to parent component
onComplete(imageItems);
onClose();
};
@ -397,7 +406,12 @@ const ImageManageModal: FC<ImageManageModalProps> = memo(
{/* Footer */}
<div className={styles.footer}>
<Button icon={<Upload size={16} />} onClick={handleUpload} type="default">
<Button
disabled={maxCount ? imageItems.length >= maxCount : false}
icon={<Upload size={16} />}
onClick={handleUpload}
type="default"
>
{t('MultiImagesUpload.modal.upload')}
</Button>

View file

@ -13,6 +13,7 @@ import { FileUploadStatus } from '@/types/files/upload';
import { CONFIG_PANEL_WIDTH } from '../../constants';
import { useDragAndDrop } from '../../hooks/useDragAndDrop';
import { useUploadFilesValidation } from '../../hooks/useUploadFilesValidation';
import { useConfigPanelStyles } from '../../style';
import ImageManageModal, { type ImageItem } from './ImageManageModal';
@ -38,6 +39,8 @@ interface DisplayItem {
export interface MultiImagesUploadProps {
// Callback when URLs change
className?: string; // Array of image URLs
maxCount?: number;
maxFileSize?: number;
onChange?: (urls: string[]) => void;
style?: React.CSSProperties;
value?: string[];
@ -311,7 +314,7 @@ const ImageUploadPlaceholder: FC<ImageUploadPlaceholderProps> = memo(({ isDragOv
ImageUploadPlaceholder.displayName = 'ImageUploadPlaceholder';
// ======== 圆形进度组件 ======== //
// ======== Circular Progress Component ======== //
interface CircularProgressProps {
className?: string;
@ -466,7 +469,7 @@ const ImageThumbnails: FC<ImageThumbnailsProps> = memo(
const showOverlay = isLastItem && remainingCount > 1;
return (
<div className={styles.imageItem} key={imageUrl}>
<div className={styles.imageItem} key={`${imageUrl}-${index}`}>
<Image
alt={`Uploaded image ${index + 1}`}
fill
@ -559,12 +562,13 @@ SingleImageDisplay.displayName = 'SingleImageDisplay';
// ======== Main Component ======== //
const MultiImagesUpload: FC<MultiImagesUploadProps> = memo(
({ value, onChange, style, className }) => {
({ value, onChange, style, className, maxCount, maxFileSize }) => {
const inputRef = useRef<HTMLInputElement>(null);
const uploadWithProgress = useFileStore((s) => s.uploadWithProgress);
const [displayItems, setDisplayItems] = useState<DisplayItem[]>([]);
const [modalOpen, setModalOpen] = useState(false);
const { styles: configStyles } = useConfigPanelStyles();
const { validateFiles } = useUploadFilesValidation(maxCount, maxFileSize);
// Cleanup blob URLs to prevent memory leaks
useEffect(() => {
@ -601,6 +605,11 @@ const MultiImagesUpload: FC<MultiImagesUploadProps> = memo(
const currentUrls = baseUrls !== undefined ? baseUrls : value || [];
// Validate files, pass current image count
if (!validateFiles(files, currentUrls.length)) {
return;
}
// Create initial display items with blob URLs for immediate preview
const newDisplayItems: DisplayItem[] = files.map((file) => ({
file,
@ -716,17 +725,17 @@ const MultiImagesUpload: FC<MultiImagesUploadProps> = memo(
onDrop: handleDrop,
});
// 处理 Modal 完成回调
// Handle Modal completion callback
const handleModalComplete = async (imageItems: ImageItem[]) => {
// 分离现有URL和新文件
// Separate existing URLs and new files
const existingUrls = imageItems.filter((item) => item.url).map((item) => item.url!);
const newFiles = imageItems.filter((item) => item.file).map((item) => item.file!);
// 立即更新现有URL删除的图片会被过滤掉
// Immediately update existing URLs (deleted images will be filtered out)
onChange?.(existingUrls);
// 如果有新文件需要上传,基于 existingUrls 启动上传流程
// If there are new files to upload, start upload process based on existingUrls
if (newFiles.length > 0) {
await handleFilesSelected(newFiles, existingUrls);
}
@ -793,6 +802,7 @@ const MultiImagesUpload: FC<MultiImagesUploadProps> = memo(
{/* Image Management Modal */}
<ImageManageModal
images={value || []}
maxCount={maxCount}
onClose={handleCloseModal}
onComplete={handleModalComplete}
open={modalOpen}

View file

@ -0,0 +1,77 @@
import { App } from 'antd';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { formatFileSize, validateImageFiles } from '../utils/imageValidation';
/**
* File upload validation hook
* Encapsulates file size and count validation logic, provides user-friendly error messages
*/
export const useUploadFilesValidation = (maxCount?: number, maxFileSize?: number) => {
const { t } = useTranslation('components');
const { message } = App.useApp();
const validateFiles = useCallback(
(files: File[], currentCount: number = 0): boolean => {
const validationResult = validateImageFiles(files, {
maxAddedFiles: maxCount ? maxCount - currentCount : undefined,
maxFileSize,
});
if (!validationResult.valid) {
// Display user-friendly error messages
validationResult.errors.forEach((error) => {
if (error === 'fileSizeExceeded') {
// Collect all failed files info
const fileSizeFailures =
validationResult.failedFiles?.filter(
(failedFile) =>
failedFile.error === 'fileSizeExceeded' &&
failedFile.actualSize &&
failedFile.maxSize,
) || [];
if (fileSizeFailures.length === 1) {
// Single file error - show detailed message
const failedFile = fileSizeFailures[0];
const actualSizeStr = formatFileSize(failedFile.actualSize!);
const maxSizeStr = formatFileSize(failedFile.maxSize!);
const fileName = failedFile.fileName || 'File';
message.error(
t('MultiImagesUpload.validation.fileSizeExceededDetail', {
actualSize: actualSizeStr,
fileName,
maxSize: maxSizeStr,
}),
);
} else if (fileSizeFailures.length > 1) {
// Multiple files error - show summary message
const maxSizeStr = formatFileSize(fileSizeFailures[0].maxSize!);
const fileList = fileSizeFailures
.map((f) => `${f.fileName || 'File'} (${formatFileSize(f.actualSize!)})`)
.join(', ');
message.error(
t('MultiImagesUpload.validation.fileSizeExceededMultiple', {
count: fileSizeFailures.length,
fileList,
maxSize: maxSizeStr,
}),
);
}
} else if (error === 'imageCountExceeded') {
message.error(t('MultiImagesUpload.validation.imageCountExceeded'));
}
});
return false;
}
return true;
},
[maxCount, maxFileSize, message, t],
);
return {
validateFiles,
};
};

View file

@ -0,0 +1,117 @@
/**
* Image file validation utility functions
*/
/**
* Format file size to human readable format
* @param bytes - File size in bytes
* @returns Formatted string like "1.5 MB"
*/
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
};
export interface ValidationResult {
// Additional details for error messages
actualSize?: number;
error?: string;
fileName?: string;
maxSize?: number;
valid: boolean;
}
/**
* Validate single image file size
* @param file - File to validate
* @param maxSize - Maximum file size in bytes, defaults to 10MB if not provided
* @returns Validation result
*/
export const validateImageFileSize = (file: File, maxSize?: number): ValidationResult => {
const defaultMaxSize = 10 * 1024 * 1024; // 10MB default limit
const actualMaxSize = maxSize ?? defaultMaxSize;
if (file.size > actualMaxSize) {
return {
actualSize: file.size,
error: 'fileSizeExceeded',
fileName: file.name,
maxSize: actualMaxSize,
valid: false,
};
}
return { valid: true };
};
/**
* Validate image count
* @param count - Current image count
* @param maxCount - Maximum allowed count, skip validation if not provided
* @returns Validation result
*/
export const validateImageCount = (count: number, maxCount?: number): ValidationResult => {
if (!maxCount) return { valid: true };
if (count > maxCount) {
return {
error: 'imageCountExceeded',
valid: false,
};
}
return { valid: true };
};
/**
* Validate image file list
* @param files - File list
* @param constraints - Constraint configuration
* @returns Validation result, including validation result for each file
*/
export const validateImageFiles = (
files: File[],
constraints: {
maxAddedFiles?: number;
maxFileSize?: number;
},
): {
errors: string[];
// Additional details for error messages
failedFiles?: ValidationResult[];
fileResults: ValidationResult[];
valid: boolean;
} => {
const errors: string[] = [];
const fileResults: ValidationResult[] = [];
const failedFiles: ValidationResult[] = [];
// Validate file count
const countResult = validateImageCount(files.length, constraints.maxAddedFiles);
if (!countResult.valid && countResult.error) {
errors.push(countResult.error);
}
// Validate each file
files.forEach((file) => {
const fileSizeResult = validateImageFileSize(file, constraints.maxFileSize);
fileResults.push(fileSizeResult);
if (!fileSizeResult.valid && fileSizeResult.error) {
errors.push(fileSizeResult.error);
failedFiles.push(fileSizeResult);
}
});
return {
errors: Array.from(new Set(errors)), // Remove duplicates
failedFiles,
fileResults,
valid: errors.length === 0,
};
};

View file

@ -40,6 +40,7 @@ export const ModelParamsMetaSchema = z.object({
.object({
default: z.string().nullable().optional(),
description: z.string().optional(),
maxFileSize: z.number().optional(),
type: z.tuple([z.literal('string'), z.literal('null')]).optional(),
})
.optional(),
@ -48,6 +49,8 @@ export const ModelParamsMetaSchema = z.object({
.object({
default: z.array(z.string()),
description: z.string().optional(),
maxCount: z.number().optional(),
maxFileSize: z.number().optional(),
type: z.literal('array').optional(),
})
.optional(),

View file

@ -133,6 +133,14 @@ export default {
progress: {
uploadingWithCount: '{{completed}}/{{total}} 已上传',
},
validation: {
fileSizeExceeded: 'File size exceeded limit',
fileSizeExceededDetail:
'{{fileName}} ({{actualSize}}) exceeds the maximum size limit of {{maxSize}}',
fileSizeExceededMultiple:
'{{count}} files exceed the maximum size limit of {{maxSize}}: {{fileList}}',
imageCountExceeded: 'Image count exceeded limit',
},
},
OllamaSetupGuide: {
action: {

View file

@ -6,7 +6,7 @@ import { getModelListByType } from '../action';
// Mock getModelPropertyWithFallback
vi.mock('@/utils/getFallbackModelProperty', () => ({
getModelPropertyWithFallback: vi.fn().mockReturnValue({ size: '1024x1024' }),
getModelPropertyWithFallback: vi.fn().mockResolvedValue({ size: '1024x1024' }),
}));
describe('getModelListByType', () => {
@ -48,9 +48,9 @@ describe('getModelListByType', () => {
abilities: {} as ModelAbilities,
displayName: 'DALL-E 3',
enabled: true,
parameters: {
parameters: {
prompt: { default: '' },
size: { default: '1024x1024', enum: ['512x512', '1024x1024', '1536x1536'] }
size: { default: '1024x1024', enum: ['512x512', '1024x1024', '1536x1536'] },
},
},
{
@ -66,15 +66,15 @@ describe('getModelListByType', () => {
const allModels = [...mockChatModels, ...mockImageModels];
describe('basic functionality', () => {
it('should filter models by providerId and type correctly', () => {
const result = getModelListByType(allModels, 'openai', 'chat');
it('should filter models by providerId and type correctly', async () => {
const result = await getModelListByType(allModels, 'openai', 'chat');
expect(result).toHaveLength(2);
expect(result.map((m) => m.id)).toEqual(['gpt-4', 'gpt-3.5-turbo']);
});
it('should return correct model structure', () => {
const result = getModelListByType(allModels, 'openai', 'chat');
it('should return correct model structure', async () => {
const result = await getModelListByType(allModels, 'openai', 'chat');
expect(result[0]).toEqual({
abilities: { functionCall: true, files: true },
@ -84,23 +84,23 @@ describe('getModelListByType', () => {
});
});
it('should add parameters field for image models', () => {
const result = getModelListByType(allModels, 'openai', 'image');
it('should add parameters field for image models', async () => {
const result = await getModelListByType(allModels, 'openai', 'image');
expect(result[0]).toEqual({
abilities: {},
contextWindowTokens: undefined,
displayName: 'DALL-E 3',
id: 'dall-e-3',
parameters: {
parameters: {
prompt: { default: '' },
size: { default: '1024x1024', enum: ['512x512', '1024x1024', '1536x1536'] }
size: { default: '1024x1024', enum: ['512x512', '1024x1024', '1536x1536'] },
},
});
});
it('should use fallback parameters for image models without parameters', () => {
const result = getModelListByType(allModels, 'midjourney', 'image');
it('should use fallback parameters for image models without parameters', async () => {
const result = await getModelListByType(allModels, 'midjourney', 'image');
expect(result[0]).toEqual({
abilities: {},
@ -113,22 +113,22 @@ describe('getModelListByType', () => {
});
describe('edge cases', () => {
it('should handle empty model list', () => {
const result = getModelListByType([], 'openai', 'chat');
it('should handle empty model list', async () => {
const result = await getModelListByType([], 'openai', 'chat');
expect(result).toEqual([]);
});
it('should handle non-existent providerId', () => {
const result = getModelListByType(allModels, 'nonexistent', 'chat');
it('should handle non-existent providerId', async () => {
const result = await getModelListByType(allModels, 'nonexistent', 'chat');
expect(result).toEqual([]);
});
it('should handle non-existent type', () => {
const result = getModelListByType(allModels, 'openai', 'nonexistent');
it('should handle non-existent type', async () => {
const result = await getModelListByType(allModels, 'openai', 'nonexistent');
expect(result).toEqual([]);
});
it('should handle missing displayName', () => {
it('should handle missing displayName', async () => {
const modelsWithoutDisplayName: EnabledAiModel[] = [
{
id: 'test-model',
@ -139,11 +139,11 @@ describe('getModelListByType', () => {
},
];
const result = getModelListByType(modelsWithoutDisplayName, 'test', 'chat');
const result = await getModelListByType(modelsWithoutDisplayName, 'test', 'chat');
expect(result[0].displayName).toBe('');
});
it('should handle missing abilities', () => {
it('should handle missing abilities', async () => {
const modelsWithoutAbilities: EnabledAiModel[] = [
{
id: 'test-model',
@ -153,13 +153,13 @@ describe('getModelListByType', () => {
} as EnabledAiModel,
];
const result = getModelListByType(modelsWithoutAbilities, 'test', 'chat');
const result = await getModelListByType(modelsWithoutAbilities, 'test', 'chat');
expect(result[0].abilities).toEqual({});
});
});
describe('deduplication', () => {
it('should remove duplicate model IDs', () => {
it('should remove duplicate model IDs', async () => {
const duplicateModels: EnabledAiModel[] = [
{
id: 'gpt-4',
@ -179,7 +179,7 @@ describe('getModelListByType', () => {
},
];
const result = getModelListByType(duplicateModels, 'openai', 'chat');
const result = await getModelListByType(duplicateModels, 'openai', 'chat');
expect(result).toHaveLength(1);
expect(result[0].displayName).toBe('GPT-4 Version 1');
@ -187,7 +187,7 @@ describe('getModelListByType', () => {
});
describe('type casting', () => {
it('should handle image model type casting correctly', () => {
it('should handle image model type casting correctly', async () => {
const imageModel: EnabledAiModel[] = [
{
id: 'dall-e-3',
@ -200,14 +200,14 @@ describe('getModelListByType', () => {
} as any, // Simulate AIImageModelCard type
];
const result = getModelListByType(imageModel, 'openai', 'image');
const result = await getModelListByType(imageModel, 'openai', 'image');
expect(result[0]).toHaveProperty('parameters');
expect(result[0].parameters).toEqual({ size: '1024x1024' });
});
it('should not add parameters field for non-image models', () => {
const result = getModelListByType(mockChatModels, 'openai', 'chat');
it('should not add parameters field for non-image models', async () => {
const result = await getModelListByType(mockChatModels, 'openai', 'chat');
result.forEach((model) => {
expect(model).not.toHaveProperty('parameters');

View file

@ -6,7 +6,12 @@ import { isDeprecatedEdition, isDesktop, isUsePgliteDB } from '@/const/version';
import { useClientDataSWR } from '@/libs/swr';
import { aiProviderService } from '@/services/aiProvider';
import { AiInfraStore } from '@/store/aiInfra/store';
import { AIImageModelCard, LobeDefaultAiModelListItem, ModelAbilities } from '@/types/aiModel';
import {
AIImageModelCard,
EnabledAiModel,
LobeDefaultAiModelListItem,
ModelAbilities,
} from '@/types/aiModel';
import {
AiProviderDetailItem,
AiProviderListItem,
@ -15,6 +20,7 @@ import {
AiProviderSourceEnum,
CreateAiProviderParams,
EnabledProvider,
EnabledProviderWithModels,
UpdateAiProviderConfigParams,
UpdateAiProviderParams,
} from '@/types/aiProvider';
@ -23,10 +29,17 @@ import { getModelPropertyWithFallback } from '@/utils/getFallbackModelProperty';
/**
* Get models by provider ID and type, with proper formatting and deduplication
*/
export const getModelListByType = (enabledAiModels: any[], providerId: string, type: string) => {
const models = enabledAiModels
.filter((model) => model.providerId === providerId && model.type === type)
.map((model) => ({
export const getModelListByType = async (
enabledAiModels: EnabledAiModel[],
providerId: string,
type: string,
) => {
const filteredModels = enabledAiModels.filter(
(model) => model.providerId === providerId && model.type === type,
);
const models = await Promise.all(
filteredModels.map(async (model) => ({
abilities: (model.abilities || {}) as ModelAbilities,
contextWindowTokens: model.contextWindowTokens,
displayName: model.displayName ?? '',
@ -34,13 +47,31 @@ export const getModelListByType = (enabledAiModels: any[], providerId: string, t
...(model.type === 'image' && {
parameters:
(model as AIImageModelCard).parameters ||
getModelPropertyWithFallback(model.id, 'parameters'),
(await getModelPropertyWithFallback(model.id, 'parameters')),
}),
}));
})),
);
return uniqBy(models, 'id');
};
/**
* Build provider model lists with proper async handling
*/
const buildProviderModelLists = async (
providers: EnabledProvider[],
enabledAiModels: EnabledAiModel[],
type: 'chat' | 'image',
) => {
return Promise.all(
providers.map(async (provider) => ({
...provider,
children: await getModelListByType(enabledAiModels, provider.id, type),
name: provider.name || provider.id,
})),
);
};
enum AiProviderSwrKey {
fetchAiProviderItem = 'FETCH_AI_PROVIDER_ITEM',
fetchAiProviderList = 'FETCH_AI_PROVIDER',
@ -49,6 +80,8 @@ enum AiProviderSwrKey {
type AiProviderRuntimeStateWithBuiltinModels = AiProviderRuntimeState & {
builtinAiModelList: LobeDefaultAiModelListItem[];
enabledChatModelList?: EnabledProviderWithModels[];
enabledImageModelList?: EnabledProviderWithModels[];
};
export interface AiProviderAction {
@ -203,31 +236,54 @@ export const createAiProviderSlice: StateCreator<
if (isLogin) {
const data = await aiProviderService.getAiProviderRuntimeState();
// Build model lists with proper async handling
const [enabledChatModelList, enabledImageModelList] = await Promise.all([
buildProviderModelLists(data.enabledChatAiProviders, data.enabledAiModels, 'chat'),
buildProviderModelLists(data.enabledImageAiProviders, data.enabledAiModels, 'image'),
]);
return {
...data,
builtinAiModelList,
enabledChatModelList,
enabledImageModelList,
};
}
const enabledAiProviders: EnabledProvider[] = DEFAULT_MODEL_PROVIDER_LIST.filter(
(provider) => provider.enabled,
).map((item) => ({ id: item.id, name: item.name, source: 'builtin' }));
).map((item) => ({ id: item.id, name: item.name, source: AiProviderSourceEnum.Builtin }));
const enabledChatAiProviders = enabledAiProviders.filter((provider) => {
return builtinAiModelList.some(
(model) => model.providerId === provider.id && model.type === 'chat',
);
});
const enabledImageAiProviders = enabledAiProviders
.filter((provider) => {
return builtinAiModelList.some(
(model) => model.providerId === provider.id && model.type === 'image',
);
})
.map((item) => ({ id: item.id, name: item.name, source: AiProviderSourceEnum.Builtin }));
// Build model lists for non-login state as well
const enabledAiModels = builtinAiModelList.filter((m) => m.enabled);
const [enabledChatModelList, enabledImageModelList] = await Promise.all([
buildProviderModelLists(enabledChatAiProviders, enabledAiModels, 'chat'),
buildProviderModelLists(enabledImageAiProviders, enabledAiModels, 'image'),
]);
return {
builtinAiModelList,
enabledAiModels: builtinAiModelList.filter((m) => m.enabled),
enabledAiProviders: enabledAiProviders,
enabledChatAiProviders: enabledAiProviders.filter((provider) => {
return builtinAiModelList.some(
(model) => model.providerId === provider.id && model.type === 'chat',
);
}),
enabledImageAiProviders: enabledAiProviders
.filter((provider) => {
return builtinAiModelList.some(
(model) => model.providerId === provider.id && model.type === 'image',
);
})
.map((item) => ({ id: item.id, name: item.name, source: 'builtin' })),
enabledAiModels,
enabledAiProviders,
enabledChatAiProviders,
enabledChatModelList,
enabledImageAiProviders,
enabledImageModelList,
runtimeConfig: {},
};
},
@ -236,26 +292,14 @@ export const createAiProviderSlice: StateCreator<
onSuccess: (data) => {
if (!data) return;
const enabledChatModelList = data.enabledChatAiProviders.map((provider) => ({
...provider,
children: getModelListByType(data.enabledAiModels, provider.id, 'chat'),
name: provider.name || provider.id,
}));
const enabledImageModelList = data.enabledImageAiProviders.map((provider) => ({
...provider,
children: getModelListByType(data.enabledAiModels, provider.id, 'image'),
name: provider.name || provider.id,
}));
set(
{
aiProviderRuntimeConfig: data.runtimeConfig,
builtinAiModelList: data.builtinAiModelList,
enabledAiModels: data.enabledAiModels,
enabledAiProviders: data.enabledAiProviders,
enabledChatModelList,
enabledImageModelList,
enabledChatModelList: data.enabledChatModelList || [],
enabledImageModelList: data.enabledImageModelList || [],
},
false,
'useFetchAiProviderRuntimeState',

View file

@ -44,6 +44,14 @@ export function useGenerationConfigParam<
paramConfig && typeof paramConfig === 'object' && 'enum' in paramConfig
? paramConfig.enum
: undefined;
const maxFileSize =
paramConfig && typeof paramConfig === 'object' && 'maxFileSize' in paramConfig
? paramConfig.maxFileSize
: undefined;
const maxCount =
paramConfig && typeof paramConfig === 'object' && 'maxCount' in paramConfig
? paramConfig.maxCount
: undefined;
return {
description,
@ -51,6 +59,8 @@ export function useGenerationConfigParam<
min,
step,
enumValues,
maxFileSize,
maxCount,
};
}, [paramConfig]);