LLM_Monitor / templates /sidecar.html
potato-pzy
feat: remove powered-by line and integrate sidecar with sentence-level streaming
02422e3
Raw
History Blame Contribute Delete
27.3 kB
<!DOCTYPE html>
<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,'&amp;')
.replace(/</g,'&lt;')
.replace(/>/g,'&gt;')
.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>