dewee-webchat 0.2.0 → 0.4.1-beta.1
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 +253 -15
- package/dist/{chunk-YV6PXYJZ.js → chunk-7WT3JK64.js} +621 -45
- package/dist/{chunk-JX6AH37X.js → chunk-USXVB3AC.js} +52 -7
- package/dist/dewee-webchat.js +36 -29
- package/dist/handler-DunrbnSD.d.ts +307 -0
- package/dist/index.d.ts +148 -40
- package/dist/index.js +4 -2
- package/dist/protocol-CLbGE9Ns.d.ts +170 -0
- package/dist/react.d.ts +2 -2
- package/dist/react.js +4 -4
- package/dist/server-identity.d.ts +6 -0
- package/dist/server-identity.js +6 -0
- package/dist/server.d.ts +33 -75
- package/dist/server.js +1507 -41
- package/dist/vue.d.ts +22 -4
- package/dist/vue.js +5 -3
- package/package.json +13 -1
- package/dist/protocol-B72Ry-fY.d.ts +0 -92
|
@@ -4,8 +4,12 @@ import {
|
|
|
4
4
|
HEADER_GATEWAY_TOKEN,
|
|
5
5
|
HEADER_GATEWAY_URL,
|
|
6
6
|
SENSITIVITIES,
|
|
7
|
+
isAdmissionErrorCode,
|
|
8
|
+
isIdentityErrorCode,
|
|
9
|
+
isLinkErrorCode,
|
|
10
|
+
parseJobAccessLaunch,
|
|
7
11
|
stripPageBlock
|
|
8
|
-
} from "./chunk-
|
|
12
|
+
} from "./chunk-USXVB3AC.js";
|
|
9
13
|
|
|
10
14
|
// src/actions/directives.ts
|
|
11
15
|
var DIRECTIVE_LANG = "dewee-action";
|
|
@@ -276,36 +280,51 @@ function raceAbort(p, signal) {
|
|
|
276
280
|
}
|
|
277
281
|
|
|
278
282
|
// src/widget/api-client.ts
|
|
283
|
+
var RENEW_CODES = /* @__PURE__ */ new Set(["LINK_EXPIRED", "LINK_REVOKED"]);
|
|
284
|
+
var VERIFIED_REFRESH_MS = 6e4;
|
|
285
|
+
var RESERVED_HEADERS = ["content-type", "authorization", HEADER_GATEWAY_URL, HEADER_GATEWAY_TOKEN];
|
|
279
286
|
var ApiError = class extends Error {
|
|
280
|
-
|
|
287
|
+
/** `retryAfterMs`: how long to wait before sending again (`RATE_LIMITED`). */
|
|
288
|
+
constructor(message, status, code, remediation, retryAfterMs) {
|
|
281
289
|
super(message);
|
|
282
290
|
this.status = status;
|
|
283
291
|
this.code = code;
|
|
292
|
+
this.remediation = remediation;
|
|
293
|
+
this.retryAfterMs = retryAfterMs;
|
|
284
294
|
}
|
|
285
295
|
status;
|
|
286
296
|
code;
|
|
297
|
+
remediation;
|
|
298
|
+
retryAfterMs;
|
|
287
299
|
};
|
|
288
|
-
|
|
300
|
+
function retryAfterOf(bodyMs, res) {
|
|
301
|
+
if (typeof bodyMs === "number" && Number.isFinite(bodyMs) && bodyMs > 0) return bodyMs;
|
|
302
|
+
const header = Number(res.headers.get("retry-after"));
|
|
303
|
+
return Number.isFinite(header) && header > 0 ? header * 1e3 : void 0;
|
|
304
|
+
}
|
|
305
|
+
var storage = (pick) => ({
|
|
289
306
|
get(k) {
|
|
290
307
|
try {
|
|
291
|
-
return
|
|
308
|
+
return pick().getItem(k);
|
|
292
309
|
} catch {
|
|
293
310
|
return null;
|
|
294
311
|
}
|
|
295
312
|
},
|
|
296
313
|
set(k, v) {
|
|
297
314
|
try {
|
|
298
|
-
|
|
315
|
+
pick().setItem(k, v);
|
|
299
316
|
} catch {
|
|
300
317
|
}
|
|
301
318
|
},
|
|
302
319
|
del(k) {
|
|
303
320
|
try {
|
|
304
|
-
|
|
321
|
+
pick().removeItem(k);
|
|
305
322
|
} catch {
|
|
306
323
|
}
|
|
307
324
|
}
|
|
308
|
-
};
|
|
325
|
+
});
|
|
326
|
+
var store = storage(() => localStorage);
|
|
327
|
+
var tabStore = storage(() => sessionStorage);
|
|
309
328
|
var ApiClient = class {
|
|
310
329
|
constructor(o) {
|
|
311
330
|
this.o = o;
|
|
@@ -322,77 +341,220 @@ var ApiClient = class {
|
|
|
322
341
|
get storageKey() {
|
|
323
342
|
return `dewee-webchat:session:${this.o.endpoint}`;
|
|
324
343
|
}
|
|
325
|
-
|
|
326
|
-
|
|
344
|
+
get reuseKey() {
|
|
345
|
+
return `dewee-webchat:reuse:${this.o.endpoint}`;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* The tab's reuse hint for signed-in sessions, or null (none sent by this backend). A logout page can
|
|
349
|
+
* send it to the backend's logout route, whose `handler.revoke(subject, request, hint)` then ends
|
|
350
|
+
* exactly that session.
|
|
351
|
+
*/
|
|
352
|
+
reuseHint() {
|
|
353
|
+
return tabStore.get(this.reuseKey);
|
|
354
|
+
}
|
|
355
|
+
request(url, init) {
|
|
356
|
+
return (this.o.fetch ?? globalThis.fetch)(url, init);
|
|
357
|
+
}
|
|
358
|
+
async headers(auth = false) {
|
|
359
|
+
const h = {};
|
|
360
|
+
const extra = typeof this.o.headers === "function" ? await this.o.headers() : this.o.headers;
|
|
361
|
+
for (const [k, v] of Object.entries(extra ?? {})) {
|
|
362
|
+
if (typeof v === "string" && !RESERVED_HEADERS.includes(k.toLowerCase())) h[k.toLowerCase()] = v;
|
|
363
|
+
}
|
|
364
|
+
h["content-type"] = "application/json";
|
|
327
365
|
if (this.o.gatewayUrl && this.o.gatewayToken) {
|
|
328
366
|
h[HEADER_GATEWAY_URL] = this.o.gatewayUrl;
|
|
329
367
|
h[HEADER_GATEWAY_TOKEN] = this.o.gatewayToken;
|
|
330
368
|
}
|
|
331
|
-
if (auth
|
|
369
|
+
if (auth) {
|
|
370
|
+
const s = this.session && this.fresh(this.session) ? this.session : await this.ensureSession();
|
|
371
|
+
h.authorization = `Bearer ${s.token}`;
|
|
372
|
+
}
|
|
332
373
|
return h;
|
|
333
374
|
}
|
|
334
375
|
async parse(res) {
|
|
335
376
|
const body = await res.json().catch(() => ({}));
|
|
336
|
-
if (!res.ok)
|
|
377
|
+
if (!res.ok) {
|
|
378
|
+
const r = body?.error?.remediation;
|
|
379
|
+
const remediation = r && typeof r.action === "string" && typeof r.audience === "string" ? { action: r.action, audience: r.audience } : void 0;
|
|
380
|
+
throw new ApiError(body?.error?.message || `HTTP ${res.status}`, res.status, body?.error?.code, remediation, retryAfterOf(body?.error?.retryAfterMs, res));
|
|
381
|
+
}
|
|
337
382
|
return body;
|
|
338
383
|
}
|
|
339
384
|
async getConfig(force = false) {
|
|
340
385
|
if (this.config && !force) return this.config;
|
|
341
|
-
this.config = await this.parse(await
|
|
386
|
+
this.config = await this.parse(await this.request(this.url("config"), { headers: await this.headers() }));
|
|
342
387
|
return this.config;
|
|
343
388
|
}
|
|
389
|
+
/** When each signed-in session should be refreshed, on this browser's clock. */
|
|
390
|
+
refreshAt = /* @__PURE__ */ new WeakMap();
|
|
391
|
+
/**
|
|
392
|
+
* Session reuse. A signed-in session follows its schedule (see `scheduleRefresh`). An anonymous
|
|
393
|
+
* token is refreshed once less than 10% of its lifetime (at most 24 h, at least 30 s) remains; a
|
|
394
|
+
* stored token from before `obtainedAt` existed uses the 24 h margin. When the backend reports the
|
|
395
|
+
* lifetime (`expiresIn`), it is counted from receipt, so a skewed browser clock does not matter.
|
|
396
|
+
*/
|
|
397
|
+
fresh(s) {
|
|
398
|
+
if (s.verified) return Date.now() < (this.refreshAt.get(s) ?? 0);
|
|
399
|
+
const reported = typeof s.expiresIn === "number" && s.expiresIn >= 0 && s.obtainedAt;
|
|
400
|
+
const end = reported ? s.obtainedAt + s.expiresIn : s.expiresAt;
|
|
401
|
+
const lifetime = reported ? s.expiresIn : s.obtainedAt ? s.expiresAt - s.obtainedAt : Infinity;
|
|
402
|
+
const margin = Math.min(864e5, Math.max(3e4, lifetime * 0.1));
|
|
403
|
+
return end - Date.now() > margin;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* A signed-in session is refreshed `min(60s, lifetime/2)` before it ends, with the lifetime the
|
|
407
|
+
* backend reports (`expiresIn`) counted from receipt, so a short lifetime is not refreshed on every
|
|
408
|
+
* request and a skewed browser clock does not matter.
|
|
409
|
+
*/
|
|
410
|
+
scheduleRefresh(s, now) {
|
|
411
|
+
const life = typeof s.expiresIn === "number" && s.expiresIn > 0 ? s.expiresIn : s.expiresAt - now;
|
|
412
|
+
this.refreshAt.set(s, now + life - Math.min(VERIFIED_REFRESH_MS, life / 2));
|
|
413
|
+
}
|
|
414
|
+
/** The stored anonymous visitor session, if any. Signed-in sessions are never stored. */
|
|
415
|
+
stored() {
|
|
416
|
+
const saved = store.get(this.storageKey);
|
|
417
|
+
if (!saved) return null;
|
|
418
|
+
try {
|
|
419
|
+
const s = JSON.parse(saved);
|
|
420
|
+
return s.verified ? null : s;
|
|
421
|
+
} catch {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
344
425
|
/** Returns a valid visitor session, creating or refreshing it when needed. */
|
|
345
426
|
ensureSession() {
|
|
346
|
-
if (this.session && this.session
|
|
427
|
+
if (this.session && this.fresh(this.session)) return Promise.resolve(this.session);
|
|
428
|
+
return this.sessionPromise ??= this.loadSession().finally(() => this.sessionPromise = null);
|
|
429
|
+
}
|
|
430
|
+
async loadSession() {
|
|
347
431
|
if (!this.session) {
|
|
348
|
-
const
|
|
349
|
-
if (
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if (s.expiresAt - Date.now() > 864e5) return Promise.resolve(this.session = s);
|
|
353
|
-
this.session = s;
|
|
354
|
-
} catch {
|
|
355
|
-
}
|
|
432
|
+
const s = this.stored();
|
|
433
|
+
if (s) {
|
|
434
|
+
if (this.fresh(s) && !(await this.getConfig()).identity) return this.session = s;
|
|
435
|
+
this.session = s;
|
|
356
436
|
}
|
|
357
437
|
}
|
|
358
|
-
return this.
|
|
438
|
+
return this.createSession();
|
|
359
439
|
}
|
|
360
440
|
async createSession() {
|
|
361
441
|
const cfg = await this.getConfig();
|
|
362
442
|
const body = {};
|
|
363
|
-
|
|
364
|
-
if (
|
|
443
|
+
const previous = this.session && !this.session.verified ? this.session : this.stored();
|
|
444
|
+
if (previous?.token) body.token = previous.token;
|
|
445
|
+
if (cfg.identity) {
|
|
446
|
+
const hint = this.session?.verified ? this.session.reuseHint : tabStore.get(this.reuseKey);
|
|
447
|
+
if (hint) body.reuseHint = hint;
|
|
448
|
+
}
|
|
449
|
+
const post = async () => this.parse(await this.request(this.url("session"), { method: "POST", headers: await this.headers(), body: JSON.stringify(body) }));
|
|
450
|
+
const challenge = async () => {
|
|
365
451
|
if (!this.o.turnstile) throw new ApiError("Verification is required but no Turnstile handler is set", 403, "turnstile_required");
|
|
366
452
|
body.turnstileToken = await this.o.turnstile(cfg.turnstileSiteKey);
|
|
453
|
+
};
|
|
454
|
+
let s;
|
|
455
|
+
try {
|
|
456
|
+
if (cfg.turnstileSiteKey && !(cfg.refreshWithoutTurnstile && !cfg.identity && body.token && !body.reuseHint)) await challenge();
|
|
457
|
+
try {
|
|
458
|
+
s = await post();
|
|
459
|
+
} catch (e) {
|
|
460
|
+
if (!(cfg.turnstileSiteKey && !body.turnstileToken && e instanceof ApiError && e.code === "turnstile_failed")) throw e;
|
|
461
|
+
await challenge();
|
|
462
|
+
s = await post();
|
|
463
|
+
}
|
|
464
|
+
} catch (e) {
|
|
465
|
+
if (e instanceof ApiError && e.status === 401) tabStore.del(this.reuseKey);
|
|
466
|
+
throw e;
|
|
367
467
|
}
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
468
|
+
const now = Date.now();
|
|
469
|
+
const held = { ...s, obtainedAt: now };
|
|
470
|
+
this.session = held;
|
|
471
|
+
if (held.verified) {
|
|
472
|
+
this.scheduleRefresh(held, now);
|
|
473
|
+
if (typeof held.reuseHint === "string" && held.reuseHint !== "") tabStore.set(this.reuseKey, held.reuseHint);
|
|
474
|
+
else tabStore.del(this.reuseKey);
|
|
475
|
+
} else {
|
|
476
|
+
tabStore.del(this.reuseKey);
|
|
477
|
+
const { reuseHint: _none, ...anonymous } = held;
|
|
478
|
+
store.set(this.storageKey, JSON.stringify(anonymous));
|
|
479
|
+
}
|
|
480
|
+
return held;
|
|
374
481
|
}
|
|
482
|
+
/** Drops the session; the next request creates a new one. A stored anonymous visitor is kept for a signed-in session. */
|
|
375
483
|
forgetSession() {
|
|
484
|
+
if (this.session?.verified) {
|
|
485
|
+
tabStore.del(this.reuseKey);
|
|
486
|
+
this.session = null;
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
376
489
|
this.session = null;
|
|
377
490
|
store.del(this.storageKey);
|
|
378
491
|
}
|
|
492
|
+
/**
|
|
493
|
+
* Asks the backend for a new session now (after a sign-in or sign-out on the host page). The
|
|
494
|
+
* stored anonymous visitor id is kept, so an anonymous visitor's history survives.
|
|
495
|
+
*/
|
|
496
|
+
async renewSession() {
|
|
497
|
+
this.session = null;
|
|
498
|
+
return this.ensureSession();
|
|
499
|
+
}
|
|
500
|
+
/** Requests a single-use launch ticket. Does not refresh or swap the signed-in session after refusal. */
|
|
501
|
+
async jobAccess(signal) {
|
|
502
|
+
try {
|
|
503
|
+
const session = await this.ensureSession();
|
|
504
|
+
if (!session.verified) throw new ApiError("Sign in to open job access", 401, "IDENTITY_REQUIRED");
|
|
505
|
+
if (signal.aborted) throw new ApiError("Job access was cancelled", 0, "job_access_unavailable");
|
|
506
|
+
const response = await this.request(this.url("job-access"), {
|
|
507
|
+
method: "POST",
|
|
508
|
+
headers: await this.headers(true),
|
|
509
|
+
body: "{}",
|
|
510
|
+
signal,
|
|
511
|
+
redirect: "error"
|
|
512
|
+
});
|
|
513
|
+
return parseJobAccessLaunch(await this.parse(response));
|
|
514
|
+
} catch (error) {
|
|
515
|
+
const code = error instanceof ApiError && typeof error.code === "string" && ["IDENTITY_REQUIRED", "LINK_EXPIRED", "LINK_REVOKED", "PERMISSION_DENIED", "SOURCE_UNAVAILABLE"].includes(error.code) ? error.code : "job_access_unavailable";
|
|
516
|
+
throw new ApiError("Job access is unavailable. Sign in again or retry.", error instanceof ApiError ? error.status : 0, code);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/** Starts a web-first link of a chat account for the signed-in user (identity backends only). */
|
|
520
|
+
async link() {
|
|
521
|
+
const res = await this.signedInRequest(
|
|
522
|
+
async () => this.request(this.url("link"), { method: "POST", headers: await this.headers(true), body: "{}" })
|
|
523
|
+
);
|
|
524
|
+
return this.parse(res);
|
|
525
|
+
}
|
|
379
526
|
async history(agent, conversationId) {
|
|
380
|
-
await this.
|
|
381
|
-
|
|
527
|
+
const res = await this.signedInRequest(
|
|
528
|
+
async () => this.request(this.url("history", { agent, conversationId }), { headers: await this.headers(true) })
|
|
529
|
+
);
|
|
382
530
|
return (await this.parse(res)).messages;
|
|
383
531
|
}
|
|
532
|
+
/**
|
|
533
|
+
* Sends a request with the current session. A signed-in session the backend refuses (expired,
|
|
534
|
+
* revoked, or another user signed in) is dropped and renewed once, as chat() does. An anonymous
|
|
535
|
+
* visitor's 401 is final, and its stored visitor is kept.
|
|
536
|
+
*/
|
|
537
|
+
async signedInRequest(send) {
|
|
538
|
+
await this.ensureSession();
|
|
539
|
+
const res = await send();
|
|
540
|
+
if (res.status !== 401 || !this.session?.verified) return res;
|
|
541
|
+
this.forgetSession();
|
|
542
|
+
await this.ensureSession();
|
|
543
|
+
return send();
|
|
544
|
+
}
|
|
384
545
|
/** Sends a message and calls `onEvent` for every streamed event. Resolves when the run ends. */
|
|
385
546
|
async chat(input, onEvent, signal) {
|
|
386
547
|
await this.ensureSession();
|
|
387
|
-
let res = await
|
|
548
|
+
let res = await this.request(this.url("chat"), { method: "POST", headers: await this.headers(true), body: JSON.stringify(input), signal });
|
|
388
549
|
if (res.status === 401) {
|
|
389
550
|
this.forgetSession();
|
|
390
551
|
await this.ensureSession();
|
|
391
|
-
res = await
|
|
552
|
+
res = await this.request(this.url("chat"), { method: "POST", headers: await this.headers(true), body: JSON.stringify(input), signal });
|
|
392
553
|
}
|
|
393
554
|
if (!res.ok || !res.body) await this.parse(res);
|
|
394
555
|
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
395
556
|
let buf = "";
|
|
557
|
+
let renew = false;
|
|
396
558
|
while (true) {
|
|
397
559
|
const { done, value } = await reader.read();
|
|
398
560
|
if (done) break;
|
|
@@ -403,14 +565,129 @@ var ApiClient = class {
|
|
|
403
565
|
buf = buf.slice(idx + 2);
|
|
404
566
|
const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trimStart()).join("\n");
|
|
405
567
|
if (!data) continue;
|
|
568
|
+
let event;
|
|
569
|
+
try {
|
|
570
|
+
event = JSON.parse(data);
|
|
571
|
+
} catch {
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
if (event.type === "error" && event.code && RENEW_CODES.has(event.code)) renew = true;
|
|
406
575
|
try {
|
|
407
|
-
onEvent(
|
|
576
|
+
onEvent(event);
|
|
408
577
|
} catch {
|
|
409
578
|
}
|
|
410
579
|
}
|
|
411
580
|
}
|
|
581
|
+
if (renew && this.session?.verified) {
|
|
582
|
+
this.forgetSession();
|
|
583
|
+
await this.ensureSession().catch(() => {
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
// src/widget/job-access.ts
|
|
590
|
+
var JobAccessPopupError = class extends Error {
|
|
591
|
+
constructor(code) {
|
|
592
|
+
super("Job access could not be opened");
|
|
593
|
+
this.code = code;
|
|
412
594
|
}
|
|
595
|
+
code;
|
|
413
596
|
};
|
|
597
|
+
async function openJobAccessTicket(load, signal) {
|
|
598
|
+
const name = `dewee-job-access-${crypto.randomUUID()}`;
|
|
599
|
+
let popup;
|
|
600
|
+
try {
|
|
601
|
+
popup = window.open("about:blank", name, "popup,width=720,height=800");
|
|
602
|
+
} catch {
|
|
603
|
+
throw new JobAccessPopupError("popup_blocked");
|
|
604
|
+
}
|
|
605
|
+
if (!popup) throw new JobAccessPopupError("popup_blocked");
|
|
606
|
+
const controller = new AbortController();
|
|
607
|
+
const cancel = () => controller.abort();
|
|
608
|
+
let rejectAbort;
|
|
609
|
+
const cancelled = new Promise((_resolve, reject) => {
|
|
610
|
+
rejectAbort = () => reject(new JobAccessPopupError(popup.closed ? "popup_closed" : "job_access_unavailable"));
|
|
611
|
+
});
|
|
612
|
+
controller.signal.addEventListener("abort", rejectAbort, { once: true });
|
|
613
|
+
signal?.addEventListener("abort", cancel, { once: true });
|
|
614
|
+
const timer = setTimeout(cancel, 3e4);
|
|
615
|
+
let navigationStarted = false;
|
|
616
|
+
let finishNavigation;
|
|
617
|
+
let refuseNavigation;
|
|
618
|
+
const navigation = new Promise((resolve, reject) => {
|
|
619
|
+
finishNavigation = resolve;
|
|
620
|
+
refuseNavigation = () => reject(new JobAccessPopupError("job_access_unavailable"));
|
|
621
|
+
});
|
|
622
|
+
const poll = setInterval(() => {
|
|
623
|
+
if (popup.closed) {
|
|
624
|
+
cancel();
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
if (!navigationStarted) return;
|
|
628
|
+
try {
|
|
629
|
+
if (popup.location.href !== "about:blank") finishNavigation();
|
|
630
|
+
} catch {
|
|
631
|
+
finishNavigation();
|
|
632
|
+
}
|
|
633
|
+
}, 200);
|
|
634
|
+
window.addEventListener("pagehide", cancel, { once: true });
|
|
635
|
+
let form;
|
|
636
|
+
let input;
|
|
637
|
+
let launchDocument;
|
|
638
|
+
const blocked = (event) => {
|
|
639
|
+
if (event.disposition === "enforce" && (event.effectiveDirective === "form-action" || event.violatedDirective === "form-action")) refuseNavigation();
|
|
640
|
+
};
|
|
641
|
+
try {
|
|
642
|
+
popup.opener = null;
|
|
643
|
+
if (signal?.aborted) cancel();
|
|
644
|
+
const ticket = parseJobAccessLaunch(await Promise.race([load(controller.signal), cancelled]));
|
|
645
|
+
if (popup.closed) throw new JobAccessPopupError("popup_closed");
|
|
646
|
+
if (controller.signal.aborted) throw new JobAccessPopupError("job_access_unavailable");
|
|
647
|
+
launchDocument = popup.document;
|
|
648
|
+
const referrer = launchDocument.createElement("meta");
|
|
649
|
+
referrer.name = "referrer";
|
|
650
|
+
referrer.content = "strict-origin";
|
|
651
|
+
launchDocument.head.append(referrer);
|
|
652
|
+
form = launchDocument.createElement("form");
|
|
653
|
+
form.method = "POST";
|
|
654
|
+
form.action = ticket.bootstrap_url;
|
|
655
|
+
form.target = "_self";
|
|
656
|
+
form.hidden = true;
|
|
657
|
+
input = launchDocument.createElement("input");
|
|
658
|
+
input.type = "hidden";
|
|
659
|
+
input.name = "launch_token";
|
|
660
|
+
input.value = ticket.launch_token;
|
|
661
|
+
form.append(input);
|
|
662
|
+
launchDocument.body.append(form);
|
|
663
|
+
launchDocument.addEventListener("securitypolicyviolation", blocked);
|
|
664
|
+
HTMLFormElement.prototype.submit.call(form);
|
|
665
|
+
navigationStarted = true;
|
|
666
|
+
input.value = "";
|
|
667
|
+
ticket.launch_token = "";
|
|
668
|
+
form.remove();
|
|
669
|
+
await Promise.race([navigation, cancelled]);
|
|
670
|
+
} catch (error) {
|
|
671
|
+
const closed = popup.closed;
|
|
672
|
+
try {
|
|
673
|
+
popup.close();
|
|
674
|
+
} catch {
|
|
675
|
+
}
|
|
676
|
+
if (closed) throw new JobAccessPopupError("popup_closed");
|
|
677
|
+
if (error instanceof JobAccessPopupError) throw error;
|
|
678
|
+
throw error;
|
|
679
|
+
} finally {
|
|
680
|
+
if (input) input.value = "";
|
|
681
|
+
form?.remove();
|
|
682
|
+
launchDocument?.removeEventListener("securitypolicyviolation", blocked);
|
|
683
|
+
clearTimeout(timer);
|
|
684
|
+
clearInterval(poll);
|
|
685
|
+
window.removeEventListener("pagehide", cancel);
|
|
686
|
+
signal?.removeEventListener("abort", cancel);
|
|
687
|
+
controller.signal.removeEventListener("abort", rejectAbort);
|
|
688
|
+
controller.abort();
|
|
689
|
+
}
|
|
690
|
+
}
|
|
414
691
|
|
|
415
692
|
// src/widget/confirm-dialog.ts
|
|
416
693
|
var seq = 0;
|
|
@@ -513,7 +790,38 @@ var en = {
|
|
|
513
790
|
confirmDestructive: "This can't be undone.",
|
|
514
791
|
allow: "Allow",
|
|
515
792
|
deny: "Deny",
|
|
516
|
-
actionLimit: (n) => `Stopped after ${n} automatic action rounds. Send a message to continue
|
|
793
|
+
actionLimit: (n) => `Stopped after ${n} automatic action rounds. Send a message to continue.`,
|
|
794
|
+
identityErrors: {
|
|
795
|
+
IDENTITY_REQUIRED: "Please sign in to chat.",
|
|
796
|
+
IDENTITY_LINK_REQUIRED: "Link your chat account to continue.",
|
|
797
|
+
IDENTITY_CONFLICT: "This chat account is already linked to another user. Unlink it there first.",
|
|
798
|
+
LINK_EXPIRED: "Your session expired. Send your message again.",
|
|
799
|
+
LINK_REVOKED: "Your access has ended. Sign in again to continue.",
|
|
800
|
+
PERMISSION_DENIED: "You don't have access to this.",
|
|
801
|
+
SOURCE_UNAVAILABLE: "We couldn't check your sign-in right now. Please try again shortly."
|
|
802
|
+
},
|
|
803
|
+
admissionErrors: {
|
|
804
|
+
POLICY_DENIED: "This message can't be sent. Try shortening or rephrasing it.",
|
|
805
|
+
RATE_LIMITED: "You're sending messages too quickly.",
|
|
806
|
+
CHALLENGE_REQUIRED: "Extra verification is needed. Please try again later."
|
|
807
|
+
},
|
|
808
|
+
cooldown: (s) => `You can send again in ${s}s`,
|
|
809
|
+
cooldownStart: (s) => `Sending is paused. You can send again in ${s} second${s === 1 ? "" : "s"}.`,
|
|
810
|
+
cooldownOver: "You can send messages again.",
|
|
811
|
+
linkErrors: {
|
|
812
|
+
idempotency_key_reused: "This link request was already used. Please try again.",
|
|
813
|
+
link_in_progress: "A link is already in progress. Use the code you got, or wait for it to expire.",
|
|
814
|
+
linking_unavailable: "Linking is not available right now. Please try again later."
|
|
815
|
+
},
|
|
816
|
+
linkIntro: "Send this code in a direct message from the chat account you want to link:",
|
|
817
|
+
linkContinueOn: (channel) => `Continue on ${channel}`,
|
|
818
|
+
linkExpires: (m) => `The code expires in ${m} min.`,
|
|
819
|
+
linkNoTargets: "No chat app is set up for linking yet.",
|
|
820
|
+
jobAccess: "Job access",
|
|
821
|
+
jobAccessOpened: "Job access opened in a new window. Review and confirm any permissions there.",
|
|
822
|
+
jobAccessUnavailable: "Job access could not be opened. Sign in again or retry.",
|
|
823
|
+
jobAccessPopupBlocked: "Allow popups for this site, then choose Job access again.",
|
|
824
|
+
jobAccessPopupClosed: "The window was closed. Choose Job access again to retry."
|
|
517
825
|
};
|
|
518
826
|
var vi = {
|
|
519
827
|
title: "Tr\u1EE3 l\xFD",
|
|
@@ -542,13 +850,45 @@ var vi = {
|
|
|
542
850
|
confirmDestructive: "Thao t\xE1c n\xE0y kh\xF4ng th\u1EC3 ho\xE0n t\xE1c.",
|
|
543
851
|
allow: "Cho ph\xE9p",
|
|
544
852
|
deny: "T\u1EEB ch\u1ED1i",
|
|
545
|
-
actionLimit: (n) => `\u0110\xE3 d\u1EEBng sau ${n} l\u01B0\u1EE3t thao t\xE1c t\u1EF1 \u0111\u1ED9ng. G\u1EEDi tin nh\u1EAFn \u0111\u1EC3 ti\u1EBFp t\u1EE5c
|
|
853
|
+
actionLimit: (n) => `\u0110\xE3 d\u1EEBng sau ${n} l\u01B0\u1EE3t thao t\xE1c t\u1EF1 \u0111\u1ED9ng. G\u1EEDi tin nh\u1EAFn \u0111\u1EC3 ti\u1EBFp t\u1EE5c.`,
|
|
854
|
+
identityErrors: {
|
|
855
|
+
IDENTITY_REQUIRED: "B\u1EA1n \u0111\u0103ng nh\u1EADp \u0111\u1EC3 tr\xF2 chuy\u1EC7n nh\xE9.",
|
|
856
|
+
IDENTITY_LINK_REQUIRED: "B\u1EA1n c\u1EA7n li\xEAn k\u1EBFt t\xE0i kho\u1EA3n chat \u0111\u1EC3 ti\u1EBFp t\u1EE5c.",
|
|
857
|
+
IDENTITY_CONFLICT: "T\xE0i kho\u1EA3n chat n\xE0y \u0111\xE3 li\xEAn k\u1EBFt v\u1EDBi ng\u01B0\u1EDDi d\xF9ng kh\xE1c. H\xE3y h\u1EE7y li\xEAn k\u1EBFt \u1EDF \u0111\xF3 tr\u01B0\u1EDBc.",
|
|
858
|
+
LINK_EXPIRED: "Phi\xEAn \u0111\u0103ng nh\u1EADp \u0111\xE3 h\u1EBFt h\u1EA1n. B\u1EA1n g\u1EEDi l\u1EA1i tin nh\u1EAFn gi\xFAp m\xECnh nh\xE9.",
|
|
859
|
+
LINK_REVOKED: "Quy\u1EC1n truy c\u1EADp c\u1EE7a b\u1EA1n \u0111\xE3 k\u1EBFt th\xFAc. H\xE3y \u0111\u0103ng nh\u1EADp l\u1EA1i \u0111\u1EC3 ti\u1EBFp t\u1EE5c.",
|
|
860
|
+
PERMISSION_DENIED: "B\u1EA1n kh\xF4ng c\xF3 quy\u1EC1n th\u1EF1c hi\u1EC7n vi\u1EC7c n\xE0y.",
|
|
861
|
+
SOURCE_UNAVAILABLE: "Hi\u1EC7n ch\u01B0a ki\u1EC3m tra \u0111\u01B0\u1EE3c \u0111\u0103ng nh\u1EADp c\u1EE7a b\u1EA1n. B\u1EA1n th\u1EED l\u1EA1i sau \xEDt ph\xFAt nh\xE9."
|
|
862
|
+
},
|
|
863
|
+
admissionErrors: {
|
|
864
|
+
POLICY_DENIED: "Kh\xF4ng g\u1EEDi \u0111\u01B0\u1EE3c tin nh\u1EAFn n\xE0y. B\u1EA1n th\u1EED r\xFAt g\u1ECDn ho\u1EB7c di\u1EC5n \u0111\u1EA1t l\u1EA1i nh\xE9.",
|
|
865
|
+
RATE_LIMITED: "B\u1EA1n \u0111ang g\u1EEDi tin nh\u1EAFn h\u01A1i nhanh.",
|
|
866
|
+
CHALLENGE_REQUIRED: "C\u1EA7n x\xE1c minh th\xEAm. B\u1EA1n th\u1EED l\u1EA1i sau nh\xE9."
|
|
867
|
+
},
|
|
868
|
+
cooldown: (s) => `B\u1EA1n c\xF3 th\u1EC3 g\u1EEDi l\u1EA1i sau ${s} gi\xE2y`,
|
|
869
|
+
cooldownStart: (s) => `T\u1EA1m d\u1EEBng g\u1EEDi tin nh\u1EAFn. B\u1EA1n c\xF3 th\u1EC3 g\u1EEDi l\u1EA1i sau ${s} gi\xE2y.`,
|
|
870
|
+
cooldownOver: "B\u1EA1n c\xF3 th\u1EC3 g\u1EEDi tin nh\u1EAFn ti\u1EBFp r\u1ED3i.",
|
|
871
|
+
linkErrors: {
|
|
872
|
+
idempotency_key_reused: "Y\xEAu c\u1EA7u li\xEAn k\u1EBFt n\xE0y \u0111\xE3 \u0111\u01B0\u1EE3c d\xF9ng. B\u1EA1n th\u1EED l\u1EA1i nh\xE9.",
|
|
873
|
+
link_in_progress: "\u0110ang c\xF3 m\u1ED9t li\xEAn k\u1EBFt ch\u01B0a ho\xE0n t\u1EA5t. H\xE3y d\xF9ng m\xE3 \u0111\xE3 nh\u1EADn ho\u1EB7c ch\u1EDD m\xE3 h\u1EBFt h\u1EA1n.",
|
|
874
|
+
linking_unavailable: "Hi\u1EC7n ch\u01B0a th\u1EC3 li\xEAn k\u1EBFt. B\u1EA1n th\u1EED l\u1EA1i sau nh\xE9."
|
|
875
|
+
},
|
|
876
|
+
linkIntro: "G\u1EEDi m\xE3 n\xE0y trong tin nh\u1EAFn ri\xEAng t\u1EEB t\xE0i kho\u1EA3n chat b\u1EA1n mu\u1ED1n li\xEAn k\u1EBFt:",
|
|
877
|
+
linkContinueOn: (channel) => `Ti\u1EBFp t\u1EE5c tr\xEAn ${channel}`,
|
|
878
|
+
linkExpires: (m) => `M\xE3 h\u1EBFt h\u1EA1n sau ${m} ph\xFAt.`,
|
|
879
|
+
linkNoTargets: "Ch\u01B0a c\xF3 \u1EE9ng d\u1EE5ng chat n\xE0o \u0111\u01B0\u1EE3c thi\u1EBFt l\u1EADp \u0111\u1EC3 li\xEAn k\u1EBFt.",
|
|
880
|
+
jobAccess: "Quy\u1EC1n c\xF4ng vi\u1EC7c",
|
|
881
|
+
jobAccessOpened: "\u0110\xE3 m\u1EDF c\u1EEDa s\u1ED5 quy\u1EC1n c\xF4ng vi\u1EC7c. H\xE3y xem l\u1EA1i v\xE0 x\xE1c nh\u1EADn quy\u1EC1n t\u1EA1i \u0111\xF3.",
|
|
882
|
+
jobAccessUnavailable: "Ch\u01B0a m\u1EDF \u0111\u01B0\u1EE3c quy\u1EC1n c\xF4ng vi\u1EC7c. H\xE3y \u0111\u0103ng nh\u1EADp l\u1EA1i ho\u1EB7c th\u1EED l\u1EA1i.",
|
|
883
|
+
jobAccessPopupBlocked: "Cho ph\xE9p c\u1EEDa s\u1ED5 b\u1EADt l\xEAn cho trang n\xE0y r\u1ED3i ch\u1ECDn Quy\u1EC1n c\xF4ng vi\u1EC7c l\u1EA7n n\u1EEFa.",
|
|
884
|
+
jobAccessPopupClosed: "C\u1EEDa s\u1ED5 \u0111\xE3 \u0111\xF3ng. Ch\u1ECDn Quy\u1EC1n c\xF4ng vi\u1EC7c l\u1EA7n n\u1EEFa \u0111\u1EC3 th\u1EED l\u1EA1i."
|
|
546
885
|
};
|
|
547
886
|
function pickStrings(locale) {
|
|
548
887
|
const l = (locale || (typeof document !== "undefined" ? document.documentElement.lang : "") || (typeof navigator !== "undefined" ? navigator.language : "") || "en").toLowerCase();
|
|
549
888
|
return l.startsWith("vi") ? vi : en;
|
|
550
889
|
}
|
|
551
890
|
var icons = {
|
|
891
|
+
jobAccess: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12 3 8 4v5c0 5-8 9-8 9s-8-4-8-9V7l8-4Z"/><path d="m8 12 3 3 5-6"/></svg>`,
|
|
552
892
|
action: `<svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8Z"/></svg>`,
|
|
553
893
|
chat: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a8.5 8.5 0 0 1-12.4 7.6L3 21l1.4-5.1A8.5 8.5 0 1 1 21 12Z"/><path d="M8.5 11h.01M12 11h.01M15.5 11h.01"/></svg>`,
|
|
554
894
|
close: `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M18 6 6 18M6 6l12 12"/></svg>`,
|
|
@@ -729,6 +1069,46 @@ function renderMarkdown(doc, src) {
|
|
|
729
1069
|
return frag;
|
|
730
1070
|
}
|
|
731
1071
|
|
|
1072
|
+
// src/widget/send-cooldown.ts
|
|
1073
|
+
var SendCooldown = class {
|
|
1074
|
+
/**
|
|
1075
|
+
* `onTick` runs once a second while cooling down, with the whole seconds left; `onEnd` runs once
|
|
1076
|
+
* when sending is allowed again.
|
|
1077
|
+
*/
|
|
1078
|
+
constructor(onTick, onEnd) {
|
|
1079
|
+
this.onTick = onTick;
|
|
1080
|
+
this.onEnd = onEnd;
|
|
1081
|
+
}
|
|
1082
|
+
onTick;
|
|
1083
|
+
onEnd;
|
|
1084
|
+
until = 0;
|
|
1085
|
+
tick;
|
|
1086
|
+
done;
|
|
1087
|
+
get active() {
|
|
1088
|
+
return Date.now() < this.until;
|
|
1089
|
+
}
|
|
1090
|
+
get secondsLeft() {
|
|
1091
|
+
return Math.max(0, Math.ceil((this.until - Date.now()) / 1e3));
|
|
1092
|
+
}
|
|
1093
|
+
/** Starts (or extends) the cooldown; a shorter one never cuts a running one short. */
|
|
1094
|
+
start(ms) {
|
|
1095
|
+
const until = Date.now() + Math.max(0, ms);
|
|
1096
|
+
if (until <= this.until) return;
|
|
1097
|
+
this.until = until;
|
|
1098
|
+
this.clear();
|
|
1099
|
+
this.tick = setInterval(() => this.onTick(this.secondsLeft), 1e3);
|
|
1100
|
+
this.done = setTimeout(() => {
|
|
1101
|
+
this.until = 0;
|
|
1102
|
+
this.clear();
|
|
1103
|
+
this.onEnd();
|
|
1104
|
+
}, until - Date.now());
|
|
1105
|
+
}
|
|
1106
|
+
clear() {
|
|
1107
|
+
clearInterval(this.tick);
|
|
1108
|
+
clearTimeout(this.done);
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
|
|
732
1112
|
// src/widget/styles.ts
|
|
733
1113
|
var styles = (
|
|
734
1114
|
/* css */
|
|
@@ -943,6 +1323,10 @@ details > summary svg.ic { width: 14px; height: 14px; flex: none; }
|
|
|
943
1323
|
.action[data-status="awaiting-confirmation"] .action-state { color: #b06f00; }
|
|
944
1324
|
.action[data-status="denied"] .action-state, .action[data-status="error"] .action-state { color: #c2334f; }
|
|
945
1325
|
.notice { margin: -6px 0 16px; font-size: 12px; color: var(--dw-muted); text-align: center; }
|
|
1326
|
+
.msg.user.refused { flex-direction: column; align-items: flex-end; }
|
|
1327
|
+
.refusal { margin-top: 6px; max-width: 86%; font-size: 12.5px; line-height: 1.45; color: #c2334f; text-align: right; }
|
|
1328
|
+
.cooldown { margin: 0 2px 8px; font-size: 12px; color: var(--dw-muted); }
|
|
1329
|
+
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
|
946
1330
|
.confirm-backdrop {
|
|
947
1331
|
position: absolute; inset: 0; z-index: 2; display: flex; align-items: flex-end; justify-content: center; padding: 12px;
|
|
948
1332
|
background: color-mix(in srgb, var(--dw-fg) 28%, transparent); animation: rise .18s ease both;
|
|
@@ -1001,6 +1385,7 @@ textarea::placeholder { color: var(--dw-muted); }
|
|
|
1001
1385
|
);
|
|
1002
1386
|
|
|
1003
1387
|
// src/widget/element.ts
|
|
1388
|
+
var DEFAULT_COOLDOWN_MS = 5e3;
|
|
1004
1389
|
var BOOL_ATTRS = ["show-reasoning", "show-tool-calls", "allow-agent-switch", "open", "persist", "hide-branding", "launcher-avatar"];
|
|
1005
1390
|
var ATTRS = [
|
|
1006
1391
|
"endpoint",
|
|
@@ -1073,6 +1458,7 @@ var DeweeChatElement = class extends ElementBase {
|
|
|
1073
1458
|
conversationId = "";
|
|
1074
1459
|
messages = [];
|
|
1075
1460
|
running = null;
|
|
1461
|
+
jobAccessLaunch = null;
|
|
1076
1462
|
activated = false;
|
|
1077
1463
|
strings = pickStrings();
|
|
1078
1464
|
mql = null;
|
|
@@ -1080,6 +1466,8 @@ var DeweeChatElement = class extends ElementBase {
|
|
|
1080
1466
|
propActionNames = /* @__PURE__ */ new Set();
|
|
1081
1467
|
hooks = {};
|
|
1082
1468
|
acks = /* @__PURE__ */ new Map();
|
|
1469
|
+
/** Pause after a RATE_LIMITED refusal; send stays disabled until it ends. */
|
|
1470
|
+
cooldown = new SendCooldown((s) => this.renderCooldown(s), () => this.endCooldown());
|
|
1083
1471
|
els;
|
|
1084
1472
|
constructor() {
|
|
1085
1473
|
super();
|
|
@@ -1101,11 +1489,13 @@ var DeweeChatElement = class extends ElementBase {
|
|
|
1101
1489
|
}
|
|
1102
1490
|
/** Updates configuration at runtime (wins over attributes). */
|
|
1103
1491
|
configure(partial) {
|
|
1104
|
-
const { actions, confirm, getPageContext, ...rest } = partial;
|
|
1492
|
+
const { actions, confirm, getPageContext, headers, fetch, ...rest } = partial;
|
|
1105
1493
|
const has = (k) => Object.prototype.hasOwnProperty.call(partial, k);
|
|
1106
1494
|
if (has("actions")) this.setPropActions(actions ?? []);
|
|
1107
1495
|
if (has("confirm")) this.hooks.confirm = confirm;
|
|
1108
1496
|
if (has("getPageContext")) this.hooks.getPageContext = getPageContext;
|
|
1497
|
+
if (has("headers")) this.hooks.headers = headers;
|
|
1498
|
+
if (has("fetch")) this.hooks.fetch = fetch;
|
|
1109
1499
|
if (!Object.keys(rest).length) return;
|
|
1110
1500
|
this.overrides = { ...this.overrides, ...rest };
|
|
1111
1501
|
if (this.els) this.applyConfig();
|
|
@@ -1119,6 +1509,20 @@ var DeweeChatElement = class extends ElementBase {
|
|
|
1119
1509
|
this.propActionNames.delete(name);
|
|
1120
1510
|
return this.registry.unregister(name);
|
|
1121
1511
|
}
|
|
1512
|
+
/** Extra request headers (property only; same as `configure({ headers })`). */
|
|
1513
|
+
get headers() {
|
|
1514
|
+
return this.hooks.headers;
|
|
1515
|
+
}
|
|
1516
|
+
set headers(v) {
|
|
1517
|
+
this.hooks.headers = v;
|
|
1518
|
+
}
|
|
1519
|
+
/** Custom fetch for widget requests (property only; same as `configure({ fetch })`). */
|
|
1520
|
+
get fetch() {
|
|
1521
|
+
return this.hooks.fetch;
|
|
1522
|
+
}
|
|
1523
|
+
set fetch(v) {
|
|
1524
|
+
this.hooks.fetch = v;
|
|
1525
|
+
}
|
|
1122
1526
|
/** Names of the currently registered page actions. */
|
|
1123
1527
|
get actionNames() {
|
|
1124
1528
|
return this.registry.names();
|
|
@@ -1150,7 +1554,7 @@ var DeweeChatElement = class extends ElementBase {
|
|
|
1150
1554
|
/** Sends a message programmatically (opens the widget first). */
|
|
1151
1555
|
async send(text) {
|
|
1152
1556
|
const message = text.trim();
|
|
1153
|
-
if (!message || this.running) return;
|
|
1557
|
+
if (!message || this.running || this.cooldown.active) return;
|
|
1154
1558
|
if (this.config.mode !== "inline" && !this.hasAttribute("data-open")) this.open();
|
|
1155
1559
|
await this.activate();
|
|
1156
1560
|
this.els.input.value = "";
|
|
@@ -1293,6 +1697,101 @@ var DeweeChatElement = class extends ElementBase {
|
|
|
1293
1697
|
stop() {
|
|
1294
1698
|
this.running?.abort();
|
|
1295
1699
|
}
|
|
1700
|
+
/**
|
|
1701
|
+
* Call after the visitor signs in or out on the host page: the next message uses a session for
|
|
1702
|
+
* the current user, and the conversation is reloaded for them.
|
|
1703
|
+
*/
|
|
1704
|
+
async renewSession() {
|
|
1705
|
+
this.stop();
|
|
1706
|
+
try {
|
|
1707
|
+
await this.client().renewSession();
|
|
1708
|
+
} catch (e) {
|
|
1709
|
+
this.showError(null, e);
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
if (this.activated) await this.loadConversation();
|
|
1713
|
+
}
|
|
1714
|
+
/**
|
|
1715
|
+
* The tab's signed-in reuse hint, or null. Not a credential: send it to your logout route for
|
|
1716
|
+
* `handler.revoke(subject, request, hint)`, which then ends exactly that session.
|
|
1717
|
+
*/
|
|
1718
|
+
reuseHint() {
|
|
1719
|
+
return this.client().reuseHint();
|
|
1720
|
+
}
|
|
1721
|
+
/**
|
|
1722
|
+
* Web-first linking for a signed-in user: shows a proof code and the chat apps to send it to
|
|
1723
|
+
* ("Continue on Telegram"). The user never types an account id. Resolves null on failure (the
|
|
1724
|
+
* error is shown in the chat and emitted as `dewee:error`).
|
|
1725
|
+
*/
|
|
1726
|
+
async startLink() {
|
|
1727
|
+
if (this.config.mode !== "inline" && !this.hasAttribute("data-open")) this.open();
|
|
1728
|
+
await this.activate();
|
|
1729
|
+
try {
|
|
1730
|
+
const link = await this.client().link();
|
|
1731
|
+
this.renderLink(link);
|
|
1732
|
+
this.emit("dewee:link", { expiresAt: link.expiresAt, deepLinks: link.deepLinks });
|
|
1733
|
+
return link;
|
|
1734
|
+
} catch (e) {
|
|
1735
|
+
this.showError(null, e);
|
|
1736
|
+
return null;
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
/** Opens the hosted permission review; the verified session stays on the backend. */
|
|
1740
|
+
async openJobAccess() {
|
|
1741
|
+
if (this.jobAccessLaunch) return;
|
|
1742
|
+
const controller = new AbortController();
|
|
1743
|
+
this.jobAccessLaunch = controller;
|
|
1744
|
+
this.els.jobAccess.disabled = true;
|
|
1745
|
+
try {
|
|
1746
|
+
await openJobAccessTicket((signal) => this.client().jobAccess(signal), controller.signal);
|
|
1747
|
+
this.showNotice(this.strings.jobAccessOpened);
|
|
1748
|
+
} catch (error) {
|
|
1749
|
+
if (controller.signal.aborted) return;
|
|
1750
|
+
if (error instanceof JobAccessPopupError) {
|
|
1751
|
+
this.showNotice(error.code === "popup_blocked" ? this.strings.jobAccessPopupBlocked : error.code === "popup_closed" ? this.strings.jobAccessPopupClosed : this.strings.jobAccessUnavailable);
|
|
1752
|
+
} else if (error instanceof ApiError && isIdentityErrorCode(error.code)) {
|
|
1753
|
+
this.showError(null, error);
|
|
1754
|
+
} else this.showNotice(this.strings.jobAccessUnavailable);
|
|
1755
|
+
} finally {
|
|
1756
|
+
this.jobAccessLaunch = null;
|
|
1757
|
+
this.els.jobAccess.disabled = false;
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
renderLink(link) {
|
|
1761
|
+
const s = this.strings;
|
|
1762
|
+
const root = document.createElement("div");
|
|
1763
|
+
root.className = "msg assistant link";
|
|
1764
|
+
const bubble = document.createElement("div");
|
|
1765
|
+
bubble.className = "bubble";
|
|
1766
|
+
const md = document.createElement("div");
|
|
1767
|
+
md.className = "md";
|
|
1768
|
+
const p = (text) => {
|
|
1769
|
+
const el2 = document.createElement("p");
|
|
1770
|
+
el2.textContent = text;
|
|
1771
|
+
return el2;
|
|
1772
|
+
};
|
|
1773
|
+
const code = document.createElement("code");
|
|
1774
|
+
code.textContent = link.proofCode;
|
|
1775
|
+
const codeLine = document.createElement("p");
|
|
1776
|
+
codeLine.appendChild(code);
|
|
1777
|
+
md.append(p(s.linkIntro), codeLine);
|
|
1778
|
+
if (link.deepLinks.length) {
|
|
1779
|
+
const list = document.createElement("ul");
|
|
1780
|
+
for (const t of link.deepLinks) {
|
|
1781
|
+
const li = document.createElement("li");
|
|
1782
|
+
const label = t.displayName || t.name;
|
|
1783
|
+
li.textContent = s.linkContinueOn(channelLabel(t.channelType)) + (label ? ` \xB7 ${label}` : "");
|
|
1784
|
+
list.appendChild(li);
|
|
1785
|
+
}
|
|
1786
|
+
md.appendChild(list);
|
|
1787
|
+
} else md.appendChild(p(s.linkNoTargets));
|
|
1788
|
+
md.appendChild(p(s.linkExpires(Math.max(1, Math.round((link.expiresAt - Date.now()) / 6e4)))));
|
|
1789
|
+
bubble.appendChild(md);
|
|
1790
|
+
root.appendChild(bubble);
|
|
1791
|
+
if (!this.messages.length) this.els.log.replaceChildren();
|
|
1792
|
+
this.els.log.appendChild(root);
|
|
1793
|
+
this.scrollToBottom(true);
|
|
1794
|
+
}
|
|
1296
1795
|
/** Starts a fresh conversation with the current agent. */
|
|
1297
1796
|
newConversation() {
|
|
1298
1797
|
this.stop();
|
|
@@ -1312,6 +1811,7 @@ var DeweeChatElement = class extends ElementBase {
|
|
|
1312
1811
|
}
|
|
1313
1812
|
}
|
|
1314
1813
|
disconnectedCallback() {
|
|
1814
|
+
this.jobAccessLaunch?.abort();
|
|
1315
1815
|
this.stop();
|
|
1316
1816
|
this.mql?.removeEventListener("change", this.onScheme);
|
|
1317
1817
|
}
|
|
@@ -1331,7 +1831,13 @@ var DeweeChatElement = class extends ElementBase {
|
|
|
1331
1831
|
endpoint: c.endpoint,
|
|
1332
1832
|
gatewayUrl: c.gatewayUrl,
|
|
1333
1833
|
gatewayToken: c.gatewayToken,
|
|
1334
|
-
turnstile: (siteKey) => this.challenge(siteKey)
|
|
1834
|
+
turnstile: (siteKey) => this.challenge(siteKey),
|
|
1835
|
+
// Read the current hooks on every request so configure({ headers, fetch }) applies at once.
|
|
1836
|
+
headers: () => {
|
|
1837
|
+
const h = this.hooks.headers;
|
|
1838
|
+
return (typeof h === "function" ? h() : h) ?? {};
|
|
1839
|
+
},
|
|
1840
|
+
fetch: (input, init) => (this.hooks.fetch ?? globalThis.fetch)(input, init)
|
|
1335
1841
|
});
|
|
1336
1842
|
}
|
|
1337
1843
|
return this.api;
|
|
@@ -1383,6 +1889,8 @@ var DeweeChatElement = class extends ElementBase {
|
|
|
1383
1889
|
e.close.title = s.close;
|
|
1384
1890
|
e.newChat.setAttribute("aria-label", s.newChat);
|
|
1385
1891
|
e.newChat.title = s.newChat;
|
|
1892
|
+
e.jobAccess.setAttribute("aria-label", s.jobAccess);
|
|
1893
|
+
e.jobAccess.title = s.jobAccess;
|
|
1386
1894
|
e.input.placeholder = c.placeholder || s.placeholder;
|
|
1387
1895
|
e.foot.hidden = !!c.hideBranding;
|
|
1388
1896
|
e.foot.querySelector(".by").textContent = s.poweredBy + " ";
|
|
@@ -1410,11 +1918,14 @@ var DeweeChatElement = class extends ElementBase {
|
|
|
1410
1918
|
<div class="subtitle"><span class="dot"></span><span class="status"></span><select class="agent-select" hidden></select></div>
|
|
1411
1919
|
</div>
|
|
1412
1920
|
<button class="icon-btn new">${icons.newChat}</button>
|
|
1921
|
+
<button class="icon-btn job-access" hidden>${icons.jobAccess}</button>
|
|
1413
1922
|
<button class="icon-btn close">${icons.close}</button>
|
|
1414
1923
|
</header>
|
|
1415
1924
|
<div class="log" part="log" role="log" aria-live="polite"></div>
|
|
1416
1925
|
<div class="challenge"><slot name="challenge"></slot></div>
|
|
1417
1926
|
<form class="composer" part="composer">
|
|
1927
|
+
<div class="cooldown" part="cooldown" aria-hidden="true" hidden></div>
|
|
1928
|
+
<div class="sr-only announcer" role="status" aria-live="polite"></div>
|
|
1418
1929
|
<div class="box">
|
|
1419
1930
|
<textarea rows="1" maxlength="4000"></textarea>
|
|
1420
1931
|
<button class="send" type="submit"></button>
|
|
@@ -1435,8 +1946,11 @@ var DeweeChatElement = class extends ElementBase {
|
|
|
1435
1946
|
input: $("textarea"),
|
|
1436
1947
|
send: $(".send"),
|
|
1437
1948
|
newChat: $(".new"),
|
|
1949
|
+
jobAccess: $(".job-access"),
|
|
1438
1950
|
close: $(".close"),
|
|
1439
|
-
foot: $(".foot")
|
|
1951
|
+
foot: $(".foot"),
|
|
1952
|
+
cooldown: $(".cooldown"),
|
|
1953
|
+
announcer: $(".announcer")
|
|
1440
1954
|
};
|
|
1441
1955
|
const e = this.els;
|
|
1442
1956
|
e.launcher.addEventListener("click", () => this.toggle());
|
|
@@ -1445,6 +1959,9 @@ var DeweeChatElement = class extends ElementBase {
|
|
|
1445
1959
|
this.following = this.nearBottom();
|
|
1446
1960
|
}, { passive: true });
|
|
1447
1961
|
e.newChat.addEventListener("click", () => this.newConversation());
|
|
1962
|
+
e.jobAccess.addEventListener("click", () => {
|
|
1963
|
+
void this.openJobAccess();
|
|
1964
|
+
});
|
|
1448
1965
|
e.select.addEventListener("change", () => this.switchAgent(e.select.value));
|
|
1449
1966
|
$("form").addEventListener("submit", (ev) => {
|
|
1450
1967
|
ev.preventDefault();
|
|
@@ -1474,13 +1991,32 @@ var DeweeChatElement = class extends ElementBase {
|
|
|
1474
1991
|
b.innerHTML = this.running ? icons.stop : icons.send;
|
|
1475
1992
|
b.setAttribute("aria-label", this.running ? this.strings.stop : this.strings.send);
|
|
1476
1993
|
b.title = b.getAttribute("aria-label");
|
|
1477
|
-
b.disabled = !this.running && !this.els.input.value.trim();
|
|
1994
|
+
b.disabled = !this.running && (this.cooldown.active || !this.els.input.value.trim());
|
|
1995
|
+
}
|
|
1996
|
+
/** Disables sending for `ms` and announces it; the visible countdown is not read out every second. */
|
|
1997
|
+
startCooldown(ms) {
|
|
1998
|
+
this.cooldown.start(typeof ms === "number" && Number.isFinite(ms) && ms > 0 ? ms : DEFAULT_COOLDOWN_MS);
|
|
1999
|
+
if (!this.cooldown.active) return;
|
|
2000
|
+
this.renderCooldown(this.cooldown.secondsLeft);
|
|
2001
|
+
this.els.announcer.textContent = this.strings.cooldownStart(this.cooldown.secondsLeft);
|
|
2002
|
+
this.updateSendButton();
|
|
2003
|
+
}
|
|
2004
|
+
renderCooldown(secondsLeft) {
|
|
2005
|
+
this.els.cooldown.hidden = false;
|
|
2006
|
+
this.els.cooldown.textContent = this.strings.cooldown(secondsLeft);
|
|
2007
|
+
}
|
|
2008
|
+
endCooldown() {
|
|
2009
|
+
this.els.cooldown.hidden = true;
|
|
2010
|
+
this.els.cooldown.textContent = "";
|
|
2011
|
+
this.els.announcer.textContent = this.strings.cooldownOver;
|
|
2012
|
+
this.updateSendButton();
|
|
1478
2013
|
}
|
|
1479
2014
|
setRunning(ctrl) {
|
|
1480
2015
|
this.running = ctrl;
|
|
1481
2016
|
this.updateSendButton();
|
|
1482
2017
|
}
|
|
1483
2018
|
renderSubtitle() {
|
|
2019
|
+
this.els.jobAccess.hidden = this.server?.identity !== true;
|
|
1484
2020
|
const e = this.els;
|
|
1485
2021
|
const c = this.config;
|
|
1486
2022
|
const agents = this.server?.agents ?? [];
|
|
@@ -1601,6 +2137,15 @@ var DeweeChatElement = class extends ElementBase {
|
|
|
1601
2137
|
const c = this.config;
|
|
1602
2138
|
if (m.role === "user") {
|
|
1603
2139
|
v.md.textContent = m.content;
|
|
2140
|
+
v.root.classList.toggle("refused", !!m.refusal);
|
|
2141
|
+
let note = v.root.querySelector(".refusal");
|
|
2142
|
+
if (m.refusal && !note) {
|
|
2143
|
+
note = document.createElement("div");
|
|
2144
|
+
note.className = "refusal";
|
|
2145
|
+
v.root.appendChild(note);
|
|
2146
|
+
}
|
|
2147
|
+
if (note && m.refusal) note.textContent = `\u26A0\uFE0F ${m.refusal}`;
|
|
2148
|
+
else note?.remove();
|
|
1604
2149
|
return;
|
|
1605
2150
|
}
|
|
1606
2151
|
v.root.classList.toggle("error", m.status === "error");
|
|
@@ -1719,7 +2264,7 @@ var DeweeChatElement = class extends ElementBase {
|
|
|
1719
2264
|
break;
|
|
1720
2265
|
case "error":
|
|
1721
2266
|
m.status = "error";
|
|
1722
|
-
this.showError(m, new ApiError(e.message, 500, e.code));
|
|
2267
|
+
this.showError(m, new ApiError(e.message, 500, e.code, e.remediation, e.retryAfterMs));
|
|
1723
2268
|
break;
|
|
1724
2269
|
}
|
|
1725
2270
|
this.scheduleUpdate(m);
|
|
@@ -1749,8 +2294,12 @@ var DeweeChatElement = class extends ElementBase {
|
|
|
1749
2294
|
}
|
|
1750
2295
|
}
|
|
1751
2296
|
showError(m, err) {
|
|
1752
|
-
const
|
|
1753
|
-
this.
|
|
2297
|
+
const { code, remediation, retryAfterMs } = err;
|
|
2298
|
+
const localized = isIdentityErrorCode(code) ? this.strings.identityErrors[code] : isAdmissionErrorCode(code) ? this.strings.admissionErrors[code] : isLinkErrorCode(code) ? this.strings.linkErrors[code] : "";
|
|
2299
|
+
const text = localized || err.message || this.strings.errorGeneric;
|
|
2300
|
+
this.emit("dewee:error", { message: text, code, ...remediation && { remediation }, ...retryAfterMs && { retryAfterMs } });
|
|
2301
|
+
if (code === "RATE_LIMITED") this.startCooldown(retryAfterMs);
|
|
2302
|
+
if (m && isAdmissionErrorCode(code) && this.refuseInline(m, code, text)) return;
|
|
1754
2303
|
if (m) {
|
|
1755
2304
|
m.content = m.content ? `${m.content}
|
|
1756
2305
|
|
|
@@ -1765,6 +2314,27 @@ var DeweeChatElement = class extends ElementBase {
|
|
|
1765
2314
|
div.firstElementChild.textContent = `\u26A0\uFE0F ${text}`;
|
|
1766
2315
|
this.els.log.appendChild(div);
|
|
1767
2316
|
}
|
|
2317
|
+
/**
|
|
2318
|
+
* An admission refusal: the visitor's message never reached the agent, so it goes back to an empty
|
|
2319
|
+
* composer to edit or resend. `POLICY_DENIED` is shown next to that message instead of as an empty
|
|
2320
|
+
* reply. Returns true when the error was shown here.
|
|
2321
|
+
*/
|
|
2322
|
+
refuseInline(reply, code, text) {
|
|
2323
|
+
const i = this.messages.indexOf(reply);
|
|
2324
|
+
const question = i > 0 ? this.messages[i - 1] : void 0;
|
|
2325
|
+
if (question?.role !== "user" || question.hidden) return false;
|
|
2326
|
+
if (!this.els.input.value.trim()) {
|
|
2327
|
+
this.els.input.value = question.content;
|
|
2328
|
+
this.autosize();
|
|
2329
|
+
}
|
|
2330
|
+
if (code !== "POLICY_DENIED" || reply.content) return false;
|
|
2331
|
+
reply.status = "error";
|
|
2332
|
+
reply.hidden = true;
|
|
2333
|
+
if (reply.view) reply.view.root.hidden = true;
|
|
2334
|
+
question.refusal = text;
|
|
2335
|
+
this.updateMessage(question);
|
|
2336
|
+
return true;
|
|
2337
|
+
}
|
|
1768
2338
|
/** Renders a Turnstile challenge in light DOM (slotted) and resolves with its token. */
|
|
1769
2339
|
async challenge(siteKey) {
|
|
1770
2340
|
await loadTurnstile();
|
|
@@ -1807,6 +2377,11 @@ function decorateHistory(messages) {
|
|
|
1807
2377
|
});
|
|
1808
2378
|
return messages;
|
|
1809
2379
|
}
|
|
2380
|
+
var CHANNEL_LABELS = { telegram: "Telegram", discord: "Discord", zalo: "Zalo", slack: "Slack", whatsapp: "WhatsApp", messenger: "Messenger", feishu: "Feishu", line: "LINE" };
|
|
2381
|
+
function channelLabel(type) {
|
|
2382
|
+
const key = type.toLowerCase().split(/[_-]/)[0];
|
|
2383
|
+
return CHANNEL_LABELS[key] ?? (type ? type.charAt(0).toUpperCase() + type.slice(1) : "chat");
|
|
2384
|
+
}
|
|
1810
2385
|
function safeJson(s, fallback) {
|
|
1811
2386
|
try {
|
|
1812
2387
|
return JSON.parse(s);
|
|
@@ -1841,6 +2416,7 @@ function mount(config = {}, target) {
|
|
|
1841
2416
|
defineDeweeChat();
|
|
1842
2417
|
|
|
1843
2418
|
export {
|
|
2419
|
+
RESERVED_HEADERS,
|
|
1844
2420
|
ApiError,
|
|
1845
2421
|
ApiClient,
|
|
1846
2422
|
renderMarkdown,
|