@lobehub/chat 1.103.2 → 1.104.1
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/code-review.mdc +2 -0
- package/.cursor/rules/typescript.mdc +3 -1
- package/CHANGELOG.md +50 -0
- package/apps/desktop/src/main/controllers/BrowserWindowsCtr.ts +1 -1
- package/apps/desktop/src/main/controllers/ShortcutCtr.ts +9 -1
- package/apps/desktop/src/main/controllers/TrayMenuCtr.ts +1 -5
- package/apps/desktop/src/main/controllers/__tests__/ShortcutCtr.test.ts +14 -11
- package/apps/desktop/src/main/core/ui/ShortcutManager.ts +71 -5
- package/apps/desktop/src/main/shortcuts/config.ts +4 -2
- package/changelog/v1.json +18 -0
- package/locales/ar/hotkey.json +10 -4
- package/locales/ar/setting.json +12 -1
- package/locales/bg-BG/hotkey.json +10 -4
- package/locales/bg-BG/setting.json +12 -1
- package/locales/de-DE/hotkey.json +10 -4
- package/locales/de-DE/setting.json +12 -1
- package/locales/en-US/hotkey.json +10 -4
- package/locales/en-US/setting.json +12 -1
- package/locales/es-ES/hotkey.json +10 -4
- package/locales/es-ES/setting.json +12 -1
- package/locales/fa-IR/hotkey.json +10 -4
- package/locales/fa-IR/setting.json +12 -1
- package/locales/fr-FR/hotkey.json +10 -4
- package/locales/fr-FR/setting.json +12 -1
- package/locales/it-IT/hotkey.json +10 -4
- package/locales/it-IT/setting.json +12 -1
- package/locales/ja-JP/hotkey.json +10 -4
- package/locales/ja-JP/setting.json +12 -1
- package/locales/ko-KR/hotkey.json +10 -4
- package/locales/ko-KR/setting.json +12 -1
- package/locales/nl-NL/hotkey.json +10 -4
- package/locales/nl-NL/setting.json +12 -1
- package/locales/pl-PL/hotkey.json +10 -4
- package/locales/pl-PL/setting.json +12 -1
- package/locales/pt-BR/hotkey.json +10 -4
- package/locales/pt-BR/setting.json +12 -1
- package/locales/ru-RU/hotkey.json +10 -4
- package/locales/ru-RU/setting.json +12 -1
- package/locales/tr-TR/hotkey.json +10 -4
- package/locales/tr-TR/setting.json +12 -1
- package/locales/vi-VN/hotkey.json +10 -4
- package/locales/vi-VN/setting.json +12 -1
- package/locales/zh-CN/hotkey.json +10 -4
- package/locales/zh-CN/setting.json +12 -1
- package/locales/zh-TW/hotkey.json +10 -4
- package/locales/zh-TW/setting.json +12 -1
- package/package.json +1 -1
- package/packages/electron-client-ipc/src/events/shortcut.ts +3 -1
- package/packages/electron-client-ipc/src/types/shortcut.ts +11 -0
- package/src/app/[variants]/(main)/image/features/GenerationFeed/BatchItem.tsx +6 -1
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/ErrorState.tsx +3 -2
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/LoadingState.tsx +27 -24
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/SuccessState.tsx +14 -3
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/index.tsx +4 -7
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/types.ts +3 -0
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/utils.test.ts +600 -0
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/utils.ts +126 -7
- package/src/app/[variants]/(main)/settings/hotkey/features/Conversation.tsx +3 -11
- package/src/app/[variants]/(main)/settings/hotkey/features/Desktop.tsx +92 -0
- package/src/app/[variants]/(main)/settings/hotkey/features/Essential.tsx +3 -11
- package/src/app/[variants]/(main)/settings/hotkey/page.tsx +3 -0
- package/src/const/desktop.ts +9 -0
- package/src/const/hotkeys.ts +20 -16
- package/src/const/imageGeneration.ts +18 -0
- package/src/features/User/UserPanel/useMenu.tsx +2 -2
- package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.test.ts +3 -0
- package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.ts +7 -5
- package/src/libs/model-runtime/utils/streams/openai/openai.ts +8 -4
- package/src/libs/model-runtime/utils/usageConverter.test.ts +45 -1
- package/src/libs/model-runtime/utils/usageConverter.ts +6 -2
- package/src/locales/default/hotkey.ts +13 -5
- package/src/locales/default/setting.ts +11 -0
- package/src/server/services/generation/index.test.ts +848 -0
- package/src/server/services/generation/index.ts +90 -69
- package/src/services/electron/settings.ts +19 -1
- package/src/store/electron/actions/settings.ts +42 -1
- package/src/store/electron/initialState.ts +9 -1
- package/src/store/electron/selectors/__tests__/desktopState.test.ts +6 -17
- package/src/store/electron/selectors/hotkey.ts +11 -0
- package/src/store/electron/selectors/index.ts +1 -0
- package/src/types/hotkey.ts +18 -4
- package/src/utils/number.test.ts +101 -1
- package/src/utils/number.ts +42 -0
@@ -4,12 +4,54 @@ import mime from 'mime';
|
|
4
4
|
import { nanoid } from 'nanoid';
|
5
5
|
import sharp from 'sharp';
|
6
6
|
|
7
|
+
import { IMAGE_GENERATION_CONFIG } from '@/const/imageGeneration';
|
7
8
|
import { LobeChatDatabase } from '@/database/type';
|
9
|
+
import { parseDataUri } from '@/libs/model-runtime/utils/uriParser';
|
8
10
|
import { FileService } from '@/server/services/file';
|
11
|
+
import { calculateThumbnailDimensions } from '@/utils/number';
|
9
12
|
import { getYYYYmmddHHMMss } from '@/utils/time';
|
13
|
+
import { inferFileExtensionFromImageUrl } from '@/utils/url';
|
10
14
|
|
11
15
|
const log = debug('lobe-image:generation-service');
|
12
16
|
|
17
|
+
/**
|
18
|
+
* Fetch image buffer and MIME type from URL or base64 data
|
19
|
+
* @param url - Image URL or base64 data URI
|
20
|
+
* @returns Object containing buffer and MIME type
|
21
|
+
*/
|
22
|
+
export async function fetchImageFromUrl(url: string): Promise<{
|
23
|
+
buffer: Buffer;
|
24
|
+
mimeType: string;
|
25
|
+
}> {
|
26
|
+
if (url.startsWith('data:')) {
|
27
|
+
const { base64, mimeType, type } = parseDataUri(url);
|
28
|
+
|
29
|
+
if (type !== 'base64' || !base64 || !mimeType) {
|
30
|
+
throw new Error(`Invalid data URI format: ${url}`);
|
31
|
+
}
|
32
|
+
|
33
|
+
try {
|
34
|
+
const buffer = Buffer.from(base64, 'base64');
|
35
|
+
return { buffer, mimeType };
|
36
|
+
} catch (error) {
|
37
|
+
throw new Error(
|
38
|
+
`Failed to decode base64 data: ${error instanceof Error ? error.message : String(error)}`,
|
39
|
+
);
|
40
|
+
}
|
41
|
+
} else {
|
42
|
+
const response = await fetch(url);
|
43
|
+
if (!response.ok) {
|
44
|
+
throw new Error(
|
45
|
+
`Failed to fetch image from ${url}: ${response.status} ${response.statusText}`,
|
46
|
+
);
|
47
|
+
}
|
48
|
+
const arrayBuffer = await response.arrayBuffer();
|
49
|
+
const buffer = Buffer.from(arrayBuffer);
|
50
|
+
const mimeType = response.headers.get('content-type') || 'application/octet-stream';
|
51
|
+
return { buffer, mimeType };
|
52
|
+
}
|
53
|
+
}
|
54
|
+
|
13
55
|
interface ImageForGeneration {
|
14
56
|
buffer: Buffer;
|
15
57
|
extension: string;
|
@@ -40,33 +82,12 @@ export class GenerationService {
|
|
40
82
|
}> {
|
41
83
|
log('Starting image transformation for:', url.startsWith('data:') ? 'base64 data' : url);
|
42
84
|
|
43
|
-
//
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
if (url.startsWith('data:')) {
|
48
|
-
log('Processing base64 image data');
|
49
|
-
// Extract the MIME type and base64 data part
|
50
|
-
const [mimeTypePart, base64Data] = url.split(',');
|
51
|
-
originalMimeType = mimeTypePart.split(':')[1].split(';')[0];
|
52
|
-
originalImageBuffer = Buffer.from(base64Data, 'base64');
|
53
|
-
} else {
|
54
|
-
log('Fetching image from URL:', url);
|
55
|
-
const response = await fetch(url);
|
56
|
-
if (!response.ok) {
|
57
|
-
throw new Error(
|
58
|
-
`Failed to fetch image from ${url}: ${response.status} ${response.statusText}`,
|
59
|
-
);
|
60
|
-
}
|
61
|
-
const arrayBuffer = await response.arrayBuffer();
|
62
|
-
originalImageBuffer = Buffer.from(arrayBuffer);
|
63
|
-
originalMimeType = response.headers.get('content-type') || 'application/octet-stream';
|
64
|
-
log('Successfully fetched image, buffer size:', originalImageBuffer.length);
|
65
|
-
}
|
85
|
+
// Fetch image buffer and MIME type using utility function
|
86
|
+
const { buffer: originalImageBuffer, mimeType: originalMimeType } =
|
87
|
+
await fetchImageFromUrl(url);
|
66
88
|
|
67
89
|
// Calculate hash for original image
|
68
90
|
const originalHash = sha256(originalImageBuffer);
|
69
|
-
log('Original image hash calculated:', originalHash);
|
70
91
|
|
71
92
|
const sharpInstance = sharp(originalImageBuffer);
|
72
93
|
const { format, width, height } = await sharpInstance.metadata();
|
@@ -76,19 +97,20 @@ export class GenerationService {
|
|
76
97
|
throw new Error(`Invalid image format: ${format}, url: ${url}`);
|
77
98
|
}
|
78
99
|
|
79
|
-
const
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
100
|
+
const {
|
101
|
+
shouldResize: shouldResizeBySize,
|
102
|
+
thumbnailWidth,
|
103
|
+
thumbnailHeight,
|
104
|
+
} = calculateThumbnailDimensions(width, height);
|
105
|
+
const shouldResize = shouldResizeBySize || format !== 'webp';
|
106
|
+
|
107
|
+
log('Thumbnail processing decision:', {
|
108
|
+
format,
|
109
|
+
shouldResize,
|
110
|
+
shouldResizeBySize,
|
111
|
+
thumbnailHeight,
|
112
|
+
thumbnailWidth,
|
113
|
+
});
|
92
114
|
|
93
115
|
const thumbnailBuffer = shouldResize
|
94
116
|
? await sharpInstance.resize(thumbnailWidth, thumbnailHeight).webp().toBuffer()
|
@@ -96,11 +118,10 @@ export class GenerationService {
|
|
96
118
|
|
97
119
|
// Calculate hash for thumbnail
|
98
120
|
const thumbnailHash = sha256(thumbnailBuffer);
|
99
|
-
log('Thumbnail image hash calculated:', thumbnailHash);
|
100
121
|
|
101
122
|
log('Image transformation completed successfully');
|
102
123
|
|
103
|
-
// Determine extension
|
124
|
+
// Determine extension using url utility
|
104
125
|
let extension: string;
|
105
126
|
if (url.startsWith('data:')) {
|
106
127
|
const mimeExtension = mime.getExtension(originalMimeType);
|
@@ -109,11 +130,10 @@ export class GenerationService {
|
|
109
130
|
}
|
110
131
|
extension = mimeExtension;
|
111
132
|
} else {
|
112
|
-
|
113
|
-
if (!
|
133
|
+
extension = inferFileExtensionFromImageUrl(url);
|
134
|
+
if (!extension) {
|
114
135
|
throw new Error(`Unable to determine file extension from URL: ${url}`);
|
115
136
|
}
|
116
|
-
extension = urlExtension;
|
117
137
|
}
|
118
138
|
|
119
139
|
return {
|
@@ -144,9 +164,8 @@ export class GenerationService {
|
|
144
164
|
const generationImagesFolder = 'generations/images';
|
145
165
|
const uuid = nanoid();
|
146
166
|
const dateTime = getYYYYmmddHHMMss(new Date());
|
147
|
-
const
|
148
|
-
const
|
149
|
-
const thumbnailKey = `${pathPrefix}_thumb.${thumbnail.extension}`;
|
167
|
+
const imageKey = `${generationImagesFolder}/${uuid}_${image.width}x${image.height}_${dateTime}_raw.${image.extension}`;
|
168
|
+
const thumbnailKey = `${generationImagesFolder}/${uuid}_${thumbnail.width}x${thumbnail.height}_${dateTime}_thumb.${thumbnail.extension}`;
|
150
169
|
|
151
170
|
log('Generated paths:', { imagePath: imageKey, thumbnailPath: thumbnailKey });
|
152
171
|
|
@@ -189,37 +208,39 @@ export class GenerationService {
|
|
189
208
|
}
|
190
209
|
|
191
210
|
/**
|
192
|
-
* Create a
|
211
|
+
* Create a cover image from a given URL and upload
|
193
212
|
* @param coverUrl - The source image URL (can be base64 or HTTP URL)
|
194
213
|
* @returns The key of the uploaded cover image
|
195
214
|
*/
|
196
215
|
async createCoverFromUrl(coverUrl: string): Promise<string> {
|
197
216
|
log('Creating cover image from URL:', coverUrl.startsWith('data:') ? 'base64 data' : coverUrl);
|
198
217
|
|
199
|
-
//
|
200
|
-
|
201
|
-
|
202
|
-
|
203
|
-
|
204
|
-
|
205
|
-
|
206
|
-
|
207
|
-
|
208
|
-
const response = await fetch(coverUrl);
|
209
|
-
if (!response.ok) {
|
210
|
-
throw new Error(
|
211
|
-
`Failed to fetch cover image from ${coverUrl}: ${response.status} ${response.statusText}`,
|
212
|
-
);
|
213
|
-
}
|
214
|
-
const arrayBuffer = await response.arrayBuffer();
|
215
|
-
originalImageBuffer = Buffer.from(arrayBuffer);
|
216
|
-
log('Successfully fetched cover image, buffer size:', originalImageBuffer.length);
|
218
|
+
// Fetch image buffer using utility function
|
219
|
+
const { buffer: originalImageBuffer } = await fetchImageFromUrl(coverUrl);
|
220
|
+
|
221
|
+
// Get image metadata to calculate proper cover dimensions
|
222
|
+
const sharpInstance = sharp(originalImageBuffer);
|
223
|
+
const { width, height } = await sharpInstance.metadata();
|
224
|
+
|
225
|
+
if (!width || !height) {
|
226
|
+
throw new Error('Invalid image format for cover creation');
|
217
227
|
}
|
218
228
|
|
219
|
-
//
|
220
|
-
|
221
|
-
|
222
|
-
|
229
|
+
// Calculate cover dimensions maintaining aspect ratio with configurable max size
|
230
|
+
const { thumbnailWidth, thumbnailHeight } = calculateThumbnailDimensions(
|
231
|
+
width,
|
232
|
+
height,
|
233
|
+
IMAGE_GENERATION_CONFIG.COVER_MAX_SIZE,
|
234
|
+
);
|
235
|
+
|
236
|
+
log('Processing cover image with dimensions:', {
|
237
|
+
cover: { height: thumbnailHeight, width: thumbnailWidth },
|
238
|
+
original: { height, width },
|
239
|
+
});
|
240
|
+
|
241
|
+
const coverBuffer = await sharpInstance
|
242
|
+
.resize(thumbnailWidth, thumbnailHeight)
|
243
|
+
.webp()
|
223
244
|
.toBuffer();
|
224
245
|
|
225
246
|
log('Cover image processed, final size:', coverBuffer.length);
|
@@ -228,7 +249,7 @@ export class GenerationService {
|
|
228
249
|
const coverFolder = 'generations/covers';
|
229
250
|
const uuid = nanoid();
|
230
251
|
const dateTime = getYYYYmmddHHMMss(new Date());
|
231
|
-
const coverKey = `${coverFolder}/${uuid}
|
252
|
+
const coverKey = `${coverFolder}/${uuid}_${thumbnailWidth}x${thumbnailHeight}_${dateTime}_cover.webp`;
|
232
253
|
|
233
254
|
log('Uploading cover image:', coverKey);
|
234
255
|
const result = await this.fileService.uploadMedia(coverKey, coverBuffer);
|
@@ -1,4 +1,8 @@
|
|
1
|
-
import {
|
1
|
+
import {
|
2
|
+
NetworkProxySettings,
|
3
|
+
ShortcutUpdateResult,
|
4
|
+
dispatch,
|
5
|
+
} from '@lobechat/electron-client-ipc';
|
2
6
|
|
3
7
|
class DesktopSettingsService {
|
4
8
|
/**
|
@@ -15,6 +19,20 @@ class DesktopSettingsService {
|
|
15
19
|
return dispatch('setProxySettings', data);
|
16
20
|
};
|
17
21
|
|
22
|
+
/**
|
23
|
+
* 获取桌面热键配置
|
24
|
+
*/
|
25
|
+
getDesktopHotkeys = async () => {
|
26
|
+
return dispatch('getShortcutsConfig');
|
27
|
+
};
|
28
|
+
|
29
|
+
/**
|
30
|
+
* 更新桌面热键配置
|
31
|
+
*/
|
32
|
+
updateDesktopHotkey = async (id: string, accelerator: string): Promise<ShortcutUpdateResult> => {
|
33
|
+
return dispatch('updateShortcutConfig', { accelerator, id });
|
34
|
+
};
|
35
|
+
|
18
36
|
/**
|
19
37
|
* 测试代理连接
|
20
38
|
*/
|
@@ -1,4 +1,4 @@
|
|
1
|
-
import { NetworkProxySettings } from '@lobechat/electron-client-ipc';
|
1
|
+
import { NetworkProxySettings, ShortcutUpdateResult } from '@lobechat/electron-client-ipc';
|
2
2
|
import isEqual from 'fast-deep-equal';
|
3
3
|
import useSWR, { SWRResponse, mutate } from 'swr';
|
4
4
|
import type { StateCreator } from 'zustand/vanilla';
|
@@ -11,12 +11,16 @@ import type { ElectronStore } from '../store';
|
|
11
11
|
* 设置操作
|
12
12
|
*/
|
13
13
|
export interface ElectronSettingsAction {
|
14
|
+
refreshDesktopHotkeys: () => Promise<void>;
|
14
15
|
refreshProxySettings: () => Promise<void>;
|
15
16
|
setProxySettings: (params: Partial<NetworkProxySettings>) => Promise<void>;
|
17
|
+
updateDesktopHotkey: (id: string, accelerator: string) => Promise<ShortcutUpdateResult>;
|
18
|
+
useFetchDesktopHotkeys: () => SWRResponse;
|
16
19
|
useGetProxySettings: () => SWRResponse;
|
17
20
|
}
|
18
21
|
|
19
22
|
const ELECTRON_PROXY_SETTINGS_KEY = 'electron:getProxySettings';
|
23
|
+
const ELECTRON_DESKTOP_HOTKEYS_KEY = 'electron:getDesktopHotkeys';
|
20
24
|
|
21
25
|
export const settingsSlice: StateCreator<
|
22
26
|
ElectronStore,
|
@@ -24,6 +28,10 @@ export const settingsSlice: StateCreator<
|
|
24
28
|
[],
|
25
29
|
ElectronSettingsAction
|
26
30
|
> = (set, get) => ({
|
31
|
+
refreshDesktopHotkeys: async () => {
|
32
|
+
await mutate(ELECTRON_DESKTOP_HOTKEYS_KEY);
|
33
|
+
},
|
34
|
+
|
27
35
|
refreshProxySettings: async () => {
|
28
36
|
await mutate(ELECTRON_PROXY_SETTINGS_KEY);
|
29
37
|
},
|
@@ -40,6 +48,39 @@ export const settingsSlice: StateCreator<
|
|
40
48
|
}
|
41
49
|
},
|
42
50
|
|
51
|
+
updateDesktopHotkey: async (id, accelerator) => {
|
52
|
+
try {
|
53
|
+
// 更新热键配置
|
54
|
+
const result = await desktopSettingsService.updateDesktopHotkey(id, accelerator);
|
55
|
+
|
56
|
+
// 如果更新成功,刷新状态
|
57
|
+
if (result.success) {
|
58
|
+
await get().refreshDesktopHotkeys();
|
59
|
+
}
|
60
|
+
|
61
|
+
return result;
|
62
|
+
} catch (error) {
|
63
|
+
console.error('桌面热键更新失败:', error);
|
64
|
+
return {
|
65
|
+
errorType: 'UNKNOWN' as const,
|
66
|
+
success: false,
|
67
|
+
};
|
68
|
+
}
|
69
|
+
},
|
70
|
+
|
71
|
+
useFetchDesktopHotkeys: () =>
|
72
|
+
useSWR<Record<string, string>>(
|
73
|
+
ELECTRON_DESKTOP_HOTKEYS_KEY,
|
74
|
+
async () => desktopSettingsService.getDesktopHotkeys(),
|
75
|
+
{
|
76
|
+
onSuccess: (data) => {
|
77
|
+
if (!isEqual(data, get().desktopHotkeys)) {
|
78
|
+
set({ desktopHotkeys: data, isDesktopHotkeysInit: true });
|
79
|
+
}
|
80
|
+
},
|
81
|
+
},
|
82
|
+
),
|
83
|
+
|
43
84
|
useGetProxySettings: () =>
|
44
85
|
useSWR<NetworkProxySettings>(
|
45
86
|
ELECTRON_PROXY_SETTINGS_KEY,
|
@@ -1,4 +1,8 @@
|
|
1
|
-
import {
|
1
|
+
import {
|
2
|
+
DataSyncConfig,
|
3
|
+
ElectronAppState,
|
4
|
+
NetworkProxySettings,
|
5
|
+
} from '@lobechat/electron-client-ipc';
|
2
6
|
|
3
7
|
export type RemoteServerError = 'CONFIG_ERROR' | 'AUTH_ERROR' | 'DISCONNECT_ERROR';
|
4
8
|
|
@@ -14,8 +18,10 @@ export const defaultProxySettings: NetworkProxySettings = {
|
|
14
18
|
export interface ElectronState {
|
15
19
|
appState: ElectronAppState;
|
16
20
|
dataSyncConfig: DataSyncConfig;
|
21
|
+
desktopHotkeys: Record<string, string>;
|
17
22
|
isAppStateInit?: boolean;
|
18
23
|
isConnectingServer?: boolean;
|
24
|
+
isDesktopHotkeysInit: boolean;
|
19
25
|
isInitRemoteServerConfig: boolean;
|
20
26
|
isSyncActive?: boolean;
|
21
27
|
proxySettings: NetworkProxySettings;
|
@@ -25,8 +31,10 @@ export interface ElectronState {
|
|
25
31
|
export const initialState: ElectronState = {
|
26
32
|
appState: {},
|
27
33
|
dataSyncConfig: { storageMode: 'local' },
|
34
|
+
desktopHotkeys: {},
|
28
35
|
isAppStateInit: false,
|
29
36
|
isConnectingServer: false,
|
37
|
+
isDesktopHotkeysInit: false,
|
30
38
|
isInitRemoteServerConfig: false,
|
31
39
|
isSyncActive: false,
|
32
40
|
proxySettings: defaultProxySettings,
|
@@ -1,14 +1,14 @@
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
2
2
|
|
3
|
-
import { ElectronState, defaultProxySettings } from '@/store/electron/initialState';
|
3
|
+
import { ElectronState, defaultProxySettings, initialState } from '@/store/electron/initialState';
|
4
|
+
import { merge } from '@/utils/merge';
|
4
5
|
|
5
6
|
import { desktopStateSelectors } from '../desktopState';
|
6
7
|
|
7
8
|
describe('desktopStateSelectors', () => {
|
8
9
|
describe('usePath', () => {
|
9
10
|
it('should return userPath from appState', () => {
|
10
|
-
const state: ElectronState = {
|
11
|
-
isAppStateInit: false,
|
11
|
+
const state: ElectronState = merge(initialState, {
|
12
12
|
appState: {
|
13
13
|
userPath: {
|
14
14
|
desktop: '/test/desktop',
|
@@ -21,12 +21,7 @@ describe('desktopStateSelectors', () => {
|
|
21
21
|
videos: '/test/videos',
|
22
22
|
},
|
23
23
|
},
|
24
|
-
|
25
|
-
storageMode: 'local',
|
26
|
-
},
|
27
|
-
isInitRemoteServerConfig: false,
|
28
|
-
proxySettings: defaultProxySettings,
|
29
|
-
};
|
24
|
+
});
|
30
25
|
|
31
26
|
expect(desktopStateSelectors.usePath(state)).toEqual({
|
32
27
|
desktop: '/test/desktop',
|
@@ -41,15 +36,9 @@ describe('desktopStateSelectors', () => {
|
|
41
36
|
});
|
42
37
|
|
43
38
|
it('should handle undefined userPath', () => {
|
44
|
-
const state: ElectronState = {
|
39
|
+
const state: ElectronState = merge(initialState, {
|
45
40
|
appState: {},
|
46
|
-
|
47
|
-
dataSyncConfig: {
|
48
|
-
storageMode: 'local',
|
49
|
-
},
|
50
|
-
isInitRemoteServerConfig: false,
|
51
|
-
proxySettings: defaultProxySettings,
|
52
|
-
};
|
41
|
+
});
|
53
42
|
|
54
43
|
expect(desktopStateSelectors.usePath(state)).toBeUndefined();
|
55
44
|
});
|
@@ -0,0 +1,11 @@
|
|
1
|
+
import { DEFAULT_DESKTOP_HOTKEY_CONFIG } from '@/const/desktop';
|
2
|
+
import { ElectronState } from '@/store/electron/initialState';
|
3
|
+
import { merge } from '@/utils/merge';
|
4
|
+
|
5
|
+
const hotkeys = (s: ElectronState) => merge(DEFAULT_DESKTOP_HOTKEY_CONFIG, s.desktopHotkeys);
|
6
|
+
const isHotkeysInit = (s: ElectronState) => s.isDesktopHotkeysInit;
|
7
|
+
|
8
|
+
export const desktopHotkeysSelectors = {
|
9
|
+
hotkeys,
|
10
|
+
isHotkeysInit,
|
11
|
+
};
|
package/src/types/hotkey.ts
CHANGED
@@ -62,7 +62,6 @@ export const HotkeyEnum = {
|
|
62
62
|
EditMessage: 'editMessage',
|
63
63
|
OpenChatSettings: 'openChatSettings',
|
64
64
|
OpenHotkeyHelper: 'openHotkeyHelper',
|
65
|
-
OpenSettings: 'openSettings',
|
66
65
|
RegenerateMessage: 'regenerateMessage',
|
67
66
|
SaveTopic: 'saveTopic',
|
68
67
|
Search: 'search',
|
@@ -96,8 +95,6 @@ export interface HotkeyItem {
|
|
96
95
|
// 快捷键分组用于展示
|
97
96
|
group: HotkeyGroupId;
|
98
97
|
id: HotkeyId;
|
99
|
-
isDesktop?: boolean;
|
100
|
-
// 是否是桌面端专用的快捷键
|
101
98
|
keys: string;
|
102
99
|
// 是否为不可编辑的快捷键
|
103
100
|
nonEditable?: boolean;
|
@@ -105,7 +102,24 @@ export interface HotkeyItem {
|
|
105
102
|
scopes?: HotkeyScopeId[];
|
106
103
|
}
|
107
104
|
|
108
|
-
|
105
|
+
// ================== Desktop ================== //
|
106
|
+
|
107
|
+
export const DesktopHotkeyEnum = {
|
108
|
+
OpenSettings: 'openSettings',
|
109
|
+
ShowApp: 'showApp',
|
110
|
+
};
|
111
|
+
|
112
|
+
export type DesktopHotkeyId = (typeof DesktopHotkeyEnum)[keyof typeof DesktopHotkeyEnum];
|
113
|
+
|
114
|
+
export interface DesktopHotkeyItem {
|
115
|
+
id: DesktopHotkeyId;
|
116
|
+
|
117
|
+
keys: string;
|
118
|
+
// 是否为不可编辑的快捷键
|
119
|
+
nonEditable?: boolean;
|
120
|
+
}
|
121
|
+
|
122
|
+
export type DesktopHotkeyConfig = Record<DesktopHotkeyId, string>;
|
109
123
|
|
110
124
|
export type HotkeyI18nTranslations = Record<
|
111
125
|
HotkeyId,
|
package/src/utils/number.test.ts
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
import { describe, expect, it, vi } from 'vitest';
|
2
2
|
|
3
|
-
import { MAX_SEED, generateUniqueSeeds } from './number';
|
3
|
+
import { MAX_SEED, calculateThumbnailDimensions, generateUniqueSeeds } from './number';
|
4
4
|
|
5
5
|
describe('number utilities', () => {
|
6
6
|
describe('MAX_SEED constant', () => {
|
@@ -102,4 +102,104 @@ describe('number utilities', () => {
|
|
102
102
|
vi.useRealTimers();
|
103
103
|
});
|
104
104
|
});
|
105
|
+
|
106
|
+
describe('calculateThumbnailDimensions', () => {
|
107
|
+
it('should not resize when both dimensions are within max size', () => {
|
108
|
+
const result = calculateThumbnailDimensions(400, 300);
|
109
|
+
|
110
|
+
expect(result).toEqual({
|
111
|
+
shouldResize: false,
|
112
|
+
thumbnailWidth: 400,
|
113
|
+
thumbnailHeight: 300,
|
114
|
+
});
|
115
|
+
});
|
116
|
+
|
117
|
+
it('should not resize when dimensions equal max size', () => {
|
118
|
+
const result = calculateThumbnailDimensions(512, 512);
|
119
|
+
|
120
|
+
expect(result).toEqual({
|
121
|
+
shouldResize: false,
|
122
|
+
thumbnailWidth: 512,
|
123
|
+
thumbnailHeight: 512,
|
124
|
+
});
|
125
|
+
});
|
126
|
+
|
127
|
+
it('should resize when width exceeds max size (landscape)', () => {
|
128
|
+
const result = calculateThumbnailDimensions(1024, 768);
|
129
|
+
|
130
|
+
expect(result).toEqual({
|
131
|
+
shouldResize: true,
|
132
|
+
thumbnailWidth: 512,
|
133
|
+
thumbnailHeight: 384, // Math.round((768 * 512) / 1024)
|
134
|
+
});
|
135
|
+
});
|
136
|
+
|
137
|
+
it('should resize when height exceeds max size (portrait)', () => {
|
138
|
+
const result = calculateThumbnailDimensions(768, 1024);
|
139
|
+
|
140
|
+
expect(result).toEqual({
|
141
|
+
shouldResize: true,
|
142
|
+
thumbnailWidth: 384, // Math.round((768 * 512) / 1024)
|
143
|
+
thumbnailHeight: 512,
|
144
|
+
});
|
145
|
+
});
|
146
|
+
|
147
|
+
it('should resize square images correctly', () => {
|
148
|
+
const result = calculateThumbnailDimensions(1024, 1024);
|
149
|
+
|
150
|
+
expect(result).toEqual({
|
151
|
+
shouldResize: true,
|
152
|
+
thumbnailWidth: 512,
|
153
|
+
thumbnailHeight: 512,
|
154
|
+
});
|
155
|
+
});
|
156
|
+
|
157
|
+
it('should handle very large images', () => {
|
158
|
+
const result = calculateThumbnailDimensions(2048, 1536);
|
159
|
+
|
160
|
+
expect(result).toEqual({
|
161
|
+
shouldResize: true,
|
162
|
+
thumbnailWidth: 512,
|
163
|
+
thumbnailHeight: 384, // Math.round((1536 * 512) / 2048)
|
164
|
+
});
|
165
|
+
});
|
166
|
+
|
167
|
+
it('should handle very tall images', () => {
|
168
|
+
const result = calculateThumbnailDimensions(800, 2400);
|
169
|
+
|
170
|
+
expect(result).toEqual({
|
171
|
+
shouldResize: true,
|
172
|
+
thumbnailWidth: 171, // Math.round((800 * 512) / 2400)
|
173
|
+
thumbnailHeight: 512,
|
174
|
+
});
|
175
|
+
});
|
176
|
+
|
177
|
+
it('should work with custom max size', () => {
|
178
|
+
const result = calculateThumbnailDimensions(1000, 800, 256);
|
179
|
+
|
180
|
+
expect(result).toEqual({
|
181
|
+
shouldResize: true,
|
182
|
+
thumbnailWidth: 256,
|
183
|
+
thumbnailHeight: 205, // Math.round((800 * 256) / 1000)
|
184
|
+
});
|
185
|
+
});
|
186
|
+
|
187
|
+
it('should handle edge case with very small dimensions', () => {
|
188
|
+
const result = calculateThumbnailDimensions(50, 100);
|
189
|
+
|
190
|
+
expect(result).toEqual({
|
191
|
+
shouldResize: false,
|
192
|
+
thumbnailWidth: 50,
|
193
|
+
thumbnailHeight: 100,
|
194
|
+
});
|
195
|
+
});
|
196
|
+
|
197
|
+
it('should maintain aspect ratio correctly', () => {
|
198
|
+
const result = calculateThumbnailDimensions(1600, 900);
|
199
|
+
const originalRatio = 1600 / 900;
|
200
|
+
const thumbnailRatio = result.thumbnailWidth / result.thumbnailHeight;
|
201
|
+
|
202
|
+
expect(Math.abs(originalRatio - thumbnailRatio)).toBeLessThan(0.01);
|
203
|
+
});
|
204
|
+
});
|
105
205
|
});
|
package/src/utils/number.ts
CHANGED
@@ -1,5 +1,7 @@
|
|
1
1
|
import prand from 'pure-rand';
|
2
2
|
|
3
|
+
import { IMAGE_GENERATION_CONFIG } from '@/const/imageGeneration';
|
4
|
+
|
3
5
|
export const MAX_SEED = 2 ** 31 - 1;
|
4
6
|
export function generateUniqueSeeds(seedCount: number): number[] {
|
5
7
|
// Use current timestamp as the initial seed
|
@@ -23,3 +25,43 @@ export function generateUniqueSeeds(seedCount: number): number[] {
|
|
23
25
|
|
24
26
|
return Array.from(seeds);
|
25
27
|
}
|
28
|
+
|
29
|
+
/**
|
30
|
+
* Calculate thumbnail dimensions
|
31
|
+
* Generate thumbnail with configurable max edge size
|
32
|
+
*/
|
33
|
+
export function calculateThumbnailDimensions(
|
34
|
+
originalWidth: number,
|
35
|
+
originalHeight: number,
|
36
|
+
maxSize: number = IMAGE_GENERATION_CONFIG.THUMBNAIL_MAX_SIZE,
|
37
|
+
): {
|
38
|
+
shouldResize: boolean;
|
39
|
+
thumbnailHeight: number;
|
40
|
+
thumbnailWidth: number;
|
41
|
+
} {
|
42
|
+
const shouldResize = originalWidth > maxSize || originalHeight > maxSize;
|
43
|
+
|
44
|
+
if (!shouldResize) {
|
45
|
+
return {
|
46
|
+
shouldResize: false,
|
47
|
+
thumbnailHeight: originalHeight,
|
48
|
+
thumbnailWidth: originalWidth,
|
49
|
+
};
|
50
|
+
}
|
51
|
+
|
52
|
+
const thumbnailWidth =
|
53
|
+
originalWidth > originalHeight
|
54
|
+
? maxSize
|
55
|
+
: Math.round((originalWidth * maxSize) / originalHeight);
|
56
|
+
|
57
|
+
const thumbnailHeight =
|
58
|
+
originalHeight > originalWidth
|
59
|
+
? maxSize
|
60
|
+
: Math.round((originalHeight * maxSize) / originalWidth);
|
61
|
+
|
62
|
+
return {
|
63
|
+
shouldResize: true,
|
64
|
+
thumbnailHeight,
|
65
|
+
thumbnailWidth,
|
66
|
+
};
|
67
|
+
}
|