@lobehub/chat 1.114.5 → 1.115.0
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/.cursor/rules/project-introduce.mdc +1 -15
- package/.cursor/rules/project-structure.mdc +227 -0
- package/.cursor/rules/testing-guide/db-model-test.mdc +5 -3
- package/.cursor/rules/testing-guide/testing-guide.mdc +153 -168
- package/.github/workflows/claude.yml +1 -1
- package/.github/workflows/test.yml +9 -0
- package/.prettierignore +0 -1
- package/.vscode/settings.json +86 -80
- package/CHANGELOG.md +50 -0
- package/CLAUDE.md +11 -27
- package/changelog/v1.json +10 -0
- package/docs/development/basic/feature-development.mdx +1 -1
- package/docs/development/basic/feature-development.zh-CN.mdx +1 -1
- package/package.json +5 -5
- package/packages/const/src/image.ts +28 -0
- package/packages/const/src/index.ts +1 -0
- package/packages/database/package.json +4 -2
- package/packages/database/src/repositories/aiInfra/index.ts +1 -1
- package/packages/database/tests/setup-db.ts +3 -0
- package/packages/database/vitest.config.mts +33 -0
- package/packages/model-runtime/src/utils/modelParse.ts +1 -1
- package/packages/utils/src/client/imageDimensions.test.ts +95 -0
- package/packages/utils/src/client/imageDimensions.ts +54 -0
- package/packages/utils/src/number.test.ts +3 -1
- package/packages/utils/src/number.ts +1 -2
- package/src/app/[variants]/(main)/files/[id]/page.tsx +0 -2
- package/src/app/[variants]/(main)/image/@menu/components/SeedNumberInput/index.tsx +1 -1
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/DimensionControlGroup.tsx +0 -1
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ImageUpload.tsx +206 -185
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ImageUrl.tsx +16 -4
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ImageUrlsUpload.tsx +52 -3
- 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 +40 -12
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/hooks/useAutoDimensions.ts +56 -0
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/hooks/useUploadFilesValidation.ts +77 -0
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/index.tsx +82 -5
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/utils/__tests__/dimensionConstraints.test.ts +235 -0
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/utils/__tests__/imageValidation.test.ts +401 -0
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/utils/dimensionConstraints.ts +54 -0
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/utils/imageValidation.ts +117 -0
- package/src/app/[variants]/(main)/image/@topic/features/Topics/TopicItem.tsx +3 -1
- package/src/app/[variants]/(main)/image/@topic/features/Topics/TopicList.tsx +15 -2
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/utils.ts +5 -4
- package/src/libs/standard-parameters/index.ts +4 -1
- package/src/locales/default/components.ts +8 -0
- package/src/server/services/generation/index.ts +1 -1
- 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/chat/slices/builtinTool/actions/dalle.test.ts +20 -13
- package/src/store/file/slices/upload/action.ts +18 -7
- package/src/store/image/slices/generationConfig/hooks.ts +11 -1
- package/tsconfig.json +1 -10
- package/packages/const/src/imageGeneration.ts +0 -16
- package/src/app/(backend)/trpc/desktop/[trpc]/route.ts +0 -26
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/AspectRatioSelect.tsx +0 -24
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/SizeSliderInput.tsx +0 -15
- package/src/app/[variants]/(main)/image/@topic/features/Topics/TopicItemContainer.tsx +0 -91
- package/src/app/desktop/devtools/page.tsx +0 -89
- package/src/app/desktop/layout.tsx +0 -31
- /package/apps/desktop/{vitest.config.ts → vitest.config.mts} +0 -0
- /package/packages/database/{vitest.config.ts → vitest.config.server.mts} +0 -0
- /package/packages/electron-server-ipc/{vitest.config.ts → vitest.config.mts} +0 -0
- /package/packages/file-loaders/{vitest.config.ts → vitest.config.mts} +0 -0
- /package/packages/model-runtime/{vitest.config.ts → vitest.config.mts} +0 -0
- /package/packages/prompts/{vitest.config.ts → vitest.config.mts} +0 -0
- /package/packages/utils/{vitest.config.ts → vitest.config.mts} +0 -0
- /package/packages/web-crawler/{vitest.config.ts → vitest.config.mts} +0 -0
- /package/{vitest.config.ts → vitest.config.mts} +0 -0
@@ -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:
|
15
|
-
* - file:
|
16
|
-
* - previewUrl:
|
17
|
-
*
|
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
|
-
//
|
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; //
|
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
|
-
|
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
|
-
//
|
210
|
+
// Initialize state when modal opens
|
207
211
|
useEffect(() => {
|
208
212
|
if (open) {
|
209
|
-
//
|
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
|
-
//
|
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
|
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
|
|
@@ -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
|
|
@@ -36,9 +37,15 @@ interface DisplayItem {
|
|
36
37
|
}
|
37
38
|
|
38
39
|
export interface MultiImagesUploadProps {
|
39
|
-
// Callback when URLs change
|
40
|
+
// Callback when URLs change - supports both old API (string[]) and new API (object with dimensions)
|
40
41
|
className?: string; // Array of image URLs
|
41
|
-
|
42
|
+
maxCount?: number;
|
43
|
+
maxFileSize?: number;
|
44
|
+
onChange?: (
|
45
|
+
data:
|
46
|
+
| string[] // Old API: just URLs
|
47
|
+
| { dimensions?: { height: number, width: number; }, urls: string[]; }, // New API: URLs with first image dimensions
|
48
|
+
) => void;
|
42
49
|
style?: React.CSSProperties;
|
43
50
|
value?: string[];
|
44
51
|
}
|
@@ -311,7 +318,7 @@ const ImageUploadPlaceholder: FC<ImageUploadPlaceholderProps> = memo(({ isDragOv
|
|
311
318
|
|
312
319
|
ImageUploadPlaceholder.displayName = 'ImageUploadPlaceholder';
|
313
320
|
|
314
|
-
// ========
|
321
|
+
// ======== Circular Progress Component ======== //
|
315
322
|
|
316
323
|
interface CircularProgressProps {
|
317
324
|
className?: string;
|
@@ -466,7 +473,7 @@ const ImageThumbnails: FC<ImageThumbnailsProps> = memo(
|
|
466
473
|
const showOverlay = isLastItem && remainingCount > 1;
|
467
474
|
|
468
475
|
return (
|
469
|
-
<div className={styles.imageItem} key={imageUrl}>
|
476
|
+
<div className={styles.imageItem} key={`${imageUrl}-${index}`}>
|
470
477
|
<Image
|
471
478
|
alt={`Uploaded image ${index + 1}`}
|
472
479
|
fill
|
@@ -559,12 +566,13 @@ SingleImageDisplay.displayName = 'SingleImageDisplay';
|
|
559
566
|
// ======== Main Component ======== //
|
560
567
|
|
561
568
|
const MultiImagesUpload: FC<MultiImagesUploadProps> = memo(
|
562
|
-
({ value, onChange, style, className }) => {
|
569
|
+
({ value, onChange, style, className, maxCount, maxFileSize }) => {
|
563
570
|
const inputRef = useRef<HTMLInputElement>(null);
|
564
571
|
const uploadWithProgress = useFileStore((s) => s.uploadWithProgress);
|
565
572
|
const [displayItems, setDisplayItems] = useState<DisplayItem[]>([]);
|
566
573
|
const [modalOpen, setModalOpen] = useState(false);
|
567
574
|
const { styles: configStyles } = useConfigPanelStyles();
|
575
|
+
const { validateFiles } = useUploadFilesValidation(maxCount, maxFileSize);
|
568
576
|
|
569
577
|
// Cleanup blob URLs to prevent memory leaks
|
570
578
|
useEffect(() => {
|
@@ -601,6 +609,11 @@ const MultiImagesUpload: FC<MultiImagesUploadProps> = memo(
|
|
601
609
|
|
602
610
|
const currentUrls = baseUrls !== undefined ? baseUrls : value || [];
|
603
611
|
|
612
|
+
// Validate files, pass current image count
|
613
|
+
if (!validateFiles(files, currentUrls.length)) {
|
614
|
+
return;
|
615
|
+
}
|
616
|
+
|
604
617
|
// Create initial display items with blob URLs for immediate preview
|
605
618
|
const newDisplayItems: DisplayItem[] = files.map((file) => ({
|
606
619
|
file,
|
@@ -639,9 +652,10 @@ const MultiImagesUpload: FC<MultiImagesUploadProps> = memo(
|
|
639
652
|
}),
|
640
653
|
);
|
641
654
|
|
642
|
-
// Wait for all uploads to complete and collect successful URLs
|
655
|
+
// Wait for all uploads to complete and collect successful URLs and dimensions
|
643
656
|
const uploadResults = await Promise.allSettled(uploadPromises);
|
644
657
|
const successfulUrls: string[] = [];
|
658
|
+
let firstImageDimensions: { height: number, width: number; } | undefined;
|
645
659
|
|
646
660
|
uploadResults.forEach((result, index) => {
|
647
661
|
const displayItem = newDisplayItems[index];
|
@@ -649,6 +663,11 @@ const MultiImagesUpload: FC<MultiImagesUploadProps> = memo(
|
|
649
663
|
if (result.status === 'fulfilled' && result.value) {
|
650
664
|
successfulUrls.push(result.value.url);
|
651
665
|
|
666
|
+
// Collect the first image's dimensions for auto-setting parameters
|
667
|
+
if (index === 0 && result.value.dimensions) {
|
668
|
+
firstImageDimensions = result.value.dimensions;
|
669
|
+
}
|
670
|
+
|
652
671
|
// Update display item with final URL and success status
|
653
672
|
setDisplayItems((prev) =>
|
654
673
|
prev.map((item) =>
|
@@ -685,10 +704,18 @@ const MultiImagesUpload: FC<MultiImagesUploadProps> = memo(
|
|
685
704
|
}
|
686
705
|
});
|
687
706
|
|
688
|
-
// Update parent component with new URLs
|
707
|
+
// Update parent component with new URLs and dimensions (if applicable)
|
689
708
|
if (successfulUrls.length > 0) {
|
690
709
|
const updatedUrls = [...currentUrls, ...successfulUrls];
|
691
|
-
|
710
|
+
|
711
|
+
// Pass dimensions if this is the first upload (no existing images) and only one image uploaded
|
712
|
+
const shouldPassDimensions = currentUrls.length === 0 && successfulUrls.length === 1;
|
713
|
+
|
714
|
+
if (shouldPassDimensions && firstImageDimensions) {
|
715
|
+
onChange?.({ dimensions: firstImageDimensions, urls: updatedUrls });
|
716
|
+
} else {
|
717
|
+
onChange?.(updatedUrls);
|
718
|
+
}
|
692
719
|
}
|
693
720
|
|
694
721
|
// Clear display items after all uploads complete
|
@@ -716,17 +743,17 @@ const MultiImagesUpload: FC<MultiImagesUploadProps> = memo(
|
|
716
743
|
onDrop: handleDrop,
|
717
744
|
});
|
718
745
|
|
719
|
-
//
|
746
|
+
// Handle Modal completion callback
|
720
747
|
const handleModalComplete = async (imageItems: ImageItem[]) => {
|
721
|
-
//
|
748
|
+
// Separate existing URLs and new files
|
722
749
|
const existingUrls = imageItems.filter((item) => item.url).map((item) => item.url!);
|
723
750
|
|
724
751
|
const newFiles = imageItems.filter((item) => item.file).map((item) => item.file!);
|
725
752
|
|
726
|
-
//
|
753
|
+
// Immediately update existing URLs (deleted images will be filtered out)
|
727
754
|
onChange?.(existingUrls);
|
728
755
|
|
729
|
-
//
|
756
|
+
// If there are new files to upload, start upload process based on existingUrls
|
730
757
|
if (newFiles.length > 0) {
|
731
758
|
await handleFilesSelected(newFiles, existingUrls);
|
732
759
|
}
|
@@ -793,6 +820,7 @@ const MultiImagesUpload: FC<MultiImagesUploadProps> = memo(
|
|
793
820
|
{/* Image Management Modal */}
|
794
821
|
<ImageManageModal
|
795
822
|
images={value || []}
|
823
|
+
maxCount={maxCount}
|
796
824
|
onClose={handleCloseModal}
|
797
825
|
onComplete={handleModalComplete}
|
798
826
|
open={modalOpen}
|
@@ -0,0 +1,56 @@
|
|
1
|
+
import { DEFAULT_DIMENSION_CONSTRAINTS } from '@lobechat/const';
|
2
|
+
|
3
|
+
import { constrainDimensions } from '@/app/[variants]/(main)/image/@menu/features/ConfigPanel/utils/dimensionConstraints';
|
4
|
+
import { useImageStore } from '@/store/image';
|
5
|
+
import { imageGenerationConfigSelectors } from '@/store/image/slices/generationConfig/selectors';
|
6
|
+
|
7
|
+
/**
|
8
|
+
* Extract URL and dimensions from callback data (supports both old and new API)
|
9
|
+
*/
|
10
|
+
const extractUrlAndDimensions = (
|
11
|
+
data?: string | { dimensions?: { height: number; width: number }; url: string },
|
12
|
+
) => {
|
13
|
+
const url = typeof data === 'string' ? data : data?.url;
|
14
|
+
const dimensions = typeof data === 'object' ? data?.dimensions : undefined;
|
15
|
+
return { dimensions, url };
|
16
|
+
};
|
17
|
+
|
18
|
+
/**
|
19
|
+
* Custom hook for automatically setting image dimensions with model constraints
|
20
|
+
* @returns Function to auto-set dimensions and type processing utilities
|
21
|
+
*/
|
22
|
+
export const useAutoDimensions = () => {
|
23
|
+
const paramsSchema = useImageStore(imageGenerationConfigSelectors.parametersSchema);
|
24
|
+
const isSupportWidth = useImageStore(imageGenerationConfigSelectors.isSupportedParam('width'));
|
25
|
+
const isSupportHeight = useImageStore(imageGenerationConfigSelectors.isSupportedParam('height'));
|
26
|
+
const setWidth = useImageStore((s) => s.setWidth);
|
27
|
+
const setHeight = useImageStore((s) => s.setHeight);
|
28
|
+
|
29
|
+
/**
|
30
|
+
* Auto-set dimensions with model constraints if parameters are supported
|
31
|
+
*/
|
32
|
+
const autoSetDimensions = (dimensions: { height: number; width: number }) => {
|
33
|
+
if (!isSupportWidth || !isSupportHeight) return;
|
34
|
+
|
35
|
+
const constraints = {
|
36
|
+
height: {
|
37
|
+
max: paramsSchema.height?.max || DEFAULT_DIMENSION_CONSTRAINTS.MAX_SIZE,
|
38
|
+
min: paramsSchema.height?.min || DEFAULT_DIMENSION_CONSTRAINTS.MIN_SIZE,
|
39
|
+
},
|
40
|
+
width: {
|
41
|
+
max: paramsSchema.width?.max || DEFAULT_DIMENSION_CONSTRAINTS.MAX_SIZE,
|
42
|
+
min: paramsSchema.width?.min || DEFAULT_DIMENSION_CONSTRAINTS.MIN_SIZE,
|
43
|
+
},
|
44
|
+
};
|
45
|
+
|
46
|
+
const adjusted = constrainDimensions(dimensions.width, dimensions.height, constraints);
|
47
|
+
setWidth(adjusted.width);
|
48
|
+
setHeight(adjusted.height);
|
49
|
+
};
|
50
|
+
|
51
|
+
return {
|
52
|
+
autoSetDimensions,
|
53
|
+
canAutoSet: isSupportWidth && isSupportHeight,
|
54
|
+
extractUrlAndDimensions,
|
55
|
+
};
|
56
|
+
};
|
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
|
+
};
|
@@ -1,7 +1,8 @@
|
|
1
1
|
'use client';
|
2
2
|
|
3
3
|
import { Text } from '@lobehub/ui';
|
4
|
-
import {
|
4
|
+
import { useTheme } from 'antd-style';
|
5
|
+
import { ReactNode, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
5
6
|
import { useTranslation } from 'react-i18next';
|
6
7
|
import { Flexbox } from 'react-layout-kit';
|
7
8
|
|
@@ -36,6 +37,9 @@ const isSupportedParamSelector = imageGenerationConfigSelectors.isSupportedParam
|
|
36
37
|
|
37
38
|
const ConfigPanel = memo(() => {
|
38
39
|
const { t } = useTranslation('image');
|
40
|
+
const theme = useTheme();
|
41
|
+
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
42
|
+
const [isScrollable, setIsScrollable] = useState(false);
|
39
43
|
|
40
44
|
const isSupportImageUrl = useImageStore(isSupportedParamSelector('imageUrl'));
|
41
45
|
const isSupportSize = useImageStore(isSupportedParamSelector('size'));
|
@@ -45,8 +49,79 @@ const ConfigPanel = memo(() => {
|
|
45
49
|
|
46
50
|
const { showDimensionControl } = useDimensionControl();
|
47
51
|
|
52
|
+
// Check if content exceeds container height and needs scrolling
|
53
|
+
const checkScrollable = useCallback(() => {
|
54
|
+
const container = scrollContainerRef.current;
|
55
|
+
if (container) {
|
56
|
+
const hasScrollbar = container.scrollHeight > container.clientHeight;
|
57
|
+
setIsScrollable(hasScrollbar);
|
58
|
+
}
|
59
|
+
}, []);
|
60
|
+
|
61
|
+
// Re-check when content changes
|
62
|
+
useEffect(() => {
|
63
|
+
checkScrollable();
|
64
|
+
}, [
|
65
|
+
checkScrollable,
|
66
|
+
isSupportImageUrl,
|
67
|
+
isSupportSize,
|
68
|
+
isSupportSeed,
|
69
|
+
isSupportSteps,
|
70
|
+
isSupportImageUrls,
|
71
|
+
showDimensionControl,
|
72
|
+
]);
|
73
|
+
|
74
|
+
// Setup observers for container changes
|
75
|
+
useEffect(() => {
|
76
|
+
const container = scrollContainerRef.current;
|
77
|
+
if (!container) return;
|
78
|
+
|
79
|
+
// Initial check
|
80
|
+
checkScrollable();
|
81
|
+
|
82
|
+
// Use ResizeObserver for container size changes
|
83
|
+
const resizeObserver = new ResizeObserver(checkScrollable);
|
84
|
+
resizeObserver.observe(container);
|
85
|
+
|
86
|
+
// Use MutationObserver for content changes
|
87
|
+
const mutationObserver = new MutationObserver(checkScrollable);
|
88
|
+
mutationObserver.observe(container, { childList: true, subtree: true });
|
89
|
+
|
90
|
+
return () => {
|
91
|
+
resizeObserver.disconnect();
|
92
|
+
mutationObserver.disconnect();
|
93
|
+
};
|
94
|
+
}, [checkScrollable]);
|
95
|
+
|
96
|
+
// Memoize sticky styles to prevent unnecessary re-renders
|
97
|
+
const stickyStyles = useMemo(
|
98
|
+
() => ({
|
99
|
+
bottom: 0,
|
100
|
+
position: 'sticky' as const,
|
101
|
+
zIndex: 1,
|
102
|
+
...(isScrollable && {
|
103
|
+
backgroundColor: theme.colorBgContainer,
|
104
|
+
borderTop: `1px solid ${theme.colorBorder}`,
|
105
|
+
// Use negative margin to extend background to container edges
|
106
|
+
marginLeft: -12,
|
107
|
+
|
108
|
+
marginRight: -12,
|
109
|
+
marginTop: 20,
|
110
|
+
// Add back internal padding
|
111
|
+
paddingLeft: 12,
|
112
|
+
paddingRight: 12,
|
113
|
+
}),
|
114
|
+
}),
|
115
|
+
[isScrollable, theme.colorBgContainer, theme.colorBorder],
|
116
|
+
);
|
117
|
+
|
48
118
|
return (
|
49
|
-
<Flexbox
|
119
|
+
<Flexbox
|
120
|
+
gap={32}
|
121
|
+
padding="12px 12px 0 12px"
|
122
|
+
ref={scrollContainerRef}
|
123
|
+
style={{ height: '100%', overflow: 'auto' }}
|
124
|
+
>
|
50
125
|
<ConfigItemLayout>
|
51
126
|
<ModelSelect />
|
52
127
|
</ConfigItemLayout>
|
@@ -83,9 +158,11 @@ const ConfigPanel = memo(() => {
|
|
83
158
|
</ConfigItemLayout>
|
84
159
|
)}
|
85
160
|
|
86
|
-
<
|
87
|
-
<
|
88
|
-
|
161
|
+
<Flexbox padding="12px 0" style={stickyStyles}>
|
162
|
+
<ConfigItemLayout label={t('config.imageNum.label')}>
|
163
|
+
<ImageNum />
|
164
|
+
</ConfigItemLayout>
|
165
|
+
</Flexbox>
|
89
166
|
</Flexbox>
|
90
167
|
);
|
91
168
|
});
|