@lobehub/chat 1.104.0 → 1.104.2
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/core/ui/ShortcutManager.ts +61 -6
- package/apps/desktop/src/main/core/ui/__tests__/ShortcutManager.test.ts +539 -0
- package/changelog/v1.json +18 -0
- package/package.json +1 -1
- 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/const/imageGeneration.ts +18 -0
- 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/server/services/generation/index.test.ts +848 -0
- package/src/server/services/generation/index.ts +90 -69
- package/src/utils/number.test.ts +101 -1
- package/src/utils/number.ts +42 -0
@@ -1,11 +1,130 @@
|
|
1
|
-
import { Generation } from '@/types/generation';
|
1
|
+
import { Generation, GenerationBatch } from '@/types/generation';
|
2
2
|
|
3
|
-
//
|
4
|
-
export const
|
5
|
-
|
6
|
-
|
7
|
-
|
3
|
+
// Default maximum width for image items
|
4
|
+
export const DEFAULT_MAX_ITEM_WIDTH = 200;
|
5
|
+
|
6
|
+
/**
|
7
|
+
* Get image dimensions from various sources
|
8
|
+
* Returns width, height and aspect ratio when available
|
9
|
+
*/
|
10
|
+
export const getImageDimensions = (
|
11
|
+
generation: Generation,
|
12
|
+
generationBatch?: GenerationBatch,
|
13
|
+
): { aspectRatio: string | null; height: number | null; width: number | null } => {
|
14
|
+
// 1. Priority: actual dimensions from asset
|
15
|
+
if (
|
16
|
+
generation.asset?.width &&
|
17
|
+
generation.asset?.height &&
|
18
|
+
generation.asset.width > 0 &&
|
19
|
+
generation.asset.height > 0
|
20
|
+
) {
|
21
|
+
const { width, height } = generation.asset;
|
22
|
+
return {
|
23
|
+
aspectRatio: `${width} / ${height}`,
|
24
|
+
height,
|
25
|
+
width,
|
26
|
+
};
|
27
|
+
}
|
28
|
+
|
29
|
+
// 2. Try to get dimensions from generationBatch config
|
30
|
+
const config = generationBatch?.config;
|
31
|
+
if (config?.width && config?.height && config.width > 0 && config.height > 0) {
|
32
|
+
const { width, height } = config;
|
33
|
+
return {
|
34
|
+
aspectRatio: `${width} / ${height}`,
|
35
|
+
height,
|
36
|
+
width,
|
37
|
+
};
|
38
|
+
}
|
39
|
+
|
40
|
+
// 3. Try to get dimensions from generationBatch top-level
|
41
|
+
if (
|
42
|
+
generationBatch?.width &&
|
43
|
+
generationBatch?.height &&
|
44
|
+
generationBatch.width > 0 &&
|
45
|
+
generationBatch.height > 0
|
46
|
+
) {
|
47
|
+
const { width, height } = generationBatch;
|
48
|
+
return {
|
49
|
+
aspectRatio: `${width} / ${height}`,
|
50
|
+
height,
|
51
|
+
width,
|
52
|
+
};
|
53
|
+
}
|
54
|
+
|
55
|
+
// 4. Try to parse from size parameter (format: "1024x768")
|
56
|
+
if (config?.size && config.size !== 'auto') {
|
57
|
+
const sizeMatch = config.size.match(/^(\d+)x(\d+)$/);
|
58
|
+
if (sizeMatch) {
|
59
|
+
const [, widthStr, heightStr] = sizeMatch;
|
60
|
+
const width = parseInt(widthStr, 10);
|
61
|
+
const height = parseInt(heightStr, 10);
|
62
|
+
if (width > 0 && height > 0) {
|
63
|
+
return {
|
64
|
+
aspectRatio: `${width} / ${height}`,
|
65
|
+
height,
|
66
|
+
width,
|
67
|
+
};
|
68
|
+
}
|
69
|
+
}
|
70
|
+
}
|
71
|
+
|
72
|
+
// 5. Try to get aspect ratio only (format: "16:9")
|
73
|
+
if (config?.aspectRatio) {
|
74
|
+
const ratioMatch = config.aspectRatio.match(/^(\d+):(\d+)$/);
|
75
|
+
if (ratioMatch) {
|
76
|
+
const [, x, y] = ratioMatch;
|
77
|
+
return {
|
78
|
+
aspectRatio: `${x} / ${y}`,
|
79
|
+
height: null,
|
80
|
+
width: null,
|
81
|
+
};
|
82
|
+
}
|
8
83
|
}
|
9
84
|
|
10
|
-
|
85
|
+
// 6. No dimensions available
|
86
|
+
return {
|
87
|
+
aspectRatio: null,
|
88
|
+
height: null,
|
89
|
+
width: null,
|
90
|
+
};
|
91
|
+
};
|
92
|
+
|
93
|
+
export const getAspectRatio = (
|
94
|
+
generation: Generation,
|
95
|
+
generationBatch?: GenerationBatch,
|
96
|
+
): string => {
|
97
|
+
const dimensions = getImageDimensions(generation, generationBatch);
|
98
|
+
return dimensions.aspectRatio || '1 / 1';
|
99
|
+
};
|
100
|
+
|
101
|
+
/**
|
102
|
+
* Calculate display max width for generation items
|
103
|
+
* Ensures height doesn't exceed half screen height based on original aspect ratio
|
104
|
+
*
|
105
|
+
* @note This function is only used in client-side rendering environments.
|
106
|
+
* It directly accesses window.innerHeight and is not designed for SSR compatibility.
|
107
|
+
*/
|
108
|
+
export const getThumbnailMaxWidth = (
|
109
|
+
generation: Generation,
|
110
|
+
generationBatch?: GenerationBatch,
|
111
|
+
): number => {
|
112
|
+
const dimensions = getImageDimensions(generation, generationBatch);
|
113
|
+
|
114
|
+
// Return default width if dimensions are not available
|
115
|
+
if (!dimensions.width || !dimensions.height) {
|
116
|
+
return DEFAULT_MAX_ITEM_WIDTH;
|
117
|
+
}
|
118
|
+
|
119
|
+
const { width: originalWidth, height: originalHeight } = dimensions;
|
120
|
+
const aspectRatio = originalWidth / originalHeight;
|
121
|
+
|
122
|
+
// Apply screen height constraint (half of screen height)
|
123
|
+
// Note: window.innerHeight is safe to use here as this function is client-side only
|
124
|
+
const maxScreenHeight = window.innerHeight / 2;
|
125
|
+
const maxWidthFromHeight = Math.round(maxScreenHeight * aspectRatio);
|
126
|
+
|
127
|
+
// Use the smaller of: calculated width from height constraint or a reasonable maximum
|
128
|
+
const maxReasonableWidth = DEFAULT_MAX_ITEM_WIDTH * 2;
|
129
|
+
return Math.min(maxWidthFromHeight, maxReasonableWidth);
|
11
130
|
};
|
@@ -0,0 +1,18 @@
|
|
1
|
+
/**
|
2
|
+
* Image generation and processing configuration constants
|
3
|
+
*/
|
4
|
+
export const IMAGE_GENERATION_CONFIG = {
|
5
|
+
|
6
|
+
/**
|
7
|
+
* Maximum cover image size in pixels (longest edge)
|
8
|
+
* Used for generating cover images from source images
|
9
|
+
*/
|
10
|
+
COVER_MAX_SIZE: 256,
|
11
|
+
|
12
|
+
|
13
|
+
/**
|
14
|
+
* Maximum thumbnail size in pixels (longest edge)
|
15
|
+
* Used for generating thumbnail images from original images
|
16
|
+
*/
|
17
|
+
THUMBNAIL_MAX_SIZE: 512,
|
18
|
+
} as const;
|
@@ -1112,6 +1112,7 @@ describe('LobeOpenAICompatibleFactory', () => {
|
|
1112
1112
|
image: expect.any(File),
|
1113
1113
|
mask: 'https://example.com/mask.jpg',
|
1114
1114
|
response_format: 'b64_json',
|
1115
|
+
input_fidelity: 'high',
|
1115
1116
|
});
|
1116
1117
|
|
1117
1118
|
expect(result).toEqual({
|
@@ -1157,6 +1158,7 @@ describe('LobeOpenAICompatibleFactory', () => {
|
|
1157
1158
|
prompt: 'Merge these images',
|
1158
1159
|
image: [mockFile1, mockFile2],
|
1159
1160
|
response_format: 'b64_json',
|
1161
|
+
input_fidelity: 'high',
|
1160
1162
|
});
|
1161
1163
|
|
1162
1164
|
expect(result).toEqual({
|
@@ -1286,6 +1288,7 @@ describe('LobeOpenAICompatibleFactory', () => {
|
|
1286
1288
|
image: expect.any(File),
|
1287
1289
|
customParam: 'should remain unchanged',
|
1288
1290
|
response_format: 'b64_json',
|
1291
|
+
input_fidelity: 'high',
|
1289
1292
|
});
|
1290
1293
|
});
|
1291
1294
|
|
@@ -340,11 +340,6 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
|
|
340
340
|
|
341
341
|
log('Creating image with model: %s and params: %O', model, params);
|
342
342
|
|
343
|
-
const defaultInput = {
|
344
|
-
n: 1,
|
345
|
-
...(model.includes('dall-e') ? { response_format: 'b64_json' } : {}),
|
346
|
-
};
|
347
|
-
|
348
343
|
// 映射参数名称,将 imageUrls 映射为 image
|
349
344
|
const paramsMap = new Map<RuntimeImageGenParamsValue, string>([
|
350
345
|
['imageUrls', 'image'],
|
@@ -357,6 +352,7 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
|
|
357
352
|
]),
|
358
353
|
);
|
359
354
|
|
355
|
+
// https://platform.openai.com/docs/api-reference/images/createEdit
|
360
356
|
const isImageEdit = Array.isArray(userInput.image) && userInput.image.length > 0;
|
361
357
|
// 如果有 imageUrls 参数,将其转换为 File 对象
|
362
358
|
if (isImageEdit) {
|
@@ -383,6 +379,12 @@ export const createOpenAICompatibleRuntime = <T extends Record<string, any> = an
|
|
383
379
|
delete userInput.size;
|
384
380
|
}
|
385
381
|
|
382
|
+
const defaultInput = {
|
383
|
+
n: 1,
|
384
|
+
...(model.includes('dall-e') ? { response_format: 'b64_json' } : {}),
|
385
|
+
...(isImageEdit ? { input_fidelity: 'high' } : {}),
|
386
|
+
};
|
387
|
+
|
386
388
|
const options = {
|
387
389
|
model,
|
388
390
|
...defaultInput,
|
@@ -23,6 +23,7 @@ import {
|
|
23
23
|
const transformOpenAIStream = (
|
24
24
|
chunk: OpenAI.ChatCompletionChunk,
|
25
25
|
streamContext: StreamContext,
|
26
|
+
provider?: string,
|
26
27
|
): StreamProtocolChunk | StreamProtocolChunk[] => {
|
27
28
|
// handle the first chunk error
|
28
29
|
if (FIRST_CHUNK_ERROR_KEY in chunk) {
|
@@ -45,7 +46,7 @@ const transformOpenAIStream = (
|
|
45
46
|
if (!item) {
|
46
47
|
if (chunk.usage) {
|
47
48
|
const usage = chunk.usage;
|
48
|
-
return { data: convertUsage(usage), id: chunk.id, type: 'usage' };
|
49
|
+
return { data: convertUsage(usage, provider), id: chunk.id, type: 'usage' };
|
49
50
|
}
|
50
51
|
|
51
52
|
return { data: chunk, id: chunk.id, type: 'data' };
|
@@ -155,7 +156,7 @@ const transformOpenAIStream = (
|
|
155
156
|
|
156
157
|
if (chunk.usage) {
|
157
158
|
const usage = chunk.usage;
|
158
|
-
return { data: convertUsage(usage), id: chunk.id, type: 'usage' };
|
159
|
+
return { data: convertUsage(usage, provider), id: chunk.id, type: 'usage' };
|
159
160
|
}
|
160
161
|
|
161
162
|
// xAI Live Search 功能返回引用源
|
@@ -274,7 +275,7 @@ const transformOpenAIStream = (
|
|
274
275
|
// litellm 的返回结果中,存在 delta 为空,但是有 usage 的情况
|
275
276
|
if (chunk.usage) {
|
276
277
|
const usage = chunk.usage;
|
277
|
-
return { data: convertUsage(usage), id: chunk.id, type: 'usage' };
|
278
|
+
return { data: convertUsage(usage, provider), id: chunk.id, type: 'usage' };
|
278
279
|
}
|
279
280
|
|
280
281
|
// 其余情况下,返回 delta 和 index
|
@@ -321,6 +322,9 @@ export const OpenAIStream = (
|
|
321
322
|
) => {
|
322
323
|
const streamStack: StreamContext = { id: '' };
|
323
324
|
|
325
|
+
const transformWithProvider = (chunk: OpenAI.ChatCompletionChunk, streamContext: StreamContext) =>
|
326
|
+
transformOpenAIStream(chunk, streamContext, provider);
|
327
|
+
|
324
328
|
const readableStream =
|
325
329
|
stream instanceof ReadableStream ? stream : convertIterableToStream(stream);
|
326
330
|
|
@@ -330,7 +334,7 @@ export const OpenAIStream = (
|
|
330
334
|
// provider like huggingface or minimax will return error in the stream,
|
331
335
|
// so in the first Transformer, we need to handle the error
|
332
336
|
.pipeThrough(createFirstErrorHandleTransformer(bizErrorTypeTransformer, provider))
|
333
|
-
.pipeThrough(createTokenSpeedCalculator(
|
337
|
+
.pipeThrough(createTokenSpeedCalculator(transformWithProvider, { inputStartAt, streamStack }))
|
334
338
|
.pipeThrough(createSSEProtocolTransformer((c) => c, streamStack))
|
335
339
|
.pipeThrough(createCallbacksTransformer(callbacks))
|
336
340
|
);
|
@@ -1,7 +1,7 @@
|
|
1
1
|
import OpenAI from 'openai';
|
2
2
|
import { describe, expect, it } from 'vitest';
|
3
3
|
|
4
|
-
import { convertUsage } from './usageConverter';
|
4
|
+
import { convertUsage, convertResponseUsage } from './usageConverter';
|
5
5
|
|
6
6
|
describe('convertUsage', () => {
|
7
7
|
it('should convert basic OpenAI usage data correctly', () => {
|
@@ -246,4 +246,48 @@ describe('convertUsage', () => {
|
|
246
246
|
expect(result).not.toHaveProperty('outputReasoningTokens');
|
247
247
|
expect(result).not.toHaveProperty('outputAudioTokens');
|
248
248
|
});
|
249
|
+
|
250
|
+
it('should handle XAI provider correctly where completion_tokens does not include reasoning_tokens', () => {
|
251
|
+
// Arrange
|
252
|
+
const xaiUsage: OpenAI.Completions.CompletionUsage = {
|
253
|
+
prompt_tokens: 6103,
|
254
|
+
completion_tokens: 66, // 这个不包含 reasoning_tokens
|
255
|
+
total_tokens: 6550,
|
256
|
+
prompt_tokens_details: {
|
257
|
+
audio_tokens: 0,
|
258
|
+
cached_tokens: 0,
|
259
|
+
},
|
260
|
+
completion_tokens_details: {
|
261
|
+
accepted_prediction_tokens: 0,
|
262
|
+
audio_tokens: 0,
|
263
|
+
reasoning_tokens: 381, // 这是额外的 reasoning tokens
|
264
|
+
rejected_prediction_tokens: 0,
|
265
|
+
},
|
266
|
+
};
|
267
|
+
|
268
|
+
// Act
|
269
|
+
const xaiResult = convertUsage(xaiUsage, 'xai');
|
270
|
+
|
271
|
+
// Assert
|
272
|
+
expect(xaiResult).toMatchObject({
|
273
|
+
totalInputTokens: 6103,
|
274
|
+
totalOutputTokens: 66,
|
275
|
+
outputTextTokens: 66, // 不减去 reasoning_tokens
|
276
|
+
outputReasoningTokens: 381,
|
277
|
+
totalTokens: 6550,
|
278
|
+
});
|
279
|
+
|
280
|
+
// 测试其他 provider(默认行为)
|
281
|
+
const defaultResult = convertUsage(xaiUsage);
|
282
|
+
|
283
|
+
// 默认行为: outputTextTokens 应该是 completion_tokens - reasoning_tokens - audio_tokens = 66 - 381 - 0 = -315
|
284
|
+
expect(defaultResult.outputTextTokens).toBe(-315);
|
285
|
+
expect(defaultResult).toMatchObject({
|
286
|
+
totalInputTokens: 6103,
|
287
|
+
totalOutputTokens: 66,
|
288
|
+
outputTextTokens: -315, // 负数确实会出现在结果中
|
289
|
+
outputReasoningTokens: 381,
|
290
|
+
totalTokens: 6550,
|
291
|
+
});
|
292
|
+
});
|
249
293
|
});
|
@@ -2,7 +2,7 @@ import OpenAI from 'openai';
|
|
2
2
|
|
3
3
|
import { ModelTokensUsage } from '@/types/message';
|
4
4
|
|
5
|
-
export const convertUsage = (usage: OpenAI.Completions.CompletionUsage): ModelTokensUsage => {
|
5
|
+
export const convertUsage = (usage: OpenAI.Completions.CompletionUsage, provider?: string): ModelTokensUsage => {
|
6
6
|
// 目前只有 pplx 才有 citation_tokens
|
7
7
|
const inputTextTokens = usage.prompt_tokens || 0;
|
8
8
|
const inputCitationTokens = (usage as any).citation_tokens || 0;
|
@@ -17,7 +17,11 @@ export const convertUsage = (usage: OpenAI.Completions.CompletionUsage): ModelTo
|
|
17
17
|
const totalOutputTokens = usage.completion_tokens;
|
18
18
|
const outputReasoning = usage.completion_tokens_details?.reasoning_tokens || 0;
|
19
19
|
const outputAudioTokens = usage.completion_tokens_details?.audio_tokens || 0;
|
20
|
-
|
20
|
+
|
21
|
+
// XAI 的 completion_tokens 不包含 reasoning_tokens,需要特殊处理
|
22
|
+
const outputTextTokens = provider === 'xai'
|
23
|
+
? totalOutputTokens - outputAudioTokens
|
24
|
+
: totalOutputTokens - outputReasoning - outputAudioTokens;
|
21
25
|
|
22
26
|
const totalTokens = inputCitationTokens + usage.total_tokens;
|
23
27
|
|