open-chat-studio-widget 0.3.1 → 0.4.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 (39) hide show
  1. package/README.md +14 -7
  2. package/dist/cjs/{index-d39b7c53.js → index-b700441a.js} +83 -4
  3. package/dist/cjs/index-b700441a.js.map +1 -0
  4. package/dist/cjs/loader.cjs.js +2 -2
  5. package/dist/cjs/open-chat-studio-widget.cjs.entry.js +4846 -50
  6. package/dist/cjs/open-chat-studio-widget.cjs.entry.js.map +1 -1
  7. package/dist/cjs/open-chat-studio-widget.cjs.js +2 -2
  8. package/dist/collection/components/ocs-chat/heroicons.js +6 -12
  9. package/dist/collection/components/ocs-chat/heroicons.js.map +1 -1
  10. package/dist/collection/components/ocs-chat/ocs-chat.css +1094 -73
  11. package/dist/collection/components/ocs-chat/ocs-chat.js +752 -40
  12. package/dist/collection/components/ocs-chat/ocs-chat.js.map +1 -1
  13. package/dist/collection/utils/markdown.js +64 -0
  14. package/dist/collection/utils/markdown.js.map +1 -0
  15. package/dist/components/open-chat-studio-widget.js +4869 -52
  16. package/dist/components/open-chat-studio-widget.js.map +1 -1
  17. package/dist/esm/{index-b73ebc69.js → index-b188b488.js} +83 -4
  18. package/dist/esm/index-b188b488.js.map +1 -0
  19. package/dist/esm/loader.js +3 -3
  20. package/dist/esm/open-chat-studio-widget.entry.js +4846 -50
  21. package/dist/esm/open-chat-studio-widget.entry.js.map +1 -1
  22. package/dist/esm/open-chat-studio-widget.js +3 -3
  23. package/dist/open-chat-studio-widget/open-chat-studio-widget.esm.js +1 -1
  24. package/dist/open-chat-studio-widget/open-chat-studio-widget.esm.js.map +1 -1
  25. package/dist/open-chat-studio-widget/p-a0fbe1b4.js +3 -0
  26. package/dist/open-chat-studio-widget/p-a0fbe1b4.js.map +1 -0
  27. package/dist/open-chat-studio-widget/p-d2d76b54.entry.js +3 -0
  28. package/dist/open-chat-studio-widget/p-d2d76b54.entry.js.map +1 -0
  29. package/dist/types/components/ocs-chat/heroicons.d.ts +2 -4
  30. package/dist/types/components/ocs-chat/ocs-chat.d.ts +129 -4
  31. package/dist/types/components.d.ts +61 -5
  32. package/dist/types/utils/markdown.d.ts +6 -0
  33. package/package.json +4 -3
  34. package/dist/cjs/index-d39b7c53.js.map +0 -1
  35. package/dist/esm/index-b73ebc69.js.map +0 -1
  36. package/dist/open-chat-studio-widget/p-4cdc34c1.js +0 -3
  37. package/dist/open-chat-studio-widget/p-4cdc34c1.js.map +0 -1
  38. package/dist/open-chat-studio-widget/p-c4c7c404.entry.js +0 -2
  39. package/dist/open-chat-studio-widget/p-c4c7c404.entry.js.map +0 -1
