diff --git a/.changeset/some-teeth-judge.md b/.changeset/some-teeth-judge.md
new file mode 100644
index 000000000..a845151cc
--- /dev/null
+++ b/.changeset/some-teeth-judge.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/server/public/chat.html b/server/public/chat.html
index ac1a92415..61366af6f 100644
--- a/server/public/chat.html
+++ b/server/public/chat.html
@@ -905,6 +905,13 @@
Hi! I'm Addie
gfm: true, // GitHub flavored markdown
});
+ // Helper to escape HTML attribute values
+ const escapeAttr = (str) => str
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(//g, '>');
+
// Use marked's built-in renderer with custom link and image handling
const renderer = new marked.Renderer();
renderer.link = function(href, title, text) {
@@ -915,8 +922,10 @@ Hi! I'm Addie
title = link.title;
text = link.text;
}
- const titleAttr = title ? ` title="${title}"` : '';
- return `${text}`;
+ // Validate URL scheme - only allow safe protocols
+ const safeHref = /^(https?:\/\/|mailto:|#)/i.test(href) ? href : '#';
+ const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
+ return `${text}`;
};
// Custom image renderer
@@ -1177,53 +1186,82 @@ Hi! I'm Addie
const allBackticks = (text.match(/`/g) || []).length;
const inlineCodeCount = allBackticks - (codeBlockCount * 3);
+ // Count link brackets - check for unmatched link syntax
+ const openBrackets = (text.match(/\[/g) || []).length;
+ const closeBrackets = (text.match(/\]/g) || []).length;
+ // Count ]( which indicates a complete link opening
+ const linkOpens = (text.match(/\]\(/g) || []).length;
+
return boldCount % 2 === 0 &&
italicCount % 2 === 0 &&
inlineCodeCount % 2 === 0 &&
- codeBlockCount % 2 === 0;
+ codeBlockCount % 2 === 0 &&
+ openBrackets === closeBrackets;
+ }
+
+ // Strip markdown markers for plain text display during streaming
+ function stripMarkdownMarkers(text) {
+ return text
+ .replace(/\*\*/g, '') // Bold **
+ .replace(/\*/g, '') // Italic *
+ .replace(/`{3}[^\n]*\n?/g, '') // Code blocks ```
+ .replace(/`/g, '') // Inline code `
+ .replace(/^#{1,6}\s*/gm, '') // Headers #
+ .replace(/^\s*[-*+]\s+/gm, '• ') // List items
+ .replace(/^\s*\d+\.\s+/gm, '• ') // Numbered lists
+ .replace(/~~([^~]+)~~/g, '$1') // Strikethrough
+ .replace(/^\s*>\s*/gm, '') // Blockquotes
+ .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') // Links and images - keep text
+ .replace(/^[-*_]{3,}\s*$/gm, ''); // Horizontal rules
}
// Append text to streaming message
- // Render complete paragraphs as markdown, show incomplete paragraph as plain text
+ // Strategy: Split into paragraphs, render complete ones with balanced markdown,
+ // show the incomplete trailing paragraph as plain text (with markers stripped)
function appendToStreamingMessage(contentDiv, text, fullContent) {
// Split on paragraph breaks (double newline)
- const lastParagraphBreak = fullContent.lastIndexOf('\n\n');
+ const paragraphs = fullContent.split(/\n\n/);
- if (lastParagraphBreak === -1) {
- // No complete paragraphs yet - show as plain text
- const escaped = fullContent
+ if (paragraphs.length === 1) {
+ // No complete paragraphs yet - show as plain text with markers stripped
+ const stripped = stripMarkdownMarkers(fullContent);
+ const escaped = stripped
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/\n/g, '
');
contentDiv.innerHTML = escaped + '|';
} else {
- // Check if the "complete" paragraphs actually have balanced markdown
- const completeParagraphs = fullContent.substring(0, lastParagraphBreak);
- const remainder = fullContent.substring(lastParagraphBreak + 2);
-
- // Only render as markdown if markers are balanced, otherwise show as plain text
- let renderedComplete;
- if (hasBalancedMarkdown(completeParagraphs)) {
- renderedComplete = renderMessage(completeParagraphs);
- } else {
- // Fall back to plain text if markdown is unbalanced
- renderedComplete = completeParagraphs
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/\n\n/g, '')
- .replace(/\n/g, '
');
- renderedComplete = '
' + renderedComplete + '
';
- }
+ // Process each paragraph individually
+ const completeParagraphs = paragraphs.slice(0, -1);
+ const remainder = paragraphs[paragraphs.length - 1];
+
+ // Render each complete paragraph - if it has balanced markdown, render it;
+ // otherwise show as plain text with markers stripped
+ const renderedParagraphs = completeParagraphs.map(para => {
+ if (hasBalancedMarkdown(para)) {
+ return renderMessage(para);
+ } else {
+ // Unbalanced - strip markers and escape
+ const stripped = stripMarkdownMarkers(para);
+ const escaped = stripped
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/\n/g, '
');
+ return '' + escaped + '
';
+ }
+ });
- const escapedRemainder = remainder
+ // Strip markers from remainder (incomplete paragraph)
+ const strippedRemainder = stripMarkdownMarkers(remainder);
+ const escapedRemainder = strippedRemainder
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/\n/g, '
');
- contentDiv.innerHTML = renderedComplete +
+ contentDiv.innerHTML = renderedParagraphs.join('') +
(escapedRemainder ? '' + escapedRemainder + '|
' : '|');
}
messagesContainer.scrollTop = messagesContainer.scrollHeight;
@@ -1296,43 +1334,58 @@ Hi! I'm Addie
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
+ let currentEventType = '';
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
-
- buffer += decoder.decode(value, { stream: true });
-
- // Process complete events in the buffer
- const lines = buffer.split('\n');
- buffer = lines.pop() || ''; // Keep incomplete line in buffer
-
- let eventType = '';
+ // Helper to process SSE lines (uses closure for currentEventType)
+ const processLines = (lines) => {
for (const line of lines) {
if (line.startsWith('event: ')) {
- eventType = line.slice(7);
- } else if (line.startsWith('data: ') && eventType) {
+ currentEventType = line.slice(7);
+ } else if (line.startsWith('data: ') && currentEventType) {
try {
const data = JSON.parse(line.slice(6));
- if (eventType === 'text') {
+ if (currentEventType === 'text') {
fullContent += data.text;
appendToStreamingMessage(contentDiv, data.text, fullContent);
- } else if (eventType === 'meta') {
+ } else if (currentEventType === 'meta') {
conversationId = data.conversation_id;
- } else if (eventType === 'done') {
+ } else if (currentEventType === 'done') {
messageId = data.message_id;
conversationId = data.conversation_id;
- } else if (eventType === 'error') {
+ } else if (currentEventType === 'error') {
throw new Error(data.error || 'Unknown error');
}
// tool_start and tool_end events are informational, could show status
} catch (parseError) {
console.warn('Failed to parse SSE data:', parseError);
}
- eventType = '';
+ currentEventType = '';
+ }
+ }
+ };
+
+ while (true) {
+ const { done, value } = await reader.read();
+
+ if (done) {
+ // Flush the decoder to handle any remaining multi-byte characters
+ buffer += decoder.decode();
+ // Process any remaining complete events
+ if (buffer.trim()) {
+ const lines = buffer.split('\n');
+ processLines(lines);
}
+ break;
}
+
+ buffer += decoder.decode(value, { stream: true });
+
+ // Process complete events in the buffer
+ const lines = buffer.split('\n');
+ buffer = lines.pop() || ''; // Keep incomplete line in buffer
+
+ processLines(lines);
}
// Finalize the message with feedback UI