dewee-webchat 0.5.0-beta.2 → 0.5.0-beta.4

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 CHANGED
@@ -217,7 +217,30 @@ Only exact same-origin paths in both lists use the binding. This reads the Asset
217
217
  **bypassing Worker middleware, Cloudflare Access, and cookie-based authorization**. Every file under
218
218
  the configured prefixes must be public to any valid chat visitor; keep private files elsewhere,
219
219
  inspect the build output, and exclude sensitive files using `.assetsignore`. The URL query is not
220
- passed to the binding. Sibling domains still require a separate approved image domain.
220
+ passed to the binding.
221
+
222
+ For a sibling hostname in the same zone, a host can instead configure a service binding to a Worker
223
+ that serves public images from the listed directories:
224
+
225
+ ```ts
226
+ createDeweeHandler({
227
+ // ... other handler options
228
+ imageSources: ["https://images.example.com/products/"],
229
+ cloudflarePublicImageServices: [
230
+ { id: "catalog-v1", binding: env.PUBLIC_IMAGES, prefixes: ["https://images.example.com/products/"] },
231
+ ],
232
+ });
233
+ ```
234
+
235
+ Both lists must allow the source. Each service needs a unique ID and non-overlapping prefixes; change
236
+ the ID whenever you repoint the binding so existing short-lived tickets cannot reach the new service.
237
+ The service ID is visible in the image ticket, but the source URL remains encrypted. The binding
238
+ calls the configured Worker directly, bypassing the
239
+ source hostname's Cloudflare security rules. Every configured prefix must be public to all chat
240
+ visitors; do not bind private services or paths. The Worker receives a GET with the approved path on
241
+ `https://public-images.invalid`, without the original query, cookies or authorization headers. It
242
+ must serve that path without relying on its hostname or a signed query string. A missing or invalid
243
+ response fails closed and never falls back to a network request.
221
244
 
222
245
  Files are limited to 2 MiB and 4096
223
246
  pixels per edge, and responses are not cached. Animated GIF/PNG/WebP are refused. Raw HTML,
@@ -368,6 +391,16 @@ used), `allowInsecureHttp` does not apply, a user switch under `rebindEachReques
368
391
  previous user's session at CCP, and logout revokes it through [`handler.revoke`](#signed-in-users).
369
392
  The integration's sign-in mode must allow signed-in users.
370
393
 
394
+ ### Hosted credential requests
395
+
396
+ Credential requests are off by default and require CCP transport, signed-in identity, and Gateway/CCP
397
+ support enabled for the agent. To opt in, add `credentialRequests: { formOrigin: "https://app.example.com" }`
398
+ to the server handler options, using the exact HTTPS origin of your Dewee-hosted form. Use the ASCII
399
+ punycode form for an internationalized domain. The form is
400
+ opened only after the visitor chooses it; a workspace approval administrator must authenticate there.
401
+ The widget never collects the credential value. Anonymous sessions, direct Gateway transport, and
402
+ an untrusted form origin cannot use this flow. See the [HTTP contract](../../docs/http-contract.md#hosted-credential-requests).
403
+
371
404
  ```ts
372
405
  import { createDeweeHandler, optionsFromEnv } from "dewee-webchat/server";
373
406
  import { deweeIdentitySdk } from "dewee-webchat/server/identity";
@@ -21,7 +21,7 @@ function isLinkErrorCode(code) {
21
21
  }
22
22
  var HEADER_GATEWAY_URL = "x-dewee-gateway-url";
23
23
  var HEADER_GATEWAY_TOKEN = "x-dewee-gateway-token";
24
- var VERSION = "0.5.0-beta.2";
24
+ var VERSION = "0.5.0-beta.4";
25
25
 
26
26
  // src/actions/page-block.ts
27
27
  var ACTION_NAME_RE = /^[a-z][a-z0-9_.-]{0,63}$/;
@@ -135,6 +135,48 @@ function parseJobAccessLaunch(raw) {
135
135
  return { bootstrap_url: url.href, launch_token: data.launch_token, expires_at: data.expires_at };
136
136
  }
137
137
 
