dewee-webchat 0.3.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-W6A4HVFB.js";
12
+ } from "./chunk-USXVB3AC.js";
9
13
 
10
14
  // src/actions/directives.ts
11
15
  var DIRECTIVE_LANG = "dewee-action";
@@ -276,37 +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;
279
285
  var RESERVED_HEADERS = ["content-type", "authorization", HEADER_GATEWAY_URL, HEADER_GATEWAY_TOKEN];
280
286
  var ApiError = class extends Error {
281
- constructor(message, status, code) {
287
+ /** `retryAfterMs`: how long to wait before sending again (`RATE_LIMITED`). */
288
+ constructor(message, status, code, remediation, retryAfterMs) {
282
289
  super(message);
283
290
  this.status = status;
284
291
  this.code = code;
292
+ this.remediation = remediation;
293
+ this.retryAfterMs = retryAfterMs;
285
294
  }
286
295
  status;
287
296
  code;
297
+ remediation;
298
+ retryAfterMs;
288
299
  };
289
- 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) => ({
290
306
  get(k) {
291
307
  try {
292
- return localStorage.getItem(k);
308
+ return pick().getItem(k);
293
309
  } catch {
294
310
  return null;
295
311
  }
296
312
  },
297
313
  set(k, v) {
298
314
  try {
299
- localStorage.setItem(k, v);
315
+ pick().setItem(k, v);
300
316
  } catch {
301
317
  }
302
318
  },
303
319
  del(k) {
304
320
  try {
305
- localStorage.removeItem(k);
321
+ pick().removeItem(k);
306
322
  } catch {
307
323
  }
308
324
  }
309
- };
325
+ });
326
+ var store = storage(() => localStorage);
327
+ var tabStore = storage(() => sessionStorage);
310
328
  var ApiClient = class {
311
329
  constructor(o) {
312
330
  this.o = o;
@@ -323,11 +341,16 @@ var ApiClient = class {
323
341
  get storageKey() {
324
342
  return `dewee-webchat:session:${this.o.endpoint}`;
325
343
  }
326
- /** Session reuse: refresh once less than 10% of the token lifetime (at most 24 h, at least 30 s) remains. */
327
- fresh(s) {
328
- const lifetime = s.obtainedAt ? s.expiresAt - s.obtainedAt : Infinity;
329
- const margin = Math.min(864e5, Math.max(3e4, lifetime * 0.1));
330
- return s.expiresAt - Date.now() > margin;
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);
331
354
  }
332
355
  request(url, init) {
333
356
  return (this.o.fetch ?? globalThis.fetch)(url, init);
@@ -343,12 +366,19 @@ var ApiClient = class {
343
366
  h[HEADER_GATEWAY_URL] = this.o.gatewayUrl;
344
367
  h[HEADER_GATEWAY_TOKEN] = this.o.gatewayToken;
345
368
  }
346
- 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
+ }
347
373
  return h;
348
374
  }
349
375
  async parse(res) {
350
376
  const body = await res.json().catch(() => ({}));
351
- 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
+ }
352
382
  return body;
353
383
  }
