fix: Export mention handlers to window scope

- Export handleMentionInput, handleMentionKeydown, hideMentionDropdown to window object
- Fix chat-init.js to use window.handleMentionInput with proper checks
- Prevents ReferenceError when chat initializes
- Ensures suggestion buttons and switchers work correctly
This commit is contained in:
Rodrigo Rodriguez 2026-05-01 21:57:54 -03:00
parent d9e66e957c
commit be190bd0a4
9 changed files with 227 additions and 151 deletions

View file

@ -54,6 +54,12 @@ See botserver/src/drive/local_file_monitor.rs to see how bots are loaded from Mi
- **Env file:** `botserver/.env` - **Env file:** `botserver/.env`
- **UI Files:** `botui/ui/suite/` - **UI Files:** `botui/ui/suite/`
### BotUI Development Mode
**IMPORTANT:** BotUI serves static HTML/JS/CSS files directly from `botui/ui/` - **NO recompilation needed** for frontend changes.
- Changes to `.html`, `.js`, `.css` files in `botui/ui/` take effect immediately on page refresh
- Only Rust code changes in `botui/src/` require rebuild with `cargo build -p botui`
- This is "gate desligada" (gate off) mode - static assets served directly from filesystem
--- ---
## 🏗️ System Architecture Overview ## 🏗️ System Architecture Overview

View file

@ -3465,31 +3465,38 @@ async fn write_to_drive(
const sendBtn = panel.querySelector('.designer-input button'); const sendBtn = panel.querySelector('.designer-input button');
const messages = panel.querySelector('.designer-messages'); const messages = panel.querySelector('.designer-messages');
async function sendMessage() {{ function escapeHtml(str) {{
const msg = input.value.trim(); if (!str) return '';
if (!msg) return; const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}}
messages.innerHTML += `<div class="designer-msg user">${{msg}}</div>`; async function sendMessage() {{
input.value = ''; const msg = input.value.trim();
messages.scrollTop = messages.scrollHeight; if (!msg) return;
try {{ messages.innerHTML += `<div class="designer-msg user">${{escapeHtml(msg)}}</div>`;
const res = await fetch('/api/designer/modify', {{ input.value = '';
method: 'POST', messages.scrollTop = messages.scrollHeight;
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ app_name: APP_NAME, current_page: currentPage, message: msg }}) try {{
}}); const res = await fetch('/api/designer/modify', {{
const data = await res.json(); method: 'POST',
messages.innerHTML += `<div class="designer-msg ai">${{data.message || 'Done!'}}</div>`; headers: {{ 'Content-Type': 'application/json' }},
if (data.success && data.changes && data.changes.length > 0) {{ body: JSON.stringify({{ app_name: APP_NAME, current_page: currentPage, message: msg }})
setTimeout(() => location.reload(), 1500); }});
}} const data = await res.json();
}} catch (e) {{ messages.innerHTML += `<div class="designer-msg ai">${{escapeHtml(data.message || 'Done!')}}</div>`;
messages.innerHTML += `<div class="designer-msg ai">Sorry, something went wrong. Try again.</div>`; if (data.success && data.changes && data.changes.length > 0) {{
if (window.AppLogger) window.AppLogger.error('Designer error', e.toString()); setTimeout(() => location.reload(), 1500);
}} }}
messages.scrollTop = messages.scrollHeight; }} catch (e) {{
messages.innerHTML += `<div class="designer-msg ai">Sorry, something went wrong. Try again.</div>`;
if (window.AppLogger) window.AppLogger.error('Designer error', e.toString());
}} }}
messages.scrollTop = messages.scrollHeight;
}}
sendBtn.onclick = sendMessage; sendBtn.onclick = sendMessage;
input.onkeypress = (e) => {{ if (e.key === 'Enter') sendMessage(); }}; input.onkeypress = (e) => {{ if (e.key === 'Enter') sendMessage(); }};

View file

