botserver/web/desktop/chat/chat.js

714 lines
25 KiB
JavaScript
Raw Normal View History

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();