@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,72 @@
|
|
|
1
|
+
import type { StorageAdapter } from './types'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* In-memory storage adapter — used as a fallback when localStorage is
|
|
5
|
+
* unavailable (SSR, Node, private browsing quota errors).
|
|
6
|
+
*/
|
|
7
|
+
export class MemoryStorageAdapter implements StorageAdapter {
|
|
8
|
+
private readonly store = new Map<string, string>()
|
|
9
|
+
|
|
10
|
+
getItem(key: string): string | null {
|
|
11
|
+
return this.store.get(key) ?? null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
setItem(key: string, value: string): void {
|
|
15
|
+
this.store.set(key, value)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
removeItem(key: string): void {
|
|
19
|
+
this.store.delete(key)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* localStorage-backed adapter.
|
|
25
|
+
* Throws at construction if window.localStorage is unavailable.
|
|
26
|
+
* Callers should catch and fall back to MemoryStorageAdapter.
|
|
27
|
+
*/
|
|
28
|
+
export class LocalStorageAdapter implements StorageAdapter {
|
|
29
|
+
private readonly ls: Storage
|
|
30
|
+
|
|
31
|
+
constructor() {
|
|
32
|
+
if (typeof window === 'undefined' || !window.localStorage) {
|
|
33
|
+
throw new Error('localStorage is not available in this environment')
|
|
34
|
+
}
|
|
35
|
+
this.ls = window.localStorage
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
getItem(key: string): string | null {
|
|
39
|
+
return this.ls.getItem(key)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
setItem(key: string, value: string): void {
|
|
43
|
+
this.ls.setItem(key, value)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
removeItem(key: string): void {
|
|
47
|
+
this.ls.removeItem(key)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Creates the best available storage adapter.
|
|
53
|
+
* Prefers localStorage; falls back silently to MemoryStorageAdapter.
|
|
54
|
+
*/
|
|
55
|
+
export function createDefaultStorage(): StorageAdapter {
|
|
56
|
+
try {
|
|
57
|
+
const adapter = new LocalStorageAdapter()
|
|
58
|
+
// Probe for quota errors in private browsing
|
|
59
|
+
const probe = '__sb_probe__'
|
|
60
|
+
adapter.setItem(probe, '1')
|
|
61
|
+
adapter.removeItem(probe)
|
|
62
|
+
return adapter
|
|
63
|
+
} catch {
|
|
64
|
+
if (typeof console !== 'undefined') {
|
|
65
|
+
console.warn(
|
|
66
|
+
'[SalesBot] localStorage unavailable — falling back to in-memory storage. ' +
|
|
67
|
+
'Visitor token will not persist across page reloads.',
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
return new MemoryStorageAdapter()
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { SalesBotError } from './types'
|
|
2
|
+
import type { PostTurnInput, SalesBotErrorCode } from './types'
|
|
3
|
+
|
|
4
|
+
export interface TransportOptions {
|
|
5
|
+
embedKey: string
|
|
6
|
+
baseUrl: string
|
|
7
|
+
customHeaders?: Record<string, string>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* POST /api/chat/turns — starts a new agent turn.
|
|
12
|
+
* Returns the raw SSE ReadableStream on success.
|
|
13
|
+
* Throws SalesBotError on HTTP errors or network failures.
|
|
14
|
+
*/
|
|
15
|
+
export async function postTurn(
|
|
16
|
+
input: PostTurnInput,
|
|
17
|
+
opts: TransportOptions,
|
|
18
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
19
|
+
const headers: Record<string, string> = {
|
|
20
|
+
Authorization: `Bearer ${opts.embedKey}`,
|
|
21
|
+
'Content-Type': 'application/json',
|
|
22
|
+
Accept: 'text/event-stream',
|
|
23
|
+
'Idempotency-Key': crypto.randomUUID(),
|
|
24
|
+
...opts.customHeaders,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let response: Response
|
|
28
|
+
try {
|
|
29
|
+
response = await fetch(`${opts.baseUrl}/api/chat/turns`, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers,
|
|
32
|
+
body: JSON.stringify(input),
|
|
33
|
+
})
|
|
34
|
+
} catch (err) {
|
|
35
|
+
throw new SalesBotError({
|
|
36
|
+
code: 'network_error',
|
|
37
|
+
message: err instanceof Error ? err.message : 'Network request failed',
|
|
38
|
+
retryable: true,
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
await throwHttpError(response)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!response.body) {
|
|
47
|
+
throw new SalesBotError({ code: 'internal', message: 'Response body is null', retryable: false })
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return response.body
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Public bot config keyed by the embed key. Read once on widget mount;
|
|
55
|
+
* shape is what GET /api/chat/bots/me returns.
|
|
56
|
+
*/
|
|
57
|
+
export interface PublicBotConfig {
|
|
58
|
+
name: string
|
|
59
|
+
greetingMessage: string | null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* GET /api/chat/bots/me — returns the public-safe config (name, greeting)
|
|
64
|
+
* for the bot this embed key authenticates as. Used by the widget to render
|
|
65
|
+
* the welcome message on first open.
|
|
66
|
+
*/
|
|
67
|
+
export async function getBotConfig(opts: TransportOptions): Promise<PublicBotConfig> {
|
|
68
|
+
const headers: Record<string, string> = {
|
|
69
|
+
Authorization: `Bearer ${opts.embedKey}`,
|
|
70
|
+
Accept: 'application/json',
|
|
71
|
+
...opts.customHeaders,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let response: Response
|
|
75
|
+
try {
|
|
76
|
+
response = await fetch(`${opts.baseUrl}/api/chat/bots/me`, { method: 'GET', headers })
|
|
77
|
+
} catch (err) {
|
|
78
|
+
throw new SalesBotError({
|
|
79
|
+
code: 'network_error',
|
|
80
|
+
message: err instanceof Error ? err.message : 'Network request failed',
|
|
81
|
+
retryable: true,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
await throwHttpError(response)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (await response.json()) as PublicBotConfig
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Persisted message as returned by GET /api/chat/conversations/:id/messages.
|
|
94
|
+
* Tool-call rows are filtered server-side — the SDK only ever sees user +
|
|
95
|
+
* assistant turns. createdAt is an ISO 8601 string.
|
|
96
|
+
*/
|
|
97
|
+
export interface HistoryMessage {
|
|
98
|
+
id: string
|
|
99
|
+
role: 'user' | 'assistant'
|
|
100
|
+
content: string
|
|
101
|
+
createdAt: string
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* GET /api/chat/conversations/:id/messages — fetches the persisted transcript
|
|
106
|
+
* for a conversation scoped to (embedKey's bot, visitorToken's end-user).
|
|
107
|
+
* The backend silently returns { messages: [] } for unknown visitors or
|
|
108
|
+
* stale conversation ids so a fresh widget never errors on a missing
|
|
109
|
+
* client-side persisted id.
|
|
110
|
+
*/
|
|
111
|
+
export async function getConversationHistory(
|
|
112
|
+
conversationId: string,
|
|
113
|
+
visitorToken: string,
|
|
114
|
+
opts: TransportOptions,
|
|
115
|
+
): Promise<HistoryMessage[]> {
|
|
116
|
+
const headers: Record<string, string> = {
|
|
117
|
+
Authorization: `Bearer ${opts.embedKey}`,
|
|
118
|
+
Accept: 'application/json',
|
|
119
|
+
...opts.customHeaders,
|
|
120
|
+
}
|
|
121
|
+
const url = `${opts.baseUrl}/api/chat/conversations/${encodeURIComponent(
|
|
122
|
+
conversationId,
|
|
123
|
+
)}/messages?visitorToken=${encodeURIComponent(visitorToken)}`
|
|
124
|
+
|
|
125
|
+
let response: Response
|
|
126
|
+
try {
|
|
127
|
+
response = await fetch(url, { method: 'GET', headers })
|
|
128
|
+
} catch (err) {
|
|
129
|
+
throw new SalesBotError({
|
|
130
|
+
code: 'network_error',
|
|
131
|
+
message: err instanceof Error ? err.message : 'Network request failed',
|
|
132
|
+
retryable: true,
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!response.ok) {
|
|
137
|
+
await throwHttpError(response)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const body = (await response.json()) as { messages: HistoryMessage[] }
|
|
141
|
+
return body.messages ?? []
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* POST /api/chat/conversations/:id/end — mark the conversation as ended.
|
|
146
|
+
* Server soft-deletes the row; future history fetches return empty. The
|
|
147
|
+
* SDK clears its persisted conversationId after a successful call. The
|
|
148
|
+
* server is idempotent — already-ended conversations return ok:true.
|
|
149
|
+
*/
|
|
150
|
+
export async function postEndConversation(
|
|
151
|
+
conversationId: string,
|
|
152
|
+
visitorToken: string,
|
|
153
|
+
opts: TransportOptions,
|
|
154
|
+
): Promise<void> {
|
|
155
|
+
const headers: Record<string, string> = {
|
|
156
|
+
Authorization: `Bearer ${opts.embedKey}`,
|
|
157
|
+
'Content-Type': 'application/json',
|
|
158
|
+
Accept: 'application/json',
|
|
159
|
+
...opts.customHeaders,
|
|
160
|
+
}
|
|
161
|
+
let response: Response
|
|
162
|
+
try {
|
|
163
|
+
response = await fetch(
|
|
164
|
+
`${opts.baseUrl}/api/chat/conversations/${encodeURIComponent(conversationId)}/end`,
|
|
165
|
+
{ method: 'POST', headers, body: JSON.stringify({ visitorToken }) },
|
|
166
|
+
)
|
|
167
|
+
} catch (err) {
|
|
168
|
+
throw new SalesBotError({
|
|
169
|
+
code: 'network_error',
|
|
170
|
+
message: err instanceof Error ? err.message : 'Network request failed',
|
|
171
|
+
retryable: true,
|
|
172
|
+
})
|
|
173
|
+
}
|
|
174
|
+
if (!response.ok) await throwHttpError(response)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* GET /api/chat/turns/:turnId/stream — re-attaches to an in-flight turn.
|
|
179
|
+
* Returns the raw SSE ReadableStream on success.
|
|
180
|
+
*/
|
|
181
|
+
export async function getResumeStream(
|
|
182
|
+
turnId: string,
|
|
183
|
+
opts: TransportOptions,
|
|
184
|
+
): Promise<ReadableStream<Uint8Array>> {
|
|
185
|
+
const headers: Record<string, string> = {
|
|
186
|
+
Authorization: `Bearer ${opts.embedKey}`,
|
|
187
|
+
Accept: 'text/event-stream',
|
|
188
|
+
...opts.customHeaders,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
let response: Response
|
|
192
|
+
try {
|
|
193
|
+
response = await fetch(`${opts.baseUrl}/api/chat/turns/${turnId}/stream`, {
|
|
194
|
+
method: 'GET',
|
|
195
|
+
headers,
|
|
196
|
+
})
|
|
197
|
+
} catch (err) {
|
|
198
|
+
throw new SalesBotError({
|
|
199
|
+
code: 'network_error',
|
|
200
|
+
message: err instanceof Error ? err.message : 'Network request failed',
|
|
201
|
+
retryable: true,
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
await throwHttpError(response)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!response.body) {
|
|
210
|
+
throw new SalesBotError({ code: 'internal', message: 'Response body is null', retryable: false })
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return response.body
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Error mapping
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
async function throwHttpError(response: Response): Promise<never> {
|
|
221
|
+
let body: { code?: string; message?: string; retryable?: boolean; details?: Record<string, unknown> } = {}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const contentType = response.headers.get('content-type') ?? ''
|
|
225
|
+
if (contentType.includes('application/json')) {
|
|
226
|
+
body = (await response.json()) as typeof body
|
|
227
|
+
}
|
|
228
|
+
} catch {
|
|
229
|
+
// ignore parse errors — fall through to status-based mapping
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Prefer the code from the response body; fall back to status-based mapping
|
|
233
|
+
const code: SalesBotErrorCode = isValidErrorCode(body.code)
|
|
234
|
+
? body.code
|
|
235
|
+
: statusToCode(response.status)
|
|
236
|
+
|
|
237
|
+
throw new SalesBotError({
|
|
238
|
+
code,
|
|
239
|
+
message: body.message ?? response.statusText,
|
|
240
|
+
retryable: body.retryable ?? isRetryableStatus(response.status),
|
|
241
|
+
details: body.details,
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const VALID_CODES = new Set<SalesBotErrorCode>([
|
|
246
|
+
'invalid_embed_key', 'origin_not_allowed', 'rate_limited', 'out_of_credits',
|
|
247
|
+
'unauthorized', 'forbidden', 'not_found', 'bad_request', 'unprocessable_entity',
|
|
248
|
+
'conflict', 'internal', 'llm_unavailable', 'mcp_unavailable', 'network_error', 'parse_error',
|
|
249
|
+
])
|
|
250
|
+
|
|
251
|
+
function isValidErrorCode(code: unknown): code is SalesBotErrorCode {
|
|
252
|
+
return typeof code === 'string' && VALID_CODES.has(code as SalesBotErrorCode)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function statusToCode(status: number): SalesBotErrorCode {
|
|
256
|
+
switch (status) {
|
|
257
|
+
case 400: return 'bad_request'
|
|
258
|
+
case 401: return 'unauthorized'
|
|
259
|
+
case 402: return 'out_of_credits'
|
|
260
|
+
case 403: return 'forbidden'
|
|
261
|
+
case 404: return 'not_found'
|
|
262
|
+
case 409: return 'conflict'
|
|
263
|
+
case 422: return 'unprocessable_entity'
|
|
264
|
+
case 429: return 'rate_limited'
|
|
265
|
+
default: return 'internal'
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function isRetryableStatus(status: number): boolean {
|
|
270
|
+
return status === 429 || status >= 500
|
|
271
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core wire-format types for the Sales Bot SDK.
|
|
3
|
+
*
|
|
4
|
+
* These interfaces are kept verbatim-compatible with the backend's
|
|
5
|
+
* sse-events.ts (sub-project #1). Any change here is a breaking change
|
|
6
|
+
* to the wire format and must be coordinated with the backend team.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
// SSE event names (locked wire format)
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
|
|
13
|
+
export type SseEventName =
|
|
14
|
+
| 'turn_started'
|
|
15
|
+
| 'delta'
|
|
16
|
+
| 'tool_call_started'
|
|
17
|
+
| 'tool_call_finished'
|
|
18
|
+
| 'message_complete'
|
|
19
|
+
| 'usage'
|
|
20
|
+
| 'done'
|
|
21
|
+
| 'error'
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// SSE event payload types (verbatim from backend sse-events.ts)
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface TurnStartedEvent {
|
|
28
|
+
turnId: string
|
|
29
|
+
conversationId: string
|
|
30
|
+
endUserId: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DeltaEvent {
|
|
34
|
+
content: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ToolCallStartedEvent {
|
|
38
|
+
id: string
|
|
39
|
+
name: string
|
|
40
|
+
args: unknown
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ToolCallFinishedEvent {
|
|
44
|
+
id: string
|
|
45
|
+
ok: boolean
|
|
46
|
+
result?: unknown
|
|
47
|
+
error?: string
|
|
48
|
+
durationMs: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface MessageCompleteEvent {
|
|
52
|
+
messageId: string
|
|
53
|
+
content: string
|
|
54
|
+
modelId: string
|
|
55
|
+
promptTokens: number
|
|
56
|
+
completionTokens: number
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface UsageEventData {
|
|
60
|
+
kind: 'chat_completion' | 'mcp_tool_call' | 'dns_verification'
|
|
61
|
+
quantity: number
|
|
62
|
+
costBasisCents: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface DoneEvent {
|
|
66
|
+
turnId: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ErrorEvent {
|
|
70
|
+
code: string
|
|
71
|
+
message: string
|
|
72
|
+
retryable: boolean
|
|
73
|
+
details?: Record<string, unknown>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Discriminated union over all SSE events
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
export type SalesBotEvent =
|
|
81
|
+
| { event: 'turn_started'; data: TurnStartedEvent }
|
|
82
|
+
| { event: 'delta'; data: DeltaEvent }
|
|
83
|
+
| { event: 'tool_call_started'; data: ToolCallStartedEvent }
|
|
84
|
+
| { event: 'tool_call_finished'; data: ToolCallFinishedEvent }
|
|
85
|
+
| { event: 'message_complete'; data: MessageCompleteEvent }
|
|
86
|
+
| { event: 'usage'; data: UsageEventData }
|
|
87
|
+
| { event: 'done'; data: DoneEvent }
|
|
88
|
+
| { event: 'error'; data: ErrorEvent }
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Error
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
/** All error codes the SDK may produce — backend codes + SDK-only codes. */
|
|
95
|
+
export type SalesBotErrorCode =
|
|
96
|
+
// Backend error codes
|
|
97
|
+
| 'invalid_embed_key'
|
|
98
|
+
| 'origin_not_allowed'
|
|
99
|
+
| 'rate_limited'
|
|
100
|
+
| 'out_of_credits'
|
|
101
|
+
| 'unauthorized'
|
|
102
|
+
| 'forbidden'
|
|
103
|
+
| 'not_found'
|
|
104
|
+
| 'bad_request'
|
|
105
|
+
| 'unprocessable_entity'
|
|
106
|
+
| 'conflict'
|
|
107
|
+
| 'internal'
|
|
108
|
+
| 'llm_unavailable'
|
|
109
|
+
| 'mcp_unavailable'
|
|
110
|
+
// SDK-only codes
|
|
111
|
+
| 'network_error'
|
|
112
|
+
| 'parse_error'
|
|
113
|
+
|
|
114
|
+
export class SalesBotError extends Error {
|
|
115
|
+
readonly code: SalesBotErrorCode
|
|
116
|
+
readonly retryable: boolean
|
|
117
|
+
readonly details: Record<string, unknown> | undefined
|
|
118
|
+
|
|
119
|
+
constructor(opts: {
|
|
120
|
+
code: SalesBotErrorCode
|
|
121
|
+
message: string
|
|
122
|
+
retryable?: boolean
|
|
123
|
+
details?: Record<string, unknown>
|
|
124
|
+
}) {
|
|
125
|
+
super(opts.message)
|
|
126
|
+
this.name = 'SalesBotError'
|
|
127
|
+
this.code = opts.code
|
|
128
|
+
this.retryable = opts.retryable ?? false
|
|
129
|
+
this.details = opts.details !== undefined ? opts.details : undefined
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Client input types
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
export interface IdentifyInput {
|
|
138
|
+
externalId?: string
|
|
139
|
+
email?: string
|
|
140
|
+
name?: string
|
|
141
|
+
traits?: Record<string, unknown>
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface AskOptions {
|
|
145
|
+
conversationId?: string
|
|
146
|
+
metadata?: Record<string, unknown>
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export interface PostTurnInput {
|
|
150
|
+
visitorToken: string
|
|
151
|
+
message: string
|
|
152
|
+
identify?: IdentifyInput
|
|
153
|
+
conversationId?: string
|
|
154
|
+
metadata?: Record<string, unknown>
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Storage adapter
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
export interface StorageAdapter {
|
|
162
|
+
getItem(key: string): string | null
|
|
163
|
+
setItem(key: string, value: string): void
|
|
164
|
+
removeItem(key: string): void
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Client options
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
export interface SalesBotClientOptions {
|
|
172
|
+
/** The embed key from the bot config — format: pk_live_<32 chars> */
|
|
173
|
+
embedKey: string
|
|
174
|
+
/** Backend base URL. Defaults to 'http://localhost:3000' */
|
|
175
|
+
baseUrl?: string
|
|
176
|
+
/** Custom storage adapter. Defaults to localStorage with MemoryStorage fallback */
|
|
177
|
+
storage?: StorageAdapter
|
|
178
|
+
/** Extra headers merged into every request (e.g. { Origin: 'https://...' } for Node) */
|
|
179
|
+
customHeaders?: Record<string, string>
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// Message (used by framework adapters)
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
export interface Message {
|
|
187
|
+
id: string
|
|
188
|
+
role: 'user' | 'assistant'
|
|
189
|
+
content: string
|
|
190
|
+
/** True while the assistant is still streaming this message */
|
|
191
|
+
streaming?: boolean
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
// Unsubscribe function returned by on()
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
export type Unsubscribe = () => void
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// Widget options
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Typed CSS-variable theme. Every field maps to a `--sb-<kebab-case>` custom
|
|
206
|
+
* property set on the widget's shadow host. Pass any subset; omitted keys
|
|
207
|
+
* inherit the default. For anything not exposed here, use `customCss`.
|
|
208
|
+
*/
|
|
209
|
+
export interface WidgetTheme {
|
|
210
|
+
// ─── Colors ─────────────────────────────────────────────────────────────
|
|
211
|
+
primary?: string
|
|
212
|
+
primaryHover?: string
|
|
213
|
+
/** Text color used on top of `primary` surfaces (header, send button, user bubble). Default: white. */
|
|
214
|
+
primaryText?: string
|
|
215
|
+
text?: string
|
|
216
|
+
textLight?: string
|
|
217
|
+
bg?: string
|
|
218
|
+
bgUser?: string
|
|
219
|
+
bgAssistant?: string
|
|
220
|
+
bgError?: string
|
|
221
|
+
textError?: string
|
|
222
|
+
border?: string
|
|
223
|
+
focusRing?: string
|
|
224
|
+
|
|
225
|
+
// ─── Layout & sizing ────────────────────────────────────────────────────
|
|
226
|
+
/** Outer panel border-radius. */
|
|
227
|
+
radius?: string
|
|
228
|
+
/** Message bubble border-radius. */
|
|
229
|
+
messageRadius?: string
|
|
230
|
+
/** Input pill border-radius. */
|
|
231
|
+
inputRadius?: string
|
|
232
|
+
z?: string | number
|
|
233
|
+
/** Launcher button diameter. */
|
|
234
|
+
launcherSize?: string
|
|
235
|
+
/** Panel width when open. */
|
|
236
|
+
panelWidth?: string
|
|
237
|
+
/** Panel height when open. */
|
|
238
|
+
panelHeight?: string
|
|
239
|
+
/** Panel max-height on small screens. */
|
|
240
|
+
panelMaxHeight?: string
|
|
241
|
+
/** Distance between launcher and viewport bottom edge. */
|
|
242
|
+
bottomOffset?: string
|
|
243
|
+
/** Distance between launcher and viewport left/right edge. */
|
|
244
|
+
sideOffset?: string
|
|
245
|
+
/** Vertical gap between launcher and the open panel. */
|
|
246
|
+
panelGap?: string
|
|
247
|
+
/** Send-button diameter. */
|
|
248
|
+
sendSize?: string
|
|
249
|
+
|
|
250
|
+
// ─── Typography ─────────────────────────────────────────────────────────
|
|
251
|
+
fontFamily?: string
|
|
252
|
+
/** Base font size used by message bubbles and input. */
|
|
253
|
+
fontSize?: string
|
|
254
|
+
fontSizeHeader?: string
|
|
255
|
+
fontSizeSmall?: string
|
|
256
|
+
|
|
257
|
+
// ─── Effects ────────────────────────────────────────────────────────────
|
|
258
|
+
shadow?: string
|
|
259
|
+
/** CSS transition shorthand used by buttons and hover states. */
|
|
260
|
+
transition?: string
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export interface WidgetOptions extends SalesBotClientOptions {
|
|
264
|
+
container?: HTMLElement
|
|
265
|
+
position?: 'bottom-right' | 'bottom-left'
|
|
266
|
+
title?: string
|
|
267
|
+
placeholder?: string
|
|
268
|
+
/**
|
|
269
|
+
* Typed CSS-variable overrides. Granular control of colors, sizing,
|
|
270
|
+
* typography, and effects without leaving the shadow DOM.
|
|
271
|
+
*/
|
|
272
|
+
theme?: WidgetTheme
|
|
273
|
+
/**
|
|
274
|
+
* Escape hatch: raw CSS injected into the shadow root AFTER the built-in
|
|
275
|
+
* styles. Overrides anything by selector (e.g. `.sb-launcher { ... }`).
|
|
276
|
+
* Use sparingly — class names are not a stable API.
|
|
277
|
+
*/
|
|
278
|
+
customCss?: string
|
|
279
|
+
/** @deprecated Use `theme.primary` instead. */
|
|
280
|
+
primaryColor?: string
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export interface WidgetInstance {
|
|
284
|
+
open(): void
|
|
285
|
+
close(): void
|
|
286
|
+
toggle(): void
|
|
287
|
+
destroy(): void
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// Sales-workflow tool name discriminators
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
//
|
|
294
|
+
// The backend's SalesWorkflowDispatcher registers these tools with the LLM
|
|
295
|
+
// alongside MCP tools. They surface to the SDK via standard `tool_call_started`
|
|
296
|
+
// / `tool_call_finished` events with the names below. Consumers can narrow on
|
|
297
|
+
// these names via `isSalesWorkflowTool` for typed handling.
|
|
298
|
+
|
|
299
|
+
/** All sales-workflow tool names. Order is intentional (set_field/get_current
|
|
300
|
+
* first, quotes after). */
|
|
301
|
+
export const SALES_WORKFLOW_TOOL_NAMES = [
|
|
302
|
+
'project_brief__set_field',
|
|
303
|
+
'project_brief__get_current',
|
|
304
|
+
'quotes__generate',
|
|
305
|
+
'quotes__send_as_pdf',
|
|
306
|
+
'quotes__send_as_proposal',
|
|
307
|
+
] as const
|
|
308
|
+
|
|
309
|
+
export type SalesWorkflowToolName = (typeof SALES_WORKFLOW_TOOL_NAMES)[number]
|
|
310
|
+
|
|
311
|
+
/** Type guard for narrowing tool-call event names to SalesWorkflowToolName. */
|
|
312
|
+
export function isSalesWorkflowTool(name: string): name is SalesWorkflowToolName {
|
|
313
|
+
return (SALES_WORKFLOW_TOOL_NAMES as readonly string[]).includes(name)
|
|
314
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { StorageAdapter } from './types'
|
|
2
|
+
|
|
3
|
+
export const VISITOR_TOKEN_KEY_PREFIX = 'salesbot:visitor:'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Returns the persisted visitor token for a given embed key,
|
|
7
|
+
* or generates and persists a new UUID if none exists.
|
|
8
|
+
*
|
|
9
|
+
* The token is a stable identifier for this browser+bot combination,
|
|
10
|
+
* stored in the provided storage adapter under:
|
|
11
|
+
* salesbot:visitor:<embedKey>
|
|
12
|
+
*/
|
|
13
|
+
export function getOrCreateVisitorToken(embedKey: string, storage: StorageAdapter): string {
|
|
14
|
+
const key = `${VISITOR_TOKEN_KEY_PREFIX}${embedKey}`
|
|
15
|
+
const existing = storage.getItem(key)
|
|
16
|
+
if (existing !== null) return existing
|
|
17
|
+
|
|
18
|
+
const token = crypto.randomUUID()
|
|
19
|
+
storage.setItem(key, token)
|
|
20
|
+
return token
|
|
21
|
+
}
|