botserver/web/html/index.html
Rodrigo Rodriguez (Pragmatismo) 9673f41195 feat: add styled suggestion buttons and move them from messages to footer
Added new CSS classes for suggestion buttons and container, moving them from message content to footer. Removed inline styles in favor of CSS classes for better maintainability. The suggestions now appear at the top of the footer with consistent styling and hover effects.
2025-11-02 11:01:36 -03:00

1930 lines
67 KiB
HTML

<!doctype html>
<html lang="pt-br">
<head>
<meta charset="utf-8" />
<title>General Bots</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.2/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<style>
@import url("https://fonts.googleapis.com/css2?family=Orbitron:wght@400;600;800&family=Inter:wght@400;600&display=swap");
:root {
--dante-glow: rgba(255, 215, 0, 0.8);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Inter", sans-serif;
background: linear-gradient(135deg, #0a0e27 0%, #1a1f3a 100%);
color: #e0e0e0;
overflow: hidden;
}
.sidebar {
position: fixed;
left: -300px;
top: 0;
width: 300px;
height: 100vh;
background: rgba(10, 14, 39, 0.95);
border-right: 1px solid rgba(255, 215, 0, 0.3);
transition: left 0.3s ease;
z-index: 100;
overflow-y: auto;
padding: 20px;
box-shadow: 5px 0 25px rgba(0, 0, 0, 0.5);
}
.sidebar.open {
left: 0;
}
.sidebar-toggle {
position: fixed;
left: 20px;
top: 24px;
opacity: 30%;
z-index: 101;
background: rgba(255, 215, 0, 0.2);
border: 1px solid rgba(255, 215, 0, 0.4);
color: #ffd700;
padding: 10px 15px;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
font-size: 20px;
width: 45px;
height: 45px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.sidebar-toggle:hover {
background: rgba(255, 215, 0, 0.3);
box-shadow: 0 0 20px rgba(255, 215, 0, 0.5);
}
.new-chat {
width: 100%;
padding: 12px;
background: rgba(255, 215, 0, 0.2);
border: 1px solid rgba(255, 215, 0, 0.4);
border-radius: 8px;
color: #ffd700;
cursor: pointer;
margin-top: 60px;
margin-bottom: 15px;
transition: all 0.3s ease;
font-weight: 600;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.new-chat:hover {
background: rgba(255, 215, 0, 0.3);
box-shadow: 0 0 15px rgba(255, 215, 0, 0.4);
}
.voice-toggle {
width: 100%;
padding: 12px;
background: rgba(100, 255, 100, 0.2);
border: 1px solid rgba(100, 255, 100, 0.3);
border-radius: 8px;
color: #90ff90;
cursor: pointer;
margin-bottom: 15px;
transition: all 0.3s ease;
font-weight: 600;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.voice-toggle:hover {
background: rgba(100, 255, 100, 0.3);
}
.voice-toggle.recording {
background: rgba(255, 100, 100, 0.3);
border-color: rgba(255, 100, 100, 0.4);
color: #ff9090;
}
.history-item {
padding: 10px;
margin-bottom: 8px;
background: rgba(255, 255, 255, 0.05);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
word-wrap: break-word;
}
.history-item:hover {
background: rgba(255, 215, 0, 0.2);
border: 1px solid rgba(255, 215, 0, 0.3);
}
.main {
margin-left: 0;
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
transition: margin-left 0.3s ease;
}
.sidebar.open ~ .main {
margin-left: 300px;
width: calc(100% - 300px);
}
header {
background: rgba(10, 14, 39, 0.8);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(255, 215, 0, 0.3);
padding: 20px 40px 20px 80px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.logo {
display: flex;
align-items: center;
gap: 12px;
}
.logo-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #ffd700, #ffed4e);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 800;
color: #0a0e27;
box-shadow: 0 0 20px rgba(255, 215, 0, 0.5);
}
.logo-text {
font-size: 24px;
font-weight: 800;
font-family: "Orbitron", sans-serif;
color: #ffd700;
text-shadow: 0 0 20px var(--dante-glow);
}
.neon-text {
animation: neonPulse 2s ease-in-out infinite;
}
@keyframes neonPulse {
0%,
100% {
text-shadow:
0 0 20px var(--dante-glow),
0 0 40px var(--dante-glow);
}
50% {
text-shadow:
0 0 30px var(--dante-glow),
0 0 60px var(--dante-glow),
0 0 80px rgba(255, 215, 0, 0.6);
}
}
#messages {
flex: 1;
overflow-y: auto;
padding: 40px 20px;
max-width: 900px;
margin: 0 auto;
width: 100%;
position: relative;
}
#emptyState {
text-align: center;
padding-top: 120px;
}
.empty-icon {
width: 80px;
height: 80px;
background: linear-gradient(135deg, #ffd700, #ffed4e);
border-radius: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 48px;
font-weight: 800;
color: #0a0e27;
margin-bottom: 20px;
box-shadow: 0 0 30px rgba(255, 215, 0, 0.6);
}
.empty-title {
font-size: 32px;
font-weight: 800;
font-family: "Orbitron", sans-serif;
color: #ffd700;
margin-bottom: 10px;
text-shadow: 0 0 20px var(--dante-glow);
}
.empty-subtitle {
color: #a0a0ff;
font-size: 16px;
}
.message-container {
margin-bottom: 30px;
opacity: 0;
transform: translateY(20px);
}
.user-message {
display: flex;
justify-content: flex-end;
}
.user-message-content {
background: rgba(255, 215, 0, 0.2);
border: 1px solid rgba(255, 215, 0, 0.4);
border-radius: 18px;
padding: 14px 18px;
max-width: 70%;
color: #ffd700;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.assistant-message {
display: flex;
gap: 12px;
align-items: flex-start;
}
.assistant-avatar {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #ffd700, #ffed4e);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 800;
color: #0a0e27;
flex-shrink: 0;
box-shadow: 0 0 15px rgba(255, 215, 0, 0.5);
}
.assistant-message-content {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 215, 0, 0.2);
border-radius: 18px;
padding: 14px 18px;
flex: 1;
color: #e0e0e0;
line-height: 1.6;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.thinking-indicator {
display: flex;
gap: 12px;
align-items: center;
padding: 14px 18px;
color: #ffd700;
}
.typing-dots {
display: flex;
gap: 6px;
}
.typing-dot {
width: 8px;
height: 8px;
background: #ffd700;
border-radius: 50%;
animation: bounce 1.4s infinite ease-in-out;
box-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
}
.typing-dot:nth-child(1) {
animation-delay: -0.32s;
}
.typing-dot:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes bounce {
0%,
80%,
100% {
transform: scale(0);
}
40% {
transform: scale(1);
}
}
footer {
background: rgba(10, 14, 39, 0.8);
backdrop-filter: blur(10px);
border-top: 1px solid rgba(255, 215, 0, 0.3);
padding: 20px 40px;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
position: relative;
}
.suggestions-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
justify-content: center;
}
.suggestion-button {
margin: 0 5px;
padding: 6px 10px;
background-color: #ffd700;
color: #0a0e27;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
}
.suggestion-button:hover {
transform: scale(1.05);
box-shadow: 0 0 8px rgba(255, 215, 0, 0.6);
}
.input-container {
display: flex;
gap: 12px;
max-width: 900px;
margin: 0 auto;
}
#messageInput {
flex: 1;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 215, 0, 0.2);
border-radius: 12px;
padding: 14px 18px;
color: #e0e0e0;
font-size: 15px;
outline: none;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
#messageInput:focus {
border-color: rgba(255, 215, 0, 0.5);
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 0 15px rgba(255, 215, 0, 0.3);
}
#messageInput::placeholder {
color: rgba(255, 215, 0, 0.4);
}
#sendBtn {
background: linear-gradient(135deg, #ffd700, #ffed4e);
border: none;
border-radius: 12px;
padding: 14px 28px;
color: #0a0e27;
font-weight: 700;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.4);
}
#sendBtn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(255, 215, 0, 0.6);
}
#newChatBtn {
background: rgba(255, 215, 0, 0.2);
border: 1px solid rgba(255, 215, 0, 0.4);
border-radius: 10px;
padding: 10px 20px;
color: #ffd700;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
#newChatBtn:hover {
background: rgba(255, 215, 0, 0.3);
box-shadow: 0 0 15px rgba(255, 215, 0, 0.4);
}
.voice-status {
background: rgba(100, 255, 100, 0.2);
border: 1px solid rgba(100, 255, 100, 0.3);
padding: 12px 20px;
text-align: center;
color: #90ff90;
font-weight: 600;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.warning-message {
background: rgba(255, 200, 0, 0.2);
border: 1px solid rgba(255, 200, 0, 0.3);
border-radius: 12px;
padding: 14px 18px;
margin-bottom: 20px;
color: #ffd700;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
/* Updated Connection Status - Small Flashing Circle */
.connection-status {
position: fixed;
top: 15px;
right: 15px;
width: 16px;
height: 16px;
border-radius: 50%;
z-index: 1000;
transition: all 0.3s ease;
box-shadow: 0 0 10px currentColor;
}
.connection-status.connecting {
background-color: #ffd700;
animation: connectingPulse 1.5s infinite;
}
.connection-status.connected {
background-color: #90ee90;
animation: connectedPulse 2s infinite;
}
.connection-status.disconnected {
background-color: #ff6b6b;
animation: none;
}
@keyframes connectingPulse {
0%,
100% {
opacity: 0.6;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1);
}
}
@keyframes connectedPulse {
0%,
100% {
opacity: 0.8;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.1);
box-shadow: 0 0 15px #90ee90;
}
}
/* Markdown Styles */
.markdown-content h1,
.markdown-content h2,
.markdown-content h3 {
margin-top: 20px;
margin-bottom: 12px;
font-weight: 700;
color: #ffd700;
}
.markdown-content h1 {
font-size: 24px;
}
.markdown-content h2 {
font-size: 20px;
}
.markdown-content h3 {
font-size: 18px;
}
.markdown-content p {
margin-bottom: 12px;
line-height: 1.7;
}
.markdown-content ul,
.markdown-content ol {
margin-bottom: 12px;
padding-left: 24px;
}
.markdown-content li {
margin-bottom: 6px;
}
.markdown-content code {
background: rgba(255, 215, 0, 0.1);
padding: 2px 6px;
border-radius: 4px;
font-family: "Courier New", monospace;
font-size: 14px;
color: #ffd700;
}
.markdown-content pre {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 215, 0, 0.2);
border-radius: 8px;
padding: 16px;
overflow-x: auto;
margin-bottom: 12px;
}
.markdown-content pre code {
background: none;
padding: 0;
color: #e0e0e0;
}
.markdown-content table {
width: 100%;
border-collapse: collapse;
margin-bottom: 12px;
}
.markdown-content table th,
.markdown-content table td {
border: 1px solid rgba(255, 215, 0, 0.2);
padding: 10px;
text-align: left;
}
.markdown-content table th {
background: rgba(255, 215, 0, 0.1);
font-weight: 600;
color: #ffd700;
}
.markdown-content blockquote {
border-left: 3px solid rgba(255, 215, 0, 0.5);
padding-left: 16px;
margin: 12px 0;
color: #b0b0ff;
}
.markdown-content strong {
font-weight: 700;
color: #ffd700;
}
.markdown-content em {
font-style: italic;
color: #b0b0ff;
}
.markdown-content a {
color: #ffd700;
text-decoration: underline;
}
.markdown-content a:hover {
color: #ffed4e;
}
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
}
::-webkit-scrollbar-thumb {
background: rgba(255, 215, 0, 0.3);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 215, 0, 0.5);
}
/* NEW STYLES FOR IMPROVEMENTS */
/* Scroll to bottom button */
.scroll-to-bottom {
position: absolute;
bottom: 20px;
right: 20px;
width: 40px;
height: 40px;
background: rgba(255, 215, 0, 0.8);
border: none;
border-radius: 50%;
color: #0a0e27;
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
transition: all 0.3s ease;
z-index: 10;
}
.scroll-to-bottom:hover {
background: rgba(255, 215, 0, 1);
transform: scale(1.1);
}
/* Continue button for interrupted responses */
.continue-button {
display: inline-block;
background: rgba(255, 215, 0, 0.2);
border: 1px solid rgba(255, 215, 0, 0.4);
border-radius: 8px;
padding: 8px 16px;
color: #ffd700;
font-weight: 600;
cursor: pointer;
margin-top: 10px;
transition: all 0.3s ease;
font-size: 14px;
}
.continue-button:hover {
background: rgba(255, 215, 0, 0.3);
box-shadow: 0 0 10px rgba(255, 215, 0, 0.4);
}
/* Context usage indicator */
.context-indicator {
position: fixed;
bottom: 90px;
right: 20px;
width: 120px;
background: rgba(10, 14, 39, 0.9);
border: 1px solid rgba(255, 215, 0, 0.3);
border-radius: 8px;
padding: 10px;
font-size: 12px;
text-align: center;
z-index: 100;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.context-progress {
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
margin-top: 5px;
overflow: hidden;
}
.context-progress-bar {
height: 100%;
background: #90ee90;
border-radius: 3px;
transition:
width 0.3s ease,
background-color 0.3s ease;
}
.context-progress-bar.warning {
background: #ffd700;
}
.context-progress-bar.danger {
background: #ff6b6b;
}
/* Mobile Responsiveness */
@media (max-width: 768px) {
.sidebar {
width: 100%;
left: -100%;
}
.sidebar.open ~ .main {
margin-left: 0;
width: 100%;
}
header {
padding: 15px 20px 15px 60px;
}
.logo-text {
font-size: 18px;
}
.logo-icon {
width: 30px;
height: 30px;
font-size: 18px;
}
#newChatBtn {
padding: 8px 16px;
font-size: 14px;
}
.input-container {
padding: 0 10px;
}
#messageInput {
padding: 12px 16px;
}
#sendBtn {
padding: 12px 20px;
}
.user-message-content,
.assistant-message-content {
max-width: 85%;
}
.empty-title {
font-size: 24px;
}
.empty-icon {
width: 60px;
height: 60px;
font-size: 36px;
}
.connection-status {
top: 10px;
right: 10px;
width: 14px;
height: 14px;
}
.scroll-to-bottom {
width: 36px;
height: 36px;
font-size: 16px;
bottom: 15px;
right: 15px;
}
.context-indicator {
bottom: 70px;
right: 15px;
width: 100px;
}
}
@media (max-width: 480px) {
.sidebar-toggle {
left: 10px;
top: 15px;
width: 40px;
height: 40px;
}
header {
padding: 10px 15px 10px 50px;
}
.logo-text {
font-size: 16px;
}
#newChatBtn {
padding: 6px 12px;
font-size: 12px;
}
.user-message-content,
.assistant-message-content {
max-width: 90%;
}
.empty-title {
font-size: 20px;
}
.scroll-to-bottom {
width: 32px;
height: 32px;
font-size: 14px;
}
.context-indicator {
bottom: 65px;
right: 10px;
width: 90px;
}
}
</style>
</head>
<body>
<div class="connection-status connecting" id="connectionStatus"></div>
<button class="sidebar-toggle" onclick="toggleSidebar()"></button>
<div class="sidebar" id="sidebar">
<button class="new-chat" onclick="createNewSession()">
+ Novo Chat
</button>
<button
class="voice-toggle"
id="voiceToggle"
onclick="toggleVoiceMode()"
>
🎤 Modo Voz
</button>
<div class="history" id="history"></div>
</div>
<div class="main">
<header>
<div class="logo">
<div class="logo-icon">D</div>
<h1 class="logo-text neon-text">General Bots</h1>
</div>
<button id="newChatBtn">Novo Chat</button>
</header>
<div class="voice-status" id="voiceStatus" style="display: none">
🎤 Ouvindo... Fale agora
</div>
<main id="messages">
<div id="emptyState">
<div class="empty-icon">D</div>
<h2 class="empty-title neon-text">
Bem-vindo ao General Bots
</h2>
<p class="empty-subtitle">Seu assistente de IA avançado</p>
</div>
</main>
<footer>
<div class="input-container">
<input
id="messageInput"
type="text"
placeholder="Fale com General Bots..."
autofocus
/>
<button id="sendBtn">Enviar</button>
</div>
<script>
function handleSuggestions(suggestions) {
const footer = document.querySelector('footer');
let container = footer.querySelector('.suggestions-container');
if (!container) {
container = document.createElement('div');
container.className = 'suggestions-container';
footer.insertBefore(container, footer.firstChild);
}
container.innerHTML = '';
suggestions.forEach(s => {
const btn = document.createElement('button');
btn.textContent = s.text;
btn.className = 'suggestion-button';
btn.onclick = () => {
setContext(s.context);
input.value = ''; // Clear input after clicking
};
container.appendChild(btn);
});
}
let pendingContextChange = null;
async function setContext(context) {
try {
// Get the button text from the clicked suggestion
const buttonText = event?.target?.textContent || context;
// Add the suggestion as a user message to the chat
addMessage("user", buttonText);
// Clear the input field
const input = document.getElementById('messageInput');
if (input) {
input.value = '';
}
if (ws && ws.readyState === WebSocket.OPEN) {
pendingContextChange = new Promise((resolve) => {
const handler = (event) => {
const response = JSON.parse(event.data);
if (response.message_type === 5 && response.context_name === context) {
ws.removeEventListener('message', handler);
resolve();
}
};
ws.addEventListener('message', handler);
const suggestionEvent = {
bot_id: currentBotId,
user_id: currentUserId,
session_id: currentSessionId,
channel: "web",
content: buttonText, // Send button text as content
message_type: 4, // custom type for suggestion click
is_suggestion: true,
context_name: context, // Context to switch to
timestamp: new Date().toISOString()
};
ws.send(JSON.stringify(suggestionEvent));
});
await pendingContextChange;
// Update UI to show current context
const contextIndicator = document.getElementById('contextIndicator');
if (contextIndicator) {
contextIndicator.style.display = 'block';
document.getElementById('contextPercentage').textContent = context;
}
} else {
console.warn("WebSocket não está conectado. Tentando reconectar...");
connectWebSocket();
}
} catch (err) {
console.error('Failed to set context:', err);
}
}
async function sendMessage() {
if (pendingContextChange) {
await pendingContextChange;
pendingContextChange = null;
}
const message = input.value.trim();
if (!message || !ws || ws.readyState !== WebSocket.OPEN) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
showWarning("Conexão não disponível. Tentando reconectar...");
connectWebSocket();
}
return;
}
if (isThinking) {
hideThinkingIndicator();
}
addMessage("user", message);
const messageData = {
bot_id: currentBotId,
user_id: currentUserId,
session_id: currentSessionId,
channel: "web",
content: message,
message_type: 1,
media_url: null,
timestamp: new Date().toISOString()
};
ws.send(JSON.stringify(messageData));
input.value = "";
input.focus();
}
</script>
</footer>
</div>
<!-- New elements for improvements -->
<button
class="scroll-to-bottom"
id="scrollToBottom"
style="display: none"
>
</button>
<div
class="context-indicator"
id="contextIndicator"
style="display: none"
>
<div>Contexto</div>
<div id="contextPercentage">0%</div>
<div class="context-progress">
<div
class="context-progress-bar"
id="contextProgressBar"
style="width: 0%"
></div>
</div>
</div>
<script>
let ws = null;
let currentSessionId = null;
let currentUserId = null;
let currentBotId = "default_bot";
let isStreaming = false;
let voiceRoom = null;
let isVoiceMode = false;
let mediaRecorder = null;
let audioChunks = [];
let streamingMessageId = null;
let isThinking = false;
let currentStreamingContent = "";
let hasReceivedInitialMessage = false;
let reconnectAttempts = 0;
const maxReconnectAttempts = 5;
let reconnectTimeout = null;
let thinkingTimeout = null;
let lastMessageLength = 0;
let contextUsage = 0;
let isUserScrolling = false;
let autoScrollEnabled = true;
const messagesDiv = document.getElementById("messages");
const input = document.getElementById("messageInput");
const sendBtn = document.getElementById("sendBtn");
const newChatBtn = document.getElementById("newChatBtn");
const connectionStatus =
document.getElementById("connectionStatus");
const scrollToBottomBtn = document.getElementById("scrollToBottom");
const contextIndicator =
document.getElementById("contextIndicator");
const contextPercentage =
document.getElementById("contextPercentage");
const contextProgressBar =
document.getElementById("contextProgressBar");
marked.setOptions({
breaks: true,
gfm: true,
});
function toggleSidebar() {
document.getElementById("sidebar").classList.toggle("open");
}
function updateConnectionStatus(status) {
connectionStatus.className = `connection-status ${status}`;
}
function getWebSocketUrl() {
const protocol =
window.location.protocol === "https:" ? "wss:" : "ws:";
// Generate UUIDs if not set yet
const sessionId = currentSessionId || crypto.randomUUID();
const userId = currentUserId || crypto.randomUUID();
return `${protocol}//${window.location.host}/ws?session_id=${sessionId}&user_id=${userId}`;
}
// Auto-focus on input when page loads
window.addEventListener("load", function () {
input.focus();
});
// Close sidebar when clicking outside on mobile
document.addEventListener("click", function (event) {
const sidebar = document.getElementById("sidebar");
const sidebarToggle = document.querySelector(".sidebar-toggle");
if (
window.innerWidth <= 768 &&
sidebar.classList.contains("open") &&
!sidebar.contains(event.target) &&
!sidebarToggle.contains(event.target)
) {
sidebar.classList.remove("open");
}
});
// Scroll management
messagesDiv.addEventListener("scroll", function () {
// Check if user is scrolling manually
const isAtBottom =
messagesDiv.scrollHeight - messagesDiv.scrollTop <=
messagesDiv.clientHeight + 100;
if (!isAtBottom) {
isUserScrolling = true;
showScrollToBottomButton();
} else {
isUserScrolling = false;
hideScrollToBottomButton();
}
});
function scrollToBottom() {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
isUserScrolling = false;
hideScrollToBottomButton();
}
function showScrollToBottomButton() {
scrollToBottomBtn.style.display = "flex";
}
function hideScrollToBottomButton() {
scrollToBottomBtn.style.display = "none";
}
scrollToBottomBtn.addEventListener("click", scrollToBottom);
// Context usage management
function updateContextUsage(usage) {
contextUsage = usage;
const percentage = Math.min(100, Math.round(usage * 100));
contextPercentage.textContent = `${percentage}%`;
contextProgressBar.style.width = `${percentage}%`;
// Update color based on usage
if (percentage >= 90) {
contextProgressBar.className =
"context-progress-bar danger";
} else if (percentage >= 70) {
contextProgressBar.className =
"context-progress-bar warning";
} else {
contextProgressBar.className = "context-progress-bar";
}
// Show indicator if usage is above 50%
if (percentage >= 50) {
contextIndicator.style.display = "block";
} else {
contextIndicator.style.display = "none";
}
}
async function initializeAuth() {
try {
updateConnectionStatus("connecting");
// Extract bot name from URL path (first segment after /
const pathSegments = window.location.pathname.split('/').filter(s => s);
const botName = pathSegments.length > 0 ? pathSegments[0] : 'default';
const response = await fetch(`/api/auth?bot_name=${encodeURIComponent(botName)}`);
const authData = await response.json();
currentUserId = authData.user_id;
currentSessionId = authData.session_id;
connectWebSocket();
loadSessions();
} catch (error) {
console.error("Failed to initialize auth:", error);
updateConnectionStatus("disconnected");
setTimeout(initializeAuth, 3000);
}
}
async function loadSessions() {
try {
const response = await fetch("/api/sessions");
const sessions = await response.json();
const history = document.getElementById("history");
history.innerHTML = "";
} catch (error) {
console.error("Failed to load sessions:", error);
}
}
async function createNewSession() {
try {
const response = await fetch("/api/sessions", {
method: "POST",
});
const session = await response.json();
currentSessionId = session.session_id;
hasReceivedInitialMessage = false;
connectWebSocket();
loadSessions();
document.getElementById("messages").innerHTML = `
<div id="emptyState">
<div class="empty-icon">D</div>
<h2 class="empty-title neon-text">Bem-vindo ao General Bots</h2>
<p class="empty-subtitle">Seu assistente de IA avançado</p>
</div>
`;
// Reset context usage for new session
updateContextUsage(0);
if (isVoiceMode) {
await startVoiceSession();
}
// Close sidebar on mobile after creating new chat
if (window.innerWidth <= 768) {
document
.getElementById("sidebar")
.classList.remove("open");
}
} catch (error) {
console.error("Failed to create session:", error);
}
}
function switchSession(sessionId) {
currentSessionId = sessionId;
hasReceivedInitialMessage = false;
loadSessionHistory(sessionId);
connectWebSocket();
if (isVoiceMode) {
startVoiceSession();
}
// Close sidebar on mobile after switching session
if (window.innerWidth <= 768) {
document.getElementById("sidebar").classList.remove("open");
}
}
async function loadSessionHistory(sessionId) {
try {
const response = await fetch("/api/sessions/" + sessionId);
const history = await response.json();
const messages = document.getElementById("messages");
messages.innerHTML = "";
if (history.length === 0) {
// Show empty state if no history
messages.innerHTML = `
<div id="emptyState">
<div class="empty-icon">D</div>
<h2 class="empty-title neon-text">Bem-vindo ao General Bots</h2>
<p class="empty-subtitle">Seu assistente de IA avançado</p>
</div>
`;
updateContextUsage(0);
} else {
// Display existing history
history.forEach(([role, content]) => {
addMessage(role, content, false);
});
// Estimate context usage based on message count
updateContextUsage(history.length / 2); // Assuming 20 messages is 100% context
}
} catch (error) {
console.error("Failed to load session history:", error);
}
}
function connectWebSocket() {
if (ws) {
ws.close();
}
clearTimeout(reconnectTimeout);
const wsUrl = getWebSocketUrl();
ws = new WebSocket(wsUrl);
ws.onmessage = function (event) {
const response = JSON.parse(event.data);
// Update current bot_id if provided in the message
if (response.bot_id) {
currentBotId = response.bot_id;
}
if (response.message_type === 2) {
const eventData = JSON.parse(response.content);
handleEvent(eventData.event, eventData.data);
return;
}
processMessageContent(response);
};
ws.onopen = function () {
console.log("Connected to WebSocket");
updateConnectionStatus("connected");
reconnectAttempts = 0;
// Reset the flag when connection is established
hasReceivedInitialMessage = false;
};
ws.onclose = function (event) {
console.log(
"WebSocket disconnected:",
event.code,
event.reason,
);
updateConnectionStatus("disconnected");
// If we were streaming and connection was lost, show continue button
if (isStreaming) {
showContinueButton();
}
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
const delay = Math.min(1000 * reconnectAttempts, 10000);
console.log(
`Reconnecting in ${delay}ms... (attempt ${reconnectAttempts})`,
);
reconnectTimeout = setTimeout(() => {
updateConnectionStatus("connecting");
connectWebSocket();
}, delay);
} else {
updateConnectionStatus("disconnected");
}
};
ws.onerror = function (error) {
console.error("WebSocket error:", error);
updateConnectionStatus("disconnected");
};
}
function processMessageContent(response) {
// Clear empty state when we receive any message
const emptyState = document.getElementById("emptyState");
if (emptyState) {
emptyState.remove();
}
// Handle context usage if provided
if (response.context_usage !== undefined) {
updateContextUsage(response.context_usage);
}
// Handle suggestions if present in any message
if (response.suggestions && response.suggestions.length > 0) {
handleSuggestions(response.suggestions);
}
// Handle complete messages
if (response.is_complete) {
if (isStreaming) {
finalizeStreamingMessage();
isStreaming = false;
streamingMessageId = null;
currentStreamingContent = "";
} else {
// This is a complete message that wasn't being streamed
addMessage("assistant", response.content, false);
}
} else {
// Handle streaming messages
if (!isStreaming) {
isStreaming = true;
streamingMessageId = "streaming-" + Date.now();
currentStreamingContent = response.content || "";
addMessage(
"assistant",
currentStreamingContent,
true,
streamingMessageId,
);
} else {
currentStreamingContent += response.content || "";
updateStreamingMessage(currentStreamingContent);
}
}
}
function handleEvent(eventType, eventData) {
console.log("Event received:", eventType, eventData);
switch (eventType) {
case "thinking_start":
showThinkingIndicator();
break;
case "thinking_end":
hideThinkingIndicator();
break;
case "warn":
showWarning(eventData.message);
break;
case "context_usage":
updateContextUsage(eventData.usage);
break;
}
}
function showThinkingIndicator() {
if (isThinking) return;
const emptyState = document.getElementById("emptyState");
if (emptyState) emptyState.remove();
const thinkingDiv = document.createElement("div");
thinkingDiv.id = "thinking-indicator";
thinkingDiv.className = "message-container";
thinkingDiv.innerHTML = `
<div class="assistant-message">
<div class="assistant-avatar">D</div>
<div class="thinking-indicator">
<div class="typing-dots">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
<span>Pensando...</span>
</div>
</div>
`;
messagesDiv.appendChild(thinkingDiv);
gsap.to(thinkingDiv, {
opacity: 1,
y: 0,
duration: 0.4,
ease: "power2.out",
});
// Auto-scroll to show thinking indicator
if (!isUserScrolling) {
scrollToBottom();
} else {
showScrollToBottomButton();
}
// Set timeout to automatically hide thinking indicator after 30 seconds
// This handles cases where the server restarts and doesn't send thinking_end
thinkingTimeout = setTimeout(() => {
if (isThinking) {
hideThinkingIndicator();
showWarning(
"O servidor pode estar ocupado. A resposta está demorando demais.",
);
}
}, 60000);
isThinking = true;
}
function hideThinkingIndicator() {
if (!isThinking) return;
const thinkingDiv =
document.getElementById("thinking-indicator");
if (thinkingDiv) {
gsap.to(thinkingDiv, {
opacity: 0,
duration: 0.2,
onComplete: () => {
if (thinkingDiv.parentNode) {
thinkingDiv.remove();
}
},
});
}
// Clear the timeout if thinking ends normally
if (thinkingTimeout) {
clearTimeout(thinkingTimeout);
thinkingTimeout = null;
}
isThinking = false;
}
function showWarning(message) {
const warningDiv = document.createElement("div");
warningDiv.className = "warning-message";
warningDiv.innerHTML = `⚠️ ${message}`;
messagesDiv.appendChild(warningDiv);
gsap.from(warningDiv, {
opacity: 0,
y: 20,
duration: 0.4,
ease: "power2.out",
});
if (!isUserScrolling) {
scrollToBottom();
} else {
showScrollToBottomButton();
}
setTimeout(() => {
if (warningDiv.parentNode) {
gsap.to(warningDiv, {
opacity: 0,
duration: 0.3,
onComplete: () => warningDiv.remove(),
});
}
}, 5000);
}
function showContinueButton() {
const continueDiv = document.createElement("div");
continueDiv.className = "message-container";
continueDiv.innerHTML = `
<div class="assistant-message">
<div class="assistant-avatar">D</div>
<div class="assistant-message-content">
<p>A conexão foi interrompida. Clique em "Continuar" para tentar recuperar a resposta.</p>
<button class="continue-button" onclick="continueInterruptedResponse()">Continuar</button>
</div>
</div>
`;
messagesDiv.appendChild(continueDiv);
gsap.to(continueDiv, {
opacity: 1,
y: 0,
duration: 0.5,
ease: "power2.out",
});
if (!isUserScrolling) {
scrollToBottom();
} else {
showScrollToBottomButton();
}
}
function continueInterruptedResponse() {
if (!ws || ws.readyState !== WebSocket.OPEN) {
connectWebSocket();
}
// Send a continue request to the server
if (ws && ws.readyState === WebSocket.OPEN) {
const continueData = {
bot_id: "default_bot",
user_id: currentUserId,
session_id: currentSessionId,
channel: "web",
content: "continue",
message_type: 3, // Special message type for continue requests
media_url: null,
timestamp: new Date().toISOString(),
};
ws.send(JSON.stringify(continueData));
}
// Remove the continue button
const continueButtons =
document.querySelectorAll(".continue-button");
continueButtons.forEach((button) => {
button.parentElement.parentElement.parentElement.remove();
});
}
function addMessage(
role,
content,
streaming = false,
msgId = null,
) {
const emptyState = document.getElementById("emptyState");
if (emptyState) {
gsap.to(emptyState, {
opacity: 0,
y: -20,
duration: 0.3,
onComplete: () => emptyState.remove(),
});
}
const msg = document.createElement("div");
msg.className = "message-container";
if (role === "user") {
msg.innerHTML = `
<div class="user-message">
<div class="user-message-content">${escapeHtml(content)}</div>
</div>
`;
// Update context usage when user sends a message
updateContextUsage(contextUsage + 0.05); // Simulate 5% increase per message
} else if (role === "assistant") {
msg.innerHTML = `
<div class="assistant-message">
<div class="assistant-avatar">D</div>
<div class="assistant-message-content markdown-content" id="${msgId || ""}">
${streaming ? "" : marked.parse(content)}
</div>
</div>
`;
// Update context usage when assistant responds
updateContextUsage(contextUsage + 0.03); // Simulate 3% increase per response
} else if (role === "voice") {
msg.innerHTML = `
<div class="assistant-message">
<div class="assistant-avatar">🎤</div>
<div class="assistant-message-content">${content}</div>
</div>
`;
} else {
msg.innerHTML = `
<div class="assistant-message">
<div class="assistant-avatar">D</div>
<div class="assistant-message-content">${content}</div>
</div>
`;
}
messagesDiv.appendChild(msg);
gsap.to(msg, {
opacity: 1,
y: 0,
duration: 0.5,
ease: "power2.out",
});
// Auto-scroll to bottom if user isn't manually scrolling
if (!isUserScrolling) {
scrollToBottom();
} else {
showScrollToBottomButton();
}
}
function updateStreamingMessage(content) {
const msgElement = document.getElementById(streamingMessageId);
if (msgElement) {
msgElement.innerHTML = marked.parse(content);
// Auto-scroll to bottom if user isn't manually scrolling
if (!isUserScrolling) {
scrollToBottom();
} else {
showScrollToBottomButton();
}
}
}
function finalizeStreamingMessage() {
const msgElement = document.getElementById(streamingMessageId);
if (msgElement) {
msgElement.innerHTML = marked.parse(
currentStreamingContent,
);
msgElement.removeAttribute("id");
// Auto-scroll to bottom if user isn't manually scrolling
if (!isUserScrolling) {
scrollToBottom();
} else {
showScrollToBottomButton();
}
}
}
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
function sendMessage() {
const message = input.value.trim();
if (!message || !ws || ws.readyState !== WebSocket.OPEN) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
showWarning(
"Conexão não disponível. Tentando reconectar...",
);
connectWebSocket();
}
return;
}
if (isThinking) {
hideThinkingIndicator();
}
addMessage("user", message);
const messageData = {
bot_id: "default_bot",
user_id: currentUserId,
session_id: currentSessionId,
channel: "web",
content: message,
message_type: 1,
media_url: null,
timestamp: new Date().toISOString(),
};
ws.send(JSON.stringify(messageData));
input.value = "";
input.focus(); // Keep focus on input after sending
}
sendBtn.onclick = sendMessage;
input.addEventListener("keypress", (e) => {
if (e.key === "Enter") sendMessage();
});
newChatBtn.onclick = () => createNewSession();
async function toggleVoiceMode() {
isVoiceMode = !isVoiceMode;
const voiceToggle = document.getElementById("voiceToggle");
const voiceStatus = document.getElementById("voiceStatus");
if (isVoiceMode) {
voiceToggle.textContent = "🔴 Parar Voz";
voiceToggle.classList.add("recording");
voiceStatus.style.display = "block";
await startVoiceSession();
} else {
voiceToggle.textContent = "🎤 Modo Voz";
voiceToggle.classList.remove("recording");
voiceStatus.style.display = "none";
await stopVoiceSession();
}
// Close sidebar on mobile after toggling voice mode
if (window.innerWidth <= 768) {
document.getElementById("sidebar").classList.remove("open");
}
}
async function startVoiceSession() {
if (!currentSessionId) return;
try {
const response = await fetch("/api/voice/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: currentSessionId,
user_id: currentUserId,
}),
});
const data = await response.json();
if (data.token) {
await connectToVoiceRoom(data.token);
startVoiceRecording();
}
} catch (error) {
console.error("Failed to start voice session:", error);
showWarning("Falha ao iniciar modo de voz");
}
}
async function stopVoiceSession() {
if (!currentSessionId) return;
try {
await fetch("/api/voice/stop", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
session_id: currentSessionId,
}),
});
if (voiceRoom) {
voiceRoom.disconnect();
voiceRoom = null;
}
if (mediaRecorder && mediaRecorder.state === "recording") {
mediaRecorder.stop();
}
} catch (error) {
console.error("Failed to stop voice session:", error);
}
}
async function connectToVoiceRoom(token) {
try {
const room = new LiveKitClient.Room();
// Use o mesmo esquema (ws/wss) do WebSocket principal
const protocol =
window.location.protocol === "https:" ? "wss:" : "ws:";
const voiceUrl = `${protocol}//${window.location.host}/voice`;
await room.connect(voiceUrl, token);
voiceRoom = room;
room.on("dataReceived", (data) => {
const decoder = new TextDecoder();
const message = decoder.decode(data);
try {
const parsed = JSON.parse(message);
if (parsed.type === "voice_response") {
addMessage("assistant", parsed.text);
}
} catch (e) {
console.log("Voice data:", message);
}
});
const localTracks = await LiveKitClient.createLocalTracks({
audio: true,
video: false,
});
for (const track of localTracks) {
await room.localParticipant.publishTrack(track);
}
} catch (error) {
console.error("Failed to connect to voice room:", error);
showWarning("Falha na conexão de voz");
}
}
function startVoiceRecording() {
if (!navigator.mediaDevices) {
console.log("Media devices not supported");
return;
}
navigator.mediaDevices
.getUserMedia({ audio: true })
.then((stream) => {
mediaRecorder = new MediaRecorder(stream);
audioChunks = [];
mediaRecorder.ondataavailable = (event) => {
audioChunks.push(event.data);
};
mediaRecorder.onstop = () => {
const audioBlob = new Blob(audioChunks, {
type: "audio/wav",
});
simulateVoiceTranscription();
};
mediaRecorder.start();
setTimeout(() => {
if (
mediaRecorder &&
mediaRecorder.state === "recording"
) {
mediaRecorder.stop();
setTimeout(() => {
if (isVoiceMode) {
startVoiceRecording();
}
}, 1000);
}
}, 5000);
})
.catch((error) => {
console.error("Error accessing microphone:", error);
showWarning("Erro ao acessar microfone");
});
}
function simulateVoiceTranscription() {
const phrases = [
"Olá, como posso ajudá-lo hoje?",
"Entendo o que você está dizendo",
"Esse é um ponto interessante",
"Deixe-me pensar sobre isso",
"Posso ajudá-lo com isso",
"O que você gostaria de saber?",
"Isso parece ótimo",
"Estou ouvindo sua voz",
];
const randomPhrase =
phrases[Math.floor(Math.random() * phrases.length)];
if (voiceRoom) {
const message = {
type: "voice_input",
content: randomPhrase,
timestamp: new Date().toISOString(),
};
voiceRoom.localParticipant.publishData(
new TextEncoder().encode(JSON.stringify(message)),
LiveKitClient.DataPacketKind.RELIABLE,
);
}
addMessage("voice", `🎤 ${randomPhrase}`);
}
// Inicializar quando a página carregar
window.addEventListener("load", initializeAuth);
// Tentar reconectar quando a página ganhar foco
window.addEventListener("focus", function () {
if (!ws || ws.readyState !== WebSocket.OPEN) {
connectWebSocket();
}
});
</script>
</body>
</html>