@@ -1,12 +1,16 @@
1
- import { Host, h, Build } from "@stencil/core";
2
- import { ArrowLeftEndOnRectangleIcon, ArrowRightEndOnRectangleIcon, ArrowDownOnSquareIcon, ViewfinderCircleIcon, XMarkIcon, ChevronDownIcon, ChevronUpIcon, } from "./heroicons";
3
- const allowedHosts = ["chatbots.dimagi.com"];
1
+ import { Host, h } from "@stencil/core";
2
+ import { XMarkIcon, ChevronDownIcon, ChevronUpIcon, GripDotsVerticalIcon, PencilSquare, } from "./heroicons";
3
+ import { renderMarkdownSync as renderMarkdownComplete } from "../../utils/markdown";
4
4
  export class OcsChat {
5
5
  constructor() {
6
6
  /**
7
- * The text to display on the button.
7
+ * The base URL for the API (defaults to current origin).
8
8
  */
9
- this.buttonText = "Chat";
9
+ this.apiBaseUrl = "https://chatbots.dimagi.com";
10
+ /**
11
+ * The shape of the chat button. 'round' makes it circular, 'square' keeps it rectangular.
12
+ */
13
+ this.buttonShape = 'square';
10
14
  /**
11
15
  * Whether the chat widget is visible on load.
12
16
  */
@@ -19,26 +23,375 @@ export class OcsChat {
19
23
  * Whether the chat widget is initially expanded.
20
24
  */
21
25
  this.expanded = false;
26
+ /**
27
+ * Whether to persist session data to local storage to allow resuming previous conversations after page reload.
28
+ */
29
+ this.persistentSession = true;
30
+ /**
31
+ * Minutes since the most recent message after which the session data in local storage will expire. Set this to
32
+ * `0` to never expire.
33
+ */
34
+ this.persistentSessionExpire = 60 * 24;
22
35
  this.loaded = false;
23
36
  this.error = "";
37
+ this.messages = [];
38
+ this.isLoading = false;
39
+ this.isTyping = false;
40
+ this.messageInput = "";
41
+ this.isTaskPolling = false;
42
+ this.isDragging = false;
43
+ this.dragOffset = { x: 0, y: 0 };
44
+ this.windowPosition = { x: 0, y: 0 };
45
+ this.showStarterQuestions = true;
46
+ this.parsedWelcomeMessages = [];
47
+ this.parsedStarterQuestions = [];
48
+ this.handleMouseDown = (event) => {
49
+ if (window.innerWidth < OcsChat.MOBILE_BREAKPOINT)
50
+ return;
51
+ if (event.target.closest('button'))
52
+ return;
53
+ const pointer = this.getPointerCoordinates(event);
54
+ if (!pointer)
55
+ return;
56
+ this.startDrag(pointer);
57
+ this.addEventListeners();
58
+ event.preventDefault();
59
+ };
60
+ this.handleMouseMove = (event) => {
61
+ const pointer = this.getPointerCoordinates(event);
62
+ if (!pointer)
63
+ return;
64
+ this.updateDragPosition(pointer);
65
+ };
66
+ this.handleMouseUp = () => {
67
+ this.endDrag();
68
+ };
69
+ this.handleTouchStart = (event) => {
70
+ if (event.target.closest('button'))
71
+ return;
72
+ if (!this.chatWindowRef)
73
+ return;
74
+ const pointer = this.getPointerCoordinates(event);
75
+ if (!pointer)
76
+ return;
77
+ this.startDrag(pointer);
78
+ this.addEventListeners();
79
+ event.preventDefault();
80
+ };
81
+ this.handleTouchMove = (event) => {
82
+ const pointer = this.getPointerCoordinates(event);
83
+ if (!pointer)
84
+ return;
85
+ this.updateDragPosition(pointer);
86
+ event.preventDefault();
87
+ };
88
+ this.handleTouchEnd = () => {
89
+ this.endDrag();
90
+ };
91
+ this.handleWindowResize = () => {
92
+ this.initializePosition();
93
+ };
24
94
  }
25
95
  componentWillLoad() {
26
96
  this.loaded = this.visible;
27
- if (!Build.isDev) {
97
+ if (!this.chatbotId) {
98
+ this.error = 'Chatbot ID is required';
99
+ return;
100
+ }
101
+ // Always try to load existing session if localStorage is available
102
+ if (this.persistentSession && this.isLocalStorageAvailable()) {
103
+ const { sessionId, messages } = this.loadSessionFromStorage();
104
+ if (sessionId && messages) {
105
+ this.sessionId = sessionId;
106
+ this.messages = messages;
107
+ this.showStarterQuestions = messages.length === 0;
108
+ }
109
+ }
110
+ this.parseWelcomeMessages();
111
+ this.parseStarterQuestions();
112
+ }
113
+ componentDidLoad() {
114
+ // Only auto-start session if we don't have an existing one
115
+ if (this.visible && !this.sessionId) {
116
+ this.startSession();
117
+ }
118
+ else if (this.visible && this.sessionId) {
119
+ // Resume polling for existing session
120
+ this.startPolling();
121
+ }
122
+ this.initializePosition();
123
+ window.addEventListener('resize', this.handleWindowResize);
124
+ }
125
+ disconnectedCallback() {
126
+ this.cleanup();
127
+ this.removeEventListeners();
128
+ window.removeEventListener('resize', this.handleWindowResize);
129
+ }
130
+ parseJSONProp(propValue, propName) {
131
+ try {
132
+ if (propValue) {
133
+ try {
134
+ return JSON.parse(propValue);
135
+ }
136
+ catch (_a) {
137
+ const fixedValue = propValue.replace(/'/g, '"');
138
+ return JSON.parse(fixedValue);
139
+ }
140
+ }
141
+ }
142
+ catch (error) {
143
+ console.warn(`Failed to parse ${propName}:`, error);
144
+ }
145
+ return [];
146
+ }
147
+ parseWelcomeMessages() {
148
+ this.parsedWelcomeMessages = this.parseJSONProp(this.welcomeMessages, 'welcome messages');
149
+ }
150
+ parseStarterQuestions() {
151
+ this.parsedStarterQuestions = this.parseJSONProp(this.starterQuestions, 'starter questions');
152
+ }
153
+ cleanup() {
154
+ if (this.pollingInterval) {
155
+ clearInterval(this.pollingInterval);
156
+ this.pollingInterval = undefined;
157
+ }
158
+ this.isTaskPolling = false;
159
+ }
160
+ getApiBaseUrl() {
161
+ return this.apiBaseUrl || window.location.origin;
162
+ }
163
+ async startSession() {
164
+ try {
165
+ this.isLoading = true;
166
+ this.error = '';
167
+ const response = await fetch(`${this.getApiBaseUrl()}/api/chat/start/`, {
168
+ method: 'POST',
169
+ headers: {
170
+ 'Content-Type': 'application/json',
171
+ },
172
+ body: JSON.stringify({
173
+ chatbot_id: this.chatbotId,
174
+ session_data: {
175
+ source: 'widget',
176
+ page_url: window.location.href
177
+ }
178
+ })
179
+ });
180
+ if (!response.ok) {
181
+ throw new Error(`Failed to start session: ${response.statusText}`);
182
+ }
183
+ const data = await response.json();
184
+ this.sessionId = data.session_id;
185
+ this.saveSessionToStorage();
186
+ // Handle seed message if present
187
+ if (data.seed_message_task_id) {
188
+ this.isTyping = true; // Show typing indicator for seed message
189
+ await this.pollTaskResponse(data.seed_message_task_id);
190
+ }
191
+ // Start polling for messages
192
+ this.startPolling();
193
+ }
194
+ catch (error) {
195
+ this.error = error instanceof Error ? error.message : 'Failed to start chat session';
196
+ }
197
+ finally {
198
+ this.isLoading = false;
199
+ }
200
+ }
201
+ async sendMessage(message) {
202
+ if (!this.sessionId || !message.trim())
203
+ return;
204
+ // Hide starter questions on any user interaction
205
+ this.showStarterQuestions = false;
206
+ try {
207
+ // If this is the first user message and there are welcome messages,
208
+ // add them to chat history as assistant messages
209
+ if (this.messages.length === 0 && this.parsedWelcomeMessages.length > 0) {
210
+ const now = new Date();
211
+ const welcomeMessages = this.parsedWelcomeMessages.map((welcomeMsg, index) => ({
212
+ created_at: new Date(now.getTime() - (this.parsedWelcomeMessages.length - index) * 1000).toISOString(),
213
+ role: 'assistant',
214
+ content: welcomeMsg,
215
+ attachments: []
216
+ }));
217
+ this.messages = [...this.messages, ...welcomeMessages];
218
+ }
219
+ // Add user message immediately
220
+ const userMessage = {
221
+ created_at: new Date().toISOString(),
222
+ role: 'user',
223
+ content: message.trim(),
224
+ attachments: []
225
+ };
226
+ this.messages = [...this.messages, userMessage];
227
+ this.saveSessionToStorage();
228
+ this.messageInput = '';
229
+ this.scrollToBottom();
230
+ // Start typing indicator - it will stay on during task polling
231
+ this.isTyping = true;
232
+ const response = await fetch(`${this.getApiBaseUrl()}/api/chat/${this.sessionId}/message/`, {
233
+ method: 'POST',
234
+ headers: {
235
+ 'Content-Type': 'application/json',
236
+ },
237
+ body: JSON.stringify({ message: message.trim() })
238
+ });
239
+ if (!response.ok) {
240
+ throw new Error(`Failed to send message: ${response.statusText}`);
241
+ }
242
+ const data = await response.json();
243
+ if (data.status === 'error') {
244
+ throw new Error(data.error || 'Failed to send message');
245
+ }
246
+ // Poll for the response - typing indicator will be managed in pollTaskResponse
247
+ await this.pollTaskResponse(data.task_id);
248
+ }
249
+ catch (error) {
250
+ this.error = error instanceof Error ? error.message : 'Failed to send message';
251
+ // Clear typing indicator on error
252
+ this.isTyping = false;
253
+ }
254
+ }
255
+ handleStarterQuestionClick(question) {
256
+ this.sendMessage(question);
257
+ }
258
+ async pollTaskResponse(taskId) {
259
+ if (!this.sessionId)
260
+ return;
261
+ // Stop message polling while task polling is active
262
+ this.isTaskPolling = true;
263
+ this.pauseMessagePolling();
264
+ let attempts = 0;
265
+ const poll = async () => {
28
266
  try {
29
- const url = new URL(this.botUrl);
30
- if (!allowedHosts.includes(url.host)) {
31
- this.error = `Invalid Bot URL: ${this.botUrl}`;
267
+ const response = await fetch(`${this.getApiBaseUrl()}/api/chat/${this.sessionId}/${taskId}/poll/`);
268
+ if (!response.ok) {
269
+ throw new Error(`Failed to poll task: ${response.statusText}`);
270
+ }
271
+ const data = await response.json();
272
+ if (data.error) {
273
+ throw new Error(data.error);
32
274
  }
275
+ if (data.status === 'complete' && data.message) {
276
+ this.messages = [...this.messages, data.message];
277
+ this.saveSessionToStorage();
278
+ this.scrollToBottom();
279
+ // Task polling complete, clear typing indicator and resume message polling
280
+ this.isTyping = false;
281
+ this.isTaskPolling = false;
282
+ this.resumeMessagePolling();
283
+ this.focusInput();
284
+ return;
285
+ }
286
+ if (data.status === 'processing' && attempts < OcsChat.TASK_POLLING_MAX_ATTEMPTS) {
287
+ attempts++;
288
+ setTimeout(poll, OcsChat.TASK_POLLING_INTERVAL_MS);
289
+ }
290
+ else if (attempts >= OcsChat.TASK_POLLING_MAX_ATTEMPTS) {
291
+ // Task polling timed out, clear typing indicator and resume message polling
292
+ this.isTyping = false;
293
+ this.isTaskPolling = false;
294
+ this.resumeMessagePolling();
295
+ }
296
+ }
297
+ catch (error) {
298
+ this.error = error instanceof Error ? error.message : 'Failed to get response';
299
+ // Error in task polling, clear typing indicator and resume message polling
300
+ this.isTyping = false;
301
+ this.isTaskPolling = false;
302
+ this.resumeMessagePolling();
303
+ }
304
+ };
305
+ await poll();
306
+ }
307
+ startPolling() {
308
+ if (this.pollingInterval)
309
+ return;
310
+ this.pollingInterval = setInterval(async () => {
311
+ // Only poll for messages if not currently polling for a task
312
+ if (!this.isTaskPolling) {
313
+ await this.pollForMessages();
314
+ }
315
+ }, OcsChat.MESSAGE_POLLING_INTERVAL_MS);
316
+ }
317
+ pauseMessagePolling() {
318
+ if (this.pollingInterval) {
319
+ clearInterval(this.pollingInterval);
320
+ this.pollingInterval = undefined;
321
+ }
322
+ }
323
+ resumeMessagePolling() {
324
+ // Resume message polling after task polling is complete
325
+ this.startPolling();
326
+ }
327
+ async pollForMessages() {
328
+ if (!this.sessionId)
329
+ return;
330
+ try {
331
+ const url = new URL(`${this.getApiBaseUrl()}/api/chat/${this.sessionId}/poll/`);
332
+ if (this.messages && this.messages.length > 0) {
333
+ url.searchParams.set('since', this.messages.at(-1).created_at);
33
334
  }
34
- catch (_a) {
35
- this.error = `Invalid Bot URL: ${this.botUrl}`;
335
+ const response = await fetch(url.toString());
336
+ if (!response.ok)
337
+ return; // Silently fail for polling
338
+ const data = await response.json();
339
+ if (data.messages.length > 0) {
340
+ this.messages = [...this.messages, ...data.messages];
341
+ this.saveSessionToStorage();
342
+ this.scrollToBottom();
343
+ this.focusInput();
36
344
  }
345
+ this.lastPollTime = new Date();
346
+ }
347
+ catch (error) {
348
+ // Silently fail for polling
349
+ }
350
+ }
351
+ clearError() {
352
+ this.error = '';
353
+ }
354
+ scrollToBottom() {
355
+ setTimeout(() => {
356
+ if (this.messageListRef) {
357
+ this.messageListRef.scrollTop = this.messageListRef.scrollHeight;
358
+ }
359
+ }, OcsChat.SCROLL_DELAY_MS);
360
+ }
361
+ focusInput() {
362
+ setTimeout(() => {
363
+ if (this.textareaRef && !this.isTyping) {
364
+ this.textareaRef.focus();
365
+ }
366
+ }, OcsChat.FOCUS_DELAY_MS);
367
+ }
368
+ handleKeyPress(event) {
369
+ if (event.key === 'Enter' && !event.shiftKey) {
370
+ event.preventDefault();
371
+ this.sendMessage(this.messageInput);
37
372
  }
38
373
  }
39
- load() {
374
+ handleInputChange(event) {
375
+ this.messageInput = event.target.value;
376
+ // Hide starter questions when user starts typing
377
+ if (this.messageInput.trim().length > 0) {
378
+ this.showStarterQuestions = false;
379
+ }
380
+ }
381
+ formatTime(dateString) {
382
+ const date = new Date(dateString);
383
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
384
+ }
385
+ async load() {
40
386
  this.visible = !this.visible;
41
387
  this.loaded = true;
388
+ if (this.visible && !this.sessionId) {
389
+ this.clearError();
390
+ await this.startSession();
391
+ }
392
+ else if (!this.visible) {
393
+ // Don't reset session when closing, allow resume
394
+ }
42
395
  }
43
396
  setPosition(position) {
44
397
  if (position === this.position)
@@ -49,31 +402,229 @@ export class OcsChat {
49
402
  this.expanded = !this.expanded;
50
403
  }
51
404
  getPositionClasses() {
52
- const baseClasses = `fixed w-full sm:w-[450px] ${this.expanded ? 'h-5/6' : 'h-3/5'} bg-white border border-gray-200 shadow-lg rounded-lg overflow-hidden flex flex-col`;
53
- const positionClasses = {
54
- left: 'left-0 sm:left-5 bottom-0 sm:bottom-5',
55
- right: 'right-0 sm:right-5 bottom-0 sm:bottom-5',
56
- center: 'left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2'
57
- }[this.position];
58
- return `${baseClasses} ${positionClasses}`;
405
+ return `fixed w-full sm:w-[450px] ${this.expanded ? 'h-5/6' : 'h-3/5'} bg-white border border-gray-200 ${this.isDragging ? 'shadow-2xl cursor-grabbing' : 'shadow-lg transition-shadow duration-200'} rounded-lg overflow-hidden flex flex-col`;
406
+ }
407
+ getPositionStyles() {
408
+ return {
409
+ left: `${this.windowPosition.x}px`,
410
+ top: `${this.windowPosition.y}px`,
411
+ };
412
+ }
413
+ initializePosition() {
414
+ const windowWidth = window.innerWidth;
415
+ const windowHeight = window.innerHeight;
416
+ const chatWidth = windowWidth < OcsChat.MOBILE_BREAKPOINT ? windowWidth : OcsChat.CHAT_WIDTH_DESKTOP;
417
+ const chatHeight = this.expanded
418
+ ? (windowHeight * OcsChat.CHAT_HEIGHT_EXPANDED_RATIO)
419
+ : (windowHeight * OcsChat.CHAT_HEIGHT_COLLAPSED_RATIO);
420
+ const isMobile = windowWidth < OcsChat.MOBILE_BREAKPOINT;
421
+ if (isMobile) {
422
+ this.windowPosition = { x: 0, y: 0 };
423
+ return;
424
+ }
425
+ switch (this.position) {
426
+ case 'left':
427
+ this.windowPosition = {
428
+ x: OcsChat.WINDOW_MARGIN,
429
+ y: windowHeight - chatHeight - OcsChat.WINDOW_MARGIN
430
+ };
431
+ break;
432
+ case 'right':
433
+ this.windowPosition = {
434
+ x: windowWidth - chatWidth - OcsChat.WINDOW_MARGIN,
435
+ y: windowHeight - chatHeight - OcsChat.WINDOW_MARGIN
436
+ };
437
+ break;
438
+ case 'center':
439
+ this.windowPosition = {
440
+ x: (windowWidth - chatWidth) / 2,
441
+ y: (windowHeight - chatHeight) / 2
442
+ };
443
+ break;
444
+ }
445
+ }
446
+ getPointerCoordinates(event) {
447
+ if (event instanceof MouseEvent) {
448
+ return { clientX: event.clientX, clientY: event.clientY };
449
+ }
450
+ else if (event instanceof TouchEvent && event.touches.length === 1) {
451
+ const touch = event.touches[0];
452
+ return { clientX: touch.clientX, clientY: touch.clientY };
453
+ }
454
+ return null;
455
+ }
456
+ startDrag(pointer) {
457
+ if (!this.chatWindowRef)
458
+ return;
459
+ this.isDragging = true;
460
+ const rect = this.chatWindowRef.getBoundingClientRect();
461
+ this.dragOffset = {
462
+ x: pointer.clientX - rect.left,
463
+ y: pointer.clientY - rect.top
464
+ };
465
+ }
466
+ updateDragPosition(pointer) {
467
+ if (!this.isDragging)
468
+ return;
469
+ const newX = pointer.clientX - this.dragOffset.x;
470
+ const newY = pointer.clientY - this.dragOffset.y;
471
+ // Constrain chatbox to window
472
+ const windowWidth = window.innerWidth;
473
+ const windowHeight = window.innerHeight;
474
+ const chatWidth = windowWidth < OcsChat.MOBILE_BREAKPOINT ? windowWidth : OcsChat.CHAT_WIDTH_DESKTOP;
475
+ const chatHeight = this.expanded
476
+ ? (windowHeight * OcsChat.CHAT_HEIGHT_EXPANDED_RATIO)
477
+ : (windowHeight * OcsChat.CHAT_HEIGHT_COLLAPSED_RATIO);
478
+ this.windowPosition = {
479
+ x: Math.max(0, Math.min(newX, windowWidth - chatWidth)),
480
+ y: Math.max(0, Math.min(newY, windowHeight - chatHeight))
481
+ };
482
+ }
483
+ endDrag() {
484
+ this.isDragging = false;
485
+ this.removeEventListeners();
486
+ }
487
+ addEventListeners() {
488
+ document.addEventListener('mousemove', this.handleMouseMove);
489
+ document.addEventListener('mouseup', this.handleMouseUp);
490
+ document.addEventListener('touchmove', this.handleTouchMove, { passive: false });
491
+ document.addEventListener('touchend', this.handleTouchEnd);
492
+ }
493
+ removeEventListeners() {
494
+ document.removeEventListener('mousemove', this.handleMouseMove);
495
+ document.removeEventListener('mouseup', this.handleMouseUp);
496
+ document.removeEventListener('touchmove', this.handleTouchMove);
497
+ document.removeEventListener('touchend', this.handleTouchEnd);
498
+ }
499
+ getDefaultIconUrl() {
500
+ return `${this.getApiBaseUrl()}/static/images/favicons/favicon.svg`;
501
+ }
502
+ getButtonClasses() {
503
+ const hasText = this.buttonText && this.buttonText.trim();
504
+ const baseClass = hasText ? 'chat-btn-text' : 'chat-btn-icon';
505
+ const shapeClass = this.buttonShape === 'round' ? 'round' : '';
506
+ return `${baseClass} ${shapeClass}`.trim();
507
+ }
508
+ renderButton() {
509
+ const hasText = this.buttonText && this.buttonText.trim();
510
+ const hasCustomIcon = this.iconUrl && this.iconUrl.trim();
511
+ const iconSrc = hasCustomIcon ? this.iconUrl : this.getDefaultIconUrl();
512
+ const buttonClasses = this.getButtonClasses();
513
+ if (hasText) {
514
+ return (h("button", { class: buttonClasses, onClick: () => this.load(), "aria-label": `Open chat - ${this.buttonText}`, title: this.buttonText }, h("img", { src: iconSrc, alt: "" }), h("span", null, this.buttonText)));
515
+ }
516
+ else {
517
+ return (h("button", { class: buttonClasses, onClick: () => this.load(), "aria-label": "Open chat", title: "Open chat" }, h("img", { src: iconSrc, alt: "Chat" })));
518
+ }
519
+ }
520
+ getStorageKeys() {
521
+ return {
522
+ sessionId: `ocs-chat-session-${this.chatbotId}`,
523
+ messages: `ocs-chat-messages-${this.chatbotId}`,
524
+ lastActivity: `ocs-chat-activity-${this.chatbotId}`
525
+ };
526
+ }
527
+ saveSessionToStorage() {
528
+ if (!this.persistentSession) {
529
+ return;
530
+ }
531
+ const keys = this.getStorageKeys();
532
+ try {
533
+ if (this.sessionId) {
534
+ localStorage.setItem(keys.sessionId, this.sessionId);
535
+ localStorage.setItem(keys.lastActivity, new Date().toISOString());
536
+ }
537
+ localStorage.setItem(keys.messages, JSON.stringify(this.messages));
538
+ }
539
+ catch (error) {
540
+ console.warn('Failed to save chat session to localStorage:', error);
541
+ }
542
+ }
543
+ loadSessionFromStorage() {
544
+ const keys = this.getStorageKeys();
545
+ try {
546
+ if (this.persistentSessionExpire > 0) {
547
+ const lastActivity = localStorage.getItem(keys.lastActivity);
548
+ if (lastActivity) {
549
+ const lastActivityDate = new Date(lastActivity);
550
+ const minutesSinceActivity = (Date.now() - lastActivityDate.getTime()) / (1000 * 60);
551
+ if (minutesSinceActivity > this.persistentSessionExpire) {
552
+ this.clearSessionStorage();
553
+ return { messages: [] };
554
+ }
555
+ }
556
+ }
557
+ const storedSessionId = localStorage.getItem(keys.sessionId);
558
+ const sessionId = storedSessionId ? storedSessionId : undefined;
559
+ const messagesJson = localStorage.getItem(keys.messages);
560
+ let messages = [];
561
+ if (messagesJson) {
562
+ try {
563
+ const parsedMessages = JSON.parse(messagesJson);
564
+ messages = Array.isArray(parsedMessages) ? parsedMessages : [];
565
+ }
566
+ catch (parseError) {
567
+ console.warn('Failed to parse messages from localStorage:', parseError);
568
+ messages = [];
569
+ }
570
+ }
571
+ return { sessionId, messages };
572
+ }
573
+ catch (error) {
574
+ // fall back to starting a new session
575
+ console.warn('Failed to load chat session from localStorage, starting new session:', error);
576
+ return { messages: [] };
577
+ }
578
+ }
579
+ clearSessionStorage() {
580
+ const keys = this.getStorageKeys();
581
+ try {
582
+ localStorage.removeItem(keys.sessionId);
583
+ localStorage.removeItem(keys.messages);
584
+ localStorage.removeItem(keys.lastActivity);
585
+ }
586
+ catch (error) {
587
+ console.warn('Failed to clear chat session from localStorage:', error);
588
+ }
589
+ }
590
+ isLocalStorageAvailable() {
591
+ try {
592
+ localStorage.setItem(OcsChat.LOCALSTORAGE_TEST_KEY, 'test');
593
+ localStorage.removeItem(OcsChat.LOCALSTORAGE_TEST_KEY);
594
+ return true;
595
+ }
596
+ catch (_a) {
597
+ return false;
598
+ }
599
+ }
600
+ async startNewChat() {
601
+ this.clearSessionStorage();
602
+ this.sessionId = undefined;
603
+ this.messages = [];
604
+ this.showStarterQuestions = true;
605
+ this.isTyping = false;
606
+ this.error = '';
607
+ this.cleanup();
608
+ await this.startSession();
59
609
  }
60
610
  render() {
61
611
  if (this.error) {
62
- return (h(Host, null, h("p", null, this.error)));
63
- }
64
- return (h(Host, null, h("button", { class: "btn", onClick: () => this.load() }, this.buttonText), this.visible && (h("div", { id: "ocs-chat-window", class: this.getPositionClasses() }, h("div", { class: "flex justify-between items-center px-2 py-2 border-b border-gray-100" }, h("div", { class: "flex gap-1" }, h("button", { class: {
65
- 'hidden sm:block p-1.5 rounded-md transition-colors duration-200 hover:bg-gray-100': true,
66
- 'text-blue-600': this.position === 'left',
67
- 'text-gray-500': this.position !== 'left'
68
- }, onClick: () => this.setPosition('left'), "aria-label": "Dock to left", title: "Dock to left" }, h(ArrowLeftEndOnRectangleIcon, null)), h("button", { class: {
69
- 'p-1.5 rounded-md transition-colors duration-200 hover:bg-gray-100': true,
70
- 'text-blue-600': this.position === 'center',
71
- 'text-gray-500': this.position !== 'center'
72
- }, onClick: () => this.setPosition('center'), "aria-label": "Center", title: "Center" }, h(ViewfinderCircleIcon, null)), h("button", { class: {
73
- 'p-1.5 rounded-md transition-colors duration-200 hover:bg-gray-100': true,
74
- 'text-blue-600': this.position === 'right',
75
- 'text-gray-500': this.position !== 'right'
76
- }, onClick: () => this.setPosition('right'), "aria-label": "Dock to right", title: "Dock to right" }, h("span", { class: "hidden sm:block" }, h(ArrowRightEndOnRectangleIcon, null)), h("span", { class: "sm:hidden" }, h(ArrowDownOnSquareIcon, null)))), h("div", { class: "flex gap-1" }, h("button", { class: "p-1.5 rounded-md transition-colors duration-200 hover:bg-gray-100 text-gray-500", onClick: () => this.toggleSize(), "aria-label": this.expanded ? "Collapse" : "Expand", title: this.expanded ? "Collapse" : "Expand" }, this.expanded ? h(ChevronDownIcon, null) : h(ChevronUpIcon, null)), h("button", { class: "p-1.5 hover:bg-gray-100 rounded-md transition-colors duration-200 text-gray-500", onClick: () => this.visible = false, "aria-label": "Close" }, h(XMarkIcon, null)))), this.loaded && (h("iframe", { class: "flex-grow w-full border-none iframe-placeholder", src: this.botUrl }))))));
612
+ return (h(Host, null, h("p", { class: "text-red-500 p-2" }, this.error)));
613
+ }
614
+ return (h(Host, null, this.renderButton(), this.visible && (h("div", { ref: (el) => this.chatWindowRef = el, id: "ocs-chat-window", class: this.getPositionClasses(), style: this.getPositionStyles() }, h("div", { class: `flex justify-between items-center px-2 py-2 border-b border-gray-100 sm:${this.isDragging ? 'cursor-grabbing' : 'cursor-grab'} active:bg-gray-50 sm:hover:bg-gray-25 transition-colors duration-150`, onMouseDown: this.handleMouseDown, onTouchStart: this.handleTouchStart }, h("div", { class: "hidden sm:flex gap-1" }, h("div", { class: "flex gap-0.5 ml-2 pointer-events-none" }, h(GripDotsVerticalIcon, null))), h("div", null), h("div", { class: "flex gap-1 items-center" }, this.sessionId && this.messages.length > 0 && (h("button", { class: "p-1.5 rounded-md transition-colors duration-200 hover:bg-gray-100 text-gray-500", onClick: () => this.startNewChat(), title: "Start new chat", "aria-label": "Start new chat" }, h(PencilSquare, null))), h("button", { class: "p-1.5 rounded-md transition-colors duration-200 hover:bg-gray-100 text-gray-500", onClick: () => this.toggleSize(), "aria-label": this.expanded ? "Collapse" : "Expand", title: this.expanded ? "Collapse" : "Expand" }, this.expanded ? h(ChevronDownIcon, null) : h(ChevronUpIcon, null)), h("button", { class: "p-1.5 hover:bg-gray-100 rounded-md transition-colors duration-200 text-gray-500", onClick: () => this.visible = false, "aria-label": "Close" }, h(XMarkIcon, null)))), h("div", { class: "flex flex-col flex-grow overflow-hidden" }, this.isLoading && !this.sessionId && (h("div", { class: "flex items-center justify-center flex-grow" }, h("div", { class: "loading-spinner" }), h("span", { class: "ml-2 text-gray-500" }, "Starting chat..."))), this.sessionId && (h("div", { ref: (el) => this.messageListRef = el, class: "flex-grow overflow-y-auto p-4 space-y-4" }, this.messages.length === 0 && !this.isTyping && this.parsedWelcomeMessages.length > 0 && (h("div", { class: "space-y-4" }, this.parsedWelcomeMessages.map((message, index) => (h("div", { key: `welcome-${index}`, class: "flex justify-start" }, h("div", { class: "bg-gray-200 text-gray-800 max-w-xs lg:max-w-md px-4 py-2 rounded-lg" }, h("div", { class: "chat-markdown", innerHTML: renderMarkdownComplete(message) }))))))), this.messages.map((message, index) => (h("div", { key: index, class: {
615
+ 'flex': true,
616
+ 'justify-end': message.role === 'user',
617
+ 'justify-start': message.role !== 'user'
618
+ } }, h("div", { class: {
619
+ 'max-w-xs lg:max-w-md px-4 py-2 rounded-lg': true,
620
+ 'bg-blue-500 text-white': message.role === 'user',
621
+ 'bg-gray-200 text-gray-800': message.role === 'assistant',
622
+ 'bg-gray-100 text-gray-600 text-sm': message.role === 'system'
623
+ } }, h("div", { class: "chat-markdown", innerHTML: renderMarkdownComplete(message.content) }), message.attachments && message.attachments.length > 0 && (h("div", { class: "mt-2 space-y-1" }, message.attachments.map((attachment, attachmentIndex) => (h("a", { key: attachmentIndex, href: attachment.content_url, target: "_blank", rel: "noopener noreferrer", class: "block text-sm underline hover:no-underline" }, "\uD83D\uDCCE ", attachment.name))))), h("div", { class: "text-xs opacity-70 mt-1" }, this.formatTime(message.created_at)))))), this.isTyping && (h("div", { class: "flex justify-start" }, h("div", { class: "bg-gray-200 text-gray-800 max-w-xs lg:max-w-md px-2 py-2 rounded-lg" }, h("div", { class: "flex items-center gap-0.5" }, h("span", { class: "inline-block w-2 h-2 rounded-full bg-gray-400 animate-bounce" }), h("span", { class: "inline-block w-2 h-2 rounded-full bg-gray-400 animate-bounce", style: { animationDelay: '0.1s' } }), h("span", { class: "inline-block w-2 h-2 rounded-full bg-gray-400 animate-bounce", style: { animationDelay: '0.2s' } }))))))), this.sessionId && this.showStarterQuestions && this.messages.length === 0 && !this.isTyping && (h("div", { class: "p-4 space-y-2" }, this.parsedStarterQuestions.map((question, index) => (h("div", { key: `starter-${index}`, class: "flex justify-end" }, h("button", { class: "starter-question", onClick: () => this.handleStarterQuestionClick(question) }, question)))))), this.sessionId && (h("div", { class: "border-t border-gray-200 p-4" }, h("div", { class: "flex gap-2" }, h("textarea", { ref: (el) => this.textareaRef = el, class: "flex-grow px-3 py-2 border border-gray-300 rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent", rows: 1, placeholder: "Type your message...", value: this.messageInput, onInput: (e) => this.handleInputChange(e), onKeyPress: (e) => this.handleKeyPress(e), disabled: this.isTyping }), h("button", { class: {
624
+ 'px-4 py-2 rounded-md font-medium transition-colors duration-200': true,
625
+ 'bg-blue-500 hover:bg-blue-600 text-white': !this.isTyping && !!this.messageInput.trim(),
626
+ 'bg-gray-300 text-gray-500 cursor-not-allowed': this.isTyping || !this.messageInput.trim()
627
+ }, onClick: () => this.sendMessage(this.messageInput), disabled: this.isTyping || !this.messageInput.trim() }, "Send")))))))));
77
628
  }
78
629
  static get is() { return "open-chat-studio-widget"; }
79
630
  static get encapsulation() { return "shadow"; }
@@ -89,7 +640,7 @@ export class OcsChat {
89
640
  }
90
641
  static get properties() {
91
642
  return {
92
- "botUrl": {
643
+ "chatbotId": {
93
644
  "type": "string",
94
645
  "mutable": false,
95
646
  "complexType": {
@@ -101,13 +652,33 @@ export class OcsChat {
101
652
  "optional": false,
102
653
  "docs": {
103
654
  "tags": [],
104
- "text": "The URL of the bot to connect to."
655
+ "text": "The ID of the chatbot to connect to."
105
656
  },
106
657
  "getter": false,
107
658
  "setter": false,
108
- "attribute": "bot-url",
659
+ "attribute": "chatbot-id",
109
660
  "reflect": false
110
661
  },
662
+ "apiBaseUrl": {
663
+ "type": "string",
664
+ "mutable": false,
665
+ "complexType": {
666
+ "original": "string",
667
+ "resolved": "string",
668
+ "references": {}
669
+ },
670
+ "required": false,
671
+ "optional": true,
672
+ "docs": {
673
+ "tags": [],
674
+ "text": "The base URL for the API (defaults to current origin)."
675
+ },
676
+ "getter": false,
677
+ "setter": false,
678
+ "attribute": "api-base-url",
679
+ "reflect": false,
680
+ "defaultValue": "\"https://chatbots.dimagi.com\""
681
+ },
111
682
  "buttonText": {
112
683
  "type": "string",
113
684
  "mutable": false,
@@ -117,7 +688,7 @@ export class OcsChat {
117
688
  "references": {}
118
689
  },
119
690
  "required": false,
120
- "optional": false,
691
+ "optional": true,
121
692
  "docs": {
122
693
  "tags": [],
123
694
  "text": "The text to display on the button."
@@ -125,8 +696,46 @@ export class OcsChat {
125
696
  "getter": false,
126
697
  "setter": false,
127
698
  "attribute": "button-text",
699
+ "reflect": false
700
+ },
701
+ "iconUrl": {
702
+ "type": "string",
703
+ "mutable": false,
704
+ "complexType": {
705
+ "original": "string",
706
+ "resolved": "string",
707
+ "references": {}
708
+ },
709
+ "required": false,
710
+ "optional": true,
711
+ "docs": {
712
+ "tags": [],
713
+ "text": "URL of the icon to display on the button. If not provided, uses the default OCS logo."
714
+ },
715
+ "getter": false,
716
+ "setter": false,
717
+ "attribute": "icon-url",
718
+ "reflect": false
719
+ },
720
+ "buttonShape": {
721
+ "type": "string",
722
+ "mutable": false,
723
+ "complexType": {
724
+ "original": "'round' | 'square'",
725
+ "resolved": "\"round\" | \"square\"",
726
+ "references": {}
727
+ },
728
+ "required": false,
729
+ "optional": false,
730
+ "docs": {
731
+ "tags": [],
732
+ "text": "The shape of the chat button. 'round' makes it circular, 'square' keeps it rectangular."
733
+ },
734
+ "getter": false,
735
+ "setter": false,
736
+ "attribute": "button-shape",
128
737
  "reflect": false,
129
- "defaultValue": "\"Chat\""
738
+ "defaultValue": "'square'"
130
739
  },
131
740
  "visible": {
132
741
  "type": "boolean",
@@ -187,14 +796,117 @@ export class OcsChat {
187
796
  "attribute": "expanded",
188
797
  "reflect": false,
189
798
  "defaultValue": "false"
799
+ },
800
+ "welcomeMessages": {
801
+ "type": "string",
802
+ "mutable": false,
803
+ "complexType": {
804
+ "original": "string",
805
+ "resolved": "string",
806
+ "references": {}
807
+ },
808
+ "required": false,
809
+ "optional": true,
810
+ "docs": {
811
+ "tags": [],
812
+ "text": "Welcome messages to display above starter questions (JSON array of strings)"
813
+ },
814
+ "getter": false,
815
+ "setter": false,
816
+ "attribute": "welcome-messages",
817
+ "reflect": false
818
+ },
819
+ "starterQuestions": {
820
+ "type": "string",
821
+ "mutable": false,
822
+ "complexType": {
823
+ "original": "string",
824
+ "resolved": "string",
825
+ "references": {}
826
+ },
827
+ "required": false,
828
+ "optional": true,
829
+ "docs": {
830
+ "tags": [],
831
+ "text": "Array of starter questions that users can click to send (JSON array of strings)"
832
+ },
833
+ "getter": false,
834
+ "setter": false,
835
+ "attribute": "starter-questions",
836
+ "reflect": false
837
+ },
838
+ "persistentSession": {
839
+ "type": "boolean",
840
+ "mutable": false,
841
+ "complexType": {
842
+ "original": "boolean",
843
+ "resolved": "boolean",
844
+ "references": {}
845
+ },
846
+ "required": false,
847
+ "optional": false,
848
+ "docs": {
849
+ "tags": [],
850
+ "text": "Whether to persist session data to local storage to allow resuming previous conversations after page reload."
851
+ },
852
+ "getter": false,
853
+ "setter": false,
854
+ "attribute": "persistent-session",
855
+ "reflect": false,
856
+ "defaultValue": "true"
857
+ },
858
+ "persistentSessionExpire": {
859
+ "type": "number",
860
+ "mutable": false,
861
+ "complexType": {
862
+ "original": "number",
863
+ "resolved": "number",
864
+ "references": {}
865
+ },
866
+ "required": false,
867
+ "optional": false,
868
+ "docs": {
869
+ "tags": [],
870
+ "text": "Minutes since the most recent message after which the session data in local storage will expire. Set this to\n`0` to never expire."
871
+ },
872
+ "getter": false,
873
+ "setter": false,
874
+ "attribute": "persistent-session-expire",
875
+ "reflect": false,
876
+ "defaultValue": "60 * 24"
190
877
  }
191
878
  };
192
879
  }
193
880
  static get states() {
194
881
  return {
195
882
  "loaded": {},
196
- "error": {}
883
+ "error": {},
884
+ "messages": {},
885
+ "sessionId": {},
886
+ "isLoading": {},
887
+ "isTyping": {},
888
+ "messageInput": {},
889
+ "pollingInterval": {},
890
+ "lastPollTime": {},
891
+ "isTaskPolling": {},
892
+ "isDragging": {},
893
+ "dragOffset": {},
894
+ "windowPosition": {},
895
+ "showStarterQuestions": {},
896
+ "parsedWelcomeMessages": {},
897
+ "parsedStarterQuestions": {}
197
898
  };
198
899
  }
199
900
  }
901
+ OcsChat.TASK_POLLING_MAX_ATTEMPTS = 30;
902
+ OcsChat.TASK_POLLING_INTERVAL_MS = 1000;
903
+ OcsChat.MESSAGE_POLLING_INTERVAL_MS = 30000;
904
+ OcsChat.SCROLL_DELAY_MS = 100;
905
+ OcsChat.FOCUS_DELAY_MS = 100;
906
+ OcsChat.CHAT_WIDTH_DESKTOP = 450;
907
+ OcsChat.CHAT_HEIGHT_EXPANDED_RATIO = 0.83; // 83% of window height
908
+ OcsChat.CHAT_HEIGHT_COLLAPSED_RATIO = 0.6; // 60% of window height
909
+ OcsChat.MOBILE_BREAKPOINT = 640;
910
+ OcsChat.WINDOW_MARGIN = 20;
911
+ OcsChat.LOCALSTORAGE_TEST_KEY = '__ocs_test__';
200
912
  //# sourceMappingURL=ocs-chat.js.map