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/public/setup.js
CHANGED
|
@@ -4,8 +4,15 @@
|
|
|
4
4
|
(() => {
|
|
5
5
|
const $ = (id) => document.getElementById(id);
|
|
6
6
|
const TOKEN = new URLSearchParams(location.hash.slice(1)).get("token") || "";
|
|
7
|
-
const ORDER = ["ai", "voice", "speech", "access", "review"];
|
|
8
|
-
const
|
|
7
|
+
const ORDER = ["welcome", "ai", "voice", "speech", "access", "review"];
|
|
8
|
+
const STEPS = ORDER.slice(1); // the numbered steps in the rail
|
|
9
|
+
const ICON = {
|
|
10
|
+
kokoro: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 19c0-8 5-13 14-14-1 9-6 14-14 14Z"/><path d="M5 19 13 11"/></svg>',
|
|
11
|
+
elevenlabs: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 10v4M8 7v10M12 4v16M16 8v8M20 11v2"/></svg>',
|
|
12
|
+
browser: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4.5" width="18" height="14" rx="2.5"/><path d="M3 9h18M6.5 6.8h.01M9 6.8h.01"/></svg>',
|
|
13
|
+
pack: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="3" width="6" height="11" rx="3"/><path d="M5.5 11.5a6.5 6.5 0 0 0 13 0M12 18v3"/></svg>',
|
|
14
|
+
};
|
|
15
|
+
const S = { step: "welcome", state: null, keyOk: false, elevenOk: false, selectedPacks: new Set(), pollTimer: null };
|
|
9
16
|
|
|
10
17
|
async function api(path, body) {
|
|
11
18
|
const r = await fetch(`/api/setup/${path}`, {
|
|
@@ -25,7 +32,8 @@
|
|
|
25
32
|
function show(step) {
|
|
26
33
|
S.step = step;
|
|
27
34
|
document.querySelectorAll(".step").forEach((s) => { s.hidden = s.dataset.step !== step; });
|
|
28
|
-
const idx =
|
|
35
|
+
const idx = STEPS.indexOf(step) === -1 ? (step === "launching" ? STEPS.length : -1) : STEPS.indexOf(step);
|
|
36
|
+
$("progressBar").style.width = `${step === "welcome" ? 4 : step === "launching" ? 100 : ((idx + 1) / (STEPS.length + 1)) * 100}%`;
|
|
29
37
|
document.querySelectorAll("#steps li").forEach((li, i) => {
|
|
30
38
|
li.classList.toggle("current", i === idx);
|
|
31
39
|
li.classList.toggle("done", idx > i || step === "launching");
|
|
@@ -104,7 +112,7 @@
|
|
|
104
112
|
$("voiceCards").innerHTML = "";
|
|
105
113
|
for (const [id, info] of Object.entries(v)) {
|
|
106
114
|
const label = document.createElement("label"); label.className = "card";
|
|
107
|
-
label.innerHTML = `<input type="radio" name="voice"><span class="card-body"><b></b><span></span></span>`;
|
|
115
|
+
label.innerHTML = `<input type="radio" name="voice"><span class="card-icon">${ICON[id] || ICON.browser}</span><span class="card-body"><b></b><span></span></span>`;
|
|
108
116
|
const input = label.querySelector("input"); input.value = id; input.checked = id === cur;
|
|
109
117
|
label.querySelector("b").textContent = info.label;
|
|
110
118
|
label.querySelector(".card-body span").textContent = id === "kokoro" && S.state.kokoro.installed ? "Free, runs on this computer. Installed." : info.hint[0].toUpperCase() + info.hint.slice(1) + ".";
|
|
@@ -127,7 +135,7 @@
|
|
|
127
135
|
for (const [id, p] of Object.entries(S.state.packs)) {
|
|
128
136
|
const installed = S.state.installedPacks.includes(id);
|
|
129
137
|
const label = document.createElement("label"); label.className = "card";
|
|
130
|
-
label.innerHTML = `<input type="checkbox"><span class="card-body"><b></b><span></span></span>`;
|
|
138
|
+
label.innerHTML = `<input type="checkbox"><span class="card-icon">${ICON.pack}</span><span class="card-body"><b></b><span></span></span>`;
|
|
131
139
|
const input = label.querySelector("input"); input.value = id;
|
|
132
140
|
input.checked = installed || S.selectedPacks.has(id); input.disabled = installed;
|
|
133
141
|
label.querySelector("b").textContent = `${p.label} · ${p.size}`;
|
|
@@ -208,7 +216,7 @@
|
|
|
208
216
|
["Voice", S.state.voices[a.voice].label],
|
|
209
217
|
["Speech recognition", packs.length ? packs.join(", ") : "Chrome's built-in recognition"],
|
|
210
218
|
["Access", a.audience === "me" ? "This computer only" : "Password protected"],
|
|
211
|
-
["
|
|
219
|
+
["Dashboard", `http://localhost:${a.port}/dashboard`],
|
|
212
220
|
];
|
|
213
221
|
if (a.audience === "others" && a.password !== "keep") rows.splice(4, 0, ["Password", a.password]);
|
|
214
222
|
const dl = $("summary"); dl.innerHTML = "";
|
|
@@ -279,7 +287,7 @@
|
|
|
279
287
|
$("regen").addEventListener("click", async () => { $("password").value = (await api("password")).password; });
|
|
280
288
|
$("copyPass").addEventListener("click", async () => { try { await navigator.clipboard.writeText($("password").value); $("copyPass").textContent = "Copied"; setTimeout(() => { $("copyPass").textContent = "Copy"; }, 1400); } catch {} });
|
|
281
289
|
$("launch").addEventListener("click", launch);
|
|
282
|
-
show("
|
|
290
|
+
show("welcome");
|
|
283
291
|
pollJobs();
|
|
284
292
|
}
|
|
285
293
|
// A fresh setup link opened in this same tab only changes the #token, which
|
package/public/style.css
CHANGED
|
@@ -838,3 +838,20 @@ body { height: 100dvh; }
|
|
|
838
838
|
.rep-foot { justify-content: flex-end; }
|
|
839
839
|
.rep-foot .ghostbtn { white-space: nowrap; }
|
|
840
840
|
}
|
|
841
|
+
|
|
842
|
+
/* ── lobby extras: dashboard link (operators) and the journey preview (share links) ── */
|
|
843
|
+
.ob-section[hidden], .ob-dashlink[hidden] { display: none; }
|
|
844
|
+
.ob-dashlink { align-self: flex-start; display: inline-flex; align-items: center; gap: 5px; margin-bottom: 8px; padding: 5px 12px 5px 8px; border-radius: 99px; border: 1px solid var(--edge); background: var(--control); color: var(--ink2); font-size: 13px; font-weight: 500; text-decoration: none; transition: border-color .15s, color .15s; }
|
|
845
|
+
.ob-dashlink:hover { color: var(--ink); border-color: #C6C6D0; }
|
|
846
|
+
.ob-journey { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; counter-reset: j; }
|
|
847
|
+
.ob-journey li { counter-increment: j; display: flex; align-items: center; gap: 12px; font-size: 14.5px; }
|
|
848
|
+
.ob-journey li::before { content: counter(j); width: 26px; height: 26px; flex: none; display: grid; place-items: center; border-radius: 50%; font: 500 12px var(--mono); color: var(--amber); background: var(--brandtint); border: 1px solid var(--brandline); }
|
|
849
|
+
.ob-journey + .ob-divider { margin-top: 4px; }
|
|
850
|
+
|
|
851
|
+
/* ── share-link (customer) mode: the call without operator controls ── */
|
|
852
|
+
.customer #obAgent, .customer #obVoiceField, .customer #obSttField, .customer .ob-dashlink,
|
|
853
|
+
.customer #consolebtn, .customer #modelchip, .customer #agentlog, .customer .rep-right, .customer .rep-replay { display: none !important; }
|
|
854
|
+
.customer .ob-gear { grid-template-columns: 1fr; }
|
|
855
|
+
.customer .rep-left { flex: 1; }
|
|
856
|
+
.wf-nosteps .wf-head, .wf-nosteps .wf-steps { display: none; }
|
|
857
|
+
.ob-unavailable .ob-section, .ob-unavailable .ob-divider, .ob-unavailable .ob-actions, .ob-unavailable .ob-priv { display: none !important; }
|
package/server.mjs
CHANGED
|
@@ -13,9 +13,12 @@ import { synth, TTS_ENABLED, providers, defaultProvider, warmKokoro, kokoroStrea
|
|
|
13
13
|
import { transcribe, STT_ENABLED } from "./lib/stt.mjs";
|
|
14
14
|
import { createAsrSession, asrAvailable, warmAsr, availableEngines, defaultEngine, ENGINE_LABELS } from "./lib/asr.mjs";
|
|
15
15
|
import { platformsView, platformById, CUSTOM_PLAYBOOKS } from "./lib/platforms.mjs";
|
|
16
|
-
import { AUTH_ENABLED, isAuthorized,
|
|
16
|
+
import { AUTH_ENABLED, isAuthorized, currentUser, signIn, logoutCookie, authCookieHeader, clientIp, sameOrigin, limiter } from "./lib/guard.mjs";
|
|
17
|
+
import { hasUsers } from "./lib/team.mjs";
|
|
17
18
|
import { emitEvent, WEBHOOKS_ENABLED } from "./lib/webhooks.mjs";
|
|
18
19
|
import { DAILY_BUDGET_USD, dailyCapReached, spentToday } from "./lib/spend.mjs";
|
|
20
|
+
import { getLink, linkBlocked, countLinkCall, publicLinkView, linkCookieHeader, linksFromRequest } from "./lib/links.mjs";
|
|
21
|
+
import { createAdmin } from "./lib/admin.mjs";
|
|
19
22
|
import { WebSocketServer } from "ws";
|
|
20
23
|
|
|
21
24
|
// A stray rejection is logged, not fatal. An uncaught exception leaves the process
|
|
@@ -26,25 +29,33 @@ process.on("uncaughtException", (e) => { console.error("[uncaught exception]", e
|
|
|
26
29
|
const __dir = dirname(fileURLToPath(import.meta.url));
|
|
27
30
|
const PUBLIC = join(__dir, "public");
|
|
28
31
|
const PORT = Number(process.env.PORT) || 5190;
|
|
32
|
+
// HOST=127.0.0.1 keeps Ava private to this computer (the CLI's "just me" setup).
|
|
33
|
+
const HOST = process.env.HOST || undefined;
|
|
34
|
+
const LOCAL_ONLY = HOST === "127.0.0.1" || HOST === "localhost" || HOST === "::1";
|
|
29
35
|
|
|
30
36
|
// Spend limits. Each session is capped in model spend; each client IP in request rate.
|
|
31
|
-
const
|
|
37
|
+
const sessionBudget = () => Number(process.env.SESSION_BUDGET_USD) || 2; // live: editable in Settings
|
|
32
38
|
const MAX_USER_TEXT = 2000;
|
|
33
39
|
const MAX_FRAME_BYTES = 2 * 1024 * 1024; // a downscaled JPEG screen frame is ~100-300 KB
|
|
34
40
|
const FRAME_RE = /^data:image\/(jpeg|png|webp);base64,[A-Za-z0-9+/=]+$/;
|
|
35
41
|
const rate = {
|
|
36
|
-
session: limiter("session", Number(process.env.RATE_SESSIONS_PER_MIN) || 10, 60_000), // new calls per IP per minute
|
|
37
|
-
api: limiter("api", Number(process.env.RATE_API_PER_MIN) || 240, 60_000), // all other /api calls per IP per minute
|
|
42
|
+
session: limiter("session", () => Number(process.env.RATE_SESSIONS_PER_MIN) || 10, 60_000), // new calls per IP per minute
|
|
43
|
+
api: limiter("api", () => Number(process.env.RATE_API_PER_MIN) || 240, 60_000), // all other /api calls per IP per minute
|
|
38
44
|
authFail: limiter("authFail", 20, 10 * 60_000), // wrong passwords per IP per 10 minutes
|
|
39
45
|
};
|
|
40
46
|
|
|
41
47
|
// A wrong password counts against the client; past the limit, stop checking.
|
|
42
48
|
// (A first visit with no credentials at all is just the login prompt, not a guess.)
|
|
43
|
-
const triedPassword = (req) => !!req.headers.authorization || /
|
|
49
|
+
const triedPassword = (req) => !!req.headers.authorization || /ava_session=/.test(String(req.headers.cookie || ""));
|
|
44
50
|
function lockedOut(req) { return triedPassword(req) && rate.authFail.blocked(clientIp(req)); }
|
|
45
|
-
|
|
51
|
+
// Pages go to the sign-in page (and come back after); API calls get a 401.
|
|
52
|
+
function denyAuth(req, res, url) {
|
|
46
53
|
if (triedPassword(req)) rate.authFail(clientIp(req));
|
|
47
|
-
|
|
54
|
+
if ((req.method === "GET" || req.method === "HEAD") && !url.pathname.startsWith("/api/")) {
|
|
55
|
+
res.writeHead(302, { Location: `/login?next=${encodeURIComponent(url.pathname + url.search)}`, "Cache-Control": "no-store" });
|
|
56
|
+
return res.end();
|
|
57
|
+
}
|
|
58
|
+
return send(res, 401, { error: "Sign in first." });
|
|
48
59
|
}
|
|
49
60
|
const LOCKED = "too many failed logins — try again in 10 minutes";
|
|
50
61
|
const asrPerIp = new Map(); // ip → open /asr sockets
|
|
@@ -132,9 +143,27 @@ const CAP_MSG = "Ava has reached today's usage limit — try again tomorrow";
|
|
|
132
143
|
/** Why a session can't spend more (its own budget, or the daily cap across all sessions), or null. */
|
|
133
144
|
function spendLimit(sess) {
|
|
134
145
|
if (!MOCK && dailyCapReached()) return CAP_MSG;
|
|
135
|
-
if (sess.score.ours.cost >=
|
|
146
|
+
if (sess.score.ours.cost >= sessionBudget()) return "this session reached its spend limit — start a new call";
|
|
136
147
|
return null;
|
|
137
148
|
}
|
|
149
|
+
const admin = createAdmin({
|
|
150
|
+
send, readBody, LOCAL_ONLY, HOST, PORT, sessionBudget,
|
|
151
|
+
origin: (req) => `${secureReq(req) || req.headers["x-forwarded-proto"] === "https" ? "https" : "http"}://${safeHost(req) || `localhost:${PORT}`}`,
|
|
152
|
+
voice: () => ({ enabled: TTS_ENABLED, default: defaultProvider(), providers: providers() }),
|
|
153
|
+
speech: () => ({ offline: asrAvailable(), engines: availableEngines(), default: defaultEngine(), cloud: STT_ENABLED }),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/** Serve one HTML page from public/ with its CSP (and the login cookie, if any). */
|
|
157
|
+
async function servePage(req, res, file, headers = {}) {
|
|
158
|
+
try {
|
|
159
|
+
const full = join(PUBLIC, file), html = await readFile(full), st = await stat(full);
|
|
160
|
+
return send(res, 200, html, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "Content-Security-Policy": cspFor(html, `${full}|${st.mtimeMs}`, safeHost(req)), ...authCookieHeader(req), ...headers });
|
|
161
|
+
} catch { return send(res, 404, "not found"); }
|
|
162
|
+
}
|
|
163
|
+
// Only same-site paths after signing in (never another host).
|
|
164
|
+
const safeNext = (n) => (typeof n === "string" && /^\/(?!\/)[\w\-./?#=&%~]*$/.test(n) && !n.startsWith("/login") ? n : "/dashboard");
|
|
165
|
+
const secureReq = (req) => process.env.TRUST_PROXY === "1" || !!req.socket.encrypted;
|
|
166
|
+
|
|
138
167
|
const untilMidnightUtc = () => String(Math.ceil((new Date().setUTCHours(24, 0, 0, 0) - Date.now()) / 1000));
|
|
139
168
|
|
|
140
169
|
async function serveStatic(req, res, pathname) {
|
|
@@ -181,10 +210,28 @@ async function serveStatic(req, res, pathname) {
|
|
|
181
210
|
// Pages that open a call (and everything they talk to) sit behind ACCESS_PASSWORD
|
|
182
211
|
// when it's set. The landing page and its assets stay public.
|
|
183
212
|
function needsAuth(pathname) {
|
|
184
|
-
return pathname.startsWith("/api/") && pathname !== "/api/health"
|
|
213
|
+
return pathname.startsWith("/api/") && pathname !== "/api/health" && !pathname.startsWith("/api/link/") // a link's id is its own key
|
|
214
|
+
&& pathname !== "/api/auth/login" && pathname !== "/api/auth/mode"
|
|
185
215
|
|| pathname === "/app" || pathname === "/app/" || pathname === "/index.html"
|
|
186
|
-
|| pathname === "/specialist" || pathname === "/specialist.html"
|
|
216
|
+
|| pathname === "/specialist" || pathname === "/specialist.html"
|
|
217
|
+
|| pathname === "/dashboard" || pathname === "/dashboard.html";
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Share-link visitors (see lib/links.mjs) may use only what a customer's call needs.
|
|
221
|
+
const CUSTOMER_API = new Set(["/api/session", "/api/turn", "/api/observe", "/api/nudge", "/api/end", "/api/summary", "/api/truncate", "/api/stt", "/api/tts", "/api/handoff", "/api/bridge"]);
|
|
222
|
+
/** Is this caller allowed to drive that session? Operators: any. Link visitors: calls from their own link. */
|
|
223
|
+
function canUse(req, sess) {
|
|
224
|
+
return !!sess && (req.ava.operator || (!!sess.link && req.ava.links.some((l) => l.id === sess.link.id)));
|
|
187
225
|
}
|
|
226
|
+
function sessionFor(req, id) { const s = getSession(id); return canUse(req, s) ? s : undefined; }
|
|
227
|
+
// "This computer": a loopback socket, addressed as localhost, not relayed by a proxy
|
|
228
|
+
// or tunnel (cloudflared and friends also connect from 127.0.0.1).
|
|
229
|
+
const isLoopback = (req) => /^(127\.|::1$|::ffff:127\.)/.test(req.socket.remoteAddress || "")
|
|
230
|
+
&& /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i.test(String(req.headers.host || ""))
|
|
231
|
+
&& !["x-forwarded-for", "forwarded", "cf-connecting-ip", "fly-client-ip", "x-real-ip"].some((h) => req.headers[h]);
|
|
232
|
+
// The dashboard changes how Ava runs. Without a password it's only for this computer.
|
|
233
|
+
const dashboardAllowed = (req) => req.ava.operator && (AUTH_ENABLED || LOCAL_ONLY || isLoopback(req) || !!req.ava.user?.apiKey); // an API key is a sign-in
|
|
234
|
+
const DASHBOARD_LOCKED = "The dashboard is only available on this computer until you set a password. Run `npx ava-engine setup` and choose “My team or customers”.";
|
|
188
235
|
|
|
189
236
|
// Aborts when the client goes away before we've answered (not after a normal finish).
|
|
190
237
|
function clientGone(res) {
|
|
@@ -199,10 +246,12 @@ async function handle(req, res) {
|
|
|
199
246
|
for (const [k, v] of Object.entries(SECURITY_HEADERS)) res.setHeader(k, v);
|
|
200
247
|
let url;
|
|
201
248
|
try { url = new URL(req.url, "http://x"); } catch { return send(res, 400, "bad request"); }
|
|
249
|
+
const user = currentUser(req), operator = !!user;
|
|
250
|
+
req.ava = { operator, user, links: operator ? [] : linksFromRequest(req) };
|
|
202
251
|
if (needsAuth(url.pathname)) {
|
|
203
252
|
// Locked-out clients get no answer about any password, right or wrong.
|
|
204
253
|
if (lockedOut(req)) return send(res, 429, LOCKED, { "Retry-After": "600" });
|
|
205
|
-
if (!
|
|
254
|
+
if (!operator && !(req.ava.links.length && CUSTOMER_API.has(url.pathname))) return denyAuth(req, res, url);
|
|
206
255
|
}
|
|
207
256
|
if (url.pathname.startsWith("/api/") && url.pathname !== "/api/health" && !rate.api(clientIp(req))) {
|
|
208
257
|
return send(res, 429, { error: "too many requests — slow down" }, { "Retry-After": "30" });
|
|
@@ -227,7 +276,7 @@ async function handle(req, res) {
|
|
|
227
276
|
// End-of-call recap for the session report.
|
|
228
277
|
if (req.method === "POST" && url.pathname === "/api/summary") {
|
|
229
278
|
const { sessionId } = await readBody(req);
|
|
230
|
-
const sess =
|
|
279
|
+
const sess = sessionFor(req, sessionId);
|
|
231
280
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
232
281
|
try { return send(res, 200, await sessionSummary(sess)); }
|
|
233
282
|
catch (e) { return upstreamFail(res, e, "summary"); }
|
|
@@ -236,7 +285,7 @@ async function handle(req, res) {
|
|
|
236
285
|
// The user barged in — rewrite Ava's last reply to what was actually heard.
|
|
237
286
|
if (req.method === "POST" && url.pathname === "/api/truncate") {
|
|
238
287
|
const { sessionId, heard = "" } = await readBody(req);
|
|
239
|
-
const sess =
|
|
288
|
+
const sess = sessionFor(req, sessionId);
|
|
240
289
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
241
290
|
return send(res, 200, truncateAssistant(sess, String(heard).slice(0, 2000)));
|
|
242
291
|
}
|
|
@@ -298,20 +347,31 @@ async function handle(req, res) {
|
|
|
298
347
|
if (!hasCapacity()) return send(res, 503, { error: "Ava is at capacity right now — try again in a few minutes" }, { "Retry-After": "60" });
|
|
299
348
|
if (!MOCK && dailyCapReached()) return send(res, 503, { error: CAP_MSG }, { "Retry-After": untilMidnightUtc() });
|
|
300
349
|
const body = await readBody(req);
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
350
|
+
// A share link fixes the product and mode; link visitors can't pick their own.
|
|
351
|
+
let link = null;
|
|
352
|
+
if (body.link != null) {
|
|
353
|
+
link = getLink(String(body.link));
|
|
354
|
+
const why = linkBlocked(link);
|
|
355
|
+
if (why) return send(res, 410, { error: why });
|
|
356
|
+
if (!operator && !req.ava.links.some((l) => l.id === link.id)) return send(res, 403, { error: "Open this call from its link." });
|
|
357
|
+
} else if (!operator) return send(res, 403, { error: "Open this call from its link." });
|
|
358
|
+
const mode = link ? link.mode : body.mode === "onboarding" ? "onboarding" : "flexible";
|
|
359
|
+
const platformId = link ? link.product : typeof body.platformId === "string" ? body.platformId : null;
|
|
360
|
+
const platform = platformId ? platformById(platformId) : null; // knowledge in any mode
|
|
361
|
+
if (link && link.product && !platform) return send(res, 410, { error: "This link's product isn't available any more. Ask whoever sent it for a new one." });
|
|
362
|
+
const s = newSession({ mode, platform, link });
|
|
304
363
|
if (!s) return send(res, 503, { error: "Ava is at capacity right now — try again in a few minutes" }, { "Retry-After": "60" });
|
|
305
|
-
|
|
364
|
+
if (link) countLinkCall(link);
|
|
365
|
+
emitEvent("session.started", { sessionId: s.id, mode, platform: platform ? { id: platform.id, name: platform.name } : null, link: link ? { id: link.id, name: link.name } : null });
|
|
306
366
|
// pre-warm the talker route so the greeting isn't a cold start
|
|
307
367
|
if (!MOCK) chat({ model: MODELS.talker, role: "talker", provider: PROVIDER.talker, messages: [{ role: "user", content: "hi" }], maxTokens: 1 }).catch(() => {});
|
|
308
|
-
return send(res, 200, { sessionId: s.id, mock: MOCK, models: MODELS, mode, platform: platform ? { id: platform.id, name: platform.name, url: platform.url } : null });
|
|
368
|
+
return send(res, 200, { sessionId: s.id, mock: MOCK, models: MODELS, mode, platform: platform ? { id: platform.id, name: platform.name, url: platform.url } : null, link: link ? publicLinkView(link, platform).options : null });
|
|
309
369
|
}
|
|
310
370
|
|
|
311
371
|
if (req.method === "POST" && url.pathname === "/api/turn") {
|
|
312
372
|
const body = await readBody(req);
|
|
313
373
|
const { sessionId, frameHash = null, greet = false, voice = false, changed = false, screenActive = false } = body;
|
|
314
|
-
const sess =
|
|
374
|
+
const sess = sessionFor(req, sessionId);
|
|
315
375
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
316
376
|
const limit = spendLimit(sess);
|
|
317
377
|
if (limit) return send(res, 402, { error: limit });
|
|
@@ -333,7 +393,7 @@ async function handle(req, res) {
|
|
|
333
393
|
|
|
334
394
|
if (req.method === "POST" && url.pathname === "/api/observe") {
|
|
335
395
|
const { sessionId, frame: rawFrame, frameHash = null, stopped = false, changed = false } = await readBody(req);
|
|
336
|
-
const sess =
|
|
396
|
+
const sess = sessionFor(req, sessionId);
|
|
337
397
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
338
398
|
if (stopped) return send(res, 200, screenOff(sess)); // user stopped sharing → mark state stale now
|
|
339
399
|
if (spendLimit(sess)) return send(res, 402, { error: spendLimit(sess) });
|
|
@@ -343,31 +403,72 @@ async function handle(req, res) {
|
|
|
343
403
|
catch (e) { return upstreamFail(res, e, "screen read"); }
|
|
344
404
|
}
|
|
345
405
|
|
|
346
|
-
//
|
|
347
|
-
if (req.method === "GET" && url.pathname === "/
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
return send(res, 200, html, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "Content-Security-Policy": cspFor(html, `${full}|${st.mtimeMs}`, safeHost(req)), ...authCookieHeader(req) });
|
|
351
|
-
}
|
|
352
|
-
catch { return send(res, 404, "not found"); }
|
|
406
|
+
// ── Signing in ──
|
|
407
|
+
if ((req.method === "GET" || req.method === "HEAD") && url.pathname === "/login") {
|
|
408
|
+
if (operator && AUTH_ENABLED) { res.writeHead(302, { Location: safeNext(url.searchParams.get("next")) }); return res.end(); }
|
|
409
|
+
return servePage(req, res, "login.html");
|
|
353
410
|
}
|
|
411
|
+
if (req.method === "GET" && url.pathname === "/api/auth/mode") return send(res, 200, { accounts: hasUsers(), password: !!process.env.ACCESS_PASSWORD, required: AUTH_ENABLED });
|
|
412
|
+
if (req.method === "POST" && url.pathname === "/api/auth/login") {
|
|
413
|
+
if (lockedOut(req) || rate.authFail.blocked(clientIp(req))) return send(res, 429, { error: LOCKED }, { "Retry-After": "600" });
|
|
414
|
+
const body = await readBody(req);
|
|
415
|
+
const r = signIn({ email: typeof body.email === "string" ? body.email.trim() : "", password: body.password }, req);
|
|
416
|
+
if (!r) { rate.authFail(clientIp(req)); return send(res, 401, { error: hasUsers() ? "That email and password don't match." : "That password isn't right." }); }
|
|
417
|
+
return send(res, 200, { ok: true, user: r.user, next: safeNext(body.next) }, { "Set-Cookie": r.setCookie });
|
|
418
|
+
}
|
|
419
|
+
if (req.method === "POST" && url.pathname === "/api/auth/logout") return send(res, 200, { ok: true }, { "Set-Cookie": logoutCookie(req) });
|
|
420
|
+
if (req.method === "GET" && url.pathname === "/api/auth/me") return send(res, 200, { user, accounts: hasUsers(), required: AUTH_ENABLED });
|
|
421
|
+
|
|
422
|
+
// ── Dashboard (operator only) ──
|
|
423
|
+
if ((req.method === "GET" || req.method === "HEAD") && (url.pathname === "/dashboard" || url.pathname === "/dashboard.html")) {
|
|
424
|
+
if (!dashboardAllowed(req)) return send(res, 403, DASHBOARD_LOCKED);
|
|
425
|
+
return servePage(req, res, "dashboard.html");
|
|
426
|
+
}
|
|
427
|
+
if (req.method === "GET" && url.pathname === "/dashboard/openrouter-callback") return await admin.finishOpenRouter(req, res, url);
|
|
428
|
+
if (url.pathname.startsWith("/api/admin/")) {
|
|
429
|
+
if (!dashboardAllowed(req)) return send(res, 403, { error: DASHBOARD_LOCKED });
|
|
430
|
+
if (req.method !== "GET" && !sameOrigin(req)) return send(res, 403, { error: "cross-site request refused" });
|
|
431
|
+
return await admin(req, res, url, req.ava.user);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── Share links: /c/<id> opens a customer call set up by the link ──
|
|
435
|
+
const linkPage = url.pathname.match(/^\/c\/([A-Za-z0-9_-]{22})\/?$/);
|
|
436
|
+
if (linkPage && (req.method === "GET" || req.method === "HEAD")) {
|
|
437
|
+
const l = getLink(linkPage[1]);
|
|
438
|
+
const live = l && l.enabled && !(l.expiresAt && Date.parse(l.expiresAt) <= Date.now());
|
|
439
|
+
return servePage(req, res, "index.html", live ? { "Set-Cookie": linkCookieHeader(l.id, secureReq(req)) } : {});
|
|
440
|
+
}
|
|
441
|
+
const linkApi = url.pathname.match(/^\/api\/link\/([A-Za-z0-9_-]{1,64})$/);
|
|
442
|
+
if (linkApi && req.method === "GET") {
|
|
443
|
+
const l = getLink(linkApi[1]);
|
|
444
|
+
const why = linkBlocked(l);
|
|
445
|
+
if (!l) return send(res, 404, { error: why });
|
|
446
|
+
const p = l.product ? platformById(l.product) : null;
|
|
447
|
+
if (l.product && !p) return send(res, 410, { error: "This link's product isn't available any more. Ask whoever sent it for a new one." });
|
|
448
|
+
return send(res, why ? 410 : 200, { ...publicLinkView(l, p), ...(why ? { error: why } : {}) });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Specialist console page (opened at /specialist?key=<specialist key>).
|
|
452
|
+
if (req.method === "GET" && url.pathname === "/specialist") return servePage(req, res, "specialist.html");
|
|
354
453
|
|
|
355
454
|
// Live bridge between the user's call and a human specialist. The user is
|
|
356
455
|
// identified by sessionId; the specialist only ever holds the specialist key
|
|
357
456
|
// minted when the user asked for a person (revoked when the handoff ends).
|
|
358
457
|
if (req.method === "GET" && url.pathname === "/api/bridge") {
|
|
359
458
|
const key = url.searchParams.get("key");
|
|
360
|
-
|
|
459
|
+
if (key && !operator) return send(res, 401, { error: "specialists sign in with the team password" });
|
|
460
|
+
const sess = key ? getSessionBySpecialistKey(key) : sessionFor(req, url.searchParams.get("sessionId"));
|
|
361
461
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
362
462
|
return send(res, 200, bridgePoll(sess, Number(url.searchParams.get("since")) || 0));
|
|
363
463
|
}
|
|
364
464
|
if (req.method === "POST" && url.pathname === "/api/bridge") {
|
|
365
465
|
const { sessionId, key, action = "say", text = "" } = await readBody(req);
|
|
366
466
|
if (action === "usersay") {
|
|
367
|
-
const sess =
|
|
467
|
+
const sess = sessionFor(req, sessionId);
|
|
368
468
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
369
469
|
return send(res, 200, userSayToBridge(sess, String(text).slice(0, MAX_USER_TEXT)));
|
|
370
470
|
}
|
|
471
|
+
if (!operator) return send(res, 401, { error: "specialists sign in with the team password" });
|
|
371
472
|
const sess = getSessionBySpecialistKey(key);
|
|
372
473
|
if (!sess) return send(res, 404, { error: "unknown or expired link" });
|
|
373
474
|
if (action === "join") { const r = specialistJoin(sess); return r ? send(res, 200, r) : send(res, 409, { error: "no handoff requested" }); }
|
|
@@ -385,16 +486,17 @@ async function handle(req, res) {
|
|
|
385
486
|
// Human handoff — request a real person, poll the connect state.
|
|
386
487
|
if (req.method === "POST" && url.pathname === "/api/handoff") {
|
|
387
488
|
const { sessionId, action = "request" } = await readBody(req);
|
|
388
|
-
const sess =
|
|
489
|
+
const sess = sessionFor(req, sessionId);
|
|
389
490
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
390
491
|
if (action === "cancel") return send(res, 200, cancelHandoff(sess));
|
|
492
|
+
if (sess.link && getLink(sess.link.id)?.handoff === false) return send(res, 403, { error: "Talking to a person isn't available on this link." });
|
|
391
493
|
if (action === "status") return send(res, 200, handoffStatus(sess));
|
|
392
494
|
return send(res, 200, requestHandoff(sess));
|
|
393
495
|
}
|
|
394
496
|
|
|
395
497
|
if (req.method === "POST" && url.pathname === "/api/nudge") {
|
|
396
498
|
const { sessionId } = await readBody(req);
|
|
397
|
-
const sess =
|
|
499
|
+
const sess = sessionFor(req, sessionId);
|
|
398
500
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
399
501
|
if (spendLimit(sess)) return send(res, 402, { error: spendLimit(sess) });
|
|
400
502
|
try { return send(res, 200, await proactiveNudge(sess)); }
|
|
@@ -402,7 +504,7 @@ async function handle(req, res) {
|
|
|
402
504
|
}
|
|
403
505
|
|
|
404
506
|
if (req.method === "GET" && url.pathname === "/api/scoreboard") {
|
|
405
|
-
const sess =
|
|
507
|
+
const sess = sessionFor(req, url.searchParams.get("sessionId"));
|
|
406
508
|
if (!sess) return send(res, 404, { error: "unknown session" });
|
|
407
509
|
return send(res, 200, { ours: scoreView(sess.score.ours) });
|
|
408
510
|
}
|
|
@@ -435,7 +537,9 @@ server.on("upgrade", (req, socket, head) => {
|
|
|
435
537
|
const reject = (code, msg) => { try { socket.write(`HTTP/1.1 ${code} ${msg}\r\nConnection: close\r\n\r\n`); } catch {} socket.destroy(); };
|
|
436
538
|
if (!sameOrigin(req)) return reject(403, "Forbidden");
|
|
437
539
|
if (lockedOut(req)) return reject(429, "Too Many Requests");
|
|
438
|
-
|
|
540
|
+
const operator = isAuthorized(req), links = operator ? [] : linksFromRequest(req);
|
|
541
|
+
if (!operator && !links.length) { if (triedPassword(req)) rate.authFail(clientIp(req)); return reject(401, "Unauthorized"); }
|
|
542
|
+
req.ava = { operator, user: currentUser(req), links };
|
|
439
543
|
if (path === "/asr") {
|
|
440
544
|
const ip = clientIp(req);
|
|
441
545
|
if (asrOpen >= MAX_ASR_TOTAL || (asrPerIp.get(ip) || 0) >= MAX_ASR_PER_IP) return reject(503, "Service Unavailable");
|
|
@@ -449,7 +553,8 @@ rtcWss.on("connection", (ws, req) => {
|
|
|
449
553
|
const role = u.searchParams.get("role") === "specialist" ? "specialist" : "user";
|
|
450
554
|
const other = role === "user" ? "specialist" : "user";
|
|
451
555
|
// The user proves the call with its session id, the specialist with the handoff key.
|
|
452
|
-
|
|
556
|
+
// Specialists are the operator's team; a share-link visitor can only be the customer.
|
|
557
|
+
const sess = role === "specialist" ? (req.ava.operator ? getSessionBySpecialistKey(u.searchParams.get("key")) : null) : sessionFor(req, u.searchParams.get("session"));
|
|
453
558
|
if (!sess) { try { ws.close(4404, "unknown session"); } catch {} return; }
|
|
454
559
|
const session = sess.id;
|
|
455
560
|
let room = rtcRooms.get(session);
|
|
@@ -491,9 +596,6 @@ wss.on("connection", (ws, req) => {
|
|
|
491
596
|
ws.on("error", () => {});
|
|
492
597
|
});
|
|
493
598
|
|
|
494
|
-
// HOST=127.0.0.1 keeps Ava private to this computer (the CLI's "just me" setup).
|
|
495
|
-
const HOST = process.env.HOST || undefined;
|
|
496
|
-
const LOCAL_ONLY = HOST === "127.0.0.1" || HOST === "localhost" || HOST === "::1";
|
|
497
599
|
|
|
498
600
|
server.on("error", (e) => {
|
|
499
601
|
if (e.code === "EADDRINUSE") console.error(`\n ✗ Port ${PORT} is already in use. Stop whatever is using it, or pick another port (PORT=5191, or \`ava-engine start --port 5191\`).\n`);
|
|
@@ -504,7 +606,8 @@ server.on("error", (e) => {
|
|
|
504
606
|
|
|
505
607
|
server.listen(PORT, HOST, () => {
|
|
506
608
|
console.log(`\n Ava — screen-aware voice agent`);
|
|
507
|
-
console.log(` → http://localhost:${PORT}/
|
|
609
|
+
console.log(` → dashboard http://localhost:${PORT}/dashboard`);
|
|
610
|
+
console.log(` → test call http://localhost:${PORT}/app`);
|
|
508
611
|
console.log(` mode: ${MOCK ? "MOCK (no OPENROUTER_API_KEY set — synthetic replies)" : "LIVE (OpenRouter)"}`);
|
|
509
612
|
if (!MOCK) console.log(` models: talker=${MODELS.talker} perception=${MODELS.perception} planner=${MODELS.planner} intent=${MODELS.intent}`);
|
|
510
613
|
if (!MOCK) console.log(DAILY_BUDGET_USD ? ` daily cap: $${DAILY_BUDGET_USD} (spent today: $${spentToday().toFixed(2)})` : " daily cap: none (set DAILY_BUDGET_USD to limit spend across all calls)");
|
|
@@ -512,7 +615,7 @@ server.listen(PORT, HOST, () => {
|
|
|
512
615
|
if (WEBHOOKS_ENABLED) console.log(" webhooks: on");
|
|
513
616
|
if (LOCAL_ONLY) console.log(" access: this computer only");
|
|
514
617
|
else if (!AUTH_ENABLED) console.log(" access: OPEN — anyone who can reach this server can spend your API credit. Set ACCESS_PASSWORD before exposing it.");
|
|
515
|
-
else console.log(" access: password protected");
|
|
618
|
+
else console.log(hasUsers() ? " access: team accounts (sign in at /login)" : " access: password protected");
|
|
516
619
|
console.log("");
|
|
517
620
|
warmKokoro(); // preload the offline voice so the first reply is fast
|
|
518
621
|
warmAsr(); // preload the offline ear so the first utterance is fast
|