@lobehub/lobehub 2.0.0-next.97 → 2.0.0-next.99

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 (150) hide show
  1. package/.console-log-whitelist.json +14 -0
  2. package/.github/workflows/check-console-log.yml +117 -0
  3. package/.github/workflows/desktop-pr-build.yml +4 -4
  4. package/.github/workflows/release-desktop-beta.yml +4 -4
  5. package/.github/workflows/release.yml +1 -1
  6. package/.github/workflows/test.yml +5 -5
  7. package/CHANGELOG.md +58 -0
  8. package/apps/desktop/src/main/services/__tests__/fileSrv.test.ts +603 -0
  9. package/changelog/v1.json +21 -0
  10. package/codecov.yml +1 -0
  11. package/docs/development/database-schema.dbml +1 -0
  12. package/e2e/package.json +1 -1
  13. package/locales/ar/file.json +9 -11
  14. package/locales/ar/plugin.json +34 -22
  15. package/locales/ar/tool.json +8 -0
  16. package/locales/bg-BG/file.json +8 -10
  17. package/locales/bg-BG/plugin.json +34 -22
  18. package/locales/bg-BG/tool.json +8 -0
  19. package/locales/de-DE/file.json +9 -11
  20. package/locales/de-DE/plugin.json +34 -22
  21. package/locales/de-DE/tool.json +8 -0
  22. package/locales/en-US/file.json +12 -14
  23. package/locales/en-US/plugin.json +34 -22
  24. package/locales/en-US/tool.json +8 -0
  25. package/locales/es-ES/file.json +7 -9
  26. package/locales/es-ES/plugin.json +34 -22
  27. package/locales/es-ES/tool.json +8 -0
  28. package/locales/fa-IR/file.json +9 -11
  29. package/locales/fa-IR/plugin.json +34 -22
  30. package/locales/fa-IR/tool.json +8 -0
  31. package/locales/fr-FR/file.json +6 -8
  32. package/locales/fr-FR/plugin.json +34 -22
  33. package/locales/fr-FR/tool.json +8 -0
  34. package/locales/it-IT/file.json +8 -10
  35. package/locales/it-IT/plugin.json +34 -22
  36. package/locales/it-IT/tool.json +8 -0
  37. package/locales/ja-JP/file.json +10 -12
  38. package/locales/ja-JP/plugin.json +34 -22
  39. package/locales/ja-JP/tool.json +8 -0
  40. package/locales/ko-KR/file.json +8 -10
  41. package/locales/ko-KR/plugin.json +34 -22
  42. package/locales/ko-KR/tool.json +8 -0
  43. package/locales/nl-NL/file.json +8 -10
  44. package/locales/nl-NL/plugin.json +34 -22
  45. package/locales/nl-NL/tool.json +8 -0
  46. package/locales/pl-PL/file.json +7 -9
  47. package/locales/pl-PL/plugin.json +34 -22
  48. package/locales/pl-PL/tool.json +8 -0
  49. package/locales/pt-BR/file.json +7 -9
  50. package/locales/pt-BR/plugin.json +34 -22
  51. package/locales/pt-BR/tool.json +8 -0
  52. package/locales/ru-RU/file.json +9 -11
  53. package/locales/ru-RU/plugin.json +34 -22
  54. package/locales/ru-RU/tool.json +8 -0
  55. package/locales/tr-TR/file.json +8 -10
  56. package/locales/tr-TR/plugin.json +34 -22
  57. package/locales/tr-TR/tool.json +8 -0
  58. package/locales/vi-VN/file.json +9 -11
  59. package/locales/vi-VN/plugin.json +34 -22
  60. package/locales/vi-VN/tool.json +8 -0
  61. package/locales/zh-CN/file.json +10 -12
  62. package/locales/zh-CN/plugin.json +34 -22
  63. package/locales/zh-CN/tool.json +8 -0
  64. package/locales/zh-TW/file.json +10 -12
  65. package/locales/zh-TW/plugin.json +34 -22
  66. package/locales/zh-TW/tool.json +8 -0
  67. package/package.json +3 -2
  68. package/packages/database/migrations/0047_add_slug_document.sql +6 -0
  69. package/packages/database/migrations/meta/0047_snapshot.json +7891 -0
  70. package/packages/database/migrations/meta/_journal.json +7 -0
  71. package/packages/database/src/core/migrations.json +16 -7
  72. package/packages/database/src/models/__tests__/document.test.ts +149 -0
  73. package/packages/database/src/models/chunk.ts +3 -1
  74. package/packages/database/src/models/document.ts +10 -4
  75. package/packages/database/src/schemas/file.ts +7 -1
  76. package/packages/model-bank/src/aiModels/qwen.ts +5 -3
  77. package/packages/model-runtime/src/core/openaiCompatibleFactory/index.ts +21 -21
  78. package/packages/obervability-otel/package.json +2 -2
  79. package/packages/prompts/src/prompts/knowledgeBaseQA/__snapshots__/formatFileContents.test.ts.snap +75 -0
  80. package/packages/prompts/src/prompts/knowledgeBaseQA/__snapshots__/formatNoSearchResults.test.ts.snap +45 -0
  81. package/packages/prompts/src/prompts/knowledgeBaseQA/__snapshots__/formatSearchResults.test.ts.snap +82 -0
  82. package/packages/prompts/src/prompts/knowledgeBaseQA/formatFileContents.test.ts +118 -0
  83. package/packages/prompts/src/prompts/knowledgeBaseQA/formatFileContents.ts +31 -0
  84. package/packages/prompts/src/prompts/knowledgeBaseQA/formatNoSearchResults.test.ts +25 -0
  85. package/packages/prompts/src/prompts/knowledgeBaseQA/formatNoSearchResults.ts +13 -0
  86. package/packages/prompts/src/prompts/knowledgeBaseQA/formatSearchResults.test.ts +191 -0
  87. package/packages/prompts/src/prompts/knowledgeBaseQA/formatSearchResults.ts +50 -0
  88. package/packages/prompts/src/prompts/knowledgeBaseQA/index.ts +6 -0
  89. package/packages/types/src/rag.ts +13 -4
  90. package/scripts/checkConsoleLog.mts +148 -0
  91. package/scripts/prebuild.mts +5 -5
  92. package/src/app/[variants]/(main)/changelog/index.tsx +1 -1
  93. package/src/app/[variants]/(main)/settings/_layout/Desktop/index.tsx +20 -16
  94. package/src/app/[variants]/(main)/settings/provider/(list)/ProviderGrid/Card.tsx +6 -3
  95. package/src/app/[variants]/(main)/settings/provider/(list)/index.tsx +3 -2
  96. package/src/app/[variants]/(main)/settings/provider/detail/index.tsx +14 -4
  97. package/src/app/[variants]/desktopRouter.config.tsx +23 -0
  98. package/src/app/[variants]/mobileRouter.config.tsx +23 -0
  99. package/src/features/ChatInput/ActionBar/Token/TokenTag.tsx +2 -2
  100. package/src/features/ChatInput/ActionBar/Token/TokenTagForGroupChat.tsx +2 -2
  101. package/src/features/ChatList/Messages/Group/Tool/Inspector/ToolTitle.tsx +5 -23
  102. package/src/features/ChatList/Messages/Tool/Inspector/ToolTitle.tsx +5 -25
  103. package/src/features/KnowledgeManager/DocumentExplorer/NoteEditorModal.tsx +0 -20
  104. package/src/features/KnowledgeManager/DocumentExplorer/index.tsx +3 -3
  105. package/src/features/KnowledgeManager/FileExplorer/MasonryFileItem/index.tsx +0 -20
  106. package/src/features/KnowledgeManager/Header/AddButton.tsx +0 -1
  107. package/src/features/KnowledgeManager/Header/NewNoteButton.tsx +1 -1
  108. package/src/features/KnowledgeManager/Header/TogglePanelButton.tsx +2 -2
  109. package/src/features/KnowledgeManager/Home/UploadEntries.tsx +2 -2
  110. package/src/features/KnowledgeManager/Home/index.tsx +4 -4
  111. package/src/features/PluginsUI/Render/BuiltinType/index.tsx +1 -1
  112. package/src/features/User/UserPanel/useMenu.tsx +7 -3
  113. package/src/helpers/toolEngineering/index.test.ts +3 -3
  114. package/src/helpers/toolEngineering/index.ts +17 -4
  115. package/src/libs/trpc/client/lambda.ts +0 -6
  116. package/src/locales/default/file.ts +11 -13
  117. package/src/locales/default/plugin.ts +34 -22
  118. package/src/locales/default/tool.ts +13 -5
  119. package/src/server/routers/lambda/chunk.ts +168 -41
  120. package/src/services/chat/chat.test.ts +3 -3
  121. package/src/services/chat/index.ts +2 -2
  122. package/src/services/rag.ts +6 -2
  123. package/src/store/chat/agents/__tests__/createAgentExecutors/helpers/testExecutor.ts +1 -4
  124. package/src/store/chat/slices/aiChat/actions/__tests__/conversationLifecycle.test.ts +11 -0
  125. package/src/store/chat/slices/aiChat/actions/__tests__/rag.test.ts +0 -87
  126. package/src/store/chat/slices/aiChat/actions/__tests__/streamingExecutor.test.ts +2 -69
  127. package/src/store/chat/slices/aiChat/actions/conversationLifecycle.ts +0 -2
  128. package/src/store/chat/slices/aiChat/actions/rag.ts +0 -47
  129. package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +8 -69
  130. package/src/store/chat/slices/builtinTool/actions/index.ts +4 -1
  131. package/src/store/chat/slices/builtinTool/actions/knowledgeBase.ts +174 -0
  132. package/src/store/chat/slices/operation/types.ts +1 -0
  133. package/src/store/chat/slices/thread/action.test.ts +0 -1
  134. package/src/store/chat/slices/thread/action.ts +0 -1
  135. package/src/tools/executionRuntimes.ts +3 -0
  136. package/src/tools/identifiers.ts +13 -0
  137. package/src/tools/index.ts +7 -0
  138. package/src/tools/knowledge-base/ExecutionRuntime/index.ts +96 -0
  139. package/src/tools/knowledge-base/Render/ReadKnowledge/FileCard.tsx +135 -0
  140. package/src/tools/knowledge-base/Render/ReadKnowledge/index.tsx +27 -0
  141. package/src/tools/knowledge-base/Render/SearchKnowledgeBase/Item/index.tsx +54 -0
  142. package/src/tools/knowledge-base/Render/SearchKnowledgeBase/Item/style.ts +51 -0
  143. package/src/tools/knowledge-base/Render/SearchKnowledgeBase/index.tsx +23 -0
  144. package/src/tools/knowledge-base/Render/index.ts +7 -0
  145. package/src/tools/knowledge-base/index.ts +64 -0
  146. package/src/tools/knowledge-base/systemRole.ts +102 -0
  147. package/src/tools/knowledge-base/type.ts +25 -0
  148. package/src/tools/local-system/Intervention/WriteFile/index.tsx +1 -1
  149. package/src/tools/renders.ts +4 -0
  150. package/src/store/chat/agents/createToolEngine.ts +0 -22
