| <!DOCTYPE html> |
| <html lang="fr"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Mariam AI!</title> |
|
|
| |
| <script src="https://cdn.tailwindcss.com?plugins=forms"></script> |
|
|
| |
| <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🤖</text></svg>"> |
|
|
| <style> |
| html, body { |
| min-height: 100vh; |
| margin: 0; |
| padding: 0; |
| } |
| body { |
| display: flex; |
| flex-direction: column; |
| font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; |
| } |
| #chat-container { |
| display: flex; |
| flex-direction: column; |
| flex-grow: 1; |
| min-height: 0; |
| } |
| #chat-messages { |
| flex-grow: 1; |
| overflow-y: auto; |
| min-height: 0; |
| } |
| ::-webkit-scrollbar { |
| width: 8px; |
| } |
| ::-webkit-scrollbar-track { |
| background: #f1f1f1; |
| border-radius: 10px; |
| } |
| ::-webkit-scrollbar-thumb { |
| background: #a8a8a8; |
| border-radius: 10px; |
| } |
| ::-webkit-scrollbar-thumb:hover { |
| background: #7a7a7a; |
| } |
| |
| #chat-messages .prose code:not(pre code) { |
| background-color: #e5e7eb; |
| padding: 0.2em 0.4em; |
| font-size: 85%; |
| border-radius: 4px; |
| font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; |
| } |
| #chat-messages .prose pre { |
| background-color: #f3f4f6; |
| padding: 0.8em 1em; |
| border-radius: 6px; |
| overflow-x: auto; |
| font-size: 0.875rem; |
| } |
| #chat-messages .prose pre code { |
| background-color: transparent; |
| padding: 0; |
| font-size: inherit; |
| border-radius: 0; |
| color: inherit; |
| } |
| #chat-messages .prose blockquote { |
| border-left-color: #9ca3af; |
| color: #4b5563; |
| } |
| #chat-messages .prose strong { |
| color: #1f2937; |
| } |
| #chat-messages .prose a { |
| color: #2563eb; |
| text-decoration: underline; |
| text-decoration-color: #93c5fd; |
| transition: color 0.2s ease; |
| } |
| #chat-messages .prose a:hover { |
| color: #1d4ed8; |
| text-decoration-color: #60a5fa; |
| } |
| #history-loading { |
| padding: 20px; |
| text-align: center; |
| color: #6b7280; |
| font-style: italic; |
| } |
| |
| #file-preview { |
| margin-top: 8px; |
| } |
| #file-preview img { |
| max-width: 100%; |
| max-height: 150px; |
| border: 1px solid #ddd; |
| border-radius: 4px; |
| padding: 4px; |
| } |
| |
| .copy-btn { |
| position: absolute; |
| top: 4px; |
| right: 4px; |
| background: rgba(255, 255, 255, 0.8); |
| border: none; |
| border-radius: 4px; |
| padding: 2px 4px; |
| cursor: pointer; |
| font-size: 0.75rem; |
| display: none; |
| } |
| |
| .message-wrapper:hover .copy-btn { |
| display: block; |
| } |
| </style> |
| </head> |
| <body class="bg-gray-100 flex flex-col min-h-screen"> |
| |
| <header class="bg-gradient-to-r from-cyan-500 to-blue-500 text-white p-3 sm:p-4 shadow-md flex justify-between items-center sticky top-0 z-10 flex-shrink-0"> |
| <h1 class="text-xl sm:text-2xl font-bold">Mariam AI!</h1> |
| <form action="/clear" method="POST" id="clear-form"> |
| <button type="submit" title="Effacer la conversation actuelle" class="bg-red-500 hover:bg-red-600 text-white text-xs font-semibold py-1 px-3 rounded-full transition duration-200 focus:outline-none focus:ring-2 focus:ring-red-400 focus:ring-opacity-75"> |
| Effacer |
| </button> |
| </form> |
| </header> |
|
|
| |
| <div id="chat-container" class="max-w-4xl w-full mx-auto bg-white shadow-xl rounded-b-lg flex flex-col flex-grow"> |
| |
| <div id="chat-messages" class="flex-grow overflow-y-auto p-4 sm:p-6 space-y-4 scroll-smooth"> |
| <div id="history-loading"> |
| <div class="flex justify-center items-center space-x-2"> |
| <svg class="animate-spin h-5 w-5 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> |
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| <span>Chargement de l'historique...</span> |
| </div> |
| </div> |
| <div id="loading-indicator" class="text-center text-gray-500 italic py-4" style="display: none;"> |
| <div class="flex justify-center items-center space-x-2"> |
| <svg class="animate-spin h-5 w-5 text-blue-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> |
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> |
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| <span>Mariam réfléchit...</span> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="error-message" class="bg-red-100 border-l-4 border-red-500 text-red-700 px-4 py-2 rounded mx-4 my-2 flex-shrink-0" role="alert" style="display: none;"> |
| <p class="font-bold mr-2">Erreur:</p> |
| <p id="error-text">Le message d'erreur détaillé ira ici.</p> |
| </div> |
|
|
| |
| <div class="bg-gray-50 border-t border-gray-200 px-4 py-2 flex-shrink-0"> |
| <div class="flex items-center justify-between text-sm"> |
| <label for="web_search_toggle" class="flex items-center space-x-2 cursor-pointer text-gray-600 hover:text-gray-800 select-none" title="Activer/Désactiver la recherche web pour le prochain message"> |
| <input type="checkbox" id="web_search_toggle" name="web_search" value="true" class="form-checkbox h-4 w-4 rounded text-blue-600 focus:ring-blue-500 focus:ring-offset-0 border-gray-300"> |
| <span class="hidden sm:inline">Recherche Web</span> |
| <span class="sm:hidden">Web</span> |
| </label> |
|
|
| <label for="advanced_reasoning_toggle" class="flex items-center space-x-2 cursor-pointer text-purple-600 hover:text-purple-800 select-none" title="Utiliser le raisonnement avancé (Gemini 1.5 Pro - 1 fois/min)"> |
| <input type="checkbox" id="advanced_reasoning_toggle" name="advanced_reasoning" value="true" class="form-checkbox h-4 w-4 rounded text-purple-600 focus:ring-purple-500 focus:ring-offset-0 border-gray-300"> |
| <span class="hidden sm:inline">Raisonnement Avancé</span> |
| <span class="sm:hidden">Avancé</span> |
| <span id="advanced-cooldown-timer" class="text-xs text-gray-500 ml-1" style="display: none;"></span> |
| </label> |
|
|
| <div class="flex items-center space-x-2"> |
| <label for="file_upload" class="cursor-pointer text-blue-600 hover:text-blue-700 font-medium flex items-center" title="Joindre un fichier (txt, pdf, png, jpg)"> |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /> |
| </svg> |
| <span class="hidden sm:inline">Fichier</span> |
| <input type="file" id="file_upload" name="file" class="hidden" accept=".txt,.pdf,.png,.jpg,.jpeg"> |
| </label> |
| <span id="file-name" class="text-gray-500 text-xs truncate max-w-[150px]" title=""></span> |
| <button id="clear-file" class="text-red-500 hover:text-red-700 text-xs p-0.5 rounded focus:outline-none focus:ring-1 focus:ring-red-400" title="Retirer le fichier" style="display: none;"> |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"> |
| <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /> |
| </svg> |
| </button> |
| </div> |
| </div> |
| |
| <div id="file-preview"></div> |
| </div> |
|
|
| |
| <form id="chat-form" class="bg-gray-100 p-3 sm:p-4 border-t border-gray-200 rounded-b-lg flex-shrink-0"> |
| <div class="flex items-center space-x-2 sm:space-x-3"> |
| <input type="text" id="prompt" name="prompt" class="flex-grow form-input px-4 py-2 border border-gray-300 rounded-full focus:outline-none focus:ring-2 focus:ring-blue-400 shadow-sm text-sm sm:text-base" placeholder="Posez votre question à Mariam..." autocomplete="off"> |
| <button type="submit" id="send-button" title="Envoyer le message" class="bg-blue-500 hover:bg-blue-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold p-2 rounded-full transition duration-200 flex items-center justify-center shadow-md w-10 h-10 flex-shrink-0 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:ring-opacity-75"> |
| <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor"> |
| <path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z" /> |
| </svg> |
| </button> |
| </div> |
| </form> |
| </div> |
|
|
| |
| <script> |
| document.addEventListener('DOMContentLoaded', () => { |
| const chatForm = document.getElementById('chat-form'); |
| const promptInput = document.getElementById('prompt'); |
| const chatMessages = document.getElementById('chat-messages'); |
| const loadingIndicator = document.getElementById('loading-indicator'); |
| const historyLoadingIndicator = document.getElementById('history-loading'); |
| const errorMessageDiv = document.getElementById('error-message'); |
| const errorTextP = document.getElementById('error-text'); |
| const webSearchToggle = document.getElementById('web_search_toggle'); |
| const fileUpload = document.getElementById('file_upload'); |
| const fileNameSpan = document.getElementById('file-name'); |
| const clearFileButton = document.getElementById('clear-file'); |
| const filePreview = document.getElementById('file-preview'); |
| const sendButton = document.getElementById('send-button'); |
| const clearForm = document.getElementById('clear-form'); |
| const advancedToggle = document.getElementById('advanced_reasoning_toggle'); |
| const advancedCooldownTimerSpan = document.getElementById('advanced-cooldown-timer'); |
| |
| const API_CHAT_ENDPOINT = '/api/chat'; |
| const API_HISTORY_ENDPOINT = '/api/history'; |
| const CLEAR_ENDPOINT = '/clear'; |
| |
| function scrollToBottom() { |
| setTimeout(() => { |
| chatMessages.scrollTop = chatMessages.scrollHeight; |
| }, 50); |
| } |
| |
| let advancedToggleCooldownEndTime = 0; |
| const COOLDOWN_DURATION = 60 * 1000; |
| |
| function showLoading(show) { |
| const currentlyLoading = loadingIndicator.style.display !== 'none'; |
| if (show && !currentlyLoading) { |
| loadingIndicator.style.display = 'block'; |
| chatMessages.appendChild(loadingIndicator); |
| scrollToBottom(); |
| } else if (!show && currentlyLoading) { |
| loadingIndicator.style.display = 'none'; |
| } |
| sendButton.disabled = show; |
| promptInput.disabled = show; |
| fileUpload.disabled = show; |
| clearFileButton.disabled = show; |
| |
| } |
| |
| function displayError(message) { |
| errorTextP.textContent = message || "Une erreur inconnue est survenue."; |
| errorMessageDiv.style.display = 'flex'; |
| errorMessageDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); |
| } |
| |
| function addMessageToChat(role, text, isHtml = false) { |
| errorMessageDiv.style.display = 'none'; |
| const messageWrapper = document.createElement('div'); |
| messageWrapper.classList.add('message-wrapper', 'flex', role === 'user' ? 'justify-end' : 'justify-start', 'mb-4'); |
| |
| const bubbleDiv = document.createElement('div'); |
| bubbleDiv.classList.add('p-3', 'rounded-lg', 'max-w-[85%]', 'sm:max-w-[75%]', 'shadow-md', 'relative'); |
| |
| if (role === 'user') { |
| bubbleDiv.classList.add('bg-blue-500', 'text-white', 'rounded-br-none'); |
| const paragraph = document.createElement('p'); |
| paragraph.classList.add('text-sm', 'sm:text-base', 'break-words'); |
| paragraph.textContent = text; |
| bubbleDiv.appendChild(paragraph); |
| } else { |
| bubbleDiv.classList.add('bg-gray-100', 'text-gray-800', 'rounded-bl-none', 'border', 'border-gray-200'); |
| const proseDiv = document.createElement('div'); |
| proseDiv.classList.add('prose', 'prose-sm', 'sm:prose-base', 'max-w-none', 'text-gray-800', 'prose-headings:font-semibold', 'prose-headings:text-gray-800', 'prose-a:text-blue-600', 'prose-a:no-underline', 'hover:prose-a:underline', 'prose-strong:text-gray-800', 'prose-code:text-red-600', 'prose-code:font-mono', 'prose-blockquote:text-gray-600', 'break-words'); |
| if (isHtml) { |
| proseDiv.innerHTML = text; |
| } else { |
| proseDiv.textContent = text; |
| } |
| bubbleDiv.appendChild(proseDiv); |
| |
| |
| const copyBtn = document.createElement('button'); |
| copyBtn.textContent = 'Copier'; |
| copyBtn.classList.add('copy-btn'); |
| copyBtn.title = "Copier la réponse"; |
| copyBtn.addEventListener('click', (e) => { |
| e.stopPropagation(); |
| e.preventDefault(); |
| |
| let textToCopy = ''; |
| if (isHtml) { |
| textToCopy = proseDiv.innerText; |
| } else { |
| textToCopy = text; |
| } |
| navigator.clipboard.writeText(textToCopy) |
| .then(() => { |
| copyBtn.textContent = 'Copié'; |
| setTimeout(() => { |
| copyBtn.textContent = 'Copier'; |
| }, 2000); |
| }) |
| .catch(err => { |
| console.error('Erreur lors de la copie :', err); |
| }); |
| }); |
| bubbleDiv.appendChild(copyBtn); |
| } |
| |
| messageWrapper.appendChild(bubbleDiv); |
| chatMessages.insertBefore(messageWrapper, loadingIndicator); |
| if (historyLoadingIndicator.parentNode !== chatMessages) { |
| scrollToBottom(); |
| } |
| } |
| |
| function startAdvancedCooldownTimer() { |
| advancedToggle.disabled = true; |
| advancedToggleCooldownEndTime = Date.now() + COOLDOWN_DURATION; |
| |
| const updateTimer = () => { |
| const now = Date.now(); |
| if (now >= advancedToggleCooldownEndTime) { |
| clearInterval(intervalId); |
| advancedCooldownTimerSpan.style.display = 'none'; |
| advancedToggle.disabled = false; |
| advancedToggleCooldownEndTime = 0; |
| } else { |
| const remainingSeconds = Math.ceil((advancedToggleCooldownEndTime - now) / 1000); |
| advancedCooldownTimerSpan.textContent = `(${remainingSeconds}s)`; |
| advancedCooldownTimerSpan.style.display = 'inline'; |
| } |
| }; |
| |
| const intervalId = setInterval(updateTimer, 1000); |
| updateTimer(); |
| } |
| |
| async function loadChatHistory() { |
| historyLoadingIndicator.style.display = 'block'; |
| try { |
| const response = await fetch(API_HISTORY_ENDPOINT); |
| if (!response.ok) { |
| let errorMsg = `Erreur serveur (${response.status})`; |
| try { |
| const errData = await response.json(); |
| errorMsg = errData.error || errorMsg; |
| } catch (e) { } |
| throw new Error(errorMsg); |
| } |
| const data = await response.json(); |
| if (data.success && Array.isArray(data.history)) { |
| chatMessages.innerHTML = ''; |
| chatMessages.appendChild(loadingIndicator); |
| loadingIndicator.style.display = 'none'; |
| if (data.history.length === 0) { |
| addMessageToChat('assistant', "Bonjour ! Comment puis-je vous aider aujourd'hui ?"); |
| } else { |
| data.history.forEach(message => { |
| const isAssistantHtml = message.role === 'assistant'; |
| addMessageToChat(message.role, message.text, isAssistantHtml); |
| }); |
| } |
| scrollToBottom(); |
| } else { |
| throw new Error(data.error || "Format de réponse de l'historique invalide."); |
| } |
| } catch (error) { |
| chatMessages.innerHTML = ''; |
| chatMessages.appendChild(loadingIndicator); |
| loadingIndicator.style.display = 'none'; |
| displayError(`Impossible de charger l'historique: ${error.message}`); |
| } finally { |
| if (historyLoadingIndicator.parentNode === chatMessages) { |
| historyLoadingIndicator.remove(); |
| } |
| promptInput.focus(); |
| } |
| } |
| |
| function clearFileInput() { |
| fileUpload.value = ''; |
| fileNameSpan.textContent = ''; |
| fileNameSpan.title = ''; |
| clearFileButton.style.display = 'none'; |
| filePreview.innerHTML = ''; |
| } |
| |
| fileUpload.addEventListener('change', () => { |
| if (fileUpload.files.length > 0) { |
| const file = fileUpload.files[0]; |
| const name = file.name; |
| fileNameSpan.textContent = name.length > 20 ? name.substring(0, 17) + '...' : name; |
| fileNameSpan.title = name; |
| clearFileButton.style.display = 'inline-block'; |
| |
| if (file.type.startsWith('image/')) { |
| const reader = new FileReader(); |
| reader.onload = (e) => { |
| filePreview.innerHTML = `<img src="${e.target.result}" alt="Prévisualisation de l'image">`; |
| }; |
| reader.readAsDataURL(file); |
| } else { |
| filePreview.innerHTML = ''; |
| } |
| } else { |
| clearFileInput(); |
| } |
| }); |
| |
| clearFileButton.addEventListener('click', () => { |
| clearFileInput(); |
| }); |
| |
| chatForm.addEventListener('submit', async (e) => { |
| e.preventDefault(); |
| const prompt = promptInput.value.trim(); |
| const file = fileUpload.files[0]; |
| const useWebSearch = webSearchToggle.checked; |
| const useAdvanced = advancedToggle.checked; |
| |
| if (!prompt && !file) { |
| displayError("Veuillez entrer un message ou sélectionner un fichier."); |
| promptInput.focus(); |
| return; |
| } |
| errorMessageDiv.style.display = 'none'; |
| |
| |
| if (useAdvanced) { |
| const now = Date.now(); |
| if (now < advancedToggleCooldownEndTime) { |
| const remainingSeconds = Math.ceil((advancedToggleCooldownEndTime - now) / 1000); |
| displayError(`Le raisonnement avancé est disponible dans ${remainingSeconds} seconde(s).`); |
| return; |
| } |
| |
| } |
| |
| |
| let userMessageText = prompt; |
| if (file && file.name) { |
| userMessageText = prompt ? `[${file.name}] ${prompt}` : `[${file.name}]`; |
| } |
| addMessageToChat('user', userMessageText); |
| const formData = new FormData(); |
| formData.append('prompt', prompt); |
| formData.append('web_search', useWebSearch); |
| if (file) { |
| formData.append('file', file); |
| } |
| |
| formData.append('advanced_reasoning', useAdvanced); |
| |
| showLoading(true); |
| promptInput.value = ''; |
| clearFileInput(); |
| |
| |
| |
| advancedToggle.checked = false; |
| if (useAdvanced) startAdvancedCooldownTimer(); |
| |
| try { |
| const response = await fetch(API_CHAT_ENDPOINT, { |
| method: 'POST', |
| body: formData, |
| }); |
| const data = await response.json(); |
| if (!response.ok) { |
| throw new Error(data.error || `Erreur serveur: ${response.status}`); |
| } |
| if (data.success && data.message) { |
| addMessageToChat('assistant', data.message, true); |
| } else { |
| throw new Error(data.error || "Réponse invalide ou vide du serveur."); |
| } |
| } catch (error) { |
| displayError(error.message); |
| if (useAdvanced) { } |
| } finally { |
| showLoading(false); |
| promptInput.focus(); |
| } |
| }); |
| |
| clearForm.addEventListener('submit', async (e) => { |
| e.preventDefault(); |
| if (confirm("Êtes-vous sûr de vouloir effacer toute la conversation ?")) { |
| const originalButtonText = e.target.querySelector('button').textContent; |
| e.target.querySelector('button').textContent = '...'; |
| e.target.querySelector('button').disabled = true; |
| try { |
| const response = await fetch(CLEAR_ENDPOINT, { |
| method: 'POST', |
| headers: { |
| 'X-Requested-With': 'XMLHttpRequest' |
| } |
| }); |
| const data = await response.json(); |
| if (response.ok && data.success) { |
| chatMessages.innerHTML = ''; |
| chatMessages.appendChild(loadingIndicator); |
| loadingIndicator.style.display = 'none'; |
| addMessageToChat('assistant', "Conversation effacée. Comment puis-je vous aider ?"); |
| errorMessageDiv.style.display = 'none'; |
| } else { |
| throw new Error(data.error || "Impossible d'effacer côté serveur."); |
| } |
| } catch (error) { |
| displayError(`Erreur lors de l'effacement du chat: ${error.message}`); |
| } finally { |
| e.target.querySelector('button').textContent = originalButtonText; |
| e.target.querySelector('button').disabled = false; |
| promptInput.focus(); |
| } |
| } |
| }); |
| |
| loadChatHistory(); |
| }); |
| </script> |
| </body> |
| </html> |