Refactor TALK delivery and streaming pipelines

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-11-20 13:40:40 -03:00
parent e146add4b2
commit 382a01658d
4 changed files with 1178 additions and 828 deletions

View file

@ -94,15 +94,8 @@ pub async fn execute_talk(
}; };
let user_id = user_session.id.to_string(); let user_id = user_session.id.to_string();
let response_clone = response.clone(); let response_clone = response.clone();
match state.response_channels.try_lock() {
Ok(response_channels) => { // Use web adapter which handles the connection properly
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); let web_adapter = Arc::clone(&state.web_adapter);
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = web_adapter if let Err(e) = web_adapter
@ -114,12 +107,6 @@ pub async fn execute_talk(
trace!("TALK message sent via web adapter"); trace!("TALK message sent via web adapter");
} }
}); });
}
}
Err(_) => {
error!("Failed to acquire lock on response_channels for TALK command");
}
}
Ok(response) Ok(response)
} }
pub fn talk_keyword(state: Arc<AppState>, user: UserSession, engine: &mut Engine) { pub fn talk_keyword(state: Arc<AppState>, user: UserSession, engine: &mut Engine) {

View file

@ -67,13 +67,156 @@ impl BotOrchestrator {
Ok(()) Ok(())
} }
// Placeholder for stream_response used by UI // Stream response to user via LLM
pub async fn stream_response( pub async fn stream_response(
&self, &self,
_user_message: UserMessage, message: UserMessage,
_response_tx: mpsc::Sender<BotResponse>, response_tx: mpsc::Sender<BotResponse>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// 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<dyn std::error::Error + Send + Sync>> {
// 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::<String>(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<dyn std::error::Error + Send + Sync>> {
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(()) Ok(())
} }
@ -146,6 +289,12 @@ async fn handle_websocket(
.add_connection(session_id.to_string(), tx.clone()) .add_connection(session_id.to_string(), tx.clone())
.await; .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!( info!(
"WebSocket connected for session: {}, user: {}", "WebSocket connected for session: {}, user: {}",
session_id, user_id session_id, user_id
@ -232,10 +381,8 @@ async fn handle_websocket(
session_id, user_msg.content session_id, user_msg.content
); );
// Process the message through the bot system // 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( if let Err(e) = process_user_message(
state_for_task, state_clone.clone(),
session_id, session_id,
user_id, user_id,
user_msg, user_msg,
@ -244,7 +391,6 @@ async fn handle_websocket(
{ {
error!("Error processing user message: {}", e); error!("Error processing user message: {}", e);
} }
});
} }
Err(e) => { Err(e) => {
error!( error!(
@ -288,6 +434,12 @@ async fn handle_websocket(
.remove_connection(&session_id.to_string()) .remove_connection(&session_id.to_string())
.await; .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); info!("WebSocket disconnected for session: {}", session_id);
} }
@ -303,64 +455,20 @@ async fn process_user_message(
user_id, session_id, user_msg.content user_id, session_id, user_msg.content
); );
// Get the session from the session manager // Get the response channel for this session
let session = { let tx = {
let mut sm = state.session_manager.lock().await; let channels = state.response_channels.lock().await;
sm.get_session_by_id(session_id) channels.get(&session_id.to_string()).cloned()
.map_err(|e| format!("Session error: {}", e))?
.ok_or("Session not found")?
}; };
let content = user_msg.content.clone(); if let Some(response_tx) = tx {
let bot_id = session.bot_id; // Use BotOrchestrator to stream the response
let orchestrator = BotOrchestrator::new(state.clone());
info!("Sending message to LLM for processing"); if let Err(e) = orchestrator.stream_response(user_msg, response_tx).await {
error!("Failed to stream response: {}", e);
// 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
)
} }
};
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 { } else {
info!("Response sent successfully to session {}", session_id); error!("No response channel found for session {}", session_id);
} }
Ok(()) Ok(())

File diff suppressed because it is too large Load diff

View file

