Spaces:
Sleeping
Sleeping
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]>
- static/chat.css +94 -1
- static/js/chat.js +163 -1
static/chat.css
CHANGED
|
@@ -166,10 +166,103 @@
|
|
| 166 |
}
|
| 167 |
|
| 168 |
.message-text {
|
| 169 |
-
line-height: 1.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
*/
|