@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,442 @@
|
|
|
1
|
+
import { SalesBotClient } from '../core/client'
|
|
2
|
+
import { SalesBotError, isSalesWorkflowTool } from '../core/types'
|
|
3
|
+
import type {
|
|
4
|
+
WidgetOptions,
|
|
5
|
+
WidgetInstance,
|
|
6
|
+
WidgetTheme,
|
|
7
|
+
DeltaEvent,
|
|
8
|
+
MessageCompleteEvent,
|
|
9
|
+
ToolCallStartedEvent,
|
|
10
|
+
ToolCallFinishedEvent,
|
|
11
|
+
SalesWorkflowToolName,
|
|
12
|
+
} from '../core/types'
|
|
13
|
+
import { WIDGET_CSS } from './styles'
|
|
14
|
+
import { renderMarkdown } from './markdown'
|
|
15
|
+
|
|
16
|
+
/** Friendly status text shown for each sales-workflow tool. */
|
|
17
|
+
const SALES_TOOL_PILL_TEXT: Record<SalesWorkflowToolName, string> = {
|
|
18
|
+
'project_brief__set_field': 'Saving project details…',
|
|
19
|
+
'project_brief__get_current': 'Checking project details…',
|
|
20
|
+
'quotes__generate': 'Generating quote…',
|
|
21
|
+
'quotes__send_as_pdf': 'Sending quote PDF…',
|
|
22
|
+
'quotes__send_as_proposal': 'Sending proposal for signing…',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Create and mount a floating chat widget using shadow DOM.
|
|
27
|
+
*
|
|
28
|
+
* Security note: All text content that gets rendered as HTML passes through
|
|
29
|
+
* renderMarkdown(), which HTML-escapes the source before applying markdown
|
|
30
|
+
* transforms. User-entered content is rendered with escapeHtml() only.
|
|
31
|
+
* No unsanitized external content is ever written to innerHTML.
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* const widget = createWidget({ embedKey: 'pk_live_xxx' })
|
|
35
|
+
* widget.open()
|
|
36
|
+
*/
|
|
37
|
+
export function createWidget(opts: WidgetOptions): WidgetInstance {
|
|
38
|
+
const container = opts.container ?? document.body
|
|
39
|
+
const position = opts.position ?? 'bottom-right'
|
|
40
|
+
const title = opts.title ?? 'Chat with us'
|
|
41
|
+
const placeholder = opts.placeholder ?? 'Type a message...'
|
|
42
|
+
|
|
43
|
+
// Create the custom element
|
|
44
|
+
const host = document.createElement('sales-bot-widget') as HTMLElement
|
|
45
|
+
if (position === 'bottom-left') host.classList.add('sb-position--bottom-left')
|
|
46
|
+
|
|
47
|
+
// Theme: typed CSS-var overrides land as inline custom properties on :host.
|
|
48
|
+
// Order is intentional: legacy `primaryColor` first, then `theme` so the
|
|
49
|
+
// typed API wins on collision.
|
|
50
|
+
if (opts.primaryColor) {
|
|
51
|
+
host.style.setProperty('--sb-primary', opts.primaryColor)
|
|
52
|
+
}
|
|
53
|
+
if (opts.theme) applyTheme(host, opts.theme)
|
|
54
|
+
|
|
55
|
+
// Attach shadow root
|
|
56
|
+
const shadow = host.attachShadow({ mode: 'open' })
|
|
57
|
+
|
|
58
|
+
// Inject base styles
|
|
59
|
+
const styleEl = document.createElement('style')
|
|
60
|
+
// Safe: WIDGET_CSS is a static compile-time string literal, not user input
|
|
61
|
+
styleEl.textContent = WIDGET_CSS
|
|
62
|
+
shadow.appendChild(styleEl)
|
|
63
|
+
|
|
64
|
+
// Escape-hatch: integrators inject raw CSS that wins via cascade order.
|
|
65
|
+
// It targets the same class names used internally and is scoped to the
|
|
66
|
+
// shadow root, so it can never leak out or be polluted by host-page CSS.
|
|
67
|
+
if (opts.customCss && opts.customCss.trim()) {
|
|
68
|
+
const customStyleEl = document.createElement('style')
|
|
69
|
+
customStyleEl.setAttribute('data-sb-custom', '1')
|
|
70
|
+
customStyleEl.textContent = opts.customCss
|
|
71
|
+
shadow.appendChild(customStyleEl)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Build DOM structure using textContent / setAttribute for safety
|
|
75
|
+
const launcher = createElement('button', 'sb-launcher', { 'aria-label': 'Open chat', type: 'button' })
|
|
76
|
+
// Safe: static SVG icon string, not user-supplied
|
|
77
|
+
setStaticSvgContent(launcher, CHAT_ICON_SVG)
|
|
78
|
+
|
|
79
|
+
const panel = createElement('div', 'sb-panel', { role: 'dialog', 'aria-label': title })
|
|
80
|
+
|
|
81
|
+
const header = createElement('div', 'sb-header')
|
|
82
|
+
const headerTitle = createElement('p', 'sb-header-title')
|
|
83
|
+
headerTitle.textContent = title // plain text — safe
|
|
84
|
+
const headerActions = createElement('div', 'sb-header-actions')
|
|
85
|
+
const newChatBtn = createElement('button', 'sb-new-chat-btn', {
|
|
86
|
+
'aria-label': 'Start a new chat',
|
|
87
|
+
title: 'Start a new chat',
|
|
88
|
+
type: 'button',
|
|
89
|
+
})
|
|
90
|
+
setStaticSvgContent(newChatBtn, NEW_CHAT_ICON_SVG)
|
|
91
|
+
const closeBtn = createElement('button', 'sb-close-btn', { 'aria-label': 'Close chat', type: 'button' })
|
|
92
|
+
closeBtn.textContent = '×' // plain text — safe
|
|
93
|
+
headerActions.appendChild(newChatBtn)
|
|
94
|
+
headerActions.appendChild(closeBtn)
|
|
95
|
+
header.appendChild(headerTitle)
|
|
96
|
+
header.appendChild(headerActions)
|
|
97
|
+
|
|
98
|
+
const messagesEl = createElement('div', 'sb-messages', { 'aria-live': 'polite' })
|
|
99
|
+
|
|
100
|
+
const inputRow = createElement('div', 'sb-input-row')
|
|
101
|
+
const input = createElement('input', 'sb-input', {
|
|
102
|
+
type: 'text',
|
|
103
|
+
placeholder,
|
|
104
|
+
'aria-label': 'Message input',
|
|
105
|
+
}) as HTMLInputElement
|
|
106
|
+
const sendBtn = createElement('button', 'sb-send', { 'aria-label': 'Send message', type: 'button' }) as HTMLButtonElement
|
|
107
|
+
setStaticSvgContent(sendBtn, SEND_ICON_SVG)
|
|
108
|
+
inputRow.appendChild(input)
|
|
109
|
+
inputRow.appendChild(sendBtn)
|
|
110
|
+
|
|
111
|
+
panel.appendChild(header)
|
|
112
|
+
panel.appendChild(messagesEl)
|
|
113
|
+
panel.appendChild(inputRow)
|
|
114
|
+
|
|
115
|
+
shadow.appendChild(launcher)
|
|
116
|
+
shadow.appendChild(panel)
|
|
117
|
+
container.appendChild(host)
|
|
118
|
+
|
|
119
|
+
// SalesBotClient
|
|
120
|
+
const client = new SalesBotClient(opts)
|
|
121
|
+
|
|
122
|
+
// Message rendering helpers
|
|
123
|
+
let streamingMsgEl: HTMLElement | null = null
|
|
124
|
+
let streamingContent = ''
|
|
125
|
+
|
|
126
|
+
function appendUserMessage(text: string): void {
|
|
127
|
+
const msg = createElement('div', 'sb-message sb-message--user')
|
|
128
|
+
msg.textContent = text // plain text — no HTML in user messages
|
|
129
|
+
messagesEl.appendChild(msg)
|
|
130
|
+
scrollToBottom()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function appendAssistantMessage(markdownContent: string): HTMLElement {
|
|
134
|
+
const msg = createElement('div', 'sb-message sb-message--assistant')
|
|
135
|
+
// Safe: renderMarkdown() HTML-escapes the source before applying transforms
|
|
136
|
+
setRenderedMarkdown(msg, markdownContent)
|
|
137
|
+
messagesEl.appendChild(msg)
|
|
138
|
+
scrollToBottom()
|
|
139
|
+
return msg
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function appendErrorMessage(text: string): void {
|
|
143
|
+
const msg = createElement('div', 'sb-message sb-message--error')
|
|
144
|
+
msg.textContent = text // plain text — safe
|
|
145
|
+
messagesEl.appendChild(msg)
|
|
146
|
+
scrollToBottom()
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function scrollToBottom() {
|
|
150
|
+
messagesEl.scrollTop = messagesEl.scrollHeight
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function showThinking(): HTMLElement {
|
|
154
|
+
const el = createElement('div', 'sb-message sb-message--assistant sb-thinking')
|
|
155
|
+
el.dataset['thinking'] = '1'
|
|
156
|
+
// Dots are created as real elements, not innerHTML
|
|
157
|
+
for (let i = 0; i < 3; i++) el.appendChild(document.createElement('span'))
|
|
158
|
+
messagesEl.appendChild(el)
|
|
159
|
+
scrollToBottom()
|
|
160
|
+
return el
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Interaction
|
|
164
|
+
async function sendMessage() {
|
|
165
|
+
const text = input.value.trim()
|
|
166
|
+
if (!text || sendBtn.disabled) return
|
|
167
|
+
|
|
168
|
+
input.value = ''
|
|
169
|
+
sendBtn.disabled = true
|
|
170
|
+
input.disabled = true
|
|
171
|
+
|
|
172
|
+
appendUserMessage(text)
|
|
173
|
+
|
|
174
|
+
// A single thinking indicator follows the conversation throughout the turn.
|
|
175
|
+
// We move it to the bottom every time it should be visible (after a segment
|
|
176
|
+
// completes, during tool calls) and only finally remove it on done/error.
|
|
177
|
+
let thinkingEl: HTMLElement | null = showThinking()
|
|
178
|
+
|
|
179
|
+
streamingMsgEl = null
|
|
180
|
+
streamingContent = ''
|
|
181
|
+
|
|
182
|
+
// Track ephemeral sales-workflow status pills by tool-call id so we can
|
|
183
|
+
// remove (or briefly flip to "Error") on the matching tool_call_finished.
|
|
184
|
+
const salesPills = new Map<string, HTMLElement>()
|
|
185
|
+
|
|
186
|
+
const ensureThinking = () => {
|
|
187
|
+
if (thinkingEl && thinkingEl.isConnected) {
|
|
188
|
+
messagesEl.appendChild(thinkingEl) // move to bottom
|
|
189
|
+
} else {
|
|
190
|
+
thinkingEl = showThinking()
|
|
191
|
+
}
|
|
192
|
+
scrollToBottom()
|
|
193
|
+
}
|
|
194
|
+
const hideThinking = () => {
|
|
195
|
+
if (thinkingEl && thinkingEl.isConnected) thinkingEl.remove()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
for await (const event of client.ask(text)) {
|
|
200
|
+
switch (event.event) {
|
|
201
|
+
case 'delta': {
|
|
202
|
+
const data = event.data as DeltaEvent
|
|
203
|
+
streamingContent += data.content
|
|
204
|
+
if (!streamingMsgEl) {
|
|
205
|
+
hideThinking()
|
|
206
|
+
streamingMsgEl = appendAssistantMessage(streamingContent)
|
|
207
|
+
} else {
|
|
208
|
+
setRenderedMarkdown(streamingMsgEl, streamingContent)
|
|
209
|
+
scrollToBottom()
|
|
210
|
+
}
|
|
211
|
+
break
|
|
212
|
+
}
|
|
213
|
+
case 'message_complete': {
|
|
214
|
+
// Finalize the current bubble with the server-confirmed content,
|
|
215
|
+
// then RESET so the next delta starts a fresh bubble. If a tool
|
|
216
|
+
// call or another LLM round follows, show "thinking" so the UI
|
|
217
|
+
// doesn't look frozen during the pause.
|
|
218
|
+
const data = event.data as MessageCompleteEvent
|
|
219
|
+
if (streamingMsgEl) setRenderedMarkdown(streamingMsgEl, data.content)
|
|
220
|
+
streamingMsgEl = null
|
|
221
|
+
streamingContent = ''
|
|
222
|
+
ensureThinking()
|
|
223
|
+
break
|
|
224
|
+
}
|
|
225
|
+
case 'tool_call_started': {
|
|
226
|
+
// Keep the thinking indicator visible during the tool call.
|
|
227
|
+
ensureThinking()
|
|
228
|
+
const tStart = event.data as ToolCallStartedEvent
|
|
229
|
+
if (isSalesWorkflowTool(tStart.name)) {
|
|
230
|
+
const pill = createElement('div', 'sb-sales-pill')
|
|
231
|
+
pill.textContent = SALES_TOOL_PILL_TEXT[tStart.name]
|
|
232
|
+
// Insert into the message stream container; placed before the
|
|
233
|
+
// thinking indicator so the pill sits above the next assistant
|
|
234
|
+
// bubble that will appear after the tool round-trip.
|
|
235
|
+
if (thinkingEl && thinkingEl.isConnected) {
|
|
236
|
+
messagesEl.insertBefore(pill, thinkingEl)
|
|
237
|
+
} else {
|
|
238
|
+
messagesEl.appendChild(pill)
|
|
239
|
+
}
|
|
240
|
+
salesPills.set(tStart.id, pill)
|
|
241
|
+
scrollToBottom()
|
|
242
|
+
}
|
|
243
|
+
break
|
|
244
|
+
}
|
|
245
|
+
case 'tool_call_finished': {
|
|
246
|
+
// Stay in "thinking" until the next delta (next LLM segment) starts.
|
|
247
|
+
ensureThinking()
|
|
248
|
+
const tEnd = event.data as ToolCallFinishedEvent
|
|
249
|
+
const pill = salesPills.get(tEnd.id)
|
|
250
|
+
if (pill) {
|
|
251
|
+
if (tEnd.ok === false) {
|
|
252
|
+
pill.textContent = 'Error'
|
|
253
|
+
pill.classList.add('sb-sales-pill-error')
|
|
254
|
+
salesPills.delete(tEnd.id)
|
|
255
|
+
setTimeout(() => {
|
|
256
|
+
pill.remove()
|
|
257
|
+
}, 4000)
|
|
258
|
+
} else {
|
|
259
|
+
pill.remove()
|
|
260
|
+
salesPills.delete(tEnd.id)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
break
|
|
264
|
+
}
|
|
265
|
+
case 'done': {
|
|
266
|
+
hideThinking()
|
|
267
|
+
break
|
|
268
|
+
}
|
|
269
|
+
case 'error': {
|
|
270
|
+
hideThinking()
|
|
271
|
+
appendErrorMessage(`Error: ${(event.data as { message: string }).message}`)
|
|
272
|
+
break
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Stream ended without an explicit done — safety net.
|
|
277
|
+
hideThinking()
|
|
278
|
+
} catch (err) {
|
|
279
|
+
hideThinking()
|
|
280
|
+
const msg = err instanceof SalesBotError ? err.message : 'Something went wrong. Please try again.'
|
|
281
|
+
appendErrorMessage(msg)
|
|
282
|
+
} finally {
|
|
283
|
+
sendBtn.disabled = false
|
|
284
|
+
input.disabled = false
|
|
285
|
+
input.focus()
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Event wiring
|
|
290
|
+
launcher.addEventListener('click', () => toggle())
|
|
291
|
+
closeBtn.addEventListener('click', () => close())
|
|
292
|
+
newChatBtn.addEventListener('click', () => void startNewChat())
|
|
293
|
+
sendBtn.addEventListener('click', () => { void sendMessage() })
|
|
294
|
+
input.addEventListener('keydown', (e: KeyboardEvent) => {
|
|
295
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
296
|
+
e.preventDefault()
|
|
297
|
+
void sendMessage()
|
|
298
|
+
}
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
// First-open hydration: either replay the prior conversation's transcript
|
|
302
|
+
// (after a page reload, when the SDK has a persisted conversationId) OR
|
|
303
|
+
// show the greeting (brand-new session). Runs at most once per widget
|
|
304
|
+
// instance — closing and reopening doesn't re-fetch.
|
|
305
|
+
let hydrated = false
|
|
306
|
+
async function hydrateOnce(): Promise<void> {
|
|
307
|
+
if (hydrated) return
|
|
308
|
+
hydrated = true
|
|
309
|
+
|
|
310
|
+
const existingConvId = client.getConversationId()
|
|
311
|
+
if (existingConvId) {
|
|
312
|
+
try {
|
|
313
|
+
const history = await client.loadHistory(existingConvId)
|
|
314
|
+
if (history.length === 0) {
|
|
315
|
+
// Conversation id was stored locally but the server has nothing
|
|
316
|
+
// for it (cleared DB in dev, expired, deleted, etc.). Clear the
|
|
317
|
+
// stale id and fall back to greeting so the widget isn't blank.
|
|
318
|
+
client.setConversationId(null)
|
|
319
|
+
await showGreetingIfConfigured()
|
|
320
|
+
return
|
|
321
|
+
}
|
|
322
|
+
for (const m of history) {
|
|
323
|
+
if (m.role === 'user') appendUserMessage(m.content)
|
|
324
|
+
else appendAssistantMessage(m.content)
|
|
325
|
+
}
|
|
326
|
+
} catch {
|
|
327
|
+
// Network / auth error fetching history — don't break the widget.
|
|
328
|
+
// User can still type a new message; any structural problem will
|
|
329
|
+
// surface on their first turn.
|
|
330
|
+
}
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
await showGreetingIfConfigured()
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Reset to a fresh conversation. Server-side: ends the current
|
|
339
|
+
* conversation (soft-deleted, dropped from admin list, history fetch
|
|
340
|
+
* returns empty). Client-side: clears the persisted conversationId so
|
|
341
|
+
* the next message starts a new Conversation row, wipes message
|
|
342
|
+
* bubbles, allows the greeting to fire again on this same widget
|
|
343
|
+
* instance.
|
|
344
|
+
*
|
|
345
|
+
* Swallows network errors — the user has clicked "start new chat" and
|
|
346
|
+
* they shouldn't see a failure; their local state still resets and
|
|
347
|
+
* the next turn will surface any structural issue.
|
|
348
|
+
*/
|
|
349
|
+
async function startNewChat(): Promise<void> {
|
|
350
|
+
try {
|
|
351
|
+
await client.endConversation()
|
|
352
|
+
} catch {
|
|
353
|
+
// already cleared local state inside endConversation on failure
|
|
354
|
+
}
|
|
355
|
+
// Wipe message bubbles + allow hydrateOnce to fire again.
|
|
356
|
+
while (messagesEl.firstChild) messagesEl.removeChild(messagesEl.firstChild)
|
|
357
|
+
streamingMsgEl = null
|
|
358
|
+
streamingContent = ''
|
|
359
|
+
hydrated = false
|
|
360
|
+
await hydrateOnce()
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function showGreetingIfConfigured(): Promise<void> {
|
|
364
|
+
try {
|
|
365
|
+
const cfg = await client.getBotConfig()
|
|
366
|
+
const greeting = (cfg.greetingMessage ?? '').trim()
|
|
367
|
+
if (greeting) appendAssistantMessage(greeting)
|
|
368
|
+
} catch {
|
|
369
|
+
// Bot config fetch failed (network, invalid key, etc.). Skip silently.
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Public API
|
|
374
|
+
function open() {
|
|
375
|
+
panel.classList.add('sb-panel--open')
|
|
376
|
+
input.focus()
|
|
377
|
+
void hydrateOnce()
|
|
378
|
+
}
|
|
379
|
+
function close() { panel.classList.remove('sb-panel--open') }
|
|
380
|
+
function toggle() { panel.classList.contains('sb-panel--open') ? close() : open() }
|
|
381
|
+
function destroy() { host.remove() }
|
|
382
|
+
|
|
383
|
+
return { open, close, toggle, destroy }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
// Helpers
|
|
388
|
+
// ---------------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
function createElement(
|
|
391
|
+
tag: string,
|
|
392
|
+
className: string,
|
|
393
|
+
attrs: Record<string, string> = {},
|
|
394
|
+
): HTMLElement {
|
|
395
|
+
const el = document.createElement(tag)
|
|
396
|
+
if (className) el.className = className
|
|
397
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
398
|
+
el.setAttribute(k, v)
|
|
399
|
+
}
|
|
400
|
+
return el
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Map a WidgetTheme to `--sb-<kebab>` custom properties on the shadow host.
|
|
405
|
+
* camelCase → kebab-case so the keys read naturally in TS while landing as
|
|
406
|
+
* the documented CSS-var names. Number values stringify (useful for `z`).
|
|
407
|
+
*/
|
|
408
|
+
function applyTheme(host: HTMLElement, theme: WidgetTheme): void {
|
|
409
|
+
for (const [key, value] of Object.entries(theme)) {
|
|
410
|
+
if (value === undefined || value === null) continue
|
|
411
|
+
const cssName = '--sb-' + key.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase())
|
|
412
|
+
host.style.setProperty(cssName, String(value))
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Set HTML content from a static compile-time SVG string.
|
|
418
|
+
* Only call this with string literals from this module — never user data.
|
|
419
|
+
*/
|
|
420
|
+
function setStaticSvgContent(el: HTMLElement, svgLiteral: string): void {
|
|
421
|
+
// eslint-disable-next-line -- intentional: static literal only, audited safe
|
|
422
|
+
el.innerHTML = svgLiteral
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Set rendered markdown content.
|
|
427
|
+
* renderMarkdown() HTML-escapes all input before processing — safe against XSS.
|
|
428
|
+
*/
|
|
429
|
+
function setRenderedMarkdown(el: HTMLElement, markdownSource: string): void {
|
|
430
|
+
// eslint-disable-next-line -- intentional: source is HTML-escaped by renderMarkdown()
|
|
431
|
+
el.innerHTML = renderMarkdown(markdownSource)
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Static SVG icon literals (not user input)
|
|
435
|
+
const NEW_CHAT_ICON_SVG =
|
|
436
|
+
`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"></path><path d="M5 12h14"></path></svg>`
|
|
437
|
+
|
|
438
|
+
const CHAT_ICON_SVG =
|
|
439
|
+
`<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>`
|
|
440
|
+
|
|
441
|
+
const SEND_ICON_SVG =
|
|
442
|
+
`<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>`
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CONTRACT TESTS — asserts the SDK's TypeScript types match the backend's
|
|
3
|
+
* locked wire format from sub-project #1's sse-events.ts.
|
|
4
|
+
*
|
|
5
|
+
* These are compile-time + runtime shape assertions. Any mismatch here
|
|
6
|
+
* means the SDK and backend are out of sync.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect } from 'vitest'
|
|
10
|
+
import type {
|
|
11
|
+
SseEventName,
|
|
12
|
+
TurnStartedEvent,
|
|
13
|
+
DeltaEvent,
|
|
14
|
+
ToolCallStartedEvent,
|
|
15
|
+
ToolCallFinishedEvent,
|
|
16
|
+
MessageCompleteEvent,
|
|
17
|
+
UsageEventData,
|
|
18
|
+
DoneEvent,
|
|
19
|
+
ErrorEvent,
|
|
20
|
+
SalesBotEvent,
|
|
21
|
+
SalesBotErrorCode,
|
|
22
|
+
} from '../../src/core/types'
|
|
23
|
+
|
|
24
|
+
describe('Wire format contract (locked for SDK)', () => {
|
|
25
|
+
it('SseEventName covers exactly the documented events', () => {
|
|
26
|
+
// These are the locked event names from the backend.
|
|
27
|
+
// Additive changes (new events) are allowed; removals require a version bump.
|
|
28
|
+
const names: SseEventName[] = [
|
|
29
|
+
'turn_started',
|
|
30
|
+
'delta',
|
|
31
|
+
'tool_call_started',
|
|
32
|
+
'tool_call_finished',
|
|
33
|
+
'message_complete',
|
|
34
|
+
'usage',
|
|
35
|
+
'done',
|
|
36
|
+
'error',
|
|
37
|
+
]
|
|
38
|
+
expect(names).toHaveLength(8)
|
|
39
|
+
names.forEach((n) => expect(typeof n).toBe('string'))
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('TurnStartedEvent has turnId, conversationId, endUserId', () => {
|
|
43
|
+
const e: TurnStartedEvent = {
|
|
44
|
+
turnId: 'turn-1',
|
|
45
|
+
conversationId: 'conv-1',
|
|
46
|
+
endUserId: 'eu-1',
|
|
47
|
+
}
|
|
48
|
+
expect(e).toMatchObject({
|
|
49
|
+
turnId: expect.any(String),
|
|
50
|
+
conversationId: expect.any(String),
|
|
51
|
+
endUserId: expect.any(String),
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('DeltaEvent has content', () => {
|
|
56
|
+
const e: DeltaEvent = { content: 'Hello' }
|
|
57
|
+
expect(e).toMatchObject({ content: expect.any(String) })
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('ToolCallStartedEvent has id, name, args', () => {
|
|
61
|
+
const e: ToolCallStartedEvent = { id: 'call-1', name: 'llms_txt__search', args: { q: 'hi' } }
|
|
62
|
+
expect(e).toMatchObject({
|
|
63
|
+
id: expect.any(String),
|
|
64
|
+
name: expect.any(String),
|
|
65
|
+
args: expect.any(Object),
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('ToolCallFinishedEvent has id, ok, optional result/error, durationMs', () => {
|
|
70
|
+
const success: ToolCallFinishedEvent = { id: 'c1', ok: true, result: { x: 1 }, durationMs: 42 }
|
|
71
|
+
expect(success).toMatchObject({ id: expect.any(String), ok: true, durationMs: expect.any(Number) })
|
|
72
|
+
|
|
73
|
+
const failure: ToolCallFinishedEvent = { id: 'c1', ok: false, error: 'boom', durationMs: 10 }
|
|
74
|
+
expect(failure).toMatchObject({ id: expect.any(String), ok: false, error: expect.any(String) })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('MessageCompleteEvent has messageId, content, modelId, promptTokens, completionTokens', () => {
|
|
78
|
+
const e: MessageCompleteEvent = {
|
|
79
|
+
messageId: 'm1',
|
|
80
|
+
content: 'hi',
|
|
81
|
+
modelId: 'anthropic/claude-sonnet-4-6',
|
|
82
|
+
promptTokens: 10,
|
|
83
|
+
completionTokens: 5,
|
|
84
|
+
}
|
|
85
|
+
expect(e).toMatchObject({
|
|
86
|
+
messageId: expect.any(String),
|
|
87
|
+
content: expect.any(String),
|
|
88
|
+
modelId: expect.any(String),
|
|
89
|
+
promptTokens: expect.any(Number),
|
|
90
|
+
completionTokens: expect.any(Number),
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('UsageEventData has kind, quantity, costBasisCents', () => {
|
|
95
|
+
const kinds: UsageEventData['kind'][] = ['chat_completion', 'mcp_tool_call', 'dns_verification']
|
|
96
|
+
kinds.forEach((kind) => {
|
|
97
|
+
const e: UsageEventData = { kind, quantity: 1, costBasisCents: 0.5 }
|
|
98
|
+
expect(e).toMatchObject({ kind: expect.any(String), quantity: expect.any(Number), costBasisCents: expect.any(Number) })
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('DoneEvent has turnId', () => {
|
|
103
|
+
const e: DoneEvent = { turnId: 'turn-1' }
|
|
104
|
+
expect(e).toMatchObject({ turnId: expect.any(String) })
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('ErrorEvent has code, message, retryable; details optional', () => {
|
|
108
|
+
const min: ErrorEvent = { code: 'out_of_credits', message: 'no credits', retryable: false }
|
|
109
|
+
expect(min).toMatchObject({
|
|
110
|
+
code: expect.any(String),
|
|
111
|
+
message: expect.any(String),
|
|
112
|
+
retryable: expect.any(Boolean),
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const full: ErrorEvent = {
|
|
116
|
+
code: 'rate_limited',
|
|
117
|
+
message: 'slow down',
|
|
118
|
+
retryable: true,
|
|
119
|
+
details: { retryAfterSeconds: 5 },
|
|
120
|
+
}
|
|
121
|
+
expect(full.details).toBeDefined()
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('SalesBotEvent is a discriminated union over all event types', () => {
|
|
125
|
+
// Runtime check: each union branch is a valid SalesBotEvent
|
|
126
|
+
const events: SalesBotEvent[] = [
|
|
127
|
+
{ event: 'turn_started', data: { turnId: 't', conversationId: 'c', endUserId: 'e' } },
|
|
128
|
+
{ event: 'delta', data: { content: 'hi' } },
|
|
129
|
+
{ event: 'tool_call_started', data: { id: 'i', name: 'n', args: {} } },
|
|
130
|
+
{ event: 'tool_call_finished', data: { id: 'i', ok: true, durationMs: 1 } },
|
|
131
|
+
{ event: 'message_complete', data: { messageId: 'm', content: 'c', modelId: 'x', promptTokens: 1, completionTokens: 1 } },
|
|
132
|
+
{ event: 'usage', data: { kind: 'chat_completion', quantity: 1, costBasisCents: 0 } },
|
|
133
|
+
{ event: 'done', data: { turnId: 't' } },
|
|
134
|
+
{ event: 'error', data: { code: 'internal', message: 'err', retryable: false } },
|
|
135
|
+
]
|
|
136
|
+
expect(events).toHaveLength(8)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('SalesBotErrorCode includes all backend codes plus SDK-only codes', () => {
|
|
140
|
+
const backendCodes: SalesBotErrorCode[] = [
|
|
141
|
+
'invalid_embed_key',
|
|
142
|
+
'origin_not_allowed',
|
|
143
|
+
'rate_limited',
|
|
144
|
+
'out_of_credits',
|
|
145
|
+
'unauthorized',
|
|
146
|
+
'forbidden',
|
|
147
|
+
'not_found',
|
|
148
|
+
'bad_request',
|
|
149
|
+
'unprocessable_entity',
|
|
150
|
+
'conflict',
|
|
151
|
+
'internal',
|
|
152
|
+
'llm_unavailable',
|
|
153
|
+
'mcp_unavailable',
|
|
154
|
+
]
|
|
155
|
+
const sdkCodes: SalesBotErrorCode[] = ['network_error', 'parse_error']
|
|
156
|
+
;[...backendCodes, ...sdkCodes].forEach((c) => expect(typeof c).toBe('string'))
|
|
157
|
+
})
|
|
158
|
+
})
|