Spaces:
Paused
Paused
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <meta name="apple-mobile-web-app-capable" content="yes"> | |
| <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> | |
| <meta name="theme-color" content="#0f172a"> | |
| <title>Membra Chat</title> | |
| <link rel="manifest" href="/static/manifest.webmanifest"> | |
| <style> | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| -webkit-tap-highlight-color: transparent; | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'SF Pro', sans-serif; | |
| background: #0f172a; | |
| color: #e2e8f0; | |
| height: 100dvh; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| #header { | |
| padding: 12px 16px; | |
| background: #1e293b; | |
| border-bottom: 1px solid #334155; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| flex-shrink: 0; | |
| } | |
| #header .logo { | |
| width: 28px; | |
| height: 28px; | |
| border-radius: 6px; | |
| background: linear-gradient(135deg, #06b6d4, #3b82f6); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 14px; | |
| } | |
| #header h1 { | |
| font-size: 16px; | |
| font-weight: 600; | |
| } | |
| #header .status { | |
| margin-left: auto; | |
| font-size: 11px; | |
| padding: 3px 8px; | |
| border-radius: 10px; | |
| background: #334155; | |
| } | |
| #header .status.connected { | |
| background: #064e3b; | |
| color: #34d399; | |
| } | |
| #header .status.compute { | |
| background: #1e3a5f; | |
| color: #60a5fa; | |
| } | |
| #messages { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 12px 16px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| } | |
| .msg { | |
| max-width: 90%; | |
| padding: 10px 14px; | |
| border-radius: 14px; | |
| font-size: 14px; | |
| line-height: 1.5; | |
| word-wrap: break-word; | |
| animation: slideIn 0.2s ease; | |
| } | |
| @keyframes slideIn { | |
| from { | |
| opacity: 0; | |
| transform: translateY(8px); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| } | |
| .msg.user { | |
| align-self: flex-end; | |
| background: #3b82f6; | |
| color: #fff; | |
| border-bottom-right-radius: 4px; | |
| } | |
| .msg.assistant { | |
| align-self: flex-start; | |
| background: #1e293b; | |
| color: #e2e8f0; | |
| border-bottom-left-radius: 4px; | |
| } | |
| .msg.system { | |
| align-self: center; | |
| background: #334155; | |
| color: #94a3b8; | |
| font-size: 11px; | |
| padding: 6px 12px; | |
| } | |
| .msg .meta { | |
| font-size: 10px; | |
| opacity: 0.6; | |
| margin-top: 4px; | |
| } | |
| .msg .meta .device { | |
| color: #34d399; | |
| } | |
| .msg .meta .latency { | |
| color: #fbbf24; | |
| } | |
| #inputArea { | |
| padding: 10px 12px; | |
| background: #1e293b; | |
| border-top: 1px solid #334155; | |
| display: flex; | |
| gap: 8px; | |
| align-items: flex-end; | |
| flex-shrink: 0; | |
| } | |
| #promptInput { | |
| flex: 1; | |
| background: #0f172a; | |
| border: 1px solid #334155; | |
| border-radius: 20px; | |
| padding: 10px 14px; | |
| color: #e2e8f0; | |
| font-size: 15px; | |
| outline: none; | |
| resize: none; | |
| min-height: 20px; | |
| max-height: 120px; | |
| font-family: inherit; | |
| } | |
| #sendBtn { | |
| width: 40px; | |
| height: 40px; | |
| border-radius: 50%; | |
| background: #3b82f6; | |
| color: #fff; | |
| border: none; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 18px; | |
| flex-shrink: 0; | |
| } | |
| #sendBtn:disabled { | |
| background: #334155; | |
| } | |
| .typing { | |
| display: flex; | |
| gap: 3px; | |
| padding: 8px 0; | |
| } | |
| .typing span { | |
| width: 6px; | |
| height: 6px; | |
| background: #94a3b8; | |
| border-radius: 50%; | |
| animation: bounce 1s infinite; | |
| } | |
| .typing span:nth-child(2) { | |
| animation-delay: 0.15s; | |
| } | |
| .typing span:nth-child(3) { | |
| animation-delay: 0.3s; | |
| } | |
| @keyframes bounce { | |
| 0%, | |
| 80%, | |
| 100% { | |
| transform: translateY(0); | |
| } | |
| 40% { | |
| transform: translateY(-4px); | |
| } | |
| } | |
| #setup { | |
| padding: 20px; | |
| } | |
| #setup input, | |
| #setup button { | |
| width: 100%; | |
| padding: 12px; | |
| border-radius: 10px; | |
| border: 1px solid #334155; | |
| background: #1e293b; | |
| color: #e2e8f0; | |
| font-size: 15px; | |
| margin-top: 8px; | |
| } | |
| #setup button { | |
| background: #3b82f6; | |
| border: none; | |
| font-weight: 600; | |
| } | |
| .metric-bar { | |
| height: 4px; | |
| background: #334155; | |
| border-radius: 2px; | |
| margin-top: 4px; | |
| overflow: hidden; | |
| } | |
| .metric-bar .fill { | |
| height: 100%; | |
| border-radius: 2px; | |
| transition: width 0.3s; | |
| } | |
| #computeBadge { | |
| position: fixed; | |
| top: 60px; | |
| right: 12px; | |
| z-index: 100; | |
| background: #1e293b; | |
| border: 1px solid #334155; | |
| border-radius: 8px; | |
| padding: 8px 12px; | |
| font-size: 11px; | |
| display: none; | |
| } | |
| #computeBadge.show { | |
| display: block; | |
| } | |
| #computeBadge .node { | |
| color: #34d399; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="setup" style="display:none;"> | |
| <h2 style="margin-bottom:8px;">Connect to Session</h2> | |
| <p style="color:#94a3b8;font-size:13px;margin-bottom:12px;"> | |
| Enter your Space session ID or scan the QR code from your MacBook. | |
| </p> | |
| <input type="text" id="sessionInput" placeholder="Session ID (e.g., sess_abc123)"> | |
| <input type="text" id="tokenInput" placeholder="Worker Token"> | |
| <button onclick="connectSession()">Connect</button> | |
| </div> | |
| <div id="header"> | |
| <div class="logo">M</div> | |
| <h1>Membra</h1> | |
| <span id="connStatus" class="status">Offline</span> | |
| </div> | |
| <div id="computeBadge"> | |
| <div>Compute: <span class="node" id="computeNode">MacBook</span></div> | |
| <div class="metric-bar"> | |
| <div class="fill" id="computeBar" style="width:0%;background:#34d399;"></div> | |
| </div> | |
| </div> | |
| <div id="messages"></div> | |
| <div id="inputArea"> | |
| <textarea id="promptInput" rows="1" placeholder="Message Membra..." | |
| onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage();}"></textarea> | |
| <button id="sendBtn" onclick="sendMessage()">➤</button> | |
| </div> | |
| <script> | |
| let ws = null; | |
| let sessionId = null; | |
| let token = null; | |
| let msgId = 0; | |
| let streamingMsg = null; | |
| function getParams() { | |
| const p = new URLSearchParams(location.search); | |
| return { sid: p.get('session_id'), tok: p.get('token') }; | |
| } | |
| function connectSession() { | |
| sessionId = document.getElementById('sessionInput').value.trim(); | |
| token = document.getElementById('tokenInput').value.trim(); | |
| startConnection(); | |
| } | |
| function startConnection() { | |
| const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| const url = `${proto}//${location.host}/ws/gbridge/client/${sessionId}`; | |
| ws = new WebSocket(url); | |
| ws.onopen = () => { | |
| updateStatus('connected', 'Connected'); | |
| addSystem('Connected to session ' + sessionId); | |
| ws.send(JSON.stringify({ op: 'client_hello', device: 'iphone' })); | |
| }; | |
| ws.onmessage = (e) => { | |
| try { handleMessage(JSON.parse(e.data)); } catch (err) { } | |
| }; | |
| ws.onclose = () => { | |
| updateStatus('offline', 'Offline'); | |
| addSystem('Disconnected. Retrying...'); | |
| setTimeout(startConnection, 3000); | |
| }; | |
| ws.onerror = () => { | |
| updateStatus('offline', 'Error'); | |
| }; | |
| } | |
| function handleMessage(data) { | |
| if (data.op === 'job_stream') { | |
| if (!streamingMsg) { | |
| streamingMsg = createMsg('assistant', ''); | |
| } | |
| streamingMsg.textContent += data.chunk || ''; | |
| scrollToBottom(); | |
| } else if (data.op === 'job_complete') { | |
| if (streamingMsg) { | |
| const meta = document.createElement('div'); | |
| meta.className = 'meta'; | |
| meta.innerHTML = `<span class="device">${data.worker || 'MacBook'}</span> 路 <span class="latency">${data.latency_ms || 0}ms</span> 路 ${data.tokens || 0} tokens 路 ${(data.tps || 0).toFixed(1)} tok/s`; | |
| streamingMsg.appendChild(meta); | |
| streamingMsg = null; | |
| } | |
| updateComputeBadge(data.worker || 'MacBook', 0); | |
| document.getElementById('sendBtn').disabled = false; | |
| } else if (data.op === 'job_assigned') { | |
| updateComputeBadge(data.worker || 'MacBook', 30); | |
| } else if (data.op === 'error') { | |
| addSystem('Error: ' + (data.error || 'Unknown')); | |
| document.getElementById('sendBtn').disabled = false; | |
| } else if (data.op === 'client_welcome') { | |
| addSystem('Welcome! Type a prompt to start inference on your MacBook.'); | |
| } | |
| } | |
| function sendMessage() { | |
| const input = document.getElementById('promptInput'); | |
| const text = input.value.trim(); | |
| if (!text || !ws || ws.readyState !== 1) return; | |
| input.value = ''; | |
| input.style.height = 'auto'; | |
| createMsg('user', text); | |
| document.getElementById('sendBtn').disabled = true; | |
| // Show typing indicator | |
| const typing = document.createElement('div'); | |
| typing.id = 'typing'; | |
| typing.className = 'msg system'; | |
| typing.innerHTML = '<div class="typing"><span></span><span></span><span></span></div>'; | |
| document.getElementById('messages').appendChild(typing); | |
| scrollToBottom(); | |
| ws.send(JSON.stringify({ | |
| op: 'chat_submit', | |
| prompt: text, | |
| max_tokens: 512, | |
| temperature: 0.7, | |
| job_type: 'chat_completion' | |
| })); | |
| } | |
| function createMsg(role, text) { | |
| const el = document.createElement('div'); | |
| el.className = 'msg ' + role; | |
| if (text) el.textContent = text; | |
| document.getElementById('messages').appendChild(el); | |
| scrollToBottom(); | |
| return el; | |
| } | |
| function addSystem(text) { | |
| const t = document.getElementById('typing'); | |
| if (t) t.remove(); | |
| createMsg('system', text); | |
| } | |
| function updateStatus(cls, text) { | |
| const el = document.getElementById('connStatus'); | |
| el.className = 'status ' + cls; | |
| el.textContent = text; | |
| } | |
| function updateComputeBadge(node, load) { | |
| const badge = document.getElementById('computeBadge'); | |
| badge.classList.add('show'); | |
| document.getElementById('computeNode').textContent = node; | |
| document.getElementById('computeBar').style.width = load + '%'; | |
| } | |
| function scrollToBottom() { | |
| const m = document.getElementById('messages'); | |
| m.scrollTop = m.scrollHeight; | |
| } | |
| // Auto-connect from URL params | |
| const params = getParams(); | |
| if (params.sid && params.tok) { | |
| sessionId = params.sid; | |
| token = params.tok; | |
| document.getElementById('setup').style.display = 'none'; | |
| startConnection(); | |
| } else { | |
| document.getElementById('setup').style.display = 'block'; | |
| document.getElementById('header').style.display = 'none'; | |
| document.getElementById('messages').style.display = 'none'; | |
| document.getElementById('inputArea').style.display = 'none'; | |
| } | |
| // Auto-resize textarea | |
| document.getElementById('promptInput').addEventListener('input', function () { | |
| this.style.height = 'auto'; | |
| this.style.height = Math.min(this.scrollHeight, 120) + 'px'; | |
| }); | |
| </script> | |
| </body> | |
| </html> |