getpatter 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -63,6 +63,7 @@ var MetricsStore = class extends import_events.EventEmitter {
63
63
  publish(eventType, data) {
64
64
  this.emit("sse", { type: eventType, data });
65
65
  }
66
+ /** Mark a call as in-progress (creates the row if it does not yet exist). */
66
67
  recordCallStart(data) {
67
68
  const callId = data.call_id || "";
68
69
  if (!callId) return;
@@ -160,6 +161,7 @@ var MetricsStore = class extends import_events.EventEmitter {
160
161
  }
161
162
  this.publish("call_status", { call_id: callId, status, ...extra });
162
163
  }
164
+ /** Append a single conversation turn to an active call and broadcast it via SSE. */
163
165
  recordTurn(data) {
164
166
  const callId = data.call_id || "";
165
167
  const turn = data.turn;
@@ -171,6 +173,7 @@ var MetricsStore = class extends import_events.EventEmitter {
171
173
  }
172
174
  this.publish("turn_complete", { call_id: callId, turn });
173
175
  }
176
+ /** Move a call from active to completed and persist its final metrics. */
174
177
  recordCallEnd(data, metrics) {
175
178
  const callId = data.call_id || "";
176
179
  if (!callId) return;
@@ -198,10 +201,12 @@ var MetricsStore = class extends import_events.EventEmitter {
198
201
  metrics: entry.metrics ?? null
199
202
  });
200
203
  }
204
+ /** Return a window of completed calls in newest-first order. */
201
205
  getCalls(limit = 50, offset = 0) {
202
206
  const ordered = [...this.calls].reverse();
203
207
  return ordered.slice(offset, offset + limit);
204
208
  }
209
+ /** Look up a completed call by id (newest match wins). */
205
210
  getCall(callId) {
206
211
  for (let i = this.calls.length - 1; i >= 0; i--) {
207
212
  if (this.calls[i].call_id === callId) return this.calls[i];
@@ -212,9 +217,11 @@ var MetricsStore = class extends import_events.EventEmitter {
212
217
  getActive(callId) {
213
218
  return this.activeCalls.get(callId);
214
219
  }
220
+ /** Return all currently active (not yet ended) calls. */
215
221
  getActiveCalls() {
216
222
  return Array.from(this.activeCalls.values());
217
223
  }
224
+ /** Compute summary statistics across the buffered call history. */
218
225
  getAggregates() {
219
226
  const totalCalls = this.calls.length;
220
227
  if (totalCalls === 0) {
@@ -266,6 +273,7 @@ var MetricsStore = class extends import_events.EventEmitter {
266
273
  active_calls: this.activeCalls.size
267
274
  };
268
275
  }
276
+ /** Return calls whose `started_at` falls within `[fromTs, toTs]` (Unix seconds). */
269
277
  getCallsInRange(fromTs = 0, toTs = 0) {
270
278
  return this.calls.filter((call) => {
271
279
  const started = call.started_at || 0;
@@ -274,6 +282,7 @@ var MetricsStore = class extends import_events.EventEmitter {
274
282
  return true;
275
283
  });
276
284
  }
285
+ /** Number of completed calls currently in the ring buffer. */
277
286
  get callCount() {
278
287
  return this.calls.length;
279
288
  }
@@ -464,630 +473,32 @@ function csvEscape(value) {
464
473
  }
465
474
 
466
475
  // 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>';
476
+ var import_node_fs = require("fs");
477
+ var import_node_path = require("path");
478
+ var FALLBACK_HTML = `<!doctype html>
479
+ <html><head><meta charset="utf-8"><title>Patter dashboard</title></head>
480
+ <body style="font-family:ui-sans-serif,system-ui;padding:2rem;color:#1a1a1a">
481
+ <h1>Dashboard asset missing</h1>
482
+ <p>The bundled <code>ui.html</code> was not found alongside this module.
483
+ Run <code>cd dashboard-app &amp;&amp; npm run build &amp;&amp; npm run sync</code>
484
+ from the repo root to regenerate it.</p>
485
+ </body></html>`;
486
+ function loadDashboardHtml() {
487
+ const here = typeof __dirname !== "undefined" ? __dirname : (0, import_node_path.dirname)(".");
488
+ const candidates = [
489
+ (0, import_node_path.join)(here, "ui.html"),
490
+ (0, import_node_path.join)(here, "dashboard", "ui.html"),
491
+ (0, import_node_path.join)(here, "..", "dashboard", "ui.html")
492
+ ];
493
+ for (const path2 of candidates) {
494
+ try {
495
+ return (0, import_node_fs.readFileSync)(path2, "utf8");
496
+ } catch {
1026
497
  }
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
498
  }
1084
- connectSSE();
1085
- } else {
1086
- setInterval(refresh, 3000);
499
+ return FALLBACK_HTML;
1087
500
  }
1088
- </script>
1089
- </body>
1090
- </html>`;
501
+ var DASHBOARD_HTML = loadDashboardHtml();
1091
502
 
1092
503
  // src/dashboard/routes.ts
1093
504
  function mountDashboard(app, store, token = "") {