diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0ba79db7b1..d4033540ee 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -12,11 +12,11 @@ "main": "./dist/main/index.js", "scripts": { "build": "electron-vite build", - "build-local": "npm run build && electron-builder --dir --config electron-builder.mjs --c.mac.notarize=false -c.mac.identity=null --c.asar=false", "build:linux": "npm run build && electron-builder --linux --config electron-builder.mjs --publish never", "build:mac": "npm run build && electron-builder --mac --config electron-builder.mjs --publish never", "build:mac:local": "npm run build && UPDATE_CHANNEL=nightly electron-builder --mac --config electron-builder.mjs --publish never", "build:win": "npm run build && electron-builder --win --config electron-builder.mjs --publish never", + "build-local": "npm run build && electron-builder --dir --config electron-builder.mjs --c.mac.notarize=false -c.mac.identity=null --c.asar=false", "dev": "electron-vite dev", "dev:static": "cross-env DESKTOP_RENDERER_STATIC=1 npm run electron:dev", "electron:dev": "electron-vite dev", @@ -47,9 +47,6 @@ "get-port-please": "^3.2.0", "superjson": "^2.2.6" }, - "optionalDependencies": { - "node-mac-permissions": "^2.5.0" - }, "devDependencies": { "@electron-toolkit/eslint-config-prettier": "^3.0.0", "@electron-toolkit/eslint-config-ts": "^3.1.0", @@ -103,6 +100,9 @@ "vitest": "^3.2.4", "zod": "^3.25.76" }, + "optionalDependencies": { + "node-mac-permissions": "^2.5.0" + }, "pnpm": { "onlyBuiltDependencies": [ "@napi-rs/canvas", diff --git a/apps/desktop/src/main/controllers/LocalFileCtr.ts b/apps/desktop/src/main/controllers/LocalFileCtr.ts index d7404fc176..23142c7d41 100644 --- a/apps/desktop/src/main/controllers/LocalFileCtr.ts +++ b/apps/desktop/src/main/controllers/LocalFileCtr.ts @@ -218,8 +218,13 @@ export default class LocalFileCtr extends ControllerModule { } @IpcMethod() - async listLocalFiles({ path: dirPath }: ListLocalFileParams): Promise { - logger.debug('Listing directory contents:', { dirPath }); + async listLocalFiles({ + path: dirPath, + sortBy = 'modifiedTime', + sortOrder = 'desc', + limit = 100, + }: ListLocalFileParams): Promise<{ files: FileResult[]; totalCount: number }> { + logger.debug('Listing directory contents:', { dirPath, limit, sortBy, sortOrder }); const results: FileResult[] = []; try { @@ -256,22 +261,51 @@ export default class LocalFileCtr extends ControllerModule { } } - // Sort entries: folders first, then by name + // Sort entries based on sortBy and sortOrder results.sort((a, b) => { - if (a.isDirectory !== b.isDirectory) { - return a.isDirectory ? -1 : 1; // Directories first + let comparison = 0; + + switch (sortBy) { + case 'name': { + comparison = (a.name || '').localeCompare(b.name || ''); + break; + } + case 'modifiedTime': { + comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime(); + break; + } + case 'createdTime': { + comparison = a.createdTime.getTime() - b.createdTime.getTime(); + break; + } + case 'size': { + comparison = a.size - b.size; + break; + } + default: { + comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime(); + } } - // Add null/undefined checks for robustness if needed, though names should exist - return (a.name || '').localeCompare(b.name || ''); // Then sort by name + + return sortOrder === 'desc' ? -comparison : comparison; }); - logger.debug('Directory listing successful', { dirPath, resultCount: results.length }); - return results; + const totalCount = results.length; + + // Apply limit + const limitedResults = results.slice(0, limit); + + logger.debug('Directory listing successful', { + dirPath, + resultCount: limitedResults.length, + totalCount, + }); + return { files: limitedResults, totalCount }; } catch (error) { logger.error(`Failed to list directory ${dirPath}:`, error); // Rethrow or return an empty array/error object depending on desired behavior - // For now, returning empty array on error listing directory itself - return []; + // For now, returning empty result on error listing directory itself + return { files: [], totalCount: 0 }; } } diff --git a/apps/desktop/src/main/controllers/__tests__/LocalFileCtr.test.ts b/apps/desktop/src/main/controllers/__tests__/LocalFileCtr.test.ts index a9717114ed..2a416b79d7 100644 --- a/apps/desktop/src/main/controllers/__tests__/LocalFileCtr.test.ts +++ b/apps/desktop/src/main/controllers/__tests__/LocalFileCtr.test.ts @@ -20,8 +20,8 @@ vi.mock('@/utils/logger', () => ({ // Mock file-loaders vi.mock('@lobechat/file-loaders', () => ({ - SYSTEM_FILES_TO_IGNORE: ['.DS_Store', 'Thumbs.db'], loadFile: vi.fn(), + SYSTEM_FILES_TO_IGNORE: ['.DS_Store', 'Thumbs.db', '$RECYCLE.BIN'], })); // Mock electron @@ -553,6 +553,514 @@ describe('LocalFileCtr', () => { }); }); + describe('listLocalFiles', () => { + it('should list directory contents successfully', async () => { + vi.mocked(mockFsPromises.readdir).mockResolvedValue(['file1.txt', 'file2.txt', 'folder1']); + vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => { + const name = (filePath as string).split('/').pop(); + if (name === 'folder1') { + return { + isDirectory: () => true, + birthtime: new Date('2024-01-01'), + mtime: new Date('2024-01-15'), + atime: new Date('2024-01-20'), + size: 4096, + } as any; + } + return { + isDirectory: () => false, + birthtime: new Date('2024-01-02'), + mtime: new Date('2024-01-10'), + atime: new Date('2024-01-18'), + size: 1024, + } as any; + }); + + const result = await localFileCtr.listLocalFiles({ path: '/test' }); + + expect(result.files).toHaveLength(3); + expect(result.totalCount).toBe(3); + expect(mockFsPromises.readdir).toHaveBeenCalledWith('/test'); + }); + + it('should filter out system files like .DS_Store and Thumbs.db', async () => { + vi.mocked(mockFsPromises.readdir).mockResolvedValue([ + 'file1.txt', + '.DS_Store', + 'Thumbs.db', + 'folder1', + ]); + vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => { + const name = (filePath as string).split('/').pop(); + if (name === 'folder1') { + return { + isDirectory: () => true, + birthtime: new Date('2024-01-01'), + mtime: new Date('2024-01-15'), + atime: new Date('2024-01-20'), + size: 4096, + } as any; + } + return { + isDirectory: () => false, + birthtime: new Date('2024-01-02'), + mtime: new Date('2024-01-10'), + atime: new Date('2024-01-18'), + size: 1024, + } as any; + }); + + const result = await localFileCtr.listLocalFiles({ path: '/test' }); + + // Should only contain file1.txt and folder1, not .DS_Store or Thumbs.db + expect(result.files).toHaveLength(2); + expect(result.totalCount).toBe(2); + expect(result.files.map((r) => r.name)).not.toContain('.DS_Store'); + expect(result.files.map((r) => r.name)).not.toContain('Thumbs.db'); + expect(result.files.map((r) => r.name)).toContain('folder1'); + expect(result.files.map((r) => r.name)).toContain('file1.txt'); + }); + + it('should filter out $RECYCLE.BIN system folder', async () => { + vi.mocked(mockFsPromises.readdir).mockResolvedValue(['file1.txt', '$RECYCLE.BIN', 'folder1']); + vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => { + const name = (filePath as string).split('/').pop(); + const isDir = name === 'folder1' || name === '$RECYCLE.BIN'; + return { + isDirectory: () => isDir, + birthtime: new Date('2024-01-01'), + mtime: new Date('2024-01-15'), + atime: new Date('2024-01-20'), + size: isDir ? 4096 : 1024, + } as any; + }); + + const result = await localFileCtr.listLocalFiles({ path: '/test' }); + + // Should not contain $RECYCLE.BIN + expect(result.files).toHaveLength(2); + expect(result.totalCount).toBe(2); + expect(result.files.map((r) => r.name)).not.toContain('$RECYCLE.BIN'); + }); + + it('should sort by name ascending when specified', async () => { + vi.mocked(mockFsPromises.readdir).mockResolvedValue(['zebra.txt', 'alpha.txt', 'apple.txt']); + vi.mocked(mockFsPromises.stat).mockResolvedValue({ + isDirectory: () => false, + birthtime: new Date('2024-01-01'), + mtime: new Date('2024-01-15'), + atime: new Date('2024-01-20'), + size: 1024, + } as any); + + const result = await localFileCtr.listLocalFiles({ + path: '/test', + sortBy: 'name', + sortOrder: 'asc', + }); + + expect(result.files.map((r) => r.name)).toEqual(['alpha.txt', 'apple.txt', 'zebra.txt']); + }); + + it('should sort by modifiedTime descending by default', async () => { + vi.mocked(mockFsPromises.readdir).mockResolvedValue(['old.txt', 'new.txt', 'mid.txt']); + vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => { + const name = (filePath as string).split('/').pop(); + const dates: Record = { + 'new.txt': new Date('2024-01-20'), + 'mid.txt': new Date('2024-01-15'), + 'old.txt': new Date('2024-01-01'), + }; + return { + isDirectory: () => false, + birthtime: new Date('2024-01-01'), + mtime: dates[name!] || new Date('2024-01-01'), + atime: new Date('2024-01-20'), + size: 1024, + } as any; + }); + + const result = await localFileCtr.listLocalFiles({ path: '/test' }); + + // Default sort: modifiedTime descending (newest first) + expect(result.files.map((r) => r.name)).toEqual(['new.txt', 'mid.txt', 'old.txt']); + }); + + it('should sort by size ascending when specified', async () => { + vi.mocked(mockFsPromises.readdir).mockResolvedValue(['large.txt', 'small.txt', 'medium.txt']); + vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => { + const name = (filePath as string).split('/').pop(); + const sizes: Record = { + 'large.txt': 10000, + 'medium.txt': 5000, + 'small.txt': 1000, + }; + return { + isDirectory: () => false, + birthtime: new Date('2024-01-01'), + mtime: new Date('2024-01-15'), + atime: new Date('2024-01-20'), + size: sizes[name!] || 1024, + } as any; + }); + + const result = await localFileCtr.listLocalFiles({ + path: '/test', + sortBy: 'size', + sortOrder: 'asc', + }); + + expect(result.files.map((r) => r.name)).toEqual(['small.txt', 'medium.txt', 'large.txt']); + }); + + it('should apply limit parameter', async () => { + vi.mocked(mockFsPromises.readdir).mockResolvedValue([ + 'file1.txt', + 'file2.txt', + 'file3.txt', + 'file4.txt', + 'file5.txt', + ]); + vi.mocked(mockFsPromises.stat).mockResolvedValue({ + isDirectory: () => false, + birthtime: new Date('2024-01-01'), + mtime: new Date('2024-01-15'), + atime: new Date('2024-01-20'), + size: 1024, + } as any); + + const result = await localFileCtr.listLocalFiles({ + path: '/test', + limit: 3, + }); + + expect(result.files).toHaveLength(3); + expect(result.totalCount).toBe(5); // Total is 5, but limited to 3 + }); + + it('should use default limit of 100', async () => { + // Create 150 files + const files = Array.from({ length: 150 }, (_, i) => `file${i}.txt`); + vi.mocked(mockFsPromises.readdir).mockResolvedValue(files); + vi.mocked(mockFsPromises.stat).mockResolvedValue({ + isDirectory: () => false, + birthtime: new Date('2024-01-01'), + mtime: new Date('2024-01-15'), + atime: new Date('2024-01-20'), + size: 1024, + } as any); + + const result = await localFileCtr.listLocalFiles({ path: '/test' }); + + expect(result.files).toHaveLength(100); + expect(result.totalCount).toBe(150); // Total is 150, but limited to 100 + }); + + it('should sort by createdTime ascending when specified', async () => { + vi.mocked(mockFsPromises.readdir).mockResolvedValue([ + 'newest.txt', + 'oldest.txt', + 'middle.txt', + ]); + vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => { + const name = (filePath as string).split('/').pop(); + const dates: Record = { + 'newest.txt': new Date('2024-03-01'), + 'middle.txt': new Date('2024-02-01'), + 'oldest.txt': new Date('2024-01-01'), + }; + return { + isDirectory: () => false, + birthtime: dates[name!] || new Date('2024-01-01'), + mtime: new Date('2024-01-15'), + atime: new Date('2024-01-20'), + size: 1024, + } as any; + }); + + const result = await localFileCtr.listLocalFiles({ + path: '/test', + sortBy: 'createdTime', + sortOrder: 'asc', + }); + + expect(result.files.map((r) => r.name)).toEqual(['oldest.txt', 'middle.txt', 'newest.txt']); + }); + + it('should sort by createdTime descending when specified', async () => { + vi.mocked(mockFsPromises.readdir).mockResolvedValue([ + 'newest.txt', + 'oldest.txt', + 'middle.txt', + ]); + vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => { + const name = (filePath as string).split('/').pop(); + const dates: Record = { + 'newest.txt': new Date('2024-03-01'), + 'middle.txt': new Date('2024-02-01'), + 'oldest.txt': new Date('2024-01-01'), + }; + return { + isDirectory: () => false, + birthtime: dates[name!] || new Date('2024-01-01'), + mtime: new Date('2024-01-15'), + atime: new Date('2024-01-20'), + size: 1024, + } as any; + }); + + const result = await localFileCtr.listLocalFiles({ + path: '/test', + sortBy: 'createdTime', + sortOrder: 'desc', + }); + + expect(result.files.map((r) => r.name)).toEqual(['newest.txt', 'middle.txt', 'oldest.txt']); + }); + + it('should sort by name descending when specified', async () => { + vi.mocked(mockFsPromises.readdir).mockResolvedValue(['alpha.txt', 'zebra.txt', 'middle.txt']); + vi.mocked(mockFsPromises.stat).mockResolvedValue({ + isDirectory: () => false, + birthtime: new Date('2024-01-01'), + mtime: new Date('2024-01-15'), + atime: new Date('2024-01-20'), + size: 1024, + } as any); + + const result = await localFileCtr.listLocalFiles({ + path: '/test', + sortBy: 'name', + sortOrder: 'desc', + }); + + expect(result.files.map((r) => r.name)).toEqual(['zebra.txt', 'middle.txt', 'alpha.txt']); + }); + + it('should sort by size descending when specified', async () => { + vi.mocked(mockFsPromises.readdir).mockResolvedValue(['small.txt', 'large.txt', 'medium.txt']); + vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => { + const name = (filePath as string).split('/').pop(); + const sizes: Record = { + 'large.txt': 10000, + 'medium.txt': 5000, + 'small.txt': 1000, + }; + return { + isDirectory: () => false, + birthtime: new Date('2024-01-01'), + mtime: new Date('2024-01-15'), + atime: new Date('2024-01-20'), + size: sizes[name!] || 1024, + } as any; + }); + + const result = await localFileCtr.listLocalFiles({ + path: '/test', + sortBy: 'size', + sortOrder: 'desc', + }); + + expect(result.files.map((r) => r.name)).toEqual(['large.txt', 'medium.txt', 'small.txt']); + }); + + it('should sort by modifiedTime ascending when specified', async () => { + vi.mocked(mockFsPromises.readdir).mockResolvedValue(['old.txt', 'new.txt', 'mid.txt']); + vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => { + const name = (filePath as string).split('/').pop(); + const dates: Record = { + 'new.txt': new Date('2024-01-20'), + 'mid.txt': new Date('2024-01-15'), + 'old.txt': new Date('2024-01-01'), + }; + return { + isDirectory: () => false, + birthtime: new Date('2024-01-01'), + mtime: dates[name!] || new Date('2024-01-01'), + atime: new Date('2024-01-20'), + size: 1024, + } as any; + }); + + const result = await localFileCtr.listLocalFiles({ + path: '/test', + sortBy: 'modifiedTime', + sortOrder: 'asc', + }); + + expect(result.files.map((r) => r.name)).toEqual(['old.txt', 'mid.txt', 'new.txt']); + }); + + it('should handle empty directory with sort options', async () => { + vi.mocked(mockFsPromises.readdir).mockResolvedValue([]); + + const result = await localFileCtr.listLocalFiles({ + path: '/empty', + sortBy: 'name', + sortOrder: 'asc', + }); + + expect(result.files).toEqual([]); + expect(result.totalCount).toBe(0); + }); + + it('should apply limit after sorting', async () => { + vi.mocked(mockFsPromises.readdir).mockResolvedValue([ + 'file1.txt', + 'file2.txt', + 'file3.txt', + 'file4.txt', + 'file5.txt', + ]); + vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => { + const name = (filePath as string).split('/').pop(); + const dates: Record = { + 'file1.txt': new Date('2024-01-01'), + 'file2.txt': new Date('2024-01-02'), + 'file3.txt': new Date('2024-01-03'), + 'file4.txt': new Date('2024-01-04'), + 'file5.txt': new Date('2024-01-05'), + }; + return { + isDirectory: () => false, + birthtime: new Date('2024-01-01'), + mtime: dates[name!] || new Date('2024-01-01'), + atime: new Date('2024-01-20'), + size: 1024, + } as any; + }); + + // Sort by modifiedTime desc (default) and limit to 3 + const result = await localFileCtr.listLocalFiles({ + path: '/test', + limit: 3, + }); + + // Should get the 3 newest files + expect(result.files).toHaveLength(3); + expect(result.totalCount).toBe(5); // Total is 5, but limited to 3 + expect(result.files.map((r) => r.name)).toEqual(['file5.txt', 'file4.txt', 'file3.txt']); + }); + + it('should handle limit larger than file count', async () => { + vi.mocked(mockFsPromises.readdir).mockResolvedValue(['file1.txt', 'file2.txt']); + vi.mocked(mockFsPromises.stat).mockResolvedValue({ + isDirectory: () => false, + birthtime: new Date('2024-01-01'), + mtime: new Date('2024-01-15'), + atime: new Date('2024-01-20'), + size: 1024, + } as any); + + const result = await localFileCtr.listLocalFiles({ + path: '/test', + limit: 1000, + }); + + expect(result.files).toHaveLength(2); + expect(result.totalCount).toBe(2); + }); + + it('should return file metadata including size, times and type', async () => { + const createdTime = new Date('2024-01-01'); + const modifiedTime = new Date('2024-01-15'); + const accessTime = new Date('2024-01-20'); + + vi.mocked(mockFsPromises.readdir).mockResolvedValue(['document.pdf']); + vi.mocked(mockFsPromises.stat).mockResolvedValue({ + isDirectory: () => false, + birthtime: createdTime, + mtime: modifiedTime, + atime: accessTime, + size: 2048, + } as any); + + const result = await localFileCtr.listLocalFiles({ path: '/test' }); + + expect(result.files).toHaveLength(1); + expect(result.totalCount).toBe(1); + expect(result.files[0]).toEqual({ + name: 'document.pdf', + path: '/test/document.pdf', + isDirectory: false, + size: 2048, + type: 'pdf', + createdTime, + modifiedTime, + lastAccessTime: accessTime, + }); + }); + + it('should return empty result when directory read fails', async () => { + vi.mocked(mockFsPromises.readdir).mockRejectedValue(new Error('Permission denied')); + + const result = await localFileCtr.listLocalFiles({ path: '/protected' }); + + expect(result.files).toEqual([]); + expect(result.totalCount).toBe(0); + }); + + it('should skip files that cannot be stat', async () => { + vi.mocked(mockFsPromises.readdir).mockResolvedValue(['good.txt', 'bad.txt']); + vi.mocked(mockFsPromises.stat).mockImplementation(async (filePath) => { + if ((filePath as string).includes('bad.txt')) { + throw new Error('Cannot stat file'); + } + return { + isDirectory: () => false, + birthtime: new Date('2024-01-01'), + mtime: new Date('2024-01-15'), + atime: new Date('2024-01-20'), + size: 1024, + } as any; + }); + + const result = await localFileCtr.listLocalFiles({ path: '/test' }); + + // Should only contain good.txt, bad.txt should be skipped + expect(result.files).toHaveLength(1); + expect(result.totalCount).toBe(1); + expect(result.files[0].name).toBe('good.txt'); + }); + + it('should handle directory type correctly', async () => { + vi.mocked(mockFsPromises.readdir).mockResolvedValue(['my_folder']); + vi.mocked(mockFsPromises.stat).mockResolvedValue({ + isDirectory: () => true, + birthtime: new Date('2024-01-01'), + mtime: new Date('2024-01-15'), + atime: new Date('2024-01-20'), + size: 4096, + } as any); + + const result = await localFileCtr.listLocalFiles({ path: '/test' }); + + expect(result.files).toHaveLength(1); + expect(result.totalCount).toBe(1); + expect(result.files[0].isDirectory).toBe(true); + expect(result.files[0].type).toBe('directory'); + }); + + it('should handle files without extension', async () => { + vi.mocked(mockFsPromises.readdir).mockResolvedValue(['Makefile', 'README']); + vi.mocked(mockFsPromises.stat).mockResolvedValue({ + isDirectory: () => false, + birthtime: new Date('2024-01-01'), + mtime: new Date('2024-01-15'), + atime: new Date('2024-01-20'), + size: 512, + } as any); + + const result = await localFileCtr.listLocalFiles({ path: '/test' }); + + expect(result.files).toHaveLength(2); + expect(result.totalCount).toBe(2); + // Files without extension should have empty type + expect(result.files[0].type).toBe(''); + expect(result.files[1].type).toBe(''); + }); + }); + describe('handleGrepContent', () => { it('should search content in a single file', async () => { vi.mocked(mockFsPromises.stat).mockResolvedValue({ diff --git a/locales/en-US/plugin.json b/locales/en-US/plugin.json index 65d32fede4..f2306e205a 100644 --- a/locales/en-US/plugin.json +++ b/locales/en-US/plugin.json @@ -1,4 +1,5 @@ { + "arguments.moreParams": "{{count}} params in total", "arguments.title": "Arguments", "builtins.lobe-agent-builder.apiName.getAvailableModels": "Get available models", "builtins.lobe-agent-builder.apiName.getAvailableTools": "Get available Skills", diff --git a/locales/zh-CN/plugin.json b/locales/zh-CN/plugin.json index a3b22ce2e6..687a381276 100644 --- a/locales/zh-CN/plugin.json +++ b/locales/zh-CN/plugin.json @@ -1,4 +1,5 @@ { + "arguments.moreParams": "等 {{count}} 个参数", "arguments.title": "参数列表", "builtins.lobe-agent-builder.apiName.getAvailableModels": "获取可用模型", "builtins.lobe-agent-builder.apiName.getAvailableTools": "获取可用技能", diff --git a/packages/builtin-tool-cloud-sandbox/src/ExecutionRuntime/index.ts b/packages/builtin-tool-cloud-sandbox/src/ExecutionRuntime/index.ts index a7a7234b91..750e11615e 100644 --- a/packages/builtin-tool-cloud-sandbox/src/ExecutionRuntime/index.ts +++ b/packages/builtin-tool-cloud-sandbox/src/ExecutionRuntime/index.ts @@ -76,13 +76,13 @@ export class CloudSandboxExecutionRuntime { const files = result.result?.files || []; const state: ListLocalFilesState = { files }; - const content = formatFileList( - files.map((f: { isDirectory: boolean; name: string }) => ({ + const content = formatFileList({ + directory: args.directoryPath, + files: files.map((f: { isDirectory: boolean; name: string }) => ({ isDirectory: f.isDirectory, name: f.name, })), - args.directoryPath, - ); + }); return { content, diff --git a/packages/builtin-tool-local-system/package.json b/packages/builtin-tool-local-system/package.json index befcba2947..86acf9f575 100644 --- a/packages/builtin-tool-local-system/package.json +++ b/packages/builtin-tool-local-system/package.json @@ -5,8 +5,7 @@ "exports": { ".": "./src/index.ts", "./client": "./src/client/index.ts", - "./executor": "./src/executor/index.ts", - "./executionRuntime": "./src/ExecutionRuntime/index.ts" + "./executor": "./src/executor/index.ts" }, "main": "./src/index.ts", "dependencies": { diff --git a/packages/builtin-tool-local-system/src/ExecutionRuntime/index.ts b/packages/builtin-tool-local-system/src/ExecutionRuntime/index.ts deleted file mode 100644 index 566a5e13b5..0000000000 --- a/packages/builtin-tool-local-system/src/ExecutionRuntime/index.ts +++ /dev/null @@ -1,466 +0,0 @@ -import { - EditLocalFileParams, - EditLocalFileResult, - GetCommandOutputParams, - GetCommandOutputResult, - GlobFilesParams, - GlobFilesResult, - GrepContentParams, - GrepContentResult, - KillCommandParams, - KillCommandResult, - ListLocalFileParams, - LocalFileItem, - LocalMoveFilesResultItem, - LocalReadFileParams, - LocalReadFileResult, - LocalReadFilesParams, - LocalSearchFilesParams, - MoveLocalFilesParams, - RenameLocalFileParams, - RenameLocalFileResult, - RunCommandParams, - RunCommandResult, - WriteLocalFileParams, -} from '@lobechat/electron-client-ipc'; -import { BuiltinServerRuntimeOutput } from '@lobechat/types'; - -import type { - EditLocalFileState, - GetCommandOutputState, - GlobFilesState, - GrepContentState, - KillCommandState, - LocalFileListState, - LocalFileSearchState, - LocalMoveFilesState, - LocalReadFileState, - LocalReadFilesState, - LocalRenameFileState, - RunCommandState, -} from '../types'; - -interface LocalFileService { - editLocalFile: (params: EditLocalFileParams) => Promise; - getCommandOutput: (params: GetCommandOutputParams) => Promise; - globFiles: (params: GlobFilesParams) => Promise; - grepContent: (params: GrepContentParams) => Promise; - killCommand: (params: KillCommandParams) => Promise; - listLocalFiles: (params: ListLocalFileParams) => Promise; - moveLocalFiles: (params: MoveLocalFilesParams) => Promise; - readLocalFile: (params: LocalReadFileParams) => Promise; - readLocalFiles: (params: LocalReadFilesParams) => Promise; - renameLocalFile: (params: RenameLocalFileParams) => Promise; - runCommand: (params: RunCommandParams) => Promise; - searchLocalFiles: (params: LocalSearchFilesParams) => Promise; - writeFile: (params: WriteLocalFileParams) => Promise<{ error?: string; success: boolean }>; -} - -export class LocalSystemExecutionRuntime { - private localFileService: LocalFileService; - - constructor(localFileService: LocalFileService) { - this.localFileService = localFileService; - } - - // ==================== File Operations ==================== - - async listLocalFiles(args: ListLocalFileParams): Promise { - try { - const result: LocalFileItem[] = await this.localFileService.listLocalFiles(args); - - const state: LocalFileListState = { listResults: result }; - - const fileList = result.map((f) => ` ${f.isDirectory ? '[D]' : '[F]'} ${f.name}`).join('\n'); - const content = - result.length > 0 - ? `Found ${result.length} item(s) in ${args.path}:\n${fileList}` - : `Directory ${args.path} is empty`; - - return { - content, - state, - success: true, - }; - } catch (error) { - return { - content: (error as Error).message, - error, - success: false, - }; - } - } - - async readLocalFile(args: LocalReadFileParams): Promise { - try { - const result: LocalReadFileResult = await this.localFileService.readLocalFile(args); - - const state: LocalReadFileState = { fileContent: result }; - - const lineInfo = args.loc ? ` (lines ${args.loc[0]}-${args.loc[1]})` : ''; - const content = `File: ${args.path}${lineInfo}\n\n${result.content}`; - - return { - content, - state, - success: true, - }; - } catch (error) { - return { - content: (error as Error).message, - error, - success: false, - }; - } - } - - async readLocalFiles(args: LocalReadFilesParams): Promise { - try { - const results: LocalReadFileResult[] = await this.localFileService.readLocalFiles(args); - - const state: LocalReadFilesState = { filesContent: results }; - - const fileContents = results.map((r) => `=== ${r.filename} ===\n${r.content}`).join('\n\n'); - const content = `Read ${results.length} file(s):\n\n${fileContents}`; - - return { - content, - state, - success: true, - }; - } catch (error) { - return { - content: (error as Error).message, - error, - success: false, - }; - } - } - - async searchLocalFiles(args: LocalSearchFilesParams): Promise { - try { - const result: LocalFileItem[] = await this.localFileService.searchLocalFiles(args); - - const state: LocalFileSearchState = { searchResults: result }; - - const fileList = result.map((f) => ` ${f.path}`).join('\n'); - const content = - result.length > 0 ? `Found ${result.length} file(s):\n${fileList}` : 'No files found'; - - return { - content, - state, - success: true, - }; - } catch (error) { - return { - content: (error as Error).message, - error, - success: false, - }; - } - } - - async moveLocalFiles(args: MoveLocalFilesParams): Promise { - try { - const results: LocalMoveFilesResultItem[] = await this.localFileService.moveLocalFiles(args); - - const allSucceeded = results.every((r) => r.success); - const someFailed = results.some((r) => !r.success); - const successCount = results.filter((r) => r.success).length; - const failedCount = results.length - successCount; - - let content = ''; - - if (allSucceeded) { - content = `Successfully moved ${results.length} item(s).`; - } else if (someFailed) { - content = `Moved ${successCount} item(s) successfully. Failed to move ${failedCount} item(s).`; - } else { - content = `Failed to move all ${results.length} item(s).`; - } - - const state: LocalMoveFilesState = { - results, - successCount, - totalCount: results.length, - }; - - return { - content, - state, - success: true, - }; - } catch (error) { - return { - content: (error as Error).message, - error, - success: false, - }; - } - } - - async renameLocalFile(args: RenameLocalFileParams): Promise { - try { - const result: RenameLocalFileResult = await this.localFileService.renameLocalFile(args); - - if (!result.success) { - const state: LocalRenameFileState = { - error: result.error, - newPath: '', - oldPath: args.path, - success: false, - }; - - return { - content: `Failed to rename file: ${result.error}`, - state, - success: false, - }; - } - - const state: LocalRenameFileState = { - newPath: result.newPath!, - oldPath: args.path, - success: true, - }; - - return { - content: `Successfully renamed file ${args.path} to ${args.newName}`, - state, - success: true, - }; - } catch (error) { - return { - content: (error as Error).message, - error, - success: false, - }; - } - } - - async writeLocalFile(args: WriteLocalFileParams): Promise { - try { - const result = await this.localFileService.writeFile(args); - - if (!result.success) { - return { - content: `Failed to write file: ${result.error || 'Unknown error'}`, - error: result.error, - success: false, - }; - } - - return { - content: `Successfully wrote to ${args.path}`, - success: true, - }; - } catch (error) { - return { - content: (error as Error).message, - error, - success: false, - }; - } - } - - async editLocalFile(args: EditLocalFileParams): Promise { - try { - const result: EditLocalFileResult = await this.localFileService.editLocalFile(args); - - if (!result.success) { - return { - content: `Edit failed: ${result.error}`, - success: false, - }; - } - - const statsText = - result.linesAdded || result.linesDeleted - ? ` (+${result.linesAdded || 0} -${result.linesDeleted || 0})` - : ''; - const message = `Successfully replaced ${result.replacements} occurrence(s) in ${args.file_path}${statsText}`; - - const state: EditLocalFileState = { - diffText: result.diffText, - linesAdded: result.linesAdded, - linesDeleted: result.linesDeleted, - replacements: result.replacements, - }; - - return { - content: message, - state, - success: true, - }; - } catch (error) { - return { - content: (error as Error).message, - error, - success: false, - }; - } - } - - // ==================== Shell Commands ==================== - - async runCommand(args: RunCommandParams): Promise { - try { - const result: RunCommandResult = await this.localFileService.runCommand(args); - - const parts: string[] = []; - - if (result.success) { - if (result.shell_id) { - parts.push(`Command started in background with shell_id: ${result.shell_id}`); - } else { - parts.push('Command completed successfully.'); - } - } else { - parts.push(`Command failed: ${result.error}`); - } - - if (result.stdout) parts.push(`Output:\n${result.stdout}`); - if (result.stderr) parts.push(`Stderr:\n${result.stderr}`); - if (result.exit_code !== undefined) parts.push(`Exit code: ${result.exit_code}`); - - const message = parts[0]; - const content = parts.join('\n\n'); - const state: RunCommandState = { message, result }; - - return { - content, - state, - success: result.success, - }; - } catch (error) { - return { - content: (error as Error).message, - error, - success: false, - }; - } - } - - async getCommandOutput(args: GetCommandOutputParams): Promise { - try { - const result: GetCommandOutputResult = await this.localFileService.getCommandOutput(args); - - const message = result.success - ? `Output retrieved. Running: ${result.running}` - : `Failed: ${result.error}`; - - const parts: string[] = [message]; - if (result.output) parts.push(`Output:\n${result.output}`); - if (result.error) parts.push(`Error: ${result.error}`); - - const state: GetCommandOutputState = { message, result }; - - return { - content: parts.join('\n\n'), - state, - success: result.success, - }; - } catch (error) { - return { - content: (error as Error).message, - error, - success: false, - }; - } - } - - async killCommand(args: KillCommandParams): Promise { - try { - const result: KillCommandResult = await this.localFileService.killCommand(args); - - const message = result.success - ? `Successfully killed shell: ${args.shell_id}` - : `Failed to kill shell: ${result.error}`; - - const state: KillCommandState = { message, result }; - - return { - content: message, - state, - success: result.success, - }; - } catch (error) { - return { - content: (error as Error).message, - error, - success: false, - }; - } - } - - // ==================== Search & Find ==================== - - async grepContent(args: GrepContentParams): Promise { - try { - const result: GrepContentResult = await this.localFileService.grepContent(args); - - const message = result.success - ? `Found ${result.total_matches} matches in ${result.matches.length} locations` - : 'Search failed'; - - const state: GrepContentState = { message, result }; - - let content = message; - if (result.success && result.matches.length > 0) { - const matchList = result.matches - .slice(0, 20) - .map((m) => ` ${m}`) - .join('\n'); - const moreInfo = - result.matches.length > 20 ? `\n ... and ${result.matches.length - 20} more` : ''; - content = `${message}:\n${matchList}${moreInfo}`; - } - - return { - content, - state, - success: result.success, - }; - } catch (error) { - return { - content: (error as Error).message, - error, - success: false, - }; - } - } - - async globLocalFiles(args: GlobFilesParams): Promise { - try { - const result: GlobFilesResult = await this.localFileService.globFiles(args); - - const message = result.success ? `Found ${result.total_files} files` : 'Glob search failed'; - - const state: GlobFilesState = { message, result }; - - let content = message; - if (result.success && result.files.length > 0) { - const fileList = result.files - .slice(0, 50) - .map((f) => ` ${f}`) - .join('\n'); - const moreInfo = - result.files.length > 50 ? `\n ... and ${result.files.length - 50} more` : ''; - content = `${message}:\n${fileList}${moreInfo}`; - } - - return { - content, - state, - success: result.success, - }; - } catch (error) { - return { - content: (error as Error).message, - error, - success: false, - }; - } - } -} diff --git a/packages/builtin-tool-local-system/src/client/Inspector/GlobLocalFiles/index.tsx b/packages/builtin-tool-local-system/src/client/Inspector/GlobLocalFiles/index.tsx index 4306ab3524..e3449eb1c8 100644 --- a/packages/builtin-tool-local-system/src/client/Inspector/GlobLocalFiles/index.tsx +++ b/packages/builtin-tool-local-system/src/client/Inspector/GlobLocalFiles/index.tsx @@ -2,8 +2,8 @@ import { type GlobFilesParams } from '@lobechat/electron-client-ipc'; import { type BuiltinInspectorProps } from '@lobechat/types'; -import { createStaticStyles, cssVar, cx } from 'antd-style'; -import { Check, X } from 'lucide-react'; +import { Text } from '@lobehub/ui'; +import { cssVar, cx } from 'antd-style'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -11,13 +11,6 @@ import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/sty import { type GlobFilesState } from '../../..'; -const styles = createStaticStyles(({ css }) => ({ - statusIcon: css` - margin-block-end: -2px; - margin-inline-start: 4px; - `, -})); - export const GlobLocalFilesInspector = memo>( ({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading }) => { const { t } = useTranslation('plugin'); @@ -41,22 +34,28 @@ export const GlobLocalFilesInspector = memo 0; return (
- - {t('builtins.lobe-local-system.apiName.globLocalFiles')}: - {pattern && {pattern}} - {isLoading ? null : pluginState?.result ? ( - isSuccess ? ( - - ) : ( - - ) - ) : null} - + {t('builtins.lobe-local-system.apiName.globLocalFiles')}: + {pattern && {pattern}} + {!isLoading && + pluginState?.result && + (hasResults ? ( + ({resultCount}) + ) : ( + + ({t('builtins.lobe-local-system.inspector.noResults')}) + + ))}
); }, diff --git a/packages/builtin-tool-local-system/src/executor/index.ts b/packages/builtin-tool-local-system/src/executor/index.ts index ae46d8ecce..ece1a089b5 100644 --- a/packages/builtin-tool-local-system/src/executor/index.ts +++ b/packages/builtin-tool-local-system/src/executor/index.ts @@ -87,11 +87,20 @@ class LocalSystemExecutor extends BaseExecutor { listLocalFiles = async (params: ListLocalFileParams): Promise => { try { - const result: LocalFileItem[] = await localFileService.listLocalFiles(params); + const result = await localFileService.listLocalFiles(params); - const state: LocalFileListState = { listResults: result }; + const state: LocalFileListState = { + listResults: result.files, + totalCount: result.totalCount, + }; - const content = formatFileList(result, params.path); + const content = formatFileList({ + directory: params.path, + files: result.files, + sortBy: params.sortBy, + sortOrder: params.sortOrder, + totalCount: result.totalCount, + }); return { content, diff --git a/packages/builtin-tool-local-system/src/manifest.ts b/packages/builtin-tool-local-system/src/manifest.ts index cebc616303..04a9e5ae76 100644 --- a/packages/builtin-tool-local-system/src/manifest.ts +++ b/packages/builtin-tool-local-system/src/manifest.ts @@ -7,14 +7,31 @@ export const LocalSystemManifest: BuiltinToolManifest = { api: [ { description: - 'List files and folders in a specified directory. Input should be a path. Output is a JSON array of file/folder names.', + 'List files and folders in a specified directory. Returns file/folder names with metadata (size, modified time). Results are sorted by modified time (newest first) by default and limited to 100 items.', name: LocalSystemApiName.listLocalFiles, parameters: { properties: { + limit: { + default: 100, + description: 'Maximum number of items to return (default: 100)', + type: 'number', + }, path: { description: 'The directory path to list', type: 'string', }, + sortBy: { + default: 'modifiedTime', + description: 'Field to sort by (default: modifiedTime)', + enum: ['name', 'modifiedTime', 'createdTime', 'size'], + type: 'string', + }, + sortOrder: { + default: 'desc', + description: 'Sort order (default: desc)', + enum: ['asc', 'desc'], + type: 'string', + }, }, required: ['path'], type: 'object', diff --git a/packages/builtin-tool-local-system/src/systemRole.ts b/packages/builtin-tool-local-system/src/systemRole.ts index abcd3d740e..65f34cb962 100644 --- a/packages/builtin-tool-local-system/src/systemRole.ts +++ b/packages/builtin-tool-local-system/src/systemRole.ts @@ -21,7 +21,7 @@ Use these paths when the user refers to these common locations by name (e.g., "m You have access to a set of tools to interact with the user's local file system: **File Operations:** -1. **listLocalFiles**: Lists files and directories in a specified path. +1. **listLocalFiles**: Lists files and directories in a specified path. Returns metadata including file size and modification time. Results are sorted by modification time (newest first) by default and limited to 100 items. 2. **readLocalFile**: Reads the content of a specified file, optionally within a line range. You can read file types such as Word, Excel, PowerPoint, PDF, and plain text files. 3. **writeLocalFile**: Write content to a specific file, only support plain text file like \`.text\` or \`.md\` 4. **editLocalFile**: Performs exact string replacements in files. Must read the file first before editing. @@ -50,7 +50,13 @@ You have access to a set of tools to interact with the user's local file system: -- For listing directory contents: Use 'listFiles' with the target directory path. +- For listing directory contents: Use 'listLocalFiles'. Provide the following parameters: + - 'path': The directory path to list. + - 'sortBy' (Optional): Field to sort results by. Options: 'name', 'modifiedTime', 'createdTime', 'size'. Defaults to 'modifiedTime'. + - 'sortOrder' (Optional): Sort order. Options: 'asc', 'desc'. Defaults to 'desc' (newest/largest first). + - 'limit' (Optional): Maximum number of items to return. Defaults to 100. + - The response includes file/folder names with metadata (size in bytes, modification time) for each item. + - System files (e.g., '.DS_Store', 'Thumbs.db', '$RECYCLE.BIN') are automatically filtered out. - For reading a file: Use 'readFile'. Provide the following parameters: - 'path': The exact file path. - 'loc' (Optional): A two-element array [startLine, endLine] to specify a line range to read (e.g., '[301, 400]' reads lines 301 to 400). diff --git a/packages/builtin-tool-local-system/src/types.ts b/packages/builtin-tool-local-system/src/types.ts index 02103e6ae4..4cf9b14195 100644 --- a/packages/builtin-tool-local-system/src/types.ts +++ b/packages/builtin-tool-local-system/src/types.ts @@ -47,6 +47,7 @@ export interface LocalFileSearchState { export interface LocalFileListState { listResults: LocalFileItem[]; + totalCount: number; } export interface LocalReadFileState { diff --git a/packages/const/src/file.ts b/packages/const/src/file.ts index f1044aa9e0..c826e8606e 100644 --- a/packages/const/src/file.ts +++ b/packages/const/src/file.ts @@ -1,10 +1,20 @@ -export const FILE_UPLOAD_BLACKLIST = [ +/** + * System files to be filtered out when listing directory contents + */ +export const SYSTEM_FILES_BLACKLIST = [ '.DS_Store', 'Thumbs.db', 'desktop.ini', '.localized', 'ehthumbs.db', 'ehthumbs_vista.db', + '$RECYCLE.BIN', + 'System Volume Information', + '.Spotlight-V100', + '.fseventsd', + '.Trashes', ]; +export const FILE_UPLOAD_BLACKLIST = SYSTEM_FILES_BLACKLIST; + export const MAX_UPLOAD_FILE_COUNT = 10; diff --git a/packages/const/src/index.ts b/packages/const/src/index.ts index 0ebc2eed61..5ac477aec2 100644 --- a/packages/const/src/index.ts +++ b/packages/const/src/index.ts @@ -2,6 +2,7 @@ export * from './currency'; export * from './desktop'; export * from './discover'; export * from './editor'; +export * from './file'; export * from './klavis'; export * from './layoutTokens'; export * from './lobehubSkill'; diff --git a/packages/const/src/version.ts b/packages/const/src/version.ts index 1c4a6e05f6..c997b75948 100644 --- a/packages/const/src/version.ts +++ b/packages/const/src/version.ts @@ -1,6 +1,6 @@ import { BRANDING_NAME, ORG_NAME } from '@lobechat/business-const'; -import pkg from '@/../package.json'; +import pkg from '../../../package.json'; export const CURRENT_VERSION = pkg.version; diff --git a/packages/electron-client-ipc/src/types/localSystem.ts b/packages/electron-client-ipc/src/types/localSystem.ts index 46f2a220e9..e4e9626ad1 100644 --- a/packages/electron-client-ipc/src/types/localSystem.ts +++ b/packages/electron-client-ipc/src/types/localSystem.ts @@ -16,8 +16,40 @@ export interface LocalFileItem { type: string; } +export type ListLocalFileSortBy = 'name' | 'modifiedTime' | 'createdTime' | 'size'; +export type ListLocalFileSortOrder = 'asc' | 'desc'; + export interface ListLocalFileParams { + /** + * Maximum number of files to return + * @default 100 + */ + limit?: number; + /** + * Directory path to list + */ path: string; + /** + * Field to sort by + * @default 'modifiedTime' + */ + sortBy?: ListLocalFileSortBy; + /** + * Sort order + * @default 'desc' + */ + sortOrder?: ListLocalFileSortOrder; +} + +export interface ListLocalFilesResult { + /** + * List of files (truncated to limit) + */ + files: LocalFileItem[]; + /** + * Total count of files before truncation + */ + totalCount: number; } export interface MoveLocalFileParams { diff --git a/packages/file-loaders/src/blackList.ts b/packages/file-loaders/src/blackList.ts index 40dda73276..3ba44d46c8 100644 --- a/packages/file-loaders/src/blackList.ts +++ b/packages/file-loaders/src/blackList.ts @@ -1,4 +1,6 @@ -// List of system files/directories to ignore +/** + * System files to be filtered out when listing directory contents + */ export const SYSTEM_FILES_TO_IGNORE = [ '.DS_Store', 'Thumbs.db', @@ -6,4 +8,9 @@ export const SYSTEM_FILES_TO_IGNORE = [ '.localized', 'ehthumbs.db', 'ehthumbs_vista.db', + '$RECYCLE.BIN', + 'System Volume Information', + '.Spotlight-V100', + '.fseventsd', + '.Trashes', ]; diff --git a/packages/prompts/src/prompts/fileSystem/formatFileList.test.ts b/packages/prompts/src/prompts/fileSystem/formatFileList.test.ts index a124ec19a4..d98531ca3e 100644 --- a/packages/prompts/src/prompts/fileSystem/formatFileList.test.ts +++ b/packages/prompts/src/prompts/fileSystem/formatFileList.test.ts @@ -3,8 +3,158 @@ import { describe, expect, it } from 'vitest'; import { formatFileList } from './formatFileList'; describe('formatFileList', () => { + describe('snapshot tests', () => { + it('should match snapshot for complete extended output', () => { + const files = [ + { + isDirectory: true, + modifiedTime: new Date('2024-01-20T14:30:00'), + name: 'Documents', + size: 4096, + }, + { + isDirectory: true, + modifiedTime: new Date('2024-01-18T09:15:00'), + name: 'Downloads', + size: 4096, + }, + { + isDirectory: false, + modifiedTime: new Date('2024-01-15T11:20:00'), + name: 'report.pdf', + size: 2457600, + }, + { + isDirectory: false, + modifiedTime: new Date('2024-01-10T16:45:00'), + name: 'screenshot.png', + size: 1153434, + }, + { + isDirectory: false, + modifiedTime: new Date('2024-01-05T08:30:00'), + name: 'notes.txt', + size: 2048, + }, + ]; + + const result = formatFileList({ + directory: '/Users/test/Desktop', + files, + sortBy: 'modifiedTime', + sortOrder: 'desc', + totalCount: 150, + }); + + expect(result).toMatchInlineSnapshot(` + "Found 150 item(s) in /Users/test/Desktop (showing first 5, sorted by modifiedTime desc): + [D] Documents 2024-01-20 14:30 -- + [D] Downloads 2024-01-18 09:15 -- + [F] report.pdf 2024-01-15 11:20 2.3 MB + [F] screenshot.png 2024-01-10 16:45 1.1 MB + [F] notes.txt 2024-01-05 08:30 2 KB" + `); + }); + + it('should match snapshot for simple output without extended info', () => { + const files = [ + { isDirectory: true, name: 'src' }, + { isDirectory: true, name: 'dist' }, + { isDirectory: false, name: 'package.json' }, + { isDirectory: false, name: 'README.md' }, + { isDirectory: false, name: 'tsconfig.json' }, + ]; + + const result = formatFileList({ directory: '/project', files }); + + expect(result).toMatchInlineSnapshot(` + "Found 5 item(s) in /project: + [D] src + [D] dist + [F] package.json + [F] README.md + [F] tsconfig.json" + `); + }); + + it('should match snapshot for output with sorting info only', () => { + const files = [ + { isDirectory: false, name: 'alpha.txt' }, + { isDirectory: false, name: 'beta.txt' }, + { isDirectory: false, name: 'gamma.txt' }, + ]; + + const result = formatFileList({ + directory: '/test', + files, + sortBy: 'name', + sortOrder: 'asc', + }); + + expect(result).toMatchInlineSnapshot(` + "Found 3 item(s) in /test (sorted by name asc): + [F] alpha.txt + [F] beta.txt + [F] gamma.txt" + `); + }); + + it('should match snapshot for various file sizes', () => { + const files = [ + { + isDirectory: false, + modifiedTime: new Date('2024-01-01T00:00:00'), + name: 'empty.txt', + size: 0, + }, + { + isDirectory: false, + modifiedTime: new Date('2024-01-01T00:00:00'), + name: 'tiny.txt', + size: 512, + }, + { + isDirectory: false, + modifiedTime: new Date('2024-01-01T00:00:00'), + name: 'small.txt', + size: 1024, + }, + { + isDirectory: false, + modifiedTime: new Date('2024-01-01T00:00:00'), + name: 'medium.txt', + size: 1048576, + }, + { + isDirectory: false, + modifiedTime: new Date('2024-01-01T00:00:00'), + name: 'large.txt', + size: 1073741824, + }, + { + isDirectory: false, + modifiedTime: new Date('2024-01-01T00:00:00'), + name: 'huge.txt', + size: 1099511627776, + }, + ]; + + const result = formatFileList({ directory: '/test', files }); + + expect(result).toMatchInlineSnapshot(` + "Found 6 item(s) in /test: + [F] empty.txt 2024-01-01 00:00 0 B + [F] tiny.txt 2024-01-01 00:00 512 B + [F] small.txt 2024-01-01 00:00 1 KB + [F] medium.txt 2024-01-01 00:00 1 MB + [F] large.txt 2024-01-01 00:00 1 GB + [F] huge.txt 2024-01-01 00:00 1 TB" + `); + }); + }); + it('should format empty directory', () => { - const result = formatFileList([], '/home/user'); + const result = formatFileList({ directory: '/home/user', files: [] }); expect(result).toMatchInlineSnapshot(`"Directory /home/user is empty"`); }); @@ -13,7 +163,7 @@ describe('formatFileList', () => { { isDirectory: false, name: 'file1.txt' }, { isDirectory: false, name: 'file2.js' }, ]; - const result = formatFileList(files, '/home/user'); + const result = formatFileList({ directory: '/home/user', files }); expect(result).toMatchInlineSnapshot(` "Found 2 item(s) in /home/user: [F] file1.txt @@ -26,7 +176,7 @@ describe('formatFileList', () => { { isDirectory: true, name: 'src' }, { isDirectory: true, name: 'dist' }, ]; - const result = formatFileList(files, '/project'); + const result = formatFileList({ directory: '/project', files }); expect(result).toMatchInlineSnapshot(` "Found 2 item(s) in /project: [D] src @@ -41,7 +191,7 @@ describe('formatFileList', () => { { isDirectory: true, name: 'node_modules' }, { isDirectory: false, name: 'README.md' }, ]; - const result = formatFileList(files, '/project'); + const result = formatFileList({ directory: '/project', files }); expect(result).toMatchInlineSnapshot(` "Found 4 item(s) in /project: [D] src @@ -53,10 +203,152 @@ describe('formatFileList', () => { it('should format single item', () => { const files = [{ isDirectory: false, name: 'index.ts' }]; - const result = formatFileList(files, '/src'); + const result = formatFileList({ directory: '/src', files }); expect(result).toMatchInlineSnapshot(` "Found 1 item(s) in /src: [F] index.ts" `); }); + + describe('extended info', () => { + it('should format files with size and modified time', () => { + const files = [ + { + isDirectory: true, + modifiedTime: new Date('2024-01-20T14:30:00'), + name: 'src', + size: 4096, + }, + { + isDirectory: false, + modifiedTime: new Date('2024-01-18T09:15:00'), + name: 'report.pdf', + size: 2457600, // 2.4 MB + }, + { + isDirectory: false, + modifiedTime: new Date('2024-01-15T11:20:00'), + name: 'screenshot.png', + size: 1153434, // ~1.1 MB + }, + ]; + const result = formatFileList({ directory: '/Users/test/Downloads', files }); + expect(result).toContain('Found 3 item(s) in /Users/test/Downloads'); + expect(result).toContain('[D] src'); + expect(result).toContain('[F] report.pdf'); + expect(result).toContain('2024-01-20 14:30'); + expect(result).toContain('2.3 MB'); + }); + + it('should show sorting and limit info in header', () => { + const files = [ + { isDirectory: false, name: 'file1.txt' }, + { isDirectory: false, name: 'file2.txt' }, + ]; + const result = formatFileList({ + directory: '/test', + files, + sortBy: 'modifiedTime', + sortOrder: 'desc', + totalCount: 150, + }); + expect(result).toContain('Found 150 item(s)'); + expect(result).toContain('showing first 2'); + expect(result).toContain('sorted by modifiedTime desc'); + }); + + it('should not show limit info when not truncated', () => { + const files = [ + { isDirectory: false, name: 'file1.txt' }, + { isDirectory: false, name: 'file2.txt' }, + ]; + const result = formatFileList({ + directory: '/test', + files, + sortBy: 'name', + sortOrder: 'asc', + }); + expect(result).not.toContain('showing'); + expect(result).toContain('sorted by name asc'); + }); + + it('should show -- for directory size', () => { + const files = [ + { + isDirectory: true, + modifiedTime: new Date('2024-01-20T14:30:00'), + name: 'my_folder', + size: 4096, + }, + ]; + const result = formatFileList({ directory: '/test', files }); + expect(result).toContain('--'); + }); + + it('should format various file sizes correctly', () => { + const files = [ + { isDirectory: false, modifiedTime: new Date('2024-01-01'), name: 'zero.txt', size: 0 }, + { isDirectory: false, modifiedTime: new Date('2024-01-01'), name: 'bytes.txt', size: 500 }, + { isDirectory: false, modifiedTime: new Date('2024-01-01'), name: 'kb.txt', size: 2048 }, + { isDirectory: false, modifiedTime: new Date('2024-01-01'), name: 'mb.txt', size: 5242880 }, + { + isDirectory: false, + modifiedTime: new Date('2024-01-01'), + name: 'gb.txt', + size: 1073741824, + }, + ]; + const result = formatFileList({ directory: '/test', files }); + expect(result).toContain('0 B'); + expect(result).toContain('500 B'); + expect(result).toContain('2 KB'); + expect(result).toContain('5 MB'); + expect(result).toContain('1 GB'); + }); + + it('should handle files with only size (no modifiedTime)', () => { + const files = [{ isDirectory: false, name: 'file.txt', size: 1024 }]; + const result = formatFileList({ directory: '/test', files }); + expect(result).toContain('[F] file.txt'); + expect(result).toContain('1 KB'); + }); + + it('should handle files with only modifiedTime (no size)', () => { + const files = [ + { isDirectory: false, modifiedTime: new Date('2024-06-15T10:30:00'), name: 'file.txt' }, + ]; + const result = formatFileList({ directory: '/test', files }); + expect(result).toContain('[F] file.txt'); + expect(result).toContain('2024-06-15 10:30'); + }); + + it('should handle long file names', () => { + const files = [ + { + isDirectory: false, + modifiedTime: new Date('2024-01-01'), + name: 'this_is_a_very_long_filename_that_exceeds_normal_length.txt', + size: 1024, + }, + ]; + const result = formatFileList({ directory: '/test', files }); + expect(result).toContain('this_is_a_very_long_filename_that_exceeds_normal_length.txt'); + }); + + it('should handle options with only totalCount', () => { + const files = [{ isDirectory: false, name: 'file.txt' }]; + const result = formatFileList({ directory: '/test', files, totalCount: 100 }); + expect(result).toContain('Found 100 item(s)'); + expect(result).toContain('showing first 1'); + }); + + it('should not show extra info when totalCount equals file count', () => { + const files = [ + { isDirectory: false, name: 'file1.txt' }, + { isDirectory: false, name: 'file2.txt' }, + ]; + const result = formatFileList({ directory: '/test', files, totalCount: 2 }); + expect(result).not.toContain('showing'); + }); + }); }); diff --git a/packages/prompts/src/prompts/fileSystem/formatFileList.ts b/packages/prompts/src/prompts/fileSystem/formatFileList.ts index 192850b045..f8a886f704 100644 --- a/packages/prompts/src/prompts/fileSystem/formatFileList.ts +++ b/packages/prompts/src/prompts/fileSystem/formatFileList.ts @@ -1,13 +1,96 @@ export interface FileListItem { isDirectory: boolean; + modifiedTime?: Date; name: string; + size?: number; } -export const formatFileList = (files: FileListItem[], directory: string): string => { +export interface FormatFileListParams { + /** Directory path */ + directory: string; + /** List of files to format */ + files: FileListItem[]; + /** Sort field used */ + sortBy?: string; + /** Sort order used */ + sortOrder?: string; + /** Total count before limit applied */ + totalCount?: number; +} + +/** + * Format file size to human readable string + */ +const formatFileSize = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${Number.parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; +}; + +/** + * Format date to YYYY-MM-DD HH:mm format + */ +const formatDate = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${year}-${month}-${day} ${hours}:${minutes}`; +}; + +export const formatFileList = ({ + files, + directory, + sortBy, + sortOrder, + totalCount: totalCountParam, +}: FormatFileListParams): string => { if (files.length === 0) { return `Directory ${directory} is empty`; } - const fileList = files.map((f) => ` ${f.isDirectory ? '[D]' : '[F]'} ${f.name}`).join('\n'); - return `Found ${files.length} item(s) in ${directory}:\n${fileList}`; + // Check if we have extended info (size and modifiedTime) + const hasExtendedInfo = files.some((f) => f.size !== undefined || f.modifiedTime !== undefined); + + // Use totalCount if available, otherwise use files.length + const totalCount = totalCountParam ?? files.length; + const isTruncated = totalCount > files.length; + + let header = `Found ${totalCount} item(s) in ${directory}`; + + // Add sorting and limit info if provided + const parts: string[] = []; + if (isTruncated) { + parts.push(`showing first ${files.length}`); + } + if (sortBy) { + parts.push(`sorted by ${sortBy} ${sortOrder || 'desc'}`); + } + if (parts.length > 0) { + header += ` (${parts.join(', ')})`; + } + + const fileList = files + .map((f) => { + const prefix = f.isDirectory ? '[D]' : '[F]'; + const name = f.name; + + if (hasExtendedInfo) { + const date = f.modifiedTime ? formatDate(f.modifiedTime) : ' '; + const size = f.isDirectory + ? ' --' + : f.size !== undefined + ? formatFileSize(f.size).padStart(10) + : ' '; + return ` ${prefix} ${name.padEnd(40)} ${date} ${size}`; + } + + return ` ${prefix} ${name}`; + }) + .join('\n'); + + return `${header}:\n${fileList}`; }; diff --git a/src/envs/file.ts b/src/envs/file.ts index bf47b12bfe..a8090bfb09 100644 --- a/src/envs/file.ts +++ b/src/envs/file.ts @@ -48,7 +48,7 @@ export const getFileConfig = () => { S3_ENDPOINT: z.string().url().optional(), S3_PREVIEW_URL_EXPIRE_IN: z.number(), - S3_PUBLIC_DOMAIN: z.string().url().optional(), + S3_PUBLIC_DOMAIN: z.string().optional(), S3_REGION: z.string().optional(), S3_SECRET_ACCESS_KEY: z.string().optional(), S3_SET_ACL: z.boolean(), diff --git a/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/ToolTitle.tsx b/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/ToolTitle.tsx index 387b64439e..feb1833f6e 100644 --- a/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/ToolTitle.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/ToolTitle.tsx @@ -2,7 +2,7 @@ import { Icon } from '@lobehub/ui'; import { createStaticStyles, cx } from 'antd-style'; import isEqual from 'fast-deep-equal'; import { ChevronRight } from 'lucide-react'; -import { memo } from 'react'; +import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { pluginHelpers, useToolStore } from '@/store/tool'; @@ -14,6 +14,18 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({ aborted: css` color: ${cssVar.colorTextQuaternary}; `, + apiName: css` + font-family: ${cssVar.fontFamilyCode}; + color: ${cssVar.colorTextSecondary}; + `, + paramKey: css` + font-family: ${cssVar.fontFamilyCode}; + color: ${cssVar.colorTextTertiary}; + `, + paramValue: css` + font-family: ${cssVar.fontFamilyCode}; + color: ${cssVar.colorTextSecondary}; + `, root: css` overflow: hidden; display: -webkit-box; @@ -24,45 +36,101 @@ export const styles = createStaticStyles(({ css, cssVar }) => ({ `, })); +// Maximum number of parameters to display +const MAX_PARAMS = 1; +// Maximum length for parameter values before truncation +const MAX_VALUE_LENGTH = 50; + +const truncateValue = (value: string, maxLength: number): string => { + if (value.length <= maxLength) return value; + return value.slice(0, maxLength) + '...'; +}; + +const formatParamValue = (value: unknown): string => { + if (typeof value === 'string') { + return truncateValue(value, MAX_VALUE_LENGTH); + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + if (Array.isArray(value)) { + return truncateValue(JSON.stringify(value), MAX_VALUE_LENGTH); + } + if (typeof value === 'object' && value !== null) { + return truncateValue(JSON.stringify(value), MAX_VALUE_LENGTH); + } + return String(value); +}; + interface ToolTitleProps { apiName: string; + args?: Record; identifier: string; isAborted?: boolean; isLoading?: boolean; + partialArgs?: Record; } -const ToolTitle = memo(({ identifier, apiName, isLoading, isAborted }) => { - const { t } = useTranslation('plugin'); +const ToolTitle = memo( + ({ identifier, apiName, args, partialArgs, isLoading, isAborted }) => { + const { t } = useTranslation('plugin'); - const pluginMeta = useToolStore(toolSelectors.getMetaById(identifier), isEqual); - const isBuiltinPlugin = builtinToolIdentifiers.includes(identifier); - const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? t('unknownPlugin'); + const pluginMeta = useToolStore(toolSelectors.getMetaById(identifier), isEqual); + const isBuiltinPlugin = builtinToolIdentifiers.includes(identifier); + const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? t('unknownPlugin'); - return ( -
- - {isBuiltinPlugin - ? t(`builtins.${identifier}.title`, { - defaultValue: identifier, - }) - : pluginTitle} - - - - {isBuiltinPlugin - ? t(`builtins.${identifier}.apiName.${apiName}`, { - defaultValue: apiName, - }) - : apiName} - -
- ); -}); + const params = useMemo(() => { + const argsToUse = args || partialArgs || {}; + return Object.entries(argsToUse).slice(0, MAX_PARAMS); + }, [args, partialArgs]); + + const remainingCount = useMemo(() => { + const argsToUse = args || partialArgs || {}; + const total = Object.keys(argsToUse).length; + return total > MAX_PARAMS ? total - MAX_PARAMS : 0; + }, [args, partialArgs]); + + const moreParamsText = useMemo(() => { + if (remainingCount === 0) return ''; + return ' ' + t('arguments.moreParams', { count: remainingCount + params.length }); + }, [params.length, remainingCount, t]); + + return ( +
+ + {isBuiltinPlugin + ? t(`builtins.${identifier}.title`, { defaultValue: identifier }) + : pluginTitle} + + + + {isBuiltinPlugin + ? t(`builtins.${identifier}.apiName.${apiName}`, { defaultValue: apiName }) + : apiName} + + {params.length > 0 && ( + <> + {' ('} + {params.map(([key, value], index) => ( + + {key}: + {formatParamValue(value)} + {index < params.length - 1 && , } + + ))} + {moreParamsText && {moreParamsText}} + {')'} + + )} +
+ ); + }, +); export default ToolTitle; diff --git a/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/index.tsx b/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/index.tsx index 7280182e50..2a20b2aea8 100644 --- a/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/index.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/index.tsx @@ -57,14 +57,19 @@ const Inspectors = memo( ); } + const args = safeParseJSON(argsStr); + const partialJson = safeParsePartialJSON(argsStr); + return ( ); diff --git a/src/features/Conversation/Messages/Supervisor/components/ContentBlock.tsx b/src/features/Conversation/Messages/Supervisor/components/ContentBlock.tsx index f2cdb3cf71..91616a1d77 100644 --- a/src/features/Conversation/Messages/Supervisor/components/ContentBlock.tsx +++ b/src/features/Conversation/Messages/Supervisor/components/ContentBlock.tsx @@ -8,8 +8,8 @@ import { type AssistantContentBlock } from '@/types/index'; import ErrorContent from '../../../ChatItem/components/ErrorContent'; import { messageStateSelectors, useConversationStore } from '../../../store'; import { Tools } from '../../AssistantGroup/Tools'; +import MessageContent from '../../AssistantGroup/components/MessageContent'; import Reasoning from '../../components/Reasoning'; -import MessageContent from './MessageContent'; interface ContentBlockProps extends AssistantContentBlock { disableEditing?: boolean; diff --git a/src/features/Conversation/Messages/Supervisor/components/MessageContent.tsx b/src/features/Conversation/Messages/Supervisor/components/MessageContent.tsx deleted file mode 100644 index 9a8b7f135d..0000000000 --- a/src/features/Conversation/Messages/Supervisor/components/MessageContent.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { memo } from 'react'; - -import { LOADING_FLAT } from '@/const/message'; -import MarkdownMessage from '@/features/Conversation/Markdown'; - -import { normalizeThinkTags, processWithArtifact } from '../../../utils/markdown'; -import { useMarkdown } from '../../AssistantGroup/useMarkdown'; -import ContentLoading from '../../components/ContentLoading'; - -interface ContentBlockProps { - content: string; - hasTools?: boolean; - id: string; -} - -const MessageContent = memo(({ content, id, hasTools }) => { - const message = normalizeThinkTags(processWithArtifact(content)); - const markdownProps = useMarkdown(id); - - if (!content || content === LOADING_FLAT) { - if (hasTools) return null; - - return ; - } - - return content && {message}; -}); - -export default MessageContent; diff --git a/src/locales/default/plugin.ts b/src/locales/default/plugin.ts index 62bf1ea6e5..16e40506c9 100644 --- a/src/locales/default/plugin.ts +++ b/src/locales/default/plugin.ts @@ -1,4 +1,5 @@ export default { + 'arguments.moreParams': '{{count}} params in total', 'arguments.title': 'Arguments', 'builtins.lobe-agent-builder.apiName.getAvailableModels': 'Get available models', 'builtins.lobe-agent-builder.apiName.getAvailableTools': 'Get available Skills', diff --git a/src/services/electron/localFileService.ts b/src/services/electron/localFileService.ts index 7ca4dbe1c2..38b4030b71 100644 --- a/src/services/electron/localFileService.ts +++ b/src/services/electron/localFileService.ts @@ -10,6 +10,7 @@ import { type KillCommandParams, type KillCommandResult, type ListLocalFileParams, + type ListLocalFilesResult, type LocalFileItem, type LocalMoveFilesResultItem, type LocalReadFileParams, @@ -31,7 +32,7 @@ import { ensureElectronIpc } from '@/utils/electron/ipc'; class LocalFileService { // File Operations - async listLocalFiles(params: ListLocalFileParams): Promise { + async listLocalFiles(params: ListLocalFileParams): Promise { return ensureElectronIpc().localSystem.listLocalFiles(params); }