ava-engine 0.0.0-stage → 0.2.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.
Files changed (104) hide show
  1. package/.env.example +63 -0
  2. package/CLA.md +43 -0
  3. package/COMMERCIAL-LICENSE.md +43 -0
  4. package/LICENSE +73 -0
  5. package/README.md +240 -2
  6. package/THIRD_PARTY_NOTICES.md +38 -0
  7. package/TRADEMARKS.md +31 -0
  8. package/bin/ava-engine.mjs +12 -0
  9. package/lib/asr.mjs +147 -0
  10. package/lib/cli/apply.mjs +58 -0
  11. package/lib/cli/config.mjs +95 -0
  12. package/lib/cli/main.mjs +187 -0
  13. package/lib/cli/setup-server.mjs +195 -0
  14. package/lib/cli/setup.mjs +233 -0
  15. package/lib/fetch-models.mjs +104 -0
  16. package/lib/guard.mjs +92 -0
  17. package/lib/kokoro-model.mjs +51 -0
  18. package/lib/models.mjs +47 -0
  19. package/lib/openrouter.mjs +153 -0
  20. package/lib/ours.mjs +766 -0
  21. package/lib/platforms.mjs +201 -0
  22. package/lib/state.mjs +150 -0
  23. package/lib/stt.mjs +22 -0
  24. package/lib/tts.mjs +178 -0
  25. package/lib/workflow.mjs +18 -0
  26. package/package.json +80 -4
  27. package/public/app.js +1708 -0
  28. package/public/demo.js +420 -0
  29. package/public/favicon.svg +1 -0
  30. package/public/fonts/OFL.txt +96 -0
  31. package/public/fonts/inter-tight-var.woff2 +0 -0
  32. package/public/fonts/inter-var.woff2 +0 -0
  33. package/public/fonts/plex-mono-400.woff2 +0 -0
  34. package/public/fonts/plex-mono-500.woff2 +0 -0
  35. package/public/img/cta-1600.avif +0 -0
  36. package/public/img/cta-1600.webp +0 -0
  37. package/public/img/cta-2400.avif +0 -0
  38. package/public/img/cta-2400.webp +0 -0
  39. package/public/img/cta-960.avif +0 -0
  40. package/public/img/cta-960.webp +0 -0
  41. package/public/img/flows-1600.avif +0 -0
  42. package/public/img/flows-1600.webp +0 -0
  43. package/public/img/flows-2400.avif +0 -0
  44. package/public/img/flows-2400.webp +0 -0
  45. package/public/img/flows-960.avif +0 -0
  46. package/public/img/flows-960.webp +0 -0
  47. package/public/img/hero-1600.avif +0 -0
  48. package/public/img/hero-1600.webp +0 -0
  49. package/public/img/hero-2400.avif +0 -0
  50. package/public/img/hero-2400.webp +0 -0
  51. package/public/img/hero-960.avif +0 -0
  52. package/public/img/hero-960.webp +0 -0
  53. package/public/img/p-csm-480.avif +0 -0
  54. package/public/img/p-csm-480.webp +0 -0
  55. package/public/img/p-csm-800.avif +0 -0
  56. package/public/img/p-csm-800.webp +0 -0
  57. package/public/img/p-docs-480.avif +0 -0
  58. package/public/img/p-docs-480.webp +0 -0
  59. package/public/img/p-docs-800.avif +0 -0
  60. package/public/img/p-docs-800.webp +0 -0
  61. package/public/img/p-quiet-480.avif +0 -0
  62. package/public/img/p-quiet-480.webp +0 -0
  63. package/public/img/p-quiet-800.avif +0 -0
  64. package/public/img/p-quiet-800.webp +0 -0
  65. package/public/img/p-tickets-480.avif +0 -0
  66. package/public/img/p-tickets-480.webp +0 -0
  67. package/public/img/p-tickets-800.avif +0 -0
  68. package/public/img/p-tickets-800.webp +0 -0
  69. package/public/img/p-trial-480.avif +0 -0
  70. package/public/img/p-trial-480.webp +0 -0
  71. package/public/img/p-trial-800.avif +0 -0
  72. package/public/img/p-trial-800.webp +0 -0
  73. package/public/img/sees-1600.avif +0 -0
  74. package/public/img/sees-1600.webp +0 -0
  75. package/public/img/sees-2400.avif +0 -0
  76. package/public/img/sees-2400.webp +0 -0
  77. package/public/img/sees-960.avif +0 -0
  78. package/public/img/sees-960.webp +0 -0
  79. package/public/img/speaks-1600.avif +0 -0
  80. package/public/img/speaks-1600.webp +0 -0
  81. package/public/img/speaks-2400.avif +0 -0
  82. package/public/img/speaks-2400.webp +0 -0
  83. package/public/img/speaks-960.avif +0 -0
  84. package/public/img/speaks-960.webp +0 -0
  85. package/public/img/voice-1600.avif +0 -0
  86. package/public/img/voice-1600.webp +0 -0
  87. package/public/img/voice-2400.avif +0 -0
  88. package/public/img/voice-2400.webp +0 -0
  89. package/public/img/voice-960.avif +0 -0
  90. package/public/img/voice-960.webp +0 -0
  91. package/public/index.html +440 -0
  92. package/public/landing.html +975 -0
  93. package/public/pcm-worklet.js +11 -0
  94. package/public/perceptionWorker.js +103 -0
  95. package/public/rtc.js +60 -0
  96. package/public/setup.css +94 -0
  97. package/public/setup.html +129 -0
  98. package/public/setup.js +264 -0
  99. package/public/specialist.html +92 -0
  100. package/public/specialist.js +132 -0
  101. package/public/style.css +836 -0
  102. package/public/vendor/lenis.min.js +3 -0
  103. package/scripts/fetch-models.sh +15 -0
  104. package/server.mjs +502 -0
