@lobehub/chat 1.138.4 → 1.138.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ### [Version 1.138.5](https://github.com/lobehub/lobe-chat/compare/v1.138.4...v1.138.5)
6
+
7
+ <sup>Released on **2025-10-18**</sup>
8
+
9
+ #### ♻ Code Refactoring
10
+
11
+ - **misc**: Refactor upload router into lambda and decide to remove it in V2.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### Code refactoring
19
+
20
+ - **misc**: Refactor upload router into lambda and decide to remove it in V2, closes [#9766](https://github.com/lobehub/lobe-chat/issues/9766) ([d1c7f41](https://github.com/lobehub/lobe-chat/commit/d1c7f41))
21
+
22
+ </details>
23
+
24
+ <div align="right">
25
+
26
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
27
+
28
+ </div>
29
+
5
30
  ### [Version 1.138.4](https://github.com/lobehub/lobe-chat/compare/v1.138.3...v1.138.4)
6
31
 
7
32
  <sup>Released on **2025-10-18**</sup>
package/changelog/v1.json CHANGED
@@ -1,4 +1,13 @@
1
1
  [
2
+ {
3
+ "children": {
4
+ "improvements": [
5
+ "Refactor upload router into lambda and decide to remove it in V2."
6
+ ]
7
+ },
8
+ "date": "2025-10-18",
9
+ "version": "1.138.5"
10
+ },
2
11
  {
3
12
  "children": {
4
13
  "fixes": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "1.138.4",
3
+ "version": "1.138.5",
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",
@@ -5,8 +5,6 @@ import { pino } from '@/libs/logger';
5
5
  import { createEdgeContext } from '@/libs/trpc/edge/context';
6
6
  import { edgeRouter } from '@/server/routers/edge';
7
7
 
8
- export const runtime = 'edge';
9
-
10
8
  const handler = (req: NextRequest) =>
11
9
  fetchRequestHandler({
12
10
  /**
@@ -1,5 +1,6 @@
1
1
  /**
2
- * This file contains the edge router of Lobe Chat tRPC-backend
2
+ * @deprecated
3
+ * TODO: it will be remove in V2.0
3
4
  */
4
5
  import { publicProcedure, router } from '@/libs/trpc/edge';
5
6
 
@@ -28,6 +28,7 @@ import { sessionRouter } from './session';
28
28
  import { sessionGroupRouter } from './sessionGroup';
29
29
  import { threadRouter } from './thread';
30
30
  import { topicRouter } from './topic';
31
+ import { uploadRouter } from './upload';
31
32
  import { userRouter } from './user';
32
33
 
33
34
  export const lambdaRouter = router({
@@ -57,6 +58,7 @@ export const lambdaRouter = router({
57
58
  sessionGroup: sessionGroupRouter,
58
59
  thread: threadRouter,
59
60
  topic: topicRouter,
61
+ upload: uploadRouter,
60
62
  user: userRouter,
61
63
  });
62
64
 
@@ -0,0 +1,16 @@
1
+ import { z } from 'zod';
2
+
3
+ import { authedProcedure, router } from '@/libs/trpc/lambda';
4
+ import { S3 } from '@/server/modules/S3';
5
+
6
+ export const uploadRouter = router({
7
+ createS3PreSignedUrl: authedProcedure
8
+ .input(z.object({ pathname: z.string() }))
9
+ .mutation(async ({ input }) => {
10
+ const s3 = new S3();
11
+
12
+ return await s3.createPreSignedUrl(input.pathname);
13
+ }),
14
+ });
15
+
16
+ export type FileRouter = typeof uploadRouter;
@@ -1,15 +1,28 @@
1
1
  import { beforeEach, describe, expect, it, vi } from 'vitest';
2
2
 
3
3
  import { fileEnv } from '@/envs/file';
4
- import { edgeClient } from '@/libs/trpc/client';
4
+ import { lambdaClient } from '@/libs/trpc/client';
5
5
  import { API_ENDPOINTS } from '@/services/_url';
6
6
  import { clientS3Storage } from '@/services/file/ClientS3';
7
7
 
8
8
  import { UPLOAD_NETWORK_ERROR, uploadService } from '../upload';
9
9
 
10
10
  // Mock dependencies
11
+ vi.mock('@lobechat/const', () => ({
12
+ isDesktop: false,
13
+ isServerMode: false,
14
+ }));
15
+
16
+ vi.mock('@lobechat/model-runtime', () => ({
17
+ parseDataUri: vi.fn(),
18
+ }));
19
+
20
+ vi.mock('@lobechat/utils', () => ({
21
+ uuid: () => 'mock-uuid',
22
+ }));
23
+
11
24
  vi.mock('@/libs/trpc/client', () => ({
12
- edgeClient: {
25
+ lambdaClient: {
13
26
  upload: {
14
27
  createS3PreSignedUrl: {
15
28
  mutate: vi.fn(),
@@ -24,8 +37,24 @@ vi.mock('@/services/file/ClientS3', () => ({
24
37
  },
25
38
  }));
26
39
 
27
- vi.mock('@/utils/uuid', () => ({
28
- uuid: () => 'mock-uuid',
40
+ vi.mock('@/store/electron', () => ({
41
+ getElectronStoreState: vi.fn(() => ({})),
42
+ }));
43
+
44
+ vi.mock('@/store/electron/selectors', () => ({
45
+ electronSyncSelectors: {
46
+ isSyncActive: vi.fn(() => false),
47
+ },
48
+ }));
49
+
50
+ vi.mock('@/services/electron/file', () => ({
51
+ desktopFileAPI: {
52
+ uploadFile: vi.fn(),
53
+ },
54
+ }));
55
+
56
+ vi.mock('js-sha256', () => ({
57
+ sha256: vi.fn((data) => 'mock-hash-' + data.byteLength),
29
58
  }));
30
59
 
31
60
  describe('UploadService', () => {
@@ -38,23 +67,174 @@ describe('UploadService', () => {
38
67
  vi.spyOn(Date, 'now').mockImplementation(() => 3600000); // 1 hour in milliseconds
39
68
  });
40
69
 
41
- describe('uploadWithProgress', () => {
70
+ describe('uploadFileToS3', () => {
71
+ it('should upload to client S3 for non-server mode with image file', async () => {
72
+ const { sha256 } = await import('js-sha256');
73
+ vi.mocked(sha256).mockReturnValue('test-hash');
74
+ vi.mocked(clientS3Storage.putObject).mockResolvedValue(undefined);
75
+
76
+ const result = await uploadService.uploadFileToS3(mockFile, {});
77
+
78
+ expect(result.success).toBe(true);
79
+ expect(result.data).toEqual({
80
+ date: '1',
81
+ dirname: '',
82
+ filename: mockFile.name,
83
+ path: 'client-s3://test-hash',
84
+ });
85
+ expect(clientS3Storage.putObject).toHaveBeenCalledWith('test-hash', mockFile);
86
+ });
87
+
88
+ it('should call onNotSupported for non-image/video files', async () => {
89
+ const nonImageFile = new File(['test'], 'test.txt', { type: 'text/plain' });
90
+ const onNotSupported = vi.fn();
91
+
92
+ const result = await uploadService.uploadFileToS3(nonImageFile, {
93
+ onNotSupported,
94
+ });
95
+
96
+ expect(result.success).toBe(false);
97
+ expect(onNotSupported).toHaveBeenCalled();
98
+ });
99
+
100
+ it('should skip file type check when skipCheckFileType is true', async () => {
101
+ const nonImageFile = new File(['test'], 'test.txt', { type: 'text/plain' });
102
+ const { sha256 } = await import('js-sha256');
103
+ vi.mocked(sha256).mockReturnValue('test-hash');
104
+ vi.mocked(clientS3Storage.putObject).mockResolvedValue(undefined);
105
+
106
+ const result = await uploadService.uploadFileToS3(nonImageFile, {
107
+ skipCheckFileType: true,
108
+ });
109
+
110
+ expect(result.success).toBe(true);
111
+ expect(clientS3Storage.putObject).toHaveBeenCalled();
112
+ });
113
+
114
+ it('should upload video files', async () => {
115
+ const videoFile = new File(['test'], 'test.mp4', { type: 'video/mp4' });
116
+ const { sha256 } = await import('js-sha256');
117
+ vi.mocked(sha256).mockReturnValue('video-hash');
118
+ vi.mocked(clientS3Storage.putObject).mockResolvedValue(undefined);
119
+
120
+ const result = await uploadService.uploadFileToS3(videoFile, {});
121
+
122
+ expect(result.success).toBe(true);
123
+ expect(clientS3Storage.putObject).toHaveBeenCalledWith('video-hash', videoFile);
124
+ });
125
+ });
126
+
127
+ describe('uploadBase64ToS3', () => {
128
+ it('should upload base64 data successfully', async () => {
129
+ const { parseDataUri } = await import('@lobechat/model-runtime');
130
+ vi.mocked(parseDataUri).mockReturnValueOnce({
131
+ base64: 'dGVzdA==', // "test" in base64
132
+ mimeType: 'image/png',
133
+ type: 'base64',
134
+ });
135
+
136
+ const { sha256 } = await import('js-sha256');
137
+ vi.mocked(sha256).mockReturnValue('base64-hash');
138
+ vi.mocked(clientS3Storage.putObject).mockResolvedValue(undefined);
139
+
140
+ const base64Data = 'data:image/png;base64,dGVzdA==';
141
+ const result = await uploadService.uploadBase64ToS3(base64Data);
142
+
143
+ expect(result).toMatchObject({
144
+ fileType: 'image/png',
145
+ hash: expect.any(String),
146
+ metadata: expect.objectContaining({
147
+ path: expect.stringContaining('client-s3://'),
148
+ }),
149
+ size: expect.any(Number),
150
+ });
151
+ });
152
+
153
+ it('should throw error for invalid base64 data', async () => {
154
+ const { parseDataUri } = await import('@lobechat/model-runtime');
155
+ vi.mocked(parseDataUri).mockReturnValueOnce({
156
+ base64: null,
157
+ mimeType: null,
158
+ type: 'url',
159
+ });
160
+
161
+ const invalidBase64 = 'not-a-base64-string';
162
+
163
+ await expect(uploadService.uploadBase64ToS3(invalidBase64)).rejects.toThrow(
164
+ 'Invalid base64 data for image',
165
+ );
166
+ });
167
+
168
+ it('should use custom filename when provided', async () => {
169
+ const { parseDataUri } = await import('@lobechat/model-runtime');
170
+ vi.mocked(parseDataUri).mockReturnValueOnce({
171
+ base64: 'dGVzdA==',
172
+ mimeType: 'image/png',
173
+ type: 'base64',
174
+ });
175
+
176
+ const { sha256 } = await import('js-sha256');
177
+ vi.mocked(sha256).mockReturnValue('custom-hash');
178
+ vi.mocked(clientS3Storage.putObject).mockResolvedValue(undefined);
179
+
180
+ const base64Data = 'data:image/png;base64,dGVzdA==';
181
+ const result = await uploadService.uploadBase64ToS3(base64Data, {
182
+ filename: 'custom-image',
183
+ });
184
+
185
+ expect(result.metadata.filename).toContain('custom-image');
186
+ });
187
+ });
188
+
189
+ describe('uploadDataToS3', () => {
190
+ it('should upload JSON data successfully', async () => {
191
+ const { sha256 } = await import('js-sha256');
192
+ vi.mocked(sha256).mockReturnValue('json-hash');
193
+ vi.mocked(clientS3Storage.putObject).mockResolvedValue(undefined);
194
+
195
+ const data = { key: 'value', number: 123 };
196
+ // uploadDataToS3 internally calls uploadFileToS3, which needs skipCheckFileType for JSON
197
+ const result = await uploadService.uploadDataToS3(data, {
198
+ skipCheckFileType: true,
199
+ });
200
+
201
+ expect(result.success).toBe(true);
202
+ expect(clientS3Storage.putObject).toHaveBeenCalled();
203
+ });
204
+
205
+ it('should use custom filename when provided', async () => {
206
+ const { sha256 } = await import('js-sha256');
207
+ vi.mocked(sha256).mockReturnValue('custom-json-hash');
208
+ vi.mocked(clientS3Storage.putObject).mockResolvedValue(undefined);
209
+
210
+ const data = { test: true };
211
+ const result = await uploadService.uploadDataToS3(data, {
212
+ filename: 'custom.json',
213
+ skipCheckFileType: true,
214
+ });
215
+
216
+ expect(result.success).toBe(true);
217
+ expect(result.data.filename).toBe('custom.json');
218
+ });
219
+ });
220
+
221
+ describe('uploadToServerS3', () => {
42
222
  beforeEach(() => {
43
223
  // Mock XMLHttpRequest
44
224
  const xhrMock = {
45
- upload: {
46
- addEventListener: vi.fn(),
47
- },
225
+ addEventListener: vi.fn(),
48
226
  open: vi.fn(),
49
227
  send: vi.fn(),
50
228
  setRequestHeader: vi.fn(),
51
- addEventListener: vi.fn(),
52
229
  status: 200,
230
+ upload: {
231
+ addEventListener: vi.fn(),
232
+ },
53
233
  };
54
234
  global.XMLHttpRequest = vi.fn(() => xhrMock) as any;
55
235
 
56
236
  // Mock createS3PreSignedUrl
57
- (edgeClient.upload.createS3PreSignedUrl.mutate as any).mockResolvedValue(mockPreSignUrl);
237
+ vi.mocked(lambdaClient.upload.createS3PreSignedUrl.mutate).mockResolvedValue(mockPreSignUrl);
58
238
  });
59
239
 
60
240
  it('should upload file successfully with progress', async () => {
@@ -64,7 +244,7 @@ describe('UploadService', () => {
64
244
  // Simulate successful upload
65
245
  vi.spyOn(xhr, 'addEventListener').mockImplementation((event, handler) => {
66
246
  if (event === 'load') {
67
- // @ts-ignore
247
+ // @ts-expect-error - mock implementation
68
248
  handler({ target: { status: 200 } });
69
249
  }
70
250
  });
@@ -79,6 +259,41 @@ describe('UploadService', () => {
79
259
  });
80
260
  });
81
261
 
262
+ it('should report progress during upload', async () => {
263
+ const onProgress = vi.fn();
264
+ const xhr = new XMLHttpRequest();
265
+
266
+ // Simulate progress events
267
+ vi.spyOn(xhr.upload, 'addEventListener').mockImplementation((event, handler) => {
268
+ if (event === 'progress') {
269
+ // @ts-expect-error - mock implementation
270
+ handler({
271
+ lengthComputable: true,
272
+ loaded: 500,
273
+ total: 1000,
274
+ });
275
+ }
276
+ });
277
+
278
+ vi.spyOn(xhr, 'addEventListener').mockImplementation((event, handler) => {
279
+ if (event === 'load') {
280
+ // @ts-expect-error - mock implementation
281
+ handler({ target: { status: 200 } });
282
+ }
283
+ });
284
+
285
+ await uploadService.uploadToServerS3(mockFile, { onProgress });
286
+
287
+ expect(onProgress).toHaveBeenCalledWith(
288
+ 'uploading',
289
+ expect.objectContaining({
290
+ progress: expect.any(Number),
291
+ restTime: expect.any(Number),
292
+ speed: expect.any(Number),
293
+ }),
294
+ );
295
+ });
296
+
82
297
  it('should handle network error', async () => {
83
298
  const xhr = new XMLHttpRequest();
84
299
 
@@ -86,7 +301,7 @@ describe('UploadService', () => {
86
301
  vi.spyOn(xhr, 'addEventListener').mockImplementation((event, handler) => {
87
302
  if (event === 'error') {
88
303
  Object.assign(xhr, { status: 0 });
89
- // @ts-ignore
304
+ // @ts-expect-error - mock implementation
90
305
  handler({});
91
306
  }
92
307
  });
@@ -102,13 +317,46 @@ describe('UploadService', () => {
102
317
  if (event === 'load') {
103
318
  Object.assign(xhr, { status: 400, statusText: 'Bad Request' });
104
319
 
105
- // @ts-ignore
320
+ // @ts-expect-error - mock implementation
106
321
  handler({});
107
322
  }
108
323
  });
109
324
 
110
325
  await expect(uploadService.uploadToServerS3(mockFile, {})).rejects.toBe('Bad Request');
111
326
  });
327
+
328
+ it('should use custom directory when provided', async () => {
329
+ const xhr = new XMLHttpRequest();
330
+ vi.spyOn(xhr, 'addEventListener').mockImplementation((event, handler) => {
331
+ if (event === 'load') {
332
+ // @ts-expect-error - mock implementation
333
+ handler({ target: { status: 200 } });
334
+ }
335
+ });
336
+
337
+ const result = await uploadService.uploadToServerS3(mockFile, {
338
+ directory: 'custom/dir',
339
+ });
340
+
341
+ expect(result.dirname).toContain('custom/dir');
342
+ });
343
+
344
+ it('should use custom pathname when provided', async () => {
345
+ const xhr = new XMLHttpRequest();
346
+ vi.spyOn(xhr, 'addEventListener').mockImplementation((event, handler) => {
347
+ if (event === 'load') {
348
+ // @ts-expect-error - mock implementation
349
+ handler({ target: { status: 200 } });
350
+ }
351
+ });
352
+
353
+ const customPath = 'custom/path/file.png';
354
+ const result = await uploadService.uploadToServerS3(mockFile, {
355
+ pathname: customPath,
356
+ });
357
+
358
+ expect(result.path).toBe(customPath);
359
+ });
112
360
  });
113
361
 
114
362
  describe('uploadToClientS3', () => {
@@ -121,7 +369,7 @@ describe('UploadService', () => {
121
369
  path: `client-s3://${hash}`,
122
370
  };
123
371
 
124
- (clientS3Storage.putObject as any).mockResolvedValue(undefined);
372
+ vi.mocked(clientS3Storage.putObject).mockResolvedValue(undefined);
125
373
 
126
374
  const result = await uploadService['uploadToClientS3'](hash, mockFile);
127
375
 
@@ -140,9 +388,9 @@ describe('UploadService', () => {
140
388
  const filename = 'test.png';
141
389
  const mockArrayBuffer = new ArrayBuffer(8);
142
390
 
143
- (global.fetch as any).mockResolvedValue({
391
+ vi.mocked(global.fetch).mockResolvedValue({
144
392
  arrayBuffer: () => Promise.resolve(mockArrayBuffer),
145
- });
393
+ } as Response);
146
394
 
147
395
  const result = await uploadService.getImageFileByUrlWithCORS(url, filename);
148
396
 
@@ -161,9 +409,9 @@ describe('UploadService', () => {
161
409
  const fileType = 'image/jpeg';
162
410
  const mockArrayBuffer = new ArrayBuffer(8);
163
411
 
164
- (global.fetch as any).mockResolvedValue({
412
+ vi.mocked(global.fetch).mockResolvedValue({
165
413
  arrayBuffer: () => Promise.resolve(mockArrayBuffer),
166
- });
414
+ } as Response);
167
415
 
168
416
  const result = await uploadService.getImageFileByUrlWithCORS(url, filename, fileType);
169
417
 
@@ -5,7 +5,7 @@ import dayjs from 'dayjs';
5
5
  import { sha256 } from 'js-sha256';
6
6
 
7
7
  import { fileEnv } from '@/envs/file';
8
- import { edgeClient } from '@/libs/trpc/client';
8
+ import { lambdaClient } from '@/libs/trpc/client';
9
9
  import { API_ENDPOINTS } from '@/services/_url';
10
10
  import { clientS3Storage } from '@/services/file/ClientS3';
11
11
  import { FileMetadata, UploadBase64ToS3Result } from '@/types/files';
@@ -264,7 +264,7 @@ class UploadService {
264
264
  // 生成文件路径元数据
265
265
  const { date, dirname, filename, pathname } = generateFilePathMetadata(file.name, options);
266
266
 
267
- const preSignUrl = await edgeClient.upload.createS3PreSignedUrl.mutate({ pathname });
267
+ const preSignUrl = await lambdaClient.upload.createS3PreSignedUrl.mutate({ pathname });
268
268
 
269
269
  return {
270
270
  date,