Copilot copilot-swe-agent[bot] sethmcknight commited on
Commit
ccb82c6
·
1 Parent(s): 846c2fc

Improve chatbot response formatting with rich text rendering and proper typography (#67)

Browse files

* Initial plan

* Implement rich text formatting for chatbot responses

Co-authored-by: sethmcknight <[email protected]>

* Fix XSS vulnerability and improve markdown parsing security

Co-authored-by: sethmcknight <[email protected]>

* Remove trailing whitespace to fix pre-commit hook

Co-authored-by: sethmcknight <[email protected]>

* Improve code quality: fix header ordering, RegExp efficiency, and remove redundant CSS

Co-authored-by: sethmcknight <[email protected]>

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: sethmcknight <[email protected]>

Files changed (2) hide show
  1. static/chat.css +94 -1
  2. static/js/chat.js +163 -1
static/chat.css CHANGED
@@ -166,10 +166,103 @@
166
  }
167
 
168
  .message-text {
169
- line-height: 1.5;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  margin-bottom: 0;
171
  }
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  .message-sources {
174
  margin-top: 1rem;
175
  padding-top: 1rem;
 
166
  }
167
 
168
  .message-text {
169
+ line-height: 1.6;
170
+ margin-bottom: 0;
171
+ text-align: left;
172
+ }
173
+
174
+ /* Rich text formatting for assistant messages */
175
+
176
+ .message-text h1 {
177
+ font-size: 1.5rem;
178
+ font-weight: 700;
179
+ color: #1e293b;
180
+ margin: 1.25rem 0 0.75rem 0;
181
+ line-height: 1.3;
182
+ }
183
+
184
+ .message-text h1:first-child {
185
+ margin-top: 0;
186
+ }
187
+
188
+ .message-text h2 {
189
+ font-size: 1.25rem;
190
+ font-weight: 600;
191
+ color: #1e293b;
192
+ margin: 1rem 0 0.5rem 0;
193
+ line-height: 1.3;
194
+ }
195
+
196
+ .message-text h2:first-child {
197
+ margin-top: 0;
198
+ }
199
+
200
+ .message-text h3 {
201
+ font-size: 1.125rem;
202
+ font-weight: 600;
203
+ color: #334155;
204
+ margin: 0.75rem 0 0.5rem 0;
205
+ line-height: 1.3;
206
+ }
207
+
208
+ .message-text h3:first-child {
209
+ margin-top: 0;
210
+ }
211
+
212
+ .message-text p {
213
+ margin: 0.75rem 0;
214
+ color: #1e293b;
215
+ line-height: 1.6;
216
+ }
217
+
218
+ .message-text p:first-child {
219
+ margin-top: 0;
220
+ }
221
+
222
+ .message-text p:last-child {
223
+ margin-bottom: 0;
224
+ }
225
+
226
+ .message-text ul,
227
+ .message-text ol {
228
+ margin: 0.75rem 0;
229
+ padding-left: 1.5rem;
230
+ color: #1e293b;
231
+ }
232
+
233
+ .message-text ul:first-child,
234
+ .message-text ol:first-child {
235
+ margin-top: 0;
236
+ }
237
+
238
+ .message-text ul:last-child,
239
+ .message-text ol:last-child {
240
  margin-bottom: 0;
241
  }
242
 
