my-llm / index.html
viraj.kothari
add HuggingFace static space
04480e3
Raw History Blame Contribute Delete
29.8 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>MY-LLM</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400&family=Syne:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--bg: #050508;
--bg2: #08080e;
--surface: #0c0c15;
--surface2: #10101c;
--border: rgba(255,255,255,0.06);
--border2: rgba(255,255,255,0.1);
--accent: #00ff88;
--accent2: #00cc6a;
--accent-dim: rgba(0,255,136,0.08);
--accent-glow:rgba(0,255,136,0.15);
--amber: #ffaa00;
--red: #ff4466;
--blue: #4488ff;
--text: #f0f0f0;
--text2: #888;
--text3: #444;
--mono: 'Space Mono', monospace;
--sans: 'Syne', sans-serif;
--radius: 4px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--sans);
overflow: hidden;
}
/* Animated grid background */
body::before {
content: '';
position: fixed;
inset: 0;
background-image:
linear-gradient(rgba(0,255,136,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,255,136,0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
body::after {
content: '';
position: fixed;
inset: 0;
background: radial-gradient(ellipse 60% 50% at 50% 0%, rgba(0,255,136,0.04) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
/* ────────────── LAYOUT ────────────── */
.shell {
position: relative;
z-index: 1;
display: grid;
grid-template-rows: 48px 1fr;
grid-template-columns: 260px 1fr;
height: 100vh;
}
/* ────────────── TOPBAR ────────────── */
.topbar {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
border-bottom: 1px solid var(--border);
background: rgba(5,5,8,0.95);
backdrop-filter: blur(20px);
position: relative;
}
.topbar::after {
content: '';
position: absolute;
bottom: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: 0.3;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-mark {
width: 24px;
height: 24px;
position: relative;
}
.brand-mark svg {
width: 100%;
height: 100%;
}
.brand-name {
font-family: var(--sans);
font-size: 14px;
font-weight: 800;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--text);
}
.brand-slash {
color: var(--accent);
margin: 0 2px;
}
.topbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.status-badge {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--mono);
font-size: 10px;
color: var(--text2);
padding: 4px 10px;
border: 1px solid var(--border2);
border-radius: 2px;
letter-spacing: 0.05em;
}
.pulse {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--accent);
position: relative;
}
.pulse::after {
content: '';
position: absolute;
inset: -3px;
border-radius: 50%;
background: var(--accent);
animation: ripple 2s ease-out infinite;
}
@keyframes ripple {
0% { transform: scale(1); opacity: 0.4; }
100% { transform: scale(2.5); opacity: 0; }
}
.pulse.offline { background: var(--red); }
.pulse.offline::after { background: var(--red); animation: none; }
.topbar-btn {
font-family: var(--mono);
font-size: 10px;
color: var(--text3);
background: none;
border: 1px solid var(--border);
border-radius: 2px;
padding: 4px 10px;
cursor: pointer;
letter-spacing: 0.05em;
transition: all 0.15s;
text-transform: uppercase;
}
.topbar-btn:hover {
color: var(--text2);
border-color: var(--border2);
}
/* ────────────── SIDEBAR ────────────── */
.sidebar {
background: var(--bg2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-section {
padding: 20px 18px 16px;
border-bottom: 1px solid var(--border);
}
.sidebar-section:last-child { border-bottom: none; }
.section-title {
font-family: var(--mono);
font-size: 9px;
color: var(--text3);
letter-spacing: 0.15em;
text-transform: uppercase;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
.section-title::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
/* Model card */
.model-card {
background: var(--surface);
border: 1px solid var(--border2);
border-radius: var(--radius);
padding: 12px 14px;
position: relative;
overflow: hidden;
}
.model-card::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 2px;
background: linear-gradient(90deg, var(--accent), transparent);
}
.model-id {
font-family: var(--mono);
font-size: 11px;
color: var(--accent);
margin-bottom: 10px;
letter-spacing: 0.05em;
}
.model-rows {
display: flex;
flex-direction: column;
gap: 5px;
}
.model-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.model-row-key {
font-family: var(--mono);
font-size: 10px;
color: var(--text3);
}
.model-row-val {
font-family: var(--mono);
font-size: 10px;
color: var(--text2);
}
.model-row-val.accent { color: var(--accent); }
/* Params */
.param-block {
margin-bottom: 14px;
}
.param-block:last-child { margin-bottom: 0; }
.param-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.param-name {
font-family: var(--mono);
font-size: 10px;
color: var(--text2);
letter-spacing: 0.05em;
}
.param-value {
font-family: var(--mono);
font-size: 10px;
color: var(--accent);
}
.track {
height: 3px;
background: var(--surface2);
border-radius: 0;
position: relative;
cursor: pointer;
}
.track-fill {
position: absolute;
left: 0; top: 0; bottom: 0;
background: var(--accent);
border-radius: 0;
transition: width 0.1s;
}
.track input[type=range] {
position: absolute;
inset: -6px 0;
width: 100%;
opacity: 0;
cursor: pointer;
height: 16px;
}
/* Endpoints */
.ep-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.ep-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
cursor: pointer;
transition: all 0.15s;
}
.ep-item:hover { border-color: var(--border2); }
.ep-item.active {
border-color: rgba(0,255,136,0.3);
background: var(--accent-dim);
}
.ep-method {
font-family: var(--mono);
font-size: 9px;
color: var(--accent);
background: rgba(0,255,136,0.1);
padding: 2px 5px;
border-radius: 2px;
}
.ep-path {
font-family: var(--mono);
font-size: 10px;
color: var(--text2);
letter-spacing: 0.03em;
}
.ep-dot {
margin-left: auto;
width: 5px; height: 5px;
border-radius: 50%;
border: 1px solid var(--border2);
}
.ep-item.active .ep-dot {
background: var(--accent);
border-color: var(--accent);
}
/* Stats in sidebar bottom */
.sidebar-stats {
margin-top: auto;
padding: 14px 18px;
border-top: 1px solid var(--border);
}
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.stat-cell {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 8px 10px;
}
.stat-label {
font-family: var(--mono);
font-size: 9px;
color: var(--text3);
letter-spacing: 0.05em;
margin-bottom: 4px;
text-transform: uppercase;
}
.stat-val {
font-family: var(--mono);
font-size: 13px;
color: var(--text);
font-weight: 700;
}
.stat-val.accent { color: var(--accent); }
/* ────────────── CHAT ────────────── */
.chat {
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
/* Messages */
.messages {
flex: 1;
overflow-y: auto;
padding: 32px 32px 16px;
display: flex;
flex-direction: column;
gap: 0;
scroll-behavior: smooth;
}
.messages::-webkit-scrollbar { width: 3px; }
.messages::-webkit-scrollbar-track { background: transparent; }
.messages::-webkit-scrollbar-thumb { background: var(--border2); }
/* Empty */
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0;
padding-bottom: 40px;
}
.empty-glyph {
font-family: var(--mono);
font-size: 80px;
line-height: 1;
color: var(--accent);
opacity: 0.15;
margin-bottom: 16px;
animation: float 4s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-8px); }
}
.empty-headline {
font-family: var(--sans);
font-size: 22px;
font-weight: 700;
color: var(--text);
margin-bottom: 8px;
letter-spacing: -0.02em;
opacity: 0.5;
}
.empty-sub {
font-family: var(--mono);
font-size: 11px;
color: var(--text3);
letter-spacing: 0.08em;
margin-bottom: 32px;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
max-width: 480px;
}
.chip {
font-family: var(--mono);
font-size: 11px;
color: var(--text2);
padding: 7px 14px;
border: 1px solid var(--border2);
border-radius: 2px;
cursor: pointer;
transition: all 0.15s;
letter-spacing: 0.03em;
}
.chip:hover {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-dim);
transform: translateY(-1px);
}
/* Message blocks */
.msg-group {
margin-bottom: 28px;
animation: msgIn 0.25s cubic-bezier(0.16,1,0.3,1);
}
@keyframes msgIn {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.msg-meta {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.msg-role-tag {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 2px 8px;
border-radius: 2px;
}
.msg-group.user .msg-role-tag {
color: var(--blue);
background: rgba(68,136,255,0.1);
border: 1px solid rgba(68,136,255,0.2);
}
.msg-group.assistant .msg-role-tag {
color: var(--accent);
background: var(--accent-dim);
border: 1px solid rgba(0,255,136,0.2);
}
.msg-time {
font-family: var(--mono);
font-size: 10px;
color: var(--text3);
}
.msg-perf {
margin-left: auto;
display: flex;
gap: 12px;
}
.perf-item {
font-family: var(--mono);
font-size: 10px;
color: var(--text3);
}
.perf-item span { color: var(--accent); }
.msg-bubble {
padding: 14px 18px;
border-radius: var(--radius);
font-size: 14px;
line-height: 1.75;
position: relative;
}
.msg-group.user .msg-bubble {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text2);
border-left: 2px solid rgba(68,136,255,0.4);
}
.msg-group.assistant .msg-bubble {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
border-left: 2px solid rgba(0,255,136,0.4);
}
.msg-text {
white-space: pre-wrap;
word-break: break-word;
}
/* Thinking */
.thinking-dots {
display: flex;
gap: 5px;
align-items: center;
padding: 4px 0;
}
.thinking-dots span {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--accent);
animation: dot 1.4s ease-in-out infinite;
opacity: 0.3;
}
.thinking-dots span:nth-child(2) { animation-delay: 0.2s; }
.thinking-dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes dot {
0%, 60%, 100% { transform: scale(0.8); opacity: 0.3; }
30% { transform: scale(1.2); opacity: 1; }
}
/* Streaming cursor */
.cursor {
display: inline-block;
width: 2px;
height: 1em;
background: var(--accent);
margin-left: 2px;
vertical-align: text-bottom;
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* ────────────── INPUT ────────────── */
.input-zone {
padding: 16px 32px 20px;
border-top: 1px solid var(--border);
background: var(--bg2);
position: relative;
}
.input-zone::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
opacity: 0.2;
}
.input-shell {
display: flex;
gap: 10px;
align-items: flex-end;
}
.input-wrapper {
flex: 1;
background: var(--surface);
border: 1px solid var(--border2);
border-radius: var(--radius);
transition: border-color 0.15s, box-shadow 0.15s;
position: relative;
}
.input-wrapper:focus-within {
border-color: rgba(0,255,136,0.4);
box-shadow: 0 0 0 3px rgba(0,255,136,0.05), 0 0 20px rgba(0,255,136,0.05);
}
.input-prefix {
position: absolute;
left: 14px;
top: 14px;
font-family: var(--mono);
font-size: 12px;
color: var(--accent);
pointer-events: none;
opacity: 0.6;
}
textarea {
width: 100%;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-family: var(--mono);
font-size: 13px;
line-height: 1.6;
padding: 13px 14px 13px 30px;
resize: none;
min-height: 46px;
max-height: 160px;
letter-spacing: 0.02em;
}
textarea::placeholder { color: var(--text3); }
.input-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 14px 8px 30px;
}
.input-hint-text {
font-family: var(--mono);
font-size: 9px;
color: var(--text3);
letter-spacing: 0.06em;
}
.char-count {
font-family: var(--mono);
font-size: 9px;
color: var(--text3);
}
.send-btn {
width: 46px; height: 46px;
background: var(--accent);
border: none;
border-radius: var(--radius);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
flex-shrink: 0;
position: relative;
overflow: hidden;
}
.send-btn::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255,255,255,0.1), transparent);
}
.send-btn:hover {
background: var(--accent2);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0,255,136,0.3);
}
.send-btn:active { transform: translateY(0); }
.send-btn:disabled { opacity: 0.3; cursor: not-allowed; transform: none; box-shadow: none; }
.send-btn svg { width: 18px; height: 18px; fill: var(--bg); }
/* Loading bar */
.loading-bar {
position: fixed;
top: 48px;
left: 0; right: 0;
height: 2px;
z-index: 100;
overflow: hidden;
opacity: 0;
transition: opacity 0.2s;
}
.loading-bar.active { opacity: 1; }
.loading-bar-fill {
height: 100%;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
animation: loading 1.5s ease-in-out infinite;
width: 60%;
}
@keyframes loading {
0% { transform: translateX(-100%); }
100% { transform: translateX(250%); }
}
/* Error toast */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
background: rgba(255,68,102,0.1);
border: 1px solid rgba(255,68,102,0.3);
color: var(--red);
font-family: var(--mono);
font-size: 11px;
padding: 10px 16px;
border-radius: var(--radius);
z-index: 200;
transform: translateY(20px);
opacity: 0;
transition: all 0.2s;
letter-spacing: 0.03em;
}
.toast.show {
transform: translateY(0);
opacity: 1;
}
/* Scrollbar */
::-webkit-scrollbar { width: 3px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border2); }
</style>
</head>
<body>
<div class="loading-bar" id="loading-bar">
<div class="loading-bar-fill"></div>
</div>
<div class="shell">
<!-- TOPBAR -->
<header class="topbar">
<div class="brand">
<div class="brand-mark">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="2" width="20" height="20" rx="2" stroke="#00ff88" stroke-width="1.5"/>
<path d="M6 12h12M12 6v12" stroke="#00ff88" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="12" cy="12" r="3" fill="none" stroke="#00ff88" stroke-width="1"/>
</svg>
</div>
<div class="brand-name">MY<span class="brand-slash">-</span>LLM</div>
</div>
<div class="topbar-right">
<div class="status-badge">
<div class="pulse" id="pulse"></div>
<span id="status-text">connecting</span>
</div>
<button class="topbar-btn" onclick="clearChat()">clear</button>
</div>
</header>
<!-- SIDEBAR -->
<aside class="sidebar">
<div class="sidebar-section">
<div class="section-title">model</div>
<div class="model-card">
<div class="model-id" id="model-id">my-llm v1.0</div>
<div class="model-rows">
<div class="model-row">
<div class="model-row-key">params</div>
<div class="model-row-val accent" id="stat-params">β€”</div>
</div>
<div class="model-row">
<div class="model-row-key">arch</div>
<div class="model-row-val">decoder</div>
</div>
<div class="model-row">
<div class="model-row-key">quant</div>
<div class="model-row-val">int8</div>
</div>
<div class="model-row">
<div class="model-row-key">device</div>
<div class="model-row-val">cpu</div>
</div>
</div>
</div>
</div>
<div class="sidebar-section">
<div class="section-title">parameters</div>
<div class="param-block">
<div class="param-header">
<div class="param-name">temperature</div>
<div class="param-value" id="temp-val">0.80</div>
</div>
<div class="track">
<div class="track-fill" id="temp-fill" style="width:35%"></div>
<input type="range" min="0.1" max="2.0" step="0.05" value="0.8"
oninput="updateParam('temp',this.value);updateFill('temp',this.value,0.1,2.0)">
</div>
</div>
<div class="param-block">
<div class="param-header">
<div class="param-name">top_k</div>
<div class="param-value" id="topk-val">40</div>
</div>
<div class="track">
<div class="track-fill" id="topk-fill" style="width:39%"></div>
<input type="range" min="1" max="100" step="1" value="40"
oninput="updateParam('topk',this.value);updateFill('topk',this.value,1,100)">
</div>
</div>
<div class="param-block">
<div class="param-header">
<div class="param-name">max_tokens</div>
<div class="param-value" id="maxtok-val">256</div>
</div>
<div class="track">
<div class="track-fill" id="maxtok-fill" style="width:49%"></div>
<input type="range" min="16" max="512" step="16" value="256"
oninput="updateParam('maxtok',this.value);updateFill('maxtok',this.value,16,512)">
</div>
</div>
</div>
<div class="sidebar-section">
<div class="section-title">endpoint</div>
<div class="ep-list">
<div class="ep-item active" id="ep-chat" onclick="setEndpoint('chat')">
<div class="ep-method">POST</div>
<div class="ep-path">/chat</div>
<div class="ep-dot"></div>
</div>
<div class="ep-item" id="ep-generate" onclick="setEndpoint('generate')">
<div class="ep-method">POST</div>
<div class="ep-path">/generate</div>
<div class="ep-dot"></div>
</div>
</div>
</div>
<div class="sidebar-stats">
<div class="section-title">last run</div>
<div class="stats-grid">
<div class="stat-cell">
<div class="stat-label">time</div>
<div class="stat-val" id="stat-time">β€”</div>
</div>
<div class="stat-cell">
<div class="stat-label">tok/s</div>
<div class="stat-val accent" id="stat-speed">β€”</div>
</div>
<div class="stat-cell">
<div class="stat-label">tokens</div>
<div class="stat-val" id="stat-tokens">β€”</div>
</div>
<div class="stat-cell">
<div class="stat-label">port</div>
<div class="stat-val">3001</div>
</div>
</div>
</div>
</aside>
<!-- CHAT -->
<main class="chat">
<div class="messages" id="messages">
<div class="empty" id="empty-state">
<div class="empty-glyph">⬑</div>
<div class="empty-headline">Ready to inference</div>
<div class="empty-sub">model loaded Β· awaiting prompt</div>
<div class="chips">
<div class="chip" onclick="suggest('Explain how transformers work')">transformers</div>
<div class="chip" onclick="suggest('Once upon a time in a digital world')">story</div>
<div class="chip" onclick="suggest('Write a Python function to sort a list')">code</div>
<div class="chip" onclick="suggest('What is the future of AI?')">future of AI</div>
<div class="chip" onclick="suggest('Write a haiku about neural networks')">haiku</div>
</div>
</div>
</div>
<div class="input-zone">
<div class="input-shell">
<div class="input-wrapper">
<div class="input-prefix">&gt;</div>
<textarea
id="prompt-input"
placeholder="enter prompt..."
rows="1"
onkeydown="handleKey(event)"
oninput="autoResize(this);updateCharCount(this)"
></textarea>
<div class="input-footer">
<div class="input-hint-text">↡ send &nbsp;Β·&nbsp; shift+↡ newline</div>
<div class="char-count" id="char-count">0</div>
</div>
</div>
<button class="send-btn" id="send-btn" onclick="sendMessage()" title="Send">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M22 2L11 13M22 2L15 22L11 13M22 2L2 9L11 13"/>
</svg>
</button>
</div>
</div>
</main>
</div>
<div class="toast" id="toast"></div>
<script>
const API = window.location.origin;
let endpoint = 'chat';
let params = { temperature: 0.8, top_k: 40, max_tokens: 256 };
let isLoading = false;
// ─── Health ───────────────────────────────────────────────────
async function checkHealth() {
try {
const r = await fetch(`${API}/health`, { signal: AbortSignal.timeout(3000) });
const d = await r.json();
document.getElementById('pulse').className = 'pulse';
document.getElementById('status-text').textContent = 'online';
if (d.params) {
document.getElementById('stat-params').textContent =
(d.params / 1e6).toFixed(1) + 'M';
}
} catch {
document.getElementById('pulse').className = 'pulse offline';
document.getElementById('status-text').textContent = 'offline';
}
}
checkHealth();
setInterval(checkHealth, 10000);
// ─── Param sliders ────────────────────────────────────────────
function updateFill(key, val, min, max) {
const pct = ((val - min) / (max - min)) * 100;
document.getElementById(`${key}-fill`).style.width = pct + '%';
}
function updateParam(key, val) {
const v = parseFloat(val);
if (key === 'temp') { params.temperature = v; document.getElementById('temp-val').textContent = v.toFixed(2); }
if (key === 'topk') { params.top_k = parseInt(val); document.getElementById('topk-val').textContent = val; }
if (key === 'maxtok') { params.max_tokens = parseInt(val); document.getElementById('maxtok-val').textContent = val; }
}
// ─── Endpoint ─────────────────────────────────────────────────
function setEndpoint(ep) {
endpoint = ep;
document.getElementById('ep-chat').classList.toggle('active', ep === 'chat');
document.getElementById('ep-generate').classList.toggle('active', ep === 'generate');
}
// ─── Suggest ─────────────────────────────────────────────────
function suggest(text) {
const el = document.getElementById('prompt-input');
el.value = text;
autoResize(el);
updateCharCount(el);
sendMessage();
}
// ─── Send ─────────────────────────────────────────────────────
async function sendMessage() {
const input = document.getElementById('prompt-input');
const prompt = input.value.trim();
if (!prompt || isLoading) return;
document.getElementById('empty-state').style.display = 'none';
addMessage('user', prompt);
input.value = '';
autoResize(input);
updateCharCount(input);
const thinkId = addThinking();
setLoading(true);
try {
const res = await fetch(`${API}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, ...params }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
removeThinking(thinkId);
addMessage('assistant', data.text, {
time: data.time_seconds + 's',
speed: data.tokens_per_sec,
tokens: data.tokens_generated,
});
document.getElementById('stat-time').textContent = data.time_seconds + 's';
document.getElementById('stat-speed').textContent = data.tokens_per_sec;
document.getElementById('stat-tokens').textContent = data.tokens_generated;
} catch (err) {
removeThinking(thinkId);
showToast('connection failed β€” is serve.py running?');
} finally {
setLoading(false);
}
}
// ─── Message helpers ──────────────────────────────────────────
function addMessage(role, text, meta = null) {
const container = document.getElementById('messages');
const now = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const group = document.createElement('div');
group.className = `msg-group ${role}`;
const perfHtml = meta ? `
<div class="msg-perf">
<div class="perf-item">${meta.time}</div>
<div class="perf-item"><span>${meta.speed}</span> tok/s</div>
<div class="perf-item"><span>${meta.tokens}</span> tok</div>
</div>` : '';
group.innerHTML = `
<div class="msg-meta">
<div class="msg-role-tag">${role === 'user' ? 'you' : 'llm'}</div>
<div class="msg-time">${now}</div>
${perfHtml}
</div>
<div class="msg-bubble">
<div class="msg-text">${esc(text)}</div>
</div>
`;
container.appendChild(group);
container.scrollTop = container.scrollHeight;
}
function addThinking() {
const container = document.getElementById('messages');
const id = 'think-' + Date.now();
const el = document.createElement('div');
el.id = id;
el.className = 'msg-group assistant';
el.innerHTML = `
<div class="msg-meta">
<div class="msg-role-tag">llm</div>
</div>
<div class="msg-bubble">
<div class="thinking-dots">
<span></span><span></span><span></span>
</div>
</div>
`;
container.appendChild(el);
container.scrollTop = container.scrollHeight;
return id;
}
function removeThinking(id) {
const el = document.getElementById(id);
if (el) el.remove();
}
function esc(t) {
return t.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ─── UI helpers ───────────────────────────────────────────────
function setLoading(v) {
isLoading = v;
document.getElementById('send-btn').disabled = v;
document.getElementById('loading-bar').classList.toggle('active', v);
}
function clearChat() {
const c = document.getElementById('messages');
c.innerHTML = `
<div class="empty" id="empty-state">
<div class="empty-glyph">⬑</div>
<div class="empty-headline">Ready to inference</div>
<div class="empty-sub">model loaded Β· awaiting prompt</div>
<div class="chips">
<div class="chip" onclick="suggest('Explain how transformers work')">transformers</div>
<div class="chip" onclick="suggest('Once upon a time in a digital world')">story</div>
<div class="chip" onclick="suggest('Write a Python function to sort a list')">code</div>
<div class="chip" onclick="suggest('What is the future of AI?')">future of AI</div>
<div class="chip" onclick="suggest('Write a haiku about neural networks')">haiku</div>
</div>
</div>
`;
['stat-time','stat-speed','stat-tokens'].forEach(id => {
document.getElementById(id).textContent = 'β€”';
});
}
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 4000);
}
function handleKey(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
function autoResize(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 160) + 'px';
}
function updateCharCount(el) {
document.getElementById('char-count').textContent = el.value.length;
}
</script>
</body>
</html>