nova-1-chat / static /index.html
Bc-AI's picture
Update UI: badge says float32 not int8, default max_tokens=128, max slider 256
2d22e5f verified
Raw
History Blame Contribute Delete
16.6 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nova 1 Chat β€” CPU Edition</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#0a0a1a;--bg2:#1a1a2e;--surface:rgba(255,255,255,.04);
--surface-h:rgba(255,255,255,.07);--border:rgba(255,255,255,.08);
--text:#e2e8f0;--dim:#94a3b8;--accent:#8b5cf6;--accent2:#6366f1;
--user-bg:linear-gradient(135deg,#6366f1,#8b5cf6);
--bot-bg:#1e1e30;--danger:#ef4444;
}
body{font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
background:linear-gradient(135deg,var(--bg),var(--bg2));color:var(--text);
height:100vh;overflow:hidden;display:flex;flex-direction:column}
/* Loading overlay */
#loadingOverlay{position:fixed;inset:0;background:linear-gradient(135deg,#0a0a1a,#1a1a2e);
display:flex;align-items:center;justify-content:center;z-index:100;transition:opacity .5s}
#loadingOverlay.hidden{opacity:0;pointer-events:none}
.loader-content{text-align:center}
.loader-spinner{width:56px;height:56px;border:3px solid rgba(139,92,246,.2);
border-top-color:var(--accent);border-radius:50%;margin:0 auto 24px;animation:spin .8s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
#loadingText{font-size:18px;font-weight:600;margin-bottom:8px}
#loadingSubtext{font-size:14px;color:var(--dim)}
/* Chat */
#chatContainer{display:flex;flex-direction:column;height:100vh}
#chatContainer.hidden{display:none}
header{display:flex;align-items:center;justify-content:space-between;padding:16px 24px;
border-bottom:1px solid var(--border);background:rgba(10,10,26,.6);backdrop-filter:blur(12px);z-index:10}
.logo{font-size:20px;font-weight:700;display:flex;align-items:center;gap:8px}
.badge{font-size:10px;padding:2px 8px;background:rgba(139,92,246,.2);
border:1px solid rgba(139,92,246,.3);border-radius:12px;color:var(--accent);font-weight:600}
.header-controls{display:flex;gap:8px}
.icon-btn{background:var(--surface);border:1px solid var(--border);color:var(--text);
padding:8px 12px;border-radius:8px;cursor:pointer;font-size:16px;transition:all .2s}
.icon-btn:hover{background:var(--surface-h)}
/* Settings */
#settingsPanel{padding:16px 24px;background:rgba(30,30,48,.5);backdrop-filter:blur(8px);
border-bottom:1px solid var(--border);display:flex;gap:24px;flex-wrap:wrap}
#settingsPanel.hidden{display:none}
.setting{display:flex;flex-direction:column;gap:6px;min-width:180px}
.setting label{font-size:12px;color:var(--dim);font-weight:600}
.setting input[type=range]{width:100%;accent-color:var(--accent)}
.setting .value{font-size:12px;color:var(--accent);font-weight:600}
/* Messages */
#messages{flex:1;overflow-y:auto;padding:24px;display:flex;flex-direction:column;gap:16px}
.message{max-width:720px;width:100%;margin:0 auto;display:flex;gap:12px;animation:fadeIn .3s}
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
.avatar{width:36px;height:36px;border-radius:8px;display:flex;align-items:center;
justify-content:center;font-size:18px;flex-shrink:0}
.avatar.user{background:var(--user-bg)}
.avatar.bot{background:linear-gradient(135deg,#8b5cf6,#6366f1)}
.bubble{padding:12px 16px;border-radius:12px;line-height:1.6;font-size:15px;position:relative;flex:1}
.bubble.user{background:var(--user-bg);color:#fff}
.bubble.bot{background:var(--bot-bg);border:1px solid var(--border)}
.bubble pre{background:#0d0d1a;border:1px solid var(--border);border-radius:8px;
padding:12px;margin:8px 0;overflow-x:auto;font-size:13px}
.bubble code{font-family:'SF Mono','Fira Code',monospace;font-size:13px}
.bubble p code,.bubble>code{background:rgba(139,92,246,.15);padding:2px 6px;border-radius:4px;color:var(--accent)}
.copy-btn{position:absolute;top:8px;right:8px;background:var(--surface);border:1px solid var(--border);
color:var(--dim);padding:4px 8px;border-radius:6px;cursor:pointer;font-size:12px;opacity:0;transition:opacity .2s}
.bubble:hover .copy-btn{opacity:1}
.typing{display:flex;gap:4px;padding:4px 0}
.typing span{width:8px;height:8px;background:var(--dim);border-radius:50%;animation:typing 1.2s infinite}
.typing span:nth-child(2){animation-delay:.2s}
.typing span:nth-child(3){animation-delay:.4s}
@keyframes typing{0%,60%,100%{opacity:.3;transform:translateY(0)}30%{opacity:1;transform:translateY(-4px)}}
/* Input */
.input-area{padding:16px 24px;border-top:1px solid var(--border);background:rgba(10,10,26,.6);backdrop-filter:blur(12px)}
.input-wrapper{max-width:720px;margin:0 auto;display:flex;gap:12px;align-items:flex-end}
#input{flex:1;background:var(--bot-bg);border:1px solid var(--border);color:var(--text);
padding:12px 16px;border-radius:12px;font-size:15px;font-family:inherit;resize:none;
max-height:120px;outline:none;transition:border-color .2s}
#input:focus{border-color:var(--accent)}
#input::placeholder{color:var(--dim)}
.send-btn,.stop-btn{padding:12px 20px;border-radius:12px;border:none;cursor:pointer;
font-size:15px;font-weight:600;transition:all .2s}
.send-btn{background:var(--user-bg);color:#fff}
.send-btn:hover{opacity:.9}
.send-btn:disabled{opacity:.5;cursor:not-allowed}
.stop-btn{background:var(--danger);color:#fff}
.stop-btn.hidden{display:none}
/* Toast */
#toast{position:fixed;bottom:100px;left:50%;transform:translateX(-50%);background:var(--bot-bg);
border:1px solid var(--border);padding:12px 20px;border-radius:12px;font-size:14px;z-index:50;
opacity:0;transition:opacity .3s;pointer-events:none}
#toast.show{opacity:1}
/* Scrollbar */
#messages::-webkit-scrollbar{width:6px}
#messages::-webkit-scrollbar-track{background:transparent}
#messages::-webkit-scrollbar-thumb{background:rgba(139,92,246,.3);border-radius:3px}
@media(max-width:640px){
.message,.input-wrapper{max-width:100%}
#messages,.input-area,header{padding:12px}
#settingsPanel{flex-direction:column;gap:12px}
}
</style>
</head>
<body>
<div id="loadingOverlay">
<div class="loader-content">
<div class="loader-spinner"></div>
<div id="loadingText">Waking up on free CPU…</div>
<div id="loadingSubtext">This takes a minute on the first load</div>
</div>
</div>
<div id="chatContainer" class="hidden">
<header>
<div class="logo">⚑ Nova 1 <span class="badge">CPU · float32</span></div>
<div class="header-controls">
<button class="icon-btn" id="clearBtn" title="Clear chat">πŸ—‘οΈ</button>
<button class="icon-btn" id="settingsBtn" title="Settings">βš™οΈ</button>
</div>
</header>
<div id="settingsPanel" class="hidden">
<div class="setting">
<label>Temperature: <span class="value" id="tempVal">0.7</span></label>
<input type="range" id="tempSlider" min="0" max="2" step="0.1" value="0.7">
</div>
<div class="setting">
<label>Top-p: <span class="value" id="topPVal">0.9</span></label>
<input type="range" id="topPSlider" min="0.1" max="1" step="0.05" value="0.9">
</div>
<div class="setting">
<label>Max tokens: <span class="value" id="maxTokVal">128</span></label>
<input type="range" id="maxTokSlider" min="16" max="256" step="16" value="128">
</div>
</div>
<div id="messages"></div>
<div class="input-area">
<div class="input-wrapper">
<textarea id="input" placeholder="Send a message… (Enter to send, Shift+Enter for newline)" rows="1"></textarea>
<button class="send-btn" id="sendBtn">Send</button>
<button class="stop-btn hidden" id="stopBtn">Stop</button>
</div>
</div>
</div>
<div id="toast"></div>
<script>
// ─── State ──────────────────────────────────────────────────────────
let conversationHistory = [];
let isGenerating = false;
let currentAbortController = null;
let temperature = 0.7, topP = 0.9, maxTokens = 128;
// ─── DOM ────────────────────────────────────────────────────────────
const $ = id => document.getElementById(id);
const loadingOverlay = $('loadingOverlay');
const loadingText = $('loadingText');
const loadingSubtext = $('loadingSubtext');
const chatContainer = $('chatContainer');
const messagesEl = $('messages');
const inputEl = $('input');
const sendBtn = $('sendBtn');
const stopBtn = $('stopBtn');
const settingsPanel = $('settingsPanel');
const toast = $('toast');
const stageMessages = {
starting: 'Starting up…',
tokenizer: 'Loading tokenizer…',
weights: 'Downloading & loading model weights (1.27B params)…',
quantize: 'Optimizing for CPU…',
ready: 'Ready!',
error: 'Error loading model'
};
// ─── Health polling ──────────────────────────────────────────────────
async function checkHealth() {
try {
const res = await fetch('/api/health');
const data = await res.json();
if (data.status === 'ready') {
loadingOverlay.classList.add('hidden');
chatContainer.classList.remove('hidden');
setTimeout(() => loadingOverlay.remove(), 600);
inputEl.focus();
return;
}
if (data.status === 'error') {
loadingText.textContent = '❌ Model failed to load';
loadingSubtext.textContent = data.error || 'Unknown error';
return;
}
loadingText.textContent = stageMessages[data.stage] || 'Waking up on free CPU…';
if (data.stage === 'weights')
loadingSubtext.textContent = 'Pulling 1.27B params from the Hub…';
else
loadingSubtext.textContent = 'This takes a minute on the first load';
setTimeout(checkHealth, 1500);
} catch (e) {
setTimeout(checkHealth, 1500);
}
}
checkHealth();
// ─── Settings ───────────────────────────────────────────────────────
$('settingsBtn').addEventListener('click', () => settingsPanel.classList.toggle('hidden'));
$('tempSlider').addEventListener('input', e => { temperature = +e.target.value; $('tempVal').textContent = temperature; });
$('topPSlider').addEventListener('input', e => { topP = +e.target.value; $('topPVal').textContent = topP; });
$('maxTokSlider').addEventListener('input', e => { maxTokens = +e.target.value; $('maxTokVal').textContent = maxTokens; });
$('clearBtn').addEventListener('click', () => { conversationHistory = []; messagesEl.innerHTML = ''; });
// ─── Input ──────────────────────────────────────────────────────────
inputEl.addEventListener('input', () => { inputEl.style.height = 'auto'; inputEl.style.height = Math.min(inputEl.scrollHeight, 120) + 'px'; });
inputEl.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } });
sendBtn.addEventListener('click', sendMessage);
stopBtn.addEventListener('click', stopGeneration);
function stopGeneration() {
if (currentAbortController) { currentAbortController.abort(); currentAbortController = null; }
isGenerating = false;
sendBtn.classList.remove('hidden');
stopBtn.classList.add('hidden');
inputEl.disabled = false;
}
function showToast(msg) {
toast.textContent = msg;
toast.classList.add('show');
setTimeout(() => toast.classList.remove('show'), 3000);
}
function escapeHtml(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
function renderMarkdown(text) {
let html = escapeHtml(text);
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, l, c) => `<pre><code>${escapeHtml(c)}</code></pre>`);
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
html = html.replace(/\n/g, '<br>');
return html;
}
// ─── Message helpers ────────────────────────────────────────────────
function addMessage(role, content) {
const msg = document.createElement('div');
msg.className = 'message';
const avatar = document.createElement('div');
avatar.className = `avatar ${role}`;
avatar.textContent = role === 'user' ? 'πŸ§‘' : '⚑';
const bubble = document.createElement('div');
bubble.className = `bubble ${role}`;
const contentEl = document.createElement('div');
contentEl.className = 'bubble-content';
contentEl.innerHTML = role === 'user' ? escapeHtml(content) : renderMarkdown(content);
bubble.appendChild(contentEl);
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-btn';
copyBtn.textContent = 'Copy';
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(content);
copyBtn.textContent = 'Copied!';
setTimeout(() => copyBtn.textContent = 'Copy', 2000);
});
bubble.appendChild(copyBtn);
msg.appendChild(avatar);
msg.appendChild(bubble);
messagesEl.appendChild(msg);
messagesEl.scrollTop = messagesEl.scrollHeight;
return contentEl;
}
function addTypingIndicator() {
const msg = document.createElement('div');
msg.className = 'message';
msg.id = 'typingMsg';
const avatar = document.createElement('div');
avatar.className = 'avatar bot';
avatar.textContent = '⚑';
const bubble = document.createElement('div');
bubble.className = 'bubble bot';
bubble.innerHTML = '<div class="typing"><span></span><span></span><span></span></div>';
msg.appendChild(avatar);
msg.appendChild(bubble);
messagesEl.appendChild(msg);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function removeTypingIndicator() {
const t = $('typingMsg');
if (t) t.remove();
}
// ─── Send + SSE stream ──────────────────────────────────────────────
async function sendMessage() {
const text = inputEl.value.trim();
if (!text || isGenerating) return;
inputEl.value = '';
inputEl.style.height = 'auto';
addMessage('user', text);
conversationHistory.push({ role: 'user', content: text });
addTypingIndicator();
isGenerating = true;
sendBtn.classList.add('hidden');
stopBtn.classList.remove('hidden');
inputEl.disabled = true;
const controller = new AbortController();
currentAbortController = controller;
let contentEl = null;
let assistantText = '';
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: conversationHistory, temperature, max_new_tokens: maxTokens, top_p: topP }),
signal: controller.signal
});
if (!response.ok) {
const err = await response.json();
removeTypingIndicator();
showToast(err.error || 'Failed to generate response');
return;
}
removeTypingIndicator();
contentEl = addMessage('bot', '');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const events = buffer.split('\n\n');
buffer = events.pop();
for (const event of events) {
if (!event.startsWith('data: ')) continue;
const data = event.slice(6);
if (data === '[DONE]') break;
try {
const parsed = JSON.parse(data);
if (parsed.token) {
assistantText += parsed.token;
contentEl.innerHTML = renderMarkdown(assistantText);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
if (parsed.error) showToast(parsed.error);
} catch (e) {}
}
}
if (assistantText.trim())
conversationHistory.push({ role: 'assistant', content: assistantText });
} catch (e) {
if (e.name !== 'AbortError') showToast('Connection error: ' + e.message);
removeTypingIndicator();
if (assistantText.trim())
conversationHistory.push({ role: 'assistant', content: assistantText });
} finally {
isGenerating = false;
currentAbortController = null;
sendBtn.classList.remove('hidden');
stopBtn.classList.add('hidden');
inputEl.disabled = false;
inputEl.focus();
}
}
</script>
</body>
</html>