138
+ // src/credential-request.ts
139
+ var uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
140
+ var statuses = /* @__PURE__ */ new Set(["pending", "applying", "resolved", "cancelled", "expired", "failed"]);
141
+ var record = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : null;
142
+ var text = (v, max) => typeof v === "string" && v.length <= max;
143
+ var date = (v) => text(v, 64) && Number.isFinite(Date.parse(v));
144
+ function parseCredentialRequest(raw, expectedAgent) {
145
+ const v = record(raw);
146
+ const target = record(v?.target);
147
+ if (!v || !target || !text(v.id, 36) || !uuid.test(v.id) || v.schema_version !== "webchat-credential-request.v1" || typeof v.status !== "string" || !statuses.has(v.status) || !text(v.title, 300) || !text(v.purpose, 4e3) || !text(target.type, 100) || !text(target.mode, 100) || !Array.isArray(v.fields) || v.fields.length > 32 || !date(v.expires_at) || v.agent_key !== expectedAgent || v.completion !== "hosted" || typeof v.resumable !== "boolean") return null;
148
+ const fields = [];
149
+ for (const item of v.fields) {
150
+ const f = record(item);
151
+ if (!f || !text(f.key, 100) || !/^[A-Za-z0-9_.-]+$/.test(f.key) || !text(f.label, 300) || !text(f.kind, 100) || typeof f.required !== "boolean") return null;
152
+ fields.push({ key: f.key, label: f.label, kind: f.kind, required: f.required });
153
+ }
154
+ return {
155
+ id: v.id,
156
+ schema_version: "webchat-credential-request.v1",
157
+ status: v.status,
158
+ title: v.title,
159
+ purpose: v.purpose,
160
+ target: { type: target.type, mode: target.mode },
161
+ fields,
162
+ expires_at: v.expires_at,
163
+ agent_key: expectedAgent,
164
+ completion: "hosted",
165
+ resumable: v.resumable
166
+ };
167
+ }
168
+ function parseCredentialHandoff(raw, expectedId, trustedOrigin) {
169
+ const v = record(raw);
170
+ if (!v || v.credential_request_id !== expectedId || !text(v.url, 4096) || !date(v.expires_at) || Date.parse(v.expires_at) <= Date.now()) return null;
171
+ try {
172
+ const url = new URL(v.url);
173
+ if (url.protocol !== "https:" || url.origin !== trustedOrigin || url.username || url.password || url.hash || url.pathname !== "/credential-requests/open" || !/^\?token=[A-Za-z0-9_-]{43}$/.test(url.search)) return null;
174
+ return { credential_request_id: expectedId, url: url.href, expires_at: v.expires_at };
175
+ } catch {
176
+ return null;
177
+ }
178
+ }
179
+
138
180
  export {
139
181
  isIdentityErrorCode,
140
182
  ADMISSION_ERROR_CODES,
@@ -152,5 +194,7 @@ export {
152
194
  hashManifest,
153
195
  buildPageBlock,
154
196
  stripPageBlock,
155
- parseJobAccessLaunch
197
+ parseJobAccessLaunch,
198
+ parseCredentialRequest,
199
+ parseCredentialHandoff
156
200
  };
@@ -7,9 +7,11 @@ import {
7
7
  isAdmissionErrorCode,
8
8
  isIdentityErrorCode,
9
9
  isLinkErrorCode,
10
+ parseCredentialHandoff,
11
+ parseCredentialRequest,
10
12
  parseJobAccessLaunch,
11
13
  stripPageBlock
12
- } from "./chunk-KXW4EWXA.js";
14
+ } from "./chunk-4EASDTWO.js";
13
15
 
14
16
  // src/actions/directives.ts
15
17
  var DIRECTIVE_LANG = "dewee-action";
@@ -846,10 +848,34 @@ var ApiClient = class {
846
848
  return this.parse(res);
847
849
  }
