AI support

This commit is contained in:
2025-08-29 03:27:00 -03:00
parent 6759e874d7
commit 9eac2be673
23 changed files with 1355 additions and 15 deletions

6
.env
View File

@@ -18,6 +18,12 @@ EMULATOR_ENABLED=true
# Set the hostname
HOSTNAME=myrient.mahou.one
# OpenAI-compatible API settings
AI_ENABLED=true
AI_API_KEY=gsk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
AI_API_URL=https://api.groq.com/openai/v1/chat/completions
AI_MODEL=openai/gpt-oss-120b
# Run docker-compose.dev.yml for running locally
# Database Configuration
POSTGRES_HOST=localhost

View File

@@ -78,6 +78,12 @@ To ensure OpenGraph metadata embed for chat apps works correctly, please be sure
# Metadata
To enable metadata synchronize and matching, you will need to create a developer application in the [Twitch TV Developer Console](https://dev.twitch.tv/console) and then add your client id to `TWITCH_CLIENT_ID` in `.env` or `docker-compose.yml` along with adding your client secret to `TWITCH_CLIENT_SECRET`. Metadata takes about half an hour to synchronize from IGDB to your database, and about another half an hour to match via Postgres Full Text Search. Once all other database maintenance operations are done, the database will attempt to match anything that still isn't matched using a much slower fuzzy trigram search that can take up to a day to complete. These processes won't run again until a new crawl of myrient has been performed and the file count has increased.
# AI Chat Assistant
The search engine includes an optional AI chat assistant that can help users find games and answer questions. The AI assistant appears as a floating button in the bottom-right corner of all pages. You will have to fill API configuration in `.env` or `docker-compose.yml` to enable the AI chat assistant.
The AI chat is OpenAI compatible, so you can use any compatible providers, like ChatGPT, Gemini, Ollama etc. We recommend using [Groq](https://groq.com) with `openai/gpt-oss-120b`, as it has excellent performance, great reasoning and generous limits for free usage.
# Contributing
You know the usual fluff.
Is there a missing category or string association? `lib/categories.json` and any of the files under `lib/json/relatedkeywords` can both updated to include these. If you do update/improve these, please put in a pull request so that it can be added to the public hosted server, as well.

View File

@@ -156,6 +156,25 @@
"back_home": "العودة إلى الصفحة الرئيسية",
"go_back": "رجوع"
},
"ai_chat": {
"title": "مساعد الذكاء الاصطناعي",
"button_tooltip": "مساعد الذكاء الاصطناعي",
"welcome": {
"title": "مساعد الذكاء الاصطناعي",
"description": "يمكنني مساعدتك في العثور على ألعاب الريترو، وتقديم توصيات الألعاب، والإجابة على الأسئلة حول استخدام محرك البحث."
},
"suggestions": {
"game_recommendations": "ما هي الألعاب التي توصي بها لجهاز Nintendo 64؟",
"search_tips": "كيف أبحث عن مناطق معينة؟",
"gaming_history": "أخبرني عن أجهزة الألعاب الريترو"
},
"input_placeholder": "اسألني عن أي شيء...",
"typing_indicator": "الذكاء الاصطناعي يفكر",
"error": {
"network": "تعذر الاتصال بخدمة الذكاء الاصطناعي. يرجى التحقق من اتصال الإنترنت والمحاولة مرة أخرى.",
"generic": "آسف، أواجه مشاكل في الاتصال الآن. يرجى المحاولة مرة أخرى لاحقاً."
}
},
"languages": {
"en": "English",
"es": "Español",

View File

@@ -156,6 +156,25 @@
"back_home": "হোম পেজে ফিরে যান",
"go_back": "পেছনে যান"
},
"ai_chat": {
"title": "এআই সহায়ক",
"button_tooltip": "এআই সহায়ক",
"welcome": {
"title": "এআই সহায়ক",
"description": "আমি আপনাকে রেট্রো গেম খুঁজতে, গেমের সুপারিশ দিতে এবং সার্চ ইঞ্জিন ব্যবহার সম্পর্কে প্রশ্নের উত্তর দিতে সাহায্য করতে পারি।"
},
"suggestions": {
"game_recommendations": "Nintendo 64-এর জন্য আপনি কোন গেমগুলি সুপারিশ করেন?",
"search_tips": "আমি কীভাবে নির্দিষ্ট অঞ্চলের জন্য সার্চ করব?",
"gaming_history": "রেট্রো গেমিং কনসোল সম্পর্কে আমাকে বলুন"
},
"input_placeholder": "আমাকে যেকোনো কিছু জিজ্ঞাসা করুন...",
"typing_indicator": "এআই চিন্তা করছে",
"error": {
"network": "এআই সেবার সাথে সংযোগ করতে পারছি না। অনুগ্রহ করে আপনার ইন্টারনেট সংযোগ পরীক্ষা করুন এবং আবার চেষ্টা করুন।",
"generic": "দুঃখিত, এখন আমার সংযোগে সমস্যা হচ্ছে। অনুগ্রহ করে পরে আবার চেষ্টা করুন।"
}
},
"languages": {
"en": "English",
"es": "Español",
@@ -169,7 +188,7 @@
"tr": "Türkçe",
"it": "Italiano",
"romaji": "Romaji",
"hi": "हि्दी",
"hi": "हि्दी",
"ar": "العربية",
"bn": "বাংলা",
"ru": "Русский"

View File

@@ -156,6 +156,25 @@
"back_home": "Zurück zur Startseite",
"go_back": "Zurück"
},
"ai_chat": {
"title": "KI-Assistent",
"button_tooltip": "KI-Assistent",
"welcome": {
"title": "KI-Assistent",
"description": "Ich kann Ihnen helfen, Retro-Spiele zu finden, Spiel-Empfehlungen zu geben und Fragen zur Nutzung der Suchmaschine zu beantworten."
},
"suggestions": {
"game_recommendations": "Welche Spiele empfehlen Sie für Nintendo 64?",
"search_tips": "Wie suche ich nach bestimmten Regionen?",
"gaming_history": "Erzählen Sie mir von Retro-Spielkonsolen"
},
"input_placeholder": "Fragen Sie mich etwas...",
"typing_indicator": "KI denkt nach",
"error": {
"network": "Verbindung zum KI-Service nicht möglich. Überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.",
"generic": "Entschuldigung, ich habe gerade Verbindungsprobleme. Bitte versuchen Sie es später erneut."
}
},
"languages": {
"en": "English",
"es": "Español",

View File

@@ -156,6 +156,25 @@
"back_home": "Back to Home",
"go_back": "Go Back"
},
"ai_chat": {
"title": "AI Assistant",
"button_tooltip": "AI Assistant",
"welcome": {
"title": "AI Assistant",
"description": "I can help you find retro games, provide gaming recommendations, and answer questions about using the search engine."
},
"suggestions": {
"game_recommendations": "What games do you recommend for Nintendo 64?",
"search_tips": "How do I search for specific regions?",
"gaming_history": "Tell me about retro gaming consoles"
},
"input_placeholder": "Ask me anything...",
"typing_indicator": "AI is thinking",
"error": {
"network": "Unable to connect to the AI service. Please check your internet connection and try again.",
"generic": "Sorry, I'm having trouble connecting right now. Please try again later."
}
},
"languages": {
"en": "English",
"es": "Español",

View File

@@ -156,6 +156,25 @@
"back_home": "Volver al Inicio",
"go_back": "Volver atrás"
},
"ai_chat": {
"title": "Asistente de IA",
"button_tooltip": "Asistente de IA",
"welcome": {
"title": "Asistente de IA",
"description": "Puedo ayudarte a encontrar juegos retro, proporcionar recomendaciones de juegos y responder preguntas sobre el uso del motor de búsqueda."
},
"suggestions": {
"game_recommendations": "¿Qué juegos recomiendas para Nintendo 64?",
"search_tips": "¿Cómo busco regiones específicas?",
"gaming_history": "Cuéntame sobre consolas de videojuegos retro"
},
"input_placeholder": "Pregúntame cualquier cosa...",
"typing_indicator": "La IA está pensando",
"error": {
"network": "No se puede conectar con el servicio de IA. Revisa tu conexión a internet e inténtalo de nuevo.",
"generic": "Lo siento, tengo problemas para conectarme ahora mismo. Inténtalo más tarde."
}
},
"languages": {
"en": "English",
"es": "Español",

View File

@@ -156,6 +156,25 @@
"back_home": "Retour à l'accueil",
"go_back": "Retour"
},
"ai_chat": {
"title": "Assistant IA",
"button_tooltip": "Assistant IA",
"welcome": {
"title": "Assistant IA",
"description": "Je peux vous aider à trouver des jeux rétro, fournir des recommandations de jeux et répondre aux questions sur l'utilisation du moteur de recherche."
},
"suggestions": {
"game_recommendations": "Quels jeux recommandez-vous pour Nintendo 64 ?",
"search_tips": "Comment rechercher des régions spécifiques ?",
"gaming_history": "Parlez-moi des consoles de jeux rétro"
},
"input_placeholder": "Demandez-moi n'importe quoi...",
"typing_indicator": "L'IA réfléchit",
"error": {
"network": "Impossible de se connecter au service IA. Vérifiez votre connexion internet et réessayez.",
"generic": "Désolé, j'ai du mal à me connecter en ce moment. Veuillez réessayer plus tard."
}
},
"languages": {
"en": "English",
"es": "Español",
@@ -169,7 +188,7 @@
"tr": "Türkçe",
"it": "Italiano",
"romaji": "Romaji",
"hi": "हिन्दी",
"hi": "हiन्दी",
"ar": "العربية",
"bn": "বাংলা",
"ru": "Русский"

View File

@@ -156,6 +156,25 @@
"back_home": "होम पर वापस जाएं",
"go_back": "वापस जाएं"
},
"ai_chat": {
"title": "एआई सहायक",
"button_tooltip": "एआई सहायक",
"welcome": {
"title": "एआई सहायक",
"description": "मैं आपको रेट्रो गेम्स खोजने, गेम सुझाव देने और सर्च इंजन के उपयोग के बारे में प्रश्नों के उत्तर देने में मदद कर सकता हूं।"
},
"suggestions": {
"game_recommendations": "Nintendo 64 के लिए आप कौन से गेम्स सुझाते हैं?",
"search_tips": "मैं विशिष्ट क्षेत्रों की खोज कैसे करूं?",
"gaming_history": "रेट्रो गेमिंग कंसोल के बारे में बताइए"
},
"input_placeholder": "मुझसे कुछ भी पूछें...",
"typing_indicator": "एआई सोच रहा है",
"error": {
"network": "एआई सेवा से कनेक्ट नहीं हो सकता। कृपया अपना इंटरनेट कनेक्शन जांचें और फिर कोशिश करें।",
"generic": "खुशी है, मुझे अभी कनेक्शन में समस्या हो रही है। कृपया बाद में कोशिश करें।"
}
},
"languages": {
"en": "English",
"es": "Español",

View File

@@ -156,6 +156,25 @@
"back_home": "Torna alla Home",
"go_back": "Indietro"
},
"ai_chat": {
"title": "Assistente AI",
"button_tooltip": "Assistente AI",
"welcome": {
"title": "Assistente AI",
"description": "Posso aiutarti a trovare giochi retrò, fornire raccomandazioni sui giochi e rispondere a domande sull'utilizzo del motore di ricerca."
},
"suggestions": {
"game_recommendations": "Che giochi consigli per Nintendo 64?",
"search_tips": "Come cerco regioni specifiche?",
"gaming_history": "Dimmi delle console retro"
},
"input_placeholder": "Chiedimi qualsiasi cosa...",
"typing_indicator": "L'AI sta pensando",
"error": {
"network": "Impossibile connettersi al servizio AI. Controlla la tua connessione internet e riprova.",
"generic": "Mi dispiace, sto avendo problemi di connessione ora. Riprova più tardi."
}
},
"languages": {
"en": "English",
"es": "Español",
@@ -169,7 +188,7 @@
"tr": "Türkçe",
"it": "Italiano",
"romaji": "Romaji",
"hi": "हिन्द",
"hi": "हिन्दŀ",
"ar": "العربية",
"bn": "বাংলা",
"ru": "Русский"

View File

@@ -156,6 +156,25 @@
"back_home": "ホームに戻る",
"go_back": "戻る"
},
"ai_chat": {
"title": "AIアシスタント",
"button_tooltip": "AIアシスタント",
"welcome": {
"title": "AIアシスタント",
"description": "レトロゲームの検索、ゲームの推薦、検索エンジンの使用方法に関する質問にお答えします。"
},
"suggestions": {
"game_recommendations": "Nintendo 64におすすめのゲームはありますか",
"search_tips": "特定の地域で検索するにはどうすればいいですか?",
"gaming_history": "レトロゲーム機について教えてください"
},
"input_placeholder": "何でもお聞きください...",
"typing_indicator": "AIが考えています",
"error": {
"network": "AIサービスに接続できません。インターネット接続を確認して、もう一度お試しください。",
"generic": "申し訳ございませんが、現在接続に問題があります。後ほど再度お試しください。"
}
},
"languages": {
"en": "English",
"es": "Español",

View File

@@ -156,6 +156,25 @@
"back_home": "홈으로 돌아가기",
"go_back": "뒤로 가기"
},
"ai_chat": {
"title": "AI 어시스턴트",
"button_tooltip": "AI 어시스턴트",
"welcome": {
"title": "AI 어시스턴트",
"description": "레트로 게임 찾기, 게임 추천 제공, 검색 엔진 사용에 대한 질문 답변을 도와드릴 수 있습니다."
},
"suggestions": {
"game_recommendations": "Nintendo 64에 어떤 게임을 추천하시나요?",
"search_tips": "특정 지역을 어떻게 검색하나요?",
"gaming_history": "레트로 게임 콘솔에 대해 알려주세요"
},
"input_placeholder": "무엇이든 물어보세요...",
"typing_indicator": "AI가 생각 중입니다",
"error": {
"network": "AI 서비스에 연결할 수 없습니다. 인터넷 연결을 확인하고 다시 시도해 주세요.",
"generic": "죄송합니다. 지금 연결에 문제가 있습니다. 나중에 다시 시도해 주세요."
}
},
"languages": {
"en": "English",
"es": "Español",

View File

@@ -156,6 +156,25 @@
"back_home": "Powrót do strony głównej",
"go_back": "Wróć"
},
"ai_chat": {
"title": "Asystent AI",
"button_tooltip": "Asystent AI",
"welcome": {
"title": "Asystent AI",
"description": "Mogę pomóc znaleźć retro gry, udzielić rekomendacji gier i odpowiedzieć na pytania dotyczące używania wyszukiwarki."
},
"suggestions": {
"game_recommendations": "Jakie gry polecasz na Nintendo 64?",
"search_tips": "Jak szukać określonych regionów?",
"gaming_history": "Opowiedz mi o retro konsolach do gier"
},
"input_placeholder": "Zapytaj mnie o cokolwiek...",
"typing_indicator": "AI myśli",
"error": {
"network": "Nie można połączyć się z usługą AI. Sprawdź połączenie internetowe i spróbuj ponownie.",
"generic": "Przepraszam, mam teraz problemy z połączeniem. Spróbuj ponownie później."
}
},
"languages": {
"en": "English",
"es": "Español",
@@ -169,7 +188,7 @@
"tr": "Türkçe",
"it": "Italiano",
"romaji": "Romaji",
"hi": "हिन्द",
"hi": "हिन्दŀ",
"ar": "العربية",
"bn": "বাংলা",
"ru": "Русский"

View File

@@ -68,7 +68,7 @@
"title": "Configurações",
"search_columns": {
"title": "Colunas de Busca",
"tooltip": "Seleciona em quais colunas o buscador vai pesquisar."
"tooltip": "Seleciona em quais colunas o mecanismo de busca vai pesquisar."
},
"score_multiplier": {
"title": "Multiplicador de Relevância",
@@ -78,7 +78,7 @@
"title": "Opções avançadas",
"fuzzy": {
"label": "Busca aproximada",
"tooltip": "Valor entre 0,00 e 1,00 que determina o quão similar uma palavra precisa ser para ser considerada uma correspondência (distância Levenshtein). Valor mais alto permite correspondências menos exatas. Valor 0 desativa esta função."
"tooltip": "Valor entre 0,00 e 1,00 que determina o quão similar uma palavra precisa ser para ser considerada uma correspondência (distância Levenshtein). Valor mais alto permite correspondências menos exatas. Valor 0 desabilita esta função."
},
"prefix": {
"label": "Permitir Prefixos",
@@ -86,7 +86,7 @@
},
"match_all": {
"label": "Corresponder Todas as Palavras",
"tooltip": "Exige que todas as palavras da busca sejam encontradas nos resultados."
"tooltip": "Requer que todas as palavras da busca sejam encontradas nos resultados."
},
"hide_non_game": {
"label": "Ocultar Conteúdo Não-Jogo",
@@ -123,7 +123,7 @@
"see_about": "Veja a página {{link}} para mais informações.",
"no_data": "Não há dados de emulador disponíveis.",
"https": "Conexão insegura: Alguns emuladores precisam de HTTPS para funcionar corretamente. Esta página não está configurada corretamente.",
"not_available_tooltip": "A emulação web não está disponível para este título pois não é um jogo ou a plataforma não é suportada."
"not_available_tooltip": "A emulação web não está disponível para este título porque não é um jogo ou a plataforma não é suportada."
},
"console": {
"about": "Este é um emulador online que executa jogos diretamente do arquivo público do Myrient.",
@@ -156,6 +156,25 @@
"back_home": "Voltar para a Página Inicial",
"go_back": "Voltar"
},
"ai_chat": {
"title": "Assistente IA",
"button_tooltip": "Assistente IA",
"welcome": {
"title": "Assistente IA",
"description": "Posso ajudar a encontrar jogos retrô, fornecer recomendações de jogos e responder perguntas sobre o uso da busca."
},
"suggestions": {
"game_recommendations": "Que jogos recomenda para Nintendo 64?",
"search_tips": "Como pesquisar por regiões específicas?",
"gaming_history": "Conte-me sobre consoles de videogames antigos"
},
"input_placeholder": "Pergunte-me qualquer coisa...",
"typing_indicator": "A IA está pensando",
"error": {
"network": "Não foi possível conectar ao serviço de IA. Tente novamente mais tarde.",
"generic": "Desculpe, estou com problemas de conexão agora. Tente novamente mais tarde."
}
},
"languages": {
"en": "English",
"es": "Español",

View File

@@ -156,6 +156,25 @@
"back_home": "Hōmu ni modoru",
"go_back": "Modoru"
},
"ai_chat": {
"title": "AI Ashisutanto",
"button_tooltip": "AI Ashisutanto",
"welcome": {
"title": "AI Ashisutanto",
"description": "Retoro geemu no kensaku, geemu no suisen, kensaku enjin no shiyouhouhou ni kansuru shitsumon ni otae dekimasu."
},
"suggestions": {
"game_recommendations": "Nintendo 64 ni osusume no geemu wa arimasu ka?",
"search_tips": "Tokutei no chiiki de kensaku suru ni wa dou sureba ii desu ka?",
"gaming_history": "Retoro geemu ki ni tsuite oshiete kudasai"
},
"input_placeholder": "Nandemo okiki kudasai...",
"typing_indicator": "AI ga kangaete imasu",
"error": {
"network": "AI saabisu ni setsuzoku dekimasen. Intaanetto setsuzoku wo kakunin shite, mou ichido otameshi kudasai.",
"generic": "Moushiwake gozaimasen ga, genzai setsuzoku ni mondai ga arimasu. Nochihodo saido otameshi kudasai."
}
},
"languages": {
"en": "English",
"es": "Español",

View File

@@ -155,6 +155,25 @@
"back_home": "Вернуться на главную",
"go_back": "Назад"
},
"ai_chat": {
"title": "ИИ Помощник",
"button_tooltip": "ИИ Помощник",
"welcome": {
"title": "ИИ Помощник",
"description": "Я могу помочь найти ретро-игры, предоставить игровые рекомендации и ответить на вопросы об использовании поисковой системы."
},
"suggestions": {
"game_recommendations": "Какие игры вы рекомендуете для Nintendo 64?",
"search_tips": "Как искать определённые регионы?",
"gaming_history": "Расскажите о ретро игровых консолях"
},
"input_placeholder": "Спросите меня о чём угодно...",
"typing_indicator": "ИИ думает",
"error": {
"network": "Не удаётся подключиться к службе ИИ. Проверьте подключение к интернету и попробуйте снова.",
"generic": "Извините, у меня проблемы с подключением прямо сейчас. Попробуйте позже."
}
},
"languages": {
"en": "English",
"es": "Español",

View File

@@ -156,6 +156,25 @@
"back_home": "Ana Sayfaya Dön",
"go_back": "Geri Dön"
},
"ai_chat": {
"title": "AI Asistanı",
"button_tooltip": "AI Asistanı",
"welcome": {
"title": "AI Asistanı",
"description": "Retro oyunlar bulmanıza, oyun önerileri sunmama ve arama motoru kullanımıyla ilgili sorularınızı yanıtlamama yardımcı olabilirim."
},
"suggestions": {
"game_recommendations": "Nintendo 64 için hangi oyunları önerirsiniz?",
"search_tips": "Belirli bölgeleri nasıl ararım?",
"gaming_history": "Retro oyun konsolları hakkında bilgi verin"
},
"input_placeholder": "Bana her şeyi sorabilirsiniz...",
"typing_indicator": "AI düşünüyor",
"error": {
"network": "AI hizmetine bağlanılamıyor. İnternet bağlantınızı kontrol edin ve tekrar deneyin.",
"generic": "Üzgünüm, şu anda bağlantı sorunum var. Lütfen daha sonra tekrar deneyin."
}
},
"languages": {
"en": "English",
"es": "Español",

View File

@@ -156,6 +156,25 @@
"back_home": "返回首页",
"go_back": "返回"
},
"ai_chat": {
"title": "AI助手",
"button_tooltip": "AI助手",
"welcome": {
"title": "AI助手",
"description": "我可以帮助您找到复古游戏,提供游戏推荐,并回答有关使用搜索引擎的问题。"
},
"suggestions": {
"game_recommendations": "您推荐Nintendo 64有什么游戏",
"search_tips": "如何搜索特定地区?",
"gaming_history": "告诉我关于复古游戏机的信息"
},
"input_placeholder": "问我任何问题...",
"typing_indicator": "AI正在思考",
"error": {
"network": "无法连接到AI服务。请检查您的互联网连接并重试。",
"generic": "抱歉,我现在连接有问题。请稍后再试。"
}
},
"languages": {
"en": "English",
"es": "Español",

104
server.js
View File

@@ -532,6 +532,110 @@ app.get("/api/emulators", function (req, res) {
res.json(emulatorsData);
});
app.post("/api/ai-chat", async function (req, res) {
try {
const { message } = req.body;
if (!message || typeof message !== 'string') {
return res.status(400).json({ error: 'Message is required' });
}
// Check if AI is enabled and configured
const aiEnabled = process.env.AI_ENABLED === 'true';
const apiKey = process.env.AI_API_KEY;
const apiUrl = process.env.AI_API_URL || 'https://api.openai.com/v1/chat/completions';
const model = process.env.AI_MODEL || 'gpt-3.5-turbo';
if (!aiEnabled) {
return res.status(503).json({
error: 'AI chat is currently disabled. Please contact the administrator.'
});
}
if (!apiKey) {
return res.status(503).json({
error: 'AI service is not configured. Please contact the administrator.'
});
}
// Create system prompt with context about Myrient
const systemPrompt = `You are a helpful AI assistant for the Myrient Search Engine, a website that helps users find and search through retro games and ROMs.
About Myrient:
- Myrient is a preservation project that offers a comprehensive collection of retro games
- Users can search for games by filename, category, type, and region
- The site includes an emulator feature for playing games directly in the browser
- The search engine indexes thousands of games from various gaming systems and regions
Your role:
- Help users find games they're looking for
- Provide information about gaming history, consoles, and game recommendations
- Answer questions about how to use the search features
- Be knowledgeable about retro gaming but stay focused on being helpful
- Keep responses concise and friendly
- If users ask about downloading or legal issues, remind them that Myrient focuses on preservation
Current search context: The user is on a retro gaming search website and may be looking for specific games or gaming information.`;
const aiResponse = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'User-Agent': 'Myrient-Search-Engine/1.0'
},
body: JSON.stringify({
model: model,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: message }
],
max_tokens: 500,
temperature: 0.7,
stream: false
})
});
if (!aiResponse.ok) {
const errorData = await aiResponse.json().catch(() => ({}));
console.error('AI API Error:', aiResponse.status, errorData);
// Handle specific error cases
if (aiResponse.status === 401) {
return res.status(503).json({
error: 'AI service authentication failed. Please contact the administrator.'
});
} else if (aiResponse.status === 429) {
return res.status(429).json({
error: 'AI service is currently busy. Please try again in a moment.'
});
} else {
return res.status(503).json({
error: 'AI service is temporarily unavailable. Please try again later.'
});
}
}
const aiData = await aiResponse.json();
if (!aiData.choices || aiData.choices.length === 0) {
return res.status(503).json({
error: 'AI service returned an unexpected response.'
});
}
const response = aiData.choices[0].message.content.trim();
res.json({ response });
} catch (error) {
console.error('AI Chat Error:', error);
res.status(500).json({
error: 'An unexpected error occurred. Please try again later.'
});
}
});
app.get("/proxy-image", async function (req, res, next) {
const imageUrl = req.query.url;

View File

@@ -21,8 +21,57 @@
<%- include('../partials/footer'); %>
</footer>
<!-- AI Chat Button and Modal -->
<button class="ai-chat-button" id="aiChatButton" title="<%= __('ai_chat.button_tooltip') %>">
<i class="fas fa-robot"></i>
</button>
<div class="ai-chat-modal" id="aiChatModal">
<div class="ai-chat-header">
<h5 class="ai-chat-title"><%= __('ai_chat.title') %></h5>
<button class="ai-chat-close" id="aiChatClose">&times;</button>
</div>
<div class="ai-chat-messages" id="aiChatMessages">
<div class="ai-chat-empty-state" id="aiChatEmptyState">
<div class="ai-chat-welcome">
<i class="fas fa-robot ai-chat-welcome-icon"></i>
<h6 class="ai-chat-welcome-title"><%= __('ai_chat.welcome.title') %></h6>
<p class="ai-chat-welcome-text">
<%= __('ai_chat.welcome.description') %>
</p>
<div class="ai-chat-suggestions">
<button class="ai-suggestion-pill" data-suggestion="<%= __('ai_chat.suggestions.game_recommendations') %>">🎮 <%= __('ai_chat.suggestions.game_recommendations') %></button>
<button class="ai-suggestion-pill" data-suggestion="<%= __('ai_chat.suggestions.search_tips') %>">🔍 <%= __('ai_chat.suggestions.search_tips') %></button>
<button class="ai-suggestion-pill" data-suggestion="<%= __('ai_chat.suggestions.gaming_history') %>">🕹️ <%= __('ai_chat.suggestions.gaming_history') %></button>
</div>
</div>
</div>
</div>
<div class="ai-chat-input-container">
<textarea
class="ai-chat-input"
id="aiChatInput"
placeholder="<%= __('ai_chat.input_placeholder') %>"
rows="1"
maxlength="1000"
></textarea>
<button class="ai-chat-send" id="aiChatSend" title="<%= __('search.button') %>">
</button>
</div>
</div>
</body>
<script>
// Pass AI Chat translations to the client
window.aiChatTranslations = {
typing_indicator: '<%= __('ai_chat.typing_indicator') %>',
error: {
network: '<%= __('ai_chat.error.network') %>',
generic: '<%= __('ai_chat.error.generic') %>'
}
};
if(window.location.pathname != '/settings'){ //don't load on the settings page
settingStore = localStorage.getItem('settings')
if(typeof settingStore == 'string' && window.location.href){
@@ -39,7 +88,11 @@
}
}
</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" integrity="sha512-iecdLmaskl7CVkqkXNQ/ZH/XLlvWZOJyj7Yy7tcenmpD1ypASozpmT/E0iPtmFIB46ZmdtAc9eNBvH0H/ZpiBw==" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src='https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.3/umd/popper.min.js'></script>
<script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1/dist/jquery.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-Fy6S3B9q64WdZWQUiU+q4/2Lc9npb8tCaSX9FK7E8HnRr0Jz8D6OP9dO5Vg3Q9ct" crossorigin="anonymous"></script>
<link rel="stylesheet" href="/public/css/ai.css">
<script src="/public/js/ai-chat.js"></script>
</html>

560
views/public/css/ai.css Normal file
View File

@@ -0,0 +1,560 @@
/* AI Chat Styles */
.ai-chat-button {
position: fixed;
bottom: 20px;
right: 20px;
width: 60px;
height: 60px;
background: linear-gradient(135deg, #ffbd33, #f0a400);
border: none;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 4px 12px rgba(255, 189, 51, 0.3);
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
color: #1c2020;
transition: all 0.3s ease;
z-index: 1000;
}
.ai-chat-button:hover {
transform: scale(1.1);
box-shadow: 0 6px 20px rgba(255, 189, 51, 0.5);
}
.ai-chat-modal {
position: fixed;
bottom: 90px;
right: 20px;
width: 350px;
height: 500px;
background: #262c2c;
border: 1px solid rgba(255, 189, 51, 0.3);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
z-index: 999;
display: none;
flex-direction: column;
overflow: hidden;
}
.ai-chat-modal.show {
display: flex;
}
.ai-chat-header {
background: #343a40;
padding: 15px;
border-bottom: 1px solid rgba(255, 189, 51, 0.2);
display: flex;
justify-content: space-between;
align-items: center;
}
.ai-chat-title {
color: #ffbd33;
margin: 0;
font-size: 16px;
font-weight: 600;
flex: 1;
}
.ai-chat-close {
background: none;
border: none;
color: #6c757d;
font-size: 20px;
cursor: pointer;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.3s ease;
}
.ai-chat-close:hover {
color: #ffbd33;
}
.ai-chat-messages {
flex: 1;
overflow-y: auto;
padding: 15px;
scrollbar-width: thin;
scrollbar-color: #ffbd33 #343a40;
}
.ai-chat-messages::-webkit-scrollbar {
width: 6px;
}
.ai-chat-messages::-webkit-scrollbar-track {
background: #343a40;
}
.ai-chat-messages::-webkit-scrollbar-thumb {
background: #ffbd33;
border-radius: 3px;
}
.ai-chat-message {
margin-bottom: 15px;
display: flex;
flex-direction: column;
}
.ai-chat-message.user {
align-items: flex-end;
}
.ai-chat-message.ai {
align-items: flex-start;
}
.ai-chat-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 20px;
text-align: center;
}
.ai-chat-welcome {
max-width: 280px;
}
.ai-chat-welcome-icon {
font-size: 32px;
color: #ffbd33;
margin-bottom: 12px;
opacity: 0.8;
}
.ai-chat-welcome-title {
color: #ffbd33;
font-size: 18px;
font-weight: 600;
margin: 0 0 8px 0;
}
.ai-chat-welcome-text {
color: #b0b8c1;
font-size: 14px;
line-height: 1.4;
margin: 0 0 20px 0;
}
.ai-chat-suggestions {
display: flex;
flex-direction: column;
gap: 8px;
}
.ai-suggestion-pill {
background: rgba(255, 189, 51, 0.1);
border: 1px solid rgba(255, 189, 51, 0.2);
border-radius: 20px;
color: #ffbd33;
cursor: pointer;
font-size: 13px;
padding: 8px 12px;
text-align: left;
transition: all 0.3s ease;
width: 100%;
}
.ai-suggestion-pill:hover {
background: rgba(255, 189, 51, 0.15);
border-color: rgba(255, 189, 51, 0.4);
transform: translateY(-1px);
}
.ai-suggestion-pill:active {
transform: translateY(0);
}
.ai-message-bubble {
max-width: 85%;
padding: 10px 14px;
border-radius: 18px;
word-wrap: break-word;
position: relative;
word-break: break-word;
hyphens: auto;
}
.ai-message-bubble.user {
background: #ffbd33;
color: #1c2020;
border-bottom-right-radius: 4px;
}
.ai-message-bubble.ai {
background: #484f60;
color: #ffffff;
border-bottom-left-radius: 4px;
}
/* Markdown Styling for AI Messages */
.ai-message-bubble h1,
.ai-message-bubble h2,
.ai-message-bubble h3,
.ai-message-bubble h4,
.ai-message-bubble h5,
.ai-message-bubble h6 {
margin: 0.5em 0 0.3em 0;
line-height: 1.3;
font-weight: 600;
}
.ai-message-bubble h1 { font-size: 1.4em; }
.ai-message-bubble h2 { font-size: 1.3em; }
.ai-message-bubble h3 { font-size: 1.2em; }
.ai-message-bubble h4 { font-size: 1.1em; }
.ai-message-bubble h5 { font-size: 1em; }
.ai-message-bubble h6 { font-size: 0.9em; }
.ai-message-bubble p {
margin: 0.5em 0;
line-height: 1.4;
}
.ai-message-bubble p:first-child {
margin-top: 0;
}
.ai-message-bubble p:last-child {
margin-bottom: 0;
}
.ai-message-bubble code {
background: rgba(0, 0, 0, 0.2);
padding: 2px 5px;
border-radius: 3px;
font-size: 0.9em;
font-family: 'Courier New', Courier, monospace;
color: #ffbd33;
}
.ai-message-bubble.user code {
background: rgba(0, 0, 0, 0.15);
color: #1c2020;
}
.ai-message-bubble pre {
background: rgba(0, 0, 0, 0.3);
padding: 10px;
border-radius: 6px;
margin: 0.5em 0;
overflow-x: auto;
border-left: 3px solid #ffbd33;
}
.ai-message-bubble.user pre {
background: rgba(0, 0, 0, 0.2);
border-left-color: #1c2020;
}
.ai-message-bubble pre code {
background: none;
padding: 0;
color: inherit;
font-size: 0.85em;
line-height: 1.4;
}
.ai-message-bubble ul,
.ai-message-bubble ol {
margin: 0.5em 0;
padding-left: 1.2em;
}
.ai-message-bubble li {
margin: 0.2em 0;
line-height: 1.4;
}
.ai-message-bubble a {
color: #ffbd33;
text-decoration: underline;
text-decoration-color: rgba(255, 189, 51, 0.5);
transition: all 0.2s ease;
}
.ai-message-bubble.user a {
color: #1c2020;
text-decoration-color: rgba(28, 32, 32, 0.5);
}
.ai-message-bubble a:hover {
text-decoration-color: currentColor;
opacity: 0.8;
}
.ai-message-bubble blockquote {
margin: 0.5em 0;
padding: 8px 12px;
border-left: 3px solid #ffbd33;
background: rgba(255, 189, 51, 0.1);
font-style: italic;
}
.ai-message-bubble.user blockquote {
border-left-color: #1c2020;
background: rgba(28, 32, 32, 0.1);
}
.ai-message-bubble table {
border-collapse: collapse;
margin: 0.5em 0;
width: 100%;
font-size: 0.9em;
}
.ai-message-bubble th,
.ai-message-bubble td {
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 4px 8px;
text-align: left;
}
.ai-message-bubble.user th,
.ai-message-bubble.user td {
border-color: rgba(28, 32, 32, 0.2);
}
.ai-message-bubble th {
background: rgba(255, 189, 51, 0.2);
font-weight: 600;
}
.ai-message-bubble.user th {
background: rgba(28, 32, 32, 0.2);
}
.ai-message-bubble hr {
border: none;
border-top: 1px solid rgba(255, 255, 255, 0.3);
margin: 1em 0;
}
.ai-message-bubble.user hr {
border-top-color: rgba(28, 32, 32, 0.3);
}
.ai-message-bubble strong {
font-weight: 600;
}
.ai-message-bubble em {
font-style: italic;
}
.ai-message-bubble del {
text-decoration: line-through;
opacity: 0.7;
}
/* Task Lists */
.ai-message-bubble .task-list-item {
list-style: none;
margin-left: -1.2em;
padding-left: 1.2em;
}
.ai-message-bubble .task-list-item input[type="checkbox"] {
margin-right: 0.5em;
pointer-events: none;
}
/* Enhanced code blocks with syntax highlighting placeholder */
.ai-message-bubble pre[class*="language-"] {
position: relative;
}
.ai-message-bubble pre[class*="language-"]::before {
content: attr(class);
position: absolute;
top: 2px;
right: 6px;
font-size: 0.7em;
color: rgba(255, 189, 51, 0.6);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.ai-message-bubble.user pre[class*="language-"]::before {
color: rgba(28, 32, 32, 0.6);
}
/* Better spacing for nested lists */
.ai-message-bubble li ul,
.ai-message-bubble li ol {
margin-top: 0.2em;
margin-bottom: 0.2em;
}
/* Special styling for inline math (if using MathJax later) */
.ai-message-bubble .math-inline {
background: rgba(255, 189, 51, 0.1);
padding: 1px 3px;
border-radius: 2px;
}
.ai-message-bubble.user .math-inline {
background: rgba(28, 32, 32, 0.1);
}
.ai-message-time {
font-size: 11px;
color: #6c757d;
margin-top: 4px;
margin-bottom: 0;
}
.ai-chat-input-container {
padding: 15px;
border-top: 1px solid rgba(255, 189, 51, 0.2);
display: flex;
gap: 10px;
}
.ai-chat-input {
flex: 1;
background: #343a40;
border: 1px solid rgba(255, 189, 51, 0.3);
border-radius: 20px;
padding: 10px 15px;
color: #ffffff;
font-size: 14px;
resize: none;
min-height: 20px;
max-height: 80px;
outline: none;
transition: border-color 0.3s ease;
}
.ai-chat-input:focus {
border-color: #ffbd33;
box-shadow: 0 0 0 0.1rem rgba(255, 189, 51, 0.25);
}
.ai-chat-input::placeholder {
color: #6c757d;
}
.ai-chat-send {
background: #ffbd33;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #1c2020;
font-size: 16px;
transition: all 0.3s ease;
flex-shrink: 0;
}
.ai-chat-send:hover:not(:disabled) {
background: #f0a400;
transform: scale(1.05);
}
.ai-chat-send:disabled {
background: #6c757d;
cursor: not-allowed;
transform: none;
}
.ai-typing-indicator {
display: flex;
align-items: center;
gap: 8px;
color: #6c757d;
font-style: italic;
font-size: 14px;
}
.ai-typing-dots {
display: flex;
gap: 3px;
}
.ai-typing-dot {
width: 6px;
height: 6px;
background: #6c757d;
border-radius: 50%;
animation: typing-pulse 1.4s infinite ease-in-out;
}
.ai-typing-dot:nth-child(1) {
animation-delay: -0.32s;
}
.ai-typing-dot:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes typing-pulse {
0%, 80%, 100% {
opacity: 0.3;
transform: scale(0.8);
}
40% {
opacity: 1;
transform: scale(1);
}
}
/* Mobile responsiveness */
@media (max-width: 480px) {
.ai-chat-modal {
width: calc(100vw - 40px);
height: 60vh;
max-height: 500px;
}
.ai-chat-button {
bottom: 15px;
right: 15px;
width: 50px;
height: 50px;
font-size: 20px;
}
.ai-chat-empty-state {
padding: 15px;
}
.ai-chat-welcome {
max-width: 100%;
}
.ai-chat-welcome-icon {
font-size: 28px;
}
.ai-chat-welcome-title {
font-size: 16px;
}
.ai-chat-welcome-text {
font-size: 13px;
}
.ai-suggestion-pill {
font-size: 12px;
padding: 6px 10px;
}
}

View File

@@ -21,7 +21,6 @@ a:hover {
color: #ffffff;
}
td {
webkit-transition: background 300ms ease-in;
transition-behavior: normal;
transition-duration: 300ms;
transition-timing-function: ease-in;

308
views/public/js/ai-chat.js Normal file
View File

@@ -0,0 +1,308 @@
// AI Chat functionality
class AIChat {
constructor() {
this.modal = document.getElementById('aiChatModal');
this.button = document.getElementById('aiChatButton');
this.closeBtn = document.getElementById('aiChatClose');
this.messagesContainer = document.getElementById('aiChatMessages');
this.input = document.getElementById('aiChatInput');
this.sendBtn = document.getElementById('aiChatSend');
this.emptyState = document.getElementById('aiChatEmptyState');
this.isOpen = false;
this.isTyping = false;
this.hasMessages = false;
// Initialize translations
this.translations = window.aiChatTranslations || {
typing_indicator: 'AI is thinking',
error: {
network: 'Unable to connect to the AI service. Please check your internet connection and try again.',
generic: 'Sorry, I\'m having trouble connecting right now. Please try again later.'
}
};
this.initializeEventListeners();
this.adjustInputHeight();
this.configureMarkdown();
}
configureMarkdown() {
// Configure marked.js options for security and rendering
if (typeof marked !== 'undefined') {
marked.setOptions({
breaks: true, // Convert \n to <br>
gfm: true, // GitHub Flavored Markdown
sanitize: false, // We'll handle sanitization manually if needed
smartypants: true, // Use smart quotes
xhtml: false,
headerIds: false, // Disable header IDs for security
mangle: false // Don't mangle email addresses
});
// Custom renderer for better styling
const renderer = new marked.Renderer();
// Custom code rendering
renderer.code = function(code, language) {
const validLang = language && language.match(/^[a-zA-Z0-9_+-]*$/);
const langClass = validLang ? ` class="language-${language}"` : '';
const escapedCode = code.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
return `<pre><code${langClass}>${escapedCode}</code></pre>`;
};
// Custom link rendering with security
renderer.link = function(href, title, text) {
const escapedHref = href.replace(/"/g, '&quot;');
const titleAttr = title ? ` title="${title.replace(/"/g, '&quot;')}"` : '';
return `<a href="${escapedHref}" target="_blank" rel="noopener noreferrer"${titleAttr}>${text}</a>`;
};
// Custom list item rendering for task lists
renderer.listitem = function(text) {
// Check if this is a task list item
const taskListMatch = text.match(/^<p>\s*\[([ xX])\]\s*(.*)<\/p>$/);
if (taskListMatch) {
const checked = taskListMatch[1] !== ' ' ? 'checked' : '';
const content = taskListMatch[2];
return `<li class="task-list-item"><input type="checkbox" disabled ${checked}> ${content}</li>`;
}
// Regular task list without <p> wrapper
const simpleTaskMatch = text.match(/^\[([ xX])\]\s*(.*)$/);
if (simpleTaskMatch) {
const checked = simpleTaskMatch[1] !== ' ' ? 'checked' : '';
const content = simpleTaskMatch[2];
return `<li class="task-list-item"><input type="checkbox" disabled ${checked}> ${content}</li>`;
}
return `<li>${text}</li>`;
};
marked.use({ renderer });
}
}
initializeEventListeners() {
// Toggle modal
this.button.addEventListener('click', () => this.toggleModal());
this.closeBtn.addEventListener('click', () => this.closeModal());
// Send message
this.sendBtn.addEventListener('click', () => this.sendMessage());
// Handle input events
this.input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
this.sendMessage();
}
});
this.input.addEventListener('input', () => this.adjustInputHeight());
// Handle suggestion pills
this.initializeSuggestionPills();
// Close modal when clicking outside
document.addEventListener('click', (e) => {
if (!this.modal.contains(e.target) && !this.button.contains(e.target)) {
if (this.isOpen) {
this.closeModal();
}
}
});
}
toggleModal() {
if (this.isOpen) {
this.closeModal();
} else {
this.openModal();
}
}
openModal() {
this.modal.classList.add('show');
this.isOpen = true;
// Focus input when modal opens
setTimeout(() => this.input.focus(), 100);
}
closeModal() {
this.modal.classList.remove('show');
this.isOpen = false;
}
adjustInputHeight() {
this.input.style.height = 'auto';
this.input.style.height = Math.min(this.input.scrollHeight, 80) + 'px';
}
initializeSuggestionPills() {
const suggestionPills = document.querySelectorAll('.ai-suggestion-pill');
suggestionPills.forEach(pill => {
pill.addEventListener('click', () => {
const suggestion = pill.getAttribute('data-suggestion');
if (suggestion) {
this.input.value = suggestion;
this.adjustInputHeight();
this.sendMessage();
}
});
});
}
hideEmptyState() {
if (this.emptyState) {
this.emptyState.style.display = 'none';
this.hasMessages = true;
}
}
async sendMessage() {
const message = this.input.value.trim();
if (!message || this.isTyping) return;
// Hide empty state on first message
if (!this.hasMessages) {
this.hideEmptyState();
}
// Add user message
this.addMessage(message, 'user');
this.input.value = '';
this.adjustInputHeight();
// Disable send button and show typing indicator
this.setSending(true);
this.showTypingIndicator();
try {
// Send to backend
const response = await fetch('/api/ai-chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ message })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
}
const data = await response.json();
// Remove typing indicator and add AI response
this.hideTypingIndicator();
this.addMessage(data.response, 'ai');
} catch (error) {
console.error('AI Chat Error:', error);
this.hideTypingIndicator();
let errorMessage = error.message;
// Handle different types of errors
if (error.message.includes('Failed to fetch')) {
errorMessage = this.translations.error.network;
} else if (!error.message.includes('HTTP error')) {
// Use backend error message if available, otherwise use generic message
errorMessage = error.message || this.translations.error.generic;
}
this.addMessage(errorMessage, 'ai');
} finally {
this.setSending(false);
}
}
addMessage(content, sender) {
const messageDiv = document.createElement('div');
messageDiv.className = `ai-chat-message ${sender}`;
const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
messageDiv.innerHTML = `
<div class="ai-message-bubble ${sender}">${this.formatMessage(content)}</div>
<small class="ai-message-time">${time}</small>
`;
this.messagesContainer.appendChild(messageDiv);
}
formatMessage(message) {
// Use marked.js for full markdown parsing if available
if (typeof marked !== 'undefined') {
try {
return marked.parse(message);
} catch (error) {
console.warn('Markdown parsing error:', error);
// Fallback to basic formatting
return this.formatMessageBasic(message);
}
} else {
// Fallback to basic formatting if marked.js not available
return this.formatMessageBasic(message);
}
}
formatMessageBasic(message) {
// Basic formatting fallback
return message
.replace(/\n/g, '<br>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/`(.*?)`/g, '<code>$1</code>')
.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
}
showTypingIndicator() {
if (this.isTyping) return;
this.isTyping = true;
const typingDiv = document.createElement('div');
typingDiv.className = 'ai-chat-message ai';
typingDiv.id = 'typingIndicator';
typingDiv.innerHTML = `
<div class="ai-typing-indicator">
<span>${this.translations.typing_indicator}</span>
<div class="ai-typing-dots">
<div class="ai-typing-dot"></div>
<div class="ai-typing-dot"></div>
<div class="ai-typing-dot"></div>
</div>
</div>
`;
this.messagesContainer.appendChild(typingDiv);
}
hideTypingIndicator() {
this.isTyping = false;
const typingIndicator = document.getElementById('typingIndicator');
if (typingIndicator) {
typingIndicator.remove();
}
}
setSending(sending) {
this.sendBtn.disabled = sending;
this.input.disabled = sending;
}
scrollToBottom() {
setTimeout(() => {
this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight;
}, 100);
}
}
// Initialize AI Chat when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
// Only initialize if AI chat elements exist
if (document.getElementById('aiChatModal')) {
new AIChat();
}
});