Spaces:
Running on Zero
Running on Zero
| /** | |
| * TraceScene β Frontend Application | |
| * | |
| * Single-page application for accident case management, | |
| * photo upload, AI analysis, and report viewing. | |
| */ | |
| const API_BASE = '/api'; | |
| // ββ State βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| let currentView = 'landing'; | |
| let currentCaseId = null; | |
| let currentCaseData = null; | |
| let currentVertical = 'le'; // Store full case data for editing | |
| let selectedFiles = []; | |
| let additionalFiles = []; // For add photos modal | |
| // Gradio API Helper for ZeroGPU Event Streams | |
| // Uses fetch-based SSE instead of EventSource (which can't set headers | |
| // required by HuggingFace's ZeroGPU proxy). | |
| async function callGradioApi(apiName, dataArr, onChunk = null) { | |
| try { | |
| const API_PATHS = ['/gradio_api/call/', '/call/']; | |
| let hfToken = ''; | |
| // Fetch config for ZeroGPU token | |
| try { | |
| const confRes = await fetch(`${API_BASE}/config`); | |
| if (confRes.ok) { | |
| const confData = await confRes.json(); | |
| hfToken = confData.hf_token || ''; | |
| } | |
| } catch (e) { | |
| console.warn('Failed to fetch config', e); | |
| } | |
| const headers = { 'Content-Type': 'application/json' }; | |
| if (hfToken) headers['Authorization'] = `Bearer ${hfToken}`; | |
| let lastError = null; | |
| let selectedApiBase = ''; | |
| let eventId = ''; | |
| // Step 1: Find working endpoint | |
| for (const apiBase of API_PATHS) { | |
| try { | |
| const queueUrl = apiBase + apiName; | |
| console.log(`[Gradio] Attempting POST: ${queueUrl}`); | |
| const res = await fetch(queueUrl, { | |
| method: 'POST', | |
| headers: headers, | |
| credentials: 'include', | |
| body: JSON.stringify({ data: dataArr }) | |
| }); | |
| if (res.status === 404) { | |
| console.warn(`[Gradio] 404 at ${queueUrl}, trying next fallback...`); | |
| continue; | |
| } | |
| if (!res.ok) { | |
| throw new Error(`Gradio API Request Failed (${res.status})`); | |
| } | |
| const eventObj = await res.json(); | |
| eventId = eventObj.event_id; | |
| selectedApiBase = apiBase; | |
| console.log(`[Gradio] Success at ${queueUrl}. Event ID: ${eventId}`); | |
| break; | |
| } catch (e) { | |
| lastError = e; | |
| console.error(`[Gradio] Error at ${apiBase}:`, e); | |
| } | |
| } | |
| if (!selectedApiBase || !eventId) { | |
| throw lastError || new Error('Gradio API could not be reached (All paths failed)'); | |
| } | |
| // Step 2: Stream the result via fetch (not EventSource) | |
| const streamUrl = selectedApiBase + apiName + '/' + eventId; | |
| console.log(`[Gradio] Streaming from: ${streamUrl}`); | |
| const streamHeaders = { 'Accept': 'text/event-stream' }; | |
| if (hfToken) { | |
| streamHeaders['Authorization'] = `Bearer ${hfToken}`; | |
| } | |
| const sseRes = await fetch(streamUrl, { | |
| method: 'GET', | |
| headers: streamHeaders, | |
| credentials: 'include', | |
| cache: 'no-cache', | |
| }); | |
| if (!sseRes.ok) { | |
| throw new Error('Gradio SSE Stream Failed (' + sseRes.status + ')'); | |
| } | |
| const reader = sseRes.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (value) { | |
| buffer += decoder.decode(value, { stream: true }); | |
| } | |
| if (done) { | |
| buffer += decoder.decode(); | |
| if (buffer.trim()) { | |
| buffer += '\n\n'; | |
| } | |
| } | |
| // Parse SSE events from buffer (events separated by double newline) | |
| const parts = buffer.split('\n\n'); | |
| // Keep the last incomplete chunk in the buffer | |
| buffer = parts.pop(); | |
| for (const part of parts) { | |
| let eventType = ''; | |
| const dataLines = []; | |
| for (const line of part.split('\n')) { | |
| if (line.startsWith('event:')) { | |
| eventType = line.substring(6).trim(); | |
| } else if (line.startsWith('data:')) { | |
| dataLines.push(line.substring(5).replace(/^ /, '')); | |
| } | |
| } | |
| const dataLine = dataLines.join('\n'); | |
| if (!dataLine && !eventType) continue; | |
| // ZeroGPU format: event type is "complete" or "error", | |
| // data is a raw JSON array of outputs | |
| if (eventType === 'complete') { | |
| if (!done) reader.cancel(); | |
| try { | |
| const result = JSON.parse(dataLine); | |
| return Array.isArray(result) ? result : [result]; | |
| } catch { | |
| return [dataLine]; | |
| } | |
| } else if (eventType === 'error') { | |
| if (!done) reader.cancel(); | |
| console.error(`[Gradio] SSE Error event:`, part); | |
| const errorDetail = dataLine === 'null' ? 'Server Error (null)' : dataLine; | |
| // Phone home specifically for SSE error | |
| throw new Error(`SSE_ERROR: ${errorDetail}`); | |
| } | |
| // Also handle the non-ZeroGPU format (Gradio standard) | |
| if (dataLine) { | |
| try { | |
| const msg = JSON.parse(dataLine); | |
| if (msg.msg === 'process_generating') { | |
| if (onChunk && msg.output?.data) { | |
| onChunk(msg.output.data); | |
| } | |
| } else if (msg.msg === 'process_completed') { | |
| if (!done) reader.cancel(); | |
| if (msg.success) { | |
| return msg.output.data; | |
| } else { | |
| throw new Error(msg.output?.error || 'Server Error'); | |
| } | |
| } | |
| } catch { | |
| // Not JSON or not the expected format, skip | |
| } | |
| } | |
| } | |
| if (done) break; | |
| } | |
| } catch (err) { | |
| // Phone-home error report | |
| try { | |
| fetch(`${API_BASE}/debug/report_error`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| apiName, | |
| error: err.message, | |
| stack: err.stack, | |
| userAgent: navigator.userAgent, | |
| url: window.location.href, | |
| timestamp: new Date().toISOString() | |
| }) | |
| }); | |
| } catch (e) { /* ignore secondary error */ } | |
| throw err; | |
| } | |
| } | |
| // ββ Init ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| document.addEventListener('DOMContentLoaded', () => { | |
| initNavigation(); | |
| initDropZone(); | |
| initButtons(); | |
| initAccordions(); | |
| loadHealth(); | |
| loadCases(); | |
| }); | |
| // ββ Accordions ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function initAccordions() { | |
| document.querySelectorAll('.accordion-header').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const item = btn.closest('.accordion-item'); | |
| const isActive = item.classList.contains('active'); | |
| // Close all other accordions | |
| document.querySelectorAll('.accordion-item').forEach(i => { | |
| i.classList.remove('active'); | |
| i.querySelector('.accordion-header').setAttribute('aria-expanded', 'false'); | |
| }); | |
| // Toggle current | |
| if (!isActive) { | |
| item.classList.add('active'); | |
| btn.setAttribute('aria-expanded', 'true'); | |
| } | |
| }); | |
| }); | |
| } | |
| // ββ Navigation ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function initNavigation() { | |
| document.querySelectorAll('.nav-item[data-view]').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const view = btn.dataset.view; | |
| switchView(view); | |
| document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| }); | |
| }); | |
| } | |
| function switchView(viewName) { | |
| document.querySelectorAll('.view').forEach(v => { | |
| v.classList.remove('active'); | |
| v.classList.add('hidden'); | |
| }); | |
| const target = document.getElementById(`view-${viewName}`); | |
| if (target) { | |
| target.classList.remove('hidden'); | |
| target.classList.add('active'); | |
| } | |
| currentView = viewName; | |
| if (viewName === 'le-dashboard') loadCases('le'); | |
| if (viewName === 'ins-dashboard') loadCases('ins'); | |
| if (viewName === 'rules') loadRules(); | |
| } | |
| // ββ Buttons βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function initButtons() { | |
| // LE Dashboard | |
| document.getElementById('btn-refresh-cases')?.addEventListener('click', () => loadCases('le')); | |
| document.getElementById('btn-le-empty-new-case')?.addEventListener('click', () => { | |
| switchView('le-new-case'); | |
| document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active')); | |
| document.querySelector('[data-view="le-new-case"]')?.classList.add('active'); | |
| }); | |
| // INS Dashboard | |
| document.getElementById('btn-refresh-cases-ins')?.addEventListener('click', () => loadCases('ins')); | |
| document.getElementById('btn-ins-empty-new-case')?.addEventListener('click', () => { | |
| switchView('ins-new-case'); | |
| document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active')); | |
| document.querySelector('[data-view="ins-new-case"]')?.classList.add('active'); | |
| }); | |
| // New Case Forms | |
| document.getElementById('btn-create-case')?.addEventListener('click', () => createCase('le')); | |
| document.getElementById('btn-ins-create-case')?.addEventListener('click', () => createCase('ins')); | |
| // Case Detail | |
| document.getElementById('btn-back-dashboard')?.addEventListener('click', () => switchView(currentVertical === 'ins' ? 'ins-dashboard' : 'le-dashboard')); | |
| document.getElementById('btn-run-analysis')?.addEventListener('click', runAnalysis); | |
| document.getElementById('btn-view-report')?.addEventListener('click', viewReport); | |
| document.getElementById('btn-edit-case')?.addEventListener('click', openEditModal); | |
| document.getElementById('btn-add-photos-inline')?.addEventListener('click', openAddPhotosModal); | |
| // Edit Modal | |
| document.getElementById('btn-close-edit-modal')?.addEventListener('click', closeEditModal); | |
| document.getElementById('btn-cancel-edit')?.addEventListener('click', closeEditModal); | |
| document.getElementById('btn-save-case')?.addEventListener('click', saveCaseChanges); | |
| // Photos Modal | |
| document.getElementById('btn-close-photos-modal')?.addEventListener('click', closePhotosModal); | |
| document.getElementById('btn-cancel-photos')?.addEventListener('click', closePhotosModal); | |
| document.getElementById('btn-upload-more')?.addEventListener('click', uploadMorePhotos); | |
| // Chat and Simulation Logic | |
| document.getElementById('btn-chat-send')?.addEventListener('click', sendChatMessage); | |
| document.getElementById('chat-input')?.addEventListener('keypress', (e) => { | |
| if (e.key === 'Enter') sendChatMessage(); | |
| }); | |
| document.getElementById('btn-sim-generate')?.addEventListener('click', generateSimulation); | |
| // Form logic | |
| document.getElementById('case-number')?.addEventListener('input', () => validateForm('le')); | |
| document.getElementById('ins-case-number')?.addEventListener('input', () => validateForm('ins')); | |
| initEditDropZone(); | |
| } | |
| function initEditDropZone() { | |
| const dropZone = document.getElementById('edit-drop-zone'); | |
| const fileInput = document.getElementById('edit-file-input'); | |
| if (!dropZone || !fileInput) return; | |
| dropZone.addEventListener('click', () => fileInput.click()); | |
| dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); }); | |
| dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')); | |
| dropZone.addEventListener('drop', e => { | |
| e.preventDefault(); | |
| dropZone.classList.remove('drag-over'); | |
| handleAdditionalFiles(Array.from(e.dataTransfer.files)); | |
| }); | |
| fileInput.addEventListener('change', () => { | |
| handleAdditionalFiles(Array.from(fileInput.files)); | |
| }); | |
| } | |
| function validateForm(vertical = 'le') { | |
| const cid = vertical === 'le' ? 'case-number' : 'ins-case-number'; | |
| const bid = vertical === 'le' ? 'btn-create-case' : 'btn-ins-create-case'; | |
| const caseNum = document.getElementById(cid)?.value.trim(); | |
| const btn = document.getElementById(bid); | |
| if (btn) btn.disabled = !caseNum; | |
| } | |
| // ββ Health βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function loadHealth() { | |
| try { | |
| const resp = await fetch(`${API_BASE}/health`); | |
| const data = await resp.json(); | |
| document.getElementById('status-model').textContent = data.model_loaded ? 'β Ready' : 'β Not loaded'; | |
| document.getElementById('status-model').style.color = data.model_loaded ? '#22c55e' : '#ef4444'; | |
| document.getElementById('status-device').textContent = data.device || 'β'; | |
| document.getElementById('status-rules').textContent = `${data.rules_loaded} rules`; | |
| } catch { | |
| document.getElementById('status-model').textContent = 'β Offline'; | |
| document.getElementById('status-model').style.color = '#ef4444'; | |
| } | |
| } | |
| // ββ Cases βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function loadCases(vertical = 'le') { | |
| currentVertical = vertical; | |
| try { | |
| const resp = await fetch(`${API_BASE}/cases`); | |
| const data = await resp.json(); | |
| renderCases(data.cases || [], vertical); | |
| } catch (e) { | |
| showToast('Failed to load cases', 'error'); | |
| } | |
| } | |
| function renderCases(cases, vertical) { | |
| const gridId = vertical === 'le' ? 'le-cases-grid' : 'ins-cases-grid'; | |
| const emptyId = vertical === 'le' ? 'le-empty-state-dashboard' : 'ins-empty-state-dashboard'; | |
| const grid = document.getElementById(gridId); | |
| const empty = document.getElementById(emptyId); | |
| if (!cases.length) { | |
| grid.innerHTML = ''; | |
| grid.style.display = 'none'; | |
| empty.style.display = 'flex'; | |
| return; | |
| } | |
| grid.style.display = 'grid'; | |
| empty.style.display = 'none'; | |
| const referenceCases = cases.filter(c => c.is_reference); | |
| const userCases = cases.filter(c => !c.is_reference); | |
| let html = ''; | |
| if (referenceCases.length) { | |
| html += `<h3 class="grid-header">Reference cases</h3>`; | |
| html += referenceCases.map(c => renderCaseCard(c, vertical)).join(''); | |
| } | |
| if (userCases.length) { | |
| const myCasesLabel = vertical === 'ins' ? 'All cases' : 'My cases'; | |
| html += `<h3 class="grid-header" style="margin-top: 1.5rem;">${myCasesLabel}</h3>`; | |
| html += userCases.map(c => renderCaseCard(c, vertical)).join(''); | |
| } | |
| grid.innerHTML = html; | |
| } | |
| function renderCaseCard(c, vertical) { | |
| return ` | |
| <div class="case-card ${c.is_reference ? 'reference-card' : ''}" onclick="openCase(${c.id})"> | |
| ${!c.is_reference ? ` | |
| <button class="btn-delete-card" onclick="event.stopPropagation(); deleteCase(${c.id}, '${escHtml(c.case_number)}', '${vertical}')" title="Delete Case"> | |
| <i class="fa-solid fa-trash-can"></i> | |
| </button>` : ''} | |
| <div class="card-header"> | |
| <span class="case-number">${escHtml(c.case_number)}</span> | |
| <span class="status-badge ${c.status}"> | |
| <i class="fa-solid fa-circle"></i> ${c.status} | |
| </span> | |
| </div> | |
| <div class="case-meta"> | |
| ${c.officer_name ? `<span><i class="fa-solid fa-user-shield"></i> ${escHtml(c.officer_name)}</span>` : ''} | |
| ${c.location ? `<span><i class="fa-solid fa-location-dot"></i> ${escHtml(c.location)}</span>` : ''} | |
| ${c.incident_date ? `<span><i class="fa-solid fa-calendar"></i> ${c.incident_date}</span>` : ''} | |
| </div> | |
| <div class="card-footer"> | |
| <span class="photo-count"><i class="fa-solid fa-camera"></i> ${c.photo_count || 0} photos</span> | |
| <span style="font-size:0.72rem;color:var(--text-muted)">${c.is_reference ? 'Static Reference' : formatDate(c.created_at)}</span> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| async function deleteCase(id, caseNum, vertical = 'le') { | |
| if (!confirm(`Are you sure you want to delete Case ${caseNum}? This will permanently remove all photos and analysis data.`)) { | |
| return; | |
| } | |
| try { | |
| const resp = await fetch(`${API_BASE}/cases/${id}`, { method: 'DELETE' }); | |
| if (!resp.ok) throw new Error('Delete failed'); | |
| showToast(`Case ${caseNum} deleted`, 'success'); | |
| loadCases(); | |
| } catch (e) { | |
| showToast(e.message, 'error'); | |
| } | |
| } | |
| // ββ Create Case βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function createCase(vertical = 'le') { | |
| const cid = vertical === 'le' ? 'case-number' : 'ins-case-number'; | |
| const oid = vertical === 'le' ? 'officer-name' : 'ins-officer-name'; | |
| const lid = vertical === 'le' ? 'incident-location' : 'ins-incident-location'; | |
| const did = vertical === 'le' ? 'incident-date' : 'ins-incident-date'; | |
| const nid = vertical === 'le' ? 'officer-notes' : 'ins-officer-notes'; | |
| const caseNumber = document.getElementById(cid).value.trim(); | |
| if (!caseNumber) return showToast('Case number is required', 'error'); | |
| const formData = new FormData(); | |
| formData.append('case_number', caseNumber); | |
| formData.append('officer_name', document.getElementById(oid).value.trim()); | |
| formData.append('location', document.getElementById(lid).value.trim()); | |
| formData.append('incident_date', document.getElementById(did).value); | |
| formData.append('notes', document.getElementById(nid).value.trim()); | |
| try { | |
| const resp = await fetch(`${API_BASE}/cases`, { method: 'POST', body: formData }); | |
| if (!resp.ok) { | |
| const err = await resp.json(); | |
| throw new Error(err.detail || 'Failed to create case'); | |
| } | |
| const data = await resp.json(); | |
| const caseId = data.id; | |
| showToast('Case created', 'success'); | |
| // Upload photos if selected | |
| if (selectedFiles.length > 0) { | |
| await uploadPhotos(caseId, vertical); | |
| } | |
| // Clear form | |
| document.getElementById(cid).value = ''; | |
| document.getElementById(oid).value = ''; | |
| document.getElementById(lid).value = ''; | |
| document.getElementById(did).value = ''; | |
| document.getElementById(nid).value = ''; | |
| selectedFiles = []; | |
| document.getElementById(vertical === 'le' ? 'photo-preview' : 'ins-photo-preview').classList.add('hidden'); | |
| // Open the case detail | |
| openCase(caseId); | |
| } catch (e) { | |
| showToast(e.message, 'error'); | |
| } | |
| } | |
| async function uploadPhotos(caseId, vertical = 'le') { | |
| const pfx = vertical === 'le' ? '' : 'ins-'; | |
| const progressSection = document.getElementById(`${pfx}upload-progress`); | |
| const progressFill = document.getElementById(`${pfx}upload-progress-fill`); | |
| const statusText = document.getElementById(`${pfx}upload-status-text`); | |
| progressSection.classList.remove('hidden'); | |
| statusText.textContent = `Uploading ${selectedFiles.length} photos...`; | |
| const formData = new FormData(); | |
| selectedFiles.forEach(f => formData.append('files', f)); | |
| try { | |
| progressFill.style.width = '50%'; | |
| const resp = await fetch(`${API_BASE}/cases/${caseId}/photos`, { | |
| method: 'POST', | |
| body: formData, | |
| }); | |
| if (!resp.ok) throw new Error('Upload failed'); | |
| const data = await resp.json(); | |
| progressFill.style.width = '100%'; | |
| statusText.textContent = `${data.count} photos uploaded β`; | |
| showToast(`${data.count} photos uploaded`, 'success'); | |
| return data; | |
| } catch (e) { | |
| statusText.textContent = 'Upload failed'; | |
| showToast('Photo upload failed', 'error'); | |
| throw e; | |
| } | |
| } | |
| // ββ Drop Zone βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function initDropZone() { | |
| setupZone('drop-zone', 'file-input', 'photo-preview', 'photo-thumbnails', 'photo-count'); | |
| setupZone('ins-drop-zone', 'ins-file-input', 'ins-photo-preview', 'ins-photo-thumbnails', 'ins-photo-count'); | |
| } | |
| function setupZone(dzId, inId, prvId, tnId, ctId) { | |
| const dropZone = document.getElementById(dzId); | |
| const fileInput = document.getElementById(inId); | |
| if (!dropZone || !fileInput) return; | |
| dropZone.addEventListener('click', () => fileInput.click()); | |
| dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); }); | |
| dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')); | |
| dropZone.addEventListener('drop', e => { | |
| e.preventDefault(); | |
| dropZone.classList.remove('drag-over'); | |
| handleFiles(Array.from(e.dataTransfer.files), prvId, tnId, ctId); | |
| }); | |
| fileInput.addEventListener('change', () => { | |
| handleFiles(Array.from(fileInput.files), prvId, tnId, ctId, prefix); | |
| }); | |
| } | |
| function handleFiles(files, prvId = 'photo-preview', tnId = 'photo-thumbnails', ctId = 'photo-count', prefix = 'le') { | |
| const imageFiles = files.filter(f => f.type.startsWith('image/')); | |
| if (!imageFiles.length) return showToast('No image files found', 'error'); | |
| selectedFiles = imageFiles.slice(0, 20); // Max 20 | |
| // Show previews | |
| const preview = document.getElementById(prvId); | |
| const thumbs = document.getElementById(tnId); | |
| const count = document.getElementById(ctId); | |
| preview.classList.remove('hidden'); | |
| count.textContent = `${selectedFiles.length} photo${selectedFiles.length > 1 ? 's' : ''} selected`; | |
| thumbs.innerHTML = ''; | |
| selectedFiles.forEach(file => { | |
| const div = document.createElement('div'); | |
| div.className = 'photo-thumbnail'; | |
| const img = document.createElement('img'); | |
| img.src = URL.createObjectURL(file); | |
| div.appendChild(img); | |
| thumbs.appendChild(div); | |
| }); | |
| validateForm(prefix); | |
| } | |
| // ββ Open Case Detail ββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function openCase(caseId) { | |
| currentCaseId = caseId; | |
| switchView('case-detail'); | |
| try { | |
| const resp = await fetch(`${API_BASE}/cases/${caseId}`); | |
| const data = await resp.json(); | |
| currentCaseData = data.case; | |
| renderCaseDetail(data); | |
| } catch (e) { | |
| showToast('Failed to load case', 'error'); | |
| } | |
| } | |
| // ββ Edit Case βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function openEditModal() { | |
| if (!currentCaseData) return; | |
| document.getElementById('edit-officer-name').value = currentCaseData.officer_name || ''; | |
| document.getElementById('edit-incident-location').value = currentCaseData.location || ''; | |
| document.getElementById('edit-incident-date').value = currentCaseData.incident_date || ''; | |
| document.getElementById('edit-officer-notes').value = currentCaseData.notes || ''; | |
| document.getElementById('modal-edit-case').classList.remove('hidden'); | |
| } | |
| function closeEditModal() { | |
| document.getElementById('modal-edit-case').classList.add('hidden'); | |
| } | |
| async function saveCaseChanges() { | |
| if (!currentCaseId) return; | |
| const formData = new FormData(); | |
| formData.append('officer_name', document.getElementById('edit-officer-name').value.trim()); | |
| formData.append('location', document.getElementById('edit-incident-location').value.trim()); | |
| formData.append('incident_date', document.getElementById('edit-incident-date').value); | |
| formData.append('notes', document.getElementById('edit-officer-notes').value.trim()); | |
| try { | |
| const resp = await fetch(`${API_BASE}/cases/${currentCaseId}`, { | |
| method: 'PUT', | |
| body: formData | |
| }); | |
| if (!resp.ok) throw new Error('Failed to update case'); | |
| showToast('Case updated', 'success'); | |
| closeEditModal(); | |
| openCase(currentCaseId); // Refresh | |
| } catch (e) { | |
| showToast(e.message, 'error'); | |
| } | |
| } | |
| // ββ Add Photos ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function openAddPhotosModal() { | |
| additionalFiles = []; | |
| document.getElementById('edit-photo-preview').classList.add('hidden'); | |
| document.getElementById('edit-upload-progress').classList.add('hidden'); | |
| document.getElementById('btn-upload-more').disabled = true; | |
| document.getElementById('modal-add-photos').classList.remove('hidden'); | |
| } | |
| function closePhotosModal() { | |
| document.getElementById('modal-add-photos').classList.add('hidden'); | |
| } | |
| function handleAdditionalFiles(files) { | |
| const imageFiles = files.filter(f => f.type.startsWith('image/')); | |
| if (!imageFiles.length) return showToast('No image files found', 'error'); | |
| additionalFiles = imageFiles.slice(0, 20); | |
| const preview = document.getElementById('edit-photo-preview'); | |
| const thumbs = document.getElementById('edit-photo-thumbnails'); | |
| const count = document.getElementById('edit-photo-count'); | |
| preview.classList.remove('hidden'); | |
| count.textContent = `${additionalFiles.length} photo${additionalFiles.length > 1 ? 's' : ''} selected`; | |
| thumbs.innerHTML = ''; | |
| additionalFiles.forEach(file => { | |
| const div = document.createElement('div'); | |
| div.className = 'photo-thumbnail'; | |
| const img = document.createElement('img'); | |
| img.src = URL.createObjectURL(file); | |
| div.appendChild(img); | |
| thumbs.appendChild(div); | |
| }); | |
| document.getElementById('btn-upload-more').disabled = additionalFiles.length === 0; | |
| } | |
| async function uploadMorePhotos() { | |
| if (!currentCaseId || !additionalFiles.length) return; | |
| const progressSection = document.getElementById('edit-upload-progress'); | |
| const progressFill = document.getElementById('edit-upload-progress-fill'); | |
| const statusText = document.getElementById('edit-upload-status-text'); | |
| progressSection.classList.remove('hidden'); | |
| document.getElementById('btn-upload-more').disabled = true; | |
| const formData = new FormData(); | |
| additionalFiles.forEach(f => formData.append('files', f)); | |
| try { | |
| progressFill.style.width = '50%'; | |
| const resp = await fetch(`${API_BASE}/cases/${currentCaseId}/photos`, { | |
| method: 'POST', | |
| body: formData, | |
| }); | |
| if (!resp.ok) throw new Error('Upload failed'); | |
| progressFill.style.width = '100%'; | |
| statusText.textContent = `Upload complete β`; | |
| showToast('Photos added successfully', 'success'); | |
| setTimeout(() => { | |
| closePhotosModal(); | |
| openCase(currentCaseId); // Refresh | |
| }, 1000); | |
| } catch (e) { | |
| statusText.textContent = 'Upload failed'; | |
| showToast('Photo upload failed', 'error'); | |
| document.getElementById('btn-upload-more').disabled = false; | |
| } | |
| } | |
| function renderCaseDetail(data) { | |
| const c = data.case; | |
| // Header | |
| document.getElementById('case-detail-title').textContent = `Case: ${c.case_number}`; | |
| // Info bar | |
| document.getElementById('detail-case-number').textContent = c.case_number; | |
| document.getElementById('detail-officer').textContent = c.officer_name || 'N/A'; | |
| document.getElementById('detail-location').textContent = c.location || 'N/A'; | |
| document.getElementById('detail-date').textContent = c.incident_date || 'N/A'; | |
| document.getElementById('detail-status').textContent = c.status; | |
| const statusChip = document.getElementById('detail-status-chip'); | |
| statusChip.className = `info-chip status-chip ${c.status}`; | |
| // Enable/disable buttons | |
| const btnAnalysis = document.getElementById('btn-run-analysis'); | |
| const btnReport = document.getElementById('btn-view-report'); | |
| const btnEdit = document.getElementById('btn-edit-case'); | |
| const btnAddPhotos = document.getElementById('btn-add-photos-inline'); | |
| if (c.is_reference) { | |
| btnAnalysis.disabled = true; | |
| btnAnalysis.title = "AI Analytics is disabled for reference cases"; | |
| if (btnEdit) btnEdit.disabled = true; | |
| if (btnAddPhotos) btnAddPhotos.disabled = true; | |
| } else { | |
| btnAnalysis.disabled = !data.photos?.length; | |
| btnAnalysis.title = data.photos?.length ? "" : "Upload photos to run analysis"; | |
| if (btnEdit) btnEdit.disabled = false; | |
| if (btnAddPhotos) btnAddPhotos.disabled = false; | |
| } | |
| btnReport.disabled = c.status !== 'complete'; | |
| // Photos | |
| const photosGrid = document.getElementById('detail-photos-grid'); | |
| document.getElementById('photo-badge').textContent = data.photos?.length || 0; | |
| if (data.photos?.length) { | |
| photosGrid.innerHTML = data.photos.map(p => { | |
| const imgSrc = `/${p.filepath}`; | |
| return ` | |
| <div class="detail-photo"> | |
| <img src="${imgSrc}" alt="${escHtml(p.filename)}" onerror="this.onerror=null; this.src='/static/placeholder.jpg';" loading="lazy"> | |
| </div> | |
| `; | |
| }).join(''); | |
| } else { | |
| photosGrid.innerHTML = '<p class="placeholder-text">No photos uploaded yet.</p>'; | |
| } | |
| // Analyses | |
| const analysisContent = document.getElementById('analysis-content'); | |
| if (data.analyses?.length) { | |
| analysisContent.innerHTML = data.analyses.map(a => ` | |
| <div class="analysis-photo-section" style="margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid var(--border);"> | |
| <div class="analysis-photo-label" style="font-weight: 600; margin-bottom: 1rem;">π· ${escHtml(a.filename || 'Photo')}</div> | |
| <div class="markdown-body"> | |
| ${marked.parse(a.raw_analysis || '')} | |
| </div> | |
| </div> | |
| `).join(''); | |
| } else { | |
| analysisContent.innerHTML = '<p class="placeholder-text">Run analysis to see AI observations.</p>'; | |
| } | |
| // Violations | |
| const violationsList = document.getElementById('violations-list'); | |
| const violationBadge = document.getElementById('violation-badge'); | |
| violationBadge.textContent = data.violations?.length || 0; | |
| if (data.violations?.length) { | |
| violationsList.innerHTML = data.violations.map(v => ` | |
| <div class="violation-card ${v.severity || 'MEDIUM'}"> | |
| <div class="violation-header"> | |
| <span class="violation-title">${escHtml(v.rule_title)}</span> | |
| <span class="severity-tag ${v.severity || 'MEDIUM'}">${v.severity || 'MEDIUM'}</span> | |
| </div> | |
| <div class="violation-meta"> | |
| <span><i class="fa-solid fa-hashtag"></i> ${v.rule_id}</span> | |
| <span><i class="fa-solid fa-percent"></i> ${Math.round(v.confidence * 100)}% confidence</span> | |
| ${v.party_label ? `<span><i class="fa-solid fa-car"></i> ${escHtml(v.party_label)}</span>` : ''} | |
| </div> | |
| ${v.evidence_summary ? `<div class="violation-evidence">${escHtml(v.evidence_summary)}</div>` : ''} | |
| </div> | |
| `).join(''); | |
| } else { | |
| violationsList.innerHTML = '<p class="placeholder-text">No violations detected yet.</p>'; | |
| } | |
| // Fault Analysis | |
| renderFaultAnalysis(data.fault_analysis, data.parties); | |
| } | |
| function renderFaultAnalysis(fault, parties) { | |
| const content = document.getElementById('fault-content'); | |
| if (!fault) { | |
| content.innerHTML = '<p class="placeholder-text">Run analysis to determine fault.</p>'; | |
| return; | |
| } | |
| let html = ''; | |
| // Fault distribution bars | |
| if (fault.fault_distribution_json) { | |
| let dist = {}; | |
| try { | |
| dist = typeof fault.fault_distribution_json === 'string' | |
| ? JSON.parse(fault.fault_distribution_json) | |
| : fault.fault_distribution_json; | |
| } catch { dist = {}; } | |
| const entries = Object.entries(dist); | |
| if (entries.length) { | |
| html += '<div class="fault-party-bars">'; | |
| entries.forEach(([label, pct], i) => { | |
| html += ` | |
| <div class="party-bar"> | |
| <div class="party-bar-label"> | |
| <span class="party-name">${escHtml(label)}</span> | |
| <span class="party-pct">${pct}%</span> | |
| </div> | |
| <div class="party-bar-track"> | |
| <div class="party-bar-fill ${i === 0 ? 'primary' : 'secondary'}" | |
| style="width: ${pct}%"></div> | |
| </div> | |
| </div> | |
| `; | |
| }); | |
| html += '</div>'; | |
| } | |
| } | |
| // Probable cause | |
| if (fault.probable_cause) { | |
| html += ` | |
| <div class="fault-cause"> | |
| <h4><i class="fa-solid fa-magnifying-glass-chart"></i> Probable Cause</h4> | |
| <p>${escHtml(fault.probable_cause)}</p> | |
| </div> | |
| `; | |
| } | |
| // Confidence | |
| if (fault.overall_confidence != null) { | |
| const pct = Math.round(fault.overall_confidence * 100); | |
| html += ` | |
| <div class="confidence-meter"> | |
| <span>Confidence:</span> | |
| <div class="meter-bar"> | |
| <div class="meter-fill" style="width: ${pct}%"></div> | |
| </div> | |
| <span>${pct}%</span> | |
| </div> | |
| `; | |
| } | |
| // Summary | |
| if (fault.analysis_summary) { | |
| html += `<p style="margin-top:0.8rem;font-size:0.82rem;color:var(--text-secondary)"> | |
| ${escHtml(fault.analysis_summary)}</p>`; | |
| } | |
| content.innerHTML = html || '<p class="placeholder-text">No fault analysis available.</p>'; | |
| } | |
| // ββ Run Analysis ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function runAnalysis() { | |
| if (!currentCaseId) return; | |
| const overlay = document.getElementById('analysis-overlay'); | |
| const stepEl = document.getElementById('analysis-step'); | |
| const detailEl = document.getElementById('analysis-detail'); | |
| overlay.classList.remove('hidden'); | |
| stepEl.textContent = 'Analyzing accident scene photos...'; | |
| detailEl.textContent = 'Running AI vision analysis on each photo. This may take several minutes.'; | |
| try { | |
| // Use Gradio API to trigger ZeroGPU explicitly | |
| const data = await callGradioApi('run_analysis', [currentCaseId]); | |
| overlay.classList.add('hidden'); | |
| showToast(`Analysis complete! Status: ${data[0]}`, 'success'); | |
| // Reload case detail | |
| openCase(currentCaseId); | |
| } catch (e) { | |
| overlay.classList.add('hidden'); | |
| showToast(`Analysis failed: ${e.message}`, 'error'); | |
| } | |
| } | |
| // ββ Chat and Simulation βββββββββββββββββββββββββββββββββββββββββββββββ | |
| let chatHistory = []; | |
| let currentSystemCtx = "You are TraceScene AI assistant. Answer concisely and accurately based on context provided."; | |
| async function sendChatMessage() { | |
| const input = document.getElementById('chat-input'); | |
| const msg = input.value.trim(); | |
| if (!msg) return; | |
| input.value = ''; | |
| const chatContainer = document.getElementById('chat-messages'); | |
| // Add user message | |
| chatContainer.innerHTML += ` | |
| <div class="chat-message user" style="text-align:right; margin:10px 0;"> | |
| <span style="background:var(--primary); color:white; padding:8px 12px; border-radius:15px; display:inline-block;">${escHtml(msg)}</span> | |
| </div> | |
| `; | |
| chatContainer.scrollTop = chatContainer.scrollHeight; | |
| // Add empty Assistant Bubble immediately | |
| const assistantBubbleId = `assistant-msg-${Date.now()}`; | |
| chatContainer.innerHTML += ` | |
| <div class="chat-message assistant" style="text-align:left; margin:10px 0; max-width:85%; display:flex; align-items:start;"> | |
| <i class="fa-solid fa-robot" style="margin-right:10px; margin-top:10px; color: var(--accent);"></i> | |
| <div id="${assistantBubbleId}" style="background:rgba(255,255,255,0.15); border:1px solid rgba(0,0,0,0.1); padding:10px 15px; border-radius:18px; color: #000; font-weight: 500; font-size: 0.95rem; line-height:1.5;"> | |
| <i style="color:var(--text-muted);">Typing...</i> | |
| </div> | |
| </div> | |
| `; | |
| chatContainer.scrollTop = chatContainer.scrollHeight; | |
| const bubbleElement = document.getElementById(assistantBubbleId); | |
| // Call Gradio Chat | |
| try { | |
| const responseData = await callGradioApi('chat', [msg, chatHistory, currentSystemCtx], (chunkData) => { | |
| // Handle partial stream update | |
| if (Array.isArray(chunkData) && chunkData.length > 0) { | |
| const partialHistory = chunkData[0]; | |
| if (Array.isArray(partialHistory) && partialHistory.length > 0) { | |
| const lastMsg = partialHistory[partialHistory.length - 1]; | |
| let text = ''; | |
| if (lastMsg && typeof lastMsg === 'object' && lastMsg.role === 'assistant') { | |
| text = lastMsg.content || ''; | |
| } else if (Array.isArray(lastMsg)) { | |
| text = lastMsg[1] || ''; | |
| } | |
| if (typeof text === 'object' && text !== null) { | |
| text = text.content || text.text || JSON.stringify(text); | |
| } | |
| if (bubbleElement) { | |
| bubbleElement.innerHTML = escHtml(text); | |
| } | |
| } | |
| } | |
| }); | |
| console.log('[Chat] Received final responseData:', responseData); | |
| const updatedHistory = responseData[0]; | |
| chatHistory = updatedHistory; | |
| let lastBotMsg = ''; | |
| if (Array.isArray(updatedHistory) && updatedHistory.length > 0) { | |
| const lastMsg = updatedHistory[updatedHistory.length - 1]; | |
| if (lastMsg && typeof lastMsg === 'object' && lastMsg.role === 'assistant') { | |
| lastBotMsg = lastMsg.content || ''; | |
| } else if (Array.isArray(lastMsg)) { | |
| lastBotMsg = lastMsg[1] || ''; | |
| } | |
| } | |
| if (typeof lastBotMsg === 'object' && lastBotMsg !== null) { | |
| lastBotMsg = lastBotMsg.content || lastBotMsg.text || JSON.stringify(lastBotMsg); | |
| } | |
| if (bubbleElement) { | |
| bubbleElement.innerHTML = escHtml(lastBotMsg) || '<i style="color:red;">(Empty Response)</i>'; | |
| } | |
| chatContainer.scrollTop = chatContainer.scrollHeight; | |
| } catch (err) { | |
| console.error('[Chat] Exception:', err); | |
| if (bubbleElement) { | |
| bubbleElement.innerHTML = `<i style="color:red;">Error: ${err.message}</i>`; | |
| } | |
| showToast('Chat failed: ' + err.message, 'error'); | |
| } | |
| } | |
| async function generateSimulation() { | |
| const caseIdInput = document.getElementById('sim-case-id').value; | |
| if (!caseIdInput) { | |
| showToast('Please enter a Case ID', 'error'); | |
| return; | |
| } | |
| const contentDiv = document.getElementById('simulation-content'); | |
| contentDiv.innerHTML = '<div class="spinner-large"></div>'; | |
| try { | |
| const simData = await callGradioApi('animation', [parseInt(caseIdInput)]); | |
| // The animation out is index 0 | |
| contentDiv.innerHTML = simData[0]; | |
| } catch (err) { | |
| contentDiv.innerHTML = `<p class="placeholder-text" style="color:red;">Error: ${err.message}</p>`; | |
| } | |
| } | |
| // ββ Report ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function viewReport() { | |
| if (!currentCaseId) return; | |
| switchView('report'); | |
| try { | |
| const resp = await fetch(`${API_BASE}/cases/${currentCaseId}/report`); | |
| const report = await resp.json(); | |
| renderReport(report); | |
| } catch (e) { | |
| showToast('Failed to load report', 'error'); | |
| } | |
| } | |
| function renderReport(report) { | |
| const content = document.getElementById('report-content'); | |
| let html = ` | |
| <div class="report-header"> | |
| <h2><i class="fa-solid fa-shield-halved"></i> ${report.report_type || 'Incident Report'}</h2> | |
| <p class="report-subtitle">Case ${escHtml(report.case?.case_number || '')} β’ Generated by AI</p> | |
| </div> | |
| <div class="report-disclaimer"> | |
| <i class="fa-solid fa-triangle-exclamation"></i> ${escHtml(report.disclaimer || '')} | |
| </div> | |
| `; | |
| // Stats | |
| const stats = report.statistics || {}; | |
| html += ` | |
| <div class="report-stat-grid"> | |
| <div class="report-stat"> | |
| <div class="stat-number">${stats.total_photos || 0}</div> | |
| <div class="stat-label">Photos</div> | |
| </div> | |
| <div class="report-stat"> | |
| <div class="stat-number">${stats.total_violations || 0}</div> | |
| <div class="stat-label">Violations</div> | |
| </div> | |
| <div class="report-stat"> | |
| <div class="stat-number">${stats.critical_violations || 0}</div> | |
| <div class="stat-label">Critical</div> | |
| </div> | |
| <div class="report-stat"> | |
| <div class="stat-number">${stats.parties_identified || 0}</div> | |
| <div class="stat-label">Parties</div> | |
| </div> | |
| </div> | |
| `; | |
| // Case Info | |
| html += `<h3>Case Information</h3>`; | |
| html += `<p><strong>Case Number:</strong> ${escHtml(report.case?.case_number || 'N/A')}</p>`; | |
| html += `<p><strong>Officer:</strong> ${escHtml(report.case?.officer_name || 'N/A')}</p>`; | |
| html += `<p><strong>Location:</strong> ${escHtml(report.case?.location || 'N/A')}</p>`; | |
| html += `<p><strong>Date:</strong> ${report.case?.incident_date || 'N/A'}</p>`; | |
| if (report.case?.notes) html += `<p><strong>Notes:</strong> ${escHtml(report.case.notes)}</p>`; | |
| // Scene Summary | |
| if (report.scene_summary) { | |
| html += `<h3>Scene Analysis</h3>`; | |
| html += `<p style="white-space:pre-wrap">${escHtml(report.scene_summary)}</p>`; | |
| } | |
| // Parties | |
| if (report.parties?.length) { | |
| html += `<h3>Parties Involved</h3>`; | |
| report.parties.forEach(p => { | |
| html += `<h4>${escHtml(p.label)}</h4>`; | |
| html += `<p>Type: ${p.vehicle_type || 'Unknown'} β’ Color: ${p.vehicle_color || 'Unknown'}</p>`; | |
| }); | |
| } | |
| // Violations | |
| if (report.violations?.list?.length) { | |
| html += `<h3>Traffic Violations Detected</h3>`; | |
| report.violations.list.forEach(v => { | |
| html += ` | |
| <div class="violation-card ${v.severity}" style="margin-bottom:0.5rem"> | |
| <div class="violation-header"> | |
| <span class="violation-title">${escHtml(v.title)}</span> | |
| <span class="severity-tag ${v.severity}">${v.severity}</span> | |
| </div> | |
| <p style="font-size:0.82rem;color:var(--text-secondary);margin-top:0.3rem"> | |
| Party: ${escHtml(v.party)} β’ Confidence: ${Math.round(v.confidence * 100)}% | |
| </p> | |
| ${v.evidence ? `<p style="font-size:0.78rem;color:var(--text-muted);font-style:italic">${escHtml(v.evidence)}</p>` : ''} | |
| </div> | |
| `; | |
| }); | |
| } | |
| // Fault Analysis | |
| if (report.fault_analysis?.determined) { | |
| html += `<h3>Fault Analysis</h3>`; | |
| html += `<p><strong>Primary Fault:</strong> ${escHtml(report.fault_analysis.primary_fault_party || 'Undetermined')}</p>`; | |
| if (report.fault_analysis.fault_distribution) { | |
| html += `<p><strong>Distribution:</strong> `; | |
| html += Object.entries(report.fault_analysis.fault_distribution) | |
| .map(([k, v]) => `${k}: ${v}%`).join(' β’ '); | |
| html += `</p>`; | |
| } | |
| html += `<p><strong>Confidence:</strong> ${Math.round((report.fault_analysis.overall_confidence || 0) * 100)}%</p>`; | |
| if (report.fault_analysis.probable_cause) { | |
| html += `<h4>Probable Cause</h4>`; | |
| html += `<p>${escHtml(report.fault_analysis.probable_cause)}</p>`; | |
| } | |
| } | |
| content.innerHTML = html; | |
| } | |
| // ββ Rules βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function loadRules() { | |
| try { | |
| const resp = await fetch(`${API_BASE}/rules`); | |
| const data = await resp.json(); | |
| renderRules(data); | |
| } catch (e) { | |
| showToast('Failed to load rules', 'error'); | |
| } | |
| } | |
| function renderRules(data) { | |
| const content = document.getElementById('rules-content'); | |
| if (!data.categories?.length) { | |
| content.innerHTML = '<p class="placeholder-text">No rules loaded.</p>'; | |
| return; | |
| } | |
| content.innerHTML = data.categories.map(cat => ` | |
| <div class="rule-category"> | |
| <div class="rule-category-header"> | |
| <i class="fa-solid fa-gavel"></i> | |
| ${escHtml(cat.name)} | |
| <span class="rule-count">${cat.rule_count} rules</span> | |
| </div> | |
| <div class="rule-list"> | |
| ${cat.rules.map(r => ` | |
| <div class="rule-item"> | |
| <span class="rule-id">${r.id}</span> | |
| <span>${escHtml(r.title)}</span> | |
| <span class="severity-tag ${r.severity}" style="margin-left:auto">${r.severity}</span> | |
| </div> | |
| `).join('')} | |
| </div> | |
| </div> | |
| `).join(''); | |
| } | |
| // ββ Toast βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function showToast(message, type = 'info') { | |
| const container = document.getElementById('toast-container'); | |
| const toast = document.createElement('div'); | |
| toast.className = `toast ${type}`; | |
| toast.textContent = message; | |
| container.appendChild(toast); | |
| setTimeout(() => toast.remove(), 4000); | |
| } | |
| // ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function escHtml(str) { | |
| if (!str) return ''; | |
| const div = document.createElement('div'); | |
| div.textContent = str; | |
| return div.innerHTML; | |
| } | |
| function formatDate(dateStr) { | |
| if (!dateStr) return ''; | |
| try { | |
| const d = new Date(dateStr); | |
| return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); | |
| } catch { return dateStr; } | |
| } | |