@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/README.md +4 -1
- package/dist/index.cjs +299 -33
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +42 -3
- package/dist/index.d.ts +42 -3
- package/dist/index.global.js +307 -41
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +299 -33
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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_…`) |
|
|
@@ -34,9 +36,10 @@ npm install @oyehello/chat
|
|
|
34
36
|
```
|
|
35
37
|
Or via a plain script tag (no bundler):
|
|
36
38
|
```html
|
|
37
|
-
<script src="https://unpkg.com/@oyehello/chat"></script>
|
|
39
|
+
<script src="https://unpkg.com/@oyehello/chat@0.5.0"></script>
|
|
38
40
|
<oyehello-chat api-key="ohk_pub_…" agent-id="agt_…" base-url="https://api.oyehello.ai"></oyehello-chat>
|
|
39
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.
|
|
40
43
|
Theme the accent with the `--oc-accent` CSS variable.
|
|
41
44
|
|
|
42
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",
|
|
@@ -43,6 +47,15 @@ async function openSession(opts) {
|
|
|
43
47
|
if (!res.ok) throw await asError(res);
|
|
44
48
|
return await res.json();
|
|
45
49
|
}
|
|
50
|
+
async function getWidgetConfig(opts) {
|
|
51
|
+
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/widget-config?agentId=${encodeURIComponent(opts.agentId)}`, {
|
|
52
|
+
method: "GET",
|
|
53
|
+
credentials: "omit",
|
|
54
|
+
headers: { Authorization: `Bearer ${opts.apiKey}` }
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok) throw await asError(res);
|
|
57
|
+
return await res.json();
|
|
58
|
+
}
|
|
46
59
|
async function postMessage(opts) {
|
|
47
60
|
const body = { text: opts.text };
|
|
48
61
|
if (opts.attachments && opts.attachments.length > 0) body.attachments = opts.attachments;
|
|
@@ -108,6 +121,50 @@ async function uploadToPresigned(uploadUrl, file, fetchImpl) {
|
|
|
108
121
|
if (!res.ok) throw new ChatApiError(`attachment upload failed with HTTP ${res.status}`, res.status);
|
|
109
122
|
}
|
|
110
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
|
+
|
|
111
168
|
// src/client.ts
|
|
112
169
|
var seq = 0;
|
|
113
170
|
var uid = (p) => `${p}_${Date.now().toString(36)}_${(seq++).toString(36)}`;
|
|
@@ -128,6 +185,8 @@ function createChatClient(options) {
|
|
|
128
185
|
let reconnectTimer = null;
|
|
129
186
|
let reconnectAttempts = 0;
|
|
130
187
|
let closed = false;
|
|
188
|
+
let warnedProtocol = false;
|
|
189
|
+
let deviceFingerprint;
|
|
131
190
|
const toolHandlers = /* @__PURE__ */ new Map();
|
|
132
191
|
const clearReconnect = () => {
|
|
133
192
|
if (reconnectTimer) {
|
|
@@ -220,6 +279,14 @@ function createChatClient(options) {
|
|
|
220
279
|
}
|
|
221
280
|
} else if (ev.type === "agent_typing") {
|
|
222
281
|
emit({ type: "agentTyping", active: !!ev.active });
|
|
282
|
+
} else if (ev.type === "suggestions") {
|
|
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");
|
|
223
290
|
}
|
|
224
291
|
};
|
|
225
292
|
const openStream = async () => {
|
|
@@ -262,7 +329,15 @@ function createChatClient(options) {
|
|
|
262
329
|
}, delay);
|
|
263
330
|
};
|
|
264
331
|
const open = async () => {
|
|
265
|
-
|
|
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
|
+
}
|
|
266
341
|
emit({ type: "agent", agent: session.agent });
|
|
267
342
|
await openStream();
|
|
268
343
|
};
|
|
@@ -287,6 +362,9 @@ function createChatClient(options) {
|
|
|
287
362
|
if (!session) throw new Error("call start() before getAttachmentUrl");
|
|
288
363
|
return getAttachmentUrl({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, storageRef, fetchImpl });
|
|
289
364
|
},
|
|
365
|
+
getWidgetConfig() {
|
|
366
|
+
return getWidgetConfig({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, fetchImpl });
|
|
367
|
+
},
|
|
290
368
|
async start() {
|
|
291
369
|
if (state === "opening" || state === "ready") return;
|
|
292
370
|
closed = false;
|
|
@@ -350,6 +428,20 @@ function createChatClient(options) {
|
|
|
350
428
|
es?.close();
|
|
351
429
|
es = null;
|
|
352
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");
|
|
353
445
|
}
|
|
354
446
|
};
|
|
355
447
|
}
|
|
@@ -533,8 +625,17 @@ var STYLES = `
|
|
|
533
625
|
.i-expand { opacity: 1; } .i-shrink { opacity: 0; }
|
|
534
626
|
.oc.max .i-expand { opacity: 0; } .oc.max .i-shrink { opacity: 1; }
|
|
535
627
|
|
|
536
|
-
|
|
537
|
-
.err
|
|
628
|
+
/* Inline connection error, shown inside the chat window (no banner) */
|
|
629
|
+
.oc-err { margin: auto; display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 24px 16px; text-align: center; }
|
|
630
|
+
.oc-err-t { font-size: 13px; line-height: 1.5; color: var(--oc-fg-muted); text-wrap: pretty; }
|
|
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); }
|
|
632
|
+
.oc-err-retry:hover { background: var(--oc-tint-3); }
|
|
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); }
|
|
538
639
|
|
|
539
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 */
|
|
540
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; }
|
|
@@ -802,6 +903,24 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
802
903
|
renderedCount = 0;
|
|
803
904
|
renderedBotFinal = 0;
|
|
804
905
|
timers = [];
|
|
906
|
+
// Server (flow) suggestions for the latest turn; null until the first `suggestions` event, then the
|
|
907
|
+
// widget prefers these over the static `suggestions` attribute.
|
|
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;
|
|
914
|
+
teaserText = "Need a hand? Ask me anything.";
|
|
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)
|
|
922
|
+
idleTimer = null;
|
|
923
|
+
idleReset = null;
|
|
805
924
|
// ── shadow refs ──
|
|
806
925
|
oc;
|
|
807
926
|
bodyEl;
|
|
@@ -809,7 +928,6 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
809
928
|
fileEl;
|
|
810
929
|
titleEl;
|
|
811
930
|
statusEl;
|
|
812
|
-
errEl;
|
|
813
931
|
emojiListEl;
|
|
814
932
|
emojiSearchEl;
|
|
815
933
|
connectedCallback() {
|
|
@@ -821,6 +939,7 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
821
939
|
this.renderError("oyehello-chat: api-key, agent-id and base-url are required");
|
|
822
940
|
return;
|
|
823
941
|
}
|
|
942
|
+
this.teaserText = this.getAttribute("teaser") || this.teaserText;
|
|
824
943
|
this.client = createChatClient({ apiKey, agentId, baseUrl });
|
|
825
944
|
if (Object.keys(this.pendingTools).length > 0) {
|
|
826
945
|
this.client.registerTools(this.pendingTools);
|
|
@@ -832,6 +951,7 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
832
951
|
this.updateHeader();
|
|
833
952
|
this.updateStatus();
|
|
834
953
|
this.updateClasses();
|
|
954
|
+
void this.loadWidgetConfig();
|
|
835
955
|
let wasOpen = false;
|
|
836
956
|
try {
|
|
837
957
|
wasOpen = sessionStorage.getItem(this.key("open")) === "1";
|
|
@@ -845,24 +965,93 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
845
965
|
this.timers.push(setTimeout(() => {
|
|
846
966
|
this.oc?.classList.remove("ring");
|
|
847
967
|
}, 6e3));
|
|
848
|
-
|
|
968
|
+
this.armIdleTeaser();
|
|
969
|
+
}
|
|
970
|
+
// Show the teaser once the visitor has been idle (no page interaction) for 10s — unless the panel
|
|
971
|
+
// is already open or the teaser was dismissed this session. Fires at most once.
|
|
972
|
+
armIdleTeaser() {
|
|
849
973
|
let dismissed = false;
|
|
850
974
|
try {
|
|
851
975
|
dismissed = sessionStorage.getItem(this.key("teaser")) === "0";
|
|
852
976
|
} catch {
|
|
853
977
|
}
|
|
854
|
-
if (
|
|
855
|
-
|
|
856
|
-
|
|
978
|
+
if (dismissed) return;
|
|
979
|
+
const reset = () => {
|
|
980
|
+
if (this.idleTimer) clearTimeout(this.idleTimer);
|
|
981
|
+
this.idleTimer = setTimeout(() => {
|
|
982
|
+
if (!this.uiOpen && !this.teaserShownOnce) {
|
|
857
983
|
this.teaserActive = true;
|
|
984
|
+
this.teaserShownOnce = true;
|
|
858
985
|
this.updateClasses();
|
|
986
|
+
this.teardownIdle();
|
|
987
|
+
}
|
|
988
|
+
}, 1e4);
|
|
989
|
+
};
|
|
990
|
+
this.idleReset = reset;
|
|
991
|
+
for (const ev of ["mousemove", "keydown", "scroll", "touchstart", "click"]) document.addEventListener(ev, reset, { passive: true });
|
|
992
|
+
reset();
|
|
993
|
+
}
|
|
994
|
+
teardownIdle() {
|
|
995
|
+
if (this.idleTimer) {
|
|
996
|
+
clearTimeout(this.idleTimer);
|
|
997
|
+
this.idleTimer = null;
|
|
998
|
+
}
|
|
999
|
+
if (this.idleReset) {
|
|
1000
|
+
for (const ev of ["mousemove", "keydown", "scroll", "touchstart", "click"]) document.removeEventListener(ev, this.idleReset);
|
|
1001
|
+
this.idleReset = null;
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
async loadWidgetConfig() {
|
|
1005
|
+
try {
|
|
1006
|
+
const cfg = await this.client.getWidgetConfig();
|
|
1007
|
+
if (!this.getAttribute("teaser") && cfg.teaser) {
|
|
1008
|
+
this.teaserText = cfg.teaser;
|
|
1009
|
+
this.applyTeaserText();
|
|
1010
|
+
}
|
|
1011
|
+
if (cfg.brand) {
|
|
1012
|
+
if (cfg.brand.accentColor) this.style.setProperty("--oc-accent", cfg.brand.accentColor);
|
|
1013
|
+
if (cfg.brand.name && !this.getAttribute("title")) {
|
|
1014
|
+
this.agentName = cfg.brand.name;
|
|
1015
|
+
this.updateHeader();
|
|
859
1016
|
}
|
|
860
|
-
|
|
1017
|
+
if (cfg.brand.logoUrl) this.applyBrandLogo(cfg.brand.logoUrl);
|
|
1018
|
+
}
|
|
1019
|
+
if (cfg.offline) {
|
|
1020
|
+
this.offline = true;
|
|
1021
|
+
if (cfg.offlineMessage) this.offlineMessage = cfg.offlineMessage;
|
|
1022
|
+
this.renderMessages();
|
|
1023
|
+
this.updateStatus();
|
|
1024
|
+
}
|
|
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
|
+
}
|
|
861
1032
|
}
|
|
862
1033
|
}
|
|
1034
|
+
applyTeaserText() {
|
|
1035
|
+
const el = this.shadowRoot?.querySelector(".teaser-btn span:last-child");
|
|
1036
|
+
if (el) el.textContent = this.teaserText;
|
|
1037
|
+
}
|
|
1038
|
+
applyBrandLogo(url) {
|
|
1039
|
+
const set = (host) => {
|
|
1040
|
+
if (!host) return;
|
|
1041
|
+
host.textContent = "";
|
|
1042
|
+
const img = document.createElement("img");
|
|
1043
|
+
img.src = url;
|
|
1044
|
+
img.alt = "";
|
|
1045
|
+
img.style.cssText = "width:100%;height:100%;object-fit:contain";
|
|
1046
|
+
host.appendChild(img);
|
|
1047
|
+
};
|
|
1048
|
+
set(this.shadowRoot?.querySelector(".hd .logo"));
|
|
1049
|
+
set(this.shadowRoot?.querySelector(".teaser-btn .logo"));
|
|
1050
|
+
}
|
|
863
1051
|
disconnectedCallback() {
|
|
864
1052
|
this.off?.();
|
|
865
1053
|
void this.client?.end();
|
|
1054
|
+
this.teardownIdle();
|
|
866
1055
|
for (const t of this.timers) clearTimeout(t);
|
|
867
1056
|
for (const url of this.imagePreviewCache.values()) {
|
|
868
1057
|
if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
|
|
@@ -876,18 +1065,22 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
876
1065
|
key(k) {
|
|
877
1066
|
return `oyehello-chat:${k}:${this.getAttribute("agent-id") ?? ""}`;
|
|
878
1067
|
}
|
|
879
|
-
|
|
1068
|
+
// Static fallback chips from the `suggestions` attribute (for embedders without flow-driven ones).
|
|
1069
|
+
get attrSuggestions() {
|
|
880
1070
|
return (this.getAttribute("suggestions") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
881
1071
|
}
|
|
1072
|
+
// Effective chips: server (flow) suggestions once received, else the static attribute fallback.
|
|
1073
|
+
get chipLabels() {
|
|
1074
|
+
return this.serverSuggestions ?? this.attrSuggestions;
|
|
1075
|
+
}
|
|
882
1076
|
// ── Shell (built once) ─────────────────────────────────────────────────────────────────────────
|
|
883
1077
|
renderShell() {
|
|
884
1078
|
const root = this.shadowRoot;
|
|
885
|
-
const hasTeaser = !!this.getAttribute("teaser");
|
|
886
1079
|
const hasPrivacy = !!this.getAttribute("privacy-url");
|
|
887
|
-
const teaserText = this.
|
|
1080
|
+
const teaserText = this.teaserText;
|
|
888
1081
|
root.innerHTML = `
|
|
889
1082
|
<style>${STYLES}</style>
|
|
890
|
-
<div class="oc
|
|
1083
|
+
<div class="oc has-teaser${hasPrivacy ? " has-privacy" : ""}">
|
|
891
1084
|
<div class="panel" role="dialog" aria-label="Chat">
|
|
892
1085
|
<div class="hd">
|
|
893
1086
|
<span class="hd-grid"></span>
|
|
@@ -896,7 +1089,6 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
896
1089
|
<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
1090
|
<button type="button" class="hd-btn min" aria-label="Minimise">${ICON.minus}</button>
|
|
898
1091
|
</div>
|
|
899
|
-
<div class="err" hidden></div>
|
|
900
1092
|
<div class="body"></div>
|
|
901
1093
|
<span class="emoji-scrim"></span>
|
|
902
1094
|
<div class="emoji-panel">
|
|
@@ -940,7 +1132,6 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
940
1132
|
this.fileEl = root.querySelector('input[type="file"]');
|
|
941
1133
|
this.titleEl = root.querySelector(".hd-title");
|
|
942
1134
|
this.statusEl = root.querySelector(".hd-status .t");
|
|
943
|
-
this.errEl = root.querySelector(".err");
|
|
944
1135
|
this.emojiListEl = root.querySelector(".emoji-list");
|
|
945
1136
|
this.emojiSearchEl = root.querySelector(".emoji-search input");
|
|
946
1137
|
root.querySelector(".launcher").addEventListener("click", () => this.toggle());
|
|
@@ -1021,11 +1212,12 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1021
1212
|
this.uiOpen = true;
|
|
1022
1213
|
this.teaserActive = false;
|
|
1023
1214
|
this.unseen = 0;
|
|
1215
|
+
this.teardownIdle();
|
|
1024
1216
|
try {
|
|
1025
1217
|
sessionStorage.setItem(this.key("open"), "1");
|
|
1026
1218
|
} catch {
|
|
1027
1219
|
}
|
|
1028
|
-
if (!this.started && this.client) {
|
|
1220
|
+
if (!this.started && this.client && this.configLoaded && !this.offline) {
|
|
1029
1221
|
this.started = true;
|
|
1030
1222
|
void this.client.start();
|
|
1031
1223
|
}
|
|
@@ -1063,6 +1255,7 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1063
1255
|
this.updateHeader();
|
|
1064
1256
|
} else if (e.type === "handoff") this.handoffState = e.state;
|
|
1065
1257
|
else if (e.type === "agentTyping") this.agentTyping = e.active;
|
|
1258
|
+
else if (e.type === "suggestions") this.serverSuggestions = e.items;
|
|
1066
1259
|
else if (e.type === "error") this.lastError = e.error.message;
|
|
1067
1260
|
this.updateStatus();
|
|
1068
1261
|
this.renderMessages();
|
|
@@ -1072,26 +1265,22 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1072
1265
|
}
|
|
1073
1266
|
updateStatus() {
|
|
1074
1267
|
if (!this.textareaEl) return;
|
|
1075
|
-
const disabled = this.state !== "ready";
|
|
1268
|
+
const disabled = this.state !== "ready" || this.offline;
|
|
1076
1269
|
this.textareaEl.disabled = disabled;
|
|
1077
1270
|
const handoff = HANDOFF_STATUS[this.handoffState];
|
|
1078
|
-
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);
|
|
1079
1272
|
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
1273
|
}
|
|
1091
1274
|
// ── Message list + scroll ─────────────────────────────────────────────────────────────────────
|
|
1092
1275
|
renderMessages() {
|
|
1093
1276
|
if (!this.bodyEl) return;
|
|
1094
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
|
+
}
|
|
1095
1284
|
const prevTop = body.scrollTop;
|
|
1096
1285
|
const added = this.messages.length - this.renderedCount;
|
|
1097
1286
|
const botFinal = this.messages.filter((m) => m.role === "assistant" && !m.streaming).length;
|
|
@@ -1104,17 +1293,36 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1104
1293
|
})();
|
|
1105
1294
|
let html = this.messages.map((m, i) => this.turnHtml(m, i === lastBot && !this.agentTyping)).join("");
|
|
1106
1295
|
if (this.agentTyping) html += '<span class="typing"><span></span><span></span><span></span></span>';
|
|
1107
|
-
const sugg = this.
|
|
1296
|
+
const sugg = this.chipLabels;
|
|
1108
1297
|
if (sugg.length > 0 && !this.agentTyping && this.state === "ready") {
|
|
1109
1298
|
html += `<span class="chips">${sugg.map((s, i) => `<button type="button" class="chip" data-chip="${i}">${escapeHtml(s)}</button>`).join("")}</span>`;
|
|
1110
1299
|
}
|
|
1300
|
+
if (this.state === "error" && this.lastError) {
|
|
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>`;
|
|
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
|
+
}
|
|
1111
1309
|
body.innerHTML = html;
|
|
1112
1310
|
this.hydrateAttachmentImages();
|
|
1113
1311
|
this.bindChips();
|
|
1114
1312
|
this.renderedCount = this.messages.length;
|
|
1115
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
|
+
}
|
|
1116
1321
|
if (!this.uiOpen) {
|
|
1117
|
-
if (added > 0)
|
|
1322
|
+
if (added > 0) {
|
|
1323
|
+
this.unseen += added;
|
|
1324
|
+
this.maybeChime();
|
|
1325
|
+
}
|
|
1118
1326
|
this.updateClasses();
|
|
1119
1327
|
return;
|
|
1120
1328
|
}
|
|
@@ -1122,7 +1330,10 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1122
1330
|
else if (!this.away) this.scrollToBottom();
|
|
1123
1331
|
else {
|
|
1124
1332
|
body.scrollTop = prevTop;
|
|
1125
|
-
if (added > 0)
|
|
1333
|
+
if (added > 0) {
|
|
1334
|
+
this.unseen += added;
|
|
1335
|
+
this.maybeChime();
|
|
1336
|
+
}
|
|
1126
1337
|
}
|
|
1127
1338
|
this.updateClasses();
|
|
1128
1339
|
}
|
|
@@ -1173,6 +1384,7 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1173
1384
|
}
|
|
1174
1385
|
this.away = false;
|
|
1175
1386
|
this.emojiOpen = false;
|
|
1387
|
+
this.serverSuggestions = [];
|
|
1176
1388
|
void this.client?.sendMessage(text, files.length > 0 ? files : void 0);
|
|
1177
1389
|
this.textareaEl.value = "";
|
|
1178
1390
|
this.textareaEl.style.height = "auto";
|
|
@@ -1181,16 +1393,70 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1181
1393
|
sendChip(text) {
|
|
1182
1394
|
this.away = false;
|
|
1183
1395
|
this.emojiOpen = false;
|
|
1396
|
+
this.serverSuggestions = [];
|
|
1184
1397
|
void this.client?.sendMessage(text);
|
|
1185
1398
|
this.updateClasses();
|
|
1186
1399
|
}
|
|
1187
1400
|
bindChips() {
|
|
1188
|
-
const sugg = this.
|
|
1401
|
+
const sugg = this.chipLabels;
|
|
1189
1402
|
this.bodyEl.querySelectorAll(".chip[data-chip]").forEach((btn) => {
|
|
1190
1403
|
const i = Number(btn.getAttribute("data-chip"));
|
|
1191
1404
|
const text = sugg[i];
|
|
1192
1405
|
if (text) btn.addEventListener("click", () => this.sendChip(text));
|
|
1193
1406
|
});
|
|
1407
|
+
this.bodyEl.querySelector(".oc-err-retry")?.addEventListener("click", () => {
|
|
1408
|
+
void this.client?.start();
|
|
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
|
+
}
|
|
1194
1460
|
}
|
|
1195
1461
|
// ── Emoji ─────────────────────────────────────────────────────────────────────────────────────
|
|
1196
1462
|
toggleEmoji() {
|