import { Body, Controller, Delete, Get, Param, Post, Query, Request, UseGuards, Res, NotFoundException, InternalServerErrorException, } from '@nestjs/common'; import { Response } from 'express'; import * as path from 'path'; import { Logger } from '@nestjs/common'; import { KnowledgeBaseService } from './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 { Public } from '../auth/public.decorator'; import { KnowledgeBase } from './knowledge-base.entity'; import { ChunkConfigService } from './chunk-config.service'; import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service'; import { I18nService } from '../i18n/i18n.service'; @Controller('knowledge-bases') @UseGuards(CombinedAuthGuard, RolesGuard) export class KnowledgeBaseController { private readonly logger = new Logger(KnowledgeBaseController.name); constructor( private readonly knowledgeBaseService: KnowledgeBaseService, private readonly chunkConfigService: ChunkConfigService, private readonly knowledgeGroupService: KnowledgeGroupService, private readonly i18nService: I18nService, ) { } @Get() @UseGuards(CombinedAuthGuard) async findAll(@Request() req): Promise { return this.knowledgeBaseService.findAll(req.user.id, req.user.tenantId); } @Delete('clear') @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN) async clearAll(@Request() req): Promise<{ message: string }> { await this.knowledgeBaseService.clearAll(req.user.id, req.user.tenantId); return { message: this.i18nService.getMessage('kbCleared') }; } @Post('search') async search(@Request() req, @Body() body: { query: string; topK?: number }) { return this.knowledgeBaseService.searchKnowledge( req.user.id, req.user.tenantId, // New body.query, body.topK || 5, ); } @Post('rag-search') async ragSearch( @Request() req, @Body() body: { query: string; settings: any }, ) { return this.knowledgeBaseService.ragSearch( req.user.id, req.user.tenantId, // New body.query, body.settings, ); } @Delete(':id') @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN) async deleteFile( @Request() req, @Param('id') fileId: string, ): Promise<{ message: string }> { await this.knowledgeBaseService.deleteFile(fileId, req.user.id, req.user.tenantId); return { message: this.i18nService.getMessage('fileDeleted') }; } @Post(':id/retry') @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN) async retryFile( @Request() req, @Param('id') fileId: string, ): Promise { return this.knowledgeBaseService.retryFailedFile(fileId, req.user.id, req.user.tenantId); } @Get(':id/chunks') async getFileChunks( @Request() req, @Param('id') fileId: string, ) { return this.knowledgeBaseService.getFileChunks(fileId, req.user.id, req.user.tenantId); } /** * チャンク設定の制限を取得(フロントエンドのスライダー設定用) * クエリパラメータ: embeddingModelId - 埋め込みモデルID */ @Get('chunk-config/limits') async getChunkConfigLimits( @Request() req, @Query('embeddingModelId') embeddingModelId: string, ) { if (!embeddingModelId) { return { maxChunkSize: parseInt(process.env.MAX_CHUNK_SIZE || '8191'), maxOverlapSize: parseInt(process.env.MAX_OVERLAP_SIZE || '2000'), minOverlapSize: 25, defaultChunkSize: 200, defaultOverlapSize: 40, modelInfo: { name: this.i18nService.getMessage('modelNotConfigured'), maxInputTokens: parseInt(process.env.MAX_CHUNK_SIZE || '8191'), maxBatchSize: 2048, expectedDimensions: parseInt(process.env.DEFAULT_VECTOR_DIMENSIONS || '2560'), }, }; } return await this.chunkConfigService.getFrontendLimits( embeddingModelId, req.user.id, req.user.tenantId, ); } // 文件分组管理 - 需要管理员权限 @Post(':id/groups') @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN) async addFileToGroups( @Param('id') fileId: string, @Body() body: { groupIds: string[] }, @Request() req, ) { await this.knowledgeGroupService.addFilesToGroup( fileId, body.groupIds, req.user.id, req.user.tenantId, ); return { message: this.i18nService.getMessage('groupSyncSuccess') }; } @Delete(':id/groups/:groupId') @Roles(UserRole.TENANT_ADMIN, UserRole.SUPER_ADMIN) async removeFileFromGroup( @Param('id') fileId: string, @Param('groupId') groupId: string, @Request() req, ) { await this.knowledgeGroupService.removeFileFromGroup( fileId, groupId, req.user.id, req.user.tenantId, ); return { message: this.i18nService.getMessage('fileDeletedFromGroup') }; } // PDF プレビュー - 公開アクセス @Public() @Get(':id/pdf') async getPDFPreview( @Param('id') fileId: string, @Query('token') token: string, @Res() res: Response, ) { try { if (!token) { throw new NotFoundException(this.i18nService.getMessage('accessDeniedNoToken')); } const jwt = await import('jsonwebtoken'); const secret = process.env.JWT_SECRET; if (!secret) { throw new InternalServerErrorException('JWT_SECRET environment variable is required but not set'); } let decoded; try { decoded = jwt.verify(token, secret) as any; } catch { throw new NotFoundException(this.i18nService.getMessage('invalidToken')); } if (decoded.type !== 'pdf-access' || decoded.fileId !== fileId) { throw new NotFoundException(this.i18nService.getMessage('invalidToken')); } const pdfPath = await this.knowledgeBaseService.ensurePDFExists( fileId, decoded.userId, decoded.tenantId, // New ); const fs = await import('fs'); const path = await import('path'); if (!fs.existsSync(pdfPath)) { throw new NotFoundException(this.i18nService.getMessage('pdfFileNotFound')); } const stat = fs.statSync(pdfPath); const fileName = path.basename(pdfPath); if (stat.size === 0) { this.logger.warn(`PDF file is empty: ${pdfPath}`); try { fs.unlinkSync(pdfPath); // 空のファイルを削除 } catch (e) { } throw new NotFoundException(this.i18nService.getMessage('pdfFileEmpty')); } res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Length', stat.size); const stream = fs.createReadStream(pdfPath); stream.pipe(res); } catch (error) { if (error instanceof NotFoundException) { throw error; } this.logger.error(`PDF preview error: ${error.message}`); throw new NotFoundException(this.i18nService.getMessage('pdfConversionFailed')); } } // PDF プレビューアドレスを取得 @Get(':id/pdf-url') async getPDFUrl( @Param('id') fileId: string, @Query('force') force: string, @Request() req, ) { try { // PDF 変換をトリガー await this.knowledgeBaseService.ensurePDFExists(fileId, req.user.id, req.user.tenantId, force === 'true'); // 一時的なアクセストークンを生成 const jwt = await import('jsonwebtoken'); const secret = process.env.JWT_SECRET; if (!secret) { throw new InternalServerErrorException('JWT_SECRET environment variable is required but not set'); } const token = jwt.sign( { fileId, userId: req.user.id, tenantId: req.user.tenantId, type: 'pdf-access' }, secret, { expiresIn: '1h' } ); return { url: `/api/knowledge-bases/${fileId}/pdf?token=${token}` }; } catch (error) { if (error.message.includes('LibreOffice')) { throw new InternalServerErrorException(this.i18nService.formatMessage('pdfServiceUnavailable', { message: error.message })); } throw new InternalServerErrorException(error.message); } } @Get(':id/pdf-status') async getPDFStatus( @Param('id') fileId: string, @Request() req, ) { return await this.knowledgeBaseService.getPDFStatus(fileId, req.user.id, req.user.tenantId); } // PDF の特定ページの画像を取得 @Get(':id/page/:index') async getPageImage( @Param('id') fileId: string, @Param('index') index: number, @Request() req, @Res() res: Response, ) { try { const imagePath = await this.knowledgeBaseService.getPageAsImage( fileId, Number(index), req.user.id, req.user.tenantId, ); const fs = await import('fs'); if (!fs.existsSync(imagePath)) { throw new NotFoundException(this.i18nService.getMessage('pageImageNotFound')); } res.sendFile(path.resolve(imagePath)); } catch (error) { this.logger.error(`PDF ページの画像取得に失敗しました: ${error.message}`); throw new NotFoundException(this.i18nService.getMessage('pdfPageImageFailed')); } } }