@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.
package/llms.txt ADDED
@@ -0,0 +1,115 @@
1
+ # @cms.ai/brand_brain
2
+
3
+ > Client-side SDK for building CMS.ai Brand Brain (Guide) chat experiences into your own UI. Streaming chat over SSE with structured "skill" deliverables (documents, recommendations, how-tos, playlists, business cases, solution designs, self-assessments), visitor identity, consent, analytics, and email lead capture. Framework-agnostic core + React binding at `@cms.ai/brand_brain/react`. All types below are exported from the package root.
4
+
5
+ ## Setup
6
+
7
+ ```ts
8
+ import { initGuide } from "@cms.ai/brand_brain";
9
+ const guide = await initGuide({ baseUrl: "https://guide.customer.com", slug: "default" });
10
+ ```
11
+
12
+ - `baseUrl`: the customer's branded CMS.ai host. Same-site with the page = full visitor identity. Cross-site (e.g. app on `*.lovable.app`, guide on `*.guides.navless.ai`) still works, but Safari drops the visitor cookies — treat the visitor as anonymous per page load and depend on nothing persisting across visits.
13
+ - `InitGuideConfig`: `{ baseUrl: string; slug: string; domain?: string; consent?: "auto" | { functional?: boolean; analytical?: boolean } | false; trackLoad?: boolean; fetch?: typeof fetch }`.
14
+
15
+ ## React binding (preferred for React apps)
16
+
17
+ ```tsx
18
+ import { useGuide } from "@cms.ai/brand_brain/react";
19
+ const { client, guide, messages, skillTurn, gate, isReady, isStreaming, error, ask, submitGate, retryLastAsk, reset } =
20
+ useGuide({ baseUrl, slug: "default", onEvent: (e) => {} });
21
+ ```
22
+
23
+ - `messages: { id, role: "user" | "assistant", content: string }[]` — render as chat bubbles; assistant content streams in.
24
+ - `skillTurn: GuideSkillTurn | null` — structured deliverable of the current turn (see below).
25
+ - `gate: { skill, prompt?, skillInstanceId? } | null` — when non-null, render an email form; `await submitGate(email)`; if `!result.needsVerification`, `await retryLastAsk()`.
26
+ - `ask(message, { forcedSkill?, signal? })` — send a message. Disable input while `isStreaming`.
27
+ - Guide branding: `guide.theme`, `guide.logoUrl`, `guide.companyName`, `guide.customization?.welcomeMessage`, `guide.customization?.chatInputPlaceholders`.
28
+
29
+ ## Event stream contract (`client.ask()` yields `GuideStreamEvent`)
30
+
31
+ ```
32
+ { type: "run-started"; runId }
33
+ { type: "message-start"; messageId; role }
34
+ { type: "text"; messageId; delta } // chat reply token chunk
35
+ { type: "message-end"; messageId }
36
+ { type: "skill"; name; value } // discriminated on `name`, payloads below
37
+ { type: "skill-unknown"; name; value } // newer server event; safe to ignore
38
+ { type: "gate"; skill; prompt?; skillInstanceId? } // email required; stream ends after this
39
+ { type: "error"; message; code?; retryable?; retryHint?: { prompt; forcedSkill? } }
40
+ { type: "run-finished"; finishReason? }
41
+ ```
42
+
43
+ Skill event payloads (`value` per `name`); every turn runs one skill bracketed by `skill_start` … `skill_end`:
44
+
45
+ ```
46
+ skill_start { skill: SkillResponseTypeType; messageId: string; diagnoseSubtype?: "standalone" | "context_gathering"; followUpSkill?: string }
47
+ skill_content { delta: string } // markdown chunk of long-form body
48
+ skill_documents { documents: GuideDocument[] } // replace, don't append
49
+ skill_recommendations { recommendations: ContentRecommendation[] | SkillRecommendation[] } // ContentRecommendation has `targetSkill`; SkillRecommendation has `skill`
50
+ skill_ctas { ctas: SkillCTA[] } // render only the first
51
+ skill_diagnose_header { header: DiagnosticHeader }
52
+ skill_diagnose_question { question: DiagnosticQuestion } // append; arrives one per event
53
+ skill_playlist_header { header: { title: string; description: string } }
54
+ skill_playlist_items { items: PlaylistItem[] } // replace
55
+ skill_playlist_rationale { itemId: string; rationale: string } // late patch to one item
56
+ skill_howto_outline { outline: { title: string; steps: { id: string; title: string }[] }; skillInstanceId?: string }
57
+ skill_howto_steps { steps: HowToStep[] } // replaces outline detail
58
+ skill_businesscase_chooser { formats: { subType: string; label: string; description: string; icon: string }[]; drafts: Record<string, { draftId: string; skillInstanceId: string }> }
59
+ skill_businesscase_delta { subType: "roi-analysis" | "stakeholder-brief" | "vendor-comparison"; delta: string } // append per subType
60
+ skill_solutiondesign_header { header: { title: string; summary: string } }
61
+ skill_solutiondesign_diagram { nodes: SolutionDesignNode[]; edges: { id: string; source: string; target: string; label?: string }[] }
62
+ skill_end { skillInstanceId?: string } // persisted deliverable id
63
+ ```
64
+
65
+ Key payload types:
66
+
67
+ ```ts
68
+ type SkillResponseTypeType = "answer" | "howto" | "diagnose" | "recommend" | "playlist" | "businesscase" | "solutiondesign";
69
+ interface GuideDocument { id: string; title: string; tag?: string; icon?: string; url?: string; contentType?: string; body?: string; thumbnailUrl?: string; citationIndex?: number } // citationIndex matches [N] in reply text
70
+ interface SkillRecommendation { id: string; skill: SkillResponseTypeType; label: string; prompt: string } // pill → ask(prompt, { forcedSkill: skill })
71
+ interface ContentRecommendation { id: string; targetSkill: string; title: string; rationale: string; prompt: string; confidence: "high" | "medium" | "low" } // card → ask(prompt, { forcedSkill: targetSkill })
72
+ type SkillCTA = { kind: "url"; label; href; bannerText?; ... } | { kind: "form"; label; formId; ... } | { kind: "product"; label; href; productId; ... } | { kind: "scheduler"; label; url; provider: "hubspot" | "calendly"; ... };
73
+ interface DiagnosticHeader { targetTopic: string; subtitle: string; questionCount: number; subtype?: "standalone" | "context_gathering"; followUpSkill?: string }
74
+ interface DiagnosticQuestion { id: string; category: string; questionText: string; options: { label: "A" | "B" | "C" | "D"; text: string; score?: number }[]; prerequisiteConceptId: string | null; prerequisiteConceptName: string | null } // single-choice self-assessment, no correct answer
75
+ interface HowToStep { id: string; title: string; description: string; icon?: string; content: { contentId: string; title: string; url: string | null; excerpt: string; thumbnailUrl?: string } | null }
76
+ interface PlaylistItem { id: string; title: string; description: string | null; contentType: string; url: string | null; thumbnailUrl?: string; rationale?: string }
77
+ interface SolutionDesignNode { id: string; label: string; description: string; nodeType: "process" | "decision" | "datastore" | "external" | "trigger" | "start" | "end"; icon: string } // icon = Lucide name
78
+ ```
79
+
80
+ ## GuideSkillTurn (pre-reduced render state)
81
+
82
+ `useGuide().skillTurn`, or fold manually: `turn = reduceSkillEvent(turn ?? createSkillTurn(), event)` for `event.type === "skill"`.
83
+
84
+ ```ts
85
+ interface GuideSkillTurn {
86
+ skill: SkillResponseTypeType | null; status: "streaming" | "complete"; messageId: string | null;
87
+ diagnoseSubtype: "standalone" | "context_gathering" | null; followUpSkill: string | null;
88
+ content: string; // accumulated markdown body
89
+ documents: GuideDocument[]; // answer: source/citation cards
90
+ contentRecommendations: ContentRecommendation[]; // recommend: cards
91
+ followUpRecommendations: SkillRecommendation[]; // answer: follow-up pills
92
+ ctas: SkillCTA[]; // render first only, below response
93
+ diagnoseHeader: DiagnosticHeader | null; diagnoseQuestions: DiagnosticQuestion[]; // stepper UI
94
+ playlistHeader: { title; description } | null; playlistItems: PlaylistItem[]; // content cards
95
+ howToOutline: { title; steps: { id; title }[] } | null; howToSteps: HowToStep[]; // numbered steps; outline renders first as skeleton
96
+ businessCaseFormats: ...[]; businessCaseDrafts: Record<string, ...>; // format chooser cards
97
+ businessCaseContent: Record<string, string>; // per-subType markdown, render as it streams
98
+ solutionDesignHeader: { title; summary } | null; solutionDesignNodes: SolutionDesignNode[]; solutionDesignEdges: ...[]; // flowchart
99
+ skillInstanceId: string | null; // for client.skills.get/update
100
+ }
101
+ ```
102
+
103
+ UI guidance per skill: `answer` → streamed text + source cards + follow-up pills; `howto` → numbered step checklist (outline first, details replace); `recommend` → recommendation cards with confidence badge; `playlist` → media/content card list; `diagnose` → one-question-at-a-time stepper (self-assessment, no correct answers; standalone mode ⇒ score results per category at the end); `businesscase` → format chooser, then streamed markdown document per chosen format (send the chosen format's label as the next ask); `solutiondesign` → flowchart of nodes/edges (or grouped node list fallback).
104
+
105
+ ## Email gate
106
+
107
+ A `gate` event means the guide requires an email to finish the turn (the stream ends). Flow: collect email → `submitGate(email)` (gate context auto-attached in the hook) → if `needsVerification` tell the user to check their inbox, else `retryLastAsk()`.
108
+
109
+ ## Theming
110
+
111
+ `guide.theme` (`BrandKitTheme`) = optional CSS color strings (hex/oklch), shadcn-style tokens: `primary`, `primaryForeground`, `secondary`, `secondaryForeground`, `background`, `foreground`, `muted`, `mutedForeground`, `border`, `input`, `ring`. Map onto your CSS variables/Tailwind theme. Also: `guide.logoUrl`, `guide.faviconUrl`, `guide.heroImageUrl`, `guide.customization` (copy overrides: `welcomeMessage`, `chatInputPlaceholders`, `suggestionsTitle`, …), `guide.skillIconStyle`.
112
+
113
+ ## GuideClient API
114
+
115
+ `guide` (ResolvedGuide) · `ask(message, { forcedSkill?, signal? })` · `messages` / `reset()` · `setConsent({ functional?, analytical? })` / `getStatus()` · `submitGate(email, options?)` · `track(eventType, properties?)` / `trackPageView(url?)` (call on SPA route changes) · `endSession()` (call on pagehide; the hook handles it) · `conversations.getOrCreate()/list()/get(id)/delete(id)` · `skills.listHistory()/get(id)/update(id, data)/selectDraft(id)/markShared(id)/importShared(id)`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cms.ai/brand_brain",
3
- "version": "0.0.1",
3
+ "version": "0.1.0",
4
4
  "license": "MIT",
5
5
  "description": "Client-side SDK for building CMS.ai Brand Brain (Guide) experiences into your own website UI.",
6
6
  "type": "module",
@@ -12,7 +12,8 @@
12
12
  },
