@oyehello/chat 0.4.0 → 0.5.1
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/README.md +2 -1
- package/dist/index.cjs +494 -26
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +17 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.global.js +494 -26
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +494 -26
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -21,6 +21,9 @@ var ChatApiError = class extends Error {
|
|
|
21
21
|
meta;
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
// src/protocol.ts
|
|
25
|
+
var CHAT_PROTOCOL_VERSION = 1;
|
|
26
|
+
|
|
24
27
|
// src/transport.ts
|
|
25
28
|
async function asError(res, fallbackCode) {
|
|
26
29
|
let body = {};
|
|
@@ -31,9 +34,10 @@ async function asError(res, fallbackCode) {
|
|
|
31
34
|
return new ChatApiError(body.error ?? `HTTP ${res.status}`, res.status, body.code ?? fallbackCode, body.meta);
|
|
32
35
|
}
|
|
33
36
|
async function openSession(opts) {
|
|
34
|
-
const body = { agentId: opts.agentId };
|
|
37
|
+
const body = { agentId: opts.agentId, protocol: CHAT_PROTOCOL_VERSION };
|
|
35
38
|
if (opts.user) body.user = opts.user;
|
|
36
39
|
if (opts.clientTools && opts.clientTools.length > 0) body.clientTools = opts.clientTools;
|
|
40
|
+
if (opts.fingerprint) body.fingerprint = opts.fingerprint;
|
|
37
41
|
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions`, {
|
|
38
42
|
method: "POST",
|
|
39
43
|
credentials: "omit",
|
|
@@ -63,6 +67,15 @@ async function postMessage(opts) {
|
|
|
63
67
|
});
|
|
64
68
|
if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
|
|
65
69
|
}
|
|
70
|
+
async function postPageViews(opts) {
|
|
71
|
+
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/page-views`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
credentials: "omit",
|
|
74
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.sessionToken}` },
|
|
75
|
+
body: JSON.stringify({ views: opts.views })
|
|
76
|
+
});
|
|
77
|
+
if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
|
|
78
|
+
}
|
|
66
79
|
async function endSession(opts) {
|
|
67
80
|
await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/end`, {
|
|
68
81
|
method: "POST",
|
|
@@ -80,6 +93,15 @@ async function postToolResult(opts) {
|
|
|
80
93
|
}).catch(() => {
|
|
81
94
|
});
|
|
82
95
|
}
|
|
96
|
+
async function refreshSessionToken(opts) {
|
|
97
|
+
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/refresh-token`, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
credentials: "omit",
|
|
100
|
+
headers: { Authorization: `Bearer ${opts.sessionToken}` }
|
|
101
|
+
});
|
|
102
|
+
if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
|
|
103
|
+
return await res.json();
|
|
104
|
+
}
|
|
83
105
|
async function mintStreamTicket(opts) {
|
|
84
106
|
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/stream-ticket`, {
|
|
85
107
|
method: "POST",
|
|
@@ -92,15 +114,18 @@ async function mintStreamTicket(opts) {
|
|
|
92
114
|
function streamUrl(baseUrl, sessionId, ticket) {
|
|
93
115
|
return `${baseUrl}/chat/sessions/${sessionId}/stream?ticket=${encodeURIComponent(ticket)}`;
|
|
94
116
|
}
|
|
95
|
-
async function
|
|
96
|
-
const
|
|
117
|
+
async function uploadAttachment(opts) {
|
|
118
|
+
const form = new FormData();
|
|
119
|
+
form.append("file", opts.file, opts.file.name);
|
|
120
|
+
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/upload`, {
|
|
97
121
|
method: "POST",
|
|
98
122
|
credentials: "omit",
|
|
99
|
-
headers: {
|
|
100
|
-
body:
|
|
123
|
+
headers: { Authorization: `Bearer ${opts.sessionToken}` },
|
|
124
|
+
body: form
|
|
101
125
|
});
|
|
102
126
|
if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
|
|
103
|
-
|
|
127
|
+
const j = await res.json();
|
|
128
|
+
return { storageRef: j.storageRef, contentType: j.contentType, name: j.name };
|
|
104
129
|
}
|
|
105
130
|
async function getAttachmentUrl(opts) {
|
|
106
131
|
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/attachment-url`, {
|
|
@@ -112,9 +137,178 @@ async function getAttachmentUrl(opts) {
|
|
|
112
137
|
if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
|
|
113
138
|
return (await res.json()).url;
|
|
114
139
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
140
|
+
|
|
141
|
+
// src/fingerprint.ts
|
|
142
|
+
async function sha256Hex(input) {
|
|
143
|
+
const g = globalThis;
|
|
144
|
+
try {
|
|
145
|
+
if (g.crypto?.subtle && typeof TextEncoder !== "undefined") {
|
|
146
|
+
const buf = await g.crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
|
|
147
|
+
return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 32);
|
|
148
|
+
}
|
|
149
|
+
} catch {
|
|
150
|
+
}
|
|
151
|
+
let h = 5381;
|
|
152
|
+
for (let i = 0; i < input.length; i++) h = (h << 5) + h + input.charCodeAt(i) >>> 0;
|
|
153
|
+
return h.toString(16).padStart(8, "0");
|
|
154
|
+
}
|
|
155
|
+
async function computeDeviceFingerprint() {
|
|
156
|
+
try {
|
|
157
|
+
const nav = typeof navigator !== "undefined" ? navigator : null;
|
|
158
|
+
if (!nav) return null;
|
|
159
|
+
const scr = typeof screen !== "undefined" ? screen : null;
|
|
160
|
+
const tz = (() => {
|
|
161
|
+
try {
|
|
162
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
163
|
+
} catch {
|
|
164
|
+
return "";
|
|
165
|
+
}
|
|
166
|
+
})();
|
|
167
|
+
const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : "";
|
|
168
|
+
const signals = [
|
|
169
|
+
nav.userAgent,
|
|
170
|
+
Array.isArray(nav.languages) ? nav.languages.join(",") : "",
|
|
171
|
+
nav.language,
|
|
172
|
+
nav.platform,
|
|
173
|
+
nav.hardwareConcurrency,
|
|
174
|
+
nav.deviceMemory,
|
|
175
|
+
scr ? `${scr.width}x${scr.height}x${scr.colorDepth}` : "",
|
|
176
|
+
dpr,
|
|
177
|
+
tz
|
|
178
|
+
].map((v) => String(v ?? "")).join("|");
|
|
179
|
+
return await sha256Hex(signals);
|
|
180
|
+
} catch {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/journey.ts
|
|
186
|
+
var MAX_BUFFER = 25;
|
|
187
|
+
var storageKey = (agentId) => `oyehello-chat:journey:${agentId}`;
|
|
188
|
+
var store = () => {
|
|
189
|
+
try {
|
|
190
|
+
return globalThis.sessionStorage ?? null;
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
function load(agentId) {
|
|
196
|
+
const s = store();
|
|
197
|
+
if (!s) return { views: [], flushed: 0 };
|
|
198
|
+
try {
|
|
199
|
+
const raw = s.getItem(storageKey(agentId));
|
|
200
|
+
if (!raw) return { views: [], flushed: 0 };
|
|
201
|
+
const parsed = JSON.parse(raw);
|
|
202
|
+
const views = Array.isArray(parsed?.views) ? parsed.views.filter((v) => !!v && typeof v.path === "string" && typeof v.at === "string") : [];
|
|
203
|
+
const flushedRaw = typeof parsed?.flushed === "number" && parsed.flushed >= 0 ? parsed.flushed : 0;
|
|
204
|
+
return { views, flushed: Math.min(flushedRaw, views.length) };
|
|
205
|
+
} catch {
|
|
206
|
+
return { views: [], flushed: 0 };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function save(agentId, data) {
|
|
210
|
+
const s = store();
|
|
211
|
+
if (!s) return;
|
|
212
|
+
try {
|
|
213
|
+
s.setItem(storageKey(agentId), JSON.stringify(data));
|
|
214
|
+
} catch {
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
var sanitizePath = (raw) => (raw || "/").split("?")[0].split("#")[0];
|
|
218
|
+
function recordView(agentId, path) {
|
|
219
|
+
try {
|
|
220
|
+
const p = sanitizePath(path);
|
|
221
|
+
const data = load(agentId);
|
|
222
|
+
const last = data.views[data.views.length - 1];
|
|
223
|
+
if (last && last.path === p) return;
|
|
224
|
+
data.views.push({ path: p, at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
225
|
+
if (data.views.length > MAX_BUFFER) {
|
|
226
|
+
const drop = data.views.length - MAX_BUFFER;
|
|
227
|
+
data.views = data.views.slice(drop);
|
|
228
|
+
data.flushed = Math.max(0, data.flushed - drop);
|
|
229
|
+
}
|
|
230
|
+
save(agentId, data);
|
|
231
|
+
} catch {
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function unsentViews(agentId) {
|
|
235
|
+
try {
|
|
236
|
+
const data = load(agentId);
|
|
237
|
+
return data.views.slice(data.flushed);
|
|
238
|
+
} catch {
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function markFlushed(agentId, count) {
|
|
243
|
+
if (count <= 0) return;
|
|
244
|
+
try {
|
|
245
|
+
const data = load(agentId);
|
|
246
|
+
data.flushed = Math.min(data.views.length, data.flushed + count);
|
|
247
|
+
save(agentId, data);
|
|
248
|
+
} catch {
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function installSpaHooks(onNav) {
|
|
252
|
+
const originalPush = history.pushState;
|
|
253
|
+
const originalReplace = history.replaceState;
|
|
254
|
+
let pushInstalled = false;
|
|
255
|
+
let replaceInstalled = false;
|
|
256
|
+
try {
|
|
257
|
+
history.pushState = function(...args) {
|
|
258
|
+
const ret = originalPush.apply(this, args);
|
|
259
|
+
try {
|
|
260
|
+
onNav();
|
|
261
|
+
} catch {
|
|
262
|
+
}
|
|
263
|
+
return ret;
|
|
264
|
+
};
|
|
265
|
+
pushInstalled = true;
|
|
266
|
+
} catch {
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
history.replaceState = function(...args) {
|
|
270
|
+
const ret = originalReplace.apply(this, args);
|
|
271
|
+
try {
|
|
272
|
+
onNav();
|
|
273
|
+
} catch {
|
|
274
|
+
}
|
|
275
|
+
return ret;
|
|
276
|
+
};
|
|
277
|
+
replaceInstalled = true;
|
|
278
|
+
} catch {
|
|
279
|
+
}
|
|
280
|
+
const onPopState = () => {
|
|
281
|
+
try {
|
|
282
|
+
onNav();
|
|
283
|
+
} catch {
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
let popstateInstalled = false;
|
|
287
|
+
try {
|
|
288
|
+
window.addEventListener("popstate", onPopState);
|
|
289
|
+
popstateInstalled = true;
|
|
290
|
+
} catch {
|
|
291
|
+
}
|
|
292
|
+
return () => {
|
|
293
|
+
if (pushInstalled) {
|
|
294
|
+
try {
|
|
295
|
+
history.pushState = originalPush;
|
|
296
|
+
} catch {
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (replaceInstalled) {
|
|
300
|
+
try {
|
|
301
|
+
history.replaceState = originalReplace;
|
|
302
|
+
} catch {
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (popstateInstalled) {
|
|
306
|
+
try {
|
|
307
|
+
window.removeEventListener("popstate", onPopState);
|
|
308
|
+
} catch {
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
};
|
|
118
312
|
}
|
|
119
313
|
|
|
120
314
|
// src/client.ts
|
|
@@ -137,13 +331,97 @@ function createChatClient(options) {
|
|
|
137
331
|
let reconnectTimer = null;
|
|
138
332
|
let reconnectAttempts = 0;
|
|
139
333
|
let closed = false;
|
|
334
|
+
let warnedProtocol = false;
|
|
335
|
+
let deviceFingerprint;
|
|
336
|
+
let refreshTimer = null;
|
|
140
337
|
const toolHandlers = /* @__PURE__ */ new Map();
|
|
338
|
+
let journeyFlushChain = Promise.resolve();
|
|
339
|
+
let journeyFlushQueued = false;
|
|
340
|
+
const flushJourney = () => {
|
|
341
|
+
if (!session) return;
|
|
342
|
+
if (journeyFlushQueued) return;
|
|
343
|
+
journeyFlushQueued = true;
|
|
344
|
+
journeyFlushChain = journeyFlushChain.then(async () => {
|
|
345
|
+
journeyFlushQueued = false;
|
|
346
|
+
if (!session) return;
|
|
347
|
+
const pending = unsentViews(options.agentId);
|
|
348
|
+
if (pending.length === 0) return;
|
|
349
|
+
const s = session;
|
|
350
|
+
try {
|
|
351
|
+
await postPageViews({ baseUrl: options.baseUrl, sessionId: s.sessionId, sessionToken: s.sessionToken, views: pending, fetchImpl });
|
|
352
|
+
markFlushed(options.agentId, pending.length);
|
|
353
|
+
} catch {
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
};
|
|
141
357
|
const clearReconnect = () => {
|
|
142
358
|
if (reconnectTimer) {
|
|
143
359
|
clearTimeout(reconnectTimer);
|
|
144
360
|
reconnectTimer = null;
|
|
145
361
|
}
|
|
146
362
|
};
|
|
363
|
+
const clearRefresh = () => {
|
|
364
|
+
if (refreshTimer) {
|
|
365
|
+
clearTimeout(refreshTimer);
|
|
366
|
+
refreshTimer = null;
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
const scheduleRefresh = () => {
|
|
370
|
+
clearRefresh();
|
|
371
|
+
if (closed || !session) return;
|
|
372
|
+
const untilExpiry = Date.parse(session.expiresAt) - Date.now();
|
|
373
|
+
const delay = Number.isNaN(untilExpiry) ? 10 * 6e4 : Math.max(5e3, untilExpiry - 6e4);
|
|
374
|
+
refreshTimer = setTimeout(async () => {
|
|
375
|
+
if (closed || !session) return;
|
|
376
|
+
try {
|
|
377
|
+
const r = await refreshSessionToken({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, fetchImpl });
|
|
378
|
+
session = { ...session, sessionToken: r.sessionToken, expiresAt: r.expiresAt };
|
|
379
|
+
persistSession(session);
|
|
380
|
+
scheduleRefresh();
|
|
381
|
+
} catch (err) {
|
|
382
|
+
const code = err?.code;
|
|
383
|
+
if (code === "session_ended" || code === "session_expired") clearStoredSession();
|
|
384
|
+
}
|
|
385
|
+
}, delay);
|
|
386
|
+
refreshTimer.unref?.();
|
|
387
|
+
};
|
|
388
|
+
const sessKey = `oyehello-chat-sess:${options.agentId}`;
|
|
389
|
+
const store2 = (() => {
|
|
390
|
+
try {
|
|
391
|
+
return globalThis.sessionStorage ?? null;
|
|
392
|
+
} catch {
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
})();
|
|
396
|
+
const loadStoredSession = () => {
|
|
397
|
+
if (!store2) return null;
|
|
398
|
+
try {
|
|
399
|
+
const raw = store2.getItem(sessKey);
|
|
400
|
+
if (!raw) return null;
|
|
401
|
+
const s = JSON.parse(raw);
|
|
402
|
+
if (!s?.sessionId || !s?.sessionToken || !s?.expiresAt || !s?.agent) return null;
|
|
403
|
+
const exp = Date.parse(s.expiresAt);
|
|
404
|
+
if (Number.isNaN(exp) || Date.now() >= exp - 5e3) {
|
|
405
|
+
store2.removeItem(sessKey);
|
|
406
|
+
return null;
|
|
407
|
+
}
|
|
408
|
+
return s;
|
|
409
|
+
} catch {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
const persistSession = (s) => {
|
|
414
|
+
try {
|
|
415
|
+
store2?.setItem(sessKey, JSON.stringify({ sessionId: s.sessionId, sessionToken: s.sessionToken, expiresAt: s.expiresAt, agent: s.agent }));
|
|
416
|
+
} catch {
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
const clearStoredSession = () => {
|
|
420
|
+
try {
|
|
421
|
+
store2?.removeItem(sessKey);
|
|
422
|
+
} catch {
|
|
423
|
+
}
|
|
424
|
+
};
|
|
147
425
|
const emit = (e) => {
|
|
148
426
|
for (const l of listeners) l(e);
|
|
149
427
|
};
|
|
@@ -231,6 +509,14 @@ function createChatClient(options) {
|
|
|
231
509
|
emit({ type: "agentTyping", active: !!ev.active });
|
|
232
510
|
} else if (ev.type === "suggestions") {
|
|
233
511
|
emit({ type: "suggestions", items: Array.isArray(ev.items) ? ev.items : [] });
|
|
512
|
+
} else if (ev.type === "done") {
|
|
513
|
+
closed = true;
|
|
514
|
+
clearReconnect();
|
|
515
|
+
clearRefresh();
|
|
516
|
+
es?.close();
|
|
517
|
+
es = null;
|
|
518
|
+
clearStoredSession();
|
|
519
|
+
setState("ended");
|
|
234
520
|
}
|
|
235
521
|
};
|
|
236
522
|
const openStream = async () => {
|
|
@@ -262,6 +548,7 @@ function createChatClient(options) {
|
|
|
262
548
|
reconnectTimer = setTimeout(() => {
|
|
263
549
|
openStream().catch((err) => {
|
|
264
550
|
if (err instanceof ChatApiError && err.code === "session_expired") {
|
|
551
|
+
clearStoredSession();
|
|
265
552
|
open().catch((e) => {
|
|
266
553
|
emit({ type: "error", error: e });
|
|
267
554
|
scheduleReconnect();
|
|
@@ -273,16 +560,32 @@ function createChatClient(options) {
|
|
|
273
560
|
}, delay);
|
|
274
561
|
};
|
|
275
562
|
const open = async () => {
|
|
276
|
-
|
|
563
|
+
const resumed = loadStoredSession();
|
|
564
|
+
if (resumed) {
|
|
565
|
+
session = resumed;
|
|
566
|
+
emit({ type: "agent", agent: resumed.agent });
|
|
567
|
+
await openStream();
|
|
568
|
+
scheduleRefresh();
|
|
569
|
+
flushJourney();
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
if (deviceFingerprint === void 0) deviceFingerprint = await computeDeviceFingerprint().catch(() => null);
|
|
573
|
+
session = await openSession({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, user: options.user, clientTools: Array.from(toolHandlers.keys()), fingerprint: deviceFingerprint ?? void 0, fetchImpl });
|
|
574
|
+
persistSession(session);
|
|
575
|
+
if (!warnedProtocol && typeof session.protocol === "number" && session.protocol !== CHAT_PROTOCOL_VERSION) {
|
|
576
|
+
warnedProtocol = true;
|
|
577
|
+
try {
|
|
578
|
+
console.warn(`[oyehello-chat] SDK protocol v${CHAT_PROTOCOL_VERSION} vs server v${session.protocol} - update @oyehello/chat to the latest version.`);
|
|
579
|
+
} catch {
|
|
580
|
+
}
|
|
581
|
+
}
|
|
277
582
|
emit({ type: "agent", agent: session.agent });
|
|
278
583
|
await openStream();
|
|
584
|
+
scheduleRefresh();
|
|
585
|
+
flushJourney();
|
|
279
586
|
};
|
|
280
587
|
const uploadFiles = async (files) => {
|
|
281
|
-
return Promise.all(files.map(
|
|
282
|
-
const { storageRef, uploadUrl } = await presignAttachment({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, contentType: file.type, fetchImpl });
|
|
283
|
-
await uploadToPresigned(uploadUrl, file, fetchImpl);
|
|
284
|
-
return { storageRef, contentType: file.type, name: file.name };
|
|
285
|
-
}));
|
|
588
|
+
return Promise.all(files.map((file) => uploadAttachment({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, file, fetchImpl })));
|
|
286
589
|
};
|
|
287
590
|
return {
|
|
288
591
|
getMessages: () => messages.slice(),
|
|
@@ -301,6 +604,13 @@ function createChatClient(options) {
|
|
|
301
604
|
getWidgetConfig() {
|
|
302
605
|
return getWidgetConfig({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, fetchImpl });
|
|
303
606
|
},
|
|
607
|
+
recordPageView(path) {
|
|
608
|
+
try {
|
|
609
|
+
recordView(options.agentId, path);
|
|
610
|
+
flushJourney();
|
|
611
|
+
} catch {
|
|
612
|
+
}
|
|
613
|
+
},
|
|
304
614
|
async start() {
|
|
305
615
|
if (state === "opening" || state === "ready") return;
|
|
306
616
|
closed = false;
|
|
@@ -346,6 +656,7 @@ function createChatClient(options) {
|
|
|
346
656
|
} catch (err) {
|
|
347
657
|
if (err instanceof ChatApiError && err.code === "session_expired") {
|
|
348
658
|
try {
|
|
659
|
+
clearStoredSession();
|
|
349
660
|
await open();
|
|
350
661
|
const freshAttachments = fileList.length ? await uploadFiles(fileList) : [];
|
|
351
662
|
await postMessage({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, text, attachments: freshAttachments, fetchImpl });
|
|
@@ -357,13 +668,38 @@ function createChatClient(options) {
|
|
|
357
668
|
}
|
|
358
669
|
}
|
|
359
670
|
},
|
|
671
|
+
detach() {
|
|
672
|
+
closed = true;
|
|
673
|
+
clearReconnect();
|
|
674
|
+
clearRefresh();
|
|
675
|
+
es?.close();
|
|
676
|
+
es = null;
|
|
677
|
+
},
|
|
360
678
|
async end() {
|
|
361
679
|
closed = true;
|
|
362
680
|
clearReconnect();
|
|
681
|
+
clearRefresh();
|
|
682
|
+
clearStoredSession();
|
|
363
683
|
if (session) await endSession({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, fetchImpl });
|
|
364
684
|
es?.close();
|
|
365
685
|
es = null;
|
|
366
686
|
setState("ended");
|
|
687
|
+
},
|
|
688
|
+
reset() {
|
|
689
|
+
clearStoredSession();
|
|
690
|
+
es?.close();
|
|
691
|
+
es = null;
|
|
692
|
+
clearReconnect();
|
|
693
|
+
clearRefresh();
|
|
694
|
+
session = null;
|
|
695
|
+
streamingId = null;
|
|
696
|
+
handoffState = "ai";
|
|
697
|
+
messages.length = 0;
|
|
698
|
+
seenTurnIds.clear();
|
|
699
|
+
seenCallIds.clear();
|
|
700
|
+
emit({ type: "messages", messages: [] });
|
|
701
|
+
emit({ type: "handoff", state: "ai" });
|
|
702
|
+
setState("idle");
|
|
367
703
|
}
|
|
368
704
|
};
|
|
369
705
|
}
|
|
@@ -445,6 +781,10 @@ var STYLES = `
|
|
|
445
781
|
--oc-glass-border: rgba(9,13,20,0.12);
|
|
446
782
|
|
|
447
783
|
position: fixed; right: 24px; bottom: 24px; z-index: 2147483000;
|
|
784
|
+
/* Explicit display/visibility so a host-page reset or consent/privacy tool that blanket-hides
|
|
785
|
+
* body children (oyehello-chat { display:none }) can't silently kill the widget. Also pinned
|
|
786
|
+
* inline with !important at mount (see element.ts) to beat an external !important rule. */
|
|
787
|
+
display: block; visibility: visible; opacity: 1;
|
|
448
788
|
font-family: 'Geist', system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
|
449
789
|
-webkit-font-smoothing: antialiased;
|
|
450
790
|
}
|
|
@@ -484,7 +824,7 @@ var STYLES = `
|
|
|
484
824
|
.launcher:hover { transform: scale(1.06); }
|
|
485
825
|
.launcher:active { transform: scale(0.93); }
|
|
486
826
|
.ring { pointer-events: none; position: absolute; inset: -1px; border-radius: var(--oc-r-full); border: 2px solid var(--oc-ac); opacity: 0; }
|
|
487
|
-
.oc.ring:not(.open) .ring { animation: w-ring 2.4s ease-out 1.1s 3; }
|
|
827
|
+
.oc.show-ring:not(.open) .ring { animation: w-ring 2.4s ease-out 1.1s 3; }
|
|
488
828
|
.ic-chat, .ic-close { position: absolute; display: flex; align-items: center; justify-content: center; transition: all .3s var(--oc-bounce); }
|
|
489
829
|
.ic-chat { width: 28px; height: 28px; opacity: 1; transform: rotate(0) scale(1); }
|
|
490
830
|
.ic-close { opacity: 0; transform: rotate(90deg) scale(0.6); }
|
|
@@ -506,7 +846,7 @@ var STYLES = `
|
|
|
506
846
|
transition: opacity .3s var(--oc-ease), transform .38s var(--oc-bounce);
|
|
507
847
|
}
|
|
508
848
|
.oc.has-teaser .teaser { display: flex; }
|
|
509
|
-
.oc.teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
|
|
849
|
+
.oc.show-teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
|
|
510
850
|
.teaser-btn {
|
|
511
851
|
max-width: 250px; display: flex; align-items: center; gap: 10px; padding: 11px 15px;
|
|
512
852
|
border-radius: 16px 16px 5px 16px; border: 1px solid var(--oc-glass-border); background: var(--oc-bg);
|
|
@@ -553,6 +893,12 @@ var STYLES = `
|
|
|
553
893
|
.oc-err-retry { padding: 7px 15px; font-size: 12.5px; font-weight: 500; border-radius: var(--oc-r-full); border: 1px solid var(--oc-border); background: var(--oc-tint-2); color: var(--oc-fg); cursor: pointer; transition: background .18s var(--oc-ease); }
|
|
554
894
|
.oc-err-retry:hover { background: var(--oc-tint-3); }
|
|
555
895
|
|
|
896
|
+
/* Session-ended state, inside the chat window */
|
|
897
|
+
.oc-ended { margin: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px 16px; text-align: center; }
|
|
898
|
+
.oc-ended-t { font-size: 13.5px; line-height: 1.5; color: var(--oc-fg-muted); text-wrap: pretty; }
|
|
899
|
+
.oc-newmsg { padding: 9px 18px; font-size: 13px; font-weight: 600; border-radius: var(--oc-r-full); border: 1px solid var(--oc-btn-rim); background: var(--oc-ac); color: #fff; box-shadow: var(--oc-btn-edge); cursor: pointer; transition: filter .18s var(--oc-ease); }
|
|
900
|
+
.oc-newmsg:hover { filter: brightness(1.08); }
|
|
901
|
+
|
|
556
902
|
/* \u2500\u2500 Message list \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
557
903
|
.body { flex: 1 1 auto; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 16px; padding: 16px 16px 26px; background: var(--oc-bg); scroll-behavior: smooth; }
|
|
558
904
|
.turn { display: flex; flex-direction: column; gap: 6px; animation: w-in .42s var(--oc-ease) both; }
|
|
@@ -597,6 +943,7 @@ var STYLES = `
|
|
|
597
943
|
|
|
598
944
|
/* \u2500\u2500 Composer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
|
|
599
945
|
.composer { flex: none; display: flex; flex-direction: column; gap: 9px; padding: 12px 14px 13px; background: var(--oc-bg); }
|
|
946
|
+
.composer[hidden] { display: none; }
|
|
600
947
|
.field { display: flex; flex-direction: column; gap: 11px; padding: 13px 14px 11px; border-radius: 18px; border: 1px solid var(--oc-field-border); background: var(--oc-field-bg); transition: border-color .18s var(--oc-ease); }
|
|
601
948
|
.oc.focus .field { border-color: var(--oc-ac); }
|
|
602
949
|
.input { width: 100%; border: none; background: transparent; color: var(--oc-fg); font-size: 13.5px; line-height: 1.4; outline: none; padding: 0; resize: none; max-height: 108px; font-family: inherit; }
|
|
@@ -822,14 +1169,29 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
822
1169
|
// Server (flow) suggestions for the latest turn; null until the first `suggestions` event, then the
|
|
823
1170
|
// widget prefers these over the static `suggestions` attribute.
|
|
824
1171
|
serverSuggestions = null;
|
|
1172
|
+
// The live streaming assistant bubble's markdown element + message id — so growing tokens patch just
|
|
1173
|
+
// that element in place instead of rebuilding the whole message list on every token.
|
|
1174
|
+
streamId = null;
|
|
1175
|
+
streamEl = null;
|
|
1176
|
+
audioCtx = null;
|
|
825
1177
|
teaserText = "Need a hand? Ask me anything.";
|
|
826
1178
|
teaserShownOnce = false;
|
|
1179
|
+
// Staffed-hours: set from widget-config. When offline, the panel shows offlineMessage and the
|
|
1180
|
+
// composer is disabled (opening a session is refused server-side anyway).
|
|
1181
|
+
offline = false;
|
|
1182
|
+
offlineMessage = "We're offline right now. Please check back during business hours.";
|
|
1183
|
+
configLoaded = false;
|
|
1184
|
+
// widget-config resolved (so we know online/offline before starting)
|
|
827
1185
|
idleTimer = null;
|
|
828
1186
|
idleReset = null;
|
|
1187
|
+
// Uninstaller for the SPA history-navigation hooks (journey tracking), installed in
|
|
1188
|
+
// connectedCallback and torn down in disconnectedCallback alongside everything else.
|
|
1189
|
+
uninstallSpaHooks = null;
|
|
829
1190
|
// ── shadow refs ──
|
|
830
1191
|
oc;
|
|
831
1192
|
bodyEl;
|
|
832
1193
|
textareaEl;
|
|
1194
|
+
composerEl;
|
|
833
1195
|
fileEl;
|
|
834
1196
|
titleEl;
|
|
835
1197
|
statusEl;
|
|
@@ -851,7 +1213,16 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
851
1213
|
this.pendingTools = {};
|
|
852
1214
|
}
|
|
853
1215
|
this.off = this.client.on((e) => this.onClientEvent(e));
|
|
1216
|
+
recordView(agentId, location.pathname);
|
|
1217
|
+
try {
|
|
1218
|
+
this.uninstallSpaHooks = installSpaHooks(() => this.client?.recordPageView(location.pathname));
|
|
1219
|
+
} catch {
|
|
1220
|
+
this.uninstallSpaHooks = null;
|
|
1221
|
+
}
|
|
854
1222
|
this.renderShell();
|
|
1223
|
+
this.style.setProperty("display", "block", "important");
|
|
1224
|
+
this.style.setProperty("visibility", "visible", "important");
|
|
1225
|
+
this.style.setProperty("opacity", "1", "important");
|
|
855
1226
|
this.renderMessages();
|
|
856
1227
|
this.updateHeader();
|
|
857
1228
|
this.updateStatus();
|
|
@@ -868,7 +1239,7 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
868
1239
|
}
|
|
869
1240
|
this.updateClasses(true);
|
|
870
1241
|
this.timers.push(setTimeout(() => {
|
|
871
|
-
this.oc?.classList.remove("ring");
|
|
1242
|
+
this.oc?.classList.remove("show-ring");
|
|
872
1243
|
}, 6e3));
|
|
873
1244
|
this.armIdleTeaser();
|
|
874
1245
|
}
|
|
@@ -921,7 +1292,19 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
921
1292
|
}
|
|
922
1293
|
if (cfg.brand.logoUrl) this.applyBrandLogo(cfg.brand.logoUrl);
|
|
923
1294
|
}
|
|
1295
|
+
if (cfg.offline) {
|
|
1296
|
+
this.offline = true;
|
|
1297
|
+
if (cfg.offlineMessage) this.offlineMessage = cfg.offlineMessage;
|
|
1298
|
+
this.renderMessages();
|
|
1299
|
+
this.updateStatus();
|
|
1300
|
+
}
|
|
924
1301
|
} catch {
|
|
1302
|
+
} finally {
|
|
1303
|
+
this.configLoaded = true;
|
|
1304
|
+
if (this.uiOpen && !this.started && !this.offline && this.client) {
|
|
1305
|
+
this.started = true;
|
|
1306
|
+
void this.client.start();
|
|
1307
|
+
}
|
|
925
1308
|
}
|
|
926
1309
|
}
|
|
927
1310
|
applyTeaserText() {
|
|
@@ -943,8 +1326,10 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
943
1326
|
}
|
|
944
1327
|
disconnectedCallback() {
|
|
945
1328
|
this.off?.();
|
|
946
|
-
|
|
1329
|
+
this.client?.detach();
|
|
947
1330
|
this.teardownIdle();
|
|
1331
|
+
this.uninstallSpaHooks?.();
|
|
1332
|
+
this.uninstallSpaHooks = null;
|
|
948
1333
|
for (const t of this.timers) clearTimeout(t);
|
|
949
1334
|
for (const url of this.imagePreviewCache.values()) {
|
|
950
1335
|
if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
|
|
@@ -1022,6 +1407,7 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1022
1407
|
this.oc = root.querySelector(".oc");
|
|
1023
1408
|
this.bodyEl = root.querySelector(".body");
|
|
1024
1409
|
this.textareaEl = root.querySelector("textarea.input");
|
|
1410
|
+
this.composerEl = root.querySelector(".composer");
|
|
1025
1411
|
this.fileEl = root.querySelector('input[type="file"]');
|
|
1026
1412
|
this.titleEl = root.querySelector(".hd-title");
|
|
1027
1413
|
this.statusEl = root.querySelector(".hd-status .t");
|
|
@@ -1080,18 +1466,22 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1080
1466
|
this.shadowRoot.innerHTML = `<div style="position:fixed;right:16px;bottom:16px;color:#b91c1c;font:12px system-ui;background:#fef2f2;border:1px solid #fecaca;padding:8px 12px;border-radius:8px">${escapeHtml(msg)}</div>`;
|
|
1081
1467
|
}
|
|
1082
1468
|
// ── State-class sync ──────────────────────────────────────────────────────────────────────────
|
|
1469
|
+
// State classes on `.oc` MUST NOT share a name with any element class that has a bare base rule
|
|
1470
|
+
// (e.g. `.teaser`, `.ring`): the bare selector would also match `.oc.<state>` and, tying `.oc`'s
|
|
1471
|
+
// own rule on specificity, win on source order — e.g. `.teaser{display:none}` would hide the whole
|
|
1472
|
+
// widget. Hence the `show-` prefix on the teaser/ring state classes.
|
|
1083
1473
|
updateClasses(initialRing = false) {
|
|
1084
1474
|
if (!this.oc) return;
|
|
1085
1475
|
const c = this.oc.classList;
|
|
1086
1476
|
c.toggle("open", this.uiOpen);
|
|
1087
1477
|
c.toggle("max", this.uiMax);
|
|
1088
1478
|
c.toggle("emoji", this.emojiOpen);
|
|
1089
|
-
c.toggle("teaser", this.teaserActive);
|
|
1479
|
+
c.toggle("show-teaser", this.teaserActive);
|
|
1090
1480
|
c.toggle("away", this.away);
|
|
1091
1481
|
c.toggle("focus", this.focused);
|
|
1092
1482
|
c.toggle("ready", (this.textareaEl?.value.trim().length ?? 0) > 0);
|
|
1093
1483
|
c.toggle("unread", this.unseen > 0);
|
|
1094
|
-
if (initialRing) c.add("ring");
|
|
1484
|
+
if (initialRing) c.add("show-ring");
|
|
1095
1485
|
const badge = this.oc.querySelector(".badge");
|
|
1096
1486
|
if (badge) badge.textContent = this.unseen > 9 ? "9+" : String(this.unseen);
|
|
1097
1487
|
const launcher = this.oc.querySelector(".launcher");
|
|
@@ -1110,7 +1500,7 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1110
1500
|
sessionStorage.setItem(this.key("open"), "1");
|
|
1111
1501
|
} catch {
|
|
1112
1502
|
}
|
|
1113
|
-
if (!this.started && this.client) {
|
|
1503
|
+
if (!this.started && this.client && this.configLoaded && !this.offline) {
|
|
1114
1504
|
this.started = true;
|
|
1115
1505
|
void this.client.start();
|
|
1116
1506
|
}
|
|
@@ -1158,16 +1548,23 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1158
1548
|
}
|
|
1159
1549
|
updateStatus() {
|
|
1160
1550
|
if (!this.textareaEl) return;
|
|
1161
|
-
const disabled = this.state !== "ready";
|
|
1551
|
+
const disabled = this.state !== "ready" || this.offline;
|
|
1162
1552
|
this.textareaEl.disabled = disabled;
|
|
1553
|
+
if (this.composerEl) this.composerEl.hidden = this.state === "ended" || this.offline;
|
|
1163
1554
|
const handoff = HANDOFF_STATUS[this.handoffState];
|
|
1164
|
-
const text = this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
|
|
1555
|
+
const text = this.offline ? "Offline" : this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
|
|
1165
1556
|
if (this.statusEl) this.statusEl.textContent = text;
|
|
1166
1557
|
}
|
|
1167
1558
|
// ── Message list + scroll ─────────────────────────────────────────────────────────────────────
|
|
1168
1559
|
renderMessages() {
|
|
1169
1560
|
if (!this.bodyEl) return;
|
|
1170
1561
|
const body = this.bodyEl;
|
|
1562
|
+
const last = this.messages[this.messages.length - 1];
|
|
1563
|
+
if (this.uiOpen && this.streamEl && last && last.id === this.streamId && last.role === "assistant" && last.streaming && this.messages.length === this.renderedCount) {
|
|
1564
|
+
this.streamEl.innerHTML = renderMarkdown(last.text || "");
|
|
1565
|
+
if (!this.away) this.scrollToBottom();
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1171
1568
|
const prevTop = body.scrollTop;
|
|
1172
1569
|
const added = this.messages.length - this.renderedCount;
|
|
1173
1570
|
const botFinal = this.messages.filter((m) => m.role === "assistant" && !m.streaming).length;
|
|
@@ -1187,13 +1584,29 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1187
1584
|
if (this.state === "error" && this.lastError) {
|
|
1188
1585
|
html += `<div class="oc-err"><span class="oc-err-t">${escapeHtml(this.lastError)}</span><button type="button" class="oc-err-retry">Try again</button></div>`;
|
|
1189
1586
|
}
|
|
1587
|
+
if (this.state === "ended") {
|
|
1588
|
+
html += `<div class="oc-ended"><span class="oc-ended-t">Your conversation has ended.</span><button type="button" class="oc-newmsg">New message</button></div>`;
|
|
1589
|
+
}
|
|
1590
|
+
if (this.offline && this.messages.length === 0 && this.state !== "ended") {
|
|
1591
|
+
html += `<div class="oc-ended"><span class="oc-ended-t">${escapeHtml(this.offlineMessage)}</span></div>`;
|
|
1592
|
+
}
|
|
1190
1593
|
body.innerHTML = html;
|
|
1191
1594
|
this.hydrateAttachmentImages();
|
|
1192
1595
|
this.bindChips();
|
|
1193
1596
|
this.renderedCount = this.messages.length;
|
|
1194
1597
|
this.renderedBotFinal = botFinal;
|
|
1598
|
+
this.streamId = null;
|
|
1599
|
+
this.streamEl = null;
|
|
1600
|
+
if (last && last.role === "assistant" && last.streaming) {
|
|
1601
|
+
const mds = this.bodyEl.querySelectorAll(".turn.bot .md");
|
|
1602
|
+
this.streamEl = mds[mds.length - 1] ?? null;
|
|
1603
|
+
if (this.streamEl) this.streamId = last.id;
|
|
1604
|
+
}
|
|
1195
1605
|
if (!this.uiOpen) {
|
|
1196
|
-
if (added > 0)
|
|
1606
|
+
if (added > 0) {
|
|
1607
|
+
this.unseen += added;
|
|
1608
|
+
this.maybeChime();
|
|
1609
|
+
}
|
|
1197
1610
|
this.updateClasses();
|
|
1198
1611
|
return;
|
|
1199
1612
|
}
|
|
@@ -1201,7 +1614,10 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1201
1614
|
else if (!this.away) this.scrollToBottom();
|
|
1202
1615
|
else {
|
|
1203
1616
|
body.scrollTop = prevTop;
|
|
1204
|
-
if (added > 0)
|
|
1617
|
+
if (added > 0) {
|
|
1618
|
+
this.unseen += added;
|
|
1619
|
+
this.maybeChime();
|
|
1620
|
+
}
|
|
1205
1621
|
}
|
|
1206
1622
|
this.updateClasses();
|
|
1207
1623
|
}
|
|
@@ -1252,6 +1668,7 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1252
1668
|
}
|
|
1253
1669
|
this.away = false;
|
|
1254
1670
|
this.emojiOpen = false;
|
|
1671
|
+
this.serverSuggestions = [];
|
|
1255
1672
|
void this.client?.sendMessage(text, files.length > 0 ? files : void 0);
|
|
1256
1673
|
this.textareaEl.value = "";
|
|
1257
1674
|
this.textareaEl.style.height = "auto";
|
|
@@ -1260,6 +1677,7 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1260
1677
|
sendChip(text) {
|
|
1261
1678
|
this.away = false;
|
|
1262
1679
|
this.emojiOpen = false;
|
|
1680
|
+
this.serverSuggestions = [];
|
|
1263
1681
|
void this.client?.sendMessage(text);
|
|
1264
1682
|
this.updateClasses();
|
|
1265
1683
|
}
|
|
@@ -1273,6 +1691,56 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1273
1691
|
this.bodyEl.querySelector(".oc-err-retry")?.addEventListener("click", () => {
|
|
1274
1692
|
void this.client?.start();
|
|
1275
1693
|
});
|
|
1694
|
+
this.bodyEl.querySelector(".oc-newmsg")?.addEventListener("click", () => this.startFresh());
|
|
1695
|
+
}
|
|
1696
|
+
// Reset the thread and open a brand-new session (the widget's "New message" after a session ended).
|
|
1697
|
+
startFresh() {
|
|
1698
|
+
this.serverSuggestions = null;
|
|
1699
|
+
this.receivedAt.clear();
|
|
1700
|
+
this.mdCache.clear();
|
|
1701
|
+
this.streamId = null;
|
|
1702
|
+
this.streamEl = null;
|
|
1703
|
+
this.renderedCount = 0;
|
|
1704
|
+
this.renderedBotFinal = 0;
|
|
1705
|
+
this.away = false;
|
|
1706
|
+
this.unseen = 0;
|
|
1707
|
+
this.client?.reset();
|
|
1708
|
+
this.started = true;
|
|
1709
|
+
void this.client?.start();
|
|
1710
|
+
setTimeout(() => this.textareaEl?.focus(), 60);
|
|
1711
|
+
}
|
|
1712
|
+
// A short two-tone chime for an incoming message the visitor hasn't seen (panel closed or scrolled
|
|
1713
|
+
// up). Off when the `sound="off"` attribute is set. Uses WebAudio (no asset); silent if unavailable
|
|
1714
|
+
// or the browser hasn't allowed audio yet.
|
|
1715
|
+
maybeChime() {
|
|
1716
|
+
const last = this.messages[this.messages.length - 1];
|
|
1717
|
+
if (last && (last.role === "assistant" || last.role === "human")) this.playChime();
|
|
1718
|
+
}
|
|
1719
|
+
playChime() {
|
|
1720
|
+
if (this.getAttribute("sound") === "off") return;
|
|
1721
|
+
try {
|
|
1722
|
+
const Ctx = window.AudioContext ?? window.webkitAudioContext;
|
|
1723
|
+
if (!Ctx) return;
|
|
1724
|
+
if (!this.audioCtx) this.audioCtx = new Ctx();
|
|
1725
|
+
const ctx = this.audioCtx;
|
|
1726
|
+
if (ctx.state === "suspended") void ctx.resume();
|
|
1727
|
+
const now = ctx.currentTime;
|
|
1728
|
+
[880, 1174.66].forEach((freq, i) => {
|
|
1729
|
+
const osc = ctx.createOscillator();
|
|
1730
|
+
const gain = ctx.createGain();
|
|
1731
|
+
osc.type = "sine";
|
|
1732
|
+
osc.frequency.value = freq;
|
|
1733
|
+
const t = now + i * 0.11;
|
|
1734
|
+
gain.gain.setValueAtTime(1e-4, t);
|
|
1735
|
+
gain.gain.exponentialRampToValueAtTime(0.12, t + 0.02);
|
|
1736
|
+
gain.gain.exponentialRampToValueAtTime(1e-4, t + 0.18);
|
|
1737
|
+
osc.connect(gain);
|
|
1738
|
+
gain.connect(ctx.destination);
|
|
1739
|
+
osc.start(t);
|
|
1740
|
+
osc.stop(t + 0.2);
|
|
1741
|
+
});
|
|
1742
|
+
} catch {
|
|
1743
|
+
}
|
|
1276
1744
|
}
|
|
1277
1745
|
// ── Emoji ─────────────────────────────────────────────────────────────────────────────────────
|
|
1278
1746
|
toggleEmoji() {
|