@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/README.md +165 -38
- package/dist/index.cjs +272 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +560 -16
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +560 -16
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +261 -10
- package/dist/index.mjs.map +1 -1
- package/dist/react/index.cjs +261 -13
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +579 -20
- package/dist/react/index.d.cts.map +1 -1
- package/dist/react/index.d.mts +579 -20
- package/dist/react/index.d.mts.map +1 -1
- package/dist/react/index.mjs +261 -13
- package/dist/react/index.mjs.map +1 -1
- package/llms.txt +115 -0
- package/package.json +6 -5
- package/src/GuideClient.test.ts +28 -0
- package/src/GuideClient.ts +24 -3
- package/src/chat-stream.test.ts +28 -0
- package/src/chat-stream.ts +28 -3
- package/src/index.ts +2 -0
- package/src/react/index.ts +1 -1
- package/src/react/useGuide.ts +107 -6
- package/src/skill-state.test.ts +144 -0
- package/src/skill-state.ts +206 -0
- package/src/types.ts +504 -17
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
|
|
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/
|
|
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
|
+
});
|
package/src/GuideClient.ts
CHANGED
|
@@ -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
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|
package/src/chat-stream.test.ts
CHANGED
|
@@ -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
|
});
|
package/src/chat-stream.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { GuideEvent, SSEMessageType } from "./types";
|
|
2
|
-
import type { ChatMessage,
|
|
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
|
-
|
|
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
|
-
|
|
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
package/src/react/index.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { useGuide } from "./useGuide";
|
|
2
|
-
export type { UseGuideResult } from "./useGuide";
|
|
2
|
+
export type { GateRequest, UseGuideConfig, UseGuideResult } from "./useGuide";
|
package/src/react/useGuide.ts
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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
|
+
});
|