From 9eac2be673d9d4b518fd3651f85f26d302605edf Mon Sep 17 00:00:00 2001 From: ovosimpatico Date: Fri, 29 Aug 2025 03:27:00 -0300 Subject: [PATCH 1/5] AI support --- .env | 6 + README.md | 10 +- config/locales/ar.json | 19 ++ config/locales/bn.json | 21 +- config/locales/de.json | 21 +- config/locales/en.json | 21 +- config/locales/es.json | 19 ++ config/locales/fr.json | 21 +- config/locales/hi.json | 19 ++ config/locales/it.json | 21 +- config/locales/ja.json | 19 ++ config/locales/ko.json | 19 ++ config/locales/pl.json | 21 +- config/locales/pt.json | 27 +- config/locales/romaji.json | 19 ++ config/locales/ru.json | 19 ++ config/locales/tr.json | 19 ++ config/locales/zh.json | 19 ++ server.js | 104 +++++++ views/pages/index.ejs | 57 +++- views/public/css/ai.css | 560 +++++++++++++++++++++++++++++++++++++ views/public/css/style.css | 1 - views/public/js/ai-chat.js | 308 ++++++++++++++++++++ 23 files changed, 1355 insertions(+), 15 deletions(-) create mode 100644 views/public/css/ai.css create mode 100644 views/public/js/ai-chat.js diff --git a/.env b/.env index 7bf3a19..170a182 100644 --- a/.env +++ b/.env @@ -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 diff --git a/README.md b/README.md index 3c076c8..55a26a8 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,13 @@ 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. +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. Pull requests are always welcome. Make sure to make any changes clear in your pull request, and if possible, run any files you've modified through prettier. \ No newline at end of file diff --git a/config/locales/ar.json b/config/locales/ar.json index e6e1c5a..2bd2ef0 100644 --- a/config/locales/ar.json +++ b/config/locales/ar.json @@ -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", diff --git a/config/locales/bn.json b/config/locales/bn.json index ba9b3d4..fa57eb8 100644 --- a/config/locales/bn.json +++ b/config/locales/bn.json @@ -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": "Русский" diff --git a/config/locales/de.json b/config/locales/de.json index ae5ebaf..4a6c38e 100644 --- a/config/locales/de.json +++ b/config/locales/de.json @@ -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", @@ -174,4 +193,4 @@ "bn": "বাংলা", "ru": "Русский" } -} +} \ No newline at end of file diff --git a/config/locales/en.json b/config/locales/en.json index a0f3b8e..1ba7588 100644 --- a/config/locales/en.json +++ b/config/locales/en.json @@ -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", @@ -174,4 +193,4 @@ "bn": "বাংলা", "ru": "Русский" } -} +} \ No newline at end of file diff --git a/config/locales/es.json b/config/locales/es.json index 7c69eef..56157a8 100644 --- a/config/locales/es.json +++ b/config/locales/es.json @@ -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", diff --git a/config/locales/fr.json b/config/locales/fr.json index e33de32..67429e2 100644 --- a/config/locales/fr.json +++ b/config/locales/fr.json @@ -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": "Русский" diff --git a/config/locales/hi.json b/config/locales/hi.json index d6b4b03..d9da728 100644 --- a/config/locales/hi.json +++ b/config/locales/hi.json @@ -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", diff --git a/config/locales/it.json b/config/locales/it.json index be1c958..0c91363 100644 --- a/config/locales/it.json +++ b/config/locales/it.json @@ -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": "Русский" diff --git a/config/locales/ja.json b/config/locales/ja.json index efd701d..a72e61a 100644 --- a/config/locales/ja.json +++ b/config/locales/ja.json @@ -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", diff --git a/config/locales/ko.json b/config/locales/ko.json index b3fa53d..c92226f 100644 --- a/config/locales/ko.json +++ b/config/locales/ko.json @@ -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", diff --git a/config/locales/pl.json b/config/locales/pl.json index 9ef8d2e..fa179c0 100644 --- a/config/locales/pl.json +++ b/config/locales/pl.json @@ -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": "Русский" diff --git a/config/locales/pt.json b/config/locales/pt.json index c13d25e..2faf75e 100644 --- a/config/locales/pt.json +++ b/config/locales/pt.json @@ -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", diff --git a/config/locales/romaji.json b/config/locales/romaji.json index 1c412a1..646e1ee 100644 --- a/config/locales/romaji.json +++ b/config/locales/romaji.json @@ -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", diff --git a/config/locales/ru.json b/config/locales/ru.json index 5b9019c..7040264 100644 --- a/config/locales/ru.json +++ b/config/locales/ru.json @@ -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", diff --git a/config/locales/tr.json b/config/locales/tr.json index eb65f27..b409e84 100644 --- a/config/locales/tr.json +++ b/config/locales/tr.json @@ -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", diff --git a/config/locales/zh.json b/config/locales/zh.json index a7eac2b..b595c14 100644 --- a/config/locales/zh.json +++ b/config/locales/zh.json @@ -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", diff --git a/server.js b/server.js index eef03bd..8237ff3 100644 --- a/server.js +++ b/server.js @@ -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; diff --git a/views/pages/index.ejs b/views/pages/index.ejs index a42c5fc..6d980a4 100644 --- a/views/pages/index.ejs +++ b/views/pages/index.ejs @@ -1,4 +1,4 @@ -<% +<% let ogPages = { "results": "opengraphresults", "info": "opengraphinfo" @@ -21,8 +21,57 @@ <%- include('../partials/footer'); %> + + + +
+
+
<%= __('ai_chat.title') %>
+ +
+
+
+
+ +
<%= __('ai_chat.welcome.title') %>
+

+ <%= __('ai_chat.welcome.description') %> +

