@lobehub/chat 1.84.25 → 1.84.27
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 +50 -0
- package/changelog/v1.json +18 -0
- package/package.json +3 -3
- package/src/config/aiModels/ollama.ts +12 -2
- package/src/config/aiModels/vertexai.ts +6 -6
- package/src/libs/agent-runtime/google/index.ts +2 -1
- package/src/libs/agent-runtime/utils/streams/google-ai.test.ts +101 -6
- package/src/libs/agent-runtime/utils/streams/google-ai.ts +62 -38
- package/src/libs/agent-runtime/utils/streams/protocol.ts +24 -4
- package/src/libs/agent-runtime/utils/streams/vertex-ai.test.ts +109 -8
- package/src/libs/agent-runtime/utils/streams/vertex-ai.ts +68 -23
- package/src/libs/langchain/loaders/epub/index.ts +4 -2
- package/src/libs/mcp/__tests__/__snapshots__/index.test.ts.snap +3 -1
- package/src/server/routers/async/file.ts +1 -1
- package/src/server/routers/lambda/__tests__/file.test.ts +213 -0
- package/src/server/routers/lambda/file.ts +1 -1
- package/src/server/routers/lambda/importer.ts +4 -2
- package/src/server/routers/lambda/message.ts +2 -2
- package/src/server/routers/lambda/ragEval.ts +1 -1
- package/src/server/routers/lambda/user.ts +2 -2
- package/src/server/services/file/index.ts +46 -0
- package/src/server/utils/tempFileManager.ts +5 -8
package/CHANGELOG.md
CHANGED
@@ -2,6 +2,56 @@
|
|
2
2
|
|
3
3
|
# Changelog
|
4
4
|
|
5
|
+
### [Version 1.84.27](https://github.com/lobehub/lobe-chat/compare/v1.84.26...v1.84.27)
|
6
|
+
|
7
|
+
<sup>Released on **2025-05-09**</sup>
|
8
|
+
|
9
|
+
#### 💄 Styles
|
10
|
+
|
11
|
+
- **misc**: Add reasoning tokens and token usage statistics for Google Gemini.
|
12
|
+
|
13
|
+
<br/>
|
14
|
+
|
15
|
+
<details>
|
16
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
17
|
+
|
18
|
+
#### Styles
|
19
|
+
|
20
|
+
- **misc**: Add reasoning tokens and token usage statistics for Google Gemini, closes [#7501](https://github.com/lobehub/lobe-chat/issues/7501) ([b466b42](https://github.com/lobehub/lobe-chat/commit/b466b42))
|
21
|
+
|
22
|
+
</details>
|
23
|
+
|
24
|
+
<div align="right">
|
25
|
+
|
26
|
+
[](#readme-top)
|
27
|
+
|
28
|
+
</div>
|
29
|
+
|
30
|
+
### [Version 1.84.26](https://github.com/lobehub/lobe-chat/compare/v1.84.25...v1.84.26)
|
31
|
+
|
32
|
+
<sup>Released on **2025-05-08**</sup>
|
33
|
+
|
34
|
+
#### 💄 Styles
|
35
|
+
|
36
|
+
- **misc**: Add qwen3 for ollama.
|
37
|
+
|
38
|
+
<br/>
|
39
|
+
|
40
|
+
<details>
|
41
|
+
<summary><kbd>Improvements and Fixes</kbd></summary>
|
42
|
+
|
43
|
+
#### Styles
|
44
|
+
|
45
|
+
- **misc**: Add qwen3 for ollama, closes [#7746](https://github.com/lobehub/lobe-chat/issues/7746) ([806d905](https://github.com/lobehub/lobe-chat/commit/806d905))
|
46
|
+
|
47
|
+
</details>
|
48
|
+
|
49
|
+
<div align="right">
|
50
|
+
|
51
|
+
[](#readme-top)
|
52
|
+
|
53
|
+
</div>
|
54
|
+
|
5
55
|
### [Version 1.84.25](https://github.com/lobehub/lobe-chat/compare/v1.84.24...v1.84.25)
|
6
56
|
|
7
57
|
<sup>Released on **2025-05-08**</sup>
|
package/changelog/v1.json
CHANGED
@@ -1,4 +1,22 @@
|
|
1
1
|
[
|
2
|
+
{
|
3
|
+
"children": {
|
4
|
+
"improvements": [
|
5
|
+
"Add reasoning tokens and token usage statistics for Google Gemini."
|
6
|
+
]
|
7
|
+
},
|
8
|
+
"date": "2025-05-09",
|
9
|
+
"version": "1.84.27"
|
10
|
+
},
|
11
|
+
{
|
12
|
+
"children": {
|
13
|
+
"improvements": [
|
14
|
+
"Add qwen3 for ollama."
|
15
|
+
]
|
16
|
+
},
|
17
|
+
"date": "2025-05-08",
|
18
|
+
"version": "1.84.26"
|
19
|
+
},
|
2
20
|
{
|
3
21
|
"children": {
|
4
22
|
"fixes": [
|
package/package.json
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
{
|
2
2
|
"name": "@lobehub/chat",
|
3
|
-
"version": "1.84.
|
3
|
+
"version": "1.84.27",
|
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",
|
@@ -121,7 +121,7 @@
|
|
121
121
|
"dependencies": {
|
122
122
|
"@ant-design/icons": "^5.6.1",
|
123
123
|
"@ant-design/pro-components": "^2.8.7",
|
124
|
-
"@anthropic-ai/sdk": "^0.
|
124
|
+
"@anthropic-ai/sdk": "^0.41.0",
|
125
125
|
"@auth/core": "^0.38.0",
|
126
126
|
"@aws-sdk/client-bedrock-runtime": "^3.779.0",
|
127
127
|
"@aws-sdk/client-s3": "^3.779.0",
|
@@ -199,7 +199,7 @@
|
|
199
199
|
"langfuse": "^3.37.1",
|
200
200
|
"langfuse-core": "^3.37.1",
|
201
201
|
"lodash-es": "^4.17.21",
|
202
|
-
"lucide-react": "^0.
|
202
|
+
"lucide-react": "^0.508.0",
|
203
203
|
"mammoth": "^1.9.0",
|
204
204
|
"mdast-util-to-markdown": "^2.1.2",
|
205
205
|
"modern-screenshot": "^4.6.0",
|
@@ -89,11 +89,22 @@ const ollamaChatModels: AIChatModelCard[] = [
|
|
89
89
|
description:
|
90
90
|
'QwQ 是 Qwen 系列的推理模型。与传统的指令调优模型相比,QwQ 具备思考和推理的能力,能够在下游任务中,尤其是困难问题上,显著提升性能。QwQ-32B 是中型推理模型,能够在与最先进的推理模型(如 DeepSeek-R1、o1-mini)竞争时取得可观的表现。',
|
91
91
|
displayName: 'QwQ 32B',
|
92
|
-
enabled: true,
|
93
92
|
id: 'qwq',
|
94
93
|
releasedAt: '2024-11-28',
|
95
94
|
type: 'chat',
|
96
95
|
},
|
96
|
+
{
|
97
|
+
abilities: {
|
98
|
+
functionCall: true,
|
99
|
+
},
|
100
|
+
contextWindowTokens: 65_536,
|
101
|
+
description: 'Qwen3 是阿里巴巴的新一代大规模语言模型,以优异的性能支持多元化的应用需求。',
|
102
|
+
displayName: 'Qwen3 7B',
|
103
|
+
enabled: true,
|
104
|
+
id: 'qwen3',
|
105
|
+
type: 'chat',
|
106
|
+
},
|
107
|
+
|
97
108
|
{
|
98
109
|
contextWindowTokens: 128_000,
|
99
110
|
description: 'Qwen2.5 是阿里巴巴的新一代大规模语言模型,以优异的性能支持多元化的应用需求。',
|
@@ -115,7 +126,6 @@ const ollamaChatModels: AIChatModelCard[] = [
|
|
115
126
|
contextWindowTokens: 128_000,
|
116
127
|
description: 'Qwen2.5 是阿里巴巴的新一代大规模语言模型,以优异的性能支持多元化的应用需求。',
|
117
128
|
displayName: 'Qwen2.5 7B',
|
118
|
-
enabled: true,
|
119
129
|
id: 'qwen2.5',
|
120
130
|
type: 'chat',
|
121
131
|
},
|
@@ -98,9 +98,9 @@ const vertexaiChatModels: AIChatModelCard[] = [
|
|
98
98
|
type: 'chat',
|
99
99
|
},
|
100
100
|
{
|
101
|
-
abilities: {
|
102
|
-
functionCall: true,
|
103
|
-
vision: true
|
101
|
+
abilities: {
|
102
|
+
functionCall: true,
|
103
|
+
vision: true
|
104
104
|
},
|
105
105
|
contextWindowTokens: 1_000_000 + 8192,
|
106
106
|
description: 'Gemini 1.5 Flash 002 是一款高效的多模态模型,支持广泛应用的扩展。',
|
@@ -115,9 +115,9 @@ const vertexaiChatModels: AIChatModelCard[] = [
|
|
115
115
|
type: 'chat',
|
116
116
|
},
|
117
117
|
{
|
118
|
-
abilities: {
|
119
|
-
functionCall: true,
|
120
|
-
vision: true
|
118
|
+
abilities: {
|
119
|
+
functionCall: true,
|
120
|
+
vision: true
|
121
121
|
},
|
122
122
|
contextWindowTokens: 2_000_000 + 8192,
|
123
123
|
description:
|
@@ -106,6 +106,7 @@ export class LobeGoogleAI implements LobeRuntimeAI {
|
|
106
106
|
|
107
107
|
const contents = await this.buildGoogleMessages(payload.messages);
|
108
108
|
|
109
|
+
const inputStartAt = Date.now();
|
109
110
|
const geminiStreamResult = await this.client
|
110
111
|
.getGenerativeModel(
|
111
112
|
{
|
@@ -161,7 +162,7 @@ export class LobeGoogleAI implements LobeRuntimeAI {
|
|
161
162
|
|
162
163
|
// Convert the response into a friendly text-stream
|
163
164
|
const Stream = this.isVertexAi ? VertexAIStream : GoogleGenerativeAIStream;
|
164
|
-
const stream = Stream(prod, options?.callback);
|
165
|
+
const stream = Stream(prod, { callbacks: options?.callback, inputStartAt });
|
165
166
|
|
166
167
|
// Respond with the stream
|
167
168
|
return StreamingResponse(stream, { headers: options?.headers });
|
@@ -34,10 +34,12 @@ describe('GoogleGenerativeAIStream', () => {
|
|
34
34
|
const onCompletionMock = vi.fn();
|
35
35
|
|
36
36
|
const protocolStream = GoogleGenerativeAIStream(mockGoogleStream, {
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
37
|
+
callbacks: {
|
38
|
+
onStart: onStartMock,
|
39
|
+
onText: onTextMock,
|
40
|
+
onToolsCalling: onToolCallMock,
|
41
|
+
onCompletion: onCompletionMock,
|
42
|
+
},
|
41
43
|
});
|
42
44
|
|
43
45
|
const decoder = new TextDecoder();
|
@@ -187,7 +189,7 @@ describe('GoogleGenerativeAIStream', () => {
|
|
187
189
|
// usage
|
188
190
|
'id: chat_1\n',
|
189
191
|
'event: usage\n',
|
190
|
-
`data: {"inputImageTokens":258,"inputTextTokens":8,"totalInputTokens":266,"totalTokens":266}\n\n`,
|
192
|
+
`data: {"inputImageTokens":258,"inputTextTokens":8,"outputTextTokens":0,"totalInputTokens":266,"totalOutputTokens":0,"totalTokens":266}\n\n`,
|
191
193
|
]);
|
192
194
|
});
|
193
195
|
|
@@ -276,7 +278,100 @@ describe('GoogleGenerativeAIStream', () => {
|
|
276
278
|
// usage
|
277
279
|
'id: chat_1',
|
278
280
|
'event: usage',
|
279
|
-
`data: {"inputTextTokens":19,"totalInputTokens":19,"totalOutputTokens":11,"totalTokens":30}\n`,
|
281
|
+
`data: {"inputTextTokens":19,"outputTextTokens":11,"totalInputTokens":19,"totalOutputTokens":11,"totalTokens":30}\n`,
|
282
|
+
].map((i) => i + '\n'),
|
283
|
+
);
|
284
|
+
});
|
285
|
+
|
286
|
+
it('should handle stop with content and thought', async () => {
|
287
|
+
vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1');
|
288
|
+
|
289
|
+
const data = [
|
290
|
+
{
|
291
|
+
candidates: [
|
292
|
+
{
|
293
|
+
content: { parts: [{ text: '234' }], role: 'model' },
|
294
|
+
safetyRatings: [
|
295
|
+
{ category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' },
|
296
|
+
{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' },
|
297
|
+
{ category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' },
|
298
|
+
{ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' },
|
299
|
+
],
|
300
|
+
},
|
301
|
+
],
|
302
|
+
text: () => '234',
|
303
|
+
usageMetadata: {
|
304
|
+
promptTokenCount: 19,
|
305
|
+
candidatesTokenCount: 3,
|
306
|
+
totalTokenCount: 122,
|
307
|
+
promptTokensDetails: [{ modality: 'TEXT', tokenCount: 19 }],
|
308
|
+
thoughtsTokenCount: 100,
|
309
|
+
},
|
310
|
+
modelVersion: 'gemini-2.0-flash-exp-image-generation',
|
311
|
+
},
|
312
|
+
{
|
313
|
+
text: () => '567890\n',
|
314
|
+
candidates: [
|
315
|
+
{
|
316
|
+
content: { parts: [{ text: '567890\n' }], role: 'model' },
|
317
|
+
finishReason: 'STOP',
|
318
|
+
safetyRatings: [
|
319
|
+
{ category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' },
|
320
|
+
{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' },
|
321
|
+
{ category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' },
|
322
|
+
{ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' },
|
323
|
+
],
|
324
|
+
},
|
325
|
+
],
|
326
|
+
usageMetadata: {
|
327
|
+
promptTokenCount: 19,
|
328
|
+
candidatesTokenCount: 11,
|
329
|
+
totalTokenCount: 131,
|
330
|
+
promptTokensDetails: [{ modality: 'TEXT', tokenCount: 19 }],
|
331
|
+
candidatesTokensDetails: [{ modality: 'TEXT', tokenCount: 11 }],
|
332
|
+
thoughtsTokenCount: 100,
|
333
|
+
},
|
334
|
+
modelVersion: 'gemini-2.0-flash-exp-image-generation',
|
335
|
+
},
|
336
|
+
];
|
337
|
+
|
338
|
+
const mockGoogleStream = new ReadableStream({
|
339
|
+
start(controller) {
|
340
|
+
data.forEach((item) => {
|
341
|
+
controller.enqueue(item);
|
342
|
+
});
|
343
|
+
|
344
|
+
controller.close();
|
345
|
+
},
|
346
|
+
});
|
347
|
+
|
348
|
+
const protocolStream = GoogleGenerativeAIStream(mockGoogleStream);
|
349
|
+
|
350
|
+
const decoder = new TextDecoder();
|
351
|
+
const chunks = [];
|
352
|
+
|
353
|
+
// @ts-ignore
|
354
|
+
for await (const chunk of protocolStream) {
|
355
|
+
chunks.push(decoder.decode(chunk, { stream: true }));
|
356
|
+
}
|
357
|
+
|
358
|
+
expect(chunks).toEqual(
|
359
|
+
[
|
360
|
+
'id: chat_1',
|
361
|
+
'event: text',
|
362
|
+
'data: "234"\n',
|
363
|
+
|
364
|
+
'id: chat_1',
|
365
|
+
'event: text',
|
366
|
+
`data: "567890\\n"\n`,
|
367
|
+
// stop
|
368
|
+
'id: chat_1',
|
369
|
+
'event: stop',
|
370
|
+
`data: "STOP"\n`,
|
371
|
+
// usage
|
372
|
+
'id: chat_1',
|
373
|
+
'event: usage',
|
374
|
+
`data: {"inputTextTokens":19,"outputReasoningTokens":100,"outputTextTokens":11,"totalInputTokens":19,"totalOutputTokens":111,"totalTokens":131}\n`,
|
280
375
|
].map((i) => i + '\n'),
|
281
376
|
);
|
282
377
|
});
|
@@ -11,6 +11,7 @@ import {
|
|
11
11
|
StreamToolCallChunkData,
|
12
12
|
createCallbacksTransformer,
|
13
13
|
createSSEProtocolTransformer,
|
14
|
+
createTokenSpeedCalculator,
|
14
15
|
generateToolCallId,
|
15
16
|
} from './protocol';
|
16
17
|
|
@@ -19,31 +20,62 @@ const transformGoogleGenerativeAIStream = (
|
|
19
20
|
context: StreamContext,
|
20
21
|
): StreamProtocolChunk | StreamProtocolChunk[] => {
|
21
22
|
// maybe need another structure to add support for multiple choices
|
23
|
+
const candidate = chunk.candidates?.[0];
|
24
|
+
const usage = chunk.usageMetadata;
|
25
|
+
const usageChunks: StreamProtocolChunk[] = [];
|
26
|
+
if (candidate?.finishReason && usage) {
|
27
|
+
const outputReasoningTokens = (usage as any).thoughtsTokenCount || undefined;
|
28
|
+
const totalOutputTokens = (usage.candidatesTokenCount ?? 0) + (outputReasoningTokens ?? 0);
|
29
|
+
|
30
|
+
usageChunks.push(
|
31
|
+
{ data: candidate.finishReason, id: context?.id, type: 'stop' },
|
32
|
+
{
|
33
|
+
data: {
|
34
|
+
// TODO: Google SDK 0.24.0 don't have promptTokensDetails types
|
35
|
+
inputImageTokens: (usage as any).promptTokensDetails?.find(
|
36
|
+
(i: any) => i.modality === 'IMAGE',
|
37
|
+
)?.tokenCount,
|
38
|
+
inputTextTokens: (usage as any).promptTokensDetails?.find(
|
39
|
+
(i: any) => i.modality === 'TEXT',
|
40
|
+
)?.tokenCount,
|
41
|
+
outputReasoningTokens,
|
42
|
+
outputTextTokens: totalOutputTokens - (outputReasoningTokens ?? 0),
|
43
|
+
totalInputTokens: usage.promptTokenCount,
|
44
|
+
totalOutputTokens,
|
45
|
+
totalTokens: usage.totalTokenCount,
|
46
|
+
} as ModelTokensUsage,
|
47
|
+
id: context?.id,
|
48
|
+
type: 'usage',
|
49
|
+
},
|
50
|
+
);
|
51
|
+
}
|
52
|
+
|
22
53
|
const functionCalls = chunk.functionCalls?.();
|
23
54
|
|
24
55
|
if (functionCalls) {
|
25
|
-
return
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
56
|
+
return [
|
57
|
+
{
|
58
|
+
data: functionCalls.map(
|
59
|
+
(value, index): StreamToolCallChunkData => ({
|
60
|
+
function: {
|
61
|
+
arguments: JSON.stringify(value.args),
|
62
|
+
name: value.name,
|
63
|
+
},
|
64
|
+
id: generateToolCallId(index, value.name),
|
65
|
+
index: index,
|
66
|
+
type: 'function',
|
67
|
+
}),
|
68
|
+
),
|
69
|
+
id: context.id,
|
70
|
+
type: 'tool_calls',
|
71
|
+
},
|
72
|
+
...usageChunks,
|
73
|
+
];
|
40
74
|
}
|
41
75
|
|
42
76
|
const text = chunk.text?.();
|
43
77
|
|
44
|
-
if (
|
45
|
-
const candidate = chunk.candidates[0];
|
46
|
-
|
78
|
+
if (candidate) {
|
47
79
|
// return the grounding
|
48
80
|
if (candidate.groundingMetadata) {
|
49
81
|
const { webSearchQueries, groundingChunks } = candidate.groundingMetadata;
|
@@ -64,31 +96,15 @@ const transformGoogleGenerativeAIStream = (
|
|
64
96
|
id: context.id,
|
65
97
|
type: 'grounding',
|
66
98
|
},
|
99
|
+
...usageChunks,
|
67
100
|
];
|
68
101
|
}
|
69
102
|
|
70
103
|
if (candidate.finishReason) {
|
71
104
|
if (chunk.usageMetadata) {
|
72
|
-
const usage = chunk.usageMetadata;
|
73
105
|
return [
|
74
106
|
!!text ? { data: text, id: context?.id, type: 'text' } : undefined,
|
75
|
-
|
76
|
-
{
|
77
|
-
data: {
|
78
|
-
// TODO: Google SDK 0.24.0 don't have promptTokensDetails types
|
79
|
-
inputImageTokens: (usage as any).promptTokensDetails?.find(
|
80
|
-
(i: any) => i.modality === 'IMAGE',
|
81
|
-
)?.tokenCount,
|
82
|
-
inputTextTokens: (usage as any).promptTokensDetails?.find(
|
83
|
-
(i: any) => i.modality === 'TEXT',
|
84
|
-
)?.tokenCount,
|
85
|
-
totalInputTokens: usage.promptTokenCount,
|
86
|
-
totalOutputTokens: usage.candidatesTokenCount,
|
87
|
-
totalTokens: usage.totalTokenCount,
|
88
|
-
} as ModelTokensUsage,
|
89
|
-
id: context?.id,
|
90
|
-
type: 'usage',
|
91
|
-
},
|
107
|
+
...usageChunks,
|
92
108
|
].filter(Boolean) as StreamProtocolChunk[];
|
93
109
|
}
|
94
110
|
return { data: candidate.finishReason, id: context?.id, type: 'stop' };
|
@@ -117,13 +133,21 @@ const transformGoogleGenerativeAIStream = (
|
|
117
133
|
};
|
118
134
|
};
|
119
135
|
|
136
|
+
export interface GoogleAIStreamOptions {
|
137
|
+
callbacks?: ChatStreamCallbacks;
|
138
|
+
inputStartAt?: number;
|
139
|
+
}
|
140
|
+
|
120
141
|
export const GoogleGenerativeAIStream = (
|
121
142
|
rawStream: ReadableStream<EnhancedGenerateContentResponse>,
|
122
|
-
callbacks
|
143
|
+
{ callbacks, inputStartAt }: GoogleAIStreamOptions = {},
|
123
144
|
) => {
|
124
145
|
const streamStack: StreamContext = { id: 'chat_' + nanoid() };
|
125
146
|
|
126
147
|
return rawStream
|
127
|
-
.pipeThrough(
|
148
|
+
.pipeThrough(
|
149
|
+
createTokenSpeedCalculator(transformGoogleGenerativeAIStream, { inputStartAt, streamStack }),
|
150
|
+
)
|
151
|
+
.pipeThrough(createSSEProtocolTransformer((c) => c, streamStack))
|
128
152
|
.pipeThrough(createCallbacksTransformer(callbacks));
|
129
153
|
};
|
@@ -298,17 +298,37 @@ export const TOKEN_SPEED_CHUNK_ID = 'output_speed';
|
|
298
298
|
*/
|
299
299
|
export const createTokenSpeedCalculator = (
|
300
300
|
transformer: (chunk: any, stack: StreamContext) => StreamProtocolChunk | StreamProtocolChunk[],
|
301
|
-
{
|
301
|
+
{ inputStartAt, streamStack }: { inputStartAt?: number; streamStack?: StreamContext } = {},
|
302
302
|
) => {
|
303
303
|
let outputStartAt: number | undefined;
|
304
|
+
let outputThinking: boolean | undefined;
|
304
305
|
|
305
306
|
const process = (chunk: StreamProtocolChunk) => {
|
306
307
|
let result = [chunk];
|
307
|
-
// if the chunk is the first text chunk, set as output start
|
308
|
-
if (!outputStartAt && chunk.type === 'text'
|
308
|
+
// if the chunk is the first text or reasoning chunk, set as output start
|
309
|
+
if (!outputStartAt && (chunk.type === 'text' || chunk.type === 'reasoning')) {
|
310
|
+
outputStartAt = Date.now();
|
311
|
+
}
|
312
|
+
|
313
|
+
/**
|
314
|
+
* 部分 provider 在正式输出 reasoning 前,可能会先输出 content 为空字符串的 chunk,
|
315
|
+
* 其中 reasoning 可能为 null,会导致判断是否输出思考内容错误,所以过滤掉 null 或者空字符串。
|
316
|
+
* 也可能是某些特殊 token,所以不修改 outputStartAt 的逻辑。
|
317
|
+
*/
|
318
|
+
if (
|
319
|
+
outputThinking === undefined &&
|
320
|
+
(chunk.type === 'text' || chunk.type === 'reasoning') &&
|
321
|
+
typeof chunk.data === 'string' &&
|
322
|
+
chunk.data.length > 0
|
323
|
+
) {
|
324
|
+
outputThinking = chunk.type === 'reasoning';
|
325
|
+
}
|
309
326
|
// if the chunk is the stop chunk, set as output finish
|
310
327
|
if (inputStartAt && outputStartAt && chunk.type === 'usage') {
|
311
|
-
const
|
328
|
+
const totalOutputTokens = chunk.data?.totalOutputTokens || chunk.data?.outputTextTokens;
|
329
|
+
const reasoningTokens = chunk.data?.outputReasoningTokens || 0;
|
330
|
+
const outputTokens =
|
331
|
+
(outputThinking ?? false) ? totalOutputTokens : totalOutputTokens - reasoningTokens;
|
312
332
|
result.push({
|
313
333
|
data: {
|
314
334
|
tps: (outputTokens / (Date.now() - outputStartAt)) * 1000,
|
@@ -103,10 +103,12 @@ describe('VertexAIStream', () => {
|
|
103
103
|
const onCompletionMock = vi.fn();
|
104
104
|
|
105
105
|
const protocolStream = VertexAIStream(mockGoogleStream, {
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
106
|
+
callbacks: {
|
107
|
+
onStart: onStartMock,
|
108
|
+
onText: onTextMock,
|
109
|
+
onToolsCalling: onToolCallMock,
|
110
|
+
onCompletion: onCompletionMock,
|
111
|
+
},
|
110
112
|
});
|
111
113
|
|
112
114
|
const decoder = new TextDecoder();
|
@@ -136,6 +138,7 @@ describe('VertexAIStream', () => {
|
|
136
138
|
|
137
139
|
it('tool_calls', async () => {
|
138
140
|
vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1');
|
141
|
+
|
139
142
|
const rawChunks = [
|
140
143
|
{
|
141
144
|
candidates: [
|
@@ -204,10 +207,12 @@ describe('VertexAIStream', () => {
|
|
204
207
|
const onCompletionMock = vi.fn();
|
205
208
|
|
206
209
|
const protocolStream = VertexAIStream(mockGoogleStream, {
|
207
|
-
|
208
|
-
|
209
|
-
|
210
|
-
|
210
|
+
callbacks: {
|
211
|
+
onStart: onStartMock,
|
212
|
+
onText: onTextMock,
|
213
|
+
onToolsCalling: onToolCallMock,
|
214
|
+
onCompletion: onCompletionMock,
|
215
|
+
},
|
211
216
|
});
|
212
217
|
|
213
218
|
const decoder = new TextDecoder();
|
@@ -223,10 +228,106 @@ describe('VertexAIStream', () => {
|
|
223
228
|
'id: chat_1\n',
|
224
229
|
'event: tool_calls\n',
|
225
230
|
`data: [{"function":{"arguments":"{\\"city\\":\\"杭州\\"}","name":"realtime-weather____fetchCurrentWeather"},"id":"realtime-weather____fetchCurrentWeather_0","index":0,"type":"function"}]\n\n`,
|
231
|
+
'id: chat_1\n',
|
232
|
+
'event: stop\n',
|
233
|
+
'data: "STOP"\n\n',
|
234
|
+
'id: chat_1\n',
|
235
|
+
'event: usage\n',
|
236
|
+
'data: {"outputTextTokens":9,"totalInputTokens":95,"totalOutputTokens":9,"totalTokens":104}\n\n',
|
226
237
|
]);
|
227
238
|
|
228
239
|
expect(onStartMock).toHaveBeenCalledTimes(1);
|
229
240
|
expect(onToolCallMock).toHaveBeenCalledTimes(1);
|
230
241
|
expect(onCompletionMock).toHaveBeenCalledTimes(1);
|
231
242
|
});
|
243
|
+
|
244
|
+
it('should handle stop with content', async () => {
|
245
|
+
vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1');
|
246
|
+
|
247
|
+
const data = [
|
248
|
+
{
|
249
|
+
candidates: [
|
250
|
+
{
|
251
|
+
content: { parts: [{ text: '234' }], role: 'model' },
|
252
|
+
safetyRatings: [
|
253
|
+
{ category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' },
|
254
|
+
{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' },
|
255
|
+
{ category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' },
|
256
|
+
{ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' },
|
257
|
+
],
|
258
|
+
},
|
259
|
+
],
|
260
|
+
text: () => '234',
|
261
|
+
usageMetadata: {
|
262
|
+
promptTokenCount: 20,
|
263
|
+
totalTokenCount: 20,
|
264
|
+
promptTokensDetails: [{ modality: 'TEXT', tokenCount: 20 }],
|
265
|
+
},
|
266
|
+
modelVersion: 'gemini-2.0-flash-exp-image-generation',
|
267
|
+
},
|
268
|
+
{
|
269
|
+
text: () => '567890\n',
|
270
|
+
candidates: [
|
271
|
+
{
|
272
|
+
content: { parts: [{ text: '567890\n' }], role: 'model' },
|
273
|
+
finishReason: 'STOP',
|
274
|
+
safetyRatings: [
|
275
|
+
{ category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' },
|
276
|
+
{ category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' },
|
277
|
+
{ category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' },
|
278
|
+
{ category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' },
|
279
|
+
],
|
280
|
+
},
|
281
|
+
],
|
282
|
+
usageMetadata: {
|
283
|
+
promptTokenCount: 19,
|
284
|
+
candidatesTokenCount: 11,
|
285
|
+
totalTokenCount: 30,
|
286
|
+
promptTokensDetails: [{ modality: 'TEXT', tokenCount: 19 }],
|
287
|
+
candidatesTokensDetails: [{ modality: 'TEXT', tokenCount: 11 }],
|
288
|
+
},
|
289
|
+
modelVersion: 'gemini-2.0-flash-exp-image-generation',
|
290
|
+
},
|
291
|
+
];
|
292
|
+
|
293
|
+
const mockGoogleStream = new ReadableStream({
|
294
|
+
start(controller) {
|
295
|
+
data.forEach((item) => {
|
296
|
+
controller.enqueue(item);
|
297
|
+
});
|
298
|
+
|
299
|
+
controller.close();
|
300
|
+
},
|
301
|
+
});
|
302
|
+
|
303
|
+
const protocolStream = VertexAIStream(mockGoogleStream);
|
304
|
+
|
305
|
+
const decoder = new TextDecoder();
|
306
|
+
const chunks = [];
|
307
|
+
|
308
|
+
// @ts-ignore
|
309
|
+
for await (const chunk of protocolStream) {
|
310
|
+
chunks.push(decoder.decode(chunk, { stream: true }));
|
311
|
+
}
|
312
|
+
|
313
|
+
expect(chunks).toEqual(
|
314
|
+
[
|
315
|
+
'id: chat_1',
|
316
|
+
'event: text',
|
317
|
+
'data: "234"\n',
|
318
|
+
|
319
|
+
'id: chat_1',
|
320
|
+
'event: text',
|
321
|
+
`data: "567890\\n"\n`,
|
322
|
+
// stop
|
323
|
+
'id: chat_1',
|
324
|
+
'event: stop',
|
325
|
+
`data: "STOP"\n`,
|
326
|
+
// usage
|
327
|
+
'id: chat_1',
|
328
|
+
'event: usage',
|
329
|
+
`data: {"inputTextTokens":19,"outputTextTokens":11,"totalInputTokens":19,"totalOutputTokens":11,"totalTokens":30}\n`,
|
330
|
+
].map((i) => i + '\n'),
|
331
|
+
);
|
332
|
+
});
|
232
333
|
});
|
@@ -1,27 +1,58 @@
|
|
1
1
|
import { EnhancedGenerateContentResponse, GenerateContentResponse } from '@google/generative-ai';
|
2
2
|
|
3
|
+
import { ModelTokensUsage } from '@/types/message';
|
3
4
|
import { nanoid } from '@/utils/uuid';
|
4
5
|
|
5
|
-
import {
|
6
|
+
import { type GoogleAIStreamOptions } from './google-ai';
|
6
7
|
import {
|
7
8
|
StreamContext,
|
8
9
|
StreamProtocolChunk,
|
9
10
|
createCallbacksTransformer,
|
10
11
|
createSSEProtocolTransformer,
|
12
|
+
createTokenSpeedCalculator,
|
11
13
|
generateToolCallId,
|
12
14
|
} from './protocol';
|
13
15
|
|
14
16
|
const transformVertexAIStream = (
|
15
17
|
chunk: GenerateContentResponse,
|
16
|
-
|
17
|
-
): StreamProtocolChunk => {
|
18
|
+
context: StreamContext,
|
19
|
+
): StreamProtocolChunk | StreamProtocolChunk[] => {
|
18
20
|
// maybe need another structure to add support for multiple choices
|
19
|
-
const
|
21
|
+
const candidate = chunk.candidates?.[0];
|
22
|
+
const usage = chunk.usageMetadata;
|
23
|
+
const usageChunks: StreamProtocolChunk[] = [];
|
24
|
+
if (candidate?.finishReason && usage) {
|
25
|
+
const outputReasoningTokens = (usage as any).thoughtsTokenCount || undefined;
|
26
|
+
const totalOutputTokens = (usage.candidatesTokenCount ?? 0) + (outputReasoningTokens ?? 0);
|
27
|
+
|
28
|
+
usageChunks.push(
|
29
|
+
{ data: candidate.finishReason, id: context?.id, type: 'stop' },
|
30
|
+
{
|
31
|
+
data: {
|
32
|
+
// TODO: Google SDK 0.24.0 don't have promptTokensDetails types
|
33
|
+
inputImageTokens: (usage as any).promptTokensDetails?.find(
|
34
|
+
(i: any) => i.modality === 'IMAGE',
|
35
|
+
)?.tokenCount,
|
36
|
+
inputTextTokens: (usage as any).promptTokensDetails?.find(
|
37
|
+
(i: any) => i.modality === 'TEXT',
|
38
|
+
)?.tokenCount,
|
39
|
+
outputReasoningTokens,
|
40
|
+
outputTextTokens: totalOutputTokens - (outputReasoningTokens ?? 0),
|
41
|
+
totalInputTokens: usage.promptTokenCount,
|
42
|
+
totalOutputTokens,
|
43
|
+
totalTokens: usage.totalTokenCount,
|
44
|
+
} as ModelTokensUsage,
|
45
|
+
id: context?.id,
|
46
|
+
type: 'usage',
|
47
|
+
},
|
48
|
+
);
|
49
|
+
}
|
20
50
|
|
51
|
+
const candidates = chunk.candidates;
|
21
52
|
if (!candidates)
|
22
53
|
return {
|
23
54
|
data: '',
|
24
|
-
id:
|
55
|
+
id: context?.id,
|
25
56
|
type: 'text',
|
26
57
|
};
|
27
58
|
|
@@ -32,44 +63,58 @@ const transformVertexAIStream = (
|
|
32
63
|
if (part.functionCall) {
|
33
64
|
const functionCall = part.functionCall;
|
34
65
|
|
35
|
-
return
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
66
|
+
return [
|
67
|
+
{
|
68
|
+
data: [
|
69
|
+
{
|
70
|
+
function: {
|
71
|
+
arguments: JSON.stringify(functionCall.args),
|
72
|
+
name: functionCall.name,
|
73
|
+
},
|
74
|
+
id: generateToolCallId(0, functionCall.name),
|
75
|
+
index: 0,
|
76
|
+
type: 'function',
|
41
77
|
},
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
78
|
+
],
|
79
|
+
id: context?.id,
|
80
|
+
type: 'tool_calls',
|
81
|
+
},
|
82
|
+
...usageChunks,
|
83
|
+
];
|
84
|
+
}
|
85
|
+
|
86
|
+
if (item.finishReason) {
|
87
|
+
if (chunk.usageMetadata) {
|
88
|
+
return [
|
89
|
+
!!part.text ? { data: part.text, id: context?.id, type: 'text' } : undefined,
|
90
|
+
...usageChunks,
|
91
|
+
].filter(Boolean) as StreamProtocolChunk[];
|
92
|
+
}
|
93
|
+
return { data: item.finishReason, id: context?.id, type: 'stop' };
|
50
94
|
}
|
51
95
|
|
52
96
|
return {
|
53
97
|
data: part.text,
|
54
|
-
id:
|
98
|
+
id: context?.id,
|
55
99
|
type: 'text',
|
56
100
|
};
|
57
101
|
}
|
58
102
|
|
59
103
|
return {
|
60
104
|
data: '',
|
61
|
-
id:
|
105
|
+
id: context?.id,
|
62
106
|
type: 'stop',
|
63
107
|
};
|
64
108
|
};
|
65
109
|
|
66
110
|
export const VertexAIStream = (
|
67
111
|
rawStream: ReadableStream<EnhancedGenerateContentResponse>,
|
68
|
-
callbacks
|
112
|
+
{ callbacks, inputStartAt }: GoogleAIStreamOptions = {},
|
69
113
|
) => {
|
70
114
|
const streamStack: StreamContext = { id: 'chat_' + nanoid() };
|
71
115
|
|
72
116
|
return rawStream
|
73
|
-
.pipeThrough(
|
117
|
+
.pipeThrough(createTokenSpeedCalculator(transformVertexAIStream, { inputStartAt, streamStack }))
|
118
|
+
.pipeThrough(createSSEProtocolTransformer((c) => c, streamStack))
|
74
119
|
.pipeThrough(createCallbacksTransformer(callbacks));
|
75
120
|
};
|
@@ -2,13 +2,15 @@ import { EPubLoader as Loader } from '@langchain/community/document_loaders/fs/e
|
|
2
2
|
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
|
3
3
|
|
4
4
|
import { TempFileManager } from '@/server/utils/tempFileManager';
|
5
|
+
import { nanoid } from '@/utils/uuid';
|
5
6
|
|
6
7
|
import { loaderConfig } from '../config';
|
7
8
|
|
8
9
|
export const EPubLoader = async (content: Uint8Array) => {
|
9
|
-
const tempManager = new TempFileManager();
|
10
|
+
const tempManager = new TempFileManager('epub-');
|
11
|
+
|
10
12
|
try {
|
11
|
-
const tempPath = await tempManager.writeTempFile(content);
|
13
|
+
const tempPath = await tempManager.writeTempFile(content, `${nanoid()}.epub`);
|
12
14
|
const loader = new Loader(tempPath);
|
13
15
|
const documents = await loader.load();
|
14
16
|
|
@@ -21,9 +21,11 @@ exports[`MCPClient > Stdio Transport > should list tools via stdio 1`] = `
|
|
21
21
|
"name": "echo",
|
22
22
|
},
|
23
23
|
{
|
24
|
-
"annotations": {},
|
25
24
|
"description": "Lists all available tools and methods",
|
26
25
|
"inputSchema": {
|
26
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
27
|
+
"additionalProperties": false,
|
28
|
+
"properties": {},
|
27
29
|
"type": "object",
|
28
30
|
},
|
29
31
|
"name": "debug",
|
@@ -35,7 +35,7 @@ const fileProcedure = asyncAuthedProcedure.use(async (opts) => {
|
|
35
35
|
chunkService: new ChunkService(ctx.userId),
|
36
36
|
embeddingModel: new EmbeddingModel(ctx.serverDB, ctx.userId),
|
37
37
|
fileModel: new FileModel(ctx.serverDB, ctx.userId),
|
38
|
-
fileService: new FileService(),
|
38
|
+
fileService: new FileService(ctx.serverDB, ctx.userId),
|
39
39
|
},
|
40
40
|
});
|
41
41
|
});
|
@@ -0,0 +1,213 @@
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
3
|
+
|
4
|
+
import { fileRouter } from '@/server/routers/lambda/file';
|
5
|
+
import { AsyncTaskStatus } from '@/types/asyncTask';
|
6
|
+
|
7
|
+
// Patch: Use actual router context middleware to inject the correct models/services
|
8
|
+
function createCallerWithCtx(partialCtx: any = {}) {
|
9
|
+
// All mocks are spies
|
10
|
+
const fileModel = {
|
11
|
+
checkHash: vi.fn().mockResolvedValue({ isExist: true }),
|
12
|
+
create: vi.fn().mockResolvedValue({ id: 'test-id' }),
|
13
|
+
findById: vi.fn().mockResolvedValue(undefined),
|
14
|
+
query: vi.fn().mockResolvedValue([]),
|
15
|
+
delete: vi.fn().mockResolvedValue(undefined),
|
16
|
+
deleteMany: vi.fn().mockResolvedValue([]),
|
17
|
+
clear: vi.fn().mockResolvedValue({} as any),
|
18
|
+
};
|
19
|
+
|
20
|
+
const fileService = {
|
21
|
+
getFullFileUrl: vi.fn().mockResolvedValue('full-url'),
|
22
|
+
deleteFile: vi.fn().mockResolvedValue(undefined),
|
23
|
+
deleteFiles: vi.fn().mockResolvedValue(undefined),
|
24
|
+
};
|
25
|
+
|
26
|
+
const chunkModel = {
|
27
|
+
countByFileIds: vi.fn().mockResolvedValue([{ id: 'test-id', count: 5 }]),
|
28
|
+
countByFileId: vi.fn().mockResolvedValue(5),
|
29
|
+
};
|
30
|
+
|
31
|
+
const asyncTaskModel = {
|
32
|
+
findByIds: vi.fn().mockResolvedValue([
|
33
|
+
{
|
34
|
+
id: 'test-task-id',
|
35
|
+
status: AsyncTaskStatus.Success,
|
36
|
+
},
|
37
|
+
]),
|
38
|
+
findById: vi.fn(),
|
39
|
+
delete: vi.fn(),
|
40
|
+
};
|
41
|
+
|
42
|
+
const ctx = {
|
43
|
+
serverDB: {} as any,
|
44
|
+
userId: 'test-user',
|
45
|
+
asyncTaskModel,
|
46
|
+
chunkModel,
|
47
|
+
fileModel,
|
48
|
+
fileService,
|
49
|
+
...partialCtx,
|
50
|
+
};
|
51
|
+
|
52
|
+
return { ctx, caller: fileRouter.createCaller(ctx) };
|
53
|
+
}
|
54
|
+
|
55
|
+
vi.mock('@/config/db', () => ({
|
56
|
+
serverDBEnv: {
|
57
|
+
REMOVE_GLOBAL_FILE: false,
|
58
|
+
},
|
59
|
+
}));
|
60
|
+
|
61
|
+
vi.mock('@/database/models/asyncTask', () => ({
|
62
|
+
AsyncTaskModel: vi.fn(() => ({
|
63
|
+
findById: vi.fn(),
|
64
|
+
findByIds: vi.fn(),
|
65
|
+
delete: vi.fn(),
|
66
|
+
})),
|
67
|
+
}));
|
68
|
+
|
69
|
+
vi.mock('@/database/models/chunk', () => ({
|
70
|
+
ChunkModel: vi.fn(() => ({
|
71
|
+
countByFileId: vi.fn(),
|
72
|
+
countByFileIds: vi.fn(),
|
73
|
+
})),
|
74
|
+
}));
|
75
|
+
|
76
|
+
vi.mock('@/database/models/file', () => ({
|
77
|
+
FileModel: vi.fn(() => ({
|
78
|
+
checkHash: vi.fn(),
|
79
|
+
create: vi.fn(),
|
80
|
+
delete: vi.fn(),
|
81
|
+
deleteMany: vi.fn(),
|
82
|
+
findById: vi.fn(),
|
83
|
+
query: vi.fn(),
|
84
|
+
clear: vi.fn(),
|
85
|
+
})),
|
86
|
+
}));
|
87
|
+
|
88
|
+
vi.mock('@/server/services/file', () => ({
|
89
|
+
FileService: vi.fn(() => ({
|
90
|
+
getFullFileUrl: vi.fn(),
|
91
|
+
deleteFile: vi.fn(),
|
92
|
+
deleteFiles: vi.fn(),
|
93
|
+
})),
|
94
|
+
}));
|
95
|
+
|
96
|
+
describe('fileRouter', () => {
|
97
|
+
let ctx: any;
|
98
|
+
let caller: any;
|
99
|
+
let mockFile: any;
|
100
|
+
|
101
|
+
beforeEach(() => {
|
102
|
+
vi.clearAllMocks();
|
103
|
+
|
104
|
+
mockFile = {
|
105
|
+
id: 'test-id',
|
106
|
+
name: 'test.txt',
|
107
|
+
url: 'test-url',
|
108
|
+
createdAt: new Date(),
|
109
|
+
updatedAt: new Date(),
|
110
|
+
accessedAt: new Date(),
|
111
|
+
userId: 'test-user',
|
112
|
+
size: 100,
|
113
|
+
fileType: 'text',
|
114
|
+
metadata: {},
|
115
|
+
fileHash: null,
|
116
|
+
clientId: null,
|
117
|
+
chunkTaskId: null,
|
118
|
+
embeddingTaskId: null,
|
119
|
+
};
|
120
|
+
|
121
|
+
// Use actual context with default mocks
|
122
|
+
({ ctx, caller } = createCallerWithCtx());
|
123
|
+
});
|
124
|
+
|
125
|
+
describe('checkFileHash', () => {
|
126
|
+
it('should handle when fileModel.checkHash returns undefined', async () => {
|
127
|
+
ctx.fileModel.checkHash.mockResolvedValue(undefined);
|
128
|
+
await expect(caller.checkFileHash({ hash: 'test-hash' })).resolves.toBeUndefined();
|
129
|
+
});
|
130
|
+
});
|
131
|
+
|
132
|
+
describe('createFile', () => {
|
133
|
+
it('should throw if fileModel.checkHash returns undefined', async () => {
|
134
|
+
ctx.fileModel.checkHash.mockResolvedValue(undefined);
|
135
|
+
await expect(
|
136
|
+
caller.createFile({
|
137
|
+
hash: 'test-hash',
|
138
|
+
fileType: 'text',
|
139
|
+
name: 'test.txt',
|
140
|
+
size: 100,
|
141
|
+
url: 'test-url',
|
142
|
+
metadata: {},
|
143
|
+
}),
|
144
|
+
).rejects.toThrow();
|
145
|
+
});
|
146
|
+
});
|
147
|
+
|
148
|
+
describe('findById', () => {
|
149
|
+
it('should throw error when file not found', async () => {
|
150
|
+
ctx.fileModel.findById.mockResolvedValue(null);
|
151
|
+
|
152
|
+
await expect(caller.findById({ id: 'invalid-id' })).rejects.toThrow(TRPCError);
|
153
|
+
});
|
154
|
+
});
|
155
|
+
|
156
|
+
describe('getFileItemById', () => {
|
157
|
+
it('should throw error when file not found', async () => {
|
158
|
+
ctx.fileModel.findById.mockResolvedValue(null);
|
159
|
+
|
160
|
+
await expect(caller.getFileItemById({ id: 'invalid-id' })).rejects.toThrow(TRPCError);
|
161
|
+
});
|
162
|
+
});
|
163
|
+
|
164
|
+
describe('getFiles', () => {
|
165
|
+
it('should handle fileModel.query returning undefined', async () => {
|
166
|
+
ctx.fileModel.query.mockResolvedValue(undefined);
|
167
|
+
|
168
|
+
await expect(caller.getFiles({})).rejects.toThrow();
|
169
|
+
});
|
170
|
+
});
|
171
|
+
|
172
|
+
describe('removeFile', () => {
|
173
|
+
it('should do nothing when file not found', async () => {
|
174
|
+
ctx.fileModel.delete.mockResolvedValue(null);
|
175
|
+
|
176
|
+
await caller.removeFile({ id: 'invalid-id' });
|
177
|
+
|
178
|
+
expect(ctx.fileService.deleteFile).not.toHaveBeenCalled();
|
179
|
+
});
|
180
|
+
});
|
181
|
+
|
182
|
+
describe('removeFiles', () => {
|
183
|
+
it('should do nothing when no files found', async () => {
|
184
|
+
ctx.fileModel.deleteMany.mockResolvedValue([]);
|
185
|
+
|
186
|
+
await caller.removeFiles({ ids: ['invalid-1', 'invalid-2'] });
|
187
|
+
|
188
|
+
expect(ctx.fileService.deleteFiles).not.toHaveBeenCalled();
|
189
|
+
});
|
190
|
+
});
|
191
|
+
|
192
|
+
describe('removeFileAsyncTask', () => {
|
193
|
+
it('should do nothing when file not found', async () => {
|
194
|
+
ctx.fileModel.findById.mockResolvedValue(null);
|
195
|
+
|
196
|
+
await caller.removeFileAsyncTask({ id: 'test-id', type: 'chunk' });
|
197
|
+
|
198
|
+
expect(ctx.asyncTaskModel.delete).not.toHaveBeenCalled();
|
199
|
+
});
|
200
|
+
|
201
|
+
it('should do nothing when task id is missing', async () => {
|
202
|
+
ctx.fileModel.findById.mockResolvedValue(mockFile);
|
203
|
+
|
204
|
+
await caller.removeFileAsyncTask({ id: 'test-id', type: 'embedding' });
|
205
|
+
|
206
|
+
expect(ctx.asyncTaskModel.delete).not.toHaveBeenCalled();
|
207
|
+
|
208
|
+
await caller.removeFileAsyncTask({ id: 'test-id', type: 'chunk' });
|
209
|
+
|
210
|
+
expect(ctx.asyncTaskModel.delete).not.toHaveBeenCalled();
|
211
|
+
});
|
212
|
+
});
|
213
|
+
});
|
@@ -19,7 +19,7 @@ const fileProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
|
|
19
19
|
asyncTaskModel: new AsyncTaskModel(ctx.serverDB, ctx.userId),
|
20
20
|
chunkModel: new ChunkModel(ctx.serverDB, ctx.userId),
|
21
21
|
fileModel: new FileModel(ctx.serverDB, ctx.userId),
|
22
|
-
fileService: new FileService(),
|
22
|
+
fileService: new FileService(ctx.serverDB, ctx.userId),
|
23
23
|
},
|
24
24
|
});
|
25
25
|
});
|
@@ -10,10 +10,12 @@ import { ImportResultData, ImporterEntryData } from '@/types/importer';
|
|
10
10
|
|
11
11
|
const importProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
|
12
12
|
const { ctx } = opts;
|
13
|
-
const dataImporterService = new DataImporterRepos(ctx.serverDB, ctx.userId);
|
14
13
|
|
15
14
|
return opts.next({
|
16
|
-
ctx: {
|
15
|
+
ctx: {
|
16
|
+
dataImporterService: new DataImporterRepos(ctx.serverDB, ctx.userId),
|
17
|
+
fileService: new FileService(ctx.serverDB, ctx.userId),
|
18
|
+
},
|
17
19
|
});
|
18
20
|
});
|
19
21
|
|
@@ -16,7 +16,7 @@ const messageProcedure = authedProcedure.use(serverDatabase).use(async (opts) =>
|
|
16
16
|
|
17
17
|
return opts.next({
|
18
18
|
ctx: {
|
19
|
-
fileService: new FileService(),
|
19
|
+
fileService: new FileService(ctx.serverDB, ctx.userId),
|
20
20
|
messageModel: new MessageModel(ctx.serverDB, ctx.userId),
|
21
21
|
},
|
22
22
|
});
|
@@ -102,7 +102,7 @@ export const messageRouter = router({
|
|
102
102
|
const serverDB = await getServerDB();
|
103
103
|
|
104
104
|
const messageModel = new MessageModel(serverDB, ctx.userId);
|
105
|
-
const fileService = new FileService();
|
105
|
+
const fileService = new FileService(serverDB, ctx.userId);
|
106
106
|
|
107
107
|
return messageModel.query(input, {
|
108
108
|
postProcessUrl: (path) => fileService.getFullFileUrl(path),
|
@@ -40,7 +40,7 @@ const ragEvalProcedure = authedProcedure
|
|
40
40
|
datasetRecordModel: new EvalDatasetRecordModel(ctx.userId),
|
41
41
|
evaluationModel: new EvalEvaluationModel(ctx.userId),
|
42
42
|
evaluationRecordModel: new EvaluationRecordModel(ctx.userId),
|
43
|
-
fileService: new FileService(),
|
43
|
+
fileService: new FileService(ctx.serverDB, ctx.userId),
|
44
44
|
},
|
45
45
|
});
|
46
46
|
});
|
@@ -1,6 +1,6 @@
|
|
1
1
|
import { UserJSON } from '@clerk/backend';
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
2
3
|
import { z } from 'zod';
|
3
|
-
import { v4 as uuidv4 } from 'uuid'; // 需要添加此导入
|
4
4
|
|
5
5
|
import { enableClerk } from '@/const/auth';
|
6
6
|
import { isDesktop } from '@/const/version';
|
@@ -28,7 +28,7 @@ const userProcedure = authedProcedure.use(serverDatabase).use(async ({ ctx, next
|
|
28
28
|
return next({
|
29
29
|
ctx: {
|
30
30
|
clerkAuth: new ClerkAuth(),
|
31
|
-
fileService: new FileService(),
|
31
|
+
fileService: new FileService(ctx.serverDB, ctx.userId),
|
32
32
|
nextAuthDbAdapter: LobeNextAuthDbAdapter(ctx.serverDB),
|
33
33
|
userModel: new UserModel(ctx.serverDB, ctx.userId),
|
34
34
|
},
|
@@ -1,3 +1,12 @@
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
2
|
+
|
3
|
+
import { serverDBEnv } from '@/config/db';
|
4
|
+
import { FileModel } from '@/database/models/file';
|
5
|
+
import { FileItem } from '@/database/schemas';
|
6
|
+
import { LobeChatDatabase } from '@/database/type';
|
7
|
+
import { TempFileManager } from '@/server/utils/tempFileManager';
|
8
|
+
import { nanoid } from '@/utils/uuid';
|
9
|
+
|
1
10
|
import { FileServiceImpl, createFileServiceModule } from './impls';
|
2
11
|
|
3
12
|
/**
|
@@ -5,8 +14,16 @@ import { FileServiceImpl, createFileServiceModule } from './impls';
|
|
5
14
|
* 使用模块化实现方式,提供文件操作服务
|
6
15
|
*/
|
7
16
|
export class FileService {
|
17
|
+
private userId: string;
|
18
|
+
private fileModel: FileModel;
|
19
|
+
|
8
20
|
private impl: FileServiceImpl = createFileServiceModule();
|
9
21
|
|
22
|
+
constructor(db: LobeChatDatabase, userId: string) {
|
23
|
+
this.userId = userId;
|
24
|
+
this.fileModel = new FileModel(db, userId);
|
25
|
+
}
|
26
|
+
|
10
27
|
/**
|
11
28
|
* 删除文件
|
12
29
|
*/
|
@@ -62,4 +79,33 @@ export class FileService {
|
|
62
79
|
public async getFullFileUrl(url?: string | null, expiresIn?: number): Promise<string> {
|
63
80
|
return this.impl.getFullFileUrl(url, expiresIn);
|
64
81
|
}
|
82
|
+
|
83
|
+
async downloadFileToLocal(
|
84
|
+
fileId: string,
|
85
|
+
): Promise<{ cleanup: () => void; file: FileItem; filePath: string }> {
|
86
|
+
const file = await this.fileModel.findById(fileId);
|
87
|
+
if (!file) {
|
88
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: 'File not found' });
|
89
|
+
}
|
90
|
+
|
91
|
+
let content: Uint8Array | undefined;
|
92
|
+
try {
|
93
|
+
content = await this.getFileByteArray(file.url);
|
94
|
+
} catch (e) {
|
95
|
+
console.error(e);
|
96
|
+
// if file not found, delete it from db
|
97
|
+
if ((e as any).Code === 'NoSuchKey') {
|
98
|
+
await this.fileModel.delete(fileId, serverDBEnv.REMOVE_GLOBAL_FILE);
|
99
|
+
throw new TRPCError({ code: 'BAD_REQUEST', message: 'File not found' });
|
100
|
+
}
|
101
|
+
}
|
102
|
+
|
103
|
+
if (!content) throw new TRPCError({ code: 'BAD_REQUEST', message: 'File content is empty' });
|
104
|
+
|
105
|
+
const dir = nanoid();
|
106
|
+
const tempManager = new TempFileManager(dir);
|
107
|
+
|
108
|
+
const filePath = await tempManager.writeTempFile(content, file.name);
|
109
|
+
return { cleanup: () => tempManager.cleanup(), file, filePath };
|
110
|
+
}
|
65
111
|
}
|
@@ -1,7 +1,6 @@
|
|
1
1
|
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
2
2
|
import { tmpdir } from 'node:os';
|
3
3
|
import { join } from 'node:path';
|
4
|
-
import { v4 as uuidv4 } from 'uuid';
|
5
4
|
|
6
5
|
/**
|
7
6
|
* 安全存储临时文件工具类
|
@@ -10,21 +9,19 @@ export class TempFileManager {
|
|
10
9
|
private readonly tempDir: string;
|
11
10
|
private filePaths: Set<string> = new Set();
|
12
11
|
|
13
|
-
constructor() {
|
12
|
+
constructor(dirname: string) {
|
14
13
|
// 创建唯一临时目录 (跨平台安全)
|
15
|
-
this.tempDir = mkdtempSync(join(tmpdir(),
|
14
|
+
this.tempDir = mkdtempSync(join(tmpdir(), dirname));
|
16
15
|
// 注册退出清理钩子
|
17
16
|
this.registerCleanupHook();
|
18
17
|
}
|
19
18
|
|
20
19
|
/**
|
21
20
|
* 将 Uint8Array 写入临时文件
|
22
|
-
|
23
|
-
* @param ext 文件扩展名 (默认 .epub)
|
24
|
-
* @returns 临时文件绝对路径
|
21
|
+
|
25
22
|
*/
|
26
|
-
async writeTempFile(data: Uint8Array,
|
27
|
-
const filePath = join(this.tempDir,
|
23
|
+
async writeTempFile(data: Uint8Array, name: string): Promise<string> {
|
24
|
+
const filePath = join(this.tempDir, name);
|
28
25
|
|
29
26
|
try {
|
30
27
|
writeFileSync(filePath, data);
|