@runtypelabs/persona 3.22.0 → 3.23.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runtypelabs/persona",
3
- "version": "3.22.0",
3
+ "version": "3.23.0",
4
4
  "description": "Themeable, pluggable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -58,6 +58,7 @@
58
58
  "zod": "^3.22.4"
59
59
  },
60
60
  "devDependencies": {
61
+ "@size-limit/file": "^12.1.0",
61
62
  "@types/node": "^20.12.7",
62
63
  "@typescript-eslint/eslint-plugin": "^7.0.0",
63
64
  "@typescript-eslint/parser": "^7.0.0",
@@ -66,6 +67,7 @@
66
67
  "eslint-config-prettier": "^9.1.0",
67
68
  "fake-indexeddb": "^6.2.5",
68
69
  "rimraf": "^5.0.5",
70
+ "size-limit": "^12.1.0",
69
71
  "tsup": "^8.0.1",
70
72
  "typescript": "^5.4.5",
71
73
  "vitest": "^4.0.9"
@@ -98,10 +100,10 @@
98
100
  },
99
101
  "scripts": {
100
102
  "build": "rimraf dist && pnpm run build:styles && pnpm run build:client && pnpm run build:installer && pnpm run build:theme-ref && pnpm run build:theme-editor && pnpm run build:testing && pnpm run build:smart-dom-reader && pnpm run build:animations",
101
- "build:theme-editor": "tsup src/theme-editor.ts --format esm,cjs --dts --out-dir dist --no-splitting",
102
- "build:testing": "tsup src/testing.ts --format esm,cjs --dts --out-dir dist --no-splitting",
103
- "build:smart-dom-reader": "tsup src/smart-dom-reader.ts --format esm,cjs --dts --out-dir dist --no-splitting",
104
- "build:animations": "tsup src/animations/glyph-cycle.ts src/animations/wipe.ts --format esm,cjs --dts --out-dir dist/animations --no-splitting",
103
+ "build:theme-editor": "tsup src/theme-editor.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
104
+ "build:testing": "tsup src/testing.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
105
+ "build:smart-dom-reader": "tsup src/smart-dom-reader.ts --format esm,cjs --minify --dts --out-dir dist --no-splitting",
106
+ "build:animations": "tsup src/animations/glyph-cycle.ts src/animations/wipe.ts --format esm,cjs --minify --dts --out-dir dist/animations --no-splitting",
105
107
  "build:theme-ref": "tsup src/theme-reference.ts --format esm,cjs --minify --dts",
106
108
  "build:styles": "node -e \"const fs=require('fs');fs.mkdirSync('dist',{recursive:true});fs.copyFileSync('src/styles/widget.css','dist/widget.css');\"",
107
109
  "build:client": "tsup src/index.ts --format esm,cjs --minify --sourcemap --splitting false --dts --loader \".css=text\" && tsup src/index-global.ts --format iife --global-name AgentWidget --minify --sourcemap --splitting false --out-dir dist --loader \".css=text\" && node -e \"const fs=require('fs');for(const ext of ['.global.js','.global.js.map']){const from='dist/index-global'+ext;if(fs.existsSync(from))fs.renameSync(from,'dist/index'+ext);}\"",
@@ -110,6 +112,7 @@
110
112
  "typecheck": "tsc --noEmit",
111
113
  "test": "vitest",
112
114
  "test:ui": "vitest --ui",
113
- "test:run": "vitest run"
115
+ "test:run": "vitest run",
116
+ "size": "size-limit"
114
117
  }
115
118
  }
@@ -7,6 +7,7 @@ import {
7
7
  resolveFollowStateFromWheel
8
8
  } from "../utils/auto-follow";
9
9
  import type { EventStreamBuffer } from "../utils/event-stream-buffer";
10
+ import type { ThroughputMetric } from "../utils/throughput-tracker";
10
11
  import type {
11
12
  SSEEventRecord,
12
13
  AgentWidgetConfig,
@@ -151,6 +152,36 @@ function formatEventForCopy(event: SSEEventRecord): string {
151
152
  );
152
153
  }
