diff --git a/src/main.rs b/src/main.rs index e112e3b8..a406e088 100644 --- a/src/main.rs +++ b/src/main.rs @@ -137,6 +137,7 @@ async fn run_axum_server( // Static file services must come first to match before other routes .nest_service("/js", ServeDir::new(static_path.join("js"))) .nest_service("/css", ServeDir::new(static_path.join("css"))) + .nest_service("/public", ServeDir::new(static_path.join("public"))) .nest_service("/drive", ServeDir::new(static_path.join("drive"))) .nest_service("/chat", ServeDir::new(static_path.join("chat"))) .nest_service("/mail", ServeDir::new(static_path.join("mail"))) diff --git a/src/web_server/mod.rs b/src/web_server/mod.rs index d1781e76..3abea842 100644 --- a/src/web_server/mod.rs +++ b/src/web_server/mod.rs @@ -1,19 +1,23 @@ use axum::{ - Router, - routing::get, - response::{Html, IntoResponse}, http::StatusCode, + response::{Html, IntoResponse}, + routing::get, + Router, }; -use tower_http::services::ServeDir; use log::error; use std::{fs, path::PathBuf}; +use tower_http::services::ServeDir; pub async fn index() -> impl IntoResponse { match fs::read_to_string("web/desktop/index.html") { Ok(html) => (StatusCode::OK, [("content-type", "text/html")], Html(html)), Err(e) => { error!("Failed to load index page: {}", e); - (StatusCode::INTERNAL_SERVER_ERROR, [("content-type", "text/plain")], Html("Failed to load index page".to_string())) + ( + StatusCode::INTERNAL_SERVER_ERROR, + [("content-type", "text/plain")], + Html("Failed to load index page".to_string()), + ) } } } @@ -26,14 +30,17 @@ pub fn configure_router() -> Router { .nest_service("/js", ServeDir::new(static_path.join("js"))) // Serve CSS files .nest_service("/css", ServeDir::new(static_path.join("css"))) + // Serve public assets (themes, etc.) + .nest_service("/public", ServeDir::new(static_path.join("public"))) .nest_service("/drive", ServeDir::new(static_path.join("drive"))) .nest_service("/chat", ServeDir::new(static_path.join("chat"))) .nest_service("/mail", ServeDir::new(static_path.join("mail"))) .nest_service("/tasks", ServeDir::new(static_path.join("tasks"))) // Fallback: serve static files and index.html for SPA routing .fallback_service( - ServeDir::new(static_path.clone()) - .fallback(ServeDir::new(static_path.clone()).append_index_html_on_directories(true)) + ServeDir::new(static_path.clone()).fallback( + ServeDir::new(static_path.clone()).append_index_html_on_directories(true), + ), ) .route("/", get(index)) } diff --git a/web/desktop/chat/chat.js b/web/desktop/chat/chat.js index b54e5c69..7a059c07 100644 --- a/web/desktop/chat/chat.js +++ b/web/desktop/chat/chat.js @@ -177,7 +177,7 @@ function chatApp() { } isInitialized = true; - window.addEventListener("load", () => { + const initializeDOM = () => { // Assign DOM elements after the document is ready messagesDiv = document.getElementById("messages"); @@ -241,7 +241,15 @@ function chatApp() { // Initialize auth only once this.initializeAuth(); - }); + }; + + // Check if DOM is already loaded (for dynamic script loading) + if (document.readyState === "loading") { + window.addEventListener("load", initializeDOM); + } else { + // DOM is already loaded, initialize immediately + initializeDOM(); + } }, flashScreen() { @@ -971,8 +979,32 @@ function chatApp() { return returnValue; } -// Initialize the app -chatApp().init(); +// Initialize the app - expose globally for dynamic loading +window.chatAppInstance = chatApp(); + +// Auto-initialize if we're already on the chat section +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => { + const hash = window.location.hash.substring(1); + if (hash === "chat" || hash === "" || !hash) { + window.chatAppInstance.init(); + } + }); +} else { + // If script is loaded dynamically, section-shown event will trigger init + const chatSection = document.getElementById("section-chat"); + if (chatSection) { + window.chatAppInstance.init(); + } +} + +// Listen for section being shown +document.addEventListener("section-shown", function (e) { + if (e.target.id === "section-chat" && window.chatAppInstance) { + console.log("Chat section shown, initializing..."); + window.chatAppInstance.init(); + } +}); // Listen for section changes to cleanup when leaving chat document.addEventListener("section-hidden", function (e) { diff --git a/web/desktop/chat/chat.js.backup b/web/desktop/chat/chat.js.backup new file mode 100644 index 00000000..b54e5c69 --- /dev/null +++ b/web/desktop/chat/chat.js.backup @@ -0,0 +1,986 @@ +// Singleton instance to prevent multiple initializations +let chatAppInstance = null; + +function chatApp() { + // Return existing instance if already created + if (chatAppInstance) { + console.log("Returning existing chatApp instance"); + return chatAppInstance; + } + + console.log("Creating new chatApp instance"); + + // Core state variables (shared via closure) + let ws = null, + pendingContextChange = null, + o, + isConnecting = false, + isInitialized = false, + authPromise = 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, + messageInputEl, + sendBtn, + voiceBtn, + connectionStatus, + flashOverlay, + suggestionsContainer, + floatLogo, + sidebar, + themeBtn, + scrollToBottomBtn, + 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 Stand‑up", + 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() { + // Prevent multiple initializations + if (isInitialized) { + console.log("Already initialized, skipping..."); + return; + } + isInitialized = true; + + window.addEventListener("load", () => { + // Assign DOM elements after the document is ready + messagesDiv = document.getElementById("messages"); + + messageInputEl = 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"); + 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(); + } + }); + if (messageInputEl) { + messageInputEl.focus(); + } + + // UI event listeners + document.addEventListener("click", (e) => {}); + + // Scroll detection + if (messagesDiv && scrollToBottomBtn) { + 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(); + messageInputEl.addEventListener("keypress", (e) => { + if (e.key === "Enter") this.sendMessage(); + }); + + // Don't auto-reconnect on focus in browser to prevent multiple connections + // Tauri doesn't fire focus events the same way + + // Initialize auth only once + this.initializeAuth(); + }); + }, + + 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() { + // Return existing promise if auth is in progress + if (authPromise) { + console.log("Auth already in progress, waiting..."); + return authPromise; + } + + // Already authenticated + if ( + currentSessionId && + currentUserId && + ws && + ws.readyState === WebSocket.OPEN + ) { + console.log("Already authenticated and connected"); + return; + } + + // Create auth promise to prevent concurrent calls + authPromise = (async () => { + 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; + console.log("Auth successful:", { currentUserId, currentSessionId }); + this.connectWebSocket(); + } catch (e) { + console.error("Failed to initialize auth:", e); + this.updateConnectionStatus("disconnected"); + authPromise = null; + setTimeout(() => this.initializeAuth(), 3000); + } finally { + authPromise = null; + } + })(); + + return authPromise; + }, + + 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(); + 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.connectWebSocket(); + if (isVoiceMode) { + this.startVoiceSession(); + } + sidebar.classList.remove("open"); + }, + + connectWebSocket() { + // Prevent multiple simultaneous connection attempts + if (isConnecting) { + console.log("Already connecting to WebSocket, skipping..."); + return; + } + if ( + ws && + (ws.readyState === WebSocket.OPEN || + ws.readyState === WebSocket.CONNECTING) + ) { + console.log("WebSocket already connected or connecting"); + return; + } + if (ws && ws.readyState !== WebSocket.CLOSED) { + ws.close(); + } + clearTimeout(reconnectTimeout); + isConnecting = true; + + const u = this.getWebSocketUrl(); + console.log("Connecting to WebSocket:", u); + ws = new WebSocket(u); + ws.onmessage = (e) => { + const r = JSON.parse(e.data); + + // Filter out welcome/connection messages that aren't BotResponse + if (r.type === "connected" || !r.message_type) { + console.log("Ignoring non-message:", r); + return; + } + + if (r.bot_id) { + currentBotId = r.bot_id; + } + // Message type 2 is a bot response (not an event) + // Message type 5 is context change + if (r.message_type === 5) { + isContextChange = true; + return; + } + // Check if this is a special event message (has event field) + if (r.event) { + this.handleEvent(r.event, r.data || {}); + return; + } + this.processMessageContent(r); + }; + ws.onopen = () => { + console.log("Connected to WebSocket"); + isConnecting = false; + this.updateConnectionStatus("connected"); + reconnectAttempts = 0; + hasReceivedInitialMessage = false; + }; + ws.onclose = (e) => { + console.log("WebSocket disconnected:", e.code, e.reason); + isConnecting = false; + 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); + isConnecting = false; + this.updateConnectionStatus("disconnected"); + }; + }, + + processMessageContent(r) { + if (isContextChange) { + isContextChange = false; + return; + } + + // Ignore messages without content + if (!r.content && r.is_complete !== true) { + return; + } + + 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 if (r.content) { + // Only add message if there's actual content + 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": + // Context usage removed + 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 = `
`; + messagesDiv.appendChild(t); + gsap.to(t, { opacity: 1, y: 0, duration: 0.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: 0.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: 0.4, ease: "power2.out" }); + if (!isUserScrolling) { + this.scrollToBottom(); + } + setTimeout(() => { + if (w.parentNode) { + gsap.to(w, { + opacity: 0, + duration: 0.3, + onComplete: () => w.remove(), + }); + } + }, 5000); + }, + + showContinueButton() { + const c = document.createElement("div"); + c.className = "message-container"; + c.innerHTML = ``; + messagesDiv.appendChild(c); + gsap.to(c, { opacity: 1, y: 0, duration: 0.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 = ``; + } else if (role === "assistant") { + m.innerHTML = ``; + } else if (role === "voice") { + m.innerHTML = ``; + } else { + m.innerHTML = ``; + } + messagesDiv.appendChild(m); + gsap.to(m, { opacity: 1, y: 0, duration: 0.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); + messageInputEl.value = ""; + }; + suggestionsContainer.appendChild(b); + }); + }, + + async setContext(c) { + try { + const t = event?.target?.textContent || c; + this.addMessage("user", t); + messageInputEl.value = ""; + messageInputEl.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; + } 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 = messageInputEl.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)); + messageInputEl.value = ""; + messageInputEl.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() { + if (messagesDiv) { + messagesDiv.scrollTop = messagesDiv.scrollHeight; + isUserScrolling = false; + if (scrollToBottomBtn) { + scrollToBottomBtn.classList.remove("visible"); + } + } + }, + }; + + const returnValue = { + init: init, + current: current, + search: search, + selectedChat: selectedChat, + navItems: navItems, + chats: chats, + get filteredChats() { + return chats.filter((chat) => + chat.name.toLowerCase().includes(search.toLowerCase()), + ); + }, + toggleSidebar: toggleSidebar, + toggleTheme: toggleTheme, + applyTheme: applyTheme, + flashScreen: flashScreen, + updateConnectionStatus: updateConnectionStatus, + getWebSocketUrl: getWebSocketUrl, + initializeAuth: initializeAuth, + loadSessions: loadSessions, + createNewSession: createNewSession, + switchSession: switchSession, + connectWebSocket: connectWebSocket, + processMessageContent: processMessageContent, + handleEvent: handleEvent, + showThinkingIndicator: showThinkingIndicator, + hideThinkingIndicator: hideThinkingIndicator, + showWarning: showWarning, + showContinueButton: showContinueButton, + continueInterruptedResponse: continueInterruptedResponse, + addMessage: addMessage, + updateStreamingMessage: updateStreamingMessage, + finalizeStreamingMessage: finalizeStreamingMessage, + escapeHtml: escapeHtml, + clearSuggestions: clearSuggestions, + handleSuggestions: handleSuggestions, + setContext: setContext, + sendMessage: sendMessage, + toggleVoiceMode: toggleVoiceMode, + startVoiceSession: startVoiceSession, + stopVoiceSession: stopVoiceSession, + connectToVoiceRoom: connectToVoiceRoom, + startVoiceRecording: startVoiceRecording, + simulateVoiceTranscription: simulateVoiceTranscription, + scrollToBottom: scrollToBottom, + cleanup: function () { + // Cleanup WebSocket connection + if (ws) { + ws.close(); + ws = null; + } + // Clear any pending timeouts/intervals + isConnecting = false; + isInitialized = false; + }, + }; + + // Cache and return the singleton instance + chatAppInstance = returnValue; + return returnValue; +} + +// Initialize the app +chatApp().init(); + +// Listen for section changes to cleanup when leaving chat +document.addEventListener("section-hidden", function (e) { + if ( + e.target.id === "section-chat" && + chatAppInstance && + chatAppInstance.cleanup + ) { + chatAppInstance.cleanup(); + } +}); diff --git a/web/desktop/css/app.css b/web/desktop/css/app.css index fb0000ab..eaa41db1 100644 --- a/web/desktop/css/app.css +++ b/web/desktop/css/app.css @@ -1,324 +1,526 @@ -* { margin: 0; padding: 0; box-sizing: border-box; } +/* App-specific styles for General Bots Desktop */ +/* All modules (chat, drive, tasks, mail) use these unified theme variables */ + +@import url("global.css"); + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; - background: #0f172a; - color: #e2e8f0; - height: 100vh; - overflow: hidden; -} - -/* Navbar */ -nav { - background: #1e293b; - border-bottom: 2px solid #334155; - padding: 0 1rem; - display: flex; - align-items: center; - height: 60px; - gap: 0.5rem; -} - -nav .logo { - font-size: 1.5rem; - font-weight: bold; - background: linear-gradient(135deg, #3b82f6, #8b5cf6); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - margin-right: auto; -} - -nav a { - color: #94a3b8; - text-decoration: none; - padding: 0.75rem 1.25rem; - border-radius: 0.5rem; - transition: all 0.2s; - font-weight: 500; -} - -nav a:hover { - background: #334155; - color: #e2e8f0; -} - -nav a.active { - background: #3b82f6; - color: white; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, + sans-serif; + background: var(--primary-bg); + color: var(--primary-fg); + height: 100vh; + overflow: hidden; + transition: + background var(--transition-smooth), + color var(--transition-smooth); } /* Main Content */ #main-content { - height: calc(100vh - 60px); - overflow: hidden; + height: 100vh; + overflow: hidden; + position: relative; } .content-section { - display: none; - height: 100%; - overflow: auto; + display: none; + height: 100%; + overflow: auto; } .content-section.active { - display: block; + display: block; } -/* Panel Styles */ +/* Panel Styles - used across all modules */ .panel { - background: #1e293b; - border: 1px solid #334155; - border-radius: 0.5rem; + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + padding: var(--space-lg); + box-shadow: var(--shadow-sm); + backdrop-filter: blur(20px); } -/* Drive Styles */ +.panel:hover { + box-shadow: var(--shadow-md); +} + +/* Drive Layout */ .drive-layout { - display: grid; - grid-template-columns: 250px 1fr 300px; - gap: 1rem; - padding: 1rem; - height: 100%; + display: grid; + grid-template-columns: 250px 1fr 300px; + gap: var(--space-md); + padding: var(--space-lg); + height: 100%; + background: var(--primary-bg); } -.drive-sidebar, .drive-details { - overflow-y: auto; +.drive-sidebar, +.drive-details { + overflow-y: auto; + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + padding: var(--space-md); + backdrop-filter: blur(20px); } .drive-main { - display: flex; - flex-direction: column; - overflow: hidden; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + backdrop-filter: blur(20px); } .nav-item { - padding: 0.75rem 1rem; - display: flex; - align-items: center; - gap: 0.75rem; - cursor: pointer; - border-radius: 0.375rem; - margin: 0.25rem 0.5rem; - transition: background 0.2s; + padding: 12px 16px; + display: flex; + align-items: center; + gap: 12px; + cursor: pointer; + border-radius: var(--radius-md); + margin: 4px 0; + transition: all var(--transition-fast); + color: var(--text-primary); + font-weight: 500; } .nav-item:hover { - background: #334155; + background: var(--bg-hover); + transform: translateX(2px); } .nav-item.active { - background: #3b82f6; + background: var(--accent-color); + color: white; } .file-list { - flex: 1; - overflow-y: auto; - padding: 1rem; + flex: 1; + overflow-y: auto; + padding: var(--space-md); } .file-item { - padding: 1rem; - display: flex; - align-items: center; - gap: 1rem; - cursor: pointer; - border-radius: 0.375rem; - border-bottom: 1px solid #334155; - transition: background 0.2s; + padding: var(--space-md); + display: flex; + align-items: center; + gap: var(--space-md); + cursor: pointer; + border-radius: var(--radius-md); + border-bottom: 1px solid var(--border-light); + transition: all var(--transition-fast); + color: var(--text-primary); } .file-item:hover { - background: #334155; + background: var(--bg-hover); + transform: translateX(2px); } .file-item.selected { - background: #1e40af; + background: var(--accent-light); + border-color: var(--accent-color); } .file-icon { - font-size: 2rem; + font-size: 32px; } -/* Tasks Styles */ +/* Tasks Layout */ .tasks-container { - max-width: 800px; - margin: 0 auto; - padding: 2rem; + max-width: 800px; + margin: 0 auto; + padding: var(--space-xl); + padding-top: 80px; } .task-input { - display: flex; - gap: 0.5rem; - margin-bottom: 2rem; + display: flex; + gap: var(--space-sm); + margin-bottom: var(--space-xl); } .task-input input { - flex: 1; - padding: 0.75rem; - background: #1e293b; - border: 1px solid #334155; - border-radius: 0.5rem; - color: #e2e8f0; - font-size: 1rem; + flex: 1; + padding: 12px 20px; + background: var(--glass-bg); + border: 2px solid var(--border-color); + border-radius: var(--radius-lg); + color: var(--text-primary); + font-size: 15px; + transition: all var(--transition-fast); + backdrop-filter: blur(10px); } .task-input input:focus { - outline: none; - border-color: #3b82f6; + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 4px var(--accent-light); +} + +.task-input input::placeholder { + color: var(--text-tertiary); } .task-input button { - padding: 0.75rem 1.5rem; - background: #3b82f6; - color: white; - border: none; - border-radius: 0.5rem; - cursor: pointer; - font-weight: 600; - transition: background 0.2s; + padding: 12px 24px; + background: var(--accent-color); + color: white; + border: none; + border-radius: var(--radius-lg); + cursor: pointer; + font-weight: 600; + font-size: 15px; + transition: all var(--transition-fast); + box-shadow: var(--shadow-sm); } .task-input button:hover { - background: #2563eb; + background: var(--accent-hover); + transform: translateY(-2px); + box-shadow: var(--shadow-md); } .task-list { - list-style: none; + list-style: none; } .task-item { - padding: 1rem; - display: flex; - align-items: center; - gap: 1rem; - background: #1e293b; - border: 1px solid #334155; - border-radius: 0.5rem; - margin-bottom: 0.5rem; + padding: var(--space-md); + display: flex; + align-items: center; + gap: var(--space-md); + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + margin-bottom: var(--space-sm); + transition: all var(--transition-fast); + backdrop-filter: blur(20px); +} + +.task-item:hover { + transform: translateX(4px); + box-shadow: var(--shadow-sm); } .task-item.completed span { - text-decoration: line-through; - opacity: 0.5; + text-decoration: line-through; + opacity: 0.5; } .task-item input[type="checkbox"] { - width: 1.25rem; - height: 1.25rem; - cursor: pointer; + width: 20px; + height: 20px; + cursor: pointer; + accent-color: var(--accent-color); } .task-item span { - flex: 1; + flex: 1; + color: var(--text-primary); + font-size: 15px; } .task-item button { - background: #ef4444; - color: white; - border: none; - padding: 0.5rem 0.75rem; - border-radius: 0.375rem; - cursor: pointer; - transition: background 0.2s; + background: var(--error-color); + color: white; + border: none; + padding: 8px 16px; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--transition-fast); + font-weight: 500; } .task-item button:hover { - background: #dc2626; + opacity: 0.9; + transform: scale(1.05); } .task-filters { - display: flex; - gap: 0.5rem; - margin-top: 2rem; - padding-top: 2rem; - border-top: 1px solid #334155; + display: flex; + gap: var(--space-sm); + margin-top: var(--space-xl); + padding-top: var(--space-xl); + border-top: 1px solid var(--border-color); } .task-filters button { - padding: 0.5rem 1rem; - background: #334155; - color: #e2e8f0; - border: none; - border-radius: 0.375rem; - cursor: pointer; - transition: all 0.2s; + padding: 10px 20px; + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-fast); + font-weight: 500; +} + +.task-filters button:hover { + background: var(--bg-hover); + transform: translateY(-2px); } .task-filters button.active { - background: #3b82f6; + background: var(--accent-color); + color: white; + border-color: var(--accent-color); } -/* Mail Styles */ +/* Mail Layout */ .mail-layout { - display: grid; - grid-template-columns: 250px 350px 1fr; - gap: 1rem; - padding: 1rem; - height: 100%; + display: grid; + grid-template-columns: 250px 350px 1fr; + gap: var(--space-md); + padding: var(--space-lg); + height: 100%; + background: var(--primary-bg); } -.mail-sidebar, .mail-list, .mail-content { - overflow-y: auto; +.mail-sidebar, +.mail-list, +.mail-content { + overflow-y: auto; + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + padding: var(--space-md); + backdrop-filter: blur(20px); } .mail-item { - padding: 1rem; - cursor: pointer; - border-bottom: 1px solid #334155; - transition: background 0.2s; + padding: var(--space-md); + cursor: pointer; + border-bottom: 1px solid var(--border-light); + transition: all var(--transition-fast); + border-radius: var(--radius-sm); + margin-bottom: var(--space-xs); + color: var(--text-primary); } .mail-item:hover { - background: #334155; + background: var(--bg-hover); + transform: translateX(2px); } .mail-item.unread { - font-weight: 600; + font-weight: 600; } .mail-item.selected { - background: #1e40af; + background: var(--accent-light); + border-color: var(--accent-color); } .mail-content-view { - padding: 2rem; + padding: var(--space-lg); } .mail-header { - margin-bottom: 2rem; - padding-bottom: 1rem; - border-bottom: 1px solid #334155; + margin-bottom: var(--space-lg); + padding-bottom: var(--space-md); + border-bottom: 1px solid var(--border-color); +} + +.mail-header h2 { + color: var(--text-primary); + margin-bottom: var(--space-sm); +} + +.mail-header .text-sm { + color: var(--text-secondary); } .mail-body { - line-height: 1.6; + line-height: 1.7; + color: var(--text-primary); } -/* Buttons */ -button { - font-family: inherit; +/* Responsive adjustments for all modules */ +@media (max-width: 1024px) { + .drive-layout { + grid-template-columns: 200px 1fr; + } + + .drive-details { + display: none; + } + + .mail-layout { + grid-template-columns: 200px 300px 1fr; + } } -button:disabled { - opacity: 0.5; - cursor: not-allowed; +@media (max-width: 768px) { + .drive-layout { + grid-template-columns: 1fr; + padding: var(--space-md); + } + + .drive-sidebar, + .drive-details { + display: none; + } + + .mail-layout { + grid-template-columns: 1fr; + padding: var(--space-md); + } + + .mail-sidebar, + .mail-list { + display: none; + } + + .tasks-container { + padding: var(--space-lg); + padding-top: 80px; + } + + .task-input { + flex-direction: column; + } + + .task-input button { + width: 100%; + } } -/* Utility */ -h1, h2, h3 { - margin-bottom: 1rem; +@media (max-width: 480px) { + .tasks-container { + padding: var(--space-md); + padding-top: 70px; + } + + .task-item { + padding: var(--space-sm); + font-size: 14px; + } + + .task-filters { + flex-wrap: wrap; + } } -.text-sm { - font-size: 0.875rem; +/* Common utility classes for all modules */ +.module-header { + padding: var(--space-lg); + border-bottom: 1px solid var(--border-color); + background: var(--glass-bg); + backdrop-filter: blur(20px); } -.text-xs { - font-size: 0.75rem; +.module-header h1 { + color: var(--text-primary); + font-size: 24px; + font-weight: 600; } -.text-gray { - color: #94a3b8; +.module-toolbar { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-md); + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); } -[x-cloak] { - display: none !important; +.module-toolbar button { + padding: 8px 16px; + background: var(--bg); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-fast); + font-weight: 500; +} + +.module-toolbar button:hover { + background: var(--bg-hover); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.module-toolbar button.primary { + background: var(--accent-color); + color: white; + border-color: var(--accent-color); +} + +.module-toolbar button.primary:hover { + background: var(--accent-hover); +} + +/* Empty state for all modules */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: var(--space-xl); + text-align: center; + color: var(--text-secondary); +} + +.empty-state-icon { + font-size: 64px; + margin-bottom: var(--space-md); + opacity: 0.5; +} + +.empty-state-title { + font-size: 20px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: var(--space-sm); +} + +.empty-state-description { + font-size: 14px; + color: var(--text-secondary); + max-width: 400px; +} + +/* Animation classes */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.fade-in { + animation: fadeIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(-20px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.slide-in { + animation: slideIn 0.3s ease-out; } diff --git a/web/desktop/index.html b/web/desktop/index.html index ad3716f3..6c7f6d46 100644 --- a/web/desktop/index.html +++ b/web/desktop/index.html @@ -295,18 +295,35 @@ + + +