Spaces:
Sleeping
Sleeping
| /* ===== Assnani Dental Chatbot — Conversation Engine ===== */ | |
| const Chatbot = (() => { | |
| // --- State --- | |
| let state = 'WELCOME'; | |
| let symptoms = { | |
| has_pain: false, pain_location: '', pain_type: '', pain_intensity: 0, | |
| pain_duration: '', pain_triggers: [], has_swelling: false, | |
| swelling_severity: '', has_fever: false, difficulty_opening: false, | |
| has_trauma: false, has_broken_tooth: false, previous_root_canal: false, | |
| last_visit: '', recent_extraction: false | |
| }; | |
| let detectionData = null; | |
| let analysisResult = null; | |
| let geminiReport = null; | |
| let uploadedFiles = []; | |
| let imageDimensions = { width: 0, height: 0 }; | |
| // --- DOM refs --- | |
| const msgContainer = document.getElementById('messages-container'); | |
| const typingIndicator = document.getElementById('typing-indicator'); | |
| const quickReplies = document.getElementById('quick-replies-area'); | |
| const uploadArea = document.getElementById('upload-area'); | |
| const inputArea = document.getElementById('input-area'); | |
| const userInput = document.getElementById('user-input'); | |
| const btnSend = document.getElementById('btn-send'); | |
| const sidePanel = document.getElementById('side-panel'); | |
| const btnTogglePanel = document.getElementById('btn-toggle-panel'); | |
| const btnClosePanel = document.getElementById('btn-close-panel'); | |
| const reportOverlay = document.getElementById('report-overlay'); | |
| const reportBody = document.getElementById('report-body'); | |
| const uploadDropzone = document.getElementById('upload-dropzone'); | |
| const fileInput = document.getElementById('xray-file-input'); | |
| const uploadPreview = document.getElementById('upload-preview'); | |
| const previewGrid = document.getElementById('upload-preview-grid'); | |
| const btnUploadAdd = document.getElementById('btn-upload-add'); | |
| const btnUploadSubmit = document.getElementById('btn-upload-submit'); | |
| const btnUploadCancel = document.getElementById('btn-upload-cancel'); | |
| // --- Helpers --- | |
| function scrollToBottom() { | |
| setTimeout(() => { msgContainer.scrollTop = msgContainer.scrollHeight; }, 50); | |
| } | |
| function addMessage(text, type = 'bot', html = false) { | |
| const msg = document.createElement('div'); | |
| msg.className = `message message-${type}`; | |
| const avatar = type === 'bot' ? '🤖' : '👤'; | |
| msg.innerHTML = ` | |
| <div class="message-avatar">${avatar}</div> | |
| <div class="message-bubble">${html ? text : escapeHtml(text)}</div> | |
| `; | |
| msgContainer.appendChild(msg); | |
| scrollToBottom(); | |
| } | |
| function escapeHtml(t) { | |
| const d = document.createElement('div'); d.textContent = t; return d.innerHTML; | |
| } | |
| function showTyping() { typingIndicator.style.display = 'flex'; scrollToBottom(); } | |
| function hideTyping() { typingIndicator.style.display = 'none'; } | |
| function botSay(text, html = false, delay = 800) { | |
| return new Promise(resolve => { | |
| showTyping(); | |
| setTimeout(() => { hideTyping(); addMessage(text, 'bot', html); resolve(); }, delay); | |
| }); | |
| } | |
| function showQuickReplies(options) { | |
| quickReplies.innerHTML = ''; | |
| options.forEach(opt => { | |
| const btn = document.createElement('button'); | |
| btn.className = 'quick-reply-btn'; | |
| btn.textContent = typeof opt === 'string' ? opt : opt.label; | |
| btn.addEventListener('click', () => { | |
| const val = typeof opt === 'string' ? opt : opt.value; | |
| addMessage(typeof opt === 'string' ? opt : opt.label, 'user'); | |
| hideQuickReplies(); | |
| handleInput(val); | |
| }); | |
| quickReplies.appendChild(btn); | |
| }); | |
| quickReplies.style.display = 'flex'; | |
| scrollToBottom(); | |
| } | |
| function hideQuickReplies() { quickReplies.style.display = 'none'; quickReplies.innerHTML = ''; } | |
| function showUpload() { uploadArea.style.display = 'block'; scrollToBottom(); } | |
| function hideUpload() { uploadArea.style.display = 'none'; uploadPreview.style.display = 'none'; uploadDropzone.style.display = 'flex'; if(previewGrid) previewGrid.innerHTML = ''; } | |
| function showInput(placeholder) { inputArea.style.display = 'block'; userInput.placeholder = placeholder || 'Type your message...'; userInput.focus(); } | |
| function hideInput() { inputArea.style.display = 'none'; } | |
| // --- State Machine --- | |
| async function transition(nextState, data) { | |
| state = nextState; | |
| switch(state) { | |
| case 'WELCOME': | |
| await botSay("Hello! 👋 I'm <strong>Assnani AI</strong>, your dental symptom assistant.", true, 600); | |
| await botSay("I'll ask you a few questions about your dental health to assess your situation and recommend whether an X-ray examination might be needed.", false, 1000); | |
| await botSay("Let's start — are you currently experiencing any <strong>dental pain</strong>?", true, 800); | |
| showQuickReplies(['Yes, I have pain', 'No pain']); | |
| break; | |
| case 'PAIN_LOCATION': | |
| symptoms.has_pain = true; | |
| await botSay("I'm sorry to hear that. Let me help figure out what's going on.", false, 600); | |
| await botSay("Where exactly do you feel the pain? Please select the area:", false, 800); | |
| // Render tooth chart | |
| hideQuickReplies(); | |
| quickReplies.style.display = 'flex'; | |
| ToothChart.render(quickReplies, (key, label) => { | |
| symptoms.pain_location = key; | |
| addMessage(label, 'user'); | |
| hideQuickReplies(); | |
| transition('PAIN_TYPE'); | |
| }); | |
| scrollToBottom(); | |
| break; | |
| case 'PAIN_TYPE': | |
| await botSay("How would you describe the pain?", false, 600); | |
| showQuickReplies([ | |
| { label: '⚡ Sharp', value: 'sharp' }, | |
| { label: '😣 Dull / Aching', value: 'dull' }, | |
| { label: '💓 Throbbing', value: 'throbbing' }, | |
| { label: '🧊 Sensitivity (hot/cold)', value: 'sensitivity' } | |
| ]); | |
| break; | |
| case 'PAIN_INTENSITY': | |
| symptoms.pain_type = data; | |
| await botSay("On a scale of <strong>1 to 10</strong>, how intense is the pain?", true, 600); | |
| showQuickReplies( | |
| [1,2,3,4,5,6,7,8,9,10].map(n => ({ | |
| label: `${n <= 3 ? '😊' : n <= 6 ? '😐' : n <= 8 ? '😣' : '😫'} ${n}`, | |
| value: String(n) | |
| })) | |
| ); | |
| break; | |
| case 'PAIN_DURATION': | |
| symptoms.pain_intensity = parseInt(data); | |
| await botSay("How long have you been experiencing this pain?", false, 600); | |
| showQuickReplies([ | |
| { label: '📅 Just today', value: 'today' }, | |
| { label: '📅 1-3 days', value: '1-3 days' }, | |
| { label: '📅 3-7 days', value: '3-7 days' }, | |
| { label: '📅 More than a week', value: '1+ week' } | |
| ]); | |
| break; | |
| case 'PAIN_TRIGGERS': | |
| symptoms.pain_duration = data; | |
| await botSay("What triggers or worsens the pain? (Select all that apply, then tap <strong>Done</strong>)", true, 600); | |
| renderMultiSelect([ | |
| { label: '🔥 Hot food/drinks', value: 'hot' }, | |
| { label: '🧊 Cold food/drinks', value: 'cold' }, | |
| { label: '🦷 Biting / Chewing', value: 'biting' }, | |
| { label: '💥 Spontaneous (no trigger)', value: 'spontaneous' }, | |
| { label: '🍬 Sweet foods', value: 'sweet' } | |
| ], (selected) => { | |
| symptoms.pain_triggers = selected; | |
| transition('SWELLING_ASK'); | |
| }); | |
| break; | |
| case 'NO_PAIN': | |
| symptoms.has_pain = false; | |
| await botSay("That's good! Let me ask a few more questions to be thorough.", false, 700); | |
| transition('SWELLING_ASK'); | |
| break; | |
| case 'SWELLING_ASK': | |
| await botSay("Do you have any <strong>swelling</strong> in your mouth, jaw, or face?", true, 700); | |
| showQuickReplies(['Yes, I have swelling', 'No swelling']); | |
| break; | |
| case 'SWELLING_DETAILS': | |
| symptoms.has_swelling = true; | |
| await botSay("How severe is the swelling?", false, 600); | |
| showQuickReplies([ | |
| { label: '🟡 Mild', value: 'mild' }, | |
| { label: '🟠 Moderate', value: 'moderate' }, | |
| { label: '🔴 Severe', value: 'severe' } | |
| ]); | |
| break; | |
| case 'FEVER_ASK': | |
| symptoms.swelling_severity = data; | |
| await botSay("Do you have a <strong>fever</strong> or difficulty opening your mouth?", true, 600); | |
| showQuickReplies([ | |
| { label: '🤒 Yes, fever', value: 'fever' }, | |
| { label: '😮 Difficulty opening mouth', value: 'trismus' }, | |
| { label: '🤒😮 Both', value: 'both' }, | |
| { label: 'Neither', value: 'neither' } | |
| ]); | |
| break; | |
| case 'HISTORY': | |
| if (data === 'fever') { symptoms.has_fever = true; } | |
| else if (data === 'trismus') { symptoms.difficulty_opening = true; } | |
| else if (data === 'both') { symptoms.has_fever = true; symptoms.difficulty_opening = true; } | |
| await botSay("A few questions about your <strong>dental history</strong>:", true, 700); | |
| await botSay("When was your last dental visit?", false, 600); | |
| showQuickReplies([ | |
| { label: '< 6 months ago', value: '< 6 months' }, | |
| { label: '6-12 months ago', value: '6-12 months' }, | |
| { label: 'Over a year ago', value: '1+ year' }, | |
| { label: 'Never / Can\'t remember', value: 'never' } | |
| ]); | |
| break; | |
| case 'HISTORY_TRAUMA': | |
| symptoms.last_visit = data; | |
| await botSay("Have you experienced any of the following?", false, 600); | |
| showQuickReplies([ | |
| { label: '💥 Recent trauma / injury', value: 'trauma' }, | |
| { label: '🔧 Previous root canal (same area)', value: 'root_canal' }, | |
| { label: '💔 Broken / chipped tooth', value: 'broken' }, | |
| { label: 'None of the above', value: 'none' } | |
| ]); | |
| break; | |
| case 'ANALYSIS': | |
| if (data === 'trauma') symptoms.has_trauma = true; | |
| else if (data === 'root_canal') symptoms.previous_root_canal = true; | |
| else if (data === 'broken') symptoms.has_broken_tooth = true; | |
| await botSay("Thank you for answering all the questions! Let me analyze your symptoms... 🔍", false, 600); | |
| await performAnalysis(); | |
| break; | |
| case 'XRAY_ASK': | |
| await botSay("Would you like to upload a dental <strong>X-ray</strong> for AI analysis? I can correlate your symptoms with the X-ray findings.", true, 800); | |
| showQuickReplies([ | |
| { label: '📤 Upload X-ray', value: 'upload' }, | |
| { label: '⏭️ Skip — Show report', value: 'skip' } | |
| ]); | |
| break; | |
| case 'XRAY_UPLOAD': | |
| await botSay("Please upload your dental X-ray image(s) below. You can upload <strong>multiple files</strong> — supported formats: <strong>PNG, JPG, PDF</strong>.", true, 600); | |
| showUpload(); | |
| break; | |
| case 'XRAY_ANALYZING': { | |
| const filesToAnalyze = [...uploadedFiles]; // capture BEFORE hideUpload clears the array | |
| hideUpload(); | |
| const fileCount = filesToAnalyze.length; | |
| await botSay(`<span class="loader-ring">Sending ${fileCount} file${fileCount>1?'s':''} to AI detection model... This may take a moment.</span>`, true, 400); | |
| await analyzeXray(filesToAnalyze); | |
| break; | |
| } | |
| case 'CORRELATION': | |
| await performCorrelation(); | |
| break; | |
| case 'REPORT': | |
| await generateReport(); | |
| break; | |
| } | |
| } | |
| // --- Multi-Select Helper --- | |
| function renderMultiSelect(options, onDone) { | |
| quickReplies.innerHTML = ''; | |
| const selected = new Set(); | |
| options.forEach(opt => { | |
| const btn = document.createElement('button'); | |
| btn.className = 'quick-reply-btn'; | |
| btn.textContent = opt.label; | |
| btn.addEventListener('click', () => { | |
| if (selected.has(opt.value)) { selected.delete(opt.value); btn.classList.remove('selected'); } | |
| else { selected.add(opt.value); btn.classList.add('selected'); } | |
| }); | |
| quickReplies.appendChild(btn); | |
| }); | |
| const doneBtn = document.createElement('button'); | |
| doneBtn.className = 'quick-reply-btn'; | |
| doneBtn.style.cssText = 'background:var(--accent);color:var(--bg-primary);font-weight:700;border-color:var(--accent);'; | |
| doneBtn.textContent = '✓ Done'; | |
| doneBtn.addEventListener('click', () => { | |
| const sel = Array.from(selected); | |
| addMessage(sel.length ? sel.join(', ') : 'No specific trigger', 'user'); | |
| hideQuickReplies(); | |
| onDone(sel); | |
| }); | |
| quickReplies.appendChild(doneBtn); | |
| quickReplies.style.display = 'flex'; | |
| scrollToBottom(); | |
| } | |
| // --- Input Handler --- | |
| function handleInput(value) { | |
| const v = value.toLowerCase().trim(); | |
| switch(state) { | |
| case 'WELCOME': | |
| if (v.includes('yes') || v.includes('pain')) transition('PAIN_LOCATION'); | |
| else transition('NO_PAIN'); | |
| break; | |
| case 'PAIN_TYPE': | |
| transition('PAIN_INTENSITY', v); break; | |
| case 'PAIN_INTENSITY': | |
| transition('PAIN_DURATION', v); break; | |
| case 'PAIN_DURATION': | |
| transition('PAIN_TRIGGERS', v); break; | |
| case 'SWELLING_ASK': | |
| if (v.includes('yes') || v.includes('swelling')) transition('SWELLING_DETAILS'); | |
| else { symptoms.has_swelling = false; transition('HISTORY'); } | |
| break; | |
| case 'SWELLING_DETAILS': | |
| transition('FEVER_ASK', v); break; | |
| case 'FEVER_ASK': | |
| transition('HISTORY', v); break; | |
| case 'HISTORY': | |
| transition('HISTORY_TRAUMA', v); break; | |
| case 'HISTORY_TRAUMA': | |
| transition('ANALYSIS', v); break; | |
| case 'XRAY_ASK': | |
| if (v.includes('upload')) transition('XRAY_UPLOAD'); | |
| else transition('REPORT'); | |
| break; | |
| } | |
| } | |
| // --- API Calls --- | |
| async function performAnalysis() { | |
| try { | |
| const resp = await fetch('/api/analyze-symptoms', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(symptoms) | |
| }); | |
| analysisResult = await resp.json(); | |
| const { risk_level, risk_emoji, score, recommendation, factors, home_care, xray_recommended } = analysisResult; | |
| const badge = `<span class="severity-badge severity-${risk_level}">${risk_emoji} ${risk_level.toUpperCase()} RISK</span>`; | |
| let factorsHtml = factors.slice(0, 5).map(f => `• ${f[0]} (+${f[1]})`).join('<br>'); | |
| await botSay(`<strong>Assessment Result:</strong> ${badge} (Score: ${score})<br><br>${factorsHtml}`, true, 1200); | |
| await botSay(recommendation, false, 800); | |
| if (home_care && home_care.length) { | |
| const tipsHtml = home_care.map(t => `• ${t}`).join('<br>'); | |
| await botSay(`<strong>💡 Home Care Tips:</strong><br>${tipsHtml}`, true, 600); | |
| } | |
| if (xray_recommended) { | |
| transition('XRAY_ASK'); | |
| } else { | |
| transition('XRAY_ASK'); // Still offer the option | |
| } | |
| } catch (err) { | |
| await botSay("Sorry, there was an error analyzing your symptoms. Please try again.", false, 500); | |
| console.error(err); | |
| } | |
| } | |
| async function analyzeXray(files) { | |
| try { | |
| const formData = new FormData(); | |
| files.forEach(f => formData.append('images', f)); | |
| const resp = await fetch('/api/detect-xray', { method: 'POST', body: formData }); | |
| if (!resp.ok) { | |
| const errData = await resp.json(); | |
| throw new Error(errData.error || 'Detection failed'); | |
| } | |
| detectionData = await resp.json(); | |
| if (!detectionData.success) throw new Error('Detection API returned unsuccessful'); | |
| // Aggregate all detections across results | |
| const allDets = []; | |
| const allAnnotated = []; | |
| for (const result of detectionData.results) { | |
| const dets = result.detections || []; | |
| allDets.push(...dets); | |
| const b64 = result.annotated_image_b64 || result.result_image_b64; | |
| if (b64) allAnnotated.push({ b64, filename: result.filename || '' }); | |
| } | |
| const total = detectionData.total_detections || allDets.length; | |
| // Show annotated images in side panel | |
| if (allAnnotated.length) showSidePanel(allDets, allAnnotated); | |
| // Summary message | |
| const counts = {}; | |
| allDets.forEach(d => { const c = d.class_name; counts[c] = (counts[c]||0) + 1; }); | |
| const summaryParts = Object.entries(counts).map(([k,v]) => `<strong>${v}</strong> ${k}${v>1?'s':''}`); | |
| const summaryText = summaryParts.length | |
| ? `AI detected <strong>${total}</strong> finding${total>1?'s':''}: ${summaryParts.join(', ')}.` | |
| : 'No dental conditions were detected in the X-ray.'; | |
| await botSay(`🔍 <strong>X-ray Analysis Complete!</strong><br>${summaryText}`, true, 800); | |
| if (allDets.length > 0) { | |
| await botSay("Now let me correlate these findings with your symptoms...", false, 600); | |
| transition('CORRELATION'); | |
| } else { | |
| await botSay("No abnormalities detected. Your X-ray appears normal! However, a clinical examination is still recommended.", false, 800); | |
| transition('REPORT'); | |
| } | |
| } catch (err) { | |
| await botSay(`❌ Error analyzing X-ray: ${err.message}. Please try again.`, false, 500); | |
| transition('XRAY_ASK'); | |
| console.error(err); | |
| } | |
| } | |
| async function performCorrelation() { | |
| try { | |
| const allDets = detectionData.results.flatMap(r => r.detections || []); | |
| const resp = await fetch('/api/correlate', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ symptoms, detections: allDets, image_width: imageDimensions.width, image_height: imageDimensions.height }) | |
| }); | |
| const corrResult = await resp.json(); | |
| if (corrResult.correlations && corrResult.correlations.length) { | |
| await botSay("<strong>📋 Symptom ↔ X-ray Correlation:</strong>", true, 600); | |
| for (const corr of corrResult.correlations) { | |
| const badge = `<span class="severity-badge severity-${corr.severity}">${corr.severity.toUpperCase()}</span>`; | |
| await botSay( | |
| `${badge} <strong>${corr.clinical_name}</strong> (${corr.confidence_label})<br>${corr.explanation}<br><em>→ ${corr.urgency}</em>`, | |
| true, 1000 | |
| ); | |
| } | |
| } | |
| if (corrResult.unmatched_symptoms && corrResult.unmatched_symptoms.length) { | |
| for (const us of corrResult.unmatched_symptoms) { | |
| await botSay(`ℹ️ ${us}`, false, 600); | |
| } | |
| } | |
| transition('REPORT'); | |
| } catch (err) { | |
| await botSay("Error correlating findings. Generating report anyway...", false, 500); | |
| transition('REPORT'); | |
| console.error(err); | |
| } | |
| } | |
| async function generateReport() { | |
| await botSay("Generating your complete dental assessment report... 📋", false, 800); | |
| // Get treatment plan if detections exist | |
| let treatmentPlan = null; | |
| if (detectionData && detectionData.results) { | |
| try { | |
| const resp = await fetch('/api/treatment-plan', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ api_response: detectionData }) | |
| }); | |
| treatmentPlan = await resp.json(); | |
| } catch(e) { console.error(e); } | |
| } | |
| // Get Gemini AI-generated report if X-ray was uploaded | |
| if (uploadedFiles.length) { | |
| try { | |
| await botSay('<span class="loader-ring">Generating AI clinical report powered by Gemini... This may take a moment.</span>', true, 400); | |
| const formData = new FormData(); | |
| uploadedFiles.forEach(f => formData.append('images', f)); | |
| const resp = await fetch('/api/ai-report', { method: 'POST', body: formData }); | |
| if (resp.ok) { | |
| const aiData = await resp.json(); | |
| if (aiData.results) { | |
| const firstKey = Object.keys(aiData.results)[0]; | |
| if (firstKey) { | |
| geminiReport = aiData.results[firstKey]; | |
| } | |
| } | |
| } | |
| // Show AI report as a chat message NOW, before "report ready" | |
| if (geminiReport && geminiReport.report) { | |
| let classSummaryHtml = ''; | |
| if (geminiReport.by_class) { | |
| classSummaryHtml = Object.entries(geminiReport.by_class) | |
| .map(([cls, count]) => `<span class="severity-badge severity-moderate" style="margin:2px 2px 4px;">${cls}: ${count}</span>`) | |
| .join(' '); | |
| } | |
| const reportChatHtml = ` | |
| <div style="font-size:12px;line-height:1.7;max-height:420px;overflow-y:auto;padding-right:4px;"> | |
| <div style="font-weight:700;font-size:13px;color:var(--accent);border-bottom:1px solid var(--border);padding-bottom:6px;margin-bottom:10px;"> | |
| 🤖 AI Clinical Report | |
| <span style="font-size:10px;color:var(--text-muted);font-weight:400;margin-left:6px;">powered by Gemini</span> | |
| </div> | |
| ${classSummaryHtml ? `<div style="margin-bottom:10px;display:flex;flex-wrap:wrap;gap:2px;align-items:center;"><strong style="margin-right:6px;font-size:11px;">Detections:</strong>${classSummaryHtml}<span style="color:var(--text-muted);font-size:11px;margin-left:6px;">(${geminiReport.total || 0} total)</span></div>` : ''} | |
| <div>${geminiReport.report}</div> | |
| </div>`; | |
| addMessage(reportChatHtml, 'bot', true); | |
| // PDF download button as a follow-up bot message | |
| window._geminiReportHtml = geminiReport.report; | |
| window._geminiByClass = geminiReport.by_class || {}; | |
| window._geminiTotal = geminiReport.total || 0; | |
| const pdfBtnHtml = `<button onclick="window._downloadGeminiPDF()" style=" | |
| display:inline-flex;align-items:center;gap:6px; | |
| padding:8px 16px;border-radius:8px;border:none;cursor:pointer; | |
| background:var(--accent);color:#fff;font-weight:600;font-size:13px; | |
| box-shadow:0 2px 8px rgba(0,0,0,.25);transition:opacity .2s; | |
| " onmouseover="this.style.opacity='.85'" onmouseout="this.style.opacity='1'"> | |
| 📥 Download Report as PDF | |
| </button>`; | |
| addMessage(pdfBtnHtml, 'bot', true); | |
| // Also inject PDF button at the bottom of the X-ray side panel | |
| const existingPanelBtn = document.getElementById('panel-pdf-section'); | |
| if (existingPanelBtn) existingPanelBtn.remove(); | |
| const panelPdfSection = document.createElement('div'); | |
| panelPdfSection.id = 'panel-pdf-section'; | |
| panelPdfSection.style.cssText = 'padding:12px 16px;border-top:1px solid var(--border);flex-shrink:0;'; | |
| panelPdfSection.innerHTML = ` | |
| <div style="font-size:11px;color:var(--text-muted);margin-bottom:8px;font-weight:600;text-transform:uppercase;letter-spacing:.4px;">AI Clinical Report</div> | |
| <button onclick="window._downloadGeminiPDF()" style=" | |
| width:100%;display:flex;align-items:center;justify-content:center;gap:8px; | |
| padding:10px 0;border-radius:8px;border:none;cursor:pointer; | |
| background:var(--accent);color:#fff;font-weight:600;font-size:13px; | |
| box-shadow:0 2px 8px rgba(0,0,0,.2);transition:opacity .2s; | |
| " onmouseover="this.style.opacity='.85'" onmouseout="this.style.opacity='1'"> | |
| 📥 Download AI Report PDF | |
| </button>`; | |
| sidePanel.appendChild(panelPdfSection); | |
| } else if (geminiReport && geminiReport.error) { | |
| await botSay(`⚠️ AI report note: ${geminiReport.error}`, false, 400); | |
| } | |
| } catch(e) { console.error('Gemini report error:', e); } | |
| } | |
| // Build report HTML | |
| let reportHtml = ''; | |
| // Risk Assessment Section | |
| if (analysisResult) { | |
| const badge = `<span class="severity-badge severity-${analysisResult.risk_level}">${analysisResult.risk_emoji} ${analysisResult.risk_level.toUpperCase()}</span>`; | |
| reportHtml += ` | |
| <div class="report-section"> | |
| <div class="report-section-title">🩺 Risk Assessment</div> | |
| <div class="report-item"> | |
| Risk Level: ${badge} Score: <strong>${analysisResult.score}</strong><br> | |
| ${analysisResult.recommendation} | |
| </div> | |
| </div>`; | |
| } | |
| // Symptoms Summary | |
| reportHtml += ` | |
| <div class="report-section"> | |
| <div class="report-section-title">📝 Reported Symptoms</div> | |
| <div class="report-item"> | |
| ${symptoms.has_pain ? `<strong>Pain:</strong> ${symptoms.pain_type} pain in ${symptoms.pain_location}, intensity ${symptoms.pain_intensity}/10, duration: ${symptoms.pain_duration}<br>` : '<strong>Pain:</strong> None reported<br>'} | |
| ${symptoms.has_swelling ? `<strong>Swelling:</strong> ${symptoms.swelling_severity}${symptoms.has_fever ? ' with fever' : ''}<br>` : '<strong>Swelling:</strong> None<br>'} | |
| <strong>Last dental visit:</strong> ${symptoms.last_visit || 'Not specified'} | |
| </div> | |
| </div>`; | |
| // Gemini AI Clinical Report (if available) | |
| if (geminiReport && geminiReport.report) { | |
| // Convert markdown-style formatting to HTML | |
| let reportText = geminiReport.report | |
| .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') | |
| .replace(/\*(.*?)\*/g, '<em>$1</em>') | |
| .replace(/^### (.*$)/gm, '<h4 style="color:var(--accent);margin:10px 0 4px;font-size:13px;">$1</h4>') | |
| .replace(/^## (.*$)/gm, '<h3 style="color:var(--accent);margin:12px 0 6px;font-size:14px;">$1</h3>') | |
| .replace(/^# (.*$)/gm, '<h3 style="color:var(--accent);margin:12px 0 6px;font-size:15px;">$1</h3>') | |
| .replace(/^- (.*$)/gm, '• $1') | |
| .replace(/\n/g, '<br>'); | |
| let classSummary = ''; | |
| if (geminiReport.by_class) { | |
| classSummary = Object.entries(geminiReport.by_class) | |
| .map(([cls, count]) => `<span class="severity-badge severity-moderate" style="margin:2px;">${cls}: ${count}</span>`) | |
| .join(' '); | |
| } | |
| reportHtml += ` | |
| <div class="report-section"> | |
| <div class="report-section-title">🤖 AI Clinical Report <span style="font-size:10px;color:var(--text-muted);text-transform:none;font-weight:400;">powered by Gemini</span></div> | |
| ${classSummary ? `<div class="report-item" style="display:flex;flex-wrap:wrap;gap:4px;align-items:center;"><strong style="margin-right:8px;">Detections:</strong> ${classSummary} <span style="color:var(--text-muted);font-size:11px;margin-left:8px;">(${geminiReport.total || 0} total)</span></div>` : ''} | |
| <div class="report-item" style="max-height:400px;overflow-y:auto;">${reportText}</div> | |
| </div>`; | |
| } | |
| // X-ray Findings (rule-based) | |
| if (treatmentPlan && treatmentPlan.recommendations) { | |
| let recsHtml = treatmentPlan.recommendations.map(r => | |
| `<div class="report-item"> | |
| ${r.icon} <strong>${r.finding}</strong> <span class="severity-badge severity-${r.severity}">${r.severity.toUpperCase()}</span><br> | |
| ${r.treatment}<br> | |
| <em>Specialist: ${r.specialist}</em> | |
| </div>` | |
| ).join(''); | |
| reportHtml += ` | |
| <div class="report-section"> | |
| <div class="report-section-title">🔬 Detection Findings & Treatment Plan</div> | |
| ${recsHtml} | |
| </div>`; | |
| if (treatmentPlan.specialists && treatmentPlan.specialists.length) { | |
| reportHtml += ` | |
| <div class="report-section"> | |
| <div class="report-section-title">👨⚕️ Recommended Specialists</div> | |
| <div class="report-item">${treatmentPlan.specialists.map(s => `• ${s}`).join('<br>')}</div> | |
| </div>`; | |
| } | |
| } | |
| // Home Care | |
| if (analysisResult && analysisResult.home_care) { | |
| reportHtml += ` | |
| <div class="report-section"> | |
| <div class="report-section-title">💡 Home Care Advice</div> | |
| <div class="report-item">${analysisResult.home_care.map(t => `• ${t}`).join('<br>')}</div> | |
| </div>`; | |
| } | |
| reportBody.innerHTML = reportHtml; | |
| reportOverlay.style.display = 'flex'; | |
| await botSay("Your assessment report is ready! 📋 Review it in the popup window.", false, 400); | |
| } | |
| // --- Side Panel --- | |
| function showSidePanel(detections, annotatedImages) { | |
| // Render annotated images | |
| const panelXray = document.getElementById('panel-xray'); | |
| panelXray.innerHTML = ''; | |
| if (annotatedImages && annotatedImages.length) { | |
| annotatedImages.forEach(({ b64, filename }) => { | |
| const img = document.createElement('img'); | |
| img.src = `data:image/png;base64,${b64}`; | |
| img.alt = filename || 'Annotated X-ray'; | |
| panelXray.appendChild(img); | |
| if (filename) { | |
| const label = document.createElement('div'); | |
| label.className = 'panel-xray-label'; | |
| label.textContent = filename; | |
| panelXray.appendChild(label); | |
| } | |
| }); | |
| } | |
| // Render detection cards | |
| const panelDets = document.getElementById('panel-detections'); | |
| panelDets.innerHTML = detections.map(d => ` | |
| <div class="detection-card"> | |
| <div class="detection-card-header"> | |
| <span class="detection-card-class">${d.class_name}</span> | |
| <span class="detection-card-conf">${(d.confidence*100).toFixed(1)}%</span> | |
| </div> | |
| <div class="detection-card-detail">Size: ${d.width}×${d.height}px • Position: (${d.x}, ${d.y})</div> | |
| </div> | |
| `).join(''); | |
| sidePanel.style.display = 'flex'; | |
| btnTogglePanel.style.display = 'flex'; | |
| } | |
| // --- PDF Download --- | |
| window._downloadGeminiPDF = function() { | |
| const reportHtml = window._geminiReportHtml || ''; | |
| const byClass = window._geminiByClass || {}; | |
| const total = window._geminiTotal || 0; | |
| const classBadges = Object.entries(byClass) | |
| .map(([cls, count]) => `<span style="display:inline-block;padding:2px 8px;border-radius:12px;border:1px solid #0d9488;color:#0d9488;font-size:11px;font-weight:600;margin:2px;">${cls}: ${count}</span>`) | |
| .join(' '); | |
| const printWin = window.open('', '_blank', 'width=850,height=1000'); | |
| if (!printWin) { alert('Please allow popups to download the PDF.'); return; } | |
| printWin.document.write(`<!DOCTYPE html><html><head> | |
| <meta charset="utf-8"> | |
| <title>AI Clinical Report — Assnani Dental</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| body{font-family:'Inter',sans-serif;padding:40px;color:#1e293b;font-size:13px;line-height:1.7;background:#fff} | |
| .header{border-bottom:3px solid #0d9488;padding-bottom:16px;margin-bottom:24px} | |
| .header h1{font-size:22px;color:#0d9488;margin-bottom:4px} | |
| .header p{color:#64748b;font-size:12px} | |
| .detections{margin-bottom:20px;padding:12px;background:#f0fdfa;border-radius:8px;border:1px solid #99f6e4} | |
| .detections strong{font-size:12px;margin-right:8px} | |
| .report-content{font-size:13px;line-height:1.8} | |
| .report-content h1,.report-content h2,.report-content h3{color:#0d9488;margin:16px 0 6px} | |
| .report-content h1{font-size:16px} .report-content h2{font-size:15px} .report-content h3{font-size:14px} | |
| .report-content strong{color:#0f172a} | |
| .report-content table{width:100%;border-collapse:collapse;margin:12px 0} | |
| .report-content th{background:#f0fdfa;color:#0d9488;padding:8px;border:1px solid #99f6e4;font-size:12px} | |
| .report-content td{padding:7px 8px;border:1px solid #e2e8f0;font-size:12px} | |
| .report-content tr:nth-child(even) td{background:#f8fafc} | |
| .disclaimer{margin-top:32px;padding:12px;border:1px solid #e2e8f0;border-radius:8px;font-size:11px;color:#64748b;text-align:center} | |
| @media print{ | |
| body{padding:20px} | |
| .no-print{display:none} | |
| @page{margin:1cm;size:A4} | |
| } | |
| </style> | |
| </head><body> | |
| <div class="header"> | |
| <h1>🦷 AI Clinical Dental Report</h1> | |
| <p>Generated by Assnani AI • ${new Date().toLocaleDateString('en-US',{year:'numeric',month:'long',day:'numeric'})} • Powered by Gemini</p> | |
| </div> | |
| ${classBadges ? `<div class="detections"><strong>Findings (${total} total):</strong>${classBadges}</div>` : ''} | |
| <div class="report-content">${reportHtml}</div> | |
| <div class="disclaimer">⚠️ This is an AI-assisted preliminary analysis. Final diagnosis must be verified by a licensed dental professional.</div> | |
| <div class="no-print" style="margin-top:24px;text-align:center"> | |
| <button onclick="window.print()" style="padding:10px 28px;background:#0d9488;color:#fff;border:none;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;">🖨️ Save as PDF / Print</button> | |
| </div> | |
| </body></html>`); | |
| printWin.document.close(); | |
| printWin.onload = () => { setTimeout(() => printWin.print(), 300); }; | |
| }; | |
| // --- Event Listeners --- | |
| function init() { | |
| // Text input | |
| btnSend.addEventListener('click', () => submitTextInput()); | |
| userInput.addEventListener('keydown', e => { if (e.key === 'Enter') submitTextInput(); }); | |
| function submitTextInput() { | |
| const val = userInput.value.trim(); | |
| if (!val) return; | |
| addMessage(val, 'user'); | |
| userInput.value = ''; | |
| handleInput(val); | |
| } | |
| // File upload — multi-file support | |
| const ALLOWED_TYPES = /^(image\/(png|jpeg|jpg)|application\/pdf)$/; | |
| const ALLOWED_EXT = /\.(png|jpe?g|pdf)$/i; | |
| uploadDropzone.addEventListener('click', () => fileInput.click()); | |
| uploadDropzone.addEventListener('dragover', e => { e.preventDefault(); uploadDropzone.classList.add('drag-over'); }); | |
| uploadDropzone.addEventListener('dragleave', () => uploadDropzone.classList.remove('drag-over')); | |
| uploadDropzone.addEventListener('drop', e => { | |
| e.preventDefault(); | |
| uploadDropzone.classList.remove('drag-over'); | |
| if (e.dataTransfer.files.length) handleFiles(e.dataTransfer.files); | |
| }); | |
| fileInput.addEventListener('change', () => { if (fileInput.files.length) handleFiles(fileInput.files); fileInput.value = ''; }); | |
| function handleFiles(fileList) { | |
| for (const file of fileList) { | |
| const typeOk = ALLOWED_TYPES.test(file.type) || ALLOWED_EXT.test(file.name); | |
| if (!typeOk) { alert(`Unsupported file: ${file.name}. Use PNG, JPG, or PDF.`); continue; } | |
| uploadedFiles.push(file); | |
| } | |
| if (uploadedFiles.length) { | |
| renderPreviewGrid(); | |
| extractFirstImageDimensions(); | |
| } | |
| } | |
| function renderPreviewGrid() { | |
| previewGrid.innerHTML = ''; | |
| uploadedFiles.forEach((file, idx) => { | |
| const item = document.createElement('div'); | |
| item.className = 'preview-item'; | |
| const isPdf = file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'); | |
| if (isPdf) { | |
| item.innerHTML = `<span class="pdf-icon">📄</span>`; | |
| } else { | |
| const img = document.createElement('img'); | |
| img.src = URL.createObjectURL(file); | |
| img.alt = file.name; | |
| item.appendChild(img); | |
| } | |
| const nameLabel = document.createElement('span'); | |
| nameLabel.className = 'preview-name'; | |
| nameLabel.textContent = file.name; | |
| item.appendChild(nameLabel); | |
| const removeBtn = document.createElement('button'); | |
| removeBtn.className = 'preview-remove'; | |
| removeBtn.innerHTML = '×'; | |
| removeBtn.title = 'Remove'; | |
| removeBtn.addEventListener('click', (e) => { | |
| e.stopPropagation(); | |
| uploadedFiles.splice(idx, 1); | |
| if (uploadedFiles.length === 0) { | |
| uploadDropzone.style.display = 'flex'; | |
| uploadPreview.style.display = 'none'; | |
| } else { | |
| renderPreviewGrid(); | |
| } | |
| }); | |
| item.appendChild(removeBtn); | |
| previewGrid.appendChild(item); | |
| }); | |
| uploadDropzone.style.display = 'none'; | |
| uploadPreview.style.display = 'flex'; | |
| } | |
| function extractFirstImageDimensions() { | |
| const firstImage = uploadedFiles.find(f => f.type.startsWith('image/')); | |
| if (!firstImage) return; | |
| const img = new window.Image(); | |
| img.onload = () => { | |
| imageDimensions = { width: img.naturalWidth, height: img.naturalHeight }; | |
| URL.revokeObjectURL(img.src); | |
| }; | |
| img.src = URL.createObjectURL(firstImage); | |
| } | |
| // Add More button | |
| btnUploadAdd.addEventListener('click', () => fileInput.click()); | |
| btnUploadSubmit.addEventListener('click', () => { | |
| if (!uploadedFiles.length) return; | |
| const names = uploadedFiles.map(f => f.name).join(', '); | |
| addMessage(`📎 Uploaded ${uploadedFiles.length} file${uploadedFiles.length > 1 ? 's' : ''}: ${names}`, 'user'); | |
| transition('XRAY_ANALYZING'); | |
| }); | |
| btnUploadCancel.addEventListener('click', () => { | |
| uploadedFiles = []; | |
| imageDimensions = { width: 0, height: 0 }; | |
| uploadDropzone.style.display = 'flex'; | |
| uploadPreview.style.display = 'none'; | |
| previewGrid.innerHTML = ''; | |
| fileInput.value = ''; | |
| }); | |
| // Side panel | |
| btnTogglePanel.addEventListener('click', () => { | |
| sidePanel.style.display = sidePanel.style.display === 'none' ? 'flex' : 'none'; | |
| }); | |
| btnClosePanel.addEventListener('click', () => { sidePanel.style.display = 'none'; }); | |
| // Report modal | |
| document.getElementById('btn-report-close').addEventListener('click', () => { reportOverlay.style.display = 'none'; }); | |
| document.getElementById('btn-report-new').addEventListener('click', () => resetChat()); | |
| // Print report | |
| document.getElementById('btn-report-print').addEventListener('click', () => { | |
| const printWin = window.open('', '_blank', 'width=800,height=900'); | |
| if (!printWin) { alert('Please allow popups to print the report.'); return; } | |
| printWin.document.write(`<!DOCTYPE html><html><head><title>Dental Assessment Report — Assnani AI</title> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet"> | |
| <style> | |
| *{margin:0;padding:0;box-sizing:border-box} | |
| body{font-family:'Inter',sans-serif;padding:32px;color:#1e293b;font-size:13px;line-height:1.6} | |
| h1{font-size:20px;margin-bottom:4px} h2{font-size:12px;color:#64748b;margin-bottom:20px;font-weight:400} | |
| .section{margin-bottom:18px} | |
| .section-title{font-size:13px;font-weight:700;color:#0f766e;text-transform:uppercase;letter-spacing:.5px;border-bottom:2px solid #0f766e;padding-bottom:4px;margin-bottom:8px} | |
| .item{padding:10px;border:1px solid #e2e8f0;border-radius:6px;margin-bottom:6px;font-size:12px} | |
| .item strong{color:#0f172a} | |
| .severity-badge{display:inline-block;padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600} | |
| .severity-high{color:#dc2626;border:1px solid #dc2626} | |
| .severity-moderate{color:#d97706;border:1px solid #d97706} | |
| .severity-low{color:#16a34a;border:1px solid #16a34a} | |
| .disclaimer{margin-top:20px;padding:10px;border:1px solid #e2e8f0;border-radius:6px;font-size:11px;color:#64748b;text-align:center} | |
| @media print{body{padding:16px}} | |
| </style></head><body> | |
| <h1>📋 Dental Assessment Report</h1> | |
| <h2>AI-Assisted Analysis by Assnani — ${new Date().toLocaleDateString()}</h2> | |
| ${reportBody.innerHTML.replace(/class="report-section"/g,'class="section"').replace(/class="report-section-title"/g,'class="section-title"').replace(/class="report-item"/g,'class="item"')} | |
| <div class="disclaimer">⚠️ This is an AI-assisted preliminary analysis. Final diagnosis must be verified by a licensed dental professional.</div> | |
| </body></html>`); | |
| printWin.document.close(); | |
| printWin.onload = () => { printWin.print(); }; | |
| }); | |
| // New chat | |
| document.getElementById('btn-new-chat').addEventListener('click', () => resetChat()); | |
| // Start conversation | |
| transition('WELCOME'); | |
| } | |
| function resetChat() { | |
| reportOverlay.style.display = 'none'; | |
| sidePanel.style.display = 'none'; | |
| btnTogglePanel.style.display = 'none'; | |
| msgContainer.innerHTML = ''; | |
| hideQuickReplies(); | |
| hideUpload(); | |
| hideInput(); | |
| symptoms = { | |
| has_pain:false,pain_location:'',pain_type:'',pain_intensity:0, | |
| pain_duration:'',pain_triggers:[],has_swelling:false, | |
| swelling_severity:'',has_fever:false,difficulty_opening:false, | |
| has_trauma:false,has_broken_tooth:false,previous_root_canal:false, | |
| last_visit:'',recent_extraction:false | |
| }; | |
| detectionData = null; | |
| analysisResult = null; | |
| geminiReport = null; | |
| const oldPdf = document.getElementById('panel-pdf-section'); | |
| if (oldPdf) oldPdf.remove(); | |
| uploadedFiles = []; | |
| imageDimensions = { width: 0, height: 0 }; | |
| state = 'WELCOME'; | |
| transition('WELCOME'); | |
| } | |
| return { init }; | |
| })(); | |
| document.addEventListener('DOMContentLoaded', () => Chatbot.init()); |