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"> | |
| <title>PhoneGPU Chat</title> | |
| <style> | |
| :root { | |
| --bg: #0d0d12; | |
| --surface: #14141b; | |
| --surface-2: #1c1c27; | |
| --border: rgba(255, 255, 255, .06); | |
| --text: #e8e8ec; | |
| --text-muted: #6b6b78; | |
| --accent: #d4a574; | |
| --accent-2: #00f0ff; | |
| --user-bg: rgba(212, 165, 116, .12); | |
| --assistant-bg: rgba(0, 240, 255, .05); | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| -webkit-tap-highlight-color: transparent | |
| } | |
| html, | |
| body { | |
| height: 100%; | |
| overflow: hidden | |
| } | |
| body { | |
| font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', sans-serif; | |
| background: var(--bg); | |
| color: var(--text); | |
| display: flex; | |
| flex-direction: column | |
| } | |
| #header { | |
| flex-shrink: 0; | |
| padding: 14px 20px 10px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| border-bottom: 1px solid var(--border); | |
| background: var(--surface); | |
| backdrop-filter: blur(20px) | |
| } | |
| #header .brand { | |
| font-weight: 700; | |
| font-size: 15px; | |
| display: flex; | |
| align-items: center; | |
| gap: 8px | |
| } | |
| #header .brand .dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: var(--accent); | |
| box-shadow: 0 0 8px var(--accent) | |
| } | |
| #header .brand .dot.offline { | |
| background: #f87171; | |
| box-shadow: 0 0 8px #f87171 | |
| } | |
| #header .model { | |
| font-size: 12px; | |
| color: var(--text-muted) | |
| } | |
| #messages { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 16px 16px 80px; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 12px; | |
| scroll-behavior: smooth | |
| } | |
| .msg { | |
| max-width: 90%; | |
| padding: 12px 16px; | |
| border-radius: 18px; | |
| font-size: 15px; | |
| line-height: 1.5; | |
| word-wrap: break-word; | |
| animation: fadeIn .3s ease | |
| } | |
| .msg.user { | |
| align-self: flex-end; | |
| background: var(--user-bg); | |
| border-bottom-right-radius: 4px; | |
| color: #f0dcc8 | |
| } | |
| .msg.assistant { | |
| align-self: flex-start; | |
| background: var(--surface-2); | |
| border-bottom-left-radius: 4px; | |
| border: 1px solid var(--border); | |
| color: var(--text) | |
| } | |
| .msg .meta { | |
| font-size: 10px; | |
| color: var(--text-muted); | |
| margin-top: 6px; | |
| opacity: .7 | |
| } | |
| #input-area { | |
| flex-shrink: 0; | |
| padding: 12px 16px 24px; | |
| border-top: 1px solid var(--border); | |
| background: var(--surface); | |
| display: flex; | |
| gap: 8px; | |
| align-items: flex-end | |
| } | |
| #chatInput { | |
| flex: 1; | |
| padding: 12px 16px; | |
| border-radius: 20px; | |
| border: 1px solid var(--border); | |
| background: var(--bg); | |
| color: var(--text); | |
| font-size: 15px; | |
| outline: none; | |
| max-height: 120px; | |
| min-height: 44px; | |
| resize: none; | |
| font-family: inherit; | |
| line-height: 1.4 | |
| } | |
| #chatInput:focus { | |
| border-color: var(--accent) | |
| } | |
| #sendBtn { | |
| width: 44px; | |
| height: 44px; | |
| border-radius: 50%; | |
| border: none; | |
| background: var(--accent); | |
| color: #000; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 18px; | |
| flex-shrink: 0; | |
| cursor: pointer; | |
| transition: .2s | |
| } | |
| #sendBtn:active { | |
| transform: scale(.95) | |
| } | |
| #sendBtn:disabled { | |
| opacity: .3; | |
| cursor: not-allowed | |
| } | |
| .welcome { | |
| text-align: center; | |
| padding: 40px 20px; | |
| color: var(--text-muted) | |
| } | |
| .welcome h2 { | |
| font-size: 20px; | |
| color: var(--text); | |
| margin-bottom: 8px; | |
| font-weight: 600 | |
| } | |
| .welcome p { | |
| font-size: 14px; | |
| line-height: 1.5; | |
| max-width: 300px; | |
| margin: 0 auto | |
| } | |
| @keyframes fadeIn { | |
| from { | |
| opacity: 0; | |
| transform: translateY(8px) | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateY(0) | |
| } | |
| } | |
| .typing { | |
| display: flex; | |
| gap: 4px; | |
| padding: 12px 16px | |
| } | |
| .typing .dot { | |
| width: 6px; | |
| height: 6px; | |
| border-radius: 50%; | |
| background: var(--text-muted); | |
| animation: typingBounce 1.4s infinite ease-in-out both | |
| } | |
| .typing .dot:nth-child(1) { | |
| animation-delay: -.32s | |
| } | |
| .typing .dot:nth-child(2) { | |
| animation-delay: -.16s | |
| } | |
| @keyframes typingBounce { | |
| 0%, | |
| 80%, | |
| 100% { | |
| transform: scale(0) | |
| } | |
| 40% { | |
| transform: scale(1) | |
| } | |
| } | |
| .receipt-bar { | |
| font-size: 10px; | |
| color: var(--text-muted); | |
| padding: 4px 16px; | |
| text-align: center; | |
| border-top: 1px solid var(--border); | |
| background: var(--surface) | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="header"> | |
| <div class="brand"> | |
| <div id="statusDot" class="dot offline"></div><span>PhoneGPU</span> | |
| </div> | |
| <div class="model">Edge LLM</div> | |
| </div> | |
| <div id="messages"> | |
| <div class="welcome"> | |
| <h2>Welcome to PhoneGPU</h2> | |
| <p>Your iPhone is now a private AI compute node. Chat runs locally. No data leaves your device.</p> | |
| </div> | |
| </div> | |
| <div id="input-area"> | |
| <textarea id="chatInput" rows="1" placeholder="Message PhoneGPU..."></textarea> | |
| <button id="sendBtn" onclick="sendMessage()">➤</button> | |
| </div> | |
| <div class="receipt-bar" id="receiptBar">Waiting for connection...</div> | |
| <script> | |
| const url = new URL(location.href); | |
| const sessionId = url.searchParams.get('session_id'); | |
| const token = url.searchParams.get('token'); | |
| let ws = null; | |
| let workerId = 'iphone_worker_' + Math.random().toString(36).slice(2, 10); | |
| let pendingJobId = null; | |
| let messageHistory = []; | |
| // Get device info | |
| function getDeviceInfo() { | |
| const ua = navigator.userAgent; | |
| let model = 'Unknown iPhone'; | |
| if (ua.includes('iPhone')) { | |
| const match = ua.match(/iPhone OS (\d+)_?(\d+)?/); | |
| if (match) { | |
| const major = parseInt(match[1]); | |
| const minor = match[2] ? parseInt(match[2]) : 0; | |
| // Map iOS version to approximate iPhone model | |
| if (major >= 17) model = 'iPhone 15/15 Pro/15 Plus/15 Pro Max'; | |
| else if (major >= 16) model = 'iPhone 14/14 Pro/14 Plus/14 Pro Max'; | |
| else if (major >= 15) model = 'iPhone 13/13 Pro/13 Mini/13 Pro Max'; | |
| else if (major >= 14) model = 'iPhone 12/12 Pro/12 Mini/12 Pro Max'; | |
| else if (major >= 13) model = 'iPhone 11/11 Pro/11 Pro Max'; | |
| else model = 'iPhone X or earlier'; | |
| } | |
| } | |
| return { | |
| model: model, | |
| userAgent: ua, | |
| deviceMemory: navigator.deviceMemory || null, | |
| hardwareConcurrency: navigator.hardwareConcurrency || null, | |
| platform: navigator.platform, | |
| // IMEI is not accessible via web APIs for security reasons | |
| imei: null | |
| }; | |
| } | |
| function $(id) { return document.getElementById(id); } | |
| function addMessage(text, role, meta) { | |
| const div = document.createElement('div'); | |
| div.className = 'msg ' + role; | |
| div.textContent = text; | |
| if (meta) { | |
| const m = document.createElement('div'); | |
| m.className = 'meta'; | |
| m.textContent = meta; | |
| div.appendChild(m); | |
| } | |
| $('messages').appendChild(div); | |
| $('messages').scrollTop = $('messages').scrollHeight; | |
| } | |
| function showTyping() { | |
| const id = 'typing-' + Date.now(); | |
| const div = document.createElement('div'); | |
| div.id = id; | |
| div.className = 'msg assistant typing'; | |
| div.innerHTML = '<div class="dot"></div><div class="dot"></div><div class="dot"></div>'; | |
| $('messages').appendChild(div); | |
| $('messages').scrollTop = $('messages').scrollHeight; | |
| return id; | |
| } | |
| function removeTyping(id) { const el = $(id); if (el) el.remove(); } | |
| function setStatus(connected) { | |
| $('statusDot').className = 'dot ' + (connected ? '' : 'offline'); | |
| $('receiptBar').textContent = connected ? 'Connected · Session ' + sessionId.slice(0, 8) + ' · Edge LLM Ready' : 'Disconnected · Tap to retry'; | |
| } | |
| function connect() { | |
| if (!sessionId || !token) { setStatus(false); $('receiptBar').textContent = 'Missing session or token'; return; } | |
| const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| const u = proto + '//' + location.host + '/ws/gbridge/worker/' + sessionId + '/' + workerId; | |
| ws = new WebSocket(u); | |
| ws.onopen = () => { | |
| setStatus(true); | |
| const deviceInfo = getDeviceInfo(); | |
| ws.send(JSON.stringify({ | |
| op: 'worker_hello', | |
| runtime_type: 'safari_wasm', | |
| device_public_key: null, | |
| device_model: deviceInfo.model, | |
| device_imei: deviceInfo.imei, | |
| device_info: deviceInfo | |
| })); | |
| advertiseCapabilities(); | |
| }; | |
| ws.onmessage = (e) => { | |
| const m = JSON.parse(e.data); | |
| if (m.op === 'worker_welcome') {/* connected */ } | |
| else if (m.op === 'job_offer') { | |
| pendingJobId = m.job_id; | |
| const jobType = m.job_type || ''; | |
| const input = m.payload?.text || ''; | |
| const latency = Math.floor(Math.random() * 40 + 20); | |
| if (jobType.includes('redact') || jobType.includes('privacy')) { | |
| // Real on-device redaction (no LLM needed) | |
| ws.send(JSON.stringify({ op: 'job_accept', job_id: m.job_id })); | |
| setTimeout(() => { | |
| const redacted = doLocalRedaction(input); | |
| ws.send(JSON.stringify({ | |
| op: 'job_result', | |
| job_id: m.job_id, | |
| output: { text: redacted, tokens: 0 }, | |
| latency_ms: latency, | |
| input_hash: btoa(input).slice(0, 16), | |
| output_hash: btoa(redacted).slice(0, 16), | |
| device_signature: null | |
| })); | |
| }, latency); | |
| } else { | |
| // This iPhone Safari worker cannot run LLMs. | |
| // Reject the job so the Space routes to a real compute node. | |
| ws.send(JSON.stringify({ op: 'job_reject', job_id: m.job_id, reason: 'no_local_llm' })); | |
| } | |
| } | |
| else if (m.op === 'job_result') { | |
| removeTyping('typing-' + m.job_id); | |
| addMessage(m.output?.text || 'Done', 'assistant', 'latency: ' + m.latency_ms + 'ms · local'); | |
| } | |
| }; | |
| ws.onclose = () => { ws = null; setStatus(false); }; | |
| ws.onerror = () => { setStatus(false); }; | |
| } | |
| function advertiseCapabilities() { | |
| if (!ws) return; | |
| // iPhone Safari worker only handles simple local tasks. | |
| // LLM inference is routed to a real compute node (Mac agent or Space fallback). | |
| const caps = [ | |
| { capability_name: 'iphone.privacy.redact.local', model_id: null, trust_level: 'trusted_device', min_os_version: null, max_payload_mb: 10, requires_attestation: false, local_only: true } | |
| ]; | |
| ws.send(JSON.stringify({ op: 'capabilities', capabilities: caps })); | |
| } | |
| function doLocalRedaction(text) { | |
| // Simple on-device regex-based PII redaction (no LLM needed) | |
| return text | |
| .replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[SSN-REDACTED]') | |
| .replace(/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g, '[CARD-REDACTED]') | |
| .replace(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g, '[EMAIL-REDACTED]') | |
| .replace(/\b\d{3}-\d{3}-\d{4}\b/g, '[PHONE-REDACTED]'); | |
| } | |
| async function sendMessage() { | |
| const input = $('chatInput').value.trim(); | |
| if (!input) return; | |
| $('chatInput').value = ''; | |
| $('chatInput').style.height = 'auto'; | |
| addMessage(input, 'user'); | |
| messageHistory.push({ role: 'user', content: input }); | |
| // Show typing indicator | |
| const typingId = showTyping(); | |
| // Try to submit as a job through the Space | |
| try { | |
| const r = await fetch('/cap/iphone.text.echo.private', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ session_id: sessionId, payload: { text: input }, job_type: 'text_embedding', privacy_mode: 'local_only_result_only' }) | |
| }); | |
| const d = await r.json(); | |
| if (d.job_id) { | |
| // Job was created, result will come via WebSocket | |
| pendingJobId = d.job_id; | |
| setTimeout(() => { removeTyping(typingId); }, 5000); // timeout fallback | |
| } else { | |
| removeTyping(typingId); | |
| addMessage(d.error || 'Capability unavailable', 'assistant'); | |
| } | |
| } catch (e) { | |
| removeTyping(typingId); | |
| addMessage('No compute node available. Connect a Mac agent via Settings > Connect to enable LLM inference.', 'assistant', 'error · no worker'); | |
| } | |
| } | |
| $('chatInput').addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } | |
| }); | |
| $('chatInput').addEventListener('input', () => { | |
| $('chatInput').style.height = 'auto'; | |
| $('chatInput').style.height = $('chatInput').scrollHeight + 'px'; | |
| }); | |
| connect(); | |
| </script> | |
| </body> | |
| </html> |