354
384
  async getConfig(force = false) {
@@ -356,46 +386,162 @@ var ApiClient = class {
356
386
  this.config = await this.parse(await this.request(this.url("config"), { headers: await this.headers() }));
357
387
  return this.config;
358
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
+ }
359
425
  /** Returns a valid visitor session, creating or refreshing it when needed. */
360
426
  ensureSession() {
361
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() {
362
431
  if (!this.session) {
363
- const saved = store.get(this.storageKey);
364
- if (saved) {
365
- try {
366
- const s = JSON.parse(saved);
367
- if (this.fresh(s)) return Promise.resolve(this.session = s);
368
- this.session = s;
369
- } catch {
370
- }
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;
371
436
  }
372
437
  }
373
- return this.sessionPromise ??= this.createSession().finally(() => this.sessionPromise = null);
438
+ return this.createSession();
374
439
  }
375
440
  async createSession() {
376
441
  const cfg = await this.getConfig();
377
442
  const body = {};
378
- if (this.session?.token) body.token = this.session.token;
379
- 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 () => {
380
451
  if (!this.o.turnstile) throw new ApiError("Verification is required but no Turnstile handler is set", 403, "turnstile_required");
381
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;
382
467
  }
383
- const s = await this.parse(
384
- await this.request(this.url("session"), { method: "POST", headers: await this.headers(), body: JSON.stringify(body) })
385
- );
386
- this.session = { ...s, obtainedAt: Date.now() };
387
- store.set(this.storageKey, JSON.stringify(this.session));
388
- return this.session;
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;
389
481
  }
482
+ /** Drops the session; the next request creates a new one. A stored anonymous visitor is kept for a signed-in session. */
390
483
  forgetSession() {
484
+ if (this.session?.verified) {
485
+ tabStore.del(this.reuseKey);
486
+ this.session = null;
487
+ return;
488
+ }
391
489
  this.session = null;
392
490
  store.del(this.storageKey);
393
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
+ }
394
526
  async history(agent, conversationId) {
395
- await this.ensureSession();
396
- const res = await this.request(this.url("history", { agent, conversationId }), { headers: await this.headers(true) });
527
+ const res = await this.signedInRequest(
528
+ async () => this.request(this.url("history", { agent, conversationId }), { headers: await this.headers(true) })
529
+ );
397
530
  return (await this.parse(res)).messages;
398
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
+ }
399
545
  /** Sends a message and calls `onEvent` for every streamed event. Resolves when the run ends. */
400
546
  async chat(input, onEvent, signal) {
401
547
  await this.ensureSession();
@@ -408,6 +554,7 @@ var ApiClient = class {
408
554
  if (!res.ok || !res.body) await this.parse(res);
409
555
  const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
410
556
  let buf = "";
557
+ let renew = false;
411
558
  while (true) {
412
559
  const { done, value } = await reader.read();
413
560
  if (done) break;
@@ -418,15 +565,130 @@ var ApiClient = class {
418
565
  buf = buf.slice(idx + 2);
419
566
  const data = frame.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trimStart()).join("\n");
420
567
  if (!data) continue;
568
+ let event;
421
569
  try {
422
- onEvent(JSON.parse(data));
570
+ event = JSON.parse(data);
571
+ } catch {
572
+ continue;
573
+ }
574
+ if (event.type === "error" && event.code && RENEW_CODES.has(event.code)) renew = true;
575
+ try {
576
+ onEvent(event);
423
577
  } catch {
424
578
  }
425
579
  }
426
580
  }
581
+ if (renew && this.session?.verified) {
582
+ this.forgetSession();
583
+ await this.ensureSession().catch(() => {
584
+ });
585
+ }
427
586
  }
428
587
  };
429
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;
594
+ }
595
+ code;
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
+ }
691
+
430
692
  // src/widget/confirm-dialog.ts
431
693
  var seq = 0;
