@oyehello/chat 0.3.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",
@@ -37,6 +41,15 @@ async function openSession(opts) {
37
41
  if (!res.ok) throw await asError(res);
38
42
  return await res.json();
39
43
  }
44
+ async function getWidgetConfig(opts) {
45
+ const res = await opts.fetchImpl(`${opts.baseUrl}/chat/widget-config?agentId=${encodeURIComponent(opts.agentId)}`, {
46
+ method: "GET",
47
+ credentials: "omit",
48
+ headers: { Authorization: `Bearer ${opts.apiKey}` }
49
+ });
50
+ if (!res.ok) throw await asError(res);
51
+ return await res.json();
52
+ }
40
53
  async function postMessage(opts) {
41
54
  const body = { text: opts.text };
42
55
  if (opts.attachments && opts.attachments.length > 0) body.attachments = opts.attachments;
@@ -102,6 +115,50 @@ async function uploadToPresigned(uploadUrl, file, fetchImpl) {
102
115
  if (!res.ok) throw new ChatApiError(`attachment upload failed with HTTP ${res.status}`, res.status);
103
116
  }
104
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
+
105
162
  // src/client.ts
106
163
  var seq = 0;
107
164
  var uid = (p) => `${p}_${Date.now().toString(36)}_${(seq++).toString(36)}`;
@@ -122,6 +179,8 @@ function createChatClient(options) {
122
179
  let reconnectTimer = null;
123
180
  let reconnectAttempts = 0;
124
181
  let closed = false;
182
+ let warnedProtocol = false;
183
+ let deviceFingerprint;
125
184
  const toolHandlers = /* @__PURE__ */ new Map();
126
185
  const clearReconnect = () => {
127
186
  if (reconnectTimer) {
@@ -214,6 +273,14 @@ function createChatClient(options) {
214
273
  }
215
274
  } else if (ev.type === "agent_typing") {
216
275
  emit({ type: "agentTyping", active: !!ev.active });
276
+ } else if (ev.type === "suggestions") {
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");
217
284
  }
218
285
  };
219
286
  const openStream = async () => {
@@ -256,7 +323,15 @@ function createChatClient(options) {
256
323
  }, delay);
257
324
  };
258
325
  const open = async () => {
259
- 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
+ }
260
335
  emit({ type: "agent", agent: session.agent });
261
336
  await openStream();
262
337
  };
@@ -281,6 +356,9 @@ function createChatClient(options) {
281
356
  if (!session) throw new Error("call start() before getAttachmentUrl");
282
357
  return getAttachmentUrl({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, storageRef, fetchImpl });
283
358
  },
359
+ getWidgetConfig() {
360
+ return getWidgetConfig({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, fetchImpl });
361
+ },
284
362
  async start() {
285
363
  if (state === "opening" || state === "ready") return;
286
364
  closed = false;
@@ -344,6 +422,20 @@ function createChatClient(options) {
344
422
  es?.close();
345
423
  es = null;
346
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");
347
439
  }
348
440
  };
349
441
  }
