ava-engine 0.3.0 → 0.4.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 +20 -2
- package/lib/admin.mjs +284 -0
- package/lib/analytics.mjs +73 -0
- package/lib/calls.mjs +97 -0
- package/lib/cli/config.mjs +1 -0
- package/lib/cli/main.mjs +43 -3
- package/lib/cli/setup-server.mjs +3 -2
- package/lib/cli/setup.mjs +1 -1
- package/lib/draft.mjs +53 -0
- package/lib/guard.mjs +73 -28
- package/lib/library.mjs +76 -0
- package/lib/links.mjs +148 -0
- package/lib/mock.mjs +14 -4
- package/lib/models.mjs +10 -4
- package/lib/openrouter.mjs +4 -1
- package/lib/ours.mjs +3 -1
- package/lib/platforms.mjs +70 -8
- package/lib/playbooks.mjs +11 -2
- package/lib/safe-fetch.mjs +79 -0
- package/lib/settings.mjs +187 -0
- package/lib/spend.mjs +4 -1
- package/lib/state.mjs +35 -5
- package/lib/stt.mjs +4 -1
- package/lib/team.mjs +191 -0
- package/lib/tts.mjs +18 -11
- package/lib/webhooks.mjs +51 -7
- package/package.json +3 -3
- package/public/app.js +84 -3
- package/public/dashboard.css +430 -0
- package/public/dashboard.html +76 -0
- package/public/dashboard.js +1096 -0
- package/public/embed.js +60 -0
- package/public/index.html +17 -10
- package/public/login.css +30 -0
- package/public/login.html +37 -0
- package/public/login.js +27 -0
- package/public/setup.css +138 -57
- package/public/setup.html +40 -19
- package/public/setup.js +15 -7
- package/public/style.css +17 -0
- package/server.mjs +142 -39
package/lib/tts.mjs
CHANGED
|
@@ -4,20 +4,28 @@
|
|
|
4
4
|
// is the on-device Web Speech fallback.
|
|
5
5
|
|
|
6
6
|
import { loadKokoro, kokoroInstalled } from "./kokoro-model.mjs";
|
|
7
|
+
import { onSettingsChange } from "./settings.mjs";
|
|
7
8
|
|
|
8
|
-
const
|
|
9
|
-
const HAS_HUME = !!process.env.HUME_API_KEY;
|
|
10
|
-
const HAS_KOKORO = process.env.KOKORO !== "off"; // local, free — on by default
|
|
11
|
-
|
|
12
|
-
const ELEVEN_VOICE = process.env.ELEVEN_VOICE_ID || "XrExE9yKIg1WjnnlVkGX";
|
|
9
|
+
const HAS_KOKORO = process.env.KOKORO !== "off"; // local, free — on by default (loading it needs a restart)
|
|
13
10
|
const ELEVEN_MODEL = process.env.ELEVEN_MODEL || "eleven_turbo_v2_5";
|
|
14
|
-
const ELEVEN_LABEL = process.env.ELEVEN_LABEL || "Matilda";
|
|
15
|
-
const HUME_VOICE = process.env.HUME_VOICE_ID || "a7ecc00a-6fc0-4546-8126-e12cfd8de3bf";
|
|
16
|
-
const HUME_LABEL = process.env.HUME_LABEL || "Aria";
|
|
17
11
|
const HUME_DESC = process.env.HUME_DESCRIPTION || "Warm, empathetic, natural and reassuring";
|
|
18
|
-
const KOKORO_VOICE = process.env.KOKORO_VOICE || "af_heart";
|
|
19
12
|
|
|
20
|
-
|
|
13
|
+
// Keys and voices can change from the dashboard; these reload when they do.
|
|
14
|
+
let HAS_ELEVEN, HAS_HUME, ELEVEN_VOICE, ELEVEN_LABEL, HUME_VOICE, HUME_LABEL, KOKORO_VOICE;
|
|
15
|
+
export let TTS_ENABLED, ELEVEN_STREAMS;
|
|
16
|
+
function load() {
|
|
17
|
+
HAS_ELEVEN = !!process.env.ELEVENLABS_API_KEY;
|
|
18
|
+
HAS_HUME = !!process.env.HUME_API_KEY;
|
|
19
|
+
ELEVEN_VOICE = process.env.ELEVEN_VOICE_ID || "XrExE9yKIg1WjnnlVkGX";
|
|
20
|
+
ELEVEN_LABEL = process.env.ELEVEN_LABEL || "Matilda";
|
|
21
|
+
HUME_VOICE = process.env.HUME_VOICE_ID || "a7ecc00a-6fc0-4546-8126-e12cfd8de3bf";
|
|
22
|
+
HUME_LABEL = process.env.HUME_LABEL || "Aria";
|
|
23
|
+
KOKORO_VOICE = process.env.KOKORO_VOICE || "af_heart";
|
|
24
|
+
TTS_ENABLED = HAS_KOKORO || HAS_ELEVEN || HAS_HUME;
|
|
25
|
+
ELEVEN_STREAMS = HAS_ELEVEN;
|
|
26
|
+
}
|
|
27
|
+
load();
|
|
28
|
+
onSettingsChange(load);
|
|
21
29
|
const FETCH_TIMEOUT_MS = 20_000;
|
|
22
30
|
const MAX_KOKORO_QUEUE = Number(process.env.KOKORO_MAX_QUEUE) || 6;
|
|
23
31
|
|
|
@@ -132,7 +140,6 @@ async function eleven(text) {
|
|
|
132
140
|
as Kokoro) so first audio lands in ~0.3-0.5s instead of waiting for the whole
|
|
133
141
|
clip. previous_text is passed so prosody stays continuous across chunks. Each
|
|
134
142
|
chunk is a complete MP3 the client decodes independently. onChunk(mp3, text). */
|
|
135
|
-
export const ELEVEN_STREAMS = HAS_ELEVEN;
|
|
136
143
|
export async function elevenStream(text, onChunk) {
|
|
137
144
|
const clean = String(text || "").slice(0, 800).trim();
|
|
138
145
|
if (!clean) throw new Error("empty text");
|
package/lib/webhooks.mjs
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// WEBHOOK_URL one or more URLs (comma-separated) that receive every event as JSON
|
|
4
4
|
// WEBHOOK_SECRET signs each delivery: X-Ava-Signature: sha256=HMAC(secret, `${timestamp}.${body}`)
|
|
5
5
|
// SLACK_WEBHOOK_URL a Slack incoming webhook; posts when a customer asks for a person
|
|
6
|
+
// SLACK_BOT_TOKEN + SLACK_CHANNEL instead: one thread per handoff, with "picked up
|
|
7
|
+
// by…" and "call ended…" replies (a bot with chat:write)
|
|
6
8
|
// PUBLIC_URL where specialists open Ava (for the links in those messages)
|
|
7
9
|
//
|
|
8
10
|
// Events: session.started, step.completed, workflow.completed, handoff.requested,
|
|
@@ -11,12 +13,23 @@
|
|
|
11
13
|
|
|
12
14
|
import { createHmac, randomUUID } from "node:crypto";
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
const SECRET = process.env.WEBHOOK_SECRET || "";
|
|
16
|
-
const SLACK = process.env.SLACK_WEBHOOK_URL || "";
|
|
17
|
-
const TIMEOUT_MS = 5_000, RETRY_MS = 2_000;
|
|
16
|
+
import { onSettingsChange } from "./settings.mjs";
|
|
18
17
|
|
|
19
|
-
|
|
18
|
+
const TIMEOUT_MS = 5_000, RETRY_MS = 2_000;
|
|
19
|
+
let URLS = [], SECRET = "", SLACK = "", BOT = "", CHANNEL = "";
|
|
20
|
+
export let WEBHOOKS_ENABLED = false;
|
|
21
|
+
/** What's configured, for the dashboard. Never the URLs themselves (Slack URLs are secrets). */
|
|
22
|
+
export const WEBHOOK_INFO = { endpoints: 0, hosts: [], signed: false, slack: false };
|
|
23
|
+
function load() {
|
|
24
|
+
URLS = (process.env.WEBHOOK_URL || "").split(",").map((u) => u.trim()).filter(Boolean);
|
|
25
|
+
SECRET = process.env.WEBHOOK_SECRET || "";
|
|
26
|
+
SLACK = process.env.SLACK_WEBHOOK_URL || "";
|
|
27
|
+
BOT = process.env.SLACK_BOT_TOKEN || ""; CHANNEL = process.env.SLACK_CHANNEL || "";
|
|
28
|
+
WEBHOOKS_ENABLED = URLS.length > 0 || !!SLACK || !!(BOT && CHANNEL);
|
|
29
|
+
Object.assign(WEBHOOK_INFO, { endpoints: URLS.length, hosts: URLS.map((u) => { try { return new URL(u).host; } catch { return "?"; } }), signed: !!SECRET, slack: !!SLACK || !!(BOT && CHANNEL), slackThreads: !!(BOT && CHANNEL) });
|
|
30
|
+
}
|
|
31
|
+
load();
|
|
32
|
+
onSettingsChange(load);
|
|
20
33
|
|
|
21
34
|
/** The URL a specialist opens to join a call. */
|
|
22
35
|
export function specialistUrl(key) {
|
|
@@ -45,6 +58,8 @@ export function sign(body, timestamp, secret = SECRET) {
|
|
|
45
58
|
}
|
|
46
59
|
|
|
47
60
|
function slackText(type, data) {
|
|
61
|
+
if (type === "ava.test") return ":white_check_mark: Ava is connected to this channel. You'll get a message here when a customer asks for a person.";
|
|
62
|
+
if (type === "handoff.picked_up") return null; // threads only
|
|
48
63
|
if (type !== "handoff.requested") return null;
|
|
49
64
|
const where = data.platform ? ` on ${data.platform.name}` : "";
|
|
50
65
|
const step = data.workflow?.currentStep ? `\n*Stuck on:* ${data.workflow.currentStep} (${data.workflow.done}/${data.workflow.total} steps done)` : "";
|
|
@@ -62,7 +77,36 @@ export function emitEvent(type, data) {
|
|
|
62
77
|
const headers = { "X-Ava-Event": type, "X-Ava-Delivery": event.id, "X-Ava-Timestamp": ts, ...(SECRET ? { "X-Ava-Signature": sign(body, ts) } : {}) };
|
|
63
78
|
return post(url, body, headers, "webhook");
|
|
64
79
|
});
|
|
65
|
-
|
|
66
|
-
if (text) jobs.push(post(SLACK, JSON.stringify({ text }), {}, "slack"));
|
|
80
|
+
if (BOT && CHANNEL) jobs.push(slackBot(type, data));
|
|
81
|
+
else { const text = SLACK && slackText(type, data); if (text) jobs.push(post(SLACK, JSON.stringify({ text }), {}, "slack")); }
|
|
67
82
|
return Promise.allSettled(jobs);
|
|
68
83
|
}
|
|
84
|
+
|
|
85
|
+
// ── Slack threads (bot token): the handoff opens a thread; updates reply in it ──
|
|
86
|
+
const threads = new Map(); // sessionId → thread ts (bounded)
|
|
87
|
+
async function slackApi(body) {
|
|
88
|
+
try {
|
|
89
|
+
const r = await fetch("https://slack.com/api/chat.postMessage", { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8", Authorization: `Bearer ${BOT}` }, body: JSON.stringify({ channel: CHANNEL, unfurl_links: false, ...body }), signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
90
|
+
const j = await r.json();
|
|
91
|
+
if (!j.ok) console.warn(` [slack] ${j.error || r.status}`);
|
|
92
|
+
return j.ok ? j : null;
|
|
93
|
+
} catch { console.warn(" [slack] delivery failed"); return null; }
|
|
94
|
+
}
|
|
95
|
+
const mins = (s) => `${Math.floor(s / 60)}:${String(Math.round(s % 60)).padStart(2, "0")}`;
|
|
96
|
+
async function slackBot(type, data) {
|
|
97
|
+
if (type === "ava.test") return !!(await slackApi({ text: slackText(type, data) }));
|
|
98
|
+
if (type === "handoff.requested") {
|
|
99
|
+
const j = await slackApi({ text: slackText(type, data) });
|
|
100
|
+
if (j) { threads.set(data.sessionId, j.ts); if (threads.size > 500) threads.delete(threads.keys().next().value); }
|
|
101
|
+
return !!j;
|
|
102
|
+
}
|
|
103
|
+
const ts = threads.get(data.sessionId);
|
|
104
|
+
if (!ts) return true;
|
|
105
|
+
if (type === "handoff.picked_up") return !!(await slackApi({ thread_ts: ts, text: `:handshake: Picked up by *${data.by}*.` }));
|
|
106
|
+
if (type === "session.ended") {
|
|
107
|
+
threads.delete(data.sessionId);
|
|
108
|
+
const steps = data.workflow ? ` · ${data.workflow.done}/${data.workflow.total} steps${data.workflow.completed ? " (finished)" : ""}` : "";
|
|
109
|
+
return !!(await slackApi({ thread_ts: ts, text: `:checkered_flag: Call ended after ${mins(data.durationSec)}${steps}.` }));
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ava-engine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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": [
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
"scripts": {
|
|
40
40
|
"start": "node --env-file-if-exists=.env server.mjs",
|
|
41
41
|
"dev": "node --watch --env-file-if-exists=.env server.mjs",
|
|
42
|
-
"test": "node --test test/*.test.mjs",
|
|
42
|
+
"test": "node --test --import ./test/env.mjs test/*.test.mjs",
|
|
43
43
|
"check": "node --check server.mjs && for f in lib/*.mjs public/*.js; do node --check \"$f\" || exit 1; done",
|
|
44
|
-
"test:e2e": "node --test --test-concurrency=1 test/e2e/*.e2e.mjs",
|
|
44
|
+
"test:e2e": "node --test --import ./test/env.mjs --test-concurrency=1 test/e2e/*.e2e.mjs",
|
|
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",
|
package/public/app.js
CHANGED
|
@@ -17,6 +17,10 @@ const S = {
|
|
|
17
17
|
workflow: null, handoffTimer: null, knowTimer: null,
|
|
18
18
|
specialistPresent: false, bridgeSince: 0, bridgeTimer: null, rtc: null, rtcMic: null,
|
|
19
19
|
};
|
|
20
|
+
// Opened from a share link (/c/<id>)? Then this is a customer's call, set up by the link.
|
|
21
|
+
const LINK_ID = (location.pathname.match(/^\/c\/([A-Za-z0-9_-]{22})\/?$/) || [])[1] || null;
|
|
22
|
+
S.link = null; // the link's public view: { title, intro, mode, platform, options }
|
|
23
|
+
if (LINK_ID) document.documentElement.classList.add("customer");
|
|
20
24
|
const video = $("video"), capcanvas = $("capcanvas"), hashcanvas = $("hashcanvas");
|
|
21
25
|
let pip = null;
|
|
22
26
|
|
|
@@ -54,10 +58,51 @@ let pip = null;
|
|
|
54
58
|
sel.onchange = () => { S.voice = sel.value; $("voicesel").value = S.voice; $("voicesel2").value = S.voice; };
|
|
55
59
|
}
|
|
56
60
|
initOnboard(h);
|
|
61
|
+
if (LINK_ID) await initLink(!!h.mock); else applyUrlParams();
|
|
57
62
|
} catch {}
|
|
58
63
|
populateMics();
|
|
59
64
|
})();
|
|
60
65
|
|
|
66
|
+
/* ═══════════ share links + URL presets ═══════════ */
|
|
67
|
+
// /app?mode=onboarding&product=<id> preselects the lobby (the dashboard's "Test" buttons use it).
|
|
68
|
+
function applyUrlParams() {
|
|
69
|
+
const q = new URLSearchParams(location.search);
|
|
70
|
+
const mode = q.get("mode"), product = q.get("product");
|
|
71
|
+
if (mode === "onboarding" || mode === "flexible") selectMode(mode);
|
|
72
|
+
if (product && S.platforms.some((p) => p.id === product)) { if (!mode) selectMode("onboarding"); selectPlatform(product); }
|
|
73
|
+
}
|
|
74
|
+
async function initLink(demo) {
|
|
75
|
+
let v;
|
|
76
|
+
try {
|
|
77
|
+
const r = await fetch(`/api/link/${LINK_ID}`);
|
|
78
|
+
v = await r.json().catch(() => ({}));
|
|
79
|
+
if (!r.ok) return linkUnavailable(v.error || "This link isn't available right now.");
|
|
80
|
+
} catch { return linkUnavailable("Couldn't reach Ava. Check your connection and reload the page."); }
|
|
81
|
+
S.link = v;
|
|
82
|
+
$("transcript").classList.add("noagents"); // customers see the conversation, not the agents' work
|
|
83
|
+
S.agentMode = v.mode;
|
|
84
|
+
S.platform = v.platform ? { ...v.platform, category: "" } : null;
|
|
85
|
+
if (S.platform && S.platform.accent) applyBrand(S.platform.accent);
|
|
86
|
+
document.title = v.platform ? `${v.platform.name} · Live guide` : "Ava · Live guide";
|
|
87
|
+
$("obAsideTitle").textContent = v.title;
|
|
88
|
+
$("obAsideLead").textContent = v.intro;
|
|
89
|
+
$("obTitle").textContent = "Ready when you are";
|
|
90
|
+
$("obLead").textContent = "Check your microphone, then start. You can type instead of talking at any time.";
|
|
91
|
+
$("obAudioLabel").textContent = "Your microphone";
|
|
92
|
+
$("joinvoiceText").textContent = "Start with voice";
|
|
93
|
+
$("jointext").textContent = "Type instead";
|
|
94
|
+
if (demo) $("obDemoText").textContent = "Ava's replies are canned for now.";
|
|
95
|
+
const steps = v.platform && v.options.steps ? v.platform.journey : [];
|
|
96
|
+
$("obJourneyBlock").hidden = !steps.length;
|
|
97
|
+
$("obJourney").innerHTML = steps.map((s) => `<li>${esc(s)}</li>`).join("");
|
|
98
|
+
}
|
|
99
|
+
function linkUnavailable(msg) {
|
|
100
|
+
document.querySelector(".ob-panel").classList.add("ob-unavailable");
|
|
101
|
+
$("obTitle").textContent = "This link isn't available";
|
|
102
|
+
$("obLead").textContent = msg;
|
|
103
|
+
$("joinvoice").disabled = true; $("jointext").disabled = true;
|
|
104
|
+
}
|
|
105
|
+
|
|
61
106
|
/* ═══════════ onboard: agent mode + platform pickers ═══════════ */
|
|
62
107
|
function initOnboard(h) {
|
|
63
108
|
const modes = (h.modes && h.modes.length) ? h.modes : [
|
|
@@ -229,7 +274,7 @@ async function joinCall(mode) {
|
|
|
229
274
|
fetch("/api/tts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Hi.", provider: S.voice }) }).then((r) => r.arrayBuffer()).catch(() => {});
|
|
230
275
|
}
|
|
231
276
|
try {
|
|
232
|
-
const res = await fetch("/api/session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: S.agentMode, platformId: S.platform ? S.platform.id : null }) });
|
|
277
|
+
const res = await fetch("/api/session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode: S.agentMode, platformId: S.platform ? S.platform.id : null, ...(LINK_ID ? { link: LINK_ID } : {}) }) });
|
|
233
278
|
const r = await res.json().catch(() => ({}));
|
|
234
279
|
if (!res.ok || !r.sessionId) throw new Error(r.error || `server error ${res.status}`);
|
|
235
280
|
S.sessionId = r.sessionId; S.joined = true;
|
|
@@ -266,6 +311,12 @@ async function joinCall(mode) {
|
|
|
266
311
|
}
|
|
267
312
|
function setTopbarForMode() {
|
|
268
313
|
const onb = S.agentMode === "onboarding" && S.platform;
|
|
314
|
+
if (S.link) { // customers see the product, not the engine
|
|
315
|
+
$("apptitle").textContent = S.platform ? S.platform.name : "Ava";
|
|
316
|
+
$("topsub").textContent = "your live guide";
|
|
317
|
+
$("footmode").textContent = "recording off · transcript on";
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
269
320
|
$("apptitle").textContent = onb ? `${S.platform.name} Onboarding` : "Flexible Agent";
|
|
270
321
|
$("topsub").textContent = onb ? `onboarding guide · ${S.platform.category || "platform"}` : "screen-aware assistant";
|
|
271
322
|
$("footmode").textContent = onb ? `onboarding ${S.platform.name} · transcript on` : "recording off · transcript on";
|
|
@@ -580,6 +631,7 @@ function frameHash() {
|
|
|
580
631
|
|
|
581
632
|
/* ═══════════ console drawer ═══════════ */
|
|
582
633
|
function consoleToggle(force) {
|
|
634
|
+
if (S.link) return; // no console on customers' calls
|
|
583
635
|
const open = force != null ? force : $("console").hidden;
|
|
584
636
|
$("console").hidden = !open;
|
|
585
637
|
$("consolebtn").classList.toggle("on", open);
|
|
@@ -863,6 +915,8 @@ function setStuck(stuck) {
|
|
|
863
915
|
/* ═══════════ workflow tracker + human handoff + RAG indicator ═══════════ */
|
|
864
916
|
function renderWorkflow(wf) {
|
|
865
917
|
S.workflow = wf || null;
|
|
918
|
+
$("workflow").classList.toggle("wf-nosteps", !!(S.link && !S.link.options.steps));
|
|
919
|
+
$("wfHandoff").hidden = !!(S.link && !S.link.options.handoff);
|
|
866
920
|
if (wf && wf.handoff) S.handoff = wf.handoff;
|
|
867
921
|
if (!wf) {
|
|
868
922
|
// A re-plan can clear the tracker mid-handoff; keep the panel (and its
|
|
@@ -898,6 +952,10 @@ function renderHandoff(h) {
|
|
|
898
952
|
link.hidden = true;
|
|
899
953
|
} else {
|
|
900
954
|
spin.style.display = "";
|
|
955
|
+
if (S.link) { // the team is told (dashboard, Slack); the customer never handles the specialist key
|
|
956
|
+
txt.textContent = "We've let the team know. A specialist will join you here shortly.";
|
|
957
|
+
link.hidden = true; return;
|
|
958
|
+
}
|
|
901
959
|
txt.textContent = "Share this link with a specialist so they can join:";
|
|
902
960
|
link.hidden = !S.specialistKey; link.textContent = specialistURL();
|
|
903
961
|
}
|
|
@@ -1052,7 +1110,17 @@ async function showReport(sid, durMs, rawTape) {
|
|
|
1052
1110
|
$("repPlay").disabled = !tape.length;
|
|
1053
1111
|
$("report").hidden = false;
|
|
1054
1112
|
|
|
1113
|
+
if (S.link) {
|
|
1114
|
+
$("repFootnote").textContent = "Thanks for using your live guide.";
|
|
1115
|
+
$("repNew").textContent = "Start again";
|
|
1116
|
+
}
|
|
1055
1117
|
if (!sid) { $("repSummary").textContent = "Recap unavailable — no active session."; return; }
|
|
1118
|
+
if (S.link && !S.link.options.recap) { // this link skips the AI recap: just free the session
|
|
1119
|
+
fetch("/api/end", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: sid }), keepalive: true }).catch(() => {});
|
|
1120
|
+
$("repHeadline").textContent = "Thanks, you're all set";
|
|
1121
|
+
$("repSummary").textContent = "You can close this tab, or start again if you need more help.";
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1056
1124
|
try {
|
|
1057
1125
|
const s = await fetch("/api/summary", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: sid }) }).then((r) => r.json())
|
|
1058
1126
|
.finally(() => { // the recap was the last use — free the session slot now
|
|
@@ -1615,7 +1683,16 @@ async function playWavStream(res, abort) {
|
|
|
1615
1683
|
return true;
|
|
1616
1684
|
}
|
|
1617
1685
|
|
|
1618
|
-
|
|
1686
|
+
// Ava keeps one voice. If the chosen server voice (Kokoro by default) can't speak
|
|
1687
|
+
// a line, it retries once, then shows the reply as text: it never swaps to the
|
|
1688
|
+
// browser's robotic voice mid-call. The browser voice is used only when picked.
|
|
1689
|
+
let voiceNoticeShown = false;
|
|
1690
|
+
function voiceUnavailable() {
|
|
1691
|
+
if (voiceNoticeShown) return;
|
|
1692
|
+
voiceNoticeShown = true;
|
|
1693
|
+
addSysLine("voice", "Ava's voice couldn't play that reply, so it's shown as text. Ava will keep trying her voice.");
|
|
1694
|
+
}
|
|
1695
|
+
async function speak(text, force, ignoreMute, retried = false) {
|
|
1619
1696
|
if (!ignoreMute && !$("speak").checked) return false;
|
|
1620
1697
|
if (!force && lastSpoken && simText(text, lastSpoken) > 0.7) { lastSpoken = text; return false; }
|
|
1621
1698
|
lastSpoken = text; S.curSpokenText = text;
|
|
@@ -1626,7 +1703,7 @@ async function speak(text, force, ignoreMute) {
|
|
|
1626
1703
|
const abort = ttsFetch = new AbortController();
|
|
1627
1704
|
try {
|
|
1628
1705
|
const res = await fetch("/api/tts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, provider: prov }), signal: abort.signal });
|
|
1629
|
-
if (!res.ok) throw new Error("tts " + res.status);
|
|
1706
|
+
if (!res.ok) throw Object.assign(new Error("tts " + res.status), { status: res.status });
|
|
1630
1707
|
if ((res.headers.get("Content-Type") || "").includes("x-wav-stream")) {
|
|
1631
1708
|
if (ttsFetch === abort) ttsFetch = null; // playWavStream owns the abort from here
|
|
1632
1709
|
if (await playWavStream(res, abort)) return true;
|
|
@@ -1644,6 +1721,10 @@ async function speak(text, force, ignoreMute) {
|
|
|
1644
1721
|
// an aborted request means a newer speak/barge-in superseded this one —
|
|
1645
1722
|
// stay silent instead of re-speaking the text in the browser voice
|
|
1646
1723
|
if (abort.signal.aborted || (e && e.name === "AbortError")) return false;
|
|
1724
|
+
// busy (another line still synthesising) or a hiccup: one more try with the same voice
|
|
1725
|
+
if (!retried) { await wait(e?.status === 429 ? 900 : 400); if (!abort.signal.aborted && ttsFetch !== null && ttsFetch !== abort) return false; return speak(text, true, ignoreMute, true); }
|
|
1726
|
+
voiceUnavailable();
|
|
1727
|
+
return false;
|
|
1647
1728
|
}
|
|
1648
1729
|
}
|
|
1649
1730
|
if ("speechSynthesis" in window) {
|