848
850
  async history(agent, conversationId) {
851
+ return (await this.historyDetails(agent, conversationId)).messages;
852
+ }
853
+ async historyDetails(agent, conversationId) {
849
854
  const res = await this.signedInRequest(
850
855
  async () => this.request(this.url("history", { agent, conversationId }), { headers: await this.headers(true) })
851
856
  );
852
- return (await this.parse(res)).messages;
857
+ const data = await this.parse(res);
858
+ const cards = this.session?.verified && this.config?.credentialFormOrigin && Array.isArray(data.credentialRequests) ? data.credentialRequests.slice(0, 100).map((v) => parseCredentialRequest(v, agent)).filter((v) => v !== null) : [];
859
+ return { messages: data.messages, credentialRequests: cards };
860
+ }
861
+ /** Never retry a credential action under a newly authenticated identity. */
862
+ async credentialRequest(route, agent, conversationId, credentialRequestId, signal) {
863
+ const session = await this.ensureSession();
864
+ const origin = (await this.getConfig()).credentialFormOrigin;
865
+ if (!session.verified || !origin) throw new ApiError("Credential request is unavailable", 403, "credential_request_unavailable");
866
+ try {
867
+ const headers = await this.headers(true);
868
+ if (this.session !== session) throw new Error("identity changed");
869
+ const response = await this.request(this.url(route), { method: "POST", headers, body: JSON.stringify({ agent, conversationId, credentialRequestId }), redirect: "error", signal });
870
+ const data = await this.parse(response);
871
+ if (this.session !== session) throw new Error("identity changed");
872
+ const result = route === "credential-request-handoff" ? parseCredentialHandoff(data, credentialRequestId, origin) : parseCredentialRequest(data.credentialRequest, agent);
873
+ if (!result || "id" in result && result.id !== credentialRequestId) throw new Error("invalid credential request");
874
+ return result;
875
+ } catch (error) {
876
+ const code = error instanceof ApiError && ["credential_requests_unavailable", "credential_request_not_found", "credential_request_not_ready", "credential_request_resumed"].includes(error.code ?? "") ? error.code : "credential_request_unavailable";
877
+ throw new ApiError("Credential request is unavailable", error instanceof ApiError ? error.status : 0, code);
878
+ }
853
879
  }
