@lobehub/lobehub 2.0.0-next.45 → 2.0.0-next.47

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.
Files changed (44) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/changelog/v1.json +18 -0
  3. package/package.json +1 -1
  4. package/packages/database/src/models/file.ts +15 -1
  5. package/packages/database/src/repositories/aiInfra/index.test.ts +1 -1
  6. package/packages/database/src/repositories/dataExporter/index.test.ts +1 -1
  7. package/packages/database/src/repositories/tableViewer/index.test.ts +1 -1
  8. package/packages/types/src/aiProvider.ts +1 -1
  9. package/packages/types/src/document/index.ts +38 -38
  10. package/packages/types/src/exportConfig.ts +15 -15
  11. package/packages/types/src/generation/index.ts +5 -5
  12. package/packages/types/src/openai/chat.ts +15 -15
  13. package/packages/types/src/plugins/mcp.ts +29 -29
  14. package/packages/types/src/plugins/protocol.ts +43 -43
  15. package/packages/types/src/search.ts +4 -4
  16. package/packages/types/src/tool/plugin.ts +3 -3
  17. package/src/app/(backend)/f/[id]/route.ts +55 -0
  18. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/ChatItem/Thread.tsx +1 -1
  19. package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/ChatItem/index.tsx +9 -16
  20. package/src/envs/app.ts +4 -3
  21. package/src/features/Conversation/Messages/Assistant/Actions/index.tsx +3 -5
  22. package/src/features/Conversation/Messages/Assistant/Extra/index.test.tsx +3 -3
  23. package/src/features/Conversation/Messages/Assistant/Extra/index.tsx +8 -5
  24. package/src/features/Conversation/Messages/Assistant/index.tsx +29 -15
  25. package/src/features/Conversation/Messages/Group/Actions/WithContentId.tsx +3 -5
  26. package/src/features/Conversation/Messages/Group/index.tsx +12 -20
  27. package/src/features/Conversation/Messages/Supervisor/index.tsx +14 -5
  28. package/src/features/Conversation/Messages/User/index.tsx +14 -8
  29. package/src/features/Conversation/Messages/index.tsx +16 -26
  30. package/src/features/Conversation/components/Extras/Usage/UsageDetail/index.tsx +7 -6
  31. package/src/features/Conversation/components/Extras/Usage/UsageDetail/tokens.ts +2 -5
  32. package/src/features/Conversation/components/Extras/Usage/index.tsx +13 -6
  33. package/src/features/Conversation/components/VirtualizedList/index.tsx +2 -1
  34. package/src/features/PluginsUI/Render/MCPType/index.tsx +26 -6
  35. package/src/server/modules/ContentChunk/index.test.ts +372 -0
  36. package/src/server/routers/desktop/mcp.ts +23 -8
  37. package/src/server/routers/tools/mcp.ts +24 -4
  38. package/src/server/services/file/impls/local.ts +4 -1
  39. package/src/server/services/file/index.ts +96 -1
  40. package/src/server/services/mcp/contentProcessor.ts +101 -0
  41. package/src/server/services/mcp/index.test.ts +52 -10
  42. package/src/server/services/mcp/index.ts +29 -26
  43. package/src/services/session/index.ts +0 -14
  44. package/src/utils/server/routeVariants.test.ts +340 -0
