@lobehub/chat 1.104.0 → 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.
Files changed (23) hide show
  1. package/.cursor/rules/code-review.mdc +2 -0
  2. package/.cursor/rules/typescript.mdc +3 -1
  3. package/CHANGELOG.md +25 -0
  4. package/changelog/v1.json +9 -0
  5. package/package.json +1 -1
  6. package/src/app/[variants]/(main)/image/features/GenerationFeed/BatchItem.tsx +6 -1
  7. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/ErrorState.tsx +3 -2
  8. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/LoadingState.tsx +27 -24
  9. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/SuccessState.tsx +14 -3
  10. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/index.tsx +4 -7
  11. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/types.ts +3 -0
  12. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/utils.test.ts +600 -0
  13. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/utils.ts +126 -7
  14. package/src/const/imageGeneration.ts +18 -0
  15. package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.test.ts +3 -0
  16. package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.ts +7 -5
  17. package/src/libs/model-runtime/utils/streams/openai/openai.ts +8 -4
  18. package/src/libs/model-runtime/utils/usageConverter.test.ts +45 -1
  19. package/src/libs/model-runtime/utils/usageConverter.ts +6 -2
  20. package/src/server/services/generation/index.test.ts +848 -0
  21. package/src/server/services/generation/index.ts +90 -69
  22. package/src/utils/number.test.ts +101 -1
  23. 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
- // 计算图片的宽高比,用于设置容器的 aspect-ratio
4
- export const getAspectRatio = (asset: Generation['asset']) => {
5
- if (!asset?.width || !asset?.height) {
6
- // 如果没有尺寸信息,使用 1:1 比例
7
- return '1 / 1';
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
- return `${asset.width} / ${asset.height}`;
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(transformOpenAIStream, { inputStartAt, streamStack }))
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
- const outputTextTokens = totalOutputTokens - outputReasoning - outputAudioTokens;
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