@lobehub/chat 1.114.6 → 1.116.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 (152) hide show
  1. package/.cursor/rules/add-provider-doc.mdc +183 -0
  2. package/.cursor/rules/project-introduce.mdc +1 -15
  3. package/.cursor/rules/project-structure.mdc +227 -0
  4. package/.cursor/rules/testing-guide/db-model-test.mdc +5 -3
  5. package/.cursor/rules/testing-guide/testing-guide.mdc +153 -168
  6. package/.env.example +8 -0
  7. package/.github/workflows/claude.yml +1 -1
  8. package/.github/workflows/release.yml +3 -3
  9. package/.github/workflows/test.yml +10 -5
  10. package/CHANGELOG.md +50 -0
  11. package/CLAUDE.md +17 -33
  12. package/Dockerfile +5 -1
  13. package/Dockerfile.database +5 -1
  14. package/Dockerfile.pglite +5 -1
  15. package/changelog/v1.json +14 -0
  16. package/docs/development/basic/feature-development.mdx +1 -1
  17. package/docs/development/basic/feature-development.zh-CN.mdx +1 -1
  18. package/docs/development/basic/setup-development.mdx +10 -13
  19. package/docs/development/basic/setup-development.zh-CN.mdx +9 -12
  20. package/docs/self-hosting/environment-variables/model-provider.mdx +27 -2
  21. package/docs/self-hosting/environment-variables/model-provider.zh-CN.mdx +27 -2
  22. package/docs/usage/providers/bfl.mdx +68 -0
  23. package/docs/usage/providers/bfl.zh-CN.mdx +67 -0
  24. package/locales/ar/components.json +11 -0
  25. package/locales/ar/error.json +11 -0
  26. package/locales/ar/models.json +64 -4
  27. package/locales/ar/providers.json +3 -0
  28. package/locales/bg-BG/components.json +11 -0
  29. package/locales/bg-BG/error.json +11 -0
  30. package/locales/bg-BG/models.json +64 -4
  31. package/locales/bg-BG/providers.json +3 -0
  32. package/locales/de-DE/components.json +11 -0
  33. package/locales/de-DE/error.json +11 -12
  34. package/locales/de-DE/models.json +64 -4
  35. package/locales/de-DE/providers.json +3 -0
  36. package/locales/en-US/components.json +6 -0
  37. package/locales/en-US/error.json +11 -12
  38. package/locales/en-US/models.json +64 -4
  39. package/locales/en-US/providers.json +3 -0
  40. package/locales/es-ES/components.json +11 -0
  41. package/locales/es-ES/error.json +11 -0
  42. package/locales/es-ES/models.json +64 -6
  43. package/locales/es-ES/providers.json +3 -0
  44. package/locales/fa-IR/components.json +11 -0
  45. package/locales/fa-IR/error.json +11 -0
  46. package/locales/fa-IR/models.json +64 -4
  47. package/locales/fa-IR/providers.json +3 -0
  48. package/locales/fr-FR/components.json +11 -0
  49. package/locales/fr-FR/error.json +11 -12
  50. package/locales/fr-FR/models.json +64 -4
  51. package/locales/fr-FR/providers.json +3 -0
  52. package/locales/it-IT/components.json +11 -0
  53. package/locales/it-IT/error.json +11 -0
  54. package/locales/it-IT/models.json +64 -4
  55. package/locales/it-IT/providers.json +3 -0
  56. package/locales/ja-JP/components.json +11 -0
  57. package/locales/ja-JP/error.json +11 -12
  58. package/locales/ja-JP/models.json +64 -4
  59. package/locales/ja-JP/providers.json +3 -0
  60. package/locales/ko-KR/components.json +11 -0
  61. package/locales/ko-KR/error.json +11 -12
  62. package/locales/ko-KR/models.json +64 -6
  63. package/locales/ko-KR/providers.json +3 -0
  64. package/locales/nl-NL/components.json +11 -0
  65. package/locales/nl-NL/error.json +11 -0
  66. package/locales/nl-NL/models.json +62 -4
  67. package/locales/nl-NL/providers.json +3 -0
  68. package/locales/pl-PL/components.json +11 -0
  69. package/locales/pl-PL/error.json +11 -0
  70. package/locales/pl-PL/models.json +64 -4
  71. package/locales/pl-PL/providers.json +3 -0
  72. package/locales/pt-BR/components.json +11 -0
  73. package/locales/pt-BR/error.json +11 -0
  74. package/locales/pt-BR/models.json +64 -4
  75. package/locales/pt-BR/providers.json +3 -0
  76. package/locales/ru-RU/components.json +11 -0
  77. package/locales/ru-RU/error.json +11 -0
  78. package/locales/ru-RU/models.json +64 -4
  79. package/locales/ru-RU/providers.json +3 -0
  80. package/locales/tr-TR/components.json +11 -0
  81. package/locales/tr-TR/error.json +11 -0
  82. package/locales/tr-TR/models.json +64 -4
  83. package/locales/tr-TR/providers.json +3 -0
  84. package/locales/vi-VN/components.json +11 -0
  85. package/locales/vi-VN/error.json +11 -0
  86. package/locales/vi-VN/models.json +64 -4
  87. package/locales/vi-VN/providers.json +3 -0
  88. package/locales/zh-CN/components.json +6 -0
  89. package/locales/zh-CN/error.json +11 -0
  90. package/locales/zh-CN/models.json +64 -4
  91. package/locales/zh-CN/providers.json +3 -0
  92. package/locales/zh-TW/components.json +11 -0
  93. package/locales/zh-TW/error.json +11 -12
  94. package/locales/zh-TW/models.json +64 -6
  95. package/locales/zh-TW/providers.json +3 -0
  96. package/package.json +4 -4
  97. package/packages/const/src/image.ts +28 -0
  98. package/packages/const/src/index.ts +1 -0
  99. package/packages/database/package.json +4 -2
  100. package/packages/database/src/repositories/aiInfra/index.ts +1 -1
  101. package/packages/database/tests/setup-db.ts +3 -0
  102. package/packages/database/vitest.config.mts +33 -0
  103. package/packages/model-runtime/src/google/index.ts +3 -0
  104. package/packages/model-runtime/src/qwen/createImage.test.ts +0 -19
  105. package/packages/model-runtime/src/qwen/createImage.ts +1 -27
  106. package/packages/model-runtime/src/utils/modelParse.ts +1 -1
  107. package/packages/model-runtime/src/utils/streams/google-ai.ts +26 -14
  108. package/packages/types/src/aiModel.ts +2 -1
  109. package/packages/utils/src/client/imageDimensions.test.ts +95 -0
  110. package/packages/utils/src/client/imageDimensions.ts +54 -0
  111. package/packages/utils/src/number.test.ts +3 -1
  112. package/packages/utils/src/number.ts +1 -2
  113. package/src/app/[variants]/(main)/image/@menu/components/SeedNumberInput/index.tsx +1 -1
  114. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/DimensionControlGroup.tsx +0 -1
  115. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ImageUpload.tsx +16 -6
  116. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ImageUrl.tsx +14 -2
  117. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ImageUrlsUpload.tsx +27 -2
  118. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/MultiImagesUpload/index.tsx +23 -5
  119. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/hooks/useAutoDimensions.ts +56 -0
  120. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/index.tsx +82 -5
  121. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/utils/__tests__/dimensionConstraints.test.ts +235 -0
  122. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/utils/__tests__/imageValidation.test.ts +401 -0
  123. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/utils/dimensionConstraints.ts +54 -0
  124. package/src/app/[variants]/(main)/image/@topic/features/Topics/TopicItem.tsx +3 -1
  125. package/src/app/[variants]/(main)/image/@topic/features/Topics/TopicList.tsx +15 -2
  126. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/utils.ts +5 -4
  127. package/src/config/aiModels/google.ts +22 -1
  128. package/src/config/aiModels/qwen.ts +2 -2
  129. package/src/config/aiModels/vertexai.ts +22 -0
  130. package/src/libs/standard-parameters/index.ts +1 -1
  131. package/src/server/services/generation/index.ts +1 -1
  132. package/src/store/chat/slices/builtinTool/actions/dalle.test.ts +20 -13
  133. package/src/store/file/slices/upload/action.ts +18 -7
  134. package/src/store/image/slices/generationConfig/hooks.ts +1 -1
  135. package/tsconfig.json +1 -10
  136. package/.cursor/rules/debug.mdc +0 -193
  137. package/packages/const/src/imageGeneration.ts +0 -16
  138. package/src/app/(backend)/trpc/desktop/[trpc]/route.ts +0 -26
  139. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/AspectRatioSelect.tsx +0 -24
  140. package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/SizeSliderInput.tsx +0 -15
  141. package/src/app/[variants]/(main)/image/@topic/features/Topics/TopicItemContainer.tsx +0 -91
  142. package/src/app/desktop/devtools/page.tsx +0 -89
  143. package/src/app/desktop/layout.tsx +0 -31
  144. /package/apps/desktop/{vitest.config.ts → vitest.config.mts} +0 -0
  145. /package/packages/database/{vitest.config.ts → vitest.config.server.mts} +0 -0
  146. /package/packages/electron-server-ipc/{vitest.config.ts → vitest.config.mts} +0 -0
  147. /package/packages/file-loaders/{vitest.config.ts → vitest.config.mts} +0 -0
  148. /package/packages/model-runtime/{vitest.config.ts → vitest.config.mts} +0 -0
  149. /package/packages/prompts/{vitest.config.ts → vitest.config.mts} +0 -0
  150. /package/packages/utils/{vitest.config.ts → vitest.config.mts} +0 -0
  151. /package/packages/web-crawler/{vitest.config.ts → vitest.config.mts} +0 -0
  152. /package/{vitest.config.ts → vitest.config.mts} +0 -0