@@ -0,0 +1,603 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import type { App } from '@/core/App';
4
+
5
+ import FileService, { FileNotFoundError } from '../fileSrv';
6
+
7
+ // Mock electron
8
+ vi.mock('electron', () => ({
9
+ app: {
10
+ getAppPath: vi.fn(() => '/mock/app/path'),
11
+ getPath: vi.fn(() => '/mock/user/data'),
12
+ },
13
+ }));
14
+
15
+ // Mock constants that depend on electron
16
+ vi.mock('@/const/dir', () => ({
17
+ FILE_STORAGE_DIR: 'file-storage',
18
+ LOCAL_STORAGE_URL_PREFIX: '/lobe-desktop-file',
19
+ }));
20
+
21
+ // Mock logger
22
+ vi.mock('@/utils/logger', () => ({
23
+ createLogger: () => ({
24
+ debug: vi.fn(),
25
+ info: vi.fn(),
26
+ warn: vi.fn(),
27
+ error: vi.fn(),
28
+ }),
29
+ }));
30
+
31
+ // Mock file-system utilities
32
+ vi.mock('@/utils/file-system', () => ({
33
+ makeSureDirExist: vi.fn(),
34
+ }));
35
+
36
+ // Mock node:fs/promises
37
+ vi.mock('node:fs/promises', () => ({
38
+ writeFile: vi.fn(),
39
+ readFile: vi.fn(),
40
+ access: vi.fn(),
41
+ }));
42
+
43
+ // Mock node:fs
44
+ vi.mock('node:fs', () => ({
45
+ default: {
46
+ constants: { F_OK: 0 },
47
+ promises: { access: vi.fn() },
48
+ readFile: vi.fn(),
49
+ unlink: vi.fn(),
50
+ },
51
+ constants: { F_OK: 0 },
52
+ promises: { access: vi.fn() },
53
+ readFile: vi.fn(),
54
+ unlink: vi.fn(),
55
+ }));
56
+
57
+ // Mock node:util promisify
58
+ vi.mock('node:util', () => ({
59
+ promisify: vi.fn((fn: any) => {
60
+ return vi.fn(async (...args: any[]) => {
61
+ return new Promise((resolve, reject) => {
62
+ fn(...args, (err: any, data: any) => {
63
+ if (err) reject(err);
64
+ else resolve(data);
65
+ });
66
+ });
67
+ });
68
+ }),
69
+ }));
70
+
71
+ describe('FileService', () => {
72
+ let fileService: FileService;
73
+ let mockApp: App;
74
+ let mockMakeSureDirExist: any;
75
+ let mockWriteFile: any;
76
+ let mockReadFile: any;
77
+ let mockAccess: any;
78
+ let mockFsReadFile: any;
79
+ let mockFsUnlink: any;
80
+
81
+ beforeEach(async () => {
82
+ vi.clearAllMocks();
83
+
84
+ // Setup mock app
85
+ mockApp = {
86
+ appStoragePath: '/mock/app/storage',
87
+ staticFileServerManager: {
88
+ getFileServerDomain: vi.fn().mockReturnValue('http://localhost:3000'),
89
+ },
90
+ } as unknown as App;
91
+
92
+ // Import mocks
93
+ mockMakeSureDirExist = (await import('@/utils/file-system')).makeSureDirExist;
94
+ const fsPromises = await import('node:fs/promises');
95
+ mockWriteFile = fsPromises.writeFile;
96
+ mockReadFile = fsPromises.readFile;
97
+ mockAccess = fsPromises.access;
98
+
99
+ const fs = await import('node:fs');
100
+ mockFsReadFile = fs.readFile;
101
+ mockFsUnlink = fs.unlink;
102
+
103
+ fileService = new FileService(mockApp);
104
+ });
105
+
106
+ describe('uploadFile', () => {
107
+ it('should upload file with ArrayBuffer content successfully', async () => {
108
+ const content = new ArrayBuffer(10);
109
+ const params = {
110
+ content,
111
+ filename: 'test.png',
112
+ hash: 'abc123',
113
+ path: 'user_uploads/images/test.png',
114
+ type: 'image/png',
115
+ };
116
+
117
+ mockWriteFile.mockResolvedValue(undefined);
118
+
119
+ const result = await fileService.uploadFile(params);
120
+
121
+ expect(result.success).toBe(true);
122
+ expect(result.metadata.filename).toBe('test.png');
123
+ expect(result.metadata.dirname).toBe('user_uploads/images');
124
+ expect(result.metadata.path).toBe('desktop://user_uploads/images/test.png');
125
+ expect(mockMakeSureDirExist).toHaveBeenCalled();
126
+ expect(mockWriteFile).toHaveBeenCalledTimes(2); // file + metadata
127
+ });
128
+
129
+ it('should upload file with Base64 string content successfully', async () => {
130
+ const base64Content = Buffer.from('test content').toString('base64');
131
+ const params = {
132
+ content: base64Content,
133
+ filename: 'test.txt',
134
+ hash: 'def456',
135
+ path: 'documents/test.txt',
136
+ type: 'text/plain',
137
+ };
138
+
139
+ mockWriteFile.mockResolvedValue(undefined);
140
+
141
+ const result = await fileService.uploadFile(params);
142
+
143
+ expect(result.success).toBe(true);
144
+ expect(result.metadata.filename).toBe('test.txt');
145
+ expect(result.metadata.path).toBe('desktop://documents/test.txt');
146
+ });
147
+
148
+ it('should create metadata file with correct structure', async () => {
149
+ const content = new ArrayBuffer(100);
150
+ const params = {
151
+ content,
152
+ filename: 'image.jpg',
153
+ hash: 'xyz789',
154
+ path: 'photos/image.jpg',
155
+ type: 'image/jpeg',
156
+ };
157
+
158
+ let metadataContent: string = '';
159
+ mockWriteFile.mockImplementation(async (path: any, data: any) => {
160
+ if (path.toString().endsWith('.meta')) {
161
+ metadataContent = data;
162
+ }
163
+ });
164
+
165
+ await fileService.uploadFile(params);
166
+
167
+ expect(metadataContent).toBeTruthy();
168
+ const metadata = JSON.parse(metadataContent);
169
+ expect(metadata.filename).toBe('image.jpg');
170
+ expect(metadata.hash).toBe('xyz789');
171
+ expect(metadata.type).toBe('image/jpeg');
172
+ expect(metadata.size).toBe(100);
173
+ expect(metadata.createdAt).toBeDefined();
174
+ });
175
+
176
+ it('should handle upload failure and throw error', async () => {
177
+ const params = {
178
+ content: new ArrayBuffer(10),
179
+ filename: 'test.png',
180
+ hash: 'abc123',
181
+ path: 'uploads/test.png',
182
+ type: 'image/png',
183
+ };
184
+
185
+ mockWriteFile.mockRejectedValue(new Error('Disk full'));
186
+
187
+ await expect(fileService.uploadFile(params)).rejects.toThrow('File upload failed: Disk full');
188
+ });
189
+
190
+ it('should handle file path with no directory', async () => {
191
+ const params = {
192
+ content: new ArrayBuffer(10),
193
+ filename: 'test.txt',
194
+ hash: 'abc',
195
+ path: 'test.txt',
196
+ type: 'text/plain',
197
+ };
198
+
199
+ mockWriteFile.mockResolvedValue(undefined);
200
+
201
+ const result = await fileService.uploadFile(params);
202
+
203
+ expect(result.success).toBe(true);
204
+ expect(result.metadata.dirname).toBe('');
205
+ expect(result.metadata.filename).toBe('test.txt');
206
+ });
207
+ });
208
+
209
+ describe('getFile', () => {
210
+ it('should get file from new path format successfully', async () => {
211
+ const mockContent = Buffer.from('test content');
212
+
213
+ mockFsReadFile.mockImplementation((path: any, callback: any) => {
214
+ callback(null, mockContent);
215
+ });
216
+
217
+ // Mock metadata read failure, will infer from extension
218
+ mockReadFile.mockRejectedValue(new Error('No metadata'));
219
+
220
+ const result = await fileService.getFile('desktop://documents/test.txt');
221
+
222
+ // Since metadata fails, it will use default or infer from extension
223
+ expect(result.mimeType).toBeDefined();
224
+ expect(result.content).toBeDefined();
225
+ });
226
+
227
+ it('should get file from legacy path format (timestamp directory)', async () => {
228
+ const mockContent = Buffer.from('legacy content');
229
+
230
+ mockFsReadFile.mockImplementation((path: any, callback: any) => {
231
+ callback(null, mockContent);
232
+ });
233
+
234
+ // Mock metadata read to succeed this time
235
+ mockReadFile.mockResolvedValue(JSON.stringify({ type: 'image/png' }));
236
+
237
+ const result = await fileService.getFile('desktop://1234567890/abc123.png');
238
+
239
+ // Check that result is returned
240
+ expect(result.mimeType).toBeDefined();
241
+ expect(result.content).toBeDefined();
242
+ });
243
+
244
+ it('should fallback from legacy to new path on failure', async () => {
245
+ const mockContent = Buffer.from('fallback content');
246
+
247
+ let callCount = 0;
248
+ mockFsReadFile.mockImplementation((path: any, callback: any) => {
249
+ callCount++;
250
+ if (callCount === 1) {
251
+ // First read (legacy) fails
252
+ const error: any = new Error('ENOENT');
253
+ error.code = 'ENOENT';
254
+ callback(error, null);
255
+ } else {
256
+ // Second read (fallback) succeeds
257
+ callback(null, mockContent);
258
+ }
259
+ });
260
+
261
+ mockReadFile.mockRejectedValue(new Error('No metadata'));
262
+
263
+ const result = await fileService.getFile('desktop://1234567890/fallback.jpg');
264
+
265
+ // Check that fallback worked and result is returned
266
+ expect(result.content).toBeDefined();
267
+ expect(result.mimeType).toBeDefined();
268
+ });
269
+
270
+ it('should infer MIME type from file extension when metadata missing', async () => {
271
+ const mockContent = Buffer.from('image data');
272
+
273
+ mockFsReadFile.mockImplementation((path: any, callback: any) => {
274
+ callback(null, mockContent);
275
+ });
276
+
277
+ mockReadFile.mockRejectedValue(new Error('Metadata not found'));
278
+
279
+ const result = await fileService.getFile('desktop://images/photo.png');
280
+
281
+ expect(result.mimeType).toBe('image/png');
282
+ });
283
+
284
+ it('should infer correct MIME types for various image formats', async () => {
285
+ const mockContent = Buffer.from('image');
286
+
287
+ const testCases = [
288
+ { path: 'desktop://test.jpg', expected: 'image/jpeg' },
289
+ { path: 'desktop://test.jpeg', expected: 'image/jpeg' },
290
+ { path: 'desktop://test.gif', expected: 'image/gif' },
291
+ { path: 'desktop://test.webp', expected: 'image/webp' },
292
+ { path: 'desktop://test.svg', expected: 'image/svg+xml' },
293
+ { path: 'desktop://test.pdf', expected: 'application/pdf' },
294
+ ];
295
+
296
+ mockFsReadFile.mockImplementation((path: any, callback: any) => {
297
+ callback(null, mockContent);
298
+ });
299
+
300
+ for (const testCase of testCases) {
301
+ mockReadFile.mockRejectedValue(new Error('No metadata'));
302
+
303
+ const result = await fileService.getFile(testCase.path);
304
+ expect(result.mimeType).toBe(testCase.expected);
305
+ }
306
+ });
307
+
308
+ it('should use default MIME type for unknown extensions', async () => {
309
+ const mockContent = Buffer.from('unknown');
310
+
311
+ mockFsReadFile.mockImplementation((path: any, callback: any) => {
312
+ callback(null, mockContent);
313
+ });
314
+
315
+ mockReadFile.mockRejectedValue(new Error('No metadata'));
316
+
317
+ const result = await fileService.getFile('desktop://file.unknown');
318
+
319
+ expect(result.mimeType).toBe('application/octet-stream');
320
+ });
321
+
322
+ it('should throw FileNotFoundError when file does not exist', async () => {
323
+ mockFsReadFile.mockImplementation((path: any, callback: any) => {
324
+ const error: any = new Error('ENOENT: no such file');
325
+ error.code = 'ENOENT';
326
+ error.message = 'ENOENT: no such file';
327
+ callback(error, null);
328
+ });
329
+
330
+ await expect(fileService.getFile('desktop://missing/file.txt')).rejects.toThrow(
331
+ FileNotFoundError,
332
+ );
333
+ });
334
+
335
+ it('should throw error for invalid path without desktop:// prefix', async () => {
336
+ await expect(fileService.getFile('/invalid/path.txt')).rejects.toThrow(
337
+ 'Invalid desktop file path',
338
+ );
339
+ });
340
+ });
341
+
342
+ describe('deleteFile', () => {
343
+ it('should delete file from new path format successfully', async () => {
344
+ mockFsUnlink.mockImplementation((path: any, callback: any) => {
345
+ callback(null);
346
+ });
347
+
348
+ const result = await fileService.deleteFile('desktop://documents/test.txt');
349
+
350
+ expect(result.success).toBe(true);
351
+ });
352
+
353
+ it('should delete file from legacy path format', async () => {
354
+ mockFsUnlink.mockImplementation((path: any, callback: any) => {
355
+ callback(null);
356
+ });
357
+
358
+ const result = await fileService.deleteFile('desktop://1234567890/file.png');
359
+
360
+ expect(result.success).toBe(true);
361
+ });
362
+
363
+ it('should fallback from legacy to new path on deletion failure', async () => {
364
+ let callCount = 0;
365
+ mockFsUnlink.mockImplementation((path: any, callback: any) => {
366
+ callCount++;
367
+ if (callCount === 1) {
368
+ // First attempt (legacy file) fails
369
+ callback(new Error('ENOENT'));
370
+ } else {
371
+ // All subsequent attempts succeed
372
+ callback(null);
373
+ }
374
+ });
375
+
376
+ const result = await fileService.deleteFile('desktop://1234567890/fallback.txt');
377
+
378
+ expect(result.success).toBe(true);
379
+ });
380
+
381
+ it('should handle metadata deletion failure gracefully', async () => {
382
+ let callCount = 0;
383
+ mockFsUnlink.mockImplementation((path: any, callback: any) => {
384
+ callCount++;
385
+ if (callCount === 1) {
386
+ // File deletion succeeds
387
+ callback(null);
388
+ } else {
389
+ // Metadata deletion fails (but doesn't throw)
390
+ callback(new Error('Metadata not found'));
391
+ }
392
+ });
393
+
394
+ const result = await fileService.deleteFile('desktop://files/test.txt');
395
+
396
+ expect(result.success).toBe(true);
397
+ });
398
+
399
+ it('should throw error when file deletion fails', async () => {
400
+ mockFsUnlink.mockImplementation((path: any, callback: any) => {
401
+ callback(new Error('Permission denied'));
402
+ });
403
+
404
+ await expect(fileService.deleteFile('desktop://protected/file.txt')).rejects.toThrow(
405
+ 'File deletion failed: Permission denied',
406
+ );
407
+ });
408
+
409
+ it('should throw error for invalid path without desktop:// prefix', async () => {
410
+ await expect(fileService.deleteFile('/invalid/path.txt')).rejects.toThrow(
411
+ 'Invalid desktop file path',
412
+ );
413
+ });
414
+ });
415
+
416
+ describe('deleteFiles', () => {
417
+ it('should delete multiple files successfully', async () => {
418
+ mockFsUnlink.mockImplementation((path: any, callback: any) => {
419
+ callback(null);
420
+ });
421
+
422
+ const paths = [
423
+ 'desktop://files/file1.txt',
424
+ 'desktop://files/file2.txt',
425
+ 'desktop://files/file3.txt',
426
+ ];
427
+
428
+ const result = await fileService.deleteFiles(paths);
429
+
430
+ expect(result.success).toBe(true);
431
+ expect(result.errors).toBeUndefined();
432
+ });
433
+
434
+ it('should handle partial failures in batch deletion', async () => {
435
+ let callCount = 0;
436
+ mockFsUnlink.mockImplementation((path: any, callback: any) => {
437
+ callCount++;
438
+ // Fail on a specific file
439
+ if (path.includes('file2.txt') && !path.includes('.meta')) {
440
+ callback(new Error('Permission denied'));
441
+ } else {
442
+ callback(null);
443
+ }
444
+ });
445
+
446
+ const paths = [
447
+ 'desktop://files/file1.txt',
448
+ 'desktop://files/file2.txt',
449
+ 'desktop://files/file3.txt',
450
+ ];
451
+
452
+ const result = await fileService.deleteFiles(paths);
453
+
454
+ expect(result.success).toBe(false);
455
+ expect(result.errors).toBeDefined();
456
+ expect(result.errors?.length).toBeGreaterThan(0);
457
+ });
458
+
459
+ it('should return errors array with failed file paths', async () => {
460
+ mockFsUnlink.mockImplementation((path: any, callback: any) => {
461
+ if (path.includes('file2') && !path.includes('.meta')) {
462
+ callback(new Error('Access denied'));
463
+ } else {
464
+ callback(null);
465
+ }
466
+ });
467
+
468
+ const paths = ['desktop://files/file1.txt', 'desktop://files/file2.txt'];
469
+
470
+ const result = await fileService.deleteFiles(paths);
471
+
472
+ expect(result.success).toBe(false);
473
+ expect(result.errors).toHaveLength(1);
474
+ expect(result.errors?.[0].path).toBe('desktop://files/file2.txt');
475
+ expect(result.errors?.[0].message).toContain('Access denied');
476
+ });
477
+
478
+ it('should handle empty paths array', async () => {
479
+ const result = await fileService.deleteFiles([]);
480
+
481
+ expect(result.success).toBe(true);
482
+ expect(result.errors).toBeUndefined();
483
+ });
484
+ });
485
+
486
+ describe('getFilePath', () => {
487
+ it('should return correct path for new format', async () => {
488
+ mockAccess.mockResolvedValue(undefined);
489
+
490
+ const result = await fileService.getFilePath('desktop://documents/test.txt');
491
+
492
+ expect(result).toBe('/mock/app/storage/file-storage/documents/test.txt');
493
+ });
494
+
495
+ it('should return legacy path when file exists in uploads directory', async () => {
496
+ mockAccess.mockResolvedValue(undefined);
497
+
498
+ const result = await fileService.getFilePath('desktop://1234567890/legacy.png');
499
+
500
+ expect(result).toBe('/mock/app/storage/file-storage/uploads/1234567890/legacy.png');
501
+ });
502
+
503
+ it('should fallback to new path when legacy path does not exist', async () => {
504
+ mockAccess
505
+ .mockRejectedValueOnce(new Error('Not found')) // legacy fails
506
+ .mockResolvedValueOnce(undefined); // fallback succeeds
507
+
508
+ const result = await fileService.getFilePath('desktop://1234567890/migrated.png');
509
+
510
+ // When legacy path doesn't exist and fallback exists, it returns the fallback path
511
+ // But since isLegacyPath returns true for timestamps, and the fallback succeeds,
512
+ // it should update to the fallback path
513
+ expect(result).toContain('1234567890/migrated.png');
514
+ });
515
+
516
+ it('should return legacy path when both paths do not exist', async () => {
517
+ mockAccess
518
+ .mockRejectedValueOnce(new Error('Not found'))
519
+ .mockRejectedValueOnce(new Error('Not found'));
520
+
521
+ const result = await fileService.getFilePath('desktop://1234567890/missing.png');
522
+
523
+ expect(result).toBe('/mock/app/storage/file-storage/uploads/1234567890/missing.png');
524
+ });
525
+
526
+ it('should throw error for invalid path', async () => {
527
+ await expect(fileService.getFilePath('/invalid/path')).rejects.toThrow(
528
+ 'Invalid desktop file path',
529
+ );
530
+ });
531
+ });
532
+
533
+ describe('getFileHTTPURL', () => {
534
+ it('should generate correct HTTP URL for new format', async () => {
535
+ const result = await fileService.getFileHTTPURL('desktop://documents/photo.jpg');
536
+
537
+ expect(result).toBe('http://localhost:3000/lobe-desktop-file/documents/photo.jpg');
538
+ });
539
+
540
+ it('should generate correct HTTP URL for legacy format', async () => {
541
+ const result = await fileService.getFileHTTPURL('desktop://1234567890/image.png');
542
+
543
+ expect(result).toBe('http://localhost:3000/lobe-desktop-file/1234567890/image.png');
544
+ });
545
+
546
+ it('should throw error for invalid path', async () => {
547
+ await expect(fileService.getFileHTTPURL('/invalid/path')).rejects.toThrow(
548
+ 'Invalid desktop file path',
549
+ );
550
+ });
551
+
552
+ it('should handle paths with special characters', async () => {
553
+ const result = await fileService.getFileHTTPURL('desktop://user/my%20file.txt');
554
+
555
+ expect(result).toBe('http://localhost:3000/lobe-desktop-file/user/my%20file.txt');
556
+ });
557
+ });
558
+
559
+ describe('isLegacyPath (via behavior testing)', () => {
560
+ it('should treat timestamp-based paths as legacy', async () => {
561
+ mockAccess.mockResolvedValue(undefined);
562
+
563
+ const result = await fileService.getFilePath('desktop://1234567890/file.txt');
564
+
565
+ // Legacy paths go to uploads directory
566
+ expect(result).toContain('uploads/1234567890/file.txt');
567
+ });
568
+
569
+ it('should treat custom paths as new format', async () => {
570
+ mockAccess.mockResolvedValue(undefined);
571
+
572
+ const result = await fileService.getFilePath('desktop://custom/path/file.txt');
573
+
574
+ expect(result).toContain('file-storage/custom/path/file.txt');
575
+ expect(result).not.toContain('uploads');
576
+ });
577
+
578
+ it('should handle single-level paths correctly', async () => {
579
+ mockAccess.mockResolvedValue(undefined);
580
+
581
+ const result = await fileService.getFilePath('desktop://file.txt');
582
+
583
+ expect(result).toContain('file-storage/file.txt');
584
+ });
585
+ });
586
+
587
+ describe('UPLOADS_DIR getter', () => {
588
+ it('should return correct uploads directory path', () => {
589
+ expect(fileService.UPLOADS_DIR).toBe('/mock/app/storage/file-storage/uploads');
590
+ });
591
+ });
592
+
593
+ describe('FileNotFoundError', () => {
594
+ it('should create error with correct properties', () => {
595
+ const error = new FileNotFoundError('File not found', 'desktop://missing.txt');
596
+
597
+ expect(error.name).toBe('FileNotFoundError');
598
+ expect(error.message).toBe('File not found');
599
+ expect(error.path).toBe('desktop://missing.txt');
600
+ expect(error instanceof Error).toBe(true);
601
+ });
602
+ });
603
+ });
package/changelog/v1.json CHANGED
@@ -1,4 +1,25 @@
1
1
  [
2
+ {
3
+ "children": {
4
+ "features": [
5
+ "Refactor to use kb search tool."
6
+ ]
7
+ },
8
+ "date": "2025-11-21",
9
+ "version": "2.0.0-next.99"
10
+ },
11
+ {
12
+ "children": {
13
+ "fixes": [
14
+ "Fixed changelog pages and open again."
15
+ ],
16
+ "improvements": [
17
+ "Fix some translations."
18
+ ]
19
+ },
20
+ "date": "2025-11-21",
21
+ "version": "2.0.0-next.98"
22
+ },
2
23
  {
3
24
  "children": {
4
25
  "improvements": [
package/codecov.yml CHANGED
@@ -32,6 +32,7 @@ coverage:
32
32
  app:
33
33
  flags:
34
34
  - app
35
+ threshold: 0.5
35
36
  patch: off
36
37
 
37
38
 
@@ -214,6 +214,7 @@ table files {
214
214
  metadata jsonb
215
215
  chunk_task_id uuid
216
216
  embedding_task_id uuid
217
+ slug text [unique]
217
218
  accessed_at "timestamp with time zone" [not null, default: `now()`]
218
219
  created_at "timestamp with time zone" [not null, default: `now()`]
219
220
  updated_at "timestamp with time zone" [not null, default: `now()`]
package/e2e/package.json CHANGED
@@ -17,7 +17,7 @@
17
17
  "playwright": "^1.56.1"
18
18
  },
19
19
  "devDependencies": {
20
- "@types/node": "^22.19.1",
20
+ "@types/node": "^24.10.1",
21
21
  "tsx": "^4.20.6",
22
22
  "typescript": "^5.9.3"
23
23
  }