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,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,94 @@
1
+ @font-face { font-family: "Inter Tight"; src: url(/fonts/inter-tight-var.woff2) format("woff2"); font-weight: 100 900; font-display: swap; }
2
+ @font-face { font-family: "Inter"; src: url(/fonts/inter-var.woff2) format("woff2"); font-weight: 100 900; font-display: swap; }
3
+ @font-face { font-family: "Plex Mono"; src: url(/fonts/plex-mono-400.woff2) format("woff2"); font-weight: 400; font-display: swap; }
4
+
5
+ :root {
6
+ --ink: #0A0A0C; --ink2: #4A4B55; --meta: #686975; --line: #E6E6EB; --soft: #F5F5F7; --card: #FFFFFF;
7
+ --accent: #4F46E5; --accent-tint: rgba(79,70,229,0.08); --ok: #15803D; --ok-tint: #ECFDF3; --err: #B42318; --err-tint: #FEF3F2;
8
+ --radius: 14px;
9
+ }
10
+ * { box-sizing: border-box; }
11
+ [hidden] { display: none !important; } /* author display rules (e.g. .field) must not un-hide things */
12
+ html, body { margin: 0; background: var(--soft); color: var(--ink); font: 16px/1.5 Inter, -apple-system, "Segoe UI", sans-serif; }
13
+ a { color: var(--accent); }
14
+ code { font: 13px "Plex Mono", ui-monospace, monospace; background: var(--soft); padding: 1px 5px; border-radius: 5px; }
15
+
16
+ .shell { display: grid; grid-template-columns: 300px 1fr; min-height: 100vh; min-height: 100dvh; }
17
+ .rail { background: var(--ink); color: #fff; padding: 36px 30px; display: flex; flex-direction: column; gap: 36px; }
18
+ .brand { display: flex; align-items: center; gap: 12px; font: 600 20px "Inter Tight", sans-serif; }
19
+ .brand svg { width: 34px; height: 34px; }
20
+ .brand svg rect { fill: #fff; } .brand svg path { stroke: #0A0A0C; }
21
+ .steps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; counter-reset: s; }
22
+ .steps li { counter-increment: s; display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 10px; color: #9D9DAB; font-weight: 500; }
23
+ .steps li::before { content: counter(s); width: 26px; height: 26px; flex: none; display: grid; place-items: center; border-radius: 50%; border: 1.5px solid #3A3A46; font: 500 13px "Plex Mono", monospace; }
24
+ .steps li.done { color: #D6D6E0; } .steps li.done::before { content: "✓"; background: #2B2B36; border-color: #2B2B36; }
25
+ .steps li.current { color: #fff; background: #1C1C24; } .steps li.current::before { border-color: #A5A0FF; color: #A5A0FF; }
26
+ .rail-note { margin-top: auto; font-size: 13px; color: #9D9DAB; }
27
+ .rail-note code { background: #1C1C24; color: #D6D6E0; }
28
+
29
+ .panel { padding: 56px clamp(20px, 6vw, 88px); max-width: 860px; width: 100%; }
30
+ .eyebrow { margin: 0 0 8px; font: 500 13px "Plex Mono", monospace; letter-spacing: .14em; text-transform: uppercase; color: var(--accent); }
31
+ h1 { margin: 0 0 12px; font: 600 clamp(28px, 4vw, 40px)/1.1 "Inter Tight", sans-serif; letter-spacing: -0.025em; }
32
+ .lead { margin: 0 0 28px; color: var(--ink2); font-size: 17px; max-width: 640px; }
33
+
34
+ .cards { display: grid; gap: 12px; margin-bottom: 24px; }
35
+ .card { position: relative; display: flex; gap: 14px; align-items: flex-start; padding: 18px 20px; background: var(--card); border: 1.5px solid var(--line); border-radius: var(--radius); cursor: pointer; transition: border-color .15s, box-shadow .15s; }
36
+ .card:hover { border-color: #CFCFD8; }
37
+ .card:has(input:checked) { border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-tint); }
38
+ .card:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 3px; }
39
+ .card input { margin: 4px 0 0; width: 18px; height: 18px; accent-color: var(--accent); flex: none; }
40
+ .card-body { display: flex; flex-direction: column; gap: 3px; flex: 1; }
41
+ .card-body b { font: 600 17px "Inter Tight", sans-serif; }
42
+ .card-body span { color: var(--ink2); font-size: 14.5px; }
43
+ .card:has(.badge) .card-body { padding-right: 84px; }
44
+ .badge { position: absolute; top: 16px; right: 18px; font: 500 12px "Plex Mono", monospace; color: var(--ok); background: var(--ok-tint); padding: 2px 8px; border-radius: 99px; }
45
+
46
+ .field { display: flex; flex-direction: column; gap: 8px; margin-bottom: 24px; }
47
+ .field label { font-weight: 600; font-size: 15px; }
48
+ .opt { font-weight: 400; color: var(--meta); }
49
+ .row { display: flex; gap: 8px; flex-wrap: wrap; }
50
+ input[type=text], input[type=password], input[type=number] { flex: 1; min-width: 200px; height: 46px; padding: 0 14px; border: 1.5px solid var(--line); border-radius: 10px; font: 15px "Plex Mono", monospace; background: #fff; color: var(--ink); }
51
+ input:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 4px var(--accent-tint); }
52
+ input.short { max-width: 160px; flex: none; }
53
+ .hint { margin: 0; color: var(--meta); font-size: 14px; }
54
+ .keep label, .check { font-weight: 400 !important; display: flex; gap: 10px; align-items: center; font-size: 15px; }
55
+ .keep input, .check input { width: 18px; height: 18px; accent-color: var(--accent); }
56
+
57
+ .status { margin: 0; min-height: 22px; font-size: 14.5px; }
58
+ .status.ok { color: var(--ok); } .status.err { color: var(--err); }
59
+ .banner { padding: 14px 18px; border-radius: 12px; margin-bottom: 24px; }
60
+ .banner.error { background: var(--err-tint); color: var(--err); }
61
+
62
+ .job { background: #fff; border: 1.5px solid var(--line); border-radius: 12px; padding: 14px 16px; margin-bottom: 12px; }
63
+ .job-head { display: flex; justify-content: space-between; font-size: 14.5px; margin-bottom: 10px; font-weight: 500; }
64
+ .job-head span:last-child { font-family: "Plex Mono", monospace; color: var(--meta); }
65
+ .bar { height: 8px; background: var(--soft); border-radius: 99px; overflow: hidden; }
66
+ .bar i { display: block; height: 100%; width: 0; background: var(--accent); border-radius: 99px; transition: width .3s; }
67
+ .job.done .bar i { background: var(--ok); } .job.error .bar i { background: var(--err); }
68
+
69
+ .summary { display: grid; grid-template-columns: max-content 1fr; gap: 14px 28px; background: #fff; border: 1.5px solid var(--line); border-radius: var(--radius); padding: 22px 24px; margin: 0 0 20px; }
70
+ .summary dt { color: var(--meta); font-size: 14.5px; } .summary dd { margin: 0; font-weight: 500; word-break: break-word; }
71
+
72
+ .nav { display: flex; justify-content: space-between; align-items: center; margin-top: 32px; gap: 12px; }
73
+ .btn { height: 46px; padding: 0 22px; border-radius: 99px; font: 600 15px "Inter Tight", sans-serif; cursor: pointer; border: 1.5px solid transparent; }
74
+ .btn.primary { background: var(--ink); color: #fff; }
75
+ .btn.primary:hover { background: #24242C; }
76
+ .btn.primary:disabled { background: #B9B9C3; cursor: not-allowed; }
77
+ .btn.ghost { background: #fff; color: var(--ink); border-color: var(--line); }
78
+ .btn.ghost:hover { border-color: #CFCFD8; }
79
+ .btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
80
+
81
+ .launching { text-align: center; padding-top: 12vh; }
82
+ .launching .lead { margin-inline: auto; }
83
+ .orb { width: 120px; height: 120px; margin: 0 auto 36px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #C9C4FF 0%, #8B7CFF 28%, #5B4FF0 60%, #3B2FC4 100%); box-shadow: 0 0 80px rgba(124,108,246,.55); animation: pulse 1.6s ease-in-out infinite; }
84
+ @keyframes pulse { 50% { transform: scale(1.06); box-shadow: 0 0 120px rgba(124,108,246,.75); } }
85
+ @media (prefers-reduced-motion: reduce) { .orb { animation: none; } .bar i { transition: none; } }
86
+
87
+ @media (max-width: 760px) {
88
+ .shell { grid-template-columns: 1fr; }
89
+ .rail { padding: 20px; gap: 16px; }
90
+ .steps { flex-direction: row; overflow-x: auto; gap: 4px; }
91
+ .steps li { padding: 6px 8px; font-size: 0; gap: 0; } .steps li.current { font-size: 14px; gap: 8px; }
92
+ .rail-note { display: none; }
93
+ .panel { padding: 32px 16px; }
94
+ }
@@ -0,0 +1,129 @@
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>Set up Ava</title>
7
+ <link rel="icon" href="/favicon.svg" type="image/svg+xml">
8
+ <link rel="stylesheet" href="/setup.css">
9
+ <script src="/setup.js" defer></script>
10
+ </head>
11
+ <body>
12
+ <div class="shell">
13
+ <aside class="rail" aria-label="Setup progress">
14
+ <div class="brand">
15
+ <svg viewBox="0 0 24 24" aria-hidden="true"><rect width="24" height="24" rx="6" fill="#0A0A0C"/><path transform="translate(2.67 8.4) scale(.3)" d="M0 24L9.5 0L19 24M21.6 0L31.1 24L40.6 0M43.2 24L52.7 0L62.2 24" stroke="#fff" fill="none" stroke-width="3.8" stroke-linecap="round" stroke-linejoin="round"/></svg>
16
+ <span>Ava Engine</span>
17
+ </div>
18
+ <ol class="steps" id="steps">
19
+ <li data-step="ai">AI replies</li>
20
+ <li data-step="voice">Voice</li>
21
+ <li data-step="speech">Speech recognition</li>
22
+ <li data-step="access">Access</li>
23
+ <li data-step="review">Launch</li>
24
+ </ol>
25
+ <p class="rail-note">Settings are saved on this computer only, in <code id="configFile">~/.ava-engine/.env</code>.</p>
26
+ </aside>
27
+
28
+ <main class="panel">
29
+ <div id="fatal" class="banner error" role="alert" hidden></div>
30
+
31
+ <!-- 1 · AI -->
32
+ <section class="step" data-step="ai" aria-labelledby="h-ai">
33
+ <p class="eyebrow">Step 1 of 5</p>
34
+ <h1 id="h-ai">How should Ava think?</h1>
35
+ <p class="lead">Ava's AI runs through <a href="https://openrouter.ai" target="_blank" rel="noopener">OpenRouter</a>: one key for every model. You can also look around first with canned replies.</p>
36
+ <div class="cards" role="radiogroup" aria-label="AI mode">
37
+ <label class="card"><input type="radio" name="mode" value="full" checked><span class="card-body"><b>With real AI</b><span>Recommended. Needs an OpenRouter key.</span></span></label>
38
+ <label class="card"><input type="radio" name="mode" value="mock"><span class="card-body"><b>Just look around first</b><span>No key. Canned replies; voice, screen sharing and everything else work.</span></span></label>
39
+ </div>
40
+ <div class="field" id="keyField">
41
+ <div id="keepKeyRow" class="keep" hidden><label><input type="checkbox" id="keepKey" checked> Keep my current key <code id="currentKey"></code></label></div>
42
+ <div id="newKeyRow">
43
+ <label for="orKey">OpenRouter API key</label>
44
+ <div class="row">
45
+ <input id="orKey" type="password" autocomplete="off" spellcheck="false" placeholder="sk-or-v1-…">
46
+ <button type="button" class="btn ghost" id="checkKey">Check key</button>
47
+ </div>
48
+ <p class="hint">Create one at <a href="https://openrouter.ai/keys" target="_blank" rel="noopener">openrouter.ai/keys</a>, and set a credit limit on it.</p>
49
+ <p class="status" id="keyStatus" role="status" aria-live="polite"></p>
50
+ </div>
51
+ </div>
52
+ <div class="nav"><span></span><button type="button" class="btn primary" data-next>Continue</button></div>
53
+ </section>
54
+
55
+ <!-- 2 · Voice -->
56
+ <section class="step" data-step="voice" aria-labelledby="h-voice" hidden>
57
+ <p class="eyebrow">Step 2 of 5</p>
58
+ <h1 id="h-voice">Which voice should Ava speak with?</h1>
59
+ <div class="cards" id="voiceCards" role="radiogroup" aria-label="Voice"></div>
60
+ <div class="job" id="kokoroJob" hidden>
61
+ <div class="job-head"><span id="kokoroLabel">Downloading the Kokoro voice</span><span id="kokoroPct">0%</span></div>
62
+ <div class="bar"><i id="kokoroBar"></i></div>
63
+ </div>
64
+ <div class="field" id="elevenField" hidden>
65
+ <div id="keepElevenRow" class="keep" hidden><label><input type="checkbox" id="keepEleven" checked> Keep my current ElevenLabs key <code id="currentEleven"></code></label></div>
66
+ <div id="newElevenRow">
67
+ <label for="elKey">ElevenLabs API key</label>
68
+ <div class="row"><input id="elKey" type="password" autocomplete="off" spellcheck="false"><button type="button" class="btn ghost" id="checkEleven">Check key</button></div>
69
+ <p class="status" id="elevenStatus" role="status" aria-live="polite"></p>
70
+ </div>
71
+ <label for="elVoice">Voice ID <span class="opt">optional, defaults to Matilda</span></label>
72
+ <input id="elVoice" type="text" autocomplete="off" spellcheck="false" placeholder="XrExE9yKIg1WjnnlVkGX">
73
+ </div>
74
+ <div class="nav"><button type="button" class="btn ghost" data-back>Back</button><button type="button" class="btn primary" data-next>Continue</button></div>
75
+ </section>
76
+
77
+ <!-- 3 · Speech -->
78
+ <section class="step" data-step="speech" aria-labelledby="h-speech" hidden>
79
+ <p class="eyebrow">Step 3 of 5</p>
80
+ <h1 id="h-speech">Offline speech recognition</h1>
81
+ <p class="lead">Turns your customers' speech into text on this computer, free. Skip it to use Chrome's built-in recognition instead.</p>
82
+ <div class="cards" id="packCards" aria-label="Speech models"></div>
83
+ <div id="packJobs"></div>
84
+ <div class="nav"><button type="button" class="btn ghost" data-back>Back</button><button type="button" class="btn primary" data-next>Continue</button></div>
85
+ </section>
86
+
87
+ <!-- 4 · Access -->
88
+ <section class="step" data-step="access" aria-labelledby="h-access" hidden>
89
+ <p class="eyebrow">Step 4 of 5</p>
90
+ <h1 id="h-access">Who will use this Ava?</h1>
91
+ <div class="cards" role="radiogroup" aria-label="Who will use Ava">
92
+ <label class="card"><input type="radio" name="audience" value="me" checked><span class="card-body"><b>Just me, on this computer</b><span>Safest. Reachable only at localhost.</span></span></label>
93
+ <label class="card"><input type="radio" name="audience" value="others"><span class="card-body"><b>My team or customers</b><span>Over a network or the internet. Needs a password, because every call spends your API credit.</span></span></label>
94
+ </div>
95
+ <div class="field" id="passwordField" hidden>
96
+ <div id="keepPassRow" class="keep" hidden><label><input type="checkbox" id="keepPass" checked> Keep my current password</label></div>
97
+ <div id="newPassRow">
98
+ <label for="password">Access password</label>
99
+ <div class="row"><input id="password" type="text" autocomplete="new-password" spellcheck="false" minlength="10"><button type="button" class="btn ghost" id="regen">New one</button><button type="button" class="btn ghost" id="copyPass">Copy</button></div>
100
+ <p class="hint">At least 10 characters. You'll type it when you open Ava.</p>
101
+ </div>
102
+ <label class="check"><input type="checkbox" id="proxy"> It will run behind a proxy or host (Fly.io, Cloudflare, nginx, Render…)</label>
103
+ </div>
104
+ <div class="field">
105
+ <label for="port">Port</label>
106
+ <input id="port" type="number" min="1" max="65535" inputmode="numeric" class="short">
107
+ </div>
108
+ <div class="nav"><button type="button" class="btn ghost" data-back>Back</button><button type="button" class="btn primary" data-next>Continue</button></div>
109
+ </section>
110
+
111
+ <!-- 5 · Review -->
112
+ <section class="step" data-step="review" aria-labelledby="h-review" hidden>
113
+ <p class="eyebrow">Step 5 of 5</p>
114
+ <h1 id="h-review">Ready to launch</h1>
115
+ <dl class="summary" id="summary"></dl>
116
+ <p class="status" id="launchStatus" role="status" aria-live="polite"></p>
117
+ <div class="nav"><button type="button" class="btn ghost" data-back>Back</button><button type="button" class="btn primary" id="launch">Launch Ava</button></div>
118
+ </section>
119
+
120
+ <!-- launching -->
121
+ <section class="step launching" data-step="launching" aria-live="polite" hidden>
122
+ <div class="orb" aria-hidden="true"></div>
123
+ <h1>Starting Ava…</h1>
124
+ <p class="lead" id="launchMsg">This takes a few seconds. The app opens here when it's ready.</p>
125
+ </section>
126
+ </main>
127
+ </div>
128
+ </body>
129
+ </html>