153
154
 
155
+ // ============================================================================
156
+ // Output Throughput Summary
157
+ // ============================================================================
158
+
159
+ /** Format the headline value, e.g. `23.9 tok/s` or `-- tok/s` when unavailable. */
160
+ function formatThroughputValue(metric: ThroughputMetric): string {
161
+ if (
162
+ metric.tokensPerSecond === undefined ||
163
+ !Number.isFinite(metric.tokensPerSecond)
164
+ ) {
165
+ return "-- tok/s";
166
+ }
167
+ return `${metric.tokensPerSecond.toFixed(1)} tok/s`;
168
+ }
169
+
170
+ /** Compact supporting details: output tokens, duration, and usage/estimate source. */
171
+ function formatThroughputMeta(metric: ThroughputMetric): string {
172
+ const parts: string[] = [];
173
+ if (metric.outputTokens !== undefined) {
174
+ parts.push(`${metric.outputTokens.toLocaleString()} tok`);
175
+ }
176
+ if (metric.durationMs !== undefined) {
177
+ parts.push(`${(metric.durationMs / 1000).toFixed(2)}s`);
178
+ }
179
+ if (metric.source) {
180
+ parts.push(metric.source);
181
+ }
182
+ return parts.join(" · ");
183
+ }
184
+
154
185
  // ============================================================================
155
186
  // Inline Payload Component
156
187
  // ============================================================================
@@ -381,6 +412,12 @@ export type EventStreamViewOptions = {
381
412
  onClose?: () => void;
382
413
  config?: AgentWidgetConfig;
383
414
  plugins?: AgentWidgetPlugin[];
415
+ /**
416
+ * Optional accessor for the current output-throughput metric, derived from
417
+ * the same SSE event stream. When provided, a compact "Output throughput"
418
+ * summary row is rendered and refreshed on each update.
419
+ */
420
+ getThroughput?: () => ThroughputMetric;
384
421
  };
385
422
 
