@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,182 @@
1
+ 'use client'
2
+
3
+ import { useCallback, useEffect, useRef, useState } from 'react'
4
+ import { SalesBotClient } from '../core/client'
5
+ import { SalesBotError, isSalesWorkflowTool } from '../core/types'
6
+ import type {
7
+ SalesBotClientOptions,
8
+ AskOptions,
9
+ Message,
10
+ DeltaEvent,
11
+ MessageCompleteEvent,
12
+ TurnStartedEvent,
13
+ ToolCallStartedEvent,
14
+ } from '../core/types'
15
+
16
+ export interface UseSalesBotOptions extends SalesBotClientOptions {
17
+ /** Optional initial conversationId (e.g. restored from URL hash) */
18
+ conversationId?: string
19
+ /**
20
+ * Fires when a sales-workflow tool call starts (e.g. quotes__send_as_pdf).
21
+ * Convenience over manually filtering tool_call_started events via
22
+ * isSalesWorkflowTool. Pass-through; receives the same payload.
23
+ */
24
+ onSalesToolCall?: (event: ToolCallStartedEvent) => void
25
+ }
26
+
27
+ export interface UseSalesBotResult {
28
+ ask: (message: string, opts?: AskOptions) => Promise<void>
29
+ messages: Message[]
30
+ isStreaming: boolean
31
+ error: SalesBotError | null
32
+ conversationId: string | null
33
+ visitorToken: string
34
+ reset: () => void
35
+ }
36
+
37
+ let msgCounter = 0
38
+ function nextId(): string {
39
+ return `msg-${++msgCounter}`
40
+ }
41
+
42
+ export function useSalesBot(opts: UseSalesBotOptions): UseSalesBotResult {
43
+ // Stable client reference — never re-created on re-render
44
+ const clientRef = useRef<SalesBotClient | null>(null)
45
+ if (clientRef.current === null) {
46
+ clientRef.current = new SalesBotClient(opts)
47
+ if (opts.conversationId) {
48
+ clientRef.current.setConversationId(opts.conversationId)
49
+ }
50
+ }
51
+ const client = clientRef.current
52
+
53
+ const [messages, setMessages] = useState<Message[]>([])
54
+ const [isStreaming, setIsStreaming] = useState(false)
55
+ const [error, setError] = useState<SalesBotError | null>(null)
56
+ const [conversationId, setConversationId] = useState<string | null>(
57
+ opts.conversationId ?? null,
58
+ )
59
+
60
+ // Keep latest onSalesToolCall in a ref so the event bus subscription can
61
+ // call the current handler without resubscribing on every render.
62
+ const onSalesToolCallRef = useRef<UseSalesBotOptions['onSalesToolCall']>(
63
+ opts.onSalesToolCall,
64
+ )
65
+ useEffect(() => {
66
+ onSalesToolCallRef.current = opts.onSalesToolCall
67
+ }, [opts.onSalesToolCall])
68
+
69
+ // Subscribe once for the client's lifetime; the ref above means we don't
70
+ // tear down + re-subscribe whenever a caller passes an inline callback.
71
+ useEffect(() => {
72
+ const off = client.on('tool_call_started', (e: ToolCallStartedEvent) => {
73
+ const cb = onSalesToolCallRef.current
74
+ if (!cb) return
75
+ if (isSalesWorkflowTool(e.name)) {
76
+ cb(e)
77
+ }
78
+ })
79
+ return () => {
80
+ off()
81
+ }
82
+ }, [client])
83
+
84
+ const ask = useCallback(
85
+ async (message: string, askOpts?: AskOptions): Promise<void> => {
86
+ setError(null)
87
+ setIsStreaming(true)
88
+
89
+ // Append user message immediately
90
+ const userMsgId = nextId()
91
+ setMessages((prev) => [
92
+ ...prev,
93
+ { id: userMsgId, role: 'user', content: message },
94
+ ])
95
+
96
+ // Create a placeholder for the streaming assistant message
97
+ const assistantMsgId = nextId()
98
+ let assistantContent = ''
99
+ let assistantAdded = false
100
+
101
+ try {
102
+ for await (const event of client.ask(message, askOpts)) {
103
+ switch (event.event) {
104
+ case 'turn_started': {
105
+ const data = event.data as TurnStartedEvent
106
+ setConversationId(data.conversationId)
107
+ break
108
+ }
109
+ case 'delta': {
110
+ const data = event.data as DeltaEvent
111
+ assistantContent += data.content
112
+ if (!assistantAdded) {
113
+ assistantAdded = true
114
+ setMessages((prev) => [
115
+ ...prev,
116
+ { id: assistantMsgId, role: 'assistant', content: assistantContent, streaming: true },
117
+ ])
118
+ } else {
119
+ setMessages((prev) =>
120
+ prev.map((m) =>
121
+ m.id === assistantMsgId
122
+ ? { ...m, content: assistantContent }
123
+ : m,
124
+ ),
125
+ )
126
+ }
127
+ break
128
+ }
129
+ case 'message_complete': {
130
+ const data = event.data as MessageCompleteEvent
131
+ // Replace with the canonical content from the backend
132
+ setMessages((prev) =>
133
+ prev.map((m) =>
134
+ m.id === assistantMsgId
135
+ ? { ...m, content: data.content, streaming: false }
136
+ : m,
137
+ ),
138
+ )
139
+ break
140
+ }
141
+ case 'done':
142
+ setIsStreaming(false)
143
+ break
144
+ case 'error':
145
+ // Will be caught below if it throws; otherwise set error state
146
+ break
147
+ }
148
+ }
149
+ } catch (err) {
150
+ const sbErr =
151
+ err instanceof SalesBotError
152
+ ? err
153
+ : new SalesBotError({ code: 'internal', message: String(err), retryable: false })
154
+ setError(sbErr)
155
+ // Remove the incomplete assistant message if nothing was streamed
156
+ if (!assistantAdded) {
157
+ setMessages((prev) => prev.filter((m) => m.id !== assistantMsgId))
158
+ }
159
+ } finally {
160
+ setIsStreaming(false)
161
+ }
162
+ },
163
+ [client],
164
+ )
165
+
166
+ const reset = useCallback(() => {
167
+ setMessages([])
168
+ setError(null)
169
+ setConversationId(null)
170
+ client.setConversationId(null)
171
+ }, [client])
172
+
173
+ return {
174
+ ask,
175
+ messages,
176
+ isStreaming,
177
+ error,
178
+ conversationId,
179
+ visitorToken: client.getVisitorToken(),
180
+ reset,
181
+ }
182
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Vanilla / IIFE entry point.
3
+ * Bundled by tsup as an IIFE exposing `window.SalesBot = { init, widget }`.
4
+ *
5
+ * Usage (plain HTML):
6
+ * <script src="dist/vanilla.global.js"></script>
7
+ * <script>
8
+ * const client = SalesBot.init({ embedKey: 'pk_live_...' })
9
+ * // or
10
+ * SalesBot.widget({ embedKey: 'pk_live_...' })
11
+ * </script>
12
+ */
13
+
14
+ import { SalesBotClient } from '../core/client'
15
+ import { createWidget } from '../widget/widget'
16
+ import type { SalesBotClientOptions, WidgetOptions, WidgetInstance } from '../core/types'
17
+
18
+ export interface SalesBotGlobal {
19
+ /** Create a bare SalesBotClient for full programmatic control */
20
+ init(opts: SalesBotClientOptions): SalesBotClient
21
+ /** Mount the floating chat widget */
22
+ widget(opts: WidgetOptions): WidgetInstance
23
+ }
24
+
25
+ const SalesBot: SalesBotGlobal = {
26
+ init(opts: SalesBotClientOptions): SalesBotClient {
27
+ return new SalesBotClient(opts)
28
+ },
29
+
30
+ widget(opts: WidgetOptions): WidgetInstance {
31
+ return createWidget(opts)
32
+ },
33
+ }
34
+
35
+ export default SalesBot
36
+
37
+ // For IIFE global assignment — tsup globalName handles the window.SalesBot assignment
38
+ export { SalesBot }
@@ -0,0 +1,2 @@
1
+ export { useSalesBot } from './use-sales-bot'
2
+ export type { UseSalesBotOptions, UseSalesBotResult } from './use-sales-bot'
@@ -0,0 +1,152 @@
1
+ import { onUnmounted, ref } from 'vue'
2
+ import type { Ref } from 'vue'
3
+ import { SalesBotClient } from '../core/client'
4
+ import { SalesBotError, isSalesWorkflowTool } from '../core/types'
5
+ import type {
6
+ SalesBotClientOptions,
7
+ AskOptions,
8
+ Message,
9
+ DeltaEvent,
10
+ MessageCompleteEvent,
11
+ TurnStartedEvent,
12
+ ToolCallStartedEvent,
13
+ Unsubscribe,
14
+ } from '../core/types'
15
+
16
+ export interface UseSalesBotOptions extends SalesBotClientOptions {
17
+ conversationId?: string
18
+ /**
19
+ * Fires when a sales-workflow tool call starts (e.g. quotes__send_as_pdf).
20
+ * Convenience over manually filtering tool_call_started events via
21
+ * isSalesWorkflowTool. Pass-through; receives the same payload.
22
+ */
23
+ onSalesToolCall?: (event: ToolCallStartedEvent) => void
24
+ }
25
+
26
+ export interface UseSalesBotResult {
27
+ ask: (message: string, opts?: AskOptions) => Promise<void>
28
+ messages: Ref<Message[]>
29
+ isStreaming: Ref<boolean>
30
+ error: Ref<SalesBotError | null>
31
+ conversationId: Ref<string | null>
32
+ visitorToken: string
33
+ reset: () => void
34
+ }
35
+
36
+ let msgCounter = 0
37
+ function nextId(): string {
38
+ return `vmsg-${++msgCounter}`
39
+ }
40
+
41
+ export function useSalesBot(opts: UseSalesBotOptions): UseSalesBotResult {
42
+ const client = new SalesBotClient(opts)
43
+ if (opts.conversationId) {
44
+ client.setConversationId(opts.conversationId)
45
+ }
46
+
47
+ const messages = ref<Message[]>([])
48
+ const isStreaming = ref(false)
49
+ const error = ref<SalesBotError | null>(null)
50
+ const conversationId = ref<string | null>(opts.conversationId ?? null)
51
+
52
+ // Track all unsubscribe functions for cleanup
53
+ const unsubs: Unsubscribe[] = []
54
+
55
+ // Optional sales-workflow tool callback. Subscribe once for the client's
56
+ // lifetime; we read opts.onSalesToolCall at call time so callers don't have
57
+ // to memoize the reference.
58
+ unsubs.push(
59
+ client.on('tool_call_started', (e: ToolCallStartedEvent) => {
60
+ const cb = opts.onSalesToolCall
61
+ if (!cb) return
62
+ if (isSalesWorkflowTool(e.name)) {
63
+ cb(e)
64
+ }
65
+ }),
66
+ )
67
+
68
+ async function ask(message: string, askOpts?: AskOptions): Promise<void> {
69
+ error.value = null
70
+ isStreaming.value = true
71
+
72
+ // Append user message immediately
73
+ const userMsgId = nextId()
74
+ messages.value = [...messages.value, { id: userMsgId, role: 'user', content: message }]
75
+
76
+ const assistantMsgId = nextId()
77
+ let assistantContent = ''
78
+ let assistantAdded = false
79
+
80
+ try {
81
+ for await (const event of client.ask(message, askOpts)) {
82
+ switch (event.event) {
83
+ case 'turn_started': {
84
+ const data = event.data as TurnStartedEvent
85
+ conversationId.value = data.conversationId
86
+ break
87
+ }
88
+ case 'delta': {
89
+ const data = event.data as DeltaEvent
90
+ assistantContent += data.content
91
+ if (!assistantAdded) {
92
+ assistantAdded = true
93
+ messages.value = [
94
+ ...messages.value,
95
+ { id: assistantMsgId, role: 'assistant', content: assistantContent, streaming: true },
96
+ ]
97
+ } else {
98
+ messages.value = messages.value.map((m) =>
99
+ m.id === assistantMsgId ? { ...m, content: assistantContent } : m,
100
+ )
101
+ }
102
+ break
103
+ }
104
+ case 'message_complete': {
105
+ const data = event.data as MessageCompleteEvent
106
+ messages.value = messages.value.map((m) =>
107
+ m.id === assistantMsgId ? { ...m, content: data.content, streaming: false } : m,
108
+ )
109
+ break
110
+ }
111
+ case 'done':
112
+ isStreaming.value = false
113
+ break
114
+ }
115
+ }
116
+ } catch (err) {
117
+ const sbErr =
118
+ err instanceof SalesBotError
119
+ ? err
120
+ : new SalesBotError({ code: 'internal', message: String(err), retryable: false })
121
+ error.value = sbErr
122
+ if (!assistantAdded) {
123
+ messages.value = messages.value.filter((m) => m.id !== assistantMsgId)
124
+ }
125
+ } finally {
126
+ isStreaming.value = false
127
+ }
128
+ }
129
+
130
+ function reset(): void {
131
+ messages.value = []
132
+ error.value = null
133
+ conversationId.value = null
134
+ client.setConversationId(null)
135
+ }
136
+
137
+ // Clean up event bus subscriptions on unmount
138
+ onUnmounted(() => {
139
+ for (const unsub of unsubs) unsub()
140
+ unsubs.length = 0
141
+ })
142
+
143
+ return {
144
+ ask,
145
+ messages,
146
+ isStreaming,
147
+ error,
148
+ conversationId,
149
+ visitorToken: client.getVisitorToken(),
150
+ reset,
151
+ }
152
+ }
@@ -0,0 +1,3 @@
1
+ export { createWidget } from './widget'
2
+ export { renderMarkdown } from './markdown'
3
+ export type { WidgetOptions, WidgetInstance, WidgetTheme } from '../core/types'
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Lightweight markdown renderer for assistant messages.
3
+ *
4
+ * Block-level: paragraphs (blank line separators), unordered lists (- or *),
5
+ * ordered lists (1. 2. …), line breaks inside paragraphs.
6
+ * Inline: **bold**, *italic*, `code`, [text](https://…).
7
+ *
8
+ * Streaming-safe: tolerates partial input (incomplete blocks at end). All
9
+ * source text is HTML-escaped first, so no XSS even when assistant content
10
+ * contains `<script>` etc.
11
+ *
12
+ * Intentionally small (≈120 lines, no external deps). For richer markdown
13
+ * (tables, code fences, headings) reach for marked / markdown-it.
14
+ */
15
+ export function renderMarkdown(text: string): string {
16
+ // 1. Escape HTML first — every subsequent transform emits trusted tags only.
17
+ const escaped = text
18
+ .replace(/&/g, '&amp;')
19
+ .replace(/</g, '&lt;')
20
+ .replace(/>/g, '&gt;')
21
+
22
+ // 2. Split into blocks on blank lines (one or more \n\s*\n).
23
+ const blocks = escaped.split(/\n\s*\n+/)
24
+ return blocks.map(renderBlock).filter(Boolean).join('')
25
+ }
26
+
27
+ function renderBlock(raw: string): string {
28
+ const trimmed = raw.replace(/^\s+|\s+$/g, '')
29
+ if (!trimmed) return ''
30
+
31
+ const lines = trimmed.split('\n')
32
+
33
+ // Unordered list: every non-empty line starts with - or * followed by space.
34
+ if (lines.every((l) => l.trim() === '' || /^[\-*]\s+/.test(l.trim()))) {
35
+ const items = lines
36
+ .filter((l) => l.trim())
37
+ .map((l) => `<li>${inline(l.trim().replace(/^[\-*]\s+/, ''))}</li>`)
38
+ return `<ul>${items.join('')}</ul>`
39
+ }
40
+
41
+ // Ordered list: every non-empty line starts with N. followed by space.
42
+ if (lines.every((l) => l.trim() === '' || /^\d+\.\s+/.test(l.trim()))) {
43
+ const items = lines
44
+ .filter((l) => l.trim())
45
+ .map((l) => `<li>${inline(l.trim().replace(/^\d+\.\s+/, ''))}</li>`)
46
+ return `<ol>${items.join('')}</ol>`
47
+ }
48
+
49
+ // Paragraph: inline-format each line, join with <br> for soft breaks.
50
+ const html = lines.map(inline).join('<br>')
51
+ return `<p>${html}</p>`
52
+ }
53
+
54
+ function inline(s: string): string {
55
+ return (
56
+ s
57
+ // **bold**
58
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
59
+ // *italic* (not inside bold)
60
+ .replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>')
61
+ // `code`
62
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
63
+ // [text](https://...) — only https links
64
+ .replace(
65
+ /\[([^\]]+)\]\((https:\/\/[^)]+)\)/g,
66
+ '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
67
+ )
68
+ )
69
+ }