diff --git a/src/commands/messages/AI/image.ts b/src/commands/messages/AI/image.ts index c4fd394..b5c2e0c 100644 --- a/src/commands/messages/AI/image.ts +++ b/src/commands/messages/AI/image.ts @@ -1,61 +1,83 @@ -import { CommandMessage } from "../../../core/types/commands"; -import { aiService } from "../../../core/services/AIService"; -import logger from "../../../core/lib/logger"; +import { Message, AttachmentBuilder } from 'discord.js'; +import { aiService } from '../../../core/services/AIService'; +import logger from '../../../core/lib/logger'; -function parseSizeArg(arg?: string): 'square' | 'portrait' | 'landscape' { - if (!arg) return 'square'; - const v = arg.toLowerCase(); - if (v === 'square' || v === 'cuadrado' || v === '1:1') return 'square'; - if (v === 'portrait' || v === 'vertical' || v === '9:16') return 'portrait'; - if (v === 'landscape' || v === 'horizontal' || v === '16:9') return 'landscape'; - return 'square'; -} +export default { + name: 'image', + description: 'Genera una imagen usando IA', + cooldown: 10, + async execute(message: Message, args: string[]) { + // Verificar que hay un prompt + if (!args || args.length === 0) { + await message.reply('❌ **Error**: Debes proporcionar una descripción para generar la imagen.\n\n**Ejemplo**: `imagen un gato espacial flotando entre estrellas`'); + return; + } + + const prompt = args.join(' ').trim(); + + // Validar longitud del prompt + if (prompt.length < 3) { + await message.reply('❌ **Error**: La descripción debe tener al menos 3 caracteres.'); + return; + } + + if (prompt.length > 1000) { + await message.reply('❌ **Error**: La descripción es demasiado larga (máximo 1000 caracteres).'); + return; + } + + // Mostrar mensaje de "generando..." + const thinkingMessage = await message.reply('🎨 **Generando imagen**... Esto puede tomar unos momentos.'); -export const command: CommandMessage = { - name: 'aiimg', - type: 'message', - aliases: ['img', 'imagen'], - cooldown: 5, - description: 'Genera una imagen con Gemini (gemini-2.5-flash-image).', - category: 'IA', - usage: 'aiimg [square|portrait|landscape] ', - run: async (message, args) => { try { - if (!args || args.length === 0) { - await message.reply({ - content: 'Uso: aiimg [square|portrait|landscape] \nEjemplo: aiimg portrait un gato astronauta' - }); - return; - } + logger.info(`Generando imagen para usuario ${message.author.id}: ${prompt.slice(0, 100)}`); - let size: 'square' | 'portrait' | 'landscape' = 'square'; - let prompt = args.join(' ').trim(); - - // Si el primer arg es un tamaño válido, usarlo y quitarlo del prompt - const maybeSize = parseSizeArg(args[0]); - if (maybeSize !== 'square' || ['square', 'cuadrado', '1:1'].includes(args[0]?.toLowerCase?.() ?? '')) { - // Detect explicit size keyword; if first arg matches any known size token, shift it - if (['square','cuadrado','1:1','portrait','vertical','9:16','landscape','horizontal','16:9'].includes(args[0].toLowerCase())) { - size = maybeSize; - prompt = args.slice(1).join(' ').trim(); - } - } - - if (!prompt) { - await message.reply({ content: 'El prompt no puede estar vacío.' }); - return; - } - - (message.channel as any)?.sendTyping?.().catch(() => {}); - const result = await aiService.generateImage(prompt, { size }); - - await message.reply({ - content: `✅ Imagen generada (${size}).`, - files: [{ attachment: result.data, name: result.fileName }] + // Generar la imagen usando el AIService actualizado + const result = await aiService.generateImage(prompt, { + size: 'square', // Por defecto usar formato cuadrado + mimeType: 'image/jpeg', + numberOfImages: 1, + personGeneration: true }); - } catch (error: any) { - logger.error(error, 'Error generando imagen'); - await message.reply({ content: `❌ Error generando imagen: ${error?.message || 'Error desconocido'}` }); + + // Crear attachment para Discord + const attachment = new AttachmentBuilder(result.data, { + name: result.fileName, + description: `Imagen generada: ${prompt.slice(0, 100)}${prompt.length > 100 ? '...' : ''}` + }); + + // Responder con la imagen + await thinkingMessage.edit({ + content: `✅ **Imagen generada** para: *${prompt.slice(0, 150)}${prompt.length > 150 ? '...' : ''}*`, + files: [attachment] + }); + + logger.info(`Imagen generada exitosamente para usuario ${message.author.id}, tamaño: ${result.data.length} bytes`); + + } catch (error) { + logger.error(`Error generando imagen para usuario ${message.author.id}: ${error}`); + + let errorMessage = '❌ **Error generando imagen**: '; + + if (error instanceof Error) { + const errorText = error.message.toLowerCase(); + + if (errorText.includes('no está disponible') || errorText.includes('not found')) { + errorMessage += 'El servicio de generación de imágenes no está disponible en este momento.'; + } else if (errorText.includes('límite') || errorText.includes('quota')) { + errorMessage += 'Se ha alcanzado el límite de generación de imágenes. Intenta más tarde.'; + } else if (errorText.includes('bloqueado') || errorText.includes('safety')) { + errorMessage += 'Tu descripción fue bloqueada por las políticas de seguridad. Intenta con algo diferente.'; + } else if (errorText.includes('inicializado') || errorText.includes('api')) { + errorMessage += 'El servicio no está configurado correctamente.'; + } else { + errorMessage += error.message; + } + } else { + errorMessage += 'Error desconocido. Intenta de nuevo más tarde.'; + } + + await thinkingMessage.edit(errorMessage); } } }; diff --git a/src/core/services/AIService.ts b/src/core/services/AIService.ts index 2660be6..114ae80 100644 --- a/src/core/services/AIService.ts +++ b/src/core/services/AIService.ts @@ -1,6 +1,6 @@ import { GoogleGenerativeAI, HarmCategory, HarmBlockThreshold } from "@google/generative-ai"; // New: modern GenAI SDK for image generation -import { GoogleGenAI } from "@google/genai"; +import { GoogleGenAI, PersonGeneration } from "@google/genai"; import logger from "../lib/logger"; import { Collection } from "discord.js"; import { prisma } from "../database/prisma"; @@ -145,9 +145,11 @@ export class AIService { return null; } - // Lista de candidatos de modelos de imagen ordenados por preferencia + // Lista de candidatos de modelos de imagen ordenados por preferencia (actualizada según Google AI Studio) const candidates = [ + 'models/imagen-4.0-generate-001', 'imagen-4.0-generate-001', + 'models/gemini-2.5-flash-image', 'gemini-2.5-flash-image', ]; @@ -157,8 +159,7 @@ export class AIService { if (listed?.models && Array.isArray(listed.models)) { const models: string[] = listed.models .map((m: any) => m?.name || m?.model || m?.id || m?.displayName) - .filter(Boolean) - .map((name: string) => name.replace(/^models\//, '')); // Quitar prefijo models/ + .filter(Boolean); logger.debug({ availableModels: models }, 'Modelos disponibles detectados'); @@ -171,7 +172,12 @@ export class AIService { if (imageModels.length > 0) { // Priorizar según orden de candidatos for (const candidate of candidates) { - const found = imageModels.find(m => m.includes(candidate.replace(/^models\//, ''))); + const candidateBase = candidate.replace(/^models\//, ''); + const found = imageModels.find(m => + m === candidate || + m === candidateBase || + m.includes(candidateBase) + ); if (found) { this.imageModelName = found; logger.info({ model: found, source: 'listModels' }, 'Modelo de imágenes detectado automáticamente'); @@ -189,20 +195,22 @@ export class AIService { logger.debug({ err: getErrorMessage(e) }, 'listModels no disponible'); } - // Fallback: probar modelos uno por uno con generateContent usando responseMimeType + // Fallback: probar modelos uno por uno for (const candidate of candidates) { try { - // Usar generateContent con responseMimeType image/* como detector - const testRes: any = await (this.genAIv2 as any).models.generateContent({ + // Probar con la nueva API de generateImages + const testRes: any = await (this.genAIv2 as any).models.generateImages({ model: candidate, - contents: [{ text: 'test' }], + prompt: 'test', config: { - responseMimeType: 'image/png', - maxOutputTokens: 1, + numberOfImages: 1, + outputMimeType: 'image/jpeg', + aspectRatio: '1:1', + imageSize: '1K', } }); - // Si no lanza error 404, el modelo existe + // Si no lanza error, el modelo existe this.imageModelName = candidate; logger.info({ model: candidate, source: 'direct-test' }, 'Modelo de imágenes detectado por prueba directa'); return candidate; @@ -1136,10 +1144,15 @@ Responde de forma directa y útil:`; } /** - * Generar imagen usando gemini-2.5-flash-image (Google Gen AI SDK moderno) + * Generar imagen usando la nueva API de @google/genai (basada en Google AI Studio) * Retorna un objeto con los bytes de la imagen y el tipo MIME. */ - public async generateImage(prompt: string, options?: { size?: 'square' | 'portrait' | 'landscape'; mimeType?: string }): Promise<{ data: Buffer; mimeType: string; fileName: string; }> { + public async generateImage(prompt: string, options?: { + size?: 'square' | 'portrait' | 'landscape'; + mimeType?: string; + numberOfImages?: number; + personGeneration?: boolean; + }): Promise<{ data: Buffer; mimeType: string; fileName: string; }> { if (!prompt?.trim()) { throw new Error('El prompt de imagen no puede estar vacío'); } @@ -1150,80 +1163,81 @@ Responde de forma directa y útil:`; // Obtener/descubrir el modelo const model = this.imageModelName ?? (await this.detectImageModel()); if (!model) { - throw new Error('El generador de imágenes no está disponible para tu cuenta o región. Habilita Imagen 3 (por ejemplo, imagen-3.0-fast) en Google AI Studio o define GENAI_IMAGE_MODEL.'); + throw new Error('El generador de imágenes no está disponible para tu cuenta o región. Habilita Imagen 4.0 (imagen-4.0-generate-001) en Google AI Studio.'); } - const mimeType = options?.mimeType ?? 'image/png'; + const mimeType = options?.mimeType ?? 'image/jpeg'; const size = options?.size ?? 'square'; - const imageSize = size === 'portrait' ? '9:16' : size === 'landscape' ? '16:9' : '1:1'; + const numberOfImages = options?.numberOfImages ?? 1; + const personGeneration = options?.personGeneration ?? true; + + // Mapear tamaño a aspectRatio según la nueva API + const aspectRatio = size === 'portrait' ? '9:16' : size === 'landscape' ? '16:9' : '1:1'; try { - const res: any = await (this.genAIv2 as any).models.generateImages({ - model, - prompt, + logger.info({ model, prompt: prompt.slice(0, 100) }, 'Generando imagen con nueva API'); + + const response: any = await (this.genAIv2 as any).models.generateImages({ + model: model, + prompt: prompt, config: { - responseMimeType: mimeType, - imageSize, + numberOfImages: numberOfImages, + outputMimeType: mimeType, + personGeneration: personGeneration ? PersonGeneration.ALLOW_ALL : PersonGeneration.DONT_ALLOW, + aspectRatio: aspectRatio, + imageSize: '1K', // Usar 1K como tamaño estándar } }); - let base64: string | undefined; - let outMime: string | undefined; - - if (Array.isArray(res?.images) && res.images.length > 0) { - const first = res.images[0]; - base64 = first?.data || first?.b64Data || first?.inlineData?.data; - outMime = first?.mimeType || first?.inlineData?.mimeType; - } - if (!base64 && res?.image?.data) { - base64 = res.image.data; - outMime = res.image.mimeType; + if (!response?.generatedImages || !Array.isArray(response.generatedImages) || response.generatedImages.length === 0) { + logger.error({ response, model }, 'No se generaron imágenes en la respuesta'); + throw new Error('No se generaron imágenes'); } - if (!base64) { - // Fallback a generateContent para algunas implementaciones - const alt: any = await (this.genAIv2 as any).models.generateContent({ - model, - contents: prompt, - config: { - responseMimeType: mimeType, - responseModalities: ['IMAGE'], - imageSize, - } - }); - const response = alt?.response ?? alt; - const candidates = response?.candidates ?? []; - const parts = candidates[0]?.content?.parts ?? []; - const imgPart = parts.find((p: any) => p?.inlineData?.data || p?.imageData?.data || p?.media?.data); - if (imgPart?.inlineData?.data) { - base64 = imgPart.inlineData.data; - outMime = imgPart.inlineData.mimeType; - } else if (imgPart?.imageData?.data) { - base64 = imgPart.imageData.data; - outMime = imgPart.imageData.mimeType; - } else if (imgPart?.media?.data) { - base64 = imgPart.media.data; - outMime = imgPart.media.mimeType; - } + // Tomar la primera imagen generada + const generatedImage = response.generatedImages[0]; + if (!generatedImage?.image?.imageBytes) { + logger.error({ generatedImage, model }, 'La imagen generada no contiene datos de bytes'); + throw new Error('La imagen generada no contiene datos válidos'); } - if (!base64) { - logger.error({ res, model }, 'Respuesta de imagen sin datos de imagen'); - throw new Error('No se recibió imagen del modelo'); - } + const base64Data = generatedImage.image.imageBytes; + const buffer = Buffer.from(base64Data, 'base64'); - const finalMime = outMime || mimeType; - let fileName = `gen_${Date.now()}.img`; - if (finalMime.includes('png')) fileName = fileName.replace(/\.img$/, '.png'); - else if (finalMime.includes('jpeg') || finalMime.includes('jpg')) fileName = fileName.replace(/\.img$/, '.jpg'); - else if (finalMime.includes('webp')) fileName = fileName.replace(/\.img$/, '.webp'); + // Generar nombre de archivo basado en el tipo MIME + let fileName = `gen_${Date.now()}`; + if (mimeType.includes('png')) fileName += '.png'; + else if (mimeType.includes('jpeg') || mimeType.includes('jpg')) fileName += '.jpg'; + else if (mimeType.includes('webp')) fileName += '.webp'; + else fileName += '.img'; + + logger.info({ + fileName, + mimeType, + bufferSize: buffer.length, + model + }, 'Imagen generada exitosamente'); + + return { + data: buffer, + mimeType: mimeType, + fileName: fileName + }; - return { data: Buffer.from(base64, 'base64'), mimeType: finalMime, fileName }; } catch (e) { - logger.error({ err: e as any, model }, 'Fallo en generateImage'); + logger.error({ err: e as any, model, prompt: prompt.slice(0, 100) }, 'Error en generateImage'); const parsed = this.parseAPIError(e); const original = getErrorMessage(e); - // Si el parser no aporta, usa el original del backend + + // Proporcionar mensajes de error más específicos + if (original.includes('not found') || original.includes('404')) { + throw new Error('El modelo de generación de imágenes no está disponible. Verifica que Imagen 4.0 esté habilitado en tu cuenta de Google AI Studio.'); + } + if (original.includes('quota') || original.includes('limit')) { + throw new Error('Has alcanzado el límite de generación de imágenes. Intenta más tarde.'); + } + + // Si el parser no aporta información útil, usar el mensaje original const message = parsed === 'Error temporal del servicio de IA. Intenta de nuevo' ? original : parsed; throw new Error(message || parsed); }