386
423
  export function createEventStreamView(
@@ -396,6 +433,7 @@ export function createEventStreamView(
396
433
  onClose,
397
434
  config,
398
435
  plugins = [],
436
+ getThroughput,
399
437
  } = options;
400
438
  const scrollToBottomConfig = config?.features?.scrollToBottom;
401
439
  const scrollToBottomEnabled = scrollToBottomConfig?.enabled !== false;
@@ -474,11 +512,17 @@ export function createEventStreamView(
474
512
  let copyAllBtn!: HTMLButtonElement;
475
513
  let searchInput!: HTMLInputElement;
476
514
  let searchClearBtn!: HTMLButtonElement;
515
+ // Inline "Throughput <tok/s>" group, rendered into the header bar next to
516
+ // the "Events" count when getThroughput is provided. The detailed
517
+ // breakdown is revealed on hover via the native title tooltip.
518
+ let throughputValueEl: HTMLElement | null = null;
519
+ let throughputContainer: HTMLElement | null = null;
520
+ let throughputTooltipEl: HTMLElement | null = null;
477
521
 
478
522
  function buildDefaultToolbar(): HTMLElement {
479
523
  const toolbarOuter = createElement(
480
524
  "div",
481
- "persona-flex persona-flex-col persona-flex-shrink-0"
525
+ "persona-relative persona-flex persona-flex-col persona-flex-shrink-0"
482
526
  );
483
527
 
484
528
  // --- Header bar ---
@@ -502,6 +546,58 @@ export function createEventStreamView(
502
546
  );
503
547
  countBadge.textContent = "0";
504
548
 
549
+ // Inline throughput group: "Throughput 146.3 tok/s", grouped with the
550
+ // Events count. Hover reveals tokens · duration · source via a custom
551
+ // tooltip (shown instantly, unlike the slow native `title` delay).
552
+ if (getThroughput) {
553
+ throughputContainer = createElement(
554
+ "div",
555
+ "persona-relative persona-flex persona-items-center persona-gap-1.5 persona-whitespace-nowrap persona-ml-1"
556
+ );
557
+ throughputContainer.style.cursor = "help";
558
+ // Label styled to match the "Events" title.
559
+ const throughputLabel = createElement(
560
+ "span",
561
+ "persona-text-sm persona-font-medium persona-text-persona-primary persona-whitespace-nowrap"
562
+ );
563
+ throughputLabel.textContent = "Throughput";
564
+ // Same bounding box + styling as the Events count badge.
565
+ throughputValueEl = createElement(
566
+ "span",
567
+ "persona-text-[11px] persona-font-mono persona-bg-persona-container persona-text-persona-muted persona-px-2 persona-py-0.5 persona-rounded persona-border persona-border-persona-border persona-tabular-nums"
568
+ );
569
+ throughputValueEl.textContent = "-- tok/s";
570
+
571
+ // Custom hover tooltip — appears instantly (no native title delay).
572
+ // Appended to the (non-clipping) toolbar wrapper rather than the header
573
+ // bar, which has overflow-hidden and would clip a dropdown. Position is
574
+ // measured on hover so it sits just under the throughput group.
575
+ throughputTooltipEl = createElement(
576
+ "div",
577
+ "persona-absolute persona-z-50 persona-whitespace-nowrap persona-rounded persona-border persona-border-persona-border persona-bg-persona-container persona-text-persona-primary persona-text-[11px] persona-font-mono persona-px-2 persona-py-1 persona-shadow"
578
+ );
579
+ throughputTooltipEl.style.display = "none";
580
+ throughputTooltipEl.style.pointerEvents = "none";
581
+ const group = throughputContainer;
582
+ const tooltip = throughputTooltipEl;
583
+ const showTooltip = () => {
584
+ if (!tooltip.textContent) return;
585
+ const gRect = group.getBoundingClientRect();
586
+ const pRect = toolbarOuter.getBoundingClientRect();
587
+ tooltip.style.left = `${gRect.left - pRect.left}px`;
588
+ tooltip.style.top = `${gRect.bottom - pRect.top + 4}px`;
589
+ tooltip.style.display = "block";
590
+ };
591
+ const hideTooltip = () => {
592
+ tooltip.style.display = "none";
593
+ };
594
+ throughputContainer.addEventListener("mouseenter", showTooltip);
595
+ throughputContainer.addEventListener("mouseleave", hideTooltip);
596
+
597
+ throughputContainer.appendChild(throughputLabel);
598
+ throughputContainer.appendChild(throughputValueEl);
599
+ }
600
+
505
601
  const headerSpacer = createElement("div", "persona-flex-1");
506
602
 
507
603
  // Filter dropdown
@@ -540,6 +636,7 @@ export function createEventStreamView(
540
636
 
541
637
  headerBar.appendChild(title);
542
638
  headerBar.appendChild(countBadge);
639
+ if (throughputContainer) headerBar.appendChild(throughputContainer);
543
640
  headerBar.appendChild(headerSpacer);
544
641
  headerBar.appendChild(filterSelect);
545
642
  headerBar.appendChild(copyAllBtn);
@@ -592,6 +689,7 @@ export function createEventStreamView(
592
689
 
593
690
  toolbarOuter.appendChild(headerBar);
594
691
  toolbarOuter.appendChild(searchBar);
692
+ if (throughputTooltipEl) toolbarOuter.appendChild(throughputTooltipEl);
595
693
  return toolbarOuter;
596
694
  }
597
695
 
@@ -630,6 +728,28 @@ export function createEventStreamView(
630
728
  );
631
729
  truncationBanner.style.display = "none";
632
730
 
731
+ // Refresh the inline header throughput value + hover tooltip. The elements
732
+ // live in the header bar (built by buildDefaultToolbar); this is a no-op
733
+ // when getThroughput is absent or a plugin replaced the toolbar.
734
+ function updateThroughputSummary(): void {
735
+ if (!getThroughput || !throughputValueEl || !throughputContainer) return;
736
+ const metric = getThroughput();
737
+ throughputValueEl.textContent = formatThroughputValue(metric);
738
+ // Detailed breakdown is revealed on hover via the custom tooltip; mirror
739
+ // it into aria-label for assistive tech. When there's nothing to show,
740
+ // hide the tooltip so an empty box never flashes on hover.
741
+ const meta = formatThroughputMeta(metric);
742
+ if (throughputTooltipEl) {
743
+ throughputTooltipEl.textContent = meta;
744
+ if (!meta) throughputTooltipEl.style.display = "none";
745
+ }
746
+ if (meta) {
747
+ throughputContainer.setAttribute("aria-label", meta);
748
+ } else {
749
+ throughputContainer.removeAttribute("aria-label");
750
+ }
751
+ }
752
+
633
753
  // ========================================================================
634
754
  // Events List (simple DOM, no virtual scroller)
635
755
  // ========================================================================
@@ -804,6 +924,7 @@ export function createEventStreamView(
804
924
  lastRenderTime = Date.now();
805
925
  pendingUpdate = false;
806
926
 
927
+ updateThroughputSummary();
807
928
  updateFilterOptions();
808
929
 
809
930
  // Truncation banner
package/src/ui.ts CHANGED
@@ -89,6 +89,7 @@ import { createApprovalBubble } from "./components/approval-bubble";
89
89
  import { createSuggestions } from "./components/suggestions";
90
90
  import { EventStreamBuffer } from "./utils/event-stream-buffer";
91
91
  import { EventStreamStore } from "./utils/event-stream-store";
92
+ import { ThroughputTracker } from "./utils/throughput-tracker";
92
93
  import { createEventStreamView } from "./components/event-stream-view";
93
94
  import { createArtifactPane, type ArtifactPaneApi } from "./components/artifact-pane";
94
95
  import {
@@ -669,6 +670,8 @@ export const createAgentExperience = (
669
670
  let eventStreamStore = showEventStreamToggle ? new EventStreamStore(eventStreamDbName) : null;
670
671
  const eventStreamMaxEvents = config.features?.eventStream?.maxEvents ?? 2000;
671
672
  let eventStreamBuffer = showEventStreamToggle ? new EventStreamBuffer(eventStreamMaxEvents, eventStreamStore) : null;
673
+ // Passive output-throughput tracker, fed from the same SSE tap as the buffer.
674
+ let throughputTracker = showEventStreamToggle ? new ThroughputTracker() : null;
672
675
  let eventStreamView: ReturnType<typeof createEventStreamView> | null = null;
673
676
  let eventStreamVisible = false;
674
677
  let eventStreamRAF: number | null = null;
@@ -858,6 +861,8 @@ export const createAgentExperience = (
858
861
  onClose: () => toggleEventStreamOff(),
859
862
  config,
860
863
  plugins,
864
+ getThroughput: () =>
865
+ throughputTracker?.getMetric() ?? { status: "idle" },
861
866
  });
862
867
  }
863
868
  if (eventStreamView) {
@@ -4508,6 +4513,7 @@ export const createAgentExperience = (
4508
4513
  if (eventStreamBuffer || config.onSSEEvent) {
4509
4514
  session.setSSEEventCallback((type: string, payload: unknown) => {
4510
4515
  config.onSSEEvent?.(type, payload);
4516
+ throughputTracker?.processEvent(type, payload);
4511
4517
  eventStreamBuffer?.push({
4512
4518
  id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
4513
4519
  type,
@@ -4563,6 +4569,10 @@ export const createAgentExperience = (
4563
4569
  // intact so the user can edit and resend without retyping.
4564
4570
  if (session.isStreaming()) {
4565
4571
  session.cancel();
4572
+ // Cancelling emits no terminal/error SSE frame, so reset the throughput
4573
+ // tracker (as clear-chat does) to avoid a stale `running` row lingering.
4574
+ throughputTracker?.reset();
4575
+ eventStreamView?.update();
4566
4576
  return;
4567
4577
  }
4568
4578
 
@@ -4697,6 +4707,10 @@ export const createAgentExperience = (
4697
4707
  if (!session.isStreaming()) return;
4698
4708
  if (!event.composedPath().includes(container)) return;
4699
4709
  session.cancel();
4710
+ // Cancelling emits no terminal/error SSE frame — reset throughput so the
4711
+ // Events row doesn't keep showing a live rate from the stopped stream.
4712
+ throughputTracker?.reset();
4713
+ eventStreamView?.update();
4700
4714
  resetHistoryNavigation();
4701
4715
  event.preventDefault();
4702
4716
  event.stopImmediatePropagation();
@@ -5562,8 +5576,9 @@ export const createAgentExperience = (
5562
5576
  persistentMetadata = {};
5563
5577
  actionManager.syncFromMetadata();
5564
5578
 
5565
- // Clear event stream buffer and store
5579
+ // Clear event stream buffer and store, and reset throughput tracking
5566
5580
  eventStreamBuffer?.clear();
5581
+ throughputTracker?.reset();
5567
5582
  eventStreamView?.update();
5568
5583
  });
5569
5584
  };
@@ -5732,10 +5747,12 @@ export const createAgentExperience = (
5732
5747
  if (!eventStreamBuffer) {
5733
5748
  eventStreamStore = new EventStreamStore(eventStreamDbName);
5734
5749
  eventStreamBuffer = new EventStreamBuffer(eventStreamMaxEvents, eventStreamStore);
5750
+ throughputTracker = throughputTracker ?? new ThroughputTracker();
5735
5751
  eventStreamStore.open().then(() => eventStreamBuffer?.restore()).catch(() => {});
5736
- // Register the SSE event callback (host tap + buffer)
5752
+ // Register the SSE event callback (host tap + buffer + throughput)
5737
5753
  session.setSSEEventCallback((type: string, payload: unknown) => {
5738
5754
  config.onSSEEvent?.(type, payload);
5755
+ throughputTracker?.processEvent(type, payload);
5739
5756
  eventStreamBuffer!.push({
5740
5757
  id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
5741
5758
  type,
@@ -5784,6 +5801,8 @@ export const createAgentExperience = (
5784
5801
  eventStreamStore?.destroy();
5785
5802
  eventStreamBuffer = null;
5786
5803
  eventStreamStore = null;
5804
+ throughputTracker?.reset();
5805
+ throughputTracker = null;
5787
5806
  }
5788
5807
 
5789
5808
  if (config.launcher?.enabled === false && launcherButtonInstance) {
@@ -7024,8 +7043,9 @@ export const createAgentExperience = (
7024
7043
  persistentMetadata = {};
7025
7044
  actionManager.syncFromMetadata();
7026
7045
 
7027
- // Clear event stream buffer and store
7046
+ // Clear event stream buffer and store, and reset throughput tracking
7028
7047
  eventStreamBuffer?.clear();
7048
+ throughputTracker?.reset();
7029
7049
  eventStreamView?.update();
7030
7050
  },
7031
7051
  setMessage(message: string): boolean {
@@ -7179,6 +7199,7 @@ export const createAgentExperience = (
7179
7199
  /** Push a raw event into the event stream buffer (for testing/debugging) */
7180
7200
  __pushEventStreamEvent(event: { type: string; payload: unknown }): void {
7181
7201
  if (eventStreamBuffer) {
7202
+ throughputTracker?.processEvent(event.type, event.payload);
7182
7203
  eventStreamBuffer.push({
7183
7204
  id: `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
7184
7205
  type: event.type,