+
+ + + +
+
+
+
+
+ + +
+
+ + + + + \ No newline at end of file diff --git a/views/public/css/ai.css b/views/public/css/ai.css new file mode 100644 index 0000000..bfb7d14 --- /dev/null +++ b/views/public/css/ai.css @@ -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; + } +} diff --git a/views/public/css/style.css b/views/public/css/style.css index e9865f3..7bf9db9 100644 --- a/views/public/css/style.css +++ b/views/public/css/style.css @@ -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; diff --git a/views/public/js/ai-chat.js b/views/public/js/ai-chat.js new file mode 100644 index 0000000..ef6fc94 --- /dev/null +++ b/views/public/js/ai-chat.js @@ -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
+ 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, '>').replace(/"/g, '"').replace(/'/g, '''); + return `
${escapedCode}
`; + }; + + // Custom link rendering with security + renderer.link = function(href, title, text) { + const escapedHref = href.replace(/"/g, '"'); + const titleAttr = title ? ` title="${title.replace(/"/g, '"')}"` : ''; + return `${text}`; + }; + + // Custom list item rendering for task lists + renderer.listitem = function(text) { + // Check if this is a task list item + const taskListMatch = text.match(/^

\s*\[([ xX])\]\s*(.*)<\/p>$/); + if (taskListMatch) { + const checked = taskListMatch[1] !== ' ' ? 'checked' : ''; + const content = taskListMatch[2]; + return `

  • ${content}
  • `; + } + // Regular task list without

    wrapper + const simpleTaskMatch = text.match(/^\[([ xX])\]\s*(.*)$/); + if (simpleTaskMatch) { + const checked = simpleTaskMatch[1] !== ' ' ? 'checked' : ''; + const content = simpleTaskMatch[2]; + return `

  • ${content}
  • `; + } + return `
  • ${text}
  • `; + }; + + 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 = ` +
    ${this.formatMessage(content)}
    + ${time} + `; + + 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, '
    ') + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/\*(.*?)\*/g, '$1') + .replace(/`(.*?)`/g, '$1') + .replace(/\[([^\]]+)\]\(([^\)]+)\)/g, '$1'); + } + + showTypingIndicator() { + if (this.isTyping) return; + + this.isTyping = true; + const typingDiv = document.createElement('div'); + typingDiv.className = 'ai-chat-message ai'; + typingDiv.id = 'typingIndicator'; + + typingDiv.innerHTML = ` +
    + ${this.translations.typing_indicator} +
    +
    +
    +
    +
    +
    + `; + + 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(); + } +}); From 7113d72a7c3d7756f7cf0ca70f5d3627a18ae2d4 Mon Sep 17 00:00:00 2001 From: ovosimpatico Date: Fri, 29 Aug 2025 04:06:40 -0300 Subject: [PATCH 2/5] Add about page the the AI assistant and conditional loading --- config/locales/ar.json | 8 +++ config/locales/bn.json | 8 +++ config/locales/de.json | 8 +++ config/locales/en.json | 8 +++ config/locales/es.json | 8 +++ config/locales/fr.json | 8 +++ config/locales/hi.json | 8 +++ config/locales/it.json | 8 +++ config/locales/ja.json | 8 +++ config/locales/ko.json | 8 +++ config/locales/pl.json | 8 +++ config/locales/pt.json | 8 +++ config/locales/romaji.json | 8 +++ config/locales/ru.json | 8 +++ config/locales/tr.json | 8 +++ config/locales/zh.json | 8 +++ server.js | 5 ++ views/pages/about.ejs | 99 ++++++++++++++++++++++++++++++-------- views/pages/index.ejs | 10 ++++ 19 files changed, 222 insertions(+), 20 deletions(-) diff --git a/config/locales/ar.json b/config/locales/ar.json index 2bd2ef0..2174ac2 100644 --- a/config/locales/ar.json +++ b/config/locales/ar.json @@ -55,6 +55,14 @@ "disabled": "تم تعطيل وظيفة المحاكي على الويب من قبل المسؤول.", "contact": "تواصل مع المسؤول أو قم بإنشاء نسختك الخاصة من Myrient Search." }, + "ai": { + "title": "مساعد الذكاء الاصطناعي", + "description": "يحتوي هذا الموقع على مساعد مدعوم بالذكاء الاصطناعي يمكنه مساعدتك في العثور على الألعاب وتقديم التوصيات والإجابة على الأسئلة حول الألعاب التقليدية.", + "provider_info": "مدعوم بواسطة {{provider}} باستخدام نموذج {{model}}.", + "privacy_note": "المساعد الذكي مدعوم بخدمة خارجية. يرجى الرجوع إلى سياسة الخصوصية للخدمة للحصول على مزيد من المعلومات.", + "disabled": "تم تعطيل وظيفة المساعد الذكي من قبل المسؤول.", + "contact": "تواصل مع المسؤول أو قم بإنشاء نسختك الخاصة من Myrient Search." + }, "credits": { "created_by": "تم إنشاء محرك البحث بواسطة", "view_github": "عرض المشروع على GitHub" diff --git a/config/locales/bn.json b/config/locales/bn.json index fa57eb8..cb3b48a 100644 --- a/config/locales/bn.json +++ b/config/locales/bn.json @@ -55,6 +55,14 @@ "disabled": "ওয়েব এমুলেটর কার্যকারিতা অ্যাডমিনিস্ট্রেটর দ্বারা নিষ্ক্রিয় করা হয়েছে।", "contact": "অ্যাডমিনিস্ট্রেটরের সাথে যোগাযোগ করুন অথবা আপনার নিজের Myrient Search ইনস্ট্যান্স সেট আপ করুন।" }, + "ai": { + "title": "AI সহায়ক", + "description": "এই ওয়েবসাইটে একটি AI-চালিত সহায়ক রয়েছে যা আপনাকে গেম খুঁজতে, সুপারিশ প্রদান করতে এবং রেট্রো গেমিং সম্পর্কে প্রশ্নের উত্তর দিতে সাহায্য করতে পারে।", + "provider_info": "{{provider}} দ্বারা চালিত {{model}} মডেল ব্যবহার করে।", + "privacy_note": "AI সহায়ক একটি বাহ্যিক সেবা দ্বারা চালিত। আরো তথ্যের জন্য সেবার গোপনীয়তা নীতি দেখুন।", + "disabled": "AI সহায়ক কার্যকারিতা প্রশাসক দ্বারা নিষ্ক্রিয় করা হয়েছে।", + "contact": "প্রশাসকের সাথে যোগাযোগ করুন অথবা Myrient Search এর আপনার নিজস্ব ইনস্ট্যান্স চালু করুন।" + }, "credits": { "created_by": "সার্চ ইঞ্জিন তৈরি করেছেন", "view_github": "GitHub-এ প্রকল্প দেখুন" diff --git a/config/locales/de.json b/config/locales/de.json index 4a6c38e..067e024 100644 --- a/config/locales/de.json +++ b/config/locales/de.json @@ -55,6 +55,14 @@ "disabled": "Die Webemulator-Funktion wurde vom Administrator deaktiviert.", "contact": "Kontaktieren Sie den Administrator oder starten Sie Ihre eigene Instanz von Myrient Search." }, + "ai": { + "title": "KI-Assistent", + "description": "Diese Website verfügt über einen KI-gestützten Assistenten, der Ihnen beim Finden von Spielen, bei Empfehlungen und bei der Beantwortung von Fragen zum Retro-Gaming helfen kann.", + "provider_info": "Bereitgestellt von {{provider}} mit dem {{model}}-Modell.", + "privacy_note": "Der KI-Assistent wird von einem externen Dienst betrieben. Weitere Informationen finden Sie in der Datenschutzrichtlinie des Dienstes.", + "disabled": "Die KI-Assistenten-Funktionalität wurde vom Administrator deaktiviert.", + "contact": "Kontaktieren Sie den Administrator oder starten Sie Ihre eigene Instanz von Myrient Search." + }, "credits": { "created_by": "Suchmaschine entwickelt von", "view_github": "Projekt auf GitHub ansehen" diff --git a/config/locales/en.json b/config/locales/en.json index 1ba7588..16a9887 100644 --- a/config/locales/en.json +++ b/config/locales/en.json @@ -55,6 +55,14 @@ "disabled": "Web Emulator functionality was disabled by the administrator.", "contact": "Contact the administrator or spin up your own instance of Myrient Search." }, + "ai": { + "title": "AI Assistant", + "description": "This website features an AI-powered assistant that can help you find games, provide recommendations, and answer questions about retro gaming.", + "provider_info": "Powered by {{provider}} using the {{model}} model.", + "privacy_note": "The AI assistant is powered by an external service. Please refer to the service's privacy policy for more information.", + "disabled": "AI Assistant functionality was disabled by the administrator.", + "contact": "Contact the administrator or spin up your own instance of Myrient Search." + }, "credits": { "created_by": "Search engine created by", "view_github": "View project on GitHub" diff --git a/config/locales/es.json b/config/locales/es.json index 56157a8..8f34baa 100644 --- a/config/locales/es.json +++ b/config/locales/es.json @@ -55,6 +55,14 @@ "disabled": "La función del emulador web ha sido desactivada por el administrador.", "contact": "Contacta con el administrador o lanza tu propia instancia de Myrient Search." }, + "ai": { + "title": "Asistente de IA", + "description": "Esta web incluye un asistente potenciado por IA que puede ayudarte a encontrar juegos, proporcionar recomendaciones y responder preguntas sobre juegos retro.", + "provider_info": "Potenciado por {{provider}} usando el modelo {{model}}.", + "privacy_note": "El asistente de IA está potenciado por un servicio externo. Por favor, consulta la política de privacidad del servicio para más información.", + "disabled": "La funcionalidad del Asistente de IA ha sido desactivada por el administrador.", + "contact": "Contacta con el administrador o lanza tu propia instancia de Myrient Search." + }, "credits": { "created_by": "Buscador creado por", "view_github": "Ver proyecto en GitHub" diff --git a/config/locales/fr.json b/config/locales/fr.json index 67429e2..2cbd7a1 100644 --- a/config/locales/fr.json +++ b/config/locales/fr.json @@ -55,6 +55,14 @@ "disabled": "La fonctionnalité d'émulation web a été désactivée par l'administrateur.", "contact": "Contactez l'administrateur ou lancez votre propre instance de Myrient Search." }, + "ai": { + "title": "Assistant IA", + "description": "Ce site web dispose d'un assistant alimenté par l'IA qui peut vous aider à trouver des jeux, fournir des recommandations et répondre aux questions sur le gaming rétro.", + "provider_info": "Alimenté par {{provider}} utilisant le modèle {{model}}.", + "privacy_note": "L'assistant IA est alimenté par un service externe. Veuillez vous référer à la politique de confidentialité du service pour plus d'informations.", + "disabled": "La fonctionnalité Assistant IA a été désactivée par l'administrateur.", + "contact": "Contactez l'administrateur ou lancez votre propre instance de Myrient Search." + }, "credits": { "created_by": "Moteur de recherche créé par", "view_github": "Voir le projet sur GitHub" diff --git a/config/locales/hi.json b/config/locales/hi.json index d9da728..1c68e23 100644 --- a/config/locales/hi.json +++ b/config/locales/hi.json @@ -55,6 +55,14 @@ "disabled": "वेब एमुलेटर फंक्शनैलिटी एडमिनिस्ट्रेटर द्वारा अक्षम की गई है।", "contact": "एडमिनिस्ट्रेटर से संपर्क करें या अपना खुद का Myrient Search इंस्टेंस स्थापित करें।" }, + "ai": { + "title": "AI सहायक", + "description": "इस वेबसाइट में एक AI-संचालित सहायक है जो आपको गेम खोजने, सिफारिशें प्रदान करने और रेट्रो गेमिंग के बारे में प्रश्नों का उत्तर देने में मदद कर सकता है।", + "provider_info": "{{provider}} द्वारा संचालित {{model}} मॉडल का उपयोग करके।", + "privacy_note": "AI सहायक एक बाहरी सेवा द्वारा संचालित है। अधिक जानकारी के लिए कृपया सेवा की गोपनीयता नीति देखें।", + "disabled": "AI सहायक कार्यक्षमता प्रशासक द्वारा अक्षम कर दी गई है।", + "contact": "प्रशासक से संपर्क करें या Myrient Search का अपना उदाहरण चलाएं।" + }, "credits": { "created_by": "खोज इंजन किसके द्वारा बनाया गया:", "view_github": "GitHub पर प्रोजेक्ट देखें" diff --git a/config/locales/it.json b/config/locales/it.json index 0c91363..b4fbccc 100644 --- a/config/locales/it.json +++ b/config/locales/it.json @@ -55,6 +55,14 @@ "disabled": "La funzionalità dell'emulatore web è stata disabilitata dall'amministratore.", "contact": "Contatta l'amministratore o avvia la tua istanza di Myrient Search." }, + "ai": { + "title": "Assistente IA", + "description": "Questo sito web dispone di un assistente potenziato dall'IA che può aiutarti a trovare giochi, fornire raccomandazioni e rispondere a domande sul gaming retrò.", + "provider_info": "Alimentato da {{provider}} utilizzando il modello {{model}}.", + "privacy_note": "L'assistente IA è alimentato da un servizio esterno. Si prega di fare riferimento alla politica sulla privacy del servizio per ulteriori informazioni.", + "disabled": "La funzionalità Assistente IA è stata disabilitata dall'amministratore.", + "contact": "Contatta l'amministratore o avvia la tua istanza di Myrient Search." + }, "credits": { "created_by": "Motore di ricerca creato da", "view_github": "Visualizza progetto su GitHub" diff --git a/config/locales/ja.json b/config/locales/ja.json index a72e61a..99344a9 100644 --- a/config/locales/ja.json +++ b/config/locales/ja.json @@ -55,6 +55,14 @@ "disabled": "ウェブエミュレータ機能は管理者によって無効化されています。", "contact": "管理者にお問い合わせいただくか、ご自身でMyrient Searchのインスタンスを立ち上げてください。" }, + "ai": { + "title": "AIアシスタント", + "description": "このウェブサイトには、ゲームを見つけたり、おすすめを提供したり、レトロゲームに関する質問に答えたりできるAI搭載アシスタントが搭載されています。", + "provider_info": "{{model}}モデルを使用した{{provider}}によって提供されています。", + "privacy_note": "AIアシスタントは外部サービスによって提供されています。詳細については、サービスのプライバシーポリシーをご参照ください。", + "disabled": "AIアシスタント機能は管理者によって無効化されています。", + "contact": "管理者にお問い合わせいただくか、独自のMyrient Searchインスタンスを立ち上げてください。" + }, "credits": { "created_by": "検索エンジン開発者:", "view_github": "GitHubでプロジェクトを見る" diff --git a/config/locales/ko.json b/config/locales/ko.json index c92226f..c288d92 100644 --- a/config/locales/ko.json +++ b/config/locales/ko.json @@ -55,6 +55,14 @@ "disabled": "웹 에뮬레이터 기능이 관리자에 의해 비활성화되었습니다.", "contact": "관리자에게 문의하거나 직접 Myrient Search 인스턴스를 설치해 보세요." }, + "ai": { + "title": "AI 어시스턴트", + "description": "이 웹사이트는 게임을 찾고, 추천을 제공하며, 레트로 게임에 대한 질문에 답할 수 있는 AI 기반 어시스턴트를 제공합니다.", + "provider_info": "{{model}} 모델을 사용하는 {{provider}}에 의해 구동됩니다.", + "privacy_note": "AI 어시스턴트는 외부 서비스에 의해 구동됩니다. 자세한 정보는 해당 서비스의 개인정보 보호정책을 참조하세요.", + "disabled": "AI 어시스턴트 기능이 관리자에 의해 비활성화되었습니다.", + "contact": "관리자에게 문의하거나 자신만의 Myrient Search 인스턴스를 실행하세요." + }, "credits": { "created_by": "검색 엔진 개발자:", "view_github": "GitHub에서 프로젝트 보기" diff --git a/config/locales/pl.json b/config/locales/pl.json index fa179c0..9d68585 100644 --- a/config/locales/pl.json +++ b/config/locales/pl.json @@ -55,6 +55,14 @@ "disabled": "Funkcja emulatora internetowego została wyłączona przez administratora.", "contact": "Skontaktuj się z administratorem lub uruchom własną instancję Myrient Search." }, + "ai": { + "title": "Asystent AI", + "description": "Ta strona internetowa posiada asystenta zasilanego przez AI, który może pomóc w znalezieniu gier, dostarczaniu rekomendacji i odpowiadaniu na pytania dotyczące retro gamingu.", + "provider_info": "Zasilany przez {{provider}} używający modelu {{model}}.", + "privacy_note": "Asystent AI jest zasilany przez zewnętrzną usługę. Aby uzyskać więcej informacji, zapoznaj się z polityką prywatności usługi.", + "disabled": "Funkcjonalność Asystenta AI została wyłączona przez administratora.", + "contact": "Skontaktuj się z administratorem lub uruchom własną instancję Myrient Search." + }, "credits": { "created_by": "Wyszukiwarka stworzona przez", "view_github": "Zobacz projekt na GitHub" diff --git a/config/locales/pt.json b/config/locales/pt.json index 2faf75e..d9e2320 100644 --- a/config/locales/pt.json +++ b/config/locales/pt.json @@ -55,6 +55,14 @@ "disabled": "A funcionalidade do emulador web foi desativada pelo administrador.", "contact": "Entre em contato com o administrador ou crie sua própria instância do Myrient Search." }, + "ai": { + "title": "Assistente de IA", + "description": "Este site possui um assistente de IA que pode ajudá-lo a encontrar jogos, fornecer recomendações e responder perguntas sobre jogos retrô.", + "provider_info": "O assistente é alimentado por {{provider}} usando o modelo {{model}}.", + "privacy_note": "O assistente de IA é fornecido por um serviço externo. Por favor, consulte a política de privacidade do serviço para mais informações.", + "disabled": "A funcionalidade do assistente de IA foi desabilitada pelo administrador.", + "contact": "Entre em contato com o administrador ou execute sua própria instância do Myrient Search." + }, "credits": { "created_by": "Buscador criado por", "view_github": "Ver projeto no GitHub" diff --git a/config/locales/romaji.json b/config/locales/romaji.json index 646e1ee..e251b78 100644 --- a/config/locales/romaji.json +++ b/config/locales/romaji.json @@ -55,6 +55,14 @@ "disabled": "Webu emyurēta kinō wa kanrisha ni yotte mukouka sareteimasu.", "contact": "Kanrisha ni otoiawase itadaku ka, go jishin de Myrient Search no insutansu wo tachiagetekudasai." }, + "ai": { + "title": "AI Ashisutanto", + "description": "Kono websaito ni wa, geemu wo mitsukete, osusume wo teikyou shite, retoro geemu ni kansuru shitsumon ni kotaeru koto ga dekiru AI-shihaisha ga tousai sarete imasu.", + "provider_info": "{{model}} moderu wo shiyou shita {{provider}} ni yotte teikyou sarete imasu.", + "privacy_note": "AI ashisutanto wa gaibu saabisu ni yotte teikyou sarete imasu. Shousai ni tsuite wa, saabisu no puraibashii porishii wo go-sanshou kudasai.", + "disabled": "AI ashisutanto kinou wa kanrisha ni yotte mukouka sarete imasu.", + "contact": "Kanrisha ni otoiawase itadaku ka, dokuji no Myrient Search insutansu wo tachiagete kudasai." + }, "credits": { "created_by": "Kensaku enjin kaihatsusha:", "view_github": "GitHub de purojekuto wo miru" diff --git a/config/locales/ru.json b/config/locales/ru.json index 7040264..40f7c96 100644 --- a/config/locales/ru.json +++ b/config/locales/ru.json @@ -55,6 +55,14 @@ "disabled": "Функция веб-эмулятора отключена администратором.", "contact": "Свяжитесь с администратором или запустите собственный экземпляр Myrient Search." }, + "ai": { + "title": "ИИ Помощник", + "description": "Этот веб-сайт оснащен ИИ-помощником, который может помочь вам находить игры, предоставлять рекомендации и отвечать на вопросы о ретро-играх.", + "provider_info": "Работает на базе {{provider}} с использованием модели {{model}}.", + "privacy_note": "ИИ-помощник работает на базе внешнего сервиса. Пожалуйста, обратитесь к политике конфиденциальности сервиса для получения дополнительной информации.", + "disabled": "Функциональность ИИ-помощника была отключена администратором.", + "contact": "Свяжитесь с администратором или запустите собственный экземпляр Myrient Search." + }, "credits": { "created_by": "Поисковая система создана", "view_github": "Посмотреть проект на GitHub" diff --git a/config/locales/tr.json b/config/locales/tr.json index b409e84..7392e23 100644 --- a/config/locales/tr.json +++ b/config/locales/tr.json @@ -55,6 +55,14 @@ "disabled": "Web Emülatörü işlevi yönetici tarafından devre dışı bırakıldı.", "contact": "Yöneticiyle iletişime geçin veya kendi Myrient Search örneğinizi kurun." }, + "ai": { + "title": "AI Asistanı", + "description": "Bu web sitesi, oyun bulmanıza, öneriler sunmanıza ve retro oyunlar hakkındaki sorularınızı yanıtlamanıza yardımcı olabilecek AI destekli bir asistana sahiptir.", + "provider_info": "{{model}} modelini kullanan {{provider}} tarafından desteklenmektedir.", + "privacy_note": "AI asistanı harici bir hizmet tarafından desteklenmektedir. Daha fazla bilgi için lütfen hizmetin gizlilik politikasına bakın.", + "disabled": "AI Asistanı işlevselliği yönetici tarafından devre dışı bırakılmıştır.", + "contact": "Yönetici ile iletişime geçin veya kendi Myrient Search örneğinizi çalıştırın." + }, "credits": { "created_by": "Arama motoru şu kişi tarafından oluşturuldu:", "view_github": "Projeyi GitHub'da görüntüle" diff --git a/config/locales/zh.json b/config/locales/zh.json index b595c14..2f061f5 100644 --- a/config/locales/zh.json +++ b/config/locales/zh.json @@ -55,6 +55,14 @@ "disabled": "网页模拟器功能已被管理员禁用。", "contact": "请联系管理员或自行部署Myrient Search实例。" }, + "ai": { + "title": "AI助手", + "description": "该网站配备了AI助手,可以帮助您查找游戏、提供推荐并回答有关复古游戏的问题。", + "provider_info": "由{{provider}}提供支持,使用{{model}}模型。", + "privacy_note": "AI助手由外部服务提供支持。更多信息请参阅服务的隐私政策。", + "disabled": "AI助手功能已被管理员禁用。", + "contact": "请联系管理员或运行您自己的Myrient Search实例。" + }, "credits": { "created_by": "搜索引擎开发者:", "view_github": "在GitHub上查看项目" diff --git a/server.js b/server.js index 8237ff3..6a3293f 100644 --- a/server.js +++ b/server.js @@ -150,6 +150,11 @@ let defaultOptions = { isEmulatorCompatible: isEmulatorCompatible, isNonGameContent: isNonGameContent, nonGameTerms: nonGameTerms, + aiEnabled: process.env.AI_ENABLED === 'true', + aiConfig: { + apiUrl: process.env.AI_API_URL || 'https://example.com', + model: process.env.AI_MODEL || 'default', + }, }; function updateDefaults() { diff --git a/views/pages/about.ejs b/views/pages/about.ejs index 7961ed5..3838a2a 100644 --- a/views/pages/about.ejs +++ b/views/pages/about.ejs @@ -24,40 +24,99 @@
    <%= __('about.emulator.title') %>
    <% if (process.env.EMULATOR_ENABLED === 'true') { %> -

    <%= __('about.emulator.description') %>

    -

    <%= __('about.emulator.compatibility') %>

    +

    <%= __('about.emulator.description') %>

    +

    <%= __('about.emulator.compatibility') %>

    -

    <%= __('about.emulator.browser_tip') %>

    +

    <%= __('about.emulator.browser_tip') %>

    -

    - - - <%= __('about.emulator.save_states') %> - -
    - - - <%= __('about.emulator.limitations') %> - -

    +

    + + + <%= __('about.emulator.save_states') %> + +
    + + + <%= __('about.emulator.limitations') %> + +

    <% } else { %> -

    <%= __('about.emulator.disabled') %>

    -

    <%= __('about.emulator.contact') %>

    +

    <%= __('about.emulator.disabled') %>

    +

    <%= __('about.emulator.contact') %>

    + <% } %> +
    + +
    +
    <%= __('about.ai.title') %>
    + <% if (aiEnabled) { %> + <% + // Extract provider from API URL + let provider = 'Unknown'; + if (aiConfig.apiUrl.includes('api.groq.com')) { + provider = 'Groq'; + } else if (aiConfig.apiUrl.includes('api.openai.com')) { + provider = 'OpenAI'; + } else if (aiConfig.apiUrl.includes('api.anthropic.com')) { + provider = 'Anthropic'; + } else if (aiConfig.apiUrl.includes('generativelanguage.googleapis.com')) { + provider = 'Google Gemini'; + } else if (aiConfig.apiUrl.includes('api.perplexity.ai')) { + provider = 'Perplexity'; + } else if (aiConfig.apiUrl.includes('api.cohere.ai')) { + provider = 'Cohere'; + } else if (aiConfig.apiUrl.includes('api.mistral.ai')) { + provider = 'Mistral'; + } else if (aiConfig.apiUrl.includes('localhost') || aiConfig.apiUrl.includes('127.0.0.1') || aiConfig.apiUrl.includes('0.0.0.0')) { + provider = 'Local LLM'; + } else { + // Try to extract domain from URL + try { + const url = new URL(aiConfig.apiUrl); + const hostname = url.hostname; + if (hostname.startsWith('api.')) { + provider = hostname.substring(4).split('.')[0]; + } else { + provider = hostname.split('.')[0]; + } + provider = provider.charAt(0).toUpperCase() + provider.slice(1); + } catch (e) { + provider = 'Custom Provider'; + } + } + %> +

    <%= __('about.ai.description') %>

    +

    <%- __('about.ai.provider_info', {provider: provider, model: aiConfig.model}) %>

    + +

    + + + <%= __('about.ai.privacy_note') %> + +

    + + <% } else { %> +

    <%= __('about.ai.disabled') %>

    +

    <%= __('about.ai.contact') %>

    <% } %>
    diff --git a/views/pages/index.ejs b/views/pages/index.ejs index 6d980a4..563dfce 100644 --- a/views/pages/index.ejs +++ b/views/pages/index.ejs @@ -21,6 +21,7 @@ <%- include('../partials/footer'); %> +<% if (aiEnabled) { %> +<% } %> +<% if (aiEnabled) { %> +<% } %> + +<% if (aiEnabled) { %> +<% } %> +<% if (aiEnabled) { %> +<% } %> \ No newline at end of file From 5e8fdd49252da8e152b0a7f0b36c329969714acf Mon Sep 17 00:00:00 2001 From: ovosimpatico Date: Sat, 18 Oct 2025 04:52:58 -0300 Subject: [PATCH 3/5] Add tool usage and fix messaging history --- lib/ai/tools.js | 186 +++++++++++++++++++++++++++++ server.js | 239 +++++++++++++++++++++++++++++++++++-- views/public/css/ai.css | 31 +++++ views/public/js/ai-chat.js | 63 +++++++++- 4 files changed, 504 insertions(+), 15 deletions(-) create mode 100644 lib/ai/tools.js diff --git a/lib/ai/tools.js b/lib/ai/tools.js new file mode 100644 index 0000000..ee22e16 --- /dev/null +++ b/lib/ai/tools.js @@ -0,0 +1,186 @@ +// AI Tool definitions for function calling +import Searcher from '../search/search.js'; + +// Initialize search instance +const searchFields = ["filename", "category", "type", "region"]; +const searcher = new Searcher(searchFields); + +// Default search settings +const defaultSearchSettings = { + boost: { + filename: 2, + category: 1, + type: 1, + region: 1 + }, + combineWith: "AND", + fields: searchFields, + fuzzy: 0, + prefix: true, + hideNonGame: true, + useOldResults: false, + pageSize: 10, + page: 0, + sort: "" +}; + +/** + * Available tools for the AI assistant + */ +export const tools = [ + { + type: "function", + function: { + name: "search_games", + description: "Search for retro games and ROMs in the Myrient database. Use simple, flexible text searches for best results. The search is fuzzy and will find partial matches.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: "The search query - game name or title. Examples: 'Super Mario', 'The Last of Us', 'Final Fantasy'. Keep it simple for best results." + }, + limit: { + type: "number", + description: "Maximum number of results to return (1-50, default 10)", + minimum: 1, + maximum: 50 + } + }, + required: ["query"] + } + } + }, + { + type: "function", + function: { + name: "get_search_suggestions", + description: "Get search suggestions based on a partial query. Useful for helping users discover games or correct typos.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: "Partial search query to get suggestions for" + } + }, + required: ["query"] + } + } + } +]; + +/** + * Execute a tool call + */ +export async function executeToolCall(toolCall) { + const { name, arguments: argsString } = toolCall.function; + + try { + // Parse arguments from JSON string + let args; + try { + args = typeof argsString === 'string' ? JSON.parse(argsString) : argsString; + } catch (parseError) { + throw new Error(`Invalid JSON arguments for ${name}: ${parseError.message}`); + } + + switch (name) { + case 'search_games': + return await searchGames(args); + case 'get_search_suggestions': + return await getSearchSuggestions(args); + default: + throw new Error(`Unknown tool: ${name}`); + } + } catch (error) { + console.error(`Tool execution error for ${name}:`, error); + return { + error: `Failed to execute ${name}: ${error.message}` + }; + } +} + +/** + * Search for games using the existing search infrastructure + */ +async function searchGames(args) { + const { query, limit = 10 } = args; + + if (!query || typeof query !== 'string') { + throw new Error('Query is required and must be a string'); + } + + // Build search options - simplified for fuzzy/flexible search + const searchOptions = { ...defaultSearchSettings }; + searchOptions.pageSize = Math.min(Math.max(1, limit), 50); + + // Enable fuzzy search for better matching + searchOptions.fuzzy = 1; // Allow some typos/variations + searchOptions.prefix = true; // Allow partial matches + + try { + const results = await searcher.findAllMatches(query.trim(), searchOptions); + + // Use results as-is without strict filtering + let filteredItems = results.items; + + // Format results for the AI + const formattedResults = filteredItems.slice(0, searchOptions.pageSize).map(item => ({ + id: item.file.id, + filename: item.file.filename, + category: item.file.category, + type: item.file.type, + region: item.file.region, + size: item.file.size, + score: item.score, + // Add URLs for linking to games + urls: { + info: `/info/${item.file.id}`, + play: `/play/${item.file.id}`, // For emulator (if compatible) + download: item.file.path // Direct download link + }, + metadata: item.metadata ? { + title: item.metadata.title, + description: item.metadata.summary, + releaseDate: item.metadata.first_release_date, + rating: item.metadata.rating, + genres: item.metadata.genres + } : null + })); + + return { + query, + results: formattedResults, + total_found: results.count, + total_returned: formattedResults.length, + search_time: results.elapsed + }; + + } catch (error) { + throw new Error(`Search failed: ${error.message}`); + } +} + +/** + * Get search suggestions + */ +async function getSearchSuggestions(args) { + const { query } = args; + + if (!query || typeof query !== 'string') { + throw new Error('Query is required and must be a string'); + } + + try { + const suggestions = await searcher.getSuggestions(query.trim(), defaultSearchSettings); + + return { + query, + suggestions: suggestions || [] + }; + + } catch (error) { + throw new Error(`Failed to get suggestions: ${error.message}`); + } +} diff --git a/server.js b/server.js index 6a3293f..796b761 100644 --- a/server.js +++ b/server.js @@ -573,16 +573,61 @@ About Myrient: - The search engine indexes thousands of games from various gaming systems and regions Your role: -- Help users find games they're looking for +- Help users find games they're looking for by using the search tools available to you - 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 +- When users ask for games, always use the search_games tool to find them +- Keep responses SHORT, CONCISE and SIMPLE - the chat interface is small +- Present search results as simple lists, NOT tables (tables don't fit in the small chat window) +- Use bullet points or numbered lists instead of tables +- Limit responses to 3-5 game recommendations maximum to keep it readable - 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.`; +IMPORTANT SEARCH STRATEGY: +- When users describe a game, THINK about what the actual game title might be before searching +- Don't search literal descriptions - identify the likely game name first +- Use SIMPLE searches with just the game title for best results +- The search is fuzzy and will find partial matches - keep queries simple +- If first search fails or returns few results, try alternative searches with different terms +- For empty results, suggest the user try different search terms or check spelling - const aiResponse = await fetch(apiUrl, { +GAME RECOMMENDATION STRATEGY: +- When users ask for "best [console] games", don't search the console name +- Instead, search for specific popular titles for that console +- EFFICIENCY: Limit to 2-3 searches maximum for recommendations to avoid hitting rate limits +- Stop searching when you have enough games (3-5) for a good recommendation +- Focus on well-known AAA titles, not obscure indie games + +Available Tools: +- search_games: Fuzzy text search for games by title/name (returns URLs for each game) +- get_search_suggestions: Get search suggestions for partial queries + +CRITICAL LINKING RULES: +- NEVER make up or guess URLs - ONLY use URLs from search tool results +- When mentioning specific games found via search_games tool, ALWAYS link to them using EXACTLY the urls.info value from the search results +- Format: [Game Title](EXACT_INFO_URL_FROM_SEARCH_RESULTS) +- Do NOT create links like /info/123 - use the EXACT urls.info field from the tool response +- If you haven't searched for a game using the tool, do NOT create any links for it +- Only link to games that were actually returned by the search_games tool with their provided URLs`; + + // Import tools dynamically + const { tools, executeToolCall } = await import('./lib/ai/tools.js'); + + // Build conversation history + let messages = [ + { role: 'system', content: systemPrompt } + ]; + + // Add conversation history if provided + if (req.body.conversation && Array.isArray(req.body.conversation)) { + messages = messages.concat(req.body.conversation); + } + + // Add current user message + messages.push({ role: 'user', content: message }); + + let aiResponse = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -591,11 +636,10 @@ Current search context: The user is on a retro gaming search website and may be }, body: JSON.stringify({ model: model, - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: message } - ], - max_tokens: 500, + messages: messages, + tools: tools, + tool_choice: 'auto', + max_tokens: 1000, temperature: 0.7, stream: false }) @@ -603,7 +647,13 @@ Current search context: The user is on a retro gaming search website and may be if (!aiResponse.ok) { const errorData = await aiResponse.json().catch(() => ({})); - console.error('AI API Error:', aiResponse.status, errorData); + console.error('AI API Error on initial request:'); + console.error('Status:', aiResponse.status); + console.error('Error data:', errorData); + console.error('Request details:'); + console.error('- Model:', model); + console.error('- Messages count:', messages.length); + console.error('- User message:', message.substring(0, 100) + '...'); // Handle specific error cases if (aiResponse.status === 401) { @@ -621,7 +671,7 @@ Current search context: The user is on a retro gaming search website and may be } } - const aiData = await aiResponse.json(); + let aiData = await aiResponse.json(); if (!aiData.choices || aiData.choices.length === 0) { return res.status(503).json({ @@ -629,9 +679,172 @@ Current search context: The user is on a retro gaming search website and may be }); } - const response = aiData.choices[0].message.content.trim(); + let assistantMessage = aiData.choices[0].message; + let toolCallsCount = 0; // Track tool calls executed + let toolsUsed = []; // Track which tools were used - res.json({ response }); + console.log('Initial AI request successful'); + + // Handle multiple rounds of tool calls + let maxToolRounds = 3; // Prevent infinite loops and token exhaustion + let currentRound = 0; + + while (assistantMessage.tool_calls && assistantMessage.tool_calls.length > 0 && currentRound < maxToolRounds) { + currentRound++; + const roundToolCalls = assistantMessage.tool_calls.length; + const roundToolsUsed = assistantMessage.tool_calls.map(tc => tc.function.name); + + console.log(`Round ${currentRound}: AI wants to use ${roundToolCalls} tools: ${roundToolsUsed.join(', ')}`); + + // Track total tools across all rounds + toolCallsCount += roundToolCalls; + toolsUsed = toolsUsed.concat(roundToolsUsed); + + // Add assistant message with tool calls to conversation + messages.push(assistantMessage); + + // Execute each tool call in this round + for (const toolCall of assistantMessage.tool_calls) { + try { + const toolResult = await executeToolCall(toolCall); + + // Add tool result to conversation + messages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: JSON.stringify(toolResult) + }); + } catch (error) { + console.error('Tool execution error:', error); + // Add error result + messages.push({ + role: 'tool', + tool_call_id: toolCall.id, + content: JSON.stringify({ error: error.message }) + }); + } + } + + // Get AI response after this round of tool execution + console.log(`Making AI request after round ${currentRound} tool execution...`); + 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: messages, + tools: tools, + tool_choice: 'auto', + max_tokens: 1000, + temperature: 0.7, + stream: false + }) + }); + + if (!aiResponse.ok) { + const errorData = await aiResponse.json().catch(() => ({})); + console.error(`AI API Error after round ${currentRound} tool execution:`); + console.error('Status:', aiResponse.status); + console.error('Error data:', errorData); + console.error('Request details:'); + console.error('- Model:', model); + console.error('- Messages count:', messages.length); + console.error('- Tools used:', toolsUsed); + + // Handle specific error cases + if (aiResponse.status === 429) { + // Extract wait time from error message if available + let waitTime = 5000; // Default 5 seconds + if (errorData.error?.message) { + const waitMatch = errorData.error.message.match(/Please try again in ([\d.]+)s/); + if (waitMatch) { + waitTime = Math.ceil(parseFloat(waitMatch[1]) * 1000) + 1000; // Add 1 extra second + } + } + + console.error(`Rate limit hit after tool execution. Waiting ${waitTime/1000}s and retrying once...`); + await new Promise(resolve => setTimeout(resolve, waitTime)); + + const retryResponse = 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: messages, + tools: tools, + tool_choice: 'auto', + max_tokens: 1000, + temperature: 0.7, + stream: false + }) + }); + + if (retryResponse.ok) { + console.log('Retry successful after rate limit'); + aiData = await retryResponse.json(); + assistantMessage = aiData.choices[0].message; + } else { + console.error('Retry also failed with status:', retryResponse.status); + return res.status(429).json({ + error: 'AI service is currently busy processing your request. Please try again in a moment.' + }); + } + } else if (aiResponse.status === 401) { + return res.status(503).json({ + error: 'AI service authentication failed. Please contact the administrator.' + }); + } else { + return res.status(503).json({ + error: 'AI service encountered an error while processing your request. Please try again later.' + }); + } + } else { + console.log(`AI request after round ${currentRound} tool execution successful`); + aiData = await aiResponse.json(); + assistantMessage = aiData.choices[0].message; + + console.log(`Round ${currentRound} response - has tool_calls:`, !!assistantMessage.tool_calls); + console.log(`Round ${currentRound} response - has content:`, !!assistantMessage.content); + } + } + + if (currentRound >= maxToolRounds && assistantMessage.tool_calls) { + console.warn('Maximum tool rounds reached, AI still wants to use tools. Stopping.'); + } + + if (currentRound === 0) { + console.log('No tool calls needed, using initial response'); + } else { + console.log(`Total rounds completed: ${currentRound}`); + } + + console.log('Final tool calls check - has tool_calls:', !!assistantMessage.tool_calls); + console.log('Final tool calls check - has content:', !!assistantMessage.content); + + console.log('Final assistant message structure:', JSON.stringify(assistantMessage, null, 2)); + console.log('Assistant message content:', assistantMessage.content); + console.log('Assistant message content type:', typeof assistantMessage.content); + console.log('Assistant message keys:', Object.keys(assistantMessage)); + + const response = assistantMessage.content?.trim() || 'Something went wrong'; + console.log('Final response after processing:', response.substring(0, 100) + '...'); + console.log('Tools used in this request:', toolsUsed); + + // Return the response along with updated conversation + res.json({ + response, + conversation: messages.slice(1), // Exclude system message from returned conversation + tool_calls_made: toolCallsCount, + tools_used: toolsUsed + }); } catch (error) { console.error('AI Chat Error:', error); diff --git a/views/public/css/ai.css b/views/public/css/ai.css index bfb7d14..51fcdc5 100644 --- a/views/public/css/ai.css +++ b/views/public/css/ai.css @@ -558,3 +558,34 @@ padding: 6px 10px; } } + +/* Tool Usage Indicator */ +.ai-tool-usage-indicator { + display: flex; + justify-content: center; + margin: 10px 0 5px 0; + opacity: 0.8; +} + +.ai-tool-indicator { + background: rgba(255, 189, 51, 0.1); + border: 1px solid rgba(255, 189, 51, 0.2); + border-radius: 20px; + padding: 6px 12px; + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: #ffbd33; +} + +.ai-tool-icon { + font-size: 14px; + filter: brightness(1.1); +} + +.ai-tool-indicator small { + font-size: 11px; + color: #b0b8c1; + margin: 0; +} diff --git a/views/public/js/ai-chat.js b/views/public/js/ai-chat.js index ef6fc94..3a9e636 100644 --- a/views/public/js/ai-chat.js +++ b/views/public/js/ai-chat.js @@ -13,6 +13,9 @@ class AIChat { this.isTyping = false; this.hasMessages = false; + // Track conversation history for tool calling + this.conversation = []; + // Initialize translations this.translations = window.aiChatTranslations || { typing_indicator: 'AI is thinking', @@ -177,13 +180,19 @@ class AIChat { this.showTypingIndicator(); try { - // Send to backend + // Add user message to conversation history + this.conversation.push({ role: 'user', content: message }); + + // Send to backend with conversation history const response = await fetch('/api/ai-chat', { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ message }) + body: JSON.stringify({ + message, + conversation: this.conversation.slice(-10) // Send last 10 messages for context + }) }); if (!response.ok) { @@ -193,10 +202,24 @@ class AIChat { const data = await response.json(); + // Update conversation history + if (data.conversation) { + this.conversation = data.conversation; + } + // Remove typing indicator and add AI response this.hideTypingIndicator(); + + // Show tool usage indicator if tools were used + if (data.tool_calls_made && data.tool_calls_made > 0) { + this.addToolUsageIndicator(data.tool_calls_made, data.tools_used || []); + } + this.addMessage(data.response, 'ai'); + // Add AI response to conversation history + this.conversation.push({ role: 'assistant', content: data.response }); + } catch (error) { console.error('AI Chat Error:', error); this.hideTypingIndicator(); @@ -229,6 +252,42 @@ class AIChat { `; this.messagesContainer.appendChild(messageDiv); + this.scrollToBottom(); + } + + addToolUsageIndicator(toolCount, toolsUsed = []) { + const indicatorDiv = document.createElement('div'); + indicatorDiv.className = 'ai-tool-usage-indicator'; + + // Create user-friendly tool names + const friendlyToolNames = { + 'search_games': 'game search', + 'get_search_suggestions': 'search suggestions' + }; + + let toolText; + if (toolsUsed.length > 0) { + const friendlyNames = toolsUsed.map(tool => friendlyToolNames[tool] || tool); + if (friendlyNames.length === 1) { + toolText = `Used ${friendlyNames[0]}`; + } else { + toolText = `Used ${friendlyNames.join(' & ')}`; + } + } else { + // Fallback to generic text + const genericToolText = toolCount === 1 ? 'tool' : 'tools'; + toolText = `Used ${toolCount} ${genericToolText}`; + } + + indicatorDiv.innerHTML = ` +
    + 🔧 + ${toolText} +
    + `; + + this.messagesContainer.appendChild(indicatorDiv); + this.scrollToBottom(); } formatMessage(message) { From 8efcbc758da89dd45c9fdff76505cebf2e4b07f1 Mon Sep 17 00:00:00 2001 From: ovosimpatico Date: Sat, 18 Oct 2025 05:05:04 -0300 Subject: [PATCH 4/5] oops forgot the compose envs --- docker-compose.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index a7169a6..435cfee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,10 @@ services: - ELASTICSEARCH_URL=http://elasticsearch:9200 - TWITCH_CLIENT_ID= - TWITCH_CLIENT_SECRET= + - 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 volumes: - ./data:/usr/src/app/data depends_on: From ad20b5bdb4c6457ed253e3ac1b8a0b057204fa29 Mon Sep 17 00:00:00 2001 From: ovosimpatico Date: Sat, 18 Oct 2025 21:05:19 -0300 Subject: [PATCH 5/5] simplify AI about page detection --- views/pages/about.ejs | 57 +++++++++++++++++++++---------------------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/views/pages/about.ejs b/views/pages/about.ejs index 3838a2a..936ba74 100644 --- a/views/pages/about.ejs +++ b/views/pages/about.ejs @@ -52,38 +52,37 @@ <% if (aiEnabled) { %> <% // Extract provider from API URL - let provider = 'Unknown'; - if (aiConfig.apiUrl.includes('api.groq.com')) { - provider = 'Groq'; - } else if (aiConfig.apiUrl.includes('api.openai.com')) { - provider = 'OpenAI'; - } else if (aiConfig.apiUrl.includes('api.anthropic.com')) { - provider = 'Anthropic'; - } else if (aiConfig.apiUrl.includes('generativelanguage.googleapis.com')) { - provider = 'Google Gemini'; - } else if (aiConfig.apiUrl.includes('api.perplexity.ai')) { - provider = 'Perplexity'; - } else if (aiConfig.apiUrl.includes('api.cohere.ai')) { - provider = 'Cohere'; - } else if (aiConfig.apiUrl.includes('api.mistral.ai')) { - provider = 'Mistral'; - } else if (aiConfig.apiUrl.includes('localhost') || aiConfig.apiUrl.includes('127.0.0.1') || aiConfig.apiUrl.includes('0.0.0.0')) { - provider = 'Local LLM'; - } else { - // Try to extract domain from URL + const providers = [ + { pattern: 'api.groq.com', name: 'Groq' }, + { pattern: 'api.openai.com', name: 'OpenAI' }, + { pattern: 'api.anthropic.com', name: 'Anthropic' }, + { pattern: 'generativelanguage.googleapis.com', name: 'Google Gemini' }, + { pattern: 'api.perplexity.ai', name: 'Perplexity' }, + { pattern: 'api.cohere.ai', name: 'Cohere' }, + { pattern: 'api.mistral.ai', name: 'Mistral' }, + ]; + const localPatterns = ['localhost', '127.0.0.1', '0.0.0.0']; + + const apiUrl = aiConfig.apiUrl; + + // Helper to extract provider name from domain + const extractProviderFromDomain = (url) => { try { - const url = new URL(aiConfig.apiUrl); - const hostname = url.hostname; - if (hostname.startsWith('api.')) { - provider = hostname.substring(4).split('.')[0]; - } else { - provider = hostname.split('.')[0]; - } - provider = provider.charAt(0).toUpperCase() + provider.slice(1); + const { hostname } = new URL(url); + const domain = hostname.startsWith('api.') + ? hostname.substring(4).split('.')[0] + : hostname.split('.')[0]; + return domain.charAt(0).toUpperCase() + domain.slice(1); } catch (e) { - provider = 'Custom Provider'; + return 'Custom Provider'; } - } + }; + + // Determine provider: local → known providers → domain extraction + const provider = localPatterns.some(pattern => apiUrl.includes(pattern)) + ? 'Local LLM' + : providers.find(p => apiUrl.includes(p.pattern))?.name + || extractProviderFromDomain(apiUrl); %>

    <%= __('about.ai.description') %>

    <%- __('about.ai.provider_info', {provider: provider, model: aiConfig.model}) %>