@helpai/elements 0.5.0 → 0.6.0

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/index.d.ts CHANGED
@@ -40,7 +40,7 @@ interface ClientStorage {
40
40
  * populated {@link Strings} map. UI components then read `strings.send` as a
41
41
  * normal property access — no per-render lookup, no per-render allocation.
42
42
  */
43
- type StringKey = "launcherOpen" | "panelTitle" | "composerPlaceholder" | "send" | "stop" | "attach" | "micStart" | "micStop" | "micUnsupported" | "expand" | "collapse" | "fullscreen" | "exitFullscreen" | "resizeHandle" | "popOut" | "close" | "moreActions" | "soundOn" | "soundOff" | "language" | "theme" | "themeAuto" | "themeLight" | "themeDark" | "history" | "historyTitle" | "historyEmpty" | "historyLoading" | "historyNewChat" | "historyBack" | "messagesLoading" | "chatClosed" | "startNewConversation" | "dateToday" | "dateYesterday" | "dateLastWeek" | "dateOlder" | "newConversation" | "dropZone" | "attachmentTooLarge" | "attachmentTooMany" | "attachmentMimeRejected" | "errorRetry" | "errorGeneric" | "loading" | "thinking" | "thoughts" | "usedTool" | "collapseSidebar" | "expandSidebar" | "tabHome" | "tabMessages" | "tabHelp" | "tabNews" | "modulesEmpty" | "moduleBack" | "contentLoading" | "homeGreeting" | "homeGreetingNamed" | "homeGreetingLead" | "homeSearchPlaceholder" | "homeContentTitle" | "helpTitle" | "helpSearchPlaceholder" | "helpEmpty" | "helpLoading" | "helpSearchEmpty" | "newsTitle" | "newsEmpty" | "newsLoading" | "newsBack" | "newsPublishedAt";
43
+ type StringKey = "launcherOpen" | "panelTitle" | "composerPlaceholder" | "send" | "stop" | "attach" | "micStart" | "micStop" | "micUnsupported" | "expand" | "collapse" | "fullscreen" | "exitFullscreen" | "resizeHandle" | "popOut" | "close" | "moreActions" | "soundOn" | "soundOff" | "language" | "theme" | "themeAuto" | "themeLight" | "themeDark" | "history" | "historyTitle" | "historyEmpty" | "historyLoading" | "historyNewChat" | "historyBack" | "messagesLoading" | "chatClosed" | "startNewConversation" | "dateToday" | "dateYesterday" | "dateLastWeek" | "dateOlder" | "newConversation" | "dropZone" | "attachmentTooLarge" | "attachmentTooMany" | "attachmentMimeRejected" | "errorRetry" | "errorGeneric" | "errorRateLimited" | "loading" | "thinking" | "thoughts" | "usedTool" | "collapseSidebar" | "expandSidebar" | "tabHome" | "tabMessages" | "tabHelp" | "tabNews" | "modulesEmpty" | "moduleBack" | "contentLoading" | "homeGreeting" | "homeGreetingNamed" | "homeGreetingLead" | "homeSearchPlaceholder" | "homeContentTitle" | "helpTitle" | "helpSearchPlaceholder" | "helpEmpty" | "helpLoading" | "helpSearchEmpty" | "newsTitle" | "newsEmpty" | "newsLoading" | "newsBack" | "newsPublishedAt";
44
44
  /** A partial map for one locale — what overrides look like on the wire. */
45
45
  type LocaleStrings = Partial<Record<StringKey, string>>;
46
46
  /**
package/index.mjs CHANGED
@@ -46,6 +46,7 @@ var STRINGS_EN = {
46
46
  dateYesterday: "Yesterday",
47
47
  dropZone: "Drop files to attach",
48
48
  errorGeneric: "Something went wrong",
49
+ errorRateLimited: "Too many requests \u2014 please try again in a moment.",
49
50
  errorRetry: "Retry",
50
51
  expand: "Expand",
51
52
  expandSidebar: "Expand sidebar",
@@ -936,9 +937,10 @@ function fillRandom(view) {
936
937
 
937
938
  // src/stream/types.ts
938
939
  var StreamError = class extends Error {
939
- constructor(message, code, cause) {
940
+ constructor(message, code, status, cause) {
940
941
  super(message);
941
942
  __publicField(this, "code", code);
943
+ __publicField(this, "status", status);
942
944
  __publicField(this, "cause", cause);
943
945
  this.name = "StreamError";
944
946
  }
@@ -949,7 +951,7 @@ var log3 = logger.scope("parser");
949
951
  async function* parseChatStream(response, signal6) {
950
952
  if (!response.ok) {
951
953
  const text = await response.text().catch(() => "");
952
- throw new StreamError(`server responded ${response.status}: ${text.slice(0, 200)}`, "server");
954
+ throw new StreamError(`server responded ${response.status}: ${text.slice(0, 200)}`, "server", response.status);
953
955
  }
954
956
  if (!response.body) throw new StreamError("response has no body", "no-body");
955
957
  const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
@@ -1113,15 +1115,27 @@ function messageToWireParts(m) {
1113
1115
  var log4 = logger.scope("transport");
1114
1116
  var MAX_RESUME_ATTEMPTS = 3;
1115
1117
  var RESUME_BACKOFF_MS = 400;
1118
+ var MAX_REQUEST_RETRIES = 3;
1119
+ var RETRYABLE_STATUS = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
1120
+ function backoffMs(attempt) {
1121
+ return RESUME_BACKOFF_MS * 2 ** attempt + Math.floor(Math.random() * 250);
1122
+ }
1123
+ function retryAfterMs(headers) {
1124
+ const raw = headers.get("retry-after");
1125
+ if (!raw) return null;
1126
+ const secs = Number(raw);
1127
+ const ms = Number.isFinite(secs) ? secs * 1e3 : Date.parse(raw) - Date.now();
1128
+ return Number.isFinite(ms) && ms >= 0 ? Math.min(ms, 3e4) : null;
1129
+ }
1116
1130
  function sleep(ms, signal6) {
1117
1131
  return new Promise((resolve) => {
1118
- if (signal6.aborted) return resolve();
1132
+ if (signal6?.aborted) return resolve();
1119
1133
  const id = setTimeout(done, ms);
1120
1134
  const onAbort = () => done();
1121
- signal6.addEventListener("abort", onAbort, { once: true });
1135
+ signal6?.addEventListener("abort", onAbort, { once: true });
1122
1136
  function done() {
1123
1137
  clearTimeout(id);
1124
- signal6.removeEventListener("abort", onAbort);
1138
+ signal6?.removeEventListener("abort", onAbort);
1125
1139
  resolve();
1126
1140
  }
1127
1141
  });
@@ -1375,6 +1389,7 @@ var AgentTransport = class {
1375
1389
  }
1376
1390
  } catch (err) {
1377
1391
  if (ctrl.signal.aborted) return true;
1392
+ if (err instanceof StreamError && err.status !== void 0) throw err;
1378
1393
  log4.debug("stream segment dropped", { err });
1379
1394
  }
1380
1395
  return false;
@@ -1419,13 +1434,23 @@ var AgentTransport = class {
1419
1434
  url.searchParams.set("resumeAt", String(resumeAt));
1420
1435
  return url.toString();
1421
1436
  }
1437
+ // JSON requests are idempotent (reads, or full-snapshot writes like
1438
+ // update-settings / mark-read / start-session), so they auto-retry transient
1439
+ // failures. The message POST is NOT in here — re-sending could duplicate the
1440
+ // reply, so it surfaces an error for the user to retry (see `openMessageStream`).
1422
1441
  async postJson(path, body, label) {
1423
- const res = await this.fetchImpl(this.url(path), {
1424
- method: "POST",
1425
- credentials: "omit",
1426
- headers: { "content-type": "application/json", ...this.opts.auth.headers() },
1427
- body: JSON.stringify(this.withEnvelope(body))
1428
- });
1442
+ const res = await this.fetchWithRetry(
1443
+ () => [
1444
+ this.url(path),
1445
+ {
1446
+ method: "POST",
1447
+ credentials: "omit",
1448
+ headers: { "content-type": "application/json", ...this.opts.auth.headers() },
1449
+ body: JSON.stringify(this.withEnvelope(body))
1450
+ }
1451
+ ],
1452
+ label
1453
+ );
1429
1454
  return this.assertOk(res, label);
1430
1455
  }
1431
1456
  async postForm(path, body, label) {
@@ -1438,16 +1463,44 @@ var AgentTransport = class {
1438
1463
  return this.assertOk(res, label);
1439
1464
  }
1440
1465
  async getJson(urlOrPath, label) {
1441
- const res = await this.fetchImpl(this.withEnvelopeQuery(urlOrPath), {
1442
- method: "GET",
1443
- credentials: "omit",
1444
- headers: { accept: "application/json", ...this.opts.auth.headers() }
1445
- });
1466
+ const res = await this.fetchWithRetry(
1467
+ () => [
1468
+ this.withEnvelopeQuery(urlOrPath),
1469
+ { method: "GET", credentials: "omit", headers: { accept: "application/json", ...this.opts.auth.headers() } }
1470
+ ],
1471
+ label
1472
+ );
1446
1473
  return this.assertOk(res, label);
1447
1474
  }
1448
- /** Throw on non-2xx, otherwise return the parsed JSON body. */
1475
+ /**
1476
+ * Fetch with bounded retries for *transient* failures: retryable statuses
1477
+ * (429 / 5xx / …) and network errors, honoring `Retry-After` (else backoff +
1478
+ * jitter). Terminal statuses (4xx) return immediately for the caller to throw.
1479
+ * Use only for idempotent requests.
1480
+ */
1481
+ async fetchWithRetry(build, label) {
1482
+ for (let attempt = 0; ; attempt++) {
1483
+ const [reqUrl, init2] = build();
1484
+ let res;
1485
+ try {
1486
+ res = await this.fetchImpl(reqUrl, init2);
1487
+ } catch (err) {
1488
+ if (attempt >= MAX_REQUEST_RETRIES) {
1489
+ throw new StreamError(`${label} failed: network error`, "network", void 0, err);
1490
+ }
1491
+ log4.debug("request network error \u2014 retrying", { label, attempt });
1492
+ await sleep(backoffMs(attempt));
1493
+ continue;
1494
+ }
1495
+ if (res.ok || !RETRYABLE_STATUS.has(res.status) || attempt >= MAX_REQUEST_RETRIES) return res;
1496
+ const wait = retryAfterMs(res.headers) ?? backoffMs(attempt);
1497
+ log4.debug("request retryable status \u2014 retrying", { label, status: res.status, attempt, wait });
1498
+ await sleep(wait);
1499
+ }
1500
+ }
1501
+ /** Throw on non-2xx (with the status attached), otherwise parse the JSON body. */
1449
1502
  async assertOk(res, label) {
1450
- if (!res.ok) throw new StreamError(`${label} failed: ${res.status}`, "server");
1503
+ if (!res.ok) throw new StreamError(`${label} failed: ${res.status}`, "server", res.status);
1451
1504
  return await res.json();
1452
1505
  }
1453
1506
  url(pathOrUrl) {
@@ -5453,7 +5506,7 @@ function App({ options, hostElement, bus }) {
5453
5506
  emitMessage(bus, options, "assistant", assistantText(assistantMsg));
5454
5507
  } catch (error) {
5455
5508
  assistantMsg.status = "error";
5456
- assistantMsg.errorText = error instanceof Error ? error.message : "Unknown error";
5509
+ assistantMsg.errorText = error instanceof StreamError && error.status === 429 ? options.strings.errorRateLimited : options.strings.errorGeneric;
5457
5510
  feedback.play("error");
5458
5511
  bus.emit("error", error);
5459
5512
  options.onError?.(error);
package/package.json CHANGED
@@ -80,5 +80,5 @@
80
80
  ],
81
81
  "type": "module",
82
82
  "types": "./index.d.ts",
83
- "version": "0.5.0"
83
+ "version": "0.6.0"
84
84
  }
package/web-component.mjs CHANGED
@@ -46,6 +46,7 @@ var STRINGS_EN = {
46
46
  dateYesterday: "Yesterday",
47
47
  dropZone: "Drop files to attach",
48
48
  errorGeneric: "Something went wrong",
49
+ errorRateLimited: "Too many requests \u2014 please try again in a moment.",
49
50
  errorRetry: "Retry",
50
51
  expand: "Expand",
51
52
  expandSidebar: "Expand sidebar",
@@ -1000,9 +1001,10 @@ function fillRandom(view) {
1000
1001
 
1001
1002
  // src/stream/types.ts
1002
1003
  var StreamError = class extends Error {
1003
- constructor(message, code, cause) {
1004
+ constructor(message, code, status, cause) {
1004
1005
  super(message);
1005
1006
  __publicField(this, "code", code);
1007
+ __publicField(this, "status", status);
1006
1008
  __publicField(this, "cause", cause);
1007
1009
  this.name = "StreamError";
1008
1010
  }
@@ -1013,7 +1015,7 @@ var log3 = logger.scope("parser");
1013
1015
  async function* parseChatStream(response, signal6) {
1014
1016
  if (!response.ok) {
1015
1017
  const text = await response.text().catch(() => "");
1016
- throw new StreamError(`server responded ${response.status}: ${text.slice(0, 200)}`, "server");
1018
+ throw new StreamError(`server responded ${response.status}: ${text.slice(0, 200)}`, "server", response.status);
1017
1019
  }
1018
1020
  if (!response.body) throw new StreamError("response has no body", "no-body");
1019
1021
  const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
@@ -1177,15 +1179,27 @@ function messageToWireParts(m) {
1177
1179
  var log4 = logger.scope("transport");
1178
1180
  var MAX_RESUME_ATTEMPTS = 3;
1179
1181
  var RESUME_BACKOFF_MS = 400;
1182
+ var MAX_REQUEST_RETRIES = 3;
1183
+ var RETRYABLE_STATUS = /* @__PURE__ */ new Set([408, 425, 429, 500, 502, 503, 504]);
1184
+ function backoffMs(attempt) {
1185
+ return RESUME_BACKOFF_MS * 2 ** attempt + Math.floor(Math.random() * 250);
1186
+ }
1187
+ function retryAfterMs(headers) {
1188
+ const raw = headers.get("retry-after");
1189
+ if (!raw) return null;
1190
+ const secs = Number(raw);
1191
+ const ms = Number.isFinite(secs) ? secs * 1e3 : Date.parse(raw) - Date.now();
1192
+ return Number.isFinite(ms) && ms >= 0 ? Math.min(ms, 3e4) : null;
1193
+ }
1180
1194
  function sleep(ms, signal6) {
1181
1195
  return new Promise((resolve) => {
1182
- if (signal6.aborted) return resolve();
1196
+ if (signal6?.aborted) return resolve();
1183
1197
  const id = setTimeout(done, ms);
1184
1198
  const onAbort = () => done();
1185
- signal6.addEventListener("abort", onAbort, { once: true });
1199
+ signal6?.addEventListener("abort", onAbort, { once: true });
1186
1200
  function done() {
1187
1201
  clearTimeout(id);
1188
- signal6.removeEventListener("abort", onAbort);
1202
+ signal6?.removeEventListener("abort", onAbort);
1189
1203
  resolve();
1190
1204
  }
1191
1205
  });
@@ -1439,6 +1453,7 @@ var AgentTransport = class {
1439
1453
  }
1440
1454
  } catch (err) {
1441
1455
  if (ctrl.signal.aborted) return true;
1456
+ if (err instanceof StreamError && err.status !== void 0) throw err;
1442
1457
  log4.debug("stream segment dropped", { err });
1443
1458
  }
1444
1459
  return false;
@@ -1483,13 +1498,23 @@ var AgentTransport = class {
1483
1498
  url.searchParams.set("resumeAt", String(resumeAt));
1484
1499
  return url.toString();
1485
1500
  }
1501
+ // JSON requests are idempotent (reads, or full-snapshot writes like
1502
+ // update-settings / mark-read / start-session), so they auto-retry transient
1503
+ // failures. The message POST is NOT in here — re-sending could duplicate the
1504
+ // reply, so it surfaces an error for the user to retry (see `openMessageStream`).
1486
1505
  async postJson(path, body, label) {
1487
- const res = await this.fetchImpl(this.url(path), {
1488
- method: "POST",
1489
- credentials: "omit",
1490
- headers: { "content-type": "application/json", ...this.opts.auth.headers() },
1491
- body: JSON.stringify(this.withEnvelope(body))
1492
- });
1506
+ const res = await this.fetchWithRetry(
1507
+ () => [
1508
+ this.url(path),
1509
+ {
1510
+ method: "POST",
1511
+ credentials: "omit",
1512
+ headers: { "content-type": "application/json", ...this.opts.auth.headers() },
1513
+ body: JSON.stringify(this.withEnvelope(body))
1514
+ }
1515
+ ],
1516
+ label
1517
+ );
1493
1518
  return this.assertOk(res, label);
1494
1519
  }
1495
1520
  async postForm(path, body, label) {
@@ -1502,16 +1527,44 @@ var AgentTransport = class {
1502
1527
  return this.assertOk(res, label);
1503
1528
  }
1504
1529
  async getJson(urlOrPath, label) {
1505
- const res = await this.fetchImpl(this.withEnvelopeQuery(urlOrPath), {
1506
- method: "GET",
1507
- credentials: "omit",
1508
- headers: { accept: "application/json", ...this.opts.auth.headers() }
1509
- });
1530
+ const res = await this.fetchWithRetry(
1531
+ () => [
1532
+ this.withEnvelopeQuery(urlOrPath),
1533
+ { method: "GET", credentials: "omit", headers: { accept: "application/json", ...this.opts.auth.headers() } }
1534
+ ],
1535
+ label
1536
+ );
1510
1537
  return this.assertOk(res, label);
1511
1538
  }
1512
- /** Throw on non-2xx, otherwise return the parsed JSON body. */
1539
+ /**
1540
+ * Fetch with bounded retries for *transient* failures: retryable statuses
1541
+ * (429 / 5xx / …) and network errors, honoring `Retry-After` (else backoff +
1542
+ * jitter). Terminal statuses (4xx) return immediately for the caller to throw.
1543
+ * Use only for idempotent requests.
1544
+ */
1545
+ async fetchWithRetry(build, label) {
1546
+ for (let attempt = 0; ; attempt++) {
1547
+ const [reqUrl, init] = build();
1548
+ let res;
1549
+ try {
1550
+ res = await this.fetchImpl(reqUrl, init);
1551
+ } catch (err) {
1552
+ if (attempt >= MAX_REQUEST_RETRIES) {
1553
+ throw new StreamError(`${label} failed: network error`, "network", void 0, err);
1554
+ }
1555
+ log4.debug("request network error \u2014 retrying", { label, attempt });
1556
+ await sleep(backoffMs(attempt));
1557
+ continue;
1558
+ }
1559
+ if (res.ok || !RETRYABLE_STATUS.has(res.status) || attempt >= MAX_REQUEST_RETRIES) return res;
1560
+ const wait = retryAfterMs(res.headers) ?? backoffMs(attempt);
1561
+ log4.debug("request retryable status \u2014 retrying", { label, status: res.status, attempt, wait });
1562
+ await sleep(wait);
1563
+ }
1564
+ }
1565
+ /** Throw on non-2xx (with the status attached), otherwise parse the JSON body. */
1513
1566
  async assertOk(res, label) {
1514
- if (!res.ok) throw new StreamError(`${label} failed: ${res.status}`, "server");
1567
+ if (!res.ok) throw new StreamError(`${label} failed: ${res.status}`, "server", res.status);
1515
1568
  return await res.json();
1516
1569
  }
1517
1570
  url(pathOrUrl) {
@@ -5517,7 +5570,7 @@ function App({ options, hostElement, bus }) {
5517
5570
  emitMessage(bus, options, "assistant", assistantText(assistantMsg));
5518
5571
  } catch (error) {
5519
5572
  assistantMsg.status = "error";
5520
- assistantMsg.errorText = error instanceof Error ? error.message : "Unknown error";
5573
+ assistantMsg.errorText = error instanceof StreamError && error.status === 429 ? options.strings.errorRateLimited : options.strings.errorGeneric;
5521
5574
  feedback.play("error");
5522
5575
  bus.emit("error", error);
5523
5576
  options.onError?.(error);