feat: stabilize production HTML rendering and update CI/CD
Some checks failed
BotServer CI / build (push) Has been cancelled

- botserver: implemented tag-aware streaming to prevent broken HTML chunks
- botserver: disabled automatic HTML-to-Markdown conversion to preserve rich design
- botserver/llm: added Claude 3.7 thinking/reasoning support
- botui: fixed chat-messages.js to allow rich HTML rendering and stop tag stripping
- botui: updated CI/CD to build botui in release mode with embedded UI
This commit is contained in:
Rodrigo Rodriguez 2026-04-30 18:41:08 -03:00
parent 5a5a7594c7
commit 91eb106567
4 changed files with 115 additions and 75 deletions

View file

@ -39,7 +39,7 @@ jobs:
echo "=== sccache stats before build ==="
sccache --show-stats 2>/dev/null || echo "sccache not available"
CARGO_BUILD_JOBS=6 cargo build -p botserver --bin botserver
CARGO_BUILD_JOBS=6 cargo build -p botui --bin botui --release
CARGO_BUILD_JOBS=6 cargo build -p botui --bin botui --release --features embed-ui
echo "=== sccache stats after build ==="
sccache --show-stats 2>/dev/null || echo "sccache not available"

View file

@ -26,7 +26,6 @@ use crate::core::shared::state::AppState;
use crate::basic::keywords::add_suggestion::get_suggestions;
#[cfg(feature = "chat")]
use crate::basic::keywords::switcher::{get_switchers, resolve_active_switchers};
use html2md::parse_html;
use axum::extract::ws::{Message, WebSocket};
use axum::{
@ -899,6 +898,7 @@ let system_prompt = if !message.active_switchers.is_empty() {
#[cfg(not(feature = "chat"))]
let switchers: Vec<Switcher> = Vec::new();
let user_id_str = user_id.to_string();
// Flush any remaining content in html_buffer
if !html_buffer.is_empty() {
let content_to_send = html_buffer
@ -1064,6 +1064,16 @@ let system_prompt = if !message.active_switchers.is_empty() {
}
});
fn is_inside_html_tag(html: &str) -> bool {
let last_open = html.rfind('<');
let last_close = html.rfind('>');
match (last_open, last_close) {
(Some(o), Some(c)) => o > c,
(Some(_), None) => true,
_ => false,
}
}
let mut full_response = String::new();
let mut analysis_buffer = String::new();
let mut in_analysis = false;
@ -1369,7 +1379,7 @@ while let Some(chunk) = stream_rx.recv().await {
// 1. HTML tag pair completed (e.g., </div>, </h1>, </p>, </ul>, </li>)
// 2. Buffer is large enough (> 500 chars)
// 3. This is the last chunk (is_complete will be true next iteration)
let should_flush = html_buffer.len() > 500
let should_flush = (html_buffer.len() > 1000
|| html_buffer.contains("</div>")
|| html_buffer.contains("</h1>")
|| html_buffer.contains("</h2>")
@ -1387,7 +1397,15 @@ while let Some(chunk) = stream_rx.recv().await {
|| html_buffer.contains("</th>")
|| html_buffer.contains("</section>")
|| html_buffer.contains("</header>")
|| html_buffer.contains("</footer>");
|| html_buffer.contains("</footer>")
|| html_buffer.contains("</azul>")
|| html_buffer.contains("</ouro>")
|| html_buffer.contains("</hero>")
|| html_buffer.contains("</wrap>")
|| html_buffer.contains("</corpo>")
|| html_buffer.contains("</item>")
|| html_buffer.contains("</rodape>"))
&& !is_inside_html_tag(&html_buffer);
if should_flush {
// Sanitize malformed tags (e.g. "< class" -> "<class", "</ div" -> "</div")
@ -1437,25 +1455,14 @@ while let Some(chunk) = stream_rx.recv().await {
preview.replace('\n', "\\n"));
let full_response_len = full_response.len();
let is_html = full_response.contains("<") && full_response.contains(">");
let content_for_save = if is_html {
let parsed = parse_html(&full_response);
// Fallback to original if parsing returns empty
if parsed.trim().is_empty() {
full_response.clone()
} else {
parsed
}
} else {
full_response.clone()
};
let content_for_save = full_response.clone();
let history_preview = if content_for_save.len() > 100 {
format!("{}...", content_for_save.split_at(100).0)
} else {
content_for_save.clone()
};
info!("history_save: session_id={} user_id={} full_response_len={} is_html={} content_len={} preview={}",
session.id, user_id, full_response_len, is_html, content_for_save.len(), history_preview);
info!("history_save: session_id={} user_id={} full_response_len={} has_html={} content_len={} preview={}",
session.id, user_id, full_response_len, has_html, content_for_save.len(), history_preview);
let state_for_save = self.state.clone();
let content_for_save_owned = content_for_save;

View file

@ -24,6 +24,13 @@ pub struct ClaudeMessage {
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeThinking {
#[serde(rename = "type")]
pub thinking_type: String, // "enabled"
pub budget_tokens: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaudeRequest {
pub model: String,
@ -33,6 +40,8 @@ pub struct ClaudeRequest {
pub system: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thinking: Option<ClaudeThinking>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -62,6 +71,8 @@ pub struct ClaudeStreamDelta {
pub delta_type: String,
#[serde(default)]
pub text: String,
#[serde(default)]
pub thinking: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -315,10 +326,10 @@ impl ClaudeClient {
.join("")
}
/// Process SSE data and extract text - handles both Anthropic and Azure formats
fn process_sse_data(&self, data: &str, model_name: &str) -> Option<String> {
/// Process SSE data and extract content/reasoning
fn process_sse_data(&self, data: &str, model_name: &str) -> (Option<String>, Option<String>) {
if data == "[DONE]" {
return None;
return (None, None);
}
let handler = get_handler(model_name);
@ -328,21 +339,25 @@ impl ClaudeClient {
if chunk.object == "chat.completion.chunk" || !chunk.choices.is_empty() {
for choice in &chunk.choices {
if let Some(delta) = &choice.delta {
// Get content (prefer content over reasoning_content)
let text = delta
.content
.as_deref()
.unwrap_or("");
// Check for reasoning content (O1/DeepSeek/Claude on Azure style)
if let Some(reasoning) = &delta.reasoning_content {
if !reasoning.is_empty() {
return (None, Some(reasoning.to_string()));
}
}
if !text.is_empty() {
let processed = handler.process_content(text);
if !processed.is_empty() {
return Some(processed);
// Get content
if let Some(text) = &delta.content {
if !text.is_empty() {
let processed = handler.process_content(text);
if !processed.is_empty() {
return (Some(processed), None);
}
}
}
}
}
return None;
return (None, None);
}
}
@ -354,24 +369,18 @@ impl ClaudeClient {
if delta.delta_type == "text_delta" && !delta.text.is_empty() {
let processed = handler.process_content(&delta.text);
if !processed.is_empty() {
return Some(processed);
return (Some(processed), None);
}
} else if delta.delta_type == "thinking_delta" && !delta.thinking.is_empty() {
return (None, Some(delta.thinking.clone()));
}
}
}
"message_start" => trace!("CLAUDE message_start"),
"content_block_start" => trace!("CLAUDE content_block_start"),
"content_block_stop" => trace!("CLAUDE content_block_stop"),
"message_stop" => trace!("CLAUDE message_stop"),
"message_delta" => trace!("CLAUDE message_delta"),
"error" => {
error!("CLAUDE Error event: {}", data);
}
_ => trace!("CLAUDE Event: {}", event.event_type),
_ => {}
}
}
None
(None, None)
}
/// Streaming implementation using reqwest - mimics Node.js https.request with res.on('data')
@ -551,13 +560,26 @@ impl ClaudeClient {
}
// Process SSE data and send text chunks
if let Some(text) = self.process_sse_data(data, model_name) {
if tx.send(text.clone()).await.is_err() {
let (content, reasoning) = self.process_sse_data(data, model_name);
if let Some(text) = content {
if tx.send(text).await.is_err() {
warn!("CLAUDE Receiver dropped, stopping stream");
return Ok(());
}
text_chunks_sent += 1;
}
if let Some(think) = reasoning {
let think_msg = serde_json::json!({
"type": "thinking",
"content": think
}).to_string();
if tx.send(think_msg).await.is_err() {
warn!("CLAUDE Receiver dropped, stopping stream");
return Ok(());
}
}
}
}
}
@ -568,10 +590,18 @@ impl ClaudeClient {
let line = line.trim();
if let Some(data) = line.strip_prefix("data: ") {
if data != "[DONE]" {
if let Some(text) = self.process_sse_data(data, model_name) {
let (content, reasoning) = self.process_sse_data(data, model_name);
if let Some(text) = content {
let _ = tx.send(text).await;
text_chunks_sent += 1;
}
if let Some(think) = reasoning {
let think_msg = serde_json::json!({
"type": "thinking",
"content": think
}).to_string();
let _ = tx.send(think_msg).await;
}
}
}
}
@ -610,12 +640,22 @@ impl ClaudeClient {
return Err("No messages to send".into());
}
let thinking = if model_name.contains("3-7") || model_name.contains("4-7") {
Some(ClaudeThinking {
thinking_type: "enabled".to_string(),
budget_tokens: 2048,
})
} else {
None
};
let request = ClaudeRequest {
model: model_name.to_string(),
max_tokens: 16000,
max_tokens: if thinking.is_some() { 16000 } else { 4096 },
messages: claude_messages,
system,
stream: Some(true),
thinking,
};
let request_body = serde_json::to_string(&request)?;
@ -657,6 +697,7 @@ impl LLMProvider for ClaudeClient {
messages: claude_messages,
system,
stream: None,
thinking: None,
};
let body = serde_json::to_string(&request)?;

View file

@ -59,22 +59,17 @@ var processedContent = renderMentionInMessage(escapeHtml(content));
div.innerHTML = '<div class="message-content user-message">' + processedContent + "</div>";
} else {
var cleanContent = stripMarkdownBlocks(content);
// Strip HTML tags from XLSX content to prevent rendering
cleanContent = cleanContent.replace(/<[^>]*>/g, "");
var hasHtmlTags = /<\/?[a-zA-Z][^>]*>|<!--|-->/i.test(cleanContent);
var parsed;
if (msgId) {
parsed = '<div class="streaming-loading"><span class="loading-dots">...</span></div>';
} else if (hasHtmlTags) {
parsed = escapeHtml(cleanContent);
} else {
parsed = typeof marked !== "undefined" && marked.parse
? marked.parse(cleanContent)
: escapeHtml(cleanContent);
}
parsed = renderMentionInMessage(parsed);
div.innerHTML = '<div class="message-content bot-message">' + parsed + "</div>";
if (msgId) {
parsed = '<div class="streaming-loading"><span class="loading-dots">...</span></div>';
} else if (hasHtmlTags) {
parsed = cleanContent; // Don't escape HTML tags
} else {
parsed = typeof marked !== "undefined" && marked.parse
? marked.parse(cleanContent)
: escapeHtml(cleanContent);
}
parsed = renderMentionInMessage(parsed);
div.innerHTML = '<div class="message-content bot-message">' + parsed + "</div>";
}
messages.appendChild(div);
@ -102,17 +97,16 @@ if (!el) return;
var msgContent = el.querySelector(".message-content");
var cleanContent = stripMarkdownBlocks(content);
// Strip HTML tags from XLSX content to prevent rendering
cleanContent = cleanContent.replace(/<[^>]*>/g, "");
var isHtml = /<\/?[a-zA-Z][^>]*>|<!--|-->/i.test(cleanContent);
if (isHtml) {
if (isTagBalanced(cleanContent) || (Date.now() - ChatState.lastRenderTime > 2000)) {
msgContent.innerHTML = renderMentionInMessage(escapeHtml(cleanContent));
ChatState.lastRenderTime = Date.now();
if (!ChatState.isUserScrolling) scrollToBottom(true);
if (isTagBalanced(cleanContent) || (Date.now() - ChatState.lastRenderTime > 2000)) {
msgContent.innerHTML = renderMentionInMessage(cleanContent); // Don't escape HTML
ChatState.lastRenderTime = Date.now();
if (!ChatState.isUserScrolling) scrollToBottom(true);
}
}
} else {
else {
var parsed = typeof marked !== "undefined" && marked.parse
? marked.parse(cleanContent)
: escapeHtml(cleanContent);
@ -126,14 +120,12 @@ function finalizeStreaming() {
var el = document.getElementById(ChatState.streamingMessageId);
if (el) {
var cleanContent = stripMarkdownBlocks(ChatState.currentStreamingContent);
// Strip HTML tags from XLSX content to prevent rendering
cleanContent = cleanContent.replace(/<[^>]*>/g, "");
var hasHtmlTags = /<\/?[a-zA-Z][^>]*>|<!--|-->/i.test(cleanContent);
var parsed = hasHtmlTags
? escapeHtml(cleanContent)
: (typeof marked !== "undefined" && marked.parse
? marked.parse(cleanContent)
: escapeHtml(cleanContent));
? cleanContent // Don't escape HTML
: (typeof marked !== "undefined" && marked.parse
? marked.parse(cleanContent)
: escapeHtml(cleanContent));
parsed = renderMentionInMessage(parsed);
el.querySelector(".message-content").innerHTML = parsed;
el.removeAttribute("id");