854
880
  /**
855
881
  * Sends a request with the current session. A signed-in session the backend refuses (expired,
@@ -868,7 +894,7 @@ var ApiClient = class {
868
894
  async chat(input, onEvent, signal) {
869
895
  await this.ensureSession();
870
896
  let res = await this.request(this.url("chat"), { method: "POST", headers: await this.headers(true), body: JSON.stringify(input), signal });
871
- if (res.status === 401) {
897
+ if (res.status === 401 && !input.credentialRequestId) {
872
898
  const before = this.visitorId();
873
899
  this.forgetSession();
874
900
  await this.ensureSession();
@@ -895,6 +921,11 @@ var ApiClient = class {
895
921
  } catch {
896
922
  continue;
897
923
  }
924
+ if (event.type === "credential_request") {
925
+ const view = this.session?.verified && this.config?.credentialFormOrigin ? parseCredentialRequest(event.credentialRequest, input.agent) : null;
926
+ if (!view) continue;
927
+ event = { type: "credential_request", credentialRequest: view };
928
+ }
898
929
  if (event.type === "error" && event.code && RENEW_CODES.has(event.code)) renew = true;
899
930
  try {
900
931
  onEvent(event);
@@ -1148,7 +1179,8 @@ var en = {
1148
1179
  jobAccessPopupClosed: "The window was closed. Choose Job access again to retry.",
1149
1180
  pageShare: "Share this page with the assistant for this message",
1150
1181
  pageChanged: "The page changed. Review it and choose to share again before sending.",
1151
- pageUnavailable: "Page details are unavailable. You can send without them."
1182
+ pageUnavailable: "Page details are unavailable. You can send without them.",
1183
+ credential: { open: "Open secure form", cancel: "Cancel request", continue: "Continue conversation", unavailable: "Could not open or update the request. Please try again.", intro: "Enter credentials only on the authenticated Dewee form.", required: "required", optional: "optional", expires: "Expires", status: { pending: "Waiting for you", applying: "Processing", resolved: "Completed", cancelled: "Cancelled", expired: "Expired", failed: "Failed" } }
1152
1184
  };
1153
1185
  var vi = {
1154
1186
  title: "Tr\u1EE3 l\xFD",
@@ -1211,7 +1243,8 @@ var vi = {
1211
1243
  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.",
1212
1244
  pageShare: "Chia s\u1EBB trang n\xE0y v\u1EDBi tr\u1EE3 l\xFD trong tin nh\u1EAFn n\xE0y",
1213
1245
  pageChanged: "Trang \u0111\xE3 thay \u0111\u1ED5i. H\xE3y xem l\u1EA1i v\xE0 ch\u1ECDn chia s\u1EBB tr\u01B0\u1EDBc khi g\u1EEDi.",
1214
- pageUnavailable: "Kh\xF4ng th\u1EC3 \u0111\u1ECDc n\u1ED9i dung trang. B\u1EA1n v\u1EABn c\xF3 th\u1EC3 g\u1EEDi tin nh\u1EAFn."
1246
+ pageUnavailable: "Kh\xF4ng th\u1EC3 \u0111\u1ECDc n\u1ED9i dung trang. B\u1EA1n v\u1EABn c\xF3 th\u1EC3 g\u1EEDi tin nh\u1EAFn.",
1247
+ credential: { open: "M\u1EDF bi\u1EC3u m\u1EABu b\u1EA3o m\u1EADt", cancel: "H\u1EE7y y\xEAu c\u1EA7u", continue: "Ti\u1EBFp t\u1EE5c tr\xF2 chuy\u1EC7n", unavailable: "Ch\u01B0a th\u1EC3 m\u1EDF ho\u1EB7c c\u1EADp nh\u1EADt y\xEAu c\u1EA7u. B\u1EA1n th\u1EED l\u1EA1i nh\xE9.", intro: "Ch\u1EC9 nh\u1EADp th\xF4ng tin x\xE1c th\u1EF1c tr\xEAn bi\u1EC3u m\u1EABu Dewee sau khi \u0111\u0103ng nh\u1EADp.", required: "b\u1EAFt bu\u1ED9c", optional: "t\xF9y ch\u1ECDn", expires: "H\u1EBFt h\u1EA1n", status: { pending: "\u0110ang ch\u1EDD b\u1EA1n", applying: "\u0110ang x\u1EED l\xFD", resolved: "\u0110\xE3 ho\xE0n t\u1EA5t", cancelled: "\u0110\xE3 h\u1EE7y", expired: "\u0110\xE3 h\u1EBFt h\u1EA1n", failed: "Kh\xF4ng th\xE0nh c\xF4ng" } }
1215
1248
  };
1216
1249
  function pickStrings(locale) {
1217
1250
  const l = (locale || (typeof document !== "undefined" ? document.documentElement.lang : "") || (typeof navigator !== "undefined" ? navigator.language : "") || "en").toLowerCase();
@@ -1758,6 +1791,11 @@ details > summary svg.ic { width: 14px; height: 14px; flex: none; }
1758
1791
  .btn.allow { background: var(--dw-accent); border-color: var(--dw-accent); color: var(--dw-accent-contrast); }
1759
1792
  .btn.allow.danger { background: #c2334f; border-color: #c2334f; color: #fff; }
1760
1793
  .btn:focus-visible { outline: 3px solid color-mix(in srgb, var(--dw-accent) 45%, transparent); outline-offset: 2px; }
1794
+ .credential-request .bubble { overflow-wrap: anywhere; max-width: 100%; }
1795
+ .credential-request p { margin: 8px 0; }
1796
+ .credential-request .spin { display: inline-block; vertical-align: middle; }
1797
+ .credential-request ul { margin: 8px 0; padding-inline-start: 20px; }
1798
+ .credential-actions { display: flex; flex-wrap: wrap; gap: 8px; }
1761
1799
 
1762
1800
  /* ---------- composer ---------- */
1763
1801
  .composer { padding: 10px 12px 12px; border-top: 1px solid var(--dw-border); background: var(--dw-bg); }
@@ -1796,6 +1834,7 @@ textarea::placeholder { color: var(--dw-muted); }
1796
1834
 
1797
1835
  // src/widget/element.ts
1798
1836
  var DEFAULT_COOLDOWN_MS = 5e3;
