getpatter 0.5.4 → 0.6.1
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/LICENSE +1 -1
- package/README.md +5 -2
- package/dist/aec-PJJMUM5E.mjs +228 -0
- package/dist/{banner-3GNZ6VQK.mjs → banner-UYW6UM3J.mjs} +4 -1
- package/dist/barge-in-strategies-X6ARMGIQ.mjs +12 -0
- package/dist/{carrier-config-33HQ2W4V.mjs → carrier-config-4ZKVYAWV.mjs} +5 -2
- package/dist/{chunk-AFUYSNDH.mjs → chunk-6GR5MHHQ.mjs} +9 -0
- package/dist/chunk-CYLJVT5G.mjs +7031 -0
- package/dist/chunk-D4424JZR.mjs +71 -0
- package/dist/{chunk-VJVDG4V5.mjs → chunk-MVOQFAEO.mjs} +5 -0
- package/dist/chunk-N565J3CF.mjs +69 -0
- package/dist/chunk-RV7APPYE.mjs +397 -0
- package/dist/{chunk-FIFIWBL7.mjs → chunk-TEW3NAZJ.mjs} +6000 -3156
- package/dist/{chunk-SEMKNPCD.mjs → chunk-XS45BAQL.mjs} +5 -1
- package/dist/cli.js +304 -640
- package/dist/client-2GJVZT42.mjs +8935 -0
- package/dist/dashboard/ui.html +63 -0
- package/dist/{dist-YRCCJQ26.mjs → dist-RYMPCILF.mjs} +28 -2
- package/dist/index.d.mts +3548 -428
- package/dist/index.d.ts +3548 -428
- package/dist/index.js +34336 -9532
- package/dist/index.mjs +3642 -512
- package/dist/{node-cron-6PRPSBG5.mjs → node-cron-JFWQQRBU.mjs} +23 -2
- package/dist/persistence-LVIAHESK.mjs +7 -0
- package/dist/silero-vad-NSEXI4XS.mjs +7 -0
- package/dist/streamableHttp-WKNGHDVO.mjs +1496 -0
- package/dist/test-mode-WEKKNBLD.mjs +8 -0
- package/dist/tunnel-43CHWPVQ.mjs +8 -0
- package/package.json +7 -7
- package/src/dashboard/ui.html +63 -0
- package/dist/chunk-QHHBUCMT.mjs +0 -25
- package/dist/persistence-LQBYQPQQ.mjs +0 -7
- package/dist/test-mode-MVJ3SKG4.mjs +0 -8
- package/dist/tunnel-UVR3PPAU.mjs +0 -8
package/dist/cli.js
CHANGED
|
@@ -50,6 +50,19 @@ var MetricsStore = class extends import_events.EventEmitter {
|
|
|
50
50
|
maxCalls;
|
|
51
51
|
calls = [];
|
|
52
52
|
activeCalls = /* @__PURE__ */ new Map();
|
|
53
|
+
/**
|
|
54
|
+
* User-driven soft delete: call_ids the operator removed from the
|
|
55
|
+
* dashboard view. The on-disk artefacts written by ``CallLogger``
|
|
56
|
+
* (``metadata.json``, ``transcript.jsonl``) are intentionally NOT
|
|
57
|
+
* touched — they serve as the durable backup. All read paths
|
|
58
|
+
* (``getCalls`` / ``getCall`` / ``getAggregates`` / ``getCallsInRange``
|
|
59
|
+
* / ``hydrate``) filter against this set so the call is invisible
|
|
60
|
+
* to the UI and excluded from rolling metrics. Populated from
|
|
61
|
+
* ``<logRoot>/.deleted_call_ids.json`` on hydrate so deletions
|
|
62
|
+
* survive a process restart. Parity with Python.
|
|
63
|
+
*/
|
|
64
|
+
deletedCallIds = /* @__PURE__ */ new Set();
|
|
65
|
+
deletedIdsPath = null;
|
|
53
66
|
/**
|
|
54
67
|
* Accepts either a numeric ``maxCalls`` (legacy positional — matches the
|
|
55
68
|
* original TS API) or an options object ``{ maxCalls }`` to align with the
|
|
@@ -63,6 +76,7 @@ var MetricsStore = class extends import_events.EventEmitter {
|
|
|
63
76
|
publish(eventType, data) {
|
|
64
77
|
this.emit("sse", { type: eventType, data });
|
|
65
78
|
}
|
|
79
|
+
/** Mark a call as in-progress (creates the row if it does not yet exist). */
|
|
66
80
|
recordCallStart(data) {
|
|
67
81
|
const callId = data.call_id || "";
|
|
68
82
|
if (!callId) return;
|
|
@@ -141,6 +155,8 @@ var MetricsStore = class extends import_events.EventEmitter {
|
|
|
141
155
|
ended_at: Date.now() / 1e3,
|
|
142
156
|
status,
|
|
143
157
|
metrics: null,
|
|
158
|
+
...active.turns && active.turns.length > 0 ? { turns: active.turns } : {},
|
|
159
|
+
...active.transcript && active.transcript.length > 0 ? { transcript: active.transcript } : {},
|
|
144
160
|
...extra
|
|
145
161
|
};
|
|
146
162
|
this.activeCalls.delete(callId);
|
|
@@ -160,6 +176,7 @@ var MetricsStore = class extends import_events.EventEmitter {
|
|
|
160
176
|
}
|
|
161
177
|
this.publish("call_status", { call_id: callId, status, ...extra });
|
|
162
178
|
}
|
|
179
|
+
/** Append a single conversation turn to an active call and broadcast it via SSE. */
|
|
163
180
|
recordTurn(data) {
|
|
164
181
|
const callId = data.call_id || "";
|
|
165
182
|
const turn = data.turn;
|
|
@@ -168,55 +185,183 @@ var MetricsStore = class extends import_events.EventEmitter {
|
|
|
168
185
|
if (active) {
|
|
169
186
|
if (!active.turns) active.turns = [];
|
|
170
187
|
active.turns.push(turn);
|
|
188
|
+
if (!active.transcript) active.transcript = [];
|
|
189
|
+
const turnRecord = turn;
|
|
190
|
+
const userText = typeof turnRecord.user_text === "string" ? turnRecord.user_text : "";
|
|
191
|
+
const agentText = typeof turnRecord.agent_text === "string" ? turnRecord.agent_text : "";
|
|
192
|
+
const ts = typeof turnRecord.timestamp === "number" ? turnRecord.timestamp : Date.now() / 1e3;
|
|
193
|
+
if (userText.length > 0) {
|
|
194
|
+
active.transcript.push({ role: "user", text: userText, timestamp: ts });
|
|
195
|
+
}
|
|
196
|
+
if (agentText.length > 0 && agentText !== "[interrupted]") {
|
|
197
|
+
active.transcript.push({
|
|
198
|
+
role: "assistant",
|
|
199
|
+
text: agentText,
|
|
200
|
+
timestamp: ts
|
|
201
|
+
});
|
|
202
|
+
}
|
|
171
203
|
}
|
|
172
204
|
this.publish("turn_complete", { call_id: callId, turn });
|
|
173
205
|
}
|
|
206
|
+
/** Move a call from active to completed and persist its final metrics. */
|
|
174
207
|
recordCallEnd(data, metrics) {
|
|
175
208
|
const callId = data.call_id || "";
|
|
176
209
|
if (!callId) return;
|
|
177
210
|
const active = this.activeCalls.get(callId);
|
|
178
211
|
this.activeCalls.delete(callId);
|
|
179
|
-
|
|
180
|
-
|
|
212
|
+
let existingIdx = -1;
|
|
213
|
+
if (active === void 0) {
|
|
214
|
+
for (let i = this.calls.length - 1; i >= 0; i--) {
|
|
215
|
+
if (this.calls[i].call_id === callId) {
|
|
216
|
+
existingIdx = i;
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
const existing = existingIdx >= 0 ? this.calls[existingIdx] : void 0;
|
|
222
|
+
const priorStatus = active?.status ?? existing?.status;
|
|
223
|
+
const resolvedStatus = priorStatus && priorStatus !== "in-progress" ? priorStatus : "completed";
|
|
224
|
+
const dataTranscript = data.transcript;
|
|
225
|
+
const resolvedTranscript = dataTranscript && dataTranscript.length > 0 ? dataTranscript : active?.transcript && active.transcript.length > 0 ? active.transcript : existing?.transcript && existing.transcript.length > 0 ? existing.transcript : [];
|
|
226
|
+
const resolvedTurns = active?.turns && active.turns.length > 0 ? active.turns : existing?.turns && existing.turns.length > 0 ? existing.turns : void 0;
|
|
181
227
|
const entry = {
|
|
182
228
|
call_id: callId,
|
|
183
|
-
caller: data.caller || active?.caller || "",
|
|
184
|
-
callee: data.callee || active?.callee || "",
|
|
185
|
-
direction: active?.direction || data.direction || "inbound",
|
|
186
|
-
started_at: active?.started_at || 0,
|
|
229
|
+
caller: data.caller || active?.caller || existing?.caller || "",
|
|
230
|
+
callee: data.callee || active?.callee || existing?.callee || "",
|
|
231
|
+
direction: active?.direction || existing?.direction || data.direction || "inbound",
|
|
232
|
+
started_at: active?.started_at || existing?.started_at || 0,
|
|
187
233
|
ended_at: Date.now() / 1e3,
|
|
188
|
-
transcript:
|
|
234
|
+
transcript: resolvedTranscript,
|
|
235
|
+
...resolvedTurns ? { turns: resolvedTurns } : {},
|
|
189
236
|
status: resolvedStatus,
|
|
190
|
-
metrics: metrics ?? null
|
|
237
|
+
metrics: metrics ?? existing?.metrics ?? null
|
|
191
238
|
};
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
239
|
+
if (existingIdx >= 0) {
|
|
240
|
+
this.calls[existingIdx] = entry;
|
|
241
|
+
} else {
|
|
242
|
+
this.calls.push(entry);
|
|
243
|
+
if (this.calls.length > this.maxCalls) {
|
|
244
|
+
this.calls = this.calls.slice(-this.maxCalls);
|
|
245
|
+
}
|
|
195
246
|
}
|
|
196
247
|
this.publish("call_end", {
|
|
197
248
|
call_id: callId,
|
|
198
249
|
metrics: entry.metrics ?? null
|
|
199
250
|
});
|
|
200
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* Return a window of completed calls in newest-first order.
|
|
254
|
+
*
|
|
255
|
+
* Soft-deleted call_ids (see ``deleteCalls``) are filtered out so the
|
|
256
|
+
* dashboard never re-shows a row the user removed. The on-disk
|
|
257
|
+
* artefacts are intentionally preserved as a backup.
|
|
258
|
+
*/
|
|
201
259
|
getCalls(limit = 50, offset = 0) {
|
|
202
|
-
const
|
|
260
|
+
const visible = this.calls.filter((c) => !this.deletedCallIds.has(c.call_id));
|
|
261
|
+
const ordered = visible.reverse();
|
|
203
262
|
return ordered.slice(offset, offset + limit);
|
|
204
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Look up a completed call by id (newest match wins).
|
|
266
|
+
*
|
|
267
|
+
* Soft-deleted call_ids resolve to ``null`` so the SPA's detail pane
|
|
268
|
+
* cannot render a row the user removed.
|
|
269
|
+
*/
|
|
205
270
|
getCall(callId) {
|
|
271
|
+
if (this.deletedCallIds.has(callId)) return null;
|
|
206
272
|
for (let i = this.calls.length - 1; i >= 0; i--) {
|
|
207
273
|
if (this.calls[i].call_id === callId) return this.calls[i];
|
|
208
274
|
}
|
|
209
275
|
return null;
|
|
210
276
|
}
|
|
277
|
+
/**
|
|
278
|
+
* Soft-delete one or more calls from the dashboard view.
|
|
279
|
+
*
|
|
280
|
+
* Adds each ``call_id`` to an in-memory set. Subsequent reads via
|
|
281
|
+
* ``getCalls`` / ``getCall`` / ``getAggregates`` / ``getCallsInRange``
|
|
282
|
+
* exclude the deleted ids, so rolling metrics (avg latency, total
|
|
283
|
+
* spend) are recomputed without them. The on-disk
|
|
284
|
+
* ``metadata.json`` / ``transcript.jsonl`` files written by
|
|
285
|
+
* ``CallLogger`` are NOT touched — they serve as a durable backup
|
|
286
|
+
* the operator can audit outside the dashboard.
|
|
287
|
+
*
|
|
288
|
+
* Active calls are never deletable. A call_id that is currently
|
|
289
|
+
* in ``activeCalls`` is silently skipped so a mid-call delete
|
|
290
|
+
* from the UI cannot orphan the live transcript pane.
|
|
291
|
+
*
|
|
292
|
+
* Persisted to ``<logRoot>/.deleted_call_ids.json`` (best-effort)
|
|
293
|
+
* when ``hydrate()`` has been called with a log root. Parity with
|
|
294
|
+
* Python ``delete_calls``.
|
|
295
|
+
*
|
|
296
|
+
* @returns The list of call_ids actually accepted as deleted.
|
|
297
|
+
*/
|
|
298
|
+
deleteCalls(callIds) {
|
|
299
|
+
const ids = /* @__PURE__ */ new Set();
|
|
300
|
+
for (const cid of callIds || []) {
|
|
301
|
+
if (typeof cid === "string" && cid && !this.activeCalls.has(cid)) {
|
|
302
|
+
ids.add(cid);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (ids.size === 0) return [];
|
|
306
|
+
const accepted = [];
|
|
307
|
+
for (const cid of ids) {
|
|
308
|
+
if (!this.deletedCallIds.has(cid)) {
|
|
309
|
+
this.deletedCallIds.add(cid);
|
|
310
|
+
accepted.push(cid);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (accepted.length === 0) return [];
|
|
314
|
+
accepted.sort();
|
|
315
|
+
this.persistDeletedIds();
|
|
316
|
+
this.publish("calls_deleted", { call_ids: accepted });
|
|
317
|
+
return accepted;
|
|
318
|
+
}
|
|
319
|
+
/** Whether ``callId`` was soft-deleted from the dashboard. */
|
|
320
|
+
isDeleted(callId) {
|
|
321
|
+
return this.deletedCallIds.has(callId);
|
|
322
|
+
}
|
|
323
|
+
/** Snapshot of soft-deleted call_ids (sorted). */
|
|
324
|
+
getDeletedCallIds() {
|
|
325
|
+
return Array.from(this.deletedCallIds).sort();
|
|
326
|
+
}
|
|
327
|
+
/** Atomically persist the deleted-ids set to disk. Best-effort. */
|
|
328
|
+
persistDeletedIds() {
|
|
329
|
+
if (this.deletedIdsPath === null) return;
|
|
330
|
+
try {
|
|
331
|
+
const dir = path.dirname(this.deletedIdsPath);
|
|
332
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
333
|
+
const tmp = this.deletedIdsPath + ".tmp";
|
|
334
|
+
const payload = {
|
|
335
|
+
version: 1,
|
|
336
|
+
deleted_call_ids: Array.from(this.deletedCallIds).sort()
|
|
337
|
+
};
|
|
338
|
+
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), "utf8");
|
|
339
|
+
fs.renameSync(tmp, this.deletedIdsPath);
|
|
340
|
+
} catch (err) {
|
|
341
|
+
getLogger().debug(
|
|
342
|
+
`MetricsStore.persistDeletedIds: ${String(err)}`
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
211
346
|
/** Look up an active call by id (returns undefined if not active or unknown). */
|
|
212
347
|
getActive(callId) {
|
|
213
348
|
return this.activeCalls.get(callId);
|
|
214
349
|
}
|
|
350
|
+
/** Return all currently active (not yet ended) calls. */
|
|
215
351
|
getActiveCalls() {
|
|
216
352
|
return Array.from(this.activeCalls.values());
|
|
217
353
|
}
|
|
354
|
+
/**
|
|
355
|
+
* Compute summary statistics across the buffered call history.
|
|
356
|
+
*
|
|
357
|
+
* Soft-deleted calls are excluded so rolling metrics (avg latency,
|
|
358
|
+
* total spend) match exactly what the operator sees in the call list.
|
|
359
|
+
*/
|
|
218
360
|
getAggregates() {
|
|
219
|
-
const
|
|
361
|
+
const visible = this.calls.filter(
|
|
362
|
+
(c) => !this.deletedCallIds.has(c.call_id)
|
|
363
|
+
);
|
|
364
|
+
const totalCalls = visible.length;
|
|
220
365
|
if (totalCalls === 0) {
|
|
221
366
|
return {
|
|
222
367
|
total_calls: 0,
|
|
@@ -235,7 +380,7 @@ var MetricsStore = class extends import_events.EventEmitter {
|
|
|
235
380
|
let costTts = 0;
|
|
236
381
|
let costLlm = 0;
|
|
237
382
|
let costTel = 0;
|
|
238
|
-
for (const call of
|
|
383
|
+
for (const call of visible) {
|
|
239
384
|
const m = call.metrics;
|
|
240
385
|
if (!m) continue;
|
|
241
386
|
const cost = m.cost || {};
|
|
@@ -246,7 +391,7 @@ var MetricsStore = class extends import_events.EventEmitter {
|
|
|
246
391
|
costTel += cost.telephony || 0;
|
|
247
392
|
totalDuration += m.duration_seconds || 0;
|
|
248
393
|
const avgLat = m.latency_avg || {};
|
|
249
|
-
const tMs = avgLat.total_ms || 0;
|
|
394
|
+
const tMs = avgLat.agent_response_ms || avgLat.total_ms || 0;
|
|
250
395
|
if (tMs > 0) {
|
|
251
396
|
totalLatency += tMs;
|
|
252
397
|
latencyCount++;
|
|
@@ -266,16 +411,26 @@ var MetricsStore = class extends import_events.EventEmitter {
|
|
|
266
411
|
active_calls: this.activeCalls.size
|
|
267
412
|
};
|
|
268
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* Return calls whose `started_at` falls within `[fromTs, toTs]` (Unix
|
|
416
|
+
* seconds). Soft-deleted calls are filtered out.
|
|
417
|
+
*/
|
|
269
418
|
getCallsInRange(fromTs = 0, toTs = 0) {
|
|
270
419
|
return this.calls.filter((call) => {
|
|
420
|
+
if (this.deletedCallIds.has(call.call_id)) return false;
|
|
271
421
|
const started = call.started_at || 0;
|
|
272
422
|
if (fromTs && started < fromTs) return false;
|
|
273
423
|
if (toTs && started > toTs) return false;
|
|
274
424
|
return true;
|
|
275
425
|
});
|
|
276
426
|
}
|
|
427
|
+
/** Number of completed (non-deleted) calls currently in the ring buffer. */
|
|
277
428
|
get callCount() {
|
|
278
|
-
|
|
429
|
+
let n = 0;
|
|
430
|
+
for (const c of this.calls) {
|
|
431
|
+
if (!this.deletedCallIds.has(c.call_id)) n++;
|
|
432
|
+
}
|
|
433
|
+
return n;
|
|
279
434
|
}
|
|
280
435
|
/**
|
|
281
436
|
* Rebuild the in-memory call list from `metadata.json` files written by
|
|
@@ -289,6 +444,24 @@ var MetricsStore = class extends import_events.EventEmitter {
|
|
|
289
444
|
*/
|
|
290
445
|
hydrate(logRoot) {
|
|
291
446
|
if (!logRoot) return 0;
|
|
447
|
+
const deletedIdsPath = path.join(logRoot, ".deleted_call_ids.json");
|
|
448
|
+
this.deletedIdsPath = deletedIdsPath;
|
|
449
|
+
if (fs.existsSync(deletedIdsPath)) {
|
|
450
|
+
try {
|
|
451
|
+
const raw = fs.readFileSync(deletedIdsPath, "utf8");
|
|
452
|
+
const payload = JSON.parse(raw);
|
|
453
|
+
const arr = Array.isArray(payload.deleted_call_ids) ? payload.deleted_call_ids : [];
|
|
454
|
+
for (const cid of arr) {
|
|
455
|
+
if (typeof cid === "string" && cid.length > 0) {
|
|
456
|
+
this.deletedCallIds.add(cid);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
} catch (err) {
|
|
460
|
+
getLogger().debug(
|
|
461
|
+
`MetricsStore.hydrate: skipping ${deletedIdsPath}: ${String(err)}`
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
292
465
|
const callsRoot = path.join(logRoot, "calls");
|
|
293
466
|
if (!fs.existsSync(callsRoot)) return 0;
|
|
294
467
|
const collected = [];
|
|
@@ -323,6 +496,12 @@ var MetricsStore = class extends import_events.EventEmitter {
|
|
|
323
496
|
);
|
|
324
497
|
continue;
|
|
325
498
|
}
|
|
499
|
+
if (!record.transcript || record.transcript.length === 0) {
|
|
500
|
+
const fromJsonl = loadTranscriptJsonl(
|
|
501
|
+
path.join(childPath, "transcript.jsonl")
|
|
502
|
+
);
|
|
503
|
+
if (fromJsonl.length > 0) record.transcript = fromJsonl;
|
|
504
|
+
}
|
|
326
505
|
collected.push(record);
|
|
327
506
|
seen.add(callId);
|
|
328
507
|
} catch (err) {
|
|
@@ -344,12 +523,45 @@ var MetricsStore = class extends import_events.EventEmitter {
|
|
|
344
523
|
return collected.length;
|
|
345
524
|
}
|
|
346
525
|
};
|
|
526
|
+
function metricsFromTopLevel(meta) {
|
|
527
|
+
const cost = meta.cost && typeof meta.cost === "object" ? meta.cost : null;
|
|
528
|
+
const latency = meta.latency && typeof meta.latency === "object" ? meta.latency : null;
|
|
529
|
+
const durationMs = meta.duration_ms;
|
|
530
|
+
const telephony = meta.telephony_provider;
|
|
531
|
+
if (cost === null && latency === null && durationMs == null && !telephony) {
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
const out = {};
|
|
535
|
+
if (cost !== null) out.cost = cost;
|
|
536
|
+
if (latency !== null) {
|
|
537
|
+
const fullAvg = latency.avg && typeof latency.avg === "object" ? latency.avg : null;
|
|
538
|
+
const fullP50 = latency.p50 && typeof latency.p50 === "object" ? latency.p50 : null;
|
|
539
|
+
const fullP95 = latency.p95 && typeof latency.p95 === "object" ? latency.p95 : null;
|
|
540
|
+
const fullP99 = latency.p99 && typeof latency.p99 === "object" ? latency.p99 : null;
|
|
541
|
+
if (fullAvg) out.latency_avg = fullAvg;
|
|
542
|
+
if (fullP50) out.latency_p50 = fullP50;
|
|
543
|
+
if (fullP95) out.latency_p95 = fullP95;
|
|
544
|
+
if (fullP99) out.latency_p99 = fullP99;
|
|
545
|
+
if (!fullAvg && !fullP50 && !fullP95) {
|
|
546
|
+
const totalMs = typeof latency.p95_ms === "number" && latency.p95_ms || typeof latency.p50_ms === "number" && latency.p50_ms || 0;
|
|
547
|
+
out.latency_avg = { total_ms: totalMs };
|
|
548
|
+
}
|
|
549
|
+
out.latency = latency;
|
|
550
|
+
}
|
|
551
|
+
if (typeof durationMs === "number" && durationMs > 0) {
|
|
552
|
+
out.duration_seconds = durationMs / 1e3;
|
|
553
|
+
}
|
|
554
|
+
if (typeof telephony === "string" && telephony) {
|
|
555
|
+
out.telephony_provider = telephony;
|
|
556
|
+
}
|
|
557
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
558
|
+
}
|
|
347
559
|
function metadataToCallRecord(callId, meta) {
|
|
348
560
|
const startedAt = parseTimestamp(meta.started_at);
|
|
349
561
|
if (startedAt === null) return null;
|
|
350
562
|
const endedAt = parseTimestamp(meta.ended_at);
|
|
351
563
|
const status = meta.status || "completed";
|
|
352
|
-
const metrics = meta.metrics && typeof meta.metrics === "object" ? meta.metrics :
|
|
564
|
+
const metrics = meta.metrics && typeof meta.metrics === "object" ? meta.metrics : metricsFromTopLevel(meta);
|
|
353
565
|
const transcript = Array.isArray(meta.transcript) ? meta.transcript : [];
|
|
354
566
|
return {
|
|
355
567
|
call_id: callId,
|
|
@@ -363,6 +575,36 @@ function metadataToCallRecord(callId, meta) {
|
|
|
363
575
|
transcript
|
|
364
576
|
};
|
|
365
577
|
}
|
|
578
|
+
function loadTranscriptJsonl(filePath) {
|
|
579
|
+
try {
|
|
580
|
+
if (!fs.existsSync(filePath)) return [];
|
|
581
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
582
|
+
const lines = raw.split("\n").filter((l) => l.trim().length > 0);
|
|
583
|
+
const out = [];
|
|
584
|
+
for (const line of lines) {
|
|
585
|
+
let row;
|
|
586
|
+
try {
|
|
587
|
+
row = JSON.parse(line);
|
|
588
|
+
} catch {
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
const tsIso = typeof row.ts === "string" ? Date.parse(row.ts) : NaN;
|
|
592
|
+
const tsNumeric = typeof row.timestamp === "number" ? row.timestamp * 1e3 : NaN;
|
|
593
|
+
const timestamp = Number.isFinite(tsIso) ? tsIso : Number.isFinite(tsNumeric) ? tsNumeric : 0;
|
|
594
|
+
const userText = typeof row.user_text === "string" ? row.user_text : "";
|
|
595
|
+
const agentText = typeof row.agent_text === "string" ? row.agent_text : "";
|
|
596
|
+
if (userText.length > 0) {
|
|
597
|
+
out.push({ role: "user", text: userText, timestamp });
|
|
598
|
+
}
|
|
599
|
+
if (agentText.length > 0 && agentText !== "[interrupted]") {
|
|
600
|
+
out.push({ role: "assistant", text: agentText, timestamp });
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return out;
|
|
604
|
+
} catch {
|
|
605
|
+
return [];
|
|
606
|
+
}
|
|
607
|
+
}
|
|
366
608
|
function parseTimestamp(raw) {
|
|
367
609
|
if (typeof raw === "number") {
|
|
368
610
|
return Number.isFinite(raw) ? raw : null;
|
|
@@ -464,630 +706,32 @@ function csvEscape(value) {
|
|
|
464
706
|
}
|
|
465
707
|
|
|
466
708
|
// src/dashboard/ui.ts
|
|
467
|
-
var
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
<meta charset="utf-8">
|
|
471
|
-
<
|
|
472
|
-
<
|
|
473
|
-
<
|
|
474
|
-
<
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
:
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
--green: #22c55e;
|
|
489
|
-
--red: #ef4444;
|
|
490
|
-
--blue: #3b82f6;
|
|
491
|
-
--purple: #a78bfa;
|
|
492
|
-
--orange: #fb923c;
|
|
493
|
-
--yellow: #eab308;
|
|
494
|
-
--radius: 12px;
|
|
495
|
-
--font: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif;
|
|
496
|
-
--mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
497
|
-
--header-bg: #fff;
|
|
498
|
-
--assistant-bubble: #f0eeff;
|
|
499
|
-
}
|
|
500
|
-
@media (prefers-color-scheme: dark) {
|
|
501
|
-
:root {
|
|
502
|
-
--bg: #151518;
|
|
503
|
-
--fg: #e4e4e7;
|
|
504
|
-
--card: #1c1c21;
|
|
505
|
-
--primary: #e4e4e7;
|
|
506
|
-
--primary-fg: #18181b;
|
|
507
|
-
--secondary: #232329;
|
|
508
|
-
--muted: #8b8b95;
|
|
509
|
-
--border: #2c2c33;
|
|
510
|
-
--border-d: #3a3a44;
|
|
511
|
-
--green: #34d399;
|
|
512
|
-
--red: #f87171;
|
|
513
|
-
--blue: #60a5fa;
|
|
514
|
-
--purple: #c4b5fd;
|
|
515
|
-
--orange: #fdba74;
|
|
516
|
-
--yellow: #fbbf24;
|
|
517
|
-
--header-bg: #1a1a1f;
|
|
518
|
-
--assistant-bubble: #252230;
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
* { margin:0; padding:0; box-sizing:border-box; }
|
|
522
|
-
html { -webkit-font-smoothing: antialiased; }
|
|
523
|
-
body {
|
|
524
|
-
font-family: var(--font);
|
|
525
|
-
font-size: 15px;
|
|
526
|
-
line-height: 1.6;
|
|
527
|
-
color: var(--fg);
|
|
528
|
-
background: var(--bg);
|
|
529
|
-
min-height: 100vh;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
/* Header */
|
|
533
|
-
header {
|
|
534
|
-
position: sticky; top: 0; z-index: 100;
|
|
535
|
-
background: var(--header-bg);
|
|
536
|
-
border-bottom: 1px solid var(--border);
|
|
537
|
-
padding: 0 24px;
|
|
538
|
-
height: 56px;
|
|
539
|
-
display: flex; align-items: center; gap: 14px;
|
|
540
|
-
}
|
|
541
|
-
.logo {
|
|
542
|
-
display: flex; align-items: center; gap: 10px;
|
|
543
|
-
font-weight: 700; font-size: 18px; letter-spacing: -0.02em;
|
|
544
|
-
text-decoration: none; color: var(--fg);
|
|
545
|
-
}
|
|
546
|
-
.logo svg { width: 22px; height: 22px; }
|
|
547
|
-
.header-sep {
|
|
548
|
-
width: 1px; height: 20px; background: var(--border-d); margin: 0 2px;
|
|
549
|
-
}
|
|
550
|
-
.header-title {
|
|
551
|
-
font-size: 14px; font-weight: 500; color: var(--muted);
|
|
552
|
-
}
|
|
553
|
-
.badge-beta {
|
|
554
|
-
font-size: 10px; font-weight: 600; letter-spacing: 0.5px;
|
|
555
|
-
color: #e67e22; background: rgba(230,126,34,0.1);
|
|
556
|
-
border: 1px solid rgba(230,126,34,0.25);
|
|
557
|
-
padding: 2px 8px; border-radius: 100px; text-transform: uppercase;
|
|
558
|
-
}
|
|
559
|
-
.status {
|
|
560
|
-
margin-left: auto; font-size: 13px; color: var(--muted);
|
|
561
|
-
display: flex; align-items: center; gap: 6px;
|
|
562
|
-
}
|
|
563
|
-
.dot {
|
|
564
|
-
width: 7px; height: 7px; border-radius: 50%;
|
|
565
|
-
background: var(--green); display: inline-block;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
/* Layout */
|
|
569
|
-
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
|
|
570
|
-
|
|
571
|
-
/* Stat cards */
|
|
572
|
-
.cards {
|
|
573
|
-
display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
574
|
-
gap: 14px; margin-bottom: 28px;
|
|
575
|
-
}
|
|
576
|
-
.card {
|
|
577
|
-
background: var(--card);
|
|
578
|
-
border: 1px solid var(--border);
|
|
579
|
-
border-radius: var(--radius);
|
|
580
|
-
padding: 18px 20px;
|
|
581
|
-
}
|
|
582
|
-
.card .label {
|
|
583
|
-
font-size: 12px; color: var(--muted);
|
|
584
|
-
text-transform: uppercase; letter-spacing: 0.5px; font-weight: 500;
|
|
585
|
-
}
|
|
586
|
-
.card .value {
|
|
587
|
-
font-size: 28px; font-weight: 700; margin-top: 4px;
|
|
588
|
-
font-family: var(--mono); letter-spacing: -0.02em;
|
|
589
|
-
}
|
|
590
|
-
.card .sub { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
|
591
|
-
|
|
592
|
-
/* Tabs */
|
|
593
|
-
.nav-tabs {
|
|
594
|
-
display: flex; gap: 0; margin-bottom: 16px;
|
|
595
|
-
border-bottom: 1px solid var(--border);
|
|
596
|
-
}
|
|
597
|
-
.nav-tab {
|
|
598
|
-
padding: 10px 20px; font-size: 13px; font-weight: 500;
|
|
599
|
-
color: var(--muted); cursor: pointer;
|
|
600
|
-
border: none; background: none;
|
|
601
|
-
border-bottom: 2px solid transparent;
|
|
602
|
-
margin-bottom: -1px; font-family: var(--font);
|
|
603
|
-
transition: color .15s;
|
|
604
|
-
}
|
|
605
|
-
.nav-tab:hover { color: var(--fg); }
|
|
606
|
-
.nav-tab.active { color: var(--fg); border-bottom-color: var(--primary); }
|
|
607
|
-
|
|
608
|
-
.tab-content { display: none; }
|
|
609
|
-
.tab-content.active { display: block; }
|
|
610
|
-
|
|
611
|
-
/* Tables */
|
|
612
|
-
table {
|
|
613
|
-
width: 100%; border-collapse: collapse;
|
|
614
|
-
background: var(--card);
|
|
615
|
-
border: 1px solid var(--border);
|
|
616
|
-
border-radius: var(--radius);
|
|
617
|
-
overflow: hidden;
|
|
618
|
-
}
|
|
619
|
-
th {
|
|
620
|
-
text-align: left; font-size: 11px; text-transform: uppercase;
|
|
621
|
-
color: var(--muted); padding: 12px 16px;
|
|
622
|
-
border-bottom: 1px solid var(--border);
|
|
623
|
-
letter-spacing: 0.5px; font-weight: 600;
|
|
624
|
-
background: var(--secondary);
|
|
625
|
-
}
|
|
626
|
-
td {
|
|
627
|
-
padding: 12px 16px; border-bottom: 1px solid var(--border);
|
|
628
|
-
font-size: 13px;
|
|
629
|
-
}
|
|
630
|
-
tr:last-child td { border-bottom: none; }
|
|
631
|
-
tr.clickable { cursor: pointer; transition: background .1s; }
|
|
632
|
-
tr.clickable:hover { background: var(--secondary); }
|
|
633
|
-
|
|
634
|
-
code {
|
|
635
|
-
font-family: var(--mono); font-size: 12px;
|
|
636
|
-
background: var(--secondary); padding: 2px 6px;
|
|
637
|
-
border-radius: 4px;
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
/* Badges */
|
|
641
|
-
.badge {
|
|
642
|
-
display: inline-block; padding: 3px 10px; border-radius: 100px;
|
|
643
|
-
font-size: 11px; font-weight: 600;
|
|
644
|
-
}
|
|
645
|
-
.badge-active { background: rgba(34,197,94,0.1); color: #16a34a; }
|
|
646
|
-
.badge-ended { background: var(--secondary); color: var(--muted); }
|
|
647
|
-
.badge-pipeline { background: rgba(167,139,250,0.1); color: #7c3aed; }
|
|
648
|
-
.badge-realtime { background: rgba(59,130,246,0.1); color: #2563eb; }
|
|
649
|
-
|
|
650
|
-
.cost { color: #16a34a; font-family: var(--mono); font-size: 13px; }
|
|
651
|
-
.latency { color: #ca8a04; font-family: var(--mono); font-size: 13px; }
|
|
652
|
-
@media (prefers-color-scheme: dark) {
|
|
653
|
-
.cost { color: var(--green); }
|
|
654
|
-
.latency { color: var(--yellow); }
|
|
655
|
-
code { background: var(--secondary); color: var(--fg); }
|
|
656
|
-
}
|
|
657
|
-
.empty {
|
|
658
|
-
text-align: center; padding: 48px; color: var(--muted);
|
|
659
|
-
font-size: 14px;
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
/* Modal */
|
|
663
|
-
.modal-overlay {
|
|
664
|
-
display: none; position: fixed; inset: 0;
|
|
665
|
-
background: rgba(0,0,0,0.4); backdrop-filter: blur(6px);
|
|
666
|
-
z-index: 200;
|
|
667
|
-
justify-content: center; align-items: flex-start;
|
|
668
|
-
padding: 48px 20px; overflow-y: auto;
|
|
669
|
-
}
|
|
670
|
-
.modal-overlay.open { display: flex; }
|
|
671
|
-
.modal {
|
|
672
|
-
background: var(--card);
|
|
673
|
-
border: 1px solid var(--border);
|
|
674
|
-
border-radius: 16px;
|
|
675
|
-
max-width: 820px; width: 100%;
|
|
676
|
-
padding: 0;
|
|
677
|
-
box-shadow: 0 24px 64px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.03);
|
|
678
|
-
overflow: hidden;
|
|
679
|
-
}
|
|
680
|
-
.modal-header {
|
|
681
|
-
display: flex; justify-content: space-between; align-items: center;
|
|
682
|
-
padding: 20px 28px;
|
|
683
|
-
border-bottom: 1px solid var(--border);
|
|
684
|
-
background: var(--bg);
|
|
685
|
-
}
|
|
686
|
-
.modal-header h2 { font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 10px; }
|
|
687
|
-
.modal-close {
|
|
688
|
-
background: none; border: 1px solid var(--border);
|
|
689
|
-
color: var(--muted); width: 30px; height: 30px;
|
|
690
|
-
border-radius: 8px; font-size: 16px; cursor: pointer;
|
|
691
|
-
display: flex; align-items: center; justify-content: center;
|
|
692
|
-
transition: all .15s;
|
|
693
|
-
}
|
|
694
|
-
.modal-close:hover { background: var(--secondary); color: var(--fg); }
|
|
695
|
-
.modal-body { padding: 24px 28px; }
|
|
696
|
-
|
|
697
|
-
.detail-grid {
|
|
698
|
-
display: grid; grid-template-columns: 1fr 1fr;
|
|
699
|
-
gap: 14px; margin-bottom: 20px;
|
|
700
|
-
}
|
|
701
|
-
.detail-card {
|
|
702
|
-
background: var(--bg);
|
|
703
|
-
border: 1px solid var(--border);
|
|
704
|
-
border-radius: var(--radius); padding: 16px 18px;
|
|
705
|
-
}
|
|
706
|
-
.detail-card h3 {
|
|
707
|
-
font-size: 11px; color: var(--muted);
|
|
708
|
-
text-transform: uppercase; letter-spacing: 0.5px;
|
|
709
|
-
margin-bottom: 10px; font-weight: 600;
|
|
710
|
-
}
|
|
711
|
-
.detail-row {
|
|
712
|
-
display: flex; justify-content: space-between; align-items: baseline;
|
|
713
|
-
font-size: 13px; padding: 5px 0;
|
|
714
|
-
}
|
|
715
|
-
.detail-row .k { color: var(--muted); font-weight: 500; }
|
|
716
|
-
.detail-row span:last-child { font-weight: 500; text-align: right; }
|
|
717
|
-
.detail-row .mono { font-family: var(--mono); font-size: 12px; }
|
|
718
|
-
.detail-sep {
|
|
719
|
-
border-top: 1px solid var(--border); padding-top: 8px; margin-top: 6px;
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
.transcript-box {
|
|
723
|
-
border: 1px solid var(--border);
|
|
724
|
-
border-radius: var(--radius);
|
|
725
|
-
padding: 16px; max-height: 340px; overflow-y: auto;
|
|
726
|
-
background: var(--bg);
|
|
727
|
-
}
|
|
728
|
-
.transcript-box .msg {
|
|
729
|
-
padding: 8px 12px; border-radius: 10px; font-size: 13px;
|
|
730
|
-
max-width: 85%; margin-bottom: 6px; line-height: 1.5;
|
|
731
|
-
}
|
|
732
|
-
.transcript-box .msg.user {
|
|
733
|
-
background: var(--secondary); margin-left: auto;
|
|
734
|
-
border-bottom-right-radius: 4px;
|
|
735
|
-
}
|
|
736
|
-
.transcript-box .msg.assistant {
|
|
737
|
-
background: var(--assistant-bubble); margin-right: auto;
|
|
738
|
-
border-bottom-left-radius: 4px;
|
|
739
|
-
}
|
|
740
|
-
.transcript-box .role {
|
|
741
|
-
font-weight: 600; font-size: 11px; text-transform: uppercase;
|
|
742
|
-
letter-spacing: 0.3px; display: block; margin-bottom: 2px;
|
|
743
|
-
}
|
|
744
|
-
.transcript-box .msg.user .role { color: var(--blue); }
|
|
745
|
-
.transcript-box .msg.assistant .role { color: #7c3aed; }
|
|
746
|
-
|
|
747
|
-
/* Turn bars */
|
|
748
|
-
.turns-table { margin-top: 16px; }
|
|
749
|
-
.turns-table table { border: 1px solid var(--border); }
|
|
750
|
-
.bar-container { display: flex; height: 14px; border-radius: 4px; overflow: hidden; min-width: 120px; }
|
|
751
|
-
.bar-stt { background: var(--blue); }
|
|
752
|
-
.bar-llm { background: var(--purple); }
|
|
753
|
-
.bar-tts { background: var(--orange); }
|
|
754
|
-
</style>
|
|
755
|
-
</head>
|
|
756
|
-
<body>
|
|
757
|
-
<header>
|
|
758
|
-
<a href="/" class="logo">
|
|
759
|
-
<svg viewBox="0 0 1188 1773" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
760
|
-
<path d="M25 561L245 694M25 561V818M245 694V951M25 961V1218M25 1357V1614M245 1489V1747M245 1093V1351M942 823V1080M1161 955V1213M1162 555V812M942 422V679M669 585V843L787 913M942 25V282M1162 158V415M25 818L245 951M244 1094L464 962M25 961L143 890M244 1352L464 1219M942 823L1162 956M942 679L1162 812M721 811L942 679M669 842L724 809M669 586L724 553M1041 883L1162 812M245 1747L1161 1213M244 1490L942 1080M25 1357L142 1289M518 1071L942 823M721 555L942 422M942 422L1162 556M942 282L1162 415M942 25L1162 158M942 1080L1161 1213M25 1218L245 1351M25 961L245 1094M464 962L519 929M464 1219L519 1186V928L403 859M25 1357L245 1490M25 1614L245 1747M25 561L942 25M244 694L941 282M1043 484L1162 415M245 951L668 704" stroke="currentColor" stroke-width="50" stroke-linecap="round"/>
|
|
761
|
-
</svg>
|
|
762
|
-
Patter
|
|
763
|
-
</a>
|
|
764
|
-
<div class="header-sep"></div>
|
|
765
|
-
<span class="header-title">Dashboard</span>
|
|
766
|
-
<span class="badge-beta">Beta</span>
|
|
767
|
-
<div class="status"><span class="dot"></span> <span id="status-text">Listening</span></div>
|
|
768
|
-
</header>
|
|
769
|
-
|
|
770
|
-
<div class="container">
|
|
771
|
-
<div class="cards">
|
|
772
|
-
<div class="card">
|
|
773
|
-
<div class="label">Total Calls</div>
|
|
774
|
-
<div class="value" id="stat-total">0</div>
|
|
775
|
-
<div class="sub"><span id="stat-active">0</span> active</div>
|
|
776
|
-
</div>
|
|
777
|
-
<div class="card">
|
|
778
|
-
<div class="label">Total Cost</div>
|
|
779
|
-
<div class="value cost" id="stat-cost">$0.00</div>
|
|
780
|
-
<div class="sub" id="stat-cost-breakdown">-</div>
|
|
781
|
-
</div>
|
|
782
|
-
<div class="card">
|
|
783
|
-
<div class="label">Avg Duration</div>
|
|
784
|
-
<div class="value" id="stat-duration">0s</div>
|
|
785
|
-
</div>
|
|
786
|
-
<div class="card">
|
|
787
|
-
<div class="label">Avg Latency</div>
|
|
788
|
-
<div class="value latency" id="stat-latency">0ms</div>
|
|
789
|
-
<div class="sub">end-to-end response</div>
|
|
790
|
-
</div>
|
|
791
|
-
</div>
|
|
792
|
-
|
|
793
|
-
<div class="nav-tabs">
|
|
794
|
-
<button class="nav-tab active" data-tab="calls">Calls</button>
|
|
795
|
-
<button class="nav-tab" data-tab="active">Active</button>
|
|
796
|
-
</div>
|
|
797
|
-
|
|
798
|
-
<div class="tab-content active" id="tab-calls">
|
|
799
|
-
<div class="section">
|
|
800
|
-
<table id="calls-table">
|
|
801
|
-
<thead>
|
|
802
|
-
<tr>
|
|
803
|
-
<th>Call ID</th><th>Direction</th><th>From / To</th>
|
|
804
|
-
<th>Duration</th><th>Mode</th><th>Cost</th><th>Avg Latency</th><th>Turns</th>
|
|
805
|
-
</tr>
|
|
806
|
-
</thead>
|
|
807
|
-
<tbody id="calls-body">
|
|
808
|
-
<tr><td colspan="8" class="empty">No calls yet. Waiting for incoming calls...</td></tr>
|
|
809
|
-
</tbody>
|
|
810
|
-
</table>
|
|
811
|
-
</div>
|
|
812
|
-
</div>
|
|
813
|
-
|
|
814
|
-
<div class="tab-content" id="tab-active">
|
|
815
|
-
<div class="section">
|
|
816
|
-
<table>
|
|
817
|
-
<thead>
|
|
818
|
-
<tr><th>Call ID</th><th>Caller</th><th>Callee</th><th>Direction</th><th>Duration</th><th>Turns</th></tr>
|
|
819
|
-
</thead>
|
|
820
|
-
<tbody id="active-body">
|
|
821
|
-
<tr><td colspan="6" class="empty">No active calls</td></tr>
|
|
822
|
-
</tbody>
|
|
823
|
-
</table>
|
|
824
|
-
</div>
|
|
825
|
-
</div>
|
|
826
|
-
</div>
|
|
827
|
-
|
|
828
|
-
<div class="modal-overlay" id="modal">
|
|
829
|
-
<div class="modal">
|
|
830
|
-
<div class="modal-header">
|
|
831
|
-
<h2 id="modal-title">Call Detail</h2>
|
|
832
|
-
<button class="modal-close" onclick="closeModal()">×</button>
|
|
833
|
-
</div>
|
|
834
|
-
<div class="modal-body" id="modal-body"></div>
|
|
835
|
-
</div>
|
|
836
|
-
</div>
|
|
837
|
-
|
|
838
|
-
<script>
|
|
839
|
-
var _$ = function(s) { return document.querySelector(s); };
|
|
840
|
-
var _$$ = function(s) { return document.querySelectorAll(s); };
|
|
841
|
-
|
|
842
|
-
_$$('.nav-tab').forEach(function(tab) {
|
|
843
|
-
tab.addEventListener('click', function() {
|
|
844
|
-
_$$('.nav-tab').forEach(function(t) { t.classList.remove('active'); });
|
|
845
|
-
_$$('.tab-content').forEach(function(t) { t.classList.remove('active'); });
|
|
846
|
-
tab.classList.add('active');
|
|
847
|
-
document.querySelector('#tab-'+tab.dataset.tab).classList.add('active');
|
|
848
|
-
});
|
|
849
|
-
});
|
|
850
|
-
|
|
851
|
-
function esc(s) {
|
|
852
|
-
if (!s) return '';
|
|
853
|
-
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
854
|
-
}
|
|
855
|
-
function fmtCost(v) { return v >= 0.01 ? '$'+v.toFixed(4) : v > 0 ? '$'+v.toFixed(6) : '$0.00'; }
|
|
856
|
-
function fmtMs(v) { return v != null && v >= 0 ? Math.round(v)+'ms' : '-'; }
|
|
857
|
-
function fmtDur(s) {
|
|
858
|
-
if (s == null || s < 0) return '-';
|
|
859
|
-
if (s < 60) return Math.round(s)+'s';
|
|
860
|
-
return Math.floor(s/60)+'m '+Math.round(s%60)+'s';
|
|
861
|
-
}
|
|
862
|
-
function shortId(id) { return id ? esc(id.length > 16 ? id.slice(0,8)+'...'+id.slice(-4) : id) : '-'; }
|
|
863
|
-
|
|
864
|
-
function fetchJSON(url) {
|
|
865
|
-
return fetch(url).then(function(r) { return r.json(); });
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
function refreshAggregates() {
|
|
869
|
-
return fetchJSON('/api/dashboard/aggregates').then(function(d) {
|
|
870
|
-
_$('#stat-total').textContent = d.total_calls;
|
|
871
|
-
_$('#stat-active').textContent = d.active_calls;
|
|
872
|
-
_$('#stat-cost').textContent = fmtCost(d.total_cost);
|
|
873
|
-
var cb = d.cost_breakdown;
|
|
874
|
-
_$('#stat-cost-breakdown').textContent =
|
|
875
|
-
'STT '+fmtCost(cb.stt)+' | LLM '+fmtCost(cb.llm)+' | TTS '+fmtCost(cb.tts)+' | Tel '+fmtCost(cb.telephony);
|
|
876
|
-
_$('#stat-duration').textContent = fmtDur(d.avg_duration);
|
|
877
|
-
_$('#stat-latency').textContent = fmtMs(d.avg_latency_ms);
|
|
878
|
-
});
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
function refreshCalls() {
|
|
882
|
-
return fetchJSON('/api/dashboard/calls?limit=50').then(function(calls) {
|
|
883
|
-
var body = _$('#calls-body');
|
|
884
|
-
if (!calls.length) {
|
|
885
|
-
body.innerHTML = '<tr><td colspan="8" class="empty">No calls yet. Waiting for incoming calls...</td></tr>';
|
|
886
|
-
return;
|
|
887
|
-
}
|
|
888
|
-
body.innerHTML = calls.map(function(c) {
|
|
889
|
-
var m = c.metrics || {};
|
|
890
|
-
var cost = m.cost || {};
|
|
891
|
-
var lat = m.latency_avg || {};
|
|
892
|
-
var mode = m.provider_mode || '-';
|
|
893
|
-
var turns = m.turns ? m.turns.length : 0;
|
|
894
|
-
var modeClass = mode === 'pipeline' ? 'badge-pipeline' : 'badge-realtime';
|
|
895
|
-
return '<tr class="clickable" onclick="showCall(\\''+esc(c.call_id)+'\\')">'+
|
|
896
|
-
'<td><code>'+shortId(c.call_id)+'</code></td>'+
|
|
897
|
-
'<td>'+(esc(c.direction) || '-')+'</td>'+
|
|
898
|
-
'<td>'+(esc(c.caller) || '-')+' → '+(esc(c.callee) || '-')+'</td>'+
|
|
899
|
-
'<td>'+fmtDur(m.duration_seconds)+'</td>'+
|
|
900
|
-
'<td><span class="badge '+modeClass+'">'+esc(mode)+'</span></td>'+
|
|
901
|
-
'<td class="cost">'+fmtCost(cost.total || 0)+'</td>'+
|
|
902
|
-
'<td class="latency">'+fmtMs(lat.total_ms || 0)+'</td>'+
|
|
903
|
-
'<td>'+turns+'</td></tr>';
|
|
904
|
-
}).join('');
|
|
905
|
-
});
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
function refreshActive() {
|
|
909
|
-
return fetchJSON('/api/dashboard/active').then(function(active) {
|
|
910
|
-
var body = _$('#active-body');
|
|
911
|
-
if (!active.length) {
|
|
912
|
-
body.innerHTML = '<tr><td colspan="6" class="empty">No active calls</td></tr>';
|
|
913
|
-
return;
|
|
914
|
-
}
|
|
915
|
-
var now = Date.now() / 1000;
|
|
916
|
-
body.innerHTML = active.map(function(c) {
|
|
917
|
-
var dur = c.started_at ? Math.round(now - c.started_at) : 0;
|
|
918
|
-
var turns = c.turns ? c.turns.length : 0;
|
|
919
|
-
return '<tr>'+
|
|
920
|
-
'<td><code>'+shortId(c.call_id)+'</code></td>'+
|
|
921
|
-
'<td>'+(esc(c.caller) || '-')+'</td>'+
|
|
922
|
-
'<td>'+(esc(c.callee) || '-')+'</td>'+
|
|
923
|
-
'<td>'+(esc(c.direction) || '-')+'</td>'+
|
|
924
|
-
'<td data-started="'+(c.started_at || 0)+'">'+fmtDur(dur)+'</td>'+
|
|
925
|
-
'<td>'+turns+'</td></tr>';
|
|
926
|
-
}).join('');
|
|
927
|
-
});
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
function showCall(callId) {
|
|
931
|
-
fetchJSON('/api/dashboard/calls/'+encodeURIComponent(callId)).then(function(c) {
|
|
932
|
-
if (c.error) return;
|
|
933
|
-
var m = c.metrics || {};
|
|
934
|
-
var cost = m.cost || {};
|
|
935
|
-
var latAvg = m.latency_avg || {};
|
|
936
|
-
var latP95 = m.latency_p95 || {};
|
|
937
|
-
var turns = m.turns || [];
|
|
938
|
-
|
|
939
|
-
var modeLabel = (m.provider_mode || '').replace(/_/g, ' ');
|
|
940
|
-
var modeBadgeClass = (m.provider_mode || '').indexOf('pipeline') !== -1 ? 'badge-pipeline' : 'badge-realtime';
|
|
941
|
-
_$('#modal-title').innerHTML = 'Call <code>'+shortId(c.call_id)+'</code> <span class="badge '+modeBadgeClass+'" style="font-size:10px">'+esc(modeLabel)+'</span>';
|
|
942
|
-
|
|
943
|
-
var isRealtime = (m.provider_mode || '').indexOf('realtime') !== -1;
|
|
944
|
-
|
|
945
|
-
var html = '<div class="detail-grid">'+
|
|
946
|
-
'<div class="detail-card">'+
|
|
947
|
-
'<h3>Overview</h3>'+
|
|
948
|
-
'<div class="detail-row"><span class="k">Direction</span><span>'+(esc(c.direction) || '-')+'</span></div>'+
|
|
949
|
-
'<div class="detail-row"><span class="k">From</span><span class="mono">'+(esc(c.caller) || '-')+'</span></div>'+
|
|
950
|
-
'<div class="detail-row"><span class="k">To</span><span class="mono">'+(esc(c.callee) || '-')+'</span></div>'+
|
|
951
|
-
'<div class="detail-row"><span class="k">Duration</span><span style="font-weight:600">'+fmtDur(m.duration_seconds)+'</span></div>'+
|
|
952
|
-
(isRealtime ? '' :
|
|
953
|
-
'<div class="detail-row"><span class="k">STT</span><span>'+(esc(m.stt_provider) || '-')+'</span></div>'+
|
|
954
|
-
'<div class="detail-row"><span class="k">TTS</span><span>'+(esc(m.tts_provider) || '-')+'</span></div>'+
|
|
955
|
-
'<div class="detail-row"><span class="k">LLM</span><span>'+(esc(m.llm_provider) || '-')+'</span></div>'
|
|
956
|
-
)+
|
|
957
|
-
'<div class="detail-row"><span class="k">Telephony</span><span>'+(esc(m.telephony_provider) || '-')+'</span></div>'+
|
|
958
|
-
'</div>'+
|
|
959
|
-
'<div class="detail-card">'+
|
|
960
|
-
'<h3>Cost</h3>'+
|
|
961
|
-
(isRealtime ?
|
|
962
|
-
'<div class="detail-row"><span class="k">OpenAI</span><span class="cost">'+fmtCost(cost.llm || 0)+'</span></div>' :
|
|
963
|
-
'<div class="detail-row"><span class="k">STT</span><span class="cost">'+fmtCost(cost.stt || 0)+'</span></div>'+
|
|
964
|
-
'<div class="detail-row"><span class="k">LLM</span><span class="cost">'+fmtCost(cost.llm || 0)+'</span></div>'+
|
|
965
|
-
'<div class="detail-row"><span class="k">TTS</span><span class="cost">'+fmtCost(cost.tts || 0)+'</span></div>'
|
|
966
|
-
)+
|
|
967
|
-
'<div class="detail-row"><span class="k">Telephony</span><span class="cost">'+fmtCost(cost.telephony || 0)+'</span></div>'+
|
|
968
|
-
'<div class="detail-row detail-sep">'+
|
|
969
|
-
'<span class="k" style="font-weight:600">Total</span><span class="cost" style="font-weight:700;font-size:14px">'+fmtCost(cost.total || 0)+'</span>'+
|
|
970
|
-
'</div>'+
|
|
971
|
-
'<h3 style="margin-top:16px">Latency <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--muted)">(avg / p95)</span></h3>'+
|
|
972
|
-
(isRealtime ? '' :
|
|
973
|
-
'<div class="detail-row"><span class="k">STT</span><span class="latency">'+fmtMs(latAvg.stt_ms)+' / '+fmtMs(latP95.stt_ms)+'</span></div>'+
|
|
974
|
-
'<div class="detail-row"><span class="k">LLM</span><span class="latency">'+fmtMs(latAvg.llm_ms)+' / '+fmtMs(latP95.llm_ms)+'</span></div>'+
|
|
975
|
-
'<div class="detail-row"><span class="k">TTS</span><span class="latency">'+fmtMs(latAvg.tts_ms)+' / '+fmtMs(latP95.tts_ms)+'</span></div>'
|
|
976
|
-
)+
|
|
977
|
-
'<div class="detail-row"><span class="k">'+(isRealtime ? 'End-to-end' : 'Total')+'</span><span class="latency" style="font-weight:700;font-size:14px">'+fmtMs(latAvg.total_ms)+' / '+fmtMs(latP95.total_ms)+'</span></div>'+
|
|
978
|
-
'</div></div>';
|
|
979
|
-
|
|
980
|
-
if (turns.length) {
|
|
981
|
-
var maxMs = Math.max.apply(null, turns.map(function(t) {
|
|
982
|
-
var l = t.latency || {};
|
|
983
|
-
return (l.stt_ms||0) + (l.llm_ms||0) + (l.tts_ms||0) + (l.total_ms||0);
|
|
984
|
-
}).concat([1]));
|
|
985
|
-
html += '<div class="detail-card turns-table"><h3>Turns ('+turns.length+')</h3>'+
|
|
986
|
-
'<table><thead><tr><th>#</th><th>User</th><th>Agent</th><th>Latency</th><th>Breakdown</th></tr></thead><tbody>';
|
|
987
|
-
turns.forEach(function(t, i) {
|
|
988
|
-
var l = t.latency || {};
|
|
989
|
-
var total = l.total_ms || ((l.stt_ms||0) + (l.llm_ms||0) + (l.tts_ms||0));
|
|
990
|
-
var scale = total > 0 ? 120 / maxMs : 0;
|
|
991
|
-
var sttW = (l.stt_ms||0) * scale;
|
|
992
|
-
var llmW = (l.llm_ms||0) * scale;
|
|
993
|
-
var ttsW = (l.tts_ms||0) * scale;
|
|
994
|
-
var totalW = total > 0 && sttW === 0 && llmW === 0 && ttsW === 0 ? total * scale : 0;
|
|
995
|
-
html += '<tr>'+
|
|
996
|
-
'<td>'+(t.turn_index !== undefined ? t.turn_index : i)+'</td>'+
|
|
997
|
-
'<td title="'+esc(t.user_text||'')+'">'+esc((t.user_text||'').slice(0,40))+((t.user_text||'').length>40?'...':'')+'</td>'+
|
|
998
|
-
'<td title="'+esc(t.agent_text||'')+'">'+esc((t.agent_text||'').slice(0,40))+((t.agent_text||'').length>40?'...':'')+'</td>'+
|
|
999
|
-
'<td class="latency">'+fmtMs(total)+'</td>'+
|
|
1000
|
-
'<td><div class="bar-container">'+
|
|
1001
|
-
(sttW > 0 ? '<div class="bar-stt" style="width:'+sttW+'px" title="STT '+fmtMs(l.stt_ms)+'"></div>' : '')+
|
|
1002
|
-
(llmW > 0 ? '<div class="bar-llm" style="width:'+llmW+'px" title="LLM '+fmtMs(l.llm_ms)+'"></div>' : '')+
|
|
1003
|
-
(ttsW > 0 ? '<div class="bar-tts" style="width:'+ttsW+'px" title="TTS '+fmtMs(l.tts_ms)+'"></div>' : '')+
|
|
1004
|
-
(totalW > 0 ? '<div class="bar-llm" style="width:'+totalW+'px" title="Total '+fmtMs(total)+'"></div>' : '')+
|
|
1005
|
-
'</div></td></tr>';
|
|
1006
|
-
});
|
|
1007
|
-
html += '</tbody></table>'+
|
|
1008
|
-
'<div style="margin-top:10px;font-size:11px;color:var(--muted)">'+
|
|
1009
|
-
(isRealtime ?
|
|
1010
|
-
'<span style="color:var(--purple)">■</span> End-to-end' :
|
|
1011
|
-
'<span style="color:var(--blue)">■</span> STT '+
|
|
1012
|
-
'<span style="color:var(--purple)">■</span> LLM '+
|
|
1013
|
-
'<span style="color:var(--orange)">■</span> TTS'
|
|
1014
|
-
)+
|
|
1015
|
-
'</div></div>';
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
var transcript = c.transcript || [];
|
|
1019
|
-
if (transcript.length) {
|
|
1020
|
-
html += '<div class="detail-card" style="margin-top:16px"><h3>Transcript</h3><div class="transcript-box">';
|
|
1021
|
-
transcript.forEach(function(msg) {
|
|
1022
|
-
var role = esc(msg.role || 'unknown');
|
|
1023
|
-
html += '<div class="msg '+role+'"><span class="role">'+role+'</span>'+esc(msg.text || '')+'</div>';
|
|
1024
|
-
});
|
|
1025
|
-
html += '</div></div>';
|
|
709
|
+
var import_node_fs = require("fs");
|
|
710
|
+
var import_node_path = require("path");
|
|
711
|
+
var FALLBACK_HTML = `<!doctype html>
|
|
712
|
+
<html><head><meta charset="utf-8"><title>Patter dashboard</title></head>
|
|
713
|
+
<body style="font-family:ui-sans-serif,system-ui;padding:2rem;color:#1a1a1a">
|
|
714
|
+
<h1>Dashboard asset missing</h1>
|
|
715
|
+
<p>The bundled <code>ui.html</code> was not found alongside this module.
|
|
716
|
+
Run <code>cd dashboard-app && npm run build && npm run sync</code>
|
|
717
|
+
from the repo root to regenerate it.</p>
|
|
718
|
+
</body></html>`;
|
|
719
|
+
function loadDashboardHtml() {
|
|
720
|
+
const here = typeof __dirname !== "undefined" ? __dirname : (0, import_node_path.dirname)(".");
|
|
721
|
+
const candidates = [
|
|
722
|
+
(0, import_node_path.join)(here, "ui.html"),
|
|
723
|
+
(0, import_node_path.join)(here, "dashboard", "ui.html"),
|
|
724
|
+
(0, import_node_path.join)(here, "..", "dashboard", "ui.html")
|
|
725
|
+
];
|
|
726
|
+
for (const path2 of candidates) {
|
|
727
|
+
try {
|
|
728
|
+
return (0, import_node_fs.readFileSync)(path2, "utf8");
|
|
729
|
+
} catch {
|
|
1026
730
|
}
|
|
1027
|
-
|
|
1028
|
-
_$('#modal-body').innerHTML = html;
|
|
1029
|
-
_$('#modal').classList.add('open');
|
|
1030
|
-
});
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
function closeModal() { _$('#modal').classList.remove('open'); }
|
|
1034
|
-
_$('#modal').addEventListener('click', function(e) { if (e.target === _$('#modal')) closeModal(); });
|
|
1035
|
-
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeModal(); });
|
|
1036
|
-
|
|
1037
|
-
function refresh() {
|
|
1038
|
-
return Promise.all([refreshAggregates(), refreshCalls(), refreshActive()]).then(function() {
|
|
1039
|
-
_$('#status-text').textContent = 'Listening';
|
|
1040
|
-
}).catch(function() {
|
|
1041
|
-
_$('#status-text').textContent = 'Connection error';
|
|
1042
|
-
});
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
refresh();
|
|
1046
|
-
|
|
1047
|
-
// Update active call durations every second
|
|
1048
|
-
setInterval(function() {
|
|
1049
|
-
var cells = document.querySelectorAll('#active-body td[data-started]');
|
|
1050
|
-
if (!cells.length) return;
|
|
1051
|
-
var now = Date.now() / 1000;
|
|
1052
|
-
cells.forEach(function(td) {
|
|
1053
|
-
var started = parseFloat(td.getAttribute('data-started'));
|
|
1054
|
-
if (started) td.textContent = fmtDur(Math.round(now - started));
|
|
1055
|
-
});
|
|
1056
|
-
}, 1000);
|
|
1057
|
-
|
|
1058
|
-
if (typeof EventSource !== 'undefined') {
|
|
1059
|
-
var sseUrl = '/api/dashboard/events';
|
|
1060
|
-
var sseBackoff = 1000;
|
|
1061
|
-
var sseFailures = 0;
|
|
1062
|
-
var SSE_MAX_BACKOFF = 30000;
|
|
1063
|
-
var SSE_MAX_FAILURES = 5;
|
|
1064
|
-
|
|
1065
|
-
function connectSSE() {
|
|
1066
|
-
var es = new EventSource(sseUrl);
|
|
1067
|
-
function onEvent() { sseBackoff = 1000; sseFailures = 0; }
|
|
1068
|
-
es.addEventListener('call_start', function() { onEvent(); refresh(); });
|
|
1069
|
-
es.addEventListener('turn_complete', function() { onEvent(); refreshAggregates(); });
|
|
1070
|
-
es.addEventListener('call_end', function() { onEvent(); refresh(); });
|
|
1071
|
-
es.onerror = function() {
|
|
1072
|
-
es.close();
|
|
1073
|
-
sseFailures++;
|
|
1074
|
-
if (sseFailures >= SSE_MAX_FAILURES) {
|
|
1075
|
-
_$('#status-text').textContent = 'Polling';
|
|
1076
|
-
setInterval(refresh, 5000);
|
|
1077
|
-
return;
|
|
1078
|
-
}
|
|
1079
|
-
_$('#status-text').textContent = 'Reconnecting...';
|
|
1080
|
-
setTimeout(connectSSE, sseBackoff);
|
|
1081
|
-
sseBackoff = Math.min(sseBackoff * 2, SSE_MAX_BACKOFF);
|
|
1082
|
-
};
|
|
1083
731
|
}
|
|
1084
|
-
|
|
1085
|
-
} else {
|
|
1086
|
-
setInterval(refresh, 3000);
|
|
732
|
+
return FALLBACK_HTML;
|
|
1087
733
|
}
|
|
1088
|
-
|
|
1089
|
-
</body>
|
|
1090
|
-
</html>`;
|
|
734
|
+
var DASHBOARD_HTML = loadDashboardHtml();
|
|
1091
735
|
|
|
1092
736
|
// src/dashboard/routes.ts
|
|
1093
737
|
function mountDashboard(app, store, token = "") {
|
|
@@ -1101,7 +745,8 @@ function mountDashboard(app, store, token = "") {
|
|
|
1101
745
|
res.json(store.getCalls(limit, offset));
|
|
1102
746
|
});
|
|
1103
747
|
app.get("/api/dashboard/calls/:callId", auth, (req, res) => {
|
|
1104
|
-
const
|
|
748
|
+
const callId = String(req.params.callId);
|
|
749
|
+
const call = store.getCall(callId) ?? store.getActive(callId);
|
|
1105
750
|
if (!call) {
|
|
1106
751
|
res.status(404).json({ error: "Not found" });
|
|
1107
752
|
return;
|
|
@@ -1114,6 +759,24 @@ function mountDashboard(app, store, token = "") {
|
|
|
1114
759
|
app.get("/api/dashboard/aggregates", auth, (_req, res) => {
|
|
1115
760
|
res.json(store.getAggregates());
|
|
1116
761
|
});
|
|
762
|
+
app.delete("/api/dashboard/calls/:callId", auth, (req, res) => {
|
|
763
|
+
const callId = String(req.params.callId);
|
|
764
|
+
const accepted = store.deleteCalls([callId]);
|
|
765
|
+
res.json({ deleted: accepted, count: accepted.length });
|
|
766
|
+
});
|
|
767
|
+
app.post("/api/dashboard/calls/delete", auth, (req, res) => {
|
|
768
|
+
const body = req.body ?? {};
|
|
769
|
+
const raw = body.call_ids;
|
|
770
|
+
if (!Array.isArray(raw)) {
|
|
771
|
+
res.status(400).json({ error: "Expected JSON body { 'call_ids': [...] }" });
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
const ids = raw.filter(
|
|
775
|
+
(cid) => typeof cid === "string" && cid.length > 0
|
|
776
|
+
);
|
|
777
|
+
const accepted = store.deleteCalls(ids);
|
|
778
|
+
res.json({ deleted: accepted, count: accepted.length });
|
|
779
|
+
});
|
|
1117
780
|
app.get("/api/dashboard/events", auth, (req, res) => {
|
|
1118
781
|
res.writeHead(200, {
|
|
1119
782
|
"Content-Type": "text/event-stream",
|
|
@@ -1186,7 +849,8 @@ function mountApi(app, store, token = "") {
|
|
|
1186
849
|
res.json({ data: active, count: active.length });
|
|
1187
850
|
});
|
|
1188
851
|
app.get("/api/v1/calls/:callId", auth, (req, res) => {
|
|
1189
|
-
const
|
|
852
|
+
const callId = String(req.params.callId);
|
|
853
|
+
const call = store.getCall(callId) ?? store.getActive(callId);
|
|
1190
854
|
if (!call) {
|
|
1191
855
|
res.status(404).json({ error: "Call not found" });
|
|
1192
856
|
return;
|