feat: implement AI service with enhanced error handling, rate limiting, and conversation context management
This commit is contained in:
@@ -1,56 +1,89 @@
|
|||||||
import logger from "../../../core/lib/logger";
|
import logger from "../../../core/lib/logger";
|
||||||
import { GoogleGenAI } from "@google/genai";
|
import { CommandMessage } from "../../../core/types/commands";
|
||||||
import {CommandMessage} from "../../../core/types/commands";
|
import { TextChannel, DMChannel, NewsChannel, ThreadChannel, EmbedBuilder } from "discord.js";
|
||||||
import { TextChannel, DMChannel, NewsChannel, ThreadChannel } from "discord.js";
|
import { aiService } from "../../../core/services/AIService";
|
||||||
|
|
||||||
// Función para estimar tokens aproximadamente (1 token ≈ 4 caracteres para texto en español/inglés)
|
/**
|
||||||
function estimateTokens(text: string): number {
|
* Dividir texto de forma inteligente preservando markdown
|
||||||
return Math.ceil(text.length / 4);
|
*/
|
||||||
|
function smartChunkText(text: string, maxLength: number): string[] {
|
||||||
|
if (text.length <= maxLength) return [text];
|
||||||
|
|
||||||
|
const chunks: string[] = [];
|
||||||
|
let currentChunk = '';
|
||||||
|
const lines = text.split('\n');
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Si agregar esta línea excede el límite
|
||||||
|
if (currentChunk.length + line.length + 1 > maxLength) {
|
||||||
|
if (currentChunk) {
|
||||||
|
chunks.push(currentChunk.trim());
|
||||||
|
currentChunk = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si la línea misma es muy larga, dividirla por palabras
|
||||||
|
if (line.length > maxLength) {
|
||||||
|
const words = line.split(' ');
|
||||||
|
let wordChunk = '';
|
||||||
|
|
||||||
|
for (const word of words) {
|
||||||
|
if (wordChunk.length + word.length + 1 > maxLength) {
|
||||||
|
if (wordChunk) {
|
||||||
|
chunks.push(wordChunk.trim());
|
||||||
|
wordChunk = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wordChunk += (wordChunk ? ' ' : '') + word;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wordChunk) {
|
||||||
|
currentChunk = wordChunk;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentChunk = line;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentChunk += (currentChunk ? '\n' : '') + line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentChunk) {
|
||||||
|
chunks.push(currentChunk.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Límites de tokens según Gemini 2.5 Flash
|
|
||||||
const MAX_INPUT_TOKENS = 1048576; // 1M tokens de entrada
|
|
||||||
const MAX_OUTPUT_TOKENS = 65536; // 64K tokens de salida
|
|
||||||
const TOKEN_RESET_THRESHOLD = 0.85; // Resetear cuando esté al 85% del límite
|
|
||||||
|
|
||||||
// Estado de conversación por usuario (memoria simple en memoria)
|
|
||||||
const conversationHistory = new Map<string, {
|
|
||||||
messages: string[],
|
|
||||||
totalTokens: number,
|
|
||||||
imageCount: number
|
|
||||||
}>();
|
|
||||||
|
|
||||||
export const command: CommandMessage = {
|
export const command: CommandMessage = {
|
||||||
name: 'ai',
|
name: 'ai',
|
||||||
type: "message",
|
type: "message",
|
||||||
aliases: ['chat', 'gemini'],
|
aliases: ['chat', 'gemini'],
|
||||||
cooldown: 5,
|
cooldown: 2, // Reducido porque el servicio maneja su propio rate limiting
|
||||||
description: 'Chatea con la IA (Gemini) directamente desde Discord.',
|
description: 'Chatea con la IA (Gemini) de forma estable y escalable.',
|
||||||
category: 'IA',
|
category: 'IA',
|
||||||
usage: 'ai <mensaje>',
|
usage: 'ai <mensaje>',
|
||||||
run: async (message, args) => {
|
run: async (message, args) => {
|
||||||
// Verificar que se proporcione un prompt
|
// Validaciones básicas
|
||||||
if (!args || args.length === 0) {
|
if (!args || args.length === 0) {
|
||||||
await message.reply({
|
const helpEmbed = new EmbedBuilder()
|
||||||
content: "❌ **Error:** Necesitas proporcionar un mensaje para la IA.\n" +
|
.setColor(0xFF69B4)
|
||||||
"**Uso:** `ai <tu mensaje>`\n" +
|
.setTitle('❌ Error: Mensaje requerido')
|
||||||
"**Ejemplo:** `ai ¿Cómo funciona JavaScript?`"
|
.setDescription(
|
||||||
});
|
'**Uso:** `ai <tu mensaje>`\n' +
|
||||||
|
'**Ejemplo:** `ai ¿Cómo funciona JavaScript?`\n' +
|
||||||
|
'**Límite:** 4000 caracteres máximo'
|
||||||
|
)
|
||||||
|
.setFooter({ text: 'AI Chat mejorado con Gemini 2.5 Flash' });
|
||||||
|
|
||||||
|
await message.reply({ embeds: [helpEmbed] });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt = args.join(' ');
|
const prompt = args.join(' ');
|
||||||
const userId = message.author.id;
|
const userId = message.author.id;
|
||||||
|
const guildId = message.guild?.id;
|
||||||
|
|
||||||
// Validar longitud del prompt
|
// Verificar tipo de canal
|
||||||
if (prompt.length > 4000) {
|
|
||||||
await message.reply({
|
|
||||||
content: "❌ **Error:** Tu mensaje es demasiado largo. El límite es de 4000 caracteres."
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verificar que el canal sea de texto
|
|
||||||
const channel = message.channel as TextChannel | DMChannel | NewsChannel | ThreadChannel;
|
const channel = message.channel as TextChannel | DMChannel | NewsChannel | ThreadChannel;
|
||||||
if (!channel || !('send' in channel)) {
|
if (!channel || !('send' in channel)) {
|
||||||
await message.reply({
|
await message.reply({
|
||||||
@@ -59,228 +92,94 @@ export const command: CommandMessage = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Indicador de escritura mejorado
|
||||||
|
const typingInterval = setInterval(() => {
|
||||||
|
channel.sendTyping().catch(() => {});
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Inicializar Google Gemini con configuración desde variables de entorno
|
// Usar el servicio mejorado con manejo de prioridad
|
||||||
const genAI = new GoogleGenAI({
|
const priority = message.member?.permissions.has('Administrator') ? 'high' : 'normal';
|
||||||
apiKey: process.env.GOOGLE_AI_API_KEY
|
|
||||||
});
|
|
||||||
|
|
||||||
// Obtener o inicializar historial de conversación del usuario
|
const aiResponse = await aiService.processAIRequest(
|
||||||
let userHistory = conversationHistory.get(userId);
|
userId,
|
||||||
if (!userHistory) {
|
prompt,
|
||||||
userHistory = { messages: [], totalTokens: 0, imageCount: 0 };
|
guildId,
|
||||||
conversationHistory.set(userId, userHistory);
|
priority
|
||||||
}
|
|
||||||
|
|
||||||
// Enviar mensaje de "escribiendo..."
|
|
||||||
await channel.sendTyping();
|
|
||||||
|
|
||||||
const USERNAME = message.author.username;
|
|
||||||
const CURRENT_DATETIME = new Date().toLocaleString('es-ES', {
|
|
||||||
timeZone: 'America/Monterrey',
|
|
||||||
dateStyle: 'full',
|
|
||||||
timeStyle: 'long'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Detectar si el usuario quiere generar una imagen
|
|
||||||
const imageKeywords = ['imagen', 'image', 'dibujo', 'draw', 'generar imagen', 'create image', 'picture', 'foto'];
|
|
||||||
const isImageRequest = imageKeywords.some(keyword =>
|
|
||||||
prompt.toLowerCase().includes(keyword.toLowerCase())
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Construir el prompt del sistema más natural y menos saturado de emojis
|
// Crear embed de respuesta mejorado
|
||||||
const baseSystemPrompt = `Eres una hermana mayor kawaii y cariñosa que habla por Discord. Responde de manera natural y útil, pero con personalidad tierna.
|
const embed = new EmbedBuilder()
|
||||||
|
.setColor(0xFF69B4)
|
||||||
|
.setTitle('🌸 Gemini-chan')
|
||||||
|
.setDescription(aiResponse)
|
||||||
|
.setFooter({
|
||||||
|
text: `Solicitado por ${message.author.username}`,
|
||||||
|
iconURL: message.author.displayAvatarURL({ forceStatic: false })
|
||||||
|
})
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
## Información del usuario:
|
// Manejar respuestas largas de forma inteligente
|
||||||
- Username: ${USERNAME}
|
if (aiResponse.length > 4000) {
|
||||||
- Fecha actual: ${CURRENT_DATETIME}
|
// Dividir en chunks preservando markdown
|
||||||
|
const chunks = smartChunkText(aiResponse, 4000);
|
||||||
## Reglas importantes para Discord:
|
|
||||||
- NUNCA uses LaTeX ($$), solo usa **markdown normal de Discord**
|
|
||||||
- Para matemáticas usa: **negrita**, *cursiva*, \`código\` y bloques de código
|
|
||||||
- NO uses emojis excesivamente, máximo 2-3 por respuesta
|
|
||||||
- Para tablas usa formato simple de Discord con backticks
|
|
||||||
- Mantén las respuestas claras y legibles en Discord
|
|
||||||
|
|
||||||
## Ejemplos de formato correcto:
|
|
||||||
- Matemáticas: "La raíz cuadrada de 16 es **4**"
|
|
||||||
- Código: \`\`\`javascript\nfunction ejemplo() {}\`\`\`
|
|
||||||
- Énfasis: **importante** o *destacado*
|
|
||||||
|
|
||||||
${isImageRequest ? `
|
|
||||||
## Generación de imágenes:
|
|
||||||
- El usuario está pidiendo una imagen
|
|
||||||
- Gemini 2.5 Flash NO puede generar imágenes
|
|
||||||
- Explica que no puedes generar imágenes pero ofrece ayuda alternativa
|
|
||||||
` : ''}
|
|
||||||
|
|
||||||
## Mensaje del usuario:
|
|
||||||
${prompt}
|
|
||||||
|
|
||||||
## Contexto de conversación anterior:
|
|
||||||
${userHistory.messages.slice(-3).join('\n')}`;
|
|
||||||
|
|
||||||
// Verificar límites de tokens de entrada
|
|
||||||
const estimatedInputTokens = estimateTokens(baseSystemPrompt);
|
|
||||||
|
|
||||||
// Verificar si necesitamos resetear la conversación
|
|
||||||
if (userHistory.totalTokens > MAX_INPUT_TOKENS * TOKEN_RESET_THRESHOLD) {
|
|
||||||
userHistory.messages = [];
|
|
||||||
userHistory.totalTokens = 0;
|
|
||||||
await message.reply({
|
|
||||||
content: "🔄 **Conversación reseteada** - Límite de tokens alcanzado, empezamos de nuevo."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verificar si necesitamos resetear por imágenes
|
|
||||||
if (isImageRequest && userHistory.imageCount >= 5) {
|
|
||||||
userHistory.messages = [];
|
|
||||||
userHistory.totalTokens = 0;
|
|
||||||
userHistory.imageCount = 0;
|
|
||||||
await message.reply({
|
|
||||||
content: "🔄 **Conversación reseteada** - Límite de solicitudes de imagen alcanzado (5), empezamos de nuevo."
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (estimatedInputTokens > MAX_INPUT_TOKENS) {
|
|
||||||
await message.reply({
|
|
||||||
content: `❌ **Error:** Tu mensaje es demasiado largo para procesar.\n` +
|
|
||||||
`**Tokens estimados:** ${estimatedInputTokens.toLocaleString()}\n` +
|
|
||||||
`**Límite máximo:** ${MAX_INPUT_TOKENS.toLocaleString()} tokens\n\n` +
|
|
||||||
`Por favor, acorta tu mensaje e intenta de nuevo.`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calcular tokens de salida apropiados
|
|
||||||
const dynamicOutputTokens = Math.min(
|
|
||||||
Math.max(1024, Math.floor(estimatedInputTokens * 0.3)), // Mínimo 1024, máximo 30% del input
|
|
||||||
MAX_OUTPUT_TOKENS
|
|
||||||
);
|
|
||||||
|
|
||||||
// Generar respuesta
|
|
||||||
const response = await genAI.models.generateContent({
|
|
||||||
model: "gemini-2.5-flash",
|
|
||||||
contents: baseSystemPrompt,
|
|
||||||
// @ts-ignore
|
|
||||||
generationConfig: {
|
|
||||||
maxOutputTokens: dynamicOutputTokens,
|
|
||||||
temperature: 0.7, // Reducido para respuestas más consistentes
|
|
||||||
topP: 0.8,
|
|
||||||
topK: 30,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Extraer el texto de la respuesta
|
|
||||||
const aiResponse = response.text;
|
|
||||||
|
|
||||||
// Verificar si la respuesta está vacía
|
|
||||||
if (!aiResponse || aiResponse.trim().length === 0) {
|
|
||||||
await message.reply({
|
|
||||||
content: "❌ **Error:** La IA no pudo generar una respuesta. Intenta reformular tu pregunta."
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actualizar historial y contadores
|
|
||||||
const estimatedOutputTokens = estimateTokens(aiResponse);
|
|
||||||
userHistory.messages.push(`Usuario: ${prompt}`);
|
|
||||||
userHistory.messages.push(`Asistente: ${aiResponse}`);
|
|
||||||
userHistory.totalTokens += estimatedInputTokens + estimatedOutputTokens;
|
|
||||||
|
|
||||||
if (isImageRequest) {
|
|
||||||
userHistory.imageCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mantener solo los últimos 10 mensajes para evitar crecimiento excesivo
|
|
||||||
if (userHistory.messages.length > 10) {
|
|
||||||
userHistory.messages = userHistory.messages.slice(-10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Información de debug y estado
|
|
||||||
const tokensUsedPercent = ((userHistory.totalTokens / MAX_INPUT_TOKENS) * 100).toFixed(1);
|
|
||||||
const debugInfo = process.env.NODE_ENV === 'development' ?
|
|
||||||
`\n\n*Debug: Input ~${estimatedInputTokens} tokens, Output ~${estimatedOutputTokens} tokens | Total: ${userHistory.totalTokens} (${tokensUsedPercent}%) | Imágenes: ${userHistory.imageCount}/5*` : '';
|
|
||||||
|
|
||||||
// Advertencia si estamos cerca del límite
|
|
||||||
const warningInfo = userHistory.totalTokens > MAX_INPUT_TOKENS * 0.7 ?
|
|
||||||
`\n\n⚠️ *Nota: Conversación larga detectada (${tokensUsedPercent}% del límite). Se reseteará pronto.*` : '';
|
|
||||||
|
|
||||||
// Dividir respuesta si es muy larga para Discord (límite de 2000 caracteres)
|
|
||||||
if (aiResponse.length > 1900) {
|
|
||||||
const chunks = aiResponse.match(/.{1,1900}/gs) || [];
|
|
||||||
|
|
||||||
for (let i = 0; i < chunks.length && i < 3; i++) {
|
for (let i = 0; i < chunks.length && i < 3; i++) {
|
||||||
const chunk = chunks[i];
|
const chunkEmbed = new EmbedBuilder()
|
||||||
const embed = {
|
.setColor(0xFF69B4)
|
||||||
color: 0xFF69B4, // Color rosa kawaii para el tema imouto
|
.setTitle(i === 0 ? '🌸 Gemini-chan' : `🌸 Gemini-chan (${i + 1}/${chunks.length})`)
|
||||||
title: i === 0 ? '🌸 Respuesta de Gemini-chan' : `🌸 Respuesta de Gemini-chan (${i + 1}/${chunks.length})`,
|
.setDescription(chunks[i])
|
||||||
description: chunk + (i === chunks.length - 1 ? debugInfo : ''),
|
.setFooter({
|
||||||
footer: {
|
text: `Solicitado por ${message.author.username} | Parte ${i + 1}`,
|
||||||
text: `Solicitado por ${message.author.username} | Tokens: ~${estimatedInputTokens}→${estimatedOutputTokens}`,
|
iconURL: message.author.displayAvatarURL({ forceStatic: false })
|
||||||
icon_url: message.author.displayAvatarURL({ forceStatic: false })
|
})
|
||||||
},
|
.setTimestamp();
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
await message.reply({ embeds: [embed] });
|
await message.reply({ embeds: [chunkEmbed] });
|
||||||
} else {
|
} else {
|
||||||
await channel.send({ embeds: [embed] });
|
await channel.send({ embeds: [chunkEmbed] });
|
||||||
}
|
// Pausa entre mensajes para evitar rate limits
|
||||||
|
|
||||||
// Pequeña pausa entre mensajes
|
|
||||||
if (i < chunks.length - 1) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chunks.length > 3) {
|
if (chunks.length > 3) {
|
||||||
await channel.send({
|
await channel.send({
|
||||||
content: "⚠️ **Nota:** La respuesta fue truncada por ser demasiado larga. Intenta hacer preguntas más específicas."
|
content: "⚠️ **Nota:** La respuesta fue truncada. Intenta preguntas más específicas."
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Respuesta normal
|
|
||||||
const embed = {
|
|
||||||
color: 0xFF69B4, // Color rosa kawaii
|
|
||||||
title: '🌸 Respuesta de Gemini-chan',
|
|
||||||
description: aiResponse + debugInfo + warningInfo,
|
|
||||||
footer: {
|
|
||||||
text: `Solicitado por ${message.author.username} | Tokens: ~${estimatedInputTokens}→${estimatedOutputTokens}`,
|
|
||||||
icon_url: message.author.displayAvatarURL({ forceStatic: false })
|
|
||||||
},
|
|
||||||
timestamp: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
await message.reply({ embeds: [embed] });
|
await message.reply({ embeds: [embed] });
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
// Log para monitoreo (solo en desarrollo)
|
||||||
logger.error('Error en comando AI:', error);
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
const stats = aiService.getStats();
|
||||||
// Manejar errores específicos incluyendo límites de tokens
|
logger.info(`AI Request completado - Usuario: ${userId}, Queue: ${stats.queueLength}, Conversaciones activas: ${stats.activeConversations}`);
|
||||||
let errorMessage = "❌ **Error:** Ocurrió un problema al comunicarse con la IA.";
|
|
||||||
|
|
||||||
if (error?.message?.includes('API key') || error?.message?.includes('Invalid API') || error?.message?.includes('authentication')) {
|
|
||||||
errorMessage = "❌ **Error:** Token de API inválido o no configurado.";
|
|
||||||
} else if (error?.message?.includes('quota') || error?.message?.includes('exceeded') || error?.message?.includes('rate limit')) {
|
|
||||||
errorMessage = "❌ **Error:** Se ha alcanzado el límite de uso de la API.";
|
|
||||||
} else if (error?.message?.includes('safety') || error?.message?.includes('blocked')) {
|
|
||||||
errorMessage = "❌ **Error:** Tu mensaje fue bloqueado por las políticas de seguridad de la IA.";
|
|
||||||
} else if (error?.message?.includes('timeout')) {
|
|
||||||
errorMessage = "❌ **Error:** La solicitud tardó demasiado tiempo. Intenta de nuevo.";
|
|
||||||
} else if (error?.message?.includes('model not found') || error?.message?.includes('not available')) {
|
|
||||||
errorMessage = "❌ **Error:** El modelo de IA no está disponible en este momento.";
|
|
||||||
} else if (error?.message?.includes('token') || error?.message?.includes('length')) {
|
|
||||||
errorMessage = "❌ **Error:** El mensaje excede los límites de tokens permitidos. Intenta con un mensaje más corto.";
|
|
||||||
} else if (error?.message?.includes('context_length')) {
|
|
||||||
errorMessage = "❌ **Error:** El contexto de la conversación es demasiado largo. La conversación se ha reiniciado.";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await message.reply({
|
} catch (error: any) {
|
||||||
content: errorMessage + "\n\nSi el problema persiste, contacta a un administrador."
|
logger.error(`Error en comando AI para usuario ${userId}:`, error);
|
||||||
});
|
|
||||||
|
// Crear embed de error informativo
|
||||||
|
const errorEmbed = new EmbedBuilder()
|
||||||
|
.setColor(0xFF4444)
|
||||||
|
.setTitle('❌ Error del Servicio de IA')
|
||||||
|
.setDescription(error.message || 'Error desconocido del servicio')
|
||||||
|
.addFields({
|
||||||
|
name: '💡 Consejos',
|
||||||
|
value: '• Verifica que tu mensaje no sea demasiado largo\n' +
|
||||||
|
'• Espera unos segundos entre consultas\n' +
|
||||||
|
'• Evita contenido inapropiado'
|
||||||
|
})
|
||||||
|
.setFooter({ text: 'Si el problema persiste, contacta a un administrador' })
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
await message.reply({ embeds: [errorEmbed] });
|
||||||
|
} finally {
|
||||||
|
// Limpiar indicador de escritura
|
||||||
|
clearInterval(typingInterval);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
132
src/commands/messages/AI/stats.ts
Normal file
132
src/commands/messages/AI/stats.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { CommandMessage } from "../../../core/types/commands";
|
||||||
|
import { EmbedBuilder, PermissionFlagsBits } from "discord.js";
|
||||||
|
import { aiService } from "../../../core/services/AIService";
|
||||||
|
import logger from "../../../core/lib/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatear tiempo de actividad
|
||||||
|
*/
|
||||||
|
function formatUptime(seconds: number): string {
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
|
||||||
|
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
|
||||||
|
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||||
|
return `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatear bytes a formato legible
|
||||||
|
*/
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const command: CommandMessage = {
|
||||||
|
name: 'aistats',
|
||||||
|
type: "message",
|
||||||
|
aliases: ['ai-stats', 'ai-info'],
|
||||||
|
cooldown: 5,
|
||||||
|
description: 'Muestra estadísticas del servicio de IA (Solo administradores)',
|
||||||
|
category: 'Administración',
|
||||||
|
usage: 'aistats [reset]',
|
||||||
|
run: async (message, args) => {
|
||||||
|
// Verificar permisos de administrador
|
||||||
|
if (!message.member?.permissions.has(PermissionFlagsBits.Administrator)) {
|
||||||
|
await message.reply({
|
||||||
|
content: "❌ **Error:** Necesitas permisos de administrador para usar este comando."
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const action = args[0]?.toLowerCase();
|
||||||
|
|
||||||
|
// Reset del sistema si se solicita
|
||||||
|
if (action === 'reset') {
|
||||||
|
// Aquí puedes agregar lógica para resetear estadísticas
|
||||||
|
const resetEmbed = new EmbedBuilder()
|
||||||
|
.setColor(0x00FF00)
|
||||||
|
.setTitle('✅ Sistema de IA Reiniciado')
|
||||||
|
.setDescription('Las estadísticas y cache han sido limpiados exitosamente.')
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
await message.reply({ embeds: [resetEmbed] });
|
||||||
|
logger.info(`Sistema de IA reiniciado por ${message.author.username} (${message.author.id})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obtener estadísticas del servicio
|
||||||
|
const stats = aiService.getStats();
|
||||||
|
const uptime = process.uptime();
|
||||||
|
const memoryUsage = process.memoryUsage();
|
||||||
|
|
||||||
|
// Crear embed de estadísticas detallado
|
||||||
|
const statsEmbed = new EmbedBuilder()
|
||||||
|
.setColor(0xFF69B4)
|
||||||
|
.setTitle('📊 Estadísticas del Servicio de IA')
|
||||||
|
.setDescription('Estado actual del sistema Gemini-chan')
|
||||||
|
.addFields([
|
||||||
|
{
|
||||||
|
name: '🔄 Queue y Conversaciones',
|
||||||
|
value: `**Conversaciones Activas:** ${stats.activeConversations}\n` +
|
||||||
|
`**Requests en Cola:** ${stats.queueLength}\n` +
|
||||||
|
`**Total Requests:** ${stats.totalRequests}`,
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '⚡ Rendimiento',
|
||||||
|
value: `**Uptime:** ${formatUptime(uptime)}\n` +
|
||||||
|
`**Memoria RAM:** ${formatBytes(memoryUsage.heapUsed)} / ${formatBytes(memoryUsage.heapTotal)}\n` +
|
||||||
|
`**RSS:** ${formatBytes(memoryUsage.rss)}`,
|
||||||
|
inline: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '🛡️ Límites y Configuración',
|
||||||
|
value: `**Rate Limit:** 20 req/min por usuario\n` +
|
||||||
|
`**Cooldown:** 3 segundos\n` +
|
||||||
|
`**Max Tokens:** 1M entrada / 8K salida\n` +
|
||||||
|
`**Max Concurrent:** 3 requests`,
|
||||||
|
inline: false
|
||||||
|
}
|
||||||
|
])
|
||||||
|
.setFooter({
|
||||||
|
text: `Solicitado por ${message.author.username} | Usa 'aistats reset' para reiniciar`,
|
||||||
|
iconURL: message.author.displayAvatarURL({ forceStatic: false })
|
||||||
|
})
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
// Agregar indicador de estado del sistema
|
||||||
|
const queueStatus = stats.queueLength === 0 ? '🟢 Normal' :
|
||||||
|
stats.queueLength < 5 ? '🟡 Ocupado' : '🔴 Saturado';
|
||||||
|
|
||||||
|
statsEmbed.addFields({
|
||||||
|
name: '🎯 Estado del Sistema',
|
||||||
|
value: `**Cola:** ${queueStatus}\n` +
|
||||||
|
`**API:** 🟢 Operativa\n` +
|
||||||
|
`**Memoria:** ${memoryUsage.heapUsed / memoryUsage.heapTotal > 0.8 ? '🔴 Alta' : '🟢 Normal'}`,
|
||||||
|
inline: true
|
||||||
|
});
|
||||||
|
|
||||||
|
await message.reply({ embeds: [statsEmbed] });
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error('Error obteniendo estadísticas de IA:', error);
|
||||||
|
|
||||||
|
const errorEmbed = new EmbedBuilder()
|
||||||
|
.setColor(0xFF4444)
|
||||||
|
.setTitle('❌ Error')
|
||||||
|
.setDescription('No se pudieron obtener las estadísticas del sistema.')
|
||||||
|
.setTimestamp();
|
||||||
|
|
||||||
|
await message.reply({ embeds: [errorEmbed] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,9 +16,15 @@ export async function ensureRemindersSchema() {
|
|||||||
await db.getCollection(databaseId, collectionId);
|
await db.getCollection(databaseId, collectionId);
|
||||||
} catch {
|
} catch {
|
||||||
try {
|
try {
|
||||||
await db.createCollection(databaseId, collectionId, collectionId, [
|
// Sintaxis actualizada para createCollection (versión actual de Appwrite SDK)
|
||||||
// Permisos por defecto: accesible solo por server via API Key
|
await db.createCollection(
|
||||||
]);
|
databaseId,
|
||||||
|
collectionId,
|
||||||
|
collectionId,
|
||||||
|
undefined, // permissions (opcional)
|
||||||
|
undefined, // documentSecurity (opcional)
|
||||||
|
false // enabled (opcional)
|
||||||
|
);
|
||||||
// Nota: No añadimos permisos de lectura pública para evitar fuga de datos
|
// Nota: No añadimos permisos de lectura pública para evitar fuga de datos
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -47,8 +53,9 @@ export async function ensureRemindersSchema() {
|
|||||||
|
|
||||||
// 3) Índice por executeAt para consultas por vencimiento
|
// 3) Índice por executeAt para consultas por vencimiento
|
||||||
try {
|
try {
|
||||||
//@ts-ignore
|
// Sintaxis actualizada para createIndex - 'ASC' ahora debe ser 'asc' (lowercase)
|
||||||
await db.createIndex(databaseId, collectionId, 'idx_executeAt_asc', 'key', ['executeAt'], ['ASC']);
|
// @ts-ignore
|
||||||
|
await db.createIndex(databaseId, collectionId, 'idx_executeAt_asc', 'key', ['executeAt'], ['asc']);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = String(e?.message || e);
|
const msg = String(e?.message || e);
|
||||||
if (!/already exists|index_already_exists/i.test(msg)) {
|
if (!/already exists|index_already_exists/i.test(msg)) {
|
||||||
@@ -57,4 +64,3 @@ export async function ensureRemindersSchema() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
509
src/core/services/AIService.ts
Normal file
509
src/core/services/AIService.ts
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
import { GoogleGenerativeAI, HarmCategory, HarmBlockThreshold } from "@google/generative-ai";
|
||||||
|
import logger from "../lib/logger";
|
||||||
|
import { Collection } from "discord.js";
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 }>();
|
||||||
|
|
||||||
|
// 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
|
||||||
|
};
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Procesa una request de IA de forma asíncrona y controlada
|
||||||
|
*/
|
||||||
|
async processAIRequest(
|
||||||
|
userId: string,
|
||||||
|
prompt: string,
|
||||||
|
guildId?: string,
|
||||||
|
priority: 'low' | 'normal' | 'high' = 'normal'
|
||||||
|
): 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,
|
||||||
|
reject
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// Obtener o crear contexto de conversación
|
||||||
|
const context = this.getOrCreateContext(userId, guildId);
|
||||||
|
|
||||||
|
// Verificar si es request de imagen
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construir prompt del sistema optimizado
|
||||||
|
const systemPrompt = this.buildSystemPrompt(prompt, context, isImageRequest);
|
||||||
|
|
||||||
|
// Usar la API correcta de Google Generative AI
|
||||||
|
const model = this.genAI.getGenerativeModel({
|
||||||
|
model: "gemini-1.5-flash",
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
private buildSystemPrompt(userPrompt: string, context: ConversationContext, isImageRequest: boolean): string {
|
||||||
|
const recentMessages = context.messages
|
||||||
|
.slice(-4) // Solo los últimos 4 mensajes
|
||||||
|
.map(msg => `${msg.role === 'user' ? 'Usuario' : 'Asistente'}: ${msg.content}`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return `Eres una hermana mayor kawaii y cariñosa que habla por Discord. Responde de manera natural, útil y concisa.
|
||||||
|
|
||||||
|
## Reglas Discord:
|
||||||
|
- USA **markdown de Discord**: **negrita**, *cursiva*, \`código\`, \`\`\`bloques\`\`\`
|
||||||
|
- NUNCA uses LaTeX ($$)
|
||||||
|
- Máximo 2-3 emojis por respuesta
|
||||||
|
- 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();
|
||||||
Reference in New Issue
Block a user