@oyehello/chat 0.1.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/LICENSE ADDED
@@ -0,0 +1,59 @@
1
+ oyehello Chat SDK — Software License Agreement
2
+
3
+ Copyright (c) 2026 Hotreloads Digital Private Limited. All rights reserved.
4
+
5
+ This is a legal agreement between you ("You") and Hotreloads Digital Private
6
+ Limited ("Hotreloads") governing your use of the @oyehello/chat software package,
7
+ including its source and distributed files (the "Software"). The "Service" means
8
+ the oyehello platform operated by Hotreloads. By installing, embedding, or
9
+ otherwise using the Software, You agree to be bound by this Agreement.
10
+
11
+ 1. LICENSE GRANT. Subject to Your compliance with this Agreement, Hotreloads
12
+ grants You a non-exclusive, non-transferable, non-sublicensable, revocable,
13
+ worldwide license to install and embed the Software in Your own website or
14
+ application, and to use it, solely to connect to and use the Service through a
15
+ valid oyehello account and a publishable key issued to You.
16
+
17
+ 2. RESTRICTIONS. You may not: (a) modify, adapt, translate, or create derivative
18
+ works of the Software; (b) reverse engineer, decompile, or disassemble the
19
+ Software, except to the extent this restriction is prohibited by applicable
20
+ law; (c) redistribute, sublicense, sell, rent, lease, or otherwise make the
21
+ Software available to any third party, except as embedded in and delivered as
22
+ part of Your own application to Your end users; (d) remove, obscure, or alter
23
+ any proprietary notices in the Software; or (e) use the Software other than in
24
+ connection with the Service.
25
+
26
+ 3. OWNERSHIP. The Software is licensed, not sold. Hotreloads and its licensors
27
+ retain all right, title, and interest in and to the Software, including all
28
+ intellectual property rights therein. No rights are granted except as
29
+ expressly set out in this Agreement.
30
+
31
+ 4. NO WARRANTY. THE SOFTWARE IS PROVIDED "AS IS" AND "AS AVAILABLE", WITHOUT
32
+ WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
33
+ WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE, AND
34
+ NON-INFRINGEMENT.
35
+
36
+ 5. LIMITATION OF LIABILITY. TO THE MAXIMUM EXTENT PERMITTED BY APPLICABLE LAW, IN
37
+ NO EVENT SHALL HOTRELOADS BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL,
38
+ CONSEQUENTIAL, OR PUNITIVE DAMAGES, OR FOR ANY LOSS OF PROFITS, REVENUE, DATA,
39
+ OR GOODWILL, ARISING OUT OF OR RELATED TO YOUR USE OF OR INABILITY TO USE THE
40
+ SOFTWARE, WHETHER IN CONTRACT, TORT, OR OTHERWISE, EVEN IF ADVISED OF THE
41
+ POSSIBILITY OF SUCH DAMAGES.
42
+
43
+ 6. TERMINATION. This license terminates automatically, without notice, if You
44
+ breach any term of this Agreement, or when Your oyehello account or the
45
+ corresponding publishable key is suspended or terminated. Upon termination You
46
+ must cease all use of the Software and remove it from Your website and
47
+ applications. Sections 3 through 8 survive termination.
48
+
49
+ 7. GOVERNING LAW. This Agreement is governed by the laws of India, without regard
50
+ to its conflict-of-laws rules. The courts at Bengaluru, Karnataka, India shall
51
+ have exclusive jurisdiction over any dispute arising out of or relating to this
52
+ Agreement.
53
+
54
+ 8. ENTIRE AGREEMENT. This Agreement is the entire agreement between You and
55
+ Hotreloads regarding the Software and supersedes any prior understanding on its
56
+ subject matter. If any provision is held unenforceable, the remaining
57
+ provisions remain in full force.
58
+
59
+ For licensing enquiries, contact: legal@hotreloads.com
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # @oyehello/chat
2
+
3
+ Framework-agnostic embeddable chat for oyehello agents — a `<oyehello-chat>` web component with a working default UI, plus a headless `createChatClient()` for custom UIs. Zero framework dependencies.
4
+
5
+ ## Install
6
+ ```bash
7
+ npm install @oyehello/chat
8
+ ```
9
+
10
+ ## Drop-in web component (any framework or plain HTML / Astro / Vue / Svelte)
11
+ ```html
12
+ <script type="module">import '@oyehello/chat';</script>
13
+ <oyehello-chat
14
+ api-key="ohk_pub_…"
15
+ agent-id="agt_…"
16
+ base-url="https://call.hotreloads.ai"
17
+ ></oyehello-chat>
18
+ ```
19
+ Or via a plain script tag (no bundler):
20
+ ```html
21
+ <script src="https://unpkg.com/@oyehello/chat"></script>
22
+ <oyehello-chat api-key="ohk_pub_…" agent-id="agt_…" base-url="https://call.hotreloads.ai"></oyehello-chat>
23
+ ```
24
+ Theme the accent with the `--oc-accent` CSS variable.
25
+
26
+ ### React
27
+ The custom element works in React too (React 19 natively; React 18 fine for these string attributes). The tag is `<oyehello-chat>` (hyphenated — a custom element must contain a dash; `<oyehelloChat>` is not valid) and attributes stay kebab-case:
28
+ ```tsx
29
+ import '@oyehello/chat';
30
+ export default function Chat() {
31
+ return <oyehello-chat api-key="ohk_pub_…" agent-id="agt_…" base-url="https://call.hotreloads.ai" />;
32
+ }
33
+ ```
34
+ TypeScript/TSX users add this ambient declaration once (e.g. `oyehello-chat.d.ts`) so the tag typechecks:
35
+ ```ts
36
+ declare global {
37
+ namespace JSX {
38
+ interface IntrinsicElements {
39
+ 'oyehello-chat': React.DetailedHTMLProps<React.HTMLAttributes<HTMLElement>, HTMLElement>
40
+ & { 'api-key'?: string; 'agent-id'?: string; 'base-url'?: string; title?: string };
41
+ }
42
+ }
43
+ }
44
+ export {};
45
+ ```
46
+
47
+ ## Headless (build your own UI)
48
+ ```js
49
+ import { createChatClient } from '@oyehello/chat';
50
+ const chat = createChatClient({ apiKey: 'ohk_pub_…', agentId: 'agt_…', baseUrl: 'https://call.hotreloads.ai' });
51
+ chat.on((e) => { if (e.type === 'messages') render(e.messages); });
52
+ await chat.start();
53
+ await chat.sendMessage('Hello');
54
+ ```
55
+
56
+ ## Client tools (browser actions)
57
+
58
+ An agent can call browser-side tools your page executes. Register a handler per tool; a tool
59
+ reaches the agent only when it is (a) enabled for that agent in the console AND (b) registered here:
60
+ ```js
61
+ const chat = createChatClient({ apiKey, agentId, baseUrl });
62
+ chat.registerTools({
63
+ navigate: ({ path }) => router.push(path),
64
+ fillField: ({ selector, value }) => { /* your DOM logic */ },
65
+ });
66
+ // or on the web component: document.querySelector('oyehello-chat').registerTools({ … })
67
+ ```
68
+ Register handlers BEFORE `start()` — only tools registered at open are offered to the agent that session.
69
+
70
+ > **Security.** `navigate`/`openUrl` targets can be constrained to an allowlist in the agent's console
71
+ > settings. The other tools (`click`, `fillField`, `readPageContext`, `custom`) receive
72
+ > model-generated arguments with no server-side restriction: **your handler is the trust boundary.**
73
+ > Because a visitor can influence the conversation, treat these as capable of arbitrary DOM action —
74
+ > validate selectors/values and scope what each handler can do before enabling them.
75
+
76
+ ## Requirements
77
+ - `api-key` is a **publishable** key (`ohk_pub_…`) created in the oyehello console.
78
+ - The agent must have **chat enabled**.
79
+ - The page's origin must be on the publishable key's **allowlist**.
package/dist/index.cjs ADDED
@@ -0,0 +1,545 @@
1
+ 'use strict';
2
+
3
+ // src/errors.ts
4
+ var ChatApiError = class extends Error {
5
+ constructor(message, status, code, meta) {
6
+ super(message);
7
+ this.status = status;
8
+ this.code = code;
9
+ this.meta = meta;
10
+ this.name = "ChatApiError";
11
+ }
12
+ status;
13
+ code;
14
+ meta;
15
+ };
16
+
17
+ // src/transport.ts
18
+ async function asError(res, fallbackCode) {
19
+ let body = {};
20
+ try {
21
+ body = await res.json();
22
+ } catch {
23
+ }
24
+ return new ChatApiError(body.error ?? `HTTP ${res.status}`, res.status, body.code ?? fallbackCode, body.meta);
25
+ }
26
+ async function openSession(opts) {
27
+ const body = { agentId: opts.agentId };
28
+ if (opts.user) body.user = opts.user;
29
+ if (opts.clientTools && opts.clientTools.length > 0) body.clientTools = opts.clientTools;
30
+ const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions`, {
31
+ method: "POST",
32
+ credentials: "omit",
33
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.apiKey}` },
34
+ body: JSON.stringify(body)
35
+ });
36
+ if (!res.ok) throw await asError(res);
37
+ return await res.json();
38
+ }
39
+ async function postMessage(opts) {
40
+ const body = { text: opts.text };
41
+ if (opts.attachments && opts.attachments.length > 0) body.attachments = opts.attachments;
42
+ const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/messages`, {
43
+ method: "POST",
44
+ credentials: "omit",
45
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.sessionToken}` },
46
+ body: JSON.stringify(body)
47
+ });
48
+ if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
49
+ }
50
+ async function endSession(opts) {
51
+ await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/end`, {
52
+ method: "POST",
53
+ credentials: "omit",
54
+ headers: { Authorization: `Bearer ${opts.sessionToken}` }
55
+ }).catch(() => {
56
+ });
57
+ }
58
+ async function postToolResult(opts) {
59
+ await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/tool-results`, {
60
+ method: "POST",
61
+ credentials: "omit",
62
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.sessionToken}` },
63
+ body: JSON.stringify({ callId: opts.callId, ok: opts.ok, result: opts.result })
64
+ }).catch(() => {
65
+ });
66
+ }
67
+ async function mintStreamTicket(opts) {
68
+ const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/stream-ticket`, {
69
+ method: "POST",
70
+ credentials: "omit",
71
+ headers: { Authorization: `Bearer ${opts.sessionToken}` }
72
+ });
73
+ if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
74
+ return await res.json();
75
+ }
76
+ function streamUrl(baseUrl, sessionId, ticket) {
77
+ return `${baseUrl}/chat/sessions/${sessionId}/stream?ticket=${encodeURIComponent(ticket)}`;
78
+ }
79
+ async function presignAttachment(opts) {
80
+ const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/attachments`, {
81
+ method: "POST",
82
+ credentials: "omit",
83
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.sessionToken}` },
84
+ body: JSON.stringify({ contentType: opts.contentType })
85
+ });
86
+ if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
87
+ return await res.json();
88
+ }
89
+ async function getAttachmentUrl(opts) {
90
+ const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/attachment-url`, {
91
+ method: "POST",
92
+ credentials: "omit",
93
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.sessionToken}` },
94
+ body: JSON.stringify({ storageRef: opts.storageRef })
95
+ });
96
+ if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
97
+ return (await res.json()).url;
98
+ }
99
+ async function uploadToPresigned(uploadUrl, file, fetchImpl) {
100
+ const res = await fetchImpl(uploadUrl, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
101
+ if (!res.ok) throw new ChatApiError(`attachment upload failed with HTTP ${res.status}`, res.status);
102
+ }
103
+
104
+ // src/client.ts
105
+ var seq = 0;
106
+ var uid = (p) => `${p}_${Date.now().toString(36)}_${(seq++).toString(36)}`;
107
+ function createChatClient(options) {
108
+ const fetchImpl = options.fetch ?? globalThis.fetch;
109
+ const ES = options.eventSource ?? globalThis.EventSource;
110
+ const maxAttachments = options.maxAttachments ?? 4;
111
+ const maxAttachmentBytes = options.maxAttachmentBytes ?? 1e7;
112
+ const listeners = /* @__PURE__ */ new Set();
113
+ let state = "idle";
114
+ const messages = [];
115
+ let streamingId = null;
116
+ let session = null;
117
+ let es = null;
118
+ const seenTurnIds = /* @__PURE__ */ new Set();
119
+ const seenCallIds = /* @__PURE__ */ new Set();
120
+ let handoffState = "ai";
121
+ let reconnectTimer = null;
122
+ let reconnectAttempts = 0;
123
+ let closed = false;
124
+ const toolHandlers = /* @__PURE__ */ new Map();
125
+ const clearReconnect = () => {
126
+ if (reconnectTimer) {
127
+ clearTimeout(reconnectTimer);
128
+ reconnectTimer = null;
129
+ }
130
+ };
131
+ const emit = (e) => {
132
+ for (const l of listeners) l(e);
133
+ };
134
+ const setState = (s) => {
135
+ state = s;
136
+ emit({ type: "state", state: s });
137
+ };
138
+ const emitMessages = () => emit({ type: "messages", messages: messages.slice() });
139
+ const onEvent = (ev) => {
140
+ if (ev.type === "token") {
141
+ let m = streamingId ? messages.find((x) => x.id === streamingId) : void 0;
142
+ if (!m) {
143
+ m = { id: uid("m"), role: "assistant", text: "", streaming: true };
144
+ messages.push(m);
145
+ streamingId = m.id;
146
+ }
147
+ m.text += ev.text;
148
+ emitMessages();
149
+ } else if (ev.type === "message") {
150
+ if (seenTurnIds.has(ev.turnId)) {
151
+ if (streamingId) {
152
+ const idx = messages.findIndex((x) => x.id === streamingId);
153
+ if (idx !== -1) messages.splice(idx, 1);
154
+ streamingId = null;
155
+ emitMessages();
156
+ }
157
+ return;
158
+ }
159
+ seenTurnIds.add(ev.turnId);
160
+ const m = streamingId ? messages.find((x) => x.id === streamingId) : void 0;
161
+ if (m) {
162
+ m.text = ev.text;
163
+ m.streaming = false;
164
+ m.turnId = ev.turnId;
165
+ } else {
166
+ messages.push({ id: uid("m"), role: "assistant", text: ev.text, streaming: false, turnId: ev.turnId });
167
+ }
168
+ streamingId = null;
169
+ emitMessages();
170
+ } else if (ev.type === "citations") {
171
+ const m = messages.find((x) => x.turnId === ev.turnId);
172
+ if (m) {
173
+ m.sources = ev.sources;
174
+ emitMessages();
175
+ }
176
+ } else if (ev.type === "error") {
177
+ emit({ type: "error", error: new Error(ev.message) });
178
+ } else if (ev.type === "tool_call") {
179
+ if (seenCallIds.has(ev.callId)) return;
180
+ seenCallIds.add(ev.callId);
181
+ const handler = toolHandlers.get(ev.name);
182
+ void (async () => {
183
+ if (!session) return;
184
+ const base = { baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, callId: ev.callId, fetchImpl };
185
+ if (!handler) {
186
+ await postToolResult({ ...base, ok: false, result: `no handler registered for ${ev.name}` });
187
+ return;
188
+ }
189
+ try {
190
+ const out = await handler(ev.args);
191
+ await postToolResult({ ...base, ok: true, result: typeof out === "string" ? out : JSON.stringify(out ?? { ok: true }) });
192
+ } catch (err) {
193
+ await postToolResult({ ...base, ok: false, result: err.message });
194
+ }
195
+ })();
196
+ } else if (ev.type === "attachment") {
197
+ const items = ev.items;
198
+ const alreadyRendered = messages.some((m) => m.attachments?.some((a) => items.some((i) => i.storageRef === a.storageRef)));
199
+ if (alreadyRendered) return;
200
+ const target = [...messages].reverse().find((m) => m.role === "user" && !m.attachments);
201
+ if (target) target.attachments = items;
202
+ else messages.push({ id: uid("u"), role: "user", text: "", streaming: false, attachments: items });
203
+ emitMessages();
204
+ } else if (ev.type === "human_message") {
205
+ if (seenTurnIds.has(ev.turnId)) return;
206
+ seenTurnIds.add(ev.turnId);
207
+ messages.push({ id: uid("m"), role: "human", text: ev.text, streaming: false });
208
+ emitMessages();
209
+ } else if (ev.type === "visitor_message") ; else if (ev.type === "state") {
210
+ if (ev.state === "ai" || ev.state === "awaiting_human" || ev.state === "human") {
211
+ handoffState = ev.state;
212
+ emit({ type: "handoff", state: handoffState });
213
+ }
214
+ } else if (ev.type === "agent_typing") {
215
+ emit({ type: "agentTyping", active: !!ev.active });
216
+ }
217
+ };
218
+ const openStream = async () => {
219
+ if (!session || closed) return;
220
+ es?.close();
221
+ const { ticket } = await mintStreamTicket({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, fetchImpl });
222
+ if (closed) return;
223
+ es = new ES(streamUrl(options.baseUrl, session.sessionId, ticket));
224
+ es.onopen = () => {
225
+ reconnectAttempts = 0;
226
+ };
227
+ es.onmessage = (e) => {
228
+ try {
229
+ onEvent(JSON.parse(e.data));
230
+ } catch {
231
+ }
232
+ };
233
+ es.onerror = () => {
234
+ es?.close();
235
+ es = null;
236
+ scheduleReconnect();
237
+ };
238
+ };
239
+ const scheduleReconnect = () => {
240
+ if (closed) return;
241
+ clearReconnect();
242
+ const delay = Math.min(3e4, 500 * 2 ** reconnectAttempts);
243
+ reconnectAttempts += 1;
244
+ reconnectTimer = setTimeout(() => {
245
+ openStream().catch((err) => {
246
+ if (err instanceof ChatApiError && err.code === "session_expired") {
247
+ open().catch((e) => {
248
+ emit({ type: "error", error: e });
249
+ scheduleReconnect();
250
+ });
251
+ } else {
252
+ scheduleReconnect();
253
+ }
254
+ });
255
+ }, delay);
256
+ };
257
+ const open = async () => {
258
+ session = await openSession({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, user: options.user, clientTools: Array.from(toolHandlers.keys()), fetchImpl });
259
+ emit({ type: "agent", agent: session.agent });
260
+ await openStream();
261
+ };
262
+ const uploadFiles = async (files) => {
263
+ return Promise.all(files.map(async (file) => {
264
+ const { storageRef, uploadUrl } = await presignAttachment({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, contentType: file.type, fetchImpl });
265
+ await uploadToPresigned(uploadUrl, file, fetchImpl);
266
+ return { storageRef, contentType: file.type, name: file.name };
267
+ }));
268
+ };
269
+ return {
270
+ getMessages: () => messages.slice(),
271
+ getState: () => state,
272
+ on(listener) {
273
+ listeners.add(listener);
274
+ return () => listeners.delete(listener);
275
+ },
276
+ registerTools(handlers) {
277
+ for (const [name, fn] of Object.entries(handlers)) toolHandlers.set(name, fn);
278
+ },
279
+ async getAttachmentUrl(storageRef) {
280
+ if (!session) throw new Error("call start() before getAttachmentUrl");
281
+ return getAttachmentUrl({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, storageRef, fetchImpl });
282
+ },
283
+ async start() {
284
+ if (state !== "idle") return;
285
+ setState("opening");
286
+ try {
287
+ await open();
288
+ setState("ready");
289
+ } catch (err) {
290
+ setState("error");
291
+ emit({ type: "error", error: err });
292
+ }
293
+ },
294
+ async sendMessage(text, files) {
295
+ if (!session) {
296
+ emit({ type: "error", error: new Error("call start() before sendMessage") });
297
+ return;
298
+ }
299
+ const fileList = files ?? [];
300
+ if (fileList.length > maxAttachments) {
301
+ emit({ type: "error", error: new Error(`too many attachments: max ${maxAttachments} per message`) });
302
+ return;
303
+ }
304
+ const oversized = fileList.find((f) => f.size > maxAttachmentBytes);
305
+ if (oversized) {
306
+ emit({ type: "error", error: new Error(`attachment too large: ${oversized.name} exceeds ${maxAttachmentBytes} bytes`) });
307
+ return;
308
+ }
309
+ let attachments = [];
310
+ if (fileList.length > 0) {
311
+ try {
312
+ attachments = await uploadFiles(fileList);
313
+ } catch (err) {
314
+ emit({ type: "error", error: err });
315
+ return;
316
+ }
317
+ }
318
+ messages.push({ id: uid("u"), role: "user", text, streaming: false, attachments: attachments.length > 0 ? attachments : void 0 });
319
+ emitMessages();
320
+ try {
321
+ await postMessage({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, text, attachments, fetchImpl });
322
+ } catch (err) {
323
+ if (err instanceof ChatApiError && err.code === "session_expired") {
324
+ try {
325
+ await open();
326
+ const freshAttachments = fileList.length ? await uploadFiles(fileList) : [];
327
+ await postMessage({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, text, attachments: freshAttachments, fetchImpl });
328
+ } catch (retryErr) {
329
+ emit({ type: "error", error: retryErr });
330
+ }
331
+ } else {
332
+ emit({ type: "error", error: err });
333
+ }
334
+ }
335
+ },
336
+ async end() {
337
+ closed = true;
338
+ clearReconnect();
339
+ if (session) await endSession({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, fetchImpl });
340
+ es?.close();
341
+ es = null;
342
+ setState("ended");
343
+ }
344
+ };
345
+ }
346
+
347
+ // src/element.ts
348
+ var HANDOFF_STATUS_TEXT = {
349
+ ai: null,
350
+ awaiting_human: "Connecting you to a person\u2026",
351
+ human: "You're chatting with a person"
352
+ };
353
+ var ATTACHMENT_ACCEPT = "image/*,application/pdf,text/plain,text/csv";
354
+ var AGENT_TYPING_TEXT = "The agent is typing\u2026";
355
+ var OyehelloChatElement = class extends HTMLElement {
356
+ static observedAttributes = ["api-key", "agent-id", "base-url", "title"];
357
+ client = null;
358
+ off = null;
359
+ messages = [];
360
+ state = "idle";
361
+ agentName = null;
362
+ handoffState = "ai";
363
+ // Transient "agent is typing…" affordance (Phase 7 followups) - never a message, only rendered
364
+ // while a handoff is active (human/awaiting_human).
365
+ agentTyping = false;
366
+ // registerTools() may be called before connectedCallback creates the client (e.g. right after
367
+ // document.createElement, before the element is attached). Buffer until the client exists.
368
+ pendingTools = {};
369
+ // Files picked in the file input, staged until the form submits alongside the text.
370
+ pendingFiles = [];
371
+ // Best-effort local thumbnail cache for images THIS widget instance just sent, keyed by filename -
372
+ // an instant blob src, no round-trip needed.
373
+ imagePreviewCache = /* @__PURE__ */ new Map();
374
+ // Read-back URLs resolved lazily (via client.getAttachmentUrl) for images with no local blob (a
375
+ // reconnect, or an `attachment` SSE echo), keyed by storageRef. Cached across re-renders so the
376
+ // same image isn't re-fetched on every render() call.
377
+ resolvedUrlCache = /* @__PURE__ */ new Map();
378
+ connectedCallback() {
379
+ if (!this.shadowRoot) this.attachShadow({ mode: "open" });
380
+ const apiKey = this.getAttribute("api-key");
381
+ const agentId = this.getAttribute("agent-id");
382
+ const baseUrl = this.getAttribute("base-url");
383
+ if (!apiKey || !agentId || !baseUrl) {
384
+ this.renderError("oyehello-chat: api-key, agent-id and base-url are required");
385
+ return;
386
+ }
387
+ this.client = createChatClient({ apiKey, agentId, baseUrl });
388
+ if (Object.keys(this.pendingTools).length > 0) {
389
+ this.client.registerTools(this.pendingTools);
390
+ this.pendingTools = {};
391
+ }
392
+ this.off = this.client.on((e) => {
393
+ if (e.type === "messages") this.messages = e.messages;
394
+ else if (e.type === "state") this.state = e.state;
395
+ else if (e.type === "agent") this.agentName = e.agent.name;
396
+ else if (e.type === "handoff") this.handoffState = e.state;
397
+ else if (e.type === "agentTyping") this.agentTyping = e.active;
398
+ this.render();
399
+ });
400
+ this.render();
401
+ void this.client.start();
402
+ }
403
+ disconnectedCallback() {
404
+ this.off?.();
405
+ void this.client?.end();
406
+ for (const url of this.imagePreviewCache.values()) {
407
+ if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
408
+ }
409
+ this.imagePreviewCache.clear();
410
+ }
411
+ registerTools(handlers) {
412
+ if (this.client) this.client.registerTools(handlers);
413
+ else Object.assign(this.pendingTools, handlers);
414
+ }
415
+ send(text, files) {
416
+ if (!text.trim()) return;
417
+ for (const f of files) {
418
+ if (f.type.startsWith("image/") && typeof URL.createObjectURL === "function" && !this.imagePreviewCache.has(f.name)) {
419
+ this.imagePreviewCache.set(f.name, URL.createObjectURL(f));
420
+ }
421
+ }
422
+ void this.client?.sendMessage(text, files.length > 0 ? files : void 0);
423
+ }
424
+ renderError(msg) {
425
+ if (!this.shadowRoot) this.attachShadow({ mode: "open" });
426
+ this.shadowRoot.innerHTML = `<div style="color:#b91c1c;font:13px system-ui;padding:12px">${escapeHtml(msg)}</div>`;
427
+ }
428
+ renderAttachment(a) {
429
+ if (a.contentType.startsWith("image/")) {
430
+ const cached = (a.name ? this.imagePreviewCache.get(a.name) : void 0) ?? this.resolvedUrlCache.get(a.storageRef);
431
+ const srcAttr = cached ? ` src="${cached}"` : "";
432
+ return `<img class="att-thumb" data-oc-ref="${escapeHtml(a.storageRef)}"${srcAttr} alt="${escapeHtml(a.name ?? "image")}"/>`;
433
+ }
434
+ return `<div class="att-chip">\u{1F4CE} ${escapeHtml(a.name ?? a.contentType)}</div>`;
435
+ }
436
+ renderSource(s) {
437
+ return `<div class="src-chip" title="${escapeHtml(s.snippet)}">${escapeHtml(s.title)}</div>`;
438
+ }
439
+ // After render() attaches the markup, resolve a read-back URL (via the client) for every image
440
+ // placeholder that has no src yet - a local blob or a previously-resolved URL already set one, so
441
+ // those are left untouched (no redundant network call).
442
+ hydrateAttachmentImages() {
443
+ if (!this.client) return;
444
+ const imgs = this.shadowRoot.querySelectorAll("img.att-thumb[data-oc-ref]");
445
+ imgs.forEach((img) => {
446
+ if (img.hasAttribute("src")) return;
447
+ const ref = img.getAttribute("data-oc-ref");
448
+ if (!ref) return;
449
+ void this.client.getAttachmentUrl(ref).then((url) => {
450
+ this.resolvedUrlCache.set(ref, url);
451
+ img.src = url;
452
+ }).catch(() => {
453
+ });
454
+ });
455
+ }
456
+ render() {
457
+ const title = this.getAttribute("title") ?? this.agentName ?? "Chat";
458
+ const bubbles = this.messages.map((m) => {
459
+ const atts = (m.attachments ?? []).map((a) => this.renderAttachment(a)).join("");
460
+ const label = m.role === "human" ? '<div class="human-label">Support</div>' : "";
461
+ const bubble = `<div class="b ${m.role}">${label}${atts}${m.text ? escapeHtml(m.text) : ""}${m.streaming ? "\u2026" : ""}</div>`;
462
+ const sources = m.sources && m.sources.length > 0 ? `<div class="src-row">${m.sources.map((s) => this.renderSource(s)).join("")}</div>` : "";
463
+ return bubble + sources;
464
+ }).join("");
465
+ const statusText = HANDOFF_STATUS_TEXT[this.handoffState];
466
+ const status = statusText ? `<div class="status">${escapeHtml(statusText)}</div>` : "";
467
+ const showTyping = this.agentTyping && (this.handoffState === "human" || this.handoffState === "awaiting_human");
468
+ const typingIndicator = showTyping ? `<div class="b assistant typing">${escapeHtml(AGENT_TYPING_TEXT)}</div>` : "";
469
+ const disabled = this.state !== "ready" ? "disabled" : "";
470
+ const prevInput = this.shadowRoot.querySelector('input[aria-label="message"]');
471
+ const prevValue = prevInput?.value ?? "";
472
+ const prevSelectionStart = prevInput?.selectionStart ?? null;
473
+ const prevSelectionEnd = prevInput?.selectionEnd ?? null;
474
+ const hadFocus = !!prevInput && this.shadowRoot.activeElement === prevInput;
475
+ this.shadowRoot.innerHTML = `
476
+ <style>
477
+ :host{display:block;height:100%;min-height:320px;font-family:system-ui,sans-serif;--oc-accent:#2563eb}
478
+ .wrap{display:flex;flex-direction:column;height:100%;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden}
479
+ .hd{padding:10px 14px;border-bottom:1px solid #e5e7eb;font-weight:600}
480
+ .body{flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:8px}
481
+ .b{max-width:80%;padding:8px 12px;border-radius:12px;white-space:pre-wrap;word-break:break-word}
482
+ .b.user{align-self:flex-end;background:var(--oc-accent);color:#fff}
483
+ .b.assistant{align-self:flex-start;background:#f3f4f6;color:#111}
484
+ .b.human{align-self:flex-start;background:#dbeafe;color:#111}
485
+ .b.typing{font-style:italic;color:#6b7280;opacity:.85}
486
+ .human-label{font-size:11px;font-weight:600;opacity:.7;margin-bottom:2px}
487
+ .status{padding:6px 14px;font-size:12px;color:#6b7280;background:#f9fafb;border-bottom:1px solid #e5e7eb}
488
+ .att-thumb{display:block;max-width:160px;max-height:160px;border-radius:8px;margin-bottom:6px}
489
+ .att-chip{display:inline-block;font-size:12px;background:rgba(0,0,0,.08);border-radius:6px;padding:2px 8px;margin:0 0 6px}
490
+ .b.user .att-chip{background:rgba(255,255,255,.25)}
491
+ .src-row{align-self:flex-start;display:flex;flex-wrap:wrap;gap:6px;max-width:80%}
492
+ .src-chip{display:inline-block;font-size:11px;background:rgba(0,0,0,.06);color:#374151;border-radius:6px;padding:2px 8px;cursor:default}
493
+ form{display:flex;gap:8px;padding:10px;border-top:1px solid #e5e7eb;align-items:center}
494
+ input[aria-label="message"]{flex:1;padding:8px 10px;border-radius:8px;border:1px solid #d1d5db;font:inherit}
495
+ input[type="file"]{max-width:120px;font-size:12px}
496
+ button{padding:8px 14px;border-radius:8px;border:none;background:var(--oc-accent);color:#fff;font-weight:600;cursor:pointer}
497
+ button:disabled,input:disabled{opacity:.6;cursor:not-allowed}
498
+ </style>
499
+ <div class="wrap">
500
+ <div class="hd">${escapeHtml(title)}</div>
501
+ ${status}
502
+ <div class="body">${bubbles}${typingIndicator}</div>
503
+ <form>
504
+ <input aria-label="message" placeholder="Type a message\u2026" ${disabled}/>
505
+ <input type="file" accept="${ATTACHMENT_ACCEPT}" multiple ${disabled}/>
506
+ <button type="submit" ${disabled}>Send</button>
507
+ </form>
508
+ </div>`;
509
+ const form = this.shadowRoot.querySelector("form");
510
+ const input = this.shadowRoot.querySelector('input[aria-label="message"]');
511
+ const fileInput = this.shadowRoot.querySelector('input[type="file"]');
512
+ fileInput.addEventListener("change", () => {
513
+ this.pendingFiles = fileInput.files ? Array.from(fileInput.files) : [];
514
+ });
515
+ form.addEventListener("submit", (e) => {
516
+ e.preventDefault();
517
+ const files = this.pendingFiles;
518
+ this.pendingFiles = [];
519
+ this.send(input.value, files);
520
+ input.value = "";
521
+ });
522
+ if (prevValue) {
523
+ input.value = prevValue;
524
+ if (prevSelectionStart !== null && prevSelectionEnd !== null) input.setSelectionRange(prevSelectionStart, prevSelectionEnd);
525
+ }
526
+ if (hadFocus) input.focus();
527
+ const body = this.shadowRoot.querySelector(".body");
528
+ body.scrollTop = body.scrollHeight;
529
+ this.hydrateAttachmentImages();
530
+ }
531
+ };
532
+ function escapeHtml(s) {
533
+ return s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);
534
+ }
535
+
536
+ // src/index.ts
537
+ if (typeof customElements !== "undefined" && !customElements.get("oyehello-chat")) {
538
+ customElements.define("oyehello-chat", OyehelloChatElement);
539
+ }
540
+
541
+ exports.ChatApiError = ChatApiError;
542
+ exports.OyehelloChatElement = OyehelloChatElement;
543
+ exports.createChatClient = createChatClient;
544
+ //# sourceMappingURL=index.cjs.map
545
+ //# sourceMappingURL=index.cjs.map