From 9c5d38b60e93f366d55b82ab95d0c6444ab47f6b Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Wed, 12 Nov 2025 15:04:04 -0300 Subject: [PATCH] feat(automation): increase schedule field size and improve task checking - Increased schedule field size from bpchar(12) to bpchar(20) in database schema - Reduced task checking interval from 60s to 5s for more responsive automation - Improved error handling for schedule parsing and execution - Added proper error logging for automation failures - Changed automation execution to use bot_id instead of nil UUID - Enhanced HEAR keyword functionality (partial diff shown) --- migrations/6.0.0_initial_schema/up.sql | 2 +- src/automation/mod.rs | 40 +- src/basic/keywords/hear_talk.rs | 229 +- web/html/index.html | 2989 ++++++++++++++---------- 4 files changed, 1886 insertions(+), 1374 deletions(-) diff --git a/migrations/6.0.0_initial_schema/up.sql b/migrations/6.0.0_initial_schema/up.sql index ddb948d19..f33506c16 100644 --- a/migrations/6.0.0_initial_schema/up.sql +++ b/migrations/6.0.0_initial_schema/up.sql @@ -58,7 +58,7 @@ CREATE TABLE public.system_automations ( bot_id uuid NOT NULL, kind int4 NOT NULL, "target" varchar(32) NULL, - schedule bpchar(12) NULL, + schedule bpchar(20) NULL, param varchar(32) NOT NULL, is_active bool DEFAULT true NOT NULL, last_triggered timestamptz NULL, diff --git a/src/automation/mod.rs b/src/automation/mod.rs index ebedd795b..c659919b7 100644 --- a/src/automation/mod.rs +++ b/src/automation/mod.rs @@ -18,7 +18,7 @@ impl AutomationService { Self { state } } pub async fn spawn(self) -> Result<(), Box> { - let mut ticker = interval(Duration::from_secs(60)); + let mut ticker = interval(Duration::from_secs(5)); loop { ticker.tick().await; if let Err(e) = self.check_scheduled_tasks().await { @@ -41,23 +41,33 @@ impl AutomationService { .load::(&mut conn)?; for automation in automations { if let Some(schedule_str) = &automation.schedule { - if let Ok(parsed_schedule) = Schedule::from_str(schedule_str) { - let now = Utc::now(); - let next_run = parsed_schedule.upcoming(Utc).next(); - if let Some(next_time) = next_run { - let time_until_next = next_time - now; - if time_until_next.num_minutes() < 1 { - if let Some(last_triggered) = automation.last_triggered { - if (now - last_triggered).num_minutes() < 1 { - continue; + match Schedule::from_str(schedule_str.trim()) { + Ok(parsed_schedule) => { + let now = Utc::now(); + let next_run = parsed_schedule.upcoming(Utc).next(); + if let Some(next_time) = next_run { + let time_until_next = next_time - now; + if time_until_next.num_minutes() < 1 { + if let Some(last_triggered) = automation.last_triggered { + if (now - last_triggered).num_minutes() < 1 { + continue; + } + } + if let Err(e) = self.execute_automation(&automation).await { + error!("Error executing automation {}: {}", automation.id, e); + } + if let Err(e) = diesel::update(system_automations.filter(id.eq(automation.id))) + .set(lt_column.eq(Some(now))) + .execute(&mut conn) + { + error!("Error updating last_triggered for automation {}: {}", automation.id, e); } } - self.execute_automation(&automation).await?; - diesel::update(system_automations.filter(id.eq(automation.id))) - .set(lt_column.eq(Some(now))) - .execute(&mut conn)?; } } + Err(e) => { + error!("Error parsing schedule for automation {} ({}): {}", automation.id, schedule_str, e); + } } } } @@ -91,7 +101,7 @@ impl AutomationService { }; let session = { let mut sm = self.state.session_manager.lock().await; - let admin_user = uuid::Uuid::nil(); + let admin_user = automation.bot_id; sm.get_or_create_user_session(admin_user, automation.bot_id, "Automation")? .ok_or("Failed to create session")? }; diff --git a/src/basic/keywords/hear_talk.rs b/src/basic/keywords/hear_talk.rs index bfbfcabc6..07155305e 100644 --- a/src/basic/keywords/hear_talk.rs +++ b/src/basic/keywords/hear_talk.rs @@ -4,105 +4,138 @@ use log::{error, trace}; use rhai::{Dynamic, Engine, EvalAltResult}; use std::sync::Arc; pub fn hear_keyword(state: Arc, user: UserSession, engine: &mut Engine) { - let session_id = user.id; - let state_clone = Arc::clone(&state); - engine - .register_custom_syntax(&["HEAR", "$ident$"], true, move |_context, inputs| { - let variable_name = inputs[0].get_string_value().expect("Expected identifier as string").to_string(); - trace!("HEAR command waiting for user input to store in variable: {}", variable_name); - let state_for_spawn = Arc::clone(&state_clone); - let session_id_clone = session_id; - let var_name_clone = variable_name.clone(); - tokio::spawn(async move { - trace!("HEAR: Setting session {} to wait for input for variable '{}'", session_id_clone, var_name_clone); - let mut session_manager = state_for_spawn.session_manager.lock().await; - session_manager.mark_waiting(session_id_clone); - if let Some(redis_client) = &state_for_spawn.cache { - let mut conn = match redis_client.get_multiplexed_async_connection().await { - Ok(conn) => conn, - Err(e) => { - error!("Failed to connect to cache: {}", e); - return; - } - }; - let key = format!("hear:{}:{}", session_id_clone, var_name_clone); - let _: Result<(), _> = redis::cmd("SET").arg(&key).arg("waiting").query_async(&mut conn).await; - } - }); - Err(Box::new(EvalAltResult::ErrorRuntime("Waiting for user input".into(), rhai::Position::NONE))) - }) - .unwrap(); + let session_id = user.id; + let state_clone = Arc::clone(&state); + engine + .register_custom_syntax(&["HEAR", "$ident$"], true, move |_context, inputs| { + let variable_name = inputs[0] + .get_string_value() + .expect("Expected identifier as string") + .to_string(); + trace!( + "HEAR command waiting for user input to store in variable: {}", + variable_name + ); + let state_for_spawn = Arc::clone(&state_clone); + let session_id_clone = session_id; + let var_name_clone = variable_name.clone(); + tokio::spawn(async move { + trace!( + "HEAR: Setting session {} to wait for input for variable '{}'", + session_id_clone, + var_name_clone + ); + let mut session_manager = state_for_spawn.session_manager.lock().await; + session_manager.mark_waiting(session_id_clone); + if let Some(redis_client) = &state_for_spawn.cache { + let mut conn = match redis_client.get_multiplexed_async_connection().await { + Ok(conn) => conn, + Err(e) => { + error!("Failed to connect to cache: {}", e); + return; + } + }; + let key = format!("hear:{}:{}", session_id_clone, var_name_clone); + let _: Result<(), _> = redis::cmd("SET") + .arg(&key) + .arg("waiting") + .query_async(&mut conn) + .await; + } + }); + Err(Box::new(EvalAltResult::ErrorRuntime( + "Waiting for user input".into(), + rhai::Position::NONE, + ))) + }) + .unwrap(); } -pub async fn execute_talk(state: Arc, user_session: UserSession, message: String) -> Result> { - let mut suggestions = Vec::new(); - if let Some(redis_client) = &state.cache { - let mut conn: redis::aio::MultiplexedConnection = redis_client.get_multiplexed_async_connection().await?; - let redis_key = format!("suggestions:{}:{}", user_session.user_id, user_session.id); - let suggestions_json: Result, _> = redis::cmd("LRANGE").arg(redis_key.as_str()).arg(0).arg(-1).query_async(&mut conn).await; - match suggestions_json { - Ok(suggestions_json) => { - suggestions = suggestions_json.into_iter().filter_map(|s| serde_json::from_str(&s).ok()).collect(); - } - Err(e) => { - error!("Failed to load suggestions from Redis: {}", e); - } - } - } - let response = BotResponse { - bot_id: user_session.bot_id.to_string(), - user_id: user_session.user_id.to_string(), - session_id: user_session.id.to_string(), - channel: "web".to_string(), - content: message, - message_type: 1, - stream_token: None, - is_complete: true, - suggestions, - context_name: None, - context_length: 0, - context_max_length: 0, - }; - let user_id = user_session.id.to_string(); - let response_clone = response.clone(); - match state.response_channels.try_lock() { - Ok(response_channels) => { - if let Some(tx) = response_channels.get(&user_id) { - if let Err(e) = tx.try_send(response_clone) { - error!("Failed to send TALK message via WebSocket: {}", e); - } else { - trace!("TALK message sent via WebSocket"); - } - } else { - let web_adapter = Arc::clone(&state.web_adapter); - tokio::spawn(async move { - if let Err(e) = web_adapter.send_message_to_session(&user_id, response_clone).await { - error!("Failed to send TALK message via web adapter: {}", e); - } else { - trace!("TALK message sent via web adapter"); - } - }); - } - } - Err(_) => { - error!("Failed to acquire lock on response_channels for TALK command"); - } - } - Ok(response) +pub async fn execute_talk( + state: Arc, + user_session: UserSession, + message: String, +) -> Result> { + let mut suggestions = Vec::new(); + if let Some(redis_client) = &state.cache { + let mut conn: redis::aio::MultiplexedConnection = + redis_client.get_multiplexed_async_connection().await?; + let redis_key = format!("suggestions:{}:{}", user_session.user_id, user_session.id); + let suggestions_json: Result, _> = redis::cmd("LRANGE") + .arg(redis_key.as_str()) + .arg(0) + .arg(-1) + .query_async(&mut conn) + .await; + match suggestions_json { + Ok(suggestions_json) => { + suggestions = suggestions_json + .into_iter() + .filter_map(|s| serde_json::from_str(&s).ok()) + .collect(); + } + Err(e) => { + error!("Failed to load suggestions from Redis: {}", e); + } + } + } + let response = BotResponse { + bot_id: user_session.bot_id.to_string(), + user_id: user_session.user_id.to_string(), + session_id: user_session.id.to_string(), + channel: "web".to_string(), + content: message, + message_type: 1, + stream_token: None, + is_complete: true, + suggestions, + context_name: None, + context_length: 0, + context_max_length: 0, + }; + let user_id = user_session.id.to_string(); + let response_clone = response.clone(); + match state.response_channels.try_lock() { + Ok(response_channels) => { + if let Some(tx) = response_channels.get(&user_id) { + if let Err(e) = tx.try_send(response_clone) { + error!("Failed to send TALK message via WebSocket: {}", e); + } else { + trace!("TALK message sent via WebSocket"); + } + } else { + let web_adapter = Arc::clone(&state.web_adapter); + tokio::spawn(async move { + if let Err(e) = web_adapter + .send_message_to_session(&user_id, response_clone) + .await + { + error!("Failed to send TALK message via web adapter: {}", e); + } else { + trace!("TALK message sent via web adapter"); + } + }); + } + } + Err(_) => { + error!("Failed to acquire lock on response_channels for TALK command"); + } + } + Ok(response) } pub fn talk_keyword(state: Arc, user: UserSession, engine: &mut Engine) { - let state_clone = Arc::clone(&state); - let user_clone = user.clone(); - engine - .register_custom_syntax(&["TALK", "$expr$"], true, move |context, inputs| { - let message = context.eval_expression_tree(&inputs[0])?.to_string(); - let state_for_talk = Arc::clone(&state_clone); - let user_for_talk = user_clone.clone(); - tokio::spawn(async move { - if let Err(e) = execute_talk(state_for_talk, user_for_talk, message).await { - error!("Error executing TALK command: {}", e); - } - }); - Ok(Dynamic::UNIT) - }) - .unwrap(); + let state_clone = Arc::clone(&state); + let user_clone = user.clone(); + engine + .register_custom_syntax(&["TALK", "$expr$"], true, move |context, inputs| { + let message = context.eval_expression_tree(&inputs[0])?.to_string(); + let state_for_talk = Arc::clone(&state_clone); + let user_for_talk = user_clone.clone(); + tokio::spawn(async move { + if let Err(e) = execute_talk(state_for_talk, user_for_talk, message).await { + error!("Error executing TALK command: {}", e); + } + }); + Ok(Dynamic::UNIT) + }) + .unwrap(); } diff --git a/web/html/index.html b/web/html/index.html index a1ba41040..b2cdee075 100644 --- a/web/html/index.html +++ b/web/html/index.html @@ -1,1268 +1,1737 @@ - + - -General Bots - - - - - + + General Bots + + + + + -
-
-
- - -
- -
-
-
-
- - - -
-
- -
-
Context
-
0%
-
-
- + async function createNewSession() { + try { + // Reset all state variables + isStreaming = false; + streamingMessageId = null; + currentStreamingContent = ""; + isThinking = false; + hasReceivedInitialMessage = false; + contextUsage = 0; + + // Clear any pending operations + if (thinkingTimeout) { + clearTimeout(thinkingTimeout); + thinkingTimeout = null; + } + if (reconnectTimeout) { + clearTimeout(reconnectTimeout); + reconnectTimeout = null; + } + + // Reset UI + messagesDiv.innerHTML = ""; + clearSuggestions(); + sidebar.classList.remove('open'); + + // Reset voice mode if active + if (isVoiceMode) { + await stopVoiceSession(); + isVoiceMode = false; + const voiceToggle = document.getElementById("voiceToggle"); + voiceToggle.textContent = "🎤 Voice Mode"; + voiceBtn.classList.remove("recording"); + } + + // Initialize fresh authenticated session + await initializeSession(); + } catch (e) { + console.error("Failed to create session:", e); + } + } + + function connectWebSocket() { + if (ws) { + ws.close(); + } + + clearTimeout(reconnectTimeout); + + const url = getWebSocketUrl(); + ws = new WebSocket(url); + + ws.onmessage = function(event) { + const response = JSON.parse(event.data); + + if (response.bot_id) { + currentBotId = response.bot_id; + } + + if (response.message_type === 2) { + const data = JSON.parse(response.content); + handleEvent(data.event, data.data); + return; + } + + if (response.message_type === 5) { + isContextChange = true; + return; + } + + processMessageContent(response); + }; + + ws.onopen = function() { + console.log("Connected to WebSocket"); + updateConnectionStatus("connected"); + reconnectAttempts = 0; + hasReceivedInitialMessage = false; + }; + + ws.onclose = function(event) { + console.log("WebSocket disconnected:", event.code, event.reason); + updateConnectionStatus("disconnected"); + + if (isStreaming) { + showContinueButton(); + } + + if (reconnectAttempts < maxReconnectAttempts) { + reconnectAttempts++; + const delay = Math.min(1000 * reconnectAttempts, 10000); + reconnectTimeout = setTimeout(() => { + updateConnectionStatus("connecting"); + connectWebSocket(); + }, delay); + } else { + updateConnectionStatus("disconnected"); + } + }; + + ws.onerror = function(event) { + console.error("WebSocket error:", event); + updateConnectionStatus("disconnected"); + }; + } + + function processMessageContent(response) { + if (isContextChange) { + isContextChange = false; + return; + } + + + if (response.suggestions && response.suggestions.length > 0) { + handleSuggestions(response.suggestions); + } + + if (response.is_complete) { + if (isStreaming) { + finalizeStreamingMessage(); + isStreaming = false; + streamingMessageId = null; + currentStreamingContent = ""; + } else { + addMessage("assistant", response.content, false); + } + } else { + if (!isStreaming) { + isStreaming = true; + streamingMessageId = "streaming-" + Date.now(); + currentStreamingContent = response.content || ""; + // Only add message if we have content + if (currentStreamingContent.trim().length > 0) { + addMessage("assistant", currentStreamingContent, true, streamingMessageId); + } + } else { + currentStreamingContent += response.content || ""; + updateStreamingMessage(currentStreamingContent); + } + } + } + + function handleEvent(type, data) { + console.log("Event received:", type, data); + + switch (type) { + case "thinking_start": + showThinkingIndicator(); + break; + case "thinking_end": + hideThinkingIndicator(); + break; + case "warn": + showWarning(data.message); + break; + case "change_theme": + if (data.color1) themeColor1 = data.color1; + if (data.color2) themeColor2 = data.color2; + if (data.logo_url) customLogoUrl = data.logo_url; + if (data.title) document.title = data.title; + if (data.logo_text) { + sidebarTitle.textContent = data.logo_text; + } + applyTheme(); + break; + } + } + + function showThinkingIndicator() { + if (isThinking) return; + + const thinkingElement = document.createElement("div"); + thinkingElement.id = "thinking-indicator"; + thinkingElement.className = "message-container"; + thinkingElement.innerHTML = ` +
+
+
+
+
+
+
+
+
+
+ `; + + messagesDiv.appendChild(thinkingElement); + gsap.to(thinkingElement, { opacity: 1, y: 0, duration: 0.3, ease: "power2.out" }); + + if (!isUserScrolling) { + scrollToBottom(); + } + + 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 thinkingElement = document.getElementById("thinking-indicator"); + if (thinkingElement) { + gsap.to(thinkingElement, { + opacity: 0, + duration: 0.2, + onComplete: () => { + if (thinkingElement.parentNode) { + thinkingElement.remove(); + } + } + }); + } + + if (thinkingTimeout) { + clearTimeout(thinkingTimeout); + thinkingTimeout = null; + } + + isThinking = false; + } + + function showWarning(message) { + const warningElement = document.createElement("div"); + warningElement.className = "warning-message"; + warningElement.innerHTML = `⚠️ ${message}`; + + messagesDiv.appendChild(warningElement); + gsap.from(warningElement, { opacity: 0, y: 20, duration: 0.4, ease: "power2.out" }); + + if (!isUserScrolling) { + scrollToBottom(); + } + + setTimeout(() => { + if (warningElement.parentNode) { + gsap.to(warningElement, { + opacity: 0, + duration: 0.3, + onComplete: () => warningElement.remove() + }); + } + }, 5000); + } + + function showContinueButton() { + const continueElement = document.createElement("div"); + continueElement.className = "message-container"; + continueElement.innerHTML = ` +
+
+
+

A conexão foi interrompida. Clique em "Continuar" para tentar recuperar a resposta.

+ +
+
+ `; + + messagesDiv.appendChild(continueElement); + gsap.to(continueElement, { opacity: 1, y: 0, duration: 0.5, ease: "power2.out" }); + + if (!isUserScrolling) { + scrollToBottom(); + } + } + + function continueInterruptedResponse() { + if (!ws || ws.readyState !== WebSocket.OPEN) { + connectWebSocket(); + } + + if (ws && ws.readyState === WebSocket.OPEN) { + const data = { + 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(data)); + } + + document.querySelectorAll(".continue-button").forEach(button => { + button.parentElement.parentElement.parentElement.remove(); + }); + } + + function addMessage(role, content, streaming = false, msgId = null) { + // Skip empty messages + if (!content || content.trim() === '') return; + + const messageElement = document.createElement("div"); + messageElement.className = "message-container"; + + if (role === "user") { + messageElement.innerHTML = ` +
+
${escapeHtml(content)}
+
+ `; + } else if (role === "assistant") { + messageElement.innerHTML = ` +
+
+
+ ${streaming ? "" : marked.parse(content)} +
+
+ `; + } else if (role === "voice") { + messageElement.innerHTML = ` +
+
🎤
+
${content}
+
+ `; + } else { + messageElement.innerHTML = ` +
+
+
${content}
+
+ `; + } + + messagesDiv.appendChild(messageElement); + gsap.to(messageElement, { + opacity: 1, + y: 0, + duration: 0.5, + ease: "power2.out", + onStart: () => { + if (role === "user") { + flashScreen(); + // Bounce animation for send button + gsap.to(sendBtn, { + scale: 1.2, + duration: 0.1, + yoyo: true, + repeat: 1 + }); + } + } + }); + + if (!isUserScrolling) { + scrollToBottom(); + } + } + + function updateStreamingMessage(content) { + // Skip empty updates + if (!content || content.trim() === '') return; + + let messageElement = document.getElementById(streamingMessageId); + + // Create element if it doesn't exist yet + if (!messageElement && content.trim().length > 0) { + addMessage("assistant", content, true, streamingMessageId); + return; + } + + if (messageElement) { + messageElement.innerHTML = marked.parse(content); + + if (!isUserScrolling) { + scrollToBottom(); + } + } + } + + function finalizeStreamingMessage() { + const messageElement = document.getElementById(streamingMessageId); + if (messageElement) { + messageElement.innerHTML = marked.parse(currentStreamingContent); + messageElement.removeAttribute("id"); + + if (!isUserScrolling) { + scrollToBottom(); + } + } + } + + function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + function clearSuggestions() { + suggestionsContainer.innerHTML = ''; + } + + function handleSuggestions(suggestions) { + const uniqueSuggestions = suggestions.filter((value, index, self) => + index === self.findIndex(t => t.text === value.text && t.context === value.context) + ); + + suggestionsContainer.innerHTML = ''; + + uniqueSuggestions.forEach(value => { + const button = document.createElement('button'); + button.textContent = value.text; + button.className = 'suggestion-button'; + button.onclick = () => { + setContext(value.context); + input.value = ''; + }; + + suggestionsContainer.appendChild(button); + }); + } + + let pendingContextChange = null; + + async function setContext(context) { + try { + const text = event?.target?.textContent || context; + addMessage("user", text); + + const inputElement = document.getElementById('messageInput'); + if (inputElement) { + inputElement.value = ''; + } + + if (ws && ws.readyState === WebSocket.OPEN) { + pendingContextChange = new Promise(resolve => { + const handler = event => { + const data = JSON.parse(event.data); + if (data.message_type === 5 && data.context_name === context) { + ws.removeEventListener('message', handler); + resolve(); + } + }; + + ws.addEventListener('message', handler); + + const data = { + bot_id: currentBotId, + user_id: currentUserId, + session_id: currentSessionId, + channel: "web", + content: text, + message_type: 4, + is_suggestion: true, + context_name: context, + timestamp: new Date().toISOString() + }; + + ws.send(JSON.stringify(data)); + }); + + await pendingContextChange; + + const contextIndicator = document.getElementById('contextIndicator'); + if (contextIndicator) { + 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 data = { + 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(data)); + input.value = ""; + input.focus(); + } + + sendBtn.onclick = sendMessage; + + input.addEventListener("keypress", function(e) { + if (e.key === "Enter") { + sendMessage(); + } + }); + + async function toggleVoiceMode() { + isVoiceMode = !isVoiceMode; + const voiceToggle = document.getElementById("voiceToggle"); + + if (isVoiceMode) { + voiceToggle.textContent = "🔴 Stop Voice"; + voiceToggle.classList.add("recording"); + await startVoiceSession(); + } else { + voiceToggle.textContent = "🎤 Voice Mode"; + voiceToggle.classList.remove("recording"); + await stopVoiceSession(); + } + } + + 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 (e) { + console.error("Failed to start voice session:", e); + 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 (e) { + console.error("Failed to stop voice session:", e); + } + } + + async function connectToVoiceRoom(token) { + try { + const room = new LiveKitClient.Room(); + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const url = `${protocol}//${window.location.host}/voice`; + + await room.connect(url, token); + + voiceRoom = room; + + room.on("dataReceived", data => { + const decoder = new TextDecoder(); + const message = decoder.decode(data); + + try { + const json = JSON.parse(message); + if (json.type === "voice_response") { + addMessage("assistant", json.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 (e) { + console.error("Failed to connect to voice room:", e); + 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}`); + } + + function scrollToBottom() { + gsap.to(messagesDiv, { + scrollTop: messagesDiv.scrollHeight, + duration: 0.3, + ease: "power2.out" + }); + + isUserScrolling = false; + gsap.to(scrollToBottomBtn, { + opacity: 0, + y: 10, + duration: 0.3, + ease: "power2.out", + onComplete: () => scrollToBottomBtn.classList.remove('visible') + }); + } + + // Initialize authentication when the window loads + window.addEventListener("load", initializeAuth); + + // Reconnect when the window gains focus + window.addEventListener("focus", function() { + if (!ws || ws.readyState !== WebSocket.OPEN) { + connectWebSocket(); + } + }); +