diff --git a/botlib/src/lib.rs b/botlib/src/lib.rs index 17fe024f..7f2ec4e1 100644 --- a/botlib/src/lib.rs +++ b/botlib/src/lib.rs @@ -31,7 +31,7 @@ pub use limits::{ RATE_LIMIT_BURST_MULTIPLIER, RATE_LIMIT_WINDOW_SECONDS, }; pub use message_types::MessageType; -pub use models::{ApiResponse, BotResponse, Session, Suggestion, UserMessage}; +pub use models::{ApiResponse, BotResponse, Session, Suggestion, Switcher, UserMessage}; pub use resilience::{ResilienceError, RetryConfig}; pub use version::{ get_botserver_version, init_version_registry, register_component, version_string, diff --git a/botlib/src/models.rs b/botlib/src/models.rs index 32f3af19..399869f1 100644 --- a/botlib/src/models.rs +++ b/botlib/src/models.rs @@ -152,6 +152,8 @@ pub struct UserMessage { pub timestamp: DateTime, #[serde(skip_serializing_if = "Option::is_none")] pub context_name: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub active_switchers: Vec, } impl UserMessage { @@ -173,6 +175,7 @@ impl UserMessage { media_url: None, timestamp: Utc::now(), context_name: None, + active_switchers: Vec::new(), } } @@ -241,6 +244,49 @@ impl> From for Suggestion { } } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Switcher { + pub id: String, + pub label: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, +} + +impl Switcher { + #[must_use] + pub fn new(id: impl Into, label: impl Into) -> Self { + Self { + id: id.into(), + label: label.into(), + prompt: None, + color: None, + icon: None, + } + } + + #[must_use] + pub fn with_prompt(mut self, prompt: impl Into) -> Self { + self.prompt = Some(prompt.into()); + self + } + + #[must_use] + pub fn with_color(mut self, color: impl Into) -> Self { + self.color = Some(color.into()); + self + } + + #[must_use] + pub fn with_icon(mut self, icon: impl Into) -> Self { + self.icon = Some(icon.into()); + self + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BotResponse { pub bot_id: String, @@ -254,6 +300,8 @@ pub struct BotResponse { pub is_complete: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub suggestions: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub switchers: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub context_name: Option, #[serde(default)] @@ -281,6 +329,7 @@ impl BotResponse { stream_token: None, is_complete: true, suggestions: Vec::new(), + switchers: Vec::new(), context_name: None, context_length: 0, context_max_length: 0, @@ -305,6 +354,7 @@ impl BotResponse { stream_token: Some(stream_token.into()), is_complete: false, suggestions: Vec::new(), + switchers: Vec::new(), context_name: None, context_length: 0, context_max_length: 0, @@ -373,9 +423,10 @@ impl Default for BotResponse { stream_token: None, is_complete: true, suggestions: Vec::new(), - context_name: None, - context_length: 0, - context_max_length: 0, + switchers: Vec::new(), + context_name: None, + context_length: 0, + context_max_length: 0, } } } diff --git a/botserver/src/attendance/mod.rs b/botserver/src/attendance/mod.rs index 0ca441ba..3143a07a 100644 --- a/botserver/src/attendance/mod.rs +++ b/botserver/src/attendance/mod.rs @@ -207,15 +207,16 @@ pub async fn attendant_respond( message_type: botlib::MessageType::BOT_RESPONSE, stream_token: None, is_complete: true, - suggestions: vec![], - context_name: None, - context_length: 0, - context_max_length: 0, - }; + suggestions: vec![], + switchers: Vec::new(), + context_name: None, + context_length: 0, + context_max_length: 0, + }; - match adapter.send_message(response).await { - Ok(_) => { - broadcast_attendant_action(&state, &session, &request, "attendant_response") + match adapter.send_message(response).await { + Ok(_) => { + broadcast_attendant_action(&state, &session, &request, "attendant_response") .await; ( @@ -253,12 +254,13 @@ pub async fn attendant_respond( message_type: botlib::MessageType::BOT_RESPONSE, stream_token: None, is_complete: true, - suggestions: vec![], - context_name: None, - context_length: 0, - context_max_length: 0, - }; - tx.send(response).await.is_ok() + suggestions: vec![], + switchers: Vec::new(), + context_name: None, + context_length: 0, + context_max_length: 0, + }; + tx.send(response).await.is_ok() } else { false }; @@ -578,12 +580,13 @@ async fn handle_attendant_message( message_type: botlib::MessageType::BOT_RESPONSE, stream_token: None, is_complete: true, - suggestions: vec![], - context_name: None, - context_length: 0, - context_max_length: 0, - }; - let _ = adapter.send_message(response).await; + suggestions: vec![], + switchers: Vec::new(), + context_name: None, + context_length: 0, + context_max_length: 0, + }; + let _ = adapter.send_message(response).await; } } diff --git a/botserver/src/basic/keywords/add_suggestion.rs b/botserver/src/basic/keywords/add_suggestion.rs index b66ead4c..b47d8a69 100644 --- a/botserver/src/basic/keywords/add_suggestion.rs +++ b/botserver/src/basic/keywords/add_suggestion.rs @@ -334,23 +334,27 @@ pub fn get_suggestions( redis::cmd("SMEMBERS").arg(&redis_key).query(&mut conn); match result { - Ok(items) => { - for item in items { - if let Ok(json) = serde_json::from_str::(&item) { - let suggestion = crate::core::shared::models::Suggestion { - text: json["text"].as_str().unwrap_or("").to_string(), - context: json["context"].as_str().map(|s| s.to_string()), - action: json - .get("action") - .and_then(|v| serde_json::to_string(v).ok()), - icon: json - .get("icon") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - }; - suggestions.push(suggestion); + Ok(items) => { + for item in items { + if let Ok(json) = serde_json::from_str::(&item) { + let item_type = json["type"].as_str().unwrap_or(""); + if item_type == "switcher" || item_type == "switch_context" { + continue; } + let suggestion = crate::core::shared::models::Suggestion { + text: json["text"].as_str().unwrap_or("").to_string(), + context: json["context"].as_str().map(|s| s.to_string()), + action: json + .get("action") + .and_then(|v| serde_json::to_string(v).ok()), + icon: json + .get("icon") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()), + }; + suggestions.push(suggestion); } + } info!( "Retrieved {} suggestions for session {}", suggestions.len(), diff --git a/botserver/src/basic/keywords/hearing/talk.rs b/botserver/src/basic/keywords/hearing/talk.rs index 887061cc..99eb27ec 100644 --- a/botserver/src/basic/keywords/hearing/talk.rs +++ b/botserver/src/basic/keywords/hearing/talk.rs @@ -42,6 +42,7 @@ pub async fn execute_talk( stream_token: None, is_complete: true, suggestions: Vec::new(), + switchers: Vec::new(), context_name: None, context_length: 0, context_max_length: 0, diff --git a/botserver/src/basic/keywords/play.rs b/botserver/src/basic/keywords/play.rs index ce3d2638..db19777a 100644 --- a/botserver/src/basic/keywords/play.rs +++ b/botserver/src/basic/keywords/play.rs @@ -582,15 +582,16 @@ async fn send_play_to_client( message_type: crate::core::shared::message_types::MessageType::BOT_RESPONSE, stream_token: None, is_complete: true, - suggestions: Vec::new(), - context_name: None, - context_length: 0, - context_max_length: 0, - }; + suggestions: Vec::new(), + switchers: Vec::new(), + context_name: None, + context_length: 0, + context_max_length: 0, + }; - state - .web_adapter - .send_message_to_session(&session_id.to_string(), bot_response) + state + .web_adapter + .send_message_to_session(&session_id.to_string(), bot_response) .await .map_err(|e| format!("Failed to send to client: {e}"))?; @@ -624,6 +625,7 @@ async fn send_player_command( stream_token: None, is_complete: true, suggestions: Vec::new(), + switchers: Vec::new(), context_name: None, context_length: 0, context_max_length: 0, diff --git a/botserver/src/basic/keywords/switcher.rs b/botserver/src/basic/keywords/switcher.rs index d1d8538c..8c60d6bb 100644 --- a/botserver/src/basic/keywords/switcher.rs +++ b/botserver/src/basic/keywords/switcher.rs @@ -1,11 +1,42 @@ use crate::core::shared::models::UserSession; +use crate::core::shared::models::Switcher; use crate::core::shared::state::AppState; -use log::{error, trace}; +use log::{error, info, trace}; use rhai::{Dynamic, Engine}; use serde_json::json; use std::sync::Arc; use std::time::Duration; +const STANDARD_SWITCHER_IDS: &[&str] = &[ + "tables", "infographic", "cards", "list", "comparison", "timeline", "markdown", "chart", +]; + +fn get_switcher_prompt_map() -> &'static [(&'static str, &'static str)] { + &[ + ("tables", "REGRAS DE FORMATO: SEMPRE retorne suas respostas em formato de tabela HTML usando , , , ,
, . Cada dado deve ser uma célula. Use cabeçalhos claros na primeira linha. Se houver dados numéricos, alinhe à direita. Se houver texto, alinhe à esquerda. Use cores sutis em linhas alternadas (nth-child). NÃO use markdown tables, use HTML puro."), + ("infographic", "REGRAS DE FORMATO: Crie representações visuais HTML usando SVG, progress bars, stat cards, e elementos gráficos. Use elementos como: para gráficos,
para barras de progresso, ícones emoji, badges coloridos. Organize informações visualmente com grids, flexbox, e espaçamento. Inclua legendas e rótulos visuais claros."), + ("cards", "REGRAS DE FORMATO: Retorne informações em formato de cards HTML. Cada card deve ter:
. Dentro do card use: título em

ou , subtítulo em

style=\"color:#666\", ícone emoji ou ícone SVG no topo, badges de status. Organize cards em grid usando display:grid ou flex-wrap."), + ("list", "REGRAS DE FORMATO: Use apenas listas HTML:

    para bullets e
      para números numerados. Cada item em
    1. . Use sublistas aninhadas quando apropriado. NÃO use parágrafos de texto, converta tudo em itens de lista. Adicione ícones emoji no início de cada
    2. quando possível. Use classes CSS para estilização: .list-item, .sub-list."), + ("comparison", "REGRAS DE FORMATO: Crie comparações lado a lado em HTML. Use grid de 2 colunas:
      . Cada lado em uma
      com borda colorida distinta. Use headers claros para cada lado. Adicione seção de \"Diferenças Chave\" com bullet points. Use cores contrastantes para cada lado (ex: azul vs laranja). Inclua tabela de comparação resumida no final."), + ("timeline", "REGRAS DE FORMATO: Organize eventos cronologicamente em formato de timeline HTML. Use
      com border-left vertical. Cada evento em
      com: data em , título em

      , descrição em

      . Adicione círculo indicador na timeline line. Ordene do mais antigo para o mais recente. Use espaçamento claro entre eventos."), + ("markdown", "REGRAS DE FORMATO: Use exclusivamente formato Markdown padrão. Sintaxe permitida: **negrito**, *itálico*, `inline code`, ```bloco de código```, # cabeçalhos, - bullets, 1. números, [links](url), ![alt](url), | tabela | markdown |. NÃO use HTML tags exceto para blocos de código. Siga estritamente a sintaxe CommonMark."), + ("chart", "REGRAS DE FORMATO: Crie gráficos e diagramas em HTML SVG. Use elementos SVG: , para gráficos de linha, para gráficos de barra, para gráficos de pizza, para gráficos de área. Inclua eixos com labels, grid lines, legendas. Use cores distintas para cada série de dados (ex: vermelho, azul, verde). Adicione tooltips com valores ao hover."), + ] +} + +pub fn resolve_switcher_prompt(switcher_id: &str) -> Option { + for (id, prompt) in get_switcher_prompt_map() { + if *id == switcher_id { + return Some((*prompt).to_string()); + } + } + None +} + +fn is_standard_switcher(id: &str) -> bool { + STANDARD_SWITCHER_IDS.contains(&id) +} + fn get_redis_connection(cache_client: &Arc) -> Option { let timeout = Duration::from_millis(50); cache_client.get_connection_with_timeout(timeout).ok() @@ -21,7 +52,7 @@ pub fn clear_switchers_keyword( engine .register_custom_syntax(["CLEAR", "SWITCHERS"], true, move |_context, _inputs| { if let Some(cache_client) = &cache { - let redis_key = format!("suggestions:{}:{}", user_session.bot_id, user_session.id); + let redis_key = format!("switchers:{}:{}", user_session.bot_id, user_session.id); let mut conn = match get_redis_connection(cache_client) { Some(conn) => conn, None => { @@ -59,20 +90,18 @@ pub fn add_switcher_keyword( ) { let cache = state.cache.clone(); - // ADD_SWITCHER "switcher_name" as "button text" - // Note: compiler converts AS -> as (lowercase keywords), so we use lowercase here engine .register_custom_syntax( ["ADD_SWITCHER", "$expr$", "as", "$expr$"], true, move |context, inputs| { - let switcher_name = context.eval_expression_tree(&inputs[0])?.to_string(); + let first_param = context.eval_expression_tree(&inputs[0])?.to_string(); let button_text = context.eval_expression_tree(&inputs[1])?.to_string(); add_switcher( cache.as_ref(), &user_session, - &switcher_name, + &first_param, &button_text, )?; @@ -85,26 +114,32 @@ pub fn add_switcher_keyword( fn add_switcher( cache: Option<&Arc>, user_session: &UserSession, - switcher_name: &str, + first_param: &str, button_text: &str, ) -> Result<(), Box> { + let (switcher_id, switcher_prompt) = if is_standard_switcher(first_param) { + (first_param.to_string(), resolve_switcher_prompt(first_param)) + } else { + let custom_id = format!("custom:{}", simple_hash(first_param)); + (custom_id, Some(first_param.to_string())) + }; + trace!( - "ADD_SWITCHER called: switcher={}, button={}", - switcher_name, - button_text + "ADD_SWITCHER: id={}, label={}, is_standard={}", + switcher_id, + button_text, + is_standard_switcher(first_param) ); if let Some(cache_client) = cache { - let redis_key = format!("suggestions:{}:{}", user_session.bot_id, user_session.id); + let redis_key = format!("switchers:{}:{}", user_session.bot_id, user_session.id); - let suggestion = json!({ - "type": "switcher", - "switcher": switcher_name, - "text": button_text, - "action": { - "type": "switch_context", - "switcher": switcher_name - } + let switcher_data = json!({ + "id": switcher_id, + "label": button_text, + "prompt": switcher_prompt, + "is_standard": is_standard_switcher(first_param), + "original_param": first_param }); let mut conn = match get_redis_connection(cache_client) { @@ -117,39 +152,137 @@ fn add_switcher( let _: Result = redis::cmd("SADD") .arg(&redis_key) - .arg(suggestion.to_string()) + .arg(switcher_data.to_string()) .query(&mut conn); trace!( - "Added switcher suggestion '{}' to session {}", - switcher_name, + "Added switcher '{}' ({}) to session {}", + switcher_id, + if is_standard_switcher(first_param) { "standard" } else { "custom" }, user_session.id ); } else { - trace!("No cache configured, switcher suggestion not added"); + trace!("No cache configured, switcher not added"); } Ok(()) } +fn simple_hash(s: &str) -> u64 { + let mut hash: u64 = 0; + for byte in s.bytes() { + hash = hash.wrapping_mul(31).wrapping_add(byte as u64); + } + hash +} + +pub fn get_switchers( + cache: Option<&Arc>, + bot_id: &str, + session_id: &str, +) -> Vec { + let mut switchers = Vec::new(); + + if let Some(cache_client) = cache { + let redis_key = format!("switchers:{}:{}", bot_id, session_id); + + let mut conn = match get_redis_connection(cache_client) { + Some(conn) => conn, + None => { + trace!("Cache not ready, returning empty switchers"); + return switchers; + } + }; + + let result: Result, redis::RedisError> = + redis::cmd("SMEMBERS").arg(&redis_key).query(&mut conn); + + match result { + Ok(items) => { + for item in items { + if let Ok(json) = serde_json::from_str::(&item) { + let switcher = Switcher::new( + json["id"].as_str().unwrap_or(""), + json["label"].as_str().unwrap_or(""), + ) + .with_prompt(json["prompt"].as_str().unwrap_or("")); + switchers.push(switcher); + } + } + info!( + "Retrieved {} switchers for session {}", + switchers.len(), + session_id + ); + } + Err(e) => error!("Failed to get switchers from Redis: {}", e), + } + } + + switchers +} + +pub fn resolve_active_switchers( + cache: Option<&Arc>, + bot_id: &str, + session_id: &str, + active_ids: &[String], +) -> String { + if active_ids.is_empty() { + return String::new(); + } + + let stored_switchers = get_switchers(cache, bot_id, session_id); + let mut prompts: Vec = Vec::new(); + + for id in active_ids { + let prompt = stored_switchers + .iter() + .find(|s| s.id == *id) + .and_then(|s| s.prompt.clone()) + .or_else(|| resolve_switcher_prompt(id)); + + if let Some(p) = prompt { + if !p.is_empty() { + prompts.push(p); + } + } + } + + prompts.join("\n\n") +} + #[cfg(test)] mod tests { - use serde_json::json; + use super::*; #[test] - fn test_switcher_json() { - let suggestion = json!({ - "type": "switcher", - "switcher": "mode_switcher", - "text": "Switch Mode", - "action": { - "type": "switch_context", - "switcher": "mode_switcher" - } - }); + fn test_is_standard_switcher() { + assert!(is_standard_switcher("tables")); + assert!(is_standard_switcher("chart")); + assert!(!is_standard_switcher("my_custom")); + } - assert_eq!(suggestion["type"], "switcher"); - assert_eq!(suggestion["action"]["type"], "switch_context"); - assert_eq!(suggestion["switcher"], "mode_switcher"); + #[test] + fn test_resolve_standard_prompt() { + let prompt = resolve_switcher_prompt("tables"); + assert!(prompt.is_some()); + assert!(prompt.unwrap().contains("tabela HTML")); + } + + #[test] + fn test_resolve_unknown_returns_none() { + let prompt = resolve_switcher_prompt("nonexistent"); + assert!(prompt.is_none()); + } + + #[test] + fn test_custom_switcher_id() { + let id = if is_standard_switcher("use quadrados") { + "use quadrados".to_string() + } else { + format!("custom:{}", simple_hash("use quadrados")) + }; + assert!(id.starts_with("custom:")); } } diff --git a/botserver/src/basic/keywords/universal_messaging.rs b/botserver/src/basic/keywords/universal_messaging.rs index 3ada0a64..67226d99 100644 --- a/botserver/src/basic/keywords/universal_messaging.rs +++ b/botserver/src/basic/keywords/universal_messaging.rs @@ -249,43 +249,46 @@ pub async fn send_message_to_recipient( message_type: MessageType::EXTERNAL, stream_token: None, is_complete: true, - suggestions: vec![], - context_name: None, - context_length: 0, - context_max_length: 0, - }; - adapter.send_message(response).await?; - } - "instagram" => { - let adapter = InstagramAdapter::new(); - let response = crate::core::shared::models::BotResponse { - bot_id: "default".to_string(), - session_id: user.id.to_string(), - user_id: recipient_id.clone(), - channel: "instagram".to_string(), - content: message.to_string(), - message_type: MessageType::EXTERNAL, - stream_token: None, - is_complete: true, - suggestions: vec![], - context_name: None, - context_length: 0, - context_max_length: 0, - }; - adapter.send_message(response).await?; - } - "teams" => { - let adapter = TeamsAdapter::new(state.conn.clone(), user.bot_id); - let response = crate::core::shared::models::BotResponse { - bot_id: "default".to_string(), - session_id: user.id.to_string(), - user_id: recipient_id.clone(), - channel: "teams".to_string(), - content: message.to_string(), - message_type: MessageType::EXTERNAL, - stream_token: None, - is_complete: true, - suggestions: vec![], + suggestions: vec![], + switchers: Vec::new(), + context_name: None, + context_length: 0, + context_max_length: 0, + }; + adapter.send_message(response).await?; + } + "instagram" => { + let adapter = InstagramAdapter::new(); + let response = crate::core::shared::models::BotResponse { + bot_id: "default".to_string(), + session_id: user.id.to_string(), + user_id: recipient_id.clone(), + channel: "instagram".to_string(), + content: message.to_string(), + message_type: MessageType::EXTERNAL, + stream_token: None, + is_complete: true, + suggestions: vec![], + switchers: Vec::new(), + context_name: None, + context_length: 0, + context_max_length: 0, + }; + adapter.send_message(response).await?; + } + "teams" => { + let adapter = TeamsAdapter::new(state.conn.clone(), user.bot_id); + let response = crate::core::shared::models::BotResponse { + bot_id: "default".to_string(), + session_id: user.id.to_string(), + user_id: recipient_id.clone(), + channel: "teams".to_string(), + content: message.to_string(), + message_type: MessageType::EXTERNAL, + stream_token: None, + is_complete: true, + suggestions: vec![], + switchers: Vec::new(), context_name: None, context_length: 0, context_max_length: 0, @@ -608,6 +611,7 @@ async fn send_web_message( stream_token: None, is_complete: true, suggestions: Vec::new(), + switchers: Vec::new(), context_name: None, context_length: 0, context_max_length: 0, diff --git a/botserver/src/core/bot/mod.rs b/botserver/src/core/bot/mod.rs index a57f8b1a..35a6b1ae 100644 --- a/botserver/src/core/bot/mod.rs +++ b/botserver/src/core/bot/mod.rs @@ -19,9 +19,13 @@ use crate::llm::OpenAIClient; use crate::nvidia::get_system_metrics; use crate::core::shared::message_types::MessageType; use crate::core::shared::models::{BotResponse, UserMessage, UserSession}; +#[cfg(not(feature = "chat"))] +use crate::core::shared::models::Switcher; use crate::core::shared::state::AppState; #[cfg(feature = "chat")] use crate::basic::keywords::add_suggestion::get_suggestions; +#[cfg(feature = "chat")] +use crate::basic::keywords::switcher::{get_switchers, resolve_active_switchers}; use html2md::parse_html; use axum::extract::ws::{Message, WebSocket}; @@ -408,45 +412,44 @@ impl BotOrchestrator { format!("Erro ao executar '{}': {}", tool_name, tool_result.error.unwrap_or_default()) }; - // Direct tool execution — return result immediately, no LLM call - let mut suggestions = vec![]; - if let Some(cache) = &self.state.cache { - #[cfg(feature = "chat")] - { - // Try to restore existing suggestions so they don't disappear in the UI - suggestions = get_suggestions(Some(cache), &message.bot_id, &message.session_id); - } - } + // Direct tool execution — return result immediately, no LLM call + let mut suggestions = vec![]; + let mut switchers = vec![]; + if let Some(cache) = &self.state.cache { + #[cfg(feature = "chat")] + { + // Try to restore existing suggestions so they don't disappear in the UI + suggestions = get_suggestions(Some(cache), &message.bot_id, &message.session_id); + switchers = get_switchers(Some(cache), &message.bot_id, &message.session_id); + } + } - 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: response_content, - message_type: MessageType::BOT_RESPONSE, - stream_token: None, - is_complete: true, - suggestions, - context_name: None, - context_length: 0, - context_max_length: 0, - }; + 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: response_content, + message_type: MessageType::BOT_RESPONSE, + stream_token: None, + is_complete: true, + suggestions, + switchers, + context_name: None, + context_length: 0, + context_max_length: 0, + }; let _ = response_tx.send(final_response).await; return Ok(()); } } - // Handle SYSTEM messages (type 7) - inject into history as system role - if message.message_type == MessageType::SYSTEM { - if !message_content.is_empty() { - info!("SYSTEM message injection for session {}", session_id); - let mut sm = self.state.session_manager.blocking_lock(); - sm.save_message(session_id, user_id, 3, &message_content, 1)?; // role 3 = System - } - return Ok(()); - } + // Handle SYSTEM messages (type 7) - no longer saved to DB, just acknowledge + if message.message_type == MessageType::SYSTEM { + trace!("SYSTEM message received for session {} (deprecated - switchers now via active_switchers field)", session_id); + return Ok(()); + } // Legacy: Handle direct tool invocation via __TOOL__: prefix if message_content.starts_with("__TOOL__:") { @@ -477,11 +480,13 @@ impl BotOrchestrator { message_type: MessageType::BOT_RESPONSE, stream_token: None, is_complete: true, - suggestions: vec![], - context_name: None, - context_length: 0, - context_max_length: 0, - }; + suggestions: vec![], + switchers: Vec::new(), + context_name: None, + context_length: 0, + context_max_length: 0, + }; + if let Err(e) = response_tx.send(final_response).await { error!("Failed to send tool response: {}", e); @@ -603,10 +608,26 @@ impl BotOrchestrator { Ok((session, context_data, history, model, key, system_prompt, bot_llm_url, explicit_llm_provider, bot_endpoint_path)) }, ) - .await?? - }; + .await?? + }; - let mut messages = OpenAIClient::build_messages(&system_prompt, &context_data, &history); + let system_prompt = if !message.active_switchers.is_empty() { + let switcher_prompts = resolve_active_switchers( + self.state.cache.as_ref(), + &session.bot_id.to_string(), + &session.id.to_string(), + &message.active_switchers, + ); + if switcher_prompts.is_empty() { + system_prompt + } else { + format!("{system_prompt}\n\n{switcher_prompts}") + } + } else { + system_prompt + }; + + let mut messages = OpenAIClient::build_messages(&system_prompt, &context_data, &history); trace!("Built messages array with {} items, first message role: {:?}", messages.as_array().map(|a| a.len()).unwrap_or(0), @@ -731,22 +752,28 @@ impl BotOrchestrator { let bot_id_str = message.bot_id.clone(); let session_id_str = message.session_id.clone(); - #[cfg(feature = "chat")] - let suggestions = get_suggestions(self.state.cache.as_ref(), &bot_id_str, &session_id_str); - #[cfg(not(feature = "chat"))] - let suggestions: Vec = Vec::new(); + #[cfg(feature = "chat")] + let suggestions = get_suggestions(self.state.cache.as_ref(), &bot_id_str, &session_id_str); + #[cfg(not(feature = "chat"))] + let suggestions: Vec = Vec::new(); - let final_response = BotResponse { - bot_id: message.bot_id, - user_id: message.user_id, - session_id: message.session_id, - channel: message.channel, - content: String::new(), - message_type: MessageType::BOT_RESPONSE, - stream_token: None, - is_complete: true, - suggestions, - context_name: None, + #[cfg(feature = "chat")] + let switchers = get_switchers(self.state.cache.as_ref(), &bot_id_str, &session_id_str); + #[cfg(not(feature = "chat"))] + let switchers: Vec = Vec::new(); + + let final_response = BotResponse { + bot_id: message.bot_id, + user_id: message.user_id, + session_id: message.session_id, + channel: message.channel, + content: String::new(), + message_type: MessageType::BOT_RESPONSE, + stream_token: None, + is_complete: true, + suggestions, + switchers, + context_name: None, context_length: 0, context_max_length: 0, }; @@ -982,8 +1009,9 @@ while let Some(chunk) = stream_rx.recv().await { message_type: MessageType::BOT_RESPONSE, stream_token: None, is_complete: false, - suggestions: Vec::new(), - context_name: None, + suggestions: Vec::new(), + switchers: Vec::new(), + context_name: None, context_length: 0, context_max_length: 0, }; @@ -1046,6 +1074,7 @@ while let Some(chunk) = stream_rx.recv().await { stream_token: None, is_complete: false, suggestions: Vec::new(), + switchers: Vec::new(), context_name: None, context_length: 0, context_max_length: 0, @@ -1078,6 +1107,7 @@ while let Some(chunk) = stream_rx.recv().await { stream_token: None, is_complete: false, suggestions: Vec::new(), + switchers: Vec::new(), context_name: None, context_length: 0, context_max_length: 0, @@ -1114,6 +1144,7 @@ while let Some(chunk) = stream_rx.recv().await { stream_token: None, is_complete: false, suggestions: Vec::new(), + switchers: Vec::new(), context_name: None, context_length: 0, context_max_length: 0, @@ -1165,6 +1196,7 @@ while let Some(chunk) = stream_rx.recv().await { stream_token: None, is_complete: false, suggestions: Vec::new(), + switchers: Vec::new(), context_name: None, context_length: 0, context_max_length: 0, @@ -1205,6 +1237,7 @@ while let Some(chunk) = stream_rx.recv().await { stream_token: None, is_complete: false, suggestions: Vec::new(), + switchers: Vec::new(), context_name: None, context_length: 0, context_max_length: 0, @@ -1264,6 +1297,7 @@ while let Some(chunk) = stream_rx.recv().await { stream_token: None, is_complete: false, suggestions: Vec::new(), + switchers: Vec::new(), context_name: None, context_length: 0, context_max_length: 0, @@ -1348,6 +1382,11 @@ while let Some(chunk) = stream_rx.recv().await { #[cfg(not(feature = "chat"))] let suggestions: Vec = Vec::new(); + #[cfg(feature = "chat")] + let switchers = get_switchers(self.state.cache.as_ref(), &bot_id_str, &session_id_str); + #[cfg(not(feature = "chat"))] + let switchers: Vec = Vec::new(); + // Flush any remaining HTML buffer before sending final response if !html_buffer.is_empty() { trace!("Flushing remaining {} chars in HTML buffer", html_buffer.len()); @@ -1359,13 +1398,14 @@ while let Some(chunk) = stream_rx.recv().await { content: html_buffer.clone(), message_type: MessageType::BOT_RESPONSE, stream_token: None, - is_complete: false, - suggestions: Vec::new(), - context_name: None, - context_length: 0, - context_max_length: 0, - }; - let _ = response_tx.send(final_chunk).await; + is_complete: false, + suggestions: Vec::new(), + switchers: Vec::new(), + context_name: None, + context_length: 0, + context_max_length: 0, + }; + let _ = response_tx.send(final_chunk).await; html_buffer.clear(); } @@ -1383,11 +1423,12 @@ while let Some(chunk) = stream_rx.recv().await { message_type: MessageType::BOT_RESPONSE, stream_token: None, is_complete: true, - suggestions, - context_name: None, - context_length: 0, - context_max_length: 0, - }; + suggestions, + switchers, + context_name: None, + context_length: 0, + context_max_length: 0, +}; response_tx.send(final_response).await?; Ok(()) @@ -1411,6 +1452,7 @@ while let Some(chunk) = stream_rx.recv().await { stream_token: None, is_complete: true, suggestions: Vec::new(), + switchers: Vec::new(), context_name: None, context_length: 0, context_max_length: 0, @@ -1711,10 +1753,11 @@ async fn handle_websocket( } } - // Fetch suggestions from Redis and send to frontend - let user_id_str = user_id.to_string(); - let suggestions = get_suggestions(state_for_redis.cache.as_ref(), &bot_id_str, &session_id_str); - if !suggestions.is_empty() { + // Fetch suggestions and switchers from Redis and send to frontend + let user_id_str = user_id.to_string(); + let suggestions = get_suggestions(state_for_redis.cache.as_ref(), &bot_id_str, &session_id_str); + let switchers = get_switchers(state_for_redis.cache.as_ref(), &bot_id_str, &session_id_str); + if !suggestions.is_empty() || !switchers.is_empty() { info!("Sending {} suggestions to frontend for session {}", suggestions.len(), session_id); let response = BotResponse { bot_id: bot_id_str.clone(), @@ -1724,9 +1767,10 @@ async fn handle_websocket( content: String::new(), message_type: MessageType::BOT_RESPONSE, stream_token: None, - is_complete: true, - suggestions, - context_name: None, + is_complete: true, + suggestions, + switchers, + context_name: None, context_length: 0, context_max_length: 0, }; diff --git a/botserver/src/core/bot/multimedia.rs b/botserver/src/core/bot/multimedia.rs index 91ac4399..da6dec69 100644 --- a/botserver/src/core/bot/multimedia.rs +++ b/botserver/src/core/bot/multimedia.rs @@ -185,6 +185,7 @@ impl MultimediaHandler for DefaultMultimediaHandler { stream_token: None, is_complete: true, suggestions: Vec::new(), + switchers: Vec::new(), context_name: None, context_length: 0, context_max_length: 0, @@ -213,6 +214,7 @@ impl MultimediaHandler for DefaultMultimediaHandler { stream_token: None, is_complete: true, suggestions: Vec::new(), + switchers: Vec::new(), context_name: None, context_length: 0, context_max_length: 0, @@ -246,6 +248,7 @@ impl MultimediaHandler for DefaultMultimediaHandler { stream_token: None, is_complete: true, suggestions: Vec::new(), + switchers: Vec::new(), context_name: None, context_length: 0, context_max_length: 0, @@ -279,6 +282,7 @@ impl MultimediaHandler for DefaultMultimediaHandler { stream_token: None, is_complete: true, suggestions: Vec::new(), + switchers: Vec::new(), context_name: None, context_length: 0, context_max_length: 0, @@ -308,6 +312,7 @@ impl MultimediaHandler for DefaultMultimediaHandler { stream_token: None, is_complete: true, suggestions: Vec::new(), + switchers: Vec::new(), context_name: None, context_length: 0, context_max_length: 0, @@ -325,6 +330,7 @@ impl MultimediaHandler for DefaultMultimediaHandler { stream_token: None, is_complete: true, suggestions: Vec::new(), + switchers: Vec::new(), context_name: None, context_length: 0, context_max_length: 0, diff --git a/botserver/src/core/shared/models/mod.rs b/botserver/src/core/shared/models/mod.rs index 6379848a..0fc67fb6 100644 --- a/botserver/src/core/shared/models/mod.rs +++ b/botserver/src/core/shared/models/mod.rs @@ -46,7 +46,7 @@ pub use super::schema::{ pub use super::schema::kb::{kb_collections, kb_group_associations}; pub use botlib::message_types::MessageType; -pub use botlib::models::{ApiResponse, Attachment, BotResponse, Session, Suggestion, UserMessage}; +pub use botlib::models::{ApiResponse, Attachment, BotResponse, Session, Suggestion, Switcher, UserMessage}; // Manually export OrganizationInvitation as it is defined in core but table is organization_invitations pub use self::core::OrganizationInvitation; diff --git a/botserver/src/meet/service.rs b/botserver/src/meet/service.rs index ceff218e..fcc461aa 100644 --- a/botserver/src/meet/service.rs +++ b/botserver/src/meet/service.rs @@ -458,12 +458,13 @@ impl MeetingService { message_type: MessageType::BOT_RESPONSE, stream_token: None, is_complete: true, - suggestions: Vec::new(), - context_name: None, - context_length: 0, - context_max_length: 0, - }) - } + suggestions: Vec::new(), + switchers: Vec::new(), + context_name: None, + context_length: 0, + context_max_length: 0, + }) +} async fn broadcast_to_room(&self, room_id: &str, message: MeetingMessage) { let connections = self.connections.read().await; diff --git a/botserver/src/msteams/mod.rs b/botserver/src/msteams/mod.rs index 69eb5d2c..a88dd557 100644 --- a/botserver/src/msteams/mod.rs +++ b/botserver/src/msteams/mod.rs @@ -100,11 +100,12 @@ async fn send_message( message_type: botlib::MessageType::BOT_RESPONSE, stream_token: None, is_complete: true, - suggestions: vec![], - context_name: None, - context_length: 0, - context_max_length: 0, - }; + suggestions: vec![], + switchers: Vec::new(), + context_name: None, + context_length: 0, + context_max_length: 0, + }; match adapter.send_message(response).await { Ok(_) => (StatusCode::OK, Json(serde_json::json!({"success": true}))), diff --git a/botserver/src/whatsapp/mod.rs b/botserver/src/whatsapp/mod.rs index cb862f37..0acd08c2 100644 --- a/botserver/src/whatsapp/mod.rs +++ b/botserver/src/whatsapp/mod.rs @@ -579,14 +579,15 @@ async fn process_incoming_message( message_type: MessageType::BOT_RESPONSE, stream_token: None, is_complete: true, - suggestions: vec![], - context_name: None, - context_length: 0, - context_max_length: 0, - }; - if let Err(e) = adapter.send_message(bot_response).await { - error!("Failed to send routing confirmation: {}", e); - } + suggestions: vec![], + switchers: Vec::new(), + context_name: None, + context_length: 0, + context_max_length: 0, + }; + if let Err(e) = adapter.send_message(bot_response).await { + error!("Failed to send routing confirmation: {}", e); + } // Execute start.bas immediately by calling route_to_bot info!("Executing start.bas for bot '{}' via route_to_bot", routed_bot_id); @@ -634,14 +635,15 @@ async fn process_incoming_message( message_type: MessageType::BOT_RESPONSE, stream_token: None, is_complete: true, - suggestions: vec![], - context_name: None, - context_length: 0, - context_max_length: 0, - }; - if let Err(e) = adapter.send_message(bot_response).await { - error!("Failed to send clear confirmation: {}", e); - } + suggestions: vec![], + switchers: Vec::new(), + context_name: None, + context_length: 0, + context_max_length: 0, + }; + if let Err(e) = adapter.send_message(bot_response).await { + error!("Failed to send clear confirmation: {}", e); + } info!("Cleared conversation history for WhatsApp user {}", phone); } Err(e) => { @@ -663,14 +665,15 @@ async fn process_incoming_message( message_type: MessageType::BOT_RESPONSE, stream_token: None, is_complete: true, - suggestions: vec![], - context_name: None, - context_length: 0, - context_max_length: 0, - }; - if let Err(e) = adapter.send_message(bot_response).await { - error!("Failed to send attendant command response: {}", e); - } + suggestions: vec![], + switchers: Vec::new(), + context_name: None, + context_length: 0, + context_max_length: 0, + }; + if let Err(e) = adapter.send_message(bot_response).await { + error!("Failed to send attendant command response: {}", e); + } return Ok(()); } } @@ -1147,13 +1150,14 @@ async fn route_to_bot( message_type: crate::core::shared::models::MessageType::BOT_RESPONSE, stream_token: None, is_complete: is_final, - suggestions: vec![], - context_name: None, - context_length: 0, - context_max_length: 0, - }; + suggestions: vec![], + switchers: Vec::new(), + context_name: None, + context_length: 0, + context_max_length: 0, + }; - if let Err(e) = adapter.send_message(wa_response).await { + if let Err(e) = adapter.send_message(wa_response).await { log::error!("Failed to send WhatsApp response part: {}", e); } // Rate limiting is handled by WhatsAppAdapter::send_whatsapp_message @@ -1319,13 +1323,14 @@ async fn route_to_bot( message_type: MessageType::BOT_RESPONSE, stream_token: None, is_complete: true, - suggestions: vec![], - context_name: None, - context_length: 0, - context_max_length: 0, - }; + suggestions: vec![], + switchers: Vec::new(), + context_name: None, + context_length: 0, + context_max_length: 0, + }; - if let Err(e) = adapter.send_message(error_response).await { + if let Err(e) = adapter.send_message(error_response).await { error!("Failed to send error response: {}", e); } } @@ -1490,6 +1495,7 @@ pub async fn send_message( stream_token: None, is_complete: true, suggestions: vec![], + switchers: Vec::new(), context_name: None, context_length: 0, context_max_length: 0, @@ -1600,13 +1606,14 @@ pub async fn attendant_respond( message_type: MessageType::BOT_RESPONSE, stream_token: None, is_complete: true, - suggestions: vec![], - context_name: None, - context_length: 0, - context_max_length: 0, - }; + suggestions: vec![], + switchers: Vec::new(), + context_name: None, + context_length: 0, + context_max_length: 0, + }; - match adapter.send_message(response).await { + match adapter.send_message(response).await { Ok(_) => ( StatusCode::OK, Json(serde_json::json!({ diff --git a/botui/ui/suite/chat/chat.html b/botui/ui/suite/chat/chat.html index 38930bfa..170c906c 100644 --- a/botui/ui/suite/chat/chat.html +++ b/botui/ui/suite/chat/chat.html @@ -18,7 +18,7 @@