botui/ui/suite/chat/chat.html
Rodrigo Rodriguez (Pragmatismo) 91e9701c3e
All checks were successful
BotUI CI/CD / build (push) Successful in 59s
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
2026-04-14 11:58:18 -03:00

1598 lines
61 KiB
HTML

<link rel="stylesheet" href="/suite/chat/chat.css?v=9" />
<link rel="stylesheet" href="/suite/css/markdown-message.css" />
<script src="/suite/js/vendor/marked.min.js"></script>
<div class="chat-layout" id="chat-app">
<!-- Connection Status -->
<div
class="connection-status connecting"
id="connectionStatus"
style="display: none"
>
<span class="connection-status-dot"></span>
<span class="connection-text">Connecting...</span>
</div>
<main id="messages"></main>
<footer>
<div class="switchers-container" id="switchers">
<div class="switchers-label">Formato:</div>
<div class="switchers-chips" id="switchersChips">
<!-- Switcher chips will be rendered here -->
</div>
</div>
<div class="suggestions-container" id="suggestions"></div>
<div class="mention-dropdown" id="mentionDropdown">
<div class="mention-header">
<span class="mention-title" data-i18n="chat-mention-title"
>Reference Entity</span
>
</div>
<div class="mention-results" id="mentionResults"></div>
</div>
<form class="input-container" id="chatForm">
<input
name="content"
id="messageInput"
type="text"
placeholder="Message... (type @ to mention)"
data-i18n-placeholder="chat-placeholder"
autofocus
autocomplete="off"
/>
<button
type="submit"
id="sendBtn"
title="Send"
data-i18n-title="chat-send"
>
</button>
</form>
</footer>
<button
class="scroll-to-bottom"
id="scrollToBottom"
title="Scroll to bottom"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
</div>
<div class="entity-card-tooltip" id="entityCardTooltip">
<div class="entity-card-header">
<span class="entity-card-type"></span>
<span class="entity-card-status"></span>
</div>
<div class="entity-card-title"></div>
<div class="entity-card-details"></div>
<div class="entity-card-actions">
<button
class="entity-card-btn"
data-action="view"
data-i18n="action-view"
>
View
</button>
</div>
</div>
<script>
(function () {
"use strict";
function notify(message, type) {
type = type || "info";
if (window.GBAlerts) {
if (type === "success") {
window.GBAlerts.info("Chat", message);
} else if (type === "error") {
window.GBAlerts.warning("Chat", message);
} else {
window.GBAlerts.info("Chat", message);
}
}
}
var WS_BASE_URL =
window.location.protocol === "https:" ? "wss://" : "ws://";
var WS_URL = WS_BASE_URL + window.location.host + "/ws";
var MessageType = {
EXTERNAL: 0,
USER: 1,
BOT_RESPONSE: 2,
CONTINUE: 3,
SUGGESTION: 4,
CONTEXT_CHANGE: 5,
};
var EntityTypes = {
lead: { icon: "👤", color: "#4CAF50", label: "Lead", route: "crm" },
opportunity: {
icon: "💰",
color: "#FF9800",
label: "Opportunity",
route: "crm",
},
account: {
icon: "🏢",
color: "#2196F3",
label: "Account",
route: "crm",
},
contact: {
icon: "📇",
color: "#9C27B0",
label: "Contact",
route: "crm",
},
invoice: {
icon: "📄",
color: "#F44336",
label: "Invoice",
route: "billing",
},
quote: {
icon: "📋",
color: "#607D8B",
label: "Quote",
route: "billing",
},
case: {
icon: "🎫",
color: "#E91E63",
label: "Case",
route: "tickets",
},
product: {
icon: "📦",
color: "#795548",
label: "Product",
route: "products",
},
service: {
icon: "⚙️",
color: "#00BCD4",
label: "Service",
route: "products",
},
};
var ws = null;
var currentSessionId = null;
var currentUserId = null;
var currentBotId = "default";
var currentBotName = "default";
var isStreaming = false;
var streamingMessageId = null;
var currentStreamingContent = "";
var reconnectAttempts = 0;
var maxReconnectAttempts = 5;
var disconnectNotified = false;
var isUserScrolling = false;
var mentionState = {
active: false,
query: "",
startPos: -1,
selectedIndex: 0,
results: [],
};
function escapeHtml(text) {
var div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
// Scroll handling
function scrollToBottom(animate) {
var messages = document.getElementById("messages");
if (messages) {
if (animate) {
messages.scrollTo({
top: messages.scrollHeight,
behavior: "smooth",
});
} else {
messages.scrollTop = messages.scrollHeight;
}
}
}
function updateScrollButton() {
var messages = document.getElementById("messages");
var scrollBtn = document.getElementById("scrollToBottom");
if (!messages || !scrollBtn) return;
var isNearBottom =
messages.scrollHeight -
messages.scrollTop -
messages.clientHeight <
100;
if (isNearBottom) {
scrollBtn.classList.remove("visible");
} else {
scrollBtn.classList.add("visible");
}
}
// Scroll-to-bottom button click
var scrollBtn = document.getElementById("scrollToBottom");
if (scrollBtn) {
scrollBtn.addEventListener("click", function () {
scrollToBottom(true);
isUserScrolling = false;
});
}
// Detect user scrolling
var messagesEl = document.getElementById("messages");
if (messagesEl) {
messagesEl.addEventListener("scroll", function () {
isUserScrolling = true;
updateScrollButton();
// Reset isUserScrolling after 2 seconds of no scrolling
clearTimeout(messagesEl.scrollTimeout);
messagesEl.scrollTimeout = setTimeout(function () {
isUserScrolling = false;
}, 2000);
});
}
function renderMentionInMessage(content) {
return content.replace(
/@(\w+):([^\s]+)/g,
function (match, type, name) {
var entityType = EntityTypes[type.toLowerCase()];
if (entityType) {
return (
'<span class="mention-tag" data-type="' +
type +
'" data-name="' +
escapeHtml(name) +
'">' +
'<span class="mention-icon">' +
entityType.icon +
"</span>" +
'<span class="mention-text">@' +
type +
":" +
escapeHtml(name) +
"</span>" +
"</span>"
);
}
return match;
},
);
}
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;
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);
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) {
scrollToBottom(true);
} else {
updateScrollButton();
}
setupMentionClickHandlers(div);
}
function setupMentionClickHandlers(container) {
var mentions = container.querySelectorAll(".mention-tag");
mentions.forEach(function (mention) {
mention.addEventListener("click", function (e) {
e.preventDefault();
var type = this.getAttribute("data-type");
var name = this.getAttribute("data-name");
navigateToEntity(type, name);
});
mention.addEventListener("mouseenter", function (e) {
var type = this.getAttribute("data-type");
var name = this.getAttribute("data-name");
showEntityCard(type, name, e.target);
});
mention.addEventListener("mouseleave", function () {
hideEntityCard();
});
});
}
function navigateToEntity(type, name) {
var entityType = EntityTypes[type.toLowerCase()];
if (entityType) {
var route = entityType.route;
window.location.hash = "#" + route;
var htmxLink = document.querySelector(
'a[data-section="' + route + '"]',
);
if (htmxLink) {
htmx.trigger(htmxLink, "click");
}
}
}
function showEntityCard(type, name, targetEl) {
var card = document.getElementById("entityCardTooltip");
var entityType = EntityTypes[type.toLowerCase()];
if (!card || !entityType) return;
card.querySelector(".entity-card-type").textContent =
entityType.label;
card.querySelector(".entity-card-type").style.background =
entityType.color;
card.querySelector(".entity-card-title").textContent =
entityType.icon + " " + name;
card.querySelector(".entity-card-status").textContent = "";
card.querySelector(".entity-card-details").textContent =
"Loading...";
var rect = targetEl.getBoundingClientRect();
card.style.left = rect.left + "px";
card.style.top = rect.top - card.offsetHeight - 8 + "px";
card.classList.add("visible");
fetchEntityDetails(type, name).then(function (details) {
if (card.classList.contains("visible")) {
card.querySelector(".entity-card-details").innerHTML =
details;
}
});
}
function hideEntityCard() {
var card = document.getElementById("entityCardTooltip");
if (card) {
card.classList.remove("visible");
}
}
function fetchEntityDetails(type, name) {
return fetch(
"/api/search/entity?type=" +
encodeURIComponent(type) +
"&name=" +
encodeURIComponent(name),
)
.then(function (r) {
return r.json();
})
.then(function (data) {
if (data && data.details) {
return data.details;
}
return "No additional details available";
})
.catch(function () {
return "Unable to load details";
});
}
function showMentionDropdown() {
var dropdown = document.getElementById("mentionDropdown");
if (dropdown) {
dropdown.classList.add("visible");
}
}
function hideMentionDropdown() {
var dropdown = document.getElementById("mentionDropdown");
if (dropdown) {
dropdown.classList.remove("visible");
}
mentionState.active = false;
mentionState.query = "";
mentionState.startPos = -1;
mentionState.selectedIndex = 0;
mentionState.results = [];
}
function searchEntities(query) {
if (!query || query.length < 1) {
var defaultResults = Object.keys(EntityTypes).map(
function (type) {
return {
type: type,
name: EntityTypes[type].label,
icon: EntityTypes[type].icon,
isTypeHint: true,
};
},
);
renderMentionResults(defaultResults);
return;
}
var colonIndex = query.indexOf(":");
if (colonIndex > 0) {
var entityType = query.substring(0, colonIndex).toLowerCase();
var searchTerm = query.substring(colonIndex + 1);
if (EntityTypes[entityType]) {
fetchEntitiesOfType(entityType, searchTerm);
return;
}
}
var filteredTypes = Object.keys(EntityTypes)
.filter(function (type) {
return (
type.toLowerCase().indexOf(query.toLowerCase()) === 0 ||
EntityTypes[type].label
.toLowerCase()
.indexOf(query.toLowerCase()) === 0
);
})
.map(function (type) {
return {
type: type,
name: EntityTypes[type].label,
icon: EntityTypes[type].icon,
isTypeHint: true,
};
});
renderMentionResults(filteredTypes);
}
function fetchEntitiesOfType(type, searchTerm) {
fetch(
"/api/search/entities?type=" +
encodeURIComponent(type) +
"&q=" +
encodeURIComponent(searchTerm || ""),
)
.then(function (r) {
return r.json();
})
.then(function (data) {
var results = (data.results || []).map(function (item) {
return {
type: type,
name: item.name || item.title || item.number,
id: item.id,
icon: EntityTypes[type].icon,
subtitle: item.subtitle || item.status || "",
isTypeHint: false,
};
});
if (results.length === 0) {
results = [
{
type: type,
name: "No results for '" + searchTerm + "'",
icon: "❌",
isTypeHint: false,
disabled: true,
},
];
}
renderMentionResults(results);
})
.catch(function () {
renderMentionResults([
{
type: type,
name: "Search unavailable",
icon: "⚠️",
isTypeHint: false,
disabled: true,
},
]);
});
}
function renderMentionResults(results) {
var container = document.getElementById("mentionResults");
if (!container) return;
mentionState.results = results;
mentionState.selectedIndex = 0;
container.innerHTML = results
.map(function (item, index) {
var classes = "mention-item";
if (index === mentionState.selectedIndex)
classes += " selected";
if (item.disabled) classes += " disabled";
var subtitle = item.subtitle
? '<span class="mention-item-subtitle">' +
escapeHtml(item.subtitle) +
"</span>"
: "";
var hint = item.isTypeHint
? '<span class="mention-item-hint">Type : to search</span>'
: "";
return (
'<div class="' +
classes +
'" data-index="' +
index +
'" data-type="' +
item.type +
'" data-name="' +
escapeHtml(item.name) +
'" data-is-type="' +
item.isTypeHint +
'">' +
'<span class="mention-item-icon">' +
item.icon +
"</span>" +
'<span class="mention-item-content">' +
'<span class="mention-item-name">' +
escapeHtml(item.name) +
"</span>" +
subtitle +
hint +
"</span>" +
"</div>"
);
})
.join("");
container
.querySelectorAll(".mention-item:not(.disabled)")
.forEach(function (item) {
item.addEventListener("click", function () {
selectMentionItem(
parseInt(this.getAttribute("data-index")),
);
});
});
}
function selectMentionItem(index) {
var item = mentionState.results[index];
if (!item || item.disabled) return;
var input = document.getElementById("messageInput");
if (!input) return;
var value = input.value;
var beforeMention = value.substring(0, mentionState.startPos);
var afterMention = value.substring(input.selectionStart);
var insertText;
if (item.isTypeHint) {
insertText = "@" + item.type + ":";
mentionState.query = item.type + ":";
mentionState.startPos = beforeMention.length;
input.value = beforeMention + insertText + afterMention;
input.setSelectionRange(
beforeMention.length + insertText.length,
beforeMention.length + insertText.length,
);
searchEntities(mentionState.query);
return;
} else {
insertText = "@" + item.type + ":" + item.name + " ";
input.value = beforeMention + insertText + afterMention;
input.setSelectionRange(
beforeMention.length + insertText.length,
beforeMention.length + insertText.length,
);
hideMentionDropdown();
}
input.focus();
}
function updateMentionSelection(direction) {
var enabledResults = mentionState.results.filter(function (r) {
return !r.disabled;
});
if (enabledResults.length === 0) return;
var currentEnabled = 0;
for (var i = 0; i < mentionState.selectedIndex; i++) {
if (!mentionState.results[i].disabled) currentEnabled++;
}
currentEnabled += direction;
if (currentEnabled < 0) currentEnabled = enabledResults.length - 1;
if (currentEnabled >= enabledResults.length) currentEnabled = 0;
var newIndex = 0;
var count = 0;
for (var j = 0; j < mentionState.results.length; j++) {
if (!mentionState.results[j].disabled) {
if (count === currentEnabled) {
newIndex = j;
break;
}
count++;
}
}
mentionState.selectedIndex = newIndex;
var items = document.querySelectorAll(
"#mentionResults .mention-item",
);
items.forEach(function (item, idx) {
item.classList.toggle("selected", idx === newIndex);
});
var selectedItem = document.querySelector(
"#mentionResults .mention-item.selected",
);
if (selectedItem) {
selectedItem.scrollIntoView({ block: "nearest" });
}
}
function handleMentionInput(e) {
var input = e.target;
var value = input.value;
var cursorPos = input.selectionStart;
var textBeforeCursor = value.substring(0, cursorPos);
var atIndex = textBeforeCursor.lastIndexOf("@");
if (atIndex >= 0) {
var charBeforeAt =
atIndex > 0 ? textBeforeCursor[atIndex - 1] : " ";
if (charBeforeAt === " " || atIndex === 0) {
var query = textBeforeCursor.substring(atIndex + 1);
if (!query.includes(" ")) {
mentionState.active = true;
mentionState.startPos = atIndex;
mentionState.query = query;
showMentionDropdown();
searchEntities(query);
return;
}
}
}
if (mentionState.active) {
hideMentionDropdown();
}
}
function handleMentionKeydown(e) {
if (!mentionState.active) return false;
if (e.key === "ArrowDown") {
e.preventDefault();
updateMentionSelection(1);
return true;
}
if (e.key === "ArrowUp") {
e.preventDefault();
updateMentionSelection(-1);
return true;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
selectMentionItem(mentionState.selectedIndex);
return true;
}
if (e.key === "Escape") {
e.preventDefault();
hideMentionDropdown();
return true;
}
return false;
}
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) {
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 = "";
}
function processMessage(data) {
// Handle thinking indicator from backend
if (data.type === "thinking") {
showThinkingIndicator();
return;
}
if (data.type === "thinking_clear") {
hideThinkingIndicator();
return;
}
if (data.is_complete) {
if (isStreaming) {
finalizeStreaming();
} else {
if (data.content && data.content.trim() !== "") {
addMessage("bot", data.content);
}
}
isStreaming = false;
// Render suggestions when message is complete
if (
data.suggestions &&
Array.isArray(data.suggestions) &&
data.suggestions.length > 0
) {
renderSuggestions(data.suggestions);
}
} else {
if (!isStreaming) {
isStreaming = true;
streamingMessageId = "streaming-" + Date.now();
currentStreamingContent = data.content || "";
addMessage(
"bot",
currentStreamingContent,
streamingMessageId,
);
} else {
currentStreamingContent += data.content || "";
updateStreaming(currentStreamingContent);
}
}
}
// Thinking indicator (shown while LLM is in reasoning mode)
var thinkingIndicator = null;
function showThinkingIndicator() {
if (thinkingIndicator) return; // Already showing
var messages = document.getElementById("messages");
if (!messages) return;
thinkingIndicator = document.createElement("div");
thinkingIndicator.id = "thinking-indicator";
thinkingIndicator.className = "message bot";
thinkingIndicator.innerHTML =
'<div class="message-content bot-message">' +
'<span class="thinking-text">🤔 Pensando...</span>' +
"</div>";
messages.appendChild(thinkingIndicator);
scrollToBottom(true);
}
function hideThinkingIndicator() {
if (!thinkingIndicator) return;
thinkingIndicator.remove();
thinkingIndicator = null;
}
// Track last rendered suggestions to prevent duplicates
var lastRenderedSuggestions = null;
// Render suggestion buttons
function renderSuggestions(suggestions) {
var suggestionsEl = document.getElementById("suggestions");
if (!suggestionsEl) {
console.warn("Suggestions container not found");
return;
}
// Prevent duplicate rendering - compare with last rendered suggestions
var currentSuggestionKeys = suggestions
.map(function (s) {
return s.text || "";
})
.join("|");
if (lastRenderedSuggestions === currentSuggestionKeys) {
console.log("Skipping duplicate suggestions render");
return;
}
lastRenderedSuggestions = currentSuggestionKeys;
// Clear existing suggestions
suggestionsEl.innerHTML = "";
console.log("Rendering " + suggestions.length + " suggestions");
// Add or remove CSS class based on whether suggestions are displayed
// Note: CSS uses .has-bot-suggestions on the suggestions-container
if (suggestions.length > 0) {
suggestionsEl.classList.add("has-bot-suggestions");
} else {
suggestionsEl.classList.remove("has-bot-suggestions");
}
suggestions.forEach(function (suggestion) {
var chip = document.createElement("button");
chip.className = "suggestion-chip";
chip.textContent = suggestion.text || "Suggestion";
// Use window.sendMessage which is already exposed
chip.onclick = (function (sugg) {
return function () {
console.log("Suggestion clicked:", sugg);
// Check if there's an action to parse
if (sugg.action) {
try {
var action =
typeof sugg.action === "string"
? JSON.parse(sugg.action)
: sugg.action;
console.log("Parsed action:", action);
if (action.type === "invoke_tool") {
// Check if tool needs parameters
if (action.prompt_for_params) {
// Need parameters, send text as message
window.sendMessage(sugg.text);
} else {
// No params needed - invoke tool directly
window.sendMessage(
"/tool " + action.tool,
);
}
} else if (action.type === "send_message") {
window.sendMessage(
action.message || sugg.text,
);
} else if (action.type === "select_context") {
window.sendMessage(action.context);
} else {
window.sendMessage(sugg.text);
}
} catch (e) {
console.error(
"Failed to parse action:",
e,
"falling back to text",
);
window.sendMessage(sugg.text);
}
} else {
// No action, just send the text
window.sendMessage(sugg.text);
}
};
})(suggestion);
suggestionsEl.appendChild(chip);
});
}
function sendMessage(messageContent) {
var input = document.getElementById("messageInput");
if (!input) {
console.error("Chat input not found");
return;
}
// If no messageContent provided, read from input
var content = messageContent || input.value.trim();
if (!content) {
return;
}
// If called from input field (no messageContent provided), clear input
if (!messageContent) {
hideMentionDropdown();
input.value = "";
input.focus();
}
// Prepend active switcher prompts
var enhancedContent = content;
if (activeSwitchers.size > 0) {
// Get prompts for active switchers from backend
var activePrompts = [];
activeSwitchers.forEach(function(id) {
// Backend has predefined prompts for each ID
// For now, use a simple mapping that will be enhanced by backend
var promptMap = {
'tables': 'REGRAS DE FORMATO: SEMPRE retorne suas respostas em formato de tabela HTML usando <table>, <thead>, <tbody>, <tr>, <th>, <td>. Cada dado deve ser uma célula. Use cabeçalhos claros na primeira linha. Se houver dados numéricos, alinhe à direita. Se houver texto, alinhe à esquerda. Use cores sutis em linhas alternadas (nth-child). NÃO use markdown tables, use HTML puro.',
'infographic': 'REGRAS DE FORMATO: Crie representações visuais HTML usando SVG, progress bars, stat cards, e elementos gráficos. Use elementos como: <svg> para gráficos, <div style="width:X%;background:color"> para barras de progresso, ícones emoji, badges coloridos. Organize informações visualmente com grids, flexbox, e espaçamento. Inclua legendas e rótulos visuais claros.',
'cards': 'REGRAS DE FORMATO: Retorne informações em formato de cards HTML. Cada card deve ter: <div class="card" style="border:1px solid #ddd;border-radius:8px;padding:16px;margin:8px;box-shadow:0 2px 4px rgba(0,0,0,0.1)">. Dentro do card use: título em <h3> ou <strong>, subtítulo em <p> style="color:#666", ícone emoji ou ícone SVG no topo, badges de status. Organize cards em grid usando display:grid ou flex-wrap.',
'list': 'REGRAS DE FORMATO: Use apenas listas HTML: <ul> para bullets e <ol> para números numerados. Cada item em <li>. Use sublistas aninhadas quando apropriado. NÃO use parágrafos de texto, converta tudo em itens de lista. Adicione ícones emoji no início de cada <li> quando possível. Use classes CSS para estilização: .list-item, .sub-list.',
'comparison': 'REGRAS DE FORMATO: Crie comparações lado a lado em HTML. Use grid de 2 colunas: <div style="display:grid;grid-template-columns:1fr 1fr;gap:20px">. Cada lado em uma <div class="comparison-side"> com borda colorida distinta. Use headers claros para cada lado. Adicione seção de "Diferenças Chave" com bullet points. Use cores contrastantes para cada lado (ex: azul vs laranja). Inclua tabela de comparação resumida no final.',
'timeline': 'REGRAS DE FORMATO: Organize eventos cronologicamente em formato de timeline HTML. Use <div class="timeline"> com border-left vertical. Cada evento em <div class="timeline-item"> com: data em <span class="timeline-date" style="font-weight:bold;color:#666">, título em <h3>, descrição em <p>. Adicione círculo indicador na timeline line. Ordene do mais antigo para o mais recente. Use espaçamento claro entre eventos.',
'markdown': 'REGRAS DE FORMATO: Use exclusivamente formato Markdown padrão. Sintaxe permitida: **negrito**, *itálico*, `inline code`, ```bloco de código```, # cabeçalhos, - bullets, 1. números, [links](url), ![alt](url), | tabela | markdown |. NÃO use HTML tags exceto para blocos de código. Siga estritamente a sintaxe CommonMark.',
'chart': 'REGRAS DE FORMATO: Crie gráficos e diagramas em HTML SVG. Use elementos SVG: <svg width="X" height="Y">, <line> para gráficos de linha, <rect> para gráficos de barra, <circle> para gráficos de pizza, <path> para gráficos de área. Inclua eixos com labels, grid lines, legendas. Use cores distintas para cada série de dados (ex: vermelho, azul, verde). Adicione tooltips com valores ao hover. Se o usuário pedir gráfico de pizza com "pizza vermelha", use fill="#FF0000" no SVG.'
};
activePrompts.push(promptMap[id] || "");
});
// Inject prompts before user message
if (activePrompts.length > 0) {
enhancedContent = activePrompts.join('\n\n') + '\n\n---\n\n' + content;
}
}
addMessage("user", content);
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
bot_id: currentBotId,
user_id: currentUserId,
session_id: currentSessionId,
channel: "web",
content: enhancedContent,
message_type: MessageType.USER,
timestamp: new Date().toISOString(),
}),
);
} else {
notify("Not connected to server. Message not sent.", "warning");
}
}
window.sendMessage = sendMessage;
// Expose session info for suggestion clicks
window.getChatSessionInfo = function () {
return {
ws: ws,
currentBotId: currentBotId,
currentUserId: currentUserId,
currentSessionId: currentSessionId,
currentBotName: currentBotName,
};
};
function connectWebSocket() {
if (ws) {
ws.close();
}
updateConnectionStatus("connecting");
// Always get the bot name from window.__INITIAL_BOT_NAME__ to ensure it's correct on reconnection
var botNameForWs =
window.__INITIAL_BOT_NAME__ || currentBotName || "default";
var url =
WS_URL +
"?session_id=" +
currentSessionId +
"&user_id=" +
currentUserId +
"&bot_name=" +
botNameForWs;
console.log("Connecting WebSocket to:", url);
ws = new WebSocket(url);
// Add connection timeout to detect silent failures
var connectionTimeout = setTimeout(function () {
if (ws.readyState !== WebSocket.OPEN) {
console.error("WebSocket connection timeout");
ws.close();
}
}, 5000);
ws.onopen = function () {
clearTimeout(connectionTimeout);
console.log("WebSocket connected to:", url);
disconnectNotified = false;
updateConnectionStatus("connected");
// Send empty message to trigger start.bas and load suggestions
ws.send(
JSON.stringify({
bot_id: currentBotId,
user_id: currentUserId,
session_id: currentSessionId,
channel: "web",
content: "",
message_type: MessageType.USER,
timestamp: new Date().toISOString(),
}),
);
};
ws.onmessage = function (event) {
try {
var data = JSON.parse(event.data);
console.log("Chat WebSocket received:", data);
// Ignore connection confirmation
if (data.type === "connected") {
reconnectAttempts = 0;
return;
}
// Process system events (theme changes, etc)
if (data.event) {
if (data.event === "change_theme") {
applyThemeData(data.data || {});
}
return;
}
// Check if content contains theme change events (JSON strings)
if (data.content && typeof data.content === "string") {
try {
var contentObj = JSON.parse(data.content);
if (contentObj.event === "change_theme") {
applyThemeData(contentObj.data || {});
return;
}
} catch (e) {
// Content is not JSON, continue processing
}
}
// Only process bot responses
if (data.message_type === MessageType.BOT_RESPONSE) {
console.log("Processing bot response:", data);
processMessage(data);
} else {
console.log("Ignoring non-bot message:", data);
}
} catch (e) {
console.error("WS message error:", e);
}
};
ws.onclose = function (event) {
clearTimeout(connectionTimeout);
console.log("WebSocket closed:", event.code, event.reason);
updateConnectionStatus("disconnected");
if (!disconnectNotified) {
notify("Disconnected from chat server", "error");
disconnectNotified = true;
}
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
updateConnectionStatus("connecting");
setTimeout(connectWebSocket, 1000 * reconnectAttempts);
} else {
console.error(
"Max reconnection attempts reached. Stopping reconnection.",
);
notify(
"Could not reconnect to chat server after multiple attempts",
"error",
);
}
};
ws.onerror = function (e) {
console.error("WebSocket error:", e);
updateConnectionStatus("disconnected");
};
}
// Apply theme data from WebSocket events
function getContrastYIQ(hexcolor) {
if (!hexcolor) return "#ffffff";
// Handle named colors and variables by letting the browser resolve them
var temp = document.createElement("div");
temp.style.color = hexcolor;
temp.style.display = "none";
document.body.appendChild(temp);
var style = window.getComputedStyle(temp).color;
document.body.removeChild(temp);
var rgb = style.match(/\d+/g);
if (!rgb || rgb.length < 3) return "#ffffff";
var r = parseInt(rgb[0]);
var g = parseInt(rgb[1]);
var b = parseInt(rgb[2]);
var yiq = (r * 299 + g * 587 + b * 114) / 1000;
return yiq >= 128 ? "#000000" : "#ffffff";
}
function applyThemeData(themeData) {
console.log("Applying theme data:", themeData);
var color1 = themeData.color1 || themeData.data?.color1 || "black";
var color2 = themeData.color2 || themeData.data?.color2 || "white";
var logo = themeData.logo_url || themeData.data?.logo_url || "";
var title =
themeData.title ||
themeData.data?.title ||
window.__INITIAL_BOT_NAME__ ||
"Chat";
// Set CSS variables for colors on document element
document.documentElement.style.setProperty("--chat-color1", color1);
document.documentElement.style.setProperty("--chat-color2", color2);
document.documentElement.style.setProperty(
"--suggestion-color",
color1,
);
document.documentElement.style.setProperty(
"--suggestion-bg",
color2,
);
// Also set on root for better cascading
document.documentElement.style.setProperty("--color1", color1);
document.documentElement.style.setProperty("--color2", color2);
// Update suggestion button colors to match theme
document.documentElement.style.setProperty("--primary", color1);
document.documentElement.style.setProperty("--accent", color1);
document.documentElement.style.setProperty(
"--chat-fg1",
getContrastYIQ(color1),
);
document.documentElement.style.setProperty(
"--chat-fg2",
getContrastYIQ(color2),
);
console.log("Theme applied:", {
color1: color1,
color2: color2,
logo: logo,
title: title,
});
}
// Load bot config and apply colors/logo
function loadBotConfig() {
var botName = window.__INITIAL_BOT_NAME__ || "default";
fetch("/api/bot/config?bot_name=" + encodeURIComponent(botName))
.then(function (response) {
return response.json();
})
.then(function (config) {
if (!config) return;
// Get the theme manager's theme for this bot to check if user selected a different theme
var botId = botName.toLowerCase();
var botThemeKey = "gb-theme-" + botId;
var botTheme = window.ThemeManager
? // Get bot-specific theme from theme manager's mapping
(window.ThemeManager.getAvailableThemes &&
window.ThemeManager.getAvailableThemes().find(
(t) => t.id === botId,
)) ||
// Fallback to localStorage
localStorage.getItem(botThemeKey)
: localStorage.getItem(botThemeKey);
// Check if bot config has a theme-base setting
var configThemeBase =
config.theme_base || config["theme-base"] || "light";
// Only use bot config colors if:
// 1. No theme has been explicitly selected by user (localStorage empty or default)
// 2. AND the bot config's theme-base matches the current theme
var localStorageTheme = localStorage.getItem(botThemeKey);
var useBotConfigColors =
!localStorageTheme ||
localStorageTheme === "default" ||
localStorageTheme === configThemeBase;
// Apply colors from config (API returns snake_case)
var color1 =
config.theme_color1 ||
config["theme-color1"] ||
config["Theme Color"] ||
"#3b82f6";
var color2 =
config.theme_color2 ||
config["theme-color2"] ||
"#f5deb3";
var title =
config.theme_title || config["theme-title"] || botName;
var logo = config.theme_logo || config["theme-logo"] || "";
// Only set bot config colors if user hasn't selected a different theme
if (useBotConfigColors) {
document.documentElement.setAttribute(
"data-has-bot-colors",
"true",
);
document.documentElement.style.setProperty(
"--chat-color1",
color1,
);
document.documentElement.style.setProperty(
"--chat-color2",
color2,
);
document.documentElement.style.setProperty(
"--suggestion-color",
color1,
);
document.documentElement.style.setProperty(
"--suggestion-bg",
color2,
);
document.documentElement.style.setProperty(
"--color1",
color1,
);
document.documentElement.style.setProperty(
"--color2",
color2,
);
document.documentElement.style.setProperty(
"--primary",
color1,
);
document.documentElement.style.setProperty(
"--accent",
color1,
);
document.documentElement.style.setProperty(
"--chat-fg1",
getContrastYIQ(color1),
);
document.documentElement.style.setProperty(
"--chat-fg2",
getContrastYIQ(color2),
);
console.log("Bot config colors applied:", {
color1: color1,
color2: color2,
});
} else {
console.log(
"Bot config colors skipped - user selected custom theme:",
localStorageTheme,
);
}
// Update logo if provided
if (logo) {
var logoImg = document.querySelector(".logo-icon-img");
if (logoImg) {
logoImg.src = logo;
logoImg.alt = title || botName;
logoImg.style.display = "block";
}
// Hide the SVG logo when image logo is used
var logoSvg = document.querySelector(".logo-icon-svg");
if (logoSvg) {
logoSvg.style.display = "none";
}
}
console.log("Bot config loaded:", {
color1: color1,
color2: color2,
title: title,
logo: logo,
});
})
.catch(function (e) {
console.log("Could not load bot config:", e);
});
}
function initChat() {
// Load bot config first
loadBotConfig();
// Just proceed with chat initialization - no auth check
proceedWithChatInit();
}
function proceedWithChatInit() {
// Render switchers on initialization
renderSwitchers();
var botName = window.__INITIAL_BOT_NAME__ || "default";
var sessionIdKey = "gb_session_" + botName;
// Try to restore session from localStorage
var storedSessionId = localStorage.getItem(sessionIdKey);
var authUrl = "/api/auth?bot_name=" + encodeURIComponent(botName);
if (storedSessionId) {
authUrl += "&session_id=" + encodeURIComponent(storedSessionId);
}
fetch(authUrl)
.then(function (response) {
return response.json();
})
.then(function (auth) {
currentUserId = auth.user_id;
currentSessionId = auth.session_id;
currentBotId = auth.bot_id || "default";
currentBotName = botName;
// Save session ID to localStorage for page refreshes
localStorage.setItem(sessionIdKey, currentSessionId);
console.log("Auth:", {
currentUserId: currentUserId,
currentSessionId: currentSessionId,
currentBotId: currentBotId,
currentBotName: currentBotName,
});
connectWebSocket();
})
.catch(function (e) {
console.error("Auth failed:", e);
// Proceed with anonymous connection
currentUserId = crypto.randomUUID
? crypto.randomUUID()
: Date.now().toString();
currentSessionId = crypto.randomUUID
? crypto.randomUUID()
: Date.now().toString();
currentBotId = botName;
currentBotName = botName;
console.log("Anonymous chat:", {
currentUserId: currentUserId,
currentSessionId: currentSessionId,
currentBotId: currentBotId,
currentBotName: currentBotName,
});
connectWebSocket();
});
}
function updateConnectionStatus(status) {
var statusEl = document.getElementById("connectionStatus");
if (!statusEl) return;
statusEl.className = "connection-status " + status;
var statusText = statusEl.querySelector(".connection-text");
if (statusText) {
switch (status) {
case "connected":
statusText.textContent = "Connected";
statusEl.style.display = "none";
break;
case "disconnected":
statusText.textContent = "Disconnected";
statusEl.style.display = "flex";
break;
case "connecting":
statusText.textContent = "Connecting...";
statusEl.style.display = "flex";
break;
}
}
}
// Switcher Logic - Response Format Modifiers
var activeSwitchers = new Set();
var switcherDefinitions = [
{
id: 'tables',
label: 'Tabelas',
icon: '📊',
color: '#4CAF50'
},
{
id: 'infographic',
label: 'Infográfico',
icon: '📈',
color: '#2196F3'
},
{
id: 'cards',
label: 'Cards',
icon: '🃏',
color: '#FF9800'
},
{
id: 'list',
label: 'Lista',
icon: '📋',
color: '#9C27B0'
},
{
id: 'comparison',
label: 'Comparação',
icon: '⚖️',
color: '#E91E63'
},
{
id: 'timeline',
label: 'Timeline',
icon: '📅',
color: '#00BCD4'
},
{
id: 'markdown',
label: 'Markdown',
icon: '📝',
color: '#607D8B'
},
{
id: 'chart',
label: 'Gráfico',
icon: '📉',
color: '#F44336'
}
];
function renderSwitchers() {
var container = document.getElementById("switcherChips");
if (!container) return;
container.innerHTML = switcherDefinitions.map(function(sw) {
var isActive = activeSwitchers.has(sw.id);
return (
'<div class="switcher-chip' + (isActive ? ' active' : '') + '" ' +
'data-switch-id="' + sw.id + '" ' +
'style="--switcher-color: ' + sw.color + '; ' +
(isActive ? 'color: ' + sw.color + ' background: ' + sw.color + '; ' : '') +
'">' +
'<span class="switcher-chip-icon">' + sw.icon + '</span>' +
'<span>' + sw.label + '</span>' +
'</div>'
);
}).join('');
// Add click handlers
container.querySelectorAll('.switcher-chip').forEach(function(chip) {
chip.addEventListener('click', function() {
toggleSwitcher(this.getAttribute('data-switch-id'));
});
});
}
function toggleSwitcher(switcherId) {
if (activeSwitchers.has(switcherId)) {
activeSwitchers.delete(switcherId);
} else {
activeSwitchers.add(switcherId);
}
renderSwitchers();
}
function setupEventHandlers() {
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 (input) {
input.addEventListener("input", handleMentionInput);
input.onkeydown = function (e) {
if (handleMentionKeydown(e)) {
return;
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
}
if (sendBtn) {
sendBtn.onclick = function (e) {
e.preventDefault();
sendMessage();
};
}
document.addEventListener("click", function (e) {
if (
!e.target.closest("#mentionDropdown") &&
!e.target.closest("#messageInput")
) {
hideMentionDropdown();
}
});
}
setupEventHandlers();
initChat();
console.log("Chat module initialized with @ mentions support");
})();
</script>