ava-engine 0.2.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/.env.example +11 -0
- package/README.md +23 -3
- 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 +77 -4
- package/lib/cli/openrouter-oauth.mjs +87 -0
- package/lib/cli/setup-server.mjs +32 -4
- package/lib/cli/setup.mjs +27 -3
- 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 +88 -0
- package/lib/models.mjs +10 -4
- package/lib/openrouter.mjs +9 -1
- package/lib/ours.mjs +43 -29
- package/lib/platforms.mjs +77 -1
- package/lib/playbooks.mjs +120 -0
- package/lib/safe-fetch.mjs +79 -0
- package/lib/settings.mjs +187 -0
- package/lib/spend.mjs +62 -0
- package/lib/state.mjs +46 -7
- package/lib/stt.mjs +4 -1
- package/lib/team.mjs +191 -0
- package/lib/tts.mjs +18 -11
- package/lib/webhooks.mjs +112 -0
- package/package.json +3 -3
- package/public/app.js +86 -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 +18 -9
- package/public/login.css +30 -0
- package/public/login.html +37 -0
- package/public/login.js +27 -0
- package/public/setup.css +139 -55
- package/public/setup.html +45 -18
- package/public/setup.js +43 -10
- package/public/style.css +21 -0
- package/server.mjs +164 -44
package/lib/team.mjs
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Team accounts: who can sign in to the dashboard, and what each person may do.
|
|
2
|
+
//
|
|
3
|
+
// owner everything, including Settings and the team
|
|
4
|
+
// editor share links, playbooks and calls
|
|
5
|
+
// specialist the inbox and calls: picks up customers who asked for a person
|
|
6
|
+
//
|
|
7
|
+
// Accounts live in USERS_FILE (default ~/.ava-engine/users.json, mode 600) with
|
|
8
|
+
// scrypt password hashes. Sign-ins are signed session cookies; the signing key is
|
|
9
|
+
// generated once per install (~/.ava-engine/secret.key). Without any accounts Ava
|
|
10
|
+
// works as before: ACCESS_PASSWORD, or this computer only.
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync, statSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { createHash, createHmac, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
|
|
16
|
+
|
|
17
|
+
const HOME = process.env.AVA_HOME || join(homedir(), ".ava-engine");
|
|
18
|
+
const FILE = process.env.USERS_FILE || join(HOME, "users.json");
|
|
19
|
+
const KEY_FILE = join(HOME, "secret.key");
|
|
20
|
+
export const ROLES = ["owner", "editor", "specialist"];
|
|
21
|
+
const EMAIL = /^[^\s@]{1,64}@[^\s@]{1,190}\.[^\s@]{2,}$/;
|
|
22
|
+
|
|
23
|
+
let users = [], keys = [], loadedMtime = -1;
|
|
24
|
+
// Re-read when the file changed underneath us (`ava-engine team` while Ava runs).
|
|
25
|
+
function fresh() {
|
|
26
|
+
let m = 0;
|
|
27
|
+
try { m = statSync(FILE).mtimeMs; } catch {}
|
|
28
|
+
if (m === loadedMtime) return;
|
|
29
|
+
loadedMtime = m;
|
|
30
|
+
try { const j = JSON.parse(readFileSync(FILE, "utf8")); users = Array.isArray(j.users) ? j.users : []; keys = Array.isArray(j.keys) ? j.keys : []; } catch { users = []; keys = []; }
|
|
31
|
+
}
|
|
32
|
+
fresh();
|
|
33
|
+
function save() {
|
|
34
|
+
mkdirSync(dirname(FILE), { recursive: true });
|
|
35
|
+
const tmp = `${FILE}.${process.pid}.tmp`;
|
|
36
|
+
writeFileSync(tmp, JSON.stringify({ users, keys }, null, 2), { mode: 0o600 });
|
|
37
|
+
renameSync(tmp, FILE);
|
|
38
|
+
try { chmodSync(FILE, 0o600); } catch {}
|
|
39
|
+
try { loadedMtime = statSync(FILE).mtimeMs; } catch {}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── signing key (one per install) ──
|
|
43
|
+
let key = null;
|
|
44
|
+
function signingKey() {
|
|
45
|
+
if (key) return key;
|
|
46
|
+
try { key = Buffer.from(readFileSync(KEY_FILE, "utf8").trim(), "base64url"); if (key.length >= 32) return key; } catch {}
|
|
47
|
+
key = randomBytes(32);
|
|
48
|
+
mkdirSync(dirname(KEY_FILE), { recursive: true });
|
|
49
|
+
writeFileSync(KEY_FILE, key.toString("base64url"), { mode: 0o600 });
|
|
50
|
+
return key;
|
|
51
|
+
}
|
|
52
|
+
const mac = (s) => createHmac("sha256", signingKey()).update(s).digest("base64url");
|
|
53
|
+
const eq = (a, b) => { const x = Buffer.from(String(a)), y = Buffer.from(String(b)); return x.length === y.length && timingSafeEqual(x, y); };
|
|
54
|
+
|
|
55
|
+
/** A signed, tamper-proof token for a cookie: payload is a small object. */
|
|
56
|
+
export function signToken(payload) {
|
|
57
|
+
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
58
|
+
return `${body}.${mac(body)}`;
|
|
59
|
+
}
|
|
60
|
+
export function readToken(token) {
|
|
61
|
+
const [body, sig] = String(token || "").split(".");
|
|
62
|
+
if (!body || !sig || !eq(sig, mac(body))) return null;
|
|
63
|
+
try { return JSON.parse(Buffer.from(body, "base64url").toString("utf8")); } catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── passwords ──
|
|
67
|
+
function hash(password, salt = randomBytes(16).toString("base64url")) {
|
|
68
|
+
return { salt, hash: scryptSync(password, salt, 64, { N: 16384, r: 8, p: 1 }).toString("base64url") };
|
|
69
|
+
}
|
|
70
|
+
const DUMMY = hash("not-a-real-password"); // equal work when the email is unknown
|
|
71
|
+
export function passwordProblem(p) {
|
|
72
|
+
if (typeof p !== "string" || p.length < 10) return "Passwords need at least 10 characters.";
|
|
73
|
+
if (p.length > 200) return "That password is too long.";
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
export const suggestPassword = () => randomBytes(12).toString("base64url");
|
|
77
|
+
|
|
78
|
+
// ── accounts ──
|
|
79
|
+
const pub = (u) => u && { id: u.id, email: u.email, name: u.name, role: u.role, createdAt: u.createdAt, lastLoginAt: u.lastLoginAt || null };
|
|
80
|
+
export const hasUsers = () => { fresh(); return users.length > 0; };
|
|
81
|
+
export const listUsers = () => { fresh(); return users.map(pub); };
|
|
82
|
+
export const getUser = (id) => { fresh(); return users.find((u) => u.id === id) || null; };
|
|
83
|
+
const byEmail = (email) => { fresh(); return users.find((u) => u.email === String(email || "").trim().toLowerCase()) || null; };
|
|
84
|
+
const owners = () => users.filter((u) => u.role === "owner");
|
|
85
|
+
|
|
86
|
+
function validate({ email, name, role }, { existing } = {}) {
|
|
87
|
+
const errors = [];
|
|
88
|
+
if (email !== undefined) {
|
|
89
|
+
const e = String(email || "").trim().toLowerCase();
|
|
90
|
+
if (!EMAIL.test(e)) errors.push("Enter a valid email address.");
|
|
91
|
+
else if (byEmail(e) && byEmail(e).id !== existing?.id) errors.push("Someone on the team already uses that email.");
|
|
92
|
+
}
|
|
93
|
+
if (name !== undefined && !(typeof name === "string" && name.trim().length >= 1 && name.length <= 60)) errors.push("Enter a name (up to 60 characters).");
|
|
94
|
+
if (role !== undefined && !ROLES.includes(role)) errors.push("Pick a role: owner, editor or specialist.");
|
|
95
|
+
return errors;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Add someone. The first account must be an owner. Returns { user } or { errors }. */
|
|
99
|
+
export function createUser({ email, name, role, password }) {
|
|
100
|
+
const errors = validate({ email, name, role });
|
|
101
|
+
const pw = passwordProblem(password); if (pw) errors.push(pw);
|
|
102
|
+
if (!users.length && role !== "owner") errors.push("The first account must be an owner.");
|
|
103
|
+
if (errors.length) return { errors };
|
|
104
|
+
const u = { id: randomUUID(), email: email.trim().toLowerCase(), name: name.trim(), role, ...hash(password), tv: 1, createdAt: new Date().toISOString() };
|
|
105
|
+
users.push(u); save();
|
|
106
|
+
return { user: pub(u) };
|
|
107
|
+
}
|
|
108
|
+
/** Change name or role. There's always at least one owner. */
|
|
109
|
+
export function updateUser(id, { name, role }) {
|
|
110
|
+
const u = getUser(id);
|
|
111
|
+
if (!u) return { errors: ["That person isn't on the team."] };
|
|
112
|
+
const errors = validate({ name, role }, { existing: u });
|
|
113
|
+
if (role && role !== "owner" && u.role === "owner" && owners().length === 1) errors.push("Ava needs at least one owner. Make someone else an owner first.");
|
|
114
|
+
if (errors.length) return { errors };
|
|
115
|
+
if (name !== undefined) u.name = name.trim();
|
|
116
|
+
if (role !== undefined && role !== u.role) { u.role = role; u.tv += 1; } // new role: sign in again
|
|
117
|
+
save();
|
|
118
|
+
return { user: pub(u) };
|
|
119
|
+
}
|
|
120
|
+
/** Set a new password. Signs that person out everywhere. */
|
|
121
|
+
export function setPassword(id, password) {
|
|
122
|
+
const u = getUser(id);
|
|
123
|
+
if (!u) return { errors: ["That person isn't on the team."] };
|
|
124
|
+
const pw = passwordProblem(password); if (pw) return { errors: [pw] };
|
|
125
|
+
Object.assign(u, hash(password)); u.tv += 1; save();
|
|
126
|
+
return { user: pub(u) };
|
|
127
|
+
}
|
|
128
|
+
export function deleteUser(id) {
|
|
129
|
+
const u = getUser(id);
|
|
130
|
+
if (!u) return { errors: ["That person isn't on the team."] };
|
|
131
|
+
if (u.role === "owner" && owners().length === 1) return { errors: ["You can't remove the last owner."] };
|
|
132
|
+
users = users.filter((x) => x.id !== id); save();
|
|
133
|
+
return { deleted: true };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Check an email and password. Returns the account or null (same work either way). */
|
|
137
|
+
export function verifyLogin(email, password) {
|
|
138
|
+
const u = byEmail(email);
|
|
139
|
+
const h = hash(String(password || ""), u ? u.salt : DUMMY.salt).hash;
|
|
140
|
+
if (!u || !eq(h, u.hash)) return null;
|
|
141
|
+
u.lastLoginAt = new Date().toISOString(); save();
|
|
142
|
+
return u;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Session token for an account; invalid after a password or role change (tv). */
|
|
146
|
+
export const sessionFor = (u) => signToken({ u: u.id, tv: u.tv, iat: Date.now() });
|
|
147
|
+
/** The account a session token belongs to, if it's still valid. */
|
|
148
|
+
export function userFromSession(payload, maxAgeMs) {
|
|
149
|
+
if (!payload || !payload.u || Date.now() - payload.iat > maxAgeMs) return null;
|
|
150
|
+
const u = getUser(payload.u);
|
|
151
|
+
return u && u.tv === payload.tv ? pub(u) : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── API keys: for scripts and tools (n8n, Zapier, your backend) ──
|
|
155
|
+
// "ava_sk_" + 32 random bytes. Only a SHA-256 is stored, so a copied users.json
|
|
156
|
+
// can't be used to call Ava; the key itself is shown once, when it's made.
|
|
157
|
+
const sha = (s) => createHash("sha256").update(s).digest("hex");
|
|
158
|
+
const keyPub = (k) => ({ id: k.id, name: k.name, role: k.role, prefix: k.prefix, createdAt: k.createdAt, createdBy: k.createdBy || null, lastUsedAt: k.lastUsedAt || null });
|
|
159
|
+
export const listKeys = () => { fresh(); return keys.map(keyPub); };
|
|
160
|
+
/** Make a key. Returns { key (shown once), apiKey } or { errors }. */
|
|
161
|
+
export function createKey({ name, role, createdBy }) {
|
|
162
|
+
const errors = [];
|
|
163
|
+
if (!(typeof name === "string" && name.trim() && name.length <= 60)) errors.push("Name the key after what uses it, e.g. “n8n” (up to 60 characters).");
|
|
164
|
+
if (!ROLES.includes(role)) errors.push("Pick a role for the key: owner, editor or specialist.");
|
|
165
|
+
if (errors.length) return { errors };
|
|
166
|
+
fresh();
|
|
167
|
+
const key = `ava_sk_${randomBytes(32).toString("base64url")}`;
|
|
168
|
+
const k = { id: randomUUID(), name: name.trim(), role, prefix: key.slice(0, 11), hash: sha(key), createdAt: new Date().toISOString(), createdBy: createdBy || null };
|
|
169
|
+
keys.push(k); save();
|
|
170
|
+
return { key, apiKey: keyPub(k) };
|
|
171
|
+
}
|
|
172
|
+
export function deleteKey(id) {
|
|
173
|
+
fresh();
|
|
174
|
+
const n = keys.length;
|
|
175
|
+
keys = keys.filter((k) => k.id !== id);
|
|
176
|
+
if (keys.length === n) return false;
|
|
177
|
+
save(); return true;
|
|
178
|
+
}
|
|
179
|
+
/** The key behind a Bearer token, as a signed-in "user" with the key's role. */
|
|
180
|
+
export function userFromApiKey(token) {
|
|
181
|
+
if (typeof token !== "string" || !token.startsWith("ava_sk_") || token.length > 100) return null;
|
|
182
|
+
fresh();
|
|
183
|
+
const h = sha(token);
|
|
184
|
+
const k = keys.find((x) => eq(x.hash, h));
|
|
185
|
+
if (!k) return null;
|
|
186
|
+
if (!k.lastUsedAt || Date.now() - Date.parse(k.lastUsedAt) > 600_000) { k.lastUsedAt = new Date().toISOString(); save(); } // at most every 10 minutes
|
|
187
|
+
return { id: `key:${k.id}`, name: k.name, role: k.role, apiKey: true };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const USERS_FILE = FILE;
|
|
191
|
+
export const usersFileExists = () => existsSync(FILE);
|
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
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Event webhooks: tell your own systems what happens on calls.
|
|
2
|
+
//
|
|
3
|
+
// WEBHOOK_URL one or more URLs (comma-separated) that receive every event as JSON
|
|
4
|
+
// WEBHOOK_SECRET signs each delivery: X-Ava-Signature: sha256=HMAC(secret, `${timestamp}.${body}`)
|
|
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)
|
|
8
|
+
// PUBLIC_URL where specialists open Ava (for the links in those messages)
|
|
9
|
+
//
|
|
10
|
+
// Events: session.started, step.completed, workflow.completed, handoff.requested,
|
|
11
|
+
// session.ended. Delivery is fire-and-forget with one retry, so a slow or broken
|
|
12
|
+
// endpoint never holds up a call.
|
|
13
|
+
|
|
14
|
+
import { createHmac, randomUUID } from "node:crypto";
|
|
15
|
+
|
|
16
|
+
import { onSettingsChange } from "./settings.mjs";
|
|
17
|
+
|
|
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);
|
|
33
|
+
|
|
34
|
+
/** The URL a specialist opens to join a call. */
|
|
35
|
+
export function specialistUrl(key) {
|
|
36
|
+
const base = (process.env.PUBLIC_URL || `http://localhost:${Number(process.env.PORT) || 5190}`).replace(/\/+$/, "");
|
|
37
|
+
return `${base}/specialist?key=${encodeURIComponent(key)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function post(url, body, headers, label) {
|
|
41
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
42
|
+
try {
|
|
43
|
+
const r = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", "User-Agent": "ava-engine", ...headers }, body, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
44
|
+
if (r.ok || (r.status >= 400 && r.status < 500 && r.status !== 429)) {
|
|
45
|
+
if (!r.ok) console.warn(` [${label}] endpoint refused the event (${r.status})`);
|
|
46
|
+
return r.ok;
|
|
47
|
+
}
|
|
48
|
+
} catch { /* network error or timeout: retry once */ }
|
|
49
|
+
if (attempt === 0) await new Promise((r) => setTimeout(r, RETRY_MS));
|
|
50
|
+
}
|
|
51
|
+
console.warn(` [${label}] delivery failed after a retry`); // never log the URL: Slack URLs are secrets
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Signature headers for a delivery body (exported for tests and for receivers to copy). */
|
|
56
|
+
export function sign(body, timestamp, secret = SECRET) {
|
|
57
|
+
return "sha256=" + createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
|
|
58
|
+
}
|
|
59
|
+
|
|
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
|
|
63
|
+
if (type !== "handoff.requested") return null;
|
|
64
|
+
const where = data.platform ? ` on ${data.platform.name}` : "";
|
|
65
|
+
const step = data.workflow?.currentStep ? `\n*Stuck on:* ${data.workflow.currentStep} (${data.workflow.done}/${data.workflow.total} steps done)` : "";
|
|
66
|
+
const said = data.lastUserMessage ? `\n*Last said:* “${data.lastUserMessage}”` : "";
|
|
67
|
+
return `:raising_hand: A customer${where} asked to talk to a person.${step}${said}\n<${data.specialistUrl}|Join the call>`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Send an event to every configured endpoint. Returns a promise tests can await; callers needn't. */
|
|
71
|
+
export function emitEvent(type, data) {
|
|
72
|
+
if (!WEBHOOKS_ENABLED) return Promise.resolve();
|
|
73
|
+
const event = { id: randomUUID(), type, createdAt: new Date().toISOString(), data };
|
|
74
|
+
const body = JSON.stringify(event);
|
|
75
|
+
const jobs = URLS.map((url) => {
|
|
76
|
+
const ts = String(Math.floor(Date.now() / 1000));
|
|
77
|
+
const headers = { "X-Ava-Event": type, "X-Ava-Delivery": event.id, "X-Ava-Timestamp": ts, ...(SECRET ? { "X-Ava-Signature": sign(body, ts) } : {}) };
|
|
78
|
+
return post(url, body, headers, "webhook");
|
|
79
|
+
});
|
|
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")); }
|
|
82
|
+
return Promise.allSettled(jobs);
|
|
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
|
|
|
@@ -25,6 +29,8 @@ let pip = null;
|
|
|
25
29
|
try {
|
|
26
30
|
const h = await (await fetch("/api/health")).json();
|
|
27
31
|
S.tts = h.tts || { enabled: false };
|
|
32
|
+
// Demo mode (no API key): say so up front, in the lobby and on the call.
|
|
33
|
+
if (h.mock) { $("obDemo").hidden = false; $("demoPill").hidden = false; }
|
|
28
34
|
S.stt = !!(h.stt && h.stt.enabled);
|
|
29
35
|
S.sttStream = !!(h.stt && h.stt.streaming); // offline sherpa streaming ASR on the server
|
|
30
36
|
// speech-recognition engine picker (green room)
|
|
@@ -52,10 +58,51 @@ let pip = null;
|
|
|
52
58
|
sel.onchange = () => { S.voice = sel.value; $("voicesel").value = S.voice; $("voicesel2").value = S.voice; };
|
|
53
59
|
}
|
|
54
60
|
initOnboard(h);
|
|
61
|
+
if (LINK_ID) await initLink(!!h.mock); else applyUrlParams();
|
|
55
62
|
} catch {}
|
|
56
63
|
populateMics();
|
|
57
64
|
})();
|
|
58
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
|
+
|
|
59
106
|
/* ═══════════ onboard: agent mode + platform pickers ═══════════ */
|
|
60
107
|
function initOnboard(h) {
|
|
61
108
|
const modes = (h.modes && h.modes.length) ? h.modes : [
|
|
@@ -227,7 +274,7 @@ async function joinCall(mode) {
|
|
|
227
274
|
fetch("/api/tts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: "Hi.", provider: S.voice }) }).then((r) => r.arrayBuffer()).catch(() => {});
|
|
228
275
|
}
|
|
229
276
|
try {
|
|
230
|
-
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 } : {}) }) });
|
|
231
278
|
const r = await res.json().catch(() => ({}));
|
|
232
279
|
if (!res.ok || !r.sessionId) throw new Error(r.error || `server error ${res.status}`);
|
|
233
280
|
S.sessionId = r.sessionId; S.joined = true;
|
|
@@ -264,6 +311,12 @@ async function joinCall(mode) {
|
|
|
264
311
|
}
|
|
265
312
|
function setTopbarForMode() {
|
|
266
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
|
+
}
|
|
267
320
|
$("apptitle").textContent = onb ? `${S.platform.name} Onboarding` : "Flexible Agent";
|
|
268
321
|
$("topsub").textContent = onb ? `onboarding guide · ${S.platform.category || "platform"}` : "screen-aware assistant";
|
|
269
322
|
$("footmode").textContent = onb ? `onboarding ${S.platform.name} · transcript on` : "recording off · transcript on";
|
|
@@ -578,6 +631,7 @@ function frameHash() {
|
|
|
578
631
|
|
|
579
632
|
/* ═══════════ console drawer ═══════════ */
|
|
580
633
|
function consoleToggle(force) {
|
|
634
|
+
if (S.link) return; // no console on customers' calls
|
|
581
635
|
const open = force != null ? force : $("console").hidden;
|
|
582
636
|
$("console").hidden = !open;
|
|
583
637
|
$("consolebtn").classList.toggle("on", open);
|
|
@@ -861,6 +915,8 @@ function setStuck(stuck) {
|
|
|
861
915
|
/* ═══════════ workflow tracker + human handoff + RAG indicator ═══════════ */
|
|
862
916
|
function renderWorkflow(wf) {
|
|
863
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);
|
|
864
920
|
if (wf && wf.handoff) S.handoff = wf.handoff;
|
|
865
921
|
if (!wf) {
|
|
866
922
|
// A re-plan can clear the tracker mid-handoff; keep the panel (and its
|
|
@@ -896,6 +952,10 @@ function renderHandoff(h) {
|
|
|
896
952
|
link.hidden = true;
|
|
897
953
|
} else {
|
|
898
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
|
+
}
|
|
899
959
|
txt.textContent = "Share this link with a specialist so they can join:";
|
|
900
960
|
link.hidden = !S.specialistKey; link.textContent = specialistURL();
|
|
901
961
|
}
|
|
@@ -1050,7 +1110,17 @@ async function showReport(sid, durMs, rawTape) {
|
|
|
1050
1110
|
$("repPlay").disabled = !tape.length;
|
|
1051
1111
|
$("report").hidden = false;
|
|
1052
1112
|
|
|
1113
|
+
if (S.link) {
|
|
1114
|
+
$("repFootnote").textContent = "Thanks for using your live guide.";
|
|
1115
|
+
$("repNew").textContent = "Start again";
|
|
1116
|
+
}
|
|
1053
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
|
+
}
|
|
1054
1124
|
try {
|
|
1055
1125
|
const s = await fetch("/api/summary", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId: sid }) }).then((r) => r.json())
|
|
1056
1126
|
.finally(() => { // the recap was the last use — free the session slot now
|
|
@@ -1613,7 +1683,16 @@ async function playWavStream(res, abort) {
|
|
|
1613
1683
|
return true;
|
|
1614
1684
|
}
|
|
1615
1685
|
|
|
1616
|
-
|
|
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) {
|
|
1617
1696
|
if (!ignoreMute && !$("speak").checked) return false;
|
|
1618
1697
|
if (!force && lastSpoken && simText(text, lastSpoken) > 0.7) { lastSpoken = text; return false; }
|
|
1619
1698
|
lastSpoken = text; S.curSpokenText = text;
|
|
@@ -1624,7 +1703,7 @@ async function speak(text, force, ignoreMute) {
|
|
|
1624
1703
|
const abort = ttsFetch = new AbortController();
|
|
1625
1704
|
try {
|
|
1626
1705
|
const res = await fetch("/api/tts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text, provider: prov }), signal: abort.signal });
|
|
1627
|
-
if (!res.ok) throw new Error("tts " + res.status);
|
|
1706
|
+
if (!res.ok) throw Object.assign(new Error("tts " + res.status), { status: res.status });
|
|
1628
1707
|
if ((res.headers.get("Content-Type") || "").includes("x-wav-stream")) {
|
|
1629
1708
|
if (ttsFetch === abort) ttsFetch = null; // playWavStream owns the abort from here
|
|
1630
1709
|
if (await playWavStream(res, abort)) return true;
|
|
@@ -1642,6 +1721,10 @@ async function speak(text, force, ignoreMute) {
|
|
|
1642
1721
|
// an aborted request means a newer speak/barge-in superseded this one —
|
|
1643
1722
|
// stay silent instead of re-speaking the text in the browser voice
|
|
1644
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;
|
|
1645
1728
|
}
|
|
1646
1729
|
}
|
|
1647
1730
|
if ("speechSynthesis" in window) {
|