@lobehub/chat 1.102.3 → 1.103.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/apps/desktop/README.md +322 -36
  3. package/apps/desktop/README.zh-CN.md +353 -0
  4. package/apps/desktop/package.json +1 -0
  5. package/apps/desktop/resources/tray-dark.png +0 -0
  6. package/apps/desktop/resources/tray-light.png +0 -0
  7. package/apps/desktop/resources/tray.png +0 -0
  8. package/apps/desktop/src/main/const/env.ts +25 -0
  9. package/apps/desktop/src/main/core/TrayManager.ts +7 -1
  10. package/changelog/v1.json +18 -0
  11. package/locales/ar/subscription.json +24 -0
  12. package/locales/bg-BG/subscription.json +24 -0
  13. package/locales/de-DE/subscription.json +24 -0
  14. package/locales/en-US/subscription.json +24 -0
  15. package/locales/es-ES/subscription.json +24 -0
  16. package/locales/fa-IR/subscription.json +24 -0
  17. package/locales/fr-FR/subscription.json +24 -0
  18. package/locales/it-IT/subscription.json +24 -0
  19. package/locales/ja-JP/subscription.json +24 -0
  20. package/locales/ko-KR/subscription.json +24 -0
  21. package/locales/nl-NL/subscription.json +24 -0
  22. package/locales/pl-PL/subscription.json +24 -0
  23. package/locales/pt-BR/subscription.json +24 -0
  24. package/locales/ru-RU/subscription.json +24 -0
  25. package/locales/tr-TR/subscription.json +24 -0
  26. package/locales/vi-VN/subscription.json +24 -0
  27. package/locales/zh-CN/subscription.json +24 -0
  28. package/locales/zh-TW/subscription.json +24 -0
  29. package/package.json +1 -1
  30. package/packages/electron-client-ipc/README.md +55 -30
  31. package/packages/electron-client-ipc/README.zh-CN.md +73 -0
  32. package/packages/electron-server-ipc/README.md +42 -20
  33. package/packages/electron-server-ipc/README.zh-CN.md +76 -0
  34. package/packages/file-loaders/README.md +77 -51
  35. package/packages/file-loaders/README.zh-CN.md +89 -0
  36. package/src/app/[variants]/(main)/chat/(workspace)/_layout/Desktop/ChatHeader/HeaderAction.tsx +11 -8
  37. package/src/app/[variants]/(main)/chat/(workspace)/features/SettingButton.tsx +11 -8
  38. package/src/app/[variants]/(main)/chat/(workspace)/features/ShareButton/index.tsx +3 -0
  39. package/src/app/[variants]/(main)/chat/@session/_layout/Desktop/SessionHeader.tsx +3 -0
  40. package/src/config/aiModels/qwen.ts +22 -2
  41. package/src/features/PlanIcon/index.tsx +126 -0
  42. package/src/features/User/PlanTag.tsx +33 -25
  43. package/src/libs/model-runtime/qwen/createImage.test.ts +613 -0
  44. package/src/libs/model-runtime/qwen/createImage.ts +218 -0
  45. package/src/libs/model-runtime/qwen/index.ts +2 -0
  46. package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.ts +19 -1
  47. package/src/locales/default/index.ts +2 -0
  48. package/src/locales/default/subscription.ts +24 -0
  49. package/src/types/subscription.ts +7 -0
  50. package/apps/desktop/resources/tray-icon.png +0 -0
