@lobehub/lobehub 2.0.0-next.46 → 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 (29) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/changelog/v1.json +9 -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/envs/app.ts +4 -3
  19. package/src/features/Conversation/components/VirtualizedList/index.tsx +2 -1
  20. package/src/features/PluginsUI/Render/MCPType/index.tsx +26 -6
  21. package/src/server/routers/desktop/mcp.ts +23 -8
  22. package/src/server/routers/tools/mcp.ts +24 -4
  23. package/src/server/services/file/impls/local.ts +4 -1
  24. package/src/server/services/file/index.ts +96 -1
  25. package/src/server/services/mcp/contentProcessor.ts +101 -0
  26. package/src/server/services/mcp/index.test.ts +52 -10
  27. package/src/server/services/mcp/index.ts +29 -26
  28. package/src/services/session/index.ts +0 -14
  29. package/src/utils/server/routeVariants.test.ts +340 -0
@@ -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 }> {
@@ -0,0 +1,101 @@
1
+ import debug from 'debug';
2
+ import pMap from 'p-map';
3
+ import urlJoin from 'url-join';
4
+
5
+ import { appEnv } from '@/envs/app';
6
+ import { fileEnv } from '@/envs/file';
7
+ import { AudioContent, ImageContent, ToolCallContent } from '@/libs/mcp';
8
+ import { FileService } from '@/server/services/file';
9
+ import { nanoid } from '@/utils/uuid';
10
+
11
+ const log = debug('lobe-mcp:content-processor');
12
+
13
+ export type ProcessContentBlocksFn = (blocks: ToolCallContent[]) => Promise<ToolCallContent[]>;
14
+
15
+ /**
16
+ * 处理 MCP 返回的 content blocks
17
+ * - 上传图片/音频到存储并替换 data 为代理 URL
18
+ * - 保持其他类型的 block 不变
19
+ */
20
+ export const processContentBlocks = async (
21
+ blocks: ToolCallContent[],
22
+ fileService: FileService,
23
+ ): Promise<ToolCallContent[]> => {
24
+ // Use date-based sharding for privacy compliance (GDPR, CCPA)
25
+ const today = new Date().toISOString().split('T')[0]; // e.g., "2025-11-08"
26
+
27
+ return pMap(blocks, async (block) => {
28
+ if (block.type === 'image') {
29
+ const imageBlock = block as ImageContent;
30
+
31
+ // Extract file extension from mimeType (e.g., "image/png" -> "png")
32
+ const fileExtension = imageBlock.mimeType.split('/')[1] || 'png';
33
+
34
+ // Generate unique pathname with date-based sharding
35
+ const pathname = `${fileEnv.NEXT_PUBLIC_S3_FILE_PATH}/mcp/images/${today}/${nanoid()}.${fileExtension}`;
36
+
37
+ // Upload base64 image and get proxy URL
38
+ const { url } = await fileService.uploadBase64(imageBlock.data, pathname);
39
+
40
+ log(`Image uploaded, proxy URL: ${url}`);
41
+
42
+ return { ...block, data: url };
43
+ }
44
+
45
+ if (block.type === 'audio') {
46
+ const audioBlock = block as AudioContent;
47
+
48
+ // Extract file extension from mimeType (e.g., "audio/mp3" -> "mp3")
49
+ const fileExtension = audioBlock.mimeType.split('/')[1] || 'mp3';
50
+
51
+ // Generate unique pathname with date-based sharding
52
+ const pathname = `${fileEnv.NEXT_PUBLIC_S3_FILE_PATH}/mcp/audio/${today}/${nanoid()}.${fileExtension}`;
53
+
54
+ // Upload base64 audio and get proxy URL
55
+ const { url } = await fileService.uploadBase64(audioBlock.data, pathname);
56
+
57
+ log(`Audio uploaded, proxy URL: ${url}`);
58
+
59
+ return { ...block, data: url };
60
+ }
61
+
62
+ return block;
63
+ });
64
+ };
65
+
66
+ /**
67
+ * 将 content blocks 转换为字符串
68
+ * - text: 提取 text 字段
69
+ * - image/audio: 提取 data 字段(通常是上传后的代理 URL)
70
+ * - 其他: 返回空字符串
71
+ */
72
+ export const contentBlocksToString = (blocks: ToolCallContent[] | null | undefined): string => {
73
+ if (!blocks) return '';
74
+
75
+ return blocks
76
+ .map((item) => {
77
+ switch (item.type) {
78
+ case 'text': {
79
+ return item.text;
80
+ }
81
+
82
+ case 'image': {
83
+ return `![](${urlJoin(appEnv.APP_URL, item.data)})`;
84
+ }
85
+
86
+ case 'audio': {
87
+ return `<resource type="${item.type}" url="${urlJoin(appEnv.APP_URL, item.data)}" />`;
88
+ }
89
+
90
+ case 'resource': {
91
+ return `<resource type="${item.type}">${JSON.stringify(item.resource)}</resource>}`;
92
+ }
93
+
94
+ default: {
95
+ return '';
96
+ }
97
+ }
98
+ })
99
+ .filter(Boolean)
100
+ .join('\n\n');
101
+ };
@@ -39,7 +39,11 @@ describe('MCPService', () => {
39
39
  isError: false,
40
40
  });
41
41
 
42
- const result = await mcpService.callTool(mockParams, 'testTool', '{}');
42
+ const result = await mcpService.callTool({
43
+ clientParams: mockParams,
44
+ toolName: 'testTool',
45
+ argsStr: '{}',
46
+ });
43
47
 
44
48
  expect(result.content).toBe('');
45
49
  expect(result.success).toBe(true);
@@ -52,7 +56,11 @@ describe('MCPService', () => {
52
56
  isError: false,
53
57
  });
54
58
 
55
- const result = await mcpService.callTool(mockParams, 'testTool', '{}');
59
+ const result = await mcpService.callTool({
60
+ clientParams: mockParams,
61
+ toolName: 'testTool',
62
+ argsStr: '{}',
63
+ });
56
64
 
57
65
  expect(result.content).toBe('');
58
66
  expect(result.success).toBe(true);
@@ -65,7 +73,11 @@ describe('MCPService', () => {
65
73
  isError: false,
66
74
  });
67
75
 
68
- const result = await mcpService.callTool(mockParams, 'testTool', '{}');
76
+ const result = await mcpService.callTool({
77
+ clientParams: mockParams,
78
+ toolName: 'testTool',
79
+ argsStr: '{}',
80
+ });
69
81
 
70
82
  expect(result.content).toBe(JSON.stringify(jsonData));
71
83
  expect(result.success).toBe(true);
@@ -78,7 +90,11 @@ describe('MCPService', () => {
78
90
  isError: false,
79
91
  });
80
92
 
81
- const result = await mcpService.callTool(mockParams, 'testTool', '{}');
93
+ const result = await mcpService.callTool({
94
+ clientParams: mockParams,
95
+ toolName: 'testTool',
96
+ argsStr: '{}',
97
+ });
82
98
 
83
99
  expect(result.content).toBe(textData);
84
100
  expect(result.success).toBe(true);
@@ -92,7 +108,11 @@ describe('MCPService', () => {
92
108
  isError: false,
93
109
  });
94
110
 
95
- const result = await mcpService.callTool(mockParams, 'testTool', '{}');
111
+ const result = await mcpService.callTool({
112
+ clientParams: mockParams,
113
+ toolName: 'testTool',
114
+ argsStr: '{}',
115
+ });
96
116
 
97
117
  expect(result.content).toBe('');
98
118
  expect(result.success).toBe(true);
@@ -111,7 +131,11 @@ describe('MCPService', () => {
111
131
  isError: false,
112
132
  });
113
133
 
114
- const result = await mcpService.callTool(mockParams, 'testTool', '{}');
134
+ const result = await mcpService.callTool({
135
+ clientParams: mockParams,
136
+ toolName: 'testTool',
137
+ argsStr: '{}',
138
+ });
115
139
 
116
140
  expect(result.content).toBe('First message\n\nSecond message\n\n{"json": "data"}');
117
141
  expect(result.success).toBe(true);
@@ -129,7 +153,11 @@ describe('MCPService', () => {
129
153
  isError: false,
130
154
  });
131
155
 
132
- const result = await mcpService.callTool(mockParams, 'testTool', '{}');
156
+ const result = await mcpService.callTool({
157
+ clientParams: mockParams,
158
+ toolName: 'testTool',
159
+ argsStr: '{}',
160
+ });
133
161
 
134
162
  expect(result.content).toBe('First message\n\nSecond message');
135
163
  expect(result.success).toBe(true);
@@ -144,7 +172,11 @@ describe('MCPService', () => {
144
172
 
145
173
  mockClient.callTool.mockResolvedValue(errorResult);
146
174
 
147
- const result = await mcpService.callTool(mockParams, 'testTool', '{}');
175
+ const result = await mcpService.callTool({
176
+ clientParams: mockParams,
177
+ toolName: 'testTool',
178
+ argsStr: '{}',
179
+ });
148
180
 
149
181
  expect(result.content).toBe('Error occurred');
150
182
  expect(result.success).toBe(true);
@@ -155,7 +187,13 @@ describe('MCPService', () => {
155
187
  const error = new Error('MCP client error');
156
188
  mockClient.callTool.mockRejectedValue(error);
157
189
 
158
- await expect(mcpService.callTool(mockParams, 'testTool', '{}')).rejects.toThrow(TRPCError);
190
+ await expect(
191
+ mcpService.callTool({
192
+ clientParams: mockParams,
193
+ toolName: 'testTool',
194
+ argsStr: '{}',
195
+ }),
196
+ ).rejects.toThrow(TRPCError);
159
197
  });
160
198
 
161
199
  it('should parse args string correctly', async () => {
@@ -167,7 +205,11 @@ describe('MCPService', () => {
167
205
  isError: false,
168
206
  });
169
207
 
170
- await mcpService.callTool(mockParams, 'testTool', argsString);
208
+ await mcpService.callTool({
209
+ clientParams: mockParams,
210
+ toolName: 'testTool',
211
+ argsStr: argsString,
212
+ });
171
213
 
172
214
  expect(mockClient.callTool).toHaveBeenCalledWith('testTool', argsObject);
173
215
  });
@@ -15,6 +15,7 @@ import {
15
15
  StdioMCPParams,
16
16
  } from '@/libs/mcp';
17
17
 
18
+ import { ProcessContentBlocksFn, contentBlocksToString } from './contentProcessor';
18
19
  import { mcpSystemDepsCheckService } from './deps';
19
20
 
20
21
  const log = debug('lobe-mcp:service');
@@ -154,12 +155,19 @@ export class MCPService {
154
155
  }
155
156
  }
156
157
 
157
- // callTool now accepts MCPClientParams, toolName, and args
158
- async callTool(params: MCPClientParams, toolName: string, argsStr: any): Promise<any> {
159
- const client = await this.getClient(params); // Get client using params
158
+ // callTool now accepts an object with clientParams, toolName, argsStr, and processContentBlocks
159
+ async callTool(options: {
160
+ argsStr: any;
161
+ clientParams: MCPClientParams;
162
+ processContentBlocks?: ProcessContentBlocksFn;
163
+ toolName: string;
164
+ }): Promise<any> {
165
+ const { clientParams, toolName, argsStr, processContentBlocks } = options;
166
+
167
+ const client = await this.getClient(clientParams); // Get client using params
160
168
 
161
169
  const args = safeParseJSON(argsStr);
162
- const loggableParams = this.sanitizeForLogging(params);
170
+ const loggableParams = this.sanitizeForLogging(clientParams);
163
171
 
164
172
  log(
165
173
  `Calling tool "${toolName}" using client for params: %O with args: %O`,
@@ -170,32 +178,27 @@ export class MCPService {
170
178
  try {
171
179
  // Delegate the call to the MCPClient instance
172
180
  const result = await client.callTool(toolName, args); // Pass args directly
181
+
182
+ // Process content blocks (upload images, etc.)
183
+ const newContent =
184
+ result.isError || !processContentBlocks
185
+ ? result.content
186
+ : await processContentBlocks(result.content);
187
+
188
+ // Convert content blocks to string
189
+ const content = contentBlocksToString(newContent);
190
+
191
+ const state = { ...result, content: newContent };
192
+
173
193
  log(
174
194
  `Tool "${toolName}" called successfully for params: %O, result: %O`,
175
195
  loggableParams,
176
- result,
196
+ state,
177
197
  );
178
198
 
179
- // TODO: map more type
180
- const content = result.content
181
- ? result.content
182
- .map((item) => {
183
- switch (item.type) {
184
- case 'text': {
185
- return item.text;
186
- }
187
- default: {
188
- return '';
189
- }
190
- }
191
- })
192
- .filter(Boolean)
193
- .join('\n\n')
194
- : '';
195
-
196
- if (result.isError) return { content, state: result, success: true };
197
-
198
- return { content, state: result, success: true };
199
+ if (result.isError) return { content, state, success: true };
200
+
201
+ return { content, state, success: true };
199
202
  } catch (error) {
200
203
  if (error instanceof McpError) {
201
204
  const mcpError = error as McpError;
@@ -213,7 +216,7 @@ export class MCPService {
213
216
 
214
217
  console.error(
215
218
  `Error calling tool "${toolName}" for params %O:`,
216
- this.sanitizeForLogging(params),
219
+ this.sanitizeForLogging(clientParams),
217
220
  error,
218
221
  );
219
222
  // Propagate a TRPCError
@@ -4,14 +4,12 @@ import type { PartialDeep } from 'type-fest';
4
4
  import { lambdaClient } from '@/libs/trpc/client';
5
5
  import { LobeAgentChatConfig, LobeAgentConfig } from '@/types/agent';
6
6
  import { MetaData } from '@/types/meta';
7
- import { BatchTaskResult } from '@/types/service';
8
7
  import {
9
8
  ChatSessionList,
10
9
  LobeAgentSession,
11
10
  LobeSessionType,
12
11
  LobeSessions,
13
12
  SessionGroupItem,
14
- SessionGroups,
15
13
  SessionRankItem,
16
14
  UpdateSessionParams,
17
15
  } from '@/types/session';
@@ -114,18 +112,6 @@ export class SessionService {
114
112
  return lambdaClient.sessionGroup.createSessionGroup.mutate({ name, sort });
115
113
  };
116
114
 
117
- getSessionGroups = (): Promise<SessionGroupItem[]> => {
118
- return lambdaClient.sessionGroup.getSessionGroup.query();
119
- };
120
-
121
- /**
122
- * 需要废弃
123
- * @deprecated
124
- */
125
- batchCreateSessionGroups = (groups: SessionGroups): Promise<BatchTaskResult> => {
126
- return Promise.resolve({ added: 0, ids: [], skips: [], success: true });
127
- };
128
-
129
115
  removeSessionGroup = (id: string, removeChildren?: boolean) => {
130
116
  return lambdaClient.sessionGroup.removeSessionGroup.mutate({ id, removeChildren });
131
117
  };