13
13
  "files": [
14
14
  "dist",
15
- "src"
15
+ "src",
16
+ "llms.txt"
16
17
  ],
17
18
  "main": "./dist/index.cjs",
18
19
  "types": "./dist/index.d.cts",
@@ -65,10 +66,10 @@
65
66
  "react-dom": "^19",
66
67
  "typescript": "^5.9",
67
68
  "vitest": "^4.1.6",
68
- "@navless/eslint-config": "0.0.1",
69
69
  "@navless/orpc": "0.0.19",
70
- "@navless/typescript-config": "0.0.1",
71
- "@navless/types": "0.0.34"
70
+ "@navless/eslint-config": "0.0.1",
71
+ "@navless/types": "0.0.34",
72
+ "@navless/typescript-config": "0.0.1"
72
73
  },
73
74
  "scripts": {
74
75
  "build": "tsdown",
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { hostnameFromBaseUrl } from "./GuideClient";
3
+
4
+ describe("hostnameFromBaseUrl", () => {
5
+ it("returns the bare host from a branded origin", () => {
6
+ expect(hostnameFromBaseUrl("https://guide.acme.com")).toBe("guide.acme.com");
7
+ });
8
+
9
+ it("ignores the appended /api/v3 path", () => {
10
+ expect(hostnameFromBaseUrl("https://guide.acme.com/api/v3")).toBe("guide.acme.com");
11
+ });
12
+
13
+ it("strips a leading www. so it matches the stored domain", () => {
14
+ expect(hostnameFromBaseUrl("https://www.acme.com")).toBe("acme.com");
15
+ });
16
+
17
+ it("drops the port", () => {
18
+ expect(hostnameFromBaseUrl("http://localhost:3000")).toBe("localhost");
19
+ });
20
+
21
+ it("lowercases the host", () => {
22
+ expect(hostnameFromBaseUrl("https://Guide.Acme.com")).toBe("guide.acme.com");
23
+ });
24
+
25
+ it("accepts a bare host without a protocol", () => {
26
+ expect(hostnameFromBaseUrl("guide.acme.com")).toBe("guide.acme.com");
27
+ });
28
+ });
@@ -43,10 +43,27 @@ interface SkillsApi {
43
43
  importShared(sourceSkillInstanceId: string): Promise<ImportSharedResult>;
44
44
  }
45
45
 
46
+ /**
47
+ * The account is resolved by the branded host the SDK talks to, and that host
48
+ * must be same-site with `baseUrl` for the visitor cookies to attach — so the
49
+ * `baseUrl` hostname IS the registered account domain. Derive it from there
50
+ * rather than the host page's `window.location`, which is an unrelated origin
51
+ * for cross-site embeds and produces a "Domain not found" 404 on resolve.
52
+ */
46
53
  function resolveDomain(config: InitGuideConfig): string {
47
54
  if (config.domain) return config.domain;
48
- if (typeof window !== "undefined") return window.location.hostname;
49
- throw new Error("[brand_brain] `domain` is required when running without a browser `window`.");
55
+ return hostnameFromBaseUrl(config.baseUrl);
56
+ }
57
+
58
+ /**
59
+ * Normalize `baseUrl` to the bare host the server stores: lowercased, no port,
60
+ * no leading `www.` (the domain write-path strips it, so a `www.` host can
61
+ * never match). Accepts a bare host too, in case `baseUrl` omits the protocol.
62
+ */
63
+ export function hostnameFromBaseUrl(baseUrl: string): string {
64
+ const withProtocol = /^[a-z][a-z0-9+.-]*:\/\//i.test(baseUrl) ? baseUrl : `https://${baseUrl}`;
65
+ const hostname = new URL(withProtocol).hostname.toLowerCase();
66
+ return hostname.replace(/^www\./, "");
50
67
  }
51
68
 
52
69
  interface GuideClientParams {
@@ -81,7 +98,11 @@ export class GuideClient {
81
98
  }
82
99
 
83
100
  static async init(config: InitGuideConfig): Promise<GuideClient> {
84
- const fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
101
+ const globalFetch = typeof globalThis.fetch === "function" ? globalThis.fetch.bind(globalThis) : undefined;
102
+ const fetchImpl = config.fetch ?? globalFetch;
103
+ if (!fetchImpl) {
104
+ throw new Error("[brand_brain] No `fetch` is available — pass `config.fetch` when running without a global fetch.");
105
+ }
85
106
  const apiBaseUrl = toApiBaseUrl(config.baseUrl);
86
107
  const domain = resolveDomain(config);
87
108
 
@@ -52,4 +52,32 @@ describe("streamConversation", () => {
52
52
  const failing: typeof globalThis.fetch = () => Promise.resolve(new Response(null, { status: 500 }));
53
53
  await expect(collect("", { fetchImpl: failing })).rejects.toThrow("status 500");
54
54
  });
55
+
56
+ it("maps a known custom frame to a typed skill event", async () => {
57
+ const documents = [{ id: "d1", title: "Pricing guide", url: "https://acme.com/pricing" }];
58
+ const events = await collect(frame({ type: "CUSTOM", name: "skill_documents", value: { documents } }) + DONE);
59
+ expect(events).toContainEqual({ type: "skill", name: "skill_documents", value: { documents } });
60
+ });
61
+
62
+ it("maps an unrecognized custom frame to a skill-unknown event", async () => {
63
+ const events = await collect(frame({ type: "CUSTOM", name: "skill_new_thing", value: { foo: 1 } }) + DONE);
64
+ expect(events).toContainEqual({ type: "skill-unknown", name: "skill_new_thing", value: { foo: 1 } });
65
+ });
66
+
67
+ it("carries retryable and retryHint on a skill_error frame", async () => {
68
+ const events = await collect(
69
+ frame({
70
+ type: "CUSTOM",
71
+ name: "skill_error",
72
+ value: { errorType: "skill_timeout", message: "Timed out", retryable: true, retryHint: { prompt: "try again" } },
73
+ }) + DONE,
74
+ );
75
+ expect(events).toContainEqual({
76
+ type: "error",
77
+ message: "Timed out",
78
+ code: "skill_timeout",
79
+ retryable: true,
80
+ retryHint: { prompt: "try again" },
81
+ });
82
+ });
55
83
  });
@@ -1,5 +1,5 @@
1
1
  import { GuideEvent, SSEMessageType } from "./types";
2
- import type { ChatMessage, GuideEventName, GuideStreamEvent, SkillResponseTypeType } from "./types";
2
+ import type { ChatMessage, GuideSkillEvent, GuideStreamEvent, SkillErrorRetryHint, SkillResponseTypeType } from "./types";
3
3
 
4
4
  export interface StreamConversationParams {
5
5
  apiBaseUrl: string;
@@ -50,7 +50,15 @@ export async function* streamConversation(params: StreamConversationParams): Asy
50
50
  });
