@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
text/typescript
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;
}
}