@helpai/elements 0.29.0 → 0.30.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
@@ -509,8 +509,9 @@ function resolveOptions(rawOpts) {
509
509
  const header = opts.header ?? {};
510
510
  const { themeMode, themeOverrides } = parseTheme(opts.theme);
511
511
  const mode = presentation.mode ?? "floating";
512
- const locale = i18n.locale ?? resolveDefaultLocale(i18n.defaultLocale);
513
- const availableLocales = i18n.availableLocales ?? [locale];
512
+ const defaultLocale = resolveDefaultLocale(i18n.defaultLocale);
513
+ const availableLocales = i18n.availableLocales ?? [i18n.locale ?? defaultLocale];
514
+ const locale = clampLocale(i18n.locale ?? defaultLocale, availableLocales, defaultLocale);
514
515
  const baseUrl = (opts.baseUrl ?? BRAND.defaultBaseUrl).replace(/\/$/, "");
515
516
  const agentApiBaseUrl = (opts.agentApiBaseUrl ?? `${baseUrl}/api/agent`).replace(/\/$/, "");
516
517
  const dataApiBaseUrl = (opts.dataApiBaseUrl ?? `${baseUrl}/api/data`).replace(/\/$/, "");
@@ -552,6 +553,7 @@ function resolveOptions(rawOpts) {
552
553
  maxCount: opts.composer?.attachments?.maxCount ?? DEFAULT_ATTACHMENTS.maxCount
553
554
  },
554
555
  locale,
556
+ defaultLocale,
555
557
  availableLocales,
556
558
  startMinimized: behavior.startMinimized ?? true,
557
559
  actions: resolveActions(mode, header.actions),
@@ -582,6 +584,11 @@ function resolveDefaultLocale(input) {
582
584
  if (input && input !== "auto") return input;
583
585
  return typeof navigator !== "undefined" && navigator.language ? navigator.language : "en";
584
586
  }
587
+ function clampLocale(desired, availableLocales, defaultLocale) {
588
+ if (availableLocales.length <= 1 || availableLocales.includes(desired)) return desired;
589
+ if (availableLocales.includes(defaultLocale)) return defaultLocale;
590
+ return availableLocales[0] ?? desired;
591
+ }
585
592
  function resolveActions(mode, overrides) {
586
593
  return overrides ?? DEFAULT_ACTIONS_BY_MODE[mode];
587
594
  }
@@ -730,13 +737,14 @@ function resolveLauncher(overrides) {
730
737
 
731
738
  // src/core/config/overrides.ts
732
739
  function applyOptionOverrides(options, activeLocale, activeThemeMode, activeTextSize) {
733
- if (activeLocale === options.locale && activeThemeMode === options.themeMode && activeTextSize === options.textSize) {
740
+ const locale = clampLocale(activeLocale, options.availableLocales, options.defaultLocale);
741
+ if (locale === options.locale && activeThemeMode === options.themeMode && activeTextSize === options.textSize) {
734
742
  return options;
735
743
  }
736
744
  const next = { ...options };
737
- if (activeLocale !== options.locale) {
738
- next.locale = activeLocale;
739
- next.strings = resolveStrings(activeLocale, options.stringsOverride);
745
+ if (locale !== options.locale) {
746
+ next.locale = locale;
747
+ next.strings = resolveStrings(locale, options.stringsOverride);
740
748
  }
741
749
  if (activeThemeMode !== options.themeMode) next.themeMode = activeThemeMode;
742
750
  if (activeTextSize !== options.textSize) next.textSize = activeTextSize;
@@ -782,6 +790,8 @@ function mergeServerConfig(user, server) {
782
790
  serverI18n ?? {},
783
791
  userI18n
784
792
  );
793
+ if (serverI18n?.availableLocales !== void 0) merged.availableLocales = serverI18n.availableLocales;
794
+ if (serverI18n?.defaultLocale !== void 0) merged.defaultLocale = serverI18n.defaultLocale;
785
795
  const userStrings = userI18n?.strings;
786
796
  const serverStrings = serverI18n?.strings;
787
797
  if (userStrings || serverStrings) {
@@ -1477,6 +1487,12 @@ var AgentTransport = class {
1477
1487
  // always sees the visitor's latest context as they navigate.
1478
1488
  __publicField(this, "userContext");
1479
1489
  __publicField(this, "pageContext");
1490
+ // Active BCP-47 locale, carried as the envelope's `locale` on EVERY request
1491
+ // so the backend can localize replies AND the data module can return
1492
+ // localized content + forms. Refreshed via `setLocale` whenever the visitor
1493
+ // switches (no re-handshake needed). Resolved + clamped to the deployment's
1494
+ // availableLocales by `resolveOptions` before it reaches here.
1495
+ __publicField(this, "locale");
1480
1496
  // ---- Data API (content / forms — module-help-ai-data-api) --------------
1481
1497
  //
1482
1498
  // The DATA surfaces live on `dataApiBaseUrl` (default `${baseUrl}/api/data`)
@@ -1502,6 +1518,15 @@ var AgentTransport = class {
1502
1518
  this.userContext = userContext;
1503
1519
  this.pageContext = pageContext;
1504
1520
  }
1521
+ /**
1522
+ * Update the active locale carried on every subsequent request. Call on
1523
+ * mount (before the first handshake) and whenever the user picks a new
1524
+ * locale — the next content / forms / message request reflects it with no
1525
+ * extra round-trip.
1526
+ */
1527
+ setLocale(locale) {
1528
+ this.locale = locale;
1529
+ }
1505
1530
  /**
1506
1531
  * Seed the visitor + conversation identity from persistence BEFORE the first
1507
1532
  * `handshake` resolves. A mount-time `resumeStream()` runs in parallel
@@ -1518,6 +1543,7 @@ var AgentTransport = class {
1518
1543
  const out = {};
1519
1544
  if (this.visitorId) out.visitorId = this.visitorId;
1520
1545
  if (this.conversationId) out.conversationId = this.conversationId;
1546
+ if (this.locale) out.locale = this.locale;
1521
1547
  const context = encodeContext(this.userContext, this.pageContext);
1522
1548
  if (context) out[CONTEXT_PARAM] = context;
1523
1549
  return out;
@@ -6280,7 +6306,7 @@ function ContentView({ id, title, transport, strings, onBack, actions }) {
6280
6306
  return /* @__PURE__ */ jsxs28("article", { class: `${p29}-content`, "data-testid": TID.contentView, children: [
6281
6307
  item.image ? /* @__PURE__ */ jsx33("img", { class: `${p29}-content-hero`, src: item.image, alt: "", loading: "lazy" }) : null,
6282
6308
  item.description ? /* @__PURE__ */ jsx33("p", { class: `${p29}-content-subtitle`, children: item.description }) : null,
6283
- item.tags && item.tags.length > 0 ? /* @__PURE__ */ jsx33("span", { class: `${p29}-news-tags`, children: item.tags.map((t) => /* @__PURE__ */ jsx33("span", { class: `${p29}-news-tag`, children: t }, t)) }) : null,
6309
+ item.tags?.length ? /* @__PURE__ */ jsx33("span", { class: `${p29}-news-tags`, children: item.tags.map((t) => /* @__PURE__ */ jsx33("span", { class: `${p29}-news-tag`, children: t }, t)) }) : null,
6284
6310
  /* @__PURE__ */ jsx33(StaticMarkdown, { text: item.content ?? "" })
6285
6311
  ] });
6286
6312
  }
@@ -6725,7 +6751,10 @@ function App({ options, hostElement, bus }) {
6725
6751
  visitorId,
6726
6752
  conversationId,
6727
6753
  userPrefs: persistence.loadUserPrefs(),
6728
- locale: options.locale
6754
+ // The visitor's ACTIVE locale (persisted pick, else auto-detected) —
6755
+ // not the config baseline — so the handshake learns what the visitor
6756
+ // wants. The response's availableLocales then clamps it for the rest.
6757
+ locale: activeLocale
6729
6758
  });
6730
6759
  } catch (err) {
6731
6760
  if (isStale()) return;
@@ -6787,7 +6816,18 @@ function App({ options, hostElement, bus }) {
6787
6816
  setConversationReady(true);
6788
6817
  }
6789
6818
  },
6790
- [transport, visitorId, persistence, options, bus, conversationIdSig, feedback, playWelcome, loadThread]
6819
+ [
6820
+ transport,
6821
+ visitorId,
6822
+ persistence,
6823
+ options,
6824
+ bus,
6825
+ conversationIdSig,
6826
+ feedback,
6827
+ playWelcome,
6828
+ loadThread,
6829
+ activeLocale
6830
+ ]
6791
6831
  );
6792
6832
  const userSig = JSON.stringify(options.userContext ?? null);
6793
6833
  const pageSig = JSON.stringify(options.pageContext ?? null);
@@ -6796,6 +6836,13 @@ function App({ options, hostElement, bus }) {
6796
6836
  const merged = Object.keys(formContext).length ? { ...formContext, ...options.userContext } : options.userContext;
6797
6837
  transport.setContext(merged, toWirePageContext(options.pageContext));
6798
6838
  }, [transport, userSig, pageSig, formCtxSig]);
6839
+ const effectiveLocale = clampLocale(activeLocale, options.availableLocales, options.defaultLocale);
6840
+ useEffect17(() => {
6841
+ transport.setLocale(effectiveLocale);
6842
+ }, [transport, effectiveLocale]);
6843
+ useEffect17(() => {
6844
+ if (effectiveLocale !== activeLocale) setActiveLocale(effectiveLocale);
6845
+ }, [effectiveLocale, activeLocale]);
6799
6846
  const runResume = useCallback6(
6800
6847
  async (handle) => {
6801
6848
  let bubble = null;
@@ -7446,12 +7493,26 @@ function emitMessage(bus, options, role, text) {
7446
7493
  options.onMessage?.(payload);
7447
7494
  }
7448
7495
 
7449
- // src/index.ts
7450
- import { h, render } from "preact";
7451
-
7452
7496
  // src/ui/error-boundary.tsx
7453
7497
  import { Component } from "preact";
7454
7498
  var log17 = logger.scope("error-boundary");
7499
+ var ErrorBoundary = class extends Component {
7500
+ constructor() {
7501
+ super(...arguments);
7502
+ __publicField(this, "state", { crashed: false });
7503
+ }
7504
+ componentDidCatch(error, info) {
7505
+ log17.error("widget render crashed", { error, componentStack: info?.componentStack });
7506
+ this.setState({ crashed: true });
7507
+ this.props.onError(error, info?.componentStack);
7508
+ }
7509
+ render() {
7510
+ return this.state.crashed ? this.props.fallback : this.props.children;
7511
+ }
7512
+ };
7513
+
7514
+ // src/index.ts
7515
+ import { h, render } from "preact";
7455
7516
 
7456
7517
  // src/shadow/iframe-mount.ts
7457
7518
  var log18 = logger.scope("iframe");
@@ -7494,6 +7555,121 @@ var EventBus = class {
7494
7555
  }
7495
7556
  };
7496
7557
 
7558
+ // src/core/tracking.ts
7559
+ var PROTOCOL_VERSION = "1";
7560
+ var MAX_HITS_PER_MINUTE = 60;
7561
+ var MAX_URL_LENGTH = 2e3;
7562
+ var TRACKED = {
7563
+ // Panel lifecycle — the owner's "is the widget used at all" funnel.
7564
+ open: () => void 0,
7565
+ close: () => void 0,
7566
+ expand: (on) => ({ on }),
7567
+ fullscreen: (on) => ({ on }),
7568
+ popOut: () => void 0,
7569
+ // Conversation funnel. `send` is the key conversion; text never rides.
7570
+ send: (p33) => ({ attachments: p33.attachmentCount }),
7571
+ message: (p33) => ({ role: p33.role }),
7572
+ stop: () => void 0,
7573
+ clear: () => void 0,
7574
+ suggestion: () => void 0,
7575
+ toggleHistory: (p33) => ({ view: p33.view }),
7576
+ handshake: () => void 0,
7577
+ // Forms + human-in-the-loop — ids and outcomes, never values.
7578
+ formSubmit: (p33) => ({ formId: p33.formId, skipped: p33.skipped }),
7579
+ toolResult: () => void 0,
7580
+ toolDecision: (p33) => ({ approved: p33.approved }),
7581
+ // Composer / attachments / voice.
7582
+ attach: (p33) => ({ count: p33.count, bytes: p33.totalBytes }),
7583
+ voiceStart: () => void 0,
7584
+ voiceStop: (p33) => ({ ms: p33.durationMs }),
7585
+ voiceCancel: () => void 0,
7586
+ // Preferences — how visitors tune the surface.
7587
+ localeChange: (locale) => ({ locale }),
7588
+ themeChange: (theme) => ({ theme }),
7589
+ textSizeChange: (size) => ({ size }),
7590
+ soundToggle: (p33) => ({ muted: p33.muted }),
7591
+ sidebarToggle: (p33) => ({ collapsed: p33.collapsed }),
7592
+ calloutDismiss: () => void 0,
7593
+ // Health signal only — the error object itself never leaves the page.
7594
+ error: () => void 0
7595
+ };
7596
+ function privacySignalsOff() {
7597
+ const nav = navigator;
7598
+ return nav.globalPrivacyControl === true || nav.doNotTrack === "1";
7599
+ }
7600
+ function pageUrl() {
7601
+ return location.origin + location.pathname;
7602
+ }
7603
+ function referrerOrigin() {
7604
+ try {
7605
+ return document.referrer ? new URL(document.referrer).origin : "";
7606
+ } catch {
7607
+ return "";
7608
+ }
7609
+ }
7610
+ function createTracker(bus, deps) {
7611
+ const sampleRoll = Math.random();
7612
+ let windowStart = 0;
7613
+ let windowHits = 0;
7614
+ const inflight = /* @__PURE__ */ new Set();
7615
+ const hit = (event, dims) => {
7616
+ const { tracking, publicKey, aiAgentDeploymentId, mode, themeMode } = deps.getOptions();
7617
+ if (!tracking.enabled || privacySignalsOff()) return;
7618
+ if (sampleRoll >= tracking.sampleRate) return;
7619
+ const now = performance.now();
7620
+ if (now - windowStart > 6e4) {
7621
+ windowStart = now;
7622
+ windowHits = 0;
7623
+ }
7624
+ if (++windowHits > MAX_HITS_PER_MINUTE) return;
7625
+ const params = new URLSearchParams();
7626
+ params.set("v", PROTOCOL_VERSION);
7627
+ params.set("e", event);
7628
+ if (tracking.token) params.set("tk", tracking.token);
7629
+ if (publicKey) params.set("pk", publicKey);
7630
+ if (aiAgentDeploymentId) params.set("dep", aiAgentDeploymentId);
7631
+ params.set("vid", deps.getVisitorId());
7632
+ const cid = deps.getConversationId();
7633
+ if (cid) params.set("cid", cid);
7634
+ params.set("url", pageUrl());
7635
+ const ref = referrerOrigin();
7636
+ if (ref) params.set("ref", ref);
7637
+ params.set("sw", String(screen.width));
7638
+ params.set("sh", String(screen.height));
7639
+ params.set("vw", String(innerWidth));
7640
+ params.set("vh", String(innerHeight));
7641
+ params.set("dpr", String(devicePixelRatio));
7642
+ params.set("lang", navigator.language);
7643
+ try {
7644
+ params.set("tz", Intl.DateTimeFormat().resolvedOptions().timeZone);
7645
+ } catch {
7646
+ }
7647
+ params.set("m", mode);
7648
+ params.set("th", themeMode);
7649
+ if (dims) {
7650
+ for (const [k, val] of Object.entries(dims)) {
7651
+ if (val !== void 0) params.set(k, String(val));
7652
+ }
7653
+ }
7654
+ params.set("ts", String(Date.now()));
7655
+ const url = `${tracking.endpoint}?${params}`;
7656
+ if (url.length > MAX_URL_LENGTH) return;
7657
+ const img = new Image(1, 1);
7658
+ inflight.add(img);
7659
+ const settle = () => inflight.delete(img);
7660
+ img.addEventListener("load", settle, { once: true });
7661
+ img.addEventListener("error", settle, { once: true });
7662
+ img.src = url;
7663
+ };
7664
+ const offs = Object.keys(TRACKED).map(
7665
+ (event) => bus.on(event, (payload) => hit(event, TRACKED[event]?.(payload)))
7666
+ );
7667
+ return () => {
7668
+ for (const off of offs) off();
7669
+ inflight.clear();
7670
+ };
7671
+ }
7672
+
7497
7673
  // src/core/hooks.ts
7498
7674
  var log19 = logger.scope("hooks");
7499
7675
 
@@ -7527,6 +7703,23 @@ var ChatElement = class extends HTMLElement {
7527
7703
  * same user-wins-per-field merge policy.
7528
7704
  */
7529
7705
  __publicField(this, "rawOptions", {});
7706
+ /**
7707
+ * Latest resolved options — refreshed every `renderApp()` so the usage
7708
+ * tracker (created once in `boot`) reads live, post-handshake-merge
7709
+ * values (`tracking.enabled` / `endpoint` / `token`).
7710
+ *
7711
+ * This path is independent of `mount()` (it supports N elements per page
7712
+ * without a shared `widgetId` registry), so everything `mount()` composes
7713
+ * around `<App>` must be mirrored here: the handshake-config merge, the
7714
+ * usage tracker, and the ErrorBoundary wrapper. This has drifted three
7715
+ * times now (mode-merge, tracker, ErrorBoundary). The proper fix is to
7716
+ * extract the shared `<App>` composition into one `bootWidget(opts,
7717
+ * { hostElement })` helper both entry points call — tracked as a
7718
+ * follow-up; until then, keep these two boot paths in lockstep.
7719
+ */
7720
+ __publicField(this, "resolved", null);
7721
+ /** Tracker unsubscribe — released on disconnect. */
7722
+ __publicField(this, "untrack", null);
7530
7723
  }
7531
7724
  static get observedAttributes() {
7532
7725
  return REACTIVE_ATTRS;
@@ -7536,6 +7729,8 @@ var ChatElement = class extends HTMLElement {
7536
7729
  }
7537
7730
  disconnectedCallback() {
7538
7731
  if (!this.mountResult) return;
7732
+ this.untrack?.();
7733
+ this.untrack = null;
7539
7734
  render2(null, this.mountResult.appRoot);
7540
7735
  this.bus.clear();
7541
7736
  }
@@ -7555,6 +7750,12 @@ var ChatElement = class extends HTMLElement {
7555
7750
  this.rawOptions = mergeServerConfig(this.rawOptions, response.config);
7556
7751
  this.renderApp();
7557
7752
  });
7753
+ const persistence = createPersistence(resolved.widgetId, resolved.storage);
7754
+ this.untrack = createTracker(this.bus, {
7755
+ getOptions: () => this.resolved ?? resolved,
7756
+ getVisitorId: () => persistence.getVisitorId(),
7757
+ getConversationId: () => persistence.loadConversationId()
7758
+ });
7558
7759
  }
7559
7760
  this.renderApp();
7560
7761
  }
@@ -7565,12 +7766,20 @@ var ChatElement = class extends HTMLElement {
7565
7766
  renderApp() {
7566
7767
  if (!this.mountResult) return;
7567
7768
  const resolved = resolveOptions(this.rawOptions);
7769
+ this.resolved = resolved;
7568
7770
  const host = this.mountResult.hostElement;
7569
7771
  applyHostPosition(host, hostPositionForMode(resolved.mode));
7570
7772
  const cachedThemeMode = createPersistence(resolved.widgetId, resolved.storage).loadUserPrefs().themeMode;
7571
7773
  applyHostAttributes(host, cachedThemeMode ? { ...resolved, themeMode: cachedThemeMode } : resolved);
7572
7774
  applyMode(host, resolveInitialHostMode(resolved));
7573
- render2(h2(App, { bus: this.bus, hostElement: host, options: resolved }), this.mountResult.appRoot);
7775
+ render2(
7776
+ h2(
7777
+ ErrorBoundary,
7778
+ { onError: (error) => this.bus.emit("error", error) },
7779
+ h2(App, { bus: this.bus, hostElement: host, options: resolved })
7780
+ ),
7781
+ this.mountResult.appRoot
7782
+ );
7574
7783
  }
7575
7784
  };
7576
7785
  var registered = false;