@yak-io/javascript 0.10.1 → 0.11.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/index.cjs ADDED
@@ -0,0 +1,2043 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ EMBED_PROTOCOL_VERSION: () => EMBED_PROTOCOL_VERSION,
24
+ INITIAL_VOICE_MACHINE: () => INITIAL_VOICE_MACHINE,
25
+ YakClient: () => YakClient,
26
+ YakEmbed: () => YakEmbed,
27
+ YakVoiceSession: () => YakVoiceSession,
28
+ createYakServerAdapter: () => createYakServerAdapter,
29
+ createYakToolset: () => createYakToolset,
30
+ disableYakLogging: () => disableYakLogging,
31
+ enableYakLogging: () => enableYakLogging,
32
+ handleRealtimeMessage: () => handleRealtimeMessage,
33
+ isYakLoggingEnabled: () => isYakLoggingEnabled,
34
+ logger: () => logger,
35
+ voiceReducer: () => voiceReducer
36
+ });
37
+ module.exports = __toCommonJS(index_exports);
38
+
39
+ // src/logger.ts
40
+ var STORAGE_KEY = "yakLogging";
41
+ function isLoggingEnabled() {
42
+ if (typeof window === "undefined") {
43
+ return false;
44
+ }
45
+ if (typeof window.__YAK_LOGGING_ENABLED__ === "boolean") {
46
+ return window.__YAK_LOGGING_ENABLED__;
47
+ }
48
+ try {
49
+ return localStorage.getItem(STORAGE_KEY) === "true";
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+ function isYakLoggingEnabled() {
55
+ return isLoggingEnabled();
56
+ }
57
+ function enableYakLogging() {
58
+ if (typeof window === "undefined") {
59
+ return;
60
+ }
61
+ window.__YAK_LOGGING_ENABLED__ = true;
62
+ try {
63
+ localStorage.setItem(STORAGE_KEY, "true");
64
+ } catch {
65
+ }
66
+ console.info("[yak] Logging enabled");
67
+ }
68
+ function disableYakLogging() {
69
+ if (typeof window === "undefined") {
70
+ return;
71
+ }
72
+ window.__YAK_LOGGING_ENABLED__ = false;
73
+ try {
74
+ localStorage.removeItem(STORAGE_KEY);
75
+ } catch {
76
+ }
77
+ console.info("[yak] Logging disabled");
78
+ }
79
+ if (typeof window !== "undefined") {
80
+ window.enableYakLogging = enableYakLogging;
81
+ window.disableYakLogging = disableYakLogging;
82
+ }
83
+ var logger = {
84
+ debug: (message, data) => {
85
+ if (isLoggingEnabled()) {
86
+ if (data !== void 0) {
87
+ console.log(`[yak-host] ${message}`, data);
88
+ } else {
89
+ console.log(`[yak-host] ${message}`);
90
+ }
91
+ }
92
+ },
93
+ info: (message, data) => {
94
+ if (isLoggingEnabled()) {
95
+ if (data !== void 0) {
96
+ console.info(`[yak-host] ${message}`, data);
97
+ } else {
98
+ console.info(`[yak-host] ${message}`);
99
+ }
100
+ }
101
+ },
102
+ warn: (message, data) => {
103
+ if (data !== void 0) {
104
+ console.warn(`[yak-host] ${message}`, data);
105
+ } else {
106
+ console.warn(`[yak-host] ${message}`);
107
+ }
108
+ },
109
+ error: (message, data) => {
110
+ if (data !== void 0) {
111
+ console.error(`[yak-host] ${message}`, data);
112
+ } else {
113
+ console.error(`[yak-host] ${message}`);
114
+ }
115
+ }
116
+ };
117
+
118
+ // src/page-context.ts
119
+ function extractPageText() {
120
+ if (typeof document === "undefined") return "";
121
+ const bodyClone = document.body.cloneNode(true);
122
+ const unwantedSelectors = [
123
+ "script",
124
+ "style",
125
+ "noscript",
126
+ "iframe",
127
+ "[style*='display: none']",
128
+ "[style*='display:none']",
129
+ "[hidden]",
130
+ ".yak-chat-widget"
131
+ ];
132
+ for (const selector of unwantedSelectors) {
133
+ const elements = bodyClone.querySelectorAll(selector);
134
+ for (const el of elements) {
135
+ el.remove();
136
+ }
137
+ }
138
+ let text = bodyClone.textContent || bodyClone.innerText || "";
139
+ text = text.replace(/\s+/g, " ").trim();
140
+ const maxLength = 1e5;
141
+ if (text.length > maxLength) {
142
+ text = `${text.substring(0, maxLength)}... [truncated]`;
143
+ }
144
+ return text;
145
+ }
146
+ function extractPageContext() {
147
+ if (typeof window === "undefined") {
148
+ return {
149
+ url: "",
150
+ title: "",
151
+ text: "",
152
+ timestamp: Date.now()
153
+ };
154
+ }
155
+ return {
156
+ url: window.location.href,
157
+ title: document.title,
158
+ text: extractPageText(),
159
+ timestamp: Date.now()
160
+ };
161
+ }
162
+ function debounce(func, wait) {
163
+ let timeout = null;
164
+ return function executedFunction(...args) {
165
+ const later = () => {
166
+ timeout = null;
167
+ func(...args);
168
+ };
169
+ if (timeout) {
170
+ clearTimeout(timeout);
171
+ }
172
+ timeout = setTimeout(later, wait);
173
+ };
174
+ }
175
+
176
+ // src/version.ts
177
+ var EMBED_PROTOCOL_VERSION = "1";
178
+
179
+ // src/client.ts
180
+ var SESSION_STORAGE_KEY = (appId) => `yak:session:${appId}`;
181
+ var CONVERSATION_POINTER_KEY = (appId) => `yak:conversation:${appId}`;
182
+ function readStoredSessionToken(appId) {
183
+ if (typeof window === "undefined" || typeof window.localStorage === "undefined") return void 0;
184
+ try {
185
+ return window.localStorage.getItem(SESSION_STORAGE_KEY(appId)) ?? void 0;
186
+ } catch {
187
+ return void 0;
188
+ }
189
+ }
190
+ function writeStoredSessionToken(appId, token) {
191
+ if (typeof window === "undefined" || typeof window.localStorage === "undefined") return;
192
+ try {
193
+ window.localStorage.setItem(SESSION_STORAGE_KEY(appId), token);
194
+ } catch {
195
+ }
196
+ }
197
+ function readStoredConversationPointer(appId) {
198
+ if (typeof window === "undefined" || typeof window.localStorage === "undefined") return void 0;
199
+ try {
200
+ return window.localStorage.getItem(CONVERSATION_POINTER_KEY(appId)) ?? void 0;
201
+ } catch {
202
+ return void 0;
203
+ }
204
+ }
205
+ function writeStoredConversationPointer(appId, pointer) {
206
+ if (typeof window === "undefined" || typeof window.localStorage === "undefined") return;
207
+ try {
208
+ window.localStorage.setItem(CONVERSATION_POINTER_KEY(appId), pointer);
209
+ } catch {
210
+ }
211
+ }
212
+ function clearStoredConversationPointer(appId) {
213
+ if (typeof window === "undefined" || typeof window.localStorage === "undefined") return;
214
+ try {
215
+ window.localStorage.removeItem(CONVERSATION_POINTER_KEY(appId));
216
+ } catch {
217
+ }
218
+ }
219
+ var DEFAULT_CHAT_ORIGIN = "https://chat.yak.io";
220
+ function getDefaultIframeOrigin() {
221
+ if (typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") && typeof window.__YAK_INTERNAL_DEV__ !== "undefined") {
222
+ return "http://localhost:3001";
223
+ }
224
+ return DEFAULT_CHAT_ORIGIN;
225
+ }
226
+ var YakClient = class {
227
+ config;
228
+ iframeWindow = null;
229
+ isWidgetOpen = false;
230
+ readyTarget = null;
231
+ unexpectedOriginLogged = false;
232
+ lastUrl = " ";
233
+ debouncedSendContext;
234
+ observer = null;
235
+ constructor(config) {
236
+ this.config = config;
237
+ this.debouncedSendContext = debounce(() => {
238
+ logger.debug("DOM mutation detected, sending page context");
239
+ this.sendPageContext();
240
+ }, 2e3);
241
+ }
242
+ updateConfig(newConfig) {
243
+ this.config = { ...this.config, ...newConfig };
244
+ if (this.readyTarget && (this.config.chatConfig || this.config.user)) {
245
+ this.sendConfigToIframe(this.readyTarget.window, this.readyTarget.origin);
246
+ }
247
+ }
248
+ /**
249
+ * Get the iframe origin URL (base URL for the chat widget). Recomputed on
250
+ * each call so environment-dependent defaults resolve correctly.
251
+ */
252
+ getIframeOrigin() {
253
+ return this.config.origin ?? getDefaultIframeOrigin();
254
+ }
255
+ /**
256
+ * Get the full iframe embed URL for the chatbot
257
+ *
258
+ * @example
259
+ * ```ts
260
+ * const client = new YakClient({ appId: "my-app" });
261
+ * const iframeSrc = client.getEmbedUrl();
262
+ * // Returns: "https://chat.yak.io/embed/v1/my-app"
263
+ * ```
264
+ */
265
+ getEmbedUrl() {
266
+ const origin = this.getIframeOrigin();
267
+ const baseUrl = `${origin}/embed/v${EMBED_PROTOCOL_VERSION}/${encodeURIComponent(this.config.appId)}`;
268
+ const params = new URLSearchParams();
269
+ const theme = this.config.theme;
270
+ if (theme?.colorMode && theme.colorMode !== "system") {
271
+ params.set("colorMode", theme.colorMode);
272
+ }
273
+ this.appendThemeColors(params, theme?.light, "light");
274
+ this.appendThemeColors(params, theme?.dark, "dark");
275
+ if (isYakLoggingEnabled()) {
276
+ params.set("yakDebug", "1");
277
+ }
278
+ const queryString = params.toString();
279
+ return queryString ? `${baseUrl}?${queryString}` : baseUrl;
280
+ }
281
+ /**
282
+ * Append theme color parameters to a URLSearchParams object
283
+ */
284
+ appendThemeColors(params, colors, prefix) {
285
+ if (!colors) return;
286
+ const map = [
287
+ [`${prefix}Bg`, colors.background],
288
+ [`${prefix}Border`, colors.border],
289
+ [`${prefix}MessageBg`, colors.messageBackground],
290
+ [`${prefix}Placeholder`, colors.placeholderColor],
291
+ [`${prefix}SubmitBtn`, colors.submitButtonColor],
292
+ [`${prefix}SubmitBtnText`, colors.submitButtonTextColor],
293
+ [`${prefix}HeaderIcon`, colors.headerIconColor]
294
+ ];
295
+ for (const [key, value] of map) {
296
+ if (value) params.set(key, value);
297
+ }
298
+ }
299
+ /**
300
+ * Get the app ID
301
+ */
302
+ getAppId() {
303
+ return this.config.appId;
304
+ }
305
+ /**
306
+ * Get the current theme configuration
307
+ */
308
+ getTheme() {
309
+ return this.config.theme;
310
+ }
311
+ /**
312
+ * Send a prompt message to the chatbot iframe
313
+ * Note: The iframe must be ready to receive messages (onReady callback must have fired)
314
+ *
315
+ * @example
316
+ * ```ts
317
+ * const client = new YakClient({ appId: "my-app", onReady: () => {
318
+ * client.sendPrompt("Help me with my order");
319
+ * }});
320
+ * ```
321
+ */
322
+ sendPrompt(prompt) {
323
+ if (!this.iframeWindow) {
324
+ logger.warn("Cannot send prompt: iframe not ready");
325
+ return;
326
+ }
327
+ const message = {
328
+ type: "yak:prompt",
329
+ payload: { prompt }
330
+ };
331
+ this.iframeWindow.postMessage(message, this.getIframeOrigin());
332
+ }
333
+ /**
334
+ * Send a focus request to the chatbot iframe
335
+ * This will focus the chat input field
336
+ */
337
+ sendFocus() {
338
+ if (!this.iframeWindow) {
339
+ logger.warn("Cannot send focus: iframe not ready");
340
+ return;
341
+ }
342
+ const message = {
343
+ type: "yak:focus"
344
+ };
345
+ this.iframeWindow.postMessage(message, this.getIframeOrigin());
346
+ }
347
+ /**
348
+ * Check if the iframe is ready to receive messages
349
+ */
350
+ isReady() {
351
+ return this.readyTarget !== null;
352
+ }
353
+ setIframeWindow(window2) {
354
+ this.iframeWindow = window2;
355
+ if (!window2) {
356
+ this.readyTarget = null;
357
+ }
358
+ }
359
+ setWidgetOpen(isOpen) {
360
+ this.isWidgetOpen = isOpen;
361
+ if (isOpen && this.readyTarget && this.config.chatConfig) {
362
+ this.sendConfigToIframe(this.readyTarget.window, this.readyTarget.origin);
363
+ }
364
+ if (isOpen) {
365
+ this.startObserving();
366
+ } else {
367
+ this.stopObserving();
368
+ }
369
+ }
370
+ mount() {
371
+ if (typeof window !== "undefined") {
372
+ window.addEventListener("message", this.handleMessage);
373
+ window.addEventListener("popstate", this.handlePopState);
374
+ }
375
+ }
376
+ unmount() {
377
+ if (typeof window !== "undefined") {
378
+ window.removeEventListener("message", this.handleMessage);
379
+ window.removeEventListener("popstate", this.handlePopState);
380
+ }
381
+ this.stopObserving();
382
+ }
383
+ startObserving() {
384
+ if (typeof window === "undefined" || !this.iframeWindow) return;
385
+ const currentUrl = window.location.href;
386
+ if (currentUrl !== this.lastUrl) {
387
+ this.lastUrl = currentUrl;
388
+ logger.debug("URL changed, sending page context");
389
+ this.sendPageContext();
390
+ }
391
+ if (this.observer) return;
392
+ this.observer = new MutationObserver((mutations) => {
393
+ const hasSubstantialChanges = mutations.some(
394
+ (mutation) => mutation.type === "childList" && (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)
395
+ );
396
+ if (hasSubstantialChanges) {
397
+ this.debouncedSendContext();
398
+ }
399
+ });
400
+ this.observer.observe(document.body, {
401
+ childList: true,
402
+ subtree: true
403
+ });
404
+ }
405
+ stopObserving() {
406
+ if (this.observer) {
407
+ this.observer.disconnect();
408
+ this.observer = null;
409
+ }
410
+ }
411
+ handlePopState = () => {
412
+ logger.debug("Navigation detected, sending page context");
413
+ this.sendPageContext();
414
+ };
415
+ handleMessage = (event) => {
416
+ if (typeof window === "undefined") return;
417
+ if (!this.isWidgetOpen && event.data?.type !== "yak:ready") {
418
+ return;
419
+ }
420
+ const hostOrigin = window.location.origin;
421
+ const allowedOrigins = /* @__PURE__ */ new Set();
422
+ allowedOrigins.add(this.getIframeOrigin());
423
+ if (hostOrigin) {
424
+ allowedOrigins.add(hostOrigin);
425
+ }
426
+ if (!allowedOrigins.has(event.origin)) {
427
+ if (!this.unexpectedOriginLogged) {
428
+ logger.warn(
429
+ `Ignoring message from unexpected origin: ${event.origin}, allowed: ${Array.from(allowedOrigins).join(", ")}`
430
+ );
431
+ this.unexpectedOriginLogged = true;
432
+ }
433
+ return;
434
+ }
435
+ if (!event.data || typeof event.data !== "object" || !("type" in event.data)) {
436
+ const data = event.data;
437
+ const isReactDevTools = data?.source === "react-devtools-content-script" || data?.source === "react-devtools-bridge" || data?.source === "react-devtools-inject-backend";
438
+ const isReduxDevTools = data?.source === "@devtools-page";
439
+ if (isReactDevTools || isReduxDevTools) {
440
+ return;
441
+ }
442
+ return;
443
+ }
444
+ const message = event.data;
445
+ const targetWindow = (event.source && "postMessage" in event.source ? event.source : null) ?? this.iframeWindow;
446
+ const targetOrigin = this.getIframeOrigin();
447
+ logger.debug("Message received from iframe:", message.type);
448
+ switch (message.type) {
449
+ case "yak:ready": {
450
+ logger.debug("Iframe ready, sending config");
451
+ if (targetWindow) {
452
+ this.readyTarget = { window: targetWindow, origin: targetOrigin };
453
+ this.sendConfigToIframe(targetWindow, targetOrigin);
454
+ setTimeout(() => this.sendPageContext(), 100);
455
+ setTimeout(() => this.config.onReady?.(), 200);
456
+ } else {
457
+ logger.warn("Unable to send config: iframe window not registered yet");
458
+ }
459
+ break;
460
+ }
461
+ case "yak:tool_call": {
462
+ const { id, name, args } = message.payload;
463
+ void this.handleToolCall(id, name, args);
464
+ break;
465
+ }
466
+ case "yak:redirect": {
467
+ const { path } = message.payload;
468
+ logger.debug("Redirect request received:", path);
469
+ if (!this.isAllowedRedirect(path)) {
470
+ logger.warn("Blocked potentially unsafe redirect:", path);
471
+ break;
472
+ }
473
+ if (this.config.onRedirect) {
474
+ this.config.onRedirect(path);
475
+ } else if (typeof window !== "undefined") {
476
+ window.location.assign(path);
477
+ }
478
+ break;
479
+ }
480
+ case "yak:session": {
481
+ const { sessionToken } = message.payload;
482
+ logger.debug("Session token received from iframe; persisting");
483
+ writeStoredSessionToken(this.config.appId, sessionToken);
484
+ break;
485
+ }
486
+ case "yak:conversation": {
487
+ const { pointer } = message.payload;
488
+ if (pointer === null) {
489
+ logger.debug("Conversation pointer cleared by iframe");
490
+ clearStoredConversationPointer(this.config.appId);
491
+ } else {
492
+ logger.debug("Conversation pointer received from iframe; persisting");
493
+ writeStoredConversationPointer(this.config.appId, pointer);
494
+ }
495
+ break;
496
+ }
497
+ case "yak:close": {
498
+ logger.debug("Close message received from iframe");
499
+ this.config.onClose?.();
500
+ break;
501
+ }
502
+ default:
503
+ logger.debug("Unknown message type:", message.type);
504
+ break;
505
+ }
506
+ };
507
+ sendConfigToIframe(targetWindow, targetOrigin) {
508
+ const loggingEnabled = typeof window !== "undefined" && typeof window.__YAK_LOGGING_ENABLED__ === "boolean" ? window.__YAK_LOGGING_ENABLED__ : void 0;
509
+ const storedSessionToken = readStoredSessionToken(this.config.appId);
510
+ const storedConversationPointer = readStoredConversationPointer(this.config.appId);
511
+ const configMessage = {
512
+ type: "yak:config",
513
+ payload: {
514
+ version: EMBED_PROTOCOL_VERSION,
515
+ appId: this.config.appId,
516
+ theme: this.config.theme,
517
+ toolManifest: this.config.chatConfig?.tools ?? void 0,
518
+ routeManifest: this.config.chatConfig?.routes ?? void 0,
519
+ options: this.config.options,
520
+ loggingEnabled,
521
+ user: this.config.user,
522
+ sessionToken: storedSessionToken,
523
+ conversationPointer: storedConversationPointer
524
+ }
525
+ };
526
+ logger.debug("Posting config to iframe origin:", {
527
+ origin: targetOrigin,
528
+ version: EMBED_PROTOCOL_VERSION,
529
+ hasToolManifest: Boolean(this.config.chatConfig?.tools),
530
+ toolCount: this.config.chatConfig?.tools?.tools.length ?? 0,
531
+ hasRouteManifest: Boolean(this.config.chatConfig?.routes)
532
+ });
533
+ targetWindow.postMessage(configMessage, targetOrigin);
534
+ }
535
+ sendPageContext() {
536
+ if (!this.iframeWindow) return;
537
+ try {
538
+ const pageContext = extractPageContext();
539
+ const message = {
540
+ type: "yak:page_context",
541
+ payload: pageContext
542
+ };
543
+ logger.debug("Sending page context to iframe:", {
544
+ url: pageContext.url,
545
+ title: pageContext.title,
546
+ textLength: pageContext.text.length
547
+ });
548
+ this.iframeWindow.postMessage(message, this.getIframeOrigin());
549
+ } catch (error) {
550
+ logger.error("Error extracting page context:", error);
551
+ }
552
+ }
553
+ async handleToolCall(id, name, args) {
554
+ logger.debug(`Tool call received: ${name}`, { id, args });
555
+ if (!this.config.onToolCall) {
556
+ logger.error("Tool call received but no onToolCall handler configured");
557
+ this.sendToolResultToIframe(id, false, void 0, "No tool call handler configured");
558
+ this.config.onToolCallComplete?.({
559
+ name,
560
+ args,
561
+ ok: false,
562
+ error: "No tool call handler configured"
563
+ });
564
+ return;
565
+ }
566
+ try {
567
+ const result = await this.config.onToolCall(name, args);
568
+ logger.debug(`Tool call succeeded: ${name}`, { id });
569
+ this.sendToolResultToIframe(id, true, result);
570
+ this.config.onToolCallComplete?.({ name, args, ok: true, result });
571
+ } catch (error) {
572
+ const errorMessage = this.extractErrorMessage(error);
573
+ logger.debug(`Tool call failed: ${name}`, { id, error });
574
+ this.sendToolResultToIframe(id, false, void 0, errorMessage);
575
+ this.config.onToolCallComplete?.({ name, args, ok: false, error: errorMessage });
576
+ }
577
+ }
578
+ sendToolResultToIframe(id, ok, result, error) {
579
+ if (!this.iframeWindow) return;
580
+ if (ok) {
581
+ const safeResult = this.toSerializable(result);
582
+ const message = {
583
+ type: "yak:tool_result",
584
+ payload: { id, ok: true, result: safeResult }
585
+ };
586
+ this.iframeWindow.postMessage(message, this.getIframeOrigin());
587
+ } else {
588
+ const message = {
589
+ type: "yak:tool_result",
590
+ payload: { id, ok: false, error: error ?? "Unknown error" }
591
+ };
592
+ this.iframeWindow.postMessage(message, this.getIframeOrigin());
593
+ }
594
+ }
595
+ /**
596
+ * Convert a value to a serializable form by stripping functions and other non-cloneable values.
597
+ * Uses JSON.parse(JSON.stringify()) which handles most cases.
598
+ */
599
+ toSerializable(value) {
600
+ if (value === void 0 || value === null) {
601
+ return value;
602
+ }
603
+ try {
604
+ return JSON.parse(JSON.stringify(value));
605
+ } catch {
606
+ logger.warn("Failed to serialize tool result, returning string representation");
607
+ return String(value);
608
+ }
609
+ }
610
+ extractErrorMessage(error) {
611
+ if (error instanceof Error) {
612
+ return error.message;
613
+ }
614
+ if (typeof error === "string") {
615
+ return error;
616
+ }
617
+ return "Unknown error";
618
+ }
619
+ /**
620
+ * Validates that a redirect path is safe (relative path or same-origin).
621
+ * Blocks absolute URLs to external domains to prevent open redirect attacks.
622
+ */
623
+ isAllowedRedirect(path) {
624
+ if (path.startsWith("/") && !path.startsWith("//")) {
625
+ return true;
626
+ }
627
+ if (path.startsWith("#") || path.startsWith("?")) {
628
+ return true;
629
+ }
630
+ if (typeof window !== "undefined") {
631
+ try {
632
+ const url = new URL(path, window.location.origin);
633
+ return url.origin === window.location.origin;
634
+ } catch {
635
+ return false;
636
+ }
637
+ }
638
+ return false;
639
+ }
640
+ };
641
+
642
+ // src/voice-machine.ts
643
+ var INITIAL_VOICE_MACHINE = { state: "idle" };
644
+ function voiceReducer(machine, event) {
645
+ switch (event.type) {
646
+ case "start":
647
+ return machine.state === "idle" ? { state: "connecting" } : machine;
648
+ case "connected":
649
+ return machine.state === "connecting" ? { state: "listening" } : machine;
650
+ case "response_requested":
651
+ return machine.state === "listening" ? { state: "thinking" } : machine;
652
+ case "speech_started":
653
+ if (machine.state === "idle" || machine.state === "error") return machine;
654
+ return { state: "listening" };
655
+ case "speech_stopped":
656
+ return machine.state === "listening" ? { state: "thinking" } : machine;
657
+ case "audio_delta":
658
+ if (machine.state === "thinking" || machine.state === "speaking") {
659
+ return { state: "speaking" };
660
+ }
661
+ return machine;
662
+ case "audio_stopped":
663
+ return machine.state === "speaking" ? { state: "listening" } : machine;
664
+ case "stop":
665
+ return { state: "idle" };
666
+ case "error":
667
+ return { state: "error", errorMessage: event.message };
668
+ default: {
669
+ const _exhaustive = event;
670
+ void _exhaustive;
671
+ return machine;
672
+ }
673
+ }
674
+ }
675
+ function isFunctionCall(item) {
676
+ return item.type === "function_call";
677
+ }
678
+ function parseToolArgs(raw) {
679
+ if (!raw) return {};
680
+ try {
681
+ return JSON.parse(raw);
682
+ } catch {
683
+ return {};
684
+ }
685
+ }
686
+ async function dispatchFunctionCall(call, ctx) {
687
+ const callId = call.call_id;
688
+ const name = call.name;
689
+ if (!callId || !name) return;
690
+ if (ctx.isDispatched(callId)) return;
691
+ ctx.markDispatched(callId);
692
+ const args = parseToolArgs(call.arguments);
693
+ let output;
694
+ try {
695
+ const result = await ctx.dispatchToolCall(name, args);
696
+ output = JSON.stringify(result ?? null);
697
+ } catch (error) {
698
+ output = JSON.stringify({
699
+ error: error instanceof Error ? error.message : "Tool execution failed"
700
+ });
701
+ }
702
+ ctx.sendData({
703
+ type: "conversation.item.create",
704
+ item: { type: "function_call_output", call_id: callId, output }
705
+ });
706
+ ctx.sendData({ type: "response.create" });
707
+ }
708
+ function extractUsage(raw) {
709
+ if (!raw) return null;
710
+ const usage = {};
711
+ if (typeof raw.input_tokens === "number") usage.inputTokens = raw.input_tokens;
712
+ if (typeof raw.output_tokens === "number") usage.outputTokens = raw.output_tokens;
713
+ const inDetails = raw.input_token_details;
714
+ if (inDetails) {
715
+ if (typeof inDetails.cached_tokens === "number") {
716
+ usage.cachedInputTokens = inDetails.cached_tokens;
717
+ }
718
+ if (typeof inDetails.audio_tokens === "number") {
719
+ usage.audioInputTokens = inDetails.audio_tokens;
720
+ }
721
+ if (typeof inDetails.text_tokens === "number") {
722
+ usage.textInputTokens = inDetails.text_tokens;
723
+ }
724
+ }
725
+ const outDetails = raw.output_token_details;
726
+ if (outDetails) {
727
+ if (typeof outDetails.audio_tokens === "number") {
728
+ usage.audioOutputTokens = outDetails.audio_tokens;
729
+ }
730
+ if (typeof outDetails.text_tokens === "number") {
731
+ usage.textOutputTokens = outDetails.text_tokens;
732
+ }
733
+ }
734
+ return Object.keys(usage).length > 0 ? usage : null;
735
+ }
736
+ async function handleResponseDone(response, ctx) {
737
+ const usage = extractUsage(response?.usage);
738
+ if (usage && ctx.recordUsage) {
739
+ try {
740
+ ctx.recordUsage(usage);
741
+ } catch {
742
+ }
743
+ }
744
+ const calls = (response?.output ?? []).filter(isFunctionCall);
745
+ for (const call of calls) {
746
+ await dispatchFunctionCall(call, ctx);
747
+ }
748
+ }
749
+ async function handleRealtimeMessage(raw, ctx) {
750
+ let message;
751
+ try {
752
+ message = JSON.parse(raw);
753
+ } catch {
754
+ return;
755
+ }
756
+ switch (message.type) {
757
+ case "input_audio_buffer.speech_started":
758
+ ctx.send({ type: "speech_started" });
759
+ return;
760
+ case "input_audio_buffer.speech_stopped":
761
+ ctx.send({ type: "speech_stopped" });
762
+ return;
763
+ case "response.output_audio_transcript.delta":
764
+ case "response.audio_transcript.delta":
765
+ ctx.send({ type: "audio_delta" });
766
+ return;
767
+ case "output_audio_buffer.stopped":
768
+ case "response.output_audio_buffer.stopped":
769
+ ctx.send({ type: "audio_stopped" });
770
+ return;
771
+ case "response.done":
772
+ await handleResponseDone(message.response, ctx);
773
+ return;
774
+ case "error":
775
+ ctx.send({
776
+ type: "error",
777
+ message: message.error?.message ?? "Voice session error"
778
+ });
779
+ return;
780
+ default:
781
+ return;
782
+ }
783
+ }
784
+
785
+ // src/tool-name.ts
786
+ var MAX_TOOL_NAME_LENGTH = 64;
787
+ function generateToolId(originalName) {
788
+ const sanitized = originalName.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, MAX_TOOL_NAME_LENGTH);
789
+ return sanitized.length > 0 ? sanitized : "tool";
790
+ }
791
+ var MCP_NAMESPACE_PREFIX = "mcp__";
792
+ function uniqueToolId(originalName, used) {
793
+ let base = generateToolId(originalName);
794
+ if (base.startsWith(MCP_NAMESPACE_PREFIX)) {
795
+ base = `t_${base}`.slice(0, MAX_TOOL_NAME_LENGTH);
796
+ }
797
+ if (!used.has(base)) return base;
798
+ for (let i = 2; i < 1e3; i++) {
799
+ const suffix = `_${i}`;
800
+ const candidate = `${base.slice(0, MAX_TOOL_NAME_LENGTH - suffix.length)}${suffix}`;
801
+ if (!used.has(candidate)) return candidate;
802
+ }
803
+ return `${base.slice(0, 56)}_${used.size}`;
804
+ }
805
+
806
+ // src/voice-session.ts
807
+ var DEFAULT_REALTIME_MODEL = "gpt-realtime";
808
+ var REALTIME_CALLS_URL = "https://api.openai.com/v1/realtime/calls";
809
+ var DEFAULT_API_ORIGIN = "https://chat.yak.io";
810
+ function getDefaultApiOrigin() {
811
+ if (typeof window !== "undefined" && (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") && typeof window.__YAK_INTERNAL_DEV__ !== "undefined") {
812
+ return "http://localhost:3001";
813
+ }
814
+ return DEFAULT_API_ORIGIN;
815
+ }
816
+ var EMPTY_RESOURCES = {
817
+ pc: null,
818
+ dataChannel: null,
819
+ micStream: null,
820
+ audioElement: null,
821
+ voiceSessionId: null
822
+ };
823
+ function emptyUsage() {
824
+ return {
825
+ inputTokens: 0,
826
+ cachedInputTokens: 0,
827
+ outputTokens: 0,
828
+ audioInputTokens: 0,
829
+ audioOutputTokens: 0,
830
+ textInputTokens: 0,
831
+ textOutputTokens: 0,
832
+ responseCount: 0
833
+ };
834
+ }
835
+ var YakVoiceSession = class {
836
+ config;
837
+ machine = INITIAL_VOICE_MACHINE;
838
+ resources = EMPTY_RESOURCES;
839
+ dispatchedCallIds = /* @__PURE__ */ new Set();
840
+ listeners = /* @__PURE__ */ new Set();
841
+ pageHideHandler = null;
842
+ /** Per-session token totals, accumulated from each `response.done` event. */
843
+ usage = emptyUsage();
844
+ /**
845
+ * Reverse map: hashed tool id (what OpenAI calls back with) → original host
846
+ * tool name (what `onToolCall` expects). Populated on every `start()` from
847
+ * the resolved chat config.
848
+ */
849
+ toolNameById = /* @__PURE__ */ new Map();
850
+ constructor(config) {
851
+ this.config = config;
852
+ this.attachPageHide();
853
+ }
854
+ /**
855
+ * Resolve the API origin lazily on each call. Environment-dependent defaults
856
+ * (e.g. a local chat UI) may not be ready at construction time, so resolving
857
+ * eagerly would risk baking in the production URL.
858
+ */
859
+ get apiOrigin() {
860
+ return this.config.apiOrigin ?? getDefaultApiOrigin();
861
+ }
862
+ /** Update mutable config fields (handlers, getConfig). */
863
+ updateConfig(patch) {
864
+ this.config = { ...this.config, ...patch };
865
+ }
866
+ getState() {
867
+ return this.machine;
868
+ }
869
+ /**
870
+ * The current API origin (defaults to `https://chat.yak.io`). Useful for
871
+ * building URLs to static assets like the brand logo.
872
+ */
873
+ getApiOrigin() {
874
+ return this.apiOrigin;
875
+ }
876
+ onStateChange(listener) {
877
+ this.listeners.add(listener);
878
+ return () => {
879
+ this.listeners.delete(listener);
880
+ };
881
+ }
882
+ /**
883
+ * Begin a voice session. Should be invoked from a user gesture (button
884
+ * click) so `getUserMedia` and audio playback both have transient activation.
885
+ */
886
+ async start() {
887
+ if (this.machine.state !== "idle") return;
888
+ logger.debug("Voice: start() called");
889
+ this.usage = emptyUsage();
890
+ this.dispatch({ type: "start" });
891
+ let chatConfig = this.config.chatConfig;
892
+ if (this.config.getConfig) {
893
+ try {
894
+ chatConfig = await this.config.getConfig();
895
+ logger.debug("Voice: getConfig() resolved", {
896
+ toolCount: chatConfig?.tools?.tools.length ?? 0,
897
+ routeCount: chatConfig?.routes?.routes.length ?? 0
898
+ });
899
+ } catch (err) {
900
+ logger.warn("Voice: getConfig() failed", err);
901
+ }
902
+ } else if (chatConfig) {
903
+ logger.debug("Voice: using static chatConfig", {
904
+ toolCount: chatConfig.tools?.tools.length ?? 0,
905
+ routeCount: chatConfig.routes?.routes.length ?? 0
906
+ });
907
+ } else {
908
+ logger.debug("Voice: no chatConfig or getConfig \u2014 only built-in tools will be available");
909
+ }
910
+ const decoratedManifest = this.buildDecoratedManifest(chatConfig);
911
+ logger.debug("Voice: decorated tools", {
912
+ ids: decoratedManifest.tools.map((t) => `${t.id}=${t.name}`)
913
+ });
914
+ const pageContext = this.safeExtractPageContext();
915
+ logger.debug("Voice: page context extracted", {
916
+ url: pageContext?.url,
917
+ title: pageContext?.title,
918
+ textLength: pageContext?.text?.length ?? 0
919
+ });
920
+ let mint;
921
+ try {
922
+ logger.debug("Voice: requesting ephemeral token from mint endpoint");
923
+ mint = await this.mintToken(chatConfig, decoratedManifest, pageContext);
924
+ logger.debug("Voice: mint succeeded", {
925
+ voiceSessionId: mint.voiceSessionId,
926
+ expiresAt: mint.expiresAt
927
+ });
928
+ } catch (err) {
929
+ await this.failWith(err instanceof Error ? err.message : "Failed to start voice session");
930
+ return;
931
+ }
932
+ let micStream;
933
+ try {
934
+ logger.debug("Voice: requesting microphone access");
935
+ micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
936
+ logger.debug("Voice: microphone access granted");
937
+ } catch (err) {
938
+ const name = err instanceof Error ? err.name : "";
939
+ const message = name === "NotAllowedError" || name === "PermissionDeniedError" ? "Microphone permission was denied. Enable microphone access in your browser settings to use voice mode." : "Could not access microphone.";
940
+ await this.failWith(message);
941
+ return;
942
+ }
943
+ const pc = new RTCPeerConnection();
944
+ const audioElement = document.createElement("audio");
945
+ audioElement.autoplay = true;
946
+ audioElement.style.display = "none";
947
+ document.body.appendChild(audioElement);
948
+ pc.ontrack = (event) => {
949
+ logger.debug("Voice: pc.ontrack received remote audio stream");
950
+ if (event.streams[0]) {
951
+ audioElement.srcObject = event.streams[0];
952
+ }
953
+ };
954
+ pc.oniceconnectionstatechange = () => {
955
+ const s = pc.iceConnectionState;
956
+ logger.debug("Voice: ICE connection state \u2192", s);
957
+ if (s === "failed" || s === "disconnected") {
958
+ void this.failWith(`WebRTC connection ${s}`);
959
+ }
960
+ };
961
+ pc.onconnectionstatechange = () => {
962
+ logger.debug("Voice: peer connection state \u2192", pc.connectionState);
963
+ if (pc.connectionState === "failed") {
964
+ void this.failWith("WebRTC connection failed");
965
+ }
966
+ };
967
+ for (const track of micStream.getAudioTracks()) {
968
+ pc.addTrack(track, micStream);
969
+ }
970
+ const dataChannel = pc.createDataChannel("oai-events");
971
+ dataChannel.onmessage = (event) => {
972
+ const raw = typeof event.data === "string" ? event.data : "";
973
+ if (!raw) return;
974
+ logger.debug("Voice: \u2190 data channel message", raw.slice(0, 200));
975
+ void handleRealtimeMessage(raw, this.buildMessageContext());
976
+ };
977
+ dataChannel.onopen = () => {
978
+ logger.debug("Voice: data channel opened");
979
+ this.dispatch({ type: "connected" });
980
+ if (mint.autoGreet !== false) {
981
+ this.dispatch({ type: "response_requested" });
982
+ this.sendOverDataChannel({ type: "response.create" });
983
+ }
984
+ };
985
+ dataChannel.onclose = () => {
986
+ logger.debug("Voice: data channel closed");
987
+ };
988
+ this.resources = {
989
+ pc,
990
+ dataChannel,
991
+ micStream,
992
+ audioElement,
993
+ voiceSessionId: mint.voiceSessionId
994
+ };
995
+ try {
996
+ logger.debug("Voice: creating WebRTC offer");
997
+ const offer = await pc.createOffer();
998
+ await pc.setLocalDescription(offer);
999
+ logger.debug("Voice: exchanging SDP with OpenAI Realtime");
1000
+ const answerSdp = await this.exchangeSdp(offer, mint.clientSecret);
1001
+ await pc.setRemoteDescription({ type: "answer", sdp: answerSdp });
1002
+ logger.debug("Voice: WebRTC negotiation complete");
1003
+ } catch (err) {
1004
+ await this.failWith(
1005
+ err instanceof Error ? err.message : "Failed to negotiate voice connection"
1006
+ );
1007
+ return;
1008
+ }
1009
+ void this.postSessionEvent("start", mint.voiceSessionId, pageContext);
1010
+ }
1011
+ /** Stop the session and tear down all resources. */
1012
+ async stop() {
1013
+ logger.debug("Voice: stop() called");
1014
+ this.dispatch({ type: "stop" });
1015
+ await this.teardown();
1016
+ }
1017
+ /** Tear down everything and remove listeners. Call once before discarding the instance. */
1018
+ destroy() {
1019
+ void this.teardown();
1020
+ if (this.pageHideHandler) {
1021
+ window.removeEventListener("pagehide", this.pageHideHandler);
1022
+ this.pageHideHandler = null;
1023
+ }
1024
+ this.listeners.clear();
1025
+ }
1026
+ // ── Internals ───────────────────────────────────────────────────────────
1027
+ buildMessageContext() {
1028
+ return {
1029
+ send: (event) => this.dispatch(event),
1030
+ sendData: (payload) => this.sendOverDataChannel(payload),
1031
+ dispatchToolCall: (name, args) => this.routeToolCall(name, args),
1032
+ isDispatched: (id) => this.dispatchedCallIds.has(id),
1033
+ markDispatched: (id) => {
1034
+ this.dispatchedCallIds.add(id);
1035
+ },
1036
+ recordUsage: (usage) => this.accumulateUsage(usage)
1037
+ };
1038
+ }
1039
+ accumulateUsage(usage) {
1040
+ this.usage.responseCount += 1;
1041
+ if (typeof usage.inputTokens === "number") this.usage.inputTokens += usage.inputTokens;
1042
+ if (typeof usage.cachedInputTokens === "number") {
1043
+ this.usage.cachedInputTokens += usage.cachedInputTokens;
1044
+ }
1045
+ if (typeof usage.outputTokens === "number") this.usage.outputTokens += usage.outputTokens;
1046
+ if (typeof usage.audioInputTokens === "number") {
1047
+ this.usage.audioInputTokens += usage.audioInputTokens;
1048
+ }
1049
+ if (typeof usage.audioOutputTokens === "number") {
1050
+ this.usage.audioOutputTokens += usage.audioOutputTokens;
1051
+ }
1052
+ if (typeof usage.textInputTokens === "number") {
1053
+ this.usage.textInputTokens += usage.textInputTokens;
1054
+ }
1055
+ if (typeof usage.textOutputTokens === "number") {
1056
+ this.usage.textOutputTokens += usage.textOutputTokens;
1057
+ }
1058
+ }
1059
+ sendOverDataChannel(payload) {
1060
+ const channel = this.resources.dataChannel;
1061
+ if (!channel || channel.readyState !== "open") {
1062
+ logger.warn("Voice data channel not ready; dropping payload");
1063
+ return;
1064
+ }
1065
+ try {
1066
+ const serialized = JSON.stringify(payload);
1067
+ logger.debug("Voice: \u2192 data channel send", serialized.slice(0, 200));
1068
+ channel.send(serialized);
1069
+ } catch (err) {
1070
+ logger.warn("Failed to send on voice data channel", err);
1071
+ }
1072
+ }
1073
+ async routeToolCall(idOrName, args) {
1074
+ const name = this.toolNameById.get(idOrName) ?? idOrName;
1075
+ logger.debug("Voice: tool call dispatched", { id: idOrName, name, args });
1076
+ if (name.startsWith("mcp__")) {
1077
+ return await this.execMcpTool(name, args);
1078
+ }
1079
+ if (name === "redirect") {
1080
+ const path = args?.path;
1081
+ if (typeof path !== "string") {
1082
+ throw new Error("redirect tool requires a string `path` argument");
1083
+ }
1084
+ if (this.config.onRedirect) {
1085
+ this.config.onRedirect(path);
1086
+ } else if (typeof window !== "undefined") {
1087
+ window.location.assign(path);
1088
+ }
1089
+ return { success: true, redirected: true, path };
1090
+ }
1091
+ if (this.config.onToolCall) {
1092
+ return await this.config.onToolCall(name, args);
1093
+ }
1094
+ throw new Error(`No handler configured for tool: ${name}`);
1095
+ }
1096
+ /**
1097
+ * Relay an MCP tool call to the server, which holds the org's credentials
1098
+ * and executes against the remote MCP server. The browser only ever passes
1099
+ * through the tool name, args, and the opaque result.
1100
+ */
1101
+ async execMcpTool(toolName, args) {
1102
+ try {
1103
+ const res = await fetch(`${this.apiOrigin}/api/voice/mcp-exec`, {
1104
+ method: "POST",
1105
+ headers: { "Content-Type": "application/json" },
1106
+ body: JSON.stringify({
1107
+ appId: this.config.appId,
1108
+ toolName,
1109
+ args: args ?? {},
1110
+ pageContext: this.safeExtractPageContext()
1111
+ })
1112
+ });
1113
+ if (!res.ok) {
1114
+ const body2 = await res.json().catch(() => ({}));
1115
+ return { error: body2.error ?? `MCP tool failed (${res.status})` };
1116
+ }
1117
+ const body = await res.json();
1118
+ return body.result ?? {};
1119
+ } catch (err) {
1120
+ logger.debug("Voice: MCP tool relay failed", err);
1121
+ return { error: "The integration could not complete this request." };
1122
+ }
1123
+ }
1124
+ async mintToken(chatConfig, decoratedManifest, pageContext) {
1125
+ const res = await fetch(`${this.apiOrigin}/api/voice/realtime-token`, {
1126
+ method: "POST",
1127
+ headers: { "Content-Type": "application/json" },
1128
+ body: JSON.stringify({
1129
+ appId: this.config.appId,
1130
+ pageContext,
1131
+ toolManifest: decoratedManifest,
1132
+ routeManifest: chatConfig?.routes
1133
+ })
1134
+ });
1135
+ if (!res.ok) {
1136
+ const body = await res.json().catch(() => ({}));
1137
+ throw new Error(body.error ?? `Mint failed (${res.status})`);
1138
+ }
1139
+ return await res.json();
1140
+ }
1141
+ /**
1142
+ * Decorate the host's tool manifest with readable, collision-free model-facing ids
1143
+ * and populate `this.toolNameById` for reverse lookup. Mirrors the decoration the
1144
+ * chat-ui iframe applies before sending tools to `/api/chat`. GraphQL/REST tools are
1145
+ * ordinary manifest entries here (contributed by their adapters), so no special-casing
1146
+ * is needed.
1147
+ */
1148
+ buildDecoratedManifest(chatConfig) {
1149
+ this.toolNameById.clear();
1150
+ const used = /* @__PURE__ */ new Set(["redirect"]);
1151
+ const decoratedHostTools = (chatConfig?.tools?.tools ?? []).map((t) => {
1152
+ const id = uniqueToolId(t.name, used);
1153
+ used.add(id);
1154
+ this.toolNameById.set(id, t.name);
1155
+ return { ...t, id };
1156
+ });
1157
+ return { tools: decoratedHostTools };
1158
+ }
1159
+ async exchangeSdp(offer, clientSecret) {
1160
+ const sdpResponse = await fetch(`${REALTIME_CALLS_URL}?model=${DEFAULT_REALTIME_MODEL}`, {
1161
+ method: "POST",
1162
+ headers: {
1163
+ Authorization: `Bearer ${clientSecret}`,
1164
+ "Content-Type": "application/sdp"
1165
+ },
1166
+ body: offer.sdp
1167
+ });
1168
+ if (!sdpResponse.ok) {
1169
+ const body = await sdpResponse.text().catch(() => "");
1170
+ throw new Error(`SDP exchange failed (${sdpResponse.status}): ${body}`);
1171
+ }
1172
+ return await sdpResponse.text();
1173
+ }
1174
+ buildStopEventBody(voiceSessionId, pageContext) {
1175
+ return {
1176
+ appId: this.config.appId,
1177
+ voiceSessionId,
1178
+ event: "stop",
1179
+ clientTimestamp: Date.now(),
1180
+ pageContext,
1181
+ usage: { ...this.usage }
1182
+ };
1183
+ }
1184
+ async postSessionEvent(event, voiceSessionId, pageContext) {
1185
+ try {
1186
+ const body = event === "stop" ? this.buildStopEventBody(voiceSessionId, pageContext) : {
1187
+ appId: this.config.appId,
1188
+ voiceSessionId,
1189
+ event,
1190
+ clientTimestamp: Date.now(),
1191
+ pageContext
1192
+ };
1193
+ await fetch(`${this.apiOrigin}/api/voice/session-event`, {
1194
+ method: "POST",
1195
+ headers: { "Content-Type": "application/json" },
1196
+ body: JSON.stringify(body),
1197
+ keepalive: event === "stop"
1198
+ });
1199
+ } catch (err) {
1200
+ logger.warn(`Failed to post voice.session.${event}`, err);
1201
+ }
1202
+ }
1203
+ async teardown() {
1204
+ const r = this.resources;
1205
+ this.resources = EMPTY_RESOURCES;
1206
+ this.dispatchedCallIds = /* @__PURE__ */ new Set();
1207
+ try {
1208
+ r.dataChannel?.close();
1209
+ } catch (err) {
1210
+ logger.warn("Error closing data channel", err);
1211
+ }
1212
+ try {
1213
+ for (const track of r.micStream?.getTracks() ?? []) {
1214
+ track.stop();
1215
+ }
1216
+ } catch (err) {
1217
+ logger.warn("Error stopping mic tracks", err);
1218
+ }
1219
+ try {
1220
+ for (const sender of r.pc?.getSenders() ?? []) {
1221
+ sender.track?.stop();
1222
+ }
1223
+ r.pc?.close();
1224
+ } catch (err) {
1225
+ logger.warn("Error closing peer connection", err);
1226
+ }
1227
+ if (r.audioElement) {
1228
+ try {
1229
+ r.audioElement.srcObject = null;
1230
+ r.audioElement.remove();
1231
+ } catch (err) {
1232
+ logger.warn("Error removing audio element", err);
1233
+ }
1234
+ }
1235
+ if (r.voiceSessionId) {
1236
+ await this.postSessionEvent("stop", r.voiceSessionId, this.safeExtractPageContext());
1237
+ }
1238
+ }
1239
+ async failWith(message) {
1240
+ logger.warn("Voice session error:", message);
1241
+ this.dispatch({ type: "error", message });
1242
+ await this.teardown();
1243
+ }
1244
+ dispatch(event) {
1245
+ const next = voiceReducer(this.machine, event);
1246
+ if (next === this.machine) return;
1247
+ this.machine = next;
1248
+ for (const listener of this.listeners) {
1249
+ try {
1250
+ listener(next);
1251
+ } catch (err) {
1252
+ logger.warn("Voice state listener threw", err);
1253
+ }
1254
+ }
1255
+ }
1256
+ safeExtractPageContext() {
1257
+ try {
1258
+ return extractPageContext();
1259
+ } catch {
1260
+ return void 0;
1261
+ }
1262
+ }
1263
+ attachPageHide() {
1264
+ if (typeof window === "undefined") return;
1265
+ this.pageHideHandler = () => {
1266
+ const r = this.resources;
1267
+ if (!r.voiceSessionId) return;
1268
+ const body = JSON.stringify(this.buildStopEventBody(r.voiceSessionId, void 0));
1269
+ if (navigator.sendBeacon) {
1270
+ navigator.sendBeacon(
1271
+ `${this.apiOrigin}/api/voice/session-event`,
1272
+ new Blob([body], { type: "application/json" })
1273
+ );
1274
+ }
1275
+ };
1276
+ window.addEventListener("pagehide", this.pageHideHandler);
1277
+ }
1278
+ };
1279
+
1280
+ // src/embed.ts
1281
+ var DEFAULT_POSITION = "bottom-left";
1282
+ var MESSAGE_CIRCLE_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/></svg>`;
1283
+ var AUDIO_LINES_SVG = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 10v3"/><path d="M6 6v11"/><path d="M10 3v18"/><path d="M14 8v7"/><path d="M18 5v13"/><path d="M22 10v3"/></svg>`;
1284
+ var STOP_SVG = `<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6" y="6" width="12" height="12" rx="2"/></svg>`;
1285
+ var VOICE_STATE_ARIA = {
1286
+ idle: "Start voice mode",
1287
+ connecting: "Connecting voice session",
1288
+ listening: "Voice listening \u2014 tap to stop",
1289
+ thinking: "Voice thinking \u2014 tap to stop",
1290
+ speaking: "Voice speaking \u2014 tap to stop",
1291
+ error: "Voice error \u2014 tap to retry"
1292
+ };
1293
+ function getPanelStyles() {
1294
+ return `
1295
+ .yak-panel-root {
1296
+ position: fixed;
1297
+ top: 0; left: 0; right: 0; bottom: 0;
1298
+ width: 100vw; height: 100vh;
1299
+ pointer-events: none;
1300
+ z-index: 9998;
1301
+ }
1302
+
1303
+ .yak-panel-container {
1304
+ position: absolute;
1305
+ width: 500px; height: 600px;
1306
+ max-width: calc(100vw - 40px);
1307
+ max-height: calc(100vh - 120px);
1308
+ border-radius: 15px;
1309
+ overflow: hidden;
1310
+ background-color: transparent;
1311
+ pointer-events: auto;
1312
+ }
1313
+
1314
+ .yak-panel-container[data-position="top-left"]:not(.yak-panel-drawer) { top: 16px; left: 16px; }
1315
+ .yak-panel-container[data-position="top-center"]:not(.yak-panel-drawer) { top: 16px; left: 50%; transform: translateX(-50%); }
1316
+ .yak-panel-container[data-position="top-right"]:not(.yak-panel-drawer) { top: 16px; right: 16px; }
1317
+ .yak-panel-container[data-position="left-center"]:not(.yak-panel-drawer) { top: 50%; left: 16px; transform: translateY(-50%); }
1318
+ .yak-panel-container[data-position="right-center"]:not(.yak-panel-drawer) { top: 50%; right: 16px; transform: translateY(-50%); }
1319
+ .yak-panel-container[data-position="bottom-left"]:not(.yak-panel-drawer) { bottom: 16px; left: 16px; }
1320
+ .yak-panel-container[data-position="bottom-center"]:not(.yak-panel-drawer) { bottom: 16px; left: 50%; transform: translateX(-50%); }
1321
+ .yak-panel-container[data-position="bottom-right"]:not(.yak-panel-drawer) { bottom: 16px; right: 16px; }
1322
+
1323
+ .yak-panel-container:not(.yak-panel-drawer) { display: none; }
1324
+ .yak-panel-container:not(.yak-panel-drawer)[data-open="true"] { display: block; }
1325
+
1326
+ .yak-panel-container[data-expanded="true"] {
1327
+ width: calc(100vw - 32px) !important;
1328
+ height: calc(100vh - 32px) !important;
1329
+ max-width: none !important; max-height: none !important;
1330
+ top: 16px !important; left: 16px !important; right: 16px !important; bottom: 16px !important;
1331
+ border-radius: 15px !important;
1332
+ border: 1px solid rgba(0, 0, 0, 0.1) !important;
1333
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15) !important;
1334
+ }
1335
+ @media (prefers-color-scheme: dark) {
1336
+ .yak-panel-container[data-expanded="true"]:not(.yak-panel-light) { border-color: rgba(255,255,255,0.1) !important; }
1337
+ }
1338
+ .yak-panel-container.yak-panel-dark[data-expanded="true"] { border-color: rgba(255,255,255,0.1) !important; }
1339
+ .yak-panel-container.yak-panel-light[data-expanded="true"] { border-color: rgba(0,0,0,0.1) !important; }
1340
+
1341
+ .yak-panel-container.yak-panel-drawer {
1342
+ height: calc(100% - 32px); max-width: 100vw; max-height: none;
1343
+ border-radius: 15px;
1344
+ transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
1345
+ }
1346
+ .yak-panel-container.yak-panel-drawer[data-position="left-center"],
1347
+ .yak-panel-container.yak-panel-drawer[data-position="top-left"],
1348
+ .yak-panel-container.yak-panel-drawer[data-position="bottom-left"] {
1349
+ top: 16px; left: 16px; bottom: 16px;
1350
+ transform: translateX(calc(-100% - 16px));
1351
+ }
1352
+ .yak-panel-container.yak-panel-drawer[data-position="right-center"],
1353
+ .yak-panel-container.yak-panel-drawer[data-position="top-right"],
1354
+ .yak-panel-container.yak-panel-drawer[data-position="bottom-right"] {
1355
+ top: 16px; right: 16px; bottom: 16px;
1356
+ transform: translateX(calc(100% + 16px));
1357
+ }
1358
+ .yak-panel-container.yak-panel-drawer[data-position="top-center"],
1359
+ .yak-panel-container.yak-panel-drawer[data-position="bottom-center"] {
1360
+ top: 16px; right: 16px; bottom: 16px;
1361
+ transform: translateX(calc(100% + 16px));
1362
+ }
1363
+ .yak-panel-container.yak-panel-drawer[data-open="true"] { transform: translateX(0); }
1364
+
1365
+ .yak-panel-iframe {
1366
+ position: absolute; inset: 0;
1367
+ width: 100%; height: 100%; border: none;
1368
+ }
1369
+
1370
+ @media (max-width: 640px) {
1371
+ .yak-panel-container:not(.yak-panel-drawer) {
1372
+ width: 100% !important; height: 100% !important; height: 100dvh !important;
1373
+ max-width: none !important; max-height: none !important;
1374
+ top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important;
1375
+ border-radius: 0 !important;
1376
+ }
1377
+ .yak-panel-container.yak-panel-drawer { width: 100% !important; max-width: none !important; }
1378
+ }
1379
+ `;
1380
+ }
1381
+ function getTriggerStyles() {
1382
+ return `
1383
+ .yak-widget-trigger {
1384
+ position: fixed; z-index: 9997;
1385
+ display: inline-flex; align-items: center; gap: 8px;
1386
+ border: none; border-radius: 30px;
1387
+ padding: 5px; height: 45px;
1388
+ transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
1389
+ background-color: #000; color: #fff;
1390
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1391
+ font-family: system-ui, -apple-system, sans-serif;
1392
+ }
1393
+
1394
+ .yak-widget-trigger[data-position="top-left"] { top: 28px; left: 28px; }
1395
+ .yak-widget-trigger[data-position="top-center"] { top: 28px; left: 50%; transform: translateX(-50%); }
1396
+ .yak-widget-trigger[data-position="top-right"] { top: 28px; right: 28px; }
1397
+ .yak-widget-trigger[data-position="left-center"] { top: 50%; left: 28px; transform: translateY(-50%); }
1398
+ .yak-widget-trigger[data-position="right-center"] { top: 50%; right: 28px; transform: translateY(-50%); }
1399
+ .yak-widget-trigger[data-position="bottom-left"] { bottom: 28px; left: 28px; }
1400
+ .yak-widget-trigger[data-position="bottom-center"] { bottom: 28px; left: 50%; transform: translateX(-50%); }
1401
+ .yak-widget-trigger[data-position="bottom-right"] { bottom: 28px; right: 28px; }
1402
+
1403
+ .yak-widget-icon-bg {
1404
+ display: flex; align-items: center; justify-content: center;
1405
+ width: 36px; height: 36px; border-radius: 50%;
1406
+ background-color: rgba(255, 255, 255, 0.1);
1407
+ flex-shrink: 0;
1408
+ }
1409
+
1410
+ .yak-widget-icon { width: 20px; height: 20px; color: currentColor; }
1411
+
1412
+ .yak-widget-trigger-icon-btn {
1413
+ display: inline-flex; align-items: center; justify-content: center;
1414
+ width: 36px; height: 36px; border-radius: 50%;
1415
+ border: none; padding: 0;
1416
+ background-color: transparent;
1417
+ color: inherit;
1418
+ cursor: pointer;
1419
+ position: relative;
1420
+ transition: background-color 0.15s ease;
1421
+ flex-shrink: 0;
1422
+ }
1423
+ .yak-widget-trigger-icon-btn:hover { background-color: rgba(255, 255, 255, 0.12); }
1424
+ .yak-widget-trigger-icon-btn:disabled { cursor: wait; opacity: 0.7; }
1425
+ .yak-widget-trigger-icon-btn svg { width: 20px; height: 20px; display: block; }
1426
+
1427
+ .yak-widget-trigger-icon-btn[data-action="voice"][data-state="error"] {
1428
+ background-color: rgba(185, 28, 28, 0.18);
1429
+ }
1430
+ .yak-widget-trigger-icon-btn[data-action="voice"][data-state="listening"]::after,
1431
+ .yak-widget-trigger-icon-btn[data-action="voice"][data-state="speaking"]::after {
1432
+ content: "";
1433
+ position: absolute; inset: 2px;
1434
+ border-radius: 50%;
1435
+ border: 2px solid currentColor;
1436
+ pointer-events: none;
1437
+ }
1438
+ .yak-widget-trigger-icon-btn[data-action="voice"][data-state="listening"]::after {
1439
+ opacity: 0.4; animation: yak-widget-pulse 1.2s ease-out infinite;
1440
+ }
1441
+ .yak-widget-trigger-icon-btn[data-action="voice"][data-state="speaking"]::after {
1442
+ opacity: 0.5; animation: yak-widget-wave 0.8s ease-in-out infinite;
1443
+ }
1444
+
1445
+ @media (prefers-color-scheme: dark) {
1446
+ .yak-widget-trigger:not(.yak-widget-light) .yak-widget-icon { filter: invert(1); }
1447
+ .yak-widget-trigger:not(.yak-widget-light) .yak-widget-trigger-icon-btn:hover {
1448
+ background-color: rgba(255, 255, 255, 0.12);
1449
+ }
1450
+ }
1451
+ .yak-widget-trigger.yak-widget-dark .yak-widget-icon { filter: invert(1); }
1452
+ .yak-widget-trigger.yak-widget-light .yak-widget-icon { filter: none; }
1453
+ .yak-widget-trigger.yak-widget-light .yak-widget-trigger-icon-btn:hover {
1454
+ background-color: rgba(0, 0, 0, 0.06);
1455
+ }
1456
+
1457
+ .yak-widget-spinner {
1458
+ width: 20px; height: 20px;
1459
+ border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%;
1460
+ animation: yak-widget-spin 0.8s linear infinite;
1461
+ }
1462
+ @keyframes yak-widget-spin { to { transform: rotate(360deg); } }
1463
+ @keyframes yak-widget-pulse {
1464
+ 0% { transform: scale(1); opacity: 0.5; }
1465
+ 100% { transform: scale(1.45); opacity: 0; }
1466
+ }
1467
+ @keyframes yak-widget-wave {
1468
+ 0%, 100% { transform: scale(1); opacity: 0.5; }
1469
+ 50% { transform: scale(1.25); opacity: 0.9; }
1470
+ }
1471
+
1472
+ .yak-widget-trigger.yak-widget-custom-light {
1473
+ background-color: var(--yak-btn-light-bg, #fff); color: var(--yak-btn-light-color, #000);
1474
+ border: 1px solid var(--yak-btn-light-border, transparent);
1475
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
1476
+ }
1477
+ .yak-widget-trigger.yak-widget-custom-dark {
1478
+ background-color: var(--yak-btn-dark-bg, #000); color: var(--yak-btn-dark-color, #fff);
1479
+ border: 1px solid var(--yak-btn-dark-border, transparent);
1480
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1481
+ }
1482
+
1483
+ @media (prefers-color-scheme: light) {
1484
+ .yak-widget-trigger[data-has-light-custom]:not(.yak-widget-dark) {
1485
+ background-color: var(--yak-btn-light-bg, #fff); color: var(--yak-btn-light-color, #000);
1486
+ border: 1px solid var(--yak-btn-light-border, transparent);
1487
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
1488
+ }
1489
+ }
1490
+ @media (prefers-color-scheme: dark) {
1491
+ .yak-widget-trigger[data-has-dark-custom]:not(.yak-widget-light) {
1492
+ background-color: var(--yak-btn-dark-bg, #000); color: var(--yak-btn-dark-color, #fff);
1493
+ border: 1px solid var(--yak-btn-dark-border, transparent);
1494
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1495
+ }
1496
+ }
1497
+
1498
+ @media (prefers-color-scheme: light) {
1499
+ .yak-widget-trigger:not(.yak-widget-dark):not([data-has-light-custom]) {
1500
+ background-color: #fff; color: #000;
1501
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); border: 1px solid #e5e5e5;
1502
+ }
1503
+ .yak-widget-trigger:not(.yak-widget-dark):not([data-has-light-custom]) .yak-widget-icon-bg {
1504
+ background-color: rgba(0, 0, 0, 0.05);
1505
+ }
1506
+ }
1507
+
1508
+ @media (prefers-color-scheme: dark) {
1509
+ .yak-widget-trigger:not(.yak-widget-light):not([data-has-dark-custom]) {
1510
+ background-color: #000; color: #fff;
1511
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); border: none;
1512
+ }
1513
+ .yak-widget-trigger:not(.yak-widget-light):not([data-has-dark-custom]) .yak-widget-icon-bg {
1514
+ background-color: rgba(255, 255, 255, 0.1);
1515
+ }
1516
+ }
1517
+
1518
+ .yak-widget-trigger.yak-widget-light:not(.yak-widget-custom-light) {
1519
+ background-color: #fff; color: #000;
1520
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); border: 1px solid #e5e5e5;
1521
+ }
1522
+ .yak-widget-trigger.yak-widget-light:not(.yak-widget-custom-light) .yak-widget-icon-bg {
1523
+ background-color: rgba(0, 0, 0, 0.05);
1524
+ }
1525
+
1526
+ .yak-widget-trigger.yak-widget-dark:not(.yak-widget-custom-dark) {
1527
+ background-color: #000; color: #fff;
1528
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); border: none;
1529
+ }
1530
+ .yak-widget-trigger.yak-widget-dark:not(.yak-widget-custom-dark) .yak-widget-icon-bg {
1531
+ background-color: rgba(255, 255, 255, 0.1);
1532
+ }
1533
+ `;
1534
+ }
1535
+ var YakEmbed = class {
1536
+ client;
1537
+ voice;
1538
+ config;
1539
+ mode;
1540
+ // DOM elements
1541
+ styleEl = null;
1542
+ panelRoot = null;
1543
+ container = null;
1544
+ iframe = null;
1545
+ triggerEl = null;
1546
+ chatButton = null;
1547
+ voiceButton = null;
1548
+ // State
1549
+ isOpen = false;
1550
+ isReady = false;
1551
+ isExpanded = false;
1552
+ hasBeenOpened = false;
1553
+ pendingPrompt = null;
1554
+ mounted = false;
1555
+ voiceMachine = INITIAL_VOICE_MACHINE;
1556
+ // Listeners
1557
+ stateListeners = /* @__PURE__ */ new Set();
1558
+ voiceListeners = /* @__PURE__ */ new Set();
1559
+ unsubscribeVoice = null;
1560
+ mobileQuery = null;
1561
+ mobileHandler = null;
1562
+ expandHandler = null;
1563
+ constructor(config) {
1564
+ this.config = config;
1565
+ this.mode = config.mode ?? "chat";
1566
+ this.client = new YakClient({
1567
+ ...config,
1568
+ onReady: () => {
1569
+ this.isReady = true;
1570
+ this.updatePanelState();
1571
+ this.updateChatButtonState();
1572
+ this.sendPendingPrompt();
1573
+ this.sendFocusIfOpen();
1574
+ this.notifyMobileState();
1575
+ this.notifyListeners();
1576
+ config.onReady?.();
1577
+ },
1578
+ onClose: () => {
1579
+ this.close();
1580
+ config.onClose?.();
1581
+ }
1582
+ });
1583
+ if (this.mode !== "chat") {
1584
+ const voiceConfig = {
1585
+ appId: config.appId,
1586
+ // A single `origin` on the embed drives both surfaces: chat (iframe)
1587
+ // and voice (mint endpoint).
1588
+ apiOrigin: config.origin,
1589
+ getConfig: config.getConfig,
1590
+ chatConfig: config.chatConfig,
1591
+ onToolCall: config.onToolCall,
1592
+ onRedirect: config.onRedirect
1593
+ };
1594
+ this.voice = new YakVoiceSession(voiceConfig);
1595
+ } else {
1596
+ this.voice = null;
1597
+ }
1598
+ }
1599
+ /** The underlying headless YakClient for advanced usage */
1600
+ getClient() {
1601
+ return this.client;
1602
+ }
1603
+ /** The underlying voice session — null when mode === "chat". */
1604
+ getVoiceSession() {
1605
+ return this.voice;
1606
+ }
1607
+ /** Current widget mode (immutable for the lifetime of the embed). */
1608
+ getMode() {
1609
+ return this.mode;
1610
+ }
1611
+ // ── Lifecycle ───────────────────────────────────────────────────────────
1612
+ /**
1613
+ * Mount the widget into the DOM. Call once after construction.
1614
+ * Inserts styles and trigger button (if enabled). The chat iframe is
1615
+ * lazily created on the first call to open().
1616
+ */
1617
+ mount(target) {
1618
+ if (this.mounted) return;
1619
+ this.mounted = true;
1620
+ const parent = target ?? this.config.target ?? document.body;
1621
+ this.styleEl = document.createElement("style");
1622
+ this.styleEl.textContent = getPanelStyles() + getTriggerStyles();
1623
+ parent.appendChild(this.styleEl);
1624
+ if (this.config.trigger !== false) {
1625
+ this.createTrigger(parent);
1626
+ }
1627
+ this.expandHandler = (event) => {
1628
+ if (event.data?.type === "YAK_SET_EXPANDED") {
1629
+ this.isExpanded = Boolean(event.data.expanded);
1630
+ this.updatePanelState();
1631
+ this.notifyListeners();
1632
+ }
1633
+ };
1634
+ window.addEventListener("message", this.expandHandler);
1635
+ this.client.mount();
1636
+ if (this.voice) {
1637
+ this.voiceMachine = this.voice.getState();
1638
+ this.unsubscribeVoice = this.voice.onStateChange((machine) => {
1639
+ this.voiceMachine = machine;
1640
+ this.updateVoiceButtonState();
1641
+ for (const listener of this.voiceListeners) {
1642
+ try {
1643
+ listener(machine);
1644
+ } catch (err) {
1645
+ logger.warn("Error in voice listener:", err);
1646
+ }
1647
+ }
1648
+ });
1649
+ this.updateVoiceButtonState();
1650
+ }
1651
+ }
1652
+ /** Remove all DOM elements and event listeners. */
1653
+ destroy() {
1654
+ if (!this.mounted) return;
1655
+ this.mounted = false;
1656
+ this.client.unmount();
1657
+ if (this.unsubscribeVoice) {
1658
+ this.unsubscribeVoice();
1659
+ this.unsubscribeVoice = null;
1660
+ }
1661
+ this.voice?.destroy();
1662
+ if (this.expandHandler) {
1663
+ window.removeEventListener("message", this.expandHandler);
1664
+ this.expandHandler = null;
1665
+ }
1666
+ if (this.mobileQuery && this.mobileHandler) {
1667
+ this.mobileQuery.removeEventListener("change", this.mobileHandler);
1668
+ this.mobileQuery = null;
1669
+ this.mobileHandler = null;
1670
+ }
1671
+ this.panelRoot?.remove();
1672
+ this.triggerEl?.remove();
1673
+ this.styleEl?.remove();
1674
+ this.panelRoot = null;
1675
+ this.container = null;
1676
+ this.iframe = null;
1677
+ this.triggerEl = null;
1678
+ this.chatButton = null;
1679
+ this.voiceButton = null;
1680
+ this.styleEl = null;
1681
+ this.isOpen = false;
1682
+ this.isReady = false;
1683
+ this.isExpanded = false;
1684
+ this.hasBeenOpened = false;
1685
+ this.stateListeners.clear();
1686
+ this.voiceListeners.clear();
1687
+ }
1688
+ // ── Public chat API ─────────────────────────────────────────────────────
1689
+ /** Open the chat widget. Creates the iframe on first call (lazy mount). */
1690
+ open() {
1691
+ if (!this.mounted) return;
1692
+ if (!this.hasBeenOpened) {
1693
+ this.hasBeenOpened = true;
1694
+ const parent = this.config.target ?? document.body;
1695
+ this.createPanel(parent);
1696
+ }
1697
+ this.isOpen = true;
1698
+ this.client.setWidgetOpen(true);
1699
+ this.updatePanelState();
1700
+ this.updateChatButtonState();
1701
+ this.sendFocusIfOpen();
1702
+ this.notifyListeners();
1703
+ }
1704
+ /** Close the chat widget. The iframe remains in the DOM for instant re-open. */
1705
+ close() {
1706
+ this.isOpen = false;
1707
+ this.client.setWidgetOpen(false);
1708
+ this.updatePanelState();
1709
+ this.updateChatButtonState();
1710
+ this.notifyListeners();
1711
+ }
1712
+ /** Toggle the chat widget open/closed. */
1713
+ toggle() {
1714
+ if (this.isOpen) {
1715
+ this.close();
1716
+ } else {
1717
+ this.open();
1718
+ }
1719
+ }
1720
+ /** Open the chat and immediately send a prompt. */
1721
+ openWithPrompt(prompt) {
1722
+ this.pendingPrompt = prompt;
1723
+ this.open();
1724
+ this.sendPendingPrompt();
1725
+ }
1726
+ /** Get the current widget state. */
1727
+ getState() {
1728
+ return {
1729
+ isOpen: this.isOpen,
1730
+ isReady: this.isReady,
1731
+ isLoading: this.isOpen && !this.isReady,
1732
+ isExpanded: this.isExpanded
1733
+ };
1734
+ }
1735
+ /** Subscribe to state changes. Returns an unsubscribe function. */
1736
+ onStateChange(listener) {
1737
+ this.stateListeners.add(listener);
1738
+ return () => {
1739
+ this.stateListeners.delete(listener);
1740
+ };
1741
+ }
1742
+ // ── Public voice API ────────────────────────────────────────────────────
1743
+ /** Start a voice session. Must be invoked from a user gesture. */
1744
+ voiceStart() {
1745
+ return this.voice ? this.voice.start() : Promise.resolve();
1746
+ }
1747
+ /** Stop the current voice session. */
1748
+ voiceStop() {
1749
+ return this.voice ? this.voice.stop() : Promise.resolve();
1750
+ }
1751
+ /** Toggle: start if idle/error, stop if active. */
1752
+ async voiceToggle() {
1753
+ if (!this.voice) return;
1754
+ const state = this.voice.getState().state;
1755
+ if (state === "idle" || state === "error") {
1756
+ await this.voice.start();
1757
+ } else if (state === "listening" || state === "speaking" || state === "thinking") {
1758
+ await this.voice.stop();
1759
+ }
1760
+ }
1761
+ /** Current voice machine snapshot. */
1762
+ getVoiceState() {
1763
+ return this.voice ? this.voice.getState() : INITIAL_VOICE_MACHINE;
1764
+ }
1765
+ /** Subscribe to voice state changes. */
1766
+ onVoiceStateChange(listener) {
1767
+ this.voiceListeners.add(listener);
1768
+ return () => {
1769
+ this.voiceListeners.delete(listener);
1770
+ };
1771
+ }
1772
+ // ── DOM creation ────────────────────────────────────────────────────────
1773
+ createPanel(parent) {
1774
+ const theme = this.config.theme;
1775
+ const position = theme?.position ?? DEFAULT_POSITION;
1776
+ const colorMode = theme?.colorMode;
1777
+ const displayMode = theme?.displayMode ?? "chatbox";
1778
+ const isDrawer = displayMode === "drawer";
1779
+ this.panelRoot = document.createElement("div");
1780
+ this.panelRoot.className = "yak-panel-root";
1781
+ this.container = document.createElement("div");
1782
+ const classes = ["yak-panel-container"];
1783
+ if (isDrawer) classes.push("yak-panel-drawer");
1784
+ if (colorMode === "light") classes.push("yak-panel-light");
1785
+ else if (colorMode === "dark") classes.push("yak-panel-dark");
1786
+ this.container.className = classes.join(" ");
1787
+ this.container.dataset.position = position;
1788
+ this.iframe = document.createElement("iframe");
1789
+ this.iframe.allow = "clipboard-write";
1790
+ this.iframe.title = "yak-chat-host";
1791
+ this.iframe.className = "yak-panel-iframe";
1792
+ this.iframe.src = this.client.getEmbedUrl();
1793
+ this.iframe.addEventListener("load", () => {
1794
+ this.client.setIframeWindow(this.iframe?.contentWindow ?? null);
1795
+ });
1796
+ this.container.appendChild(this.iframe);
1797
+ this.panelRoot.appendChild(this.container);
1798
+ parent.appendChild(this.panelRoot);
1799
+ this.mobileQuery = window.matchMedia("(max-width: 640px)");
1800
+ this.mobileHandler = (e) => {
1801
+ this.notifyIframeFullscreen(e.matches);
1802
+ };
1803
+ this.mobileQuery.addEventListener("change", this.mobileHandler);
1804
+ }
1805
+ createTrigger(parent) {
1806
+ const theme = this.config.theme;
1807
+ const position = theme?.position ?? DEFAULT_POSITION;
1808
+ const colorMode = theme?.colorMode;
1809
+ const triggerConfig = typeof this.config.trigger === "object" ? this.config.trigger : {};
1810
+ this.triggerEl = document.createElement("div");
1811
+ this.triggerEl.dataset.position = position;
1812
+ this.triggerEl.dataset.mode = this.mode;
1813
+ this.triggerEl.className = this.buildTriggerClasses(colorMode, triggerConfig);
1814
+ this.applyTriggerCustomColors(triggerConfig);
1815
+ const iconBg = document.createElement("div");
1816
+ iconBg.className = "yak-widget-icon-bg";
1817
+ const logoImg = document.createElement("img");
1818
+ logoImg.src = `${this.client.getIframeOrigin()}/logo.svg`;
1819
+ logoImg.alt = "";
1820
+ logoImg.width = 20;
1821
+ logoImg.height = 20;
1822
+ logoImg.className = "yak-widget-icon";
1823
+ iconBg.appendChild(logoImg);
1824
+ this.triggerEl.appendChild(iconBg);
1825
+ if (this.mode === "chat" || this.mode === "both") {
1826
+ this.chatButton = document.createElement("button");
1827
+ this.chatButton.type = "button";
1828
+ this.chatButton.className = "yak-widget-trigger-icon-btn";
1829
+ this.chatButton.dataset.action = "chat";
1830
+ this.chatButton.setAttribute("aria-label", "Open chat");
1831
+ this.chatButton.innerHTML = MESSAGE_CIRCLE_SVG;
1832
+ this.chatButton.addEventListener("click", () => this.open());
1833
+ this.triggerEl.appendChild(this.chatButton);
1834
+ }
1835
+ if (this.mode === "voice" || this.mode === "both") {
1836
+ this.voiceButton = document.createElement("button");
1837
+ this.voiceButton.type = "button";
1838
+ this.voiceButton.className = "yak-widget-trigger-icon-btn";
1839
+ this.voiceButton.dataset.action = "voice";
1840
+ this.voiceButton.dataset.state = "idle";
1841
+ this.voiceButton.setAttribute("aria-label", VOICE_STATE_ARIA.idle);
1842
+ this.voiceButton.innerHTML = AUDIO_LINES_SVG;
1843
+ this.voiceButton.addEventListener("click", () => {
1844
+ void this.voiceToggle();
1845
+ });
1846
+ this.triggerEl.appendChild(this.voiceButton);
1847
+ }
1848
+ parent.appendChild(this.triggerEl);
1849
+ }
1850
+ buildTriggerClasses(colorMode, triggerConfig) {
1851
+ const classes = ["yak-widget-trigger"];
1852
+ if (colorMode === "light") classes.push("yak-widget-light");
1853
+ else if (colorMode === "dark") classes.push("yak-widget-dark");
1854
+ const hasLightCustom = triggerConfig.lightButton?.background || triggerConfig.lightButton?.color || triggerConfig.lightButton?.border;
1855
+ const hasDarkCustom = triggerConfig.darkButton?.background || triggerConfig.darkButton?.color || triggerConfig.darkButton?.border;
1856
+ if (colorMode === "light" && hasLightCustom) classes.push("yak-widget-custom-light");
1857
+ else if (colorMode === "dark" && hasDarkCustom) classes.push("yak-widget-custom-dark");
1858
+ return classes.join(" ");
1859
+ }
1860
+ applyTriggerCustomColors(triggerConfig) {
1861
+ if (!this.triggerEl) return;
1862
+ const { lightButton, darkButton } = triggerConfig;
1863
+ const hasLightCustom = lightButton?.background || lightButton?.color || lightButton?.border;
1864
+ const hasDarkCustom = darkButton?.background || darkButton?.color || darkButton?.border;
1865
+ if (hasLightCustom || hasDarkCustom) {
1866
+ const vars = [
1867
+ ["--yak-btn-light-bg", lightButton?.background],
1868
+ ["--yak-btn-light-color", lightButton?.color],
1869
+ ["--yak-btn-light-border", lightButton?.border],
1870
+ ["--yak-btn-dark-bg", darkButton?.background],
1871
+ ["--yak-btn-dark-color", darkButton?.color],
1872
+ ["--yak-btn-dark-border", darkButton?.border]
1873
+ ];
1874
+ for (const [prop, value] of vars) {
1875
+ if (value) this.triggerEl.style.setProperty(prop, value);
1876
+ }
1877
+ }
1878
+ if (hasLightCustom) this.triggerEl.dataset.hasLightCustom = "true";
1879
+ if (hasDarkCustom) this.triggerEl.dataset.hasDarkCustom = "true";
1880
+ }
1881
+ // ── Internal state management ───────────────────────────────────────────
1882
+ updatePanelState() {
1883
+ if (!this.container) return;
1884
+ this.container.dataset.open = String(this.isOpen && this.isReady);
1885
+ this.container.dataset.expanded = String(this.isExpanded);
1886
+ if (this.panelRoot) {
1887
+ this.panelRoot.dataset.expanded = String(this.isExpanded);
1888
+ }
1889
+ }
1890
+ updateChatButtonState() {
1891
+ if (!this.chatButton) return;
1892
+ const isLoading = this.isOpen && !this.isReady;
1893
+ this.chatButton.disabled = isLoading;
1894
+ this.chatButton.setAttribute("aria-label", isLoading ? "Loading chat" : "Open chat");
1895
+ if (isLoading) {
1896
+ this.chatButton.innerHTML = `<span class="yak-widget-spinner" aria-hidden="true"></span>`;
1897
+ } else {
1898
+ this.chatButton.innerHTML = MESSAGE_CIRCLE_SVG;
1899
+ }
1900
+ }
1901
+ updateVoiceButtonState() {
1902
+ if (!this.voiceButton) return;
1903
+ const state = this.voiceMachine.state;
1904
+ this.voiceButton.dataset.state = state;
1905
+ this.voiceButton.setAttribute("aria-label", VOICE_STATE_ARIA[state]);
1906
+ this.voiceButton.disabled = state === "connecting";
1907
+ this.voiceButton.innerHTML = this.iconForVoiceState(state);
1908
+ }
1909
+ iconForVoiceState(state) {
1910
+ if (state === "connecting") {
1911
+ return `<span class="yak-widget-spinner" aria-hidden="true"></span>`;
1912
+ }
1913
+ if (state === "listening" || state === "speaking" || state === "thinking") {
1914
+ return STOP_SVG;
1915
+ }
1916
+ return AUDIO_LINES_SVG;
1917
+ }
1918
+ sendPendingPrompt() {
1919
+ if (!this.pendingPrompt || !this.isReady) return;
1920
+ logger.debug("Sending pending prompt:", this.pendingPrompt);
1921
+ this.client.sendPrompt(this.pendingPrompt);
1922
+ this.pendingPrompt = null;
1923
+ }
1924
+ sendFocusIfOpen() {
1925
+ if (this.isOpen && this.isReady) {
1926
+ this.client.sendFocus();
1927
+ }
1928
+ }
1929
+ notifyMobileState() {
1930
+ if (this.mobileQuery) {
1931
+ this.notifyIframeFullscreen(this.mobileQuery.matches);
1932
+ }
1933
+ }
1934
+ notifyIframeFullscreen(isFullscreen) {
1935
+ if (!this.iframe?.contentWindow) return;
1936
+ const msg = {
1937
+ type: "yak:viewport",
1938
+ payload: { fullscreen: isFullscreen }
1939
+ };
1940
+ this.iframe.contentWindow.postMessage(msg, this.client.getIframeOrigin());
1941
+ }
1942
+ notifyListeners() {
1943
+ const state = this.getState();
1944
+ for (const listener of this.stateListeners) {
1945
+ try {
1946
+ listener(state);
1947
+ } catch (err) {
1948
+ logger.warn("Error in state listener:", err);
1949
+ }
1950
+ }
1951
+ }
1952
+ };
1953
+
1954
+ // src/toolset.ts
1955
+ function createYakToolset(adapters) {
1956
+ let cache = null;
1957
+ async function resolve(force = false) {
1958
+ if (cache && !force) return cache;
1959
+ cache = await Promise.all(
1960
+ adapters.map(async (adapter) => ({ adapter, tools: await adapter.getTools() }))
1961
+ );
1962
+ return cache;
1963
+ }
1964
+ async function getConfig() {
1965
+ const resolved = await resolve(true);
1966
+ const tools = [];
1967
+ const seen = /* @__PURE__ */ new Set();
1968
+ for (const { adapter, tools: adapterTools } of resolved) {
1969
+ for (const tool of adapterTools) {
1970
+ if (seen.has(tool.name)) {
1971
+ logger.warn(
1972
+ `Duplicate tool name "${tool.name}"; keeping the first and ignoring the one from adapter "${adapter.id ?? "unknown"}".`
1973
+ );
1974
+ continue;
1975
+ }
1976
+ seen.add(tool.name);
1977
+ tools.push(tool);
1978
+ }
1979
+ }
1980
+ return {
1981
+ tools: {
1982
+ tools,
1983
+ sources: resolved.map(({ adapter, tools: adapterTools }, index) => ({
1984
+ id: adapter.id ?? `adapter-${index}`,
1985
+ count: adapterTools.length
1986
+ }))
1987
+ }
1988
+ };
1989
+ }
1990
+ const onToolCall = async (name, args) => {
1991
+ for (const adapter of adapters) {
1992
+ if (adapter.ownsTool?.(name)) {
1993
+ return adapter.execute(name, args);
1994
+ }
1995
+ }
1996
+ const resolved = await resolve();
1997
+ for (const { adapter, tools } of resolved) {
1998
+ if (adapter.ownsTool) continue;
1999
+ if (tools.some((tool) => tool.name === name)) {
2000
+ return adapter.execute(name, args);
2001
+ }
2002
+ }
2003
+ throw new Error(`Unknown tool: ${name}`);
2004
+ };
2005
+ return { getConfig, onToolCall };
2006
+ }
2007
+ async function buildHeaders(source, base) {
2008
+ const headers = new Headers(base);
2009
+ const resolved = typeof source === "function" ? await source() : source;
2010
+ if (resolved) {
2011
+ for (const [key, value] of new Headers(resolved)) {
2012
+ headers.set(key, value);
2013
+ }
2014
+ }
2015
+ return headers;
2016
+ }
2017
+ function createYakServerAdapter(config = {}) {
2018
+ const endpoint = config.endpoint ?? "/api/yak";
2019
+ return {
2020
+ id: config.id ?? "server",
2021
+ getTools: async () => {
2022
+ const res = await fetch(endpoint, { headers: await buildHeaders(config.headers) });
2023
+ if (!res.ok) {
2024
+ throw new Error(`Failed to load tools from ${endpoint} (${res.status})`);
2025
+ }
2026
+ const chatConfig = await res.json();
2027
+ return chatConfig.tools?.tools ?? [];
2028
+ },
2029
+ execute: async (name, args) => {
2030
+ const res = await fetch(endpoint, {
2031
+ method: "POST",
2032
+ headers: await buildHeaders(config.headers, { "Content-Type": "application/json" }),
2033
+ body: JSON.stringify({ name, args })
2034
+ });
2035
+ const data = await res.json().catch(() => ({}));
2036
+ if (!res.ok || !data.ok) {
2037
+ throw new Error(data.error ?? `Tool "${name}" failed (${res.status})`);
2038
+ }
2039
+ return data.result;
2040
+ }
2041
+ };
2042
+ }
2043
+ //# sourceMappingURL=index.cjs.map