@ -1,36 +1,50 @@
function chatApp() { function chatApp() {
// Core state variables (shared via closure) // Core state variables (shared via closure)
let ws = null, let ws = null,
pendingContextChange = null,o pendingContextChange = null,
currentSessionId = null, o;
currentUserId = null, ((currentSessionId = null),
currentBotId = "default_bot", (currentUserId = null),
isStreaming = false, (currentBotId = "default_bot"),
voiceRoom = null, (isStreaming = false),
isVoiceMode = false, (voiceRoom = null),
mediaRecorder = null, (isVoiceMode = false),
audioChunks = [], (mediaRecorder = null),
streamingMessageId = null, (audioChunks = []),
isThinking = false, (streamingMessageId = null),
currentStreamingContent = "", (isThinking = false),
hasReceivedInitialMessage = false, (currentStreamingContent = ""),
reconnectAttempts = 0, (hasReceivedInitialMessage = false),
reconnectTimeout = null, (reconnectAttempts = 0),
thinkingTimeout = null, (reconnectTimeout = null),
currentTheme = 'auto', (thinkingTimeout = null),
themeColor1 = null, (currentTheme = "auto"),
themeColor2 = null, (themeColor1 = null),
customLogoUrl = null, (themeColor2 = null),
contextUsage = 0, (customLogoUrl = null),
isUserScrolling = false, (contextUsage = 0),
autoScrollEnabled = true, (isUserScrolling = false),
isContextChange = false; (autoScrollEnabled = true),
(isContextChange = false));
const maxReconnectAttempts = 5; const maxReconnectAttempts = 5;
// DOM references (cached for performance) // 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 }); marked.setOptions({ breaks: true, gfm: true });
@ -38,25 +52,60 @@ function chatApp() {
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
// UI state (mirrors the structure used in driveApp) // UI state (mirrors the structure used in driveApp)
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
current: 'All Chats', current: "All Chats",
search: '', search: "",
selectedChat: null, selectedChat: null,
navItems: [ navItems: [
{ name: 'All Chats', icon: '💬' }, { name: "All Chats", icon: "💬" },
{ name: 'Direct', icon: '👤' }, { name: "Direct", icon: "👤" },
{ name: 'Groups', icon: '👥' }, { name: "Groups", icon: "👥" },
{ name: 'Archived', icon: '🗄' } { name: "Archived", icon: "🗄" },
], ],
chats: [ 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: 1,
{ id: 3, name: 'Team Standup', icon: '🗣️', lastMessage: 'Done with the UI updates.', time: '2 hrs ago', status: 'Active' }, name: "General Bot Support",
{ id: 4, name: 'Random Chat', icon: '🎲', lastMessage: 'Did you see the game last night?', time: '5 hrs ago', status: 'Idle' }, icon: "🤖",
{ id: 5, name: 'Support Ticket #1234', icon: '🛠️', lastMessage: 'Issue resolved, closing ticket.', time: '3 days ago', status: 'Closed' } lastMessage: "How can I help you?",
time: "10:15 AM",
status: "Online",
},
{
id: 2,
name: "Project Alpha",
icon: "🚀",
lastMessage: "Launch scheduled for tomorrow.",
time: "Yesterday",
status: "Active",
},
{
id: 3,
name: "Team Standup",
icon: "🗣️",
lastMessage: "Done with the UI updates.",
time: "2 hrs ago",
status: "Active",
},
{
id: 4,
name: "Random Chat",
icon: "🎲",
lastMessage: "Did you see the game last night?",
time: "5 hrs ago",
status: "Idle",
},
{
id: 5,
name: "Support Ticket #1234",
icon: "🛠️",
lastMessage: "Issue resolved, closing ticket.",
time: "3 days ago",
status: "Closed",
},
], ],
get filteredChats() { get filteredChats() {
return this.chats.filter(chat => return this.chats.filter((chat) =>
chat.name.toLowerCase().includes(this.search.toLowerCase()) chat.name.toLowerCase().includes(this.search.toLowerCase()),
); );
}, },
@ -64,34 +113,45 @@ function chatApp() {
// UI helpers (formerly standalone functions) // UI helpers (formerly standalone functions)
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
toggleSidebar() { toggleSidebar() {
sidebar.classList.toggle('open'); sidebar.classList.toggle("open");
}, },
toggleTheme() { toggleTheme() {
const themes = ['auto', 'dark', 'light']; const themes = ["auto", "dark", "light"];
const savedTheme = localStorage.getItem('gb-theme') || 'auto'; const savedTheme = localStorage.getItem("gb-theme") || "auto";
const idx = themes.indexOf(savedTheme); const idx = themes.indexOf(savedTheme);
const newTheme = themes[(idx + 1) % themes.length]; const newTheme = themes[(idx + 1) % themes.length];
localStorage.setItem('gb-theme', newTheme); localStorage.setItem("gb-theme", newTheme);
currentTheme = newTheme; currentTheme = newTheme;
this.applyTheme(); this.applyTheme();
this.updateThemeButton(); this.updateThemeButton();
}, },
applyTheme() { applyTheme() {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
let theme = currentTheme; let theme = currentTheme;
if (theme === 'auto') { if (theme === "auto") {
theme = prefersDark ? 'dark' : 'light'; theme = prefersDark ? "dark" : "light";
} }
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute("data-theme", theme);
if (themeColor1 && themeColor2) { if (themeColor1 && themeColor2) {
const root = document.documentElement; const root = document.documentElement;
root.style.setProperty('--bg', theme === 'dark' ? themeColor2 : themeColor1); root.style.setProperty(
root.style.setProperty('--fg', theme === 'dark' ? themeColor1 : themeColor2); "--bg",
theme === "dark" ? themeColor2 : themeColor1,
);
root.style.setProperty(
"--fg",
theme === "dark" ? themeColor1 : themeColor2,
);
} }
if (customLogoUrl) { if (customLogoUrl) {
document.documentElement.style.setProperty('--logo-url', `url('${customLogoUrl}')`); document.documentElement.style.setProperty(
"--logo-url",
`url('${customLogoUrl}')`,
);
} }
}, },
@ -99,7 +159,7 @@ function chatApp() {
// Lifecycle / event handlers // Lifecycle / event handlers
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
init() { init() {
window.addEventListener('load', () => { window.addEventListener("load", () => {
// Assign DOM elements after the document is ready // Assign DOM elements after the document is ready
messagesDiv = document.getElementById("messages"); messagesDiv = document.getElementById("messages");
@ -119,11 +179,13 @@ function chatApp() {
sidebarTitle = document.getElementById("sidebarTitle"); sidebarTitle = document.getElementById("sidebarTitle");
// Theme initialization and focus // Theme initialization and focus
const savedTheme = localStorage.getItem('gb-theme') || 'auto'; const savedTheme = localStorage.getItem("gb-theme") || "auto";
currentTheme = savedTheme; currentTheme = savedTheme;
this.applyTheme(); this.applyTheme();
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { window
if (currentTheme === 'auto') { .matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", () => {
if (currentTheme === "auto") {
this.applyTheme(); this.applyTheme();
} }
}); });
@ -132,27 +194,29 @@ function chatApp() {
} }
// UI event listeners // UI event listeners
document.addEventListener('click', (e) => { document.addEventListener("click", (e) => {});
}); messagesDiv.addEventListener("scroll", () => {
const isAtBottom =
messagesDiv.addEventListener('scroll', () => { messagesDiv.scrollHeight - messagesDiv.scrollTop <=
const isAtBottom = messagesDiv.scrollHeight - messagesDiv.scrollTop <= messagesDiv.clientHeight + 100; messagesDiv.clientHeight + 100;
if (!isAtBottom) { if (!isAtBottom) {
isUserScrolling = true; isUserScrolling = true;
scrollToBottomBtn.classList.add('visible'); scrollToBottomBtn.classList.add("visible");
} else { } else {
isUserScrolling = false; isUserScrolling = false;
scrollToBottomBtn.classList.remove('visible'); scrollToBottomBtn.classList.remove("visible");
} }
}); });
scrollToBottomBtn.addEventListener('click', () => { scrollToBottomBtn.addEventListener("click", () => {
this.scrollToBottom(); this.scrollToBottom();
}); });
sendBtn.onclick = () => this.sendMessage(); 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", () => { window.addEventListener("focus", () => {
if (!ws || ws.readyState !== WebSocket.OPEN) { if (!ws || ws.readyState !== WebSocket.OPEN) {
this.connectWebSocket(); this.connectWebSocket();
@ -169,13 +233,17 @@ function chatApp() {
const p = Math.min(100, Math.round(u * 100)); const p = Math.min(100, Math.round(u * 100));
contextPercentage.textContent = `${p}%`; contextPercentage.textContent = `${p}%`;
contextProgressBar.style.width = `${p}%`; contextProgressBar.style.width = `${p}%`;
contextIndicator.classList.remove('visible'); contextIndicator.classList.remove("visible");
}, },
flashScreen() { flashScreen() {
gsap.to(flashOverlay, { opacity: 0.15, duration: 0.1, onComplete: () => { gsap.to(flashOverlay, {
opacity: 0.15,
duration: 0.1,
onComplete: () => {
gsap.to(flashOverlay, { opacity: 0, duration: 0.2 }); gsap.to(flashOverlay, { opacity: 0, duration: 0.2 });
} }); },
});
}, },
updateConnectionStatus(s) { updateConnectionStatus(s) {
@ -183,16 +251,20 @@ function chatApp() {
}, },
getWebSocketUrl() { 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}`; return `${p}//localhost:8080/ws?session_id=${s}&user_id=${u}`;
}, },
async initializeAuth() { async initializeAuth() {
try { try {
this.updateConnectionStatus("connecting"); this.updateConnectionStatus("connecting");
const p = window.location.pathname.split('/').filter(s => s); const p = window.location.pathname.split("/").filter((s) => s);
const b = p.length > 0 ? p[0] : 'default'; const b = p.length > 0 ? p[0] : "default";
const r = await fetch(`http://localhost:8080/api/auth?bot_name=${encodeURIComponent(b)}`); const r = await fetch(
`http://localhost:8080/api/auth?bot_name=${encodeURIComponent(b)}`,
);
const a = await r.json(); const a = await r.json();
currentUserId = a.user_id; currentUserId = a.user_id;
currentSessionId = a.session_id; currentSessionId = a.session_id;
@ -210,10 +282,11 @@ function chatApp() {
const s = await r.json(); const s = await r.json();
const h = document.getElementById("history"); const h = document.getElementById("history");
h.innerHTML = ""; h.innerHTML = "";
s.forEach(session => { s.forEach((session) => {
const item = document.createElement('div'); const item = document.createElement("div");
item.className = 'history-item'; item.className = "history-item";
item.textContent = session.title || `Session ${session.session_id.substring(0, 8)}`; item.textContent =
session.title || `Session ${session.session_id.substring(0, 8)}`;
item.onclick = () => this.switchSession(session.session_id); item.onclick = () => this.switchSession(session.session_id);
h.appendChild(item); h.appendChild(item);
}); });
@ -224,7 +297,9 @@ function chatApp() {
async createNewSession() { async createNewSession() {
try { 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(); const s = await r.json();
currentSessionId = s.session_id; currentSessionId = s.session_id;
hasReceivedInitialMessage = false; hasReceivedInitialMessage = false;
@ -252,10 +327,9 @@ function chatApp() {
if (isVoiceMode) { if (isVoiceMode) {
this.startVoiceSession(); this.startVoiceSession();
} }
sidebar.classList.remove('open'); sidebar.classList.remove("open");
}, },
connectWebSocket() { connectWebSocket() {
if (ws) { if (ws) {
ws.close(); ws.close();
@ -268,15 +342,17 @@ function chatApp() {
if (r.bot_id) { if (r.bot_id) {
currentBotId = r.bot_id; currentBotId = r.bot_id;
} }
if (r.message_type === 2) { // Message type 2 is a bot response (not an event)
const d = JSON.parse(r.content); // Message type 5 is context change
this.handleEvent(d.event, d.data);
return;
}
if (r.message_type === 5) { if (r.message_type === 5) {
isContextChange = true; isContextChange = true;
return; 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); this.processMessageContent(r);
}; };
ws.onopen = () => { ws.onopen = () => {
@ -333,7 +409,12 @@ function chatApp() {
isStreaming = true; isStreaming = true;
streamingMessageId = "streaming-" + Date.now(); streamingMessageId = "streaming-" + Date.now();
currentStreamingContent = r.content || ""; currentStreamingContent = r.content || "";
this.addMessage("assistant", currentStreamingContent, true, streamingMessageId); this.addMessage(
"assistant",
currentStreamingContent,
true,
streamingMessageId,
);
} else { } else {
currentStreamingContent += r.content || ""; currentStreamingContent += r.content || "";
this.updateStreamingMessage(currentStreamingContent); this.updateStreamingMessage(currentStreamingContent);
@ -376,14 +457,16 @@ function chatApp() {
t.className = "message-container"; t.className = "message-container";
t.innerHTML = `<div class="assistant-message"><div class="assistant-avatar"></div><div class="thinking-indicator"><div class="typing-dots"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div></div></div>`; t.innerHTML = `<div class="assistant-message"><div class="assistant-avatar"></div><div class="thinking-indicator"><div class="typing-dots"><div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div></div></div></div>`;
messagesDiv.appendChild(t); 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) { if (!isUserScrolling) {
this.scrollToBottom(); this.scrollToBottom();
} }
thinkingTimeout = setTimeout(() => { thinkingTimeout = setTimeout(() => {
if (isThinking) { if (isThinking) {
this.hideThinkingIndicator(); 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); }, 60000);
isThinking = true; isThinking = true;
@ -393,7 +476,15 @@ function chatApp() {
if (!isThinking) return; if (!isThinking) return;
const t = document.getElementById("thinking-indicator"); const t = document.getElementById("thinking-indicator");
if (t) { 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) { if (thinkingTimeout) {
clearTimeout(thinkingTimeout); clearTimeout(thinkingTimeout);
@ -407,13 +498,17 @@ function chatApp() {
w.className = "warning-message"; w.className = "warning-message";
w.innerHTML = `⚠️ ${m}`; w.innerHTML = `⚠️ ${m}`;
messagesDiv.appendChild(w); 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) { if (!isUserScrolling) {
this.scrollToBottom(); this.scrollToBottom();
} }
setTimeout(() => { setTimeout(() => {
if (w.parentNode) { 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); }, 5000);
}, },
@ -423,7 +518,7 @@ function chatApp() {
c.className = "message-container"; c.className = "message-container";
c.innerHTML = `<div class="assistant-message"><div class="assistant-avatar"></div><div class="assistant-message-content"><p>A conexão foi interrompida. Clique em "Continuar" para tentar recuperar a resposta.</p><button class="continue-button" onclick="this.parentElement.parentElement.parentElement.remove();">Continuar</button></div></div>`; c.innerHTML = `<div class="assistant-message"><div class="assistant-avatar"></div><div class="assistant-message-content"><p>A conexão foi interrompida. Clique em "Continuar" para tentar recuperar a resposta.</p><button class="continue-button" onclick="this.parentElement.parentElement.parentElement.remove();">Continuar</button></div></div>`;
messagesDiv.appendChild(c); 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) { if (!isUserScrolling) {
this.scrollToBottom(); this.scrollToBottom();
} }
@ -442,11 +537,13 @@ function chatApp() {
content: "continue", content: "continue",
message_type: 3, message_type: 3,
media_url: null, media_url: null,
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}; };
ws.send(JSON.stringify(d)); 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) { addMessage(role, content, streaming = false, msgId = null) {
@ -454,17 +551,17 @@ function chatApp() {
m.className = "message-container"; m.className = "message-container";
if (role === "user") { if (role === "user") {
m.innerHTML = `<div class="user-message"><div class="user-message-content">${this.escapeHtml(content)}</div></div>`; m.innerHTML = `<div class="user-message"><div class="user-message-content">${this.escapeHtml(content)}</div></div>`;
this.updateContextUsage(contextUsage + .05); this.updateContextUsage(contextUsage + 0.05);
} else if (role === "assistant") { } else if (role === "assistant") {
m.innerHTML = `<div class="assistant-message"><div class="assistant-avatar"></div><div class="assistant-message-content markdown-content" id="${msgId || ""}">${streaming ? "" : marked.parse(content)}</div></div>`; m.innerHTML = `<div class="assistant-message"><div class="assistant-avatar"></div><div class="assistant-message-content markdown-content" id="${msgId || ""}">${streaming ? "" : marked.parse(content)}</div></div>`;
this.updateContextUsage(contextUsage + .03); this.updateContextUsage(contextUsage + 0.03);
} else if (role === "voice") { } else if (role === "voice") {
m.innerHTML = `<div class="assistant-message"><div class="assistant-avatar">🎤</div><div class="assistant-message-content">${content}</div></div>`; m.innerHTML = `<div class="assistant-message"><div class="assistant-avatar">🎤</div><div class="assistant-message-content">${content}</div></div>`;
} else { } else {
m.innerHTML = `<div class="assistant-message"><div class="assistant-avatar"></div><div class="assistant-message-content">${content}</div></div>`; m.innerHTML = `<div class="assistant-message"><div class="assistant-avatar"></div><div class="assistant-message-content">${content}</div></div>`;
} }
messagesDiv.appendChild(m); 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) { if (!isUserScrolling) {
this.scrollToBottom(); this.scrollToBottom();
} }
@ -498,17 +595,24 @@ function chatApp() {
}, },
clearSuggestions() { clearSuggestions() {
suggestionsContainer.innerHTML = ''; suggestionsContainer.innerHTML = "";
}, },
handleSuggestions(s) { handleSuggestions(s) {
const uniqueSuggestions = s.filter((v, i, a) => i === a.findIndex(t => t.text === v.text && t.context === v.context)); const uniqueSuggestions = s.filter(
suggestionsContainer.innerHTML = ''; (v, i, a) =>
uniqueSuggestions.forEach(v => { i ===
const b = document.createElement('button'); 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.textContent = v.text;
b.className = 'suggestion-button'; b.className = "suggestion-button";
b.onclick = () => { this.setContext(v.context); messageInputEl.value = ''; }; b.onclick = () => {
this.setContext(v.context);
messageInputEl.value = "";
};
suggestionsContainer.appendChild(b); suggestionsContainer.appendChild(b);
}); });
}, },
@ -517,30 +621,42 @@ function chatApp() {
try { try {
const t = event?.target?.textContent || c; const t = event?.target?.textContent || c;
this.addMessage("user", t); this.addMessage("user", t);
messageInputEl.value = ''; messageInputEl.value = "";
messageInputEl.value = ''; messageInputEl.value = "";
if (ws && ws.readyState === WebSocket.OPEN) { if (ws && ws.readyState === WebSocket.OPEN) {
pendingContextChange = new Promise(r => { pendingContextChange = new Promise((r) => {
const h = e => { const h = (e) => {
const d = JSON.parse(e.data); const d = JSON.parse(e.data);
if (d.message_type === 5 && d.context_name === c) { if (d.message_type === 5 && d.context_name === c) {
ws.removeEventListener('message', h); ws.removeEventListener("message", h);
r(); r();
} }
}; };
ws.addEventListener('message', h); 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() }; 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)); ws.send(JSON.stringify(s));
}); });
await pendingContextChange; await pendingContextChange;
const x = document.getElementById('contextIndicator'); const x = document.getElementById("contextIndicator");
if (x) { document.getElementById('contextPercentage').textContent = c; } if (x) {
document.getElementById("contextPercentage").textContent = c;
}
} else { } else {
console.warn("WebSocket não está conectado. Tentando reconectar..."); console.warn("WebSocket não está conectado. Tentando reconectar...");
this.connectWebSocket(); this.connectWebSocket();
} }
} catch (err) { } 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.hideThinkingIndicator();
} }
this.addMessage("user", m); 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)); ws.send(JSON.stringify(d));
messageInputEl.value = ""; messageInputEl.value = "";
messageInputEl.focus(); messageInputEl.focus();
@ -587,7 +712,10 @@ messageInputEl.value = '';
const r = await fetch("http://localhost:8080/api/voice/start", { const r = await fetch("http://localhost:8080/api/voice/start", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, 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(); const d = await r.json();
if (d.token) { if (d.token) {
@ -606,7 +734,7 @@ messageInputEl.value = '';
await fetch("http://localhost:8080/api/voice/stop", { await fetch("http://localhost:8080/api/voice/stop", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: currentSessionId }) body: JSON.stringify({ session_id: currentSessionId }),
}); });
if (voiceRoom) { if (voiceRoom) {
voiceRoom.disconnect(); voiceRoom.disconnect();
@ -623,11 +751,13 @@ messageInputEl.value = '';
async connectToVoiceRoom(t) { async connectToVoiceRoom(t) {
try { try {
const r = new LiveKitClient.Room(); 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); await r.connect(u, t);
voiceRoom = r; voiceRoom = r;
r.on("dataReceived", d => { r.on("dataReceived", (d) => {
const dc = new TextDecoder(), m = dc.decode(d); const dc = new TextDecoder(),
m = dc.decode(d);
try { try {
const j = JSON.parse(m); const j = JSON.parse(m);
if (j.type === "voice_response") { if (j.type === "voice_response") {
@ -637,7 +767,10 @@ messageInputEl.value = '';
console.log("Voice data:", m); 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) { for (const k of l) {
await r.localParticipant.publishTrack(k); await r.localParticipant.publishTrack(k);
} }
@ -652,11 +785,18 @@ messageInputEl.value = '';
console.log("Media devices not supported"); console.log("Media devices not supported");
return; return;
} }
navigator.mediaDevices.getUserMedia({ audio: true }).then(s => { navigator.mediaDevices
.getUserMedia({ audio: true })
.then((s) => {
mediaRecorder = new MediaRecorder(s); mediaRecorder = new MediaRecorder(s);
audioChunks = []; audioChunks = [];
mediaRecorder.ondataavailable = e => { audioChunks.push(e.data); }; mediaRecorder.ondataavailable = (e) => {
mediaRecorder.onstop = () => { const a = new Blob(audioChunks, { type: "audio/wav" }); this.simulateVoiceTranscription(); }; audioChunks.push(e.data);
};
mediaRecorder.onstop = () => {
const a = new Blob(audioChunks, { type: "audio/wav" });
this.simulateVoiceTranscription();
};
mediaRecorder.start(); mediaRecorder.start();
setTimeout(() => { setTimeout(() => {
if (mediaRecorder && mediaRecorder.state === "recording") { if (mediaRecorder && mediaRecorder.state === "recording") {
@ -668,18 +808,35 @@ messageInputEl.value = '';
}, 1000); }, 1000);
} }
}, 5000); }, 5000);
}).catch(e => { })
.catch((e) => {
console.error("Error accessing microphone:", e); console.error("Error accessing microphone:", e);
this.showWarning("Erro ao acessar microfone"); this.showWarning("Erro ao acessar microfone");
}); });
}, },
simulateVoiceTranscription() { 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)]; const r = p[Math.floor(Math.random() * p.length)];
if (voiceRoom) { if (voiceRoom) {
const m = { type: "voice_input", content: r, timestamp: new Date().toISOString() }; const m = {
voiceRoom.localParticipant.publishData(new TextEncoder().encode(JSON.stringify(m)), LiveKitClient.DataPacketKind.RELIABLE); 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}`); this.addMessage("voice", `🎤 ${r}`);
}, },
@ -687,8 +844,8 @@ messageInputEl.value = '';
scrollToBottom() { scrollToBottom() {
messagesDiv.scrollTop = messagesDiv.scrollHeight; messagesDiv.scrollTop = messagesDiv.scrollHeight;
isUserScrolling = false; isUserScrolling = false;
scrollToBottomBtn.classList.remove('visible'); scrollToBottomBtn.classList.remove("visible");
} },
}; };
} }