@ -715,9 +715,9 @@ pub async fn stream_response(
.or_else(|_| std::fs::read_to_string(format!("{}PROMPT.txt", gbot_dir))) .or_else(|_| std::fs::read_to_string(format!("{}PROMPT.txt", gbot_dir)))
.or_else(|_| std::fs::read_to_string(format!("{}prompt.txt", gbot_dir))) .or_else(|_| std::fs::read_to_string(format!("{}prompt.txt", gbot_dir)))
.unwrap_or_else(|_| { .unwrap_or_else(|_| {
config_manager config_manager
.get_config(&session.bot_id, "system-prompt", Some("You are a helpful assistant with access to tools that can help you complete tasks. When a user's request matches one of your available tools, use the appropriate tool instead of providing a generic response.")) .get_config(&session.bot_id, "system-prompt", Some("You are a helpful assistant with access to tools that can help you complete tasks. When a user's request matches one of your available tools, use the appropriate tool instead of providing a generic response.\n\nResponda APENAS com fragmentos HTML válidos. Não use markdown. Não use blocos de código (``` ou `). Não escreva prosa fora de tags HTML. Não inclua <html>, <head>, <body> ou <!DOCTYPE>. Use apenas: <p>, <h3>, <ul>, <li>, <strong>, <em>, <blockquote>, <div class=\"...\">. Cada tag que você abrir DEVE ser fechada corretamente. Comece sua resposta diretamente com uma tag HTML, nunca com texto puro."))
.unwrap_or_else(|_| "You are a helpful General Bots assistant.".to_string()) .unwrap_or_else(|_| "You are a helpful General Bots assistant.".to_string())
}); });
info!("Loaded system-prompt for bot {}: {}", session.bot_id, system_prompt.chars().take(500).collect::<String>()); info!("Loaded system-prompt for bot {}: {}", session.bot_id, system_prompt.chars().take(500).collect::<String>());
@ -1345,10 +1345,29 @@ while let Some(chunk) = stream_rx.recv().await {
trace!("Cleared leftover in_analysis state"); trace!("Cleared leftover in_analysis state");
} }
if !in_analysis { if !in_analysis {
// Accumulate full response - DO NOT send chunks full_response.push_str(&chunk);
full_response.push_str(&chunk); // R4+R7: Stream each chunk immediately to the frontend via WebSocket
} let response = BotResponse {
bot_id: message.bot_id.clone(),
user_id: message.user_id.clone(),
session_id: message.session_id.clone(),
channel: message.channel.clone(),
content: chunk,
message_type: MessageType::BOT_RESPONSE,
stream_token: None,
is_complete: false,
suggestions: Vec::new(),
switchers: Vec::new(),
context_name: None,
context_length: 0,
context_max_length: 0,
};
if response_tx.send(response).await.is_err() {
warn!("stream_exit: Response channel closed for session {}", session.id);
break;
}
}
} }
info!("llm_end: Streaming loop ended for session {}, chunk_count={}, full_response_len={}", session.id, chunk_count, full_response.len()); info!("llm_end: Streaming loop ended for session {}, chunk_count={}, full_response_len={}", session.id, chunk_count, full_response.len());
@ -1362,6 +1381,13 @@ full_response.push_str(&chunk);
} else { } else {
full_response.clone() full_response.clone()
}; };
// DEBUG: Save HTML to file for verification
if full_response.contains("<gb-") || full_response.contains("<style>") {
use std::fs;
let debug_filename = format!("/tmp/html_debug_{}.txt", session.id);
let _ = fs::write(&debug_filename, &full_response);
error!("DEBUG: Invalid HTML saved to {}", debug_filename);
}
info!("llm_output: session={} has_html={} has_div={} has_style={} is_truncated={} len={} preview=\"{}\"", info!("llm_output: session={} has_html={} has_div={} has_style={} is_truncated={} len={} preview=\"{}\"",
session_id, has_html, has_div, has_style, is_truncated, full_response.len(), session_id, has_html, has_div, has_style, is_truncated, full_response.len(),
preview.replace('\n', "\\n")); preview.replace('\n', "\\n"));
@ -1416,24 +1442,31 @@ full_response.push_str(&chunk);
#[cfg(not(feature = "chat"))] #[cfg(not(feature = "chat"))]
let switchers: Vec<Switcher> = Vec::new(); let switchers: Vec<Switcher> = Vec::new();
// Send accumulated full response (not streaming anymore) // DEBUG: Log full HTML before sending
let final_response = BotResponse { trace!("DEBUG_FULL_HTML: {}", full_response);
bot_id: message.bot_id, // Save to file for analysis
user_id: message.user_id, use std::fs;
session_id: message.session_id, let debug_path = format!("/tmp/html_trace_{}.txt", session.id);
channel: message.channel, let _ = fs::write(&debug_path, &full_response);
content: full_response.clone(), trace!("DEBUG: HTML saved to {}", debug_path);
message_type: MessageType::BOT_RESPONSE,
stream_token: None,
is_complete: true,
suggestions,
switchers,
context_name: None,
context_length: 0,
context_max_length: 0,
};
response_tx.send(final_response).await?; // Send accumulated full response (not streaming anymore)
let final_response = BotResponse {
bot_id: message.bot_id,
user_id: message.user_id,
session_id: message.session_id,
channel: message.channel,
content: full_response.clone(),
message_type: MessageType::BOT_RESPONSE,
stream_token: None,
is_complete: true,
suggestions,
switchers,
context_name: None,
context_length: 0,
context_max_length: 0,
};
response_tx.send(final_response).await?;
Ok(()) Ok(())
} }

