feat: support R2 image attachments in agent conversations

This commit is contained in:
2026-10-19 13:34:06 +09:00
parent ed70bad7a2
commit f58a20f262
15 changed files with 930 additions and 16 deletions
+76
View File
@@ -0,0 +1,76 @@
import { BadGatewayException, BadRequestException, Injectable } from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto';
import sharp from 'sharp';
import { R2PublicFileService } from '../storage/r2-public-file.service';
import { AiCredentialService } from './ai-credential.service';
export const AGENT_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
const STORED_MAX_BYTES = 10 * 1024 * 1024;
export type AgentImageScope = { studentId: string; courseId: string; week: number };
export type AgentImageReference = { token: string; name: string; mimeType: 'image/png'; size: number; url: string };
type ImageClaim = AgentImageScope & { type: 'agent-image'; id: string; name: string; key: string; size: number; sha256: string };
export function supportsAgentImages(provider: string, model: string): boolean {
return provider === 'QWEN' && ['qwen3.7-plus', 'qwen3.7-flash', 'qwen3.8-max', 'qwen3.8-flash', 'qwen-vl-plus', 'qwen-vl-max']
.some(id => model === id || model.startsWith(`${id}-`));
}
@Injectable()
export class AgentImagesService {
constructor(private readonly storage: R2PublicFileService, private readonly credentials: AiCredentialService) {}
async upload(scope: AgentImageScope, file: { originalname: string; buffer: Buffer } | undefined): Promise<AgentImageReference> {
if (!file?.buffer.length || file.buffer.length > AGENT_IMAGE_MAX_BYTES) throw new BadRequestException('이미지는 장당 5MB 이하여야 합니다.');
if (!this.storage.isConfigured()) throw new BadRequestException('이미지 저장소가 설정되지 않았습니다.');
let body: Buffer;
try {
const image = sharp(file.buffer, { limitInputPixels: 16_000_000, animated: false });
const info = await image.metadata();
if (!['png', 'jpeg', 'webp'].includes(info.format ?? '') || (info.pages ?? 1) > 1) throw new Error('format');
// Decode and re-encode losslessly to remove metadata and reject malformed image bytes.
body = await image.rotate().png().toBuffer();
if (body.length > STORED_MAX_BYTES) throw new Error('size');
} catch {
throw new BadRequestException('PNG·JPEG·WebP 정지 이미지만 첨부할 수 있습니다. 1600만 픽셀 이하의 이미지로 다시 선택해주세요.');
}
const id = randomUUID();
const key = this.storage.createLmsAgentImageKey({ courseId: scope.courseId, userId: scope.studentId, imageId: id });
const name = file.originalname.replace(/[\\/\u0000-\u001f]/g, '_').slice(0, 255) || 'image.png';
const claim: ImageClaim = { ...scope, type: 'agent-image', id, name, key, size: body.length, sha256: createHash('sha256').update(body).digest('hex') };
const token = this.credentials.encrypt(JSON.stringify(claim));
const url = await this.storage.uploadPublicFile({ key, body, contentType: 'image/png' }).catch(() => { throw new BadGatewayException('첨부 이미지 업로드에 실패했습니다. 잠시 후 다시 전송해주세요.'); });
if (!url) throw new BadRequestException('첨부 이미지를 저장하지 못했습니다.');
return { token, name, mimeType: 'image/png', size: body.length, url };
}
references(value: unknown, scope?: AgentImageScope): AgentImageReference[] {
if (value === undefined || value === null) return [];
if (!Array.isArray(value) || value.length > 3) throw new BadRequestException('이미지는 최대 3장까지 첨부할 수 있습니다.');
const seen = new Set<string>();
return value.map(item => {
if (!item || typeof item !== 'object' || typeof item.token !== 'string' || item.token.length > 4000) throw new BadRequestException('첨부 이미지 정보가 올바르지 않습니다.');
let claim: ImageClaim;
try { claim = JSON.parse(this.credentials.decrypt(item.token)) as ImageClaim; } catch { throw new BadRequestException('첨부 이미지 인증 정보가 올바르지 않습니다.'); }
if (claim.type !== 'agent-image' || typeof claim.id !== 'string' || typeof claim.name !== 'string' || claim.name.length > 255
|| !Number.isInteger(claim.size) || claim.size < 1 || claim.size > STORED_MAX_BYTES || !/^[a-f0-9]{64}$/.test(claim.sha256)
|| typeof claim.studentId !== 'string' || typeof claim.courseId !== 'string' || !Number.isInteger(claim.week)
|| claim.key !== this.storage.createLmsAgentImageKey({ courseId: claim.courseId, userId: claim.studentId, imageId: claim.id })
|| (scope && (scope.studentId !== claim.studentId || scope.courseId !== claim.courseId || scope.week !== claim.week)) || seen.has(claim.id)) {
throw new BadRequestException('이 대화에서 사용할 수 없는 첨부 이미지입니다.');
}
seen.add(claim.id);
const url = this.storage.publicUrl(claim.key);
if (!url) throw new BadRequestException('첨부 이미지 주소를 찾을 수 없습니다.');
return { token: item.token, name: claim.name, mimeType: 'image/png', size: claim.size, url };
});
}
async load(references: AgentImageReference[]): Promise<Array<{ mimeType: string; data: string }>> {
return Promise.all(references.map(async reference => {
const claim = JSON.parse(this.credentials.decrypt(reference.token)) as ImageClaim;
const body = await this.storage.readPublicFile(claim.key, STORED_MAX_BYTES).catch(() => { throw new BadGatewayException('첨부 이미지를 불러오지 못했습니다. 다시 첨부해주세요.'); });
if (body.length !== claim.size || createHash('sha256').update(body).digest('hex') !== claim.sha256) throw new BadRequestException('저장된 첨부 이미지가 변경되었습니다. 다시 첨부해주세요.');
return { mimeType: 'image/png', data: body.toString('base64') };
}));
}
}