feat: Add switcher toggle functionality

- Added SWITCHER_TOGGLE message type (8) for reprocessing last user message with active switchers
- Backend: Handler fetches last user question from DB, mutates message in-place, injects switcher prompts into system_prompt
- Backend: Switcher replays skip message_history save to avoid duplication
- Frontend: toggleSwitcher() sends SWITCHER_TOGGLE when input empty, sendMessage() when text present
- Frontend: Added TOOL_EXEC and SWITCHER_TOGGLE to MessageType constants
- Fixed session_id shadowing bug in DB query (used session_id_for_query)
- Preserves conversation history for LLM context when reprocessing with switchers
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-04-26 16:36:03 -03:00
parent 6a64756bfd
commit 0911448548
4 changed files with 88 additions and 18 deletions

View file

@ -17,8 +17,9 @@ impl MessageType {
pub const CONTEXT_CHANGE: Self = Self(5);
pub const TOOL_EXEC: Self = Self(6);
pub const SYSTEM: Self = Self(7);
pub const TOOL_EXEC: Self = Self(6);
pub const SYSTEM: Self = Self(7);
pub const SWITCHER_TOGGLE: Self = Self(8);
}
impl From<i32> for MessageType {
@ -50,6 +51,7 @@ impl std::fmt::Display for MessageType {
5 => "CONTEXT_CHANGE",
6 => "TOOL_EXEC",
7 => "SYSTEM",
8 => "SWITCHER_TOGGLE",
_ => "UNKNOWN",
};
write!(f, "{name}")

View file

@ -362,11 +362,11 @@ impl BotOrchestrator {
}
#[cfg(feature = "llm")]
pub async fn stream_response(
&self,
message: UserMessage,
response_tx: mpsc::Sender<BotResponse>,
#[cfg(feature = "llm")]
pub async fn stream_response(
&self,
mut message: UserMessage,
response_tx: mpsc::Sender<BotResponse>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
trace!(
"Streaming response for user: {}, session: {}",
@ -374,13 +374,12 @@ impl BotOrchestrator {
message.session_id
);
let user_id = Uuid::parse_str(&message.user_id)?;
let session_id = Uuid::parse_str(&message.session_id)?;
let message_content = message.content.clone();
let user_id = Uuid::parse_str(&message.user_id)?;
let session_id = Uuid::parse_str(&message.session_id)?;
// Handle direct tool execution via TOOL_EXEC message type (invisible to user)
if message.message_type == MessageType::TOOL_EXEC {
let tool_name = message_content.trim();
// Handle direct tool execution via TOOL_EXEC message type (invisible to user)
if message.message_type == MessageType::TOOL_EXEC {
let tool_name = message.content.trim();
if !tool_name.is_empty() {
info!("tool_exec: Direct tool execution: {}", tool_name);
@ -451,7 +450,56 @@ impl BotOrchestrator {
return Ok(());
}
// Legacy: Handle direct tool invocation via __TOOL__: prefix
// Handle SWITCHER_TOGGLE (type 8) - user clicked a switcher chip
// Re-process last user message with the active switchers injected into system prompt
// Mutates message in-place to avoid recursive async call
// Replays are NOT saved to message_history, so the DB always has the last original user question
// When user types a new message (e.g. "faz azul"), it IS saved and becomes the new base for switchers
let mut is_switcher_replay = false;
if message.message_type == MessageType::SWITCHER_TOGGLE {
let last_user_content: Option<String> = {
let conn = self.state.conn.get().ok();
let session_id_for_query = session_id;
conn.and_then(|mut db_conn| {
use crate::core::shared::models::schema::message_history::dsl::*;
message_history
.filter(session_id.eq(session_id_for_query))
.filter(role.eq(1))
.order(created_at.desc())
.select(content_encrypted)
.first::<String>(&mut db_conn)
.ok()
})
};
if let Some(last_content) = last_user_content {
message.content = last_content;
message.message_type = MessageType::USER;
is_switcher_replay = true;
} else {
let empty_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: String::new(),
message_type: MessageType::BOT_RESPONSE,
stream_token: None,
is_complete: true,
suggestions: Vec::new(),
switchers: Vec::new(),
context_name: None,
context_length: 0,
context_max_length: 0,
};
let _ = response_tx.send(empty_response).await;
return Ok(());
}
}
let message_content = message.content.clone();
// Legacy: Handle direct tool invocation via __TOOL__: prefix
if message_content.starts_with("__TOOL__:") {
let tool_name = message_content.trim_start_matches("__TOOL__:").trim();
if !tool_name.is_empty() {
@ -525,10 +573,10 @@ impl BotOrchestrator {
session.context_data = serde_json::Value::Object(map);
}
if !message.content.trim().is_empty() {
let mut sm = state_clone.session_manager.blocking_lock();
sm.save_message(session.id, user_id, 1, &message.content, 1)?;
}
if !message.content.trim().is_empty() && !is_switcher_replay {
let mut sm = state_clone.session_manager.blocking_lock();
sm.save_message(session.id, user_id, 1, &message.content, 1)?;
}
let context_data = {
let sm = state_clone.session_manager.blocking_lock();

View file

@ -36,6 +36,8 @@ var MessageType = {
CONTINUE: 3,
SUGGESTION: 4,
CONTEXT_CHANGE: 5,
TOOL_EXEC: 6,
SWITCHER_TOGGLE: 8,
};
var EntityTypes = {

View file

@ -19,6 +19,24 @@ function toggleSwitcher(switcherId) {
ChatState.activeSwitchers.add(switcherId);
}
renderSwitcherChips();
var input = document.getElementById("messageInput");
var inputText = input ? input.value.trim() : "";
if (inputText) {
window.sendMessage(inputText);
} else if (ChatState.ws && ChatState.ws.readyState === WebSocket.OPEN) {
ChatState.ws.send(JSON.stringify({
bot_id: ChatState.currentBotId,
user_id: ChatState.currentUserId,
session_id: ChatState.currentSessionId,
channel: "web",
content: "",
message_type: MessageType.SWITCHER_TOGGLE,
active_switchers: Array.from(ChatState.activeSwitchers),
timestamp: new Date().toISOString(),
}));
}
}
function renderBotSwitchers(switchers) {