ava-engine 0.1.0 → 0.3.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 +11 -0
- package/README.md +14 -10
- package/lib/asr.mjs +5 -1
- package/lib/cli/apply.mjs +58 -0
- package/lib/cli/main.mjs +77 -9
- package/lib/cli/openrouter-oauth.mjs +87 -0
- package/lib/cli/setup-server.mjs +222 -0
- package/lib/cli/setup.mjs +118 -80
- package/lib/kokoro-model.mjs +51 -0
- package/lib/mock.mjs +78 -0
- package/lib/openrouter.mjs +5 -0
- package/lib/ours.mjs +40 -28
- package/lib/platforms.mjs +15 -1
- package/lib/playbooks.mjs +111 -0
- package/lib/spend.mjs +59 -0
- package/lib/state.mjs +14 -5
- package/lib/tts.mjs +4 -4
- package/lib/webhooks.mjs +68 -0
- package/package.json +10 -2
- package/public/app.js +2 -0
- package/public/index.html +2 -0
- package/public/setup.css +97 -0
- package/public/setup.html +135 -0
- package/public/setup.js +289 -0
- package/public/style.css +4 -0
- package/server.mjs +24 -7
|
@@ -0,0 +1,135 @@
|
|
|
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
|
+
<div class="connect">
|
|
44
|
+
<button type="button" class="btn primary" id="connectOr">Connect OpenRouter</button>
|
|
45
|
+
<p class="hint">Sign in to OpenRouter and approve, and Ava gets its own key. Nothing to copy.</p>
|
|
46
|
+
<p class="status" id="connectStatus" role="status" aria-live="polite"></p>
|
|
47
|
+
</div>
|
|
48
|
+
<p class="or"><span>or paste a key</span></p>
|
|
49
|
+
<label for="orKey">OpenRouter API key</label>
|
|
50
|
+
<div class="row">
|
|
51
|
+
<input id="orKey" type="password" autocomplete="off" spellcheck="false" placeholder="sk-or-v1-…">
|
|
52
|
+
<button type="button" class="btn ghost" id="checkKey">Check key</button>
|
|
53
|
+
</div>
|
|
54
|
+
<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>
|
|
55
|
+
<p class="status" id="keyStatus" role="status" aria-live="polite"></p>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="nav"><span></span><button type="button" class="btn primary" data-next>Continue</button></div>
|
|
59
|
+
</section>
|
|
60
|
+
|
|
61
|
+
<!-- 2 · Voice -->
|
|
62
|
+
<section class="step" data-step="voice" aria-labelledby="h-voice" hidden>
|
|
63
|
+
<p class="eyebrow">Step 2 of 5</p>
|
|
64
|
+
<h1 id="h-voice">Which voice should Ava speak with?</h1>
|
|
65
|
+
<div class="cards" id="voiceCards" role="radiogroup" aria-label="Voice"></div>
|
|
66
|
+
<div class="job" id="kokoroJob" hidden>
|
|
67
|
+
<div class="job-head"><span id="kokoroLabel">Downloading the Kokoro voice</span><span id="kokoroPct">0%</span></div>
|
|
68
|
+
<div class="bar"><i id="kokoroBar"></i></div>
|
|
69
|
+
</div>
|
|
70
|
+
<div class="field" id="elevenField" hidden>
|
|
71
|
+
<div id="keepElevenRow" class="keep" hidden><label><input type="checkbox" id="keepEleven" checked> Keep my current ElevenLabs key <code id="currentEleven"></code></label></div>
|
|
72
|
+
<div id="newElevenRow">
|
|
73
|
+
<label for="elKey">ElevenLabs API key</label>
|
|
74
|
+
<div class="row"><input id="elKey" type="password" autocomplete="off" spellcheck="false"><button type="button" class="btn ghost" id="checkEleven">Check key</button></div>
|
|
75
|
+
<p class="status" id="elevenStatus" role="status" aria-live="polite"></p>
|
|
76
|
+
</div>
|
|
77
|
+
<label for="elVoice">Voice ID <span class="opt">optional, defaults to Matilda</span></label>
|
|
78
|
+
<input id="elVoice" type="text" autocomplete="off" spellcheck="false" placeholder="XrExE9yKIg1WjnnlVkGX">
|
|
79
|
+
</div>
|
|
80
|
+
<div class="nav"><button type="button" class="btn ghost" data-back>Back</button><button type="button" class="btn primary" data-next>Continue</button></div>
|
|
81
|
+
</section>
|
|
82
|
+
|
|
83
|
+
<!-- 3 · Speech -->
|
|
84
|
+
<section class="step" data-step="speech" aria-labelledby="h-speech" hidden>
|
|
85
|
+
<p class="eyebrow">Step 3 of 5</p>
|
|
86
|
+
<h1 id="h-speech">Offline speech recognition</h1>
|
|
87
|
+
<p class="lead">Turns your customers' speech into text on this computer, free. Skip it to use Chrome's built-in recognition instead.</p>
|
|
88
|
+
<div class="cards" id="packCards" aria-label="Speech models"></div>
|
|
89
|
+
<div id="packJobs"></div>
|
|
90
|
+
<div class="nav"><button type="button" class="btn ghost" data-back>Back</button><button type="button" class="btn primary" data-next>Continue</button></div>
|
|
91
|
+
</section>
|
|
92
|
+
|
|
93
|
+
<!-- 4 · Access -->
|
|
94
|
+
<section class="step" data-step="access" aria-labelledby="h-access" hidden>
|
|
95
|
+
<p class="eyebrow">Step 4 of 5</p>
|
|
96
|
+
<h1 id="h-access">Who will use this Ava?</h1>
|
|
97
|
+
<div class="cards" role="radiogroup" aria-label="Who will use Ava">
|
|
98
|
+
<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>
|
|
99
|
+
<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>
|
|
100
|
+
</div>
|
|
101
|
+
<div class="field" id="passwordField" hidden>
|
|
102
|
+
<div id="keepPassRow" class="keep" hidden><label><input type="checkbox" id="keepPass" checked> Keep my current password</label></div>
|
|
103
|
+
<div id="newPassRow">
|
|
104
|
+
<label for="password">Access password</label>
|
|
105
|
+
<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>
|
|
106
|
+
<p class="hint">At least 10 characters. You'll type it when you open Ava.</p>
|
|
107
|
+
</div>
|
|
108
|
+
<label class="check"><input type="checkbox" id="proxy"> It will run behind a proxy or host (Fly.io, Cloudflare, nginx, Render…)</label>
|
|
109
|
+
</div>
|
|
110
|
+
<div class="field">
|
|
111
|
+
<label for="port">Port</label>
|
|
112
|
+
<input id="port" type="number" min="1" max="65535" inputmode="numeric" class="short">
|
|
113
|
+
</div>
|
|
114
|
+
<div class="nav"><button type="button" class="btn ghost" data-back>Back</button><button type="button" class="btn primary" data-next>Continue</button></div>
|
|
115
|
+
</section>
|
|
116
|
+
|
|
117
|
+
<!-- 5 · Review -->
|
|
118
|
+
<section class="step" data-step="review" aria-labelledby="h-review" hidden>
|
|
119
|
+
<p class="eyebrow">Step 5 of 5</p>
|
|
120
|
+
<h1 id="h-review">Ready to launch</h1>
|
|
121
|
+
<dl class="summary" id="summary"></dl>
|
|
122
|
+
<p class="status" id="launchStatus" role="status" aria-live="polite"></p>
|
|
123
|
+
<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>
|
|
124
|
+
</section>
|
|
125
|
+
|
|
126
|
+
<!-- launching -->
|
|
127
|
+
<section class="step launching" data-step="launching" aria-live="polite" hidden>
|
|
128
|
+
<div class="orb" aria-hidden="true"></div>
|
|
129
|
+
<h1>Starting Ava…</h1>
|
|
130
|
+
<p class="lead" id="launchMsg">This takes a few seconds. The app opens here when it's ready.</p>
|
|
131
|
+
</section>
|
|
132
|
+
</main>
|
|
133
|
+
</div>
|
|
134
|
+
</body>
|
|
135
|
+
</html>
|
package/public/setup.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
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"), "Connect OpenRouter or check a 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
|
+
// Connect OpenRouter: sign-in opens in a new tab; the setup server receives the
|
|
60
|
+
// key and keeps it. This page only learns that it worked, plus a masked key.
|
|
61
|
+
async function connectOpenRouter() {
|
|
62
|
+
const tab = window.open("about:blank", "_blank"); // open now, while the click still counts, so it isn't blocked
|
|
63
|
+
setStatus($("connectStatus"), "Opening OpenRouter…");
|
|
64
|
+
try {
|
|
65
|
+
const { url } = await api("openrouter/start", {});
|
|
66
|
+
if (tab) { tab.opener = null; tab.location = url; } else location.assign(url);
|
|
67
|
+
setStatus($("connectStatus"), "Waiting for you to approve in the OpenRouter tab…");
|
|
68
|
+
clearInterval(S.connectTimer);
|
|
69
|
+
S.connectTimer = setInterval(async () => {
|
|
70
|
+
let r; try { r = await api("openrouter/status"); } catch { return; }
|
|
71
|
+
if (r.status === "connected") {
|
|
72
|
+
clearInterval(S.connectTimer);
|
|
73
|
+
S.connected = true; S.keyOk = true; $("orKey").value = "";
|
|
74
|
+
setStatus($("connectStatus"), `✓ Connected (${r.key}). Set a credit limit on it at openrouter.ai/keys.`, "ok");
|
|
75
|
+
setStatus($("keyStatus"), "");
|
|
76
|
+
} else if (r.status === "error") {
|
|
77
|
+
clearInterval(S.connectTimer);
|
|
78
|
+
setStatus($("connectStatus"), `✗ ${r.error}`, "err");
|
|
79
|
+
}
|
|
80
|
+
}, 1500);
|
|
81
|
+
} catch (e) { tab?.close(); setStatus($("connectStatus"), `✗ ${e.message}`, "err"); }
|
|
82
|
+
}
|
|
83
|
+
function syncMode() {
|
|
84
|
+
const full = radio("mode") === "full";
|
|
85
|
+
$("keyField").hidden = !full;
|
|
86
|
+
const hasKey = !!S.state.current.openrouterKey;
|
|
87
|
+
$("keepKeyRow").hidden = !hasKey;
|
|
88
|
+
$("newKeyRow").hidden = hasKey && $("keepKey").checked;
|
|
89
|
+
}
|
|
90
|
+
async function checkKey(service, input, statusEl, okFlag) {
|
|
91
|
+
const key = input.value.trim();
|
|
92
|
+
setStatus(statusEl, "Checking…");
|
|
93
|
+
try {
|
|
94
|
+
const r = await api("check", { service, key });
|
|
95
|
+
S[okFlag] = !!r.ok || !!r.offline;
|
|
96
|
+
if (r.ok) setStatus(statusEl, `✓ Key works${r.label ? ` (${r.label})` : ""}${r.limit != null ? `, credit limit $${r.limit}` : ""}`, "ok");
|
|
97
|
+
else setStatus(statusEl, r.offline ? `${r.error}. It will be saved anyway.` : `✗ ${r.error}`, r.offline ? "" : "err");
|
|
98
|
+
} catch (e) { S[okFlag] = false; setStatus(statusEl, `✗ ${e.message}`, "err"); }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── 2 · voice ──
|
|
102
|
+
function renderVoices() {
|
|
103
|
+
const v = S.state.voices, cur = S.state.current.voice;
|
|
104
|
+
$("voiceCards").innerHTML = "";
|
|
105
|
+
for (const [id, info] of Object.entries(v)) {
|
|
106
|
+
const label = document.createElement("label"); label.className = "card";
|
|
107
|
+
label.innerHTML = `<input type="radio" name="voice"><span class="card-body"><b></b><span></span></span>`;
|
|
108
|
+
const input = label.querySelector("input"); input.value = id; input.checked = id === cur;
|
|
109
|
+
label.querySelector("b").textContent = info.label;
|
|
110
|
+
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) + ".";
|
|
111
|
+
$("voiceCards").append(label);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function syncVoice() {
|
|
115
|
+
const voice = radio("voice");
|
|
116
|
+
$("elevenField").hidden = voice !== "elevenlabs";
|
|
117
|
+
const hasEl = !!S.state.current.elevenKey;
|
|
118
|
+
$("keepElevenRow").hidden = !hasEl;
|
|
119
|
+
$("newElevenRow").hidden = hasEl && $("keepEleven").checked;
|
|
120
|
+
// Only download once you're actually choosing a voice, never just because Kokoro is the default.
|
|
121
|
+
if (voice === "kokoro" && !S.state.kokoro.installed && S.step === "voice") startDownload("kokoro");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── 3 · speech models ──
|
|
125
|
+
function renderPacks() {
|
|
126
|
+
const box = $("packCards"); box.innerHTML = "";
|
|
127
|
+
for (const [id, p] of Object.entries(S.state.packs)) {
|
|
128
|
+
const installed = S.state.installedPacks.includes(id);
|
|
129
|
+
const label = document.createElement("label"); label.className = "card";
|
|
130
|
+
label.innerHTML = `<input type="checkbox"><span class="card-body"><b></b><span></span></span>`;
|
|
131
|
+
const input = label.querySelector("input"); input.value = id;
|
|
132
|
+
input.checked = installed || S.selectedPacks.has(id); input.disabled = installed;
|
|
133
|
+
label.querySelector("b").textContent = `${p.label} · ${p.size}`;
|
|
134
|
+
label.querySelector(".card-body span").textContent = p.hint;
|
|
135
|
+
if (installed) { const badge = document.createElement("span"); badge.className = "badge"; badge.textContent = "installed"; label.append(badge); }
|
|
136
|
+
input.addEventListener("change", () => { if (input.checked) { S.selectedPacks.add(id); startDownload(id); } else S.selectedPacks.delete(id); });
|
|
137
|
+
box.append(label);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── downloads ──
|
|
142
|
+
async function startDownload(id) {
|
|
143
|
+
try { await api("download", { id }); pollJobs(); } catch (e) { fatal(e.message); }
|
|
144
|
+
}
|
|
145
|
+
function renderJob(container, id, job) {
|
|
146
|
+
let el = document.getElementById(`job-${id}`);
|
|
147
|
+
if (!el) {
|
|
148
|
+
el = document.createElement("div"); el.className = "job"; el.id = `job-${id}`;
|
|
149
|
+
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>`;
|
|
150
|
+
container.append(el);
|
|
151
|
+
}
|
|
152
|
+
el.className = "job" + (job.state === "done" ? " done" : job.state === "error" ? " error" : "");
|
|
153
|
+
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)" : ""}`;
|
|
154
|
+
el.querySelector(".job-head span:last-child").textContent = `${job.pct || 0}%`;
|
|
155
|
+
el.querySelector(".bar").setAttribute("aria-valuenow", String(job.pct || 0));
|
|
156
|
+
el.querySelector(".bar i").style.width = `${job.pct || 0}%`;
|
|
157
|
+
}
|
|
158
|
+
async function pollJobs() {
|
|
159
|
+
clearTimeout(S.pollTimer);
|
|
160
|
+
try {
|
|
161
|
+
const { jobs, installedPacks, kokoroInstalled } = await api("jobs");
|
|
162
|
+
S.state.installedPacks = installedPacks; S.state.kokoro.installed = kokoroInstalled;
|
|
163
|
+
for (const [id, job] of Object.entries(jobs)) {
|
|
164
|
+
if (id === "kokoro") {
|
|
165
|
+
$("kokoroJob").hidden = false;
|
|
166
|
+
$("kokoroJob").className = "job" + (job.state === "done" ? " done" : job.state === "error" ? " error" : "");
|
|
167
|
+
$("kokoroLabel").textContent = job.state === "done" ? "Kokoro voice ready" : job.state === "error" ? `Kokoro voice failed: ${job.error}` : `Downloading the Kokoro voice (${S.state.kokoro.size})`;
|
|
168
|
+
$("kokoroPct").textContent = `${job.pct || 0}%`;
|
|
169
|
+
$("kokoroBar").style.width = `${job.pct || 0}%`;
|
|
170
|
+
} else renderJob($("packJobs"), id, job);
|
|
171
|
+
}
|
|
172
|
+
if (Object.values(jobs).some((j) => j.state === "running")) S.pollTimer = setTimeout(pollJobs, 700);
|
|
173
|
+
else if (S.step === "review") renderSummary();
|
|
174
|
+
} catch { S.pollTimer = setTimeout(pollJobs, 2000); }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ── 4 · access ──
|
|
178
|
+
function syncAudience() {
|
|
179
|
+
const others = radio("audience") === "others";
|
|
180
|
+
$("passwordField").hidden = !others;
|
|
181
|
+
$("keepPassRow").hidden = !S.state.current.hasPassword;
|
|
182
|
+
$("newPassRow").hidden = S.state.current.hasPassword && $("keepPass").checked;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── 5 · review & launch ──
|
|
186
|
+
function answers() {
|
|
187
|
+
const mode = radio("mode"), voice = radio("voice"), audience = radio("audience");
|
|
188
|
+
return {
|
|
189
|
+
mode,
|
|
190
|
+
openrouterKey: mode === "mock" ? null : S.state.current.openrouterKey && $("keepKey").checked ? "keep" : S.connected && !$("orKey").value.trim() ? "connected" : $("orKey").value.trim(),
|
|
191
|
+
voice,
|
|
192
|
+
elevenKey: voice === "elevenlabs" ? (S.state.current.elevenKey && $("keepEleven").checked ? "keep" : $("elKey").value.trim()) : undefined,
|
|
193
|
+
elevenVoice: voice === "elevenlabs" ? $("elVoice").value.trim() || null : undefined,
|
|
194
|
+
audience,
|
|
195
|
+
password: audience === "others" ? (S.state.current.hasPassword && $("keepPass").checked ? "keep" : $("password").value) : undefined,
|
|
196
|
+
proxy: $("proxy").checked,
|
|
197
|
+
port: Number($("port").value),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
function renderSummary() {
|
|
201
|
+
const a = answers(), packs = S.state.installedPacks.map((id) => S.state.packs[id].label);
|
|
202
|
+
// Wait only for downloads that match the current choices (a voice you switched away from doesn't count).
|
|
203
|
+
const busy = (el) => el && !el.hidden && !el.classList.contains("done") && !el.classList.contains("error");
|
|
204
|
+
const running = (a.voice === "kokoro" && busy($("kokoroJob")) ? 1 : 0)
|
|
205
|
+
+ [...S.selectedPacks].filter((id) => busy(document.getElementById(`job-${id}`))).length;
|
|
206
|
+
const rows = [
|
|
207
|
+
["AI replies", a.mode === "mock" ? "MOCK mode (canned replies)" : "OpenRouter"],
|
|
208
|
+
["Voice", S.state.voices[a.voice].label],
|
|
209
|
+
["Speech recognition", packs.length ? packs.join(", ") : "Chrome's built-in recognition"],
|
|
210
|
+
["Access", a.audience === "me" ? "This computer only" : "Password protected"],
|
|
211
|
+
["Address", `http://localhost:${a.port}/app`],
|
|
212
|
+
];
|
|
213
|
+
if (a.audience === "others" && a.password !== "keep") rows.splice(4, 0, ["Password", a.password]);
|
|
214
|
+
const dl = $("summary"); dl.innerHTML = "";
|
|
215
|
+
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); }
|
|
216
|
+
$("launch").disabled = running > 0;
|
|
217
|
+
setStatus($("launchStatus"), running ? "Waiting for downloads to finish…" : "");
|
|
218
|
+
}
|
|
219
|
+
async function launch() {
|
|
220
|
+
$("launch").disabled = true;
|
|
221
|
+
setStatus($("launchStatus"), "Saving…");
|
|
222
|
+
try {
|
|
223
|
+
const saved = await api("save", { answers: answers() });
|
|
224
|
+
await api("finish", {});
|
|
225
|
+
show("launching");
|
|
226
|
+
const url = saved.url;
|
|
227
|
+
// Wait until *Ava* answers. On the same port the setup server may still be
|
|
228
|
+
// closing, so read the reply and make sure it's Ava's health report. On a
|
|
229
|
+
// different port anything that answers is Ava (no-cors can't read it).
|
|
230
|
+
const samePort = String(saved.port) === (location.port || "80");
|
|
231
|
+
for (let i = 0; i < 120; i++) {
|
|
232
|
+
try {
|
|
233
|
+
if (samePort) {
|
|
234
|
+
const r = await fetch("/api/health", { cache: "no-store" });
|
|
235
|
+
if (r.ok && "models" in (await r.json())) { location.replace(url); return; }
|
|
236
|
+
} else {
|
|
237
|
+
await fetch(`http://localhost:${saved.port}/api/health`, { mode: "no-cors", cache: "no-store" });
|
|
238
|
+
location.replace(url); return;
|
|
239
|
+
}
|
|
240
|
+
} catch {}
|
|
241
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
242
|
+
}
|
|
243
|
+
$("launchMsg").textContent = `Ava is taking a while. Open ${url} when it's ready, or check the terminal.`;
|
|
244
|
+
} catch (e) {
|
|
245
|
+
setStatus($("launchStatus"), `✗ ${e.message}`, "err");
|
|
246
|
+
$("launch").disabled = false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── boot ──
|
|
251
|
+
async function boot() {
|
|
252
|
+
if (!TOKEN) return fatal("This page needs the link printed by `npx ava-engine setup --web`.");
|
|
253
|
+
try { S.state = await api("state"); } catch (e) { return fatal(e.message); }
|
|
254
|
+
history.replaceState(null, "", "/setup"); // drop the token from the address bar (it stays in memory)
|
|
255
|
+
const c = S.state.current;
|
|
256
|
+
$("configFile").textContent = S.state.configFile;
|
|
257
|
+
if (c.openrouterKey) { $("currentKey").textContent = c.openrouterKey; }
|
|
258
|
+
if (c.elevenKey) $("currentEleven").textContent = c.elevenKey;
|
|
259
|
+
$("elVoice").value = c.elevenVoice;
|
|
260
|
+
document.querySelector(`input[name="audience"][value="${c.audience}"]`).checked = true;
|
|
261
|
+
$("password").value = S.state.suggestedPassword;
|
|
262
|
+
$("proxy").checked = c.proxy;
|
|
263
|
+
$("port").value = c.port;
|
|
264
|
+
renderVoices(); renderPacks();
|
|
265
|
+
syncMode(); syncVoice(); syncAudience();
|
|
266
|
+
|
|
267
|
+
document.querySelectorAll('input[name="mode"]').forEach((i) => i.addEventListener("change", syncMode));
|
|
268
|
+
$("keepKey").addEventListener("change", syncMode);
|
|
269
|
+
$("voiceCards").addEventListener("change", syncVoice);
|
|
270
|
+
$("keepEleven").addEventListener("change", syncVoice);
|
|
271
|
+
document.querySelectorAll('input[name="audience"]').forEach((i) => i.addEventListener("change", syncAudience));
|
|
272
|
+
$("keepPass").addEventListener("change", syncAudience);
|
|
273
|
+
$("orKey").addEventListener("input", () => { S.keyOk = !!S.connected && !$("orKey").value.trim(); setStatus($("keyStatus"), ""); });
|
|
274
|
+
$("connectOr").addEventListener("click", connectOpenRouter);
|
|
275
|
+
$("elKey").addEventListener("input", () => { S.elevenOk = false; setStatus($("elevenStatus"), ""); });
|
|
276
|
+
$("checkKey").addEventListener("click", () => checkKey("openrouter", $("orKey"), $("keyStatus"), "keyOk"));
|
|
277
|
+
$("checkEleven").addEventListener("click", () => checkKey("elevenlabs", $("elKey"), $("elevenStatus"), "elevenOk"));
|
|
278
|
+
$("orKey").addEventListener("keydown", (e) => { if (e.key === "Enter") $("checkKey").click(); });
|
|
279
|
+
$("regen").addEventListener("click", async () => { $("password").value = (await api("password")).password; });
|
|
280
|
+
$("copyPass").addEventListener("click", async () => { try { await navigator.clipboard.writeText($("password").value); $("copyPass").textContent = "Copied"; setTimeout(() => { $("copyPass").textContent = "Copy"; }, 1400); } catch {} });
|
|
281
|
+
$("launch").addEventListener("click", launch);
|
|
282
|
+
show("ai");
|
|
283
|
+
pollJobs();
|
|
284
|
+
}
|
|
285
|
+
// A fresh setup link opened in this same tab only changes the #token, which
|
|
286
|
+
// doesn't reload the page; reload so the new token is used.
|
|
287
|
+
window.addEventListener("hashchange", () => { if (/token=/.test(location.hash)) location.reload(); });
|
|
288
|
+
boot();
|
|
289
|
+
})();
|
package/public/style.css
CHANGED
|
@@ -96,6 +96,10 @@ b { font-weight: 600; }
|
|
|
96
96
|
.pill { display: flex; align-items: center; gap: 7px; padding: 5px 11px 5px 9px; border-radius: 999px; font-family: var(--mono); font-size: 10px; font-weight: 500; letter-spacing: 0.1em; }
|
|
97
97
|
.pill .dot { width: 6px; height: 6px; border-radius: 50%; }
|
|
98
98
|
.pill.standby { background: var(--control); border: 1px solid var(--edge); color: var(--ink2); }
|
|
99
|
+
.pill.demo { background: #FFF6E5; border: 1px solid #F2D49A; color: #7A4B00; }
|
|
100
|
+
.pill.demo[hidden], .ob-demo[hidden] { display: none; }
|
|
101
|
+
.ob-demo { margin-top: 12px; padding: 10px 14px; border-radius: 10px; background: #FFF6E5; border: 1px solid #F2D49A; color: #5C3A00; font-size: 13.5px; }
|
|
102
|
+
.ob-demo code { font-family: var(--mono); font-size: 12.5px; }
|
|
99
103
|
.pill.standby .dot { border: 1px solid var(--meta); }
|
|
100
104
|
.pill.live { background: var(--brandtint); border: 1px solid var(--brandline); color: var(--amber); }
|
|
101
105
|
.pill.live .dot { background: var(--amber); }
|
package/server.mjs
CHANGED
|
@@ -12,8 +12,10 @@ import { oursTurnStream, greetStream, observeUpdate, screenOff, proactiveNudge,
|
|
|
12
12
|
import { synth, TTS_ENABLED, providers, defaultProvider, warmKokoro, kokoroStream, KOKORO_STREAMS, elevenStream, ELEVEN_STREAMS, TtsBusyError } from "./lib/tts.mjs";
|
|
13
13
|
import { transcribe, STT_ENABLED } from "./lib/stt.mjs";
|
|
14
14
|
import { createAsrSession, asrAvailable, warmAsr, availableEngines, defaultEngine, ENGINE_LABELS } from "./lib/asr.mjs";
|
|
15
|
-
import { platformsView, platformById } from "./lib/platforms.mjs";
|
|
15
|
+
import { platformsView, platformById, CUSTOM_PLAYBOOKS } from "./lib/platforms.mjs";
|
|
16
16
|
import { AUTH_ENABLED, isAuthorized, challengeHeaders, authCookieHeader, clientIp, sameOrigin, limiter } from "./lib/guard.mjs";
|
|
17
|
+
import { emitEvent, WEBHOOKS_ENABLED } from "./lib/webhooks.mjs";
|
|
18
|
+
import { DAILY_BUDGET_USD, dailyCapReached, spentToday } from "./lib/spend.mjs";
|
|
17
19
|
import { WebSocketServer } from "ws";
|
|
18
20
|
|
|
19
21
|
// A stray rejection is logged, not fatal. An uncaught exception leaves the process
|
|
@@ -93,6 +95,7 @@ function send(res, code, body, headers = {}) {
|
|
|
93
95
|
// Upstream errors (OpenRouter, ElevenLabs, Deepgram…) can carry account or quota
|
|
94
96
|
// detail. Log it here; the browser only gets a short, generic reason.
|
|
95
97
|
function upstreamFail(res, e, what) {
|
|
98
|
+
if (e?.code === "daily_cap") return send(res, 503, { error: CAP_MSG }, { "Retry-After": untilMidnightUtc() });
|
|
96
99
|
console.error(` [${what}]`, e?.message || e);
|
|
97
100
|
return send(res, e?.status === 429 ? 429 : 502, { error: `${what} failed` });
|
|
98
101
|
}
|
|
@@ -125,7 +128,14 @@ function readBody(req, limit = 3 * 1024 * 1024) {
|
|
|
125
128
|
|
|
126
129
|
function safeHost(req) { const h = String(req.headers.host || ""); return /^[a-z0-9.-]+(:\d+)?$/i.test(h) ? h : ""; }
|
|
127
130
|
function validFrame(f) { return typeof f === "string" && f.length <= MAX_FRAME_BYTES && FRAME_RE.test(f) ? f : null; }
|
|
128
|
-
|
|
131
|
+
const CAP_MSG = "Ava has reached today's usage limit — try again tomorrow";
|
|
132
|
+
/** Why a session can't spend more (its own budget, or the daily cap across all sessions), or null. */
|
|
133
|
+
function spendLimit(sess) {
|
|
134
|
+
if (!MOCK && dailyCapReached()) return CAP_MSG;
|
|
135
|
+
if (sess.score.ours.cost >= SESSION_BUDGET_USD) return "this session reached its spend limit — start a new call";
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const untilMidnightUtc = () => String(Math.ceil((new Date().setUTCHours(24, 0, 0, 0) - Date.now()) / 1000));
|
|
129
139
|
|
|
130
140
|
async function serveStatic(req, res, pathname) {
|
|
131
141
|
let p;
|
|
@@ -201,6 +211,7 @@ async function handle(req, res) {
|
|
|
201
211
|
if (req.method === "GET" && url.pathname === "/api/health") {
|
|
202
212
|
return send(res, 200, {
|
|
203
213
|
mock: MOCK,
|
|
214
|
+
dailyCapReached: !MOCK && dailyCapReached(),
|
|
204
215
|
models: { talker: MODELS.talker, perception: MODELS.perception, planner: MODELS.planner, intent: MODELS.intent },
|
|
205
216
|
labels: ROLE_LABEL,
|
|
206
217
|
tts: { enabled: TTS_ENABLED, providers: providers(), default: defaultProvider() },
|
|
@@ -285,11 +296,13 @@ async function handle(req, res) {
|
|
|
285
296
|
if (req.method === "POST" && url.pathname === "/api/session") {
|
|
286
297
|
if (!rate.session(clientIp(req))) return send(res, 429, { error: "too many new sessions — try again in a minute" }, { "Retry-After": "60" });
|
|
287
298
|
if (!hasCapacity()) return send(res, 503, { error: "Ava is at capacity right now — try again in a few minutes" }, { "Retry-After": "60" });
|
|
299
|
+
if (!MOCK && dailyCapReached()) return send(res, 503, { error: CAP_MSG }, { "Retry-After": untilMidnightUtc() });
|
|
288
300
|
const body = await readBody(req);
|
|
289
301
|
const mode = body.mode === "onboarding" ? "onboarding" : "flexible";
|
|
290
302
|
const platform = typeof body.platformId === "string" ? platformById(body.platformId) : null; // knowledge in any mode
|
|
291
303
|
const s = newSession({ mode, platform });
|
|
292
304
|
if (!s) return send(res, 503, { error: "Ava is at capacity right now — try again in a few minutes" }, { "Retry-After": "60" });
|
|
305
|
+
emitEvent("session.started", { sessionId: s.id, mode, platform: platform ? { id: platform.id, name: platform.name } : null });
|
|
293
306
|
// pre-warm the talker route so the greeting isn't a cold start
|
|
294
307
|
if (!MOCK) chat({ model: MODELS.talker, role: "talker", provider: PROVIDER.talker, messages: [{ role: "user", content: "hi" }], maxTokens: 1 }).catch(() => {});
|
|
295
308
|
return send(res, 200, { sessionId: s.id, mock: MOCK, models: MODELS, mode, platform: platform ? { id: platform.id, name: platform.name, url: platform.url } : null });
|
|
@@ -300,7 +313,8 @@ async function handle(req, res) {
|
|
|
300
313
|
const { sessionId, frameHash = null, greet = false, voice = false, changed = false, screenActive = false } = body;
|
|
301
314
|
const sess = getSession(sessionId);
|
|
302
315
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
303
|
-
|
|
316
|
+
const limit = spendLimit(sess);
|
|
317
|
+
if (limit) return send(res, 402, { error: limit });
|
|
304
318
|
const userText = String(body.userText ?? "").slice(0, MAX_USER_TEXT);
|
|
305
319
|
const frame = validFrame(body.frame);
|
|
306
320
|
|
|
@@ -310,8 +324,8 @@ async function handle(req, res) {
|
|
|
310
324
|
if (greet) await greetStream(sess, emit, !!voice);
|
|
311
325
|
else await oursTurnStream(sess, userText, frame, !!screenActive, frameHash, emit, !!voice, !!changed);
|
|
312
326
|
} catch (e) {
|
|
313
|
-
|
|
314
|
-
emit("error", { error: "Ava couldn't answer that one — try again" });
|
|
327
|
+
if (e?.code === "daily_cap") emit("error", { error: CAP_MSG });
|
|
328
|
+
else { console.error(" [turn]", e?.message || e); emit("error", { error: "Ava couldn't answer that one — try again" }); }
|
|
315
329
|
}
|
|
316
330
|
res.end();
|
|
317
331
|
return;
|
|
@@ -322,7 +336,7 @@ async function handle(req, res) {
|
|
|
322
336
|
const sess = getSession(sessionId);
|
|
323
337
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
324
338
|
if (stopped) return send(res, 200, screenOff(sess)); // user stopped sharing → mark state stale now
|
|
325
|
-
if (
|
|
339
|
+
if (spendLimit(sess)) return send(res, 402, { error: spendLimit(sess) });
|
|
326
340
|
const frame = validFrame(rawFrame);
|
|
327
341
|
if (!frame) return send(res, 400, { error: "no frame" });
|
|
328
342
|
try { return send(res, 200, await observeUpdate(sess, frame, frameHash, !!changed)); }
|
|
@@ -382,7 +396,7 @@ async function handle(req, res) {
|
|
|
382
396
|
const { sessionId } = await readBody(req);
|
|
383
397
|
const sess = getSession(sessionId);
|
|
384
398
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
385
|
-
if (
|
|
399
|
+
if (spendLimit(sess)) return send(res, 402, { error: spendLimit(sess) });
|
|
386
400
|
try { return send(res, 200, await proactiveNudge(sess)); }
|
|
387
401
|
catch (e) { return upstreamFail(res, e, "nudge"); }
|
|
388
402
|
}
|
|
@@ -493,6 +507,9 @@ server.listen(PORT, HOST, () => {
|
|
|
493
507
|
console.log(` → http://localhost:${PORT}/app`);
|
|
494
508
|
console.log(` mode: ${MOCK ? "MOCK (no OPENROUTER_API_KEY set — synthetic replies)" : "LIVE (OpenRouter)"}`);
|
|
495
509
|
if (!MOCK) console.log(` models: talker=${MODELS.talker} perception=${MODELS.perception} planner=${MODELS.planner} intent=${MODELS.intent}`);
|
|
510
|
+
if (!MOCK) console.log(DAILY_BUDGET_USD ? ` daily cap: $${DAILY_BUDGET_USD} (spent today: $${spentToday().toFixed(2)})` : " daily cap: none (set DAILY_BUDGET_USD to limit spend across all calls)");
|
|
511
|
+
if (CUSTOM_PLAYBOOKS.count) console.log(` playbooks: ${CUSTOM_PLAYBOOKS.count} from ${CUSTOM_PLAYBOOKS.dir}`);
|
|
512
|
+
if (WEBHOOKS_ENABLED) console.log(" webhooks: on");
|
|
496
513
|
if (LOCAL_ONLY) console.log(" access: this computer only");
|
|
497
514
|
else if (!AUTH_ENABLED) console.log(" access: OPEN — anyone who can reach this server can spend your API credit. Set ACCESS_PASSWORD before exposing it.");
|
|
498
515
|
else console.log(" access: password protected");
|