voxa-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- server/__init__.py +0 -0
- server/apns.py +89 -0
- server/app.py +589 -0
- server/appattest.py +310 -0
- server/appstore.py +141 -0
- server/attested_store.py +60 -0
- server/auth.py +70 -0
- server/ax_controller.py +202 -0
- server/billing.py +177 -0
- server/call_manager.py +91 -0
- server/certs/AppleRootCA-G3.pem +15 -0
- server/certs/Apple_App_Attestation_Root_CA.pem +14 -0
- server/claude_controller.py +156 -0
- server/cli.py +365 -0
- server/cloud_app.py +345 -0
- server/config.py +56 -0
- server/device_registry.py +52 -0
- server/gemini_operator.py +677 -0
- server/hooks.py +202 -0
- server/orchestrator.py +315 -0
- server/push_routes.py +50 -0
- server/ratelimit.py +41 -0
- server/relay.py +157 -0
- server/relay_client.py +89 -0
- server/remote_operator.py +128 -0
- server/session_hub.py +33 -0
- server/terminal_watcher.py +241 -0
- server/terminals.py +510 -0
- server/tmux_controller.py +580 -0
- server/transcript_monitor.py +134 -0
- server/transcripts.py +143 -0
- server/users.py +90 -0
- server/voxa_cloud.py +132 -0
- server/waitlist.py +130 -0
- static/app.js +388 -0
- static/favicon.svg +1 -0
- static/index.html +253 -0
- static/pcm-worklet.js +69 -0
- static/pro.html +29 -0
- static/pro2.html +33 -0
- static/voxa-mark-white.svg +1 -0
- voxa_code-0.1.0.dist-info/METADATA +227 -0
- voxa_code-0.1.0.dist-info/RECORD +47 -0
- voxa_code-0.1.0.dist-info/WHEEL +5 -0
- voxa_code-0.1.0.dist-info/entry_points.txt +2 -0
- voxa_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- voxa_code-0.1.0.dist-info/top_level.txt +2 -0
static/app.js
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* app.js - Loop phone web client (ES module)
|
|
3
|
+
*
|
|
4
|
+
* Mic capture pipeline:
|
|
5
|
+
* getUserMedia -> MediaStreamSource -> AudioWorkletNode (pcm-worklet.js)
|
|
6
|
+
* -> postMessage (Int16 ArrayBuffer, 16 kHz) -> WebSocket binary frame
|
|
7
|
+
*
|
|
8
|
+
* Playback pipeline:
|
|
9
|
+
* WebSocket binary frame (Int16 ArrayBuffer, 24 kHz)
|
|
10
|
+
* -> Float32 conversion -> AudioBufferSourceNode scheduled on 24 kHz AudioContext
|
|
11
|
+
* (jitter buffer via nextStartTime cursor)
|
|
12
|
+
*
|
|
13
|
+
* JSON control messages: {"type":"status","status":"..."} and friends update the UI.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const PLAYBACK_SAMPLE_RATE = 24000;
|
|
17
|
+
// Minimum ahead-of-time scheduling cushion (seconds). Keeps audio gapless.
|
|
18
|
+
const SCHEDULE_AHEAD = 0.06;
|
|
19
|
+
|
|
20
|
+
// --- State ---
|
|
21
|
+
let ws = null;
|
|
22
|
+
let micContext = null; // AudioContext for capture (browser's native rate)
|
|
23
|
+
let playCtx = null; // AudioContext fixed at 24 kHz for playback
|
|
24
|
+
let workletNode = null;
|
|
25
|
+
let sourceNode = null;
|
|
26
|
+
let stream = null;
|
|
27
|
+
let muted = false;
|
|
28
|
+
let nextStartTime = 0; // jitter-buffer scheduling cursor (in playCtx.currentTime)
|
|
29
|
+
|
|
30
|
+
// --- DOM refs (populated in init()) ---
|
|
31
|
+
let tokenInput, folderInput, terminalSelect, connectBtn, muteBtn, stopBtn, terminalsBtn, terminalsEl, statusDot, statusText, infoText, transcriptEl;
|
|
32
|
+
|
|
33
|
+
// Transcript state: coalesce consecutive same-role caption chunks into one line.
|
|
34
|
+
let lastRole = null;
|
|
35
|
+
let lastSaidEl = null;
|
|
36
|
+
|
|
37
|
+
// ---- Token helpers ----
|
|
38
|
+
|
|
39
|
+
function getTokenFromUrl() {
|
|
40
|
+
const params = new URLSearchParams(window.location.search);
|
|
41
|
+
return params.get('token') || '';
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function buildWsUrl(token) {
|
|
45
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
46
|
+
return `${proto}//${location.host}/ws?token=${encodeURIComponent(token)}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function wsOpen() {
|
|
50
|
+
return ws && ws.readyState === WebSocket.OPEN;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sendTerminal() {
|
|
54
|
+
if (wsOpen()) {
|
|
55
|
+
ws.send(JSON.stringify({ type: 'set_terminal', app: terminalSelect.value }));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function sendFolder() {
|
|
60
|
+
const path = folderInput.value.trim();
|
|
61
|
+
if (path && wsOpen()) {
|
|
62
|
+
ws.send(JSON.stringify({ type: 'set_dir', path }));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---- UI helpers ----
|
|
67
|
+
|
|
68
|
+
function setStatus(state, text) {
|
|
69
|
+
// state: 'disconnected' | 'connecting' | 'connected' | 'error'
|
|
70
|
+
statusDot.className = 'dot dot-' + state;
|
|
71
|
+
statusText.textContent = text;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function setInfo(text) {
|
|
75
|
+
infoText.textContent = text || '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function updateConnectUI(connected) {
|
|
79
|
+
connectBtn.textContent = connected ? 'Disconnect' : 'Connect';
|
|
80
|
+
muteBtn.disabled = !connected;
|
|
81
|
+
stopBtn.disabled = !connected;
|
|
82
|
+
terminalsBtn.disabled = !connected;
|
|
83
|
+
tokenInput.disabled = connected;
|
|
84
|
+
if (!connected && terminalsEl) terminalsEl.innerHTML = '';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function sendStop() {
|
|
88
|
+
if (wsOpen()) ws.send(JSON.stringify({ type: 'stop' }));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function requestTerminals() {
|
|
92
|
+
if (wsOpen()) ws.send(JSON.stringify({ type: 'list_terminals' }));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renderTerminals(items) {
|
|
96
|
+
terminalsEl.innerHTML = '';
|
|
97
|
+
if (!items || !items.length) {
|
|
98
|
+
const p = document.createElement('div');
|
|
99
|
+
p.className = 'term-item uncontrollable';
|
|
100
|
+
p.textContent = 'No open Claude terminals found.';
|
|
101
|
+
terminalsEl.appendChild(p);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
for (const it of items) {
|
|
105
|
+
const b = document.createElement('button');
|
|
106
|
+
b.className = 'term-item' + (it.controllable ? '' : ' uncontrollable');
|
|
107
|
+
b.disabled = !it.controllable;
|
|
108
|
+
// Build with textContent, never innerHTML: app/label/cwd/id come from the
|
|
109
|
+
// laptop's terminal enumeration (window titles, directory names) and could
|
|
110
|
+
// contain HTML, which innerHTML would execute (DOM XSS in the phone client).
|
|
111
|
+
const appSpan = document.createElement('span');
|
|
112
|
+
appSpan.className = 'app';
|
|
113
|
+
appSpan.textContent = it.app || '';
|
|
114
|
+
b.appendChild(appSpan);
|
|
115
|
+
b.appendChild(document.createElement('br'));
|
|
116
|
+
b.appendChild(document.createTextNode(
|
|
117
|
+
(it.label || it.cwd || it.id || '') + (it.controllable ? '' : ' (can’t control)')
|
|
118
|
+
));
|
|
119
|
+
if (it.controllable) {
|
|
120
|
+
b.addEventListener('click', () => {
|
|
121
|
+
if (wsOpen()) ws.send(JSON.stringify({ type: 'attach_terminal', id: it.id }));
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
terminalsEl.appendChild(b);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ---- WebSocket ----
|
|
129
|
+
|
|
130
|
+
function openWebSocket(token) {
|
|
131
|
+
const url = buildWsUrl(token);
|
|
132
|
+
setStatus('connecting', 'Connecting...');
|
|
133
|
+
ws = new WebSocket(url);
|
|
134
|
+
ws.binaryType = 'arraybuffer';
|
|
135
|
+
|
|
136
|
+
ws.addEventListener('open', () => {
|
|
137
|
+
setStatus('connected', 'Connected');
|
|
138
|
+
updateConnectUI(true);
|
|
139
|
+
sendTerminal(); // choose terminal app before any session starts
|
|
140
|
+
sendFolder(); // optional: pre-set the project folder (else say it by voice)
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
ws.addEventListener('close', (ev) => {
|
|
144
|
+
const reason = ev.reason ? `: ${ev.reason}` : '';
|
|
145
|
+
setStatus('disconnected', `Disconnected (${ev.code}${reason})`);
|
|
146
|
+
teardown();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
ws.addEventListener('error', () => {
|
|
150
|
+
setStatus('error', 'Connection error');
|
|
151
|
+
teardown();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
ws.addEventListener('message', (ev) => {
|
|
155
|
+
if (ev.data instanceof ArrayBuffer) {
|
|
156
|
+
schedulePlayback(ev.data);
|
|
157
|
+
} else if (typeof ev.data === 'string') {
|
|
158
|
+
handleControlMessage(ev.data);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- Control messages ----
|
|
164
|
+
|
|
165
|
+
function handleControlMessage(raw) {
|
|
166
|
+
let msg;
|
|
167
|
+
try { msg = JSON.parse(raw); } catch { return; }
|
|
168
|
+
|
|
169
|
+
if (msg.type === 'transcript') {
|
|
170
|
+
appendTranscript(msg.role, msg.text);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (msg.type === 'terminals') {
|
|
174
|
+
renderTerminals(msg.items);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (msg.type === 'status') {
|
|
178
|
+
// e.g. {"type":"status","status":"finished"}
|
|
179
|
+
setStatus('connected', msg.status || 'Connected');
|
|
180
|
+
}
|
|
181
|
+
if (msg.working_dir) {
|
|
182
|
+
setInfo(`Dir: ${msg.working_dir}${msg.mode ? ' Mode: ' + msg.mode : ''}`);
|
|
183
|
+
}
|
|
184
|
+
if (msg.mode && !msg.working_dir) {
|
|
185
|
+
setInfo(`Mode: ${msg.mode}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---- Live transcript / captions ----
|
|
190
|
+
|
|
191
|
+
function appendTranscript(role, text) {
|
|
192
|
+
if (!text) return;
|
|
193
|
+
role = role === 'user' ? 'user' : 'agent';
|
|
194
|
+
|
|
195
|
+
if (role !== lastRole || !lastSaidEl) {
|
|
196
|
+
const line = document.createElement('div');
|
|
197
|
+
line.className = 'line ' + role;
|
|
198
|
+
const who = document.createElement('span');
|
|
199
|
+
who.className = 'who';
|
|
200
|
+
who.textContent = role === 'user' ? 'You' : 'Voxa';
|
|
201
|
+
const said = document.createElement('span');
|
|
202
|
+
said.className = 'said';
|
|
203
|
+
line.appendChild(who);
|
|
204
|
+
line.appendChild(said);
|
|
205
|
+
transcriptEl.appendChild(line);
|
|
206
|
+
lastSaidEl = said;
|
|
207
|
+
lastRole = role;
|
|
208
|
+
}
|
|
209
|
+
lastSaidEl.textContent += text;
|
|
210
|
+
transcriptEl.scrollTop = transcriptEl.scrollHeight;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function clearTranscript() {
|
|
214
|
+
if (transcriptEl) transcriptEl.innerHTML = '';
|
|
215
|
+
lastRole = null;
|
|
216
|
+
lastSaidEl = null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---- Mic capture pipeline ----
|
|
220
|
+
|
|
221
|
+
async function startCapture() {
|
|
222
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
|
223
|
+
|
|
224
|
+
micContext = new AudioContext();
|
|
225
|
+
await micContext.audioWorklet.addModule('/static/pcm-worklet.js');
|
|
226
|
+
|
|
227
|
+
sourceNode = micContext.createMediaStreamSource(stream);
|
|
228
|
+
workletNode = new AudioWorkletNode(micContext, 'pcm-processor');
|
|
229
|
+
|
|
230
|
+
workletNode.port.onmessage = (ev) => {
|
|
231
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
232
|
+
// When muted, send silence (zero-filled, same size) instead of stopping the
|
|
233
|
+
// stream, so the realtime channel stays alive and Voxa keeps talking.
|
|
234
|
+
ws.send(muted ? new ArrayBuffer(ev.data.byteLength) : ev.data);
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
sourceNode.connect(workletNode);
|
|
239
|
+
// Do NOT connect workletNode to micContext.destination (no echo)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function stopCapture() {
|
|
243
|
+
if (workletNode) { workletNode.disconnect(); workletNode = null; }
|
|
244
|
+
if (sourceNode) { sourceNode.disconnect(); sourceNode = null; }
|
|
245
|
+
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
|
|
246
|
+
if (micContext) { micContext.close(); micContext = null; }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---- Playback (24 kHz jitter buffer) ----
|
|
250
|
+
|
|
251
|
+
function ensurePlayContext() {
|
|
252
|
+
if (!playCtx || playCtx.state === 'closed') {
|
|
253
|
+
playCtx = new AudioContext({ sampleRate: PLAYBACK_SAMPLE_RATE });
|
|
254
|
+
nextStartTime = 0;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function schedulePlayback(arrayBuffer) {
|
|
259
|
+
ensurePlayContext();
|
|
260
|
+
|
|
261
|
+
// Convert Int16 little-endian -> Float32
|
|
262
|
+
const int16 = new Int16Array(arrayBuffer);
|
|
263
|
+
const float32 = new Float32Array(int16.length);
|
|
264
|
+
for (let i = 0; i < int16.length; i++) {
|
|
265
|
+
float32[i] = int16[i] / 32768;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const audioBuffer = playCtx.createBuffer(1, float32.length, PLAYBACK_SAMPLE_RATE);
|
|
269
|
+
audioBuffer.copyToChannel(float32, 0);
|
|
270
|
+
|
|
271
|
+
const src = playCtx.createBufferSource();
|
|
272
|
+
src.buffer = audioBuffer;
|
|
273
|
+
src.connect(playCtx.destination);
|
|
274
|
+
|
|
275
|
+
// Jitter buffer: schedule at least SCHEDULE_AHEAD seconds ahead of playback head,
|
|
276
|
+
// but never in the past. Chain successive buffers end-to-end.
|
|
277
|
+
const now = playCtx.currentTime;
|
|
278
|
+
const earliest = now + SCHEDULE_AHEAD;
|
|
279
|
+
if (nextStartTime < earliest) nextStartTime = earliest;
|
|
280
|
+
|
|
281
|
+
src.start(nextStartTime);
|
|
282
|
+
nextStartTime += audioBuffer.duration;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function stopPlayback() {
|
|
286
|
+
if (playCtx) {
|
|
287
|
+
playCtx.close();
|
|
288
|
+
playCtx = null;
|
|
289
|
+
nextStartTime = 0;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ---- Connect / Disconnect ----
|
|
294
|
+
|
|
295
|
+
async function connect() {
|
|
296
|
+
const token = tokenInput.value.trim();
|
|
297
|
+
if (!token) {
|
|
298
|
+
setStatus('error', 'Enter a token first');
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
connectBtn.disabled = true;
|
|
303
|
+
clearTranscript();
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
// Start mic capture BEFORE opening WS so the two are ready together
|
|
307
|
+
await startCapture();
|
|
308
|
+
ensurePlayContext();
|
|
309
|
+
openWebSocket(token);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
setStatus('error', `Failed: ${err.message}`);
|
|
312
|
+
stopCapture();
|
|
313
|
+
stopPlayback();
|
|
314
|
+
updateConnectUI(false);
|
|
315
|
+
} finally {
|
|
316
|
+
connectBtn.disabled = false;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function disconnect() {
|
|
321
|
+
if (ws) {
|
|
322
|
+
ws.close(1000, 'User disconnected');
|
|
323
|
+
ws = null;
|
|
324
|
+
}
|
|
325
|
+
teardown();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function teardown() {
|
|
329
|
+
stopCapture();
|
|
330
|
+
stopPlayback();
|
|
331
|
+
updateConnectUI(false);
|
|
332
|
+
muted = false;
|
|
333
|
+
muteBtn.textContent = 'Mute';
|
|
334
|
+
setInfo('');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---- Mute toggle ----
|
|
338
|
+
|
|
339
|
+
function toggleMute() {
|
|
340
|
+
muted = !muted;
|
|
341
|
+
muteBtn.textContent = muted ? 'Unmute' : 'Mute';
|
|
342
|
+
muteBtn.classList.toggle('muted', muted);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---- Init ----
|
|
346
|
+
|
|
347
|
+
function init() {
|
|
348
|
+
tokenInput = document.getElementById('token');
|
|
349
|
+
folderInput = document.getElementById('folder');
|
|
350
|
+
terminalSelect = document.getElementById('terminal');
|
|
351
|
+
connectBtn = document.getElementById('connect-btn');
|
|
352
|
+
muteBtn = document.getElementById('mute-btn');
|
|
353
|
+
stopBtn = document.getElementById('stop-btn');
|
|
354
|
+
terminalsBtn = document.getElementById('terminals-btn');
|
|
355
|
+
terminalsEl = document.getElementById('terminals');
|
|
356
|
+
statusDot = document.getElementById('status-dot');
|
|
357
|
+
statusText = document.getElementById('status-text');
|
|
358
|
+
infoText = document.getElementById('info-text');
|
|
359
|
+
transcriptEl = document.getElementById('transcript');
|
|
360
|
+
|
|
361
|
+
// Prefill token + folder from URL (?token=... &dir=...)
|
|
362
|
+
const params = new URLSearchParams(window.location.search);
|
|
363
|
+
if (params.get('token')) tokenInput.value = params.get('token');
|
|
364
|
+
if (params.get('dir')) folderInput.value = params.get('dir');
|
|
365
|
+
|
|
366
|
+
if (params.get('terminal')) terminalSelect.value = params.get('terminal');
|
|
367
|
+
|
|
368
|
+
// Push changes to the server mid-call.
|
|
369
|
+
folderInput.addEventListener('change', sendFolder);
|
|
370
|
+
terminalSelect.addEventListener('change', sendTerminal);
|
|
371
|
+
|
|
372
|
+
connectBtn.addEventListener('click', () => {
|
|
373
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
374
|
+
disconnect();
|
|
375
|
+
} else {
|
|
376
|
+
connect();
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
muteBtn.addEventListener('click', toggleMute);
|
|
381
|
+
stopBtn.addEventListener('click', sendStop);
|
|
382
|
+
terminalsBtn.addEventListener('click', requestTerminals);
|
|
383
|
+
|
|
384
|
+
setStatus('disconnected', 'Disconnected');
|
|
385
|
+
updateConnectUI(false);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
document.addEventListener('DOMContentLoaded', init);
|
static/favicon.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg width="512" height="512" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg"><rect width="512" height="512" rx="116" fill="#0E0E10"/><g fill="#FFFFFF"><rect x="250.0" y="107.8" width="12" height="50.2" rx="6.0" transform="rotate(0.00 256 256)"/><rect x="250.0" y="86.5" width="12" height="71.5" rx="6.0" transform="rotate(12.00 256 256)"/><rect x="250.0" y="76.4" width="12" height="81.6" rx="6.0" transform="rotate(24.00 256 256)"/><rect x="250.0" y="80.1" width="12" height="77.9" rx="6.0" transform="rotate(36.00 256 256)"/><rect x="250.0" y="96.6" width="12" height="61.4" rx="6.0" transform="rotate(48.00 256 256)"/><rect x="250.0" y="121.6" width="12" height="36.4" rx="6.0" transform="rotate(60.00 256 256)"/><rect x="250.0" y="107.8" width="12" height="50.2" rx="6.0" transform="rotate(72.00 256 256)"/><rect x="250.0" y="86.5" width="12" height="71.5" rx="6.0" transform="rotate(84.00 256 256)"/><rect x="250.0" y="76.4" width="12" height="81.6" rx="6.0" transform="rotate(96.00 256 256)"/><rect x="250.0" y="80.1" width="12" height="77.9" rx="6.0" transform="rotate(108.00 256 256)"/><rect x="250.0" y="96.6" width="12" height="61.4" rx="6.0" transform="rotate(120.00 256 256)"/><rect x="250.0" y="121.6" width="12" height="36.4" rx="6.0" transform="rotate(132.00 256 256)"/><rect x="250.0" y="107.8" width="12" height="50.2" rx="6.0" transform="rotate(144.00 256 256)"/><rect x="250.0" y="86.5" width="12" height="71.5" rx="6.0" transform="rotate(156.00 256 256)"/><rect x="250.0" y="76.4" width="12" height="81.6" rx="6.0" transform="rotate(168.00 256 256)"/><rect x="250.0" y="80.1" width="12" height="77.9" rx="6.0" transform="rotate(180.00 256 256)"/><rect x="250.0" y="96.6" width="12" height="61.4" rx="6.0" transform="rotate(192.00 256 256)"/><rect x="250.0" y="121.6" width="12" height="36.4" rx="6.0" transform="rotate(204.00 256 256)"/><rect x="250.0" y="107.8" width="12" height="50.2" rx="6.0" transform="rotate(216.00 256 256)"/><rect x="250.0" y="86.5" width="12" height="71.5" rx="6.0" transform="rotate(228.00 256 256)"/><rect x="250.0" y="76.4" width="12" height="81.6" rx="6.0" transform="rotate(240.00 256 256)"/><rect x="250.0" y="80.1" width="12" height="77.9" rx="6.0" transform="rotate(252.00 256 256)"/><rect x="250.0" y="96.6" width="12" height="61.4" rx="6.0" transform="rotate(264.00 256 256)"/><rect x="250.0" y="121.6" width="12" height="36.4" rx="6.0" transform="rotate(276.00 256 256)"/><rect x="250.0" y="107.8" width="12" height="50.2" rx="6.0" transform="rotate(288.00 256 256)"/><rect x="250.0" y="86.5" width="12" height="71.5" rx="6.0" transform="rotate(300.00 256 256)"/><rect x="250.0" y="76.4" width="12" height="81.6" rx="6.0" transform="rotate(312.00 256 256)"/><rect x="250.0" y="80.1" width="12" height="77.9" rx="6.0" transform="rotate(324.00 256 256)"/><rect x="250.0" y="96.6" width="12" height="61.4" rx="6.0" transform="rotate(336.00 256 256)"/><rect x="250.0" y="121.6" width="12" height="36.4" rx="6.0" transform="rotate(348.00 256 256)"/><circle cx="256" cy="256" r="22"/></g></svg>
|
static/index.html
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Voxa</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
|
8
|
+
<style>
|
|
9
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
10
|
+
|
|
11
|
+
body {
|
|
12
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
13
|
+
background: #0f0f0f;
|
|
14
|
+
color: #e8e8e8;
|
|
15
|
+
min-height: 100dvh;
|
|
16
|
+
display: flex;
|
|
17
|
+
flex-direction: column;
|
|
18
|
+
align-items: center;
|
|
19
|
+
justify-content: center;
|
|
20
|
+
padding: 1.5rem;
|
|
21
|
+
gap: 1.5rem;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
h1 {
|
|
25
|
+
font-size: 2rem;
|
|
26
|
+
font-weight: 700;
|
|
27
|
+
letter-spacing: 0.05em;
|
|
28
|
+
color: #fff;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* Status row */
|
|
32
|
+
.status-row {
|
|
33
|
+
display: flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
gap: 0.6rem;
|
|
36
|
+
font-size: 0.95rem;
|
|
37
|
+
color: #aaa;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.dot {
|
|
41
|
+
width: 10px;
|
|
42
|
+
height: 10px;
|
|
43
|
+
border-radius: 50%;
|
|
44
|
+
flex-shrink: 0;
|
|
45
|
+
}
|
|
46
|
+
.dot-disconnected { background: #555; }
|
|
47
|
+
.dot-connecting { background: #f5a623; animation: pulse 1s infinite; }
|
|
48
|
+
.dot-connected { background: #4caf50; }
|
|
49
|
+
.dot-error { background: #f44336; }
|
|
50
|
+
|
|
51
|
+
@keyframes pulse {
|
|
52
|
+
0%, 100% { opacity: 1; }
|
|
53
|
+
50% { opacity: 0.3; }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Token input */
|
|
57
|
+
.token-wrap {
|
|
58
|
+
width: 100%;
|
|
59
|
+
max-width: 360px;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.token-wrap label {
|
|
63
|
+
display: block;
|
|
64
|
+
font-size: 0.78rem;
|
|
65
|
+
color: #888;
|
|
66
|
+
margin-bottom: 0.4rem;
|
|
67
|
+
text-transform: uppercase;
|
|
68
|
+
letter-spacing: 0.08em;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
#token {
|
|
72
|
+
width: 100%;
|
|
73
|
+
padding: 0.65rem 0.9rem;
|
|
74
|
+
border: 1px solid #333;
|
|
75
|
+
border-radius: 8px;
|
|
76
|
+
background: #1a1a1a;
|
|
77
|
+
color: #e8e8e8;
|
|
78
|
+
font-size: 1rem;
|
|
79
|
+
font-family: monospace;
|
|
80
|
+
outline: none;
|
|
81
|
+
transition: border-color 0.2s;
|
|
82
|
+
}
|
|
83
|
+
#token:focus { border-color: #555; }
|
|
84
|
+
#token:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
85
|
+
|
|
86
|
+
#terminal {
|
|
87
|
+
width: 100%;
|
|
88
|
+
padding: 0.65rem 0.9rem;
|
|
89
|
+
border: 1px solid #333;
|
|
90
|
+
border-radius: 8px;
|
|
91
|
+
background: #1a1a1a;
|
|
92
|
+
color: #e8e8e8;
|
|
93
|
+
font-size: 1rem;
|
|
94
|
+
outline: none;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* Buttons */
|
|
98
|
+
.btn-row {
|
|
99
|
+
display: flex;
|
|
100
|
+
gap: 0.75rem;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
button {
|
|
104
|
+
padding: 0.75rem 1.75rem;
|
|
105
|
+
border: none;
|
|
106
|
+
border-radius: 10px;
|
|
107
|
+
font-size: 1rem;
|
|
108
|
+
font-weight: 600;
|
|
109
|
+
cursor: pointer;
|
|
110
|
+
transition: opacity 0.15s, background 0.15s;
|
|
111
|
+
}
|
|
112
|
+
button:disabled { opacity: 0.35; cursor: not-allowed; }
|
|
113
|
+
|
|
114
|
+
#connect-btn {
|
|
115
|
+
background: #3a7bd5;
|
|
116
|
+
color: #fff;
|
|
117
|
+
min-width: 130px;
|
|
118
|
+
}
|
|
119
|
+
#connect-btn:hover:not(:disabled) { background: #2f6ac5; }
|
|
120
|
+
|
|
121
|
+
#mute-btn {
|
|
122
|
+
background: #2a2a2a;
|
|
123
|
+
color: #ccc;
|
|
124
|
+
border: 1px solid #444;
|
|
125
|
+
}
|
|
126
|
+
#mute-btn:hover:not(:disabled) { background: #333; }
|
|
127
|
+
#mute-btn.muted {
|
|
128
|
+
background: #7b3a3a;
|
|
129
|
+
color: #ffcdd2;
|
|
130
|
+
border-color: #7b3a3a;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
#stop-btn {
|
|
134
|
+
background: #2a2a2a;
|
|
135
|
+
color: #ffb4b4;
|
|
136
|
+
border: 1px solid #5a3a3a;
|
|
137
|
+
}
|
|
138
|
+
#stop-btn:hover:not(:disabled) { background: #3a2a2a; }
|
|
139
|
+
|
|
140
|
+
#terminals-btn { background: #2a2a2a; color: #cfe3ff; border: 1px solid #3a4a5a; }
|
|
141
|
+
#terminals-btn:hover:not(:disabled) { background: #2f3a47; }
|
|
142
|
+
|
|
143
|
+
#terminals {
|
|
144
|
+
width: 100%;
|
|
145
|
+
max-width: 360px;
|
|
146
|
+
display: flex;
|
|
147
|
+
flex-direction: column;
|
|
148
|
+
gap: 0.4rem;
|
|
149
|
+
}
|
|
150
|
+
.term-item {
|
|
151
|
+
text-align: left;
|
|
152
|
+
padding: 0.6rem 0.8rem;
|
|
153
|
+
border-radius: 8px;
|
|
154
|
+
background: #161616;
|
|
155
|
+
border: 1px solid #2a2a2a;
|
|
156
|
+
color: #e8e8e8;
|
|
157
|
+
font-size: 0.9rem;
|
|
158
|
+
}
|
|
159
|
+
.term-item:hover:not(:disabled) { background: #1f2a36; border-color: #3a4a5a; }
|
|
160
|
+
.term-item .app { color: #6ab0ff; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.06em; }
|
|
161
|
+
.term-item.uncontrollable { opacity: 0.5; }
|
|
162
|
+
|
|
163
|
+
/* Info text (working dir / mode) */
|
|
164
|
+
#info-text {
|
|
165
|
+
font-size: 0.8rem;
|
|
166
|
+
color: #666;
|
|
167
|
+
min-height: 1.2em;
|
|
168
|
+
text-align: center;
|
|
169
|
+
max-width: 360px;
|
|
170
|
+
word-break: break-all;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/* Live transcript / captions */
|
|
174
|
+
#transcript {
|
|
175
|
+
width: 100%;
|
|
176
|
+
max-width: 360px;
|
|
177
|
+
flex: 1 1 auto;
|
|
178
|
+
min-height: 120px;
|
|
179
|
+
max-height: 45dvh;
|
|
180
|
+
overflow-y: auto;
|
|
181
|
+
background: #161616;
|
|
182
|
+
border: 1px solid #2a2a2a;
|
|
183
|
+
border-radius: 10px;
|
|
184
|
+
padding: 0.75rem 0.9rem;
|
|
185
|
+
font-size: 0.95rem;
|
|
186
|
+
line-height: 1.45;
|
|
187
|
+
display: flex;
|
|
188
|
+
flex-direction: column;
|
|
189
|
+
gap: 0.5rem;
|
|
190
|
+
}
|
|
191
|
+
#transcript:empty::before {
|
|
192
|
+
content: "Captions will appear here…";
|
|
193
|
+
color: #555;
|
|
194
|
+
font-size: 0.85rem;
|
|
195
|
+
}
|
|
196
|
+
.line { display: flex; flex-direction: column; gap: 0.1rem; }
|
|
197
|
+
.line .who {
|
|
198
|
+
font-size: 0.68rem;
|
|
199
|
+
text-transform: uppercase;
|
|
200
|
+
letter-spacing: 0.08em;
|
|
201
|
+
color: #777;
|
|
202
|
+
}
|
|
203
|
+
.line.agent .who { color: #6ab0ff; }
|
|
204
|
+
.line.user .who { color: #7ed492; }
|
|
205
|
+
.line .said { color: #e8e8e8; word-break: break-word; }
|
|
206
|
+
</style>
|
|
207
|
+
</head>
|
|
208
|
+
<body>
|
|
209
|
+
<div style="display:flex;align-items:center;gap:.6rem">
|
|
210
|
+
<img src="/static/voxa-mark-white.svg" alt="Voxa" style="width:40px;height:40px">
|
|
211
|
+
<h1>Voxa</h1>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<div class="status-row">
|
|
215
|
+
<span id="status-dot" class="dot dot-disconnected"></span>
|
|
216
|
+
<span id="status-text">Disconnected</span>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<div class="token-wrap">
|
|
220
|
+
<label for="token">Auth token</label>
|
|
221
|
+
<input id="token" type="password" placeholder="paste token here" autocomplete="off" spellcheck="false">
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<div class="token-wrap">
|
|
225
|
+
<label for="folder">Working folder (optional — or just say it out loud)</label>
|
|
226
|
+
<input id="folder" type="text" placeholder="/Users/you/path/to/project" autocomplete="off" spellcheck="false" autocapitalize="off">
|
|
227
|
+
</div>
|
|
228
|
+
|
|
229
|
+
<div class="token-wrap">
|
|
230
|
+
<label for="terminal">Terminal app</label>
|
|
231
|
+
<select id="terminal">
|
|
232
|
+
<option value="auto">Auto-detect</option>
|
|
233
|
+
<option value="iTerm">iTerm2</option>
|
|
234
|
+
<option value="Terminal">Terminal</option>
|
|
235
|
+
</select>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
<div class="btn-row">
|
|
239
|
+
<button id="connect-btn">Connect</button>
|
|
240
|
+
<button id="mute-btn" disabled>Mute</button>
|
|
241
|
+
<button id="stop-btn" disabled>Stop</button>
|
|
242
|
+
<button id="terminals-btn" disabled>Terminals</button>
|
|
243
|
+
</div>
|
|
244
|
+
|
|
245
|
+
<div id="terminals"></div>
|
|
246
|
+
|
|
247
|
+
<p id="info-text"></p>
|
|
248
|
+
|
|
249
|
+
<div id="transcript"></div>
|
|
250
|
+
|
|
251
|
+
<script src="/static/app.js" type="module"></script>
|
|
252
|
+
</body>
|
|
253
|
+
</html>
|