@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 +25 -0
- package/changelog/v1.json +9 -0
- package/package.json +1 -1
- package/packages/agent-runtime/src/core/runtime.ts +23 -12
- package/packages/agent-runtime/src/types/instruction.ts +4 -0
- package/packages/const/src/currency.ts +2 -2
- package/packages/model-runtime/src/core/usageConverters/utils/computeChatCost.test.ts +205 -12
- package/packages/model-runtime/src/core/usageConverters/utils/computeChatCost.ts +47 -11
- package/src/app/__tests__/desktop.routes.test.ts +18 -0
- package/src/features/FileManager/FileList/MasonryFileItem/index.tsx +56 -25
- package/src/features/FileManager/FileList/index.tsx +12 -3
- package/src/store/file/slices/fileManager/action.test.ts +88 -0
- package/src/store/file/slices/fileManager/action.ts +21 -9
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
|
+
[](#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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobehub/chat",
|
|
3
|
-
"version": "1.141.
|
|
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
|
|
260
|
-
* @
|
|
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
|
|
264
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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:
|
|
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:
|
|
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,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).
|
|
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).
|
|
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); //
|
|
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).
|
|
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).
|
|
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); //
|
|
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).
|
|
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).
|
|
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); //
|
|
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).
|
|
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).
|
|
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); //
|
|
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
|
|
263
|
+
const rawCredits = computeFixedCredits(fixedUnit, quantity);
|
|
264
|
+
const usdCredits = toUSDCredits(rawCredits, currency, usdToCnyRate);
|
|
235
265
|
breakdown.push({
|
|
236
|
-
cost: creditsToUSD(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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={
|
|
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
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
});
|
|
110
|
+
await get().refreshFileList();
|
|
110
111
|
|
|
111
|
-
|
|
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) => {
|