@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.
Files changed (68) hide show
  1. package/.cursor/rules/project-introduce.mdc +1 -15
  2. package/.cursor/rules/project-structure.mdc +227 -0
  3. package/.cursor/rules/testing-guide/db-model-test.mdc +5 -3
  4. package/.cursor/rules/testing-guide/testing-guide.mdc +153 -168
  5. package/.github/workflows/claude.yml +1 -1
  6. package/.github/workflows/test.yml +9 -0
  7. package/.prettierignore +0 -1
  8. package/.vscode/settings.json +86 -80
  9. package/CHANGELOG.md +50 -0
  10. package/CLAUDE.md +11 -27
  11. package/changelog/v1.json +10 -0
  12. package/docs/development/basic/feature-development.mdx +1 -1
  13. package/docs/development/basic/feature-development.zh-CN.mdx +1 -1
  14. package/package.json +5 -5
  15. package/packages/const/src/image.ts +28 -0
  16. package/packages/const/src/index.ts +1 -0
  17. package/packages/database/package.json +4 -2
  18. package/packages/database/src/repositories/aiInfra/index.ts +1 -1
  19. package/packages/database/tests/setup-db.ts +3 -0
  20. package/packages/database/vitest.config.mts +33 -0
  21. package/packages/model-runtime/src/utils/modelParse.ts +1 -1
  22. package/packages/utils/src/client/imageDimensions.test.ts +95 -0
  23. package/packages/utils/src/client/imageDimensions.ts +54 -0
  24. package/packages/utils/src/number.test.ts +3 -1
  25. package/packages/utils/src/number.ts +1 -2
  26. package/src/app/[variants]/(main)/files/[id]/page.tsx +0 -2
  27. package/src/app/[variants]/(main)/image/@menu/components/SeedNumberInput/index.tsx +1 -1
  28. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/DimensionControlGroup.tsx +0 -1
  29. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ImageUpload.tsx +206 -185
  30. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ImageUrl.tsx +16 -4
  31. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ImageUrlsUpload.tsx +52 -3
  32. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/MultiImagesUpload/ImageManageModal.tsx +33 -19
  33. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/MultiImagesUpload/index.tsx +40 -12
  34. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/hooks/useAutoDimensions.ts +56 -0
  35. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/hooks/useUploadFilesValidation.ts +77 -0
  36. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/index.tsx +82 -5
  37. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/utils/__tests__/dimensionConstraints.test.ts +235 -0
  38. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/utils/__tests__/imageValidation.test.ts +401 -0
  39. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/utils/dimensionConstraints.ts +54 -0
  40. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/utils/imageValidation.ts +117 -0
  41. package/src/app/[variants]/(main)/image/@topic/features/Topics/TopicItem.tsx +3 -1
  42. package/src/app/[variants]/(main)/image/@topic/features/Topics/TopicList.tsx +15 -2
  43. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/utils.ts +5 -4
  44. package/src/libs/standard-parameters/index.ts +4 -1
  45. package/src/locales/default/components.ts +8 -0
  46. package/src/server/services/generation/index.ts +1 -1
  47. package/src/store/aiInfra/slices/aiProvider/__tests__/action.test.ts +29 -29
  48. package/src/store/aiInfra/slices/aiProvider/action.ts +80 -36
  49. package/src/store/chat/slices/builtinTool/actions/dalle.test.ts +20 -13
  50. package/src/store/file/slices/upload/action.ts +18 -7
  51. package/src/store/image/slices/generationConfig/hooks.ts +11 -1
  52. package/tsconfig.json +1 -10
  53. package/packages/const/src/imageGeneration.ts +0 -16
  54. package/src/app/(backend)/trpc/desktop/[trpc]/route.ts +0 -26
  55. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/AspectRatioSelect.tsx +0 -24
  56. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/SizeSliderInput.tsx +0 -15
  57. package/src/app/[variants]/(main)/image/@topic/features/Topics/TopicItemContainer.tsx +0 -91
  58. package/src/app/desktop/devtools/page.tsx +0 -89
  59. package/src/app/desktop/layout.tsx +0 -31
  60. /package/apps/desktop/{vitest.config.ts → vitest.config.mts} +0 -0
  61. /package/packages/database/{vitest.config.ts → vitest.config.server.mts} +0 -0
  62. /package/packages/electron-server-ipc/{vitest.config.ts → vitest.config.mts} +0 -0
  63. /package/packages/file-loaders/{vitest.config.ts → vitest.config.mts} +0 -0
  64. /package/packages/model-runtime/{vitest.config.ts → vitest.config.mts} +0 -0
  65. /package/packages/prompts/{vitest.config.ts → vitest.config.mts} +0 -0
  66. /package/packages/utils/{vitest.config.ts → vitest.config.mts} +0 -0
  67. /package/packages/web-crawler/{vitest.config.ts → vitest.config.mts} +0 -0
  68. /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: 现有图片的远程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
 
@@ -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
- onChange?: (urls: string[]) => void;
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
- onChange?.(updatedUrls);
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
- // 处理 Modal 完成回调
746
+ // Handle Modal completion callback
720
747
  const handleModalComplete = async (imageItems: ImageItem[]) => {
721
- // 分离现有URL和新文件
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
- // 立即更新现有URL(删除的图片会被过滤掉)
753
+ // Immediately update existing URLs (deleted images will be filtered out)
727
754
  onChange?.(existingUrls);
728
755
 
729
- // 如果有新文件需要上传,基于 existingUrls 启动上传流程
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
+ };
@@ -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 { ReactNode, memo } from 'react';
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 gap={32} padding={12} style={{ overflow: 'auto' }}>
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
- <ConfigItemLayout label={t('config.imageNum.label')}>
87
- <ImageNum />
88
- </ConfigItemLayout>
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
  });