@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/index.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",
@@ -472,13 +477,12 @@ var DEFAULT_FEATURES = {
472
477
  var DEFAULT_FORMS = { list: [], byTrigger: {} };
473
478
  var DEFAULT_TRACKING = {
474
479
  enabled: false,
475
- endpoint: `https://t.${BRAND.domain}/api/ai-elements/px.gif`,
476
480
  sampleRate: 1
477
481
  };
478
482
  var DEFAULT_ENDPOINTS = {
479
- upload: "/ai/agent/upload-file",
480
- transcribe: "/ai/agent/transcribe-audio",
481
- submitForm: "/ai/agent/submit-form"
483
+ upload: "/ai-agent/upload-file",
484
+ transcribe: "/ai-agent/transcribe-audio",
485
+ submitForm: "/submit-form"
482
486
  };
483
487
  var DEFAULT_LAUNCHER = {
484
488
  variant: "circle",
@@ -520,11 +524,16 @@ function resolveOptions(rawOpts) {
520
524
  const mode = presentation.mode ?? "floating";
521
525
  const locale = i18n.locale ?? resolveDefaultLocale(i18n.defaultLocale);
522
526
  const availableLocales = i18n.availableLocales ?? [locale];
527
+ const baseUrl = (opts.baseUrl ?? BRAND.defaultBaseUrl).replace(/\/$/, "");
528
+ const agentBaseUrl = (opts.agentBaseUrl ?? `${baseUrl}/api/agent`).replace(/\/$/, "");
529
+ const dataBaseUrl = (opts.dataBaseUrl ?? `${baseUrl}/api/data`).replace(/\/$/, "");
523
530
  return {
524
531
  widgetId: opts.widgetId ?? "default",
525
532
  publicKey: opts.publicKey,
526
533
  aiAgentDeploymentId: opts.aiAgentDeploymentId,
527
- baseUrl: (opts.baseUrl ?? BRAND.defaultBaseUrl).replace(/\/$/, ""),
534
+ baseUrl,
535
+ agentBaseUrl,
536
+ dataBaseUrl,
528
537
  userContext: sanitizeUserContext(opts.userContext),
529
538
  pageContext: sanitizePageContext(opts.pageContext),
530
539
  position: presentation.position ?? "bottom-right",
@@ -569,7 +578,7 @@ function resolveOptions(rawOpts) {
569
578
  poweredBy: resolvePoweredBy(footer.poweredBy),
570
579
  composer: resolveComposer(opts.composer),
571
580
  modules: resolveModules(opts.modules),
572
- tracking: resolveTracking(opts.tracking),
581
+ tracking: resolveTracking(opts.tracking, dataBaseUrl),
573
582
  storage: opts.storage ?? defaultStorage,
574
583
  onMessage: opts.onMessage,
575
584
  onOpen: opts.onOpen,
@@ -602,11 +611,13 @@ function resolveHaptics(overrides) {
602
611
  events: overrides?.events
603
612
  };
604
613
  }
605
- function resolveTracking(overrides) {
614
+ function resolveTracking(overrides, dataBaseUrl) {
606
615
  const sampleRate = overrides?.sampleRate ?? DEFAULT_TRACKING.sampleRate;
607
616
  return {
608
617
  enabled: overrides?.enabled ?? DEFAULT_TRACKING.enabled,
609
- endpoint: overrides?.endpoint ?? DEFAULT_TRACKING.endpoint,
618
+ // The collector rides the data module — `${dataBaseUrl}/elements/px.gif`
619
+ // (e.g. `https://help.ai/api/data/elements/px.gif`).
620
+ endpoint: overrides?.endpoint ?? `${dataBaseUrl}/elements/px.gif`,
610
621
  // Clamp instead of reject — a malformed rate must never turn a
611
622
  // disabled tracker on or crash resolve (no Zod on the IIFE path).
612
623
  sampleRate: Math.min(1, Math.max(0, Number.isFinite(sampleRate) ? sampleRate : 1)),
@@ -775,7 +786,7 @@ function mergeServerConfig(user, server) {
775
786
  out[key] = mergeLeaves(sv, uv);
776
787
  }
777
788
  if (server.modules !== void 0 && user.modules === void 0) out.modules = server.modules;
778
- if (server.forms !== void 0 && user.forms === void 0) out.forms = server.forms;
789
+ if (server.dataBaseUrl !== void 0 && user.dataBaseUrl === void 0) out.dataBaseUrl = server.dataBaseUrl;
779
790
  const userI18n = user.i18n;
780
791
  const serverI18n = server.i18n;
781
792
  if (userI18n || serverI18n) {
@@ -835,6 +846,8 @@ var EMBED_SCALAR_ATTRS = [
835
846
  ["public-key", "publicKey"],
836
847
  ["ai-agent-deployment-id", "aiAgentDeploymentId"],
837
848
  ["base-url", "baseUrl"],
849
+ ["agent-base-url", "agentBaseUrl"],
850
+ ["data-base-url", "dataBaseUrl"],
838
851
  ["theme", "theme", (v) => v]
839
852
  ];
840
853
  var PRESENTATION_ATTRS = [
@@ -909,7 +922,7 @@ var REACTIVE_ATTRS = [
909
922
  ...SPECIAL_REACTIVE_ATTRS
910
923
  ];
911
924
 
912
- // src/core/start-conversation-shape.ts
925
+ // src/core/handshake-shape.ts
913
926
  function isPlainObject(raw) {
914
927
  return !!raw && typeof raw === "object" && !Array.isArray(raw);
915
928
  }
@@ -1240,46 +1253,63 @@ function parseWireDate(value) {
1240
1253
  }
1241
1254
  var DEFAULT_PATHS = {
1242
1255
  /** Conversation bootstrap. `POST` → deployment config + per-visitor state. */
1243
- startConversation: "/ai/agent/start-conversation",
1256
+ handshake: "/ai-agent/handshake",
1244
1257
  /** Send a message + stream the reply (SSE). `POST`. */
1245
- streamMessage: "/ai/agent/stream-message",
1258
+ streamMessage: "/ai-agent/stream-message",
1246
1259
  /**
1247
1260
  * Resume an interrupted reply. `GET` (envelope rides as query params).
1248
1261
  * Stateless: the server re-streams the whole reply SSE again from the start —
1249
1262
  * there is NO `Last-Event-ID`. The client reconciles by id (`seenIds`), so
1250
1263
  * replayed events are skipped and only the continuation surfaces.
1251
1264
  */
1252
- streamResume: "/ai/agent/stream-resume",
1265
+ streamResume: "/ai-agent/stream-resume",
1253
1266
  /** Abort the in-flight reply stream. `POST`. */
1254
- cancelStream: "/ai/agent/cancel-stream",
1267
+ cancelStream: "/ai-agent/cancel-stream",
1255
1268
  /** List the visitor's conversations. `GET`. */
1256
- listConversations: "/ai/agent/list-conversations",
1257
- /** Load one conversation's messages. `GET ?conversationId=…`. */
1258
- listMessages: "/ai/agent/list-messages",
1269
+ listConversations: "/ai-agent/list-conversations",
1270
+ /** Load one conversation's thread (messages + canContinue + suggestions). `GET ?conversationId=…`. */
1271
+ listMessages: "/ai-agent/list-messages",
1259
1272
  /**
1260
1273
  * Persist a user-driven settings change. POST `{ visitorId, userPrefs }` →
1261
1274
  * server persists, returns the merged authoritative copy. Fire-and-forget on
1262
1275
  * the client — a failure leaves the local cache valid; the next
1263
- * `startConversation()` reconciles.
1276
+ * `handshake()` reconciles.
1264
1277
  */
1265
- updateSettings: "/ai/agent/update-settings",
1278
+ updateSettings: "/ai-agent/update-settings",
1266
1279
  /**
1267
1280
  * Mark a conversation read. POST `{ visitorId, conversationId }` → the server
1268
1281
  * records `lastReadAt = now` for that (visitor, conversation) and recomputes
1269
1282
  * `unreadCount` (returned on `/list-conversations`) accordingly. Fire-and-forget
1270
1283
  * on the client; a failure just leaves the badge until the next sync.
1271
1284
  */
1272
- markRead: "/ai/agent/mark-read",
1273
- // ── CMS content (unified Home / Help / News / docs) ─────────────
1274
- /** All widget content via one filtered endpoint. `GET /ai/agent/content?tags=…`. */
1275
- content: "/ai/agent/content",
1285
+ markRead: "/ai-agent/mark-read",
1286
+ // ── Data API (module-help-ai-data-api, via `dataBaseUrl`) ─────────
1287
+ /** All widget content via one filtered endpoint. `GET /content?tags=…`. */
1288
+ content: "/content",
1289
+ /**
1290
+ * Resolved form definitions for the deployment. `GET /forms` →
1291
+ * `{ forms: FormDef[] }`. Fetched once at boot (in parallel with
1292
+ * handshake); form gating waits for it. A failure is treated as
1293
+ * "no forms" (non-fatal).
1294
+ */
1295
+ forms: "/forms",
1276
1296
  /**
1277
1297
  * Form submission (the event-driven forms engine). POST `{ visitorId,
1278
1298
  * conversationId, formId, trigger, values, skipped? }` → record the form's answers
1279
1299
  * immediately, keyed by the same `visitorId` + `conversationId` the chat uses.
1280
1300
  * Overridable via `endpoints.submitForm`. Fire-and-forget; 404 is ignored.
1281
1301
  */
1282
- submitForm: "/ai/agent/submit-form"
1302
+ submitForm: "/submit-form",
1303
+ /**
1304
+ * Visitor interaction records for a conversation. `GET
1305
+ * /activity?visitorId=…&conversationId=…` →
1306
+ * `{ formSubmissions: FormSubmissionRecord[] }` (chronological). Replaces
1307
+ * the former `formSubmissions` echo on the handshake / list-messages;
1308
+ * deliberately generic — future visitor interaction records ride the same
1309
+ * endpoint. A failure (e.g. 404 `AI_AGENT_PUBLIC_ACCESS_NOT_FOUND` when the
1310
+ * deployment doesn't resolve) is treated as "no records" (non-fatal).
1311
+ */
1312
+ activity: "/activity"
1283
1313
  };
1284
1314
  var CONTEXT_PARAM = "context";
1285
1315
  function buildSendMessageRequest(params) {
@@ -1380,7 +1410,7 @@ function sleep(ms, signal7) {
1380
1410
  var AgentTransport = class {
1381
1411
  constructor(opts) {
1382
1412
  __publicField(this, "opts", opts);
1383
- // Identity adopted from the start-conversation response; rides the request envelope
1413
+ // Identity adopted from the handshake response; rides the request envelope
1384
1414
  // (see `envelope()`) on every subsequent call so the backend can validate the
1385
1415
  // (visitor, conversation) pair.
1386
1416
  __publicField(this, "visitorId");
@@ -1403,9 +1433,9 @@ var AgentTransport = class {
1403
1433
  }
1404
1434
  /**
1405
1435
  * Seed the visitor + conversation identity from persistence BEFORE the first
1406
- * `startConversation` resolves. A mount-time `resumeStream()` runs in parallel
1407
- * with `startConversation`, so without this its envelope would carry no
1408
- * `conversationId` and the resume GET couldn't identify the thread. `startConversation`
1436
+ * `handshake` resolves. A mount-time `resumeStream()` runs in parallel
1437
+ * with `handshake`, so without this its envelope would carry no
1438
+ * `conversationId` and the resume GET couldn't identify the thread. `handshake`
1409
1439
  * later overwrites these with the server's authoritative ids (rebind-safe).
1410
1440
  */
1411
1441
  primeIdentity(visitorId, conversationId) {
@@ -1422,7 +1452,7 @@ var AgentTransport = class {
1422
1452
  return out;
1423
1453
  }
1424
1454
  /** Merge the envelope into a JSON body (POSTs). Explicit body fields win — e.g.
1425
- * start-conversation's client-proposed ids, or a mark-read target conversation that
1455
+ * handshake's client-proposed ids, or a mark-read target conversation that
1426
1456
  * differs from the active one. */
1427
1457
  withEnvelope(body) {
1428
1458
  return { ...this.envelope(), ...body };
@@ -1443,28 +1473,22 @@ var AgentTransport = class {
1443
1473
  return url.toString();
1444
1474
  }
1445
1475
  /** One-shot runtime bootstrap. Called once after the widget mounts. */
1446
- async startConversation(body) {
1447
- log4.debug("startConversation \u2192", {
1476
+ async handshake(body) {
1477
+ log4.debug("handshake \u2192", {
1448
1478
  visitorId: body.visitorId,
1449
1479
  conversationId: body.conversationId,
1450
1480
  locale: body.locale
1451
1481
  });
1452
- const res = await this.postJson(
1453
- DEFAULT_PATHS.startConversation,
1454
- body,
1455
- "startConversation"
1456
- );
1457
- log4.debug("startConversation \u2190", {
1482
+ const res = await this.postJson(DEFAULT_PATHS.handshake, body, "handshake");
1483
+ log4.debug("handshake \u2190", {
1458
1484
  visitorId: res.visitorId,
1459
1485
  conversationId: res.conversationId,
1460
- canContinue: res.canContinue,
1461
- hasMessages: !!res.messages?.length,
1462
1486
  hasWelcome: !!res.welcome,
1463
1487
  hasConfig: !!res.config,
1464
1488
  rebind: res.rebind
1465
1489
  });
1466
- if (!isStartConversationResponseShape(res)) {
1467
- log4.warn("startConversation response did not match expected shape; using raw response", { response: res });
1490
+ if (!isHandshakeResponseShape(res)) {
1491
+ log4.warn("handshake response did not match expected shape; using raw response", { response: res });
1468
1492
  }
1469
1493
  this.visitorId = res.visitorId ?? body.visitorId;
1470
1494
  this.conversationId = res.conversationId;
@@ -1552,14 +1576,17 @@ var AgentTransport = class {
1552
1576
  log4.debug("markRead failed (non-fatal)", { err });
1553
1577
  }
1554
1578
  }
1555
- // ---- CMS content (unified) --------------------------------------------
1579
+ // ---- Data API (content / forms — module-help-ai-data-api) --------------
1556
1580
  //
1557
- // One endpoint for every content surface (Home / Help / News / docs).
1558
- // Callers pass filters; a thrown StreamError (e.g. 404 on an older backend)
1559
- // is treated as "no content" by the module and the tab degrades gracefully.
1560
- /** Fetch content rows by filter. `GET /ai/agent/content`. */
1581
+ // The DATA surfaces live on `dataBaseUrl` (default `${baseUrl}/api/data`)
1582
+ // with ROOT-level paths same auth headers + envelope, same retry
1583
+ // behavior. One endpoint for every content surface (Home / Help / News /
1584
+ // docs); callers pass filters; a thrown StreamError (e.g. 404 on an older
1585
+ // backend) is treated as "no content" by the module and the tab degrades
1586
+ // gracefully.
1587
+ /** Fetch content rows by filter. `GET /content` (data-API). */
1561
1588
  async listContent(query = {}) {
1562
- const url = new URL(this.url(DEFAULT_PATHS.content));
1589
+ const url = new URL(this.dataUrl(DEFAULT_PATHS.content));
1563
1590
  for (const [key, value] of Object.entries(query)) {
1564
1591
  if (value === void 0) continue;
1565
1592
  url.searchParams.set(key, Array.isArray(value) ? value.join(",") : String(value));
@@ -1570,6 +1597,40 @@ var AgentTransport = class {
1570
1597
  log4.debug("listContent \u2190", { count: items.length, total: res.pagination?.total });
1571
1598
  return { ...res, items };
1572
1599
  }
1600
+ /**
1601
+ * Fetch the deployment's resolved form definitions. `GET /forms`
1602
+ * (data-API). Replaces the former `config.forms` push on the handshake —
1603
+ * called once at boot, in parallel with `handshake`; form gating
1604
+ * (pre-chat etc.) waits for it. Callers treat a thrown StreamError as
1605
+ * "no forms" (non-fatal).
1606
+ */
1607
+ async listForms() {
1608
+ log4.debug("listForms \u2192");
1609
+ const res = await this.getJson(this.dataUrl(DEFAULT_PATHS.forms), "listForms");
1610
+ const forms = res.forms ?? [];
1611
+ log4.debug("listForms \u2190", { count: forms.length });
1612
+ return { forms };
1613
+ }
1614
+ /**
1615
+ * Fetch the visitor's recorded activity for a conversation — currently the
1616
+ * form submissions that rebuild the collapsed "form submitted / skipped"
1617
+ * timeline markers on resume. `GET /activity` (data-API); the
1618
+ * envelope carries `visitorId` / `conversationId` as query params.
1619
+ * `conversationId` here is an optional resource selector (a thread that may
1620
+ * differ from the active conversation — the history-pane switch); when
1621
+ * omitted, the envelope's active conversation applies. Callers treat a
1622
+ * thrown StreamError (e.g. 404 on a deployment that doesn't resolve) as
1623
+ * "no activity" (non-fatal).
1624
+ */
1625
+ async getActivity(conversationId) {
1626
+ const url = new URL(this.dataUrl(DEFAULT_PATHS.activity));
1627
+ if (conversationId) url.searchParams.set("conversationId", conversationId);
1628
+ log4.debug("getActivity \u2192", { conversationId: conversationId ?? this.conversationId });
1629
+ const res = await this.getJson(url.toString(), "getActivity");
1630
+ const formSubmissions = res.formSubmissions ?? [];
1631
+ log4.debug("getActivity \u2190", { count: formSubmissions.length });
1632
+ return { formSubmissions };
1633
+ }
1573
1634
  async saveUserPrefs(userPrefs) {
1574
1635
  log4.debug("saveUserPrefs \u2192", {
1575
1636
  visitorId: this.visitorId,
@@ -1581,17 +1642,18 @@ var AgentTransport = class {
1581
1642
  return res;
1582
1643
  }
1583
1644
  get uploadPath() {
1584
- return this.opts.endpoints?.upload ?? "/ai/agent/upload-file";
1645
+ return this.opts.endpoints?.upload ?? "/ai-agent/upload-file";
1585
1646
  }
1586
1647
  get transcribePath() {
1587
- return this.opts.endpoints?.transcribe ?? "/ai/agent/transcribe-audio";
1648
+ return this.opts.endpoints?.transcribe ?? "/ai-agent/transcribe-audio";
1588
1649
  }
1589
1650
  get submitFormPath() {
1590
1651
  return this.opts.endpoints?.submitForm ?? DEFAULT_PATHS.submitForm;
1591
1652
  }
1592
1653
  /**
1593
- * Record a completed form (intake, CSAT, claim, …). Fire-and-forget — the
1594
- * record is captured even if the visitor never sends a message. `conversationId`,
1654
+ * Record a completed form (intake, CSAT, claim, …). `POST
1655
+ * /submit-form` (data-API). Fire-and-forget the record is
1656
+ * captured even if the visitor never sends a message. `conversationId`,
1595
1657
  * `formId`, and `trigger` are explicit so the backend can tie + route;
1596
1658
  * `visitorId` + context ride the envelope. A failure (e.g. 404 on a backend
1597
1659
  * that doesn't implement the endpoint) is non-fatal, so callers don't await.
@@ -1599,7 +1661,7 @@ var AgentTransport = class {
1599
1661
  async submitForm(body) {
1600
1662
  log4.debug("submitForm \u2192", { formId: body.formId, trigger: body.trigger, fields: Object.keys(body.values).length });
1601
1663
  try {
1602
- await this.postJson(this.submitFormPath, body, "submitForm");
1664
+ await this.postJson(this.dataUrl(this.submitFormPath), body, "submitForm");
1603
1665
  } catch (err) {
1604
1666
  log4.debug("submitForm failed (non-fatal)", { err });
1605
1667
  }
@@ -1623,9 +1685,9 @@ var AgentTransport = class {
1623
1685
  * Re-attach to an in-flight reply on a cold page load / refresh.
1624
1686
  *
1625
1687
  * UNCONDITIONAL by design — the caller runs this in parallel with
1626
- * `startConversation`, never gated on whether there are persisted messages. If
1688
+ * `handshake`, never gated on whether there are persisted messages. If
1627
1689
  * the user refreshed while the assistant was still streaming, the
1628
- * `startConversation` reply carries no messages (the turn isn't persisted until
1690
+ * `handshake` reply carries no messages (the turn isn't persisted until
1629
1691
  * it finishes) and THIS is the channel that resumes the live reply. When
1630
1692
  * nothing is in flight the server answers 204 and the handle yields nothing
1631
1693
  * (the common case — no bubble is created).
@@ -1652,7 +1714,7 @@ var AgentTransport = class {
1652
1714
  * - after a POST /stream-message drops mid-reply (`immediate=false` — back
1653
1715
  * off first, the reply may still be spinning up server-side);
1654
1716
  * - on a cold page load / refresh (`immediate=true` — hit it at once, in
1655
- * parallel with start-conversation, so a reply that was streaming when the
1717
+ * parallel with handshake, so a reply that was streaming when the
1656
1718
  * user reloaded keeps flowing).
1657
1719
  *
1658
1720
  * The endpoint ALWAYS opens a 200 SSE stream (mirrors the AI SDK
@@ -1661,7 +1723,7 @@ var AgentTransport = class {
1661
1723
  * nothing ever started) — closes an EMPTY stream. So "nothing to resume" is
1662
1724
  * signalled by a segment that ends without a terminal chunk AND surfaced no
1663
1725
  * NEW events — not by a status code. We stop gracefully there; the completed
1664
- * turn comes from the DB via start-conversation / list-messages.
1726
+ * turn comes from the DB via the handshake / list-messages.
1665
1727
  *
1666
1728
  * Returns (no throw) on a terminal chunk, an empty/no-new-events segment, or
1667
1729
  * a 204 / non-OK (a backend that signals "nothing" by status, or lacks the
@@ -1726,7 +1788,7 @@ var AgentTransport = class {
1726
1788
  }
1727
1789
  return false;
1728
1790
  }
1729
- /** Abort + fire-and-forget POST to `/ai/agent/cancel-stream`. Idempotent. */
1791
+ /** Abort + fire-and-forget POST to `/ai-agent/cancel-stream`. Idempotent. */
1730
1792
  cancelStream(ctrl, conversationId) {
1731
1793
  if (ctrl.signal.aborted) return;
1732
1794
  log4.debug("cancel stream", { conversationId, visitorId: this.visitorId });
@@ -1767,7 +1829,7 @@ var AgentTransport = class {
1767
1829
  return url.toString();
1768
1830
  }
1769
1831
  // JSON requests are idempotent (reads, or full-snapshot writes like
1770
- // update-settings / mark-read / start-conversation), so they auto-retry transient
1832
+ // update-settings / mark-read / handshake), so they auto-retry transient
1771
1833
  // failures. The message POST is NOT in here — re-sending could duplicate the
1772
1834
  // reply, so it surfaces an error for the user to retry (see `openMessageStream`).
1773
1835
  async postJson(path, body, label) {
@@ -1835,9 +1897,23 @@ var AgentTransport = class {
1835
1897
  if (!res.ok) throw new StreamError(`${label} failed: ${res.status}`, "server", res.status);
1836
1898
  return await res.json();
1837
1899
  }
1900
+ /**
1901
+ * Agent-API URL — resolves a `/ai-agent/*` path against the resolved
1902
+ * agent module base. Absolute URLs pass through unchanged.
1903
+ */
1838
1904
  url(pathOrUrl) {
1839
1905
  if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
1840
- return `${this.opts.baseUrl}${pathOrUrl}`;
1906
+ return `${this.opts.agentBaseUrl}${pathOrUrl}`;
1907
+ }
1908
+ /**
1909
+ * Data-API variant of {@link url} — resolves a root-level path (content /
1910
+ * forms / submit-form / activity) against the resolved data module base.
1911
+ * Absolute URLs (e.g. an `endpoints.submitForm` pointing straight at a
1912
+ * CRM) pass through unchanged.
1913
+ */
1914
+ dataUrl(pathOrUrl) {
1915
+ if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
1916
+ return `${this.opts.dataBaseUrl}${pathOrUrl}`;
1841
1917
  }
1842
1918
  get fetchImpl() {
1843
1919
  return this.opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
@@ -1851,10 +1927,10 @@ function extensionFor(mimeType) {
1851
1927
  if (mimeType.includes("wav")) return "wav";
1852
1928
  return "bin";
1853
1929
  }
1854
- function isStartConversationResponseShape(raw) {
1930
+ function isHandshakeResponseShape(raw) {
1855
1931
  if (!raw || typeof raw !== "object") return false;
1856
1932
  const r = raw;
1857
- return typeof r.visitorId === "string" && typeof r.conversationId === "string" && typeof r.canContinue === "boolean";
1933
+ return typeof r.visitorId === "string" && typeof r.conversationId === "string";
1858
1934
  }
1859
1935
 
1860
1936
  // src/stream/messages.ts
@@ -6362,11 +6438,21 @@ function App({ options, hostElement, bus }) {
6362
6438
  const chatTabIdRef = useRef9(void 0);
6363
6439
  chatTabIdRef.current = chatTabId();
6364
6440
  const [conversationReady, setConversationReady] = useState13(false);
6441
+ const [remoteForms, setRemoteForms] = useState13(null);
6442
+ const [formsReady, setFormsReady] = useState13(false);
6365
6443
  const isInlineLike = options.mode === "standalone" || options.mode === "inline";
6366
6444
  const initialPanelRef = useRef9(
6367
6445
  resolveInitialPanelState(options, currentViewportWidth(), persistence.loadPanelOpen(), persistence.loadPanelSize())
6368
6446
  );
6369
6447
  const [isOpen, setIsOpen] = useState13(initialPanelRef.current.panelOpen);
6448
+ const [activated, setActivated] = useState13(initialPanelRef.current.panelOpen);
6449
+ const activatedRef = useRef9(activated);
6450
+ activatedRef.current = activated;
6451
+ const pendingThreadRef = useRef9(false);
6452
+ const welcomeRef = useRef9(void 0);
6453
+ useEffect17(() => {
6454
+ if (isOpen) setActivated(true);
6455
+ }, [isOpen]);
6370
6456
  const initialPanelApplied = useRef9(false);
6371
6457
  const [launcherLeaving, setLauncherLeaving] = useState13(false);
6372
6458
  const { dismissed: calloutDismissed, dismissCallout: dismissCalloutRaw } = useLauncherCallout({
@@ -6393,7 +6479,8 @@ function App({ options, hostElement, bus }) {
6393
6479
  const [formContext, setFormContext] = useState13({});
6394
6480
  const [transport] = useState13(
6395
6481
  () => new AgentTransport({
6396
- baseUrl: options.baseUrl,
6482
+ agentBaseUrl: options.agentBaseUrl,
6483
+ dataBaseUrl: options.dataBaseUrl,
6397
6484
  auth: createAuth({
6398
6485
  publicKey: options.publicKey,
6399
6486
  aiAgentDeploymentId: options.aiAgentDeploymentId,
@@ -6474,8 +6561,67 @@ function App({ options, hostElement, bus }) {
6474
6561
  },
6475
6562
  [messagesSig]
6476
6563
  );
6564
+ const fetchFormMarkers = useCallback6(
6565
+ async (conversationId) => {
6566
+ try {
6567
+ const { formSubmissions: subs } = await transport.getActivity(conversationId);
6568
+ const lastIndexByForm = /* @__PURE__ */ new Map();
6569
+ subs.forEach((rec, i) => lastIndexByForm.set(rec.formId, i));
6570
+ return subs.filter((rec, i) => !rec.skipped || lastIndexByForm.get(rec.formId) === i).map((rec, i) => ({
6571
+ key: `wire:${rec.formId}:${rec.createdAt ?? i}`,
6572
+ formId: rec.formId,
6573
+ outcome: rec.skipped ? "skipped" : "submitted",
6574
+ createdAt: parseWireDate(rec.createdAt),
6575
+ values: rec.values
6576
+ }));
6577
+ } catch (err) {
6578
+ log16.debug("getActivity failed \u2014 no form markers (non-fatal)", { err });
6579
+ return [];
6580
+ }
6581
+ },
6582
+ [transport]
6583
+ );
6477
6584
  const connectGenRef = useRef9(0);
6478
- const runStartConversation = useCallback6(
6585
+ const loadThread = useCallback6(
6586
+ async (conversationId) => {
6587
+ const myGen = connectGenRef.current;
6588
+ const isStale = () => myGen !== connectGenRef.current;
6589
+ setLoadingMessages(true);
6590
+ try {
6591
+ const [chat, markers] = await Promise.all([transport.loadConversation(conversationId), fetchFormMarkers()]);
6592
+ if (isStale()) return;
6593
+ const loaded = (chat.messages ?? []).map(fromWireMessage);
6594
+ setFormMarkers(markers);
6595
+ const timelineStamps = [...loaded.map((m) => m.createdAt), ...markers.map((m) => m.createdAt)].filter(
6596
+ (n) => n !== void 0
6597
+ );
6598
+ const welcomeAnchor = (timelineStamps.length ? Math.min(...timelineStamps) : Date.now()) - 1;
6599
+ const loadedIds = new Set(loaded.map((m) => m.id));
6600
+ const welcomeRows = (welcomeRef.current?.messages ?? []).filter((w) => !loadedIds.has(w.id)).map((w) => {
6601
+ const row = makeInstantWelcomeMessage(w);
6602
+ row.createdAt = welcomeAnchor;
6603
+ return row;
6604
+ });
6605
+ const tail = resumeBubbleRef.current ? [resumeBubbleRef.current] : [];
6606
+ if (loaded.length || tail.length) {
6607
+ messagesSig.value = [...welcomeRows, ...loaded, ...tail];
6608
+ setCanSend(chat.canContinue ?? true);
6609
+ setSuggestions(chat.suggestions ?? []);
6610
+ } else {
6611
+ messagesSig.value = welcomeRows;
6612
+ }
6613
+ } catch (err) {
6614
+ if (isStale()) return;
6615
+ log16.warn("loadThread failed; resetting conversationId", { err, conversationId });
6616
+ conversationIdSig.value = void 0;
6617
+ persistence.saveConversationId(void 0);
6618
+ } finally {
6619
+ if (!isStale()) setLoadingMessages(false);
6620
+ }
6621
+ },
6622
+ [transport, fetchFormMarkers, messagesSig, conversationIdSig, persistence]
6623
+ );
6624
+ const runHandshake = useCallback6(
6479
6625
  async ({ newConversation }) => {
6480
6626
  const myGen = ++connectGenRef.current;
6481
6627
  const isStale = () => myGen !== connectGenRef.current;
@@ -6487,7 +6633,7 @@ function App({ options, hostElement, bus }) {
6487
6633
  }
6488
6634
  let res;
6489
6635
  try {
6490
- res = await transport.startConversation({
6636
+ res = await transport.handshake({
6491
6637
  visitorId,
6492
6638
  conversationId,
6493
6639
  userPrefs: persistence.loadUserPrefs(),
@@ -6538,66 +6684,22 @@ function App({ options, hostElement, bus }) {
6538
6684
  if (res.userPrefs.themeMode) setActiveThemeMode(res.userPrefs.themeMode);
6539
6685
  if (res.userPrefs.textSize) setActiveTextSize(res.userPrefs.textSize);
6540
6686
  }
6687
+ welcomeRef.current = res.welcome;
6541
6688
  const isResume = !newConversation && persistedChatId && res.conversationId === persistedChatId;
6542
6689
  if (isResume) {
6543
- setLoadingMessages(true);
6544
- try {
6545
- const chat = res.messages?.length ? {
6546
- conversationId: persistedChatId,
6547
- canContinue: res.canContinue ?? true,
6548
- messages: res.messages,
6549
- formSubmissions: res.formSubmissions
6550
- } : await transport.loadConversation(persistedChatId);
6551
- if (isStale()) return;
6552
- const loaded = (chat.messages ?? []).map(fromWireMessage);
6553
- const subs = chat.formSubmissions ?? res.formSubmissions ?? [];
6554
- const lastIndexByForm = /* @__PURE__ */ new Map();
6555
- subs.forEach((rec, i) => lastIndexByForm.set(rec.formId, i));
6556
- const markers = subs.filter((rec, i) => !rec.skipped || lastIndexByForm.get(rec.formId) === i).map((rec, i) => ({
6557
- key: `wire:${rec.formId}:${rec.createdAt ?? i}`,
6558
- formId: rec.formId,
6559
- outcome: rec.skipped ? "skipped" : "submitted",
6560
- createdAt: parseWireDate(rec.createdAt),
6561
- values: rec.values
6562
- }));
6563
- setFormMarkers(markers);
6564
- const timelineStamps = [...loaded.map((m) => m.createdAt), ...markers.map((m) => m.createdAt)].filter(
6565
- (n) => n !== void 0
6566
- );
6567
- const welcomeAnchor = (timelineStamps.length ? Math.min(...timelineStamps) : Date.now()) - 1;
6568
- const loadedIds = new Set(loaded.map((m) => m.id));
6569
- const welcomeRows = (res.welcome?.messages ?? []).filter((w) => !loadedIds.has(w.id)).map((w) => {
6570
- const row = makeInstantWelcomeMessage(w);
6571
- row.createdAt = welcomeAnchor;
6572
- return row;
6573
- });
6574
- const tail = resumeBubbleRef.current ? [resumeBubbleRef.current] : [];
6575
- if (loaded.length || tail.length) {
6576
- messagesSig.value = [...welcomeRows, ...loaded, ...tail];
6577
- setCanSend(chat.canContinue ?? true);
6578
- setSuggestions(chat.suggestions ?? []);
6579
- } else {
6580
- messagesSig.value = welcomeRows;
6581
- }
6582
- } catch (err) {
6583
- if (isStale()) return;
6584
- log16.warn("loadConversation failed; resetting conversationId", { err, conversationId: persistedChatId });
6585
- conversationIdSig.value = void 0;
6586
- persistence.saveConversationId(void 0);
6587
- } finally {
6588
- if (!isStale()) setLoadingMessages(false);
6589
- }
6690
+ if (activatedRef.current) void loadThread(persistedChatId);
6691
+ else pendingThreadRef.current = true;
6590
6692
  } else {
6591
6693
  conversationIdSig.value = res.conversationId;
6592
6694
  persistence.saveConversationId(res.conversationId);
6593
6695
  playWelcome(res.welcome?.messages);
6594
6696
  }
6595
6697
  if (!newConversation) {
6596
- bus.emit("conversationStarted", res);
6698
+ bus.emit("handshake", res);
6597
6699
  setConversationReady(true);
6598
6700
  }
6599
6701
  },
6600
- [transport, visitorId, persistence, options, bus, messagesSig, conversationIdSig, feedback, playWelcome]
6702
+ [transport, visitorId, persistence, options, bus, conversationIdSig, feedback, playWelcome, loadThread]
6601
6703
  );
6602
6704
  const userSig = JSON.stringify(options.userContext ?? null);
6603
6705
  const pageSig = JSON.stringify(options.pageContext ?? null);
@@ -6650,26 +6752,45 @@ function App({ options, hostElement, bus }) {
6650
6752
  },
6651
6753
  [reducer, messagesSig, feedback, bus, options, homeNav]
6652
6754
  );
6755
+ const resumeHandleRef = useRef9(null);
6653
6756
  useEffect17(() => {
6654
- void runStartConversation({ newConversation: false });
6655
6757
  const persistedConversation = conversationIdSig.value;
6656
- let resume = null;
6657
- if (persistedConversation) {
6658
- transport.primeIdentity(visitorId, persistedConversation);
6659
- resume = transport.resumeStream();
6660
- void runResume(resume);
6661
- }
6758
+ if (persistedConversation) transport.primeIdentity(visitorId, persistedConversation);
6759
+ void runHandshake({ newConversation: false });
6662
6760
  return () => {
6663
6761
  connectGenRef.current++;
6664
- resume?.cancel();
6762
+ resumeHandleRef.current?.cancel();
6665
6763
  };
6666
6764
  }, []);
6765
+ useEffect17(() => {
6766
+ if (!activated) return;
6767
+ void (async () => {
6768
+ try {
6769
+ const res = await transport.listForms();
6770
+ setRemoteForms(resolveForms(res.forms));
6771
+ } catch (err) {
6772
+ log16.debug("listForms failed \u2014 treating as no forms", { err });
6773
+ } finally {
6774
+ setFormsReady(true);
6775
+ }
6776
+ })();
6777
+ if (pendingThreadRef.current) {
6778
+ pendingThreadRef.current = false;
6779
+ const persisted = conversationIdSig.value;
6780
+ if (persisted) void loadThread(persisted);
6781
+ }
6782
+ if (conversationIdSig.value) {
6783
+ const handle = transport.resumeStream();
6784
+ resumeHandleRef.current = handle;
6785
+ void runResume(handle);
6786
+ }
6787
+ }, [activated]);
6667
6788
  const lastUserSig = useRef9(userSig);
6668
6789
  useEffect17(() => {
6669
6790
  if (!conversationReady || lastUserSig.current === userSig) return;
6670
6791
  lastUserSig.current = userSig;
6671
- void runStartConversation({ newConversation: false });
6672
- }, [userSig, conversationReady, runStartConversation]);
6792
+ void runHandshake({ newConversation: false });
6793
+ }, [userSig, conversationReady, runHandshake]);
6673
6794
  useEffect17(() => {
6674
6795
  applyMode(hostElement, computeHostMode(options.mode, panelSize, isOpen));
6675
6796
  }, [hostElement, isOpen, panelSize, options.mode]);
@@ -6788,8 +6909,9 @@ function App({ options, hostElement, bus }) {
6788
6909
  [options.features.humanInLoop, handleToolResult, handleToolDecision]
6789
6910
  );
6790
6911
  const userMessageCount = () => messagesSig.value.reduce((n, m) => n + (m.role === "user" ? 1 : 0), 0);
6912
+ const effectiveForms = options.forms.list.length > 0 ? options.forms : remoteForms ?? options.forms;
6791
6913
  const forms = useForms({
6792
- forms: options.forms,
6914
+ forms: effectiveForms,
6793
6915
  persistence,
6794
6916
  userContext: () => options.userContext,
6795
6917
  messageCount: userMessageCount,
@@ -6822,18 +6944,18 @@ function App({ options, hostElement, bus }) {
6822
6944
  const pageArea = options.pageContext?.area ? String(options.pageContext.area) : void 0;
6823
6945
  const msgCount = useComputed7(() => messagesSig.value.length);
6824
6946
  useEffect17(() => {
6825
- if (conversationReady) forms.fire("pre-chat");
6826
- }, [conversationReady, forms]);
6947
+ if (conversationReady && formsReady) forms.fire("pre-chat");
6948
+ }, [conversationReady, formsReady, forms]);
6827
6949
  useEffect17(() => {
6828
- if (!canSend) forms.fire("conversation-closed");
6829
- }, [canSend, forms]);
6950
+ if (formsReady && !canSend) forms.fire("conversation-closed");
6951
+ }, [formsReady, canSend, forms]);
6830
6952
  useEffect17(() => {
6831
- if (conversationReady && pageArea) forms.fire("page-area");
6832
- }, [conversationReady, pageArea, forms]);
6953
+ if (conversationReady && formsReady && pageArea) forms.fire("page-area");
6954
+ }, [conversationReady, formsReady, pageArea, forms]);
6833
6955
  const idleMs = useMemo3(() => {
6834
- const secs = options.forms.list.flatMap((f) => f.triggers).filter((t) => t.kind === "idle").map((t) => Number(t.param));
6956
+ const secs = effectiveForms.list.flatMap((f) => f.triggers).filter((t) => t.kind === "idle").map((t) => Number(t.param));
6835
6957
  return secs.length ? Math.min(...secs) * 1e3 : 0;
6836
- }, [options.forms]);
6958
+ }, [effectiveForms]);
6837
6959
  useEffect17(() => {
6838
6960
  if (!idleMs || !isOpen) return;
6839
6961
  const id = setTimeout(() => forms.fire("idle"), idleMs);
@@ -6895,10 +7017,10 @@ function App({ options, hostElement, bus }) {
6895
7017
  }, [transport, options.modules, unreadCountSig]);
6896
7018
  const unreadSeeded = useRef9(false);
6897
7019
  useEffect17(() => {
6898
- if (!conversationReady || unreadSeeded.current) return;
7020
+ if (!activated || !conversationReady || unreadSeeded.current) return;
6899
7021
  unreadSeeded.current = true;
6900
7022
  refreshUnread();
6901
- }, [conversationReady, refreshUnread]);
7023
+ }, [activated, conversationReady, refreshUnread]);
6902
7024
  const handleOpen = useCallback6(() => {
6903
7025
  log16.info("open", { mode: options.mode });
6904
7026
  feedback.unlock();
@@ -6920,8 +7042,8 @@ function App({ options, hostElement, bus }) {
6920
7042
  persistence.clearConversation();
6921
7043
  setCanSend(true);
6922
7044
  setFormMarkers([]);
6923
- void runStartConversation({ newConversation: true });
6924
- }, [streaming, activeCancel, messagesSig, conversationIdSig, persistence, runStartConversation, bus]);
7045
+ void runHandshake({ newConversation: true });
7046
+ }, [streaming, activeCancel, messagesSig, conversationIdSig, persistence, runHandshake, bus]);
6925
7047
  const handleNewChat = useCallback6(() => {
6926
7048
  handleClear();
6927
7049
  setView("chat");
@@ -7010,9 +7132,13 @@ function App({ options, hostElement, bus }) {
7010
7132
  log16.info("selectConversation", { conversationId: targetConversationId });
7011
7133
  bus.emit("selectConversation", { conversationId: targetConversationId });
7012
7134
  try {
7013
- const res = await transport.loadConversation(targetConversationId);
7135
+ const [res, markers] = await Promise.all([
7136
+ transport.loadConversation(targetConversationId),
7137
+ fetchFormMarkers(targetConversationId)
7138
+ ]);
7014
7139
  const hydrated = res.messages.map(fromWireMessage);
7015
7140
  messagesSig.value = hydrated;
7141
+ setFormMarkers(markers);
7016
7142
  conversationIdSig.value = res.conversationId;
7017
7143
  persistence.saveConversationId(res.conversationId);
7018
7144
  if (res.agent) setAgent(res.agent);
@@ -7026,7 +7152,7 @@ function App({ options, hostElement, bus }) {
7026
7152
  options.onError?.(err);
7027
7153
  }
7028
7154
  },
7029
- [transport, messagesSig, conversationIdSig, bus, options, persistence, refreshUnread]
7155
+ [transport, messagesSig, conversationIdSig, bus, options, persistence, refreshUnread, fetchFormMarkers]
7030
7156
  );
7031
7157
  useEffect17(() => {
7032
7158
  const unsub = bindHostCommands(hostElement, {
@@ -7045,10 +7171,10 @@ function App({ options, hostElement, bus }) {
7045
7171
  );
7046
7172
  const enrichedFormMarkers = useMemo3(
7047
7173
  () => formMarkers.map((m) => {
7048
- const def = options.forms.list.find((f) => f.id === m.formId);
7174
+ const def = effectiveForms.list.find((f) => f.id === m.formId);
7049
7175
  return { ...m, title: def?.title, form: def };
7050
7176
  }),
7051
- [formMarkers, options.forms]
7177
+ [formMarkers, effectiveForms]
7052
7178
  );
7053
7179
  const panelProps = {
7054
7180
  options: effectiveOptions,
@@ -7333,7 +7459,7 @@ var TRACKED = {
7333
7459
  clear: () => void 0,
7334
7460
  suggestion: () => void 0,
7335
7461
  toggleHistory: (p33) => ({ view: p33.view }),
7336
- conversationStarted: () => void 0,
7462
+ handshake: () => void 0,
7337
7463
  // Forms + human-in-the-loop — ids and outcomes, never values.
7338
7464
  formSubmit: (p33) => ({ formId: p33.formId, skipped: p33.skipped }),
7339
7465
  toolResult: () => void 0,
@@ -7502,7 +7628,7 @@ var EVENT_NAMES = [
7502
7628
  "calloutDismiss",
7503
7629
  // Lifecycle / errors
7504
7630
  "error",
7505
- "conversationStarted",
7631
+ "handshake",
7506
7632
  "visitorRebound",
7507
7633
  "conversationRebound"
7508
7634
  ];
@@ -7559,7 +7685,7 @@ function mount(opts) {
7559
7685
  );
7560
7686
  };
7561
7687
  renderApp();
7562
- bus.on("conversationStarted", (response) => {
7688
+ bus.on("handshake", (response) => {
7563
7689
  if (!response.config) return;
7564
7690
  rawOptions = mergeServerConfig(rawOptions, response.config);
7565
7691
  currentOptions = resolveOptions(rawOptions);