51
51
 
52
52
  if (!response.ok || !response.body) {
53
- throw new Error(`[brand_brain] Conversation request failed with status ${response.status}`);
53
+ // Surface the server's error body (e.g. `{ "error": "guideId is required" }`)
54
+ // so integration failures are debuggable rather than just a bare status.
55
+ let detail = "";
56
+ try {
57
+ detail = (await response.text()).trim();
58
+ } catch {
59
+ // ignore — the body may be empty or already consumed
60
+ }
61
+ throw new Error(`[brand_brain] Conversation request failed with status ${response.status}${detail ? `: ${detail}` : ""}`);
54
62
  }
55
63
 
56
64
  const reader = response.body.getReader();
@@ -105,6 +113,16 @@ function mapFrame(frame: SSEFrame): GuideStreamEvent | null {
105
113
  }
106
114
  }
107
115
 
116
+ /**
117
+ * Names that surface as typed `skill` events. `SkillGate`/`SkillError` are
118
+ * re-mapped to the `gate`/`error` variants below, so they are excluded here;
119
+ * any name outside this set is a server event this SDK version predates and
120
+ * flows through as `skill-unknown`.
121
+ */
122
+ const SKILL_EVENT_NAMES: ReadonlySet<string> = new Set(
123
+ Object.values(GuideEvent).filter((name) => name !== GuideEvent.SkillGate && name !== GuideEvent.SkillError),
124
+ );
125
+
108
126
  function mapCustomFrame(frame: SSEFrame): GuideStreamEvent | null {
109
127
  if (!frame.name) return null;
110
128
  const value = frame.value ?? {};
@@ -124,7 +142,14 @@ function mapCustomFrame(frame: SSEFrame): GuideStreamEvent | null {
124
142
  type: "error",
125
143
  message: (value.message as string | undefined) ?? "Skill error",
126
144
  code: value.errorType as string | undefined,
145
+ retryable: value.retryable as boolean | undefined,
146
+ retryHint: value.retryHint as SkillErrorRetryHint | undefined,
127
147
  };
128
148
  }
