Spaces:
Sleeping
Sleeping
| <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> |