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.

295 lines (261 loc) 9.41 kB
import { DeleteFilesResponse } from '@lobechat/electron-server-ipc'; import * as fs from 'node:fs'; import { writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { promisify } from 'node:util'; import { FILE_STORAGE_DIR } from '@/const/dir'; import { makeSureDirExist } from '@/utils/file-system'; import { createLogger } from '@/utils/logger'; import { ServiceModule } from './index'; const readFilePromise = promisify(fs.readFile); const unlinkPromise = promisify(fs.unlink); // Create logger const logger = createLogger('services:FileService'); interface UploadFileParams { content: ArrayBuffer; filename: string; hash: string; path: string; type: string; } interface FileMetadata { date: string; dirname: string; filename: string; path: string; } export default class FileService extends ServiceModule { get UPLOADS_DIR() { return join(this.app.appStoragePath, FILE_STORAGE_DIR, 'uploads'); } constructor(app) { super(app); // Initialize file storage directory logger.info('Initializing file storage directory'); makeSureDirExist(this.UPLOADS_DIR); logger.debug(`Upload directory created: ${this.UPLOADS_DIR}`); } /** * 上传文件到本地存储 */ async uploadFile({ content, filename, hash, type, }: UploadFileParams): Promise<{ metadata: FileMetadata; success: boolean }> { logger.info(`Starting to upload file: ${filename}, hash: ${hash}`); try { // 创建时间戳目录 const date = (Date.now() / 1000 / 60 / 60).toFixed(0); const dirname = join(this.UPLOADS_DIR, date); logger.debug(`Creating timestamp directory: ${dirname}`); makeSureDirExist(dirname); // 生成文件保存路径 const fileExt = filename.split('.').pop() || ''; const savedFilename = `${hash}${fileExt ? `.${fileExt}` : ''}`; const savedPath = join(dirname, savedFilename); logger.debug(`Generated file save path: ${savedPath}`); // 写入文件内容 const buffer = Buffer.from(content); logger.debug(`Writing file content, size: ${buffer.length} bytes`); await writeFile(savedPath, buffer); // 写入元数据文件 const metaFilePath = `${savedPath}.meta`; const metadata = { createdAt: Date.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://${date}/${savedFilename}`; logger.info(`File upload successful: ${desktopPath}`); return { metadata: { date, dirname: date, filename: savedFilename, path: desktopPath, }, success: true, }; } catch (error) { logger.error(`File upload failed:`, error); throw new Error(`File upload failed: ${(error as Error).message}`); } } /** * 获取文件内容 */ 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://', ''); const filePath = join(this.UPLOADS_DIR, relativePath); logger.debug(`Reading file from path: ${filePath}`); // 读取文件内容 logger.debug(`Starting to read file content`); const content = await readFilePromise(filePath); logger.debug(`File content read complete, size: ${content.length} bytes`); // 读取元数据获取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); 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 relativePath = path.replace('desktop://', ''); const filePath = join(this.UPLOADS_DIR, relativePath); logger.debug(`File deletion path: ${filePath}`); // 删除文件及其元数据 logger.debug(`Starting file deletion`); await unlinkPromise(filePath); logger.debug(`File deletion successful`); // 尝试删除元数据文件,但不强制要求存在 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 relativePath = path.replace('desktop://', ''); const fullPath = join(this.UPLOADS_DIR, relativePath); logger.debug(`Resolved filesystem path: ${fullPath}`); return fullPath; } }