upload.controller.ts 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  1. import {
  2. BadRequestException,
  3. Body,
  4. Controller,
  5. Post,
  6. Request,
  7. UploadedFile,
  8. UseGuards,
  9. UseInterceptors,
  10. Logger,
  11. } from '@nestjs/common';
  12. import { FileInterceptor } from '@nestjs/platform-express';
  13. import { UploadService } from './upload.service';
  14. import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
  15. import { CombinedAuthGuard } from '../auth/combined-auth.guard';
  16. import { RolesGuard } from '../auth/roles.guard';
  17. import { Roles } from '../auth/roles.decorator';
  18. import { UserRole } from '../user/user-role.enum';
  19. import { errorMessages } from '../i18n/messages';
  20. import {
  21. DEFAULT_CHUNK_SIZE,
  22. MIN_CHUNK_SIZE,
  23. MAX_CHUNK_SIZE,
  24. DEFAULT_CHUNK_OVERLAP,
  25. DEFAULT_MAX_OVERLAP_RATIO,
  26. MAX_FILE_SIZE,
  27. DEFAULT_LANGUAGE
  28. } from '../common/constants';
  29. import { I18nService } from '../i18n/i18n.service';
  30. import { isAllowedByExtension, IMAGE_MIME_TYPES } from '../common/file-support.constants';
  31. export interface UploadConfigDto {
  32. chunkSize?: string;
  33. chunkOverlap?: string;
  34. embeddingModelId?: string;
  35. mode?: 'fast' | 'precise'; // 処理モード
  36. }
  37. @Controller('upload')
  38. @UseGuards(CombinedAuthGuard, RolesGuard)
  39. export class UploadController {
  40. private readonly logger = new Logger(UploadController.name);
  41. constructor(
  42. private readonly uploadService: UploadService,
  43. private readonly knowledgeBaseService: KnowledgeBaseService,
  44. private readonly i18nService: I18nService,
  45. ) { }
  46. @Post()
  47. @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
  48. @UseInterceptors(
  49. FileInterceptor('file', {
  50. fileFilter: (req, file, cb) => {
  51. // 画像MIMEタイプまたは拡張子によるチェック
  52. const isAllowed = IMAGE_MIME_TYPES.includes(file.mimetype) ||
  53. isAllowedByExtension(file.originalname);
  54. if (isAllowed) {
  55. cb(null, true);
  56. } else {
  57. cb(
  58. new BadRequestException(
  59. (errorMessages[DEFAULT_LANGUAGE]?.uploadTypeUnsupported || errorMessages['ja'].uploadTypeUnsupported).replace('{type}', file?.mimetype ?? 'unknown')
  60. ),
  61. false,
  62. );
  63. }
  64. },
  65. }),
  66. )
  67. async uploadFile(
  68. @UploadedFile() file: Express.Multer.File,
  69. @Request() req,
  70. @Body() config: UploadConfigDto,
  71. ) {
  72. if (!file) {
  73. throw new BadRequestException(this.i18nService.getMessage('uploadNoFile'));
  74. }
  75. // ファイルサイズの検証(フロントエンド制限 + バックエンド検証)
  76. const maxFileSize = parseInt(
  77. process.env.MAX_FILE_SIZE || String(MAX_FILE_SIZE),
  78. ); // 100MB
  79. if (file.size > maxFileSize) {
  80. throw new BadRequestException(
  81. this.i18nService.formatMessage('uploadSizeExceeded', { size: this.formatBytes(file.size), max: this.formatBytes(maxFileSize) }),
  82. );
  83. }
  84. // 埋め込みモデル設定の検証
  85. if (!config.embeddingModelId) {
  86. throw new BadRequestException(this.i18nService.getMessage('uploadModelRequired'));
  87. }
  88. this.logger.log(
  89. `ユーザー ${req.user.id} がファイルをアップロードしました: ${file.originalname} (${this.formatBytes(file.size)})`,
  90. );
  91. const fileInfo = await this.uploadService.processUploadedFile(file);
  92. // 設定パラメータを解析し、安全なデフォルト値を設定
  93. const indexingConfig = {
  94. chunkSize: config.chunkSize ? parseInt(config.chunkSize) : DEFAULT_CHUNK_SIZE,
  95. chunkOverlap: config.chunkOverlap ? parseInt(config.chunkOverlap) : DEFAULT_CHUNK_OVERLAP,
  96. embeddingModelId: config.embeddingModelId || null,
  97. };
  98. // オーバーラップサイズがチャンクサイズの50%を超えないようにする
  99. if (indexingConfig.chunkOverlap > indexingConfig.chunkSize * DEFAULT_MAX_OVERLAP_RATIO) {
  100. indexingConfig.chunkOverlap = Math.floor(indexingConfig.chunkSize * DEFAULT_MAX_OVERLAP_RATIO);
  101. this.logger.warn(
  102. this.i18nService.formatMessage('overlapAdjusted', { newSize: indexingConfig.chunkOverlap }),
  103. );
  104. }
  105. // データベースに保存し、インデックスプロセスをトリガー(非同期)
  106. const kb = await this.knowledgeBaseService.createAndIndex(
  107. fileInfo,
  108. req.user.id,
  109. req.user.tenantId, // Ensure tenantId is passed down
  110. {
  111. ...indexingConfig,
  112. mode: config.mode || 'fast',
  113. } as any, // Bypass strict type check for now or cast to correct type
  114. );
  115. return {
  116. message: this.i18nService.getMessage('uploadSuccess'),
  117. id: kb.id,
  118. filename: fileInfo.filename,
  119. originalname: fileInfo.originalname,
  120. status: kb.status,
  121. mode: config.mode || 'fast',
  122. config: indexingConfig,
  123. estimatedChunks: Math.ceil(file.size / (indexingConfig.chunkSize * 4)), // 推定チャンク数
  124. };
  125. }
  126. @Post('text')
  127. @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN)
  128. async uploadText(
  129. @Request() req,
  130. @Body() body: {
  131. content: string;
  132. title: string;
  133. chunkSize?: string;
  134. chunkOverlap?: string;
  135. embeddingModelId?: string;
  136. mode?: 'fast' | 'precise';
  137. }
  138. ) {
  139. if (!body.content || !body.title) {
  140. throw new BadRequestException(this.i18nService.getMessage('contentAndTitleRequired'));
  141. }
  142. const fs = await import('fs');
  143. const path = await import('path');
  144. const uploadPath = process.env.UPLOAD_FILE_PATH || './uploads';
  145. if (!fs.existsSync(uploadPath)) {
  146. fs.mkdirSync(uploadPath, { recursive: true });
  147. }
  148. const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
  149. const filename = `note-${uniqueSuffix}.md`;
  150. const filePath = path.join(uploadPath, filename);
  151. // Save content to file (mocking a file upload)
  152. fs.writeFileSync(filePath, body.content, 'utf8');
  153. const fileSize = Buffer.byteLength(body.content, 'utf8');
  154. // Mimic Multer file info
  155. const fileInfo = {
  156. filename: filename,
  157. originalname: body.title.endsWith('.md') ? body.title : `${body.title}.md`,
  158. size: fileSize,
  159. mimetype: 'text/markdown',
  160. path: filePath
  161. };
  162. // Validating Config
  163. if (!body.embeddingModelId) {
  164. throw new BadRequestException(this.i18nService.getMessage('uploadModelRequired'));
  165. }
  166. const indexingConfig = {
  167. chunkSize: body.chunkSize ? parseInt(body.chunkSize) : DEFAULT_CHUNK_SIZE,
  168. chunkOverlap: body.chunkOverlap ? parseInt(body.chunkOverlap) : DEFAULT_CHUNK_OVERLAP,
  169. embeddingModelId: body.embeddingModelId || null,
  170. };
  171. if (indexingConfig.chunkOverlap > indexingConfig.chunkSize * DEFAULT_MAX_OVERLAP_RATIO) {
  172. indexingConfig.chunkOverlap = Math.floor(indexingConfig.chunkSize * DEFAULT_MAX_OVERLAP_RATIO);
  173. }
  174. const kb = await this.knowledgeBaseService.createAndIndex(
  175. fileInfo,
  176. req.user.id,
  177. req.user.tenantId,
  178. {
  179. ...indexingConfig,
  180. mode: body.mode || 'fast',
  181. } as any
  182. );
  183. return {
  184. message: this.i18nService.getMessage('uploadTextSuccess'),
  185. id: kb.id,
  186. filename: fileInfo.filename,
  187. originalname: fileInfo.originalname,
  188. status: kb.status,
  189. config: indexingConfig
  190. };
  191. }
  192. private formatBytes(bytes: number): string {
  193. if (bytes === 0) return '0 B';
  194. const k = 1024;
  195. const sizes = ['B', 'KB', 'MB', 'GB'];
  196. const i = Math.floor(Math.log(bytes) / Math.log(k));
  197. return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
  198. }
  199. }