@oyehello/chat 0.3.0 → 0.4.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
@@ -6,6 +6,8 @@ The widget connects lazily (a session opens the first time the visitor opens the
6
6
 
7
7
  ### Attributes
8
8
 
9
+ The **accent/logo/name** (workspace brand), the **teaser** (a chat-agent setting), and the **quick-reply chips** (generated from the agent's flow after each reply) are served automatically by the API — the attributes below just override them.
10
+
9
11
  | Attribute | Required | What it is |
10
12
  | --- | --- | --- |
11
13
  | `api-key` | yes | Publishable key (`ohk_pub_…`) |
package/dist/index.cjs CHANGED
@@ -43,6 +43,15 @@ async function openSession(opts) {
43
43
  if (!res.ok) throw await asError(res);
44
44
  return await res.json();
45
45
  }
46
+ async function getWidgetConfig(opts) {
47
+ const res = await opts.fetchImpl(`${opts.baseUrl}/chat/widget-config?agentId=${encodeURIComponent(opts.agentId)}`, {
48
+ method: "GET",
49
+ credentials: "omit",
50
+ headers: { Authorization: `Bearer ${opts.apiKey}` }
51
+ });
52
+ if (!res.ok) throw await asError(res);
53
+ return await res.json();
54
+ }
46
55
  async function postMessage(opts) {
47
56
  const body = { text: opts.text };
48
57
  if (opts.attachments && opts.attachments.length > 0) body.attachments = opts.attachments;
@@ -220,6 +229,8 @@ function createChatClient(options) {
220
229
  }
221
230
  } else if (ev.type === "agent_typing") {
222
231
  emit({ type: "agentTyping", active: !!ev.active });
232
+ } else if (ev.type === "suggestions") {
233
+ emit({ type: "suggestions", items: Array.isArray(ev.items) ? ev.items : [] });
223
234
  }
224
235
  };
225
236
  const openStream = async () => {
@@ -287,6 +298,9 @@ function createChatClient(options) {
287
298
  if (!session) throw new Error("call start() before getAttachmentUrl");
288
299
  return getAttachmentUrl({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, storageRef, fetchImpl });
289
300
  },
301
+ getWidgetConfig() {
302
+ return getWidgetConfig({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, fetchImpl });
303
+ },
290
304
  async start() {
291
305
  if (state === "opening" || state === "ready") return;
292
306
  closed = false;
@@ -533,8 +547,11 @@ var STYLES = `
533
547
  .i-expand { opacity: 1; } .i-shrink { opacity: 0; }
534
548
  .oc.max .i-expand { opacity: 0; } .oc.max .i-shrink { opacity: 1; }
535
549
 
536
- .err { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 16px; font-size: 12px; color: #fff; background: var(--oc-danger); }
537
- .err .retry { padding: 3px 10px; font-size: 11px; background: rgba(255,255,255,0.2); color: #fff; border: none; border-radius: 6px; cursor: pointer; }
550
+ /* Inline connection error, shown inside the chat window (no banner) */
551
+ .oc-err { margin: auto; display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 24px 16px; text-align: center; }
552
+ .oc-err-t { font-size: 13px; line-height: 1.5; color: var(--oc-fg-muted); text-wrap: pretty; }
553
+ .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
+ .oc-err-retry:hover { background: var(--oc-tint-3); }
538
555
 
539
556
  /* \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 */
540
557
  .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; }
@@ -802,6 +819,13 @@ var OyehelloChatElement = class extends HTMLElement {
802
819
  renderedCount = 0;
803
820
  renderedBotFinal = 0;
804
821
  timers = [];
822
+ // Server (flow) suggestions for the latest turn; null until the first `suggestions` event, then the
823
+ // widget prefers these over the static `suggestions` attribute.
824
+ serverSuggestions = null;
825
+ teaserText = "Need a hand? Ask me anything.";
826
+ teaserShownOnce = false;
827
+ idleTimer = null;
828
+ idleReset = null;
805
829
  // ── shadow refs ──
806
830
  oc;
807
831
  bodyEl;
@@ -809,7 +833,6 @@ var OyehelloChatElement = class extends HTMLElement {
809
833
  fileEl;
810
834
  titleEl;
811
835
  statusEl;
812
- errEl;
813
836
  emojiListEl;
814
837
  emojiSearchEl;
815
838
  connectedCallback() {
@@ -821,6 +844,7 @@ var OyehelloChatElement = class extends HTMLElement {
821
844
  this.renderError("oyehello-chat: api-key, agent-id and base-url are required");
822
845
  return;
823
846
  }
847
+ this.teaserText = this.getAttribute("teaser") || this.teaserText;
824
848
  this.client = createChatClient({ apiKey, agentId, baseUrl });
825
849
  if (Object.keys(this.pendingTools).length > 0) {
826
850
  this.client.registerTools(this.pendingTools);
@@ -832,6 +856,7 @@ var OyehelloChatElement = class extends HTMLElement {
832
856
  this.updateHeader();
833
857
  this.updateStatus();
834
858
  this.updateClasses();
859
+ void this.loadWidgetConfig();
835
860
  let wasOpen = false;
836
861
  try {
837
862
  wasOpen = sessionStorage.getItem(this.key("open")) === "1";
@@ -845,24 +870,81 @@ var OyehelloChatElement = class extends HTMLElement {
845
870
  this.timers.push(setTimeout(() => {
846
871
  this.oc?.classList.remove("ring");
847
872
  }, 6e3));
848
- const teaserText = this.getAttribute("teaser");
873
+ this.armIdleTeaser();
874
+ }
875
+ // Show the teaser once the visitor has been idle (no page interaction) for 10s — unless the panel
876
+ // is already open or the teaser was dismissed this session. Fires at most once.
877
+ armIdleTeaser() {
849
878
  let dismissed = false;
850
879
  try {
851
880
  dismissed = sessionStorage.getItem(this.key("teaser")) === "0";
852
881
  } catch {
853
882
  }
854
- if (teaserText && !dismissed) {
855
- this.timers.push(setTimeout(() => {
856
- if (!this.uiOpen) {
883
+ if (dismissed) return;
884
+ const reset = () => {
885
+ if (this.idleTimer) clearTimeout(this.idleTimer);
886
+ this.idleTimer = setTimeout(() => {
887
+ if (!this.uiOpen && !this.teaserShownOnce) {
857
888
  this.teaserActive = true;
889
+ this.teaserShownOnce = true;
858
890
  this.updateClasses();
891
+ this.teardownIdle();
892
+ }
893
+ }, 1e4);
894
+ };
895
+ this.idleReset = reset;
896
+ for (const ev of ["mousemove", "keydown", "scroll", "touchstart", "click"]) document.addEventListener(ev, reset, { passive: true });
897
+ reset();
898
+ }
899
+ teardownIdle() {
900
+ if (this.idleTimer) {
901
+ clearTimeout(this.idleTimer);
902
+ this.idleTimer = null;
903
+ }
904
+ if (this.idleReset) {
905
+ for (const ev of ["mousemove", "keydown", "scroll", "touchstart", "click"]) document.removeEventListener(ev, this.idleReset);
906
+ this.idleReset = null;
907
+ }
908
+ }
909
+ async loadWidgetConfig() {
910
+ try {
911
+ const cfg = await this.client.getWidgetConfig();
912
+ if (!this.getAttribute("teaser") && cfg.teaser) {
913
+ this.teaserText = cfg.teaser;
914
+ this.applyTeaserText();
915
+ }
916
+ if (cfg.brand) {
917
+ if (cfg.brand.accentColor) this.style.setProperty("--oc-accent", cfg.brand.accentColor);
918
+ if (cfg.brand.name && !this.getAttribute("title")) {
919
+ this.agentName = cfg.brand.name;
920
+ this.updateHeader();
859
921
  }
860
- }, 1400));
922
+ if (cfg.brand.logoUrl) this.applyBrandLogo(cfg.brand.logoUrl);
923
+ }
924
+ } catch {
861
925
  }
862
926
  }
927
+ applyTeaserText() {
928
+ const el = this.shadowRoot?.querySelector(".teaser-btn span:last-child");
929
+ if (el) el.textContent = this.teaserText;
930
+ }
931
+ applyBrandLogo(url) {
932
+ const set = (host) => {
933
+ if (!host) return;
934
+ host.textContent = "";
935
+ const img = document.createElement("img");
936
+ img.src = url;
937
+ img.alt = "";
938
+ img.style.cssText = "width:100%;height:100%;object-fit:contain";
939
+ host.appendChild(img);
940
+ };
941
+ set(this.shadowRoot?.querySelector(".hd .logo"));
942
+ set(this.shadowRoot?.querySelector(".teaser-btn .logo"));
943
+ }
863
944
  disconnectedCallback() {
864
945
  this.off?.();
865
946
  void this.client?.end();
947
+ this.teardownIdle();
866
948
  for (const t of this.timers) clearTimeout(t);
867
949
  for (const url of this.imagePreviewCache.values()) {
868
950
  if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
@@ -876,18 +958,22 @@ var OyehelloChatElement = class extends HTMLElement {
876
958
  key(k) {
877
959
  return `oyehello-chat:${k}:${this.getAttribute("agent-id") ?? ""}`;
878
960
  }
879
- get suggestions() {
961
+ // Static fallback chips from the `suggestions` attribute (for embedders without flow-driven ones).
962
+ get attrSuggestions() {
880
963
  return (this.getAttribute("suggestions") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
881
964
  }
965
+ // Effective chips: server (flow) suggestions once received, else the static attribute fallback.
966
+ get chipLabels() {
967
+ return this.serverSuggestions ?? this.attrSuggestions;
968
+ }
882
969
  // ── Shell (built once) ─────────────────────────────────────────────────────────────────────────
883
970
  renderShell() {
884
971
  const root = this.shadowRoot;
885
- const hasTeaser = !!this.getAttribute("teaser");
886
972
  const hasPrivacy = !!this.getAttribute("privacy-url");
887
- const teaserText = this.getAttribute("teaser") ?? "";
973
+ const teaserText = this.teaserText;
888
974
  root.innerHTML = `
889
975
  <style>${STYLES}</style>
890
- <div class="oc${hasTeaser ? " has-teaser" : ""}${hasPrivacy ? " has-privacy" : ""}">
976
+ <div class="oc has-teaser${hasPrivacy ? " has-privacy" : ""}">
891
977
  <div class="panel" role="dialog" aria-label="Chat">
892
978
  <div class="hd">
893
979
  <span class="hd-grid"></span>
@@ -896,7 +982,6 @@ var OyehelloChatElement = class extends HTMLElement {
896
982
  <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>
897
983
  <button type="button" class="hd-btn min" aria-label="Minimise">${ICON.minus}</button>
898
984
  </div>
899
- <div class="err" hidden></div>
900
985
  <div class="body"></div>
901
986
  <span class="emoji-scrim"></span>
902
987
  <div class="emoji-panel">
@@ -940,7 +1025,6 @@ var OyehelloChatElement = class extends HTMLElement {
940
1025
  this.fileEl = root.querySelector('input[type="file"]');
941
1026
  this.titleEl = root.querySelector(".hd-title");
942
1027
  this.statusEl = root.querySelector(".hd-status .t");
943
- this.errEl = root.querySelector(".err");
944
1028
  this.emojiListEl = root.querySelector(".emoji-list");
945
1029
  this.emojiSearchEl = root.querySelector(".emoji-search input");
946
1030
  root.querySelector(".launcher").addEventListener("click", () => this.toggle());
@@ -1021,6 +1105,7 @@ var OyehelloChatElement = class extends HTMLElement {
1021
1105
  this.uiOpen = true;
1022
1106
  this.teaserActive = false;
1023
1107
  this.unseen = 0;
1108
+ this.teardownIdle();
1024
1109
  try {
1025
1110
  sessionStorage.setItem(this.key("open"), "1");
1026
1111
  } catch {
@@ -1063,6 +1148,7 @@ var OyehelloChatElement = class extends HTMLElement {
1063
1148
  this.updateHeader();
1064
1149
  } else if (e.type === "handoff") this.handoffState = e.state;
1065
1150
  else if (e.type === "agentTyping") this.agentTyping = e.active;
1151
+ else if (e.type === "suggestions") this.serverSuggestions = e.items;
1066
1152
  else if (e.type === "error") this.lastError = e.error.message;
1067
1153
  this.updateStatus();
1068
1154
  this.renderMessages();
@@ -1077,16 +1163,6 @@ var OyehelloChatElement = class extends HTMLElement {
1077
1163
  const handoff = HANDOFF_STATUS[this.handoffState];
1078
1164
  const text = this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
1079
1165
  if (this.statusEl) this.statusEl.textContent = text;
1080
- if (this.state === "error" && this.lastError) {
1081
- this.errEl.hidden = false;
1082
- this.errEl.innerHTML = `<span>Couldn't start chat: ${escapeHtml(this.lastError)}</span><button class="retry" type="button">Retry</button>`;
1083
- this.errEl.querySelector(".retry").addEventListener("click", () => {
1084
- void this.client?.start();
1085
- });
1086
- } else {
1087
- this.errEl.hidden = true;
1088
- this.errEl.innerHTML = "";
1089
- }
1090
1166
  }
1091
1167
  // ── Message list + scroll ─────────────────────────────────────────────────────────────────────
1092
1168
  renderMessages() {
@@ -1104,10 +1180,13 @@ var OyehelloChatElement = class extends HTMLElement {
1104
1180
  })();
1105
1181
  let html = this.messages.map((m, i) => this.turnHtml(m, i === lastBot && !this.agentTyping)).join("");
1106
1182
  if (this.agentTyping) html += '<span class="typing"><span></span><span></span><span></span></span>';
1107
- const sugg = this.suggestions;
1183
+ const sugg = this.chipLabels;
1108
1184
  if (sugg.length > 0 && !this.agentTyping && this.state === "ready") {
1109
1185
  html += `<span class="chips">${sugg.map((s, i) => `<button type="button" class="chip" data-chip="${i}">${escapeHtml(s)}</button>`).join("")}</span>`;
1110
1186
  }
1187
+ if (this.state === "error" && this.lastError) {
1188
+ 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
+ }
1111
1190
  body.innerHTML = html;
1112
1191
  this.hydrateAttachmentImages();
1113
1192
  this.bindChips();
@@ -1185,12 +1264,15 @@ var OyehelloChatElement = class extends HTMLElement {
1185
1264
  this.updateClasses();
1186
1265
  }
1187
1266
  bindChips() {
1188
- const sugg = this.suggestions;
1267
+ const sugg = this.chipLabels;
1189
1268
  this.bodyEl.querySelectorAll(".chip[data-chip]").forEach((btn) => {
1190
1269
  const i = Number(btn.getAttribute("data-chip"));
1191
1270
  const text = sugg[i];
1192
1271
  if (text) btn.addEventListener("click", () => this.sendChip(text));
1193
1272
  });
1273
+ this.bodyEl.querySelector(".oc-err-retry")?.addEventListener("click", () => {
1274
+ void this.client?.start();
1275
+ });
1194
1276
  }
1195
1277
  // ── Emoji ─────────────────────────────────────────────────────────────────────────────────────
1196
1278
  toggleEmoji() {