@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.
@@ -12,6 +12,7 @@ import { useFileStore } from '@/store/file';
12
12
  import { FileUploadStatus } from '@/types/files/upload';
13
13
 
14
14
  import { useDragAndDrop } from '../hooks/useDragAndDrop';
15
+ import { useUploadFilesValidation } from '../hooks/useUploadFilesValidation';
15
16
  import { useConfigPanelStyles } from '../style';
16
17
 
17
18
  // ======== Business Types ======== //
@@ -19,6 +20,7 @@ import { useConfigPanelStyles } from '../style';
19
20
  export interface ImageUploadProps {
20
21
  // Callback when URL changes
21
22
  className?: string; // Image URL
23
+ maxFileSize?: number;
22
24
  onChange?: (url?: string) => void;
23
25
  style?: React.CSSProperties;
24
26
  value?: string | null;
@@ -414,212 +416,221 @@ SuccessDisplay.displayName = 'SuccessDisplay';
414
416
 
415
417
  // ======== Main Component ======== //
416
418
 
417
- const ImageUpload: FC<ImageUploadProps> = memo(({ value, onChange, style, className }) => {
418
- const inputRef = useRef<HTMLInputElement>(null);
419
- const uploadWithProgress = useFileStore((s) => s.uploadWithProgress);
420
- const [uploadState, setUploadState] = useState<UploadState | null>(null);
421
- const { t } = useTranslation('components');
422
- const { message } = App.useApp();
423
-
424
- // Cleanup blob URLs to prevent memory leaks
425
- useEffect(() => {
426
- return () => {
427
- if (uploadState?.previewUrl && isLocalBlobUrl(uploadState.previewUrl)) {
428
- URL.revokeObjectURL(uploadState.previewUrl);
429
- }
419
+ const ImageUpload: FC<ImageUploadProps> = memo(
420
+ ({ value, onChange, style, className, maxFileSize }) => {
421
+ const inputRef = useRef<HTMLInputElement>(null);
422
+ const uploadWithProgress = useFileStore((s) => s.uploadWithProgress);
423
+ const [uploadState, setUploadState] = useState<UploadState | null>(null);
424
+ const { t } = useTranslation('components');
425
+ const { message } = App.useApp();
426
+ const { validateFiles } = useUploadFilesValidation(undefined, maxFileSize);
427
+
428
+ // Cleanup blob URLs to prevent memory leaks
429
+ useEffect(() => {
430
+ return () => {
431
+ if (uploadState?.previewUrl && isLocalBlobUrl(uploadState.previewUrl)) {
432
+ URL.revokeObjectURL(uploadState.previewUrl);
433
+ }
434
+ };
435
+ }, [uploadState?.previewUrl]);
436
+
437
+ const handleFileSelect = () => {
438
+ inputRef.current?.click();
430
439
  };
431
- }, [uploadState?.previewUrl]);
432
440
 
433
- const handleFileSelect = () => {
434
- inputRef.current?.click();
435
- };
441
+ const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
442
+ const file = event.target.files?.[0];
443
+ if (!file) return;
436
444
 
437
- const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
438
- const file = event.target.files?.[0];
439
- if (!file) return;
445
+ // Validate file using unified validation hook
446
+ if (!validateFiles([file])) return;
440
447
 
441
- // Create preview URL
442
- const previewUrl = URL.createObjectURL(file);
448
+ // Create preview URL
449
+ const previewUrl = URL.createObjectURL(file);
443
450
 
444
- // Set initial upload state
445
- setUploadState({
446
- previewUrl,
447
- progress: 0,
448
- status: 'pending',
449
- });
450
-
451
- try {
452
- // Start upload
453
- const result = await uploadWithProgress({
454
- file,
455
- onStatusUpdate: (updateData) => {
456
- if (updateData.type === 'updateFile') {
457
- setUploadState((prev) => {
458
- if (!prev) return null;
459
-
460
- const fileStatus = updateData.value.status;
461
- if (!fileStatus) return prev;
462
-
463
- return {
464
- ...prev,
465
- error: fileStatus === 'error' ? 'Upload failed' : undefined,
466
- progress: updateData.value.uploadState?.progress || 0,
467
- status: fileStatus,
468
- };
469
- });
470
- } else if (updateData.type === 'removeFile') {
471
- // Handle file removal
472
- setUploadState(null);
473
- }
474
- },
475
- skipCheckFileType: true,
451
+ // Set initial upload state
452
+ setUploadState({
453
+ previewUrl,
454
+ progress: 0,
455
+ status: 'pending',
476
456
  });
477
457
 
478
- if (result?.url) {
479
- // Upload successful
480
- onChange?.(result.url);
481
- }
482
- } catch {
483
- // Upload failed
484
- setUploadState((prev) =>
485
- prev
486
- ? {
487
- ...prev,
488
- error: 'Upload failed',
489
- status: 'error',
458
+ try {
459
+ // Start upload
460
+ const result = await uploadWithProgress({
461
+ file,
462
+ onStatusUpdate: (updateData) => {
463
+ if (updateData.type === 'updateFile') {
464
+ setUploadState((prev) => {
465
+ if (!prev) return null;
466
+
467
+ const fileStatus = updateData.value.status;
468
+ if (!fileStatus) return prev;
469
+
470
+ return {
471
+ ...prev,
472
+ error: fileStatus === 'error' ? 'Upload failed' : undefined,
473
+ progress: updateData.value.uploadState?.progress || 0,
474
+ status: fileStatus,
475
+ };
476
+ });
477
+ } else if (updateData.type === 'removeFile') {
478
+ // Handle file removal
479
+ setUploadState(null);
490
480
  }
491
- : null,
492
- );
493
- } finally {
494
- // Cleanup
495
- if (isLocalBlobUrl(previewUrl)) {
496
- URL.revokeObjectURL(previewUrl);
481
+ },
482
+ skipCheckFileType: true,
483
+ });
484
+
485
+ if (result?.url) {
486
+ // Upload successful
487
+ onChange?.(result.url);
488
+ }
489
+ } catch {
490
+ // Upload failed
491
+ setUploadState((prev) =>
492
+ prev
493
+ ? {
494
+ ...prev,
495
+ error: 'Upload failed',
496
+ status: 'error',
497
+ }
498
+ : null,
499
+ );
500
+ } finally {
501
+ // Cleanup
502
+ if (isLocalBlobUrl(previewUrl)) {
503
+ URL.revokeObjectURL(previewUrl);
504
+ }
505
+
506
+ // Clear upload state after a delay to show completion
507
+ setTimeout(() => {
508
+ setUploadState(null);
509
+ }, 1000);
497
510
  }
511
+ };
498
512
 
499
- // Clear upload state after a delay to show completion
500
- setTimeout(() => {
501
- setUploadState(null);
502
- }, 1000);
503
- }
504
- };
505
-
506
- const handleDelete = () => {
507
- onChange?.(undefined);
508
- };
509
-
510
- const handleDrop = async (files: File[]) => {
511
- // Show warning if multiple files detected
512
- if (files.length > 1) {
513
- message.warning(t('ImageUpload.actions.dropMultipleFiles'));
514
- }
515
-
516
- // Take the first image file
517
- const file = files[0];
513
+ const handleDelete = () => {
514
+ onChange?.(undefined);
515
+ };
518
516
 
519
- // Create preview URL
520
- const previewUrl = URL.createObjectURL(file);
517
+ const handleDrop = async (files: File[]) => {
518
+ // Show warning if multiple files detected
519
+ if (files.length > 1) {
520
+ message.warning(t('ImageUpload.actions.dropMultipleFiles'));
521
+ }
521
522
 
522
- // Set initial upload state
523
- setUploadState({
524
- previewUrl,
525
- progress: 0,
526
- status: 'pending',
527
- });
523
+ // Take the first image file
524
+ const file = files[0];
528
525
 
529
- try {
530
- // Start upload using the same logic as handleFileChange
531
- const result = await uploadWithProgress({
532
- file,
533
- onStatusUpdate: (updateData) => {
534
- if (updateData.type === 'updateFile') {
535
- setUploadState((prev) => {
536
- if (!prev) return null;
526
+ // Validate file using unified validation hook
527
+ if (!validateFiles([file])) return;
537
528
 
538
- const fileStatus = updateData.value.status;
539
- if (!fileStatus) return prev;
529
+ // Create preview URL
530
+ const previewUrl = URL.createObjectURL(file);
540
531
 
541
- return {
542
- ...prev,
543
- error: fileStatus === 'error' ? 'Upload failed' : undefined,
544
- progress: updateData.value.uploadState?.progress || 0,
545
- status: fileStatus,
546
- };
547
- });
548
- } else if (updateData.type === 'removeFile') {
549
- setUploadState(null);
550
- }
551
- },
552
- skipCheckFileType: true,
532
+ // Set initial upload state
533
+ setUploadState({
534
+ previewUrl,
535
+ progress: 0,
536
+ status: 'pending',
553
537
  });
554
538
 
555
- if (result?.url) {
556
- // Upload successful
557
- onChange?.(result.url);
558
- }
559
- } catch {
560
- // Upload failed
561
- setUploadState((prev) =>
562
- prev
563
- ? {
564
- ...prev,
565
- error: 'Upload failed',
566
- status: 'error',
539
+ try {
540
+ // Start upload using the same logic as handleFileChange
541
+ const result = await uploadWithProgress({
542
+ file,
543
+ onStatusUpdate: (updateData) => {
544
+ if (updateData.type === 'updateFile') {
545
+ setUploadState((prev) => {
546
+ if (!prev) return null;
547
+
548
+ const fileStatus = updateData.value.status;
549
+ if (!fileStatus) return prev;
550
+
551
+ return {
552
+ ...prev,
553
+ error: fileStatus === 'error' ? 'Upload failed' : undefined,
554
+ progress: updateData.value.uploadState?.progress || 0,
555
+ status: fileStatus,
556
+ };
557
+ });
558
+ } else if (updateData.type === 'removeFile') {
559
+ setUploadState(null);
567
560
  }
568
- : null,
569
- );
570
- } finally {
571
- // Cleanup
572
- if (isLocalBlobUrl(previewUrl)) {
573
- URL.revokeObjectURL(previewUrl);
561
+ },
562
+ skipCheckFileType: true,
563
+ });
564
+
565
+ if (result?.url) {
566
+ // Upload successful
567
+ onChange?.(result.url);
568
+ }
569
+ } catch {
570
+ // Upload failed
571
+ setUploadState((prev) =>
572
+ prev
573
+ ? {
574
+ ...prev,
575
+ error: 'Upload failed',
576
+ status: 'error',
577
+ }
578
+ : null,
579
+ );
580
+ } finally {
581
+ // Cleanup
582
+ if (isLocalBlobUrl(previewUrl)) {
583
+ URL.revokeObjectURL(previewUrl);
584
+ }
585
+
586
+ // Clear upload state after a delay to show completion
587
+ setTimeout(() => {
588
+ setUploadState(null);
589
+ }, 1000);
574
590
  }
591
+ };
575
592
 
576
- // Clear upload state after a delay to show completion
577
- setTimeout(() => {
578
- setUploadState(null);
579
- }, 1000);
580
- }
581
- };
582
-
583
- const { isDragOver, dragHandlers } = useDragAndDrop({
584
- accept: 'image/*',
585
- onDrop: handleDrop,
586
- });
587
-
588
- // Determine which view to render
589
- const hasImage = Boolean(value);
590
- const isUploading = Boolean(uploadState);
593
+ const { isDragOver, dragHandlers } = useDragAndDrop({
594
+ accept: 'image/*',
595
+ onDrop: handleDrop,
596
+ });
591
597
 
592
- return (
593
- <div className={className} {...dragHandlers} style={style}>
594
- {/* Hidden file input */}
595
- <input
596
- accept="image/*"
597
- onChange={handleFileChange}
598
- onClick={(e) => {
599
- // Reset value to allow re-selecting the same file
600
- e.currentTarget.value = '';
601
- }}
602
- ref={inputRef}
603
- style={{ display: 'none' }}
604
- type="file"
605
- />
598
+ // Determine which view to render
599
+ const hasImage = Boolean(value);
600
+ const isUploading = Boolean(uploadState);
606
601
 
607
- {/* Conditional rendering based on state */}
608
- {isUploading && uploadState ? (
609
- <UploadingDisplay previewUrl={uploadState.previewUrl} progress={uploadState.progress} />
610
- ) : hasImage ? (
611
- <SuccessDisplay
612
- imageUrl={value!}
613
- isDragOver={isDragOver}
614
- onChangeImage={handleFileSelect}
615
- onDelete={handleDelete}
602
+ return (
603
+ <div className={className} {...dragHandlers} style={style}>
604
+ {/* Hidden file input */}
605
+ <input
606
+ accept="image/*"
607
+ onChange={handleFileChange}
608
+ onClick={(e) => {
609
+ // Reset value to allow re-selecting the same file
610
+ e.currentTarget.value = '';
611
+ }}
612
+ ref={inputRef}
613
+ style={{ display: 'none' }}
614
+ type="file"
616
615
  />
617
- ) : (
618
- <Placeholder isDragOver={isDragOver} onClick={handleFileSelect} />
619
- )}
620
- </div>
621
- );
622
- });
616
+
617
+ {/* Conditional rendering based on state */}
618
+ {isUploading && uploadState ? (
619
+ <UploadingDisplay previewUrl={uploadState.previewUrl} progress={uploadState.progress} />
620
+ ) : hasImage ? (
621
+ <SuccessDisplay
622
+ imageUrl={value!}
623
+ isDragOver={isDragOver}
624
+ onChangeImage={handleFileSelect}
625
+ onDelete={handleDelete}
626
+ />
627
+ ) : (
628
+ <Placeholder isDragOver={isDragOver} onClick={handleFileSelect} />
629
+ )}
630
+ </div>
631
+ );
632
+ },
633
+ );
623
634
 
624
635
  ImageUpload.displayName = 'ImageUpload';
625
636
 
@@ -5,14 +5,14 @@ import { useGenerationConfigParam } from '@/store/image/slices/generationConfig/
5
5
  import ImageUpload from './ImageUpload';
6
6
 
7
7
  const ImageUrl = memo(() => {
8
- const { value: imageUrl, setValue } = useGenerationConfigParam('imageUrl');
8
+ const { value: imageUrl, setValue, maxFileSize } = useGenerationConfigParam('imageUrl');
9
9
 
10
10
  // Extract the first URL from the array for single image display
11
11
  const handleChange = (url?: string) => {
12
12
  setValue(url ?? null);
13
13
  };
14
14
 
15
- return <ImageUpload onChange={handleChange} value={imageUrl} />;
15
+ return <ImageUpload maxFileSize={maxFileSize} onChange={handleChange} value={imageUrl} />;
16
16
  });
17
17
 
18
18
  export default ImageUrl;
@@ -2,18 +2,42 @@ import { memo } from 'react';
2
2
 
3
3
  import { useGenerationConfigParam } from '@/store/image/slices/generationConfig/hooks';
4
4
 
5
+ import ImageUpload from './ImageUpload';
5
6
  import MultiImagesUpload from './MultiImagesUpload';
6
7
 
7
8
  const ImageUrlsUpload = memo(() => {
8
- const { value, setValue } = useGenerationConfigParam('imageUrls');
9
+ const { value, setValue, maxCount, maxFileSize } = useGenerationConfigParam('imageUrls');
9
10
 
11
+ // When maxCount is 1, use ImageUpload for single image upload
12
+ if (maxCount === 1) {
13
+ const handleSingleChange = (url?: string) => {
14
+ setValue(url ? [url] : []);
15
+ };
16
+
17
+ return (
18
+ <ImageUpload
19
+ maxFileSize={maxFileSize}
20
+ onChange={handleSingleChange}
21
+ value={value?.[0] ?? null}
22
+ />
23
+ );
24
+ }
25
+
26
+ // Otherwise use MultiImagesUpload for multiple images
10
27
  const handleChange = (urls: string[]) => {
11
28
  // Directly set the URLs to the store
12
29
  // The store will handle URL to path conversion when needed
13
30
  setValue(urls);
14
31
  };
15
32
 
16
- return <MultiImagesUpload onChange={handleChange} value={value} />;
33
+ return (
34
+ <MultiImagesUpload
35
+ maxCount={maxCount}
36
+ maxFileSize={maxFileSize}
37
+ onChange={handleChange}
38
+ value={value}
39
+ />
40
+ );
17
41
  });
18
42
 
19
43
  export default ImageUrlsUpload;
@@ -7,30 +7,33 @@ import Image from 'next/image';
7
7
  import React, { type FC, memo, useEffect, useRef, useState } from 'react';
8
8
  import { useTranslation } from 'react-i18next';
9
9
 
10
+ import { useUploadFilesValidation } from '../../hooks/useUploadFilesValidation';
11
+
10
12
  // ======== Types ======== //
11
13
 
12
14
  /**
13
- * 统一的图片项数据结构
14
- * - url: 现有图片的远程URL
15
- * - file: 新选择的文件,需要上传
16
- * - previewUrl: 本地文件的预览URLblob URL
17
- * url的是现有图片,有file的是待上传文件
15
+ * Unified image item data structure
16
+ * - url: Remote URL of existing image
17
+ * - file: Newly selected file that needs to be uploaded
18
+ * - previewUrl: Preview URL for local file (blob URL)
19
+ * Items with url are existing images, items with file are files to be uploaded
18
20
  */
19
21
  export interface ImageItem {
20
- // 现有图片的URL
22
+ // URL of existing image
21
23
  file?: File;
22
24
  id: string;
23
- // 新选择的文件
25
+ // Newly selected file
24
26
  previewUrl?: string;
25
- url?: string; // 本地文件的预览URL,仅在file存在时使用
27
+ url?: string; // Preview URL for local file, only used when file exists
26
28
  }
27
29
 
28
30
  interface ImageManageModalProps {
29
31
  images: string[];
30
- // 现有图片URL数组
32
+ maxCount?: number;
33
+ // Array of existing image URLs
31
34
  onClose: () => void;
32
35
  onComplete: (imageItems: ImageItem[]) => void;
33
- open: boolean; // 统一的完成回调
36
+ open: boolean; // Unified completion callback
34
37
  }
35
38
 
36
39
  // ======== Utils ======== //
@@ -194,19 +197,20 @@ const useStyles = createStyles(({ css, token }) => ({
194
197
  // ======== Main Component ======== //
195
198
 
196
199
  const ImageManageModal: FC<ImageManageModalProps> = memo(
197
- ({ open, images, onClose, onComplete }) => {
200
+ ({ open, images, maxCount, onClose, onComplete }) => {
198
201
  const { styles } = useStyles();
199
202
  const { t } = useTranslation('components');
200
203
  const inputRef = useRef<HTMLInputElement>(null);
204
+ const { validateFiles } = useUploadFilesValidation(maxCount);
201
205
 
202
- // 使用统一的数据结构管理所有图片
206
+ // Use unified data structure to manage all images
203
207
  const [imageItems, setImageItems] = useState<ImageItem[]>([]);
204
208
  const [selectedIndex, setSelectedIndex] = useState<number>(0);
205
209
 
206
- // Modal 打开时初始化状态
210
+ // Initialize state when modal opens
207
211
  useEffect(() => {
208
212
  if (open) {
209
- // 将现有URL转换为ImageItem格式
213
+ // Convert existing URLs to ImageItem format
210
214
  const initialItems: ImageItem[] = images.map((url) => ({
211
215
  id: generateId(),
212
216
  url,
@@ -253,7 +257,7 @@ const ImageManageModal: FC<ImageManageModalProps> = memo(
253
257
  const newItems = imageItems.filter((_, i) => i !== index);
254
258
  setImageItems(newItems);
255
259
 
256
- // 调整选中索引
260
+ // Adjust selected index
257
261
  if (selectedIndex >= newItems.length) {
258
262
  setSelectedIndex(Math.max(0, newItems.length - 1));
259
263
  }
@@ -272,18 +276,23 @@ const ImageManageModal: FC<ImageManageModalProps> = memo(
272
276
  const files = event.target.files;
273
277
  if (!files || files.length === 0) return;
274
278
 
275
- // 创建新的ImageItem,为每个文件生成一次性的预览URL
279
+ // Validate files, pass current image count
280
+ if (!validateFiles(Array.from(files), imageItems.length)) {
281
+ return;
282
+ }
283
+
284
+ // Create new ImageItem, generate one-time preview URL for each file
276
285
  const newItems: ImageItem[] = Array.from(files).map((file) => ({
277
286
  file,
278
287
  id: generateId(),
279
- previewUrl: URL.createObjectURL(file), // 只创建一次
288
+ previewUrl: URL.createObjectURL(file), // Create only once
280
289
  }));
281
290
 
282
291
  setImageItems((prev) => [...prev, ...newItems]);
283
292
  };
284
293
 
285
294
  const handleComplete = () => {
286
- // 直接传递当前完整状态给父组件处理
295
+ // Directly pass current complete state to parent component
287
296
  onComplete(imageItems);
288
297
  onClose();
289
298
  };
@@ -397,7 +406,12 @@ const ImageManageModal: FC<ImageManageModalProps> = memo(
397
406
 
398
407
  {/* Footer */}
399
408
  <div className={styles.footer}>
400
- <Button icon={<Upload size={16} />} onClick={handleUpload} type="default">
409
+ <Button
410
+ disabled={maxCount ? imageItems.length >= maxCount : false}
411
+ icon={<Upload size={16} />}
412
+ onClick={handleUpload}
413
+ type="default"
414
+ >
401
415
  {t('MultiImagesUpload.modal.upload')}
402
416
  </Button>
403
417