mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
chore: support set more image constrints (#8896)
This commit is contained in:
parent
c7e94e7446
commit
ccc733dac5
12 changed files with 601 additions and 283 deletions
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: 本地文件的预览URL(blob 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue