@helpai/elements 0.7.5 → 0.8.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
@@ -1226,6 +1226,17 @@ var AgentTransport = class {
1226
1226
  this.userContext = userContext;
1227
1227
  this.pageContext = pageContext;
1228
1228
  }
1229
+ /**
1230
+ * Seed the visitor + session identity from persistence BEFORE the first
1231
+ * `startSession` resolves. A mount-time `resumeStream()` runs in parallel
1232
+ * with `startSession`, so without this its envelope would carry no
1233
+ * `sessionId` and the resume GET couldn't identify the thread. `startSession`
1234
+ * later overwrites these with the server's authoritative ids (rebind-safe).
1235
+ */
1236
+ primeIdentity(visitorId, sessionId) {
1237
+ if (visitorId) this.visitorId = visitorId;
1238
+ if (sessionId) this.sessionId = sessionId;
1239
+ }
1229
1240
  /** Build the per-request envelope from the current transport state. */
1230
1241
  envelope() {
1231
1242
  const out = {};
@@ -1309,9 +1320,10 @@ var AgentTransport = class {
1309
1320
  cursor: params.cursor
1310
1321
  });
1311
1322
  const res = await this.getJson(url.toString(), "listSessions");
1312
- log4.debug("listSessions \u2190", { count: res.sessions.length, nextCursor: res.nextCursor });
1323
+ const sessions = res.sessions ?? [];
1324
+ log4.debug("listSessions \u2190", { count: sessions.length, nextCursor: res.nextCursor });
1313
1325
  return {
1314
- sessions: res.sessions.map((session) => ({
1326
+ sessions: sessions.map((session) => ({
1315
1327
  sessionId: session.sessionId,
1316
1328
  title: session.title,
1317
1329
  lastMessageAt: session.lastMessageAt,
@@ -1327,16 +1339,17 @@ var AgentTransport = class {
1327
1339
  url.searchParams.set("sessionId", sessionId);
1328
1340
  log4.debug("loadSession \u2192", { sessionId, visitorId: this.visitorId });
1329
1341
  const res = await this.getJson(url.toString(), "loadSession");
1342
+ const messages = res.messages ?? [];
1330
1343
  log4.debug("loadSession \u2190", {
1331
1344
  sessionId: res.sessionId,
1332
1345
  canContinue: res.canContinue,
1333
- messageCount: res.messages.length,
1346
+ messageCount: messages.length,
1334
1347
  hasSuggestions: !!res.suggestions
1335
1348
  });
1336
1349
  return {
1337
1350
  sessionId: res.sessionId,
1338
- canContinue: res.canContinue,
1339
- messages: res.messages,
1351
+ canContinue: res.canContinue ?? true,
1352
+ messages,
1340
1353
  agent: res.agent,
1341
1354
  suggestions: res.suggestions
1342
1355
  };
@@ -1370,8 +1383,9 @@ var AgentTransport = class {
1370
1383
  }
1371
1384
  log4.debug("listContent \u2192", query);
1372
1385
  const res = await this.getJson(url.toString(), "listContent");
1373
- log4.debug("listContent \u2190", { count: res.items.length, total: res.pagination?.total });
1374
- return res;
1386
+ const items = res.items ?? [];
1387
+ log4.debug("listContent \u2190", { count: items.length, total: res.pagination?.total });
1388
+ return { ...res, items };
1375
1389
  }
1376
1390
  async saveUserPrefs(userPrefs) {
1377
1391
  log4.debug("saveUserPrefs \u2192", {
@@ -1404,12 +1418,53 @@ var AgentTransport = class {
1404
1418
  const cancel = () => this.cancelStream(ctrl, body.sessionId);
1405
1419
  return { iter, cancel };
1406
1420
  }
1421
+ /**
1422
+ * Re-attach to an in-flight reply on a cold page load / refresh.
1423
+ *
1424
+ * UNCONDITIONAL by design — the caller runs this in parallel with
1425
+ * `startSession`, never gated on whether there are persisted messages. If
1426
+ * the user refreshed while the assistant was still streaming, the
1427
+ * `startSession` reply carries no messages (the turn isn't persisted until
1428
+ * it finishes) and THIS is the channel that resumes the live reply. When
1429
+ * nothing is in flight the server answers 204 and the handle yields nothing
1430
+ * (the common case — no bubble is created).
1431
+ *
1432
+ * `cancel` only detaches the client (aborts the local read); unlike
1433
+ * `sendMessage` it does NOT POST `/cancel-stream` — the reply may still be
1434
+ * generating for another tab or about to be persisted, and a page-load
1435
+ * resume must never tear that down.
1436
+ */
1437
+ resumeStream() {
1438
+ const ctrl = new AbortController();
1439
+ const seenIds = /* @__PURE__ */ new Set();
1440
+ const iter = this.resumeStreamGen(ctrl, seenIds, true);
1441
+ return { iter, cancel: () => ctrl.abort() };
1442
+ }
1407
1443
  // ---- Stream orchestration ---------------------------------------------
1408
1444
  async *runStream(body, ctrl, seenIds) {
1409
1445
  if (yield* this.drain(this.openMessageStream(body, ctrl.signal), seenIds, ctrl)) return;
1446
+ yield* this.resumeStreamGen(ctrl, seenIds, false);
1447
+ }
1448
+ /**
1449
+ * Re-attach to an in-flight reply via GET /stream-resume, yielding the
1450
+ * (deduped) continuation. Two callers:
1451
+ * - after a POST /stream-message drops mid-reply (`immediate=false` — back
1452
+ * off first, the reply may still be spinning up server-side);
1453
+ * - on a cold page load / refresh (`immediate=true` — hit it at once, in
1454
+ * parallel with start-session, so a reply that was streaming when the
1455
+ * user reloaded keeps flowing).
1456
+ *
1457
+ * Returns (no throw) on 204 / non-OK — nothing in flight (or a backend
1458
+ * without the endpoint → graceful no-op) — or once a terminal chunk is
1459
+ * drained. Throws only when every attempt produced a 200 that dropped before
1460
+ * finishing: a genuinely incomplete reply the UI should flag with a retry.
1461
+ */
1462
+ async *resumeStreamGen(ctrl, seenIds, immediate) {
1410
1463
  for (let attempt = 1; attempt <= MAX_RESUME_ATTEMPTS && !ctrl.signal.aborted; attempt++) {
1411
- await sleep(RESUME_BACKOFF_MS * attempt, ctrl.signal);
1412
- if (ctrl.signal.aborted) return;
1464
+ if (!immediate || attempt > 1) {
1465
+ await sleep(RESUME_BACKOFF_MS * attempt, ctrl.signal);
1466
+ if (ctrl.signal.aborted) return;
1467
+ }
1413
1468
  let res;
1414
1469
  try {
1415
1470
  res = await this.fetchImpl(this.resumeUrl(seenIds.size), {
@@ -3779,7 +3834,7 @@ function ConversationList({ transport, strings, visitorId, onSelect, onNewChat }
3779
3834
  let cancelled = false;
3780
3835
  transport.listSessions({ visitorId }).then((res) => {
3781
3836
  if (cancelled) return;
3782
- setChats(res.sessions);
3837
+ setChats(res.sessions ?? []);
3783
3838
  setState("loaded");
3784
3839
  }).catch((err) => {
3785
3840
  if (cancelled) return;
@@ -4670,7 +4725,7 @@ function HelpRoot({ transport, strings, config, nav, panelProps }) {
4670
4725
  setState("loading");
4671
4726
  transport.listContent({ tags, sortBy: "order", limit: 100 }).then((res) => {
4672
4727
  if (cancelled) return;
4673
- setItems(res.items);
4728
+ setItems(res.items ?? []);
4674
4729
  setState("loaded");
4675
4730
  }).catch((err) => {
4676
4731
  if (cancelled) return;
@@ -4744,7 +4799,7 @@ function HomeRoot(props2) {
4744
4799
  useEffect12(() => {
4745
4800
  if (!config.showRecentMessages) return;
4746
4801
  let cancelled = false;
4747
- transport.listSessions({ limit: 1 }).then((res) => !cancelled && setRecent(res.sessions[0] ?? null)).catch((err) => !cancelled && log13.warn("listSessions (home) failed", { err }));
4802
+ transport.listSessions({ limit: 1 }).then((res) => !cancelled && setRecent(res.sessions?.[0] ?? null)).catch((err) => !cancelled && log13.warn("listSessions (home) failed", { err }));
4748
4803
  return () => {
4749
4804
  cancelled = true;
4750
4805
  };
@@ -4752,7 +4807,7 @@ function HomeRoot(props2) {
4752
4807
  useEffect12(() => {
4753
4808
  if (!config.contentTags?.length) return;
4754
4809
  let cancelled = false;
4755
- transport.listContent({ tags: config.contentTags, limit: 6 }).then((res) => !cancelled && setContent(res.items)).catch((err) => !cancelled && log13.warn("listContent (home) failed", { err }));
4810
+ transport.listContent({ tags: config.contentTags, limit: 6 }).then((res) => !cancelled && setContent(res.items ?? [])).catch((err) => !cancelled && log13.warn("listContent (home) failed", { err }));
4756
4811
  return () => {
4757
4812
  cancelled = true;
4758
4813
  };
@@ -4848,7 +4903,7 @@ function NewsRoot({ transport, strings, config, nav, panelProps }) {
4848
4903
  setState("loading");
4849
4904
  transport.listContent({ tags, sortBy: "publishedAt", sortOrder: "desc" }).then((res) => {
4850
4905
  if (cancelled) return;
4851
- setItems(res.items);
4906
+ setItems(res.items ?? []);
4852
4907
  setState("loaded");
4853
4908
  }).catch((err) => {
4854
4909
  if (cancelled) return;
@@ -5461,10 +5516,11 @@ function App({ options, hostElement, bus }) {
5461
5516
  if (isResume) {
5462
5517
  setLoadingMessages(true);
5463
5518
  try {
5464
- const chat = res.messages ? { sessionId: persistedChatId, canContinue: res.canContinue ?? true, messages: res.messages } : await transport.loadSession(persistedChatId);
5519
+ const chat = res.messages?.length ? { sessionId: persistedChatId, canContinue: res.canContinue ?? true, messages: res.messages } : await transport.loadSession(persistedChatId);
5465
5520
  if (isStale()) return;
5466
- messagesSig.value = chat.messages.map(fromWireMessage);
5467
- setCanSend(chat.canContinue);
5521
+ const loaded = (chat.messages ?? []).map(fromWireMessage);
5522
+ if (loaded.length) messagesSig.value = loaded;
5523
+ setCanSend(chat.canContinue ?? true);
5468
5524
  setSuggestions(chat.suggestions ?? []);
5469
5525
  } catch (err) {
5470
5526
  if (isStale()) return;
@@ -5491,10 +5547,57 @@ function App({ options, hostElement, bus }) {
5491
5547
  useEffect17(() => {
5492
5548
  transport.setContext(options.userContext, toWirePageContext(options.pageContext));
5493
5549
  }, [transport, userSig, pageSig]);
5550
+ const runResume = useCallback8(
5551
+ async (handle) => {
5552
+ let bubble = null;
5553
+ try {
5554
+ for await (const evt of handle.iter) {
5555
+ if (!bubble) {
5556
+ bubble = makeAssistantMessage();
5557
+ reducer.attach(bubble);
5558
+ messagesSig.value = [...messagesSig.value, bubble];
5559
+ setStreaming(true);
5560
+ setActiveCancel(() => handle.cancel);
5561
+ log16.info("resumed in-flight reply on mount");
5562
+ }
5563
+ reducer.apply(evt.chunk);
5564
+ if (evt.chunk.type === "finish" && evt.chunk.canContinue === false) setCanSend(false);
5565
+ }
5566
+ if (bubble) {
5567
+ bubble.status = "complete";
5568
+ feedback.play("messageReceived");
5569
+ emitMessage(bus, options, "assistant", assistantText(bubble));
5570
+ }
5571
+ } catch (err) {
5572
+ if (bubble) {
5573
+ bubble.status = "error";
5574
+ bubble.errorText = errorMessageFor(err, options.strings);
5575
+ feedback.play("error");
5576
+ }
5577
+ log16.debug("mount resume ended without completing", { err });
5578
+ } finally {
5579
+ if (bubble) {
5580
+ reducer.detach();
5581
+ setStreaming(false);
5582
+ setActiveCancel(null);
5583
+ messagesSig.value = [...messagesSig.value];
5584
+ }
5585
+ }
5586
+ },
5587
+ [reducer, messagesSig, feedback, bus, options]
5588
+ );
5494
5589
  useEffect17(() => {
5495
5590
  void runStartSession({ newConversation: false });
5591
+ const persistedSession = sessionIdSig.value;
5592
+ let resume = null;
5593
+ if (persistedSession) {
5594
+ transport.primeIdentity(visitorId, persistedSession);
5595
+ resume = transport.resumeStream();
5596
+ void runResume(resume);
5597
+ }
5496
5598
  return () => {
5497
5599
  connectGenRef.current++;
5600
+ resume?.cancel();
5498
5601
  };
5499
5602
  }, []);
5500
5603
  const lastUserSig = useRef8(userSig);