Merge pull request #34 from alexankitty/ai

AI-powered game search
This commit is contained in:
2025-10-20 22:55:09 -03:00
committed by GitHub
26 changed files with 2069 additions and 35 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

@@ -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"
@@ -156,6 +164,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

@@ -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-এ প্রকল্প দেখুন"
@@ -156,6 +164,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 +196,7 @@
"tr": "Türkçe",
"it": "Italiano",
"romaji": "Romaji",
"hi": "हि्दी",
"hi": "हि्दी",
"ar": "العربية",
"bn": "বাংলা",
"ru": "Русский"

View File

@@ -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"
@@ -156,6 +164,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

@@ -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"
@@ -156,6 +164,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

@@ -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"
@@ -156,6 +164,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

@@ -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"
@@ -156,6 +164,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 +196,7 @@
"tr": "Türkçe",
"it": "Italiano",
"romaji": "Romaji",
"hi": "हिन्दी",
"hi": "हiन्दी",
"ar": "العربية",
"bn": "বাংলা",
"ru": "Русский"

View File

@@ -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 पर प्रोजेक्ट देखें"
@@ -156,6 +164,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

@@ -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"
@@ -156,6 +164,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 +196,7 @@
"tr": "Türkçe",
"it": "Italiano",
"romaji": "Romaji",
"hi": "हिन्द",
"hi": "हिन्दŀ",
"ar": "العربية",
"bn": "বাংলা",
"ru": "Русский"

View File

@@ -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でプロジェクトを見る"
@@ -156,6 +164,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

@@ -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에서 프로젝트 보기"
@@ -156,6 +164,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

@@ -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"
@@ -156,6 +164,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 +196,7 @@
"tr": "Türkçe",
"it": "Italiano",
"romaji": "Romaji",
"hi": "हिन्द",
"hi": "हिन्दŀ",
"ar": "العربية",
"bn": "বাংলা",
"ru": "Русский"

View File

@@ -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"
@@ -68,7 +76,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 +86,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 +94,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 +131,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 +164,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

@@ -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"
@@ -156,6 +164,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

@@ -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"
@@ -155,6 +163,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

@@ -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"
@@ -156,6 +164,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

@@ -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上查看项目"
@@ -156,6 +164,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

@@ -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:

186
lib/ai/tools.js Normal file
View File

@@ -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}`);
}
}

322
server.js
View File

@@ -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() {
@@ -532,6 +537,323 @@ 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 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
- 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
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
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',
'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 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) {
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.'
});
}
}
let aiData = await aiResponse.json();
if (!aiData.choices || aiData.choices.length === 0) {
return res.status(503).json({
error: 'AI service returned an unexpected response.'
});
}
let assistantMessage = aiData.choices[0].message;
let toolCallsCount = 0; // Track tool calls executed
let toolsUsed = []; // Track which tools were used
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);
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

@@ -47,17 +47,75 @@
<% } %>
</div>
<div class="mb-4 border-top pt-3">
<h5><%= __('about.ai.title') %></h5>
<% if (aiEnabled) { %>
<%
// Extract provider from API 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 { 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) {
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);
%>
<p><%= __('about.ai.description') %></p>
<p><%- __('about.ai.provider_info', {provider: provider, model: aiConfig.model}) %></p>
<p class="text-secondary">
<small>
<i class="fas fa-info-circle"></i>
<%= __('about.ai.privacy_note') %>
</small>
</p>
<% } else { %>
<p><%= __('about.ai.disabled') %></p>
<p><%= __('about.ai.contact') %></p>
<% } %>
</div>
<div class="border-top pt-3">
<p><%= __('about.credits.created_by') %> <a href="https://github.com/alexankitty">Alexankitty</a></p>
<p><%= __('about.credits.created_by') %> <a href="https://github.com/alexankitty">Alexankitty</a>
</p>
<div class="mb-3">
<a href="https://github.com/alexankitty/Myrient-Search-Engine/graphs/contributors">
<img src="/proxy-image?url=<%= encodeURIComponent('https://contrib.rocks/image?repo=alexankitty/Myrient-Search-Engine') %>" alt="Contributors" />
<img src="/proxy-image?url=<%= encodeURIComponent('https://contrib.rocks/image?repo=alexankitty/Myrient-Search-Engine') %>"
alt="Contributors" />
</a>
</div>
<p><a href="https://github.com/alexankitty/myrient-global-search"><%= __('about.credits.view_github') %></a></p>
<p><a
href="https://github.com/alexankitty/myrient-global-search"><%= __('about.credits.view_github') %></a>
</p>
<a href='https://ko-fi.com/Q5Q4IFNAO' target='_blank'>
<img height='36' style='border:0px;height:36px;'
src='/proxy-image?url=<%= encodeURIComponent("https://storage.ko-fi.com/cdn/kofi5.png?v=3") %>' alt='Buy Me a Coffee at ko-fi.com' />
src='/proxy-image?url=<%= encodeURIComponent("https://storage.ko-fi.com/cdn/kofi5.png?v=3") %>'
alt='Buy Me a Coffee at ko-fi.com' />
</a>
</div>
</div>

View File

@@ -21,7 +21,62 @@
<%- include('../partials/footer'); %>
</footer>
<% if (aiEnabled) { %>
<!-- 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>
<% if (aiEnabled) { %>
<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') %>'
}
};
</script>
<% } %>
<script>
if(window.location.pathname != '/settings'){ //don't load on the settings page
settingStore = localStorage.getItem('settings')
@@ -39,7 +94,15 @@
}
}
</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">
<% if (aiEnabled) { %>
<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>
<% if (aiEnabled) { %>
<link rel="stylesheet" href="/public/css/ai.css">
<script src="/public/js/ai-chat.js"></script>
<% } %>
</html>

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

@@ -0,0 +1,591 @@
/* 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;
}
}
/* 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;
}

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;

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

@@ -0,0 +1,367 @@
// 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;
// Track conversation history for tool calling
this.conversation = [];
// 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 {
// 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,
conversation: this.conversation.slice(-10) // Send last 10 messages for context
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
}
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();
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);
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 = `
<div class="ai-tool-indicator">
<span class="ai-tool-icon">🔧</span>
<small>${toolText}</small>
</div>
`;
this.messagesContainer.appendChild(indicatorDiv);
this.scrollToBottom();
}
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();
}
});