import { BadRequestException, Body, Controller, Post, Request, UploadedFile, UseGuards, UseInterceptors, Logger, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { UploadService } from './upload.service'; import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service'; import { CombinedAuthGuard } from '../auth/combined-auth.guard'; import { RolesGuard } from '../auth/roles.guard'; import { Roles } from '../auth/roles.decorator'; import { UserRole } from '../user/user-role.enum'; import { errorMessages } from '../i18n/messages'; import { DEFAULT_CHUNK_SIZE, MIN_CHUNK_SIZE, MAX_CHUNK_SIZE, DEFAULT_CHUNK_OVERLAP, DEFAULT_MAX_OVERLAP_RATIO, MAX_FILE_SIZE, DEFAULT_LANGUAGE } from '../common/constants'; import { I18nService } from '../i18n/i18n.service'; import { isAllowedByExtension, IMAGE_MIME_TYPES } from '../common/file-support.constants'; export interface UploadConfigDto { chunkSize?: string; chunkOverlap?: string; embeddingModelId?: string; mode?: 'fast' | 'precise'; // 処理モード } @Controller('upload') @UseGuards(CombinedAuthGuard, RolesGuard) export class UploadController { private readonly logger = new Logger(UploadController.name); constructor( private readonly uploadService: UploadService, private readonly knowledgeBaseService: KnowledgeBaseService, private readonly i18nService: I18nService, ) { } @Post() @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN) @UseInterceptors( FileInterceptor('file', { fileFilter: (req, file, cb) => { // 画像MIMEタイプまたは拡張子によるチェック const isAllowed = IMAGE_MIME_TYPES.includes(file.mimetype) || isAllowedByExtension(file.originalname); if (isAllowed) { cb(null, true); } else { cb( new BadRequestException( (errorMessages[DEFAULT_LANGUAGE]?.uploadTypeUnsupported || errorMessages['ja'].uploadTypeUnsupported).replace('{type}', file?.mimetype ?? 'unknown') ), false, ); } }, }), ) async uploadFile( @UploadedFile() file: Express.Multer.File, @Request() req, @Body() config: UploadConfigDto, ) { if (!file) { throw new BadRequestException(this.i18nService.getMessage('uploadNoFile')); } // ファイルサイズの検証(フロントエンド制限 + バックエンド検証) const maxFileSize = parseInt( process.env.MAX_FILE_SIZE || String(MAX_FILE_SIZE), ); // 100MB if (file.size > maxFileSize) { throw new BadRequestException( this.i18nService.formatMessage('uploadSizeExceeded', { size: this.formatBytes(file.size), max: this.formatBytes(maxFileSize) }), ); } // 埋め込みモデル設定の検証 if (!config.embeddingModelId) { throw new BadRequestException(this.i18nService.getMessage('uploadModelRequired')); } this.logger.log( `ユーザー ${req.user.id} がファイルをアップロードしました: ${file.originalname} (${this.formatBytes(file.size)})`, ); const fileInfo = await this.uploadService.processUploadedFile(file); // 設定パラメータを解析し、安全なデフォルト値を設定 const indexingConfig = { chunkSize: config.chunkSize ? parseInt(config.chunkSize) : DEFAULT_CHUNK_SIZE, chunkOverlap: config.chunkOverlap ? parseInt(config.chunkOverlap) : DEFAULT_CHUNK_OVERLAP, embeddingModelId: config.embeddingModelId || null, }; // オーバーラップサイズがチャンクサイズの50%を超えないようにする if (indexingConfig.chunkOverlap > indexingConfig.chunkSize * DEFAULT_MAX_OVERLAP_RATIO) { indexingConfig.chunkOverlap = Math.floor(indexingConfig.chunkSize * DEFAULT_MAX_OVERLAP_RATIO); this.logger.warn( this.i18nService.formatMessage('overlapAdjusted', { newSize: indexingConfig.chunkOverlap }), ); } // データベースに保存し、インデックスプロセスをトリガー(非同期) const kb = await this.knowledgeBaseService.createAndIndex( fileInfo, req.user.id, req.user.tenantId, // Ensure tenantId is passed down { ...indexingConfig, mode: config.mode || 'fast', } as any, // Bypass strict type check for now or cast to correct type ); return { message: this.i18nService.getMessage('uploadSuccess'), id: kb.id, filename: fileInfo.filename, originalname: fileInfo.originalname, status: kb.status, mode: config.mode || 'fast', config: indexingConfig, estimatedChunks: Math.ceil(file.size / (indexingConfig.chunkSize * 4)), // 推定チャンク数 }; } @Post('text') @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN) async uploadText( @Request() req, @Body() body: { content: string; title: string; chunkSize?: string; chunkOverlap?: string; embeddingModelId?: string; mode?: 'fast' | 'precise'; } ) { if (!body.content || !body.title) { throw new BadRequestException(this.i18nService.getMessage('contentAndTitleRequired')); } const fs = await import('fs'); const path = await import('path'); const uploadPath = process.env.UPLOAD_FILE_PATH || './uploads'; if (!fs.existsSync(uploadPath)) { fs.mkdirSync(uploadPath, { recursive: true }); } const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); const filename = `note-${uniqueSuffix}.md`; const filePath = path.join(uploadPath, filename); // Save content to file (mocking a file upload) fs.writeFileSync(filePath, body.content, 'utf8'); const fileSize = Buffer.byteLength(body.content, 'utf8'); // Mimic Multer file info const fileInfo = { filename: filename, originalname: body.title.endsWith('.md') ? body.title : `${body.title}.md`, size: fileSize, mimetype: 'text/markdown', path: filePath }; // Validating Config if (!body.embeddingModelId) { throw new BadRequestException(this.i18nService.getMessage('uploadModelRequired')); } const indexingConfig = { chunkSize: body.chunkSize ? parseInt(body.chunkSize) : DEFAULT_CHUNK_SIZE, chunkOverlap: body.chunkOverlap ? parseInt(body.chunkOverlap) : DEFAULT_CHUNK_OVERLAP, embeddingModelId: body.embeddingModelId || null, }; if (indexingConfig.chunkOverlap > indexingConfig.chunkSize * DEFAULT_MAX_OVERLAP_RATIO) { indexingConfig.chunkOverlap = Math.floor(indexingConfig.chunkSize * DEFAULT_MAX_OVERLAP_RATIO); } const kb = await this.knowledgeBaseService.createAndIndex( fileInfo, req.user.id, req.user.tenantId, { ...indexingConfig, mode: body.mode || 'fast', } as any ); return { message: this.i18nService.getMessage('uploadTextSuccess'), id: kb.id, filename: fileInfo.filename, originalname: fileInfo.originalname, status: kb.status, config: indexingConfig }; } private formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; } }