diff --git a/src/apps/desktop/src/api/config_api.rs b/src/apps/desktop/src/api/config_api.rs index db247b2b..b28ab169 100644 --- a/src/apps/desktop/src/api/config_api.rs +++ b/src/apps/desktop/src/api/config_api.rs @@ -60,6 +60,18 @@ pub async fn set_config( .await { Ok(_) => { + if let Err(e) = bitfun_core::service::config::reload_global_config().await { + warn!( + "Failed to sync global config after set_config: path={}, error={}", + request.path, e + ); + } else { + info!( + "Global config synced after set_config: path={}", + request.path + ); + } + if request.path.starts_with("ai.models") || request.path.starts_with("ai.default_models") || request.path.starts_with("ai.agent_models") @@ -90,6 +102,18 @@ pub async fn reset_config( match config_service.reset_config(request.path.as_deref()).await { Ok(_) => { + if let Err(e) = bitfun_core::service::config::reload_global_config().await { + warn!( + "Failed to sync global config after reset_config: path={:?}, error={}", + request.path, e + ); + } else { + info!( + "Global config synced after reset_config: path={:?}", + request.path + ); + } + let message = if let Some(path) = &request.path { format!("Configuration '{}' reset successfully", path) } else { @@ -142,6 +166,11 @@ pub async fn import_config(state: State<'_, AppState>, config: Value) -> Result< match config_service.import_config(export_data).await { Ok(result) => { + if let Err(e) = bitfun_core::service::config::reload_global_config().await { + warn!("Failed to sync global config after import_config: {}", e); + } else { + info!("Global config synced after import_config"); + } state.ai_client_factory.invalidate_cache(); info!("Config imported, AI client cache invalidated"); Ok(to_json_value(result, "import config result")?) diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index 5758cd99..e9bc8d38 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -136,9 +136,13 @@ impl ExecutionEngine { ai_config: &crate::service::config::types::AIConfig, model_id: &str, ) -> String { + let trimmed = model_id.trim(); + if trimmed.is_empty() || trimmed == "auto" || trimmed == "default" { + return "auto".to_string(); + } ai_config - .resolve_model_selection(model_id) - .unwrap_or_else(|| model_id.to_string()) + .resolve_model_selection(trimmed) + .unwrap_or_else(|| "auto".to_string()) } fn resolve_locked_auto_model_id( @@ -167,23 +171,32 @@ impl ExecutionEngine { turn_index: usize, ) -> BitFunResult { let agent_registry = get_agent_registry(); - let configured_model_id = agent_registry + let fallback_model_id = agent_registry .get_model_id_for_agent(agent_type, workspace.map(|binding| binding.root_path())) .await .map_err(|e| BitFunError::AIClient(format!("Failed to get model ID: {}", e)))?; - - let model_id = if configured_model_id == "auto" { - let config_service = get_global_config_service().await.map_err(|e| { - BitFunError::AIClient(format!( - "Failed to get config service for auto model resolution: {}", - e - )) - })?; - let ai_config: crate::service::config::types::AIConfig = config_service - .get_config(Some("ai")) - .await - .unwrap_or_default(); - + let config_service = get_global_config_service().await.map_err(|e| { + BitFunError::AIClient(format!( + "Failed to get config service for model resolution: {}", + e + )) + })?; + let ai_config: crate::service::config::types::AIConfig = config_service + .get_config(Some("ai")) + .await + .unwrap_or_default(); + let configured_model_id = session + .config + .model_id + .as_ref() + .map(|model_id| model_id.trim()) + .filter(|model_id| !model_id.is_empty()) + .map(str::to_string) + .unwrap_or(fallback_model_id); + let resolved_configured_model_id = + Self::resolve_configured_model_id(&ai_config, &configured_model_id); + + let model_id = if configured_model_id == "auto" || resolved_configured_model_id == "auto" { let locked_model_id = Self::resolve_locked_auto_model_id(&ai_config, session.config.model_id.as_ref()); let raw_locked_model_id = session.config.model_id.clone(); @@ -230,7 +243,7 @@ impl ExecutionEngine { } } } else { - configured_model_id + resolved_configured_model_id }; Ok(model_id) 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 4fe6ef43..dbb05473 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -101,6 +101,131 @@ async fn resolve_file_workspace_root(session_id: Option<&str>) -> Option Option { + use crate::agentic::coordination::get_global_coordinator; + + let coordinator = get_global_coordinator()?; + let session_manager = coordinator.get_session_manager(); + + let normalize = |model_id: Option| match model_id { + Some(value) => { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed == "default" { + Some("auto".to_string()) + } else { + Some(trimmed.to_string()) + } + } + None => Some("auto".to_string()), + }; + + if let Some(session) = session_manager.get_session(session_id) { + return normalize(session.config.model_id.clone()); + } + + let workspace_path = resolve_session_workspace_path(session_id).await?; + coordinator + .restore_session(&workspace_path, session_id) + .await + .ok() + .and_then(|session| normalize(session.config.model_id.clone())) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteModelConfig { + pub id: String, + pub name: String, + pub provider: String, + pub base_url: String, + pub model_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_window: Option, + pub enabled: bool, + pub capabilities: Vec, + pub enable_thinking_process: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteModelCatalog { + pub version: u64, + pub models: Vec, + pub default_models: crate::service::config::types::DefaultModelsConfig, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_model_id: Option, +} + +async fn load_remote_model_catalog( + session_id: Option<&str>, +) -> std::result::Result { + use crate::service::config::{ + get_global_config_service, + types::{AIConfig, GlobalConfig}, + }; + + let config_service = get_global_config_service() + .await + .map_err(|e| format!("Config service not available: {e}"))?; + let global_config: GlobalConfig = config_service + .get_config(None) + .await + .map_err(|e| format!("Failed to load global config: {e}"))?; + let ai_config: AIConfig = global_config.ai; + + let models: Vec = ai_config + .models + .into_iter() + .map(|model| RemoteModelConfig { + id: model.id, + name: model.name, + provider: model.provider, + base_url: model.base_url, + model_name: model.model_name, + context_window: model.context_window, + enabled: model.enabled, + capabilities: model + .capabilities + .into_iter() + .map(|capability| match capability { + crate::service::config::types::ModelCapability::TextChat => "text_chat", + crate::service::config::types::ModelCapability::ImageUnderstanding => { + "image_understanding" + } + crate::service::config::types::ModelCapability::ImageGeneration => { + "image_generation" + } + crate::service::config::types::ModelCapability::Embedding => "embedding", + crate::service::config::types::ModelCapability::Search => "search", + crate::service::config::types::ModelCapability::CodeSpecialized => { + "code_specialized" + } + crate::service::config::types::ModelCapability::FunctionCalling => { + "function_calling" + } + crate::service::config::types::ModelCapability::SpeechRecognition => { + "speech_recognition" + } + }.to_string()) + .collect(), + enable_thinking_process: model.enable_thinking_process, + reasoning_effort: model.reasoning_effort, + }) + .collect(); + + let session_model_id = if let Some(session_id) = session_id { + resolve_session_model_id(session_id).await + } else { + None + }; + Ok(RemoteModelCatalog { + version: global_config.last_modified.timestamp_millis().max(0) as u64, + models, + default_models: ai_config.default_models, + session_model_id, + }) +} + /// Image sent from mobile as a base64 data-URL. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageAttachment { @@ -127,6 +252,13 @@ pub enum RemoteCommand { session_name: Option, workspace_path: Option, }, + GetModelCatalog { + session_id: Option, + }, + SetSessionModel { + session_id: String, + model_id: String, + }, GetSessionMessages { session_id: String, limit: Option, @@ -168,6 +300,7 @@ pub enum RemoteCommand { session_id: String, since_version: u64, known_msg_count: usize, + known_model_catalog_version: Option, }, /// Read a workspace file and return its base64-encoded content. /// @@ -224,6 +357,13 @@ pub enum RemoteResponse { SessionCreated { session_id: String, }, + ModelCatalog { + catalog: RemoteModelCatalog, + }, + SessionModelUpdated { + session_id: String, + model_id: String, + }, Messages { session_id: String, messages: Vec, @@ -267,6 +407,8 @@ pub enum RemoteResponse { total_msg_count: Option, #[serde(skip_serializing_if = "Option::is_none")] active_turn: Option, + #[serde(skip_serializing_if = "Option::is_none")] + model_catalog: Option, }, AnswerAccepted, InteractionAccepted { @@ -1656,6 +1798,8 @@ impl RemoteServer { RemoteCommand::ListSessions { .. } | RemoteCommand::CreateSession { .. } + | RemoteCommand::GetModelCatalog { .. } + | RemoteCommand::SetSessionModel { .. } | RemoteCommand::GetSessionMessages { .. } | RemoteCommand::DeleteSession { .. } => self.handle_session_command(cmd).await, @@ -1766,6 +1910,7 @@ impl RemoteServer { session_id, since_version, known_msg_count, + known_model_catalog_version, } = cmd else { return RemoteResponse::Error { @@ -1775,8 +1920,16 @@ impl RemoteServer { let tracker = self.ensure_tracker(session_id); let current_version = tracker.version(); + let current_model_catalog = load_remote_model_catalog(Some(session_id)).await.ok(); + let current_model_catalog_version = current_model_catalog + .as_ref() + .map(|catalog| catalog.version) + .unwrap_or(0); + let requested_model_catalog_version = known_model_catalog_version.unwrap_or(0); + let should_send_model_catalog = + requested_model_catalog_version != current_model_catalog_version; - if *since_version == current_version && *since_version > 0 { + if *since_version == current_version && *since_version > 0 && !should_send_model_catalog { return RemoteResponse::SessionPoll { version: current_version, changed: false, @@ -1785,6 +1938,7 @@ impl RemoteServer { new_messages: None, total_msg_count: None, active_turn: None, + model_catalog: None, }; } @@ -1805,6 +1959,11 @@ impl RemoteServer { new_messages: None, total_msg_count: None, active_turn, + model_catalog: if should_send_model_catalog { + current_model_catalog + } else { + None + }, }; } @@ -1862,6 +2021,11 @@ impl RemoteServer { new_messages: send_msgs, total_msg_count: send_total, active_turn, + model_catalog: if should_send_model_catalog { + current_model_catalog + } else { + None + }, } } @@ -2250,6 +2414,85 @@ impl RemoteServer { }, } } + RemoteCommand::GetModelCatalog { session_id } => { + match load_remote_model_catalog(session_id.as_deref()).await { + Ok(catalog) => RemoteResponse::ModelCatalog { catalog }, + Err(message) => RemoteResponse::Error { message }, + } + } + RemoteCommand::SetSessionModel { + session_id, + model_id, + } => { + use crate::service::config::{get_global_config_service, types::AIConfig}; + + let requested_model_id = model_id.trim(); + if requested_model_id.is_empty() { + return RemoteResponse::Error { + message: "model_id is required".to_string(), + }; + } + + let normalized_model_id = if matches!(requested_model_id, "auto" | "default" | "primary" | "fast") { + if requested_model_id == "default" { + "auto".to_string() + } else { + requested_model_id.to_string() + } + } else { + let Ok(config_service) = get_global_config_service().await else { + return RemoteResponse::Error { + message: "Config service not available".to_string(), + }; + }; + let ai_config: AIConfig = match config_service.get_config(Some("ai")).await { + Ok(config) => config, + Err(e) => { + return RemoteResponse::Error { + message: format!("Failed to load AI config: {e}"), + } + } + }; + match ai_config.resolve_model_reference(requested_model_id) { + Some(resolved) => resolved, + None => { + return RemoteResponse::Error { + message: format!("Unknown model selection: {requested_model_id}"), + } + } + } + }; + + if coordinator.get_session_manager().get_session(session_id).is_none() { + let Some(workspace_path) = resolve_session_workspace_path(session_id).await else { + return RemoteResponse::Error { + message: format!( + "Workspace path not available for session: {}", + session_id + ), + }; + }; + if let Err(e) = coordinator.restore_session(&workspace_path, session_id).await { + return RemoteResponse::Error { + message: format!("Failed to restore session: {e}"), + }; + } + } + + match coordinator + .get_session_manager() + .update_session_model_id(session_id, &normalized_model_id) + .await + { + Ok(()) => RemoteResponse::SessionModelUpdated { + session_id: session_id.clone(), + model_id: normalized_model_id, + }, + Err(e) => RemoteResponse::Error { + message: e.to_string(), + }, + } + } RemoteCommand::GetSessionMessages { session_id, limit: _, diff --git a/src/mobile-web/index.html b/src/mobile-web/index.html index 85ac8ac0..3dfd18e7 100644 --- a/src/mobile-web/index.html +++ b/src/mobile-web/index.html @@ -5,6 +5,8 @@ + + BitFun Remote