@@ -527,8 +619,17 @@ var STYLES = `
527
619
  .i-expand { opacity: 1; } .i-shrink { opacity: 0; }
528
620
  .oc.max .i-expand { opacity: 0; } .oc.max .i-shrink { opacity: 1; }
529
621
 
530
- .err { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 16px; font-size: 12px; color: #fff; background: var(--oc-danger); }
531
- .err .retry { padding: 3px 10px; font-size: 11px; background: rgba(255,255,255,0.2); color: #fff; border: none; border-radius: 6px; cursor: pointer; }
622
+ /* Inline connection error, shown inside the chat window (no banner) */
623
+ .oc-err { margin: auto; display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 24px 16px; text-align: center; }
624
+ .oc-err-t { font-size: 13px; line-height: 1.5; color: var(--oc-fg-muted); text-wrap: pretty; }
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); }
626
+ .oc-err-retry:hover { background: var(--oc-tint-3); }
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); }
532
633
 
533
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 */
534
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; }
@@ -796,6 +897,24 @@ var OyehelloChatElement = class extends HTMLElement {
796
897
  renderedCount = 0;
797
898
  renderedBotFinal = 0;
798
899
  timers = [];
900
+ // Server (flow) suggestions for the latest turn; null until the first `suggestions` event, then the
901
+ // widget prefers these over the static `suggestions` attribute.
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;
908
+ teaserText = "Need a hand? Ask me anything.";
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)
916
+ idleTimer = null;
917
+ idleReset = null;
799
918
  // ── shadow refs ──
800
919
  oc;
801
920
  bodyEl;
@@ -803,7 +922,6 @@ var OyehelloChatElement = class extends HTMLElement {
803
922
  fileEl;
804
923
  titleEl;
805
924
  statusEl;
806
- errEl;
807
925
  emojiListEl;
808
926
  emojiSearchEl;
809
927
  connectedCallback() {
@@ -815,6 +933,7 @@ var OyehelloChatElement = class extends HTMLElement {
815
933
  this.renderError("oyehello-chat: api-key, agent-id and base-url are required");
816
934
  return;
817
935
  }
936
+ this.teaserText = this.getAttribute("teaser") || this.teaserText;
818
937
  this.client = createChatClient({ apiKey, agentId, baseUrl });
819
938
  if (Object.keys(this.pendingTools).length > 0) {
820
939
  this.client.registerTools(this.pendingTools);
@@ -826,6 +945,7 @@ var OyehelloChatElement = class extends HTMLElement {
826
945
  this.updateHeader();
827
946
  this.updateStatus();
828
947
  this.updateClasses();
948
+ void this.loadWidgetConfig();
829
949
  let wasOpen = false;
830
950
  try {
831
951
  wasOpen = sessionStorage.getItem(this.key("open")) === "1";
@@ -839,24 +959,93 @@ var OyehelloChatElement = class extends HTMLElement {
839
959
  this.timers.push(setTimeout(() => {
840
960
  this.oc?.classList.remove("ring");
841
961
  }, 6e3));
842
- const teaserText = this.getAttribute("teaser");
962
+ this.armIdleTeaser();
963
+ }
964
+ // Show the teaser once the visitor has been idle (no page interaction) for 10s — unless the panel
965
+ // is already open or the teaser was dismissed this session. Fires at most once.
966
+ armIdleTeaser() {
843
967
  let dismissed = false;
844
968
  try {
845
969
  dismissed = sessionStorage.getItem(this.key("teaser")) === "0";
846
970
  } catch {
847
971
  }
848
- if (teaserText && !dismissed) {
849
- this.timers.push(setTimeout(() => {
850
- if (!this.uiOpen) {
972
+ if (dismissed) return;
973
+ const reset = () => {
974
+ if (this.idleTimer) clearTimeout(this.idleTimer);
975
+ this.idleTimer = setTimeout(() => {
976
+ if (!this.uiOpen && !this.teaserShownOnce) {
851
977
  this.teaserActive = true;
978
+ this.teaserShownOnce = true;
852
979
  this.updateClasses();
980
+ this.teardownIdle();
981
+ }
982
+ }, 1e4);
983
+ };
984
+ this.idleReset = reset;
985
+ for (const ev of ["mousemove", "keydown", "scroll", "touchstart", "click"]) document.addEventListener(ev, reset, { passive: true });
986
+ reset();
987
+ }
988
+ teardownIdle() {
989
+ if (this.idleTimer) {
990
+ clearTimeout(this.idleTimer);
991
+ this.idleTimer = null;
992
+ }
993
+ if (this.idleReset) {
994
+ for (const ev of ["mousemove", "keydown", "scroll", "touchstart", "click"]) document.removeEventListener(ev, this.idleReset);
995
+ this.idleReset = null;
996
+ }
997
+ }
998
+ async loadWidgetConfig() {
999
+ try {
1000
+ const cfg = await this.client.getWidgetConfig();
1001
+ if (!this.getAttribute("teaser") && cfg.teaser) {
1002
+ this.teaserText = cfg.teaser;
1003
+ this.applyTeaserText();
1004
+ }
1005
+ if (cfg.brand) {
1006
+ if (cfg.brand.accentColor) this.style.setProperty("--oc-accent", cfg.brand.accentColor);
1007
+ if (cfg.brand.name && !this.getAttribute("title")) {
1008
+ this.agentName = cfg.brand.name;
1009
+ this.updateHeader();
853
1010
  }
854
- }, 1400));
1011
+ if (cfg.brand.logoUrl) this.applyBrandLogo(cfg.brand.logoUrl);
1012
+ }
1013
+ if (cfg.offline) {
1014
+ this.offline = true;
1015
+ if (cfg.offlineMessage) this.offlineMessage = cfg.offlineMessage;
1016
+ this.renderMessages();
1017
+ this.updateStatus();
1018
+ }
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
+ }
855
1026
  }
856
1027
  }
1028
+ applyTeaserText() {
1029
+ const el = this.shadowRoot?.querySelector(".teaser-btn span:last-child");
1030
+ if (el) el.textContent = this.teaserText;
1031
+ }
1032
+ applyBrandLogo(url) {
1033
+ const set = (host) => {
1034
+ if (!host) return;
1035
+ host.textContent = "";
1036
+ const img = document.createElement("img");
1037
+ img.src = url;
1038
+ img.alt = "";
1039
+ img.style.cssText = "width:100%;height:100%;object-fit:contain";
1040
+ host.appendChild(img);
1041
+ };
1042
+ set(this.shadowRoot?.querySelector(".hd .logo"));
1043
+ set(this.shadowRoot?.querySelector(".teaser-btn .logo"));
1044
+ }
857
1045
  disconnectedCallback() {
858
1046
  this.off?.();
859
1047
  void this.client?.end();
1048
+ this.teardownIdle();
860
1049
  for (const t of this.timers) clearTimeout(t);
861
1050
  for (const url of this.imagePreviewCache.values()) {
862
1051
  if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
@@ -870,18 +1059,22 @@ var OyehelloChatElement = class extends HTMLElement {
870
1059
  key(k) {
871
1060
  return `oyehello-chat:${k}:${this.getAttribute("agent-id") ?? ""}`;
872
1061
  }
873
- get suggestions() {
1062
+ // Static fallback chips from the `suggestions` attribute (for embedders without flow-driven ones).
1063
+ get attrSuggestions() {
874
1064
  return (this.getAttribute("suggestions") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
875
1065
  }
1066
+ // Effective chips: server (flow) suggestions once received, else the static attribute fallback.
1067
+ get chipLabels() {
1068
+ return this.serverSuggestions ?? this.attrSuggestions;
1069
+ }
876
1070
  // ── Shell (built once) ─────────────────────────────────────────────────────────────────────────
877
1071
  renderShell() {
878
1072
  const root = this.shadowRoot;
879
- const hasTeaser = !!this.getAttribute("teaser");
880
1073
  const hasPrivacy = !!this.getAttribute("privacy-url");
881
- const teaserText = this.getAttribute("teaser") ?? "";
1074
+ const teaserText = this.teaserText;
882
1075
  root.innerHTML = `
