From 382a01658d5a8961dfee3ba270a209fcceb0475b Mon Sep 17 00:00:00 2001 From: "Rodrigo Rodriguez (Pragmatismo)" Date: Thu, 20 Nov 2025 13:40:40 -0300 Subject: [PATCH] Refactor TALK delivery and streaming pipelines --- src/basic/keywords/hear_talk.rs | 37 +- src/bot/mod.rs | 250 ++++-- web/desktop/chat/chat.css | 1266 +++++++++++++++++-------------- web/desktop/chat/chat.js | 453 +++++++---- 4 files changed, 1178 insertions(+), 828 deletions(-) diff --git a/src/basic/keywords/hear_talk.rs b/src/basic/keywords/hear_talk.rs index 07155305e..c8103ca8a 100644 --- a/src/basic/keywords/hear_talk.rs +++ b/src/basic/keywords/hear_talk.rs @@ -94,32 +94,19 @@ pub async fn execute_talk( }; 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"); - } - }); - } + + // Use web adapter which handles the connection properly + 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) { diff --git a/src/bot/mod.rs b/src/bot/mod.rs index 5ed3db8e3..84e637da1 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -67,13 +67,156 @@ impl BotOrchestrator { Ok(()) } - // Placeholder for stream_response used by UI + // Stream response to user via LLM pub async fn stream_response( &self, - _user_message: UserMessage, - _response_tx: mpsc::Sender, + message: UserMessage, + response_tx: mpsc::Sender, ) -> Result<(), Box> { - // No-op placeholder + trace!( + "Streaming response for user: {}, session: {}", + message.user_id, + message.session_id + ); + + let user_id = Uuid::parse_str(&message.user_id)?; + let session_id = Uuid::parse_str(&message.session_id)?; + let bot_id = Uuid::parse_str(&message.bot_id).unwrap_or_default(); + + // All database operations in one blocking section + let (session, context_data, history, model, key) = { + let state_clone = self.state.clone(); + tokio::task::spawn_blocking( + move || -> Result<_, Box> { + // Get session + let session = { + let mut sm = state_clone.session_manager.blocking_lock(); + sm.get_session_by_id(session_id)? + } + .ok_or_else(|| "Session not found")?; + + // Save user message + { + let mut sm = state_clone.session_manager.blocking_lock(); + sm.save_message(session.id, user_id, 1, &message.content, 1)?; + } + + // Get context and history + let context_data = { + let sm = state_clone.session_manager.blocking_lock(); + let rt = tokio::runtime::Handle::current(); + rt.block_on(async { + sm.get_session_context_data(&session.id, &session.user_id) + .await + })? + }; + + let history = { + let mut sm = state_clone.session_manager.blocking_lock(); + sm.get_conversation_history(session.id, user_id)? + }; + + // Get model config + let config_manager = ConfigManager::new(state_clone.conn.clone()); + let model = config_manager + .get_config(&bot_id, "llm-model", Some("gpt-3.5-turbo")) + .unwrap_or_else(|_| "gpt-3.5-turbo".to_string()); + let key = config_manager + .get_config(&bot_id, "llm-key", Some("")) + .unwrap_or_default(); + + Ok((session, context_data, history, model, key)) + }, + ) + .await?? + }; + + // Build messages + let system_prompt = std::env::var("SYSTEM_PROMPT") + .unwrap_or_else(|_| "You are a helpful assistant.".to_string()); + let messages = OpenAIClient::build_messages(&system_prompt, &context_data, &history); + + // Stream from LLM + let (stream_tx, mut stream_rx) = mpsc::channel::(100); + let llm = self.state.llm_provider.clone(); + + tokio::spawn(async move { + if let Err(e) = llm + .generate_stream("", &messages, stream_tx, &model, &key) + .await + { + error!("LLM streaming error: {}", e); + } + }); + + let mut full_response = String::new(); + let mut chunk_count = 0; + + while let Some(chunk) = stream_rx.recv().await { + chunk_count += 1; + info!("Received LLM chunk #{}: {:?}", chunk_count, chunk); + full_response.push_str(&chunk); + + let response = BotResponse { + bot_id: message.bot_id.clone(), + user_id: message.user_id.clone(), + session_id: message.session_id.clone(), + channel: message.channel.clone(), + content: chunk, + message_type: 2, + stream_token: None, + is_complete: false, + suggestions: Vec::new(), + context_name: None, + context_length: 0, + context_max_length: 0, + }; + + info!("Sending streaming chunk to WebSocket"); + if let Err(e) = response_tx.send(response).await { + error!("Failed to send streaming chunk: {}", e); + break; + } + } + + info!( + "LLM streaming complete, received {} chunks, total length: {}", + chunk_count, + full_response.len() + ); + + // Send final complete response + let final_response = BotResponse { + bot_id: message.bot_id.clone(), + user_id: message.user_id.clone(), + session_id: message.session_id.clone(), + channel: message.channel.clone(), + content: full_response.clone(), + message_type: 2, + stream_token: None, + is_complete: true, + suggestions: Vec::new(), + context_name: None, + context_length: 0, + context_max_length: 0, + }; + + info!("Sending final complete response to WebSocket"); + response_tx.send(final_response).await?; + info!("Final response sent successfully"); + + // Save bot response in blocking context + let state_for_save = self.state.clone(); + let full_response_clone = full_response.clone(); + tokio::task::spawn_blocking( + move || -> Result<(), Box> { + let mut sm = state_for_save.session_manager.blocking_lock(); + sm.save_message(session.id, user_id, 2, &full_response_clone, 2)?; + Ok(()) + }, + ) + .await??; + Ok(()) } @@ -146,6 +289,12 @@ async fn handle_websocket( .add_connection(session_id.to_string(), tx.clone()) .await; + // Also register in response_channels for BotOrchestrator + { + let mut channels = state.response_channels.lock().await; + channels.insert(session_id.to_string(), tx.clone()); + } + info!( "WebSocket connected for session: {}, user: {}", session_id, user_id @@ -232,19 +381,16 @@ async fn handle_websocket( session_id, user_msg.content ); // Process the message through the bot system - let state_for_task = state_clone.clone(); - tokio::spawn(async move { - if let Err(e) = process_user_message( - state_for_task, - session_id, - user_id, - user_msg, - ) - .await - { - error!("Error processing user message: {}", e); - } - }); + if let Err(e) = process_user_message( + state_clone.clone(), + session_id, + user_id, + user_msg, + ) + .await + { + error!("Error processing user message: {}", e); + } } Err(e) => { error!( @@ -288,6 +434,12 @@ async fn handle_websocket( .remove_connection(&session_id.to_string()) .await; + // Also remove from response_channels + { + let mut channels = state.response_channels.lock().await; + channels.remove(&session_id.to_string()); + } + info!("WebSocket disconnected for session: {}", session_id); } @@ -303,64 +455,20 @@ async fn process_user_message( user_id, session_id, user_msg.content ); - // Get the session from the session manager - let session = { - let mut sm = state.session_manager.lock().await; - sm.get_session_by_id(session_id) - .map_err(|e| format!("Session error: {}", e))? - .ok_or("Session not found")? + // Get the response channel for this session + let tx = { + let channels = state.response_channels.lock().await; + channels.get(&session_id.to_string()).cloned() }; - let content = user_msg.content.clone(); - let bot_id = session.bot_id; - - info!("Sending message to LLM for processing"); - - // Call the LLM to generate a response - let messages = serde_json::json!([{"role": "user", "content": content}]); - let llm_response = match state - .llm_provider - .generate(&content, &messages, "gpt-3.5-turbo", "") - .await - { - Ok(response) => response, - Err(e) => { - error!("LLM generation failed: {}", e); - format!( - "I'm sorry, I encountered an error processing your message: {}", - e - ) + if let Some(response_tx) = tx { + // Use BotOrchestrator to stream the response + let orchestrator = BotOrchestrator::new(state.clone()); + if let Err(e) = orchestrator.stream_response(user_msg, response_tx).await { + error!("Failed to stream response: {}", e); } - }; - - info!("LLM response received: {}", llm_response); - - // Create and send the bot response - let response = BotResponse { - bot_id: bot_id.to_string(), - user_id: user_id.to_string(), - session_id: session_id.to_string(), - channel: "web".to_string(), - content: llm_response, - message_type: 2, - stream_token: None, - is_complete: true, - suggestions: Vec::new(), - context_name: None, - context_length: 0, - context_max_length: 0, - }; - - // Send response back through WebSocket - info!("Sending response to WebSocket session {}", session_id); - if let Err(e) = state - .web_adapter - .send_message_to_session(&session_id.to_string(), response) - .await - { - error!("Failed to send LLM response: {:?}", e); } else { - info!("Response sent successfully to session {}", session_id); + error!("No response channel found for session {}", session_id); } Ok(()) diff --git a/web/desktop/chat/chat.css b/web/desktop/chat/chat.css index 211160389..a1e170fae 100644 --- a/web/desktop/chat/chat.css +++ b/web/desktop/chat/chat.css @@ -1,586 +1,684 @@ @import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500&display=swap"); -:root{ ---bg:#ffffff; ---fg:#000000; ---border:#e0e0e0; ---accent:#0066ff; ---glass:rgba(0,0,0,0.02); ---shadow:rgba(0,0,0,0.05); ---logo-url:url('https://pragmatismo.com.br/icons/general-bots.svg'); ---gradient-1:linear-gradient(135deg,rgba(0,102,255,0.05) 0%,rgba(0,102,255,0.0) 100%); ---gradient-2:linear-gradient(45deg,rgba(0,0,0,0.02) 0%,rgba(0,0,0,0.0) 100%); -} -[data-theme="dark"]{ ---bg:#1a1a1a; ---fg:#ffffff; ---border:#333333; ---accent:#ffffff; ---glass:rgba(255,255,255,0.05); ---shadow:rgba(0,0,0,0.5); ---gradient-1:linear-gradient(135deg,rgba(255,255,255,0.08) 0%,rgba(255,255,255,0.0) 100%); ---gradient-2:linear-gradient(45deg,rgba(255,255,255,0.03) 0%,rgba(255,255,255,0.0) 100%); -} -*{margin:0;padding:0;box-sizing:border-box} -body{ -font-family:"Inter",sans-serif; -background:var(--bg); -color:var(--fg); -overflow:hidden; -transition:background 0.3s, color 0.3s; -display:flex; -flex-direction:column; -height:100vh; -position:relative; -} -body::before{ -content:''; -position:fixed; -inset:0; -background:var(--gradient-1); -pointer-events:none; -z-index:0; -} -.float-menu{ -position:fixed; -left:20px; -top:20px; -display:flex; -flex-direction:column; -gap:8px; -z-index:1000; -} -.float-logo{ -width:40px; -height:40px; -background:var(--logo-url) center/contain no-repeat; -filter:var(--logo-filter, none); -border-radius:50%; -cursor:pointer; -transition:all 0.3s; -border:1px solid var(--border); -backdrop-filter:blur(10px); -} -[data-theme="dark"] .float-logo{ -} -.float-logo:hover{ -transform:scale(1.1) rotate(5deg); -} -.menu-button{ -width:40px; -height:40px; -border-radius:50%; -display:flex; -align-items:center; -justify-content:center; -cursor:pointer; -transition:all 0.3s; -background:var(--bg); -border:1px solid var(--border); -font-size:16px; -color:var(--fg); -backdrop-filter:blur(10px); -} -.menu-button:hover{ -transform:scale(1.1) rotate(-5deg); -background:var(--fg); -color:var(--bg); -} -.sidebar{ -position:fixed; -left:-320px; -top:0; -width:320px; -height:100vh; -background:var(--bg); -border-right:1px solid var(--border); -transition:left 0.4s cubic-bezier(0.4,0,0.2,1); -z-index:999; -overflow-y:auto; -padding:20px; -backdrop-filter:blur(20px); -box-shadow:4px 0 20px var(--shadow); -} -.sidebar.open{ -left:0; -} -.sidebar-header{ -display:flex; -align-items:center; -gap:12px; -margin-bottom:30px; -padding-top:10px; -} -.sidebar-logo{ -width:32px; -height:32px; -background:var(--logo-url) center/contain no-repeat; -filter:var(--logo-filter, none); -} -[data-theme="dark"] .sidebar-logo{ -} -.sidebar-title{ -font-size:16px; -font-weight:500; -} -.sidebar-button{ -width:100%; -padding:12px 16px; -border-radius:12px; -cursor:pointer; -transition:all 0.3s; -font-weight:500; -font-size:14px; -margin-bottom:8px; -background:var(--glass); -border:1px solid var(--border); -color:var(--fg); -text-align:left; -} -.sidebar-button:hover{ -background:var(--fg); -color:var(--bg); -transform:translateX(4px) scale(1.02); -} -.history-section{ -margin-top:20px; -} -.history-title{ -font-size:12px; -opacity:0.5; -margin-bottom:12px; -text-transform:uppercase; -letter-spacing:0.5px; -} -.history-item{ -padding:10px 14px; -margin-bottom:6px; -border-radius:10px; -cursor:pointer; -transition:all 0.3s; -font-size:13px; -border:1px solid transparent; -} -.history-item:hover{ -background:var(--fg); -color:var(--bg); -transform:translateX(4px) scale(1.02); -} -#messages{ -flex:1; -overflow-y:auto; -padding:20px 20px 140px; -max-width:680px; -margin:0 auto; -width:100%; -position:relative; -z-index:1; -} -.message-container{ -margin-bottom:24px; -opacity:0; -transform:translateY(10px); -} -.user-message{ -display:flex; -justify-content:flex-end; -margin-bottom:8px; -} -.user-message-content{ -background:var(--fg); -color:var(--bg); -border-radius:18px; -padding:12px 18px; -max-width:80%; -font-size:14px; -line-height:1.5; -box-shadow:0 2px 8px var(--shadow); -position:relative; -overflow:hidden; -} -.user-message-content::before{ -content:''; -position:absolute; -inset:0; -background:var(--gradient-2); -opacity:0.3; -pointer-events:none; -} -.assistant-message{ -display:flex; -gap:8px; -align-items:flex-start; -} -.assistant-avatar{ -width:24px; -height:24px; -border-radius:50%; -background:var(--logo-url) center/contain no-repeat; -flex-shrink:0; -margin-top:2px; -filter:var(--logo-filter, none); -} -[data-theme="dark"] .assistant-avatar{ -} -.assistant-message-content{ -flex:1; -font-size:14px; -line-height:1.7; -background:var(--glass); -border-radius:18px; -padding:12px 18px; -border:1px solid var(--border); -box-shadow:0 2px 8px var(--shadow); -position:relative; -overflow:hidden; -} -.assistant-message-content::before{ -content:''; -position:absolute; -inset:0; -background:var(--gradient-1); -opacity:0.5; -pointer-events:none; -} -.thinking-indicator{ -display:flex; -gap:8px; -align-items:center; -font-size:13px; -opacity:0.4; -} -.typing-dots{ -display:flex; -gap:4px; -} -.typing-dot{ -width:4px; -height:4px; -background:var(--fg); -border-radius:50%; -animation:bounce 1.4s infinite; -} -.typing-dot:nth-child(1){animation-delay:-.32s} -.typing-dot:nth-child(2){animation-delay:-.16s} -@keyframes bounce{ -0%,80%,100%{transform:scale(0);opacity:.3} -40%{transform:scale(1);opacity:1} -} -footer{ -position:fixed; -bottom:0; -left:0; -right:0; -background:var(--bg); -border-top:1px solid var(--border); -padding:12px; -z-index:100; -transition:all 0.3s; -backdrop-filter:blur(20px); -} -.suggestions-container{ -display:flex; -flex-wrap:wrap; -gap:4px; -margin-bottom:8px; -justify-content:center; -max-width:680px; -margin:0 auto 8px; -} -.suggestion-button{ -padding:6px 12px; -border-radius:12px; -cursor:pointer; -font-size:11px; -font-weight:400; -transition:all 0.2s; -background:var(--glass); -border:1px solid var(--border); -color:var(--fg); -} -.suggestion-button:hover{ -background:var(--fg); -color:var(--bg); -transform:scale(1.05); -} -.input-container{ -display:flex; -gap:6px; -max-width:680px; -margin:0 auto; -align-items:center; -} -#messageInput{ -flex:1; -border-radius:20px; -padding:10px 16px; -font-size:14px; -font-family:"Inter",sans-serif; -outline:none; -transition:all 0.3s; -background:var(--glass); -border:1px solid var(--border); -color:var(--fg); -backdrop-filter:blur(10px); -} -#messageInput:focus{ -border-color:var(--accent); -box-shadow:0 0 0 3px rgba(0,102,255,0.1); -} -#messageInput::placeholder{ -opacity:0.3; -} -#sendBtn,#voiceBtn{ -width:36px; -height:36px; -border-radius:18px; -display:flex; -align-items:center; -justify-content:center; -cursor:pointer; -transition:all 0.2s; -border:none; -background:var(--fg); -color:var(--bg); -font-size:16px; -flex-shrink:0; -} -#sendBtn:hover,#voiceBtn:hover{ -transform:scale(1.08) rotate(5deg); -} -#sendBtn:active,#voiceBtn:active{ -transform:scale(0.95); -} -#voiceBtn.recording{ -animation:pulse 1.5s infinite; -} -@keyframes pulse{ -0%,100%{opacity:1;transform:scale(1)} -50%{opacity:0.6;transform:scale(1.1)} -} -.flash-overlay{ -position:fixed; -inset:0; -background:var(--fg); -opacity:0; -pointer-events:none; -z-index:9999; -} -.scroll-to-bottom{ -position:fixed; -bottom:80px; -right:20px; -width:40px; -height:40px; -background:var(--fg); -border:1px solid var(--border); -border-radius:50%; -color:var(--bg); -font-size:18px; -cursor:pointer; -display:none; -align-items:center; -justify-content:center; -transition:all 0.3s; -z-index:90; -} -.scroll-to-bottom.visible{ -display:flex; -} -.scroll-to-bottom:hover{ -transform:scale(1.1) rotate(180deg); -} -.warning-message{ -border-radius:12px; -padding:12px 16px; -margin-bottom:18px; -opacity:0.6; -background:var(--glass); -border:1px solid var(--border); -font-size:13px; -} -.continue-button{ -display:inline-block; -border-radius:10px; -padding:8px 16px; -font-weight:500; -cursor:pointer; -margin-top:10px; -transition:all 0.3s; -font-size:13px; -background:var(--glass); -border:1px solid var(--border); -} -.continue-button:hover{ -background:var(--fg); -color:var(--bg); -transform:translateY(-2px); -} -.context-indicator{ -position:fixed; -bottom:130px; -right:20px; -width:120px; -border-radius:12px; -padding:10px; -font-size:10px; -text-align:center; -z-index:90; -background:var(--bg); -border:1px solid var(--border); -display:none; -backdrop-filter:blur(10px); -} -.context-indicator.visible{ -display:block; -} -.context-progress{ -height:3px; -background:var(--glass); -border-radius:2px; -margin-top:6px; -overflow:hidden; -} -.context-progress-bar{ -height:100%; -background:var(--accent); -border-radius:2px; -transition:width 0.3s; -} -.connection-status{ -position:fixed; -top:20px; -right:20px; -width:8px; -height:8px; -border-radius:50%; -z-index:1000; -transition:all 0.3s; -} -.connection-status.connecting{ -background:var(--fg); -opacity:0.3; -animation:ping 1.5s infinite; -} -.connection-status.connected{ -background:var(--accent); -opacity:0.8; -} -.connection-status.disconnected{ -background:var(--fg); -opacity:0.2; -} -@keyframes ping{ -0%,100%{opacity:0.3;transform:scale(0.8)} -50%{opacity:0.8;transform:scale(1.2)} -} -.markdown-content p{ -margin-bottom:12px; -line-height:1.7; -} -.markdown-content ul,.markdown-content ol{ -margin-bottom:12px; -padding-left:20px; -} -.markdown-content li{ -margin-bottom:4px; -} -.markdown-content code{ -background:var(--glass); -padding:2px 6px; -border-radius:4px; -font-family:monospace; -font-size:13px; -} -.markdown-content pre{ -border-radius:8px; -padding:12px; -overflow-x:auto; -margin-bottom:12px; -background:var(--glass); -border:1px solid var(--border); -} -.markdown-content pre code{ -background:none; -padding:0; -} -.markdown-content h1,.markdown-content h2,.markdown-content h3{ -margin-top:16px; -margin-bottom:8px; -font-weight:600; -} -.markdown-content h1{font-size:20px} -.markdown-content h2{font-size:18px} -.markdown-content h3{font-size:16px} -.markdown-content table{ -width:100%; -border-collapse:collapse; -margin-bottom:14px; -} -.markdown-content table th,.markdown-content table td{ -padding:8px; -text-align:left; -border:1px solid var(--border); -} -.markdown-content table th{ -font-weight:600; -background:var(--glass); -} -.markdown-content blockquote{ -border-left:2px solid var(--accent); -padding-left:14px; -margin:12px 0; -opacity:0.7; -font-style:italic; -} -.markdown-content a{ -color:var(--accent); -text-decoration:none; -transition:all 0.3s; -} -.markdown-content a:hover{ -opacity:0.7; -text-decoration:underline; -} -::-webkit-scrollbar{ -width:6px; -} -::-webkit-scrollbar-track{ -background:transparent; -} -::-webkit-scrollbar-thumb{ -background:var(--border); -border-radius:3px; -} -::-webkit-scrollbar-thumb:hover{ -background:var(--fg); -opacity:0.3; -} -@media(max-width:768px){ -.sidebar{ -width:100%; -left:-100%; -} -#messages{ -padding:20px 16px 140px; -} -.float-menu{ -left:12px; -top:12px; -} -.float-logo,.menu-button{ -width:36px; -height:36px; -font-size:14px; -} -.scroll-to-bottom{ -width:36px; -height:36px; -bottom:70px; -right:12px; -} -.context-indicator{ -bottom:120px; -right:12px; -width:100px; -} +:root { + --bg: #ffffff; + --fg: #000000; + --border: #e0e0e0; + --accent: #0066ff; + --glass: rgba(0, 0, 0, 0.02); + --shadow: rgba(0, 0, 0, 0.05); + --logo-url: url("https://pragmatismo.com.br/icons/general-bots.svg"); + --gradient-1: linear-gradient( + 135deg, + rgba(0, 102, 255, 0.05) 0%, + rgba(0, 102, 255, 0) 100% + ); + --gradient-2: linear-gradient( + 45deg, + rgba(0, 0, 0, 0.02) 0%, + rgba(0, 0, 0, 0) 100% + ); +} +[data-theme="dark"] { + --bg: #1a1a1a; + --fg: #ffffff; + --border: #333333; + --accent: #ffffff; + --glass: rgba(255, 255, 255, 0.05); + --shadow: rgba(0, 0, 0, 0.5); + --gradient-1: linear-gradient( + 135deg, + rgba(255, 255, 255, 0.08) 0%, + rgba(255, 255, 255, 0) 100% + ); + --gradient-2: linear-gradient( + 45deg, + rgba(255, 255, 255, 0.03) 0%, + rgba(255, 255, 255, 0) 100% + ); +} +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} +body { + font-family: "Inter", sans-serif; + background: var(--bg); + color: var(--fg); + overflow: hidden; + transition: + background 0.3s, + color 0.3s; + display: flex; + flex-direction: column; + height: 100vh; + position: relative; +} +body::before { + content: ""; + position: fixed; + inset: 0; + background: var(--gradient-1); + pointer-events: none; + z-index: 0; +} +.float-menu { + position: fixed; + left: 20px; + top: 20px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 1000; +} +.float-logo { + width: 40px; + height: 40px; + background: var(--logo-url) center/contain no-repeat; + filter: var(--logo-filter, none); + border-radius: 50%; + cursor: pointer; + transition: all 0.3s; + border: 1px solid var(--border); + backdrop-filter: blur(10px); +} +[data-theme="dark"] .float-logo { +} +.float-logo:hover { + transform: scale(1.1) rotate(5deg); +} +.menu-button { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.3s; + background: var(--bg); + border: 1px solid var(--border); + font-size: 16px; + color: var(--fg); + backdrop-filter: blur(10px); +} +.menu-button:hover { + transform: scale(1.1) rotate(-5deg); + background: var(--fg); + color: var(--bg); +} +.sidebar { + position: fixed; + left: -320px; + top: 0; + width: 320px; + height: 100vh; + background: var(--bg); + border-right: 1px solid var(--border); + transition: left 0.4s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 999; + overflow-y: auto; + padding: 20px; + backdrop-filter: blur(20px); + box-shadow: 4px 0 20px var(--shadow); +} +.sidebar.open { + left: 0; +} +.sidebar-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 30px; + padding-top: 10px; +} +.sidebar-logo { + width: 32px; + height: 32px; + background: var(--logo-url) center/contain no-repeat; + filter: var(--logo-filter, none); +} +[data-theme="dark"] .sidebar-logo { +} +.sidebar-title { + font-size: 16px; + font-weight: 500; +} +.sidebar-button { + width: 100%; + padding: 12px 16px; + border-radius: 12px; + cursor: pointer; + transition: all 0.3s; + font-weight: 500; + font-size: 14px; + margin-bottom: 8px; + background: var(--glass); + border: 1px solid var(--border); + color: var(--fg); + text-align: left; +} +.sidebar-button:hover { + background: var(--fg); + color: var(--bg); + transform: translateX(4px) scale(1.02); +} +.history-section { + margin-top: 20px; +} +.history-title { + font-size: 12px; + opacity: 0.5; + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.history-item { + padding: 10px 14px; + margin-bottom: 6px; + border-radius: 10px; + cursor: pointer; + transition: all 0.3s; + font-size: 13px; + border: 1px solid transparent; +} +.history-item:hover { + background: var(--fg); + color: var(--bg); + transform: translateX(4px) scale(1.02); +} +.chat-layout { + display: flex; + flex-direction: column; + height: 100vh; + width: 100%; + position: relative; +} +#messages { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 20px 20px 140px; + max-width: 680px; + margin: 0 auto; + width: 100%; + position: relative; + z-index: 1; + scroll-behavior: smooth; + -webkit-overflow-scrolling: touch; +} +.message-container { + margin-bottom: 24px; + opacity: 0; + transform: translateY(10px); +} +.user-message { + display: flex; + justify-content: flex-end; + margin-bottom: 8px; +} +.user-message-content { + background: var(--fg); + color: var(--bg); + border-radius: 18px; + padding: 12px 18px; + max-width: 80%; + font-size: 14px; + line-height: 1.5; + box-shadow: 0 2px 8px var(--shadow); + position: relative; + overflow: hidden; +} +.user-message-content::before { + content: ""; + position: absolute; + inset: 0; + background: var(--gradient-2); + opacity: 0.3; + pointer-events: none; +} +.assistant-message { + display: flex; + gap: 8px; + align-items: flex-start; +} +.assistant-avatar { + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--logo-url) center/contain no-repeat; + flex-shrink: 0; + margin-top: 2px; + filter: var(--logo-filter, none); +} +[data-theme="dark"] .assistant-avatar { +} +.assistant-message-content { + flex: 1; + font-size: 14px; + line-height: 1.7; + background: var(--glass); + border-radius: 18px; + padding: 12px 18px; + border: 1px solid var(--border); + box-shadow: 0 2px 8px var(--shadow); + position: relative; + overflow: hidden; +} +.assistant-message-content::before { + content: ""; + position: absolute; + inset: 0; + background: var(--gradient-1); + opacity: 0.5; + pointer-events: none; +} +.thinking-indicator { + display: flex; + gap: 8px; + align-items: center; + font-size: 13px; + opacity: 0.4; +} +.typing-dots { + display: flex; + gap: 4px; +} +.typing-dot { + width: 4px; + height: 4px; + background: var(--fg); + border-radius: 50%; + animation: bounce 1.4s infinite; +} +.typing-dot:nth-child(1) { + animation-delay: -0.32s; +} +.typing-dot:nth-child(2) { + animation-delay: -0.16s; +} +@keyframes bounce { + 0%, + 80%, + 100% { + transform: scale(0); + opacity: 0.3; + } + 40% { + transform: scale(1); + opacity: 1; + } +} +footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: var(--bg); + border-top: 1px solid var(--border); + padding: 12px; + z-index: 100; + transition: all 0.3s; + backdrop-filter: blur(20px); +} +.suggestions-container { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-bottom: 8px; + justify-content: center; + max-width: 680px; + margin: 0 auto 8px; +} +.suggestion-button { + padding: 6px 12px; + border-radius: 12px; + cursor: pointer; + font-size: 11px; + font-weight: 400; + transition: all 0.2s; + background: var(--glass); + border: 1px solid var(--border); + color: var(--fg); +} +.suggestion-button:hover { + background: var(--fg); + color: var(--bg); + transform: scale(1.05); +} +.input-container { + display: flex; + gap: 6px; + max-width: 680px; + margin: 0 auto; + align-items: center; +} +#messageInput { + flex: 1; + border-radius: 20px; + padding: 10px 16px; + font-size: 14px; + font-family: "Inter", sans-serif; + outline: none; + transition: all 0.3s; + background: var(--glass); + border: 1px solid var(--border); + color: var(--fg); + backdrop-filter: blur(10px); +} +#messageInput:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.1); +} +#messageInput::placeholder { + opacity: 0.3; +} +#sendBtn, +#voiceBtn { + width: 36px; + height: 36px; + border-radius: 18px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; + border: none; + background: var(--fg); + color: var(--bg); + font-size: 16px; + flex-shrink: 0; +} +#sendBtn:hover, +#voiceBtn:hover { + transform: scale(1.08) rotate(5deg); +} +#sendBtn:active, +#voiceBtn:active { + transform: scale(0.95); +} +#voiceBtn.recording { + animation: pulse 1.5s infinite; +} +@keyframes pulse { + 0%, + 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.6; + transform: scale(1.1); + } +} +.flash-overlay { + position: fixed; + inset: 0; + background: var(--fg); + opacity: 0; + pointer-events: none; + z-index: 9999; +} +.scroll-to-bottom { + position: fixed; + bottom: 80px; + right: 20px; + width: 40px; + height: 40px; + background: var(--fg); + border: 1px solid var(--border); + border-radius: 50%; + color: var(--bg); + font-size: 18px; + cursor: pointer; + display: none; + align-items: center; + justify-content: center; + transition: all 0.3s; + z-index: 90; +} +.scroll-to-bottom.visible { + display: flex; +} +.scroll-to-bottom:hover { + transform: scale(1.1) rotate(180deg); +} +.warning-message { + border-radius: 12px; + padding: 12px 16px; + margin-bottom: 18px; + opacity: 0.6; + background: var(--glass); + border: 1px solid var(--border); + font-size: 13px; +} +.continue-button { + display: inline-block; + border-radius: 10px; + padding: 8px 16px; + font-weight: 500; + cursor: pointer; + margin-top: 10px; + transition: all 0.3s; + font-size: 13px; + background: var(--glass); + border: 1px solid var(--border); +} +.continue-button:hover { + background: var(--fg); + color: var(--bg); + transform: translateY(-2px); +} +.context-indicator { + position: fixed; + bottom: 130px; + right: 20px; + width: 120px; + border-radius: 12px; + padding: 10px; + font-size: 10px; + text-align: center; + z-index: 90; + background: var(--bg); + border: 1px solid var(--border); + display: none; + backdrop-filter: blur(10px); +} +.context-indicator.visible { + display: block; +} +.context-progress { + height: 3px; + background: var(--glass); + border-radius: 2px; + margin-top: 6px; + overflow: hidden; +} +.context-progress-bar { + height: 100%; + background: var(--accent); + border-radius: 2px; + transition: width 0.3s; +} +.connection-status { + position: fixed; + top: 20px; + right: 20px; + width: 8px; + height: 8px; + border-radius: 50%; + z-index: 1000; + transition: all 0.3s; +} +.connection-status.connecting { + background: var(--fg); + opacity: 0.3; + animation: ping 1.5s infinite; +} +.connection-status.connected { + background: var(--accent); + opacity: 0.8; +} +.connection-status.disconnected { + background: var(--fg); + opacity: 0.2; +} +@keyframes ping { + 0%, + 100% { + opacity: 0.3; + transform: scale(0.8); + } + 50% { + opacity: 0.8; + transform: scale(1.2); + } +} +.markdown-content p { + margin-bottom: 12px; + line-height: 1.7; +} +.markdown-content ul, +.markdown-content ol { + margin-bottom: 12px; + padding-left: 20px; +} +.markdown-content li { + margin-bottom: 4px; +} +.markdown-content code { + background: var(--glass); + padding: 2px 6px; + border-radius: 4px; + font-family: monospace; + font-size: 13px; +} +.markdown-content pre { + border-radius: 8px; + padding: 12px; + overflow-x: auto; + margin-bottom: 12px; + background: var(--glass); + border: 1px solid var(--border); +} +.markdown-content pre code { + background: none; + padding: 0; +} +.markdown-content h1, +.markdown-content h2, +.markdown-content h3 { + margin-top: 16px; + margin-bottom: 8px; + font-weight: 600; +} +.markdown-content h1 { + font-size: 20px; +} +.markdown-content h2 { + font-size: 18px; +} +.markdown-content h3 { + font-size: 16px; +} +.markdown-content table { + width: 100%; + border-collapse: collapse; + margin-bottom: 14px; +} +.markdown-content table th, +.markdown-content table td { + padding: 8px; + text-align: left; + border: 1px solid var(--border); +} +.markdown-content table th { + font-weight: 600; + background: var(--glass); +} +.markdown-content blockquote { + border-left: 2px solid var(--accent); + padding-left: 14px; + margin: 12px 0; + opacity: 0.7; + font-style: italic; +} +.markdown-content a { + color: var(--accent); + text-decoration: none; + transition: all 0.3s; +} +.markdown-content a:hover { + opacity: 0.7; + text-decoration: underline; +} +/* Claude-style scrollbar - thin, subtle, hover-only */ +#messages::-webkit-scrollbar { + width: 8px; +} +#messages::-webkit-scrollbar-track { + background: transparent; +} +#messages::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 4px; + transition: background 0.2s; +} +#messages:hover::-webkit-scrollbar-thumb { + background: rgba(128, 128, 128, 0.3); +} +#messages::-webkit-scrollbar-thumb:hover { + background: rgba(128, 128, 128, 0.5); +} +[data-theme="dark"] #messages:hover::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); +} +[data-theme="dark"] #messages::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.3); +} + +/* Fallback for other elements */ +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--fg); + opacity: 0.3; +} +@media (max-width: 768px) { + .sidebar { + width: 100%; + left: -100%; + } + #messages { + padding: 20px 16px 140px; + } + .float-menu { + left: 12px; + top: 12px; + } + .float-logo, + .menu-button { + width: 36px; + height: 36px; + font-size: 14px; + } + .scroll-to-bottom { + width: 36px; + height: 36px; + bottom: 70px; + right: 12px; + } + .context-indicator { + bottom: 120px; + right: 12px; + width: 100px; + } } diff --git a/web/desktop/chat/chat.js b/web/desktop/chat/chat.js index 1c81ae175..cb5ea500e 100644 --- a/web/desktop/chat/chat.js +++ b/web/desktop/chat/chat.js @@ -1,36 +1,50 @@ function chatApp() { - // Core state variables (shared via closure) let ws = null, - pendingContextChange = null,o - 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; + pendingContextChange = null, + o; + ((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, contextIndicator, contextPercentage, contextProgressBar, sidebarTitle; + let messagesDiv, + messageInputEl, + sendBtn, + voiceBtn, + connectionStatus, + flashOverlay, + suggestionsContainer, + floatLogo, + sidebar, + themeBtn, + scrollToBottomBtn, + contextIndicator, + contextPercentage, + contextProgressBar, + sidebarTitle; marked.setOptions({ breaks: true, gfm: true }); @@ -38,25 +52,60 @@ function chatApp() { // ---------------------------------------------------------------------- // UI state (mirrors the structure used in driveApp) // ---------------------------------------------------------------------- - current: 'All Chats', - search: '', + current: "All Chats", + search: "", selectedChat: null, navItems: [ - { name: 'All Chats', icon: '💬' }, - { name: 'Direct', icon: '👤' }, - { name: 'Groups', icon: '👥' }, - { name: 'Archived', icon: '🗄' } + { 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' } + { + 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()) + return this.chats.filter((chat) => + chat.name.toLowerCase().includes(this.search.toLowerCase()), ); }, @@ -64,34 +113,45 @@ function chatApp() { // UI helpers (formerly standalone functions) // ---------------------------------------------------------------------- toggleSidebar() { - sidebar.classList.toggle('open'); + sidebar.classList.toggle("open"); }, toggleTheme() { - const themes = ['auto', 'dark', 'light']; - const savedTheme = localStorage.getItem('gb-theme') || 'auto'; + 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); + localStorage.setItem("gb-theme", newTheme); currentTheme = newTheme; this.applyTheme(); this.updateThemeButton(); }, applyTheme() { - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + const prefersDark = window.matchMedia( + "(prefers-color-scheme: dark)", + ).matches; let theme = currentTheme; - if (theme === 'auto') { - theme = prefersDark ? 'dark' : 'light'; + if (theme === "auto") { + theme = prefersDark ? "dark" : "light"; } - document.documentElement.setAttribute('data-theme', theme); + 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); + 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}')`); + document.documentElement.style.setProperty( + "--logo-url", + `url('${customLogoUrl}')`, + ); } }, @@ -99,10 +159,10 @@ function chatApp() { // Lifecycle / event handlers // ---------------------------------------------------------------------- init() { - window.addEventListener('load', () => { + 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"); @@ -119,40 +179,44 @@ function chatApp() { sidebarTitle = document.getElementById("sidebarTitle"); // Theme initialization and focus - const savedTheme = localStorage.getItem('gb-theme') || 'auto'; + const savedTheme = localStorage.getItem("gb-theme") || "auto"; currentTheme = savedTheme; this.applyTheme(); - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { - if (currentTheme === 'auto') { - 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) => { - - }); + document.addEventListener("click", (e) => {}); - messagesDiv.addEventListener('scroll', () => { - const isAtBottom = messagesDiv.scrollHeight - messagesDiv.scrollTop <= messagesDiv.clientHeight + 100; + messagesDiv.addEventListener("scroll", () => { + const isAtBottom = + messagesDiv.scrollHeight - messagesDiv.scrollTop <= + messagesDiv.clientHeight + 100; if (!isAtBottom) { isUserScrolling = true; - scrollToBottomBtn.classList.add('visible'); + scrollToBottomBtn.classList.add("visible"); } else { isUserScrolling = false; - scrollToBottomBtn.classList.remove('visible'); + scrollToBottomBtn.classList.remove("visible"); } }); - scrollToBottomBtn.addEventListener('click', () => { + scrollToBottomBtn.addEventListener("click", () => { this.scrollToBottom(); }); sendBtn.onclick = () => this.sendMessage(); - messageInputEl.addEventListener("keypress", e => { if (e.key === "Enter") this.sendMessage(); }); + messageInputEl.addEventListener("keypress", (e) => { + if (e.key === "Enter") this.sendMessage(); + }); window.addEventListener("focus", () => { if (!ws || ws.readyState !== WebSocket.OPEN) { this.connectWebSocket(); @@ -169,13 +233,17 @@ function chatApp() { const p = Math.min(100, Math.round(u * 100)); contextPercentage.textContent = `${p}%`; contextProgressBar.style.width = `${p}%`; - contextIndicator.classList.remove('visible'); + contextIndicator.classList.remove("visible"); }, flashScreen() { - gsap.to(flashOverlay, { opacity: 0.15, duration: 0.1, onComplete: () => { - gsap.to(flashOverlay, { opacity: 0, duration: 0.2 }); - } }); + gsap.to(flashOverlay, { + opacity: 0.15, + duration: 0.1, + onComplete: () => { + gsap.to(flashOverlay, { opacity: 0, duration: 0.2 }); + }, + }); }, updateConnectionStatus(s) { @@ -183,16 +251,20 @@ function chatApp() { }, getWebSocketUrl() { - const p = "ws:", s = currentSessionId || crypto.randomUUID(), u = currentUserId || crypto.randomUUID(); + 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 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; @@ -210,10 +282,11 @@ function chatApp() { 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)}`; + 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); }); @@ -224,7 +297,9 @@ function chatApp() { async createNewSession() { try { - const r = await fetch("http://localhost:8080/api/sessions", { method: "POST" }); + const r = await fetch("http://localhost:8080/api/sessions", { + method: "POST", + }); const s = await r.json(); currentSessionId = s.session_id; hasReceivedInitialMessage = false; @@ -252,10 +327,9 @@ function chatApp() { if (isVoiceMode) { this.startVoiceSession(); } - sidebar.classList.remove('open'); + sidebar.classList.remove("open"); }, - connectWebSocket() { if (ws) { ws.close(); @@ -268,15 +342,17 @@ function chatApp() { 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; - } + // 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 = () => { @@ -333,7 +409,12 @@ function chatApp() { isStreaming = true; streamingMessageId = "streaming-" + Date.now(); currentStreamingContent = r.content || ""; - this.addMessage("assistant", currentStreamingContent, true, streamingMessageId); + this.addMessage( + "assistant", + currentStreamingContent, + true, + streamingMessageId, + ); } else { currentStreamingContent += r.content || ""; this.updateStreamingMessage(currentStreamingContent); @@ -376,14 +457,16 @@ function chatApp() { t.className = "message-container"; t.innerHTML = `
`; messagesDiv.appendChild(t); - gsap.to(t, { opacity: 1, y: 0, duration: .3, ease: "power2.out" }); + 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."); + this.showWarning( + "O servidor pode estar ocupado. A resposta está demorando demais.", + ); } }, 60000); isThinking = true; @@ -393,7 +476,15 @@ function chatApp() { if (!isThinking) return; const t = document.getElementById("thinking-indicator"); if (t) { - gsap.to(t, { opacity: 0, duration: .2, onComplete: () => { if (t.parentNode) { t.remove(); } } }); + gsap.to(t, { + opacity: 0, + duration: 0.2, + onComplete: () => { + if (t.parentNode) { + t.remove(); + } + }, + }); } if (thinkingTimeout) { clearTimeout(thinkingTimeout); @@ -407,13 +498,17 @@ function chatApp() { w.className = "warning-message"; w.innerHTML = `⚠️ ${m}`; messagesDiv.appendChild(w); - gsap.from(w, { opacity: 0, y: 20, duration: .4, ease: "power2.out" }); + 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: .3, onComplete: () => w.remove() }); + gsap.to(w, { + opacity: 0, + duration: 0.3, + onComplete: () => w.remove(), + }); } }, 5000); }, @@ -423,7 +518,7 @@ function chatApp() { c.className = "message-container"; c.innerHTML = `

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

