fix: prevent broken HTML during streaming by deferring render to completion
All checks were successful
BotUI CI/CD / build (push) Successful in 59s

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
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-04-14 11:58:18 -03:00
parent 87fcb02b60
commit 91e9701c3e
2 changed files with 144 additions and 103 deletions

View file

@ -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 =
'<div class="message-content user-message">' +
processedContent +
"</div>";
} 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 =
'<div class="message-content bot-message">' +
parsed +
"</div>";
}
if (sender === "user") {
var processedContent = renderMentionInMessage(
escapeHtml(content),
);
div.innerHTML =
'<div class="message-content user-message">' +
processedContent +
"</div>";
} 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 =
'<div class="message-content bot-message">' +
parsed +
"</div>";
}
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 = "";
}

View file

@ -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 =
'<div class="message-content user-message">' +
processedContent +
"</div>";
} 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 =
'<div class="message-content bot-message">' +
parsed +
"</div>";
}
if (sender === "user") {
var processedContent = renderMentionInMessage(
escapeHtml(content),
);
div.innerHTML =
'<div class="message-content user-message">' +
processedContent +
"</div>";
} 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 =
'<div class="message-content bot-message">' +
parsed +
"</div>";
}
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() {