open-chat-studio-widget 0.3.1 → 0.4.1

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