`; messagesDiv.appendChild(c); - gsap.to(c, { opacity: 1, y: 0, duration: .5, ease: "power2.out" }); + gsap.to(c, { opacity: 1, y: 0, duration: 0.5, ease: "power2.out" }); if (!isUserScrolling) { this.scrollToBottom(); } @@ -442,11 +537,13 @@ function chatApp() { content: "continue", message_type: 3, media_url: null, - timestamp: new Date().toISOString() + timestamp: new Date().toISOString(), }; ws.send(JSON.stringify(d)); } - document.querySelectorAll(".continue-button").forEach(b => { b.parentElement.parentElement.parentElement.remove(); }); + document.querySelectorAll(".continue-button").forEach((b) => { + b.parentElement.parentElement.parentElement.remove(); + }); }, addMessage(role, content, streaming = false, msgId = null) { @@ -454,17 +551,17 @@ function chatApp() { m.className = "message-container"; if (role === "user") { m.innerHTML = `
${this.escapeHtml(content)}
`; - this.updateContextUsage(contextUsage + .05); + this.updateContextUsage(contextUsage + 0.05); } else if (role === "assistant") { m.innerHTML = `
${streaming ? "" : marked.parse(content)}
`; - this.updateContextUsage(contextUsage + .03); + this.updateContextUsage(contextUsage + 0.03); } else if (role === "voice") { m.innerHTML = `
🎤
${content}
`; } else { m.innerHTML = `
${content}
`; } messagesDiv.appendChild(m); - gsap.to(m, { opacity: 1, y: 0, duration: .5, ease: "power2.out" }); + gsap.to(m, { opacity: 1, y: 0, duration: 0.5, ease: "power2.out" }); if (!isUserScrolling) { this.scrollToBottom(); } @@ -498,17 +595,24 @@ function chatApp() { }, clearSuggestions() { - suggestionsContainer.innerHTML = ''; + 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'); + 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 = ''; }; + b.className = "suggestion-button"; + b.onclick = () => { + this.setContext(v.context); + messageInputEl.value = ""; + }; suggestionsContainer.appendChild(b); }); }, @@ -517,30 +621,42 @@ function chatApp() { try { const t = event?.target?.textContent || c; this.addMessage("user", t); - messageInputEl.value = ''; -messageInputEl.value = ''; + messageInputEl.value = ""; + messageInputEl.value = ""; if (ws && ws.readyState === WebSocket.OPEN) { - pendingContextChange = new Promise(r => { - const h = e => { + 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); + 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.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; } + 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); + console.error("Failed to set context:", err); } }, @@ -561,7 +677,16 @@ messageInputEl.value = ''; 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() }; + 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(); @@ -587,7 +712,10 @@ messageInputEl.value = ''; 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 }) + body: JSON.stringify({ + session_id: currentSessionId, + user_id: currentUserId, + }), }); const d = await r.json(); if (d.token) { @@ -606,7 +734,7 @@ messageInputEl.value = ''; await fetch("http://localhost:8080/api/voice/stop", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ session_id: currentSessionId }) + body: JSON.stringify({ session_id: currentSessionId }), }); if (voiceRoom) { voiceRoom.disconnect(); @@ -623,11 +751,13 @@ messageInputEl.value = ''; async connectToVoiceRoom(t) { try { const r = new LiveKitClient.Room(); - const p = "ws:", u = `${p}//localhost:8080/voice`; + 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); + r.on("dataReceived", (d) => { + const dc = new TextDecoder(), + m = dc.decode(d); try { const j = JSON.parse(m); if (j.type === "voice_response") { @@ -637,7 +767,10 @@ messageInputEl.value = ''; console.log("Voice data:", m); } }); - const l = await LiveKitClient.createLocalTracks({ audio: true, video: false }); + const l = await LiveKitClient.createLocalTracks({ + audio: true, + video: false, + }); for (const k of l) { await r.localParticipant.publishTrack(k); } @@ -652,34 +785,58 @@ messageInputEl.value = ''; 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"); - }); + 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 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); + 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}`); }, @@ -687,8 +844,8 @@ messageInputEl.value = ''; scrollToBottom() { messagesDiv.scrollTop = messagesDiv.scrollHeight; isUserScrolling = false; - scrollToBottomBtn.classList.remove('visible'); - } + scrollToBottomBtn.classList.remove("visible"); + }, }; }