@helpai/elements 0.24.0 → 0.26.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
@@ -19,7 +19,12 @@ var BRAND = {
19
19
  // and @goai builds.
20
20
  tagName: true ? "web-ai-chat" : "web-ai-chat",
21
21
  cssPrefix: true ? "helpai" : "helpai",
22
- defaultBaseUrl: true ? "https://help.ai/api" : "",
22
+ /**
23
+ * MAIN site origin (e.g. `https://help.ai`) — module bases derive from it:
24
+ * agent API at `${defaultBaseUrl}/api/agent`, data API at
25
+ * `${defaultBaseUrl}/api/data`.
26
+ */
27
+ defaultBaseUrl: true ? "https://help.ai" : "",
23
28
  standaloneUrl: true ? "https://help.ai/chat" : "",
24
29
  /** Public-facing brand label, e.g. "Help AI" / "Go AI". */
25
30
  displayName: true ? "Help AI" : "Help AI",
@@ -457,13 +462,12 @@ var DEFAULT_FEATURES = {
457
462
  var DEFAULT_FORMS = { list: [], byTrigger: {} };
458
463
  var DEFAULT_TRACKING = {
459
464
  enabled: false,
460
- endpoint: `https://t.${BRAND.domain}/api/ai-elements/px.gif`,
461
465
  sampleRate: 1
462
466
  };
463
467
  var DEFAULT_ENDPOINTS = {
464
- upload: "/ai/agent/upload-file",
465
- transcribe: "/ai/agent/transcribe-audio",
466
- submitForm: "/ai/agent/submit-form"
468
+ upload: "/ai-agent/upload-file",
469
+ transcribe: "/ai-agent/transcribe-audio",
470
+ submitForm: "/submit-form"
467
471
  };
468
472
  var DEFAULT_LAUNCHER = {
469
473
  variant: "circle",
@@ -505,11 +509,16 @@ function resolveOptions(rawOpts) {
505
509
  const mode = presentation.mode ?? "floating";
506
510
  const locale = i18n.locale ?? resolveDefaultLocale(i18n.defaultLocale);
507
511
  const availableLocales = i18n.availableLocales ?? [locale];
512
+ const baseUrl = (opts.baseUrl ?? BRAND.defaultBaseUrl).replace(/\/$/, "");
513
+ const agentBaseUrl = (opts.agentBaseUrl ?? `${baseUrl}/api/agent`).replace(/\/$/, "");
514
+ const dataBaseUrl = (opts.dataBaseUrl ?? `${baseUrl}/api/data`).replace(/\/$/, "");
508
515
  return {
509
516
  widgetId: opts.widgetId ?? "default",
510
517
  publicKey: opts.publicKey,
511
518
  aiAgentDeploymentId: opts.aiAgentDeploymentId,
512
- baseUrl: (opts.baseUrl ?? BRAND.defaultBaseUrl).replace(/\/$/, ""),
519
+ baseUrl,
520
+ agentBaseUrl,
521
+ dataBaseUrl,
513
522
  userContext: sanitizeUserContext(opts.userContext),
514
523
  pageContext: sanitizePageContext(opts.pageContext),
515
524
  position: presentation.position ?? "bottom-right",
@@ -554,7 +563,7 @@ function resolveOptions(rawOpts) {
554
563
  poweredBy: resolvePoweredBy(footer.poweredBy),
555
564
  composer: resolveComposer(opts.composer),
556
565
  modules: resolveModules(opts.modules),
557
- tracking: resolveTracking(opts.tracking),
566
+ tracking: resolveTracking(opts.tracking, dataBaseUrl),
558
567
  storage: opts.storage ?? defaultStorage,
559
568
  onMessage: opts.onMessage,
560
569
  onOpen: opts.onOpen,
@@ -587,11 +596,13 @@ function resolveHaptics(overrides) {
587
596
  events: overrides?.events
588
597
  };
589
598
  }
590
- function resolveTracking(overrides) {
599
+ function resolveTracking(overrides, dataBaseUrl) {
591
600
  const sampleRate = overrides?.sampleRate ?? DEFAULT_TRACKING.sampleRate;
592
601
  return {
593
602
  enabled: overrides?.enabled ?? DEFAULT_TRACKING.enabled,
594
- endpoint: overrides?.endpoint ?? DEFAULT_TRACKING.endpoint,
603
+ // The collector rides the data module — `${dataBaseUrl}/elements/px.gif`
604
+ // (e.g. `https://help.ai/api/data/elements/px.gif`).
605
+ endpoint: overrides?.endpoint ?? `${dataBaseUrl}/elements/px.gif`,
595
606
  // Clamp instead of reject — a malformed rate must never turn a
596
607
  // disabled tracker on or crash resolve (no Zod on the IIFE path).
597
608
  sampleRate: Math.min(1, Math.max(0, Number.isFinite(sampleRate) ? sampleRate : 1)),
@@ -760,7 +771,7 @@ function mergeServerConfig(user, server) {
760
771
  out[key] = mergeLeaves(sv, uv);
761
772
  }
762
773
  if (server.modules !== void 0 && user.modules === void 0) out.modules = server.modules;
763
- if (server.forms !== void 0 && user.forms === void 0) out.forms = server.forms;
774
+ if (server.dataBaseUrl !== void 0 && user.dataBaseUrl === void 0) out.dataBaseUrl = server.dataBaseUrl;
764
775
  const userI18n = user.i18n;
765
776
  const serverI18n = server.i18n;
766
777
  if (userI18n || serverI18n) {
@@ -820,6 +831,8 @@ var EMBED_SCALAR_ATTRS = [
820
831
  ["public-key", "publicKey"],
821
832
  ["ai-agent-deployment-id", "aiAgentDeploymentId"],
822
833
  ["base-url", "baseUrl"],
834
+ ["agent-base-url", "agentBaseUrl"],
835
+ ["data-base-url", "dataBaseUrl"],
823
836
  ["theme", "theme", (v) => v]
824
837
  ];
825
838
  var PRESENTATION_ATTRS = [
@@ -967,7 +980,7 @@ function parseLauncher(get, str2) {
967
980
  return Object.keys(base).length > 0 ? base : null;
968
981
  }
969
982
 
970
- // src/core/start-conversation-shape.ts
983
+ // src/core/handshake-shape.ts
971
984
  function isPlainObject(raw) {
972
985
  return !!raw && typeof raw === "object" && !Array.isArray(raw);
973
986
  }
@@ -1295,46 +1308,63 @@ function parseWireDate(value) {
1295
1308
  }
1296
1309
  var DEFAULT_PATHS = {
1297
1310
  /** Conversation bootstrap. `POST` → deployment config + per-visitor state. */
1298
- startConversation: "/ai/agent/start-conversation",
1311
+ handshake: "/ai-agent/handshake",
1299
1312
  /** Send a message + stream the reply (SSE). `POST`. */
1300
- streamMessage: "/ai/agent/stream-message",
1313
+ streamMessage: "/ai-agent/stream-message",
1301
1314
  /**
1302
1315
  * Resume an interrupted reply. `GET` (envelope rides as query params).
1303
1316
  * Stateless: the server re-streams the whole reply SSE again from the start —
1304
1317
  * there is NO `Last-Event-ID`. The client reconciles by id (`seenIds`), so
1305
1318
  * replayed events are skipped and only the continuation surfaces.
1306
1319
  */
1307
- streamResume: "/ai/agent/stream-resume",
1320
+ streamResume: "/ai-agent/stream-resume",
1308
1321
  /** Abort the in-flight reply stream. `POST`. */
1309
- cancelStream: "/ai/agent/cancel-stream",
1322
+ cancelStream: "/ai-agent/cancel-stream",
1310
1323
  /** List the visitor's conversations. `GET`. */
1311
- listConversations: "/ai/agent/list-conversations",
1312
- /** Load one conversation's messages. `GET ?conversationId=…`. */
1313
- listMessages: "/ai/agent/list-messages",
1324
+ listConversations: "/ai-agent/list-conversations",
1325
+ /** Load one conversation's thread (messages + canContinue + suggestions). `GET ?conversationId=…`. */
1326
+ listMessages: "/ai-agent/list-messages",
1314
1327
  /**
1315
1328
  * Persist a user-driven settings change. POST `{ visitorId, userPrefs }` →
1316
1329
  * server persists, returns the merged authoritative copy. Fire-and-forget on
1317
1330
  * the client — a failure leaves the local cache valid; the next
1318
- * `startConversation()` reconciles.
1331
+ * `handshake()` reconciles.
1319
1332
  */
1320
- updateSettings: "/ai/agent/update-settings",
1333
+ updateSettings: "/ai-agent/update-settings",
1321
1334
  /**
1322
1335
  * Mark a conversation read. POST `{ visitorId, conversationId }` → the server
1323
1336
  * records `lastReadAt = now` for that (visitor, conversation) and recomputes
1324
1337
  * `unreadCount` (returned on `/list-conversations`) accordingly. Fire-and-forget
1325
1338
  * on the client; a failure just leaves the badge until the next sync.
1326
1339
  */
1327
- markRead: "/ai/agent/mark-read",
1328
- // ── CMS content (unified Home / Help / News / docs) ─────────────
1329
- /** All widget content via one filtered endpoint. `GET /ai/agent/content?tags=…`. */
1330
- content: "/ai/agent/content",
1340
+ markRead: "/ai-agent/mark-read",
1341
+ // ── Data API (module-help-ai-data-api, via `dataBaseUrl`) ─────────
1342
+ /** All widget content via one filtered endpoint. `GET /content?tags=…`. */
1343
+ content: "/content",
1344
+ /**
1345
+ * Resolved form definitions for the deployment. `GET /forms` →
1346
+ * `{ forms: FormDef[] }`. Fetched once at boot (in parallel with
1347
+ * handshake); form gating waits for it. A failure is treated as
1348
+ * "no forms" (non-fatal).
1349
+ */
1350
+ forms: "/forms",
1331
1351
  /**
1332
1352
  * Form submission (the event-driven forms engine). POST `{ visitorId,
1333
1353
  * conversationId, formId, trigger, values, skipped? }` → record the form's answers
1334
1354
  * immediately, keyed by the same `visitorId` + `conversationId` the chat uses.
1335
1355
  * Overridable via `endpoints.submitForm`. Fire-and-forget; 404 is ignored.
1336
1356
  */
1337
- submitForm: "/ai/agent/submit-form"
1357
+ submitForm: "/submit-form",
1358
+ /**
1359
+ * Visitor interaction records for a conversation. `GET
1360
+ * /activity?visitorId=…&conversationId=…` →
1361
+ * `{ formSubmissions: FormSubmissionRecord[] }` (chronological). Replaces
1362
+ * the former `formSubmissions` echo on the handshake / list-messages;
1363
+ * deliberately generic — future visitor interaction records ride the same
1364
+ * endpoint. A failure (e.g. 404 `AI_AGENT_PUBLIC_ACCESS_NOT_FOUND` when the
1365
+ * deployment doesn't resolve) is treated as "no records" (non-fatal).
1366
+ */
1367
+ activity: "/activity"
1338
1368
  };
1339
1369
  var CONTEXT_PARAM = "context";
1340
1370
  function buildSendMessageRequest(params) {
@@ -1435,7 +1465,7 @@ function sleep(ms, signal7) {
1435
1465
  var AgentTransport = class {
1436
1466
  constructor(opts) {
1437
1467
  __publicField(this, "opts", opts);
1438
- // Identity adopted from the start-conversation response; rides the request envelope
1468
+ // Identity adopted from the handshake response; rides the request envelope
1439
1469
  // (see `envelope()`) on every subsequent call so the backend can validate the
1440
1470
  // (visitor, conversation) pair.
1441
1471
  __publicField(this, "visitorId");
@@ -1458,9 +1488,9 @@ var AgentTransport = class {
1458
1488
  }
1459
1489
  /**
1460
1490
  * Seed the visitor + conversation identity from persistence BEFORE the first
1461
- * `startConversation` resolves. A mount-time `resumeStream()` runs in parallel
1462
- * with `startConversation`, so without this its envelope would carry no
1463
- * `conversationId` and the resume GET couldn't identify the thread. `startConversation`
1491
+ * `handshake` resolves. A mount-time `resumeStream()` runs in parallel
1492
+ * with `handshake`, so without this its envelope would carry no
1493
+ * `conversationId` and the resume GET couldn't identify the thread. `handshake`
1464
1494
  * later overwrites these with the server's authoritative ids (rebind-safe).
1465
1495
  */
1466
1496
  primeIdentity(visitorId, conversationId) {
@@ -1477,7 +1507,7 @@ var AgentTransport = class {
1477
1507
  return out;
1478
1508
  }
1479
1509
  /** Merge the envelope into a JSON body (POSTs). Explicit body fields win — e.g.
1480
- * start-conversation's client-proposed ids, or a mark-read target conversation that
1510
+ * handshake's client-proposed ids, or a mark-read target conversation that
1481
1511
  * differs from the active one. */
1482
1512
  withEnvelope(body) {
1483
1513
  return { ...this.envelope(), ...body };
@@ -1498,28 +1528,22 @@ var AgentTransport = class {
1498
1528
  return url.toString();
1499
1529
  }
1500
1530
  /** One-shot runtime bootstrap. Called once after the widget mounts. */
1501
- async startConversation(body) {
1502
- log4.debug("startConversation \u2192", {
1531
+ async handshake(body) {
1532
+ log4.debug("handshake \u2192", {
1503
1533
  visitorId: body.visitorId,
1504
1534
  conversationId: body.conversationId,
1505
1535
  locale: body.locale
1506
1536
  });
1507
- const res = await this.postJson(
1508
- DEFAULT_PATHS.startConversation,
1509
- body,
1510
- "startConversation"
1511
- );
1512
- log4.debug("startConversation \u2190", {
1537
+ const res = await this.postJson(DEFAULT_PATHS.handshake, body, "handshake");
1538
+ log4.debug("handshake \u2190", {
1513
1539
  visitorId: res.visitorId,
1514
1540
  conversationId: res.conversationId,
1515
- canContinue: res.canContinue,
1516
- hasMessages: !!res.messages?.length,
1517
1541
  hasWelcome: !!res.welcome,
1518
1542
  hasConfig: !!res.config,
1519
1543
  rebind: res.rebind
1520
1544
  });
1521
- if (!isStartConversationResponseShape(res)) {
1522
- log4.warn("startConversation response did not match expected shape; using raw response", { response: res });
1545
+ if (!isHandshakeResponseShape(res)) {
1546
+ log4.warn("handshake response did not match expected shape; using raw response", { response: res });
1523
1547
  }
1524
1548
  this.visitorId = res.visitorId ?? body.visitorId;
1525
1549
  this.conversationId = res.conversationId;
@@ -1607,14 +1631,17 @@ var AgentTransport = class {
1607
1631
  log4.debug("markRead failed (non-fatal)", { err });
1608
1632
  }
1609
1633
  }
1610
- // ---- CMS content (unified) --------------------------------------------
1634
+ // ---- Data API (content / forms — module-help-ai-data-api) --------------
1611
1635
  //
1612
- // One endpoint for every content surface (Home / Help / News / docs).
1613
- // Callers pass filters; a thrown StreamError (e.g. 404 on an older backend)
1614
- // is treated as "no content" by the module and the tab degrades gracefully.
1615
- /** Fetch content rows by filter. `GET /ai/agent/content`. */
1636
+ // The DATA surfaces live on `dataBaseUrl` (default `${baseUrl}/api/data`)
1637
+ // with ROOT-level paths same auth headers + envelope, same retry
1638
+ // behavior. One endpoint for every content surface (Home / Help / News /
1639
+ // docs); callers pass filters; a thrown StreamError (e.g. 404 on an older
1640
+ // backend) is treated as "no content" by the module and the tab degrades
1641
+ // gracefully.
1642
+ /** Fetch content rows by filter. `GET /content` (data-API). */
1616
1643
  async listContent(query = {}) {
1617
- const url = new URL(this.url(DEFAULT_PATHS.content));
1644
+ const url = new URL(this.dataUrl(DEFAULT_PATHS.content));
1618
1645
  for (const [key, value] of Object.entries(query)) {
1619
1646
  if (value === void 0) continue;
1620
1647
  url.searchParams.set(key, Array.isArray(value) ? value.join(",") : String(value));
@@ -1625,6 +1652,40 @@ var AgentTransport = class {
1625
1652
  log4.debug("listContent \u2190", { count: items.length, total: res.pagination?.total });
1626
1653
  return { ...res, items };
1627
1654
  }
1655
+ /**
1656
+ * Fetch the deployment's resolved form definitions. `GET /forms`
1657
+ * (data-API). Replaces the former `config.forms` push on the handshake —
1658
+ * called once at boot, in parallel with `handshake`; form gating
1659
+ * (pre-chat etc.) waits for it. Callers treat a thrown StreamError as
1660
+ * "no forms" (non-fatal).
1661
+ */
1662
+ async listForms() {
1663
+ log4.debug("listForms \u2192");
1664
+ const res = await this.getJson(this.dataUrl(DEFAULT_PATHS.forms), "listForms");
1665
+ const forms = res.forms ?? [];
1666
+ log4.debug("listForms \u2190", { count: forms.length });
1667
+ return { forms };
1668
+ }
1669
+ /**
1670
+ * Fetch the visitor's recorded activity for a conversation — currently the
1671
+ * form submissions that rebuild the collapsed "form submitted / skipped"
1672
+ * timeline markers on resume. `GET /activity` (data-API); the
1673
+ * envelope carries `visitorId` / `conversationId` as query params.
1674
+ * `conversationId` here is an optional resource selector (a thread that may
1675
+ * differ from the active conversation — the history-pane switch); when
1676
+ * omitted, the envelope's active conversation applies. Callers treat a
1677
+ * thrown StreamError (e.g. 404 on a deployment that doesn't resolve) as
1678
+ * "no activity" (non-fatal).
1679
+ */
1680
+ async getActivity(conversationId) {
1681
+ const url = new URL(this.dataUrl(DEFAULT_PATHS.activity));
1682
+ if (conversationId) url.searchParams.set("conversationId", conversationId);
1683
+ log4.debug("getActivity \u2192", { conversationId: conversationId ?? this.conversationId });
1684
+ const res = await this.getJson(url.toString(), "getActivity");
1685
+ const formSubmissions = res.formSubmissions ?? [];
1686
+ log4.debug("getActivity \u2190", { count: formSubmissions.length });
1687
+ return { formSubmissions };
1688
+ }
1628
1689
  async saveUserPrefs(userPrefs) {
1629
1690
  log4.debug("saveUserPrefs \u2192", {
1630
1691
  visitorId: this.visitorId,
@@ -1636,17 +1697,18 @@ var AgentTransport = class {
1636
1697
  return res;
1637
1698
  }
1638
1699
  get uploadPath() {
1639
- return this.opts.endpoints?.upload ?? "/ai/agent/upload-file";
1700
+ return this.opts.endpoints?.upload ?? "/ai-agent/upload-file";
1640
1701
  }
1641
1702
  get transcribePath() {
1642
- return this.opts.endpoints?.transcribe ?? "/ai/agent/transcribe-audio";
1703
+ return this.opts.endpoints?.transcribe ?? "/ai-agent/transcribe-audio";
1643
1704
  }
1644
1705
  get submitFormPath() {
1645
1706
  return this.opts.endpoints?.submitForm ?? DEFAULT_PATHS.submitForm;
1646
1707
  }
1647
1708
  /**
1648
- * Record a completed form (intake, CSAT, claim, …). Fire-and-forget — the
1649
- * record is captured even if the visitor never sends a message. `conversationId`,
1709
+ * Record a completed form (intake, CSAT, claim, …). `POST
1710
+ * /submit-form` (data-API). Fire-and-forget the record is
1711
+ * captured even if the visitor never sends a message. `conversationId`,
1650
1712
  * `formId`, and `trigger` are explicit so the backend can tie + route;
1651
1713
  * `visitorId` + context ride the envelope. A failure (e.g. 404 on a backend
1652
1714
  * that doesn't implement the endpoint) is non-fatal, so callers don't await.
@@ -1654,7 +1716,7 @@ var AgentTransport = class {
1654
1716
  async submitForm(body) {
1655
1717
  log4.debug("submitForm \u2192", { formId: body.formId, trigger: body.trigger, fields: Object.keys(body.values).length });
1656
1718
  try {
1657
- await this.postJson(this.submitFormPath, body, "submitForm");
1719
+ await this.postJson(this.dataUrl(this.submitFormPath), body, "submitForm");
1658
1720
  } catch (err) {
1659
1721
  log4.debug("submitForm failed (non-fatal)", { err });
1660
1722
  }
@@ -1678,9 +1740,9 @@ var AgentTransport = class {
1678
1740
  * Re-attach to an in-flight reply on a cold page load / refresh.
1679
1741
  *
1680
1742
  * UNCONDITIONAL by design — the caller runs this in parallel with
1681
- * `startConversation`, never gated on whether there are persisted messages. If
1743
+ * `handshake`, never gated on whether there are persisted messages. If
1682
1744
  * the user refreshed while the assistant was still streaming, the
1683
- * `startConversation` reply carries no messages (the turn isn't persisted until
1745
+ * `handshake` reply carries no messages (the turn isn't persisted until
1684
1746
  * it finishes) and THIS is the channel that resumes the live reply. When
1685
1747
  * nothing is in flight the server answers 204 and the handle yields nothing
1686
1748
  * (the common case — no bubble is created).
@@ -1707,7 +1769,7 @@ var AgentTransport = class {
1707
1769
  * - after a POST /stream-message drops mid-reply (`immediate=false` — back
1708
1770
  * off first, the reply may still be spinning up server-side);
1709
1771
  * - on a cold page load / refresh (`immediate=true` — hit it at once, in
1710
- * parallel with start-conversation, so a reply that was streaming when the
1772
+ * parallel with handshake, so a reply that was streaming when the
1711
1773
  * user reloaded keeps flowing).
1712
1774
  *
1713
1775
  * The endpoint ALWAYS opens a 200 SSE stream (mirrors the AI SDK
@@ -1716,7 +1778,7 @@ var AgentTransport = class {
1716
1778
  * nothing ever started) — closes an EMPTY stream. So "nothing to resume" is
1717
1779
  * signalled by a segment that ends without a terminal chunk AND surfaced no
1718
1780
  * NEW events — not by a status code. We stop gracefully there; the completed
1719
- * turn comes from the DB via start-conversation / list-messages.
1781
+ * turn comes from the DB via the handshake / list-messages.
1720
1782
  *
1721
1783
  * Returns (no throw) on a terminal chunk, an empty/no-new-events segment, or
1722
1784
  * a 204 / non-OK (a backend that signals "nothing" by status, or lacks the
@@ -1781,7 +1843,7 @@ var AgentTransport = class {
1781
1843
  }
1782
1844
  return false;
1783
1845
  }
1784
- /** Abort + fire-and-forget POST to `/ai/agent/cancel-stream`. Idempotent. */
1846
+ /** Abort + fire-and-forget POST to `/ai-agent/cancel-stream`. Idempotent. */
1785
1847
  cancelStream(ctrl, conversationId) {
1786
1848
  if (ctrl.signal.aborted) return;
1787
1849
  log4.debug("cancel stream", { conversationId, visitorId: this.visitorId });
@@ -1822,7 +1884,7 @@ var AgentTransport = class {
1822
1884
  return url.toString();
1823
1885
  }
1824
1886
  // JSON requests are idempotent (reads, or full-snapshot writes like
1825
- // update-settings / mark-read / start-conversation), so they auto-retry transient
1887
+ // update-settings / mark-read / handshake), so they auto-retry transient
1826
1888
  // failures. The message POST is NOT in here — re-sending could duplicate the
1827
1889
  // reply, so it surfaces an error for the user to retry (see `openMessageStream`).
1828
1890
  async postJson(path, body, label) {
@@ -1890,9 +1952,23 @@ var AgentTransport = class {
1890
1952
  if (!res.ok) throw new StreamError(`${label} failed: ${res.status}`, "server", res.status);
1891
1953
  return await res.json();
1892
1954
  }
1955
+ /**
1956
+ * Agent-API URL — resolves a `/ai-agent/*` path against the resolved
1957
+ * agent module base. Absolute URLs pass through unchanged.
1958
+ */
1893
1959
  url(pathOrUrl) {
1894
1960
  if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
1895
- return `${this.opts.baseUrl}${pathOrUrl}`;
1961
+ return `${this.opts.agentBaseUrl}${pathOrUrl}`;
1962
+ }
1963
+ /**
1964
+ * Data-API variant of {@link url} — resolves a root-level path (content /
1965
+ * forms / submit-form / activity) against the resolved data module base.
1966
+ * Absolute URLs (e.g. an `endpoints.submitForm` pointing straight at a
1967
+ * CRM) pass through unchanged.
1968
+ */
1969
+ dataUrl(pathOrUrl) {
1970
+ if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
1971
+ return `${this.opts.dataBaseUrl}${pathOrUrl}`;
1896
1972
  }
1897
1973
  get fetchImpl() {
1898
1974
  return this.opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
@@ -1906,10 +1982,10 @@ function extensionFor(mimeType) {
1906
1982
  if (mimeType.includes("wav")) return "wav";
1907
1983
  return "bin";
1908
1984
  }
1909
- function isStartConversationResponseShape(raw) {
1985
+ function isHandshakeResponseShape(raw) {
1910
1986
  if (!raw || typeof raw !== "object") return false;
1911
1987
  const r = raw;
1912
- return typeof r.visitorId === "string" && typeof r.conversationId === "string" && typeof r.canContinue === "boolean";
1988
+ return typeof r.visitorId === "string" && typeof r.conversationId === "string";
1913
1989
  }
1914
1990
 
1915
1991
  // src/stream/messages.ts
@@ -6417,11 +6493,21 @@ function App({ options, hostElement, bus }) {
6417
6493
  const chatTabIdRef = useRef9(void 0);
6418
6494
  chatTabIdRef.current = chatTabId();
6419
6495
  const [conversationReady, setConversationReady] = useState13(false);
6496
+ const [remoteForms, setRemoteForms] = useState13(null);
6497
+ const [formsReady, setFormsReady] = useState13(false);
6420
6498
  const isInlineLike = options.mode === "standalone" || options.mode === "inline";
6421
6499
  const initialPanelRef = useRef9(
6422
6500
  resolveInitialPanelState(options, currentViewportWidth(), persistence.loadPanelOpen(), persistence.loadPanelSize())
6423
6501
  );
6424
6502
  const [isOpen, setIsOpen] = useState13(initialPanelRef.current.panelOpen);
6503
+ const [activated, setActivated] = useState13(initialPanelRef.current.panelOpen);
6504
+ const activatedRef = useRef9(activated);
6505
+ activatedRef.current = activated;
6506
+ const pendingThreadRef = useRef9(false);
6507
+ const welcomeRef = useRef9(void 0);
6508
+ useEffect17(() => {
6509
+ if (isOpen) setActivated(true);
6510
+ }, [isOpen]);
6425
6511
  const initialPanelApplied = useRef9(false);
6426
6512
  const [launcherLeaving, setLauncherLeaving] = useState13(false);
6427
6513
  const { dismissed: calloutDismissed, dismissCallout: dismissCalloutRaw } = useLauncherCallout({
@@ -6448,7 +6534,8 @@ function App({ options, hostElement, bus }) {
6448
6534
  const [formContext, setFormContext] = useState13({});
6449
6535
  const [transport] = useState13(
6450
6536
  () => new AgentTransport({
6451
- baseUrl: options.baseUrl,
6537
+ agentBaseUrl: options.agentBaseUrl,
6538
+ dataBaseUrl: options.dataBaseUrl,
6452
6539
  auth: createAuth({
6453
6540
  publicKey: options.publicKey,
6454
6541
  aiAgentDeploymentId: options.aiAgentDeploymentId,
@@ -6529,8 +6616,67 @@ function App({ options, hostElement, bus }) {
6529
6616
  },
6530
6617
  [messagesSig]
6531
6618
  );
6619
+ const fetchFormMarkers = useCallback6(
6620
+ async (conversationId) => {
6621
+ try {
6622
+ const { formSubmissions: subs } = await transport.getActivity(conversationId);
6623
+ const lastIndexByForm = /* @__PURE__ */ new Map();
6624
+ subs.forEach((rec, i) => lastIndexByForm.set(rec.formId, i));
6625
+ return subs.filter((rec, i) => !rec.skipped || lastIndexByForm.get(rec.formId) === i).map((rec, i) => ({
6626
+ key: `wire:${rec.formId}:${rec.createdAt ?? i}`,
6627
+ formId: rec.formId,
6628
+ outcome: rec.skipped ? "skipped" : "submitted",
6629
+ createdAt: parseWireDate(rec.createdAt),
6630
+ values: rec.values
6631
+ }));
6632
+ } catch (err) {
6633
+ log16.debug("getActivity failed \u2014 no form markers (non-fatal)", { err });
6634
+ return [];
6635
+ }
6636
+ },
6637
+ [transport]
6638
+ );
6532
6639
  const connectGenRef = useRef9(0);
6533
- const runStartConversation = useCallback6(
6640
+ const loadThread = useCallback6(
6641
+ async (conversationId) => {
6642
+ const myGen = connectGenRef.current;
6643
+ const isStale = () => myGen !== connectGenRef.current;
6644
+ setLoadingMessages(true);
6645
+ try {
6646
+ const [chat, markers] = await Promise.all([transport.loadConversation(conversationId), fetchFormMarkers()]);
6647
+ if (isStale()) return;
6648
+ const loaded = (chat.messages ?? []).map(fromWireMessage);
6649
+ setFormMarkers(markers);
6650
+ const timelineStamps = [...loaded.map((m) => m.createdAt), ...markers.map((m) => m.createdAt)].filter(
6651
+ (n) => n !== void 0
6652
+ );
6653
+ const welcomeAnchor = (timelineStamps.length ? Math.min(...timelineStamps) : Date.now()) - 1;
6654
+ const loadedIds = new Set(loaded.map((m) => m.id));
6655
+ const welcomeRows = (welcomeRef.current?.messages ?? []).filter((w) => !loadedIds.has(w.id)).map((w) => {
6656
+ const row = makeInstantWelcomeMessage(w);
6657
+ row.createdAt = welcomeAnchor;
6658
+ return row;
6659
+ });
6660
+ const tail = resumeBubbleRef.current ? [resumeBubbleRef.current] : [];
6661
+ if (loaded.length || tail.length) {
6662
+ messagesSig.value = [...welcomeRows, ...loaded, ...tail];
6663
+ setCanSend(chat.canContinue ?? true);
6664
+ setSuggestions(chat.suggestions ?? []);
6665
+ } else {
6666
+ messagesSig.value = welcomeRows;
6667
+ }
6668
+ } catch (err) {
6669
+ if (isStale()) return;
6670
+ log16.warn("loadThread failed; resetting conversationId", { err, conversationId });
6671
+ conversationIdSig.value = void 0;
6672
+ persistence.saveConversationId(void 0);
6673
+ } finally {
6674
+ if (!isStale()) setLoadingMessages(false);
6675
+ }
6676
+ },
6677
+ [transport, fetchFormMarkers, messagesSig, conversationIdSig, persistence]
6678
+ );
6679
+ const runHandshake = useCallback6(
6534
6680
  async ({ newConversation }) => {
6535
6681
  const myGen = ++connectGenRef.current;
6536
6682
  const isStale = () => myGen !== connectGenRef.current;
@@ -6542,7 +6688,7 @@ function App({ options, hostElement, bus }) {
6542
6688
  }
6543
6689
  let res;
6544
6690
  try {
6545
- res = await transport.startConversation({
6691
+ res = await transport.handshake({
6546
6692
  visitorId,
6547
6693
  conversationId,
6548
6694
  userPrefs: persistence.loadUserPrefs(),
@@ -6593,66 +6739,22 @@ function App({ options, hostElement, bus }) {
6593
6739
  if (res.userPrefs.themeMode) setActiveThemeMode(res.userPrefs.themeMode);
6594
6740
  if (res.userPrefs.textSize) setActiveTextSize(res.userPrefs.textSize);
6595
6741
  }
6742
+ welcomeRef.current = res.welcome;
6596
6743
  const isResume = !newConversation && persistedChatId && res.conversationId === persistedChatId;
6597
6744
  if (isResume) {
6598
- setLoadingMessages(true);
6599
- try {
6600
- const chat = res.messages?.length ? {
6601
- conversationId: persistedChatId,
6602
- canContinue: res.canContinue ?? true,
6603
- messages: res.messages,
6604
- formSubmissions: res.formSubmissions
6605
- } : await transport.loadConversation(persistedChatId);
6606
- if (isStale()) return;
6607
- const loaded = (chat.messages ?? []).map(fromWireMessage);
6608
- const subs = chat.formSubmissions ?? res.formSubmissions ?? [];
6609
- const lastIndexByForm = /* @__PURE__ */ new Map();
6610
- subs.forEach((rec, i) => lastIndexByForm.set(rec.formId, i));
6611
- const markers = subs.filter((rec, i) => !rec.skipped || lastIndexByForm.get(rec.formId) === i).map((rec, i) => ({
6612
- key: `wire:${rec.formId}:${rec.createdAt ?? i}`,
6613
- formId: rec.formId,
6614
- outcome: rec.skipped ? "skipped" : "submitted",
6615
- createdAt: parseWireDate(rec.createdAt),
6616
- values: rec.values
6617
- }));
6618
- setFormMarkers(markers);
6619
- const timelineStamps = [...loaded.map((m) => m.createdAt), ...markers.map((m) => m.createdAt)].filter(
6620
- (n) => n !== void 0
6621
- );
6622
- const welcomeAnchor = (timelineStamps.length ? Math.min(...timelineStamps) : Date.now()) - 1;
6623
- const loadedIds = new Set(loaded.map((m) => m.id));
6624
- const welcomeRows = (res.welcome?.messages ?? []).filter((w) => !loadedIds.has(w.id)).map((w) => {
6625
- const row = makeInstantWelcomeMessage(w);
6626
- row.createdAt = welcomeAnchor;
6627
- return row;
6628
- });
6629
- const tail = resumeBubbleRef.current ? [resumeBubbleRef.current] : [];
6630
- if (loaded.length || tail.length) {
6631
- messagesSig.value = [...welcomeRows, ...loaded, ...tail];
6632
- setCanSend(chat.canContinue ?? true);
6633
- setSuggestions(chat.suggestions ?? []);
6634
- } else {
6635
- messagesSig.value = welcomeRows;
6636
- }
6637
- } catch (err) {
6638
- if (isStale()) return;
6639
- log16.warn("loadConversation failed; resetting conversationId", { err, conversationId: persistedChatId });
6640
- conversationIdSig.value = void 0;
6641
- persistence.saveConversationId(void 0);
6642
- } finally {
6643
- if (!isStale()) setLoadingMessages(false);
6644
- }
6745
+ if (activatedRef.current) void loadThread(persistedChatId);
6746
+ else pendingThreadRef.current = true;
6645
6747
  } else {
6646
6748
  conversationIdSig.value = res.conversationId;
6647
6749
  persistence.saveConversationId(res.conversationId);
6648
6750
  playWelcome(res.welcome?.messages);
6649
6751
  }
6650
6752
  if (!newConversation) {
6651
- bus.emit("conversationStarted", res);
6753
+ bus.emit("handshake", res);
6652
6754
  setConversationReady(true);
6653
6755
  }
6654
6756
  },
6655
- [transport, visitorId, persistence, options, bus, messagesSig, conversationIdSig, feedback, playWelcome]
6757
+ [transport, visitorId, persistence, options, bus, conversationIdSig, feedback, playWelcome, loadThread]
6656
6758
  );
6657
6759
  const userSig = JSON.stringify(options.userContext ?? null);
6658
6760
  const pageSig = JSON.stringify(options.pageContext ?? null);
@@ -6705,26 +6807,45 @@ function App({ options, hostElement, bus }) {
6705
6807
  },
6706
6808
  [reducer, messagesSig, feedback, bus, options, homeNav]
6707
6809
  );
6810
+ const resumeHandleRef = useRef9(null);
6708
6811
  useEffect17(() => {
6709
- void runStartConversation({ newConversation: false });
6710
6812
  const persistedConversation = conversationIdSig.value;
6711
- let resume = null;
6712
- if (persistedConversation) {
6713
- transport.primeIdentity(visitorId, persistedConversation);
6714
- resume = transport.resumeStream();
6715
- void runResume(resume);
6716
- }
6813
+ if (persistedConversation) transport.primeIdentity(visitorId, persistedConversation);
6814
+ void runHandshake({ newConversation: false });
6717
6815
  return () => {
6718
6816
  connectGenRef.current++;
6719
- resume?.cancel();
6817
+ resumeHandleRef.current?.cancel();
6720
6818
  };
6721
6819
  }, []);
6820
+ useEffect17(() => {
6821
+ if (!activated) return;
6822
+ void (async () => {
6823
+ try {
6824
+ const res = await transport.listForms();
6825
+ setRemoteForms(resolveForms(res.forms));
6826
+ } catch (err) {
6827
+ log16.debug("listForms failed \u2014 treating as no forms", { err });
6828
+ } finally {
6829
+ setFormsReady(true);
6830
+ }
6831
+ })();
6832
+ if (pendingThreadRef.current) {
6833
+ pendingThreadRef.current = false;
6834
+ const persisted = conversationIdSig.value;
6835
+ if (persisted) void loadThread(persisted);
6836
+ }
6837
+ if (conversationIdSig.value) {
6838
+ const handle = transport.resumeStream();
6839
+ resumeHandleRef.current = handle;
6840
+ void runResume(handle);
6841
+ }
6842
+ }, [activated]);
6722
6843
  const lastUserSig = useRef9(userSig);
6723
6844
  useEffect17(() => {
6724
6845
  if (!conversationReady || lastUserSig.current === userSig) return;
6725
6846
  lastUserSig.current = userSig;
6726
- void runStartConversation({ newConversation: false });
6727
- }, [userSig, conversationReady, runStartConversation]);
6847
+ void runHandshake({ newConversation: false });
6848
+ }, [userSig, conversationReady, runHandshake]);
6728
6849
  useEffect17(() => {
6729
6850
  applyMode(hostElement, computeHostMode(options.mode, panelSize, isOpen));
6730
6851
  }, [hostElement, isOpen, panelSize, options.mode]);
@@ -6843,8 +6964,9 @@ function App({ options, hostElement, bus }) {
6843
6964
  [options.features.humanInLoop, handleToolResult, handleToolDecision]
6844
6965
  );
6845
6966
  const userMessageCount = () => messagesSig.value.reduce((n, m) => n + (m.role === "user" ? 1 : 0), 0);
6967
+ const effectiveForms = options.forms.list.length > 0 ? options.forms : remoteForms ?? options.forms;
6846
6968
  const forms = useForms({
6847
- forms: options.forms,
6969
+ forms: effectiveForms,
6848
6970
  persistence,
6849
6971
  userContext: () => options.userContext,
6850
6972
  messageCount: userMessageCount,
@@ -6877,18 +6999,18 @@ function App({ options, hostElement, bus }) {
6877
6999
  const pageArea = options.pageContext?.area ? String(options.pageContext.area) : void 0;
6878
7000
  const msgCount = useComputed7(() => messagesSig.value.length);
6879
7001
  useEffect17(() => {
6880
- if (conversationReady) forms.fire("pre-chat");
6881
- }, [conversationReady, forms]);
7002
+ if (conversationReady && formsReady) forms.fire("pre-chat");
7003
+ }, [conversationReady, formsReady, forms]);
6882
7004
  useEffect17(() => {
6883
- if (!canSend) forms.fire("conversation-closed");
6884
- }, [canSend, forms]);
7005
+ if (formsReady && !canSend) forms.fire("conversation-closed");
7006
+ }, [formsReady, canSend, forms]);
6885
7007
  useEffect17(() => {
6886
- if (conversationReady && pageArea) forms.fire("page-area");
6887
- }, [conversationReady, pageArea, forms]);
7008
+ if (conversationReady && formsReady && pageArea) forms.fire("page-area");
7009
+ }, [conversationReady, formsReady, pageArea, forms]);
6888
7010
  const idleMs = useMemo3(() => {
6889
- const secs = options.forms.list.flatMap((f) => f.triggers).filter((t) => t.kind === "idle").map((t) => Number(t.param));
7011
+ const secs = effectiveForms.list.flatMap((f) => f.triggers).filter((t) => t.kind === "idle").map((t) => Number(t.param));
6890
7012
  return secs.length ? Math.min(...secs) * 1e3 : 0;
6891
- }, [options.forms]);
7013
+ }, [effectiveForms]);
6892
7014
  useEffect17(() => {
6893
7015
  if (!idleMs || !isOpen) return;
6894
7016
  const id = setTimeout(() => forms.fire("idle"), idleMs);
@@ -6950,10 +7072,10 @@ function App({ options, hostElement, bus }) {
6950
7072
  }, [transport, options.modules, unreadCountSig]);
6951
7073
  const unreadSeeded = useRef9(false);
6952
7074
  useEffect17(() => {
6953
- if (!conversationReady || unreadSeeded.current) return;
7075
+ if (!activated || !conversationReady || unreadSeeded.current) return;
6954
7076
  unreadSeeded.current = true;
6955
7077
  refreshUnread();
6956
- }, [conversationReady, refreshUnread]);
7078
+ }, [activated, conversationReady, refreshUnread]);
6957
7079
  const handleOpen = useCallback6(() => {
6958
7080
  log16.info("open", { mode: options.mode });
6959
7081
  feedback.unlock();
@@ -6975,8 +7097,8 @@ function App({ options, hostElement, bus }) {
6975
7097
  persistence.clearConversation();
6976
7098
  setCanSend(true);
6977
7099
  setFormMarkers([]);
6978
- void runStartConversation({ newConversation: true });
6979
- }, [streaming, activeCancel, messagesSig, conversationIdSig, persistence, runStartConversation, bus]);
7100
+ void runHandshake({ newConversation: true });
7101
+ }, [streaming, activeCancel, messagesSig, conversationIdSig, persistence, runHandshake, bus]);
6980
7102
  const handleNewChat = useCallback6(() => {
6981
7103
  handleClear();
6982
7104
  setView("chat");
@@ -7065,9 +7187,13 @@ function App({ options, hostElement, bus }) {
7065
7187
  log16.info("selectConversation", { conversationId: targetConversationId });
7066
7188
  bus.emit("selectConversation", { conversationId: targetConversationId });
7067
7189
  try {
7068
- const res = await transport.loadConversation(targetConversationId);
7190
+ const [res, markers] = await Promise.all([
7191
+ transport.loadConversation(targetConversationId),
7192
+ fetchFormMarkers(targetConversationId)
7193
+ ]);
7069
7194
  const hydrated = res.messages.map(fromWireMessage);
7070
7195
  messagesSig.value = hydrated;
7196
+ setFormMarkers(markers);
7071
7197
  conversationIdSig.value = res.conversationId;
7072
7198
  persistence.saveConversationId(res.conversationId);
7073
7199
  if (res.agent) setAgent(res.agent);
@@ -7081,7 +7207,7 @@ function App({ options, hostElement, bus }) {
7081
7207
  options.onError?.(err);
7082
7208
  }
7083
7209
  },
7084
- [transport, messagesSig, conversationIdSig, bus, options, persistence, refreshUnread]
7210
+ [transport, messagesSig, conversationIdSig, bus, options, persistence, refreshUnread, fetchFormMarkers]
7085
7211
  );
7086
7212
  useEffect17(() => {
7087
7213
  const unsub = bindHostCommands(hostElement, {
@@ -7100,10 +7226,10 @@ function App({ options, hostElement, bus }) {
7100
7226
  );
7101
7227
  const enrichedFormMarkers = useMemo3(
7102
7228
  () => formMarkers.map((m) => {
7103
- const def = options.forms.list.find((f) => f.id === m.formId);
7229
+ const def = effectiveForms.list.find((f) => f.id === m.formId);
7104
7230
  return { ...m, title: def?.title, form: def };
7105
7231
  }),
7106
- [formMarkers, options.forms]
7232
+ [formMarkers, effectiveForms]
7107
7233
  );
7108
7234
  const panelProps = {
7109
7235
  options: effectiveOptions,
@@ -7330,7 +7456,7 @@ var ChatElement = class extends HTMLElement {
7330
7456
  /**
7331
7457
  * Raw options accumulator. Starts as whatever the page-owner set via
7332
7458
  * HTML attributes; gets server-side settings merged in on the
7333
- * `conversationStarted` event so subsequent re-renders pick up the server's
7459
+ * `handshake` event so subsequent re-renders pick up the server's
7334
7460
  * mode / theme / launcher / etc.
7335
7461
  *
7336
7462
  * Mirrors `index.ts`'s `rawOptions` so both entry points share the
@@ -7360,7 +7486,7 @@ var ChatElement = class extends HTMLElement {
7360
7486
  existingHost: this,
7361
7487
  position: hostPositionForMode(resolved.mode)
7362
7488
  });
7363
- this.bus.on("conversationStarted", (response) => {
7489
+ this.bus.on("handshake", (response) => {
7364
7490
  if (!response.config) return;
7365
7491
  this.rawOptions = mergeServerConfig(this.rawOptions, response.config);
7366
7492
  this.renderApp();
@@ -7370,7 +7496,7 @@ var ChatElement = class extends HTMLElement {
7370
7496
  }
7371
7497
  /**
7372
7498
  * Apply the current `rawOptions` to the host element + App tree.
7373
- * Called by both the initial boot and every `conversationStarted` merge.
7499
+ * Called by both the initial boot and every `handshake` merge.
7374
7500
  */
7375
7501
  renderApp() {
7376
7502
  if (!this.mountResult) return;