@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 +59 -0
- package/README.md +79 -0
- package/dist/index.cjs +545 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +157 -0
- package/dist/index.d.ts +157 -0
- package/dist/index.global.js +550 -0
- package/dist/index.global.js.map +1 -0
- package/dist/index.js +541 -0
- package/dist/index.js.map +1 -0
- package/package.json +18 -0
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
var OyehelloChat = (function (exports) {
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// src/errors.ts
|
|
5
|
+
var ChatApiError = class extends Error {
|
|
6
|
+
constructor(message, status, code, meta) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.meta = meta;
|
|
11
|
+
this.name = "ChatApiError";
|
|
12
|
+
}
|
|
13
|
+
status;
|
|
14
|
+
code;
|
|
15
|
+
meta;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// src/transport.ts
|
|
19
|
+
async function asError(res, fallbackCode) {
|
|
20
|
+
let body = {};
|
|
21
|
+
try {
|
|
22
|
+
body = await res.json();
|
|
23
|
+
} catch {
|
|
24
|
+
}
|
|
25
|
+
return new ChatApiError(body.error ?? `HTTP ${res.status}`, res.status, body.code ?? fallbackCode, body.meta);
|
|
26
|
+
}
|
|
27
|
+
async function openSession(opts) {
|
|
28
|
+
const body = { agentId: opts.agentId };
|
|
29
|
+
if (opts.user) body.user = opts.user;
|
|
30
|
+
if (opts.clientTools && opts.clientTools.length > 0) body.clientTools = opts.clientTools;
|
|
31
|
+
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions`, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
credentials: "omit",
|
|
34
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.apiKey}` },
|
|
35
|
+
body: JSON.stringify(body)
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) throw await asError(res);
|
|
38
|
+
return await res.json();
|
|
39
|
+
}
|
|
40
|
+
async function postMessage(opts) {
|
|
41
|
+
const body = { text: opts.text };
|
|
42
|
+
if (opts.attachments && opts.attachments.length > 0) body.attachments = opts.attachments;
|
|
43
|
+
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/messages`, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
credentials: "omit",
|
|
46
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.sessionToken}` },
|
|
47
|
+
body: JSON.stringify(body)
|
|
48
|
+
});
|
|
49
|
+
if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
|
|
50
|
+
}
|
|
51
|
+
async function endSession(opts) {
|
|
52
|
+
await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/end`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
credentials: "omit",
|
|
55
|
+
headers: { Authorization: `Bearer ${opts.sessionToken}` }
|
|
56
|
+
}).catch(() => {
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async function postToolResult(opts) {
|
|
60
|
+
await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/tool-results`, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
credentials: "omit",
|
|
63
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.sessionToken}` },
|
|
64
|
+
body: JSON.stringify({ callId: opts.callId, ok: opts.ok, result: opts.result })
|
|
65
|
+
}).catch(() => {
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
async function mintStreamTicket(opts) {
|
|
69
|
+
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/stream-ticket`, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
credentials: "omit",
|
|
72
|
+
headers: { Authorization: `Bearer ${opts.sessionToken}` }
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
|
|
75
|
+
return await res.json();
|
|
76
|
+
}
|
|
77
|
+
function streamUrl(baseUrl, sessionId, ticket) {
|
|
78
|
+
return `${baseUrl}/chat/sessions/${sessionId}/stream?ticket=${encodeURIComponent(ticket)}`;
|
|
79
|
+
}
|
|
80
|
+
async function presignAttachment(opts) {
|
|
81
|
+
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/attachments`, {
|
|
82
|
+
method: "POST",
|
|
83
|
+
credentials: "omit",
|
|
84
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.sessionToken}` },
|
|
85
|
+
body: JSON.stringify({ contentType: opts.contentType })
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
|
|
88
|
+
return await res.json();
|
|
89
|
+
}
|
|
90
|
+
async function getAttachmentUrl(opts) {
|
|
91
|
+
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/attachment-url`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
credentials: "omit",
|
|
94
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.sessionToken}` },
|
|
95
|
+
body: JSON.stringify({ storageRef: opts.storageRef })
|
|
96
|
+
});
|
|
97
|
+
if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
|
|
98
|
+
return (await res.json()).url;
|
|
99
|
+
}
|
|
100
|
+
async function uploadToPresigned(uploadUrl, file, fetchImpl) {
|
|
101
|
+
const res = await fetchImpl(uploadUrl, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
|
|
102
|
+
if (!res.ok) throw new ChatApiError(`attachment upload failed with HTTP ${res.status}`, res.status);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/client.ts
|
|
106
|
+
var seq = 0;
|
|
107
|
+
var uid = (p) => `${p}_${Date.now().toString(36)}_${(seq++).toString(36)}`;
|
|
108
|
+
function createChatClient(options) {
|
|
109
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
110
|
+
const ES = options.eventSource ?? globalThis.EventSource;
|
|
111
|
+
const maxAttachments = options.maxAttachments ?? 4;
|
|
112
|
+
const maxAttachmentBytes = options.maxAttachmentBytes ?? 1e7;
|
|
113
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
114
|
+
let state = "idle";
|
|
115
|
+
const messages = [];
|
|
116
|
+
let streamingId = null;
|
|
117
|
+
let session = null;
|
|
118
|
+
let es = null;
|
|
119
|
+
const seenTurnIds = /* @__PURE__ */ new Set();
|
|
120
|
+
const seenCallIds = /* @__PURE__ */ new Set();
|
|
121
|
+
let handoffState = "ai";
|
|
122
|
+
let reconnectTimer = null;
|
|
123
|
+
let reconnectAttempts = 0;
|
|
124
|
+
let closed = false;
|
|
125
|
+
const toolHandlers = /* @__PURE__ */ new Map();
|
|
126
|
+
const clearReconnect = () => {
|
|
127
|
+
if (reconnectTimer) {
|
|
128
|
+
clearTimeout(reconnectTimer);
|
|
129
|
+
reconnectTimer = null;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const emit = (e) => {
|
|
133
|
+
for (const l of listeners) l(e);
|
|
134
|
+
};
|
|
135
|
+
const setState = (s) => {
|
|
136
|
+
state = s;
|
|
137
|
+
emit({ type: "state", state: s });
|
|
138
|
+
};
|
|
139
|
+
const emitMessages = () => emit({ type: "messages", messages: messages.slice() });
|
|
140
|
+
const onEvent = (ev) => {
|
|
141
|
+
if (ev.type === "token") {
|
|
142
|
+
let m = streamingId ? messages.find((x) => x.id === streamingId) : void 0;
|
|
143
|
+
if (!m) {
|
|
144
|
+
m = { id: uid("m"), role: "assistant", text: "", streaming: true };
|
|
145
|
+
messages.push(m);
|
|
146
|
+
streamingId = m.id;
|
|
147
|
+
}
|
|
148
|
+
m.text += ev.text;
|
|
149
|
+
emitMessages();
|
|
150
|
+
} else if (ev.type === "message") {
|
|
151
|
+
if (seenTurnIds.has(ev.turnId)) {
|
|
152
|
+
if (streamingId) {
|
|
153
|
+
const idx = messages.findIndex((x) => x.id === streamingId);
|
|
154
|
+
if (idx !== -1) messages.splice(idx, 1);
|
|
155
|
+
streamingId = null;
|
|
156
|
+
emitMessages();
|
|
157
|
+
}
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
seenTurnIds.add(ev.turnId);
|
|
161
|
+
const m = streamingId ? messages.find((x) => x.id === streamingId) : void 0;
|
|
162
|
+
if (m) {
|
|
163
|
+
m.text = ev.text;
|
|
164
|
+
m.streaming = false;
|
|
165
|
+
m.turnId = ev.turnId;
|
|
166
|
+
} else {
|
|
167
|
+
messages.push({ id: uid("m"), role: "assistant", text: ev.text, streaming: false, turnId: ev.turnId });
|
|
168
|
+
}
|
|
169
|
+
streamingId = null;
|
|
170
|
+
emitMessages();
|
|
171
|
+
} else if (ev.type === "citations") {
|
|
172
|
+
const m = messages.find((x) => x.turnId === ev.turnId);
|
|
173
|
+
if (m) {
|
|
174
|
+
m.sources = ev.sources;
|
|
175
|
+
emitMessages();
|
|
176
|
+
}
|
|
177
|
+
} else if (ev.type === "error") {
|
|
178
|
+
emit({ type: "error", error: new Error(ev.message) });
|
|
179
|
+
} else if (ev.type === "tool_call") {
|
|
180
|
+
if (seenCallIds.has(ev.callId)) return;
|
|
181
|
+
seenCallIds.add(ev.callId);
|
|
182
|
+
const handler = toolHandlers.get(ev.name);
|
|
183
|
+
void (async () => {
|
|
184
|
+
if (!session) return;
|
|
185
|
+
const base = { baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, callId: ev.callId, fetchImpl };
|
|
186
|
+
if (!handler) {
|
|
187
|
+
await postToolResult({ ...base, ok: false, result: `no handler registered for ${ev.name}` });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const out = await handler(ev.args);
|
|
192
|
+
await postToolResult({ ...base, ok: true, result: typeof out === "string" ? out : JSON.stringify(out ?? { ok: true }) });
|
|
193
|
+
} catch (err) {
|
|
194
|
+
await postToolResult({ ...base, ok: false, result: err.message });
|
|
195
|
+
}
|
|
196
|
+
})();
|
|
197
|
+
} else if (ev.type === "attachment") {
|
|
198
|
+
const items = ev.items;
|
|
199
|
+
const alreadyRendered = messages.some((m) => m.attachments?.some((a) => items.some((i) => i.storageRef === a.storageRef)));
|
|
200
|
+
if (alreadyRendered) return;
|
|
201
|
+
const target = [...messages].reverse().find((m) => m.role === "user" && !m.attachments);
|
|
202
|
+
if (target) target.attachments = items;
|
|
203
|
+
else messages.push({ id: uid("u"), role: "user", text: "", streaming: false, attachments: items });
|
|
204
|
+
emitMessages();
|
|
205
|
+
} else if (ev.type === "human_message") {
|
|
206
|
+
if (seenTurnIds.has(ev.turnId)) return;
|
|
207
|
+
seenTurnIds.add(ev.turnId);
|
|
208
|
+
messages.push({ id: uid("m"), role: "human", text: ev.text, streaming: false });
|
|
209
|
+
emitMessages();
|
|
210
|
+
} else if (ev.type === "visitor_message") ; else if (ev.type === "state") {
|
|
211
|
+
if (ev.state === "ai" || ev.state === "awaiting_human" || ev.state === "human") {
|
|
212
|
+
handoffState = ev.state;
|
|
213
|
+
emit({ type: "handoff", state: handoffState });
|
|
214
|
+
}
|
|
215
|
+
} else if (ev.type === "agent_typing") {
|
|
216
|
+
emit({ type: "agentTyping", active: !!ev.active });
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
const openStream = async () => {
|
|
220
|
+
if (!session || closed) return;
|
|
221
|
+
es?.close();
|
|
222
|
+
const { ticket } = await mintStreamTicket({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, fetchImpl });
|
|
223
|
+
if (closed) return;
|
|
224
|
+
es = new ES(streamUrl(options.baseUrl, session.sessionId, ticket));
|
|
225
|
+
es.onopen = () => {
|
|
226
|
+
reconnectAttempts = 0;
|
|
227
|
+
};
|
|
228
|
+
es.onmessage = (e) => {
|
|
229
|
+
try {
|
|
230
|
+
onEvent(JSON.parse(e.data));
|
|
231
|
+
} catch {
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
es.onerror = () => {
|
|
235
|
+
es?.close();
|
|
236
|
+
es = null;
|
|
237
|
+
scheduleReconnect();
|
|
238
|
+
};
|
|
239
|
+
};
|
|
240
|
+
const scheduleReconnect = () => {
|
|
241
|
+
if (closed) return;
|
|
242
|
+
clearReconnect();
|
|
243
|
+
const delay = Math.min(3e4, 500 * 2 ** reconnectAttempts);
|
|
244
|
+
reconnectAttempts += 1;
|
|
245
|
+
reconnectTimer = setTimeout(() => {
|
|
246
|
+
openStream().catch((err) => {
|
|
247
|
+
if (err instanceof ChatApiError && err.code === "session_expired") {
|
|
248
|
+
open().catch((e) => {
|
|
249
|
+
emit({ type: "error", error: e });
|
|
250
|
+
scheduleReconnect();
|
|
251
|
+
});
|
|
252
|
+
} else {
|
|
253
|
+
scheduleReconnect();
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
}, delay);
|
|
257
|
+
};
|
|
258
|
+
const open = async () => {
|
|
259
|
+
session = await openSession({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, user: options.user, clientTools: Array.from(toolHandlers.keys()), fetchImpl });
|
|
260
|
+
emit({ type: "agent", agent: session.agent });
|
|
261
|
+
await openStream();
|
|
262
|
+
};
|
|
263
|
+
const uploadFiles = async (files) => {
|
|
264
|
+
return Promise.all(files.map(async (file) => {
|
|
265
|
+
const { storageRef, uploadUrl } = await presignAttachment({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, contentType: file.type, fetchImpl });
|
|
266
|
+
await uploadToPresigned(uploadUrl, file, fetchImpl);
|
|
267
|
+
return { storageRef, contentType: file.type, name: file.name };
|
|
268
|
+
}));
|
|
269
|
+
};
|
|
270
|
+
return {
|
|
271
|
+
getMessages: () => messages.slice(),
|
|
272
|
+
getState: () => state,
|
|
273
|
+
on(listener) {
|
|
274
|
+
listeners.add(listener);
|
|
275
|
+
return () => listeners.delete(listener);
|
|
276
|
+
},
|
|
277
|
+
registerTools(handlers) {
|
|
278
|
+
for (const [name, fn] of Object.entries(handlers)) toolHandlers.set(name, fn);
|
|
279
|
+
},
|
|
280
|
+
async getAttachmentUrl(storageRef) {
|
|
281
|
+
if (!session) throw new Error("call start() before getAttachmentUrl");
|
|
282
|
+
return getAttachmentUrl({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, storageRef, fetchImpl });
|
|
283
|
+
},
|
|
284
|
+
async start() {
|
|
285
|
+
if (state !== "idle") return;
|
|
286
|
+
setState("opening");
|
|
287
|
+
try {
|
|
288
|
+
await open();
|
|
289
|
+
setState("ready");
|
|
290
|
+
} catch (err) {
|
|
291
|
+
setState("error");
|
|
292
|
+
emit({ type: "error", error: err });
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
async sendMessage(text, files) {
|
|
296
|
+
if (!session) {
|
|
297
|
+
emit({ type: "error", error: new Error("call start() before sendMessage") });
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const fileList = files ?? [];
|
|
301
|
+
if (fileList.length > maxAttachments) {
|
|
302
|
+
emit({ type: "error", error: new Error(`too many attachments: max ${maxAttachments} per message`) });
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const oversized = fileList.find((f) => f.size > maxAttachmentBytes);
|
|
306
|
+
if (oversized) {
|
|
307
|
+
emit({ type: "error", error: new Error(`attachment too large: ${oversized.name} exceeds ${maxAttachmentBytes} bytes`) });
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
let attachments = [];
|
|
311
|
+
if (fileList.length > 0) {
|
|
312
|
+
try {
|
|
313
|
+
attachments = await uploadFiles(fileList);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
emit({ type: "error", error: err });
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
messages.push({ id: uid("u"), role: "user", text, streaming: false, attachments: attachments.length > 0 ? attachments : void 0 });
|
|
320
|
+
emitMessages();
|
|
321
|
+
try {
|
|
322
|
+
await postMessage({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, text, attachments, fetchImpl });
|
|
323
|
+
} catch (err) {
|
|
324
|
+
if (err instanceof ChatApiError && err.code === "session_expired") {
|
|
325
|
+
try {
|
|
326
|
+
await open();
|
|
327
|
+
const freshAttachments = fileList.length ? await uploadFiles(fileList) : [];
|
|
328
|
+
await postMessage({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, text, attachments: freshAttachments, fetchImpl });
|
|
329
|
+
} catch (retryErr) {
|
|
330
|
+
emit({ type: "error", error: retryErr });
|
|
331
|
+
}
|
|
332
|
+
} else {
|
|
333
|
+
emit({ type: "error", error: err });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
async end() {
|
|
338
|
+
closed = true;
|
|
339
|
+
clearReconnect();
|
|
340
|
+
if (session) await endSession({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, fetchImpl });
|
|
341
|
+
es?.close();
|
|
342
|
+
es = null;
|
|
343
|
+
setState("ended");
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/element.ts
|
|
349
|
+
var HANDOFF_STATUS_TEXT = {
|
|
350
|
+
ai: null,
|
|
351
|
+
awaiting_human: "Connecting you to a person\u2026",
|
|
352
|
+
human: "You're chatting with a person"
|
|
353
|
+
};
|
|
354
|
+
var ATTACHMENT_ACCEPT = "image/*,application/pdf,text/plain,text/csv";
|
|
355
|
+
var AGENT_TYPING_TEXT = "The agent is typing\u2026";
|
|
356
|
+
var OyehelloChatElement = class extends HTMLElement {
|
|
357
|
+
static observedAttributes = ["api-key", "agent-id", "base-url", "title"];
|
|
358
|
+
client = null;
|
|
359
|
+
off = null;
|
|
360
|
+
messages = [];
|
|
361
|
+
state = "idle";
|
|
362
|
+
agentName = null;
|
|
363
|
+
handoffState = "ai";
|
|
364
|
+
// Transient "agent is typing…" affordance (Phase 7 followups) - never a message, only rendered
|
|
365
|
+
// while a handoff is active (human/awaiting_human).
|
|
366
|
+
agentTyping = false;
|
|
367
|
+
// registerTools() may be called before connectedCallback creates the client (e.g. right after
|
|
368
|
+
// document.createElement, before the element is attached). Buffer until the client exists.
|
|
369
|
+
pendingTools = {};
|
|
370
|
+
// Files picked in the file input, staged until the form submits alongside the text.
|
|
371
|
+
pendingFiles = [];
|
|
372
|
+
// Best-effort local thumbnail cache for images THIS widget instance just sent, keyed by filename -
|
|
373
|
+
// an instant blob src, no round-trip needed.
|
|
374
|
+
imagePreviewCache = /* @__PURE__ */ new Map();
|
|
375
|
+
// Read-back URLs resolved lazily (via client.getAttachmentUrl) for images with no local blob (a
|
|
376
|
+
// reconnect, or an `attachment` SSE echo), keyed by storageRef. Cached across re-renders so the
|
|
377
|
+
// same image isn't re-fetched on every render() call.
|
|
378
|
+
resolvedUrlCache = /* @__PURE__ */ new Map();
|
|
379
|
+
connectedCallback() {
|
|
380
|
+
if (!this.shadowRoot) this.attachShadow({ mode: "open" });
|
|
381
|
+
const apiKey = this.getAttribute("api-key");
|
|
382
|
+
const agentId = this.getAttribute("agent-id");
|
|
383
|
+
const baseUrl = this.getAttribute("base-url");
|
|
384
|
+
if (!apiKey || !agentId || !baseUrl) {
|
|
385
|
+
this.renderError("oyehello-chat: api-key, agent-id and base-url are required");
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
this.client = createChatClient({ apiKey, agentId, baseUrl });
|
|
389
|
+
if (Object.keys(this.pendingTools).length > 0) {
|
|
390
|
+
this.client.registerTools(this.pendingTools);
|
|
391
|
+
this.pendingTools = {};
|
|
392
|
+
}
|
|
393
|
+
this.off = this.client.on((e) => {
|
|
394
|
+
if (e.type === "messages") this.messages = e.messages;
|
|
395
|
+
else if (e.type === "state") this.state = e.state;
|
|
396
|
+
else if (e.type === "agent") this.agentName = e.agent.name;
|
|
397
|
+
else if (e.type === "handoff") this.handoffState = e.state;
|
|
398
|
+
else if (e.type === "agentTyping") this.agentTyping = e.active;
|
|
399
|
+
this.render();
|
|
400
|
+
});
|
|
401
|
+
this.render();
|
|
402
|
+
void this.client.start();
|
|
403
|
+
}
|
|
404
|
+
disconnectedCallback() {
|
|
405
|
+
this.off?.();
|
|
406
|
+
void this.client?.end();
|
|
407
|
+
for (const url of this.imagePreviewCache.values()) {
|
|
408
|
+
if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
|
|
409
|
+
}
|
|
410
|
+
this.imagePreviewCache.clear();
|
|
411
|
+
}
|
|
412
|
+
registerTools(handlers) {
|
|
413
|
+
if (this.client) this.client.registerTools(handlers);
|
|
414
|
+
else Object.assign(this.pendingTools, handlers);
|
|
415
|
+
}
|
|
416
|
+
send(text, files) {
|
|
417
|
+
if (!text.trim()) return;
|
|
418
|
+
for (const f of files) {
|
|
419
|
+
if (f.type.startsWith("image/") && typeof URL.createObjectURL === "function" && !this.imagePreviewCache.has(f.name)) {
|
|
420
|
+
this.imagePreviewCache.set(f.name, URL.createObjectURL(f));
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
void this.client?.sendMessage(text, files.length > 0 ? files : void 0);
|
|
424
|
+
}
|
|
425
|
+
renderError(msg) {
|
|
426
|
+
if (!this.shadowRoot) this.attachShadow({ mode: "open" });
|
|
427
|
+
this.shadowRoot.innerHTML = `<div style="color:#b91c1c;font:13px system-ui;padding:12px">${escapeHtml(msg)}</div>`;
|
|
428
|
+
}
|
|
429
|
+
renderAttachment(a) {
|
|
430
|
+
if (a.contentType.startsWith("image/")) {
|
|
431
|
+
const cached = (a.name ? this.imagePreviewCache.get(a.name) : void 0) ?? this.resolvedUrlCache.get(a.storageRef);
|
|
432
|
+
const srcAttr = cached ? ` src="${cached}"` : "";
|
|
433
|
+
return `<img class="att-thumb" data-oc-ref="${escapeHtml(a.storageRef)}"${srcAttr} alt="${escapeHtml(a.name ?? "image")}"/>`;
|
|
434
|
+
}
|
|
435
|
+
return `<div class="att-chip">\u{1F4CE} ${escapeHtml(a.name ?? a.contentType)}</div>`;
|
|
436
|
+
}
|
|
437
|
+
renderSource(s) {
|
|
438
|
+
return `<div class="src-chip" title="${escapeHtml(s.snippet)}">${escapeHtml(s.title)}</div>`;
|
|
439
|
+
}
|
|
440
|
+
// After render() attaches the markup, resolve a read-back URL (via the client) for every image
|
|
441
|
+
// placeholder that has no src yet - a local blob or a previously-resolved URL already set one, so
|
|
442
|
+
// those are left untouched (no redundant network call).
|
|
443
|
+
hydrateAttachmentImages() {
|
|
444
|
+
if (!this.client) return;
|
|
445
|
+
const imgs = this.shadowRoot.querySelectorAll("img.att-thumb[data-oc-ref]");
|
|
446
|
+
imgs.forEach((img) => {
|
|
447
|
+
if (img.hasAttribute("src")) return;
|
|
448
|
+
const ref = img.getAttribute("data-oc-ref");
|
|
449
|
+
if (!ref) return;
|
|
450
|
+
void this.client.getAttachmentUrl(ref).then((url) => {
|
|
451
|
+
this.resolvedUrlCache.set(ref, url);
|
|
452
|
+
img.src = url;
|
|
453
|
+
}).catch(() => {
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
render() {
|
|
458
|
+
const title = this.getAttribute("title") ?? this.agentName ?? "Chat";
|
|
459
|
+
const bubbles = this.messages.map((m) => {
|
|
460
|
+
const atts = (m.attachments ?? []).map((a) => this.renderAttachment(a)).join("");
|
|
461
|
+
const label = m.role === "human" ? '<div class="human-label">Support</div>' : "";
|
|
462
|
+
const bubble = `<div class="b ${m.role}">${label}${atts}${m.text ? escapeHtml(m.text) : ""}${m.streaming ? "\u2026" : ""}</div>`;
|
|
463
|
+
const sources = m.sources && m.sources.length > 0 ? `<div class="src-row">${m.sources.map((s) => this.renderSource(s)).join("")}</div>` : "";
|
|
464
|
+
return bubble + sources;
|
|
465
|
+
}).join("");
|
|
466
|
+
const statusText = HANDOFF_STATUS_TEXT[this.handoffState];
|
|
467
|
+
const status = statusText ? `<div class="status">${escapeHtml(statusText)}</div>` : "";
|
|
468
|
+
const showTyping = this.agentTyping && (this.handoffState === "human" || this.handoffState === "awaiting_human");
|
|
469
|
+
const typingIndicator = showTyping ? `<div class="b assistant typing">${escapeHtml(AGENT_TYPING_TEXT)}</div>` : "";
|
|
470
|
+
const disabled = this.state !== "ready" ? "disabled" : "";
|
|
471
|
+
const prevInput = this.shadowRoot.querySelector('input[aria-label="message"]');
|
|
472
|
+
const prevValue = prevInput?.value ?? "";
|
|
473
|
+
const prevSelectionStart = prevInput?.selectionStart ?? null;
|
|
474
|
+
const prevSelectionEnd = prevInput?.selectionEnd ?? null;
|
|
475
|
+
const hadFocus = !!prevInput && this.shadowRoot.activeElement === prevInput;
|
|
476
|
+
this.shadowRoot.innerHTML = `
|
|
477
|
+
<style>
|
|
478
|
+
:host{display:block;height:100%;min-height:320px;font-family:system-ui,sans-serif;--oc-accent:#2563eb}
|
|
479
|
+
.wrap{display:flex;flex-direction:column;height:100%;border:1px solid #e5e7eb;border-radius:12px;overflow:hidden}
|
|
480
|
+
.hd{padding:10px 14px;border-bottom:1px solid #e5e7eb;font-weight:600}
|
|
481
|
+
.body{flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:8px}
|
|
482
|
+
.b{max-width:80%;padding:8px 12px;border-radius:12px;white-space:pre-wrap;word-break:break-word}
|
|
483
|
+
.b.user{align-self:flex-end;background:var(--oc-accent);color:#fff}
|
|
484
|
+
.b.assistant{align-self:flex-start;background:#f3f4f6;color:#111}
|
|
485
|
+
.b.human{align-self:flex-start;background:#dbeafe;color:#111}
|
|
486
|
+
.b.typing{font-style:italic;color:#6b7280;opacity:.85}
|
|
487
|
+
.human-label{font-size:11px;font-weight:600;opacity:.7;margin-bottom:2px}
|
|
488
|
+
.status{padding:6px 14px;font-size:12px;color:#6b7280;background:#f9fafb;border-bottom:1px solid #e5e7eb}
|
|
489
|
+
.att-thumb{display:block;max-width:160px;max-height:160px;border-radius:8px;margin-bottom:6px}
|
|
490
|
+
.att-chip{display:inline-block;font-size:12px;background:rgba(0,0,0,.08);border-radius:6px;padding:2px 8px;margin:0 0 6px}
|
|
491
|
+
.b.user .att-chip{background:rgba(255,255,255,.25)}
|
|
492
|
+
.src-row{align-self:flex-start;display:flex;flex-wrap:wrap;gap:6px;max-width:80%}
|
|
493
|
+
.src-chip{display:inline-block;font-size:11px;background:rgba(0,0,0,.06);color:#374151;border-radius:6px;padding:2px 8px;cursor:default}
|
|
494
|
+
form{display:flex;gap:8px;padding:10px;border-top:1px solid #e5e7eb;align-items:center}
|
|
495
|
+
input[aria-label="message"]{flex:1;padding:8px 10px;border-radius:8px;border:1px solid #d1d5db;font:inherit}
|
|
496
|
+
input[type="file"]{max-width:120px;font-size:12px}
|
|
497
|
+
button{padding:8px 14px;border-radius:8px;border:none;background:var(--oc-accent);color:#fff;font-weight:600;cursor:pointer}
|
|
498
|
+
button:disabled,input:disabled{opacity:.6;cursor:not-allowed}
|
|
499
|
+
</style>
|
|
500
|
+
<div class="wrap">
|
|
501
|
+
<div class="hd">${escapeHtml(title)}</div>
|
|
502
|
+
${status}
|
|
503
|
+
<div class="body">${bubbles}${typingIndicator}</div>
|
|
504
|
+
<form>
|
|
505
|
+
<input aria-label="message" placeholder="Type a message\u2026" ${disabled}/>
|
|
506
|
+
<input type="file" accept="${ATTACHMENT_ACCEPT}" multiple ${disabled}/>
|
|
507
|
+
<button type="submit" ${disabled}>Send</button>
|
|
508
|
+
</form>
|
|
509
|
+
</div>`;
|
|
510
|
+
const form = this.shadowRoot.querySelector("form");
|
|
511
|
+
const input = this.shadowRoot.querySelector('input[aria-label="message"]');
|
|
512
|
+
const fileInput = this.shadowRoot.querySelector('input[type="file"]');
|
|
513
|
+
fileInput.addEventListener("change", () => {
|
|
514
|
+
this.pendingFiles = fileInput.files ? Array.from(fileInput.files) : [];
|
|
515
|
+
});
|
|
516
|
+
form.addEventListener("submit", (e) => {
|
|
517
|
+
e.preventDefault();
|
|
518
|
+
const files = this.pendingFiles;
|
|
519
|
+
this.pendingFiles = [];
|
|
520
|
+
this.send(input.value, files);
|
|
521
|
+
input.value = "";
|
|
522
|
+
});
|
|
523
|
+
if (prevValue) {
|
|
524
|
+
input.value = prevValue;
|
|
525
|
+
if (prevSelectionStart !== null && prevSelectionEnd !== null) input.setSelectionRange(prevSelectionStart, prevSelectionEnd);
|
|
526
|
+
}
|
|
527
|
+
if (hadFocus) input.focus();
|
|
528
|
+
const body = this.shadowRoot.querySelector(".body");
|
|
529
|
+
body.scrollTop = body.scrollHeight;
|
|
530
|
+
this.hydrateAttachmentImages();
|
|
531
|
+
}
|
|
532
|
+
};
|
|
533
|
+
function escapeHtml(s) {
|
|
534
|
+
return s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// src/index.ts
|
|
538
|
+
if (typeof customElements !== "undefined" && !customElements.get("oyehello-chat")) {
|
|
539
|
+
customElements.define("oyehello-chat", OyehelloChatElement);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
exports.ChatApiError = ChatApiError;
|
|
543
|
+
exports.OyehelloChatElement = OyehelloChatElement;
|
|
544
|
+
exports.createChatClient = createChatClient;
|
|
545
|
+
|
|
546
|
+
return exports;
|
|
547
|
+
|
|
548
|
+
})({});
|
|
549
|
+
//# sourceMappingURL=index.global.js.map
|
|
550
|
+
//# sourceMappingURL=index.global.js.map
|