From 2d938c92ab04c890899b5f973ec9f861184f5977 Mon Sep 17 00:00:00 2001 From: shni Date: Sat, 4 Oct 2025 03:53:51 -0500 Subject: [PATCH] feat: implement background detection of image model and enhance image generation handling in AI service --- src/core/services/AIService.ts | 104 ++++++++++++++++++++++++++++----- 1 file changed, 91 insertions(+), 13 deletions(-) diff --git a/src/core/services/AIService.ts b/src/core/services/AIService.ts index 03a209f..4cb88f0 100644 --- a/src/core/services/AIService.ts +++ b/src/core/services/AIService.ts @@ -87,8 +87,9 @@ 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; + // Cache del modelo de imágenes detectado + private imageModelName?: string | null; private conversations = new Collection(); private requestQueue: AIRequest[] = []; private processing = false; @@ -121,7 +122,6 @@ 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 { @@ -129,6 +129,83 @@ export class AIService { } this.startCleanupService(); this.startQueueProcessor(); + // Detectar modelo de imágenes en background (no bloqueante) + this.detectImageModel().catch(err => { + logger.warn({ err }, 'No se pudo detectar automáticamente un modelo de imágenes'); + }); + } + + // Detecta un modelo de imágenes disponible y lo cachea + private async detectImageModel(): Promise { + if (!this.genAIv2) { + this.imageModelName = null; + return null; + } + + // Permitir override por variable de entorno + const override = process.env.GENAI_IMAGE_MODEL?.trim(); + if (override) { + this.imageModelName = override; + logger.info({ model: override }, 'Usando modelo de imágenes por ENV override'); + return override; + } + + // Lista de candidatos conocidos (orden de preferencia) + const candidates = [ + 'gemini-2.5-flash-image', // puede no estar disponible aún en tu proyecto/region/apiVersion + 'imagen-3.0-generate', + 'imagen-3.0-fast', + 'imagen-3.0', + ]; + + // Intentar listar modelos si el SDK lo soporta + try { + const listed: any = await (this.genAIv2 as any).models?.listModels?.(); + const models: string[] = Array.isArray(listed?.models) + ? listed.models.map((m: any) => m?.name || m?.model || m?.id).filter(Boolean) + : []; + if (models.length) { + // Buscar modelos que parezcan de imagen + const imageLike = models.filter((id: string) => /imagen|image/i.test(id)); + // Priorizar nuestros candidatos en el orden propuesto; si no, tomar el primero imageLike + for (const c of candidates) { + if (imageLike.some((m) => m.includes(c))) { + this.imageModelName = imageLike.find((m) => m.includes(c))!; + logger.info({ model: this.imageModelName }, 'Modelo de imágenes detectado por listModels (preferencia)'); + return this.imageModelName; + } + } + if (imageLike[0]) { + this.imageModelName = imageLike[0]; + logger.info({ model: this.imageModelName }, 'Modelo de imágenes detectado por listModels'); + return this.imageModelName; + } + } + } catch (e) { + // Continuar con prueba directa de candidatos si listModels no existe o falla + logger.debug({ err: getErrorMessage(e) }, 'listModels no disponible o falló; probando candidatos conocidos'); + } + + // Probar generar un ping mínimo con candidatos conocidos (sin coste excesivo) + for (const candidate of candidates) { + try { + // Intento muy ligero: usar generateImages con un prompt mínimo + await (this.genAIv2 as any).models.generateImages({ + model: candidate, + prompt: 'ping', + config: { imageSize: '1:1' }, + }); + this.imageModelName = candidate; + logger.info({ model: candidate }, 'Modelo de imágenes detectado por prueba directa'); + return candidate; + } catch (e) { + // 404/NOT_FOUND esperado para modelos no disponibles; seguir + continue; + } + } + + this.imageModelName = null; + return null; } /** @@ -1378,14 +1455,19 @@ Responde de forma directa y útil:`; throw new Error('El SDK moderno (@google/genai) no está inicializado'); } + // 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.'); + } + const mimeType = options?.mimeType ?? 'image/png'; const size = options?.size ?? 'square'; const imageSize = size === 'portrait' ? '9:16' : size === 'landscape' ? '16:9' : '1:1'; try { - // Preferir generateImages (SDK moderno) cuando está disponible const res: any = await (this.genAIv2 as any).models.generateImages({ - model: 'gemini-2.5-flash-image', + model, prompt, config: { responseMimeType: mimeType, @@ -1393,7 +1475,6 @@ Responde de forma directa y útil:`; } }); - // Respuesta típica: { images: [{ data, mimeType }] } let base64: string | undefined; let outMime: string | undefined; @@ -1402,17 +1483,15 @@ Responde de forma directa y útil:`; base64 = first?.data || first?.b64Data || first?.inlineData?.data; outMime = first?.mimeType || first?.inlineData?.mimeType; } - - // Fallbacks para formas alternativas if (!base64 && res?.image?.data) { base64 = res.image.data; outMime = res.image.mimeType; } - // Fallback a generateContent si generateImages no retorna 'images' if (!base64) { + // Fallback a generateContent para algunas implementaciones const alt: any = await (this.genAIv2 as any).models.generateContent({ - model: 'gemini-2.5-flash-image', + model, contents: prompt, config: { responseMimeType: mimeType, @@ -1437,7 +1516,7 @@ Responde de forma directa y útil:`; } if (!base64) { - logger.error({ res }, 'Respuesta de imagen sin datos de imagen'); + logger.error({ res, model }, 'Respuesta de imagen sin datos de imagen'); throw new Error('No se recibió imagen del modelo'); } @@ -1449,11 +1528,10 @@ Responde de forma directa y útil:`; return { data: Buffer.from(base64, 'base64'), mimeType: finalMime, fileName }; } catch (e) { - // Log completo del error original para depuración - logger.error(e as any, 'Fallo en generateImage'); + logger.error({ err: e as any, model }, 'Fallo en generateImage'); const parsed = this.parseAPIError(e); const original = getErrorMessage(e); - // Conservar mensaje original si el parser no aporta más contexto + // Si el parser no aporta, usa el original del backend const message = parsed === 'Error temporal del servicio de IA. Intenta de nuevo' ? original : parsed; throw new Error(message || parsed); }