@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.
Files changed (54) hide show
  1. package/biome.json +36 -0
  2. package/docs/superpowers/plans/2026-05-08-sales-bot-sdk-plan.md +258 -0
  3. package/docs/superpowers/plans/2026-05-11-w3-sales-tool-polish-plan.md +476 -0
  4. package/docs/superpowers/specs/2026-05-08-sales-bot-sdk-design.md +587 -0
  5. package/example/.env.example +5 -0
  6. package/example/README.md +90 -0
  7. package/example/index.html +12 -0
  8. package/example/package.json +27 -0
  9. package/example/public/vanilla.global.js +345 -0
  10. package/example/src/App.tsx +50 -0
  11. package/example/src/main.tsx +16 -0
  12. package/example/src/routes/HookDemo.tsx +174 -0
  13. package/example/src/routes/VanillaDemo.tsx +67 -0
  14. package/example/src/routes/WidgetDemo.tsx +55 -0
  15. package/example/src/styles.css +18 -0
  16. package/example/tsconfig.json +19 -0
  17. package/example/tsconfig.tsbuildinfo +1 -0
  18. package/example/vite.config.ts +4 -0
  19. package/package.json +106 -0
  20. package/pnpm-workspace.yaml +3 -0
  21. package/src/core/client.ts +245 -0
  22. package/src/core/conversation.ts +34 -0
  23. package/src/core/index.ts +6 -0
  24. package/src/core/sse-parser.ts +87 -0
  25. package/src/core/storage.ts +72 -0
  26. package/src/core/transport.ts +271 -0
  27. package/src/core/types.ts +314 -0
  28. package/src/core/visitor.ts +21 -0
  29. package/src/react/index.ts +2 -0
  30. package/src/react/use-sales-bot.tsx +182 -0
  31. package/src/vanilla/index.ts +38 -0
  32. package/src/vue/index.ts +2 -0
  33. package/src/vue/use-sales-bot.ts +152 -0
  34. package/src/widget/index.ts +3 -0
  35. package/src/widget/markdown.ts +69 -0
  36. package/src/widget/styles.ts +350 -0
  37. package/src/widget/widget.ts +442 -0
  38. package/tests/contract/wire-format.test.ts +158 -0
  39. package/tests/core/client.test.ts +292 -0
  40. package/tests/core/conversation.test.ts +41 -0
  41. package/tests/core/sse-parser.test.ts +142 -0
  42. package/tests/core/storage.test.ts +78 -0
  43. package/tests/core/transport.test.ts +204 -0
  44. package/tests/core/visitor.test.ts +42 -0
  45. package/tests/react/use-sales-bot.test.tsx +188 -0
  46. package/tests/sales-tool-discriminator.test.ts +45 -0
  47. package/tests/setup.ts +3 -0
  48. package/tests/vanilla/vanilla.test.ts +37 -0
  49. package/tests/vue/use-sales-bot.test.ts +163 -0
  50. package/tests/widget/markdown.test.ts +113 -0
  51. package/tests/widget/widget.test.ts +388 -0
  52. package/tsconfig.json +28 -0
  53. package/tsup.config.ts +38 -0
  54. package/vitest.config.ts +26 -0
