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`
- **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

View file

@ -3465,31 +3465,38 @@ async fn write_to_drive(
const sendBtn = panel.querySelector('.designer-input button');
const messages = panel.querySelector('.designer-messages');
async function sendMessage() {{
const msg = input.value.trim();
if (!msg) return;
function escapeHtml(str) {{
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}}
messages.innerHTML += `<div class="designer-msg user">${{msg}}</div>`;
input.value = '';
messages.scrollTop = messages.scrollHeight;
async function sendMessage() {{
const msg = input.value.trim();
if (!msg) return;
try {{
const res = await fetch('/api/designer/modify', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ app_name: APP_NAME, current_page: currentPage, message: msg }})
}});
const data = await res.json();
messages.innerHTML += `<div class="designer-msg ai">${{data.message || 'Done!'}}</div>`;
if (data.success && data.changes && data.changes.length > 0) {{
setTimeout(() => location.reload(), 1500);
}}
}} 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.innerHTML += `<div class="designer-msg user">${{escapeHtml(msg)}}</div>`;
input.value = '';
messages.scrollTop = messages.scrollHeight;
try {{
const res = await fetch('/api/designer/modify', {{
method: 'POST',
headers: {{ 'Content-Type': 'application/json' }},
body: JSON.stringify({{ app_name: APP_NAME, current_page: currentPage, message: msg }})
}});
const data = await res.json();
messages.innerHTML += `<div class="designer-msg ai">${{escapeHtml(data.message || 'Done!')}}</div>`;
if (data.success && data.changes && data.changes.length > 0) {{
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;
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)))
.unwrap_or_else(|_| {
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."))
.unwrap_or_else(|_| "You are a helpful General Bots assistant.".to_string())
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.\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())
});
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");
}
if !in_analysis {
// Accumulate full response - DO NOT send chunks
full_response.push_str(&chunk);
}
if !in_analysis {
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());
@ -1362,6 +1381,13 @@ full_response.push_str(&chunk);
} else {
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=\"{}\"",
session_id, has_html, has_div, has_style, is_truncated, full_response.len(),
preview.replace('\n', "\\n"));
@ -1416,24 +1442,31 @@ full_response.push_str(&chunk);
#[cfg(not(feature = "chat"))]
let switchers: Vec<Switcher> = Vec::new();
// 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,
};
// DEBUG: Log full HTML before sending
trace!("DEBUG_FULL_HTML: {}", full_response);
// Save to file for analysis
use std::fs;
let debug_path = format!("/tmp/html_trace_{}.txt", session.id);
let _ = fs::write(&debug_path, &full_response);
trace!("DEBUG: HTML saved to {}", debug_path);
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(())
}

View file

@ -166,49 +166,54 @@ pub async fn handle_social_list_page(State(_state): State<Arc<AppState>>) -> Htm
}
}
function renderPosts(posts) {
const list = document.getElementById('postsList');
if (!posts || posts.length === 0) {
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 escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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: ${getPlatformColor(a.platform)};"></div>
<div class="account-info">
<div class="account-name">${a.name}</div>
<div class="account-handle">@${a.handle}</div>
</div>
<div class="account-status ${a.connected ? 'connected' : 'disconnected'}"></div>
function renderPosts(posts) {
const list = document.getElementById('postsList');
if (!posts || posts.length === 0) {
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-${escapeHtml(p.platform || 'twitter')}">${escapeHtml(getPlatformIcon(p.platform))}</div>
<span>${escapeHtml(p.platform || 'Twitter')}</span>
</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) {
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);
}
}
function renderVideos(projects) {
const grid = document.getElementById('videoGrid');
if (!projects || projects.length === 0) {
grid.innerHTML = '<div class="empty-state"><h3>No videos yet</h3><p>Upload your first video to get started</p></div>';
return;
}
grid.innerHTML = projects.map(p => `
<div class="video-card" onclick="window.location='/suite/video/${p.id}'">
<div class="video-thumbnail">
<img src="${p.thumbnail_url || '/assets/video-placeholder.png'}" alt="${p.name}">
<span class="video-duration">${formatDuration(p.duration_ms / 1000)}</span>
</div>
<div class="video-info">
<div class="video-title">${p.name}</div>
<div class="video-meta">${p.status} ${formatDate(p.created_at)}</div>
</div>
</div>
`).join('');
}
function escapeHtml(str) {
if (!str) return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function renderVideos(projects) {
const grid = document.getElementById('videoGrid');
if (!projects || projects.length === 0) {
grid.innerHTML = '<div class="empty-state"><h3>No videos yet</h3><p>Upload your first video to get started</p></div>';
return;
}
grid.innerHTML = projects.map(p => `
<div class="video-card" onclick="window.location='/suite/video/${escapeHtml(p.id)}'">
<div class="video-thumbnail">
<img src="${escapeHtml(p.thumbnail_url || '/assets/video-placeholder.png')}" alt="${escapeHtml(p.name)}">
<span class="video-duration">${escapeHtml(formatDuration(p.duration_ms / 1000))}</span>
</div>
<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) {
if (!seconds) return '0:00';
const mins = Math.floor(seconds / 60);

View file

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

View file

@ -242,8 +242,15 @@ function hideEntityCard() {
}
function fetchEntityDetails(type, name) {
return fetch("/api/search/entity?type=" + encodeURIComponent(type) + "&name=" + encodeURIComponent(name))
.then(function (r) { return r.json(); })
.then(function (data) { return data && data.details ? data.details : "No additional details available"; })
.catch(function () { return "Unable to load details"; });
return fetch("/api/search/entity?type=" + encodeURIComponent(type) + "&name=" + encodeURIComponent(name))
.then(function (r) { return r.json(); })
.then(function (data) { return data && data.details ? data.details : "No additional details available"; })
.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) {
// Remove <think>...</think> and anything in between
return content.replace(/<think>[\s\S]*?(?:<\/think>|$)/gi, "").trim();
// R6: Remove <think>...</think> but do NOT trim — preserves leading '<' in HTML chunks
return content.replace(/<think>[\s\S]*?(?:<\/think>|$)/gi, "");
}
function stripMarkdownBlocks(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);
if (htmlMatch) return htmlMatch[1].trim();
if (htmlMatch) return htmlMatch[1];
return cleanContent;
}
@ -54,15 +56,19 @@ 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>";
if (sender === "user") {
var processedContent = renderMentionInMessage(escapeHtml(content));
div.innerHTML = '<div class="message-content user-message">' + processedContent + "</div>";
} else {
var cleanContent = stripMarkdownBlocks(content);
var hasHtmlTags = /<\/?[a-zA-Z][^>]*>|<!--|-->/i.test(cleanContent);
var parsed;
if (msgId) {
parsed = '<div class="streaming-loading"><span class="loading-dots">...</span></div>';
if (hasHtmlTags) {
// 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 {
parsed = escapeHtml(cleanContent);
}
@ -98,12 +104,11 @@ function updateStreaming(content) {
var isHtml = /<\/?[a-zA-Z][^>]*>|<!--|-->/i.test(cleanContent);
if (isHtml) {
if (!el.querySelector(".streaming-loading")) {
var loader = document.createElement("div");
loader.className = "streaming-loading";
loader.innerHTML = '<span class="loading-dots">...</span>';
msgContent.appendChild(loader);
}
// F3+F5: Render HTML chunks directly via innerHTML += (never textContent/innerText)
// For streaming HTML, set full accumulated content — partial tags won't render, but completed ones will
var parsed = renderMentionInMessage(cleanContent);
msgContent.innerHTML = parsed;
if (!ChatState.isUserScrolling) scrollToBottom(true);
} else {
var parsed = typeof marked !== "undefined" && marked.parse
? marked.parse(cleanContent)

View file

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