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/lib/cli/setup.mjs
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
// `ava-engine setup
|
|
1
|
+
// `ava-engine setup` in the terminal: the guided wizard (and its non-interactive twin).
|
|
2
|
+
// The browser version lives in setup-server.mjs; both save through apply.mjs.
|
|
2
3
|
import * as p from "@clack/prompts";
|
|
3
|
-
import {
|
|
4
|
+
import { CONFIG_FILE, MODELS_DIR, DEFAULT_PORT, readConfig, writeConfig, checkOpenRouterKey, checkElevenLabsKey, portFree, suggestPassword } from "./config.mjs";
|
|
5
|
+
import { buildConfig, VOICES } from "./apply.mjs";
|
|
4
6
|
import { MODEL_PACKS, installedPacks, fetchPack } from "../fetch-models.mjs";
|
|
7
|
+
import { loadKokoro, kokoroInstalled, KOKORO_SIZE } from "../kokoro-model.mjs";
|
|
5
8
|
|
|
6
9
|
const mask = (v) => (v ? `${v.slice(0, 6)}…${v.slice(-4)}` : "");
|
|
7
10
|
function bail(v) { if (p.isCancel(v)) { p.cancel("Setup cancelled. Nothing was changed."); process.exit(0); } return v; }
|
|
8
11
|
|
|
9
|
-
/** Download model packs with a progress bar per
|
|
12
|
+
/** Download speech model packs with a progress bar per pack. */
|
|
10
13
|
export async function downloadPacks(packs) {
|
|
11
14
|
for (const pack of packs) {
|
|
12
15
|
const spec = MODEL_PACKS[pack];
|
|
@@ -24,18 +27,37 @@ export async function downloadPacks(packs) {
|
|
|
24
27
|
if (bar) bar.stop(`${spec.label} ready`); else p.log.success(`${spec.label} already installed`);
|
|
25
28
|
} catch (e) {
|
|
26
29
|
if (bar) bar.error(`${spec.label} failed`);
|
|
27
|
-
p.log.error(`${e.message}\nYou can retry later with: ava-engine models ${pack}`);
|
|
30
|
+
p.log.error(`${e.message}\nYou can retry later with: npx ava-engine models ${pack}`);
|
|
28
31
|
}
|
|
29
32
|
}
|
|
30
33
|
}
|
|
31
34
|
|
|
35
|
+
/** Download the Kokoro voice now, so the first call doesn't wait for it. */
|
|
36
|
+
export async function downloadKokoro() {
|
|
37
|
+
process.env.AVA_MODELS_DIR ??= MODELS_DIR;
|
|
38
|
+
if (kokoroInstalled()) { p.log.success("Kokoro voice already installed"); return true; }
|
|
39
|
+
const bar = p.progress({ max: 100, style: "heavy" });
|
|
40
|
+
bar.start(`Downloading the Kokoro voice (${KOKORO_SIZE})`);
|
|
41
|
+
let last = 0;
|
|
42
|
+
try {
|
|
43
|
+
await loadKokoro({ onProgress: ({ pct }) => { if (pct > last) { bar.advance(pct - last, `Downloading the Kokoro voice: ${pct}%`); last = pct; } } });
|
|
44
|
+
bar.stop("Kokoro voice ready");
|
|
45
|
+
return true;
|
|
46
|
+
} catch (e) {
|
|
47
|
+
bar.error("Kokoro voice download failed");
|
|
48
|
+
p.log.error(`${e.message}\nAva will retry when it starts, or use the browser's voice meanwhile.`);
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
32
53
|
/**
|
|
33
54
|
* The interactive wizard. Returns { start: boolean, port }.
|
|
34
55
|
* Existing settings are kept unless you change them.
|
|
35
56
|
*/
|
|
36
57
|
export async function runSetup() {
|
|
58
|
+
process.env.AVA_MODELS_DIR ??= MODELS_DIR; // so "is Kokoro installed?" looks in ~/.ava-engine/models
|
|
37
59
|
const prev = readConfig() || {};
|
|
38
|
-
const
|
|
60
|
+
const answers = {};
|
|
39
61
|
|
|
40
62
|
console.log("");
|
|
41
63
|
p.intro(" ΛVΛ Ava Engine setup ");
|
|
@@ -43,29 +65,24 @@ export async function runSetup() {
|
|
|
43
65
|
"A screen-aware voice agent: it joins a call, sees the tab your\n" +
|
|
44
66
|
"customer shares, and talks them through it.\n\n" +
|
|
45
67
|
"This takes about 2 minutes. You can change anything later with\n" +
|
|
46
|
-
"`ava-engine setup`, or by editing " + CONFIG_FILE,
|
|
68
|
+
"`npx ava-engine setup`, or by editing " + CONFIG_FILE,
|
|
47
69
|
"Welcome",
|
|
48
70
|
);
|
|
49
71
|
|
|
50
|
-
// 1 ·
|
|
72
|
+
// 1 · AI replies
|
|
51
73
|
const mode = bail(await p.select({
|
|
52
|
-
message: "How
|
|
74
|
+
message: "How should Ava think?",
|
|
53
75
|
options: [
|
|
54
|
-
{ value: "full", label: "
|
|
55
|
-
{ value: "mock", label: "Just look around", hint: "no
|
|
76
|
+
{ value: "full", label: "With real AI", hint: "recommended; needs an OpenRouter key" },
|
|
77
|
+
{ value: "mock", label: "Just look around first", hint: "no key; canned replies, everything else works" },
|
|
56
78
|
],
|
|
57
79
|
}));
|
|
58
|
-
|
|
59
|
-
// 2 · language models
|
|
60
80
|
if (mode === "mock") {
|
|
61
|
-
|
|
62
|
-
p.log.info("MOCK mode: Ava will use canned replies.
|
|
81
|
+
answers.openrouterKey = null;
|
|
82
|
+
p.log.info("MOCK mode: Ava will use canned replies. Add a key any time with `npx ava-engine setup`.");
|
|
63
83
|
} else {
|
|
64
84
|
let key = prev.OPENROUTER_API_KEY;
|
|
65
|
-
if (key)
|
|
66
|
-
const keep = bail(await p.confirm({ message: `Keep your current OpenRouter key (${mask(key)})?`, initialValue: true }));
|
|
67
|
-
if (!keep) key = undefined;
|
|
68
|
-
}
|
|
85
|
+
if (key && !bail(await p.confirm({ message: `Keep your current OpenRouter key (${mask(key)})?`, initialValue: true }))) key = undefined;
|
|
69
86
|
while (!key) {
|
|
70
87
|
p.log.message("Ava's AI runs through OpenRouter: one key for every model.\nGet one at https://openrouter.ai/keys (set a credit limit on it).");
|
|
71
88
|
const entered = bail(await p.password({ message: "Paste your OpenRouter API key", validate: (v) => (!v || v.trim().length < 20 ? "That doesn't look like a full key" : undefined) })).trim();
|
|
@@ -89,96 +106,88 @@ export async function runSetup() {
|
|
|
89
106
|
if (next === "mock") break;
|
|
90
107
|
}
|
|
91
108
|
}
|
|
92
|
-
|
|
109
|
+
answers.openrouterKey = key || null;
|
|
110
|
+
}
|
|
93
111
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}));
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const r = await checkElevenLabsKey(entered);
|
|
113
|
-
if (r.ok || r.offline) { s.stop(r.ok ? "Key works" : `${r.error}; saved anyway`); ek = entered; }
|
|
114
|
-
else { s.error(r.error); if (!bail(await p.confirm({ message: "Try another key?", initialValue: true }))) { cfg.TTS_DEFAULT = "kokoro"; cfg.KOKORO = "on"; break; } }
|
|
115
|
-
}
|
|
116
|
-
if (ek) {
|
|
117
|
-
cfg.ELEVENLABS_API_KEY = ek;
|
|
118
|
-
const vid = bail(await p.text({ message: "ElevenLabs voice ID (Enter for the default voice, Matilda)", placeholder: "XrExE9yKIg1WjnnlVkGX", defaultValue: prev.ELEVEN_VOICE_ID || "" }));
|
|
119
|
-
if (vid) cfg.ELEVEN_VOICE_ID = vid.trim(); else delete cfg.ELEVEN_VOICE_ID;
|
|
112
|
+
// 2 · voice (free and offline by default, so offered in every mode)
|
|
113
|
+
answers.voice = bail(await p.select({
|
|
114
|
+
message: "Which voice should Ava speak with?",
|
|
115
|
+
initialValue: VOICES[prev.TTS_DEFAULT] ? prev.TTS_DEFAULT : "kokoro",
|
|
116
|
+
options: Object.entries(VOICES).map(([value, v]) => ({ value, label: v.label, hint: value === "kokoro" && kokoroInstalled() ? "free, runs on this computer (installed)" : v.hint })),
|
|
117
|
+
}));
|
|
118
|
+
if (answers.voice === "kokoro") await downloadKokoro();
|
|
119
|
+
if (answers.voice === "elevenlabs") {
|
|
120
|
+
let ek = prev.ELEVENLABS_API_KEY;
|
|
121
|
+
if (ek && !bail(await p.confirm({ message: `Keep your current ElevenLabs key (${mask(ek)})?`, initialValue: true }))) ek = undefined;
|
|
122
|
+
while (!ek) {
|
|
123
|
+
const entered = bail(await p.password({ message: "Paste your ElevenLabs API key (elevenlabs.io → API keys)" })).trim();
|
|
124
|
+
const s = p.spinner(); s.start("Checking the key with ElevenLabs");
|
|
125
|
+
const r = await checkElevenLabsKey(entered);
|
|
126
|
+
if (r.ok || r.offline) { s.stop(r.ok ? "Key works" : `${r.error}; saved anyway`); ek = entered; }
|
|
127
|
+
else {
|
|
128
|
+
s.error(r.error);
|
|
129
|
+
if (!bail(await p.confirm({ message: "Try another key?", initialValue: true }))) { answers.voice = "kokoro"; await downloadKokoro(); break; }
|
|
120
130
|
}
|
|
121
131
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
message: "Download offline speech recognition? (space to toggle, enter to confirm)",
|
|
127
|
-
required: false,
|
|
128
|
-
initialValues: have.length ? have : ["english-small"],
|
|
129
|
-
options: Object.entries(MODEL_PACKS).map(([id, m]) => ({ value: id, label: `${m.label} · ${m.size}`, hint: have.includes(id) ? "installed" : m.hint })),
|
|
130
|
-
}));
|
|
131
|
-
const missing = packs.filter((x) => !have.includes(x));
|
|
132
|
-
if (missing.length) await downloadPacks(missing);
|
|
133
|
-
else if (!packs.length) p.log.info("No models: Ava will use the browser's speech recognition (Chrome).");
|
|
132
|
+
if (ek) {
|
|
133
|
+
answers.elevenKey = ek;
|
|
134
|
+
answers.elevenVoice = bail(await p.text({ message: "ElevenLabs voice ID (Enter for the default voice, Matilda)", placeholder: "XrExE9yKIg1WjnnlVkGX", defaultValue: prev.ELEVEN_VOICE_ID || "" })).trim() || null;
|
|
135
|
+
}
|
|
134
136
|
}
|
|
135
137
|
|
|
136
|
-
//
|
|
137
|
-
const
|
|
138
|
+
// 3 · speech recognition (offered in every mode)
|
|
139
|
+
const have = installedPacks(MODELS_DIR);
|
|
140
|
+
const packs = bail(await p.multiselect({
|
|
141
|
+
message: "Download offline speech recognition? (space to toggle, enter to confirm)",
|
|
142
|
+
required: false,
|
|
143
|
+
initialValues: have.length ? have : ["english-small"],
|
|
144
|
+
options: Object.entries(MODEL_PACKS).map(([id, m]) => ({ value: id, label: `${m.label} · ${m.size}`, hint: have.includes(id) ? "installed" : m.hint })),
|
|
145
|
+
}));
|
|
146
|
+
const missing = packs.filter((x) => !have.includes(x));
|
|
147
|
+
if (missing.length) await downloadPacks(missing);
|
|
148
|
+
else if (!packs.length) p.log.info("No models: Ava will use the browser's speech recognition (Chrome). Add them later with `npx ava-engine models`.");
|
|
149
|
+
|
|
150
|
+
// 4 · who can reach it
|
|
151
|
+
answers.audience = bail(await p.select({
|
|
138
152
|
message: "Who will use this Ava?",
|
|
139
|
-
initialValue: prev.
|
|
153
|
+
initialValue: prev.ACCESS_PASSWORD ? "others" : "me",
|
|
140
154
|
options: [
|
|
141
155
|
{ value: "me", label: "Just me, on this computer", hint: "safest; reachable only at localhost" },
|
|
142
156
|
{ value: "others", label: "My team or customers", hint: "over a network or the internet; needs a password" },
|
|
143
157
|
],
|
|
144
158
|
}));
|
|
145
|
-
if (audience === "
|
|
146
|
-
cfg.HOST = "127.0.0.1";
|
|
147
|
-
delete cfg.ACCESS_PASSWORD; delete cfg.TRUST_PROXY;
|
|
148
|
-
} else {
|
|
149
|
-
delete cfg.HOST;
|
|
159
|
+
if (answers.audience === "others") {
|
|
150
160
|
const suggested = suggestPassword();
|
|
151
161
|
const how = bail(await p.select({
|
|
152
162
|
message: "Set an access password (every call spends your API credit)",
|
|
153
163
|
options: [
|
|
154
164
|
...(prev.ACCESS_PASSWORD ? [{ value: "keep", label: "Keep the current password" }] : []),
|
|
155
|
-
{ value: "gen", label: `Use a generated one: ${suggested}`, hint: "recommended; it's saved in the
|
|
165
|
+
{ value: "gen", label: `Use a generated one: ${suggested}`, hint: "recommended; it's saved in the settings file" },
|
|
156
166
|
{ value: "own", label: "Choose my own" },
|
|
157
167
|
],
|
|
158
168
|
}));
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (proxied) cfg.TRUST_PROXY = "1"; else delete cfg.TRUST_PROXY;
|
|
169
|
+
answers.password = how === "keep" ? prev.ACCESS_PASSWORD : how === "gen" ? suggested
|
|
170
|
+
: bail(await p.password({ message: "Access password (at least 10 characters)", validate: (v) => (!v || v.length < 10 ? "Use at least 10 characters" : undefined) }));
|
|
171
|
+
answers.proxy = bail(await p.confirm({ message: "Will it run behind a proxy or host (Fly.io, Cloudflare, nginx, Render…)?", initialValue: prev.TRUST_PROXY === "1" }));
|
|
163
172
|
}
|
|
164
173
|
|
|
165
|
-
//
|
|
174
|
+
// 5 · port
|
|
166
175
|
const portStr = bail(await p.text({
|
|
167
176
|
message: "Port",
|
|
168
177
|
defaultValue: String(prev.PORT || DEFAULT_PORT),
|
|
169
178
|
placeholder: String(prev.PORT || DEFAULT_PORT),
|
|
170
179
|
validate: (v) => { if (!v) return undefined; const n = Number(v); return Number.isInteger(n) && n > 0 && n < 65536 ? undefined : "Use a number between 1 and 65535"; },
|
|
171
180
|
}));
|
|
172
|
-
|
|
173
|
-
if (!(await portFree(Number(cfg.PORT), cfg.HOST))) p.log.warn(`Port ${cfg.PORT} is in use right now. Stop that program, or run \`ava-engine start --port <other>\`.`);
|
|
181
|
+
answers.port = Number(portStr) || DEFAULT_PORT;
|
|
174
182
|
|
|
175
|
-
//
|
|
176
|
-
cfg
|
|
183
|
+
// 6 · save
|
|
184
|
+
const cfg = buildConfig(prev, answers);
|
|
185
|
+
if (!(await portFree(Number(cfg.PORT), cfg.HOST))) p.log.warn(`Port ${cfg.PORT} is in use right now. Stop that program, or run \`npx ava-engine start --port <other>\`.`);
|
|
177
186
|
writeConfig(cfg);
|
|
178
187
|
p.note(
|
|
179
188
|
[
|
|
180
189
|
`AI replies ${cfg.OPENROUTER_API_KEY ? `OpenRouter (${mask(cfg.OPENROUTER_API_KEY)})` : "MOCK mode (canned replies)"}`,
|
|
181
|
-
`Voice ${
|
|
190
|
+
`Voice ${VOICES[cfg.TTS_DEFAULT]?.label || "Kokoro"}`,
|
|
182
191
|
`Speech models ${installedPacks(MODELS_DIR).map((x) => MODEL_PACKS[x].label).join(", ") || "none (browser speech recognition)"}`,
|
|
183
192
|
`Access ${cfg.HOST === "127.0.0.1" ? "this computer only" : `password protected${cfg.ACCESS_PASSWORD ? ` (${cfg.ACCESS_PASSWORD})` : ""}`}`,
|
|
184
193
|
`Address http://localhost:${cfg.PORT}/app`,
|
|
@@ -209,11 +218,16 @@ export async function runSetupNonInteractive({ models = [], port } = {}) {
|
|
|
209
218
|
cfg.AVA_MODELS_DIR = MODELS_DIR;
|
|
210
219
|
writeConfig(cfg);
|
|
211
220
|
for (const pack of models) {
|
|
212
|
-
|
|
221
|
+
if (pack === "kokoro") {
|
|
222
|
+
process.env.AVA_MODELS_DIR ??= MODELS_DIR;
|
|
223
|
+
console.log("↓ Kokoro voice");
|
|
224
|
+
if (!kokoroInstalled()) await loadKokoro();
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (!MODEL_PACKS[pack]) throw new Error(`unknown model "${pack}" (choose: ${Object.keys(MODEL_PACKS).join(", ")}, kokoro)`);
|
|
228
|
+
console.log(`↓ ${MODEL_PACKS[pack].label}`);
|
|
213
229
|
await fetchPack(pack, MODELS_DIR);
|
|
214
230
|
}
|
|
215
231
|
console.log(`Saved ${CONFIG_FILE}${cfg.OPENROUTER_API_KEY ? "" : " (MOCK mode: no OPENROUTER_API_KEY)"}`);
|
|
216
232
|
return cfg;
|
|
217
233
|
}
|
|
218
|
-
|
|
219
|
-
export { AVA_HOME };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// The offline Kokoro voice model: where it's cached, whether it's there, and
|
|
2
|
+
// loading it with download progress. Shared by the server and the setup wizard.
|
|
3
|
+
//
|
|
4
|
+
// It's cached under the models folder (~/.ava-engine/models/kokoro for the CLI),
|
|
5
|
+
// not inside node_modules, so it survives `npx` updates instead of downloading
|
|
6
|
+
// again after every new version.
|
|
7
|
+
|
|
8
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
9
|
+
import { join, dirname } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
|
|
12
|
+
export const KOKORO_MODEL = process.env.KOKORO_MODEL || "onnx-community/Kokoro-82M-v1.0-ONNX";
|
|
13
|
+
export const KOKORO_DTYPE = process.env.KOKORO_DTYPE || "q8";
|
|
14
|
+
export const KOKORO_SIZE = "~90 MB";
|
|
15
|
+
|
|
16
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
17
|
+
export function kokoroCacheDir() {
|
|
18
|
+
return process.env.KOKORO_CACHE_DIR || join(process.env.AVA_MODELS_DIR || join(ROOT, "models"), "kokoro");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** True once the model's weights are on disk (no network needed to start). */
|
|
22
|
+
export function kokoroInstalled(dir = kokoroCacheDir()) {
|
|
23
|
+
const onnx = join(dir, ...KOKORO_MODEL.split("/"), "onnx");
|
|
24
|
+
try { return existsSync(onnx) && readdirSync(onnx).some((f) => f.endsWith(".onnx")); } catch { return false; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Load Kokoro, downloading it first if needed.
|
|
29
|
+
* onProgress({ loaded, total, pct }) reports the overall download.
|
|
30
|
+
*/
|
|
31
|
+
export async function loadKokoro({ onProgress } = {}) {
|
|
32
|
+
const { env } = await import("@huggingface/transformers");
|
|
33
|
+
env.cacheDir = kokoroCacheDir();
|
|
34
|
+
const { KokoroTTS } = await import("kokoro-js");
|
|
35
|
+
const files = new Map(); // file → { loaded, total }
|
|
36
|
+
// Count only the large files (the weights). The small config/tokenizer files
|
|
37
|
+
// finish first and would make the bar jump to 100% and back.
|
|
38
|
+
const progress_callback = (e) => {
|
|
39
|
+
if (!onProgress || e.status !== "progress" || !(e.total > 1_000_000)) return;
|
|
40
|
+
files.set(e.file, { loaded: e.loaded, total: e.total });
|
|
41
|
+
let loaded = 0, total = 0;
|
|
42
|
+
for (const f of files.values()) { loaded += f.loaded; total += f.total; }
|
|
43
|
+
onProgress({ loaded, total, pct: Math.min(100, Math.floor((loaded / total) * 100)) });
|
|
44
|
+
};
|
|
45
|
+
// transformers.js warns about a missing content-length on every download; it's
|
|
46
|
+
// harmless and would break the setup wizard's progress bar, so hide just that line.
|
|
47
|
+
const warn = console.warn;
|
|
48
|
+
console.warn = (...args) => { if (!String(args[0]).includes("content-length")) warn(...args); };
|
|
49
|
+
try { return await KokoroTTS.from_pretrained(KOKORO_MODEL, { dtype: KOKORO_DTYPE, device: "cpu", progress_callback }); }
|
|
50
|
+
finally { console.warn = warn; }
|
|
51
|
+
}
|
package/lib/tts.mjs
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// audio — works on the oldest device. ElevenLabs/Hume are cloud (paid). "browser"
|
|
4
4
|
// is the on-device Web Speech fallback.
|
|
5
5
|
|
|
6
|
+
import { loadKokoro, kokoroInstalled } from "./kokoro-model.mjs";
|
|
7
|
+
|
|
6
8
|
const HAS_ELEVEN = !!process.env.ELEVENLABS_API_KEY;
|
|
7
9
|
const HAS_HUME = !!process.env.HUME_API_KEY;
|
|
8
10
|
const HAS_KOKORO = process.env.KOKORO !== "off"; // local, free — on by default
|
|
@@ -13,9 +15,7 @@ const ELEVEN_LABEL = process.env.ELEVEN_LABEL || "Matilda";
|
|
|
13
15
|
const HUME_VOICE = process.env.HUME_VOICE_ID || "a7ecc00a-6fc0-4546-8126-e12cfd8de3bf";
|
|
14
16
|
const HUME_LABEL = process.env.HUME_LABEL || "Aria";
|
|
15
17
|
const HUME_DESC = process.env.HUME_DESCRIPTION || "Warm, empathetic, natural and reassuring";
|
|
16
|
-
const KOKORO_MODEL = process.env.KOKORO_MODEL || "onnx-community/Kokoro-82M-v1.0-ONNX";
|
|
17
18
|
const KOKORO_VOICE = process.env.KOKORO_VOICE || "af_heart";
|
|
18
|
-
const KOKORO_DTYPE = process.env.KOKORO_DTYPE || "q8";
|
|
19
19
|
|
|
20
20
|
export const TTS_ENABLED = HAS_KOKORO || HAS_ELEVEN || HAS_HUME;
|
|
21
21
|
const FETCH_TIMEOUT_MS = 20_000;
|
|
@@ -50,8 +50,8 @@ let kokoroTTS = null, kokoroInit = null;
|
|
|
50
50
|
async function initKokoro() {
|
|
51
51
|
if (kokoroTTS) return kokoroTTS;
|
|
52
52
|
if (!kokoroInit) kokoroInit = (async () => {
|
|
53
|
-
|
|
54
|
-
kokoroTTS = await
|
|
53
|
+
if (!kokoroInstalled()) console.log(" kokoro: downloading the voice model (~90 MB, first run only)…");
|
|
54
|
+
kokoroTTS = await loadKokoro();
|
|
55
55
|
return kokoroTTS;
|
|
56
56
|
})();
|
|
57
57
|
return kokoroInit;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ava-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A screen-aware voice agent: sees the tab a user shares, talks them through it, and hands off to a human when needed.",
|
|
6
6
|
"keywords": [
|
|
@@ -45,16 +45,20 @@
|
|
|
45
45
|
"lint": "eslint .",
|
|
46
46
|
"test:all": "npm run lint && npm run check && npm test && npm run test:e2e",
|
|
47
47
|
"demo:record": "node scripts/record-demo.mjs",
|
|
48
|
-
"cli": "node bin/ava-engine.mjs"
|
|
48
|
+
"cli": "node bin/ava-engine.mjs",
|
|
49
|
+
"prepare": "node -e \"if(require('fs').existsSync('.git')&&require('fs').existsSync('.githooks'))try{require('child_process').execFileSync('git',['config','core.hooksPath','.githooks'],{stdio:'ignore'})}catch{}\""
|
|
49
50
|
},
|
|
50
51
|
"dependencies": {
|
|
51
52
|
"@clack/prompts": "1.8.1",
|
|
53
|
+
"@huggingface/transformers": "3.8.1",
|
|
52
54
|
"kokoro-js": "^1.2.1",
|
|
53
55
|
"sherpa-onnx-node": "^1.13.8",
|
|
54
56
|
"ws": "^8.21.3"
|
|
55
57
|
},
|
|
56
58
|
"devDependencies": {
|
|
57
59
|
"@axe-core/playwright": "^4.13.0",
|
|
60
|
+
"@commitlint/cli": "21.2.4",
|
|
61
|
+
"@commitlint/config-conventional": "21.2.4",
|
|
58
62
|
"eslint": "^9.39.5",
|
|
59
63
|
"globals": "^15.15.0",
|
|
60
64
|
"playwright": "1.61"
|
|
@@ -70,5 +74,9 @@
|
|
|
70
74
|
"homepage": "https://github.com/Mr-vero/ava-engine#readme",
|
|
71
75
|
"bugs": {
|
|
72
76
|
"url": "https://github.com/Mr-vero/ava-engine/issues"
|
|
77
|
+
},
|
|
78
|
+
"publishConfig": {
|
|
79
|
+
"access": "public",
|
|
80
|
+
"provenance": true
|
|
73
81
|
}
|
|
74
82
|
}
|
package/public/setup.css
ADDED
|
@@ -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>
|