@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 CHANGED
@@ -36,9 +36,10 @@ npm install @oyehello/chat
36
36
  ```
37
37
  Or via a plain script tag (no bundler):
38
38
  ```html
39
- <script src="https://unpkg.com/@oyehello/chat"></script>
39
+ <script src="https://unpkg.com/@oyehello/chat@0.5.0"></script>
40
40
  <oyehello-chat api-key="ohk_pub_…" agent-id="agt_…" base-url="https://api.oyehello.ai"></oyehello-chat>
41
41
  ```
42
+ Pin a specific version (`@0.5.0` above) rather than the bare package name, so a customer's site loads the exact widget it was tested against instead of silently auto-upgrading on every publish.
42
43
  Theme the accent with the `--oc-accent` CSS variable.
43
44
 
44
45
  ### React
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",
@@ -117,6 +121,50 @@ async function uploadToPresigned(uploadUrl, file, fetchImpl) {
117
121
  if (!res.ok) throw new ChatApiError(`attachment upload failed with HTTP ${res.status}`, res.status);
118
122
  }
119
123
 
124
+ // src/fingerprint.ts
125
+ async function sha256Hex(input) {
126
+ const g = globalThis;
127
+ try {
128
+ if (g.crypto?.subtle && typeof TextEncoder !== "undefined") {
129
+ const buf = await g.crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
130
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 32);
131
+ }
132
+ } catch {
133
+ }
134
+ let h = 5381;
135
+ for (let i = 0; i < input.length; i++) h = (h << 5) + h + input.charCodeAt(i) >>> 0;
136
+ return h.toString(16).padStart(8, "0");
137
+ }
138
+ async function computeDeviceFingerprint() {
139
+ try {
140
+ const nav = typeof navigator !== "undefined" ? navigator : null;
141
+ if (!nav) return null;
142
+ const scr = typeof screen !== "undefined" ? screen : null;
143
+ const tz = (() => {
144
+ try {
145
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
146
+ } catch {
147
+ return "";
148
+ }
149
+ })();
150
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : "";
151
+ const signals = [
152
+ nav.userAgent,
153
+ Array.isArray(nav.languages) ? nav.languages.join(",") : "",
154
+ nav.language,
155
+ nav.platform,
156
+ nav.hardwareConcurrency,
157
+ nav.deviceMemory,
158
+ scr ? `${scr.width}x${scr.height}x${scr.colorDepth}` : "",
159
+ dpr,
160
+ tz
161
+ ].map((v) => String(v ?? "")).join("|");
162
+ return await sha256Hex(signals);
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
120
168
  // src/client.ts
121
169
  var seq = 0;
122
170
  var uid = (p) => `${p}_${Date.now().toString(36)}_${(seq++).toString(36)}`;
@@ -137,6 +185,8 @@ function createChatClient(options) {
137
185
  let reconnectTimer = null;
138
186
  let reconnectAttempts = 0;
139
187
  let closed = false;
188
+ let warnedProtocol = false;
189
+ let deviceFingerprint;
140
190
  const toolHandlers = /* @__PURE__ */ new Map();
141
191
  const clearReconnect = () => {
142
192
  if (reconnectTimer) {
@@ -231,6 +281,12 @@ function createChatClient(options) {
231
281
  emit({ type: "agentTyping", active: !!ev.active });
232
282
  } else if (ev.type === "suggestions") {
233
283
  emit({ type: "suggestions", items: Array.isArray(ev.items) ? ev.items : [] });
284
+ } else if (ev.type === "done") {
285
+ closed = true;
286
+ clearReconnect();
287
+ es?.close();
288
+ es = null;
289
+ setState("ended");
234
290
  }
235
291
  };
236
292
  const openStream = async () => {
@@ -273,7 +329,15 @@ function createChatClient(options) {
273
329
  }, delay);
274
330
  };
275
331
  const open = async () => {
276
- session = await openSession({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, user: options.user, clientTools: Array.from(toolHandlers.keys()), fetchImpl });
332
+ if (deviceFingerprint === void 0) deviceFingerprint = await computeDeviceFingerprint().catch(() => null);
333
+ 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 });
334
+ if (!warnedProtocol && typeof session.protocol === "number" && session.protocol !== CHAT_PROTOCOL_VERSION) {
335
+ warnedProtocol = true;
336
+ try {
337
+ console.warn(`[oyehello-chat] SDK protocol v${CHAT_PROTOCOL_VERSION} vs server v${session.protocol} - update @oyehello/chat to the latest version.`);
338
+ } catch {
339
+ }
340
+ }
277
341
  emit({ type: "agent", agent: session.agent });
278
342
  await openStream();
279
343
  };
@@ -364,6 +428,20 @@ function createChatClient(options) {
364
428
  es?.close();
365
429
  es = null;
366
430
  setState("ended");
431
+ },
432
+ reset() {
433
+ es?.close();
434
+ es = null;
435
+ clearReconnect();
436
+ session = null;
437
+ streamingId = null;
438
+ handoffState = "ai";
439
+ messages.length = 0;
440
+ seenTurnIds.clear();
441
+ seenCallIds.clear();
442
+ emit({ type: "messages", messages: [] });
443
+ emit({ type: "handoff", state: "ai" });
444
+ setState("idle");
367
445
  }
368
446
  };
369
447
  }
@@ -553,6 +631,12 @@ var STYLES = `
553
631
  .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
