| <!DOCTYPE html> |
| <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 !important; background:#eff6ff !important; 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 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 class="flex-1 grid grid-cols-12 overflow-hidden" style="height:calc(100vh - 56px)"> |
|
|
| |
| <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"> |
|
|
| |
| <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 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> |
|
|
| |
| <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> |
|
|
| |
| <section class="col-span-3 bg-white border-r border-slate-200 flex flex-col overflow-hidden"> |
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <section class="col-span-6 flex flex-col overflow-hidden bg-white"> |
|
|
| |
| <div id="report-scroll" class="flex-1 overflow-y-auto px-6 py-5"> |
| |
| <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> |
|
|
| |
| <div id="report-content" class="hidden space-y-5 animate-fade-in"> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <div class="border-t border-slate-200 bg-white shrink-0"> |
|
|
| |
| <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> |
|
|
| |
| <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> |
|
|
| |
| <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> |
| |
| 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; |
| |
| |
| function init() { |
| renderAgents(); |
| setInterval(() => { |
| document.getElementById('clock').textContent = new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'}); |
| }, 1000); |
| startGpuPolling(); |
| } |
| |
| |
| 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'); |
| } |
| |
| |
| 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); |
| } |
| } |
| |
| |
| 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`; |
| } |
| |
| |
| 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}`); |
| } |
| } |
| |
| |
| 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'); |
| |
| |
| 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] || 'β
'; |
| |
| |
| 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`; |
| |
| |
| 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(''); |
| } |
| |
| |
| 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' }, |
| }; |
| |
| |
| let topSev = 'NORMAL'; |
| for (const sev of ['CRITICAL','SIGNIFICANT','INCIDENTAL','NORMAL']) { |
| if (text.toUpperCase().includes(sev)) { topSev = sev; break; } |
| } |
| |
| |
| 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>`; |
| } |
| |
| |
| function updateCharts(data) {
|
|
|
| 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%' }
|
| });
|
|
|
|
|
| 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 } } }
|
| }); |
| |
| |
| 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} } } |
| }); |
| } |
| } |
| |
| |
| 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; |
| } |
| |
| |
| 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'); } |
| }); |
| |
| |
| 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) { |
| |
| } |
| } |
| |
| 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); |
| |
| |
| 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>`; |
| } |
| |
| |
| 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> |
|
|