@origonai/web-chat-sdk 0.1.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/src/chat.js ADDED
@@ -0,0 +1,376 @@
1
+ /**
2
+ * Chat Service for Chat SDK
3
+ * Handles real-time chat functionality without depending on external state
4
+ * Uses callbacks to communicate state changes to the consumer
5
+ */
6
+
7
+ import { fetchEventSource } from '@microsoft/fetch-event-source'
8
+ import { getMessages, authenticate } from './http.js'
9
+ import { getDeviceId, sleep } from './utils.js'
10
+ import { MESSAGE_ROLES } from './constants.js'
11
+
12
+ /**
13
+ * @typedef {Object} ChatCallbacks
14
+ * @property {(messages: Array) => void} [onMessagesUpdate] - Called when messages array should be updated
15
+ * @property {(isTyping: boolean) => void} [onTyping] - Called when typing status changes
16
+ * @property {(isLiveAgent: boolean) => void} [onLiveAgentMode] - Called when live agent mode status changes
17
+ * @property {(sessionId: string) => void} [onSessionUpdate] - Called when session ID is updated
18
+ */
19
+
20
+ /**
21
+ * @typedef {Object} ChatSession
22
+ * @property {string} sessionId
23
+ * @property {string} sseUrl
24
+ * @property {string} [requestId]
25
+ * @property {boolean} liveAgent
26
+ * @property {AbortController} [abortController]
27
+ * @property {string} [lastStreamId]
28
+ * @property {Array} messages
29
+ * @property {ChatCallbacks} callbacks
30
+ */
31
+
32
+ /**
33
+ * Create a new chat session
34
+ * @param {ChatCallbacks} [callbacks={}]
35
+ * @returns {ChatSession}
36
+ */
37
+ function createSession(callbacks = {}) {
38
+ return {
39
+ credentials: undefined,
40
+ authenticated: false,
41
+ configData: undefined,
42
+ sessionId: undefined,
43
+ requestId: undefined,
44
+ sseUrl: undefined,
45
+ abortController: undefined,
46
+ liveAgent: false,
47
+ lastStreamId: undefined,
48
+ messages: [],
49
+ callbacks
50
+ }
51
+ }
52
+
53
+ /** @type {ChatSession} */
54
+ let currentSession = createSession()
55
+
56
+ /**
57
+ * Set callbacks for the current session
58
+ * @param {ChatCallbacks} callbacks
59
+ */
60
+ export function setCallbacks(callbacks) {
61
+ currentSession.callbacks = { ...currentSession.callbacks, ...callbacks }
62
+ }
63
+
64
+ /**
65
+ * Initialize the chat session
66
+ * @param {Object} credentials - Credentials for the chat
67
+ */
68
+ export function initialize(credentials) {
69
+ console.log('Initializing chat...', credentials)
70
+ currentSession.credentials = credentials
71
+ if (credentials.token) {
72
+ currentSession.authenticated = true
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Get current chat session credentials
78
+ * @returns {{ endpoint: string, apiKey: string } | undefined}
79
+ */
80
+ export function getCredentials() {
81
+ return currentSession.credentials
82
+ }
83
+
84
+ /**
85
+ * Update the session ID and notify via callback
86
+ * @param {string} sessionId - The new session ID
87
+ */
88
+ export function updateSessionId(sessionId) {
89
+ if (sessionId && sessionId !== currentSession.sessionId) {
90
+ currentSession.sessionId = sessionId
91
+ currentSession.callbacks.onSessionUpdate?.(sessionId)
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Initiate a new chat session or resume an existing one
97
+ * @param {Object} credentials - Credentials for the chat
98
+ * @param {Object} payload - Payload for the chat. It contains sessionId (optional)
99
+ * @param {string} [payload.sessionId] - Optional session ID to resume
100
+ * @returns {Promise<{ sessionId: string, messages: Array }>}
101
+ */
102
+ export async function startChat(payload = {}) {
103
+ try {
104
+ console.log('startChat: ', payload, currentSession)
105
+
106
+ let configData = null
107
+ if (!currentSession.authenticated) {
108
+ configData = await authenticate(currentSession.credentials)
109
+ currentSession.authenticated = true
110
+ currentSession.configData = configData
111
+ } else {
112
+ configData = currentSession.configData
113
+ }
114
+
115
+ let messages = []
116
+
117
+ if (payload.sessionId) {
118
+ const messagesRes = await getMessages(payload.sessionId)
119
+ messages = (messagesRes?.sessionHistory ?? []).map((msg) => ({
120
+ id: msg.id,
121
+ text: msg.text,
122
+ role: msg.youtubeVideo
123
+ ? MESSAGE_ROLES.BOT // for youtube video messages, role is "system" from backend, we need to make it "assistant"
124
+ : msg.role,
125
+ timestamp: msg.timestamp,
126
+ video: msg.youtubeVideo,
127
+ channel: msg.channel,
128
+ done: true
129
+ }))
130
+ }
131
+
132
+ const searchParams = new URLSearchParams({
133
+ externalId: getExternalId()
134
+ })
135
+ currentSession.sseUrl = `${currentSession.credentials.endpoint}?${searchParams.toString()}`
136
+ currentSession.sessionId = payload.sessionId
137
+ currentSession.messages = messages
138
+
139
+ console.log('Chat initiated successfully')
140
+
141
+ return {
142
+ sessionId: currentSession.sessionId,
143
+ messages,
144
+ configData
145
+ }
146
+ } catch (error) {
147
+ console.error(`Failed to start chat: ${error.message}`)
148
+ cleanup()
149
+ throw error
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Disconnect from the current chat session
155
+ */
156
+ export function disconnect() {
157
+ cleanup()
158
+ }
159
+
160
+ /**
161
+ * Clean up the current session
162
+ */
163
+ function cleanup() {
164
+ if (currentSession.abortController) {
165
+ currentSession.abortController.abort()
166
+ }
167
+
168
+ const { callbacks, credentials } = currentSession
169
+ currentSession = createSession(callbacks)
170
+ currentSession.credentials = credentials
171
+
172
+ console.log('Chat session cleaned up')
173
+ }
174
+
175
+ export function getExternalId() {
176
+ if (currentSession.credentials?.externalId) {
177
+ return currentSession.credentials.externalId
178
+ }
179
+ return getDeviceId()
180
+ }
181
+
182
+ /**
183
+ * Send a message in the current chat session
184
+ * @param {{ text: string, html?: string }} message
185
+ * @returns {Promise<string>}
186
+ */
187
+ export function sendMessage({ text, html }) {
188
+ return new Promise((resolve, reject) => {
189
+ ;(async () => {
190
+ try {
191
+ // Add user message
192
+ const userMessage = {
193
+ role: MESSAGE_ROLES.USER,
194
+ text,
195
+ html,
196
+ timestamp: new Date().toISOString()
197
+ }
198
+ currentSession.messages = [...currentSession.messages, userMessage]
199
+ currentSession.callbacks.onMessagesUpdate?.([...currentSession.messages])
200
+
201
+ await sleep(200)
202
+
203
+ // Add loading message for bot if not in live agent mode
204
+ if (!currentSession.liveAgent) {
205
+ const loadingMessage = {
206
+ role: MESSAGE_ROLES.BOT,
207
+ text: '',
208
+ loading: true
209
+ }
210
+ currentSession.messages = [...currentSession.messages, loadingMessage]
211
+ currentSession.callbacks.onMessagesUpdate?.([...currentSession.messages])
212
+ } else {
213
+ resolve(currentSession.sessionId)
214
+ }
215
+
216
+ const url = new URL(currentSession.sseUrl)
217
+ if (currentSession.sessionId) {
218
+ url.searchParams.set('sessionId', currentSession.sessionId)
219
+ }
220
+ if (currentSession.requestId) {
221
+ url.searchParams.set('requestId', currentSession.requestId)
222
+ }
223
+
224
+ currentSession.lastStreamId = undefined
225
+
226
+ // Create a new abort controller for this request
227
+ currentSession.abortController = new AbortController()
228
+
229
+ const headers = {
230
+ 'Content-Type': 'application/json'
231
+ }
232
+ if (currentSession.credentials?.token) {
233
+ headers.Authorization = `Bearer ${currentSession.credentials.token}`
234
+ }
235
+
236
+ await fetchEventSource(url.toString(), {
237
+ method: 'POST',
238
+ headers,
239
+ body: JSON.stringify({
240
+ message: text,
241
+ html
242
+ }),
243
+ signal: currentSession.abortController.signal,
244
+ onopen: async (response) => {
245
+ if (!response.ok) {
246
+ console.error('Failed to send message bad response: ', response)
247
+ throw new Error('Failed to send message')
248
+ }
249
+ },
250
+ onmessage: (event) => {
251
+ console.log('Event: ', event)
252
+ const data = JSON.parse(event.data)
253
+
254
+ if (data.status === 'connected') {
255
+ currentSession.sessionId = data.sessionId
256
+ currentSession.requestId = data.requestId
257
+ if (currentSession.liveAgent) {
258
+ resolve(currentSession.sessionId)
259
+ }
260
+ } else if (data.agent) {
261
+ currentSession.liveAgent = true
262
+ const { type, data: payload } = data.agent
263
+
264
+ switch (type) {
265
+ case 'typing':
266
+ currentSession.callbacks.onTyping?.(true)
267
+ currentSession.callbacks.onLiveAgentMode?.(true)
268
+ break
269
+ case 'typingOff':
270
+ currentSession.callbacks.onTyping?.(false)
271
+ currentSession.callbacks.onLiveAgentMode?.(true)
272
+ break
273
+ case 'message':
274
+ if (payload.role !== MESSAGE_ROLES.USER) {
275
+ // Remove loading messages and add new message
276
+ currentSession.messages = currentSession.messages.filter((msg) => !msg.loading)
277
+ currentSession.messages = [...currentSession.messages, payload]
278
+
279
+ const isEnded = payload.action === 'ended' || payload.action === 'left'
280
+ if (isEnded) {
281
+ currentSession.liveAgent = false
282
+ }
283
+
284
+ currentSession.callbacks.onMessagesUpdate?.([...currentSession.messages])
285
+ currentSession.callbacks.onLiveAgentMode?.(!isEnded)
286
+ }
287
+ if (payload.action === 'ended' || payload.action === 'left') {
288
+ currentSession.liveAgent = false
289
+ }
290
+ break
291
+ default:
292
+ break
293
+ }
294
+ } else if (data.message !== undefined) {
295
+ let messages = currentSession.messages
296
+
297
+ // If streamId changes, start a new assistant message
298
+ if (data.streamId !== undefined) {
299
+ if (currentSession.lastStreamId === undefined) {
300
+ currentSession.lastStreamId = data.streamId
301
+ } else if (data.streamId !== currentSession.lastStreamId) {
302
+ currentSession.lastStreamId = data.streamId
303
+ messages = [
304
+ ...messages,
305
+ {
306
+ role: MESSAGE_ROLES.BOT,
307
+ text: '',
308
+ loading: true
309
+ }
310
+ ]
311
+ }
312
+ }
313
+
314
+ // Update the last message with new content
315
+ currentSession.messages = messages.map((msg, index) => {
316
+ if (index === messages.length - 1) {
317
+ return {
318
+ ...msg,
319
+ loading: false,
320
+ text: (msg.text || '') + data.message,
321
+ done: data.done ?? msg.done
322
+ }
323
+ }
324
+ return msg
325
+ })
326
+
327
+ currentSession.callbacks.onMessagesUpdate?.([...currentSession.messages])
328
+
329
+ if (data.done) {
330
+ resolve(currentSession.sessionId)
331
+ }
332
+
333
+ // Store session info for reuse
334
+ currentSession.sessionId = data.session_id ?? currentSession.sessionId
335
+ currentSession.requestId = data.requestId ?? currentSession.requestId
336
+ } else if (data.error) {
337
+ const errorMessage = 'Failed to connect to the system'
338
+ currentSession.messages = currentSession.messages.map((msg, index) => {
339
+ if (index === currentSession.messages.length - 1) {
340
+ return {
341
+ ...msg,
342
+ loading: false,
343
+ errorText: errorMessage
344
+ }
345
+ }
346
+ return msg
347
+ })
348
+ currentSession.callbacks.onMessagesUpdate?.([...currentSession.messages])
349
+ reject(new Error(errorMessage))
350
+ }
351
+ },
352
+ onerror: (error) => {
353
+ throw error // Rethrow to stop retries
354
+ },
355
+ openWhenHidden: true
356
+ })
357
+ } catch (error) {
358
+ console.error('Failed to send message: ', error)
359
+ const errorMessage = 'Failed to connect to the system'
360
+ currentSession.messages = currentSession.messages.map((msg, index) => {
361
+ if (index === currentSession.messages.length - 1) {
362
+ return {
363
+ ...msg,
364
+ loading: false,
365
+ errorText: errorMessage,
366
+ done: true
367
+ }
368
+ }
369
+ return msg
370
+ })
371
+ currentSession.callbacks.onMessagesUpdate?.([...currentSession.messages])
372
+ reject(error)
373
+ }
374
+ })()
375
+ })
376
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Constants for the Chat SDK
3
+ */
4
+
5
+ export const MESSAGE_ROLES = {
6
+ BOT: 'assistant', // this can be automated or LLM AI Agent response
7
+ USER: 'user', // this is widget user
8
+ AGENT: 'agent', // this is human agent (dock side)
9
+ SYSTEM: 'system' // this is system message, for ex "Agent joined" / "Agent left"
10
+ }
package/src/http.js ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * API Service for Chat SDK
3
+ * Handles all HTTP requests without depending on external state
4
+ */
5
+
6
+ import { getExternalId } from './chat.js'
7
+
8
+ const AUTHENTICATION_ERROR = 'Something went wrong initializing the chat'
9
+ const INITIALIZATION_ERROR = 'Chat SDK not initialized'
10
+
11
+ // Module-level configuration
12
+ let _config = {
13
+ endpoint: null
14
+ }
15
+
16
+ /**
17
+ * Configure the API service with endpoint
18
+ * @param {{ endpoint: string }} credentials
19
+ */
20
+ export function configure(credentials) {
21
+ _config = {
22
+ endpoint: credentials.endpoint
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Get current configuration
28
+ * @returns {{ endpoint: string | null }}
29
+ */
30
+ export function getConfig() {
31
+ return { ..._config }
32
+ }
33
+
34
+ /**
35
+ * Authenticate with the chat service
36
+ * @param {{ endpoint: string }} credentials
37
+ * @returns {Promise<object>} Authentication response data
38
+ */
39
+ export async function authenticate(payload) {
40
+ const { endpoint } = payload
41
+ const url = `${endpoint}/config`
42
+
43
+ const response = await fetch(url, {
44
+ method: 'GET',
45
+ headers: {
46
+ 'Content-Type': 'application/json'
47
+ }
48
+ })
49
+
50
+ if (!response.ok) {
51
+ const errorPayload = await response.json()
52
+ throw new Error(errorPayload?.error || AUTHENTICATION_ERROR)
53
+ }
54
+
55
+ const res = await response.json()
56
+ const data = res.data
57
+
58
+ // Store endpoint for subsequent requests
59
+ configure({ endpoint })
60
+
61
+ return data
62
+ }
63
+
64
+ /**
65
+ * Get chat history for the current device
66
+ * @returns {Promise<{ sessions: Array }>}
67
+ */
68
+ export async function getHistory() {
69
+ const queryParams = new URLSearchParams({
70
+ externalId: getExternalId()
71
+ })
72
+ const response = await fetchRequest(`/sessions?${queryParams.toString()}`, 'GET')
73
+
74
+ if (!response.ok) {
75
+ throw new Error('Unable to load history, please try again later')
76
+ }
77
+
78
+ return response.json()
79
+ }
80
+
81
+ /**
82
+ * Get messages for a specific session
83
+ * @param {string} sessionId
84
+ * @returns {Promise<{ sessionHistory: Array }>}
85
+ */
86
+ export async function getMessages(sessionId) {
87
+ const queryParams = new URLSearchParams({
88
+ sessionId
89
+ })
90
+ const response = await fetchRequest(`/session?${queryParams.toString()}`, 'GET')
91
+
92
+ if (!response.ok) {
93
+ throw new Error('Unable to load messages, please try again later')
94
+ }
95
+
96
+ return response.json()
97
+ }
98
+
99
+ /**
100
+ * Internal fetch request helper
101
+ * @param {string} pathname
102
+ * @param {string} method
103
+ * @param {object|null} body
104
+ * @returns {Promise<Response>}
105
+ */
106
+ async function fetchRequest(pathname, method = 'GET', body = null) {
107
+ const { endpoint } = _config
108
+
109
+ if (!endpoint) {
110
+ throw new Error(INITIALIZATION_ERROR)
111
+ }
112
+
113
+ const url = `${endpoint}${pathname}`
114
+
115
+ return fetch(url, {
116
+ headers: {
117
+ 'Content-Type': 'application/json'
118
+ },
119
+ method,
120
+ body: body ? JSON.stringify(body) : null
121
+ })
122
+ }
package/src/index.js ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @origon/chat-sdk
3
+ *
4
+ * Chat SDK for Origon/Samespace - provides core chat functionality
5
+ * without UI dependencies.
6
+ *
7
+ * Usage:
8
+ * ```js
9
+ * import {
10
+ * authenticate,
11
+ * initiate,
12
+ * sendMessage,
13
+ * disconnect,
14
+ * getHistory,
15
+ * setCallbacks
16
+ * } from '@origon/chat-sdk'
17
+ *
18
+ * // Authenticate first
19
+ * const config = await authenticate({ endpoint: '...', apiKey: '...' })
20
+ *
21
+ * // Set up callbacks for state updates
22
+ * setCallbacks({
23
+ * onMessage: (msg) => console.log('New message:', msg),
24
+ * onMessagesUpdate: (messages) => updateUI(messages),
25
+ * onTyping: (isTyping) => showTypingIndicator(isTyping),
26
+ * onError: (error) => console.error(error)
27
+ * })
28
+ *
29
+ * // Start a chat session
30
+ * const { sessionId, messages } = await initiate()
31
+ *
32
+ * // Send messages
33
+ * await sendMessage({ text: 'Hello!' })
34
+ *
35
+ * // Disconnect when done
36
+ * disconnect()
37
+ * ```
38
+ */
39
+
40
+ // HTTP API functions
41
+ export { authenticate, getHistory, getMessages, configure } from './http.js'
42
+
43
+ // Chat functions
44
+ export { initialize, startChat, sendMessage, disconnect, setCallbacks } from './chat.js'
45
+
46
+ // Call functions
47
+ export {
48
+ startCall,
49
+ disconnectCall,
50
+ toggleMute,
51
+ getLocalStream,
52
+ getInboundAudioEnergy,
53
+ getOutboundAudioEnergy,
54
+ setCallCallbacks
55
+ } from './call.js'
56
+
57
+ // Constants
58
+ export { MESSAGE_ROLES } from './constants.js'
package/src/utils.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Utility functions for the Chat SDK
3
+ */
4
+
5
+ export function uuidv7() {
6
+ const timestamp = Date.now()
7
+ const bytes = new Uint8Array(16)
8
+ crypto.getRandomValues(bytes)
9
+
10
+ // Set timestamp (48 bits)
11
+ bytes[0] = (timestamp >> 40) & 0xff
12
+ bytes[1] = (timestamp >> 32) & 0xff
13
+ bytes[2] = (timestamp >> 24) & 0xff
14
+ bytes[3] = (timestamp >> 16) & 0xff
15
+ bytes[4] = (timestamp >> 8) & 0xff
16
+ bytes[5] = timestamp & 0xff
17
+
18
+ // Set version 7 (0111)
19
+ bytes[6] = (bytes[6] & 0x0f) | 0x70
20
+
21
+ // Set variant (10xx)
22
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
23
+
24
+ const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')
25
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(
26
+ 16,
27
+ 20
28
+ )}-${hex.slice(20)}`
29
+ }
30
+
31
+ export function getDeviceId() {
32
+ if (localStorage.getItem('chatDeviceId')) {
33
+ return localStorage.getItem('chatDeviceId')
34
+ }
35
+
36
+ const deviceId = uuidv7()
37
+ localStorage.setItem('chatDeviceId', deviceId)
38
+ return deviceId
39
+ }
40
+
41
+ export function parseJwt(token) {
42
+ try {
43
+ const base64Url = token.split('.')[1]
44
+ const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/')
45
+ const jsonPayload = decodeURIComponent(
46
+ atob(base64)
47
+ .split('')
48
+ .map(function (c) {
49
+ return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
50
+ })
51
+ .join('')
52
+ )
53
+
54
+ return JSON.parse(jsonPayload)
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+
60
+ export async function sleep(ms) {
61
+ return new Promise((resolve) => setTimeout(resolve, ms))
62
+ }
63
+
64
+ export function getSocketEndpoint(baseUrl) {
65
+ let socketEndpoint
66
+ try {
67
+ const url = new URL(baseUrl)
68
+ socketEndpoint = `wss://${url.hostname}${url.pathname}/wss`
69
+ } catch {
70
+ console.error('Invalid base URL: ', baseUrl)
71
+ }
72
+ return socketEndpoint
73
+ }
74
+
75
+ export function getCallServerEndpoint(baseUrl) {
76
+ let socketEndpoint
77
+ try {
78
+ const url = new URL(baseUrl)
79
+ socketEndpoint = `wss://${url.hostname}${url.pathname}/audio`
80
+ } catch {
81
+ console.error('getCallServerEndpoint: Invalid base URL: ', baseUrl)
82
+ }
83
+ return socketEndpoint
84
+ }