883
1076
  <style>${STYLES}</style>
884
- <div class="oc${hasTeaser ? " has-teaser" : ""}${hasPrivacy ? " has-privacy" : ""}">
1077
+ <div class="oc has-teaser${hasPrivacy ? " has-privacy" : ""}">
885
1078
  <div class="panel" role="dialog" aria-label="Chat">
886
1079
  <div class="hd">
887
1080
  <span class="hd-grid"></span>
@@ -890,7 +1083,6 @@ var OyehelloChatElement = class extends HTMLElement {
890
1083
  <button type="button" class="hd-btn max" aria-label="Expand"><span class="i i-expand">${ICON.expand}</span><span class="i i-shrink">${ICON.shrink}</span></button>
891
1084
  <button type="button" class="hd-btn min" aria-label="Minimise">${ICON.minus}</button>
892
1085
  </div>
893
- <div class="err" hidden></div>
894
1086
  <div class="body"></div>
895
1087
  <span class="emoji-scrim"></span>
896
1088
  <div class="emoji-panel">
@@ -934,7 +1126,6 @@ var OyehelloChatElement = class extends HTMLElement {
934
1126
  this.fileEl = root.querySelector('input[type="file"]');
935
1127
  this.titleEl = root.querySelector(".hd-title");
936
1128
  this.statusEl = root.querySelector(".hd-status .t");
937
- this.errEl = root.querySelector(".err");
938
1129
  this.emojiListEl = root.querySelector(".emoji-list");
939
1130
  this.emojiSearchEl = root.querySelector(".emoji-search input");
940
1131
  root.querySelector(".launcher").addEventListener("click", () => this.toggle());
@@ -1015,11 +1206,12 @@ var OyehelloChatElement = class extends HTMLElement {
1015
1206
  this.uiOpen = true;
1016
1207
  this.teaserActive = false;
1017
1208
  this.unseen = 0;
1209
+ this.teardownIdle();
1018
1210
  try {
1019
1211
  sessionStorage.setItem(this.key("open"), "1");
1020
1212
  } catch {
1021
1213
  }
1022
- if (!this.started && this.client) {
1214
+ if (!this.started && this.client && this.configLoaded && !this.offline) {
1023
1215
  this.started = true;
1024
1216
  void this.client.start();
1025
1217
  }
@@ -1057,6 +1249,7 @@ var OyehelloChatElement = class extends HTMLElement {
1057
1249
  this.updateHeader();
1058
1250
  } else if (e.type === "handoff") this.handoffState = e.state;
1059
1251
  else if (e.type === "agentTyping") this.agentTyping = e.active;
1252
+ else if (e.type === "suggestions") this.serverSuggestions = e.items;
1060
1253
  else if (e.type === "error") this.lastError = e.error.message;
1061
1254
  this.updateStatus();
1062
1255
  this.renderMessages();
@@ -1066,26 +1259,22 @@ var OyehelloChatElement = class extends HTMLElement {
1066
1259
  }
1067
1260
  updateStatus() {
1068
1261
  if (!this.textareaEl) return;
1069
- const disabled = this.state !== "ready";
1262
+ const disabled = this.state !== "ready" || this.offline;
1070
1263
  this.textareaEl.disabled = disabled;
1071
1264
  const handoff = HANDOFF_STATUS[this.handoffState];
1072
- 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);
1073
1266
  if (this.statusEl) this.statusEl.textContent = text;
1074
- if (this.state === "error" && this.lastError) {
1075
- this.errEl.hidden = false;
1076
- this.errEl.innerHTML = `<span>Couldn't start chat: ${escapeHtml(this.lastError)}</span><button class="retry" type="button">Retry</button>`;
1077
- this.errEl.querySelector(".retry").addEventListener("click", () => {
1078
- void this.client?.start();
1079
- });
1080
- } else {
1081
- this.errEl.hidden = true;
1082
- this.errEl.innerHTML = "";
1083
- }
1084
1267
  }
1085
1268
  // ── Message list + scroll ─────────────────────────────────────────────────────────────────────
1086
1269
  renderMessages() {
1087
1270
  if (!this.bodyEl) return;
1088
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
+ }
1089
1278
  const prevTop = body.scrollTop;
1090
1279
  const added = this.messages.length - this.renderedCount;
1091
1280
  const botFinal = this.messages.filter((m) => m.role === "assistant" && !m.streaming).length;
@@ -1098,17 +1287,36 @@ var OyehelloChatElement = class extends HTMLElement {
1098
1287
  })();
1099
1288
  let html = this.messages.map((m, i) => this.turnHtml(m, i === lastBot && !this.agentTyping)).join("");
1100
1289
  if (this.agentTyping) html += '<span class="typing"><span></span><span></span><span></span></span>';
1101
- const sugg = this.suggestions;
1290
+ const sugg = this.chipLabels;
1102
1291
  if (sugg.length > 0 && !this.agentTyping && this.state === "ready") {
1103
1292
  html += `<span class="chips">${sugg.map((s, i) => `<button type="button" class="chip" data-chip="${i}">${escapeHtml(s)}</button>`).join("")}</span>`;
1104
1293
  }
1294
+ if (this.state === "error" && this.lastError) {
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>`;
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
+ }
1105
1303
  body.innerHTML = html;
1106
1304
  this.hydrateAttachmentImages();
1107
1305
  this.bindChips();
1108
1306
  this.renderedCount = this.messages.length;
1109
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
+ }
1110
1315
  if (!this.uiOpen) {
1111
- if (added > 0) this.unseen += added;
1316
+ if (added > 0) {
1317
+ this.unseen += added;
1318
+ this.maybeChime();
1319
+ }
1112
1320
  this.updateClasses();
1113
1321
  return;
1114
1322
  }
@@ -1116,7 +1324,10 @@ var OyehelloChatElement = class extends HTMLElement {
1116
1324
  else if (!this.away) this.scrollToBottom();
1117
1325
  else {
1118
1326
  body.scrollTop = prevTop;
1119
- if (added > 0) this.unseen += added;
1327
+ if (added > 0) {
1328
+ this.unseen += added;
1329
+ this.maybeChime();
1330
+ }
1120
1331
  }
1121
1332
  this.updateClasses();
1122
1333
  }
@@ -1167,6 +1378,7 @@ var OyehelloChatElement = class extends HTMLElement {
1167
1378
  }
1168
1379
  this.away = false;
1169
1380
  this.emojiOpen = false;
1381
+ this.serverSuggestions = [];
1170
1382
  void this.client?.sendMessage(text, files.length > 0 ? files : void 0);
1171
1383
  this.textareaEl.value = "";
1172
1384
  this.textareaEl.style.height = "auto";
@@ -1175,16 +1387,70 @@ var OyehelloChatElement = class extends HTMLElement {
1175
1387
  sendChip(text) {
1176
1388
  this.away = false;
1177
1389
  this.emojiOpen = false;
1390
+ this.serverSuggestions = [];
1178
1391
  void this.client?.sendMessage(text);
1179
1392
  this.updateClasses();
1180
1393
  }
1181
1394
  bindChips() {
1182
- const sugg = this.suggestions;
1395
+ const sugg = this.chipLabels;
1183
1396
  this.bodyEl.querySelectorAll(".chip[data-chip]").forEach((btn) => {
1184
1397
  const i = Number(btn.getAttribute("data-chip"));
1185
1398
  const text = sugg[i];
1186
1399
  if (text) btn.addEventListener("click", () => this.sendChip(text));
1187
1400
  });
1401
+ this.bodyEl.querySelector(".oc-err-retry")?.addEventListener("click", () => {
1402
+ void this.client?.start();
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
+ }
1188
1454
  }
1189
1455
  // ── Emoji ─────────────────────────────────────────────────────────────────────────────────────
1190
1456
  toggleEmoji() {