@salesforcedevs/docs-components 0.0.11-chat → 0.0.11-edit

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.
@@ -9,6 +9,7 @@ interface ChatMessage {
9
9
  sender: "user" | "assistant";
10
10
  isTyping?: boolean;
11
11
  formattedTime?: string;
12
+ isHTML?: boolean; // Flag to indicate if content should be rendered as HTML
12
13
  }
13
14
 
14
15
  export default class Chat extends LightningElement {
@@ -16,7 +17,7 @@ export default class Chat extends LightningElement {
16
17
  @api placeholder: string = "Type your message...";
17
18
  @api assistantName: string = "Assistant";
18
19
  @api maxHeight: string = "400px";
19
-
20
+
20
21
  @api
21
22
  get disabled() {
22
23
  return this._disabled;
@@ -35,26 +36,108 @@ export default class Chat extends LightningElement {
35
36
  this._showTimestamp = normalizeBoolean(value);
36
37
  }
37
38
 
39
+ @api
40
+ get isOpen() {
41
+ return this._isOpen;
42
+ }
43
+
44
+ set isOpen(value) {
45
+ this._isOpen = normalizeBoolean(value);
46
+ }
47
+
38
48
  @track messages: ChatMessage[] = [];
39
49
  @track currentMessage: string = "";
40
50
  @track isAssistantTyping: boolean = false;
41
-
51
+
42
52
  private _disabled: boolean = false;
43
53
  private _showTimestamp: boolean = false;
54
+ private _isOpen: boolean = false;
44
55
  private messageIdCounter: number = 0;
45
56
 
57
+ // API Configuration
58
+ private static readonly API_URL =
59
+ "https://276ca7264b79.ngrok-free.app/api/llm/search";
60
+ private static readonly MAX_RESULTS = 3;
61
+
62
+ // Development mode flag - set to true if running in development
63
+ private static readonly IS_DEVELOPMENT =
64
+ window.location.hostname === "localhost" ||
65
+ window.location.hostname === "127.0.0.1";
66
+
67
+ // localStorage keys for persisting messages and open state
68
+ private static readonly STORAGE_KEY = "doc-chat-messages";
69
+ private static readonly OPEN_STATE_KEY = "doc-chat-should-open";
70
+
46
71
  connectedCallback() {
47
- // Add a welcome message
48
- this.addMessage("Hello! How can I help you today?", "assistant");
72
+ console.log("---- Connected callback ------");
73
+ // Load existing messages from localStorage
74
+ this.loadMessages();
75
+
76
+ // Add welcome message only if no existing messages
77
+ if (this.messages.length === 0) {
78
+ this.addMessage("Hello! How can I help you today?", "assistant");
79
+ }
80
+
81
+ // Check if chat should be opened after reload
82
+ this.checkAndOpenAfterReload();
83
+
84
+ // Ensure body has proper class state
85
+ this.updateBodyClass();
86
+ }
87
+
88
+ disconnectedCallback() {
89
+ // Clean up body classes when component is destroyed
90
+ document.body.classList.remove("chat-open", "chat-closed");
91
+ }
92
+
93
+ renderedCallback() {
94
+ // Handle HTML content rendering for messages with isHTML flag
95
+ this.renderHTMLMessages();
96
+ }
97
+
98
+ private renderHTMLMessages() {
99
+ console.log("=== renderHTMLMessages called ===");
100
+ console.log("All messages:", this.messages);
101
+ console.log(
102
+ "HTML messages:",
103
+ this.messages.filter((msg) => msg.isHTML)
104
+ );
105
+
106
+ // Use a simpler selector approach
107
+ const htmlMessageElements = this.template.querySelectorAll(
108
+ ".html-content[data-message-id]"
109
+ );
110
+ console.log("Found HTML elements:", htmlMessageElements.length);
111
+
112
+ htmlMessageElements.forEach((element) => {
113
+ const messageId = element.getAttribute("data-message-id");
114
+ console.log("Processing element with ID:", messageId);
115
+
116
+ const message = this.messages.find(
117
+ (msg) => msg.id === messageId && msg.isHTML
118
+ );
119
+ console.log("Found message:", message);
120
+
121
+ if (message) {
122
+ console.log("Setting innerHTML for message:", messageId);
123
+ console.log("Content:", message.text);
124
+ element.innerHTML = message.text;
125
+ }
126
+ });
49
127
  }
50
128
 
51
129
  get chatContainerClass() {
52
130
  return cx(
53
131
  "chat-container",
54
- this.disabled && "chat-container--disabled"
132
+ this.disabled && "chat-container_disabled",
133
+ this.isOpen && "chat-container_open"
55
134
  );
56
135
  }
57
136
 
137
+ get showTriggerButton() {
138
+ return !this.isOpen;
139
+ }
140
+
58
141
  get messagesWithTyping() {
59
142
  const messages = [...this.messages];
60
143
  if (this.isAssistantTyping) {
@@ -71,36 +154,151 @@ export default class Chat extends LightningElement {
71
154
  return messages;
72
155
  }
73
156
 
74
- private addMessage(text: string, sender: "user" | "assistant") {
157
+ // Helper method to get HTML content for a message
158
+ getMessageHTML(messageId: string): string {
159
+ const message = this.messages.find(
160
+ (msg) => msg.id === messageId && msg.isHTML
161
+ );
162
+ return message ? message.text : "";
163
+ }
164
+
165
+ private addMessage(
166
+ text: string,
167
+ sender: "user" | "assistant",
168
+ isHTML: boolean = false
169
+ ) {
75
170
  const timestamp = new Date();
76
171
  const message: ChatMessage = {
77
172
  id: `msg-${this.messageIdCounter++}`,
78
173
  text,
79
174
  timestamp,
80
175
  sender,
81
- formattedTime: this.formatTimestamp(timestamp)
176
+ formattedTime: this.formatTimestamp(timestamp),
177
+ isHTML
82
178
  };
83
179
  this.messages = [...this.messages, message];
180
+ this.saveMessages();
84
181
  this.scrollToBottom();
85
182
  }
86
183
 
87
184
  private scrollToBottom() {
88
185
  // Use setTimeout to ensure DOM is updated
89
186
  setTimeout(() => {
90
- const messagesContainer = this.template.querySelector('.chat-messages');
187
+ const messagesContainer =
188
+ this.template.querySelector(".chat-messages");
91
189
  if (messagesContainer) {
92
190
  messagesContainer.scrollTop = messagesContainer.scrollHeight;
93
191
  }
94
192
  }, 0);
95
193
  }
96
194
 
195
+ private updateBodyClass() {
196
+ // Update body classes to trigger content shift
197
+ if (this.isOpen) {
198
+ document.body.classList.remove("chat-closed");
199
+ document.body.classList.add("chat-open");
200
+ } else {
201
+ document.body.classList.remove("chat-open");
202
+ document.body.classList.add("chat-closed");
203
+ }
204
+ }
205
+
206
+ private saveMessages() {
207
+ try {
208
+ const messagesToSave = this.messages.map((msg) => ({
209
+ id: msg.id,
210
+ text: msg.text,
211
+ timestamp: msg.timestamp.toISOString(),
212
+ sender: msg.sender,
213
+ formattedTime: msg.formattedTime,
214
+ isHTML: msg.isHTML || false
215
+ }));
216
+ localStorage.setItem(
217
+ Chat.STORAGE_KEY,
218
+ JSON.stringify(messagesToSave)
219
+ );
220
+ } catch (error) {
221
+ console.warn("Failed to save messages to localStorage:", error);
222
+ }
223
+ }
224
+
225
+ private loadMessages() {
226
+ try {
227
+ const savedMessages = localStorage.getItem(Chat.STORAGE_KEY);
228
+ if (savedMessages) {
229
+ const parsedMessages = JSON.parse(savedMessages);
230
+ this.messages = parsedMessages.map((msg: any) => ({
231
+ id: msg.id,
232
+ text: msg.text,
233
+ timestamp: new Date(msg.timestamp),
234
+ sender: msg.sender,
235
+ formattedTime: msg.formattedTime,
236
+ isHTML: msg.isHTML || false
237
+ }));
238
+
239
+ // Update message counter to avoid ID conflicts
240
+ const lastId =
241
+ this.messages.length > 0
242
+ ? Math.max(
243
+ ...this.messages.map(
244
+ (m) =>
245
+ parseInt(m.id.replace("msg-", ""), 10) ||
246
+ 0
247
+ )
248
+ )
249
+ : 0;
250
+ this.messageIdCounter = lastId + 1;
251
+ }
252
+ } catch (error) {
253
+ console.warn("Failed to load messages from localStorage:", error);
254
+ this.messages = [];
255
+ }
256
+ }
257
+
258
+ private clearMessages() {
259
+ try {
260
+ localStorage.removeItem(Chat.STORAGE_KEY);
261
+ this.messages = [];
262
+ this.messageIdCounter = 0;
263
+ // Add welcome message after clearing
264
+ this.addMessage("Hello! How can I help you today?", "assistant");
265
+ } catch (error) {
266
+ console.warn("Failed to clear messages from localStorage:", error);
267
+ }
268
+ }
269
+
270
+ private checkAndOpenAfterReload() {
271
+ try {
272
+ const shouldOpen = localStorage.getItem(Chat.OPEN_STATE_KEY);
273
+ if (shouldOpen === "true") {
274
+ // Clear the flag
275
+ localStorage.removeItem(Chat.OPEN_STATE_KEY);
276
+ // Open the chat
277
+ this._isOpen = true;
278
+ this.updateBodyClass();
279
+
280
+ // Dispatch custom event to notify parent components
281
+ this.dispatchEvent(
282
+ new CustomEvent("chatopened", {
283
+ detail: { opened: true }
284
+ })
285
+ );
286
+ }
287
+ } catch (error) {
288
+ console.warn(
289
+ "Failed to check open state from localStorage:",
290
+ error
291
+ );
292
+ }
293
+ }
294
+
97
295
  handleInputChange(event: Event) {
98
296
  const target = event.target as HTMLInputElement;
99
297
  this.currentMessage = target.value;
100
298
  }
101
299
 
102
300
  handleKeyDown(event: KeyboardEvent) {
103
- if (event.key === 'Enter' && !event.shiftKey) {
301
+ if (event.key === "Enter" && !event.shiftKey) {
104
302
  event.preventDefault();
105
303
  this.sendMessage();
106
304
  }
@@ -121,43 +319,268 @@ export default class Chat extends LightningElement {
121
319
  this.currentMessage = "";
122
320
 
123
321
  // Clear input
124
- const input = this.template.querySelector('.chat-input') as HTMLInputElement;
322
+ const input = this.template.querySelector(
323
+ ".chat-input"
324
+ ) as HTMLInputElement;
125
325
  if (input) {
126
326
  input.value = "";
127
327
  }
128
328
 
129
- // Simulate assistant typing
329
+ // Show typing indicator
130
330
  this.isAssistantTyping = true;
131
-
132
- // Simulate assistant response after a delay
331
+
332
+ // Get real assistant response from API
333
+ // Add a small delay to show the typing indicator
133
334
  setTimeout(() => {
134
- this.isAssistantTyping = false;
135
- this.simulateAssistantResponse(userMessage);
136
- }, 1000 + Math.random() * 2000);
137
- }
138
-
139
- private simulateAssistantResponse(userMessage: string) {
140
- // Simple response simulation - in a real implementation, this would call an API
141
- let response = `I received your message: "${userMessage}". `;
142
-
143
- if (userMessage.toLowerCase().includes("hello") || userMessage.toLowerCase().includes("hi")) {
144
- response = "Hello! Nice to meet you. How can I assist you today?";
145
- } else if (userMessage.toLowerCase().includes("help")) {
146
- response = "I'm here to help! Feel free to ask me any questions about the documentation or topics you're interested in.";
147
- } else if (userMessage.toLowerCase().includes("documentation") || userMessage.toLowerCase().includes("docs")) {
148
- response = "I can help you navigate the documentation. What specific topic are you looking for?";
149
- } else {
150
- response = "That's an interesting question. Let me help you with that. Could you provide more details about what you're looking for?";
335
+ this.getAssistantResponse(userMessage);
336
+ }, 500);
337
+ }
338
+
339
+ private async callChatAPI(userMessage: string): Promise<string> {
340
+ try {
341
+ // Format chat history for API (exclude the current user message)
342
+ const chatHistory = this.messages.map((msg) => ({
343
+ text: msg.text,
344
+ sender: msg.sender
345
+ }));
346
+
347
+ const requestBody = {
348
+ query: userMessage,
349
+ maxResults: Chat.MAX_RESULTS,
350
+ chatHistory: chatHistory
351
+ };
352
+
353
+ console.log("Sending API request:", requestBody);
354
+
355
+ const response = await fetch(Chat.API_URL, {
356
+ method: "POST",
357
+ headers: {
358
+ "Content-Type": "application/json",
359
+ "ngrok-skip-browser-warning": "true" // Skip ngrok browser warning
360
+ },
361
+ body: JSON.stringify(requestBody)
362
+ });
363
+
364
+ if (!response.ok) {
365
+ const errorText = await response.text();
366
+ console.error("API Error Response:", errorText);
367
+ throw new Error(
368
+ `API request failed with status: ${response.status}`
369
+ );
370
+ }
371
+
372
+ const data = await response.json();
373
+ console.log("API Response:", data);
374
+
375
+ // Handle the new API response format with "data" field
376
+ const responseText =
377
+ data.data ||
378
+ data.answer ||
379
+ data.text ||
380
+ data.response ||
381
+ data.result ||
382
+ data.message;
383
+ console.log("Response Text:", responseText);
384
+
385
+ if (!responseText) {
386
+ console.warn("No response text found in API response:", data);
387
+ return "I received your message but couldn't generate a proper response.";
388
+ }
389
+
390
+ return responseText;
391
+ } catch (error) {
392
+ console.error("Chat API error:", error);
393
+
394
+ // Provide more specific error messages for CORS issues
395
+ if (error instanceof TypeError && error.message.includes("fetch")) {
396
+ return "I'm having trouble connecting to the service. This might be a CORS configuration issue. Please check your internet connection and try again.";
397
+ }
398
+
399
+ if (
400
+ error instanceof Error &&
401
+ (error.message.includes("CORS") ||
402
+ error.message.includes("cross-origin"))
403
+ ) {
404
+ return "I'm having trouble connecting due to browser security restrictions. Please ask your administrator to configure CORS headers on the backend API.";
405
+ }
406
+
407
+ return "I apologize, but I'm having trouble connecting to the service right now. Please try again later.";
151
408
  }
409
+ }
410
+
411
+ private parseMarkdownToHTML(text: string): string {
412
+ // Convert markdown-like text to HTML
413
+ let html = text;
414
+
415
+ console.log("Original text:", text);
416
+
417
+ // First, convert markdown links [text](url) to HTML links
418
+ // Make sure links are absolute (relative to root) by adding leading slash if missing
419
+ html = html.replace(
420
+ /\[([^\]]+)\]\(([^)]+)\)/g,
421
+ (match, linkText, url) => {
422
+ // If URL doesn't start with http, https, or /, make it absolute
423
+ let absoluteUrl = url;
424
+ if (!url.startsWith("http") && !url.startsWith("/")) {
425
+ absoluteUrl = "/" + url;
426
+ }
427
+ return `<a href="${absoluteUrl}" target="_blank" rel="noopener noreferrer" class="chat-link">${linkText}</a>`;
428
+ }
429
+ );
430
+
431
+ // Handle numbered lists with descriptions (more complex pattern)
432
+ // Split into lines to process each line
433
+ const lines = html.split("\n");
434
+ const processedLines = [];
435
+ let inList = false;
436
+
437
+ for (let i = 0; i < lines.length; i++) {
438
+ const line = lines[i];
439
+
440
+ // Check if this is a numbered list item (starts with number and dot)
441
+ if (/^\d+\.\s+/.test(line)) {
442
+ if (!inList) {
443
+ processedLines.push('<ol class="chat-list">');
444
+ inList = true;
445
+ }
446
+ // Extract the content after the number
447
+ const content = line.replace(/^\d+\.\s+/, "");
448
+ processedLines.push(`<li>${content}`);
449
+
450
+ // Check if next line is indented (description)
451
+ if (i + 1 < lines.length && /^\s{2,}/.test(lines[i + 1])) {
452
+ i++; // Skip to next line
453
+ const description = lines[i].trim();
454
+ processedLines.push(
455
+ `<br><span class="chat-description">${description}</span></li>`
456
+ );
457
+ } else {
458
+ processedLines.push("</li>");
459
+ }
460
+ } else if (line.trim() === "" && inList) {
461
+ // Empty line might end the list
462
+ continue;
463
+ } else {
464
+ if (inList && line.trim() !== "") {
465
+ processedLines.push("</ol>");
466
+ inList = false;
467
+ }
468
+ if (line.trim() !== "") {
469
+ processedLines.push(line);
470
+ }
471
+ }
472
+ }
473
+
474
+ // Close any open list
475
+ if (inList) {
476
+ processedLines.push("</ol>");
477
+ }
478
+
479
+ html = processedLines.join("<br>");
152
480
 
153
- this.addMessage(response, "assistant");
481
+ // Clean up extra breaks
482
+ html = html.replace(/<br><br>/g, "<br>");
483
+ html = html.replace(/^<br>/, "");
484
+
485
+ // Convert **bold** to <strong>
486
+ html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
487
+
488
+ // Convert *italic* to <em>
489
+ html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
490
+
491
+ console.log("Processed HTML:", html);
492
+
493
+ return html;
494
+ }
495
+
496
+ private async getAssistantResponse(userMessage: string) {
497
+ try {
498
+ const response = await this.callChatAPI(userMessage);
499
+ // Convert markdown to HTML for rich content display
500
+ const htmlContent = this.parseMarkdownToHTML(response);
501
+ this.addMessage(htmlContent, "assistant", true); // true indicates HTML content
502
+
503
+ // Force a re-render after adding the message
504
+ setTimeout(() => {
505
+ this.renderHTMLMessages();
506
+ }, 100);
507
+ } catch (error) {
508
+ console.error("Error getting assistant response:", error);
509
+ this.addMessage(
510
+ "I apologize, but I encountered an error while processing your request. Please try again.",
511
+ "assistant"
512
+ );
513
+ } finally {
514
+ this.isAssistantTyping = false;
515
+ }
154
516
  }
155
517
 
156
518
  formatTimestamp(timestamp: Date) {
157
- return timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
519
+ return timestamp.toLocaleTimeString([], {
520
+ hour: "2-digit",
521
+ minute: "2-digit"
522
+ });
158
523
  }
159
524
 
160
525
  get sendButtonDisabled() {
161
526
  return this.disabled || !this.currentMessage.trim();
162
527
  }
163
- }
528
+
529
+ handleCloseClick() {
530
+ this._isOpen = false;
531
+ this.updateBodyClass();
532
+
533
+ // Dispatch custom event to notify parent components
534
+ this.dispatchEvent(
535
+ new CustomEvent("chatclosed", {
536
+ detail: { closed: true }
537
+ })
538
+ );
539
+ }
540
+
541
+ handleClearClick() {
542
+ this.clearMessages();
543
+
544
+ // Dispatch custom event to notify parent components
545
+ this.dispatchEvent(
546
+ new CustomEvent("chatcleared", {
547
+ detail: { cleared: true }
548
+ })
549
+ );
550
+ }
551
+
552
+ handleOpenClick() {
553
+ try {
554
+ // Set flag to open chat after reload
555
+ localStorage.setItem(Chat.OPEN_STATE_KEY, "true");
556
+ } catch (error) {
557
+ console.warn("Failed to set open state in localStorage:", error);
558
+ }
559
+ // Hard reload the page to clear cache when opening chat
560
+ window.location.reload();
561
+ }
562
+
563
+ openChat() {
564
+ try {
565
+ // Set flag to open chat after reload
566
+ localStorage.setItem(Chat.OPEN_STATE_KEY, "true");
567
+ } catch (error) {
568
+ console.warn("Failed to set open state in localStorage:", error);
569
+ }
570
+
571
+ // Hard reload the page to clear cache when opening chat
572
+ window.location.reload();
573
+ }
574
+
575
+ closeChat() {
576
+ this._isOpen = false;
577
+ this.updateBodyClass();
578
+
579
+ // Dispatch custom event to notify parent components
580
+ this.dispatchEvent(
581
+ new CustomEvent("chatclosed", {
582
+ detail: { closed: true }
583
+ })
584
+ );
585
+ }
586
+ }
@@ -42,23 +42,23 @@
42
42
  <slot name="doc-phase"></slot>
43
43
  <slot name="version-banner"></slot>
44
44
  <div class="content-body-container">
45
- <div class="content-body">
46
- <doc-breadcrumbs
47
- lwc:if={showBreadcrumbs}
48
- breadcrumbs={breadcrumbs}
49
- ></doc-breadcrumbs>
50
- <slot onslotchange={onSlotChange}></slot>
51
- <doc-sprig-survey
52
- lwc:if={shouldDisplayFeedback}
53
- ></doc-sprig-survey>
54
- </div>
55
- <div lwc:if={showToc} class="right-nav-bar is-sticky">
56
- <dx-toc
57
- header={tocTitle}
58
- options={tocOptions}
59
- value={tocValue}
60
- ></dx-toc>
61
- </div>
45
+ <div class="content-body">
46
+ <doc-breadcrumbs
47
+ lwc:if={showBreadcrumbs}
48
+ breadcrumbs={breadcrumbs}
49
+ ></doc-breadcrumbs>
50
+ <slot onslotchange={onSlotChange}></slot>
51
+ <doc-sprig-survey
52
+ lwc:if={shouldDisplayFeedback}
53
+ ></doc-sprig-survey>
54
+ </div>
55
+ <div lwc:if={showToc} class="right-nav-bar is-sticky">
56
+ <dx-toc
57
+ header={tocTitle}
58
+ options={tocOptions}
59
+ value={tocValue}
60
+ ></dx-toc>
61
+ </div>
62
62
  </div>
63
63
  <div lwc:if={showFooter} class="footer-container">
64
64
  <dx-footer variant="no-signup"></dx-footer>