@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.js
CHANGED
|
@@ -15,6 +15,9 @@ var ChatApiError = class extends Error {
|
|
|
15
15
|
meta;
|
|
16
16
|
};
|
|
17
17
|
|
|
18
|
+
// src/protocol.ts
|
|
19
|
+
var CHAT_PROTOCOL_VERSION = 1;
|
|
20
|
+
|
|
18
21
|
// src/transport.ts
|
|
19
22
|
async function asError(res, fallbackCode) {
|
|
20
23
|
let body = {};
|
|
@@ -25,9 +28,10 @@ async function asError(res, fallbackCode) {
|
|
|
25
28
|
return new ChatApiError(body.error ?? `HTTP ${res.status}`, res.status, body.code ?? fallbackCode, body.meta);
|
|
26
29
|
}
|
|
27
30
|
async function openSession(opts) {
|
|
28
|
-
const body = { agentId: opts.agentId };
|
|
31
|
+
const body = { agentId: opts.agentId, protocol: CHAT_PROTOCOL_VERSION };
|
|
29
32
|
if (opts.user) body.user = opts.user;
|
|
30
33
|
if (opts.clientTools && opts.clientTools.length > 0) body.clientTools = opts.clientTools;
|
|
34
|
+
if (opts.fingerprint) body.fingerprint = opts.fingerprint;
|
|
31
35
|
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions`, {
|
|
32
36
|
method: "POST",
|
|
33
37
|
credentials: "omit",
|
|
@@ -57,6 +61,15 @@ async function postMessage(opts) {
|
|
|
57
61
|
});
|
|
58
62
|
if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
|
|
59
63
|
}
|
|
64
|
+
async function postPageViews(opts) {
|
|
65
|
+
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/page-views`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
credentials: "omit",
|
|
68
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.sessionToken}` },
|
|
69
|
+
body: JSON.stringify({ views: opts.views })
|
|
70
|
+
});
|
|
71
|
+
if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
|
|
72
|
+
}
|
|
60
73
|
async function endSession(opts) {
|
|
61
74
|
await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/end`, {
|
|
62
75
|
method: "POST",
|
|
@@ -74,6 +87,15 @@ async function postToolResult(opts) {
|
|
|
74
87
|
}).catch(() => {
|
|
75
88
|
});
|
|
76
89
|
}
|
|
90
|
+
async function refreshSessionToken(opts) {
|
|
91
|
+
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/refresh-token`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
credentials: "omit",
|
|
94
|
+
headers: { Authorization: `Bearer ${opts.sessionToken}` }
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
|
|
97
|
+
return await res.json();
|
|
98
|
+
}
|
|
77
99
|
async function mintStreamTicket(opts) {
|
|
78
100
|
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/stream-ticket`, {
|
|
79
101
|
method: "POST",
|
|
@@ -86,15 +108,18 @@ async function mintStreamTicket(opts) {
|
|
|
86
108
|
function streamUrl(baseUrl, sessionId, ticket) {
|
|
87
109
|
return `${baseUrl}/chat/sessions/${sessionId}/stream?ticket=${encodeURIComponent(ticket)}`;
|
|
88
110
|
}
|
|
89
|
-
async function
|
|
90
|
-
const
|
|
111
|
+
async function uploadAttachment(opts) {
|
|
112
|
+
const form = new FormData();
|
|
113
|
+
form.append("file", opts.file, opts.file.name);
|
|
114
|
+
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/upload`, {
|
|
91
115
|
method: "POST",
|
|
92
116
|
credentials: "omit",
|
|
93
|
-
headers: {
|
|
94
|
-
body:
|
|
117
|
+
headers: { Authorization: `Bearer ${opts.sessionToken}` },
|
|
118
|
+
body: form
|
|
95
119
|
});
|
|
96
120
|
if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
|
|
97
|
-
|
|
121
|
+
const j = await res.json();
|
|
122
|
+
return { storageRef: j.storageRef, contentType: j.contentType, name: j.name };
|
|
98
123
|
}
|
|
99
124
|
async function getAttachmentUrl(opts) {
|
|
100
125
|
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/attachment-url`, {
|
|
@@ -106,9 +131,178 @@ async function getAttachmentUrl(opts) {
|
|
|
106
131
|
if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
|
|
107
132
|
return (await res.json()).url;
|
|
108
133
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
134
|
+
|
|
135
|
+
// src/fingerprint.ts
|
|
136
|
+
async function sha256Hex(input) {
|
|
137
|
+
const g = globalThis;
|
|
138
|
+
try {
|
|
139
|
+
if (g.crypto?.subtle && typeof TextEncoder !== "undefined") {
|
|
140
|
+
const buf = await g.crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
|
|
141
|
+
return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 32);
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
}
|
|
145
|
+
let h = 5381;
|
|
146
|
+
for (let i = 0; i < input.length; i++) h = (h << 5) + h + input.charCodeAt(i) >>> 0;
|
|
147
|
+
return h.toString(16).padStart(8, "0");
|
|
148
|
+
}
|
|
149
|
+
async function computeDeviceFingerprint() {
|
|
150
|
+
try {
|
|
151
|
+
const nav = typeof navigator !== "undefined" ? navigator : null;
|
|
152
|
+
if (!nav) return null;
|
|
153
|
+
const scr = typeof screen !== "undefined" ? screen : null;
|
|
154
|
+
const tz = (() => {
|
|
155
|
+
try {
|
|
156
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
157
|
+
} catch {
|
|
158
|
+
return "";
|
|
159
|
+
}
|
|
160
|
+
})();
|
|
161
|
+
const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : "";
|
|
162
|
+
const signals = [
|
|
163
|
+
nav.userAgent,
|
|
164
|
+
Array.isArray(nav.languages) ? nav.languages.join(",") : "",
|
|
165
|
+
nav.language,
|
|
166
|
+
nav.platform,
|
|
167
|
+
nav.hardwareConcurrency,
|
|
168
|
+
nav.deviceMemory,
|
|
169
|
+
scr ? `${scr.width}x${scr.height}x${scr.colorDepth}` : "",
|
|
170
|
+
dpr,
|
|
171
|
+
tz
|
|
172
|
+
].map((v) => String(v ?? "")).join("|");
|
|
173
|
+
return await sha256Hex(signals);
|
|
174
|
+
} catch {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/journey.ts
|
|
180
|
+
var MAX_BUFFER = 25;
|
|
181
|
+
var storageKey = (agentId) => `oyehello-chat:journey:${agentId}`;
|
|
182
|
+
var store = () => {
|
|
183
|
+
try {
|
|
184
|
+
return globalThis.sessionStorage ?? null;
|
|
185
|
+
} catch {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
function load(agentId) {
|
|
190
|
+
const s = store();
|
|
191
|
+
if (!s) return { views: [], flushed: 0 };
|
|
192
|
+
try {
|
|
193
|
+
const raw = s.getItem(storageKey(agentId));
|
|
194
|
+
if (!raw) return { views: [], flushed: 0 };
|
|
195
|
+
const parsed = JSON.parse(raw);
|
|
196
|
+
const views = Array.isArray(parsed?.views) ? parsed.views.filter((v) => !!v && typeof v.path === "string" && typeof v.at === "string") : [];
|
|
197
|
+
const flushedRaw = typeof parsed?.flushed === "number" && parsed.flushed >= 0 ? parsed.flushed : 0;
|
|
198
|
+
return { views, flushed: Math.min(flushedRaw, views.length) };
|
|
199
|
+
} catch {
|
|
200
|
+
return { views: [], flushed: 0 };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function save(agentId, data) {
|
|
204
|
+
const s = store();
|
|
205
|
+
if (!s) return;
|
|
206
|
+
try {
|
|
207
|
+
s.setItem(storageKey(agentId), JSON.stringify(data));
|
|
208
|
+
} catch {
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
var sanitizePath = (raw) => (raw || "/").split("?")[0].split("#")[0];
|
|
212
|
+
function recordView(agentId, path) {
|
|
213
|
+
try {
|
|
214
|
+
const p = sanitizePath(path);
|
|
215
|
+
const data = load(agentId);
|
|
216
|
+
const last = data.views[data.views.length - 1];
|
|
217
|
+
if (last && last.path === p) return;
|
|
218
|
+
data.views.push({ path: p, at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
219
|
+
if (data.views.length > MAX_BUFFER) {
|
|
220
|
+
const drop = data.views.length - MAX_BUFFER;
|
|
221
|
+
data.views = data.views.slice(drop);
|
|
222
|
+
data.flushed = Math.max(0, data.flushed - drop);
|
|
223
|
+
}
|
|
224
|
+
save(agentId, data);
|
|
225
|
+
} catch {
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function unsentViews(agentId) {
|
|
229
|
+
try {
|
|
230
|
+
const data = load(agentId);
|
|
231
|
+
return data.views.slice(data.flushed);
|
|
232
|
+
} catch {
|
|
233
|
+
return [];
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function markFlushed(agentId, count) {
|
|
237
|
+
if (count <= 0) return;
|
|
238
|
+
try {
|
|
239
|
+
const data = load(agentId);
|
|
240
|
+
data.flushed = Math.min(data.views.length, data.flushed + count);
|
|
241
|
+
save(agentId, data);
|
|
242
|
+
} catch {
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function installSpaHooks(onNav) {
|
|
246
|
+
const originalPush = history.pushState;
|
|
247
|
+
const originalReplace = history.replaceState;
|
|
248
|
+
let pushInstalled = false;
|
|
249
|
+
let replaceInstalled = false;
|
|
250
|
+
try {
|
|
251
|
+
history.pushState = function(...args) {
|
|
252
|
+
const ret = originalPush.apply(this, args);
|
|
253
|
+
try {
|
|
254
|
+
onNav();
|
|
255
|
+
} catch {
|
|
256
|
+
}
|
|
257
|
+
return ret;
|
|
258
|
+
};
|
|
259
|
+
pushInstalled = true;
|
|
260
|
+
} catch {
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
history.replaceState = function(...args) {
|
|
264
|
+
const ret = originalReplace.apply(this, args);
|
|
265
|
+
try {
|
|
266
|
+
onNav();
|
|
267
|
+
} catch {
|
|
268
|
+
}
|
|
269
|
+
return ret;
|
|
270
|
+
};
|
|
271
|
+
replaceInstalled = true;
|
|
272
|
+
} catch {
|
|
273
|
+
}
|
|
274
|
+
const onPopState = () => {
|
|
275
|
+
try {
|
|
276
|
+
onNav();
|
|
277
|
+
} catch {
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
let popstateInstalled = false;
|
|
281
|
+
try {
|
|
282
|
+
window.addEventListener("popstate", onPopState);
|
|
283
|
+
popstateInstalled = true;
|
|
284
|
+
} catch {
|
|
285
|
+
}
|
|
286
|
+
return () => {
|
|
287
|
+
if (pushInstalled) {
|
|
288
|
+
try {
|
|
289
|
+
history.pushState = originalPush;
|
|
290
|
+
} catch {
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (replaceInstalled) {
|
|
294
|
+
try {
|
|
295
|
+
history.replaceState = originalReplace;
|
|
296
|
+
} catch {
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (popstateInstalled) {
|
|
300
|
+
try {
|
|
301
|
+
window.removeEventListener("popstate", onPopState);
|
|
302
|
+
} catch {
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
};
|
|
112
306
|
}
|
|
113
307
|
|
|
114
308
|
// src/client.ts
|
|
@@ -131,13 +325,97 @@ function createChatClient(options) {
|
|
|
131
325
|
let reconnectTimer = null;
|
|
132
326
|
let reconnectAttempts = 0;
|
|
133
327
|
let closed = false;
|
|
328
|
+
let warnedProtocol = false;
|
|
329
|
+
let deviceFingerprint;
|
|
330
|
+
let refreshTimer = null;
|
|
134
331
|
const toolHandlers = /* @__PURE__ */ new Map();
|
|
332
|
+
let journeyFlushChain = Promise.resolve();
|
|
333
|
+
let journeyFlushQueued = false;
|
|
334
|
+
const flushJourney = () => {
|
|
335
|
+
if (!session) return;
|
|
336
|
+
if (journeyFlushQueued) return;
|
|
337
|
+
journeyFlushQueued = true;
|
|
338
|
+
journeyFlushChain = journeyFlushChain.then(async () => {
|
|
339
|
+
journeyFlushQueued = false;
|
|
340
|
+
if (!session) return;
|
|
341
|
+
const pending = unsentViews(options.agentId);
|
|
342
|
+
if (pending.length === 0) return;
|
|
343
|
+
const s = session;
|
|
344
|
+
try {
|
|
345
|
+
await postPageViews({ baseUrl: options.baseUrl, sessionId: s.sessionId, sessionToken: s.sessionToken, views: pending, fetchImpl });
|
|
346
|
+
markFlushed(options.agentId, pending.length);
|
|
347
|
+
} catch {
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
};
|
|
135
351
|
const clearReconnect = () => {
|
|
136
352
|
if (reconnectTimer) {
|
|
137
353
|
clearTimeout(reconnectTimer);
|
|
138
354
|
reconnectTimer = null;
|
|
139
355
|
}
|
|
140
356
|
};
|
|
357
|
+
const clearRefresh = () => {
|
|
358
|
+
if (refreshTimer) {
|
|
359
|
+
clearTimeout(refreshTimer);
|
|
360
|
+
refreshTimer = null;
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
const scheduleRefresh = () => {
|
|
364
|
+
clearRefresh();
|
|
365
|
+
if (closed || !session) return;
|
|
366
|
+
const untilExpiry = Date.parse(session.expiresAt) - Date.now();
|
|
367
|
+
const delay = Number.isNaN(untilExpiry) ? 10 * 6e4 : Math.max(5e3, untilExpiry - 6e4);
|
|
368
|
+
refreshTimer = setTimeout(async () => {
|
|
369
|
+
if (closed || !session) return;
|
|
370
|
+
try {
|
|
371
|
+
const r = await refreshSessionToken({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, fetchImpl });
|
|
372
|
+
session = { ...session, sessionToken: r.sessionToken, expiresAt: r.expiresAt };
|
|
373
|
+
persistSession(session);
|
|
374
|
+
scheduleRefresh();
|
|
375
|
+
} catch (err) {
|
|
376
|
+
const code = err?.code;
|
|
377
|
+
if (code === "session_ended" || code === "session_expired") clearStoredSession();
|
|
378
|
+
}
|
|
379
|
+
}, delay);
|
|
380
|
+
refreshTimer.unref?.();
|
|
381
|
+
};
|
|
382
|
+
const sessKey = `oyehello-chat-sess:${options.agentId}`;
|
|
383
|
+
const store2 = (() => {
|
|
384
|
+
try {
|
|
385
|
+
return globalThis.sessionStorage ?? null;
|
|
386
|
+
} catch {
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
})();
|
|
390
|
+
const loadStoredSession = () => {
|
|
391
|
+
if (!store2) return null;
|
|
392
|
+
try {
|
|
393
|
+
const raw = store2.getItem(sessKey);
|
|
394
|
+
if (!raw) return null;
|
|
395
|
+
const s = JSON.parse(raw);
|
|
396
|
+
if (!s?.sessionId || !s?.sessionToken || !s?.expiresAt || !s?.agent) return null;
|
|
397
|
+
const exp = Date.parse(s.expiresAt);
|
|
398
|
+
if (Number.isNaN(exp) || Date.now() >= exp - 5e3) {
|
|
399
|
+
store2.removeItem(sessKey);
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
return s;
|
|
403
|
+
} catch {
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
};
|
|
407
|
+
const persistSession = (s) => {
|
|
408
|
+
try {
|
|
409
|
+
store2?.setItem(sessKey, JSON.stringify({ sessionId: s.sessionId, sessionToken: s.sessionToken, expiresAt: s.expiresAt, agent: s.agent }));
|
|
410
|
+
} catch {
|
|
411
|
+
}
|
|
412
|
+
};
|
|
413
|
+
const clearStoredSession = () => {
|
|
414
|
+
try {
|
|
415
|
+
store2?.removeItem(sessKey);
|
|
416
|
+
} catch {
|
|
417
|
+
}
|
|
418
|
+
};
|
|
141
419
|
const emit = (e) => {
|
|
142
420
|
for (const l of listeners) l(e);
|
|
143
421
|
};
|
|
@@ -225,6 +503,14 @@ function createChatClient(options) {
|
|
|
225
503
|
emit({ type: "agentTyping", active: !!ev.active });
|
|
226
504
|
} else if (ev.type === "suggestions") {
|
|
227
505
|
emit({ type: "suggestions", items: Array.isArray(ev.items) ? ev.items : [] });
|
|
506
|
+
} else if (ev.type === "done") {
|
|
507
|
+
closed = true;
|
|
508
|
+
clearReconnect();
|
|
509
|
+
clearRefresh();
|
|
510
|
+
es?.close();
|
|
511
|
+
es = null;
|
|
512
|
+
clearStoredSession();
|
|
513
|
+
setState("ended");
|
|
228
514
|
}
|
|
229
515
|
};
|
|
230
516
|
const openStream = async () => {
|
|
@@ -256,6 +542,7 @@ function createChatClient(options) {
|
|
|
256
542
|
reconnectTimer = setTimeout(() => {
|
|
257
543
|
openStream().catch((err) => {
|
|
258
544
|
if (err instanceof ChatApiError && err.code === "session_expired") {
|
|
545
|
+
clearStoredSession();
|
|
259
546
|
open().catch((e) => {
|
|
260
547
|
emit({ type: "error", error: e });
|
|
261
548
|
scheduleReconnect();
|
|
@@ -267,16 +554,32 @@ function createChatClient(options) {
|
|
|
267
554
|
}, delay);
|
|
268
555
|
};
|
|
269
556
|
const open = async () => {
|
|
270
|
-
|
|
557
|
+
const resumed = loadStoredSession();
|
|
558
|
+
if (resumed) {
|
|
559
|
+
session = resumed;
|
|
560
|
+
emit({ type: "agent", agent: resumed.agent });
|
|
561
|
+
await openStream();
|
|
562
|
+
scheduleRefresh();
|
|
563
|
+
flushJourney();
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (deviceFingerprint === void 0) deviceFingerprint = await computeDeviceFingerprint().catch(() => null);
|
|
567
|
+
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 });
|
|
568
|
+
persistSession(session);
|
|
569
|
+
if (!warnedProtocol && typeof session.protocol === "number" && session.protocol !== CHAT_PROTOCOL_VERSION) {
|
|
570
|
+
warnedProtocol = true;
|
|
571
|
+
try {
|
|
572
|
+
console.warn(`[oyehello-chat] SDK protocol v${CHAT_PROTOCOL_VERSION} vs server v${session.protocol} - update @oyehello/chat to the latest version.`);
|
|
573
|
+
} catch {
|
|
574
|
+
}
|
|
575
|
+
}
|
|
271
576
|
emit({ type: "agent", agent: session.agent });
|
|
272
577
|
await openStream();
|
|
578
|
+
scheduleRefresh();
|
|
579
|
+
flushJourney();
|
|
273
580
|
};
|
|
274
581
|
const uploadFiles = async (files) => {
|
|
275
|
-
return Promise.all(files.map(
|
|
276
|
-
const { storageRef, uploadUrl } = await presignAttachment({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, contentType: file.type, fetchImpl });
|
|
277
|
-
await uploadToPresigned(uploadUrl, file, fetchImpl);
|
|
278
|
-
return { storageRef, contentType: file.type, name: file.name };
|
|
279
|
-
}));
|
|
582
|
+
return Promise.all(files.map((file) => uploadAttachment({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, file, fetchImpl })));
|
|
280
583
|
};
|
|
281
584
|
return {
|
|
282
585
|
getMessages: () => messages.slice(),
|
|
@@ -295,6 +598,13 @@ function createChatClient(options) {
|
|
|
295
598
|
getWidgetConfig() {
|
|
296
599
|
return getWidgetConfig({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, fetchImpl });
|
|
297
600
|
},
|
|
601
|
+
recordPageView(path) {
|
|
602
|
+
try {
|
|
603
|
+
recordView(options.agentId, path);
|
|
604
|
+
flushJourney();
|
|
605
|
+
} catch {
|
|
606
|
+
}
|
|
607
|
+
},
|
|
298
608
|
async start() {
|
|
299
609
|
if (state === "opening" || state === "ready") return;
|
|
300
610
|
closed = false;
|
|
@@ -340,6 +650,7 @@ function createChatClient(options) {
|
|
|
340
650
|
} catch (err) {
|
|
341
651
|
if (err instanceof ChatApiError && err.code === "session_expired") {
|
|
342
652
|
try {
|
|
653
|
+
clearStoredSession();
|
|
343
654
|
await open();
|
|
344
655
|
const freshAttachments = fileList.length ? await uploadFiles(fileList) : [];
|
|
345
656
|
await postMessage({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, text, attachments: freshAttachments, fetchImpl });
|
|
@@ -351,13 +662,38 @@ function createChatClient(options) {
|
|
|
351
662
|
}
|
|
352
663
|
}
|
|
353
664
|
},
|
|
665
|
+
detach() {
|
|
666
|
+
closed = true;
|
|
667
|
+
clearReconnect();
|
|
668
|
+
clearRefresh();
|
|
669
|
+
es?.close();
|
|
670
|
+
es = null;
|
|
671
|
+
},
|
|
354
672
|
async end() {
|
|
355
673
|
closed = true;
|
|
356
674
|
clearReconnect();
|
|
675
|
+
clearRefresh();
|
|
676
|
+
clearStoredSession();
|
|
357
677
|
if (session) await endSession({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, fetchImpl });
|
|
358
678
|
es?.close();
|
|
359
679
|
es = null;
|
|
360
680
|
setState("ended");
|
|
681
|
+
},
|
|
682
|
+
reset() {
|
|
683
|
+
clearStoredSession();
|
|
684
|
+
es?.close();
|
|
685
|
+
es = null;
|
|
686
|
+
clearReconnect();
|
|
687
|
+
clearRefresh();
|
|
688
|
+
session = null;
|
|
689
|
+
streamingId = null;
|
|
690
|
+
handoffState = "ai";
|
|
691
|
+
messages.length = 0;
|
|
692
|
+
seenTurnIds.clear();
|
|
693
|
+
seenCallIds.clear();
|
|
694
|
+
emit({ type: "messages", messages: [] });
|
|
695
|
+
emit({ type: "handoff", state: "ai" });
|
|
696
|
+
setState("idle");
|
|
361
697
|
}
|
|
362
698
|
};
|
|
363
699
|
}
|
|
@@ -439,6 +775,10 @@ var STYLES = `
|
|
|
439
775
|
--oc-glass-border: rgba(9,13,20,0.12);
|
|
440
776
|
|
|
441
777
|
position: fixed; right: 24px; bottom: 24px; z-index: 2147483000;
|
|
778
|
+
/* Explicit display/visibility so a host-page reset or consent/privacy tool that blanket-hides
|
|
779
|
+
* body children (oyehello-chat { display:none }) can't silently kill the widget. Also pinned
|
|
780
|
+
* inline with !important at mount (see element.ts) to beat an external !important rule. */
|
|
781
|
+
display: block; visibility: visible; opacity: 1;
|
|
442
782
|
font-family: 'Geist', system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
|
443
783
|
-webkit-font-smoothing: antialiased;
|
|
444
784
|
}
|
|
@@ -478,7 +818,7 @@ var STYLES = `
|
|
|
478
818
|
.launcher:hover { transform: scale(1.06); }
|
|
479
819
|
.launcher:active { transform: scale(0.93); }
|
|
480
820
|
.ring { pointer-events: none; position: absolute; inset: -1px; border-radius: var(--oc-r-full); border: 2px solid var(--oc-ac); opacity: 0; }
|
|
481
|
-
.oc.ring:not(.open) .ring { animation: w-ring 2.4s ease-out 1.1s 3; }
|
|
821
|
+
.oc.show-ring:not(.open) .ring { animation: w-ring 2.4s ease-out 1.1s 3; }
|
|
482
822
|
.ic-chat, .ic-close { position: absolute; display: flex; align-items: center; justify-content: center; transition: all .3s var(--oc-bounce); }
|
|
483
823
|
.ic-chat { width: 28px; height: 28px; opacity: 1; transform: rotate(0) scale(1); }
|
|
484
824
|
.ic-close { opacity: 0; transform: rotate(90deg) scale(0.6); }
|
|
@@ -500,7 +840,7 @@ var STYLES = `
|
|
|
500
840
|
transition: opacity .3s var(--oc-ease), transform .38s var(--oc-bounce);
|
|
501
841
|
}
|
|
502
842
|
.oc.has-teaser .teaser { display: flex; }
|
|
503
|
-
.oc.teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
|
|
843
|
+
.oc.show-teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
|
|
504
844
|
.teaser-btn {
|
|
505
845
|
max-width: 250px; display: flex; align-items: center; gap: 10px; padding: 11px 15px;
|
|
506
846
|
border-radius: 16px 16px 5px 16px; border: 1px solid var(--oc-glass-border); background: var(--oc-bg);
|
|
@@ -547,6 +887,12 @@ var STYLES = `
|
|
|
547
887
|
.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); }
|
|
548
888
|
.oc-err-retry:hover { background: var(--oc-tint-3); }
|
|
549
889
|
|
|
890
|
+
/* Session-ended state, inside the chat window */
|
|
891
|
+
.oc-ended { margin: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px 16px; text-align: center; }
|
|
892
|
+
.oc-ended-t { font-size: 13.5px; line-height: 1.5; color: var(--oc-fg-muted); text-wrap: pretty; }
|
|
893
|
+
.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); }
|
|
894
|
+
.oc-newmsg:hover { filter: brightness(1.08); }
|
|
895
|
+
|
|
550
896
|
/* \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 */
|
|
551
897
|
.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; }
|
|
552
898
|
.turn { display: flex; flex-direction: column; gap: 6px; animation: w-in .42s var(--oc-ease) both; }
|
|
@@ -591,6 +937,7 @@ var STYLES = `
|
|
|
591
937
|
|
|
592
938
|
/* \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 */
|
|
593
939
|
.composer { flex: none; display: flex; flex-direction: column; gap: 9px; padding: 12px 14px 13px; background: var(--oc-bg); }
|
|
940
|
+
.composer[hidden] { display: none; }
|
|
594
941
|
.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); }
|
|
595
942
|
.oc.focus .field { border-color: var(--oc-ac); }
|
|
596
943
|
.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; }
|
|
@@ -816,14 +1163,29 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
816
1163
|
// Server (flow) suggestions for the latest turn; null until the first `suggestions` event, then the
|
|
817
1164
|
// widget prefers these over the static `suggestions` attribute.
|
|
818
1165
|
serverSuggestions = null;
|
|
1166
|
+
// The live streaming assistant bubble's markdown element + message id — so growing tokens patch just
|
|
1167
|
+
// that element in place instead of rebuilding the whole message list on every token.
|
|
1168
|
+
streamId = null;
|
|
1169
|
+
streamEl = null;
|
|
1170
|
+
audioCtx = null;
|
|
819
1171
|
teaserText = "Need a hand? Ask me anything.";
|
|
820
1172
|
teaserShownOnce = false;
|
|
1173
|
+
// Staffed-hours: set from widget-config. When offline, the panel shows offlineMessage and the
|
|
1174
|
+
// composer is disabled (opening a session is refused server-side anyway).
|
|
1175
|
+
offline = false;
|
|
1176
|
+
offlineMessage = "We're offline right now. Please check back during business hours.";
|
|
1177
|
+
configLoaded = false;
|
|
1178
|
+
// widget-config resolved (so we know online/offline before starting)
|
|
821
1179
|
idleTimer = null;
|
|
822
1180
|
idleReset = null;
|
|
1181
|
+
// Uninstaller for the SPA history-navigation hooks (journey tracking), installed in
|
|
1182
|
+
// connectedCallback and torn down in disconnectedCallback alongside everything else.
|
|
1183
|
+
uninstallSpaHooks = null;
|
|
823
1184
|
// ── shadow refs ──
|
|
824
1185
|
oc;
|
|
825
1186
|
bodyEl;
|
|
826
1187
|
textareaEl;
|
|
1188
|
+
composerEl;
|
|
827
1189
|
fileEl;
|
|
828
1190
|
titleEl;
|
|
829
1191
|
statusEl;
|
|
@@ -845,7 +1207,16 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
845
1207
|
this.pendingTools = {};
|
|
846
1208
|
}
|
|
847
1209
|
this.off = this.client.on((e) => this.onClientEvent(e));
|
|
1210
|
+
recordView(agentId, location.pathname);
|
|
1211
|
+
try {
|
|
1212
|
+
this.uninstallSpaHooks = installSpaHooks(() => this.client?.recordPageView(location.pathname));
|
|
1213
|
+
} catch {
|
|
1214
|
+
this.uninstallSpaHooks = null;
|
|
1215
|
+
}
|
|
848
1216
|
this.renderShell();
|
|
1217
|
+
this.style.setProperty("display", "block", "important");
|
|
1218
|
+
this.style.setProperty("visibility", "visible", "important");
|
|
1219
|
+
this.style.setProperty("opacity", "1", "important");
|
|
849
1220
|
this.renderMessages();
|
|
850
1221
|
this.updateHeader();
|
|
851
1222
|
this.updateStatus();
|
|
@@ -862,7 +1233,7 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
862
1233
|
}
|
|
863
1234
|
this.updateClasses(true);
|
|
864
1235
|
this.timers.push(setTimeout(() => {
|
|
865
|
-
this.oc?.classList.remove("ring");
|
|
1236
|
+
this.oc?.classList.remove("show-ring");
|
|
866
1237
|
}, 6e3));
|
|
867
1238
|
this.armIdleTeaser();
|
|
868
1239
|
}
|
|
@@ -915,7 +1286,19 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
915
1286
|
}
|
|
916
1287
|
if (cfg.brand.logoUrl) this.applyBrandLogo(cfg.brand.logoUrl);
|
|
917
1288
|
}
|
|
1289
|
+
if (cfg.offline) {
|
|
1290
|
+
this.offline = true;
|
|
1291
|
+
if (cfg.offlineMessage) this.offlineMessage = cfg.offlineMessage;
|
|
1292
|
+
this.renderMessages();
|
|
1293
|
+
this.updateStatus();
|
|
1294
|
+
}
|
|
918
1295
|
} catch {
|
|
1296
|
+
} finally {
|
|
1297
|
+
this.configLoaded = true;
|
|
1298
|
+
if (this.uiOpen && !this.started && !this.offline && this.client) {
|
|
1299
|
+
this.started = true;
|
|
1300
|
+
void this.client.start();
|
|
1301
|
+
}
|
|
919
1302
|
}
|
|
920
1303
|
}
|
|
921
1304
|
applyTeaserText() {
|
|
@@ -937,8 +1320,10 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
937
1320
|
}
|
|
938
1321
|
disconnectedCallback() {
|
|
939
1322
|
this.off?.();
|
|
940
|
-
|
|
1323
|
+
this.client?.detach();
|
|
941
1324
|
this.teardownIdle();
|
|
1325
|
+
this.uninstallSpaHooks?.();
|
|
1326
|
+
this.uninstallSpaHooks = null;
|
|
942
1327
|
for (const t of this.timers) clearTimeout(t);
|
|
943
1328
|
for (const url of this.imagePreviewCache.values()) {
|
|
944
1329
|
if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
|
|
@@ -1016,6 +1401,7 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1016
1401
|
this.oc = root.querySelector(".oc");
|
|
1017
1402
|
this.bodyEl = root.querySelector(".body");
|
|
1018
1403
|
this.textareaEl = root.querySelector("textarea.input");
|
|
1404
|
+
this.composerEl = root.querySelector(".composer");
|
|
1019
1405
|
this.fileEl = root.querySelector('input[type="file"]');
|
|
1020
1406
|
this.titleEl = root.querySelector(".hd-title");
|
|
1021
1407
|
this.statusEl = root.querySelector(".hd-status .t");
|
|
@@ -1074,18 +1460,22 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1074
1460
|
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>`;
|
|
1075
1461
|
}
|
|
1076
1462
|
// ── State-class sync ──────────────────────────────────────────────────────────────────────────
|
|
1463
|
+
// State classes on `.oc` MUST NOT share a name with any element class that has a bare base rule
|
|
1464
|
+
// (e.g. `.teaser`, `.ring`): the bare selector would also match `.oc.<state>` and, tying `.oc`'s
|
|
1465
|
+
// own rule on specificity, win on source order — e.g. `.teaser{display:none}` would hide the whole
|
|
1466
|
+
// widget. Hence the `show-` prefix on the teaser/ring state classes.
|
|
1077
1467
|
updateClasses(initialRing = false) {
|
|
1078
1468
|
if (!this.oc) return;
|
|
1079
1469
|
const c = this.oc.classList;
|
|
1080
1470
|
c.toggle("open", this.uiOpen);
|
|
1081
1471
|
c.toggle("max", this.uiMax);
|
|
1082
1472
|
c.toggle("emoji", this.emojiOpen);
|
|
1083
|
-
c.toggle("teaser", this.teaserActive);
|
|
1473
|
+
c.toggle("show-teaser", this.teaserActive);
|
|
1084
1474
|
c.toggle("away", this.away);
|
|
1085
1475
|
c.toggle("focus", this.focused);
|
|
1086
1476
|
c.toggle("ready", (this.textareaEl?.value.trim().length ?? 0) > 0);
|
|
1087
1477
|
c.toggle("unread", this.unseen > 0);
|
|
1088
|
-
if (initialRing) c.add("ring");
|
|
1478
|
+
if (initialRing) c.add("show-ring");
|
|
1089
1479
|
const badge = this.oc.querySelector(".badge");
|
|
1090
1480
|
if (badge) badge.textContent = this.unseen > 9 ? "9+" : String(this.unseen);
|
|
1091
1481
|
const launcher = this.oc.querySelector(".launcher");
|
|
@@ -1104,7 +1494,7 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1104
1494
|
sessionStorage.setItem(this.key("open"), "1");
|
|
1105
1495
|
} catch {
|
|
1106
1496
|
}
|
|
1107
|
-
if (!this.started && this.client) {
|
|
1497
|
+
if (!this.started && this.client && this.configLoaded && !this.offline) {
|
|
1108
1498
|
this.started = true;
|
|
1109
1499
|
void this.client.start();
|
|
1110
1500
|
}
|
|
@@ -1152,16 +1542,23 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1152
1542
|
}
|
|
1153
1543
|
updateStatus() {
|
|
1154
1544
|
if (!this.textareaEl) return;
|
|
1155
|
-
const disabled = this.state !== "ready";
|
|
1545
|
+
const disabled = this.state !== "ready" || this.offline;
|
|
1156
1546
|
this.textareaEl.disabled = disabled;
|
|
1547
|
+
if (this.composerEl) this.composerEl.hidden = this.state === "ended" || this.offline;
|
|
1157
1548
|
const handoff = HANDOFF_STATUS[this.handoffState];
|
|
1158
|
-
const text = this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
|
|
1549
|
+
const text = this.offline ? "Offline" : this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
|
|
1159
1550
|
if (this.statusEl) this.statusEl.textContent = text;
|
|
1160
1551
|
}
|
|
1161
1552
|
// ── Message list + scroll ─────────────────────────────────────────────────────────────────────
|
|
1162
1553
|
renderMessages() {
|
|
1163
1554
|
if (!this.bodyEl) return;
|
|
1164
1555
|
const body = this.bodyEl;
|
|
1556
|
+
const last = this.messages[this.messages.length - 1];
|
|
1557
|
+
if (this.uiOpen && this.streamEl && last && last.id === this.streamId && last.role === "assistant" && last.streaming && this.messages.length === this.renderedCount) {
|
|
1558
|
+
this.streamEl.innerHTML = renderMarkdown(last.text || "");
|
|
1559
|
+
if (!this.away) this.scrollToBottom();
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1165
1562
|
const prevTop = body.scrollTop;
|
|
1166
1563
|
const added = this.messages.length - this.renderedCount;
|
|
1167
1564
|
const botFinal = this.messages.filter((m) => m.role === "assistant" && !m.streaming).length;
|
|
@@ -1181,13 +1578,29 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1181
1578
|
if (this.state === "error" && this.lastError) {
|
|
1182
1579
|
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>`;
|
|
1183
1580
|
}
|
|
1581
|
+
if (this.state === "ended") {
|
|
1582
|
+
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>`;
|
|
1583
|
+
}
|
|
1584
|
+
if (this.offline && this.messages.length === 0 && this.state !== "ended") {
|
|
1585
|
+
html += `<div class="oc-ended"><span class="oc-ended-t">${escapeHtml(this.offlineMessage)}</span></div>`;
|
|
1586
|
+
}
|
|
1184
1587
|
body.innerHTML = html;
|
|
1185
1588
|
this.hydrateAttachmentImages();
|
|
1186
1589
|
this.bindChips();
|
|
1187
1590
|
this.renderedCount = this.messages.length;
|
|
1188
1591
|
this.renderedBotFinal = botFinal;
|
|
1592
|
+
this.streamId = null;
|
|
1593
|
+
this.streamEl = null;
|
|
1594
|
+
if (last && last.role === "assistant" && last.streaming) {
|
|
1595
|
+
const mds = this.bodyEl.querySelectorAll(".turn.bot .md");
|
|
1596
|
+
this.streamEl = mds[mds.length - 1] ?? null;
|
|
1597
|
+
if (this.streamEl) this.streamId = last.id;
|
|
1598
|
+
}
|
|
1189
1599
|
if (!this.uiOpen) {
|
|
1190
|
-
if (added > 0)
|
|
1600
|
+
if (added > 0) {
|
|
1601
|
+
this.unseen += added;
|
|
1602
|
+
this.maybeChime();
|
|
1603
|
+
}
|
|
1191
1604
|
this.updateClasses();
|
|
1192
1605
|
return;
|
|
1193
1606
|
}
|
|
@@ -1195,7 +1608,10 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1195
1608
|
else if (!this.away) this.scrollToBottom();
|
|
1196
1609
|
else {
|
|
1197
1610
|
body.scrollTop = prevTop;
|
|
1198
|
-
if (added > 0)
|
|
1611
|
+
if (added > 0) {
|
|
1612
|
+
this.unseen += added;
|
|
1613
|
+
this.maybeChime();
|
|
1614
|
+
}
|
|
1199
1615
|
}
|
|
1200
1616
|
this.updateClasses();
|
|
1201
1617
|
}
|
|
@@ -1246,6 +1662,7 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1246
1662
|
}
|
|
1247
1663
|
this.away = false;
|
|
1248
1664
|
this.emojiOpen = false;
|
|
1665
|
+
this.serverSuggestions = [];
|
|
1249
1666
|
void this.client?.sendMessage(text, files.length > 0 ? files : void 0);
|
|
1250
1667
|
this.textareaEl.value = "";
|
|
1251
1668
|
this.textareaEl.style.height = "auto";
|
|
@@ -1254,6 +1671,7 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1254
1671
|
sendChip(text) {
|
|
1255
1672
|
this.away = false;
|
|
1256
1673
|
this.emojiOpen = false;
|
|
1674
|
+
this.serverSuggestions = [];
|
|
1257
1675
|
void this.client?.sendMessage(text);
|
|
1258
1676
|
this.updateClasses();
|
|
1259
1677
|
}
|
|
@@ -1267,6 +1685,56 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1267
1685
|
this.bodyEl.querySelector(".oc-err-retry")?.addEventListener("click", () => {
|
|
1268
1686
|
void this.client?.start();
|
|
1269
1687
|
});
|
|
1688
|
+
this.bodyEl.querySelector(".oc-newmsg")?.addEventListener("click", () => this.startFresh());
|
|
1689
|
+
}
|
|
1690
|
+
// Reset the thread and open a brand-new session (the widget's "New message" after a session ended).
|
|
1691
|
+
startFresh() {
|
|
1692
|
+
this.serverSuggestions = null;
|
|
1693
|
+
this.receivedAt.clear();
|
|
1694
|
+
this.mdCache.clear();
|
|
1695
|
+
this.streamId = null;
|
|
1696
|
+
this.streamEl = null;
|
|
1697
|
+
this.renderedCount = 0;
|
|
1698
|
+
this.renderedBotFinal = 0;
|
|
1699
|
+
this.away = false;
|
|
1700
|
+
this.unseen = 0;
|
|
1701
|
+
this.client?.reset();
|
|
1702
|
+
this.started = true;
|
|
1703
|
+
void this.client?.start();
|
|
1704
|
+
setTimeout(() => this.textareaEl?.focus(), 60);
|
|
1705
|
+
}
|
|
1706
|
+
// A short two-tone chime for an incoming message the visitor hasn't seen (panel closed or scrolled
|
|
1707
|
+
// up). Off when the `sound="off"` attribute is set. Uses WebAudio (no asset); silent if unavailable
|
|
1708
|
+
// or the browser hasn't allowed audio yet.
|
|
1709
|
+
maybeChime() {
|
|
1710
|
+
const last = this.messages[this.messages.length - 1];
|
|
1711
|
+
if (last && (last.role === "assistant" || last.role === "human")) this.playChime();
|
|
1712
|
+
}
|
|
1713
|
+
playChime() {
|
|
1714
|
+
if (this.getAttribute("sound") === "off") return;
|
|
1715
|
+
try {
|
|
1716
|
+
const Ctx = window.AudioContext ?? window.webkitAudioContext;
|
|
1717
|
+
if (!Ctx) return;
|
|
1718
|
+
if (!this.audioCtx) this.audioCtx = new Ctx();
|
|
1719
|
+
const ctx = this.audioCtx;
|
|
1720
|
+
if (ctx.state === "suspended") void ctx.resume();
|
|
1721
|
+
const now = ctx.currentTime;
|
|
1722
|
+
[880, 1174.66].forEach((freq, i) => {
|
|
1723
|
+
const osc = ctx.createOscillator();
|
|
1724
|
+
const gain = ctx.createGain();
|
|
1725
|
+
osc.type = "sine";
|
|
1726
|
+
osc.frequency.value = freq;
|
|
1727
|
+
const t = now + i * 0.11;
|
|
1728
|
+
gain.gain.setValueAtTime(1e-4, t);
|
|
1729
|
+
gain.gain.exponentialRampToValueAtTime(0.12, t + 0.02);
|
|
1730
|
+
gain.gain.exponentialRampToValueAtTime(1e-4, t + 0.18);
|
|
1731
|
+
osc.connect(gain);
|
|
1732
|
+
gain.connect(ctx.destination);
|
|
1733
|
+
osc.start(t);
|
|
1734
|
+
osc.stop(t + 0.2);
|
|
1735
|
+
});
|
|
1736
|
+
} catch {
|
|
1737
|
+
}
|
|
1270
1738
|
}
|
|
1271
1739
|
// ── Emoji ─────────────────────────────────────────────────────────────────────────────────────
|
|
1272
1740
|
toggleEmoji() {
|