@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
@@ -0,0 +1,600 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import type { Generation, GenerationBatch } from '@/types/generation';
4
+
5
+ // Import functions for testing
6
+ import {
7
+ DEFAULT_MAX_ITEM_WIDTH,
8
+ getAspectRatio,
9
+ getImageDimensions,
10
+ getThumbnailMaxWidth,
11
+ } from './utils';
12
+
13
+ describe('getImageDimensions', () => {
14
+ // Mock base generation object
15
+ const baseGeneration: Generation = {
16
+ id: 'test-gen-id',
17
+ seed: 12345,
18
+ createdAt: new Date(),
19
+ asyncTaskId: null,
20
+ task: {
21
+ id: 'task-id',
22
+ status: 'success' as any,
23
+ },
24
+ };
25
+
26
+ describe('with asset dimensions', () => {
27
+ it('should return width, height and aspect ratio from asset', () => {
28
+ const generation: Generation = {
29
+ ...baseGeneration,
30
+ asset: {
31
+ type: 'image',
32
+ width: 1920,
33
+ height: 1080,
34
+ },
35
+ };
36
+
37
+ const result = getImageDimensions(generation);
38
+ expect(result).toEqual({
39
+ width: 1920,
40
+ height: 1080,
41
+ aspectRatio: '1920 / 1080',
42
+ });
43
+ });
44
+
45
+ it('should prioritize asset even when other sources exist', () => {
46
+ const generation: Generation = {
47
+ ...baseGeneration,
48
+ asset: {
49
+ type: 'image',
50
+ width: 800,
51
+ height: 600,
52
+ },
53
+ };
54
+
55
+ const generationBatch: GenerationBatch = {
56
+ id: 'batch-id',
57
+ provider: 'test',
58
+ model: 'test-model',
59
+ prompt: 'test prompt',
60
+ width: 1024,
61
+ height: 1024,
62
+ config: {
63
+ prompt: 'test',
64
+ width: 512,
65
+ height: 512,
66
+ },
67
+ createdAt: new Date(),
68
+ generations: [],
69
+ };
70
+
71
+ const result = getImageDimensions(generation, generationBatch);
72
+ expect(result).toEqual({
73
+ width: 800,
74
+ height: 600,
75
+ aspectRatio: '800 / 600',
76
+ });
77
+ });
78
+ });
79
+
80
+ describe('with config dimensions', () => {
81
+ it('should return dimensions from config when asset is not available', () => {
82
+ const generation: Generation = {
83
+ ...baseGeneration,
84
+ asset: null,
85
+ };
86
+
87
+ const generationBatch: GenerationBatch = {
88
+ id: 'batch-id',
89
+ provider: 'test',
90
+ model: 'test-model',
91
+ prompt: 'test prompt',
92
+ config: {
93
+ prompt: 'test',
94
+ width: 1024,
95
+ height: 768,
96
+ },
97
+ createdAt: new Date(),
98
+ generations: [],
99
+ };
100
+
101
+ const result = getImageDimensions(generation, generationBatch);
102
+ expect(result).toEqual({
103
+ width: 1024,
104
+ height: 768,
105
+ aspectRatio: '1024 / 768',
106
+ });
107
+ });
108
+ });
109
+
110
+ describe('with batch top-level dimensions', () => {
111
+ it('should return dimensions from batch when config is not available', () => {
112
+ const generation: Generation = {
113
+ ...baseGeneration,
114
+ asset: null,
115
+ };
116
+
117
+ const generationBatch: GenerationBatch = {
118
+ id: 'batch-id',
119
+ provider: 'test',
120
+ model: 'test-model',
121
+ prompt: 'test prompt',
122
+ width: 1280,
123
+ height: 720,
124
+ createdAt: new Date(),
125
+ generations: [],
126
+ };
127
+
128
+ const result = getImageDimensions(generation, generationBatch);
129
+ expect(result).toEqual({
130
+ width: 1280,
131
+ height: 720,
132
+ aspectRatio: '1280 / 720',
133
+ });
134
+ });
135
+ });
136
+
137
+ describe('with size parameter', () => {
138
+ it('should parse dimensions from size parameter', () => {
139
+ const generation: Generation = {
140
+ ...baseGeneration,
141
+ asset: null,
142
+ };
143
+
144
+ const generationBatch: GenerationBatch = {
145
+ id: 'batch-id',
146
+ provider: 'test',
147
+ model: 'test-model',
148
+ prompt: 'test prompt',
149
+ config: {
150
+ prompt: 'test',
151
+ size: '1920x1080',
152
+ },
153
+ createdAt: new Date(),
154
+ generations: [],
155
+ };
156
+
157
+ const result = getImageDimensions(generation, generationBatch);
158
+ expect(result).toEqual({
159
+ width: 1920,
160
+ height: 1080,
161
+ aspectRatio: '1920 / 1080',
162
+ });
163
+ });
164
+
165
+ it('should ignore size when it is "auto"', () => {
166
+ const generation: Generation = {
167
+ ...baseGeneration,
168
+ asset: null,
169
+ };
170
+
171
+ const generationBatch: GenerationBatch = {
172
+ id: 'batch-id',
173
+ provider: 'test',
174
+ model: 'test-model',
175
+ prompt: 'test prompt',
176
+ config: {
177
+ prompt: 'test',
178
+ size: 'auto',
179
+ aspectRatio: '16:9',
180
+ },
181
+ createdAt: new Date(),
182
+ generations: [],
183
+ };
184
+
185
+ const result = getImageDimensions(generation, generationBatch);
186
+ expect(result).toEqual({
187
+ width: null,
188
+ height: null,
189
+ aspectRatio: '16 / 9',
190
+ });
191
+ });
192
+ });
193
+
194
+ describe('with aspectRatio parameter only', () => {
195
+ it('should return aspect ratio without dimensions', () => {
196
+ const generation: Generation = {
197
+ ...baseGeneration,
198
+ asset: null,
199
+ };
200
+
201
+ const generationBatch: GenerationBatch = {
202
+ id: 'batch-id',
203
+ provider: 'test',
204
+ model: 'test-model',
205
+ prompt: 'test prompt',
206
+ config: {
207
+ prompt: 'test',
208
+ aspectRatio: '16:9',
209
+ },
210
+ createdAt: new Date(),
211
+ generations: [],
212
+ };
213
+
214
+ const result = getImageDimensions(generation, generationBatch);
215
+ expect(result).toEqual({
216
+ width: null,
217
+ height: null,
218
+ aspectRatio: '16 / 9',
219
+ });
220
+ });
221
+
222
+ it('should handle various aspect ratio formats', () => {
223
+ const testCases = [
224
+ { aspectRatio: '1:1', expected: '1 / 1' },
225
+ { aspectRatio: '4:3', expected: '4 / 3' },
226
+ { aspectRatio: '16:9', expected: '16 / 9' },
227
+ { aspectRatio: '21:9', expected: '21 / 9' },
228
+ ];
229
+
230
+ testCases.forEach(({ aspectRatio, expected }) => {
231
+ const generation: Generation = {
232
+ ...baseGeneration,
233
+ asset: null,
234
+ };
235
+
236
+ const generationBatch: GenerationBatch = {
237
+ id: 'batch-id',
238
+ provider: 'test',
239
+ model: 'test-model',
240
+ prompt: 'test prompt',
241
+ config: {
242
+ prompt: 'test',
243
+ aspectRatio,
244
+ },
245
+ createdAt: new Date(),
246
+ generations: [],
247
+ };
248
+
249
+ const result = getImageDimensions(generation, generationBatch);
250
+ expect(result.aspectRatio).toBe(expected);
251
+ });
252
+ });
253
+ });
254
+
255
+ describe('edge cases', () => {
256
+ it('should return all null when no dimensions are available', () => {
257
+ const generation: Generation = {
258
+ ...baseGeneration,
259
+ asset: null,
260
+ };
261
+
262
+ const result = getImageDimensions(generation);
263
+ expect(result).toEqual({
264
+ width: null,
265
+ height: null,
266
+ aspectRatio: null,
267
+ });
268
+ });
269
+
270
+ it('should handle partial asset dimensions', () => {
271
+ const generation: Generation = {
272
+ ...baseGeneration,
273
+ asset: {
274
+ type: 'image',
275
+ width: 1920,
276
+ // height is missing
277
+ },
278
+ };
279
+
280
+ const generationBatch: GenerationBatch = {
281
+ id: 'batch-id',
282
+ provider: 'test',
283
+ model: 'test-model',
284
+ prompt: 'test prompt',
285
+ config: {
286
+ prompt: 'test',
287
+ width: 1024,
288
+ height: 768,
289
+ },
290
+ createdAt: new Date(),
291
+ generations: [],
292
+ };
293
+
294
+ const result = getImageDimensions(generation, generationBatch);
295
+ expect(result).toEqual({
296
+ width: 1024,
297
+ height: 768,
298
+ aspectRatio: '1024 / 768',
299
+ });
300
+ });
301
+
302
+ it('should handle invalid size format', () => {
303
+ const generation: Generation = {
304
+ ...baseGeneration,
305
+ asset: null,
306
+ };
307
+
308
+ const generationBatch: GenerationBatch = {
309
+ id: 'batch-id',
310
+ provider: 'test',
311
+ model: 'test-model',
312
+ prompt: 'test prompt',
313
+ config: {
314
+ prompt: 'test',
315
+ size: 'invalid-format',
316
+ },
317
+ createdAt: new Date(),
318
+ generations: [],
319
+ };
320
+
321
+ const result = getImageDimensions(generation, generationBatch);
322
+ expect(result).toEqual({
323
+ width: null,
324
+ height: null,
325
+ aspectRatio: null,
326
+ });
327
+ });
328
+
329
+ it('should handle invalid aspectRatio format', () => {
330
+ const generation: Generation = {
331
+ ...baseGeneration,
332
+ asset: null,
333
+ };
334
+
335
+ const generationBatch: GenerationBatch = {
336
+ id: 'batch-id',
337
+ provider: 'test',
338
+ model: 'test-model',
339
+ prompt: 'test prompt',
340
+ config: {
341
+ prompt: 'test',
342
+ aspectRatio: 'invalid-format',
343
+ },
344
+ createdAt: new Date(),
345
+ generations: [],
346
+ };
347
+
348
+ const result = getImageDimensions(generation, generationBatch);
349
+ expect(result).toEqual({
350
+ width: null,
351
+ height: null,
352
+ aspectRatio: null,
353
+ });
354
+ });
355
+
356
+ it('should handle zero dimensions', () => {
357
+ const generation: Generation = {
358
+ ...baseGeneration,
359
+ asset: {
360
+ type: 'image',
361
+ width: 0,
362
+ height: 0,
363
+ },
364
+ };
365
+
366
+ const result = getImageDimensions(generation);
367
+ expect(result).toEqual({
368
+ width: null,
369
+ height: null,
370
+ aspectRatio: null,
371
+ });
372
+ });
373
+ });
374
+ });
375
+
376
+ describe('getAspectRatio (isolated unit testing)', () => {
377
+ const mockGeneration: Generation = {
378
+ id: 'test-gen-id',
379
+ seed: 12345,
380
+ createdAt: new Date(),
381
+ asyncTaskId: null,
382
+ task: {
383
+ id: 'task-id',
384
+ status: 'success' as any,
385
+ },
386
+ };
387
+ const mockGenerationBatch: GenerationBatch = {
388
+ id: 'test-batch-id',
389
+ provider: 'test-provider',
390
+ model: 'test-model',
391
+ prompt: 'test prompt',
392
+ createdAt: new Date(),
393
+ generations: [],
394
+ };
395
+
396
+ beforeEach(() => {
397
+ vi.clearAllMocks();
398
+ });
399
+
400
+ it('should return aspectRatio from getImageDimensions when dimensions have aspectRatio', () => {
401
+ // Test the actual implementation directly with mock data
402
+ const mockGen: Generation = {
403
+ ...mockGeneration,
404
+ asset: {
405
+ type: 'image',
406
+ width: 1920,
407
+ height: 1080,
408
+ },
409
+ };
410
+
411
+ const result = getAspectRatio(mockGen);
412
+ expect(result).toBe('1920 / 1080');
413
+ });
414
+
415
+ it('should return default "1 / 1" when no dimensions are available', () => {
416
+ const result = getAspectRatio(mockGeneration, mockGenerationBatch);
417
+ expect(result).toBe('1 / 1');
418
+ });
419
+
420
+ it('should work with different aspectRatio sources', () => {
421
+ const mockBatch: GenerationBatch = {
422
+ id: 'test-batch',
423
+ provider: 'test-provider',
424
+ model: 'test-model',
425
+ prompt: 'test prompt',
426
+ createdAt: new Date(),
427
+ generations: [],
428
+ config: {
429
+ prompt: 'test prompt',
430
+ aspectRatio: '16:9',
431
+ },
432
+ };
433
+
434
+ const result = getAspectRatio(mockGeneration, mockBatch);
435
+ expect(result).toBe('16 / 9');
436
+ });
437
+ });
438
+
439
+ describe('getThumbnailMaxWidth (isolated unit testing)', () => {
440
+ const mockGeneration: Generation = {
441
+ id: 'test-gen-id',
442
+ seed: 12345,
443
+ createdAt: new Date(),
444
+ asyncTaskId: null,
445
+ task: {
446
+ id: 'task-id',
447
+ status: 'success' as any,
448
+ },
449
+ };
450
+ const mockGenerationBatch: GenerationBatch = {
451
+ id: 'test-batch-id',
452
+ provider: 'test-provider',
453
+ model: 'test-model',
454
+ prompt: 'test prompt',
455
+ createdAt: new Date(),
456
+ generations: [],
457
+ };
458
+
459
+ // Mock window.innerHeight for tests
460
+ const originalWindow = global.window;
461
+
462
+ beforeEach(() => {
463
+ vi.clearAllMocks();
464
+ Object.defineProperty(global, 'window', {
465
+ writable: true,
466
+ value: {
467
+ innerHeight: 800,
468
+ },
469
+ });
470
+ });
471
+
472
+ afterEach(() => {
473
+ global.window = originalWindow;
474
+ });
475
+
476
+ it('should return DEFAULT_MAX_ITEM_WIDTH when no dimensions available', () => {
477
+ const result = getThumbnailMaxWidth(mockGeneration, mockGenerationBatch);
478
+ expect(result).toBe(DEFAULT_MAX_ITEM_WIDTH);
479
+ });
480
+
481
+ it('should return DEFAULT_MAX_ITEM_WIDTH when width is missing', () => {
482
+ const mockGen: Generation = {
483
+ ...mockGeneration,
484
+ // No asset with width/height, should fall back to default
485
+ };
486
+ const result = getThumbnailMaxWidth(mockGen);
487
+ expect(result).toBe(DEFAULT_MAX_ITEM_WIDTH);
488
+ });
489
+
490
+ it('should return DEFAULT_MAX_ITEM_WIDTH when height is missing', () => {
491
+ const mockGen: Generation = {
492
+ ...mockGeneration,
493
+ // No asset with valid dimensions
494
+ };
495
+ const result = getThumbnailMaxWidth(mockGen);
496
+ expect(result).toBe(DEFAULT_MAX_ITEM_WIDTH);
497
+ });
498
+
499
+ it('should calculate width based on screen height constraint', () => {
500
+ const mockGen: Generation = {
501
+ ...mockGeneration,
502
+ asset: {
503
+ type: 'image',
504
+ width: 300,
505
+ height: 200,
506
+ },
507
+ };
508
+
509
+ // aspectRatio = 300/200 = 1.5
510
+ // maxScreenHeight = 800/2 = 400
511
+ // maxWidthFromHeight = 400 * 1.5 = 600
512
+ // maxReasonableWidth = 200 * 2 = 400
513
+ // min(600, 400) = 400
514
+ const result = getThumbnailMaxWidth(mockGen);
515
+ expect(result).toBe(400);
516
+ });
517
+
518
+ it('should apply maxReasonableWidth limit', () => {
519
+ const mockGen: Generation = {
520
+ ...mockGeneration,
521
+ asset: {
522
+ type: 'image',
523
+ width: 600,
524
+ height: 200,
525
+ },
526
+ };
527
+
528
+ // aspectRatio = 600/200 = 3
529
+ // maxScreenHeight = 800/2 = 400
530
+ // maxWidthFromHeight = 400 * 3 = 1200
531
+ // maxReasonableWidth = 200 * 2 = 400
532
+ // min(1200, 400) = 400
533
+ const result = getThumbnailMaxWidth(mockGen);
534
+ expect(result).toBe(400);
535
+ });
536
+
537
+ it('should use screen height constraint when smaller', () => {
538
+ const mockGen: Generation = {
539
+ ...mockGeneration,
540
+ asset: {
541
+ type: 'image',
542
+ width: 200,
543
+ height: 400,
544
+ },
545
+ };
546
+
547
+ // aspectRatio = 200/400 = 0.5
548
+ // maxScreenHeight = 800/2 = 400
549
+ // maxWidthFromHeight = 400 * 0.5 = 200
550
+ // maxReasonableWidth = 200 * 2 = 400
551
+ // min(200, 400) = 200
552
+ const result = getThumbnailMaxWidth(mockGen);
553
+ expect(result).toBe(200);
554
+ });
555
+
556
+ it('should handle different window.innerHeight values', () => {
557
+ Object.defineProperty(global, 'window', {
558
+ writable: true,
559
+ value: {
560
+ innerHeight: 600,
561
+ },
562
+ });
563
+
564
+ const mockGen: Generation = {
565
+ ...mockGeneration,
566
+ asset: {
567
+ type: 'image',
568
+ width: 400,
569
+ height: 200,
570
+ },
571
+ };
572
+
573
+ // aspectRatio = 400/200 = 2
574
+ // maxScreenHeight = 600/2 = 300
575
+ // maxWidthFromHeight = 300 * 2 = 600
576
+ // maxReasonableWidth = 200 * 2 = 400
577
+ // min(600, 400) = 400
578
+ const result = getThumbnailMaxWidth(mockGen);
579
+ expect(result).toBe(400);
580
+ });
581
+
582
+ it('should round calculated width correctly', () => {
583
+ const mockGen: Generation = {
584
+ ...mockGeneration,
585
+ asset: {
586
+ type: 'image',
587
+ width: 512,
588
+ height: 1000,
589
+ },
590
+ };
591
+
592
+ // aspectRatio = 512/1000 = 0.512
593
+ // maxScreenHeight = 800/2 = 400
594
+ // maxWidthFromHeight = Math.round(400 * 0.512) = Math.round(204.8) = 205
595
+ // maxReasonableWidth = 200 * 2 = 400
596
+ // min(205, 400) = 205
597
+ const result = getThumbnailMaxWidth(mockGen);
598
+ expect(result).toBe(205);
599
+ });
600
+ });