ava-engine 0.1.0 → 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.
- package/README.md +11 -9
- package/lib/asr.mjs +5 -1
- package/lib/cli/apply.mjs +58 -0
- package/lib/cli/main.mjs +43 -8
- package/lib/cli/setup-server.mjs +195 -0
- package/lib/cli/setup.mjs +93 -79
- package/lib/kokoro-model.mjs +51 -0
- package/lib/tts.mjs +4 -4
- package/package.json +10 -2
- package/public/setup.css +94 -0
- package/public/setup.html +129 -0
- package/public/setup.js +264 -0
package/public/setup.js
ADDED
|
@@ -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
|
+
})();
|