feat: support R2 image attachments in agent conversations
This commit is contained in:
@@ -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') };
|
||||
}));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user