|
<!DOCTYPE html> |
|
<html lang="fr"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<meta name="description" content="Assistant intelligent pour la résolution détaillée de problèmes mathématiques"> |
|
<title>Mariam - Résolution de Problèmes Mathématiques</title> |
|
|
|
|
|
<link rel="preload" href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" as="style"> |
|
<link rel="preload" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" as="style"> |
|
|
|
|
|
<script src="https://cdn.tailwindcss.com"></script> |
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"> |
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet"> |
|
|
|
<style> |
|
|
|
:root { |
|
--primary-color: #3b82f6; |
|
--primary-dark: #2563eb; |
|
--error-color: #ef4444; |
|
--success-color: #22c55e; |
|
--border-color: #d1d5db; |
|
} |
|
|
|
body { |
|
font-family: 'Poppins', system-ui, sans-serif; |
|
background-color: #f9fafb; |
|
} |
|
|
|
|
|
.dropzone { |
|
border: 3px dashed var(--primary-color); |
|
transition: all 0.2s ease; |
|
border-radius: 1rem; |
|
padding: 2rem; |
|
background-color: rgba(59, 130, 246, 0.05); |
|
min-height: 200px; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
} |
|
|
|
.dropzone:hover, .dropzone.drag-active { |
|
border-color: var(--primary-dark); |
|
background-color: rgba(59, 130, 246, 0.1); |
|
transform: scale(1.01); |
|
} |
|
|
|
|
|
.btn { |
|
padding: 0.75rem 1.5rem; |
|
border-radius: 0.5rem; |
|
font-weight: 600; |
|
transition: all 0.2s ease; |
|
display: inline-flex; |
|
align-items: center; |
|
gap: 0.5rem; |
|
cursor: pointer; |
|
} |
|
|
|
.btn:disabled { |
|
opacity: 0.6; |
|
cursor: not-allowed; |
|
} |
|
|
|
.btn-primary { |
|
background-color: var(--primary-color); |
|
color: white; |
|
} |
|
|
|
.btn-primary:hover:not(:disabled) { |
|
background-color: var(--primary-dark); |
|
transform: translateY(-1px); |
|
} |
|
|
|
.btn-danger { |
|
background-color: var(--error-color); |
|
color: white; |
|
} |
|
|
|
.btn-danger:hover { |
|
background-color: #dc2626; |
|
transform: translateY(-1px); |
|
} |
|
|
|
|
|
.math-content { |
|
font-size: 1.1em; |
|
line-height: 1.6; |
|
overflow-x: auto; |
|
opacity: 0; |
|
transition: opacity 0.3s ease; |
|
} |
|
|
|
.math-content.visible { |
|
opacity: 1; |
|
} |
|
|
|
.math-content p { |
|
margin-bottom: 1rem; |
|
white-space: pre-wrap; |
|
} |
|
|
|
|
|
.form-input { |
|
width: 100%; |
|
padding: 0.75rem 1rem; |
|
border: 2px solid var(--border-color); |
|
border-radius: 0.5rem; |
|
transition: all 0.2s ease; |
|
} |
|
|
|
.form-input:focus { |
|
border-color: var(--primary-color); |
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.3); |
|
outline: none; |
|
} |
|
|
|
|
|
@keyframes pulse { |
|
0%, 100% { transform: scale(1); } |
|
50% { transform: scale(1.1); } |
|
} |
|
|
|
.loading-spinner { |
|
width: 3rem; |
|
height: 3rem; |
|
border: 4px solid var(--primary-color); |
|
border-radius: 50%; |
|
border-top-color: transparent; |
|
animation: spin 1s linear infinite; |
|
} |
|
|
|
@keyframes spin { |
|
to { transform: rotate(360deg); } |
|
} |
|
|
|
|
|
@media (max-width: 640px) { |
|
.container { |
|
padding: 1rem; |
|
} |
|
|
|
.btn { |
|
width: 100%; |
|
justify-content: center; |
|
} |
|
|
|
.dropzone { |
|
padding: 1.5rem; |
|
} |
|
} |
|
|
|
|
|
.sr-only { |
|
position: absolute; |
|
width: 1px; |
|
height: 1px; |
|
padding: 0; |
|
margin: -1px; |
|
overflow: hidden; |
|
clip: rect(0, 0, 0, 0); |
|
white-space: nowrap; |
|
border: 0; |
|
} |
|
|
|
|
|
.toast { |
|
position: fixed; |
|
bottom: 1rem; |
|
right: 1rem; |
|
padding: 1rem; |
|
border-radius: 0.5rem; |
|
background: white; |
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); |
|
z-index: 50; |
|
max-width: 24rem; |
|
transform: translateY(100%); |
|
opacity: 0; |
|
transition: all 0.3s ease; |
|
} |
|
|
|
.toast.show { |
|
transform: translateY(0); |
|
opacity: 1; |
|
} |
|
</style> |
|
</head> |
|
|
|
<body> |
|
<div class="container mx-auto px-4 py-8 max-w-4xl"> |
|
|
|
<header class="text-center mb-12"> |
|
<h1 class="text-4xl font-bold mb-4"> |
|
<span class="bg-gradient-to-r from-blue-500 to-blue-700 text-transparent bg-clip-text">Mariam</span> |
|
- Résolution de Problèmes Mathématiques |
|
</h1> |
|
<p class="text-gray-600 text-lg">Assistant intelligent pour des solutions mathématiques détaillées</p> |
|
</header> |
|
|
|
|
|
<form id="uploadForm" class="space-y-6"> |
|
<div id="dropzone" class="dropzone"> |
|
<input type="file" id="fileInput" class="sr-only" accept="image/*"> |
|
<div class="text-center"> |
|
<i class="fas fa-cloud-upload-alt text-5xl text-blue-500 mb-4"></i> |
|
<p class="text-lg text-gray-700"> |
|
Glissez votre image ici ou <button type="button" class="text-blue-500 font-semibold hover:text-blue-700">parcourez vos fichiers</button> |
|
</p> |
|
<p class="text-sm text-gray-500 mt-2">Formats acceptés: PNG, JPG, JPEG</p> |
|
</div> |
|
</div> |
|
|
|
<div class="space-y-4"> |
|
<div> |
|
<label for="customInstruction" class="block text-gray-700 font-medium mb-2"> |
|
Instruction personnalisée (optionnel) |
|
</label> |
|
<input type="text" id="customInstruction" class="form-input" |
|
placeholder="Exemple : Résoudre en utilisant le théorème de Pythagore"> |
|
</div> |
|
|
|
<div class="flex flex-col sm:flex-row gap-4"> |
|
<select id="modelChoice" class="form-input"> |
|
<option value="mariam's">Mariam's (Ultra performant)</option> |
|
<option value="qwen2">Qwen2 (lent et performant)</option> |
|
</select> |
|
|
|
<button type="submit" class="btn btn-primary" disabled> |
|
<i class="fas fa-paper-plane"></i> |
|
<span>Analyser l'image</span> |
|
</button> |
|
</div> |
|
</div> |
|
</form> |
|
|
|
|
|
<div id="loading" class="hidden mt-8 text-center"> |
|
<div class="loading-spinner mx-auto mb-4"></div> |
|
<p class="text-gray-600 font-medium">Analyse en cours...</p> |
|
</div> |
|
|
|
|
|
<div id="response" class="hidden mt-8"> |
|
<div class="bg-white rounded-xl shadow-lg p-6"> |
|
<h2 class="text-2xl font-semibold text-blue-600 mb-4"> |
|
Solution (Modèle: <span id="modelName"></span>) |
|
</h2> |
|
<div id="latexContent" class="math-content"></div> |
|
</div> |
|
</div> |
|
|
|
|
|
<section id="savedResponsesSection" class="mt-8"> |
|
<div class="flex justify-between items-center mb-4"> |
|
<h2 class="text-2xl font-semibold text-blue-600">Réponses Sauvegardées</h2> |
|
<button id="clearSavedResponses" class="btn btn-danger"> |
|
<i class="fas fa-trash-alt"></i> |
|
<span>Effacer Tout</span> |
|
</button> |
|
</div> |
|
<div id="savedResponses" class="space-y-4"></div> |
|
</section> |
|
|
|
|
|
<div id="toastContainer"></div> |
|
</div> |
|
|
|
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js" defer></script> |
|
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11" defer></script> |
|
<script src="https://cdn.jsdelivr.net/npm/localforage@1.10.0/dist/localforage.min.js" defer></script> |
|
<script> |
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
|
window.MathJax = { |
|
tex: { |
|
inlineMath: [['$', '$'], ['\\(', '\\)']], |
|
displayMath: [['$$', '$$'], ['\\[', '\\]']], |
|
processEscapes: true, |
|
macros: { |
|
R: "{\\mathbb{R}}", |
|
N: "{\\mathbb{N}}", |
|
Z: "{\\mathbb{Z}}", |
|
vecv: ["\\begin{pmatrix}#1\\\\#2\\\\#3\\end{pmatrix}", 3] |
|
} |
|
}, |
|
svg: { |
|
fontCache: 'global' |
|
} |
|
}; |
|
|
|
|
|
const loadMathJax = () => { |
|
const script = document.createElement('script'); |
|
script.src = 'https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js'; |
|
script.async = true; |
|
document.head.appendChild(script); |
|
return new Promise(resolve => script.onload = resolve); |
|
}; |
|
|
|
|
|
const showToast = (message, type = 'info') => { |
|
const toast = document.createElement('div'); |
|
toast.className = `toast ${type}`; |
|
toast.innerHTML = ` |
|
<div class="flex items-center"> |
|
<i class="fas fa-${type === 'error' ? 'exclamation-circle' : 'info-circle'} mr-2"></i> |
|
<p>${message}</p> |
|
</div> |
|
`; |
|
document.getElementById('toastContainer').appendChild(toast); |
|
requestAnimationFrame(() => toast.classList.add('show')); |
|
setTimeout(() => { |
|
toast.classList.remove('show'); |
|
setTimeout(() => toast.remove(), 300); |
|
}, 3000); |
|
}; |
|
|
|
|
|
const handleFile = file => { |
|
if (!file.type.startsWith('image/')) { |
|
showToast('Veuillez sélectionner une image valide', 'error'); |
|
return false; |
|
} |
|
|
|
const maxSize = 10 * 1024 * 1024; |
|
if (file.size > maxSize) { |
|
showToast('L\'image est trop volumineuse. Maximum 10MB.', 'error'); |
|
return false; |
|
} |
|
|
|
return true; |
|
}; |
|
|
|
|
|
const init = async () => { |
|
const dropzone = document.getElementById('dropzone'); |
|
const fileInput = document.getElementById('fileInput'); |
|
const form = document.getElementById('uploadForm'); |
|
const submitBtn = form.querySelector('button[type="submit"]'); |
|
|
|
|
|
dropzone.addEventListener('dragenter', e => { |
|
e.preventDefault(); |
|
dropzone.classList.add('drag-active'); |
|
}); |
|
|
|
dropzone.addEventListener('dragleave', e => { |
|
e.preventDefault(); |
|
dropzone.classList.remove('drag-active'); |
|
}); |
|
|
|
dropzone.addEventListener('dragover', e => { |
|
e.preventDefault(); |
|
}); |
|
|
|
dropzone.addEventListener('drop', e => { |
|
e.preventDefault(); |
|
dropzone.classList.remove('drag-active'); |
|
const file = e.dataTransfer.files[0]; |
|
if (handleFile(file)) { |
|
fileInput.files = e.dataTransfer.files; |
|
handleFileSelect(file); |
|
} |
|
}); |
|
|
|
|
|
const handleFileSelect = file => { |
|
const reader = new FileReader(); |
|
reader.onload = e => { |
|
const preview = dropzone.querySelector('img') || document.createElement('img'); |
|
preview.src = e.target.result; |
|
preview.className = 'max-h-48 mx-auto mt-4 rounded-lg'; |
|
if (!dropzone.querySelector('img')) { |
|
dropzone.appendChild(preview); |
|
} |
|
submitBtn.disabled = false; |
|
}; |
|
reader.readAsDataURL(file); |
|
}; |
|
|
|
fileInput.addEventListener('change', e => { |
|
const file = e.target.files[0]; |
|
if (handleFile(file)) { |
|
handleFileSelect(file); |
|
} |
|
}); |
|
|
|
|
|
form.addEventListener('submit', async e => { |
|
e.preventDefault(); |
|
if (!fileInput.files.length) { |
|
showToast('Veuillez sélectionner une image', 'error'); |
|
return; |
|
} |
|
|
|
const formData = new FormData(form); |
|
submitBtn.disabled = true; |
|
|
|
try { |
|
document.getElementById('loading').classList.remove('hidden'); |
|
document.getElementById('response').classList.add('hidden'); |
|
|
|
const response = await fetch('/upload', { |
|
method: 'POST', |
|
body: formData |
|
}); |
|
|
|
const data = await response.json(); |
|
if (!response.ok) throw new Error(data.error || 'Erreur serveur'); |
|
|
|
await renderMathContent(data.result); |
|
document.getElementById('modelName').textContent = data.model; |
|
await saveResponse(data.result, data.model); |
|
|
|
|
|
if (data.image_paths?.length) { |
|
const imageContainer = document.createElement('div'); |
|
imageContainer.className = 'mt-4 grid gap-4 grid-cols-1 sm:grid-cols-2'; |
|
|
|
for (const path of data.image_paths) { |
|
const figure = document.createElement('figure'); |
|
figure.className = 'relative group'; |
|
|
|
const img = document.createElement('img'); |
|
img.src = `/temp/${path.split('/').pop()}`; |
|
img.className = 'w-full h-auto rounded-lg shadow-md transition-transform hover:scale-105'; |
|
img.loading = 'lazy'; |
|
|
|
figure.appendChild(img); |
|
imageContainer.appendChild(figure); |
|
} |
|
|
|
document.getElementById('latexContent').appendChild(imageContainer); |
|
} |
|
|
|
showToast('Analyse terminée avec succès', 'success'); |
|
} catch (error) { |
|
console.error('Erreur:', error); |
|
showToast(error.message || 'Une erreur est survenue', 'error'); |
|
} finally { |
|
document.getElementById('loading').classList.add('hidden'); |
|
submitBtn.disabled = false; |
|
} |
|
}); |
|
|
|
|
|
const renderMathContent = async (text) => { |
|
const latexContent = document.getElementById('latexContent'); |
|
try { |
|
latexContent.innerHTML = ''; |
|
latexContent.classList.remove('visible'); |
|
|
|
|
|
const htmlContent = marked.parse(text, { |
|
breaks: true, |
|
gfm: true, |
|
sanitize: true |
|
}); |
|
|
|
latexContent.innerHTML = htmlContent; |
|
await MathJax.typesetPromise([latexContent]); |
|
|
|
document.getElementById('response').classList.remove('hidden'); |
|
requestAnimationFrame(() => latexContent.classList.add('visible')); |
|
} catch (error) { |
|
console.error('Erreur de rendu:', error); |
|
showToast('Erreur lors du rendu mathématique', 'error'); |
|
latexContent.innerHTML = ` |
|
<div class="text-red-600 mb-4">Une erreur s'est produite lors du rendu. Voici le texte brut :</div> |
|
<pre class="bg-gray-100 p-4 rounded-lg overflow-x-auto">${text}</pre> |
|
`; |
|
} |
|
}; |
|
|
|
|
|
const saveResponse = async (response, model) => { |
|
try { |
|
const timestamp = Date.now(); |
|
const key = `response-${timestamp}-${model}`; |
|
await localforage.setItem(key, { |
|
content: response, |
|
model, |
|
timestamp, |
|
id: key |
|
}); |
|
await loadSavedResponses(); |
|
} catch (error) { |
|
console.error('Erreur de sauvegarde:', error); |
|
showToast('Erreur lors de la sauvegarde', 'error'); |
|
} |
|
}; |
|
|
|
const loadSavedResponses = async () => { |
|
const container = document.getElementById('savedResponses'); |
|
container.innerHTML = ''; |
|
|
|
try { |
|
const keys = await localforage.keys(); |
|
const responses = await Promise.all( |
|
keys.map(async key => { |
|
const data = await localforage.getItem(key); |
|
return { ...data, key }; |
|
}) |
|
); |
|
|
|
|
|
responses.sort((a, b) => b.timestamp - a.timestamp); |
|
|
|
for (const response of responses) { |
|
const element = createResponseElement(response); |
|
container.appendChild(element); |
|
} |
|
|
|
if (responses.length === 0) { |
|
container.innerHTML = ` |
|
<div class="text-center text-gray-500 py-8"> |
|
<i class="fas fa-inbox text-4xl mb-4"></i> |
|
<p>Aucune réponse sauvegardée</p> |
|
</div> |
|
`; |
|
} |
|
} catch (error) { |
|
console.error('Erreur de chargement:', error); |
|
showToast('Erreur lors du chargement des réponses', 'error'); |
|
} |
|
}; |
|
|
|
const createResponseElement = (response) => { |
|
const element = document.createElement('div'); |
|
element.className = 'bg-white rounded-lg shadow-md overflow-hidden'; |
|
|
|
const header = document.createElement('div'); |
|
header.className = 'flex items-center justify-between p-4 bg-gray-50'; |
|
|
|
const date = new Date(response.timestamp).toLocaleString('fr-FR', { |
|
dateStyle: 'medium', |
|
timeStyle: 'short' |
|
}); |
|
|
|
header.innerHTML = ` |
|
<div class="flex items-center space-x-2"> |
|
<span class="font-medium text-gray-700">${date}</span> |
|
<span class="text-sm text-gray-500">(${response.model})</span> |
|
</div> |
|
<div class="flex space-x-2"> |
|
<button class="btn-toggle p-2 rounded-full hover:bg-gray-200 transition-colors"> |
|
<i class="fas fa-chevron-down"></i> |
|
</button> |
|
<button class="btn-delete p-2 rounded-full hover:bg-red-100 text-red-500 transition-colors"> |
|
<i class="fas fa-trash-alt"></i> |
|
</button> |
|
</div> |
|
`; |
|
|
|
const content = document.createElement('div'); |
|
content.className = 'p-4 math-content hidden'; |
|
content.innerHTML = marked.parse(response.content); |
|
|
|
element.appendChild(header); |
|
element.appendChild(content); |
|
|
|
|
|
header.querySelector('.btn-toggle').addEventListener('click', async (e) => { |
|
const icon = e.currentTarget.querySelector('i'); |
|
icon.classList.toggle('fa-chevron-down'); |
|
icon.classList.toggle('fa-chevron-up'); |
|
content.classList.toggle('hidden'); |
|
|
|
if (!content.classList.contains('hidden')) { |
|
await MathJax.typesetPromise([content]); |
|
} |
|
}); |
|
|
|
header.querySelector('.btn-delete').addEventListener('click', async () => { |
|
if (await confirmDelete()) { |
|
try { |
|
await localforage.removeItem(response.key); |
|
element.remove(); |
|
showToast('Réponse supprimée', 'success'); |
|
} catch (error) { |
|
console.error('Erreur de suppression:', error); |
|
showToast('Erreur lors de la suppression', 'error'); |
|
} |
|
} |
|
}); |
|
|
|
return element; |
|
}; |
|
|
|
const confirmDelete = () => { |
|
return Swal.fire({ |
|
title: 'Confirmer la suppression', |
|
text: 'Cette action est irréversible', |
|
icon: 'warning', |
|
showCancelButton: true, |
|
confirmButtonColor: '#ef4444', |
|
cancelButtonColor: '#6b7280', |
|
confirmButtonText: 'Supprimer', |
|
cancelButtonText: 'Annuler' |
|
}).then(result => result.isConfirmed); |
|
}; |
|
|
|
|
|
await loadMathJax(); |
|
await loadSavedResponses(); |
|
|
|
|
|
document.getElementById('clearSavedResponses').addEventListener('click', async () => { |
|
const result = await Swal.fire({ |
|
title: 'Tout effacer ?', |
|
text: 'Cette action supprimera toutes vos réponses sauvegardées', |
|
icon: 'warning', |
|
showCancelButton: true, |
|
confirmButtonColor: '#ef4444', |
|
cancelButtonColor: '#6b7280', |
|
confirmButtonText: 'Tout effacer', |
|
cancelButtonText: 'Annuler' |
|
}); |
|
|
|
if (result.isConfirmed) { |
|
try { |
|
await localforage.clear(); |
|
await loadSavedResponses(); |
|
showToast('Toutes les réponses ont été supprimées', 'success'); |
|
} catch (error) { |
|
console.error('Erreur de suppression:', error); |
|
showToast('Erreur lors de la suppression', 'error'); |
|
} |
|
} |
|
}); |
|
}; |
|
|
|
|
|
init().catch(console.error); |
|
}); |
|
</script> |
|
</body> |
|
</html> |