@sales-bot-llm/sdk 0.2.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/biome.json +36 -0
- package/docs/superpowers/plans/2026-05-08-sales-bot-sdk-plan.md +258 -0
- package/docs/superpowers/plans/2026-05-11-w3-sales-tool-polish-plan.md +476 -0
- package/docs/superpowers/specs/2026-05-08-sales-bot-sdk-design.md +587 -0
- package/example/.env.example +5 -0
- package/example/README.md +90 -0
- package/example/index.html +12 -0
- package/example/package.json +27 -0
- package/example/public/vanilla.global.js +345 -0
- package/example/src/App.tsx +50 -0
- package/example/src/main.tsx +16 -0
- package/example/src/routes/HookDemo.tsx +174 -0
- package/example/src/routes/VanillaDemo.tsx +67 -0
- package/example/src/routes/WidgetDemo.tsx +55 -0
- package/example/src/styles.css +18 -0
- package/example/tsconfig.json +19 -0
- package/example/tsconfig.tsbuildinfo +1 -0
- package/example/vite.config.ts +4 -0
- package/package.json +106 -0
- package/pnpm-workspace.yaml +3 -0
- package/src/core/client.ts +245 -0
- package/src/core/conversation.ts +34 -0
- package/src/core/index.ts +6 -0
- package/src/core/sse-parser.ts +87 -0
- package/src/core/storage.ts +72 -0
- package/src/core/transport.ts +271 -0
- package/src/core/types.ts +314 -0
- package/src/core/visitor.ts +21 -0
- package/src/react/index.ts +2 -0
- package/src/react/use-sales-bot.tsx +182 -0
- package/src/vanilla/index.ts +38 -0
- package/src/vue/index.ts +2 -0
- package/src/vue/use-sales-bot.ts +152 -0
- package/src/widget/index.ts +3 -0
- package/src/widget/markdown.ts +69 -0
- package/src/widget/styles.ts +350 -0
- package/src/widget/widget.ts +442 -0
- package/tests/contract/wire-format.test.ts +158 -0
- package/tests/core/client.test.ts +292 -0
- package/tests/core/conversation.test.ts +41 -0
- package/tests/core/sse-parser.test.ts +142 -0
- package/tests/core/storage.test.ts +78 -0
- package/tests/core/transport.test.ts +204 -0
- package/tests/core/visitor.test.ts +42 -0
- package/tests/react/use-sales-bot.test.tsx +188 -0
- package/tests/sales-tool-discriminator.test.ts +45 -0
- package/tests/setup.ts +3 -0
- package/tests/vanilla/vanilla.test.ts +37 -0
- package/tests/vue/use-sales-bot.test.ts +163 -0
- package/tests/widget/markdown.test.ts +113 -0
- package/tests/widget/widget.test.ts +388 -0
- package/tsconfig.json +28 -0
- package/tsup.config.ts +38 -0
- package/vitest.config.ts +26 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
# Sales Bot — Sub-project #2: Frontend SDK Design
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-05-08
|
|
4
|
+
**Status:** Approved (drives Phase 2-N implementation)
|
|
5
|
+
**Author:** Claude (autonomous build, delegated by Artem Zaitsev)
|
|
6
|
+
**Scope:** Sub-project #2 of 5. Consumes the locked wire format from sub-project #1.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 1. Goals & Non-Goals
|
|
11
|
+
|
|
12
|
+
### 1.1 Goals
|
|
13
|
+
|
|
14
|
+
- **Framework-agnostic TypeScript core** — a single `SalesBotClient` class that any framework adapter can wrap.
|
|
15
|
+
- **React adapter** — `useSalesBot` hook, compatible with React 18 + Next.js App Router.
|
|
16
|
+
- **Vue adapter** — `useSalesBot` Composition API composable, Vue 3.4+.
|
|
17
|
+
- **Vanilla / IIFE adapter** — `window.SalesBot.init(...)` for plain `<script>` embed tags.
|
|
18
|
+
- **Floating widget** — a self-contained shadow-DOM chat widget, zero host-page CSS bleed.
|
|
19
|
+
- **Type-safe wire format** — TypeScript types verbatim from the backend's `sse-events.ts`; contract tests assert no drift.
|
|
20
|
+
- **localStorage visitor-token persistence** — keyed per embed-key, with in-memory fallback for SSR/Node.
|
|
21
|
+
- **SSE streaming consumer** — parses `event: <name>\ndata: <json>\n\n` frames from `fetch` responses.
|
|
22
|
+
- **v1 ships ESM + CJS + IIFE bundles** — via `tsup`, all in one package.
|
|
23
|
+
|
|
24
|
+
### 1.2 Non-Goals (v1)
|
|
25
|
+
|
|
26
|
+
- Voice channel / WebRTC (sub-project #4)
|
|
27
|
+
- File uploads
|
|
28
|
+
- Multi-bot management UI
|
|
29
|
+
- Server-side rendering of chat history
|
|
30
|
+
- Deep theme customization beyond CSS custom-properties
|
|
31
|
+
- Admin dashboard
|
|
32
|
+
- Publishing to npm (local-only builds)
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## 2. Repo Layout
|
|
37
|
+
|
|
38
|
+
Single package (`@sales-bot/sdk`) with multiple entry points. This avoids version skew between adapters and halves dependency management overhead compared to a monorepo.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
sales_bot_sdk/
|
|
42
|
+
├── src/
|
|
43
|
+
│ ├── core/
|
|
44
|
+
│ │ ├── types.ts ← SalesBotEvent union, SalesBotError, wire types
|
|
45
|
+
│ │ ├── sse-parser.ts ← ReadableStream → AsyncIterable<SalesBotEvent>
|
|
46
|
+
│ │ ├── transport.ts ← fetch-based HTTP layer
|
|
47
|
+
│ │ ├── storage.ts ← Storage interface + adapters
|
|
48
|
+
│ │ ├── visitor.ts ← visitor token management
|
|
49
|
+
│ │ ├── client.ts ← SalesBotClient class
|
|
50
|
+
│ │ └── index.ts ← re-exports for ./core entry
|
|
51
|
+
│ ├── react/
|
|
52
|
+
│ │ ├── use-sales-bot.tsx ← useSalesBot hook
|
|
53
|
+
│ │ └── index.ts
|
|
54
|
+
│ ├── vue/
|
|
55
|
+
│ │ ├── use-sales-bot.ts ← useSalesBot composable
|
|
56
|
+
│ │ └── index.ts
|
|
57
|
+
│ ├── vanilla/
|
|
58
|
+
│ │ ├── index.ts ← window.SalesBot.init
|
|
59
|
+
│ │ └── iife-wrapper.ts
|
|
60
|
+
│ └── widget/
|
|
61
|
+
│ ├── widget.ts ← shadow DOM floating widget
|
|
62
|
+
│ ├── styles.ts ← CSS-in-JS injected into shadow root
|
|
63
|
+
│ └── index.ts
|
|
64
|
+
├── tests/
|
|
65
|
+
│ ├── core/
|
|
66
|
+
│ │ ├── sse-parser.test.ts
|
|
67
|
+
│ │ ├── transport.test.ts
|
|
68
|
+
│ │ ├── storage.test.ts
|
|
69
|
+
│ │ ├── visitor.test.ts
|
|
70
|
+
│ │ └── client.test.ts
|
|
71
|
+
│ ├── react/
|
|
72
|
+
│ │ └── use-sales-bot.test.tsx
|
|
73
|
+
│ ├── vue/
|
|
74
|
+
│ │ └── use-sales-bot.test.ts
|
|
75
|
+
│ ├── widget/
|
|
76
|
+
│ │ └── widget.test.ts
|
|
77
|
+
│ └── contract/
|
|
78
|
+
│ └── wire-format.test.ts ← asserts SDK types match backend
|
|
79
|
+
├── docs/
|
|
80
|
+
├── tsup.config.ts
|
|
81
|
+
├── tsconfig.json
|
|
82
|
+
├── vitest.config.ts
|
|
83
|
+
├── package.json
|
|
84
|
+
└── biome.json
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## 3. Public API
|
|
90
|
+
|
|
91
|
+
### 3.1 Core class
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
class SalesBotClient {
|
|
95
|
+
constructor(opts: SalesBotClientOptions)
|
|
96
|
+
|
|
97
|
+
// Identify the current visitor. Merged into the next ask() call.
|
|
98
|
+
identify(traits: IdentifyInput): void
|
|
99
|
+
|
|
100
|
+
// Start a new agent turn. Returns an AsyncIterable and also emits events
|
|
101
|
+
// on the client's event bus.
|
|
102
|
+
ask(message: string, opts?: AskOptions): AsyncIterable<SalesBotEvent>
|
|
103
|
+
|
|
104
|
+
// Re-attach to an in-flight or completed turn by turnId.
|
|
105
|
+
resume(turnId: string): AsyncIterable<SalesBotEvent>
|
|
106
|
+
|
|
107
|
+
// Visitor token — a UUID stable across page reloads.
|
|
108
|
+
getVisitorToken(): string
|
|
109
|
+
|
|
110
|
+
// Active conversation ID (set after turn_started, cleared on reset).
|
|
111
|
+
getConversationId(): string | null
|
|
112
|
+
setConversationId(id: string | null): void
|
|
113
|
+
|
|
114
|
+
// Event-bus interface (alternative to async iterable).
|
|
115
|
+
on(event: 'delta', handler: (e: DeltaEvent) => void): Unsubscribe
|
|
116
|
+
on(event: 'message_complete', handler: (e: MessageCompleteEvent) => void): Unsubscribe
|
|
117
|
+
on(event: 'turn_started', handler: (e: TurnStartedEvent) => void): Unsubscribe
|
|
118
|
+
on(event: 'done', handler: (e: DoneEvent) => void): Unsubscribe
|
|
119
|
+
on(event: 'error', handler: (e: ErrorEvent) => void): Unsubscribe
|
|
120
|
+
on(event: 'tool_call_started', handler: (e: ToolCallStartedEvent) => void): Unsubscribe
|
|
121
|
+
on(event: 'tool_call_finished', handler: (e: ToolCallFinishedEvent) => void): Unsubscribe
|
|
122
|
+
on(event: 'usage', handler: (e: UsageEventData) => void): Unsubscribe
|
|
123
|
+
on(event: string, handler: (e: unknown) => void): Unsubscribe
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### 3.2 Options
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
interface SalesBotClientOptions {
|
|
131
|
+
embedKey: string // pk_live_<32 chars>
|
|
132
|
+
baseUrl?: string // default: 'http://localhost:3000'
|
|
133
|
+
storage?: StorageAdapter // default: LocalStorageAdapter (with MemoryStorageAdapter fallback)
|
|
134
|
+
customHeaders?: Record<string, string> // for Node: { Origin: 'https://...' }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface AskOptions {
|
|
138
|
+
conversationId?: string
|
|
139
|
+
metadata?: Record<string, unknown>
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface IdentifyInput {
|
|
143
|
+
externalId?: string
|
|
144
|
+
email?: string
|
|
145
|
+
name?: string
|
|
146
|
+
traits?: Record<string, unknown>
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### 3.3 Error class
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
class SalesBotError extends Error {
|
|
154
|
+
code: SalesBotErrorCode
|
|
155
|
+
retryable: boolean
|
|
156
|
+
details?: Record<string, unknown>
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
type SalesBotErrorCode =
|
|
160
|
+
| 'invalid_embed_key'
|
|
161
|
+
| 'origin_not_allowed'
|
|
162
|
+
| 'rate_limited'
|
|
163
|
+
| 'out_of_credits'
|
|
164
|
+
| 'unauthorized'
|
|
165
|
+
| 'forbidden'
|
|
166
|
+
| 'not_found'
|
|
167
|
+
| 'bad_request'
|
|
168
|
+
| 'unprocessable_entity'
|
|
169
|
+
| 'conflict'
|
|
170
|
+
| 'internal'
|
|
171
|
+
| 'llm_unavailable'
|
|
172
|
+
| 'mcp_unavailable'
|
|
173
|
+
| 'network_error' // SDK-only: fetch failed
|
|
174
|
+
| 'parse_error' // SDK-only: SSE frame malformed
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### 3.4 React adapter
|
|
178
|
+
|
|
179
|
+
```typescript
|
|
180
|
+
// Returns stable references; re-renders only on state changes.
|
|
181
|
+
function useSalesBot(opts: UseSalesBotOptions): UseSalesBotResult
|
|
182
|
+
|
|
183
|
+
interface UseSalesBotOptions extends SalesBotClientOptions {
|
|
184
|
+
// Optional initial conversationId (e.g. restored from URL hash).
|
|
185
|
+
conversationId?: string
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface UseSalesBotResult {
|
|
189
|
+
ask: (message: string, opts?: AskOptions) => Promise<void>
|
|
190
|
+
messages: Message[] // [{role, content, id?}]
|
|
191
|
+
isStreaming: boolean
|
|
192
|
+
error: SalesBotError | null
|
|
193
|
+
conversationId: string | null
|
|
194
|
+
reset: () => void // clears messages + conversationId
|
|
195
|
+
}
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### 3.5 Vue adapter
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
function useSalesBot(opts: UseSalesBotOptions): {
|
|
202
|
+
ask: (message: string, opts?: AskOptions) => Promise<void>
|
|
203
|
+
messages: Ref<Message[]>
|
|
204
|
+
isStreaming: Ref<boolean>
|
|
205
|
+
error: Ref<SalesBotError | null>
|
|
206
|
+
conversationId: Ref<string | null>
|
|
207
|
+
reset: () => void
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### 3.6 Vanilla / IIFE
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
// Exposed as window.SalesBot
|
|
215
|
+
interface SalesBotGlobal {
|
|
216
|
+
init(opts: SalesBotClientOptions): SalesBotClient
|
|
217
|
+
// Convenience: open the floating widget
|
|
218
|
+
widget(opts: WidgetOptions): WidgetInstance
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### 3.7 Widget API
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
interface WidgetOptions extends SalesBotClientOptions {
|
|
226
|
+
container?: HTMLElement // default: document.body
|
|
227
|
+
position?: 'bottom-right' | 'bottom-left' // default: 'bottom-right'
|
|
228
|
+
title?: string // default: 'Chat with us'
|
|
229
|
+
placeholder?: string // default: 'Type a message...'
|
|
230
|
+
primaryColor?: string // overrides --sb-primary CSS var
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
interface WidgetInstance {
|
|
234
|
+
open(): void
|
|
235
|
+
close(): void
|
|
236
|
+
toggle(): void
|
|
237
|
+
destroy(): void
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## 4. SSE Parser
|
|
244
|
+
|
|
245
|
+
The parser is a pure function consuming `ReadableStream<Uint8Array>` and yielding typed `SalesBotEvent` objects.
|
|
246
|
+
|
|
247
|
+
Wire format (per backend contract):
|
|
248
|
+
```
|
|
249
|
+
event: turn_started\ndata: {"turnId":"...","conversationId":"...","endUserId":"..."}\n\n
|
|
250
|
+
event: delta\ndata: {"content":"Hello"}\n\n
|
|
251
|
+
event: done\ndata: {"turnId":"..."}\n\n
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Algorithm:
|
|
255
|
+
1. TextDecoder chunk-by-chunk from the readable stream.
|
|
256
|
+
2. Buffer accumulates text; split on `\n\n` for complete frames.
|
|
257
|
+
3. Each frame: extract `event:` line for the name, `data:` line for JSON.
|
|
258
|
+
4. `JSON.parse` the data; cast to the matching interface by event name.
|
|
259
|
+
5. Yield `{ event: SseEventName, data: <typed payload> }`.
|
|
260
|
+
6. Malformed frames throw `SalesBotError({ code: 'parse_error' })`.
|
|
261
|
+
|
|
262
|
+
Hand-rolled parser, under 80 lines, zero dependencies.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## 5. Storage
|
|
267
|
+
|
|
268
|
+
```typescript
|
|
269
|
+
interface StorageAdapter {
|
|
270
|
+
getItem(key: string): string | null
|
|
271
|
+
setItem(key: string, value: string): void
|
|
272
|
+
removeItem(key: string): void
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
- **`LocalStorageAdapter`** — wraps `window.localStorage`. Construction throws if `window` is undefined; callers should catch and fall back.
|
|
277
|
+
- **`MemoryStorageAdapter`** — `Map<string, string>`. Used when localStorage is unavailable (SSR, Node, private browsing with quota error).
|
|
278
|
+
- **Auto-detect** at `SalesBotClient` construction: try `LocalStorageAdapter`; catch → fall back to `MemoryStorageAdapter` with a `console.warn`.
|
|
279
|
+
|
|
280
|
+
Visitor token key: `salesbot:visitor:<embedKey>` (embedKey suffix prevents collisions when multiple bots are embedded).
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## 6. Visitor Token Management
|
|
285
|
+
|
|
286
|
+
On first call to `getVisitorToken()`:
|
|
287
|
+
1. Read key from storage.
|
|
288
|
+
2. If present, return it.
|
|
289
|
+
3. If absent, generate via `crypto.randomUUID()` (browser native, no polyfill needed for targets we support).
|
|
290
|
+
4. Persist to storage, return.
|
|
291
|
+
|
|
292
|
+
Tokens are UUIDs; they are stable across page reloads and cleared only when the storage is cleared. The SDK never exposes a `clearVisitorToken()` — consumers who need that can call `storage.removeItem(key)` directly.
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## 7. Transport
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
async function postTurn(
|
|
300
|
+
input: PostTurnInput,
|
|
301
|
+
opts: TransportOptions
|
|
302
|
+
): Promise<ReadableStream<Uint8Array>>
|
|
303
|
+
|
|
304
|
+
async function getResumeStream(
|
|
305
|
+
turnId: string,
|
|
306
|
+
opts: TransportOptions
|
|
307
|
+
): Promise<ReadableStream<Uint8Array>>
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
- Sets `Authorization: Bearer <embedKey>`, `Accept: text/event-stream`, `Content-Type: application/json`.
|
|
311
|
+
- Sets `Idempotency-Key` header with a fresh `crypto.randomUUID()` per `ask()` invocation.
|
|
312
|
+
- `Origin` is set automatically by the browser; Node callers pass via `customHeaders`.
|
|
313
|
+
- On non-2xx response: reads the JSON error body, maps to `SalesBotError`.
|
|
314
|
+
- HTTP 429 → `rate_limited`, 402 → `out_of_credits`, 403 → `origin_not_allowed` or `forbidden`, etc.
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## 8. Error Handling
|
|
319
|
+
|
|
320
|
+
All errors thrown by the SDK are `SalesBotError` instances. The `.code` field is a stable TypeScript union that consumers can `switch` on.
|
|
321
|
+
|
|
322
|
+
HTTP status → code mapping:
|
|
323
|
+
- 400 → `bad_request`
|
|
324
|
+
- 401 → `unauthorized` or `invalid_embed_key` (body.code takes precedence)
|
|
325
|
+
- 402 → `out_of_credits`
|
|
326
|
+
- 403 → body.code takes precedence; default `forbidden`
|
|
327
|
+
- 404 → `not_found`
|
|
328
|
+
- 409 → `conflict`
|
|
329
|
+
- 422 → `unprocessable_entity`
|
|
330
|
+
- 429 → `rate_limited`
|
|
331
|
+
- 5xx → `internal` (or body.code if present)
|
|
332
|
+
- fetch throws (network down) → `network_error`
|
|
333
|
+
|
|
334
|
+
SSE `error` event → throw `SalesBotError` from the async iterable, emit on `on('error')` bus.
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## 9. Reconnect / Resume
|
|
339
|
+
|
|
340
|
+
The SDK tracks the latest `turnId` from `turn_started` on the active client instance. `resume(turnId)` calls `GET /api/chat/turns/:turnId/stream` and returns an `AsyncIterable` of remaining events.
|
|
341
|
+
|
|
342
|
+
**Policy: opt-in, default off.** The `ask()` method does NOT auto-resume on stream drop. Consumers that want reconnect logic should:
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
let lastTurnId: string | null = null
|
|
346
|
+
client.on('turn_started', e => { lastTurnId = e.turnId })
|
|
347
|
+
|
|
348
|
+
async function askWithResume(msg: string) {
|
|
349
|
+
try {
|
|
350
|
+
for await (const event of client.ask(msg)) { /* handle */ }
|
|
351
|
+
} catch (err) {
|
|
352
|
+
if (lastTurnId && err instanceof SalesBotError && err.retryable) {
|
|
353
|
+
for await (const event of client.resume(lastTurnId)) { /* handle */ }
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
This keeps `ask()` behavior predictable and avoids ghost messages from double-processing.
|
|
360
|
+
|
|
361
|
+
---
|
|
362
|
+
|
|
363
|
+
## 10. Framework Adapters
|
|
364
|
+
|
|
365
|
+
### 10.1 React (`./react`)
|
|
366
|
+
|
|
367
|
+
- `useSalesBot(opts)` creates a `SalesBotClient` via `useRef` (stable across renders).
|
|
368
|
+
- State: `messages: Message[]`, `isStreaming: boolean`, `error: SalesBotError | null`.
|
|
369
|
+
- `ask()` appends the user message, sets `isStreaming = true`, drives the async iterable on each `delta`, appends a streaming assistant message, finalizes on `message_complete`, clears on `done` / `error`.
|
|
370
|
+
- `'use client'` directive at top so it's compatible with Next.js App Router (the hook itself uses `useState`/`useEffect`).
|
|
371
|
+
- React is a `peerDependency` (^18).
|
|
372
|
+
|
|
373
|
+
### 10.2 Vue (`./vue`)
|
|
374
|
+
|
|
375
|
+
- Same semantics as React adapter, built on `ref`, `shallowRef`, and the `SalesBotClient.on()` event bus.
|
|
376
|
+
- Uses `onUnmounted` to unsubscribe event handlers.
|
|
377
|
+
- Vue is a `peerDependency` (^3.4).
|
|
378
|
+
- No `<script setup>` macro — pure function composable for testing clarity.
|
|
379
|
+
|
|
380
|
+
### 10.3 Next.js
|
|
381
|
+
|
|
382
|
+
The React adapter works out of the box with Next.js App Router. Document:
|
|
383
|
+
- Add `'use client'` to any component that calls `useSalesBot`.
|
|
384
|
+
- `baseUrl` should point to the production backend (not localhost).
|
|
385
|
+
- SSR: the hook returns empty `messages` and noop `ask` until hydration. The `SalesBotClient` is constructed client-side only.
|
|
386
|
+
|
|
387
|
+
### 10.4 Vanilla / IIFE (`./vanilla`)
|
|
388
|
+
|
|
389
|
+
- `tsup` builds an IIFE bundle that sets `window.SalesBot = { init, widget }`.
|
|
390
|
+
- `init(opts)` returns a `SalesBotClient`.
|
|
391
|
+
- `widget(opts)` constructs and mounts the shadow-DOM widget.
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
## 11. Widget UI
|
|
396
|
+
|
|
397
|
+
### 11.1 Shadow DOM approach
|
|
398
|
+
|
|
399
|
+
The widget is mounted inside a shadow root attached to a `<sales-bot-widget>` custom element. This completely isolates its CSS from the host page. No CSS reset needed.
|
|
400
|
+
|
|
401
|
+
```html
|
|
402
|
+
<!-- Injected into document.body -->
|
|
403
|
+
<sales-bot-widget>
|
|
404
|
+
#shadow-root
|
|
405
|
+
<style> ... </style>
|
|
406
|
+
<button class="sb-launcher">💬</button>
|
|
407
|
+
<div class="sb-panel">
|
|
408
|
+
<div class="sb-header">...</div>
|
|
409
|
+
<div class="sb-messages">...</div>
|
|
410
|
+
<div class="sb-input-row">
|
|
411
|
+
<input class="sb-input" />
|
|
412
|
+
<button class="sb-send">Send</button>
|
|
413
|
+
</div>
|
|
414
|
+
</div>
|
|
415
|
+
</sales-bot-widget>
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
### 11.2 Theming
|
|
419
|
+
|
|
420
|
+
Consumed as CSS custom properties declared on `:host`:
|
|
421
|
+
|
|
422
|
+
```css
|
|
423
|
+
:host {
|
|
424
|
+
--sb-primary: #2563eb;
|
|
425
|
+
--sb-text: #111827;
|
|
426
|
+
--sb-bg: #ffffff;
|
|
427
|
+
--sb-radius: 12px;
|
|
428
|
+
--sb-z: 9999;
|
|
429
|
+
}
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
Override from the host page:
|
|
433
|
+
```css
|
|
434
|
+
sales-bot-widget {
|
|
435
|
+
--sb-primary: hotpink;
|
|
436
|
+
}
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
Or via `WidgetOptions.primaryColor` which sets an inline style on the custom element.
|
|
440
|
+
|
|
441
|
+
### 11.3 Markdown rendering
|
|
442
|
+
|
|
443
|
+
Lightweight hand-rolled renderer:
|
|
444
|
+
- `**bold**` → `<strong>`
|
|
445
|
+
- `*italic*` → `<em>`
|
|
446
|
+
- `` `code` `` → `<code>`
|
|
447
|
+
- `[text](url)` → `<a target="_blank" rel="noopener">` (only https:// links)
|
|
448
|
+
- Newlines → `<br>`
|
|
449
|
+
|
|
450
|
+
No full Markdown parser dependency (keeps widget bundle small).
|
|
451
|
+
|
|
452
|
+
### 11.4 Message flow in widget
|
|
453
|
+
|
|
454
|
+
1. User types and presses Enter or clicks Send.
|
|
455
|
+
2. Widget calls `client.ask(message)`.
|
|
456
|
+
3. Widget appends user bubble immediately.
|
|
457
|
+
4. On `turn_started`, shows a "thinking" indicator.
|
|
458
|
+
5. On first `delta`, replaces thinking indicator with a streaming assistant bubble.
|
|
459
|
+
6. Each `delta` appends to bubble content (rendered as markdown).
|
|
460
|
+
7. On `message_complete`, marks the bubble as complete.
|
|
461
|
+
8. On `done`, enables the input.
|
|
462
|
+
9. On `error`, shows an error message in the chat.
|
|
463
|
+
|
|
464
|
+
---
|
|
465
|
+
|
|
466
|
+
## 12. Build & Bundling
|
|
467
|
+
|
|
468
|
+
### 12.1 tsup config (conceptual)
|
|
469
|
+
|
|
470
|
+
```typescript
|
|
471
|
+
// tsup.config.ts
|
|
472
|
+
export default defineConfig([
|
|
473
|
+
// Core, React, Vue — ESM + CJS
|
|
474
|
+
{
|
|
475
|
+
entry: {
|
|
476
|
+
core: 'src/core/index.ts',
|
|
477
|
+
react: 'src/react/index.ts',
|
|
478
|
+
vue: 'src/vue/index.ts',
|
|
479
|
+
widget: 'src/widget/index.ts',
|
|
480
|
+
},
|
|
481
|
+
format: ['esm', 'cjs'],
|
|
482
|
+
dts: true,
|
|
483
|
+
external: ['react', 'react-dom', 'vue'],
|
|
484
|
+
},
|
|
485
|
+
// Vanilla IIFE
|
|
486
|
+
{
|
|
487
|
+
entry: { vanilla: 'src/vanilla/index.ts' },
|
|
488
|
+
format: ['iife'],
|
|
489
|
+
globalName: 'SalesBot',
|
|
490
|
+
minify: true,
|
|
491
|
+
dts: false,
|
|
492
|
+
},
|
|
493
|
+
])
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
### 12.2 package.json exports map
|
|
497
|
+
|
|
498
|
+
```json
|
|
499
|
+
{
|
|
500
|
+
"exports": {
|
|
501
|
+
"./core": {
|
|
502
|
+
"import": "./dist/core.mjs",
|
|
503
|
+
"require": "./dist/core.cjs"
|
|
504
|
+
},
|
|
505
|
+
"./react": {
|
|
506
|
+
"import": "./dist/react.mjs",
|
|
507
|
+
"require": "./dist/react.cjs"
|
|
508
|
+
},
|
|
509
|
+
"./vue": {
|
|
510
|
+
"import": "./dist/vue.mjs",
|
|
511
|
+
"require": "./dist/vue.cjs"
|
|
512
|
+
},
|
|
513
|
+
"./widget": {
|
|
514
|
+
"import": "./dist/widget.mjs",
|
|
515
|
+
"require": "./dist/widget.cjs"
|
|
516
|
+
},
|
|
517
|
+
"./vanilla": "./dist/vanilla.global.js"
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
---
|
|
523
|
+
|
|
524
|
+
## 13. Testing
|
|
525
|
+
|
|
526
|
+
### 13.1 Framework
|
|
527
|
+
|
|
528
|
+
**Vitest** with `happy-dom` environment for DOM tests. `@testing-library/react` for React adapter. `@vue/test-utils` for Vue adapter.
|
|
529
|
+
|
|
530
|
+
### 13.2 Test categories
|
|
531
|
+
|
|
532
|
+
| File | Approach |
|
|
533
|
+
|---|---|
|
|
534
|
+
| `sse-parser.test.ts` | Feeds mock `ReadableStream` with raw SSE bytes, asserts yielded events |
|
|
535
|
+
| `transport.test.ts` | Mocks `globalThis.fetch` via `vi.stubGlobal`, asserts headers/body/error mapping |
|
|
536
|
+
| `storage.test.ts` | Unit tests for both adapters |
|
|
537
|
+
| `visitor.test.ts` | Asserts token generation + persistence + embedKey namespacing |
|
|
538
|
+
| `client.test.ts` | Integration: mock fetch → full event sequence → state transitions |
|
|
539
|
+
| `use-sales-bot.test.tsx` | React Testing Library, `renderHook`, asserts messages/isStreaming/error |
|
|
540
|
+
| `use-sales-bot.test.ts` | Vue Test Utils, asserts refs update correctly |
|
|
541
|
+
| `widget.test.ts` | happy-dom, asserts shadow DOM structure + interaction |
|
|
542
|
+
| `wire-format.test.ts` | Contract test: TypeScript assignability + runtime shape assertions |
|
|
543
|
+
|
|
544
|
+
### 13.3 Contract sync
|
|
545
|
+
|
|
546
|
+
`tests/contract/wire-format.test.ts` imports the event type interfaces from `src/core/types.ts` and asserts they match the exact shapes from the backend's `sse-events.ts` (copied verbatim, not imported cross-project). Any shape mismatch is a compile error before the test even runs.
|
|
547
|
+
|
|
548
|
+
---
|
|
549
|
+
|
|
550
|
+
## 14. Bundle-size Budget
|
|
551
|
+
|
|
552
|
+
Measured gzipped, tracked via `size-limit` in `package.json`.
|
|
553
|
+
|
|
554
|
+
| Entry | Budget |
|
|
555
|
+
|---|---|
|
|
556
|
+
| `./core` | 8 KB |
|
|
557
|
+
| `./react` | 12 KB (includes core) |
|
|
558
|
+
| `./vue` | 12 KB (includes core) |
|
|
559
|
+
| `./widget` | 25 KB (includes core + styles) |
|
|
560
|
+
| `./vanilla` IIFE | 20 KB |
|
|
561
|
+
|
|
562
|
+
---
|
|
563
|
+
|
|
564
|
+
## 15. Decisions Log
|
|
565
|
+
|
|
566
|
+
| # | Decision | Rationale |
|
|
567
|
+
|---|---|---|
|
|
568
|
+
| 1 | Single package, multiple entry points (not monorepo) | Eliminates version skew between adapters; one `npm install`, one `package.json` to maintain. |
|
|
569
|
+
| 2 | Both `AsyncIterable` AND event-bus (`on()`) interfaces | `AsyncIterable` is cleaner for custom consumers (for-await); event-bus is better for React/Vue adapter internals (no need to manage async loop lifetime). Both are thin wrappers over the same stream. |
|
|
570
|
+
| 3 | Hand-rolled SSE parser, no `eventsource-parser` dep | The backend's SSE format is fixed and simple; a hand-rolled parser stays under 80 lines, is zero-dependency, and is straightforwardly testable. Adds an `eventsource-parser` dep if complexity grows. |
|
|
571
|
+
| 4 | Shadow DOM for widget | Host-page CSS bleed is the #1 real-world pain point for chat widgets. Shadow DOM eliminates it completely and is supported in all target browsers. |
|
|
572
|
+
| 5 | `crypto.randomUUID()` instead of `nanoid` | One fewer dependency; native `crypto.randomUUID()` is available in all modern browsers and Node 15+. |
|
|
573
|
+
| 6 | `MemoryStorageAdapter` fallback (silent warning) | Private browsing mode throws on `localStorage.setItem`. A silent fallback with a `console.warn` keeps the widget working while informing developers. |
|
|
574
|
+
| 7 | `'use client'` on React adapter | Next.js App Router requires this for hooks that use `useState`/`useEffect`; making it explicit in the entry file avoids consumer confusion. |
|
|
575
|
+
| 8 | No auto-reconnect | Opt-in reconnect prevents double-processing of messages; the turn lifecycle is well-defined by the backend, so consumers can implement exactly the reconnect policy they need. |
|
|
576
|
+
| 9 | Biome for linting/formatting | Single binary, minimal config, significantly faster than ESLint + Prettier. Acceptable for a library project. |
|
|
577
|
+
| 10 | Hand-rolled markdown renderer | `marked` is 22KB+ and overkill for the subset needed (`**bold**`, links, code). Hand-rolled renderer stays under 50 lines and keeps the widget bundle small. |
|
|
578
|
+
|
|
579
|
+
---
|
|
580
|
+
|
|
581
|
+
## 16. Known Limitations / Follow-ups
|
|
582
|
+
|
|
583
|
+
- **No retry built into `ask()`** — callers must implement their own retry or use `resume()`.
|
|
584
|
+
- **No conversation history pre-load** — `GET /api/chat/conversations/:id` exists on the backend but the SDK doesn't expose a `loadHistory()` method in v1. Add in a follow-up.
|
|
585
|
+
- **No accessibility audit on widget** — shadow DOM requires explicit ARIA attributes. The v1 widget has basic `aria-label` on key elements but hasn't been audited against WCAG 2.1 AA.
|
|
586
|
+
- **Vue SSR** — `useSalesBot` returns empty state safely in SSR context, but hydration mismatch avoidance hasn't been tested with Nuxt. Document as a known gap.
|
|
587
|
+
- **iOS Safari `ReadableStream` tee** — some older iOS Safari versions have quirks with `ReadableStream`. If user targeting requires iOS < 16, consider adding a `getReader()` polyfill guard.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Sales Bot SDK — Example app
|
|
2
|
+
|
|
3
|
+
Vite + React app that exercises the SDK locally against a running backend.
|
|
4
|
+
|
|
5
|
+
## Prereqs
|
|
6
|
+
|
|
7
|
+
1. Backend running at `http://localhost:3000` (sub-project #1 / `sales_bot/`).
|
|
8
|
+
2. A verified Resource on the backend whose `domain` allows `localhost` as an origin.
|
|
9
|
+
**This is the most common "I tried it and got 403" gotcha** — the SDK sends an `Origin` header
|
|
10
|
+
with every request. Browsers automatically send `Origin: http://localhost:5173`. The bot's
|
|
11
|
+
resource must have `localhost` as the verified domain (or use a wildcard match) for the backend
|
|
12
|
+
to permit the request. Verify a Resource with domain `localhost` for local dev.
|
|
13
|
+
3. A Bot created against that Resource. Copy its `embedKey` (`pk_live_…`) from the admin UI.
|
|
14
|
+
|
|
15
|
+
## Run
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
# From the SDK repo root (sales_bot_sdk/):
|
|
19
|
+
pnpm build # build the SDK → dist/
|
|
20
|
+
pnpm install # installs root + example via workspace
|
|
21
|
+
pnpm --filter @sales-bot/sdk-example dev
|
|
22
|
+
# Open http://localhost:5173
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Configure via `example/.env.local` (copy from `.env.example`):
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
VITE_BACKEND_URL=http://localhost:3000
|
|
29
|
+
VITE_EMBED_KEY=pk_live_…
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Pages
|
|
33
|
+
|
|
34
|
+
| Route | What it exercises |
|
|
35
|
+
|------------|-------------------|
|
|
36
|
+
| `/` | `useSalesBot` hook from `@sales-bot/sdk/react` — text input, streaming messages, conversation continuity, identify form |
|
|
37
|
+
| `/widget` | `createWidget()` from `@sales-bot/sdk/widget` — floating button + panel in shadow DOM, mounted to `<body>` |
|
|
38
|
+
| `/vanilla` | IIFE bundle (`dist/vanilla.global.js`) loaded via `<script>`, uses `window.SalesBot.default.widget()` |
|
|
39
|
+
|
|
40
|
+
## Known gotchas
|
|
41
|
+
|
|
42
|
+
### Origin header required
|
|
43
|
+
|
|
44
|
+
The backend validates `Origin` on every chat request. Vite serves on `http://localhost:5173` by
|
|
45
|
+
default — that is the origin browsers send automatically. Ensure the Bot's Resource has `localhost`
|
|
46
|
+
as a verified domain. If you see `403 origin_not_allowed`, this is why.
|
|
47
|
+
|
|
48
|
+
Fix: verify a Resource with domain `localhost` in the backend admin UI, then link a Bot to it and
|
|
49
|
+
copy its `embedKey` to `.env.local`.
|
|
50
|
+
|
|
51
|
+
### Vanilla IIFE global wrapping
|
|
52
|
+
|
|
53
|
+
The SDK's vanilla bundle is built with `tsup` using `globalName: 'SalesBot'` and `format: 'iife'`.
|
|
54
|
+
The output wraps the module as:
|
|
55
|
+
|
|
56
|
+
```js
|
|
57
|
+
var SalesBot = (function(exports) {
|
|
58
|
+
// ...
|
|
59
|
+
exports.SalesBot = { init, widget }
|
|
60
|
+
exports.default = { init, widget }
|
|
61
|
+
return exports
|
|
62
|
+
})({})
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
In a browser, top-level `var` in a `<script>` tag becomes `window.SalesBot`. However `window.SalesBot`
|
|
66
|
+
is the *exports wrapper object*, not the SDK object directly. The actual API is at:
|
|
67
|
+
|
|
68
|
+
- `window.SalesBot.default.widget(...)` — recommended
|
|
69
|
+
- `window.SalesBot.SalesBot.widget(...)` — also works
|
|
70
|
+
|
|
71
|
+
`window.SalesBot.widget(...)` will be `undefined`. This is a known SDK-side quirk (follow-up item).
|
|
72
|
+
The `VanillaDemo` page accounts for this automatically.
|
|
73
|
+
|
|
74
|
+
### `useSalesBot` does not expose `identify()`
|
|
75
|
+
|
|
76
|
+
The hook does not currently expose the underlying client's `identify()` method in its return value.
|
|
77
|
+
To call `identify()` you need a direct reference to a `SalesBotClient` instance. This is a
|
|
78
|
+
SDK-side follow-up. The Hook demo notes this inline with an alert.
|
|
79
|
+
|
|
80
|
+
## Import paths used
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
import { useSalesBot } from '@sales-bot/sdk/react'
|
|
84
|
+
import { createWidget } from '@sales-bot/sdk/widget'
|
|
85
|
+
import type { WidgetInstance } from '@sales-bot/sdk/widget'
|
|
86
|
+
// vanilla: loaded via <script src="/vanilla.global.js">
|
|
87
|
+
// → window.SalesBot.default.widget(...)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
These resolve through the SDK's `exports` map in `package.json`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>Sales Bot SDK — Example</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div id="root"></div>
|
|
10
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
11
|
+
</body>
|
|
12
|
+
</html>
|