@lobehub/chat 1.141.3 → 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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ### [Version 1.141.4](https://github.com/lobehub/lobe-chat/compare/v1.141.3...v1.141.4)
6
+
7
+ <sup>Released on **2025-10-22**</sup>
8
+
9
+ #### ♻ Code Refactoring
10
+
11
+ - **misc**: Fix model runtime cost calculate with CNY.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### Code refactoring
19
+
20
+ - **misc**: Fix model runtime cost calculate with CNY, closes [#9834](https://github.com/lobehub/lobe-chat/issues/9834) ([2e911ea](https://github.com/lobehub/lobe-chat/commit/2e911ea))
21
+
22
+ </details>
23
+
24
+ <div align="right">
25
+
26
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
27
+
28
+ </div>
29
+
5
30
  ### [Version 1.141.3](https://github.com/lobehub/lobe-chat/compare/v1.141.2...v1.141.3)
6
31
 
7
32
  <sup>Released on **2025-10-22**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,13 @@
1
1
  [
2
+ {
3
+ "children": {
4
+ "improvements": [
5
+ "Fix model runtime cost calculate with CNY."
6
+ ]
7
+ },
8
+ "date": "2025-10-22",
9
+ "version": "1.141.4"
10
+ },
2
11
  {
3
12
  "children": {
4
13
  "improvements": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.141.3",
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>
@@ -5,7 +5,7 @@ import { VirtuosoMasonry } from '@virtuoso.dev/masonry';
5
5
  import { createStyles } from 'antd-style';
6
6
  import { useQueryState } from 'nuqs';
7
7
  import { rgba } from 'polished';
8
- import React, { memo, useState } from 'react';
8
+ import React, { memo, useMemo, useState } from 'react';
9
9
  import { useTranslation } from 'react-i18next';
10
10
  import { Center, Flexbox } from 'react-layout-kit';
11
11
  import { Virtuoso } from 'react-virtuoso';
@@ -118,6 +118,16 @@ const FileList = memo<FileListProps>(({ knowledgeBaseId, category }) => {
118
118
  }
119
119
  }, [data]);
120
120
 
121
+ // Memoize context object to avoid recreating on every render
122
+ const masonryContext = useMemo(
123
+ () => ({
124
+ knowledgeBaseId,
125
+ selectFileIds,
126
+ setSelectedFileIds,
127
+ }),
128
+ [knowledgeBaseId, selectFileIds],
129
+ );
130
+
121
131
  return !isLoading && data?.length === 0 ? (
122
132
  <EmptyStatus knowledgeBaseId={knowledgeBaseId} showKnowledgeBase={!knowledgeBaseId} />
123
133
  ) : (
@@ -195,9 +205,8 @@ const FileList = memo<FileListProps>(({ knowledgeBaseId, category }) => {
195
205
  <VirtuosoMasonry
196
206
  ItemContent={MasonryItemWrapper}
197
207
  columnCount={columnCount}
198
- context={{ knowledgeBaseId, selectFileIds, setSelectedFileIds }}
208
+ context={masonryContext}
199
209
  data={data || []}
200
- key={`masonry-${query || 'all'}-${data?.length || 0}`}
201
210
  style={{
202
211
  gap: '16px',
203
212
  }}
@@ -234,6 +234,7 @@ describe('FileManagerActions', () => {
234
234
  .mockResolvedValue({ id: 'file-1', url: 'http://example.com/file-1' });
235
235
  const refreshSpy = vi.spyOn(result.current, 'refreshFileList').mockResolvedValue();
236
236
  const dispatchSpy = vi.spyOn(result.current, 'dispatchDockFileList');
237
+ const parseSpy = vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue();
237
238
 
238
239
  await act(async () => {
239
240
  await result.current.pushDockFileList([validFile, blacklistedFile]);
@@ -252,6 +253,8 @@ describe('FileManagerActions', () => {
252
253
  onStatusUpdate: expect.any(Function),
253
254
  });
254
255
  expect(refreshSpy).toHaveBeenCalled();
256
+ // Should auto-parse text files
257
+ expect(parseSpy).toHaveBeenCalledWith(['file-1'], { skipExist: false });
255
258
  });
256
259
 
257
260
  it('should upload files with knowledgeBaseId', async () => {
@@ -263,6 +266,7 @@ describe('FileManagerActions', () => {
263
266
  .spyOn(result.current, 'uploadWithProgress')
264
267
  .mockResolvedValue({ id: 'file-1', url: 'http://example.com/file-1' });
265
268
  vi.spyOn(result.current, 'refreshFileList').mockResolvedValue();
269
+ vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue();
266
270
 
267
271
  await act(async () => {
268
272
  await result.current.pushDockFileList([file], 'kb-123');
@@ -287,6 +291,7 @@ describe('FileManagerActions', () => {
287
291
  return { id: 'file-1', url: 'http://example.com/file-1' };
288
292
  });
289
293
  vi.spyOn(result.current, 'refreshFileList').mockResolvedValue();
294
+ vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue();
290
295
  const dispatchSpy = vi.spyOn(result.current, 'dispatchDockFileList');
291
296
 
292
297
  await act(async () => {
@@ -302,6 +307,7 @@ describe('FileManagerActions', () => {
302
307
 
303
308
  const uploadSpy = vi.spyOn(result.current, 'uploadWithProgress');
304
309
  const refreshSpy = vi.spyOn(result.current, 'refreshFileList');
310
+ const parseSpy = vi.spyOn(result.current, 'parseFilesToChunks');
305
311
 
306
312
  await act(async () => {
307
313
  await result.current.pushDockFileList([]);
@@ -309,6 +315,88 @@ describe('FileManagerActions', () => {
309
315
 
310
316
  expect(uploadSpy).not.toHaveBeenCalled();
311
317
  expect(refreshSpy).not.toHaveBeenCalled();
318
+ expect(parseSpy).not.toHaveBeenCalled();
319
+ });
320
+
321
+ it('should auto-embed files that support chunking', async () => {
322
+ const { result } = renderHook(() => useStore());
323
+
324
+ const textFile = new File(['text content'], 'doc.txt', { type: 'text/plain' });
325
+ const pdfFile = new File(['pdf content'], 'doc.pdf', { type: 'application/pdf' });
326
+
327
+ vi.spyOn(result.current, 'uploadWithProgress')
328
+ .mockResolvedValueOnce({ id: 'file-1', url: 'http://example.com/file-1' })
329
+ .mockResolvedValueOnce({ id: 'file-2', url: 'http://example.com/file-2' });
330
+ vi.spyOn(result.current, 'refreshFileList').mockResolvedValue();
331
+ const parseSpy = vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue();
332
+
333
+ await act(async () => {
334
+ await result.current.pushDockFileList([textFile, pdfFile]);
335
+ });
336
+
337
+ // Should auto-parse both files that support chunking
338
+ expect(parseSpy).toHaveBeenCalledWith(['file-1', 'file-2'], { skipExist: false });
339
+ });
340
+
341
+ it('should skip auto-embed for unsupported file types (images/videos/audio)', async () => {
342
+ const { result } = renderHook(() => useStore());
343
+
344
+ const imageFile = new File(['image content'], 'image.png', { type: 'image/png' });
345
+ const videoFile = new File(['video content'], 'video.mp4', { type: 'video/mp4' });
346
+ const audioFile = new File(['audio content'], 'audio.mp3', { type: 'audio/mpeg' });
347
+
348
+ vi.spyOn(result.current, 'uploadWithProgress')
349
+ .mockResolvedValueOnce({ id: 'file-1', url: 'http://example.com/file-1' })
350
+ .mockResolvedValueOnce({ id: 'file-2', url: 'http://example.com/file-2' })
351
+ .mockResolvedValueOnce({ id: 'file-3', url: 'http://example.com/file-3' });
352
+ vi.spyOn(result.current, 'refreshFileList').mockResolvedValue();
353
+ const parseSpy = vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue();
354
+
355
+ await act(async () => {
356
+ await result.current.pushDockFileList([imageFile, videoFile, audioFile]);
357
+ });
358
+
359
+ // Should not auto-parse unsupported files
360
+ expect(parseSpy).not.toHaveBeenCalled();
361
+ });
362
+
363
+ it('should auto-embed only supported files in mixed upload', async () => {
364
+ const { result } = renderHook(() => useStore());
365
+
366
+ const textFile = new File(['text content'], 'doc.txt', { type: 'text/plain' });
367
+ const imageFile = new File(['image content'], 'image.png', { type: 'image/png' });
368
+ const pdfFile = new File(['pdf content'], 'doc.pdf', { type: 'application/pdf' });
369
+
370
+ vi.spyOn(result.current, 'uploadWithProgress')
371
+ .mockResolvedValueOnce({ id: 'file-1', url: 'http://example.com/file-1' })
372
+ .mockResolvedValueOnce({ id: 'file-2', url: 'http://example.com/file-2' })
373
+ .mockResolvedValueOnce({ id: 'file-3', url: 'http://example.com/file-3' });
374
+ vi.spyOn(result.current, 'refreshFileList').mockResolvedValue();
375
+ const parseSpy = vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue();
376
+
377
+ await act(async () => {
378
+ await result.current.pushDockFileList([textFile, imageFile, pdfFile]);
379
+ });
380
+
381
+ // Should only auto-parse text and pdf files, skip image
382
+ expect(parseSpy).toHaveBeenCalledWith(['file-1', 'file-3'], { skipExist: false });
383
+ });
384
+
385
+ it('should skip auto-embed when upload fails', async () => {
386
+ const { result } = renderHook(() => useStore());
387
+
388
+ const textFile = new File(['text content'], 'doc.txt', { type: 'text/plain' });
389
+
390
+ vi.spyOn(result.current, 'uploadWithProgress').mockResolvedValue(undefined);
391
+ vi.spyOn(result.current, 'refreshFileList').mockResolvedValue();
392
+ const parseSpy = vi.spyOn(result.current, 'parseFilesToChunks').mockResolvedValue();
393
+
394
+ await act(async () => {
395
+ await result.current.pushDockFileList([textFile]);
396
+ });
397
+
398
+ // Should not auto-parse when upload returns undefined
399
+ expect(parseSpy).not.toHaveBeenCalled();
312
400
  });
313
401
  });
314
402
 
@@ -11,6 +11,7 @@ import {
11
11
  uploadFileListReducer,
12
12
  } from '@/store/file/reducers/uploadFileList';
13
13
  import { FileListItem, QueryFileListParams } from '@/types/files';
14
+ import { isChunkingUnsupported } from '@/utils/isChunkingUnsupported';
14
15
 
15
16
  import { FileStore } from '../../store';
16
17
  import { fileManagerSelectors } from './selectors';
@@ -98,17 +99,28 @@ export const createFileManageSlice: StateCreator<
98
99
  type: 'addFiles',
99
100
  });
100
101
 
101
- const pools = files.map(async (file) => {
102
- await get().uploadWithProgress({
103
- file,
104
- knowledgeBaseId,
105
- onStatusUpdate: dispatchDockFileList,
106
- });
102
+ const uploadResults = await Promise.all(
103
+ files.map(async (file) => {
104
+ const result = await get().uploadWithProgress({
105
+ file,
106
+ knowledgeBaseId,
107
+ onStatusUpdate: dispatchDockFileList,
108
+ });
107
109
 
108
- await get().refreshFileList();
109
- });
110
+ await get().refreshFileList();
110
111
 
111
- await Promise.all(pools);
112
+ return { file, fileId: result?.id, fileType: file.type };
113
+ }),
114
+ );
115
+
116
+ // 2. auto-embed files that support chunking
117
+ const fileIdsToEmbed = uploadResults
118
+ .filter(({ fileType, fileId }) => fileId && !isChunkingUnsupported(fileType))
119
+ .map(({ fileId }) => fileId!);
120
+
121
+ if (fileIdsToEmbed.length > 0) {
122
+ await get().parseFilesToChunks(fileIdsToEmbed, { skipExist: false });
123
+ }
112
124
  },
113
125
 
114
126
  reEmbeddingChunks: async (id) => {