| <!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. AI</title> |
| <style> |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| body { |
| font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
| background: #0a0a1a; color: #e0e0e0; |
| height: 100vh; display: flex; flex-direction: column; overflow: hidden; |
| } |
| |
| .header { |
| background: linear-gradient(135deg, #0d1b2a, #1b2838); |
| border-bottom: 1px solid #00d4ff33; |
| padding: 12px 20px; display: flex; align-items: center; |
| justify-content: space-between; flex-shrink: 0; |
| } |
| .header-left { display: flex; align-items: center; gap: 12px; } |
| .arc-reactor { |
| width: 38px; height: 38px; border-radius: 50%; |
| background: radial-gradient(circle, #00d4ff 0%, #0088aa 40%, #004466 70%, transparent 100%); |
| box-shadow: 0 0 20px #00d4ff88, 0 0 40px #00d4ff44, inset 0 0 10px #00d4ff66; |
| animation: pulse 2s ease-in-out infinite; position: relative; |
| } |
| .arc-reactor::after { |
| content: ''; position: absolute; top: 50%; left: 50%; |
| transform: translate(-50%, -50%); width: 12px; height: 12px; |
| border-radius: 50%; background: #00d4ff; box-shadow: 0 0 8px #00d4ff; |
| } |
| @keyframes pulse { |
| 0%, 100% { box-shadow: 0 0 20px #00d4ff88, 0 0 40px #00d4ff44; } |
| 50% { box-shadow: 0 0 30px #00d4ffaa, 0 0 60px #00d4ff66; } |
| } |
| .header-title h1 { font-size: 1.2rem; color: #00d4ff; letter-spacing: 3px; text-transform: uppercase; } |
| .header-title p { font-size: 0.65rem; color: #5a8a9a; letter-spacing: 1px; } |
| |
| .header-controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } |
| .ctrl-btn { |
| background: #0d1b2a; border: 1px solid #00d4ff44; color: #00d4ff; |
| padding: 5px 12px; border-radius: 6px; cursor: pointer; |
| font-size: 0.7rem; transition: all 0.3s; letter-spacing: 0.5px; |
| } |
| .ctrl-btn:hover { background: #00d4ff22; border-color: #00d4ff88; } |
| .ctrl-btn.active { background: #00d4ff22; border-color: #00d4ff; box-shadow: 0 0 8px #00d4ff44; } |
| .status-dot { |
| width: 8px; height: 8px; border-radius: 50%; |
| background: #00ff88; box-shadow: 0 0 6px #00ff88; |
| } |
| .status-dot.error { background: #ff4444; box-shadow: 0 0 6px #ff4444; } |
| |
| |
| .config-bar { |
| background: #0d1117; border-bottom: 1px solid #00d4ff15; |
| padding: 8px 20px; display: none; flex-wrap: wrap; gap: 12px; |
| align-items: center; flex-shrink: 0; |
| } |
| .config-bar.open { display: flex; } |
| .config-group { display: flex; align-items: center; gap: 6px; } |
| .config-group label { font-size: 0.65rem; color: #5a8a9a; text-transform: uppercase; letter-spacing: 1px; } |
| .config-group select { |
| background: #0f1923; border: 1px solid #00d4ff33; color: #00d4ff; |
| padding: 4px 8px; border-radius: 4px; font-size: 0.7rem; cursor: pointer; |
| } |
| .config-group select:focus { border-color: #00d4ff; outline: none; } |
| .config-tag { |
| font-size: 0.6rem; padding: 3px 8px; border-radius: 10px; |
| background: #00d4ff15; border: 1px solid #00d4ff33; color: #00d4ffaa; |
| } |
| |
| |
| .chat-container { |
| flex: 1; overflow-y: auto; padding: 16px 20px; |
| display: flex; flex-direction: column; gap: 14px; scroll-behavior: smooth; |
| } |
| .chat-container::-webkit-scrollbar { width: 3px; } |
| .chat-container::-webkit-scrollbar-thumb { background: #00d4ff33; border-radius: 2px; } |
| .message { |
| max-width: 80%; padding: 12px 16px; border-radius: 14px; |
| font-size: 0.9rem; line-height: 1.6; animation: fadeIn 0.3s ease-out; |
| } |
| @keyframes fadeIn { |
| from { opacity: 0; transform: translateY(8px); } |
| to { opacity: 1; transform: translateY(0); } |
| } |
| .message.user { |
| align-self: flex-end; background: linear-gradient(135deg, #1a3a5c, #0d2847); |
| border: 1px solid #00d4ff33; color: #c8e6ff; border-bottom-right-radius: 4px; |
| } |
| .message.assistant { |
| align-self: flex-start; background: linear-gradient(135deg, #141e30, #0f1923); |
| border: 1px solid #00d4ff22; color: #e0e0e0; border-bottom-left-radius: 4px; |
| } |
| .message .label { font-size: 0.58rem; color: #00d4ff88; letter-spacing: 2px; margin-bottom: 5px; text-transform: uppercase; } |
| .message .text-content { white-space: pre-wrap; word-wrap: break-word; } |
| .message .audio-controls { margin-top: 8px; display: flex; align-items: center; gap: 8px; } |
| .audio-btn { |
| display: inline-flex; align-items: center; gap: 4px; |
| background: #00d4ff15; border: 1px solid #00d4ff33; color: #00d4ff; |
| padding: 3px 10px; border-radius: 10px; cursor: pointer; |
| font-size: 0.65rem; transition: all 0.2s; |
| } |
| .audio-btn:hover { background: #00d4ff25; border-color: #00d4ff66; } |
| .audio-btn:disabled { opacity: 0.3; cursor: wait; } |
| .audio-status { font-size: 0.58rem; color: #5a8a9a; } |
| |
| .typing-indicator { align-self: flex-start; display: flex; gap: 5px; padding: 14px 18px; } |
| .typing-indicator span { |
| width: 7px; height: 7px; border-radius: 50%; background: #00d4ff; animation: typing 1.4s infinite; |
| } |
| .typing-indicator span:nth-child(2) { animation-delay: 0.2s; } |
| .typing-indicator span:nth-child(3) { animation-delay: 0.4s; } |
| @keyframes typing { |
| 0%, 60%, 100% { opacity: 0.2; transform: scale(0.8); } |
| 30% { opacity: 1; transform: scale(1.1); } |
| } |
| |
| .welcome { |
| display: flex; flex-direction: column; align-items: center; |
| justify-content: center; flex: 1; gap: 10px; opacity: 0.5; |
| } |
| .welcome .big-reactor { |
| width: 70px; height: 70px; border-radius: 50%; |
| background: radial-gradient(circle, #00d4ff 0%, #0088aa 35%, #004466 65%, transparent 100%); |
| box-shadow: 0 0 40px #00d4ff66; animation: pulse 2s ease-in-out infinite; |
| } |
| .welcome h2 { color: #00d4ff; font-size: 1rem; letter-spacing: 4px; } |
| .welcome p { color: #5a8a9a; font-size: 0.75rem; } |
| .welcome .model-info { font-size: 0.65rem; color: #3a5a6a; margin-top: 4px; } |
| |
| .input-container { |
| padding: 14px 20px; background: linear-gradient(0deg, #0d1b2a, #0a0a1a); |
| border-top: 1px solid #00d4ff22; flex-shrink: 0; |
| } |
| .input-wrapper { display: flex; gap: 8px; max-width: 900px; margin: 0 auto; } |
| #messageInput { |
| flex: 1; background: #0f1923; border: 1px solid #00d4ff33; border-radius: 12px; |
| padding: 11px 16px; color: #e0e0e0; font-size: 0.9rem; outline: none; |
| transition: border-color 0.3s; font-family: inherit; |
| } |
| #messageInput:focus { border-color: #00d4ff88; box-shadow: 0 0 12px #00d4ff22; } |
| #messageInput::placeholder { color: #3a5a6a; } |
| #sendBtn { |
| background: linear-gradient(135deg, #00d4ff, #0088cc); border: none; border-radius: 12px; |
| padding: 11px 22px; color: #0a0a1a; font-weight: 700; cursor: pointer; |
| font-size: 0.8rem; letter-spacing: 1px; transition: all 0.3s; text-transform: uppercase; |
| } |
| #sendBtn:hover { box-shadow: 0 0 18px #00d4ff66; transform: translateY(-1px); } |
| #sendBtn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; } |
| .input-footer { |
| display: flex; justify-content: space-between; margin-top: 5px; |
| max-width: 900px; margin-left: auto; margin-right: auto; |
| } |
| .input-footer span { font-size: 0.6rem; color: #3a5a6a; } |
| |
| @media (max-width: 640px) { |
| .header { padding: 10px 12px; } |
| .header-title h1 { font-size: 1rem; } |
| .message { max-width: 92%; font-size: 0.82rem; } |
| .chat-container { padding: 10px; } |
| .input-container { padding: 10px; } |
| .config-bar { padding: 6px 12px; } |
| } |
| </style> |
| </head> |
| <body> |
|
|
| <div class="header"> |
| <div class="header-left"> |
| <div class="arc-reactor"></div> |
| <div class="header-title"> |
| <h1>J.A.R.V.I.S.</h1> |
| <p>Just A Rather Very Intelligent System</p> |
| </div> |
| </div> |
| <div class="header-controls"> |
| <div class="status-dot" id="statusDot"></div> |
| <button class="ctrl-btn" id="configToggle" onclick="toggleConfig()">⚙ CONFIG</button> |
| <button class="ctrl-btn active" id="ttsToggle" onclick="toggleTTS()">🔊 VOICE</button> |
| <button class="ctrl-btn" onclick="clearChat()">🗑 CLEAR</button> |
| </div> |
| </div> |
|
|
| |
| <div class="config-bar" id="configPanel"> |
| <div class="config-group"> |
| <label>LLM:</label> |
| <span class="config-tag" id="llmTag">loading...</span> |
| </div> |
| <div class="config-group"> |
| <label>TTS:</label> |
| <span class="config-tag" id="ttsTag">loading...</span> |
| </div> |
| <div class="config-group"> |
| <label>Voice:</label> |
| <select id="voiceSelect"> |
| <option value="Kiki">Kiki</option> |
| <option value="Bella">Bella</option> |
| <option value="Jasper">Jasper</option> |
| <option value="Luna">Luna</option> |
| <option value="Bruno">Bruno</option> |
| <option value="Rosie">Rosie</option> |
| <option value="Hugo">Hugo</option> |
| <option value="Leo">Leo</option> |
| </select> |
| </div> |
| <div class="config-group"> |
| <label>Options (set via env vars):</label> |
| <span class="config-tag">LLM_MODE: gemma-3-270m-it | minilm-semantic</span> |
| <span class="config-tag">TTS_MODE: nano-fp32 | nano-int8 | micro | mini</span> |
| </div> |
| </div> |
|
|
| <div class="chat-container" id="chatContainer"> |
| <div class="welcome" id="welcome"> |
| <div class="big-reactor"></div> |
| <h2>SYSTEMS ONLINE</h2> |
| <p>Type a message below to begin interaction</p> |
| <div class="model-info" id="welcomeInfo">Initializing...</div> |
| </div> |
| </div> |
|
|
| <div class="input-container"> |
| <div class="input-wrapper"> |
| <input type="text" id="messageInput" placeholder="Talk to J.A.R.V.I.S..." autocomplete="off" /> |
| <button id="sendBtn" onclick="sendMessage()">SEND</button> |
| </div> |
| <div class="input-footer"> |
| <span id="memoryCount">Memory: 0 turns</span> |
| <span id="modelInfo">Loading...</span> |
| </div> |
| </div> |
|
|
| <script> |
| let sessionId = crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2); |
| let ttsEnabled = true; |
| let isProcessing = false; |
| let msgCounter = 0; |
| let currentVoice = 'Kiki'; |
| |
| const chatEl = document.getElementById('chatContainer'); |
| const inputEl = document.getElementById('messageInput'); |
| const sendBtn = document.getElementById('sendBtn'); |
| const welcome = document.getElementById('welcome'); |
| |
| inputEl.addEventListener('keydown', e => { |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } |
| }); |
| |
| document.getElementById('voiceSelect').addEventListener('change', function() { |
| currentVoice = this.value; |
| }); |
| |
| function toggleTTS() { |
| ttsEnabled = !ttsEnabled; |
| const btn = document.getElementById('ttsToggle'); |
| btn.classList.toggle('active', ttsEnabled); |
| btn.textContent = ttsEnabled ? '🔊 VOICE' : '🔇 MUTE'; |
| } |
| |
| function toggleConfig() { |
| const panel = document.getElementById('configPanel'); |
| const btn = document.getElementById('configToggle'); |
| panel.classList.toggle('open'); |
| btn.classList.toggle('active'); |
| } |
| |
| async function sendMessage() { |
| const text = inputEl.value.trim(); |
| if (!text || isProcessing) return; |
| |
| if (welcome) welcome.style.display = 'none'; |
| addUserMessage(text); |
| inputEl.value = ''; |
| isProcessing = true; |
| sendBtn.disabled = true; |
| |
| const typingEl = showTyping(); |
| const msgId = ++msgCounter; |
| |
| try { |
| |
| const res = await fetch('/chat', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ message: text, session_id: sessionId }) |
| }); |
| if (!res.ok) throw new Error(`HTTP ${res.status}`); |
| const data = await res.json(); |
| typingEl.remove(); |
| |
| const msgEl = addAssistantMessage(data.response, msgId); |
| document.getElementById('memoryCount').textContent = `Memory: ${data.memory_length} turns`; |
| |
| |
| if (ttsEnabled && data.tts_available) { |
| fetchAndPlayAudio(data.response, msgId, msgEl); |
| } |
| } catch (err) { |
| typingEl.remove(); |
| addAssistantMessage('System malfunction. Please try again.', msgId); |
| console.error(err); |
| } |
| |
| isProcessing = false; |
| sendBtn.disabled = false; |
| inputEl.focus(); |
| } |
| |
| async function fetchAndPlayAudio(text, msgId, msgEl) { |
| const statusEl = msgEl.querySelector('.audio-status'); |
| const playBtn = msgEl.querySelector('.audio-btn'); |
| if (statusEl) statusEl.textContent = '⏳ Generating voice...'; |
| if (playBtn) playBtn.disabled = true; |
| |
| try { |
| const res = await fetch('/tts', { |
| method: 'POST', |
| headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ text: text, voice: currentVoice }) |
| }); |
| const data = await res.json(); |
| if (data.audio) { |
| if (playBtn) { playBtn.dataset.audio = data.audio; playBtn.disabled = false; playBtn.textContent = '▶ Play'; } |
| if (statusEl) statusEl.textContent = '✅ Ready'; |
| playAudioBase64(data.audio); |
| } else { |
| if (statusEl) statusEl.textContent = '⚠️ Voice unavailable'; |
| if (playBtn) playBtn.style.display = 'none'; |
| } |
| } catch (e) { |
| if (statusEl) statusEl.textContent = '⚠️ Voice error'; |
| if (playBtn) playBtn.style.display = 'none'; |
| } |
| } |
| |
| function addUserMessage(text) { |
| const div = document.createElement('div'); |
| div.className = 'message user'; |
| div.innerHTML = `<div class="text-content">${esc(text)}</div>`; |
| chatEl.appendChild(div); |
| scroll(); |
| } |
| |
| function addAssistantMessage(text, msgId) { |
| const div = document.createElement('div'); |
| div.className = 'message assistant'; |
| div.id = `msg-${msgId}`; |
| div.innerHTML = ` |
| <div class="label">⟐ JARVIS</div> |
| <div class="text-content">${esc(text)}</div> |
| ${ttsEnabled ? ` |
| <div class="audio-controls"> |
| <button class="audio-btn" disabled onclick="replayAudio(this)">⏳</button> |
| <span class="audio-status">Requesting voice...</span> |
| </div>` : ''}`; |
| chatEl.appendChild(div); |
| scroll(); |
| return div; |
| } |
| |
| function showTyping() { |
| const div = document.createElement('div'); |
| div.className = 'typing-indicator'; |
| div.innerHTML = '<span></span><span></span><span></span>'; |
| chatEl.appendChild(div); |
| scroll(); |
| return div; |
| } |
| |
| function playAudioBase64(b64) { |
| try { |
| const bin = atob(b64); |
| const bytes = new Uint8Array(bin.length); |
| for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); |
| const blob = new Blob([bytes], { type: 'audio/wav' }); |
| const url = URL.createObjectURL(blob); |
| const audio = new Audio(url); |
| audio.play().catch(e => console.log('Autoplay blocked:', e)); |
| audio.onended = () => URL.revokeObjectURL(url); |
| } catch (e) { console.error(e); } |
| } |
| |
| function replayAudio(btn) { if (btn.dataset.audio) playAudioBase64(btn.dataset.audio); } |
| |
| async function clearChat() { |
| await fetch('/clear', { |
| method: 'POST', headers: { 'Content-Type': 'application/json' }, |
| body: JSON.stringify({ session_id: sessionId }) |
| }); |
| chatEl.innerHTML = ` |
| <div class="welcome" id="welcome"> |
| <div class="big-reactor"></div> |
| <h2>SYSTEMS ONLINE</h2> |
| <p>Type a message below to begin</p> |
| </div>`; |
| document.getElementById('memoryCount').textContent = 'Memory: 0 turns'; |
| sessionId = crypto.randomUUID ? crypto.randomUUID() : Date.now().toString(36) + Math.random().toString(36).slice(2); |
| } |
| |
| function esc(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; } |
| function scroll() { chatEl.scrollTop = chatEl.scrollHeight; } |
| |
| |
| fetch('/health').then(r => r.json()).then(d => { |
| document.getElementById('llmTag').textContent = d.llm_mode; |
| document.getElementById('ttsTag').textContent = d.tts_mode + (d.tts_model === 'DISABLED' ? ' (OFF)' : ''); |
| document.getElementById('modelInfo').textContent = `${d.llm_mode} · ${d.tts_mode} · ${d.tts_voice} · CPU`; |
| const wi = document.getElementById('welcomeInfo'); |
| if (wi) wi.textContent = `LLM: ${d.llm_mode} | TTS: ${d.tts_mode} | Voice: ${d.tts_voice}`; |
| if (d.tts_model === 'DISABLED') document.getElementById('statusDot').classList.add('error'); |
| |
| if (d.tts_voice) { document.getElementById('voiceSelect').value = d.tts_voice; currentVoice = d.tts_voice; } |
| }).catch(() => {}); |
| |
| inputEl.focus(); |
| </script> |
| </body> |
| </html> |