@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.d.cts CHANGED
@@ -64,6 +64,8 @@ interface WidgetConfig {
64
64
  logoUrl: string | null;
65
65
  accentColor: string | null;
66
66
  } | null;
67
+ offline?: boolean;
68
+ offlineMessage?: string;
67
69
  }
68
70
  interface OpenSessionResponse {
69
71
  sessionId: string;
@@ -73,6 +75,7 @@ interface OpenSessionResponse {
73
75
  id: string;
74
76
  name: string;
75
77
  };
78
+ protocol?: number;
76
79
  }
77
80
  interface ChatClientOptions {
78
81
  apiKey: string;
@@ -137,6 +140,7 @@ interface ChatClient {
137
140
  start(): Promise<void>;
138
141
  sendMessage(text: string, files?: File[]): Promise<void>;
139
142
  end(): Promise<void>;
143
+ reset(): void;
140
144
  getMessages(): ChatMessage[];
141
145
  getState(): ChatClientState;
142
146
  on(listener: (e: ChatClientEvent) => void): () => void;
@@ -175,8 +179,14 @@ declare class OyehelloChatElement extends HTMLElement {
175
179
  private renderedBotFinal;
176
180
  private timers;
177
181
  private serverSuggestions;
182
+ private streamId;
183
+ private streamEl;
184
+ private audioCtx;
178
185
  private teaserText;
179
186
  private teaserShownOnce;
187
+ private offline;
188
+ private offlineMessage;
189
+ private configLoaded;
180
190
  private idleTimer;
181
191
  private idleReset;
182
192
  private oc;
@@ -216,6 +226,9 @@ declare class OyehelloChatElement extends HTMLElement {
216
226
  private doSend;
217
227
  private sendChip;
218
228
  private bindChips;
229
+ private startFresh;
230
+ private maybeChime;
231
+ private playChime;
219
232
  private toggleEmoji;
220
233
  private closeEmoji;
221
234
  private renderEmoji;
package/dist/index.d.ts CHANGED
@@ -64,6 +64,8 @@ interface WidgetConfig {
64
64
  logoUrl: string | null;
65
65
  accentColor: string | null;
66
66
  } | null;
67
+ offline?: boolean;
68
+ offlineMessage?: string;
67
69
  }
68
70
  interface OpenSessionResponse {
69
71
  sessionId: string;
@@ -73,6 +75,7 @@ interface OpenSessionResponse {
73
75
  id: string;
74
76
  name: string;
75
77
  };
78
+ protocol?: number;
76
79
  }
77
80
  interface ChatClientOptions {
78
81
  apiKey: string;
@@ -137,6 +140,7 @@ interface ChatClient {
137
140
  start(): Promise<void>;
138
141
  sendMessage(text: string, files?: File[]): Promise<void>;
139
142
  end(): Promise<void>;
143
+ reset(): void;
140
144
  getMessages(): ChatMessage[];
141
145
  getState(): ChatClientState;
142
146
  on(listener: (e: ChatClientEvent) => void): () => void;
@@ -175,8 +179,14 @@ declare class OyehelloChatElement extends HTMLElement {
175
179
  private renderedBotFinal;
176
180
  private timers;
177
181
  private serverSuggestions;
182
+ private streamId;
183
+ private streamEl;
184
+ private audioCtx;
178
185
  private teaserText;
179
186
  private teaserShownOnce;
187
+ private offline;
188
+ private offlineMessage;
189
+ private configLoaded;
180
190
  private idleTimer;
181
191
  private idleReset;
182
192
  private oc;
@@ -216,6 +226,9 @@ declare class OyehelloChatElement extends HTMLElement {
216
226
  private doSend;
217
227
  private sendChip;
218
228
  private bindChips;
229
+ private startFresh;
230
+ private maybeChime;
231
+ private playChime;
219
232
  private toggleEmoji;
220
233
  private closeEmoji;
221
234
  private renderEmoji;
@@ -15,6 +15,9 @@ var OyehelloChat = (function (exports) {
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 @@ var OyehelloChat = (function (exports) {
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 @@ var OyehelloChat = (function (exports) {
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((b2) => b2.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((v2) => String(v2 ?? "")).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 @@ var OyehelloChat = (function (exports) {
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 @@ var OyehelloChat = (function (exports) {
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 @@ var OyehelloChat = (function (exports) {
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 @@ var OyehelloChat = (function (exports) {
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
  }
@@ -3427,6 +3505,12 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3427
3505
  .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); }
3428
3506
  .oc-err-retry:hover { background: var(--oc-tint-3); }
3429
3507
 
3508
+ /* Session-ended state, inside the chat window */
3509
+ .oc-ended { margin: auto; display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 24px 16px; text-align: center; }
3510
+ .oc-ended-t { font-size: 13.5px; line-height: 1.5; color: var(--oc-fg-muted); text-wrap: pretty; }
3511
+ .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); }
3512
+ .oc-newmsg:hover { filter: brightness(1.08); }
3513
+
3430
3514
  /* \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 */
3431
3515
  .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; }
3432
3516
  .turn { display: flex; flex-direction: column; gap: 6px; animation: w-in .42s var(--oc-ease) both; }
@@ -3696,8 +3780,19 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3696
3780
  // Server (flow) suggestions for the latest turn; null until the first `suggestions` event, then the
3697
3781
  // widget prefers these over the static `suggestions` attribute.
3698
3782
  serverSuggestions = null;
3783
+ // The live streaming assistant bubble's markdown element + message id — so growing tokens patch just
3784
+ // that element in place instead of rebuilding the whole message list on every token.
3785
+ streamId = null;
3786
+ streamEl = null;
3787
+ audioCtx = null;
3699
3788
  teaserText = "Need a hand? Ask me anything.";
3700
3789
  teaserShownOnce = false;
3790
+ // Staffed-hours: set from widget-config. When offline, the panel shows offlineMessage and the
3791
+ // composer is disabled (opening a session is refused server-side anyway).
3792
+ offline = false;
3793
+ offlineMessage = "We're offline right now. Please check back during business hours.";
3794
+ configLoaded = false;
3795
+ // widget-config resolved (so we know online/offline before starting)
3701
3796
  idleTimer = null;
3702
3797
  idleReset = null;
3703
3798
  // ── shadow refs ──
@@ -3795,7 +3890,19 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3795
3890
  }
3796
3891
  if (cfg.brand.logoUrl) this.applyBrandLogo(cfg.brand.logoUrl);
3797
3892
  }
3893
+ if (cfg.offline) {
3894
+ this.offline = true;
3895
+ if (cfg.offlineMessage) this.offlineMessage = cfg.offlineMessage;
3896
+ this.renderMessages();
3897
+ this.updateStatus();
3898
+ }
3798
3899
  } catch {
3900
+ } finally {
3901
+ this.configLoaded = true;
3902
+ if (this.uiOpen && !this.started && !this.offline && this.client) {
3903
+ this.started = true;
3904
+ void this.client.start();
3905
+ }
3799
3906
  }
3800
3907
  }
3801
3908
  applyTeaserText() {
@@ -3984,7 +4091,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
3984
4091
  sessionStorage.setItem(this.key("open"), "1");
3985
4092
  } catch {
3986
4093
  }
3987
- if (!this.started && this.client) {
4094
+ if (!this.started && this.client && this.configLoaded && !this.offline) {
3988
4095
  this.started = true;
3989
4096
  void this.client.start();
3990
4097
  }
@@ -4032,16 +4139,22 @@ Please report this to https://github.com/markedjs/marked.`, e) {
4032
4139
  }
4033
4140
  updateStatus() {
4034
4141
  if (!this.textareaEl) return;
4035
- const disabled = this.state !== "ready";
4142
+ const disabled = this.state !== "ready" || this.offline;
4036
4143
  this.textareaEl.disabled = disabled;
4037
4144
  const handoff = HANDOFF_STATUS[this.handoffState];
4038
- const text2 = this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
4145
+ const text2 = this.offline ? "Offline" : this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
4039
4146
  if (this.statusEl) this.statusEl.textContent = text2;
4040
4147
  }
4041
4148
  // ── Message list + scroll ─────────────────────────────────────────────────────────────────────
4042
4149
  renderMessages() {
4043
4150
  if (!this.bodyEl) return;
4044
4151
  const body = this.bodyEl;
4152
+ const last = this.messages[this.messages.length - 1];
4153
+ if (this.uiOpen && this.streamEl && last && last.id === this.streamId && last.role === "assistant" && last.streaming && this.messages.length === this.renderedCount) {
4154
+ this.streamEl.innerHTML = renderMarkdown(last.text || "");
4155
+ if (!this.away) this.scrollToBottom();
4156
+ return;
4157
+ }
4045
4158
  const prevTop = body.scrollTop;
4046
4159
  const added = this.messages.length - this.renderedCount;
4047
4160
  const botFinal = this.messages.filter((m2) => m2.role === "assistant" && !m2.streaming).length;
@@ -4061,13 +4174,29 @@ Please report this to https://github.com/markedjs/marked.`, e) {
4061
4174
  if (this.state === "error" && this.lastError) {
4062
4175
  html2 += `<div class="oc-err"><span class="oc-err-t">${escapeHtml(this.lastError)}</span><button type="button" class="oc-err-retry">Try again</button></div>`;
4063
4176
  }
4177
+ if (this.state === "ended") {
4178
+ html2 += `<div class="oc-ended"><span class="oc-ended-t">Your conversation has ended.</span><button type="button" class="oc-newmsg">New message</button></div>`;
4179
+ }
4180
+ if (this.offline && this.messages.length === 0 && this.state !== "ended") {
4181
+ html2 += `<div class="oc-ended"><span class="oc-ended-t">${escapeHtml(this.offlineMessage)}</span></div>`;
4182
+ }
4064
4183
  body.innerHTML = html2;
4065
4184
  this.hydrateAttachmentImages();
4066
4185
  this.bindChips();
4067
4186
  this.renderedCount = this.messages.length;
4068
4187
  this.renderedBotFinal = botFinal;
4188
+ this.streamId = null;
4189
+ this.streamEl = null;
4190
+ if (last && last.role === "assistant" && last.streaming) {
4191
+ const mds = this.bodyEl.querySelectorAll(".turn.bot .md");
4192
+ this.streamEl = mds[mds.length - 1] ?? null;
4193
+ if (this.streamEl) this.streamId = last.id;
4194
+ }
4069
4195
  if (!this.uiOpen) {
4070
- if (added > 0) this.unseen += added;
4196
+ if (added > 0) {
4197
+ this.unseen += added;
4198
+ this.maybeChime();
4199
+ }
4071
4200
  this.updateClasses();
4072
4201
  return;
4073
4202
  }
@@ -4075,7 +4204,10 @@ Please report this to https://github.com/markedjs/marked.`, e) {
4075
4204
  else if (!this.away) this.scrollToBottom();
4076
4205
  else {
4077
4206
  body.scrollTop = prevTop;
4078
- if (added > 0) this.unseen += added;
4207
+ if (added > 0) {
4208
+ this.unseen += added;
4209
+ this.maybeChime();
4210
+ }
4079
4211
  }
4080
4212
  this.updateClasses();
4081
4213
  }
@@ -4126,6 +4258,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
4126
4258
  }
4127
4259
  this.away = false;
4128
4260
  this.emojiOpen = false;
4261
+ this.serverSuggestions = [];
4129
4262
  void this.client?.sendMessage(text2, files.length > 0 ? files : void 0);
4130
4263
  this.textareaEl.value = "";
4131
4264
  this.textareaEl.style.height = "auto";
@@ -4134,6 +4267,7 @@ Please report this to https://github.com/markedjs/marked.`, e) {
4134
4267
  sendChip(text2) {
4135
4268
  this.away = false;
4136
4269
  this.emojiOpen = false;
4270
+ this.serverSuggestions = [];
4137
4271
  void this.client?.sendMessage(text2);
4138
4272
  this.updateClasses();
4139
4273
  }
@@ -4147,6 +4281,56 @@ Please report this to https://github.com/markedjs/marked.`, e) {
4147
4281
  this.bodyEl.querySelector(".oc-err-retry")?.addEventListener("click", () => {
4148
4282
  void this.client?.start();
4149
4283
  });
4284
+ this.bodyEl.querySelector(".oc-newmsg")?.addEventListener("click", () => this.startFresh());
4285
+ }
4286
+ // Reset the thread and open a brand-new session (the widget's "New message" after a session ended).
4287
+ startFresh() {
4288
+ this.serverSuggestions = null;
4289
+ this.receivedAt.clear();
4290
+ this.mdCache.clear();
4291
+ this.streamId = null;
4292
+ this.streamEl = null;
4293
+ this.renderedCount = 0;
4294
+ this.renderedBotFinal = 0;
4295
+ this.away = false;
4296
+ this.unseen = 0;
4297
+ this.client?.reset();
4298
+ this.started = true;
4299
+ void this.client?.start();
4300
+ setTimeout(() => this.textareaEl?.focus(), 60);
4301
+ }
4302
+ // A short two-tone chime for an incoming message the visitor hasn't seen (panel closed or scrolled
4303
+ // up). Off when the `sound="off"` attribute is set. Uses WebAudio (no asset); silent if unavailable
4304
+ // or the browser hasn't allowed audio yet.
4305
+ maybeChime() {
4306
+ const last = this.messages[this.messages.length - 1];
4307
+ if (last && (last.role === "assistant" || last.role === "human")) this.playChime();
4308
+ }
4309
+ playChime() {
4310
+ if (this.getAttribute("sound") === "off") return;
4311
+ try {
4312
+ const Ctx = window.AudioContext ?? window.webkitAudioContext;
4313
+ if (!Ctx) return;
4314
+ if (!this.audioCtx) this.audioCtx = new Ctx();
4315
+ const ctx = this.audioCtx;
4316
+ if (ctx.state === "suspended") void ctx.resume();
4317
+ const now = ctx.currentTime;
4318
+ [880, 1174.66].forEach((freq, i) => {
4319
+ const osc = ctx.createOscillator();
4320
+ const gain = ctx.createGain();
4321
+ osc.type = "sine";
4322
+ osc.frequency.value = freq;
4323
+ const t = now + i * 0.11;
4324
+ gain.gain.setValueAtTime(1e-4, t);
4325
+ gain.gain.exponentialRampToValueAtTime(0.12, t + 0.02);
4326
+ gain.gain.exponentialRampToValueAtTime(1e-4, t + 0.18);
4327
+ osc.connect(gain);
4328
+ gain.connect(ctx.destination);
4329
+ osc.start(t);
4330
+ osc.stop(t + 0.2);
4331
+ });
4332
+ } catch {
4333
+ }
4150
4334
  }
4151
4335
  // ── Emoji ─────────────────────────────────────────────────────────────────────────────────────
4152
4336
  toggleEmoji() {