@lobehub/chat
Version:
Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.
709 lines (627 loc) • 25.3 kB
text/typescript
import {
EditLocalFileParams,
EditLocalFileResult,
GlobFilesParams,
GlobFilesResult,
GrepContentParams,
GrepContentResult,
ListLocalFileParams,
LocalMoveFilesResultItem,
LocalReadFileParams,
LocalReadFileResult,
LocalReadFilesParams,
LocalSearchFilesParams,
MoveLocalFilesParams,
OpenLocalFileParams,
OpenLocalFolderParams,
RenameLocalFileResult,
WriteLocalFileParams,
} from '@lobechat/electron-client-ipc';
import { SYSTEM_FILES_TO_IGNORE, loadFile } from '@lobechat/file-loaders';
import { shell } from 'electron';
import fg from 'fast-glob';
import { Stats, constants } from 'node:fs';
import { access, mkdir, readFile, readdir, rename, stat, writeFile } from 'node:fs/promises';
import * as path from 'node:path';
import FileSearchService from '@/services/fileSearchSrv';
import { FileResult, SearchOptions } from '@/types/fileSearch';
import { makeSureDirExist } from '@/utils/file-system';
import { createLogger } from '@/utils/logger';
import { ControllerModule, ipcClientEvent } from './index';
// Create logger
const logger = createLogger('controllers:LocalFileCtr');
export default class LocalFileCtr extends ControllerModule {
private get searchService() {
return this.app.getService(FileSearchService);
}
// ==================== File Operation ====================
('openLocalFile')
async handleOpenLocalFile({ path: filePath }: OpenLocalFileParams): Promise<{
error?: string;
success: boolean;
}> {
logger.debug('Attempting to open file:', { filePath });
try {
await shell.openPath(filePath);
logger.debug('File opened successfully:', { filePath });
return { success: true };
} catch (error) {
logger.error(`Failed to open file ${filePath}:`, error);
return { error: (error as Error).message, success: false };
}
}
('openLocalFolder')
async handleOpenLocalFolder({ path: targetPath, isDirectory }: OpenLocalFolderParams): Promise<{
error?: string;
success: boolean;
}> {
const folderPath = isDirectory ? targetPath : path.dirname(targetPath);
logger.debug('Attempting to open folder:', { folderPath, isDirectory, targetPath });
try {
await shell.openPath(folderPath);
logger.debug('Folder opened successfully:', { folderPath });
return { success: true };
} catch (error) {
logger.error(`Failed to open folder ${folderPath}:`, error);
return { error: (error as Error).message, success: false };
}
}
('readLocalFiles')
async readFiles({ paths }: LocalReadFilesParams): Promise<LocalReadFileResult[]> {
logger.debug('Starting batch file reading:', { count: paths.length });
const results: LocalReadFileResult[] = [];
for (const filePath of paths) {
// Initialize result object
logger.debug('Reading single file:', { filePath });
const result = await this.readFile({ path: filePath });
results.push(result);
}
logger.debug('Batch file reading completed', { count: results.length });
return results;
}
('readLocalFile')
async readFile({ path: filePath, loc }: LocalReadFileParams): Promise<LocalReadFileResult> {
const effectiveLoc = loc ?? [0, 200];
logger.debug('Starting to read file:', { filePath, loc: effectiveLoc });
try {
const fileDocument = await loadFile(filePath);
const [startLine, endLine] = effectiveLoc;
const lines = fileDocument.content.split('\n');
const totalLineCount = lines.length;
const totalCharCount = fileDocument.content.length;
// Adjust slice indices to be 0-based and inclusive/exclusive
const selectedLines = lines.slice(startLine, endLine);
const content = selectedLines.join('\n');
const charCount = content.length;
const lineCount = selectedLines.length;
logger.debug('File read successfully:', {
filePath,
selectedLineCount: lineCount,
totalCharCount,
totalLineCount,
});
const result: LocalReadFileResult = {
// Char count for the selected range
charCount,
// Content for the selected range
content,
createdTime: fileDocument.createdTime,
fileType: fileDocument.fileType,
filename: fileDocument.filename,
lineCount,
loc: effectiveLoc,
// Line count for the selected range
modifiedTime: fileDocument.modifiedTime,
// Total char count of the file
totalCharCount,
// Total line count of the file
totalLineCount,
};
try {
const stats = await stat(filePath);
if (stats.isDirectory()) {
logger.warn('Attempted to read directory content:', { filePath });
result.content = 'This is a directory and cannot be read as plain text.';
result.charCount = 0;
result.lineCount = 0;
// Keep total counts for directory as 0 as well, or decide if they should reflect metadata size
result.totalCharCount = 0;
result.totalLineCount = 0;
}
} catch (statError) {
logger.error(`Failed to get file status ${filePath}:`, statError);
}
return result;
} catch (error) {
logger.error(`Failed to read file ${filePath}:`, error);
const errorMessage = (error as Error).message;
return {
charCount: 0,
content: `Error accessing or processing file: ${errorMessage}`,
createdTime: new Date(),
fileType: path.extname(filePath).toLowerCase().replace('.', '') || 'unknown',
filename: path.basename(filePath),
lineCount: 0,
loc: [0, 0],
modifiedTime: new Date(),
totalCharCount: 0, // Add total counts to error result
totalLineCount: 0,
};
}
}
('listLocalFiles')
async listLocalFiles({ path: dirPath }: ListLocalFileParams): Promise<FileResult[]> {
logger.debug('Listing directory contents:', { dirPath });
const results: FileResult[] = [];
try {
const entries = await readdir(dirPath);
logger.debug('Directory entries retrieved successfully:', {
dirPath,
entriesCount: entries.length,
});
for (const entry of entries) {
// Skip specific system files based on the ignore list
if (SYSTEM_FILES_TO_IGNORE.includes(entry)) {
logger.debug('Ignoring system file:', { fileName: entry });
continue;
}
const fullPath = path.join(dirPath, entry);
try {
const stats = await stat(fullPath);
const isDirectory = stats.isDirectory();
results.push({
createdTime: stats.birthtime,
isDirectory,
lastAccessTime: stats.atime,
modifiedTime: stats.mtime,
name: entry,
path: fullPath,
size: stats.size,
type: isDirectory ? 'directory' : path.extname(entry).toLowerCase().replace('.', ''),
});
} catch (statError) {
// Silently ignore files we can't stat (e.g. permissions)
logger.error(`Failed to get file status ${fullPath}:`, statError);
}
}
// Sort entries: folders first, then by name
results.sort((a, b) => {
if (a.isDirectory !== b.isDirectory) {
return a.isDirectory ? -1 : 1; // Directories first
}
// Add null/undefined checks for robustness if needed, though names should exist
return (a.name || '').localeCompare(b.name || ''); // Then sort by name
});
logger.debug('Directory listing successful', { dirPath, resultCount: results.length });
return results;
} 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 [];
}
}
('moveLocalFiles')
async handleMoveFiles({ items }: MoveLocalFilesParams): Promise<LocalMoveFilesResultItem[]> {
logger.debug('Starting batch file move:', { itemsCount: items?.length });
const results: LocalMoveFilesResultItem[] = [];
if (!items || items.length === 0) {
logger.warn('moveLocalFiles called with empty parameters');
return [];
}
// Process each move request
for (const item of items) {
const { oldPath: sourcePath, newPath } = item;
const logPrefix = `[Moving file ${sourcePath} -> ${newPath}]`;
logger.debug(`${logPrefix} Starting process`);
const resultItem: LocalMoveFilesResultItem = {
newPath: undefined,
sourcePath,
success: false,
};
// Basic validation
if (!sourcePath || !newPath) {
logger.error(`${logPrefix} Parameter validation failed: source or target path is empty`);
resultItem.error = 'Both oldPath and newPath are required for each item.';
results.push(resultItem);
continue;
}
try {
// Check if source exists
try {
await access(sourcePath, constants.F_OK);
logger.debug(`${logPrefix} Source file exists`);
} catch (accessError: any) {
if (accessError.code === 'ENOENT') {
logger.error(`${logPrefix} Source file does not exist`);
throw new Error(`Source path not found: ${sourcePath}`);
} else {
logger.error(`${logPrefix} Permission error accessing source file:`, accessError);
throw new Error(
`Permission denied accessing source path: ${sourcePath}. ${accessError.message}`,
);
}
}
// Check if target path is the same as source path
if (path.normalize(sourcePath) === path.normalize(newPath)) {
logger.info(`${logPrefix} Source and target paths are identical, skipping move`);
resultItem.success = true;
resultItem.newPath = newPath; // Report target path even if not moved
results.push(resultItem);
continue;
}
// LBYL: Ensure target directory exists
const targetDir = path.dirname(newPath);
makeSureDirExist(targetDir);
logger.debug(`${logPrefix} Ensured target directory exists: ${targetDir}`);
// Execute move (rename)
await rename(sourcePath, newPath);
resultItem.success = true;
resultItem.newPath = newPath;
logger.info(`${logPrefix} Move successful`);
} catch (error) {
logger.error(`${logPrefix} Move failed:`, error);
// Use similar error handling logic as handleMoveFile
let errorMessage = (error as Error).message;
if ((error as any).code === 'ENOENT')
errorMessage = `Source path not found: ${sourcePath}.`;
else if ((error as any).code === 'EPERM' || (error as any).code === 'EACCES')
errorMessage = `Permission denied to move the item at ${sourcePath}. Check file/folder permissions.`;
else if ((error as any).code === 'EBUSY')
errorMessage = `The file or directory at ${sourcePath} or ${newPath} is busy or locked by another process.`;
else if ((error as any).code === 'EXDEV')
errorMessage = `Cannot move across different file systems or drives. Source: ${sourcePath}, Target: ${newPath}.`;
else if ((error as any).code === 'EISDIR')
errorMessage = `Cannot overwrite a directory with a file, or vice versa. Source: ${sourcePath}, Target: ${newPath}.`;
else if ((error as any).code === 'ENOTEMPTY')
errorMessage = `The target directory ${newPath} is not empty (relevant on some systems if target exists and is a directory).`;
else if ((error as any).code === 'EEXIST')
errorMessage = `An item already exists at the target path: ${newPath}.`;
// Keep more specific errors from access or directory checks
else if (
!errorMessage.startsWith('Source path not found') &&
!errorMessage.startsWith('Permission denied accessing source path') &&
!errorMessage.includes('Target directory')
) {
// Keep the original error message if none of the specific codes match
}
resultItem.error = errorMessage;
}
results.push(resultItem);
}
logger.debug('Batch file move completed', {
successCount: results.filter((r) => r.success).length,
totalCount: results.length,
});
return results;
}
('renameLocalFile')
async handleRenameFile({
path: currentPath,
newName,
}: {
newName: string;
path: string;
}): Promise<RenameLocalFileResult> {
const logPrefix = `[Renaming ${currentPath} -> ${newName}]`;
logger.debug(`${logPrefix} Starting rename request`);
// Basic validation (can also be done in frontend action)
if (!currentPath || !newName) {
logger.error(`${logPrefix} Parameter validation failed: path or new name is empty`);
return { error: 'Both path and newName are required.', newPath: '', success: false };
}
// Prevent path traversal or using invalid characters/names
if (
newName.includes('/') ||
newName.includes('\\') ||
newName === '.' ||
newName === '..' ||
/["*/:<>?\\|]/.test(newName) // Check for typical invalid filename characters
) {
logger.error(`${logPrefix} New filename contains illegal characters: ${newName}`);
return {
error:
'Invalid new name. It cannot contain path separators (/, \\), be "." or "..", or include characters like < > : " / \\ | ? *.',
newPath: '',
success: false,
};
}
let newPath: string;
try {
const dir = path.dirname(currentPath);
newPath = path.join(dir, newName);
logger.debug(`${logPrefix} Calculated new path: ${newPath}`);
// Check if paths are identical after calculation
if (path.normalize(currentPath) === path.normalize(newPath)) {
logger.info(
`${logPrefix} Source path and calculated target path are identical, skipping rename`,
);
// Consider success as no change is needed, but maybe inform the user?
// Return success for now.
return { newPath, success: true };
}
} catch (error) {
logger.error(`${logPrefix} Failed to calculate new path:`, error);
return {
error: `Internal error calculating the new path: ${(error as Error).message}`,
newPath: '',
success: false,
};
}
// Perform the rename operation using rename directly
try {
await rename(currentPath, newPath);
logger.info(`${logPrefix} Rename successful: ${currentPath} -> ${newPath}`);
// Optionally return the newPath if frontend needs it
// return { success: true, newPath: newPath };
return { newPath, success: true };
} catch (error) {
logger.error(`${logPrefix} Rename failed:`, error);
let errorMessage = (error as Error).message;
// Provide more specific error messages based on common codes
if ((error as any).code === 'ENOENT') {
errorMessage = `File or directory not found at the original path: ${currentPath}.`;
} else if ((error as any).code === 'EPERM' || (error as any).code === 'EACCES') {
errorMessage = `Permission denied to rename the item at ${currentPath}. Check file/folder permissions.`;
} else if ((error as any).code === 'EBUSY') {
errorMessage = `The file or directory at ${currentPath} or ${newPath} is busy or locked by another process.`;
} else if ((error as any).code === 'EISDIR' || (error as any).code === 'ENOTDIR') {
errorMessage = `Cannot rename - conflict between file and directory. Source: ${currentPath}, Target: ${newPath}.`;
} else if ((error as any).code === 'EEXIST') {
// Target already exists
errorMessage = `Cannot rename: an item with the name '${newName}' already exists at this location.`;
}
// Add more specific checks as needed
return { error: errorMessage, newPath: '', success: false };
}
}
('writeLocalFile')
async handleWriteFile({ path: filePath, content }: WriteLocalFileParams) {
const logPrefix = `[Writing file ${filePath}]`;
logger.debug(`${logPrefix} Starting to write file`, { contentLength: content?.length });
// Validate parameters
if (!filePath) {
logger.error(`${logPrefix} Parameter validation failed: path is empty`);
return { error: 'Path cannot be empty', success: false };
}
if (content === undefined) {
logger.error(`${logPrefix} Parameter validation failed: content is empty`);
return { error: 'Content cannot be empty', success: false };
}
try {
// Ensure target directory exists (use async to avoid blocking main thread)
const dirname = path.dirname(filePath);
logger.debug(`${logPrefix} Creating directory: ${dirname}`);
await mkdir(dirname, { recursive: true });
// Write file content
logger.debug(`${logPrefix} Starting to write content to file`);
await writeFile(filePath, content, 'utf8');
logger.info(`${logPrefix} File written successfully`, {
path: filePath,
size: content.length,
});
return { success: true };
} catch (error) {
logger.error(`${logPrefix} Failed to write file:`, error);
return {
error: `Failed to write file: ${(error as Error).message}`,
success: false,
};
}
}
// ==================== Search & Find ====================
/**
* Handle IPC event for local file search
*/
('searchLocalFiles')
async handleLocalFilesSearch(params: LocalSearchFilesParams): Promise<FileResult[]> {
logger.debug('Received file search request:', { keywords: params.keywords });
const options: Omit<SearchOptions, 'keywords'> = {
limit: 30,
};
try {
const results = await this.searchService.search(params.keywords, options);
logger.debug('File search completed', { count: results.length });
return results;
} catch (error) {
logger.error('File search failed:', error);
return [];
}
}
('grepContent')
async handleGrepContent(params: GrepContentParams): Promise<GrepContentResult> {
const {
pattern,
path: searchPath = process.cwd(),
output_mode = 'files_with_matches',
} = params;
const logPrefix = `[grepContent: ${pattern}]`;
logger.debug(`${logPrefix} Starting content search`, { output_mode, searchPath });
try {
const regex = new RegExp(
pattern,
`g${params['-i'] ? 'i' : ''}${params.multiline ? 's' : ''}`,
);
// Determine files to search
let filesToSearch: string[] = [];
const stats = await stat(searchPath);
if (stats.isFile()) {
filesToSearch = [searchPath];
} else {
// Use glob pattern if provided, otherwise search all files
const globPattern = params.glob || '**/*';
filesToSearch = await fg(globPattern, {
absolute: true,
cwd: searchPath,
dot: true,
ignore: ['**/node_modules/**', '**/.git/**'],
});
// Filter by type if provided
if (params.type) {
const ext = `.${params.type}`;
filesToSearch = filesToSearch.filter((file) => file.endsWith(ext));
}
}
logger.debug(`${logPrefix} Found ${filesToSearch.length} files to search`);
const matches: string[] = [];
let totalMatches = 0;
for (const filePath of filesToSearch) {
try {
const fileStats = await stat(filePath);
if (!fileStats.isFile()) continue;
const content = await readFile(filePath, 'utf8');
const lines = content.split('\n');
switch (output_mode) {
case 'files_with_matches': {
if (regex.test(content)) {
matches.push(filePath);
totalMatches++;
if (params.head_limit && matches.length >= params.head_limit) break;
}
break;
}
case 'content': {
const matchedLines: string[] = [];
for (let i = 0; i < lines.length; i++) {
if (regex.test(lines[i])) {
const contextBefore = params['-B'] || params['-C'] || 0;
const contextAfter = params['-A'] || params['-C'] || 0;
const startLine = Math.max(0, i - contextBefore);
const endLine = Math.min(lines.length - 1, i + contextAfter);
for (let j = startLine; j <= endLine; j++) {
const lineNum = params['-n'] ? `${j + 1}:` : '';
matchedLines.push(`${filePath}:${lineNum}${lines[j]}`);
}
totalMatches++;
}
}
matches.push(...matchedLines);
if (params.head_limit && matches.length >= params.head_limit) break;
break;
}
case 'count': {
const fileMatches = (content.match(regex) || []).length;
if (fileMatches > 0) {
matches.push(`${filePath}:${fileMatches}`);
totalMatches += fileMatches;
}
break;
}
}
} catch (error) {
logger.debug(`${logPrefix} Skipping file ${filePath}:`, error);
}
}
logger.info(`${logPrefix} Search completed`, {
matchCount: matches.length,
totalMatches,
});
return {
matches: params.head_limit ? matches.slice(0, params.head_limit) : matches,
success: true,
total_matches: totalMatches,
};
} catch (error) {
logger.error(`${logPrefix} Grep failed:`, error);
return {
matches: [],
success: false,
total_matches: 0,
};
}
}
('globLocalFiles')
async handleGlobFiles({
path: searchPath = process.cwd(),
pattern,
}: GlobFilesParams): Promise<GlobFilesResult> {
const logPrefix = `[globFiles: ${pattern}]`;
logger.debug(`${logPrefix} Starting glob search`, { searchPath });
try {
const files = await fg(pattern, {
absolute: true,
cwd: searchPath,
dot: true,
onlyFiles: false,
stats: true,
});
// Sort by modification time (most recent first)
const sortedFiles = (files as unknown as Array<{ path: string; stats: Stats }>)
.sort((a, b) => b.stats.mtime.getTime() - a.stats.mtime.getTime())
.map((f) => f.path);
logger.info(`${logPrefix} Glob completed`, { fileCount: sortedFiles.length });
return {
files: sortedFiles,
success: true,
total_files: sortedFiles.length,
};
} catch (error) {
logger.error(`${logPrefix} Glob failed:`, error);
return {
files: [],
success: false,
total_files: 0,
};
}
}
// ==================== File Editing ====================
('editLocalFile')
async handleEditFile({
file_path: filePath,
new_string,
old_string,
replace_all = false,
}: EditLocalFileParams): Promise<EditLocalFileResult> {
const logPrefix = `[editFile: ${filePath}]`;
logger.debug(`${logPrefix} Starting file edit`, { replace_all });
try {
// Read file content
const content = await readFile(filePath, 'utf8');
// Check if old_string exists
if (!content.includes(old_string)) {
logger.error(`${logPrefix} Old string not found in file`);
return {
error: 'The specified old_string was not found in the file',
replacements: 0,
success: false,
};
}
// Perform replacement
let newContent: string;
let replacements: number;
if (replace_all) {
const regex = new RegExp(old_string.replaceAll(/[$()*+.?[\\\]^{|}]/g, '\\$&'), 'g');
const matches = content.match(regex);
replacements = matches ? matches.length : 0;
newContent = content.replaceAll(old_string, new_string);
} else {
// Replace only first occurrence
const index = content.indexOf(old_string);
if (index === -1) {
return {
error: 'Old string not found',
replacements: 0,
success: false,
};
}
newContent =
content.slice(0, index) + new_string + content.slice(index + old_string.length);
replacements = 1;
}
// Write back to file
await writeFile(filePath, newContent, 'utf8');
logger.info(`${logPrefix} File edited successfully`, { replacements });
return {
replacements,
success: true,
};
} catch (error) {
logger.error(`${logPrefix} Edit failed:`, error);
return {
error: (error as Error).message,
replacements: 0,
success: false,
};
}
}
}