@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.
- package/CHANGELOG.md +50 -0
- package/changelog/v1.json +18 -0
- package/package.json +1 -1
- package/packages/database/src/models/file.ts +15 -1
- package/packages/database/src/repositories/aiInfra/index.test.ts +1 -1
- package/packages/database/src/repositories/dataExporter/index.test.ts +1 -1
- package/packages/database/src/repositories/tableViewer/index.test.ts +1 -1
- package/packages/types/src/aiProvider.ts +1 -1
- package/packages/types/src/document/index.ts +38 -38
- package/packages/types/src/exportConfig.ts +15 -15
- package/packages/types/src/generation/index.ts +5 -5
- package/packages/types/src/openai/chat.ts +15 -15
- package/packages/types/src/plugins/mcp.ts +29 -29
- package/packages/types/src/plugins/protocol.ts +43 -43
- package/packages/types/src/search.ts +4 -4
- package/packages/types/src/tool/plugin.ts +3 -3
- package/src/app/(backend)/f/[id]/route.ts +55 -0
- package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/ChatItem/Thread.tsx +1 -1
- package/src/app/[variants]/(main)/chat/components/conversation/features/ChatList/ChatItem/index.tsx +9 -16
- package/src/envs/app.ts +4 -3
- package/src/features/Conversation/Messages/Assistant/Actions/index.tsx +3 -5
- package/src/features/Conversation/Messages/Assistant/Extra/index.test.tsx +3 -3
- package/src/features/Conversation/Messages/Assistant/Extra/index.tsx +8 -5
- package/src/features/Conversation/Messages/Assistant/index.tsx +29 -15
- package/src/features/Conversation/Messages/Group/Actions/WithContentId.tsx +3 -5
- package/src/features/Conversation/Messages/Group/index.tsx +12 -20
- package/src/features/Conversation/Messages/Supervisor/index.tsx +14 -5
- package/src/features/Conversation/Messages/User/index.tsx +14 -8
- package/src/features/Conversation/Messages/index.tsx +16 -26
- package/src/features/Conversation/components/Extras/Usage/UsageDetail/index.tsx +7 -6
- package/src/features/Conversation/components/Extras/Usage/UsageDetail/tokens.ts +2 -5
- package/src/features/Conversation/components/Extras/Usage/index.tsx +13 -6
- package/src/features/Conversation/components/VirtualizedList/index.tsx +2 -1
- package/src/features/PluginsUI/Render/MCPType/index.tsx +26 -6
- package/src/server/modules/ContentChunk/index.test.ts +372 -0
- package/src/server/routers/desktop/mcp.ts +23 -8
- package/src/server/routers/tools/mcp.ts +24 -4
- package/src/server/services/file/impls/local.ts +4 -1
- package/src/server/services/file/index.ts +96 -1
- package/src/server/services/mcp/contentProcessor.ts +101 -0
- package/src/server/services/mcp/index.test.ts +52 -10
- package/src/server/services/mcp/index.ts +29 -26
- package/src/services/session/index.ts +0 -14
- 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
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
//
|
|
103
|
-
|
|
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
|
-
|
|
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 }> {
|