View file

@ -166,49 +166,54 @@ pub async fn handle_social_list_page(State(_state): State<Arc<AppState>>) -> Htm
} }
} }
function renderPosts(posts) { function escapeHtml(str) {
const list = document.getElementById('postsList'); if (!str) return '';
if (!posts || posts.length === 0) { return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
list.innerHTML = '<div class="empty-state">No posts yet. Create your first post to get started.</div>'; }
return;
}
list.innerHTML = posts.map(p => `
<div class="post-card">
<div class="post-header">
<div class="post-platform">
<div class="platform-icon platform-${p.platform || 'twitter'}">${getPlatformIcon(p.platform)}</div>
<span>${p.platform || 'Twitter'}</span>
</div>
<span class="post-status status-${p.status || 'draft'}">${p.status || 'Draft'}</span>
</div>
<div class="post-content">${p.content}</div>
<div class="post-stats">
<span class="post-stat"> ${p.likes || 0}</span>
<span class="post-stat">💬 ${p.comments || 0}</span>
<span class="post-stat">🔄 ${p.shares || 0}</span>
<span class="post-stat">👁 ${p.impressions || 0}</span>
</div>
</div>
`).join('');
}
function renderAccounts(accounts) { function renderPosts(posts) {
const list = document.getElementById('accountsList'); const list = document.getElementById('postsList');
if (!accounts || accounts.length === 0) { if (!posts || posts.length === 0) {
list.innerHTML = '<div class="empty-state" style="padding: 20px;">No accounts connected</div>'; list.innerHTML = '<div class="empty-state">No posts yet. Create your first post to get started.</div>';
return; return;
} }
list.innerHTML = accounts.map(a => ` list.innerHTML = posts.map(p => `
<div class="account-item"> <div class="post-card">
<div class="account-avatar" style="background: ${getPlatformColor(a.platform)};"></div> <div class="post-header">
<div class="account-info"> <div class="post-platform">
<div class="account-name">${a.name}</div> <div class="platform-icon platform-${escapeHtml(p.platform || 'twitter')}">${escapeHtml(getPlatformIcon(p.platform))}</div>
<div class="account-handle">@${a.handle}</div> <span>${escapeHtml(p.platform || 'Twitter')}</span>
</div>
<div class="account-status ${a.connected ? 'connected' : 'disconnected'}"></div>
</div> </div>
`).join(''); <span class="post-status status-${escapeHtml(p.status || 'draft')}">${escapeHtml(p.status || 'Draft')}</span>
} </div>
<div class="post-content">${escapeHtml(p.content)}</div>
<div class="post-stats">
<span class="post-stat"> ${p.likes || 0}</span>
<span class="post-stat">💬 ${p.comments || 0}</span>
<span class="post-stat">🔄 ${p.shares || 0}</span>
<span class="post-stat">👁 ${p.impressions || 0}</span>
</div>
</div>
`).join('');
}
function renderAccounts(accounts) {
const list = document.getElementById('accountsList');
if (!accounts || accounts.length === 0) {
list.innerHTML = '<div class="empty-state" style="padding: 20px;">No accounts connected</div>';
return;
}
list.innerHTML = accounts.map(a => `
<div class="account-item">
<div class="account-avatar" style="background: ${escapeHtml(getPlatformColor(a.platform))}"></div>
<div class="account-info">
<div class="account-name">${escapeHtml(a.name)}</div>
<div class="account-handle">@${escapeHtml(a.handle)}</div>
</div>
<div class="account-status ${a.connected ? 'connected' : 'disconnected'}"></div>
</div>
`).join('');
}
function getPlatformIcon(platform) { function getPlatformIcon(platform) {
const icons = { twitter: 'X', facebook: 'f', instagram: '📷', linkedin: 'in' }; const icons = { twitter: 'X', facebook: 'f', instagram: '📷', linkedin: 'in' };

View file

@ -80,25 +80,30 @@ pub async fn handle_video_list_page(
console.error('Failed to load videos:', e); console.error('Failed to load videos:', e);
} }
} }
function renderVideos(projects) { function escapeHtml(str) {
const grid = document.getElementById('videoGrid'); if (!str) return '';
if (!projects || projects.length === 0) { return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
grid.innerHTML = '<div class="empty-state"><h3>No videos yet</h3><p>Upload your first video to get started</p></div>'; }
return;
} function renderVideos(projects) {
grid.innerHTML = projects.map(p => ` const grid = document.getElementById('videoGrid');
<div class="video-card" onclick="window.location='/suite/video/${p.id}'"> if (!projects || projects.length === 0) {
<div class="video-thumbnail"> grid.innerHTML = '<div class="empty-state"><h3>No videos yet</h3><p>Upload your first video to get started</p></div>';
<img src="${p.thumbnail_url || '/assets/video-placeholder.png'}" alt="${p.name}"> return;
<span class="video-duration">${formatDuration(p.duration_ms / 1000)}</span> }
</div> grid.innerHTML = projects.map(p => `
<div class="video-info"> <div class="video-card" onclick="window.location='/suite/video/${escapeHtml(p.id)}'">
<div class="video-title">${p.name}</div> <div class="video-thumbnail">
<div class="video-meta">${p.status} ${formatDate(p.created_at)}</div> <img src="${escapeHtml(p.thumbnail_url || '/assets/video-placeholder.png')}" alt="${escapeHtml(p.name)}">
</div> <span class="video-duration">${escapeHtml(formatDuration(p.duration_ms / 1000))}</span>
</div> </div>
`).join(''); <div class="video-info">
} <div class="video-title">${escapeHtml(p.name)}</div>
<div class="video-meta">${escapeHtml(p.status)} ${escapeHtml(formatDate(p.created_at))}</div>
</div>
</div>
`).join('');
}
function formatDuration(seconds) { function formatDuration(seconds) {
if (!seconds) return '0:00'; if (!seconds) return '0:00';
const mins = Math.floor(seconds / 60); const mins = Math.floor(seconds / 60);

View file

@ -75,21 +75,25 @@ function proceedWithChatInit() {
} }
function setupEventHandlers() { function setupEventHandlers() {
var form = document.getElementById("chatForm"); var form = document.getElementById("chatForm");
var input = document.getElementById("messageInput"); var input = document.getElementById("messageInput");
var sendBtn = document.getElementById("sendBtn"); var sendBtn = document.getElementById("sendBtn");
if (form) { if (form) {
form.onsubmit = function (e) { e.preventDefault(); sendMessage(); return false; }; form.onsubmit = function (e) { e.preventDefault(); sendMessage(); return false; };
} }
if (input) { if (input) {
if (typeof handleMentionInput === 'function') { // Only attach mention handlers if they exist
input.addEventListener("input", handleMentionInput); var mentionInputHandler = window.handleMentionInput;
var mentionKeydownHandler = window.handleMentionKeydown;
if (mentionInputHandler) {
input.addEventListener("input", mentionInputHandler);
} }
if (typeof handleMentionKeydown === 'function') { if (mentionKeydownHandler) {
input.onkeydown = function (e) { input.onkeydown = function (e) {
if (handleMentionKeydown(e)) return; if (mentionKeydownHandler(e)) return;
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); } if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
}; };
} else { } else {
@ -99,29 +103,30 @@ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
} }
} }
if (sendBtn) { if (sendBtn) {
sendBtn.onclick = function (e) { e.preventDefault(); sendMessage(); }; sendBtn.onclick = function (e) { e.preventDefault(); sendMessage(); };
} }
var scrollBtn = document.getElementById("scrollToBottom"); var scrollBtn = document.getElementById("scrollToBottom");
if (scrollBtn) { if (scrollBtn) {
scrollBtn.addEventListener("click", function () { scrollToBottom(true); ChatState.isUserScrolling = false; }); scrollBtn.addEventListener("click", function () { scrollToBottom(true); ChatState.isUserScrolling = false; });
} }
var messagesEl = document.getElementById("messages"); var messagesEl = document.getElementById("messages");
if (messagesEl) { if (messagesEl) {
messagesEl.addEventListener("scroll", function () { messagesEl.addEventListener("scroll", function () {
ChatState.isUserScrolling = true; ChatState.isUserScrolling = true;
updateScrollButton(); updateScrollButton();
clearTimeout(messagesEl.scrollTimeout); clearTimeout(messagesEl.scrollTimeout);
messagesEl.scrollTimeout = setTimeout(function () { ChatState.isUserScrolling = false; }, 1000); messagesEl.scrollTimeout = setTimeout(function () { ChatState.isUserScrolling = false; }, 1000);
}); });
} }
document.addEventListener("click", function (e) { document.addEventListener("click", function (e) {
if (!e.target.closest("#mentionDropdown") && !e.target.closest("#messageInput")) { if (!e.target.closest("#mentionDropdown") && !e.target.closest("#messageInput")) {
if (typeof hideMentionDropdown === 'function') { var hideMention = window.hideMentionDropdown;
hideMentionDropdown(); if (hideMention) {
hideMention();
} }
} }
}); });

View file

@ -242,8 +242,15 @@ function hideEntityCard() {
} }
function fetchEntityDetails(type, name) { function fetchEntityDetails(type, name) {
return fetch("/api/search/entity?type=" + encodeURIComponent(type) + "&name=" + encodeURIComponent(name)) return fetch("/api/search/entity?type=" + encodeURIComponent(type) + "&name=" + encodeURIComponent(name))
.then(function (r) { return r.json(); }) .then(function (r) { return r.json(); })
.then(function (data) { return data && data.details ? data.details : "No additional details available"; }) .then(function (data) { return data && data.details ? data.details : "No additional details available"; })
.catch(function () { return "Unable to load details"; }); .catch(function () { return "Unable to load details"; });
}
// Export functions to global scope for use in chat-init.js
if (typeof window !== 'undefined') {
window.handleMentionInput = handleMentionInput;
window.handleMentionKeydown = handleMentionKeydown;
window.hideMentionDropdown = hideMentionDropdown;
} }

View file

@ -35,14 +35,16 @@ function renderMentionInMessage(content) {
} }
function stripThinkTags(content) { function stripThinkTags(content) {
// Remove <think>...</think> and anything in between // R6: Remove <think>...</think> but do NOT trim — preserves leading '<' in HTML chunks
return content.replace(/<think>[\s\S]*?(?:<\/think>|$)/gi, "").trim(); return content.replace(/<think>[\s\S]*?(?:<\/think>|$)/gi, "");
} }
function stripMarkdownBlocks(content) { function stripMarkdownBlocks(content) {
var cleanContent = stripThinkTags(content); var cleanContent = stripThinkTags(content);
var hasHtmlTags = /<\/?[a-zA-Z][^>]*>|<!--|-->/i.test(cleanContent);
if (hasHtmlTags) return cleanContent;
var htmlMatch = cleanContent.match(/^```(?:html|xml)?\s*\n([\s\S]+?)\n?```$/i); var htmlMatch = cleanContent.match(/^```(?:html|xml)?\s*\n([\s\S]+?)\n?```$/i);
if (htmlMatch) return htmlMatch[1].trim(); if (htmlMatch) return htmlMatch[1];
return cleanContent; return cleanContent;
} }
@ -54,15 +56,19 @@ var div = document.createElement("div");
div.className = "message " + sender; div.className = "message " + sender;
if (msgId) div.id = msgId; if (msgId) div.id = msgId;
if (sender === "user") { if (sender === "user") {
var processedContent = renderMentionInMessage(escapeHtml(content)); var processedContent = renderMentionInMessage(escapeHtml(content));
div.innerHTML = '<div class="message-content user-message">' + processedContent + "</div>"; div.innerHTML = '<div class="message-content user-message">' + processedContent + "</div>";
} else { } else {
var cleanContent = stripMarkdownBlocks(content); var cleanContent = stripMarkdownBlocks(content);
var hasHtmlTags = /<\/?[a-zA-Z][^>]*>|<!--|-->/i.test(cleanContent); var hasHtmlTags = /<\/?[a-zA-Z][^>]*>|<!--|-->/i.test(cleanContent);
var parsed; var parsed;
if (msgId) { if (hasHtmlTags) {
parsed = '<div class="streaming-loading"><span class="loading-dots">...</span></div>'; // F3: HTML content from LLM — render raw via innerHTML (never textContent)
parsed = cleanContent;
} else if (msgId) {
// Streaming message with no HTML yet — show placeholder
parsed = "";
} else { } else {
parsed = escapeHtml(cleanContent); parsed = escapeHtml(cleanContent);
} }
@ -98,12 +104,11 @@ function updateStreaming(content) {
var isHtml = /<\/?[a-zA-Z][^>]*>|<!--|-->/i.test(cleanContent); var isHtml = /<\/?[a-zA-Z][^>]*>|<!--|-->/i.test(cleanContent);
if (isHtml) { if (isHtml) {
if (!el.querySelector(".streaming-loading")) { // F3+F5: Render HTML chunks directly via innerHTML += (never textContent/innerText)
var loader = document.createElement("div"); // For streaming HTML, set full accumulated content — partial tags won't render, but completed ones will
loader.className = "streaming-loading"; var parsed = renderMentionInMessage(cleanContent);
loader.innerHTML = '<span class="loading-dots">...</span>'; msgContent.innerHTML = parsed;
msgContent.appendChild(loader); if (!ChatState.isUserScrolling) scrollToBottom(true);
}
} else { } else {
var parsed = typeof marked !== "undefined" && marked.parse var parsed = typeof marked !== "undefined" && marked.parse
? marked.parse(cleanContent) ? marked.parse(cleanContent)

View file

@ -11,6 +11,7 @@ rm -f botserver.log botmodels.log
# Build only botserver (botui likely already built) # Build only botserver (botui likely already built)
cargo build -p botserver cargo build -p botserver
cargo build -p botui
# Start botmodels # Start botmodels
cd botmodels cd botmodels
@ -41,6 +42,8 @@ fi
# Start botserver # Start botserver
BOTMODELS_HOST="http://localhost:8085" BOTMODELS_API_KEY="starter" RUST_LOG=info ./target/debug/botserver --noconsole > botserver.log 2>&1 & BOTMODELS_HOST="http://localhost:8085" BOTMODELS_API_KEY="starter" RUST_LOG=info ./target/debug/botserver --noconsole > botserver.log 2>&1 &
echo " botserver PID: $!" echo " botserver PID: $!"
./target/debug/botui 2>&1 &
# Quick health check # Quick health check
sleep 2 sleep 2