@@ -0,0 +1,67 @@
1
+ import { useEffect } from 'react'
2
+
3
+ // The IIFE bundle (vanilla.global.js) is copied to public/ by the predev/prebuild script.
4
+ // Top-level `var SalesBot` in a <script> tag becomes window.SalesBot in browsers.
5
+ // However, tsup's IIFE output wraps exports as:
6
+ // var SalesBot = (function(exports){ ... exports.SalesBot = {...}; exports.default = {...}; return exports; })({})
7
+ // so window.SalesBot is the exports wrapper object, not the SDK object directly.
8
+ // Use window.SalesBot.default.widget(...) or window.SalesBot.SalesBot.widget(...)
9
+ // See README Known gotchas § Vanilla IIFE wrapping.
10
+
11
+ declare global {
12
+ interface Window {
13
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
+ SalesBot?: any
15
+ }
16
+ }
17
+
18
+ export function VanillaDemo() {
19
+ const embedKey = (import.meta.env.VITE_EMBED_KEY as string | undefined) ?? ''
20
+ const baseUrl =
21
+ (import.meta.env.VITE_BACKEND_URL as string | undefined) ?? 'http://localhost:3000'
22
+
23
+ useEffect(() => {
24
+ if (!embedKey) return
25
+
26
+ const script = document.createElement('script')
27
+ // public/vanilla.global.js is copied there by the predev script
28
+ script.src = '/vanilla.global.js'
29
+ script.onload = () => {
30
+ const sb = window.SalesBot?.default ?? window.SalesBot?.SalesBot
31
+ if (!sb) {
32
+ console.error('[VanillaDemo] window.SalesBot not found after script load')
33
+ return
34
+ }
35
+ const mountEl = document.getElementById('vanilla-mount')
36
+ if (mountEl) {
37
+ sb.widget({ embedKey, baseUrl, container: mountEl })
38
+ }
39
+ }
40
+ document.head.appendChild(script)
41
+
42
+ return () => {
43
+ document.head.removeChild(script)
44
+ delete window.SalesBot
45
+ }
46
+ }, [embedKey, baseUrl])
47
+
48
+ return (
49
+ <div>
50
+ <h2>Vanilla / IIFE demo</h2>
51
+ <p>
52
+ Exercises <code>dist/vanilla.global.js</code> loaded via <code>&lt;script&gt;</code> ·
53
+ uses <code>window.SalesBot.default.widget()</code> to mount a chat widget.
54
+ </p>
55
+ <p>
56
+ The IIFE bundle is served from <code>/vanilla.global.js</code> (copied to{' '}
57
+ <code>public/</code> by the <code>predev</code> script).
58
+ </p>
59
+ {!embedKey && (
60
+ <div className="error">
61
+ VITE_EMBED_KEY is not set — widget will not mount.
62
+ </div>
63
+ )}
64
+ <div id="vanilla-mount" style={{ position: 'relative', height: '400px', border: '1px dashed #ccc', borderRadius: '8px', marginTop: '1rem' }} />
65
+ </div>
66
+ )
67
+ }
@@ -0,0 +1,55 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { createWidget } from '@sales-bot/sdk/widget'
3
+ import type { WidgetInstance } from '@sales-bot/sdk/core'
4
+
5
+ export function WidgetDemo() {
6
+ const embedKey = (import.meta.env.VITE_EMBED_KEY as string | undefined) ?? ''
7
+ const baseUrl =
8
+ (import.meta.env.VITE_BACKEND_URL as string | undefined) ?? 'http://localhost:3000'
9
+
10
+ const instanceRef = useRef<WidgetInstance | null>(null)
11
+
12
+ useEffect(() => {
13
+ if (!embedKey) return
14
+
15
+ const widget = createWidget({ embedKey, baseUrl, container: document.body })
16
+ instanceRef.current = widget
17
+
18
+ return () => {
19
+ widget.destroy()
20
+ instanceRef.current = null
21
+ }
22
+ }, [embedKey, baseUrl])
23
+
24
+ return (
25
+ <div>
26
+ <h2>Widget demo</h2>
27
+ <p>
28
+ Exercises <code>@sales-bot/sdk/widget</code> · <code>createWidget()</code> · shadow DOM.
29
+ </p>
30
+ <p>
31
+ The floating chat button appears in the <strong>bottom-right corner</strong> of the
32
+ viewport. The widget is mounted into <code>document.body</code> using shadow DOM and is
33
+ cleaned up when you navigate away.
34
+ </p>
35
+ {!embedKey && (
36
+ <div className="error">
37
+ VITE_EMBED_KEY is not set — widget will not mount. Copy{' '}
38
+ <code>.env.example</code> to <code>.env.local</code> and restart Vite.
39
+ </div>
40
+ )}
41
+
42
+ <div style={{ marginTop: '1rem', display: 'flex', gap: '0.5rem' }}>
43
+ <button type="button" onClick={() => instanceRef.current?.open()}>
44
+ Open widget
45
+ </button>
46
+ <button type="button" onClick={() => instanceRef.current?.close()}>
47
+ Close widget
48
+ </button>
49
+ <button type="button" onClick={() => instanceRef.current?.toggle()}>
50
+ Toggle widget
51
+ </button>
52
+ </div>
53
+ </div>
54
+ )
55
+ }
@@ -0,0 +1,18 @@
1
+ body { font-family: system-ui, sans-serif; max-width: 720px; margin: 2rem auto; padding: 0 1rem; }
2
+ nav { display: flex; gap: 1rem; margin-bottom: 1.5rem; }
3
+ nav a { padding: 0.4rem 0.8rem; border-radius: 6px; background: #f0f0f0; text-decoration: none; color: inherit; }
4
+ nav a.active { background: #111; color: #fff; }
5
+ .env-warning { background: #fff8e1; border: 1px solid #f9c23c; border-radius: 6px; padding: 0.6rem 1rem; margin: 0.5rem 0 1rem; }
6
+ .env-ok { font-size: 0.85rem; color: #555; }
7
+ .messages { list-style: none; padding: 0; }
8
+ .messages li { padding: 0.5rem 0.75rem; border-radius: 6px; margin: 0.4rem 0; }
9
+ .messages .role-user { background: #e6f3ff; }
10
+ .messages .role-assistant { background: #f5f5f5; }
11
+ form { display: flex; gap: 0.5rem; margin-top: 1rem; flex-wrap: wrap; }
12
+ input { flex: 1; padding: 0.5rem; border: 1px solid #ccc; border-radius: 6px; min-width: 0; }
13
+ button { padding: 0.5rem 1rem; border: 1px solid #ccc; border-radius: 6px; cursor: pointer; background: #fff; }
14
+ button:disabled { opacity: 0.5; cursor: not-allowed; }
15
+ .error { color: #c00; padding: 0.5rem; background: #fff0f0; border-radius: 4px; margin: 0.5rem 0; }
16
+ .streaming-cursor { animation: blink 1s step-end infinite; }
17
+ @keyframes blink { 50% { opacity: 0; } }
18
+ .identify-form { display: flex; gap: 0.5rem; margin-top: 0.5rem; flex-wrap: wrap; }
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noUnusedLocals": true,
10
+ "noUnusedParameters": true,
11
+ "noFallthroughCasesInSwitch": true,
12
+ "skipLibCheck": true,
13
+ "resolveJsonModule": true,
14
+ "isolatedModules": true,
15
+ "noEmit": true,
16
+ "types": ["vite/client"]
17
+ },
18
+ "include": ["src"]
19
+ }
@@ -0,0 +1 @@
1
+ {"root":["./src/app.tsx","./src/main.tsx","./src/routes/hookdemo.tsx","./src/routes/vanillademo.tsx","./src/routes/widgetdemo.tsx"],"version":"5.9.3"}
@@ -0,0 +1,4 @@
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ export default defineConfig({ plugins: [react()] })
package/package.json ADDED
@@ -0,0 +1,106 @@
1
+ {
2
+ "name": "@sales-bot-llm/sdk",
3
+ "version": "0.2.0",
4
+ "description": "Frontend SDK for the Sales Bot chat API — framework-agnostic core with React, Vue, and vanilla adapters",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "exports": {
9
+ "./core": {
10
+ "types": "./dist/core.d.ts",
11
+ "import": "./dist/core.js",
12
+ "require": "./dist/core.cjs"
13
+ },
14
+ "./react": {
15
+ "types": "./dist/react.d.ts",
16
+ "import": "./dist/react.js",
17
+ "require": "./dist/react.cjs"
18
+ },
19
+ "./vue": {
20
+ "types": "./dist/vue.d.ts",
21
+ "import": "./dist/vue.js",
22
+ "require": "./dist/vue.cjs"
23
+ },
24
+ "./widget": {
25
+ "types": "./dist/widget.d.ts",
26
+ "import": "./dist/widget.js",
27
+ "require": "./dist/widget.cjs"
28
+ },
29
+ "./vanilla": {
30
+ "script": "./dist/vanilla.global.js"
31
+ }
32
+ },
33
+ "scripts": {
34
+ "build": "tsup",
35
+ "dev": "tsup --watch",
36
+ "test": "vitest run",
37
+ "test:watch": "vitest",
38
+ "test:coverage": "vitest run --coverage",
39
+ "lint": "biome check .",
40
+ "lint:fix": "biome check --write .",
41
+ "format": "biome format --write .",
42
+ "size": "size-limit",
43
+ "typecheck": "tsc --noEmit"
44
+ },
45
+ "peerDependencies": {
46
+ "react": ">=18.0.0",
47
+ "react-dom": ">=18.0.0",
48
+ "vue": ">=3.4.0"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "react": {
52
+ "optional": true
53
+ },
54
+ "react-dom": {
55
+ "optional": true
56
+ },
57
+ "vue": {
58
+ "optional": true
59
+ }
60
+ },
61
+ "devDependencies": {
62
+ "@biomejs/biome": "^1.9.4",
63
+ "@size-limit/preset-small-lib": "^11.2.0",
64
+ "@testing-library/react": "^16.3.2",
65
+ "@testing-library/user-event": "^14.6.1",
66
+ "@types/react": "^18.3.28",
67
+ "@types/react-dom": "^18.3.7",
68
+ "@vitejs/plugin-react": "^4.7.0",
69
+ "@vue/test-utils": "^2.4.10",
70
+ "happy-dom": "^15.11.7",
71
+ "react": "^18.3.1",
72
+ "react-dom": "^18.3.1",
73
+ "size-limit": "^11.2.0",
74
+ "tsup": "^8.5.1",
75
+ "typescript": "^5.9.3",
76
+ "vitest": "^2.1.9",
77
+ "vue": "^3.5.34"
78
+ },
79
+ "size-limit": [
80
+ {
81
+ "name": "core",
82
+ "path": "./dist/core.js",
83
+ "limit": "8 KB"
84
+ },
85
+ {
86
+ "name": "react",
87
+ "path": "./dist/react.js",
88
+ "limit": "12 KB"
89
+ },
90
+ {
91
+ "name": "vue",
92
+ "path": "./dist/vue.js",
93
+ "limit": "12 KB"
94
+ },
95
+ {
96
+ "name": "widget",
97
+ "path": "./dist/widget.js",
98
+ "limit": "25 KB"
99
+ },
100
+ {
101
+ "name": "vanilla",
102
+ "path": "./dist/vanilla.global.js",
103
+ "limit": "20 KB"
104
+ }
105
+ ]
106
+ }
@@ -0,0 +1,3 @@
1
+ packages:
2
+ - .
3
+ - example
@@ -0,0 +1,245 @@
1
+ import type {
2
+ SalesBotClientOptions,
3
+ IdentifyInput,
4
+ AskOptions,
5
+ SalesBotEvent,
6
+ SseEventName,
7
+ TurnStartedEvent,
8
+ DeltaEvent,
9
+ ToolCallStartedEvent,
10
+ ToolCallFinishedEvent,
11
+ MessageCompleteEvent,
12
+ UsageEventData,
13
+ DoneEvent,
14
+ ErrorEvent,
15
+ Unsubscribe,
16
+ } from './types'
17
+ import { createDefaultStorage } from './storage'
18
+ import { getOrCreateVisitorToken } from './visitor'
19
+ import { loadConversationId, saveConversationId } from './conversation'
20
+ import { parseSseStream } from './sse-parser'
21
+ import { postTurn, getResumeStream, getBotConfig, getConversationHistory, postEndConversation } from './transport'
22
+ import type { PublicBotConfig, HistoryMessage } from './transport'
23
+ import type { StorageAdapter } from './types'
24
+
25
+ type EventHandler<T> = (data: T) => void
26
+
27
+ type EventMap = {
28
+ turn_started: TurnStartedEvent
29
+ delta: DeltaEvent
30
+ tool_call_started: ToolCallStartedEvent
31
+ tool_call_finished: ToolCallFinishedEvent
32
+ message_complete: MessageCompleteEvent
33
+ usage: UsageEventData
34
+ done: DoneEvent
35
+ error: ErrorEvent
36
+ }
37
+
38
+ /**
39
+ * The core Sales Bot client.
40
+ *
41
+ * Framework-agnostic. Works in any environment that has:
42
+ * - fetch
43
+ * - crypto.randomUUID
44
+ * - ReadableStream
45
+ */
46
+ export class SalesBotClient {
47
+ private readonly embedKey: string
48
+ private readonly baseUrl: string
49
+ private readonly customHeaders?: Record<string, string>
50
+ private readonly storage: StorageAdapter
51
+ private readonly visitorToken: string
52
+ private conversationId: string | null
53
+ private pendingIdentify: IdentifyInput | null = null
54
+
55
+ // Event bus: map of event name → Set of handlers
56
+ private readonly handlers = new Map<string, Set<EventHandler<unknown>>>()
57
+
58
+ constructor(opts: SalesBotClientOptions) {
59
+ this.embedKey = opts.embedKey
60
+ this.baseUrl = opts.baseUrl ?? 'http://localhost:3000'
61
+ this.customHeaders = opts.customHeaders
62
+
63
+ this.storage = opts.storage ?? createDefaultStorage()
64
+ this.visitorToken = getOrCreateVisitorToken(opts.embedKey, this.storage)
65
+ // Hydrate conversationId from storage so refresh/reopen continues the
66
+ // same conversation server-side (last-20-messages window keeps the LLM
67
+ // in context). Cleared explicitly via setConversationId(null).
68
+ this.conversationId = loadConversationId(opts.embedKey, this.storage)
69
+ }
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Identification
73
+ // ---------------------------------------------------------------------------
74
+
75
+ /** Merge identify traits into the next ask() call. */
76
+ identify(traits: IdentifyInput): void {
77
+ this.pendingIdentify = { ...this.pendingIdentify, ...traits }
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Ask — starts a new agent turn
82
+ // ---------------------------------------------------------------------------
83
+
84
+ async *ask(message: string, opts?: AskOptions): AsyncIterable<SalesBotEvent> {
85
+ const input = {
86
+ visitorToken: this.visitorToken,
87
+ message,
88
+ identify: this.pendingIdentify ?? undefined,
89
+ conversationId: opts?.conversationId ?? this.conversationId ?? undefined,
90
+ metadata: opts?.metadata,
91
+ }
92
+
93
+ const transportOpts = {
94
+ embedKey: this.embedKey,
95
+ baseUrl: this.baseUrl,
96
+ customHeaders: this.customHeaders,
97
+ }
98
+
99
+ const stream = await postTurn(input, transportOpts)
100
+ yield* this.consumeStream(stream)
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Resume — re-attach to an in-flight turn
105
+ // ---------------------------------------------------------------------------
106
+
107
+ async *resume(turnId: string): AsyncIterable<SalesBotEvent> {
108
+ const transportOpts = {
109
+ embedKey: this.embedKey,
110
+ baseUrl: this.baseUrl,
111
+ customHeaders: this.customHeaders,
112
+ }
113
+ const stream = await getResumeStream(turnId, transportOpts)
114
+ yield* this.consumeStream(stream)
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Stream consumer — parses SSE and emits on both iterable and event bus
119
+ // ---------------------------------------------------------------------------
120
+
121
+ private async *consumeStream(stream: ReadableStream<Uint8Array>): AsyncIterable<SalesBotEvent> {
122
+ for await (const event of parseSseStream(stream)) {
123
+ // Side-effects: update client state
124
+ if (event.event === 'turn_started') {
125
+ const next = (event.data as TurnStartedEvent).conversationId
126
+ this.conversationId = next
127
+ saveConversationId(this.embedKey, next, this.storage)
128
+ }
129
+
130
+ // Emit on event bus
131
+ this.emit(event.event, event.data)
132
+
133
+ // Yield to the async iterable consumer
134
+ yield event
135
+ }
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Conversation state
140
+ // ---------------------------------------------------------------------------
141
+
142
+ getVisitorToken(): string {
143
+ return this.visitorToken
144
+ }
145
+
146
+ getConversationId(): string | null {
147
+ return this.conversationId
148
+ }
149
+
150
+ setConversationId(id: string | null): void {
151
+ this.conversationId = id
152
+ saveConversationId(this.embedKey, id, this.storage)
153
+ }
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // Bot config — fetched once per client instance
157
+ // ---------------------------------------------------------------------------
158
+
159
+ private botConfigPromise: Promise<PublicBotConfig> | null = null
160
+
161
+ /**
162
+ * Fetch the public bot config (name + greeting). Cached per client
163
+ * instance — repeated calls return the same in-flight or resolved promise.
164
+ * Throws SalesBotError on network/HTTP failure; callers should fall back
165
+ * gracefully (e.g. skip greeting rather than fail the whole widget).
166
+ */
167
+ getBotConfig(): Promise<PublicBotConfig> {
168
+ if (!this.botConfigPromise) {
169
+ this.botConfigPromise = getBotConfig({
170
+ embedKey: this.embedKey,
171
+ baseUrl: this.baseUrl,
172
+ customHeaders: this.customHeaders,
173
+ })
174
+ }
175
+ return this.botConfigPromise
176
+ }
177
+
178
+ /**
179
+ * Load the persisted transcript for the currently-tracked conversation
180
+ * (or an explicit override). Returns an empty array when there is no
181
+ * conversation to load — safe to call unconditionally on widget mount.
182
+ * Used by the widget to repopulate message bubbles after page refresh.
183
+ */
184
+ async loadHistory(conversationId?: string): Promise<HistoryMessage[]> {
185
+ const id = conversationId ?? this.conversationId
186
+ if (!id) return []
187
+ return getConversationHistory(id, this.visitorToken, {
188
+ embedKey: this.embedKey,
189
+ baseUrl: this.baseUrl,
190
+ customHeaders: this.customHeaders,
191
+ })
192
+ }
193
+
194
+ /**
195
+ * End the current conversation. Server-side: soft-deletes the row so
196
+ * future history fetches return empty and the admin list drops it.
197
+ * Client-side: clears the persisted conversationId so the next ask()
198
+ * starts a fresh conversation. Idempotent — safe to call when there's
199
+ * no active conversation (no-op + clears local state defensively).
200
+ */
201
+ async endConversation(): Promise<void> {
202
+ const id = this.conversationId
203
+ if (id) {
204
+ try {
205
+ await postEndConversation(id, this.visitorToken, {
206
+ embedKey: this.embedKey,
207
+ baseUrl: this.baseUrl,
208
+ customHeaders: this.customHeaders,
209
+ })
210
+ } catch (err) {
211
+ // Network/auth failure — still clear local state so the user can
212
+ // start a new conversation locally. The server-side row may stay
213
+ // alive; retention will purge it eventually.
214
+ this.setConversationId(null)
215
+ throw err
216
+ }
217
+ }
218
+ this.setConversationId(null)
219
+ }
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Event bus
223
+ // ---------------------------------------------------------------------------
224
+
225
+ on<K extends SseEventName>(event: K, handler: EventHandler<EventMap[K]>): Unsubscribe
226
+ on(event: string, handler: EventHandler<unknown>): Unsubscribe
227
+ on(event: string, handler: EventHandler<unknown>): Unsubscribe {
228
+ if (!this.handlers.has(event)) {
229
+ this.handlers.set(event, new Set())
230
+ }
231
+ this.handlers.get(event)!.add(handler)
232
+
233
+ return () => {
234
+ this.handlers.get(event)?.delete(handler)
235
+ }
236
+ }
237
+
238
+ private emit(event: string, data: unknown): void {
239
+ const listeners = this.handlers.get(event)
240
+ if (!listeners) return
241
+ for (const handler of listeners) {
242
+ handler(data)
243
+ }
244
+ }
245
+ }
@@ -0,0 +1,34 @@
1
+ import type { StorageAdapter } from './types'
2
+
3
+ export const CONVERSATION_ID_KEY_PREFIX = 'salesbot:conversation:'
4
+
5
+ /**
6
+ * Returns the persisted conversationId for a given embed key, or null if none.
7
+ *
8
+ * Stored in the provided storage adapter under:
9
+ * salesbot:conversation:<embedKey>
10
+ *
11
+ * Persisting the conversation id across page loads (in browser localStorage by
12
+ * default) is what makes the chat feel continuous: the same EndUser keeps
13
+ * talking to the same Conversation row, and the backend's last-20-messages
14
+ * sliding window keeps the model in context.
15
+ */
16
+ export function loadConversationId(embedKey: string, storage: StorageAdapter): string | null {
17
+ return storage.getItem(`${CONVERSATION_ID_KEY_PREFIX}${embedKey}`)
18
+ }
19
+
20
+ /**
21
+ * Persist (or clear, when id is null) the active conversation id.
22
+ */
23
+ export function saveConversationId(
24
+ embedKey: string,
25
+ id: string | null,
26
+ storage: StorageAdapter,
27
+ ): void {
28
+ const key = `${CONVERSATION_ID_KEY_PREFIX}${embedKey}`
29
+ if (id === null) {
30
+ storage.removeItem(key)
31
+ } else {
32
+ storage.setItem(key, id)
33
+ }
34
+ }
@@ -0,0 +1,6 @@
1
+ export * from './types'
2
+ export { SalesBotClient } from './client'
3
+ export { LocalStorageAdapter, MemoryStorageAdapter, createDefaultStorage } from './storage'
4
+ export { getOrCreateVisitorToken, VISITOR_TOKEN_KEY_PREFIX } from './visitor'
5
+ export { parseSseStream } from './sse-parser'
6
+ export { postTurn, getResumeStream } from './transport'
@@ -0,0 +1,87 @@
1
+ import { SalesBotError } from './types'
2
+ import type { SalesBotEvent, SseEventName } from './types'
3
+
4
+ /**
5
+ * Parse a Server-Sent Events stream from the Sales Bot backend.
6
+ *
7
+ * Consumes a ReadableStream<Uint8Array> and yields typed SalesBotEvent objects.
8
+ * Handles frames split across chunk boundaries correctly.
9
+ *
10
+ * Wire format (per backend contract):
11
+ * event: <name>\ndata: <json>\n\n
12
+ */
13
+ export async function* parseSseStream(
14
+ stream: ReadableStream<Uint8Array>,
15
+ ): AsyncIterable<SalesBotEvent> {
16
+ const decoder = new TextDecoder()
17
+ const reader = stream.getReader()
18
+ let buffer = ''
19
+
20
+ try {
21
+ while (true) {
22
+ const { done, value } = await reader.read()
23
+ if (done) break
24
+
25
+ buffer += decoder.decode(value, { stream: true })
26
+
27
+ // Split on the double-newline SSE frame delimiter
28
+ const frames = buffer.split('\n\n')
29
+
30
+ // The last element is either empty (complete frame ended with \n\n)
31
+ // or an incomplete frame to carry over in the buffer.
32
+ buffer = frames.pop() ?? ''
33
+
34
+ for (const frame of frames) {
35
+ const trimmed = frame.trim()
36
+ if (!trimmed) continue
37
+
38
+ const event = parseFrame(trimmed)
39
+ if (event !== null) yield event
40
+ }
41
+ }
42
+
43
+ // Flush any remaining content in the decoder
44
+ const tail = decoder.decode(undefined, { stream: false })
45
+ if (tail) buffer += tail
46
+
47
+ // Process any final frame
48
+ const frames = buffer.split('\n\n')
49
+ for (const frame of frames) {
50
+ const trimmed = frame.trim()
51
+ if (!trimmed) continue
52
+ const event = parseFrame(trimmed)
53
+ if (event !== null) yield event
54
+ }
55
+ } finally {
56
+ reader.releaseLock()
57
+ }
58
+ }
59
+
60
+ function parseFrame(frame: string): SalesBotEvent | null {
61
+ let eventName: string | null = null
62
+ let dataLine: string | null = null
63
+
64
+ for (const line of frame.split('\n')) {
65
+ if (line.startsWith('event:')) {
66
+ eventName = line.slice('event:'.length).trim()
67
+ } else if (line.startsWith('data:')) {
68
+ dataLine = line.slice('data:'.length).trim()
69
+ }
70
+ }
71
+
72
+ // Frames without an event name are skipped (keepalive pings, etc.)
73
+ if (eventName === null || dataLine === null) return null
74
+
75
+ let parsed: unknown
76
+ try {
77
+ parsed = JSON.parse(dataLine)
78
+ } catch {
79
+ throw new SalesBotError({
80
+ code: 'parse_error',
81
+ message: `Failed to parse SSE data as JSON for event "${eventName}": ${dataLine}`,
82
+ retryable: false,
83
+ })
84
+ }
85
+
86
+ return { event: eventName as SseEventName, data: parsed } as SalesBotEvent
87
+ }