@lobehub/chat 1.84.27 → 1.85.1
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/docs/development/database-schema.dbml +59 -1
- package/package.json +3 -2
- package/packages/file-loaders/package.json +5 -1
- package/packages/file-loaders/src/loadFile.ts +51 -1
- package/packages/file-loaders/src/loaders/docx/index.ts +16 -1
- package/packages/file-loaders/src/loaders/excel/index.ts +30 -2
- package/packages/file-loaders/src/loaders/pdf/__snapshots__/index.test.ts.snap +1 -1
- package/packages/file-loaders/src/loaders/pdf/index.ts +52 -12
- package/packages/file-loaders/src/loaders/pptx/index.ts +32 -1
- package/packages/file-loaders/src/loaders/text/index.test.ts +1 -1
- package/packages/file-loaders/src/loaders/text/index.ts +13 -1
- package/packages/file-loaders/test/__snapshots__/loaders.test.ts.snap +41 -0
- package/packages/file-loaders/test/loaders.test.ts +20 -0
- package/packages/file-loaders/test/setup.ts +17 -0
- package/packages/file-loaders/vitest.config.ts +14 -0
- package/src/config/aiModels/infiniai.ts +113 -9
- package/src/const/file.ts +8 -1
- package/src/database/client/migrations.json +23 -1
- package/src/database/migrations/0022_add_documents.sql +49 -0
- package/src/database/migrations/meta/0022_snapshot.json +5340 -0
- package/src/database/migrations/meta/_journal.json +7 -0
- package/src/database/models/_template.ts +1 -1
- package/src/database/models/document.ts +54 -0
- package/src/database/models/message.ts +25 -0
- package/src/database/repositories/tableViewer/index.test.ts +1 -1
- package/src/database/schemas/document.ts +104 -0
- package/src/database/schemas/index.ts +1 -0
- package/src/database/schemas/relations.ts +34 -2
- package/src/database/schemas/topic.ts +31 -8
- package/src/database/utils/idGenerator.ts +1 -0
- package/src/features/ChatInput/Desktop/FilePreview/FileItem/Content.tsx +1 -1
- package/src/features/ChatInput/Desktop/FilePreview/FileItem/index.tsx +10 -10
- package/src/features/ChatInput/components/UploadDetail/UploadStatus.tsx +2 -2
- package/src/features/Conversation/Actions/Error.tsx +2 -2
- package/src/libs/agent-runtime/infiniai/index.ts +1 -1
- package/src/libs/trpc/lambda/context.ts +7 -0
- package/src/prompts/files/file.ts +6 -4
- package/src/server/routers/lambda/__tests__/message.test.ts +213 -0
- package/src/server/routers/lambda/document.ts +36 -0
- package/src/server/routers/lambda/index.ts +2 -0
- package/src/server/services/document/index.ts +66 -0
- package/src/server/services/file/__tests__/index.test.ts +115 -0
- package/src/server/services/mcp/index.ts +0 -4
- package/src/server/utils/__tests__/tempFileManager.test.ts +94 -0
- package/src/services/rag.ts +4 -0
- package/src/store/chat/slices/aiChat/actions/__tests__/rag.test.ts +2 -2
- package/src/store/chat/slices/aiChat/actions/rag.ts +2 -3
- package/src/store/file/slices/chat/action.ts +3 -51
- package/src/types/document/index.ts +172 -0
- package/src/types/message/chat.ts +1 -0
- package/src/features/ChatInput/Desktop/FilePreview/FileItem/style.ts +0 -4
@@ -0,0 +1,213 @@
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
2
|
+
|
3
|
+
import { MessageModel } from '@/database/models/message';
|
4
|
+
import { FileService } from '@/server/services/file';
|
5
|
+
import { ChatMessage, CreateMessageParams } from '@/types/message';
|
6
|
+
|
7
|
+
vi.mock('@/database/models/message', () => ({
|
8
|
+
MessageModel: vi.fn(),
|
9
|
+
}));
|
10
|
+
|
11
|
+
vi.mock('@/server/services/file', () => ({
|
12
|
+
FileService: vi.fn(),
|
13
|
+
}));
|
14
|
+
|
15
|
+
vi.mock('@/database/server', () => ({
|
16
|
+
getServerDB: vi.fn(),
|
17
|
+
}));
|
18
|
+
|
19
|
+
describe('messageRouter', () => {
|
20
|
+
it('should handle batchCreateMessages', async () => {
|
21
|
+
const mockBatchCreate = vi.fn().mockResolvedValue({ rowCount: 2 });
|
22
|
+
vi.mocked(MessageModel).mockImplementation(
|
23
|
+
() =>
|
24
|
+
({
|
25
|
+
batchCreate: mockBatchCreate,
|
26
|
+
}) as any,
|
27
|
+
);
|
28
|
+
|
29
|
+
const input = [
|
30
|
+
{
|
31
|
+
id: '1',
|
32
|
+
role: 'user',
|
33
|
+
content: 'test',
|
34
|
+
sessionId: 'session1',
|
35
|
+
createdAt: new Date(),
|
36
|
+
updatedAt: new Date(),
|
37
|
+
agentId: 'agent1',
|
38
|
+
clientId: 'client1',
|
39
|
+
parentId: null,
|
40
|
+
quotaId: null,
|
41
|
+
model: null,
|
42
|
+
provider: null,
|
43
|
+
topicId: null,
|
44
|
+
error: null,
|
45
|
+
favorite: false,
|
46
|
+
observationId: null,
|
47
|
+
reasoning: null,
|
48
|
+
pluginState: null,
|
49
|
+
translate: null,
|
50
|
+
tts: null,
|
51
|
+
search: null,
|
52
|
+
threadId: null,
|
53
|
+
tools: null,
|
54
|
+
traceId: null,
|
55
|
+
userId: 'user1',
|
56
|
+
} as any,
|
57
|
+
];
|
58
|
+
|
59
|
+
const ctx = {
|
60
|
+
messageModel: new MessageModel({} as any, 'user1'),
|
61
|
+
};
|
62
|
+
|
63
|
+
const result = await ctx.messageModel.batchCreate(input);
|
64
|
+
|
65
|
+
expect(mockBatchCreate).toHaveBeenCalledWith(input);
|
66
|
+
expect(result.rowCount).toBe(2);
|
67
|
+
});
|
68
|
+
|
69
|
+
it('should handle count', async () => {
|
70
|
+
const mockCount = vi.fn().mockResolvedValue(5);
|
71
|
+
vi.mocked(MessageModel).mockImplementation(
|
72
|
+
() =>
|
73
|
+
({
|
74
|
+
count: mockCount,
|
75
|
+
}) as any,
|
76
|
+
);
|
77
|
+
|
78
|
+
const input = { startDate: '2024-01-01' };
|
79
|
+
const ctx = {
|
80
|
+
messageModel: new MessageModel({} as any, 'user1'),
|
81
|
+
};
|
82
|
+
|
83
|
+
const result = await ctx.messageModel.count(input);
|
84
|
+
|
85
|
+
expect(mockCount).toHaveBeenCalledWith(input);
|
86
|
+
expect(result).toBe(5);
|
87
|
+
});
|
88
|
+
|
89
|
+
it('should handle createMessage', async () => {
|
90
|
+
const mockCreate = vi.fn().mockResolvedValue({ id: 'msg1' });
|
91
|
+
vi.mocked(MessageModel).mockImplementation(
|
92
|
+
() =>
|
93
|
+
({
|
94
|
+
create: mockCreate,
|
95
|
+
}) as any,
|
96
|
+
);
|
97
|
+
|
98
|
+
const input: CreateMessageParams = {
|
99
|
+
content: 'test',
|
100
|
+
role: 'user',
|
101
|
+
sessionId: 'session1',
|
102
|
+
};
|
103
|
+
|
104
|
+
const ctx = {
|
105
|
+
messageModel: new MessageModel({} as any, 'user1'),
|
106
|
+
};
|
107
|
+
|
108
|
+
const result = await ctx.messageModel.create(input);
|
109
|
+
|
110
|
+
expect(mockCreate).toHaveBeenCalledWith(input);
|
111
|
+
expect(result.id).toBe('msg1');
|
112
|
+
});
|
113
|
+
|
114
|
+
it('should handle getMessages', async () => {
|
115
|
+
const mockQuery = vi.fn().mockResolvedValue([{ id: 'msg1' }]);
|
116
|
+
const mockGetFullFileUrl = vi
|
117
|
+
.fn()
|
118
|
+
.mockImplementation((path: string | null, file: { fileType: string }) => {
|
119
|
+
return Promise.resolve('url');
|
120
|
+
});
|
121
|
+
|
122
|
+
vi.mocked(MessageModel).mockImplementation(
|
123
|
+
() =>
|
124
|
+
({
|
125
|
+
query: mockQuery,
|
126
|
+
}) as any,
|
127
|
+
);
|
128
|
+
|
129
|
+
vi.mocked(FileService).mockImplementation(
|
130
|
+
() =>
|
131
|
+
({
|
132
|
+
getFullFileUrl: mockGetFullFileUrl,
|
133
|
+
}) as any,
|
134
|
+
);
|
135
|
+
|
136
|
+
const input = { sessionId: 'session1' };
|
137
|
+
const ctx = {
|
138
|
+
messageModel: new MessageModel({} as any, 'user1'),
|
139
|
+
fileService: new FileService({} as any, 'user1'),
|
140
|
+
userId: 'user1',
|
141
|
+
};
|
142
|
+
|
143
|
+
const result = await ctx.messageModel.query(input, {
|
144
|
+
postProcessUrl: mockGetFullFileUrl,
|
145
|
+
});
|
146
|
+
|
147
|
+
expect(mockQuery).toHaveBeenCalledWith(input, expect.any(Object));
|
148
|
+
expect(result).toEqual([{ id: 'msg1' }]);
|
149
|
+
});
|
150
|
+
|
151
|
+
it('should handle getAllMessages', async () => {
|
152
|
+
const mockQueryAll = vi.fn().mockResolvedValue([
|
153
|
+
{
|
154
|
+
id: 'msg1',
|
155
|
+
meta: {},
|
156
|
+
} as ChatMessage,
|
157
|
+
]);
|
158
|
+
vi.mocked(MessageModel).mockImplementation(
|
159
|
+
() =>
|
160
|
+
({
|
161
|
+
queryAll: mockQueryAll,
|
162
|
+
}) as any,
|
163
|
+
);
|
164
|
+
|
165
|
+
const ctx = {
|
166
|
+
messageModel: new MessageModel({} as any, 'user1'),
|
167
|
+
};
|
168
|
+
|
169
|
+
const result = await ctx.messageModel.queryAll();
|
170
|
+
|
171
|
+
expect(mockQueryAll).toHaveBeenCalled();
|
172
|
+
expect(result).toEqual([{ id: 'msg1', meta: {} }]);
|
173
|
+
});
|
174
|
+
|
175
|
+
it('should handle removeMessage', async () => {
|
176
|
+
const mockDelete = vi.fn().mockResolvedValue(undefined);
|
177
|
+
vi.mocked(MessageModel).mockImplementation(
|
178
|
+
() =>
|
179
|
+
({
|
180
|
+
deleteMessage: mockDelete,
|
181
|
+
}) as any,
|
182
|
+
);
|
183
|
+
|
184
|
+
const input = { id: 'msg1' };
|
185
|
+
const ctx = {
|
186
|
+
messageModel: new MessageModel({} as any, 'user1'),
|
187
|
+
};
|
188
|
+
|
189
|
+
await ctx.messageModel.deleteMessage(input.id);
|
190
|
+
|
191
|
+
expect(mockDelete).toHaveBeenCalledWith(input.id);
|
192
|
+
});
|
193
|
+
|
194
|
+
it('should handle updateMessage', async () => {
|
195
|
+
const mockUpdate = vi.fn().mockResolvedValue({ success: true });
|
196
|
+
vi.mocked(MessageModel).mockImplementation(
|
197
|
+
() =>
|
198
|
+
({
|
199
|
+
update: mockUpdate,
|
200
|
+
}) as any,
|
201
|
+
);
|
202
|
+
|
203
|
+
const input = { id: 'msg1', value: { content: 'updated' } };
|
204
|
+
const ctx = {
|
205
|
+
messageModel: new MessageModel({} as any, 'user1'),
|
206
|
+
};
|
207
|
+
|
208
|
+
const result = await ctx.messageModel.update(input.id, input.value);
|
209
|
+
|
210
|
+
expect(mockUpdate).toHaveBeenCalledWith(input.id, input.value);
|
211
|
+
expect(result).toEqual({ success: true });
|
212
|
+
});
|
213
|
+
});
|
@@ -0,0 +1,36 @@
|
|
1
|
+
import { z } from 'zod';
|
2
|
+
|
3
|
+
import { ChunkModel } from '@/database/models/chunk';
|
4
|
+
import { FileModel } from '@/database/models/file';
|
5
|
+
import { MessageModel } from '@/database/models/message';
|
6
|
+
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
7
|
+
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
8
|
+
import { DocumentService } from '@/server/services/document';
|
9
|
+
|
10
|
+
const documentProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
|
11
|
+
const { ctx } = opts;
|
12
|
+
|
13
|
+
return opts.next({
|
14
|
+
ctx: {
|
15
|
+
chunkModel: new ChunkModel(ctx.serverDB, ctx.userId),
|
16
|
+
documentService: new DocumentService(ctx.serverDB, ctx.userId),
|
17
|
+
fileModel: new FileModel(ctx.serverDB, ctx.userId),
|
18
|
+
messageModel: new MessageModel(ctx.serverDB, ctx.userId),
|
19
|
+
},
|
20
|
+
});
|
21
|
+
});
|
22
|
+
|
23
|
+
export const documentRouter = router({
|
24
|
+
parseFileContent: documentProcedure
|
25
|
+
.input(
|
26
|
+
z.object({
|
27
|
+
id: z.string(),
|
28
|
+
skipExist: z.boolean().optional(),
|
29
|
+
}),
|
30
|
+
)
|
31
|
+
.mutation(async ({ ctx, input }) => {
|
32
|
+
const lobeDocument = await ctx.documentService.parseFile(input.id);
|
33
|
+
|
34
|
+
return lobeDocument;
|
35
|
+
}),
|
36
|
+
});
|
@@ -7,6 +7,7 @@ import { agentRouter } from './agent';
|
|
7
7
|
import { aiModelRouter } from './aiModel';
|
8
8
|
import { aiProviderRouter } from './aiProvider';
|
9
9
|
import { chunkRouter } from './chunk';
|
10
|
+
import { documentRouter } from './document';
|
10
11
|
import { exporterRouter } from './exporter';
|
11
12
|
import { fileRouter } from './file';
|
12
13
|
import { importerRouter } from './importer';
|
@@ -25,6 +26,7 @@ export const lambdaRouter = router({
|
|
25
26
|
aiModel: aiModelRouter,
|
26
27
|
aiProvider: aiProviderRouter,
|
27
28
|
chunk: chunkRouter,
|
29
|
+
document: documentRouter,
|
28
30
|
exporter: exporterRouter,
|
29
31
|
file: fileRouter,
|
30
32
|
healthcheck: publicProcedure.query(() => "i'm live!"),
|
@@ -0,0 +1,66 @@
|
|
1
|
+
import { loadFile } from '@lobechat/file-loaders';
|
2
|
+
import debug from 'debug';
|
3
|
+
|
4
|
+
import { DocumentModel } from '@/database/models/document';
|
5
|
+
import { FileModel } from '@/database/models/file';
|
6
|
+
import { LobeChatDatabase } from '@/database/type';
|
7
|
+
import { LobeDocument } from '@/types/document';
|
8
|
+
|
9
|
+
import { FileService } from '../file';
|
10
|
+
|
11
|
+
const log = debug('lobe-chat:service:document');
|
12
|
+
|
13
|
+
export class DocumentService {
|
14
|
+
userId: string;
|
15
|
+
private fileModel: FileModel;
|
16
|
+
private documentModel: DocumentModel;
|
17
|
+
private fileService: FileService;
|
18
|
+
|
19
|
+
constructor(db: LobeChatDatabase, userId: string) {
|
20
|
+
this.userId = userId;
|
21
|
+
this.fileModel = new FileModel(db, userId);
|
22
|
+
this.fileService = new FileService(db, userId);
|
23
|
+
this.documentModel = new DocumentModel(db, userId);
|
24
|
+
}
|
25
|
+
|
26
|
+
/**
|
27
|
+
* 解析文件内容
|
28
|
+
*
|
29
|
+
*/
|
30
|
+
async parseFile(fileId: string): Promise<LobeDocument> {
|
31
|
+
const { filePath, file, cleanup } = await this.fileService.downloadFileToLocal(fileId);
|
32
|
+
|
33
|
+
const logPrefix = `[${file.name}]`;
|
34
|
+
log(`${logPrefix} 开始解析文件, 路径: ${filePath}`);
|
35
|
+
|
36
|
+
try {
|
37
|
+
// 使用loadFile加载文件内容
|
38
|
+
const fileDocument = await loadFile(filePath);
|
39
|
+
|
40
|
+
log(`${logPrefix} 文件解析成功 %O`, {
|
41
|
+
fileType: fileDocument.fileType,
|
42
|
+
size: fileDocument.content.length,
|
43
|
+
});
|
44
|
+
|
45
|
+
const document = await this.documentModel.create({
|
46
|
+
content: fileDocument.content,
|
47
|
+
fileId,
|
48
|
+
fileType: file.fileType,
|
49
|
+
metadata: fileDocument.metadata,
|
50
|
+
pages: fileDocument.pages,
|
51
|
+
source: file.url,
|
52
|
+
sourceType: 'file',
|
53
|
+
title: fileDocument.metadata?.title,
|
54
|
+
totalCharCount: fileDocument.totalCharCount,
|
55
|
+
totalLineCount: fileDocument.totalLineCount,
|
56
|
+
});
|
57
|
+
|
58
|
+
return document as LobeDocument;
|
59
|
+
} catch (error) {
|
60
|
+
console.error(`${logPrefix} 文件解析失败:`, error);
|
61
|
+
throw error;
|
62
|
+
} finally {
|
63
|
+
cleanup();
|
64
|
+
}
|
65
|
+
}
|
66
|
+
}
|
@@ -0,0 +1,115 @@
|
|
1
|
+
import { TRPCError } from '@trpc/server';
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
3
|
+
|
4
|
+
import { FileModel } from '@/database/models/file';
|
5
|
+
import { TempFileManager } from '@/server/utils/tempFileManager';
|
6
|
+
|
7
|
+
import { FileService } from '../index';
|
8
|
+
|
9
|
+
vi.mock('@/config/db', () => ({
|
10
|
+
serverDBEnv: {
|
11
|
+
REMOVE_GLOBAL_FILE: false,
|
12
|
+
},
|
13
|
+
}));
|
14
|
+
|
15
|
+
vi.mock('../impls', () => ({
|
16
|
+
createFileServiceModule: () => ({
|
17
|
+
deleteFile: vi.fn(),
|
18
|
+
deleteFiles: vi.fn(),
|
19
|
+
getFileContent: vi.fn(),
|
20
|
+
getFileByteArray: vi.fn(),
|
21
|
+
createPreSignedUrl: vi.fn(),
|
22
|
+
createPreSignedUrlForPreview: vi.fn(),
|
23
|
+
uploadContent: vi.fn(),
|
24
|
+
getFullFileUrl: vi.fn(),
|
25
|
+
}),
|
26
|
+
}));
|
27
|
+
|
28
|
+
vi.mock('@/database/models/file');
|
29
|
+
|
30
|
+
vi.mock('@/server/utils/tempFileManager');
|
31
|
+
|
32
|
+
vi.mock('@/utils/uuid', () => ({
|
33
|
+
nanoid: () => 'test-id',
|
34
|
+
}));
|
35
|
+
|
36
|
+
describe('FileService', () => {
|
37
|
+
let service: FileService;
|
38
|
+
const mockDb = {} as any;
|
39
|
+
const mockUserId = 'test-user';
|
40
|
+
let mockFileModel: any;
|
41
|
+
let mockTempManager: any;
|
42
|
+
|
43
|
+
beforeEach(() => {
|
44
|
+
mockFileModel = {
|
45
|
+
findById: vi.fn(),
|
46
|
+
delete: vi.fn(),
|
47
|
+
};
|
48
|
+
mockTempManager = {
|
49
|
+
writeTempFile: vi.fn(),
|
50
|
+
cleanup: vi.fn(),
|
51
|
+
};
|
52
|
+
vi.mocked(FileModel).mockImplementation(() => mockFileModel);
|
53
|
+
vi.mocked(TempFileManager).mockImplementation(() => mockTempManager);
|
54
|
+
service = new FileService(mockDb, mockUserId);
|
55
|
+
});
|
56
|
+
|
57
|
+
afterEach(() => {
|
58
|
+
vi.clearAllMocks();
|
59
|
+
});
|
60
|
+
|
61
|
+
describe('downloadFileToLocal', () => {
|
62
|
+
const mockFile = {
|
63
|
+
id: 'test-file-id',
|
64
|
+
name: 'test.txt',
|
65
|
+
url: 'test-url',
|
66
|
+
};
|
67
|
+
|
68
|
+
it('should throw error if file not found', async () => {
|
69
|
+
mockFileModel.findById.mockResolvedValue(undefined);
|
70
|
+
|
71
|
+
await expect(service.downloadFileToLocal('test-file-id')).rejects.toThrow(
|
72
|
+
new TRPCError({ code: 'BAD_REQUEST', message: 'File not found' }),
|
73
|
+
);
|
74
|
+
});
|
75
|
+
|
76
|
+
it('should throw error if file content is empty', async () => {
|
77
|
+
mockFileModel.findById.mockResolvedValue(mockFile);
|
78
|
+
vi.mocked(service['impl'].getFileByteArray).mockResolvedValue(undefined as any);
|
79
|
+
|
80
|
+
await expect(service.downloadFileToLocal('test-file-id')).rejects.toThrow(
|
81
|
+
new TRPCError({ code: 'BAD_REQUEST', message: 'File content is empty' }),
|
82
|
+
);
|
83
|
+
});
|
84
|
+
|
85
|
+
it('should delete file from db and throw error if file not found in storage', async () => {
|
86
|
+
mockFileModel.findById.mockResolvedValue(mockFile);
|
87
|
+
vi.mocked(service['impl'].getFileByteArray).mockRejectedValue({ Code: 'NoSuchKey' });
|
88
|
+
|
89
|
+
await expect(service.downloadFileToLocal('test-file-id')).rejects.toThrow(
|
90
|
+
new TRPCError({ code: 'BAD_REQUEST', message: 'File not found' }),
|
91
|
+
);
|
92
|
+
|
93
|
+
expect(mockFileModel.delete).toHaveBeenCalledWith('test-file-id', false);
|
94
|
+
});
|
95
|
+
|
96
|
+
it('should successfully download file to local', async () => {
|
97
|
+
const mockContent = new Uint8Array([1, 2, 3]);
|
98
|
+
const mockFilePath = '/tmp/test.txt';
|
99
|
+
|
100
|
+
mockFileModel.findById.mockResolvedValue(mockFile);
|
101
|
+
vi.mocked(service['impl'].getFileByteArray).mockResolvedValue(mockContent);
|
102
|
+
mockTempManager.writeTempFile.mockResolvedValue(mockFilePath);
|
103
|
+
|
104
|
+
const result = await service.downloadFileToLocal('test-file-id');
|
105
|
+
|
106
|
+
expect(result).toEqual({
|
107
|
+
cleanup: expect.any(Function),
|
108
|
+
file: mockFile,
|
109
|
+
filePath: mockFilePath,
|
110
|
+
});
|
111
|
+
|
112
|
+
expect(mockTempManager.writeTempFile).toHaveBeenCalledWith(mockContent, mockFile.name);
|
113
|
+
});
|
114
|
+
});
|
115
|
+
});
|
@@ -15,10 +15,6 @@ class MCPService {
|
|
15
15
|
// Store instances of the custom MCPClient, keyed by serialized MCPClientParams
|
16
16
|
private clients: Map<string, MCPClient> = new Map();
|
17
17
|
|
18
|
-
constructor() {
|
19
|
-
log('MCPService initialized');
|
20
|
-
}
|
21
|
-
|
22
18
|
// --- MCP Interaction ---
|
23
19
|
|
24
20
|
// listTools now accepts MCPClientParams
|
@@ -0,0 +1,94 @@
|
|
1
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
2
|
+
import { tmpdir } from 'node:os';
|
3
|
+
import { join } from 'node:path';
|
4
|
+
import { describe, expect, it, vi } from 'vitest';
|
5
|
+
|
6
|
+
import { TempFileManager } from '../tempFileManager';
|
7
|
+
|
8
|
+
// Mock node modules
|
9
|
+
vi.mock('node:fs');
|
10
|
+
vi.mock('node:os');
|
11
|
+
vi.mock('node:path', () => ({
|
12
|
+
join: (...args: string[]) => args.join('/'),
|
13
|
+
default: {
|
14
|
+
join: (...args: string[]) => args.join('/'),
|
15
|
+
},
|
16
|
+
}));
|
17
|
+
|
18
|
+
describe('TempFileManager', () => {
|
19
|
+
const mockTmpDir = '/tmp';
|
20
|
+
const mockDirname = 'test-';
|
21
|
+
const mockFullTmpDir = '/tmp/test-xyz';
|
22
|
+
|
23
|
+
beforeEach(() => {
|
24
|
+
vi.clearAllMocks();
|
25
|
+
vi.mocked(tmpdir).mockReturnValue(mockTmpDir);
|
26
|
+
vi.mocked(mkdtempSync).mockReturnValue(mockFullTmpDir);
|
27
|
+
vi.mocked(existsSync).mockReturnValue(true);
|
28
|
+
});
|
29
|
+
|
30
|
+
it('should create temp directory on initialization', () => {
|
31
|
+
new TempFileManager(mockDirname);
|
32
|
+
|
33
|
+
expect(tmpdir).toHaveBeenCalled();
|
34
|
+
expect(mkdtempSync).toHaveBeenCalledWith(`${mockTmpDir}/${mockDirname}`);
|
35
|
+
});
|
36
|
+
|
37
|
+
it('should write temp file successfully', async () => {
|
38
|
+
const manager = new TempFileManager(mockDirname);
|
39
|
+
const testData = new Uint8Array([1, 2, 3]);
|
40
|
+
const fileName = 'test.txt';
|
41
|
+
|
42
|
+
const filePath = await manager.writeTempFile(testData, fileName);
|
43
|
+
|
44
|
+
expect(writeFileSync).toHaveBeenCalledWith(`${mockFullTmpDir}/${fileName}`, testData);
|
45
|
+
expect(filePath).toBe(`${mockFullTmpDir}/${fileName}`);
|
46
|
+
});
|
47
|
+
|
48
|
+
it('should cleanup on write failure', async () => {
|
49
|
+
const manager = new TempFileManager(mockDirname);
|
50
|
+
const testData = new Uint8Array([1, 2, 3]);
|
51
|
+
const fileName = 'test.txt';
|
52
|
+
|
53
|
+
vi.mocked(writeFileSync).mockImplementation(() => {
|
54
|
+
throw new Error('Write failed');
|
55
|
+
});
|
56
|
+
|
57
|
+
await expect(manager.writeTempFile(testData, fileName)).rejects.toThrow(
|
58
|
+
'Failed to write temp file: Write failed',
|
59
|
+
);
|
60
|
+
|
61
|
+
expect(existsSync).toHaveBeenCalledWith(mockFullTmpDir);
|
62
|
+
expect(rmSync).toHaveBeenCalledWith(mockFullTmpDir, { force: true, recursive: true });
|
63
|
+
});
|
64
|
+
|
65
|
+
it('should cleanup temp directory', () => {
|
66
|
+
const manager = new TempFileManager(mockDirname);
|
67
|
+
vi.mocked(existsSync).mockReturnValue(true);
|
68
|
+
|
69
|
+
manager.cleanup();
|
70
|
+
|
71
|
+
expect(existsSync).toHaveBeenCalledWith(mockFullTmpDir);
|
72
|
+
expect(rmSync).toHaveBeenCalledWith(mockFullTmpDir, { force: true, recursive: true });
|
73
|
+
});
|
74
|
+
|
75
|
+
it('should skip cleanup if directory does not exist', () => {
|
76
|
+
const manager = new TempFileManager(mockDirname);
|
77
|
+
vi.mocked(existsSync).mockReturnValue(false);
|
78
|
+
|
79
|
+
manager.cleanup();
|
80
|
+
|
81
|
+
expect(existsSync).toHaveBeenCalledWith(mockFullTmpDir);
|
82
|
+
expect(rmSync).not.toHaveBeenCalled();
|
83
|
+
});
|
84
|
+
|
85
|
+
it('should register cleanup hooks on process events', () => {
|
86
|
+
const processOnSpy = vi.spyOn(process, 'on');
|
87
|
+
new TempFileManager(mockDirname);
|
88
|
+
|
89
|
+
expect(processOnSpy).toHaveBeenCalledWith('exit', expect.any(Function));
|
90
|
+
expect(processOnSpy).toHaveBeenCalledWith('uncaughtException', expect.any(Function));
|
91
|
+
expect(processOnSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function));
|
92
|
+
expect(processOnSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function));
|
93
|
+
});
|
94
|
+
});
|
package/src/services/rag.ts
CHANGED
@@ -2,6 +2,10 @@ import { lambdaClient } from '@/libs/trpc/client';
|
|
2
2
|
import { SemanticSearchSchemaType } from '@/types/rag';
|
3
3
|
|
4
4
|
class RAGService {
|
5
|
+
parseFileContent = async (id: string, skipExist?: boolean) => {
|
6
|
+
return lambdaClient.document.parseFileContent.mutate({ id, skipExist });
|
7
|
+
};
|
8
|
+
|
5
9
|
createParseFileTask = async (id: string, skipExist?: boolean) => {
|
6
10
|
return lambdaClient.chunk.createParseFileTask.mutate({ id, skipExist });
|
7
11
|
};
|
@@ -197,13 +197,13 @@ describe('chatRAG actions', () => {
|
|
197
197
|
expect(result.current.internal_shouldUseRAG()).toBe(true);
|
198
198
|
});
|
199
199
|
|
200
|
-
it('should return
|
200
|
+
it('should return false if has user files', () => {
|
201
201
|
const { result } = renderHook(() => useChatStore());
|
202
202
|
|
203
203
|
vi.spyOn(agentSelectors, 'hasEnabledKnowledge').mockReturnValue(false);
|
204
204
|
vi.spyOn(chatSelectors, 'currentUserFiles').mockReturnValue([{ id: 'file-1' }] as any);
|
205
205
|
|
206
|
-
expect(result.current.internal_shouldUseRAG()).
|
206
|
+
expect(result.current.internal_shouldUseRAG()).toBeFalsy();
|
207
207
|
});
|
208
208
|
|
209
209
|
it('should return false if no knowledge or files', () => {
|
@@ -130,9 +130,8 @@ export const chatRag: StateCreator<ChatStore, [['zustand/devtools', never]], [],
|
|
130
130
|
return rewriteQuery;
|
131
131
|
},
|
132
132
|
internal_shouldUseRAG: () => {
|
133
|
-
|
134
|
-
|
135
|
-
return hasEnabledKnowledge() || userFiles.length > 0;
|
133
|
+
// if there is enabled knowledge, try with ragQuery
|
134
|
+
return hasEnabledKnowledge();
|
136
135
|
},
|
137
136
|
|
138
137
|
internal_toggleMessageRAGLoading: (loading, id) => {
|
@@ -7,14 +7,10 @@ import { fileService } from '@/services/file';
|
|
7
7
|
import { ServerService } from '@/services/file/server';
|
8
8
|
import { ragService } from '@/services/rag';
|
9
9
|
import { UPLOAD_NETWORK_ERROR } from '@/services/upload';
|
10
|
-
import { userService } from '@/services/user';
|
11
|
-
import { useAgentStore } from '@/store/agent';
|
12
10
|
import {
|
13
11
|
UploadFileListDispatch,
|
14
12
|
uploadFileListReducer,
|
15
13
|
} from '@/store/file/reducers/uploadFileList';
|
16
|
-
import { useUserStore } from '@/store/user';
|
17
|
-
import { preferenceSelectors } from '@/store/user/selectors';
|
18
14
|
import { FileListItem } from '@/types/files';
|
19
15
|
import { UploadFileItem } from '@/types/files/upload';
|
20
16
|
import { isChunkingUnsupported } from '@/utils/isChunkingUnsupported';
|
@@ -97,7 +93,7 @@ export const createFileSlice: StateCreator<
|
|
97
93
|
},
|
98
94
|
|
99
95
|
uploadChatFiles: async (rawFiles) => {
|
100
|
-
const { dispatchChatUploadFileList
|
96
|
+
const { dispatchChatUploadFileList } = get();
|
101
97
|
// 0. skip file in blacklist
|
102
98
|
const files = rawFiles.filter((file) => !FILE_UPLOAD_BLACKLIST.includes(file.name));
|
103
99
|
// 1. add files with base64
|
@@ -154,52 +150,8 @@ export const createFileSlice: StateCreator<
|
|
154
150
|
// image don't need to be chunked and embedding
|
155
151
|
if (isChunkingUnsupported(file.type)) return;
|
156
152
|
|
157
|
-
|
158
|
-
|
159
|
-
id: fileResult.id,
|
160
|
-
type: 'updateFile',
|
161
|
-
// make the taks empty to hint the user that the task is starting but not triggered
|
162
|
-
value: { tasks: {} },
|
163
|
-
});
|
164
|
-
|
165
|
-
await startAsyncTask(
|
166
|
-
fileResult.id,
|
167
|
-
async (id) => {
|
168
|
-
const data = await ragService.createParseFileTask(id);
|
169
|
-
if (!data || !data.id) throw new Error('failed to createParseFileTask');
|
170
|
-
|
171
|
-
// run the assignment
|
172
|
-
useAgentStore
|
173
|
-
.getState()
|
174
|
-
.addFilesToAgent([id], false)
|
175
|
-
.then(() => {
|
176
|
-
// trigger the tip if it's the first time
|
177
|
-
if (!preferenceSelectors.shouldTriggerFileInKnowledgeBaseTip(useUserStore.getState()))
|
178
|
-
return;
|
179
|
-
|
180
|
-
userService.updateGuide({ uploadFileInKnowledgeBase: true });
|
181
|
-
});
|
182
|
-
|
183
|
-
return data.id;
|
184
|
-
},
|
185
|
-
|
186
|
-
(fileItem) => {
|
187
|
-
dispatchChatUploadFileList({
|
188
|
-
id: fileResult.id,
|
189
|
-
type: 'updateFile',
|
190
|
-
value: {
|
191
|
-
tasks: {
|
192
|
-
chunkCount: fileItem.chunkCount,
|
193
|
-
chunkingError: fileItem.chunkingError,
|
194
|
-
chunkingStatus: fileItem.chunkingStatus,
|
195
|
-
embeddingError: fileItem.embeddingError,
|
196
|
-
embeddingStatus: fileItem.embeddingStatus,
|
197
|
-
finishEmbedding: fileItem.finishEmbedding,
|
198
|
-
},
|
199
|
-
},
|
200
|
-
});
|
201
|
-
},
|
202
|
-
);
|
153
|
+
const data = await ragService.parseFileContent(fileResult.id);
|
154
|
+
console.log(data);
|
203
155
|
});
|
204
156
|
|
205
157
|
await Promise.all(pools);
|