1837
+ var CREDENTIAL_GONE_CODES = /* @__PURE__ */ new Set(["credential_requests_unavailable", "credential_request_not_found"]);
1799
1838
  var BOOL_ATTRS = ["show-reasoning", "show-tool-calls", "allow-agent-switch", "open", "persist", "hide-branding", "launcher-avatar", "dom-actions"];
1800
1839
  var ATTRS = [
1801
1840
  "endpoint",
@@ -1878,6 +1917,11 @@ var DeweeChatElement = class extends ElementBase {
1878
1917
  messages = [];
1879
1918
  running = null;
1880
1919
  jobAccessLaunch = null;
1920
+ credentialCards = /* @__PURE__ */ new Map();
1921
+ credentialGeneration = 0;
1922
+ credentialBusy = /* @__PURE__ */ new Set();
1923
+ credentialPopups = /* @__PURE__ */ new Map();
1924
+ credentialRequestsInFlight = /* @__PURE__ */ new Set();
1881
1925
  activated = false;
1882
1926
  probedPage = false;
1883
1927
  strings = pickStrings();
@@ -2057,6 +2101,7 @@ var DeweeChatElement = class extends ElementBase {
2057
2101
  }
2058
2102
  /** One request/response with the agent. */
2059
2103
  async runTurn(message, hidden, attachment) {
2104
+ const generation = this.credentialGeneration;
2060
2105
  this.pushMessage({ role: "user", content: message, hidden });
2061
2106
  const reply = { role: "assistant", content: "", status: "streaming", startedAt: Date.now() };
2062
2107
  this.pushMessage(reply);
@@ -2064,7 +2109,9 @@ var DeweeChatElement = class extends ElementBase {
2064
2109
  this.emit("dewee:send", { message, agent: this.agentId, conversationId: this.conversationId, auto: hidden || void 0 });
2065
2110
  try {
2066
2111
  await this.client().ensureSession();
2067
- await this.client().chat({ ...this.chatBody(message), ...attachment ? { page_context: attachment } : {} }, (e) => this.onStreamEvent(reply, e), this.running.signal);
2112
+ await this.client().chat({ ...this.chatBody(message), ...attachment ? { page_context: attachment } : {} }, (e) => {
2113
+ if (generation === this.credentialGeneration) this.onStreamEvent(reply, e);
2114
+ }, this.running.signal);
2068
2115
  if (reply.status === "streaming") reply.status = "done";
2069
2116
  } catch (e) {
2070
2117
  if (e.name === "AbortError") {
@@ -2192,6 +2239,8 @@ var DeweeChatElement = class extends ElementBase {
2192
2239
  */
2193
2240
  async renewSession() {
2194
2241
  this.stop();
2242
+ this.clearCredentials();
2243
+ this.renderLog();
2195
2244
  try {
2196
2245
  await this.client().renewSession();
2197
2246
  } catch (e) {
@@ -2284,6 +2333,7 @@ var DeweeChatElement = class extends ElementBase {
2284
2333
  /** Starts a fresh conversation with the current agent. */
2285
2334
  newConversation() {
2286
2335
  this.stop();
2336
+ this.clearCredentials();
2287
2337
  this.conversationId = randomConversationId();
2288
2338
  if (this.config.persist !== false) store2.set(this.convKey(), this.conversationId);
2289
2339
  this.messages = [];
@@ -2300,6 +2350,7 @@ var DeweeChatElement = class extends ElementBase {
2300
2350
  }
2301
2351
  }
2302
2352
  disconnectedCallback() {
2353
+ this.clearCredentials();
2303
2354
  this.pageGeneration++;
2304
2355
  this.sharedPage = void 0;
2305
2356
  this.els.pageChoice.checked = false;
@@ -2644,6 +2695,8 @@ var DeweeChatElement = class extends ElementBase {
2644
2695
  await this.loadConversation();
2645
2696
  }
2646
2697
  async loadConversation() {
2698
+ this.clearCredentials();
2699
+ const generation = this.credentialGeneration;
2647
2700
  const persist = this.config.persist !== false;
2648
2701
  const saved = persist ? store2.get(this.convKey()) : null;
2649
2702
  this.conversationId = saved || randomConversationId();
@@ -2652,8 +2705,10 @@ var DeweeChatElement = class extends ElementBase {
2652
2705
  this.renderLog();
2653
2706
  if (!saved) return;
2654
2707
  try {
2655
- const history = await this.client().history(this.agentId, this.conversationId);
2656
- this.messages = decorateHistory(history.map((m) => ({ ...m, status: "done" })));
2708
+ const history = await this.client().historyDetails(this.agentId, this.conversationId);
2709
+ if (generation !== this.credentialGeneration) return;
2710
+ this.messages = decorateHistory(history.messages.map((m) => ({ ...m, status: "done" })));
2711
+ for (const card of history.credentialRequests) this.credentialCards.set(card.id, card);
2657
2712
  this.renderLog();
2658
2713
  } catch {
2659
2714
  }
@@ -2664,8 +2719,202 @@ var DeweeChatElement = class extends ElementBase {
2664
2719
  log.replaceChildren();
2665
2720
  if (!this.messages.length) log.appendChild(this.renderWelcome());
2666
2721
  for (const m of this.messages) log.appendChild(this.renderMessage(m));
2722
+ for (const card of this.credentialCards.values()) log.appendChild(this.renderCredential(card));
2667
2723
  log.scrollTop = log.scrollHeight;
2668
2724
  }
2725
+ clearCredentials() {
2726
+ this.credentialGeneration++;
2727
+ for (const id of this.credentialPopups.keys()) this.closeCredentialPopup(id);
2728
+ for (const request of this.credentialRequestsInFlight) request.abort();
2729
+ this.credentialRequestsInFlight.clear();
2730
+ this.credentialCards.clear();
2731
+ this.credentialBusy.clear();
2732
+ }
2733
+ closeCredentialPopup(id) {
2734
+ const active = this.credentialPopups.get(id);
2735
+ if (!active) return;
2736
+ this.credentialPopups.delete(id);
2737
+ if (active.timer) clearTimeout(active.timer);
2738
+ active.controller.abort();
2739
+ try {
2740
+ active.popup.close();
2741
+ } catch {
2742
+ }
2743
+ }
2744
+ discardCredential(id) {
2745
+ this.closeCredentialPopup(id);
2746
+ this.credentialCards.delete(id);
2747
+ }
2748
+ credentialButton(card, label, run, disabled = false) {
2749
+ const button = document.createElement("button");
2750
+ button.type = "button";
2751
+ button.className = "btn";
2752
+ button.textContent = label;
2753
+ button.disabled = disabled || this.credentialBusy.has(card.id);
2754
+ button.addEventListener("click", run);
2755
+ return button;
2756
+ }
2757
+ renderCredential(card) {
2758
+ const root = document.createElement("section");
2759
+ root.className = "msg assistant credential-request";
2760
+ const bubble = document.createElement("div");
2761
+ bubble.className = "bubble";
2762
+ const title = document.createElement("strong");
2763
+ title.textContent = card.title;
2764
+ const purpose = document.createElement("p");
2765
+ purpose.textContent = card.purpose;
2766
+ const intro = document.createElement("p");
2767
+ intro.textContent = this.strings.credential.intro;
2768
+ const destination = document.createElement("p");
2769
+ destination.textContent = [card.target.type, card.target.mode].filter(Boolean).join(" \xB7 ");
2770
+ const status = document.createElement("p");
2771
+ status.setAttribute("role", "status");
2772
+ const effective = card.status === "pending" && Date.parse(card.expires_at) <= Date.now() ? "expired" : card.status;
2773
+ if (this.credentialBusy.has(card.id)) {
2774
+ root.setAttribute("aria-busy", "true");
2775
+ const spinner = document.createElement("span");
2776
+ spinner.className = "spin";
2777
+ spinner.setAttribute("aria-hidden", "true");
2778
+ status.append(spinner, ` ${this.strings.credential.status.applying}`);
2779
+ } else status.textContent = this.strings.credential.status[effective];
2780
+ const list = document.createElement("ul");
2781
+ for (const field of card.fields) {
2782
+ const li = document.createElement("li");
2783
+ li.textContent = `${field.label} (${field.required ? this.strings.credential.required : this.strings.credential.optional})`;
2784
+ list.appendChild(li);
2785
+ }
2786
+ const expiry = document.createElement("p");
2787
+ expiry.textContent = `${this.strings.credential.expires}: ${new Date(card.expires_at).toLocaleString()}`;
2788
+ const actions = document.createElement("div");
2789
+ actions.className = "credential-actions";
2790
+ if (effective === "pending") {
2791
+ actions.append(
2792
+ this.credentialButton(card, this.strings.credential.open, () => {
2793
+ void this.openCredential(card);
2794
+ }, this.credentialPopups.has(card.id)),
2795
+ this.credentialButton(card, this.strings.credential.cancel, () => {
2796
+ void this.updateCredential(card, "credential-request-cancel");
2797
+ })
2798
+ );
2799
+ }
2800
+ if (card.resumable) actions.append(this.credentialButton(card, this.strings.credential.continue, () => {
2801
+ void this.resumeCredential(card);
2802
+ }));
2803
+ bubble.append(title, purpose, destination, list, intro, expiry, status, actions);
2804
+ root.appendChild(bubble);
2805
+ return root;
2806
+ }
2807
+ async updateCredential(card, route) {
2808
+ if (!this.credentialCards.has(card.id) || this.credentialBusy.has(card.id)) return;
2809
+ const generation = this.credentialGeneration, agent = this.agentId, conversation = this.conversationId;
2810
+ const controller = new AbortController();
2811
+ this.credentialRequestsInFlight.add(controller);
2812
+ this.credentialBusy.add(card.id);
2813
+ this.renderLog();
2814
+ try {
2815
+ const result = await this.client().credentialRequest(route, agent, conversation, card.id, controller.signal);
2816
+ if (generation !== this.credentialGeneration || !("id" in result)) return;
2817
+ this.credentialCards.set(result.id, result);
2818
+ if (route === "credential-request-cancel") this.closeCredentialPopup(card.id);
2819
+ } catch (error) {
2820
+ if (generation === this.credentialGeneration && error instanceof ApiError && CREDENTIAL_GONE_CODES.has(error.code ?? "")) this.discardCredential(card.id);
2821
+ else if (generation === this.credentialGeneration && route !== "credential-request") this.showNotice(this.strings.credential.unavailable);
2822
+ } finally {
2823
+ this.credentialRequestsInFlight.delete(controller);
2824
+ if (generation === this.credentialGeneration) {
2825
+ this.credentialBusy.delete(card.id);
2826
+ this.renderLog();
2827
+ }
2828
+ }
2829
+ }
2830
+ async openCredential(card) {
2831
+ if (this.credentialCards.get(card.id) !== card || this.credentialBusy.has(card.id) || this.credentialPopups.has(card.id)) return;
2832
+ let popup;
2833
+ try {
2834
+ popup = window.open("about:blank", `dewee-credential-${crypto.randomUUID()}`, "popup,width=720,height=800");
2835
+ } catch {
2836
+ popup = null;
2837
+ }
2838
+ if (!popup) {
2839
+ this.showNotice(this.strings.credential.unavailable);
2840
+ return;
2841
+ }
2842
+ const generation = this.credentialGeneration, agent = this.agentId, conversation = this.conversationId;
2843
+ const active = { popup, controller: new AbortController(), timer: void 0 };
2844
+ this.credentialPopups.set(card.id, active);
2845
+ this.credentialBusy.add(card.id);
2846
+ this.renderLog();
2847
+ try {
2848
+ popup.opener = null;
2849
+ const result = await this.client().credentialRequest("credential-request-handoff", agent, conversation, card.id, active.controller.signal);
2850
+ if (generation !== this.credentialGeneration || popup.closed || !("url" in result)) throw new Error("cancelled");
2851
+ const meta = popup.document.createElement("meta");
2852
+ meta.name = "referrer";
2853
+ meta.content = "no-referrer";
2854
+ popup.document.head.append(meta);
2855
+ popup.location.replace(result.url);
2856
+ const poll = async () => {
2857
+ if (generation !== this.credentialGeneration) return;
2858
+ const before = this.credentialCards.get(card.id);
2859
+ if (before && (before.status === "pending" || before.status === "applying")) await this.updateCredential(before, "credential-request");
2860
+ const current = this.credentialCards.get(card.id);
2861
+ if (generation === this.credentialGeneration && !popup.closed && current && Date.parse(current.expires_at) > Date.now() && this.credentialPopups.get(card.id) === active) {
2862
+ if (current.status === "pending" || current.status === "applying") active.timer = setTimeout(poll, 4e3);
2863
+ else {
2864
+ this.closeCredentialPopup(card.id);
2865
+ this.renderLog();
2866
+ }
2867
+ } else {
2868
+ if (this.credentialPopups.get(card.id) === active) this.closeCredentialPopup(card.id);
2869
+ if (generation === this.credentialGeneration) this.renderLog();
2870
+ }
2871
+ };
2872
+ active.timer = setTimeout(poll, 4e3);
2873
+ } catch {
2874
+ if (this.credentialPopups.get(card.id) === active) this.closeCredentialPopup(card.id);
2875
+ if (generation === this.credentialGeneration) this.showNotice(this.strings.credential.unavailable);
2876
+ } finally {
2877
+ if (generation === this.credentialGeneration) {
2878
+ this.credentialBusy.delete(card.id);
2879
+ this.renderLog();
2880
+ }
2881
+ }
2882
+ }
2883
+ async resumeCredential(card) {
2884
+ if (this.credentialCards.get(card.id) !== card || this.running || this.credentialBusy.has(card.id)) return;
2885
+ const generation = this.credentialGeneration, agent = this.agentId, conversation = this.conversationId;
2886
+ const reply = { role: "assistant", content: "", status: "streaming", startedAt: Date.now() };
2887
+ const controller = new AbortController();
2888
+ this.credentialBusy.add(card.id);
2889
+ this.pushMessage(reply);
2890
+ this.setRunning(controller);
2891
+ try {
2892
+ await this.client().chat({ message: "", agent, conversationId: conversation, credentialRequestId: card.id }, (event) => {
2893
+ if (generation === this.credentialGeneration) this.onStreamEvent(reply, event);
2894
+ }, this.running.signal);
2895
+ if (reply.status === "streaming") reply.status = "done";
2896
+ if (generation === this.credentialGeneration) {
2897
+ this.credentialBusy.delete(card.id);
2898
+ await this.updateCredential(card, "credential-request");
2899
+ }
2900
+ } catch (error) {
2901
+ if (generation === this.credentialGeneration) {
2902
+ this.messages = this.messages.filter((message) => message !== reply);
2903
+ if (error instanceof ApiError && CREDENTIAL_GONE_CODES.has(error.code ?? "")) this.discardCredential(card.id);
2904
+ else if (error instanceof ApiError && error.code === "credential_request_resumed") {
2905
+ this.credentialBusy.delete(card.id);
2906
+ await this.updateCredential(card, "credential-request");
2907
+ } else this.showNotice(this.strings.credential.unavailable);
2908
+ }
2909
+ } finally {
2910
+ if (this.running === controller) this.setRunning(null);
2911
+ if (generation === this.credentialGeneration) {
2912
+ this.credentialBusy.delete(card.id);
2913
+ this.updateMessage(reply);
2914
+ this.renderLog();
2915
+ }
2916
+ }
2917
+ }
2669
2918
  renderWelcome() {
2670
2919
  const c = this.config;
2671
2920
  const s = this.strings;
@@ -2852,6 +3101,10 @@ var DeweeChatElement = class extends ElementBase {
2852
3101
  t.isError = e.isError;
2853
3102
  break;
2854
3103
  }
3104
+ case "credential_request":
3105
+ this.credentialCards.set(e.credentialRequest.id, e.credentialRequest);
3106
+ this.renderLog();
3107
+ break;
2855
3108
  case "done":
2856
3109
  this.finishThinking(m);
2857
3110
  if (e.content && e.content.length >= m.content.length) m.content = e.content;