129
- return { type: "skill", name: frame.name as GuideEventName, value };
149
+ if (SKILL_EVENT_NAMES.has(frame.name)) {
150
+ // Wire-boundary assertion: membership in SKILL_EVENT_NAMES pins `name` to a
151
+ // known skill event, and toSSE writes the payload shape the union declares.
152
+ return { type: "skill", name: frame.name, value } as GuideSkillEvent;
153
+ }
154
+ return { type: "skill-unknown", name: frame.name, value };
130
155
  }
package/src/index.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export { GuideClient, initGuide } from "./GuideClient";
2
+ export { createSkillTurn, reduceSkillEvent } from "./skill-state";
3
+ export type { GuideSkillTurn } from "./skill-state";
2
4
  export * from "./types";
@@ -1,2 +1,2 @@
1
1
  export { useGuide } from "./useGuide";
2
- export type { UseGuideResult } from "./useGuide";
2
+ export type { GateRequest, UseGuideConfig, UseGuideResult } from "./useGuide";
@@ -2,7 +2,34 @@
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
4
  import { initGuide, type GuideClient } from "../GuideClient";
5
- import type { AskOptions, ChatMessage, InitGuideConfig, ResolvedGuide } from "../types";
5
+ import { createSkillTurn, reduceSkillEvent, type GuideSkillTurn } from "../skill-state";
6
+ import type {
7
+ AskOptions,
8
+ ChatMessage,
9
+ GuideStreamEvent,
10
+ InitGuideConfig,
11
+ ResolvedGuide,
12
+ SkillResponseTypeType,
13
+ SubmitGateOptions,
14
+ SubmitGateResult,
15
+ } from "../types";
16
+
17
+ export interface UseGuideConfig extends InitGuideConfig {
18
+ /**
19
+ * Called with every raw stream event, in order — the escape hatch for
20
+ * anything the hook's derived state doesn't cover.
21
+ */
22
+ onEvent?: (event: GuideStreamEvent) => void;
23
+ }
24
+
25
+ /** A pending email gate — the guide wants an email before finishing the turn. */
26
+ export interface GateRequest {
27
+ /** The skill that was gated. */
28
+ skill: SkillResponseTypeType;
29
+ /** The visitor prompt that triggered the gate (re-sent after unlocking). */
30
+ prompt?: string;
31
+ skillInstanceId?: string;
32
+ }
6
33
 
