@lobehub/chat 1.141.2 → 1.141.4

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 (89) hide show
  1. package/.github/workflows/sync.yml +2 -2
  2. package/CHANGELOG.md +50 -0
  3. package/README.md +1 -1
  4. package/README.zh-CN.md +1 -1
  5. package/changelog/v1.json +18 -0
  6. package/locales/ar/error.json +12 -0
  7. package/locales/ar/modelProvider.json +52 -0
  8. package/locales/ar/models.json +33 -0
  9. package/locales/ar/providers.json +3 -3
  10. package/locales/bg-BG/error.json +12 -0
  11. package/locales/bg-BG/modelProvider.json +52 -0
  12. package/locales/bg-BG/models.json +33 -0
  13. package/locales/bg-BG/providers.json +3 -3
  14. package/locales/de-DE/error.json +12 -0
  15. package/locales/de-DE/modelProvider.json +52 -0
  16. package/locales/de-DE/models.json +33 -0
  17. package/locales/de-DE/providers.json +3 -3
  18. package/locales/en-US/error.json +12 -0
  19. package/locales/en-US/modelProvider.json +14 -14
  20. package/locales/en-US/models.json +33 -0
  21. package/locales/en-US/providers.json +3 -3
  22. package/locales/es-ES/error.json +12 -0
  23. package/locales/es-ES/modelProvider.json +52 -0
  24. package/locales/es-ES/models.json +33 -0
  25. package/locales/es-ES/providers.json +3 -3
  26. package/locales/fa-IR/error.json +12 -0
  27. package/locales/fa-IR/modelProvider.json +52 -0
  28. package/locales/fa-IR/models.json +33 -0
  29. package/locales/fa-IR/providers.json +3 -3
  30. package/locales/fr-FR/error.json +12 -0
  31. package/locales/fr-FR/modelProvider.json +52 -0
  32. package/locales/fr-FR/models.json +33 -0
  33. package/locales/fr-FR/providers.json +3 -3
  34. package/locales/it-IT/error.json +12 -0
  35. package/locales/it-IT/modelProvider.json +52 -0
  36. package/locales/it-IT/models.json +33 -0
  37. package/locales/it-IT/providers.json +3 -3
  38. package/locales/ja-JP/error.json +12 -0
  39. package/locales/ja-JP/modelProvider.json +52 -0
  40. package/locales/ja-JP/models.json +33 -0
  41. package/locales/ja-JP/providers.json +3 -3
  42. package/locales/ko-KR/error.json +12 -0
  43. package/locales/ko-KR/modelProvider.json +52 -0
  44. package/locales/ko-KR/models.json +33 -0
  45. package/locales/ko-KR/providers.json +3 -3
  46. package/locales/nl-NL/error.json +12 -0
  47. package/locales/nl-NL/modelProvider.json +52 -0
  48. package/locales/nl-NL/models.json +33 -0
  49. package/locales/nl-NL/providers.json +3 -3
  50. package/locales/pl-PL/error.json +12 -0
  51. package/locales/pl-PL/modelProvider.json +52 -0
  52. package/locales/pl-PL/models.json +33 -0
  53. package/locales/pl-PL/providers.json +3 -3
  54. package/locales/pt-BR/error.json +12 -0
  55. package/locales/pt-BR/modelProvider.json +52 -0
  56. package/locales/pt-BR/models.json +33 -0
  57. package/locales/pt-BR/providers.json +3 -3
  58. package/locales/ru-RU/error.json +12 -0
  59. package/locales/ru-RU/modelProvider.json +52 -0
  60. package/locales/ru-RU/models.json +33 -0
  61. package/locales/ru-RU/providers.json +3 -0
  62. package/locales/tr-TR/error.json +12 -0
  63. package/locales/tr-TR/modelProvider.json +52 -0
  64. package/locales/tr-TR/models.json +33 -0
  65. package/locales/tr-TR/providers.json +3 -3
  66. package/locales/vi-VN/error.json +12 -0
  67. package/locales/vi-VN/modelProvider.json +52 -0
  68. package/locales/vi-VN/models.json +33 -0
  69. package/locales/vi-VN/providers.json +3 -3
  70. package/locales/zh-CN/chat.json +1 -1
  71. package/locales/zh-CN/components.json +4 -4
  72. package/locales/zh-CN/error.json +12 -0
  73. package/locales/zh-CN/modelProvider.json +14 -14
  74. package/locales/zh-CN/models.json +8 -27
  75. package/locales/zh-TW/error.json +12 -0
  76. package/locales/zh-TW/modelProvider.json +52 -0
  77. package/locales/zh-TW/models.json +33 -0
  78. package/locales/zh-TW/providers.json +3 -3
  79. package/package.json +1 -1
  80. package/packages/agent-runtime/src/core/runtime.ts +23 -12
  81. package/packages/agent-runtime/src/types/instruction.ts +4 -0
  82. package/packages/const/src/currency.ts +2 -2
  83. package/packages/model-runtime/src/core/usageConverters/utils/computeChatCost.test.ts +205 -12
  84. package/packages/model-runtime/src/core/usageConverters/utils/computeChatCost.ts +47 -11
  85. package/src/app/__tests__/desktop.routes.test.ts +18 -0
  86. package/src/features/FileManager/FileList/MasonryFileItem/index.tsx +56 -25
  87. package/src/features/FileManager/FileList/index.tsx +12 -3
  88. package/src/store/file/slices/fileManager/action.test.ts +88 -0
  89. package/src/store/file/slices/fileManager/action.ts +21 -9
