@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/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
- session = await openSession({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, user: options.user, clientTools: Array.from(toolHandlers.keys()), fetchImpl });
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) this.unseen += added;
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) this.unseen += added;
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() {