@kibee/sdk 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/dist/index.d.mts +465 -0
- package/dist/index.d.ts +465 -0
- package/dist/index.js +1831 -0
- package/dist/index.mjs +1799 -0
- package/package.json +26 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1799 @@
|
|
|
1
|
+
// src/api/KiBeeApiClient.ts
|
|
2
|
+
var KiBeeApiClient = class {
|
|
3
|
+
constructor(baseUrl, options = {}) {
|
|
4
|
+
this.baseUrl = baseUrl;
|
|
5
|
+
this.options = options;
|
|
6
|
+
}
|
|
7
|
+
baseUrl;
|
|
8
|
+
options;
|
|
9
|
+
async fetchFlow(id) {
|
|
10
|
+
return this.request(`/flows/${id}`);
|
|
11
|
+
}
|
|
12
|
+
async startSession(input) {
|
|
13
|
+
return this.request("/sessions", {
|
|
14
|
+
method: "POST",
|
|
15
|
+
body: JSON.stringify(input)
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
async resolveIntent(input) {
|
|
19
|
+
return this.request("/intent/resolve", {
|
|
20
|
+
method: "POST",
|
|
21
|
+
body: JSON.stringify(input)
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
async resolveRecovery(input) {
|
|
25
|
+
return this.request("/recovery/resolve", {
|
|
26
|
+
method: "POST",
|
|
27
|
+
body: JSON.stringify(input)
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async trackEvent(event) {
|
|
31
|
+
await this.request("/events", {
|
|
32
|
+
method: "POST",
|
|
33
|
+
body: JSON.stringify(event)
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Unified visitor poll. One round-trip pulls messages + pending pushes
|
|
38
|
+
* + mode + friction. Replaces the previous fetchVisitorMessages +
|
|
39
|
+
* /admin/sessions/:id/pending pair for visitors using publishable-key
|
|
40
|
+
* auth. The legacy fetchVisitorMessages remains for shipped SDK
|
|
41
|
+
* builds that haven't been updated yet.
|
|
42
|
+
*/
|
|
43
|
+
async fetchVisitorSync(sessionId, since) {
|
|
44
|
+
const params = new URLSearchParams({ sessionId });
|
|
45
|
+
if (since) params.set("since", since);
|
|
46
|
+
return this.request(`/v1/visitor/sync?${params.toString()}`);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Legacy split poll — kept for backward compat. Prefer fetchVisitorSync.
|
|
50
|
+
*/
|
|
51
|
+
async fetchVisitorMessages(sessionId, since) {
|
|
52
|
+
const params = new URLSearchParams({ sessionId });
|
|
53
|
+
if (since) params.set("since", since);
|
|
54
|
+
return this.request(`/v1/visitor/messages?${params.toString()}`);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Visitor (or distress detector) requests a human. Sets
|
|
58
|
+
* `requested_human_at`, generates an AI summary for the agent, and lights
|
|
59
|
+
* up the "Needs help" badge in admin Hive.
|
|
60
|
+
*/
|
|
61
|
+
async escalateToHuman(sessionId) {
|
|
62
|
+
await this.request("/v1/visitor/escalate", {
|
|
63
|
+
method: "POST",
|
|
64
|
+
body: JSON.stringify({ sessionId })
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Open the visitor's per-session SSE stream. EventSource can't pass auth
|
|
69
|
+
* headers, so the publishable key + websiteId ride along as query params.
|
|
70
|
+
* Caller wires `onmessage` and is responsible for re-opening on disconnect.
|
|
71
|
+
*
|
|
72
|
+
* Returns null when EventSource isn't available (older browsers / strict
|
|
73
|
+
* proxies) — caller falls back to polling `fetchVisitorMessages`.
|
|
74
|
+
*/
|
|
75
|
+
openVisitorStream(sessionId) {
|
|
76
|
+
if (typeof EventSource === "undefined") return null;
|
|
77
|
+
const websiteId = this.options.websiteId;
|
|
78
|
+
const token = this.options.publicKey;
|
|
79
|
+
if (!websiteId || !token) return null;
|
|
80
|
+
const params = new URLSearchParams({
|
|
81
|
+
sessionId,
|
|
82
|
+
websiteId,
|
|
83
|
+
// The token query param is necessary because EventSource doesn't accept
|
|
84
|
+
// headers. The query string is logged on the server but the token never
|
|
85
|
+
// shows up in `logRequest` (the visitor stream is special-cased there).
|
|
86
|
+
token
|
|
87
|
+
});
|
|
88
|
+
return new EventSource(`${this.baseUrl}/v1/visitor/stream?${params.toString()}`);
|
|
89
|
+
}
|
|
90
|
+
buildHeaders(extra) {
|
|
91
|
+
const headers = {
|
|
92
|
+
"content-type": "application/json",
|
|
93
|
+
...extra
|
|
94
|
+
};
|
|
95
|
+
if (this.options.websiteId && this.options.publicKey) {
|
|
96
|
+
headers["x-kibee-website-id"] = this.options.websiteId;
|
|
97
|
+
headers.authorization = `Bearer ${this.options.publicKey}`;
|
|
98
|
+
} else if (this.options.legacyApiKey) {
|
|
99
|
+
headers["x-kibee-key"] = this.options.legacyApiKey;
|
|
100
|
+
}
|
|
101
|
+
return headers;
|
|
102
|
+
}
|
|
103
|
+
async request(path, init = {}) {
|
|
104
|
+
const headers = this.buildHeaders(init.headers);
|
|
105
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
106
|
+
...init,
|
|
107
|
+
headers,
|
|
108
|
+
cache: "no-store"
|
|
109
|
+
});
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
const body = await response.text();
|
|
112
|
+
throw new Error(body || `Request failed: ${response.status}`);
|
|
113
|
+
}
|
|
114
|
+
if (response.status === 204) {
|
|
115
|
+
return void 0;
|
|
116
|
+
}
|
|
117
|
+
return await response.json();
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// src/bridge/BridgeClient.ts
|
|
122
|
+
import {
|
|
123
|
+
isBridgeEnvelope
|
|
124
|
+
} from "@kibee/contracts";
|
|
125
|
+
var BridgeClient = class {
|
|
126
|
+
ws = null;
|
|
127
|
+
handlers = /* @__PURE__ */ new Map();
|
|
128
|
+
opts;
|
|
129
|
+
state = "disconnected";
|
|
130
|
+
heartbeatTimer;
|
|
131
|
+
lastPongAt = 0;
|
|
132
|
+
constructor(opts) {
|
|
133
|
+
this.opts = opts;
|
|
134
|
+
}
|
|
135
|
+
on(eventType, handler) {
|
|
136
|
+
const list = this.handlers.get(eventType) ?? [];
|
|
137
|
+
list.push(handler);
|
|
138
|
+
this.handlers.set(eventType, list);
|
|
139
|
+
return () => {
|
|
140
|
+
const next = (this.handlers.get(eventType) ?? []).filter(
|
|
141
|
+
(h) => h !== handler
|
|
142
|
+
);
|
|
143
|
+
this.handlers.set(eventType, next);
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
async connect() {
|
|
147
|
+
const { token } = await this.opts.tokenFetcher();
|
|
148
|
+
const urlWithToken = `${this.opts.bridgeUrl}?token=${encodeURIComponent(token)}`;
|
|
149
|
+
const ws = new WebSocket(urlWithToken);
|
|
150
|
+
this.ws = ws;
|
|
151
|
+
await new Promise((resolve, reject) => {
|
|
152
|
+
ws.addEventListener("open", () => resolve());
|
|
153
|
+
ws.addEventListener("error", (e) => reject(e));
|
|
154
|
+
});
|
|
155
|
+
ws.addEventListener("message", (e) => {
|
|
156
|
+
this.lastPongAt = Date.now();
|
|
157
|
+
let parsed;
|
|
158
|
+
try {
|
|
159
|
+
parsed = JSON.parse(typeof e.data === "string" ? e.data : "");
|
|
160
|
+
} catch {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (!isBridgeEnvelope(parsed)) return;
|
|
164
|
+
const list = this.handlers.get(parsed.type) ?? [];
|
|
165
|
+
list.forEach((fn) => fn(parsed));
|
|
166
|
+
});
|
|
167
|
+
this.send({
|
|
168
|
+
v: 1,
|
|
169
|
+
source: this.opts.source,
|
|
170
|
+
target: "broadcast",
|
|
171
|
+
tenantId: null,
|
|
172
|
+
workspaceId: null,
|
|
173
|
+
visitorId: this.opts.visitorId,
|
|
174
|
+
sessionId: null,
|
|
175
|
+
type: "presence:hello",
|
|
176
|
+
ts: Date.now(),
|
|
177
|
+
payload: {}
|
|
178
|
+
});
|
|
179
|
+
this.startHeartbeat();
|
|
180
|
+
}
|
|
181
|
+
startHeartbeat() {
|
|
182
|
+
if (this.heartbeatTimer) {
|
|
183
|
+
clearInterval(this.heartbeatTimer);
|
|
184
|
+
this.heartbeatTimer = void 0;
|
|
185
|
+
}
|
|
186
|
+
this.lastPongAt = Date.now();
|
|
187
|
+
this.heartbeatTimer = setInterval(() => {
|
|
188
|
+
if (this.ws?.readyState === 1) {
|
|
189
|
+
try {
|
|
190
|
+
this.ws.send(
|
|
191
|
+
JSON.stringify({
|
|
192
|
+
v: 1,
|
|
193
|
+
source: this.opts.source,
|
|
194
|
+
target: "broadcast",
|
|
195
|
+
tenantId: null,
|
|
196
|
+
workspaceId: null,
|
|
197
|
+
visitorId: this.opts.visitorId,
|
|
198
|
+
sessionId: null,
|
|
199
|
+
type: "presence:hello",
|
|
200
|
+
ts: Date.now(),
|
|
201
|
+
payload: { ping: true }
|
|
202
|
+
})
|
|
203
|
+
);
|
|
204
|
+
} catch {
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (Date.now() - this.lastPongAt > 12e3) {
|
|
208
|
+
this.markDegraded();
|
|
209
|
+
}
|
|
210
|
+
}, 1e4);
|
|
211
|
+
}
|
|
212
|
+
markDegraded() {
|
|
213
|
+
if (this.heartbeatTimer) {
|
|
214
|
+
clearInterval(this.heartbeatTimer);
|
|
215
|
+
this.heartbeatTimer = void 0;
|
|
216
|
+
}
|
|
217
|
+
if (typeof window !== "undefined") {
|
|
218
|
+
window.dispatchEvent(new CustomEvent("kibee-bridge-degraded"));
|
|
219
|
+
}
|
|
220
|
+
this.state = "disconnected";
|
|
221
|
+
this.ws?.close();
|
|
222
|
+
}
|
|
223
|
+
send(envelope) {
|
|
224
|
+
if (!this.ws) throw new Error("BridgeClient: not connected");
|
|
225
|
+
const OPEN = this.ws.constructor.OPEN;
|
|
226
|
+
if (this.ws.readyState !== OPEN) return;
|
|
227
|
+
this.ws.send(JSON.stringify(envelope));
|
|
228
|
+
}
|
|
229
|
+
close() {
|
|
230
|
+
if (this.heartbeatTimer) {
|
|
231
|
+
clearInterval(this.heartbeatTimer);
|
|
232
|
+
this.heartbeatTimer = void 0;
|
|
233
|
+
}
|
|
234
|
+
if (!this.ws) return;
|
|
235
|
+
try {
|
|
236
|
+
this.send({
|
|
237
|
+
v: 1,
|
|
238
|
+
source: this.opts.source,
|
|
239
|
+
target: "broadcast",
|
|
240
|
+
tenantId: null,
|
|
241
|
+
workspaceId: null,
|
|
242
|
+
visitorId: this.opts.visitorId,
|
|
243
|
+
sessionId: null,
|
|
244
|
+
type: "presence:bye",
|
|
245
|
+
ts: Date.now(),
|
|
246
|
+
payload: {}
|
|
247
|
+
});
|
|
248
|
+
} catch {
|
|
249
|
+
}
|
|
250
|
+
this.ws.close();
|
|
251
|
+
this.ws = null;
|
|
252
|
+
this.state = "disconnected";
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Wraps `connect()` with exponential backoff. Defaults: 10 attempts,
|
|
256
|
+
* 1s base, 30s cap (delays: 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...). Adds
|
|
257
|
+
* 0–20% jitter per attempt.
|
|
258
|
+
*
|
|
259
|
+
* On success, `state` is `connected` and the method resolves.
|
|
260
|
+
* After `maxAttempts` failures, `state` is `failed` and the last error
|
|
261
|
+
* is re-thrown.
|
|
262
|
+
*
|
|
263
|
+
* `connect()` itself remains a single-attempt method so existing callers
|
|
264
|
+
* are unaffected.
|
|
265
|
+
*/
|
|
266
|
+
async connectWithReconnect(opts = {}) {
|
|
267
|
+
const max = opts.maxAttempts ?? 10;
|
|
268
|
+
const base = opts.baseDelayMs ?? 1e3;
|
|
269
|
+
const cap = opts.capDelayMs ?? 3e4;
|
|
270
|
+
let attempt = 0;
|
|
271
|
+
while (true) {
|
|
272
|
+
try {
|
|
273
|
+
this.state = "connecting";
|
|
274
|
+
await this.connect();
|
|
275
|
+
this.state = "connected";
|
|
276
|
+
return;
|
|
277
|
+
} catch (err) {
|
|
278
|
+
attempt += 1;
|
|
279
|
+
if (attempt >= max) {
|
|
280
|
+
this.state = "failed";
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
const delay = Math.min(cap, base * Math.pow(2, attempt - 1));
|
|
284
|
+
const jitter = delay * 0.2 * Math.random();
|
|
285
|
+
await new Promise((r) => setTimeout(r, delay + jitter));
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
// src/controllers/BookmarkMemory.ts
|
|
292
|
+
var BookmarkMemory = class {
|
|
293
|
+
storageKey = "kibee:bookmarks";
|
|
294
|
+
list() {
|
|
295
|
+
return this.read();
|
|
296
|
+
}
|
|
297
|
+
create(input) {
|
|
298
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
299
|
+
const bookmark = {
|
|
300
|
+
id: this.generateId(),
|
|
301
|
+
kind: input.kind,
|
|
302
|
+
label: input.label,
|
|
303
|
+
note: input.note,
|
|
304
|
+
pageUrl: window.location.href,
|
|
305
|
+
pageTitle: document.title,
|
|
306
|
+
target: input.target,
|
|
307
|
+
metadata: input.metadata,
|
|
308
|
+
createdAt: now,
|
|
309
|
+
updatedAt: now
|
|
310
|
+
};
|
|
311
|
+
const current = this.read();
|
|
312
|
+
current.push(bookmark);
|
|
313
|
+
this.write(current);
|
|
314
|
+
return bookmark;
|
|
315
|
+
}
|
|
316
|
+
update(id, patch) {
|
|
317
|
+
const current = this.read();
|
|
318
|
+
const index = current.findIndex((x) => x.id === id);
|
|
319
|
+
if (index === -1) return null;
|
|
320
|
+
current[index] = {
|
|
321
|
+
...current[index],
|
|
322
|
+
...patch,
|
|
323
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
324
|
+
};
|
|
325
|
+
this.write(current);
|
|
326
|
+
return current[index];
|
|
327
|
+
}
|
|
328
|
+
remove(id) {
|
|
329
|
+
const current = this.read().filter((x) => x.id !== id);
|
|
330
|
+
this.write(current);
|
|
331
|
+
}
|
|
332
|
+
findByPage(url = window.location.href) {
|
|
333
|
+
return this.read().filter((x) => x.pageUrl === url);
|
|
334
|
+
}
|
|
335
|
+
read() {
|
|
336
|
+
try {
|
|
337
|
+
const raw = localStorage.getItem(this.storageKey);
|
|
338
|
+
if (!raw) return [];
|
|
339
|
+
const parsed = JSON.parse(raw);
|
|
340
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
341
|
+
} catch {
|
|
342
|
+
return [];
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
write(value) {
|
|
346
|
+
localStorage.setItem(this.storageKey, JSON.stringify(value));
|
|
347
|
+
}
|
|
348
|
+
generateId() {
|
|
349
|
+
return `fbm_${Math.random().toString(36).slice(2, 10)}`;
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// src/controllers/FlowController.ts
|
|
354
|
+
var FlowController = class {
|
|
355
|
+
constructor(bee, locator, highlight, bookmarks = new BookmarkMemory(), onCommand) {
|
|
356
|
+
this.bee = bee;
|
|
357
|
+
this.locator = locator;
|
|
358
|
+
this.highlight = highlight;
|
|
359
|
+
this.bookmarks = bookmarks;
|
|
360
|
+
this.onCommand = onCommand;
|
|
361
|
+
}
|
|
362
|
+
bee;
|
|
363
|
+
locator;
|
|
364
|
+
highlight;
|
|
365
|
+
bookmarks;
|
|
366
|
+
onCommand;
|
|
367
|
+
async init() {
|
|
368
|
+
this.highlight.mount();
|
|
369
|
+
await this.bee.dock();
|
|
370
|
+
}
|
|
371
|
+
async runFlow(flow) {
|
|
372
|
+
await this.bee.undock();
|
|
373
|
+
if (flow.introMessage) {
|
|
374
|
+
this.bee.speak(flow.introMessage);
|
|
375
|
+
}
|
|
376
|
+
for (const command of flow.steps) {
|
|
377
|
+
this.onCommand?.(command, "started");
|
|
378
|
+
const success = await this.execute(command);
|
|
379
|
+
this.onCommand?.(
|
|
380
|
+
command,
|
|
381
|
+
success ? "completed" : "missing_target"
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Interactive step-through mode: bee flies to each `fly_to` step and shows
|
|
387
|
+
* prev / next buttons in the tooltip so the user controls the pace.
|
|
388
|
+
*/
|
|
389
|
+
async runInteractiveFlow(flow) {
|
|
390
|
+
const guidedSteps = flow.steps.filter(
|
|
391
|
+
(s) => s.type === "fly_to"
|
|
392
|
+
);
|
|
393
|
+
if (guidedSteps.length === 0) {
|
|
394
|
+
await this.runFlow(flow);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
await this.bee.undock();
|
|
398
|
+
const showStep = async (index) => {
|
|
399
|
+
const step = guidedSteps[index];
|
|
400
|
+
const isFirst = index === 0;
|
|
401
|
+
const isLast = index === guidedSteps.length - 1;
|
|
402
|
+
await this.guideTo(
|
|
403
|
+
step.target,
|
|
404
|
+
step.message,
|
|
405
|
+
step.highlight,
|
|
406
|
+
step.moveTo,
|
|
407
|
+
{
|
|
408
|
+
onPrev: isFirst ? null : () => {
|
|
409
|
+
void showStep(index - 1);
|
|
410
|
+
},
|
|
411
|
+
onNext: isLast ? () => {
|
|
412
|
+
this.clearFocus();
|
|
413
|
+
} : () => {
|
|
414
|
+
void showStep(index + 1);
|
|
415
|
+
},
|
|
416
|
+
stepLabel: `${index + 1} / ${guidedSteps.length}`
|
|
417
|
+
}
|
|
418
|
+
);
|
|
419
|
+
};
|
|
420
|
+
await showStep(0);
|
|
421
|
+
}
|
|
422
|
+
clearFocus() {
|
|
423
|
+
this.highlight.hide();
|
|
424
|
+
this.bee.hideMessage();
|
|
425
|
+
this.bee.hideActions();
|
|
426
|
+
this.bee.setState("idle");
|
|
427
|
+
void this.bee.dock();
|
|
428
|
+
}
|
|
429
|
+
async createHoneyBookmark(label, target, note) {
|
|
430
|
+
return this.createBookmark("honey", label, target, note);
|
|
431
|
+
}
|
|
432
|
+
async createStingBookmark(label, target, note) {
|
|
433
|
+
return this.createBookmark("sting", label, target, note);
|
|
434
|
+
}
|
|
435
|
+
listBookmarks() {
|
|
436
|
+
return this.bookmarks.list();
|
|
437
|
+
}
|
|
438
|
+
getPageBookmarks() {
|
|
439
|
+
return this.bookmarks.findByPage();
|
|
440
|
+
}
|
|
441
|
+
async revisitBookmark(bookmark) {
|
|
442
|
+
const kindMessage = bookmark.kind === "honey" ? `Here\u2019s your honey mark: ${bookmark.label}` : `Here\u2019s your sting mark: ${bookmark.label}`;
|
|
443
|
+
await this.guideTo(
|
|
444
|
+
bookmark.target,
|
|
445
|
+
bookmark.note ? `${kindMessage}. ${bookmark.note}` : kindMessage,
|
|
446
|
+
bookmark.kind === "honey" ? "soft" : "ring"
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
removeBookmark(id) {
|
|
450
|
+
this.bookmarks.remove(id);
|
|
451
|
+
}
|
|
452
|
+
async execute(command) {
|
|
453
|
+
switch (command.type) {
|
|
454
|
+
case "speak":
|
|
455
|
+
this.bee.speak(command.message);
|
|
456
|
+
return true;
|
|
457
|
+
case "celebrate":
|
|
458
|
+
this.bee.setState("celebrating");
|
|
459
|
+
if (command.message) {
|
|
460
|
+
this.bee.speak(command.message);
|
|
461
|
+
}
|
|
462
|
+
return true;
|
|
463
|
+
case "end_flow":
|
|
464
|
+
if (command.message) {
|
|
465
|
+
this.bee.speak(command.message);
|
|
466
|
+
}
|
|
467
|
+
if (command.status === "completed") {
|
|
468
|
+
this.bee.setState("celebrating");
|
|
469
|
+
await this.finishAndDock(860);
|
|
470
|
+
} else {
|
|
471
|
+
this.bee.setState("idle");
|
|
472
|
+
await this.bee.dock();
|
|
473
|
+
}
|
|
474
|
+
this.highlight.hide();
|
|
475
|
+
return true;
|
|
476
|
+
case "highlight": {
|
|
477
|
+
const resolved = this.locator.resolve(command.target);
|
|
478
|
+
if (!resolved) {
|
|
479
|
+
this.notifyMissingTarget();
|
|
480
|
+
return false;
|
|
481
|
+
}
|
|
482
|
+
this.highlight.show(resolved.rect, command.variant ?? "pulse");
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
case "fly_to":
|
|
486
|
+
return this.guideTo(command.target, command.message, command.highlight, command.moveTo);
|
|
487
|
+
case "wait_for_click": {
|
|
488
|
+
const resolved = this.locator.resolve(command.target);
|
|
489
|
+
if (!resolved) {
|
|
490
|
+
this.notifyMissingTarget();
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
await this.waitForClick(resolved.element, command.timeoutMs ?? 2e4);
|
|
494
|
+
return true;
|
|
495
|
+
}
|
|
496
|
+
case "wait_for_input": {
|
|
497
|
+
const resolved = this.locator.resolve(command.target);
|
|
498
|
+
if (!resolved) {
|
|
499
|
+
this.notifyMissingTarget();
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
await this.waitForInput(resolved.element, command.timeoutMs ?? 2e4);
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
default:
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
async createBookmark(kind, label, target, note) {
|
|
510
|
+
const resolved = this.locator.resolve(target);
|
|
511
|
+
if (!resolved) {
|
|
512
|
+
this.bee.setState("thinking");
|
|
513
|
+
this.bee.speak("I couldn\u2019t save that spot because I couldn\u2019t find it.");
|
|
514
|
+
void this.finishAndDock(900);
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
const bookmark = this.bookmarks.create({
|
|
518
|
+
kind,
|
|
519
|
+
label,
|
|
520
|
+
note,
|
|
521
|
+
target
|
|
522
|
+
});
|
|
523
|
+
const placement = this.resolveGuidePlacement(resolved.rect);
|
|
524
|
+
await this.bee.moveTo(resolved.rect.right, resolved.rect.top, placement.moveTo);
|
|
525
|
+
this.bee.setState("celebrating");
|
|
526
|
+
this.bee.speak(
|
|
527
|
+
kind === "honey" ? `Saved bookmark: ${label}` : `Issue flagged: ${label}`,
|
|
528
|
+
placement.speak
|
|
529
|
+
);
|
|
530
|
+
await this.finishInPlace(920);
|
|
531
|
+
return bookmark;
|
|
532
|
+
}
|
|
533
|
+
async guideTo(target, message, highlight, moveTo, navOptions) {
|
|
534
|
+
await this.bee.undock();
|
|
535
|
+
const resolved = this.locator.resolve(target);
|
|
536
|
+
if (!resolved) {
|
|
537
|
+
this.notifyMissingTarget();
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
this.locator.scrollIntoView(target);
|
|
541
|
+
await this.delay(350);
|
|
542
|
+
const fresh = this.locator.resolve(target);
|
|
543
|
+
if (!fresh) {
|
|
544
|
+
this.notifyMissingTarget();
|
|
545
|
+
return false;
|
|
546
|
+
}
|
|
547
|
+
if (highlight) {
|
|
548
|
+
this.highlight.show(fresh.rect, highlight);
|
|
549
|
+
}
|
|
550
|
+
const anchorX = fresh.rect.right;
|
|
551
|
+
const anchorY = fresh.rect.top;
|
|
552
|
+
const placement = this.resolveGuidePlacement(fresh.rect, moveTo);
|
|
553
|
+
await this.bee.moveTo(anchorX, anchorY, {
|
|
554
|
+
...placement.moveTo,
|
|
555
|
+
offsetX: placement.moveTo.offsetX ?? 16,
|
|
556
|
+
offsetY: placement.moveTo.offsetY ?? -18
|
|
557
|
+
});
|
|
558
|
+
if (message) {
|
|
559
|
+
this.bee.speak(message, { ...placement.speak, ...navOptions });
|
|
560
|
+
}
|
|
561
|
+
if (!navOptions) {
|
|
562
|
+
this.bee.showActions((choice) => {
|
|
563
|
+
if (choice === "honey") {
|
|
564
|
+
void this.createHoneyBookmark("Quick honey", target, message);
|
|
565
|
+
} else {
|
|
566
|
+
void this.createStingBookmark("Quick sting", target, message);
|
|
567
|
+
}
|
|
568
|
+
this.bee.hideActions();
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
waitForClick(el, timeoutMs) {
|
|
574
|
+
return new Promise((resolve) => {
|
|
575
|
+
let finished = false;
|
|
576
|
+
const cleanup = () => {
|
|
577
|
+
el.removeEventListener("click", onClick);
|
|
578
|
+
clearTimeout(timer);
|
|
579
|
+
};
|
|
580
|
+
const onClick = () => {
|
|
581
|
+
if (finished) return;
|
|
582
|
+
finished = true;
|
|
583
|
+
cleanup();
|
|
584
|
+
resolve();
|
|
585
|
+
};
|
|
586
|
+
const timer = window.setTimeout(() => {
|
|
587
|
+
if (finished) return;
|
|
588
|
+
finished = true;
|
|
589
|
+
cleanup();
|
|
590
|
+
resolve();
|
|
591
|
+
}, timeoutMs);
|
|
592
|
+
el.addEventListener("click", onClick, { once: true });
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
waitForInput(el, timeoutMs) {
|
|
596
|
+
return new Promise((resolve) => {
|
|
597
|
+
let finished = false;
|
|
598
|
+
const cleanup = () => {
|
|
599
|
+
el.removeEventListener("input", onInput);
|
|
600
|
+
el.removeEventListener("change", onInput);
|
|
601
|
+
clearTimeout(timer);
|
|
602
|
+
};
|
|
603
|
+
const onInput = () => {
|
|
604
|
+
if (finished) return;
|
|
605
|
+
finished = true;
|
|
606
|
+
cleanup();
|
|
607
|
+
resolve();
|
|
608
|
+
};
|
|
609
|
+
const timer = window.setTimeout(() => {
|
|
610
|
+
if (finished) return;
|
|
611
|
+
finished = true;
|
|
612
|
+
cleanup();
|
|
613
|
+
resolve();
|
|
614
|
+
}, timeoutMs);
|
|
615
|
+
el.addEventListener("input", onInput, { once: true });
|
|
616
|
+
el.addEventListener("change", onInput, { once: true });
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
delay(ms) {
|
|
620
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
621
|
+
}
|
|
622
|
+
resolveGuidePlacement(rect, overrides) {
|
|
623
|
+
const viewportWidth = window.innerWidth;
|
|
624
|
+
const viewportHeight = window.innerHeight;
|
|
625
|
+
const anchorX = rect.right;
|
|
626
|
+
const anchorY = rect.top;
|
|
627
|
+
const side = this.pickGuideSide(rect, viewportWidth, viewportHeight);
|
|
628
|
+
const base = this.resolveTopRightBeeOffset(rect, side, viewportWidth, viewportHeight);
|
|
629
|
+
const offsetX = overrides?.offsetX ?? base.offsetX;
|
|
630
|
+
const offsetY = overrides?.offsetY ?? base.offsetY;
|
|
631
|
+
const beeX = anchorX + offsetX;
|
|
632
|
+
const beeY = anchorY + offsetY;
|
|
633
|
+
const tooltip = this.resolveTooltipPosition(
|
|
634
|
+
side,
|
|
635
|
+
beeX,
|
|
636
|
+
beeY,
|
|
637
|
+
viewportWidth,
|
|
638
|
+
viewportHeight,
|
|
639
|
+
rect
|
|
640
|
+
);
|
|
641
|
+
return {
|
|
642
|
+
moveTo: {
|
|
643
|
+
...overrides,
|
|
644
|
+
offsetX,
|
|
645
|
+
offsetY
|
|
646
|
+
},
|
|
647
|
+
speak: {
|
|
648
|
+
...tooltip
|
|
649
|
+
}
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
pickGuideSide(rect, viewportWidth, viewportHeight) {
|
|
653
|
+
const space = {
|
|
654
|
+
left: rect.left,
|
|
655
|
+
right: viewportWidth - rect.right,
|
|
656
|
+
top: rect.top,
|
|
657
|
+
bottom: viewportHeight - rect.bottom
|
|
658
|
+
};
|
|
659
|
+
const wideTarget = rect.width > viewportWidth * 0.32;
|
|
660
|
+
const tallTarget = rect.height > 140;
|
|
661
|
+
if (wideTarget || tallTarget) {
|
|
662
|
+
if (Math.max(space.left, space.right) >= 180) {
|
|
663
|
+
return space.left >= space.right ? "left" : "right";
|
|
664
|
+
}
|
|
665
|
+
if (space.top >= space.bottom) {
|
|
666
|
+
return "top";
|
|
667
|
+
}
|
|
668
|
+
return "bottom";
|
|
669
|
+
}
|
|
670
|
+
if (space.right >= 320) return "right";
|
|
671
|
+
if (space.left >= 320) return "left";
|
|
672
|
+
if (space.top >= 220) return "top";
|
|
673
|
+
if (space.bottom >= 220) return "bottom";
|
|
674
|
+
return Object.entries(space).sort((a, b) => b[1] - a[1])[0]?.[0] ?? "right";
|
|
675
|
+
}
|
|
676
|
+
resolveTopRightBeeOffset(rect, _side, viewportWidth, viewportHeight) {
|
|
677
|
+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
678
|
+
const beeX = clamp(rect.right + 24, 88, viewportWidth - 88);
|
|
679
|
+
const beeY = clamp(rect.top - 28, 64, viewportHeight - 96);
|
|
680
|
+
return {
|
|
681
|
+
offsetX: beeX - rect.right,
|
|
682
|
+
offsetY: beeY - rect.top
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
/** Area of intersection of two axis-aligned rectangles (px²). */
|
|
686
|
+
rectOverlapArea(a, b) {
|
|
687
|
+
const w = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left));
|
|
688
|
+
const h = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
|
|
689
|
+
return w * h;
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Place the speech bubble with a healthy gap from the bee and pick left-vs-right
|
|
693
|
+
* by scoring overlap with the bee body and the highlighted target.
|
|
694
|
+
*/
|
|
695
|
+
resolveTooltipPosition(side, beeX, beeY, viewportWidth, viewportHeight, targetRect) {
|
|
696
|
+
const tooltipWidth = viewportWidth < 1100 ? 220 : 260;
|
|
697
|
+
const EST_H = 132;
|
|
698
|
+
const CLEAR = 56;
|
|
699
|
+
const BEE_R = 44;
|
|
700
|
+
const M = 16;
|
|
701
|
+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
702
|
+
const beeBox = {
|
|
703
|
+
left: beeX - BEE_R,
|
|
704
|
+
right: beeX + BEE_R,
|
|
705
|
+
top: beeY - BEE_R,
|
|
706
|
+
bottom: beeY + BEE_R
|
|
707
|
+
};
|
|
708
|
+
const targetBox = {
|
|
709
|
+
left: targetRect.left,
|
|
710
|
+
top: targetRect.top,
|
|
711
|
+
right: targetRect.right,
|
|
712
|
+
bottom: targetRect.bottom
|
|
713
|
+
};
|
|
714
|
+
const tooltipBox = (xRaw, y2) => {
|
|
715
|
+
const x2 = clamp(xRaw, M, viewportWidth - tooltipWidth - M);
|
|
716
|
+
const top = y2 - EST_H / 2;
|
|
717
|
+
const bottom = y2 + EST_H / 2;
|
|
718
|
+
return {
|
|
719
|
+
left: x2,
|
|
720
|
+
right: x2 + tooltipWidth,
|
|
721
|
+
top,
|
|
722
|
+
bottom
|
|
723
|
+
};
|
|
724
|
+
};
|
|
725
|
+
const scoreHorizontal = (xRaw, y2) => {
|
|
726
|
+
const t = tooltipBox(xRaw, y2);
|
|
727
|
+
const beeOverlap = this.rectOverlapArea(t, beeBox);
|
|
728
|
+
const targetOverlap = this.rectOverlapArea(t, targetBox);
|
|
729
|
+
const clampDelta = Math.abs(
|
|
730
|
+
clamp(xRaw, M, viewportWidth - tooltipWidth - M) - xRaw
|
|
731
|
+
);
|
|
732
|
+
return beeOverlap * 8e3 + targetOverlap * 4 + clampDelta * 2;
|
|
733
|
+
};
|
|
734
|
+
let x;
|
|
735
|
+
let y;
|
|
736
|
+
let arrowEdge;
|
|
737
|
+
if (side === "top") {
|
|
738
|
+
x = beeX - tooltipWidth * 0.56;
|
|
739
|
+
y = beeY - 56;
|
|
740
|
+
arrowEdge = "bottom";
|
|
741
|
+
} else if (side === "bottom") {
|
|
742
|
+
x = beeX - tooltipWidth * 0.48;
|
|
743
|
+
y = beeY + 26;
|
|
744
|
+
arrowEdge = "top";
|
|
745
|
+
} else {
|
|
746
|
+
const yH = beeY - 6;
|
|
747
|
+
const xLeft = beeX - tooltipWidth - CLEAR;
|
|
748
|
+
const xRight = beeX + CLEAR;
|
|
749
|
+
const sL = scoreHorizontal(xLeft, yH);
|
|
750
|
+
const sR = scoreHorizontal(xRight, yH);
|
|
751
|
+
const preferLeftOfBee = side === "left";
|
|
752
|
+
const tieEps = 500;
|
|
753
|
+
if (sL + tieEps < sR) {
|
|
754
|
+
x = xLeft;
|
|
755
|
+
y = yH;
|
|
756
|
+
arrowEdge = "right";
|
|
757
|
+
} else if (sR + tieEps < sL) {
|
|
758
|
+
x = xRight;
|
|
759
|
+
y = yH;
|
|
760
|
+
arrowEdge = "left";
|
|
761
|
+
} else if (preferLeftOfBee) {
|
|
762
|
+
x = xLeft;
|
|
763
|
+
y = yH;
|
|
764
|
+
arrowEdge = "right";
|
|
765
|
+
} else {
|
|
766
|
+
x = xRight;
|
|
767
|
+
y = yH;
|
|
768
|
+
arrowEdge = "left";
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return {
|
|
772
|
+
x: clamp(x, M, viewportWidth - tooltipWidth - M),
|
|
773
|
+
y: clamp(y, 64, viewportHeight - 64),
|
|
774
|
+
maxWidth: tooltipWidth,
|
|
775
|
+
arrowEdge
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
notifyMissingTarget() {
|
|
779
|
+
this.bee.setState("thinking");
|
|
780
|
+
this.bee.speak("I couldn't find that spot on this page.");
|
|
781
|
+
void this.finishAndDock(1e3);
|
|
782
|
+
}
|
|
783
|
+
async finishAndDock(delayMs) {
|
|
784
|
+
await this.delay(delayMs);
|
|
785
|
+
this.bee.hideActions();
|
|
786
|
+
this.bee.setState("idle");
|
|
787
|
+
await this.bee.dock();
|
|
788
|
+
}
|
|
789
|
+
/** Like finishAndDock but without docking — bee stays wherever it landed. */
|
|
790
|
+
async finishInPlace(delayMs) {
|
|
791
|
+
await this.delay(delayMs);
|
|
792
|
+
this.bee.hideActions();
|
|
793
|
+
this.bee.setState("idle");
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
// src/dom/captureContext.ts
|
|
798
|
+
var MAX_TEXT = 80;
|
|
799
|
+
var MAX_PROXIMITY_PX = 240;
|
|
800
|
+
var INTERACTIVE_SELECTOR = 'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="radio"], [role="switch"], [contenteditable=""], [contenteditable="true"], [data-kibee-id]';
|
|
801
|
+
function shortText(s) {
|
|
802
|
+
if (!s) return null;
|
|
803
|
+
const cleaned = s.replace(/\s+/g, " ").trim();
|
|
804
|
+
if (!cleaned) return null;
|
|
805
|
+
return cleaned.length > MAX_TEXT ? cleaned.slice(0, MAX_TEXT - 1) + "\u2026" : cleaned;
|
|
806
|
+
}
|
|
807
|
+
function visibleText(el) {
|
|
808
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
809
|
+
return shortText(el.placeholder || el.getAttribute("aria-label"));
|
|
810
|
+
}
|
|
811
|
+
if (el instanceof HTMLSelectElement) {
|
|
812
|
+
return shortText(el.getAttribute("aria-label"));
|
|
813
|
+
}
|
|
814
|
+
return shortText(el.textContent);
|
|
815
|
+
}
|
|
816
|
+
function elementRect(el) {
|
|
817
|
+
const r = el.getBoundingClientRect();
|
|
818
|
+
return { x: r.left, y: r.top, width: r.width, height: r.height };
|
|
819
|
+
}
|
|
820
|
+
function distanceToCentre(rect, x, y) {
|
|
821
|
+
const cx = rect.left + rect.width / 2;
|
|
822
|
+
const cy = rect.top + rect.height / 2;
|
|
823
|
+
return Math.hypot(cx - x, cy - y);
|
|
824
|
+
}
|
|
825
|
+
function selectorPath(target) {
|
|
826
|
+
const segments = [];
|
|
827
|
+
let el = target;
|
|
828
|
+
for (let i = 0; i < 6 && el && el !== document.body; i++) {
|
|
829
|
+
const tag = el.tagName.toLowerCase();
|
|
830
|
+
let seg = tag;
|
|
831
|
+
if (el.id) {
|
|
832
|
+
seg += `#${el.id}`;
|
|
833
|
+
segments.unshift(seg);
|
|
834
|
+
break;
|
|
835
|
+
}
|
|
836
|
+
const cls = (el.getAttribute("class") || "").split(/\s+/).filter((c) => c && !c.startsWith("kibee-")).slice(0, 2).join(".");
|
|
837
|
+
if (cls) seg += `.${cls}`;
|
|
838
|
+
segments.unshift(seg);
|
|
839
|
+
el = el.parentElement;
|
|
840
|
+
}
|
|
841
|
+
return segments.join(" > ");
|
|
842
|
+
}
|
|
843
|
+
function describeNearest(el, beeX, beeY) {
|
|
844
|
+
return {
|
|
845
|
+
tag: el.tagName.toLowerCase(),
|
|
846
|
+
text: visibleText(el),
|
|
847
|
+
role: el.getAttribute("role") || null,
|
|
848
|
+
kibeeId: el.getAttribute("data-kibee-id"),
|
|
849
|
+
testId: el.getAttribute("data-testid"),
|
|
850
|
+
domId: el.id || null,
|
|
851
|
+
selectorPath: selectorPath(el),
|
|
852
|
+
distancePx: Math.round(distanceToCentre(el.getBoundingClientRect(), beeX, beeY)),
|
|
853
|
+
rect: elementRect(el)
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
function findNearestInteractive(beeX, beeY) {
|
|
857
|
+
const stack = document.elementsFromPoint?.(beeX, beeY) ?? [];
|
|
858
|
+
for (const el of stack) {
|
|
859
|
+
if (el.matches?.(INTERACTIVE_SELECTOR)) return el;
|
|
860
|
+
const closest = el.closest?.(INTERACTIVE_SELECTOR);
|
|
861
|
+
if (closest) return closest;
|
|
862
|
+
}
|
|
863
|
+
let best = null;
|
|
864
|
+
const candidates = document.querySelectorAll(INTERACTIVE_SELECTOR);
|
|
865
|
+
for (const el of candidates) {
|
|
866
|
+
const r = el.getBoundingClientRect();
|
|
867
|
+
if (r.width === 0 && r.height === 0) continue;
|
|
868
|
+
if (r.bottom < 0 || r.top > window.innerHeight) continue;
|
|
869
|
+
if (r.right < 0 || r.left > window.innerWidth) continue;
|
|
870
|
+
const dist = distanceToCentre(r, beeX, beeY);
|
|
871
|
+
if (dist > MAX_PROXIMITY_PX) continue;
|
|
872
|
+
if (!best || dist < best.dist) best = { el, dist };
|
|
873
|
+
}
|
|
874
|
+
return best?.el ?? null;
|
|
875
|
+
}
|
|
876
|
+
function sampleNearbyText(beeX, beeY) {
|
|
877
|
+
const snippets = [];
|
|
878
|
+
const seen = /* @__PURE__ */ new Set();
|
|
879
|
+
const candidates = document.querySelectorAll(
|
|
880
|
+
"h1, h2, h3, h4, p, label, span, li, td, th, button"
|
|
881
|
+
);
|
|
882
|
+
for (const el of candidates) {
|
|
883
|
+
const r = el.getBoundingClientRect();
|
|
884
|
+
if (r.width === 0 && r.height === 0) continue;
|
|
885
|
+
const dist = distanceToCentre(r, beeX, beeY);
|
|
886
|
+
if (dist > MAX_PROXIMITY_PX) continue;
|
|
887
|
+
const t = visibleText(el);
|
|
888
|
+
if (!t || seen.has(t)) continue;
|
|
889
|
+
seen.add(t);
|
|
890
|
+
snippets.push(t);
|
|
891
|
+
if (snippets.length >= 6) break;
|
|
892
|
+
}
|
|
893
|
+
return snippets;
|
|
894
|
+
}
|
|
895
|
+
function captureContext(input) {
|
|
896
|
+
const { beeX, beeY } = input;
|
|
897
|
+
const nearest = findNearestInteractive(beeX, beeY);
|
|
898
|
+
return {
|
|
899
|
+
pathname: location.pathname,
|
|
900
|
+
search: location.search,
|
|
901
|
+
hash: location.hash,
|
|
902
|
+
title: shortText(document.title) ?? "",
|
|
903
|
+
beePosition: { x: Math.round(beeX), y: Math.round(beeY) },
|
|
904
|
+
viewport: {
|
|
905
|
+
width: window.innerWidth,
|
|
906
|
+
height: window.innerHeight,
|
|
907
|
+
dpr: window.devicePixelRatio || 1
|
|
908
|
+
},
|
|
909
|
+
scroll: { x: window.scrollX, y: window.scrollY },
|
|
910
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
911
|
+
nearestElement: nearest ? describeNearest(nearest, beeX, beeY) : null,
|
|
912
|
+
nearbyText: sampleNearbyText(beeX, beeY)
|
|
913
|
+
};
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
// src/dom/Locator.ts
|
|
917
|
+
var Locator = class {
|
|
918
|
+
resolve(target) {
|
|
919
|
+
const element = this.findElement(target);
|
|
920
|
+
if (!element) return null;
|
|
921
|
+
const rect = element.getBoundingClientRect();
|
|
922
|
+
return {
|
|
923
|
+
element,
|
|
924
|
+
rect,
|
|
925
|
+
centerX: rect.left + rect.width / 2,
|
|
926
|
+
centerY: rect.top + rect.height / 2
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
findElement(target) {
|
|
930
|
+
if (target.selector) {
|
|
931
|
+
const el = document.querySelector(target.selector);
|
|
932
|
+
if (el instanceof HTMLElement) return el;
|
|
933
|
+
}
|
|
934
|
+
const kibeeId = target.kibeeId ?? target.beeId;
|
|
935
|
+
if (kibeeId) {
|
|
936
|
+
const el = document.querySelector(`[data-kibee-id="${kibeeId}"]`);
|
|
937
|
+
if (el instanceof HTMLElement) return el;
|
|
938
|
+
}
|
|
939
|
+
if (target.textHints?.length) {
|
|
940
|
+
const candidates = Array.from(
|
|
941
|
+
document.querySelectorAll(
|
|
942
|
+
"button, a, input, textarea, select, [role='button'], [data-kibee-id]"
|
|
943
|
+
)
|
|
944
|
+
);
|
|
945
|
+
for (const node of candidates) {
|
|
946
|
+
if (!(node instanceof HTMLElement)) continue;
|
|
947
|
+
const text = this.getSearchableText(node).toLowerCase();
|
|
948
|
+
const matched = target.textHints.some(
|
|
949
|
+
(hint) => text.includes(hint.toLowerCase())
|
|
950
|
+
);
|
|
951
|
+
if (matched) return node;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
return null;
|
|
955
|
+
}
|
|
956
|
+
scrollIntoView(target, behavior = "smooth") {
|
|
957
|
+
const resolved = this.resolve(target);
|
|
958
|
+
if (!resolved) return;
|
|
959
|
+
resolved.element.scrollIntoView({
|
|
960
|
+
behavior,
|
|
961
|
+
block: "center",
|
|
962
|
+
inline: "center"
|
|
963
|
+
});
|
|
964
|
+
}
|
|
965
|
+
getSearchableText(el) {
|
|
966
|
+
const bits = [
|
|
967
|
+
el.textContent ?? "",
|
|
968
|
+
el.getAttribute("aria-label") ?? "",
|
|
969
|
+
el.getAttribute("placeholder") ?? "",
|
|
970
|
+
el.getAttribute("title") ?? "",
|
|
971
|
+
el.getAttribute("data-kibee-id") ?? ""
|
|
972
|
+
];
|
|
973
|
+
return bits.join(" ").trim();
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
// src/dom/TargetRegistry.ts
|
|
978
|
+
var TargetRegistry = class {
|
|
979
|
+
targets = [];
|
|
980
|
+
set(targets) {
|
|
981
|
+
this.targets = [...targets];
|
|
982
|
+
}
|
|
983
|
+
list() {
|
|
984
|
+
return [...this.targets];
|
|
985
|
+
}
|
|
986
|
+
getVisibleTargets(doc = document) {
|
|
987
|
+
return this.targets.reduce((visible, target) => {
|
|
988
|
+
const kibeeId = target.target.kibeeId ?? target.target.beeId;
|
|
989
|
+
const node = kibeeId && doc.querySelector(`[data-kibee-id="${kibeeId}"]`) || (target.target.selector ? doc.querySelector(target.target.selector) : null);
|
|
990
|
+
if (!node) return visible;
|
|
991
|
+
const matchedText = [
|
|
992
|
+
node.textContent ?? "",
|
|
993
|
+
node.getAttribute("aria-label") ?? "",
|
|
994
|
+
node.getAttribute("placeholder") ?? ""
|
|
995
|
+
].join(" ").trim();
|
|
996
|
+
visible.push({
|
|
997
|
+
...target,
|
|
998
|
+
matchedText
|
|
999
|
+
});
|
|
1000
|
+
return visible;
|
|
1001
|
+
}, []);
|
|
1002
|
+
}
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
// src/renderers/HighlightRenderer.ts
|
|
1006
|
+
var HighlightRenderer = class {
|
|
1007
|
+
el = null;
|
|
1008
|
+
mount(container = document.body) {
|
|
1009
|
+
if (this.el) return;
|
|
1010
|
+
this.el = document.createElement("div");
|
|
1011
|
+
this.el.className = "kibee-highlight kibee-highlight--hidden";
|
|
1012
|
+
container.appendChild(this.el);
|
|
1013
|
+
}
|
|
1014
|
+
show(rect, variant = "pulse") {
|
|
1015
|
+
if (!this.el) return;
|
|
1016
|
+
this.el.className = `kibee-highlight kibee-highlight--${variant}`;
|
|
1017
|
+
this.el.style.left = `${rect.left - 6}px`;
|
|
1018
|
+
this.el.style.top = `${rect.top - 6}px`;
|
|
1019
|
+
this.el.style.width = `${rect.width + 12}px`;
|
|
1020
|
+
this.el.style.height = `${rect.height + 12}px`;
|
|
1021
|
+
}
|
|
1022
|
+
hide() {
|
|
1023
|
+
if (!this.el) return;
|
|
1024
|
+
this.el.className = "kibee-highlight kibee-highlight--hidden";
|
|
1025
|
+
}
|
|
1026
|
+
destroy() {
|
|
1027
|
+
if (!this.el) return;
|
|
1028
|
+
this.el.remove();
|
|
1029
|
+
this.el = null;
|
|
1030
|
+
}
|
|
1031
|
+
};
|
|
1032
|
+
|
|
1033
|
+
// src/KiBeeClient.ts
|
|
1034
|
+
var VISITOR_ID_KEY = "kibee:visitor_id";
|
|
1035
|
+
function getOrCreateVisitorId() {
|
|
1036
|
+
if (typeof window === "undefined") return `srv_${Date.now()}`;
|
|
1037
|
+
try {
|
|
1038
|
+
const existing = localStorage.getItem(VISITOR_ID_KEY);
|
|
1039
|
+
if (existing) return existing;
|
|
1040
|
+
const id = typeof crypto !== "undefined" && crypto.randomUUID ? `kv_${crypto.randomUUID()}` : `kv_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
1041
|
+
localStorage.setItem(VISITOR_ID_KEY, id);
|
|
1042
|
+
return id;
|
|
1043
|
+
} catch {
|
|
1044
|
+
return `kv_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
var KiBeeClient = class {
|
|
1048
|
+
constructor(options) {
|
|
1049
|
+
this.options = options;
|
|
1050
|
+
this.visitorId = options.visitorId ?? getOrCreateVisitorId();
|
|
1051
|
+
this.mountContainer = options.mountContainer;
|
|
1052
|
+
const websiteId = options.websiteId;
|
|
1053
|
+
let publicKey = options.publicKey;
|
|
1054
|
+
if (!publicKey && options.apiKey) {
|
|
1055
|
+
publicKey = options.apiKey;
|
|
1056
|
+
if (typeof console !== "undefined") {
|
|
1057
|
+
console.warn(
|
|
1058
|
+
"[KiBee] `apiKey` option is deprecated \u2014 use `publicKey` instead. Removed in v1.0."
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
const isProd = typeof process !== "undefined" && typeof process.env !== "undefined" && process.env.NODE_ENV === "production";
|
|
1063
|
+
if (isProd && (!websiteId || !publicKey)) {
|
|
1064
|
+
throw new Error(
|
|
1065
|
+
"[KiBee] Missing websiteId or publicKey. Get yours from your KiBee admin dashboard \u2192 Settings \u2192 Setup & Integration."
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
if (!isProd && (!websiteId || !publicKey) && typeof console !== "undefined") {
|
|
1069
|
+
console.warn(
|
|
1070
|
+
"[KiBee] Running in dev mode without websiteId \u2014 set websiteId and publicKey for production."
|
|
1071
|
+
);
|
|
1072
|
+
}
|
|
1073
|
+
this.api = new KiBeeApiClient(
|
|
1074
|
+
options.apiBaseUrl ?? "https://api.kibee.ai",
|
|
1075
|
+
{
|
|
1076
|
+
websiteId,
|
|
1077
|
+
publicKey
|
|
1078
|
+
}
|
|
1079
|
+
);
|
|
1080
|
+
this.controller = new FlowController(
|
|
1081
|
+
this.options.renderer,
|
|
1082
|
+
this.locator,
|
|
1083
|
+
this.highlight,
|
|
1084
|
+
this.bookmarks,
|
|
1085
|
+
(command, phase) => {
|
|
1086
|
+
void this.track("command_transition", {
|
|
1087
|
+
commandType: command.type,
|
|
1088
|
+
phase
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
);
|
|
1092
|
+
if (options.useBridge && options.apiToken) {
|
|
1093
|
+
const apiToken = options.apiToken;
|
|
1094
|
+
const apiBase = options.apiBaseUrl ?? "";
|
|
1095
|
+
const bridgeVisitorId = this.visitorId;
|
|
1096
|
+
const bridgeUrl = apiBase.replace(/^http/, "ws") + "/v1/bridge";
|
|
1097
|
+
this.bridgeClient = new BridgeClient({
|
|
1098
|
+
bridgeUrl,
|
|
1099
|
+
source: "web",
|
|
1100
|
+
visitorId: bridgeVisitorId,
|
|
1101
|
+
tokenFetcher: async () => {
|
|
1102
|
+
const res = await fetch(`${apiBase}/v1/bridge/token`, {
|
|
1103
|
+
method: "POST",
|
|
1104
|
+
headers: { authorization: `Bearer ${apiToken}` }
|
|
1105
|
+
});
|
|
1106
|
+
if (!res.ok) {
|
|
1107
|
+
throw new Error(`bridge token fetch failed: ${res.status}`);
|
|
1108
|
+
}
|
|
1109
|
+
return res.json();
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
if (typeof window !== "undefined") {
|
|
1113
|
+
this.bridgeUnsubscribes.push(
|
|
1114
|
+
this.bridgeClient.on("presence:hello", (env) => {
|
|
1115
|
+
window.dispatchEvent(
|
|
1116
|
+
new CustomEvent("kibee-bridge-presence", {
|
|
1117
|
+
detail: { source: env.source, present: true }
|
|
1118
|
+
})
|
|
1119
|
+
);
|
|
1120
|
+
})
|
|
1121
|
+
);
|
|
1122
|
+
this.bridgeUnsubscribes.push(
|
|
1123
|
+
this.bridgeClient.on("presence:bye", (env) => {
|
|
1124
|
+
window.dispatchEvent(
|
|
1125
|
+
new CustomEvent("kibee-bridge-presence", {
|
|
1126
|
+
detail: { source: env.source, present: false }
|
|
1127
|
+
})
|
|
1128
|
+
);
|
|
1129
|
+
})
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
this.bridgeUnsubscribes.push(
|
|
1133
|
+
this.bridgeClient.on("flow:handoff", (envelope) => {
|
|
1134
|
+
const payload = envelope.payload;
|
|
1135
|
+
const flowId = payload != null && typeof payload === "object" && "flowId" in payload && typeof payload.flowId === "string" ? payload.flowId : void 0;
|
|
1136
|
+
if (!flowId || flowId.length === 0) return;
|
|
1137
|
+
void this.runFlowFromHandoff(flowId).catch((err) => {
|
|
1138
|
+
console.warn("[kibee-sdk] flow:handoff run failed", err);
|
|
1139
|
+
});
|
|
1140
|
+
})
|
|
1141
|
+
);
|
|
1142
|
+
this.bridgeUnsubscribes.push(
|
|
1143
|
+
this.bridgeClient.on("voice:state", (envelope) => {
|
|
1144
|
+
const payload = envelope.payload;
|
|
1145
|
+
const stateStr = payload != null && typeof payload === "object" && "state" in payload && typeof payload.state === "string" ? payload.state : void 0;
|
|
1146
|
+
if (typeof stateStr !== "string") return;
|
|
1147
|
+
if (envelope.source === "web") return;
|
|
1148
|
+
this.otherSurfaceListening = stateStr === "listening";
|
|
1149
|
+
if (typeof window !== "undefined") {
|
|
1150
|
+
window.dispatchEvent(
|
|
1151
|
+
new CustomEvent("kibee-voice-state", {
|
|
1152
|
+
detail: { source: envelope.source, state: stateStr }
|
|
1153
|
+
})
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
})
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
options;
|
|
1161
|
+
api;
|
|
1162
|
+
locator = new Locator();
|
|
1163
|
+
highlight = new HighlightRenderer();
|
|
1164
|
+
bookmarks = new BookmarkMemory();
|
|
1165
|
+
registry = new TargetRegistry();
|
|
1166
|
+
controller;
|
|
1167
|
+
initPromise = null;
|
|
1168
|
+
mountContainer;
|
|
1169
|
+
currentPageId = "landing";
|
|
1170
|
+
currentSession = null;
|
|
1171
|
+
currentFlowId;
|
|
1172
|
+
adminSyncTimer = null;
|
|
1173
|
+
/** Polling cadence when SSE is unavailable / closed — the visitor
|
|
1174
|
+
* experience depends on this for new admin messages, so it stays
|
|
1175
|
+
* tight (5s default; afforestation passes 2.5s). */
|
|
1176
|
+
fastPollInterval = 5e3;
|
|
1177
|
+
/** Cadence when SSE is healthy. Polling becomes a heartbeat — it only
|
|
1178
|
+
* needs to catch state SSE doesn't push (friction score, pending
|
|
1179
|
+
* pushes) and recover any messages we somehow missed. */
|
|
1180
|
+
slowPollInterval = 3e4;
|
|
1181
|
+
presenceSessionPromise = null;
|
|
1182
|
+
/** EventSource subscription for the per-session visitor stream. */
|
|
1183
|
+
visitorStream = null;
|
|
1184
|
+
/** Last-seen message timestamp for the polling fallback `?since=`. */
|
|
1185
|
+
lastVisitorMessageAt = null;
|
|
1186
|
+
/** Set of message ids the SDK has emitted to the panel — dedupes when a
|
|
1187
|
+
* message arrives via SSE *and* a subsequent poll. */
|
|
1188
|
+
seenMessageIds = /* @__PURE__ */ new Set();
|
|
1189
|
+
/** Distress signals tracker. Reset on a high-confidence resolve so the
|
|
1190
|
+
* bee doesn't nag if the visitor recovers on their own. */
|
|
1191
|
+
consecutiveLowConfidence = 0;
|
|
1192
|
+
lastDistressPromptAt = 0;
|
|
1193
|
+
/** Current chat mode mirrored from the server. The panel reads this to
|
|
1194
|
+
* hide the "talk to a human" chip when an agent is on. */
|
|
1195
|
+
chatMode = "ai";
|
|
1196
|
+
/** Mirror of whether the host's chat panel is currently visible. The
|
|
1197
|
+
* panel dispatches kibee-panel-open / -close — we listen so we can
|
|
1198
|
+
* decide whether to surface an inbound admin message as a bee tooltip
|
|
1199
|
+
* (panel closed → speak it so the visitor notices) or stay quiet
|
|
1200
|
+
* (panel open → the thread already shows it; tooltip would dupe). */
|
|
1201
|
+
panelOpen = false;
|
|
1202
|
+
/** S3 T5: optional cross-surface bridge client. Initialized only when
|
|
1203
|
+
* `useBridge: true` AND `apiToken` are passed at construction. */
|
|
1204
|
+
bridgeClient;
|
|
1205
|
+
/** S4 T4: unsubscribes for handlers registered against `bridgeClient`. Drained
|
|
1206
|
+
* in `destroy()` so we don't leave dangling handlers if a host swaps clients. */
|
|
1207
|
+
bridgeUnsubscribes = [];
|
|
1208
|
+
/** S4 T4: in-flight `flow:handoff` flowIds. Guards against the same flow being
|
|
1209
|
+
* started twice if duplicate envelopes arrive (e.g. retried publish). */
|
|
1210
|
+
inflightHandoffFlowIds = /* @__PURE__ */ new Set();
|
|
1211
|
+
/** S4 T7: mirrors whether another surface (extension/desktop) is currently
|
|
1212
|
+
* holding the listening floor (announced via `voice:state` over the bridge).
|
|
1213
|
+
* The SDK has no built-in voice listening UI today — voice is host-implemented
|
|
1214
|
+
* — so this is exposed as a flag + a `kibee-voice-state` CustomEvent that hosts
|
|
1215
|
+
* can subscribe to in order to suppress their own listening indicator. */
|
|
1216
|
+
otherSurfaceListening = false;
|
|
1217
|
+
visitorId;
|
|
1218
|
+
/**
|
|
1219
|
+
* Connects to the cross-surface bridge (`/v1/bridge`). No-op when
|
|
1220
|
+
* `useBridge` was false or `apiToken` was missing at construction.
|
|
1221
|
+
*/
|
|
1222
|
+
async connectBridge() {
|
|
1223
|
+
if (!this.bridgeClient) return;
|
|
1224
|
+
await this.bridgeClient.connect();
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Register a handler for inbound bridge envelopes of a specific type.
|
|
1228
|
+
* Returns an unsubscribe function. No-op (returns a noop unsubscribe)
|
|
1229
|
+
* when the bridge isn't initialized.
|
|
1230
|
+
*/
|
|
1231
|
+
onBridgeEvent(type, handler) {
|
|
1232
|
+
if (!this.bridgeClient) {
|
|
1233
|
+
return () => {
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
return this.bridgeClient.on(type, handler);
|
|
1237
|
+
}
|
|
1238
|
+
/**
|
|
1239
|
+
* S4 T6: ask another surface (typically `desktop`) to point its on-screen
|
|
1240
|
+
* bee at a global screen coordinate. No-op when the bridge isn't wired
|
|
1241
|
+
* (`useBridge: false` or `apiToken` missing) — the caller doesn't need
|
|
1242
|
+
* to feature-detect.
|
|
1243
|
+
*
|
|
1244
|
+
* `tenantId`, `workspaceId`, and `visitorId` set here are advisory: the
|
|
1245
|
+
* broker overwrites identity fields with JWT-derived values on receipt
|
|
1246
|
+
* (S2 T2 invariant). `source: "web"` is hardcoded because the SDK ships
|
|
1247
|
+
* web-only by default; the broker also enforces source against the
|
|
1248
|
+
* value bound at WS handshake.
|
|
1249
|
+
*/
|
|
1250
|
+
async requestPointTo(coord) {
|
|
1251
|
+
if (!this.bridgeClient) return;
|
|
1252
|
+
const envelope = {
|
|
1253
|
+
v: 1,
|
|
1254
|
+
source: "web",
|
|
1255
|
+
target: "desktop",
|
|
1256
|
+
tenantId: null,
|
|
1257
|
+
workspaceId: null,
|
|
1258
|
+
visitorId: this.visitorId,
|
|
1259
|
+
sessionId: this.currentSession?.id ?? null,
|
|
1260
|
+
type: "pointer:show",
|
|
1261
|
+
ts: Date.now(),
|
|
1262
|
+
payload: coord
|
|
1263
|
+
};
|
|
1264
|
+
this.bridgeClient.send(envelope);
|
|
1265
|
+
}
|
|
1266
|
+
async init() {
|
|
1267
|
+
if (this.initPromise) return this.initPromise;
|
|
1268
|
+
if (typeof window !== "undefined") {
|
|
1269
|
+
window.addEventListener("kibee-panel-open", () => {
|
|
1270
|
+
this.panelOpen = true;
|
|
1271
|
+
});
|
|
1272
|
+
window.addEventListener("kibee-panel-close", () => {
|
|
1273
|
+
this.panelOpen = false;
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
this.initPromise = (async () => {
|
|
1277
|
+
await this.options.renderer.mount(this.mountContainer);
|
|
1278
|
+
await this.controller.init();
|
|
1279
|
+
this.options.renderer.showActions((choice) => {
|
|
1280
|
+
const kind = choice === "honey" ? "honey" : "sting";
|
|
1281
|
+
const baseLabel = choice === "honey" ? "Bookmarked" : "Friction point";
|
|
1282
|
+
const beePos = this.options.renderer.getBeePosition?.() ?? { x: 0, y: 0 };
|
|
1283
|
+
const context = captureContext({ beeX: beePos.x, beeY: beePos.y });
|
|
1284
|
+
const promptMessage = choice === "honey" ? "What's worth coming back to?" : "What felt rough? (one line is plenty)";
|
|
1285
|
+
const placeholder = choice === "honey" ? 'e.g. "Pricing examples table"' : 'e.g. "Filter resets on back-nav"';
|
|
1286
|
+
void this.options.renderer.prompt(promptMessage, { placeholder }).then((userNote) => {
|
|
1287
|
+
const note = userNote && userNote.length > 0 ? userNote : choice === "honey" ? "User bookmarked this page" : "User flagged friction on this page";
|
|
1288
|
+
const nearText = context.nearestElement?.text;
|
|
1289
|
+
const label = nearText ? `${baseLabel}: ${nearText}` : baseLabel;
|
|
1290
|
+
const bookmark = this.bookmarks.create({
|
|
1291
|
+
kind,
|
|
1292
|
+
label,
|
|
1293
|
+
target: { kibeeId: this.currentPageId },
|
|
1294
|
+
note,
|
|
1295
|
+
metadata: {
|
|
1296
|
+
userNoteProvided: typeof userNote === "string" && userNote.length > 0,
|
|
1297
|
+
context
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
if (bookmark) {
|
|
1301
|
+
this.options.renderer.speak(
|
|
1302
|
+
choice === "honey" ? "Saved! This page is bookmarked." : "Noted! This friction point has been recorded."
|
|
1303
|
+
);
|
|
1304
|
+
void this.track("bookmark_saved", {
|
|
1305
|
+
kind,
|
|
1306
|
+
label,
|
|
1307
|
+
hasUserNote: typeof userNote === "string" && userNote.length > 0,
|
|
1308
|
+
pathname: context.pathname,
|
|
1309
|
+
nearestTag: context.nearestElement?.tag ?? null,
|
|
1310
|
+
nearestKibeeId: context.nearestElement?.kibeeId ?? null
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
});
|
|
1314
|
+
});
|
|
1315
|
+
await this.track("runtime_initialized");
|
|
1316
|
+
})();
|
|
1317
|
+
return this.initPromise;
|
|
1318
|
+
}
|
|
1319
|
+
identifyPage(pageId, metadata) {
|
|
1320
|
+
this.currentPageId = pageId;
|
|
1321
|
+
void this.track("page_identified", metadata);
|
|
1322
|
+
void this.ensurePresenceSession();
|
|
1323
|
+
}
|
|
1324
|
+
registerTargets(targets) {
|
|
1325
|
+
this.registry.set(targets);
|
|
1326
|
+
void this.track("targets_registered", {
|
|
1327
|
+
count: targets.length,
|
|
1328
|
+
targetIds: targets.map((target) => target.id)
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
getRegisteredTargets() {
|
|
1332
|
+
return this.registry.list();
|
|
1333
|
+
}
|
|
1334
|
+
async runFlow(flowOrId) {
|
|
1335
|
+
await this.init();
|
|
1336
|
+
let flow;
|
|
1337
|
+
try {
|
|
1338
|
+
flow = typeof flowOrId === "string" ? await this.api.fetchFlow(flowOrId) : flowOrId;
|
|
1339
|
+
} catch {
|
|
1340
|
+
this.options.renderer.setState("thinking");
|
|
1341
|
+
this.options.renderer.speak(
|
|
1342
|
+
"KiBee can't reach the orchestration API right now. Start the API service to run guided flows."
|
|
1343
|
+
);
|
|
1344
|
+
throw new Error("Unable to fetch KiBee flow definition.");
|
|
1345
|
+
}
|
|
1346
|
+
this.currentFlowId = flow.id;
|
|
1347
|
+
this.currentSession = await this.startSession(flow.id);
|
|
1348
|
+
await this.track("flow_started", {
|
|
1349
|
+
flowId: flow.id,
|
|
1350
|
+
title: flow.title,
|
|
1351
|
+
goal: flow.goal
|
|
1352
|
+
});
|
|
1353
|
+
await this.controller.runFlow(flow);
|
|
1354
|
+
this.currentSession = this.currentSession ? {
|
|
1355
|
+
...this.currentSession,
|
|
1356
|
+
status: "completed",
|
|
1357
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1358
|
+
} : null;
|
|
1359
|
+
await this.track("flow_completed", {
|
|
1360
|
+
flowId: flow.id,
|
|
1361
|
+
title: flow.title
|
|
1362
|
+
});
|
|
1363
|
+
return flow;
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Run a flow in interactive step-through mode: the bee tooltip shows
|
|
1367
|
+
* prev / next buttons so the user controls the pace instead of
|
|
1368
|
+
* auto-advancing on click/input events.
|
|
1369
|
+
*/
|
|
1370
|
+
async runInteractiveFlow(flowOrId) {
|
|
1371
|
+
await this.init();
|
|
1372
|
+
let flow;
|
|
1373
|
+
try {
|
|
1374
|
+
flow = typeof flowOrId === "string" ? await this.api.fetchFlow(flowOrId) : flowOrId;
|
|
1375
|
+
} catch {
|
|
1376
|
+
this.options.renderer.setState("thinking");
|
|
1377
|
+
this.options.renderer.speak(
|
|
1378
|
+
"KiBee can't reach the orchestration API right now. Start the API service to run guided flows."
|
|
1379
|
+
);
|
|
1380
|
+
throw new Error("Unable to fetch KiBee flow definition.");
|
|
1381
|
+
}
|
|
1382
|
+
this.currentFlowId = flow.id;
|
|
1383
|
+
this.currentSession = await this.startSession(flow.id);
|
|
1384
|
+
await this.track("flow_started", {
|
|
1385
|
+
flowId: flow.id,
|
|
1386
|
+
title: flow.title,
|
|
1387
|
+
goal: flow.goal,
|
|
1388
|
+
mode: "interactive"
|
|
1389
|
+
});
|
|
1390
|
+
await this.controller.runInteractiveFlow(flow);
|
|
1391
|
+
return flow;
|
|
1392
|
+
}
|
|
1393
|
+
async ask(input) {
|
|
1394
|
+
await this.init();
|
|
1395
|
+
this.options.renderer.setState("thinking");
|
|
1396
|
+
let match;
|
|
1397
|
+
try {
|
|
1398
|
+
match = await this.api.resolveIntent({
|
|
1399
|
+
input,
|
|
1400
|
+
pageId: this.currentPageId,
|
|
1401
|
+
visibleTargets: this.registry.getVisibleTargets(),
|
|
1402
|
+
sessionId: this.currentSession?.id
|
|
1403
|
+
});
|
|
1404
|
+
} catch {
|
|
1405
|
+
this.options.renderer.speak(
|
|
1406
|
+
"KiBee can't resolve intent while the API is offline."
|
|
1407
|
+
);
|
|
1408
|
+
throw new Error("Unable to resolve KiBee intent.");
|
|
1409
|
+
}
|
|
1410
|
+
if (match.message) {
|
|
1411
|
+
this.options.renderer.speak(match.message);
|
|
1412
|
+
}
|
|
1413
|
+
await this.track("intent_resolved", {
|
|
1414
|
+
input,
|
|
1415
|
+
flowId: match.flowId ?? void 0,
|
|
1416
|
+
confidence: match.confidence
|
|
1417
|
+
});
|
|
1418
|
+
if (match.flowId) {
|
|
1419
|
+
await this.runFlow(match.flowId);
|
|
1420
|
+
}
|
|
1421
|
+
return match;
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Visitor-facing chat send. Wraps `ask()` so the rule-matched answer
|
|
1425
|
+
* still drives flows when intent is clear, but ALSO:
|
|
1426
|
+
* - emits a `kibee-message-received` event for each customer + ai
|
|
1427
|
+
* message so the panel renders them as bubbles even before the SSE
|
|
1428
|
+
* stream catches up
|
|
1429
|
+
* - tracks distress signals (low confidence streaks, "human"
|
|
1430
|
+
* keywords) and dispatches `kibee-distress-detected` so the panel
|
|
1431
|
+
* can surface the inline "want a teammate?" prompt
|
|
1432
|
+
*/
|
|
1433
|
+
async send(input) {
|
|
1434
|
+
if (!input.trim()) return;
|
|
1435
|
+
if (typeof window !== "undefined") {
|
|
1436
|
+
window.dispatchEvent(
|
|
1437
|
+
new CustomEvent("kibee-message-received", {
|
|
1438
|
+
detail: {
|
|
1439
|
+
id: `local-${Date.now()}`,
|
|
1440
|
+
sender: "customer",
|
|
1441
|
+
text: input,
|
|
1442
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1443
|
+
optimistic: true
|
|
1444
|
+
}
|
|
1445
|
+
})
|
|
1446
|
+
);
|
|
1447
|
+
}
|
|
1448
|
+
if (/\b(human|agent|support|talk to (someone|person))\b/i.test(input)) {
|
|
1449
|
+
void this.requestHumanFromKeyword();
|
|
1450
|
+
}
|
|
1451
|
+
const match = await this.ask(input);
|
|
1452
|
+
if (match.confidence < 0.35) {
|
|
1453
|
+
this.consecutiveLowConfidence += 1;
|
|
1454
|
+
if (this.consecutiveLowConfidence >= 2 && this.shouldShowDistressPrompt()) {
|
|
1455
|
+
this.dispatchDistressPrompt("low_confidence_streak");
|
|
1456
|
+
}
|
|
1457
|
+
} else if (match.confidence >= 0.55) {
|
|
1458
|
+
this.consecutiveLowConfidence = 0;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Visitor-initiated escalation. Called by the panel's "Talk to a human"
|
|
1463
|
+
* chip and by the inline distress prompt's "Yes please" button.
|
|
1464
|
+
*/
|
|
1465
|
+
async escalateToHuman() {
|
|
1466
|
+
if (!this.currentSession) return;
|
|
1467
|
+
try {
|
|
1468
|
+
await this.api.escalateToHuman(this.currentSession.id);
|
|
1469
|
+
if (typeof window !== "undefined") {
|
|
1470
|
+
window.dispatchEvent(
|
|
1471
|
+
new CustomEvent("kibee-escalated", { detail: { sessionId: this.currentSession.id } })
|
|
1472
|
+
);
|
|
1473
|
+
}
|
|
1474
|
+
} catch {
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
/**
|
|
1478
|
+
* Surface an incoming agent / AI message as a bee speech bubble when the
|
|
1479
|
+
* panel is closed, so a passive visitor sees that someone replied. The
|
|
1480
|
+
* panel-open path stays quiet because the panel thread already shows the
|
|
1481
|
+
* message — speaking would dupe it.
|
|
1482
|
+
*
|
|
1483
|
+
* Skipped for `customer` (the visitor's own echoed message) and `system`
|
|
1484
|
+
* (admin annotations like "Sahil joined the conversation" — visible only
|
|
1485
|
+
* inside the panel).
|
|
1486
|
+
*/
|
|
1487
|
+
maybeSpeakIncomingMessage(msg) {
|
|
1488
|
+
if (this.panelOpen) return;
|
|
1489
|
+
if (msg.sender !== "agent" && msg.sender !== "ai") return;
|
|
1490
|
+
if (!msg.text) return;
|
|
1491
|
+
try {
|
|
1492
|
+
this.options.renderer.speak(msg.text, { maxWidth: 280 });
|
|
1493
|
+
} catch {
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
shouldShowDistressPrompt() {
|
|
1497
|
+
if (this.chatMode === "human") return false;
|
|
1498
|
+
return Date.now() - this.lastDistressPromptAt > 9e4;
|
|
1499
|
+
}
|
|
1500
|
+
dispatchDistressPrompt(reason) {
|
|
1501
|
+
this.lastDistressPromptAt = Date.now();
|
|
1502
|
+
if (typeof window === "undefined") return;
|
|
1503
|
+
window.dispatchEvent(
|
|
1504
|
+
new CustomEvent("kibee-distress-detected", { detail: { reason } })
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
async requestHumanFromKeyword() {
|
|
1508
|
+
if (this.chatMode === "human") return;
|
|
1509
|
+
if (this.shouldShowDistressPrompt()) {
|
|
1510
|
+
this.dispatchDistressPrompt("keyword");
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
async saveHoney(label, target, note) {
|
|
1514
|
+
await this.init();
|
|
1515
|
+
const bookmark = await this.controller.createHoneyBookmark(label, target, note);
|
|
1516
|
+
if (bookmark) {
|
|
1517
|
+
await this.track("bookmark_saved", {
|
|
1518
|
+
kind: "honey",
|
|
1519
|
+
label
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
return bookmark;
|
|
1523
|
+
}
|
|
1524
|
+
async saveSting(label, target, note) {
|
|
1525
|
+
await this.init();
|
|
1526
|
+
const bookmark = await this.controller.createStingBookmark(label, target, note);
|
|
1527
|
+
if (bookmark) {
|
|
1528
|
+
await this.track("bookmark_saved", {
|
|
1529
|
+
kind: "sting",
|
|
1530
|
+
label
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
return bookmark;
|
|
1534
|
+
}
|
|
1535
|
+
listBookmarks() {
|
|
1536
|
+
return this.controller.listBookmarks();
|
|
1537
|
+
}
|
|
1538
|
+
removeBookmark(id) {
|
|
1539
|
+
this.controller.removeBookmark(id);
|
|
1540
|
+
}
|
|
1541
|
+
async revisitBookmark(bookmark) {
|
|
1542
|
+
await this.controller.revisitBookmark(bookmark);
|
|
1543
|
+
}
|
|
1544
|
+
clearFocus() {
|
|
1545
|
+
this.controller.clearFocus();
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* When true, the dock button toggles the in-app assistant (kibee-assist-toggle)
|
|
1549
|
+
* instead of immediately undocking the bee.
|
|
1550
|
+
*/
|
|
1551
|
+
setDockAssistPanel(enabled) {
|
|
1552
|
+
this.options.renderer.setDockClickBehavior(
|
|
1553
|
+
enabled ? "assist-panel" : "undock"
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
async track(type, metadata) {
|
|
1557
|
+
const event = {
|
|
1558
|
+
type,
|
|
1559
|
+
pageId: this.currentPageId,
|
|
1560
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1561
|
+
visitorId: this.visitorId,
|
|
1562
|
+
sessionId: this.currentSession?.id,
|
|
1563
|
+
flowId: this.currentFlowId,
|
|
1564
|
+
metadata
|
|
1565
|
+
};
|
|
1566
|
+
this.options.onEvent?.(event);
|
|
1567
|
+
try {
|
|
1568
|
+
await this.api.trackEvent({ event });
|
|
1569
|
+
} catch {
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
/** Returns the dock's current screen position (center of the dock button) */
|
|
1573
|
+
getDockPosition() {
|
|
1574
|
+
return this.options.renderer.getDockScreenPosition();
|
|
1575
|
+
}
|
|
1576
|
+
/**
|
|
1577
|
+
* Polls the API for admin-pushed flows/targets AND drains new chat
|
|
1578
|
+
* messages on the same tick. Also opens the per-session visitor SSE
|
|
1579
|
+
* stream so messages arrive in real time when the network supports it
|
|
1580
|
+
* (polling stays as a guaranteed-delivery backstop).
|
|
1581
|
+
*
|
|
1582
|
+
* Call after init() and startSession() to enable admin push support.
|
|
1583
|
+
*
|
|
1584
|
+
* Cadence is SSE-aware: when the visitor stream is OPEN (realtime is
|
|
1585
|
+
* delivering messages), polling backs off to a 30s heartbeat — just
|
|
1586
|
+
* enough to catch state the SSE channel can't push (e.g. friction
|
|
1587
|
+
* score, drained pending pushes). On SSE error/close the interval
|
|
1588
|
+
* snaps back to `intervalMs` so polling carries the load until SSE
|
|
1589
|
+
* reconnects.
|
|
1590
|
+
*
|
|
1591
|
+
* The single `/v1/visitor/sync` endpoint returns messages + pending
|
|
1592
|
+
* pushes + mode + friction in one round-trip, so each tick is one
|
|
1593
|
+
* request instead of two.
|
|
1594
|
+
*/
|
|
1595
|
+
enableAdminSync(intervalMs = 5e3) {
|
|
1596
|
+
this.disableAdminSync();
|
|
1597
|
+
this.fastPollInterval = intervalMs;
|
|
1598
|
+
void this.pollVisitorSync();
|
|
1599
|
+
this.armSyncTimer();
|
|
1600
|
+
this.openVisitorStream();
|
|
1601
|
+
}
|
|
1602
|
+
disableAdminSync() {
|
|
1603
|
+
if (this.adminSyncTimer) {
|
|
1604
|
+
clearInterval(this.adminSyncTimer);
|
|
1605
|
+
this.adminSyncTimer = null;
|
|
1606
|
+
}
|
|
1607
|
+
if (this.visitorStream) {
|
|
1608
|
+
this.visitorStream.close();
|
|
1609
|
+
this.visitorStream = null;
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
/** (Re)arm the polling interval based on current SSE health. Called
|
|
1613
|
+
* whenever SSE state changes (open / error) so the interval reflects
|
|
1614
|
+
* reality without dropping a tick. */
|
|
1615
|
+
armSyncTimer() {
|
|
1616
|
+
if (this.adminSyncTimer) {
|
|
1617
|
+
clearInterval(this.adminSyncTimer);
|
|
1618
|
+
this.adminSyncTimer = null;
|
|
1619
|
+
}
|
|
1620
|
+
const healthy = this.visitorStream?.readyState === 1;
|
|
1621
|
+
const ms = healthy ? this.slowPollInterval : this.fastPollInterval;
|
|
1622
|
+
this.adminSyncTimer = setInterval(() => {
|
|
1623
|
+
void this.pollVisitorSync();
|
|
1624
|
+
}, ms);
|
|
1625
|
+
}
|
|
1626
|
+
/**
|
|
1627
|
+
* Single visitor poll. Drains pending flow pushes (admin → SDK) AND
|
|
1628
|
+
* sucks in new chat messages in one HTTP request. Always runs alongside
|
|
1629
|
+
* SSE so a missed event during network blips eventually shows up;
|
|
1630
|
+
* `seenMessageIds` dedupes anything SSE already delivered.
|
|
1631
|
+
*/
|
|
1632
|
+
async pollVisitorSync() {
|
|
1633
|
+
if (!this.currentSession) return;
|
|
1634
|
+
try {
|
|
1635
|
+
const data = await this.api.fetchVisitorSync(
|
|
1636
|
+
this.currentSession.id,
|
|
1637
|
+
this.lastVisitorMessageAt
|
|
1638
|
+
);
|
|
1639
|
+
this.handleVisitorPoll(data);
|
|
1640
|
+
for (const push of data.pendingPushes) {
|
|
1641
|
+
if (push.flowId) {
|
|
1642
|
+
const flow = await this.api.fetchFlow(push.flowId);
|
|
1643
|
+
if (flow) await this.runFlow(flow);
|
|
1644
|
+
}
|
|
1645
|
+
}
|
|
1646
|
+
} catch {
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
handleVisitorPoll(data) {
|
|
1650
|
+
if (typeof window === "undefined") return;
|
|
1651
|
+
if (data.mode !== this.chatMode) {
|
|
1652
|
+
this.chatMode = data.mode;
|
|
1653
|
+
window.dispatchEvent(
|
|
1654
|
+
new CustomEvent("kibee-mode-changed", {
|
|
1655
|
+
detail: { mode: data.mode, hasAgent: data.hasAgent }
|
|
1656
|
+
})
|
|
1657
|
+
);
|
|
1658
|
+
}
|
|
1659
|
+
for (const msg of data.messages) {
|
|
1660
|
+
if (this.seenMessageIds.has(msg.id)) continue;
|
|
1661
|
+
this.seenMessageIds.add(msg.id);
|
|
1662
|
+
window.dispatchEvent(new CustomEvent("kibee-message-received", { detail: msg }));
|
|
1663
|
+
this.maybeSpeakIncomingMessage(msg);
|
|
1664
|
+
}
|
|
1665
|
+
if (data.lastMessageAt) {
|
|
1666
|
+
this.lastVisitorMessageAt = data.lastMessageAt;
|
|
1667
|
+
}
|
|
1668
|
+
if (data.frictionScore > 120 && this.shouldShowDistressPrompt()) {
|
|
1669
|
+
window.dispatchEvent(
|
|
1670
|
+
new CustomEvent("kibee-friction-high", {
|
|
1671
|
+
detail: { score: data.frictionScore, pageId: this.currentPageId }
|
|
1672
|
+
})
|
|
1673
|
+
);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
openVisitorStream() {
|
|
1677
|
+
if (!this.currentSession || this.visitorStream) return;
|
|
1678
|
+
const stream = this.api.openVisitorStream(this.currentSession.id);
|
|
1679
|
+
if (!stream) return;
|
|
1680
|
+
stream.onopen = () => {
|
|
1681
|
+
this.armSyncTimer();
|
|
1682
|
+
};
|
|
1683
|
+
stream.onmessage = (ev) => {
|
|
1684
|
+
try {
|
|
1685
|
+
const event = JSON.parse(ev.data);
|
|
1686
|
+
if (event.type === "message_sent") {
|
|
1687
|
+
const msg = event.payload;
|
|
1688
|
+
if (msg && msg.id && !this.seenMessageIds.has(msg.id)) {
|
|
1689
|
+
this.seenMessageIds.add(msg.id);
|
|
1690
|
+
this.lastVisitorMessageAt = msg.timestamp;
|
|
1691
|
+
if (typeof window !== "undefined") {
|
|
1692
|
+
window.dispatchEvent(new CustomEvent("kibee-message-received", { detail: msg }));
|
|
1693
|
+
}
|
|
1694
|
+
this.maybeSpeakIncomingMessage(msg);
|
|
1695
|
+
}
|
|
1696
|
+
} else if (event.type === "session_mode_changed") {
|
|
1697
|
+
const payload = event.payload;
|
|
1698
|
+
if (payload.mode !== this.chatMode) {
|
|
1699
|
+
this.chatMode = payload.mode;
|
|
1700
|
+
if (typeof window !== "undefined") {
|
|
1701
|
+
window.dispatchEvent(
|
|
1702
|
+
new CustomEvent("kibee-mode-changed", { detail: payload })
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
} catch {
|
|
1708
|
+
}
|
|
1709
|
+
};
|
|
1710
|
+
stream.onerror = () => {
|
|
1711
|
+
if (stream.readyState === 2) {
|
|
1712
|
+
stream.close();
|
|
1713
|
+
this.visitorStream = null;
|
|
1714
|
+
}
|
|
1715
|
+
this.armSyncTimer();
|
|
1716
|
+
};
|
|
1717
|
+
this.visitorStream = stream;
|
|
1718
|
+
}
|
|
1719
|
+
destroy() {
|
|
1720
|
+
for (const unsubscribe of this.bridgeUnsubscribes) {
|
|
1721
|
+
try {
|
|
1722
|
+
unsubscribe();
|
|
1723
|
+
} catch {
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
this.bridgeUnsubscribes = [];
|
|
1727
|
+
this.inflightHandoffFlowIds.clear();
|
|
1728
|
+
this.bridgeClient?.close();
|
|
1729
|
+
this.bridgeClient = void 0;
|
|
1730
|
+
this.disableAdminSync();
|
|
1731
|
+
this.options.renderer.destroy();
|
|
1732
|
+
this.clearFocus();
|
|
1733
|
+
}
|
|
1734
|
+
/**
|
|
1735
|
+
* S4 T4: run a flow handed off via the bridge. Wraps `runFlow(flowId)` with
|
|
1736
|
+
* an in-flight guard so duplicate `flow:handoff` envelopes for the same
|
|
1737
|
+
* flowId (e.g. retried publish) don't start two concurrent runs.
|
|
1738
|
+
*/
|
|
1739
|
+
async runFlowFromHandoff(flowId) {
|
|
1740
|
+
if (this.inflightHandoffFlowIds.has(flowId)) return;
|
|
1741
|
+
if (!this.bridgeClient) return;
|
|
1742
|
+
this.inflightHandoffFlowIds.add(flowId);
|
|
1743
|
+
try {
|
|
1744
|
+
await this.runFlow(flowId);
|
|
1745
|
+
} finally {
|
|
1746
|
+
this.inflightHandoffFlowIds.delete(flowId);
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
async startSession(flowId) {
|
|
1750
|
+
try {
|
|
1751
|
+
return await this.api.startSession({
|
|
1752
|
+
visitorId: this.visitorId,
|
|
1753
|
+
pageId: this.currentPageId,
|
|
1754
|
+
flowId
|
|
1755
|
+
});
|
|
1756
|
+
} catch {
|
|
1757
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1758
|
+
return {
|
|
1759
|
+
id: `local_${Math.random().toString(36).slice(2, 10)}`,
|
|
1760
|
+
visitorId: this.visitorId,
|
|
1761
|
+
pageId: this.currentPageId,
|
|
1762
|
+
flowId,
|
|
1763
|
+
currentStepIndex: 0,
|
|
1764
|
+
status: "active",
|
|
1765
|
+
startedAt: now,
|
|
1766
|
+
updatedAt: now
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
/**
|
|
1771
|
+
* Creates a server session once the page is known so admin "Visitors" and
|
|
1772
|
+
* analytics can attach to a real `sessionId` (not only when a flow runs).
|
|
1773
|
+
*/
|
|
1774
|
+
async ensurePresenceSession() {
|
|
1775
|
+
if (this.currentSession?.status === "active") return;
|
|
1776
|
+
if (this.presenceSessionPromise) {
|
|
1777
|
+
await this.presenceSessionPromise;
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
this.presenceSessionPromise = (async () => {
|
|
1781
|
+
await this.init();
|
|
1782
|
+
if (this.currentSession?.status === "active") return;
|
|
1783
|
+
this.currentSession = await this.startSession(void 0);
|
|
1784
|
+
})().finally(() => {
|
|
1785
|
+
this.presenceSessionPromise = null;
|
|
1786
|
+
});
|
|
1787
|
+
await this.presenceSessionPromise;
|
|
1788
|
+
}
|
|
1789
|
+
};
|
|
1790
|
+
export {
|
|
1791
|
+
BookmarkMemory,
|
|
1792
|
+
BridgeClient,
|
|
1793
|
+
FlowController,
|
|
1794
|
+
HighlightRenderer,
|
|
1795
|
+
KiBeeApiClient,
|
|
1796
|
+
KiBeeClient,
|
|
1797
|
+
Locator,
|
|
1798
|
+
TargetRegistry
|
|
1799
|
+
};
|