From 91e9701c3ec47e8b5ecebddcc89ac108dd708197 Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Tue, 14 Apr 2026 11:58:18 -0300 Subject: [PATCH] fix: prevent broken HTML during streaming by deferring render to completion When HTML content is streamed incrementally, injecting partial HTML into the DOM causes the browser to malform or discard incomplete tags. Changes: - In addMessage(): For streaming HTML (has msgId), show as escaped text initially - In updateStreaming(): For HTML content, show plain text during streaming - In finalizeStreaming(): Render complete HTML only when streaming is done - This ensures HTML is only rendered when the full content is received Applies to both chat.html and partials/chat.html --- ui/suite/chat/chat.html | 143 +++++++++++++++++++++--------------- ui/suite/partials/chat.html | 104 +++++++++++++++----------- 2 files changed, 144 insertions(+), 103 deletions(-) diff --git a/ui/suite/chat/chat.html b/ui/suite/chat/chat.html index 8e22778..537bd76 100644 --- a/ui/suite/chat/chat.html +++ b/ui/suite/chat/chat.html @@ -283,40 +283,49 @@ ); } - function addMessage(sender, content, msgId) { - var messages = document.getElementById("messages"); - if (!messages) return; +function addMessage(sender, content, msgId) { + var messages = document.getElementById("messages"); + if (!messages) return; - var div = document.createElement("div"); - div.className = "message " + sender; - if (msgId) div.id = msgId; + var div = document.createElement("div"); + div.className = "message " + sender; + if (msgId) div.id = msgId; - if (sender === "user") { - var processedContent = renderMentionInMessage( - escapeHtml(content), - ); - div.innerHTML = - '
' + - processedContent + - "
"; - } else { - // Check if content has HTML (any tag, including comments) - var hasHtmlTags = /<\/?[a-zA-Z][^>]*>|/i.test(content); - console.log("Bot message - hasHtmlTags:", hasHtmlTags, "content length:", content.length); - - var parsed = hasHtmlTags - ? content // Use HTML directly (no escaping!) - : (typeof marked !== "undefined" && marked.parse - ? marked.parse(content) - : escapeHtml(content)); - parsed = renderMentionInMessage(parsed); - div.innerHTML = - '
' + - parsed + - "
"; - } + if (sender === "user") { + var processedContent = renderMentionInMessage( + escapeHtml(content), + ); + div.innerHTML = + '
' + + processedContent + + "
"; + } else { + // Check if content has HTML (any tag, including comments) + var hasHtmlTags = /<\/?[a-zA-Z][^>]*>|/i.test(content); + console.log("Bot message - hasHtmlTags:", hasHtmlTags, "content length:", content.length, "msgId:", msgId); - messages.appendChild(div); + var parsed; + if (hasHtmlTags && msgId) { + // Streaming HTML content - show as text initially to avoid broken tags + // Will be rendered as HTML at finalizeStreaming + parsed = escapeHtml(content); + } else if (hasHtmlTags) { + // Complete HTML content - render directly + parsed = content; + } else { + // Markdown content + parsed = typeof marked !== "undefined" && marked.parse + ? marked.parse(content) + : escapeHtml(content); + } + parsed = renderMentionInMessage(parsed); + div.innerHTML = + '
' + + parsed + + "
"; + } + + messages.appendChild(div); // Auto-scroll to bottom unless user is manually scrolling if (!isUserScrolling) { @@ -735,36 +744,52 @@ return false; } - function updateStreaming(content) { - var el = document.getElementById(streamingMessageId); - if (el) { - // Check if content has HTML tags - var hasHtmlTags = /<\/?[a-zA-Z][^>]*>|/i.test(content); - var parsed = hasHtmlTags - ? content // Use HTML directly - : (typeof marked !== "undefined" && marked.parse - ? marked.parse(content) - : escapeHtml(content)); - parsed = renderMentionInMessage(parsed); - el.querySelector(".message-content").innerHTML = parsed; - } +function updateStreaming(content) { + var el = document.getElementById(streamingMessageId); + if (el) { + var msgContent = el.querySelector(".message-content"); + // Check if final content will be HTML (full accumulated content) + var willBeHtml = /<\/?[a-zA-Z][^>]*>|/i.test(currentStreamingContent); + if (willBeHtml) { + // For HTML content, show plain text during streaming to avoid broken tags + // HTML will be rendered at finalizeStreaming when complete + msgContent.textContent = content; + } else { + // For markdown, render incrementally + var parsed = typeof marked !== "undefined" && marked.parse + ? marked.parse(content) + : escapeHtml(content); + parsed = renderMentionInMessage(parsed); + msgContent.innerHTML = parsed; + } + } + } } - function finalizeStreaming() { - var el = document.getElementById(streamingMessageId); - if (el) { - // Check if content has HTML tags - var hasHtmlTags = /<\/?[a-zA-Z][^>]*>|/i.test(currentStreamingContent); - var parsed = hasHtmlTags - ? currentStreamingContent // Use HTML directly - : (typeof marked !== "undefined" && marked.parse - ? marked.parse(currentStreamingContent) - : escapeHtml(currentStreamingContent)); - parsed = renderMentionInMessage(parsed); - el.querySelector(".message-content").innerHTML = parsed; - el.removeAttribute("id"); - setupMentionClickHandlers(el); - } +function finalizeStreaming() { + var el = document.getElementById(streamingMessageId); + if (el) { + var msgContent = el.querySelector(".message-content"); + // Check if content has HTML tags + var hasHtmlTags = /<\/?[a-zA-Z][^>]*>|/i.test(currentStreamingContent); + if (hasHtmlTags) { + // Render complete HTML at the end + var parsed = renderMentionInMessage(currentStreamingContent); + msgContent.innerHTML = parsed; + } else { + // Render markdown + var parsed = typeof marked !== "undefined" && marked.parse + ? marked.parse(currentStreamingContent) + : escapeHtml(currentStreamingContent); + parsed = renderMentionInMessage(parsed); + msgContent.innerHTML = parsed; + } + el.removeAttribute("id"); + setupMentionClickHandlers(el); + } + streamingMessageId = null; + currentStreamingContent = ""; + } streamingMessageId = null; currentStreamingContent = ""; } diff --git a/ui/suite/partials/chat.html b/ui/suite/partials/chat.html index a055c48..eb4402e 100644 --- a/ui/suite/partials/chat.html +++ b/ui/suite/partials/chat.html @@ -487,40 +487,49 @@ ); } - function addMessage(sender, content, msgId) { - var messages = document.getElementById("messages"); - if (!messages) return; +function addMessage(sender, content, msgId) { + var messages = document.getElementById("messages"); + if (!messages) return; - var div = document.createElement("div"); - div.className = "message " + sender; - if (msgId) div.id = msgId; + var div = document.createElement("div"); + div.className = "message " + sender; + if (msgId) div.id = msgId; - if (sender === "user") { - var processedContent = renderMentionInMessage( - escapeHtml(content), - ); - div.innerHTML = - '
' + - processedContent + - "
"; - } else { - // Check if content has HTML (any tag, including comments) - var hasHtmlTags = /<\/?[a-zA-Z][^>]*>|/i.test(content); - console.log("Bot message - hasHtmlTags:", hasHtmlTags, "content length:", content.length); - - var parsed = hasHtmlTags - ? content // Use HTML directly (no escaping!) - : (typeof marked !== "undefined" && marked.parse - ? marked.parse(content) - : escapeHtml(content)); - parsed = renderMentionInMessage(parsed); - div.innerHTML = - '
' + - parsed + - "
"; - } + if (sender === "user") { + var processedContent = renderMentionInMessage( + escapeHtml(content), + ); + div.innerHTML = + '
' + + processedContent + + "
"; + } else { + // Check if content has HTML (any tag, including comments) + var hasHtmlTags = /<\/?[a-zA-Z][^>]*>|/i.test(content); + console.log("Bot message - hasHtmlTags:", hasHtmlTags, "content length:", content.length, "msgId:", msgId); - messages.appendChild(div); + var parsed; + if (hasHtmlTags && msgId) { + // Streaming HTML content - show as text initially to avoid broken tags + // Will be rendered as HTML at finalizeStreaming + parsed = escapeHtml(content); + } else if (hasHtmlTags) { + // Complete HTML content - render directly + parsed = content; + } else { + // Markdown content + parsed = typeof marked !== "undefined" && marked.parse + ? marked.parse(content) + : escapeHtml(content); + } + parsed = renderMentionInMessage(parsed); + div.innerHTML = + '
' + + parsed + + "
"; + } + + messages.appendChild(div); // Auto-scroll to bottom unless user is manually scrolling if (!isUserScrolling) { @@ -939,19 +948,26 @@ return false; } - function updateStreaming(content) { - var el = document.getElementById(streamingMessageId); - if (el) { - // Check if content has HTML tags - var hasHtmlTags = /<\/?[a-zA-Z][^>]*>|/i.test(content); - var parsed = hasHtmlTags - ? content // Use HTML directly - : (typeof marked !== "undefined" && marked.parse - ? marked.parse(content) - : escapeHtml(content)); - parsed = renderMentionInMessage(parsed); - el.querySelector(".message-content").innerHTML = parsed; - } +function updateStreaming(content) { + var el = document.getElementById(streamingMessageId); + if (el) { + var msgContent = el.querySelector(".message-content"); + // Check if final content will be HTML (full accumulated content) + var willBeHtml = /<\/?[a-zA-Z][^>]*>|/i.test(currentStreamingContent); + if (willBeHtml) { + // For HTML content, show plain text during streaming to avoid broken tags + // HTML will be rendered at finalizeStreaming when complete + msgContent.textContent = content; + } else { + // For markdown, render incrementally + var parsed = typeof marked !== "undefined" && marked.parse + ? marked.parse(content) + : escapeHtml(content); + parsed = renderMentionInMessage(parsed); + msgContent.innerHTML = parsed; + } + } + } } function finalizeStreaming() {