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/.env.example
CHANGED
|
@@ -16,11 +16,22 @@ PORT=5190
|
|
|
16
16
|
# ACCESS_PASSWORD=
|
|
17
17
|
# TRUST_PROXY=1 # behind Fly/Cloudflare/nginx: rate-limit by the real client IP
|
|
18
18
|
# SESSION_BUDGET_USD=2 # model spend cap per call
|
|
19
|
+
# DAILY_BUDGET_USD=20 # model spend cap per UTC day across all calls (unset = none)
|
|
19
20
|
# MAX_SESSIONS=50 # concurrent calls (idle ones expire after 30 min)
|
|
20
21
|
# MAX_ASR_CONNECTIONS=40 # concurrent speech-to-text streams
|
|
21
22
|
# RATE_SESSIONS_PER_MIN=10 # new calls per client IP per minute
|
|
22
23
|
# RATE_API_PER_MIN=240 # other API requests per client IP per minute
|
|
23
24
|
|
|
25
|
+
# ── Your products ──
|
|
26
|
+
# PLAYBOOKS_DIR=/srv/ava/playbooks # one JSON file per product (default ~/.ava-engine/playbooks): npx ava-engine playbooks new <id>
|
|
27
|
+
# BUILTIN_PLAYBOOKS=off # hide the built-in sample products from the lobby
|
|
28
|
+
|
|
29
|
+
# ── Events & handoff alerts ──
|
|
30
|
+
# WEBHOOK_URL=https://your-app.example/ava-events # receives every call event as JSON (comma-separate several)
|
|
31
|
+
# WEBHOOK_SECRET= # signs deliveries: X-Ava-Signature: sha256=HMAC(secret, "<timestamp>.<body>")
|
|
32
|
+
# SLACK_WEBHOOK_URL= # Slack incoming webhook: posts when a customer asks for a person
|
|
33
|
+
# PUBLIC_URL=https://ava.your-site.example # base for the specialist links in those alerts
|
|
34
|
+
|
|
24
35
|
# ── Models (OpenRouter slugs) — one per role ──
|
|
25
36
|
# MODEL_TALKER=meta-llama/llama-3.3-70b-instruct # the spoken reply (latency-critical)
|
|
26
37
|
# MODEL_PERCEPTION=google/gemini-2.5-flash # reads the shared screen
|
package/README.md
CHANGED
|
@@ -58,20 +58,37 @@ Requires **Node 22.9+** and desktop **Chrome**. One command installs Ava and wal
|
|
|
58
58
|
npx ava-engine
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
First it asks where to set up: **in your browser** (a guided page) or **in the terminal**. Both do the same:
|
|
62
62
|
1. check your OpenRouter key (or look around with no keys, in MOCK mode);
|
|
63
63
|
2. pick Ava's voice, and download the free offline voice (Kokoro, ~90 MB) with progress;
|
|
64
64
|
3. download offline speech recognition, also with progress;
|
|
65
65
|
4. protect Ava with a password if other people will use it;
|
|
66
|
-
5. start Ava and open **http://localhost:5190/
|
|
66
|
+
5. start Ava and open your **dashboard** at **http://localhost:5190/dashboard**.
|
|
67
67
|
|
|
68
68
|
After that, `npx ava-engine` just starts Ava.
|
|
69
69
|
|
|
70
|
+
### Run it from the dashboard
|
|
71
|
+
|
|
72
|
+
| | |
|
|
73
|
+
| --- | --- |
|
|
74
|
+
| **Overview** | Live status, today's calls and spend, who's waiting for a person, and a getting-started checklist |
|
|
75
|
+
| **Inbox** | Customers who asked for a person, live. A sound and a notification when someone new is waiting; **Pick up** marks it yours and opens the specialist console |
|
|
76
|
+
| **Calls** | Live calls (join a customer who asked for a person) and history with search, filters and CSV export. Each call has a page: outcome, journey, length, cost, and (if you turn transcripts on) the conversation and recap |
|
|
77
|
+
| **Analytics** | How many finish onboarding, the step where people get stuck (with a hint on what to fix), how often they need a person, and cost per finished onboarding. By period, product and link |
|
|
78
|
+
| **Share links** | One link per product and case, e.g. a trial welcome email or your app's help menu. Customers land on a simple start page with no pickers and no console. Pause, expire or cap a link per day. **Embed** gives you one line of HTML for a "Talk to Ava" button in your own product |
|
|
79
|
+
| **Playbooks** | Describe your product in a form, or **Draft with AI** from your docs page. Or install one from the shared [playbook library](https://github.com/Mr-vero/ava-playbooks) and adapt it. Live on new calls as soon as you save |
|
|
80
|
+
| **Team** | Everyone signs in with their own account: owners (everything), editors (links, playbooks, calls) and specialists (the inbox). API keys for n8n, Zapier or your backend: everything the dashboard does, over [the API](docs/API.md). Locked out? `npx ava-engine team reset you@company.com` |
|
|
81
|
+
| **Settings** | Edit keys, models, voice, password, public address, limits and webhooks, applied without a restart (host, port and proxy mode need one). Connect OpenRouter from here too. Secrets are never shown back |
|
|
82
|
+
|
|
83
|
+
`/app` stays as your own test bench. Preselect it with `/app?mode=onboarding&product=<playbook id>`.
|
|
84
|
+
|
|
70
85
|
| Command | What it does |
|
|
71
86
|
| --- | --- |
|
|
72
87
|
| `npx ava-engine setup` | Re-run setup to change keys, voice, password or port (`--web` for the browser, `--terminal` for here) |
|
|
73
88
|
| `npx ava-engine doctor` | Check everything and get a fix for anything that's wrong |
|
|
74
89
|
| `npx ava-engine models` | Download speech models or the Kokoro voice (`models kokoro`) |
|
|
90
|
+
| `npx ava-engine playbooks` | Check your playbook files (`playbooks new <id>` writes a starter) |
|
|
91
|
+
| `npx ava-engine team` | List team accounts (`team add <email>`, `team reset <email>`) |
|
|
75
92
|
| `npx ava-engine start --port 8080` | Start on another port |
|
|
76
93
|
|
|
77
94
|
Settings live in `~/.ava-engine` (set `AVA_HOME` to move them). **Watch the demo locally** at `http://localhost:5190/app?demo=simple`.
|
|
@@ -105,6 +122,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
105
122
|
| 🎧 | [**Speech-to-text**](docs/setup/speech-to-text.md) | Offline sherpa-onnx models, English or multilingual |
|
|
106
123
|
| 🧩 | [**Customizing**](docs/setup/customize.md) | Products in the onboarding catalogue, persona, look |
|
|
107
124
|
| 🚀 | [**Deploying**](DEPLOY.md) | A Cloudflare tunnel, Fly.io or any Docker host |
|
|
125
|
+
| 🔌 | [**API**](docs/API.md) | Drive Ava from scripts and tools with an API key |
|
|
108
126
|
| 📚 | [**Reference**](docs/REFERENCE.md) | Every route, file and setting |
|
|
109
127
|
|
|
110
128
|
---
|
|
@@ -149,12 +167,14 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
149
167
|
**For the customer**
|
|
150
168
|
- **Talks, naturally.** Live voice with streamed replies, interruptible at any time (barge-in), in text or voice.
|
|
151
169
|
- **Sees their screen.** Reads the shared tab — the page, what's selected, the numbers on it — and grounds every answer in it. Never invents buttons; if it isn't sure, it takes another look.
|
|
152
|
-
- **Helps with anything on the page.** A *Flexible agent* for whatever the customer is doing, and an *Onboarding agent* that walks a journey step by step on a
|
|
170
|
+
- **Helps with anything on the page.** A *Flexible agent* for whatever the customer is doing, and an *Onboarding agent* that walks a journey step by step on your product, described in a JSON [playbook](docs/setup/customize.md) (samples included for Todoist, Supabase, Stripe, AWS Console, Salesforce, Datadog and Vercel).
|
|
153
171
|
- **Speaks up.** Reacts when the screen changes, and flags risky or costly moves before they happen (a $9,900 seat count, a destructive click).
|
|
154
172
|
- **Knows when it's done.** Confirms each step actually worked, and goes quiet once the task is complete.
|
|
155
173
|
- **A person, one tap away.** Hands off to a human specialist with the goal, the screen and the progress already shared — the specialist joins the same call over WebRTC.
|
|
156
174
|
|
|
157
175
|
**For the team running it**
|
|
176
|
+
- **Events** — webhooks for call started, step completed, handoff requested and call ended, plus a Slack alert with a join link when a customer asks for a person.
|
|
177
|
+
- **Spend caps** — per call and per day across all calls.
|
|
158
178
|
- **Session recap** — an AI summary, highlights and stats, plus a replay of how the agents worked, with a timestamped timeline.
|
|
159
179
|
- **Console** — end-to-end latency per turn, which agents ran and how long each took, the compiled context the talker saw, and live spend.
|
|
160
180
|
- **Per-platform theming** — onboarding calls take on the product's brand colour.
|
package/lib/admin.mjs
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
// The dashboard's API (/api/admin/*): overview, calls, share links, playbooks and
|
|
2
|
+
// settings. Only for the operator: the server checks access before calling in.
|
|
3
|
+
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { join, dirname } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { liveSessions, getSession } from "./state.mjs";
|
|
8
|
+
import { listUsers, createUser, updateUser, setPassword, deleteUser, hasUsers, getUser, verifyLogin, suggestPassword, ROLES, listKeys, createKey, deleteKey } from "./team.mjs";
|
|
9
|
+
import { analytics } from "./analytics.mjs";
|
|
10
|
+
import { recentCalls, callsToday, getCall, deleteCall, getTranscript, transcriptsOn, deleteAllTranscripts } from "./calls.mjs";
|
|
11
|
+
import { listLinks, getLink, createLink, updateLink, deleteLink, validateLink, LINKS_FILE } from "./links.mjs";
|
|
12
|
+
import { PLATFORMS, platformById, playbooksAdminView, savePlaybook, deletePlaybook } from "./platforms.mjs";
|
|
13
|
+
import { validatePlaybook, template } from "./playbooks.mjs";
|
|
14
|
+
import { DAILY_BUDGET_USD, spentToday, dailyCapReached } from "./spend.mjs";
|
|
15
|
+
import { emitEvent, WEBHOOK_INFO } from "./webhooks.mjs";
|
|
16
|
+
import { MOCK } from "./openrouter.mjs";
|
|
17
|
+
import { MODELS, PROVIDER } from "./models.mjs";
|
|
18
|
+
import { AUTH_ENABLED, refreshAuth, sessionCookieFor, logoutCookie } from "./guard.mjs";
|
|
19
|
+
import { settingsView, updateSettings } from "./settings.mjs";
|
|
20
|
+
import { libraryView, installFromLibrary } from "./library.mjs";
|
|
21
|
+
import { draftPlaybook } from "./draft.mjs";
|
|
22
|
+
import { pkce, authUrl, exchangeCode, callbackPage, FLOW_TIMEOUT_MS } from "./cli/openrouter-oauth.mjs";
|
|
23
|
+
|
|
24
|
+
const VERSION = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8")).version;
|
|
25
|
+
const round = (n, d = 4) => Math.round(n * 10 ** d) / 10 ** d;
|
|
26
|
+
const productExists = (id) => PLATFORMS.some((p) => p.id === id);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* ctx: { send, readBody, LOCAL_ONLY, HOST, PORT, sessionBudget(), voice(), speech() }
|
|
30
|
+
* Returns a handler(req, res, url) for /api/admin/* paths.
|
|
31
|
+
*/
|
|
32
|
+
export function createAdmin(ctx) {
|
|
33
|
+
const { send, readBody } = ctx;
|
|
34
|
+
const publicUrl = () => (process.env.PUBLIC_URL || "").replace(/\/+$/, "") || null;
|
|
35
|
+
|
|
36
|
+
function status() {
|
|
37
|
+
return {
|
|
38
|
+
version: VERSION, mock: MOCK,
|
|
39
|
+
access: { password: AUTH_ENABLED, localOnly: ctx.LOCAL_ONLY, publicUrl: publicUrl() },
|
|
40
|
+
spend: { today: round(spentToday()), dailyCap: DAILY_BUDGET_USD || null, capReached: !MOCK && dailyCapReached(), perCall: ctx.sessionBudget() },
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function liveView() {
|
|
45
|
+
return liveSessions().map((s) => ({ ...s, handoff: s.handoff ? { state: s.handoff.state, since: s.handoff.since, pickedUpBy: s.handoff.pickedUpBy?.name || null, joinPath: s.handoff.specialistKey ? `/specialist?key=${encodeURIComponent(s.handoff.specialistKey)}` : null } : null }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function overview() {
|
|
49
|
+
const live = liveView(), done = callsToday();
|
|
50
|
+
const all = [...done, ...live];
|
|
51
|
+
const view = playbooksAdminView();
|
|
52
|
+
const links = listLinks();
|
|
53
|
+
return {
|
|
54
|
+
status: status(),
|
|
55
|
+
today: {
|
|
56
|
+
calls: all.length, live: live.length,
|
|
57
|
+
waiting: live.filter((s) => s.handoff && s.handoff.state !== "joined").length,
|
|
58
|
+
minutes: Math.round(all.reduce((a, c) => a + (c.durationSec || 0), 0) / 60),
|
|
59
|
+
completed: all.filter((c) => c.workflow?.completed).length,
|
|
60
|
+
handoffs: all.filter((c) => c.handoffRequested).length,
|
|
61
|
+
},
|
|
62
|
+
live, recent: recentCalls(8),
|
|
63
|
+
checklist: [
|
|
64
|
+
{ id: "key", done: !MOCK, label: "Connect OpenRouter for real answers", hint: "npx ava-engine setup" },
|
|
65
|
+
{ id: "playbook", done: view.playbooks.some((p) => p.source === "yours"), label: "Describe your product in a playbook" },
|
|
66
|
+
{ id: "link", done: links.length > 0, label: "Create a share link for your customers" },
|
|
67
|
+
{ id: "access", done: AUTH_ENABLED || false, label: "Let customers reach Ava (password + public address)", hint: "npx ava-engine setup → “My team or customers”" },
|
|
68
|
+
{ id: "events", done: WEBHOOK_INFO.endpoints > 0 || WEBHOOK_INFO.slack, label: "Get handoff alerts in Slack or your own systems", hint: "SLACK_WEBHOOK_URL / WEBHOOK_URL" },
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function linkView(l) {
|
|
74
|
+
const p = l.product ? platformById(l.product) : null;
|
|
75
|
+
return { ...l, product: l.product, productName: p ? p.name : null, productAccent: p?.accent || null, productMissing: !!l.product && !p, path: `/c/${l.id}` };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function settings() {
|
|
79
|
+
return {
|
|
80
|
+
...status(), ...settingsView(),
|
|
81
|
+
ai: { models: MODELS, providers: PROVIDER },
|
|
82
|
+
voice: ctx.voice(), speech: ctx.speech(),
|
|
83
|
+
server: { host: ctx.HOST || "all network interfaces", port: ctx.PORT, trustProxy: process.env.TRUST_PROXY === "1" },
|
|
84
|
+
limits: { perCallUsd: ctx.sessionBudget(), dailyUsd: DAILY_BUDGET_USD || null, maxSessions: Number(process.env.MAX_SESSIONS) || 50, sessionsPerMin: Number(process.env.RATE_SESSIONS_PER_MIN) || 10, apiPerMin: Number(process.env.RATE_API_PER_MIN) || 240 },
|
|
85
|
+
events: WEBHOOK_INFO,
|
|
86
|
+
files: { config: process.env.AVA_CONFIG_FILE || null, playbooks: playbooksAdminView().dir, links: LINKS_FILE },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// "Connect OpenRouter" from the dashboard: same PKCE flow as setup. OpenRouter
|
|
91
|
+
// sends the browser back to /dashboard/openrouter-callback, which the server
|
|
92
|
+
// routes to finishOpenRouter() (a cross-site redirect carries no login cookie,
|
|
93
|
+
// so the single-use state proves the request is ours).
|
|
94
|
+
let oauth = null;
|
|
95
|
+
function startOpenRouter(origin) {
|
|
96
|
+
const { verifier, challenge, state } = pkce();
|
|
97
|
+
oauth = { verifier, state, at: Date.now() };
|
|
98
|
+
return authUrl({ callbackUrl: `${origin}/dashboard/openrouter-callback`, challenge, state });
|
|
99
|
+
}
|
|
100
|
+
async function finishOpenRouter(req, res, url) {
|
|
101
|
+
const page = (ok) => send(res, ok ? 200 : 400, callbackPage(ok, { cssHref: "/dashboard.css" }).replace("You can close this tab and go back to setup.", "Ava is using your new key. You can close this tab."), { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
|
|
102
|
+
const f = oauth;
|
|
103
|
+
if (!f || Date.now() - f.at > FLOW_TIMEOUT_MS || url.searchParams.get("state") !== f.state || !url.searchParams.get("code")) return page(false);
|
|
104
|
+
oauth = null; // single use
|
|
105
|
+
try {
|
|
106
|
+
const key = await exchangeCode({ code: url.searchParams.get("code"), verifier: f.verifier });
|
|
107
|
+
const r = updateSettings({ OPENROUTER_API_KEY: key });
|
|
108
|
+
return page(!r.errors);
|
|
109
|
+
} catch { return page(false); }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// What each role may do. Specialists see calls and the inbox; editors also
|
|
113
|
+
// manage links and playbooks; owners also settings and the team.
|
|
114
|
+
const RANK = { specialist: 1, editor: 2, owner: 3 };
|
|
115
|
+
const NEED = [
|
|
116
|
+
[/^\w+ \/api\/admin\/(settings|openrouter|webhooks|transcripts|team|keys)\b/, "owner"],
|
|
117
|
+
[/^(POST|PUT|DELETE) \/api\/admin\/(links|playbooks|library)\b(?!\/check$)/, "editor"],
|
|
118
|
+
[/^DELETE \/api\/admin\/calls\//, "editor"],
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
async function handle(req, res, url, user) {
|
|
122
|
+
const route = `${req.method} ${url.pathname}`;
|
|
123
|
+
const tail = (prefix) => (url.pathname.startsWith(prefix) ? decodeURIComponent(url.pathname.slice(prefix.length)) : null);
|
|
124
|
+
const need = NEED.find(([re]) => re.test(route))?.[1];
|
|
125
|
+
if (need && RANK[user.role] < RANK[need]) return send(res, 403, { error: `Only ${need === "owner" ? "owners" : "owners and editors"} can do that. You're signed in as ${user.role === "editor" ? "an" : "a"} ${user.role}.` });
|
|
126
|
+
|
|
127
|
+
// ── you ──
|
|
128
|
+
if (route === "GET /api/admin/me") return send(res, 200, { user, accounts: hasUsers() });
|
|
129
|
+
if (route === "POST /api/admin/me/password") {
|
|
130
|
+
const acct = getUser(user.id);
|
|
131
|
+
if (!acct) return send(res, 400, { error: "You're signed in with the shared password, which is changed in Settings." });
|
|
132
|
+
const body = await readBody(req);
|
|
133
|
+
if (!verifyLogin(acct.email, body.current)) return send(res, 400, { error: "Your current password isn't right." });
|
|
134
|
+
const r = setPassword(acct.id, body.password);
|
|
135
|
+
if (r.errors) return send(res, 400, { errors: r.errors });
|
|
136
|
+
return send(res, 200, { ok: true }, { "Set-Cookie": sessionCookieFor(getUser(acct.id), req) }); // stay signed in here, out everywhere else
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── team (owners) ──
|
|
140
|
+
if (route === "GET /api/admin/team") return send(res, 200, { users: listUsers(), roles: ROLES, me: user.id, first: !hasUsers() });
|
|
141
|
+
if (route === "POST /api/admin/team") {
|
|
142
|
+
const body = await readBody(req);
|
|
143
|
+
const first = !hasUsers();
|
|
144
|
+
const password = body.password || suggestPassword();
|
|
145
|
+
const r = createUser({ email: body.email, name: body.name, role: first ? "owner" : body.role, password });
|
|
146
|
+
if (r.errors) return send(res, 400, { errors: r.errors });
|
|
147
|
+
refreshAuth(); // the first account turns sign-in on
|
|
148
|
+
// The first owner is whoever set it up: sign them in, or they'd be locked out.
|
|
149
|
+
return send(res, 201, { user: r.user, password: body.password ? undefined : password }, first ? { "Set-Cookie": sessionCookieFor(getUser(r.user.id), req) } : {});
|
|
150
|
+
}
|
|
151
|
+
const pw = url.pathname.match(/^\/api\/admin\/team\/([\w-]+)\/password$/);
|
|
152
|
+
if (pw && req.method === "POST") {
|
|
153
|
+
const password = (await readBody(req)).password || suggestPassword();
|
|
154
|
+
const r = setPassword(pw[1], password);
|
|
155
|
+
return r.errors ? send(res, 400, { errors: r.errors }) : send(res, 200, { user: r.user, password });
|
|
156
|
+
}
|
|
157
|
+
const memberId = tail("/api/admin/team/");
|
|
158
|
+
if (memberId != null && !pw) {
|
|
159
|
+
if (req.method === "PUT") { const r = updateUser(memberId, await readBody(req)); return r.errors ? send(res, 400, { errors: r.errors }) : send(res, 200, r); }
|
|
160
|
+
if (req.method === "DELETE") {
|
|
161
|
+
const r = deleteUser(memberId);
|
|
162
|
+
if (r.errors) return send(res, 400, { errors: r.errors });
|
|
163
|
+
refreshAuth();
|
|
164
|
+
return send(res, 200, r, memberId === user.id ? { "Set-Cookie": logoutCookie(req) } : {});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── API keys (owners) ──
|
|
169
|
+
if (route === "GET /api/admin/keys") return send(res, 200, { keys: listKeys(), roles: ROLES });
|
|
170
|
+
if (route === "POST /api/admin/keys") {
|
|
171
|
+
if (user.apiKey) return send(res, 403, { error: "API keys can't make more API keys. Use the dashboard." });
|
|
172
|
+
const b = await readBody(req);
|
|
173
|
+
const r = createKey({ name: b.name, role: b.role, createdBy: user.name });
|
|
174
|
+
return r.errors ? send(res, 400, { errors: r.errors }) : send(res, 201, r);
|
|
175
|
+
}
|
|
176
|
+
const keyId = tail("/api/admin/keys/");
|
|
177
|
+
if (keyId != null && req.method === "DELETE") return deleteKey(keyId) ? send(res, 200, { deleted: true }) : send(res, 404, { error: "unknown key" });
|
|
178
|
+
|
|
179
|
+
// ── inbox: pick up a customer who asked for a person ──
|
|
180
|
+
const pick = url.pathname.match(/^\/api\/admin\/calls\/([0-9a-f-]{36})\/pickup$/);
|
|
181
|
+
if (pick && req.method === "POST") {
|
|
182
|
+
const s = getSession(pick[1]);
|
|
183
|
+
const h = s?.ours.handoff;
|
|
184
|
+
if (!s || !h?.requested || !s.specialistKey) return send(res, 409, { error: "That customer isn't waiting any more." });
|
|
185
|
+
if (h.pickedUpBy && h.pickedUpBy.id !== user.id) return send(res, 409, { error: `${h.pickedUpBy.name} already picked this up.` });
|
|
186
|
+
h.pickedUpBy = { id: user.id, name: user.name, at: Date.now() };
|
|
187
|
+
emitEvent("handoff.picked_up", { sessionId: s.id, by: user.name, link: s.link ? { id: s.link.id, name: s.link.name } : null });
|
|
188
|
+
return send(res, 200, { joinPath: `/specialist?key=${encodeURIComponent(s.specialistKey)}` });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (route === "GET /api/admin/overview") return send(res, 200, overview());
|
|
192
|
+
if (route === "GET /api/admin/analytics") {
|
|
193
|
+
const q = url.searchParams, days = [0, 7, 30, 90].includes(Number(q.get("days"))) ? Number(q.get("days")) : 30;
|
|
194
|
+
const all = recentCalls(5000);
|
|
195
|
+
const products = [...new Map(all.filter((c) => c.platform).map((c) => [c.platform.id, c.platform.name])).entries()].map(([id, name]) => ({ id, name }));
|
|
196
|
+
const links = [...new Map(all.filter((c) => c.link).map((c) => [c.link.id, c.link.name])).entries()].map(([id, name]) => ({ id, name }));
|
|
197
|
+
return send(res, 200, { ...analytics(all, { days, product: q.get("product") || "", link: q.get("link") || "" }), options: { products, links } });
|
|
198
|
+
}
|
|
199
|
+
if (route === "GET /api/admin/calls") return send(res, 200, { live: liveView(), recent: recentCalls(500), transcripts: transcriptsOn() });
|
|
200
|
+
if (route === "DELETE /api/admin/transcripts") { deleteAllTranscripts(); return send(res, 200, { deleted: true }); }
|
|
201
|
+
const callId = tail("/api/admin/calls/");
|
|
202
|
+
if (callId != null) {
|
|
203
|
+
const c = getCall(callId);
|
|
204
|
+
if (!c) return send(res, 404, { error: "That call isn't in the history (it may still be running, or was deleted)." });
|
|
205
|
+
if (req.method === "GET") return send(res, 200, { call: c, transcript: getTranscript(callId), transcripts: transcriptsOn() });
|
|
206
|
+
if (req.method === "DELETE") return send(res, 200, { deleted: deleteCall(callId) });
|
|
207
|
+
}
|
|
208
|
+
if (route === "GET /api/admin/settings") return send(res, 200, settings());
|
|
209
|
+
if (route === "PUT /api/admin/settings") {
|
|
210
|
+
const r = updateSettings((await readBody(req)).values);
|
|
211
|
+
if (r.errors) return send(res, 400, { errors: r.errors });
|
|
212
|
+
return send(res, 200, { ...r, ...settings() });
|
|
213
|
+
}
|
|
214
|
+
if (route === "POST /api/admin/openrouter/start") return send(res, 200, { url: startOpenRouter(ctx.origin(req)) });
|
|
215
|
+
|
|
216
|
+
// ── share links ──
|
|
217
|
+
if (route === "GET /api/admin/links") return send(res, 200, { links: listLinks().map(linkView), publicUrl: publicUrl(), localOnly: ctx.LOCAL_ONLY, password: AUTH_ENABLED });
|
|
218
|
+
if (route === "POST /api/admin/links") {
|
|
219
|
+
const r = validateLink(await readBody(req), { productExists });
|
|
220
|
+
if (r.errors) return send(res, 400, { errors: r.errors });
|
|
221
|
+
return send(res, 201, { link: linkView(createLink(r.value)) });
|
|
222
|
+
}
|
|
223
|
+
const linkId = tail("/api/admin/links/");
|
|
224
|
+
if (linkId != null) {
|
|
225
|
+
if (!getLink(linkId)) return send(res, 404, { error: "unknown link" });
|
|
226
|
+
if (req.method === "PUT") {
|
|
227
|
+
const r = validateLink(await readBody(req), { productExists });
|
|
228
|
+
if (r.errors) return send(res, 400, { errors: r.errors });
|
|
229
|
+
return send(res, 200, { link: linkView(updateLink(linkId, r.value)) });
|
|
230
|
+
}
|
|
231
|
+
if (req.method === "DELETE") return send(res, 200, { deleted: deleteLink(linkId) });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── playbooks ──
|
|
235
|
+
if (route === "GET /api/admin/playbooks") return send(res, 200, { ...playbooksAdminView(), template: template("my-product") });
|
|
236
|
+
// ── library and drafting ──
|
|
237
|
+
if (route === "GET /api/admin/library") {
|
|
238
|
+
try { return send(res, 200, await libraryView()); } catch (e) { return send(res, 502, { error: e.message }); }
|
|
239
|
+
}
|
|
240
|
+
const lib = url.pathname.match(/^\/api\/admin\/library\/([a-z0-9-]{1,40})\/install$/);
|
|
241
|
+
if (lib && req.method === "POST") {
|
|
242
|
+
try {
|
|
243
|
+
const r = await installFromLibrary(lib[1], { overwrite: !!(await readBody(req)).overwrite });
|
|
244
|
+
return r.errors ? send(res, r.status, { errors: r.errors }) : send(res, 200, r);
|
|
245
|
+
} catch (e) { return send(res, 502, { error: e.message }); }
|
|
246
|
+
}
|
|
247
|
+
if (route === "POST /api/admin/playbooks/draft") {
|
|
248
|
+
try { return send(res, 200, await draftPlaybook(await readBody(req))); }
|
|
249
|
+
catch (e) { return send(res, e.code === "daily_cap" ? 503 : e.status || 502, { error: e.code === "daily_cap" ? "Today's spending cap is reached, so drafting is paused until tomorrow." : e.message || "Drafting failed. Try again." }); }
|
|
250
|
+
}
|
|
251
|
+
if (route === "POST /api/admin/playbooks/check") {
|
|
252
|
+
const r = validatePlaybook((await readBody(req)).playbook);
|
|
253
|
+
return send(res, 200, { ok: !r.errors, errors: r.errors || [], warnings: r.warnings || [] });
|
|
254
|
+
}
|
|
255
|
+
const pbId = tail("/api/admin/playbooks/");
|
|
256
|
+
if (pbId != null && pbId !== "check") {
|
|
257
|
+
if (req.method === "PUT") {
|
|
258
|
+
const body = await readBody(req);
|
|
259
|
+
const previousId = body.previousId ? String(body.previousId) : undefined;
|
|
260
|
+
const r = savePlaybook(body.playbook, { previousId });
|
|
261
|
+
if (r.errors) return send(res, 400, { errors: r.errors });
|
|
262
|
+
if (previousId && previousId !== r.playbook.id) for (const l of listLinks()) if (l.product === previousId) updateLink(l.id, { product: r.playbook.id });
|
|
263
|
+
return send(res, 200, { playbook: r.playbook });
|
|
264
|
+
}
|
|
265
|
+
if (req.method === "DELETE") {
|
|
266
|
+
const using = listLinks().filter((l) => l.product === pbId);
|
|
267
|
+
if (using.length) return send(res, 409, { error: `Share links use this playbook: ${using.map((l) => `“${l.name}”`).join(", ")}. Point them at another product or delete them first.` });
|
|
268
|
+
return deletePlaybook(pbId) ? send(res, 200, { deleted: true }) : send(res, 404, { error: "Only your own playbooks can be deleted." });
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── events ──
|
|
273
|
+
if (route === "POST /api/admin/webhooks/test") {
|
|
274
|
+
if (!WEBHOOK_INFO.endpoints && !WEBHOOK_INFO.slack) return send(res, 400, { error: "No webhooks are set up. Add WEBHOOK_URL or SLACK_WEBHOOK_URL to your settings and restart Ava." });
|
|
275
|
+
const results = await emitEvent("ava.test", { message: "Test event from the Ava dashboard" });
|
|
276
|
+
const ok = results.filter((r) => r.status === "fulfilled" && r.value === true).length;
|
|
277
|
+
return send(res, 200, { sent: results.length, delivered: ok });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return send(res, 404, { error: "unknown endpoint" });
|
|
281
|
+
}
|
|
282
|
+
handle.finishOpenRouter = finishOpenRouter;
|
|
283
|
+
return handle;
|
|
284
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Analytics for the dashboard, computed from the call history (finished calls).
|
|
2
|
+
// Answers what a team running onboarding wants to know: how many people finish,
|
|
3
|
+
// where they get stuck, how often they need a person, and what a finished
|
|
4
|
+
// onboarding costs.
|
|
5
|
+
|
|
6
|
+
const DAY = 86_400_000;
|
|
7
|
+
const median = (xs) => { if (!xs.length) return 0; const s = [...xs].sort((a, b) => a - b), m = s.length >> 1; return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; };
|
|
8
|
+
const rate = (n, d) => (d ? n / d : null);
|
|
9
|
+
const round = (n, k = 4) => (n == null ? null : Math.round(n * 10 ** k) / 10 ** k);
|
|
10
|
+
|
|
11
|
+
/** calls: finished-call records (newest first). Filters: days (0 = all), product id, link id ("test" = test calls). */
|
|
12
|
+
export function analytics(calls, { days = 30, product = "", link = "", now = Date.now() } = {}) {
|
|
13
|
+
const since = days ? now - days * DAY : 0;
|
|
14
|
+
const rows = calls.filter((c) => Date.parse(c.startedAt) >= since
|
|
15
|
+
&& (!product || c.platform?.id === product)
|
|
16
|
+
&& (!link || (link === "test" ? !c.link : c.link?.id === link)));
|
|
17
|
+
const onboarding = rows.filter((c) => c.workflow);
|
|
18
|
+
const completed = onboarding.filter((c) => c.workflow.completed);
|
|
19
|
+
const spend = rows.reduce((a, c) => a + (c.costUsd || 0), 0);
|
|
20
|
+
|
|
21
|
+
// calls per day, oldest first, with empty days filled in
|
|
22
|
+
const byDay = new Map();
|
|
23
|
+
for (const c of rows) {
|
|
24
|
+
const d = c.startedAt.slice(0, 10);
|
|
25
|
+
const b = byDay.get(d) || { date: d, calls: 0, completed: 0, handoffs: 0 };
|
|
26
|
+
b.calls++; if (c.workflow?.completed) b.completed++; if (c.handoffRequested) b.handoffs++;
|
|
27
|
+
byDay.set(d, b);
|
|
28
|
+
}
|
|
29
|
+
const span = days || Math.min(365, Math.max(1, Math.ceil((now - Math.min(now, ...rows.map((c) => Date.parse(c.startedAt)))) / DAY) + 1));
|
|
30
|
+
const daily = [];
|
|
31
|
+
for (let i = span - 1; i >= 0; i--) { const d = new Date(now - i * DAY).toISOString().slice(0, 10); daily.push(byDay.get(d) || { date: d, calls: 0, completed: 0, handoffs: 0 }); }
|
|
32
|
+
|
|
33
|
+
// the journey funnel per product: how many calls got each step done, and the biggest drop
|
|
34
|
+
const funnels = [];
|
|
35
|
+
const byProduct = new Map();
|
|
36
|
+
for (const c of onboarding) {
|
|
37
|
+
if (!c.platform || !c.steps) continue;
|
|
38
|
+
const f = byProduct.get(c.platform.id) || { product: c.platform, calls: 0, steps: new Map() };
|
|
39
|
+
f.calls++;
|
|
40
|
+
c.steps.forEach((s, i) => { const k = `${i}:${s.title}`; const e = f.steps.get(k) || { index: i, title: s.title, reached: 0 }; if (s.done) e.reached++; f.steps.set(k, e); });
|
|
41
|
+
byProduct.set(c.platform.id, f);
|
|
42
|
+
}
|
|
43
|
+
for (const f of byProduct.values()) {
|
|
44
|
+
const steps = [...f.steps.values()].sort((a, b) => a.index - b.index).map((s) => ({ ...s, pct: round(rate(s.reached, f.calls)) }));
|
|
45
|
+
let stuck = null, prev = 1;
|
|
46
|
+
steps.forEach((s, i) => { const drop = prev - (s.pct ?? 0); if (drop > 0.001 && (!stuck || drop > stuck.drop)) stuck = { index: i, title: s.title, drop: round(drop) }; prev = s.pct ?? 0; });
|
|
47
|
+
funnels.push({ product: f.product, calls: f.calls, steps, stuck });
|
|
48
|
+
}
|
|
49
|
+
funnels.sort((a, b) => b.calls - a.calls);
|
|
50
|
+
|
|
51
|
+
// by share link
|
|
52
|
+
const byLink = new Map();
|
|
53
|
+
for (const c of rows) {
|
|
54
|
+
const key = c.link?.id || "test";
|
|
55
|
+
const e = byLink.get(key) || { id: key, name: c.link?.name || "Test calls", calls: 0, onboarding: 0, completed: 0, handoffs: 0, spend: 0 };
|
|
56
|
+
e.calls++; if (c.workflow) e.onboarding++; if (c.workflow?.completed) e.completed++; if (c.handoffRequested) e.handoffs++; e.spend += c.costUsd || 0;
|
|
57
|
+
byLink.set(key, e);
|
|
58
|
+
}
|
|
59
|
+
const links = [...byLink.values()].map((e) => ({ ...e, spend: round(e.spend), completionRate: round(rate(e.completed, e.onboarding)), handoffRate: round(rate(e.handoffs, e.calls)), costPerCompletion: e.completed ? round(e.spend / e.completed) : null }))
|
|
60
|
+
.sort((a, b) => b.calls - a.calls);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
days, product: product || null, link: link || null,
|
|
64
|
+
totals: {
|
|
65
|
+
calls: rows.length, onboarding: onboarding.length, completed: completed.length,
|
|
66
|
+
completionRate: round(rate(completed.length, onboarding.length)),
|
|
67
|
+
handoffRate: round(rate(rows.filter((c) => c.handoffRequested).length, rows.length)),
|
|
68
|
+
medianMinutes: round(median(rows.map((c) => c.durationSec || 0)) / 60, 1),
|
|
69
|
+
spend: round(spend), costPerCompletion: completed.length ? round(spend / completed.length) : null,
|
|
70
|
+
},
|
|
71
|
+
daily, funnels, links,
|
|
72
|
+
};
|
|
73
|
+
}
|
package/lib/calls.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Call history for the dashboard.
|
|
2
|
+
//
|
|
3
|
+
// Every finished call: one line of numbers and step results (never what was said
|
|
4
|
+
// or shown), kept in memory (the latest 500) and appended to CALLS_FILE (default
|
|
5
|
+
// ~/.ava-engine/calls.jsonl) so a restart keeps it.
|
|
6
|
+
//
|
|
7
|
+
// Transcripts are separate and off by default (TRANSCRIPTS=on in Settings): what
|
|
8
|
+
// was said and the recap, one file per call in ~/.ava-engine/transcripts/ (mode
|
|
9
|
+
// 600), deleted after TRANSCRIPT_DAYS (default 30).
|
|
10
|
+
|
|
11
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
|
|
15
|
+
const HOME = process.env.AVA_HOME || join(homedir(), ".ava-engine");
|
|
16
|
+
const FILE = process.env.CALLS_FILE || join(HOME, "calls.jsonl");
|
|
17
|
+
const TRANSCRIPTS = join(HOME, "transcripts");
|
|
18
|
+
const ID = /^[0-9a-f-]{36}$/;
|
|
19
|
+
const KEEP = 5000; // enough history for analytics
|
|
20
|
+
let history = load();
|
|
21
|
+
|
|
22
|
+
function load() {
|
|
23
|
+
try {
|
|
24
|
+
const lines = readFileSync(FILE, "utf8").trim().split("\n").slice(-KEEP);
|
|
25
|
+
return lines.map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean).reverse();
|
|
26
|
+
} catch { return []; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Remember a finished call (newest first). */
|
|
30
|
+
export function recordCall(c) {
|
|
31
|
+
history.unshift(c);
|
|
32
|
+
if (history.length > KEEP) history.length = KEEP;
|
|
33
|
+
try {
|
|
34
|
+
mkdirSync(dirname(FILE), { recursive: true });
|
|
35
|
+
if (!existsSync(FILE)) writeFileSync(FILE, "", { mode: 0o600 });
|
|
36
|
+
appendFileSync(FILE, JSON.stringify(c) + "\n");
|
|
37
|
+
} catch (e) { console.warn(" [calls] couldn't save the call history:", e.message); }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function recentCalls(limit = 100) { return history.slice(0, limit); }
|
|
41
|
+
|
|
42
|
+
/** Finished calls that started today (UTC). */
|
|
43
|
+
export function callsToday() {
|
|
44
|
+
const d = new Date().toISOString().slice(0, 10);
|
|
45
|
+
return history.filter((c) => String(c.startedAt || "").startsWith(d));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getCall(id) { return history.find((c) => c.id === id) || null; }
|
|
49
|
+
|
|
50
|
+
/** Forget a call: its history line and its transcript (e.g. a customer's deletion request). */
|
|
51
|
+
export function deleteCall(id) {
|
|
52
|
+
const n = history.length;
|
|
53
|
+
history = history.filter((c) => c.id !== id);
|
|
54
|
+
deleteTranscript(id);
|
|
55
|
+
if (history.length === n) return false;
|
|
56
|
+
try { writeFileSync(FILE, history.slice().reverse().map((c) => JSON.stringify(c) + "\n").join(""), { mode: 0o600 }); } catch {}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── transcripts (opt-in) ──
|
|
61
|
+
export const transcriptsOn = () => process.env.TRANSCRIPTS === "on";
|
|
62
|
+
const keepDays = () => Number(process.env.TRANSCRIPT_DAYS) || 30;
|
|
63
|
+
const tfile = (id) => join(TRANSCRIPTS, `${id}.json`);
|
|
64
|
+
|
|
65
|
+
/** Save what was said on a call. Only when transcripts are on. */
|
|
66
|
+
export function saveTranscript(id, data) {
|
|
67
|
+
if (!transcriptsOn() || !ID.test(id)) return false;
|
|
68
|
+
try {
|
|
69
|
+
mkdirSync(TRANSCRIPTS, { recursive: true, mode: 0o700 });
|
|
70
|
+
writeFileSync(tfile(id), JSON.stringify(data), { mode: 0o600 });
|
|
71
|
+
return true;
|
|
72
|
+
} catch (e) { console.warn(" [calls] couldn't save a transcript:", e.message); return false; }
|
|
73
|
+
}
|
|
74
|
+
export function getTranscript(id) {
|
|
75
|
+
if (!ID.test(id)) return null;
|
|
76
|
+
try { return JSON.parse(readFileSync(tfile(id), "utf8")); } catch { return null; }
|
|
77
|
+
}
|
|
78
|
+
export function deleteTranscript(id) { if (ID.test(id)) rmSync(tfile(id), { force: true }); }
|
|
79
|
+
/** Delete every saved transcript. */
|
|
80
|
+
export function deleteAllTranscripts() {
|
|
81
|
+
rmSync(TRANSCRIPTS, { recursive: true, force: true });
|
|
82
|
+
for (const c of history) c.transcript = false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Delete transcripts older than TRANSCRIPT_DAYS. Runs at start and every hour. */
|
|
86
|
+
export function sweepTranscripts(now = Date.now()) {
|
|
87
|
+
let removed = 0;
|
|
88
|
+
try {
|
|
89
|
+
for (const f of readdirSync(TRANSCRIPTS)) {
|
|
90
|
+
const p = join(TRANSCRIPTS, f);
|
|
91
|
+
if (now - statSync(p).mtimeMs > keepDays() * 86_400_000) { rmSync(p, { force: true }); removed++; }
|
|
92
|
+
}
|
|
93
|
+
} catch { /* no transcripts yet */ }
|
|
94
|
+
return removed;
|
|
95
|
+
}
|
|
96
|
+
sweepTranscripts();
|
|
97
|
+
setInterval(sweepTranscripts, 3_600_000).unref();
|
package/lib/cli/config.mjs
CHANGED
|
@@ -26,6 +26,7 @@ export function readConfig(file = CONFIG_FILE) {
|
|
|
26
26
|
if (!m) continue;
|
|
27
27
|
let v = m[2];
|
|
28
28
|
if (/^".*"$/.test(v)) v = v.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, "\\");
|
|
29
|
+
else if (/^'.*'$/.test(v)) v = v.slice(1, -1); // written by the dashboard's Settings page
|
|
29
30
|
out[m[1]] = v;
|
|
30
31
|
}
|
|
31
32
|
return out;
|