@cms.ai/brand_brain 0.0.1 → 0.1.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.
@@ -1387,7 +1387,11 @@ const SSEMessageType = {
1387
1387
  TextMessageEnd: "TEXT_MESSAGE_END",
1388
1388
  Custom: "CUSTOM"
1389
1389
  };
1390
- /** The `name` on `CUSTOM` guide events (skill payloads). */
1390
+ /**
1391
+ * The `name` on `CUSTOM` guide events (skill payloads). `SkillGate` and
1392
+ * `SkillError` never surface as `skill` stream events — the SDK re-maps them to
1393
+ * the dedicated `gate` / `error` {@link GuideStreamEvent} variants.
1394
+ */
1391
1395
  const GuideEvent = {
1392
1396
  SkillStart: "skill_start",
1393
1397
  SkillContent: "skill_content",
@@ -1407,8 +1411,17 @@ const GuideEvent = {
1407
1411
  SkillSolutionDesignDiagram: "skill_solutiondesign_diagram",
1408
1412
  SkillEnd: "skill_end",
1409
1413
  SkillError: "skill_error",
1410
- SkillGate: "skill_gate",
1411
- InputSuggestions: "input_suggestions"
1414
+ SkillGate: "skill_gate"
1415
+ };
1416
+ /** The guide skill types. `answer` is always on; the rest are enabled per-guide. */
1417
+ const SkillResponseType = {
1418
+ Answer: "answer",
1419
+ HowTo: "howto",
1420
+ Diagnose: "diagnose",
1421
+ Recommend: "recommend",
1422
+ Playlist: "playlist",
1423
+ BusinessCase: "businesscase",
1424
+ SolutionDesign: "solutiondesign"
1412
1425
  };
1413
1426
  /** Analytics events fired by the SDK bootstrap. */
1414
1427
  const EmbedTrackingEvent = {
@@ -1445,7 +1458,13 @@ async function* streamConversation(params) {
1445
1458
  body: JSON.stringify(body),
1446
1459
  signal
1447
1460
  });
1448
- if (!response.ok || !response.body) throw new Error(`[brand_brain] Conversation request failed with status ${response.status}`);
1461
+ if (!response.ok || !response.body) {
1462
+ let detail = "";
1463
+ try {
1464
+ detail = (await response.text()).trim();
1465
+ } catch {}
1466
+ throw new Error(`[brand_brain] Conversation request failed with status ${response.status}${detail ? `: ${detail}` : ""}`);
1467
+ }
1449
1468
  const reader = response.body.getReader();
1450
1469
  const decoder = new TextDecoder();
1451
1470
  let buffer = "";
@@ -1504,6 +1523,13 @@ function mapFrame(frame) {
1504
1523
  default: return null;
1505
1524
  }
1506
1525
  }