@@ -0,0 +1,372 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import type { NewChunkItem, NewUnstructuredChunkItem } from '@/database/schemas';
4
+ import { knowledgeEnv } from '@/envs/knowledge';
5
+ import { ChunkingLoader } from '@/libs/langchain';
6
+ import { ChunkingStrategy, Unstructured } from '@/libs/unstructured';
7
+
8
+ import { ContentChunk } from './index';
9
+
10
+ // Mock the dependencies
11
+ vi.mock('@/libs/unstructured');
12
+ vi.mock('@/libs/langchain');
13
+ vi.mock('@/envs/knowledge', () => ({
14
+ knowledgeEnv: {
15
+ FILE_TYPE_CHUNKING_RULES: '',
16
+ UNSTRUCTURED_API_KEY: 'test-api-key',
17
+ UNSTRUCTURED_SERVER_URL: 'https://test.unstructured.io',
18
+ },
19
+ }));
20
+
21
+ describe('ContentChunk', () => {
22
+ let contentChunk: ContentChunk;
23
+ let mockUnstructuredPartition: ReturnType<typeof vi.fn>;
24
+ let mockLangChainPartition: ReturnType<typeof vi.fn>;
25
+
26
+ beforeEach(() => {
27
+ vi.clearAllMocks();
28
+
29
+ // Setup Unstructured mock
30
+ mockUnstructuredPartition = vi.fn();
31
+ (Unstructured as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
32
+ partition: mockUnstructuredPartition,
33
+ }));
34
+
35
+ // Setup LangChain mock
36
+ mockLangChainPartition = vi.fn();
37
+ (ChunkingLoader as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
38
+ partitionContent: mockLangChainPartition,
39
+ }));
40
+
41
+ contentChunk = new ContentChunk();
42
+ });
43
+
44
+ describe('constructor', () => {
45
+ it('should initialize with Unstructured and LangChain clients', () => {
46
+ expect(Unstructured).toHaveBeenCalledTimes(1);
47
+ expect(ChunkingLoader).toHaveBeenCalledTimes(1);
48
+ });
49
+ });
50
+
51
+ describe('chunkContent', () => {
52
+ const mockFileContent = new Uint8Array([1, 2, 3, 4, 5]);
53
+ const mockFilename = 'test-document.pdf';
54
+
55
+ it('should use default langchain service when no rules are configured', async () => {
56
+ const mockLangChainResult = [
57
+ {
58
+ id: 'chunk-1',
59
+ metadata: { source: 'test' },
60
+ pageContent: 'Test content chunk 1',
61
+ },
62
+ {
63
+ id: 'chunk-2',
64
+ metadata: { source: 'test' },
65
+ pageContent: 'Test content chunk 2',
66
+ },
67
+ ];
68
+
69
+ mockLangChainPartition.mockResolvedValue(mockLangChainResult);
70
+
71
+ const result = await contentChunk.chunkContent({
72
+ content: mockFileContent,
73
+ fileType: 'application/pdf',
74
+ filename: mockFilename,
75
+ });
76
+
77
+ expect(mockLangChainPartition).toHaveBeenCalledWith(mockFilename, mockFileContent);
78
+ expect(result.chunks).toHaveLength(2);
79
+ expect(result.chunks[0]).toMatchObject({
80
+ id: 'chunk-1',
81
+ index: 0,
82
+ metadata: { source: 'test' },
83
+ text: 'Test content chunk 1',
84
+ type: 'LangChainElement',
85
+ });
86
+ expect(result.unstructuredChunks).toBeUndefined();
87
+ });
88
+
89
+ it('should use langchain when unstructured is not configured', async () => {
90
+ // Temporarily mock env to disable unstructured
91
+ vi.mocked(knowledgeEnv).UNSTRUCTURED_API_KEY = '';
92
+
93
+ const mockLangChainResult = [
94
+ {
95
+ id: 'chunk-1',
96
+ metadata: { source: 'test' },
97
+ pageContent: 'LangChain content',
98
+ },
99
+ ];
100
+
101
+ mockLangChainPartition.mockResolvedValue(mockLangChainResult);
102
+
103
+ const result = await contentChunk.chunkContent({
104
+ content: mockFileContent,
105
+ fileType: 'application/pdf',
106
+ filename: mockFilename,
107
+ });
108
+
109
+ expect(mockLangChainPartition).toHaveBeenCalledWith(mockFilename, mockFileContent);
110
+ expect(result.chunks).toHaveLength(1);
111
+ expect(result.chunks[0].text).toBe('LangChain content');
112
+ expect(result.unstructuredChunks).toBeUndefined();
113
+
114
+ // Restore mock
115
+ vi.mocked(knowledgeEnv).UNSTRUCTURED_API_KEY = 'test-api-key';
116
+ });
117
+
118
+ it('should handle langchain results with metadata', async () => {
119
+ const mockLangChainResult = [
120
+ {
121
+ id: 'chunk-1',
122
+ metadata: {
123
+ source: 'test-document.pdf',
124
+ page: 1,
125
+ loc: { lines: { from: 1, to: 10 } },
126
+ },
127
+ pageContent: 'First paragraph content',
128
+ },
129
+ {
130
+ id: 'chunk-2',
131
+ metadata: {
132
+ source: 'test-document.pdf',
133
+ page: 2,
134
+ },
135
+ pageContent: 'Second paragraph content',
136
+ },
137
+ ];
138
+
139
+ mockLangChainPartition.mockResolvedValue(mockLangChainResult);
140
+
141
+ const result = await contentChunk.chunkContent({
142
+ content: mockFileContent,
143
+ fileType: 'application/pdf',
144
+ filename: mockFilename,
145
+ });
146
+
147
+ expect(result.chunks).toHaveLength(2);
148
+ expect(result.chunks[0]).toMatchObject({
149
+ id: 'chunk-1',
150
+ index: 0,
151
+ metadata: {
152
+ source: 'test-document.pdf',
153
+ page: 1,
154
+ loc: { lines: { from: 1, to: 10 } },
155
+ },
156
+ text: 'First paragraph content',
157
+ type: 'LangChainElement',
158
+ });
159
+ expect(result.chunks[1]).toMatchObject({
160
+ id: 'chunk-2',
161
+ index: 1,
162
+ text: 'Second paragraph content',
163
+ type: 'LangChainElement',
164
+ });
165
+ });
166
+
167
+ it('should handle different file types', async () => {
168
+ const mockLangChainResult = [
169
+ {
170
+ id: 'docx-chunk-1',
171
+ metadata: { source: 'test.docx' },
172
+ pageContent: 'Word document content',
173
+ },
174
+ ];
175
+
176
+ mockLangChainPartition.mockResolvedValue(mockLangChainResult);
177
+
178
+ const result = await contentChunk.chunkContent({
179
+ content: mockFileContent,
180
+ fileType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
181
+ filename: 'test.docx',
182
+ });
183
+
184
+ expect(mockLangChainPartition).toHaveBeenCalledWith('test.docx', mockFileContent);
185
+ expect(result.chunks).toHaveLength(1);
186
+ expect(result.chunks[0].text).toBe('Word document content');
187
+ });
188
+
189
+ it('should throw error when all services fail and its the last service', async () => {
190
+ mockLangChainPartition.mockRejectedValue(new Error('LangChain error'));
191
+
192
+ await expect(
193
+ contentChunk.chunkContent({
194
+ content: mockFileContent,
195
+ fileType: 'application/pdf',
196
+ filename: mockFilename,
197
+ }),
198
+ ).rejects.toThrow('LangChain error');
199
+ });
200
+
201
+ it('should handle empty langchain results', async () => {
202
+ mockLangChainPartition.mockResolvedValue([]);
203
+
204
+ const result = await contentChunk.chunkContent({
205
+ content: mockFileContent,
206
+ fileType: 'application/pdf',
207
+ filename: mockFilename,
208
+ });
209
+
210
+ expect(result.chunks).toHaveLength(0);
211
+ expect(result.unstructuredChunks).toBeUndefined();
212
+ });
213
+
214
+ it('should extract file extension correctly from MIME type', async () => {
215
+ const mockLangChainResult = [
216
+ {
217
+ id: 'chunk-1',
218
+ metadata: {},
219
+ pageContent: 'Content',
220
+ },
221
+ ];
222
+
223
+ mockLangChainPartition.mockResolvedValue(mockLangChainResult);
224
+
225
+ await contentChunk.chunkContent({
226
+ content: mockFileContent,
227
+ fileType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
228
+ filename: 'test.docx',
229
+ });
230
+
231
+ expect(mockLangChainPartition).toHaveBeenCalledWith('test.docx', mockFileContent);
232
+ });
233
+
234
+ it('should handle langchain results with minimal metadata', async () => {
235
+ const mockLangChainResult = [
236
+ {
237
+ id: 'chunk-minimal',
238
+ metadata: {},
239
+ pageContent: 'Content with no metadata',
240
+ },
241
+ ];
242
+
243
+ mockLangChainPartition.mockResolvedValue(mockLangChainResult);
244
+
245
+ const result = await contentChunk.chunkContent({
246
+ content: mockFileContent,
247
+ fileType: 'text/plain',
248
+ filename: 'test.txt',
249
+ });
250
+
251
+ expect(result.chunks[0]).toMatchObject({
252
+ id: 'chunk-minimal',
253
+ index: 0,
254
+ metadata: {},
255
+ text: 'Content with no metadata',
256
+ type: 'LangChainElement',
257
+ });
258
+ });
259
+ });
260
+
261
+ describe('canUseUnstructured', () => {
262
+ it('should return true when API key and server URL are configured', () => {
263
+ const result = contentChunk['canUseUnstructured']();
264
+ expect(result).toBe(true);
265
+ });
266
+
267
+ it('should return false when API key is missing', () => {
268
+ const originalKey = knowledgeEnv.UNSTRUCTURED_API_KEY;
269
+ vi.mocked(knowledgeEnv).UNSTRUCTURED_API_KEY = '';
270
+
271
+ const result = contentChunk['canUseUnstructured']();
272
+ expect(result).toBe(false);
273
+
274
+ // Restore
275
+ vi.mocked(knowledgeEnv).UNSTRUCTURED_API_KEY = originalKey;
276
+ });
277
+
278
+ it('should return false when server URL is missing', () => {
279
+ const originalUrl = knowledgeEnv.UNSTRUCTURED_SERVER_URL;
280
+ vi.mocked(knowledgeEnv).UNSTRUCTURED_SERVER_URL = '';
281
+
282
+ const result = contentChunk['canUseUnstructured']();
283
+ expect(result).toBe(false);
284
+
285
+ // Restore
286
+ vi.mocked(knowledgeEnv).UNSTRUCTURED_SERVER_URL = originalUrl;
287
+ });
288
+ });
289
+
290
+ describe('getChunkingServices', () => {
291
+ it('should return default service for unknown file type', () => {
292
+ const services = contentChunk['getChunkingServices']('application/unknown');
293
+ expect(services).toEqual(['default']);
294
+ });
295
+
296
+ it('should extract extension from MIME type correctly', () => {
297
+ const services = contentChunk['getChunkingServices']('application/pdf');
298
+ expect(services).toEqual(['default']);
299
+ });
300
+
301
+ it('should handle MIME types with multiple slashes', () => {
302
+ const services = contentChunk['getChunkingServices'](
303
+ 'application/vnd.openxmlformats-officedocument/wordprocessingml.document',
304
+ );
305
+ expect(services).toEqual(['default']);
306
+ });
307
+
308
+ it('should convert extension to lowercase', () => {
309
+ const services = contentChunk['getChunkingServices']('application/PDF');
310
+ expect(services).toEqual(['default']);
311
+ });
312
+ });
313
+
314
+ describe('integration scenarios', () => {
315
+ it('should handle multiple chunk items with correct indices', async () => {
316
+ const mockLangChainResult = Array.from({ length: 5 }, (_, i) => ({
317
+ id: `chunk-${i}`,
318
+ metadata: { index: i },
319
+ pageContent: `Content ${i}`,
320
+ }));
321
+
322
+ mockLangChainPartition.mockResolvedValue(mockLangChainResult);
323
+
324
+ const result = await contentChunk.chunkContent({
325
+ content: new Uint8Array([1, 2, 3]),
326
+ fileType: 'text/plain',
327
+ filename: 'test.txt',
328
+ });
329
+
330
+ expect(result.chunks).toHaveLength(5);
331
+ result.chunks.forEach((chunk, index) => {
332
+ expect(chunk.index).toBe(index);
333
+ expect(chunk.text).toBe(`Content ${index}`);
334
+ });
335
+ });
336
+
337
+ it('should preserve order of chunks from langchain response', async () => {
338
+ const mockLangChainResult = [
339
+ {
340
+ id: 'elem-3',
341
+ metadata: { source: 'test.txt' },
342
+ pageContent: 'Third',
343
+ },
344
+ {
345
+ id: 'elem-1',
346
+ metadata: { source: 'test.txt' },
347
+ pageContent: 'First',
348
+ },
349
+ {
350
+ id: 'elem-2',
351
+ metadata: { source: 'test.txt' },
352
+ pageContent: 'Second',
353
+ },
354
+ ];
355
+
356
+ mockLangChainPartition.mockResolvedValue(mockLangChainResult);
357
+
358
+ const result = await contentChunk.chunkContent({
359
+ content: new Uint8Array([1, 2, 3]),
360
+ fileType: 'text/plain',
361
+ filename: 'test.txt',
362
+ });
363
+
364
+ expect(result.chunks[0].text).toBe('Third');
365
+ expect(result.chunks[1].text).toBe('First');
366
+ expect(result.chunks[2].text).toBe('Second');
367
+ expect(result.chunks[0].index).toBe(0);
368
+ expect(result.chunks[1].index).toBe(1);
369
+ expect(result.chunks[2].index).toBe(2);
370
+ });
371
+ });
372
+ });
@@ -2,8 +2,12 @@ import { GetStreamableMcpServerManifestInputSchema } from '@lobechat/types';
2
2
  import debug from 'debug';
