@helpai/elements 0.5.0 → 0.7.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/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) {
@@ -2180,6 +2233,12 @@ var FeedbackBus = class {
2180
2233
  }
2181
2234
  };
2182
2235
 
2236
+ // src/ui/error-message.ts
2237
+ function errorMessageFor(error, strings) {
2238
+ if (error instanceof StreamError && error.status === 429) return strings.errorRateLimited;
2239
+ return strings.errorGeneric;
2240
+ }
2241
+
2183
2242
  // src/ui/launcher.tsx
2184
2243
  import { useEffect, useState } from "preact/hooks";
2185
2244
 
@@ -4596,6 +4655,7 @@ function ArticleRow({ article, nav }) {
4596
4655
  function HelpRoot({ transport, strings, config, nav, panelProps }) {
4597
4656
  const tags = config.contentTags;
4598
4657
  const [state, setState] = useState6("loading");
4658
+ const [errorMsg, setErrorMsg] = useState6(strings.errorGeneric);
4599
4659
  const [items, setItems] = useState6([]);
4600
4660
  const [query, setQuery] = useState6("");
4601
4661
  const [reloadKey, setReloadKey] = useState6(0);
@@ -4609,6 +4669,7 @@ function HelpRoot({ transport, strings, config, nav, panelProps }) {
4609
4669
  }).catch((err) => {
4610
4670
  if (cancelled) return;
4611
4671
  log12.warn("listContent (help) failed", { err });
4672
+ setErrorMsg(errorMessageFor(err, strings));
4612
4673
  setState("error");
4613
4674
  });
4614
4675
  return () => {
@@ -4623,15 +4684,7 @@ function HelpRoot({ transport, strings, config, nav, panelProps }) {
4623
4684
  }
4624
4685
  if (state === "loading") return /* @__PURE__ */ jsx25(ModuleState, { message: strings.helpLoading, strings });
4625
4686
  if (state === "error") {
4626
- return /* @__PURE__ */ jsx25(
4627
- ModuleState,
4628
- {
4629
- tone: "error",
4630
- message: strings.errorGeneric,
4631
- onRetry: () => setReloadKey((k) => k + 1),
4632
- strings
4633
- }
4634
- );
4687
+ return /* @__PURE__ */ jsx25(ModuleState, { tone: "error", message: errorMsg, onRetry: () => setReloadKey((k) => k + 1), strings });
4635
4688
  }
4636
4689
  if (items.length === 0) return /* @__PURE__ */ jsx25(ModuleState, { message: strings.helpEmpty, strings });
4637
4690
  return groupByCategory(items).map(([category, rows]) => /* @__PURE__ */ jsxs20("section", { class: `${p18}-help-group`, children: [
@@ -4781,6 +4834,7 @@ var log14 = logger.scope("news");
4781
4834
  function NewsRoot({ transport, strings, config, nav, panelProps }) {
4782
4835
  const tags = config.contentTags;
4783
4836
  const [state, setState] = useState8("loading");
4837
+ const [errorMsg, setErrorMsg] = useState8(strings.errorGeneric);
4784
4838
  const [items, setItems] = useState8([]);
4785
4839
  const [reloadKey, setReloadKey] = useState8(0);
4786
4840
  useEffect13(() => {
@@ -4793,6 +4847,7 @@ function NewsRoot({ transport, strings, config, nav, panelProps }) {
4793
4847
  }).catch((err) => {
4794
4848
  if (cancelled) return;
4795
4849
  log14.warn("listContent (news) failed", { err });
4850
+ setErrorMsg(errorMessageFor(err, strings));
4796
4851
  setState("error");
4797
4852
  });
4798
4853
  return () => {
@@ -4802,15 +4857,7 @@ function NewsRoot({ transport, strings, config, nav, panelProps }) {
4802
4857
  function renderBody() {
4803
4858
  if (state === "loading") return /* @__PURE__ */ jsx28(ModuleState, { message: strings.newsLoading, strings });
4804
4859
  if (state === "error") {
4805
- return /* @__PURE__ */ jsx28(
4806
- ModuleState,
4807
- {
4808
- tone: "error",
4809
- message: strings.errorGeneric,
4810
- onRetry: () => setReloadKey((k) => k + 1),
4811
- strings
4812
- }
4813
- );
4860
+ return /* @__PURE__ */ jsx28(ModuleState, { tone: "error", message: errorMsg, onRetry: () => setReloadKey((k) => k + 1), strings });
4814
4861
  }
4815
4862
  if (items.length === 0) return /* @__PURE__ */ jsx28(ModuleState, { message: strings.newsEmpty, strings });
4816
4863
  return /* @__PURE__ */ jsx28("div", { class: `${p21}-news-list`, children: items.map((item) => /* @__PURE__ */ jsxs22(
@@ -5517,7 +5564,7 @@ function App({ options, hostElement, bus }) {
5517
5564
  emitMessage(bus, options, "assistant", assistantText(assistantMsg));
5518
5565
  } catch (error) {
5519
5566
  assistantMsg.status = "error";
5520
- assistantMsg.errorText = error instanceof Error ? error.message : "Unknown error";
5567
+ assistantMsg.errorText = errorMessageFor(error, options.strings);
5521
5568
  feedback.play("error");
5522
5569
  bus.emit("error", error);
5523
5570
  options.onError?.(error);