botserver/web/desktop/chat/chat.js
Rodrigo Rodriguez (Pragmatismo) f9a1e3a8c0 refactor(web): consolidate routing and expose auth handler
- Add `*.log` to `.gitignore` to exclude log files from version control.
- Change `auth_handler` to `pub` in `src/auth/mod.rs` to make the endpoint publicly accessible.
- Remove unused `bot_index` import and route; replace direct service registration with `web_server::configure_app` in `src/main.rs`.
- Refactor `src/web_server/mod.rs`:
  - Remove the `bot_index` handler.
  - Introduce `serve_html` helper for loading HTML pages.
  - Simplify static file serving by configuring separate routes for JS and CSS assets.
  - Centralize all route and static file configuration in `configure_app`.
- Clean up related imports and improve error handling for missing pages.
2025-11-17 10:00:12 -03:00

713 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

function chatApp() {
// Core state variables (shared via closure)
let ws = null,
currentSessionId = null,
currentUserId = null,
currentBotId = "default_bot",
isStreaming = false,
voiceRoom = null,
isVoiceMode = false,
mediaRecorder = null,
audioChunks = [],
streamingMessageId = null,
isThinking = false,
currentStreamingContent = "",
hasReceivedInitialMessage = false,
reconnectAttempts = 0,
reconnectTimeout = null,
thinkingTimeout = null,
currentTheme = 'auto',
themeColor1 = null,
themeColor2 = null,
customLogoUrl = null,
contextUsage = 0,
isUserScrolling = false,
autoScrollEnabled = true,
isContextChange = false;
const maxReconnectAttempts = 5;
// DOM references (cached for performance)
let messagesDiv, input, sendBtn, voiceBtn, connectionStatus, flashOverlay, suggestionsContainer, floatLogo, sidebar, themeBtn, scrollToBottomBtn, contextIndicator, contextPercentage, contextProgressBar, sidebarTitle;
marked.setOptions({ breaks: true, gfm: true });
return {
// ----------------------------------------------------------------------
// UI state (mirrors the structure used in driveApp)
// ----------------------------------------------------------------------
current: 'All Chats',
search: '',
selectedChat: null,
navItems: [
{ name: 'All Chats', icon: '💬' },
{ name: 'Direct', icon: '👤' },
{ name: 'Groups', icon: '👥' },
{ name: 'Archived', icon: '🗄' }
],
chats: [
{ id: 1, name: 'General Bot Support', icon: '🤖', lastMessage: 'How can I help you?', time: '10:15 AM', status: 'Online' },
{ id: 2, name: 'Project Alpha', icon: '🚀', lastMessage: 'Launch scheduled for tomorrow.', time: 'Yesterday', status: 'Active' },
{ id: 3, name: 'Team Standup', icon: '🗣️', lastMessage: 'Done with the UI updates.', time: '2 hrs ago', status: 'Active' },
{ id: 4, name: 'Random Chat', icon: '🎲', lastMessage: 'Did you see the game last night?', time: '5 hrs ago', status: 'Idle' },
{ id: 5, name: 'Support Ticket #1234', icon: '🛠️', lastMessage: 'Issue resolved, closing ticket.', time: '3 days ago', status: 'Closed' }
],
get filteredChats() {
return this.chats.filter(chat =>
chat.name.toLowerCase().includes(this.search.toLowerCase())
);
},
// ----------------------------------------------------------------------
// UI helpers (formerly standalone functions)
// ----------------------------------------------------------------------
toggleSidebar() {
sidebar.classList.toggle('open');
},
toggleTheme() {
const themes = ['auto', 'dark', 'light'];
const savedTheme = localStorage.getItem('gb-theme') || 'auto';
const idx = themes.indexOf(savedTheme);
const newTheme = themes[(idx + 1) % themes.length];
localStorage.setItem('gb-theme', newTheme);
currentTheme = newTheme;
this.applyTheme();
this.updateThemeButton();
},
applyTheme() {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
let theme = currentTheme;
if (theme === 'auto') {
theme = prefersDark ? 'dark' : 'light';
}
document.documentElement.setAttribute('data-theme', theme);
if (themeColor1 && themeColor2) {
const root = document.documentElement;
root.style.setProperty('--bg', theme === 'dark' ? themeColor2 : themeColor1);
root.style.setProperty('--fg', theme === 'dark' ? themeColor1 : themeColor2);
}
if (customLogoUrl) {
document.documentElement.style.setProperty('--logo-url', `url('${customLogoUrl}')`);
}
},
// ----------------------------------------------------------------------
// Lifecycle / event handlers
// ----------------------------------------------------------------------
init() {
document.addEventListener('ready', () => {
// Assign DOM elements after the document is ready
messagesDiv = document.getElementById("messages");
input = document.getElementById("messageInput");
sendBtn = document.getElementById("sendBtn");
voiceBtn = document.getElementById("voiceBtn");
connectionStatus = document.getElementById("connectionStatus");
flashOverlay = document.getElementById("flashOverlay");
suggestionsContainer = document.getElementById("suggestions");
floatLogo = document.getElementById("floatLogo");
sidebar = document.getElementById("sidebar");
themeBtn = document.getElementById("themeBtn");
scrollToBottomBtn = document.getElementById("scrollToBottom");
contextIndicator = document.getElementById("contextIndicator");
contextPercentage = document.getElementById("contextPercentage");
contextProgressBar = document.getElementById("contextProgressBar");
sidebarTitle = document.getElementById("sidebarTitle");
// Theme initialization and focus
const savedTheme = localStorage.getItem('gb-theme') || 'auto';
currentTheme = savedTheme;
this.applyTheme();
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (currentTheme === 'auto') {
this.applyTheme();
}
});
input.focus();
// UI event listeners
document.addEventListener('click', (e) => {
if (sidebar.classList.contains('open') && !sidebar.contains(e.target) && !floatLogo.contains(e.target)) {
sidebar.classList.remove('open');
}
});
messagesDiv.addEventListener('scroll', () => {
const isAtBottom = messagesDiv.scrollHeight - messagesDiv.scrollTop <= messagesDiv.clientHeight + 100;
if (!isAtBottom) {
isUserScrolling = true;
scrollToBottomBtn.classList.add('visible');
} else {
isUserScrolling = false;
scrollToBottomBtn.classList.remove('visible');
}
});
scrollToBottomBtn.addEventListener('click', () => {
this.scrollToBottom();
});
sendBtn.onclick = () => this.sendMessage();
input.addEventListener("keypress", e => { if (e.key === "Enter") this.sendMessage(); });
window.addEventListener("focus", () => {
if (!ws || ws.readyState !== WebSocket.OPEN) {
this.connectWebSocket();
}
});
// Start authentication flow
this.initializeAuth();
});
},
updateContextUsage(u) {
contextUsage = u;
const p = Math.min(100, Math.round(u * 100));
contextPercentage.textContent = `${p}%`;
contextProgressBar.style.width = `${p}%`;
contextIndicator.classList.remove('visible');
},
flashScreen() {
gsap.to(flashOverlay, { opacity: 0.15, duration: 0.1, onComplete: () => {
gsap.to(flashOverlay, { opacity: 0, duration: 0.2 });
} });
},
updateConnectionStatus(s) {
connectionStatus.className = `connection-status ${s}`;
},
getWebSocketUrl() {
const p = "ws:", s = currentSessionId || crypto.randomUUID(), u = currentUserId || crypto.randomUUID();
return `${p}//localhost:8080/ws?session_id=${s}&user_id=${u}`;
},
async initializeAuth() {
try {
this.updateConnectionStatus("connecting");
const p = window.location.pathname.split('/').filter(s => s);
const b = p.length > 0 ? p[0] : 'default';
const r = await fetch(`http://localhost:8080/api/auth?bot_name=${encodeURIComponent(b)}`);
const a = await r.json();
currentUserId = a.user_id;
currentSessionId = a.session_id;
this.connectWebSocket();
this.loadSessions();
} catch (e) {
console.error("Failed to initialize auth:", e);
this.updateConnectionStatus("disconnected");
setTimeout(() => this.initializeAuth(), 3000);
}
},
async loadSessions() {
try {
const r = await fetch("http://localhost:8080/api/sessions");
const s = await r.json();
const h = document.getElementById("history");
h.innerHTML = "";
s.forEach(session => {
const item = document.createElement('div');
item.className = 'history-item';
item.textContent = session.title || `Session ${session.session_id.substring(0, 8)}`;
item.onclick = () => this.switchSession(session.session_id);
h.appendChild(item);
});
} catch (e) {
console.error("Failed to load sessions:", e);
}
},
async createNewSession() {
try {
const r = await fetch("http://localhost:8080/api/sessions", { method: "POST" });
const s = await r.json();
currentSessionId = s.session_id;
hasReceivedInitialMessage = false;
this.connectWebSocket();
this.loadSessions();
messagesDiv.innerHTML = "";
this.clearSuggestions();
this.updateContextUsage(0);
if (isVoiceMode) {
await this.stopVoiceSession();
isVoiceMode = false;
const v = document.getElementById("voiceToggle");
v.textContent = "🎤 Voice Mode";
voiceBtn.classList.remove("recording");
}
} catch (e) {
console.error("Failed to create session:", e);
}
},
switchSession(s) {
currentSessionId = s;
hasReceivedInitialMessage = false;
this.loadSessionHistory(s);
this.connectWebSocket();
if (isVoiceMode) {
this.startVoiceSession();
}
sidebar.classList.remove('open');
},
async loadSessionHistory(s) {
try {
const r = await fetch(`http://localhost:8080/api/sessions/${s}`);
const h = await r.json();
const m = document.getElementById("messages");
m.innerHTML = "";
if (h.length === 0) {
this.updateContextUsage(0);
} else {
h.forEach(([role, content]) => {
this.addMessage(role, content, false);
});
this.updateContextUsage(h.length / 20);
}
} catch (e) {
console.error("Failed to load session history:", e);
}
},
connectWebSocket() {
if (ws) {
ws.close();
}
clearTimeout(reconnectTimeout);
const u = this.getWebSocketUrl();
ws = new WebSocket(u);
ws.onmessage = (e) => {
const r = JSON.parse(e.data);
if (r.bot_id) {
currentBotId = r.bot_id;
}
if (r.message_type === 2) {
const d = JSON.parse(r.content);
this.handleEvent(d.event, d.data);
return;
}
if (r.message_type === 5) {
isContextChange = true;
return;
}
this.processMessageContent(r);
};
ws.onopen = () => {
console.log("Connected to WebSocket");
this.updateConnectionStatus("connected");
reconnectAttempts = 0;
hasReceivedInitialMessage = false;
};
ws.onclose = (e) => {
console.log("WebSocket disconnected:", e.code, e.reason);
this.updateConnectionStatus("disconnected");
if (isStreaming) {
this.showContinueButton();
}
if (reconnectAttempts < maxReconnectAttempts) {
reconnectAttempts++;
const d = Math.min(1000 * reconnectAttempts, 10000);
reconnectTimeout = setTimeout(() => {
this.updateConnectionStatus("connecting");
this.connectWebSocket();
}, d);
} else {
this.updateConnectionStatus("disconnected");
}
};
ws.onerror = (e) => {
console.error("WebSocket error:", e);
this.updateConnectionStatus("disconnected");
};
},
processMessageContent(r) {
if (isContextChange) {
isContextChange = false;
return;
}
if (r.context_usage !== undefined) {
this.updateContextUsage(r.context_usage);
}
if (r.suggestions && r.suggestions.length > 0) {
this.handleSuggestions(r.suggestions);
}
if (r.is_complete) {
if (isStreaming) {
this.finalizeStreamingMessage();
isStreaming = false;
streamingMessageId = null;
currentStreamingContent = "";
} else {
this.addMessage("assistant", r.content, false);
}
} else {
if (!isStreaming) {
isStreaming = true;
streamingMessageId = "streaming-" + Date.now();
currentStreamingContent = r.content || "";
this.addMessage("assistant", currentStreamingContent, true, streamingMessageId);
} else {
currentStreamingContent += r.content || "";
this.updateStreamingMessage(currentStreamingContent);
}
}
},
handleEvent(t, d) {
console.log("Event received:", t, d);
switch (t) {
case "thinking_start":
this.showThinkingIndicator();
break;
case "thinking_end":
this.hideThinkingIndicator();
break;
case "warn":
this.showWarning(d.message);
break;
case "context_usage":
this.updateContextUsage(d.usage);
break;
case "change_theme":
if (d.color1) themeColor1 = d.color1;
if (d.color2) themeColor2 = d.color2;
if (d.logo_url) customLogoUrl = d.logo_url;
if (d.title) document.title = d.title;
if (d.logo_text) {
sidebarTitle.textContent = d.logo_text;
}
this.applyTheme();
break;
}
},
showThinkingIndicator() {
if (isThinking) return;
const t = document.createElement("div");
t.id = "thinking-indicator";
t.className = "message-container";
t.innerHTML = `<div class="assistant-message"><div class="assistant-avatar"></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></div></div>`;
messagesDiv.appendChild(t);
gsap.to(t, { opacity: 1, y: 0, duration: .3, ease: "power2.out" });
if (!isUserScrolling) {
this.scrollToBottom();
}
thinkingTimeout = setTimeout(() => {
if (isThinking) {
this.hideThinkingIndicator();
this.showWarning("O servidor pode estar ocupado. A resposta está demorando demais.");
}
}, 60000);
isThinking = true;
},
hideThinkingIndicator() {
if (!isThinking) return;
const t = document.getElementById("thinking-indicator");
if (t) {
gsap.to(t, { opacity: 0, duration: .2, onComplete: () => { if (t.parentNode) { t.remove(); } } });
}
if (thinkingTimeout) {
clearTimeout(thinkingTimeout);
thinkingTimeout = null;
}
isThinking = false;
},
showWarning(m) {
const w = document.createElement("div");
w.className = "warning-message";
w.innerHTML = `⚠️ ${m}`;
messagesDiv.appendChild(w);
gsap.from(w, { opacity: 0, y: 20, duration: .4, ease: "power2.out" });
if (!isUserScrolling) {
this.scrollToBottom();
}
setTimeout(() => {
if (w.parentNode) {
gsap.to(w, { opacity: 0, duration: .3, onComplete: () => w.remove() });
}
}, 5000);
},
showContinueButton() {
const c = document.createElement("div");
c.className = "message-container";
c.innerHTML = `<div class="assistant-message"><div class="assistant-avatar"></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="this.parentElement.parentElement.parentElement.remove();">Continuar</button></div></div>`;
messagesDiv.appendChild(c);
gsap.to(c, { opacity: 1, y: 0, duration: .5, ease: "power2.out" });
if (!isUserScrolling) {
this.scrollToBottom();
}
},
continueInterruptedResponse() {
if (!ws || ws.readyState !== WebSocket.OPEN) {
this.connectWebSocket();
}
if (ws && ws.readyState === WebSocket.OPEN) {
const d = {
bot_id: "default_bot",
user_id: currentUserId,
session_id: currentSessionId,
channel: "web",
content: "continue",
message_type: 3,
media_url: null,
timestamp: new Date().toISOString()
};
ws.send(JSON.stringify(d));
}
document.querySelectorAll(".continue-button").forEach(b => { b.parentElement.parentElement.parentElement.remove(); });
},
addMessage(role, content, streaming = false, msgId = null) {
const m = document.createElement("div");
m.className = "message-container";
if (role === "user") {
m.innerHTML = `<div class="user-message"><div class="user-message-content">${this.escapeHtml(content)}</div></div>`;
this.updateContextUsage(contextUsage + .05);
} else if (role === "assistant") {
m.innerHTML = `<div class="assistant-message"><div class="assistant-avatar"></div><div class="assistant-message-content markdown-content" id="${msgId || ""}">${streaming ? "" : marked.parse(content)}</div></div>`;
this.updateContextUsage(contextUsage + .03);
} else if (role === "voice") {
m.innerHTML = `<div class="assistant-message"><div class="assistant-avatar">🎤</div><div class="assistant-message-content">${content}</div></div>`;
} else {
m.innerHTML = `<div class="assistant-message"><div class="assistant-avatar"></div><div class="assistant-message-content">${content}</div></div>`;
}
messagesDiv.appendChild(m);
gsap.to(m, { opacity: 1, y: 0, duration: .5, ease: "power2.out" });
if (!isUserScrolling) {
this.scrollToBottom();
}
},
updateStreamingMessage(c) {
const m = document.getElementById(streamingMessageId);
if (m) {
m.innerHTML = marked.parse(c);
if (!isUserScrolling) {
this.scrollToBottom();
}
}
},
finalizeStreamingMessage() {
const m = document.getElementById(streamingMessageId);
if (m) {
m.innerHTML = marked.parse(currentStreamingContent);
m.removeAttribute("id");
if (!isUserScrolling) {
this.scrollToBottom();
}
}
},
escapeHtml(t) {
const d = document.createElement("div");
d.textContent = t;
return d.innerHTML;
},
clearSuggestions() {
suggestionsContainer.innerHTML = '';
},
handleSuggestions(s) {
const uniqueSuggestions = s.filter((v, i, a) => i === a.findIndex(t => t.text === v.text && t.context === v.context));
suggestionsContainer.innerHTML = '';
uniqueSuggestions.forEach(v => {
const b = document.createElement('button');
b.textContent = v.text;
b.className = 'suggestion-button';
b.onclick = () => { this.setContext(v.context); input.value = ''; };
suggestionsContainer.appendChild(b);
});
},
async setContext(c) {
try {
const t = event?.target?.textContent || c;
this.addMessage("user", t);
input.value = '';
if (ws && ws.readyState === WebSocket.OPEN) {
pendingContextChange = new Promise(r => {
const h = e => {
const d = JSON.parse(e.data);
if (d.message_type === 5 && d.context_name === c) {
ws.removeEventListener('message', h);
r();
}
};
ws.addEventListener('message', h);
const s = { bot_id: currentBotId, user_id: currentUserId, session_id: currentSessionId, channel: "web", content: t, message_type: 4, is_suggestion: true, context_name: c, timestamp: new Date().toISOString() };
ws.send(JSON.stringify(s));
});
await pendingContextChange;
const x = document.getElementById('contextIndicator');
if (x) { document.getElementById('contextPercentage').textContent = c; }
} else {
console.warn("WebSocket não está conectado. Tentando reconectar...");
this.connectWebSocket();
}
} catch (err) {
console.error('Failed to set context:', err);
}
},
async sendMessage() {
if (pendingContextChange) {
await pendingContextChange;
pendingContextChange = null;
}
const m = input.value.trim();
if (!m || !ws || ws.readyState !== WebSocket.OPEN) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
this.showWarning("Conexão não disponível. Tentando reconectar...");
this.connectWebSocket();
}
return;
}
if (isThinking) {
this.hideThinkingIndicator();
}
this.addMessage("user", m);
const d = { bot_id: currentBotId, user_id: currentUserId, session_id: currentSessionId, channel: "web", content: m, message_type: 1, media_url: null, timestamp: new Date().toISOString() };
ws.send(JSON.stringify(d));
input.value = "";
input.focus();
},
async toggleVoiceMode() {
isVoiceMode = !isVoiceMode;
const v = document.getElementById("voiceToggle");
if (isVoiceMode) {
v.textContent = "🔴 Stop Voice";
v.classList.add("recording");
await this.startVoiceSession();
} else {
v.textContent = "🎤 Voice Mode";
v.classList.remove("recording");
await this.stopVoiceSession();
}
},
async startVoiceSession() {
if (!currentSessionId) return;
try {
const r = await fetch("http://localhost:8080/api/voice/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: currentSessionId, user_id: currentUserId })
});
const d = await r.json();
if (d.token) {
await this.connectToVoiceRoom(d.token);
this.startVoiceRecording();
}
} catch (e) {
console.error("Failed to start voice session:", e);
this.showWarning("Falha ao iniciar modo de voz");
}
},
async stopVoiceSession() {
if (!currentSessionId) return;
try {
await fetch("http://localhost:8080/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 (e) {
console.error("Failed to stop voice session:", e);
}
},
async connectToVoiceRoom(t) {
try {
const r = new LiveKitClient.Room();
const p = "ws:", u = `${p}//localhost:8080/voice`;
await r.connect(u, t);
voiceRoom = r;
r.on("dataReceived", d => {
const dc = new TextDecoder(), m = dc.decode(d);
try {
const j = JSON.parse(m);
if (j.type === "voice_response") {
this.addMessage("assistant", j.text);
}
} catch (e) {
console.log("Voice data:", m);
}
});
const l = await LiveKitClient.createLocalTracks({ audio: true, video: false });
for (const k of l) {
await r.localParticipant.publishTrack(k);
}
} catch (e) {
console.error("Failed to connect to voice room:", e);
this.showWarning("Falha na conexão de voz");
}
},
startVoiceRecording() {
if (!navigator.mediaDevices) {
console.log("Media devices not supported");
return;
}
navigator.mediaDevices.getUserMedia({ audio: true }).then(s => {
mediaRecorder = new MediaRecorder(s);
audioChunks = [];
mediaRecorder.ondataavailable = e => { audioChunks.push(e.data); };
mediaRecorder.onstop = () => { const a = new Blob(audioChunks, { type: "audio/wav" }); this.simulateVoiceTranscription(); };
mediaRecorder.start();
setTimeout(() => {
if (mediaRecorder && mediaRecorder.state === "recording") {
mediaRecorder.stop();
setTimeout(() => {
if (isVoiceMode) {
this.startVoiceRecording();
}
}, 1000);
}
}, 5000);
}).catch(e => {
console.error("Error accessing microphone:", e);
this.showWarning("Erro ao acessar microfone");
});
},
simulateVoiceTranscription() {
const p = ["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 r = p[Math.floor(Math.random() * p.length)];
if (voiceRoom) {
const m = { type: "voice_input", content: r, timestamp: new Date().toISOString() };
voiceRoom.localParticipant.publishData(new TextEncoder().encode(JSON.stringify(m)), LiveKitClient.DataPacketKind.RELIABLE);
}
this.addMessage("voice", `🎤 ${r}`);
},
scrollToBottom() {
messagesDiv.scrollTop = messagesDiv.scrollHeight;
isUserScrolling = false;
scrollToBottomBtn.classList.remove('visible');
}
};
}
// Initialize the app
chatApp().init();