ava-engine 0.0.0-stage → 0.1.0
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.
- package/.env.example +63 -0
- package/CLA.md +43 -0
- package/COMMERCIAL-LICENSE.md +43 -0
- package/LICENSE +73 -0
- package/README.md +238 -2
- package/THIRD_PARTY_NOTICES.md +38 -0
- package/TRADEMARKS.md +31 -0
- package/bin/ava-engine.mjs +12 -0
- package/lib/asr.mjs +143 -0
- package/lib/cli/config.mjs +95 -0
- package/lib/cli/main.mjs +152 -0
- package/lib/cli/setup.mjs +219 -0
- package/lib/fetch-models.mjs +104 -0
- package/lib/guard.mjs +92 -0
- package/lib/models.mjs +47 -0
- package/lib/openrouter.mjs +153 -0
- package/lib/ours.mjs +766 -0
- package/lib/platforms.mjs +201 -0
- package/lib/state.mjs +150 -0
- package/lib/stt.mjs +22 -0
- package/lib/tts.mjs +178 -0
- package/lib/workflow.mjs +18 -0
- package/package.json +72 -4
- package/public/app.js +1708 -0
- package/public/demo.js +420 -0
- package/public/favicon.svg +1 -0
- package/public/fonts/OFL.txt +96 -0
- package/public/fonts/inter-tight-var.woff2 +0 -0
- package/public/fonts/inter-var.woff2 +0 -0
- package/public/fonts/plex-mono-400.woff2 +0 -0
- package/public/fonts/plex-mono-500.woff2 +0 -0
- package/public/img/cta-1600.avif +0 -0
- package/public/img/cta-1600.webp +0 -0
- package/public/img/cta-2400.avif +0 -0
- package/public/img/cta-2400.webp +0 -0
- package/public/img/cta-960.avif +0 -0
- package/public/img/cta-960.webp +0 -0
- package/public/img/flows-1600.avif +0 -0
- package/public/img/flows-1600.webp +0 -0
- package/public/img/flows-2400.avif +0 -0
- package/public/img/flows-2400.webp +0 -0
- package/public/img/flows-960.avif +0 -0
- package/public/img/flows-960.webp +0 -0
- package/public/img/hero-1600.avif +0 -0
- package/public/img/hero-1600.webp +0 -0
- package/public/img/hero-2400.avif +0 -0
- package/public/img/hero-2400.webp +0 -0
- package/public/img/hero-960.avif +0 -0
- package/public/img/hero-960.webp +0 -0
- package/public/img/p-csm-480.avif +0 -0
- package/public/img/p-csm-480.webp +0 -0
- package/public/img/p-csm-800.avif +0 -0
- package/public/img/p-csm-800.webp +0 -0
- package/public/img/p-docs-480.avif +0 -0
- package/public/img/p-docs-480.webp +0 -0
- package/public/img/p-docs-800.avif +0 -0
- package/public/img/p-docs-800.webp +0 -0
- package/public/img/p-quiet-480.avif +0 -0
- package/public/img/p-quiet-480.webp +0 -0
- package/public/img/p-quiet-800.avif +0 -0
- package/public/img/p-quiet-800.webp +0 -0
- package/public/img/p-tickets-480.avif +0 -0
- package/public/img/p-tickets-480.webp +0 -0
- package/public/img/p-tickets-800.avif +0 -0
- package/public/img/p-tickets-800.webp +0 -0
- package/public/img/p-trial-480.avif +0 -0
- package/public/img/p-trial-480.webp +0 -0
- package/public/img/p-trial-800.avif +0 -0
- package/public/img/p-trial-800.webp +0 -0
- package/public/img/sees-1600.avif +0 -0
- package/public/img/sees-1600.webp +0 -0
- package/public/img/sees-2400.avif +0 -0
- package/public/img/sees-2400.webp +0 -0
- package/public/img/sees-960.avif +0 -0
- package/public/img/sees-960.webp +0 -0
- package/public/img/speaks-1600.avif +0 -0
- package/public/img/speaks-1600.webp +0 -0
- package/public/img/speaks-2400.avif +0 -0
- package/public/img/speaks-2400.webp +0 -0
- package/public/img/speaks-960.avif +0 -0
- package/public/img/speaks-960.webp +0 -0
- package/public/img/voice-1600.avif +0 -0
- package/public/img/voice-1600.webp +0 -0
- package/public/img/voice-2400.avif +0 -0
- package/public/img/voice-2400.webp +0 -0
- package/public/img/voice-960.avif +0 -0
- package/public/img/voice-960.webp +0 -0
- package/public/index.html +440 -0
- package/public/landing.html +975 -0
- package/public/pcm-worklet.js +11 -0
- package/public/perceptionWorker.js +103 -0
- package/public/rtc.js +60 -0
- package/public/specialist.html +92 -0
- package/public/specialist.js +132 -0
- package/public/style.css +836 -0
- package/public/vendor/lenis.min.js +3 -0
- package/scripts/fetch-models.sh +15 -0
- package/server.mjs +502 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Taps raw PCM off the (echo-cancelled) mic graph and posts each 128-sample
|
|
2
|
+
// block to the main thread, which downsamples to 16kHz and ships it to the
|
|
3
|
+
// server's /asr WebSocket. Outputs silence so the node can sit in the graph.
|
|
4
|
+
class PCMTap extends AudioWorkletProcessor {
|
|
5
|
+
process(inputs) {
|
|
6
|
+
const ch = inputs[0] && inputs[0][0];
|
|
7
|
+
if (ch && ch.length) this.port.postMessage(ch.slice(0));
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
registerProcessor("pcm-tap", PCMTap);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Off-main-thread screen-change detector. Reads the screen-share track's frames
|
|
2
|
+
// via MediaStreamTrackProcessor (transferred from the main thread), computes a
|
|
3
|
+
// dHash, and — only when the screen actually changes — encodes a JPEG and posts
|
|
4
|
+
// it back. Runs in a Worker, so it is NOT throttled when the tab is backgrounded.
|
|
5
|
+
|
|
6
|
+
// Two change signals, because one is not enough:
|
|
7
|
+
// - a 32×32 dHash (2048 bits) catches structural/layout change
|
|
8
|
+
// - a pixel-difference test at 128×72 catches TEXT-level change — a tab switch
|
|
9
|
+
// inside the same modal keeps the layout (tiny hash distance) but rewrites
|
|
10
|
+
// the words. Thresholds tuned on synthetic screens: a tab switch measures
|
|
11
|
+
// ~0.7% changed pixels across ~25 columns; a moving mouse cursor ~0.07%
|
|
12
|
+
// across ~4 columns; a blinking caret ~0%. Text must SPREAD (many columns)
|
|
13
|
+
// or be big to count, so the mouse never triggers vision calls.
|
|
14
|
+
const THRESH = 20; // bit hamming distance on the 2048-bit dHash — same units as the server's gate
|
|
15
|
+
const PIX_DELTA = 10; // per-pixel luma delta that counts as changed
|
|
16
|
+
const RATIO_BIG = 0.003; // this much of the screen changed → change, regardless of shape
|
|
17
|
+
const RATIO_SMALL = 0.0005;// smaller changes count only when they spread wide…
|
|
18
|
+
const COLS_MIN = 10; // …across at least this many of the 128 columns
|
|
19
|
+
let last = null, lastLuma = null, lastTime = 0, firstSent = false, running = false, reader = null;
|
|
20
|
+
let lastBitmap = null; // most recent frame, kept so a "grab" answers instantly (screen-share tracks emit no frames while the screen is static)
|
|
21
|
+
const PW = 128, PH = 72;
|
|
22
|
+
let hc, pc, jc;
|
|
23
|
+
function ensure() { if (!hc) { hc = new OffscreenCanvas(33, 33); pc = new OffscreenCanvas(PW, PH); jc = new OffscreenCanvas(8, 8); } }
|
|
24
|
+
|
|
25
|
+
function dhash(bitmap) {
|
|
26
|
+
ensure();
|
|
27
|
+
const ctx = hc.getContext("2d"); ctx.drawImage(bitmap, 0, 0, 33, 33);
|
|
28
|
+
const d = ctx.getImageData(0, 0, 33, 33).data;
|
|
29
|
+
const g = (x, y) => { const i = (y * 33 + x) * 4; return d[i] * 0.299 + d[i + 1] * 0.587 + d[i + 2] * 0.114; };
|
|
30
|
+
let bits = "";
|
|
31
|
+
for (let y = 0; y < 32; y++) for (let x = 0; x < 32; x++) bits += g(x, y) > g(x + 1, y) ? "1" : "0";
|
|
32
|
+
for (let y = 0; y < 32; y++) for (let x = 0; x < 32; x++) bits += g(x, y) > g(x, y + 1) ? "1" : "0";
|
|
33
|
+
let hex = ""; for (let i = 0; i < bits.length; i += 4) hex += parseInt(bits.slice(i, i + 4), 2).toString(16);
|
|
34
|
+
return hex;
|
|
35
|
+
}
|
|
36
|
+
// Pixel-difference change test vs the frame we LAST ACTED ON (not the previous
|
|
37
|
+
// sample, so slow transitions still add up). Returns true when the change is
|
|
38
|
+
// big, or small-but-wide (real text spans many columns; a cursor doesn't).
|
|
39
|
+
function pixChanged(bitmap) {
|
|
40
|
+
ensure();
|
|
41
|
+
const ctx = pc.getContext("2d"); ctx.drawImage(bitmap, 0, 0, PW, PH);
|
|
42
|
+
const d = ctx.getImageData(0, 0, PW, PH).data;
|
|
43
|
+
const luma = new Uint8Array(PW * PH);
|
|
44
|
+
for (let i = 0; i < luma.length; i++) { const j = i * 4; luma[i] = d[j] * 0.299 + d[j + 1] * 0.587 + d[j + 2] * 0.114; }
|
|
45
|
+
if (!lastLuma) { lastLuma = luma; return true; }
|
|
46
|
+
let changed = 0; const cols = new Set();
|
|
47
|
+
for (let i = 0; i < luma.length; i++) if (Math.abs(luma[i] - lastLuma[i]) > PIX_DELTA) { changed++; cols.add(i % PW); }
|
|
48
|
+
const ratio = changed / luma.length;
|
|
49
|
+
const isChange = ratio > RATIO_BIG || (ratio > RATIO_SMALL && cols.size >= COLS_MIN);
|
|
50
|
+
if (isChange) lastLuma = luma; // acted on → new reference
|
|
51
|
+
return isChange;
|
|
52
|
+
}
|
|
53
|
+
// Bit-accurate hamming distance (per hex digit), matching the server's gate exactly.
|
|
54
|
+
function ham(a, b) {
|
|
55
|
+
if (!a || !b || a.length !== b.length) return 4096;
|
|
56
|
+
let d = 0;
|
|
57
|
+
for (let i = 0; i < a.length; i++) { let x = parseInt(a[i], 16) ^ parseInt(b[i], 16); while (x) { d += x & 1; x >>= 1; } }
|
|
58
|
+
return d;
|
|
59
|
+
}
|
|
60
|
+
async function encode(bitmap) {
|
|
61
|
+
// 1600px / q0.8 so small, low-contrast text (per-plan credits, tiny labels)
|
|
62
|
+
// stays legible to the vision model; larger frames don't cost noticeable latency.
|
|
63
|
+
const w = bitmap.width, h = bitmap.height, s = Math.min(1, 1600 / Math.max(w, h));
|
|
64
|
+
jc.width = Math.round(w * s); jc.height = Math.round(h * s);
|
|
65
|
+
jc.getContext("2d").drawImage(bitmap, 0, 0, jc.width, jc.height);
|
|
66
|
+
return await jc.convertToBlob({ type: "image/jpeg", quality: 0.8 });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function loop() {
|
|
70
|
+
running = true;
|
|
71
|
+
while (running) {
|
|
72
|
+
let res; try { res = await reader.read(); } catch { break; }
|
|
73
|
+
if (res.done) break;
|
|
74
|
+
const frame = res.value;
|
|
75
|
+
const now = performance.now();
|
|
76
|
+
if (now - lastTime < 320) { frame.close(); continue; } // ~3 fps sampling
|
|
77
|
+
lastTime = now;
|
|
78
|
+
let bitmap; try { bitmap = await createImageBitmap(frame); } catch { frame.close(); continue; }
|
|
79
|
+
frame.close();
|
|
80
|
+
const hash = dhash(bitmap);
|
|
81
|
+
const textChanged = pixChanged(bitmap); // text-sensitive; keeps its own reference
|
|
82
|
+
if (last === null || ham(last, hash) > THRESH || textChanged) {
|
|
83
|
+
const announce = !firstSent; firstSent = true; last = hash;
|
|
84
|
+
try { const blob = await encode(bitmap); self.postMessage({ type: "change", hash, blob, announce }); } catch {}
|
|
85
|
+
}
|
|
86
|
+
if (lastBitmap) lastBitmap.close();
|
|
87
|
+
lastBitmap = bitmap; // keep for instant grabs; closed when replaced or on stop
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
self.onmessage = async (e) => {
|
|
92
|
+
const m = e.data || {};
|
|
93
|
+
if (m.type === "start") { last = null; lastLuma = null; firstSent = false; reader = m.readable.getReader(); loop(); }
|
|
94
|
+
else if (m.type === "stop") { running = false; try { reader && reader.cancel(); } catch {} if (lastBitmap) { try { lastBitmap.close(); } catch {} lastBitmap = null; } }
|
|
95
|
+
else if (m.type === "grab") {
|
|
96
|
+
// Answer from the cached frame immediately — waiting on the track stalls
|
|
97
|
+
// indefinitely when the screen is static (tracks only emit on change).
|
|
98
|
+
if (!lastBitmap) { self.postMessage({ type: "grabbed", hash: null, blob: null }); return; }
|
|
99
|
+
ensure();
|
|
100
|
+
try { const hash = dhash(lastBitmap); const blob = await encode(lastBitmap); self.postMessage({ type: "grabbed", hash, blob }); }
|
|
101
|
+
catch { self.postMessage({ type: "grabbed", hash: null, blob: null }); }
|
|
102
|
+
}
|
|
103
|
+
};
|
package/public/rtc.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Minimal WebRTC peer for the specialist handoff. The user is the offerer, the
|
|
3
|
+
// specialist the answerer; signaling is relayed by the server's /rtc socket.
|
|
4
|
+
// Local tracks (screen video + mic on the user; mic on the specialist) are added
|
|
5
|
+
// up front; the remote stream (real screen + voice) is delivered via onRemote.
|
|
6
|
+
function makeRTC(opts) {
|
|
7
|
+
// opts: { sessionId (user) | key (specialist), role:'user'|'specialist', localStreams:[MediaStream], onRemote(stream), onStatus(str) }
|
|
8
|
+
const isOfferer = opts.role === "user";
|
|
9
|
+
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
10
|
+
const cred = opts.role === "specialist" ? `key=${encodeURIComponent(opts.key)}` : `session=${encodeURIComponent(opts.sessionId)}`;
|
|
11
|
+
const ws = new WebSocket(`${proto}//${location.host}/rtc?${cred}&role=${opts.role}`);
|
|
12
|
+
const remote = new MediaStream();
|
|
13
|
+
let pc = null, pendingIce = [], closed = false, offered = false;
|
|
14
|
+
const status = (s) => { try { opts.onStatus && opts.onStatus(s); } catch {} };
|
|
15
|
+
const sig = (m) => { try { if (ws.readyState === 1) ws.send(JSON.stringify(m)); } catch {} };
|
|
16
|
+
|
|
17
|
+
function ensurePC() {
|
|
18
|
+
if (pc) return pc;
|
|
19
|
+
pc = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }, { urls: "stun:stun1.l.google.com:19302" }] });
|
|
20
|
+
pc.onicecandidate = (e) => { if (e.candidate) sig({ type: "ice", candidate: e.candidate }); };
|
|
21
|
+
pc.ontrack = (e) => { const s = e.streams && e.streams[0]; if (s) s.getTracks().forEach((t) => { if (!remote.getTracks().includes(t)) remote.addTrack(t); }); else remote.addTrack(e.track); try { opts.onRemote && opts.onRemote(remote); } catch {} };
|
|
22
|
+
pc.onconnectionstatechange = () => status(pc.connectionState);
|
|
23
|
+
for (const st of (opts.localStreams || [])) for (const t of st.getTracks()) { try { pc.addTrack(t, st); } catch {} }
|
|
24
|
+
return pc;
|
|
25
|
+
}
|
|
26
|
+
// The peer left (e.g. the specialist refreshed): drop this connection so the
|
|
27
|
+
// next arrival gets a fresh offer instead of a dead one.
|
|
28
|
+
function resetPC() {
|
|
29
|
+
if (pc) { try { pc.close(); } catch {} pc = null; }
|
|
30
|
+
pendingIce = []; offered = false;
|
|
31
|
+
remote.getTracks().forEach((t) => remote.removeTrack(t));
|
|
32
|
+
}
|
|
33
|
+
async function flushIce() { const q = pendingIce; pendingIce = []; for (const c of q) { try { await pc.addIceCandidate(c); } catch {} } }
|
|
34
|
+
async function makeOffer() {
|
|
35
|
+
if (!isOfferer || offered) return; offered = true;
|
|
36
|
+
ensurePC();
|
|
37
|
+
try { const o = await pc.createOffer(); await pc.setLocalDescription(o); sig({ type: "offer", sdp: pc.localDescription }); } catch { offered = false; }
|
|
38
|
+
}
|
|
39
|
+
ws.onopen = () => status("signaling");
|
|
40
|
+
ws.onmessage = async (ev) => {
|
|
41
|
+
let m; try { m = JSON.parse(ev.data); } catch { return; }
|
|
42
|
+
if (m.type === "peer") {
|
|
43
|
+
status(m.present ? "peer-present" : "waiting");
|
|
44
|
+
if (!m.present && pc) resetPC();
|
|
45
|
+
if (m.present && isOfferer) { if (m.joined && pc) resetPC(); makeOffer(); }
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (m.type === "offer") { ensurePC(); try { await pc.setRemoteDescription(m.sdp); await flushIce(); const a = await pc.createAnswer(); await pc.setLocalDescription(a); sig({ type: "answer", sdp: pc.localDescription }); } catch {} return; }
|
|
49
|
+
if (m.type === "answer") { if (pc) try { await pc.setRemoteDescription(m.sdp); await flushIce(); } catch {} return; }
|
|
50
|
+
if (m.type === "ice") { if (pc && pc.remoteDescription && pc.remoteDescription.type) { try { await pc.addIceCandidate(m.candidate); } catch {} } else pendingIce.push(m.candidate); return; }
|
|
51
|
+
};
|
|
52
|
+
ws.onclose = () => { if (!closed) status("closed"); };
|
|
53
|
+
return {
|
|
54
|
+
remote,
|
|
55
|
+
// Local tracks belong to the caller (the user's live mic and shared screen keep
|
|
56
|
+
// running after a handoff), so closing only tears down the connection.
|
|
57
|
+
close() { closed = true; try { ws.close(); } catch {} if (pc) { try { pc.close(); } catch {} pc = null; } },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
if (typeof window !== "undefined") window.makeRTC = makeRTC;
|
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
|
7
|
+
<title>Ava · Specialist console</title>
|
|
8
|
+
<link rel="preload" href="/fonts/inter-tight-var.woff2" as="font" type="font/woff2" crossorigin>
|
|
9
|
+
<link rel="preload" href="/fonts/inter-var.woff2" as="font" type="font/woff2" crossorigin>
|
|
10
|
+
<link rel="stylesheet" href="style.css">
|
|
11
|
+
<style>
|
|
12
|
+
body.spec { display: flex; flex-direction: column; height: 100vh; height: 100dvh; background: var(--canvas); overflow: hidden; }
|
|
13
|
+
.spec-top { height: 54px; flex-shrink: 0; display: flex; align-items: center; gap: 11px; padding: 0 20px; background: var(--chrome); border-bottom: 1px solid var(--divider); }
|
|
14
|
+
.spec-title { font-family: var(--disp); font-size: 14px; font-weight: 600; }
|
|
15
|
+
.spec-sid { font-size: 11px; color: var(--meta); padding: 3px 9px; border: 1px solid var(--edge2); border-radius: 6px; }
|
|
16
|
+
.spec-conn { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--meta); }
|
|
17
|
+
.spec-conn .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--null); }
|
|
18
|
+
.spec-conn.live .dot { background: var(--green); animation: livePulse 1.6s ease-in-out infinite; }
|
|
19
|
+
.spec-main { flex-grow: 1; min-height: 0; display: flex; }
|
|
20
|
+
.spec-ctx { width: 340px; flex-shrink: 0; overflow-y: auto; padding: 18px 20px; border-right: 1px solid var(--divider); background: var(--chrome); display: flex; flex-direction: column; gap: 18px; scrollbar-width: thin; scrollbar-color: var(--edge) transparent; }
|
|
21
|
+
.spec-block { display: flex; flex-direction: column; gap: 8px; }
|
|
22
|
+
.spec-lbl { font-size: 10px; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; color: var(--meta); }
|
|
23
|
+
.spec-goal { font-size: 14px; font-weight: 500; color: var(--ink); line-height: 1.4; }
|
|
24
|
+
.spec-plat { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink2); }
|
|
25
|
+
.spec-screen { font-size: 12px; color: var(--ink2); line-height: 1.5; background: var(--canvas); border: 1px solid var(--hairline); border-radius: 9px; padding: 10px 12px; }
|
|
26
|
+
.spec-screen b { color: var(--ink); font-weight: 600; }
|
|
27
|
+
.spec-els { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 7px; }
|
|
28
|
+
.spec-el { font-family: var(--mono); font-size: 10px; color: var(--ink2); background: var(--chrome); border: 1px solid var(--edge2); border-radius: 5px; padding: 2px 6px; }
|
|
29
|
+
.spec-blocker { font-size: 12.5px; line-height: 1.5; color: var(--ink); background: rgba(14,140,140,0.08); border: 1px solid rgba(14,140,140,0.25); border-radius: 9px; padding: 10px 12px; }
|
|
30
|
+
.spec-blocker.none { color: var(--meta); background: var(--canvas); border-color: var(--hairline); }
|
|
31
|
+
.spec-live { width: 100%; border-radius: 9px; background: #0b0d12; border: 1px solid var(--edge2); display: block; margin-bottom: 8px; }
|
|
32
|
+
.spec-live[hidden] { display: none; }
|
|
33
|
+
.spec-mic { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--green); }
|
|
34
|
+
.spec-mic .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); animation: livePulse 1.6s ease-in-out infinite; }
|
|
35
|
+
|
|
36
|
+
.spec-convo { flex-grow: 1; min-width: 0; display: flex; flex-direction: column; }
|
|
37
|
+
.spec-msgs { flex-grow: 1; min-height: 0; overflow-y: auto; padding: 20px 24px; display: flex; flex-direction: column; gap: 14px; scrollbar-width: thin; scrollbar-color: var(--edge) transparent; }
|
|
38
|
+
.sm { max-width: 78%; display: flex; flex-direction: column; gap: 3px; }
|
|
39
|
+
.sm .who { font-family: var(--mono); font-size: 10px; font-weight: 600; letter-spacing: 0.06em; color: var(--meta); }
|
|
40
|
+
.sm .bub { padding: 9px 13px; border-radius: 12px; font-size: 14px; line-height: 1.5; }
|
|
41
|
+
.sm.user { align-self: flex-end; align-items: flex-end; }
|
|
42
|
+
.sm.user .bub { background: var(--active); color: var(--ink); border-bottom-right-radius: 4px; }
|
|
43
|
+
.sm.ava .who { color: var(--amber); }
|
|
44
|
+
.sm.ava .bub { background: var(--brandtint); color: var(--ink); border: 1px solid var(--brandline); border-bottom-left-radius: 4px; }
|
|
45
|
+
.sm.specialist { align-self: flex-end; align-items: flex-end; }
|
|
46
|
+
.sm.specialist .who { color: var(--green); }
|
|
47
|
+
.sm.specialist .bub { background: var(--green); color: #fff; border-bottom-right-radius: 4px; }
|
|
48
|
+
.sm.system { align-self: center; max-width: 90%; }
|
|
49
|
+
.sm.system .bub { background: transparent; color: var(--meta); font-size: 12px; font-style: italic; padding: 2px 0; }
|
|
50
|
+
|
|
51
|
+
.spec-composer { flex-shrink: 0; display: flex; align-items: center; gap: 10px; padding: 14px 24px; border-top: 1px solid var(--divider); background: var(--chrome); }
|
|
52
|
+
.spec-composer input { flex-grow: 1; height: 44px; padding: 0 14px; border-radius: 10px; border: 1px solid var(--edge); background: var(--control); color: var(--ink); font-family: var(--sans); font-size: 14px; outline: none; }
|
|
53
|
+
.spec-composer input:focus { border-color: var(--amber); box-shadow: 0 0 0 3px var(--brandtint); }
|
|
54
|
+
.spec-composer input:disabled { background: var(--active); color: var(--meta); }
|
|
55
|
+
.spec-empty { margin: auto; text-align: center; color: var(--meta); font-size: 13px; max-width: 340px; line-height: 1.5; }
|
|
56
|
+
.grow { flex-grow: 1; }
|
|
57
|
+
</style>
|
|
58
|
+
</head>
|
|
59
|
+
<body class="spec">
|
|
60
|
+
<header class="spec-top">
|
|
61
|
+
<svg class="wm" viewBox="0 0 66 27.8" role="img" aria-label="Ava"><path transform="translate(1.9 1.9)" d="M0 24L9.5 0L19 24M21.6 0L31.1 24L40.6 0M43.2 24L52.7 0L62.2 24" fill="none" stroke="currentColor" stroke-width="3.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
62
|
+
<span class="spec-title">Specialist console</span>
|
|
63
|
+
<span class="spec-sid mono" id="sid">—</span>
|
|
64
|
+
<span class="spec-conn" id="conn"><span class="dot"></span><span id="conntext">connecting…</span></span>
|
|
65
|
+
<span class="spec-mic" id="micind" style="display:none"><span class="dot"></span>Your mic is live</span>
|
|
66
|
+
<div class="grow"></div>
|
|
67
|
+
<button id="joinbtn" class="amberbtn" style="height:38px">Join the call</button>
|
|
68
|
+
<button id="leavebtn" class="ghostbtn" style="height:38px;display:none">Leave</button>
|
|
69
|
+
</header>
|
|
70
|
+
<div class="spec-main">
|
|
71
|
+
<aside class="spec-ctx" id="ctx">
|
|
72
|
+
<div class="spec-block"><span class="spec-lbl">Session</span><div class="spec-plat" id="platline">—</div></div>
|
|
73
|
+
<div class="spec-block"><span class="spec-lbl">What they're trying to do</span><div class="spec-goal" id="goal">—</div></div>
|
|
74
|
+
<div class="spec-block"><span class="spec-lbl">Progress</span><div id="wfmount"></div></div>
|
|
75
|
+
<div class="spec-block"><span class="spec-lbl">On screen now</span>
|
|
76
|
+
<video id="liveScreen" class="spec-live" playsinline autoplay hidden></video>
|
|
77
|
+
<div class="spec-screen" id="screen">Waiting for a screen read…</div>
|
|
78
|
+
</div>
|
|
79
|
+
<div class="spec-block"><span class="spec-lbl">Where they may be stuck</span><div class="spec-blocker none" id="blocker">Nothing flagged yet.</div></div>
|
|
80
|
+
</aside>
|
|
81
|
+
<main class="spec-convo">
|
|
82
|
+
<div class="spec-msgs" id="msgs"><div class="spec-empty" id="empty">Loading the conversation…</div></div>
|
|
83
|
+
<form class="spec-composer" id="form">
|
|
84
|
+
<input id="input" placeholder="Join the call to message the user…" autocomplete="off" disabled>
|
|
85
|
+
<button type="submit" class="amberbtn" style="height:44px" id="send" disabled>Send</button>
|
|
86
|
+
</form>
|
|
87
|
+
</main>
|
|
88
|
+
</div>
|
|
89
|
+
<script src="rtc.js"></script>
|
|
90
|
+
<script src="specialist.js"></script>
|
|
91
|
+
</body>
|
|
92
|
+
</html>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/* global makeRTC -- from rtc.js */
|
|
3
|
+
const $ = (id) => document.getElementById(id);
|
|
4
|
+
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
5
|
+
// The link carries a specialist key minted for this handoff (not the user's
|
|
6
|
+
// session id); it stops working when the handoff ends.
|
|
7
|
+
const key = new URLSearchParams(location.search).get("key") || "";
|
|
8
|
+
$("sid").textContent = key ? "handoff " + key.slice(0, 8) : "no session";
|
|
9
|
+
|
|
10
|
+
const S = { since: 0, joined: false, timer: null, seenSeq: 0, dead: false, rtc: null, mic: null, rtcStarting: false };
|
|
11
|
+
const LABEL = { user: "USER", ava: "AVA", specialist: "YOU (SPECIALIST)", system: "" };
|
|
12
|
+
|
|
13
|
+
function addMsg(e) {
|
|
14
|
+
if (e.seq <= S.seenSeq) return;
|
|
15
|
+
S.seenSeq = e.seq;
|
|
16
|
+
$("empty") && $("empty").remove();
|
|
17
|
+
const el = document.createElement("div");
|
|
18
|
+
el.className = "sm " + e.role;
|
|
19
|
+
if (e.role === "system") el.innerHTML = `<span class="bub">${esc(e.text)}</span>`;
|
|
20
|
+
else el.innerHTML = `<span class="who">${LABEL[e.role] || e.role}</span><span class="bub">${esc(e.text)}</span>`;
|
|
21
|
+
$("msgs").appendChild(el); $("msgs").scrollTop = $("msgs").scrollHeight;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function renderContext(c) {
|
|
25
|
+
if (!c) return;
|
|
26
|
+
$("platline").textContent = c.platform ? `${c.platform.name} onboarding` : (c.mode === "onboarding" ? "Onboarding" : "Flexible session");
|
|
27
|
+
$("goal").textContent = c.goal && c.goal !== "—" ? c.goal : "Still figuring out what they need.";
|
|
28
|
+
// screen
|
|
29
|
+
if (c.screen) {
|
|
30
|
+
const els = (c.screen.elements || []).slice(0, 10).map((x) => `<span class="spec-el">${esc(x)}</span>`).join("");
|
|
31
|
+
$("screen").innerHTML = `<b>${esc(c.screen.app || "?")}</b> · ${esc(c.screen.page || "")}${c.screen.state ? `<br>${esc(c.screen.state)}` : ""}${els ? `<div class="spec-els">${els}</div>` : ""}`;
|
|
32
|
+
} else $("screen").textContent = "They haven't shared a screen yet.";
|
|
33
|
+
// reasoning / blocker
|
|
34
|
+
const b = $("blocker"), r = c.reasoning;
|
|
35
|
+
if (r && (r.stuck || r.blocker)) { b.className = "spec-blocker"; b.textContent = r.blocker || r.thought || "They seem stuck."; }
|
|
36
|
+
else if (r && r.thought) { b.className = "spec-blocker none"; b.textContent = r.thought; }
|
|
37
|
+
else { b.className = "spec-blocker none"; b.textContent = "Nothing flagged yet."; }
|
|
38
|
+
// workflow
|
|
39
|
+
renderWf(c.workflow);
|
|
40
|
+
// connection + join state
|
|
41
|
+
if (c.specialist && !S.joined) markJoined();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function renderWf(wf) {
|
|
45
|
+
const m = $("wfmount");
|
|
46
|
+
if (!wf) { m.innerHTML = '<span style="font-size:12px;color:var(--meta)">No tracked workflow.</span>'; return; }
|
|
47
|
+
const pct = wf.total ? Math.round((wf.done / wf.total) * 100) : 0;
|
|
48
|
+
const steps = wf.steps.map((s) => {
|
|
49
|
+
const sub = s.status === "done" ? "Done" : s.status === "active" ? ("Happening now" + (wf.note ? " · " + esc(wf.note) : "")) : "";
|
|
50
|
+
return `<div class="wf-step ${s.status}"><span class="wf-mark"></span><div class="wf-stepbody"><span class="wf-steptitle">${esc(s.title)}</span>${sub ? `<span class="wf-substatus">${sub}</span>` : ""}</div></div>`;
|
|
51
|
+
}).join("");
|
|
52
|
+
m.innerHTML = `<div class="wf-titlerow" style="margin-bottom:8px"><span class="mono wf-count">${wf.done} / ${wf.total}</span><span class="mono wf-count">${pct}%</span></div><div class="wf-bar" style="margin-bottom:12px"><div class="wf-fill" style="width:${pct}%"></div></div><div class="wf-steps" style="padding:0;overflow:visible">${steps}</div>`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function poll() {
|
|
56
|
+
if (S.dead) return;
|
|
57
|
+
try {
|
|
58
|
+
const r = await fetch(`/api/bridge?key=${encodeURIComponent(key)}&since=${S.since}`);
|
|
59
|
+
if (r.status === 404) { fail("This handoff link has expired or the call ended. Ask the user for a fresh link."); return; }
|
|
60
|
+
const d = await r.json();
|
|
61
|
+
S.since = d.seq;
|
|
62
|
+
(d.events || []).forEach(addMsg);
|
|
63
|
+
renderContext(d.context);
|
|
64
|
+
setConn(true);
|
|
65
|
+
} catch { setConn(false); }
|
|
66
|
+
S.timer = setTimeout(poll, 1200);
|
|
67
|
+
}
|
|
68
|
+
function setConn(live) { const c = $("conn"); c.classList.toggle("live", live); $("conntext").textContent = live ? (S.joined ? "on the call" : "watching") : "reconnecting…"; }
|
|
69
|
+
// The link is dead (handoff ended, call over): drop the call, the mic and the
|
|
70
|
+
// customer's context — a revoked specialist keeps nothing on screen.
|
|
71
|
+
function fail(msg) {
|
|
72
|
+
S.dead = true; S.joined = false; clearTimeout(S.timer);
|
|
73
|
+
stopRTC();
|
|
74
|
+
$("msgs").innerHTML = `<div class="spec-empty">${esc(msg)}</div>`;
|
|
75
|
+
for (const id of ["goal", "screen", "blocker"]) { const el = $(id); if (el) el.textContent = "—"; }
|
|
76
|
+
const wf = $("wfmount"); if (wf) wf.textContent = "";
|
|
77
|
+
$("joinbtn").style.display = "none"; $("leavebtn").style.display = "none";
|
|
78
|
+
$("conntext").textContent = "disconnected"; $("input").disabled = true; $("send").disabled = true;
|
|
79
|
+
}
|
|
80
|
+
function markJoined() {
|
|
81
|
+
S.joined = true;
|
|
82
|
+
$("joinbtn").style.display = "none"; $("leavebtn").style.display = "";
|
|
83
|
+
$("input").disabled = false; $("send").disabled = false; $("input").placeholder = "Message the user…"; $("input").focus();
|
|
84
|
+
setConn(true);
|
|
85
|
+
startRTC();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Live A/V: send the specialist's mic, receive the user's screen + voice.
|
|
89
|
+
async function startRTC() {
|
|
90
|
+
if (S.rtc || S.rtcStarting || !window.makeRTC) return; // set before the await so a second call can't race in
|
|
91
|
+
S.rtcStarting = true;
|
|
92
|
+
try { S.mic = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } }); $("micind").style.display = ""; } catch { S.mic = null; }
|
|
93
|
+
S.rtcStarting = false;
|
|
94
|
+
if (!S.joined) { stopRTC(); return; } // left while the mic prompt was up
|
|
95
|
+
S.rtc = makeRTC({ key, role: "specialist", localStreams: S.mic ? [S.mic] : [],
|
|
96
|
+
onRemote: (stream) => {
|
|
97
|
+
const v = $("liveScreen");
|
|
98
|
+
v.srcObject = stream; v.play().catch(() => {});
|
|
99
|
+
const hasVideo = stream.getVideoTracks().length > 0;
|
|
100
|
+
v.hidden = !hasVideo;
|
|
101
|
+
$("screen").style.display = hasVideo ? "none" : "";
|
|
102
|
+
},
|
|
103
|
+
onStatus: (s) => { if (s === "connected") setConn(true); } });
|
|
104
|
+
}
|
|
105
|
+
function stopRTC() {
|
|
106
|
+
if (S.rtc) { try { S.rtc.close(); } catch {} S.rtc = null; }
|
|
107
|
+
if (S.mic) { try { S.mic.getTracks().forEach((t) => t.stop()); } catch {} S.mic = null; }
|
|
108
|
+
$("micind").style.display = "none"; const v = $("liveScreen"); if (v) { try { v.srcObject = null; } catch {} v.hidden = true; } $("screen").style.display = "";
|
|
109
|
+
}
|
|
110
|
+
$("joinbtn").onclick = async () => {
|
|
111
|
+
if (!key) return;
|
|
112
|
+
try {
|
|
113
|
+
const r = await fetch("/api/bridge", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, action: "join" }) });
|
|
114
|
+
const d = await r.json();
|
|
115
|
+
if (r.status === 404) return fail("This handoff link has expired or the call ended. Ask the user for a fresh link.");
|
|
116
|
+
if (d.context) { markJoined(); (d.events || []).forEach(addMsg); S.since = d.seq; } // markJoined starts the A/V link
|
|
117
|
+
} catch {}
|
|
118
|
+
};
|
|
119
|
+
$("leavebtn").onclick = async () => {
|
|
120
|
+
try { await fetch("/api/bridge", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, action: "leave" }) }); } catch {}
|
|
121
|
+
stopRTC();
|
|
122
|
+
S.joined = false; $("joinbtn").style.display = ""; $("leavebtn").style.display = "none"; $("input").disabled = true; $("send").disabled = true; $("input").placeholder = "Join the call to message the user…"; setConn(true);
|
|
123
|
+
};
|
|
124
|
+
$("form").addEventListener("submit", async (e) => {
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
const v = $("input").value.trim(); if (!v || !S.joined) return;
|
|
127
|
+
$("input").value = "";
|
|
128
|
+
try { await fetch("/api/bridge", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ key, action: "say", text: v }) }); } catch {}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (!key) fail("No handoff key in the link. The user's call generates a link like /specialist?key=…");
|
|
132
|
+
else poll();
|