@lobehub/chat 1.114.5 → 1.114.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.prettierignore +0 -1
- package/.vscode/settings.json +86 -80
- package/CHANGELOG.md +25 -0
- package/changelog/v1.json +5 -0
- package/package.json +2 -2
- package/src/app/[variants]/(main)/files/[id]/page.tsx +0 -2
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ImageUpload.tsx +194 -183
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ImageUrl.tsx +2 -2
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ImageUrlsUpload.tsx +26 -2
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/MultiImagesUpload/ImageManageModal.tsx +33 -19
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/MultiImagesUpload/index.tsx +17 -7
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/hooks/useUploadFilesValidation.ts +77 -0
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/utils/imageValidation.ts +117 -0
- package/src/libs/standard-parameters/index.ts +3 -0
- package/src/locales/default/components.ts +8 -0
- package/src/store/aiInfra/slices/aiProvider/__tests__/action.test.ts +29 -29
- package/src/store/aiInfra/slices/aiProvider/action.ts +80 -36
- package/src/store/image/slices/generationConfig/hooks.ts +10 -0
@@ -13,6 +13,7 @@ import { FileUploadStatus } from '@/types/files/upload';
|
|
13
13
|
|
14
14
|
import { CONFIG_PANEL_WIDTH } from '../../constants';
|
15
15
|
import { useDragAndDrop } from '../../hooks/useDragAndDrop';
|
16
|
+
import { useUploadFilesValidation } from '../../hooks/useUploadFilesValidation';
|
16
17
|
import { useConfigPanelStyles } from '../../style';
|
17
18
|
import ImageManageModal, { type ImageItem } from './ImageManageModal';
|
18
19
|
|
@@ -38,6 +39,8 @@ interface DisplayItem {
|
|
38
39
|
export interface MultiImagesUploadProps {
|
39
40
|
// Callback when URLs change
|
40
41
|
className?: string; // Array of image URLs
|
42
|
+
maxCount?: number;
|
43
|
+
maxFileSize?: number;
|
41
44
|
onChange?: (urls: string[]) => void;
|
42
45
|
style?: React.CSSProperties;
|
43
46
|
value?: string[];
|
@@ -311,7 +314,7 @@ const ImageUploadPlaceholder: FC<ImageUploadPlaceholderProps> = memo(({ isDragOv
|
|
311
314
|
|
312
315
|
ImageUploadPlaceholder.displayName = 'ImageUploadPlaceholder';
|
313
316
|
|
314
|
-
// ========
|
317
|
+
// ======== Circular Progress Component ======== //
|
315
318
|
|
316
319
|
interface CircularProgressProps {
|
317
320
|
className?: string;
|
@@ -466,7 +469,7 @@ const ImageThumbnails: FC<ImageThumbnailsProps> = memo(
|
|
466
469
|
const showOverlay = isLastItem && remainingCount > 1;
|
467
470
|
|
468
471
|
return (
|
469
|
-
<div className={styles.imageItem} key={imageUrl}>
|
472
|
+
<div className={styles.imageItem} key={`${imageUrl}-${index}`}>
|
470
473
|
<Image
|
471
474
|
alt={`Uploaded image ${index + 1}`}
|
472
475
|
fill
|
@@ -559,12 +562,13 @@ SingleImageDisplay.displayName = 'SingleImageDisplay';
|
|
559
562
|
// ======== Main Component ======== //
|
560
563
|
|
561
564
|
const MultiImagesUpload: FC<MultiImagesUploadProps> = memo(
|
562
|
-
({ value, onChange, style, className }) => {
|
565
|
+
({ value, onChange, style, className, maxCount, maxFileSize }) => {
|
563
566
|
const inputRef = useRef<HTMLInputElement>(null);
|
564
567
|
const uploadWithProgress = useFileStore((s) => s.uploadWithProgress);
|
565
568
|
const [displayItems, setDisplayItems] = useState<DisplayItem[]>([]);
|
566
569
|
const [modalOpen, setModalOpen] = useState(false);
|
567
570
|
const { styles: configStyles } = useConfigPanelStyles();
|
571
|
+
const { validateFiles } = useUploadFilesValidation(maxCount, maxFileSize);
|
568
572
|
|
569
573
|
// Cleanup blob URLs to prevent memory leaks
|
570
574
|
useEffect(() => {
|
@@ -601,6 +605,11 @@ const MultiImagesUpload: FC<MultiImagesUploadProps> = memo(
|
|
601
605
|
|
602
606
|
const currentUrls = baseUrls !== undefined ? baseUrls : value || [];
|
603
607
|
|
608
|
+
// Validate files, pass current image count
|
609
|
+
if (!validateFiles(files, currentUrls.length)) {
|
610
|
+
return;
|
611
|
+
}
|
612
|
+
|
604
613
|
// Create initial display items with blob URLs for immediate preview
|
605
614
|
const newDisplayItems: DisplayItem[] = files.map((file) => ({
|
606
615
|
file,
|
@@ -716,17 +725,17 @@ const MultiImagesUpload: FC<MultiImagesUploadProps> = memo(
|
|
716
725
|
onDrop: handleDrop,
|
717
726
|
});
|
718
727
|
|
719
|
-
//
|
728
|
+
// Handle Modal completion callback
|
720
729
|
const handleModalComplete = async (imageItems: ImageItem[]) => {
|
721
|
-
//
|
730
|
+
// Separate existing URLs and new files
|
722
731
|
const existingUrls = imageItems.filter((item) => item.url).map((item) => item.url!);
|
723
732
|
|
724
733
|
const newFiles = imageItems.filter((item) => item.file).map((item) => item.file!);
|
725
734
|
|
726
|
-
//
|
735
|
+
// Immediately update existing URLs (deleted images will be filtered out)
|
727
736
|
onChange?.(existingUrls);
|
728
737
|
|
729
|
-
//
|
738
|
+
// If there are new files to upload, start upload process based on existingUrls
|
730
739
|
if (newFiles.length > 0) {
|
731
740
|
await handleFilesSelected(newFiles, existingUrls);
|
732
741
|
}
|
@@ -793,6 +802,7 @@ const MultiImagesUpload: FC<MultiImagesUploadProps> = memo(
|
|
793
802
|
{/* Image Management Modal */}
|
794
803
|
<ImageManageModal
|
795
804
|
images={value || []}
|
805
|
+
maxCount={maxCount}
|
796
806
|
onClose={handleCloseModal}
|
797
807
|
onComplete={handleModalComplete}
|
798
808
|
open={modalOpen}
|
package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/hooks/useUploadFilesValidation.ts
ADDED
@@ -0,0 +1,77 @@
|
|
1
|
+
import { App } from 'antd';
|
2
|
+
import { useCallback } from 'react';
|
3
|
+
import { useTranslation } from 'react-i18next';
|
4
|
+
|
5
|
+
import { formatFileSize, validateImageFiles } from '../utils/imageValidation';
|
6
|
+
|
7
|
+
/**
|
8
|
+
* File upload validation hook
|
9
|
+
* Encapsulates file size and count validation logic, provides user-friendly error messages
|
10
|
+
*/
|
11
|
+
export const useUploadFilesValidation = (maxCount?: number, maxFileSize?: number) => {
|
12
|
+
const { t } = useTranslation('components');
|
13
|
+
const { message } = App.useApp();
|
14
|
+
|
15
|
+
const validateFiles = useCallback(
|
16
|
+
(files: File[], currentCount: number = 0): boolean => {
|
17
|
+
const validationResult = validateImageFiles(files, {
|
18
|
+
maxAddedFiles: maxCount ? maxCount - currentCount : undefined,
|
19
|
+
maxFileSize,
|
20
|
+
});
|
21
|
+
|
22
|
+
if (!validationResult.valid) {
|
23
|
+
// Display user-friendly error messages
|
24
|
+
validationResult.errors.forEach((error) => {
|
25
|
+
if (error === 'fileSizeExceeded') {
|
26
|
+
// Collect all failed files info
|
27
|
+
const fileSizeFailures =
|
28
|
+
validationResult.failedFiles?.filter(
|
29
|
+
(failedFile) =>
|
30
|
+
failedFile.error === 'fileSizeExceeded' &&
|
31
|
+
failedFile.actualSize &&
|
32
|
+
failedFile.maxSize,
|
33
|
+
) || [];
|
34
|
+
|
35
|
+
if (fileSizeFailures.length === 1) {
|
36
|
+
// Single file error - show detailed message
|
37
|
+
const failedFile = fileSizeFailures[0];
|
38
|
+
const actualSizeStr = formatFileSize(failedFile.actualSize!);
|
39
|
+
const maxSizeStr = formatFileSize(failedFile.maxSize!);
|
40
|
+
const fileName = failedFile.fileName || 'File';
|
41
|
+
message.error(
|
42
|
+
t('MultiImagesUpload.validation.fileSizeExceededDetail', {
|
43
|
+
actualSize: actualSizeStr,
|
44
|
+
fileName,
|
45
|
+
maxSize: maxSizeStr,
|
46
|
+
}),
|
47
|
+
);
|
48
|
+
} else if (fileSizeFailures.length > 1) {
|
49
|
+
// Multiple files error - show summary message
|
50
|
+
const maxSizeStr = formatFileSize(fileSizeFailures[0].maxSize!);
|
51
|
+
const fileList = fileSizeFailures
|
52
|
+
.map((f) => `${f.fileName || 'File'} (${formatFileSize(f.actualSize!)})`)
|
53
|
+
.join(', ');
|
54
|
+
message.error(
|
55
|
+
t('MultiImagesUpload.validation.fileSizeExceededMultiple', {
|
56
|
+
count: fileSizeFailures.length,
|
57
|
+
fileList,
|
58
|
+
maxSize: maxSizeStr,
|
59
|
+
}),
|
60
|
+
);
|
61
|
+
}
|
62
|
+
} else if (error === 'imageCountExceeded') {
|
63
|
+
message.error(t('MultiImagesUpload.validation.imageCountExceeded'));
|
64
|
+
}
|
65
|
+
});
|
66
|
+
return false;
|
67
|
+
}
|
68
|
+
|
69
|
+
return true;
|
70
|
+
},
|
71
|
+
[maxCount, maxFileSize, message, t],
|
72
|
+
);
|
73
|
+
|
74
|
+
return {
|
75
|
+
validateFiles,
|
76
|
+
};
|
77
|
+
};
|
@@ -0,0 +1,117 @@
|
|
1
|
+
/**
|
2
|
+
* Image file validation utility functions
|
3
|
+
*/
|
4
|
+
|
5
|
+
/**
|
6
|
+
* Format file size to human readable format
|
7
|
+
* @param bytes - File size in bytes
|
8
|
+
* @returns Formatted string like "1.5 MB"
|
9
|
+
*/
|
10
|
+
export const formatFileSize = (bytes: number): string => {
|
11
|
+
if (bytes === 0) return '0 B';
|
12
|
+
|
13
|
+
const k = 1024;
|
14
|
+
const sizes = ['B', 'KB', 'MB', 'GB'];
|
15
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
16
|
+
|
17
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
18
|
+
};
|
19
|
+
|
20
|
+
export interface ValidationResult {
|
21
|
+
// Additional details for error messages
|
22
|
+
actualSize?: number;
|
23
|
+
error?: string;
|
24
|
+
fileName?: string;
|
25
|
+
maxSize?: number;
|
26
|
+
valid: boolean;
|
27
|
+
}
|
28
|
+
|
29
|
+
/**
|
30
|
+
* Validate single image file size
|
31
|
+
* @param file - File to validate
|
32
|
+
* @param maxSize - Maximum file size in bytes, defaults to 10MB if not provided
|
33
|
+
* @returns Validation result
|
34
|
+
*/
|
35
|
+
export const validateImageFileSize = (file: File, maxSize?: number): ValidationResult => {
|
36
|
+
const defaultMaxSize = 10 * 1024 * 1024; // 10MB default limit
|
37
|
+
const actualMaxSize = maxSize ?? defaultMaxSize;
|
38
|
+
|
39
|
+
if (file.size > actualMaxSize) {
|
40
|
+
return {
|
41
|
+
actualSize: file.size,
|
42
|
+
error: 'fileSizeExceeded',
|
43
|
+
fileName: file.name,
|
44
|
+
maxSize: actualMaxSize,
|
45
|
+
valid: false,
|
46
|
+
};
|
47
|
+
}
|
48
|
+
|
49
|
+
return { valid: true };
|
50
|
+
};
|
51
|
+
|
52
|
+
/**
|
53
|
+
* Validate image count
|
54
|
+
* @param count - Current image count
|
55
|
+
* @param maxCount - Maximum allowed count, skip validation if not provided
|
56
|
+
* @returns Validation result
|
57
|
+
*/
|
58
|
+
export const validateImageCount = (count: number, maxCount?: number): ValidationResult => {
|
59
|
+
if (!maxCount) return { valid: true };
|
60
|
+
|
61
|
+
if (count > maxCount) {
|
62
|
+
return {
|
63
|
+
error: 'imageCountExceeded',
|
64
|
+
valid: false,
|
65
|
+
};
|
66
|
+
}
|
67
|
+
|
68
|
+
return { valid: true };
|
69
|
+
};
|
70
|
+
|
71
|
+
/**
|
72
|
+
* Validate image file list
|
73
|
+
* @param files - File list
|
74
|
+
* @param constraints - Constraint configuration
|
75
|
+
* @returns Validation result, including validation result for each file
|
76
|
+
*/
|
77
|
+
export const validateImageFiles = (
|
78
|
+
files: File[],
|
79
|
+
constraints: {
|
80
|
+
maxAddedFiles?: number;
|
81
|
+
maxFileSize?: number;
|
82
|
+
},
|
83
|
+
): {
|
84
|
+
errors: string[];
|
85
|
+
// Additional details for error messages
|
86
|
+
failedFiles?: ValidationResult[];
|
87
|
+
fileResults: ValidationResult[];
|
88
|
+
valid: boolean;
|
89
|
+
} => {
|
90
|
+
const errors: string[] = [];
|
91
|
+
const fileResults: ValidationResult[] = [];
|
92
|
+
const failedFiles: ValidationResult[] = [];
|
93
|
+
|
94
|
+
// Validate file count
|
95
|
+
const countResult = validateImageCount(files.length, constraints.maxAddedFiles);
|
96
|
+
if (!countResult.valid && countResult.error) {
|
97
|
+
errors.push(countResult.error);
|
98
|
+
}
|
99
|
+
|
100
|
+
// Validate each file
|
101
|
+
files.forEach((file) => {
|
102
|
+
const fileSizeResult = validateImageFileSize(file, constraints.maxFileSize);
|
103
|
+
fileResults.push(fileSizeResult);
|
104
|
+
|
105
|
+
if (!fileSizeResult.valid && fileSizeResult.error) {
|
106
|
+
errors.push(fileSizeResult.error);
|
107
|
+
failedFiles.push(fileSizeResult);
|
108
|
+
}
|
109
|
+
});
|
110
|
+
|
111
|
+
return {
|
112
|
+
errors: Array.from(new Set(errors)), // Remove duplicates
|
113
|
+
failedFiles,
|
114
|
+
fileResults,
|
115
|
+
valid: errors.length === 0,
|
116
|
+
};
|
117
|
+
};
|
@@ -40,6 +40,7 @@ export const ModelParamsMetaSchema = z.object({
|
|
40
40
|
.object({
|
41
41
|
default: z.string().nullable().optional(),
|
42
42
|
description: z.string().optional(),
|
43
|
+
maxFileSize: z.number().optional(),
|
43
44
|
type: z.tuple([z.literal('string'), z.literal('null')]).optional(),
|
44
45
|
})
|
45
46
|
.optional(),
|
@@ -48,6 +49,8 @@ export const ModelParamsMetaSchema = z.object({
|
|
48
49
|
.object({
|
49
50
|
default: z.array(z.string()),
|
50
51
|
description: z.string().optional(),
|
52
|
+
maxCount: z.number().optional(),
|
53
|
+
maxFileSize: z.number().optional(),
|
51
54
|
type: z.literal('array').optional(),
|
52
55
|
})
|
53
56
|
.optional(),
|
@@ -133,6 +133,14 @@ export default {
|
|
133
133
|
progress: {
|
134
134
|
uploadingWithCount: '{{completed}}/{{total}} 已上传',
|
135
135
|
},
|
136
|
+
validation: {
|
137
|
+
fileSizeExceeded: 'File size exceeded limit',
|
138
|
+
fileSizeExceededDetail:
|
139
|
+
'{{fileName}} ({{actualSize}}) exceeds the maximum size limit of {{maxSize}}',
|
140
|
+
fileSizeExceededMultiple:
|
141
|
+
'{{count}} files exceed the maximum size limit of {{maxSize}}: {{fileList}}',
|
142
|
+
imageCountExceeded: 'Image count exceeded limit',
|
143
|
+
},
|
136
144
|
},
|
137
145
|
OllamaSetupGuide: {
|
138
146
|
action: {
|
@@ -6,7 +6,7 @@ import { getModelListByType } from '../action';
|
|
6
6
|
|
7
7
|
// Mock getModelPropertyWithFallback
|
8
8
|
vi.mock('@/utils/getFallbackModelProperty', () => ({
|
9
|
-
getModelPropertyWithFallback: vi.fn().
|
9
|
+
getModelPropertyWithFallback: vi.fn().mockResolvedValue({ size: '1024x1024' }),
|
10
10
|
}));
|
11
11
|
|
12
12
|
describe('getModelListByType', () => {
|
@@ -48,9 +48,9 @@ describe('getModelListByType', () => {
|
|
48
48
|
abilities: {} as ModelAbilities,
|
49
49
|
displayName: 'DALL-E 3',
|
50
50
|
enabled: true,
|
51
|
-
parameters: {
|
51
|
+
parameters: {
|
52
52
|
prompt: { default: '' },
|
53
|
-
size: { default: '1024x1024', enum: ['512x512', '1024x1024', '1536x1536'] }
|
53
|
+
size: { default: '1024x1024', enum: ['512x512', '1024x1024', '1536x1536'] },
|
54
54
|
},
|
55
55
|
},
|
56
56
|
{
|
@@ -66,15 +66,15 @@ describe('getModelListByType', () => {
|
|
66
66
|
const allModels = [...mockChatModels, ...mockImageModels];
|
67
67
|
|
68
68
|
describe('basic functionality', () => {
|
69
|
-
it('should filter models by providerId and type correctly', () => {
|
70
|
-
const result = getModelListByType(allModels, 'openai', 'chat');
|
69
|
+
it('should filter models by providerId and type correctly', async () => {
|
70
|
+
const result = await getModelListByType(allModels, 'openai', 'chat');
|
71
71
|
|
72
72
|
expect(result).toHaveLength(2);
|
73
73
|
expect(result.map((m) => m.id)).toEqual(['gpt-4', 'gpt-3.5-turbo']);
|
74
74
|
});
|
75
75
|
|
76
|
-
it('should return correct model structure', () => {
|
77
|
-
const result = getModelListByType(allModels, 'openai', 'chat');
|
76
|
+
it('should return correct model structure', async () => {
|
77
|
+
const result = await getModelListByType(allModels, 'openai', 'chat');
|
78
78
|
|
79
79
|
expect(result[0]).toEqual({
|
80
80
|
abilities: { functionCall: true, files: true },
|
@@ -84,23 +84,23 @@ describe('getModelListByType', () => {
|
|
84
84
|
});
|
85
85
|
});
|
86
86
|
|
87
|
-
it('should add parameters field for image models', () => {
|
88
|
-
const result = getModelListByType(allModels, 'openai', 'image');
|
87
|
+
it('should add parameters field for image models', async () => {
|
88
|
+
const result = await getModelListByType(allModels, 'openai', 'image');
|
89
89
|
|
90
90
|
expect(result[0]).toEqual({
|
91
91
|
abilities: {},
|
92
92
|
contextWindowTokens: undefined,
|
93
93
|
displayName: 'DALL-E 3',
|
94
94
|
id: 'dall-e-3',
|
95
|
-
parameters: {
|
95
|
+
parameters: {
|
96
96
|
prompt: { default: '' },
|
97
|
-
size: { default: '1024x1024', enum: ['512x512', '1024x1024', '1536x1536'] }
|
97
|
+
size: { default: '1024x1024', enum: ['512x512', '1024x1024', '1536x1536'] },
|
98
98
|
},
|
99
99
|
});
|
100
100
|
});
|
101
101
|
|
102
|
-
it('should use fallback parameters for image models without parameters', () => {
|
103
|
-
const result = getModelListByType(allModels, 'midjourney', 'image');
|
102
|
+
it('should use fallback parameters for image models without parameters', async () => {
|
103
|
+
const result = await getModelListByType(allModels, 'midjourney', 'image');
|
104
104
|
|
105
105
|
expect(result[0]).toEqual({
|
106
106
|
abilities: {},
|
@@ -113,22 +113,22 @@ describe('getModelListByType', () => {
|
|
113
113
|
});
|
114
114
|
|
115
115
|
describe('edge cases', () => {
|
116
|
-
it('should handle empty model list', () => {
|
117
|
-
const result = getModelListByType([], 'openai', 'chat');
|
116
|
+
it('should handle empty model list', async () => {
|
117
|
+
const result = await getModelListByType([], 'openai', 'chat');
|
118
118
|
expect(result).toEqual([]);
|
119
119
|
});
|
120
120
|
|
121
|
-
it('should handle non-existent providerId', () => {
|
122
|
-
const result = getModelListByType(allModels, 'nonexistent', 'chat');
|
121
|
+
it('should handle non-existent providerId', async () => {
|
122
|
+
const result = await getModelListByType(allModels, 'nonexistent', 'chat');
|
123
123
|
expect(result).toEqual([]);
|
124
124
|
});
|
125
125
|
|
126
|
-
it('should handle non-existent type', () => {
|
127
|
-
const result = getModelListByType(allModels, 'openai', 'nonexistent');
|
126
|
+
it('should handle non-existent type', async () => {
|
127
|
+
const result = await getModelListByType(allModels, 'openai', 'nonexistent');
|
128
128
|
expect(result).toEqual([]);
|
129
129
|
});
|
130
130
|
|
131
|
-
it('should handle missing displayName', () => {
|
131
|
+
it('should handle missing displayName', async () => {
|
132
132
|
const modelsWithoutDisplayName: EnabledAiModel[] = [
|
133
133
|
{
|
134
134
|
id: 'test-model',
|
@@ -139,11 +139,11 @@ describe('getModelListByType', () => {
|
|
139
139
|
},
|
140
140
|
];
|
141
141
|
|
142
|
-
const result = getModelListByType(modelsWithoutDisplayName, 'test', 'chat');
|
142
|
+
const result = await getModelListByType(modelsWithoutDisplayName, 'test', 'chat');
|
143
143
|
expect(result[0].displayName).toBe('');
|
144
144
|
});
|
145
145
|
|
146
|
-
it('should handle missing abilities', () => {
|
146
|
+
it('should handle missing abilities', async () => {
|
147
147
|
const modelsWithoutAbilities: EnabledAiModel[] = [
|
148
148
|
{
|
149
149
|
id: 'test-model',
|
@@ -153,13 +153,13 @@ describe('getModelListByType', () => {
|
|
153
153
|
} as EnabledAiModel,
|
154
154
|
];
|
155
155
|
|
156
|
-
const result = getModelListByType(modelsWithoutAbilities, 'test', 'chat');
|
156
|
+
const result = await getModelListByType(modelsWithoutAbilities, 'test', 'chat');
|
157
157
|
expect(result[0].abilities).toEqual({});
|
158
158
|
});
|
159
159
|
});
|
160
160
|
|
161
161
|
describe('deduplication', () => {
|
162
|
-
it('should remove duplicate model IDs', () => {
|
162
|
+
it('should remove duplicate model IDs', async () => {
|
163
163
|
const duplicateModels: EnabledAiModel[] = [
|
164
164
|
{
|
165
165
|
id: 'gpt-4',
|
@@ -179,7 +179,7 @@ describe('getModelListByType', () => {
|
|
179
179
|
},
|
180
180
|
];
|
181
181
|
|
182
|
-
const result = getModelListByType(duplicateModels, 'openai', 'chat');
|
182
|
+
const result = await getModelListByType(duplicateModels, 'openai', 'chat');
|
183
183
|
|
184
184
|
expect(result).toHaveLength(1);
|
185
185
|
expect(result[0].displayName).toBe('GPT-4 Version 1');
|
@@ -187,7 +187,7 @@ describe('getModelListByType', () => {
|
|
187
187
|
});
|
188
188
|
|
189
189
|
describe('type casting', () => {
|
190
|
-
it('should handle image model type casting correctly', () => {
|
190
|
+
it('should handle image model type casting correctly', async () => {
|
191
191
|
const imageModel: EnabledAiModel[] = [
|
192
192
|
{
|
193
193
|
id: 'dall-e-3',
|
@@ -200,14 +200,14 @@ describe('getModelListByType', () => {
|
|
200
200
|
} as any, // Simulate AIImageModelCard type
|
201
201
|
];
|
202
202
|
|
203
|
-
const result = getModelListByType(imageModel, 'openai', 'image');
|
203
|
+
const result = await getModelListByType(imageModel, 'openai', 'image');
|
204
204
|
|
205
205
|
expect(result[0]).toHaveProperty('parameters');
|
206
206
|
expect(result[0].parameters).toEqual({ size: '1024x1024' });
|
207
207
|
});
|
208
208
|
|
209
|
-
it('should not add parameters field for non-image models', () => {
|
210
|
-
const result = getModelListByType(mockChatModels, 'openai', 'chat');
|
209
|
+
it('should not add parameters field for non-image models', async () => {
|
210
|
+
const result = await getModelListByType(mockChatModels, 'openai', 'chat');
|
211
211
|
|
212
212
|
result.forEach((model) => {
|
213
213
|
expect(model).not.toHaveProperty('parameters');
|
@@ -6,7 +6,12 @@ import { isDeprecatedEdition, isDesktop, isUsePgliteDB } from '@/const/version';
|
|
6
6
|
import { useClientDataSWR } from '@/libs/swr';
|
7
7
|
import { aiProviderService } from '@/services/aiProvider';
|
8
8
|
import { AiInfraStore } from '@/store/aiInfra/store';
|
9
|
-
import {
|
9
|
+
import {
|
10
|
+
AIImageModelCard,
|
11
|
+
EnabledAiModel,
|
12
|
+
LobeDefaultAiModelListItem,
|
13
|
+
ModelAbilities,
|
14
|
+
} from '@/types/aiModel';
|
10
15
|
import {
|
11
16
|
AiProviderDetailItem,
|
12
17
|
AiProviderListItem,
|
@@ -15,6 +20,7 @@ import {
|
|
15
20
|
AiProviderSourceEnum,
|
16
21
|
CreateAiProviderParams,
|
17
22
|
EnabledProvider,
|
23
|
+
EnabledProviderWithModels,
|
18
24
|
UpdateAiProviderConfigParams,
|
19
25
|
UpdateAiProviderParams,
|
20
26
|
} from '@/types/aiProvider';
|
@@ -23,10 +29,17 @@ import { getModelPropertyWithFallback } from '@/utils/getFallbackModelProperty';
|
|
23
29
|
/**
|
24
30
|
* Get models by provider ID and type, with proper formatting and deduplication
|
25
31
|
*/
|
26
|
-
export const getModelListByType = (
|
27
|
-
|
28
|
-
|
29
|
-
|
32
|
+
export const getModelListByType = async (
|
33
|
+
enabledAiModels: EnabledAiModel[],
|
34
|
+
providerId: string,
|
35
|
+
type: string,
|
36
|
+
) => {
|
37
|
+
const filteredModels = enabledAiModels.filter(
|
38
|
+
(model) => model.providerId === providerId && model.type === type,
|
39
|
+
);
|
40
|
+
|
41
|
+
const models = await Promise.all(
|
42
|
+
filteredModels.map(async (model) => ({
|
30
43
|
abilities: (model.abilities || {}) as ModelAbilities,
|
31
44
|
contextWindowTokens: model.contextWindowTokens,
|
32
45
|
displayName: model.displayName ?? '',
|
@@ -34,13 +47,31 @@ export const getModelListByType = (enabledAiModels: any[], providerId: string, t
|
|
34
47
|
...(model.type === 'image' && {
|
35
48
|
parameters:
|
36
49
|
(model as AIImageModelCard).parameters ||
|
37
|
-
getModelPropertyWithFallback(model.id, 'parameters'),
|
50
|
+
(await getModelPropertyWithFallback(model.id, 'parameters')),
|
38
51
|
}),
|
39
|
-
}))
|
52
|
+
})),
|
53
|
+
);
|
40
54
|
|
41
55
|
return uniqBy(models, 'id');
|
42
56
|
};
|
43
57
|
|
58
|
+
/**
|
59
|
+
* Build provider model lists with proper async handling
|
60
|
+
*/
|
61
|
+
const buildProviderModelLists = async (
|
62
|
+
providers: EnabledProvider[],
|
63
|
+
enabledAiModels: EnabledAiModel[],
|
64
|
+
type: 'chat' | 'image',
|
65
|
+
) => {
|
66
|
+
return Promise.all(
|
67
|
+
providers.map(async (provider) => ({
|
68
|
+
...provider,
|
69
|
+
children: await getModelListByType(enabledAiModels, provider.id, type),
|
70
|
+
name: provider.name || provider.id,
|
71
|
+
})),
|
72
|
+
);
|
73
|
+
};
|
74
|
+
|
44
75
|
enum AiProviderSwrKey {
|
45
76
|
fetchAiProviderItem = 'FETCH_AI_PROVIDER_ITEM',
|
46
77
|
fetchAiProviderList = 'FETCH_AI_PROVIDER',
|
@@ -49,6 +80,8 @@ enum AiProviderSwrKey {
|
|
49
80
|
|
50
81
|
type AiProviderRuntimeStateWithBuiltinModels = AiProviderRuntimeState & {
|
51
82
|
builtinAiModelList: LobeDefaultAiModelListItem[];
|
83
|
+
enabledChatModelList?: EnabledProviderWithModels[];
|
84
|
+
enabledImageModelList?: EnabledProviderWithModels[];
|
52
85
|
};
|
53
86
|
|
54
87
|
export interface AiProviderAction {
|
@@ -203,31 +236,54 @@ export const createAiProviderSlice: StateCreator<
|
|
203
236
|
|
204
237
|
if (isLogin) {
|
205
238
|
const data = await aiProviderService.getAiProviderRuntimeState();
|
239
|
+
|
240
|
+
// Build model lists with proper async handling
|
241
|
+
const [enabledChatModelList, enabledImageModelList] = await Promise.all([
|
242
|
+
buildProviderModelLists(data.enabledChatAiProviders, data.enabledAiModels, 'chat'),
|
243
|
+
buildProviderModelLists(data.enabledImageAiProviders, data.enabledAiModels, 'image'),
|
244
|
+
]);
|
245
|
+
|
206
246
|
return {
|
207
247
|
...data,
|
208
248
|
builtinAiModelList,
|
249
|
+
enabledChatModelList,
|
250
|
+
enabledImageModelList,
|
209
251
|
};
|
210
252
|
}
|
211
253
|
|
212
254
|
const enabledAiProviders: EnabledProvider[] = DEFAULT_MODEL_PROVIDER_LIST.filter(
|
213
255
|
(provider) => provider.enabled,
|
214
|
-
).map((item) => ({ id: item.id, name: item.name, source:
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
219
|
-
|
256
|
+
).map((item) => ({ id: item.id, name: item.name, source: AiProviderSourceEnum.Builtin }));
|
257
|
+
|
258
|
+
const enabledChatAiProviders = enabledAiProviders.filter((provider) => {
|
259
|
+
return builtinAiModelList.some(
|
260
|
+
(model) => model.providerId === provider.id && model.type === 'chat',
|
261
|
+
);
|
262
|
+
});
|
263
|
+
|
264
|
+
const enabledImageAiProviders = enabledAiProviders
|
265
|
+
.filter((provider) => {
|
220
266
|
return builtinAiModelList.some(
|
221
|
-
(model) => model.providerId === provider.id && model.type === '
|
267
|
+
(model) => model.providerId === provider.id && model.type === 'image',
|
222
268
|
);
|
223
|
-
})
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
269
|
+
})
|
270
|
+
.map((item) => ({ id: item.id, name: item.name, source: AiProviderSourceEnum.Builtin }));
|
271
|
+
|
272
|
+
// Build model lists for non-login state as well
|
273
|
+
const enabledAiModels = builtinAiModelList.filter((m) => m.enabled);
|
274
|
+
const [enabledChatModelList, enabledImageModelList] = await Promise.all([
|
275
|
+
buildProviderModelLists(enabledChatAiProviders, enabledAiModels, 'chat'),
|
276
|
+
buildProviderModelLists(enabledImageAiProviders, enabledAiModels, 'image'),
|
277
|
+
]);
|
278
|
+
|
279
|
+
return {
|
280
|
+
builtinAiModelList,
|
281
|
+
enabledAiModels,
|
282
|
+
enabledAiProviders,
|
283
|
+
enabledChatAiProviders,
|
284
|
+
enabledChatModelList,
|
285
|
+
enabledImageAiProviders,
|
286
|
+
enabledImageModelList,
|
231
287
|
runtimeConfig: {},
|
232
288
|
};
|
233
289
|
},
|
@@ -236,26 +292,14 @@ export const createAiProviderSlice: StateCreator<
|
|
236
292
|
onSuccess: (data) => {
|
237
293
|
if (!data) return;
|
238
294
|
|
239
|
-
const enabledChatModelList = data.enabledChatAiProviders.map((provider) => ({
|
240
|
-
...provider,
|
241
|
-
children: getModelListByType(data.enabledAiModels, provider.id, 'chat'),
|
242
|
-
name: provider.name || provider.id,
|
243
|
-
}));
|
244
|
-
|
245
|
-
const enabledImageModelList = data.enabledImageAiProviders.map((provider) => ({
|
246
|
-
...provider,
|
247
|
-
children: getModelListByType(data.enabledAiModels, provider.id, 'image'),
|
248
|
-
name: provider.name || provider.id,
|
249
|
-
}));
|
250
|
-
|
251
295
|
set(
|
252
296
|
{
|
253
297
|
aiProviderRuntimeConfig: data.runtimeConfig,
|
254
298
|
builtinAiModelList: data.builtinAiModelList,
|
255
299
|
enabledAiModels: data.enabledAiModels,
|
256
300
|
enabledAiProviders: data.enabledAiProviders,
|
257
|
-
enabledChatModelList,
|
258
|
-
enabledImageModelList,
|
301
|
+
enabledChatModelList: data.enabledChatModelList || [],
|
302
|
+
enabledImageModelList: data.enabledImageModelList || [],
|
259
303
|
},
|
260
304
|
false,
|
261
305
|
'useFetchAiProviderRuntimeState',
|