@helpai/elements 0.7.5 → 0.8.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/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,62 @@ 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
+ * The endpoint ALWAYS opens a 200 SSE stream (mirrors the AI SDK
1458
+ * resumable-stream lib): it replays the in-flight reply from the buffer, or —
1459
+ * when nothing is in flight (reply finished + the buffer was flushed, or
1460
+ * nothing ever started) — closes an EMPTY stream. So "nothing to resume" is
1461
+ * signalled by a segment that ends without a terminal chunk AND surfaced no
1462
+ * NEW events — not by a status code. We stop gracefully there; the completed
1463
+ * turn comes from the DB via start-session / list-messages.
1464
+ *
1465
+ * Returns (no throw) on a terminal chunk, an empty/no-new-events segment, or
1466
+ * a 204 / non-OK (a backend that signals "nothing" by status, or lacks the
1467
+ * endpoint). Retries only a segment that yielded new events but no finish — a
1468
+ * real mid-reply drop. Throws only if every attempt kept yielding new events
1469
+ * yet never finished: a genuinely stuck, incomplete reply.
1470
+ */
1471
+ async *resumeStreamGen(ctrl, seenIds, immediate) {
1410
1472
  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;
1473
+ if (!immediate || attempt > 1) {
1474
+ await sleep(RESUME_BACKOFF_MS * attempt, ctrl.signal);
1475
+ if (ctrl.signal.aborted) return;
1476
+ }
1413
1477
  let res;
1414
1478
  try {
1415
1479
  res = await this.fetchImpl(this.resumeUrl(seenIds.size), {
@@ -1427,7 +1491,12 @@ var AgentTransport = class {
1427
1491
  log4.debug("resume \u2192 no in-flight stream", { status: res.status });
1428
1492
  return;
1429
1493
  }
1494
+ const before = seenIds.size;
1430
1495
  if (yield* this.drain(parseChatStream(res, ctrl.signal), seenIds, ctrl)) return;
1496
+ if (seenIds.size === before) {
1497
+ log4.debug("resume \u2192 stream closed with no new events; nothing to resume");
1498
+ return;
1499
+ }
1431
1500
  }
1432
1501
  if (ctrl.signal.aborted) return;
1433
1502
  throw new StreamError("stream incomplete after resume attempts", "server");
@@ -3779,7 +3848,7 @@ function ConversationList({ transport, strings, visitorId, onSelect, onNewChat }
3779
3848
  let cancelled = false;
3780
3849
  transport.listSessions({ visitorId }).then((res) => {
3781
3850
  if (cancelled) return;
3782
- setChats(res.sessions);
3851
+ setChats(res.sessions ?? []);
3783
3852
  setState("loaded");
3784
3853
  }).catch((err) => {
3785
3854
  if (cancelled) return;
@@ -4670,7 +4739,7 @@ function HelpRoot({ transport, strings, config, nav, panelProps }) {
4670
4739
  setState("loading");
4671
4740
  transport.listContent({ tags, sortBy: "order", limit: 100 }).then((res) => {
4672
4741
  if (cancelled) return;
4673
- setItems(res.items);
4742
+ setItems(res.items ?? []);
4674
4743
  setState("loaded");
4675
4744
  }).catch((err) => {
4676
4745
  if (cancelled) return;
@@ -4744,7 +4813,7 @@ function HomeRoot(props2) {
4744
4813
  useEffect12(() => {
4745
4814
  if (!config.showRecentMessages) return;
4746
4815
  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 }));
4816
+ transport.listSessions({ limit: 1 }).then((res) => !cancelled && setRecent(res.sessions?.[0] ?? null)).catch((err) => !cancelled && log13.warn("listSessions (home) failed", { err }));
4748
4817
  return () => {
4749
4818
  cancelled = true;
4750
4819
  };
@@ -4752,7 +4821,7 @@ function HomeRoot(props2) {
4752
4821
  useEffect12(() => {
4753
4822
  if (!config.contentTags?.length) return;
4754
4823
  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 }));
4824
+ transport.listContent({ tags: config.contentTags, limit: 6 }).then((res) => !cancelled && setContent(res.items ?? [])).catch((err) => !cancelled && log13.warn("listContent (home) failed", { err }));
4756
4825
  return () => {
4757
4826
  cancelled = true;
4758
4827
  };
@@ -4848,7 +4917,7 @@ function NewsRoot({ transport, strings, config, nav, panelProps }) {
4848
4917
  setState("loading");
4849
4918
  transport.listContent({ tags, sortBy: "publishedAt", sortOrder: "desc" }).then((res) => {
4850
4919
  if (cancelled) return;
4851
- setItems(res.items);
4920
+ setItems(res.items ?? []);
4852
4921
  setState("loaded");
4853
4922
  }).catch((err) => {
4854
4923
  if (cancelled) return;
@@ -5461,10 +5530,11 @@ function App({ options, hostElement, bus }) {
5461
5530
  if (isResume) {
5462
5531
  setLoadingMessages(true);
5463
5532
  try {
5464
- const chat = res.messages ? { sessionId: persistedChatId, canContinue: res.canContinue ?? true, messages: res.messages } : await transport.loadSession(persistedChatId);
5533
+ const chat = res.messages?.length ? { sessionId: persistedChatId, canContinue: res.canContinue ?? true, messages: res.messages } : await transport.loadSession(persistedChatId);
5465
5534
  if (isStale()) return;
5466
- messagesSig.value = chat.messages.map(fromWireMessage);
5467
- setCanSend(chat.canContinue);
5535
+ const loaded = (chat.messages ?? []).map(fromWireMessage);
5536
+ if (loaded.length) messagesSig.value = loaded;
5537
+ setCanSend(chat.canContinue ?? true);
5468
5538
  setSuggestions(chat.suggestions ?? []);
5469
5539
  } catch (err) {
5470
5540
  if (isStale()) return;
@@ -5491,10 +5561,57 @@ function App({ options, hostElement, bus }) {
5491
5561
  useEffect17(() => {
5492
5562
  transport.setContext(options.userContext, toWirePageContext(options.pageContext));
5493
5563
  }, [transport, userSig, pageSig]);
5564
+ const runResume = useCallback8(
5565
+ async (handle) => {
5566
+ let bubble = null;
5567
+ try {
5568
+ for await (const evt of handle.iter) {
5569
+ if (!bubble) {
5570
+ bubble = makeAssistantMessage();
5571
+ reducer.attach(bubble);
5572
+ messagesSig.value = [...messagesSig.value, bubble];
5573
+ setStreaming(true);
5574
+ setActiveCancel(() => handle.cancel);
5575
+ log16.info("resumed in-flight reply on mount");
5576
+ }
5577
+ reducer.apply(evt.chunk);
5578
+ if (evt.chunk.type === "finish" && evt.chunk.canContinue === false) setCanSend(false);
5579
+ }
5580
+ if (bubble) {
5581
+ bubble.status = "complete";
5582
+ feedback.play("messageReceived");
5583
+ emitMessage(bus, options, "assistant", assistantText(bubble));
5584
+ }
5585
+ } catch (err) {
5586
+ if (bubble) {
5587
+ bubble.status = "error";
5588
+ bubble.errorText = errorMessageFor(err, options.strings);
5589
+ feedback.play("error");
5590
+ }
5591
+ log16.debug("mount resume ended without completing", { err });
5592
+ } finally {
5593
+ if (bubble) {
5594
+ reducer.detach();
5595
+ setStreaming(false);
5596
+ setActiveCancel(null);
5597
+ messagesSig.value = [...messagesSig.value];
5598
+ }
5599
+ }
5600
+ },
5601
+ [reducer, messagesSig, feedback, bus, options]
5602
+ );
5494
5603
  useEffect17(() => {
5495
5604
  void runStartSession({ newConversation: false });
5605
+ const persistedSession = sessionIdSig.value;
5606
+ let resume = null;
5607
+ if (persistedSession) {
5608
+ transport.primeIdentity(visitorId, persistedSession);
5609
+ resume = transport.resumeStream();
5610
+ void runResume(resume);
5611
+ }
5496
5612
  return () => {
5497
5613
  connectGenRef.current++;
5614
+ resume?.cancel();
5498
5615
  };
5499
5616
  }, []);
5500
5617
  const lastUserSig = useRef8(userSig);