@oyehello/chat 0.4.0 → 0.5.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/README.md +2 -1
- package/dist/index.cjs +191 -7
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.global.js +191 -7
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +191 -7
- 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",
|
|
@@ -111,6 +115,50 @@ async function uploadToPresigned(uploadUrl, file, fetchImpl) {
|
|
|
111
115
|
if (!res.ok) throw new ChatApiError(`attachment upload failed with HTTP ${res.status}`, res.status);
|
|
112
116
|
}
|
|
113
117
|
|
|
118
|
+
// src/fingerprint.ts
|
|
119
|
+
async function sha256Hex(input) {
|
|
120
|
+
const g = globalThis;
|
|
121
|
+
try {
|
|
122
|
+
if (g.crypto?.subtle && typeof TextEncoder !== "undefined") {
|
|
123
|
+
const buf = await g.crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
|
|
124
|
+
return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 32);
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
}
|
|
128
|
+
let h = 5381;
|
|
129
|
+
for (let i = 0; i < input.length; i++) h = (h << 5) + h + input.charCodeAt(i) >>> 0;
|
|
130
|
+
return h.toString(16).padStart(8, "0");
|
|
131
|
+
}
|
|
132
|
+
async function computeDeviceFingerprint() {
|
|
133
|
+
try {
|
|
134
|
+
const nav = typeof navigator !== "undefined" ? navigator : null;
|
|
135
|
+
if (!nav) return null;
|
|
136
|
+
const scr = typeof screen !== "undefined" ? screen : null;
|
|
137
|
+
const tz = (() => {
|
|
138
|
+
try {
|
|
139
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
140
|
+
} catch {
|
|
141
|
+
return "";
|
|
142
|
+
}
|
|
143
|
+
})();
|
|
144
|
+
const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : "";
|
|
145
|
+
const signals = [
|
|
146
|
+
nav.userAgent,
|
|
147
|
+
Array.isArray(nav.languages) ? nav.languages.join(",") : "",
|
|
148
|
+
nav.language,
|
|
149
|
+
nav.platform,
|
|
150
|
+
nav.hardwareConcurrency,
|
|
151
|
+
nav.deviceMemory,
|
|
152
|
+
scr ? `${scr.width}x${scr.height}x${scr.colorDepth}` : "",
|
|
153
|
+
dpr,
|
|
154
|
+
tz
|
|
155
|
+
].map((v) => String(v ?? "")).join("|");
|
|
156
|
+
return await sha256Hex(signals);
|
|
157
|
+
} catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
114
162
|
// src/client.ts
|
|
115
163
|
var seq = 0;
|
|
116
164
|
var uid = (p) => `${p}_${Date.now().toString(36)}_${(seq++).toString(36)}`;
|
|
@@ -131,6 +179,8 @@ function createChatClient(options) {
|
|
|
131
179
|
let reconnectTimer = null;
|
|
132
180
|
let reconnectAttempts = 0;
|
|
133
181
|
let closed = false;
|
|
182
|
+
let warnedProtocol = false;
|
|
183
|
+
let deviceFingerprint;
|
|
134
184
|
const toolHandlers = /* @__PURE__ */ new Map();
|
|
135
185
|
const clearReconnect = () => {
|
|
136
186
|
if (reconnectTimer) {
|
|
@@ -225,6 +275,12 @@ function createChatClient(options) {
|
|
|
225
275
|
emit({ type: "agentTyping", active: !!ev.active });
|
|
226
276
|
} else if (ev.type === "suggestions") {
|
|
227
277
|
emit({ type: "suggestions", items: Array.isArray(ev.items) ? ev.items : [] });
|
|
278
|
+
} else if (ev.type === "done") {
|
|
279
|
+
closed = true;
|
|
280
|
+
clearReconnect();
|
|
281
|
+
es?.close();
|
|
282
|
+
es = null;
|
|
283
|
+
setState("ended");
|
|
228
284
|
}
|
|
229
285
|
};
|
|
230
286
|
const openStream = async () => {
|
|
@@ -267,7 +323,15 @@ function createChatClient(options) {
|
|
|
267
323
|
}, delay);
|
|
268
324
|
};
|
|
269
325
|
const open = async () => {
|
|
270
|
-
|
|
326
|
+
if (deviceFingerprint === void 0) deviceFingerprint = await computeDeviceFingerprint().catch(() => null);
|
|
327
|
+
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 });
|
|
328
|
+
if (!warnedProtocol && typeof session.protocol === "number" && session.protocol !== CHAT_PROTOCOL_VERSION) {
|
|
329
|
+
warnedProtocol = true;
|
|
330
|
+
try {
|
|
331
|
+
console.warn(`[oyehello-chat] SDK protocol v${CHAT_PROTOCOL_VERSION} vs server v${session.protocol} - update @oyehello/chat to the latest version.`);
|
|
332
|
+
} catch {
|
|
333
|
+
}
|
|
334
|
+
}
|
|
271
335
|
emit({ type: "agent", agent: session.agent });
|
|
272
336
|
await openStream();
|
|
273
337
|
};
|
|
@@ -358,6 +422,20 @@ function createChatClient(options) {
|
|
|
358
422
|
es?.close();
|
|
359
423
|
es = null;
|
|
360
424
|
setState("ended");
|
|
425
|
+
},
|
|
426
|
+
reset() {
|
|
427
|
+
es?.close();
|
|
428
|
+
es = null;
|
|
429
|
+
clearReconnect();
|
|
430
|
+
session = null;
|
|
431
|
+
streamingId = null;
|
|
432
|
+
handoffState = "ai";
|
|
433
|
+
messages.length = 0;
|
|
434
|
+
seenTurnIds.clear();
|
|
435
|
+
seenCallIds.clear();
|
|
436
|
+
emit({ type: "messages", messages: [] });
|
|
437
|
+
emit({ type: "handoff", state: "ai" });
|
|
438
|
+
setState("idle");
|
|
361
439
|
}
|
|
362
440
|
};
|
|
363
441
|
}
|
|
@@ -547,6 +625,12 @@ var STYLES = `
|
|
|
547
625
|
.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
626
|
.oc-err-retry:hover { background: var(--oc-tint-3); }
|
|
549
627
|
|
|
628
|
+
/* Session-ended state, inside the chat window */
|
|
629
|
+
.oc-ended { margin: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px 16px; text-align: center; }
|
|
630
|
+
.oc-ended-t { font-size: 13.5px; line-height: 1.5; color: var(--oc-fg-muted); text-wrap: pretty; }
|
|
631
|
+
.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); }
|
|
632
|
+
.oc-newmsg:hover { filter: brightness(1.08); }
|
|
633
|
+
|
|
550
634
|
/* \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
635
|
.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
636
|
.turn { display: flex; flex-direction: column; gap: 6px; animation: w-in .42s var(--oc-ease) both; }
|
|
@@ -816,8 +900,19 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
816
900
|
// Server (flow) suggestions for the latest turn; null until the first `suggestions` event, then the
|
|
817
901
|
// widget prefers these over the static `suggestions` attribute.
|
|
818
902
|
serverSuggestions = null;
|
|
903
|
+
// The live streaming assistant bubble's markdown element + message id — so growing tokens patch just
|
|
904
|
+
// that element in place instead of rebuilding the whole message list on every token.
|
|
905
|
+
streamId = null;
|
|
906
|
+
streamEl = null;
|
|
907
|
+
audioCtx = null;
|
|
819
908
|
teaserText = "Need a hand? Ask me anything.";
|
|
820
909
|
teaserShownOnce = false;
|
|
910
|
+
// Staffed-hours: set from widget-config. When offline, the panel shows offlineMessage and the
|
|
911
|
+
// composer is disabled (opening a session is refused server-side anyway).
|
|
912
|
+
offline = false;
|
|
913
|
+
offlineMessage = "We're offline right now. Please check back during business hours.";
|
|
914
|
+
configLoaded = false;
|
|
915
|
+
// widget-config resolved (so we know online/offline before starting)
|
|
821
916
|
idleTimer = null;
|
|
822
917
|
idleReset = null;
|
|
823
918
|
// ── shadow refs ──
|
|
@@ -915,7 +1010,19 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
915
1010
|
}
|
|
916
1011
|
if (cfg.brand.logoUrl) this.applyBrandLogo(cfg.brand.logoUrl);
|
|
917
1012
|
}
|
|
1013
|
+
if (cfg.offline) {
|
|
1014
|
+
this.offline = true;
|
|
1015
|
+
if (cfg.offlineMessage) this.offlineMessage = cfg.offlineMessage;
|
|
1016
|
+
this.renderMessages();
|
|
1017
|
+
this.updateStatus();
|
|
1018
|
+
}
|
|
918
1019
|
} catch {
|
|
1020
|
+
} finally {
|
|
1021
|
+
this.configLoaded = true;
|
|
1022
|
+
if (this.uiOpen && !this.started && !this.offline && this.client) {
|
|
1023
|
+
this.started = true;
|
|
1024
|
+
void this.client.start();
|
|
1025
|
+
}
|
|
919
1026
|
}
|
|
920
1027
|
}
|
|
921
1028
|
applyTeaserText() {
|
|
@@ -1104,7 +1211,7 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1104
1211
|
sessionStorage.setItem(this.key("open"), "1");
|
|
1105
1212
|
} catch {
|
|
1106
1213
|
}
|
|
1107
|
-
if (!this.started && this.client) {
|
|
1214
|
+
if (!this.started && this.client && this.configLoaded && !this.offline) {
|
|
1108
1215
|
this.started = true;
|
|
1109
1216
|
void this.client.start();
|
|
1110
1217
|
}
|
|
@@ -1152,16 +1259,22 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1152
1259
|
}
|
|
1153
1260
|
updateStatus() {
|
|
1154
1261
|
if (!this.textareaEl) return;
|
|
1155
|
-
const disabled = this.state !== "ready";
|
|
1262
|
+
const disabled = this.state !== "ready" || this.offline;
|
|
1156
1263
|
this.textareaEl.disabled = disabled;
|
|
1157
1264
|
const handoff = HANDOFF_STATUS[this.handoffState];
|
|
1158
|
-
const text = this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
|
|
1265
|
+
const text = this.offline ? "Offline" : this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
|
|
1159
1266
|
if (this.statusEl) this.statusEl.textContent = text;
|
|
1160
1267
|
}
|
|
1161
1268
|
// ── Message list + scroll ─────────────────────────────────────────────────────────────────────
|
|
1162
1269
|
renderMessages() {
|
|
1163
1270
|
if (!this.bodyEl) return;
|
|
1164
1271
|
const body = this.bodyEl;
|
|
1272
|
+
const last = this.messages[this.messages.length - 1];
|
|
1273
|
+
if (this.uiOpen && this.streamEl && last && last.id === this.streamId && last.role === "assistant" && last.streaming && this.messages.length === this.renderedCount) {
|
|
1274
|
+
this.streamEl.innerHTML = renderMarkdown(last.text || "");
|
|
1275
|
+
if (!this.away) this.scrollToBottom();
|
|
1276
|
+
return;
|
|
1277
|
+
}
|
|
1165
1278
|
const prevTop = body.scrollTop;
|
|
1166
1279
|
const added = this.messages.length - this.renderedCount;
|
|
1167
1280
|
const botFinal = this.messages.filter((m) => m.role === "assistant" && !m.streaming).length;
|
|
@@ -1181,13 +1294,29 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1181
1294
|
if (this.state === "error" && this.lastError) {
|
|
1182
1295
|
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
1296
|
}
|
|
1297
|
+
if (this.state === "ended") {
|
|
1298
|
+
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>`;
|
|
1299
|
+
}
|
|
1300
|
+
if (this.offline && this.messages.length === 0 && this.state !== "ended") {
|
|
1301
|
+
html += `<div class="oc-ended"><span class="oc-ended-t">${escapeHtml(this.offlineMessage)}</span></div>`;
|
|
1302
|
+
}
|
|
1184
1303
|
body.innerHTML = html;
|
|
1185
1304
|
this.hydrateAttachmentImages();
|
|
1186
1305
|
this.bindChips();
|
|
1187
1306
|
this.renderedCount = this.messages.length;
|
|
1188
1307
|
this.renderedBotFinal = botFinal;
|
|
1308
|
+
this.streamId = null;
|
|
1309
|
+
this.streamEl = null;
|
|
1310
|
+
if (last && last.role === "assistant" && last.streaming) {
|
|
1311
|
+
const mds = this.bodyEl.querySelectorAll(".turn.bot .md");
|
|
1312
|
+
this.streamEl = mds[mds.length - 1] ?? null;
|
|
1313
|
+
if (this.streamEl) this.streamId = last.id;
|
|
1314
|
+
}
|
|
1189
1315
|
if (!this.uiOpen) {
|
|
1190
|
-
if (added > 0)
|
|
1316
|
+
if (added > 0) {
|
|
1317
|
+
this.unseen += added;
|
|
1318
|
+
this.maybeChime();
|
|
1319
|
+
}
|
|
1191
1320
|
this.updateClasses();
|
|
1192
1321
|
return;
|
|
1193
1322
|
}
|
|
@@ -1195,7 +1324,10 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1195
1324
|
else if (!this.away) this.scrollToBottom();
|
|
1196
1325
|
else {
|
|
1197
1326
|
body.scrollTop = prevTop;
|
|
1198
|
-
if (added > 0)
|
|
1327
|
+
if (added > 0) {
|
|
1328
|
+
this.unseen += added;
|
|
1329
|
+
this.maybeChime();
|
|
1330
|
+
}
|
|
1199
1331
|
}
|
|
1200
1332
|
this.updateClasses();
|
|
1201
1333
|
}
|
|
@@ -1246,6 +1378,7 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1246
1378
|
}
|
|
1247
1379
|
this.away = false;
|
|
1248
1380
|
this.emojiOpen = false;
|
|
1381
|
+
this.serverSuggestions = [];
|
|
1249
1382
|
void this.client?.sendMessage(text, files.length > 0 ? files : void 0);
|
|
1250
1383
|
this.textareaEl.value = "";
|
|
1251
1384
|
this.textareaEl.style.height = "auto";
|
|
@@ -1254,6 +1387,7 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1254
1387
|
sendChip(text) {
|
|
1255
1388
|
this.away = false;
|
|
1256
1389
|
this.emojiOpen = false;
|
|
1390
|
+
this.serverSuggestions = [];
|
|
1257
1391
|
void this.client?.sendMessage(text);
|
|
1258
1392
|
this.updateClasses();
|
|
1259
1393
|
}
|
|
@@ -1267,6 +1401,56 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1267
1401
|
this.bodyEl.querySelector(".oc-err-retry")?.addEventListener("click", () => {
|
|
1268
1402
|
void this.client?.start();
|
|
1269
1403
|
});
|
|
1404
|
+
this.bodyEl.querySelector(".oc-newmsg")?.addEventListener("click", () => this.startFresh());
|
|
1405
|
+
}
|
|
1406
|
+
// Reset the thread and open a brand-new session (the widget's "New message" after a session ended).
|
|
1407
|
+
startFresh() {
|
|
1408
|
+
this.serverSuggestions = null;
|
|
1409
|
+
this.receivedAt.clear();
|
|
1410
|
+
this.mdCache.clear();
|
|
1411
|
+
this.streamId = null;
|
|
1412
|
+
this.streamEl = null;
|
|
1413
|
+
this.renderedCount = 0;
|
|
1414
|
+
this.renderedBotFinal = 0;
|
|
1415
|
+
this.away = false;
|
|
1416
|
+
this.unseen = 0;
|
|
1417
|
+
this.client?.reset();
|
|
1418
|
+
this.started = true;
|
|
1419
|
+
void this.client?.start();
|
|
1420
|
+
setTimeout(() => this.textareaEl?.focus(), 60);
|
|
1421
|
+
}
|
|
1422
|
+
// A short two-tone chime for an incoming message the visitor hasn't seen (panel closed or scrolled
|
|
1423
|
+
// up). Off when the `sound="off"` attribute is set. Uses WebAudio (no asset); silent if unavailable
|
|
1424
|
+
// or the browser hasn't allowed audio yet.
|
|
1425
|
+
maybeChime() {
|
|
1426
|
+
const last = this.messages[this.messages.length - 1];
|
|
1427
|
+
if (last && (last.role === "assistant" || last.role === "human")) this.playChime();
|
|
1428
|
+
}
|
|
1429
|
+
playChime() {
|
|
1430
|
+
if (this.getAttribute("sound") === "off") return;
|
|
1431
|
+
try {
|
|
1432
|
+
const Ctx = window.AudioContext ?? window.webkitAudioContext;
|
|
1433
|
+
if (!Ctx) return;
|
|
1434
|
+
if (!this.audioCtx) this.audioCtx = new Ctx();
|
|
1435
|
+
const ctx = this.audioCtx;
|
|
1436
|
+
if (ctx.state === "suspended") void ctx.resume();
|
|
1437
|
+
const now = ctx.currentTime;
|
|
1438
|
+
[880, 1174.66].forEach((freq, i) => {
|
|
1439
|
+
const osc = ctx.createOscillator();
|
|
1440
|
+
const gain = ctx.createGain();
|
|
1441
|
+
osc.type = "sine";
|
|
1442
|
+
osc.frequency.value = freq;
|
|
1443
|
+
const t = now + i * 0.11;
|
|
1444
|
+
gain.gain.setValueAtTime(1e-4, t);
|
|
1445
|
+
gain.gain.exponentialRampToValueAtTime(0.12, t + 0.02);
|
|
1446
|
+
gain.gain.exponentialRampToValueAtTime(1e-4, t + 0.18);
|
|
1447
|
+
osc.connect(gain);
|
|
1448
|
+
gain.connect(ctx.destination);
|
|
1449
|
+
osc.start(t);
|
|
1450
|
+
osc.stop(t + 0.2);
|
|
1451
|
+
});
|
|
1452
|
+
} catch {
|
|
1453
|
+
}
|
|
1270
1454
|
}
|
|
1271
1455
|
// ── Emoji ─────────────────────────────────────────────────────────────────────────────────────
|
|
1272
1456
|
toggleEmoji() {
|