@oyehello/chat 0.5.0 → 0.5.2
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 +19 -9
- package/dist/index.cjs +330 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.global.js +330 -22
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +330 -22
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -74,23 +74,33 @@ await chat.sendMessage('Hello');
|
|
|
74
74
|
|
|
75
75
|
## Client tools (browser actions)
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
The SDK supports two browser-side tools: `navigate` (go to a path on the current site) and `openUrl`
|
|
78
|
+
(open a URL, by default in a new tab). Once a tool is enabled for the agent in the console **and** the
|
|
79
|
+
target passes the agent's navigate allowlist (also set in the console), the SDK executes it itself —
|
|
80
|
+
**no host integration required**:
|
|
81
|
+
```html
|
|
82
|
+
<script type="module">import '@oyehello/chat';</script>
|
|
83
|
+
<oyehello-chat api-key="ohk_pub_…" agent-id="agt_…" base-url="https://api.oyehello.ai"></oyehello-chat>
|
|
84
|
+
```
|
|
85
|
+
That's it — a plain embed with no JS wiring can navigate the visitor or open a link when the agent calls
|
|
86
|
+
the tool. `navigate` calls `window.location.assign(path)`; `openUrl` calls `window.open(url, '_blank')`
|
|
87
|
+
(pass `newTab: false` from the agent to open in the same tab instead).
|
|
88
|
+
|
|
89
|
+
A host page MAY still register its own `navigate`/`openUrl` handler to override the default behavior
|
|
90
|
+
(e.g. push through a client-side router instead of a full navigation):
|
|
79
91
|
```js
|
|
80
92
|
const chat = createChatClient({ apiKey, agentId, baseUrl });
|
|
81
93
|
chat.registerTools({
|
|
82
94
|
navigate: ({ path }) => router.push(path),
|
|
83
|
-
fillField: ({ selector, value }) => { /* your DOM logic */ },
|
|
84
95
|
});
|
|
85
96
|
// or on the web component: document.querySelector('oyehello-chat').registerTools({ … })
|
|
86
97
|
```
|
|
87
|
-
Register handlers BEFORE `start()
|
|
98
|
+
Register handlers BEFORE `start()`. A host-registered handler always takes priority over the SDK's
|
|
99
|
+
built-in; any other tool name has no handler and fails cleanly.
|
|
88
100
|
|
|
89
|
-
> **Security.**
|
|
90
|
-
>
|
|
91
|
-
>
|
|
92
|
-
> Because a visitor can influence the conversation, treat these as capable of arbitrary DOM action —
|
|
93
|
-
> validate selectors/values and scope what each handler can do before enabling them.
|
|
101
|
+
> **Security.** Navigation targets (both the SDK's built-in execution and a host-overridden handler)
|
|
102
|
+
> are validated against the agent's navigate allowlist **server-side** before the tool call ever
|
|
103
|
+
> reaches the browser — the SDK/host is not the trust boundary for where a visitor can be sent.
|
|
94
104
|
|
|
95
105
|
## Requirements
|
|
96
106
|
- `api-key` is a **publishable** key (`ohk_pub_…`) created in the oyehello console.
|
package/dist/index.cjs
CHANGED
|
@@ -67,6 +67,15 @@ async function postMessage(opts) {
|
|
|
67
67
|
});
|
|
68
68
|
if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
|
|
69
69
|
}
|
|
70
|
+
async function postPageViews(opts) {
|
|
71
|
+
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/page-views`, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
credentials: "omit",
|
|
74
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.sessionToken}` },
|
|
75
|
+
body: JSON.stringify({ views: opts.views })
|
|
76
|
+
});
|
|
77
|
+
if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
|
|
78
|
+
}
|
|
70
79
|
async function endSession(opts) {
|
|
71
80
|
await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/end`, {
|
|
72
81
|
method: "POST",
|
|
@@ -84,6 +93,15 @@ async function postToolResult(opts) {
|
|
|
84
93
|
}).catch(() => {
|
|
85
94
|
});
|
|
86
95
|
}
|
|
96
|
+
async function refreshSessionToken(opts) {
|
|
97
|
+
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/refresh-token`, {
|
|
98
|
+
method: "POST",
|
|
99
|
+
credentials: "omit",
|
|
100
|
+
headers: { Authorization: `Bearer ${opts.sessionToken}` }
|
|
101
|
+
});
|
|
102
|
+
if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
|
|
103
|
+
return await res.json();
|
|
104
|
+
}
|
|
87
105
|
async function mintStreamTicket(opts) {
|
|
88
106
|
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/stream-ticket`, {
|
|
89
107
|
method: "POST",
|
|
@@ -96,15 +114,18 @@ async function mintStreamTicket(opts) {
|
|
|
96
114
|
function streamUrl(baseUrl, sessionId, ticket) {
|
|
97
115
|
return `${baseUrl}/chat/sessions/${sessionId}/stream?ticket=${encodeURIComponent(ticket)}`;
|
|
98
116
|
}
|
|
99
|
-
async function
|
|
100
|
-
const
|
|
117
|
+
async function uploadAttachment(opts) {
|
|
118
|
+
const form = new FormData();
|
|
119
|
+
form.append("file", opts.file, opts.file.name);
|
|
120
|
+
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/upload`, {
|
|
101
121
|
method: "POST",
|
|
102
122
|
credentials: "omit",
|
|
103
|
-
headers: {
|
|
104
|
-
body:
|
|
123
|
+
headers: { Authorization: `Bearer ${opts.sessionToken}` },
|
|
124
|
+
body: form
|
|
105
125
|
});
|
|
106
126
|
if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
|
|
107
|
-
|
|
127
|
+
const j = await res.json();
|
|
128
|
+
return { storageRef: j.storageRef, contentType: j.contentType, name: j.name };
|
|
108
129
|
}
|
|
109
130
|
async function getAttachmentUrl(opts) {
|
|
110
131
|
const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/attachment-url`, {
|
|
@@ -116,10 +137,6 @@ async function getAttachmentUrl(opts) {
|
|
|
116
137
|
if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
|
|
117
138
|
return (await res.json()).url;
|
|
118
139
|
}
|
|
119
|
-
async function uploadToPresigned(uploadUrl, file, fetchImpl) {
|
|
120
|
-
const res = await fetchImpl(uploadUrl, { method: "PUT", body: file, headers: { "Content-Type": file.type } });
|
|
121
|
-
if (!res.ok) throw new ChatApiError(`attachment upload failed with HTTP ${res.status}`, res.status);
|
|
122
|
-
}
|
|
123
140
|
|
|
124
141
|
// src/fingerprint.ts
|
|
125
142
|
async function sha256Hex(input) {
|
|
@@ -165,9 +182,161 @@ async function computeDeviceFingerprint() {
|
|
|
165
182
|
}
|
|
166
183
|
}
|
|
167
184
|
|
|
185
|
+
// src/journey.ts
|
|
186
|
+
var MAX_BUFFER = 25;
|
|
187
|
+
var storageKey = (agentId) => `oyehello-chat:journey:${agentId}`;
|
|
188
|
+
var store = () => {
|
|
189
|
+
try {
|
|
190
|
+
return globalThis.sessionStorage ?? null;
|
|
191
|
+
} catch {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
function load(agentId) {
|
|
196
|
+
const s = store();
|
|
197
|
+
if (!s) return { views: [], flushed: 0 };
|
|
198
|
+
try {
|
|
199
|
+
const raw = s.getItem(storageKey(agentId));
|
|
200
|
+
if (!raw) return { views: [], flushed: 0 };
|
|
201
|
+
const parsed = JSON.parse(raw);
|
|
202
|
+
const views = Array.isArray(parsed?.views) ? parsed.views.filter((v) => !!v && typeof v.path === "string" && typeof v.at === "string") : [];
|
|
203
|
+
const flushedRaw = typeof parsed?.flushed === "number" && parsed.flushed >= 0 ? parsed.flushed : 0;
|
|
204
|
+
return { views, flushed: Math.min(flushedRaw, views.length) };
|
|
205
|
+
} catch {
|
|
206
|
+
return { views: [], flushed: 0 };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function save(agentId, data) {
|
|
210
|
+
const s = store();
|
|
211
|
+
if (!s) return;
|
|
212
|
+
try {
|
|
213
|
+
s.setItem(storageKey(agentId), JSON.stringify(data));
|
|
214
|
+
} catch {
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
var sanitizePath = (raw) => (raw || "/").split("?")[0].split("#")[0];
|
|
218
|
+
function recordView(agentId, path) {
|
|
219
|
+
try {
|
|
220
|
+
const p = sanitizePath(path);
|
|
221
|
+
const data = load(agentId);
|
|
222
|
+
const last = data.views[data.views.length - 1];
|
|
223
|
+
if (last && last.path === p) return;
|
|
224
|
+
data.views.push({ path: p, at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
225
|
+
if (data.views.length > MAX_BUFFER) {
|
|
226
|
+
const drop = data.views.length - MAX_BUFFER;
|
|
227
|
+
data.views = data.views.slice(drop);
|
|
228
|
+
data.flushed = Math.max(0, data.flushed - drop);
|
|
229
|
+
}
|
|
230
|
+
save(agentId, data);
|
|
231
|
+
} catch {
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
function unsentViews(agentId) {
|
|
235
|
+
try {
|
|
236
|
+
const data = load(agentId);
|
|
237
|
+
return data.views.slice(data.flushed);
|
|
238
|
+
} catch {
|
|
239
|
+
return [];
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function markFlushed(agentId, count) {
|
|
243
|
+
if (count <= 0) return;
|
|
244
|
+
try {
|
|
245
|
+
const data = load(agentId);
|
|
246
|
+
data.flushed = Math.min(data.views.length, data.flushed + count);
|
|
247
|
+
save(agentId, data);
|
|
248
|
+
} catch {
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function installSpaHooks(onNav) {
|
|
252
|
+
const originalPush = history.pushState;
|
|
253
|
+
const originalReplace = history.replaceState;
|
|
254
|
+
let pushInstalled = false;
|
|
255
|
+
let replaceInstalled = false;
|
|
256
|
+
try {
|
|
257
|
+
history.pushState = function(...args) {
|
|
258
|
+
const ret = originalPush.apply(this, args);
|
|
259
|
+
try {
|
|
260
|
+
onNav();
|
|
261
|
+
} catch {
|
|
262
|
+
}
|
|
263
|
+
return ret;
|
|
264
|
+
};
|
|
265
|
+
pushInstalled = true;
|
|
266
|
+
} catch {
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
history.replaceState = function(...args) {
|
|
270
|
+
const ret = originalReplace.apply(this, args);
|
|
271
|
+
try {
|
|
272
|
+
onNav();
|
|
273
|
+
} catch {
|
|
274
|
+
}
|
|
275
|
+
return ret;
|
|
276
|
+
};
|
|
277
|
+
replaceInstalled = true;
|
|
278
|
+
} catch {
|
|
279
|
+
}
|
|
280
|
+
const onPopState = () => {
|
|
281
|
+
try {
|
|
282
|
+
onNav();
|
|
283
|
+
} catch {
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
let popstateInstalled = false;
|
|
287
|
+
try {
|
|
288
|
+
window.addEventListener("popstate", onPopState);
|
|
289
|
+
popstateInstalled = true;
|
|
290
|
+
} catch {
|
|
291
|
+
}
|
|
292
|
+
return () => {
|
|
293
|
+
if (pushInstalled) {
|
|
294
|
+
try {
|
|
295
|
+
history.pushState = originalPush;
|
|
296
|
+
} catch {
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (replaceInstalled) {
|
|
300
|
+
try {
|
|
301
|
+
history.replaceState = originalReplace;
|
|
302
|
+
} catch {
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (popstateInstalled) {
|
|
306
|
+
try {
|
|
307
|
+
window.removeEventListener("popstate", onPopState);
|
|
308
|
+
} catch {
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
168
314
|
// src/client.ts
|
|
169
315
|
var seq = 0;
|
|
170
316
|
var uid = (p) => `${p}_${Date.now().toString(36)}_${(seq++).toString(36)}`;
|
|
317
|
+
var isSafeRelativePath = (p) => p.startsWith("/") && !p.startsWith("//");
|
|
318
|
+
var isSafeHttpUrl = (u) => {
|
|
319
|
+
try {
|
|
320
|
+
const parsed = new URL(u, window.location.origin);
|
|
321
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
322
|
+
} catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
var BUILTIN_TOOL_HANDLERS = {
|
|
327
|
+
navigate: (args) => {
|
|
328
|
+
const path = String(args.path ?? "");
|
|
329
|
+
if (!isSafeRelativePath(path)) return { ok: false, error: "unsafe navigate path" };
|
|
330
|
+
window.location.assign(path);
|
|
331
|
+
return { ok: true };
|
|
332
|
+
},
|
|
333
|
+
openUrl: (args) => {
|
|
334
|
+
const url = String(args.url ?? "");
|
|
335
|
+
if (!isSafeHttpUrl(url)) return { ok: false, error: "unsafe url" };
|
|
336
|
+
window.open(url, args.newTab === false ? "_self" : "_blank", "noopener,noreferrer");
|
|
337
|
+
return { ok: true };
|
|
338
|
+
}
|
|
339
|
+
};
|
|
171
340
|
function createChatClient(options) {
|
|
172
341
|
const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
173
342
|
const ES = options.eventSource ?? globalThis.EventSource;
|
|
@@ -187,13 +356,95 @@ function createChatClient(options) {
|
|
|
187
356
|
let closed = false;
|
|
188
357
|
let warnedProtocol = false;
|
|
189
358
|
let deviceFingerprint;
|
|
359
|
+
let refreshTimer = null;
|
|
190
360
|
const toolHandlers = /* @__PURE__ */ new Map();
|
|
361
|
+
let journeyFlushChain = Promise.resolve();
|
|
362
|
+
let journeyFlushQueued = false;
|
|
363
|
+
const flushJourney = () => {
|
|
364
|
+
if (!session) return;
|
|
365
|
+
if (journeyFlushQueued) return;
|
|
366
|
+
journeyFlushQueued = true;
|
|
367
|
+
journeyFlushChain = journeyFlushChain.then(async () => {
|
|
368
|
+
journeyFlushQueued = false;
|
|
369
|
+
if (!session) return;
|
|
370
|
+
const pending = unsentViews(options.agentId);
|
|
371
|
+
if (pending.length === 0) return;
|
|
372
|
+
const s = session;
|
|
373
|
+
try {
|
|
374
|
+
await postPageViews({ baseUrl: options.baseUrl, sessionId: s.sessionId, sessionToken: s.sessionToken, views: pending, fetchImpl });
|
|
375
|
+
markFlushed(options.agentId, pending.length);
|
|
376
|
+
} catch {
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
};
|
|
191
380
|
const clearReconnect = () => {
|
|
192
381
|
if (reconnectTimer) {
|
|
193
382
|
clearTimeout(reconnectTimer);
|
|
194
383
|
reconnectTimer = null;
|
|
195
384
|
}
|
|
196
385
|
};
|
|
386
|
+
const clearRefresh = () => {
|
|
387
|
+
if (refreshTimer) {
|
|
388
|
+
clearTimeout(refreshTimer);
|
|
389
|
+
refreshTimer = null;
|
|
390
|
+
}
|
|
391
|
+
};
|
|
392
|
+
const scheduleRefresh = () => {
|
|
393
|
+
clearRefresh();
|
|
394
|
+
if (closed || !session) return;
|
|
395
|
+
const untilExpiry = Date.parse(session.expiresAt) - Date.now();
|
|
396
|
+
const delay = Number.isNaN(untilExpiry) ? 10 * 6e4 : Math.max(5e3, untilExpiry - 6e4);
|
|
397
|
+
refreshTimer = setTimeout(async () => {
|
|
398
|
+
if (closed || !session) return;
|
|
399
|
+
try {
|
|
400
|
+
const r = await refreshSessionToken({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, fetchImpl });
|
|
401
|
+
session = { ...session, sessionToken: r.sessionToken, expiresAt: r.expiresAt };
|
|
402
|
+
persistSession(session);
|
|
403
|
+
scheduleRefresh();
|
|
404
|
+
} catch (err) {
|
|
405
|
+
const code = err?.code;
|
|
406
|
+
if (code === "session_ended" || code === "session_expired") clearStoredSession();
|
|
407
|
+
}
|
|
408
|
+
}, delay);
|
|
409
|
+
refreshTimer.unref?.();
|
|
410
|
+
};
|
|
411
|
+
const sessKey = `oyehello-chat-sess:${options.agentId}`;
|
|
412
|
+
const store2 = (() => {
|
|
413
|
+
try {
|
|
414
|
+
return globalThis.sessionStorage ?? null;
|
|
415
|
+
} catch {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
})();
|
|
419
|
+
const loadStoredSession = () => {
|
|
420
|
+
if (!store2) return null;
|
|
421
|
+
try {
|
|
422
|
+
const raw = store2.getItem(sessKey);
|
|
423
|
+
if (!raw) return null;
|
|
424
|
+
const s = JSON.parse(raw);
|
|
425
|
+
if (!s?.sessionId || !s?.sessionToken || !s?.expiresAt || !s?.agent) return null;
|
|
426
|
+
const exp = Date.parse(s.expiresAt);
|
|
427
|
+
if (Number.isNaN(exp) || Date.now() >= exp - 5e3) {
|
|
428
|
+
store2.removeItem(sessKey);
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
return s;
|
|
432
|
+
} catch {
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
const persistSession = (s) => {
|
|
437
|
+
try {
|
|
438
|
+
store2?.setItem(sessKey, JSON.stringify({ sessionId: s.sessionId, sessionToken: s.sessionToken, expiresAt: s.expiresAt, agent: s.agent }));
|
|
439
|
+
} catch {
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
const clearStoredSession = () => {
|
|
443
|
+
try {
|
|
444
|
+
store2?.removeItem(sessKey);
|
|
445
|
+
} catch {
|
|
446
|
+
}
|
|
447
|
+
};
|
|
197
448
|
const emit = (e) => {
|
|
198
449
|
for (const l of listeners) l(e);
|
|
199
450
|
};
|
|
@@ -244,7 +495,7 @@ function createChatClient(options) {
|
|
|
244
495
|
} else if (ev.type === "tool_call") {
|
|
245
496
|
if (seenCallIds.has(ev.callId)) return;
|
|
246
497
|
seenCallIds.add(ev.callId);
|
|
247
|
-
const handler = toolHandlers.get(ev.name);
|
|
498
|
+
const handler = toolHandlers.get(ev.name) ?? BUILTIN_TOOL_HANDLERS[ev.name];
|
|
248
499
|
void (async () => {
|
|
249
500
|
if (!session) return;
|
|
250
501
|
const base = { baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, callId: ev.callId, fetchImpl };
|
|
@@ -284,8 +535,10 @@ function createChatClient(options) {
|
|
|
284
535
|
} else if (ev.type === "done") {
|
|
285
536
|
closed = true;
|
|
286
537
|
clearReconnect();
|
|
538
|
+
clearRefresh();
|
|
287
539
|
es?.close();
|
|
288
540
|
es = null;
|
|
541
|
+
clearStoredSession();
|
|
289
542
|
setState("ended");
|
|
290
543
|
}
|
|
291
544
|
};
|
|
@@ -318,6 +571,7 @@ function createChatClient(options) {
|
|
|
318
571
|
reconnectTimer = setTimeout(() => {
|
|
319
572
|
openStream().catch((err) => {
|
|
320
573
|
if (err instanceof ChatApiError && err.code === "session_expired") {
|
|
574
|
+
clearStoredSession();
|
|
321
575
|
open().catch((e) => {
|
|
322
576
|
emit({ type: "error", error: e });
|
|
323
577
|
scheduleReconnect();
|
|
@@ -329,8 +583,19 @@ function createChatClient(options) {
|
|
|
329
583
|
}, delay);
|
|
330
584
|
};
|
|
331
585
|
const open = async () => {
|
|
586
|
+
const resumed = loadStoredSession();
|
|
587
|
+
if (resumed) {
|
|
588
|
+
session = resumed;
|
|
589
|
+
emit({ type: "agent", agent: resumed.agent });
|
|
590
|
+
await openStream();
|
|
591
|
+
scheduleRefresh();
|
|
592
|
+
flushJourney();
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
332
595
|
if (deviceFingerprint === void 0) deviceFingerprint = await computeDeviceFingerprint().catch(() => null);
|
|
333
|
-
|
|
596
|
+
const advertisedTools = /* @__PURE__ */ new Set([...toolHandlers.keys(), ...Object.keys(BUILTIN_TOOL_HANDLERS)]);
|
|
597
|
+
session = await openSession({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, user: options.user, clientTools: Array.from(advertisedTools), fingerprint: deviceFingerprint ?? void 0, fetchImpl });
|
|
598
|
+
persistSession(session);
|
|
334
599
|
if (!warnedProtocol && typeof session.protocol === "number" && session.protocol !== CHAT_PROTOCOL_VERSION) {
|
|
335
600
|
warnedProtocol = true;
|
|
336
601
|
try {
|
|
@@ -340,13 +605,11 @@ function createChatClient(options) {
|
|
|
340
605
|
}
|
|
341
606
|
emit({ type: "agent", agent: session.agent });
|
|
342
607
|
await openStream();
|
|
608
|
+
scheduleRefresh();
|
|
609
|
+
flushJourney();
|
|
343
610
|
};
|
|
344
611
|
const uploadFiles = async (files) => {
|
|
345
|
-
return Promise.all(files.map(
|
|
346
|
-
const { storageRef, uploadUrl } = await presignAttachment({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, contentType: file.type, fetchImpl });
|
|
347
|
-
await uploadToPresigned(uploadUrl, file, fetchImpl);
|
|
348
|
-
return { storageRef, contentType: file.type, name: file.name };
|
|
349
|
-
}));
|
|
612
|
+
return Promise.all(files.map((file) => uploadAttachment({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, file, fetchImpl })));
|
|
350
613
|
};
|
|
351
614
|
return {
|
|
352
615
|
getMessages: () => messages.slice(),
|
|
@@ -365,6 +628,13 @@ function createChatClient(options) {
|
|
|
365
628
|
getWidgetConfig() {
|
|
366
629
|
return getWidgetConfig({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, fetchImpl });
|
|
367
630
|
},
|
|
631
|
+
recordPageView(path) {
|
|
632
|
+
try {
|
|
633
|
+
recordView(options.agentId, path);
|
|
634
|
+
flushJourney();
|
|
635
|
+
} catch {
|
|
636
|
+
}
|
|
637
|
+
},
|
|
368
638
|
async start() {
|
|
369
639
|
if (state === "opening" || state === "ready") return;
|
|
370
640
|
closed = false;
|
|
@@ -410,6 +680,7 @@ function createChatClient(options) {
|
|
|
410
680
|
} catch (err) {
|
|
411
681
|
if (err instanceof ChatApiError && err.code === "session_expired") {
|
|
412
682
|
try {
|
|
683
|
+
clearStoredSession();
|
|
413
684
|
await open();
|
|
414
685
|
const freshAttachments = fileList.length ? await uploadFiles(fileList) : [];
|
|
415
686
|
await postMessage({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, text, attachments: freshAttachments, fetchImpl });
|
|
@@ -421,18 +692,29 @@ function createChatClient(options) {
|
|
|
421
692
|
}
|
|
422
693
|
}
|
|
423
694
|
},
|
|
695
|
+
detach() {
|
|
696
|
+
closed = true;
|
|
697
|
+
clearReconnect();
|
|
698
|
+
clearRefresh();
|
|
699
|
+
es?.close();
|
|
700
|
+
es = null;
|
|
701
|
+
},
|
|
424
702
|
async end() {
|
|
425
703
|
closed = true;
|
|
426
704
|
clearReconnect();
|
|
705
|
+
clearRefresh();
|
|
706
|
+
clearStoredSession();
|
|
427
707
|
if (session) await endSession({ baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, fetchImpl });
|
|
428
708
|
es?.close();
|
|
429
709
|
es = null;
|
|
430
710
|
setState("ended");
|
|
431
711
|
},
|
|
432
712
|
reset() {
|
|
713
|
+
clearStoredSession();
|
|
433
714
|
es?.close();
|
|
434
715
|
es = null;
|
|
435
716
|
clearReconnect();
|
|
717
|
+
clearRefresh();
|
|
436
718
|
session = null;
|
|
437
719
|
streamingId = null;
|
|
438
720
|
handoffState = "ai";
|
|
@@ -523,6 +805,10 @@ var STYLES = `
|
|
|
523
805
|
--oc-glass-border: rgba(9,13,20,0.12);
|
|
524
806
|
|
|
525
807
|
position: fixed; right: 24px; bottom: 24px; z-index: 2147483000;
|
|
808
|
+
/* Explicit display/visibility so a host-page reset or consent/privacy tool that blanket-hides
|
|
809
|
+
* body children (oyehello-chat { display:none }) can't silently kill the widget. Also pinned
|
|
810
|
+
* inline with !important at mount (see element.ts) to beat an external !important rule. */
|
|
811
|
+
display: block; visibility: visible; opacity: 1;
|
|
526
812
|
font-family: 'Geist', system-ui, -apple-system, 'Helvetica Neue', sans-serif;
|
|
527
813
|
-webkit-font-smoothing: antialiased;
|
|
528
814
|
}
|
|
@@ -562,7 +848,7 @@ var STYLES = `
|
|
|
562
848
|
.launcher:hover { transform: scale(1.06); }
|
|
563
849
|
.launcher:active { transform: scale(0.93); }
|
|
564
850
|
.ring { pointer-events: none; position: absolute; inset: -1px; border-radius: var(--oc-r-full); border: 2px solid var(--oc-ac); opacity: 0; }
|
|
565
|
-
.oc.ring:not(.open) .ring { animation: w-ring 2.4s ease-out 1.1s 3; }
|
|
851
|
+
.oc.show-ring:not(.open) .ring { animation: w-ring 2.4s ease-out 1.1s 3; }
|
|
566
852
|
.ic-chat, .ic-close { position: absolute; display: flex; align-items: center; justify-content: center; transition: all .3s var(--oc-bounce); }
|
|
567
853
|
.ic-chat { width: 28px; height: 28px; opacity: 1; transform: rotate(0) scale(1); }
|
|
568
854
|
.ic-close { opacity: 0; transform: rotate(90deg) scale(0.6); }
|
|
@@ -584,7 +870,7 @@ var STYLES = `
|
|
|
584
870
|
transition: opacity .3s var(--oc-ease), transform .38s var(--oc-bounce);
|
|
585
871
|
}
|
|
586
872
|
.oc.has-teaser .teaser { display: flex; }
|
|
587
|
-
.oc.teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
|
|
873
|
+
.oc.show-teaser:not(.open) .teaser { opacity: 1; transform: none; pointer-events: auto; }
|
|
588
874
|
.teaser-btn {
|
|
589
875
|
max-width: 250px; display: flex; align-items: center; gap: 10px; padding: 11px 15px;
|
|
590
876
|
border-radius: 16px 16px 5px 16px; border: 1px solid var(--oc-glass-border); background: var(--oc-bg);
|
|
@@ -681,6 +967,7 @@ var STYLES = `
|
|
|
681
967
|
|
|
682
968
|
/* \u2500\u2500 Composer \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\u2500\u2500\u2500\u2500 */
|
|
683
969
|
.composer { flex: none; display: flex; flex-direction: column; gap: 9px; padding: 12px 14px 13px; background: var(--oc-bg); }
|
|
970
|
+
.composer[hidden] { display: none; }
|
|
684
971
|
.field { display: flex; flex-direction: column; gap: 11px; padding: 13px 14px 11px; border-radius: 18px; border: 1px solid var(--oc-field-border); background: var(--oc-field-bg); transition: border-color .18s var(--oc-ease); }
|
|
685
972
|
.oc.focus .field { border-color: var(--oc-ac); }
|
|
686
973
|
.input { width: 100%; border: none; background: transparent; color: var(--oc-fg); font-size: 13.5px; line-height: 1.4; outline: none; padding: 0; resize: none; max-height: 108px; font-family: inherit; }
|
|
@@ -921,10 +1208,14 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
921
1208
|
// widget-config resolved (so we know online/offline before starting)
|
|
922
1209
|
idleTimer = null;
|
|
923
1210
|
idleReset = null;
|
|
1211
|
+
// Uninstaller for the SPA history-navigation hooks (journey tracking), installed in
|
|
1212
|
+
// connectedCallback and torn down in disconnectedCallback alongside everything else.
|
|
1213
|
+
uninstallSpaHooks = null;
|
|
924
1214
|
// ── shadow refs ──
|
|
925
1215
|
oc;
|
|
926
1216
|
bodyEl;
|
|
927
1217
|
textareaEl;
|
|
1218
|
+
composerEl;
|
|
928
1219
|
fileEl;
|
|
929
1220
|
titleEl;
|
|
930
1221
|
statusEl;
|
|
@@ -946,7 +1237,16 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
946
1237
|
this.pendingTools = {};
|
|
947
1238
|
}
|
|
948
1239
|
this.off = this.client.on((e) => this.onClientEvent(e));
|
|
1240
|
+
recordView(agentId, location.pathname);
|
|
1241
|
+
try {
|
|
1242
|
+
this.uninstallSpaHooks = installSpaHooks(() => this.client?.recordPageView(location.pathname));
|
|
1243
|
+
} catch {
|
|
1244
|
+
this.uninstallSpaHooks = null;
|
|
1245
|
+
}
|
|
949
1246
|
this.renderShell();
|
|
1247
|
+
this.style.setProperty("display", "block", "important");
|
|
1248
|
+
this.style.setProperty("visibility", "visible", "important");
|
|
1249
|
+
this.style.setProperty("opacity", "1", "important");
|
|
950
1250
|
this.renderMessages();
|
|
951
1251
|
this.updateHeader();
|
|
952
1252
|
this.updateStatus();
|
|
@@ -963,7 +1263,7 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
963
1263
|
}
|
|
964
1264
|
this.updateClasses(true);
|
|
965
1265
|
this.timers.push(setTimeout(() => {
|
|
966
|
-
this.oc?.classList.remove("ring");
|
|
1266
|
+
this.oc?.classList.remove("show-ring");
|
|
967
1267
|
}, 6e3));
|
|
968
1268
|
this.armIdleTeaser();
|
|
969
1269
|
}
|
|
@@ -1050,8 +1350,10 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1050
1350
|
}
|
|
1051
1351
|
disconnectedCallback() {
|
|
1052
1352
|
this.off?.();
|
|
1053
|
-
|
|
1353
|
+
this.client?.detach();
|
|
1054
1354
|
this.teardownIdle();
|
|
1355
|
+
this.uninstallSpaHooks?.();
|
|
1356
|
+
this.uninstallSpaHooks = null;
|
|
1055
1357
|
for (const t of this.timers) clearTimeout(t);
|
|
1056
1358
|
for (const url of this.imagePreviewCache.values()) {
|
|
1057
1359
|
if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
|
|
@@ -1129,6 +1431,7 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1129
1431
|
this.oc = root.querySelector(".oc");
|
|
1130
1432
|
this.bodyEl = root.querySelector(".body");
|
|
1131
1433
|
this.textareaEl = root.querySelector("textarea.input");
|
|
1434
|
+
this.composerEl = root.querySelector(".composer");
|
|
1132
1435
|
this.fileEl = root.querySelector('input[type="file"]');
|
|
1133
1436
|
this.titleEl = root.querySelector(".hd-title");
|
|
1134
1437
|
this.statusEl = root.querySelector(".hd-status .t");
|
|
@@ -1187,18 +1490,22 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1187
1490
|
this.shadowRoot.innerHTML = `<div style="position:fixed;right:16px;bottom:16px;color:#b91c1c;font:12px system-ui;background:#fef2f2;border:1px solid #fecaca;padding:8px 12px;border-radius:8px">${escapeHtml(msg)}</div>`;
|
|
1188
1491
|
}
|
|
1189
1492
|
// ── State-class sync ──────────────────────────────────────────────────────────────────────────
|
|
1493
|
+
// State classes on `.oc` MUST NOT share a name with any element class that has a bare base rule
|
|
1494
|
+
// (e.g. `.teaser`, `.ring`): the bare selector would also match `.oc.<state>` and, tying `.oc`'s
|
|
1495
|
+
// own rule on specificity, win on source order — e.g. `.teaser{display:none}` would hide the whole
|
|
1496
|
+
// widget. Hence the `show-` prefix on the teaser/ring state classes.
|
|
1190
1497
|
updateClasses(initialRing = false) {
|
|
1191
1498
|
if (!this.oc) return;
|
|
1192
1499
|
const c = this.oc.classList;
|
|
1193
1500
|
c.toggle("open", this.uiOpen);
|
|
1194
1501
|
c.toggle("max", this.uiMax);
|
|
1195
1502
|
c.toggle("emoji", this.emojiOpen);
|
|
1196
|
-
c.toggle("teaser", this.teaserActive);
|
|
1503
|
+
c.toggle("show-teaser", this.teaserActive);
|
|
1197
1504
|
c.toggle("away", this.away);
|
|
1198
1505
|
c.toggle("focus", this.focused);
|
|
1199
1506
|
c.toggle("ready", (this.textareaEl?.value.trim().length ?? 0) > 0);
|
|
1200
1507
|
c.toggle("unread", this.unseen > 0);
|
|
1201
|
-
if (initialRing) c.add("ring");
|
|
1508
|
+
if (initialRing) c.add("show-ring");
|
|
1202
1509
|
const badge = this.oc.querySelector(".badge");
|
|
1203
1510
|
if (badge) badge.textContent = this.unseen > 9 ? "9+" : String(this.unseen);
|
|
1204
1511
|
const launcher = this.oc.querySelector(".launcher");
|
|
@@ -1267,6 +1574,7 @@ var OyehelloChatElement = class extends HTMLElement {
|
|
|
1267
1574
|
if (!this.textareaEl) return;
|
|
1268
1575
|
const disabled = this.state !== "ready" || this.offline;
|
|
1269
1576
|
this.textareaEl.disabled = disabled;
|
|
1577
|
+
if (this.composerEl) this.composerEl.hidden = this.state === "ended" || this.offline;
|
|
1270
1578
|
const handoff = HANDOFF_STATUS[this.handoffState];
|
|
1271
1579
|
const text = this.offline ? "Offline" : this.state === "opening" ? "Connecting\u2026" : handoff ?? (this.getAttribute("status") ?? DEFAULT_STATUS);
|
|
1272
1580
|
if (this.statusEl) this.statusEl.textContent = text;
|