2025-10-02 21:52:08 -05:00
|
|
|
import { GoogleGenerativeAI, HarmCategory, HarmBlockThreshold } from "@google/generative-ai";
|
|
|
|
|
import logger from "../lib/logger";
|
|
|
|
|
import { Collection } from "discord.js";
|
2025-10-04 01:31:36 -05:00
|
|
|
import { prisma } from "../database/prisma";
|
2025-10-02 21:52:08 -05:00
|
|
|
|
|
|
|
|
// Tipos mejorados para mejor type safety
|
|
|
|
|
interface ConversationContext {
|
|
|
|
|
messages: Array<{
|
|
|
|
|
role: 'user' | 'assistant';
|
|
|
|
|
content: string;
|
|
|
|
|
timestamp: number;
|
|
|
|
|
tokens: number;
|
|
|
|
|
}>;
|
|
|
|
|
totalTokens: number;
|
|
|
|
|
imageRequests: number;
|
|
|
|
|
lastActivity: number;
|
|
|
|
|
userId: string;
|
|
|
|
|
guildId?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface AIRequest {
|
|
|
|
|
userId: string;
|
|
|
|
|
guildId?: string;
|
|
|
|
|
prompt: string;
|
|
|
|
|
priority: 'low' | 'normal' | 'high';
|
|
|
|
|
timestamp: number;
|
|
|
|
|
resolve: (value: string) => void;
|
|
|
|
|
reject: (error: Error) => void;
|
2025-10-04 01:31:36 -05:00
|
|
|
aiRolePrompt?: string;
|
|
|
|
|
meta?: string;
|
2025-10-02 21:52:08 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Utility function para manejar errores de forma type-safe
|
|
|
|
|
function getErrorMessage(error: unknown): string {
|
|
|
|
|
if (error instanceof Error) {
|
|
|
|
|
return error.message;
|
|
|
|
|
}
|
|
|
|
|
if (typeof error === 'string') {
|
|
|
|
|
return error;
|
|
|
|
|
}
|
|
|
|
|
if (error && typeof error === 'object' && 'message' in error) {
|
|
|
|
|
return String(error.message);
|
|
|
|
|
}
|
|
|
|
|
return 'Error desconocido';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Type guard para verificar si es un Error
|
|
|
|
|
function isError(error: unknown): error is Error {
|
|
|
|
|
return error instanceof Error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Type guard para verificar errores de API específicos
|
|
|
|
|
function isAPIError(error: unknown): error is { message: string; code?: string } {
|
|
|
|
|
return (
|
|
|
|
|
error !== null &&
|
|
|
|
|
typeof error === 'object' &&
|
|
|
|
|
'message' in error &&
|
|
|
|
|
typeof (error as any).message === 'string'
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export class AIService {
|
|
|
|
|
private genAI: GoogleGenerativeAI;
|
|
|
|
|
private conversations = new Collection<string, ConversationContext>();
|
|
|
|
|
private requestQueue: AIRequest[] = [];
|
|
|
|
|
private processing = false;
|
|
|
|
|
private userCooldowns = new Collection<string, number>();
|
|
|
|
|
private rateLimitTracker = new Collection<string, { count: number; resetTime: number }>();
|
2025-10-04 01:31:36 -05:00
|
|
|
// Cache de configuración por guild
|
|
|
|
|
private guildPromptCache = new Collection<string, { prompt: string | null; fetchedAt: number }>();
|
|
|
|
|
|
2025-10-02 21:52:08 -05:00
|
|
|
// Configuración mejorada y escalable
|
|
|
|
|
private readonly config = {
|
|
|
|
|
maxInputTokens: 1048576, // 1M tokens Gemini 2.5 Flash
|
|
|
|
|
maxOutputTokens: 8192, // Reducido para mejor rendimiento
|
|
|
|
|
tokenResetThreshold: 0.80, // Más conservador
|
|
|
|
|
maxConversationAge: 30 * 60 * 1000, // 30 minutos
|
|
|
|
|
maxMessageHistory: 8, // Reducido para mejor memoria
|
|
|
|
|
cooldownMs: 3000, // 3 segundos entre requests
|
|
|
|
|
maxImageRequests: 3, // Reducido para evitar spam
|
|
|
|
|
requestTimeout: 30000, // 30 segundos timeout
|
|
|
|
|
maxConcurrentRequests: 3, // Máximo 3 requests simultáneos
|
|
|
|
|
rateLimitWindow: 60000, // 1 minuto
|
|
|
|
|
rateLimitMax: 20, // 20 requests por minuto por usuario
|
|
|
|
|
cleanupInterval: 5 * 60 * 1000, // Limpiar cada 5 minutos
|
2025-10-04 01:31:36 -05:00
|
|
|
guildConfigTTL: 5 * 60 * 1000, // 5 minutos de cache para prompts de guild
|
|
|
|
|
} as const;
|
2025-10-02 21:52:08 -05:00
|
|
|
|
|
|
|
|
constructor() {
|
|
|
|
|
const apiKey = process.env.GOOGLE_AI_API_KEY;
|
|
|
|
|
if (!apiKey) {
|
|
|
|
|
throw new Error('GOOGLE_AI_API_KEY no está configurada');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.genAI = new GoogleGenerativeAI(apiKey);
|
|
|
|
|
this.startCleanupService();
|
|
|
|
|
this.startQueueProcessor();
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-04 01:31:36 -05:00
|
|
|
/**
|
2025-10-04 01:47:02 -05:00
|
|
|
* Obtener prompt de rol de IA por guild con caché
|
2025-10-04 01:31:36 -05:00
|
|
|
*/
|
|
|
|
|
public async getGuildAiPrompt(guildId: string): Promise<string | null> {
|
|
|
|
|
try {
|
|
|
|
|
const cached = this.guildPromptCache.get(guildId);
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
if (cached && (now - cached.fetchedAt) < this.config.guildConfigTTL) {
|
|
|
|
|
return cached.prompt;
|
|
|
|
|
}
|
2025-10-04 01:47:02 -05:00
|
|
|
// @ts-ignore
|
2025-10-04 01:31:36 -05:00
|
|
|
const guild = await prisma.guild.findUnique({ where: { id: guildId }, select: { aiRolePrompt: true } });
|
|
|
|
|
//@ts-ignore
|
|
|
|
|
const prompt = guild?.aiRolePrompt ?? null;
|
|
|
|
|
this.guildPromptCache.set(guildId, { prompt, fetchedAt: now });
|
|
|
|
|
return prompt;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
logger.warn(`No se pudo cargar aiRolePrompt para guild ${guildId}: ${getErrorMessage(e)}`);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Invalidar cache de configuración de un guild (llamar tras guardar cambios)
|
|
|
|
|
*/
|
|
|
|
|
public invalidateGuildConfig(guildId: string): void {
|
|
|
|
|
this.guildPromptCache.delete(guildId);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-02 21:52:08 -05:00
|
|
|
/**
|
|
|
|
|
* Procesa una request de IA de forma asíncrona y controlada
|
|
|
|
|
*/
|
|
|
|
|
async processAIRequest(
|
|
|
|
|
userId: string,
|
|
|
|
|
prompt: string,
|
|
|
|
|
guildId?: string,
|
2025-10-04 01:31:36 -05:00
|
|
|
priority: 'low' | 'normal' | 'high' = 'normal',
|
|
|
|
|
options?: { aiRolePrompt?: string; meta?: string }
|
2025-10-02 21:52:08 -05:00
|
|
|
): 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 = {
|
|
|
|
|
userId,
|
|
|
|
|
guildId,
|
|
|
|
|
prompt: prompt.trim(),
|
|
|
|
|
priority,
|
|
|
|
|
timestamp: Date.now(),
|
|
|
|
|
resolve,
|
2025-10-04 01:31:36 -05:00
|
|
|
reject,
|
|
|
|
|
aiRolePrompt: options?.aiRolePrompt,
|
|
|
|
|
meta: options?.meta,
|
2025-10-02 21:52:08 -05:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 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());
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Procesador de queue mejorado con control de concurrencia
|
|
|
|
|
*/
|
|
|
|
|
private async startQueueProcessor(): Promise<void> {
|
|
|
|
|
setInterval(async () => {
|
|
|
|
|
if (this.processing || this.requestQueue.length === 0) return;
|
|
|
|
|
|
|
|
|
|
this.processing = true;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
// Procesar hasta 3 requests simultáneamente
|
|
|
|
|
const batch = this.requestQueue.splice(0, this.config.maxConcurrentRequests);
|
|
|
|
|
|
|
|
|
|
await Promise.allSettled(
|
|
|
|
|
batch.map(request => this.processRequest(request))
|
|
|
|
|
);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// Usar nuestro helper para manejar el error de forma type-safe
|
|
|
|
|
const errorMessage = getErrorMessage(error);
|
|
|
|
|
logger.error(`Error en el procesador de queue: ${errorMessage}`);
|
|
|
|
|
|
|
|
|
|
// Si necesitamos más detalles del error, podemos usar type guards
|
|
|
|
|
if (isError(error) && error.stack) {
|
|
|
|
|
logger.error(`Stack trace: ${error.stack}`);
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
this.processing = false;
|
|
|
|
|
}
|
|
|
|
|
}, 1000); // Revisar cada segundo
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Procesa una request individual con manejo completo de errores
|
|
|
|
|
*/
|
|
|
|
|
private async processRequest(request: AIRequest): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const { userId, prompt, guildId } = request;
|
|
|
|
|
const context = this.getOrCreateContext(userId, guildId);
|
|
|
|
|
const isImageRequest = this.detectImageRequest(prompt);
|
|
|
|
|
if (isImageRequest && context.imageRequests >= this.config.maxImageRequests) {
|
|
|
|
|
const error = new Error(`Has alcanzado el límite de ${this.config.maxImageRequests} solicitudes de imagen. La conversación se ha reiniciado.`);
|
|
|
|
|
request.reject(error);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verificar límites de tokens
|
|
|
|
|
const estimatedTokens = this.estimateTokens(prompt);
|
|
|
|
|
if (context.totalTokens + estimatedTokens > this.config.maxInputTokens * this.config.tokenResetThreshold) {
|
|
|
|
|
this.resetConversation(userId);
|
|
|
|
|
logger.info(`Conversación reseteada para usuario ${userId} por límite de tokens`);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-04 01:31:36 -05:00
|
|
|
// Obtener prompt del sistema (desde opciones o DB)
|
|
|
|
|
let effectiveAiRolePrompt = request.aiRolePrompt;
|
|
|
|
|
if (effectiveAiRolePrompt === undefined && guildId) {
|
|
|
|
|
effectiveAiRolePrompt = (await this.getGuildAiPrompt(guildId)) ?? undefined;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-02 21:52:08 -05:00
|
|
|
// Construir prompt del sistema optimizado
|
2025-10-04 01:31:36 -05:00
|
|
|
const systemPrompt = this.buildSystemPrompt(
|
|
|
|
|
prompt,
|
|
|
|
|
context,
|
|
|
|
|
isImageRequest,
|
|
|
|
|
effectiveAiRolePrompt,
|
|
|
|
|
request.meta
|
|
|
|
|
);
|
|
|
|
|
|
2025-10-02 21:52:08 -05:00
|
|
|
// Usar la API correcta de Google Generative AI
|
|
|
|
|
const model = this.genAI.getGenerativeModel({
|
2025-10-02 22:13:09 -05:00
|
|
|
model: "gemini-2.5-flash-preview-09-2025",
|
2025-10-02 21:52:08 -05:00
|
|
|
generationConfig: {
|
|
|
|
|
maxOutputTokens: Math.min(this.config.maxOutputTokens, Math.max(1024, estimatedTokens * 0.5)),
|
|
|
|
|
temperature: 0.7,
|
|
|
|
|
topP: 0.85,
|
|
|
|
|
topK: 40,
|
|
|
|
|
},
|
|
|
|
|
safetySettings: [
|
|
|
|
|
{
|
|
|
|
|
category: HarmCategory.HARM_CATEGORY_HARASSMENT,
|
|
|
|
|
threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
category: HarmCategory.HARM_CATEGORY_HATE_SPEECH,
|
|
|
|
|
threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const result = await model.generateContent(systemPrompt);
|
|
|
|
|
const response = await result.response;
|
|
|
|
|
const aiResponse = response.text()?.trim();
|
|
|
|
|
|
|
|
|
|
if (!aiResponse) {
|
|
|
|
|
const error = new Error('La IA no generó una respuesta válida');
|
|
|
|
|
request.reject(error);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Actualizar contexto de forma eficiente
|
|
|
|
|
this.updateContext(context, prompt, aiResponse, estimatedTokens, isImageRequest);
|
|
|
|
|
|
|
|
|
|
request.resolve(aiResponse);
|
|
|
|
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// Manejo type-safe de errores sin ts-ignore
|
|
|
|
|
const errorMessage = this.parseAPIError(error);
|
|
|
|
|
const logMessage = getErrorMessage(error);
|
|
|
|
|
logger.error(`Error procesando AI request para ${request.userId}: ${logMessage}`);
|
|
|
|
|
|
|
|
|
|
// Log adicional si es un Error con stack trace
|
|
|
|
|
if (isError(error) && error.stack) {
|
|
|
|
|
logger.error(`Stack trace completo: ${error.stack}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
request.reject(new Error(errorMessage));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Construcción optimizada del prompt del sistema
|
|
|
|
|
*/
|
2025-10-04 01:31:36 -05:00
|
|
|
private buildSystemPrompt(
|
|
|
|
|
userPrompt: string,
|
|
|
|
|
context: ConversationContext,
|
|
|
|
|
isImageRequest: boolean,
|
|
|
|
|
aiRolePrompt?: string,
|
|
|
|
|
meta?: string
|
|
|
|
|
): string {
|
2025-10-02 21:52:08 -05:00
|
|
|
const recentMessages = context.messages
|
2025-10-04 01:47:02 -05:00
|
|
|
.slice(-4)
|
2025-10-02 21:52:08 -05:00
|
|
|
.map(msg => `${msg.role === 'user' ? 'Usuario' : 'Asistente'}: ${msg.content}`)
|
|
|
|
|
.join('\n');
|
|
|
|
|
|
2025-10-04 01:31:36 -05:00
|
|
|
const roleBlock = aiRolePrompt && aiRolePrompt.trim() ? `\n## Rol del sistema (servidor):\n${aiRolePrompt.trim().slice(0, 1200)}\n` : '';
|
|
|
|
|
const metaBlock = meta && meta.trim() ? `\n## Contexto del mensaje:\n${meta.trim().slice(0, 800)}\n` : '';
|
|
|
|
|
|
|
|
|
|
return `Eres una hermana mayor kawaii y cariñosa que habla por Discord. Responde de manera natural, útil y concisa.${roleBlock}${metaBlock}
|
2025-10-02 21:52:08 -05:00
|
|
|
|
|
|
|
|
## Reglas Discord:
|
|
|
|
|
- USA **markdown de Discord**: **negrita**, *cursiva*, \`código\`, \`\`\`bloques\`\`\`
|
|
|
|
|
- NUNCA uses LaTeX ($$)
|
|
|
|
|
- Máximo 2-3 emojis por respuesta
|
2025-10-04 01:47:02 -05:00
|
|
|
- Prefiere emojis Unicode estándar (🙂, 🎯, etc.) cuando no haya más contexto
|
|
|
|
|
- Si se te proporciona una lista de "Emojis personalizados disponibles", puedes usarlos escribiendo :nombre: exactamente como aparece; NO inventes nombres
|
2025-10-02 21:52:08 -05:00
|
|
|
- Respuestas concisas y claras
|
|
|
|
|
|
|
|
|
|
${isImageRequest ? `
|
|
|
|
|
## Limitación:
|
|
|
|
|
- No puedes generar imágenes
|
|
|
|
|
- Ofrece ayuda alternativa (descripciones, recursos, etc.)
|
|
|
|
|
` : ''}
|
|
|
|
|
|
|
|
|
|
## Contexto reciente:
|
|
|
|
|
${recentMessages || 'Sin historial previo'}
|
|
|
|
|
|
|
|
|
|
## Consulta actual:
|
|
|
|
|
${userPrompt}
|
|
|
|
|
|
|
|
|
|
Responde de forma directa y útil:`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Sistema de rate limiting mejorado
|
|
|
|
|
*/
|
|
|
|
|
private checkRateLimit(userId: string): boolean {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const userLimit = this.rateLimitTracker.get(userId);
|
|
|
|
|
|
|
|
|
|
if (!userLimit || now > userLimit.resetTime) {
|
|
|
|
|
this.rateLimitTracker.set(userId, {
|
|
|
|
|
count: 1,
|
|
|
|
|
resetTime: now + this.config.rateLimitWindow
|
|
|
|
|
});
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (userLimit.count >= this.config.rateLimitMax) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
userLimit.count++;
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Detección mejorada de requests de imagen
|
|
|
|
|
*/
|
|
|
|
|
private detectImageRequest(prompt: string): boolean {
|
|
|
|
|
const imageKeywords = [
|
|
|
|
|
'imagen', 'image', 'dibujo', 'draw', 'dibujar',
|
|
|
|
|
'generar imagen', 'create image', 'picture', 'foto',
|
|
|
|
|
'ilustración', 'arte', 'pintura', 'sketch'
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const lowerPrompt = prompt.toLowerCase();
|
|
|
|
|
return imageKeywords.some(keyword => lowerPrompt.includes(keyword));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Obtener o crear contexto de conversación
|
|
|
|
|
*/
|
|
|
|
|
private getOrCreateContext(userId: string, guildId?: string): ConversationContext {
|
|
|
|
|
const key = `${userId}-${guildId || 'dm'}`;
|
|
|
|
|
let context = this.conversations.get(key);
|
|
|
|
|
|
|
|
|
|
if (!context) {
|
|
|
|
|
context = {
|
|
|
|
|
messages: [],
|
|
|
|
|
totalTokens: 0,
|
|
|
|
|
imageRequests: 0,
|
|
|
|
|
lastActivity: Date.now(),
|
|
|
|
|
userId,
|
|
|
|
|
guildId
|
|
|
|
|
};
|
|
|
|
|
this.conversations.set(key, context);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
context.lastActivity = Date.now();
|
|
|
|
|
return context;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Actualizar contexto de forma eficiente
|
|
|
|
|
*/
|
|
|
|
|
private updateContext(
|
|
|
|
|
context: ConversationContext,
|
|
|
|
|
userPrompt: string,
|
|
|
|
|
aiResponse: string,
|
|
|
|
|
inputTokens: number,
|
|
|
|
|
isImageRequest: boolean
|
|
|
|
|
): void {
|
|
|
|
|
const outputTokens = this.estimateTokens(aiResponse);
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
|
|
|
|
|
// Agregar mensajes
|
|
|
|
|
context.messages.push(
|
|
|
|
|
{ role: 'user', content: userPrompt, timestamp: now, tokens: inputTokens },
|
|
|
|
|
{ role: 'assistant', content: aiResponse, timestamp: now, tokens: outputTokens }
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Mantener solo los mensajes más recientes
|
|
|
|
|
if (context.messages.length > this.config.maxMessageHistory) {
|
|
|
|
|
const removed = context.messages.splice(0, context.messages.length - this.config.maxMessageHistory);
|
|
|
|
|
const removedTokens = removed.reduce((sum, msg) => sum + msg.tokens, 0);
|
|
|
|
|
context.totalTokens -= removedTokens;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
context.totalTokens += inputTokens + outputTokens;
|
|
|
|
|
context.lastActivity = now;
|
|
|
|
|
|
|
|
|
|
if (isImageRequest) {
|
|
|
|
|
context.imageRequests++;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resetear conversación
|
|
|
|
|
*/
|
|
|
|
|
private resetConversation(userId: string, guildId?: string): void {
|
|
|
|
|
const key = `${userId}-${guildId || 'dm'}`;
|
|
|
|
|
this.conversations.delete(key);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Servicio de limpieza automática
|
|
|
|
|
*/
|
|
|
|
|
private startCleanupService(): void {
|
|
|
|
|
setInterval(() => {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const toDelete: string[] = [];
|
|
|
|
|
|
|
|
|
|
this.conversations.forEach((context, key) => {
|
|
|
|
|
if (now - context.lastActivity > this.config.maxConversationAge) {
|
|
|
|
|
toDelete.push(key);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
toDelete.forEach(key => this.conversations.delete(key));
|
|
|
|
|
|
|
|
|
|
if (toDelete.length > 0) {
|
|
|
|
|
logger.info(`Limpieza automática: ${toDelete.length} conversaciones expiradas eliminadas`);
|
|
|
|
|
}
|
|
|
|
|
}, this.config.cleanupInterval);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Parser mejorado de errores de API - Type-safe sin ts-ignore
|
|
|
|
|
*/
|
|
|
|
|
private parseAPIError(error: unknown): string {
|
|
|
|
|
// Extraer mensaje de forma type-safe
|
|
|
|
|
const message = getErrorMessage(error).toLowerCase();
|
|
|
|
|
|
|
|
|
|
// Verificar si es un error de API estructurado
|
|
|
|
|
if (isAPIError(error)) {
|
|
|
|
|
const apiMessage = error.message.toLowerCase();
|
|
|
|
|
|
|
|
|
|
if (apiMessage.includes('api key') || apiMessage.includes('authentication')) {
|
|
|
|
|
return 'Error de autenticación con la API de IA';
|
|
|
|
|
}
|
|
|
|
|
if (apiMessage.includes('quota') || apiMessage.includes('exceeded')) {
|
|
|
|
|
return 'Se ha alcanzado el límite de uso de la API. Intenta más tarde';
|
|
|
|
|
}
|
|
|
|
|
if (apiMessage.includes('safety') || apiMessage.includes('blocked')) {
|
|
|
|
|
return 'Tu mensaje fue bloqueado por las políticas de seguridad';
|
|
|
|
|
}
|
|
|
|
|
if (apiMessage.includes('timeout') || apiMessage.includes('deadline')) {
|
|
|
|
|
return 'La solicitud tardó demasiado tiempo. Intenta de nuevo';
|
|
|
|
|
}
|
|
|
|
|
if (apiMessage.includes('model not found')) {
|
|
|
|
|
return 'El modelo de IA no está disponible en este momento';
|
|
|
|
|
}
|
|
|
|
|
if (apiMessage.includes('token') || apiMessage.includes('length')) {
|
|
|
|
|
return 'El mensaje excede los límites permitidos';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Manejo genérico para otros tipos de errores
|
|
|
|
|
if (message.includes('api key') || message.includes('authentication')) {
|
|
|
|
|
return 'Error de autenticación con la API de IA';
|
|
|
|
|
}
|
|
|
|
|
if (message.includes('quota') || message.includes('exceeded')) {
|
|
|
|
|
return 'Se ha alcanzado el límite de uso de la API. Intenta más tarde';
|
|
|
|
|
}
|
|
|
|
|
if (message.includes('safety') || message.includes('blocked')) {
|
|
|
|
|
return 'Tu mensaje fue bloqueado por las políticas de seguridad';
|
|
|
|
|
}
|
|
|
|
|
if (message.includes('timeout') || message.includes('deadline')) {
|
|
|
|
|
return 'La solicitud tardó demasiado tiempo. Intenta de nuevo';
|
|
|
|
|
}
|
|
|
|
|
if (message.includes('model not found')) {
|
|
|
|
|
return 'El modelo de IA no está disponible en este momento';
|
|
|
|
|
}
|
|
|
|
|
if (message.includes('token') || message.includes('length')) {
|
|
|
|
|
return 'El mensaje excede los límites permitidos';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return 'Error temporal del servicio de IA. Intenta de nuevo';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Obtener estadísticas del servicio
|
|
|
|
|
*/
|
|
|
|
|
getStats(): {
|
|
|
|
|
activeConversations: number;
|
|
|
|
|
queueLength: number;
|
|
|
|
|
totalRequests: number;
|
|
|
|
|
averageResponseTime: number;
|
|
|
|
|
} {
|
|
|
|
|
return {
|
|
|
|
|
activeConversations: this.conversations.size,
|
|
|
|
|
queueLength: this.requestQueue.length,
|
|
|
|
|
totalRequests: this.userCooldowns.size,
|
|
|
|
|
averageResponseTime: 0
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Instancia singleton
|
|
|
|
|
export const aiService = new AIService();
|