243
+ .message-text li {
244
+ margin: 0.25rem 0;
245
+ line-height: 1.5;
246
+ }
247
+
248
+ .message-text ul li {
249
+ list-style-type: disc;
250
+ }
251
+
252
+ .message-text ol li {
253
+ list-style-type: decimal;
254
+ }
255
+
256
+ .message-text strong {
257
+ font-weight: 600;
258
+ color: #0f172a;
259
+ }
260
+
261
+ .message-text em {
262
+ font-style: italic;
263
+ color: #334155;
264
+ }
265
+
266
  .message-sources {
267
  margin-top: 1rem;
268
  padding-top: 1rem;
static/js/chat.js CHANGED
@@ -1158,7 +1158,13 @@ class ChatInterface {
1158
 
1159
  const textDiv = document.createElement('div');
1160
  textDiv.className = 'message-text';
1161
- textDiv.textContent = text;
 
 
 
 
 
 
1162
 
1163
  contentDiv.appendChild(textDiv);
1164
 
@@ -1450,6 +1456,162 @@ class ChatInterface {
1450
  return div.innerHTML;
1451
  }
1452
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1453
  /**
1454
  * Render error message with retry functionality for source documents
1455
  */
 
1158
 
1159
  const textDiv = document.createElement('div');
1160
  textDiv.className = 'message-text';
1161
+
1162
+ // Format assistant responses with markdown rendering for better readability
1163
+ if (sender === 'assistant') {
1164
+ textDiv.innerHTML = this.formatMarkdown(text);
1165
+ } else {
1166
+ textDiv.textContent = text;
1167
+ }
1168
 
1169
  contentDiv.appendChild(textDiv);
1170
 
 
1456
  return div.innerHTML;
1457
  }
1458
 
1459
+ /**
1460
+ * Format markdown text for better readability in chat responses
1461
+ * Safely converts markdown to HTML while preventing XSS attacks
1462
+ */
1463
+ formatMarkdown(text) {
1464
+ if (!text) return '';
1465
+
1466
+ // First escape ALL HTML to prevent XSS - this is critical for security
1467
+ let escapedText = this.escapeHtml(text);
1468
+
1469
+ // Now safely convert markdown formatting to HTML
1470
+ // Process line by line to maintain proper structure
1471
+ const lines = escapedText.split('\n');
1472
+ const processedLines = [];
1473
+ let inList = false;
1474
+ let listType = '';
1475
+
1476
+ for (let i = 0; i < lines.length; i++) {
1477
+ let line = lines[i];
1478
+ const trimmedLine = line.trim();
1479
+
1480
+ // Skip empty lines for now - we'll handle them in paragraph processing
1481
+ if (!trimmedLine) {
1482
+ processedLines.push('');
1483
+ continue;
1484
+ }
1485
+
1486
+ // Process headers (must be at start of line) - check from least to most specific
1487
+ if (trimmedLine.match(/^# (.+)$/)) {
1488
+ if (inList) {
1489
+ processedLines.push(`</${listType}>`);
1490
+ inList = false;
1491
+ listType = '';
1492
+ }
1493
+ const headerText = trimmedLine.replace(/^# /, '');
1494
+ processedLines.push(`<h1>${headerText}</h1>`);
1495
+ continue;
1496
+ } else if (trimmedLine.match(/^## (.+)$/)) {
1497
+ if (inList) {
1498
+ processedLines.push(`</${listType}>`);
1499
+ inList = false;
1500
+ listType = '';
1501
+ }
1502
+ const headerText = trimmedLine.replace(/^## /, '');
1503
+ processedLines.push(`<h2>${headerText}</h2>`);
1504
+ continue;
1505
+ } else if (trimmedLine.match(/^### (.+)$/)) {
1506
+ if (inList) {
1507
+ processedLines.push(`</${listType}>`);
1508
+ inList = false;
1509
+ listType = '';
1510
+ }
1511
+ const headerText = trimmedLine.replace(/^### /, '');
1512
+ processedLines.push(`<h3>${headerText}</h3>`);
1513
+ continue;
1514
+ }
1515
+
1516
+ // Process list items
1517
+ const bulletMatch = trimmedLine.match(/^[-*+]\s+(.+)$/);
1518
+ const numberMatch = trimmedLine.match(/^\d+\.\s+(.+)$/);
1519
+
1520
+ if (bulletMatch) {
1521
+ if (!inList || listType !== 'ul') {
1522
+ if (inList) processedLines.push(`</${listType}>`);
1523
+ processedLines.push('<ul>');
1524
+ inList = true;
1525
+ listType = 'ul';
1526
+ }
1527
+ let listContent = bulletMatch[1];
1528
+ // Apply inline formatting to list content
1529
+ listContent = this.applyInlineFormatting(listContent);
1530
+ processedLines.push(`<li>${listContent}</li>`);
1531
+ continue;
1532
+ } else if (numberMatch) {
1533
+ if (!inList || listType !== 'ol') {
1534
+ if (inList) processedLines.push(`</${listType}>`);
1535
+ processedLines.push('<ol>');
1536
+ inList = true;
1537
+ listType = 'ol';
1538
+ }
1539
+ let listContent = numberMatch[1];
1540
+ // Apply inline formatting to list content
1541
+ listContent = this.applyInlineFormatting(listContent);
1542
+ processedLines.push(`<li>${listContent}</li>`);
1543
+ continue;
1544
+ }
1545
+
1546
+ // Close any open list for regular text
1547
+ if (inList) {
1548
+ processedLines.push(`</${listType}>`);
1549
+ inList = false;
1550
+ listType = '';
1551
+ }
1552
+
1553
+ // Apply inline formatting (bold, italic) to regular text
1554
+ line = this.applyInlineFormatting(trimmedLine);
1555
+ processedLines.push(line);
1556
+ }
1557
+
1558
+ // Close any remaining open list
1559
+ if (inList) {
1560
+ processedLines.push(`</${listType}>`);
1561
+ }
1562
+
1563
+ // Convert to paragraph structure
1564
+ const content = processedLines.join('\n');
1565
+ return this.convertToParagraphs(content);
1566
+ }
1567
+
1568
+ /**
1569
+ * Apply inline formatting (bold, italic) to text that's already HTML-escaped
1570
+ */
1571
+ applyInlineFormatting(text) {
1572
+ // First handle bold formatting (**text**) and replace with a placeholder
1573
+ const boldPlaceholder = '___BOLD_PLACEHOLDER___';
1574
+ const boldMatches = [];
1575
+ text = text.replace(/\*\*([^*]+)\*\*/g, (match, content) => {
1576
+ boldMatches.push(`<strong>${content}</strong>`);
1577
+ return `${boldPlaceholder}${boldMatches.length - 1}${boldPlaceholder}`;
1578
+ });
1579
+
1580
+ // Now handle italic formatting (*text*) - won't conflict with bold placeholders
1581
+ text = text.replace(/\*([^*]+)\*/g, '<em>$1</em>');
1582
+
1583
+ // Restore bold formatting
1584
+ const restoreRegex = new RegExp(`${boldPlaceholder}(\\d+)${boldPlaceholder}`, 'g');
1585
+ text = text.replace(restoreRegex, (match, index) => boldMatches[parseInt(index)]);
1586
+
1587
+ return text;
1588
+ }
1589
+
1590
+ /**
1591
+ * Convert processed lines to proper paragraph structure
1592
+ */
1593
+ convertToParagraphs(content) {
1594
+ // Split by double line breaks for paragraphs
1595
+ const sections = content.split('\n\n');
1596
+ const formattedSections = [];
1597
+
1598
+ for (const section of sections) {
1599
+ const trimmed = section.trim();
1600
+ if (!trimmed) continue;
1601
+
1602
+ // Check if this section contains only block elements (headers, lists)
1603
+ if (trimmed.match(/^<(h[1-6]|ul|ol)/)) {
1604
+ formattedSections.push(trimmed);
1605
+ } else {
1606
+ // Regular text content - wrap in paragraph and handle line breaks
1607
+ const withBreaks = trimmed.replace(/\n/g, '<br>');
1608
+ formattedSections.push(`<p>${withBreaks}</p>`);
1609
+ }
1610
+ }
1611
+
1612
+ return formattedSections.join('\n');
1613
+ }
1614
+
1615
  /**
1616
  * Render error message with retry functionality for source documents
1617
  */