| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"/> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"/> |
| <title>J.A.R.V.I.S</title> |
| <link rel="preconnect" href="https://fonts.googleapis.com"/> |
| <link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet"/> |
| <style> |
| :root { |
| --bg: #020b14; |
| --panel: #040f1c; |
| --border: #0a3a5c; |
| --glow: #00aaff; |
| --glow2: #00ffcc; |
| --text: #c8e8ff; |
| --dim: #3a6080; |
| --user-bg: #001a2e; |
| --ai-bg: #001228; |
| --danger: #ff3860; |
| --font-hud: 'Orbitron', monospace; |
| --font-mono: 'Share Tech Mono', monospace; |
| } |
| |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| |
| html, body { |
| height: 100%; |
| background: var(--bg); |
| color: var(--text); |
| font-family: var(--font-mono); |
| overflow: hidden; |
| } |
| |
| |
| body::before { |
| content: ''; |
| position: fixed; inset: 0; |
| background-image: |
| linear-gradient(rgba(0,170,255,.04) 1px, transparent 1px), |
| linear-gradient(90deg, rgba(0,170,255,.04) 1px, transparent 1px); |
| background-size: 40px 40px; |
| animation: gridScroll 20s linear infinite; |
| pointer-events: none; |
| z-index: 0; |
| } |
| @keyframes gridScroll { |
| from { background-position: 0 0; } |
| to { background-position: 40px 40px; } |
| } |
| |
| |
| body::after { |
| content: ''; |
| position: fixed; inset: 0; |
| background: repeating-linear-gradient( |
| 0deg, |
| transparent, |
| transparent 2px, |
| rgba(0,0,0,.15) 2px, |
| rgba(0,0,0,.15) 4px |
| ); |
| pointer-events: none; |
| z-index: 0; |
| } |
| |
| |
| #shell { |
| position: relative; z-index: 1; |
| display: flex; flex-direction: column; |
| height: 100vh; |
| max-width: 860px; |
| margin: 0 auto; |
| padding: 16px 16px 0; |
| } |
| |
| |
| header { |
| display: flex; align-items: center; justify-content: space-between; |
| padding: 10px 18px; |
| border: 1px solid var(--border); |
| background: var(--panel); |
| margin-bottom: 10px; |
| clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 0 100%); |
| } |
| |
| .logo { |
| font-family: var(--font-hud); |
| font-weight: 900; |
| font-size: 1.3rem; |
| letter-spacing: .15em; |
| color: var(--glow); |
| text-shadow: 0 0 12px var(--glow), 0 0 30px rgba(0,170,255,.3); |
| } |
| .logo span { color: var(--glow2); text-shadow: 0 0 12px var(--glow2); } |
| |
| .status-row { display: flex; gap: 12px; align-items: center; } |
| |
| .status-dot { |
| width: 8px; height: 8px; border-radius: 50%; |
| background: var(--dim); |
| transition: background .4s, box-shadow .4s; |
| } |
| .status-dot.online { |
| background: var(--glow2); |
| box-shadow: 0 0 8px var(--glow2); |
| animation: pulse 2s ease-in-out infinite; |
| } |
| @keyframes pulse { |
| 0%,100% { opacity: 1; } |
| 50% { opacity: .4; } |
| } |
| |
| .status-label { |
| font-size: .65rem; |
| letter-spacing: .1em; |
| color: var(--dim); |
| font-family: var(--font-hud); |
| } |
| .status-dot.online + .status-label { color: var(--glow2); } |
| |
| .btn-icon { |
| background: transparent; |
| border: 1px solid var(--border); |
| color: var(--dim); |
| padding: 5px 10px; |
| font-family: var(--font-hud); |
| font-size: .6rem; |
| letter-spacing: .1em; |
| cursor: pointer; |
| transition: all .2s; |
| } |
| .btn-icon:hover { |
| border-color: var(--glow); |
| color: var(--glow); |
| box-shadow: 0 0 8px rgba(0,170,255,.3); |
| } |
| .btn-icon.active { |
| border-color: var(--glow2); |
| color: var(--glow2); |
| box-shadow: 0 0 8px rgba(0,255,204,.3); |
| } |
| |
| |
| #boot { |
| flex: 1; |
| display: flex; flex-direction: column; justify-content: center; align-items: center; |
| gap: 8px; |
| } |
| .boot-line { |
| font-size: .78rem; |
| color: var(--glow); |
| opacity: 0; |
| animation: fadeIn .3s forwards; |
| } |
| .boot-line.dim { color: var(--dim); } |
| .boot-bar { |
| width: 300px; height: 2px; |
| background: var(--border); |
| margin-top: 16px; |
| overflow: hidden; |
| } |
| .boot-fill { |
| height: 100%; |
| background: linear-gradient(90deg, var(--glow), var(--glow2)); |
| width: 0%; |
| transition: width .4s ease; |
| box-shadow: 0 0 8px var(--glow); |
| } |
| @keyframes fadeIn { to { opacity: 1; } } |
| |
| |
| #messages { |
| flex: 1; |
| overflow-y: auto; |
| display: none; |
| flex-direction: column; |
| gap: 10px; |
| padding: 4px 2px 12px; |
| scrollbar-width: thin; |
| scrollbar-color: var(--border) transparent; |
| } |
| #messages.visible { display: flex; } |
| |
| .msg { |
| display: flex; |
| gap: 10px; |
| animation: msgIn .25s ease; |
| } |
| @keyframes msgIn { |
| from { opacity: 0; transform: translateY(6px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| .msg.user { justify-content: flex-end; } |
| |
| .avatar { |
| width: 30px; height: 30px; flex-shrink: 0; |
| border: 1px solid var(--border); |
| display: flex; align-items: center; justify-content: center; |
| font-family: var(--font-hud); |
| font-size: .55rem; |
| color: var(--glow); |
| background: var(--panel); |
| clip-path: polygon(0 0, 80% 0, 100% 20%, 100% 100%, 0 100%); |
| } |
| |
| .bubble { |
| max-width: 75%; |
| padding: 10px 14px; |
| font-size: .82rem; |
| line-height: 1.55; |
| border: 1px solid var(--border); |
| background: var(--ai-bg); |
| clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%); |
| } |
| .msg.user .bubble { |
| background: var(--user-bg); |
| border-color: #0a2a44; |
| clip-path: polygon(10px 0, 100% 0, 100% 100%, 0 100%, 0 10px); |
| color: #90c8f0; |
| } |
| .msg.ai .bubble { |
| border-color: var(--border); |
| color: var(--text); |
| } |
| |
| .typing-dots span { |
| display: inline-block; |
| width: 5px; height: 5px; border-radius: 50%; |
| background: var(--glow); |
| margin: 0 2px; |
| animation: blink 1.2s ease-in-out infinite; |
| } |
| .typing-dots span:nth-child(2) { animation-delay: .2s; } |
| .typing-dots span:nth-child(3) { animation-delay: .4s; } |
| @keyframes blink { 0%,80%,100% { opacity: .2; } 40% { opacity: 1; } } |
| |
| |
| #input-bar { |
| display: none; |
| gap: 8px; |
| padding: 10px 0 14px; |
| align-items: flex-end; |
| } |
| #input-bar.visible { display: flex; } |
| |
| #user-input { |
| flex: 1; |
| background: var(--panel); |
| border: 1px solid var(--border); |
| color: var(--text); |
| font-family: var(--font-mono); |
| font-size: .82rem; |
| padding: 10px 14px; |
| resize: none; |
| outline: none; |
| min-height: 42px; max-height: 140px; |
| transition: border-color .2s, box-shadow .2s; |
| clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%); |
| } |
| #user-input:focus { |
| border-color: var(--glow); |
| box-shadow: 0 0 12px rgba(0,170,255,.2); |
| } |
| #user-input::placeholder { color: var(--dim); } |
| |
| #send-btn { |
| background: linear-gradient(135deg, #003a5c, #001a2e); |
| border: 1px solid var(--glow); |
| color: var(--glow); |
| font-family: var(--font-hud); |
| font-size: .65rem; |
| letter-spacing: .12em; |
| padding: 10px 18px; |
| cursor: pointer; |
| height: 42px; |
| transition: all .2s; |
| clip-path: polygon(0 0, 80% 0, 100% 20%, 100% 100%, 0 100%); |
| } |
| #send-btn:hover:not(:disabled) { |
| background: linear-gradient(135deg, #004a7a, #002244); |
| box-shadow: 0 0 16px rgba(0,170,255,.4); |
| } |
| #send-btn:disabled { opacity: .4; cursor: not-allowed; } |
| |
| |
| .corner-deco { |
| position: fixed; |
| width: 60px; height: 60px; |
| opacity: .15; |
| pointer-events: none; |
| } |
| .corner-deco.tl { top: 0; left: 0; |
| border-top: 2px solid var(--glow); border-left: 2px solid var(--glow); } |
| .corner-deco.br { bottom: 0; right: 0; |
| border-bottom: 2px solid var(--glow2); border-right: 2px solid var(--glow2); } |
| |
| |
| #messages::-webkit-scrollbar { width: 4px; } |
| #messages::-webkit-scrollbar-track { background: transparent; } |
| #messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } |
| </style> |
| </head> |
| <body> |
|
|
| <div class="corner-deco tl"></div> |
| <div class="corner-deco br"></div> |
|
|
| <div id="shell"> |
| |
| <header> |
| <div class="logo">J.<span>A</span>.R.V.I.S</div> |
| <div class="status-row"> |
| <div class="status-dot" id="status-dot"></div> |
| <span class="status-label" id="status-label">INITIALIZING</span> |
| <button class="btn-icon" id="tts-btn" title="Toggle voice">VOICE</button> |
| <button class="btn-icon" id="clear-btn" title="Clear chat">CLEAR</button> |
| <button class="btn-icon" id="save-btn" title="Save chat">SAVE</button> |
| </div> |
| </header> |
|
|
| |
| <div id="boot"></div> |
|
|
| |
| <div id="messages"></div> |
|
|
| |
| <div id="input-bar"> |
| <textarea id="user-input" rows="1" placeholder="Enter query..."></textarea> |
| <button id="send-btn" disabled>SEND</button> |
| </div> |
| </div> |
|
|
| <script> |
| const $ = id => document.getElementById(id); |
| |
| |
| let history = []; |
| let ttsOn = false; |
| let busy = false; |
| let modelReady = false; |
| |
| const statusDot = $('status-dot'); |
| const statusLabel = $('status-label'); |
| const messagesEl = $('messages'); |
| const inputBar = $('input-bar'); |
| const bootEl = $('boot'); |
| const sendBtn = $('send-btn'); |
| const inputEl = $('user-input'); |
| const ttsBtn = $('tts-btn'); |
| |
| |
| const bootLines = [ |
| { text: '// INITIALIZING NEURAL CORE', dim: false }, |
| { text: '// LOADING LANGUAGE MODEL', dim: false }, |
| { text: '// ESTABLISHING VECTOR INDEX', dim: false }, |
| { text: '// VOICE SYNTHESIZER STANDBY', dim: true }, |
| { text: '// AWAITING SERVER HANDSHAKE', dim: false }, |
| ]; |
| |
| async function runBoot() { |
| const bar = document.createElement('div'); |
| bar.className = 'boot-bar'; |
| const fill = document.createElement('div'); |
| fill.className = 'boot-fill'; |
| bar.appendChild(fill); |
| |
| for (let i = 0; i < bootLines.length; i++) { |
| const l = bootLines[i]; |
| const el = document.createElement('div'); |
| el.className = 'boot-line' + (l.dim ? ' dim' : ''); |
| el.style.animationDelay = (i * 0.12) + 's'; |
| el.textContent = l.text; |
| bootEl.appendChild(el); |
| await sleep(130); |
| } |
| |
| bootEl.appendChild(bar); |
| await sleep(100); |
| |
| |
| while (true) { |
| try { |
| const r = await fetch('/health'); |
| if (r.ok) { |
| const d = await r.json(); |
| if (d.llm) break; |
| } |
| } catch (_) {} |
| fill.style.width = (Math.min(parseInt(fill.style.width || '0') + 8, 85)) + '%'; |
| await sleep(600); |
| } |
| |
| fill.style.width = '100%'; |
| await sleep(400); |
| |
| |
| bootEl.style.display = 'none'; |
| messagesEl.classList.add('visible'); |
| inputBar.classList.add('visible'); |
| statusDot.classList.add('online'); |
| statusLabel.textContent = 'ONLINE'; |
| sendBtn.disabled = false; |
| modelReady = true; |
| inputEl.focus(); |
| } |
| |
| |
| const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| |
| function scrollBottom() { |
| messagesEl.scrollTop = messagesEl.scrollHeight; |
| } |
| |
| function addMsg(role, text) { |
| const wrapper = document.createElement('div'); |
| wrapper.className = 'msg ' + role; |
| |
| if (role === 'ai') { |
| const av = document.createElement('div'); |
| av.className = 'avatar'; |
| av.textContent = 'AI'; |
| wrapper.appendChild(av); |
| } |
| |
| const bubble = document.createElement('div'); |
| bubble.className = 'bubble'; |
| bubble.textContent = text; |
| wrapper.appendChild(bubble); |
| |
| if (role === 'user') { |
| const av = document.createElement('div'); |
| av.className = 'avatar'; |
| av.textContent = 'YOU'; |
| wrapper.appendChild(av); |
| } |
| |
| messagesEl.appendChild(wrapper); |
| scrollBottom(); |
| return bubble; |
| } |
| |
| function addTyping() { |
| const wrapper = document.createElement('div'); |
| wrapper.className = 'msg ai'; |
| wrapper.id = 'typing'; |
| |
| const av = document.createElement('div'); |
| av.className = 'avatar'; |
| av.textContent = 'AI'; |
| wrapper.appendChild(av); |
| |
| const bubble = document.createElement('div'); |
| bubble.className = 'bubble'; |
| bubble.innerHTML = '<div class="typing-dots"><span></span><span></span><span></span></div>'; |
| wrapper.appendChild(bubble); |
| |
| messagesEl.appendChild(wrapper); |
| scrollBottom(); |
| return wrapper; |
| } |
| |
| function removeTyping() { |
| const t = $('typing'); |
| if (t) t.remove(); |
| } |
| |
| |
| async function send() { |
| const text = inputEl.value.trim(); |
| if (!text || busy || !modelReady) return; |
| |
| busy = true; |
| sendBtn.disabled = true; |
| inputEl.value = ''; |
| inputEl.style.height = 'auto'; |
| |
| addMsg('user', text); |
| const typingEl = addTyping(); |
| |
| let reply = ''; |
| |
| try { |
| const res = await fetch('/chat/stream', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ message: text, history }) |
| }); |
| |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| |
| const reader = res.body.getReader(); |
| const decoder = new TextDecoder(); |
| let aiBubble = null; |
| |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
| |
| const raw = decoder.decode(value, { stream: true }); |
| for (const line of raw.split('\n')) { |
| if (!line.startsWith('data:')) continue; |
| const payload = line.slice(5).trim(); |
| if (payload === '[DONE]') break; |
| try { |
| const piece = JSON.parse(payload); |
| if (!aiBubble) { |
| removeTyping(); |
| aiBubble = addMsg('ai', ''); |
| } |
| reply += piece; |
| aiBubble.textContent = reply; |
| scrollBottom(); |
| } catch (_) {} |
| } |
| } |
| |
| } catch (err) { |
| removeTyping(); |
| addMsg('ai', '[Error: ' + err.message + ']'); |
| busy = false; |
| sendBtn.disabled = false; |
| return; |
| } |
| |
| removeTyping(); |
| if (!reply) addMsg('ai', '[No response]'); |
| |
| reply = reply.trim(); |
| if (reply) { |
| history.push([text, reply]); |
| if (history.length > 20) history.shift(); |
| if (ttsOn) speakText(reply); |
| } |
| |
| busy = false; |
| sendBtn.disabled = false; |
| inputEl.focus(); |
| } |
| |
| |
| async function speakText(text) { |
| try { |
| const res = await fetch('/tts', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ text }) |
| }); |
| if (!res.ok) return; |
| const blob = await res.blob(); |
| const url = URL.createObjectURL(blob); |
| const audio = new Audio(url); |
| audio.play(); |
| } catch (_) {} |
| } |
| |
| |
| sendBtn.addEventListener('click', send); |
| |
| inputEl.addEventListener('keydown', e => { |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } |
| }); |
| |
| inputEl.addEventListener('input', () => { |
| inputEl.style.height = 'auto'; |
| inputEl.style.height = Math.min(inputEl.scrollHeight, 140) + 'px'; |
| }); |
| |
| ttsBtn.addEventListener('click', () => { |
| ttsOn = !ttsOn; |
| ttsBtn.classList.toggle('active', ttsOn); |
| ttsBtn.textContent = ttsOn ? 'VOICE ON' : 'VOICE'; |
| }); |
| |
| $('clear-btn').addEventListener('click', () => { |
| history = []; |
| messagesEl.innerHTML = ''; |
| addMsg('ai', 'Memory cleared. How can I assist you?'); |
| }); |
| |
| $('save-btn').addEventListener('click', async () => { |
| try { |
| const res = await fetch('/save', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ history }) |
| }); |
| const d = await res.json(); |
| addMsg('ai', d.saved ? 'Chat session saved to disk.' : 'Nothing to save.'); |
| } catch (_) { |
| addMsg('ai', 'Save failed.'); |
| } |
| }); |
| |
| |
| runBoot(); |
| </script> |
| </body> |
| </html> |