432
694
  function showConfirmDialog(container, s, action, args, signal) {
@@ -528,7 +790,38 @@ var en = {
528
790
  confirmDestructive: "This can't be undone.",
529
791
  allow: "Allow",
530
792
  deny: "Deny",
531
- 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."
532
825
  };
533
826
  var vi = {
534
827
  title: "Tr\u1EE3 l\xFD",
@@ -557,13 +850,45 @@ var vi = {
557
850
  confirmDestructive: "Thao t\xE1c n\xE0y kh\xF4ng th\u1EC3 ho\xE0n t\xE1c.",
558
851
  allow: "Cho ph\xE9p",
559
852
  deny: "T\u1EEB ch\u1ED1i",
560
- 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."
561
885
  };
562
886
  function pickStrings(locale) {
563
887
  const l = (locale || (typeof document !== "undefined" ? document.documentElement.lang : "") || (typeof navigator !== "undefined" ? navigator.language : "") || "en").toLowerCase();
564
888
  return l.startsWith("vi") ? vi : en;
565
889
  }
566
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>`,
567
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>`,
568
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>`,
569
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>`,
@@ -744,6 +1069,46 @@ function renderMarkdown(doc, src) {
744
1069
  return frag;
745
1070
  }
746
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
+
747
1112
  // src/widget/styles.ts
748
1113
  var styles = (
749
1114
  /* css */
@@ -958,6 +1323,10 @@ details > summary svg.ic { width: 14px; height: 14px; flex: none; }
958
1323
  .action[data-status="awaiting-confirmation"] .action-state { color: #b06f00; }
959
1324
  .action[data-status="denied"] .action-state, .action[data-status="error"] .action-state { color: #c2334f; }
960
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; }
961
1330
  .confirm-backdrop {
962
1331
  position: absolute; inset: 0; z-index: 2; display: flex; align-items: flex-end; justify-content: center; padding: 12px;
963
1332
  background: color-mix(in srgb, var(--dw-fg) 28%, transparent); animation: rise .18s ease both;
@@ -1016,6 +1385,7 @@ textarea::placeholder { color: var(--dw-muted); }
1016
1385
  );
1017
1386
 
1018
1387
  // src/widget/element.ts
1388
+ var DEFAULT_COOLDOWN_MS = 5e3;
1019
1389
  var BOOL_ATTRS = ["show-reasoning", "show-tool-calls", "allow-agent-switch", "open", "persist", "hide-branding", "launcher-avatar"];
1020
1390
  var ATTRS = [
1021
1391
  "endpoint",
@@ -1088,6 +1458,7 @@ var DeweeChatElement = class extends ElementBase {
1088
1458
  conversationId = "";
1089
1459
  messages = [];
1090
1460
  running = null;
1461
+ jobAccessLaunch = null;
1091
1462
  activated = false;
1092
1463
  strings = pickStrings();
1093
1464
  mql = null;
@@ -1095,6 +1466,8 @@ var DeweeChatElement = class extends ElementBase {
1095
1466
  propActionNames = /* @__PURE__ */ new Set();
1096
1467
  hooks = {};
1097
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());
1098
1471
  els;
1099
1472
  constructor() {
1100
1473
  super();
@@ -1181,7 +1554,7 @@ var DeweeChatElement = class extends ElementBase {
1181
1554
  /** Sends a message programmatically (opens the widget first). */
1182
1555
  async send(text) {
1183
1556
  const message = text.trim();
1184
- if (!message || this.running) return;
1557
+ if (!message || this.running || this.cooldown.active) return;
1185
1558
  if (this.config.mode !== "inline" && !this.hasAttribute("data-open")) this.open();
1186
1559
  await this.activate();
1187
1560
  this.els.input.value = "";
@@ -1324,6 +1697,101 @@ var DeweeChatElement = class extends ElementBase {
1324
1697
  stop() {
1325
1698
  this.running?.abort();
1326
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
+ }
1327
1795
  /** Starts a fresh conversation with the current agent. */
1328
1796
  newConversation() {
1329
1797
  this.stop();
@@ -1343,6 +1811,7 @@ var DeweeChatElement = class extends ElementBase {
1343
1811
  }
1344
1812
  }
1345
1813
  disconnectedCallback() {
1814
+ this.jobAccessLaunch?.abort();
1346
1815
  this.stop();
1347
1816
  this.mql?.removeEventListener("change", this.onScheme);
1348
1817
  }
@@ -1420,6 +1889,8 @@ var DeweeChatElement = class extends ElementBase {
1420
1889
  e.close.title = s.close;
1421
1890
  e.newChat.setAttribute("aria-label", s.newChat);
1422
1891
  e.newChat.title = s.newChat;
1892
+ e.jobAccess.setAttribute("aria-label", s.jobAccess);
1893
+ e.jobAccess.title = s.jobAccess;
1423
1894
  e.input.placeholder = c.placeholder || s.placeholder;
1424
1895
  e.foot.hidden = !!c.hideBranding;
1425
1896
  e.foot.querySelector(".by").textContent = s.poweredBy + " ";
@@ -1447,11 +1918,14 @@ var DeweeChatElement = class extends ElementBase {
1447
1918
  <div class="subtitle"><span class="dot"></span><span class="status"></span><select class="agent-select" hidden></select></div>
1448
1919
  </div>
1449
1920
  <button class="icon-btn new">${icons.newChat}</button>
1921
+ <button class="icon-btn job-access" hidden>${icons.jobAccess}</button>
1450
1922
  <button class="icon-btn close">${icons.close}</button>
1451
1923
  </header>
1452
1924
  <div class="log" part="log" role="log" aria-live="polite"></div>
1453
1925
  <div class="challenge"><slot name="challenge"></slot></div>
1454
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>
1455
1929
  <div class="box">
1456
1930
  <textarea rows="1" maxlength="4000"></textarea>
1457
1931
  <button class="send" type="submit"></button>
@@ -1472,8 +1946,11 @@ var DeweeChatElement = class extends ElementBase {
1472
1946
  input: $("textarea"),
1473
1947
  send: $(".send"),
1474
1948
  newChat: $(".new"),
1949
+ jobAccess: $(".job-access"),
1475
1950
  close: $(".close"),
1476
- foot: $(".foot")
1951
+ foot: $(".foot"),
1952
+ cooldown: $(".cooldown"),
1953
+ announcer: $(".announcer")
1477
1954
  };
1478
1955
  const e = this.els;
1479
1956
  e.launcher.addEventListener("click", () => this.toggle());
@@ -1482,6 +1959,9 @@ var DeweeChatElement = class extends ElementBase {
1482
1959
  this.following = this.nearBottom();
1483
1960
  }, { passive: true });
1484
1961
  e.newChat.addEventListener("click", () => this.newConversation());
1962
+ e.jobAccess.addEventListener("click", () => {
1963
+ void this.openJobAccess();
1964
+ });
1485
1965
  e.select.addEventListener("change", () => this.switchAgent(e.select.value));
1486
1966
  $("form").addEventListener("submit", (ev) => {
1487
1967
  ev.preventDefault();
@@ -1511,13 +1991,32 @@ var DeweeChatElement = class extends ElementBase {
1511
1991
  b.innerHTML = this.running ? icons.stop : icons.send;
1512
1992
  b.setAttribute("aria-label", this.running ? this.strings.stop : this.strings.send);
1513
1993
  b.title = b.getAttribute("aria-label");
1514
- 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();
1515
2013
  }
1516
2014
  setRunning(ctrl) {
1517
2015
  this.running = ctrl;
1518
2016
  this.updateSendButton();
1519
2017
  }
1520
2018
  renderSubtitle() {
2019
+ this.els.jobAccess.hidden = this.server?.identity !== true;
1521
2020
  const e = this.els;
1522
2021
  const c = this.config;
1523
2022
  const agents = this.server?.agents ?? [];
@@ -1638,6 +2137,15 @@ var DeweeChatElement = class extends ElementBase {
1638
2137
  const c = this.config;
1639
2138
  if (m.role === "user") {
1640
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();
1641
2149
  return;
1642
2150
  }
1643
2151
  v.root.classList.toggle("error", m.status === "error");
@@ -1756,7 +2264,7 @@ var DeweeChatElement = class extends ElementBase {
1756
2264
  break;
1757
2265
  case "error":
1758
2266
  m.status = "error";
1759
- 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));
1760
2268
  break;
1761
2269
  }
1762
2270
  this.scheduleUpdate(m);
@@ -1786,8 +2294,12 @@ var DeweeChatElement = class extends ElementBase {
1786
2294
  }
1787
2295
  }
1788
2296
  showError(m, err) {
1789
- const text = err.message || this.strings.errorGeneric;
1790
- 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;
1791
2303
  if (m) {
1792
2304
  m.content = m.content ? `${m.content}
1793
2305
 
@@ -1802,6 +2314,27 @@ var DeweeChatElement = class extends ElementBase {
1802
2314
  div.firstElementChild.textContent = `\u26A0\uFE0F ${text}`;
1803
2315
  this.els.log.appendChild(div);
1804
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
+ }
1805
2338
  /** Renders a Turnstile challenge in light DOM (slotted) and resolves with its token. */
1806
2339
  async challenge(siteKey) {
1807
2340
  await loadTurnstile();
@@ -1844,6 +2377,11 @@ function decorateHistory(messages) {
1844
2377
  });
1845
2378
  return messages;
1846
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
+ }
1847
2385
  function safeJson(s, fallback) {
1848
2386
  try {
1849
2387
  return JSON.parse(s);