@@ -19,39 +19,13 @@ interface QwenImageTaskResponse {
19
19
  request_id: string;
20
20
  }
21
21
 
22
- const QwenText2ImageModels = [
23
- 'wan2.2-t2i',
24
- 'wanx2.1-t2i',
25
- 'wanx2.0-t2i',
26
- 'wanx-v1',
27
- 'flux',
28
- 'stable-diffusion',
29
- ];
30
-
31
- const getModelType = (model: string): string => {
32
- // 可以添加其他模型类型的判断
33
- // if (QwenImage2ImageModels.some(prefix => model.startsWith(prefix))) {
34
- // return 'image2image';
35
- // }
36
-
37
- if (QwenText2ImageModels.some((prefix) => model.startsWith(prefix))) {
38
- return 'text2image';
39
- }
40
-
41
- throw new Error(`Unsupported model: ${model}`);
42
- };
43
-
44
22
  /**
45
23
  * Create an image generation task with Qwen API
46
24
  */
47
25
  async function createImageTask(payload: CreateImagePayload, apiKey: string): Promise<string> {
48
26
  const { model, params } = payload;
49
27
  // I can only say that the design of Alibaba Cloud's API is really bad; each model has a different endpoint path.
50
- const modelType = getModelType(model);
51
- const endpoint = `https://dashscope.aliyuncs.com/api/v1/services/aigc/${modelType}/image-synthesis`;
52
- if (!endpoint) {
53
- throw new Error(`No endpoint configured for model type: ${modelType}`);
54
- }
28
+ const endpoint = `https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis`;
55
29
  log('Creating image task with model: %s, endpoint: %s', model, endpoint);
56
30
 
57
31
  const response = await fetch(endpoint, {
@@ -358,7 +358,7 @@ export const processMultiProviderModelList = async (
358
358
  let providerLocalConfig: any[] | null = null;
359
359
  if (providerid) {
360
360
  try {
361
- const moduleImport = await import(`@/config/aiModels/${providerid}`);
361
+ const moduleImport = await import(`@/config/aiModels/${providerid}.ts`);
362
362
  providerLocalConfig = moduleImport.default;
363
363
  } catch {
364
364
  // 如果配置文件不存在或导入失败,保持为 null
@@ -139,6 +139,31 @@ const transformGoogleGenerativeAIStream = (
139
139
  ];
140
140
  }
141
141
 
142
+ // Check for image data before handling finishReason
143
+ if (Array.isArray(candidate.content?.parts) && candidate.content.parts.length > 0) {
144
+ const part = candidate.content.parts[0];
145
+
146
+ if (part && part.inlineData && part.inlineData.data && part.inlineData.mimeType) {
147
+ const imageChunk = {
148
+ data: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`,
149
+ id: context.id,
150
+ type: 'base64_image' as const,
151
+ };
152
+
153
+ // If also has finishReason, combine image with finish chunks
154
+ if (candidate.finishReason) {
155
+ const chunks: StreamProtocolChunk[] = [imageChunk];
156
+ if (chunk.usageMetadata) {
157
+ chunks.push(...usageChunks);
158
+ }
159
+ chunks.push({ data: candidate.finishReason, id: context?.id, type: 'stop' });
160
+ return chunks;
161
+ }
162
+
163
+ return imageChunk;
164
+ }
165
+ }
166
+
142
167
  if (candidate.finishReason) {
143
168
  if (chunk.usageMetadata) {
144
169
  return [
@@ -150,23 +175,10 @@ const transformGoogleGenerativeAIStream = (
150
175
  }
151
176
 
152
177
  if (!!text?.trim()) return { data: text, id: context?.id, type: 'text' };
153
-
154
- // streaming the image
155
- if (Array.isArray(candidate.content?.parts) && candidate.content.parts.length > 0) {
156
- const part = candidate.content.parts[0];
157
-
158
- if (part && part.inlineData && part.inlineData.data && part.inlineData.mimeType) {
159
- return {
160
- data: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`,
161
- id: context.id,
162
- type: 'base64_image',
163
- };
164
- }
165
- }
166
178
  }
167
179
 
168
180
  return {
169
- data: text,
181
+ data: text || '',
170
182
  id: context?.id,
171
183
  type: 'text',
172
184
  };
@@ -121,7 +121,8 @@ export type PricingUnitName =
121
121
  | 'audioInput_cacheRead' // corresponds to ChatModelPricing.cachedAudioInput
122
122
 
123
123
  // Image-based pricing units
124
- | 'imageGeneration'; // for image generation models
124
+ | 'imageGeneration' // for image generation models
125
+ | 'imageOutput';
125
126
 
126
127
  export type PricingUnitType =
127
128
  | 'millionTokens' // per 1M tokens
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ import { getImageDimensions } from './imageDimensions';
7
+
8
+ // Mock functions - need to be accessible in tests
9
+ const mockAddEventListener = vi.fn();
10
+ let mockImage: ReturnType<typeof vi.fn>;
11
+ let mockCreateObjectURL: any;
12
+ let mockRevokeObjectURL: any;
13
+
14
+ // Store event handlers for manual triggering
15
+ let loadHandler: (() => void) | null = null;
16
+ let errorHandler: (() => void) | null = null;
17
+
18
+ beforeEach(() => {
19
+ vi.clearAllMocks();
20
+ loadHandler = null;
21
+ errorHandler = null;
22
+
23
+ // Mock Image constructor using vi.stubGlobal (modern approach)
24
+ const mockImageInstance = {
25
+ addEventListener: mockAddEventListener.mockImplementation(
26
+ (event: string, handler: () => void) => {
27
+ if (event === 'load') loadHandler = handler;
28
+ if (event === 'error') errorHandler = handler;
29
+ },
30
+ ),
31
+ naturalHeight: 600,
32
+ naturalWidth: 800,
33
+ src: '',
34
+ };
35
+
36
+ mockImage = vi.fn().mockImplementation(() => mockImageInstance);
37
+ vi.stubGlobal('Image', mockImage);
38
+
39
+ // Mock URL methods using vi.spyOn (preserves other URL functionality)
40
+ mockCreateObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock-url');
41
+ mockRevokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
42
+ });
43
+
44
+ describe('getImageDimensions', () => {
45
+ it('should return correct dimensions for valid File object', async () => {
46
+ const imageFile = new File(['fake image data'], 'test.png', { type: 'image/png' });
47
+
48
+ const resultPromise = getImageDimensions(imageFile);
49
+ loadHandler?.();
50
+ const result = await resultPromise;
51
+
52
+ expect(result).toEqual({ height: 600, width: 800 });
53
+ expect(mockImage).toHaveBeenCalledTimes(1);
54
+ expect(mockCreateObjectURL).toHaveBeenCalledWith(imageFile);
55
+ expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
56
+ });
57
+
58
+ it('should return correct dimensions for valid data URI', async () => {
59
+ const dataUri =
60
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
61
+
62
+ const resultPromise = getImageDimensions(dataUri);
63
+ loadHandler?.();
64
+ const result = await resultPromise;
65
+
66
+ expect(result).toEqual({ height: 600, width: 800 });
67
+ expect(mockImage).toHaveBeenCalledTimes(1);
68
+ // Data URI should not use createObjectURL
69
+ expect(mockCreateObjectURL).not.toHaveBeenCalled();
70
+ expect(mockRevokeObjectURL).not.toHaveBeenCalled();
71
+ });
72
+
73
+ it('should return undefined for invalid inputs', async () => {
74
+ // Test non-image file
75
+ const textFile = new File(['content'], 'test.txt', { type: 'text/plain' });
76
+ const result1 = await getImageDimensions(textFile);
77
+ expect(result1).toBeUndefined();
78
+
79
+ // Test non-data URI string
80
+ const result2 = await getImageDimensions('https://example.com/image.jpg');
81
+ expect(result2).toBeUndefined();
82
+ });
83
+
84
+ it('should return undefined when image fails to load', async () => {
85
+ const imageFile = new File(['fake image data'], 'test.png', { type: 'image/png' });
86
+
87
+ const resultPromise = getImageDimensions(imageFile);
88
+ errorHandler?.(); // Simulate load error
89
+ const result = await resultPromise;
90
+
91
+ expect(result).toBeUndefined();
92
+ expect(mockCreateObjectURL).toHaveBeenCalledWith(imageFile);
93
+ expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:mock-url');
94
+ });
95
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Helper function to extract image dimensions from File objects or base64 data URIs
3
+ * @param source The image source - either a File object or base64 data URI string
4
+ * @returns Promise resolving to dimensions or undefined if not an image or error occurs
5
+ */
6
+ export const getImageDimensions = async (
7
+ source: File | string,
8
+ ): Promise<{ height: number; width: number } | undefined> => {
9
+ // Type guard and validation
10
+ if (typeof source === 'string') {
11
+ // Handle base64 data URI
12
+ if (!source.startsWith('data:image/')) return undefined;
13
+ } else {
14
+ // Handle File object
15
+ if (!source.type.startsWith('image/')) return undefined;
16
+ }
17
+
18
+ return new Promise((resolve) => {
19
+ const img = new Image();
20
+ let objectUrl: string | null = null;
21
+
22
+ const handleLoad = () => {
23
+ resolve({
24
+ height: img.naturalHeight,
25
+ width: img.naturalWidth,
26
+ });
27
+ // Clean up object URL if created
28
+ if (objectUrl) {
29
+ URL.revokeObjectURL(objectUrl);
30
+ }
31
+ };
32
+
33
+ const handleError = () => {
34
+ // Clean up object URL if created
35
+ if (objectUrl) {
36
+ URL.revokeObjectURL(objectUrl);
37
+ }
38
+ resolve(undefined);
39
+ };
40
+
41
+ img.addEventListener('load', handleLoad);
42
+ img.addEventListener('error', handleError);
43
+
44
+ // Set source based on input type
45
+ if (typeof source === 'string') {
46
+ // Base64 data URI - use directly
47
+ img.src = source;
48
+ } else {
49
+ // File object - create object URL
50
+ objectUrl = URL.createObjectURL(source);
51
+ img.src = objectUrl;
52
+ }
53
+ });
54
+ };
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
 
3
- import { MAX_SEED, calculateThumbnailDimensions, generateUniqueSeeds } from './number';
3
+ import { MAX_SEED } from '@/const/image';
4
+
5
+ import { calculateThumbnailDimensions, generateUniqueSeeds } from './number';
4
6
 
5
7
  describe('number utilities', () => {
6
8
  describe('MAX_SEED constant', () => {
@@ -1,8 +1,7 @@
1
1
  import prand from 'pure-rand';
2
2
 
3
- import { IMAGE_GENERATION_CONFIG } from '@/const/imageGeneration';
3
+ import { IMAGE_GENERATION_CONFIG, MAX_SEED } from '@/const/image';
4
4
 
5
- export const MAX_SEED = 2 ** 31 - 1;
6
5
  export function generateUniqueSeeds(seedCount: number): number[] {
7
6
  // Use current timestamp as the initial seed
8
7
  const initialSeed = Date.now();
@@ -6,7 +6,7 @@ import { CSSProperties, memo, useCallback } from 'react';
6
6
  import { useTranslation } from 'react-i18next';
7
7
  import { Flexbox } from 'react-layout-kit';
8
8
 
9
- import { MAX_SEED } from '@/libs/standard-parameters/index';
9
+ import { MAX_SEED } from '@/const/image';
10
10
  import { generateUniqueSeeds } from '@/utils/number';
11
11
 
12
12
  export interface SeedNumberInputProps {
@@ -15,7 +15,6 @@ const styles = {
15
15
  width: '100%',
16
16
  },
17
17
  label: {
18
- fontSize: 12,
19
18
  fontWeight: 500,
20
19
  },
21
20
  } as const;
@@ -18,10 +18,14 @@ import { useConfigPanelStyles } from '../style';
18
18
  // ======== Business Types ======== //
19
19
 
20
20
  export interface ImageUploadProps {
21
- // Callback when URL changes
21
+ // Callback when URL changes - supports both old API (string) and new API (object with dimensions)
22
22
  className?: string; // Image URL
23
23
  maxFileSize?: number;
24
- onChange?: (url?: string) => void;
24
+ onChange?: (
25
+ data?:
26
+ | string // Old API: just URL
27
+ | { dimensions?: { height: number, width: number; }, url: string; }, // New API: URL with dimensions
28
+ ) => void;
25
29
  style?: React.CSSProperties;
26
30
  value?: string | null;
27
31
  }
@@ -483,8 +487,11 @@ const ImageUpload: FC<ImageUploadProps> = memo(
483
487
  });
484
488
 
485
489
  if (result?.url) {
486
- // Upload successful
487
- onChange?.(result.url);
490
+ // Upload successful - pass dimensions if available
491
+ const callbackData = result.dimensions
492
+ ? { dimensions: result.dimensions, url: result.url }
493
+ : result.url;
494
+ onChange?.(callbackData);
488
495
  }
489
496
  } catch {
490
497
  // Upload failed
@@ -563,8 +570,11 @@ const ImageUpload: FC<ImageUploadProps> = memo(
563
570
  });
564
571
 
565
572
  if (result?.url) {
566
- // Upload successful
567
- onChange?.(result.url);
573
+ // Upload successful - pass dimensions if available
574
+ const callbackData = result.dimensions
575
+ ? { dimensions: result.dimensions, url: result.url }
576
+ : result.url;
577
+ onChange?.(callbackData);
568
578
  }
569
579
  } catch {
570
580
  // Upload failed
@@ -2,14 +2,26 @@ import { memo } from 'react';
2
2
 
3
3
  import { useGenerationConfigParam } from '@/store/image/slices/generationConfig/hooks';
4
4
 
5
+ import { useAutoDimensions } from '../hooks/useAutoDimensions';
5
6
  import ImageUpload from './ImageUpload';
6
7
 
7
8
  const ImageUrl = memo(() => {
8
9
  const { value: imageUrl, setValue, maxFileSize } = useGenerationConfigParam('imageUrl');
10
+ const { autoSetDimensions, extractUrlAndDimensions } = useAutoDimensions();
11
+
12
+ const handleChange = (
13
+ data?:
14
+ | string // Old API: just URL
15
+ | { dimensions?: { height: number; width: number }; url: string }, // New API: URL with dimensions
16
+ ) => {
17
+ const { url, dimensions } = extractUrlAndDimensions(data);
9
18
 
10
- // Extract the first URL from the array for single image display
11
- const handleChange = (url?: string) => {
12
19
  setValue(url ?? null);
20
+
21
+ // Auto-set dimensions if available
22
+ if (dimensions) {
23
+ autoSetDimensions(dimensions);
24
+ }
13
25
  };
14
26
 
15
27
  return <ImageUpload maxFileSize={maxFileSize} onChange={handleChange} value={imageUrl} />;
@@ -2,16 +2,29 @@ import { memo } from 'react';
2
2
 
3
3
  import { useGenerationConfigParam } from '@/store/image/slices/generationConfig/hooks';
4
4
 
5
+ import { useAutoDimensions } from '../hooks/useAutoDimensions';
5
6
  import ImageUpload from './ImageUpload';
6
7
  import MultiImagesUpload from './MultiImagesUpload';
7
8
 
8
9
  const ImageUrlsUpload = memo(() => {
9
10
  const { value, setValue, maxCount, maxFileSize } = useGenerationConfigParam('imageUrls');
11
+ const { autoSetDimensions, extractUrlAndDimensions } = useAutoDimensions();
10
12
 
11
13
  // When maxCount is 1, use ImageUpload for single image upload
12
14
  if (maxCount === 1) {
13
- const handleSingleChange = (url?: string) => {
15
+ const handleSingleChange = (
16
+ data?:
17
+ | string // Old API: just URL
18
+ | { dimensions?: { height: number; width: number }; url: string }, // New API: URL with dimensions
19
+ ) => {
20
+ const { url, dimensions } = extractUrlAndDimensions(data);
21
+
14
22
  setValue(url ? [url] : []);
23
+
24
+ // Auto-set dimensions if available
25
+ if (dimensions) {
26
+ autoSetDimensions(dimensions);
27
+ }
15
28
  };
16
29
 
17
30
  return (
@@ -24,10 +37,22 @@ const ImageUrlsUpload = memo(() => {
24
37
  }
25
38
 
26
39
  // Otherwise use MultiImagesUpload for multiple images
27
- const handleChange = (urls: string[]) => {
40
+ const handleChange = (
41
+ data:
42
+ | string[] // Old API: just URLs
43
+ | { dimensions?: { height: number; width: number }; urls: string[] }, // New API: URLs with first image dimensions
44
+ ) => {
45
+ const urls = Array.isArray(data) ? data : data.urls;
46
+ const dimensions = Array.isArray(data) ? undefined : data.dimensions;
47
+
28
48
  // Directly set the URLs to the store
29
49
  // The store will handle URL to path conversion when needed
30
50
  setValue(urls);
51
+
52
+ // Only auto-set dimensions if no existing images and only uploading one image
53
+ if (!value?.length && urls.length === 1 && dimensions) {
54
+ autoSetDimensions(dimensions);
55
+ }
31
56
  };
32
57
 
33
58
  return (
@@ -37,11 +37,15 @@ interface DisplayItem {
37
37
  }
38
38
 
39
39
  export interface MultiImagesUploadProps {
40
- // Callback when URLs change
40
+ // Callback when URLs change - supports both old API (string[]) and new API (object with dimensions)
41
41
  className?: string; // Array of image URLs
42
42
  maxCount?: number;
43
43
  maxFileSize?: number;
44
- onChange?: (urls: string[]) => void;
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;
45
49
  style?: React.CSSProperties;
46
50
  value?: string[];
47
51
  }
@@ -648,9 +652,10 @@ const MultiImagesUpload: FC<MultiImagesUploadProps> = memo(
648
652
  }),
649
653
  );
650
654
 
651
- // Wait for all uploads to complete and collect successful URLs
655
+ // Wait for all uploads to complete and collect successful URLs and dimensions
652
656
  const uploadResults = await Promise.allSettled(uploadPromises);
653
657
  const successfulUrls: string[] = [];
658
+ let firstImageDimensions: { height: number, width: number; } | undefined;
654
659
 
655
660
  uploadResults.forEach((result, index) => {
656
661
  const displayItem = newDisplayItems[index];
@@ -658,6 +663,11 @@ const MultiImagesUpload: FC<MultiImagesUploadProps> = memo(
658
663
  if (result.status === 'fulfilled' && result.value) {
659
664
  successfulUrls.push(result.value.url);
660
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
+
661
671
  // Update display item with final URL and success status
662
672
  setDisplayItems((prev) =>
663
673
  prev.map((item) =>
@@ -694,10 +704,18 @@ const MultiImagesUpload: FC<MultiImagesUploadProps> = memo(
694
704
  }
695
705
  });
696
706
 
697
- // Update parent component with new URLs
707
+ // Update parent component with new URLs and dimensions (if applicable)
698
708
  if (successfulUrls.length > 0) {
699
709
  const updatedUrls = [...currentUrls, ...successfulUrls];
700
- 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
+ }
701
719
  }
702
720
 
703
721
  // Clear display items after all uploads complete
@@ -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
+ };
@@ -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
  });