632
  .oc-err-retry:hover { background: var(--oc-tint-3); }
555
633
 
634
+ /* Session-ended state, inside the chat window */
635
+ .oc-ended { margin: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px 16px; text-align: center; }
636
+ .oc-ended-t { font-size: 13.5px; line-height: 1.5; color: var(--oc-fg-muted); text-wrap: pretty; }
637
+ .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); }
638
+ .oc-newmsg:hover { filter: brightness(1.08); }
639
+
556
640
  /* \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
641
  .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
642
  .turn { display: flex; flex-direction: column; gap: 6px; animation: w-in .42s var(--oc-ease) both; }
@@ -822,8 +906,19 @@ var OyehelloChatElement = class extends HTMLElement {
822
906
  // Server (flow) suggestions for the latest turn; null until the first `suggestions` event, then the
823
907
  // widget prefers these over the static `suggestions` attribute.
824
908
  serverSuggestions = null;
909
+ // The live streaming assistant bubble's markdown element + message id — so growing tokens patch just
910
+ // that element in place instead of rebuilding the whole message list on every token.
911
+ streamId = null;
912
+ streamEl = null;
913
+ audioCtx = null;
825
914
  teaserText = "Need a hand? Ask me anything.";
826
915
  teaserShownOnce = false;
916
+ // Staffed-hours: set from widget-config. When offline, the panel shows offlineMessage and the
917
+ // composer is disabled (opening a session is refused server-side anyway).
918
+ offline = false;
919
+ offlineMessage = "We're offline right now. Please check back during business hours.";
920
+ configLoaded = false;
921
+ // widget-config resolved (so we know online/offline before starting)
827
922
  idleTimer = null;
828
923
  idleReset = null;
829
924
  // ── shadow refs ──
@@ -921,7 +1016,19 @@ var OyehelloChatElement = class extends HTMLElement {
921
1016
  }
922
1017
  if (cfg.brand.logoUrl) this.applyBrandLogo(cfg.brand.logoUrl);
923
1018
  }
1019
+ if (cfg.offline) {
1020
+ this.offline = true;
1021
+ if (cfg.offlineMessage) this.offlineMessage = cfg.offlineMessage;
1022
+ this.renderMessages();
1023
+ this.updateStatus();
1024
+ }
924
1025
  } catch {
1026
+ } finally {
1027
+ this.configLoaded = true;
1028
+ if (this.uiOpen && !this.started && !this.offline && this.client) {
1029
+ this.started = true;
1030
+ void this.client.start();
1031
+ }
925
1032
  }
926
1033
  }
927
1034
  applyTeaserText() {
@@ -1110,7 +1217,7 @@ var OyehelloChatElement = class extends HTMLElement {
1110
1217
  sessionStorage.setItem(this.key("open"), "1");
1111
1218
  } catch {
1112
1219
  }
1113
- if (!this.started && this.client) {
1220
+ if (!this.started && this.client && this.configLoaded && !this.offline) {
1114
1221
  this.started = true;
1115
1222
  void this.client.start();
1116
1223
  }
@@ -1158,16 +1265,22 @@ var OyehelloChatElement = class extends HTMLElement {
1158
1265
  }
1159
1266
  updateStatus() {
1160
1267
  if (!this.textareaEl) return;
1161
- const disabled = this.state !== "ready";
1268
+ const disabled = this.state !== "ready" || this.offline;
1162
1269
  this.textareaEl.disabled = disabled;
1163
1270
  const handoff = HANDOFF_STATUS[this.handoffState];
1164
- const text = this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
1271
+ const text = this.offline ? "Offline" : this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
1165
1272
  if (this.statusEl) this.statusEl.textContent = text;
1166
1273
  }
1167
1274
  // ── Message list + scroll ─────────────────────────────────────────────────────────────────────
1168
1275
  renderMessages() {
1169
1276
  if (!this.bodyEl) return;
1170
1277
  const body = this.bodyEl;
1278
+ const last = this.messages[this.messages.length - 1];
1279
+ if (this.uiOpen && this.streamEl && last && last.id === this.streamId && last.role === "assistant" && last.streaming && this.messages.length === this.renderedCount) {
1280
+ this.streamEl.innerHTML = renderMarkdown(last.text || "");
1281
+ if (!this.away) this.scrollToBottom();
1282
+ return;
1283
+ }
1171
1284
  const prevTop = body.scrollTop;
1172
1285
  const added = this.messages.length - this.renderedCount;
1173
1286
  const botFinal = this.messages.filter((m) => m.role === "assistant" && !m.streaming).length;
@@ -1187,13 +1300,29 @@ var OyehelloChatElement = class extends HTMLElement {
1187
1300
  if (this.state === "error" && this.lastError) {
1188
1301
  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
1302
  }
1303
+ if (this.state === "ended") {
1304
+ 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>`;
1305
+ }
1306
+ if (this.offline && this.messages.length === 0 && this.state !== "ended") {
1307
+ html += `<div class="oc-ended"><span class="oc-ended-t">${escapeHtml(this.offlineMessage)}</span></div>`;
1308
+ }
1190
1309
  body.innerHTML = html;
1191
1310
  this.hydrateAttachmentImages();
1192
1311
  this.bindChips();
1193
1312
  this.renderedCount = this.messages.length;
1194
1313
  this.renderedBotFinal = botFinal;
1314
+ this.streamId = null;
1315
+ this.streamEl = null;
1316
+ if (last && last.role === "assistant" && last.streaming) {
1317
+ const mds = this.bodyEl.querySelectorAll(".turn.bot .md");
1318
+ this.streamEl = mds[mds.length - 1] ?? null;
1319
+ if (this.streamEl) this.streamId = last.id;
1320
+ }
1195
1321
  if (!this.uiOpen) {
1196
- if (added > 0) this.unseen += added;
1322
+ if (added > 0) {
1323
+ this.unseen += added;
1324
+ this.maybeChime();
1325
+ }
1197
1326
  this.updateClasses();
1198
1327
  return;
1199
1328
  }
@@ -1201,7 +1330,10 @@ var OyehelloChatElement = class extends HTMLElement {
1201
1330
  else if (!this.away) this.scrollToBottom();
1202
1331
  else {
1203
1332
  body.scrollTop = prevTop;
1204
- if (added > 0) this.unseen += added;
1333
+ if (added > 0) {
1334
+ this.unseen += added;
1335
+ this.maybeChime();
1336
+ }
1205
1337
  }
1206
1338
  this.updateClasses();
1207
1339
  }
@@ -1252,6 +1384,7 @@ var OyehelloChatElement = class extends HTMLElement {
1252
1384
  }
1253
1385
  this.away = false;
1254
1386
  this.emojiOpen = false;
1387
+ this.serverSuggestions = [];
1255
1388
  void this.client?.sendMessage(text, files.length > 0 ? files : void 0);
1256
1389
  this.textareaEl.value = "";
1257
1390
  this.textareaEl.style.height = "auto";
@@ -1260,6 +1393,7 @@ var OyehelloChatElement = class extends HTMLElement {
1260
1393
  sendChip(text) {
1261
1394
  this.away = false;
1262
1395
  this.emojiOpen = false;
1396
+ this.serverSuggestions = [];
1263
1397
  void this.client?.sendMessage(text);
1264
1398
  this.updateClasses();
1265
1399
  }
@@ -1273,6 +1407,56 @@ var OyehelloChatElement = class extends HTMLElement {
1273
1407
  this.bodyEl.querySelector(".oc-err-retry")?.addEventListener("click", () => {
1274
1408
  void this.client?.start();
1275
1409
  });
1410
+ this.bodyEl.querySelector(".oc-newmsg")?.addEventListener("click", () => this.startFresh());
1411
+ }
1412
+ // Reset the thread and open a brand-new session (the widget's "New message" after a session ended).
1413
+ startFresh() {
1414
+ this.serverSuggestions = null;
1415
+ this.receivedAt.clear();
1416
+ this.mdCache.clear();
1417
+ this.streamId = null;
1418
+ this.streamEl = null;
1419
+ this.renderedCount = 0;
1420
+ this.renderedBotFinal = 0;
1421
+ this.away = false;
1422
+ this.unseen = 0;
1423
+ this.client?.reset();
1424
+ this.started = true;
1425
+ void this.client?.start();
1426
+ setTimeout(() => this.textareaEl?.focus(), 60);
1427
+ }
1428
+ // A short two-tone chime for an incoming message the visitor hasn't seen (panel closed or scrolled
1429
+ // up). Off when the `sound="off"` attribute is set. Uses WebAudio (no asset); silent if unavailable
1430
+ // or the browser hasn't allowed audio yet.
1431
+ maybeChime() {
1432
+ const last = this.messages[this.messages.length - 1];
1433
+ if (last && (last.role === "assistant" || last.role === "human")) this.playChime();
1434
+ }
1435
+ playChime() {
1436
+ if (this.getAttribute("sound") === "off") return;
1437
+ try {
1438
+ const Ctx = window.AudioContext ?? window.webkitAudioContext;
1439
+ if (!Ctx) return;
1440
+ if (!this.audioCtx) this.audioCtx = new Ctx();
1441
+ const ctx = this.audioCtx;
1442
+ if (ctx.state === "suspended") void ctx.resume();
1443
+ const now = ctx.currentTime;
1444
+ [880, 1174.66].forEach((freq, i) => {
1445
+ const osc = ctx.createOscillator();
1446
+ const gain = ctx.createGain();
1447
+ osc.type = "sine";
1448
+ osc.frequency.value = freq;
1449
+ const t = now + i * 0.11;
1450
+ gain.gain.setValueAtTime(1e-4, t);
1451
+ gain.gain.exponentialRampToValueAtTime(0.12, t + 0.02);
1452
+ gain.gain.exponentialRampToValueAtTime(1e-4, t + 0.18);
1453
+ osc.connect(gain);
1454
+ gain.connect(ctx.destination);
1455
+ osc.start(t);
1456
+ osc.stop(t + 0.2);
1457
+ });
1458
+ } catch {
1459
+ }
1276
1460
  }
1277
1461
  // ── Emoji ─────────────────────────────────────────────────────────────────────────────────────
1278
1462
  toggleEmoji() {