From 912b7f8653a80a5876eb0d0a4f1daa8a217e0340 Mon Sep 17 00:00:00 2001 From: MinhhTien <92145479+MinhhTien@users.noreply.github.com> Date: Fri, 24 May 2024 15:14:42 +0700 Subject: [PATCH] feature:FUR-21 [BE][Web] Integrate AI Generate 2D product API (#107) --- .github/workflows/develop.yml | 3 + example.env | 5 ++ src/ai-generation/ai-generation.module.ts | 8 ++- src/ai-generation/contracts/constant.ts | 8 ++- .../controllers/text-to-image.controller.ts | 30 ++++++++++ src/ai-generation/dtos/text-to-image.dto.ts | 23 +++++++ .../schemas/ai-generation.schema.ts | 16 ++++- .../services/text-to-image.service.ts | 60 +++++++++++++++++++ .../services/text-to-model.service.ts | 4 +- src/common/contracts/error.ts | 5 ++ src/config/index.ts | 4 ++ 11 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 src/ai-generation/controllers/text-to-image.controller.ts create mode 100644 src/ai-generation/dtos/text-to-image.dto.ts create mode 100644 src/ai-generation/services/text-to-image.service.ts diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 2172b92..58af409 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -91,6 +91,9 @@ jobs: echo TRIPO_3D_AI_ENDPOINT=${{ vars.TRIPO_3D_AI_ENDPOINT }} >> .env echo TRIPO_3D_AI_API_KEY=${{ secrets.TRIPO_3D_AI_API_KEY }} >> .env + echo EDEN_AI_ENDPOINT=${{ vars.EDEN_AI_ENDPOINT }} >> .env + echo EDEN_AI_API_KEY=${{ secrets.EDEN_AI_API_KEY }} >> .env + - name: Deploy run: pm2 restart furnique-api diff --git a/example.env b/example.env index 6c0ab0b..db79eb5 100644 --- a/example.env +++ b/example.env @@ -34,6 +34,11 @@ DISCORD_WEBHOOK_TOKEN= TRIPO_3D_AI_ENDPOINT=https://api.tripo3d.ai TRIPO_3D_AI_API_KEY= +#Eden AI +EDEN_AI_ENDPOINT=https://api.edenai.run +EDEN_AI_API_KEY= + + ## PAYMENT #MOMO MOMO_PARTNER_CODE= diff --git a/src/ai-generation/ai-generation.module.ts b/src/ai-generation/ai-generation.module.ts index d1fe297..39ac054 100644 --- a/src/ai-generation/ai-generation.module.ts +++ b/src/ai-generation/ai-generation.module.ts @@ -6,6 +6,8 @@ import { CustomerModule } from '@customer/customer.module' import { AIGenerationTextToModelController } from './controllers/text-to-model.controller' import { AIGenerationTextToModelService } from './services/text-to-model.service' import { AIGenerationRepository } from './repositories/ai-generation.repository' +import { AIGenerationTextToImageController } from './controllers/text-to-image.controller' +import { AIGenerationTextToImageService } from './services/text-to-image.service' @Global() @Module({ @@ -14,8 +16,8 @@ import { AIGenerationRepository } from './repositories/ai-generation.repository' HttpModule, CustomerModule ], - controllers: [AIGenerationTextToModelController], - providers: [AIGenerationTextToModelService, AIGenerationRepository], - exports: [AIGenerationTextToModelService, AIGenerationRepository] + controllers: [AIGenerationTextToModelController, AIGenerationTextToImageController], + providers: [AIGenerationTextToModelService, AIGenerationTextToImageService, AIGenerationRepository], + exports: [AIGenerationTextToModelService, AIGenerationTextToImageService, AIGenerationRepository] }) export class AIGenerationModule {} diff --git a/src/ai-generation/contracts/constant.ts b/src/ai-generation/contracts/constant.ts index 7998f5b..8913af8 100644 --- a/src/ai-generation/contracts/constant.ts +++ b/src/ai-generation/contracts/constant.ts @@ -3,8 +3,14 @@ export enum AIGenerationPlan { PREMIUM = 'PREMIUM' } +export enum AIGenerationPlatform { + TRIPO_3D_AI = 'TRIPO_3D_AI', + EDEN_AI = 'EDEN_AI' +} + export enum AIGenerationType { - TEXT_TO_MODEL = 'TEXT_TO_MODEL' + TEXT_TO_MODEL = 'TEXT_TO_MODEL', + TEXT_TO_IMAGE = 'TEXT_TO_IMAGE' } export enum AIGenerationTaskStatus { diff --git a/src/ai-generation/controllers/text-to-image.controller.ts b/src/ai-generation/controllers/text-to-image.controller.ts new file mode 100644 index 0000000..16d97db --- /dev/null +++ b/src/ai-generation/controllers/text-to-image.controller.ts @@ -0,0 +1,30 @@ +import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common' +import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger' +import * as _ from 'lodash' +import { Roles } from '@auth/decorators/roles.decorator' +import { UserRole } from '@common/contracts/constant' +import { RolesGuard } from '@auth/guards/roles.guard' +import { JwtAuthGuard } from '@auth/guards/jwt-auth.guard' +import { AIGenerationTextToImageService } from '@ai-generation/services/text-to-image.service' +import { GenerateTextToImageDto, TextToImageResponseDto } from '@ai-generation/dtos/text-to-image.dto' + +@ApiTags('AIGeneration - TextToImage') +@ApiBearerAuth() +@Roles(UserRole.CUSTOMER) +@UseGuards(JwtAuthGuard.ACCESS_TOKEN, RolesGuard) +@Controller('text-to-image') +export class AIGenerationTextToImageController { + constructor(private readonly aiGenerationTextToImageService: AIGenerationTextToImageService) {} + + @ApiOperation({ + summary: 'Generate image from text' + }) + @ApiOkResponse({ type: TextToImageResponseDto }) + @Post() + generate(@Req() req, @Body() generateTextToImageDto: GenerateTextToImageDto) { + generateTextToImageDto.providers = ["amazon", 'amazon/titan-image-generator-v1_premium', 'amazon/titan-image-generator-v1_standard', 'openai/dall-e-2'] + generateTextToImageDto.resolution = "512x512" + generateTextToImageDto.customerId = _.get(req, 'user._id') + return this.aiGenerationTextToImageService.generateTextToImage(generateTextToImageDto) + } +} diff --git a/src/ai-generation/dtos/text-to-image.dto.ts b/src/ai-generation/dtos/text-to-image.dto.ts new file mode 100644 index 0000000..2db0bd9 --- /dev/null +++ b/src/ai-generation/dtos/text-to-image.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty } from '@nestjs/swagger' +import { DataResponse } from '@common/contracts/openapi-builder' +import { IsNotEmpty, MaxLength } from 'class-validator' + +export class GenerateTextToImageDto { + @ApiProperty() + @IsNotEmpty() + @MaxLength(1024) + text: string + + providers?: string[] + resolution?: string + customerId?: string +} + +export class TextToImageDto { + @ApiProperty({ + example: 'https://d14uq1pz7dzsdq.cloudfront.net/79926ef2-c82f-4352-9e94-a394c871846a_.png' + }) + imageUrl: string +} + +export class TextToImageResponseDto extends DataResponse(TextToImageDto) {} \ No newline at end of file diff --git a/src/ai-generation/schemas/ai-generation.schema.ts b/src/ai-generation/schemas/ai-generation.schema.ts index b15776b..22d6047 100644 --- a/src/ai-generation/schemas/ai-generation.schema.ts +++ b/src/ai-generation/schemas/ai-generation.schema.ts @@ -3,7 +3,7 @@ import { HydratedDocument } from 'mongoose' import * as paginate from 'mongoose-paginate-v2' import { Transform } from 'class-transformer' import { ApiProperty } from '@nestjs/swagger' -import { AIGenerationType } from '../contracts/constant' +import { AIGenerationPlatform, AIGenerationType } from '../contracts/constant' export type AIGenerationDocument = HydratedDocument @@ -32,11 +32,21 @@ export class AIGeneration { @Prop({ enum: AIGenerationType, default: AIGenerationType.TEXT_TO_MODEL }) type: AIGenerationType + @ApiProperty({ enum: AIGenerationPlatform }) + @Prop({ enum: AIGenerationPlatform, default: AIGenerationPlatform.TRIPO_3D_AI }) + platform: AIGenerationPlatform + + @ApiProperty() + @Prop({ type: Number }) + cost: number + @ApiProperty() @Prop({ type: String }) - taskId: string // used for TEXT_TO_MODEL + taskId?: string // used for TEXT_TO_MODEL - // more prop for other type + @ApiProperty() + @Prop({ type: String }) + imageUrl?: string // used for TEXT_TO_IMAGE } export const AIGenerationSchema = SchemaFactory.createForClass(AIGeneration) diff --git a/src/ai-generation/services/text-to-image.service.ts b/src/ai-generation/services/text-to-image.service.ts new file mode 100644 index 0000000..d2d4b06 --- /dev/null +++ b/src/ai-generation/services/text-to-image.service.ts @@ -0,0 +1,60 @@ +import { Injectable, Logger } from '@nestjs/common' +import { AIGenerationRepository } from '@ai-generation/repositories/ai-generation.repository' +import { HttpService } from '@nestjs/axios' +import { ConfigService } from '@nestjs/config' +import { catchError, firstValueFrom } from 'rxjs' +import { AxiosError } from 'axios' +import { AppException } from '@common/exceptions/app.exception' +import { Errors } from '@common/contracts/error' +import { AIGenerationPlatform, AIGenerationType } from '@ai-generation/contracts/constant' +import { GenerateTextToImageDto } from '@ai-generation/dtos/text-to-image.dto' + +@Injectable() +export class AIGenerationTextToImageService { + private readonly logger = new Logger(AIGenerationTextToImageService.name) + private config + private headersRequest + constructor( + private readonly aiGenerationRepository: AIGenerationRepository, + private readonly httpService: HttpService, + private readonly configService: ConfigService + ) { + this.config = this.configService.get('edenAI') + this.headersRequest = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.apiKey}` + } + } + + async generateTextToImage(generateTextToImageDto: GenerateTextToImageDto) { + const { customerId } = generateTextToImageDto + + // TODO: Check limit AI generation here + + const { data } = await firstValueFrom( + this.httpService + .post(`${this.config.endpoint}/v2/image/generation`, generateTextToImageDto, { + headers: this.headersRequest + }) + .pipe( + catchError((error: AxiosError) => { + this.logger.error(error?.response?.data) + throw new AppException({ ...Errors.EDEN_AI_ERROR, data: error?.response?.data }) + }) + ) + ) + const result: any = Object.values(data)[0] + if (result?.status !== 'success') throw new AppException({ ...Errors.EDEN_AI_ERROR, data }) + + const imageUrl = result?.items[0]?.image_resource_url + await this.aiGenerationRepository.create({ + customerId, + type: AIGenerationType.TEXT_TO_IMAGE, + platform: AIGenerationPlatform.EDEN_AI, + cost: result?.cost ?? 0.01, // total 1 credits + imageUrl + }) + + return { imageUrl } + } +} diff --git a/src/ai-generation/services/text-to-model.service.ts b/src/ai-generation/services/text-to-model.service.ts index 18819e5..616c42a 100644 --- a/src/ai-generation/services/text-to-model.service.ts +++ b/src/ai-generation/services/text-to-model.service.ts @@ -7,7 +7,7 @@ import { AxiosError } from 'axios' import { GenerateTextToDraftModelDto } from '@ai-generation/dtos/text-to-model.dto' import { AppException } from '@common/exceptions/app.exception' import { Errors } from '@common/contracts/error' -import { AIGenerationType } from '@ai-generation/contracts/constant' +import { AIGenerationPlatform, AIGenerationType } from '@ai-generation/contracts/constant' @Injectable() export class AIGenerationTextToModelService { @@ -48,6 +48,8 @@ export class AIGenerationTextToModelService { await this.aiGenerationRepository.create({ customerId, type: AIGenerationType.TEXT_TO_MODEL, + platform: AIGenerationPlatform.TRIPO_3D_AI, + cost: 20, // total 2000 credits taskId: data?.data?.task_id }) diff --git a/src/common/contracts/error.ts b/src/common/contracts/error.ts index ac13782..8584581 100644 --- a/src/common/contracts/error.ts +++ b/src/common/contracts/error.ts @@ -120,5 +120,10 @@ export const Errors: Record = { error: 'TRIPO_3D_AI_ERROR', message: 'Có chút lỗi xảy ra. Vui lòng thử lại sau giây lát bạn nhé.', httpStatus: HttpStatus.INTERNAL_SERVER_ERROR + }, + EDEN_AI_ERROR: { + error: 'EDEN_AI_ERROR', + message: 'Có chút lỗi xảy ra. Vui lòng thử lại sau giây lát bạn nhé.', + httpStatus: HttpStatus.INTERNAL_SERVER_ERROR } } diff --git a/src/config/index.ts b/src/config/index.ts index fb8026b..0b242b4 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -39,6 +39,10 @@ export default () => ({ endpoint: process.env.TRIPO_3D_AI_ENDPOINT, apiKey: process.env.TRIPO_3D_AI_API_KEY }, + edenAI: { + endpoint: process.env.EDEN_AI_ENDPOINT, + apiKey: process.env.EDEN_AI_API_KEY + }, JWT_ACCESS_SECRET: process.env.JWT_ACCESS_SECRET || 'accessSecret', JWT_ACCESS_EXPIRATION: process.env.JWT_ACCESS_EXPIRATION || 864000, // seconds JWT_REFRESH_SECRET: process.env.JWT_REFRESH_SECRET || 'refreshSecret',