josephrw's picture
Upload folder using huggingface_hub
c4916b2 verified
Raw
History Blame Contribute Delete
16.1 kB
<!DOCTYPE html>
<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()">&#10148;</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>