From a6c47cca5e88cc28415499d2cf41664371fc86ec Mon Sep 17 00:00:00 2001 From: shni Date: Sat, 4 Oct 2025 03:10:04 -0500 Subject: [PATCH] feat: integrate modern GenAI SDK for image generation and enhance image handling in AI requests --- src/commands/messages/AI/chat.ts | 65 +++------ src/commands/messages/AI/image.ts | 62 +++++++++ src/commands/messages/help.ts | 2 +- src/core/services/AIService.ts | 221 ++++++++++++++++++++++++++++-- src/events/messageCreate.ts | 11 +- 5 files changed, 295 insertions(+), 66 deletions(-) create mode 100644 src/commands/messages/AI/image.ts diff --git a/src/commands/messages/AI/chat.ts b/src/commands/messages/AI/chat.ts index 4016328..a839b85 100644 --- a/src/commands/messages/AI/chat.ts +++ b/src/commands/messages/AI/chat.ts @@ -227,56 +227,23 @@ export const command: CommandMessage = { // Verificar si hay imágenes adjuntas const attachments = Array.from(message.attachments.values()); - const hasImages = attachments.length > 0 && aiService.hasImageAttachments?.(attachments); + const hasImages = attachments.length > 0 && aiService.hasImageAttachments(attachments); - // Usar el nuevo método con memoria persistente y soporte para imágenes - if (hasImages) { - // Agregar información sobre las imágenes a los metadatos - const imageInfo = attachments - .filter(att => att.contentType?.startsWith('image/') || - ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'].some(ext => - att.name?.toLowerCase().endsWith(ext))) - .map(att => `${att.name} (${att.contentType || 'imagen'})`) - .join(', '); - - const enhancedMeta = messageMeta + (imageInfo ? ` | Imágenes adjuntas: ${imageInfo}` : ''); - - // Usar método específico para imágenes - const request = { - userId, - guildId, - channelId: message.channel.id, - prompt: prompt.trim(), - priority: 'normal' as const, - timestamp: Date.now(), - aiRolePrompt: undefined, - meta: enhancedMeta, - messageId: message.id, - referencedMessageId, - attachments: attachments, - client: message.client - }; - - // Procesar con imágenes - aiResponse = await new Promise((resolve, reject) => { - request.resolve = resolve; - request.reject = reject; - aiService.requestQueue.push(request as any); - }); - } else { - // Método normal sin imágenes - aiResponse = await aiService.processAIRequestWithMemory( - userId, - prompt, - guildId, - message.channel.id, - message.id, - referencedMessageId, - message.client, - 'normal', - { meta: messageMeta } - ); - } + // Usar el método unificado con memoria persistente y soporte para imágenes + aiResponse = await aiService.processAIRequestWithMemory( + userId, + prompt, + guildId, + message.channel.id, + message.id, + referencedMessageId, + message.client, + 'normal', + { + meta: messageMeta + (hasImages ? ` | Tiene ${attachments.length} imagen(es) adjunta(s)` : ''), + attachments: hasImages ? attachments : undefined + } + ); // Reemplazar :nombre: por el tag real del emoji, evitando bloques de código if (emojiNames.length > 0) { diff --git a/src/commands/messages/AI/image.ts b/src/commands/messages/AI/image.ts new file mode 100644 index 0000000..d079bd9 --- /dev/null +++ b/src/commands/messages/AI/image.ts @@ -0,0 +1,62 @@ +import { CommandMessage } from "../../../core/types/commands"; +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 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; + } + + 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; + } + + //@ts-ignore + await message.channel.sendTyping().catch(() => {}); + const result = await aiService.generateImage(prompt, { size }); + + await message.reply({ + content: `✅ Imagen generada (${size}).`, + files: [{ attachment: result.data, name: result.fileName }] + }); + } catch (error: any) { + logger.error('Error generando imagen:', error); + await message.reply({ content: `❌ Error generando imagen: ${error?.message || 'Error desconocido'}` }); + } + } +}; diff --git a/src/commands/messages/help.ts b/src/commands/messages/help.ts index b80b5a8..f2e8ea1 100644 --- a/src/commands/messages/help.ts +++ b/src/commands/messages/help.ts @@ -172,7 +172,7 @@ export const command: CommandMessage = { ] }; - const panelMessage = await message.reply({ flags: 32768, components: [helpPanel, categorySelectRow, exportRow] }); + const panelMessage = await message.reply({ flags: 32768, components: [helpPanel, categorySelectRow] }); const collector = panelMessage.createMessageComponentCollector({ time: 600000, diff --git a/src/core/services/AIService.ts b/src/core/services/AIService.ts index 55dd1da..dd12d37 100644 --- a/src/core/services/AIService.ts +++ b/src/core/services/AIService.ts @@ -1,4 +1,6 @@ import { GoogleGenerativeAI, HarmCategory, HarmBlockThreshold } from "@google/generative-ai"; +// New: modern GenAI SDK for image generation +import { GoogleGenAI } from "@google/genai"; import logger from "../lib/logger"; import { Collection } from "discord.js"; import { prisma } from "../database/prisma"; @@ -85,6 +87,8 @@ function isAPIError(error: unknown): error is { message: string; code?: string } export class AIService { private genAI: GoogleGenerativeAI; + // New: client for modern GenAI features (images) + private genAIv2: any; private conversations = new Collection(); private requestQueue: AIRequest[] = []; private processing = false; @@ -95,8 +99,8 @@ export class AIService { // Configuración mejorada y escalable private readonly config = { - maxInputTokens: 1048576, // 1M tokens Gemini 2.5 Flash - maxOutputTokens: 8192, // Reducido para mejor rendimiento + maxInputTokens: 1048576, // 1M tokens Gemini 2.5 Flash (entrada) + maxOutputTokens: 65536, // 65,536 salida (según aclaración del usuario para preview 09-2025) tokenResetThreshold: 0.80, // Más conservador maxConversationAge: 30 * 60 * 1000, // 30 minutos maxMessageHistory: 8, // Reducido para mejor memoria @@ -117,6 +121,12 @@ export class AIService { } this.genAI = new GoogleGenerativeAI(apiKey); + // Initialize modern SDK (lo tratamos como any para compatibilidad de tipos) + try { + this.genAIv2 = new GoogleGenAI({ apiKey }); + } catch { + this.genAIv2 = null; + } this.startCleanupService(); this.startQueueProcessor(); } @@ -278,6 +288,91 @@ export class AIService { }, this.config.cleanupInterval); } + /** + * Parser de errores (versión legacy no utilizada) + */ + private parseAPIErrorLegacy(error: unknown): string { + // Delegar a la versión nueva + return this.parseAPIError(error); + } + + /** + * Versión legacy de processAIRequestWithMemory (sin uso externo) + */ + async processAIRequestWithMemoryLegacy( + userId: string, + prompt: string, + guildId?: string, + channelId?: string, + messageId?: string, + referencedMessageId?: string, + client?: any, + priority: 'low' | 'normal' | 'high' = 'normal', + options?: { aiRolePrompt?: string; meta?: string; attachments?: any[] } + ): Promise { + // Validaciones exhaustivas + if (!prompt?.trim()) { + throw new Error('El prompt no puede estar vacío'); + } + + if (prompt.length > 4000) { + throw new Error('El prompt excede el límite de 4000 caracteres'); + } + + // Rate limiting por usuario + if (!this.checkRateLimit(userId)) { + throw new Error('Has excedido el límite de requests. Espera un momento.'); + } + + // Cooldown entre requests + const lastRequest = this.userCooldowns.get(userId) || 0; + const timeSinceLastRequest = Date.now() - lastRequest; + + if (timeSinceLastRequest < this.config.cooldownMs) { + const waitTime = Math.ceil((this.config.cooldownMs - timeSinceLastRequest) / 1000); + throw new Error(`Debes esperar ${waitTime} segundos antes de hacer otra consulta`); + } + + // Agregar a la queue con Promise + return new Promise((resolve, reject) => { + const request: AIRequest & { client?: any; attachments?: any[] } = { + userId, + guildId, + channelId, + prompt: prompt.trim(), + priority, + timestamp: Date.now(), + resolve, + reject, + aiRolePrompt: options?.aiRolePrompt, + meta: options?.meta, + messageId, + referencedMessageId, + client, + attachments: options?.attachments + }; + + // Insertar según prioridad + if (priority === 'high') { + this.requestQueue.unshift(request); + } else { + this.requestQueue.push(request); + } + + // Timeout automático + setTimeout(() => { + const index = this.requestQueue.findIndex(r => r === request); + if (index !== -1) { + this.requestQueue.splice(index, 1); + reject(new Error('Request timeout: La solicitud tardó demasiado tiempo')); + } + }, this.config.requestTimeout); + + this.userCooldowns.set(userId, Date.now()); + }); + } + + /** * Parser mejorado de errores de API - Type-safe sin ts-ignore */ @@ -344,7 +439,7 @@ export class AIService { referencedMessageId?: string, client?: any, priority: 'low' | 'normal' | 'high' = 'normal', - options?: { aiRolePrompt?: string; meta?: string } + options?: { aiRolePrompt?: string; meta?: string; attachments?: any[] } ): Promise { // Validaciones exhaustivas if (!prompt?.trim()) { @@ -371,7 +466,7 @@ export class AIService { // Agregar a la queue con Promise return new Promise((resolve, reject) => { - const request: AIRequest = { + const request: AIRequest & { client?: any; attachments?: any[] } = { userId, guildId, channelId, @@ -384,6 +479,8 @@ export class AIService { meta: options?.meta, messageId, referencedMessageId, + client, + attachments: options?.attachments }; // Insertar según prioridad @@ -468,15 +565,9 @@ export class AIService { } } - // Usar la API correcta de Google Generative AI - // Para imágenes, usar gemini-2.5-flash (soporta multimodal) - // Para solo texto, usar gemini-2.5-flash-preview-09-2025 - const modelName = hasImages && imageAttachments.length > 0 - ? "gemini-2.5-flash-image" - : "gemini-2.5-flash-preview-09-2025"; - + // Usar gemini-2.5-flash-preview-09-2025 que puede leer imágenes y responder con texto const model = this.genAI.getGenerativeModel({ - model: modelName, + model: "gemini-2.5-flash-preview-09-2025", generationConfig: { maxOutputTokens: Math.min(this.config.maxOutputTokens, Math.max(1024, estimatedTokens * 0.5)), temperature: 0.7, @@ -613,6 +704,18 @@ Responde de forma directa y útil:`; return true; } + /** + * Estimación de tokens más precisa + */ + private estimateTokens(text: string): number { + // Aproximación mejorada basada en la tokenización real + const words = text.split(/\s+/).length; + const chars = text.length; + + // Fórmula híbrida más precisa + return Math.ceil((words * 1.3) + (chars * 0.25)); + } + /** * Detección mejorada de requests de imagen */ @@ -628,9 +731,28 @@ Responde de forma directa y útil:`; } /** - * Detectar si hay imágenes adjuntas en el mensaje para análisis + * Detectar si hay imágenes adjuntas en el mensaje para análisis (método público) */ - private hasImageAttachments(attachments?: any[]): boolean { + public hasImageAttachments(attachments?: any[]): boolean { + if (!attachments || attachments.length === 0) return false; + + const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp']; + const imageMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp']; + + return attachments.some(attachment => { + const hasImageExtension = imageExtensions.some(ext => + attachment.name?.toLowerCase().endsWith(ext) + ); + const hasImageMimeType = imageMimeTypes.includes(attachment.contentType?.toLowerCase()); + + return hasImageExtension || hasImageMimeType; + }); + } + + /** + * Detectar si hay imágenes adjuntas en el mensaje para análisis (método privado) + */ + private hasImageAttachmentsPrivate(attachments?: any[]): boolean { if (!attachments || attachments.length === 0) return false; const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp']; @@ -1243,6 +1365,77 @@ Responde de forma directa y útil:`; return ''; } } + + /** + * Generar imagen usando gemini-2.5-flash-image (Google Gen AI SDK moderno) + * 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; }> { + if (!prompt?.trim()) { + throw new Error('El prompt de imagen no puede estar vacío'); + } + if (!this.genAIv2) { + throw new Error('El SDK moderno (@google/genai) no está inicializado'); + } + + // Mapear tamaño a hints simples (si el modelo los admite) + const sizeHint = options?.size ?? 'square'; + const mimeType = options?.mimeType ?? 'image/png'; + + try { + // Llamada flexible usando any para compatibilidad de tipos entre versiones + const res: any = await (this.genAIv2 as any).models.generateImages({ + model: 'gemini-2.5-flash-image', + // La API moderna acepta `contents` o `prompt` según versión; usamos ambos por compatibilidad + contents: prompt, + prompt, + config: { + // Algunos despliegues soportan indicar formato de salida + responseMimeType: mimeType, + // Hints de tamaño en metadatos si aplica + // Nota: si no es soportado, el backend lo ignorará sin fallar + aspectRatio: sizeHint === 'portrait' ? '9:16' : sizeHint === 'landscape' ? '16:9' : '1:1', + } + }); + + // Intentar obtener el primer resultado de imagen en varias formas comunes + let imageDataBase64: string | undefined; + let resultMime: string = mimeType; + let fileName = `gen_${Date.now()}.${mimeType.includes('png') ? 'png' : mimeType.includes('jpeg') ? 'jpg' : 'img'}`; + + if (res?.image?.data) { + imageDataBase64 = res.image.data; + resultMime = res.image.mimeType ?? resultMime; + } else if (Array.isArray(res?.images) && res.images.length > 0) { + // Some SDKs return { images: [{ data, mimeType }] } + imageDataBase64 = res.images[0].data ?? res.images[0].inlineData?.data; + resultMime = res.images[0].mimeType ?? res.images[0].inlineData?.mimeType ?? resultMime; + } else if (res?.response?.candidates?.[0]?.content?.parts) { + // Fallback: imagen como inlineData en parts + const parts = res.response.candidates[0].content.parts; + const imgPart = parts.find((p: any) => p.inlineData && p.inlineData.data); + if (imgPart) { + imageDataBase64 = imgPart.inlineData.data; + resultMime = imgPart.inlineData.mimeType ?? resultMime; + } + } + + if (!imageDataBase64) { + throw new Error('No se recibió imagen del modelo'); + } + + const buffer = Buffer.from(imageDataBase64, 'base64'); + // Ajustar nombre de archivo segun mimetype real + if (resultMime.includes('png')) fileName = fileName.replace(/\.[^.]+$/, '.png'); + else if (resultMime.includes('jpeg') || resultMime.includes('jpg')) fileName = fileName.replace(/\.[^.]+$/, '.jpg'); + else if (resultMime.includes('webp')) fileName = fileName.replace(/\.[^.]+$/, '.webp'); + + return { data: buffer, mimeType: resultMime, fileName }; + } catch (e) { + const msg = this.parseAPIError(e); + throw new Error(msg); + } + } } // Instancia singleton diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 0f44673..addc243 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -104,7 +104,11 @@ async function handleAIReply(message: any) { const messageMeta = buildMessageMeta(message, emojiResult.names); - // Procesar con el servicio de AI usando memoria persistente + // Verificar si hay imágenes adjuntas + const attachments = Array.from(message.attachments.values()); + const hasImages = attachments.length > 0 && aiService.hasImageAttachments(attachments); + + // Procesar con el servicio de AI usando memoria persistente y soporte para imágenes const aiResponse = await aiService.processAIRequestWithMemory( message.author.id, message.content, @@ -114,7 +118,10 @@ async function handleAIReply(message: any) { message.reference.messageId, message.client, 'normal', - { meta: messageMeta } + { + meta: messageMeta + (hasImages ? ` | Tiene ${attachments.length} imagen(es) adjunta(s)` : ''), + attachments: hasImages ? attachments : undefined + } ); // Reemplazar emojis personalizados