@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,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><script></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"}
|
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,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
|
+
}
|