1526
+ /**
1527
+ * Names that surface as typed `skill` events. `SkillGate`/`SkillError` are
1528
+ * re-mapped to the `gate`/`error` variants below, so they are excluded here;
1529
+ * any name outside this set is a server event this SDK version predates and
1530
+ * flows through as `skill-unknown`.
1531
+ */
1532
+ const SKILL_EVENT_NAMES = new Set(Object.values(GuideEvent).filter((name) => name !== GuideEvent.SkillGate && name !== GuideEvent.SkillError));
1507
1533
  function mapCustomFrame(frame) {
1508
1534
  if (!frame.name) return null;
1509
1535
  const value = frame.value ?? {};
@@ -1516,20 +1542,42 @@ function mapCustomFrame(frame) {
1516
1542
  if (frame.name === GuideEvent.SkillError) return {
1517
1543
  type: "error",
1518
1544
  message: value.message ?? "Skill error",
1519
- code: value.errorType
1545
+ code: value.errorType,
1546
+ retryable: value.retryable,
1547
+ retryHint: value.retryHint
1520
1548
  };
1521
- return {
1549
+ if (SKILL_EVENT_NAMES.has(frame.name)) return {
1522
1550
  type: "skill",
1523
1551
  name: frame.name,
1524
1552
  value
1525
1553
  };
1554
+ return {
1555
+ type: "skill-unknown",
1556
+ name: frame.name,
1557
+ value
1558
+ };
1526
1559
  }
1527
1560
  //#endregion
1528
1561
  //#region src/GuideClient.ts
1562
+ /**
1563
+ * The account is resolved by the branded host the SDK talks to, and that host
1564
+ * must be same-site with `baseUrl` for the visitor cookies to attach — so the
1565
+ * `baseUrl` hostname IS the registered account domain. Derive it from there
1566
+ * rather than the host page's `window.location`, which is an unrelated origin
1567
+ * for cross-site embeds and produces a "Domain not found" 404 on resolve.
1568
+ */
1529
1569
  function resolveDomain(config) {
1530
1570
  if (config.domain) return config.domain;
1531
- if (typeof window !== "undefined") return window.location.hostname;
1532
- throw new Error("[brand_brain] `domain` is required when running without a browser `window`.");
1571
+ return hostnameFromBaseUrl(config.baseUrl);
1572
+ }
1573
+ /**
1574
+ * Normalize `baseUrl` to the bare host the server stores: lowercased, no port,
1575
+ * no leading `www.` (the domain write-path strips it, so a `www.` host can
1576
+ * never match). Accepts a bare host too, in case `baseUrl` omits the protocol.
1577
+ */
1578
+ function hostnameFromBaseUrl(baseUrl) {
1579
+ const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(baseUrl) ? baseUrl : `https://${baseUrl}`;
1580
+ return new URL(withProtocol).hostname.toLowerCase().replace(/^www\./, "");
1533
1581
  }
1534
1582
  /**
1535
1583
  * The client returned by {@link initGuide}. Holds the resolved guide, the
@@ -1583,7 +1631,9 @@ var GuideClient = class GuideClient {
1583
1631
  this.#api = createApiClient(this.apiBaseUrl, this.fetchImpl);
1584
1632
  }
1585
1633
  static async init(config) {
1586
- const fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
1634
+ const globalFetch = typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : void 0;
1635
+ const fetchImpl = config.fetch ?? globalFetch;
1636
+ if (!fetchImpl) throw new Error("[brand_brain] No `fetch` is available — pass `config.fetch` when running without a global fetch.");
1587
1637
  const apiBaseUrl = toApiBaseUrl(config.baseUrl);
1588
1638
  const domain = resolveDomain(config);
1589
1639
  const guide = await createApiClient(apiBaseUrl, fetchImpl).guides.resolve({
@@ -1722,26 +1772,181 @@ async function initGuide(config) {
1722
1772
  return GuideClient.init(config);
1723
1773
  }
1724
1774
  //#endregion
1775
+ //#region src/skill-state.ts
1776
+ /** A fresh, empty turn state. */
1777
+ function createSkillTurn() {
1778
+ return {
1779
+ skill: null,
1780
+ status: "streaming",
1781
+ messageId: null,
1782
+ diagnoseSubtype: null,
1783
+ followUpSkill: null,
1784
+ content: "",
1785
+ documents: [],
1786
+ contentRecommendations: [],
1787
+ followUpRecommendations: [],
1788
+ ctas: [],
1789
+ diagnoseHeader: null,
1790
+ diagnoseQuestions: [],
1791
+ playlistHeader: null,
1792
+ playlistItems: [],
1793
+ howToOutline: null,
1794
+ howToSteps: [],
1795
+ businessCaseFormats: [],
1796
+ businessCaseDrafts: {},
1797
+ businessCaseContent: {},
1798
+ solutionDesignHeader: null,
1799
+ solutionDesignNodes: [],
1800
+ solutionDesignEdges: [],
1801
+ skillInstanceId: null
1802
+ };
1803
+ }
1804
+ /** A recommendations payload is content cards iff its entries carry `targetSkill`. */
1805
+ function isContentRecommendations(recommendations) {
1806
+ return recommendations.length > 0 && "targetSkill" in recommendations[0];
1807
+ }
1808
+ /**
1809
+ * Fold one `skill` stream event into the turn state. Pure and immutable — the
1810
+ * returned object is a new reference whenever anything changed, so the result
1811
+ * can back React state directly:
1812
+ *
1813
+ * ```ts
1814
+ * let turn = createSkillTurn();
1815
+ * for await (const event of client.ask(message)) {
1816
+ * if (event.type === "skill") turn = reduceSkillEvent(turn, event);
1817
+ * }
1818
+ * ```
1819
+ */
1820
+ function reduceSkillEvent(turn, event) {
1821
+ switch (event.name) {
1822
+ case GuideEvent.SkillStart: return {
1823
+ ...event.value.skill === SkillResponseType.BusinessCase && turn.skill === SkillResponseType.BusinessCase ? {
1824
+ ...createSkillTurn(),
1825
+ businessCaseFormats: turn.businessCaseFormats,
1826
+ businessCaseDrafts: turn.businessCaseDrafts,
1827
+ businessCaseContent: turn.businessCaseContent
1828
+ } : createSkillTurn(),
1829
+ skill: event.value.skill,
1830
+ messageId: event.value.messageId,
1831
+ diagnoseSubtype: event.value.diagnoseSubtype ?? null,
1832
+ followUpSkill: event.value.followUpSkill ?? null
1833
+ };
1834
+ case GuideEvent.SkillContent: return {
1835
+ ...turn,
1836
+ content: turn.content + event.value.delta
1837
+ };
1838
+ case GuideEvent.SkillDocuments: return {
1839
+ ...turn,
1840
+ documents: event.value.documents
1841
+ };
1842
+ case GuideEvent.SkillRecommendations: return isContentRecommendations(event.value.recommendations) ? {
1843
+ ...turn,
1844
+ contentRecommendations: event.value.recommendations
1845
+ } : {
1846
+ ...turn,
1847
+ followUpRecommendations: event.value.recommendations
1848
+ };
1849
+ case GuideEvent.SkillCTAs: return {
1850
+ ...turn,
1851
+ ctas: event.value.ctas
1852
+ };
1853
+ case GuideEvent.SkillDiagnoseHeader: return {
1854
+ ...turn,
1855
+ diagnoseHeader: event.value.header
1856
+ };
1857
+ case GuideEvent.SkillDiagnoseQuestion: return {
1858
+ ...turn,
1859
+ diagnoseQuestions: [...turn.diagnoseQuestions, event.value.question]
1860
+ };
1861
+ case GuideEvent.SkillPlaylistHeader: return {
1862
+ ...turn,
1863
+ playlistHeader: event.value.header
1864
+ };
1865
+ case GuideEvent.SkillPlaylistItems: return {
1866
+ ...turn,
1867
+ playlistItems: event.value.items
1868
+ };
1869
+ case GuideEvent.SkillPlaylistRationale: return {
1870
+ ...turn,
1871
+ playlistItems: turn.playlistItems.map((item) => item.id === event.value.itemId ? {
1872
+ ...item,
1873
+ rationale: event.value.rationale
1874
+ } : item)
1875
+ };
1876
+ case GuideEvent.SkillHowToOutline: return {
1877
+ ...turn,
1878
+ howToOutline: event.value.outline,
1879
+ skillInstanceId: event.value.skillInstanceId ?? turn.skillInstanceId
1880
+ };
1881
+ case GuideEvent.SkillHowToSteps: return {
1882
+ ...turn,
1883
+ howToSteps: event.value.steps
1884
+ };
1885
+ case GuideEvent.SkillBusinessCaseChooser: return {
1886
+ ...turn,
1887
+ businessCaseFormats: event.value.formats,
1888
+ businessCaseDrafts: event.value.drafts
1889
+ };
1890
+ case GuideEvent.SkillBusinessCaseDelta: {
1891
+ const existing = turn.businessCaseContent[event.value.subType] ?? "";
1892
+ return {
1893
+ ...turn,
1894
+ businessCaseContent: {
1895
+ ...turn.businessCaseContent,
1896
+ [event.value.subType]: existing + event.value.delta
1897
+ }
1898
+ };
1899
+ }
1900
+ case GuideEvent.SkillSolutionDesignHeader: return {
1901
+ ...turn,
1902
+ solutionDesignHeader: event.value.header
1903
+ };
1904
+ case GuideEvent.SkillSolutionDesignDiagram: return {
1905
+ ...turn,
1906
+ solutionDesignNodes: event.value.nodes,
1907
+ solutionDesignEdges: event.value.edges
1908
+ };
1909
+ case GuideEvent.SkillEnd: return {
1910
+ ...turn,
1911
+ status: "complete",
1912
+ skillInstanceId: event.value.skillInstanceId ?? event.value.skillResponseId ?? turn.skillInstanceId
1913
+ };
1914
+ default: return event;
1915
+ }
1916
+ }
1917
+ //#endregion
1725
1918
  //#region src/react/useGuide.ts
1726
1919
  /**
1727
1920
  * React binding over {@link initGuide}. Bootstraps once per `baseUrl`+`slug`,
1728
- * mirrors the streamed reply into state, and ends the session on unmount —
1921
+ * mirrors the streamed reply into `messages`, folds structured skill events
1922
+ * into `skillTurn`, surfaces email gates, and ends the session on unmount —
1729
1923
  * encapsulating the streaming/cleanup patterns so generated UIs stay correct.
1730
1924
  */
1731
1925
  function useGuide(config) {
1732
1926
  const [client, setClient] = (0, react.useState)(null);
1733
1927
  const [messages, setMessages] = (0, react.useState)([]);
1928
+ const [skillTurn, setSkillTurn] = (0, react.useState)(null);
1929
+ const [gate, setGate] = (0, react.useState)(null);
1734
1930
  const [isStreaming, setIsStreaming] = (0, react.useState)(false);
1735
1931
  const [error, setError] = (0, react.useState)(null);
1736
1932
  const { baseUrl, slug } = config;
1737
1933
  const configRef = (0, react.useRef)(config);
1738
1934
  configRef.current = config;
1935
+ const turnRef = (0, react.useRef)(null);
1936
+ const lastAskRef = (0, react.useRef)(null);
1739
1937
  (0, react.useEffect)(() => {
1740
1938
  let active = true;
1741
1939
  let created = null;
1940
+ setClient(null);
1941
+ setMessages([]);
1942
+ setSkillTurn(null);
1943
+ setGate(null);
1944
+ setError(null);
1945
+ turnRef.current = null;
1946
+ lastAskRef.current = null;
1742
1947
  initGuide(configRef.current).then((instance) => {
1743
1948
  if (!active) {
1744
- instance.endSession();
1949
+ instance.endSession().catch(() => {});
1745
1950
  return;
1746
1951
  }
1747
1952
  created = instance;
@@ -1752,15 +1957,32 @@ function useGuide(config) {
1752
1957
  });
1753
1958
  return () => {
1754
1959
  active = false;
1755
- created?.endSession();
1960
+ created?.endSession().catch(() => {});
1756
1961
  };
1757
1962
  }, [baseUrl, slug]);
1758
1963
  const ask = (0, react.useCallback)(async (message, options) => {
1759
1964
  if (!client) return;
1965
+ lastAskRef.current = {
1966
+ message,
1967
+ options
1968
+ };
1760
1969
  setIsStreaming(true);
1761
1970
  setError(null);
1971
+ setGate(null);
1762
1972
  try {
1763
- for await (const event of client.ask(message, options)) if (event.type === "text" || event.type === "message-start") setMessages([...client.messages]);
1973
+ for await (const event of client.ask(message, options)) {
1974
+ configRef.current.onEvent?.(event);
1975
+ if (event.type === "text" || event.type === "message-start") setMessages([...client.messages]);
1976
+ else if (event.type === "skill") {
1977
+ turnRef.current = reduceSkillEvent(turnRef.current ?? createSkillTurn(), event);
1978
+ setSkillTurn(turnRef.current);
1979
+ } else if (event.type === "gate") setGate({
1980
+ skill: event.skill,
1981
+ prompt: event.prompt,
1982
+ skillInstanceId: event.skillInstanceId
1983
+ });
1984
+ else if (event.type === "error") setError(new Error(event.message));
1985
+ }
1764
1986
  setMessages([...client.messages]);
1765
1987
  } catch (err) {
1766
1988
  setError(err instanceof Error ? err : new Error(String(err)));
@@ -1768,18 +1990,44 @@ function useGuide(config) {
1768
1990
  setIsStreaming(false);
1769
1991
  }
1770
1992
  }, [client]);
1993
+ const submitGate = (0, react.useCallback)(async (email, options) => {
1994
+ if (!client) throw new Error("[brand_brain] Guide is not ready yet — wait for isReady before submitting the gate.");
1995
+ const pending = gate;
1996
+ const result = await client.submitGate(email, {
1997
+ ...pending && {
1998
+ skill: pending.skill,
1999
+ prompt: pending.prompt,
2000
+ skillInstanceId: pending.skillInstanceId
2001
+ },
2002
+ ...options
2003
+ });
2004
+ if (result.success) setGate(null);
2005
+ return result;
2006
+ }, [client, gate]);
2007
+ const retryLastAsk = (0, react.useCallback)(async () => {
2008
+ const last = lastAskRef.current;
2009
+ if (last) await ask(last.message, last.options);
2010
+ }, [ask]);
1771
2011
  const reset = (0, react.useCallback)(() => {
1772
2012
  client?.reset();
1773
2013
  setMessages([]);
2014
+ setSkillTurn(null);
2015
+ setGate(null);
2016
+ turnRef.current = null;
2017
+ lastAskRef.current = null;
1774
2018
  }, [client]);
1775
2019
  return {
1776
2020
  client,
1777
2021
  guide: client?.guide ?? null,
1778
2022
  messages,
2023
+ skillTurn,
2024
+ gate,
1779
2025
  isReady: client !== null,
1780
2026
  isStreaming,
1781
2027
  error,
1782
2028
  ask,
2029
+ submitGate,
2030
+ retryLastAsk,
1783
2031
  reset
1784
2032
  };
1785
2033
  }