From 9ed5c10e4b7f8fae590c139ee4133b16b1ffaf13 Mon Sep 17 00:00:00 2001 From: bowen628 Date: Fri, 6 Mar 2026 19:48:02 +0800 Subject: [PATCH 1/4] feat: Update mobile-web and bot calling --- BitFun@0.1.1 | 0 bitfun-mobile-web@0.1.1 | 0 node | 0 src/apps/cli/src/agent/core_adapter.rs | 4 +- src/apps/desktop/src/api/agentic_api.rs | 11 +- .../desktop/src/api/context_upload_api.rs | 79 +- .../desktop/src/api/image_analysis_api.rs | 4 +- .../src/agentic/coordination/coordinator.rs | 240 ++++- .../src/agentic/execution/stream_processor.rs | 19 +- .../src/agentic/session/session_manager.rs | 59 ++ .../core/src/agentic/tools/image_context.rs | 93 +- .../implementations/ask_user_question_tool.rs | 22 +- .../remote_connect/bot/command_router.rs | 848 ++++++++++++++++-- .../src/service/remote_connect/bot/feishu.rs | 293 +++++- .../service/remote_connect/bot/telegram.rs | 39 +- .../service/remote_connect/remote_server.rs | 646 +++++++------ src/mobile-web/src/pages/ChatPage.tsx | 316 ++++--- .../src/services/RemoteSessionManager.ts | 16 +- .../src/styles/components/tool-card.scss | 8 + .../components/PersistentFooterActions.tsx | 68 +- .../RemoteConnectDialog.scss | 27 + .../RemoteConnectDialog.tsx | 185 ++-- .../RemoteConnectDisclaimer.scss | 75 ++ .../RemoteConnectDisclaimer.tsx | 87 ++ src/web-ui/src/flow_chat/hooks/useFlowChat.ts | 14 +- .../services/AgenticEventListener.ts | 11 +- .../flow-chat-manager/EventHandlerModule.ts | 14 + .../flow-chat-manager/MessageModule.ts | 38 +- .../flow-chat-manager/PersistenceModule.ts | 41 +- .../flow-chat-manager/SessionModule.ts | 73 +- .../services/flow-chat-manager/index.ts | 5 +- .../tool-cards/AskUserQuestionCard.tsx | 19 +- src/web-ui/src/locales/en-US/common.json | 3 + src/web-ui/src/locales/zh-CN/common.json | 3 + tsc | 0 35 files changed, 2492 insertions(+), 868 deletions(-) create mode 100644 BitFun@0.1.1 create mode 100644 bitfun-mobile-web@0.1.1 create mode 100644 node create mode 100644 src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.scss create mode 100644 src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.tsx create mode 100644 tsc diff --git a/BitFun@0.1.1 b/BitFun@0.1.1 new file mode 100644 index 00000000..e69de29b diff --git a/bitfun-mobile-web@0.1.1 b/bitfun-mobile-web@0.1.1 new file mode 100644 index 00000000..e69de29b diff --git a/node b/node new file mode 100644 index 00000000..e69de29b diff --git a/src/apps/cli/src/agent/core_adapter.rs b/src/apps/cli/src/agent/core_adapter.rs index 68ad0201..9bffec92 100644 --- a/src/apps/cli/src/agent/core_adapter.rs +++ b/src/apps/cli/src/agent/core_adapter.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use super::{Agent, AgentEvent, AgentResponse}; use crate::session::{ToolCall, ToolCallStatus}; -use bitfun_core::agentic::coordination::ConversationCoordinator; +use bitfun_core::agentic::coordination::{ConversationCoordinator, DialogTriggerSource}; use bitfun_core::agentic::core::SessionConfig; use bitfun_core::agentic::events::EventQueue; use bitfun_events::{AgenticEvent as CoreEvent, ToolEventData}; @@ -85,7 +85,7 @@ impl Agent for CoreAgentAdapter { message.clone(), None, self.agent_type.clone(), - false, + DialogTriggerSource::Cli, ).await?; let mut accumulated_text = String::new(); diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 10fa5c9d..ef4534e4 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -6,8 +6,9 @@ use std::sync::Arc; use tauri::{AppHandle, State}; use crate::api::app_state::AppState; -use bitfun_core::agentic::coordination::ConversationCoordinator; +use bitfun_core::agentic::coordination::{ConversationCoordinator, DialogTriggerSource}; use bitfun_core::agentic::core::*; +use bitfun_core::infrastructure::get_workspace_path; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -142,6 +143,9 @@ pub async fn create_session( coordinator: State<'_, Arc>, request: CreateSessionRequest, ) -> Result { + let workspace_path = get_workspace_path() + .map(|p| p.to_string_lossy().to_string()); + let config = request .config .map(|c| SessionConfig { @@ -157,11 +161,12 @@ pub async fn create_session( .unwrap_or_default(); let session = coordinator - .create_session_with_id( + .create_session_with_workspace( request.session_id, request.session_name.clone(), request.agent_type.clone(), config, + workspace_path, ) .await .map_err(|e| format!("Failed to create session: {}", e))?; @@ -185,7 +190,7 @@ pub async fn start_dialog_turn( request.user_input, request.turn_id, request.agent_type, - false, + DialogTriggerSource::DesktopUi, ) .await .map_err(|e| format!("Failed to start dialog turn: {}", e))?; diff --git a/src/apps/desktop/src/api/context_upload_api.rs b/src/apps/desktop/src/api/context_upload_api.rs index 57493c2e..9be133a1 100644 --- a/src/apps/desktop/src/api/context_upload_api.rs +++ b/src/apps/desktop/src/api/context_upload_api.rs @@ -1,15 +1,12 @@ //! Temporary Image Storage API use bitfun_core::agentic::tools::image_context::{ - ImageContextData as CoreImageContextData, ImageContextProvider, + create_image_context_provider as create_core_image_context_provider, + store_image_contexts, + GlobalImageContextProvider, + ImageContextData as CoreImageContextData, }; -use dashmap::DashMap; -use log::{debug, warn}; -use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; -use std::time::{SystemTime, UNIX_EPOCH}; - -static IMAGE_STORAGE: Lazy> = Lazy::new(DashMap::new); #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageContextData { @@ -47,73 +44,11 @@ pub struct UploadImageContextRequest { #[tauri::command] pub async fn upload_image_contexts(request: UploadImageContextRequest) -> Result<(), String> { - let timestamp = - current_unix_timestamp().map_err(|e| format!("Failed to get current timestamp: {}", e))?; - - for image in request.images { - let image_id = image.id.clone(); - IMAGE_STORAGE.insert(image_id.clone(), (image, timestamp)); - debug!("Stored image context: image_id={}", image_id); - } - - cleanup_expired_images(300); - + let images: Vec = request.images.into_iter().map(Into::into).collect(); + store_image_contexts(images); Ok(()) } -pub fn get_image_context(image_id: &str) -> Option { - IMAGE_STORAGE.get(image_id).map(|entry| entry.0.clone()) -} - -pub fn remove_image_context(image_id: &str) { - if IMAGE_STORAGE.remove(image_id).is_some() { - debug!("Removed image context: image_id={}", image_id); - } -} - -fn cleanup_expired_images(max_age_secs: u64) { - let now = match current_unix_timestamp() { - Ok(timestamp) => timestamp, - Err(e) => { - warn!( - "Failed to cleanup expired images due to timestamp error: {}", - e - ); - return; - } - }; - - let expired_keys: Vec = IMAGE_STORAGE - .iter() - .filter(|entry| now.saturating_sub(entry.value().1) > max_age_secs) - .map(|entry| entry.key().clone()) - .collect(); - - for key in expired_keys { - IMAGE_STORAGE.remove(&key); - debug!("Cleaned up expired image: image_id={}", key); - } -} - -#[derive(Debug)] -pub struct GlobalImageContextProvider; - -impl ImageContextProvider for GlobalImageContextProvider { - fn get_image(&self, image_id: &str) -> Option { - get_image_context(image_id).map(|data| data.into()) - } - - fn remove_image(&self, image_id: &str) { - remove_image_context(image_id); - } -} - pub fn create_image_context_provider() -> GlobalImageContextProvider { - GlobalImageContextProvider -} - -fn current_unix_timestamp() -> Result { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) + create_core_image_context_provider() } diff --git a/src/apps/desktop/src/api/image_analysis_api.rs b/src/apps/desktop/src/api/image_analysis_api.rs index 09035c0b..c2322c5d 100644 --- a/src/apps/desktop/src/api/image_analysis_api.rs +++ b/src/apps/desktop/src/api/image_analysis_api.rs @@ -1,7 +1,7 @@ //! Image Analysis API use crate::api::app_state::AppState; -use bitfun_core::agentic::coordination::ConversationCoordinator; +use bitfun_core::agentic::coordination::{ConversationCoordinator, DialogTriggerSource}; use bitfun_core::agentic::image_analysis::*; use log::error; use std::sync::Arc; @@ -111,7 +111,7 @@ pub async fn send_enhanced_message( enhanced_message.clone(), Some(request.dialog_turn_id.clone()), request.agent_type.clone(), - false, + DialogTriggerSource::DesktopApi, ) .await .map_err(|e| format!("Failed to send enhanced message: {}", e))?; diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index f84b8cf4..c3e94ac1 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -30,6 +30,21 @@ pub struct SubagentResult { pub tool_arguments: Option, } +#[derive(Debug, Clone, Copy)] +pub enum DialogTriggerSource { + DesktopUi, + DesktopApi, + RemoteRelay, + Bot, + Cli, +} + +impl DialogTriggerSource { + fn skip_tool_confirmation(self) -> bool { + matches!(self, Self::RemoteRelay | Self::Bot) + } +} + /// Cancel token cleanup guard /// /// Automatically cleans up cancel tokens in ExecutionEngine when dropped @@ -107,23 +122,129 @@ impl ConversationCoordinator { mut config: SessionConfig, workspace_path: Option, ) -> BitFunResult { - // Persist the workspace binding inside the session config so that SendMessage - // can retrieve it from memory (no slow disk search needed). - config.workspace_path = workspace_path.clone(); + let effective_workspace_path = workspace_path.or_else(|| { + crate::infrastructure::get_workspace_path() + .map(|p| p.to_string_lossy().to_string()) + }); + + // Persist the workspace binding inside the session config so execution can + // consistently restore the correct workspace regardless of the entry point. + config.workspace_path = effective_workspace_path.clone(); let session = self .session_manager .create_session_with_id(session_id, session_name, agent_type, config) .await?; + + self.sync_session_metadata_to_workspace(&session, effective_workspace_path.clone()) + .await; + self.emit_event(AgenticEvent::SessionCreated { session_id: session.session_id.clone(), session_name: session.session_name.clone(), agent_type: session.agent_type.clone(), - workspace_path, + workspace_path: effective_workspace_path, }) .await; Ok(session) } + async fn sync_session_metadata_to_workspace( + &self, + session: &Session, + workspace_path: Option, + ) { + use crate::infrastructure::PathManager; + use crate::service::conversation::{ + ConversationPersistenceManager, SessionMetadata, SessionStatus, + }; + + let Some(workspace_path) = workspace_path else { + return; + }; + + let path_manager = match PathManager::new() { + Ok(pm) => Arc::new(pm), + Err(e) => { + warn!("Failed to initialize PathManager for session metadata sync: {e}"); + return; + } + }; + + let conv_mgr = match ConversationPersistenceManager::new( + path_manager, + std::path::PathBuf::from(&workspace_path), + ) + .await + { + Ok(mgr) => mgr, + Err(e) => { + warn!( + "Failed to initialize ConversationPersistenceManager for session metadata sync: {e}" + ); + return; + } + }; + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + let existing = match conv_mgr.load_session_metadata(&session.session_id).await { + Ok(meta) => meta, + Err(e) => { + debug!( + "Failed to load existing session metadata before sync: session_id={}, error={}", + session.session_id, e + ); + None + } + }; + + let metadata = SessionMetadata { + session_id: session.session_id.clone(), + session_name: session.session_name.clone(), + agent_type: session.agent_type.clone(), + model_name: existing + .as_ref() + .map(|m| m.model_name.clone()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "default".to_string()), + created_at: existing.as_ref().map(|m| m.created_at).unwrap_or(now_ms), + last_active_at: now_ms, + turn_count: existing.as_ref().map(|m| m.turn_count).unwrap_or(0), + message_count: existing.as_ref().map(|m| m.message_count).unwrap_or(0), + tool_call_count: existing.as_ref().map(|m| m.tool_call_count).unwrap_or(0), + status: existing + .as_ref() + .map(|m| m.status.clone()) + .unwrap_or(SessionStatus::Active), + terminal_session_id: existing + .as_ref() + .and_then(|m| m.terminal_session_id.clone()), + snapshot_session_id: session + .snapshot_session_id + .clone() + .or_else(|| existing.as_ref().and_then(|m| m.snapshot_session_id.clone())), + tags: existing + .as_ref() + .map(|m| m.tags.clone()) + .unwrap_or_default(), + custom_metadata: existing + .as_ref() + .and_then(|m| m.custom_metadata.clone()), + todos: existing.as_ref().and_then(|m| m.todos.clone()), + workspace_path: Some(workspace_path), + }; + + if let Err(e) = conv_mgr.save_session_metadata(&metadata).await { + warn!( + "Failed to sync session metadata to workspace: session_id={}, error={}", + session.session_id, e + ); + } + } + /// Create a subagent session for internal AI execution. /// Unlike `create_session`, this does NOT emit `SessionCreated` to the transport layer, /// because subagent sessions are internal implementation details of the execution engine @@ -162,21 +283,35 @@ impl ConversationCoordinator { } /// Start a new dialog turn - /// Note: Events are sent to frontend via EventLoop, no Stream returned - /// skip_tool_confirmation: when true, all tool executions auto-approve (used by remote mobile messages) + /// Note: Events are sent to frontend via EventLoop, no Stream returned. + /// Channel-specific interaction policy is decided here from `trigger_source` + /// so adapters only declare where the message came from. pub async fn start_dialog_turn( &self, session_id: String, user_input: String, turn_id: Option, agent_type: String, - skip_tool_confirmation: bool, + trigger_source: DialogTriggerSource, ) -> BitFunResult<()> { - // Get latest session (re-fetch each time to ensure latest state) - let session = self - .session_manager - .get_session(&session_id) - .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))?; + // Get latest session, restoring from persistence on demand so every entry + // point can use the same start_dialog_turn flow. + let session = match self.session_manager.get_session(&session_id) { + Some(session) => session, + None => { + debug!( + "Session not found in memory, attempting restore before starting dialog: session_id={}", + session_id + ); + self.session_manager.restore_session(&session_id).await? + } + }; + + let effective_agent_type = if session.agent_type.is_empty() { + agent_type + } else { + session.agent_type.clone() + }; debug!( "Checking session state: session_id={}, state={:?}", @@ -279,7 +414,10 @@ impl ConversationCoordinator { } } - let wrapped_user_input = self.wrap_user_input(&agent_type, user_input).await?; + let original_user_input = user_input.clone(); + let wrapped_user_input = self + .wrap_user_input(&effective_agent_type, user_input) + .await?; // Start new dialog turn (sets state to Processing internally) let turn_index = self.session_manager.get_turn_count(&session_id); @@ -328,24 +466,81 @@ impl ConversationCoordinator { session_id: session_id.clone(), dialog_turn_id: turn_id.clone(), turn_index, - agent_type: session.agent_type.clone(), + agent_type: effective_agent_type.clone(), context: context_vars, subagent_parent_info: None, - skip_tool_confirmation, + skip_tool_confirmation: trigger_source.skip_tool_confirmation(), }; + // Auto-generate session title on first message + if turn_index == 0 { + let sm = self.session_manager.clone(); + let eq = self.event_queue.clone(); + let sid = session_id.clone(); + let msg = original_user_input; + tokio::spawn(async move { + let enabled = match crate::service::config::get_global_config_service().await { + Ok(svc) => svc + .get_config::(Some( + "app.ai_experience.enable_session_title_generation", + )) + .await + .unwrap_or(true), + Err(_) => true, + }; + if !enabled { + return; + } + match sm.generate_session_title(&msg, Some(20)).await { + Ok(title) => { + if let Err(e) = sm.update_session_title(&sid, &title).await { + debug!("Failed to persist auto-generated title: {e}"); + } + let _ = eq + .enqueue( + AgenticEvent::SessionTitleGenerated { + session_id: sid, + title, + method: "ai".to_string(), + }, + Some(EventPriority::Normal), + ) + .await; + } + Err(e) => { + debug!("Auto session title generation failed: {e}"); + } + } + }); + } + // Start async execution task let session_manager = self.session_manager.clone(); let execution_engine = self.execution_engine.clone(); let event_queue = self.event_queue.clone(); let session_id_clone = session_id.clone(); let turn_id_clone = turn_id.clone(); + let session_workspace_path = session.config.workspace_path.clone(); + let effective_agent_type_clone = effective_agent_type.clone(); tokio::spawn(async move { // Note: Don't check cancellation here as cancel token hasn't been created yet // Cancel token is created in execute_dialog_turn -> execute_round // execute_dialog_turn has proper cancellation checks internally + if let Some(workspace_path) = session_workspace_path { + use crate::infrastructure::{get_workspace_path, set_workspace_path}; + + let current = get_workspace_path().map(|p| p.to_string_lossy().to_string()); + if current.as_deref() != Some(workspace_path.as_str()) { + info!( + "Activating session workspace before dialog turn: session_id={}, workspace_path={}", + session_id_clone, workspace_path + ); + set_workspace_path(Some(std::path::PathBuf::from(workspace_path))); + } + } + let _ = session_manager .update_session_state( &session_id_clone, @@ -357,7 +552,7 @@ impl ConversationCoordinator { .await; match execution_engine - .execute_dialog_turn(agent_type, messages, execution_context) + .execute_dialog_turn(effective_agent_type_clone, messages, execution_context) .await { Ok(execution_result) => { @@ -773,7 +968,10 @@ impl ConversationCoordinator { /// Generate session title /// - /// Use AI to generate a concise and accurate session title based on user message content + /// Use AI to generate a concise and accurate session title based on user message content. + /// Also persists the title to the session backend. Callers that go through + /// `start_dialog_turn` do NOT need to call this separately — first-message + /// title generation is handled automatically inside `start_dialog_turn`. pub async fn generate_session_title( &self, session_id: &str, @@ -785,6 +983,14 @@ impl ConversationCoordinator { .generate_session_title(user_message, max_length) .await?; + if let Err(e) = self + .session_manager + .update_session_title(session_id, &title) + .await + { + debug!("Failed to persist generated title: {e}"); + } + let event = AgenticEvent::SessionTitleGenerated { session_id: session_id.to_string(), title: title.clone(), diff --git a/src/crates/core/src/agentic/execution/stream_processor.rs b/src/crates/core/src/agentic/execution/stream_processor.rs index 15e42502..2deba5ad 100644 --- a/src/crates/core/src/agentic/execution/stream_processor.rs +++ b/src/crates/core/src/agentic/execution/stream_processor.rs @@ -217,6 +217,7 @@ struct StreamContext { thinking_chunks_count: usize, thinking_completed_sent: bool, has_effective_output: bool, + encountered_end_turn_tool: bool, } impl StreamContext { @@ -243,6 +244,7 @@ impl StreamContext { thinking_chunks_count: 0, thinking_completed_sent: false, has_effective_output: false, + encountered_end_turn_tool: false, } } @@ -509,7 +511,15 @@ impl StreamProcessor { // Check if JSON is complete if ctx.tool_call_buffer.is_valid() { - ctx.tool_calls.push(ctx.tool_call_buffer.to_tool_call()); + let tool_call = ctx.tool_call_buffer.to_tool_call(); + if tool_call.should_end_turn { + debug!( + "End-turn tool fully detected during streaming: {} ({})", + tool_call.tool_name, tool_call.tool_id + ); + ctx.encountered_end_turn_tool = true; + } + ctx.tool_calls.push(tool_call); // Clear buffer // Normally there should be no delta data after parameters are complete, but this has been triggered in practice, possibly due to network issues or model output anomalies @@ -755,6 +765,13 @@ impl StreamProcessor { if let Some(err) = self.check_cancellation(&mut ctx, cancellation_token, "processing tool call").await { return err; } + if ctx.encountered_end_turn_tool { + debug!( + "Stopping stream after end-turn tool detection: session_id={}, turn_id={}", + ctx.session_id, ctx.dialog_turn_id + ); + break; + } } } } diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index f26042ce..9018f147 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -173,6 +173,65 @@ impl SessionManager { Ok(()) } + /// Update session title (in-memory + persistence) + pub async fn update_session_title( + &self, + session_id: &str, + title: &str, + ) -> BitFunResult<()> { + let workspace_path = self + .sessions + .get(session_id) + .and_then(|session| session.config.workspace_path.clone()) + .map(std::path::PathBuf::from) + .or_else(get_workspace_path); + + if let Some(mut session) = self.sessions.get_mut(session_id) { + session.session_name = title.to_string(); + session.updated_at = SystemTime::now(); + } + + if self.config.enable_persistence { + if let Some(session) = self.sessions.get(session_id) { + self.persistence_manager.save_session(&session).await?; + } + } + + if let Some(workspace_path) = workspace_path { + match ConversationPersistenceManager::new( + self.persistence_manager.path_manager().clone(), + workspace_path, + ) + .await + { + Ok(conv_mgr) => { + if let Ok(Some(mut meta)) = + conv_mgr.load_session_metadata(session_id).await + { + meta.session_name = title.to_string(); + meta.touch(); + if let Err(e) = conv_mgr.save_session_metadata(&meta).await { + warn!( + "Failed to persist session title in conversation metadata: {}", + e + ); + } + } + } + Err(e) => { + debug!("Failed to update conversation metadata title: {}", e); + } + } + } + + info!( + "Session title updated: session_id={}, title={}", + session_id, title + ); + + Ok(()) + } + /// Update session activity time pub fn touch_session(&self, session_id: &str) { if let Some(mut session) = self.sessions.get_mut(session_id) { diff --git a/src/crates/core/src/agentic/tools/image_context.rs b/src/crates/core/src/agentic/tools/image_context.rs index 933c90b5..3b1ba885 100644 --- a/src/crates/core/src/agentic/tools/image_context.rs +++ b/src/crates/core/src/agentic/tools/image_context.rs @@ -1,9 +1,13 @@ -//! Image context provider trait -//! -//! Through dependency injection mode, tools can access image context without directly depending on specific implementations +//! Image context provider and shared in-memory image storage. +//! +//! Through dependency injection mode, tools can access image context without +//! directly depending on specific implementations. +use dashmap::DashMap; +use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; /// Image context data #[derive(Debug, Clone, Serialize, Deserialize)] @@ -19,8 +23,11 @@ pub struct ImageContextData { pub source: String, } +static IMAGE_STORAGE: Lazy> = Lazy::new(DashMap::new); +const DEFAULT_IMAGE_MAX_AGE_SECS: u64 = 300; + /// Image context provider trait -/// +/// /// Types that implement this trait can provide image data access capabilities to tools pub trait ImageContextProvider: Send + Sync + std::fmt::Debug { /// Get image context data by image_id @@ -36,3 +43,81 @@ pub trait ImageContextProvider: Send + Sync + std::fmt::Debug { /// Optional wrapper type, for convenience pub type ImageContextProviderRef = Arc; +pub fn store_image_context(image: ImageContextData) { + let image_id = image.id.clone(); + let timestamp = current_unix_timestamp(); + IMAGE_STORAGE.insert(image_id, (image, timestamp)); + cleanup_expired_images(DEFAULT_IMAGE_MAX_AGE_SECS); +} + +pub fn store_image_contexts(images: Vec) { + for image in images { + store_image_context(image); + } +} + +pub fn get_image_context(image_id: &str) -> Option { + IMAGE_STORAGE.get(image_id).map(|entry| entry.value().0.clone()) +} + +pub fn remove_image_context(image_id: &str) { + IMAGE_STORAGE.remove(image_id); +} + +pub fn format_image_context_reference(image: &ImageContextData) -> String { + let size_label = if image.file_size > 0 { + format!(" ({:.1}KB)", image.file_size as f64 / 1024.0) + } else { + String::new() + }; + + if let Some(image_path) = &image.image_path { + format!( + "[Image: {}{}]\nPath: {}\nTip: You can use the AnalyzeImage tool with the image_path parameter.", + image.image_name, size_label, image_path + ) + } else { + format!( + "[Image: {}{} (from clipboard)]\nImage ID: {}\nTip: You can use the AnalyzeImage tool.\nParameter: image_id=\"{}\"", + image.image_name, size_label, image.id, image.id + ) + } +} + +#[derive(Debug)] +pub struct GlobalImageContextProvider; + +impl ImageContextProvider for GlobalImageContextProvider { + fn get_image(&self, image_id: &str) -> Option { + get_image_context(image_id) + } + + fn remove_image(&self, image_id: &str) { + remove_image_context(image_id); + } +} + +pub fn create_image_context_provider() -> GlobalImageContextProvider { + GlobalImageContextProvider +} + +fn cleanup_expired_images(max_age_secs: u64) { + let now = current_unix_timestamp(); + let expired_keys: Vec = IMAGE_STORAGE + .iter() + .filter(|entry| now.saturating_sub(entry.value().1) > max_age_secs) + .map(|entry| entry.key().clone()) + .collect(); + + for key in expired_keys { + IMAGE_STORAGE.remove(&key); + } +} + +fn current_unix_timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + diff --git a/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs b/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs index e2541e4b..a7ac71f2 100644 --- a/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs @@ -76,8 +76,8 @@ impl AskUserQuestionTool { } // Validate options - if question.options.len() < 2 || question.options.len() > 4 { - return Err(format!("Question {} must have 2-4 options", q_num)); + if question.options.len() < 2 || question.options.len() > 5 { + return Err(format!("Question {} must have 2-5 options", q_num)); } for (opt_idx, opt) in question.options.iter().enumerate() { @@ -171,6 +171,8 @@ impl Tool for AskUserQuestionTool { 4. Offer choices to the user about what direction to take. Usage notes: +- This tool ends the current dialog turn and waits for the user's reply before the assistant continues +- Put all questions you need into a single AskUserQuestion call instead of calling it repeatedly in one response - Users will always be able to select "Other" to provide custom text input - Use multiSelect: true to allow multiple answers to be selected for a question"#.to_string()) } @@ -213,8 +215,8 @@ Usage notes: "additionalProperties": false }, "minItems": 2, - "maxItems": 4, - "description": "The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically." + "maxItems": 5, + "description": "The available choices for this question. Must have 2-5 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically." }, "multiSelect": { "type": "boolean", @@ -249,6 +251,10 @@ Usage notes: true } + fn should_end_turn(&self) -> bool { + false + } + async fn call_impl( &self, input: &Value, @@ -264,8 +270,9 @@ Usage notes: })?; // 2. Validate question format - Self::validate_input(&tool_input) - .map_err(|e| crate::util::errors::BitFunError::Validation(e))?; + if let Err(error) = Self::validate_input(&tool_input) { + return Err(crate::util::errors::BitFunError::Validation(error)); + } let question_count = tool_input.questions.len(); debug!( @@ -307,7 +314,6 @@ Usage notes: let timeout_duration = Duration::from_secs(600); // 10 minutes match timeout(timeout_duration, rx).await { Ok(Ok(response)) => { - // Received user answer debug!( "AskUserQuestion tool received user response, tool_id: {}", tool_id @@ -337,7 +343,6 @@ Usage notes: }]) } Ok(Err(_)) => { - // Channel was closed warn!("AskUserQuestion tool channel closed, tool_id: {}", tool_id); Ok(vec![ToolResult::Result { data: json!({ @@ -348,7 +353,6 @@ Usage notes: }]) } Err(_) => { - // Timeout warn!( "AskUserQuestion tool timeout after 600 seconds, tool_id: {}", tool_id diff --git a/src/crates/core/src/service/remote_connect/bot/command_router.rs b/src/crates/core/src/service/remote_connect/bot/command_router.rs index 0c7c0270..26bc92eb 100644 --- a/src/crates/core/src/service/remote_connect/bot/command_router.rs +++ b/src/crates/core/src/service/remote_connect/bot/command_router.rs @@ -6,6 +6,10 @@ use log::{error, info}; use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; // ── Per-chat state ────────────────────────────────────────────────── @@ -41,6 +45,14 @@ pub enum PendingAction { page: usize, has_more: bool, }, + AskUserQuestion { + tool_id: String, + questions: Vec, + current_index: usize, + answers: Vec, + awaiting_custom_text: bool, + pending_answer: Option, + }, } // ── Parsed command ────────────────────────────────────────────────── @@ -52,6 +64,7 @@ pub enum BotCommand { ResumeSession, NewCodeSession, NewCoworkSession, + CancelTask(Option), Help, PairingCode(String), NumberSelection(usize), @@ -63,20 +76,94 @@ pub enum BotCommand { pub struct HandleResult { pub reply: String, + pub actions: Vec, pub forward_to_session: Option, } +#[derive(Debug, Clone)] +pub struct BotInteractiveRequest { + pub reply: String, + pub actions: Vec, + pub pending_action: PendingAction, +} + +pub type BotInteractionHandler = Arc< + dyn Fn(BotInteractiveRequest) -> Pin + Send>> + Send + Sync, +>; + +pub type BotMessageSender = Arc< + dyn Fn(String) -> Pin + Send>> + Send + Sync, +>; + pub struct ForwardRequest { pub session_id: String, pub content: String, pub agent_type: String, - pub workspace_path: Option, + pub turn_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BotQuestionOption { + pub label: String, + #[serde(default)] + pub description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BotQuestion { + #[serde(default)] + pub question: String, + #[serde(default)] + pub header: String, + #[serde(default)] + pub options: Vec, + #[serde(rename = "multiSelect", default)] + pub multi_select: bool, +} + +#[derive(Debug, Clone)] +pub struct BotAction { + pub label: String, + pub command: String, + pub style: BotActionStyle, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BotActionStyle { + Primary, + Default, +} + +impl BotAction { + pub fn primary(label: impl Into, command: impl Into) -> Self { + Self { + label: label.into(), + command: command.into(), + style: BotActionStyle::Primary, + } + } + + pub fn secondary(label: impl Into, command: impl Into) -> Self { + Self { + label: label.into(), + command: command.into(), + style: BotActionStyle::Default, + } + } } // ── Command parsing ───────────────────────────────────────────────── pub fn parse_command(text: &str) -> BotCommand { let trimmed = text.trim(); + if let Some(rest) = trimmed.strip_prefix("/cancel_task") { + let arg = rest.trim(); + return if arg.is_empty() { + BotCommand::CancelTask(None) + } else { + BotCommand::CancelTask(Some(arg.to_string())) + }; + } match trimmed { "/start" => BotCommand::Start, "/switch_workspace" => BotCommand::SwitchWorkspace, @@ -116,28 +203,70 @@ Available commands: /resume_session - Resume an existing session /new_code_session - Create a new coding session /new_cowork_session - Create a new cowork session +/cancel_task - Cancel the current task /help - Show this help message"; pub fn paired_success_message() -> String { format!("Pairing successful! BitFun is now connected.\n\n{}", HELP_MESSAGE) } +pub fn main_menu_actions() -> Vec { + vec![ + BotAction::primary("Switch Workspace", "/switch_workspace"), + BotAction::secondary("Resume Session", "/resume_session"), + BotAction::secondary("New Code Session", "/new_code_session"), + BotAction::secondary("New Cowork Session", "/new_cowork_session"), + BotAction::secondary("Help", "/help"), + ] +} + +fn workspace_required_actions() -> Vec { + vec![BotAction::primary("Switch Workspace", "/switch_workspace")] +} + +fn session_entry_actions() -> Vec { + vec![ + BotAction::primary("Resume Session", "/resume_session"), + BotAction::secondary("New Code Session", "/new_code_session"), + BotAction::secondary("New Cowork Session", "/new_cowork_session"), + ] +} + +fn new_session_actions() -> Vec { + vec![ + BotAction::primary("New Code Session", "/new_code_session"), + BotAction::secondary("New Cowork Session", "/new_cowork_session"), + ] +} + +fn cancel_task_actions(command: impl Into) -> Vec { + vec![BotAction::secondary("Cancel Task", command.into())] +} + // ── Main dispatch ─────────────────────────────────────────────────── pub async fn handle_command(state: &mut BotChatState, cmd: BotCommand) -> HandleResult { match cmd { BotCommand::Start | BotCommand::Help => { - let reply = if state.paired { - HELP_MESSAGE.to_string() + if state.paired { + HandleResult { + reply: HELP_MESSAGE.to_string(), + actions: main_menu_actions(), + forward_to_session: None, + } } else { - WELCOME_MESSAGE.to_string() - }; - HandleResult { reply, forward_to_session: None } + HandleResult { + reply: WELCOME_MESSAGE.to_string(), + actions: vec![], + forward_to_session: None, + } + } } BotCommand::PairingCode(_) => HandleResult { reply: "Pairing codes are handled automatically. If you need to re-pair, \ please restart the connection from BitFun Desktop." .to_string(), + actions: vec![], forward_to_session: None, }, BotCommand::SwitchWorkspace => { @@ -173,6 +302,12 @@ pub async fn handle_command(state: &mut BotChatState, cmd: BotCommand) -> Handle } handle_new_session(state, "Cowork").await } + BotCommand::CancelTask(turn_id) => { + if !state.paired { + return not_paired(); + } + handle_cancel_task(state, turn_id.as_deref()).await + } BotCommand::NumberSelection(n) => { if !state.paired { return not_paired(); @@ -200,6 +335,7 @@ fn not_paired() -> HandleResult { HandleResult { reply: "Not connected to BitFun Desktop. Please enter the 6-digit pairing code first." .to_string(), + actions: vec![], forward_to_session: None, } } @@ -207,10 +343,114 @@ fn not_paired() -> HandleResult { fn need_workspace() -> HandleResult { HandleResult { reply: "No workspace selected. Use /switch_workspace first.".to_string(), + actions: workspace_required_actions(), forward_to_session: None, } } +fn question_option_line(index: usize, option: &BotQuestionOption) -> String { + if option.description.is_empty() { + format!("{}. {}", index + 1, option.label) + } else { + format!("{}. {} - {}", index + 1, option.label, option.description) + } +} + +fn truncate_action_label(label: &str, max_chars: usize) -> String { + let trimmed = label.trim(); + if trimmed.chars().count() <= max_chars { + trimmed.to_string() + } else { + let truncated: String = trimmed.chars().take(max_chars.saturating_sub(3)).collect(); + format!("{truncated}...") + } +} + +fn numbered_actions(labels: &[String]) -> Vec { + labels + .iter() + .enumerate() + .map(|(idx, label)| { + BotAction::secondary( + truncate_action_label(label, 28), + (idx + 1).to_string(), + ) + }) + .collect() +} + +fn build_question_prompt( + tool_id: String, + questions: Vec, + current_index: usize, + answers: Vec, + awaiting_custom_text: bool, + pending_answer: Option, +) -> BotInteractiveRequest { + let question = &questions[current_index]; + let mut actions = Vec::new(); + let mut reply = format!( + "Question {}/{}\n", + current_index + 1, + questions.len() + ); + if !question.header.is_empty() { + reply.push_str(&format!("{}\n", question.header)); + } + reply.push_str(&format!("{}\n\n", question.question)); + for (idx, option) in question.options.iter().enumerate() { + reply.push_str(&format!("{}\n", question_option_line(idx, option))); + } + reply.push_str(&format!( + "{}. Other\n\n", + question.options.len() + 1 + )); + if awaiting_custom_text { + reply.push_str("Please type your custom answer."); + } else if question.multi_select { + reply.push_str("Reply with one or more option numbers, separated by commas. Example: 1,3"); + } else { + reply.push_str("Reply with a single option number."); + let mut labels: Vec = question + .options + .iter() + .map(|option| option.label.clone()) + .collect(); + labels.push("Other".to_string()); + actions = numbered_actions(&labels); + } + + BotInteractiveRequest { + reply, + actions, + pending_action: PendingAction::AskUserQuestion { + tool_id, + questions, + current_index, + answers, + awaiting_custom_text, + pending_answer, + }, + } +} + +fn parse_question_numbers(input: &str) -> Option> { + let mut result = Vec::new(); + for part in input.split(',') { + let trimmed = part.trim(); + if trimmed.is_empty() { + continue; + } + let value = trimmed.parse::().ok()?; + result.push(value); + } + if result.is_empty() { + None + } else { + Some(result) + } +} + async fn handle_switch_workspace(state: &mut BotChatState) -> HandleResult { use crate::infrastructure::get_workspace_path; use crate::service::workspace::get_global_workspace_service; @@ -222,6 +462,7 @@ async fn handle_switch_workspace(state: &mut BotChatState) -> HandleResult { None => { return HandleResult { reply: "Workspace service not available.".to_string(), + actions: vec![], forward_to_session: None, }; } @@ -232,6 +473,7 @@ async fn handle_switch_workspace(state: &mut BotChatState) -> HandleResult { return HandleResult { reply: "No workspaces found. Please open a project in BitFun Desktop first." .to_string(), + actions: vec![], forward_to_session: None, }; } @@ -256,8 +498,13 @@ async fn handle_switch_workspace(state: &mut BotChatState) -> HandleResult { } text.push_str("\nReply with the workspace number."); + let action_labels: Vec = options.iter().map(|(_, name)| name.clone()).collect(); state.pending_action = Some(PendingAction::SelectWorkspace { options }); - HandleResult { reply: text, forward_to_session: None } + HandleResult { + reply: text, + actions: numbered_actions(&action_labels), + forward_to_session: None, + } } async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleResult { @@ -277,6 +524,7 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR Err(e) => { return HandleResult { reply: format!("Failed to load sessions: {e}"), + actions: vec![], forward_to_session: None, }; } @@ -287,6 +535,7 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR Err(e) => { return HandleResult { reply: format!("Failed to load sessions: {e}"), + actions: vec![], forward_to_session: None, }; } @@ -297,6 +546,7 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR Err(e) => { return HandleResult { reply: format!("Failed to list sessions: {e}"), + actions: vec![], forward_to_session: None, }; } @@ -307,6 +557,7 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR reply: "No sessions found in this workspace. Use /new_code_session or \ /new_cowork_session to create one." .to_string(), + actions: new_session_actions(), forward_to_session: None, }; } @@ -353,7 +604,20 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR text.push_str("\nReply with the session number."); state.pending_action = Some(PendingAction::SelectSession { options, page, has_more }); - HandleResult { reply: text, forward_to_session: None } + let mut action_labels: Vec = sessions + .iter() + .map(|session| format!("[{}] {}", session.agent_type, session.session_name)) + .collect(); + let mut actions = numbered_actions(&action_labels); + if has_more { + action_labels.push("Next Page".to_string()); + actions.push(BotAction::secondary("Next Page", "0")); + } + HandleResult { + reply: text, + actions, + forward_to_session: None, + } } async fn handle_new_session(state: &mut BotChatState, agent_type: &str) -> HandleResult { @@ -365,6 +629,7 @@ async fn handle_new_session(state: &mut BotChatState, agent_type: &str) -> Handl None => { return HandleResult { reply: "BitFun session system not ready.".to_string(), + actions: vec![], forward_to_session: None, }; } @@ -388,7 +653,6 @@ async fn handle_new_session(state: &mut BotChatState, agent_type: &str) -> Handl { Ok(session) => { let session_id = session.session_id.clone(); - persist_new_session(&session_id, session_name, agent_type, ws_path.as_deref()).await; state.current_session_id = Some(session_id.clone()); let label = if agent_type == "Cowork" { "cowork" } else { "coding" }; let workspace = ws_path.as_deref().unwrap_or("(unknown)"); @@ -398,66 +662,18 @@ async fn handle_new_session(state: &mut BotChatState, agent_type: &str) -> Handl You can now send messages to interact with the AI agent.", label, session_name, session_id, workspace ), + actions: vec![], forward_to_session: None, } } Err(e) => HandleResult { reply: format!("Failed to create session: {e}"), + actions: vec![], forward_to_session: None, }, } } -async fn persist_new_session( - session_id: &str, - session_name: &str, - agent_type: &str, - workspace_path: Option<&str>, -) { - use crate::infrastructure::PathManager; - use crate::service::conversation::{ - ConversationPersistenceManager, SessionMetadata, SessionStatus, - }; - - let Some(wp_str) = workspace_path else { return }; - let wp = std::path::PathBuf::from(wp_str); - - let pm = match PathManager::new() { - Ok(pm) => std::sync::Arc::new(pm), - Err(_) => return, - }; - let conv_mgr = match ConversationPersistenceManager::new(pm, wp).await { - Ok(m) => m, - Err(_) => return, - }; - - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; - let meta = SessionMetadata { - session_id: session_id.to_string(), - session_name: session_name.to_string(), - agent_type: agent_type.to_string(), - model_name: "default".to_string(), - created_at: now_ms, - last_active_at: now_ms, - turn_count: 0, - message_count: 0, - tool_call_count: 0, - status: SessionStatus::Active, - terminal_session_id: None, - snapshot_session_id: None, - tags: vec![], - custom_metadata: None, - todos: None, - workspace_path: workspace_path.map(String::from), - }; - if let Err(e) = conv_mgr.save_session_metadata(&meta).await { - error!("Failed to persist bot session metadata: {e}"); - } -} - async fn handle_number_selection(state: &mut BotChatState, n: usize) -> HandleResult { let pending = state.pending_action.take(); match pending { @@ -468,6 +684,7 @@ async fn handle_number_selection(state: &mut BotChatState, n: usize) -> HandleRe reply: format!("Invalid selection. Please enter 1-{}.", state.pending_action.as_ref() .map(|a| match a { PendingAction::SelectWorkspace { options } => options.len(), _ => 0 }) .unwrap_or(0)), + actions: vec![], forward_to_session: None, }; } @@ -480,12 +697,33 @@ async fn handle_number_selection(state: &mut BotChatState, n: usize) -> HandleRe state.pending_action = Some(PendingAction::SelectSession { options, page, has_more }); return HandleResult { reply: format!("Invalid selection. Please enter 1-{max}."), + actions: vec![], forward_to_session: None, }; } let (session_id, session_name) = options[n - 1].clone(); select_session(state, &session_id, &session_name).await } + Some(PendingAction::AskUserQuestion { + tool_id, + questions, + current_index, + answers, + awaiting_custom_text, + pending_answer, + }) => { + handle_question_reply( + state, + tool_id, + questions, + current_index, + answers, + awaiting_custom_text, + pending_answer, + &n.to_string(), + ) + .await + } None => handle_chat_message(state, &n.to_string()).await, } } @@ -498,6 +736,7 @@ async fn select_workspace(state: &mut BotChatState, path: &str, name: &str) -> H None => { return HandleResult { reply: "Workspace service not available.".to_string(), + actions: vec![], forward_to_session: None, }; } @@ -520,10 +759,20 @@ async fn select_workspace(state: &mut BotChatState, path: &str, name: &str) -> H let session_count = count_workspace_sessions(path).await; let reply = build_workspace_switched_reply(name, session_count); - HandleResult { reply, forward_to_session: None } + let actions = if session_count > 0 { + session_entry_actions() + } else { + new_session_actions() + }; + HandleResult { + reply, + actions, + forward_to_session: None, + } } Err(e) => HandleResult { reply: format!("Failed to switch workspace: {e}"), + actions: vec![], forward_to_session: None, }, } @@ -570,12 +819,6 @@ async fn select_session( session_id: &str, session_name: &str, ) -> HandleResult { - use crate::agentic::coordination::get_global_coordinator; - - if let Some(coordinator) = get_global_coordinator() { - let _ = coordinator.restore_session(session_id).await; - } - state.current_session_id = Some(session_id.to_string()); info!("Bot resumed session: {session_id}"); @@ -592,7 +835,11 @@ async fn select_session( reply.push_str("You can now send messages to interact with the AI agent."); } - HandleResult { reply, forward_to_session: None } + HandleResult { + reply, + actions: vec![], + forward_to_session: None, + } } /// Load the last user/assistant dialog pair from ConversationPersistenceManager, @@ -681,6 +928,327 @@ fn truncate_text(text: &str, max_chars: usize) -> String { } } +async fn handle_cancel_task( + state: &mut BotChatState, + requested_turn_id: Option<&str>, +) -> HandleResult { + use crate::agentic::coordination::get_global_coordinator; + use crate::agentic::core::SessionState; + + let session_id = match state.current_session_id.clone() { + Some(id) => id, + None => { + return HandleResult { + reply: "No active session to cancel.".to_string(), + actions: session_entry_actions(), + forward_to_session: None, + }; + } + }; + + let coordinator = match get_global_coordinator() { + Some(c) => c, + None => { + return HandleResult { + reply: "BitFun session system not ready.".to_string(), + actions: vec![], + forward_to_session: None, + }; + } + }; + + let session = match coordinator.restore_session(&session_id).await { + Ok(session) => session, + Err(e) => { + return HandleResult { + reply: format!("Failed to load session: {e}"), + actions: vec![], + forward_to_session: None, + }; + } + }; + + let current_turn_id = match session.state { + SessionState::Processing { current_turn_id, .. } => current_turn_id, + _ => { + return HandleResult { + reply: if requested_turn_id.is_some() { + "This request has already finished.".to_string() + } else { + "No running task to cancel.".to_string() + }, + actions: vec![], + forward_to_session: None, + }; + } + }; + + if let Some(requested_turn_id) = requested_turn_id { + if requested_turn_id != current_turn_id { + return HandleResult { + reply: "This request is no longer running.".to_string(), + actions: vec![], + forward_to_session: None, + }; + } + } + + match coordinator + .cancel_dialog_turn(&session_id, ¤t_turn_id) + .await + { + Ok(_) => { + state.pending_action = None; + HandleResult { + reply: "Cancellation requested for the current task.".to_string(), + actions: vec![], + forward_to_session: None, + } + } + Err(e) => HandleResult { + reply: format!("Failed to cancel task: {e}"), + actions: vec![], + forward_to_session: None, + }, + } +} + +fn restore_question_pending_action( + state: &mut BotChatState, + tool_id: String, + questions: Vec, + current_index: usize, + answers: Vec, + awaiting_custom_text: bool, + pending_answer: Option, +) { + state.pending_action = Some(PendingAction::AskUserQuestion { + tool_id, + questions, + current_index, + answers, + awaiting_custom_text, + pending_answer, + }); +} + +async fn submit_question_answers(tool_id: &str, answers: &[Value]) -> HandleResult { + use crate::agentic::tools::user_input_manager::get_user_input_manager; + + let mut payload = serde_json::Map::new(); + for (idx, value) in answers.iter().enumerate() { + payload.insert(idx.to_string(), value.clone()); + } + + let manager = get_user_input_manager(); + match manager.send_answer(tool_id, Value::Object(payload)) { + Ok(_) => HandleResult { + reply: "Answers submitted. Waiting for the assistant to continue...".to_string(), + actions: vec![], + forward_to_session: None, + }, + Err(e) => HandleResult { + reply: format!("Failed to submit answers: {e}"), + actions: vec![], + forward_to_session: None, + }, + } +} + +async fn handle_question_reply( + state: &mut BotChatState, + tool_id: String, + questions: Vec, + current_index: usize, + mut answers: Vec, + awaiting_custom_text: bool, + pending_answer: Option, + message: &str, +) -> HandleResult { + let Some(question) = questions.get(current_index).cloned() else { + return HandleResult { + reply: "Question state is invalid.".to_string(), + actions: vec![], + forward_to_session: None, + }; + }; + + if awaiting_custom_text { + let custom_text = message.trim(); + if custom_text.is_empty() { + restore_question_pending_action( + state, + tool_id, + questions, + current_index, + answers, + true, + pending_answer, + ); + return HandleResult { + reply: "Custom answer cannot be empty. Please type your custom answer.".to_string(), + actions: vec![], + forward_to_session: None, + }; + } + + let final_value = match pending_answer { + Some(Value::String(_)) => Value::String(custom_text.to_string()), + Some(Value::Array(existing)) => { + let mut values: Vec = existing + .into_iter() + .filter(|value| value.as_str() != Some("Other")) + .collect(); + values.push(Value::String(custom_text.to_string())); + Value::Array(values) + } + _ => Value::String(custom_text.to_string()), + }; + answers.push(final_value); + } else { + let selections = match parse_question_numbers(message) { + Some(values) => values, + None => { + restore_question_pending_action( + state, + tool_id, + questions, + current_index, + answers, + false, + None, + ); + return HandleResult { + reply: if question.multi_select { + "Invalid input. Reply with option numbers like `1,3`.".to_string() + } else { + "Invalid input. Reply with a single option number.".to_string() + }, + actions: vec![], + forward_to_session: None, + }; + } + }; + + if !question.multi_select && selections.len() != 1 { + restore_question_pending_action( + state, + tool_id, + questions, + current_index, + answers, + false, + None, + ); + return HandleResult { + reply: "Please reply with a single option number.".to_string(), + actions: vec![], + forward_to_session: None, + }; + } + + let other_index = question.options.len() + 1; + let mut labels = Vec::new(); + let mut includes_other = false; + for selection in selections { + if selection == other_index { + includes_other = true; + labels.push(Value::String("Other".to_string())); + } else if selection >= 1 && selection <= question.options.len() { + labels.push(Value::String( + question.options[selection - 1].label.clone(), + )); + } else { + restore_question_pending_action( + state, + tool_id, + questions, + current_index, + answers, + false, + None, + ); + return HandleResult { + reply: format!( + "Invalid selection. Please choose between 1 and {}.", + other_index + ), + actions: vec![], + forward_to_session: None, + }; + } + } + + let pending_answer = if question.multi_select { + Some(Value::Array(labels.clone())) + } else { + labels.into_iter().next() + }; + + if includes_other { + restore_question_pending_action( + state, + tool_id, + questions, + current_index, + answers, + true, + pending_answer, + ); + return HandleResult { + reply: "Please type your custom answer for `Other`.".to_string(), + actions: vec![], + forward_to_session: None, + }; + } + + answers.push(if question.multi_select { + pending_answer.unwrap_or_else(|| Value::Array(Vec::new())) + } else { + pending_answer.unwrap_or_else(|| Value::String(String::new())) + }); + } + + if current_index + 1 < questions.len() { + let prompt = build_question_prompt( + tool_id, + questions, + current_index + 1, + answers, + false, + None, + ); + restore_question_pending_action( + state, + match &prompt.pending_action { + PendingAction::AskUserQuestion { tool_id, .. } => tool_id.clone(), + _ => String::new(), + }, + match &prompt.pending_action { + PendingAction::AskUserQuestion { questions, .. } => questions.clone(), + _ => Vec::new(), + }, + match &prompt.pending_action { + PendingAction::AskUserQuestion { current_index, .. } => *current_index, + _ => 0, + }, + match &prompt.pending_action { + PendingAction::AskUserQuestion { answers, .. } => answers.clone(), + _ => Vec::new(), + }, + false, + None, + ); + return HandleResult { + reply: prompt.reply, + actions: prompt.actions, + forward_to_session: None, + }; + } + + submit_question_answers(&tool_id, &answers).await +} + async fn handle_next_page(state: &mut BotChatState) -> HandleResult { let pending = state.pending_action.take(); match pending { @@ -691,6 +1259,7 @@ async fn handle_next_page(state: &mut BotChatState) -> HandleResult { state.pending_action = Some(action); HandleResult { reply: "No more pages available.".to_string(), + actions: vec![], forward_to_session: None, } } @@ -699,9 +1268,51 @@ async fn handle_next_page(state: &mut BotChatState) -> HandleResult { } async fn handle_chat_message(state: &mut BotChatState, message: &str) -> HandleResult { + if let Some(PendingAction::AskUserQuestion { + tool_id, + questions, + current_index, + answers, + awaiting_custom_text, + pending_answer, + }) = state.pending_action.take() + { + return handle_question_reply( + state, + tool_id, + questions, + current_index, + answers, + awaiting_custom_text, + pending_answer, + message, + ) + .await; + } + if let Some(pending) = state.pending_action.clone() { + return match pending { + PendingAction::SelectWorkspace { .. } => HandleResult { + reply: "Please reply with the workspace number.".to_string(), + actions: vec![], + forward_to_session: None, + }, + PendingAction::SelectSession { has_more, .. } => HandleResult { + reply: if has_more { + "Please reply with the session number, or `0` for the next page.".to_string() + } else { + "Please reply with the session number.".to_string() + }, + actions: vec![], + forward_to_session: None, + }, + PendingAction::AskUserQuestion { .. } => unreachable!(), + }; + } + if state.current_workspace.is_none() { return HandleResult { reply: "No workspace selected. Use /switch_workspace to select one first.".to_string(), + actions: workspace_required_actions(), forward_to_session: None, }; } @@ -710,31 +1321,25 @@ async fn handle_chat_message(state: &mut BotChatState, message: &str) -> HandleR reply: "No active session. Use /resume_session to resume one or \ /new_code_session to create a new one." .to_string(), + actions: session_entry_actions(), forward_to_session: None, }; } let session_id = state.current_session_id.clone().unwrap(); - let workspace_path = state.current_workspace.clone(); - - let agent_type = { - use crate::agentic::coordination::get_global_coordinator; - get_global_coordinator() - .and_then(|c| { - c.get_session_manager() - .get_session(&session_id) - .map(|s| s.agent_type.clone()) - }) - .unwrap_or_else(|| "agentic".to_string()) - }; - + let turn_id = format!("turn_{}", uuid::Uuid::new_v4()); + let cancel_command = format!("/cancel_task {}", turn_id); HandleResult { - reply: "Processing your message...".to_string(), + reply: format!( + "Processing your message...\n\nIf needed, send `{}` to stop this request.", + cancel_command + ), + actions: cancel_task_actions(cancel_command), forward_to_session: Some(ForwardRequest { session_id, content: message.to_string(), - agent_type, - workspace_path, + agent_type: "agentic".to_string(), + turn_id, }), } } @@ -743,6 +1348,9 @@ async fn handle_chat_message(state: &mut BotChatState, message: &str) -> HandleR enum StreamChunk { Text(String), + Thinking(String), + ThinkingEnd, + Interaction(BotInteractiveRequest), Done, Error(String), } @@ -763,6 +1371,41 @@ impl crate::agentic::events::EventSubscriber for BotResponseCollector { AE::TextChunk { text, session_id, .. } if session_id == &self.session_id => { let _ = self.chunk_tx.send(StreamChunk::Text(text.clone())); } + AE::ThinkingChunk { content, session_id, .. } if session_id == &self.session_id => { + if content == "" { + let _ = self.chunk_tx.send(StreamChunk::ThinkingEnd); + } else { + let _ = self.chunk_tx.send(StreamChunk::Thinking(content.clone())); + } + } + AE::ToolEvent { + session_id, + tool_event, + .. + } if session_id == &self.session_id => match tool_event { + bitfun_events::ToolEventData::Started { + tool_id, + tool_name, + params, + } if tool_name == "AskUserQuestion" => { + if let Some(questions_value) = params.get("questions").cloned() { + if let Ok(questions) = + serde_json::from_value::>(questions_value) + { + let request = build_question_prompt( + tool_id.clone(), + questions, + 0, + Vec::new(), + false, + None, + ); + let _ = self.chunk_tx.send(StreamChunk::Interaction(request)); + } + } + } + _ => {} + }, AE::DialogTurnCompleted { session_id, .. } if session_id == &self.session_id => { let _ = self.chunk_tx.send(StreamChunk::Done); } @@ -780,22 +1423,21 @@ impl crate::agentic::events::EventSubscriber for BotResponseCollector { /// Called from the bot implementations after `handle_command` returns a /// `ForwardRequest`. Subscribes to session events, starts the turn, and /// collects text chunks until completion or timeout. -pub async fn execute_forwarded_turn(forward: ForwardRequest) -> String { - use crate::agentic::coordination::get_global_coordinator; +/// +/// `message_sender` is called to send intermediate messages (e.g. thinking +/// content) before the final response is returned. +pub async fn execute_forwarded_turn( + forward: ForwardRequest, + interaction_handler: Option, + message_sender: Option, +) -> String { + use crate::agentic::coordination::{get_global_coordinator, DialogTriggerSource}; let coordinator = match get_global_coordinator() { Some(c) => c, None => return "Session system not ready.".to_string(), }; - if let Some(wp) = &forward.workspace_path { - use crate::infrastructure::{get_workspace_path, set_workspace_path}; - let current = get_workspace_path().map(|p| p.to_string_lossy().to_string()); - if current.as_deref() != Some(wp.as_str()) { - set_workspace_path(Some(std::path::PathBuf::from(wp))); - } - } - let (chunk_tx, mut chunk_rx) = tokio::sync::mpsc::unbounded_channel::(); let subscriber_id = format!("bot_forward_{}", uuid::Uuid::new_v4()); let collector = BotResponseCollector { @@ -804,14 +1446,13 @@ pub async fn execute_forwarded_turn(forward: ForwardRequest) -> String { }; coordinator.subscribe_internal(subscriber_id.clone(), collector); - let turn_id = format!("turn_{}", chrono::Utc::now().timestamp_millis()); if let Err(e) = coordinator .start_dialog_turn( forward.session_id.clone(), forward.content, - Some(turn_id), + Some(forward.turn_id), forward.agent_type, - true, + DialogTriggerSource::Bot, ) .await { @@ -821,10 +1462,25 @@ pub async fn execute_forwarded_turn(forward: ForwardRequest) -> String { let sub_id = subscriber_id.clone(); let result = tokio::time::timeout(std::time::Duration::from_secs(300), async { + let mut thinking = String::new(); let mut response = String::new(); while let Some(chunk) = chunk_rx.recv().await { match chunk { + StreamChunk::Thinking(t) => thinking.push_str(&t), + StreamChunk::ThinkingEnd => { + if !thinking.is_empty() { + if let Some(sender) = message_sender.as_ref() { + sender(thinking.clone()).await; + } + thinking.clear(); + } + } StreamChunk::Text(t) => response.push_str(&t), + StreamChunk::Interaction(interaction) => { + if let Some(handler) = interaction_handler.as_ref() { + handler(interaction).await; + } + } StreamChunk::Done => break, StreamChunk::Error(e) => return format!("Error: {e}"), } diff --git a/src/crates/core/src/service/remote_connect/bot/feishu.rs b/src/crates/core/src/service/remote_connect/bot/feishu.rs index f5ed821e..3ad860fe 100644 --- a/src/crates/core/src/service/remote_connect/bot/feishu.rs +++ b/src/crates/core/src/service/remote_connect/bot/feishu.rs @@ -14,8 +14,9 @@ use tokio::sync::RwLock; use tokio_tungstenite::tungstenite::Message as WsMessage; use super::command_router::{ - execute_forwarded_turn, handle_command, paired_success_message, parse_command, BotChatState, - WELCOME_MESSAGE, + execute_forwarded_turn, handle_command, main_menu_actions, paired_success_message, + parse_command, BotAction, BotActionStyle, BotChatState, BotInteractiveRequest, + BotInteractionHandler, BotMessageSender, HandleResult, WELCOME_MESSAGE, }; use super::{load_bot_persistence, save_bot_persistence, BotConfig, SavedBotConnection}; @@ -306,6 +307,142 @@ impl FeishuBot { Ok(()) } + pub async fn send_action_card( + &self, + chat_id: &str, + content: &str, + actions: &[BotAction], + ) -> Result<()> { + let token = self.get_access_token().await?; + let client = reqwest::Client::new(); + let card = Self::build_action_card(chat_id, content, actions); + let resp = client + .post("https://open.feishu.cn/open-apis/im/v1/messages") + .query(&[("receive_id_type", "chat_id")]) + .bearer_auth(&token) + .json(&serde_json::json!({ + "receive_id": chat_id, + "msg_type": "interactive", + "content": serde_json::to_string(&card)?, + })) + .send() + .await?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(anyhow!("feishu send_action_card failed: {body}")); + } + debug!("Feishu action card sent to {chat_id}"); + Ok(()) + } + + async fn send_handle_result(&self, chat_id: &str, result: &HandleResult) -> Result<()> { + if result.actions.is_empty() { + self.send_message(chat_id, &result.reply).await + } else { + self.send_action_card(chat_id, &result.reply, &result.actions).await + } + } + + fn build_action_card(chat_id: &str, content: &str, actions: &[BotAction]) -> serde_json::Value { + let body = Self::card_body_text(content); + let mut elements = vec![serde_json::json!({ + "tag": "div", + "text": { + "tag": "lark_md", + "content": body, + } + })]; + + for chunk in actions.chunks(2) { + let buttons: Vec<_> = chunk + .iter() + .map(|action| { + let button_type = match action.style { + BotActionStyle::Primary => "primary", + BotActionStyle::Default => "default", + }; + serde_json::json!({ + "tag": "button", + "text": { + "tag": "plain_text", + "content": action.label, + }, + "type": button_type, + "value": { + "chat_id": chat_id, + "command": action.command, + } + }) + }) + .collect(); + elements.push(serde_json::json!({ + "tag": "action", + "actions": buttons, + })); + } + + serde_json::json!({ + "config": { + "wide_screen_mode": true, + }, + "header": { + "title": { + "tag": "plain_text", + "content": "BitFun Remote Connect", + } + }, + "elements": elements, + }) + } + + fn card_body_text(content: &str) -> String { + let mut removed_command_lines = false; + let mut lines = Vec::new(); + + for line in content.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with('/') && trimmed.contains(" - ") { + removed_command_lines = true; + continue; + } + if trimmed.contains("/cancel_task ") { + lines.push("If needed, use the Cancel Task button below to stop this request.".to_string()); + continue; + } + lines.push(Self::replace_command_tokens(line)); + } + + let mut body = lines.join("\n").trim().to_string(); + if removed_command_lines { + if !body.is_empty() { + body.push_str("\n\n"); + } + body.push_str("Choose an action below."); + } + + if body.is_empty() { + "Choose an action below.".to_string() + } else { + body + } + } + + fn replace_command_tokens(line: &str) -> String { + let replacements = [ + ("/switch_workspace", "Switch Workspace"), + ("/resume_session", "Resume Session"), + ("/new_code_session", "New Code Session"), + ("/new_cowork_session", "New Cowork Session"), + ("/cancel_task", "Cancel Task"), + ("/help", "Help"), + ]; + + replacements + .iter() + .fold(line.to_string(), |acc, (from, to)| acc.replace(from, to)) + } + pub async fn register_pairing(&self, pairing_code: &str) -> Result<()> { self.pending_pairings.write().await.insert( pairing_code.to_string(), @@ -361,8 +498,8 @@ impl FeishuBot { Ok((url, client_config)) } - /// Extract (chat_id, text) from a Feishu WebSocket event message. - fn parse_ws_event(event: &serde_json::Value) -> Option<(String, String)> { + /// Extract (chat_id, text) from a Feishu text message event. + fn parse_message_event(event: &serde_json::Value) -> Option<(String, String)> { let event_type = event .pointer("/header/event_type") .and_then(|v| v.as_str())?; @@ -389,6 +526,36 @@ impl FeishuBot { Some((chat_id, text)) } + /// Extract (chat_id, command) from a Feishu card action callback. + fn parse_card_action_event(event: &serde_json::Value) -> Option<(String, String)> { + let event_type = event + .pointer("/header/event_type") + .and_then(|v| v.as_str())?; + if event_type != "card.action.trigger" { + return None; + } + + let chat_id = event + .pointer("/event/action/value/chat_id") + .and_then(|v| v.as_str()) + .or_else(|| { + event.pointer("/event/context/open_chat_id") + .and_then(|v| v.as_str()) + })? + .to_string(); + let command = event + .pointer("/event/action/value/command") + .and_then(|v| v.as_str())? + .trim() + .to_string(); + + Some((chat_id, command)) + } + + fn parse_ws_event(event: &serde_json::Value) -> Option<(String, String)> { + Self::parse_message_event(event).or_else(|| Self::parse_card_action_event(event)) + } + /// Handle a single incoming protobuf data frame. /// Returns Some(chat_id) if pairing succeeded, None to continue waiting. async fn handle_data_frame_for_pairing( @@ -410,7 +577,7 @@ impl FeishuBot { let resp_frame = pb::Frame::new_response(frame, 200); let _ = write.write().await.send(WsMessage::Binary(pb::encode_frame(&resp_frame))).await; - if let Some((chat_id, msg_text)) = Self::parse_ws_event(&event) { + if let Some((chat_id, msg_text)) = Self::parse_message_event(&event) { let trimmed = msg_text.trim(); if trimmed == "/start" { @@ -418,8 +585,12 @@ impl FeishuBot { } else if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) { if self.verify_pairing_code(trimmed).await { info!("Feishu pairing successful, chat_id={chat_id}"); - let msg = paired_success_message(); - self.send_message(&chat_id, &msg).await.ok(); + let result = HandleResult { + reply: paired_success_message(), + actions: main_menu_actions(), + forward_to_session: None, + }; + self.send_handle_result(&chat_id, &result).await.ok(); let mut state = BotChatState::new(chat_id.clone()); state.paired = true; @@ -658,8 +829,12 @@ impl FeishuBot { if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) { if self.verify_pairing_code(trimmed).await { state.paired = true; - let msg = paired_success_message(); - self.send_message(chat_id, &msg).await.ok(); + let result = HandleResult { + reply: paired_success_message(), + actions: main_menu_actions(), + forward_to_session: None, + }; + self.send_handle_result(chat_id, &result).await.ok(); self.persist_chat_state(chat_id, state).await; return; } else { @@ -687,18 +862,59 @@ impl FeishuBot { self.persist_chat_state(chat_id, state).await; drop(states); - self.send_message(chat_id, &result.reply).await.ok(); + self.send_handle_result(chat_id, &result).await.ok(); if let Some(forward) = result.forward_to_session { let bot = self.clone(); let cid = chat_id.to_string(); tokio::spawn(async move { - let response = execute_forwarded_turn(forward).await; + let interaction_bot = bot.clone(); + let interaction_chat_id = cid.clone(); + let handler: BotInteractionHandler = std::sync::Arc::new(move |interaction: BotInteractiveRequest| { + let interaction_bot = interaction_bot.clone(); + let interaction_chat_id = interaction_chat_id.clone(); + Box::pin(async move { + interaction_bot + .deliver_interaction(&interaction_chat_id, interaction) + .await; + }) + }); + let msg_bot = bot.clone(); + let msg_cid = cid.clone(); + let sender: BotMessageSender = std::sync::Arc::new(move |text: String| { + let msg_bot = msg_bot.clone(); + let msg_cid = msg_cid.clone(); + Box::pin(async move { + msg_bot.send_message(&msg_cid, &text).await.ok(); + }) + }); + let response = execute_forwarded_turn(forward, Some(handler), Some(sender)).await; bot.send_message(&cid, &response).await.ok(); }); } } + async fn deliver_interaction(&self, chat_id: &str, interaction: BotInteractiveRequest) { + let mut states = self.chat_states.write().await; + let state = states + .entry(chat_id.to_string()) + .or_insert_with(|| { + let mut s = BotChatState::new(chat_id.to_string()); + s.paired = true; + s + }); + state.pending_action = Some(interaction.pending_action.clone()); + self.persist_chat_state(chat_id, state).await; + drop(states); + + let result = HandleResult { + reply: interaction.reply, + actions: interaction.actions, + forward_to_session: None, + }; + self.send_handle_result(chat_id, &result).await.ok(); + } + async fn persist_chat_state(&self, chat_id: &str, state: &BotChatState) { let mut data = load_bot_persistence(); data.upsert(SavedBotConnection { @@ -714,3 +930,58 @@ impl FeishuBot { save_bot_persistence(&data); } } + +#[cfg(test)] +mod tests { + use super::FeishuBot; + + #[test] + fn parse_text_message_event() { + let event = serde_json::json!({ + "header": { "event_type": "im.message.receive_v1" }, + "event": { + "message": { + "message_type": "text", + "chat_id": "oc_test_chat", + "content": "{\"text\":\"/help\"}" + } + } + }); + + let parsed = FeishuBot::parse_ws_event(&event); + assert_eq!(parsed, Some(("oc_test_chat".to_string(), "/help".to_string()))); + } + + #[test] + fn parse_card_action_event_uses_embedded_chat_id() { + let event = serde_json::json!({ + "header": { "event_type": "card.action.trigger" }, + "event": { + "context": { + "open_chat_id": "oc_fallback" + }, + "action": { + "value": { + "chat_id": "oc_actual", + "command": "/switch_workspace" + } + } + } + }); + + let parsed = FeishuBot::parse_ws_event(&event); + assert_eq!( + parsed, + Some(("oc_actual".to_string(), "/switch_workspace".to_string())) + ); + } + + #[test] + fn card_body_removes_slash_command_list() { + let body = FeishuBot::card_body_text( + "Available commands:\n/switch_workspace - List and switch workspaces\n/help - Show this help message", + ); + + assert_eq!(body, "Available commands:\n\nChoose an action below."); + } +} diff --git a/src/crates/core/src/service/remote_connect/bot/telegram.rs b/src/crates/core/src/service/remote_connect/bot/telegram.rs index cdc59d33..5047acb7 100644 --- a/src/crates/core/src/service/remote_connect/bot/telegram.rs +++ b/src/crates/core/src/service/remote_connect/bot/telegram.rs @@ -12,8 +12,8 @@ use std::sync::Arc; use tokio::sync::RwLock; use super::command_router::{ - execute_forwarded_turn, handle_command, paired_success_message, parse_command, BotChatState, - WELCOME_MESSAGE, + execute_forwarded_turn, handle_command, paired_success_message, parse_command, + BotInteractiveRequest, BotInteractionHandler, BotMessageSender, BotChatState, WELCOME_MESSAGE, }; use super::{load_bot_persistence, save_bot_persistence, BotConfig, SavedBotConnection}; @@ -84,6 +84,7 @@ impl TelegramBot { { "command": "resume_session", "description": "Resume an existing session" }, { "command": "new_code_session", "description": "Create a new coding session" }, { "command": "new_cowork_session", "description": "Create a new cowork session" }, + { "command": "cancel_task", "description": "Cancel the current task" }, { "command": "help", "description": "Show available commands" }, ] }); @@ -301,12 +302,44 @@ impl TelegramBot { if let Some(forward) = result.forward_to_session { let bot = self.clone(); tokio::spawn(async move { - let response = execute_forwarded_turn(forward).await; + let interaction_bot = bot.clone(); + let handler: BotInteractionHandler = std::sync::Arc::new(move |interaction: BotInteractiveRequest| { + let interaction_bot = interaction_bot.clone(); + Box::pin(async move { + interaction_bot + .deliver_interaction(chat_id, interaction) + .await; + }) + }); + let msg_bot = bot.clone(); + let sender: BotMessageSender = std::sync::Arc::new(move |text: String| { + let msg_bot = msg_bot.clone(); + Box::pin(async move { + msg_bot.send_message(chat_id, &text).await.ok(); + }) + }); + let response = execute_forwarded_turn(forward, Some(handler), Some(sender)).await; bot.send_message(chat_id, &response).await.ok(); }); } } + async fn deliver_interaction(&self, chat_id: i64, interaction: BotInteractiveRequest) { + let mut states = self.chat_states.write().await; + let state = states + .entry(chat_id) + .or_insert_with(|| { + let mut s = BotChatState::new(chat_id.to_string()); + s.paired = true; + s + }); + state.pending_action = Some(interaction.pending_action.clone()); + self.persist_chat_state(chat_id, state).await; + drop(states); + + self.send_message(chat_id, &interaction.reply).await.ok(); + } + async fn persist_chat_state(&self, chat_id: i64, state: &BotChatState) { let mut data = load_bot_persistence(); data.upsert(SavedBotConnection { diff --git a/src/crates/core/src/service/remote_connect/remote_server.rs b/src/crates/core/src/service/remote_connect/remote_server.rs index aa218424..64685308 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -12,7 +12,7 @@ use anyhow::{anyhow, Result}; use dashmap::DashMap; use log::{debug, error, info}; use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{json, Value}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, RwLock}; @@ -57,10 +57,23 @@ pub enum RemoteCommand { }, CancelTask { session_id: String, + turn_id: Option, }, DeleteSession { session_id: String, }, + ConfirmTool { + tool_id: String, + updated_input: Option, + }, + RejectTool { + tool_id: String, + reason: Option, + }, + CancelTool { + tool_id: String, + reason: Option, + }, /// Submit answers for an AskUserQuestion tool. AnswerQuestion { tool_id: String, @@ -144,6 +157,10 @@ pub enum RemoteResponse { active_turn: Option, }, AnswerAccepted, + InteractionAccepted { + action: String, + target_id: String, + }, Pong, Error { message: String, @@ -248,13 +265,16 @@ fn turns_to_chat_messages( // Collect ordered items across all rounds, preserving interleaved order struct OrderedEntry { - order_index: usize, + order_index: Option, + timestamp: u64, + sequence: usize, item: ChatMessageItem, } let mut ordered: Vec = Vec::new(); let mut tools_flat = Vec::new(); let mut thinking_parts = Vec::new(); let mut text_parts = Vec::new(); + let mut sequence = 0usize; for round in &turn.model_rounds { for t in &round.text_items { @@ -264,13 +284,16 @@ fn turns_to_chat_messages( if !t.content.is_empty() { text_parts.push(t.content.clone()); ordered.push(OrderedEntry { - order_index: t.order_index.unwrap_or(usize::MAX), + order_index: t.order_index, + timestamp: t.timestamp, + sequence, item: ChatMessageItem { item_type: "text".to_string(), content: Some(t.content.clone()), tool: None, }, }); + sequence += 1; } } for t in &round.thinking_items { @@ -280,13 +303,16 @@ fn turns_to_chat_messages( if !t.content.is_empty() { thinking_parts.push(t.content.clone()); ordered.push(OrderedEntry { - order_index: t.order_index.unwrap_or(usize::MAX), + order_index: t.order_index, + timestamp: t.timestamp, + sequence, item: ChatMessageItem { item_type: "thinking".to_string(), content: Some(t.content.clone()), tool: None, }, }); + sequence += 1; } } for t in &round.tool_items { @@ -307,21 +333,39 @@ fn turns_to_chat_messages( duration_ms: t.duration_ms, start_ms: Some(t.start_time), input_preview: None, - tool_input: None, + tool_input: if t.tool_name == "AskUserQuestion" { + Some(t.tool_call.input.clone()) + } else { + None + }, }; tools_flat.push(tool_status.clone()); ordered.push(OrderedEntry { - order_index: t.order_index.unwrap_or(usize::MAX), + order_index: t.order_index, + timestamp: t.start_time, + sequence, item: ChatMessageItem { item_type: "tool".to_string(), content: None, tool: Some(tool_status), }, }); + sequence += 1; } } - ordered.sort_by_key(|e| e.order_index); + ordered.sort_by(|a, b| match (a.order_index, b.order_index) { + (Some(a_idx), Some(b_idx)) => a_idx + .cmp(&b_idx) + .then_with(|| a.timestamp.cmp(&b.timestamp)) + .then_with(|| a.sequence.cmp(&b.sequence)), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => a + .timestamp + .cmp(&b.timestamp) + .then_with(|| a.sequence.cmp(&b.sequence)), + }); let items: Vec = ordered.into_iter().map(|e| e.item).collect(); let ts = turn @@ -397,36 +441,48 @@ fn resolve_agent_type(mobile_type: Option<&str>) -> &'static str { } } -fn save_data_url_image( - dir: &std::path::Path, - name: &str, - data_url: &str, -) -> Option { - use base64::{engine::general_purpose::STANDARD as B64, Engine}; - - let (header, b64_data) = data_url.split_once(",")?; - let ext = if header.contains("png") { - "png" - } else if header.contains("gif") { - "gif" - } else if header.contains("webp") { - "webp" - } else { - "jpg" +fn build_message_with_remote_images(content: &str, images: &[ImageAttachment]) -> String { + use crate::agentic::tools::image_context::{ + format_image_context_reference, store_image_context, ImageContextData, }; - let decoded = B64.decode(b64_data.trim()).ok()?; + if images.is_empty() { + return content.to_string(); + } - let stem = std::path::Path::new(name) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("image"); - let ts = chrono::Utc::now().timestamp_millis(); - let filename = format!("{stem}_{ts}.{ext}"); - let path = dir.join(&filename); + let context_section = images + .iter() + .map(|img| { + let mime_type = img + .data_url + .split_once(',') + .and_then(|(header, _)| { + header + .strip_prefix("data:") + .and_then(|rest| rest.split(';').next()) + }) + .unwrap_or("image/png") + .to_string(); + + let image_context = ImageContextData { + id: format!("remote_img_{}", uuid::Uuid::new_v4()), + image_path: None, + data_url: Some(img.data_url.clone()), + mime_type, + image_name: img.name.clone(), + file_size: 0, + width: None, + height: None, + source: "remote".to_string(), + }; + + store_image_context(image_context.clone()); + format_image_context_reference(&image_context) + }) + .collect::>() + .join("\n"); - std::fs::write(&path, &decoded).ok()?; - Some(path) + format!("{context_section}\n\n{content}") } // ── RemoteSessionStateTracker ────────────────────────────────────── @@ -502,6 +558,78 @@ impl RemoteSessionStateTracker { self.state.read().unwrap().title.clone() } + fn upsert_active_tool( + state: &mut TrackerState, + tool_id: &str, + tool_name: &str, + status: &str, + input_preview: Option, + tool_input: Option, + ) { + let resolved_id = if tool_id.is_empty() { + format!("{}-{}", tool_name, state.active_tools.len()) + } else { + tool_id.to_string() + }; + let allow_name_fallback = tool_id.is_empty() && !tool_name.is_empty(); + + if let Some(tool) = state + .active_tools + .iter_mut() + .rev() + .find(|t| t.id == resolved_id || (allow_name_fallback && t.name == tool_name)) + { + tool.status = status.to_string(); + if input_preview.is_some() { + tool.input_preview = input_preview.clone(); + } + if tool_input.is_some() { + tool.tool_input = tool_input.clone(); + } + } else { + let tool_status = RemoteToolStatus { + id: resolved_id.clone(), + name: tool_name.to_string(), + status: status.to_string(), + duration_ms: None, + start_ms: Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + ), + input_preview, + tool_input, + }; + state.active_tools.push(tool_status.clone()); + state.active_items.push(ChatMessageItem { + item_type: "tool".to_string(), + content: None, + tool: Some(tool_status), + }); + return; + } + + if let Some(item) = state.active_items.iter_mut().rev().find(|i| { + i.item_type == "tool" + && i.tool + .as_ref() + .map_or(false, |t| { + t.id == resolved_id || (allow_name_fallback && t.name == tool_name) + }) + }) { + if let Some(tool) = item.tool.as_mut() { + tool.status = status.to_string(); + if input_preview.is_some() { + tool.input_preview = input_preview; + } + if tool_input.is_some() { + tool.tool_input = tool_input; + } + } + } + } + fn handle_event(&self, event: &crate::agentic::events::AgenticEvent) { use bitfun_events::AgenticEvent as AE; @@ -594,56 +722,100 @@ impl RemoteSessionStateTracker { .to_string(); let mut s = self.state.write().unwrap(); + let allow_name_fallback = tool_id.is_empty() && !tool_name.is_empty(); match event_type { + "EarlyDetected" => { + Self::upsert_active_tool( + &mut s, + &tool_id, + &tool_name, + "preparing", + None, + None, + ); + } + "ConfirmationNeeded" => { + let params = val.get("params").cloned(); + let input_preview = params.as_ref().map(|v| { + let text = if v.is_string() { + v.as_str().unwrap_or_default().to_string() + } else { + serde_json::to_string(v).unwrap_or_default() + }; + text.chars().take(160).collect() + }); + Self::upsert_active_tool( + &mut s, + &tool_id, + &tool_name, + "pending_confirmation", + input_preview, + params, + ); + } "Started" => { - let input_preview = val - .get("input") - .and_then(|v| v.as_str()) - .map(|s| s.chars().take(100).collect()); + let params = val.get("params").cloned(); + let input_preview = params.as_ref().map(|v| { + let text = if v.is_string() { + v.as_str().unwrap_or_default().to_string() + } else { + serde_json::to_string(v).unwrap_or_default() + }; + text.chars().take(160).collect() + }); let tool_input = if tool_name == "AskUserQuestion" { - val.get("params").cloned() + params } else { None }; - let tool_count = s.active_tools.len(); - let resolved_id = if tool_id.is_empty() { - format!("{}-{}", tool_name, tool_count) - } else { - tool_id - }; - let tool_status = RemoteToolStatus { - id: resolved_id, - name: tool_name, - status: "running".to_string(), - duration_ms: None, - start_ms: Some( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64, - ), + Self::upsert_active_tool( + &mut s, + &tool_id, + &tool_name, + "running", input_preview, tool_input, - }; - s.active_items.push(ChatMessageItem { - item_type: "tool".to_string(), - content: None, - tool: Some(tool_status.clone()), - }); - s.active_tools.push(tool_status); + ); + } + "Confirmed" => { + Self::upsert_active_tool( + &mut s, + &tool_id, + &tool_name, + "confirmed", + None, + None, + ); + } + "Rejected" => { + Self::upsert_active_tool( + &mut s, + &tool_id, + &tool_name, + "rejected", + None, + None, + ); } "Completed" | "Succeeded" => { let duration = val .get("duration_ms") .and_then(|v| v.as_u64()); if let Some(t) = s.active_tools.iter_mut().rev().find(|t| { - (t.id == tool_id || t.name == tool_name) && t.status == "running" + (t.id == tool_id + || (allow_name_fallback && t.name == tool_name)) + && t.status == "running" }) { t.status = "completed".to_string(); t.duration_ms = duration; } if let Some(item) = s.active_items.iter_mut().rev().find(|i| { - i.item_type == "tool" && i.tool.as_ref().map_or(false, |t| (t.id == tool_id || t.name == tool_name) && t.status == "running") + i.item_type == "tool" + && i.tool.as_ref().map_or(false, |t| { + (t.id == tool_id + || (allow_name_fallback && t.name == tool_name)) + && t.status == "running" + }) }) { if let Some(t) = item.tool.as_mut() { t.status = "completed".to_string(); @@ -653,18 +825,52 @@ impl RemoteSessionStateTracker { } "Failed" => { if let Some(t) = s.active_tools.iter_mut().rev().find(|t| { - (t.id == tool_id || t.name == tool_name) && t.status == "running" + (t.id == tool_id + || (allow_name_fallback && t.name == tool_name)) + && t.status == "running" }) { t.status = "failed".to_string(); } if let Some(item) = s.active_items.iter_mut().rev().find(|i| { - i.item_type == "tool" && i.tool.as_ref().map_or(false, |t| (t.id == tool_id || t.name == tool_name) && t.status == "running") + i.item_type == "tool" + && i.tool.as_ref().map_or(false, |t| { + (t.id == tool_id + || (allow_name_fallback && t.name == tool_name)) + && t.status == "running" + }) }) { if let Some(t) = item.tool.as_mut() { t.status = "failed".to_string(); } } } + "Cancelled" => { + if let Some(t) = s.active_tools.iter_mut().rev().find(|t| { + (t.id == tool_id + || (allow_name_fallback && t.name == tool_name)) + && matches!( + t.status.as_str(), + "running" | "pending_confirmation" | "confirmed" + ) + }) { + t.status = "cancelled".to_string(); + } + if let Some(item) = s.active_items.iter_mut().rev().find(|i| { + i.item_type == "tool" + && i.tool.as_ref().map_or(false, |t| { + (t.id == tool_id + || (allow_name_fallback && t.name == tool_name)) + && matches!( + t.status.as_str(), + "running" | "pending_confirmation" | "confirmed" + ) + }) + }) { + if let Some(t) = item.tool.as_mut() { + t.status = "cancelled".to_string(); + } + } + } _ => {} } drop(s); @@ -823,6 +1029,9 @@ impl RemoteServer { RemoteCommand::SendMessage { .. } | RemoteCommand::CancelTask { .. } + | RemoteCommand::ConfirmTool { .. } + | RemoteCommand::RejectTool { .. } + | RemoteCommand::CancelTool { .. } | RemoteCommand::AnswerQuestion { .. } => { self.handle_execution_command(cmd).await } @@ -953,6 +1162,24 @@ impl RemoteServer { let sess_state = tracker.session_state(); let title = tracker.title(); + let active_turn_ask_tool_ids = active_turn + .as_ref() + .map(|turn| { + turn.tools + .iter() + .filter(|tool| tool.name == "AskUserQuestion") + .map(|tool| tool.id.clone()) + .collect::>() + }) + .unwrap_or_default(); + let new_message_ask_tool_ids = new_messages + .iter() + .flat_map(|message| message.items.iter().flatten()) + .filter_map(|item| item.tool.as_ref()) + .filter(|tool| tool.name == "AskUserQuestion") + .map(|tool| tool.id.clone()) + .collect::>(); + RemoteResponse::SessionPoll { version: current_version, changed: true, @@ -1189,10 +1416,7 @@ impl RemoteServer { session_name: custom_name, workspace_path: requested_ws_path, } => { - use crate::infrastructure::{get_workspace_path, PathManager}; - use crate::service::conversation::{ - ConversationPersistenceManager, SessionMetadata, SessionStatus, - }; + use crate::infrastructure::get_workspace_path; let agent = resolve_agent_type(agent_type.as_deref()); let session_name = custom_name @@ -1227,51 +1451,6 @@ impl RemoteServer { { Ok(session) => { let session_id = session.session_id.clone(); - - if let Some(wp) = binding_ws_path { - if let Ok(pm) = PathManager::new() { - let pm = std::sync::Arc::new(pm); - if let Ok(conv_mgr) = - ConversationPersistenceManager::new(pm, wp.clone()).await - { - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() - as u64; - let meta = SessionMetadata { - session_id: session_id.clone(), - session_name: session_name.to_string(), - agent_type: agent.to_string(), - model_name: "default".to_string(), - created_at: now_ms, - last_active_at: now_ms, - turn_count: 0, - message_count: 0, - tool_call_count: 0, - status: SessionStatus::Active, - terminal_session_id: None, - snapshot_session_id: None, - tags: vec![], - custom_metadata: None, - todos: None, - workspace_path: binding_ws_str, - }; - if let Err(e) = - conv_mgr.save_session_metadata(&meta).await - { - error!( - "Failed to sync remote session to workspace: {e}" - ); - } else { - info!( - "Remote session synced to workspace: {session_id}" - ); - } - } - } - } - RemoteResponse::SessionCreated { session_id } } Err(e) => RemoteResponse::Error { @@ -1318,7 +1497,7 @@ impl RemoteServer { // ── Execution commands ────────────────────────────────────────── async fn handle_execution_command(&self, cmd: &RemoteCommand) -> RemoteResponse { - use crate::agentic::coordination::get_global_coordinator; + use crate::agentic::coordination::{get_global_coordinator, DialogTriggerSource}; let coordinator = match get_global_coordinator() { Some(c) => c, @@ -1339,71 +1518,20 @@ impl RemoteServer { self.ensure_tracker(session_id); let session_mgr = coordinator.get_session_manager(); - let (session_agent_type, session_ws) = session_mgr - .get_session(session_id) - .map(|s| (s.agent_type.clone(), s.config.workspace_path.clone())) - .unwrap_or_else(|| ("default".to_string(), None)); + let _ = match session_mgr.get_session(session_id) { + Some(session) => Some(session), + None => coordinator.restore_session(session_id).await.ok(), + }; let agent_type = requested_agent_type .as_deref() .map(|t| resolve_agent_type(Some(t)).to_string()) - .unwrap_or(session_agent_type); - - if let Some(ws_path_str) = &session_ws { - use crate::infrastructure::{get_workspace_path, set_workspace_path}; - let current = get_workspace_path(); - let current_str = - current.as_ref().map(|p| p.to_string_lossy().to_string()); - if current_str.as_deref() != Some(ws_path_str.as_str()) { - info!("Remote send_message: temporarily setting workspace for session={session_id} to {ws_path_str}"); - set_workspace_path(Some(std::path::PathBuf::from(ws_path_str))); - } - } - - let full_content = if let Some(imgs) = &images { - if imgs.is_empty() { - content.clone() - } else { - let save_dir = if let Some(ws) = &session_ws { - let d = std::path::PathBuf::from(ws) - .join(".bitfun") - .join("remote-images"); - let _ = std::fs::create_dir_all(&d); - Some(d) - } else { - None - }; - - let mut extra = String::new(); - for (i, img) in imgs.iter().enumerate() { - if let Some(ref dir) = save_dir { - if let Some(saved) = - save_data_url_image(dir, &img.name, &img.data_url) - { - let path_str = saved.to_string_lossy(); - extra.push_str(&format!( - "\n\n[Image: {}]\nPath: {}\nTip: You can use the AnalyzeImage tool with the image_path parameter.", - img.name, path_str - )); - info!("Remote image {i} saved: {path_str}"); - continue; - } - } - extra.push_str(&format!( - "\n\n[Image: {} (inline)]\nData URL provided inline.\nTip: You can use the AnalyzeImage tool with the data_url parameter.", - img.name - )); - } - format!("{content}{extra}") - } - } else { - content.clone() - }; + .unwrap_or_else(|| "agentic".to_string()); - let is_first_message = session_mgr - .get_session(session_id) - .map(|s| s.dialog_turn_ids.is_empty()) - .unwrap_or(true); + let full_content = images + .as_ref() + .map(|imgs| build_message_with_remote_images(content, imgs)) + .unwrap_or_else(|| content.clone()); info!( "Remote send_message: session={session_id}, agent_type={agent_type}, images={}", @@ -1416,58 +1544,106 @@ impl RemoteServer { full_content, Some(turn_id.clone()), agent_type, - true, + DialogTriggerSource::RemoteRelay, ) .await { - Ok(()) => { - if is_first_message { - let sid = session_id.clone(); - let msg = content.clone(); - let ws = session_ws.clone(); - tokio::spawn(async move { - if let Some(coord) = get_global_coordinator() { - match coord - .generate_session_title(&sid, &msg, Some(20)) - .await - { - Ok(title) => { - Self::persist_session_title(&sid, &title, ws.as_ref()) - .await; - } - Err(e) => { - debug!( - "Remote session title generation failed: {e}" - ); - } - } - } - }); - } - RemoteResponse::MessageSent { - session_id: session_id.clone(), - turn_id, - } - } + Ok(()) => RemoteResponse::MessageSent { + session_id: session_id.clone(), + turn_id, + }, Err(e) => RemoteResponse::Error { message: e.to_string(), }, } } - RemoteCommand::CancelTask { session_id } => { - let session_mgr = coordinator.get_session_manager(); - if let Some(session) = session_mgr.get_session(session_id) { - use crate::agentic::core::SessionState; - let _ = session_mgr - .update_session_state(session_id, SessionState::Idle) - .await; - if let Some(last_turn_id) = session.dialog_turn_ids.last() { - let _ = - coordinator.cancel_dialog_turn(session_id, last_turn_id).await; + RemoteCommand::CancelTask { + session_id, + turn_id, + } => { + let session = match coordinator.get_session_manager().get_session(session_id) { + Some(session) => Some(session), + None => coordinator.restore_session(session_id).await.ok(), + }; + + let Some(session) = session else { + return RemoteResponse::Error { + message: format!("Session not found: {}", session_id), + }; + }; + + let running_turn_id = match &session.state { + crate::agentic::core::SessionState::Processing { current_turn_id, .. } => { + Some(current_turn_id.clone()) } + _ => None, + }; + + match (running_turn_id, turn_id.as_ref()) { + (Some(current_turn_id), Some(requested_turn_id)) + if requested_turn_id != ¤t_turn_id => + { + RemoteResponse::Error { + message: "This task is no longer running.".to_string(), + } + } + (Some(current_turn_id), _) => match coordinator + .cancel_dialog_turn(session_id, ¤t_turn_id) + .await + { + Ok(_) => RemoteResponse::TaskCancelled { + session_id: session_id.clone(), + }, + Err(e) => RemoteResponse::Error { + message: e.to_string(), + }, + }, + (None, Some(_)) => RemoteResponse::Error { + message: "This task is already finished.".to_string(), + }, + (None, None) => RemoteResponse::Error { + message: format!("No running task to cancel for session: {}", session_id), + }, } - RemoteResponse::TaskCancelled { - session_id: session_id.clone(), + } + RemoteCommand::ConfirmTool { + tool_id, + updated_input, + } => match coordinator.confirm_tool(tool_id, updated_input.clone()).await { + Ok(_) => RemoteResponse::InteractionAccepted { + action: "confirm_tool".to_string(), + target_id: tool_id.clone(), + }, + Err(e) => RemoteResponse::Error { + message: e.to_string(), + }, + }, + RemoteCommand::RejectTool { tool_id, reason } => { + let reject_reason = reason + .clone() + .unwrap_or_else(|| "User rejected".to_string()); + match coordinator.reject_tool(tool_id, reject_reason).await { + Ok(_) => RemoteResponse::InteractionAccepted { + action: "reject_tool".to_string(), + target_id: tool_id.clone(), + }, + Err(e) => RemoteResponse::Error { + message: e.to_string(), + }, + } + } + RemoteCommand::CancelTool { tool_id, reason } => { + let cancel_reason = reason + .clone() + .unwrap_or_else(|| "User cancelled".to_string()); + match coordinator.cancel_tool(tool_id, cancel_reason).await { + Ok(_) => RemoteResponse::InteractionAccepted { + action: "cancel_tool".to_string(), + target_id: tool_id.clone(), + }, + Err(e) => RemoteResponse::Error { + message: e.to_string(), + }, } } RemoteCommand::AnswerQuestion { tool_id, answers } => { @@ -1484,40 +1660,6 @@ impl RemoteServer { } } - async fn persist_session_title( - session_id: &str, - title: &str, - workspace_path: Option<&String>, - ) { - use crate::infrastructure::{get_workspace_path, PathManager}; - use crate::service::conversation::ConversationPersistenceManager; - - let ws = workspace_path - .map(std::path::PathBuf::from) - .or_else(get_workspace_path); - let Some(wp) = ws else { return }; - - let pm = match PathManager::new() { - Ok(pm) => std::sync::Arc::new(pm), - Err(_) => return, - }; - let conv_mgr = match ConversationPersistenceManager::new(pm, wp).await { - Ok(m) => m, - Err(_) => return, - }; - if let Ok(Some(mut meta)) = conv_mgr.load_session_metadata(session_id).await { - meta.session_name = title.to_string(); - meta.last_active_at = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; - if let Err(e) = conv_mgr.save_session_metadata(&meta).await { - error!("Failed to persist remote session title: {e}"); - } else { - info!("Remote session title persisted: session_id={session_id}, title={title}"); - } - } - } } #[cfg(test)] diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index ea364c66..bfd1824d 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react'; +import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; @@ -118,11 +118,16 @@ const TOOL_TYPE_MAP: Record = { web_search: 'Web', }; -const ToolCard: React.FC<{ tool: RemoteToolStatus; now: number }> = ({ tool, now }) => { +const ToolCard: React.FC<{ + tool: RemoteToolStatus; + now: number; + onCancelTool?: (toolId: string) => void; +}> = ({ tool, now, onCancelTool }) => { const toolKey = tool.name.toLowerCase().replace(/[\s-]/g, '_'); const typeLabel = TOOL_TYPE_MAP[toolKey] || TOOL_TYPE_MAP[tool.name] || 'Tool'; const isRunning = tool.status === 'running'; const isCompleted = tool.status === 'completed'; + const showCancel = isRunning && !!onCancelTool; const durationLabel = isCompleted && tool.duration_ms != null ? `${(tool.duration_ms / 1000).toFixed(1)}s` @@ -154,13 +159,22 @@ const ToolCard: React.FC<{ tool: RemoteToolStatus; now: number }> = ({ tool, now {durationLabel} )} + {showCancel && ( +
+ +
+ )} ); }; const TOOL_LIST_COLLAPSE_THRESHOLD = 2; -const ToolList: React.FC<{ tools: RemoteToolStatus[]; now: number }> = ({ tools, now }) => { +const ToolList: React.FC<{ + tools: RemoteToolStatus[]; + now: number; + onCancelTool?: (toolId: string) => void; +}> = ({ tools, now, onCancelTool }) => { const scrollRef = useRef(null); const prevCountRef = useRef(0); @@ -177,7 +191,7 @@ const ToolList: React.FC<{ tools: RemoteToolStatus[]; now: number }> = ({ tools, return (
{tools.map((tc) => ( - + ))}
); @@ -197,7 +211,7 @@ const ToolList: React.FC<{ tools: RemoteToolStatus[]; now: number }> = ({ tools,
{tools.map((tc) => ( - + ))}
@@ -216,16 +230,35 @@ const TypingDots: React.FC = () => ( interface AskQuestionCardProps { tool: RemoteToolStatus; - onAnswer: (toolId: string, answers: any) => void; + onAnswer: (toolId: string, answers: any) => Promise; } +const isPendingAskUserQuestion = (tool?: RemoteToolStatus | null) => { + if (!tool || tool.name !== 'AskUserQuestion' || !tool.tool_input) return false; + return !['completed', 'failed', 'cancelled', 'rejected'].includes(tool.status); +}; + +const isOtherQuestionOption = (label?: string) => { + const normalized = (label || '').trim().toLowerCase(); + return normalized === 'other' || normalized === '其他'; +}; + const AskQuestionCard: React.FC = ({ tool, onAnswer }) => { const questions: any[] = tool.tool_input?.questions || []; const [selected, setSelected] = useState>({}); const [customTexts, setCustomTexts] = useState>({}); + const [submitting, setSubmitting] = useState(false); const [submitted, setSubmitted] = useState(false); - if (questions.length === 0) return null; + const normalizedQuestions = useMemo(() => { + return questions.map((q) => { + const options = Array.isArray(q.options) ? q.options : []; + const hasBuiltInOther = options.some((opt: any) => isOtherQuestionOption(opt?.label)); + return { ...q, options, hasBuiltInOther }; + }); + }, [questions]); + + if (normalizedQuestions.length === 0) return null; const handleSelect = (qIdx: number, label: string, multi: boolean) => { setSelected(prev => { @@ -237,44 +270,54 @@ const AskQuestionCard: React.FC = ({ tool, onAnswer }) => }); }; - const handleSubmit = () => { + const handleSubmit = async () => { + if (!allAnswered || submitting || submitted) return; + const answers: Record = {}; - questions.forEach((q, idx) => { + normalizedQuestions.forEach((q, idx) => { const sel = selected[idx]; - if (sel === '其他' || sel === 'Other') { - answers[String(idx)] = customTexts[idx] || sel; + const customText = (customTexts[idx] || '').trim(); + if (Array.isArray(sel)) { + answers[String(idx)] = sel.map(value => isOtherQuestionOption(value) ? (customText || value) : value); + } else if (isOtherQuestionOption(sel)) { + answers[String(idx)] = customText || sel; } else { answers[String(idx)] = sel ?? ''; } }); - setSubmitted(true); - onAnswer(tool.id, answers); + + setSubmitting(true); + try { + await onAnswer(tool.id, answers); + setSubmitted(true); + } finally { + setSubmitting(false); + } }; - const allAnswered = questions.every((q, idx) => { + const allAnswered = normalizedQuestions.every((q, idx) => { const s = selected[idx]; - if (q.multiSelect) return Array.isArray(s) && s.length > 0; - return !!s; + const hasSelection = q.multiSelect ? Array.isArray(s) && s.length > 0 : !!s; + if (!hasSelection) return false; + const requiresCustomText = Array.isArray(s) + ? s.some(value => isOtherQuestionOption(value)) + : isOtherQuestionOption(s); + return !requiresCustomText || !!(customTexts[idx] || '').trim(); }); return (
{questions.length} question{questions.length > 1 ? 's' : ''} - - {!submitted && ( + {!submitted && !submitting && ( Waiting )}
- {questions.map((q, qIdx) => { - const isOtherSelected = selected[qIdx] === '其他' || selected[qIdx] === 'Other'; + {normalizedQuestions.map((q, qIdx) => { + const answer = selected[qIdx]; + const isOtherSelected = Array.isArray(answer) + ? answer.some(value => isOtherQuestionOption(value)) + : isOtherQuestionOption(answer); return (
@@ -291,7 +334,7 @@ const AskQuestionCard: React.FC = ({ tool, onAnswer }) => key={oIdx} className={`chat-ask-card__option ${isSelected ? 'is-selected' : ''}`} onClick={() => handleSelect(qIdx, opt.label, q.multiSelect)} - disabled={submitted} + disabled={submitted || submitting} > {isSelected && ( @@ -307,42 +350,49 @@ const AskQuestionCard: React.FC = ({ tool, onAnswer }) => ); })} - {/* "Other" option */} - + {!q.hasBuiltInOther && ( + + )} {isOtherSelected && ( setCustomTexts(prev => ({ ...prev, [qIdx]: e.target.value }))} - disabled={submitted} + disabled={submitted || submitting} /> )}
); })} +
); }; -// ─── Ordered Items renderer ───────────────────────────────────────────────── - -function renderOrderedItems(items: ChatMessageItem[], now: number) { +function groupChatItems(items: ChatMessageItem[]) { const groups: { type: string; entries: ChatMessageItem[] }[] = []; for (const item of items) { const last = groups[groups.length - 1]; @@ -352,29 +402,86 @@ function renderOrderedItems(items: ChatMessageItem[], now: number) { groups.push({ type: item.type, entries: [item] }); } } + return groups; +} +function renderQuestionEntries( + entries: ChatMessageItem[], + keyPrefix: string, + onAnswer?: (toolId: string, answers: any) => Promise, +) { + if (!onAnswer) return null; + return entries.map((entry, idx) => ( + + )); +} + +function renderStandardGroups( + groups: { type: string; entries: ChatMessageItem[] }[], + keyPrefix: string, + now: number, + onCancelTool?: (toolId: string) => void, +) { return groups.map((g, gi) => { if (g.type === 'thinking') { const text = g.entries.map(e => e.content || '').join('\n\n'); - return ; + return ; } if (g.type === 'tool') { const tools = g.entries.map(e => e.tool!).filter(Boolean); - return ; + return ; } if (g.type === 'text') { - return g.entries.map((entry, ii) => - entry.content ? ( -
- -
- ) : null, - ); + const text = g.entries.map(e => e.content || '').join(''); + return text ? ( +
+ +
+ ) : null; } return null; }); } +// ─── Ordered Items renderer ───────────────────────────────────────────────── + +function renderOrderedItems( + items: ChatMessageItem[], + now: number, + onCancelTool?: (toolId: string) => void, + onAnswer?: (toolId: string, answers: any) => Promise, +) { + const askEntries = items.filter(item => isPendingAskUserQuestion(item.tool)); + if (askEntries.length === 0) { + return renderStandardGroups(groupChatItems(items), 'ordered', now, onCancelTool); + } + + const beforeAskItems: ChatMessageItem[] = []; + const afterAskItems: ChatMessageItem[] = []; + let foundFirstAsk = false; + for (const item of items) { + if (isPendingAskUserQuestion(item.tool)) { + foundFirstAsk = true; + } else if (!foundFirstAsk) { + beforeAskItems.push(item); + } else { + afterAskItems.push(item); + } + } + + return ( + <> + {renderStandardGroups(groupChatItems(beforeAskItems), 'ordered-before', now, onCancelTool)} + {renderQuestionEntries(askEntries, 'ordered', onAnswer)} + {renderStandardGroups(groupChatItems(afterAskItems), 'ordered-after', now, onCancelTool)} + + ); +} + // ─── Active turn items renderer (with AskUserQuestion support) ───────────── function renderActiveTurnItems( @@ -382,55 +489,37 @@ function renderActiveTurnItems( now: number, sessionMgr: RemoteSessionManager, setError: (e: string) => void, + onAnswer: (toolId: string, answers: any) => Promise, ) { - const groups: { type: string; entries: ChatMessageItem[] }[] = []; + const askEntries = items.filter(item => isPendingAskUserQuestion(item.tool)); + const onCancel = (toolId: string) => { + sessionMgr.cancelTool(toolId, 'User cancelled').catch(err => { setError(String(err)); }); + }; + + if (askEntries.length === 0) { + return renderStandardGroups(groupChatItems(items), 'active', now, onCancel); + } + + const beforeAskItems: ChatMessageItem[] = []; + const afterAskItems: ChatMessageItem[] = []; + let foundFirstAsk = false; for (const item of items) { - const last = groups[groups.length - 1]; - if (last && last.type === item.type) { - last.entries.push(item); + if (isPendingAskUserQuestion(item.tool)) { + foundFirstAsk = true; + } else if (!foundFirstAsk) { + beforeAskItems.push(item); } else { - groups.push({ type: item.type, entries: [item] }); + afterAskItems.push(item); } } - return groups.map((g, gi) => { - if (g.type === 'thinking') { - const text = g.entries.map(e => e.content || '').join('\n\n'); - return ; - } - if (g.type === 'tool') { - const askEntries = g.entries.filter( - e => e.tool?.name === 'AskUserQuestion' && e.tool?.status === 'running' && e.tool?.tool_input, - ); - const regularEntries = g.entries.filter(e => !askEntries.includes(e)); - const regularTools = regularEntries.map(e => e.tool!).filter(Boolean); - - return ( - - {regularTools.length > 0 && } - {askEntries.map(e => ( - { - sessionMgr.answerQuestion(toolId, answers).catch(err => { setError(String(err)); }); - }} - /> - ))} - - ); - } - if (g.type === 'text') { - return g.entries.map((entry, ii) => - entry.content ? ( -
- -
- ) : null, - ); - } - return null; - }); + return ( + <> + {renderStandardGroups(groupChatItems(beforeAskItems), 'active-before', now, onCancel)} + {renderQuestionEntries(askEntries, 'active', onAnswer)} + {renderStandardGroups(groupChatItems(afterAskItems), 'active-after', now, onCancel)} + + ); } // ─── Theme toggle icon ───────────────────────────────────────────────────── @@ -488,6 +577,15 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, const isStreaming = activeTurn != null && activeTurn.status === 'active'; const [now, setNow] = useState(() => Date.now()); + const handleAnswerQuestion = useCallback(async (toolId: string, answers: any) => { + try { + await sessionMgr.answerQuestion(toolId, answers); + } catch (err) { + setError(String(err)); + throw err; + } + }, [sessionMgr, setError]); + useEffect(() => { if (!isStreaming) return; const timer = setInterval(() => setNow(Date.now()), 500); @@ -621,7 +719,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, const handleCancel = async () => { try { - await sessionMgr.cancelTask(sessionId); + await sessionMgr.cancelTask(sessionId, activeTurn?.turn_id); } catch { // best effort } @@ -691,7 +789,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, return (
{m.items && m.items.length > 0 ? ( - renderOrderedItems(m.items, now) + renderOrderedItems(m.items, now, undefined, handleAnswerQuestion) ) : ( <> {m.thinking && } @@ -712,7 +810,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, if (activeTurn.items && activeTurn.items.length > 0) { return (
- {renderActiveTurnItems(activeTurn.items, now, sessionMgr, setError)} + {renderActiveTurnItems(activeTurn.items, now, sessionMgr, setError, handleAnswerQuestion)} {activeTurn.status === 'active' && !activeTurn.thinking && !activeTurn.text && activeTurn.tools.length === 0 && (
)} @@ -734,14 +832,18 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, streaming={activeTurn.status === 'active' && !activeTurn.thinking && !activeTurn.text} /> )} - + { + sessionMgr.cancelTool(toolId, 'User cancelled').catch(err => { setError(String(err)); }); + }} + /> {askTools.map(at => ( { - sessionMgr.answerQuestion(toolId, answers).catch(err => { setError(String(err)); }); - }} + onAnswer={handleAnswerQuestion} /> ))} {activeTurn.text ? ( diff --git a/src/mobile-web/src/services/RemoteSessionManager.ts b/src/mobile-web/src/services/RemoteSessionManager.ts index b2eda676..f5e0f740 100644 --- a/src/mobile-web/src/services/RemoteSessionManager.ts +++ b/src/mobile-web/src/services/RemoteSessionManager.ts @@ -212,8 +212,20 @@ export class RemoteSessionManager { return resp.turn_id; } - async cancelTask(sessionId: string): Promise { - await this.request({ cmd: 'cancel_task', session_id: sessionId }); + async cancelTask(sessionId: string, turnId?: string): Promise { + await this.request({ + cmd: 'cancel_task', + session_id: sessionId, + turn_id: turnId ?? undefined, + }); + } + + async cancelTool(toolId: string, reason?: string): Promise { + await this.request({ + cmd: 'cancel_tool', + tool_id: toolId, + reason: reason ?? undefined, + }); } async deleteSession(sessionId: string): Promise { diff --git a/src/mobile-web/src/styles/components/tool-card.scss b/src/mobile-web/src/styles/components/tool-card.scss index f3c5462e..891ea707 100644 --- a/src/mobile-web/src/styles/components/tool-card.scss +++ b/src/mobile-web/src/styles/components/tool-card.scss @@ -245,6 +245,14 @@ &:not(:disabled):active { background: var(--color-accent-100); } + + &--bottom { + width: 100%; + justify-content: center; + padding: 10px; + margin-top: 8px; + font-size: 13px; + } } .chat-ask-card__waiting { diff --git a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx index 1d2f8b31..e2e7b54d 100644 --- a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx @@ -9,16 +9,11 @@ import { useNotification } from '@/shared/notification-system'; import NotificationButton from '../../TitleBar/NotificationButton'; import { AboutDialog } from '../../AboutDialog'; import { RemoteConnectDialog } from '../../RemoteConnectDialog'; - -const REMOTE_CONNECT_DISCLAIMER_KEY = 'bitfun:remote-connect:disclaimer-agreed:v1'; - -const getRemoteDisclaimerAgreed = (): boolean => { - try { - return localStorage.getItem(REMOTE_CONNECT_DISCLAIMER_KEY) === 'true'; - } catch { - return false; - } -}; +import { + getRemoteConnectDisclaimerAgreed, + setRemoteConnectDisclaimerAgreed, + RemoteConnectDisclaimerContent, +} from '../../RemoteConnectDialog/RemoteConnectDisclaimer'; const PersistentFooterActions: React.FC = () => { const { t } = useI18n('common'); @@ -32,7 +27,7 @@ const PersistentFooterActions: React.FC = () => { const [showAbout, setShowAbout] = useState(false); const [showRemoteConnect, setShowRemoteConnect] = useState(false); const [showRemoteDisclaimer, setShowRemoteDisclaimer] = useState(false); - const [hasAgreedRemoteDisclaimer, setHasAgreedRemoteDisclaimer] = useState(() => getRemoteDisclaimerAgreed()); + const [hasAgreedRemoteDisclaimer, setHasAgreedRemoteDisclaimer] = useState(() => getRemoteConnectDisclaimerAgreed()); const closeMenu = useCallback(() => { setMenuClosing(true); @@ -73,7 +68,7 @@ const PersistentFooterActions: React.FC = () => { closeMenu(); - if (hasAgreedRemoteDisclaimer || getRemoteDisclaimerAgreed()) { + if (hasAgreedRemoteDisclaimer || getRemoteConnectDisclaimerAgreed()) { setHasAgreedRemoteDisclaimer(true); setShowRemoteConnect(true); return; @@ -83,11 +78,7 @@ const PersistentFooterActions: React.FC = () => { }, [hasWorkspace, warning, t, closeMenu, hasAgreedRemoteDisclaimer]); const handleAgreeDisclaimer = useCallback(() => { - try { - localStorage.setItem(REMOTE_CONNECT_DISCLAIMER_KEY, 'true'); - } catch { - // Ignore storage failures and keep in-memory consent for current session. - } + setRemoteConnectDisclaimerAgreed(); setHasAgreedRemoteDisclaimer(true); setShowRemoteDisclaimer(false); setShowRemoteConnect(true); @@ -179,44 +170,11 @@ const PersistentFooterActions: React.FC = () => { size="large" contentInset > -
-

{t('remoteConnect.disclaimerIntro')}

-
    -
  1. {t('remoteConnect.disclaimerItemBeta')}
  2. -
  3. {t('remoteConnect.disclaimerItemSecurity')}
  4. -
  5. {t('remoteConnect.disclaimerItemEncryption')}
  6. -
  7. {t('remoteConnect.disclaimerItemOpenSource')}
  8. -
  9. {t('remoteConnect.disclaimerItemPrivacy')}
  10. -
  11. {t('remoteConnect.disclaimerItemDataUsage')}
  12. -
  13. {t('remoteConnect.disclaimerItemCredentials')}
  14. -
  15. {t('remoteConnect.disclaimerItemQrCode')}
  16. -
  17. {t('remoteConnect.disclaimerItemNgrok')}
  18. -
  19. {t('remoteConnect.disclaimerItemSelfHosted')}
  20. -
  21. {t('remoteConnect.disclaimerItemNetwork')}
  22. -
  23. {t('remoteConnect.disclaimerItemBot')}
  24. -
  25. {t('remoteConnect.disclaimerItemBotPersistence')}
  26. -
  27. {t('remoteConnect.disclaimerItemMobileBrowser')}
  28. -
  29. {t('remoteConnect.disclaimerItemCompliance')}
  30. -
  31. {t('remoteConnect.disclaimerItemLiability')}
  32. -
- -
- - -
-
+ setShowRemoteDisclaimer(false)} + onAgree={handleAgreeDisclaimer} + />
); diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss index a1768b9a..dda75013 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss @@ -5,6 +5,33 @@ flex-direction: column; } +.bitfun-remote-connect__title-extra { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.bitfun-remote-connect__disclaimer-trigger { + border: none; + background: transparent; + padding: 0; + font-size: 12px; + color: var(--color-text-muted); + text-decoration: underline; + cursor: pointer; + transition: color 0.15s ease, opacity 0.15s ease; + + &:hover { + color: var(--color-text-primary); + } + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-accent-500, #3b82f6) 55%, transparent); + outline-offset: 2px; + border-radius: 4px; + } +} + // ==================== Group tabs (top level) ==================== .bitfun-remote-connect__groups { diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx index e856e786..5b7dad55 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx @@ -15,6 +15,11 @@ import { type ConnectionResult, type RemoteConnectStatus, } from '@/infrastructure/api/service-api/RemoteConnectAPI'; +import { + getRemoteConnectDisclaimerAgreed, + setRemoteConnectDisclaimerAgreed, + RemoteConnectDisclaimerContent, +} from './RemoteConnectDisclaimer'; import './RemoteConnectDialog.scss'; // ── Types ──────────────────────────────────────────────────────────── @@ -74,6 +79,8 @@ export const RemoteConnectDialog: React.FC = ({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [lanNetworkInfo, setLanNetworkInfo] = useState<{ localIp: string; gatewayIp: string | null } | null>(null); + const [showDisclaimer, setShowDisclaimer] = useState(false); + const [hasAgreedDisclaimer, setHasAgreedDisclaimer] = useState(() => getRemoteConnectDisclaimerAgreed()); const [customUrl, setCustomUrl] = useState(''); const [tgToken, setTgToken] = useState(''); @@ -117,6 +124,9 @@ export const RemoteConnectDialog: React.FC = ({ pollRef.current = null; return; } + + setHasAgreedDisclaimer(getRemoteConnectDisclaimerAgreed()); + let cancelled = false; const checkExisting = async () => { for (let attempt = 0; attempt < 3; attempt++) { @@ -482,78 +492,117 @@ export const RemoteConnectDialog: React.FC = ({ const isNetworkConnecting = !!connectionResult && activeGroup === 'network' && !isRelayConnected; const isBotConnecting = !!connectionResult && activeGroup === 'bot' && !isBotConnected; + const handleAgreeDisclaimer = useCallback(() => { + setRemoteConnectDisclaimerAgreed(); + setHasAgreedDisclaimer(true); + setShowDisclaimer(false); + }, []); return ( - -
- {/* ── Group tabs ── */} -
- - - -
- - {/* ── Sub-tabs ── */} - {activeGroup === 'network' ? ( -
- {NETWORK_TABS.map((tab, i) => ( - - {i > 0 && } - - - ))} -
- ) : ( -
- {BOT_TABS.map((tab, i) => ( - - {i > 0 && } - - - ))} -
+ <> + + + )} + showCloseButton + size="large" + > +
+ {/* ── Group tabs ── */} +
+ + + +
- {/* ── Content ── */} - {activeGroup === 'network' ? renderNetworkContent() : renderBotContent()} -
-
+ {/* ── Sub-tabs ── */} + {activeGroup === 'network' ? ( +
+ {NETWORK_TABS.map((tab, i) => ( + + {i > 0 && } + + + ))} +
+ ) : ( +
+ {BOT_TABS.map((tab, i) => ( + + {i > 0 && } + + + ))} +
+ )} + + {/* ── Content ── */} + {activeGroup === 'network' ? renderNetworkContent() : renderBotContent()} +
+
+ + setShowDisclaimer(false)} + title={t('remoteConnect.disclaimerTitle')} + showCloseButton + size="large" + contentInset + > + setShowDisclaimer(false)} + onAgree={hasAgreedDisclaimer ? undefined : handleAgreeDisclaimer} + /> + + ); }; diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.scss b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.scss new file mode 100644 index 00000000..e3845bbc --- /dev/null +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.scss @@ -0,0 +1,75 @@ +@use '../../../component-library/styles/tokens' as *; + +.bitfun-remote-disclaimer { + display: flex; + flex-direction: column; + gap: 10px; + color: var(--color-text-secondary); + padding: 12px 0 14px; +} + +.bitfun-remote-disclaimer__meta { + display: flex; + justify-content: flex-start; +} + +.bitfun-remote-disclaimer__text { + margin: 0; + font-size: 12px; + line-height: 1.4; + color: var(--color-text-muted); +} + +.bitfun-remote-disclaimer__list { + margin: 0; + padding-left: 20px; + display: flex; + flex-direction: column; + gap: 4px; + + li { + font-size: 12px; + line-height: 1.45; + color: var(--color-text-secondary); + } +} + +.bitfun-remote-disclaimer__actions { + display: flex; + justify-content: center; + gap: 12px; + margin-top: 6px; + padding-top: 10px; + border-top: 1px solid var(--border-subtle); +} + +.bitfun-remote-disclaimer__btn { + border-radius: 6px; + border: 1px solid transparent; + min-width: 112px; + padding: 7px 16px; + font-size: 12px; + cursor: pointer; + transition: all $motion-fast $easing-standard; +} + +.bitfun-remote-disclaimer__btn--secondary { + background: var(--element-bg-subtle); + color: var(--color-text-secondary); + border-color: var(--border-subtle); + + &:hover:not(:disabled) { + color: var(--color-text-primary); + border-color: var(--border-medium); + } +} + +.bitfun-remote-disclaimer__btn--primary { + background: var(--color-accent-600, #2563eb); + color: #fff; + border-color: color-mix(in srgb, var(--color-accent-700, #1d4ed8) 35%, transparent); + + &:hover:not(:disabled) { + background: var(--color-accent-700, #1d4ed8); + } +} diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.tsx new file mode 100644 index 00000000..cf7a8ab5 --- /dev/null +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.tsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { Badge } from '@/component-library'; +import { useI18n } from '@/infrastructure/i18n'; +import './RemoteConnectDisclaimer.scss'; + +export const REMOTE_CONNECT_DISCLAIMER_KEY = 'bitfun:remote-connect:disclaimer-agreed:v1'; + +export const getRemoteConnectDisclaimerAgreed = (): boolean => { + try { + return localStorage.getItem(REMOTE_CONNECT_DISCLAIMER_KEY) === 'true'; + } catch { + return false; + } +}; + +export const setRemoteConnectDisclaimerAgreed = (): void => { + try { + localStorage.setItem(REMOTE_CONNECT_DISCLAIMER_KEY, 'true'); + } catch { + // Ignore storage failures and fall back to in-memory state. + } +}; + +interface RemoteConnectDisclaimerContentProps { + agreed: boolean; + onClose: () => void; + onAgree?: () => void; +} + +export const RemoteConnectDisclaimerContent: React.FC = ({ + agreed, + onClose, + onAgree, +}) => { + const { t } = useI18n('common'); + const canAgree = !!onAgree && !agreed; + + return ( +
+
+ + {t(agreed ? 'remoteConnect.disclaimerStatusAgreed' : 'remoteConnect.disclaimerStatusPending')} + +
+ +

{t('remoteConnect.disclaimerIntro')}

+ +
    +
  1. {t('remoteConnect.disclaimerItemBeta')}
  2. +
  3. {t('remoteConnect.disclaimerItemSecurity')}
  4. +
  5. {t('remoteConnect.disclaimerItemEncryption')}
  6. +
  7. {t('remoteConnect.disclaimerItemOpenSource')}
  8. +
  9. {t('remoteConnect.disclaimerItemPrivacy')}
  10. +
  11. {t('remoteConnect.disclaimerItemDataUsage')}
  12. +
  13. {t('remoteConnect.disclaimerItemCredentials')}
  14. +
  15. {t('remoteConnect.disclaimerItemQrCode')}
  16. +
  17. {t('remoteConnect.disclaimerItemNgrok')}
  18. +
  19. {t('remoteConnect.disclaimerItemSelfHosted')}
  20. +
  21. {t('remoteConnect.disclaimerItemNetwork')}
  22. +
  23. {t('remoteConnect.disclaimerItemBot')}
  24. +
  25. {t('remoteConnect.disclaimerItemBotPersistence')}
  26. +
  27. {t('remoteConnect.disclaimerItemMobileBrowser')}
  28. +
  29. {t('remoteConnect.disclaimerItemCompliance')}
  30. +
  31. {t('remoteConnect.disclaimerItemLiability')}
  32. +
+ +
+ + {canAgree && ( + + )} +
+
+ ); +}; diff --git a/src/web-ui/src/flow_chat/hooks/useFlowChat.ts b/src/web-ui/src/flow_chat/hooks/useFlowChat.ts index ca240a45..218e7440 100644 --- a/src/web-ui/src/flow_chat/hooks/useFlowChat.ts +++ b/src/web-ui/src/flow_chat/hooks/useFlowChat.ts @@ -228,19 +228,11 @@ export const useFlowChat = () => { flowChatStore.addDialogTurn(targetSessionId, dialogTurn); if (isFirstMessage) { - // Use a temp title from the message prefix to avoid slow title generation. const tempTitle = generateTempTitle(content, 20); - flowChatStore.updateSessionTitle(targetSessionId, tempTitle, 'generating'); - if (aiExperienceConfigService.isSessionTitleGenerationEnabled()) { - agentAPI.generateSessionTitle(targetSessionId, content, 20) - .then((aiTitle) => { - log.debug('AI title generated successfully', { sessionId: targetSessionId, title: aiTitle }); - }) - .catch((error) => { - log.warn('AI title generation failed, keeping temporary title', { sessionId: targetSessionId, error }); - flowChatStore.updateSessionTitle(targetSessionId, tempTitle, 'generated'); - }); + // Set temp title while waiting for coordinator's auto-generated AI title + // (delivered via SessionTitleGenerated event). + flowChatStore.updateSessionTitle(targetSessionId, tempTitle, 'generating'); } else { flowChatStore.updateSessionTitle(targetSessionId, tempTitle, 'generated'); } diff --git a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts index 18b381ba..1af347d8 100644 --- a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts +++ b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts @@ -8,7 +8,7 @@ */ import { agentAPI } from '@/infrastructure/api'; -import type { TextChunkEvent, ToolEvent, AgenticEvent } from '@/infrastructure/api/service-api/AgentAPI'; +import type { TextChunkEvent, ToolEvent, AgenticEvent, SessionTitleGeneratedEvent } from '@/infrastructure/api/service-api/AgentAPI'; import { createLogger } from '@/shared/utils/logger'; type UnlistenFn = () => void; @@ -30,6 +30,7 @@ export interface AgenticEventCallbacks { onContextCompressionStarted?: (event: AgenticEvent) => void; onContextCompressionCompleted?: (event: AgenticEvent) => void; onContextCompressionFailed?: (event: AgenticEvent) => void; + onSessionTitleGenerated?: (event: SessionTitleGeneratedEvent) => void; } export class AgenticEventListener { @@ -155,6 +156,14 @@ export class AgenticEventListener { this.unlistenFunctions.push(unlisten); } + if (callbacks.onSessionTitleGenerated) { + const unlisten = agentAPI.onSessionTitleGenerated((event) => { + logger.debug('Session title generated:', event); + callbacks.onSessionTitleGenerated?.(event); + }); + this.unlistenFunctions.push(unlisten); + } + this.isListening = true; logger.info(`Registered ${this.unlistenFunctions.length} event listeners`); } catch (error) { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 813440b9..8cbe5d63 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -180,6 +180,9 @@ export async function initializeEventListeners( }, onContextCompressionFailed: (event) => { handleCompressionFailed(context, event); + }, + onSessionTitleGenerated: (event) => { + handleSessionTitleGenerated(event); } }; @@ -199,6 +202,17 @@ function handleSessionCreated(context: FlowChatContext, event: any): void { store.addExternalSession(sessionId, sessionName || 'Remote Session', agentType || 'agentic', workspacePath); } +/** + * Handle session title generated event (from AI auto-generation) + */ +function handleSessionTitleGenerated(event: any): void { + const { sessionId, title } = event; + if (!sessionId || !title) return; + + const store = FlowChatStore.getInstance(); + store.updateSessionTitle(sessionId, title, 'generated'); +} + /** * Handle session deleted event (backend already deleted; only remove from store) */ diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index 9cc46f82..3547b9b8 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -12,7 +12,6 @@ import { SessionExecutionEvent, SessionExecutionState } from '../../state-machin import { generateTempTitle } from '../../utils/titleUtils'; import { createLogger } from '@/shared/utils/logger'; import type { FlowChatContext, DialogTurn } from './types'; -import { ensureBackendSession, retryCreateBackendSession } from './SessionModule'; import { cleanupSessionBuffers } from './TextChunkModule'; const log = createLogger('MessageModule'); @@ -87,12 +86,6 @@ export async function sendMessage( throw new Error(`Session lost after adding dialog turn: ${sessionId}`); } - try { - await ensureBackendSession(context, sessionId); - } catch (createError: any) { - log.warn('Backend session create/restore failed', { sessionId: sessionId, error: createError }); - } - context.contentBuffers.set(sessionId, new Map()); context.activeTextItems.set(sessionId, new Map()); @@ -107,23 +100,7 @@ export async function sendMessage( agentType: currentAgentType, }); } catch (error: any) { - if (error?.message?.includes('Session does not exist') || error?.message?.includes('Not found')) { - log.warn('Backend session still not found, retrying creation', { - sessionId: sessionId, - dialogTurnsCount: updatedSession.dialogTurns.length - }); - - await retryCreateBackendSession(context, sessionId); - - turnResponse = await agentAPI.startDialogTurn({ - sessionId: sessionId, - userInput: message, - turnId: dialogTurnId, - agentType: currentAgentType, - }); - } else { - throw error; - } + throw error; } const sessionStateMachine = stateMachineManager.get(sessionId); @@ -165,16 +142,11 @@ function handleTitleGeneration( message: string ): void { const tempTitle = generateTempTitle(message, 20); - context.flowChatStore.updateSessionTitle(sessionId, tempTitle, 'generating'); - + if (aiExperienceConfigService.isSessionTitleGenerationEnabled()) { - agentAPI.generateSessionTitle(sessionId, message, 20) - .then((_aiTitle) => { - }) - .catch((error) => { - log.debug('AI title generation failed, keeping temp title', { sessionId, error }); - context.flowChatStore.updateSessionTitle(sessionId, tempTitle, 'generated'); - }); + // Set temp title while waiting for coordinator's auto-generated AI title + // (delivered via SessionTitleGenerated event). + context.flowChatStore.updateSessionTitle(sessionId, tempTitle, 'generating'); } else { context.flowChatStore.updateSessionTitle(sessionId, tempTitle, 'generated'); } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index f7922897..4bfaa150 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -6,7 +6,7 @@ import { globalAPI } from '@/infrastructure/api'; import { createLogger } from '@/shared/utils/logger'; import { i18nService } from '@/infrastructure/i18n'; -import type { FlowChatContext, DialogTurn, SessionConfig } from './types'; +import type { FlowChatContext, DialogTurn } from './types'; const log = createLogger('PersistenceModule'); @@ -363,45 +363,6 @@ export async function updateSessionMetadata( } } -/** - * Save new session metadata - */ -export async function saveNewSessionMetadata( - sessionId: string, - config: SessionConfig, - sessionName: string, - mode?: string -): Promise { - try { - const { conversationAPI } = await import('@/infrastructure/api'); - const workspacePath = await globalAPI.getCurrentWorkspacePath(); - - if (!workspacePath) { - log.debug('Cannot get workspace path, skipping save', { sessionId }); - return; - } - - const metadata: any = { - sessionId, - sessionName, - agentType: mode || 'agentic', - modelName: config.modelName || 'default', - createdAt: Date.now(), - lastActiveAt: Date.now(), - turnCount: 0, - messageCount: 0, - toolCallCount: 0, - status: 'active', - tags: [], - todos: [], - }; - - await conversationAPI.saveSessionMetadata(metadata, workspacePath); - } catch (error) { - log.warn('Failed to save new session metadata', { sessionId, error }); - } -} - /** * Update session activity time (used for session switching) */ diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index 4aab1077..db9b2657 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -8,7 +8,7 @@ import { notificationService } from '../../../shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; import { i18nService } from '@/infrastructure/i18n'; import type { FlowChatContext, SessionConfig } from './types'; -import { saveNewSessionMetadata, touchSessionActivity, cleanupSaveState } from './PersistenceModule'; +import { touchSessionActivity, cleanupSaveState } from './PersistenceModule'; const log = createLogger('SessionModule'); @@ -96,8 +96,6 @@ export async function createChatSession( maxContextTokens, mode ); - - await saveNewSessionMetadata(response.sessionId, config, sessionName, mode); return response.sessionId; } catch (error) { @@ -207,72 +205,3 @@ export async function deleteChatSession( } } -/** - * Ensure backend session exists (check before sending message) - */ -export async function ensureBackendSession( - context: FlowChatContext, - sessionId: string -): Promise { - const session = context.flowChatStore.getState().sessions.get(sessionId); - if (!session) { - throw new Error(`Session does not exist: ${sessionId}`); - } - - const isHistoricalSession = session.isHistorical === true; - const isFirstTurn = session.dialogTurns.length <= 1; - const needsBackendSetup = isHistoricalSession || isFirstTurn; - - if (needsBackendSetup) { - try { - await agentAPI.restoreSession(sessionId); - - if (isHistoricalSession) { - context.flowChatStore.setState(prev => { - const newSessions = new Map(prev.sessions); - const sess = newSessions.get(sessionId); - if (sess) { - newSessions.set(sessionId, { ...sess, isHistorical: false }); - } - return { ...prev, sessions: newSessions }; - }); - } - } catch (restoreError: any) { - log.debug('Session restore failed, creating new session', { sessionId, error: restoreError }); - await agentAPI.createSession({ - sessionId: sessionId, - sessionName: session.title || `Session ${sessionId.slice(0, 8)}`, - agentType: session.mode || 'agentic', - config: { - modelName: session.config.modelName || 'default', - enableTools: true, - safeMode: true - } - }); - } - } -} - -/** - * Retry creating backend session (retry after message send failure) - */ -export async function retryCreateBackendSession( - context: FlowChatContext, - sessionId: string -): Promise { - const session = context.flowChatStore.getState().sessions.get(sessionId); - if (!session) { - throw new Error(`Session does not exist: ${sessionId}`); - } - - await agentAPI.createSession({ - sessionId: sessionId, - sessionName: session.title || `Session ${sessionId.slice(0, 8)}`, - agentType: session.mode || 'agentic', - config: { - modelName: session.config.modelName || 'default', - enableTools: true, - safeMode: true - } - }); -} diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts index 362404d6..bc364933 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/index.ts @@ -10,7 +10,6 @@ export { saveAllInProgressTurns, convertDialogTurnToBackendFormat, updateSessionMetadata, - saveNewSessionMetadata, touchSessionActivity } from './PersistenceModule'; @@ -40,9 +39,7 @@ export { getModelMaxTokens, createChatSession, switchChatSession, - deleteChatSession, - ensureBackendSession, - retryCreateBackendSession + deleteChatSession } from './SessionModule'; export { diff --git a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx index 0822b470..7cd6727e 100644 --- a/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx +++ b/src/web-ui/src/flow_chat/tool-cards/AskUserQuestionCard.tsx @@ -146,7 +146,7 @@ export const AskUserQuestionCard: React.FC = ({ }; const renderQuestion = (q: QuestionData, questionIndex: number) => { - const answer = answers[questionIndex]; + const answer = getEffectiveAnswer(questionIndex); const otherInput = otherInputs[questionIndex] || ''; const isOtherSelected = q.multiSelect @@ -280,15 +280,28 @@ export const AskUserQuestionCard: React.FC = ({ ); }; + const getEffectiveAnswer = useCallback((questionIndex: number): string | string[] | undefined => { + const localAnswer = answers[questionIndex]; + if (localAnswer !== undefined) return localAnswer; + + if (status === 'completed' && toolResult?.result) { + const result = typeof toolResult.result === 'string' + ? JSON.parse(toolResult.result) + : toolResult.result; + return result?.answers?.[String(questionIndex)]; + } + return undefined; + }, [answers, status, toolResult]); + const getAnswerDisplay = (questionIndex: number): string => { - const answer = answers[questionIndex]; + const answer = getEffectiveAnswer(questionIndex); const otherInput = otherInputs[questionIndex] || ''; if (!answer) return ''; if (Array.isArray(answer)) { return answer.map(v => v === 'Other' ? otherInput || 'Other' : v).join(', '); } - return answer === 'Other' ? otherInput || 'Other' : answer; + return answer === 'Other' ? otherInput || 'Other' : String(answer); }; const getAnswersSummary = (): string => { diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index c3b1bd7c..ccf01c7a 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -219,6 +219,9 @@ "disclaimerItemMobileBrowser": "The mobile client loads as a web application in a browser. Browser cache, local storage, page screenshots, system clipboard, and other browser/system features may introduce additional information exposure risks.", "disclaimerItemCompliance": "Some countries or regions have legal restrictions on encrypted communication, tunneling services, cross-border data transfer, and related activities. Please evaluate compliance with your local laws and regulations, including potential policy changes.", "disclaimerItemLiability": "Any risks, losses, disputes, or other adverse consequences arising during your use of Remote Connect (including but not limited to data leakage, service interruption, account anomalies, or service unavailability) are your responsibility; BitFun is not liable for resulting issues.", + "disclaimerReview": "View disclaimer", + "disclaimerStatusAgreed": "Agreed", + "disclaimerStatusPending": "Not agreed", "disclaimerDecline": "Decline", "disclaimerAgree": "Agree and Continue" }, diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index b6b5a512..a1e23c1c 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -219,6 +219,9 @@ "disclaimerItemMobileBrowser": "移动端通过浏览器加载 Web 应用进行连接,浏览器缓存、本地存储、页面截图、系统剪贴板等都可能带来额外的信息暴露风险,其他浏览器插件或系统功能亦可能扩大风险面。", "disclaimerItemCompliance": "部分国家或地区对加密通信、隧道服务、跨境数据传输等有法规限制,请自行评估当地法律合规性;其他监管政策变化也可能影响功能可用性与合规义务。", "disclaimerItemLiability": "使用远程连接功能过程中产生的风险、损失、纠纷或其他不利后果(包括但不限于数据泄露、连接中断、账号异常、服务不可用等)由你自行承担;BitFun 不对由此造成的问题负责。", + "disclaimerReview": "查看免责声明", + "disclaimerStatusAgreed": "已同意", + "disclaimerStatusPending": "未同意", "disclaimerDecline": "不同意", "disclaimerAgree": "同意并继续" }, diff --git a/tsc b/tsc new file mode 100644 index 00000000..e69de29b From 90b4701fb19ea4ef3f35e4271795ae6a0598ddb4 Mon Sep 17 00:00:00 2001 From: bowen628 Date: Fri, 6 Mar 2026 20:07:48 +0800 Subject: [PATCH 2/4] fix: bot sync and resuming session --- .../remote_connect/bot/command_router.rs | 230 +++-------- .../service/remote_connect/remote_server.rs | 380 ++++++++++++------ 2 files changed, 317 insertions(+), 293 deletions(-) diff --git a/src/crates/core/src/service/remote_connect/bot/command_router.rs b/src/crates/core/src/service/remote_connect/bot/command_router.rs index 26bc92eb..e9b51591 100644 --- a/src/crates/core/src/service/remote_connect/bot/command_router.rs +++ b/src/crates/core/src/service/remote_connect/bot/command_router.rs @@ -932,8 +932,7 @@ async fn handle_cancel_task( state: &mut BotChatState, requested_turn_id: Option<&str>, ) -> HandleResult { - use crate::agentic::coordination::get_global_coordinator; - use crate::agentic::core::SessionState; + use crate::service::remote_connect::remote_server::get_or_init_global_dispatcher; let session_id = match state.current_session_id.clone() { Some(id) => id, @@ -946,55 +945,9 @@ async fn handle_cancel_task( } }; - let coordinator = match get_global_coordinator() { - Some(c) => c, - None => { - return HandleResult { - reply: "BitFun session system not ready.".to_string(), - actions: vec![], - forward_to_session: None, - }; - } - }; - - let session = match coordinator.restore_session(&session_id).await { - Ok(session) => session, - Err(e) => { - return HandleResult { - reply: format!("Failed to load session: {e}"), - actions: vec![], - forward_to_session: None, - }; - } - }; - - let current_turn_id = match session.state { - SessionState::Processing { current_turn_id, .. } => current_turn_id, - _ => { - return HandleResult { - reply: if requested_turn_id.is_some() { - "This request has already finished.".to_string() - } else { - "No running task to cancel.".to_string() - }, - actions: vec![], - forward_to_session: None, - }; - } - }; - - if let Some(requested_turn_id) = requested_turn_id { - if requested_turn_id != current_turn_id { - return HandleResult { - reply: "This request is no longer running.".to_string(), - actions: vec![], - forward_to_session: None, - }; - } - } - - match coordinator - .cancel_dialog_turn(&session_id, ¤t_turn_id) + let dispatcher = get_or_init_global_dispatcher(); + match dispatcher + .cancel_task(&session_id, requested_turn_id) .await { Ok(_) => { @@ -1346,83 +1299,12 @@ async fn handle_chat_message(state: &mut BotChatState, message: &str) -> HandleR // ── Forwarded-turn execution ──────────────────────────────────────── -enum StreamChunk { - Text(String), - Thinking(String), - ThinkingEnd, - Interaction(BotInteractiveRequest), - Done, - Error(String), -} - -struct BotResponseCollector { - session_id: String, - chunk_tx: tokio::sync::mpsc::UnboundedSender, -} - -#[async_trait::async_trait] -impl crate::agentic::events::EventSubscriber for BotResponseCollector { - async fn on_event( - &self, - event: &crate::agentic::events::AgenticEvent, - ) -> crate::util::errors::BitFunResult<()> { - use bitfun_events::AgenticEvent as AE; - match event { - AE::TextChunk { text, session_id, .. } if session_id == &self.session_id => { - let _ = self.chunk_tx.send(StreamChunk::Text(text.clone())); - } - AE::ThinkingChunk { content, session_id, .. } if session_id == &self.session_id => { - if content == "" { - let _ = self.chunk_tx.send(StreamChunk::ThinkingEnd); - } else { - let _ = self.chunk_tx.send(StreamChunk::Thinking(content.clone())); - } - } - AE::ToolEvent { - session_id, - tool_event, - .. - } if session_id == &self.session_id => match tool_event { - bitfun_events::ToolEventData::Started { - tool_id, - tool_name, - params, - } if tool_name == "AskUserQuestion" => { - if let Some(questions_value) = params.get("questions").cloned() { - if let Ok(questions) = - serde_json::from_value::>(questions_value) - { - let request = build_question_prompt( - tool_id.clone(), - questions, - 0, - Vec::new(), - false, - None, - ); - let _ = self.chunk_tx.send(StreamChunk::Interaction(request)); - } - } - } - _ => {} - }, - AE::DialogTurnCompleted { session_id, .. } if session_id == &self.session_id => { - let _ = self.chunk_tx.send(StreamChunk::Done); - } - AE::DialogTurnFailed { session_id, error, .. } if session_id == &self.session_id => { - let _ = self.chunk_tx.send(StreamChunk::Error(error.clone())); - } - _ => {} - } - Ok(()) - } -} - /// Execute a forwarded dialog turn and return the AI response text. /// /// Called from the bot implementations after `handle_command` returns a -/// `ForwardRequest`. Subscribes to session events, starts the turn, and -/// collects text chunks until completion or timeout. +/// `ForwardRequest`. Dispatches the command through +/// `RemoteExecutionDispatcher` (the same path used by mobile), then +/// subscribes to the tracker's broadcast channel for real-time events. /// /// `message_sender` is called to send intermediate messages (e.g. thinking /// content) before the final response is returned. @@ -1431,68 +1313,90 @@ pub async fn execute_forwarded_turn( interaction_handler: Option, message_sender: Option, ) -> String { - use crate::agentic::coordination::{get_global_coordinator, DialogTriggerSource}; - - let coordinator = match get_global_coordinator() { - Some(c) => c, - None => return "Session system not ready.".to_string(), + use crate::agentic::coordination::DialogTriggerSource; + use crate::service::remote_connect::remote_server::{ + get_or_init_global_dispatcher, TrackerEvent, }; - let (chunk_tx, mut chunk_rx) = tokio::sync::mpsc::unbounded_channel::(); - let subscriber_id = format!("bot_forward_{}", uuid::Uuid::new_v4()); - let collector = BotResponseCollector { - session_id: forward.session_id.clone(), - chunk_tx, - }; - coordinator.subscribe_internal(subscriber_id.clone(), collector); + let dispatcher = get_or_init_global_dispatcher(); + + let tracker = dispatcher.ensure_tracker(&forward.session_id); + let mut event_rx = tracker.subscribe(); - if let Err(e) = coordinator - .start_dialog_turn( - forward.session_id.clone(), + if let Err(e) = dispatcher + .send_message( + &forward.session_id, forward.content, - Some(forward.turn_id), - forward.agent_type, + Some(&forward.agent_type), + None, DialogTriggerSource::Bot, + Some(forward.turn_id), ) .await { - coordinator.unsubscribe_internal(&subscriber_id); return format!("Failed to send message: {e}"); } - let sub_id = subscriber_id.clone(); let result = tokio::time::timeout(std::time::Duration::from_secs(300), async { let mut thinking = String::new(); let mut response = String::new(); - while let Some(chunk) = chunk_rx.recv().await { - match chunk { - StreamChunk::Thinking(t) => thinking.push_str(&t), - StreamChunk::ThinkingEnd => { - if !thinking.is_empty() { - if let Some(sender) = message_sender.as_ref() { - sender(thinking.clone()).await; + loop { + match event_rx.recv().await { + Ok(event) => match event { + TrackerEvent::ThinkingChunk(t) => thinking.push_str(&t), + TrackerEvent::ThinkingEnd => { + if !thinking.is_empty() { + if let Some(sender) = message_sender.as_ref() { + sender(thinking.clone()).await; + } + thinking.clear(); } - thinking.clear(); } - } - StreamChunk::Text(t) => response.push_str(&t), - StreamChunk::Interaction(interaction) => { - if let Some(handler) = interaction_handler.as_ref() { - handler(interaction).await; + TrackerEvent::TextChunk(t) => response.push_str(&t), + TrackerEvent::ToolStarted { + tool_id, + tool_name, + params, + } if tool_name == "AskUserQuestion" => { + if let Some(questions_value) = + params.and_then(|p| p.get("questions").cloned()) + { + if let Ok(questions) = + serde_json::from_value::>(questions_value) + { + let request = build_question_prompt( + tool_id, + questions, + 0, + Vec::new(), + false, + None, + ); + if let Some(handler) = interaction_handler.as_ref() { + handler(request).await; + } + } + } + } + TrackerEvent::TurnCompleted => break, + TrackerEvent::TurnFailed(e) => return format!("Error: {e}"), + TrackerEvent::TurnCancelled => { + return "Task was cancelled.".to_string(); } + _ => {} + }, + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + log::warn!("Bot event receiver lagged by {n} events"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + break; } - StreamChunk::Done => break, - StreamChunk::Error(e) => return format!("Error: {e}"), } } response }) .await; - if let Some(coord) = get_global_coordinator() { - coord.unsubscribe_internal(&sub_id); - } - match result { Ok(text) if text.is_empty() => "(No response)".to_string(), Ok(mut text) => { diff --git a/src/crates/core/src/service/remote_connect/remote_server.rs b/src/crates/core/src/service/remote_connect/remote_server.rs index 64685308..0ebad07e 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -14,7 +14,7 @@ use log::{debug, error, info}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, RwLock}; +use std::sync::{Arc, OnceLock, RwLock}; use super::encryption; @@ -502,16 +502,35 @@ struct TrackerState { active_items: Vec, } +/// Lightweight event broadcast by the tracker for real-time consumers (e.g. bots). +#[derive(Debug, Clone)] +pub enum TrackerEvent { + TextChunk(String), + ThinkingChunk(String), + ThinkingEnd, + ToolStarted { + tool_id: String, + tool_name: String, + params: Option, + }, + TurnCompleted, + TurnFailed(String), + TurnCancelled, +} + /// Tracks the real-time state of a session for polling by the mobile client. /// Subscribes to `AgenticEvent` and updates an in-memory snapshot. +/// Also broadcasts lightweight `TrackerEvent`s for real-time consumers. pub struct RemoteSessionStateTracker { target_session_id: String, version: AtomicU64, state: RwLock, + event_tx: tokio::sync::broadcast::Sender, } impl RemoteSessionStateTracker { pub fn new(session_id: String) -> Self { + let (event_tx, _) = tokio::sync::broadcast::channel(256); Self { target_session_id: session_id, version: AtomicU64::new(0), @@ -526,9 +545,15 @@ impl RemoteSessionStateTracker { round_index: 0, active_items: Vec::new(), }), + event_tx, } } + /// Subscribe to real-time tracker events (for bot streaming). + pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver { + self.event_tx.subscribe() + } + pub fn version(&self) -> u64 { self.version.load(Ordering::Relaxed) } @@ -675,6 +700,7 @@ impl RemoteSessionStateTracker { } drop(s); self.bump_version(); + let _ = self.event_tx.send(TrackerEvent::TextChunk(text.clone())); } AE::ThinkingChunk { content, .. } => { let clean = content @@ -703,6 +729,11 @@ impl RemoteSessionStateTracker { } drop(s); self.bump_version(); + if content == "" { + let _ = self.event_tx.send(TrackerEvent::ThinkingEnd); + } else { + let _ = self.event_tx.send(TrackerEvent::ThinkingChunk(content.clone())); + } } AE::ToolEvent { tool_event, .. } => { if let Ok(val) = serde_json::to_value(tool_event) { @@ -764,7 +795,7 @@ impl RemoteSessionStateTracker { text.chars().take(160).collect() }); let tool_input = if tool_name == "AskUserQuestion" { - params + params.clone() } else { None }; @@ -776,6 +807,11 @@ impl RemoteSessionStateTracker { input_preview, tool_input, ); + let _ = self.event_tx.send(TrackerEvent::ToolStarted { + tool_id: tool_id.clone(), + tool_name: tool_name.clone(), + params, + }); } "Confirmed" => { Self::upsert_active_tool( @@ -901,14 +937,16 @@ impl RemoteSessionStateTracker { s.session_state = "idle".to_string(); drop(s); self.bump_version(); + let _ = self.event_tx.send(TrackerEvent::TurnCompleted); } - AE::DialogTurnFailed { .. } if is_direct => { + AE::DialogTurnFailed { error, .. } if is_direct => { let mut s = self.state.write().unwrap(); s.turn_status = "failed".to_string(); s.turn_id = None; s.session_state = "idle".to_string(); drop(s); self.bump_version(); + let _ = self.event_tx.send(TrackerEvent::TurnFailed(error.clone())); } AE::DialogTurnCancelled { .. } if is_direct => { let mut s = self.state.write().unwrap(); @@ -917,6 +955,7 @@ impl RemoteSessionStateTracker { s.session_state = "idle".to_string(); drop(s); self.bump_version(); + let _ = self.event_tx.send(TrackerEvent::TurnCancelled); } AE::ModelRoundStarted { round_index, .. } if is_direct => { let mut s = self.state.write().unwrap(); @@ -952,32 +991,169 @@ impl crate::agentic::events::EventSubscriber for Arc } } -// ── RemoteServer ─────────────────────────────────────────────────── +// ── RemoteExecutionDispatcher (global singleton) ──────────────────── -/// Bridges remote commands to local session operations. -pub struct RemoteServer { - shared_secret: [u8; 32], +/// Shared dispatch layer that owns the session state trackers. +/// Both `RemoteServer` (mobile relay) and the bot use this to +/// dispatch commands through the same path. +pub struct RemoteExecutionDispatcher { state_trackers: Arc>>, } -impl Drop for RemoteServer { - fn drop(&mut self) { - use crate::agentic::coordination::get_global_coordinator; - if let Some(coordinator) = get_global_coordinator() { - for entry in self.state_trackers.iter() { - let sub_id = format!("remote_tracker_{}", entry.key()); +static GLOBAL_DISPATCHER: OnceLock> = OnceLock::new(); + +pub fn get_or_init_global_dispatcher() -> Arc { + GLOBAL_DISPATCHER + .get_or_init(|| { + Arc::new(RemoteExecutionDispatcher { + state_trackers: Arc::new(DashMap::new()), + }) + }) + .clone() +} + +pub fn get_global_dispatcher() -> Option> { + GLOBAL_DISPATCHER.get().cloned() +} + +impl RemoteExecutionDispatcher { + /// Ensure a state tracker exists for the given session and return it. + pub fn ensure_tracker(&self, session_id: &str) -> Arc { + if let Some(tracker) = self.state_trackers.get(session_id) { + return tracker.clone(); + } + + let tracker = Arc::new(RemoteSessionStateTracker::new(session_id.to_string())); + self.state_trackers + .insert(session_id.to_string(), tracker.clone()); + + if let Some(coordinator) = crate::agentic::coordination::get_global_coordinator() { + let sub_id = format!("remote_tracker_{}", session_id); + coordinator.subscribe_internal(sub_id, tracker.clone()); + info!("Registered state tracker for session {session_id}"); + } + + tracker + } + + pub fn get_tracker(&self, session_id: &str) -> Option> { + self.state_trackers.get(session_id).map(|t| t.clone()) + } + + pub fn remove_tracker(&self, session_id: &str) { + if let Some((_, _)) = self.state_trackers.remove(session_id) { + if let Some(coordinator) = crate::agentic::coordination::get_global_coordinator() { + let sub_id = format!("remote_tracker_{}", session_id); coordinator.unsubscribe_internal(&sub_id); } } } + + /// Dispatch a SendMessage command: ensure tracker, restore session, start dialog turn. + /// Returns `(session_id, turn_id)` on success. + /// If `turn_id` is `None`, one is auto-generated. + pub async fn send_message( + &self, + session_id: &str, + content: String, + agent_type: Option<&str>, + images: Option<&Vec>, + trigger_source: crate::agentic::coordination::DialogTriggerSource, + turn_id: Option, + ) -> std::result::Result<(String, String), String> { + use crate::agentic::coordination::get_global_coordinator; + + let coordinator = get_global_coordinator() + .ok_or_else(|| "Desktop session system not ready".to_string())?; + + self.ensure_tracker(session_id); + + let session_mgr = coordinator.get_session_manager(); + let _ = match session_mgr.get_session(session_id) { + Some(session) => Some(session), + None => coordinator.restore_session(session_id).await.ok(), + }; + + let resolved_agent_type = agent_type + .map(|t| resolve_agent_type(Some(t)).to_string()) + .unwrap_or_else(|| "agentic".to_string()); + + let full_content = images + .map(|imgs| build_message_with_remote_images(&content, imgs)) + .unwrap_or_else(|| content.clone()); + + let turn_id = + turn_id.unwrap_or_else(|| format!("turn_{}", chrono::Utc::now().timestamp_millis())); + coordinator + .start_dialog_turn( + session_id.to_string(), + full_content, + Some(turn_id.clone()), + resolved_agent_type, + trigger_source, + ) + .await + .map_err(|e| e.to_string())?; + + Ok((session_id.to_string(), turn_id)) + } + + /// Cancel a running dialog turn. + pub async fn cancel_task( + &self, + session_id: &str, + requested_turn_id: Option<&str>, + ) -> std::result::Result<(), String> { + use crate::agentic::coordination::get_global_coordinator; + + let coordinator = get_global_coordinator() + .ok_or_else(|| "Desktop session system not ready".to_string())?; + + let session_mgr = coordinator.get_session_manager(); + let session = match session_mgr.get_session(session_id) { + Some(s) => s, + None => coordinator + .restore_session(session_id) + .await + .map_err(|e| format!("Session not found: {e}"))?, + }; + + let running_turn_id = match &session.state { + crate::agentic::core::SessionState::Processing { + current_turn_id, .. + } => Some(current_turn_id.clone()), + _ => None, + }; + + match (running_turn_id, requested_turn_id) { + (Some(current_turn_id), Some(req_id)) if req_id != current_turn_id => { + Err("This task is no longer running.".to_string()) + } + (Some(current_turn_id), _) => coordinator + .cancel_dialog_turn(session_id, ¤t_turn_id) + .await + .map_err(|e| e.to_string()), + (None, Some(_)) => Err("This task is already finished.".to_string()), + (None, None) => Err(format!( + "No running task to cancel for session: {}", + session_id + )), + } + } +} + +// ── RemoteServer ─────────────────────────────────────────────────── + +/// Bridges remote commands to local session operations. +/// Delegates execution and tracker management to the global `RemoteExecutionDispatcher`. +pub struct RemoteServer { + shared_secret: [u8; 32], } impl RemoteServer { pub fn new(shared_secret: [u8; 32]) -> Self { - Self { - shared_secret, - state_trackers: Arc::new(DashMap::new()), - } + get_or_init_global_dispatcher(); + Self { shared_secret } } pub fn shared_secret(&self) -> &[u8; 32] { @@ -1040,23 +1216,8 @@ impl RemoteServer { } } - /// Ensure a state tracker exists for the given session and return it. fn ensure_tracker(&self, session_id: &str) -> Arc { - if let Some(tracker) = self.state_trackers.get(session_id) { - return tracker.clone(); - } - - let tracker = Arc::new(RemoteSessionStateTracker::new(session_id.to_string())); - self.state_trackers - .insert(session_id.to_string(), tracker.clone()); - - if let Some(coordinator) = crate::agentic::coordination::get_global_coordinator() { - let sub_id = format!("remote_tracker_{}", session_id); - coordinator.subscribe_internal(sub_id, tracker.clone()); - info!("Registered state tracker for session {session_id}"); - } - - tracker + get_or_init_global_dispatcher().ensure_tracker(session_id) } pub async fn generate_initial_sync(&self) -> RemoteResponse { @@ -1472,13 +1633,7 @@ impl RemoteServer { } } RemoteCommand::DeleteSession { session_id } => { - self.state_trackers.remove(session_id); - if let Some(coordinator) = - crate::agentic::coordination::get_global_coordinator() - { - let sub_id = format!("remote_tracker_{}", session_id); - coordinator.unsubscribe_internal(&sub_id); - } + get_or_init_global_dispatcher().remove_tracker(session_id); match coordinator.delete_session(session_id).await { Ok(_) => RemoteResponse::SessionDeleted { session_id: session_id.clone(), @@ -1499,14 +1654,7 @@ impl RemoteServer { async fn handle_execution_command(&self, cmd: &RemoteCommand) -> RemoteResponse { use crate::agentic::coordination::{get_global_coordinator, DialogTriggerSource}; - let coordinator = match get_global_coordinator() { - Some(c) => c, - None => { - return RemoteResponse::Error { - message: "Desktop session system not ready".into(), - }; - } - }; + let dispatcher = get_or_init_global_dispatcher(); match cmd { RemoteCommand::SendMessage { @@ -1515,110 +1663,74 @@ impl RemoteServer { agent_type: requested_agent_type, images, } => { - self.ensure_tracker(session_id); - - let session_mgr = coordinator.get_session_manager(); - let _ = match session_mgr.get_session(session_id) { - Some(session) => Some(session), - None => coordinator.restore_session(session_id).await.ok(), - }; - - let agent_type = requested_agent_type - .as_deref() - .map(|t| resolve_agent_type(Some(t)).to_string()) - .unwrap_or_else(|| "agentic".to_string()); - - let full_content = images - .as_ref() - .map(|imgs| build_message_with_remote_images(content, imgs)) - .unwrap_or_else(|| content.clone()); - info!( - "Remote send_message: session={session_id}, agent_type={agent_type}, images={}", + "Remote send_message: session={session_id}, agent_type={}, images={}", + requested_agent_type.as_deref().unwrap_or("agentic"), images.as_ref().map_or(0, |v| v.len()) ); - let turn_id = format!("turn_{}", chrono::Utc::now().timestamp_millis()); - match coordinator - .start_dialog_turn( - session_id.clone(), - full_content, - Some(turn_id.clone()), - agent_type, + match dispatcher + .send_message( + session_id, + content.clone(), + requested_agent_type.as_deref(), + images.as_ref(), DialogTriggerSource::RemoteRelay, + None, ) .await { - Ok(()) => RemoteResponse::MessageSent { - session_id: session_id.clone(), + Ok((sid, turn_id)) => RemoteResponse::MessageSent { + session_id: sid, turn_id, }, - Err(e) => RemoteResponse::Error { - message: e.to_string(), - }, + Err(e) => RemoteResponse::Error { message: e }, } } RemoteCommand::CancelTask { session_id, turn_id, } => { - let session = match coordinator.get_session_manager().get_session(session_id) { - Some(session) => Some(session), - None => coordinator.restore_session(session_id).await.ok(), - }; - - let Some(session) = session else { - return RemoteResponse::Error { - message: format!("Session not found: {}", session_id), - }; - }; - - let running_turn_id = match &session.state { - crate::agentic::core::SessionState::Processing { current_turn_id, .. } => { - Some(current_turn_id.clone()) - } - _ => None, - }; - - match (running_turn_id, turn_id.as_ref()) { - (Some(current_turn_id), Some(requested_turn_id)) - if requested_turn_id != ¤t_turn_id => - { - RemoteResponse::Error { - message: "This task is no longer running.".to_string(), - } - } - (Some(current_turn_id), _) => match coordinator - .cancel_dialog_turn(session_id, ¤t_turn_id) - .await - { - Ok(_) => RemoteResponse::TaskCancelled { - session_id: session_id.clone(), - }, - Err(e) => RemoteResponse::Error { - message: e.to_string(), - }, - }, - (None, Some(_)) => RemoteResponse::Error { - message: "This task is already finished.".to_string(), - }, - (None, None) => RemoteResponse::Error { - message: format!("No running task to cancel for session: {}", session_id), + match dispatcher + .cancel_task(session_id, turn_id.as_deref()) + .await + { + Ok(()) => RemoteResponse::TaskCancelled { + session_id: session_id.clone(), }, + Err(e) => RemoteResponse::Error { message: e }, } } RemoteCommand::ConfirmTool { tool_id, updated_input, - } => match coordinator.confirm_tool(tool_id, updated_input.clone()).await { - Ok(_) => RemoteResponse::InteractionAccepted { - action: "confirm_tool".to_string(), - target_id: tool_id.clone(), - }, - Err(e) => RemoteResponse::Error { - message: e.to_string(), - }, - }, + } => { + let coordinator = match get_global_coordinator() { + Some(c) => c, + None => { + return RemoteResponse::Error { + message: "Desktop session system not ready".into(), + }; + } + }; + match coordinator.confirm_tool(tool_id, updated_input.clone()).await { + Ok(_) => RemoteResponse::InteractionAccepted { + action: "confirm_tool".to_string(), + target_id: tool_id.clone(), + }, + Err(e) => RemoteResponse::Error { + message: e.to_string(), + }, + } + } RemoteCommand::RejectTool { tool_id, reason } => { + let coordinator = match get_global_coordinator() { + Some(c) => c, + None => { + return RemoteResponse::Error { + message: "Desktop session system not ready".into(), + }; + } + }; let reject_reason = reason .clone() .unwrap_or_else(|| "User rejected".to_string()); @@ -1633,6 +1745,14 @@ impl RemoteServer { } } RemoteCommand::CancelTool { tool_id, reason } => { + let coordinator = match get_global_coordinator() { + Some(c) => c, + None => { + return RemoteResponse::Error { + message: "Desktop session system not ready".into(), + }; + } + }; let cancel_reason = reason .clone() .unwrap_or_else(|| "User cancelled".to_string()); From eaf03ee3e87957a6bd6d46edf3a8159e4bc4286c Mon Sep 17 00:00:00 2001 From: bowen628 Date: Fri, 6 Mar 2026 21:11:22 +0800 Subject: [PATCH 3/4] feat: Update bot and relay server --- docs/remote-connect/feishu-bot-setup.md | 67 +++++++++++++ docs/remote-connect/feishu-bot-setup.zh-CN.md | 57 +++++++++++ scripts/dev.cjs | 27 ++++++ src/apps/relay-server/Dockerfile | 2 +- src/apps/relay-server/README.md | 96 ++++++++++++------- src/apps/relay-server/deploy.sh | 23 ++++- .../RemoteConnectDialog.scss | 16 +++- .../RemoteConnectDialog.tsx | 23 ++++- src/web-ui/src/locales/en-US/common.json | 3 + src/web-ui/src/locales/zh-CN/common.json | 3 + 10 files changed, 277 insertions(+), 40 deletions(-) create mode 100644 docs/remote-connect/feishu-bot-setup.md create mode 100644 docs/remote-connect/feishu-bot-setup.zh-CN.md diff --git a/docs/remote-connect/feishu-bot-setup.md b/docs/remote-connect/feishu-bot-setup.md new file mode 100644 index 00000000..06faa481 --- /dev/null +++ b/docs/remote-connect/feishu-bot-setup.md @@ -0,0 +1,67 @@ +# Feishu Bot Setup Guide + +[中文](./feishu-bot-setup.zh-CN.md) + +Use this guide to pair BitFun through a Feishu bot. + +## Setup Steps + +### Step1 + +Open the Feishu Developer Platform and log in + + + +### Step2 + +Create custom app + +### Step3 + +Add Features - Bot - Add + +### Step4 + +Permissions & Scopes - + +Add permission scopes to app - + +Search "im:" - Approval required "No" - Select all - Add Scopes + +### Step5 + +Credentials & Basic Info - Copy App ID and App Secret + +### Step6 + +Open BitFun - Remote Connect - SMS Bot - Feishu Bot - Fill in App ID and App Secret - Connect + +### Step7 + +Back to Feishu Developer Platform + +### Step8 + +Events & callbacks - Event configuration - + +Subscription mode - persistent connection - Save + +Add Events - Search "im.message" - Select all - Confirm + +### Step9 + +Events & callbacks - Callback configuration - + +Subscription mode - persistent connection - Save + +Add callback - Search "card.action.trigger" - Select all - Confirm + +### Step10 + +Open Feishu - Search "{robot name}" - + +Click the robot to open the chat box - Input any message and send + +### Step11 + +Enter the 6-digit pairing code from BitFun Desktop - Send - Connection successful diff --git a/docs/remote-connect/feishu-bot-setup.zh-CN.md b/docs/remote-connect/feishu-bot-setup.zh-CN.md new file mode 100644 index 00000000..48b6e1ac --- /dev/null +++ b/docs/remote-connect/feishu-bot-setup.zh-CN.md @@ -0,0 +1,57 @@ +# 飞书机器人配置指南 + +[English](./feishu-bot-setup.md) + +适用于 BitFun 通过飞书机器人完成远程连接配对。 + +## 配置步骤 + +### 第一步 + +打开飞书开发者平台并登录 + + + +### 第二步 + +创建企业自建应用 + +### 第三步 + +添加应用能力 - 机器人 - 添加 + +### 第四步 + +权限管理 - 开通权限 - 搜索"im:" - 是否需要审核选择"免审权限" - 全选 - 确认开通权限 + +### 第五步 + +凭证与基础信息 - 复制 App ID 和 App Secret + +### 第六步 + +打开 BitFun - 远程连接 - SMS 机器人 - Feishu 机器人 - 填写 App ID 和 App Secret - 连接 + +### 第七步 + +回到飞书开发者平台机器人设置页 + +### 第八步 + +事件与回调 - 事件配置 - 订阅方式 - 使用 长连接 接收事件 - 保存 + +添加事件 - 搜索"im.message" - 全选 - 确认添加 + +### 第九步 + +事件与回调 - 回调配置 - 订阅方式 - 使用 长连接 接收事件 - 保存 + +添加回调 - 搜索"card.action.trigger" - 选中 - 确认添加 + +### 第十步 + +打开飞书应用 - 搜索"{机器人名称}" - 点击机器人打开对话框 - 输入任意消息并发送 + +### 第十一步 + +被机器人要求输入6位验证码 - 输入 - 发送 - 连接成功 diff --git a/scripts/dev.cjs b/scripts/dev.cjs index 638d7aa5..130d239d 100644 --- a/scripts/dev.cjs +++ b/scripts/dev.cjs @@ -105,6 +105,32 @@ function runCommand(command, cwd = ROOT_DIR) { }); } +/** + * Clean stale mobile-web resource copies in Tauri target directories. + * + * Tauri copies resources from src/mobile-web/dist/ into target/{profile}/mobile-web/dist/ + * on each dev/build run, but never removes old files. Since Vite generates content-hashed + * filenames, previous builds leave behind orphaned assets that accumulate over time. + * This causes the relay upload to send hundreds of stale files instead of just a few. + */ +function cleanStaleMobileWebResources() { + const fs = require('fs'); + const targetDir = path.join(ROOT_DIR, 'target'); + if (!fs.existsSync(targetDir)) return; + + let cleaned = 0; + for (const profile of fs.readdirSync(targetDir)) { + const mobileWebDir = path.join(targetDir, profile, 'mobile-web'); + if (fs.existsSync(mobileWebDir) && fs.statSync(mobileWebDir).isDirectory()) { + fs.rmSync(mobileWebDir, { recursive: true, force: true }); + cleaned++; + } + } + if (cleaned > 0) { + printInfo(`Cleaned stale mobile-web resources from ${cleaned} target profile(s)`); + } +} + /** * Main entry */ @@ -173,6 +199,7 @@ async function main() { } process.exit(1); } + cleanStaleMobileWebResources(); printSuccess('mobile-web build complete'); } diff --git a/src/apps/relay-server/Dockerfile b/src/apps/relay-server/Dockerfile index c18ce086..b10b2908 100644 --- a/src/apps/relay-server/Dockerfile +++ b/src/apps/relay-server/Dockerfile @@ -51,7 +51,7 @@ RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/ WORKDIR /app COPY --from=builder /build/target/release/bitfun-relay-server /app/bitfun-relay-server -COPY src/mobile-web/dist /app/static +COPY src/apps/relay-server/static /app/static ENV RELAY_PORT=9700 ENV RELAY_STATIC_DIR=/app/static diff --git a/src/apps/relay-server/README.md b/src/apps/relay-server/README.md index 541b9ba0..73750750 100644 --- a/src/apps/relay-server/README.md +++ b/src/apps/relay-server/README.md @@ -1,14 +1,15 @@ # BitFun Relay Server -WebSocket relay server for BitFun Remote Connect. Provides room-based message relaying between desktop and mobile clients with E2E encryption support. +WebSocket relay server for BitFun Remote Connect. Bridges desktop (WebSocket) and mobile (HTTP) clients with E2E encryption support. ## Features -- Room-based WebSocket relay +- Desktop connects via WebSocket, mobile via HTTP — relay bridges between them - End-to-end encrypted message passthrough (server cannot decrypt) -- Heartbeat-based connection management -- Static file serving for mobile web client -- Docker deployment ready +- Correlation-based HTTP-to-WebSocket request-response matching +- Per-room mobile-web static file upload & serving (content-addressable, incremental) +- Heartbeat-based connection management with configurable room TTL +- Docker deployment ready with Caddy reverse proxy ## Quick Start @@ -18,7 +19,7 @@ WebSocket relay server for BitFun Remote Connect. Provides room-based message re # One-click deploy bash deploy.sh -# With mobile web client +# With mobile web client rebuild bash deploy.sh --build-mobile ``` @@ -47,49 +48,75 @@ RELAY_PORT=9700 ./target/release/bitfun-relay-server | Variable | Default | Description | |----------|---------|-------------| | `RELAY_PORT` | `9700` | Server listen port | -| `RELAY_STATIC_DIR` | `./static` | Path to mobile web static files | +| `RELAY_STATIC_DIR` | `./static` | Path to mobile web static files (fallback SPA) | +| `RELAY_ROOM_WEB_DIR` | `/tmp/bitfun-room-web` | Directory for per-room uploaded mobile-web files | | `RELAY_ROOM_TTL` | `3600` | Room TTL in seconds (0 = no expiry) | ## API Endpoints +### Health & Info + | Endpoint | Method | Description | |----------|--------|-------------| -| `/health` | GET | Health check | -| `/api/info` | GET | Server info | -| `/ws` | WebSocket | Main relay endpoint | +| `/health` | GET | Health check (returns status, version, uptime, room/connection counts) | +| `/api/info` | GET | Server info (name, version, protocol_version) | -## WebSocket Protocol +### Room Operations (Mobile HTTP → Desktop WS bridge) -### Client → Server +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/rooms/:room_id/pair` | POST | Mobile initiates pairing — relay forwards to desktop via WS, waits for response | +| `/api/rooms/:room_id/command` | POST | Mobile sends encrypted command — relay forwards to desktop, returns response | -```json -// Create a room (desktop) -{ "type": "create_room", "room_id": "...", "device_id": "...", "device_type": "desktop", "public_key": "base64..." } +### Per-Room Mobile-Web File Management -// Join a room (mobile) -{ "type": "join_room", "room_id": "...", "device_id": "...", "device_type": "mobile", "public_key": "base64..." } +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/rooms/:room_id/upload-web` | POST | Full upload: base64-encoded files keyed by path (10MB body limit) | +| `/api/rooms/:room_id/check-web-files` | POST | Incremental: check which files the server already has by hash | +| `/api/rooms/:room_id/upload-web-files` | POST | Incremental: upload only the missing files (10MB body limit) | +| `/r/:room_id/*path` | GET | Serve uploaded mobile-web static files for a room | + +### WebSocket -// Relay an encrypted message -{ "type": "relay", "room_id": "...", "encrypted_data": "base64...", "nonce": "base64..." } +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/ws` | WebSocket | Desktop client connection endpoint | + +## WebSocket Protocol (Desktop Only) + +Only desktop clients connect via WebSocket. Mobile clients use the HTTP endpoints above. + +### Desktop → Server (Inbound) + +```json +// Create a room +{ "type": "create_room", "room_id": "optional-id", "device_id": "...", "device_type": "desktop", "public_key": "base64..." } + +// Respond to a bridged HTTP request (pair or command) +{ "type": "relay_response", "correlation_id": "...", "encrypted_data": "base64...", "nonce": "base64..." } // Heartbeat { "type": "heartbeat" } ``` -### Server → Client +### Server → Desktop (Outbound) ```json -// Peer joined notification -{ "type": "peer_joined", "device_id": "...", "device_type": "...", "public_key": "base64..." } +// Room created confirmation +{ "type": "room_created", "room_id": "..." } -// Relayed message -{ "type": "relay", "from_device_id": "...", "encrypted_data": "base64...", "nonce": "base64..." } +// Pair request forwarded from mobile HTTP +{ "type": "pair_request", "correlation_id": "...", "public_key": "base64...", "device_id": "...", "device_name": "..." } -// Peer disconnected -{ "type": "peer_disconnected", "device_id": "..." } +// Encrypted command forwarded from mobile HTTP +{ "type": "command", "correlation_id": "...", "encrypted_data": "base64...", "nonce": "base64..." } // Heartbeat acknowledgment { "type": "heartbeat_ack" } + +// Error +{ "type": "error", "message": "..." } ``` ## Self-Hosted Deployment @@ -120,11 +147,16 @@ RELAY_PORT=9700 ./target/release/bitfun-relay-server ## Architecture ``` -Mobile Phone ──WSS──► Relay Server ◄──WSS── Desktop Client - │ - E2E Encrypted - (server cannot - read messages) +Mobile Phone ──HTTP POST──► Relay Server ◄──WebSocket── Desktop Client + │ + E2E Encrypted + (server cannot + read messages) ``` -The relay server only manages rooms and forwards opaque encrypted payloads. All encryption/decryption happens on the client side. +The relay server bridges HTTP and WebSocket: + +- **Desktop** connects via WebSocket, creates a room, and stays connected. +- **Mobile** sends HTTP POST requests (`/pair`, `/command`). The relay forwards them to the desktop over WS using correlation IDs, waits for the WS response, and returns it to mobile via HTTP. +- The relay only manages rooms and forwards opaque encrypted payloads. All encryption/decryption happens on the client side. +- Per-room mobile-web static files can be uploaded via the incremental upload API and served at `/r/:room_id/`. diff --git a/src/apps/relay-server/deploy.sh b/src/apps/relay-server/deploy.sh index 61c806c1..8786b934 100755 --- a/src/apps/relay-server/deploy.sh +++ b/src/apps/relay-server/deploy.sh @@ -93,13 +93,26 @@ echo "[3/3] Starting services..." docker compose up -d if [ "$SKIP_HEALTH_CHECK" = false ]; then + echo "Waiting for services to start..." + sleep 5 echo "Checking relay health endpoint..." if command -v curl >/dev/null 2>&1; then - if curl -fsS --max-time 8 "http://127.0.0.1:9700/health" >/dev/null; then - echo "Health check passed: http://127.0.0.1:9700/health" - else - echo "Warning: health check failed. Check logs below." - fi + MAX_RETRIES=6 + RETRY=0 + while [ $RETRY -lt $MAX_RETRIES ]; do + if curl -fsS --max-time 5 "http://127.0.0.1:9700/health" >/dev/null 2>&1; then + echo "Health check passed: http://127.0.0.1:9700/health" + break + fi + RETRY=$((RETRY + 1)) + if [ $RETRY -lt $MAX_RETRIES ]; then + echo " Retry $RETRY/$MAX_RETRIES in 3s..." + sleep 3 + else + echo "Warning: health check failed after $MAX_RETRIES attempts. Check logs:" + docker compose logs --tail=30 relay-server + fi + done else echo "Warning: 'curl' not found, skipped health check." fi diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss index dda75013..a795159d 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss @@ -349,16 +349,29 @@ .bitfun-remote-connect__bot-guide { width: 100%; - max-width: 300px; + max-width: 380px; display: flex; flex-direction: column; + align-items: center; gap: 12px; + + .bitfun-remote-connect__input-group { + display: flex; + flex-direction: column; + align-items: center; + + label { + text-align: center; + } + } } .bitfun-remote-connect__steps { display: flex; flex-direction: column; + align-items: center; gap: 4px; + width: 100%; } .bitfun-remote-connect__step { @@ -366,6 +379,7 @@ color: var(--color-text-muted); line-height: 1.6; margin: 0; + text-align: center; } .bitfun-remote-connect__step-link { diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx index 5b7dad55..67d8a472 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx @@ -41,6 +41,10 @@ const BOT_TABS: { id: BotTab; label: string }[] = [ const NGROK_SETUP_URL = 'https://dashboard.ngrok.com/get-started/setup'; const RELAY_SERVER_README_URL = 'https://github.com/GCWing/BitFun/blob/main/src/apps/relay-server/README.md'; +const FEISHU_SETUP_GUIDE_URLS = { + 'zh-CN': 'https://github.com/GCWing/BitFun/blob/main/docs/remote-connect/feishu-bot-setup.zh-CN.md', + 'en-US': 'https://github.com/GCWing/BitFun/blob/main/docs/remote-connect/feishu-bot-setup.md', +} as const; const methodToNetworkTab = (method: string | null | undefined): NetworkTab | null => { if (!method) return null; @@ -68,7 +72,7 @@ export const RemoteConnectDialog: React.FC = ({ isOpen, onClose, }) => { - const { t } = useI18n('common'); + const { t, currentLanguage } = useI18n('common'); const [activeGroup, setActiveGroup] = useState('network'); const [networkTab, setNetworkTab] = useState(NETWORK_TABS[0].id); @@ -263,6 +267,10 @@ export const RemoteConnectDialog: React.FC = ({ void systemAPI.openExternal(RELAY_SERVER_README_URL); }, []); + const handleOpenFeishuGuide = useCallback(() => { + void systemAPI.openExternal(FEISHU_SETUP_GUIDE_URLS[currentLanguage]); + }, [currentLanguage]); + // ── Sub-tab disabled logic ─────────────────────────────────────── const isNetworkSubDisabled = (tabId: NetworkTab): boolean => { @@ -446,6 +454,19 @@ export const RemoteConnectDialog: React.FC = ({
) : (
+

+ {t('remoteConnect.botFeishuDocPrefix')} + { if (e.key === 'Enter') handleOpenFeishuGuide(); }} + > + {t('remoteConnect.botFeishuDocLink')} + + {t('remoteConnect.botFeishuDocSuffix')} +

1. {t('remoteConnect.botFeishuStep1Prefix')} diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index ccf01c7a..c79e0d51 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -195,6 +195,9 @@ "botTgStep1": "Open Telegram and search for @BotFather", "botTgStep2": "Send /newbot and follow the prompts to create a bot", "botTgStep3": "Copy the Bot Token and paste it below", + "botFeishuDocPrefix": "View the ", + "botFeishuDocLink": "full Feishu bot setup guide", + "botFeishuDocSuffix": "", "botFeishuStep1Prefix": "Go to ", "botFeishuOpenPlatform": "Feishu Open Platform", "botFeishuStep1Suffix": " and create a Custom App", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index a1e23c1c..9156f24b 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -195,6 +195,9 @@ "botTgStep1": "打开Telegram,搜索 @BotFather", "botTgStep2": "发送 /newbot,按提示创建机器人", "botTgStep3": "复制Bot Token,粘贴到下方", + "botFeishuDocPrefix": "查看", + "botFeishuDocLink": "飞书机器人完整配置指南", + "botFeishuDocSuffix": "", "botFeishuStep1Prefix": "前往", "botFeishuOpenPlatform": "飞书开放平台", "botFeishuStep1Suffix": ",创建企业自建应用", From 3856dc52b3b5a5a1a5ac0effa986a68885d4d116 Mon Sep 17 00:00:00 2001 From: bowen628 Date: Fri, 6 Mar 2026 21:29:20 +0800 Subject: [PATCH 4/4] docs: add bot publish step to Feishu bot setup guide Made-with: Cursor --- docs/remote-connect/feishu-bot-setup.md | 6 +++++- docs/remote-connect/feishu-bot-setup.zh-CN.md | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/remote-connect/feishu-bot-setup.md b/docs/remote-connect/feishu-bot-setup.md index 06faa481..2a39ee96 100644 --- a/docs/remote-connect/feishu-bot-setup.md +++ b/docs/remote-connect/feishu-bot-setup.md @@ -58,10 +58,14 @@ Add callback - Search "card.action.trigger" - Select all - Confirm ### Step10 +Publish the bot + +### Step11 + Open Feishu - Search "{robot name}" - Click the robot to open the chat box - Input any message and send -### Step11 +### Step12 Enter the 6-digit pairing code from BitFun Desktop - Send - Connection successful diff --git a/docs/remote-connect/feishu-bot-setup.zh-CN.md b/docs/remote-connect/feishu-bot-setup.zh-CN.md index 48b6e1ac..12f24a2b 100644 --- a/docs/remote-connect/feishu-bot-setup.zh-CN.md +++ b/docs/remote-connect/feishu-bot-setup.zh-CN.md @@ -50,8 +50,12 @@ ### 第十步 -打开飞书应用 - 搜索"{机器人名称}" - 点击机器人打开对话框 - 输入任意消息并发送 +发布机器人 ### 第十一步 +打开飞书应用 - 搜索"{机器人名称}" - 点击机器人打开对话框 - 输入任意消息并发送 + +### 第十二步 + 被机器人要求输入6位验证码 - 输入 - 发送 - 连接成功