@@ -0,0 +1,264 @@
1
+ "use strict";
2
+ // Browser setup wizard for `npx ava-engine setup --web`. Talks only to the local
3
+ // setup server; every call carries the one-time token from the URL fragment.
4
+ (() => {
5
+ const $ = (id) => document.getElementById(id);
6
+ const TOKEN = new URLSearchParams(location.hash.slice(1)).get("token") || "";
7
+ const ORDER = ["ai", "voice", "speech", "access", "review"];
8
+ const S = { step: "ai", state: null, keyOk: false, elevenOk: false, selectedPacks: new Set(), pollTimer: null };
9
+
10
+ async function api(path, body) {
11
+ const r = await fetch(`/api/setup/${path}`, {
12
+ method: body ? "POST" : "GET",
13
+ headers: { "X-Setup-Token": TOKEN, ...(body ? { "Content-Type": "application/json" } : {}) },
14
+ body: body ? JSON.stringify(body) : undefined,
15
+ });
16
+ const j = await r.json().catch(() => ({}));
17
+ if (!r.ok) throw Object.assign(new Error(j.error || `error ${r.status}`), { status: r.status });
18
+ return j;
19
+ }
20
+ const radio = (name) => (document.querySelector(`input[name="${name}"]:checked`) || {}).value;
21
+ const fatal = (msg) => { const b = $("fatal"); b.textContent = msg; b.hidden = false; };
22
+ const setStatus = (el, text, kind) => { el.textContent = text; el.className = "status" + (kind ? " " + kind : ""); };
23
+
24
+ // ── step navigation ──
25
+ function show(step) {
26
+ S.step = step;
27
+ document.querySelectorAll(".step").forEach((s) => { s.hidden = s.dataset.step !== step; });
28
+ const idx = ORDER.indexOf(step);
29
+ document.querySelectorAll("#steps li").forEach((li, i) => {
30
+ li.classList.toggle("current", i === idx);
31
+ li.classList.toggle("done", idx > i || step === "launching");
32
+ if (i === idx) li.setAttribute("aria-current", "step"); else li.removeAttribute("aria-current");
33
+ });
34
+ if (step === "voice") syncVoice();
35
+ if (step === "review") renderSummary();
36
+ const h = document.querySelector(`.step[data-step="${step}"] h1`);
37
+ if (h) { h.tabIndex = -1; h.focus({ preventScroll: true }); }
38
+ window.scrollTo({ top: 0 });
39
+ }
40
+ function canLeave(step) {
41
+ if (step === "ai" && radio("mode") === "full" && !(S.state.current.openrouterKey && $("keepKey").checked) && !S.keyOk) {
42
+ setStatus($("keyStatus"), "Check your key first, or choose “Just look around first”.", "err"); return false;
43
+ }
44
+ if (step === "voice" && radio("voice") === "elevenlabs" && !(S.state.current.elevenKey && $("keepEleven").checked) && !S.elevenOk) {
45
+ setStatus($("elevenStatus"), "Check your ElevenLabs key first, or pick another voice.", "err"); return false;
46
+ }
47
+ if (step === "access" && radio("audience") === "others" && !(S.state.current.hasPassword && $("keepPass").checked) && $("password").value.length < 10) {
48
+ $("password").focus(); return false;
49
+ }
50
+ if (step === "access") { const p = Number($("port").value); if (!(Number.isInteger(p) && p > 0 && p < 65536)) { $("port").focus(); return false; } }
51
+ return true;
52
+ }
53
+ document.addEventListener("click", (e) => {
54
+ if (e.target.matches("[data-next]")) { if (canLeave(S.step)) show(ORDER[ORDER.indexOf(S.step) + 1]); }
55
+ if (e.target.matches("[data-back]")) show(ORDER[ORDER.indexOf(S.step) - 1]);
56
+ });
57
+
58
+ // ── 1 · AI ──
59
+ function syncMode() {
60
+ const full = radio("mode") === "full";
61
+ $("keyField").hidden = !full;
62
+ const hasKey = !!S.state.current.openrouterKey;
63
+ $("keepKeyRow").hidden = !hasKey;
64
+ $("newKeyRow").hidden = hasKey && $("keepKey").checked;
65
+ }
66
+ async function checkKey(service, input, statusEl, okFlag) {
67
+ const key = input.value.trim();
68
+ setStatus(statusEl, "Checking…");
69
+ try {
70
+ const r = await api("check", { service, key });
71
+ S[okFlag] = !!r.ok || !!r.offline;
72
+ if (r.ok) setStatus(statusEl, `✓ Key works${r.label ? ` (${r.label})` : ""}${r.limit != null ? `, credit limit $${r.limit}` : ""}`, "ok");
73
+ else setStatus(statusEl, r.offline ? `${r.error}. It will be saved anyway.` : `✗ ${r.error}`, r.offline ? "" : "err");
74
+ } catch (e) { S[okFlag] = false; setStatus(statusEl, `✗ ${e.message}`, "err"); }
75
+ }
76
+
77
+ // ── 2 · voice ──
78
+ function renderVoices() {
79
+ const v = S.state.voices, cur = S.state.current.voice;
80
+ $("voiceCards").innerHTML = "";
81
+ for (const [id, info] of Object.entries(v)) {
82
+ const label = document.createElement("label"); label.className = "card";
83
+ label.innerHTML = `<input type="radio" name="voice"><span class="card-body"><b></b><span></span></span>`;
84
+ const input = label.querySelector("input"); input.value = id; input.checked = id === cur;
85
+ label.querySelector("b").textContent = info.label;
86
+ label.querySelector(".card-body span").textContent = id === "kokoro" && S.state.kokoro.installed ? "Free, runs on this computer. Installed." : info.hint[0].toUpperCase() + info.hint.slice(1) + ".";
87
+ $("voiceCards").append(label);
88
+ }
89
+ }
90
+ function syncVoice() {
91
+ const voice = radio("voice");
92
+ $("elevenField").hidden = voice !== "elevenlabs";
93
+ const hasEl = !!S.state.current.elevenKey;
94
+ $("keepElevenRow").hidden = !hasEl;
95
+ $("newElevenRow").hidden = hasEl && $("keepEleven").checked;
96
+ // Only download once you're actually choosing a voice, never just because Kokoro is the default.
97
+ if (voice === "kokoro" && !S.state.kokoro.installed && S.step === "voice") startDownload("kokoro");
98
+ }
99
+
100
+ // ── 3 · speech models ──
101
+ function renderPacks() {
102
+ const box = $("packCards"); box.innerHTML = "";
103
+ for (const [id, p] of Object.entries(S.state.packs)) {
104
+ const installed = S.state.installedPacks.includes(id);
105
+ const label = document.createElement("label"); label.className = "card";
106
+ label.innerHTML = `<input type="checkbox"><span class="card-body"><b></b><span></span></span>`;
107
+ const input = label.querySelector("input"); input.value = id;
108
+ input.checked = installed || S.selectedPacks.has(id); input.disabled = installed;
109
+ label.querySelector("b").textContent = `${p.label} · ${p.size}`;
110
+ label.querySelector(".card-body span").textContent = p.hint;
111
+ if (installed) { const badge = document.createElement("span"); badge.className = "badge"; badge.textContent = "installed"; label.append(badge); }
112
+ input.addEventListener("change", () => { if (input.checked) { S.selectedPacks.add(id); startDownload(id); } else S.selectedPacks.delete(id); });
113
+ box.append(label);
114
+ }
115
+ }
116
+
117
+ // ── downloads ──
118
+ async function startDownload(id) {
119
+ try { await api("download", { id }); pollJobs(); } catch (e) { fatal(e.message); }
120
+ }
121
+ function renderJob(container, id, job) {
122
+ let el = document.getElementById(`job-${id}`);
123
+ if (!el) {
124
+ el = document.createElement("div"); el.className = "job"; el.id = `job-${id}`;
125
+ el.innerHTML = `<div class="job-head"><span></span><span></span></div><div class="bar" role="progressbar" aria-valuemin="0" aria-valuemax="100"><i></i></div>`;
126
+ container.append(el);
127
+ }
128
+ el.className = "job" + (job.state === "done" ? " done" : job.state === "error" ? " error" : "");
129
+ el.querySelector(".job-head span").textContent = job.state === "error" ? `${job.label}: failed (${job.error})` : job.state === "done" ? `${job.label}: ready` : `Downloading ${job.label}${job.phase === "unpacking" ? " (unpacking)" : ""}`;
130
+ el.querySelector(".job-head span:last-child").textContent = `${job.pct || 0}%`;
131
+ el.querySelector(".bar").setAttribute("aria-valuenow", String(job.pct || 0));
132
+ el.querySelector(".bar i").style.width = `${job.pct || 0}%`;
133
+ }
134
+ async function pollJobs() {
135
+ clearTimeout(S.pollTimer);
136
+ try {
137
+ const { jobs, installedPacks, kokoroInstalled } = await api("jobs");
138
+ S.state.installedPacks = installedPacks; S.state.kokoro.installed = kokoroInstalled;
139
+ for (const [id, job] of Object.entries(jobs)) {
140
+ if (id === "kokoro") {
141
+ $("kokoroJob").hidden = false;
142
+ $("kokoroJob").className = "job" + (job.state === "done" ? " done" : job.state === "error" ? " error" : "");
143
+ $("kokoroLabel").textContent = job.state === "done" ? "Kokoro voice ready" : job.state === "error" ? `Kokoro voice failed: ${job.error}` : `Downloading the Kokoro voice (${S.state.kokoro.size})`;
144
+ $("kokoroPct").textContent = `${job.pct || 0}%`;
145
+ $("kokoroBar").style.width = `${job.pct || 0}%`;
146
+ } else renderJob($("packJobs"), id, job);
147
+ }
148
+ if (Object.values(jobs).some((j) => j.state === "running")) S.pollTimer = setTimeout(pollJobs, 700);
149
+ else if (S.step === "review") renderSummary();
150
+ } catch { S.pollTimer = setTimeout(pollJobs, 2000); }
151
+ }
152
+
153
+ // ── 4 · access ──
154
+ function syncAudience() {
155
+ const others = radio("audience") === "others";
156
+ $("passwordField").hidden = !others;
157
+ $("keepPassRow").hidden = !S.state.current.hasPassword;
158
+ $("newPassRow").hidden = S.state.current.hasPassword && $("keepPass").checked;
159
+ }
160
+
161
+ // ── 5 · review & launch ──
162
+ function answers() {
163
+ const mode = radio("mode"), voice = radio("voice"), audience = radio("audience");
164
+ return {
165
+ mode,
166
+ openrouterKey: mode === "mock" ? null : S.state.current.openrouterKey && $("keepKey").checked ? "keep" : $("orKey").value.trim(),
167
+ voice,
168
+ elevenKey: voice === "elevenlabs" ? (S.state.current.elevenKey && $("keepEleven").checked ? "keep" : $("elKey").value.trim()) : undefined,
169
+ elevenVoice: voice === "elevenlabs" ? $("elVoice").value.trim() || null : undefined,
170
+ audience,
171
+ password: audience === "others" ? (S.state.current.hasPassword && $("keepPass").checked ? "keep" : $("password").value) : undefined,
172
+ proxy: $("proxy").checked,
173
+ port: Number($("port").value),
174
+ };
175
+ }
176
+ function renderSummary() {
177
+ const a = answers(), packs = S.state.installedPacks.map((id) => S.state.packs[id].label);
178
+ // Wait only for downloads that match the current choices (a voice you switched away from doesn't count).
179
+ const busy = (el) => el && !el.hidden && !el.classList.contains("done") && !el.classList.contains("error");
180
+ const running = (a.voice === "kokoro" && busy($("kokoroJob")) ? 1 : 0)
181
+ + [...S.selectedPacks].filter((id) => busy(document.getElementById(`job-${id}`))).length;
182
+ const rows = [
183
+ ["AI replies", a.mode === "mock" ? "MOCK mode (canned replies)" : "OpenRouter"],
184
+ ["Voice", S.state.voices[a.voice].label],
185
+ ["Speech recognition", packs.length ? packs.join(", ") : "Chrome's built-in recognition"],
186
+ ["Access", a.audience === "me" ? "This computer only" : "Password protected"],
187
+ ["Address", `http://localhost:${a.port}/app`],
188
+ ];
189
+ if (a.audience === "others" && a.password !== "keep") rows.splice(4, 0, ["Password", a.password]);
190
+ const dl = $("summary"); dl.innerHTML = "";
191
+ for (const [k, v] of rows) { const dt = document.createElement("dt"), dd = document.createElement("dd"); dt.textContent = k; dd.textContent = v; dl.append(dt, dd); }
192
+ $("launch").disabled = running > 0;
193
+ setStatus($("launchStatus"), running ? "Waiting for downloads to finish…" : "");
194
+ }
195
+ async function launch() {
196
+ $("launch").disabled = true;
197
+ setStatus($("launchStatus"), "Saving…");
198
+ try {
199
+ const saved = await api("save", { answers: answers() });
200
+ await api("finish", {});
201
+ show("launching");
202
+ const url = saved.url;
203
+ // Wait until *Ava* answers. On the same port the setup server may still be
204
+ // closing, so read the reply and make sure it's Ava's health report. On a
205
+ // different port anything that answers is Ava (no-cors can't read it).
206
+ const samePort = String(saved.port) === (location.port || "80");
207
+ for (let i = 0; i < 120; i++) {
208
+ try {
209
+ if (samePort) {
210
+ const r = await fetch("/api/health", { cache: "no-store" });
211
+ if (r.ok && "models" in (await r.json())) { location.replace(url); return; }
212
+ } else {
213
+ await fetch(`http://localhost:${saved.port}/api/health`, { mode: "no-cors", cache: "no-store" });
214
+ location.replace(url); return;
215
+ }
216
+ } catch {}
217
+ await new Promise((r) => setTimeout(r, 500));
218
+ }
219
+ $("launchMsg").textContent = `Ava is taking a while. Open ${url} when it's ready, or check the terminal.`;
220
+ } catch (e) {
221
+ setStatus($("launchStatus"), `✗ ${e.message}`, "err");
222
+ $("launch").disabled = false;
223
+ }
224
+ }
225
+
226
+ // ── boot ──
227
+ async function boot() {
228
+ if (!TOKEN) return fatal("This page needs the link printed by `npx ava-engine setup --web`.");
229
+ try { S.state = await api("state"); } catch (e) { return fatal(e.message); }
230
+ history.replaceState(null, "", "/setup"); // drop the token from the address bar (it stays in memory)
231
+ const c = S.state.current;
232
+ $("configFile").textContent = S.state.configFile;
233
+ if (c.openrouterKey) { $("currentKey").textContent = c.openrouterKey; }
234
+ if (c.elevenKey) $("currentEleven").textContent = c.elevenKey;
235
+ $("elVoice").value = c.elevenVoice;
236
+ document.querySelector(`input[name="audience"][value="${c.audience}"]`).checked = true;
237
+ $("password").value = S.state.suggestedPassword;
238
+ $("proxy").checked = c.proxy;
239
+ $("port").value = c.port;
240
+ renderVoices(); renderPacks();
241
+ syncMode(); syncVoice(); syncAudience();
242
+
243
+ document.querySelectorAll('input[name="mode"]').forEach((i) => i.addEventListener("change", syncMode));
244
+ $("keepKey").addEventListener("change", syncMode);
245
+ $("voiceCards").addEventListener("change", syncVoice);
246
+ $("keepEleven").addEventListener("change", syncVoice);
247
+ document.querySelectorAll('input[name="audience"]').forEach((i) => i.addEventListener("change", syncAudience));
248
+ $("keepPass").addEventListener("change", syncAudience);
249
+ $("orKey").addEventListener("input", () => { S.keyOk = false; setStatus($("keyStatus"), ""); });
250
+ $("elKey").addEventListener("input", () => { S.elevenOk = false; setStatus($("elevenStatus"), ""); });
251
+ $("checkKey").addEventListener("click", () => checkKey("openrouter", $("orKey"), $("keyStatus"), "keyOk"));
252
+ $("checkEleven").addEventListener("click", () => checkKey("elevenlabs", $("elKey"), $("elevenStatus"), "elevenOk"));
253
+ $("orKey").addEventListener("keydown", (e) => { if (e.key === "Enter") $("checkKey").click(); });
254
+ $("regen").addEventListener("click", async () => { $("password").value = (await api("password")).password; });
255
+ $("copyPass").addEventListener("click", async () => { try { await navigator.clipboard.writeText($("password").value); $("copyPass").textContent = "Copied"; setTimeout(() => { $("copyPass").textContent = "Copy"; }, 1400); } catch {} });
256
+ $("launch").addEventListener("click", launch);
257
+ show("ai");
258
+ pollJobs();
259
+ }
260
+ // A fresh setup link opened in this same tab only changes the #token, which
261
+ // doesn't reload the page; reload so the new token is used.
262
+ window.addEventListener("hashchange", () => { if (/token=/.test(location.hash)) location.reload(); });
263
+ boot();
264
+ })();
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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();