feat: integrate modern GenAI SDK for image generation and enhance image handling in AI requests
This commit is contained in:
@@ -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) {
|
||||
|
||||
62
src/commands/messages/AI/image.ts
Normal file
62
src/commands/messages/AI/image.ts
Normal file
@@ -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] <prompt>',
|
||||
run: async (message, args) => {
|
||||
try {
|
||||
if (!args || args.length === 0) {
|
||||
await message.reply({
|
||||
content: 'Uso: aiimg [square|portrait|landscape] <prompt>\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'}` });
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, ConversationContext>();
|
||||
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<string> {
|
||||
// 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<string> {
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user