7
34
  export interface UseGuideResult {
8
35
  /** The client once bootstrapped, else `null`. */
@@ -11,41 +38,77 @@ export interface UseGuideResult {
11
38
  guide: ResolvedGuide | null;
12
39
  /** Conversation history, kept in sync with the client. */
13
40
  messages: ChatMessage[];
41
+ /**
42
+ * Structured deliverable of the current / most recent turn (documents,
43
+ * recommendations, CTAs, how-to steps, …). `null` before the first ask.
44
+ */
45
+ skillTurn: GuideSkillTurn | null;
46
+ /**
47
+ * Set when the guide asks for an email mid-turn. Render an email form, call
48
+ * `submitGate(email)`, then `retryLastAsk()` to finish the interrupted turn.
49
+ */
50
+ gate: GateRequest | null;
14
51
  /** `true` once the client has finished initializing. */
15
52
  isReady: boolean;
16
53
  /** `true` while a reply is streaming. */
17
54
  isStreaming: boolean;
18
55
  /** Init or stream error, else `null`. */
19
56
  error: Error | null;
20
- /** Send a message and stream the reply into `messages`. */
57
+ /** Send a message and stream the reply into `messages` / `skillTurn`. */
21
58
  ask: (message: string, options?: AskOptions) => Promise<void>;
59
+ /**
60
+ * Submit the visitor's email — for the pending `gate` (its context is merged
61
+ * in automatically) or as general lead capture. Clears `gate` on success.
62
+ */
63
+ submitGate: (email: string, options?: SubmitGateOptions) => Promise<SubmitGateResult>;
64
+ /** Re-send the last user message, e.g. to finish a gated turn after `submitGate`. */
65
+ retryLastAsk: () => Promise<void>;
22
66
  /** Clear the in-memory conversation. */
23
67
  reset: () => void;
24
68
  }
25
69
 
26
70
  /**
27
71
  * React binding over {@link initGuide}. Bootstraps once per `baseUrl`+`slug`,
28
- * mirrors the streamed reply into state, and ends the session on unmount —
72
+ * mirrors the streamed reply into `messages`, folds structured skill events
73
+ * into `skillTurn`, surfaces email gates, and ends the session on unmount —
29
74
  * encapsulating the streaming/cleanup patterns so generated UIs stay correct.
30
75
  */
31
- export function useGuide(config: InitGuideConfig): UseGuideResult {
76
+ export function useGuide(config: UseGuideConfig): UseGuideResult {
32
77
  const [client, setClient] = useState<GuideClient | null>(null);
33
78
  const [messages, setMessages] = useState<ChatMessage[]>([]);
79
+ const [skillTurn, setSkillTurn] = useState<GuideSkillTurn | null>(null);
80
+ const [gate, setGate] = useState<GateRequest | null>(null);
34
81
  const [isStreaming, setIsStreaming] = useState(false);
35
82
  const [error, setError] = useState<Error | null>(null);
36
83
 
37
84
  const { baseUrl, slug } = config;
38
85
  const configRef = useRef(config);
39
86
  configRef.current = config;
87
+ // The turn state lives in a ref (not just React state) so the reducer can
88
+ // fold consecutive events synchronously and business-case continuations can
89
+ // read the previous turn without a re-render in between.
90
+ const turnRef = useRef<GuideSkillTurn | null>(null);
91
+ const lastAskRef = useRef<{ message: string; options?: AskOptions } | null>(null);
40
92
 
41
93
  useEffect(() => {
42
94
  let active = true;
43
95
  let created: GuideClient | null = null;
44
96
 
97
+ // Clear any prior guide's client + state so a baseUrl/slug change doesn't
98
+ // leave callers talking to the old guide (or rendering its messages) while
99
+ // the new one resolves.
100
+ setClient(null);
101
+ setMessages([]);
102
+ setSkillTurn(null);
103
+ setGate(null);
104
+ setError(null);
105
+ turnRef.current = null;
106
+ lastAskRef.current = null;
107
+
45
108
  void initGuide(configRef.current)
46
109
  .then((instance) => {
47
110
  if (!active) {
48
- void instance.endSession();
111
+ void instance.endSession().catch(() => {});
49
112
  return;
50
113
  }
51
114
  created = instance;
@@ -58,19 +121,30 @@ export function useGuide(config: InitGuideConfig): UseGuideResult {
58
121
 
59
122
  return () => {
60
123
  active = false;
61
- void created?.endSession();
124
+ void created?.endSession().catch(() => {});
62
125
  };
63
126
  }, [baseUrl, slug]);
64
127
 
65
128
  const ask = useCallback(
66
129
  async (message: string, options?: AskOptions) => {
67
130
  if (!client) return;
131
+ lastAskRef.current = { message, options };
68
132
  setIsStreaming(true);
69
133
  setError(null);
134
+ // A new ask supersedes a pending gate for the previous turn.
135
+ setGate(null);
70
136
  try {
71
137
  for await (const event of client.ask(message, options)) {
138
+ configRef.current.onEvent?.(event);
72
139
  if (event.type === "text" || event.type === "message-start") {
73
140
  setMessages([...client.messages]);
141
+ } else if (event.type === "skill") {
142
+ turnRef.current = reduceSkillEvent(turnRef.current ?? createSkillTurn(), event);
143
+ setSkillTurn(turnRef.current);
144
+ } else if (event.type === "gate") {
145
+ setGate({ skill: event.skill, prompt: event.prompt, skillInstanceId: event.skillInstanceId });
146
+ } else if (event.type === "error") {
147
+ setError(new Error(event.message));
74
148
  }
75
149
  }
76
150
  setMessages([...client.messages]);
@@ -83,19 +157,46 @@ export function useGuide(config: InitGuideConfig): UseGuideResult {
83
157
  [client],
84
158
  );
85
159
 
160
+ const submitGate = useCallback(
161
+ async (email: string, options?: SubmitGateOptions): Promise<SubmitGateResult> => {
162
+ if (!client) throw new Error("[brand_brain] Guide is not ready yet — wait for isReady before submitting the gate.");
163
+ const pending = gate;
164
+ const result = await client.submitGate(email, {
165
+ ...(pending && { skill: pending.skill, prompt: pending.prompt, skillInstanceId: pending.skillInstanceId }),
166
+ ...options,
167
+ });
168
+ if (result.success) setGate(null);
169
+ return result;
170
+ },
171
+ [client, gate],
172
+ );
173
+
174
+ const retryLastAsk = useCallback(async () => {
175
+ const last = lastAskRef.current;
176
+ if (last) await ask(last.message, last.options);
177
+ }, [ask]);
178
+
86
179
  const reset = useCallback(() => {
87
180
  client?.reset();
88
181
  setMessages([]);
182
+ setSkillTurn(null);
183
+ setGate(null);
184
+ turnRef.current = null;
185
+ lastAskRef.current = null;
89
186
  }, [client]);
90
187
 
91
188
  return {
92
189
  client,
93
190
  guide: client?.guide ?? null,
94
191
  messages,
192
+ skillTurn,
193
+ gate,
95
194
  isReady: client !== null,
96
195
  isStreaming,
97
196
  error,
98
197
  ask,
198
+ submitGate,
199
+ retryLastAsk,
99
200
  reset,
100
201
  };
101
202
  }
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createSkillTurn, reduceSkillEvent, type GuideSkillTurn } from "./skill-state";
3
+ import { GuideEvent } from "./types";
4
+ import type { GuideSkillEvent, SkillResponseTypeType } from "./types";
5
+
6
+ function reduceAll(events: GuideSkillEvent[], initial: GuideSkillTurn = createSkillTurn()): GuideSkillTurn {
7
+ return events.reduce(reduceSkillEvent, initial);
8
+ }
9
+
10
+ const start = (skill: SkillResponseTypeType): GuideSkillEvent => ({
11
+ type: "skill",
12
+ name: GuideEvent.SkillStart,
13
+ value: { skill, messageId: "m1" },
14
+ });
15
+
16
+ describe("reduceSkillEvent", () => {
17
+ it("captures the running skill from skill_start", () => {
18
+ const turn = reduceAll([start("answer")]);
19
+ expect(turn.skill).toBe("answer");
20
+ });
21
+
22
+ it("accumulates skill_content deltas in order", () => {
23
+ const turn = reduceAll([
24
+ start("answer"),
25
+ { type: "skill", name: GuideEvent.SkillContent, value: { delta: "Hello " } },
26
+ { type: "skill", name: GuideEvent.SkillContent, value: { delta: "world" } },
27
+ ]);
28
+ expect(turn.content).toBe("Hello world");
29
+ });
30
+
31
+ it("replaces documents with the latest skill_documents payload", () => {
32
+ const turn = reduceAll([
33
+ start("answer"),
34
+ { type: "skill", name: GuideEvent.SkillDocuments, value: { documents: [{ id: "d1", title: "Old" }] } },
35
+ { type: "skill", name: GuideEvent.SkillDocuments, value: { documents: [{ id: "d2", title: "New" }] } },
36
+ ]);
37
+ expect(turn.documents).toEqual([{ id: "d2", title: "New" }]);
38
+ });
39
+
40
+ it("routes recommendations with targetSkill to contentRecommendations", () => {
41
+ const recommendation = {
42
+ id: "r1",
43
+ targetSkill: "howto" as const,
44
+ title: "Set it up",
45
+ rationale: "You asked about setup",
46
+ prompt: "How do I set it up?",
47
+ confidence: "high" as const,
48
+ };
49
+ const turn = reduceAll([
50
+ start("recommend"),
51
+ { type: "skill", name: GuideEvent.SkillRecommendations, value: { recommendations: [recommendation] } },
52
+ ]);
53
+ expect(turn.contentRecommendations).toEqual([recommendation]);
54
+ });
55
+
56
+ it("routes recommendations without targetSkill to followUpRecommendations", () => {
57
+ const pill = { id: "r1", skill: "howto" as const, label: "Show me how", prompt: "Show me how" };
58
+ const turn = reduceAll([
59
+ start("answer"),
60
+ { type: "skill", name: GuideEvent.SkillRecommendations, value: { recommendations: [pill] } },
61
+ ]);
62
+ expect(turn.followUpRecommendations).toEqual([pill]);
63
+ });
64
+
65
+ it("appends diagnose questions across events", () => {
66
+ const question = (id: string) => ({
67
+ id,
68
+ category: "Fit",
69
+ questionText: `Q${id}`,
70
+ options: [],
71
+ prerequisiteConceptId: null,
72
+ prerequisiteConceptName: null,
73
+ });
74
+ const turn = reduceAll([
75
+ start("diagnose"),
76
+ { type: "skill", name: GuideEvent.SkillDiagnoseQuestion, value: { question: question("q1") } },
77
+ { type: "skill", name: GuideEvent.SkillDiagnoseQuestion, value: { question: question("q2") } },
78
+ ]);
79
+ expect(turn.diagnoseQuestions.map((q) => q.id)).toEqual(["q1", "q2"]);
80
+ });
81
+
82
+ it("patches a playlist item's rationale by itemId", () => {
83
+ const item = { id: "p1", title: "Video", description: null, contentType: "video_youtube" as const, url: null };
84
+ const turn = reduceAll([
85
+ start("playlist"),
86
+ { type: "skill", name: GuideEvent.SkillPlaylistItems, value: { items: [item] } },
87
+ { type: "skill", name: GuideEvent.SkillPlaylistRationale, value: { itemId: "p1", rationale: "Because reasons" } },
88
+ ]);
89
+ expect(turn.playlistItems[0].rationale).toBe("Because reasons");
90
+ });
91
+
92
+ it("accumulates business case deltas per subType", () => {
93
+ const turn = reduceAll([
94
+ start("businesscase"),
95
+ { type: "skill", name: GuideEvent.SkillBusinessCaseDelta, value: { subType: "roi-analysis", delta: "# ROI" } },
96
+ { type: "skill", name: GuideEvent.SkillBusinessCaseDelta, value: { subType: "roi-analysis", delta: " body" } },
97
+ ]);
98
+ expect(turn.businessCaseContent["roi-analysis"]).toBe("# ROI body");
99
+ });
100
+
101
+ it("preserves business case state across a continuation skill_start", () => {
102
+ const chooser: GuideSkillEvent = {
103
+ type: "skill",
104
+ name: GuideEvent.SkillBusinessCaseChooser,
105
+ value: { formats: [], drafts: { "roi-analysis": { draftId: "d1", skillInstanceId: "s1" } } },
106
+ };
107
+ const turn = reduceAll([start("businesscase"), chooser, start("businesscase")]);
108
+ expect(turn.businessCaseDrafts["roi-analysis"]).toEqual({ draftId: "d1", skillInstanceId: "s1" });
109
+ });
110
+
111
+ it("resets prior state when a different skill starts", () => {
112
+ const turn = reduceAll([
113
+ start("answer"),
114
+ { type: "skill", name: GuideEvent.SkillDocuments, value: { documents: [{ id: "d1", title: "Doc" }] } },
115
+ start("howto"),
116
+ ]);
117
+ expect(turn.documents).toEqual([]);
118
+ });
119
+
120
+ it("marks the turn complete with its skillInstanceId on skill_end", () => {
121
+ const turn = reduceAll([start("howto"), { type: "skill", name: GuideEvent.SkillEnd, value: { skillInstanceId: "si-1" } }]);
122
+ expect(turn).toMatchObject({ status: "complete", skillInstanceId: "si-1" });
123
+ });
124
+
125
+ it("captures the early skillInstanceId from skill_howto_outline", () => {
126
+ const turn = reduceAll([
127
+ start("howto"),
128
+ {
129
+ type: "skill",
130
+ name: GuideEvent.SkillHowToOutline,
131
+ value: { outline: { title: "Setup", steps: [] }, skillInstanceId: "si-early" },
132
+ },
133
+ ]);
134
+ expect(turn.skillInstanceId).toBe("si-early");
135
+ });
136
+
137
+ it("falls back to the legacy skillResponseId alias on skill_end", () => {
138
+ const turn = reduceAll([
139
+ start("howto"),
140
+ { type: "skill", name: GuideEvent.SkillEnd, value: { skillResponseId: "si-legacy" } },
141
+ ]);
142
+ expect(turn.skillInstanceId).toBe("si-legacy");
143
+ });
144
+ });