@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 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
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#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
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#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.25",
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.39.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.503.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
- onStart: onStartMock,
38
- onText: onTextMock,
39
- onToolsCalling: onToolCallMock,
40
- onCompletion: onCompletionMock,
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
- data: functionCalls.map(
27
- (value, index): StreamToolCallChunkData => ({
28
- function: {
29
- arguments: JSON.stringify(value.args),
30
- name: value.name,
31
- },
32
- id: generateToolCallId(index, value.name),
33
- index: index,
34
- type: 'function',
35
- }),
36
- ),
37
- id: context.id,
38
- type: 'tool_calls',
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 (chunk.candidates) {
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
- { data: candidate.finishReason, id: context?.id, type: 'stop' },
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?: ChatStreamCallbacks,
143
+ { callbacks, inputStartAt }: GoogleAIStreamOptions = {},
123
144
  ) => {
124
145
  const streamStack: StreamContext = { id: 'chat_' + nanoid() };
125
146
 
126
147
  return rawStream
127
- .pipeThrough(createSSEProtocolTransformer(transformGoogleGenerativeAIStream, streamStack))
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
- { streamStack, inputStartAt }: { inputStartAt?: number; streamStack?: StreamContext } = {},
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') outputStartAt = Date.now();
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 outputTokens = chunk.data?.totalOutputTokens || chunk.data?.outputTextTokens;
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
- onStart: onStartMock,
107
- onText: onTextMock,
108
- onToolsCalling: onToolCallMock,
109
- onCompletion: onCompletionMock,
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
- onStart: onStartMock,
208
- onText: onTextMock,
209
- onToolsCalling: onToolCallMock,
210
- onCompletion: onCompletionMock,
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 { ChatStreamCallbacks } from '../../types';
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
- stack: StreamContext,
17
- ): StreamProtocolChunk => {
18
+ context: StreamContext,
19
+ ): StreamProtocolChunk | StreamProtocolChunk[] => {
18
20
  // maybe need another structure to add support for multiple choices
19
- const candidates = chunk.candidates;
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: stack?.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
- data: [
37
- {
38
- function: {
39
- arguments: JSON.stringify(functionCall.args),
40
- name: functionCall.name,
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
- id: generateToolCallId(0, functionCall.name),
43
- index: 0,
44
- type: 'function',
45
- },
46
- ],
47
- id: stack?.id,
48
- type: 'tool_calls',
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: stack?.id,
98
+ id: context?.id,
55
99
  type: 'text',
56
100
  };
57
101
  }
58
102
 
59
103
  return {
60
104
  data: '',
61
- id: stack?.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?: ChatStreamCallbacks,
112
+ { callbacks, inputStartAt }: GoogleAIStreamOptions = {},
69
113
  ) => {
70
114
  const streamStack: StreamContext = { id: 'chat_' + nanoid() };
71
115
 
72
116
  return rawStream
73
- .pipeThrough(createSSEProtocolTransformer(transformVertexAIStream, streamStack))
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: { dataImporterService, fileService: new FileService() },
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(), 'epub-'));
14
+ this.tempDir = mkdtempSync(join(tmpdir(), dirname));
16
15
  // 注册退出清理钩子
17
16
  this.registerCleanupHook();
18
17
  }
19
18
 
20
19
  /**
21
20
  * 将 Uint8Array 写入临时文件
22
- * @param data 文件数据
23
- * @param ext 文件扩展名 (默认 .epub)
24
- * @returns 临时文件绝对路径
21
+
25
22
  */
26
- async writeTempFile(data: Uint8Array, ext = '.epub'): Promise<string> {
27
- const filePath = join(this.tempDir, `${uuidv4()}${ext}`);
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);