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.
- package/README.md +237 -22
- package/dist/{chunk-ZTVGWFXL.js → chunk-7WT3JK64.js} +580 -42
- package/dist/{chunk-W6A4HVFB.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 +91 -7
- package/dist/index.js +2 -2
- package/dist/protocol-CLbGE9Ns.d.ts +170 -0
- package/dist/react.d.ts +1 -1
- package/dist/react.js +2 -2
- 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 +3 -3
- package/dist/vue.js +2 -2
- 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,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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
315
|
+
pick().setItem(k, v);
|
|
300
316
|
} catch {
|
|
301
317
|
}
|
|
302
318
|
},
|
|
303
319
|
del(k) {
|
|
304
320
|
try {
|
|
305
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
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)
|
|
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
|
|
364
|
-
if (
|
|
365
|
-
|
|
366
|
-
|
|
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.
|
|
438
|
+
return this.createSession();
|
|
374
439
|
}
|
|
375
440
|
async createSession() {
|
|
376
441
|
const cfg = await this.getConfig();
|
|
377
442
|
const body = {};
|
|
378
|
-
|
|
379
|
-
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 () => {
|
|
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
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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.
|
|
396
|
-
|
|
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
|
-
|
|
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
|
|
1790
|
-
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;
|
|
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);
|