@cms.ai/brand_brain 0.0.1
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 +95 -0
- package/dist/index.cjs +1748 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +395 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.mts +395 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1741 -0
- package/dist/index.mjs.map +1 -0
- package/dist/react/index.cjs +1789 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.d.cts +387 -0
- package/dist/react/index.d.cts.map +1 -0
- package/dist/react/index.d.mts +387 -0
- package/dist/react/index.d.mts.map +1 -0
- package/dist/react/index.mjs +1788 -0
- package/dist/react/index.mjs.map +1 -0
- package/package.json +80 -0
- package/src/GuideClient.ts +239 -0
- package/src/analytics.test.ts +25 -0
- package/src/analytics.ts +36 -0
- package/src/api.ts +26 -0
- package/src/chat-stream.test.ts +55 -0
- package/src/chat-stream.ts +130 -0
- package/src/index.ts +2 -0
- package/src/react/index.ts +2 -0
- package/src/react/useGuide.ts +101 -0
- package/src/types.ts +316 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import type { LiveHttpClient } from "@navless/orpc/live/client";
|
|
2
|
+
import { createApiClient, toApiBaseUrl } from "./api";
|
|
3
|
+
import { extractIntegrationCookies, extractUTMParams, safeCookieString } from "./analytics";
|
|
4
|
+
import { streamConversation } from "./chat-stream";
|
|
5
|
+
import { EmbedTrackingEvent } from "./types";
|
|
6
|
+
import type {
|
|
7
|
+
AskOptions,
|
|
8
|
+
ChatMessage,
|
|
9
|
+
ConsentLevels,
|
|
10
|
+
Conversation,
|
|
11
|
+
ConversationWithMessages,
|
|
12
|
+
GuideStreamEvent,
|
|
13
|
+
ImportSharedResult,
|
|
14
|
+
InitGuideConfig,
|
|
15
|
+
MarkSharedResult,
|
|
16
|
+
ResolvedGuide,
|
|
17
|
+
SelectDraftResult,
|
|
18
|
+
SkillHistoryGroup,
|
|
19
|
+
SkillInstance,
|
|
20
|
+
SubmitGateOptions,
|
|
21
|
+
SubmitGateResult,
|
|
22
|
+
TrackEventType,
|
|
23
|
+
UpdateSkillInstanceResult,
|
|
24
|
+
VisitorStatus,
|
|
25
|
+
} from "./types";
|
|
26
|
+
|
|
27
|
+
// Explicit shapes for the grouped API namespaces, annotated with locally-authored
|
|
28
|
+
// types rather than the deep @orpc/client-derived client types — so the published
|
|
29
|
+
// .d.ts stays self-contained and never inlines an external type across chunks.
|
|
30
|
+
interface ConversationsApi {
|
|
31
|
+
getOrCreate(): Promise<Conversation>;
|
|
32
|
+
list(): Promise<Conversation[]>;
|
|
33
|
+
get(conversationId: string, options?: { cursor?: string; limit?: number }): Promise<ConversationWithMessages>;
|
|
34
|
+
delete(conversationId: string): Promise<Conversation>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface SkillsApi {
|
|
38
|
+
listHistory(): Promise<SkillHistoryGroup[]>;
|
|
39
|
+
get(skillInstanceId: string): Promise<SkillInstance>;
|
|
40
|
+
update(skillInstanceId: string, data: Record<string, unknown>): Promise<UpdateSkillInstanceResult>;
|
|
41
|
+
selectDraft(draftId: string): Promise<SelectDraftResult>;
|
|
42
|
+
markShared(skillInstanceId: string): Promise<MarkSharedResult>;
|
|
43
|
+
importShared(sourceSkillInstanceId: string): Promise<ImportSharedResult>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveDomain(config: InitGuideConfig): string {
|
|
47
|
+
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`.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface GuideClientParams {
|
|
53
|
+
guide: ResolvedGuide;
|
|
54
|
+
apiBaseUrl: string;
|
|
55
|
+
domain: string;
|
|
56
|
+
fetchImpl: typeof globalThis.fetch;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The client returned by {@link initGuide}. Holds the resolved guide, the
|
|
61
|
+
* credentialed live API client, and the in-memory conversation history. Every
|
|
62
|
+
* method reuses an existing public endpoint — the SDK adds no server surface.
|
|
63
|
+
*/
|
|
64
|
+
export class GuideClient {
|
|
65
|
+
/** Resolved guide metadata (ids, theme, customization). */
|
|
66
|
+
readonly guide: ResolvedGuide;
|
|
67
|
+
|
|
68
|
+
private readonly apiBaseUrl: string;
|
|
69
|
+
private readonly domain: string;
|
|
70
|
+
private readonly fetchImpl: typeof globalThis.fetch;
|
|
71
|
+
// ECMAScript-private so the heavy LiveHttpClient type never reaches the .d.ts.
|
|
72
|
+
#api: LiveHttpClient;
|
|
73
|
+
private readonly history: ChatMessage[] = [];
|
|
74
|
+
|
|
75
|
+
private constructor(params: GuideClientParams) {
|
|
76
|
+
this.guide = params.guide;
|
|
77
|
+
this.apiBaseUrl = params.apiBaseUrl;
|
|
78
|
+
this.domain = params.domain;
|
|
79
|
+
this.fetchImpl = params.fetchImpl;
|
|
80
|
+
this.#api = createApiClient(this.apiBaseUrl, this.fetchImpl);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
static async init(config: InitGuideConfig): Promise<GuideClient> {
|
|
84
|
+
const fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
85
|
+
const apiBaseUrl = toApiBaseUrl(config.baseUrl);
|
|
86
|
+
const domain = resolveDomain(config);
|
|
87
|
+
|
|
88
|
+
// First credentialed call — resolves the guide AND mints the visitor +
|
|
89
|
+
// session cookies via the live router middleware.
|
|
90
|
+
const api = createApiClient(apiBaseUrl, fetchImpl);
|
|
91
|
+
const guide = await api.guides.resolve({ domain, slug: config.slug });
|
|
92
|
+
const client = new GuideClient({ guide, apiBaseUrl, domain, fetchImpl });
|
|
93
|
+
|
|
94
|
+
await client.applyInitialConsent(config.consent ?? "auto", guide.gdprEnabled);
|
|
95
|
+
if (config.trackLoad ?? true) client.trackLoad();
|
|
96
|
+
|
|
97
|
+
return client;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The account this guide belongs to (used implicitly by history calls). */
|
|
101
|
+
get accountId(): string {
|
|
102
|
+
return this.guide.accountId;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The in-memory conversation history accumulated by {@link ask}. */
|
|
106
|
+
get messages(): readonly ChatMessage[] {
|
|
107
|
+
return this.history;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Send a message and stream the reply. Yields text deltas for the chat bubble
|
|
112
|
+
* plus every structured skill event; the full 7-skill surface flows through
|
|
113
|
+
* one stream. The user + assistant turns are appended to {@link messages}.
|
|
114
|
+
*/
|
|
115
|
+
async *ask(message: string, options: AskOptions = {}): AsyncGenerator<GuideStreamEvent> {
|
|
116
|
+
this.history.push({ id: crypto.randomUUID(), role: "user", content: message });
|
|
117
|
+
|
|
118
|
+
let assistant: ChatMessage | null = null;
|
|
119
|
+
for await (const event of streamConversation({
|
|
120
|
+
apiBaseUrl: this.apiBaseUrl,
|
|
121
|
+
guideId: this.guide.id,
|
|
122
|
+
messages: this.history,
|
|
123
|
+
forcedSkill: options.forcedSkill,
|
|
124
|
+
fetchImpl: this.fetchImpl,
|
|
125
|
+
signal: options.signal,
|
|
126
|
+
})) {
|
|
127
|
+
if (event.type === "message-start") {
|
|
128
|
+
assistant = { id: event.messageId || crypto.randomUUID(), role: "assistant", content: "" };
|
|
129
|
+
this.history.push(assistant);
|
|
130
|
+
} else if (event.type === "text" && assistant) {
|
|
131
|
+
assistant.content += event.delta;
|
|
132
|
+
}
|
|
133
|
+
yield event;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Clear the in-memory conversation history (server-side history is unaffected). */
|
|
138
|
+
reset(): void {
|
|
139
|
+
this.history.length = 0;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Grant consent tiers. `essential` is always forced true server-side. */
|
|
143
|
+
async setConsent(levels: ConsentLevels): Promise<void> {
|
|
144
|
+
await this.#api.visitors.setConsent({ domain: this.domain, consent: levels });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Read the current visitor's tracking + consent + email status. */
|
|
148
|
+
getStatus(): Promise<VisitorStatus> {
|
|
149
|
+
return this.#api.visitors.getVisitorStatus();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Capture a lead email (sends a magic link when verification is required). */
|
|
153
|
+
submitGate(email: string, options: SubmitGateOptions = {}): Promise<SubmitGateResult> {
|
|
154
|
+
return this.#api.visitors.submitGate({ email, domain: this.domain, guideId: this.guide.id, ...options });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Fire an analytics event. Fire-and-forget — never throws into the host page.
|
|
159
|
+
* Uses a raw request (the same shape the embed script posts) so it stays
|
|
160
|
+
* decoupled from the typed client's event enum.
|
|
161
|
+
*/
|
|
162
|
+
track(eventType: TrackEventType, properties?: Record<string, unknown>): void {
|
|
163
|
+
void this.fetchImpl(`${this.apiBaseUrl}/events/track`, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: { "Content-Type": "application/json" },
|
|
166
|
+
credentials: "include",
|
|
167
|
+
body: JSON.stringify({ json: { domain: this.domain, guideId: this.guide.id, eventType, properties: properties ?? {} } }),
|
|
168
|
+
}).catch(() => {
|
|
169
|
+
// Best-effort — analytics must not break the host page.
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Fire the initial `embed_loaded` (with attribution) + first page view. */
|
|
174
|
+
trackLoad(): void {
|
|
175
|
+
const search = typeof window !== "undefined" ? window.location.search : "";
|
|
176
|
+
const parentUrl = typeof window !== "undefined" ? window.location.href : undefined;
|
|
177
|
+
const referrer = typeof document !== "undefined" ? document.referrer : undefined;
|
|
178
|
+
this.track(EmbedTrackingEvent.EmbedLoaded, {
|
|
179
|
+
referrer,
|
|
180
|
+
parentUrl,
|
|
181
|
+
utmParams: extractUTMParams(search),
|
|
182
|
+
integrationCookies: extractIntegrationCookies(safeCookieString()),
|
|
183
|
+
});
|
|
184
|
+
this.trackPageView();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Record a page view (call on client-side route changes in an SPA). */
|
|
188
|
+
trackPageView(url?: string): void {
|
|
189
|
+
const pageUrl = url ?? (typeof window !== "undefined" ? window.location.href : undefined);
|
|
190
|
+
this.track(EmbedTrackingEvent.EmbedPageView, { pageUrl });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** End the current visit (best to call on `pagehide`). */
|
|
194
|
+
async endSession(): Promise<void> {
|
|
195
|
+
await this.#api.sessions.endSession();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Conversation history CRUD, scoped to this guide + account. */
|
|
199
|
+
readonly conversations: ConversationsApi = {
|
|
200
|
+
getOrCreate: () => this.#api.conversations.getOrCreate({ guideId: this.guide.id, accountId: this.guide.accountId }),
|
|
201
|
+
list: () => this.#api.conversations.list({ guideId: this.guide.id, accountId: this.guide.accountId }),
|
|
202
|
+
get: (conversationId: string, options?: { cursor?: string; limit?: number }) =>
|
|
203
|
+
this.#api.conversations.get({ conversationId, accountId: this.guide.accountId, ...options }),
|
|
204
|
+
delete: (conversationId: string) => this.#api.conversations.delete({ conversationId, accountId: this.guide.accountId }),
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** Persisted skill deliverables (how-to progress, diagnose answers, etc.). */
|
|
208
|
+
readonly skills: SkillsApi = {
|
|
209
|
+
listHistory: () => this.#api.skillInstances.listHistory({ accountId: this.guide.accountId, guideId: this.guide.id }),
|
|
210
|
+
get: (skillInstanceId: string) => this.#api.skillInstances.get({ skillInstanceId }),
|
|
211
|
+
update: (skillInstanceId: string, data: Record<string, unknown>) =>
|
|
212
|
+
this.#api.skillInstances.update({ skillInstanceId, data }),
|
|
213
|
+
selectDraft: (draftId: string) => this.#api.skillInstances.selectDraft({ draftId }),
|
|
214
|
+
markShared: (skillInstanceId: string) => this.#api.skillInstances.markShared({ skillInstanceId }),
|
|
215
|
+
importShared: (sourceSkillInstanceId: string) =>
|
|
216
|
+
this.#api.skillInstances.importShared({ sourceSkillInstanceId, accountId: this.guide.accountId }),
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
private async applyInitialConsent(consent: "auto" | ConsentLevels | false, gdprEnabled: boolean): Promise<void> {
|
|
220
|
+
if (consent === false) return;
|
|
221
|
+
try {
|
|
222
|
+
if (consent === "auto") {
|
|
223
|
+
// Non-GDPR guides auto-grant everything (mirrors the live app). GDPR
|
|
224
|
+
// guides wait for an explicit setConsent() from the host.
|
|
225
|
+
if (!gdprEnabled) await this.setConsent({ functional: true, analytical: true });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
await this.setConsent(consent);
|
|
229
|
+
} catch {
|
|
230
|
+
// Best-effort at init — the host can retry via setConsent() and inspect
|
|
231
|
+
// getStatus(). Don't fail init on a consent hiccup.
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Bootstrap a guide session and return a ready-to-use {@link GuideClient}. */
|
|
237
|
+
export async function initGuide(config: InitGuideConfig): Promise<GuideClient> {
|
|
238
|
+
return GuideClient.init(config);
|
|
239
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { extractIntegrationCookies, extractUTMParams } from "./analytics";
|
|
3
|
+
|
|
4
|
+
describe("extractUTMParams", () => {
|
|
5
|
+
it("returns the utm params present in the query string", () => {
|
|
6
|
+
expect(extractUTMParams("?utm_source=google&utm_medium=cpc")).toEqual({
|
|
7
|
+
utm_source: "google",
|
|
8
|
+
utm_medium: "cpc",
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("returns an empty object when no utm params are present", () => {
|
|
13
|
+
expect(extractUTMParams("?q=hello")).toEqual({});
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("extractIntegrationCookies", () => {
|
|
18
|
+
it("extracts the hubspotutk value from a cookie string", () => {
|
|
19
|
+
expect(extractIntegrationCookies("foo=1; hubspotutk=abc123; bar=2")).toEqual({ hubspotutk: "abc123" });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns an empty object when no integration cookies are present", () => {
|
|
23
|
+
expect(extractIntegrationCookies("foo=1; bar=2")).toEqual({});
|
|
24
|
+
});
|
|
25
|
+
});
|
package/src/analytics.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const UTM_KEYS = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"] as const;
|
|
2
|
+
const INTEGRATION_COOKIE_KEYS = ["hubspotutk", "_mkto_trk"] as const;
|
|
3
|
+
|
|
4
|
+
/** Pull UTM attribution params out of a `location.search` string. */
|
|
5
|
+
export function extractUTMParams(search: string): Record<string, string> {
|
|
6
|
+
const params = new URLSearchParams(search);
|
|
7
|
+
const result: Record<string, string> = {};
|
|
8
|
+
for (const key of UTM_KEYS) {
|
|
9
|
+
const value = params.get(key);
|
|
10
|
+
if (value) result[key] = value;
|
|
11
|
+
}
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Pull first-party integration cookies (HubSpot, Marketo) out of a
|
|
17
|
+
* `document.cookie` string so they can be forwarded for CRM stitching.
|
|
18
|
+
*/
|
|
19
|
+
export function extractIntegrationCookies(cookieString: string): Record<string, string> {
|
|
20
|
+
const cookies: Record<string, string> = {};
|
|
21
|
+
for (const key of INTEGRATION_COOKIE_KEYS) {
|
|
22
|
+
const match = cookieString.match(new RegExp(`(?:^|;\\s*)${key}=([^;]*)`));
|
|
23
|
+
if (match?.[1]) cookies[key] = decodeURIComponent(match[1]);
|
|
24
|
+
}
|
|
25
|
+
return cookies;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Read `document.cookie`, swallowing the SecurityError thrown in opaque origins. */
|
|
29
|
+
export function safeCookieString(): string {
|
|
30
|
+
if (typeof document === "undefined") return "";
|
|
31
|
+
try {
|
|
32
|
+
return document.cookie;
|
|
33
|
+
} catch {
|
|
34
|
+
return "";
|
|
35
|
+
}
|
|
36
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createLiveHttpClient, type LiveHttpClient } from "@navless/orpc/live/client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Normalize a branded-host origin into the `/api/v3` base the live API is served
|
|
5
|
+
* under. Accepts a bare origin ("https://guide.acme.com") or a URL that already
|
|
6
|
+
* ends with `/api/v3`.
|
|
7
|
+
*/
|
|
8
|
+
export function toApiBaseUrl(baseUrl: string): string {
|
|
9
|
+
const trimmed = baseUrl.replace(/\/+$/, "");
|
|
10
|
+
return trimmed.endsWith("/api/v3") ? trimmed : `${trimmed}/api/v3`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Wrap a fetch implementation so every request carries the visitor cookies.
|
|
15
|
+
* This is the load-bearing piece: the branded host is same-site with the
|
|
16
|
+
* customer's page, so `credentials: "include"` lets the existing
|
|
17
|
+
* `navless-live-*` cookies attach in every browser.
|
|
18
|
+
*/
|
|
19
|
+
export function withCredentials(fetchImpl: typeof globalThis.fetch): typeof globalThis.fetch {
|
|
20
|
+
return (input, init) => fetchImpl(input, { ...init, credentials: "include" });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Build the typed live oRPC client, reusing @navless/orpc's client factory. */
|
|
24
|
+
export function createApiClient(apiBaseUrl: string, fetchImpl: typeof globalThis.fetch): LiveHttpClient {
|
|
25
|
+
return createLiveHttpClient({ baseUrl: apiBaseUrl, fetch: withCredentials(fetchImpl) });
|
|
26
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { streamConversation, type StreamConversationParams } from "./chat-stream";
|
|
3
|
+
import type { GuideStreamEvent } from "./types";
|
|
4
|
+
|
|
5
|
+
function sseFetch(payload: string): typeof globalThis.fetch {
|
|
6
|
+
return () => {
|
|
7
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
8
|
+
start(controller) {
|
|
9
|
+
controller.enqueue(new TextEncoder().encode(payload));
|
|
10
|
+
controller.close();
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
return Promise.resolve(new Response(stream, { status: 200 }));
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function collect(payload: string, overrides: Partial<StreamConversationParams> = {}): Promise<GuideStreamEvent[]> {
|
|
18
|
+
const events: GuideStreamEvent[] = [];
|
|
19
|
+
const stream = streamConversation({
|
|
20
|
+
apiBaseUrl: "https://guide.example.com/api/v3",
|
|
21
|
+
guideId: "guide-1",
|
|
22
|
+
messages: [{ id: "1", role: "user", content: "hi" }],
|
|
23
|
+
fetchImpl: sseFetch(payload),
|
|
24
|
+
...overrides,
|
|
25
|
+
});
|
|
26
|
+
for await (const event of stream) events.push(event);
|
|
27
|
+
return events;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const frame = (obj: Record<string, unknown>): string => `data: ${JSON.stringify(obj)}\n\n`;
|
|
31
|
+
const DONE = "data: [DONE]\n\n";
|
|
32
|
+
|
|
33
|
+
describe("streamConversation", () => {
|
|
34
|
+
it("yields a text event for a TEXT_MESSAGE_CONTENT frame", async () => {
|
|
35
|
+
const events = await collect(frame({ type: "TEXT_MESSAGE_CONTENT", messageId: "m1", delta: "Hello" }) + DONE);
|
|
36
|
+
expect(events).toContainEqual({ type: "text", messageId: "m1", delta: "Hello" });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("maps a skill_gate custom frame to a gate event", async () => {
|
|
40
|
+
const events = await collect(
|
|
41
|
+
frame({ type: "CUSTOM", name: "skill_gate", value: { skill: "businesscase", prompt: "email?" } }) + DONE,
|
|
42
|
+
);
|
|
43
|
+
expect(events).toContainEqual({ type: "gate", skill: "businesscase", prompt: "email?", skillInstanceId: undefined });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("stops yielding at the [DONE] sentinel", async () => {
|
|
47
|
+
const events = await collect(DONE + frame({ type: "TEXT_MESSAGE_CONTENT", messageId: "m1", delta: "late" }));
|
|
48
|
+
expect(events).toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("throws when the response is not ok", async () => {
|
|
52
|
+
const failing: typeof globalThis.fetch = () => Promise.resolve(new Response(null, { status: 500 }));
|
|
53
|
+
await expect(collect("", { fetchImpl: failing })).rejects.toThrow("status 500");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { GuideEvent, SSEMessageType } from "./types";
|
|
2
|
+
import type { ChatMessage, GuideEventName, GuideStreamEvent, SkillResponseTypeType } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface StreamConversationParams {
|
|
5
|
+
apiBaseUrl: string;
|
|
6
|
+
guideId: string;
|
|
7
|
+
messages: ChatMessage[];
|
|
8
|
+
forcedSkill?: SkillResponseTypeType;
|
|
9
|
+
fetchImpl: typeof globalThis.fetch;
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Raw SSE frame as written by the API's `toSSE` adapter. */
|
|
14
|
+
interface SSEFrame {
|
|
15
|
+
type: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
value?: Record<string, unknown>;
|
|
18
|
+
delta?: string;
|
|
19
|
+
messageId?: string;
|
|
20
|
+
role?: string;
|
|
21
|
+
runId?: string;
|
|
22
|
+
finishReason?: string;
|
|
23
|
+
error?: { message: string; code?: string };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DATA_PREFIX = "data:";
|
|
27
|
+
const DONE_SENTINEL = "[DONE]";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Stream a guide chat turn. POSTs the conversation to the SSE route
|
|
31
|
+
* (`/api/v3/guide/conversation`) and yields normalized {@link GuideStreamEvent}s
|
|
32
|
+
* until the server writes `data: [DONE]`. This is the one endpoint that is not
|
|
33
|
+
* part of the typed oRPC router, so it is spoken by hand here.
|
|
34
|
+
*/
|
|
35
|
+
export async function* streamConversation(params: StreamConversationParams): AsyncGenerator<GuideStreamEvent> {
|
|
36
|
+
const { apiBaseUrl, guideId, messages, forcedSkill, fetchImpl, signal } = params;
|
|
37
|
+
|
|
38
|
+
const body: Record<string, unknown> = {
|
|
39
|
+
guideId,
|
|
40
|
+
messages: messages.map((m) => ({ role: m.role, content: m.content })),
|
|
41
|
+
};
|
|
42
|
+
if (forcedSkill) body.forcedSkill = forcedSkill;
|
|
43
|
+
|
|
44
|
+
const response = await fetchImpl(`${apiBaseUrl}/guide/conversation`, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: { "Content-Type": "application/json" },
|
|
47
|
+
credentials: "include",
|
|
48
|
+
body: JSON.stringify(body),
|
|
49
|
+
signal,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
if (!response.ok || !response.body) {
|
|
53
|
+
throw new Error(`[brand_brain] Conversation request failed with status ${response.status}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const reader = response.body.getReader();
|
|
57
|
+
const decoder = new TextDecoder();
|
|
58
|
+
let buffer = "";
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
for (;;) {
|
|
62
|
+
const { done, value } = await reader.read();
|
|
63
|
+
if (done) break;
|
|
64
|
+
buffer += decoder.decode(value, { stream: true });
|
|
65
|
+
|
|
66
|
+
let separatorIndex = buffer.indexOf("\n\n");
|
|
67
|
+
while (separatorIndex !== -1) {
|
|
68
|
+
const rawEvent = buffer.slice(0, separatorIndex);
|
|
69
|
+
buffer = buffer.slice(separatorIndex + 2);
|
|
70
|
+
separatorIndex = buffer.indexOf("\n\n");
|
|
71
|
+
|
|
72
|
+
const dataLine = rawEvent.split("\n").find((line) => line.startsWith(DATA_PREFIX));
|
|
73
|
+
if (!dataLine) continue;
|
|
74
|
+
|
|
75
|
+
const data = dataLine.slice(DATA_PREFIX.length).trim();
|
|
76
|
+
if (data === DONE_SENTINEL) return;
|
|
77
|
+
|
|
78
|
+
const mapped = mapFrame(JSON.parse(data) as SSEFrame);
|
|
79
|
+
if (mapped) yield mapped;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} finally {
|
|
83
|
+
reader.releaseLock();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function mapFrame(frame: SSEFrame): GuideStreamEvent | null {
|
|
88
|
+
switch (frame.type) {
|
|
89
|
+
case SSEMessageType.RunStarted:
|
|
90
|
+
return { type: "run-started", runId: frame.runId ?? "" };
|
|
91
|
+
case SSEMessageType.RunFinished:
|
|
92
|
+
return { type: "run-finished", finishReason: frame.finishReason };
|
|
93
|
+
case SSEMessageType.RunError:
|
|
94
|
+
return { type: "error", message: frame.error?.message ?? "Unknown error", code: frame.error?.code };
|
|
95
|
+
case SSEMessageType.TextMessageStart:
|
|
96
|
+
return { type: "message-start", messageId: frame.messageId ?? "", role: frame.role ?? "assistant" };
|
|
97
|
+
case SSEMessageType.TextMessageContent:
|
|
98
|
+
return { type: "text", messageId: frame.messageId ?? "", delta: frame.delta ?? "" };
|
|
99
|
+
case SSEMessageType.TextMessageEnd:
|
|
100
|
+
return { type: "message-end", messageId: frame.messageId ?? "" };
|
|
101
|
+
case SSEMessageType.Custom:
|
|
102
|
+
return mapCustomFrame(frame);
|
|
103
|
+
default:
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function mapCustomFrame(frame: SSEFrame): GuideStreamEvent | null {
|
|
109
|
+
if (!frame.name) return null;
|
|
110
|
+
const value = frame.value ?? {};
|
|
111
|
+
|
|
112
|
+
// Wire-boundary reads: `value` is untyped JSON from the SSE stream, so the
|
|
113
|
+
// fields below are asserted against the shapes the API's toSSE adapter writes.
|
|
114
|
+
if (frame.name === GuideEvent.SkillGate) {
|
|
115
|
+
return {
|
|
116
|
+
type: "gate",
|
|
117
|
+
skill: value.skill as SkillResponseTypeType,
|
|
118
|
+
prompt: value.prompt as string | undefined,
|
|
119
|
+
skillInstanceId: value.skillInstanceId as string | undefined,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (frame.name === GuideEvent.SkillError) {
|
|
123
|
+
return {
|
|
124
|
+
type: "error",
|
|
125
|
+
message: (value.message as string | undefined) ?? "Skill error",
|
|
126
|
+
code: value.errorType as string | undefined,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
return { type: "skill", name: frame.name as GuideEventName, value };
|
|
130
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { initGuide, type GuideClient } from "../GuideClient";
|
|
5
|
+
import type { AskOptions, ChatMessage, InitGuideConfig, ResolvedGuide } from "../types";
|
|
6
|
+
|
|
7
|
+
export interface UseGuideResult {
|
|
8
|
+
/** The client once bootstrapped, else `null`. */
|
|
9
|
+
client: GuideClient | null;
|
|
10
|
+
/** Resolved guide metadata once ready, else `null`. */
|
|
11
|
+
guide: ResolvedGuide | null;
|
|
12
|
+
/** Conversation history, kept in sync with the client. */
|
|
13
|
+
messages: ChatMessage[];
|
|
14
|
+
/** `true` once the client has finished initializing. */
|
|
15
|
+
isReady: boolean;
|
|
16
|
+
/** `true` while a reply is streaming. */
|
|
17
|
+
isStreaming: boolean;
|
|
18
|
+
/** Init or stream error, else `null`. */
|
|
19
|
+
error: Error | null;
|
|
20
|
+
/** Send a message and stream the reply into `messages`. */
|
|
21
|
+
ask: (message: string, options?: AskOptions) => Promise<void>;
|
|
22
|
+
/** Clear the in-memory conversation. */
|
|
23
|
+
reset: () => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* React binding over {@link initGuide}. Bootstraps once per `baseUrl`+`slug`,
|
|
28
|
+
* mirrors the streamed reply into state, and ends the session on unmount —
|
|
29
|
+
* encapsulating the streaming/cleanup patterns so generated UIs stay correct.
|
|
30
|
+
*/
|
|
31
|
+
export function useGuide(config: InitGuideConfig): UseGuideResult {
|
|
32
|
+
const [client, setClient] = useState<GuideClient | null>(null);
|
|
33
|
+
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
|
34
|
+
const [isStreaming, setIsStreaming] = useState(false);
|
|
35
|
+
const [error, setError] = useState<Error | null>(null);
|
|
36
|
+
|
|
37
|
+
const { baseUrl, slug } = config;
|
|
38
|
+
const configRef = useRef(config);
|
|
39
|
+
configRef.current = config;
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
let active = true;
|
|
43
|
+
let created: GuideClient | null = null;
|
|
44
|
+
|
|
45
|
+
void initGuide(configRef.current)
|
|
46
|
+
.then((instance) => {
|
|
47
|
+
if (!active) {
|
|
48
|
+
void instance.endSession();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
created = instance;
|
|
52
|
+
setClient(instance);
|
|
53
|
+
setError(null);
|
|
54
|
+
})
|
|
55
|
+
.catch((err: unknown) => {
|
|
56
|
+
if (active) setError(err instanceof Error ? err : new Error(String(err)));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return () => {
|
|
60
|
+
active = false;
|
|
61
|
+
void created?.endSession();
|
|
62
|
+
};
|
|
63
|
+
}, [baseUrl, slug]);
|
|
64
|
+
|
|
65
|
+
const ask = useCallback(
|
|
66
|
+
async (message: string, options?: AskOptions) => {
|
|
67
|
+
if (!client) return;
|
|
68
|
+
setIsStreaming(true);
|
|
69
|
+
setError(null);
|
|
70
|
+
try {
|
|
71
|
+
for await (const event of client.ask(message, options)) {
|
|
72
|
+
if (event.type === "text" || event.type === "message-start") {
|
|
73
|
+
setMessages([...client.messages]);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
setMessages([...client.messages]);
|
|
77
|
+
} catch (err: unknown) {
|
|
78
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
79
|
+
} finally {
|
|
80
|
+
setIsStreaming(false);
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
[client],
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const reset = useCallback(() => {
|
|
87
|
+
client?.reset();
|
|
88
|
+
setMessages([]);
|
|
89
|
+
}, [client]);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
client,
|
|
93
|
+
guide: client?.guide ?? null,
|
|
94
|
+
messages,
|
|
95
|
+
isReady: client !== null,
|
|
96
|
+
isStreaming,
|
|
97
|
+
error,
|
|
98
|
+
ask,
|
|
99
|
+
reset,
|
|
100
|
+
};
|
|
101
|
+
}
|