@yak-io/javascript 0.10.0 → 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/client.d.ts.map +1 -1
- package/dist/index.cjs +2043 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.js +2020 -16
- package/dist/index.js.map +7 -0
- package/dist/index.server.cjs +339 -0
- package/dist/index.server.cjs.map +7 -0
- package/dist/index.server.js +316 -1
- package/dist/index.server.js.map +7 -0
- package/dist/tool-name.d.ts +16 -5
- package/dist/tool-name.d.ts.map +1 -1
- package/dist/voice-session.d.ts +5 -5
- package/dist/voice-session.d.ts.map +1 -1
- package/package.json +5 -3
- package/dist/client.js +0 -524
- package/dist/embed.js +0 -743
- package/dist/logger.js +0 -117
- package/dist/page-context.js +0 -71
- package/dist/server/createYakHandler.js +0 -185
- package/dist/server/index.js +0 -2
- package/dist/server/sources.js +0 -125
- package/dist/tool-name.js +0 -24
- package/dist/toolset.js +0 -119
- package/dist/types/config.js +0 -1
- package/dist/types/messaging.js +0 -1
- package/dist/types/routes.js +0 -1
- package/dist/types/tools.js +0 -1
- package/dist/version.js +0 -18
- package/dist/voice-machine.js +0 -168
- package/dist/voice-session.js +0 -518
package/dist/embed.js
DELETED
|
@@ -1,743 +0,0 @@
|
|
|
1
|
-
import { YakClient } from "./client.js";
|
|
2
|
-
import { logger } from "./logger.js";
|
|
3
|
-
import { INITIAL_VOICE_MACHINE } from "./voice-machine.js";
|
|
4
|
-
import { YakVoiceSession, } from "./voice-session.js";
|
|
5
|
-
// Single source of truth for the default trigger + panel corner.
|
|
6
|
-
const DEFAULT_POSITION = "bottom-left";
|
|
7
|
-
// ── Inline SVG icons (lucide) ───────────────────────────────────────────────
|
|
8
|
-
const 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>`;
|
|
9
|
-
const 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>`;
|
|
10
|
-
const 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>`;
|
|
11
|
-
const VOICE_STATE_ARIA = {
|
|
12
|
-
idle: "Start voice mode",
|
|
13
|
-
connecting: "Connecting voice session",
|
|
14
|
-
listening: "Voice listening — tap to stop",
|
|
15
|
-
thinking: "Voice thinking — tap to stop",
|
|
16
|
-
speaking: "Voice speaking — tap to stop",
|
|
17
|
-
error: "Voice error — tap to retry",
|
|
18
|
-
};
|
|
19
|
-
// ── CSS ─────────────────────────────────────────────────────────────────────
|
|
20
|
-
function getPanelStyles() {
|
|
21
|
-
return `
|
|
22
|
-
.yak-panel-root {
|
|
23
|
-
position: fixed;
|
|
24
|
-
top: 0; left: 0; right: 0; bottom: 0;
|
|
25
|
-
width: 100vw; height: 100vh;
|
|
26
|
-
pointer-events: none;
|
|
27
|
-
z-index: 9998;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
.yak-panel-container {
|
|
31
|
-
position: absolute;
|
|
32
|
-
width: 500px; height: 600px;
|
|
33
|
-
max-width: calc(100vw - 40px);
|
|
34
|
-
max-height: calc(100vh - 120px);
|
|
35
|
-
border-radius: 15px;
|
|
36
|
-
overflow: hidden;
|
|
37
|
-
background-color: transparent;
|
|
38
|
-
pointer-events: auto;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
.yak-panel-container[data-position="top-left"]:not(.yak-panel-drawer) { top: 16px; left: 16px; }
|
|
42
|
-
.yak-panel-container[data-position="top-center"]:not(.yak-panel-drawer) { top: 16px; left: 50%; transform: translateX(-50%); }
|
|
43
|
-
.yak-panel-container[data-position="top-right"]:not(.yak-panel-drawer) { top: 16px; right: 16px; }
|
|
44
|
-
.yak-panel-container[data-position="left-center"]:not(.yak-panel-drawer) { top: 50%; left: 16px; transform: translateY(-50%); }
|
|
45
|
-
.yak-panel-container[data-position="right-center"]:not(.yak-panel-drawer) { top: 50%; right: 16px; transform: translateY(-50%); }
|
|
46
|
-
.yak-panel-container[data-position="bottom-left"]:not(.yak-panel-drawer) { bottom: 16px; left: 16px; }
|
|
47
|
-
.yak-panel-container[data-position="bottom-center"]:not(.yak-panel-drawer) { bottom: 16px; left: 50%; transform: translateX(-50%); }
|
|
48
|
-
.yak-panel-container[data-position="bottom-right"]:not(.yak-panel-drawer) { bottom: 16px; right: 16px; }
|
|
49
|
-
|
|
50
|
-
.yak-panel-container:not(.yak-panel-drawer) { display: none; }
|
|
51
|
-
.yak-panel-container:not(.yak-panel-drawer)[data-open="true"] { display: block; }
|
|
52
|
-
|
|
53
|
-
.yak-panel-container[data-expanded="true"] {
|
|
54
|
-
width: calc(100vw - 32px) !important;
|
|
55
|
-
height: calc(100vh - 32px) !important;
|
|
56
|
-
max-width: none !important; max-height: none !important;
|
|
57
|
-
top: 16px !important; left: 16px !important; right: 16px !important; bottom: 16px !important;
|
|
58
|
-
border-radius: 15px !important;
|
|
59
|
-
border: 1px solid rgba(0, 0, 0, 0.1) !important;
|
|
60
|
-
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.15) !important;
|
|
61
|
-
}
|
|
62
|
-
@media (prefers-color-scheme: dark) {
|
|
63
|
-
.yak-panel-container[data-expanded="true"]:not(.yak-panel-light) { border-color: rgba(255,255,255,0.1) !important; }
|
|
64
|
-
}
|
|
65
|
-
.yak-panel-container.yak-panel-dark[data-expanded="true"] { border-color: rgba(255,255,255,0.1) !important; }
|
|
66
|
-
.yak-panel-container.yak-panel-light[data-expanded="true"] { border-color: rgba(0,0,0,0.1) !important; }
|
|
67
|
-
|
|
68
|
-
.yak-panel-container.yak-panel-drawer {
|
|
69
|
-
height: calc(100% - 32px); max-width: 100vw; max-height: none;
|
|
70
|
-
border-radius: 15px;
|
|
71
|
-
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
72
|
-
}
|
|
73
|
-
.yak-panel-container.yak-panel-drawer[data-position="left-center"],
|
|
74
|
-
.yak-panel-container.yak-panel-drawer[data-position="top-left"],
|
|
75
|
-
.yak-panel-container.yak-panel-drawer[data-position="bottom-left"] {
|
|
76
|
-
top: 16px; left: 16px; bottom: 16px;
|
|
77
|
-
transform: translateX(calc(-100% - 16px));
|
|
78
|
-
}
|
|
79
|
-
.yak-panel-container.yak-panel-drawer[data-position="right-center"],
|
|
80
|
-
.yak-panel-container.yak-panel-drawer[data-position="top-right"],
|
|
81
|
-
.yak-panel-container.yak-panel-drawer[data-position="bottom-right"] {
|
|
82
|
-
top: 16px; right: 16px; bottom: 16px;
|
|
83
|
-
transform: translateX(calc(100% + 16px));
|
|
84
|
-
}
|
|
85
|
-
.yak-panel-container.yak-panel-drawer[data-position="top-center"],
|
|
86
|
-
.yak-panel-container.yak-panel-drawer[data-position="bottom-center"] {
|
|
87
|
-
top: 16px; right: 16px; bottom: 16px;
|
|
88
|
-
transform: translateX(calc(100% + 16px));
|
|
89
|
-
}
|
|
90
|
-
.yak-panel-container.yak-panel-drawer[data-open="true"] { transform: translateX(0); }
|
|
91
|
-
|
|
92
|
-
.yak-panel-iframe {
|
|
93
|
-
position: absolute; inset: 0;
|
|
94
|
-
width: 100%; height: 100%; border: none;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
@media (max-width: 640px) {
|
|
98
|
-
.yak-panel-container:not(.yak-panel-drawer) {
|
|
99
|
-
width: 100% !important; height: 100% !important; height: 100dvh !important;
|
|
100
|
-
max-width: none !important; max-height: none !important;
|
|
101
|
-
top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important;
|
|
102
|
-
border-radius: 0 !important;
|
|
103
|
-
}
|
|
104
|
-
.yak-panel-container.yak-panel-drawer { width: 100% !important; max-width: none !important; }
|
|
105
|
-
}
|
|
106
|
-
`;
|
|
107
|
-
}
|
|
108
|
-
function getTriggerStyles() {
|
|
109
|
-
return `
|
|
110
|
-
.yak-widget-trigger {
|
|
111
|
-
position: fixed; z-index: 9997;
|
|
112
|
-
display: inline-flex; align-items: center; gap: 8px;
|
|
113
|
-
border: none; border-radius: 30px;
|
|
114
|
-
padding: 5px; height: 45px;
|
|
115
|
-
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
|
|
116
|
-
background-color: #000; color: #fff;
|
|
117
|
-
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
118
|
-
font-family: system-ui, -apple-system, sans-serif;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
.yak-widget-trigger[data-position="top-left"] { top: 28px; left: 28px; }
|
|
122
|
-
.yak-widget-trigger[data-position="top-center"] { top: 28px; left: 50%; transform: translateX(-50%); }
|
|
123
|
-
.yak-widget-trigger[data-position="top-right"] { top: 28px; right: 28px; }
|
|
124
|
-
.yak-widget-trigger[data-position="left-center"] { top: 50%; left: 28px; transform: translateY(-50%); }
|
|
125
|
-
.yak-widget-trigger[data-position="right-center"] { top: 50%; right: 28px; transform: translateY(-50%); }
|
|
126
|
-
.yak-widget-trigger[data-position="bottom-left"] { bottom: 28px; left: 28px; }
|
|
127
|
-
.yak-widget-trigger[data-position="bottom-center"] { bottom: 28px; left: 50%; transform: translateX(-50%); }
|
|
128
|
-
.yak-widget-trigger[data-position="bottom-right"] { bottom: 28px; right: 28px; }
|
|
129
|
-
|
|
130
|
-
.yak-widget-icon-bg {
|
|
131
|
-
display: flex; align-items: center; justify-content: center;
|
|
132
|
-
width: 36px; height: 36px; border-radius: 50%;
|
|
133
|
-
background-color: rgba(255, 255, 255, 0.1);
|
|
134
|
-
flex-shrink: 0;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
.yak-widget-icon { width: 20px; height: 20px; color: currentColor; }
|
|
138
|
-
|
|
139
|
-
.yak-widget-trigger-icon-btn {
|
|
140
|
-
display: inline-flex; align-items: center; justify-content: center;
|
|
141
|
-
width: 36px; height: 36px; border-radius: 50%;
|
|
142
|
-
border: none; padding: 0;
|
|
143
|
-
background-color: transparent;
|
|
144
|
-
color: inherit;
|
|
145
|
-
cursor: pointer;
|
|
146
|
-
position: relative;
|
|
147
|
-
transition: background-color 0.15s ease;
|
|
148
|
-
flex-shrink: 0;
|
|
149
|
-
}
|
|
150
|
-
.yak-widget-trigger-icon-btn:hover { background-color: rgba(255, 255, 255, 0.12); }
|
|
151
|
-
.yak-widget-trigger-icon-btn:disabled { cursor: wait; opacity: 0.7; }
|
|
152
|
-
.yak-widget-trigger-icon-btn svg { width: 20px; height: 20px; display: block; }
|
|
153
|
-
|
|
154
|
-
.yak-widget-trigger-icon-btn[data-action="voice"][data-state="error"] {
|
|
155
|
-
background-color: rgba(185, 28, 28, 0.18);
|
|
156
|
-
}
|
|
157
|
-
.yak-widget-trigger-icon-btn[data-action="voice"][data-state="listening"]::after,
|
|
158
|
-
.yak-widget-trigger-icon-btn[data-action="voice"][data-state="speaking"]::after {
|
|
159
|
-
content: "";
|
|
160
|
-
position: absolute; inset: 2px;
|
|
161
|
-
border-radius: 50%;
|
|
162
|
-
border: 2px solid currentColor;
|
|
163
|
-
pointer-events: none;
|
|
164
|
-
}
|
|
165
|
-
.yak-widget-trigger-icon-btn[data-action="voice"][data-state="listening"]::after {
|
|
166
|
-
opacity: 0.4; animation: yak-widget-pulse 1.2s ease-out infinite;
|
|
167
|
-
}
|
|
168
|
-
.yak-widget-trigger-icon-btn[data-action="voice"][data-state="speaking"]::after {
|
|
169
|
-
opacity: 0.5; animation: yak-widget-wave 0.8s ease-in-out infinite;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
@media (prefers-color-scheme: dark) {
|
|
173
|
-
.yak-widget-trigger:not(.yak-widget-light) .yak-widget-icon { filter: invert(1); }
|
|
174
|
-
.yak-widget-trigger:not(.yak-widget-light) .yak-widget-trigger-icon-btn:hover {
|
|
175
|
-
background-color: rgba(255, 255, 255, 0.12);
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
.yak-widget-trigger.yak-widget-dark .yak-widget-icon { filter: invert(1); }
|
|
179
|
-
.yak-widget-trigger.yak-widget-light .yak-widget-icon { filter: none; }
|
|
180
|
-
.yak-widget-trigger.yak-widget-light .yak-widget-trigger-icon-btn:hover {
|
|
181
|
-
background-color: rgba(0, 0, 0, 0.06);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
.yak-widget-spinner {
|
|
185
|
-
width: 20px; height: 20px;
|
|
186
|
-
border: 2px solid currentColor; border-top-color: transparent; border-radius: 50%;
|
|
187
|
-
animation: yak-widget-spin 0.8s linear infinite;
|
|
188
|
-
}
|
|
189
|
-
@keyframes yak-widget-spin { to { transform: rotate(360deg); } }
|
|
190
|
-
@keyframes yak-widget-pulse {
|
|
191
|
-
0% { transform: scale(1); opacity: 0.5; }
|
|
192
|
-
100% { transform: scale(1.45); opacity: 0; }
|
|
193
|
-
}
|
|
194
|
-
@keyframes yak-widget-wave {
|
|
195
|
-
0%, 100% { transform: scale(1); opacity: 0.5; }
|
|
196
|
-
50% { transform: scale(1.25); opacity: 0.9; }
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
.yak-widget-trigger.yak-widget-custom-light {
|
|
200
|
-
background-color: var(--yak-btn-light-bg, #fff); color: var(--yak-btn-light-color, #000);
|
|
201
|
-
border: 1px solid var(--yak-btn-light-border, transparent);
|
|
202
|
-
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
203
|
-
}
|
|
204
|
-
.yak-widget-trigger.yak-widget-custom-dark {
|
|
205
|
-
background-color: var(--yak-btn-dark-bg, #000); color: var(--yak-btn-dark-color, #fff);
|
|
206
|
-
border: 1px solid var(--yak-btn-dark-border, transparent);
|
|
207
|
-
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
@media (prefers-color-scheme: light) {
|
|
211
|
-
.yak-widget-trigger[data-has-light-custom]:not(.yak-widget-dark) {
|
|
212
|
-
background-color: var(--yak-btn-light-bg, #fff); color: var(--yak-btn-light-color, #000);
|
|
213
|
-
border: 1px solid var(--yak-btn-light-border, transparent);
|
|
214
|
-
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
@media (prefers-color-scheme: dark) {
|
|
218
|
-
.yak-widget-trigger[data-has-dark-custom]:not(.yak-widget-light) {
|
|
219
|
-
background-color: var(--yak-btn-dark-bg, #000); color: var(--yak-btn-dark-color, #fff);
|
|
220
|
-
border: 1px solid var(--yak-btn-dark-border, transparent);
|
|
221
|
-
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
@media (prefers-color-scheme: light) {
|
|
226
|
-
.yak-widget-trigger:not(.yak-widget-dark):not([data-has-light-custom]) {
|
|
227
|
-
background-color: #fff; color: #000;
|
|
228
|
-
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); border: 1px solid #e5e5e5;
|
|
229
|
-
}
|
|
230
|
-
.yak-widget-trigger:not(.yak-widget-dark):not([data-has-light-custom]) .yak-widget-icon-bg {
|
|
231
|
-
background-color: rgba(0, 0, 0, 0.05);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
@media (prefers-color-scheme: dark) {
|
|
236
|
-
.yak-widget-trigger:not(.yak-widget-light):not([data-has-dark-custom]) {
|
|
237
|
-
background-color: #000; color: #fff;
|
|
238
|
-
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); border: none;
|
|
239
|
-
}
|
|
240
|
-
.yak-widget-trigger:not(.yak-widget-light):not([data-has-dark-custom]) .yak-widget-icon-bg {
|
|
241
|
-
background-color: rgba(255, 255, 255, 0.1);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
.yak-widget-trigger.yak-widget-light:not(.yak-widget-custom-light) {
|
|
246
|
-
background-color: #fff; color: #000;
|
|
247
|
-
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); border: 1px solid #e5e5e5;
|
|
248
|
-
}
|
|
249
|
-
.yak-widget-trigger.yak-widget-light:not(.yak-widget-custom-light) .yak-widget-icon-bg {
|
|
250
|
-
background-color: rgba(0, 0, 0, 0.05);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
.yak-widget-trigger.yak-widget-dark:not(.yak-widget-custom-dark) {
|
|
254
|
-
background-color: #000; color: #fff;
|
|
255
|
-
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); border: none;
|
|
256
|
-
}
|
|
257
|
-
.yak-widget-trigger.yak-widget-dark:not(.yak-widget-custom-dark) .yak-widget-icon-bg {
|
|
258
|
-
background-color: rgba(255, 255, 255, 0.1);
|
|
259
|
-
}
|
|
260
|
-
`;
|
|
261
|
-
}
|
|
262
|
-
// ── YakEmbed class ──────────────────────────────────────────────────────────
|
|
263
|
-
/**
|
|
264
|
-
* Drop-in widget that renders the yak trigger pill plus, depending on mode,
|
|
265
|
-
* the chat iframe panel and/or a WebRTC voice session. Composes both
|
|
266
|
-
* `YakClient` (chat) and `YakVoiceSession` (voice) under one trigger.
|
|
267
|
-
*
|
|
268
|
-
* @example
|
|
269
|
-
* ```ts
|
|
270
|
-
* const embed = new YakEmbed({
|
|
271
|
-
* appId: "my-app",
|
|
272
|
-
* mode: "both",
|
|
273
|
-
* theme: { position: "bottom-left" },
|
|
274
|
-
* onToolCall: async (name, args) => { ... },
|
|
275
|
-
* });
|
|
276
|
-
* embed.mount();
|
|
277
|
-
* ```
|
|
278
|
-
*/
|
|
279
|
-
export class YakEmbed {
|
|
280
|
-
client;
|
|
281
|
-
voice;
|
|
282
|
-
config;
|
|
283
|
-
mode;
|
|
284
|
-
// DOM elements
|
|
285
|
-
styleEl = null;
|
|
286
|
-
panelRoot = null;
|
|
287
|
-
container = null;
|
|
288
|
-
iframe = null;
|
|
289
|
-
triggerEl = null;
|
|
290
|
-
chatButton = null;
|
|
291
|
-
voiceButton = null;
|
|
292
|
-
// State
|
|
293
|
-
isOpen = false;
|
|
294
|
-
isReady = false;
|
|
295
|
-
isExpanded = false;
|
|
296
|
-
hasBeenOpened = false;
|
|
297
|
-
pendingPrompt = null;
|
|
298
|
-
mounted = false;
|
|
299
|
-
voiceMachine = INITIAL_VOICE_MACHINE;
|
|
300
|
-
// Listeners
|
|
301
|
-
stateListeners = new Set();
|
|
302
|
-
voiceListeners = new Set();
|
|
303
|
-
unsubscribeVoice = null;
|
|
304
|
-
mobileQuery = null;
|
|
305
|
-
mobileHandler = null;
|
|
306
|
-
expandHandler = null;
|
|
307
|
-
constructor(config) {
|
|
308
|
-
this.config = config;
|
|
309
|
-
this.mode = config.mode ?? "chat";
|
|
310
|
-
// Wrap callbacks to integrate with our state
|
|
311
|
-
this.client = new YakClient({
|
|
312
|
-
...config,
|
|
313
|
-
onReady: () => {
|
|
314
|
-
this.isReady = true;
|
|
315
|
-
this.updatePanelState();
|
|
316
|
-
this.updateChatButtonState();
|
|
317
|
-
this.sendPendingPrompt();
|
|
318
|
-
this.sendFocusIfOpen();
|
|
319
|
-
this.notifyMobileState();
|
|
320
|
-
// Surface the ready transition to framework subscribers (React/Vue/
|
|
321
|
-
// Svelte/Angular all derive their loading state from this). Without
|
|
322
|
-
// it `isReady` stays false forever and consumers spin indefinitely.
|
|
323
|
-
this.notifyListeners();
|
|
324
|
-
config.onReady?.();
|
|
325
|
-
},
|
|
326
|
-
onClose: () => {
|
|
327
|
-
this.close();
|
|
328
|
-
config.onClose?.();
|
|
329
|
-
},
|
|
330
|
-
});
|
|
331
|
-
if (this.mode !== "chat") {
|
|
332
|
-
const voiceConfig = {
|
|
333
|
-
appId: config.appId,
|
|
334
|
-
// A single `origin` on the embed drives both surfaces: chat (iframe)
|
|
335
|
-
// and voice (mint endpoint).
|
|
336
|
-
apiOrigin: config.origin,
|
|
337
|
-
getConfig: config.getConfig,
|
|
338
|
-
chatConfig: config.chatConfig,
|
|
339
|
-
onToolCall: config.onToolCall,
|
|
340
|
-
onRedirect: config.onRedirect,
|
|
341
|
-
};
|
|
342
|
-
this.voice = new YakVoiceSession(voiceConfig);
|
|
343
|
-
}
|
|
344
|
-
else {
|
|
345
|
-
this.voice = null;
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
/** The underlying headless YakClient for advanced usage */
|
|
349
|
-
getClient() {
|
|
350
|
-
return this.client;
|
|
351
|
-
}
|
|
352
|
-
/** The underlying voice session — null when mode === "chat". */
|
|
353
|
-
getVoiceSession() {
|
|
354
|
-
return this.voice;
|
|
355
|
-
}
|
|
356
|
-
/** Current widget mode (immutable for the lifetime of the embed). */
|
|
357
|
-
getMode() {
|
|
358
|
-
return this.mode;
|
|
359
|
-
}
|
|
360
|
-
// ── Lifecycle ───────────────────────────────────────────────────────────
|
|
361
|
-
/**
|
|
362
|
-
* Mount the widget into the DOM. Call once after construction.
|
|
363
|
-
* Inserts styles and trigger button (if enabled). The chat iframe is
|
|
364
|
-
* lazily created on the first call to open().
|
|
365
|
-
*/
|
|
366
|
-
mount(target) {
|
|
367
|
-
if (this.mounted)
|
|
368
|
-
return;
|
|
369
|
-
this.mounted = true;
|
|
370
|
-
const parent = target ?? this.config.target ?? document.body;
|
|
371
|
-
// Inject styles
|
|
372
|
-
this.styleEl = document.createElement("style");
|
|
373
|
-
this.styleEl.textContent = getPanelStyles() + getTriggerStyles();
|
|
374
|
-
parent.appendChild(this.styleEl);
|
|
375
|
-
// Create trigger
|
|
376
|
-
if (this.config.trigger !== false) {
|
|
377
|
-
this.createTrigger(parent);
|
|
378
|
-
}
|
|
379
|
-
// Listen for expansion messages from iframe
|
|
380
|
-
this.expandHandler = (event) => {
|
|
381
|
-
if (event.data?.type === "YAK_SET_EXPANDED") {
|
|
382
|
-
this.isExpanded = Boolean(event.data.expanded);
|
|
383
|
-
this.updatePanelState();
|
|
384
|
-
this.notifyListeners();
|
|
385
|
-
}
|
|
386
|
-
};
|
|
387
|
-
window.addEventListener("message", this.expandHandler);
|
|
388
|
-
// Start the client's message listeners
|
|
389
|
-
this.client.mount();
|
|
390
|
-
// Subscribe to voice state for trigger updates + fan-out to listeners
|
|
391
|
-
if (this.voice) {
|
|
392
|
-
this.voiceMachine = this.voice.getState();
|
|
393
|
-
this.unsubscribeVoice = this.voice.onStateChange((machine) => {
|
|
394
|
-
this.voiceMachine = machine;
|
|
395
|
-
this.updateVoiceButtonState();
|
|
396
|
-
for (const listener of this.voiceListeners) {
|
|
397
|
-
try {
|
|
398
|
-
listener(machine);
|
|
399
|
-
}
|
|
400
|
-
catch (err) {
|
|
401
|
-
logger.warn("Error in voice listener:", err);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
});
|
|
405
|
-
this.updateVoiceButtonState();
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
/** Remove all DOM elements and event listeners. */
|
|
409
|
-
destroy() {
|
|
410
|
-
if (!this.mounted)
|
|
411
|
-
return;
|
|
412
|
-
this.mounted = false;
|
|
413
|
-
this.client.unmount();
|
|
414
|
-
if (this.unsubscribeVoice) {
|
|
415
|
-
this.unsubscribeVoice();
|
|
416
|
-
this.unsubscribeVoice = null;
|
|
417
|
-
}
|
|
418
|
-
this.voice?.destroy();
|
|
419
|
-
if (this.expandHandler) {
|
|
420
|
-
window.removeEventListener("message", this.expandHandler);
|
|
421
|
-
this.expandHandler = null;
|
|
422
|
-
}
|
|
423
|
-
if (this.mobileQuery && this.mobileHandler) {
|
|
424
|
-
this.mobileQuery.removeEventListener("change", this.mobileHandler);
|
|
425
|
-
this.mobileQuery = null;
|
|
426
|
-
this.mobileHandler = null;
|
|
427
|
-
}
|
|
428
|
-
this.panelRoot?.remove();
|
|
429
|
-
this.triggerEl?.remove();
|
|
430
|
-
this.styleEl?.remove();
|
|
431
|
-
this.panelRoot = null;
|
|
432
|
-
this.container = null;
|
|
433
|
-
this.iframe = null;
|
|
434
|
-
this.triggerEl = null;
|
|
435
|
-
this.chatButton = null;
|
|
436
|
-
this.voiceButton = null;
|
|
437
|
-
this.styleEl = null;
|
|
438
|
-
this.isOpen = false;
|
|
439
|
-
this.isReady = false;
|
|
440
|
-
this.isExpanded = false;
|
|
441
|
-
this.hasBeenOpened = false;
|
|
442
|
-
this.stateListeners.clear();
|
|
443
|
-
this.voiceListeners.clear();
|
|
444
|
-
}
|
|
445
|
-
// ── Public chat API ─────────────────────────────────────────────────────
|
|
446
|
-
/** Open the chat widget. Creates the iframe on first call (lazy mount). */
|
|
447
|
-
open() {
|
|
448
|
-
if (!this.mounted)
|
|
449
|
-
return;
|
|
450
|
-
if (!this.hasBeenOpened) {
|
|
451
|
-
this.hasBeenOpened = true;
|
|
452
|
-
const parent = this.config.target ?? document.body;
|
|
453
|
-
this.createPanel(parent);
|
|
454
|
-
}
|
|
455
|
-
this.isOpen = true;
|
|
456
|
-
this.client.setWidgetOpen(true);
|
|
457
|
-
this.updatePanelState();
|
|
458
|
-
this.updateChatButtonState();
|
|
459
|
-
this.sendFocusIfOpen();
|
|
460
|
-
this.notifyListeners();
|
|
461
|
-
}
|
|
462
|
-
/** Close the chat widget. The iframe remains in the DOM for instant re-open. */
|
|
463
|
-
close() {
|
|
464
|
-
this.isOpen = false;
|
|
465
|
-
this.client.setWidgetOpen(false);
|
|
466
|
-
this.updatePanelState();
|
|
467
|
-
this.updateChatButtonState();
|
|
468
|
-
this.notifyListeners();
|
|
469
|
-
}
|
|
470
|
-
/** Toggle the chat widget open/closed. */
|
|
471
|
-
toggle() {
|
|
472
|
-
if (this.isOpen) {
|
|
473
|
-
this.close();
|
|
474
|
-
}
|
|
475
|
-
else {
|
|
476
|
-
this.open();
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
/** Open the chat and immediately send a prompt. */
|
|
480
|
-
openWithPrompt(prompt) {
|
|
481
|
-
this.pendingPrompt = prompt;
|
|
482
|
-
this.open();
|
|
483
|
-
this.sendPendingPrompt();
|
|
484
|
-
}
|
|
485
|
-
/** Get the current widget state. */
|
|
486
|
-
getState() {
|
|
487
|
-
return {
|
|
488
|
-
isOpen: this.isOpen,
|
|
489
|
-
isReady: this.isReady,
|
|
490
|
-
isLoading: this.isOpen && !this.isReady,
|
|
491
|
-
isExpanded: this.isExpanded,
|
|
492
|
-
};
|
|
493
|
-
}
|
|
494
|
-
/** Subscribe to state changes. Returns an unsubscribe function. */
|
|
495
|
-
onStateChange(listener) {
|
|
496
|
-
this.stateListeners.add(listener);
|
|
497
|
-
return () => {
|
|
498
|
-
this.stateListeners.delete(listener);
|
|
499
|
-
};
|
|
500
|
-
}
|
|
501
|
-
// ── Public voice API ────────────────────────────────────────────────────
|
|
502
|
-
/** Start a voice session. Must be invoked from a user gesture. */
|
|
503
|
-
voiceStart() {
|
|
504
|
-
return this.voice ? this.voice.start() : Promise.resolve();
|
|
505
|
-
}
|
|
506
|
-
/** Stop the current voice session. */
|
|
507
|
-
voiceStop() {
|
|
508
|
-
return this.voice ? this.voice.stop() : Promise.resolve();
|
|
509
|
-
}
|
|
510
|
-
/** Toggle: start if idle/error, stop if active. */
|
|
511
|
-
async voiceToggle() {
|
|
512
|
-
if (!this.voice)
|
|
513
|
-
return;
|
|
514
|
-
const state = this.voice.getState().state;
|
|
515
|
-
if (state === "idle" || state === "error") {
|
|
516
|
-
await this.voice.start();
|
|
517
|
-
}
|
|
518
|
-
else if (state === "listening" || state === "speaking" || state === "thinking") {
|
|
519
|
-
await this.voice.stop();
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
/** Current voice machine snapshot. */
|
|
523
|
-
getVoiceState() {
|
|
524
|
-
return this.voice ? this.voice.getState() : INITIAL_VOICE_MACHINE;
|
|
525
|
-
}
|
|
526
|
-
/** Subscribe to voice state changes. */
|
|
527
|
-
onVoiceStateChange(listener) {
|
|
528
|
-
this.voiceListeners.add(listener);
|
|
529
|
-
return () => {
|
|
530
|
-
this.voiceListeners.delete(listener);
|
|
531
|
-
};
|
|
532
|
-
}
|
|
533
|
-
// ── DOM creation ────────────────────────────────────────────────────────
|
|
534
|
-
createPanel(parent) {
|
|
535
|
-
const theme = this.config.theme;
|
|
536
|
-
const position = theme?.position ?? DEFAULT_POSITION;
|
|
537
|
-
const colorMode = theme?.colorMode;
|
|
538
|
-
const displayMode = theme?.displayMode ?? "chatbox";
|
|
539
|
-
const isDrawer = displayMode === "drawer";
|
|
540
|
-
// Root overlay (pointer-events: none)
|
|
541
|
-
this.panelRoot = document.createElement("div");
|
|
542
|
-
this.panelRoot.className = "yak-panel-root";
|
|
543
|
-
// Container
|
|
544
|
-
this.container = document.createElement("div");
|
|
545
|
-
const classes = ["yak-panel-container"];
|
|
546
|
-
if (isDrawer)
|
|
547
|
-
classes.push("yak-panel-drawer");
|
|
548
|
-
if (colorMode === "light")
|
|
549
|
-
classes.push("yak-panel-light");
|
|
550
|
-
else if (colorMode === "dark")
|
|
551
|
-
classes.push("yak-panel-dark");
|
|
552
|
-
this.container.className = classes.join(" ");
|
|
553
|
-
this.container.dataset.position = position;
|
|
554
|
-
// Iframe — set `allow` and `title` BEFORE `src` (some browsers
|
|
555
|
-
// persist the pre-load Permissions-Policy otherwise).
|
|
556
|
-
this.iframe = document.createElement("iframe");
|
|
557
|
-
this.iframe.allow = "clipboard-write";
|
|
558
|
-
this.iframe.title = "yak-chat-host";
|
|
559
|
-
this.iframe.className = "yak-panel-iframe";
|
|
560
|
-
this.iframe.src = this.client.getEmbedUrl();
|
|
561
|
-
this.iframe.addEventListener("load", () => {
|
|
562
|
-
this.client.setIframeWindow(this.iframe?.contentWindow ?? null);
|
|
563
|
-
});
|
|
564
|
-
this.container.appendChild(this.iframe);
|
|
565
|
-
this.panelRoot.appendChild(this.container);
|
|
566
|
-
parent.appendChild(this.panelRoot);
|
|
567
|
-
// Set up mobile detection
|
|
568
|
-
this.mobileQuery = window.matchMedia("(max-width: 640px)");
|
|
569
|
-
this.mobileHandler = (e) => {
|
|
570
|
-
this.notifyIframeFullscreen(e.matches);
|
|
571
|
-
};
|
|
572
|
-
this.mobileQuery.addEventListener("change", this.mobileHandler);
|
|
573
|
-
}
|
|
574
|
-
createTrigger(parent) {
|
|
575
|
-
const theme = this.config.theme;
|
|
576
|
-
const position = theme?.position ?? DEFAULT_POSITION;
|
|
577
|
-
const colorMode = theme?.colorMode;
|
|
578
|
-
const triggerConfig = typeof this.config.trigger === "object" ? this.config.trigger : {};
|
|
579
|
-
this.triggerEl = document.createElement("div");
|
|
580
|
-
this.triggerEl.dataset.position = position;
|
|
581
|
-
this.triggerEl.dataset.mode = this.mode;
|
|
582
|
-
this.triggerEl.className = this.buildTriggerClasses(colorMode, triggerConfig);
|
|
583
|
-
this.applyTriggerCustomColors(triggerConfig);
|
|
584
|
-
// Logo circle on the left
|
|
585
|
-
const iconBg = document.createElement("div");
|
|
586
|
-
iconBg.className = "yak-widget-icon-bg";
|
|
587
|
-
const logoImg = document.createElement("img");
|
|
588
|
-
logoImg.src = `${this.client.getIframeOrigin()}/logo.svg`;
|
|
589
|
-
logoImg.alt = "";
|
|
590
|
-
logoImg.width = 20;
|
|
591
|
-
logoImg.height = 20;
|
|
592
|
-
logoImg.className = "yak-widget-icon";
|
|
593
|
-
iconBg.appendChild(logoImg);
|
|
594
|
-
this.triggerEl.appendChild(iconBg);
|
|
595
|
-
// Chat icon button
|
|
596
|
-
if (this.mode === "chat" || this.mode === "both") {
|
|
597
|
-
this.chatButton = document.createElement("button");
|
|
598
|
-
this.chatButton.type = "button";
|
|
599
|
-
this.chatButton.className = "yak-widget-trigger-icon-btn";
|
|
600
|
-
this.chatButton.dataset.action = "chat";
|
|
601
|
-
this.chatButton.setAttribute("aria-label", "Open chat");
|
|
602
|
-
this.chatButton.innerHTML = MESSAGE_CIRCLE_SVG;
|
|
603
|
-
this.chatButton.addEventListener("click", () => this.open());
|
|
604
|
-
this.triggerEl.appendChild(this.chatButton);
|
|
605
|
-
}
|
|
606
|
-
// Voice icon button
|
|
607
|
-
if (this.mode === "voice" || this.mode === "both") {
|
|
608
|
-
this.voiceButton = document.createElement("button");
|
|
609
|
-
this.voiceButton.type = "button";
|
|
610
|
-
this.voiceButton.className = "yak-widget-trigger-icon-btn";
|
|
611
|
-
this.voiceButton.dataset.action = "voice";
|
|
612
|
-
this.voiceButton.dataset.state = "idle";
|
|
613
|
-
this.voiceButton.setAttribute("aria-label", VOICE_STATE_ARIA.idle);
|
|
614
|
-
this.voiceButton.innerHTML = AUDIO_LINES_SVG;
|
|
615
|
-
this.voiceButton.addEventListener("click", () => {
|
|
616
|
-
void this.voiceToggle();
|
|
617
|
-
});
|
|
618
|
-
this.triggerEl.appendChild(this.voiceButton);
|
|
619
|
-
}
|
|
620
|
-
parent.appendChild(this.triggerEl);
|
|
621
|
-
}
|
|
622
|
-
buildTriggerClasses(colorMode, triggerConfig) {
|
|
623
|
-
const classes = ["yak-widget-trigger"];
|
|
624
|
-
if (colorMode === "light")
|
|
625
|
-
classes.push("yak-widget-light");
|
|
626
|
-
else if (colorMode === "dark")
|
|
627
|
-
classes.push("yak-widget-dark");
|
|
628
|
-
const hasLightCustom = triggerConfig.lightButton?.background ||
|
|
629
|
-
triggerConfig.lightButton?.color ||
|
|
630
|
-
triggerConfig.lightButton?.border;
|
|
631
|
-
const hasDarkCustom = triggerConfig.darkButton?.background ||
|
|
632
|
-
triggerConfig.darkButton?.color ||
|
|
633
|
-
triggerConfig.darkButton?.border;
|
|
634
|
-
if (colorMode === "light" && hasLightCustom)
|
|
635
|
-
classes.push("yak-widget-custom-light");
|
|
636
|
-
else if (colorMode === "dark" && hasDarkCustom)
|
|
637
|
-
classes.push("yak-widget-custom-dark");
|
|
638
|
-
return classes.join(" ");
|
|
639
|
-
}
|
|
640
|
-
applyTriggerCustomColors(triggerConfig) {
|
|
641
|
-
if (!this.triggerEl)
|
|
642
|
-
return;
|
|
643
|
-
const { lightButton, darkButton } = triggerConfig;
|
|
644
|
-
const hasLightCustom = lightButton?.background || lightButton?.color || lightButton?.border;
|
|
645
|
-
const hasDarkCustom = darkButton?.background || darkButton?.color || darkButton?.border;
|
|
646
|
-
if (hasLightCustom || hasDarkCustom) {
|
|
647
|
-
const vars = [
|
|
648
|
-
["--yak-btn-light-bg", lightButton?.background],
|
|
649
|
-
["--yak-btn-light-color", lightButton?.color],
|
|
650
|
-
["--yak-btn-light-border", lightButton?.border],
|
|
651
|
-
["--yak-btn-dark-bg", darkButton?.background],
|
|
652
|
-
["--yak-btn-dark-color", darkButton?.color],
|
|
653
|
-
["--yak-btn-dark-border", darkButton?.border],
|
|
654
|
-
];
|
|
655
|
-
for (const [prop, value] of vars) {
|
|
656
|
-
if (value)
|
|
657
|
-
this.triggerEl.style.setProperty(prop, value);
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
if (hasLightCustom)
|
|
661
|
-
this.triggerEl.dataset.hasLightCustom = "true";
|
|
662
|
-
if (hasDarkCustom)
|
|
663
|
-
this.triggerEl.dataset.hasDarkCustom = "true";
|
|
664
|
-
}
|
|
665
|
-
// ── Internal state management ───────────────────────────────────────────
|
|
666
|
-
updatePanelState() {
|
|
667
|
-
if (!this.container)
|
|
668
|
-
return;
|
|
669
|
-
this.container.dataset.open = String(this.isOpen && this.isReady);
|
|
670
|
-
this.container.dataset.expanded = String(this.isExpanded);
|
|
671
|
-
if (this.panelRoot) {
|
|
672
|
-
this.panelRoot.dataset.expanded = String(this.isExpanded);
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
updateChatButtonState() {
|
|
676
|
-
if (!this.chatButton)
|
|
677
|
-
return;
|
|
678
|
-
const isLoading = this.isOpen && !this.isReady;
|
|
679
|
-
this.chatButton.disabled = isLoading;
|
|
680
|
-
this.chatButton.setAttribute("aria-label", isLoading ? "Loading chat" : "Open chat");
|
|
681
|
-
if (isLoading) {
|
|
682
|
-
this.chatButton.innerHTML = `<span class="yak-widget-spinner" aria-hidden="true"></span>`;
|
|
683
|
-
}
|
|
684
|
-
else {
|
|
685
|
-
this.chatButton.innerHTML = MESSAGE_CIRCLE_SVG;
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
updateVoiceButtonState() {
|
|
689
|
-
if (!this.voiceButton)
|
|
690
|
-
return;
|
|
691
|
-
const state = this.voiceMachine.state;
|
|
692
|
-
this.voiceButton.dataset.state = state;
|
|
693
|
-
this.voiceButton.setAttribute("aria-label", VOICE_STATE_ARIA[state]);
|
|
694
|
-
this.voiceButton.disabled = state === "connecting";
|
|
695
|
-
this.voiceButton.innerHTML = this.iconForVoiceState(state);
|
|
696
|
-
}
|
|
697
|
-
iconForVoiceState(state) {
|
|
698
|
-
if (state === "connecting") {
|
|
699
|
-
return `<span class="yak-widget-spinner" aria-hidden="true"></span>`;
|
|
700
|
-
}
|
|
701
|
-
if (state === "listening" || state === "speaking" || state === "thinking") {
|
|
702
|
-
return STOP_SVG;
|
|
703
|
-
}
|
|
704
|
-
return AUDIO_LINES_SVG;
|
|
705
|
-
}
|
|
706
|
-
sendPendingPrompt() {
|
|
707
|
-
if (!this.pendingPrompt || !this.isReady)
|
|
708
|
-
return;
|
|
709
|
-
logger.debug("Sending pending prompt:", this.pendingPrompt);
|
|
710
|
-
this.client.sendPrompt(this.pendingPrompt);
|
|
711
|
-
this.pendingPrompt = null;
|
|
712
|
-
}
|
|
713
|
-
sendFocusIfOpen() {
|
|
714
|
-
if (this.isOpen && this.isReady) {
|
|
715
|
-
this.client.sendFocus();
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
notifyMobileState() {
|
|
719
|
-
if (this.mobileQuery) {
|
|
720
|
-
this.notifyIframeFullscreen(this.mobileQuery.matches);
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
notifyIframeFullscreen(isFullscreen) {
|
|
724
|
-
if (!this.iframe?.contentWindow)
|
|
725
|
-
return;
|
|
726
|
-
const msg = {
|
|
727
|
-
type: "yak:viewport",
|
|
728
|
-
payload: { fullscreen: isFullscreen },
|
|
729
|
-
};
|
|
730
|
-
this.iframe.contentWindow.postMessage(msg, this.client.getIframeOrigin());
|
|
731
|
-
}
|
|
732
|
-
notifyListeners() {
|
|
733
|
-
const state = this.getState();
|
|
734
|
-
for (const listener of this.stateListeners) {
|
|
735
|
-
try {
|
|
736
|
-
listener(state);
|
|
737
|
-
}
|
|
738
|
-
catch (err) {
|
|
739
|
-
logger.warn("Error in state listener:", err);
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
}
|