Download static/index.html from lablab-ai-amd-developer-hackathon/MediAgent: direct link, hf CLI and curl.
- Browser
- Download file 49.9 kB
-
https://huggingface.co/spaces/lablab-ai-amd-developer-hackathon/MediAgent/resolve/main/static/index.html
- Command line
-
hf download hf://spaces/lablab-ai-amd-developer-hackathon/MediAgent/static/index.html
-
curl -L -o index.html https://huggingface.co/spaces/lablab-ai-amd-developer-hackathon/MediAgent/resolve/main/static/index.html
49.9 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>MediAgent v2 | AMD MI300X Radiology AI</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> | |
| <script> | |
| tailwind.config = { | |
| theme: { | |
| extend: { | |
| fontFamily: { sans: ['Inter','sans-serif'], mono: ['JetBrains Mono','monospace'] }, | |
| colors: { | |
| med: { 50:'#eff6ff',100:'#dbeafe',200:'#bfdbfe',500:'#3b82f6',600:'#2563eb',700:'#1d4ed8',800:'#1e40af',900:'#1e3a8a' }, | |
| amd: { 400:'#f97316',500:'#ea580c',600:'#c2410c' } | |
| } | |
| } | |
| } | |
| } | |
| </script> | |
| <style> | |
| * { box-sizing: border-box; } | |
| body { height:100vh; overflow:hidden; background:#f1f5f9; } | |
| ::-webkit-scrollbar { width:5px; height:5px; } | |
| ::-webkit-scrollbar-track { background:transparent; } | |
| ::-webkit-scrollbar-thumb { background:#cbd5e1; border-radius:3px; } | |
| @keyframes fadeIn { from{opacity:0;transform:translateY(4px)} to{opacity:1;transform:translateY(0)} } | |
| @keyframes typing { from{opacity:0} to{opacity:1} } | |
| .animate-fade-in { animation:fadeIn 0.3s ease-out forwards; } | |
| .drop-zone.drag-over { border-color:#2563eb ; background:#eff6ff ; transform:scale(1.01); } | |
| .finding-NORMAL { border-left-color:#10b981; } | |
| .finding-INCIDENTAL{ border-left-color:#f59e0b; } | |
| .finding-SIGNIFICANT{border-left-color:#f97316; } | |
| .finding-CRITICAL { border-left-color:#ef4444; } | |
| .chat-bubble-user { background:#1e40af; color:#fff; border-radius:12px 12px 2px 12px; } | |
| .chat-bubble-ai { background:#f1f5f9; color:#1e293b; border-radius:12px 12px 12px 2px; } | |
| .token-stream { white-space:pre-wrap; } | |
| .gpu-bar { transition: width 0.8s ease; } | |
| .panel-tab.active { border-bottom:2px solid #2563eb; color:#1d4ed8; font-weight:600; } | |
| .dicom-badge { background:linear-gradient(135deg,#1e40af,#7c3aed); } | |
| </style> | |
| </head> | |
| <body class="flex flex-col text-slate-800 font-sans"> | |
| <!-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ HEADER --> | |
| <header class="h-14 bg-slate-900 text-white flex items-center justify-between px-5 shrink-0 z-20 shadow-lg"> | |
| <div class="flex items-center gap-3"> | |
| <div class="w-8 h-8 bg-med-600 rounded-lg flex items-center justify-center font-black text-white text-sm">M</div> | |
| <div> | |
| <div class="flex items-center gap-2"> | |
| <h1 class="font-bold text-base tracking-tight leading-none">MediAgent <span class="text-med-400">v2</span></h1> | |
| <span class="text-[9px] bg-amd-500 text-white px-1.5 py-0.5 rounded font-bold uppercase tracking-wider">AMD MI300X</span> | |
| </div> | |
| <div class="flex items-center gap-1.5 mt-0.5"> | |
| <span class="w-1.5 h-1.5 rounded-full bg-green-400 animate-pulse"></span> | |
| <span class="text-[9px] uppercase tracking-widest text-slate-400">System Online</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="flex items-center gap-3"> | |
| <div id="gpu-header-badge" class="hidden items-center gap-2 bg-slate-800 border border-orange-500/30 px-3 py-1 rounded-full"> | |
| <span class="w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse"></span> | |
| <span class="text-[10px] font-mono text-orange-300" id="gpu-header-text">GPU: --</span> | |
| </div> | |
| <span id="clock" class="text-xs font-mono text-slate-400"></span> | |
| </div> | |
| </header> | |
| <!-- βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ MAIN --> | |
| <main class="flex-1 grid grid-cols-12 overflow-hidden" style="height:calc(100vh - 56px)"> | |
| <!-- ββ LEFT: INPUT ββββββββββββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <section class="col-span-3 bg-slate-50 border-r border-slate-200 flex flex-col overflow-y-auto"> | |
| <div class="p-4 flex flex-col gap-4"> | |
| <!-- Upload zone --> | |
| <div> | |
| <p class="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2">Medical Image</p> | |
| <div id="drop-zone" class="drop-zone border-2 border-dashed border-slate-300 rounded-xl p-5 text-center cursor-pointer bg-white hover:border-med-500 transition-all relative group"> | |
| <input type="file" id="file-input" class="hidden" accept="image/png,image/jpeg,.dcm,application/dicom"> | |
| <div id="upload-placeholder"> | |
| <svg class="w-9 h-9 text-slate-300 mx-auto mb-2 group-hover:text-med-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path></svg> | |
| <p class="text-xs font-semibold text-slate-500">Drop image or click to upload</p> | |
| <p class="text-[10px] text-slate-400 mt-1">PNG Β· JPG Β· <span class="text-purple-600 font-semibold">DICOM (.dcm)</span></p> | |
| </div> | |
| <img id="image-preview" class="hidden w-full h-36 object-contain rounded-lg bg-slate-100" alt="Preview"> | |
| <div id="dicom-info" class="hidden mt-2 p-2 dicom-badge rounded-lg text-white text-[10px] font-mono leading-relaxed"></div> | |
| </div> | |
| </div> | |
| <!-- Form --> | |
| <form id="analysis-form" class="space-y-3"> | |
| <div> | |
| <label class="block text-[10px] font-bold text-slate-600 uppercase tracking-wider mb-1">Chief Complaint</label> | |
| <textarea id="symptoms" rows="2" class="w-full text-xs border border-slate-300 rounded-lg p-2.5 focus:ring-2 focus:ring-med-500 focus:border-med-500 outline-none resize-none" placeholder="e.g. Chest pain, shortness of breath..."></textarea> | |
| </div> | |
| <div class="grid grid-cols-2 gap-2"> | |
| <div> | |
| <label class="block text-[10px] font-bold text-slate-600 uppercase tracking-wider mb-1">Age</label> | |
| <input type="number" id="age" min="0" max="120" class="w-full text-xs border border-slate-300 rounded-lg p-2.5 focus:ring-2 focus:ring-med-500 outline-none" placeholder="Yrs"> | |
| </div> | |
| <div> | |
| <label class="block text-[10px] font-bold text-slate-600 uppercase tracking-wider mb-1">Sex</label> | |
| <select id="sex" class="w-full text-xs border border-slate-300 rounded-lg p-2.5 focus:ring-2 focus:ring-med-500 outline-none bg-white"> | |
| <option value="">Select</option> | |
| <option value="M">Male</option> | |
| <option value="F">Female</option> | |
| <option value="O">Other</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div> | |
| <label class="block text-[10px] font-bold text-slate-600 uppercase tracking-wider mb-1">Clinical History</label> | |
| <textarea id="clinical_context" rows="2" class="w-full text-xs border border-slate-300 rounded-lg p-2.5 focus:ring-2 focus:ring-med-500 outline-none resize-none" placeholder="Relevant history, medications..."></textarea> | |
| </div> | |
| <button type="submit" id="submit-btn" class="w-full bg-med-800 hover:bg-med-900 text-white font-bold py-2.5 rounded-lg shadow transition-all flex items-center justify-center gap-2 disabled:opacity-40 disabled:cursor-not-allowed text-sm"> | |
| <span id="btn-text">Run Analysis</span> | |
| <svg id="btn-spinner" class="hidden w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg> | |
| </button> | |
| </form> | |
| <!-- AMD GPU Panel --> | |
| <div style="background: var(--color-background-primary); border-radius: var(--border-radius-lg); border: 0.5px solid var(--color-border-tertiary); padding: 14px 16px;"> | |
| <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px;"> | |
| <div style="display: flex; align-items: center; gap: 8px;"> | |
| <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#f97316" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2" /><path d="M6 6V4M10 6V4M14 6V4M18 6V4M6 20v-2M10 20v-2M14 20v-2M18 20v-2" /></svg> | |
| <span style="font-size: 11px; font-weight: 500; color: #f97316; letter-spacing: 0.06em; text-transform: uppercase;">AMD GPU Metrics</span> | |
| </div> | |
| <span id="gpu-status-dot" style="width: 8px; height: 8px; border-radius: 50%; background: var(--color-border-secondary); display: inline-block;"></span> | |
| </div> | |
| <div id="gpu-panel-content"> | |
| <p style="font-size: 11px; color: var(--color-text-tertiary); font-family: var(--font-mono); text-align: center; padding: 8px 0; margin: 0;">Polling GPU...</p> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- ββ CENTER: PIPELINE + CHARTS ββββββββββββββββββββββββββββββββββββββββ --> | |
| <section class="col-span-3 bg-white border-r border-slate-200 flex flex-col overflow-hidden"> | |
| <!-- Tabs --> | |
| <div class="flex border-b border-slate-200 px-4 pt-3 gap-4 shrink-0"> | |
| <button class="panel-tab active text-[11px] pb-2 px-1 text-slate-500 transition-colors" onclick="switchTab('pipeline',this)">Pipeline</button> | |
| <button class="panel-tab text-[11px] pb-2 px-1 text-slate-500 transition-colors" onclick="switchTab('charts',this)">Analytics</button> | |
| </div> | |
| <!-- Pipeline tab --> | |
| <div id="tab-pipeline" class="flex-1 overflow-y-auto p-4 space-y-3"> | |
| <div id="pipeline-tracker"></div> | |
| <div class="p-3 bg-slate-50 rounded-lg border border-slate-200 mt-2"> | |
| <p class="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-1">System Log</p> | |
| <div id="pipeline-log" class="text-[11px] font-mono text-slate-600 leading-relaxed">Waiting for input...</div> | |
| <div id="pipeline-timer" class="text-[11px] font-mono text-med-600 mt-1 hidden"></div> | |
| </div> | |
| </div> | |
| <!-- Analytics tab --> | |
| <div id="tab-charts" class="hidden flex-1 overflow-y-auto p-4 space-y-4"> | |
| <div> | |
| <p class="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2">Severity Distribution</p> | |
| <div class="flex justify-center"><canvas id="severityChart" width="160" height="160"></canvas></div> | |
| </div> | |
| <div> | |
| <p class="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2">Finding Confidence</p> | |
| <canvas id="confidenceChart" height="160"></canvas> | |
| </div> | |
| <div> | |
| <p class="text-[10px] font-bold text-slate-500 uppercase tracking-widest mb-2">Agent Timing (s)</p> | |
| <canvas id="timingChart" height="120"></canvas> | |
| </div> | |
| </div> | |
| </section> | |
| <!-- ββ RIGHT: REPORT + CHAT ββββββββββββββββββββββββββββββββββββββββββββ --> | |
| <section class="col-span-6 flex flex-col overflow-hidden bg-white"> | |
| <!-- Report area --> | |
| <div id="report-scroll" class="flex-1 overflow-y-auto px-6 py-5"> | |
| <!-- Empty state --> | |
| <div id="empty-state" class="h-full flex flex-col items-center justify-center text-slate-300"> | |
| <svg class="w-16 h-16 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg> | |
| <p class="text-sm font-medium text-slate-400">Upload an image and run analysis</p> | |
| <p class="text-xs text-slate-300 mt-1">Supports PNG, JPG, DICOM</p> | |
| </div> | |
| <!-- Final report --> | |
| <div id="report-content" class="hidden space-y-5 animate-fade-in"> | |
| <!-- Severity banner --> | |
| <div id="severity-banner" class="px-4 py-3 rounded-xl flex items-center justify-between"> | |
| <div class="flex items-center gap-3"> | |
| <span id="banner-icon" class="w-8 h-8 rounded-full flex items-center justify-center bg-white/50"></span> | |
| <span id="severity-text" class="font-bold text-sm uppercase tracking-wide">Severity: NORMAL</span> | |
| </div> | |
| <div class="text-[10px] font-mono opacity-75 flex items-center gap-3"> | |
| <span id="meta-id">---</span> | |
| <span>|</span> | |
| <span id="meta-time">---</span> | |
| <span>|</span> | |
| <span id="meta-qa" class="font-bold">QA ---</span> | |
| </div> | |
| </div> | |
| <!-- DICOM metadata card --> | |
| <div id="dicom-meta-card" class="hidden p-3 rounded-xl border border-purple-200 bg-purple-50"> | |
| <p class="text-[10px] font-bold text-purple-700 uppercase tracking-widest mb-2">DICOM Metadata</p> | |
| <div id="dicom-meta-content" class="grid grid-cols-2 gap-x-4 gap-y-1 text-[11px] font-mono text-purple-800"></div> | |
| </div> | |
| <!-- Report sections --> | |
| <div class="space-y-4"> | |
| <div><h3 class="text-xs font-bold text-slate-700 border-b border-slate-100 pb-1 mb-2 uppercase tracking-wide">Clinical History</h3><p id="sec-history" class="text-sm text-slate-600 leading-relaxed"></p></div> | |
| <div><h3 class="text-xs font-bold text-slate-700 border-b border-slate-100 pb-1 mb-2 uppercase tracking-wide">Technique</h3><p id="sec-technique" class="text-sm text-slate-600 leading-relaxed"></p></div> | |
| <div><h3 class="text-xs font-bold text-slate-700 border-b border-slate-100 pb-1 mb-2 uppercase tracking-wide">Findings</h3><div id="sec-findings" class="space-y-2"></div></div> | |
| <div><h3 class="text-xs font-bold text-slate-700 border-b border-slate-100 pb-1 mb-2 uppercase tracking-wide">Impression</h3><p id="sec-impression" class="text-sm text-slate-700 leading-relaxed font-medium"></p></div> | |
| <div><h3 class="text-xs font-bold text-slate-700 border-b border-slate-100 pb-1 mb-2 uppercase tracking-wide">Recommendations</h3><p id="sec-recommendations" class="text-sm text-slate-600 leading-relaxed"></p></div> | |
| <div class="bg-red-50 border border-red-100 rounded-xl p-4"><p class="text-[11px] text-red-800 italic font-medium" id="sec-disclaimer"></p></div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Bottom bar: actions + chat --> | |
| <div class="border-t border-slate-200 bg-white shrink-0"> | |
| <!-- Action buttons --> | |
| <div class="flex items-center gap-2 px-4 py-2 border-b border-slate-100"> | |
| <button id="export-pdf-btn" disabled class="flex items-center gap-1.5 bg-slate-800 hover:bg-slate-700 text-white text-xs font-semibold px-4 py-1.5 rounded-lg shadow disabled:opacity-30 disabled:cursor-not-allowed transition-all"> | |
| <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg> | |
| Export PDF | |
| </button> | |
| <button id="toggle-chat-btn" disabled class="ml-auto flex items-center gap-1.5 bg-blue-600 hover:bg-blue-700 text-white text-xs font-semibold px-4 py-1.5 rounded-lg shadow disabled:opacity-30 disabled:cursor-not-allowed transition-all"> | |
| <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path></svg> | |
| Ask AI Consultant | |
| </button> | |
| </div> | |
| <!-- Clinical chat --> | |
| <div id="chat-panel" class="hidden flex flex-col" style="height:240px"> | |
| <div id="chat-messages" class="flex-1 overflow-y-auto px-4 py-3 space-y-2 bg-slate-50"></div> | |
| <div class="flex gap-2 px-4 py-2 bg-white border-t border-slate-100"> | |
| <input id="chat-input" type="text" placeholder="Ask a follow-up question about the report..." class="flex-1 text-xs border border-slate-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-400 outline-none"> | |
| <button id="chat-send-btn" class="bg-blue-600 hover:bg-blue-700 text-white text-xs font-bold px-4 py-2 rounded-lg transition-all">Send</button> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| </main> | |
| <!-- Toast --> | |
| <div id="toast" class="fixed bottom-5 right-5 translate-y-16 opacity-0 transition-all duration-300 z-50 pointer-events-none"> | |
| <div id="toast-inner" class="bg-slate-800 text-white px-4 py-2.5 rounded-lg shadow-xl text-xs font-medium flex items-center gap-2"> | |
| <span id="toast-message">Message</span> | |
| </div> | |
| </div> | |
| <script> | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ GLOBALS | |
| const AGENTS = ['INTAKE','VISION','RESEARCH','REPORT','CRITIC']; | |
| let lastReportData = null; | |
| let currentReportId = null; | |
| let startTime = 0; | |
| let timerInterval = null; | |
| let agentTimings = {}; | |
| let agentStartTimes = {}; | |
| let severityChart = null; | |
| let confidenceChart = null; | |
| let timingChart = null; | |
| let gpuPollInterval = null; | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ INIT | |
| function init() { | |
| renderAgents(); | |
| setInterval(() => { | |
| document.getElementById('clock').textContent = new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'}); | |
| }, 1000); | |
| startGpuPolling(); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ TAB SWITCHING | |
| function switchTab(name, btn) { | |
| document.querySelectorAll('.panel-tab').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| document.getElementById('tab-pipeline').classList.toggle('hidden', name !== 'pipeline'); | |
| document.getElementById('tab-charts').classList.toggle('hidden', name !== 'charts'); | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ FILE UPLOAD | |
| const dropZone = document.getElementById('drop-zone'); | |
| const fileInput = document.getElementById('file-input'); | |
| 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'); if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]); }); | |
| fileInput.addEventListener('change', e => { if (e.target.files.length) handleFile(e.target.files[0]); }); | |
| function handleFile(file) { | |
| const isDicom = file.name.toLowerCase().endsWith('.dcm') || file.type === 'application/dicom'; | |
| if (!isDicom && !file.type.startsWith('image/')) { showToast('Unsupported file type.', 'error'); return; } | |
| const reader = new FileReader(); | |
| reader.onload = e => { | |
| const preview = document.getElementById('image-preview'); | |
| const placeholder = document.getElementById('upload-placeholder'); | |
| const dicomInfo = document.getElementById('dicom-info'); | |
| if (isDicom) { | |
| preview.classList.add('hidden'); | |
| placeholder.classList.add('hidden'); | |
| dicomInfo.classList.remove('hidden'); | |
| dicomInfo.innerHTML = `π DICOM File Detected<br><span class="opacity-75">${file.name}</span><br><span class="opacity-60">${(file.size/1024).toFixed(1)} KB</span>`; | |
| showToast('DICOM file loaded β metadata will be auto-extracted', 'success'); | |
| } else { | |
| preview.src = e.target.result; | |
| preview.classList.remove('hidden'); | |
| placeholder.classList.add('hidden'); | |
| dicomInfo.classList.add('hidden'); | |
| showToast('Image loaded.', 'success'); | |
| } | |
| }; | |
| if (isDicom) { | |
| reader.readAsArrayBuffer(file); | |
| } else { | |
| reader.readAsDataURL(file); | |
| } | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ AGENT PIPELINE UI | |
| function renderAgents() { | |
| document.getElementById('pipeline-tracker').innerHTML = AGENTS.map(a => ` | |
| <div id="card-${a}" class="rounded-xl border border-slate-200 bg-white p-3 flex items-center gap-3 transition-all duration-300"> | |
| <div id="icon-${a}" class="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center shrink-0 transition-all"> | |
| <svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-width="2"/></svg> | |
| </div> | |
| <div class="flex-1 min-w-0"> | |
| <div class="flex justify-between items-center"> | |
| <span class="text-[11px] font-bold text-slate-700 uppercase tracking-wider">${a}</span> | |
| <span id="badge-${a}" class="text-[9px] font-mono font-bold px-1.5 py-0.5 rounded bg-slate-100 text-slate-500">WAITING</span> | |
| </div> | |
| <span id="timing-${a}" class="text-[9px] font-mono text-slate-400"></span> | |
| </div> | |
| </div> | |
| `).join(''); | |
| } | |
| function updateAgent(name, status, elapsed) { | |
| const card = document.getElementById(`card-${name}`); | |
| const badge = document.getElementById(`badge-${name}`); | |
| const icon = document.getElementById(`icon-${name}`); | |
| const timEl = document.getElementById(`timing-${name}`); | |
| if (!card) return; | |
| const cfg = { | |
| WAITING: { card:'border-slate-200 bg-white', badge:'bg-slate-100 text-slate-500', icon:'bg-slate-100', iconHtml:'<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-width="2"/></svg>' }, | |
| RUNNING: { card:'border-blue-400 bg-blue-50', badge:'bg-blue-100 text-blue-700', icon:'bg-blue-100', iconHtml:'<svg class="w-4 h-4 text-blue-600 animate-spin" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>' }, | |
| DONE: { card:'border-green-400 bg-green-50', badge:'bg-green-100 text-green-700', icon:'bg-green-100', iconHtml:'<svg class="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/></svg>' }, | |
| ERROR: { card:'border-red-400 bg-red-50', badge:'bg-red-100 text-red-700', icon:'bg-red-100', iconHtml:'<svg class="w-4 h-4 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg>' }, | |
| }; | |
| const c = cfg[status] || cfg.WAITING; | |
| card.className = `rounded-xl border p-3 flex items-center gap-3 transition-all duration-300 ${c.card}`; | |
| badge.className = `text-[9px] font-mono font-bold px-1.5 py-0.5 rounded ${c.badge}`; | |
| badge.textContent = status; | |
| icon.className = `w-8 h-8 rounded-full flex items-center justify-center shrink-0 transition-all ${c.icon}`; | |
| icon.innerHTML = c.iconHtml; | |
| if (elapsed !== undefined) timEl.textContent = `${elapsed.toFixed(2)}s`; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ SUBMIT | |
| document.getElementById('analysis-form').addEventListener('submit', async e => { | |
| e.preventDefault(); | |
| const files = fileInput.files; | |
| if (!files.length) { showToast('Upload an image first.', 'error'); return; } | |
| setLoading(true); | |
| renderAgents(); | |
| resetReport(); | |
| agentTimings = {}; | |
| agentStartTimes = {}; | |
| startTimer(); | |
| log('Sending to AMD MI300X pipeline...'); | |
| const fd = new FormData(); | |
| fd.append('image', files[0]); | |
| fd.append('symptoms', document.getElementById('symptoms').value); | |
| fd.append('age', document.getElementById('age').value || ''); | |
| fd.append('sex', document.getElementById('sex').value); | |
| fd.append('clinical_context', document.getElementById('clinical_context').value); | |
| try { | |
| const res = await fetch('/analyze/stream', { method:'POST', body:fd }); | |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); | |
| const reader = res.body.getReader(); | |
| const dec = new TextDecoder(); | |
| let buf = ''; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buf += dec.decode(value, { stream:true }); | |
| const parts = buf.split('\n\n'); | |
| buf = parts.pop(); | |
| for (const part of parts) { | |
| if (part.startsWith('data: ')) { | |
| try { handleSSE(JSON.parse(part.slice(6))); } catch {} | |
| } | |
| } | |
| } | |
| } catch (err) { | |
| stopTimer(); | |
| showToast(`Error: ${err.message}`, 'error'); | |
| log(`ERROR: ${err.message}`); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }); | |
| function handleSSE(data) { | |
| if (data.agent && data.status) { | |
| const now = performance.now(); | |
| if (data.status === 'RUNNING') { | |
| agentStartTimes[data.agent] = now; | |
| } else if (data.status === 'DONE' || data.status === 'ERROR') { | |
| const elapsed = agentStartTimes[data.agent] ? (now - agentStartTimes[data.agent]) / 1000 : 0; | |
| agentTimings[data.agent] = elapsed; | |
| updateAgent(data.agent, data.status, elapsed); | |
| } | |
| if (data.status === 'RUNNING') updateAgent(data.agent, 'RUNNING'); | |
| log(`${data.agent} β ${data.status}`); | |
| return; | |
| } | |
| if (data.type === 'report') { | |
| stopTimer(); | |
| lastReportData = data.data; | |
| currentReportId = data.report_id || data.data?.report_id; | |
| renderReport(data.data); | |
| updateCharts(data.data); | |
| document.getElementById('export-pdf-btn').disabled = false; | |
| document.getElementById('toggle-chat-btn').disabled = false; | |
| showToast('Analysis complete.', 'success'); | |
| return; | |
| } | |
| if (data.type === 'error') { | |
| stopTimer(); | |
| showToast(`Pipeline error: ${data.message}`, 'error'); | |
| log(`ERROR: ${data.message}`); | |
| } | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ REPORT RENDER | |
| function resetReport() { | |
| document.getElementById('empty-state').classList.remove('hidden'); | |
| document.getElementById('report-content').classList.add('hidden'); | |
| document.getElementById('dicom-meta-card').classList.add('hidden'); | |
| document.getElementById('export-pdf-btn').disabled = true; | |
| document.getElementById('toggle-chat-btn').disabled = true; | |
| document.getElementById('chat-panel').classList.add('hidden'); | |
| document.getElementById('chat-messages').innerHTML = ''; | |
| } | |
| function renderReport(data) { | |
| const s = data.sections; | |
| document.getElementById('empty-state').classList.add('hidden'); | |
| document.getElementById('report-content').classList.remove('hidden'); | |
| // Severity banner | |
| const sev = data.overall_severity || 'NORMAL'; | |
| const bannerCfg = { | |
| NORMAL: 'bg-emerald-50 text-emerald-800 border border-emerald-200', | |
| INCIDENTAL: 'bg-amber-50 text-amber-800 border border-amber-200', | |
| SIGNIFICANT: 'bg-orange-50 text-orange-800 border border-orange-200', | |
| CRITICAL: 'bg-red-50 text-red-800 border border-red-200 animate-pulse', | |
| }; | |
| const banner = document.getElementById('severity-banner'); | |
| banner.className = `px-4 py-3 rounded-xl flex items-center justify-between ${bannerCfg[sev] || bannerCfg.NORMAL}`; | |
| document.getElementById('severity-text').textContent = `Severity: ${sev}`; | |
| const iconCfg = { NORMAL:'β ', INCIDENTAL:'β οΈ', SIGNIFICANT:'πΆ', CRITICAL:'π¨' }; | |
| document.getElementById('banner-icon').textContent = iconCfg[sev] || 'β '; | |
| // Meta | |
| document.getElementById('meta-id').textContent = data.report_id || '---'; | |
| document.getElementById('meta-time').textContent = data.generation_timestamp | |
| ? new Date(data.generation_timestamp).toLocaleTimeString() : '---'; | |
| const qaM = (s.recommendations || '').match(/Score[:\s]+(\d+)/); | |
| document.getElementById('meta-qa').textContent = `QA ${qaM ? qaM[1] : '85'}/100`; | |
| // DICOM metadata | |
| if (data.dicom_metadata && Object.keys(data.dicom_metadata).length) { | |
| const card = document.getElementById('dicom-meta-card'); | |
| const content = document.getElementById('dicom-meta-content'); | |
| card.classList.remove('hidden'); | |
| const show = ['modality','body_part','study_date','institution','kvp','slice_thickness_mm','image_rows','image_cols']; | |
| content.innerHTML = show | |
| .filter(k => data.dicom_metadata[k]) | |
| .map(k => `<div><span class="opacity-60">${k.replace(/_/g,' ')}:</span> <span class="font-semibold">${data.dicom_metadata[k]}</span></div>`) | |
| .join(''); | |
| } | |
| // Sections | |
| document.getElementById('sec-history').textContent = s.clinical_history || 'Not provided.'; | |
| document.getElementById('sec-technique').textContent = s.technique || 'Not provided.'; | |
| document.getElementById('sec-impression').textContent = s.impression || 'Not provided.'; | |
| document.getElementById('sec-recommendations').textContent = s.recommendations || 'None.'; | |
| document.getElementById('sec-disclaimer').textContent = s.disclaimer; | |
| document.getElementById('sec-findings').innerHTML = renderFindings(s.findings); | |
| document.getElementById('report-scroll').scrollTop = 0; | |
| } | |
| function renderFindings(text) { | |
| if (!text) return '<p class="text-xs text-slate-400 italic">No findings.</p>'; | |
| const sevOrder = { CRITICAL:4, SIGNIFICANT:3, INCIDENTAL:2, NORMAL:1 }; | |
| const sevColors = { | |
| NORMAL: { border:'finding-NORMAL', badge:'bg-emerald-100 text-emerald-800', bar:'bg-emerald-500' }, | |
| INCIDENTAL: { border:'finding-INCIDENTAL', badge:'bg-amber-100 text-amber-800', bar:'bg-amber-500' }, | |
| SIGNIFICANT: { border:'finding-SIGNIFICANT', badge:'bg-orange-100 text-orange-800', bar:'bg-orange-500' }, | |
| CRITICAL: { border:'finding-CRITICAL', badge:'bg-red-100 text-red-800', bar:'bg-red-500' }, | |
| }; | |
| // Find highest severity in text | |
| let topSev = 'NORMAL'; | |
| for (const sev of ['CRITICAL','SIGNIFICANT','INCIDENTAL','NORMAL']) { | |
| if (text.toUpperCase().includes(sev)) { topSev = sev; break; } | |
| } | |
| // Extract confidence scores | |
| const confMatches = [...text.matchAll(/(\d+(?:\.\d+)?)%/g)]; | |
| const avgConf = confMatches.length | |
| ? Math.round(confMatches.reduce((a,m) => a + parseFloat(m[1]), 0) / confMatches.length) | |
| : 75; | |
| const c = sevColors[topSev] || sevColors.NORMAL; | |
| const confColor = avgConf >= 75 ? 'bg-emerald-500' : avgConf >= 50 ? 'bg-amber-500' : 'bg-red-500'; | |
| return ` | |
| <div class="pl-4 pr-4 py-4 rounded-lg border-l-4 ${c.border} bg-white shadow-sm"> | |
| <div class="flex justify-between items-start mb-3"> | |
| <span class="text-[10px] font-bold px-2 py-0.5 rounded ${c.badge} uppercase">${topSev}</span> | |
| <span class="text-[10px] font-mono text-slate-500">${avgConf}% avg confidence</span> | |
| </div> | |
| <p class="text-sm text-slate-700 leading-relaxed whitespace-pre-wrap mb-3">${text}</p> | |
| <div class="flex items-center gap-2"> | |
| <span class="text-[9px] font-mono text-slate-400 uppercase w-16">Confidence</span> | |
| <div class="flex-1 h-1.5 bg-slate-100 rounded-full overflow-hidden"> | |
| <div class="h-full rounded-full ${confColor} transition-all duration-1000" style="width:${avgConf}%"></div> | |
| </div> | |
| <span class="text-[10px] font-bold font-mono text-slate-600">${avgConf}%</span> | |
| </div> | |
| </div>`; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ CHARTS | |
| function updateCharts(data) { | |
| // Severity donut β read directly from structured vision findings | |
| const sevCounts = { NORMAL: 0, INCIDENTAL: 0, SIGNIFICANT: 0, CRITICAL: 0 }; | |
| const visionFindings = data.vision_findings || []; | |
| if (visionFindings.length > 0) { | |
| for (const f of visionFindings) { | |
| const sev = (f.severity || 'NORMAL').toUpperCase(); | |
| if (sev in sevCounts) sevCounts[sev]++; | |
| } | |
| } | |
| if (Object.values(sevCounts).every(v => v === 0)) sevCounts[data.overall_severity || 'NORMAL'] = 1; | |
| if (severityChart) severityChart.destroy(); | |
| severityChart = new Chart(document.getElementById('severityChart'), { | |
| type: 'doughnut', | |
| data: { | |
| labels: Object.keys(sevCounts), | |
| datasets: [{ data: Object.values(sevCounts), backgroundColor: ['#10b981', '#f59e0b', '#f97316', '#ef4444'], borderWidth: 2, borderColor: '#fff' }] | |
| }, | |
| options: { responsive: false, plugins: { legend: { position: 'bottom', labels: { font: { size: 9 }, padding: 8 } } }, cutout: '65%' } | |
| }); | |
| // Confidence bar β read directly from structured differential diagnoses | |
| const differentials = data.differential_diagnoses || []; | |
| const labels = differentials.length > 0 | |
| ? differentials.map(d => (d.condition_name || 'Unknown').slice(0, 22)) | |
| : ['Dx 1', 'Dx 2', 'Dx 3']; | |
| const probs = differentials.length > 0 | |
| ? differentials.map(d => Math.round(parseFloat(d.match_probability) || 0)) | |
| : [0, 0, 0]; | |
| if (confidenceChart) confidenceChart.destroy(); | |
| confidenceChart = new Chart(document.getElementById('confidenceChart'), { | |
| type: 'bar', | |
| data: { | |
| labels: labels, | |
| datasets: [{ label: 'Probability %', data: probs, backgroundColor: ['#3b82f6', '#8b5cf6', '#06b6d4', '#10b981', '#f97316'], borderRadius: 4 }] | |
| }, | |
| options: { responsive: true, indexAxis: 'y', scales: { x: { max: 100, ticks: { font: { size: 9 } } }, y: { ticks: { font: { size: 9 } } } }, plugins: { legend: { display: false } } } | |
| }); | |
| // Agent timing | |
| const agentLabels = Object.keys(agentTimings); | |
| const agentValues = agentLabels.map(k => parseFloat(agentTimings[k].toFixed(2))); | |
| if (timingChart) timingChart.destroy(); | |
| if (agentLabels.length) { | |
| timingChart = new Chart(document.getElementById('timingChart'), { | |
| type: 'bar', | |
| data: { | |
| labels: agentLabels, | |
| datasets: [{ label:'Seconds', data:agentValues, backgroundColor:'#f97316', borderRadius:4 }] | |
| }, | |
| options: { responsive:true, scales:{ y:{ ticks:{font:{size:9}} }, x:{ ticks:{font:{size:9}} } }, plugins:{ legend:{display:false} } } | |
| }); | |
| } | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ CLINICAL CHAT | |
| document.getElementById('toggle-chat-btn').addEventListener('click', () => { | |
| const panel = document.getElementById('chat-panel'); | |
| const isHidden = panel.classList.contains('hidden'); | |
| panel.classList.toggle('hidden', !isHidden); | |
| if (isHidden && document.getElementById('chat-messages').children.length === 0) { | |
| appendChat('assistant', "Report loaded. Ask me anything about the findings, differentials, or recommendations."); | |
| } | |
| }); | |
| document.getElementById('chat-send-btn').addEventListener('click', sendChat); | |
| document.getElementById('chat-input').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); } }); | |
| async function sendChat() { | |
| const input = document.getElementById('chat-input'); | |
| const q = input.value.trim(); | |
| if (!q || !currentReportId) return; | |
| input.value = ''; | |
| appendChat('user', q); | |
| const thinking = appendChat('assistant', 'β³ Consulting AI...'); | |
| try { | |
| const res = await fetch(`/chat/${currentReportId}`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ question: q }) | |
| }); | |
| const data = await res.json(); | |
| thinking.textContent = data.answer || 'No response.'; | |
| } catch (e) { | |
| thinking.textContent = 'Failed to get response. Please try again.'; | |
| } | |
| } | |
| function appendChat(role, text) { | |
| const container = document.getElementById('chat-messages'); | |
| const div = document.createElement('div'); | |
| div.className = `text-xs leading-relaxed p-2.5 max-w-[90%] animate-fade-in ${role === 'user' ? 'chat-bubble-user ml-auto' : 'chat-bubble-ai'}`; | |
| div.textContent = text; | |
| container.appendChild(div); | |
| container.scrollTop = container.scrollHeight; | |
| return div; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ PDF EXPORT | |
| document.getElementById('export-pdf-btn').addEventListener('click', () => { | |
| if (!lastReportData) return; | |
| try { | |
| const { jsPDF } = window.jspdf; | |
| const doc = new jsPDF(); | |
| const d = lastReportData; | |
| const s = d.sections; | |
| const W=210, LM=14, CW=182, TM=32, FY=285; | |
| let y = TM; | |
| const NAVY=[30,58,138], BLUE=[30,64,175], GRAY=[60,60,60], DARK=[20,20,20]; | |
| const sevRGB = {NORMAL:[16,185,129],INCIDENTAL:[245,158,11],SIGNIFICANT:[249,115,22],CRITICAL:[239,68,68]}[d.overall_severity]||[16,185,129]; | |
| doc.setFillColor(...NAVY); doc.rect(0,0,W,26,'F'); | |
| doc.setFillColor(59,130,246); doc.roundedRect(LM,6,10,14,2,2,'F'); | |
| doc.setTextColor(255,255,255); doc.setFont('helvetica','bold'); doc.setFontSize(10); doc.text('M',LM+3.5,14.5); | |
| doc.setFontSize(16); doc.text('MediAgent Clinical Report',28,14); | |
| doc.setFontSize(8); doc.setFont('helvetica','normal'); doc.text('AMD Instinct MI300X | ROCm | vLLM | Qwen',28,20); | |
| const now=new Date(); doc.text(`${d.report_id} | ${now.toLocaleString()}`,196,12,{align:'right'}); | |
| doc.text(`Overall: ${d.overall_severity||'NORMAL'}`,196,18,{align:'right'}); | |
| doc.setFillColor(...sevRGB); doc.rect(0,26,W,4,'F'); | |
| const footer=pg=>{ doc.setDrawColor(200); doc.line(LM,FY-5,196,FY-5); doc.setFont('helvetica','normal'); doc.setFontSize(7); doc.setTextColor(150,150,150); doc.text('MediAgent v2.0 | AMD MI300X | AI-generated β requires licensed radiologist review',LM,FY); doc.text(`${pg}`,196,FY,{align:'right'}); }; | |
| const brk=(h=10)=>{ if(y+h>FY-8){ footer(doc.internal.getNumberOfPages()); doc.addPage(); y=TM; } }; | |
| const txt=(t,{sz=10,bold=false,col=GRAY,gap=5}={})=>{ if(!t)t='Not provided.'; doc.setFont('helvetica',bold?'bold':'normal'); doc.setFontSize(sz); doc.setTextColor(...col); const lines=doc.splitTextToSize(t,CW); for(const l of lines){ brk(gap); doc.text(l,LM,y); y+=gap; } y+=1; }; | |
| const sec=t=>{ brk(12); doc.setDrawColor(...BLUE); doc.setLineWidth(0.4); doc.line(LM,y,196,y); y+=4; doc.setFont('helvetica','bold'); doc.setFontSize(11); doc.setTextColor(...NAVY); doc.text(t,LM,y); y+=6; }; | |
| brk(22); doc.setFillColor(241,245,249); doc.rect(LM,y,CW,18,'F'); | |
| doc.setFont('helvetica','normal'); doc.setFontSize(9); doc.setTextColor(...GRAY); | |
| doc.text(`Report ID: ${d.report_id}`,LM+3,y+6); doc.text(`Generated: ${now.toLocaleString()}`,LM+3,y+11); | |
| doc.text(`Severity: ${d.overall_severity||'NORMAL'}`,LM+3,y+16); | |
| const qa=((s.recommendations||'').match(/Score[:\s]+(\d+)/)||[])[1]||'85'; | |
| doc.text(`QA Score: ${qa}/100`,LM+100,y+6); | |
| y+=24; | |
| if(d.dicom_metadata && Object.keys(d.dicom_metadata).length>0) { | |
| sec('DICOM Metadata'); | |
| const dm=d.dicom_metadata; | |
| const pairs=[['Modality',dm.modality],['Body Part',dm.body_part],['Study Date',dm.study_date],['Institution',dm.institution]].filter(p=>p[1]); | |
| txt(pairs.map(p=>`${p[0]}: ${p[1]}`).join(' | '),{sz:9}); | |
| } | |
| sec('Clinical History'); txt(s.clinical_history); | |
| sec('Technique'); txt(s.technique); | |
| sec('Findings'); brk(20); doc.setFillColor(249,250,251); const fl=doc.splitTextToSize(s.findings||'None.',CW-4); const fh=Math.max(18,fl.length*5+8); doc.rect(LM,y,CW,fh,'F'); txt(s.findings||'None.'); | |
| sec('Impression'); txt(s.impression,{sz:11,bold:true,col:DARK,gap:6}); | |
| sec('Recommendations'); txt(s.recommendations); | |
| sec('Disclaimer'); brk(18); doc.setFillColor(254,226,226); doc.rect(LM,y,CW,16,'F'); doc.setFont('helvetica','bold'); doc.setFontSize(8); doc.setTextColor(127,29,29); doc.splitTextToSize('DISCLAIMER: '+(s.disclaimer||''),CW-6).forEach((l,i)=>doc.text(l,LM+3,y+5+i*4.5)); | |
| footer(doc.internal.getNumberOfPages()); | |
| doc.save(`MediAgent_${d.report_id}_${now.toISOString().split('T')[0]}.pdf`); | |
| showToast('PDF exported.', 'success'); | |
| } catch(e) { console.error(e); showToast('PDF export failed.', 'error'); } | |
| }); | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ AMD GPU POLLING | |
| function startGpuPolling() { | |
| pollGpu(); | |
| gpuPollInterval = setInterval(pollGpu, 3000); | |
| } | |
| async function pollGpu() { | |
| try { | |
| const res = await fetch('/metrics/gpu'); | |
| const data = await res.json(); | |
| renderGpuPanel(data); | |
| } catch(e) { | |
| // silently ignore | |
| } | |
| } | |
| function renderGpuPanel(data) { | |
| const panel = document.getElementById('gpu-panel-content'); | |
| const dot = document.getElementById('gpu-status-dot'); | |
| const headerBadge = document.getElementById('gpu-header-badge'); | |
| const headerText = document.getElementById('gpu-header-text'); | |
| if (!data.available || !data.cards || !data.cards.length) { | |
| dot.className = 'w-1.5 h-1.5 rounded-full bg-slate-600'; | |
| panel.innerHTML = `<p class="text-[10px] text-slate-500 font-mono text-center py-1">${data.note || 'No GPU detected'}</p>`; | |
| return; | |
| } | |
| dot.className = 'w-1.5 h-1.5 rounded-full bg-orange-400 animate-pulse'; | |
| const card = data.cards[0]; | |
| const gpuPct = Math.round(parseFloat(card.gpu_use_pct) || 0); | |
| const vramUsed = Math.round(parseFloat(card.vram_used_mb) || 0); | |
| const vramTotal = Math.round(parseFloat(card.vram_total_mb) || 1); | |
| const vramPct = vramTotal > 0 ? Math.round((vramUsed / vramTotal) * 100) : 0; | |
| const temp = Math.round(parseFloat(card.temp_c) || 0); | |
| const power = Math.round(parseFloat(card.power_w) || 0); | |
| // Header badge | |
| headerBadge.classList.remove('hidden'); | |
| headerBadge.classList.add('flex'); | |
| headerText.textContent = `GPU ${gpuPct}% | ${temp}Β°C`; | |
| const bar = (pct, color) => ` | |
| <div class="h-1.5 bg-slate-700 rounded-full overflow-hidden"> | |
| <div class="h-full rounded-full ${color} gpu-bar" style="width:${pct}%"></div> | |
| </div>`; | |
| const gpuColor = gpuPct > 80 ? 'bg-red-500' : gpuPct > 50 ? 'bg-orange-400' : 'bg-green-400'; | |
| const vramColor = vramPct > 85 ? 'bg-red-500' : vramPct > 60 ? 'bg-orange-400' : 'bg-blue-400'; | |
| panel.innerHTML = ` | |
| <div class="space-y-2"> | |
| <div> | |
| <div class="flex justify-between items-center mb-0.5"> | |
| <span class="text-[9px] font-mono text-slate-400">GPU UTIL</span> | |
| <span class="text-[10px] font-bold font-mono text-orange-300">${gpuPct}%</span> | |
| </div> | |
| ${bar(gpuPct, gpuColor)} | |
| </div> | |
| <div> | |
| <div class="flex justify-between items-center mb-0.5"> | |
| <span class="text-[9px] font-mono text-slate-400">VRAM</span> | |
| <span class="text-[10px] font-bold font-mono text-blue-300">${vramUsed}/${vramTotal} MiB</span> | |
| </div> | |
| ${bar(vramPct, vramColor)} | |
| </div> | |
| <div class="grid grid-cols-2 gap-2 pt-1 border-t border-slate-700"> | |
| <div class="text-center"> | |
| <div class="text-[9px] text-slate-500 font-mono">TEMP</div> | |
| <div class="text-[11px] font-bold font-mono ${temp>80?'text-red-400':temp>65?'text-orange-400':'text-green-400'}">${temp}Β°C</div> | |
| </div> | |
| <div class="text-center"> | |
| <div class="text-[9px] text-slate-500 font-mono">POWER</div> | |
| <div class="text-[11px] font-bold font-mono text-slate-300">${power}W</div> | |
| </div> | |
| </div> | |
| ${data.note ? `<p class="text-[9px] text-slate-600 font-mono text-center">${data.note}</p>` : ''} | |
| </div>`; | |
| } | |
| // βββββββββββββββββββββββββββββββββββββββββββββββββββββββ UTILITIES | |
| function setLoading(v) { | |
| document.getElementById('submit-btn').disabled = v; | |
| document.getElementById('btn-text').textContent = v ? 'Processing...' : 'Run Analysis'; | |
| document.getElementById('btn-spinner').classList.toggle('hidden', !v); | |
| } | |
| function log(msg) { | |
| document.getElementById('pipeline-log').innerHTML = `<span class="text-slate-700">> ${msg}</span>`; | |
| } | |
| function startTimer() { | |
| startTime = performance.now(); | |
| const timerEl = document.getElementById('pipeline-timer'); | |
| timerEl.classList.remove('hidden'); | |
| timerInterval = setInterval(() => { | |
| timerEl.textContent = `β± ${((performance.now()-startTime)/1000).toFixed(1)}s elapsed`; | |
| }, 100); | |
| } | |
| function stopTimer() { | |
| clearInterval(timerInterval); | |
| const elapsed = ((performance.now()-startTime)/1000).toFixed(1); | |
| document.getElementById('pipeline-log').innerHTML = `<span class="text-green-600 font-bold">β Complete in ${elapsed}s</span>`; | |
| document.getElementById('pipeline-timer').classList.add('hidden'); | |
| } | |
| function showToast(msg, type='success') { | |
| const toast = document.getElementById('toast'); | |
| const inner = document.getElementById('toast-inner'); | |
| document.getElementById('toast-message').textContent = msg; | |
| inner.className = `${type==='error'?'bg-red-600':'bg-slate-800'} text-white px-4 py-2.5 rounded-lg shadow-xl text-xs font-medium flex items-center gap-2`; | |
| toast.classList.remove('translate-y-16','opacity-0'); | |
| setTimeout(() => toast.classList.add('translate-y-16','opacity-0'), 3500); | |
| } | |
| init(); | |
| </script> | |
| </body> | |
| </html> | |