@runtypelabs/persona 4.24.0 → 4.25.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.
Files changed (45) hide show
  1. package/dist/approval-ui.d.cts +63 -0
  2. package/dist/approval-ui.d.ts +63 -0
  3. package/dist/artifacts-ui.d.cts +63 -0
  4. package/dist/artifacts-ui.d.ts +63 -0
  5. package/dist/codegen.cjs +1 -1
  6. package/dist/codegen.js +1 -1
  7. package/dist/context-mentions.d.cts +63 -0
  8. package/dist/context-mentions.d.ts +63 -0
  9. package/dist/event-stream-view.d.cts +63 -0
  10. package/dist/event-stream-view.d.ts +63 -0
  11. package/dist/forms-ui.d.cts +74 -0
  12. package/dist/forms-ui.d.ts +74 -0
  13. package/dist/index.cjs +42 -42
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +75 -1
  16. package/dist/index.d.ts +75 -1
  17. package/dist/index.global.js +27 -27
  18. package/dist/index.global.js.map +1 -1
  19. package/dist/index.js +42 -42
  20. package/dist/index.js.map +1 -1
  21. package/dist/session-reconnect.d.cts +63 -0
  22. package/dist/session-reconnect.d.ts +63 -0
  23. package/dist/smart-dom-reader.d.cts +63 -0
  24. package/dist/smart-dom-reader.d.ts +63 -0
  25. package/dist/theme-editor-preview.cjs +49 -49
  26. package/dist/theme-editor-preview.d.cts +63 -0
  27. package/dist/theme-editor-preview.d.ts +63 -0
  28. package/dist/theme-editor-preview.js +27 -27
  29. package/dist/theme-editor.d.cts +63 -0
  30. package/dist/theme-editor.d.ts +63 -0
  31. package/dist/voice-runtime-7DA7MJH4.js +1 -0
  32. package/dist/voice-runtime.cjs +1 -1
  33. package/dist/voice-runtime.d.cts +33 -0
  34. package/dist/voice-runtime.d.ts +33 -0
  35. package/dist/voice-runtime.js +1 -1
  36. package/package.json +1 -1
  37. package/src/index-core.ts +2 -0
  38. package/src/session.ts +30 -1
  39. package/src/session.voice.test.ts +72 -1
  40. package/src/types.ts +71 -0
  41. package/src/ui.ts +59 -0
  42. package/src/ui.voice-prewarm.test.ts +222 -0
  43. package/src/voice/runtype-voice-provider.ts +238 -27
  44. package/src/voice/voice.test.ts +272 -2
  45. package/dist/voice-runtime-EFR3R5ES.js +0 -1
package/src/types.ts CHANGED
@@ -4614,6 +4614,24 @@ export type AgentWidgetStatusIndicatorConfig = {
4614
4614
  resumingText?: string;
4615
4615
  };
4616
4616
 