@@ -35,9 +35,6 @@
35
35
  "cerebras": {
36
36
  "description": "Cerebras 是一個基於其專用 CS-3 系統的 AI 推理平台,旨在提供全球最快、即時回應、高吞吐量的 LLM 服務,專為消除延遲並加速複雜的 AI 工作流程(如即時程式碼生成與代理任務)而設計。"
37
37
  },
38
- "cerebras": {
39
- "description": "Cerebras 是一個基於其專用 CS-3 系統的 AI 推理平台,旨在提供全球最快、即時回應、高吞吐量的 LLM 服務,專為消除延遲並加速複雜的 AI 工作流程(如即時程式碼生成與代理任務)而設計。"
40
- },
41
38
  "cloudflare": {
42
39
  "description": "在 Cloudflare 的全球網絡上運行由無伺服器 GPU 驅動的機器學習模型。"
43
40
  },
@@ -47,6 +44,9 @@
47
44
  "cometapi": {
48
45
  "description": "CometAPI 是一個提供多種前沿大型模型介面的服務平台,支援 OpenAI、Anthropic、Google 及更多,適合多樣化的開發和應用需求。使用者可根據自身需求靈活選擇最優的模型和價格,助力 AI 體驗的提升。"
49
46
  },
47
+ "comfyui": {
48
+ "description": "強大的開源圖像、影片、音訊生成工作流程引擎,支援 SD、FLUX、Qwen、Hunyuan、WAN 等先進模型,提供節點化工作流程編輯與私有化部署能力"
49
+ },
50
50
  "deepseek": {
51
51
  "description": "DeepSeek 是一家專注於人工智慧技術研究和應用的公司,其最新模型 DeepSeek-V2.5 融合了通用對話和代碼處理能力,並在人類偏好對齊、寫作任務和指令跟隨等方面實現了顯著提升。"
52
52
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.141.2",
3
+ "version": "1.141.4",
4
4
  "description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
5
5
  "keywords": [
6
6
  "framework",
@@ -256,15 +256,11 @@ export class AgentRuntime {
256
256
  }
257
257
 
258
258
  /**
259
- * Create a new agent state with flexible initialization
260
- * @param partialState - Partial state to override defaults
261
- * @returns Complete AgentState with defaults filled in
259
+ * Create default usage statistics structure
260
+ * @returns Default Usage object with all counters set to 0
262
261
  */
263
- static createInitialState(partialState: Partial<AgentState> & { sessionId: string }): AgentState {
264
- const now = new Date().toISOString();
265
-
266
- // Default usage statistics
267
- const defaultUsage: Usage = {
262
+ static createDefaultUsage(): Usage {
263
+ return {
268
264
  humanInteraction: {
269
265
  approvalRequests: 0,
270
266
  promptRequests: 0,
@@ -282,9 +278,15 @@ export class AgentRuntime {
282
278
  totalTimeMs: 0,
283
279
  },
284
280
  };
281
+ }
285
282
 
286
- // Default cost structure
287
- const defaultCost: Cost = {
283
+ /**
284
+ * Create default cost structure
285
+ * @returns Default Cost object with all costs set to 0
286
+ */
287
+ static createDefaultCost(): Cost {
288
+ const now = new Date().toISOString();
289
+ return {
288
290
  calculatedAt: now,
289
291
  currency: 'USD',
290
292
  llm: {
@@ -299,16 +301,25 @@ export class AgentRuntime {
299
301
  },
300
302
  total: 0,
301
303
  };
304
+ }
305
+
306
+ /**
307
+ * Create a new agent state with flexible initialization
308
+ * @param partialState - Partial state to override defaults
309
+ * @returns Complete AgentState with defaults filled in
310
+ */
311
+ static createInitialState(partialState: Partial<AgentState> & { sessionId: string }): AgentState {
312
+ const now = new Date().toISOString();
302
313
 
303
314
  return {
304
- cost: defaultCost,
315
+ cost: AgentRuntime.createDefaultCost(),
305
316
  // Default values
306
317
  createdAt: now,
307
318
  lastModified: now,
308
319
  messages: [],
309
320
  status: 'idle',
310
321
  stepCount: 0,
311
- usage: defaultUsage,
322
+ usage: AgentRuntime.createDefaultUsage(),
312
323
  // User provided values override defaults
313
324
  ...partialState,
314
325
  };
@@ -1,3 +1,5 @@
1
+ import type { ModelUsage } from '@lobechat/types';
2
+
1
3
  import type { FinishReason } from './event';
2
4
  import { AgentState, ToolRegistry, ToolsCalling } from './state';
3
5
  import type { Cost, CostCalculationContext, Usage } from './usage';
@@ -26,6 +28,8 @@ export interface AgentRuntimeContext {
26
28
  status: AgentState['status'];
27
29
  stepCount: number;
28
30
  };
31
+ /** Usage statistics from the current step (if applicable) */
32
+ stepUsage?: ModelUsage;
29
33
  }
30
34
 
31
35
  /**
@@ -1,4 +1,4 @@
1
- // in 2025.01.26
2
- export const USD_TO_CNY = 7.24;
1
+ // in 2025.10.22
2
+ export const USD_TO_CNY = 7.12;
3
3
 
4
4
  export const CREDITS_PER_DOLLAR = 1_000_000;
@@ -1,3 +1,4 @@
1
+ import { Pricing } from 'model-bank';
1
2
  import anthropicChatModels from 'model-bank/anthropic';
2
3
  import googleChatModels from 'model-bank/google';
3
4
  import lobehubChatModels from 'model-bank/lobehub';
@@ -157,13 +158,13 @@ describe('computeChatPricing', () => {
157
158
  // Verify cached tokens (over 200k threshold, use higher tier rate)
158
159
  const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
159
160
  expect(cached?.quantity).toBe(253_891);
160
- expect(cached?.credits).toBeCloseTo(158_681.875, 6);
161
+ expect(cached?.credits).toBe(158_682); // ceil(158681.875) = 158682
161
162
  expect(cached?.segments).toEqual([{ quantity: 253_891, rate: 0.625, credits: 158_681.875 }]);
162
163
 
163
164
  // Verify input cache miss tokens (calculated as totalInputTokens - inputCachedTokens = 4275)
164
165
  const input = breakdown.find((item) => item.unit.name === 'textInput');
165
166
  expect(input?.quantity).toBe(4_275); // 258_166 - 253_891 = 4_275 (cache miss)
166
- expect(input?.credits).toBeCloseTo(5_343.75, 6);
167
+ expect(input?.credits).toBe(5_344); // ceil(5343.75) = 5344
167
168
  expect(input?.segments).toEqual([{ quantity: 4_275, rate: 1.25, credits: 5_343.75 }]);
168
169
 
169
170
  // Verify output tokens include reasoning tokens (under 200k threshold, use lower tier rate)
@@ -173,7 +174,7 @@ describe('computeChatPricing', () => {
173
174
  expect(output?.segments).toEqual([{ quantity: 3_063, rate: 10, credits: 30_630 }]);
174
175
 
175
176
  // Verify corrected totals (no double counting of cached tokens)
176
- expect(totalCredits).toBe(194_656); // ceil(158681.875 + 5343.75 + 30630) = 194656
177
+ expect(totalCredits).toBe(194_656); // 158682 + 5344 + 30630 = 194656
177
178
  expect(totalCost).toBeCloseTo(0.194656, 6); // 194656 credits = $0.194656
178
179
  });
179
180
 
@@ -274,13 +275,13 @@ describe('computeChatPricing', () => {
274
275
  const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
275
276
  expect(cached?.quantity).toBe(257_955);
276
277
 
277
- expect(cached?.credits).toBeCloseTo(161_221.875, 6);
278
+ expect(cached?.credits).toBe(161_222); // ceil(161221.875) = 161222
278
279
  expect(cached?.segments).toEqual([{ quantity: 257_955, rate: 0.625, credits: 161_221.875 }]);
279
280
 
280
281
  // Verify input cache miss tokens (under 200k tier, use lower rate)
281
282
  const input = breakdown.find((item) => item.unit.name === 'textInput');
282
283
  expect(input?.quantity).toBe(5_005);
283
- expect(input?.credits).toBeCloseTo(6_256.25, 6);
284
+ expect(input?.credits).toBe(6_257); // ceil(6256.25) = 6257
284
285
  expect(input?.segments).toEqual([{ quantity: 5_005, rate: 1.25, credits: 6_256.25 }]);
285
286
 
286
287
  // Verify output tokens (under 200k threshold, use lower tier rate)
@@ -290,7 +291,7 @@ describe('computeChatPricing', () => {
290
291
  expect(output?.segments).toEqual([{ quantity: 1_744, rate: 10, credits: 17_440 }]);
291
292
 
292
293
  // Verify totals match actual billing log
293
- expect(totalCredits).toBe(184_919); // ceil(161221.875 + 6256.25 + 17440) = 184919
294
+ expect(totalCredits).toBe(184_919); // 161222 + 6257 + 17440 = 184919
294
295
  expect(totalCost).toBeCloseTo(0.184919, 6); // 184919 credits = $0.184919
295
296
  });
296
297
  });
@@ -468,16 +469,16 @@ describe('computeChatPricing', () => {
468
469
  // Verify cached tokens (discounted rate)
469
470
  const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
470
471
  expect(cached?.quantity).toBe(1183);
471
- expect(cached?.credits).toBeCloseTo(354.9, 6);
472
+ expect(cached?.credits).toBe(355); // 354.9 rounded = 355
472
473
 
473
474
  // Verify cache write tokens
474
475
  const cacheWrite = breakdown.find((item) => item.unit.name === 'textInput_cacheWrite');
475
476
  expect(cacheWrite?.quantity).toBe(458);
476
477
  expect(cacheWrite?.lookupKey).toBe('5m');
477
- expect(cacheWrite?.credits).toBeCloseTo(1_717.5, 6);
478
+ expect(cacheWrite?.credits).toBe(1_718); // 1717.5 rounded = 1718
478
479
 
479
480
  // Verify totals match the actual billing log
480
- expect(totalCredits).toBe(9_915); // ceil(12 + 7830 + 354.9 + 1717.5) = 9915
481
+ expect(totalCredits).toBe(9_915); // 12 + 7830 + 355 + 1718 = 9915
481
482
  expect(totalCost).toBeCloseTo(0.009915, 6); // 9915 credits = $0.009915
482
483
  });
483
484
 
@@ -516,15 +517,15 @@ describe('computeChatPricing', () => {
516
517
  // Verify cached tokens (discounted rate)
517
518
  const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
518
519
  expect(cached?.quantity).toBe(3021);
519
- expect(cached?.credits).toBeCloseTo(906.3, 6);
520
+ expect(cached?.credits).toBe(907); // ceil(906.3) = 907
520
521
 
521
522
  // Verify cache write tokens (fixed strategy in lobehub model)
522
523
  const cacheWrite = breakdown.find((item) => item.unit.name === 'textInput_cacheWrite');
523
524
  expect(cacheWrite?.quantity).toBe(1697);
524
- expect(cacheWrite?.credits).toBeCloseTo(6_363.75, 6);
525
+ expect(cacheWrite?.credits).toBe(6_364); // ceil(6363.75) = 6364
525
526
 
526
527
  // Verify totals match the actual billing log
527
- expect(totalCredits).toBe(49_916); // ceil(30 + 42615 + 906.3 + 6363.75) = 49916
528
+ expect(totalCredits).toBe(49_916); // 30 + 42615 + 907 + 6364 = 49916
528
529
  expect(totalCost).toBeCloseTo(0.049916, 6); // 49916 credits = $0.049916
529
530
  });
530
531
  });
@@ -682,4 +683,196 @@ describe('computeChatPricing', () => {
682
683
  expect(result?.totalCost).toBe(0);
683
684
  });
684
685
  });
686
+
687
+ describe('Currency Conversion', () => {
688
+ describe('DeepSeek (CNY pricing)', () => {
689
+ it('converts CNY to USD for deepseek-chat without cache', () => {
690
+ // DeepSeek pricing in CNY
691
+ const pricing = {
692
+ currency: 'CNY',
693
+ units: [
694
+ { name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
695
+ { name: 'textOutput', rate: 3, strategy: 'fixed', unit: 'millionTokens' },
696
+ ],
697
+ };
698
+
699
+ const usage: ModelTokensUsage = {
700
+ inputCacheMissTokens: 1000,
701
+ inputTextTokens: 1000,
702
+ outputTextTokens: 500,
703
+ totalInputTokens: 1000,
704
+ totalOutputTokens: 500,
705
+ totalTokens: 1500,
706
+ };
707
+
708
+ // Use fixed exchange rate for testing
709
+ const result = computeChatCost(pricing as any, usage, { usdToCnyRate: 5 });
710
+ expect(result).toBeDefined();
711
+ expect(result?.issues).toHaveLength(0);
712
+
713
+ const { breakdown, totalCost, totalCredits } = result!;
714
+ expect(breakdown).toHaveLength(2); // Input and output
715
+
716
+ // Verify input tokens
717
+ // 1000 tokens * 2 CNY/M = 2000 raw CNY-credits
718
+ // 2000 / 5 = 400 USD-credits
719
+ const input = breakdown.find((item) => item.unit.name === 'textInput');
720
+ expect(input?.quantity).toBe(1000);
721
+ expect(input?.credits).toBe(400); // USD credits
722
+
723
+ // Verify output tokens
724
+ // 500 tokens * 3 CNY/M = 1500 raw CNY-credits
725
+ // 1500 / 5 = 300 USD-credits
726
+ const output = breakdown.find((item) => item.unit.name === 'textOutput');
727
+ expect(output?.quantity).toBe(500);
728
+ expect(output?.credits).toBe(300); // USD credits
729
+
730
+ // Verify totals with CNY to USD conversion
731
+ // Total USD credits = 400 + 300 = 700
732
+ // totalCredits = ceil(700) = 700
733
+ expect(totalCredits).toBe(700);
734
+
735
+ // totalCost = 700 / 1_000_000 = 0.0007 USD
736
+ expect(totalCost).toBeCloseTo(0.0007, 6);
737
+ });
738
+
739
+ it('converts CNY to USD for deepseek-chat with cache tokens', () => {
740
+ const pricing = {
741
+ currency: 'CNY',
742
+ units: [
743
+ { name: 'textInput_cacheRead', rate: 0.2, strategy: 'fixed', unit: 'millionTokens' },
744
+ { name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
745
+ { name: 'textOutput', rate: 3, strategy: 'fixed', unit: 'millionTokens' },
746
+ ],
747
+ } satisfies Pricing;
748
+
749
+ const usage: ModelTokensUsage = {
750
+ inputCacheMissTokens: 785,
751
+ inputCachedTokens: 2752,
752
+ inputTextTokens: 3537,
753
+ outputTextTokens: 77,
754
+ totalInputTokens: 3537,
755
+ totalOutputTokens: 77,
756
+ totalTokens: 3614,
757
+ };
758
+
759
+ const result = computeChatCost(pricing, usage, { usdToCnyRate: 5 });
760
+ expect(result).toBeDefined();
761
+ expect(result?.issues).toHaveLength(0);
762
+
763
+ const { breakdown, totalCost, totalCredits } = result!;
764
+ expect(breakdown).toHaveLength(3); // Cache read, input, and output
765
+
766
+ // Verify cache miss tokens
767
+ // 785 tokens * 2 CNY/M = 1570 raw CNY-credits
768
+ // 1570 / 5 = 314 USD-credits
769
+ const input = breakdown.find((item) => item.unit.name === 'textInput');
770
+ expect(input?.quantity).toBe(785);
771
+ expect(input?.credits).toBe(314); // USD credits
772
+
773
+ // Verify cached tokens
774
+ // 2752 tokens * 0.2 CNY/M = 550.4 raw CNY-credits
775
+ // 550.4 / 5 = 110.08 -> ceil(110.08) = 111 USD-credits
776
+ const cached = breakdown.find((item) => item.unit.name === 'textInput_cacheRead');
777
+ expect(cached?.quantity).toBe(2752);
778
+ expect(cached?.credits).toBe(111); // USD credits
779
+
780
+ // Verify output tokens
781
+ // 77 tokens * 3 CNY/M = 231 raw CNY-credits
782
+ // 231 / 5 = 46.2 -> ceil(46.2) = 47 USD-credits
783
+ const output = breakdown.find((item) => item.unit.name === 'textOutput');
784
+ expect(output?.quantity).toBe(77);
785
+ expect(output?.credits).toBe(47); // USD credits
786
+
787
+ // Verify totals with CNY to USD conversion
788
+ // Total USD credits = 314 + 111 + 47 = 472
789
+ expect(totalCredits).toBe(472);
790
+
791
+ // totalCost = 472 / 1_000_000 = 0.000472 USD
792
+ expect(totalCost).toBe(0.000472);
793
+ });
794
+
795
+ it('converts CNY to USD for large token usage', () => {
796
+ const pricing = {
797
+ currency: 'CNY',
798
+ units: [
799
+ { name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
800
+ { name: 'textOutput', rate: 3, strategy: 'fixed', unit: 'millionTokens' },
801
+ ],
802
+ };
803
+
804
+ const usage: ModelTokensUsage = {
805
+ inputTextTokens: 1_000_000, // 1M input tokens
806
+ outputTextTokens: 500_000, // 500K output tokens
807
+ };
808
+
809
+ const result = computeChatCost(pricing as any, usage, { usdToCnyRate: 5 });
810
+ expect(result).toBeDefined();
811
+
812
+ const { totalCost, totalCredits } = result!;
813
+
814
+ // Input: 1M * 2 CNY = 2M CNY-credits = 2M / 5 = 400000 USD-credits
815
+ // Output: 500K * 3 CNY = 1.5M CNY-credits = 1.5M / 5 = 300000 USD-credits
816
+ // Total: 700000 USD-credits
817
+ expect(totalCredits).toBe(700_000);
818
+
819
+ // totalCost = 700000 / 1_000_000 = 0.7 USD
820
+ expect(totalCost).toBe(0.7);
821
+ });
822
+ });
823
+
824
+ describe('USD pricing (no conversion)', () => {
825
+ it('does not convert USD pricing', () => {
826
+ const pricing = {
827
+ currency: 'USD',
828
+ units: [
829
+ { name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
830
+ { name: 'textOutput', rate: 8, strategy: 'fixed', unit: 'millionTokens' },
831
+ ],
832
+ };
833
+
834
+ const usage: ModelTokensUsage = {
835
+ inputTextTokens: 1000,
836
+ outputTextTokens: 500,
837
+ };
838
+
839
+ const result = computeChatCost(pricing as any, usage);
840
+ expect(result).toBeDefined();
841
+
842
+ const { totalCost, totalCredits } = result!;
843
+
844
+ // Input: 1000 * 2 = 2000 USD-credits
845
+ // Output: 500 * 8 = 4000 USD-credits
846
+ // Total: 6000 USD-credits
847
+ expect(totalCredits).toBe(6000);
848
+
849
+ // totalCost = 6000 / 1_000_000 = 0.006 USD
850
+ expect(totalCost).toBeCloseTo(0.006, 6);
851
+ });
852
+
853
+ it('defaults to USD when currency is not specified', () => {
854
+ const pricing = {
855
+ // No currency field
856
+ units: [
857
+ { name: 'textInput', rate: 2, strategy: 'fixed', unit: 'millionTokens' },
858
+ { name: 'textOutput', rate: 8, strategy: 'fixed', unit: 'millionTokens' },
859
+ ],
860
+ };
861
+
862
+ const usage: ModelTokensUsage = {
863
+ inputTextTokens: 1000,
864
+ outputTextTokens: 500,
865
+ };
866
+
867
+ const result = computeChatCost(pricing as any, usage);
868
+ expect(result).toBeDefined();
869
+
870
+ const { totalCost, totalCredits } = result!;
871
+
872
+ // Should be treated as USD (no conversion)
873
+ expect(totalCredits).toBe(6000);
874
+ expect(totalCost).toBeCloseTo(0.006, 6);
875
+ });
876
+ });
877
+ });
685
878
  });
@@ -1,5 +1,5 @@
1
1
  /* eslint-disable sort-keys-fix/sort-keys-fix */
2
- import { CREDITS_PER_DOLLAR } from '@lobechat/const/currency';
2
+ import { CREDITS_PER_DOLLAR, USD_TO_CNY } from '@lobechat/const/currency';
3
3
  import debug from 'debug';
4
4
  import {
5
5
  FixedPricingUnit,
@@ -17,6 +17,7 @@ const log = debug('lobe-cost:computeChatPricing');
17
17
  export interface PricingUnitBreakdown {
18
18
  cost: number;
19
19
  credits: number;
20
+ currency: string | 'USD' | 'CNY';
20
21
  /**
21
22
  * For lookup strategies we expose the resolved key.
22
23
  */
@@ -39,6 +40,11 @@ export interface ComputeChatCostOptions {
39
40
  * Input parameters used by lookup strategies (e.g. ttl, thinkingMode).
40
41
  */
41
42
  lookupParams?: Record<string, string | number | boolean>;
43
+ /**
44
+ * Exchange rate for CNY to USD conversion. Defaults to USD_TO_CNY constant.
45
+ * Useful for testing with fixed exchange rates.
46
+ */
47
+ usdToCnyRate?: number;
42
48
  }
43
49
 
44
50
  export interface PricingComputationResult {
@@ -98,6 +104,27 @@ const UNIT_QUANTITY_RESOLVERS: Partial<Record<PricingUnitName, UnitQuantityResol
98
104
  audioOutput: (usage) => usage.outputAudioTokens,
99
105
  };
100
106
 
107
+ /**
108
+ * Convert currency-specific credits to USD credits and ceil to integer
109
+ * @param credits - Credits in the original currency
110
+ * @param currency - The currency of the credits ('USD' or 'CNY')
111
+ * @param usdToCnyRate - Exchange rate for CNY to USD conversion (defaults to USD_TO_CNY constant)
112
+ * @returns USD-equivalent credits (ceiled to integer)
113
+ */
114
+ const toUSDCredits = (
115
+ credits: number,
116
+ currency: string = 'USD',
117
+ usdToCnyRate = USD_TO_CNY,
118
+ ): number => {
119
+ const usdCredits = currency === 'CNY' ? credits / usdToCnyRate : credits;
120
+ return Math.ceil(usdCredits);
121
+ };
122
+
123
+ /**
124
+ * Convert credits to USD dollar amount
125
+ * @param credits - USD credits
126
+ * @returns USD dollar amount
127
+ */
101
128
  const creditsToUSD = (credits: number) => credits / CREDITS_PER_DOLLAR;
102
129
 
103
130
  /**
@@ -221,6 +248,8 @@ export const computeChatCost = (
221
248
 
222
249
  const breakdown: PricingUnitBreakdown[] = [];
223
250
  const issues: PricingComputationIssue[] = [];
251
+ const currency = pricing.currency || 'USD';
252
+ const usdToCnyRate = options?.usdToCnyRate ?? USD_TO_CNY;
224
253
 
225
254
  for (const unit of pricing.units) {
226
255
  const quantity = resolveQuantity(unit, usage);
@@ -231,11 +260,13 @@ export const computeChatCost = (
231
260
  throw new Error(`Unsupported chat pricing unit: ${unit.unit}`);
232
261
 
233
262
  const fixedUnit = unit as FixedPricingUnit;
234
- const credits = computeFixedCredits(fixedUnit, quantity);
263
+ const rawCredits = computeFixedCredits(fixedUnit, quantity);
264
+ const usdCredits = toUSDCredits(rawCredits, currency, usdToCnyRate);
235
265
  breakdown.push({
236
- cost: creditsToUSD(credits),
237
- credits,
266
+ cost: creditsToUSD(usdCredits),
267
+ credits: usdCredits,
238
268
  quantity,
269
+ currency,
239
270
  unit,
240
271
  });
241
272
  continue;
@@ -243,11 +274,13 @@ export const computeChatCost = (
243
274
 
244
275
  if (unit.strategy === 'tiered') {
245
276
  const tieredUnit = unit as TieredPricingUnit;
246
- const { credits, segments } = computeTieredCredits(tieredUnit, quantity);
277
+ const { credits: rawCredits, segments } = computeTieredCredits(tieredUnit, quantity);
278
+ const usdCredits = toUSDCredits(rawCredits, currency, usdToCnyRate);
247
279
  breakdown.push({
248
- cost: creditsToUSD(credits),
249
- credits,
280
+ cost: creditsToUSD(usdCredits),
281
+ credits: usdCredits,
250
282
  quantity,
283
+ currency,
251
284
  segments,
252
285
  unit,
253
286
  });
@@ -257,18 +290,20 @@ export const computeChatCost = (
257
290
  if (unit.strategy === 'lookup') {
258
291
  const lookupUnit = unit as LookupPricingUnit;
259
292
  const {
260
- credits,
293
+ credits: rawCredits,
261
294
  key,
262
295
  issues: lookupIssue,
263
296
  } = computeLookupCredits(lookupUnit, quantity, options);
264
297
 
265
298
  if (lookupIssue) issues.push(lookupIssue);
266
299
 
300
+ const usdCredits = toUSDCredits(rawCredits, currency, usdToCnyRate);
267
301
  breakdown.push({
268
- cost: creditsToUSD(credits),
269
- credits,
302
+ cost: creditsToUSD(usdCredits),
303
+ credits: usdCredits,
270
304
  lookupKey: key,
271
305
  quantity,
306
+ currency,
272
307
  unit,
273
308
  });
274
309
  continue;
@@ -277,9 +312,10 @@ export const computeChatCost = (
277
312
  issues.push({ reason: 'Unsupported pricing strategy', unit });
278
313
  }
279
314
 
315
+ // Sum up USD credits from all breakdown items
280
316
  const rawTotalCredits = breakdown.reduce((sum, item) => sum + item.credits, 0);
281
317
  const totalCredits = Math.ceil(rawTotalCredits);
282
- // !: totalCredits has been uniformly rounded up to integer credits, divided by CREDITS_PER_DOLLAR naturally retains only 6 decimal places, no additional processing needed
318
+ // !: totalCredits has been uniformly rounded up to integer USD credits, divided by CREDITS_PER_DOLLAR naturally retains only 6 decimal places, no additional processing needed
283
319
  const totalCost = creditsToUSD(totalCredits);
284
320
 
285
321
  log(`computeChatPricing breakdown: ${JSON.stringify(breakdown, null, 2)}`);
@@ -0,0 +1,18 @@
1
+ import fs from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { describe, expect, it } from 'vitest';
4
+
5
+ describe('Desktop Routes', () => {
6
+ const appRootDir = resolve(__dirname, '..');
7
+
8
+ const desktopRoutes = [
9
+ '(backend)/trpc/desktop/[trpc]/route.ts',
10
+ 'desktop/devtools/page.tsx',
11
+ 'desktop/layout.tsx',
12
+ ];
13
+
14
+ it.each(desktopRoutes)('should have file: %s', (route) => {
15
+ const filePath = resolve(appRootDir, route);
16
+ expect(fs.existsSync(filePath)).toBe(true);
17
+ });
18
+ });
@@ -4,7 +4,7 @@ import { createStyles } from 'antd-style';
4
4
  import { isNull } from 'lodash-es';
5
5
  import { FileBoxIcon } from 'lucide-react';
6
6
  import { useRouter } from 'next/navigation';
7
- import { memo, useEffect, useState } from 'react';
7
+ import { memo, useEffect, useRef, useState } from 'react';
8
8
  import { useTranslation } from 'react-i18next';
9
9
  import { Flexbox } from 'react-layout-kit';
10
10
 
@@ -304,9 +304,37 @@ const MasonryFileItem = memo<MasonryFileItemProps>(
304
304
  const isImage = fileType && IMAGE_TYPES.has(fileType);
305
305
  const isMarkdown = isMarkdownFile(name, fileType);
306
306
 
307
- // Fetch markdown content
307
+ const cardRef = useRef<HTMLDivElement>(null);
308
+ const [isInView, setIsInView] = useState(false);
309
+
310
+ // Use Intersection Observer to detect when card enters viewport
311
+ useEffect(() => {
312
+ if (!cardRef.current) return;
313
+
314
+ const observer = new IntersectionObserver(
315
+ (entries) => {
316
+ entries.forEach((entry) => {
317
+ if (entry.isIntersecting && !isInView) {
318
+ setIsInView(true);
319
+ }
320
+ });
321
+ },
322
+ {
323
+ rootMargin: '50px', // Start loading slightly before entering viewport
324
+ threshold: 0.1,
325
+ },
326
+ );
327
+
328
+ observer.observe(cardRef.current);
329
+
330
+ return () => {
331
+ observer.disconnect();
332
+ };
333
+ }, [isInView]);
334
+
335
+ // Fetch markdown content only when in viewport
308
336
  useEffect(() => {
309
- if (isMarkdown && url) {
337
+ if (isMarkdown && url && isInView && !markdownContent) {
310
338
  setIsLoadingMarkdown(true);
311
339
  fetch(url)
312
340
  .then((res) => res.text())
@@ -323,10 +351,10 @@ const MasonryFileItem = memo<MasonryFileItemProps>(
323
351
  setIsLoadingMarkdown(false);
324
352
  });
325
353
  }
326
- }, [isMarkdown, url]);
354
+ }, [isMarkdown, url, isInView, markdownContent]);
327
355
 
328
356
  return (
329
- <div className={cx(styles.card, selected && styles.selected)}>
357
+ <div className={cx(styles.card, selected && styles.selected)} ref={cardRef}>
330
358
  <div
331
359
  className={cx('checkbox', styles.checkbox)}
332
360
  onClick={(e) => {
@@ -355,26 +383,29 @@ const MasonryFileItem = memo<MasonryFileItemProps>(
355
383
  <FileIcon fileName={name} fileType={fileType} size={64} />
356
384
  </div>
357
385
  )}
358
- <Image
359
- alt={name}
360
- onError={() => setImageLoaded(false)}
361
- onLoad={() => setImageLoaded(true)}
362
- preview={{
363
- src: url,
364
- }}
365
- src={url}
366
- style={{
367
- display: 'block',
368
- height: 'auto',
369
- opacity: imageLoaded ? 1 : 0,
370
- transition: 'opacity 0.3s',
371
- width: '100%',
372
- }}
373
- wrapperStyle={{
374
- display: 'block',
375
- width: '100%',
376
- }}
377
- />
386
+ {isInView && (
387
+ <Image
388
+ alt={name}
389
+ loading="lazy"
390
+ onError={() => setImageLoaded(false)}
391
+ onLoad={() => setImageLoaded(true)}
392
+ preview={{
393
+ src: url,
394
+ }}
395
+ src={url}
396
+ style={{
397
+ display: 'block',
398
+ height: 'auto',
399
+ opacity: imageLoaded ? 1 : 0,
400
+ transition: 'opacity 0.3s',
401
+ width: '100%',
402
+ }}
403
+ wrapperStyle={{
404
+ display: 'block',
405
+ width: '100%',
406
+ }}
407
+ />
408
+ )}
378
409
  {/* Hover overlay */}
379
410
  <div className={styles.hoverOverlay}>
380
411
  <div className={styles.overlayTitle}>{name}</div>