@@ -0,0 +1,613 @@
1
+ // @vitest-environment edge-runtime
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { CreateImagePayload } from '../types/image';
5
+ import { CreateImageOptions } from '../utils/openaiCompatibleFactory';
6
+ import { createQwenImage } from './createImage';
7
+
8
+ // Mock the console.error to avoid polluting test output
9
+ vi.spyOn(console, 'error').mockImplementation(() => {});
10
+
11
+ const mockOptions: CreateImageOptions = {
12
+ apiKey: 'test-api-key',
13
+ provider: 'qwen',
14
+ };
15
+
16
+ beforeEach(() => {
17
+ // Reset all mocks before each test
18
+ vi.clearAllMocks();
19
+ });
20
+
21
+ afterEach(() => {
22
+ vi.clearAllMocks();
23
+ });
24
+
25
+ describe('createQwenImage', () => {
26
+ describe('Success scenarios', () => {
27
+ it('should successfully generate image with immediate success', async () => {
28
+ const mockTaskId = 'task-123456';
29
+ const mockImageUrl = 'https://dashscope.oss-cn-beijing.aliyuncs.com/aigc/test-image.jpg';
30
+
31
+ // Mock fetch for task creation and immediate success
32
+ global.fetch = vi
33
+ .fn()
34
+ .mockResolvedValueOnce({
35
+ ok: true,
36
+ json: async () => ({
37
+ output: { task_id: mockTaskId },
38
+ request_id: 'req-123',
39
+ }),
40
+ })
41
+ .mockResolvedValueOnce({
42
+ ok: true,
43
+ json: async () => ({
44
+ output: {
45
+ task_id: mockTaskId,
46
+ task_status: 'SUCCEEDED',
47
+ results: [{ url: mockImageUrl }],
48
+ },
49
+ request_id: 'req-124',
50
+ }),
51
+ });
52
+
53
+ const payload: CreateImagePayload = {
54
+ model: 'wanx2.1-t2i-turbo',
55
+ params: {
56
+ prompt: 'A beautiful sunset over the mountains',
57
+ },
58
+ };
59
+
60
+ const result = await createQwenImage(payload, mockOptions);
61
+
62
+ // Verify task creation request
63
+ expect(fetch).toHaveBeenCalledWith(
64
+ 'https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis',
65
+ {
66
+ method: 'POST',
67
+ headers: {
68
+ 'Authorization': 'Bearer test-api-key',
69
+ 'Content-Type': 'application/json',
70
+ 'X-DashScope-Async': 'enable',
71
+ },
72
+ body: JSON.stringify({
73
+ input: {
74
+ prompt: 'A beautiful sunset over the mountains',
75
+ },
76
+ model: 'wanx2.1-t2i-turbo',
77
+ parameters: {
78
+ n: 1,
79
+ size: '1024*1024',
80
+ },
81
+ }),
82
+ },
83
+ );
84
+
85
+ // Verify status query request
86
+ expect(fetch).toHaveBeenCalledWith(
87
+ `https://dashscope.aliyuncs.com/api/v1/tasks/${mockTaskId}`,
88
+ {
89
+ headers: {
90
+ Authorization: 'Bearer test-api-key',
91
+ },
92
+ },
93
+ );
94
+
95
+ expect(result).toEqual({
96
+ imageUrl: mockImageUrl,
97
+ });
98
+ });
99
+
100
+ it('should handle task that needs polling before success', async () => {
101
+ const mockTaskId = 'task-polling';
102
+ const mockImageUrl = 'https://dashscope.oss-cn-beijing.aliyuncs.com/aigc/test-image-3.jpg';
103
+
104
+ global.fetch = vi
105
+ .fn()
106
+ .mockResolvedValueOnce({
107
+ ok: true,
108
+ json: async () => ({
109
+ output: { task_id: mockTaskId },
110
+ request_id: 'req-127',
111
+ }),
112
+ })
113
+ // First status query - still running
114
+ .mockResolvedValueOnce({
115
+ ok: true,
116
+ json: async () => ({
117
+ output: {
118
+ task_id: mockTaskId,
119
+ task_status: 'RUNNING',
120
+ },
121
+ request_id: 'req-128',
122
+ }),
123
+ })
124
+ // Second status query - succeeded
125
+ .mockResolvedValueOnce({
126
+ ok: true,
127
+ json: async () => ({
128
+ output: {
129
+ task_id: mockTaskId,
130
+ task_status: 'SUCCEEDED',
131
+ results: [{ url: mockImageUrl }],
132
+ },
133
+ request_id: 'req-129',
134
+ }),
135
+ });
136
+
137
+ const payload: CreateImagePayload = {
138
+ model: 'wanx2.1-t2i-turbo',
139
+ params: {
140
+ prompt: 'Abstract digital art',
141
+ },
142
+ };
143
+
144
+ const result = await createQwenImage(payload, mockOptions);
145
+
146
+ // Should have made 3 fetch calls: 1 create + 2 status checks
147
+ expect(fetch).toHaveBeenCalledTimes(3);
148
+ expect(result).toEqual({
149
+ imageUrl: mockImageUrl,
150
+ });
151
+ });
152
+
153
+ it('should handle custom image dimensions', async () => {
154
+ const mockTaskId = 'task-custom-size';
155
+ const mockImageUrl = 'https://dashscope.oss-cn-beijing.aliyuncs.com/aigc/custom-size.jpg';
156
+
157
+ global.fetch = vi
158
+ .fn()
159
+ .mockResolvedValueOnce({
160
+ ok: true,
161
+ json: async () => ({
162
+ output: { task_id: mockTaskId },
163
+ request_id: 'req-140',
164
+ }),
165
+ })
166
+ .mockResolvedValueOnce({
167
+ ok: true,
168
+ json: async () => ({
169
+ output: {
170
+ task_id: mockTaskId,
171
+ task_status: 'SUCCEEDED',
172
+ results: [{ url: mockImageUrl }],
173
+ },
174
+ request_id: 'req-141',
175
+ }),
176
+ });
177
+
178
+ const payload: CreateImagePayload = {
179
+ model: 'wanx2.1-t2i-turbo',
180
+ params: {
181
+ prompt: 'Custom size image',
182
+ width: 512,
183
+ height: 768,
184
+ },
185
+ };
186
+
187
+ await createQwenImage(payload, mockOptions);
188
+
189
+ expect(fetch).toHaveBeenCalledWith(
190
+ 'https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis',
191
+ expect.objectContaining({
192
+ body: JSON.stringify({
193
+ input: {
194
+ prompt: 'Custom size image',
195
+ },
196
+ model: 'wanx2.1-t2i-turbo',
197
+ parameters: {
198
+ n: 1,
199
+ size: '512*768',
200
+ },
201
+ }),
202
+ }),
203
+ );
204
+ });
205
+
206
+ it('should handle long running tasks with retries', async () => {
207
+ const mockTaskId = 'task-long-running';
208
+
209
+ // Mock status query that returns RUNNING a few times then succeeds
210
+ let statusCallCount = 0;
211
+ const statusQueryMock = vi.fn().mockImplementation(() => {
212
+ statusCallCount++;
213
+ if (statusCallCount <= 3) {
214
+ return Promise.resolve({
215
+ ok: true,
216
+ json: async () => ({
217
+ output: {
218
+ task_id: mockTaskId,
219
+ task_status: 'RUNNING',
220
+ },
221
+ request_id: `req-${133 + statusCallCount}`,
222
+ }),
223
+ });
224
+ } else {
225
+ return Promise.resolve({
226
+ ok: true,
227
+ json: async () => ({
228
+ output: {
229
+ task_id: mockTaskId,
230
+ task_status: 'SUCCEEDED',
231
+ results: [{ url: 'https://example.com/final-image.jpg' }],
232
+ },
233
+ request_id: 'req-137',
234
+ }),
235
+ });
236
+ }
237
+ });
238
+
239
+ global.fetch = vi
240
+ .fn()
241
+ .mockResolvedValueOnce({
242
+ ok: true,
243
+ json: async () => ({
244
+ output: { task_id: mockTaskId },
245
+ request_id: 'req-132',
246
+ }),
247
+ })
248
+ .mockImplementation(statusQueryMock);
249
+
250
+ const payload: CreateImagePayload = {
251
+ model: 'wanx2.1-t2i-turbo',
252
+ params: {
253
+ prompt: 'Long running task',
254
+ },
255
+ };
256
+
257
+ // Mock setTimeout to make test run faster but still allow controlled execution
258
+ vi.spyOn(global, 'setTimeout').mockImplementation((callback: any) => {
259
+ // Use setImmediate to avoid recursion issues
260
+ setImmediate(callback);
261
+ return 1 as any;
262
+ });
263
+
264
+ const result = await createQwenImage(payload, mockOptions);
265
+
266
+ expect(result).toEqual({
267
+ imageUrl: 'https://example.com/final-image.jpg',
268
+ });
269
+
270
+ // Should have made 1 create call + 4 status calls (3 RUNNING + 1 SUCCEEDED)
271
+ expect(fetch).toHaveBeenCalledTimes(5);
272
+ });
273
+
274
+ it('should handle seed value of 0 correctly', async () => {
275
+ const mockTaskId = 'task-with-zero-seed';
276
+ const mockImageUrl = 'https://dashscope.oss-cn-beijing.aliyuncs.com/aigc/seed-zero.jpg';
277
+
278
+ global.fetch = vi
279
+ .fn()
280
+ .mockResolvedValueOnce({
281
+ ok: true,
282
+ json: async () => ({
283
+ output: { task_id: mockTaskId },
284
+ request_id: 'req-seed-0',
285
+ }),
286
+ })
287
+ .mockResolvedValueOnce({
288
+ ok: true,
289
+ json: async () => ({
290
+ output: {
291
+ task_id: mockTaskId,
292
+ task_status: 'SUCCEEDED',
293
+ results: [{ url: mockImageUrl }],
294
+ },
295
+ request_id: 'req-seed-0-status',
296
+ }),
297
+ });
298
+
299
+ const payload: CreateImagePayload = {
300
+ model: 'wanx2.1-t2i-turbo',
301
+ params: {
302
+ prompt: 'Image with seed 0',
303
+ seed: 0,
304
+ },
305
+ };
306
+
307
+ await createQwenImage(payload, mockOptions);
308
+
309
+ // Verify that seed: 0 is included in the request
310
+ expect(fetch).toHaveBeenCalledWith(
311
+ 'https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis',
312
+ expect.objectContaining({
313
+ body: JSON.stringify({
314
+ input: {
315
+ prompt: 'Image with seed 0',
316
+ },
317
+ model: 'wanx2.1-t2i-turbo',
318
+ parameters: {
319
+ n: 1,
320
+ seed: 0,
321
+ size: '1024*1024',
322
+ },
323
+ }),
324
+ }),
325
+ );
326
+ });
327
+ });
328
+
329
+ describe('Error scenarios', () => {
330
+ it('should handle unsupported model', async () => {
331
+ const payload: CreateImagePayload = {
332
+ model: 'unsupported-model',
333
+ params: {
334
+ prompt: 'Test prompt',
335
+ },
336
+ };
337
+
338
+ await expect(createQwenImage(payload, mockOptions)).rejects.toEqual(
339
+ expect.objectContaining({
340
+ errorType: 'ProviderBizError',
341
+ provider: 'qwen',
342
+ }),
343
+ );
344
+
345
+ // Should not make any fetch calls
346
+ expect(fetch).not.toHaveBeenCalled();
347
+ });
348
+
349
+ it('should handle task creation failure', async () => {
350
+ global.fetch = vi.fn().mockResolvedValueOnce({
351
+ ok: false,
352
+ statusText: 'Bad Request',
353
+ json: async () => ({
354
+ message: 'Invalid model name',
355
+ }),
356
+ });
357
+
358
+ const payload: CreateImagePayload = {
359
+ model: 'invalid-model',
360
+ params: {
361
+ prompt: 'Test prompt',
362
+ },
363
+ };
364
+
365
+ await expect(createQwenImage(payload, mockOptions)).rejects.toEqual(
366
+ expect.objectContaining({
367
+ errorType: 'ProviderBizError',
368
+ provider: 'qwen',
369
+ }),
370
+ );
371
+ });
372
+
373
+ it('should handle non-JSON error responses', async () => {
374
+ global.fetch = vi.fn().mockResolvedValueOnce({
375
+ ok: false,
376
+ status: 500,
377
+ statusText: 'Internal Server Error',
378
+ json: async () => {
379
+ throw new Error('Failed to parse JSON');
380
+ },
381
+ });
382
+
383
+ const payload: CreateImagePayload = {
384
+ model: 'wanx2.1-t2i-turbo',
385
+ params: {
386
+ prompt: 'Test prompt',
387
+ },
388
+ };
389
+
390
+ await expect(createQwenImage(payload, mockOptions)).rejects.toEqual(
391
+ expect.objectContaining({
392
+ errorType: 'ProviderBizError',
393
+ provider: 'qwen',
394
+ }),
395
+ );
396
+ });
397
+
398
+ it('should handle task failure status', async () => {
399
+ const mockTaskId = 'task-failed';
400
+
401
+ global.fetch = vi
402
+ .fn()
403
+ .mockResolvedValueOnce({
404
+ ok: true,
405
+ json: async () => ({
406
+ output: { task_id: mockTaskId },
407
+ request_id: 'req-130',
408
+ }),
409
+ })
410
+ .mockResolvedValueOnce({
411
+ ok: true,
412
+ json: async () => ({
413
+ output: {
414
+ task_id: mockTaskId,
415
+ task_status: 'FAILED',
416
+ error_message: 'Content policy violation',
417
+ },
418
+ request_id: 'req-131',
419
+ }),
420
+ });
421
+
422
+ const payload: CreateImagePayload = {
423
+ model: 'wanx2.1-t2i-turbo',
424
+ params: {
425
+ prompt: 'Invalid prompt that causes failure',
426
+ },
427
+ };
428
+
429
+ await expect(createQwenImage(payload, mockOptions)).rejects.toEqual(
430
+ expect.objectContaining({
431
+ errorType: 'ProviderBizError',
432
+ provider: 'qwen',
433
+ }),
434
+ );
435
+ });
436
+
437
+ it('should handle task succeeded but no results', async () => {
438
+ const mockTaskId = 'task-no-results';
439
+
440
+ global.fetch = vi
441
+ .fn()
442
+ .mockResolvedValueOnce({
443
+ ok: true,
444
+ json: async () => ({
445
+ output: { task_id: mockTaskId },
446
+ request_id: 'req-134',
447
+ }),
448
+ })
449
+ .mockResolvedValueOnce({
450
+ ok: true,
451
+ json: async () => ({
452
+ output: {
453
+ task_id: mockTaskId,
454
+ task_status: 'SUCCEEDED',
455
+ results: [], // Empty results array
456
+ },
457
+ request_id: 'req-135',
458
+ }),
459
+ });
460
+
461
+ const payload: CreateImagePayload = {
462
+ model: 'wanx2.1-t2i-turbo',
463
+ params: {
464
+ prompt: 'Test prompt',
465
+ },
466
+ };
467
+
468
+ await expect(createQwenImage(payload, mockOptions)).rejects.toEqual(
469
+ expect.objectContaining({
470
+ errorType: 'ProviderBizError',
471
+ provider: 'qwen',
472
+ }),
473
+ );
474
+ });
475
+
476
+ it('should handle status query failure', async () => {
477
+ const mockTaskId = 'task-query-fail';
478
+
479
+ global.fetch = vi
480
+ .fn()
481
+ .mockResolvedValueOnce({
482
+ ok: true,
483
+ json: async () => ({
484
+ output: { task_id: mockTaskId },
485
+ request_id: 'req-136',
486
+ }),
487
+ })
488
+ .mockResolvedValueOnce({
489
+ ok: false,
490
+ statusText: 'Unauthorized',
491
+ json: async () => ({
492
+ message: 'Invalid API key',
493
+ }),
494
+ });
495
+
496
+ const payload: CreateImagePayload = {
497
+ model: 'wanx2.1-t2i-turbo',
498
+ params: {
499
+ prompt: 'Test prompt',
500
+ },
501
+ };
502
+
503
+ await expect(createQwenImage(payload, mockOptions)).rejects.toEqual(
504
+ expect.objectContaining({
505
+ errorType: 'ProviderBizError',
506
+ provider: 'qwen',
507
+ }),
508
+ );
509
+ });
510
+
511
+ it('should handle transient status query failures and retry', async () => {
512
+ const mockTaskId = 'task-transient-failure';
513
+ const mockImageUrl = 'https://dashscope.oss-cn-beijing.aliyuncs.com/aigc/retry-success.jpg';
514
+
515
+ let statusQueryCount = 0;
516
+ const statusQueryMock = vi.fn().mockImplementation(() => {
517
+ statusQueryCount++;
518
+ if (statusQueryCount === 1 || statusQueryCount === 2) {
519
+ // First two calls fail
520
+ return Promise.reject(new Error('Network timeout'));
521
+ } else {
522
+ // Third call succeeds
523
+ return Promise.resolve({
524
+ ok: true,
525
+ json: async () => ({
526
+ output: {
527
+ task_id: mockTaskId,
528
+ task_status: 'SUCCEEDED',
529
+ results: [{ url: mockImageUrl }],
530
+ },
531
+ request_id: 'req-retry-success',
532
+ }),
533
+ });
534
+ }
535
+ });
536
+
537
+ global.fetch = vi
538
+ .fn()
539
+ .mockResolvedValueOnce({
540
+ ok: true,
541
+ json: async () => ({
542
+ output: { task_id: mockTaskId },
543
+ request_id: 'req-transient',
544
+ }),
545
+ })
546
+ .mockImplementation(statusQueryMock);
547
+
548
+ const payload: CreateImagePayload = {
549
+ model: 'wanx2.1-t2i-turbo',
550
+ params: {
551
+ prompt: 'Test transient failure',
552
+ },
553
+ };
554
+
555
+ // Mock setTimeout to make test run faster
556
+ vi.spyOn(global, 'setTimeout').mockImplementation((callback: any) => {
557
+ setImmediate(callback);
558
+ return 1 as any;
559
+ });
560
+
561
+ const result = await createQwenImage(payload, mockOptions);
562
+
563
+ expect(result).toEqual({
564
+ imageUrl: mockImageUrl,
565
+ });
566
+
567
+ // Verify the mock was called the expected number of times
568
+ expect(statusQueryMock).toHaveBeenCalledTimes(3); // 2 failures + 1 success
569
+
570
+ // Should have made 1 create call + 3 status calls (2 failed + 1 succeeded)
571
+ expect(fetch).toHaveBeenCalledTimes(4);
572
+ });
573
+
574
+ it('should fail after consecutive query failures', async () => {
575
+ const mockTaskId = 'task-consecutive-failures';
576
+
577
+ global.fetch = vi
578
+ .fn()
579
+ .mockResolvedValueOnce({
580
+ ok: true,
581
+ json: async () => ({
582
+ output: { task_id: mockTaskId },
583
+ request_id: 'req-will-fail',
584
+ }),
585
+ })
586
+ // All subsequent calls fail
587
+ .mockRejectedValue(new Error('Persistent network error'));
588
+
589
+ const payload: CreateImagePayload = {
590
+ model: 'wanx2.1-t2i-turbo',
591
+ params: {
592
+ prompt: 'Test persistent failure',
593
+ },
594
+ };
595
+
596
+ // Mock setTimeout to make test run faster
597
+ vi.spyOn(global, 'setTimeout').mockImplementation((callback: any) => {
598
+ setImmediate(callback);
599
+ return 1 as any;
600
+ });
601
+
602
+ await expect(createQwenImage(payload, mockOptions)).rejects.toEqual(
603
+ expect.objectContaining({
604
+ errorType: 'ProviderBizError',
605
+ provider: 'qwen',
606
+ }),
607
+ );
608
+
609
+ // Should have made 1 create call + 3 failed status calls (maxConsecutiveFailures)
610
+ expect(fetch).toHaveBeenCalledTimes(4);
611
+ });
612
+ });
613
+ });