@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.
- package/.cursor/rules/add-provider-doc.mdc +183 -0
- 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/.env.example +8 -0
- package/.github/workflows/claude.yml +1 -1
- package/.github/workflows/release.yml +3 -3
- package/.github/workflows/test.yml +10 -5
- package/CHANGELOG.md +50 -0
- package/CLAUDE.md +17 -33
- package/Dockerfile +5 -1
- package/Dockerfile.database +5 -1
- package/Dockerfile.pglite +5 -1
- package/changelog/v1.json +14 -0
- package/docs/development/basic/feature-development.mdx +1 -1
- package/docs/development/basic/feature-development.zh-CN.mdx +1 -1
- package/docs/development/basic/setup-development.mdx +10 -13
- package/docs/development/basic/setup-development.zh-CN.mdx +9 -12
- package/docs/self-hosting/environment-variables/model-provider.mdx +27 -2
- package/docs/self-hosting/environment-variables/model-provider.zh-CN.mdx +27 -2
- package/docs/usage/providers/bfl.mdx +68 -0
- package/docs/usage/providers/bfl.zh-CN.mdx +67 -0
- package/locales/ar/components.json +11 -0
- package/locales/ar/error.json +11 -0
- package/locales/ar/models.json +64 -4
- package/locales/ar/providers.json +3 -0
- package/locales/bg-BG/components.json +11 -0
- package/locales/bg-BG/error.json +11 -0
- package/locales/bg-BG/models.json +64 -4
- package/locales/bg-BG/providers.json +3 -0
- package/locales/de-DE/components.json +11 -0
- package/locales/de-DE/error.json +11 -12
- package/locales/de-DE/models.json +64 -4
- package/locales/de-DE/providers.json +3 -0
- package/locales/en-US/components.json +6 -0
- package/locales/en-US/error.json +11 -12
- package/locales/en-US/models.json +64 -4
- package/locales/en-US/providers.json +3 -0
- package/locales/es-ES/components.json +11 -0
- package/locales/es-ES/error.json +11 -0
- package/locales/es-ES/models.json +64 -6
- package/locales/es-ES/providers.json +3 -0
- package/locales/fa-IR/components.json +11 -0
- package/locales/fa-IR/error.json +11 -0
- package/locales/fa-IR/models.json +64 -4
- package/locales/fa-IR/providers.json +3 -0
- package/locales/fr-FR/components.json +11 -0
- package/locales/fr-FR/error.json +11 -12
- package/locales/fr-FR/models.json +64 -4
- package/locales/fr-FR/providers.json +3 -0
- package/locales/it-IT/components.json +11 -0
- package/locales/it-IT/error.json +11 -0
- package/locales/it-IT/models.json +64 -4
- package/locales/it-IT/providers.json +3 -0
- package/locales/ja-JP/components.json +11 -0
- package/locales/ja-JP/error.json +11 -12
- package/locales/ja-JP/models.json +64 -4
- package/locales/ja-JP/providers.json +3 -0
- package/locales/ko-KR/components.json +11 -0
- package/locales/ko-KR/error.json +11 -12
- package/locales/ko-KR/models.json +64 -6
- package/locales/ko-KR/providers.json +3 -0
- package/locales/nl-NL/components.json +11 -0
- package/locales/nl-NL/error.json +11 -0
- package/locales/nl-NL/models.json +62 -4
- package/locales/nl-NL/providers.json +3 -0
- package/locales/pl-PL/components.json +11 -0
- package/locales/pl-PL/error.json +11 -0
- package/locales/pl-PL/models.json +64 -4
- package/locales/pl-PL/providers.json +3 -0
- package/locales/pt-BR/components.json +11 -0
- package/locales/pt-BR/error.json +11 -0
- package/locales/pt-BR/models.json +64 -4
- package/locales/pt-BR/providers.json +3 -0
- package/locales/ru-RU/components.json +11 -0
- package/locales/ru-RU/error.json +11 -0
- package/locales/ru-RU/models.json +64 -4
- package/locales/ru-RU/providers.json +3 -0
- package/locales/tr-TR/components.json +11 -0
- package/locales/tr-TR/error.json +11 -0
- package/locales/tr-TR/models.json +64 -4
- package/locales/tr-TR/providers.json +3 -0
- package/locales/vi-VN/components.json +11 -0
- package/locales/vi-VN/error.json +11 -0
- package/locales/vi-VN/models.json +64 -4
- package/locales/vi-VN/providers.json +3 -0
- package/locales/zh-CN/components.json +6 -0
- package/locales/zh-CN/error.json +11 -0
- package/locales/zh-CN/models.json +64 -4
- package/locales/zh-CN/providers.json +3 -0
- package/locales/zh-TW/components.json +11 -0
- package/locales/zh-TW/error.json +11 -12
- package/locales/zh-TW/models.json +64 -6
- package/locales/zh-TW/providers.json +3 -0
- package/package.json +4 -4
- 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/google/index.ts +3 -0
- package/packages/model-runtime/src/qwen/createImage.test.ts +0 -19
- package/packages/model-runtime/src/qwen/createImage.ts +1 -27
- package/packages/model-runtime/src/utils/modelParse.ts +1 -1
- package/packages/model-runtime/src/utils/streams/google-ai.ts +26 -14
- package/packages/types/src/aiModel.ts +2 -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)/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 +16 -6
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ImageUrl.tsx +14 -2
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ImageUrlsUpload.tsx +27 -2
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/MultiImagesUpload/index.tsx +23 -5
- package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/hooks/useAutoDimensions.ts +56 -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/@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/config/aiModels/google.ts +22 -1
- package/src/config/aiModels/qwen.ts +2 -2
- package/src/config/aiModels/vertexai.ts +22 -0
- package/src/libs/standard-parameters/index.ts +1 -1
- package/src/server/services/generation/index.ts +1 -1
- 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 +1 -1
- package/tsconfig.json +1 -10
- package/.cursor/rules/debug.mdc +0 -193
- 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
@@ -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
|
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'
|
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
|
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/
|
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 '@/
|
9
|
+
import { MAX_SEED } from '@/const/image';
|
10
10
|
import { generateUniqueSeeds } from '@/utils/number';
|
11
11
|
|
12
12
|
export interface SeedNumberInputProps {
|
package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ImageUpload.tsx
CHANGED
@@ -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?: (
|
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
|
-
|
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
|
-
|
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} />;
|
package/src/app/[variants]/(main)/image/@menu/features/ConfigPanel/components/ImageUrlsUpload.tsx
CHANGED
@@ -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 = (
|
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 = (
|
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?: (
|
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
|
-
|
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 {
|
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
|
});
|