| <!DOCTYPE html> |
| <html lang="fr"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>AI Assistant</title> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/marked/4.3.0/marked.min.js"></script> |
| <script> |
| tailwind.config = { |
| darkMode: 'class', |
| theme: { |
| extend: { |
| colors: { |
| 'dark-bg': '#121212', |
| 'dark-card': '#1e1e1e', |
| 'dark-elevated': '#2c2c2c', |
| 'dark-border': '#333', |
| 'dark-text': '#f1f1f1', |
| 'dark-muted': '#aaa' |
| } |
| } |
| } |
| } |
| </script> |
| <style> |
| |
| .markdown-content { @apply text-dark-text leading-relaxed; } |
| .markdown-content h1, .markdown-content h2, .markdown-content h3 { @apply font-bold mb-2 mt-4; } |
| .markdown-content h1 { @apply text-xl; } |
| .markdown-content h2 { @apply text-lg; } |
| .markdown-content h3 { @apply text-base; } |
| .markdown-content p { @apply mb-2; } |
| .markdown-content ul, .markdown-content ol { @apply ml-4 mb-2 list-disc; } |
| .markdown-content li { @apply mb-1; } |
| .markdown-content code { @apply bg-gray-700 px-2 py-1 rounded text-sm font-mono; } |
| .markdown-content pre { @apply bg-gray-800 p-4 rounded-lg overflow-x-auto mb-4; } |
| .markdown-content pre code { @apply bg-transparent p-0; } |
| .markdown-content blockquote { @apply border-l-4 border-gray-600 pl-4 italic text-gray-300 mb-4; } |
| .markdown-content strong { @apply font-semibold; } |
| .markdown-content em { @apply italic; } |
| .markdown-content a { @apply text-blue-400 hover:text-blue-300 underline; } |
| |
| |
| @keyframes typing { 0%, 60%, 100% { transform: scale(0.8); opacity: 0.5; } 30% { transform: scale(1.2); opacity: 1; } } |
| @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } |
| .animate-typing { animation: typing 1.4s infinite ease-in-out; } |
| .animate-fade-in { animation: fadeIn 0.3s ease; } |
| |
| |
| .custom-scrollbar::-webkit-scrollbar { width: 8px; } |
| .custom-scrollbar::-webkit-scrollbar-track { background: #1e1e1e; } |
| .custom-scrollbar::-webkit-scrollbar-thumb { background: #555; border-radius: 4px; } |
| .custom-scrollbar::-webkit-scrollbar-thumb:hover { background: #777; } |
| |
| |
| .thoughts-toggle:checked + .thoughts-content { max-height: 1000px; opacity: 1; } |
| .thoughts-content { max-height: 0; opacity: 0; overflow: hidden; transition: max-height 0.3s ease, opacity 0.3s ease; } |
| </style> |
| </head> |
| <body class="bg-dark-bg text-dark-text min-h-screen flex justify-center items-center p-0 md:p-5"> |
| <div class="w-full max-w-4xl h-screen md:h-[90vh] bg-dark-card flex flex-col overflow-hidden md:rounded-xl md:border md:border-dark-border"> |
| |
| |
| <header class="flex justify-between items-center p-4 md:px-6 border-b border-dark-border flex-shrink-0"> |
| <h1 class="text-xl font-semibold">Mariam AI Assistant</h1> |
| <button class="bg-transparent border border-gray-600 text-gray-300 px-4 py-2 rounded-full text-sm hover:bg-gray-700 transition-colors" onclick="resetConversation()"> |
| 🔄 Reset |
| </button> |
| </header> |
|
|
| |
| <div class="flex-1 overflow-y-auto p-4 md:p-6 flex flex-col gap-5 custom-scrollbar" id="messages"> |
| <div class="max-w-[85%] p-4 rounded-2xl bg-dark-elevated text-gray-200 self-start rounded-bl-sm animate-fade-in"> |
| <div class="markdown-content">👋 Bonjour, sur quoi allons-nous travailler aujourd'hui ?</div> |
| </div> |
| </div> |
|
|
| |
| <div class="px-4 md:px-6 pb-2 hidden items-center gap-2 text-gray-500 text-sm italic" id="typingIndicator"> |
| <span>L'assistant réfléchit</span> |
| <div class="flex gap-1"> |
| <span class="w-2 h-2 rounded-full bg-gray-600 animate-typing"></span> |
| <span class="w-2 h-2 rounded-full bg-gray-600 animate-typing" style="animation-delay: 0.2s;"></span> |
| <span class="w-2 h-2 rounded-full bg-gray-600 animate-typing" style="animation-delay: 0.4s;"></span> |
| </div> |
| </div> |
|
|
| |
| <div class="p-4 md:p-6 bg-dark-card border-t border-dark-border flex-shrink-0"> |
| <div class="mb-3 p-3 bg-dark-elevated rounded-lg text-sm hidden" id="filePreview"></div> |
| <div class="flex gap-3 items-end"> |
| <input type="file" id="fileInput" class="hidden" accept="image/*,video/*,.pdf,.txt,.csv,.json" multiple onchange="handleFileSelect(event)"> |
| <button class="bg-gray-700 text-white h-12 w-12 rounded-full hover:bg-gray-600 transition-colors flex-shrink-0 flex items-center justify-center text-xl" onclick="document.getElementById('fileInput').click()" title="Joindre un fichier"> |
| 📎 |
| </button> |
| <div class="flex-1 relative"> |
| <textarea |
| id="messageInput" |
| class="w-full min-h-[48px] p-3 pr-14 border border-gray-600 rounded-3xl text-base resize-none outline-none transition-colors bg-dark-elevated text-dark-text placeholder-gray-500 focus:border-gray-500" |
| placeholder="Tapez votre message ici..." |
| rows="1" |
| onkeydown="handleKeyDown(event)" |
| oninput="autoResize(this)" |
| ></textarea> |
| <button class="absolute right-1 top-1/2 transform -translate-y-1/2 bg-gray-700 hover:bg-gray-600 disabled:bg-gray-800 disabled:text-gray-600 border-none rounded-full w-10 h-10 text-white cursor-pointer transition-colors flex items-center justify-center text-lg" id="sendButton" onclick="sendMessage()" title="Envoyer"> |
| ➤ |
| </button> |
| </div> |
| </div> |
| </div> |
| </div> |
|
|
| <script> |
| let currentFiles = []; |
| let conversationId = 'session_' + Date.now(); |
| |
| marked.setOptions({ |
| breaks: true, |
| gfm: true |
| }); |
| |
| function autoResize(textarea) { |
| textarea.style.height = 'auto'; |
| textarea.style.height = Math.min(textarea.scrollHeight, 150) + 'px'; |
| } |
| |
| function handleKeyDown(event) { |
| if (event.key === 'Enter' && !event.shiftKey) { |
| event.preventDefault(); |
| sendMessage(); |
| } |
| } |
| |
| function handleFileSelect(event) { |
| const files = Array.from(event.target.files); |
| if (files.length === 0) return; |
| |
| const filePreview = document.getElementById('filePreview'); |
| currentFiles = []; |
| let previews = []; |
| |
| files.forEach((file, index) => { |
| const formData = new FormData(); |
| formData.append('file', file); |
| |
| if (file.type.startsWith('image/')) { |
| const reader = new FileReader(); |
| reader.onload = function(e) { |
| previews[index] = ` |
| <div class="flex items-center gap-3"> |
| <img src="${e.target.result}" class="w-16 h-16 object-cover rounded-lg border border-gray-600"> |
| <div class="text-gray-500 text-xs">${(file.size / 1024).toFixed(1)} KB</div> |
| </div> |
| `; |
| updatePreview(); |
| }; |
| reader.readAsDataURL(file); |
| } else { |
| previews[index] = ` |
| <div class="flex items-center gap-3"> |
| <span class="text-gray-300">📎</span> |
| <div class="text-gray-500 text-xs">${(file.size / 1024).toFixed(1)} KB</div> |
| </div> |
| `; |
| updatePreview(); |
| } |
| |
| fetch('/upload', { |
| method: 'POST', |
| body: formData |
| }) |
| .then(response => response.json()) |
| .then(data => { |
| if (data.success) { |
| currentFiles.push(data); |
| } else { |
| showError('Erreur lors du téléchargement: ' + data.error); |
| } |
| }) |
| .catch(error => { |
| showError('Erreur lors du téléchargement: ' + error.message); |
| }); |
| }); |
| |
| function updatePreview() { |
| if (previews.filter(p => p).length === files.length) { |
| filePreview.innerHTML = ` |
| <div class="flex flex-wrap gap-3 items-center"> |
| ${previews.join('')} |
| <button onclick="removeFile()" class="bg-gray-600 hover:bg-gray-500 text-white px-3 py-1 rounded transition-colors">✕</button> |
| </div> |
| `; |
| filePreview.classList.remove('hidden'); |
| } |
| } |
| } |
| |
| function removeFile() { |
| currentFiles = []; |
| document.getElementById('filePreview').classList.add('hidden'); |
| document.getElementById('fileInput').value = ''; |
| } |
| |
| function sendMessage() { |
| const messageInput = document.getElementById('messageInput'); |
| const message = messageInput.value.trim(); |
| |
| if (!message && currentFiles.length === 0) return; |
| |
| const sendButton = document.getElementById('sendButton'); |
| const typingIndicator = document.getElementById('typingIndicator'); |
| |
| messageInput.disabled = true; |
| sendButton.disabled = true; |
| |
| if (message || currentFiles.length > 0) { |
| addMessage('user', message, currentFiles); |
| } |
| |
| typingIndicator.classList.remove('hidden'); |
| typingIndicator.classList.add('flex'); |
| |
| messageInput.value = ''; |
| autoResize(messageInput); |
| |
| |
| const thinkingEnabled = true; |
| const endpoint = currentFiles.length > 0 ? '/chat_with_file' : '/chat'; |
| const payload = { |
| message: message || 'Analyse ces fichiers', |
| thinking_enabled: thinkingEnabled, |
| conversation_id: conversationId |
| }; |
| |
| if (currentFiles.length > 0) { |
| payload.file_data = currentFiles; |
| } |
| |
| fetch(endpoint, { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify(payload) |
| }) |
| .then(response => { |
| if (!response.ok) { |
| throw new Error(`Erreur réseau: ${response.status} ${response.statusText}`); |
| } |
| |
| const reader = response.body.getReader(); |
| const decoder = new TextDecoder(); |
| let buffer = ''; |
| let messageElement = null; |
| let thoughtsElement = null; |
| let thoughtsContainer = null; |
| let messageContent = ''; |
| let thoughtsContent = ''; |
| |
| function processStream() { |
| return reader.read().then(({ done, value }) => { |
| if (done) { return; } |
| |
| buffer += decoder.decode(value, { stream: true }); |
| const lines = buffer.split('\n'); |
| buffer = lines.pop() || ''; |
| |
| for (const line of lines) { |
| if (line.startsWith('data: ')) { |
| try { |
| const data = JSON.parse(line.substring(6)); |
| |
| if (data.type === 'text') { |
| messageContent += data.content; |
| if (!messageElement) { |
| messageElement = addMessage('assistant', ''); |
| } |
| const markdownHtml = marked.parse(messageContent); |
| const markdownElement = messageElement.querySelector('.markdown-content'); |
| if (markdownElement) { |
| markdownElement.innerHTML = markdownHtml; |
| markdownElement.style.display = 'block'; |
| } |
| } else if (data.type === 'thought' && thinkingEnabled) { |
| thoughtsContent += data.content; |
| if (!thoughtsContainer) { |
| thoughtsContainer = addThoughtsContainer(); |
| } |
| const thoughtsHtml = marked.parse(thoughtsContent); |
| thoughtsContainer.querySelector('.thoughts-content .markdown-content').innerHTML = thoughtsHtml; |
| } else if (data.type === 'error') { |
| showError(data.content); |
| } else if (data.type === 'end') { |
| typingIndicator.classList.add('hidden'); |
| typingIndicator.classList.remove('flex'); |
| messageInput.disabled = false; |
| sendButton.disabled = false; |
| messageInput.focus(); |
| removeFile(); |
| return; |
| } |
| |
| const messagesContainer = document.getElementById('messages'); |
| messagesContainer.scrollTop = messagesContainer.scrollHeight; |
| |
| } catch (e) { |
| console.error('Erreur parsing JSON:', e, "Ligne reçue:", line); |
| } |
| } |
| } |
| return processStream(); |
| }); |
| } |
| return processStream(); |
| }) |
| .catch(error => { |
| typingIndicator.classList.add('hidden'); |
| typingIndicator.classList.remove('flex'); |
| messageInput.disabled = false; |
| sendButton.disabled = false; |
| showError('Erreur: ' + error.message); |
| messageInput.focus(); |
| }); |
| } |
| |
| function addMessage(role, content, fileData = null) { |
| const messagesContainer = document.getElementById('messages'); |
| const messageDiv = document.createElement('div'); |
| let messageContent = ''; |
| |
| if (fileData) { |
| const files = Array.isArray(fileData) ? fileData : [fileData]; |
| files.forEach(file => { |
| if (file.mime_type && file.mime_type.startsWith('image/')) { |
| const imageUrl = `data:${file.mime_type};base64,${file.data}`; |
| messageContent += `<img src="${imageUrl}" class="max-w-full h-auto rounded-lg mb-2 border border-gray-600">`; |
| } |
| }); |
| } |
| |
| const markdownHtml = marked.parse(content || ''); |
| messageContent += `<div class="markdown-content">${markdownHtml}</div>`; |
| |
| messageDiv.className = role === 'user' |
| ? 'max-w-[85%] p-4 rounded-2xl bg-gray-600 text-gray-100 self-end rounded-br-sm animate-fade-in' |
| : 'max-w-[85%] p-4 rounded-2xl bg-dark-elevated text-gray-200 self-start rounded-bl-sm animate-fade-in'; |
| |
| messageDiv.innerHTML = messageContent; |
| messagesContainer.appendChild(messageDiv); |
| messagesContainer.scrollTop = messagesContainer.scrollHeight; |
| return messageDiv; |
| } |
| |
| function addThoughtsContainer() { |
| const messagesContainer = document.getElementById('messages'); |
| const thoughtsDiv = document.createElement('div'); |
| thoughtsDiv.className = 'bg-gray-800 border-l-4 border-gray-600 rounded-lg p-4 mb-2 max-w-[90%] self-start'; |
| thoughtsDiv.innerHTML = ` |
| <div class="flex items-center justify-between cursor-pointer" onclick="toggleThoughts(this)"> |
| <div class="flex items-center gap-2 font-medium text-gray-300"> |
| <span>🧠</span> |
| <span>Réflexion de l'assistant</span> |
| </div> |
| <svg class="w-5 h-5 text-gray-400 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path> |
| </svg> |
| </div> |
| <input type="checkbox" class="thoughts-toggle hidden"> |
| <div class="thoughts-content mt-3 text-sm text-gray-400 italic"> |
| <div class="markdown-content"></div> |
| </div> |
| `; |
| messagesContainer.appendChild(thoughtsDiv); |
| messagesContainer.scrollTop = messagesContainer.scrollHeight; |
| return thoughtsDiv; |
| } |
| |
| function toggleThoughts(element) { |
| const container = element.parentElement; |
| const checkbox = container.querySelector('.thoughts-toggle'); |
| const arrow = element.querySelector('svg'); |
| |
| checkbox.checked = !checkbox.checked; |
| arrow.style.transform = checkbox.checked ? 'rotate(180deg)' : 'rotate(0deg)'; |
| } |
| |
| function showError(message) { |
| const messagesContainer = document.getElementById('messages'); |
| const errorDiv = document.createElement('div'); |
| errorDiv.className = 'bg-red-900 text-red-200 border-l-4 border-red-600 p-4 rounded-lg mb-2'; |
| errorDiv.textContent = message; |
| messagesContainer.appendChild(errorDiv); |
| messagesContainer.scrollTop = messagesContainer.scrollHeight; |
| } |
| |
| function resetConversation() { |
| if (confirm('Êtes-vous sûr de vouloir réinitialiser la conversation ?')) { |
| fetch('/reset_conversation', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ conversation_id: conversationId }) |
| }) |
| .then(response => { |
| if (response.ok) { |
| document.getElementById('messages').innerHTML = ` |
| <div class="max-w-[85%] p-4 rounded-2xl bg-dark-elevated text-gray-200 self-start rounded-bl-sm animate-fade-in"> |
| <div class="markdown-content">👋 Conversation réinitialisée ! Comment puis-je vous aider ?</div> |
| </div> |
| `; |
| conversationId = 'session_' + Date.now(); |
| removeFile(); |
| } else { |
| showError('La réinitialisation a échoué.'); |
| } |
| }) |
| .catch(error => { |
| showError('Erreur lors de la réinitialisation: ' + error.message); |
| }); |
| } |
| } |
| |
| document.addEventListener('DOMContentLoaded', function() { |
| document.getElementById('messageInput').focus(); |
| }); |
| </script> |
| </body> |
| </html> |