3
3
  import { z } from 'zod';
4
4
 
5
+ import { ToolCallContent } from '@/libs/mcp';
5
6
  import { authedProcedure, router } from '@/libs/trpc/lambda';
7
+ import { serverDatabase } from '@/libs/trpc/lambda/middleware';
8
+ import { FileService } from '@/server/services/file';
6
9
  import { mcpService } from '@/server/services/mcp';
10
+ import { processContentBlocks } from '@/server/services/mcp/contentProcessor';
7
11
 
8
12
  const log = debug('lobe-mcp:router');
9
13
 
@@ -22,7 +26,13 @@ const stdioParamsSchema = z.object({
22
26
  type: z.literal('stdio').default('stdio'),
23
27
  });
24
28
 
25
- const mcpProcedure = authedProcedure;
29
+ const mcpProcedure = authedProcedure.use(serverDatabase).use(async ({ ctx, next }) => {
30
+ return next({
31
+ ctx: {
32
+ fileService: new FileService(ctx.serverDB, ctx.userId),
33
+ },
34
+ });
35
+ });
26
36
 
27
37
  export const mcpRouter = router({
28
38
  getStdioMcpServerManifest: mcpProcedure.input(stdioParamsSchema).query(async ({ input }) => {
@@ -85,13 +95,18 @@ export const mcpRouter = router({
85
95
  toolName: z.string(),
86
96
  }),
87
97
  )
88
- .mutation(async ({ input }) => {
89
- // Pass the validated params, toolName, and args to the service
90
- return await mcpService.callTool(
91
- { ...input.params, env: input.env },
92
- input.toolName,
93
- input.args,
94
- );
98
+ .mutation(async ({ input, ctx }) => {
99
+ // Create a closure that binds fileService and userId to processContentBlocks
100
+ const boundProcessContentBlocks = async (blocks: ToolCallContent[]) =>
101
+ processContentBlocks(blocks, ctx.fileService);
102
+
103
+ // Pass the validated params, toolName, args, and bound processContentBlocks to the service
104
+ return await mcpService.callTool({
105
+ clientParams: { ...input.params, env: input.env },
106
+ toolName: input.toolName,
107
+ argsStr: input.args,
108
+ processContentBlocks: boundProcessContentBlocks,
109
+ });
95
110
  }),
96
111
 
97
112
  validMcpServerInstallable: mcpProcedure
@@ -6,8 +6,12 @@ import {
6
6
  import { TRPCError } from '@trpc/server';
7
7
  import { z } from 'zod';
8
8
 
9
+ import { ToolCallContent } from '@/libs/mcp';
9
10
  import { authedProcedure, router } from '@/libs/trpc/lambda';
11
+ import { serverDatabase } from '@/libs/trpc/lambda/middleware';
12
+ import { FileService } from '@/server/services/file';
10
13
  import { mcpService } from '@/server/services/mcp';
14
+ import { processContentBlocks } from '@/server/services/mcp/contentProcessor';
11
15
 
12
16
  // Define Zod schemas for MCP Client parameters
13
17
  const httpParamsSchema = z.object({
@@ -37,7 +41,13 @@ const checkStdioEnvironment = (params: z.infer<typeof mcpClientParamsSchema>) =>
37
41
  }
38
42
  };
39
43
 
40
- const mcpProcedure = authedProcedure;
44
+ const mcpProcedure = authedProcedure.use(serverDatabase).use(async ({ ctx, next }) => {
45
+ return next({
46
+ ctx: {
47
+ fileService: new FileService(ctx.serverDB, ctx.userId),
48
+ },
49
+ });
50
+ });
41
51
 
42
52
  export const mcpRouter = router({
43
53
  getStreamableMcpServerManifest: mcpProcedure
@@ -95,11 +105,21 @@ export const mcpRouter = router({
95
105
  toolName: z.string(),
96
106
  }),
97
107
  )
98
- .mutation(async ({ input }) => {
108
+ .mutation(async ({ input, ctx }) => {
99
109
  // Stdio check can be done here or rely on the service/client layer
100
110
  checkStdioEnvironment(input.params);
101
111
 
102
- // Pass the validated params, toolName, and args to the service
103
- return await mcpService.callTool(input.params, input.toolName, input.args);
112
+ // Create a closure that binds fileService and userId to processContentBlocks
113
+ const boundProcessContentBlocks = async (blocks: ToolCallContent[]) => {
114
+ return processContentBlocks(blocks, ctx.fileService);
115
+ };
116
+
117
+ // Pass the validated params, toolName, args, and bound processContentBlocks to the service
118
+ return await mcpService.callTool({
119
+ clientParams: input.params,
120
+ toolName: input.toolName,
121
+ argsStr: input.args,
122
+ processContentBlocks: boundProcessContentBlocks,
123
+ });
104
124
  }),
105
125
  });
@@ -1,3 +1,4 @@
1
+ import debug from 'debug';
1
2
  import { sha256 } from 'js-sha256';
2
3
  import { existsSync, readFileSync } from 'node:fs';
3
4
  import path from 'node:path';
@@ -8,6 +9,8 @@ import { inferContentTypeFromImageUrl } from '@/utils/url';
8
9
  import { FileServiceImpl } from './type';
9
10
  import { extractKeyFromUrlOrReturnOriginal } from './utils';
10
11
 
12
+ const log = debug('lobe-file:desktop-local');
13
+
11
14
  /**
12
15
  * 桌面应用本地文件服务实现
13
16
  */
@@ -202,7 +205,7 @@ export class DesktopLocalFileImpl implements FileServiceImpl {
202
205
  throw new Error('Failed to upload file via Electron IPC');
203
206
  }
204
207
 
205
- console.log('[DesktopLocalFileImpl] File uploaded successfully:', result.metadata);
208
+ log('File uploaded successfully: %O', result.metadata);
206
209
  return { key: result.metadata.path };
207
210
  } catch (error) {
208
211
  console.error('[DesktopLocalFileImpl] Failed to upload media file:', error);
@@ -1,11 +1,12 @@
1
1
  import { LobeChatDatabase } from '@lobechat/database';
2
+ import { inferContentTypeFromImageUrl, nanoid, uuid } from '@lobechat/utils';
2
3
  import { TRPCError } from '@trpc/server';
4
+ import { sha256 } from 'js-sha256';
3
5
 
4
6
  import { serverDBEnv } from '@/config/db';
5
7
  import { FileModel } from '@/database/models/file';
6
8
  import { FileItem } from '@/database/schemas';
7
9
  import { TempFileManager } from '@/server/utils/tempFileManager';
8
- import { nanoid } from '@/utils/uuid';
9
10
 
10
11
  import { FileServiceImpl, createFileServiceModule } from './impls';
11
12
 
@@ -94,6 +95,100 @@ export class FileService {
94
95
  return this.impl.uploadMedia(key, buffer);
95
96
  }
96
97
 
98
+ /**
99
+ * Create file record (common method)
100
+ * Automatically handles globalFiles deduplication logic
101
+ *
102
+ * @param params - File parameters
103
+ * @param params.id - Optional custom file ID (defaults to auto-generated)
104
+ * @returns File record and proxy URL
105
+ */
106
+ public async createFileRecord(params: {
107
+ fileHash: string;
108
+ fileType: string;
109
+ id?: string;
110
+ name: string;
111
+ size: number;
112
+ url: string;
113
+ }): Promise<{ fileId: string; url: string }> {
114
+ // Check if hash already exists in globalFiles
115
+ const { isExist } = await this.fileModel.checkHash(params.fileHash);
116
+
117
+ // Create database record
118
+ // If hash doesn't exist, also create globalFiles record
119
+ const { id } = await this.fileModel.create(
120
+ {
121
+ fileHash: params.fileHash,
122
+ fileType: params.fileType,
123
+ id: params.id, // Use custom ID if provided
124
+ name: params.name,
125
+ size: params.size,
126
+ url: params.url,
127
+ },
128
+ !isExist, // insertToGlobalFiles
129
+ );
130
+
131
+ // Return unified proxy URL: /f/:id
132
+ return {
133
+ fileId: id,
134
+ url: `/f/${id}`,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Upload base64 data and create database record
140
+ * @param base64Data - Base64 data (supports data URI format or pure base64)
141
+ * @param pathname - File storage path (must include file extension)
142
+ * @returns Contains key (storage path), fileId (database record ID) and url (proxy access path)
143
+ */
144
+ public async uploadBase64(
145
+ base64Data: string,
146
+ pathname: string,
147
+ ): Promise<{ fileId: string; key: string; url: string }> {
148
+ let base64String: string;
149
+
150
+ // If data URI format (data:image/png;base64,xxx)
151
+ if (base64Data.startsWith('data:')) {
152
+ const commaIndex = base64Data.indexOf(',');
153
+ if (commaIndex === -1) {
154
+ throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid base64 data format' });
155
+ }
156
+ base64String = base64Data.slice(commaIndex + 1);
157
+ } else {
158
+ // Pure base64 string
159
+ base64String = base64Data;
160
+ }
161
+
162
+ // Convert to Buffer
163
+ const buffer = Buffer.from(base64String, 'base64');
164
+
165
+ // Upload to storage (S3 or local)
166
+ const { key } = await this.uploadMedia(pathname, buffer);
167
+
168
+ // Extract filename from pathname
169
+ const name = pathname.split('/').pop() || 'unknown';
170
+
171
+ // Calculate file metadata
172
+ const size = buffer.length;
173
+ const fileType = inferContentTypeFromImageUrl(pathname) || 'application/octet-stream';
174
+ const hash = sha256(buffer);
175
+
176
+ // Generate UUID for cleaner URLs
177
+ const fileId = uuid();
178
+
179
+ // Use common method to create file record
180
+ const { fileId: createdId, url } = await this.createFileRecord({
181
+ fileHash: hash,
182
+ fileType,
183
+ id: fileId, // Use UUID instead of auto-generated ID
184
+ name,
185
+ size,
186
+ url: key, // Store original key (S3 key or desktop://)
187
+ });
188
+
189
+ return { fileId: createdId, key, url };
190
+ }
191
+
97
192
  async downloadFileToLocal(
98
193
  fileId: string,
99
194
  ): Promise<{ cleanup: () => void; file: FileItem; filePath: string }> {