💄 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:
Arvin Xu
2026-02-01 12:13:27 +08:00
committed by GitHub
parent 6e4ad89c82
commit 5e203b868c
27 changed files with 1171 additions and 591 deletions

View File

@@ -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",

View File

@@ -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 };
}
}

View File

@@ -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({

View File

@@ -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",

View File

@@ -1,4 +1,5 @@
{
"arguments.moreParams": "等 {{count}} 个参数",
"arguments.title": "参数列表",
"builtins.lobe-agent-builder.apiName.getAvailableModels": "获取可用模型",
"builtins.lobe-agent-builder.apiName.getAvailableTools": "获取可用技能",

View File

@@ -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,

View File

@@ -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": {

View File

@@ -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,
};
}
}
}

View File

@@ -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>
);
},

View File

@@ -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,

View File

@@ -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',

View File

@@ -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).

View File

@@ -47,6 +47,7 @@ export interface LocalFileSearchState {
export interface LocalFileListState {
listResults: LocalFileItem[];
totalCount: number;
}
export interface LocalReadFileState {

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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',
];

View File

@@ -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');
});
});
});

View File

@@ -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}`;
};

View File

@@ -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(),

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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',

View File

@@ -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);
}