diff --git a/index.html b/index.html index bafcf08..499099e 100644 --- a/index.html +++ b/index.html @@ -63,6 +63,9 @@
- + + + + diff --git a/script.js b/script.js index 78d85b0..96f0820 100644 --- a/script.js +++ b/script.js @@ -330,8 +330,18 @@ async function handleCallback() { function addMessage(text, type = "user") { const msg = document.createElement("div"); msg.className = `message ${type}`; - msg.innerHTML = `
${escapeHtml(text)}
`; + + // Render markdown for bot messages, escape HTML for user messages + const content = type === "assistant" ? renderMarkdown(text) : escapeHtml(text); + msg.innerHTML = `
${content}
`; + chatMessages.appendChild(msg); + + // Add copy buttons to code blocks for assistant messages + if (type === "assistant") { + addCopyButtonsToCodeBlocks(msg); + } + scrollToBottom(); } @@ -367,6 +377,87 @@ function escapeHtml(text) { return div.innerHTML; } +function renderMarkdown(text) { + // Check if libraries are loaded + if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') { + logger.error('Markdown libraries not loaded', { + markedAvailable: typeof marked !== 'undefined', + dompurifyAvailable: typeof DOMPurify !== 'undefined' + }); + return escapeHtml(text); + } + + try { + // Configure marked.js for optimal chat rendering + marked.setOptions({ + breaks: true, // Convert line breaks to
+ gfm: true, // GitHub-flavored markdown + headerIds: false, // No IDs for headers + mangle: false // Don't obfuscate emails + }); + + // Parse markdown to HTML + const rawHtml = marked.parse(text); + + // Sanitize HTML to prevent XSS attacks (include button element for copy functionality) + const cleanHtml = DOMPurify.sanitize(rawHtml, { + ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre', + 'a', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'blockquote', 'hr', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'button'], + ALLOWED_ATTR: ['href', 'target', 'rel', 'class'] + }); + + return cleanHtml; + } catch (error) { + logger.error('Markdown rendering failed', error); + return escapeHtml(text); + } +} + +function addCopyButtonsToCodeBlocks(element) { + // Find all
 elements in the given element
+  const preElements = element.querySelectorAll('pre');
+
+  preElements.forEach((pre) => {
+    // Skip if button already exists
+    if (pre.querySelector('.copy-button')) {
+      return;
+    }
+
+    // Create copy button
+    const button = document.createElement('button');
+    button.className = 'copy-button';
+    button.textContent = 'Copy';
+
+    // Add click handler
+    button.addEventListener('click', async () => {
+      const code = pre.querySelector('code');
+      const text = code ? code.textContent : pre.textContent;
+
+      try {
+        await navigator.clipboard.writeText(text);
+        button.textContent = 'Copied!';
+        button.classList.add('copied');
+
+        // Reset after 2 seconds
+        setTimeout(() => {
+          button.textContent = 'Copy';
+          button.classList.remove('copied');
+        }, 2000);
+      } catch (error) {
+        logger.error('Failed to copy code to clipboard', error);
+        button.textContent = 'Failed';
+        setTimeout(() => {
+          button.textContent = 'Copy';
+        }, 2000);
+      }
+    });
+
+    // Add button to pre element
+    pre.appendChild(button);
+  });
+}
+
 async function sendMessage(text) {
   if (!text.trim()) return;
 
@@ -438,11 +529,13 @@ async function sendMessage(text) {
 
           if (data.chunk) {
             assistantMessage += data.chunk;
-            textDiv.textContent = assistantMessage;
+            textDiv.innerHTML = renderMarkdown(assistantMessage);
             scrollToBottom();
           }
 
           if (data.done) {
+            // Add copy buttons to code blocks after streaming is complete
+            addCopyButtonsToCodeBlocks(msgDiv);
             break;
           }
         }
diff --git a/style.css b/style.css
index bc57f85..02095bf 100644
--- a/style.css
+++ b/style.css
@@ -177,7 +177,7 @@ kbd {
 .chat-messages {
     flex: 1;
     overflow-y: auto;
-    padding: 16px 0;
+    padding: 16px;
 }
 
 .chat-messages::-webkit-scrollbar {
@@ -199,8 +199,9 @@ kbd {
 /* ==================== MESSAGES ==================== */
 .message {
     font-size: 13px;
-    line-height: 1.6;
-    padding: 8px 16px;
+    line-height: 1.9;
+    padding: 12px 24px;
+    margin-bottom: 16px;
     border-left: 3px solid transparent;
     opacity: 0;
     animation: fadeIn 0.2s ease forwards;
@@ -229,6 +230,229 @@ kbd {
     color: var(--ctp-subtext1);
 }
 
+/* ==================== MARKDOWN ELEMENTS ==================== */
+/* Styles for markdown-rendered content in assistant messages */
+
+.message.assistant .message-text p {
+    margin: 0.8em 0;
+}
+
+.message.assistant .message-text p:first-child {
+    margin-top: 0;
+}
+
+.message.assistant .message-text p:last-child {
+    margin-bottom: 0;
+}
+
+/* Headers */
+.message.assistant .message-text h1,
+.message.assistant .message-text h2,
+.message.assistant .message-text h3,
+.message.assistant .message-text h4,
+.message.assistant .message-text h5,
+.message.assistant .message-text h6 {
+    color: var(--ctp-text);
+}
+
+.message.assistant .message-text h1 {
+    font-size: 2em;
+    font-weight: 700;
+    margin: 1.5em 0 0.6em 0;
+}
+
+.message.assistant .message-text h2 {
+    font-size: 1.6em;
+    font-weight: 700;
+    margin: 1.2em 0 0.5em 0;
+}
+
+.message.assistant .message-text h3 {
+    font-size: 1.3em;
+    font-weight: 600;
+    margin: 1em 0 0.4em 0;
+}
+
+.message.assistant .message-text h4 {
+    font-size: 1.15em;
+    font-weight: 600;
+    margin: 1em 0 0.4em 0;
+}
+
+.message.assistant .message-text h5 {
+    font-size: 1em;
+    font-weight: 500;
+    margin: 0.8em 0 0.3em 0;
+}
+
+.message.assistant .message-text h6 {
+    font-size: 0.9em;
+    font-weight: 500;
+    margin: 0.8em 0 0.3em 0;
+}
+
+/* First header in message should have no top margin */
+.message.assistant .message-text h1:first-child,
+.message.assistant .message-text h2:first-child,
+.message.assistant .message-text h3:first-child,
+.message.assistant .message-text h4:first-child,
+.message.assistant .message-text h5:first-child,
+.message.assistant .message-text h6:first-child {
+    margin-top: 0;
+}
+
+/* Inline code */
+.message.assistant .message-text code {
+    background: var(--ctp-surface0);
+    color: var(--ctp-pink);
+    padding: 2px 6px;
+    border-radius: 3px;
+    font-family: inherit;
+    font-size: 0.95em;
+}
+
+/* Code blocks */
+.message.assistant .message-text pre {
+    background: var(--ctp-surface0);
+    border: 1px solid var(--ctp-surface1);
+    border-left: 3px solid var(--ctp-mauve);
+    padding: 16px;
+    margin: 1.2em 0;
+    overflow-x: auto;
+    border-radius: 6px;
+    position: relative;
+}
+
+.message.assistant .message-text pre code {
+    background: transparent;
+    color: var(--ctp-text);
+    padding: 0;
+    border-radius: 0;
+    font-size: 0.9em;
+}
+
+/* Lists */
+.message.assistant .message-text ul,
+.message.assistant .message-text ol {
+    margin: 1em 0;
+    padding-left: 2.5em;
+}
+
+.message.assistant .message-text li {
+    margin: 0.5em 0;
+}
+
+.message.assistant .message-text ul {
+    list-style-type: disc;
+}
+
+.message.assistant .message-text ol {
+    list-style-type: decimal;
+}
+
+/* Custom bullet colors */
+.message.assistant .message-text ul li::marker {
+    color: var(--ctp-mauve);
+}
+
+.message.assistant .message-text ol li::marker {
+    color: var(--ctp-mauve);
+    font-weight: 600;
+}
+
+/* Links */
+.message.assistant .message-text a {
+    color: var(--ctp-sapphire);
+    text-decoration: underline;
+    transition: color 0.15s ease;
+}
+
+.message.assistant .message-text a:hover {
+    color: var(--ctp-sky);
+}
+
+/* Blockquotes */
+.message.assistant .message-text blockquote {
+    border-left: 3px solid var(--ctp-overlay0);
+    padding-left: 12px;
+    margin: 0.8em 0;
+    color: var(--ctp-subtext0);
+    font-style: italic;
+}
+
+/* Horizontal rule */
+.message.assistant .message-text hr {
+    border: none;
+    border-top: 1px solid var(--ctp-surface1);
+    margin: 1em 0;
+}
+
+/* Tables */
+.message.assistant .message-text table {
+    border-collapse: collapse;
+    width: 100%;
+    margin: 0.8em 0;
+}
+
+.message.assistant .message-text th,
+.message.assistant .message-text td {
+    border: 1px solid var(--ctp-surface1);
+    padding: 6px 10px;
+    text-align: left;
+}
+
+.message.assistant .message-text th {
+    background: var(--ctp-surface0);
+    color: var(--ctp-text);
+    font-weight: 600;
+}
+
+/* Strong and emphasis */
+.message.assistant .message-text strong {
+    font-weight: 700;
+    color: var(--ctp-text);
+}
+
+.message.assistant .message-text em {
+    font-style: italic;
+}
+
+/* Copy button for code blocks */
+.copy-button {
+    position: absolute;
+    top: 8px;
+    right: 8px;
+    background: var(--ctp-surface1);
+    color: var(--ctp-text);
+    border: 1px solid var(--ctp-surface2);
+    padding: 4px 12px;
+    font-size: 11px;
+    font-family: inherit;
+    border-radius: 4px;
+    cursor: pointer;
+    opacity: 0;
+    transition: opacity 0.2s ease, background 0.15s ease;
+}
+
+.message.assistant .message-text pre:hover .copy-button {
+    opacity: 1;
+}
+
+.copy-button:hover {
+    background: var(--ctp-surface2);
+    border-color: var(--ctp-mauve);
+}
+
+.copy-button:active {
+    background: var(--ctp-surface0);
+}
+
+.copy-button.copied {
+    background: var(--ctp-green);
+    color: var(--ctp-base);
+    border-color: var(--ctp-green);
+}
+
 /* ==================== CHAT INPUT ==================== */
 .chat-input {
     padding: 16px;