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/README.md
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
</p>
|
|
12
12
|
|
|
13
13
|
<p align="center">
|
|
14
|
+
<a href="https://www.npmjs.com/package/ava-engine"><img alt="npm" src="https://img.shields.io/npm/v/ava-engine?color=0A0A0C&label=npm"></a>
|
|
15
|
+
<a href="https://scorecard.dev/viewer/?uri=github.com/Mr-vero/ava-engine"><img alt="OpenSSF Scorecard" src="https://api.scorecard.dev/projects/github.com/Mr-vero/ava-engine/badge"></a>
|
|
14
16
|
<a href="https://github.com/Mr-vero/ava-engine/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/Mr-vero/ava-engine/actions/workflows/ci.yml/badge.svg"></a>
|
|
15
17
|
<a href="COMMERCIAL-LICENSE.md"><img alt="License: Ava Engine License (source-available)" src="https://img.shields.io/badge/license-source--available-0A0A0C"></a>
|
|
16
18
|
<img alt="Node 22.9+" src="https://img.shields.io/badge/node-22.9%2B-4F46E5">
|
|
@@ -56,25 +58,25 @@ Requires **Node 22.9+** and desktop **Chrome**. One command installs Ava and wal
|
|
|
56
58
|
npx ava-engine
|
|
57
59
|
```
|
|
58
60
|
|
|
59
|
-
|
|
60
|
-
1.
|
|
61
|
-
2.
|
|
62
|
-
3.
|
|
63
|
-
4.
|
|
64
|
-
5.
|
|
61
|
+
Set up **in your browser** (a step-by-step page) or **in the terminal**; both do the same:
|
|
62
|
+
1. check your OpenRouter key (or look around with no keys, in MOCK mode);
|
|
63
|
+
2. pick Ava's voice, and download the free offline voice (Kokoro, ~90 MB) with progress;
|
|
64
|
+
3. download offline speech recognition, also with progress;
|
|
65
|
+
4. protect Ava with a password if other people will use it;
|
|
66
|
+
5. start Ava and open **http://localhost:5190/app**.
|
|
65
67
|
|
|
66
68
|
After that, `npx ava-engine` just starts Ava.
|
|
67
69
|
|
|
68
70
|
| Command | What it does |
|
|
69
71
|
| --- | --- |
|
|
70
|
-
| `npx ava-engine setup` | Re-run
|
|
72
|
+
| `npx ava-engine setup` | Re-run setup to change keys, voice, password or port (`--web` for the browser, `--terminal` for here) |
|
|
71
73
|
| `npx ava-engine doctor` | Check everything and get a fix for anything that's wrong |
|
|
72
|
-
| `npx ava-engine models` |
|
|
74
|
+
| `npx ava-engine models` | Download speech models or the Kokoro voice (`models kokoro`) |
|
|
73
75
|
| `npx ava-engine start --port 8080` | Start on another port |
|
|
74
76
|
|
|
75
77
|
Settings live in `~/.ava-engine` (set `AVA_HOME` to move them). **Watch the demo locally** at `http://localhost:5190/app?demo=simple`.
|
|
76
78
|
|
|
77
|
-
**
|
|
79
|
+
**On a server:** `npx ava-engine setup --web` works over SSH too. Forward the port (`ssh -L 5190:localhost:5190 your-server`) and open the link it prints. Or skip the questions: `OPENROUTER_API_KEY=… ACCESS_PASSWORD=… npx ava-engine setup --yes --models english-small,kokoro`. There's also a Docker image; see [DEPLOY.md](DEPLOY.md).
|
|
78
80
|
|
|
79
81
|
<details>
|
|
80
82
|
<summary><b>Working on Ava itself? Run from source</b></summary>
|
package/lib/asr.mjs
CHANGED
|
@@ -76,7 +76,11 @@ function getOffline() {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
export function warmAsr() {
|
|
79
|
-
if (!asrAvailable()) {
|
|
79
|
+
if (!asrAvailable()) {
|
|
80
|
+
if (initErr) console.log(" speech recognition: the offline engine didn't load; using the browser's (run `npx ava-engine doctor`)");
|
|
81
|
+
else console.log(" speech recognition: using the browser's. For offline recognition: npx ava-engine models");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
80
84
|
const e = availableEngines();
|
|
81
85
|
if (e.includes("zipformer") && getOnline()) console.log(" sherpa ASR ready — English streaming (zipformer)");
|
|
82
86
|
if (e.includes("sensevoice")) console.log(" sherpa ASR ready — multilingual (SenseVoice), lazy-loaded on first use");
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Turning setup answers into saved settings. Shared by the terminal wizard and
|
|
2
|
+
// the browser setup, so both produce exactly the same configuration.
|
|
3
|
+
import { MODELS_DIR, DEFAULT_PORT } from "./config.mjs";
|
|
4
|
+
|
|
5
|
+
export const VOICES = {
|
|
6
|
+
kokoro: { label: "Kokoro", hint: "free, runs on this computer (~90 MB download)" },
|
|
7
|
+
elevenlabs: { label: "ElevenLabs", hint: "premium cloud voices, needs an ElevenLabs key" },
|
|
8
|
+
browser: { label: "The browser's voice", hint: "no setup, sounds robotic" },
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* answers: {
|
|
13
|
+
* openrouterKey?: string | null (null/"" → MOCK mode; undefined → keep the current key)
|
|
14
|
+
* voice: "kokoro" | "elevenlabs" | "browser",
|
|
15
|
+
* elevenKey?, elevenVoice?,
|
|
16
|
+
* audience: "me" | "others", password?, proxy?: boolean,
|
|
17
|
+
* port?: number
|
|
18
|
+
* }
|
|
19
|
+
*/
|
|
20
|
+
export function buildConfig(prev, a) {
|
|
21
|
+
const cfg = { ...prev };
|
|
22
|
+
const set = (k, v) => { if (v === undefined || v === null || v === "") delete cfg[k]; else cfg[k] = String(v); };
|
|
23
|
+
|
|
24
|
+
if (a.openrouterKey !== undefined) set("OPENROUTER_API_KEY", a.openrouterKey);
|
|
25
|
+
|
|
26
|
+
const voice = VOICES[a.voice] ? a.voice : "kokoro";
|
|
27
|
+
set("TTS_DEFAULT", voice);
|
|
28
|
+
set("KOKORO", voice === "kokoro" ? "on" : "off");
|
|
29
|
+
if (voice === "elevenlabs") {
|
|
30
|
+
if (a.elevenKey !== undefined) set("ELEVENLABS_API_KEY", a.elevenKey);
|
|
31
|
+
if (a.elevenVoice !== undefined) set("ELEVEN_VOICE_ID", a.elevenVoice);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (a.audience === "others") {
|
|
35
|
+
set("HOST", "");
|
|
36
|
+
if (a.password !== undefined) set("ACCESS_PASSWORD", a.password);
|
|
37
|
+
set("TRUST_PROXY", a.proxy ? "1" : "");
|
|
38
|
+
} else {
|
|
39
|
+
set("HOST", "127.0.0.1");
|
|
40
|
+
set("ACCESS_PASSWORD", "");
|
|
41
|
+
set("TRUST_PROXY", "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const port = Number(a.port);
|
|
45
|
+
set("PORT", Number.isInteger(port) && port > 0 && port < 65536 ? port : prev.PORT || DEFAULT_PORT);
|
|
46
|
+
set("AVA_MODELS_DIR", MODELS_DIR);
|
|
47
|
+
return cfg;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Problems that must be fixed before saving, as a list of human messages. */
|
|
51
|
+
export function validateAnswers(a) {
|
|
52
|
+
const errors = [];
|
|
53
|
+
if (a.audience === "others" && (!a.password || String(a.password).length < 10)) errors.push("Choose an access password of at least 10 characters.");
|
|
54
|
+
if (a.voice === "elevenlabs" && a.elevenKey === "") errors.push("Add your ElevenLabs key, or pick another voice.");
|
|
55
|
+
const port = Number(a.port);
|
|
56
|
+
if (a.port !== undefined && !(Number.isInteger(port) && port > 0 && port < 65536)) errors.push("The port must be a number between 1 and 65535.");
|
|
57
|
+
return errors;
|
|
58
|
+
}
|
package/lib/cli/main.mjs
CHANGED
|
@@ -14,17 +14,19 @@ const HELP = `
|
|
|
14
14
|
|
|
15
15
|
Usage
|
|
16
16
|
npx ava-engine first run: setup wizard, then start. After that: start
|
|
17
|
-
npx ava-engine setup (re)run the
|
|
17
|
+
npx ava-engine setup (re)run setup; asks: in the browser or the terminal
|
|
18
|
+
npx ava-engine setup --web setup in your browser
|
|
19
|
+
npx ava-engine setup --terminal
|
|
18
20
|
npx ava-engine start start the server
|
|
19
21
|
npx ava-engine doctor check your setup and tell you what to fix
|
|
20
|
-
npx ava-engine models [..] download
|
|
22
|
+
npx ava-engine models [..] download models: ${Object.keys(MODEL_PACKS).join(", ")}, kokoro (voice)
|
|
21
23
|
|
|
22
24
|
Options
|
|
23
25
|
--port <n> port to listen on (default ${DEFAULT_PORT})
|
|
24
26
|
--open open the browser once Ava is up (default on a first run)
|
|
25
27
|
--no-open don't open the browser
|
|
26
28
|
--yes setup without questions, from environment variables
|
|
27
|
-
--models <a,b> with --yes:
|
|
29
|
+
--models <a,b> with --yes: what to download, e.g. english-small,kokoro
|
|
28
30
|
-v, --version print the version
|
|
29
31
|
-h, --help show this help
|
|
30
32
|
|
|
@@ -40,6 +42,8 @@ function parseArgs(argv) {
|
|
|
40
42
|
else if (a === "-v" || a === "--version") out.flags.version = true;
|
|
41
43
|
else if (a === "--yes" || a === "-y") out.flags.yes = true;
|
|
42
44
|
else if (a === "--open") out.flags.open = true;
|
|
45
|
+
else if (a === "--web") out.flags.web = true;
|
|
46
|
+
else if (a === "--terminal") out.flags.terminal = true;
|
|
43
47
|
else if (a === "--no-open") out.flags.open = false;
|
|
44
48
|
else if (a === "--port") out.flags.port = Number(argv[++i]);
|
|
45
49
|
else if (a.startsWith("--port=")) out.flags.port = Number(a.slice(7));
|
|
@@ -116,7 +120,7 @@ export async function main(argv) {
|
|
|
116
120
|
case "doctor": return doctor();
|
|
117
121
|
case "start": await start({ port: flags.port, open: flags.open === true }); return null; // keeps running
|
|
118
122
|
case "models": {
|
|
119
|
-
const { downloadPacks } = await import("./setup.mjs");
|
|
123
|
+
const { downloadPacks, downloadKokoro } = await import("./setup.mjs");
|
|
120
124
|
let packs = args._.slice(1);
|
|
121
125
|
if (!packs.length && interactive) {
|
|
122
126
|
const p = await import("@clack/prompts");
|
|
@@ -126,19 +130,50 @@ export async function main(argv) {
|
|
|
126
130
|
packs = sel;
|
|
127
131
|
}
|
|
128
132
|
if (!packs.length) packs = ["english-small"];
|
|
129
|
-
const bad = packs.filter((x) => !MODEL_PACKS[x]);
|
|
130
|
-
if (bad.length) { console.error(`✗ unknown model
|
|
131
|
-
|
|
133
|
+
const bad = packs.filter((x) => !MODEL_PACKS[x] && x !== "kokoro");
|
|
134
|
+
if (bad.length) { console.error(`✗ unknown model: ${bad.join(", ")}. Choose from: ${Object.keys(MODEL_PACKS).join(", ")}, kokoro`); return 2; }
|
|
135
|
+
if (packs.includes("kokoro")) await downloadKokoro();
|
|
136
|
+
await downloadPacks(packs.filter((x) => x !== "kokoro"));
|
|
132
137
|
return 0;
|
|
133
138
|
}
|
|
134
139
|
case "setup":
|
|
135
140
|
case "first-run": {
|
|
136
141
|
const setup = await import("./setup.mjs");
|
|
137
|
-
|
|
142
|
+
// --web needs a browser, not a terminal, so it works over SSH and from scripts too.
|
|
143
|
+
if (!interactive && !(flags.web && !flags.yes)) {
|
|
138
144
|
await setup.runSetupNonInteractive({ models: flags.models || [], port: flags.port });
|
|
139
145
|
if (cmd === "first-run") { await start({ port: flags.port, open: flags.open === true }); return null; }
|
|
140
146
|
return 0;
|
|
141
147
|
}
|
|
148
|
+
// Where to run the wizard: the browser (default) or this terminal.
|
|
149
|
+
let where = flags.web ? "web" : flags.terminal ? "terminal" : null;
|
|
150
|
+
if (!where) {
|
|
151
|
+
const p = await import("@clack/prompts");
|
|
152
|
+
console.log("");
|
|
153
|
+
p.intro(" ΛVΛ Ava Engine setup ");
|
|
154
|
+
where = await p.select({
|
|
155
|
+
message: "Where would you like to set up Ava?",
|
|
156
|
+
options: [
|
|
157
|
+
{ value: "web", label: "In my browser", hint: "recommended; a step-by-step page" },
|
|
158
|
+
{ value: "terminal", label: "Here in the terminal" },
|
|
159
|
+
],
|
|
160
|
+
});
|
|
161
|
+
if (p.isCancel(where)) { p.cancel("Setup cancelled. Nothing was changed."); return 0; }
|
|
162
|
+
}
|
|
163
|
+
if (where === "web") {
|
|
164
|
+
const { runWebSetup } = await import("./setup-server.mjs");
|
|
165
|
+
const r = await runWebSetup({
|
|
166
|
+
preferredPort: flags.port,
|
|
167
|
+
formPort: flags.port,
|
|
168
|
+
onReady: ({ url }) => {
|
|
169
|
+
console.log(`\n Setup is open in your browser. If it didn't open, go to:\n ${url}\n\n (Keep this terminal open. Press Ctrl+C to cancel.)\n`);
|
|
170
|
+
if (flags.open !== false) openBrowser(url);
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
console.log(" Settings saved. Starting Ava…");
|
|
174
|
+
await start({ port: r.port, open: false }); // the port chosen on the page wins; the page redirects itself
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
142
177
|
const r = await setup.runSetup();
|
|
143
178
|
if (r.start) { await start({ port: flags.port || r.port, open: flags.open !== false }); return null; }
|
|
144
179
|
return 0;
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// `ava-engine setup --web`: the setup wizard as a page in your browser.
|
|
2
|
+
//
|
|
3
|
+
// A small, separate server that runs only during setup:
|
|
4
|
+
// - listens on 127.0.0.1 only,
|
|
5
|
+
// - every API call needs the one-time token from the URL the CLI opens,
|
|
6
|
+
// - rejects requests whose Host isn't localhost (stops DNS-rebinding attacks),
|
|
7
|
+
// - serves a strict Content-Security-Policy.
|
|
8
|
+
// When you press "Launch", it saves the settings, closes, and the CLI starts Ava.
|
|
9
|
+
|
|
10
|
+
import http from "node:http";
|
|
11
|
+
import { readFile } from "node:fs/promises";
|
|
12
|
+
import { join, dirname } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
15
|
+
import { CONFIG_FILE, MODELS_DIR, DEFAULT_PORT, readConfig, writeConfig, checkOpenRouterKey, checkElevenLabsKey, portFree, suggestPassword } from "./config.mjs";
|
|
16
|
+
import { buildConfig, validateAnswers, VOICES } from "./apply.mjs";
|
|
17
|
+
import { MODEL_PACKS, installedPacks, fetchPack } from "../fetch-models.mjs";
|
|
18
|
+
import { loadKokoro, kokoroInstalled, KOKORO_SIZE } from "../kokoro-model.mjs";
|
|
19
|
+
|
|
20
|
+
const PUBLIC = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "public");
|
|
21
|
+
const STATIC = new Map([
|
|
22
|
+
["/setup", ["setup.html", "text/html; charset=utf-8"]],
|
|
23
|
+
["/setup.js", ["setup.js", "text/javascript; charset=utf-8"]],
|
|
24
|
+
["/setup.css", ["setup.css", "text/css; charset=utf-8"]],
|
|
25
|
+
["/favicon.svg", ["favicon.svg", "image/svg+xml"]],
|
|
26
|
+
["/fonts/inter-var.woff2", ["fonts/inter-var.woff2", "font/woff2"]],
|
|
27
|
+
["/fonts/inter-tight-var.woff2", ["fonts/inter-tight-var.woff2", "font/woff2"]],
|
|
28
|
+
["/fonts/plex-mono-400.woff2", ["fonts/plex-mono-400.woff2", "font/woff2"]],
|
|
29
|
+
["/fonts/plex-mono-500.woff2", ["fonts/plex-mono-500.woff2", "font/woff2"]],
|
|
30
|
+
]);
|
|
31
|
+
const HEADERS = {
|
|
32
|
+
"X-Content-Type-Options": "nosniff",
|
|
33
|
+
"Referrer-Policy": "no-referrer",
|
|
34
|
+
"Cache-Control": "no-store",
|
|
35
|
+
"Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:*; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const mask = (v) => (v ? `${v.slice(0, 6)}…${v.slice(-4)}` : "");
|
|
39
|
+
|
|
40
|
+
async function findPort(preferred) {
|
|
41
|
+
for (let p = preferred; p < preferred + 20; p++) if (await portFree(p, "127.0.0.1")) return p;
|
|
42
|
+
return 0; // let the OS pick
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run the browser setup. Resolves { start: true, port } once you press Launch.
|
|
47
|
+
* onReady({ url }) is called when the page is ready to open.
|
|
48
|
+
*/
|
|
49
|
+
export async function runWebSetup({ preferredPort, formPort, onReady } = {}) {
|
|
50
|
+
process.env.AVA_MODELS_DIR ??= MODELS_DIR; // so Kokoro downloads land in ~/.ava-engine/models
|
|
51
|
+
const prev = readConfig() || {};
|
|
52
|
+
const token = randomBytes(24).toString("base64url");
|
|
53
|
+
const jobs = {}; // id → { label, state: "running" | "done" | "error", pct, error }
|
|
54
|
+
|
|
55
|
+
const okToken = (req) => {
|
|
56
|
+
const t = Buffer.from(String(req.headers["x-setup-token"] || ""));
|
|
57
|
+
const want = Buffer.from(token);
|
|
58
|
+
return t.length === want.length && timingSafeEqual(t, want);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function startJob(id) {
|
|
62
|
+
if (jobs[id]?.state === "running" || jobs[id]?.state === "done") return;
|
|
63
|
+
if (id === "kokoro") {
|
|
64
|
+
jobs[id] = { label: `Kokoro voice (${KOKORO_SIZE})`, state: "running", pct: 0 };
|
|
65
|
+
if (kokoroInstalled()) { jobs[id] = { ...jobs[id], state: "done", pct: 100 }; return; }
|
|
66
|
+
loadKokoro({ onProgress: ({ pct }) => { jobs[id].pct = pct; } })
|
|
67
|
+
.then(() => { jobs[id].state = "done"; jobs[id].pct = 100; })
|
|
68
|
+
.catch((e) => { jobs[id].state = "error"; jobs[id].error = e.message; });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const spec = MODEL_PACKS[id];
|
|
72
|
+
jobs[id] = { label: `${spec.label} (${spec.size})`, state: "running", pct: 0 };
|
|
73
|
+
fetchPack(id, MODELS_DIR, (ev) => {
|
|
74
|
+
if (ev.phase === "download" && ev.total) jobs[id].pct = Math.min(99, Math.floor((ev.done / ev.total) * 100));
|
|
75
|
+
if (ev.phase === "extract") jobs[id].phase = "unpacking";
|
|
76
|
+
}).then(() => { jobs[id].state = "done"; jobs[id].pct = 100; })
|
|
77
|
+
.catch((e) => { jobs[id].state = "error"; jobs[id].error = e.message; });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function state() {
|
|
81
|
+
const c = readConfig() || prev;
|
|
82
|
+
return {
|
|
83
|
+
configFile: CONFIG_FILE,
|
|
84
|
+
current: {
|
|
85
|
+
openrouterKey: mask(c.OPENROUTER_API_KEY),
|
|
86
|
+
voice: VOICES[c.TTS_DEFAULT] ? c.TTS_DEFAULT : "kokoro",
|
|
87
|
+
elevenKey: mask(c.ELEVENLABS_API_KEY),
|
|
88
|
+
elevenVoice: c.ELEVEN_VOICE_ID || "",
|
|
89
|
+
audience: c.ACCESS_PASSWORD ? "others" : "me",
|
|
90
|
+
hasPassword: !!c.ACCESS_PASSWORD,
|
|
91
|
+
proxy: c.TRUST_PROXY === "1",
|
|
92
|
+
port: formPort || Number(c.PORT) || DEFAULT_PORT,
|
|
93
|
+
},
|
|
94
|
+
voices: VOICES,
|
|
95
|
+
kokoro: { installed: kokoroInstalled(), size: KOKORO_SIZE },
|
|
96
|
+
packs: Object.fromEntries(Object.entries(MODEL_PACKS).map(([id, m]) => [id, { label: m.label, size: m.size, hint: m.hint }])),
|
|
97
|
+
installedPacks: installedPacks(MODELS_DIR),
|
|
98
|
+
suggestedPassword: suggestPassword(),
|
|
99
|
+
jobs,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let finish;
|
|
104
|
+
const finished = new Promise((r) => { finish = r; });
|
|
105
|
+
let listenPort = 0;
|
|
106
|
+
|
|
107
|
+
const server = http.createServer(async (req, res) => {
|
|
108
|
+
const send = (code, body, type = "application/json") => {
|
|
109
|
+
res.writeHead(code, { ...HEADERS, "Content-Type": type });
|
|
110
|
+
res.end(typeof body === "string" || Buffer.isBuffer(body) ? body : JSON.stringify(body));
|
|
111
|
+
};
|
|
112
|
+
// DNS-rebinding guard: only answer requests addressed to this machine.
|
|
113
|
+
const host = String(req.headers.host || "");
|
|
114
|
+
if (!/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(host)) return send(403, { error: "forbidden host" });
|
|
115
|
+
let url;
|
|
116
|
+
try { url = new URL(req.url, "http://localhost"); } catch { return send(400, { error: "bad request" }); }
|
|
117
|
+
|
|
118
|
+
if (req.method === "GET" && STATIC.has(url.pathname)) {
|
|
119
|
+
const [file, type] = STATIC.get(url.pathname);
|
|
120
|
+
try { return send(200, await readFile(join(PUBLIC, file)), type); } catch { return send(404, "not found", "text/plain"); }
|
|
121
|
+
}
|
|
122
|
+
if (req.method === "GET" && url.pathname === "/") { res.writeHead(302, { Location: "/setup" }); return res.end(); }
|
|
123
|
+
if (!url.pathname.startsWith("/api/setup/")) return send(404, "not found", "text/plain");
|
|
124
|
+
if (!okToken(req)) return send(401, { error: "This setup link has expired. Run `npx ava-engine setup --web` for a new one." });
|
|
125
|
+
|
|
126
|
+
let body = {};
|
|
127
|
+
if (req.method === "POST") {
|
|
128
|
+
if (!/^application\/json\b/i.test(String(req.headers["content-type"] || ""))) return send(415, { error: "expected application/json" });
|
|
129
|
+
const chunks = []; let size = 0;
|
|
130
|
+
for await (const c of req) { size += c.length; if (size > 64 * 1024) return send(413, { error: "too large" }); chunks.push(c); }
|
|
131
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}"); } catch { return send(400, { error: "invalid JSON" }); }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const route = `${req.method} ${url.pathname}`;
|
|
136
|
+
if (route === "GET /api/setup/state") return send(200, state());
|
|
137
|
+
if (route === "GET /api/setup/jobs") return send(200, { jobs, installedPacks: installedPacks(MODELS_DIR), kokoroInstalled: kokoroInstalled() });
|
|
138
|
+
if (route === "GET /api/setup/password") return send(200, { password: suggestPassword() });
|
|
139
|
+
if (route === "POST /api/setup/check") {
|
|
140
|
+
const key = String(body.key || "").trim();
|
|
141
|
+
if (key.length < 20) return send(200, { ok: false, error: "That doesn't look like a full key." });
|
|
142
|
+
const r = body.service === "elevenlabs" ? await checkElevenLabsKey(key) : await checkOpenRouterKey(key);
|
|
143
|
+
if (r.ok && r.label && /^sk-or/i.test(r.label)) delete r.label; // unnamed keys report a masked key as their label
|
|
144
|
+
return send(200, r);
|
|
145
|
+
}
|
|
146
|
+
if (route === "POST /api/setup/download") {
|
|
147
|
+
const id = String(body.id || "");
|
|
148
|
+
if (id !== "kokoro" && !MODEL_PACKS[id]) return send(400, { error: "unknown download" });
|
|
149
|
+
startJob(id);
|
|
150
|
+
return send(200, { ok: true, job: jobs[id] });
|
|
151
|
+
}
|
|
152
|
+
if (route === "POST /api/setup/save") {
|
|
153
|
+
const a = body.answers || {};
|
|
154
|
+
// "keep" means: leave the saved key as it is.
|
|
155
|
+
const answers = {
|
|
156
|
+
openrouterKey: a.mode === "mock" ? null : a.openrouterKey === "keep" ? undefined : String(a.openrouterKey || "") || null,
|
|
157
|
+
voice: a.voice,
|
|
158
|
+
elevenKey: a.elevenKey === "keep" ? undefined : a.elevenKey,
|
|
159
|
+
elevenVoice: a.elevenVoice,
|
|
160
|
+
audience: a.audience === "others" ? "others" : "me",
|
|
161
|
+
password: a.audience === "others" ? (a.password === "keep" ? prev.ACCESS_PASSWORD || readConfig()?.ACCESS_PASSWORD : a.password) : undefined,
|
|
162
|
+
proxy: !!a.proxy,
|
|
163
|
+
port: a.port,
|
|
164
|
+
};
|
|
165
|
+
const errors = validateAnswers(answers);
|
|
166
|
+
if (errors.length) return send(400, { error: errors.join(" ") });
|
|
167
|
+
const cfg = buildConfig(readConfig() || prev, answers);
|
|
168
|
+
const p = Number(cfg.PORT);
|
|
169
|
+
if (p !== listenPort && !(await portFree(p, cfg.HOST))) return send(409, { error: `Port ${p} is in use by another program. Pick another port.` });
|
|
170
|
+
writeConfig(cfg);
|
|
171
|
+
return send(200, { ok: true, port: p, url: `http://localhost:${p}/app`, configFile: CONFIG_FILE });
|
|
172
|
+
}
|
|
173
|
+
if (route === "POST /api/setup/finish") {
|
|
174
|
+
const cfg = readConfig();
|
|
175
|
+
if (!cfg) return send(400, { error: "Save your settings first." });
|
|
176
|
+
// Downloads still running (e.g. a voice you switched away from) keep going in
|
|
177
|
+
// this process after Ava starts; the page already waited for the ones you chose.
|
|
178
|
+
send(200, { ok: true, port: Number(cfg.PORT) || DEFAULT_PORT });
|
|
179
|
+
// let the response flush, then hand the port over to Ava
|
|
180
|
+
setTimeout(() => { server.closeAllConnections?.(); server.close(() => finish({ start: true, port: Number(cfg.PORT) || DEFAULT_PORT })); }, 150);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
return send(404, { error: "unknown endpoint" });
|
|
184
|
+
} catch (e) {
|
|
185
|
+
return send(500, { error: e.message });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
listenPort = await findPort(preferredPort || Number(prev.PORT) || DEFAULT_PORT);
|
|
190
|
+
await new Promise((r) => server.listen(listenPort, "127.0.0.1", r));
|
|
191
|
+
listenPort = server.address().port;
|
|
192
|
+
const url = `http://localhost:${listenPort}/setup#token=${token}`;
|
|
193
|
+
onReady?.({ url, port: listenPort });
|
|
194
|
+
return finished;
|
|
195
|
+
}
|