UNPKG

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

482 lines (428 loc) 17.2 kB
import { DeleteFilesResponse } from '@lobechat/electron-server-ipc'; import * as fs from 'node:fs'; import { writeFile } from 'node:fs/promises'; import path, { join } from 'node:path'; import { promisify } from 'node:util'; import { FILE_STORAGE_DIR, LOCAL_STORAGE_URL_PREFIX } from '@/const/dir'; import { makeSureDirExist } from '@/utils/file-system'; import { createLogger } from '@/utils/logger'; import { ServiceModule } from './index'; /** * 文件未找到错误类 */ export class FileNotFoundError extends Error { constructor( message: string, public path: string, ) { super(message); this.name = 'FileNotFoundError'; } } const readFilePromise = promisify(fs.readFile); const unlinkPromise = promisify(fs.unlink); // Create logger const logger = createLogger('services:FileService'); interface UploadFileParams { content: ArrayBuffer | string; // ArrayBuffer from browser or Base64 string from server filename: string; hash: string; path: string; type: string; } interface FileMetadata { date: string; dirname: string; filename: string; path: string; } export default class FileService extends ServiceModule { /** * 获取旧版上传目录路径 * @deprecated 仅用于向后兼容旧版文件访问,新文件应存储在 FILE_STORAGE_DIR 的自定义路径下 */ get UPLOADS_DIR() { return join(this.app.appStoragePath, FILE_STORAGE_DIR, 'uploads'); } constructor(app) { super(app); } /** * 上传文件到本地存储 */ async uploadFile({ content, filename, hash, path: filePath, type, }: UploadFileParams): Promise<{ metadata: FileMetadata; success: boolean }> { logger.info(`Starting to upload file: ${filename}, hash: ${hash}, path: ${filePath}`); try { // 获取当前时间戳,避免重复调用 Date.now() const now = Date.now(); const date = (now / 1000 / 60 / 60).toFixed(0); // 使用传入的 filePath 作为文件的存储路径 const fullStoragePath = join(this.app.appStoragePath, FILE_STORAGE_DIR, filePath); logger.debug(`Target file storage path: ${fullStoragePath}`); // 确保目标目录存在 const targetDir = path.dirname(fullStoragePath); logger.debug(`Ensuring target directory exists: ${targetDir}`); makeSureDirExist(targetDir); const savedPath = fullStoragePath; logger.debug(`Final file save path: ${savedPath}`); // 根据 content 类型创建 Buffer let buffer: Buffer; if (typeof content === 'string') { // 来自服务端的 Base64 字符串 buffer = Buffer.from(content, 'base64'); logger.debug(`Creating buffer from Base64 string, size: ${buffer.length} bytes`); } else { // 来自浏览器端的 ArrayBuffer buffer = Buffer.from(content); logger.debug(`Creating buffer from ArrayBuffer, size: ${buffer.length} bytes`); } await writeFile(savedPath, buffer); // 写入元数据文件 const metaFilePath = `${savedPath}.meta`; const metadata = { createdAt: now, // 使用统一的时间戳 filename, hash, size: buffer.length, type, }; logger.debug(`Writing metadata file: ${metaFilePath}`); await writeFile(metaFilePath, JSON.stringify(metadata, null, 2)); // 返回与S3兼容的元数据格式 const desktopPath = `desktop://${filePath}`; logger.info(`File upload successful: ${desktopPath}`); // 从路径中提取文件名和目录信息 const parsedPath = path.parse(filePath); const dirname = parsedPath.dir || ''; const savedFilename = parsedPath.base; return { metadata: { date, // 保持时间戳格式,用于兼容性和时间追踪 dirname, filename: savedFilename, path: desktopPath, }, success: true, }; } catch (error) { logger.error(`File upload failed:`, error); throw new Error(`File upload failed: ${(error as Error).message}`); } } /** * 判断路径是否为旧版格式(时间戳目录) * * 旧版路径格式: {timestamp}/{hash}.{ext} (例如: 1234567890/abc123.png) * 新版路径格式: 任意自定义路径 (例如: user_uploads/images/photo.png, ai_generations/image.jpg) * * @param path - 相对路径,不包含 desktop:// 前缀 * @returns true 如果是旧版格式,false 如果是新版格式 */ private isLegacyPath(path: string): boolean { const parts = path.split('/'); if (parts.length < 2) return false; // 如果第一部分是纯数字(时间戳),则认为是旧版格式 // 时间戳格式:精确到小时的 Unix 时间戳,通常是 10 位数字 return /^\d+$/.test(parts[0]); } /** * 获取文件内容 */ async getFile(path: string): Promise<{ content: ArrayBuffer; mimeType: string }> { logger.info(`Getting file content: ${path}`); try { // 处理desktop://路径 if (!path.startsWith('desktop://')) { logger.error(`Invalid desktop file path: ${path}`); throw new Error(`Invalid desktop file path: ${path}`); } // 标准化路径格式 // 可能收到的格式: desktop:/12345/file.png 或 desktop://12345/file.png const normalizedPath = path.replace(/^desktop:\/+/, 'desktop://'); logger.debug(`Normalized path: ${normalizedPath}`); // 解析路径 const relativePath = normalizedPath.replace('desktop://', ''); // 智能路由:根据路径格式决定从哪个目录读取文件 let filePath: string; let isLegacyAttempt = false; if (this.isLegacyPath(relativePath)) { // 旧版路径:从 uploads 目录读取(向后兼容) filePath = join(this.UPLOADS_DIR, relativePath); isLegacyAttempt = true; logger.debug(`Legacy path detected, reading from uploads directory: ${filePath}`); } else { // 新版路径:从 FILE_STORAGE_DIR 根目录读取 filePath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath); logger.debug(`New path format, reading from storage root: ${filePath}`); } // 读取文件内容,如果第一次尝试失败且是 legacy 路径,则尝试新路径 logger.debug(`Starting to read file content`); let content: Buffer; try { content = await readFilePromise(filePath); logger.debug(`File content read complete, size: ${content.length} bytes`); } catch (firstError) { if (isLegacyAttempt) { // 如果是 legacy 路径读取失败,尝试从新路径读取 const fallbackPath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath); logger.debug( `Legacy path read failed, attempting fallback to storage root: ${fallbackPath}`, ); try { content = await readFilePromise(fallbackPath); filePath = fallbackPath; // 更新 filePath 用于后续的元数据读取 logger.debug(`Fallback read successful, size: ${content.length} bytes`); } catch (fallbackError) { logger.error( `Both legacy and fallback paths failed. Legacy error: ${(firstError as Error).message}, Fallback error: ${(fallbackError as Error).message}`, ); throw firstError; // 抛出原始错误 } } else { throw firstError; } } // 读取元数据获取MIME类型 const metaFilePath = `${filePath}.meta`; let mimeType = 'application/octet-stream'; // 默认MIME类型 logger.debug(`Attempting to read metadata file: ${metaFilePath}`); try { const metaContent = await readFilePromise(metaFilePath, 'utf8'); const metadata = JSON.parse(metaContent); mimeType = metadata.type || mimeType; logger.debug(`Got MIME type from metadata: ${mimeType}`); } catch (metaError) { logger.warn( `Failed to read metadata file: ${(metaError as Error).message}, using default MIME type`, ); // 如果元数据文件不存在,尝试从文件扩展名猜测MIME类型 const ext = path.split('.').pop()?.toLowerCase(); if (ext) { if (['jpg', 'jpeg'].includes(ext)) mimeType = 'image/jpeg'; else switch (ext) { case 'png': { mimeType = 'image/png'; break; } case 'gif': { mimeType = 'image/gif'; break; } case 'webp': { mimeType = 'image/webp'; break; } case 'svg': { mimeType = 'image/svg+xml'; break; } case 'pdf': { { mimeType = 'application/pdf'; // No default } break; } } logger.debug(`Set MIME type based on file extension: ${mimeType}`); } } logger.info(`File retrieval successful: ${path}`); return { content: content.buffer as ArrayBuffer, mimeType, }; } catch (error) { logger.error(`File retrieval failed:`, error); // 如果是文件不存在错误,抛出自定义的 FileNotFoundError if (error instanceof Error && error.message.includes('ENOENT')) { throw new FileNotFoundError(`File not found: ${path}`, path); } throw new Error(`File retrieval failed: ${(error as Error).message}`); } } /** * 删除文件 */ async deleteFile(path: string): Promise<{ success: boolean }> { logger.info(`Deleting file: ${path}`); try { // 处理desktop://路径 if (!path.startsWith('desktop://')) { logger.error(`Invalid desktop file path: ${path}`); throw new Error(`Invalid desktop file path: ${path}`); } // 标准化路径格式 const normalizedPath = path.replace(/^desktop:\/+/, 'desktop://'); // 解析路径 const relativePath = normalizedPath.replace('desktop://', ''); // 智能路由:根据路径格式决定从哪个目录删除文件 let filePath: string; let isLegacyAttempt = false; if (this.isLegacyPath(relativePath)) { // 旧版路径:从 uploads 目录删除(向后兼容) filePath = join(this.UPLOADS_DIR, relativePath); isLegacyAttempt = true; logger.debug(`Legacy path detected, deleting from uploads directory: ${filePath}`); } else { // 新版路径:从 FILE_STORAGE_DIR 根目录删除 filePath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath); logger.debug(`New path format, deleting from storage root: ${filePath}`); } // 删除文件及其元数据,如果第一次尝试失败且是 legacy 路径,则尝试新路径 logger.debug(`Starting file deletion`); try { await unlinkPromise(filePath); logger.debug(`File deletion successful`); } catch (firstError) { if (isLegacyAttempt) { // 如果是 legacy 路径删除失败,尝试从新路径删除 const fallbackPath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath); logger.debug( `Legacy path deletion failed, attempting fallback to storage root: ${fallbackPath}`, ); try { await unlinkPromise(fallbackPath); filePath = fallbackPath; // 更新 filePath 用于后续的元数据删除 logger.debug(`Fallback deletion successful`); } catch (fallbackError) { logger.error( `Both legacy and fallback deletion failed. Legacy error: ${(firstError as Error).message}, Fallback error: ${(fallbackError as Error).message}`, ); throw firstError; // 抛出原始错误 } } else { throw firstError; } } // 尝试删除元数据文件,但不强制要求存在 try { logger.debug(`Attempting to delete metadata file`); await unlinkPromise(`${filePath}.meta`); logger.debug(`Metadata file deletion successful`); } catch (error) { logger.warn(`Failed to delete metadata file: ${(error as Error).message}`); } logger.info(`File deletion operation complete: ${path}`); return { success: true }; } catch (error) { logger.error(`File deletion failed:`, error); throw new Error(`File deletion failed: ${(error as Error).message}`); } } /** * 批量删除文件 */ async deleteFiles(paths: string[]): Promise<DeleteFilesResponse> { logger.info(`Batch deleting files, count: ${paths.length}`); const errors: { message: string; path: string }[] = []; // 并行处理所有删除请求 logger.debug(`Starting parallel deletion requests`); const results = await Promise.allSettled( paths.map(async (path) => { try { await this.deleteFile(path); return { path, success: true }; } catch (error) { logger.warn(`Failed to delete file: ${path}, error: ${(error as Error).message}`); return { error: (error as Error).message, path, success: false, }; } }), ); // 处理结果 logger.debug(`Processing batch deletion results`); results.forEach((result) => { if (result.status === 'rejected') { logger.error(`Unexpected error: ${result.reason}`); errors.push({ message: `Unexpected error: ${result.reason}`, path: 'unknown', }); } else if (!result.value.success) { errors.push({ message: result.value.error, path: result.value.path, }); } }); const success = errors.length === 0; logger.info( `Batch deletion operation complete, success: ${success}, error count: ${errors.length}`, ); return { success, ...(errors.length > 0 && { errors }), }; } async getFilePath(path: string): Promise<string> { logger.debug(`Getting filesystem path: ${path}`); // 处理desktop://路径 if (!path.startsWith('desktop://')) { logger.error(`Invalid desktop file path: ${path}`); throw new Error(`Invalid desktop file path: ${path}`); } // 标准化路径格式 const normalizedPath = path.replace(/^desktop:\/+/, 'desktop://'); // 解析路径 const relativePath = normalizedPath.replace('desktop://', ''); // 智能路由:根据路径格式决定从哪个目录获取文件路径 let fullPath: string; if (this.isLegacyPath(relativePath)) { // 旧版路径:从 uploads 目录获取(向后兼容) fullPath = join(this.UPLOADS_DIR, relativePath); logger.debug(`Legacy path detected, resolved to uploads directory: ${fullPath}`); // 检查文件是否存在,如果不存在则尝试新路径 try { await fs.promises.access(fullPath, fs.constants.F_OK); logger.debug(`Legacy path file exists: ${fullPath}`); } catch { // 如果 legacy 路径文件不存在,尝试新路径 const fallbackPath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath); logger.debug(`Legacy path file not found, trying fallback path: ${fallbackPath}`); try { await fs.promises.access(fallbackPath, fs.constants.F_OK); fullPath = fallbackPath; logger.debug(`Fallback path file exists: ${fullPath}`); } catch { // 两个路径都不存在,返回原始的 legacy 路径(保持原有行为) logger.debug( `Neither legacy nor fallback path exists, returning legacy path: ${fullPath}`, ); } } } else { // 新版路径:从 FILE_STORAGE_DIR 根目录获取 fullPath = join(this.app.appStoragePath, FILE_STORAGE_DIR, relativePath); logger.debug(`New path format, resolved to storage root: ${fullPath}`); } return fullPath; } async getFileHTTPURL(path: string): Promise<string> { logger.debug(`Getting file HTTP URL: ${path}`); // 处理desktop://路径 if (!path.startsWith('desktop://')) { logger.error(`Invalid desktop file path: ${path}`); throw new Error(`Invalid desktop file path: ${path}`); } // 标准化路径格式 const normalizedPath = path.replace(/^desktop:\/+/, 'desktop://'); // 解析路径:从 desktop://path/to/file.png 中提取 path/to/file.png const relativePath = normalizedPath.replace('desktop://', ''); // 使用 StaticFileServerManager 获取文件服务器域名,然后构建完整 URL const serverDomain = this.app.staticFileServerManager.getFileServerDomain(); const httpURL = `${serverDomain}${LOCAL_STORAGE_URL_PREFIX}/${relativePath}`; logger.debug(`Generated HTTP URL: ${httpURL}`); return httpURL; } }