Spaces:
Runtime error
Runtime error
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Risknox | GenAI Shield β Sidecar</title> | |
| <meta name="description" content="GenAI Shield sidecar proxy with real-time streaming and sentence-level threat detection"> | |
| <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet"> | |
| <style> | |
| :root { | |
| --bg: #0a0a0f; | |
| --surface: #12121a; | |
| --surface2: #1a1a26; | |
| --border: #2a2a3d; | |
| --accent: #6c63ff; | |
| --accent2: #00d4aa; | |
| --danger: #ff4757; | |
| --warn: #ffa502; | |
| --text: #e8e8f0; | |
| --muted: #6b6b8a; | |
| --mono: 'JetBrains Mono', monospace; | |
| --sans: 'Inter', system-ui, sans-serif; | |
| } | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| body { | |
| font-family: var(--sans); | |
| background: var(--bg); | |
| color: var(--text); | |
| height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| /* ββ Header βββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .header { | |
| padding: 0.875rem 1.5rem; | |
| border-bottom: 1px solid var(--border); | |
| background: var(--surface); | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| flex-shrink: 0; | |
| } | |
| .header-brand { display: flex; align-items: center; gap: 0.75rem; } | |
| .shield-icon { | |
| width: 36px; height: 36px; | |
| background: linear-gradient(135deg, var(--accent), var(--accent2)); | |
| border-radius: 8px; | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 1.1rem; | |
| } | |
| .header h1 { font-size: 1rem; font-weight: 700; } | |
| .header-sub { | |
| font-family: var(--mono); | |
| font-size: 0.6rem; | |
| color: var(--muted); | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| } | |
| .header-actions { display: flex; align-items: center; gap: 0.75rem; } | |
| .mode-badge { | |
| font-family: var(--mono); | |
| font-size: 0.6rem; | |
| font-weight: 700; | |
| padding: 3px 8px; | |
| border-radius: 3px; | |
| background: rgba(108, 99, 255, 0.15); | |
| border: 1px solid rgba(108, 99, 255, 0.4); | |
| color: var(--accent); | |
| text-transform: uppercase; | |
| } | |
| .status-dot { | |
| width: 8px; height: 8px; | |
| border-radius: 50%; | |
| background: var(--accent2); | |
| box-shadow: 0 0 6px var(--accent2); | |
| animation: pulse 2s ease-in-out infinite; | |
| } | |
| @keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.4; } } | |
| /* ββ Chat area ββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .chat-area { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 1.5rem; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1.5rem; | |
| } | |
| .chat-area::-webkit-scrollbar { width: 4px; } | |
| .chat-area::-webkit-scrollbar-track { background: transparent; } | |
| .chat-area::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } | |
| /* ββ Messages βββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .msg { display: flex; gap: 0.75rem; max-width: 760px; width: 100%; } | |
| .msg.user { align-self: flex-end; flex-direction: row-reverse; } | |
| .msg.assistant { align-self: flex-start; } | |
| .avatar { | |
| width: 32px; height: 32px; | |
| border-radius: 8px; | |
| display: flex; align-items: center; justify-content: center; | |
| font-weight: 700; font-size: 0.65rem; | |
| flex-shrink: 0; | |
| font-family: var(--mono); | |
| } | |
| .msg.user .avatar { background: var(--accent); color: #fff; } | |
| .msg.assistant .avatar { background: var(--surface2); color: var(--accent2); border: 1px solid var(--border); } | |
| .bubble { | |
| padding: 0.875rem 1.125rem; | |
| border-radius: 12px; | |
| line-height: 1.65; | |
| font-size: 0.9375rem; | |
| max-width: calc(100% - 44px); | |
| } | |
| .msg.user .bubble { | |
| background: var(--accent); | |
| color: #fff; | |
| border-bottom-right-radius: 3px; | |
| } | |
| .msg.assistant .bubble { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-bottom-left-radius: 3px; | |
| color: var(--text); | |
| position: relative; | |
| } | |
| /* ββ Streaming cursor βββββββββββββββββββββββββββββββββββββββ */ | |
| .stream-cursor { | |
| display: inline-block; | |
| width: 2px; | |
| height: 1em; | |
| background: var(--accent2); | |
| margin-left: 2px; | |
| vertical-align: text-bottom; | |
| border-radius: 1px; | |
| animation: blink 0.8s step-end infinite; | |
| } | |
| @keyframes blink { 0%,100% { opacity:1; } 50% { opacity:0; } } | |
| /* ββ Shield monitoring bar ββββββββββββββββββββββββββββββββββ */ | |
| .shield-bar { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| margin-top: 0.625rem; | |
| font-family: var(--mono); | |
| font-size: 0.6rem; | |
| color: var(--muted); | |
| } | |
| .shield-bar .spinner { | |
| width: 10px; height: 10px; | |
| border: 1.5px solid var(--accent2); | |
| border-top-color: transparent; | |
| border-radius: 50%; | |
| animation: spin 0.8s linear infinite; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| /* ββ Threat meta row ββββββββββββββββββββββββββββββββββββββββ */ | |
| .msg-meta { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| margin-top: 0.5rem; | |
| flex-wrap: wrap; | |
| } | |
| .threat-badge { | |
| font-family: var(--mono); | |
| font-size: 0.6rem; | |
| font-weight: 700; | |
| padding: 2px 7px; | |
| border-radius: 3px; | |
| text-transform: uppercase; | |
| } | |
| .threat-clean { background: rgba(0,212,170,0.12); color: var(--accent2); border: 1px solid rgba(0,212,170,0.3); } | |
| .threat-medium { background: rgba(255,165,2,0.12); color: var(--warn); border: 1px solid rgba(255,165,2,0.3); } | |
| .threat-high { background: rgba(255,71,87,0.12); color: var(--danger); border: 1px solid rgba(255,71,87,0.3); } | |
| .meta-chip { | |
| font-family: var(--mono); | |
| font-size: 0.57rem; | |
| color: var(--muted); | |
| } | |
| .flag-chip { | |
| font-family: var(--mono); | |
| font-size: 0.57rem; | |
| padding: 1px 5px; | |
| border: 1px solid rgba(255,71,87,0.4); | |
| color: var(--danger); | |
| border-radius: 2px; | |
| } | |
| /* ββ Block signal overlay βββββββββββββββββββββββββββββββββββ */ | |
| .block-signal-overlay { | |
| margin-top: 0.625rem; | |
| padding: 0.75rem 1rem; | |
| background: rgba(255,71,87,0.08); | |
| border: 1px solid rgba(255,71,87,0.4); | |
| border-radius: 8px; | |
| font-size: 0.8rem; | |
| } | |
| .block-signal-overlay strong { color: var(--danger); font-family: var(--mono); font-size: 0.7rem; } | |
| /* Tainted text fade */ | |
| .tainted { opacity: 0.3; text-decoration: line-through; color: var(--danger); } | |
| /* ββ Blocked message ββββββββββββββββββββββββββββββββββββββββ */ | |
| .blocked-card { | |
| max-width: 760px; | |
| padding: 1rem 1.25rem; | |
| background: rgba(255,71,87,0.07); | |
| border: 1px solid rgba(255,71,87,0.4); | |
| border-radius: 12px; | |
| align-self: flex-end; | |
| } | |
| .blocked-card-title { | |
| font-weight: 700; | |
| font-size: 0.875rem; | |
| color: var(--danger); | |
| margin-bottom: 0.375rem; | |
| display: flex; align-items: center; gap: 0.5rem; | |
| } | |
| .blocked-card-reason { | |
| font-family: var(--mono); | |
| font-size: 0.7rem; | |
| color: var(--muted); | |
| margin-bottom: 0.5rem; | |
| } | |
| /* ββ Input area βββββββββββββββββββββββββββββββββββββββββββββ */ | |
| .input-area { | |
| padding: 1rem 1.5rem; | |
| border-top: 1px solid var(--border); | |
| background: var(--surface); | |
| flex-shrink: 0; | |
| } | |
| .input-row { | |
| max-width: 760px; | |
| margin: 0 auto; | |
| display: flex; | |
| gap: 0.75rem; | |
| align-items: flex-end; | |
| } | |
| .input-wrap { | |
| flex: 1; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| border-radius: 10px; | |
| padding: 0.75rem 1rem; | |
| transition: border-color 0.2s; | |
| } | |
| .input-wrap:focus-within { border-color: var(--accent); } | |
| textarea { | |
| width: 100%; | |
| background: transparent; | |
| border: none; | |
| outline: none; | |
| font-family: var(--sans); | |
| font-size: 0.9375rem; | |
| color: var(--text); | |
| resize: none; | |
| min-height: 24px; | |
| max-height: 160px; | |
| line-height: 1.5; | |
| } | |
| textarea::placeholder { color: var(--muted); } | |
| .send-btn { | |
| width: 44px; height: 44px; | |
| border-radius: 10px; | |
| border: none; | |
| background: var(--accent); | |
| color: #fff; | |
| cursor: pointer; | |
| display: flex; align-items: center; justify-content: center; | |
| transition: background 0.2s, transform 0.1s; | |
| flex-shrink: 0; | |
| } | |
| .send-btn:hover { background: #7c73ff; } | |
| .send-btn:active { transform: scale(0.94); } | |
| .send-btn:disabled { background: var(--border); cursor: not-allowed; } | |
| /* ββ Latency bar (below input) ββββββββββββββββββββββββββββββ */ | |
| .latency-row { | |
| max-width: 760px; | |
| margin: 0.5rem auto 0; | |
| display: flex; | |
| gap: 1rem; | |
| font-family: var(--mono); | |
| font-size: 0.58rem; | |
| color: var(--muted); | |
| } | |
| .latency-seg { display: flex; align-items: center; gap: 0.3rem; } | |
| .latency-dot { width: 6px; height: 6px; border-radius: 50%; } | |
| .dot-guard { background: var(--accent); } | |
| .dot-model { background: var(--accent2); } | |
| .dot-monitor { background: var(--warn); } | |
| /* ββ Welcome state ββββββββββββββββββββββββββββββββββββββββββ */ | |
| .welcome { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| text-align: center; | |
| gap: 1rem; | |
| color: var(--muted); | |
| padding: 2rem; | |
| } | |
| .welcome-icon { font-size: 3rem; } | |
| .welcome h2 { font-size: 1.25rem; color: var(--text); } | |
| .welcome p { font-size: 0.875rem; max-width: 400px; line-height: 1.6; } | |
| .pill-row { display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: center; } | |
| .pill { | |
| font-family: var(--mono); | |
| font-size: 0.65rem; | |
| padding: 4px 10px; | |
| border-radius: 99px; | |
| background: var(--surface2); | |
| border: 1px solid var(--border); | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header class="header"> | |
| <div class="header-brand"> | |
| <div class="shield-icon">π‘οΈ</div> | |
| <div> | |
| <h1>GenAI Shield <span style="color:var(--accent)">Sidecar</span></h1> | |
| <div class="header-sub">Parallel Guard Β· Sentence Streaming Β· Background Monitor</div> | |
| </div> | |
| </div> | |
| <div class="header-actions"> | |
| <div class="mode-badge">Sidecar v2</div> | |
| <div class="status-dot" id="status-dot" title="Guard ready"></div> | |
| </div> | |
| </header> | |
| <div class="chat-area" id="chat-area"> | |
| <div class="welcome" id="welcome-screen"> | |
| <div class="welcome-icon">π‘οΈ</div> | |
| <h2>Sidecar Proxy Active</h2> | |
| <p>Your prompt is guarded <em>before</em> the LLM sees it, and every sentence of the response is monitored <em>as it streams</em> β with zero added latency on the happy path.</p> | |
| <div class="pill-row"> | |
| <span class="pill">β‘ Parallel guard + LLM</span> | |
| <span class="pill">π‘ Sentence-level SSE</span> | |
| <span class="pill">π Background monitor</span> | |
| <span class="pill">π« Mid-stream block signal</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="input-area"> | |
| <div class="input-row"> | |
| <div class="input-wrap"> | |
| <textarea | |
| id="prompt-input" | |
| rows="1" | |
| placeholder="Enter a prompt β the shield and the LLM start together..." | |
| onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage();}" | |
| oninput="autoResize(this)" | |
| ></textarea> | |
| </div> | |
| <button class="send-btn" id="send-btn" onclick="sendMessage()" title="Send"> | |
| <svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor"> | |
| <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/> | |
| </svg> | |
| </button> | |
| </div> | |
| <div class="latency-row" id="latency-row" style="display:none"> | |
| <span class="latency-seg"><span class="latency-dot dot-guard"></span><span id="lat-guard">Guard: β</span></span> | |
| <span class="latency-seg"><span class="latency-dot dot-model"></span><span id="lat-model">Stream: β</span></span> | |
| <span class="latency-seg"><span class="latency-dot dot-monitor"></span><span id="lat-monitor">Monitor: background</span></span> | |
| </div> | |
| </div> | |
| <script> | |
| // ββ Config ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const SIDECAR_URL = '/v1/chat'; // proxied through Flask or direct to :5050 | |
| // ββ Helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const chatArea = document.getElementById('chat-area'); | |
| const input = document.getElementById('prompt-input'); | |
| const sendBtn = document.getElementById('send-btn'); | |
| const latRow = document.getElementById('latency-row'); | |
| function esc(s) { | |
| return String(s) | |
| .replace(/&/g,'&') | |
| .replace(/</g,'<') | |
| .replace(/>/g,'>') | |
| .replace(/\n/g,'<br>'); | |
| } | |
| function autoResize(el) { | |
| el.style.height = 'auto'; | |
| el.style.height = Math.min(el.scrollHeight, 160) + 'px'; | |
| } | |
| function scrollBottom() { | |
| chatArea.scrollTop = chatArea.scrollHeight; | |
| } | |
| function hideWelcome() { | |
| const w = document.getElementById('welcome-screen'); | |
| if (w) w.remove(); | |
| } | |
| function setLatency(guard, total) { | |
| latRow.style.display = 'flex'; | |
| document.getElementById('lat-guard').textContent = `Guard: ${guard || 'β'}ms`; | |
| document.getElementById('lat-model').textContent = `Total: ${total || 'β'}ms`; | |
| } | |
| // ββ Append user bubble βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function appendUser(text) { | |
| const div = document.createElement('div'); | |
| div.className = 'msg user'; | |
| div.innerHTML = ` | |
| <div class="avatar">U</div> | |
| <div class="bubble">${esc(text)}</div> | |
| `; | |
| chatArea.appendChild(div); | |
| scrollBottom(); | |
| } | |
| // ββ Append assistant streaming bubble βββββββββββββββββββββββββββββββββββββββββ | |
| function createAssistantBubble() { | |
| const div = document.createElement('div'); | |
| div.className = 'msg assistant'; | |
| const bubbleId = 'bubble-' + Date.now(); | |
| const metaId = 'meta-' + Date.now(); | |
| const shieldId = 'shield-' + Date.now(); | |
| div.innerHTML = ` | |
| <div class="avatar">AI</div> | |
| <div> | |
| <div class="bubble" id="${bubbleId}"> | |
| <span class="stream-cursor"></span> | |
| </div> | |
| <div class="shield-bar" id="${shieldId}"> | |
| <div class="spinner"></div> | |
| <span>Shield monitoring outputβ¦</span> | |
| </div> | |
| <div class="msg-meta" id="${metaId}" style="display:none"></div> | |
| </div> | |
| `; | |
| chatArea.appendChild(div); | |
| scrollBottom(); | |
| return { | |
| getBubble: () => document.getElementById(bubbleId), | |
| getMeta: () => document.getElementById(metaId), | |
| getShield: () => document.getElementById(shieldId), | |
| bubbleId, | |
| metaId, | |
| shieldId, | |
| // Sentence tracking for block signals | |
| sentences: {}, // sentence_id β { start, end } char positions not tracked easily, | |
| // so we track spans instead | |
| fullText: '', | |
| }; | |
| } | |
| // ββ Block signal: taint sentences from sentence_id onward βββββββββββββββββββββ | |
| function applyBlockSignal(bubbleEl, bubbleState, signalData) { | |
| const sid = signalData.sentence_id; | |
| // Mark all sentence spans from sid onward as tainted | |
| const spans = bubbleEl.querySelectorAll(`[data-sid]`); | |
| spans.forEach(span => { | |
| const spanSid = parseInt(span.dataset.sid, 10); | |
| if (spanSid >= sid) span.classList.add('tainted'); | |
| }); | |
| // Append block signal banner | |
| const overlay = document.createElement('div'); | |
| overlay.className = 'block-signal-overlay'; | |
| overlay.innerHTML = ` | |
| <strong>π« OUTPUT BLOCKED AT SENTENCE ${sid}</strong> | |
| <div style="margin-top:4px;font-size:0.75rem;color:var(--muted)">${esc(signalData.reason)}</div> | |
| <div style="margin-top:6px">${(signalData.flags||[]).map(f => `<span class="flag-chip">${f}</span>`).join(' ')}</div> | |
| `; | |
| bubbleEl.parentElement.insertAdjacentElement('beforeend', overlay); | |
| } | |
| // ββ Render done meta ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function renderDone(metaEl, shieldEl, doneData) { | |
| // Hide shield spinner | |
| shieldEl.style.display = 'none'; | |
| const score = doneData.threat_score || 0; | |
| const cls = score >= 60 ? 'threat-high' : score >= 30 ? 'threat-medium' : 'threat-clean'; | |
| const label = score >= 60 ? 'HIGH RISK' : score >= 30 ? 'SUSPICIOUS' : 'CLEAN'; | |
| let html = `<span class="threat-badge ${cls}">${label} Β· ${score}</span>`; | |
| html += `<span class="meta-chip">${doneData.latency_ms || 0}ms total Β· ${doneData.sentences || 0} sentences</span>`; | |
| (doneData.flags || []).forEach(f => { html += `<span class="flag-chip">${f}</span>`; }); | |
| metaEl.innerHTML = html; | |
| metaEl.style.display = 'flex'; | |
| } | |
| // ββ Append blocked card βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function appendBlocked(data) { | |
| const div = document.createElement('div'); | |
| div.className = 'blocked-card'; | |
| div.innerHTML = ` | |
| <div class="blocked-card-title">π« Input Rejected by Prompt Guard</div> | |
| <div class="blocked-card-reason">${esc(data.reason || 'GUARD_BLOCKED')}</div> | |
| <div style="margin-top:4px"> | |
| ${(data.flags||[]).map(f => `<span class="flag-chip">${f}</span>`).join(' ')} | |
| <span class="meta-chip" style="margin-left:8px">PG score: ${(data.pg_score||0).toFixed(3)} Β· Guard: ${data.guard_ms||0}ms</span> | |
| </div> | |
| `; | |
| chatArea.appendChild(div); | |
| scrollBottom(); | |
| } | |
| // ββ Main send logic βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function sendMessage() { | |
| const prompt = input.value.trim(); | |
| if (!prompt) return; | |
| hideWelcome(); | |
| input.value = ''; | |
| input.style.height = 'auto'; | |
| sendBtn.disabled = true; | |
| appendUser(prompt); | |
| const state = createAssistantBubble(); | |
| const bubble = state.getBubble(); | |
| const meta = state.getMeta(); | |
| const shield = state.getShield(); | |
| const tStart = performance.now(); | |
| let currentSentenceId = 0; | |
| let blocked = false; | |
| let guardMs = 0; | |
| try { | |
| const res = await fetch(SIDECAR_URL, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ prompt, stream: true }), | |
| }); | |
| if (!res.ok) { | |
| const err = await res.json().catch(() => ({})); | |
| shield.style.display = 'none'; | |
| bubble.innerHTML = `<span style="color:var(--danger)">Error: ${esc(err.detail || res.statusText)}</span>`; | |
| sendBtn.disabled = false; | |
| return; | |
| } | |
| const reader = res.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let rawBuf = ''; | |
| // Remove blinking cursor initially placed | |
| bubble.innerHTML = ''; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| rawBuf += decoder.decode(value, { stream: true }); | |
| // Parse SSE lines | |
| const lines = rawBuf.split('\n'); | |
| rawBuf = lines.pop(); // keep incomplete line | |
| for (const line of lines) { | |
| if (!line.startsWith('data: ')) continue; | |
| let ev; | |
| try { ev = JSON.parse(line.slice(6)); } catch { continue; } | |
| switch (ev.type) { | |
| // ββ Raw token chunk β append immediately ββββββββββββββ | |
| case 'chunk': { | |
| // We actually render sentence spans, not raw chunks | |
| // Chunks are for the browser but we track sentences for guard | |
| // Simple approach: append text nodes directly | |
| bubble.appendChild(document.createTextNode(ev.text)); | |
| scrollBottom(); | |
| break; | |
| } | |
| // ββ Complete sentence β wrap in span with data-sid ββββ | |
| case 'sentence': { | |
| // Replace plain text accumulated in bubble with a tracked span | |
| // Strategy: clear bubble's text nodes, re-render as spans | |
| currentSentenceId = ev.sentence_id; | |
| // Find last text node and replace with span | |
| const lastChild = bubble.lastChild; | |
| if (lastChild && lastChild.nodeType === Node.TEXT_NODE) { | |
| bubble.removeChild(lastChild); | |
| } | |
| const span = document.createElement('span'); | |
| span.dataset.sid = ev.sentence_id; | |
| span.textContent = ev.text + ' '; | |
| bubble.appendChild(span); | |
| scrollBottom(); | |
| break; | |
| } | |
| // ββ Guard blocked the prompt (pre-inference) ββββββββββ | |
| case 'blocked': { | |
| blocked = true; | |
| guardMs = ev.guard_ms || 0; | |
| // Remove the assistant bubble we created | |
| bubble.closest('.msg').remove(); | |
| appendBlocked(ev); | |
| setLatency(ev.guard_ms, ev.guard_ms); | |
| break; | |
| } | |
| // ββ Mid-stream block signal (post-monitor) ββββββββββββ | |
| case 'block_signal': { | |
| applyBlockSignal(bubble, state, ev); | |
| break; | |
| } | |
| // ββ Stream done βββββββββββββββββββββββββββββββββββββββ | |
| case 'done': { | |
| // Remove trailing cursor if present | |
| const cursor = bubble.querySelector('.stream-cursor'); | |
| if (cursor) cursor.remove(); | |
| renderDone(meta, shield, ev); | |
| setLatency(null, ev.latency_ms); | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| } catch (err) { | |
| shield.style.display = 'none'; | |
| bubble.innerHTML = `<span style="color:var(--danger)">Connection error: ${esc(err.message)}</span>`; | |
| } | |
| sendBtn.disabled = false; | |
| input.focus(); | |
| } | |
| // ββ Health check on load ββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| (async () => { | |
| try { | |
| const r = await fetch('/v1/health'); | |
| const d = await r.json(); | |
| if (!d.guard_ready) { | |
| document.getElementById('status-dot').style.background = 'var(--warn)'; | |
| document.getElementById('status-dot').style.boxShadow = '0 0 6px var(--warn)'; | |
| document.getElementById('status-dot').title = 'Guard not ready'; | |
| } | |
| } catch { | |
| document.getElementById('status-dot').style.background = 'var(--danger)'; | |
| document.getElementById('status-dot').title = 'Sidecar unreachable'; | |
| } | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |