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.
Files changed (34) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +5 -2
  3. package/dist/aec-PJJMUM5E.mjs +228 -0
  4. package/dist/{banner-3GNZ6VQK.mjs → banner-UYW6UM3J.mjs} +4 -1
  5. package/dist/barge-in-strategies-X6ARMGIQ.mjs +12 -0
  6. package/dist/{carrier-config-33HQ2W4V.mjs → carrier-config-4ZKVYAWV.mjs} +5 -2
  7. package/dist/{chunk-AFUYSNDH.mjs → chunk-6GR5MHHQ.mjs} +9 -0
  8. package/dist/chunk-CYLJVT5G.mjs +7031 -0
  9. package/dist/chunk-D4424JZR.mjs +71 -0
  10. package/dist/{chunk-VJVDG4V5.mjs → chunk-MVOQFAEO.mjs} +5 -0
  11. package/dist/chunk-N565J3CF.mjs +69 -0
  12. package/dist/chunk-RV7APPYE.mjs +397 -0
  13. package/dist/{chunk-FIFIWBL7.mjs → chunk-TEW3NAZJ.mjs} +6000 -3156
  14. package/dist/{chunk-SEMKNPCD.mjs → chunk-XS45BAQL.mjs} +5 -1
  15. package/dist/cli.js +304 -640
  16. package/dist/client-2GJVZT42.mjs +8935 -0
  17. package/dist/dashboard/ui.html +63 -0
  18. package/dist/{dist-YRCCJQ26.mjs → dist-RYMPCILF.mjs} +28 -2
  19. package/dist/index.d.mts +3548 -428
  20. package/dist/index.d.ts +3548 -428
  21. package/dist/index.js +34336 -9532
  22. package/dist/index.mjs +3642 -512
  23. package/dist/{node-cron-6PRPSBG5.mjs → node-cron-JFWQQRBU.mjs} +23 -2
  24. package/dist/persistence-LVIAHESK.mjs +7 -0
  25. package/dist/silero-vad-NSEXI4XS.mjs +7 -0
  26. package/dist/streamableHttp-WKNGHDVO.mjs +1496 -0
  27. package/dist/test-mode-WEKKNBLD.mjs +8 -0
  28. package/dist/tunnel-43CHWPVQ.mjs +8 -0
  29. package/package.json +7 -7
  30. package/src/dashboard/ui.html +63 -0
  31. package/dist/chunk-QHHBUCMT.mjs +0 -25
  32. package/dist/persistence-LQBYQPQQ.mjs +0 -7
  33. package/dist/test-mode-MVJ3SKG4.mjs +0 -8
  34. 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
- const activeStatus = active?.status;
180
- const resolvedStatus = activeStatus && activeStatus !== "in-progress" ? activeStatus : "completed";
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: data.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
- this.calls.push(entry);
193
- if (this.calls.length > this.maxCalls) {
194
- this.calls = this.calls.slice(-this.maxCalls);
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 ordered = [...this.calls].reverse();
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 totalCalls = this.calls.length;
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 this.calls) {
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
- return this.calls.length;
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 : null;
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 DASHBOARD_HTML = `<!DOCTYPE html>
468
- <html lang="en">
469
- <head>
470
- <meta charset="utf-8">
471
- <meta name="viewport" content="width=device-width, initial-scale=1">
472
- <title>Patter | Dashboard</title>
473
- <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1188 1773' fill='none'%3E%3Cstyle%3Epath%7Bstroke:%2309090b%7D@media(prefers-color-scheme:dark)%7Bpath%7Bstroke:%23e4e4e7%7D%7D%3C/style%3E%3Cpath 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-width='50' stroke-linecap='round'/%3E%3C/svg%3E">
474
- <link rel="preconnect" href="https://fonts.googleapis.com">
475
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
476
- <link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
477
- <style>
478
- :root {
479
- --bg: #fdfcfc;
480
- --fg: #09090b;
481
- --card: #ffffff;
482
- --primary: #18181b;
483
- --primary-fg: #fafafa;
484
- --secondary: #f4f4f5;
485
- --muted: #71717b;
486
- --border: #e4e4e7;
487
- --border-d: #d4d4d8;
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()">&times;</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
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) || '-')+' &rarr; '+(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)">&#9632;</span> End-to-end' :
1011
- '<span style="color:var(--blue)">&#9632;</span> STT &nbsp;'+
1012
- '<span style="color:var(--purple)">&#9632;</span> LLM &nbsp;'+
1013
- '<span style="color:var(--orange)">&#9632;</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 &amp;&amp; npm run build &amp;&amp; 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
- connectSSE();
1085
- } else {
1086
- setInterval(refresh, 3000);
732
+ return FALLBACK_HTML;
1087
733
  }
1088
- </script>
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 call = store.getCall(String(req.params.callId));
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 call = store.getCall(String(req.params.callId));
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;