mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
💄 style: improve local-system tool implement (#12022)
* improve local system ability * fix build * improve tools title render * fix tools * update * try to fix lint * update * refactor the LocalFileCtr.ts result * refactor the exector result
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -218,8 +218,13 @@ export default class LocalFileCtr extends ControllerModule {
|
||||
}
|
||||
|
||||
@IpcMethod()
|
||||
async listLocalFiles({ path: dirPath }: ListLocalFileParams): Promise<FileResult[]> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, Date> = {
|
||||
'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<string, number> = {
|
||||
'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<string, Date> = {
|
||||
'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<string, Date> = {
|
||||
'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<string, number> = {
|
||||
'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<string, Date> = {
|
||||
'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<string, Date> = {
|
||||
'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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"arguments.moreParams": "等 {{count}} 个参数",
|
||||
"arguments.title": "参数列表",
|
||||
"builtins.lobe-agent-builder.apiName.getAvailableModels": "获取可用模型",
|
||||
"builtins.lobe-agent-builder.apiName.getAvailableTools": "获取可用技能",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<EditLocalFileResult>;
|
||||
getCommandOutput: (params: GetCommandOutputParams) => Promise<GetCommandOutputResult>;
|
||||
globFiles: (params: GlobFilesParams) => Promise<GlobFilesResult>;
|
||||
grepContent: (params: GrepContentParams) => Promise<GrepContentResult>;
|
||||
killCommand: (params: KillCommandParams) => Promise<KillCommandResult>;
|
||||
listLocalFiles: (params: ListLocalFileParams) => Promise<LocalFileItem[]>;
|
||||
moveLocalFiles: (params: MoveLocalFilesParams) => Promise<LocalMoveFilesResultItem[]>;
|
||||
readLocalFile: (params: LocalReadFileParams) => Promise<LocalReadFileResult>;
|
||||
readLocalFiles: (params: LocalReadFilesParams) => Promise<LocalReadFileResult[]>;
|
||||
renameLocalFile: (params: RenameLocalFileParams) => Promise<RenameLocalFileResult>;
|
||||
runCommand: (params: RunCommandParams) => Promise<RunCommandResult>;
|
||||
searchLocalFiles: (params: LocalSearchFilesParams) => Promise<LocalFileItem[]>;
|
||||
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<BuiltinServerRuntimeOutput> {
|
||||
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<BuiltinServerRuntimeOutput> {
|
||||
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<BuiltinServerRuntimeOutput> {
|
||||
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<BuiltinServerRuntimeOutput> {
|
||||
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<BuiltinServerRuntimeOutput> {
|
||||
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<BuiltinServerRuntimeOutput> {
|
||||
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<BuiltinServerRuntimeOutput> {
|
||||
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<BuiltinServerRuntimeOutput> {
|
||||
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<BuiltinServerRuntimeOutput> {
|
||||
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<BuiltinServerRuntimeOutput> {
|
||||
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<BuiltinServerRuntimeOutput> {
|
||||
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<BuiltinServerRuntimeOutput> {
|
||||
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<BuiltinServerRuntimeOutput> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<BuiltinInspectorProps<GlobFilesParams, GlobFilesState>>(
|
||||
({ args, partialArgs, isArgumentsStreaming, pluginState, isLoading }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
@@ -41,22 +34,28 @@ export const GlobLocalFilesInspector = memo<BuiltinInspectorProps<GlobFilesParam
|
||||
);
|
||||
}
|
||||
|
||||
// Check if glob was successful
|
||||
const isSuccess = pluginState?.result?.success;
|
||||
// Check result count
|
||||
const resultCount = pluginState?.result?.total_files ?? 0;
|
||||
const hasResults = resultCount > 0;
|
||||
|
||||
return (
|
||||
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
|
||||
<span style={{ marginInlineStart: 2 }}>
|
||||
<span>{t('builtins.lobe-local-system.apiName.globLocalFiles')}: </span>
|
||||
{pattern && <span className={highlightTextStyles.primary}>{pattern}</span>}
|
||||
{isLoading ? null : pluginState?.result ? (
|
||||
isSuccess ? (
|
||||
<Check className={styles.statusIcon} color={cssVar.colorSuccess} size={14} />
|
||||
) : (
|
||||
<X className={styles.statusIcon} color={cssVar.colorError} size={14} />
|
||||
)
|
||||
) : null}
|
||||
</span>
|
||||
<span>{t('builtins.lobe-local-system.apiName.globLocalFiles')}: </span>
|
||||
{pattern && <span className={highlightTextStyles.primary}>{pattern}</span>}
|
||||
{!isLoading &&
|
||||
pluginState?.result &&
|
||||
(hasResults ? (
|
||||
<span style={{ marginInlineStart: 4 }}>({resultCount})</span>
|
||||
) : (
|
||||
<Text
|
||||
as={'span'}
|
||||
color={cssVar.colorTextDescription}
|
||||
fontSize={12}
|
||||
style={{ marginInlineStart: 4 }}
|
||||
>
|
||||
({t('builtins.lobe-local-system.inspector.noResults')})
|
||||
</Text>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -87,11 +87,20 @@ class LocalSystemExecutor extends BaseExecutor<typeof LocalSystemApiEnum> {
|
||||
|
||||
listLocalFiles = async (params: ListLocalFileParams): Promise<BuiltinToolResult> => {
|
||||
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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
</workflow>
|
||||
|
||||
<tool_usage_guidelines>
|
||||
- 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).
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface LocalFileSearchState {
|
||||
|
||||
export interface LocalFileListState {
|
||||
listResults: LocalFileItem[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export interface LocalReadFileState {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
identifier: string;
|
||||
isAborted?: boolean;
|
||||
isLoading?: boolean;
|
||||
partialArgs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const ToolTitle = memo<ToolTitleProps>(({ identifier, apiName, isLoading, isAborted }) => {
|
||||
const { t } = useTranslation('plugin');
|
||||
const ToolTitle = memo<ToolTitleProps>(
|
||||
({ 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 (
|
||||
<div
|
||||
className={cx(
|
||||
styles.root,
|
||||
isLoading && shinyTextStyles.shinyText,
|
||||
isAborted && styles.aborted,
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{isBuiltinPlugin
|
||||
? t(`builtins.${identifier}.title`, {
|
||||
defaultValue: identifier,
|
||||
})
|
||||
: pluginTitle}
|
||||
</span>
|
||||
<Icon icon={ChevronRight} style={{ marginInline: 4 }} />
|
||||
<span>
|
||||
{isBuiltinPlugin
|
||||
? t(`builtins.${identifier}.apiName.${apiName}`, {
|
||||
defaultValue: apiName,
|
||||
})
|
||||
: apiName}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
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 (
|
||||
<div
|
||||
className={cx(
|
||||
styles.root,
|
||||
isLoading && shinyTextStyles.shinyText,
|
||||
isAborted && styles.aborted,
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{isBuiltinPlugin
|
||||
? t(`builtins.${identifier}.title`, { defaultValue: identifier })
|
||||
: pluginTitle}
|
||||
</span>
|
||||
<Icon icon={ChevronRight} style={{ marginInline: 4 }} />
|
||||
<span className={styles.apiName}>
|
||||
{isBuiltinPlugin
|
||||
? t(`builtins.${identifier}.apiName.${apiName}`, { defaultValue: apiName })
|
||||
: apiName}
|
||||
</span>
|
||||
{params.length > 0 && (
|
||||
<>
|
||||
<span className={styles.paramKey}>{' ('}</span>
|
||||
{params.map(([key, value], index) => (
|
||||
<span key={key}>
|
||||
<span className={styles.paramKey}>{key}: </span>
|
||||
<span className={styles.paramValue}>{formatParamValue(value)}</span>
|
||||
{index < params.length - 1 && <span className={styles.paramKey}>, </span>}
|
||||
</span>
|
||||
))}
|
||||
{moreParamsText && <span className={styles.paramKey}>{moreParamsText}</span>}
|
||||
<span className={styles.paramKey}>{')'}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default ToolTitle;
|
||||
|
||||
@@ -57,14 +57,19 @@ const Inspectors = memo<InspectorProps>(
|
||||
);
|
||||
}
|
||||
|
||||
const args = safeParseJSON(argsStr);
|
||||
const partialJson = safeParsePartialJSON(argsStr);
|
||||
|
||||
return (
|
||||
<Flexbox align={'center'} gap={6} horizontal>
|
||||
<StatusIndicator intervention={intervention} result={result} />
|
||||
<ToolTitle
|
||||
apiName={apiName}
|
||||
args={args || undefined}
|
||||
identifier={identifier}
|
||||
isAborted={isAborted}
|
||||
isLoading={isTitleLoading}
|
||||
partialArgs={partialJson || undefined}
|
||||
/>
|
||||
</Flexbox>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ContentBlockProps>(({ content, id, hasTools }) => {
|
||||
const message = normalizeThinkTags(processWithArtifact(content));
|
||||
const markdownProps = useMarkdown(id);
|
||||
|
||||
if (!content || content === LOADING_FLAT) {
|
||||
if (hasTools) return null;
|
||||
|
||||
return <ContentLoading id={id} />;
|
||||
}
|
||||
|
||||
return content && <MarkdownMessage {...markdownProps}>{message}</MarkdownMessage>;
|
||||
});
|
||||
|
||||
export default MessageContent;
|
||||
@@ -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',
|
||||
|
||||
@@ -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<LocalFileItem[]> {
|
||||
async listLocalFiles(params: ListLocalFileParams): Promise<ListLocalFilesResult> {
|
||||
return ensureElectronIpc().localSystem.listLocalFiles(params);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user