4617
+ /** Built-in `voiceRecognition.prewarm` triggers. See {@link AgentWidgetVoiceRecognitionConfig.prewarm}. */
4618
+ export type AgentWidgetVoicePrewarmTrigger = 'hover';
4619
+
4620
+ /**
4621
+ * Custom `voiceRecognition.prewarm` trigger. Runs while the composer has a mic
4622
+ * button, and again when the button is rebuilt or `update()` swaps in a
4623
+ * different function. Call `warm()` whenever the visitor shows intent to talk
4624
+ * (it is safe to call repeatedly). Return a cleanup to run on replacement.
4625
+ */
4626
+ export type AgentWidgetVoicePrewarmHook = (context: {
4627
+ /** Fire-and-forget voice warm-up; latched and error-swallowing. */
4628
+ warm: () => void;
4629
+ /** The composer's current mic button. */
4630
+ micButton: HTMLButtonElement;
4631
+ /** The widget's mount element. */
4632
+ mount: HTMLElement;
4633
+ }) => void | (() => void);
4634
+
4617
4635
  export type AgentWidgetVoiceRecognitionConfig = {
4618
4636
  /**
4619
4637
  * Enable the mic button and voice input. The mic still only renders when the
@@ -4685,6 +4703,30 @@ export type AgentWidgetVoiceRecognitionConfig = {
4685
4703
  */
4686
4704
  onMetrics?: (metrics: VoiceMetrics) => void;
4687
4705
 
4706
+ /**
4707
+ * Warm the voice path when the visitor shows intent to talk, before the mic
4708
+ * click, so the call starts faster.
4709
+ *
4710
+ * - `'hover'`: when the pointer enters the mic button, it receives focus, or
4711
+ * (on touch) the first pointer-down on it.
4712
+ * - `false`: never.
4713
+ * - A function: custom trigger. Called with `{ warm, micButton, mount }`
4714
+ * while the mic button exists (and again when the button is rebuilt or
4715
+ * `update()` swaps in a different function); call `warm()` whenever you
4716
+ * decide, e.g. when the panel opens. May return a cleanup that runs on
4717
+ * destroy, rebuild, or replacement. Replaces the built-in trigger.
4718
+ *
4719
+ * What `warm()` does depends on the provider: `runtype` fires its prewarm
4720
+ * (see `provider.runtype.prewarmMode`), a `custom` provider's optional
4721
+ * {@link VoiceProvider.prewarm} runs, and the browser (Web Speech) path
4722
+ * starts the client-token session init (as `sessionInit` does), since speech
4723
+ * recognition itself cannot start before the click. Warming is
4724
+ * fire-and-forget, latched, and never surfaces an error or UI state.
4725
+ *
4726
+ * @default 'hover' in client token mode, otherwise `false`
4727
+ */
4728
+ prewarm?: AgentWidgetVoicePrewarmTrigger | AgentWidgetVoicePrewarmHook | false;
4729
+
4688
4730
  // Voice provider configuration
4689
4731
  provider?: {
4690
4732
  type: 'browser' | 'runtype' | 'custom';
@@ -4707,6 +4749,23 @@ export type AgentWidgetVoiceRecognitionConfig = {
4707
4749
  * `@runtypelabs/persona/voice-worklet-player` for a jitter-buffered engine.
4708
4750
  */
4709
4751
  createPlaybackEngine?: () => VoicePlaybackEngine | Promise<VoicePlaybackEngine>;
4752
+ /**
4753
+ * How `prewarm()` warms the voice path (see `voiceRecognition.prewarm`).
4754
+ *
4755
+ * - `'request'` (default): a fire-and-forget
4756
+ * `POST {host}/v1/client/agents/{agentId}/voice/prewarm`.
4757
+ * - `'attach'`: open the voice WebSocket ahead of the click without
4758
+ * starting a call; the click then starts the call on that socket. It can
4759
+ * create server work before the visitor clicks, so it is opt-in.
4760
+ * @default 'request'
4761
+ */
4762
+ prewarmMode?: 'request' | 'attach';
4763
+ /**
4764
+ * `'attach'` mode: how long (ms) an attached socket waits for the click
4765
+ * before it closes. The server clamps it to 30000..600000.
4766
+ * @default 30000
4767
+ */
4768
+ attachIdleMs?: number;
4710
4769
  /** @deprecated No-op on the realtime path: the server's STT owns turn-taking. */
4711
4770
  pauseDuration?: number;
4712
4771
  /** @deprecated No-op on the realtime path: the server's STT owns turn-taking. */
@@ -5014,6 +5073,10 @@ export type VoiceConfig = {
5014
5073
  * `@runtypelabs/persona/voice-worklet-player` for a jitter-buffered engine.
5015
5074
  */
5016
5075
  createPlaybackEngine?: () => VoicePlaybackEngine | Promise<VoicePlaybackEngine>;
5076
+ /** See `voiceRecognition.provider.runtype.prewarmMode`. @default 'request' */
5077
+ prewarmMode?: 'request' | 'attach';
5078
+ /** See `voiceRecognition.provider.runtype.attachIdleMs`. @default 30000 */
5079
+ attachIdleMs?: number;
5017
5080
  /** @deprecated No-op on the realtime path: the server's STT owns turn-taking. */
5018
5081
  pauseDuration?: number;
5019
5082
  /** @deprecated No-op on the realtime path: the server's STT owns turn-taking. */
@@ -5085,6 +5148,14 @@ export interface VoiceProvider {
5085
5148
 
5086
5149
  /** Stop playback / cancel in-flight request without starting recording */
5087
5150
  stopPlayback?(): void;
5151
+
5152
+ /**
5153
+ * Warm the provider ahead of `startListening()` (the widget calls it on mic
5154
+ * intent, see `voiceRecognition.prewarm`). Must be fire-and-forget: never
5155
+ * throw, emit errors or status, or acquire the microphone. May be called
5156
+ * repeatedly; throttle as needed.
5157
+ */
5158
+ prewarm?(): void;
5088
5159
  }
5089
5160
 
5090
5161
  /**
package/src/ui.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  AgentWidgetConfig,
17
17
  AgentWidgetConfigPatch,
18
18
  AgentWidgetSessionInitHook,
19
+ AgentWidgetVoicePrewarmHook,
19
20
  AgentWidgetApprovalDecisionOptions,
20
21
  AgentWidgetMessage,
21
22
  AgentWidgetEvent,
@@ -12981,6 +12982,62 @@ export const createAgentExperience = (
12981
12982
 
12982
12983
  composerVoiceBridge = handleMicButtonClick;
12983
12984
 
12985
+ // Voice prewarm on mic intent (`voiceRecognition.prewarm`). Delegated on the
12986
+ // mount so composer rebuilds stay covered, and `config` is read at event time
12987
+ // so `update()` applies without re-wiring. Default: 'hover' in client token
12988
+ // mode, off otherwise.
12989
+ const warmVoice = () => session.prewarmVoice();
12990
+ const voicePrewarmSetting = () => {
12991
+ const voice = config.voiceRecognition;
12992
+ if (voice?.enabled !== true) return false;
12993
+ return voice.prewarm ?? (config.clientToken ? "hover" : false);
12994
+ };
12995
+ // `pointerover` covers mouse hover and precedes a touch's pointer-down;
12996
+ // `focusin` covers keyboard focus.
12997
+ const handleVoicePrewarmIntent = (event: Event) => {
12998
+ if (voicePrewarmSetting() !== "hover") return;
12999
+ if (micButton?.contains(event.target as Node)) warmVoice();
13000
+ };
13001
+ const voicePrewarmEvents = ["pointerover", "pointerdown", "focusin"];
13002
+ voicePrewarmEvents.forEach((type) => mount.addEventListener(type, handleVoicePrewarmIntent));
13003
+ // A custom prewarm hook is bound to one mic button: it re-runs (after its
13004
+ // cleanup) when the button is rebuilt or `update()` swaps the function.
13005
+ let voicePrewarmHook: AgentWidgetVoicePrewarmHook | undefined;
13006
+ let voicePrewarmHookButton: HTMLButtonElement | null = null;
13007
+ let voicePrewarmHookCleanup: ReturnType<AgentWidgetVoicePrewarmHook>;
13008
+ // Host hook code may throw; neither setup nor cleanup may break the widget.
13009
+ const releaseVoicePrewarmHook = () => {
13010
+ const cleanup = voicePrewarmHookCleanup;
13011
+ voicePrewarmHookCleanup = undefined;
13012
+ try {
13013
+ if (typeof cleanup === "function") cleanup();
13014
+ } catch {
13015
+ // ignored, see above
13016
+ }
13017
+ };
13018
+ const syncVoicePrewarmHook = () => {
13019
+ const setting = voicePrewarmSetting();
13020
+ const next = typeof setting === "function" && micButton ? setting : undefined;
13021
+ const button = next ? micButton : null;
13022
+ if (next === voicePrewarmHook && button === voicePrewarmHookButton) return;
13023
+ releaseVoicePrewarmHook();
13024
+ voicePrewarmHook = next;
13025
+ voicePrewarmHookButton = button;
13026
+ if (!next || !button) return;
13027
+ try {
13028
+ voicePrewarmHookCleanup = next({ warm: warmVoice, micButton: button, mount });
13029
+ } catch {
13030
+ // ignored, see above
13031
+ }
13032
+ };
13033
+ syncVoicePrewarmHook();
13034
+ destroyCallbacks.push(() => {
13035
+ voicePrewarmEvents.forEach((type) =>
13036
+ mount.removeEventListener(type, handleVoicePrewarmIntent)
13037
+ );
13038
+ releaseVoicePrewarmHook();
13039
+ });
13040
+
12984
13041
  if (micButton) {
12985
13042
  // The click listener itself is registered by `wireComposerSurface`, so a
12986
13043
  // composer rebuild unwires it with the rest of the surface.
@@ -13578,6 +13635,7 @@ export const createAgentExperience = (
13578
13635
  if (micButton) {
13579
13636
  composerBindings.addListener(micButton, "click", handleMicButtonClick);
13580
13637
  }
13638
+ syncVoicePrewarmHook();
13581
13639
  };
13582
13640
 
13583
13641
  wireComposerSurface();
@@ -14764,6 +14822,7 @@ export const createAgentExperience = (
14764
14822
  }
14765
14823
  }
14766
14824
  }
14825
+ syncVoicePrewarmHook();
14767
14826
 
14768
14827
  // Update attachment button visibility based on attachments config
14769
14828
  const attachmentsEnabled = config.attachments?.enabled === true;
@@ -0,0 +1,222 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import { createAgentExperience } from "./ui";
5
+ import type {
6
+ AgentWidgetConfig,
7
+ AgentWidgetVoicePrewarmHook,
8
+ AgentWidgetVoiceRecognitionConfig,
9
+ } from "./types";
10
+
11
+ let controller: ReturnType<typeof createAgentExperience> | undefined;
12
+
13
+ beforeEach(() => {
14
+ window.scrollTo = vi.fn();
15
+ });
16
+
17
+ afterEach(() => {
18
+ controller?.destroy();
19
+ controller = undefined;
20
+ document.body.replaceChildren();
21
+ vi.restoreAllMocks();
22
+ vi.unstubAllGlobals();
23
+ });
24
+
25
+ const initResponse = () =>
26
+ Response.json({
27
+ sessionId: "session-1",
28
+ expiresAt: new Date(Date.now() + 600_000).toISOString(),
29
+ flow: { id: "agent-1", name: "Agent", description: null },
30
+ config: { welcomeMessage: null, placeholder: "Ask...", theme: null },
31
+ });
32
+
33
+ const runtypeVoice = (
34
+ extra: Partial<AgentWidgetVoiceRecognitionConfig> = {}
35
+ ): AgentWidgetVoiceRecognitionConfig => ({
36
+ enabled: true,
37
+ provider: { type: "runtype", runtype: { agentId: "agent-1" } },
38
+ ...extra,
39
+ });
40
+
41
+ function mountWidget(config: Partial<AgentWidgetConfig>) {
42
+ const prewarms: Array<{ url: string; init?: RequestInit }> = [];
43
+ const inits: string[] = [];
44
+ vi.stubGlobal(
45
+ "fetch",
46
+ vi.fn(async (url: string, init?: RequestInit) => {
47
+ if (url.endsWith("/voice/prewarm")) {
48
+ prewarms.push({ url, init });
49
+ return new Response(null, { status: 204 });
50
+ }
51
+ if (url.endsWith("/v1/client/init")) {
52
+ inits.push(url);
53
+ return initResponse();
54
+ }
55
+ return new Response('data: {"type":"done"}\n\n', {
56
+ headers: { "Content-Type": "text/event-stream" },
57
+ });
58
+ })
59
+ );
60
+ const mount = document.createElement("div");
61
+ document.body.appendChild(mount);
62
+ controller = createAgentExperience(mount, {
63
+ apiUrl: "https://api.example.com",
64
+ agentId: "agent-1",
65
+ launcher: { enabled: false },
66
+ persistState: false,
67
+ ...config,
68
+ } as AgentWidgetConfig);
69
+ const mic = () => mount.querySelector<HTMLButtonElement>("[data-persona-composer-mic]")!;
70
+ const hover = () => mic().dispatchEvent(new Event("pointerover", { bubbles: true }));
71
+ return { mount, mic, hover, prewarms, inits };
72
+ }
73
+
74
+ // Lets the lazy voice runtime install and any fire-and-forget fetch run.
75
+ const settle = async () => {
76
+ for (let i = 0; i < 5; i += 1) await new Promise((r) => setTimeout(r, 0));
77
+ };
78
+
79
+ describe("voiceRecognition.prewarm: default", () => {
80
+ it("prewarms once on mic hover in client token mode (latched)", async () => {
81
+ const w = mountWidget({ clientToken: "ct_test", voiceRecognition: runtypeVoice() });
82
+ await settle();
83
+ expect(w.prewarms).toHaveLength(0);
84
+
85
+ w.hover();
86
+ await vi.waitFor(() => expect(w.prewarms).toHaveLength(1));
87
+ expect(w.prewarms[0].url).toBe(
88
+ "https://api.example.com/v1/client/agents/agent-1/voice/prewarm"
89
+ );
90
+ expect(w.prewarms[0].init?.headers).toEqual({ Authorization: "Bearer ct_test" });
91
+
92
+ w.hover();
93
+ w.mic().dispatchEvent(new Event("pointerdown", { bubbles: true }));
94
+ w.mic().dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
95
+ await settle();
96
+ expect(w.prewarms).toHaveLength(1);
97
+ });
98
+
99
+ it("also prewarms on keyboard focus", async () => {
100
+ const w = mountWidget({ clientToken: "ct_test", voiceRecognition: runtypeVoice() });
101
+ await settle();
102
+ w.mic().dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
103
+ await vi.waitFor(() => expect(w.prewarms).toHaveLength(1));
104
+ });
105
+
106
+ it("ignores hover elsewhere in the widget", async () => {
107
+ const w = mountWidget({ clientToken: "ct_test", voiceRecognition: runtypeVoice() });
108
+ await settle();
109
+ w.mount
110
+ .querySelector("[data-persona-composer-input]")!
111
+ .dispatchEvent(new Event("pointerover", { bubbles: true }));
112
+ await settle();
113
+ expect(w.prewarms).toHaveLength(0);
114
+ });
115
+
116
+ it("does not auto-prewarm outside client token mode", async () => {
117
+ const w = mountWidget({
118
+ voiceRecognition: {
119
+ enabled: true,
120
+ provider: { type: "runtype", runtype: { agentId: "agent-1", clientToken: "ct_voice" } },
121
+ },
122
+ });
123
+ await settle();
124
+ w.hover();
125
+ await settle();
126
+ expect(w.prewarms).toHaveLength(0);
127
+ });
128
+
129
+ it("warms the client session on the browser (Web Speech) path", async () => {
130
+ vi.stubGlobal("SpeechRecognition", vi.fn());
131
+ const w = mountWidget({ clientToken: "ct_test", voiceRecognition: { enabled: true } });
132
+ await settle();
133
+ expect(w.inits).toHaveLength(0);
134
+ w.hover();
135
+ await vi.waitFor(() => expect(w.inits).toHaveLength(1));
136
+ expect(w.prewarms).toHaveLength(0);
137
+ });
138
+ });
139
+
140
+ describe("voiceRecognition.prewarm: explicit settings", () => {
141
+ it("false opts out", async () => {
142
+ const w = mountWidget({
143
+ clientToken: "ct_test",
144
+ voiceRecognition: runtypeVoice({ prewarm: false }),
145
+ });
146
+ await settle();
147
+ w.hover();
148
+ await settle();
149
+ expect(w.prewarms).toHaveLength(0);
150
+ });
151
+
152
+ it("'hover' enables it outside client token mode", async () => {
153
+ const w = mountWidget({
154
+ voiceRecognition: {
155
+ enabled: true,
156
+ prewarm: "hover",
157
+ provider: { type: "runtype", runtype: { agentId: "agent-1", clientToken: "ct_voice" } },
158
+ },
159
+ });
160
+ await settle();
161
+ w.hover();
162
+ await vi.waitFor(() => expect(w.prewarms).toHaveLength(1));
163
+ expect(w.prewarms[0].init?.headers).toEqual({ Authorization: "Bearer ct_voice" });
164
+ });
165
+
166
+ it("a custom hook receives warm and the mic button, replaces hover, and is cleaned up", async () => {
167
+ const cleanup = vi.fn();
168
+ let context: Parameters<AgentWidgetVoicePrewarmHook>[0] | undefined;
169
+ const hook = vi.fn((ctx: Parameters<AgentWidgetVoicePrewarmHook>[0]) => {
170
+ context = ctx;
171
+ return cleanup;
172
+ });
173
+ const w = mountWidget({
174
+ clientToken: "ct_test",
175
+ voiceRecognition: runtypeVoice({ prewarm: hook }),
176
+ });
177
+ await settle();
178
+ expect(hook).toHaveBeenCalledOnce();
179
+ expect(context?.micButton).toBe(w.mic());
180
+ expect(context?.mount).toBe(w.mount);
181
+
182
+ w.hover();
183
+ await settle();
184
+ expect(w.prewarms).toHaveLength(0);
185
+
186
+ context!.warm();
187
+ await vi.waitFor(() => expect(w.prewarms).toHaveLength(1));
188
+
189
+ controller!.destroy();
190
+ controller = undefined;
191
+ expect(cleanup).toHaveBeenCalledOnce();
192
+ });
193
+
194
+ it("update() swaps the hook and turning prewarm off releases it", async () => {
195
+ const cleanupA = vi.fn();
196
+ const hookA = vi.fn(() => cleanupA);
197
+ const hookB = vi.fn();
198
+ mountWidget({ clientToken: "ct_test", voiceRecognition: runtypeVoice({ prewarm: hookA }) });
199
+ await settle();
200
+
201
+ controller!.update({ voiceRecognition: runtypeVoice({ prewarm: hookB }) });
202
+ expect(cleanupA).toHaveBeenCalledOnce();
203
+ expect(hookB).toHaveBeenCalledOnce();
204
+
205
+ controller!.update({ voiceRecognition: runtypeVoice({ prewarm: false }) });
206
+ expect(hookB).toHaveBeenCalledOnce();
207
+ expect(hookA).toHaveBeenCalledOnce();
208
+ });
209
+
210
+ it("a throwing hook does not break mount", async () => {
211
+ const w = mountWidget({
212
+ clientToken: "ct_test",
213
+ voiceRecognition: runtypeVoice({
214
+ prewarm: () => {
215
+ throw new Error("host bug");
216
+ },
217
+ }),
218
+ });
219
+ await settle();
220
+ expect(w.mic()).toBeTruthy();
221
+ });
222
+ });