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.
@@ -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-JX6AH37X.js";
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
- constructor(message, status, code) {
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
- var store = {
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 localStorage.getItem(k);
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
- localStorage.setItem(k, v);
315
+ pick().setItem(k, v);
299
316
  } catch {
300
317
  }
301
318
  },
302
319
  del(k) {
303
320
  try {
304
- localStorage.removeItem(k);
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
- headers(auth = false) {
326
- const h = { "content-type": "application/json" };
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 && this.session) h.authorization = `Bearer ${this.session.token}`;
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) throw new ApiError(body?.error?.message || `HTTP ${res.status}`, res.status, body?.error?.code);
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 fetch(this.url("config"), { headers: this.headers() }));
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.expiresAt - Date.now() > 864e5) return Promise.resolve(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 saved = store.get(this.storageKey);
349
- if (saved) {
350
- try {
351
- const s = JSON.parse(saved);
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.sessionPromise ??= this.createSession().finally(() => this.sessionPromise = null);
438
+ return this.createSession();
359
439
  }
360
440
  async createSession() {
361
441
  const cfg = await this.getConfig();
362
442
  const body = {};
363
- if (this.session?.token) body.token = this.session.token;
364
- if (cfg.turnstileSiteKey) {
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 s = await this.parse(
369
- await fetch(this.url("session"), { method: "POST", headers: this.headers(), body: JSON.stringify(body) })
370
- );
371
- this.session = s;
372
- store.set(this.storageKey, JSON.stringify(s));
373
- return s;
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.ensureSession();
381
- const res = await fetch(this.url("history", { agent, conversationId }), { headers: this.headers(true) });
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 fetch(this.url("chat"), { method: "POST", headers: this.headers(true), body: JSON.stringify(input), signal });
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 fetch(this.url("chat"), { method: "POST", headers: this.headers(true), body: JSON.stringify(input), signal });
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(JSON.parse(data));
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 text = err.message || this.strings.errorGeneric;
1753
- this.emit("dewee:error", { message: text, code: err.code });
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,