feat: streamline image model detection and enhance image attachment processing in AI service

This commit is contained in:
2025-10-04 04:24:42 -05:00
parent 96ad2d8f48
commit ed7165fad9

View File

@@ -122,44 +122,31 @@ export class AIService {
} }
this.genAI = new GoogleGenerativeAI(apiKey); this.genAI = new GoogleGenerativeAI(apiKey);
try { try {
this.genAIv2 = new GoogleGenAI({ apiKey }); this.genAIv2 = new GoogleGenAI({ apiKey });
} catch { logger.info('GoogleGenAI v2 inicializado correctamente para generación de imágenes');
} catch (e) {
logger.warn(`GoogleGenAI v2 no pudo inicializarse: ${getErrorMessage(e)}`);
this.genAIv2 = null; this.genAIv2 = null;
} }
this.startCleanupService();
this.startQueueProcessor(); this.startQueueProcessor();
// Detectar modelo de imágenes en background (no bloqueante) this.startCleanupService();
this.detectImageModel().catch(err => { this.detectImageModel();
logger.warn({ err }, 'No se pudo detectar automáticamente un modelo de imágenes');
});
} }
// Detecta un modelo de imágenes disponible y lo cachea /**
* Auto-detectar modelo de imagen disponible
*/
private async detectImageModel(): Promise<string | null> { private async detectImageModel(): Promise<string | null> {
if (!this.genAIv2) { if (!this.genAIv2) {
this.imageModelName = null; logger.warn('GoogleGenAI v2 no disponible; sin soporte para imágenes');
return null; return null;
} }
// Permitir override por variable de entorno // Lista de candidatos de modelos de imagen ordenados por preferencia
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 más amplia y realista para cuentas Pro actuales
const candidates = [ const candidates = [
'gemini-2.5-flash-exp',
'gemini-2.0-flash-exp',
'imagen-3.0-generate-001',
'imagen-3.0-fast-generate-001',
'imagen-3.0-001',
'imagegeneration@002',
'imagegeneration@001',
'imagen-3.0-generate',
'imagen-3.0-fast', 'imagen-3.0-fast',
'imagen-3.0', 'imagen-3.0',
'gemini-2.5-flash-image', 'gemini-2.5-flash-image',
@@ -393,91 +380,6 @@ export class AIService {
}, this.config.cleanupInterval); }, 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 * Parser mejorado de errores de API - Type-safe sin ts-ignore
*/ */
@@ -636,218 +538,83 @@ export class AIService {
} }
/** /**
* Parser de errores (versión legacy no utilizada) * Detectar si hay imágenes adjuntas en el mensaje para análisis
*/ */
private parseAPIErrorLegacy(error: unknown): string { public hasImageAttachments(attachments?: any[]): boolean {
// Delegar a la versión nueva if (!attachments || attachments.length === 0) return false;
return this.parseAPIError(error);
}
/** const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'];
* Versión legacy de processAIRequestWithMemory (sin uso externo) const imageMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'];
*/
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) { return attachments.some(attachment => {
throw new Error('El prompt excede el límite de 4000 caracteres'); const hasImageExtension = imageExtensions.some(ext =>
} attachment.name?.toLowerCase().endsWith(ext)
);
const hasImageMimeType = imageMimeTypes.includes(attachment.contentType?.toLowerCase());
// Rate limiting por usuario return hasImageExtension || hasImageMimeType;
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 * Procesar imágenes adjuntas para análisis con Gemini Vision
*/ */
private parseAPIError(error: unknown): string { private async processImageAttachments(attachments: any[]): Promise<any[]> {
// Extraer mensaje de forma type-safe const imageAttachments = [];
const message = getErrorMessage(error).toLowerCase();
// Verificar si es un error de API estructurado for (const attachment of attachments) {
if (isAPIError(error)) { if (this.hasImageAttachments([attachment])) {
const apiMessage = error.message.toLowerCase(); try {
// Descargar la imagen
const response = await fetch(attachment.url);
if (!response.ok) {
logger.warn(`Error descargando imagen: ${response.statusText}`);
continue;
}
if (apiMessage.includes('api key') || apiMessage.includes('authentication')) { const arrayBuffer = await response.arrayBuffer();
return 'Error de autenticación con la API de IA'; const base64Data = Buffer.from(arrayBuffer).toString('base64');
}
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 // Determinar el tipo MIME
if (message.includes('api key') || message.includes('authentication')) { let mimeType = attachment.contentType || 'image/png';
return 'Error de autenticación con la API de IA'; if (!mimeType.startsWith('image/')) {
} // Inferir del nombre del archivo
if (message.includes('quota') || message.includes('exceeded')) { const ext = attachment.name?.toLowerCase().split('.').pop();
return 'Se ha alcanzado el límite de uso de la API. Intenta más tarde'; switch (ext) {
} case 'jpg':
if (message.includes('safety') || message.includes('blocked')) { case 'jpeg':
return 'Tu mensaje fue bloqueado por las políticas de seguridad'; mimeType = 'image/jpeg';
} break;
if (message.includes('timeout') || message.includes('deadline')) { case 'png':
return 'La solicitud tardó demasiado tiempo. Intenta de nuevo'; mimeType = 'image/png';
} break;
if (message.includes('model not found')) { case 'gif':
return 'El modelo de IA no está disponible en este momento'; mimeType = 'image/gif';
} break;
if (message.includes('token') || message.includes('length')) { case 'webp':
return 'El mensaje excede los límites permitidos'; mimeType = 'image/webp';
} break;
default:
mimeType = 'image/png';
}
}
return 'Error temporal del servicio de IA. Intenta de nuevo'; imageAttachments.push({
} inlineData: {
data: base64Data,
mimeType: mimeType
}
});
/** logger.info(`Imagen procesada: ${attachment.name} (${mimeType})`);
* Procesa una request de IA con soporte para conversaciones y memoria persistente
*/
async processAIRequestWithMemory(
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) { } catch (error) {
throw new Error('El prompt excede el límite de 4000 caracteres'); logger.error(`Error procesando imagen ${attachment.name}: ${getErrorMessage(error)}`);
}
// 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()); return imageAttachments;
});
} }
/** /**
@@ -1078,309 +845,31 @@ Responde de forma directa y útil:`;
} }
/** /**
* Detectar si hay imágenes adjuntas en el mensaje para análisis (método público) * Obtener o crear contexto de conversación con carga desde Appwrite
*/ */
public hasImageAttachments(attachments?: any[]): boolean { private async getOrCreateContextWithMemory(userId: string, guildId?: string, channelId?: string): Promise<ConversationContext> {
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'];
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;
});
}
/**
* Procesar imágenes adjuntas para análisis con Gemini Vision
*/
private async processImageAttachments(attachments: any[]): Promise<any[]> {
const imageAttachments = [];
for (const attachment of attachments) {
if (this.hasImageAttachments([attachment])) {
try {
// Descargar la imagen
const response = await fetch(attachment.url);
if (!response.ok) {
logger.warn(`Error descargando imagen: ${response.statusText}`);
continue;
}
const arrayBuffer = await response.arrayBuffer();
const base64Data = Buffer.from(arrayBuffer).toString('base64');
// Determinar el tipo MIME
let mimeType = attachment.contentType || 'image/png';
if (!mimeType.startsWith('image/')) {
// Inferir del nombre del archivo
const ext = attachment.name?.toLowerCase().split('.').pop();
switch (ext) {
case 'jpg':
case 'jpeg':
mimeType = 'image/jpeg';
break;
case 'png':
mimeType = 'image/png';
break;
case 'gif':
mimeType = 'image/gif';
break;
case 'webp':
mimeType = 'image/webp';
break;
default:
mimeType = 'image/png';
}
}
imageAttachments.push({
inlineData: {
data: base64Data,
mimeType: mimeType
}
});
logger.debug(`Imagen procesada: ${attachment.name} (${mimeType})`);
} catch (error) {
logger.warn(`Error procesando imagen ${attachment.name}: ${getErrorMessage(error)}`);
}
}
}
return imageAttachments;
}
/**
* Procesa una request de IA con soporte para imágenes adjuntas
*/
async processAIRequestWithAttachments(
userId: string,
prompt: string,
attachments: any[],
guildId?: string,
channelId?: string,
messageId?: string,
referencedMessageId?: string,
client?: any,
priority: 'low' | 'normal' | 'high' = 'normal',
options?: { aiRolePrompt?: string; meta?: string }
): 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`);
}
// Procesar imágenes adjuntas
let imageAnalysisResults = [];
if (attachments && attachments.length > 0) {
imageAnalysisResults = await this.processImageAttachments(attachments);
}
// Agregar a la queue con Promise
return new Promise((resolve, reject) => {
const request: AIRequest = {
userId,
guildId,
channelId,
prompt: prompt.trim(),
priority,
timestamp: Date.now(),
resolve,
reject,
aiRolePrompt: options?.aiRolePrompt,
meta: options?.meta,
messageId,
referencedMessageId,
};
// 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());
});
}
/**
* Procesar request de IA con imágenes adjuntas
*/
private async processRequestWithAttachments(request: AIRequest, imageAttachments: any[]): Promise<void> {
try {
const { userId, prompt, guildId, channelId, messageId, referencedMessageId } = request;
const context = await this.getOrCreateContextWithMemory(userId, guildId, channelId);
const isImageRequest = this.detectImageRequest(prompt);
// Si el prompt es una solicitud de imagen, pero ya se alcanzó el límite, reiniciar conversación
if (isImageRequest && context.imageRequests >= this.config.maxImageRequests) {
this.resetConversation(userId, guildId);
logger.info(`Conversación reseteada para usuario ${userId} por límite de solicitudes de imagen`);
}
// Verificar límites de tokens
const estimatedTokens = this.estimateTokens(prompt);
if (context.totalTokens + estimatedTokens > this.config.maxInputTokens * this.config.tokenResetThreshold) {
this.resetConversation(userId, guildId);
logger.info(`Conversación reseteada para usuario ${userId} por límite de tokens`);
}
// Obtener prompt del sistema (desde opciones o DB)
let effectiveAiRolePrompt = request.aiRolePrompt;
if (effectiveAiRolePrompt === undefined && guildId) {
effectiveAiRolePrompt = (await this.getGuildAiPrompt(guildId)) ?? undefined;
}
// Obtener jerarquía de roles si está en un servidor
let roleHierarchy = '';
if (guildId) {
// Necesitamos acceso al cliente de Discord - lo pasaremos desde el comando
const client = (request as any).client;
if (client) {
roleHierarchy = await this.getGuildRoleHierarchy(guildId, client);
}
}
// Construir metadatos mejorados
const enhancedMeta = (request.meta || '') + roleHierarchy;
// Construir prompt del sistema optimizado
const systemPrompt = this.buildSystemPrompt(
prompt,
context,
isImageRequest,
effectiveAiRolePrompt,
enhancedMeta
);
// Usar la API correcta de Google Generative AI
const model = this.genAI.getGenerativeModel({
model: "gemini-2.5-flash-preview-09-2025",
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 con memoria persistente
await this.updateContextWithMemory(
context,
prompt,
aiResponse,
estimatedTokens,
isImageRequest,
messageId,
referencedMessageId
);
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 con imágenes 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));
}
}
/**
* Obtener o crear contexto de conversación (método legacy)
*/
private getOrCreateContext(userId: string, guildId?: string): ConversationContext {
const key = `${userId}-${guildId || 'dm'}`; const key = `${userId}-${guildId || 'dm'}`;
let context = this.conversations.get(key); let context = this.conversations.get(key);
if (!context) { if (!context) {
context = { // Intentar cargar desde Appwrite
messages: [], const loadedContext = await this.loadConversationFromAppwrite(userId, guildId, channelId);
totalTokens: 0,
imageRequests: 0, if (loadedContext) {
lastActivity: Date.now(), context = loadedContext;
userId, } else {
guildId // Crear nuevo contexto si no existe en Appwrite
}; context = {
messages: [],
totalTokens: 0,
imageRequests: 0,
lastActivity: Date.now(),
userId,
guildId,
channelId
};
}
this.conversations.set(key, context); this.conversations.set(key, context);
} }
@@ -1388,40 +877,6 @@ Responde de forma directa y útil:`;
return context; return context;
} }
/**
* Actualizar contexto de forma eficiente (método legacy)
*/
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++;
}
}
/** /**
* Actualizar contexto de forma eficiente con guardado en Appwrite * Actualizar contexto de forma eficiente con guardado en Appwrite
*/ */
@@ -1475,38 +930,6 @@ Responde de forma directa y útil:`;
}); });
} }
/**
* Obtener o crear contexto de conversación con carga desde Appwrite
*/
private async getOrCreateContextWithMemory(userId: string, guildId?: string, channelId?: string): Promise<ConversationContext> {
const key = `${userId}-${guildId || 'dm'}`;
let context = this.conversations.get(key);
if (!context) {
// Intentar cargar desde Appwrite
const loadedContext = await this.loadConversationFromAppwrite(userId, guildId, channelId);
if (loadedContext) {
context = loadedContext;
} else {
// Crear nuevo contexto si no existe en Appwrite
context = {
messages: [],
totalTokens: 0,
imageRequests: 0,
lastActivity: Date.now(),
userId,
guildId,
channelId
};
}
this.conversations.set(key, context);
}
context.lastActivity = Date.now();
return context;
}
/** /**
* Limpiar cache pero mantener memoria persistente * Limpiar cache pero mantener memoria persistente