diff --git a/src/apps/cli/src/agent/core_adapter.rs b/src/apps/cli/src/agent/core_adapter.rs index 6951cbc5..2d89592a 100644 --- a/src/apps/cli/src/agent/core_adapter.rs +++ b/src/apps/cli/src/agent/core_adapter.rs @@ -1,10 +1,10 @@ //! Core Agent adapter -//! +//! //! Adapts bitfun-core's Agentic system to CLI's Agent interface use anyhow::Result; -use std::sync::Arc; use std::path::PathBuf; +use std::sync::Arc; use tokio::sync::mpsc; use super::{Agent, AgentEvent, AgentResponse}; @@ -26,7 +26,7 @@ pub struct CoreAgentAdapter { impl CoreAgentAdapter { pub fn new( - agent_type: String, + agent_type: String, coordinator: Arc, event_queue: Arc, workspace_path: Option, @@ -35,7 +35,7 @@ impl CoreAgentAdapter { "agentic" => "Fang", _ => "AI Assistant", }; - + Self { name: name.to_string(), agent_type: agent_type.clone(), @@ -45,7 +45,7 @@ impl CoreAgentAdapter { session_id: None, } } - + async fn ensure_session(&mut self) -> Result { if let Some(session_id) = &self.session_id { return Ok(session_id.clone()); @@ -56,19 +56,25 @@ impl CoreAgentAdapter { .clone() .or_else(|| std::env::current_dir().ok()) .map(|path| path.to_string_lossy().to_string()); - - let session = self.coordinator.create_session( - format!("CLI Session - {}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S")), - self.agent_type.clone(), - SessionConfig { - workspace_path, - ..Default::default() - }, - ).await?; - + + let session = self + .coordinator + .create_session( + format!( + "CLI Session - {}", + chrono::Local::now().format("%Y-%m-%d %H:%M:%S") + ), + self.agent_type.clone(), + SessionConfig { + workspace_path, + ..Default::default() + }, + ) + .await?; + self.session_id = Some(session.session_id.clone()); tracing::info!("Created session: {}", session.session_id); - + Ok(session.session_id) } } @@ -88,54 +94,59 @@ impl Agent for CoreAgentAdapter { workspace_path: self.workspace_path.clone(), session_id: self.session_id.clone(), }; - + let session_id = self_mut.ensure_session().await?; tracing::info!("Processing message: {}", message); - + let _ = event_tx.send(AgentEvent::Thinking); - - self.coordinator.start_dialog_turn( - session_id.clone(), - message.clone(), - None, - self.agent_type.clone(), - None, - DialogTriggerSource::Cli, - ).await?; - + + self.coordinator + .start_dialog_turn( + session_id.clone(), + message.clone(), + None, + None, + self.agent_type.clone(), + None, + DialogTriggerSource::Cli, + ) + .await?; + let mut accumulated_text = String::new(); - let mut tool_map: std::collections::HashMap = std::collections::HashMap::new(); - + let mut tool_map: std::collections::HashMap = + std::collections::HashMap::new(); + let event_queue = self.event_queue.clone(); let session_id_clone = session_id.clone(); - + loop { let events = event_queue.dequeue_batch(10).await; - + if events.is_empty() { tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; continue; } - + for envelope in events { let event = envelope.event; - + if event.session_id() != Some(&session_id_clone) { continue; } - + tracing::debug!("Received event: {:?}", event); - + match event { CoreEvent::TextChunk { text, .. } => { accumulated_text.push_str(&text); let _ = event_tx.send(AgentEvent::TextChunk(text)); } - - CoreEvent::ToolEvent { tool_event, .. } => { - match tool_event { - ToolEventData::EarlyDetected { tool_id, tool_name } => { - tool_map.insert(tool_id.clone(), ToolCall { + + CoreEvent::ToolEvent { tool_event, .. } => match tool_event { + ToolEventData::EarlyDetected { tool_id, tool_name } => { + tool_map.insert( + tool_id.clone(), + ToolCall { tool_id: Some(tool_id), tool_name: tool_name.clone(), parameters: serde_json::Value::Null, @@ -144,162 +155,212 @@ impl Agent for CoreAgentAdapter { progress: None, progress_message: None, duration_ms: None, - }); - } - - ToolEventData::ParamsPartial { tool_id, tool_name: _, params } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::ParamsPartial; - tool.progress_message = Some(params); - } - } - - ToolEventData::Queued { tool_id, tool_name: _, position } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Queued; - tool.progress_message = Some(format!("Queue position: {}", position)); - } + }, + ); + } + + ToolEventData::ParamsPartial { + tool_id, + tool_name: _, + params, + } => { + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.status = ToolCallStatus::ParamsPartial; + tool.progress_message = Some(params); } - - ToolEventData::Waiting { tool_id, tool_name: _, dependencies } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Waiting; - tool.progress_message = Some(format!("Waiting for: {:?}", dependencies)); - } + } + + ToolEventData::Queued { + tool_id, + tool_name: _, + position, + } => { + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.status = ToolCallStatus::Queued; + tool.progress_message = + Some(format!("Queue position: {}", position)); } - - ToolEventData::Started { tool_id, tool_name, params } => { - tool_map.entry(tool_id.clone()).or_insert_with(|| ToolCall { - tool_id: Some(tool_id.clone()), - tool_name: tool_name.clone(), - parameters: params.clone(), - result: None, - status: ToolCallStatus::Running, - progress: Some(0.0), - progress_message: None, - duration_ms: None, - }); - - let _ = event_tx.send(AgentEvent::ToolCallStart { - tool_name, - parameters: params, - }); + } + + ToolEventData::Waiting { + tool_id, + tool_name: _, + dependencies, + } => { + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.status = ToolCallStatus::Waiting; + tool.progress_message = + Some(format!("Waiting for: {:?}", dependencies)); } - - ToolEventData::Progress { tool_id, tool_name, message, percentage } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.progress = Some(percentage); - tool.progress_message = Some(message.clone()); - } - - let _ = event_tx.send(AgentEvent::ToolCallProgress { - tool_name, - message, - }); + } + + ToolEventData::Started { + tool_id, + tool_name, + params, + } => { + tool_map.entry(tool_id.clone()).or_insert_with(|| ToolCall { + tool_id: Some(tool_id.clone()), + tool_name: tool_name.clone(), + parameters: params.clone(), + result: None, + status: ToolCallStatus::Running, + progress: Some(0.0), + progress_message: None, + duration_ms: None, + }); + + let _ = event_tx.send(AgentEvent::ToolCallStart { + tool_name, + parameters: params, + }); + } + + ToolEventData::Progress { + tool_id, + tool_name, + message, + percentage, + } => { + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.progress = Some(percentage); + tool.progress_message = Some(message.clone()); } - - ToolEventData::Streaming { tool_id, tool_name: _, chunks_received } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Streaming; - tool.progress_message = Some(format!("Received {} chunks", chunks_received)); - } + + let _ = + event_tx.send(AgentEvent::ToolCallProgress { tool_name, message }); + } + + ToolEventData::Streaming { + tool_id, + tool_name: _, + chunks_received, + } => { + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.status = ToolCallStatus::Streaming; + tool.progress_message = + Some(format!("Received {} chunks", chunks_received)); } - - ToolEventData::ConfirmationNeeded { tool_id, tool_name: _, params: _ } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::ConfirmationNeeded; - tool.progress_message = Some("Waiting for user confirmation".to_string()); - } + } + + ToolEventData::ConfirmationNeeded { + tool_id, + tool_name: _, + params: _, + } => { + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.status = ToolCallStatus::ConfirmationNeeded; + tool.progress_message = + Some("Waiting for user confirmation".to_string()); } - - ToolEventData::Confirmed { tool_id, tool_name: _ } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Confirmed; - } + } + + ToolEventData::Confirmed { + tool_id, + tool_name: _, + } => { + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.status = ToolCallStatus::Confirmed; } - - ToolEventData::Rejected { tool_id, tool_name: _ } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Rejected; - tool.result = Some("User rejected execution".to_string()); - } + } + + ToolEventData::Rejected { + tool_id, + tool_name: _, + } => { + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.status = ToolCallStatus::Rejected; + tool.result = Some("User rejected execution".to_string()); } - - ToolEventData::Completed { tool_id, tool_name, result, duration_ms } => { - let result_str = serde_json::to_string(&result) - .unwrap_or_else(|_| "Success".to_string()); - - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Success; - tool.result = Some(result_str.clone()); - tool.progress = Some(1.0); - tool.duration_ms = Some(duration_ms); - } - - let _ = event_tx.send(AgentEvent::ToolCallComplete { - tool_name, - result: result_str, - success: true, - }); + } + + ToolEventData::Completed { + tool_id, + tool_name, + result, + duration_ms, + } => { + let result_str = serde_json::to_string(&result) + .unwrap_or_else(|_| "Success".to_string()); + + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.status = ToolCallStatus::Success; + tool.result = Some(result_str.clone()); + tool.progress = Some(1.0); + tool.duration_ms = Some(duration_ms); } - - ToolEventData::Failed { tool_id, tool_name, error } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Failed; - tool.result = Some(error.clone()); - } - - let _ = event_tx.send(AgentEvent::ToolCallComplete { - tool_name, - result: error, - success: false, - }); + + let _ = event_tx.send(AgentEvent::ToolCallComplete { + tool_name, + result: result_str, + success: true, + }); + } + + ToolEventData::Failed { + tool_id, + tool_name, + error, + } => { + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.status = ToolCallStatus::Failed; + tool.result = Some(error.clone()); } - - ToolEventData::Cancelled { tool_id, tool_name: _, reason } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Cancelled; - tool.result = Some(reason); - } + + let _ = event_tx.send(AgentEvent::ToolCallComplete { + tool_name, + result: error, + success: false, + }); + } + + ToolEventData::Cancelled { + tool_id, + tool_name: _, + reason, + } => { + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.status = ToolCallStatus::Cancelled; + tool.result = Some(reason); } - - _ => {} } - } - + + _ => {} + }, + CoreEvent::DialogTurnCompleted { .. } => { tracing::info!("Dialog turn completed"); let _ = event_tx.send(AgentEvent::Done); let tool_calls: Vec = tool_map.into_values().collect(); - + return Ok(AgentResponse { tool_calls, success: true, }); } - + CoreEvent::DialogTurnFailed { error, .. } => { tracing::error!("Execution error: {}", error); let _ = event_tx.send(AgentEvent::Error(error.clone())); let tool_calls: Vec = tool_map.into_values().collect(); - + return Ok(AgentResponse { tool_calls, success: false, }); } - + CoreEvent::SystemError { error, .. } => { tracing::error!("System error: {}", error); let _ = event_tx.send(AgentEvent::Error(error.clone())); let tool_calls: Vec = tool_map.into_values().collect(); - + return Ok(AgentResponse { tool_calls, success: false, }); } - + _ => { tracing::debug!("Ignoring event: {:?}", event); } diff --git a/src/apps/cli/src/agent/mod.rs b/src/apps/cli/src/agent/mod.rs index 4d5f0e44..b8b42c06 100644 --- a/src/apps/cli/src/agent/mod.rs +++ b/src/apps/cli/src/agent/mod.rs @@ -1,8 +1,6 @@ /// Agent integration module -/// +/// /// Wraps interaction with bitfun-core's Agent system - - pub mod agentic_system; pub mod core_adapter; @@ -24,10 +22,7 @@ pub enum AgentEvent { parameters: serde_json::Value, }, /// Tool call in progress - ToolCallProgress { - tool_name: String, - message: String, - }, + ToolCallProgress { tool_name: String, message: String }, /// Tool call completed ToolCallComplete { tool_name: String, diff --git a/src/apps/cli/src/config.rs b/src/apps/cli/src/config.rs index 84153b49..7a06d8de 100644 --- a/src/apps/cli/src/config.rs +++ b/src/apps/cli/src/config.rs @@ -1,12 +1,11 @@ /// Configuration management module -/// +/// /// CLI uses core's GlobalConfig system directly (same as tauri version) /// Only CLI-specific configuration is kept here (UI, shortcuts, etc.) - use anyhow::Result; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; use std::fs; +use std::path::PathBuf; /// CLI configuration (contains only CLI-specific config) /// AI model configuration uses core's GlobalConfig @@ -107,14 +106,14 @@ impl CliConfig { .join(".config") .join("bitfun") }; - + Ok(config_dir.join("config.toml")) } /// Load configuration pub fn load() -> Result { let config_path = Self::config_path()?; - + if !config_path.exists() { tracing::info!("Config file not found, using defaults"); let config = Self::default(); @@ -131,7 +130,7 @@ impl CliConfig { /// Save configuration pub fn save(&self) -> Result<()> { let config_path = Self::config_path()?; - + if let Some(parent) = config_path.parent() { fs::create_dir_all(parent)?; } @@ -154,7 +153,7 @@ impl CliConfig { .join(".config") .join("bitfun") }; - + fs::create_dir_all(&config_dir)?; Ok(config_dir) } @@ -165,7 +164,4 @@ impl CliConfig { fs::create_dir_all(&sessions_dir)?; Ok(sessions_dir) } - } - - diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 9f503564..c19b04e8 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -1,18 +1,17 @@ +mod agent; /// BitFun CLI -/// +/// /// Command-line interface version, supports: /// - Interactive TUI /// - Single command execution /// - Batch task processing - mod config; +mod modes; mod session; mod ui; -mod modes; -mod agent; -use clap::{Parser, Subcommand}; use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; use config::CliConfig; use modes::chat::ChatMode; @@ -25,7 +24,7 @@ use modes::exec::ExecMode; struct Cli { #[command(subcommand)] command: Option, - + /// Enable verbose logging #[arg(short, long, global = true)] verbose: bool, @@ -38,69 +37,69 @@ enum Commands { /// Agent type #[arg(short, long, default_value = "agentic")] agent: String, - + /// Workspace path #[arg(short, long)] workspace: Option, }, - + /// Execute single command Exec { /// User message message: String, - + /// Agent type #[arg(short, long, default_value = "agentic")] agent: String, - + /// Workspace path #[arg(short, long)] workspace: Option, - + /// Output in JSON format (script-friendly) #[arg(long)] json: bool, - + /// Output git diff patch after execution (for SWE-bench evaluation) /// Without path outputs to terminal, with path saves to file /// Example: --output-patch or --output-patch ./result.patch #[arg(long, num_args = 0..=1, default_missing_value = "-")] output_patch: Option, - + /// Tool execution requires confirmation (default: no confirmation to avoid blocking non-interactive mode) #[arg(long)] confirm: bool, }, - + /// Execute batch tasks Batch { /// Task configuration file path #[arg(short, long)] tasks: String, }, - + /// Session management Sessions { #[command(subcommand)] action: SessionAction, }, - + /// Configuration management Config { #[command(subcommand)] action: ConfigAction, }, - + /// Invoke tool directly Tool { /// Tool name name: String, - + /// Tool parameters (JSON) #[arg(short, long)] params: Option, }, - + /// Health check Health, } @@ -142,30 +141,27 @@ fn resolve_workspace_path(workspace: Option<&str>) -> Option #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); - + let log_level = if cli.verbose { tracing::Level::DEBUG } else { tracing::Level::INFO }; - + let is_tui_mode = matches!(cli.command, None | Some(Commands::Chat { .. })); - + if is_tui_mode { use std::fs::OpenOptions; - - let log_dir = CliConfig::config_dir().ok() + + let log_dir = CliConfig::config_dir() + .ok() .map(|d| d.join("logs")) .unwrap_or_else(|| std::env::temp_dir().join("bitfun-cli")); - + std::fs::create_dir_all(&log_dir).ok(); let log_file = log_dir.join("bitfun-cli.log"); - - if let Ok(file) = OpenOptions::new() - .create(true) - .append(true) - .open(log_file) - { + + if let Ok(file) = OpenOptions::new().create(true).append(true).open(log_file) { tracing_subscriber::fmt() .with_max_level(log_level) .with_writer(move || -> Box { @@ -192,7 +188,7 @@ async fn main() -> Result<()> { .with_target(false) .init(); } - + let config = CliConfig::load().unwrap_or_else(|e| { if !is_tui_mode { eprintln!("Warning: Failed to load config: {}", e); @@ -200,27 +196,27 @@ async fn main() -> Result<()> { } CliConfig::default() }); - + match cli.command { Some(Commands::Chat { agent, workspace }) => { let (workspace, mut startup_terminal) = if workspace.is_none() { use ui::startup::StartupPage; - + let mut terminal = ui::init_terminal()?; let mut startup_page = StartupPage::new(); let selected_workspace = startup_page.run(&mut terminal)?; - + if selected_workspace.is_none() { ui::restore_terminal(terminal)?; println!("Goodbye!"); return Ok(()); } - + (selected_workspace, Some(terminal)) } else { (workspace, None) }; - + if let Some(ref mut term) = startup_terminal { ui::render_loading(term, "Initializing system, please wait...")?; } else { @@ -229,7 +225,7 @@ async fn main() -> Result<()> { let workspace_path = resolve_workspace_path(workspace.as_deref()); tracing::info!("CLI workspace: {:?}", workspace_path); - + bitfun_core::service::config::initialize_global_config() .await .context("Failed to initialize global config service")?; @@ -247,28 +243,31 @@ async fn main() -> Result<()> { }; if let Some(ref svc) = config_service { if let Err(e) = svc.set_config("ai.skip_tool_confirmation", true).await { - tracing::warn!("Failed to temporarily disable tool confirmation, continuing: {}", e); + tracing::warn!( + "Failed to temporarily disable tool confirmation, continuing: {}", + e + ); } } - + use bitfun_core::infrastructure::ai::AIClientFactory; AIClientFactory::initialize_global() .await .context("Failed to initialize global AIClientFactory")?; tracing::info!("Global AI client factory initialized"); - + let agentic_system = agent::agentic_system::init_agentic_system() .await .context("Failed to initialize agentic system")?; tracing::info!("Agentic system initialized"); - + if let Some(ref mut term) = startup_terminal { ui::render_loading(term, "System initialized, starting chat interface...")?; } else { println!("System initialized, starting chat interface...\n"); std::thread::sleep(std::time::Duration::from_millis(500)); } - + let mut chat_mode = ChatMode::new(config, agent, workspace_path, &agentic_system); let chat_result = chat_mode.run(startup_terminal); @@ -280,12 +279,19 @@ async fn main() -> Result<()> { chat_result?; } - - Some(Commands::Exec { message, agent, workspace, json: _, output_patch, confirm }) => { - let workspace_path_resolved = - resolve_workspace_path(workspace.as_deref()).or_else(|| std::env::current_dir().ok()); + + Some(Commands::Exec { + message, + agent, + workspace, + json: _, + output_patch, + confirm, + }) => { + let workspace_path_resolved = resolve_workspace_path(workspace.as_deref()) + .or_else(|| std::env::current_dir().ok()); tracing::info!("CLI workspace: {:?}", workspace_path_resolved); - + bitfun_core::service::config::initialize_global_config() .await .context("Failed to initialize global config service")?; @@ -303,26 +309,29 @@ async fn main() -> Result<()> { }; if let Some(ref svc) = config_service { let desired_skip = !confirm; - if let Err(e) = svc.set_config("ai.skip_tool_confirmation", desired_skip).await { + if let Err(e) = svc + .set_config("ai.skip_tool_confirmation", desired_skip) + .await + { tracing::warn!("Failed to set tool confirmation toggle, continuing: {}", e); } } - + use bitfun_core::infrastructure::ai::AIClientFactory; AIClientFactory::initialize_global() .await .context("Failed to initialize global AIClientFactory")?; tracing::info!("Global AI client factory initialized"); - + let agentic_system = agent::agentic_system::init_agentic_system() .await .context("Failed to initialize agentic system")?; tracing::info!("Agentic system initialized"); - + let mut exec_mode = ExecMode::new( - config, - message, - agent, + config, + message, + agent, &agentic_system, workspace_path_resolved, output_patch, @@ -337,21 +346,21 @@ async fn main() -> Result<()> { run_result?; } - + Some(Commands::Batch { tasks }) => { println!("Executing batch tasks..."); println!("Tasks file: {}", tasks); println!("\nWarning: Batch execution feature coming soon"); } - + Some(Commands::Sessions { action }) => { handle_session_action(action)?; } - + Some(Commands::Config { action }) => { handle_config_action(action, &config)?; } - + Some(Commands::Tool { name, params }) => { println!("Invoking tool: {}", name); if let Some(p) = params { @@ -359,33 +368,33 @@ async fn main() -> Result<()> { } println!("\nWarning: Tool invocation feature coming soon"); } - + Some(Commands::Health) => { println!("BitFun CLI is running normally"); println!("Version: {}", env!("CARGO_PKG_VERSION")); println!("Config directory: {:?}", CliConfig::config_dir()?); } - + None => { - use ui::startup::StartupPage; use modes::chat::ChatExitReason; - + use ui::startup::StartupPage; + loop { let mut terminal = ui::init_terminal()?; let mut startup_page = StartupPage::new(); let workspace = startup_page.run(&mut terminal)?; - + if workspace.is_none() { ui::restore_terminal(terminal)?; println!("Goodbye!"); break; } - + ui::render_loading(&mut terminal, "Initializing system, please wait...")?; let workspace_path = resolve_workspace_path(workspace.as_deref()); tracing::info!("CLI workspace: {:?}", workspace_path); - + bitfun_core::service::config::initialize_global_config() .await .context("Failed to initialize global config service")?; @@ -404,20 +413,23 @@ async fn main() -> Result<()> { if let Some(ref svc) = config_service { let _ = svc.set_config("ai.skip_tool_confirmation", true).await; } - + use bitfun_core::infrastructure::ai::AIClientFactory; AIClientFactory::initialize_global() .await .context("Failed to initialize global AIClientFactory")?; tracing::info!("Global AI client factory initialized"); - + let agentic_system = agent::agentic_system::init_agentic_system() .await .context("Failed to initialize agentic system")?; tracing::info!("Agentic system initialized"); - - ui::render_loading(&mut terminal, "System initialized, starting chat interface...")?; - + + ui::render_loading( + &mut terminal, + "System initialized, starting chat interface...", + )?; + let agent = config.behavior.default_agent.clone(); let mut chat_mode = ChatMode::new(config.clone(), agent, workspace_path, &agentic_system); @@ -429,7 +441,7 @@ async fn main() -> Result<()> { .await; } let exit_reason = exit_reason?; - + match exit_reason { ChatExitReason::Quit => { println!("Goodbye!"); @@ -442,7 +454,7 @@ async fn main() -> Result<()> { } } } - + Ok(()) } @@ -451,18 +463,19 @@ fn handle_session_action(action: SessionAction) -> Result<()> { SessionAction::List => { use session::Session; let sessions = Session::list_all()?; - + if sessions.is_empty() { println!("No history sessions"); return Ok(()); } - + println!("History sessions (total {})\n", sessions.len()); - + for (i, info) in sessions.iter().enumerate() { println!("{}. {} (ID: {})", i + 1, info.title, info.id); - println!(" Agent: {} | Messages: {} | Updated: {}", - info.agent, + println!( + " Agent: {} | Messages: {} | Updated: {}", + info.agent, info.message_count, info.updated_at.format("%Y-%m-%d %H:%M") ); @@ -472,23 +485,28 @@ fn handle_session_action(action: SessionAction) -> Result<()> { println!(); } } - + SessionAction::Show { id } => { use session::Session; - + let session = if id == "last" { - Session::get_last()? - .ok_or_else(|| anyhow::anyhow!("No history sessions"))? + Session::get_last()?.ok_or_else(|| anyhow::anyhow!("No history sessions"))? } else { Session::load(&id)? }; - + println!("Session Details\n"); println!("Title: {}", session.title); println!("ID: {}", session.id); println!("Agent: {}", session.agent); - println!("Created: {}", session.created_at.format("%Y-%m-%d %H:%M:%S")); - println!("Updated: {}", session.updated_at.format("%Y-%m-%d %H:%M:%S")); + println!( + "Created: {}", + session.created_at.format("%Y-%m-%d %H:%M:%S") + ); + println!( + "Updated: {}", + session.updated_at.format("%Y-%m-%d %H:%M:%S") + ); if let Some(ws) = &session.workspace { println!("Workspace: {}", ws); } @@ -498,12 +516,13 @@ fn handle_session_action(action: SessionAction) -> Result<()> { println!(" Tool calls: {}", session.metadata.tool_calls); println!(" Files modified: {}", session.metadata.files_modified); println!(); - + if !session.messages.is_empty() { println!("Recent messages:"); let recent = session.messages.iter().rev().take(3); for msg in recent { - println!(" [{}] {}: {}", + println!( + " [{}] {}: {}", msg.timestamp.format("%H:%M:%S"), msg.role, msg.content.lines().next().unwrap_or("") @@ -511,14 +530,14 @@ fn handle_session_action(action: SessionAction) -> Result<()> { } } } - + SessionAction::Delete { id } => { use session::Session; Session::delete(&id)?; println!("Deleted session: {}", id); } } - + Ok(()) } @@ -541,7 +560,7 @@ fn handle_config_action(action: ConfigAction, config: &CliConfig) -> Result<()> println!(); println!("Config file: {:?}", CliConfig::config_path()?); } - + ConfigAction::Edit => { let config_path = CliConfig::config_path()?; println!("Config file location: {:?}", config_path); @@ -551,15 +570,13 @@ fn handle_config_action(action: ConfigAction, config: &CliConfig) -> Result<()> println!(" or"); println!(" code {:?}", config_path); } - + ConfigAction::Reset => { let default_config = CliConfig::default(); default_config.save()?; println!("Reset to default configuration"); } } - + Ok(()) } - - diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 8be25ac5..6b974e5b 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -1,7 +1,6 @@ /// Chat mode implementation -/// +/// /// Interactive chat mode with TUI interface - use anyhow::Result; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::backend::CrosstermBackend; @@ -12,12 +11,12 @@ use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc; +use crate::agent::{agentic_system::AgenticSystem, core_adapter::CoreAgentAdapter, Agent}; use crate::config::CliConfig; use crate::session::Session; use crate::ui::chat::ChatView; use crate::ui::theme::Theme; use crate::ui::{init_terminal, restore_terminal}; -use crate::agent::{Agent, core_adapter::CoreAgentAdapter, agentic_system::AgenticSystem}; use uuid; /// Chat mode exit reason @@ -38,8 +37,8 @@ pub struct ChatMode { impl ChatMode { pub fn new( - config: CliConfig, - agent_name: String, + config: CliConfig, + agent_name: String, workspace_path: Option, agentic_system: &AgenticSystem, ) -> Self { @@ -50,7 +49,7 @@ impl ChatMode { agentic_system.event_queue.clone(), workspace_path.clone(), )) as Arc; - + Self { config, agent_name, @@ -78,7 +77,7 @@ impl ChatMode { .as_ref() .map(|path| path.to_string_lossy().to_string()), ); - + let theme = match self.config.ui.theme.as_str() { "light" => Theme::light(), _ => Theme::dark(), @@ -86,16 +85,18 @@ impl ChatMode { let mut chat_view = ChatView::new(session, theme); let rt_handle = tokio::runtime::Handle::current(); - let (response_tx, mut response_rx) = mpsc::unbounded_channel::(); + let (response_tx, mut response_rx) = + mpsc::unbounded_channel::(); let (stream_tx, mut stream_rx) = mpsc::unbounded_channel::(); - + let mut pending_response: Option>> = None; let mut current_assistant_message_text = String::new(); - let mut current_tool_map: std::collections::HashMap = std::collections::HashMap::new(); + let mut current_tool_map: std::collections::HashMap = + std::collections::HashMap::new(); let mut exit_reason = ChatExitReason::Quit; let mut should_quit = false; - + while !should_quit { terminal.draw(|frame| { chat_view.render(frame); @@ -104,24 +105,27 @@ impl ChatMode { while let Ok(event) = stream_rx.try_recv() { use crate::agent::AgentEvent; use crate::session::{ToolCall, ToolCallStatus}; - + match event { AgentEvent::TextChunk(chunk) => { current_assistant_message_text.push_str(&chunk); chat_view.session.update_last_message_text_flow( current_assistant_message_text.clone(), - true + true, ); } - - AgentEvent::ToolCallStart { tool_name, parameters } => { + + AgentEvent::ToolCallStart { + tool_name, + parameters, + } => { if !current_assistant_message_text.is_empty() { chat_view.session.update_last_message_text_flow( current_assistant_message_text.clone(), - false + false, ); } - + let tool_id = uuid::Uuid::new_v4().to_string(); let tool_call = ToolCall { tool_id: Some(tool_id.clone()), @@ -133,11 +137,11 @@ impl ChatMode { progress_message: None, duration_ms: None, }; - + current_tool_map.insert(tool_id, tool_call.clone()); chat_view.session.add_tool_to_last_message(tool_call); } - + AgentEvent::ToolCallProgress { tool_name, message } => { for (tool_id, tool) in current_tool_map.iter() { if tool.tool_name == tool_name { @@ -149,10 +153,15 @@ impl ChatMode { } } } - - AgentEvent::ToolCallComplete { tool_name, result, success } => { + + AgentEvent::ToolCallComplete { + tool_name, + result, + success, + } => { for (tool_id, tool) in current_tool_map.iter_mut() { - if tool.tool_name == tool_name && tool.status == ToolCallStatus::Running { + if tool.tool_name == tool_name && tool.status == ToolCallStatus::Running + { tool.status = if success { ToolCallStatus::Success } else { @@ -160,7 +169,7 @@ impl ChatMode { }; tool.result = Some(result.clone()); tool.progress = Some(1.0); - + let tid = tool_id.clone(); chat_view.session.update_tool_in_last_message(&tid, |t| { t.status = tool.status.clone(); @@ -171,20 +180,20 @@ impl ChatMode { } } } - + AgentEvent::Done => { if !current_assistant_message_text.is_empty() { chat_view.session.update_last_message_text_flow( current_assistant_message_text.clone(), - false + false, ); } } - + AgentEvent::Error(err) => { chat_view.set_status(Some(format!("Error: {}", err))); } - + _ => {} } } @@ -195,7 +204,7 @@ impl ChatMode { chat_view.set_loading(false); chat_view.set_status(None); } - + if let Some(handle) = &pending_response { if handle.is_finished() { pending_response = None; @@ -208,10 +217,10 @@ impl ChatMode { match event { Event::Key(key) => { if let Some(reason) = self.handle_key_event( - key, - &mut chat_view, - &mut pending_response, - &rt_handle, + key, + &mut chat_view, + &mut pending_response, + &rt_handle, &response_tx, &stream_tx, &mut current_assistant_message_text, @@ -240,8 +249,8 @@ impl ChatMode { } fn handle_key_event( - &self, - key: KeyEvent, + &self, + key: KeyEvent, chat_view: &mut ChatView, pending_response: &mut Option>>, rt_handle: &tokio::runtime::Handle, @@ -253,7 +262,7 @@ impl ChatMode { if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat { return Ok(None); } - + match (key.code, key.modifiers) { (KeyCode::Char('c'), KeyModifiers::CONTROL) => { tracing::info!("User requested quit"); @@ -277,33 +286,42 @@ impl ChatMode { if let Some(input) = chat_view.send_input() { tracing::info!("User input: {}", input); - + if input.starts_with('/') { self.handle_command(&input, chat_view)?; return Ok(None); } - + chat_view.set_loading(true); chat_view.set_status(Some(format!("{} is thinking...", self.agent_name))); - chat_view.session.add_message("assistant".to_string(), String::new()); - + chat_view + .session + .add_message("assistant".to_string(), String::new()); + current_assistant_message_text.clear(); current_tool_map.clear(); - + let agent = Arc::clone(&self.agent); let input_clone = input.clone(); let resp_tx = response_tx.clone(); let stream_tx_clone = stream_tx.clone(); - + let handle_clone = rt_handle.spawn(async move { - match agent.process_message(input_clone, stream_tx_clone.clone()).await { + match agent + .process_message(input_clone, stream_tx_clone.clone()) + .await + { Ok(response) => { - tracing::info!("Agent response complete: {} tool calls", response.tool_calls.len()); + tracing::info!( + "Agent response complete: {} tool calls", + response.tool_calls.len() + ); let _ = resp_tx.send(response); } Err(e) => { tracing::error!("Agent processing failed: {}", e); - let _ = stream_tx_clone.send(crate::agent::AgentEvent::Error(e.to_string())); + let _ = stream_tx_clone + .send(crate::agent::AgentEvent::Error(e.to_string())); let _ = resp_tx.send(crate::agent::AgentResponse { tool_calls: vec![], success: false, @@ -312,7 +330,7 @@ impl ChatMode { } Ok(()) }); - + *pending_response = Some(handle_clone); } } @@ -423,7 +441,8 @@ impl ChatMode { /agents - List available agents\n\ /switch - Switch agent\n\ /history - Show history\n\ - /export - Export session".to_string(), + /export - Export session" + .to_string(), ); } "/clear" => { @@ -439,7 +458,8 @@ impl ChatMode { • test-writer - Test writing expert\n\ • docs-writer - Documentation expert\n\ • rust-specialist - Rust expert\n\ - • visual-debugger - Visual debugging expert".to_string(), + • visual-debugger - Visual debugging expert" + .to_string(), ); } "/switch" => { @@ -449,34 +469,40 @@ impl ChatMode { format!("Warning: Agent switching feature coming soon\nTip: Use `bitfun chat --agent {}` to start a new session", parts[1]), ); } else { - chat_view.add_message( - "system".to_string(), - "Usage: /switch ".to_string(), - ); + chat_view + .add_message("system".to_string(), "Usage: /switch ".to_string()); } } "/history" => { chat_view.add_message( "system".to_string(), - format!("Current session statistics:\n\ + format!( + "Current session statistics:\n\ • Messages: {}\n\ • Tool calls: {}\n\ • Files modified: {}", - chat_view.session.metadata.message_count, - chat_view.session.metadata.tool_calls, - chat_view.session.metadata.files_modified), + chat_view.session.metadata.message_count, + chat_view.session.metadata.tool_calls, + chat_view.session.metadata.files_modified + ), ); } "/export" => { chat_view.add_message( "system".to_string(), - format!("Session auto-saved to: ~/.config/bitfun/sessions/{}.json", chat_view.session.id), + format!( + "Session auto-saved to: ~/.config/bitfun/sessions/{}.json", + chat_view.session.id + ), ); } _ => { chat_view.add_message( "system".to_string(), - format!("Unknown command: {}\nUse /help to see available commands", parts[0]), + format!( + "Unknown command: {}\nUse /help to see available commands", + parts[0] + ), ); } } @@ -484,4 +510,3 @@ impl ChatMode { Ok(()) } } - diff --git a/src/apps/cli/src/modes/exec.rs b/src/apps/cli/src/modes/exec.rs index 9b8c7963..eca04396 100644 --- a/src/apps/cli/src/modes/exec.rs +++ b/src/apps/cli/src/modes/exec.rs @@ -1,13 +1,14 @@ +use crate::agent::{ + agentic_system::AgenticSystem, core_adapter::CoreAgentAdapter, Agent, AgentEvent, +}; +use crate::config::CliConfig; /// Exec mode implementation -/// +/// /// Single command execution mode - use anyhow::Result; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::mpsc; -use crate::config::CliConfig; -use crate::agent::{Agent, AgentEvent, core_adapter::CoreAgentAdapter, agentic_system::AgenticSystem}; pub struct ExecMode { #[allow(dead_code)] @@ -21,8 +22,8 @@ pub struct ExecMode { impl ExecMode { pub fn new( - config: CliConfig, - message: String, + config: CliConfig, + message: String, agent_type: String, agentic_system: &AgenticSystem, workspace_path: Option, @@ -35,7 +36,7 @@ impl ExecMode { agentic_system.event_queue.clone(), workspace_path.clone(), )) as Arc; - + Self { config, message, @@ -44,22 +45,22 @@ impl ExecMode { output_patch, } } - + fn get_git_diff(&self) -> Option { let workspace = self.workspace_path.as_ref()?; - + let git_dir = workspace.join(".git"); if !git_dir.exists() { eprintln!("Warning: Workspace is not a git repository, cannot generate patch"); return None; } - + let output = bitfun_core::util::process_manager::create_command("git") .args(["diff", "--no-color"]) .current_dir(workspace) .output() .ok()?; - + if output.status.success() { Some(String::from_utf8_lossy(&output.stdout).to_string()) } else { @@ -69,7 +70,11 @@ impl ExecMode { } pub async fn run(&mut self) -> Result<()> { - tracing::info!("Executing command, Agent: {}, Message: {}", self.agent.name(), self.message); + tracing::info!( + "Executing command, Agent: {}, Message: {}", + self.agent.name(), + self.message + ); println!("Executing: {}", self.message); println!(); @@ -90,13 +95,23 @@ impl ExecMode { use std::io::Write; std::io::stdout().flush().ok(); } - AgentEvent::ToolCallStart { tool_name, parameters: _ } => { + AgentEvent::ToolCallStart { + tool_name, + parameters: _, + } => { println!("\nTool call: {}", tool_name); } - AgentEvent::ToolCallProgress { tool_name: _, message } => { + AgentEvent::ToolCallProgress { + tool_name: _, + message, + } => { println!(" In progress: {}", message); } - AgentEvent::ToolCallComplete { tool_name, result, success } => { + AgentEvent::ToolCallComplete { + tool_name, + result, + success, + } => { if success { println!(" [+] {}: {}", tool_name, result); } else { @@ -115,13 +130,16 @@ impl ExecMode { } let result = handle.await; - + match result { Ok(Ok(response)) => { if response.success { println!("Execution complete"); if !response.tool_calls.is_empty() { - println!("\nTool call statistics: {} tools invoked", response.tool_calls.len()); + println!( + "\nTool call statistics: {} tools invoked", + response.tool_calls.len() + ); } } else { println!("Execution failed"); @@ -136,7 +154,7 @@ impl ExecMode { return Err(e.into()); } } - + if let Some(ref output_target) = self.output_patch { println!("\n--- Generating Patch ---"); if let Some(patch) = self.get_git_diff() { @@ -168,4 +186,3 @@ impl ExecMode { Ok(()) } } - diff --git a/src/apps/cli/src/modes/mod.rs b/src/apps/cli/src/modes/mod.rs index cfdcaa47..2ce28d65 100644 --- a/src/apps/cli/src/modes/mod.rs +++ b/src/apps/cli/src/modes/mod.rs @@ -1,4 +1,3 @@ /// Different interaction modes - pub mod chat; pub mod exec; diff --git a/src/apps/cli/src/session.rs b/src/apps/cli/src/session.rs index 27fde505..eb170fc4 100644 --- a/src/apps/cli/src/session.rs +++ b/src/apps/cli/src/session.rs @@ -1,12 +1,11 @@ /// Session management module -/// +/// /// Responsible for creating, saving, loading and managing chat sessions - use anyhow::Result; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; use std::fs; -use chrono::{DateTime, Utc}; +use std::path::PathBuf; use crate::config::CliConfig; @@ -137,7 +136,7 @@ impl Session { pub fn new(agent: String, workspace: Option) -> Self { let id = uuid::Uuid::new_v4().to_string(); let now = Utc::now(); - + Self { id: id.clone(), title: format!("Session {}", now.format("%m-%d %H:%M")), @@ -159,7 +158,7 @@ impl Session { timestamp: Utc::now(), flow_items: Vec::new(), }; - + self.messages.push(message); self.metadata.message_count = self.messages.len(); self.updated_at = Utc::now(); @@ -169,7 +168,11 @@ impl Session { pub fn update_last_message_text_flow(&mut self, content: String, is_streaming: bool) { if let Some(last_message) = self.messages.last_mut() { if let Some(last_item) = last_message.flow_items.last_mut() { - if let FlowItem::Text { content: ref mut c, is_streaming: ref mut s } = last_item { + if let FlowItem::Text { + content: ref mut c, + is_streaming: ref mut s, + } = last_item + { *c = content.clone(); *s = is_streaming; } else { @@ -188,7 +191,7 @@ impl Session { self.updated_at = Utc::now(); } } - + /// Add tool call to the last message pub fn add_tool_to_last_message(&mut self, tool_call: ToolCall) { if let Some(last_message) = self.messages.last_mut() { @@ -197,9 +200,13 @@ impl Session { self.updated_at = Utc::now(); } } - + /// Update tool call status in the last message - pub fn update_tool_in_last_message(&mut self, tool_id: &str, update_fn: impl FnOnce(&mut ToolCall)) { + pub fn update_tool_in_last_message( + &mut self, + tool_id: &str, + update_fn: impl FnOnce(&mut ToolCall), + ) { if let Some(last_message) = self.messages.last_mut() { for item in last_message.flow_items.iter_mut() { if let FlowItem::Tool { tool_call } = item { @@ -227,31 +234,35 @@ impl Session { pub fn load(id: &str) -> Result { let sessions_dir = CliConfig::sessions_dir()?; let session_file = sessions_dir.join(format!("{}.json", id)); - + if !session_file.exists() { anyhow::bail!("Session not found: {}", id); } let content = fs::read_to_string(&session_file)?; let session: Self = serde_json::from_str(&content)?; - tracing::info!("Loaded session: {} ({} messages)", session.title, session.messages.len()); + tracing::info!( + "Loaded session: {} ({} messages)", + session.title, + session.messages.len() + ); Ok(session) } /// List all sessions pub fn list_all() -> Result> { let sessions_dir = CliConfig::sessions_dir()?; - + if !sessions_dir.exists() { return Ok(Vec::new()); } let mut sessions = Vec::new(); - + for entry in fs::read_dir(sessions_dir)? { let entry = entry?; let path = entry.path(); - + if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; } @@ -271,7 +282,7 @@ impl Session { fn load_info(path: &PathBuf) -> Result { let content = fs::read_to_string(path)?; let session: Self = serde_json::from_str(&content)?; - + Ok(SessionInfo { id: session.id, title: session.title, @@ -287,19 +298,19 @@ impl Session { pub fn delete(id: &str) -> Result<()> { let sessions_dir = CliConfig::sessions_dir()?; let session_file = sessions_dir.join(format!("{}.json", id)); - + if session_file.exists() { fs::remove_file(session_file)?; tracing::info!("Deleted session: {}", id); } - + Ok(()) } /// Get most recent session pub fn get_last() -> Result> { let sessions = Self::list_all()?; - + if let Some(info) = sessions.first() { Ok(Some(Self::load(&info.id)?)) } else { @@ -319,5 +330,3 @@ pub struct SessionInfo { pub message_count: usize, pub workspace: Option, } - - diff --git a/src/apps/cli/src/ui/chat.rs b/src/apps/cli/src/ui/chat.rs index cf050f0d..fab52e24 100644 --- a/src/apps/cli/src/ui/chat.rs +++ b/src/apps/cli/src/ui/chat.rs @@ -1,5 +1,4 @@ /// Chat mode TUI interface - use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, @@ -10,10 +9,10 @@ use ratatui::{ use std::collections::VecDeque; use unicode_width::UnicodeWidthStr; -use super::theme::{Theme, StyleKind}; -use super::widgets::{Spinner, HelpText}; use super::markdown::MarkdownRenderer; -use crate::session::{Message, Session, FlowItem}; +use super::theme::{StyleKind, Theme}; +use super::widgets::{HelpText, Spinner}; +use crate::session::{FlowItem, Message, Session}; /// Chat interface state pub struct ChatView { @@ -77,11 +76,11 @@ impl ChatView { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // header - Constraint::Min(10), // messages area - Constraint::Length(1), // status bar - Constraint::Length(3), // input area - Constraint::Length(1), // shortcuts hint + Constraint::Length(3), // header + Constraint::Min(10), // messages area + Constraint::Length(1), // status bar + Constraint::Length(3), // input area + Constraint::Length(1), // shortcuts hint ]) .split(size); @@ -97,8 +96,11 @@ impl ChatView { fn render_header(&self, frame: &mut Frame, area: Rect) { let title = format!(" BitFun CLI v{} ", env!("CARGO_PKG_VERSION")); let agent_info = format!(" Agent: {} ", self.session.agent); - - let workspace = self.session.workspace.as_ref() + + let workspace = self + .session + .workspace + .as_ref() .map(|w| format!("Workspace: {}", w)) .unwrap_or_else(|| "No workspace".to_string()); @@ -112,15 +114,13 @@ impl ChatView { .fg(ratatui::style::Color::Rgb(147, 51, 234)) .add_modifier(Modifier::BOLD); - let text = vec![ - Line::from(vec![ - Span::styled(&title, title_style), - Span::raw(" "), - Span::styled(&agent_info, self.theme.style(StyleKind::Primary)), - Span::raw(" "), - Span::styled(&workspace, self.theme.style(StyleKind::Muted)), - ]), - ]; + let text = vec![Line::from(vec![ + Span::styled(&title, title_style), + Span::raw(" "), + Span::styled(&agent_info, self.theme.style(StyleKind::Primary)), + Span::raw(" "), + Span::styled(&workspace, self.theme.style(StyleKind::Muted)), + ])]; let paragraph = Paragraph::new(text) .block(header) @@ -135,7 +135,7 @@ impl ChatView { } else { " Conversation ".to_string() }; - + let block = Block::default() .borders(Borders::ALL) .border_style(self.theme.style(StyleKind::Border)) @@ -169,7 +169,9 @@ impl ChatView { frame.render_widget(paragraph, inner); } else { - let messages: Vec = self.session.messages + let messages: Vec = self + .session + .messages .iter() .flat_map(|msg| self.render_message(msg)) .collect(); @@ -177,28 +179,28 @@ impl ChatView { if !messages.is_empty() { let total_lines = messages.len(); let visible_lines = inner.height as usize; - + if self.browse_mode { let view_position = if self.scroll_offset >= total_lines { 0 } else { total_lines.saturating_sub(self.scroll_offset + visible_lines) }; - + *self.list_state.offset_mut() = view_position; - + let selected_index = view_position + visible_lines / 2; - self.list_state.select(Some(selected_index.min(total_lines.saturating_sub(1)))); - + self.list_state + .select(Some(selected_index.min(total_lines.saturating_sub(1)))); } else if self.auto_scroll { let bottom_offset = total_lines.saturating_sub(visible_lines); *self.list_state.offset_mut() = bottom_offset; - + let last_index = total_lines.saturating_sub(1); self.list_state.select(Some(last_index)); self.scroll_offset = 0; } - + if self.browse_mode { let progress_pct = if self.scroll_offset == 0 { 100 @@ -207,7 +209,7 @@ impl ChatView { } else { ((total_lines - self.scroll_offset) * 100 / total_lines).min(100) }; - + let scroll_indicator = format!("{}%", progress_pct); let indicator_area = Rect { x: inner.x + inner.width.saturating_sub(12), @@ -215,7 +217,7 @@ impl ChatView { width: 10, height: 1, }; - + let indicator_widget = Paragraph::new(scroll_indicator) .style(self.theme.style(StyleKind::Info)) .alignment(Alignment::Right); @@ -223,8 +225,7 @@ impl ChatView { } } - let list = List::new(messages) - .highlight_style(Style::default()); + let list = List::new(messages).highlight_style(Style::default()); frame.render_stateful_widget(list, inner, &mut self.list_state); } @@ -233,14 +234,14 @@ impl ChatView { self.spinner.tick(); let loading_text = format!("{} Thinking...", self.spinner.current()); let loading_span = Span::styled(loading_text, self.theme.style(StyleKind::Primary)); - + let loading_area = Rect { x: inner.x + 2, y: inner.y + inner.height.saturating_sub(1), width: inner.width.saturating_sub(4), height: 1, }; - + let paragraph = Paragraph::new(loading_span); frame.render_widget(paragraph, loading_area); } @@ -262,7 +263,7 @@ impl ChatView { }; let time = message.timestamp.format("%H:%M:%S"); - + items.push(ListItem::new(Line::from(vec![Span::raw("")]))); items.push(ListItem::new(Line::from(vec![ @@ -274,11 +275,17 @@ impl ChatView { if !message.flow_items.is_empty() { for flow_item in &message.flow_items { match flow_item { - FlowItem::Text { content, is_streaming } => { - if message.role == "assistant" && MarkdownRenderer::has_markdown_syntax(content) { + FlowItem::Text { + content, + is_streaming, + } => { + if message.role == "assistant" + && MarkdownRenderer::has_markdown_syntax(content) + { let available_width = 80; - let markdown_lines = self.markdown_renderer.render(content, available_width); - + let markdown_lines = + self.markdown_renderer.render(content, available_width); + for md_line in markdown_lines { let mut spans = vec![Span::raw(" ")]; spans.extend(md_line.spans); @@ -293,7 +300,7 @@ impl ChatView { ]))); } } - + if *is_streaming { items.push(ListItem::new(Line::from(vec![ Span::raw(" "), @@ -301,19 +308,24 @@ impl ChatView { ]))); } } - + FlowItem::Tool { tool_call } => { items.push(ListItem::new(Line::from(""))); - let tool_items = crate::ui::tool_cards::render_tool_card(tool_call, &self.theme); + let tool_items = + crate::ui::tool_cards::render_tool_card(tool_call, &self.theme); items.extend(tool_items); } } } } else { - if message.role == "assistant" && MarkdownRenderer::has_markdown_syntax(&message.content) { + if message.role == "assistant" + && MarkdownRenderer::has_markdown_syntax(&message.content) + { let available_width = 80; - let markdown_lines = self.markdown_renderer.render(&message.content, available_width); - + let markdown_lines = self + .markdown_renderer + .render(&message.content, available_width); + for md_line in markdown_lines { let mut spans = vec![Span::raw(" ")]; spans.extend(md_line.spans); @@ -332,7 +344,7 @@ impl ChatView { items } - + /// Render status bar fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let status_text = if let Some(status) = &self.status { @@ -365,11 +377,7 @@ impl ChatView { Span::raw(&self.input) }; - let paragraph = Paragraph::new(Line::from(vec![ - Span::raw("> "), - input_text, - ])) - .block(block); + let paragraph = Paragraph::new(Line::from(vec![Span::raw("> "), input_text])).block(block); frame.render_widget(paragraph, area); @@ -378,7 +386,7 @@ impl ChatView { // Calculate display width to cursor position let byte_pos = self.char_pos_to_byte_pos(self.cursor); let display_width = self.input[..byte_pos].width() as u16; - + frame.set_cursor_position(( area.x + 3 + display_width, // "> " + display width area.y + 1, @@ -410,8 +418,7 @@ impl ChatView { style: self.theme.style(StyleKind::Muted), }; - let paragraph = Paragraph::new(help.render()) - .alignment(Alignment::Center); + let paragraph = Paragraph::new(help.render()).alignment(Alignment::Center); frame.render_widget(paragraph, area); } @@ -430,7 +437,7 @@ impl ChatView { } let input = self.input.clone(); - + // Add to history self.input_history.push_front(input.clone()); if self.input_history.len() > 50 { @@ -452,7 +459,7 @@ impl ChatView { if c.is_control() || c == '\u{0}' { return; } - + let byte_pos = self.char_pos_to_byte_pos(self.cursor); self.input.insert(byte_pos, c); self.cursor += 1; @@ -552,11 +559,13 @@ impl ChatView { pub fn scroll_up(&mut self, lines: usize) { if self.browse_mode { - let total_lines: usize = self.session.messages + let total_lines: usize = self + .session + .messages .iter() .flat_map(|msg| self.render_message(msg)) .count(); - + self.scroll_offset = (self.scroll_offset + lines).min(total_lines.saturating_sub(1)); } else { self.browse_mode = true; @@ -568,7 +577,7 @@ impl ChatView { pub fn scroll_down(&mut self, lines: usize) { if self.scroll_offset > 0 { self.scroll_offset = self.scroll_offset.saturating_sub(lines); - + if self.scroll_offset == 0 && self.browse_mode { self.browse_mode = false; self.auto_scroll = true; @@ -577,11 +586,13 @@ impl ChatView { } pub fn scroll_to_top(&mut self) { - let total_lines: usize = self.session.messages + let total_lines: usize = self + .session + .messages .iter() .flat_map(|msg| self.render_message(msg)) .count(); - + self.browse_mode = true; self.auto_scroll = false; self.scroll_offset = total_lines.saturating_sub(1); @@ -593,4 +604,3 @@ impl ChatView { self.scroll_offset = 0; } } - diff --git a/src/apps/cli/src/ui/markdown.rs b/src/apps/cli/src/ui/markdown.rs index b46791f8..7dd99fb8 100644 --- a/src/apps/cli/src/ui/markdown.rs +++ b/src/apps/cli/src/ui/markdown.rs @@ -1,12 +1,11 @@ /// Markdown rendering utilities - -use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, HeadingLevel}; +use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; use ratatui::{ style::{Modifier, Style}, text::{Line, Span}, }; -use super::theme::{Theme, StyleKind}; +use super::theme::{StyleKind, Theme}; /// Markdown renderer pub struct MarkdownRenderer { @@ -18,20 +17,20 @@ impl MarkdownRenderer { pub fn new(theme: Theme) -> Self { Self { theme } } - + pub fn render(&self, markdown: &str, _width: usize) -> Vec> { let mut lines = Vec::new(); let mut current_line_spans: Vec> = Vec::new(); - + // Style stack let mut style_stack: Vec = Vec::new(); let mut list_level: usize = 0; let mut in_code_block = false; let mut code_block_lang = String::new(); - + let options = Options::all(); let parser = Parser::new_ext(markdown, options); - + for event in parser { match event { Event::Start(tag) => { @@ -42,7 +41,7 @@ impl MarkdownRenderer { lines.push(Line::from(std::mem::take(&mut current_line_spans))); } lines.push(Line::from("")); - + // Heading prefix let prefix = match level { HeadingLevel::H1 => "# ", @@ -54,9 +53,11 @@ impl MarkdownRenderer { }; current_line_spans.push(Span::styled( prefix.to_string(), - self.theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD) + self.theme + .style(StyleKind::Primary) + .add_modifier(Modifier::BOLD), )); - + style_stack.push(StyleModifier::Heading); } Tag::Paragraph => { @@ -65,7 +66,7 @@ impl MarkdownRenderer { Tag::BlockQuote(_) => { current_line_spans.push(Span::styled( "│ ".to_string(), - self.theme.style(StyleKind::Muted) + self.theme.style(StyleKind::Muted), )); style_stack.push(StyleModifier::Quote); } @@ -79,12 +80,12 @@ impl MarkdownRenderer { lines.push(Line::from(std::mem::take(&mut current_line_spans))); } lines.push(Line::from("")); - + // Add language identifier if !code_block_lang.is_empty() { current_line_spans.push(Span::styled( format!("```{}", code_block_lang), - self.theme.style(StyleKind::Muted) + self.theme.style(StyleKind::Muted), )); lines.push(Line::from(std::mem::take(&mut current_line_spans))); } @@ -104,7 +105,7 @@ impl MarkdownRenderer { current_line_spans.push(Span::raw(indent)); current_line_spans.push(Span::styled( "• ".to_string(), - self.theme.style(StyleKind::Primary) + self.theme.style(StyleKind::Primary), )); } Tag::Strong => { @@ -119,13 +120,13 @@ impl MarkdownRenderer { Tag::Image { .. } => { current_line_spans.push(Span::styled( "[Image]".to_string(), - self.theme.style(StyleKind::Info) + self.theme.style(StyleKind::Info), )); } _ => {} } } - + Event::End(tag_end) => { match tag_end { TagEnd::Heading(_) => { @@ -157,7 +158,7 @@ impl MarkdownRenderer { if !code_block_lang.is_empty() { lines.push(Line::from(Span::styled( "```".to_string(), - self.theme.style(StyleKind::Muted) + self.theme.style(StyleKind::Muted), ))); code_block_lang.clear(); } @@ -194,17 +195,14 @@ impl MarkdownRenderer { _ => {} } } - + Event::Text(text) => { let style = self.compute_style(&style_stack, in_code_block); - + if in_code_block { // Code block: process each line separately for line in text.lines() { - current_line_spans.push(Span::styled( - format!(" {}", line), - style - )); + current_line_spans.push(Span::styled(format!(" {}", line), style)); lines.push(Line::from(std::mem::take(&mut current_line_spans))); } } else { @@ -212,76 +210,83 @@ impl MarkdownRenderer { current_line_spans.push(Span::styled(text.to_string(), style)); } } - + Event::Code(code) => { // Inline code current_line_spans.push(Span::styled( format!("`{}`", code), - self.theme.style(StyleKind::Success) + self.theme.style(StyleKind::Success), )); } - + Event::SoftBreak | Event::HardBreak => { if !in_code_block && !current_line_spans.is_empty() { lines.push(Line::from(std::mem::take(&mut current_line_spans))); } } - + Event::Rule => { lines.push(Line::from(Span::styled( "─".repeat(60), - self.theme.style(StyleKind::Muted) + self.theme.style(StyleKind::Muted), ))); } - + _ => {} } } - + // Process remaining spans if !current_line_spans.is_empty() { lines.push(Line::from(current_line_spans)); } - + // Remove trailing empty lines - while lines.last().map_or(false, |line| line.spans.is_empty() || - (line.spans.len() == 1 && line.spans[0].content.is_empty())) { + while lines.last().map_or(false, |line| { + line.spans.is_empty() || (line.spans.len() == 1 && line.spans[0].content.is_empty()) + }) { lines.pop(); } - + lines } - + fn compute_style(&self, stack: &[StyleModifier], in_code_block: bool) -> Style { let mut style = Style::default(); - + if in_code_block { return self.theme.style(StyleKind::Success); } - + for modifier in stack { style = match modifier { StyleModifier::Bold => style.add_modifier(Modifier::BOLD), StyleModifier::Italic => style.add_modifier(Modifier::ITALIC), - StyleModifier::Heading => self.theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + StyleModifier::Heading => self + .theme + .style(StyleKind::Primary) + .add_modifier(Modifier::BOLD), StyleModifier::Quote => style.fg(self.theme.muted), - StyleModifier::Link => self.theme.style(StyleKind::Info).add_modifier(Modifier::UNDERLINED), + StyleModifier::Link => self + .theme + .style(StyleKind::Info) + .add_modifier(Modifier::UNDERLINED), }; } - + style } - + pub fn has_markdown_syntax(text: &str) -> bool { - text.contains("**") || - text.contains("__") || - text.contains("*") || - text.contains("_") || - text.contains("`") || - text.contains("#") || - text.contains("[") || - text.contains(">") || - text.contains("```") + text.contains("**") + || text.contains("__") + || text.contains("*") + || text.contains("_") + || text.contains("`") + || text.contains("#") + || text.contains("[") + || text.contains(">") + || text.contains("```") } } @@ -298,7 +303,7 @@ enum StyleModifier { #[cfg(test)] mod tests { use super::*; - + #[test] fn test_has_markdown_syntax() { assert!(MarkdownRenderer::has_markdown_syntax("**bold**")); @@ -307,7 +312,7 @@ mod tests { assert!(MarkdownRenderer::has_markdown_syntax("# Title")); assert!(!MarkdownRenderer::has_markdown_syntax("plain text")); } - + #[test] fn test_render_simple() { let theme = Theme::default(); @@ -315,7 +320,7 @@ mod tests { let lines = renderer.render("**bold** text", 80); assert!(!lines.is_empty()); } - + #[test] fn test_render_code_block() { let theme = Theme::default(); diff --git a/src/apps/cli/src/ui/mod.rs b/src/apps/cli/src/ui/mod.rs index 57f7559a..90e1b56e 100644 --- a/src/apps/cli/src/ui/mod.rs +++ b/src/apps/cli/src/ui/mod.rs @@ -1,14 +1,13 @@ /// TUI interface module -/// +/// /// Build terminal user interface using ratatui - pub mod chat; -pub mod theme; -pub mod widgets; +pub mod markdown; pub mod startup; -pub mod tool_cards; pub mod string_utils; -pub mod markdown; +pub mod theme; +pub mod tool_cards; +pub mod widgets; use anyhow::Result; use crossterm::{ @@ -44,7 +43,10 @@ pub fn restore_terminal(mut terminal: Terminal>) -> } /// Render a loading/status message on the terminal (stays in alternate screen) -pub fn render_loading(terminal: &mut Terminal>, message: &str) -> Result<()> { +pub fn render_loading( + terminal: &mut Terminal>, + message: &str, +) -> Result<()> { let msg = message.to_string(); terminal.draw(|frame| { let area = frame.area(); @@ -57,14 +59,12 @@ pub fn render_loading(terminal: &mut Terminal>, mes ]) .split(area); - let text = vec![ - Line::from(Span::styled( - msg, - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )), - ]; + let text = vec![Line::from(Span::styled( + msg, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))]; let paragraph = Paragraph::new(text).alignment(Alignment::Center); frame.render_widget(paragraph, chunks[1]); diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 6f015670..d1cea763 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1,5 +1,4 @@ /// Startup page module - use anyhow::Result; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; use ratatui::{ @@ -138,10 +137,10 @@ struct SessionItem { impl StartupPage { pub fn new() -> Self { let config = CliConfig::load().unwrap_or_default(); - + let mut list_state = ListState::default(); list_state.select(Some(0)); - + let menu_items = vec![ MenuItem { name: "New Session".to_string(), @@ -169,7 +168,7 @@ impl StartupPage { action: MenuAction::Exit, }, ]; - + Self { menu_items, selected: 0, @@ -181,11 +180,11 @@ impl StartupPage { pub fn run(&mut self, terminal: &mut Terminal) -> Result> { terminal.clear()?; - + loop { terminal.draw(|f| self.render(f))?; - // Check if finished + // Check if finished if let PageState::Finished(result) = &self.page_state { return match result { StartupResult::NewSession(ws) => Ok(Some(ws.clone())), @@ -205,8 +204,8 @@ impl StartupPage { if event::poll(Duration::from_millis(100))? { match event::read()? { Event::Key(key) => { - self.handle_key(key)?; - } + self.handle_key(key)?; + } Event::Resize(_, _) => { terminal.clear()?; } @@ -235,8 +234,8 @@ impl StartupPage { .direction(Direction::Vertical) .constraints([ Constraint::Length(12), // Logo area - Constraint::Min(10), // Menu area - Constraint::Length(3), // Hints area + Constraint::Min(10), // Menu area + Constraint::Length(3), // Hints area ]) .split(area); @@ -251,7 +250,7 @@ impl StartupPage { .map(|(i, item)| { let is_selected = i == self.selected; let icon = if is_selected { "▶" } else { " " }; - + let style = if is_selected { Style::default() .fg(Color::Cyan) @@ -277,14 +276,13 @@ impl StartupPage { }) .collect(); - let list = List::new(items) - .block( - Block::default() - .borders(Borders::ALL) - .title(" BitFun CLI - Main Menu ") - .title_alignment(Alignment::Center) - .border_style(Style::default().fg(Color::Cyan)), - ); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .title(" BitFun CLI - Main Menu ") + .title_alignment(Alignment::Center) + .border_style(Style::default().fg(Color::Cyan)), + ); frame.render_stateful_widget(list, chunks[1], &mut self.list_state); @@ -309,7 +307,7 @@ impl StartupPage { let use_fancy_logo = area.width >= 80; let mut lines = vec![]; lines.push(Line::from("")); - + if use_fancy_logo { let logo = vec![ " ██████╗ ██╗████████╗███████╗██╗ ██╗███╗ ██╗", @@ -371,7 +369,7 @@ impl StartupPage { .fg(Color::Gray) .add_modifier(Modifier::ITALIC), ))); - + let version = format!("v{}", env!("CARGO_PKG_VERSION")); lines.push(Line::from(Span::styled( version, @@ -382,20 +380,29 @@ impl StartupPage { frame.render_widget(paragraph, area); } - fn render_workspace_select(&mut self, frame: &mut Frame, area: Rect, page: &WorkspaceSelectPage) { + fn render_workspace_select( + &mut self, + frame: &mut Frame, + area: Rect, + page: &WorkspaceSelectPage, + ) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Title - Constraint::Length(3), // Input box - Constraint::Min(5), // Help - Constraint::Length(5), // Hints + Constraint::Length(3), // Title + Constraint::Length(3), // Input box + Constraint::Min(5), // Help + Constraint::Length(5), // Hints ]) .split(area); // Title let title = Paragraph::new("Enter workspace path") - .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) .alignment(Alignment::Center) .block(Block::default().borders(Borders::ALL)); frame.render_widget(title, chunks[0]); @@ -406,27 +413,35 @@ impl StartupPage { } else { &page.custom_input }; - + let input_style = if page.custom_input.is_empty() { Style::default().fg(Color::DarkGray) } else { - Style::default().fg(Color::Yellow).add_modifier(Modifier::UNDERLINED) + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::UNDERLINED) }; - - let input = Paragraph::new(input_display) - .style(input_style) - .block(Block::default().borders(Borders::ALL).title(" Workspace Path ").border_style(Style::default().fg(Color::Yellow))); + + let input = Paragraph::new(input_display).style(input_style).block( + Block::default() + .borders(Borders::ALL) + .title(" Workspace Path ") + .border_style(Style::default().fg(Color::Yellow)), + ); frame.render_widget(input, chunks[1]); // Help let help_lines = vec![ Line::from(""), - Line::from(vec![ - Span::styled("Tips:", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), - ]), - Line::from(vec![ - Span::raw(" • You can enter relative or absolute path"), - ]), + Line::from(vec![Span::styled( + "Tips:", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )]), + Line::from(vec![Span::raw( + " • You can enter relative or absolute path", + )]), Line::from(vec![ Span::raw(" • Use "), Span::styled(".", Style::default().fg(Color::Green)), @@ -442,9 +457,9 @@ impl StartupPage { Span::styled("~", Style::default().fg(Color::Green)), Span::raw(" for home directory (e.g.: ~/projects)"), ]), - Line::from(vec![ - Span::raw(" • Leave empty and press Enter for current directory"), - ]), + Line::from(vec![Span::raw( + " • Leave empty and press Enter for current directory", + )]), ]; let help = Paragraph::new(help_lines) .style(Style::default().fg(Color::Gray)) @@ -461,11 +476,12 @@ impl StartupPage { Span::styled(" Backspace ", Style::default().fg(Color::Yellow)), Span::raw("Delete"), ]), - Line::from(vec![ - Span::styled(" Type characters... ", Style::default().fg(Color::DarkGray)), - ]), + Line::from(vec![Span::styled( + " Type characters... ", + Style::default().fg(Color::DarkGray), + )]), ]; - + let paragraph = Paragraph::new(hints_text) .alignment(Alignment::Center) .style(Style::default().fg(Color::Gray)); @@ -477,15 +493,19 @@ impl StartupPage { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Title - Constraint::Min(10), // Settings list - Constraint::Length(5), // Hints + Constraint::Length(3), // Title + Constraint::Min(10), // Settings list + Constraint::Length(5), // Hints ]) .split(area); // Title let title = Paragraph::new("Settings") - .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) .alignment(Alignment::Center) .block(Block::default().borders(Borders::ALL)); frame.render_widget(title, chunks[0]); @@ -498,17 +518,21 @@ impl StartupPage { .map(|(i, setting)| { let is_selected = i == page.selected; let is_editing = page.editing == Some(i); - + let icon = if is_selected { "▶" } else { " " }; - + let style = if is_selected { - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; let value_style = if is_editing { - Style::default().fg(Color::Yellow).add_modifier(Modifier::UNDERLINED) + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::UNDERLINED) } else if setting.editable { Style::default().fg(Color::Green) } else { @@ -543,8 +567,11 @@ impl StartupPage { let mut list_state = ListState::default(); list_state.select(Some(page.selected)); - let list = List::new(items) - .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Cyan))); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ); frame.render_stateful_widget(list, chunks[1], &mut list_state); @@ -557,9 +584,10 @@ impl StartupPage { Span::styled(" Esc ", Style::default().fg(Color::Red)), Span::raw("Cancel"), ]), - Line::from(vec![ - Span::styled(" Enter new value... ", Style::default().fg(Color::Yellow)), - ]), + Line::from(vec![Span::styled( + " Enter new value... ", + Style::default().fg(Color::Yellow), + )]), ] } else { vec![ @@ -571,9 +599,10 @@ impl StartupPage { Span::styled(" Esc ", Style::default().fg(Color::Red)), Span::raw("Back"), ]), - Line::from(vec![ - Span::styled(" Changes will be auto-saved to config file ", Style::default().fg(Color::DarkGray)), - ]), + Line::from(vec![Span::styled( + " Changes will be auto-saved to config file ", + Style::default().fg(Color::DarkGray), + )]), ] }; @@ -588,16 +617,20 @@ impl StartupPage { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Title - Constraint::Min(10), // Session list - Constraint::Length(3), // Hints + Constraint::Length(3), // Title + Constraint::Min(10), // Session list + Constraint::Length(3), // Hints ]) .split(area); // Title let title_text = format!("History Sessions (total {})", page.sessions.len()); let title = Paragraph::new(title_text) - .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) .alignment(Alignment::Center) .block(Block::default().borders(Borders::ALL)); frame.render_widget(title, chunks[0]); @@ -608,7 +641,9 @@ impl StartupPage { Line::from(""), Line::from(Span::styled( "No history sessions yet", - Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC), + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::ITALIC), )), Line::from(""), Line::from(Span::styled( @@ -618,7 +653,11 @@ impl StartupPage { ]; let paragraph = Paragraph::new(empty_text) .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Cyan))); + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ); frame.render_widget(paragraph, chunks[1]); } else { // Session list @@ -629,9 +668,11 @@ impl StartupPage { .map(|(i, session)| { let is_selected = i == page.selected; let icon = if is_selected { "▶" } else { " " }; - + let style = if is_selected { - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; @@ -664,8 +705,11 @@ impl StartupPage { let mut list_state = ListState::default(); list_state.select(Some(page.selected)); - let list = List::new(items) - .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Cyan))); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ); frame.render_stateful_widget(list, chunks[1], &mut list_state); } @@ -691,16 +735,20 @@ impl StartupPage { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Title - Constraint::Min(10), // Model list - Constraint::Length(5), // Hints + Constraint::Length(3), // Title + Constraint::Min(10), // Model list + Constraint::Length(5), // Hints ]) .split(area); // Title let title_text = format!("AI Model Configuration (total {})", page.models.len()); let title = Paragraph::new(title_text) - .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) .alignment(Alignment::Center) .block(Block::default().borders(Borders::ALL)); frame.render_widget(title, chunks[0]); @@ -711,7 +759,9 @@ impl StartupPage { Line::from(""), Line::from(Span::styled( "No models configured yet", - Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC), + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::ITALIC), )), Line::from(""), Line::from(Span::styled( @@ -721,7 +771,11 @@ impl StartupPage { ]; let paragraph = Paragraph::new(empty_text) .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Cyan))); + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ); frame.render_widget(paragraph, chunks[1]); } else { // Model list @@ -732,9 +786,11 @@ impl StartupPage { .map(|(i, model)| { let is_selected = i == page.selected; let icon = if is_selected { "▶" } else { " " }; - + let style = if is_selected { - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; @@ -752,7 +808,14 @@ impl StartupPage { Line::from(vec![ Span::styled(icon, Style::default().fg(Color::Green)), Span::raw(" "), - Span::styled(status_icon, Style::default().fg(if model.is_default { Color::Yellow } else { Color::Green })), + Span::styled( + status_icon, + Style::default().fg(if model.is_default { + Color::Yellow + } else { + Color::Green + }), + ), Span::raw(" "), Span::styled(&model.name, style), ]), @@ -774,8 +837,11 @@ impl StartupPage { let mut list_state = ListState::default(); list_state.select(Some(page.selected)); - let list = List::new(items) - .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Cyan))); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ); frame.render_stateful_widget(list, chunks[1], &mut list_state); } @@ -816,15 +882,21 @@ impl StartupPage { return Ok(()); } - let page_state = std::mem::replace(&mut self.page_state, PageState::Finished(StartupResult::Exit)); - + let page_state = std::mem::replace( + &mut self.page_state, + PageState::Finished(StartupResult::Exit), + ); + let result = match page_state { PageState::MainMenu => { self.page_state = PageState::MainMenu; self.handle_main_menu_key(key) } PageState::WorkspaceSelect(mut page) => { - let old_state = std::mem::replace(&mut self.page_state, PageState::Finished(StartupResult::Exit)); + let old_state = std::mem::replace( + &mut self.page_state, + PageState::Finished(StartupResult::Exit), + ); let result = self.handle_workspace_key(key, &mut page); if matches!(self.page_state, PageState::Finished(StartupResult::Exit)) { if !matches!(old_state, PageState::Finished(_)) { @@ -836,7 +908,10 @@ impl StartupPage { result } PageState::Settings(mut page) => { - let old_state = std::mem::replace(&mut self.page_state, PageState::Finished(StartupResult::Exit)); + let old_state = std::mem::replace( + &mut self.page_state, + PageState::Finished(StartupResult::Exit), + ); let result = self.handle_settings_key(key, &mut page); if matches!(self.page_state, PageState::Finished(StartupResult::Exit)) { if !matches!(old_state, PageState::Finished(_)) { @@ -848,7 +923,10 @@ impl StartupPage { result } PageState::AIModels(mut page) => { - let old_state = std::mem::replace(&mut self.page_state, PageState::Finished(StartupResult::Exit)); + let old_state = std::mem::replace( + &mut self.page_state, + PageState::Finished(StartupResult::Exit), + ); let result = self.handle_ai_models_key(key, &mut page); if matches!(self.page_state, PageState::Finished(StartupResult::Exit)) { if !matches!(old_state, PageState::Finished(_)) { @@ -860,7 +938,10 @@ impl StartupPage { result } PageState::History(mut page) => { - let old_state = std::mem::replace(&mut self.page_state, PageState::Finished(StartupResult::Exit)); + let old_state = std::mem::replace( + &mut self.page_state, + PageState::Finished(StartupResult::Exit), + ); let result = self.handle_history_key(key, &mut page); if matches!(self.page_state, PageState::Finished(StartupResult::Exit)) { if !matches!(old_state, PageState::Finished(_)) { @@ -876,7 +957,7 @@ impl StartupPage { Ok(()) } }; - + result } @@ -907,7 +988,8 @@ impl StartupPage { MenuAction::ContinueLastSession => { // Load last session if let Ok(Some(session)) = Session::get_last() { - self.page_state = PageState::Finished(StartupResult::ContinueSession(session.id)); + self.page_state = + PageState::Finished(StartupResult::ContinueSession(session.id)); } else { // No history session, enter new session self.page_state = PageState::WorkspaceSelect(WorkspaceSelectPage { @@ -947,7 +1029,11 @@ impl StartupPage { Ok(()) } - fn handle_workspace_key(&mut self, key: KeyEvent, page: &mut WorkspaceSelectPage) -> Result<()> { + fn handle_workspace_key( + &mut self, + key: KeyEvent, + page: &mut WorkspaceSelectPage, + ) -> Result<()> { match key.code { KeyCode::Enter => { // If input is empty, use current directory @@ -1000,18 +1086,21 @@ impl StartupPage { } Ok(()) } - + fn expand_path(&self, path: &str) -> String { let path = path.trim(); - + // Handle paths starting with ~ if path.starts_with('~') { if let Some(home) = dirs::home_dir() { let rest = &path[1..]; - return home.join(rest.trim_start_matches('/')).to_string_lossy().to_string(); + return home + .join(rest.trim_start_matches('/')) + .to_string_lossy() + .to_string(); } } - + // Handle relative and absolute paths if let Ok(absolute) = std::fs::canonicalize(path) { absolute.to_string_lossy().to_string() @@ -1058,11 +1147,12 @@ impl StartupPage { KeyCode::Enter => { if page.settings[page.selected].key == "ai_models" { let models = Self::load_ai_models_sync(); - let default_model_id = models.iter() + let default_model_id = models + .iter() .find(|m| m.is_default) .map(|m| m.id.clone()) .unwrap_or_default(); - + self.page_state = PageState::AIModels(AIModelsPage { models, selected: 0, @@ -1130,20 +1220,27 @@ impl StartupPage { let selected_model_id = page.models[page.selected].id.clone(); let result = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { - use bitfun_core::service::config::GlobalConfigManager; use bitfun_core::service::config::types::GlobalConfig; - + use bitfun_core::service::config::GlobalConfigManager; + match GlobalConfigManager::get_service().await { Ok(config_service) => { - let mut global_config = config_service.get_config::(None).await?; - global_config.ai.default_models.primary = Some(selected_model_id.clone()); - config_service.set_config("ai.default_models.primary", &global_config.ai.default_models.primary).await + let mut global_config = + config_service.get_config::(None).await?; + global_config.ai.default_models.primary = + Some(selected_model_id.clone()); + config_service + .set_config( + "ai.default_models.primary", + &global_config.ai.default_models.primary, + ) + .await } Err(e) => Err(e), } }) }); - + if result.is_ok() { page.models = Self::load_ai_models_sync(); page.default_model_id = selected_model_id; @@ -1172,7 +1269,8 @@ impl StartupPage { key: "ai_models".to_string(), name: "AI Model Configuration".to_string(), value: "Manage AI models".to_string(), - description: "View and manage all AI model configurations (press Enter to enter)".to_string(), + description: "View and manage all AI model configurations (press Enter to enter)" + .to_string(), editable: false, // Not directly editable, enters sub-page }, SettingItem { @@ -1207,41 +1305,41 @@ impl StartupPage { } async fn load_ai_models() -> Vec { - use bitfun_core::service::config::GlobalConfigManager; use bitfun_core::service::config::types::GlobalConfig; - + use bitfun_core::service::config::GlobalConfigManager; + match GlobalConfigManager::get_service().await { - Ok(config_service) => { - match config_service.get_config::(None).await { - Ok(global_config) => { - let default_model_id = global_config.ai.default_models.primary - .unwrap_or_default(); - - global_config.ai.models - .iter() - .map(|m| AIModelItem { - id: m.id.clone(), - name: m.name.clone(), - provider: m.provider.clone(), - model_name: m.model_name.clone(), - enabled: m.enabled, - is_default: m.id == default_model_id, - }) - .collect() - } - Err(e) => { - tracing::warn!("Failed to get GlobalConfig: {}", e); - vec![] - } + Ok(config_service) => match config_service.get_config::(None).await { + Ok(global_config) => { + let default_model_id = + global_config.ai.default_models.primary.unwrap_or_default(); + + global_config + .ai + .models + .iter() + .map(|m| AIModelItem { + id: m.id.clone(), + name: m.name.clone(), + provider: m.provider.clone(), + model_name: m.model_name.clone(), + enabled: m.enabled, + is_default: m.id == default_model_id, + }) + .collect() } - } + Err(e) => { + tracing::warn!("Failed to get GlobalConfig: {}", e); + vec![] + } + }, Err(e) => { tracing::warn!("Failed to get config service: {}", e); vec![] } } } - + fn load_ai_models_sync() -> Vec { tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(Self::load_ai_models()) @@ -1280,7 +1378,7 @@ impl StartupPage { "ai_models" => {} _ => {} } - + self.config.save()?; Ok(()) } diff --git a/src/apps/cli/src/ui/string_utils.rs b/src/apps/cli/src/ui/string_utils.rs index 47635076..25553732 100644 --- a/src/apps/cli/src/ui/string_utils.rs +++ b/src/apps/cli/src/ui/string_utils.rs @@ -3,32 +3,32 @@ /// Safely truncate string to specified byte length pub fn truncate_str(s: &str, max_bytes: usize) -> String { let first_line = s.lines().next().unwrap_or(""); - + if first_line.len() <= max_bytes { return first_line.to_string(); } - + let mut boundary = max_bytes; while boundary > 0 && !first_line.is_char_boundary(boundary) { boundary -= 1; } - + if boundary == 0 { return String::new(); } - + format!("{}...", &first_line[..boundary]) } /// Prettify tool result display pub fn prettify_result(s: &str) -> String { let first_line = s.lines().next().unwrap_or(""); - - let looks_like_debug = first_line.contains("Some(") + + let looks_like_debug = first_line.contains("Some(") || first_line.contains(": None") || (first_line.matches('{').count() > 2) || first_line.contains("_tokens:"); - + if looks_like_debug { if s.contains("Success") || s.contains("Ok") { return "✓ Execution successful".to_string(); @@ -36,6 +36,6 @@ pub fn prettify_result(s: &str) -> String { return "Done".to_string(); } } - + truncate_str(s, 80) } diff --git a/src/apps/cli/src/ui/theme.rs b/src/apps/cli/src/ui/theme.rs index b204daed..688acd34 100644 --- a/src/apps/cli/src/ui/theme.rs +++ b/src/apps/cli/src/ui/theme.rs @@ -1,5 +1,4 @@ /// Theme and style definitions - use ratatui::style::{Color, Modifier, Style}; #[derive(Debug, Clone)] @@ -17,14 +16,14 @@ pub struct Theme { impl Theme { pub fn dark() -> Self { Self { - primary: Color::Rgb(59, 130, 246), // blue - success: Color::Rgb(34, 197, 94), // green - warning: Color::Rgb(251, 191, 36), // yellow - error: Color::Rgb(239, 68, 68), // red - info: Color::Rgb(147, 197, 253), // light blue - muted: Color::Rgb(156, 163, 175), // gray - background: Color::Rgb(17, 24, 39), // dark gray background - border: Color::Rgb(55, 65, 81), // border gray + primary: Color::Rgb(59, 130, 246), // blue + success: Color::Rgb(34, 197, 94), // green + warning: Color::Rgb(251, 191, 36), // yellow + error: Color::Rgb(239, 68, 68), // red + info: Color::Rgb(147, 197, 253), // light blue + muted: Color::Rgb(156, 163, 175), // gray + background: Color::Rgb(17, 24, 39), // dark gray background + border: Color::Rgb(55, 65, 81), // border gray } } diff --git a/src/apps/cli/src/ui/tool_cards.rs b/src/apps/cli/src/ui/tool_cards.rs index bfeb9a0d..239f831b 100644 --- a/src/apps/cli/src/ui/tool_cards.rs +++ b/src/apps/cli/src/ui/tool_cards.rs @@ -1,31 +1,29 @@ /// Tool card rendering - use ratatui::{ text::{Line, Span}, widgets::ListItem, }; +use super::string_utils::{prettify_result, truncate_str}; +use super::theme::{StyleKind, Theme}; use crate::session::ToolCall; -use super::theme::{Theme, StyleKind}; -use super::string_utils::{truncate_str, prettify_result}; -pub fn render_tool_card<'a>( - tool_call: &'a ToolCall, - theme: &Theme, -) -> Vec> { +pub fn render_tool_card<'a>(tool_call: &'a ToolCall, theme: &Theme) -> Vec> { let mut items = Vec::new(); - + // Choose specialized renderer based on tool type match tool_call.tool_name.as_str() { "read_file" | "read_file_tool" => render_read_file_card(&mut items, tool_call, theme), - "write_file" | "write_file_tool" | "search_replace" => render_write_file_card(&mut items, tool_call, theme), + "write_file" | "write_file_tool" | "search_replace" => { + render_write_file_card(&mut items, tool_call, theme) + } "bash_tool" | "run_terminal_cmd" => render_bash_tool_card(&mut items, tool_call, theme), "codebase_search" => render_codebase_search_card(&mut items, tool_call, theme), "grep" => render_grep_card(&mut items, tool_call, theme), "list_dir" | "ls" => render_list_dir_card(&mut items, tool_call, theme), _ => render_default_tool_card(&mut items, tool_call, theme), } - + items } @@ -35,21 +33,25 @@ fn render_read_file_card<'a>( theme: &Theme, ) { use crate::session::ToolCallStatus; - + // Get file path - let file_path = tool_call.parameters.get("file_path") + let file_path = tool_call + .parameters + .get("file_path") .or_else(|| tool_call.parameters.get("target_file")) .and_then(|v| v.as_str()) .unwrap_or("unknown"); - + // Status icon let (status_icon, status_style) = match &tool_call.status { - ToolCallStatus::Running | ToolCallStatus::Streaming => ("*", theme.style(StyleKind::Primary)), + ToolCallStatus::Running | ToolCallStatus::Streaming => { + ("*", theme.style(StyleKind::Primary)) + } ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), _ => ("-", theme.style(StyleKind::Muted)), }; - + // Top border items.push(ListItem::new(Line::from(vec![ Span::raw(" ┌─ "), @@ -58,17 +60,17 @@ fn render_read_file_card<'a>( Span::raw(" "), Span::styled(status_icon, status_style), ]))); - + // File path items.push(ListItem::new(Line::from(vec![ Span::raw(" │ "), Span::styled(file_path, theme.style(StyleKind::Primary)), ]))); - + // Result (if available) if let Some(result) = &tool_call.result { let summary = truncate_str(result, 80); - + items.push(ListItem::new(Line::from(vec![ Span::raw(" └─ "), Span::styled(summary, theme.style(StyleKind::Muted)), @@ -87,19 +89,21 @@ fn render_write_file_card<'a>( theme: &Theme, ) { use crate::session::ToolCallStatus; - - let file_path = tool_call.parameters.get("file_path") + + let file_path = tool_call + .parameters + .get("file_path") .or_else(|| tool_call.parameters.get("target_file")) .and_then(|v| v.as_str()) .unwrap_or("unknown"); - + let (status_icon, status_style) = match &tool_call.status { ToolCallStatus::Running => ("*", theme.style(StyleKind::Primary)), ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), _ => ("-", theme.style(StyleKind::Muted)), }; - + items.push(ListItem::new(Line::from(vec![ Span::raw(" ┌─ "), Span::raw("[Edit] "), @@ -107,12 +111,12 @@ fn render_write_file_card<'a>( Span::raw(" "), Span::styled(status_icon, status_style), ]))); - + items.push(ListItem::new(Line::from(vec![ Span::raw(" │ "), Span::styled(file_path, theme.style(StyleKind::Primary)), ]))); - + if let Some(result) = &tool_call.result { items.push(ListItem::new(Line::from(vec![ Span::raw(" └─ "), @@ -132,18 +136,22 @@ fn render_bash_tool_card<'a>( theme: &Theme, ) { use crate::session::ToolCallStatus; - - let command = tool_call.parameters.get("command") + + let command = tool_call + .parameters + .get("command") .and_then(|v| v.as_str()) .unwrap_or("unknown"); - + let (status_icon, status_style) = match &tool_call.status { - ToolCallStatus::Running | ToolCallStatus::Streaming => ("*", theme.style(StyleKind::Primary)), + ToolCallStatus::Running | ToolCallStatus::Streaming => { + ("*", theme.style(StyleKind::Primary)) + } ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), _ => ("-", theme.style(StyleKind::Muted)), }; - + items.push(ListItem::new(Line::from(vec![ Span::raw(" ┌─ "), Span::raw("[Bash] "), @@ -151,15 +159,15 @@ fn render_bash_tool_card<'a>( Span::raw(" "), Span::styled(status_icon, status_style), ]))); - + // Command (limited length) let cmd_display = truncate_str(command, 60); - + items.push(ListItem::new(Line::from(vec![ Span::raw(" │ "), Span::styled(cmd_display, theme.style(StyleKind::Info)), ]))); - + // Output summary if let Some(result) = &tool_call.result { let lines: Vec<&str> = result.lines().collect(); @@ -168,9 +176,9 @@ fn render_bash_tool_card<'a>( } else { result.clone() }; - + let summary_short = truncate_str(&summary, 80); - + items.push(ListItem::new(Line::from(vec![ Span::raw(" └─ "), Span::styled(summary_short, theme.style(StyleKind::Muted)), @@ -189,18 +197,20 @@ fn render_codebase_search_card<'a>( theme: &Theme, ) { use crate::session::ToolCallStatus; - - let query = tool_call.parameters.get("query") + + let query = tool_call + .parameters + .get("query") .and_then(|v| v.as_str()) .unwrap_or("unknown"); - + let (status_icon, status_style) = match &tool_call.status { ToolCallStatus::Running => ("*", theme.style(StyleKind::Primary)), ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), _ => ("-", theme.style(StyleKind::Muted)), }; - + items.push(ListItem::new(Line::from(vec![ Span::raw(" ┌─ "), Span::raw("[Search] "), @@ -208,12 +218,12 @@ fn render_codebase_search_card<'a>( Span::raw(" "), Span::styled(status_icon, status_style), ]))); - + items.push(ListItem::new(Line::from(vec![ Span::raw(" │ "), Span::styled(query, theme.style(StyleKind::Primary)), ]))); - + if let Some(result) = &tool_call.result { // Try to parse result count let summary = if result.contains("chunk") { @@ -221,7 +231,7 @@ fn render_codebase_search_card<'a>( } else { "Search complete" }; - + items.push(ListItem::new(Line::from(vec![ Span::raw(" └─ "), Span::styled(summary, theme.style(StyleKind::Success)), @@ -234,24 +244,22 @@ fn render_codebase_search_card<'a>( } } -fn render_grep_card<'a>( - items: &mut Vec>, - tool_call: &'a ToolCall, - theme: &Theme, -) { +fn render_grep_card<'a>(items: &mut Vec>, tool_call: &'a ToolCall, theme: &Theme) { use crate::session::ToolCallStatus; - - let pattern = tool_call.parameters.get("pattern") + + let pattern = tool_call + .parameters + .get("pattern") .and_then(|v| v.as_str()) .unwrap_or("unknown"); - + let (status_icon, status_style) = match &tool_call.status { ToolCallStatus::Running => ("*", theme.style(StyleKind::Primary)), ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), _ => ("-", theme.style(StyleKind::Muted)), }; - + items.push(ListItem::new(Line::from(vec![ Span::raw(" ┌─ "), Span::raw("[Grep] "), @@ -259,16 +267,16 @@ fn render_grep_card<'a>( Span::raw(" "), Span::styled(status_icon, status_style), ]))); - + items.push(ListItem::new(Line::from(vec![ Span::raw(" │ "), Span::styled(pattern, theme.style(StyleKind::Primary)), ]))); - + if let Some(result) = &tool_call.result { let lines_count = result.lines().count(); let summary = format!("Found {} matches", lines_count); - + items.push(ListItem::new(Line::from(vec![ Span::raw(" └─ "), Span::styled(summary, theme.style(StyleKind::Success)), @@ -281,25 +289,23 @@ fn render_grep_card<'a>( } } -fn render_list_dir_card<'a>( - items: &mut Vec>, - tool_call: &'a ToolCall, - theme: &Theme, -) { +fn render_list_dir_card<'a>(items: &mut Vec>, tool_call: &'a ToolCall, theme: &Theme) { use crate::session::ToolCallStatus; - - let path = tool_call.parameters.get("target_directory") + + let path = tool_call + .parameters + .get("target_directory") .or_else(|| tool_call.parameters.get("path")) .and_then(|v| v.as_str()) .unwrap_or("."); - + let (status_icon, status_style) = match &tool_call.status { ToolCallStatus::Running => ("*", theme.style(StyleKind::Primary)), ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), _ => ("-", theme.style(StyleKind::Muted)), }; - + items.push(ListItem::new(Line::from(vec![ Span::raw(" ┌─ "), Span::raw("[List] "), @@ -307,16 +313,16 @@ fn render_list_dir_card<'a>( Span::raw(" "), Span::styled(status_icon, status_style), ]))); - + items.push(ListItem::new(Line::from(vec![ Span::raw(" │ "), Span::styled(path, theme.style(StyleKind::Primary)), ]))); - + if let Some(result) = &tool_call.result { let items_count = result.lines().count(); let summary = format!("{} items", items_count); - + items.push(ListItem::new(Line::from(vec![ Span::raw(" └─ "), Span::styled(summary, theme.style(StyleKind::Success)), @@ -335,18 +341,20 @@ fn render_default_tool_card<'a>( theme: &Theme, ) { use crate::session::ToolCallStatus; - + let (icon, _color) = crate::ui::theme::tool_icon(&tool_call.tool_name); - + let (status_icon, status_style) = match &tool_call.status { - ToolCallStatus::Running | ToolCallStatus::Streaming => ("*", theme.style(StyleKind::Primary)), + ToolCallStatus::Running | ToolCallStatus::Streaming => { + ("*", theme.style(StyleKind::Primary)) + } ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), ToolCallStatus::Queued => ("||", theme.style(StyleKind::Muted)), ToolCallStatus::Waiting => ("...", theme.style(StyleKind::Warning)), _ => ("-", theme.style(StyleKind::Muted)), }; - + items.push(ListItem::new(Line::from(vec![ Span::raw(" ┌─ "), Span::raw(icon), @@ -355,7 +363,7 @@ fn render_default_tool_card<'a>( Span::raw(" "), Span::styled(status_icon, status_style), ]))); - + // Show parameter summary (only key fields) let param_summary = extract_key_params(&tool_call.parameters); if !param_summary.is_empty() { @@ -364,7 +372,7 @@ fn render_default_tool_card<'a>( Span::styled(param_summary, theme.style(StyleKind::Info)), ]))); } - + // Progress info if let Some(progress_msg) = &tool_call.progress_message { items.push(ListItem::new(Line::from(vec![ @@ -372,11 +380,11 @@ fn render_default_tool_card<'a>( Span::styled(progress_msg, theme.style(StyleKind::Muted)), ]))); } - + // Result if let Some(result) = &tool_call.result { let summary = prettify_result(result); - + items.push(ListItem::new(Line::from(vec![ Span::raw(" └─ "), Span::styled(summary, theme.style(StyleKind::Muted)), @@ -391,8 +399,16 @@ fn render_default_tool_card<'a>( fn extract_key_params(params: &serde_json::Value) -> String { if let Some(obj) = params.as_object() { - let priority_keys = ["path", "file_path", "target_file", "query", "pattern", "command", "message"]; - + let priority_keys = [ + "path", + "file_path", + "target_file", + "query", + "pattern", + "command", + "message", + ]; + for key in &priority_keys { if let Some(value) = obj.get(*key) { if let Some(s) = value.as_str() { @@ -400,7 +416,7 @@ fn extract_key_params(params: &serde_json::Value) -> String { } } } - + for (_key, value) in obj.iter() { if let Some(s) = value.as_str() { if s.len() < 100 { @@ -409,7 +425,6 @@ fn extract_key_params(params: &serde_json::Value) -> String { } } } - + String::new() } - diff --git a/src/apps/cli/src/ui/widgets.rs b/src/apps/cli/src/ui/widgets.rs index 748c033a..cb3a4f7d 100644 --- a/src/apps/cli/src/ui/widgets.rs +++ b/src/apps/cli/src/ui/widgets.rs @@ -1,5 +1,4 @@ /// Custom TUI widgets - use ratatui::{ style::Style, text::{Line, Span}, @@ -33,7 +32,7 @@ pub struct HelpText { impl HelpText { pub fn render(&self) -> Line<'_> { let mut spans = Vec::new(); - + for (i, (key, desc)) in self.shortcuts.iter().enumerate() { if i > 0 { spans.push(Span::raw(" ")); @@ -41,7 +40,7 @@ impl HelpText { spans.push(Span::styled(format!("[{}]", key), self.style)); spans.push(Span::raw(desc)); } - + Line::from(spans) } } diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index f37d31e7..0bd6f839 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -7,10 +7,12 @@ use std::sync::Arc; use tauri::{AppHandle, State}; use crate::api::app_state::AppState; -use bitfun_core::agentic::tools::image_context::get_image_context; -use bitfun_core::agentic::coordination::{ConversationCoordinator, DialogScheduler, DialogTriggerSource}; +use bitfun_core::agentic::coordination::{ + ConversationCoordinator, DialogScheduler, DialogTriggerSource, +}; use bitfun_core::agentic::core::*; use bitfun_core::agentic::image_analysis::ImageContextData; +use bitfun_core::agentic::tools::image_context::get_image_context; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -48,6 +50,7 @@ pub struct CreateSessionResponse { pub struct StartDialogTurnRequest { pub session_id: String, pub user_input: String, + pub original_user_input: Option, pub agent_type: String, pub workspace_path: Option, pub turn_id: Option, @@ -204,6 +207,7 @@ pub async fn start_dialog_turn( let StartDialogTurnRequest { session_id, user_input, + original_user_input, agent_type, workspace_path, turn_id, @@ -220,6 +224,7 @@ pub async fn start_dialog_turn( .start_dialog_turn_with_image_contexts( session_id, user_input, + original_user_input, resolved_image_contexts, turn_id, agent_type, @@ -233,6 +238,7 @@ pub async fn start_dialog_turn( .submit( session_id, user_input, + original_user_input, turn_id, agent_type, workspace_path, @@ -288,7 +294,10 @@ fn resolve_missing_image_payloads( image.mime_type = stored.mime_type.clone(); } - let mut metadata = image.metadata.take().unwrap_or_else(|| serde_json::json!({})); + let mut metadata = image + .metadata + .take() + .unwrap_or_else(|| serde_json::json!({})); if !metadata.is_object() { metadata = serde_json::json!({ "raw_metadata": metadata }); } diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 489f90e4..548e90cd 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -47,7 +47,9 @@ pub struct AppState { } impl AppState { - pub async fn new_async(token_usage_service: Arc) -> BitFunResult { + pub async fn new_async( + token_usage_service: Arc, + ) -> BitFunResult { let start_time = std::time::Instant::now(); let config_service = config::get_global_config_service().await.map_err(|e| { @@ -66,8 +68,9 @@ impl AppState { }; let workspace_service = Arc::new(workspace::WorkspaceService::new().await?); - let workspace_identity_watch_service = - Arc::new(workspace::WorkspaceIdentityWatchService::new(workspace_service.clone())); + let workspace_identity_watch_service = Arc::new( + workspace::WorkspaceIdentityWatchService::new(workspace_service.clone()), + ); workspace::set_global_workspace_service(workspace_service.clone()); let filesystem_service = Arc::new(filesystem::FileSystemServiceFactory::create_default()); @@ -119,11 +122,12 @@ impl AppState { .map(|workspace| workspace.root_path); if let Some(workspace_path) = initial_workspace_path.clone() { - if let Err(e) = bitfun_core::service::snapshot::initialize_snapshot_manager_for_workspace( - workspace_path.clone(), - None, - ) - .await + if let Err(e) = + bitfun_core::service::snapshot::initialize_snapshot_manager_for_workspace( + workspace_path.clone(), + None, + ) + .await { log::warn!( "Failed to restore snapshot system on startup: path={}, error={}", diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index e5f45338..30b9bffa 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -2,8 +2,10 @@ use crate::api::app_state::AppState; use crate::api::dto::WorkspaceInfoDto; -use bitfun_core::service::workspace::{ScanOptions, WorkspaceInfo, WorkspaceKind, WorkspaceOpenOptions}; use bitfun_core::infrastructure::{file_watcher, FileOperationOptions, SearchMatchType}; +use bitfun_core::service::workspace::{ + ScanOptions, WorkspaceInfo, WorkspaceKind, WorkspaceOpenOptions, +}; use log::{debug, error, info, warn}; use serde::Deserialize; use std::path::Path; @@ -52,6 +54,11 @@ pub struct TestAIConfigConnectionRequest { pub config: bitfun_core::service::config::types::AIModelConfig, } +#[derive(Debug, Deserialize)] +pub struct ListAIModelsByConfigRequest { + pub config: bitfun_core::service::config::types::AIModelConfig, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FixMermaidCodeRequest { @@ -427,6 +434,26 @@ pub async fn test_ai_config_connection( } } +#[tauri::command] +pub async fn list_ai_models_by_config( + request: ListAIModelsByConfigRequest, +) -> Result, String> { + let config_name = request.config.name.clone(); + let ai_config = request + .config + .try_into() + .map_err(|e| format!("Failed to convert configuration: {}", e))?; + let ai_client = bitfun_core::infrastructure::ai::client::AIClient::new(ai_config); + + ai_client.list_models().await.map_err(|e| { + error!( + "Failed to list models for config: name={}, error={}", + config_name, e + ); + format!("Failed to list models: {}", e) + }) +} + #[tauri::command] pub async fn fix_mermaid_code( state: State<'_, AppState>, @@ -599,7 +626,10 @@ pub async fn open_workspace( .sync_watched_workspaces() .await { - warn!("Failed to sync workspace identity watchers after open: {}", e); + warn!( + "Failed to sync workspace identity watchers after open: {}", + e + ); } info!( @@ -621,7 +651,11 @@ pub async fn create_assistant_workspace( state: State<'_, AppState>, _request: CreateAssistantWorkspaceRequest, ) -> Result { - match state.workspace_service.create_assistant_workspace(None).await { + match state + .workspace_service + .create_assistant_workspace(None) + .await + { Ok(workspace_info) => { if let Err(e) = state .workspace_identity_watch_service @@ -667,9 +701,10 @@ pub async fn delete_assistant_workspace( )); } - let assistant_id = workspace_info.assistant_id.clone().ok_or_else(|| { - "Default assistant workspace cannot be deleted".to_string() - })?; + let assistant_id = workspace_info + .assistant_id + .clone() + .ok_or_else(|| "Default assistant workspace cannot be deleted".to_string())?; if !state .workspace_service @@ -738,19 +773,29 @@ pub async fn delete_assistant_workspace( } async fn clear_directory_contents(directory: &Path) -> Result<(), String> { - tokio::fs::create_dir_all(directory) - .await - .map_err(|e| format!("Failed to create workspace directory '{}': {}", directory.display(), e))?; + tokio::fs::create_dir_all(directory).await.map_err(|e| { + format!( + "Failed to create workspace directory '{}': {}", + directory.display(), + e + ) + })?; - let mut entries = tokio::fs::read_dir(directory) - .await - .map_err(|e| format!("Failed to read workspace directory '{}': {}", directory.display(), e))?; + let mut entries = tokio::fs::read_dir(directory).await.map_err(|e| { + format!( + "Failed to read workspace directory '{}': {}", + directory.display(), + e + ) + })?; - while let Some(entry) = entries - .next_entry() - .await - .map_err(|e| format!("Failed to iterate workspace directory '{}': {}", directory.display(), e))? - { + while let Some(entry) = entries.next_entry().await.map_err(|e| { + format!( + "Failed to iterate workspace directory '{}': {}", + directory.display(), + e + ) + })? { let entry_path = entry.path(); let file_type = entry.file_type().await.map_err(|e| { format!( diff --git a/src/apps/desktop/src/api/context_upload_api.rs b/src/apps/desktop/src/api/context_upload_api.rs index 9be133a1..e556269b 100644 --- a/src/apps/desktop/src/api/context_upload_api.rs +++ b/src/apps/desktop/src/api/context_upload_api.rs @@ -1,10 +1,8 @@ //! Temporary Image Storage API use bitfun_core::agentic::tools::image_context::{ - create_image_context_provider as create_core_image_context_provider, - store_image_contexts, - GlobalImageContextProvider, - ImageContextData as CoreImageContextData, + create_image_context_provider as create_core_image_context_provider, store_image_contexts, + GlobalImageContextProvider, ImageContextData as CoreImageContextData, }; use serde::{Deserialize, Serialize}; diff --git a/src/apps/desktop/src/api/dto.rs b/src/apps/desktop/src/api/dto.rs index 1aeca6d6..e7f7cb35 100644 --- a/src/apps/desktop/src/api/dto.rs +++ b/src/apps/desktop/src/api/dto.rs @@ -77,7 +77,10 @@ impl WorkspaceInfoDto { .statistics .as_ref() .map(ProjectStatisticsDto::from_workspace_statistics), - identity: info.identity.as_ref().map(WorkspaceIdentityDto::from_workspace_identity), + identity: info + .identity + .as_ref() + .map(WorkspaceIdentityDto::from_workspace_identity), } } } diff --git a/src/apps/desktop/src/api/image_analysis_api.rs b/src/apps/desktop/src/api/image_analysis_api.rs index 5f18d9ec..14662811 100644 --- a/src/apps/desktop/src/api/image_analysis_api.rs +++ b/src/apps/desktop/src/api/image_analysis_api.rs @@ -93,6 +93,7 @@ pub async fn send_enhanced_message( .submit( request.session_id.clone(), enhanced_message, + Some(request.original_message.clone()), Some(request.dialog_turn_id.clone()), request.agent_type.clone(), None, diff --git a/src/apps/desktop/src/api/mcp_api.rs b/src/apps/desktop/src/api/mcp_api.rs index 41647df0..f4728e2f 100644 --- a/src/apps/desktop/src/api/mcp_api.rs +++ b/src/apps/desktop/src/api/mcp_api.rs @@ -331,14 +331,17 @@ pub async fn fetch_mcp_app_resource( state: State<'_, AppState>, request: FetchMCPAppResourceRequest, ) -> Result { - let mcp_service = state.mcp_service.as_ref() + let mcp_service = state + .mcp_service + .as_ref() .ok_or_else(|| "MCP service not initialized".to_string())?; if !request.resource_uri.starts_with("ui://") { return Err("Resource URI must use ui:// scheme".to_string()); } - let connection = mcp_service.server_manager() + let connection = mcp_service + .server_manager() .get_connection(&request.server_id) .await .ok_or_else(|| format!("MCP server not connected: {}", request.server_id))?; @@ -353,7 +356,8 @@ pub async fn fetch_mcp_app_resource( .into_iter() .map(|c| { // Extract CSP and permissions from _meta.ui (MCP Apps spec path) - let (csp, permissions) = c.meta + let (csp, permissions) = c + .meta .as_ref() .and_then(|meta| meta.ui.as_ref()) .map(|ui| { @@ -363,12 +367,15 @@ pub async fn fetch_mcp_app_resource( frame_domains: core_csp.frame_domains.clone(), base_uri_domains: core_csp.base_uri_domains.clone(), }); - let permissions = ui.permissions.as_ref().map(|core_perm| McpUiResourcePermissions { - camera: core_perm.camera.clone(), - microphone: core_perm.microphone.clone(), - geolocation: core_perm.geolocation.clone(), - clipboard_write: core_perm.clipboard_write.clone(), - }); + let permissions = + ui.permissions + .as_ref() + .map(|core_perm| McpUiResourcePermissions { + camera: core_perm.camera.clone(), + microphone: core_perm.microphone.clone(), + geolocation: core_perm.geolocation.clone(), + clipboard_write: core_perm.clipboard_write.clone(), + }); (csp, permissions) }) .unwrap_or((None, None)); @@ -410,29 +417,50 @@ pub async fn send_mcp_app_message( state: State<'_, AppState>, request: SendMCPAppMessageRequest, ) -> Result { - let mcp_service = state.mcp_service.as_ref() + let mcp_service = state + .mcp_service + .as_ref() .ok_or_else(|| "MCP service not initialized".to_string())?; - let connection = mcp_service.server_manager() + let connection = mcp_service + .server_manager() .get_connection(&request.server_id) .await .ok_or_else(|| format!("MCP server not connected: {}", request.server_id))?; let msg = &request.message; - let method = msg.get("method").and_then(|m| m.as_str()).ok_or_else(|| "Missing method".to_string())?; + let method = msg + .get("method") + .and_then(|m| m.as_str()) + .ok_or_else(|| "Missing method".to_string())?; let id = msg.get("id").cloned(); - let params = msg.get("params").cloned().unwrap_or(serde_json::Value::Null); + let params = msg + .get("params") + .cloned() + .unwrap_or(serde_json::Value::Null); let result_value: serde_json::Value = match method { "tools/call" => { - let name = params.get("name").and_then(|n| n.as_str()).ok_or_else(|| "tools/call: missing name".to_string())?; + let name = params + .get("name") + .and_then(|n| n.as_str()) + .ok_or_else(|| "tools/call: missing name".to_string())?; let arguments = params.get("arguments").cloned(); - let result = connection.call_tool(name, arguments).await.map_err(|e| e.to_string())?; + let result = connection + .call_tool(name, arguments) + .await + .map_err(|e| e.to_string())?; serde_json::to_value(result).map_err(|e| e.to_string())? } "resources/read" => { - let uri = params.get("uri").and_then(|u| u.as_str()).ok_or_else(|| "resources/read: missing uri".to_string())?; - let result = connection.read_resource(uri).await.map_err(|e| e.to_string())?; + let uri = params + .get("uri") + .and_then(|u| u.as_str()) + .ok_or_else(|| "resources/read: missing uri".to_string())?; + let result = connection + .read_resource(uri) + .await + .map_err(|e| e.to_string())?; serde_json::to_value(result).map_err(|e| e.to_string())? } "ping" => { diff --git a/src/apps/desktop/src/api/miniapp_api.rs b/src/apps/desktop/src/api/miniapp_api.rs index d78aa0fa..16295741 100644 --- a/src/apps/desktop/src/api/miniapp_api.rs +++ b/src/apps/desktop/src/api/miniapp_api.rs @@ -1,11 +1,11 @@ //! MiniApp API — Tauri commands for MiniApp CRUD, JS Worker, and dialog. use crate::api::app_state::AppState; +use bitfun_core::infrastructure::events::{emit_global_event, BackendEvent}; use bitfun_core::miniapp::{ - MiniApp, MiniAppAiContext, MiniAppMeta, MiniAppPermissions, MiniAppSource, - InstallResult as CoreInstallResult, + InstallResult as CoreInstallResult, MiniApp, MiniAppAiContext, MiniAppMeta, MiniAppPermissions, + MiniAppSource, }; -use bitfun_core::infrastructure::events::{emit_global_event, BackendEvent}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::path::PathBuf; @@ -481,18 +481,21 @@ pub async fn miniapp_worker_call( .resolve_policy_for_app(&request.app_id, &app.permissions, workspace_root.as_deref()) .await; let policy_json = serde_json::to_string(&policy).map_err(|e| e.to_string())?; - let worker_revision = state.miniapp_manager.build_worker_revision(&app, &policy_json); + let worker_revision = state + .miniapp_manager + .build_worker_revision(&app, &policy_json); let should_emit_restart = !was_running || deps_installed || app.runtime.worker_restart_required; - let result = pool.call( - &request.app_id, - &worker_revision, - &policy_json, - app.permissions.node.as_ref(), - &request.method, - request.params, - ) - .await - .map_err(|e| e.to_string())?; + let result = pool + .call( + &request.app_id, + &worker_revision, + &policy_json, + app.permissions.node.as_ref(), + &request.method, + request.params, + ) + .await + .map_err(|e| e.to_string())?; if should_emit_restart { let app = state .miniapp_manager @@ -501,7 +504,14 @@ pub async fn miniapp_worker_call( .map_err(|e| e.to_string())?; emit_miniapp_event( "miniapp-worker-restarted", - miniapp_payload(&app, if deps_installed { "deps-installed" } else { "runtime-restart" }), + miniapp_payload( + &app, + if deps_installed { + "deps-installed" + } else { + "runtime-restart" + }, + ), ) .await; } @@ -522,7 +532,9 @@ pub async fn miniapp_worker_stop(state: State<'_, AppState>, app_id: String) -> } #[tauri::command] -pub async fn miniapp_worker_list_running(state: State<'_, AppState>) -> Result, String> { +pub async fn miniapp_worker_list_running( + state: State<'_, AppState>, +) -> Result, String> { let Some(ref pool) = state.js_worker_pool else { return Ok(vec![]); }; diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 2932251c..f099ca31 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -17,8 +17,10 @@ pub mod image_analysis_api; pub mod lsp_api; pub mod lsp_workspace_api; pub mod mcp_api; +pub mod miniapp_api; pub mod project_context_api; pub mod prompt_template_api; +pub mod remote_connect_api; pub mod runtime_api; pub mod session_api; pub mod skill_api; @@ -30,7 +32,5 @@ pub mod system_api; pub mod terminal_api; pub mod token_usage_api; pub mod tool_api; -pub mod remote_connect_api; -pub mod miniapp_api; pub use app_state::{AppState, AppStatistics, HealthStatus}; diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index 94ca6847..995edc3c 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -1,8 +1,9 @@ //! Tauri commands for Remote Connect. use bitfun_core::service::remote_connect::{ - bot::{self, BotConfig}, lan, ConnectionMethod, ConnectionResult, PairingState, - RemoteConnectConfig, RemoteConnectService, + bot::{self, BotConfig}, + lan, ConnectionMethod, ConnectionResult, PairingState, RemoteConnectConfig, + RemoteConnectService, }; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -76,7 +77,9 @@ async fn restore_saved_bots() { let holder = get_service_holder(); let guard = holder.read().await; - let Some(service) = guard.as_ref() else { return }; + let Some(service) = guard.as_ref() else { + return; + }; for conn in &data.connections { if !conn.chat_state.paired { @@ -268,10 +271,9 @@ fn detect_default_gateway_ip() -> Option { return None; } let stdout = String::from_utf8_lossy(&output.stdout); - let re = Regex::new( - r"(?m)^\s*0\.0\.0\.0\s+0\.0\.0\.0\s+([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)\s+", - ) - .ok()?; + let re = + Regex::new(r"(?m)^\s*0\.0\.0\.0\s+0\.0\.0\.0\s+([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)\s+") + .ok()?; return re .captures(&stdout) .and_then(|c| c.get(1).map(|m| m.as_str().to_string())); @@ -467,8 +469,7 @@ pub async fn remote_connect_configure_custom_server(url: String) -> Result<(), S if guard.is_none() { let mut config = RemoteConnectConfig::default(); config.custom_server_url = Some(url); - let service = - RemoteConnectService::new(config).map_err(|e| format!("init: {e}"))?; + let service = RemoteConnectService::new(config).map_err(|e| format!("init: {e}"))?; *guard = Some(service); } Ok(()) @@ -483,9 +484,7 @@ pub struct ConfigureBotRequest { } #[tauri::command] -pub async fn remote_connect_configure_bot( - request: ConfigureBotRequest, -) -> Result<(), String> { +pub async fn remote_connect_configure_bot(request: ConfigureBotRequest) -> Result<(), String> { let holder = get_service_holder(); let mut guard = holder.write().await; @@ -507,8 +506,7 @@ pub async fn remote_connect_configure_bot( BotConfig::Feishu { .. } => config.bot_feishu = Some(bot_config), BotConfig::Telegram { .. } => config.bot_telegram = Some(bot_config), } - let service = - RemoteConnectService::new(config).map_err(|e| format!("init: {e}"))?; + let service = RemoteConnectService::new(config).map_err(|e| format!("init: {e}"))?; *guard = Some(service); } else if let Some(service) = guard.as_mut() { service.update_bot_config(bot_config); @@ -516,4 +514,3 @@ pub async fn remote_connect_configure_bot( Ok(()) } - diff --git a/src/apps/desktop/src/api/skill_api.rs b/src/apps/desktop/src/api/skill_api.rs index 34e76b65..d2712e30 100644 --- a/src/apps/desktop/src/api/skill_api.rs +++ b/src/apps/desktop/src/api/skill_api.rs @@ -118,7 +118,9 @@ pub async fn get_skill_configs( let workspace_root = workspace_root_from_input(workspace_path.as_deref()); if force_refresh.unwrap_or(false) { - registry.refresh_for_workspace(workspace_root.as_deref()).await; + registry + .refresh_for_workspace(workspace_root.as_deref()) + .await; } let all_skills = registry @@ -152,7 +154,9 @@ pub async fn set_skill_enabled( ) .map_err(|e| format!("Failed to save skill config: {}", e))?; - registry.refresh_for_workspace(workspace_root.as_deref()).await; + registry + .refresh_for_workspace(workspace_root.as_deref()) + .await; Ok(format!( "Skill '{}' configuration saved successfully", @@ -329,7 +333,9 @@ pub async fn delete_skill( } } - registry.refresh_for_workspace(workspace_root.as_deref()).await; + registry + .refresh_for_workspace(workspace_root.as_deref()) + .await; info!( "Skill deleted: name={}, path={}", @@ -454,7 +460,9 @@ pub async fn download_skill_market( )); } - registry.refresh_for_workspace(workspace_path.as_deref()).await; + registry + .refresh_for_workspace(workspace_path.as_deref()) + .await; let mut installed_skills: Vec = registry .get_all_skills_for_workspace(workspace_path.as_deref()) .await diff --git a/src/apps/desktop/src/api/snapshot_service.rs b/src/apps/desktop/src/api/snapshot_service.rs index 1e6b5e97..eb1700f2 100644 --- a/src/apps/desktop/src/api/snapshot_service.rs +++ b/src/apps/desktop/src/api/snapshot_service.rs @@ -215,7 +215,9 @@ fn resolve_workspace_dir(workspace_path: &str) -> Result { Ok(workspace_dir) } -async fn ensure_snapshot_manager_ready(workspace_path: &str) -> Result, String> { +async fn ensure_snapshot_manager_ready( + workspace_path: &str, +) -> Result, String> { let workspace_dir = resolve_workspace_dir(workspace_path)?; if let Some(manager) = get_snapshot_manager_for_workspace(&workspace_dir) { @@ -360,33 +362,24 @@ pub async fn rollback_to_turn( use bitfun_core::agentic::persistence::PersistenceManager; match try_get_path_manager_arc() { - Ok(path_manager) => { - match PersistenceManager::new(path_manager) { - Ok(persistence_manager) => { - match persistence_manager - .delete_turns_from( - &workspace_path, - &request.session_id, - request.turn_index, - ) - .await - { - Ok(count) => { - deleted_turns_count = count; - } - Err(e) => { - warn!("Failed to delete conversation turns: session_id={}, turn_index={}, error={}", request.session_id, request.turn_index, e); - } + Ok(path_manager) => match PersistenceManager::new(path_manager) { + Ok(persistence_manager) => { + match persistence_manager + .delete_turns_from(&workspace_path, &request.session_id, request.turn_index) + .await + { + Ok(count) => { + deleted_turns_count = count; + } + Err(e) => { + warn!("Failed to delete conversation turns: session_id={}, turn_index={}, error={}", request.session_id, request.turn_index, e); } - } - Err(e) => { - warn!( - "Failed to create PersistenceManager: error={}", - e - ); } } - } + Err(e) => { + warn!("Failed to create PersistenceManager: error={}", e); + } + }, Err(e) => { warn!("Failed to create PathManager: error={}", e); } @@ -540,7 +533,10 @@ pub async fn get_session_turns( } } Err(e) => { - warn!("Failed to create PersistenceManager: error={}, falling back to snapshot", e); + warn!( + "Failed to create PersistenceManager: error={}, falling back to snapshot", + e + ); } } } diff --git a/src/apps/desktop/src/api/token_usage_api.rs b/src/apps/desktop/src/api/token_usage_api.rs index 4ac13e00..57f757c0 100644 --- a/src/apps/desktop/src/api/token_usage_api.rs +++ b/src/apps/desktop/src/api/token_usage_api.rs @@ -93,22 +93,18 @@ pub async fn get_model_token_stats( debug!("Getting token stats for model: {}", request.model_id); match request.time_range { - Some(time_range) => { - state - .token_usage_service - .get_model_stats_filtered(&request.model_id, time_range, request.include_subagent) - .await - .map_err(|e| { - error!("Failed to get filtered model stats: {}", e); - format!("Failed to get filtered model stats: {}", e) - }) - } - None => { - Ok(state - .token_usage_service - .get_model_stats(&request.model_id) - .await) - } + Some(time_range) => state + .token_usage_service + .get_model_stats_filtered(&request.model_id, time_range, request.include_subagent) + .await + .map_err(|e| { + error!("Failed to get filtered model stats: {}", e); + format!("Failed to get filtered model stats: {}", e) + }), + None => Ok(state + .token_usage_service + .get_model_stats(&request.model_id) + .await), } } @@ -197,4 +193,3 @@ pub async fn clear_all_token_stats(state: State<'_, AppState>) -> Result<(), Str format!("Failed to clear all stats: {}", e) }) } - diff --git a/src/apps/desktop/src/api/tool_api.rs b/src/apps/desktop/src/api/tool_api.rs index 92f5ad15..d1928b46 100644 --- a/src/apps/desktop/src/api/tool_api.rs +++ b/src/apps/desktop/src/api/tool_api.rs @@ -8,9 +8,9 @@ use std::sync::Arc; use crate::api::context_upload_api::create_image_context_provider; use bitfun_core::agentic::{ - WorkspaceBinding, tools::framework::ToolUseContext, tools::{get_all_tools, get_readonly_tools}, + WorkspaceBinding, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -117,12 +117,8 @@ fn is_relative_path(value: Option<&serde_json::Value>) -> bool { fn tool_requires_workspace_path(tool_name: &str, input: &serde_json::Value) -> bool { match tool_name { "Bash" => true, - "Glob" | "Grep" => { - input.get("path").is_none() || is_relative_path(input.get("path")) - } - "Read" | "Write" | "Edit" | "GetFileDiff" => { - is_relative_path(input.get("file_path")) - } + "Glob" | "Grep" => input.get("path").is_none() || is_relative_path(input.get("path")), + "Read" | "Write" | "Edit" | "GetFileDiff" => is_relative_path(input.get("file_path")), _ => false, } } @@ -132,7 +128,9 @@ fn ensure_workspace_requirement( input: &serde_json::Value, workspace_path: Option<&str>, ) -> Result<(), String> { - if tool_requires_workspace_path(tool_name, input) && !has_explicit_workspace_path(workspace_path) { + if tool_requires_workspace_path(tool_name, input) + && !has_explicit_workspace_path(workspace_path) + { return Err(format!( "workspacePath is required to execute tool '{}' with workspace-relative input", tool_name diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 54708948..2c947139 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -26,7 +26,6 @@ use api::ai_rules_api::*; use api::clipboard_file_api::*; use api::commands::*; use api::config_api::*; -use api::session_api::*; use api::diff_api::*; use api::git_agent_api::*; use api::git_api::*; @@ -35,6 +34,7 @@ use api::lsp_api::*; use api::lsp_workspace_api::*; use api::mcp_api::*; use api::runtime_api::*; +use api::session_api::*; use api::skill_api::*; use api::snapshot_service::*; use api::startchat_agent_api::*; @@ -323,6 +323,7 @@ pub async fn run() { get_statistics, test_ai_connection, test_ai_config_connection, + list_ai_models_by_config, initialize_ai, set_agent_model, get_agent_models, @@ -713,10 +714,10 @@ async fn init_agentic_system() -> anyhow::Result<( .map_err(|e| anyhow::anyhow!("Failed to initialize token usage service: {}", e))?, ); let token_usage_subscriber = Arc::new( - bitfun_core::service::token_usage::TokenUsageSubscriber::new(token_usage_service.clone()) + bitfun_core::service::token_usage::TokenUsageSubscriber::new(token_usage_service.clone()), ); event_router.subscribe_internal("token_usage".to_string(), token_usage_subscriber); - + log::info!("Token usage service initialized and subscriber registered"); // Create the DialogScheduler and wire up the outcome notification channel @@ -836,7 +837,10 @@ fn init_services(app_handle: tauri::AppHandle, default_log_level: log::LevelFilt .set_event_emitter(emitter.clone()) .await { - log::error!("Failed to initialize workspace identity watch service: {}", e); + log::error!( + "Failed to initialize workspace identity watch service: {}", + e + ); } if let Err(e) = service::lsp::initialize_global_lsp_manager().await { diff --git a/src/apps/desktop/src/main.rs b/src/apps/desktop/src/main.rs index eee4fb53..e910e3ee 100644 --- a/src/apps/desktop/src/main.rs +++ b/src/apps/desktop/src/main.rs @@ -1,5 +1,8 @@ // Hide console window in Windows release builds -#![cfg_attr(all(not(debug_assertions), target_os = "windows"), windows_subsystem = "windows")] +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] #[tokio::main(flavor = "multi_thread", worker_threads = 4)] async fn main() { diff --git a/src/apps/relay-server/src/lib.rs b/src/apps/relay-server/src/lib.rs index 3be16d62..b8e472fa 100644 --- a/src/apps/relay-server/src/lib.rs +++ b/src/apps/relay-server/src/lib.rs @@ -12,7 +12,7 @@ pub mod relay; pub mod routes; -pub use relay::room::{RoomManager, ResponsePayload}; +pub use relay::room::{ResponsePayload, RoomManager}; pub use routes::api::AppState; use axum::extract::DefaultBodyLimit; @@ -94,9 +94,7 @@ impl WebAssetStore for MemoryAssetStore { fn get_file(&self, room_id: &str, path: &str) -> Option> { let manifest = self.room_manifests.get(room_id)?; - let hash = manifest - .get(path) - .or_else(|| manifest.get("index.html"))?; + let hash = manifest.get(path).or_else(|| manifest.get("index.html"))?; let content = self.content_store.get(hash)?; Some(content.value().as_ref().clone()) } diff --git a/src/apps/relay-server/src/main.rs b/src/apps/relay-server/src/main.rs index 29d3a9db..1616650b 100644 --- a/src/apps/relay-server/src/main.rs +++ b/src/apps/relay-server/src/main.rs @@ -42,8 +42,7 @@ async fn main() -> anyhow::Result<()> { if let Some(static_dir) = &cfg.static_dir { info!("Serving static files from: {static_dir}"); app = app.fallback_service( - tower_http::services::ServeDir::new(static_dir) - .append_index_html_on_directories(true), + tower_http::services::ServeDir::new(static_dir).append_index_html_on_directories(true), ); } diff --git a/src/apps/relay-server/src/relay/room.rs b/src/apps/relay-server/src/relay/room.rs index 85a263ce..592b3299 100644 --- a/src/apps/relay-server/src/relay/room.rs +++ b/src/apps/relay-server/src/relay/room.rs @@ -156,10 +156,7 @@ impl RoomManager { .and_then(|r| r.desktop.as_ref().map(|d| d.public_key.clone())) } - pub fn register_pending( - &self, - correlation_id: String, - ) -> oneshot::Receiver { + pub fn register_pending(&self, correlation_id: String) -> oneshot::Receiver { let (tx, rx) = oneshot::channel(); self.pending_requests.insert(correlation_id, tx); rx diff --git a/src/apps/relay-server/src/routes/api.rs b/src/apps/relay-server/src/routes/api.rs index b046c95b..f365295a 100644 --- a/src/apps/relay-server/src/routes/api.rs +++ b/src/apps/relay-server/src/routes/api.rs @@ -210,7 +210,9 @@ pub async fn upload_web( if rel_path.contains("..") { continue; } - let decoded = B64.decode(b64_content).map_err(|_| StatusCode::BAD_REQUEST)?; + let decoded = B64 + .decode(b64_content) + .map_err(|_| StatusCode::BAD_REQUEST)?; let hash = hex_sha256(&decoded); if !state.asset_store.has_content(&hash) { @@ -327,7 +329,9 @@ pub async fn upload_web_files( if rel_path.contains("..") { continue; } - let decoded = B64.decode(&entry.content).map_err(|_| StatusCode::BAD_REQUEST)?; + let decoded = B64 + .decode(&entry.content) + .map_err(|_| StatusCode::BAD_REQUEST)?; let actual_hash = hex_sha256(&decoded); if actual_hash != entry.hash { tracing::warn!( @@ -352,7 +356,9 @@ pub async fn upload_web_files( } tracing::info!("Room {room_id}: upload-web-files stored {stored} new files"); - Ok(Json(serde_json::json!({ "status": "ok", "files_stored": stored }))) + Ok(Json( + serde_json::json!({ "status": "ok", "files_stored": stored }), + )) } /// `GET /r/{*rest}` — serve per-room mobile-web static files. diff --git a/src/apps/relay-server/src/routes/websocket.rs b/src/apps/relay-server/src/routes/websocket.rs index 13cf88e9..3e456121 100644 --- a/src/apps/relay-server/src/routes/websocket.rs +++ b/src/apps/relay-server/src/routes/websocket.rs @@ -66,10 +66,7 @@ pub enum OutboundProtocol { }, } -pub async fn websocket_handler( - ws: WebSocketUpgrade, - State(state): State, -) -> Response { +pub async fn websocket_handler(ws: WebSocketUpgrade, State(state): State) -> Response { ws.max_message_size(64 * 1024 * 1024) .max_frame_size(64 * 1024 * 1024) .max_write_buffer_size(64 * 1024 * 1024) diff --git a/src/apps/server/src/main.rs b/src/apps/server/src/main.rs index fc22d0ef..6e76cde2 100644 --- a/src/apps/server/src/main.rs +++ b/src/apps/server/src/main.rs @@ -1,19 +1,14 @@ +use anyhow::Result; /// BitFun Server /// /// Web server with support for: /// - RESTful API /// - WebSocket real-time communication /// - Static file serving (frontend) - -use axum::{ - routing::get, - Router, - Json, -}; +use axum::{routing::get, Json, Router}; use serde::Serialize; use std::net::SocketAddr; use tower_http::cors::CorsLayer; -use anyhow::Result; mod routes; diff --git a/src/apps/server/src/routes/api.rs b/src/apps/server/src/routes/api.rs index fd6a50fc..5e94b12f 100644 --- a/src/apps/server/src/routes/api.rs +++ b/src/apps/server/src/routes/api.rs @@ -1,8 +1,7 @@ /// HTTP API routes /// /// Provides RESTful API endpoints - -use axum::{Json, extract::State}; +use axum::{extract::State, Json}; use serde::Serialize; use crate::AppState; diff --git a/src/apps/server/src/routes/mod.rs b/src/apps/server/src/routes/mod.rs index 52b1ce29..0f3f3704 100644 --- a/src/apps/server/src/routes/mod.rs +++ b/src/apps/server/src/routes/mod.rs @@ -1,6 +1,5 @@ +pub mod api; /// Routes module /// /// Contains all HTTP and WebSocket routes - pub mod websocket; -pub mod api; diff --git a/src/apps/server/src/routes/websocket.rs b/src/apps/server/src/routes/websocket.rs index 543833de..a1bb1b5a 100644 --- a/src/apps/server/src/routes/websocket.rs +++ b/src/apps/server/src/routes/websocket.rs @@ -1,9 +1,9 @@ +use anyhow::Result; /// WebSocket handler /// /// Implements real-time bidirectional communication with frontend: /// - Command request/response (JSON RPC format) /// - Event push (streaming output, tool calls, etc.) - use axum::{ extract::{ ws::{Message, WebSocket, WebSocketUpgrade}, @@ -13,7 +13,6 @@ use axum::{ }; use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; -use anyhow::Result; use crate::AppState; @@ -54,10 +53,7 @@ pub struct ErrorInfo { } /// WebSocket connection handler -pub async fn websocket_handler( - ws: WebSocketUpgrade, - State(state): State, -) -> Response { +pub async fn websocket_handler(ws: WebSocketUpgrade, State(state): State) -> Response { tracing::info!("New WebSocket connection"); ws.on_upgrade(|socket| handle_socket(socket, state)) } @@ -165,12 +161,10 @@ async fn handle_command( _state: &AppState, ) -> Result { match method { - "ping" => { - Ok(serde_json::json!({ - "pong": true, - "timestamp": chrono::Utc::now().timestamp(), - })) - } + "ping" => Ok(serde_json::json!({ + "pong": true, + "timestamp": chrono::Utc::now().timestamp(), + })), _ => { tracing::warn!("Unknown command: {}", method); Err(anyhow::anyhow!("Unknown command: {}", method)) diff --git a/src/crates/api-layer/src/dto.rs b/src/crates/api-layer/src/dto.rs index 0ab65a55..80ad3589 100644 --- a/src/crates/api-layer/src/dto.rs +++ b/src/crates/api-layer/src/dto.rs @@ -1,7 +1,6 @@ /// Data Transfer Objects (DTO) - Platform-agnostic request and response types /// /// These types are used by all platforms (CLI, Tauri, Server) - use serde::{Deserialize, Serialize}; /// Execute agent task request @@ -27,7 +26,7 @@ pub struct ExecuteAgentResponse { /// Image data #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageData { - pub data: String, // Base64 + pub data: String, // Base64 pub mime_type: String, } diff --git a/src/crates/api-layer/src/lib.rs b/src/crates/api-layer/src/lib.rs index c22940c3..1507afac 100644 --- a/src/crates/api-layer/src/lib.rs +++ b/src/crates/api-layer/src/lib.rs @@ -4,7 +4,6 @@ /// - CLI (apps/cli) /// - Tauri Desktop (apps/desktop) /// - Web Server (apps/server) - pub mod dto; pub mod handlers; diff --git a/src/crates/core/src/agentic/agents/custom_subagents/custom_subagent_loader.rs b/src/crates/core/src/agentic/agents/custom_subagents/custom_subagent_loader.rs index d3b44abd..0b2929df 100644 --- a/src/crates/core/src/agentic/agents/custom_subagents/custom_subagent_loader.rs +++ b/src/crates/core/src/agentic/agents/custom_subagents/custom_subagent_loader.rs @@ -1,6 +1,6 @@ -use log::{error}; use crate::agentic::agents::Agent; use crate::infrastructure::get_path_manager_arc; +use log::error; use std::collections::HashMap; use std::path::{Path, PathBuf}; diff --git a/src/crates/core/src/agentic/agents/debug_mode.rs b/src/crates/core/src/agentic/agents/debug_mode.rs index 65970bee..7b1bac8c 100644 --- a/src/crates/core/src/agentic/agents/debug_mode.rs +++ b/src/crates/core/src/agentic/agents/debug_mode.rs @@ -1,13 +1,13 @@ //! Debug Mode - Evidence-driven debugging mode -use log::debug; use super::prompt_builder::PromptBuilder; use super::Agent; -use async_trait::async_trait; use crate::service::config::global::GlobalConfigManager; use crate::service::config::types::{DebugModeConfig, LanguageDebugTemplate}; use crate::service::lsp::project_detector::{ProjectDetector, ProjectInfo}; use crate::util::errors::BitFunResult; +use async_trait::async_trait; +use log::debug; use std::path::Path; pub struct DebugMode; @@ -70,7 +70,7 @@ impl DebugMode { .get("javascript") .map(|t| t.enabled && !t.instrumentation_template.trim().is_empty()) .unwrap_or(false); - + if use_custom { if let Some(template) = config.language_templates.get("javascript") { output.push_str(&Self::render_template(template, config)); @@ -84,9 +84,9 @@ impl DebugMode { let matched_user_templates: Vec<_> = user_other_templates .iter() .filter(|(lang, _)| { - detected_languages.iter().any(|detected| { - detected.to_lowercase() == lang.to_lowercase() - }) + detected_languages + .iter() + .any(|detected| detected.to_lowercase() == lang.to_lowercase()) }) .collect(); @@ -109,7 +109,7 @@ impl DebugMode { output } - + fn render_builtin_js_template(config: &DebugModeConfig) -> String { let mut section = "## JavaScript / TypeScript Instrumentation\n\n".to_string(); section.push_str("```javascript\n"); @@ -175,11 +175,7 @@ impl DebugMode { } /// Builds session-level configuration with dynamic values like server endpoint and log path. - fn build_session_level_rule( - &self, - config: &DebugModeConfig, - workspace_path: &str, - ) -> String { + fn build_session_level_rule(&self, config: &DebugModeConfig, workspace_path: &str) -> String { let log_path = if config.log_path.starts_with('/') || config.log_path.starts_with('.') { config.log_path.clone() } else { @@ -290,12 +286,11 @@ impl Agent for DebugMode { debug!( "Debug mode project detection: languages={:?}, types={:?}", - project_info.languages, - project_info.project_types + project_info.languages, project_info.project_types ); - let system_prompt_template = - get_embedded_prompt("debug_mode").unwrap_or("Debug mode prompt not found in embedded files"); + let system_prompt_template = get_embedded_prompt("debug_mode") + .unwrap_or("Debug mode prompt not found in embedded files"); let language_templates = Self::build_language_templates_prompt(&debug_config, &project_info.languages); diff --git a/src/crates/core/src/agentic/agents/prompt_builder/mod.rs b/src/crates/core/src/agentic/agents/prompt_builder/mod.rs index 504c3421..b4c83123 100644 --- a/src/crates/core/src/agentic/agents/prompt_builder/mod.rs +++ b/src/crates/core/src/agentic/agents/prompt_builder/mod.rs @@ -1,3 +1,3 @@ mod prompt_builder; -pub use prompt_builder::PromptBuilder; \ No newline at end of file +pub use prompt_builder::PromptBuilder; diff --git a/src/crates/core/src/agentic/agents/registry.rs b/src/crates/core/src/agentic/agents/registry.rs index 0659bc73..3775b102 100644 --- a/src/crates/core/src/agentic/agents/registry.rs +++ b/src/crates/core/src/agentic/agents/registry.rs @@ -108,6 +108,13 @@ impl AgentInfo { } } +fn default_model_id_for_builtin_agent(agent_type: &str) -> &'static str { + match agent_type { + "agentic" | "Cowork" | "Plan" | "debug" | "Claw" => "auto", + _ => "primary", + } +} + async fn get_mode_configs() -> HashMap { if let Ok(config_service) = GlobalConfigManager::get_service().await { config_service @@ -194,7 +201,11 @@ impl AgentRegistry { } } - fn find_agent_entry(&self, agent_type: &str, workspace_root: Option<&Path>) -> Option { + fn find_agent_entry( + &self, + agent_type: &str, + workspace_root: Option<&Path>, + ) -> Option { if let Some(entry) = self.read_agents().get(agent_type).cloned() { return Some(entry); } @@ -297,7 +308,11 @@ impl AgentRegistry { } /// Get a agent by ID (searches all categories including hidden) - pub fn get_agent(&self, agent_type: &str, workspace_root: Option<&Path>) -> Option> { + pub fn get_agent( + &self, + agent_type: &str, + workspace_root: Option<&Path>, + ) -> Option> { self.find_agent_entry(agent_type, workspace_root) .map(|entry| entry.agent) } @@ -340,7 +355,11 @@ impl AgentRegistry { /// get agent tools from config /// if not set, return default tools /// tool configuration synchronization is implemented through tool_config_sync, here only read configuration - pub async fn get_agent_tools(&self, agent_type: &str, workspace_root: Option<&Path>) -> Vec { + pub async fn get_agent_tools( + &self, + agent_type: &str, + workspace_root: Option<&Path>, + ) -> Vec { let entry = self.find_agent_entry(agent_type, workspace_root); let Some(entry) = entry else { return Vec::new(); @@ -418,7 +437,8 @@ impl AgentRegistry { /// - custom subagent: read enabled and model configuration from custom_config cache pub async fn get_subagents_info(&self, workspace_root: Option<&Path>) -> Vec { if let Some(workspace_root) = workspace_root { - let is_project_cache_loaded = self.read_project_subagents().contains_key(workspace_root); + let is_project_cache_loaded = + self.read_project_subagents().contains_key(workspace_root); if !is_project_cache_loaded { self.load_custom_subagents(workspace_root).await; } @@ -447,7 +467,11 @@ impl AgentRegistry { drop(map); if let Some(workspace_root) = workspace_root { if let Some(project_entries) = self.read_project_subagents().get(workspace_root) { - result.extend(project_entries.values().map(|entry| AgentInfo::from_agent_entry(entry))); + result.extend( + project_entries + .values() + .map(|entry| AgentInfo::from_agent_entry(entry)), + ); } } result @@ -695,7 +719,10 @@ impl AgentRegistry { } } - Err(BitFunError::agent(format!("Subagent not found: {}", agent_id))) + Err(BitFunError::agent(format!( + "Subagent not found: {}", + agent_id + ))) } /// get model ID used by agent from agent_models[agent_type] in configuration @@ -718,20 +745,20 @@ impl AgentRegistry { // check if it is a custom subagent, if so, read from cache if let Some(entry) = self.find_agent_entry(agent_type, workspace_root) { if let Some(config) = entry.custom_config { - let model = config.model; - if !model.is_empty() { + let model = config.model; + if !model.is_empty() { + debug!( + "[AgentRegistry] Custom subagent '{}' using model from cache: {}", + agent_type, model + ); + return Ok(model); + } + // empty model, use default value debug!( - "[AgentRegistry] Custom subagent '{}' using model from cache: {}", - agent_type, model + "[AgentRegistry] Custom subagent '{}' using default model: primary", + agent_type ); - return Ok(model); - } - // empty model, use default value - debug!( - "[AgentRegistry] Custom subagent '{}' using default model: primary", - agent_type - ); - return Ok("primary".to_string()); + return Ok("primary".to_string()); } } @@ -753,12 +780,12 @@ impl AgentRegistry { ) }; - // use default primary model + let default_model_id = default_model_id_for_builtin_agent(agent_type); warn!( - "[AgentRegistry] Agent '{}' has no model configured, using default primary model", - agent_type + "[AgentRegistry] Agent '{}' has no model configured, using default model '{}'", + agent_type, default_model_id ); - Ok("primary".to_string()) + Ok(default_model_id.to_string()) } /// Get the default agent type @@ -779,3 +806,21 @@ pub fn get_agent_registry() -> Arc { }) .clone() } + +#[cfg(test)] +mod tests { + use super::default_model_id_for_builtin_agent; + + #[test] + fn top_level_modes_default_to_auto() { + for agent_type in ["agentic", "Cowork", "Plan", "debug", "Claw"] { + assert_eq!(default_model_id_for_builtin_agent(agent_type), "auto"); + } + } + + #[test] + fn non_mode_agents_default_to_primary() { + assert_eq!(default_model_id_for_builtin_agent("Explore"), "primary"); + assert_eq!(default_model_id_for_builtin_agent("CodeReview"), "primary"); + } +} diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index 2c854799..7b5e83e8 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -120,7 +120,9 @@ impl ConversationCoordinator { || workspace_service .get_workspace_by_path(&workspace_path_buf) .await - .map(|workspace| workspace.workspace_kind == crate::service::workspace::WorkspaceKind::Assistant) + .map(|workspace| { + workspace.workspace_kind == crate::service::workspace::WorkspaceKind::Assistant + }) .unwrap_or(false) { if normalized_agent_type != "Claw" { @@ -167,7 +169,9 @@ impl ConversationCoordinator { config: SessionConfig, ) -> BitFunResult { let workspace_path = config.workspace_path.clone().ok_or_else(|| { - BitFunError::Validation("workspace_path is required when creating a session".to_string()) + BitFunError::Validation( + "workspace_path is required when creating a session".to_string(), + ) })?; self.create_session_with_workspace_and_creator( None, @@ -189,7 +193,9 @@ impl ConversationCoordinator { config: SessionConfig, ) -> BitFunResult { let workspace_path = config.workspace_path.clone().ok_or_else(|| { - BitFunError::Validation("workspace_path is required when creating a session".to_string()) + BitFunError::Validation( + "workspace_path is required when creating a session".to_string(), + ) })?; self.create_session_with_workspace_and_creator( session_id, @@ -263,11 +269,7 @@ impl ConversationCoordinator { Ok(session) } - async fn sync_session_metadata_to_workspace( - &self, - session: &Session, - workspace_path: String, - ) { + async fn sync_session_metadata_to_workspace(&self, session: &Session, workspace_path: String) { use crate::agentic::persistence::PersistenceManager; use crate::infrastructure::PathManager; use crate::service::session::{SessionMetadata, SessionStatus}; @@ -461,7 +463,9 @@ impl ConversationCoordinator { ) -> BitFunResult { let agent_registry = get_agent_registry(); if let Some(workspace) = workspace { - agent_registry.load_custom_subagents(workspace.root_path()).await; + agent_registry + .load_custom_subagents(workspace.root_path()) + .await; } let current_agent = agent_registry .get_agent(agent_type, workspace.map(|binding| binding.root_path())) @@ -491,6 +495,7 @@ impl ConversationCoordinator { &self, session_id: String, user_input: String, + original_user_input: Option, turn_id: Option, agent_type: String, workspace_path: Option, @@ -499,6 +504,7 @@ impl ConversationCoordinator { self.start_dialog_turn_internal( session_id, user_input, + original_user_input, None, turn_id, agent_type, @@ -512,6 +518,7 @@ impl ConversationCoordinator { &self, session_id: String, user_input: String, + original_user_input: Option, image_contexts: Vec, turn_id: Option, agent_type: String, @@ -521,6 +528,7 @@ impl ConversationCoordinator { self.start_dialog_turn_internal( session_id, user_input, + original_user_input, Some(image_contexts), turn_id, agent_type, @@ -663,6 +671,7 @@ impl ConversationCoordinator { &self, session_id: String, user_input: String, + original_user_input: Option, image_contexts: Option>, turn_id: Option, agent_type: String, @@ -852,7 +861,7 @@ impl ConversationCoordinator { } } - let original_user_input = user_input.clone(); + let original_user_input = original_user_input.unwrap_or_else(|| user_input.clone()); // Build image metadata for workspace turn persistence (before image_contexts is consumed) // Also stores original_text so the UI can display the user's actual input @@ -906,7 +915,11 @@ impl ConversationCoordinator { .await?; let wrapped_user_input = self - .wrap_user_input(&effective_agent_type, user_input, session_workspace.as_ref()) + .wrap_user_input( + &effective_agent_type, + user_input, + session_workspace.as_ref(), + ) .await?; // Start new dialog turn (sets state to Processing internally) @@ -957,6 +970,10 @@ impl ConversationCoordinator { "enable_tools".to_string(), session.config.enable_tools.to_string(), ); + context_vars.insert( + "original_user_input".to_string(), + original_user_input.clone(), + ); // Pass model_id for token usage tracking if let Some(model_id) = &session.config.model_id { @@ -1271,8 +1288,14 @@ impl ConversationCoordinator { } /// Delete session - pub async fn delete_session(&self, workspace_path: &Path, session_id: &str) -> BitFunResult<()> { - self.session_manager.delete_session(workspace_path, session_id).await?; + pub async fn delete_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<()> { + self.session_manager + .delete_session(workspace_path, session_id) + .await?; self.emit_event(AgenticEvent::SessionDeleted { session_id: session_id.to_string(), }) @@ -1281,8 +1304,14 @@ impl ConversationCoordinator { } /// Restore session - pub async fn restore_session(&self, workspace_path: &Path, session_id: &str) -> BitFunResult { - self.session_manager.restore_session(workspace_path, session_id).await + pub async fn restore_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { + self.session_manager + .restore_session(workspace_path, session_id) + .await } /// List all sessions @@ -1500,7 +1529,9 @@ impl ConversationCoordinator { ); } - Ok(SubagentResult { text: response_text }) + Ok(SubagentResult { + text: response_text, + }) } /// Clean up subagent session resources diff --git a/src/crates/core/src/agentic/coordination/scheduler.rs b/src/crates/core/src/agentic/coordination/scheduler.rs index b520647a..25d034de 100644 --- a/src/crates/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/core/src/agentic/coordination/scheduler.rs @@ -29,6 +29,7 @@ const DEBOUNCE_DELAY: Duration = Duration::from_secs(1); #[derive(Debug)] pub struct QueuedTurn { pub user_input: String, + pub original_user_input: Option, pub turn_id: Option, pub agent_type: String, pub workspace_path: Option, @@ -98,6 +99,7 @@ impl DialogScheduler { &self, session_id: String, user_input: String, + original_user_input: Option, turn_id: Option, agent_type: String, workspace_path: Option, @@ -114,6 +116,7 @@ impl DialogScheduler { .start_dialog_turn( session_id, user_input, + original_user_input, turn_id, agent_type, workspace_path, @@ -128,6 +131,7 @@ impl DialogScheduler { .start_dialog_turn( session_id, user_input, + original_user_input, turn_id, agent_type, workspace_path, @@ -149,6 +153,7 @@ impl DialogScheduler { self.enqueue( &session_id, user_input, + original_user_input, turn_id, agent_type, workspace_path, @@ -161,6 +166,7 @@ impl DialogScheduler { .start_dialog_turn( session_id, user_input, + original_user_input, turn_id, agent_type, workspace_path, @@ -175,6 +181,7 @@ impl DialogScheduler { self.enqueue( &session_id, user_input, + original_user_input, turn_id, agent_type, workspace_path, @@ -196,6 +203,7 @@ impl DialogScheduler { &self, session_id: &str, user_input: String, + original_user_input: Option, turn_id: Option, agent_type: String, workspace_path: Option, @@ -219,6 +227,7 @@ impl DialogScheduler { .or_default() .push_back(QueuedTurn { user_input, + original_user_input, turn_id, agent_type, workspace_path, @@ -285,13 +294,20 @@ impl DialogScheduler { session_id_clone ); - let (merged_input, turn_id, agent_type, workspace_path, trigger_source) = - merge_messages(messages); + let ( + merged_input, + original_user_input, + turn_id, + agent_type, + workspace_path, + trigger_source, + ) = merge_messages(messages); if let Err(e) = coordinator .start_dialog_turn( session_id_clone.clone(), merged_input, + original_user_input, turn_id, agent_type, workspace_path, @@ -360,11 +376,19 @@ impl DialogScheduler { /// ``` fn merge_messages( messages: Vec, -) -> (String, Option, String, Option, DialogTriggerSource) { +) -> ( + String, + Option, + Option, + String, + Option, + DialogTriggerSource, +) { if messages.len() == 1 { let m = messages.into_iter().next().unwrap(); return ( m.user_input, + m.original_user_input, m.turn_id, m.agent_type, m.workspace_path, @@ -381,6 +405,7 @@ fn merge_messages( .last() .map(|m| m.trigger_source) .unwrap_or(DialogTriggerSource::DesktopUi); + let original_user_input = messages.last().and_then(|m| m.original_user_input.clone()); let entries: Vec = messages .iter() @@ -393,7 +418,14 @@ fn merge_messages( entries.join("\n\n") ); - (merged, None, agent_type, workspace_path, trigger_source) + ( + merged, + original_user_input, + None, + agent_type, + workspace_path, + trigger_source, + ) } // ── Global instance ────────────────────────────────────────────────────────── diff --git a/src/crates/core/src/agentic/core/message.rs b/src/crates/core/src/agentic/core/message.rs index bca0b912..8f27f9c7 100644 --- a/src/crates/core/src/agentic/core/message.rs +++ b/src/crates/core/src/agentic/core/message.rs @@ -110,11 +110,7 @@ impl From for AIMessage { .filter(|s| !s.is_empty()) .map(str::to_string) .or_else(|| { - image - .image_path - .as_ref() - .filter(|s| !s.is_empty()) - .cloned() + image.image_path.as_ref().filter(|s| !s.is_empty()).cloned() }) .unwrap_or_else(|| image.id.clone()); diff --git a/src/crates/core/src/agentic/core/mod.rs b/src/crates/core/src/agentic/core/mod.rs index 1541ad90..90524d06 100644 --- a/src/crates/core/src/agentic/core/mod.rs +++ b/src/crates/core/src/agentic/core/mod.rs @@ -4,14 +4,14 @@ pub mod dialog_turn; pub mod message; +pub mod messages_helper; pub mod model_round; pub mod session; pub mod state; -pub mod messages_helper; pub use dialog_turn::{DialogTurn, DialogTurnState, TurnStats}; pub use message::{Message, MessageContent, MessageRole, ToolCall, ToolResult}; -pub use model_round::ModelRound; -pub use session::{Session, SessionConfig, SessionSummary, CompressionState}; pub use messages_helper::MessageHelper; +pub use model_round::ModelRound; +pub use session::{CompressionState, Session, SessionConfig, SessionSummary}; pub use state::{ProcessingPhase, SessionState, ToolExecutionState}; diff --git a/src/crates/core/src/agentic/core/session.rs b/src/crates/core/src/agentic/core/session.rs index e0183010..22a0a045 100644 --- a/src/crates/core/src/agentic/core/session.rs +++ b/src/crates/core/src/agentic/core/session.rs @@ -11,11 +11,20 @@ pub struct Session { pub session_id: String, pub session_name: String, pub agent_type: String, - #[serde(default, skip_serializing_if = "Option::is_none", alias = "created_by", alias = "createdBy")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "created_by", + alias = "createdBy" + )] pub created_by: Option, /// Associated resources - #[serde(skip_serializing_if = "Option::is_none", alias = "sandbox_session_id", alias = "sandboxSessionId")] + #[serde( + skip_serializing_if = "Option::is_none", + alias = "sandbox_session_id", + alias = "sandboxSessionId" + )] pub snapshot_session_id: Option, /// Dialog turn ID list @@ -146,7 +155,12 @@ pub struct SessionSummary { pub session_id: String, pub session_name: String, pub agent_type: String, - #[serde(default, skip_serializing_if = "Option::is_none", alias = "created_by", alias = "createdBy")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "created_by", + alias = "createdBy" + )] pub created_by: Option, pub turn_count: usize, pub created_at: SystemTime, diff --git a/src/crates/core/src/agentic/events/mod.rs b/src/crates/core/src/agentic/events/mod.rs index b55b6439..fdbde912 100644 --- a/src/crates/core/src/agentic/events/mod.rs +++ b/src/crates/core/src/agentic/events/mod.rs @@ -1,13 +1,11 @@ //! Event Layer -//! +//! //! Provides event queue, routing and management functionality -pub mod types; pub mod queue; pub mod router; +pub mod types; -pub use types::*; pub use queue::*; pub use router::*; - - +pub use types::*; diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index 38ace630..3e280365 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -131,6 +131,32 @@ impl ExecutionEngine { Self::is_recoverable_historical_image_error(err) } + fn resolve_configured_model_id( + ai_config: &crate::service::config::types::AIConfig, + model_id: &str, + ) -> String { + ai_config + .resolve_model_selection(model_id) + .unwrap_or_else(|| model_id.to_string()) + } + + fn resolve_locked_auto_model_id( + ai_config: &crate::service::config::types::AIConfig, + model_id: Option<&String>, + ) -> Option { + let model_id = model_id?; + let trimmed = model_id.trim(); + if trimmed.is_empty() || trimmed == "auto" || trimmed == "default" { + return None; + } + + ai_config.resolve_model_selection(trimmed) + } + + fn should_use_fast_auto_model(turn_index: usize, original_user_input: &str) -> bool { + turn_index == 0 && original_user_input.chars().count() <= 10 + } + async fn build_ai_messages_for_send( messages: &[Message], provider: &str, @@ -382,10 +408,18 @@ impl ExecutionEngine { // 1. Get current agent let agent_registry = get_agent_registry(); if let Some(workspace) = context.workspace.as_ref() { - agent_registry.load_custom_subagents(workspace.root_path()).await; + agent_registry + .load_custom_subagents(workspace.root_path()) + .await; } let current_agent = agent_registry - .get_agent(&agent_type, context.workspace.as_ref().map(|workspace| workspace.root_path())) + .get_agent( + &agent_type, + context + .workspace + .as_ref() + .map(|workspace| workspace.root_path()), + ) .ok_or_else(|| BitFunError::NotFound(format!("Agent not found: {}", agent_type)))?; info!( "Current Agent: {} ({})", @@ -393,18 +427,94 @@ impl ExecutionEngine { current_agent.id() ); + let session = self + .session_manager + .get_session(&context.session_id) + .ok_or_else(|| { + BitFunError::Session(format!("Session not found: {}", context.session_id)) + })?; + // 2. Get AI client // Get model ID from AgentRegistry - let model_id = agent_registry + let configured_model_id = agent_registry .get_model_id_for_agent( &agent_type, - context.workspace.as_ref().map(|workspace| workspace.root_path()), + context + .workspace + .as_ref() + .map(|workspace| workspace.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 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(); + + if let Some(locked_model_id) = locked_model_id { + locked_model_id + } else { + if let Some(raw_locked_model_id) = raw_locked_model_id.as_ref() { + let trimmed = raw_locked_model_id.trim(); + if !trimmed.is_empty() && trimmed != "auto" && trimmed != "default" { + warn!( + "Ignoring invalid locked auto model for session: session_id={}, model_id={}", + context.session_id, trimmed + ); + } + } + + let original_user_input = context + .context + .get("original_user_input") + .cloned() + .unwrap_or_default(); + let use_fast_model = + Self::should_use_fast_auto_model(context.turn_index, &original_user_input); + let fallback_model = if use_fast_model { "fast" } else { "primary" }; + let resolved_model_id = ai_config.resolve_model_selection(fallback_model); + + if let Some(resolved_model_id) = resolved_model_id { + self.session_manager + .update_session_model_id(&context.session_id, &resolved_model_id) + .await?; + + info!( + "Auto model resolved: session_id={}, turn_index={}, user_input_chars={}, strategy={}, resolved_model_id={}", + context.session_id, + context.turn_index, + original_user_input.chars().count(), + fallback_model, + resolved_model_id + ); + + resolved_model_id + } else { + warn!( + "Auto model strategy unresolved, keeping symbolic selector: session_id={}, strategy={}", + context.session_id, fallback_model + ); + fallback_model.to_string() + } + } + } else { + configured_model_id.clone() + }; info!( - "Agent using model: agent={}, model_id={}", + "Agent using model: agent={}, configured_model_id={}, resolved_model_id={}", current_agent.name(), + configured_model_id, model_id ); @@ -482,7 +592,10 @@ impl ExecutionEngine { let allowed_tools = agent_registry .get_agent_tools( &agent_type, - context.workspace.as_ref().map(|workspace| workspace.root_path()), + context + .workspace + .as_ref() + .map(|workspace| workspace.root_path()), ) .await; let enable_tools = context @@ -502,13 +615,6 @@ impl ExecutionEngine { (vec![], None) }; - // Get session configuration - let session = self - .session_manager - .get_session(&context.session_id) - .ok_or_else(|| { - BitFunError::Session(format!("Session not found: {}", context.session_id)) - })?; let enable_context_compression = session.config.enable_context_compression; let compression_threshold = session.config.compression_threshold; // Detect whether the primary model supports multimodal image inputs. @@ -521,20 +627,7 @@ impl ExecutionEngine { let ai_config: crate::service::config::types::AIConfig = service.get_config(Some("ai")).await.unwrap_or_default(); - let resolved_id = match model_id.as_str() { - "primary" => ai_config - .default_models - .primary - .clone() - .unwrap_or_else(|| model_id.clone()), - "fast" => ai_config - .default_models - .fast - .clone() - .or_else(|| ai_config.default_models.primary.clone()) - .unwrap_or_else(|| model_id.clone()), - _ => model_id.clone(), - }; + let resolved_id = Self::resolve_configured_model_id(&ai_config, &model_id); let model_cfg = ai_config .models @@ -702,7 +795,10 @@ impl ExecutionEngine { let ai_messages = Self::build_ai_messages_for_send( &messages, &ai_client.config.format, - context.workspace.as_ref().map(|workspace| workspace.root_path()), + context + .workspace + .as_ref() + .map(|workspace| workspace.root_path()), &context.dialog_turn_id, ) .await?; @@ -995,3 +1091,68 @@ impl ExecutionEngine { let _ = self.event_queue.enqueue(event, Some(priority)).await; } } + +#[cfg(test)] +mod tests { + use super::ExecutionEngine; + use crate::service::config::types::AIConfig; + use crate::service::config::types::AIModelConfig; + + fn build_model(id: &str, name: &str, model_name: &str) -> AIModelConfig { + AIModelConfig { + id: id.to_string(), + name: name.to_string(), + model_name: model_name.to_string(), + provider: "anthropic".to_string(), + enabled: true, + ..Default::default() + } + } + + #[test] + fn auto_model_uses_fast_for_short_first_message() { + assert!(ExecutionEngine::should_use_fast_auto_model(0, "你好")); + assert!(ExecutionEngine::should_use_fast_auto_model(0, "1234567890")); + } + + #[test] + fn auto_model_uses_primary_for_long_first_message() { + assert!(!ExecutionEngine::should_use_fast_auto_model( + 0, + "12345678901" + )); + } + + #[test] + fn auto_model_uses_primary_after_first_turn() { + assert!(!ExecutionEngine::should_use_fast_auto_model(1, "短消息")); + } + + #[test] + fn resolve_configured_fast_model_falls_back_to_primary_when_fast_is_stale() { + let mut ai_config = AIConfig::default(); + ai_config.models = vec![build_model("model-primary", "Primary", "claude-sonnet-4.5")]; + ai_config.default_models.primary = Some("model-primary".to_string()); + ai_config.default_models.fast = Some("deleted-fast-model".to_string()); + + assert_eq!( + ExecutionEngine::resolve_configured_model_id(&ai_config, "fast"), + "model-primary" + ); + } + + #[test] + fn invalid_locked_auto_model_is_ignored() { + let mut ai_config = AIConfig::default(); + ai_config.models = vec![build_model("model-primary", "Primary", "claude-sonnet-4.5")]; + ai_config.default_models.primary = Some("model-primary".to_string()); + + assert_eq!( + ExecutionEngine::resolve_locked_auto_model_id( + &ai_config, + Some(&"deleted-fast-model".to_string()) + ), + None + ); + } +} diff --git a/src/crates/core/src/agentic/execution/mod.rs b/src/crates/core/src/agentic/execution/mod.rs index 0f8a664d..af22b10f 100644 --- a/src/crates/core/src/agentic/execution/mod.rs +++ b/src/crates/core/src/agentic/execution/mod.rs @@ -1,14 +1,13 @@ //! Execution Engine Layer -//! +//! //! Responsible for AI interaction and model round control -pub mod types; -pub mod stream_processor; -pub mod round_executor; pub mod execution_engine; +pub mod round_executor; +pub mod stream_processor; +pub mod types; pub use execution_engine::*; pub use round_executor::*; pub use stream_processor::*; pub use types::{ExecutionContext, ExecutionResult, FinishReason, RoundContext, RoundResult}; - diff --git a/src/crates/core/src/agentic/execution/round_executor.rs b/src/crates/core/src/agentic/execution/round_executor.rs index 357f069e..0d70a807 100644 --- a/src/crates/core/src/agentic/execution/round_executor.rs +++ b/src/crates/core/src/agentic/execution/round_executor.rs @@ -362,7 +362,8 @@ impl RoundExecutor { (None, None, false) // Default: no timeout, requires confirmation }; - let skip_from_context = context.context_vars + let skip_from_context = context + .context_vars .get("skip_tool_confirmation") .map(|v| v == "true") .unwrap_or(false); diff --git a/src/crates/core/src/agentic/execution/types.rs b/src/crates/core/src/agentic/execution/types.rs index 80cef1e5..0ca43b04 100644 --- a/src/crates/core/src/agentic/execution/types.rs +++ b/src/crates/core/src/agentic/execution/types.rs @@ -2,8 +2,8 @@ use crate::agentic::core::Message; use crate::agentic::tools::pipeline::SubagentParentInfo; -use serde_json::Value; use crate::agentic::WorkspaceBinding; +use serde_json::Value; use std::collections::HashMap; use tokio_util::sync::CancellationToken; diff --git a/src/crates/core/src/agentic/image_analysis/enhancer.rs b/src/crates/core/src/agentic/image_analysis/enhancer.rs index 93b38876..3d24e799 100644 --- a/src/crates/core/src/agentic/image_analysis/enhancer.rs +++ b/src/crates/core/src/agentic/image_analysis/enhancer.rs @@ -23,12 +23,16 @@ impl MessageEnhancer { if !image_analyses.is_empty() { enhanced.push_str("User uploaded "); enhanced.push_str(&image_analyses.len().to_string()); - enhanced.push_str(" image(s). AI's understanding of the image content is as follows:\n\n"); + enhanced + .push_str(" image(s). AI's understanding of the image content is as follows:\n\n"); for (idx, analysis) in image_analyses.iter().enumerate() { enhanced.push_str(&format!("[Image {}]\n", idx + 1)); enhanced.push_str(&format!("• Summary: {}\n", analysis.summary)); - enhanced.push_str(&format!("• Detailed description: {}\n", analysis.detailed_description)); + enhanced.push_str(&format!( + "• Detailed description: {}\n", + analysis.detailed_description + )); if !analysis.detected_elements.is_empty() { enhanced.push_str("• Key elements: "); diff --git a/src/crates/core/src/agentic/image_analysis/mod.rs b/src/crates/core/src/agentic/image_analysis/mod.rs index 0778eb2a..aef9a8e4 100644 --- a/src/crates/core/src/agentic/image_analysis/mod.rs +++ b/src/crates/core/src/agentic/image_analysis/mod.rs @@ -9,11 +9,10 @@ pub mod types; pub use enhancer::MessageEnhancer; pub use image_processing::{ - build_multimodal_message, decode_data_url, detect_mime_type_from_bytes, load_image_from_path, - optimize_image_for_provider, optimize_image_with_size_limit, - process_image_contexts_for_provider, resolve_image_path, - resolve_vision_model_from_ai_config, resolve_vision_model_from_global_config, - build_multimodal_message_with_images, ProcessedImage, + build_multimodal_message, build_multimodal_message_with_images, decode_data_url, + detect_mime_type_from_bytes, load_image_from_path, optimize_image_for_provider, + optimize_image_with_size_limit, process_image_contexts_for_provider, resolve_image_path, + resolve_vision_model_from_ai_config, resolve_vision_model_from_global_config, ProcessedImage, }; pub use processor::ImageAnalyzer; pub use types::*; diff --git a/src/crates/core/src/agentic/persistence/manager.rs b/src/crates/core/src/agentic/persistence/manager.rs index 8d9fc6d5..537b0f30 100644 --- a/src/crates/core/src/agentic/persistence/manager.rs +++ b/src/crates/core/src/agentic/persistence/manager.rs @@ -87,11 +87,13 @@ impl PersistenceManager { } fn metadata_path(&self, workspace_path: &Path, session_id: &str) -> PathBuf { - self.session_dir(workspace_path, session_id).join("metadata.json") + self.session_dir(workspace_path, session_id) + .join("metadata.json") } fn state_path(&self, workspace_path: &Path, session_id: &str) -> PathBuf { - self.session_dir(workspace_path, session_id).join("state.json") + self.session_dir(workspace_path, session_id) + .join("state.json") } fn turns_dir(&self, workspace_path: &Path, session_id: &str) -> PathBuf { @@ -99,7 +101,8 @@ impl PersistenceManager { } fn snapshots_dir(&self, workspace_path: &Path, session_id: &str) -> PathBuf { - self.session_dir(workspace_path, session_id).join("snapshots") + self.session_dir(workspace_path, session_id) + .join("snapshots") } fn turn_path(&self, workspace_path: &Path, session_id: &str, turn_index: usize) -> PathBuf { @@ -123,13 +126,20 @@ impl PersistenceManager { async fn ensure_project_sessions_dir(&self, workspace_path: &Path) -> BitFunResult { let dir = self.project_sessions_dir(workspace_path); - fs::create_dir_all(&dir) - .await - .map_err(|e| BitFunError::io(format!("Failed to create project sessions directory: {}", e)))?; + fs::create_dir_all(&dir).await.map_err(|e| { + BitFunError::io(format!( + "Failed to create project sessions directory: {}", + e + )) + })?; Ok(dir) } - async fn ensure_session_dir(&self, workspace_path: &Path, session_id: &str) -> BitFunResult { + async fn ensure_session_dir( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { let dir = self.session_dir(workspace_path, session_id); fs::create_dir_all(&dir) .await @@ -137,7 +147,11 @@ impl PersistenceManager { Ok(dir) } - async fn ensure_turns_dir(&self, workspace_path: &Path, session_id: &str) -> BitFunResult { + async fn ensure_turns_dir( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { let dir = self.turns_dir(workspace_path, session_id); fs::create_dir_all(&dir) .await @@ -157,14 +171,21 @@ impl PersistenceManager { Ok(dir) } - async fn read_json_optional(&self, path: &Path) -> BitFunResult> { + async fn read_json_optional( + &self, + path: &Path, + ) -> BitFunResult> { if !path.exists() { return Ok(None); } - let content = fs::read_to_string(path) - .await - .map_err(|e| BitFunError::io(format!("Failed to read JSON file {}: {}", path.display(), e)))?; + let content = fs::read_to_string(path).await.map_err(|e| { + BitFunError::io(format!( + "Failed to read JSON file {}: {}", + path.display(), + e + )) + })?; let value = serde_json::from_str::(&content).map_err(|e| { BitFunError::Deserialization(format!( @@ -179,7 +200,10 @@ impl PersistenceManager { async fn write_json_atomic(&self, path: &Path, value: &T) -> BitFunResult<()> { let parent = path.parent().ok_or_else(|| { - BitFunError::io(format!("Target path has no parent directory: {}", path.display())) + BitFunError::io(format!( + "Target path has no parent directory: {}", + path.display() + )) })?; fs::create_dir_all(parent) @@ -197,7 +221,10 @@ impl PersistenceManager { for attempt in 0..=JSON_WRITE_MAX_RETRIES { let tmp_path = Self::build_temp_json_path(path, attempt)?; if let Err(e) = fs::write(&tmp_path, &json_bytes).await { - return Err(BitFunError::io(format!("Failed to write temp JSON file: {}", e))); + return Err(BitFunError::io(format!( + "Failed to write temp JSON file: {}", + e + ))); } match Self::replace_file_from_temp(path, &tmp_path).await { @@ -228,12 +255,19 @@ impl PersistenceManager { path.display() ); fs::write(path, &json_bytes).await.map_err(|e| { - BitFunError::io(format!("Failed fallback JSON overwrite {}: {}", path.display(), e)) + BitFunError::io(format!( + "Failed fallback JSON overwrite {}: {}", + path.display(), + e + )) })?; return Ok(()); } - return Err(BitFunError::io(format!("Failed to replace JSON file: {}", error))); + return Err(BitFunError::io(format!( + "Failed to replace JSON file: {}", + error + ))); } Err(BitFunError::io(format!( @@ -253,7 +287,10 @@ impl PersistenceManager { fn build_temp_json_path(path: &Path, attempt: usize) -> BitFunResult { let parent = path.parent().ok_or_else(|| { - BitFunError::io(format!("Target path has no parent directory: {}", path.display())) + BitFunError::io(format!( + "Target path has no parent directory: {}", + path.display() + )) })?; let file_name = path @@ -438,11 +475,9 @@ impl PersistenceManager { .await .map_err(|e| BitFunError::io(format!("Failed to read sessions root: {}", e)))?; - while let Some(entry) = entries - .next_entry() - .await - .map_err(|e| BitFunError::io(format!("Failed to read session directory entry: {}", e)))? - { + while let Some(entry) = entries.next_entry().await.map_err(|e| { + BitFunError::io(format!("Failed to read session directory entry: {}", e)) + })? { let file_type = entry .file_type() .await @@ -452,7 +487,10 @@ impl PersistenceManager { } let session_id = entry.file_name().to_string_lossy().to_string(); - match self.load_session_metadata(workspace_path, &session_id).await { + match self + .load_session_metadata(workspace_path, &session_id) + .await + { Ok(Some(metadata)) => metadata_list.push(metadata), Ok(None) => {} Err(e) => { @@ -502,30 +540,47 @@ impl PersistenceManager { index.sessions.push(metadata.clone()); } - index.sessions.sort_by(|a, b| b.last_active_at.cmp(&a.last_active_at)); + index + .sessions + .sort_by(|a, b| b.last_active_at.cmp(&a.last_active_at)); index.updated_at = Self::system_time_to_unix_ms(SystemTime::now()); index.schema_version = SESSION_SCHEMA_VERSION; self.write_json_atomic(&index_path, &index).await } - async fn remove_index_entry(&self, workspace_path: &Path, session_id: &str) -> BitFunResult<()> { + async fn remove_index_entry( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<()> { let index_path = self.index_path(workspace_path); - let Some(mut index) = self.read_json_optional::(&index_path).await? else { + let Some(mut index) = self + .read_json_optional::(&index_path) + .await? + else { return Ok(()); }; - index.sessions.retain(|value| value.session_id != session_id); + index + .sessions + .retain(|value| value.session_id != session_id); index.updated_at = Self::system_time_to_unix_ms(SystemTime::now()); self.write_json_atomic(&index_path, &index).await } - pub async fn list_session_metadata(&self, workspace_path: &Path) -> BitFunResult> { + pub async fn list_session_metadata( + &self, + workspace_path: &Path, + ) -> BitFunResult> { if !workspace_path.exists() { return Ok(Vec::new()); } let index_path = self.index_path(workspace_path); - if let Some(index) = self.read_json_optional::(&index_path).await? { + if let Some(index) = self + .read_json_optional::(&index_path) + .await? + { return Ok(index.sessions); } @@ -537,15 +592,19 @@ impl PersistenceManager { workspace_path: &Path, metadata: &SessionMetadata, ) -> BitFunResult<()> { - self.ensure_session_dir(workspace_path, &metadata.session_id).await?; + self.ensure_session_dir(workspace_path, &metadata.session_id) + .await?; let file = StoredSessionMetadataFile { schema_version: SESSION_SCHEMA_VERSION, metadata: metadata.clone(), }; - self.write_json_atomic(&self.metadata_path(workspace_path, &metadata.session_id), &file) - .await?; + self.write_json_atomic( + &self.metadata_path(workspace_path, &metadata.session_id), + &file, + ) + .await?; self.upsert_index_entry(workspace_path, metadata).await } @@ -566,8 +625,10 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult> { - self.read_json_optional::(&self.state_path(workspace_path, session_id)) - .await + self.read_json_optional::( + &self.state_path(workspace_path, session_id), + ) + .await } async fn save_stored_session_state( @@ -589,7 +650,8 @@ impl PersistenceManager { turn_index: usize, messages: &[Message], ) -> BitFunResult<()> { - self.ensure_snapshots_dir(workspace_path, session_id).await?; + self.ensure_snapshots_dir(workspace_path, session_id) + .await?; let snapshot = StoredTurnContextSnapshotFile { schema_version: SESSION_SCHEMA_VERSION, @@ -717,13 +779,16 @@ impl PersistenceManager { if !workspace_path.exists() { return Ok(()); } - self.ensure_session_dir(workspace_path, &session.session_id).await?; + self.ensure_session_dir(workspace_path, &session.session_id) + .await?; let existing_metadata = self .load_session_metadata(workspace_path, &session.session_id) .await?; - let metadata = self.build_session_metadata(workspace_path, session, existing_metadata.as_ref()); - self.save_session_metadata(workspace_path, &metadata).await?; + let metadata = + self.build_session_metadata(workspace_path, session, existing_metadata.as_ref()); + self.save_session_metadata(workspace_path, &metadata) + .await?; let state = StoredSessionStateFile { schema_version: SESSION_SCHEMA_VERSION, @@ -737,11 +802,17 @@ impl PersistenceManager { } /// Load session - pub async fn load_session(&self, workspace_path: &Path, session_id: &str) -> BitFunResult { + pub async fn load_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { let metadata = self .load_session_metadata(workspace_path, session_id) .await? - .ok_or_else(|| BitFunError::NotFound(format!("Session metadata not found: {}", session_id)))?; + .ok_or_else(|| { + BitFunError::NotFound(format!("Session metadata not found: {}", session_id)) + })?; let stored_state = self .load_stored_session_state(workspace_path, session_id) .await?; @@ -814,12 +885,16 @@ impl PersistenceManager { } /// Delete session - pub async fn delete_session(&self, workspace_path: &Path, session_id: &str) -> BitFunResult<()> { + pub async fn delete_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<()> { let dir = self.session_dir(workspace_path, session_id); if dir.exists() { - fs::remove_dir_all(&dir) - .await - .map_err(|e| BitFunError::io(format!("Failed to delete session directory: {}", e)))?; + fs::remove_dir_all(&dir).await.map_err(|e| { + BitFunError::io(format!("Failed to delete session directory: {}", e)) + })?; } self.remove_index_entry(workspace_path, session_id).await?; @@ -869,7 +944,8 @@ impl PersistenceManager { workspace_path: &Path, turn: &DialogTurnData, ) -> BitFunResult<()> { - self.ensure_turns_dir(workspace_path, &turn.session_id).await?; + self.ensure_turns_dir(workspace_path, &turn.session_id) + .await?; let file = StoredDialogTurnFile { schema_version: SESSION_SCHEMA_VERSION, @@ -893,13 +969,15 @@ impl PersistenceManager { ) }); - let turns = self.load_session_turns(workspace_path, &turn.session_id).await?; + let turns = self + .load_session_turns(workspace_path, &turn.session_id) + .await?; metadata.turn_count = turns.len(); metadata.message_count = turns.iter().map(Self::estimate_turn_message_count).sum(); metadata.tool_call_count = turns.iter().map(DialogTurnData::count_tool_calls).sum(); - metadata.last_active_at = turn.end_time.unwrap_or_else(|| { - Self::system_time_to_unix_ms(SystemTime::now()) - }); + metadata.last_active_at = turn + .end_time + .unwrap_or_else(|| Self::system_time_to_unix_ms(SystemTime::now())); metadata.workspace_path = Some(workspace_path.to_string_lossy().to_string()); self.save_session_metadata(workspace_path, &metadata).await } @@ -960,7 +1038,10 @@ impl PersistenceManager { let mut turns = Vec::with_capacity(indexed_paths.len()); for (_, path) in indexed_paths { - if let Some(file) = self.read_json_optional::(&path).await? { + if let Some(file) = self + .read_json_optional::(&path) + .await? + { turns.push(file.turn); } } @@ -988,7 +1069,10 @@ impl PersistenceManager { let turns = self.load_session_turns(workspace_path, session_id).await?; let mut deleted = 0usize; - for turn in turns.into_iter().filter(|value| value.turn_index > turn_index) { + for turn in turns + .into_iter() + .filter(|value| value.turn_index > turn_index) + { let path = self.turn_path(workspace_path, session_id, turn.turn_index); if path.exists() { fs::remove_file(&path) @@ -998,13 +1082,23 @@ impl PersistenceManager { } } - if let Some(mut metadata) = self.load_session_metadata(workspace_path, session_id).await? { + if let Some(mut metadata) = self + .load_session_metadata(workspace_path, session_id) + .await? + { let remaining_turns = self.load_session_turns(workspace_path, session_id).await?; metadata.turn_count = remaining_turns.len(); - metadata.message_count = remaining_turns.iter().map(Self::estimate_turn_message_count).sum(); - metadata.tool_call_count = remaining_turns.iter().map(DialogTurnData::count_tool_calls).sum(); + metadata.message_count = remaining_turns + .iter() + .map(Self::estimate_turn_message_count) + .sum(); + metadata.tool_call_count = remaining_turns + .iter() + .map(DialogTurnData::count_tool_calls) + .sum(); metadata.last_active_at = Self::system_time_to_unix_ms(SystemTime::now()); - self.save_session_metadata(workspace_path, &metadata).await?; + self.save_session_metadata(workspace_path, &metadata) + .await?; } Ok(deleted) @@ -1019,7 +1113,10 @@ impl PersistenceManager { let turns = self.load_session_turns(workspace_path, session_id).await?; let mut deleted = 0usize; - for turn in turns.into_iter().filter(|value| value.turn_index >= turn_index) { + for turn in turns + .into_iter() + .filter(|value| value.turn_index >= turn_index) + { let path = self.turn_path(workspace_path, session_id, turn.turn_index); if path.exists() { fs::remove_file(&path) @@ -1029,22 +1126,36 @@ impl PersistenceManager { } } - if let Some(mut metadata) = self.load_session_metadata(workspace_path, session_id).await? { + if let Some(mut metadata) = self + .load_session_metadata(workspace_path, session_id) + .await? + { let remaining_turns = self.load_session_turns(workspace_path, session_id).await?; metadata.turn_count = remaining_turns.len(); - metadata.message_count = remaining_turns.iter().map(Self::estimate_turn_message_count).sum(); - metadata.tool_call_count = remaining_turns.iter().map(DialogTurnData::count_tool_calls).sum(); + metadata.message_count = remaining_turns + .iter() + .map(Self::estimate_turn_message_count) + .sum(); + metadata.tool_call_count = remaining_turns + .iter() + .map(DialogTurnData::count_tool_calls) + .sum(); metadata.last_active_at = Self::system_time_to_unix_ms(SystemTime::now()); - self.save_session_metadata(workspace_path, &metadata).await?; + self.save_session_metadata(workspace_path, &metadata) + .await?; } Ok(deleted) } pub async fn touch_session(&self, workspace_path: &Path, session_id: &str) -> BitFunResult<()> { - if let Some(mut metadata) = self.load_session_metadata(workspace_path, session_id).await? { + if let Some(mut metadata) = self + .load_session_metadata(workspace_path, session_id) + .await? + { metadata.touch(); - self.save_session_metadata(workspace_path, &metadata).await?; + self.save_session_metadata(workspace_path, &metadata) + .await?; } Ok(()) } @@ -1061,9 +1172,9 @@ impl PersistenceManager { async fn ensure_legacy_session_dir(&self, session_id: &str) -> BitFunResult { let dir = self.legacy_session_dir(session_id); - fs::create_dir_all(&dir) - .await - .map_err(|e| BitFunError::io(format!("Failed to create legacy session directory: {}", e)))?; + fs::create_dir_all(&dir).await.map_err(|e| { + BitFunError::io(format!("Failed to create legacy session directory: {}", e)) + })?; Ok(dir) } @@ -1073,8 +1184,9 @@ impl PersistenceManager { let messages_path = dir.join("messages.jsonl"); let sanitized_message = Self::sanitize_message_for_persistence(message); - let json = serde_json::to_string(&sanitized_message) - .map_err(|e| BitFunError::serialization(format!("Failed to serialize message: {}", e)))?; + let json = serde_json::to_string(&sanitized_message).map_err(|e| { + BitFunError::serialization(format!("Failed to serialize message: {}", e)) + })?; let mut file = fs::OpenOptions::new() .create(true) @@ -1162,7 +1274,9 @@ impl PersistenceManager { .append(true) .open(&compressed_path) .await - .map_err(|e| BitFunError::io(format!("Failed to open compressed message file: {}", e)))?; + .map_err(|e| { + BitFunError::io(format!("Failed to open compressed message file: {}", e)) + })?; file.write_all(json.as_bytes()) .await @@ -1188,7 +1302,9 @@ impl PersistenceManager { .truncate(true) .open(&compressed_path) .await - .map_err(|e| BitFunError::io(format!("Failed to open compressed message file: {}", e)))?; + .map_err(|e| { + BitFunError::io(format!("Failed to open compressed message file: {}", e)) + })?; let sanitized_messages = Self::sanitize_messages_for_persistence(messages); for message in &sanitized_messages { @@ -1196,9 +1312,9 @@ impl PersistenceManager { BitFunError::serialization(format!("Failed to serialize compressed message: {}", e)) })?; - file.write_all(json.as_bytes()) - .await - .map_err(|e| BitFunError::io(format!("Failed to write compressed message: {}", e)))?; + file.write_all(json.as_bytes()).await.map_err(|e| { + BitFunError::io(format!("Failed to write compressed message: {}", e)) + })?; file.write_all(b"\n") .await .map_err(|e| BitFunError::io(format!("Failed to write newline: {}", e)))?; @@ -1224,19 +1340,17 @@ impl PersistenceManager { return Ok(None); } - let file = fs::File::open(&compressed_path) - .await - .map_err(|e| BitFunError::io(format!("Failed to open compressed message file: {}", e)))?; + let file = fs::File::open(&compressed_path).await.map_err(|e| { + BitFunError::io(format!("Failed to open compressed message file: {}", e)) + })?; let reader = BufReader::new(file); let mut lines = reader.lines(); let mut messages = Vec::new(); - while let Some(line) = lines - .next_line() - .await - .map_err(|e| BitFunError::io(format!("Failed to read compressed message line: {}", e)))? - { + while let Some(line) = lines.next_line().await.map_err(|e| { + BitFunError::io(format!("Failed to read compressed message line: {}", e)) + })? { if line.trim().is_empty() { continue; } diff --git a/src/crates/core/src/agentic/persistence/mod.rs b/src/crates/core/src/agentic/persistence/mod.rs index b6ac4cec..e60f7a01 100644 --- a/src/crates/core/src/agentic/persistence/mod.rs +++ b/src/crates/core/src/agentic/persistence/mod.rs @@ -1,9 +1,7 @@ //! Persistence layer -//! +//! //! Responsible for persistent storage and loading of data pub mod manager; pub use manager::PersistenceManager; - - diff --git a/src/crates/core/src/agentic/session/history_manager.rs b/src/crates/core/src/agentic/session/history_manager.rs index 702ce7b4..f83ac119 100644 --- a/src/crates/core/src/agentic/session/history_manager.rs +++ b/src/crates/core/src/agentic/session/history_manager.rs @@ -1,12 +1,12 @@ //! Message History Manager -//! +//! //! Manages session message history, supports memory caching and persistence -use log::debug; use crate::agentic::core::Message; use crate::agentic::persistence::PersistenceManager; use crate::util::errors::BitFunResult; use dashmap::DashMap; +use log::debug; use std::sync::Arc; /// Message history configuration @@ -27,10 +27,10 @@ impl Default for HistoryConfig { pub struct MessageHistoryManager { /// Message history in memory (by session ID) histories: Arc>>, - + /// Persistence manager persistence: Arc, - + /// Configuration config: HistoryConfig, } @@ -43,14 +43,14 @@ impl MessageHistoryManager { config, } } - + /// Create session history pub async fn create_session(&self, session_id: &str) -> BitFunResult<()> { self.histories.insert(session_id.to_string(), vec![]); debug!("Created session history: session_id={}", session_id); Ok(()) } - + /// Add message pub async fn add_message(&self, session_id: &str, message: Message) -> BitFunResult<()> { // 1. Add to memory @@ -58,33 +58,37 @@ impl MessageHistoryManager { messages.push(message.clone()); } else { // Session doesn't exist, create and add - self.histories.insert(session_id.to_string(), vec![message.clone()]); + self.histories + .insert(session_id.to_string(), vec![message.clone()]); } - + // 2. Persist if self.config.enable_persistence { - self.persistence.append_message(session_id, &message).await?; + self.persistence + .append_message(session_id, &message) + .await?; } - + Ok(()) } - + /// Get message history pub async fn get_messages(&self, session_id: &str) -> BitFunResult> { // First try to get from memory if let Some(messages) = self.histories.get(session_id) { return Ok(messages.clone()); } - + // Load from persistence if self.config.enable_persistence { let messages = self.persistence.load_messages(session_id).await?; - + // Cache to memory if !messages.is_empty() { - self.histories.insert(session_id.to_string(), messages.clone()); + self.histories + .insert(session_id.to_string(), messages.clone()); } - + Ok(messages) } else { Ok(vec![]) @@ -99,7 +103,7 @@ impl MessageHistoryManager { before_message_id: Option<&str>, ) -> BitFunResult<(Vec, bool)> { let messages = self.get_messages(session_id).await?; - + if messages.is_empty() { return Ok((vec![], false)); } @@ -116,24 +120,29 @@ impl MessageHistoryManager { let start_idx = end_idx.saturating_sub(limit); let has_more = start_idx > 0; - + Ok((messages[start_idx..end_idx].to_vec(), has_more)) } - + /// Get recent N messages - pub async fn get_recent_messages(&self, session_id: &str, count: usize) -> BitFunResult> { + pub async fn get_recent_messages( + &self, + session_id: &str, + count: usize, + ) -> BitFunResult> { let messages = self.get_messages(session_id).await?; let start = messages.len().saturating_sub(count); Ok(messages[start..].to_vec()) } - + /// Get message count pub async fn count_messages(&self, session_id: &str) -> usize { if let Some(messages) = self.histories.get(session_id) { messages.len() } else if self.config.enable_persistence { // Load from persistence - self.persistence.load_messages(session_id) + self.persistence + .load_messages(session_id) .await .map(|msgs| msgs.len()) .unwrap_or(0) @@ -141,43 +150,45 @@ impl MessageHistoryManager { 0 } } - + /// Clear message history pub async fn clear_messages(&self, session_id: &str) -> BitFunResult<()> { // Clear memory if let Some(mut messages) = self.histories.get_mut(session_id) { messages.clear(); } - + // Clear persistence if self.config.enable_persistence { self.persistence.clear_messages(session_id).await?; } - + debug!("Cleared session message history: session_id={}", session_id); Ok(()) } - + /// Delete session pub async fn delete_session(&self, session_id: &str) -> BitFunResult<()> { // Remove from memory self.histories.remove(session_id); - + // Delete from persistence if self.config.enable_persistence { self.persistence.delete_messages(session_id).await?; } - + debug!("Deleted session history: session_id={}", session_id); Ok(()) } - + /// Restore session (load from persistence) - pub async fn restore_session(&self, session_id: &str, messages: Vec) -> BitFunResult<()> { + pub async fn restore_session( + &self, + session_id: &str, + messages: Vec, + ) -> BitFunResult<()> { self.histories.insert(session_id.to_string(), messages); debug!("Restored session history: session_id={}", session_id); Ok(()) } } - - diff --git a/src/crates/core/src/agentic/session/mod.rs b/src/crates/core/src/agentic/session/mod.rs index 71c19c9d..baac1fed 100644 --- a/src/crates/core/src/agentic/session/mod.rs +++ b/src/crates/core/src/agentic/session/mod.rs @@ -1,13 +1,11 @@ //! Session Management Layer -//! +//! //! Provides session lifecycle management, message history, and context management -pub mod session_manager; -pub mod history_manager; pub mod compression_manager; +pub mod history_manager; +pub mod session_manager; -pub use session_manager::*; -pub use history_manager::*; pub use compression_manager::*; - - +pub use history_manager::*; +pub use session_manager::*; diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 8fa24fe9..6e50b47b 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -3,8 +3,8 @@ //! Responsible for session CRUD, lifecycle management, and resource association use crate::agentic::core::{ - CompressionState, DialogTurn, Message, ProcessingPhase, Session, - SessionConfig, SessionState, SessionSummary, TurnStats, + CompressionState, DialogTurn, Message, ProcessingPhase, Session, SessionConfig, SessionState, + SessionSummary, TurnStats, }; use crate::agentic::image_analysis::ImageContextData; use crate::agentic::persistence::PersistenceManager; @@ -131,7 +131,8 @@ impl SessionManager { .join("\n\n"); if !assistant_text.trim().is_empty() { - messages.push(Message::assistant(assistant_text).with_turn_id(turn.turn_id.clone())); + messages + .push(Message::assistant(assistant_text).with_turn_id(turn.turn_id.clone())); } } @@ -325,9 +326,10 @@ impl SessionManager { } if self.config.enable_persistence { - if let (Some(workspace_path), Some(session)) = - (self.session_workspace_path(session_id), self.sessions.get(session_id)) - { + if let (Some(workspace_path), Some(session)) = ( + self.session_workspace_path(session_id), + self.sessions.get(session_id), + ) { self.persistence_manager .save_session(&workspace_path, &session) .await?; @@ -342,6 +344,42 @@ impl SessionManager { Ok(()) } + /// Update session model id (in-memory + persistence) + pub async fn update_session_model_id( + &self, + session_id: &str, + model_id: &str, + ) -> BitFunResult<()> { + if let Some(mut session) = self.sessions.get_mut(session_id) { + session.config.model_id = Some(model_id.to_string()); + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); + } else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + } + + if self.config.enable_persistence { + if let (Some(workspace_path), Some(session)) = ( + self.session_workspace_path(session_id), + self.sessions.get(session_id), + ) { + self.persistence_manager + .save_session(&workspace_path, &session) + .await?; + } + } + + debug!( + "Session model id updated: session_id={}, model_id={}", + session_id, model_id + ); + + Ok(()) + } + /// Update session activity time pub fn touch_session(&self, session_id: &str) { if let Some(mut session) = self.sessions.get_mut(session_id) { @@ -350,7 +388,11 @@ impl SessionManager { } /// Delete session (cascade delete all resources) - pub async fn delete_session(&self, workspace_path: &Path, session_id: &str) -> BitFunResult<()> { + pub async fn delete_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<()> { // 1. Clean up snapshot system resources (including physical snapshot files) if let Ok(snapshot_manager) = ensure_snapshot_manager_for_workspace(workspace_path) { let snapshot_service = snapshot_manager.get_snapshot_service(); @@ -400,7 +442,11 @@ impl SessionManager { } /// Restore session (from persistent storage) - pub async fn restore_session(&self, workspace_path: &Path, session_id: &str) -> BitFunResult { + pub async fn restore_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { // Check if session is already in memory let session_already_in_memory = self.sessions.contains_key(session_id); @@ -432,9 +478,10 @@ impl SessionManager { latest_turn_index = Some(turn_index); msgs } - None => self - .rebuild_messages_from_turns(workspace_path, session_id) - .await?, + None => { + self.rebuild_messages_from_turns(workspace_path, session_id) + .await? + } }; if messages.is_empty() { @@ -602,12 +649,13 @@ impl SessionManager { let session = self .get_session(session_id) .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))?; - let workspace_path = Self::session_workspace_from_config(&session.config).ok_or_else(|| { - BitFunError::Validation(format!( - "Session workspace_path is missing: {}", - session_id - )) - })?; + let workspace_path = + Self::session_workspace_from_config(&session.config).ok_or_else(|| { + BitFunError::Validation(format!( + "Session workspace_path is missing: {}", + session_id + )) + })?; let turn_index = session.dialog_turn_ids.len(); // Pass frontend's turnId @@ -706,10 +754,12 @@ impl SessionManager { .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; - let has_assistant_text = turn - .model_rounds - .iter() - .any(|round| round.text_items.iter().any(|item| !item.content.trim().is_empty())); + let has_assistant_text = turn.model_rounds.iter().any(|round| { + round + .text_items + .iter() + .any(|item| !item.content.trim().is_empty()) + }); if !has_assistant_text && !final_response.trim().is_empty() { let round_index = turn.model_rounds.len(); turn.model_rounds.push(ModelRoundData { diff --git a/src/crates/core/src/agentic/tools/image_context.rs b/src/crates/core/src/agentic/tools/image_context.rs index fedebf1e..f870c87d 100644 --- a/src/crates/core/src/agentic/tools/image_context.rs +++ b/src/crates/core/src/agentic/tools/image_context.rs @@ -32,7 +32,7 @@ const DEFAULT_IMAGE_MAX_AGE_SECS: u64 = 300; pub trait ImageContextProvider: Send + Sync + std::fmt::Debug { /// Get image context data by image_id fn get_image(&self, image_id: &str) -> Option; - + /// Optional: delete image context (clean up after use) fn remove_image(&self, image_id: &str) { // Default implementation: do nothing @@ -57,7 +57,9 @@ pub fn store_image_contexts(images: Vec) { } pub fn get_image_context(image_id: &str) -> Option { - IMAGE_STORAGE.get(image_id).map(|entry| entry.value().0.clone()) + IMAGE_STORAGE + .get(image_id) + .map(|entry| entry.value().0.clone()) } pub fn remove_image_context(image_id: &str) { @@ -120,4 +122,3 @@ fn current_unix_timestamp() -> u64 { .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 861e7e4f..84b2c410 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 @@ -105,7 +105,8 @@ impl AskUserQuestionTool { fn format_result_for_assistant(questions: &[Question], answers: &Value) -> String { // Try flat structure first (frontend sends {"0": "...", "1": [...]}), // then fall back to nested {"answers": {...}} for backward compatibility - let answers_obj = answers.as_object() + let answers_obj = answers + .as_object() .or_else(|| answers.get("answers").and_then(|v| v.as_object())); if let Some(answers_map) = answers_obj { diff --git a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs index e7aa0edc..882da94d 100644 --- a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs @@ -405,7 +405,9 @@ Usage notes: let binding = terminal_api.session_manager().binding(); let workspace_path = context .workspace_root() - .ok_or_else(|| BitFunError::tool("workspace_path is required for Bash tool".to_string()))? + .ok_or_else(|| { + BitFunError::tool("workspace_path is required for Bash tool".to_string()) + })? .to_string_lossy() .to_string(); diff --git a/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs b/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs index b0eb641a..2edc29f7 100644 --- a/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs @@ -1,13 +1,15 @@ -use log::debug; +use crate::agentic::tools::framework::{ + Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; +use log::debug; use serde_json::{json, Value}; use std::path::Path; use tokio::fs; -use crate::agentic::tools::framework::{Tool, ToolUseContext, ToolResult, ValidationResult, ToolRenderOptions}; -use crate::util::errors::{BitFunError, BitFunResult}; /// File deletion tool - provides safe file/directory deletion functionality -/// +/// /// This tool automatically integrates with the snapshot system, all deletion operations are recorded and support rollback pub struct DeleteFileTool; @@ -22,7 +24,7 @@ impl Tool for DeleteFileTool { fn name(&self) -> &str { "Delete" } - + async fn description(&self) -> BitFunResult { Ok(r#"Deletes a file or directory from the filesystem. This operation is tracked by the snapshot system and can be rolled back if needed. @@ -73,7 +75,7 @@ Important notes: - All deletions can be rolled back through the snapshot interface - The tool will fail gracefully if permissions are insufficient"#.to_string()) } - + fn input_schema(&self) -> Value { json!({ "type": "object", @@ -90,20 +92,24 @@ Important notes: "required": ["path"] }) } - + fn is_readonly(&self) -> bool { false } - + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { false } - + fn needs_permissions(&self, _input: Option<&Value>) -> bool { false } - - async fn validate_input(&self, input: &Value, _context: Option<&ToolUseContext>) -> ValidationResult { + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { // Validate path parameter let path_str = match input.get("path").and_then(|v| v.as_str()) { Some(p) => p, @@ -116,7 +122,7 @@ Important notes: }; } }; - + if path_str.is_empty() { return ValidationResult { result: false, @@ -125,9 +131,9 @@ Important notes: meta: None, }; } - + let path = Path::new(path_str); - + // Validate if path is absolute if !path.is_absolute() { return ValidationResult { @@ -137,7 +143,7 @@ Important notes: meta: None, }; } - + // Validate if path exists if !path.exists() { return ValidationResult { @@ -147,19 +153,20 @@ Important notes: meta: None, }; } - + // If directory, check if recursive deletion is needed if path.is_dir() { - let recursive = input.get("recursive") + let recursive = input + .get("recursive") .and_then(|v| v.as_bool()) .unwrap_or(false); - + // Check if directory is empty let is_empty = match fs::read_dir(path).await { Ok(mut entries) => entries.next_entry().await.ok().flatten().is_none(), Err(_) => false, }; - + if !is_empty && !recursive { return ValidationResult { result: false, @@ -173,7 +180,7 @@ Important notes: }; } } - + ValidationResult { result: true, message: None, @@ -181,13 +188,14 @@ Important notes: meta: None, } } - + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { if let Some(path) = input.get("path").and_then(|v| v.as_str()) { - let recursive = input.get("recursive") + let recursive = input + .get("recursive") .and_then(|v| v.as_bool()) .unwrap_or(false); - + if recursive { format!("Deleting directory and contents: {}", path) } else { @@ -197,49 +205,63 @@ Important notes: "Deleting file or directory".to_string() } } - + fn render_result_for_assistant(&self, output: &Value) -> String { if let Some(path) = output.get("path").and_then(|v| v.as_str()) { - let is_directory = output.get("is_directory") + let is_directory = output + .get("is_directory") .and_then(|v| v.as_bool()) .unwrap_or(false); - + let type_name = if is_directory { "directory" } else { "file" }; - + format!("Successfully deleted {} at: {}", type_name, path) } else { "Deletion completed".to_string() } } - - async fn call_impl(&self, input: &Value, _context: &ToolUseContext) -> BitFunResult> { - let path_str = input.get("path") + + async fn call_impl( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let path_str = input + .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| BitFunError::tool("path is required".to_string()))?; - - let recursive = input.get("recursive") + + let recursive = input + .get("recursive") .and_then(|v| v.as_bool()) .unwrap_or(false); - + let path = Path::new(path_str); let is_directory = path.is_dir(); - - debug!("DeleteFile tool deleting {}: {}", if is_directory { "directory" } else { "file" }, path_str); - + + debug!( + "DeleteFile tool deleting {}: {}", + if is_directory { "directory" } else { "file" }, + path_str + ); + // Execute deletion operation if is_directory { if recursive { - fs::remove_dir_all(path).await + fs::remove_dir_all(path) + .await .map_err(|e| BitFunError::tool(format!("Failed to delete directory: {}", e)))?; } else { - fs::remove_dir(path).await + fs::remove_dir(path) + .await .map_err(|e| BitFunError::tool(format!("Failed to delete directory: {}", e)))?; } } else { - fs::remove_file(path).await + fs::remove_file(path) + .await .map_err(|e| BitFunError::tool(format!("Failed to delete file: {}", e)))?; } - + // Build result let result_data = json!({ "success": true, @@ -247,9 +269,9 @@ Important notes: "is_directory": is_directory, "recursive": recursive }); - + let result_text = self.render_result_for_assistant(&result_data); - + Ok(vec![ToolResult::Result { data: result_data, result_for_assistant: Some(result_text), diff --git a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs index 5f322e17..5d59df31 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs @@ -69,10 +69,7 @@ Usage: input: &Value, context: Option<&ToolUseContext>, ) -> ValidationResult { - let file_path = match input - .get("file_path") - .and_then(|v| v.as_str()) - { + let file_path = match input.get("file_path").and_then(|v| v.as_str()) { Some(path) if !path.is_empty() => path, _ => { return ValidationResult { @@ -93,10 +90,9 @@ Usage: }; } - if let Err(err) = resolve_path_with_workspace( - file_path, - context.and_then(|ctx| ctx.workspace_root()), - ) { + if let Err(err) = + resolve_path_with_workspace(file_path, context.and_then(|ctx| ctx.workspace_root())) + { return ValidationResult { result: false, message: Some(err.to_string()), diff --git a/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs b/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs index ffd8f171..0887f0c7 100644 --- a/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs @@ -418,7 +418,10 @@ Usage: // Priority 1: Try baseline diff let path = Path::new(&resolved_path); - if let Some(result) = self.try_baseline_diff(&path, context.workspace_root()).await { + if let Some(result) = self + .try_baseline_diff(&path, context.workspace_root()) + .await + { match result { Ok(data) => { debug!("GetFileDiff tool using baseline diff"); diff --git a/src/crates/core/src/agentic/tools/implementations/glob_tool.rs b/src/crates/core/src/agentic/tools/implementations/glob_tool.rs index 1578fc70..a494af0b 100644 --- a/src/crates/core/src/agentic/tools/implementations/glob_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/glob_tool.rs @@ -202,14 +202,11 @@ impl Tool for GlobTool { })?; workspace_root.join(user_path) } - None => context - .workspace_root() - .map(PathBuf::from) - .ok_or_else(|| { - BitFunError::tool( - "workspace_path is required when Glob path is omitted".to_string(), - ) - })?, + None => context.workspace_root().map(PathBuf::from).ok_or_else(|| { + BitFunError::tool( + "workspace_path is required when Glob path is omitted".to_string(), + ) + })?, }; let limit = input diff --git a/src/crates/core/src/agentic/tools/implementations/ide_control_tool.rs b/src/crates/core/src/agentic/tools/implementations/ide_control_tool.rs index 6fd779b6..3e9b1da9 100644 --- a/src/crates/core/src/agentic/tools/implementations/ide_control_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/ide_control_tool.rs @@ -58,11 +58,17 @@ impl IdeControlTool { /// Validate if panel type is valid fn is_valid_panel_type(&self, panel_type: &str) -> bool { - matches!(panel_type, - "git-settings" | "git-diff" | - "config-center" | "planner" | - "files" | "code-editor" | "markdown-editor" | - "ai-session" | "mermaid-editor" + matches!( + panel_type, + "git-settings" + | "git-diff" + | "config-center" + | "planner" + | "files" + | "code-editor" + | "markdown-editor" + | "ai-session" + | "mermaid-editor" ) } diff --git a/src/crates/core/src/agentic/tools/implementations/mermaid_interactive_tool.rs b/src/crates/core/src/agentic/tools/implementations/mermaid_interactive_tool.rs index 2b3c511b..f6b76a49 100644 --- a/src/crates/core/src/agentic/tools/implementations/mermaid_interactive_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/mermaid_interactive_tool.rs @@ -1,14 +1,14 @@ //! Mermaid interactive diagram tool -//! +//! //! Allows Agent to generate Mermaid diagrams with interactive features, supports node click navigation and highlight states -use log::debug; -use crate::agentic::tools::framework::{Tool, ToolUseContext, ToolResult, ValidationResult}; +use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; +use crate::infrastructure::events::event_system::{get_global_event_system, BackendEvent}; use crate::util::errors::BitFunResult; -use crate::infrastructure::events::event_system::{BackendEvent, get_global_event_system}; -use serde_json::{json, Value}; use async_trait::async_trait; use chrono::Utc; +use log::debug; +use serde_json::{json, Value}; /// Mermaid interactive diagram tool pub struct MermaidInteractiveTool; @@ -21,21 +21,34 @@ impl MermaidInteractiveTool { /// Validate if Mermaid code is valid, returns validation result and error message fn validate_mermaid_code(&self, code: &str) -> (bool, Option) { let trimmed = code.trim(); - + // Check if empty if trimmed.is_empty() { return (false, Some("Mermaid code cannot be empty".to_string())); } - + // Check if starts with valid diagram type let valid_starters = vec![ - "graph ", "flowchart ", "sequenceDiagram", "classDiagram", - "stateDiagram", "erDiagram", "gantt", "pie", "journey", - "timeline", "mindmap", "gitgraph", "C4Context", "C4Container" + "graph ", + "flowchart ", + "sequenceDiagram", + "classDiagram", + "stateDiagram", + "erDiagram", + "gantt", + "pie", + "journey", + "timeline", + "mindmap", + "gitgraph", + "C4Context", + "C4Container", ]; - - let starts_with_valid = valid_starters.iter().any(|starter| trimmed.starts_with(starter)); - + + let starts_with_valid = valid_starters + .iter() + .any(|starter| trimmed.starts_with(starter)); + if !starts_with_valid { return (false, Some(format!( "Mermaid code must start with a valid diagram type. Supported diagram types: graph, flowchart, sequenceDiagram, classDiagram, stateDiagram, erDiagram, gantt, pie, journey, timeline, mindmap, etc.\nCurrent code start: {}", @@ -46,45 +59,50 @@ impl MermaidInteractiveTool { } ))); } - + // Check basic syntax structure let lines: Vec<&str> = trimmed.lines().collect(); if lines.len() < 2 { return (false, Some("Mermaid code needs at least 2 lines (diagram type declaration and at least one node/relationship)".to_string())); } - + // Check if graph/flowchart has node definitions if trimmed.starts_with("graph ") || trimmed.starts_with("flowchart ") { // Check if there are arrows or node definitions - let has_arrow = trimmed.contains("-->") || trimmed.contains("---") || trimmed.contains("==>"); + let has_arrow = + trimmed.contains("-->") || trimmed.contains("---") || trimmed.contains("==>"); let has_node = trimmed.contains('[') || trimmed.contains('(') || trimmed.contains('{'); - + if !has_arrow && !has_node { return (false, Some("Flowchart (graph/flowchart) must contain node definitions and connections. Example: A[Node] --> B[Node]".to_string())); } } - + // Check if sequenceDiagram has participants if trimmed.starts_with("sequenceDiagram") { - if !trimmed.contains("participant") && !trimmed.contains("->>") && !trimmed.contains("-->>") { + if !trimmed.contains("participant") + && !trimmed.contains("->>") + && !trimmed.contains("-->>") + { return (false, Some("Sequence diagram (sequenceDiagram) must contain participant definitions and interaction arrows. Example: participant A\nA->>B: Message".to_string())); } } - + // Check if classDiagram has class definitions if trimmed.starts_with("classDiagram") { - if !trimmed.contains("class ") && !trimmed.contains("<|--") && !trimmed.contains("..>") { + if !trimmed.contains("class ") && !trimmed.contains("<|--") && !trimmed.contains("..>") + { return (false, Some("Class diagram (classDiagram) must contain class definitions and relationships. Example: class A\nclass B\nA <|-- B".to_string())); } } - + // Check if stateDiagram has state definitions if trimmed.starts_with("stateDiagram") { if !trimmed.contains("state ") && !trimmed.contains("[*]") && !trimmed.contains("-->") { return (false, Some("State diagram (stateDiagram) must contain state definitions and transitions. Example: state A\n[*] --> A".to_string())); } } - + // Check for unclosed brackets let open_brackets = trimmed.matches('[').count(); let close_brackets = trimmed.matches(']').count(); @@ -94,7 +112,7 @@ impl MermaidInteractiveTool { open_brackets, close_brackets ))); } - + let open_parens = trimmed.matches('(').count(); let close_parens = trimmed.matches(')').count(); if open_parens != close_parens { @@ -103,7 +121,7 @@ impl MermaidInteractiveTool { open_parens, close_parens ))); } - + let open_braces = trimmed.matches('{').count(); let close_braces = trimmed.matches('}').count(); if open_braces != close_braces { @@ -112,16 +130,19 @@ impl MermaidInteractiveTool { open_braces, close_braces ))); } - + // Check for obvious syntax errors (like isolated arrows) - let lines_with_arrows: Vec<&str> = lines.iter() + let lines_with_arrows: Vec<&str> = lines + .iter() .filter(|line| { let trimmed_line = line.trim(); - trimmed_line.contains("-->") || trimmed_line.contains("---") || trimmed_line.contains("==>") + trimmed_line.contains("-->") + || trimmed_line.contains("---") + || trimmed_line.contains("==>") }) .copied() .collect(); - + for line in &lines_with_arrows { let trimmed_line = line.trim(); // Check if there are node identifiers before and after arrows @@ -139,7 +160,7 @@ impl MermaidInteractiveTool { } } } - + (true, None) } @@ -161,26 +182,29 @@ impl MermaidInteractiveTool { } // Check required field: file_path is required - let has_file_path = node_data.get("file_path") + let has_file_path = node_data + .get("file_path") .and_then(|v| v.as_str()) .map(|s| !s.is_empty()) .unwrap_or(false); - + if !has_file_path { return false; } // Get node type (defaults to file) - let node_type = node_data.get("node_type") + let node_type = node_data + .get("node_type") .and_then(|v| v.as_str()) .unwrap_or("file"); // For file type, line_number is required if node_type == "file" { - let has_line_number = node_data.get("line_number") + let has_line_number = node_data + .get("line_number") .and_then(|v| v.as_u64()) .is_some(); - + if !has_line_number { return false; } @@ -397,7 +421,11 @@ Mermaid Syntax: false } - async fn validate_input(&self, input: &Value, _context: Option<&ToolUseContext>) -> ValidationResult { + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { // Validate mermaid_code let mermaid_code = match input.get("mermaid_code").and_then(|v| v.as_str()) { Some(code) if !code.trim().is_empty() => code, @@ -418,7 +446,8 @@ Mermaid Syntax: // Validate Mermaid code format (returns detailed error message) let (is_valid, error_msg) = self.validate_mermaid_code(mermaid_code); if !is_valid { - let error_message = error_msg.unwrap_or_else(|| "Invalid Mermaid diagram syntax".to_string()); + let error_message = + error_msg.unwrap_or_else(|| "Invalid Mermaid diagram syntax".to_string()); return ValidationResult { result: false, message: Some(format!( @@ -458,16 +487,19 @@ Mermaid Syntax: fn render_result_for_assistant(&self, output: &Value) -> String { if let Some(success) = output.get("success").and_then(|v| v.as_bool()) { if success { - let title = output.get("title") + let title = output + .get("title") .and_then(|v| v.as_str()) .unwrap_or("Mermaid diagram"); - - let node_count = output.get("metadata") + + let node_count = output + .get("metadata") .and_then(|m| m.get("node_count")) .and_then(|v| v.as_u64()) .unwrap_or(0); - let interactive_nodes = output.get("metadata") + let interactive_nodes = output + .get("metadata") .and_then(|m| m.get("interactive_nodes")) .and_then(|v| v.as_u64()) .unwrap_or(0); @@ -485,7 +517,7 @@ Mermaid Syntax: } } } - + if let Some(error) = output.get("error").and_then(|v| v.as_str()) { return format!("Failed to create Mermaid diagram: {}", error); } @@ -493,15 +525,22 @@ Mermaid Syntax: "Mermaid diagram creation result unknown".to_string() } - fn render_tool_use_message(&self, input: &Value, _options: &crate::agentic::tools::framework::ToolRenderOptions) -> String { - let title = input.get("title") + fn render_tool_use_message( + &self, + input: &Value, + _options: &crate::agentic::tools::framework::ToolRenderOptions, + ) -> String { + let title = input + .get("title") .and_then(|v| v.as_str()) .unwrap_or("Interactive Mermaid Diagram"); - let has_metadata = input.get("node_metadata") + let has_metadata = input + .get("node_metadata") .and_then(|v| v.as_object()) .map(|obj| obj.len()) - .unwrap_or(0) > 0; + .unwrap_or(0) + > 0; if has_metadata { format!("Creating interactive diagram: {}", title) @@ -510,11 +549,16 @@ Mermaid Syntax: } } - async fn call_impl(&self, input: &Value, context: &ToolUseContext) -> BitFunResult> { - let mermaid_code = input.get("mermaid_code") + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let mermaid_code = input + .get("mermaid_code") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing mermaid_code field"))?; - + // Validate Mermaid code let (is_valid, error_msg) = self.validate_mermaid_code(mermaid_code); if !is_valid { @@ -538,15 +582,19 @@ Mermaid Syntax: }]); } - let title = input.get("title") + let title = input + .get("title") .and_then(|v| v.as_str()) .unwrap_or("Interactive Mermaid Diagram"); - let mode = input.get("mode") + let mode = input + .get("mode") .and_then(|v| v.as_str()) .unwrap_or("interactive"); - let session_id = context.session_id.clone() + let session_id = context + .session_id + .clone() .unwrap_or_else(|| format!("mermaid-{}", Utc::now().timestamp_millis())); // Build interactive configuration @@ -566,17 +614,19 @@ Mermaid Syntax: } // Calculate statistics - let node_count = mermaid_code.lines() + let node_count = mermaid_code + .lines() .filter(|line| { let trimmed = line.trim(); - !trimmed.is_empty() && - !trimmed.starts_with("%%") && - !trimmed.starts_with("style") && - !trimmed.starts_with("classDef") + !trimmed.is_empty() + && !trimmed.starts_with("%%") + && !trimmed.starts_with("style") + && !trimmed.starts_with("classDef") }) .count(); - let interactive_nodes = input.get("node_metadata") + let interactive_nodes = input + .get("node_metadata") .and_then(|v| v.as_object()) .map(|obj| obj.len()) .unwrap_or(0); @@ -614,7 +664,7 @@ Mermaid Syntax: "timestamp": Utc::now().timestamp_millis(), "session_id": session_id.clone() } - }) + }), }; debug!("MermaidInteractive tool creating diagram, mode: {}, title: {}, node_count: {}, interactive_nodes: {}", diff --git a/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs b/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs index 9ec535ed..d0c942ef 100644 --- a/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs @@ -2,10 +2,10 @@ use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use crate::infrastructure::events::{emit_global_event, BackendEvent}; +use crate::miniapp::try_get_global_miniapp_manager; use crate::miniapp::types::{ FsPermissions, MiniAppPermissions, MiniAppSource, NetPermissions, ShellPermissions, }; -use crate::miniapp::try_get_global_miniapp_manager; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use serde_json::{json, Value}; @@ -146,8 +146,12 @@ Returns app_id and the app root directory. Use the root directory and file names read: Some(vec!["{appdata}".to_string(), "{workspace}".to_string()]), write: Some(vec!["{appdata}".to_string()]), }), - shell: Some(ShellPermissions { allow: Some(Vec::new()) }), - net: Some(NetPermissions { allow: Some(vec!["*".to_string()]) }), + shell: Some(ShellPermissions { + allow: Some(Vec::new()), + }), + net: Some(NetPermissions { + allow: Some(vec!["*".to_string()]), + }), node: None, }; diff --git a/src/crates/core/src/agentic/tools/implementations/mod.rs b/src/crates/core/src/agentic/tools/implementations/mod.rs index a398cb73..348dad69 100644 --- a/src/crates/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/core/src/agentic/tools/implementations/mod.rs @@ -17,10 +17,10 @@ pub mod linter_tool; pub mod log_tool; pub mod ls_tool; pub mod mermaid_interactive_tool; +pub mod miniapp_init_tool; pub mod session_control_tool; pub mod skill_tool; pub mod skills; -pub mod miniapp_init_tool; pub mod task_tool; pub mod terminal_control_tool; pub mod todo_write_tool; @@ -45,11 +45,11 @@ pub use linter_tool::ReadLintsTool; pub use log_tool::LogTool; pub use ls_tool::LSTool; pub use mermaid_interactive_tool::MermaidInteractiveTool; +pub use miniapp_init_tool::InitMiniAppTool; pub use session_control_tool::SessionControlTool; pub use skill_tool::SkillTool; pub use task_tool::TaskTool; pub use terminal_control_tool::TerminalControlTool; pub use todo_write_tool::TodoWriteTool; -pub use miniapp_init_tool::InitMiniAppTool; pub use view_image_tool::ViewImageTool; pub use web_tools::{WebFetchTool, WebSearchTool}; diff --git a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs b/src/crates/core/src/agentic/tools/implementations/skill_tool.rs index 0d0bacca..82f96a28 100644 --- a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/skill_tool.rs @@ -59,9 +59,11 @@ Important: async fn build_description(&self, workspace_root: Option<&Path>) -> String { let registry = get_skill_registry(); let available_skills = match workspace_root { - Some(workspace_root) => registry - .get_enabled_skills_xml_for_workspace(Some(workspace_root)) - .await, + Some(workspace_root) => { + registry + .get_enabled_skills_xml_for_workspace(Some(workspace_root)) + .await + } None => registry.get_enabled_skills_xml().await, }; diff --git a/src/crates/core/src/agentic/tools/implementations/skills/registry.rs b/src/crates/core/src/agentic/tools/implementations/skills/registry.rs index f8b80b0a..b6671357 100644 --- a/src/crates/core/src/agentic/tools/implementations/skills/registry.rs +++ b/src/crates/core/src/agentic/tools/implementations/skills/registry.rs @@ -218,7 +218,10 @@ impl SkillRegistry { let by_name = self.scan_skill_map_for_workspace(workspace_root).await; let mut cache = self.cache.write().await; *cache = by_name; - debug!("SkillRegistry refreshed for workspace, {} skills loaded", cache.len()); + debug!( + "SkillRegistry refreshed for workspace, {} skills loaded", + cache.len() + ); } /// Ensure cache is initialized @@ -239,7 +242,10 @@ impl SkillRegistry { cache.values().cloned().collect() } - pub async fn get_all_skills_for_workspace(&self, workspace_root: Option<&Path>) -> Vec { + pub async fn get_all_skills_for_workspace( + &self, + workspace_root: Option<&Path>, + ) -> Vec { self.scan_skill_map_for_workspace(workspace_root) .await .into_values() @@ -375,9 +381,9 @@ impl SkillRegistry { workspace_root: Option<&Path>, ) -> BitFunResult { let skill_map = self.scan_skill_map_for_workspace(workspace_root).await; - let info = skill_map.get(skill_name).ok_or_else(|| { - BitFunError::tool(format!("Skill '{}' not found", skill_name)) - })?; + let info = skill_map + .get(skill_name) + .ok_or_else(|| BitFunError::tool(format!("Skill '{}' not found", skill_name)))?; if !info.enabled { return Err(BitFunError::tool(format!( diff --git a/src/crates/core/src/agentic/tools/implementations/skills/types.rs b/src/crates/core/src/agentic/tools/implementations/skills/types.rs index 88794252..3d839744 100644 --- a/src/crates/core/src/agentic/tools/implementations/skills/types.rs +++ b/src/crates/core/src/agentic/tools/implementations/skills/types.rs @@ -88,13 +88,17 @@ impl SkillData { .get("name") .and_then(|v| v.as_str()) .map(|s| s.to_string()) - .ok_or_else(|| BitFunError::tool("Missing required field 'name' in SKILL.md".to_string()))?; + .ok_or_else(|| { + BitFunError::tool("Missing required field 'name' in SKILL.md".to_string()) + })?; let description = metadata .get("description") .and_then(|v| v.as_str()) .map(|s| s.to_string()) - .ok_or_else(|| BitFunError::tool("Missing required field 'description' in SKILL.md".to_string()))?; + .ok_or_else(|| { + BitFunError::tool("Missing required field 'description' in SKILL.md".to_string()) + })?; // enabled field defaults to true if not present let enabled = metadata @@ -102,11 +106,7 @@ impl SkillData { .and_then(|v| v.as_bool()) .unwrap_or(true); - let skill_content = if with_content { - body - } else { - String::new() - }; + let skill_content = if with_content { body } else { String::new() }; Ok(SkillData { name, @@ -119,7 +119,7 @@ impl SkillData { } /// Set enabled status and save to SKILL.md file - /// + /// /// If enabled is true, remove enabled field (use default value) /// If enabled is false, write enabled: false pub fn set_enabled_and_save(skill_md_path: &str, enabled: bool) -> BitFunResult<()> { @@ -127,19 +127,16 @@ impl SkillData { .map_err(|e| BitFunError::tool(format!("Failed to load SKILL.md: {}", e)))?; // Get mutable mapping of metadata - let map = metadata - .as_mapping_mut() - .ok_or_else(|| BitFunError::tool("Invalid SKILL.md: metadata is not a mapping".to_string()))?; + let map = metadata.as_mapping_mut().ok_or_else(|| { + BitFunError::tool("Invalid SKILL.md: metadata is not a mapping".to_string()) + })?; if enabled { // When enabling, remove enabled field (use default value) map.remove(&Value::String("enabled".to_string())); } else { // When disabling, write enabled: false - map.insert( - Value::String("enabled".to_string()), - Value::Bool(false), - ); + map.insert(Value::String("enabled".to_string()), Value::Bool(false)); } FrontMatterMarkdown::save(skill_md_path, &metadata, &body) @@ -167,4 +164,3 @@ impl SkillData { ) } } - diff --git a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/mod.rs b/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/mod.rs index 726fcd7e..a8c766c5 100644 --- a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/mod.rs +++ b/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/mod.rs @@ -1,2 +1,2 @@ +pub mod edit_file; pub mod read_file; -pub mod edit_file; \ No newline at end of file diff --git a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/string.rs b/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/string.rs index a00b0436..4d3a2f8d 100644 --- a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/string.rs +++ b/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/string.rs @@ -9,4 +9,4 @@ pub fn normalize_string(s: &str) -> String { pub fn truncate_string_by_chars(s: &str, kept_chars: usize) -> String { let chars: Vec = s.chars().collect(); chars[..kept_chars].into_iter().collect() -} \ No newline at end of file +} diff --git a/src/crates/core/src/agentic/tools/implementations/util.rs b/src/crates/core/src/agentic/tools/implementations/util.rs index 3735b58e..1b9dcccb 100644 --- a/src/crates/core/src/agentic/tools/implementations/util.rs +++ b/src/crates/core/src/agentic/tools/implementations/util.rs @@ -24,7 +24,10 @@ pub fn normalize_path(path: &str) -> String { .to_string() } -pub fn resolve_path_with_workspace(path: &str, workspace_root: Option<&Path>) -> BitFunResult { +pub fn resolve_path_with_workspace( + path: &str, + workspace_root: Option<&Path>, +) -> BitFunResult { if Path::new(path).is_absolute() { Ok(normalize_path(path)) } else { diff --git a/src/crates/core/src/agentic/tools/implementations/view_image_tool.rs b/src/crates/core/src/agentic/tools/implementations/view_image_tool.rs index b4b338dd..57ef2d18 100644 --- a/src/crates/core/src/agentic/tools/implementations/view_image_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/view_image_tool.rs @@ -170,8 +170,7 @@ impl ViewImageTool { let fallback_mime = data_url_mime .as_deref() .or_else(|| Some(ctx_mime_type.as_str())); - let processed = - optimize_image_for_provider(data, primary_provider, fallback_mime)?; + let processed = optimize_image_for_provider(data, primary_provider, fallback_mime)?; let optimized_data_url = format!( "data:{};base64,{}", processed.mime_type, diff --git a/src/crates/core/src/agentic/tools/pipeline/mod.rs b/src/crates/core/src/agentic/tools/pipeline/mod.rs index c295ddef..92e36c1e 100644 --- a/src/crates/core/src/agentic/tools/pipeline/mod.rs +++ b/src/crates/core/src/agentic/tools/pipeline/mod.rs @@ -1,12 +1,11 @@ //! Tool pipeline module -//! +//! //! Provides complete lifecycle management for tool execution -pub mod types; pub mod state_manager; pub mod tool_pipeline; +pub mod types; -pub use types::*; pub use state_manager::*; pub use tool_pipeline::*; - +pub use types::*; diff --git a/src/crates/core/src/agentic/tools/pipeline/state_manager.rs b/src/crates/core/src/agentic/tools/pipeline/state_manager.rs index c5d52854..dfc61445 100644 --- a/src/crates/core/src/agentic/tools/pipeline/state_manager.rs +++ b/src/crates/core/src/agentic/tools/pipeline/state_manager.rs @@ -1,19 +1,19 @@ //! Tool state manager -//! +//! //! Manages the status and lifecycle of tool execution tasks -use log::debug; use super::types::ToolTask; use crate::agentic::core::ToolExecutionState; -use crate::agentic::events::{EventQueue, AgenticEvent, ToolEventData, EventPriority}; +use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue, ToolEventData}; use dashmap::DashMap; +use log::debug; use std::sync::Arc; /// Tool state manager pub struct ToolStateManager { /// Tool task status (by tool ID) tasks: Arc>, - + /// Event queue event_queue: Arc, } @@ -51,52 +51,50 @@ impl ToolStateManager { event_queue, } } - + /// Create task pub async fn create_task(&self, task: ToolTask) -> String { let tool_id = task.tool_call.tool_id.clone(); self.tasks.insert(tool_id.clone(), task); tool_id } - + /// Update task state - pub async fn update_state( - &self, - tool_id: &str, - new_state: ToolExecutionState, - ) { + pub async fn update_state(&self, tool_id: &str, new_state: ToolExecutionState) { if let Some(mut task) = self.tasks.get_mut(tool_id) { let old_state = task.state.clone(); task.state = new_state.clone(); - + // Update timestamp match &new_state { ToolExecutionState::Running { .. } | ToolExecutionState::Streaming { .. } => { task.started_at = Some(std::time::SystemTime::now()); } - ToolExecutionState::Completed { .. } | ToolExecutionState::Failed { .. } | ToolExecutionState::Cancelled { .. } => { + ToolExecutionState::Completed { .. } + | ToolExecutionState::Failed { .. } + | ToolExecutionState::Cancelled { .. } => { task.completed_at = Some(std::time::SystemTime::now()); } _ => {} } - + debug!( "Tool state changed: tool_id={}, old_state={:?}, new_state={:?}", tool_id, format!("{:?}", old_state).split('{').next().unwrap_or(""), format!("{:?}", new_state).split('{').next().unwrap_or("") ); - + // Send state change event self.emit_state_change_event(task.clone()).await; } } - + /// Get task pub fn get_task(&self, tool_id: &str) -> Option { self.tasks.get(tool_id).map(|t| t.clone()) } - + /// Update task arguments pub fn update_task_arguments(&self, tool_id: &str, new_arguments: serde_json::Value) { if let Some(mut task) = self.tasks.get_mut(tool_id) { @@ -107,7 +105,7 @@ impl ToolStateManager { task.tool_call.arguments = new_arguments; } } - + /// Get all tasks of a session pub fn get_session_tasks(&self, session_id: &str) -> Vec { self.tasks @@ -116,7 +114,7 @@ impl ToolStateManager { .map(|entry| entry.value().clone()) .collect() } - + /// Get all tasks of a dialog turn pub fn get_dialog_turn_tasks(&self, dialog_turn_id: &str) -> Vec { self.tasks @@ -125,27 +123,28 @@ impl ToolStateManager { .map(|entry| entry.value().clone()) .collect() } - + /// Delete task pub fn remove_task(&self, tool_id: &str) { self.tasks.remove(tool_id); } - + /// Clear all tasks of a session pub fn clear_session(&self, session_id: &str) { - let to_remove: Vec<_> = self.tasks + let to_remove: Vec<_> = self + .tasks .iter() .filter(|entry| entry.value().context.session_id == session_id) .map(|entry| entry.key().clone()) .collect(); - + for tool_id in to_remove { self.tasks.remove(&tool_id); } - + debug!("Cleared session tool tasks: session_id={}", session_id); } - + /// Send state change event (full version) async fn emit_state_change_event(&self, task: ToolTask) { let tool_event = match &task.state { @@ -154,51 +153,61 @@ impl ToolStateManager { tool_name: task.tool_call.tool_name.clone(), position: *position, }, - + ToolExecutionState::Waiting { dependencies } => ToolEventData::Waiting { tool_id: task.tool_call.tool_id.clone(), tool_name: task.tool_call.tool_name.clone(), dependencies: dependencies.clone(), }, - + ToolExecutionState::Running { .. } => ToolEventData::Started { tool_id: task.tool_call.tool_id.clone(), tool_name: task.tool_call.tool_name.clone(), params: task.tool_call.arguments.clone(), }, - - ToolExecutionState::Streaming { chunks_received, .. } => ToolEventData::Streaming { + + ToolExecutionState::Streaming { + chunks_received, .. + } => ToolEventData::Streaming { tool_id: task.tool_call.tool_id.clone(), tool_name: task.tool_call.tool_name.clone(), chunks_received: *chunks_received, }, - - ToolExecutionState::AwaitingConfirmation { params, .. } => ToolEventData::ConfirmationNeeded { - tool_id: task.tool_call.tool_id.clone(), - tool_name: task.tool_call.tool_name.clone(), - params: params.clone(), - }, - - ToolExecutionState::Completed { result, duration_ms } => ToolEventData::Completed { + + ToolExecutionState::AwaitingConfirmation { params, .. } => { + ToolEventData::ConfirmationNeeded { + tool_id: task.tool_call.tool_id.clone(), + tool_name: task.tool_call.tool_name.clone(), + params: params.clone(), + } + } + + ToolExecutionState::Completed { + result, + duration_ms, + } => ToolEventData::Completed { tool_id: task.tool_call.tool_id.clone(), tool_name: task.tool_call.tool_name.clone(), result: Self::sanitize_tool_result_for_event(&result.content()), duration_ms: *duration_ms, }, - - ToolExecutionState::Failed { error, is_retryable: _ } => ToolEventData::Failed { + + ToolExecutionState::Failed { + error, + is_retryable: _, + } => ToolEventData::Failed { tool_id: task.tool_call.tool_id.clone(), tool_name: task.tool_call.tool_name.clone(), error: error.clone(), }, - + ToolExecutionState::Cancelled { reason } => ToolEventData::Cancelled { tool_id: task.tool_call.tool_id.clone(), tool_name: task.tool_call.tool_name.clone(), reason: reason.clone(), }, }; - + // Determine priority based on tool event type let priority = match &task.state { // Critical state change: High priority (user needs to see immediately) @@ -217,7 +226,7 @@ impl ToolStateManager { | ToolExecutionState::Streaming { .. } => EventPriority::Normal, }; - + let event_subagent_parent_info = task.context.subagent_parent_info.map(|info| info.into()); let event = AgenticEvent::ToolEvent { session_id: task.context.session_id, @@ -225,17 +234,17 @@ impl ToolStateManager { tool_event, subagent_parent_info: event_subagent_parent_info, }; - + let _ = self.event_queue.enqueue(event, Some(priority)).await; } - + /// Get statistics pub fn get_stats(&self) -> ToolStats { let tasks: Vec<_> = self.tasks.iter().map(|e| e.value().clone()).collect(); - + let mut stats = ToolStats::default(); stats.total = tasks.len(); - + for task in tasks { match task.state { ToolExecutionState::Queued { .. } => stats.queued += 1, @@ -248,7 +257,7 @@ impl ToolStateManager { ToolExecutionState::Cancelled { .. } => stats.cancelled += 1, } } - + stats } } diff --git a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs index 430b0b66..0b430793 100644 --- a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -1,28 +1,30 @@ //! Tool pipeline -//! -//! Manages the complete lifecycle of tools: +//! +//! Manages the complete lifecycle of tools: //! confirmation, execution, caching, retries, etc. -use log::{debug, info, warn, error}; use super::state_manager::ToolStateManager; use super::types::*; -use crate::agentic::core::{ToolCall, ToolResult as ModelToolResult, ToolExecutionState}; +use crate::agentic::core::{ToolCall, ToolExecutionState, ToolResult as ModelToolResult}; use crate::agentic::events::types::ToolEventData; -use crate::agentic::tools::registry::ToolRegistry; -use crate::agentic::tools::framework::{ToolUseContext, ToolOptions, ToolResult as FrameworkToolResult}; +use crate::agentic::tools::framework::{ + ToolOptions, ToolResult as FrameworkToolResult, ToolUseContext, +}; use crate::agentic::tools::image_context::ImageContextProviderRef; +use crate::agentic::tools::registry::ToolRegistry; use crate::util::errors::{BitFunError, BitFunResult}; +use dashmap::DashMap; use futures::future::join_all; +use log::{debug, error, info, warn}; use std::collections::HashMap; use std::sync::Arc; use std::time::Instant; -use tokio::time::{timeout, Duration}; use tokio::sync::{oneshot, RwLock as TokioRwLock}; -use dashmap::DashMap; +use tokio::time::{timeout, Duration}; use tokio_util::sync::CancellationToken; /// Convert framework::ToolResult to core::ToolResult -/// +/// /// Ensure always has result_for_assistant, avoid tool message content being empty fn convert_tool_result( framework_result: FrameworkToolResult, @@ -30,13 +32,16 @@ fn convert_tool_result( tool_name: &str, ) -> ModelToolResult { match framework_result { - FrameworkToolResult::Result { data, result_for_assistant } => { + FrameworkToolResult::Result { + data, + result_for_assistant, + } => { // If the tool does not provide result_for_assistant, generate default friendly description let assistant_text = result_for_assistant.or_else(|| { // Generate natural language description based on data generate_default_assistant_text(tool_name, &data) }); - + ModelToolResult { tool_id: tool_id.to_string(), tool_name: tool_name.to_string(), @@ -45,11 +50,11 @@ fn convert_tool_result( is_error: false, duration_ms: None, } - }, + } FrameworkToolResult::Progress { content, .. } => { // Progress message also generates friendly text let assistant_text = generate_default_assistant_text(tool_name, &content); - + ModelToolResult { tool_id: tool_id.to_string(), tool_name: tool_name.to_string(), @@ -58,11 +63,11 @@ fn convert_tool_result( is_error: false, duration_ms: None, } - }, + } FrameworkToolResult::StreamChunk { data, .. } => { // Streaming data block also generates friendly text let assistant_text = generate_default_assistant_text(tool_name, &data); - + ModelToolResult { tool_id: tool_id.to_string(), tool_name: tool_name.to_string(), @@ -71,7 +76,7 @@ fn convert_tool_result( is_error: false, duration_ms: None, } - }, + } } } @@ -79,32 +84,45 @@ fn convert_tool_result( fn generate_default_assistant_text(tool_name: &str, data: &serde_json::Value) -> Option { // Check if data is null or empty if data.is_null() { - return Some(format!("Tool {} completed, but no result returned.", tool_name)); + return Some(format!( + "Tool {} completed, but no result returned.", + tool_name + )); } - + // If it is an empty object or empty array - if (data.is_object() && data.as_object().map_or(false, |o| o.is_empty())) || - (data.is_array() && data.as_array().map_or(false, |a| a.is_empty())) { - return Some(format!("Tool {} completed, returned empty result.", tool_name)); + if (data.is_object() && data.as_object().map_or(false, |o| o.is_empty())) + || (data.is_array() && data.as_array().map_or(false, |a| a.is_empty())) + { + return Some(format!( + "Tool {} completed, returned empty result.", + tool_name + )); } - + // Try to extract common fields to generate description if let Some(obj) = data.as_object() { // Check if there is a success field if let Some(success) = obj.get("success").and_then(|v| v.as_bool()) { if success { if let Some(message) = obj.get("message").and_then(|v| v.as_str()) { - return Some(format!("Tool {} completed successfully: {}", tool_name, message)); + return Some(format!( + "Tool {} completed successfully: {}", + tool_name, message + )); } return Some(format!("Tool {} completed successfully.", tool_name)); } else { if let Some(error) = obj.get("error").and_then(|v| v.as_str()) { - return Some(format!("Tool {} completed with error: {}", tool_name, error)); + return Some(format!( + "Tool {} completed with error: {}", + tool_name, error + )); } return Some(format!("Tool {} completed with error.", tool_name)); } } - + // Check if there is a result/data/content field for key in &["result", "data", "content", "output"] { if let Some(value) = obj.get(*key) { @@ -115,7 +133,7 @@ fn generate_default_assistant_text(tool_name: &str, data: &serde_json::Value) -> } } } - + // If there are multiple fields, provide field list let field_names: Vec<&str> = obj.keys().take(5).map(|s| s.as_str()).collect(); if !field_names.is_empty() { @@ -126,30 +144,38 @@ fn generate_default_assistant_text(tool_name: &str, data: &serde_json::Value) -> )); } } - + // If it is a string, return directly (but limit length) if let Some(text) = data.as_str() { if !text.is_empty() { if text.len() <= 500 { return Some(format!("Tool {} completed: {}", tool_name, text)); } else { - return Some(format!("Tool {} completed, returned {} characters of text result.", tool_name, text.len())); + return Some(format!( + "Tool {} completed, returned {} characters of text result.", + tool_name, + text.len() + )); } } } - + // If it is a number or boolean if data.is_number() || data.is_boolean() { return Some(format!("Tool {} completed, returned: {}", tool_name, data)); } - + // Default: simply describe data type Some(format!( "Tool {} completed, returned {} type of result.", tool_name, - if data.is_object() { "object" } - else if data.is_array() { "array" } - else { "data" } + if data.is_object() { + "object" + } else if data.is_array() { + "array" + } else { + "data" + } )) } @@ -194,7 +220,7 @@ impl ToolPipeline { image_context_provider, } } - + /// Execute multiple tool calls pub async fn execute_tools( &self, @@ -212,7 +238,8 @@ impl ToolPipeline { let all_concurrency_safe = { let registry = self.tool_registry.read().await; tool_calls.iter().all(|tc| { - registry.get_tool(&tc.tool_name) + registry + .get_tool(&tc.tool_name) .map(|tool| tool.is_concurrency_safe(Some(&tc.arguments))) .unwrap_or(false) // If the tool does not exist, it is considered unsafe }) @@ -246,16 +273,19 @@ impl ToolPipeline { } } } - + /// Execute tools in parallel - async fn execute_parallel(&self, task_ids: Vec) -> BitFunResult> { + async fn execute_parallel( + &self, + task_ids: Vec, + ) -> BitFunResult> { let futures: Vec<_> = task_ids .iter() .map(|id| self.execute_single_tool(id.clone())) .collect(); - + let results = join_all(futures).await; - + // Collect results, including failed results let mut all_results = Vec::new(); for (idx, result) in results.into_iter().enumerate() { @@ -263,7 +293,7 @@ impl ToolPipeline { Ok(r) => all_results.push(r), Err(e) => { error!("Tool execution failed: error={}", e); - + // Get task information from state manager if let Some(task) = self.state_manager.get_task(&task_ids[idx]) { // Create error result to return to model @@ -288,20 +318,23 @@ impl ToolPipeline { } } } - + Ok(all_results) } - + /// Execute tools sequentially - async fn execute_sequential(&self, task_ids: Vec) -> BitFunResult> { + async fn execute_sequential( + &self, + task_ids: Vec, + ) -> BitFunResult> { let mut results = Vec::new(); - + for task_id in task_ids { match self.execute_single_tool(task_id.clone()).await { Ok(result) => results.push(result), Err(e) => { error!("Tool execution failed: error={}", e); - + // Get task information from state manager if let Some(task) = self.state_manager.get_task(&task_id) { // Create error result to return to model @@ -326,26 +359,30 @@ impl ToolPipeline { } } } - + Ok(results) } - + /// Execute single tool async fn execute_single_tool(&self, tool_id: String) -> BitFunResult { let start_time = Instant::now(); - + debug!("Starting tool execution: tool_id={}", tool_id); - + // Get task - let task = self.state_manager + let task = self + .state_manager .get_task(&tool_id) .ok_or_else(|| BitFunError::NotFound(format!("Tool task not found: {}", tool_id)))?; - + let tool_name = task.tool_call.tool_name.clone(); let tool_args = task.tool_call.arguments.clone(); let tool_is_error = task.tool_call.is_error; - - debug!("Tool task details: tool_name={}, tool_id={}", tool_name, tool_id); + + debug!( + "Tool task details: tool_name={}, tool_id={}", + tool_name, tool_id + ); if tool_name.is_empty() || tool_is_error { let error_msg = format!( @@ -354,60 +391,68 @@ impl ToolPipeline { Please regenerate the tool call with valid tool name and arguments." ); self.state_manager - .update_state(&tool_id, ToolExecutionState::Failed { - error: error_msg.clone(), - is_retryable: false, - }) + .update_state( + &tool_id, + ToolExecutionState::Failed { + error: error_msg.clone(), + is_retryable: false, + }, + ) .await; return Err(BitFunError::Validation(error_msg)); } - + // Security check: check if the tool is in the allowed list // If allowed_tools is not empty, only allow execution of tools in the whitelist - if !task.context.allowed_tools.is_empty() - && !task.context.allowed_tools.contains(&tool_name) + if !task.context.allowed_tools.is_empty() + && !task.context.allowed_tools.contains(&tool_name) { let error_msg = format!( "Tool '{}' is not in the allowed list: {:?}", - tool_name, - task.context.allowed_tools + tool_name, task.context.allowed_tools ); warn!("Tool not allowed: {}", error_msg); - + // Update state to failed self.state_manager - .update_state(&tool_id, ToolExecutionState::Failed { - error: error_msg.clone(), - is_retryable: false, - }) + .update_state( + &tool_id, + ToolExecutionState::Failed { + error: error_msg.clone(), + is_retryable: false, + }, + ) .await; - + return Err(BitFunError::Validation(error_msg)); } - + // Create cancellation token let cancellation_token = CancellationToken::new(); - self.cancellation_tokens.insert(tool_id.clone(), cancellation_token.clone()); - + self.cancellation_tokens + .insert(tool_id.clone(), cancellation_token.clone()); + debug!("Executing tool: tool_name={}", tool_name); - + let tool = { let registry = self.tool_registry.read().await; - registry.get_tool(&task.tool_call.tool_name).ok_or_else(|| { - let error_msg = format!( - "Tool '{}' is not registered or enabled.", - task.tool_call.tool_name, - ); - error!("{}", error_msg); - BitFunError::tool(error_msg) - })? + registry + .get_tool(&task.tool_call.tool_name) + .ok_or_else(|| { + let error_msg = format!( + "Tool '{}' is not registered or enabled.", + task.tool_call.tool_name, + ); + error!("{}", error_msg); + BitFunError::tool(error_msg) + })? }; let is_streaming = tool.supports_streaming(); - let needs_confirmation = task.options.confirm_before_run - && tool.needs_permissions(Some(&tool_args)); + let needs_confirmation = + task.options.confirm_before_run && tool.needs_permissions(Some(&tool_args)); if needs_confirmation { info!("Tool requires confirmation: tool_name={}", tool_name); @@ -424,17 +469,23 @@ impl ToolPipeline { self.confirmation_channels.insert(tool_id.clone(), tx); self.state_manager - .update_state(&tool_id, ToolExecutionState::AwaitingConfirmation { - params: tool_args.clone(), - timeout_at, - }) + .update_state( + &tool_id, + ToolExecutionState::AwaitingConfirmation { + params: tool_args.clone(), + timeout_at, + }, + ) .await; debug!("Waiting for confirmation: tool_name={}", tool_name); let confirmation_result = match task.options.confirmation_timeout_secs { Some(timeout_secs) => { - debug!("Waiting for user confirmation with timeout: timeout_secs={}, tool_name={}", timeout_secs, tool_name); + debug!( + "Waiting for user confirmation with timeout: timeout_secs={}, tool_name={}", + timeout_secs, tool_name + ); // There is a timeout limit match timeout(Duration::from_secs(timeout_secs), rx).await { Ok(result) => Some(result), @@ -442,7 +493,10 @@ impl ToolPipeline { } } None => { - debug!("Waiting for user confirmation without timeout: tool_name={}", tool_name); + debug!( + "Waiting for user confirmation without timeout: tool_name={}", + tool_name + ); Some(rx.await) } }; @@ -453,82 +507,116 @@ impl ToolPipeline { } Some(Ok(ConfirmationResponse::Rejected(reason))) => { self.state_manager - .update_state(&tool_id, ToolExecutionState::Cancelled { - reason: format!("User rejected: {}", reason), - }) + .update_state( + &tool_id, + ToolExecutionState::Cancelled { + reason: format!("User rejected: {}", reason), + }, + ) .await; - return Err(BitFunError::Validation(format!("Tool was rejected by user: {}", reason))); + return Err(BitFunError::Validation(format!( + "Tool was rejected by user: {}", + reason + ))); } Some(Err(_)) => { // Channel closed self.state_manager - .update_state(&tool_id, ToolExecutionState::Cancelled { - reason: "Confirmation channel closed".to_string(), - }) + .update_state( + &tool_id, + ToolExecutionState::Cancelled { + reason: "Confirmation channel closed".to_string(), + }, + ) .await; return Err(BitFunError::service("Confirmation channel closed")); } None => { self.state_manager - .update_state(&tool_id, ToolExecutionState::Cancelled { - reason: "Confirmation timeout".to_string(), - }) + .update_state( + &tool_id, + ToolExecutionState::Cancelled { + reason: "Confirmation timeout".to_string(), + }, + ) .await; warn!("Confirmation timeout: {}", tool_name); - return Err(BitFunError::Timeout(format!("Confirmation timeout: {}", tool_name))); + return Err(BitFunError::Timeout(format!( + "Confirmation timeout: {}", + tool_name + ))); } } self.confirmation_channels.remove(&tool_id); } - + if cancellation_token.is_cancelled() { self.state_manager - .update_state(&tool_id, ToolExecutionState::Cancelled { - reason: "Tool was cancelled before execution".to_string(), - }) + .update_state( + &tool_id, + ToolExecutionState::Cancelled { + reason: "Tool was cancelled before execution".to_string(), + }, + ) .await; self.cancellation_tokens.remove(&tool_id); - return Err(BitFunError::Cancelled("Tool was cancelled before execution".to_string())); + return Err(BitFunError::Cancelled( + "Tool was cancelled before execution".to_string(), + )); } - + // Set initial state if is_streaming { self.state_manager - .update_state(&tool_id, ToolExecutionState::Streaming { - started_at: std::time::SystemTime::now(), - chunks_received: 0, - }) + .update_state( + &tool_id, + ToolExecutionState::Streaming { + started_at: std::time::SystemTime::now(), + chunks_received: 0, + }, + ) .await; } else { self.state_manager - .update_state(&tool_id, ToolExecutionState::Running { - started_at: std::time::SystemTime::now(), - progress: None, - }) + .update_state( + &tool_id, + ToolExecutionState::Running { + started_at: std::time::SystemTime::now(), + progress: None, + }, + ) .await; } - - let result = self.execute_with_retry(&task, cancellation_token.clone(), tool).await; - + + let result = self + .execute_with_retry(&task, cancellation_token.clone(), tool) + .await; + self.cancellation_tokens.remove(&tool_id); - + match result { Ok(tool_result) => { let duration_ms = start_time.elapsed().as_millis() as u64; - + self.state_manager - .update_state(&tool_id, ToolExecutionState::Completed { - result: convert_to_framework_result(&tool_result), - duration_ms, - }) + .update_state( + &tool_id, + ToolExecutionState::Completed { + result: convert_to_framework_result(&tool_result), + duration_ms, + }, + ) .await; - - info!("Tool completed: tool_name={}, duration_ms={}", tool_name, duration_ms); - + + info!( + "Tool completed: tool_name={}, duration_ms={}", + tool_name, duration_ms + ); + Ok(ToolExecutionResult { tool_id, tool_name, @@ -539,21 +627,24 @@ impl ToolPipeline { Err(e) => { let error_msg = e.to_string(); let is_retryable = task.options.max_retries > 0; - + self.state_manager - .update_state(&tool_id, ToolExecutionState::Failed { - error: error_msg.clone(), - is_retryable, - }) + .update_state( + &tool_id, + ToolExecutionState::Failed { + error: error_msg.clone(), + is_retryable, + }, + ) .await; - + error!("Tool failed: tool_name={}, error={}", tool_name, error_msg); - + Err(e) } } } - + /// Execute with retry async fn execute_with_retry( &self, @@ -567,29 +658,36 @@ impl ToolPipeline { loop { // Check cancellation token if cancellation_token.is_cancelled() { - return Err(BitFunError::Cancelled("Tool execution was cancelled".to_string())); + return Err(BitFunError::Cancelled( + "Tool execution was cancelled".to_string(), + )); } attempts += 1; - let result = self.execute_tool_impl(task, cancellation_token.clone(), tool.clone()).await; - + let result = self + .execute_tool_impl(task, cancellation_token.clone(), tool.clone()) + .await; + match result { Ok(r) => return Ok(r), Err(e) => { if attempts >= max_attempts { return Err(e); } - - debug!("Retrying tool execution: attempt={}/{}, error={}", attempts, max_attempts, e); - + + debug!( + "Retrying tool execution: attempt={}/{}, error={}", + attempts, max_attempts, e + ); + // Wait for a period of time and retry tokio::time::sleep(Duration::from_millis(100 * attempts as u64)).await; } } } } - + /// Actual execution of tool async fn execute_tool_impl( &self, @@ -599,9 +697,11 @@ impl ToolPipeline { ) -> BitFunResult { // Check cancellation token if cancellation_token.is_cancelled() { - return Err(BitFunError::Cancelled("Tool execution was cancelled".to_string())); + return Err(BitFunError::Cancelled( + "Tool execution was cancelled".to_string(), + )); } - + // Build tool context (pass all resource IDs) let tool_context = ToolUseContext { tool_call_id: Some(task.tool_call.tool_id.clone()), @@ -627,7 +727,7 @@ impl ToolPipeline { is_custom_command: None, custom_data: Some({ let mut map = HashMap::new(); - + if let Some(snapshot_id) = task .context .context_vars @@ -645,7 +745,8 @@ impl ToolPipeline { } } - if let Some(provider) = task.context.context_vars.get("primary_model_provider") { + if let Some(provider) = task.context.context_vars.get("primary_model_provider") + { if !provider.is_empty() { map.insert( "primary_model_provider".to_string(), @@ -678,7 +779,7 @@ impl ToolPipeline { ); } } - + map }), }), @@ -687,31 +788,41 @@ impl ToolPipeline { subagent_parent_info: task.context.subagent_parent_info.clone(), cancellation_token: Some(cancellation_token), }; - + let execution_future = tool.call(&task.tool_call.arguments, &tool_context); - + let tool_results = match task.options.timeout_secs { Some(timeout_secs) => { let timeout_duration = Duration::from_secs(timeout_secs); let result = timeout(timeout_duration, execution_future) .await - .map_err(|_| BitFunError::Timeout(format!("Tool execution timeout: {}", task.tool_call.tool_name)))?; + .map_err(|_| { + BitFunError::Timeout(format!( + "Tool execution timeout: {}", + task.tool_call.tool_name + )) + })?; result? } - None => { - execution_future.await? - } + None => execution_future.await?, }; - + if tool.supports_streaming() && tool_results.len() > 1 { self.handle_streaming_results(task, &tool_results).await?; } - - tool_results.into_iter().last() + + tool_results + .into_iter() + .last() .map(|r| convert_tool_result(r, &task.tool_call.tool_id, &task.tool_call.tool_name)) - .ok_or_else(|| BitFunError::Tool(format!("Tool did not return result: {}", task.tool_call.tool_name))) + .ok_or_else(|| { + BitFunError::Tool(format!( + "Tool did not return result: {}", + task.tool_call.tool_name + )) + }) } - + /// Handle streaming results async fn handle_streaming_results( &self, @@ -719,19 +830,27 @@ impl ToolPipeline { results: &[FrameworkToolResult], ) -> BitFunResult<()> { let mut chunks_received = 0; - + for result in results { - if let FrameworkToolResult::StreamChunk { data, chunk_index: _, is_final: _ } = result { + if let FrameworkToolResult::StreamChunk { + data, + chunk_index: _, + is_final: _, + } = result + { chunks_received += 1; - + // Update state self.state_manager - .update_state(&task.tool_call.tool_id, ToolExecutionState::Streaming { - started_at: std::time::SystemTime::now(), - chunks_received, - }) + .update_state( + &task.tool_call.tool_id, + ToolExecutionState::Streaming { + started_at: std::time::SystemTime::now(), + chunks_received, + }, + ) .await; - + // Send StreamChunk event let _event_data = ToolEventData::StreamChunk { tool_id: task.tool_call.tool_id.clone(), @@ -740,10 +859,10 @@ impl ToolPipeline { }; } } - + Ok(()) } - + /// Cancel tool execution pub async fn cancel_tool(&self, tool_id: &str, reason: String) -> BitFunResult<()> { // 1. Trigger cancellation token @@ -751,66 +870,93 @@ impl ToolPipeline { token.cancel(); debug!("Cancellation token triggered: tool_id={}", tool_id); } else { - debug!("Cancellation token not found (tool may have completed): tool_id={}", tool_id); + debug!( + "Cancellation token not found (tool may have completed): tool_id={}", + tool_id + ); } - + // 2. Clean up confirmation channel (if waiting for confirmation) if let Some((_, _tx)) = self.confirmation_channels.remove(tool_id) { // Channel will be automatically closed, causing await rx to return Err debug!("Cleared confirmation channel: tool_id={}", tool_id); } - + // 3. Update state to cancelled self.state_manager - .update_state(tool_id, ToolExecutionState::Cancelled { - reason: reason.clone(), - }) + .update_state( + tool_id, + ToolExecutionState::Cancelled { + reason: reason.clone(), + }, + ) .await; - - info!("Tool execution cancelled: tool_id={}, reason={}", tool_id, reason); + + info!( + "Tool execution cancelled: tool_id={}, reason={}", + tool_id, reason + ); Ok(()) } - + /// Cancel all tools for a dialog turn pub async fn cancel_dialog_turn_tools(&self, dialog_turn_id: &str) -> BitFunResult<()> { - info!("Cancelling all tools for dialog turn: dialog_turn_id={}", dialog_turn_id); - + info!( + "Cancelling all tools for dialog turn: dialog_turn_id={}", + dialog_turn_id + ); + let tasks = self.state_manager.get_dialog_turn_tasks(dialog_turn_id); debug!("Found {} tool tasks for dialog turn", tasks.len()); - + let mut cancelled_count = 0; let mut skipped_count = 0; - + for task in tasks { // Only cancel tasks in cancellable states let can_cancel = matches!( task.state, ToolExecutionState::Queued { .. } - | ToolExecutionState::Waiting { .. } - | ToolExecutionState::Running { .. } - | ToolExecutionState::AwaitingConfirmation { .. } + | ToolExecutionState::Waiting { .. } + | ToolExecutionState::Running { .. } + | ToolExecutionState::AwaitingConfirmation { .. } ); - + if can_cancel { - debug!("Cancelling tool: tool_id={}, state={:?}", task.tool_call.tool_id, task.state); - self.cancel_tool(&task.tool_call.tool_id, "Dialog turn cancelled".to_string()).await?; + debug!( + "Cancelling tool: tool_id={}, state={:?}", + task.tool_call.tool_id, task.state + ); + self.cancel_tool(&task.tool_call.tool_id, "Dialog turn cancelled".to_string()) + .await?; cancelled_count += 1; } else { - debug!("Skipping tool (state not cancellable): tool_id={}, state={:?}", task.tool_call.tool_id, task.state); + debug!( + "Skipping tool (state not cancellable): tool_id={}, state={:?}", + task.tool_call.tool_id, task.state + ); skipped_count += 1; } } - - info!("Tool cancellation completed: cancelled={}, skipped={}", cancelled_count, skipped_count); + + info!( + "Tool cancellation completed: cancelled={}, skipped={}", + cancelled_count, skipped_count + ); Ok(()) } - + /// Confirm tool execution - pub async fn confirm_tool(&self, tool_id: &str, updated_input: Option) -> BitFunResult<()> { - let task = self.state_manager + pub async fn confirm_tool( + &self, + tool_id: &str, + updated_input: Option, + ) -> BitFunResult<()> { + let task = self + .state_manager .get_task(tool_id) .ok_or_else(|| BitFunError::NotFound(format!("Tool task not found: {}", tool_id)))?; - + // Check if the state is waiting for confirmation if !matches!(task.state, ToolExecutionState::AwaitingConfirmation { .. }) { return Err(BitFunError::Validation(format!( @@ -818,29 +964,33 @@ impl ToolPipeline { task.state ))); } - + // If the user modified the parameters, update the task parameters first if let Some(new_args) = updated_input { debug!("User updated tool arguments: tool_id={}", tool_id); self.state_manager.update_task_arguments(tool_id, new_args); } - + // Get sender from map and send confirmation response if let Some((_, tx)) = self.confirmation_channels.remove(tool_id) { let _ = tx.send(ConfirmationResponse::Confirmed); info!("User confirmed tool execution: tool_id={}", tool_id); Ok(()) } else { - Err(BitFunError::NotFound(format!("Confirmation channel not found: {}", tool_id))) + Err(BitFunError::NotFound(format!( + "Confirmation channel not found: {}", + tool_id + ))) } } - + /// Reject tool execution pub async fn reject_tool(&self, tool_id: &str, reason: String) -> BitFunResult<()> { - let task = self.state_manager + let task = self + .state_manager .get_task(tool_id) .ok_or_else(|| BitFunError::NotFound(format!("Tool task not found: {}", tool_id)))?; - + // Check if the state is waiting for confirmation if !matches!(task.state, ToolExecutionState::AwaitingConfirmation { .. }) { return Err(BitFunError::Validation(format!( @@ -848,20 +998,26 @@ impl ToolPipeline { task.state ))); } - + // Get sender from map and send rejection response if let Some((_, tx)) = self.confirmation_channels.remove(tool_id) { let _ = tx.send(ConfirmationResponse::Rejected(reason.clone())); - info!("User rejected tool execution: tool_id={}, reason={}", tool_id, reason); + info!( + "User rejected tool execution: tool_id={}, reason={}", + tool_id, reason + ); Ok(()) } else { // If the channel does not exist, mark it as cancelled directly self.state_manager - .update_state(tool_id, ToolExecutionState::Cancelled { - reason: format!("User rejected: {}", reason), - }) + .update_state( + tool_id, + ToolExecutionState::Cancelled { + reason: format!("User rejected: {}", reason), + }, + ) .await; - + Ok(()) } } diff --git a/src/crates/core/src/agentic/tools/pipeline/types.rs b/src/crates/core/src/agentic/tools/pipeline/types.rs index 224411b1..9d499336 100644 --- a/src/crates/core/src/agentic/tools/pipeline/types.rs +++ b/src/crates/core/src/agentic/tools/pipeline/types.rs @@ -1,8 +1,8 @@ //! Tool pipeline type definitions -use crate::agentic::WorkspaceBinding; use crate::agentic::core::{ToolCall, ToolExecutionState}; use crate::agentic::events::SubagentParentInfo as EventSubagentParentInfo; +use crate::agentic::WorkspaceBinding; use std::collections::HashMap; use std::time::SystemTime; @@ -75,7 +75,11 @@ pub struct ToolTask { } impl ToolTask { - pub fn new(tool_call: ToolCall, context: ToolExecutionContext, options: ToolExecutionOptions) -> Self { + pub fn new( + tool_call: ToolCall, + context: ToolExecutionContext, + options: ToolExecutionOptions, + ) -> Self { Self { tool_call, context, @@ -96,4 +100,3 @@ pub struct ToolExecutionResult { pub result: crate::agentic::core::ToolResult, pub execution_time_ms: u64, } - diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs index 96e260d0..1c24ecc4 100644 --- a/src/crates/core/src/agentic/tools/registry.rs +++ b/src/crates/core/src/agentic/tools/registry.rs @@ -183,8 +183,10 @@ pub async fn get_all_tools() -> Vec> { let registry_lock = registry.read().await; let all_tools = registry_lock.get_all_tools(); let wrapped_tools = crate::service::snapshot::get_snapshot_wrapped_tools(); - let file_tool_names: std::collections::HashSet = - wrapped_tools.iter().map(|tool| tool.name().to_string()).collect(); + let file_tool_names: std::collections::HashSet = wrapped_tools + .iter() + .map(|tool| tool.name().to_string()) + .collect(); let mut result = wrapped_tools; for tool in all_tools { @@ -256,4 +258,3 @@ pub async fn get_all_registered_tool_names() -> Vec { .map(|tool| tool.name().to_string()) .collect() } - diff --git a/src/crates/core/src/agentic/util/mod.rs b/src/crates/core/src/agentic/util/mod.rs index 21877382..ccd0765a 100644 --- a/src/crates/core/src/agentic/util/mod.rs +++ b/src/crates/core/src/agentic/util/mod.rs @@ -1,3 +1,3 @@ pub mod list_files; -pub use list_files::get_formatted_files_list; \ No newline at end of file +pub use list_files::get_formatted_files_list; diff --git a/src/crates/core/src/function_agents/git-func-agent/ai_service.rs b/src/crates/core/src/function_agents/git-func-agent/ai_service.rs index 005f2154..f2d18259 100644 --- a/src/crates/core/src/function_agents/git-func-agent/ai_service.rs +++ b/src/crates/core/src/function_agents/git-func-agent/ai_service.rs @@ -1,18 +1,17 @@ +use super::types::{ + AICommitAnalysis, AgentError, AgentResult, CommitFormat, CommitMessageOptions, CommitType, + Language, ProjectContext, +}; +use crate::infrastructure::ai::AIClient; +use crate::util::types::Message; /** * AI service layer * * Handles AI client interaction and provides intelligent analysis for commit message generation */ - use log::{debug, error, warn}; -use super::types::{ - AgentError, AgentResult, AICommitAnalysis, CommitFormat, - CommitMessageOptions, CommitType, Language, ProjectContext, -}; -use crate::infrastructure::ai::AIClient; -use crate::util::types::Message; -use std::sync::Arc; use serde_json::Value; +use std::sync::Arc; /// Prompt template constants (embedded at compile time) const COMMIT_MESSAGE_PROMPT: &str = include_str!("prompts/commit_message.md"); @@ -24,21 +23,22 @@ pub struct AIAnalysisService { impl AIAnalysisService { pub async fn new_with_agent_config( factory: std::sync::Arc, - agent_name: &str + agent_name: &str, ) -> AgentResult { let ai_client = match factory.get_client_by_func_agent(agent_name).await { Ok(client) => client, Err(e) => { error!("Failed to get AI client: {}", e); - return Err(AgentError::internal_error(format!("Failed to get AI client: {}", e))); + return Err(AgentError::internal_error(format!( + "Failed to get AI client: {}", + e + ))); } }; - - Ok(Self { - ai_client, - }) + + Ok(Self { ai_client }) } - + pub async fn generate_commit_message_ai( &self, diff_content: &str, @@ -48,42 +48,44 @@ impl AIAnalysisService { if diff_content.is_empty() { return Err(AgentError::invalid_input("Code changes are empty")); } - + let processed_diff = self.truncate_diff_if_needed(diff_content, 50000); - - let prompt = self.build_commit_prompt( - &processed_diff, - project_context, - options, - ); - + + let prompt = self.build_commit_prompt(&processed_diff, project_context, options); + let ai_response = self.call_ai(&prompt).await?; - + self.parse_commit_response(&ai_response) } - + async fn call_ai(&self, prompt: &str) -> AgentResult { debug!("Sending request to AI: prompt_length={}", prompt.len()); - + let messages = vec![Message::user(prompt.to_string())]; - let response = self.ai_client + let response = self + .ai_client .send_message(messages, None) .await .map_err(|e| { error!("AI call failed: {}", e); AgentError::internal_error(format!("AI call failed: {}", e)) })?; - - debug!("AI response received: response_length={}", response.text.len()); - + + debug!( + "AI response received: response_length={}", + response.text.len() + ); + if response.text.is_empty() { error!("AI response is empty"); - Err(AgentError::internal_error("AI response is empty".to_string())) + Err(AgentError::internal_error( + "AI response is empty".to_string(), + )) } else { Ok(response.text) } } - + fn build_commit_prompt( &self, diff_content: &str, @@ -94,14 +96,14 @@ impl AIAnalysisService { Language::Chinese => "Chinese", Language::English => "English", }; - + let format_desc = match options.format { CommitFormat::Conventional => "Conventional Commits", CommitFormat::Angular => "Angular Style", CommitFormat::Simple => "Simple Format", CommitFormat::Custom => "Custom Format", }; - + COMMIT_MESSAGE_PROMPT .replace("{project_type}", &project_context.project_type) .replace("{tech_stack}", &project_context.tech_stack.join(", ")) @@ -110,13 +112,14 @@ impl AIAnalysisService { .replace("{diff_content}", diff_content) .replace("{max_title_length}", &options.max_title_length.to_string()) } - + fn parse_commit_response(&self, response: &str) -> AgentResult { let json_str = self.extract_json_from_response(response)?; - - let value: Value = serde_json::from_str(&json_str) - .map_err(|e| AgentError::analysis_error(format!("Failed to parse AI response: {}", e)))?; - + + let value: Value = serde_json::from_str(&json_str).map_err(|e| { + AgentError::analysis_error(format!("Failed to parse AI response: {}", e)) + })?; + Ok(AICommitAnalysis { commit_type: self.parse_commit_type(value["type"].as_str().unwrap_or("chore"))?, scope: value["scope"].as_str().map(|s| s.to_string()), @@ -130,51 +133,55 @@ impl AIAnalysisService { .as_str() .unwrap_or("AI analysis") .to_string(), - confidence: value["confidence"] - .as_f64() - .unwrap_or(0.8) as f32, + confidence: value["confidence"].as_f64().unwrap_or(0.8) as f32, }) } - + fn extract_json_from_response(&self, response: &str) -> AgentResult { let trimmed = response.trim(); - + if trimmed.starts_with('{') { return Ok(trimmed.to_string()); } - + if let Some(start) = trimmed.find("```json") { - let json_start = start + 7; + let json_start = start + 7; if let Some(end_offset) = trimmed[json_start..].find("```") { let json_end = json_start + end_offset; let json_str = trimmed[json_start..json_end].trim(); return Ok(json_str.to_string()); } } - + if let Some(start) = trimmed.find('{') { if let Some(end) = trimmed.rfind('}') { let json_str = &trimmed[start..=end]; return Ok(json_str.to_string()); } } - - Err(AgentError::analysis_error("Cannot extract JSON from response")) + + Err(AgentError::analysis_error( + "Cannot extract JSON from response", + )) } - + fn truncate_diff_if_needed(&self, diff: &str, max_chars: usize) -> String { if diff.len() <= max_chars { return diff.to_string(); } - - warn!("Diff too large ({} chars), truncating to {} chars", diff.len(), max_chars); - + + warn!( + "Diff too large ({} chars), truncating to {} chars", + diff.len(), + max_chars + ); + let mut truncated = diff.chars().take(max_chars - 100).collect::(); truncated.push_str("\n\n... [content truncated] ..."); - + truncated } - + fn parse_commit_type(&self, s: &str) -> AgentResult { match s.to_lowercase().as_str() { "feat" | "feature" => Ok(CommitType::Feat), diff --git a/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs b/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs index a06e63d1..0123b178 100644 --- a/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs +++ b/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs @@ -1,15 +1,14 @@ +use super::ai_service::AIAnalysisService; +use super::context_analyzer::ContextAnalyzer; +use super::types::*; +use crate::infrastructure::ai::AIClientFactory; +use crate::service::git::{GitDiffParams, GitService}; /** * Git Function Agent - commit message generator * * Uses AI to deeply analyze code changes and generate compliant commit messages */ - use log::{debug, info}; -use super::types::*; -use super::ai_service::AIAnalysisService; -use super::context_analyzer::ContextAnalyzer; -use crate::service::git::{GitService, GitDiffParams}; -use crate::infrastructure::ai::AIClientFactory; use std::path::Path; use std::sync::Arc; @@ -21,48 +20,64 @@ impl CommitGenerator { options: CommitMessageOptions, factory: Arc, ) -> AgentResult { - info!("Generating commit message (AI-driven): repo_path={:?}", repo_path); - + info!( + "Generating commit message (AI-driven): repo_path={:?}", + repo_path + ); + let status = GitService::get_status(repo_path) .await .map_err(|e| AgentError::git_error(format!("Failed to get Git status: {}", e)))?; - + let changed_files: Vec = status.staged.iter().map(|f| f.path.clone()).collect(); - + if changed_files.is_empty() { - return Err(AgentError::invalid_input("Staging area is empty, please stage files first")); + return Err(AgentError::invalid_input( + "Staging area is empty, please stage files first", + )); } - - debug!("Staged files: count={}, files={:?}", changed_files.len(), changed_files); - + + debug!( + "Staged files: count={}, files={:?}", + changed_files.len(), + changed_files + ); + let diff_content = Self::get_full_diff(repo_path).await?; - + if diff_content.trim().is_empty() { return Err(AgentError::invalid_input("Diff content is empty")); } - + let project_context = ContextAnalyzer::analyze_project_context(repo_path) .await .unwrap_or_default(); // Fallback to default on failure - - debug!("Project context: type={}, tech_stack={:?}", project_context.project_type, project_context.tech_stack); - - let ai_service = AIAnalysisService::new_with_agent_config(factory, "git-func-agent").await?; - + + debug!( + "Project context: type={}, tech_stack={:?}", + project_context.project_type, project_context.tech_stack + ); + + let ai_service = + AIAnalysisService::new_with_agent_config(factory, "git-func-agent").await?; + let ai_analysis = ai_service .generate_commit_message_ai(&diff_content, &project_context, &options) .await?; - - debug!("AI analysis completed: commit_type={:?}, confidence={}", ai_analysis.commit_type, ai_analysis.confidence); - + + debug!( + "AI analysis completed: commit_type={:?}, confidence={}", + ai_analysis.commit_type, ai_analysis.confidence + ); + let changes_summary = Self::build_changes_summary(&status, &changed_files); - + let full_message = Self::assemble_full_message( &ai_analysis.title, &ai_analysis.body, &ai_analysis.breaking_changes, ); - + Ok(CommitMessage { title: ai_analysis.title, body: ai_analysis.body, @@ -74,7 +89,7 @@ impl CommitGenerator { changes_summary, }) } - + async fn get_full_diff(repo_path: &Path) -> AgentResult { let diff_params = GitDiffParams { staged: Some(true), @@ -82,24 +97,24 @@ impl CommitGenerator { files: None, ..Default::default() }; - + let diff = GitService::get_diff(repo_path, &diff_params) .await .map_err(|e| AgentError::git_error(format!("Failed to get diff: {}", e)))?; - + debug!("Got staged diff: length={} chars", diff.len()); Ok(diff) } - + fn build_changes_summary( status: &crate::service::git::GitStatus, changed_files: &[String], ) -> ChangesSummary { - let total_additions = status.staged.iter().map(|_| 10u32).sum::() + - status.unstaged.iter().map(|_| 10u32).sum::(); - let total_deletions = status.staged.iter().map(|_| 5u32).sum::() + - status.unstaged.iter().map(|_| 5u32).sum::(); - + let total_additions = status.staged.iter().map(|_| 10u32).sum::() + + status.unstaged.iter().map(|_| 10u32).sum::(); + let total_deletions = status.staged.iter().map(|_| 5u32).sum::() + + status.unstaged.iter().map(|_| 5u32).sum::(); + let file_changes: Vec = changed_files .iter() .map(|path| { @@ -113,7 +128,7 @@ impl CommitGenerator { } }) .collect(); - + let affected_modules: Vec = changed_files .iter() .filter_map(|path| super::utils::extract_module_name(path)) @@ -121,9 +136,9 @@ impl CommitGenerator { .into_iter() .take(3) .collect(); - + let change_patterns = super::utils::detect_change_patterns(&file_changes); - + ChangesSummary { total_additions, total_deletions, @@ -133,28 +148,28 @@ impl CommitGenerator { change_patterns, } } - + fn assemble_full_message( title: &str, body: &Option, footer: &Option, ) -> String { let mut parts = vec![title.to_string()]; - + if let Some(body_text) = body { if !body_text.is_empty() { parts.push(String::new()); parts.push(body_text.clone()); } } - + if let Some(footer_text) = footer { if !footer_text.is_empty() { parts.push(String::new()); parts.push(footer_text.clone()); } } - + parts.join("\n") } } diff --git a/src/crates/core/src/function_agents/git-func-agent/context_analyzer.rs b/src/crates/core/src/function_agents/git-func-agent/context_analyzer.rs index dc4f46a8..d30de87d 100644 --- a/src/crates/core/src/function_agents/git-func-agent/context_analyzer.rs +++ b/src/crates/core/src/function_agents/git-func-agent/context_analyzer.rs @@ -1,28 +1,27 @@ +use super::types::*; /** * Context analyzer * * Provides project context for AI to better understand code changes */ - use log::debug; -use super::types::*; -use std::path::Path; use std::fs; +use std::path::Path; pub struct ContextAnalyzer; impl ContextAnalyzer { pub async fn analyze_project_context(repo_path: &Path) -> AgentResult { debug!("Analyzing project context: repo_path={:?}", repo_path); - + let project_type = Self::detect_project_type(repo_path)?; - + let tech_stack = Self::detect_tech_stack(repo_path)?; - + let project_docs = Self::read_project_docs(repo_path); - + let code_standards = Self::detect_code_standards(repo_path); - + Ok(ProjectContext { project_type, tech_stack, @@ -30,22 +29,22 @@ impl ContextAnalyzer { code_standards, }) } - + fn detect_project_type(repo_path: &Path) -> AgentResult { if repo_path.join("Cargo.toml").exists() { if repo_path.join("src-tauri").exists() { return Ok("tauri-app".to_string()); } - + if let Ok(content) = fs::read_to_string(repo_path.join("Cargo.toml")) { if content.contains("[lib]") { return Ok("rust-library".to_string()); } } - + return Ok("rust-application".to_string()); } - + if repo_path.join("package.json").exists() { if let Ok(content) = fs::read_to_string(repo_path.join("package.json")) { if content.contains("\"react\"") { @@ -60,32 +59,33 @@ impl ContextAnalyzer { } return Ok("nodejs-app".to_string()); } - + if repo_path.join("go.mod").exists() { return Ok("go-application".to_string()); } - - if repo_path.join("requirements.txt").exists() || repo_path.join("pyproject.toml").exists() { + + if repo_path.join("requirements.txt").exists() || repo_path.join("pyproject.toml").exists() + { return Ok("python-application".to_string()); } - + if repo_path.join("pom.xml").exists() { return Ok("java-maven-app".to_string()); } - + if repo_path.join("build.gradle").exists() { return Ok("java-gradle-app".to_string()); } - + Ok("unknown".to_string()) } - + fn detect_tech_stack(repo_path: &Path) -> AgentResult> { let mut stack = Vec::new(); - + if repo_path.join("Cargo.toml").exists() { stack.push("Rust".to_string()); - + if let Ok(content) = fs::read_to_string(repo_path.join("Cargo.toml")) { if content.contains("tokio") { stack.push("Tokio".to_string()); @@ -101,7 +101,7 @@ impl ContextAnalyzer { } } } - + if repo_path.join("package.json").exists() { if let Ok(content) = fs::read_to_string(repo_path.join("package.json")) { if content.contains("\"typescript\"") { @@ -109,7 +109,7 @@ impl ContextAnalyzer { } else { stack.push("JavaScript".to_string()); } - + if content.contains("\"react\"") { stack.push("React".to_string()); } @@ -124,19 +124,20 @@ impl ContextAnalyzer { } } } - + if repo_path.join("go.mod").exists() { stack.push("Go".to_string()); } - - if repo_path.join("requirements.txt").exists() || repo_path.join("pyproject.toml").exists() { + + if repo_path.join("requirements.txt").exists() || repo_path.join("pyproject.toml").exists() + { stack.push("Python".to_string()); } - + if repo_path.join("pom.xml").exists() || repo_path.join("build.gradle").exists() { stack.push("Java".to_string()); } - + if let Ok(entries) = fs::read_dir(repo_path) { for entry in entries.flatten() { let path = entry.path(); @@ -156,17 +157,17 @@ impl ContextAnalyzer { } } } - + if stack.is_empty() { stack.push("Unknown".to_string()); } - + Ok(stack) } - + fn read_project_docs(repo_path: &Path) -> Option { let readme_paths = ["README.md", "README", "README.txt", "readme.md"]; - + for readme_name in &readme_paths { let readme_path = repo_path.join(readme_name); if readme_path.exists() { @@ -176,40 +177,41 @@ impl ContextAnalyzer { } } } - + None } - + fn detect_code_standards(repo_path: &Path) -> Option { let mut standards = Vec::new(); - + if repo_path.join("rustfmt.toml").exists() || repo_path.join(".rustfmt.toml").exists() { standards.push("rustfmt"); } if repo_path.join("clippy.toml").exists() { standards.push("clippy"); } - - if repo_path.join(".eslintrc.js").exists() || - repo_path.join(".eslintrc.json").exists() || - repo_path.join("eslint.config.js").exists() { + + if repo_path.join(".eslintrc.js").exists() + || repo_path.join(".eslintrc.json").exists() + || repo_path.join("eslint.config.js").exists() + { standards.push("ESLint"); } if repo_path.join(".prettierrc").exists() || repo_path.join("prettier.config.js").exists() { standards.push("Prettier"); } - + if repo_path.join(".flake8").exists() { standards.push("flake8"); } if repo_path.join(".pylintrc").exists() { standards.push("pylint"); } - + if repo_path.join(".editorconfig").exists() { standards.push("EditorConfig"); } - + if standards.is_empty() { None } else { diff --git a/src/crates/core/src/function_agents/git-func-agent/mod.rs b/src/crates/core/src/function_agents/git-func-agent/mod.rs index d7e74eb1..cf39872e 100644 --- a/src/crates/core/src/function_agents/git-func-agent/mod.rs +++ b/src/crates/core/src/function_agents/git-func-agent/mod.rs @@ -1,20 +1,19 @@ +pub mod ai_service; +pub mod commit_generator; +pub mod context_analyzer; /** * Git Function Agent - module entry * * Provides Git-related intelligent functions: * - Automatic commit message generation */ - pub mod types; pub mod utils; -pub mod ai_service; -pub mod context_analyzer; -pub mod commit_generator; -pub use types::*; pub use ai_service::AIAnalysisService; -pub use context_analyzer::ContextAnalyzer; pub use commit_generator::CommitGenerator; +pub use context_analyzer::ContextAnalyzer; +pub use types::*; use crate::infrastructure::ai::AIClientFactory; use std::path::Path; @@ -29,7 +28,7 @@ impl GitFunctionAgent { pub fn new(factory: Arc) -> Self { Self { factory } } - + pub async fn generate_commit_message( &self, repo_path: &Path, @@ -37,9 +36,10 @@ impl GitFunctionAgent { ) -> AgentResult { CommitGenerator::generate_commit_message(repo_path, options, self.factory.clone()).await } - + /// Quickly generate commit message (use default options) pub async fn quick_commit_message(&self, repo_path: &Path) -> AgentResult { - self.generate_commit_message(repo_path, CommitMessageOptions::default()).await + self.generate_commit_message(repo_path, CommitMessageOptions::default()) + .await } } diff --git a/src/crates/core/src/function_agents/git-func-agent/types.rs b/src/crates/core/src/function_agents/git-func-agent/types.rs index ec0a937c..70c0b03f 100644 --- a/src/crates/core/src/function_agents/git-func-agent/types.rs +++ b/src/crates/core/src/function_agents/git-func-agent/types.rs @@ -3,7 +3,6 @@ * * Defines data structures for commit message generation */ - use serde::{Deserialize, Serialize}; use std::fmt; @@ -12,16 +11,16 @@ use std::fmt; pub struct CommitMessageOptions { #[serde(default = "default_commit_format")] pub format: CommitFormat, - + #[serde(default = "default_true")] pub include_files: bool, - + #[serde(default = "default_max_length")] pub max_title_length: usize, - + #[serde(default = "default_true")] pub include_body: bool, - + #[serde(default = "default_language")] pub language: Language, } @@ -77,21 +76,21 @@ pub enum Language { pub struct CommitMessage { /// Title (50-72 chars) pub title: String, - + pub body: Option, - + /// Footer info (breaking changes, etc.) pub footer: Option, - + pub full_message: String, - + pub commit_type: CommitType, - + pub scope: Option, - + /// Confidence (0.0-1.0) pub confidence: f32, - + pub changes_summary: ChangesSummary, } @@ -141,15 +140,15 @@ impl fmt::Display for CommitType { #[serde(rename_all = "camelCase")] pub struct ChangesSummary { pub total_additions: u32, - + pub total_deletions: u32, - + pub files_changed: u32, - + pub file_changes: Vec, - + pub affected_modules: Vec, - + pub change_patterns: Vec, } @@ -157,13 +156,13 @@ pub struct ChangesSummary { #[serde(rename_all = "camelCase")] pub struct FileChange { pub path: String, - + pub change_type: FileChangeType, - + pub additions: u32, - + pub deletions: u32, - + pub file_type: String, } @@ -216,21 +215,21 @@ impl AgentError { error_type: AgentErrorType::GitError, } } - + pub fn analysis_error(msg: impl Into) -> Self { Self { message: msg.into(), error_type: AgentErrorType::AnalysisError, } } - + pub fn invalid_input(msg: impl Into) -> Self { Self { message: msg.into(), error_type: AgentErrorType::InvalidInput, } } - + pub fn internal_error(msg: impl Into) -> Self { Self { message: msg.into(), @@ -245,11 +244,11 @@ pub type AgentResult = Result; pub struct ProjectContext { /// Project type (e.g., web-app, library, cli-tool, etc.) pub project_type: String, - + pub tech_stack: Vec, - + pub project_docs: Option, - + pub code_standards: Option, } @@ -267,16 +266,16 @@ impl Default for ProjectContext { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AICommitAnalysis { pub commit_type: CommitType, - + pub scope: Option, - + pub title: String, - + pub body: Option, - + pub breaking_changes: Option, - + pub reasoning: String, - + pub confidence: f32, } diff --git a/src/crates/core/src/function_agents/git-func-agent/utils.rs b/src/crates/core/src/function_agents/git-func-agent/utils.rs index d04d805f..dd191e42 100644 --- a/src/crates/core/src/function_agents/git-func-agent/utils.rs +++ b/src/crates/core/src/function_agents/git-func-agent/utils.rs @@ -3,7 +3,6 @@ * * Provides various helper utilities */ - use super::types::*; use std::path::Path; @@ -17,56 +16,71 @@ pub fn infer_file_type(path: &str) -> String { pub fn extract_module_name(path: &str) -> Option { let path = Path::new(path); - + if let Some(parent) = path.parent() { if let Some(dir_name) = parent.file_name() { return Some(dir_name.to_string_lossy().to_string()); } } - + path.file_stem() .map(|name| name.to_string_lossy().to_string()) } pub fn is_config_file(path: &str) -> bool { let config_patterns = [ - ".json", ".yaml", ".yml", ".toml", ".xml", ".ini", ".conf", - "config", "package.json", "cargo.toml", "tsconfig", + ".json", + ".yaml", + ".yml", + ".toml", + ".xml", + ".ini", + ".conf", + "config", + "package.json", + "cargo.toml", + "tsconfig", ]; - + let path_lower = path.to_lowercase(); - config_patterns.iter().any(|pattern| path_lower.contains(pattern)) + config_patterns + .iter() + .any(|pattern| path_lower.contains(pattern)) } pub fn is_doc_file(path: &str) -> bool { let doc_patterns = [".md", ".txt", ".rst", "readme", "changelog", "license"]; - + let path_lower = path.to_lowercase(); - doc_patterns.iter().any(|pattern| path_lower.contains(pattern)) + doc_patterns + .iter() + .any(|pattern| path_lower.contains(pattern)) } pub fn is_test_file(path: &str) -> bool { let test_patterns = ["test", "spec", "__tests__", ".test.", ".spec."]; - + let path_lower = path.to_lowercase(); - test_patterns.iter().any(|pattern| path_lower.contains(pattern)) + test_patterns + .iter() + .any(|pattern| path_lower.contains(pattern)) } pub fn detect_change_patterns(file_changes: &[FileChange]) -> Vec { let mut patterns = Vec::new(); - + let mut has_code_changes = false; let mut has_test_changes = false; let mut has_doc_changes = false; let mut has_config_changes = false; let mut has_new_files = false; - + for change in file_changes { match change.change_type { FileChangeType::Added => has_new_files = true, _ => {} } - + if is_test_file(&change.path) { has_test_changes = true; } else if is_doc_file(&change.path) { @@ -77,43 +91,44 @@ pub fn detect_change_patterns(file_changes: &[FileChange]) -> Vec has_code_changes = true; } } - + if has_new_files && has_code_changes { patterns.push(ChangePattern::FeatureAddition); } - + if has_code_changes && !has_new_files { patterns.push(ChangePattern::BugFix); } - + if has_test_changes { patterns.push(ChangePattern::TestUpdate); } - + if has_doc_changes { patterns.push(ChangePattern::DocumentationUpdate); } - + if has_config_changes { - if file_changes.iter().any(|f| - f.path.contains("package.json") || - f.path.contains("cargo.toml") || - f.path.contains("requirements.txt") - ) { + if file_changes.iter().any(|f| { + f.path.contains("package.json") + || f.path.contains("cargo.toml") + || f.path.contains("requirements.txt") + }) { patterns.push(ChangePattern::DependencyUpdate); } else { patterns.push(ChangePattern::ConfigChange); } } - + // Large code changes with few files may indicate refactoring - let total_lines = file_changes.iter() + let total_lines = file_changes + .iter() .map(|f| f.additions + f.deletions) .sum::(); - + if has_code_changes && total_lines > 200 && file_changes.len() < 5 { patterns.push(ChangePattern::Refactoring); } - + patterns } diff --git a/src/crates/core/src/function_agents/mod.rs b/src/crates/core/src/function_agents/mod.rs index dce5cd56..3e90239c 100644 --- a/src/crates/core/src/function_agents/mod.rs +++ b/src/crates/core/src/function_agents/mod.rs @@ -13,19 +13,9 @@ pub mod startchat_func_agent; pub use git_func_agent::GitFunctionAgent; pub use startchat_func_agent::StartchatFunctionAgent; -pub use git_func_agent::{ - CommitMessage, - CommitMessageOptions, - CommitFormat, - CommitType, -}; +pub use git_func_agent::{CommitFormat, CommitMessage, CommitMessageOptions, CommitType}; pub use startchat_func_agent::{ - WorkStateAnalysis, - WorkStateOptions, - GreetingMessage, - CurrentWorkState, - GitWorkState, - PredictedAction, - QuickAction, + CurrentWorkState, GitWorkState, GreetingMessage, PredictedAction, QuickAction, + WorkStateAnalysis, WorkStateOptions, }; diff --git a/src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs b/src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs index f5c73fa0..57fca80a 100644 --- a/src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs +++ b/src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs @@ -1,13 +1,12 @@ +use super::types::*; +use crate::infrastructure::ai::AIClient; +use crate::util::types::Message; /** * AI analysis service * * Provides AI-driven work state analysis for the Startchat function agent */ - -use log::{debug, warn, error}; -use super::types::*; -use crate::infrastructure::ai::AIClient; -use crate::util::types::Message; +use log::{debug, error, warn}; use std::sync::Arc; /// Prompt template constants (embedded at compile time) @@ -20,21 +19,22 @@ pub struct AIWorkStateService { impl AIWorkStateService { pub async fn new_with_agent_config( factory: Arc, - agent_name: &str + agent_name: &str, ) -> AgentResult { let ai_client = match factory.get_client_by_func_agent(agent_name).await { Ok(client) => client, Err(e) => { error!("Failed to get AI client: {}", e); - return Err(AgentError::internal_error(format!("Failed to get AI client: {}", e))); + return Err(AgentError::internal_error(format!( + "Failed to get AI client: {}", + e + ))); } }; - - Ok(Self { - ai_client, - }) + + Ok(Self { ai_client }) } - + pub async fn generate_complete_analysis( &self, git_state: &Option, @@ -42,36 +42,45 @@ impl AIWorkStateService { language: &Language, ) -> AgentResult { let prompt = self.build_complete_analysis_prompt(git_state, git_diff, language); - - debug!("Calling AI to generate complete analysis: prompt_length={}", prompt.len()); - + + debug!( + "Calling AI to generate complete analysis: prompt_length={}", + prompt.len() + ); + let response = self.call_ai(&prompt).await?; - + self.parse_complete_analysis(&response) } - + async fn call_ai(&self, prompt: &str) -> AgentResult { debug!("Sending request to AI: prompt_length={}", prompt.len()); - + let messages = vec![Message::user(prompt.to_string())]; - let response = self.ai_client + let response = self + .ai_client .send_message(messages, None) .await .map_err(|e| { error!("AI call failed: {}", e); AgentError::internal_error(format!("AI call failed: {}", e)) })?; - - debug!("AI response received: response_length={}", response.text.len()); - + + debug!( + "AI response received: response_length={}", + response.text.len() + ); + if response.text.is_empty() { error!("AI response is empty"); - Err(AgentError::internal_error("AI response is empty".to_string())) + Err(AgentError::internal_error( + "AI response is empty".to_string(), + )) } else { Ok(response.text) } } - + fn build_complete_analysis_prompt( &self, git_state: &Option, @@ -83,14 +92,14 @@ impl AIWorkStateService { Language::Chinese => "Please respond in Chinese.", Language::English => "Please respond in English.", }; - + // Build Git state section let git_state_section = if let Some(git) = git_state { let mut section = format!( "## Git Status\n\n- Current branch: {}\n- Unstaged files: {}\n- Staged files: {}\n- Unpushed commits: {}\n", git.current_branch, git.unstaged_files, git.staged_files, git.unpushed_commits ); - + if !git.modified_files.is_empty() { section.push_str("\nModified files:\n"); for file in git.modified_files.iter().take(10) { @@ -101,12 +110,13 @@ impl AIWorkStateService { } else { String::new() }; - + // Build Git diff section let git_diff_section = if !git_diff.is_empty() { let max_diff_length = 8000; if git_diff.len() > max_diff_length { - let truncated_diff = git_diff.char_indices() + let truncated_diff = git_diff + .char_indices() .take_while(|(idx, _)| *idx < max_diff_length) .map(|(_, c)| c) .collect::(); @@ -120,14 +130,14 @@ impl AIWorkStateService { } else { String::new() }; - + // Use template replacement WORK_STATE_ANALYSIS_PROMPT .replace("{lang_instruction}", lang_instruction) .replace("{git_state_section}", &git_state_section) .replace("{git_diff_section}", &git_diff_section) } - + fn parse_complete_analysis(&self, response: &str) -> AgentResult { let json_str = if let Some(start) = response.find('{') { if let Some(end) = response.rfind('}') { @@ -138,30 +148,36 @@ impl AIWorkStateService { } else { response }; - + debug!("Parsing JSON response: length={}", json_str.len()); - - let parsed: serde_json::Value = serde_json::from_str(json_str) - .map_err(|e| { - error!("Failed to parse complete analysis response: {}, response: {}", e, response); - AgentError::internal_error(format!("Failed to parse complete analysis response: {}", e)) - })?; - + + let parsed: serde_json::Value = serde_json::from_str(json_str).map_err(|e| { + error!( + "Failed to parse complete analysis response: {}, response: {}", + e, response + ); + AgentError::internal_error(format!("Failed to parse complete analysis response: {}", e)) + })?; + let summary = parsed["summary"] .as_str() .unwrap_or("You were working on development, with multiple files modified.") .to_string(); - + let ongoing_work = Vec::new(); - - let mut predicted_actions = if let Some(actions_array) = parsed["predicted_actions"].as_array() { - self.parse_predicted_actions_from_value(actions_array)? - } else { - Vec::new() - }; - + + let mut predicted_actions = + if let Some(actions_array) = parsed["predicted_actions"].as_array() { + self.parse_predicted_actions_from_value(actions_array)? + } else { + Vec::new() + }; + if predicted_actions.len() < 3 { - warn!("AI generated insufficient predicted actions ({}), adding defaults", predicted_actions.len()); + warn!( + "AI generated insufficient predicted actions ({}), adding defaults", + predicted_actions.len() + ); while predicted_actions.len() < 3 { predicted_actions.push(PredictedAction { description: "Continue current development".to_string(), @@ -171,26 +187,39 @@ impl AIWorkStateService { }); } } else if predicted_actions.len() > 3 { - warn!("AI generated too many predicted actions ({}), truncating to 3", predicted_actions.len()); + warn!( + "AI generated too many predicted actions ({}), truncating to 3", + predicted_actions.len() + ); predicted_actions.truncate(3); } - + let mut quick_actions = if let Some(actions_array) = parsed["quick_actions"].as_array() { self.parse_quick_actions_from_value(actions_array)? } else { Vec::new() }; - + if quick_actions.len() < 6 { // Don't fill defaults here, frontend has its own defaultActions with i18n support - warn!("AI generated insufficient quick actions ({}), frontend will use defaults", quick_actions.len()); + warn!( + "AI generated insufficient quick actions ({}), frontend will use defaults", + quick_actions.len() + ); } else if quick_actions.len() > 6 { - warn!("AI generated too many quick actions ({}), truncating to 6", quick_actions.len()); + warn!( + "AI generated too many quick actions ({}), truncating to 6", + quick_actions.len() + ); quick_actions.truncate(6); } - - debug!("Parsing completed: predicted_actions={}, quick_actions={}", predicted_actions.len(), quick_actions.len()); - + + debug!( + "Parsing completed: predicted_actions={}, quick_actions={}", + predicted_actions.len(), + quick_actions.len() + ); + Ok(AIGeneratedAnalysis { summary, ongoing_work, @@ -198,35 +227,31 @@ impl AIWorkStateService { quick_actions, }) } - - fn parse_predicted_actions_from_value(&self, actions_array: &[serde_json::Value]) -> AgentResult> { + + fn parse_predicted_actions_from_value( + &self, + actions_array: &[serde_json::Value], + ) -> AgentResult> { let mut actions = Vec::new(); - + for action_value in actions_array { let description = action_value["description"] .as_str() .unwrap_or("Continue current work") .to_string(); - - let priority_str = action_value["priority"] - .as_str() - .unwrap_or("Medium"); - + + let priority_str = action_value["priority"].as_str().unwrap_or("Medium"); + let priority = match priority_str { "High" => ActionPriority::High, "Low" => ActionPriority::Low, _ => ActionPriority::Medium, }; - - let icon = action_value["icon"] - .as_str() - .unwrap_or("") - .to_string(); - - let is_reminder = action_value["is_reminder"] - .as_bool() - .unwrap_or(false); - + + let icon = action_value["icon"].as_str().unwrap_or("").to_string(); + + let is_reminder = action_value["is_reminder"].as_bool().unwrap_or(false); + actions.push(PredictedAction { description, priority, @@ -234,33 +259,28 @@ impl AIWorkStateService { is_reminder, }); } - + Ok(actions) } - - fn parse_quick_actions_from_value(&self, actions_array: &[serde_json::Value]) -> AgentResult> { + + fn parse_quick_actions_from_value( + &self, + actions_array: &[serde_json::Value], + ) -> AgentResult> { let mut quick_actions = Vec::new(); - + for action_value in actions_array { let title = action_value["title"] .as_str() .unwrap_or("Quick Action") .to_string(); - - let command = action_value["command"] - .as_str() - .unwrap_or("") - .to_string(); - - let icon = action_value["icon"] - .as_str() - .unwrap_or("") - .to_string(); - - let action_type_str = action_value["action_type"] - .as_str() - .unwrap_or("Custom"); - + + let command = action_value["command"].as_str().unwrap_or("").to_string(); + + let icon = action_value["icon"].as_str().unwrap_or("").to_string(); + + let action_type_str = action_value["action_type"].as_str().unwrap_or("Custom"); + let action_type = match action_type_str { "Continue" => QuickActionType::Continue, "ViewStatus" => QuickActionType::ViewStatus, @@ -268,7 +288,7 @@ impl AIWorkStateService { "Visualize" => QuickActionType::Visualize, _ => QuickActionType::Custom, }; - + quick_actions.push(QuickAction { title, command, @@ -276,7 +296,7 @@ impl AIWorkStateService { action_type, }); } - + Ok(quick_actions) } } diff --git a/src/crates/core/src/function_agents/startchat-func-agent/mod.rs b/src/crates/core/src/function_agents/startchat-func-agent/mod.rs index 904ae3ea..9c1dec30 100644 --- a/src/crates/core/src/function_agents/startchat-func-agent/mod.rs +++ b/src/crates/core/src/function_agents/startchat-func-agent/mod.rs @@ -1,20 +1,19 @@ +pub mod ai_service; /** * Startchat Function Agent - module entry * * Provides work state analysis and greeting generation on session start */ - pub mod types; pub mod work_state_analyzer; -pub mod ai_service; +pub use ai_service::AIWorkStateService; pub use types::*; pub use work_state_analyzer::WorkStateAnalyzer; -pub use ai_service::AIWorkStateService; +use crate::infrastructure::ai::AIClientFactory; use std::path::Path; use std::sync::Arc; -use crate::infrastructure::ai::AIClientFactory; /// Combines work state analysis and greeting generation pub struct StartchatFunctionAgent { @@ -25,7 +24,7 @@ impl StartchatFunctionAgent { pub fn new(factory: Arc) -> Self { Self { factory } } - + /// Analyze work state and generate greeting pub async fn analyze_work_state( &self, @@ -34,16 +33,20 @@ impl StartchatFunctionAgent { ) -> AgentResult { WorkStateAnalyzer::analyze_work_state(self.factory.clone(), repo_path, options).await } - + /// Quickly analyze work state (use default options with specified language) - pub async fn quick_analyze(&self, repo_path: &Path, language: Language) -> AgentResult { + pub async fn quick_analyze( + &self, + repo_path: &Path, + language: Language, + ) -> AgentResult { let options = WorkStateOptions { language, ..WorkStateOptions::default() }; self.analyze_work_state(repo_path, options).await } - + /// Generate greeting only (do not analyze Git status) pub async fn generate_greeting_only(&self, repo_path: &Path) -> AgentResult { let options = WorkStateOptions { @@ -52,8 +55,7 @@ impl StartchatFunctionAgent { include_quick_actions: false, language: Language::Chinese, }; - + self.analyze_work_state(repo_path, options).await } } - diff --git a/src/crates/core/src/function_agents/startchat-func-agent/types.rs b/src/crates/core/src/function_agents/startchat-func-agent/types.rs index 8babe95f..a18aa96e 100644 --- a/src/crates/core/src/function_agents/startchat-func-agent/types.rs +++ b/src/crates/core/src/function_agents/startchat-func-agent/types.rs @@ -3,7 +3,6 @@ * * Defines data structures for work state analysis and greeting info at session start */ - use serde::{Deserialize, Serialize}; use std::fmt; @@ -12,13 +11,13 @@ use std::fmt; pub struct WorkStateOptions { #[serde(default = "default_true")] pub analyze_git: bool, - + #[serde(default = "default_true")] pub predict_next_actions: bool, - + #[serde(default = "default_true")] pub include_quick_actions: bool, - + #[serde(default = "default_language")] pub language: Language, } @@ -52,13 +51,13 @@ pub enum Language { #[serde(rename_all = "camelCase")] pub struct WorkStateAnalysis { pub greeting: GreetingMessage, - + pub current_state: CurrentWorkState, - + pub predicted_actions: Vec, - + pub quick_actions: Vec, - + pub analyzed_at: String, } @@ -66,9 +65,9 @@ pub struct WorkStateAnalysis { #[serde(rename_all = "camelCase")] pub struct GreetingMessage { pub title: String, - + pub subtitle: String, - + pub tagline: Option, } @@ -76,11 +75,11 @@ pub struct GreetingMessage { #[serde(rename_all = "camelCase")] pub struct CurrentWorkState { pub summary: String, - + pub git_state: Option, - + pub ongoing_work: Vec, - + pub time_info: TimeInfo, } @@ -88,15 +87,15 @@ pub struct CurrentWorkState { #[serde(rename_all = "camelCase")] pub struct GitWorkState { pub current_branch: String, - + pub unstaged_files: u32, - + pub staged_files: u32, - + pub unpushed_commits: u32, - + pub ahead_behind: Option, - + /// List of modified files (show at most the first few) pub modified_files: Vec, } @@ -105,7 +104,7 @@ pub struct GitWorkState { #[serde(rename_all = "camelCase")] pub struct AheadBehind { pub ahead: u32, - + pub behind: u32, } @@ -113,9 +112,9 @@ pub struct AheadBehind { #[serde(rename_all = "camelCase")] pub struct FileModification { pub path: String, - + pub change_type: FileChangeType, - + pub module: Option, } @@ -145,13 +144,13 @@ impl fmt::Display for FileChangeType { #[serde(rename_all = "camelCase")] pub struct WorkItem { pub title: String, - + pub description: String, - + pub related_files: Vec, - + pub category: WorkCategory, - + pub icon: String, } @@ -188,10 +187,10 @@ impl fmt::Display for WorkCategory { pub struct TimeInfo { /// Minutes since last commit pub minutes_since_last_commit: Option, - + /// Last commit time description (e.g., "2 hours ago") pub last_commit_time_desc: Option, - + /// Current time of day (morning/afternoon/evening) pub time_of_day: TimeOfDay, } @@ -220,11 +219,11 @@ impl fmt::Display for TimeOfDay { #[serde(rename_all = "camelCase")] pub struct PredictedAction { pub description: String, - + pub priority: ActionPriority, - + pub icon: String, - + pub is_reminder: bool, } @@ -250,12 +249,12 @@ impl fmt::Display for ActionPriority { #[serde(rename_all = "camelCase")] pub struct QuickAction { pub title: String, - + /// Action command (natural language) pub command: String, - + pub icon: String, - + pub action_type: QuickActionType, } @@ -272,11 +271,11 @@ pub enum QuickActionType { #[serde(rename_all = "camelCase")] pub struct AIGeneratedAnalysis { pub summary: String, - + pub ongoing_work: Vec, - + pub predicted_actions: Vec, - + pub quick_actions: Vec, } @@ -309,21 +308,21 @@ impl AgentError { error_type: AgentErrorType::GitError, } } - + pub fn analysis_error(msg: impl Into) -> Self { Self { message: msg.into(), error_type: AgentErrorType::AnalysisError, } } - + pub fn invalid_input(msg: impl Into) -> Self { Self { message: msg.into(), error_type: AgentErrorType::InvalidInput, } } - + pub fn internal_error(msg: impl Into) -> Self { Self { message: msg.into(), @@ -333,4 +332,3 @@ impl AgentError { } pub type AgentResult = Result; - diff --git a/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs b/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs index ca536c60..67f37155 100644 --- a/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs +++ b/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs @@ -1,15 +1,14 @@ +use super::types::*; +use crate::infrastructure::ai::AIClientFactory; +use chrono::{Local, Timelike}; /** * Work state analyzer * * Analyzes the user's current work state, including Git status and file changes */ - use log::{debug, info}; -use super::types::*; use std::path::Path; use std::sync::Arc; -use chrono::{Local, Timelike}; -use crate::infrastructure::ai::AIClientFactory; pub struct WorkStateAnalyzer; @@ -20,46 +19,51 @@ impl WorkStateAnalyzer { options: WorkStateOptions, ) -> AgentResult { info!("Analyzing work state: repo_path={:?}", repo_path); - + let greeting = Self::generate_greeting(&options); - + let git_state = if options.analyze_git { Self::analyze_git_state(repo_path).await.ok() } else { None }; - - let git_diff = if git_state.as_ref().map_or(false, |g| g.unstaged_files > 0 || g.staged_files > 0) { + + let git_diff = if git_state + .as_ref() + .map_or(false, |g| g.unstaged_files > 0 || g.staged_files > 0) + { Self::get_git_diff(repo_path).await.unwrap_or_default() } else { String::new() }; - + let time_info = Self::get_time_info(repo_path).await; - - let ai_analysis = Self::generate_complete_analysis_with_ai(factory, &git_state, &git_diff, &options).await?; - + + let ai_analysis = + Self::generate_complete_analysis_with_ai(factory, &git_state, &git_diff, &options) + .await?; + debug!("AI complete analysis generation succeeded"); let summary = ai_analysis.summary; let ongoing_work = ai_analysis.ongoing_work; - let predicted_actions = if options.predict_next_actions { - ai_analysis.predicted_actions - } else { - Vec::new() + let predicted_actions = if options.predict_next_actions { + ai_analysis.predicted_actions + } else { + Vec::new() }; - let quick_actions = if options.include_quick_actions { - ai_analysis.quick_actions - } else { - Vec::new() + let quick_actions = if options.include_quick_actions { + ai_analysis.quick_actions + } else { + Vec::new() }; - + let current_state = CurrentWorkState { summary, git_state, ongoing_work, time_info, }; - + Ok(WorkStateAnalysis { greeting, current_state, @@ -68,7 +72,7 @@ impl WorkStateAnalyzer { analyzed_at: Local::now().to_rfc3339(), }) } - + fn generate_greeting(_options: &WorkStateOptions) -> GreetingMessage { // Frontend uses its own static greeting from i18n. GreetingMessage { @@ -77,38 +81,38 @@ impl WorkStateAnalyzer { tagline: None, } } - + async fn get_git_diff(repo_path: &Path) -> AgentResult { debug!("Getting Git diff"); - + let unstaged_output = crate::util::process_manager::create_command("git") .arg("diff") .arg("HEAD") .current_dir(repo_path) .output() .map_err(|e| AgentError::git_error(format!("Failed to get git diff: {}", e)))?; - + let mut diff = String::from_utf8_lossy(&unstaged_output.stdout).to_string(); - + let staged_output = crate::util::process_manager::create_command("git") .arg("diff") .arg("--cached") .current_dir(repo_path) .output() .map_err(|e| AgentError::git_error(format!("Failed to get staged diff: {}", e)))?; - + let staged_diff = String::from_utf8_lossy(&staged_output.stdout); - + if !staged_diff.is_empty() { diff.push_str("\n\n=== Staged Changes ===\n\n"); diff.push_str(&staged_diff); } - + debug!("Git diff retrieved: length={} chars", diff.len()); - + Ok(diff) } - + async fn generate_complete_analysis_with_ai( factory: Arc, git_state: &Option, @@ -116,41 +120,44 @@ impl WorkStateAnalyzer { options: &WorkStateOptions, ) -> AgentResult { use super::ai_service::AIWorkStateService; - + debug!("Starting AI complete analysis generation"); - - let ai_service = AIWorkStateService::new_with_agent_config(factory, "startchat-func-agent").await?; - ai_service.generate_complete_analysis(git_state, git_diff, &options.language).await + + let ai_service = + AIWorkStateService::new_with_agent_config(factory, "startchat-func-agent").await?; + ai_service + .generate_complete_analysis(git_state, git_diff, &options.language) + .await } - + async fn analyze_git_state(repo_path: &Path) -> AgentResult { let current_branch = Self::get_current_branch(repo_path)?; - + let status_output = crate::util::process_manager::create_command("git") .arg("status") .arg("--porcelain") .current_dir(repo_path) .output() .map_err(|e| AgentError::git_error(format!("Failed to get git status: {}", e)))?; - + let status_str = String::from_utf8_lossy(&status_output.stdout); - + let mut unstaged_files = 0; let mut staged_files = 0; let mut modified_files = Vec::new(); - + for line in status_str.lines() { if line.is_empty() { continue; } - + let status_code = &line[0..2]; let file_path = if line.len() > 3 { line[3..].trim().to_string() } else { continue; }; - + let (change_type, is_staged) = match status_code { "A " => (FileChangeType::Added, true), " M" => (FileChangeType::Modified, false), @@ -162,13 +169,13 @@ impl WorkStateAnalyzer { "R " => (FileChangeType::Renamed, true), _ => (FileChangeType::Modified, false), }; - + if is_staged { staged_files += 1; } else { unstaged_files += 1; } - + if modified_files.len() < 10 { modified_files.push(FileModification { path: file_path.clone(), @@ -177,10 +184,10 @@ impl WorkStateAnalyzer { }); } } - + let unpushed_commits = Self::get_unpushed_commits(repo_path)?; let ahead_behind = Self::get_ahead_behind(repo_path).ok(); - + Ok(GitWorkState { current_branch, unstaged_files, @@ -190,7 +197,7 @@ impl WorkStateAnalyzer { modified_files, }) } - + fn get_current_branch(repo_path: &Path) -> AgentResult { let output = crate::util::process_manager::create_command("git") .arg("branch") @@ -198,10 +205,10 @@ impl WorkStateAnalyzer { .current_dir(repo_path) .output() .map_err(|e| AgentError::git_error(format!("Failed to get current branch: {}", e)))?; - + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } - + fn get_unpushed_commits(repo_path: &Path) -> AgentResult { let output = crate::util::process_manager::create_command("git") .arg("log") @@ -209,19 +216,17 @@ impl WorkStateAnalyzer { .arg("--oneline") .current_dir(repo_path) .output(); - + if let Ok(output) = output { if output.status.success() { - let count = String::from_utf8_lossy(&output.stdout) - .lines() - .count() as u32; + let count = String::from_utf8_lossy(&output.stdout).lines().count() as u32; return Ok(count); } } - + Ok(0) } - + fn get_ahead_behind(repo_path: &Path) -> AgentResult { let output = crate::util::process_manager::create_command("git") .arg("rev-list") @@ -231,14 +236,14 @@ impl WorkStateAnalyzer { .current_dir(repo_path) .output() .map_err(|e| AgentError::git_error(format!("Failed to get ahead/behind: {}", e)))?; - + if !output.status.success() { return Err(AgentError::git_error("No upstream branch configured")); } - + let result = String::from_utf8_lossy(&output.stdout); let parts: Vec<&str> = result.trim().split_whitespace().collect(); - + if parts.len() >= 2 { let ahead = parts[0].parse().unwrap_or(0); let behind = parts[1].parse().unwrap_or(0); @@ -247,17 +252,17 @@ impl WorkStateAnalyzer { Err(AgentError::git_error("Failed to parse ahead/behind info")) } } - + fn extract_module(file_path: &str) -> Option { let path = Path::new(file_path); - + if let Some(component) = path.components().next() { return Some(component.as_os_str().to_string_lossy().to_string()); } - + None } - + async fn get_time_info(repo_path: &Path) -> TimeInfo { let hour = Local::now().hour(); let time_of_day = match hour { @@ -266,14 +271,14 @@ impl WorkStateAnalyzer { 18..=22 => TimeOfDay::Evening, _ => TimeOfDay::Night, }; - + let output = crate::util::process_manager::create_command("git") .arg("log") .arg("-1") .arg("--format=%ct") .current_dir(repo_path) .output(); - + let (minutes_since_last_commit, last_commit_time_desc) = if let Ok(output) = output { if output.status.success() { let timestamp_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); @@ -281,7 +286,7 @@ impl WorkStateAnalyzer { let now = Local::now().timestamp(); let diff_seconds = now - timestamp; let minutes = (diff_seconds / 60) as u64; - + // Don't format time description here, let frontend handle i18n (Some(minutes), None) } else { @@ -293,7 +298,7 @@ impl WorkStateAnalyzer { } else { (None, None) }; - + TimeInfo { minutes_since_last_commit, last_commit_time_desc, @@ -301,4 +306,3 @@ impl WorkStateAnalyzer { } } } - diff --git a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/mod.rs b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/mod.rs index 24e2938a..865f0ac4 100644 --- a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/mod.rs +++ b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/mod.rs @@ -1,9 +1,9 @@ -mod openai; mod anthropic; -mod responses; mod gemini; +mod openai; +mod responses; -pub use openai::handle_openai_stream; pub use anthropic::handle_anthropic_stream; -pub use responses::handle_responses_stream; pub use gemini::handle_gemini_stream; +pub use openai::handle_openai_stream; +pub use responses::handle_responses_stream; diff --git a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/responses.rs b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/responses.rs index ec2f28ce..7d38aac1 100644 --- a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/responses.rs +++ b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/responses.rs @@ -98,8 +98,9 @@ fn handle_function_call_output_item_done( .and_then(Value::as_str) .unwrap_or_default(); let need_fallback_full = !tc.saw_any_delta; - let need_tail = - tc.saw_any_delta && tc.args_so_far.len() < full_args.len() && full_args.starts_with(&tc.args_so_far); + let need_tail = tc.saw_any_delta + && tc.args_so_far.len() < full_args.len() + && full_args.starts_with(&tc.args_so_far); if need_fallback_full || need_tail { let delta = if need_fallback_full { @@ -215,7 +216,10 @@ pub async fn handle_responses_stream( }; if let Some(api_error_message) = extract_api_error_message(&event_json) { - let error_msg = format!("Responses SSE API error: {}, data: {}", api_error_message, raw); + let error_msg = format!( + "Responses SSE API error: {}, data: {}", + api_error_message, raw + ); error!("{}", error_msg); let _ = tx_event.send(Err(anyhow!(error_msg))); return; @@ -234,7 +238,8 @@ pub async fn handle_responses_stream( match event.kind.as_str() { "response.output_item.added" => { // Track tool calls so we can stream arguments via `response.function_call_arguments.delta`. - if let (Some(output_index), Some(item)) = (event.output_index, event.item.as_ref()) { + if let (Some(output_index), Some(item)) = (event.output_index, event.item.as_ref()) + { if let Some(tc) = InProgressToolCall::from_item_value(item) { if let Some(ref call_id) = tc.call_id { tool_call_index_by_id.insert(call_id.clone(), output_index); @@ -340,28 +345,31 @@ pub async fn handle_responses_stream( && full_args.starts_with(&tc.args_so_far) { let delta = full_args[tc.args_so_far.len()..].to_string(); - if !delta.is_empty() { - tc.args_so_far.push_str(&delta); - let (id, name) = if tc.sent_header { - (None, None) - } else { - tc.sent_header = true; - (tc.call_id.clone(), tc.name.clone()) - }; - let _ = tx_event.send(Ok(UnifiedResponse { - tool_call: Some(crate::types::unified::UnifiedToolCall { - id, - name, - arguments: Some(delta), - }), - ..Default::default() - })); + if !delta.is_empty() { + tc.args_so_far.push_str(&delta); + let (id, name) = if tc.sent_header { + (None, None) + } else { + tc.sent_header = true; + (tc.call_id.clone(), tc.name.clone()) + }; + let _ = tx_event.send(Ok(UnifiedResponse { + tool_call: Some(crate::types::unified::UnifiedToolCall { + id, + name, + arguments: Some(delta), + }), + ..Default::default() + })); + } } } } } - } - match event.response.map(serde_json::from_value::) { + match event + .response + .map(serde_json::from_value::) + { Some(Ok(response)) => { received_finish_reason = true; let _ = tx_event.send(Ok(UnifiedResponse { @@ -372,7 +380,8 @@ pub async fn handle_responses_stream( continue; } Some(Err(e)) => { - let error_msg = format!("Failed to parse response.completed payload: {}", e); + let error_msg = + format!("Failed to parse response.completed payload: {}", e); error!("{}", error_msg); let _ = tx_event.send(Err(anyhow!(error_msg))); return; @@ -539,10 +548,16 @@ mod tests { &mut tool_call_index_by_id, ); - let response = rx_event.try_recv().expect("tool call event").expect("ok response"); + let response = rx_event + .try_recv() + .expect("tool call event") + .expect("ok response"); let tool_call = response.tool_call.expect("tool call"); assert_eq!(tool_call.id.as_deref(), Some("call_1")); assert_eq!(tool_call.name.as_deref(), Some("get_weather")); - assert_eq!(tool_call.arguments.as_deref(), Some("{\"city\":\"Beijing\"}")); + assert_eq!( + tool_call.arguments.as_deref(), + Some("{\"city\":\"Beijing\"}") + ); } } diff --git a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/gemini.rs b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/gemini.rs index 3cb810f2..c2e26719 100644 --- a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/gemini.rs +++ b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/gemini.rs @@ -241,7 +241,10 @@ impl GeminiSSEData { } } - fn safety_summary(prompt_feedback: Option<&Value>, safety_ratings: Option<&Value>) -> Option { + fn safety_summary( + prompt_feedback: Option<&Value>, + safety_ratings: Option<&Value>, + ) -> Option { let mut lines = Vec::new(); if let Some(prompt_feedback) = prompt_feedback { diff --git a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/mod.rs b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/mod.rs index c266edbd..39693a3a 100644 --- a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/mod.rs +++ b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/mod.rs @@ -1,5 +1,5 @@ -pub mod unified; -pub mod openai; pub mod anthropic; -pub mod responses; pub mod gemini; +pub mod openai; +pub mod responses; +pub mod unified; diff --git a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/responses.rs b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/responses.rs index 6e8a3e00..a12ef79b 100644 --- a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/responses.rs +++ b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/responses.rs @@ -174,8 +174,8 @@ mod tests { })) .expect("event"); - let completed: ResponsesCompleted = serde_json::from_value(event.response.expect("response")) - .expect("completed"); + let completed: ResponsesCompleted = + serde_json::from_value(event.response.expect("response")).expect("completed"); assert_eq!(completed.id, "resp_1"); assert_eq!(completed.usage.expect("usage").total_tokens, 14); } diff --git a/src/crates/core/src/infrastructure/ai/client.rs b/src/crates/core/src/infrastructure/ai/client.rs index 35d6bcf7..7d987874 100644 --- a/src/crates/core/src/infrastructure/ai/client.rs +++ b/src/crates/core/src/infrastructure/ai/client.rs @@ -9,12 +9,14 @@ use crate::service::config::ProxyConfig; use crate::util::types::*; use crate::util::JsonChecker; use ai_stream_handlers::{ - handle_anthropic_stream, handle_gemini_stream, handle_openai_stream, handle_responses_stream, UnifiedResponse, + handle_anthropic_stream, handle_gemini_stream, handle_openai_stream, handle_responses_stream, + UnifiedResponse, }; use anyhow::{anyhow, Result}; use futures::StreamExt; use log::{debug, error, info, warn}; use reqwest::{Client, Proxy}; +use serde::Deserialize; use std::collections::HashMap; use tokio::sync::mpsc; @@ -32,6 +34,28 @@ pub struct AIClient { pub config: AIConfig, } +#[derive(Debug, Deserialize)] +struct OpenAIModelsResponse { + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct OpenAIModelEntry { + id: String, +} + +#[derive(Debug, Deserialize)] +struct AnthropicModelsResponse { + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct AnthropicModelEntry { + id: String, + #[serde(default)] + display_name: Option, +} + impl AIClient { const TEST_IMAGE_EXPECTED_CODE: &'static str = "BYGR"; const TEST_IMAGE_PNG_BASE64: &'static str = @@ -99,7 +123,10 @@ impl AIClient { } fn is_responses_api_format(api_format: &str) -> bool { - matches!(api_format.to_ascii_lowercase().as_str(), "response" | "responses") + matches!( + api_format.to_ascii_lowercase().as_str(), + "response" | "responses" + ) } fn build_test_connection_extra_body(&self) -> Option { @@ -127,7 +154,113 @@ impl AIClient { } fn is_gemini_api_format(api_format: &str) -> bool { - matches!(api_format.to_ascii_lowercase().as_str(), "gemini" | "google") + matches!( + api_format.to_ascii_lowercase().as_str(), + "gemini" | "google" + ) + } + + fn normalize_base_url_for_discovery(base_url: &str) -> String { + base_url + .trim() + .trim_end_matches('#') + .trim_end_matches('/') + .to_string() + } + + fn resolve_openai_models_url(&self) -> String { + let mut base = Self::normalize_base_url_for_discovery(&self.config.base_url); + + for suffix in ["/chat/completions", "/responses", "/models"] { + if base.ends_with(suffix) { + base.truncate(base.len() - suffix.len()); + break; + } + } + + if base.is_empty() { + return "models".to_string(); + } + + format!("{}/models", base) + } + + fn resolve_anthropic_models_url(&self) -> String { + let mut base = Self::normalize_base_url_for_discovery(&self.config.base_url); + + if base.ends_with("/v1/messages") { + base.truncate(base.len() - "/v1/messages".len()); + return format!("{}/v1/models", base); + } + + if base.ends_with("/v1/models") { + return base; + } + + if base.ends_with("/v1") { + return format!("{}/models", base); + } + + if base.is_empty() { + return "v1/models".to_string(); + } + + format!("{}/v1/models", base) + } + + fn dedupe_remote_models(models: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut deduped = Vec::new(); + + for model in models { + if seen.insert(model.id.clone()) { + deduped.push(model); + } + } + + deduped + } + + async fn list_openai_models(&self) -> Result> { + let url = self.resolve_openai_models_url(); + let response = self + .apply_openai_headers(self.client.get(&url)) + .send() + .await? + .error_for_status()?; + + let payload: OpenAIModelsResponse = response.json().await?; + Ok(Self::dedupe_remote_models( + payload + .data + .into_iter() + .map(|model| RemoteModelInfo { + id: model.id, + display_name: None, + }) + .collect(), + )) + } + + async fn list_anthropic_models(&self) -> Result> { + let url = self.resolve_anthropic_models_url(); + let response = self + .apply_anthropic_headers(self.client.get(&url), &url) + .send() + .await? + .error_for_status()?; + + let payload: AnthropicModelsResponse = response.json().await?; + Ok(Self::dedupe_remote_models( + payload + .data + .into_iter() + .map(|model| RemoteModelInfo { + id: model.id, + display_name: model.display_name, + }) + .collect(), + )) } /// Create an AIClient without proxy (backward compatible) @@ -469,9 +602,11 @@ impl AIClient { fn normalize_gemini_stop_sequences(value: &serde_json::Value) -> Option { match value { - serde_json::Value::String(sequence) => Some(serde_json::Value::Array(vec![ - serde_json::Value::String(sequence.clone()), - ])), + serde_json::Value::String(sequence) => { + Some(serde_json::Value::Array(vec![serde_json::Value::String( + sequence.clone(), + )])) + } serde_json::Value::Array(items) => { let sequences = items .iter() @@ -557,7 +692,9 @@ impl AIClient { Self::insert_gemini_generation_field(request_body, "temperature", temperature); } - let top_p = extra_obj.remove("top_p").or_else(|| extra_obj.remove("topP")); + let top_p = extra_obj + .remove("top_p") + .or_else(|| extra_obj.remove("topP")); if let Some(top_p) = top_p { Self::insert_gemini_generation_field(request_body, "topP", top_p); } @@ -567,11 +704,7 @@ impl AIClient { .and_then(Self::normalize_gemini_stop_sequences) { extra_obj.remove("stop"); - Self::insert_gemini_generation_field( - request_body, - "stopSequences", - stop_sequences, - ); + Self::insert_gemini_generation_field(request_body, "stopSequences", stop_sequences); } if let Some(response_mime_type) = extra_obj @@ -863,21 +996,30 @@ impl AIClient { } if let Some(top_p) = self.config.top_p { - Self::insert_gemini_generation_field(&mut request_body, "topP", serde_json::json!(top_p)); + Self::insert_gemini_generation_field( + &mut request_body, + "topP", + serde_json::json!(top_p), + ); } if self.config.enable_thinking_process { - Self::insert_gemini_generation_field(&mut request_body, "thinkingConfig", serde_json::json!({ - "includeThoughts": true, - })); + Self::insert_gemini_generation_field( + &mut request_body, + "thinkingConfig", + serde_json::json!({ + "includeThoughts": true, + }), + ); } if let Some(tools) = gemini_tools { let tool_names = tools .iter() .flat_map(|tool| { - if let Some(declarations) = - tool.get("functionDeclarations").and_then(|value| value.as_array()) + if let Some(declarations) = tool + .get("functionDeclarations") + .and_then(|value| value.as_array()) { declarations .iter() @@ -903,7 +1045,8 @@ impl AIClient { let has_function_declarations = request_body["tools"] .as_array() .map(|tools| { - tools.iter() + tools + .iter() .any(|tool| tool.get("functionDeclarations").is_some()) }) .unwrap_or(false); @@ -925,9 +1068,7 @@ impl AIClient { for (key, value) in extra_obj { if let Some(request_obj) = request_body.as_object_mut() { - let target = request_obj - .entry(key) - .or_insert(serde_json::Value::Null); + let target = request_obj.entry(key).or_insert(serde_json::Value::Null); Self::merge_json_value(target, value); } } @@ -1321,8 +1462,12 @@ impl AIClient { let (instructions, response_input) = OpenAIMessageConverter::convert_messages_to_responses_input(messages); let openai_tools = OpenAIMessageConverter::convert_tools(tools); - let request_body = - self.build_responses_request_body(instructions, response_input, openai_tools, extra_body); + let request_body = self.build_responses_request_body( + instructions, + response_input, + openai_tools, + extra_body, + ); let mut last_error = None; let base_wait_time_ms = 500; @@ -1343,7 +1488,11 @@ impl AIClient { .await .unwrap_or_else(|e| format!("Failed to read error response: {}", e)); error!("Responses API client error {}: {}", status, error_text); - return Err(anyhow!("Responses API client error {}: {}", status, error_text)); + return Err(anyhow!( + "Responses API client error {}: {}", + status, + error_text + )); } if status.is_success() { @@ -1843,6 +1992,17 @@ impl AIClient { } } } + + pub async fn list_models(&self) -> Result> { + match self.get_api_format().to_ascii_lowercase().as_str() { + "openai" | "response" | "responses" => self.list_openai_models().await, + "anthropic" => self.list_anthropic_models().await, + unsupported => Err(anyhow!( + "Listing models is not supported for API format: {}", + unsupported + )), + } + } } #[cfg(test)] @@ -1912,6 +2072,62 @@ mod tests { assert_eq!(extra_body["temperature"], 0.3); } + #[test] + fn resolves_openai_models_url_from_completion_endpoint() { + let client = AIClient::new(AIConfig { + name: "test".to_string(), + base_url: "https://api.openai.com/v1/chat/completions".to_string(), + request_url: "https://api.openai.com/v1/chat/completions".to_string(), + api_key: "test-key".to_string(), + model: "gpt-4.1".to_string(), + format: "openai".to_string(), + context_window: 128000, + max_tokens: Some(8192), + temperature: None, + top_p: None, + enable_thinking_process: false, + support_preserved_thinking: false, + custom_headers: None, + custom_headers_mode: None, + skip_ssl_verify: false, + reasoning_effort: None, + custom_request_body: None, + }); + + assert_eq!( + client.resolve_openai_models_url(), + "https://api.openai.com/v1/models" + ); + } + + #[test] + fn resolves_anthropic_models_url_from_messages_endpoint() { + let client = AIClient::new(AIConfig { + name: "test".to_string(), + base_url: "https://api.anthropic.com/v1/messages".to_string(), + request_url: "https://api.anthropic.com/v1/messages".to_string(), + api_key: "test-key".to_string(), + model: "claude-sonnet-4-5".to_string(), + format: "anthropic".to_string(), + context_window: 200000, + max_tokens: Some(8192), + temperature: None, + top_p: None, + enable_thinking_process: false, + support_preserved_thinking: false, + custom_headers: None, + custom_headers_mode: None, + skip_ssl_verify: false, + reasoning_effort: None, + custom_request_body: None, + }); + + assert_eq!( + client.resolve_anthropic_models_url(), + "https://api.anthropic.com/v1/models" + ); + } + #[test] fn build_gemini_request_body_translates_response_format_and_merges_generation_config() { let client = AIClient::new(AIConfig { @@ -1975,7 +2191,10 @@ mod tests { "application/json" ); assert_eq!(request_body["generationConfig"]["candidateCount"], 1); - assert_eq!(request_body["generationConfig"]["stopSequences"], json!(["END"])); + assert_eq!( + request_body["generationConfig"]["stopSequences"], + json!(["END"]) + ); assert_eq!( request_body["generationConfig"]["responseJsonSchema"]["required"], json!(["answer"]) diff --git a/src/crates/core/src/infrastructure/ai/client_factory.rs b/src/crates/core/src/infrastructure/ai/client_factory.rs index 94300f4b..3b70e326 100644 --- a/src/crates/core/src/infrastructure/ai/client_factory.rs +++ b/src/crates/core/src/infrastructure/ai/client_factory.rs @@ -21,6 +21,20 @@ pub struct AIClientFactory { } impl AIClientFactory { + fn resolve_model_reference_in_config( + global_config: &crate::service::config::GlobalConfig, + model_ref: &str, + ) -> Option { + global_config.ai.resolve_model_reference(model_ref) + } + + fn resolve_model_selection_in_config( + global_config: &crate::service::config::GlobalConfig, + model_ref: &str, + ) -> Option { + global_config.ai.resolve_model_selection(model_ref) + } + fn new(config_service: Arc) -> Self { Self { config_service, @@ -63,28 +77,17 @@ impl AIClientFactory { /// Get a client (supports resolving primary/fast) pub async fn get_client_resolved(&self, model_id: &str) -> Result> { + let global_config: crate::service::config::GlobalConfig = + self.config_service.get_config(None).await?; + let resolved_model_id = match model_id { - "primary" => { - let global_config: crate::service::config::GlobalConfig = - self.config_service.get_config(None).await?; - global_config - .ai - .default_models - .primary - .ok_or_else(|| anyhow!("Primary model not configured"))? - } - "fast" => { - let global_config: crate::service::config::GlobalConfig = - self.config_service.get_config(None).await?; - - match global_config.ai.default_models.fast { - Some(fast_id) => fast_id, - None => global_config.ai.default_models.primary.ok_or_else(|| { - anyhow!("Fast model not configured and primary model not configured") - })?, - } - } - _ => model_id.to_string(), + "primary" => Self::resolve_model_selection_in_config(&global_config, "primary") + .ok_or_else(|| anyhow!("Primary model not configured or invalid"))?, + "fast" => Self::resolve_model_selection_in_config(&global_config, "fast").ok_or_else( + || anyhow!("Fast model not configured or invalid, and primary model not configured or invalid"), + )?, + _ => Self::resolve_model_reference_in_config(&global_config, model_id) + .unwrap_or_else(|| model_id.to_string()), }; self.get_or_create_client(&resolved_model_id).await @@ -128,6 +131,15 @@ impl AIClientFactory { } async fn get_or_create_client(&self, model_id: &str) -> Result> { + let global_config: crate::service::config::GlobalConfig = + self.config_service.get_config(None).await?; + let normalized_model_id = match model_id { + "primary" | "fast" => Self::resolve_model_selection_in_config(&global_config, model_id) + .unwrap_or_else(|| model_id.to_string()), + _ => Self::resolve_model_reference_in_config(&global_config, model_id) + .unwrap_or_else(|| model_id.to_string()), + }; + { let cache = match self.client_cache.read() { Ok(cache) => cache, @@ -138,21 +150,22 @@ impl AIClientFactory { poisoned.into_inner() } }; - if let Some(client) = cache.get(model_id) { + if let Some(client) = cache.get(&normalized_model_id) { return Ok(client.clone()); } } - debug!("Creating new AI client: model_id={}", model_id); - - let global_config: crate::service::config::GlobalConfig = - self.config_service.get_config(None).await?; + debug!("Creating new AI client: model_id={}", normalized_model_id); let model_config = global_config .ai .models .iter() - .find(|m| m.id == model_id) - .ok_or_else(|| anyhow!("Model configuration not found: {}", model_id))?; + .find(|m| { + m.id == normalized_model_id + || m.name == normalized_model_id + || m.model_name == normalized_model_id + }) + .ok_or_else(|| anyhow!("Model configuration not found: {}", normalized_model_id))?; let ai_config = AIConfig::try_from(model_config.clone()) .map_err(|e| anyhow!("AI configuration conversion failed: {}", e))?; @@ -175,12 +188,12 @@ impl AIClientFactory { poisoned.into_inner() } }; - cache.insert(model_id.to_string(), client.clone()); + cache.insert(model_config.id.clone(), client.clone()); } debug!( "AI client created: model_id={}, name={}", - model_id, model_config.name + model_config.id, model_config.name ); Ok(client) @@ -257,3 +270,76 @@ pub async fn get_global_ai_client_factory() -> BitFunResult pub async fn initialize_global_ai_client_factory() -> BitFunResult<()> { AIClientFactory::initialize_global().await } + +#[cfg(test)] +mod tests { + use super::AIClientFactory; + use crate::service::config::types::{AIModelConfig, GlobalConfig}; + + fn build_model(id: &str, name: &str, model_name: &str) -> AIModelConfig { + AIModelConfig { + id: id.to_string(), + name: name.to_string(), + model_name: model_name.to_string(), + provider: "anthropic".to_string(), + enabled: true, + ..Default::default() + } + } + + #[test] + fn resolve_model_reference_supports_id_name_and_model_name() { + let mut config = GlobalConfig::default(); + config.ai.models = vec![build_model( + "model-123", + "Primary Chat", + "claude-sonnet-4.5", + )]; + + assert_eq!( + AIClientFactory::resolve_model_reference_in_config(&config, "model-123"), + Some("model-123".to_string()) + ); + assert_eq!( + AIClientFactory::resolve_model_reference_in_config(&config, "Primary Chat"), + Some("model-123".to_string()) + ); + assert_eq!( + AIClientFactory::resolve_model_reference_in_config(&config, "claude-sonnet-4.5"), + Some("model-123".to_string()) + ); + } + + #[test] + fn resolve_fast_selection_falls_back_to_primary_when_fast_missing() { + let mut config = GlobalConfig::default(); + config.ai.models = vec![build_model( + "model-primary", + "Primary Chat", + "claude-sonnet-4.5", + )]; + config.ai.default_models.primary = Some("model-primary".to_string()); + + assert_eq!( + AIClientFactory::resolve_model_selection_in_config(&config, "fast"), + Some("model-primary".to_string()) + ); + } + + #[test] + fn resolve_fast_selection_falls_back_to_primary_when_fast_is_stale() { + let mut config = GlobalConfig::default(); + config.ai.models = vec![build_model( + "model-primary", + "Primary Chat", + "claude-sonnet-4.5", + )]; + config.ai.default_models.primary = Some("model-primary".to_string()); + config.ai.default_models.fast = Some("deleted-fast-model".to_string()); + + assert_eq!( + AIClientFactory::resolve_model_selection_in_config(&config, "fast"), + Some("model-primary".to_string()) + ); + } +} diff --git a/src/crates/core/src/infrastructure/ai/mod.rs b/src/crates/core/src/infrastructure/ai/mod.rs index 544e738e..ae9e7015 100644 --- a/src/crates/core/src/infrastructure/ai/mod.rs +++ b/src/crates/core/src/infrastructure/ai/mod.rs @@ -9,4 +9,6 @@ pub mod providers; pub use ai_stream_handlers; pub use client::{AIClient, StreamResponse}; -pub use client_factory::{AIClientFactory, get_global_ai_client_factory, initialize_global_ai_client_factory}; +pub use client_factory::{ + get_global_ai_client_factory, initialize_global_ai_client_factory, AIClientFactory, +}; diff --git a/src/crates/core/src/infrastructure/ai/providers/anthropic/mod.rs b/src/crates/core/src/infrastructure/ai/providers/anthropic/mod.rs index c1684ff5..e01d6710 100644 --- a/src/crates/core/src/infrastructure/ai/providers/anthropic/mod.rs +++ b/src/crates/core/src/infrastructure/ai/providers/anthropic/mod.rs @@ -5,4 +5,3 @@ pub mod message_converter; pub use message_converter::AnthropicMessageConverter; - diff --git a/src/crates/core/src/infrastructure/ai/providers/gemini/message_converter.rs b/src/crates/core/src/infrastructure/ai/providers/gemini/message_converter.rs index 70000f97..71752bed 100644 --- a/src/crates/core/src/infrastructure/ai/providers/gemini/message_converter.rs +++ b/src/crates/core/src/infrastructure/ai/providers/gemini/message_converter.rs @@ -7,7 +7,10 @@ use serde_json::{json, Map, Value}; pub struct GeminiMessageConverter; impl GeminiMessageConverter { - pub fn convert_messages(messages: Vec, model_name: &str) -> (Option, Vec) { + pub fn convert_messages( + messages: Vec, + model_name: &str, + ) -> (Option, Vec) { let mut system_texts = Vec::new(); let mut contents = Vec::new(); let is_gemini_3 = model_name.contains("gemini-3"); @@ -36,7 +39,11 @@ impl GeminiMessageConverter { .map(|tool_calls| !tool_calls.is_empty()) .unwrap_or(false); - if let Some(content) = msg.content.as_deref().filter(|value| !value.trim().is_empty()) { + if let Some(content) = msg + .content + .as_deref() + .filter(|value| !value.trim().is_empty()) + { if !has_tool_calls { if let Some(signature) = pending_thought_signature.take() { parts.push(json!({ @@ -516,7 +523,9 @@ impl GeminiMessageConverter { Some(Value::String(value)) if value != "null" => (Some(value), false), Some(Value::String(_)) => (None, true), Some(Value::Array(values)) => { - let mut types = values.into_iter().filter_map(|value| value.as_str().map(str::to_string)); + let mut types = values + .into_iter() + .filter_map(|value| value.as_str().map(str::to_string)); let mut nullable = false; let mut selected = None; @@ -565,7 +574,11 @@ impl GeminiMessageConverter { nullable } - fn merge_schema_variants(target: &mut Map, variants: Value, preserve_required: bool) { + fn merge_schema_variants( + target: &mut Map, + variants: Value, + preserve_required: bool, + ) { if let Value::Array(variants) = variants { for variant in variants { if let Value::Object(map) = Self::strip_unsupported_schema_fields(variant) { diff --git a/src/crates/core/src/infrastructure/ai/providers/mod.rs b/src/crates/core/src/infrastructure/ai/providers/mod.rs index d0e806ae..452cfabc 100644 --- a/src/crates/core/src/infrastructure/ai/providers/mod.rs +++ b/src/crates/core/src/infrastructure/ai/providers/mod.rs @@ -2,9 +2,9 @@ //! //! Provides a unified interface for different AI providers -pub mod openai; pub mod anthropic; pub mod gemini; +pub mod openai; pub use anthropic::AnthropicMessageConverter; pub use gemini::GeminiMessageConverter; diff --git a/src/crates/core/src/infrastructure/ai/providers/openai/message_converter.rs b/src/crates/core/src/infrastructure/ai/providers/openai/message_converter.rs index 0eb1de14..b4b095d6 100644 --- a/src/crates/core/src/infrastructure/ai/providers/openai/message_converter.rs +++ b/src/crates/core/src/infrastructure/ai/providers/openai/message_converter.rs @@ -1,20 +1,23 @@ //! OpenAI message format converter -use log::{warn, error}; use crate::util::types::{Message, ToolDefinition}; +use log::{error, warn}; use serde_json::{json, Value}; pub struct OpenAIMessageConverter; impl OpenAIMessageConverter { - pub fn convert_messages_to_responses_input(messages: Vec) -> (Option, Vec) { + pub fn convert_messages_to_responses_input( + messages: Vec, + ) -> (Option, Vec) { let mut instructions = Vec::new(); let mut input = Vec::new(); for msg in messages { match msg.role.as_str() { "system" => { - if let Some(content) = msg.content.filter(|content| !content.trim().is_empty()) { + if let Some(content) = msg.content.filter(|content| !content.trim().is_empty()) + { instructions.push(content); } } @@ -24,7 +27,10 @@ impl OpenAIMessageConverter { } } "assistant" => { - if let Some(content_items) = Self::convert_message_content_to_responses_items(&msg.role, msg.content.as_deref()) { + if let Some(content_items) = Self::convert_message_content_to_responses_items( + &msg.role, + msg.content.as_deref(), + ) { input.push(json!({ "type": "message", "role": "assistant", @@ -45,7 +51,10 @@ impl OpenAIMessageConverter { } } role => { - if let Some(content_items) = Self::convert_message_content_to_responses_items(role, msg.content.as_deref()) { + if let Some(content_items) = Self::convert_message_content_to_responses_items( + role, + msg.content.as_deref(), + ) { input.push(json!({ "type": "message", "role": role, @@ -66,14 +75,17 @@ impl OpenAIMessageConverter { } pub fn convert_messages(messages: Vec) -> Vec { - messages.into_iter() + messages + .into_iter() .map(Self::convert_single_message) .collect() } fn convert_tool_message_to_responses_item(msg: Message) -> Option { let call_id = msg.tool_call_id?; - let output = msg.content.unwrap_or_else(|| "Tool execution completed".to_string()); + let output = msg + .content + .unwrap_or_else(|| "Tool execution completed".to_string()); Some(json!({ "type": "function_call_output", @@ -82,7 +94,10 @@ impl OpenAIMessageConverter { })) } - fn convert_message_content_to_responses_items(role: &str, content: Option<&str>) -> Option> { + fn convert_message_content_to_responses_items( + role: &str, + content: Option<&str>, + ) -> Option> { let content = content?; let text_item_type = Self::responses_text_item_type(role); @@ -118,14 +133,12 @@ impl OpenAIMessageConverter { } } Some("image_url") if role != "assistant" => { - let image_url = item - .get("image_url") - .and_then(|value| { - value - .get("url") - .and_then(Value::as_str) - .or_else(|| value.as_str()) - }); + let image_url = item.get("image_url").and_then(|value| { + value + .get("url") + .and_then(Value::as_str) + .or_else(|| value.as_str()) + }); if let Some(image_url) = image_url { content_items.push(json!({ @@ -172,15 +185,12 @@ impl OpenAIMessageConverter { } else if msg.role == "tool" { openai_msg["content"] = Value::String("Tool execution completed".to_string()); warn!( - "[OpenAI] Tool response content is empty: name={:?}", + "[OpenAI] Tool response content is empty: name={:?}", msg.name ); } else { openai_msg["content"] = Value::String(" ".to_string()); - warn!( - "[OpenAI] Message content is empty: role={}", - msg.role - ); + warn!("[OpenAI] Message content is empty: role={}", msg.role); } } else { if let Ok(parsed) = serde_json::from_str::(&content) { @@ -199,9 +209,9 @@ impl OpenAIMessageConverter { openai_msg["content"] = Value::String(" ".to_string()); } else if msg.role == "tool" { openai_msg["content"] = Value::String("Tool execution completed".to_string()); - + warn!( - "[OpenAI] Tool response message content is empty, set to default: name={:?}", + "[OpenAI] Tool response message content is empty, set to default: name={:?}", msg.name ); } else { @@ -210,7 +220,7 @@ impl OpenAIMessageConverter { msg.role, has_tool_calls ); - + openai_msg["content"] = Value::String(" ".to_string()); } } @@ -300,7 +310,8 @@ mod tests { }, ]; - let (instructions, input) = OpenAIMessageConverter::convert_messages_to_responses_input(messages); + let (instructions, input) = + OpenAIMessageConverter::convert_messages_to_responses_input(messages); assert_eq!(instructions.as_deref(), Some("You are helpful")); assert_eq!(input.len(), 3); @@ -313,18 +324,21 @@ mod tests { fn converts_openai_style_image_content_to_responses_input() { let messages = vec![Message { role: "user".to_string(), - content: Some(json!([ - { - "type": "image_url", - "image_url": { - "url": "data:image/png;base64,abc" + content: Some( + json!([ + { + "type": "image_url", + "image_url": { + "url": "data:image/png;base64,abc" + } + }, + { + "type": "text", + "text": "Describe this image" } - }, - { - "type": "text", - "text": "Describe this image" - } - ]).to_string()), + ]) + .to_string(), + ), reasoning_content: None, thinking_signature: None, tool_calls: None, diff --git a/src/crates/core/src/infrastructure/ai/providers/openai/mod.rs b/src/crates/core/src/infrastructure/ai/providers/openai/mod.rs index 3b1f965c..44ad1060 100644 --- a/src/crates/core/src/infrastructure/ai/providers/openai/mod.rs +++ b/src/crates/core/src/infrastructure/ai/providers/openai/mod.rs @@ -3,4 +3,3 @@ pub mod message_converter; pub use message_converter::OpenAIMessageConverter; - diff --git a/src/crates/core/src/infrastructure/debug_log/http_server.rs b/src/crates/core/src/infrastructure/debug_log/http_server.rs index 5c5b2f26..e894e408 100644 --- a/src/crates/core/src/infrastructure/debug_log/http_server.rs +++ b/src/crates/core/src/infrastructure/debug_log/http_server.rs @@ -3,22 +3,25 @@ //! HTTP server that receives debug logs from web applications. //! This is platform-agnostic and can be started by any application (desktop, CLI, etc.). -use log::{trace, debug, info, warn, error}; -use std::net::SocketAddr; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::OnceLock; use axum::{ extract::{Path, State}, http::StatusCode, routing::{get, post}, Json, Router, }; +use log::{debug, error, info, trace, warn}; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::OnceLock; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; use tower_http::cors::{Any, CorsLayer}; -use super::types::{IngestServerConfig, IngestServerState, IngestLogRequest, IngestResponse, handle_ingest, DEFAULT_INGEST_PORT}; +use super::types::{ + handle_ingest, IngestLogRequest, IngestResponse, IngestServerConfig, IngestServerState, + DEFAULT_INGEST_PORT, +}; static GLOBAL_INGEST_MANAGER: OnceLock> = OnceLock::new(); @@ -36,32 +39,35 @@ impl IngestServerManager { actual_port: Arc::new(RwLock::new(DEFAULT_INGEST_PORT)), } } - + pub fn global() -> &'static Arc { GLOBAL_INGEST_MANAGER.get_or_init(|| Arc::new(IngestServerManager::new())) } - + pub async fn start(&self, config: Option) -> anyhow::Result<()> { self.stop().await; - + let cfg = config.unwrap_or_default(); let base_port = cfg.port; - + let mut listener: Option = None; let mut actual_port = base_port; - + for offset in 0..10u16 { let port = base_port + offset; if let Some(l) = try_bind_port(port).await { listener = Some(l); actual_port = port; if offset > 0 { - info!("Default port {} is occupied, using port {} instead", base_port, port); + info!( + "Default port {} is occupied, using port {} instead", + base_port, port + ); } break; } } - + let listener = match listener { Some(l) => l, None => { @@ -70,38 +76,38 @@ impl IngestServerManager { return Ok(()); } }; - + let mut updated_cfg = cfg; updated_cfg.port = actual_port; - + let state = IngestServerState::new(updated_cfg); let cancel_token = CancellationToken::new(); - + *self.state.write().await = Some(state.clone()); *self.cancel_token.write().await = Some(cancel_token.clone()); *self.actual_port.write().await = actual_port; - + let cors = CorsLayer::new() .allow_origin(Any) .allow_methods(Any) .allow_headers(Any); - + let app = Router::new() .route("/health", get(health_handler)) .route("/ingest/:session_id", post(ingest_handler)) .layer(cors) .with_state(state.clone()); - + *state.is_running.write().await = true; - + let addr = listener.local_addr()?; info!("Debug Log Ingest Server started on http://{}", addr); info!("Debug logs will be written to: /.bitfun/debug.log"); - + let state_clone = state.clone(); tokio::spawn(async move { let server = axum::serve(listener, app); - + tokio::select! { result = server => { if let Err(e) = result { @@ -112,13 +118,13 @@ impl IngestServerManager { info!("Debug Log Ingest Server shutting down"); } } - + *state_clone.is_running.write().await = false; }); - + Ok(()) } - + pub async fn stop(&self) { if let Some(token) = self.cancel_token.write().await.take() { token.cancel(); @@ -127,20 +133,22 @@ impl IngestServerManager { } *self.state.write().await = None; } - + pub async fn restart(&self, config: IngestServerConfig) -> anyhow::Result<()> { - debug!("Restarting Debug Log Ingest Server with new config (port: {}, log_path: {:?})", - config.port, config.log_config.log_path); + debug!( + "Restarting Debug Log Ingest Server with new config (port: {}, log_path: {:?})", + config.port, config.log_config.log_path + ); self.stop().await; self.start(Some(config)).await } - + pub async fn update_log_path(&self, log_path: PathBuf) { if let Some(state) = self.state.read().await.as_ref() { state.update_log_path(log_path).await; } } - + pub async fn update_port(&self, new_port: u16, log_path: PathBuf) -> anyhow::Result<()> { let current_port = *self.actual_port.read().await; if current_port != new_port { @@ -151,11 +159,11 @@ impl IngestServerManager { Ok(()) } } - + pub async fn get_actual_port(&self) -> u16 { *self.actual_port.read().await } - + pub async fn is_running(&self) -> bool { if let Some(state) = self.state.read().await.as_ref() { *state.is_running.read().await @@ -186,30 +194,30 @@ async fn ingest_handler( if request.session_id.is_none() { request.session_id = Some(session_id); } - + let config = state.config.read().await; let log_config = config.log_config.clone(); drop(config); - - match handle_ingest(request.clone(), &log_config).await { - Ok(response) => { - trace!( - "Debug log received: [{}] {} | hypothesis: {:?}", - request.location, - request.message, - request.hypothesis_id - ); - Ok(Json(response)) - } - Err(e) => { - warn!("Failed to ingest log: {}", e); - Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(IngestResponse { - success: false, - error: Some(e.to_string()), - }), - )) - } + + match handle_ingest(request.clone(), &log_config).await { + Ok(response) => { + trace!( + "Debug log received: [{}] {} | hypothesis: {:?}", + request.location, + request.message, + request.hypothesis_id + ); + Ok(Json(response)) } + Err(e) => { + warn!("Failed to ingest log: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(IngestResponse { + success: false, + error: Some(e.to_string()), + }), + )) + } + } } diff --git a/src/crates/core/src/infrastructure/debug_log/mod.rs b/src/crates/core/src/infrastructure/debug_log/mod.rs index 744ca895..7da06c56 100644 --- a/src/crates/core/src/infrastructure/debug_log/mod.rs +++ b/src/crates/core/src/infrastructure/debug_log/mod.rs @@ -5,12 +5,12 @@ //! - `types` - Types and handlers for the HTTP ingest server (Config, State, Request, Response) //! - `http_server` - The actual HTTP server implementation (axum-based) -pub mod types; pub mod http_server; +pub mod types; pub use types::{ - IngestServerConfig, IngestServerState, IngestLogRequest, IngestResponse, - handle_ingest, DEFAULT_INGEST_PORT, + handle_ingest, IngestLogRequest, IngestResponse, IngestServerConfig, IngestServerState, + DEFAULT_INGEST_PORT, }; pub use http_server::IngestServerManager; @@ -39,9 +39,8 @@ static DEFAULT_LOG_PATH: LazyLock = LazyLock::new(|| { .join("debug.log") }); -static DEFAULT_INGEST_URL: LazyLock> = LazyLock::new(|| { - std::env::var("BITFUN_DEBUG_INGEST_URL").ok() -}); +static DEFAULT_INGEST_URL: LazyLock> = + LazyLock::new(|| std::env::var("BITFUN_DEBUG_INGEST_URL").ok()); #[derive(Debug, Clone)] pub struct DebugLogConfig { @@ -168,7 +167,11 @@ fn ensure_parent_exists(path: &PathBuf) -> Result<()> { Ok(()) } -pub async fn append_log_async(entry: DebugLogEntry, config: Option, send_http: bool) -> Result<()> { +pub async fn append_log_async( + entry: DebugLogEntry, + config: Option, + send_http: bool, +) -> Result<()> { let cfg = config.unwrap_or_default(); let log_line = build_log_line(entry, &cfg); let log_path = cfg.log_path.clone(); diff --git a/src/crates/core/src/infrastructure/debug_log/types.rs b/src/crates/core/src/infrastructure/debug_log/types.rs index 14199c46..22298118 100644 --- a/src/crates/core/src/infrastructure/debug_log/types.rs +++ b/src/crates/core/src/infrastructure/debug_log/types.rs @@ -108,16 +108,15 @@ pub async fn handle_ingest( request: IngestLogRequest, config: &DebugLogConfig, ) -> Result { - let log_config = - if let Some(workspace_path) = get_global_workspace_service() - .and_then(|service| service.try_get_current_workspace_path()) - { - let mut cfg = config.clone(); - cfg.log_path = workspace_path.join(".bitfun").join("debug.log"); - cfg - } else { - config.clone() - }; + let log_config = if let Some(workspace_path) = + get_global_workspace_service().and_then(|service| service.try_get_current_workspace_path()) + { + let mut cfg = config.clone(); + cfg.log_path = workspace_path.join(".bitfun").join("debug.log"); + cfg + } else { + config.clone() + }; let entry: DebugLogEntry = request.into(); diff --git a/src/crates/core/src/infrastructure/events/event_system.rs b/src/crates/core/src/infrastructure/events/event_system.rs index 4ba00840..9b22e8a5 100644 --- a/src/crates/core/src/infrastructure/events/event_system.rs +++ b/src/crates/core/src/infrastructure/events/event_system.rs @@ -1,12 +1,12 @@ //! Backend event system for tool execution and custom events -use log::{trace, warn, error}; -use crate::util::types::event::ToolExecutionProgressInfo; use crate::infrastructure::events::EventEmitter; +use crate::util::types::event::ToolExecutionProgressInfo; +use anyhow::Result; +use log::{error, trace, warn}; +use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::Mutex; -use serde::{Deserialize, Serialize}; -use anyhow::Result; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", content = "value")] @@ -17,9 +17,9 @@ pub enum BackendEvent { session_id: String, questions: serde_json::Value, }, - Custom { - event_name: String, - payload: serde_json::Value + Custom { + event_name: String, + payload: serde_json::Value, }, } @@ -46,10 +46,14 @@ impl BackendEventSystem { if let Some(ref emitter) = *emitter_guard { let event_name = match &event { BackendEvent::Custom { event_name, .. } => event_name.clone(), - BackendEvent::ToolExecutionProgress(_) => "backend-event-toolexecutionprogress".to_string(), - BackendEvent::ToolAwaitingUserInput { .. } => "backend-event-toolawaitinguserinput".to_string(), + BackendEvent::ToolExecutionProgress(_) => { + "backend-event-toolexecutionprogress".to_string() + } + BackendEvent::ToolAwaitingUserInput { .. } => { + "backend-event-toolawaitinguserinput".to_string() + } }; - + let event_data = match &event { BackendEvent::Custom { payload, .. } => payload.clone(), _ => match serde_json::to_value(&event) { @@ -60,7 +64,7 @@ impl BackendEventSystem { } }, }; - + if let Err(e) = emitter.emit(&event_name, event_data).await { warn!("Failed to emit to frontend: {}", e); } @@ -76,12 +80,13 @@ impl Default for BackendEventSystem { } } -static GLOBAL_EVENT_SYSTEM: std::sync::OnceLock> = std::sync::OnceLock::new(); +static GLOBAL_EVENT_SYSTEM: std::sync::OnceLock> = + std::sync::OnceLock::new(); pub fn get_global_event_system() -> Arc { - GLOBAL_EVENT_SYSTEM.get_or_init(|| { - Arc::new(BackendEventSystem::new()) - }).clone() + GLOBAL_EVENT_SYSTEM + .get_or_init(|| Arc::new(BackendEventSystem::new())) + .clone() } pub async fn emit_global_event(event: BackendEvent) -> Result<()> { diff --git a/src/crates/core/src/infrastructure/events/mod.rs b/src/crates/core/src/infrastructure/events/mod.rs index 384f3eaf..5f5d1715 100644 --- a/src/crates/core/src/infrastructure/events/mod.rs +++ b/src/crates/core/src/infrastructure/events/mod.rs @@ -1,9 +1,11 @@ //! Event system module -pub mod event_system; pub mod emitter; +pub mod event_system; -pub use event_system::BackendEventSystem as BackendEventManager; -pub use emitter::EventEmitter; pub use bitfun_transport::TransportEmitter; -pub use event_system::{BackendEvent, BackendEventSystem, get_global_event_system, emit_global_event}; +pub use emitter::EventEmitter; +pub use event_system::BackendEventSystem as BackendEventManager; +pub use event_system::{ + emit_global_event, get_global_event_system, BackendEvent, BackendEventSystem, +}; diff --git a/src/crates/core/src/infrastructure/filesystem/file_watcher.rs b/src/crates/core/src/infrastructure/filesystem/file_watcher.rs index 3b7a0023..2e174c62 100644 --- a/src/crates/core/src/infrastructure/filesystem/file_watcher.rs +++ b/src/crates/core/src/infrastructure/filesystem/file_watcher.rs @@ -180,8 +180,7 @@ impl FileWatcher { loop { match rx.recv_timeout(poll) { Ok(Ok(event)) => { - let ignore = - rt.block_on(Self::should_ignore_event(&event, &watched_paths)); + let ignore = rt.block_on(Self::should_ignore_event(&event, &watched_paths)); if !ignore { if let Some(file_event) = Self::convert_event(&event) { lock_event_buffer(&event_buffer).push(file_event); diff --git a/src/crates/core/src/infrastructure/filesystem/mod.rs b/src/crates/core/src/infrastructure/filesystem/mod.rs index 264da0a4..96b03549 100644 --- a/src/crates/core/src/infrastructure/filesystem/mod.rs +++ b/src/crates/core/src/infrastructure/filesystem/mod.rs @@ -2,33 +2,21 @@ //! //! File operations, file tree building, file watching, and path management. -pub mod file_tree; pub mod file_operations; +pub mod file_tree; pub mod file_watcher; pub mod path_manager; -pub use path_manager::{ - PathManager, - StorageLevel, - CacheType, - get_path_manager_arc, - try_get_path_manager_arc, +pub use file_operations::{ + FileInfo, FileOperationOptions, FileOperationService, FileReadResult, FileWriteResult, }; pub use file_tree::{ - FileTreeService, - FileTreeNode, - FileTreeOptions, - FileTreeStatistics, - FileSearchResult, + FileSearchResult, FileTreeNode, FileTreeOptions, FileTreeService, FileTreeStatistics, SearchMatchType, }; -pub use file_operations::{ - FileOperationService, - FileOperationOptions, - FileInfo, - FileReadResult, - FileWriteResult, -}; -#[cfg(feature = "tauri-support")] -pub use file_watcher::{start_file_watch, stop_file_watch, get_watched_paths}; pub use file_watcher::initialize_file_watcher; +#[cfg(feature = "tauri-support")] +pub use file_watcher::{get_watched_paths, start_file_watch, stop_file_watch}; +pub use path_manager::{ + get_path_manager_arc, try_get_path_manager_arc, CacheType, PathManager, StorageLevel, +}; diff --git a/src/crates/core/src/infrastructure/filesystem/path_manager.rs b/src/crates/core/src/infrastructure/filesystem/path_manager.rs index 526c3d6f..2c9f47db 100644 --- a/src/crates/core/src/infrastructure/filesystem/path_manager.rs +++ b/src/crates/core/src/infrastructure/filesystem/path_manager.rs @@ -75,22 +75,46 @@ impl PathManager { .join(".bitfun") } - /// Get assistant workspace base directory. + /// Get the legacy assistant workspace base directory: ~/.bitfun/ /// /// `override_root` is reserved for future user customization. - pub fn assistant_workspace_base_dir(&self, override_root: Option<&Path>) -> PathBuf { + pub fn legacy_assistant_workspace_base_dir(&self, override_root: Option<&Path>) -> PathBuf { override_root .map(Path::to_path_buf) .unwrap_or_else(|| self.bitfun_home_dir()) } - /// Get the default assistant workspace directory: ~/.bitfun/workspace + /// Get assistant workspace base directory: ~/.bitfun/personal_assistant/ + /// + /// `override_root` is reserved for future user customization. + pub fn assistant_workspace_base_dir(&self, override_root: Option<&Path>) -> PathBuf { + self.legacy_assistant_workspace_base_dir(override_root) + .join("personal_assistant") + } + + /// Get the legacy default assistant workspace directory: ~/.bitfun/workspace + pub fn legacy_default_assistant_workspace_dir(&self, override_root: Option<&Path>) -> PathBuf { + self.legacy_assistant_workspace_base_dir(override_root) + .join("workspace") + } + + /// Get the default assistant workspace directory: ~/.bitfun/personal_assistant/workspace pub fn default_assistant_workspace_dir(&self, override_root: Option<&Path>) -> PathBuf { self.assistant_workspace_base_dir(override_root) .join("workspace") } - /// Get a named assistant workspace directory: ~/.bitfun/workspace- + /// Get a legacy named assistant workspace directory: ~/.bitfun/workspace- + pub fn legacy_assistant_workspace_dir( + &self, + assistant_id: &str, + override_root: Option<&Path>, + ) -> PathBuf { + self.legacy_assistant_workspace_base_dir(override_root) + .join(format!("workspace-{}", assistant_id)) + } + + /// Get a named assistant workspace directory: ~/.bitfun/personal_assistant/workspace- pub fn assistant_workspace_dir( &self, assistant_id: &str, @@ -339,6 +363,7 @@ impl PathManager { pub async fn initialize_user_directories(&self) -> BitFunResult<()> { let dirs = vec![ self.bitfun_home_dir(), + self.assistant_workspace_base_dir(None), self.user_config_dir(), self.user_agents_dir(), self.agent_templates_dir(), @@ -490,10 +515,56 @@ pub fn try_get_path_manager_arc() -> BitFunResult> { let manager = init_global_path_manager()?; match GLOBAL_PATH_MANAGER.set(Arc::clone(&manager)) { Ok(()) => Ok(manager), - Err(_) => Ok(Arc::clone( - GLOBAL_PATH_MANAGER - .get() - .expect("GLOBAL_PATH_MANAGER should be initialized after set failure"), - )), + Err(_) => Ok(Arc::clone(GLOBAL_PATH_MANAGER.get().expect( + "GLOBAL_PATH_MANAGER should be initialized after set failure", + ))), + } +} + +#[cfg(test)] +mod tests { + use super::PathManager; + + #[test] + fn assistant_workspace_paths_use_personal_assistant_subdir() { + let path_manager = PathManager::default(); + let base_dir = path_manager.assistant_workspace_base_dir(None); + + assert_eq!( + base_dir, + path_manager.bitfun_home_dir().join("personal_assistant") + ); + assert_eq!( + path_manager.default_assistant_workspace_dir(None), + base_dir.join("workspace") + ); + assert_eq!( + path_manager.assistant_workspace_dir("demo", None), + base_dir.join("workspace-demo") + ); + assert_eq!( + path_manager.resolve_assistant_workspace_dir(None, None), + base_dir.join("workspace") + ); + assert_eq!( + path_manager.resolve_assistant_workspace_dir(Some("demo"), None), + base_dir.join("workspace-demo") + ); + } + + #[test] + fn legacy_assistant_workspace_paths_remain_at_bitfun_root() { + let path_manager = PathManager::default(); + let legacy_base_dir = path_manager.legacy_assistant_workspace_base_dir(None); + + assert_eq!(legacy_base_dir, path_manager.bitfun_home_dir()); + assert_eq!( + path_manager.legacy_default_assistant_workspace_dir(None), + legacy_base_dir.join("workspace") + ); + assert_eq!( + path_manager.legacy_assistant_workspace_dir("demo", None), + legacy_base_dir.join("workspace-demo") + ); } } diff --git a/src/crates/core/src/infrastructure/storage/cleanup.rs b/src/crates/core/src/infrastructure/storage/cleanup.rs index 0b3869d6..02607949 100644 --- a/src/crates/core/src/infrastructure/storage/cleanup.rs +++ b/src/crates/core/src/infrastructure/storage/cleanup.rs @@ -2,13 +2,13 @@ //! //! Provides storage cleanup policies and scheduling -use log::{debug, info, warn}; -use crate::util::errors::*; use crate::infrastructure::PathManager; +use crate::util::errors::*; +use log::{debug, info, warn}; +use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -use std::time::{SystemTime, Duration}; +use std::time::{Duration, SystemTime}; use tokio::fs; -use serde::{Serialize, Deserialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CleanupPolicy { @@ -60,73 +60,76 @@ impl CleanupService { policy, } } - + pub async fn cleanup_all(&self) -> BitFunResult { let mut result = CleanupResult::default(); - + if !self.policy.auto_cleanup_enabled { return Ok(result); } - + info!("Starting cleanup process"); - + if let Ok(temp_result) = self.cleanup_temp_files().await { result.merge(temp_result, "Temporary Files"); } - + if let Ok(log_result) = self.cleanup_old_logs().await { result.merge(log_result, "Old Logs"); } - + if let Ok(session_result) = self.cleanup_old_sessions().await { result.merge(session_result, "Expired Sessions"); } - + if let Ok(cache_result) = self.cleanup_oversized_cache().await { result.merge(cache_result, "Oversized Cache"); } - + info!( "Cleanup completed: {} files, {} dirs, {:.2} MB freed", result.files_deleted, result.directories_deleted, result.bytes_freed as f64 / 1_048_576.0 ); - + Ok(result) } - + async fn cleanup_temp_files(&self) -> BitFunResult { let temp_dir = self.path_manager.temp_dir(); let retention = Duration::from_secs(self.policy.temp_retention_days * 24 * 3600); - + self.cleanup_old_files(&temp_dir, retention).await } - + async fn cleanup_old_logs(&self) -> BitFunResult { let logs_dir = self.path_manager.logs_dir(); let retention = Duration::from_secs(self.policy.log_retention_days * 24 * 3600); - + self.cleanup_old_files(&logs_dir, retention).await } - + async fn cleanup_old_sessions(&self) -> BitFunResult { let mut result = CleanupResult::default(); - + let workspaces_dir = self.path_manager.workspaces_dir(); - + if !workspaces_dir.exists() { return Ok(result); } - + let retention = Duration::from_secs(self.policy.session_retention_days * 24 * 3600); - - let mut read_dir = fs::read_dir(&workspaces_dir).await + + let mut read_dir = fs::read_dir(&workspaces_dir) + .await .map_err(|e| BitFunError::service(format!("Failed to read workspaces: {}", e)))?; - - while let Some(entry) = read_dir.next_entry().await - .map_err(|e| BitFunError::service(format!("Failed to read workspace entry: {}", e)))? { - + + while let Some(entry) = read_dir + .next_entry() + .await + .map_err(|e| BitFunError::service(format!("Failed to read workspace entry: {}", e)))? + { if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) { let session_result = self.cleanup_old_files(&entry.path(), retention).await?; result.files_deleted += session_result.files_deleted; @@ -134,62 +137,72 @@ impl CleanupService { result.bytes_freed += session_result.bytes_freed; } } - + Ok(result) } - + async fn cleanup_oversized_cache(&self) -> BitFunResult { let cache_dir = self.path_manager.cache_root(); let max_size = self.policy.max_cache_size_mb * 1_048_576; - + let current_size = Self::calculate_dir_size(&cache_dir).await?; - + if current_size <= max_size { return Ok(CleanupResult::default()); } - + debug!( "Cache size {:.2} MB exceeds limit {:.2} MB, cleaning up", current_size as f64 / 1_048_576.0, max_size as f64 / 1_048_576.0 ); - + self.cleanup_by_size(&cache_dir, max_size).await } - - async fn cleanup_old_files(&self, dir: &Path, retention: Duration) -> BitFunResult { + + async fn cleanup_old_files( + &self, + dir: &Path, + retention: Duration, + ) -> BitFunResult { let mut result = CleanupResult::default(); - + if !dir.exists() { return Ok(result); } - + let cutoff_time = SystemTime::now() .checked_sub(retention) .unwrap_or(SystemTime::UNIX_EPOCH); - - self.cleanup_recursively(dir, |metadata| { - metadata.modified() - .map(|time| time < cutoff_time) - .unwrap_or(false) - }, &mut result).await?; - + + self.cleanup_recursively( + dir, + |metadata| { + metadata + .modified() + .map(|time| time < cutoff_time) + .unwrap_or(false) + }, + &mut result, + ) + .await?; + Ok(result) } - + async fn cleanup_by_size(&self, dir: &Path, max_size: u64) -> BitFunResult { let mut result = CleanupResult::default(); - + let mut files = Vec::new(); self.collect_files_with_time(dir, &mut files).await?; - + files.sort_by(|a, b| b.1.cmp(&a.1)); - + let mut current_size = 0u64; - + for (path, _, size) in files { current_size += size; - + if current_size > max_size { match fs::remove_file(&path).await { Ok(_) => { @@ -202,10 +215,10 @@ impl CleanupService { } } } - + Ok(result) } - + fn cleanup_recursively<'a, F>( &'a self, dir: &'a Path, @@ -220,19 +233,22 @@ impl CleanupService { Ok(d) => d, Err(_) => return Ok(()), }; - - while let Some(entry) = read_dir.next_entry().await - .map_err(|e| BitFunError::service(format!("Failed to read entry: {}", e)))? { - + + while let Some(entry) = read_dir + .next_entry() + .await + .map_err(|e| BitFunError::service(format!("Failed to read entry: {}", e)))? + { let path = entry.path(); let metadata = match entry.metadata().await { Ok(m) => m, Err(_) => continue, }; - + if metadata.is_dir() { - self.cleanup_recursively(&path, should_delete, result).await?; - + self.cleanup_recursively(&path, should_delete, result) + .await?; + if Self::is_empty_dir(&path).await { match fs::remove_dir(&path).await { Ok(_) => { @@ -256,11 +272,11 @@ impl CleanupService { } } } - + Ok(()) }) } - + fn collect_files_with_time<'a>( &'a self, dir: &'a Path, @@ -271,60 +287,64 @@ impl CleanupService { Ok(d) => d, Err(_) => return Ok(()), }; - - while let Some(entry) = read_dir.next_entry().await - .map_err(|e| BitFunError::service(format!("Failed to read entry: {}", e)))? { - + + while let Some(entry) = read_dir + .next_entry() + .await + .map_err(|e| BitFunError::service(format!("Failed to read entry: {}", e)))? + { let path = entry.path(); let metadata = match entry.metadata().await { Ok(m) => m, Err(_) => continue, }; - + if metadata.is_dir() { self.collect_files_with_time(&path, files).await?; } else if let Ok(modified) = metadata.modified() { files.push((path, modified, metadata.len())); } } - + Ok(()) }) } - - fn calculate_dir_size(dir: &Path) -> std::pin::Pin> + Send + '_>> { + + fn calculate_dir_size( + dir: &Path, + ) -> std::pin::Pin> + Send + '_>> { Box::pin(async move { let mut total = 0u64; - + let mut read_dir = match fs::read_dir(dir).await { Ok(d) => d, Err(_) => return Ok(0), }; - - while let Some(entry) = read_dir.next_entry().await - .map_err(|e| BitFunError::service(format!("Failed to read entry: {}", e)))? { - + + while let Some(entry) = read_dir + .next_entry() + .await + .map_err(|e| BitFunError::service(format!("Failed to read entry: {}", e)))? + { let metadata = match entry.metadata().await { Ok(m) => m, Err(_) => continue, }; - + if metadata.is_dir() { total += Self::calculate_dir_size(&entry.path()).await?; } else { total += metadata.len(); } } - + Ok(total) }) } - + async fn is_empty_dir(dir: &Path) -> bool { match fs::read_dir(dir).await { - Ok(mut read_dir) => { - read_dir.next_entry().await.ok().flatten().is_none() - } + Ok(mut read_dir) => read_dir.next_entry().await.ok().flatten().is_none(), Err(_) => false, } } @@ -335,7 +355,7 @@ impl CleanupResult { self.files_deleted += other.files_deleted; self.directories_deleted += other.directories_deleted; self.bytes_freed += other.bytes_freed; - + if other.files_deleted > 0 || other.bytes_freed > 0 { self.categories.push(CleanupCategory { name: category_name.to_string(), @@ -349,7 +369,7 @@ impl CleanupResult { #[cfg(test)] mod tests { use super::*; - + #[test] fn test_cleanup_policy_default() { let policy = CleanupPolicy::default(); @@ -358,4 +378,3 @@ mod tests { assert!(policy.auto_cleanup_enabled); } } - diff --git a/src/crates/core/src/infrastructure/storage/mod.rs b/src/crates/core/src/infrastructure/storage/mod.rs index 0c954cb6..85e4c3f2 100644 --- a/src/crates/core/src/infrastructure/storage/mod.rs +++ b/src/crates/core/src/infrastructure/storage/mod.rs @@ -1,9 +1,9 @@ //! Storage system -//! +//! //! Data persistence, cleanup, and storage policies. -pub mod persistence; pub mod cleanup; -pub use cleanup::{CleanupService, CleanupPolicy, CleanupResult}; +pub mod persistence; +pub use cleanup::{CleanupPolicy, CleanupResult, CleanupService}; pub use persistence::{PersistenceService, StorageOptions}; diff --git a/src/crates/core/src/infrastructure/storage/persistence.rs b/src/crates/core/src/infrastructure/storage/persistence.rs index b549d201..4ec5ba77 100644 --- a/src/crates/core/src/infrastructure/storage/persistence.rs +++ b/src/crates/core/src/infrastructure/storage/persistence.rs @@ -2,25 +2,25 @@ //! //! Provides data persistence with JSON support -use log::warn; +use crate::infrastructure::{try_get_path_manager_arc, PathManager}; use crate::util::errors::*; -use crate::infrastructure::{PathManager, try_get_path_manager_arc}; +use log::warn; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; -use serde::{Serialize, Deserialize}; -use tokio::fs; use std::sync::{Arc, LazyLock}; -use std::collections::HashMap; +use tokio::fs; use tokio::sync::Mutex; /// Global file lock map to prevent concurrent writes to the same file -static FILE_LOCKS: LazyLock>>>> = LazyLock::new(|| { - Mutex::new(HashMap::new()) -}); +static FILE_LOCKS: LazyLock>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); /// Get or create a lock for the specified file async fn get_file_lock(path: &Path) -> Arc> { let mut locks = FILE_LOCKS.lock().await; - locks.entry(path.to_path_buf()) + locks + .entry(path.to_path_buf()) .or_insert_with(|| Arc::new(Mutex::new(()))) .clone() } @@ -52,45 +52,46 @@ impl Default for StorageOptions { impl PersistenceService { pub async fn new(base_dir: PathBuf) -> BitFunResult { if !base_dir.exists() { - fs::create_dir_all(&base_dir).await - .map_err(|e| BitFunError::service(format!("Failed to create storage directory: {}", e)))?; + fs::create_dir_all(&base_dir).await.map_err(|e| { + BitFunError::service(format!("Failed to create storage directory: {}", e)) + })?; } let path_manager = try_get_path_manager_arc()?; - - Ok(Self { + + Ok(Self { base_dir, path_manager, }) } - + pub async fn new_user_level(path_manager: Arc) -> BitFunResult { let base_dir = path_manager.user_data_dir(); path_manager.ensure_dir(&base_dir).await?; - + Ok(Self { base_dir, path_manager, }) } - + pub async fn new_project_level( path_manager: Arc, workspace_path: PathBuf, ) -> BitFunResult { let base_dir = path_manager.project_root(&workspace_path); path_manager.ensure_dir(&base_dir).await?; - + Ok(Self { base_dir, path_manager, }) } - + pub fn base_dir(&self) -> &Path { &self.base_dir } - + pub fn path_manager(&self) -> &Arc { &self.path_manager } @@ -103,17 +104,18 @@ impl PersistenceService { options: StorageOptions, ) -> BitFunResult<()> { let file_path = self.base_dir.join(format!("{}.json", key)); - + let lock = get_file_lock(&file_path).await; let _guard = lock.lock().await; - + if let Some(parent) = file_path.parent() { if !parent.exists() { - fs::create_dir_all(parent).await - .map_err(|e| BitFunError::service(format!("Failed to create directory {:?}: {}", parent, e)))?; + fs::create_dir_all(parent).await.map_err(|e| { + BitFunError::service(format!("Failed to create directory {:?}: {}", parent, e)) + })?; } } - + if options.create_backup && file_path.exists() { self.create_backup(&file_path, options.backup_count).await?; } @@ -123,17 +125,15 @@ impl PersistenceService { // Use atomic writes: write to a temp file first, then rename to avoid corruption on interruption. let temp_path = file_path.with_extension("json.tmp"); - - fs::write(&temp_path, &json_data).await - .map_err(|e| { - BitFunError::service(format!("Failed to write temp file: {}", e)) - })?; - - fs::rename(&temp_path, &file_path).await - .map_err(|e| { - let _ = std::fs::remove_file(&temp_path); - BitFunError::service(format!("Failed to rename temp file: {}", e)) - })?; + + fs::write(&temp_path, &json_data) + .await + .map_err(|e| BitFunError::service(format!("Failed to write temp file: {}", e)))?; + + fs::rename(&temp_path, &file_path).await.map_err(|e| { + let _ = std::fs::remove_file(&temp_path); + BitFunError::service(format!("Failed to rename temp file: {}", e)) + })?; Ok(()) } @@ -143,12 +143,13 @@ impl PersistenceService { key: &str, ) -> BitFunResult> { let file_path = self.base_dir.join(format!("{}.json", key)); - + if !file_path.exists() { return Ok(None); } - let content = fs::read_to_string(&file_path).await + let content = fs::read_to_string(&file_path) + .await .map_err(|e| BitFunError::service(format!("Failed to read file: {}", e)))?; let data: T = serde_json::from_str(&content) @@ -159,9 +160,10 @@ impl PersistenceService { pub async fn delete(&self, key: &str) -> BitFunResult { let json_path = self.base_dir.join(format!("{}.json", key)); - + if json_path.exists() { - fs::remove_file(&json_path).await + fs::remove_file(&json_path) + .await .map_err(|e| BitFunError::service(format!("Failed to delete JSON file: {}", e)))?; return Ok(true); } @@ -172,11 +174,13 @@ impl PersistenceService { async fn create_backup(&self, file_path: &Path, max_backups: usize) -> BitFunResult<()> { let backup_dir = self.base_dir.join("backups"); if !backup_dir.exists() { - fs::create_dir_all(&backup_dir).await - .map_err(|e| BitFunError::service(format!("Failed to create backup directory: {}", e)))?; + fs::create_dir_all(&backup_dir).await.map_err(|e| { + BitFunError::service(format!("Failed to create backup directory: {}", e)) + })?; } - let file_name = file_path.file_name() + let file_name = file_path + .file_name() .and_then(|n| n.to_str()) .ok_or_else(|| BitFunError::service("Invalid file name".to_string()))?; @@ -184,10 +188,12 @@ impl PersistenceService { let backup_name = format!("{}_{}", timestamp, file_name); let backup_path = backup_dir.join(backup_name); - fs::copy(file_path, &backup_path).await + fs::copy(file_path, &backup_path) + .await .map_err(|e| BitFunError::service(format!("Failed to create backup: {}", e)))?; - self.cleanup_old_backups(&backup_dir, file_name, max_backups).await?; + self.cleanup_old_backups(&backup_dir, file_name, max_backups) + .await?; Ok(()) } @@ -199,12 +205,15 @@ impl PersistenceService { max_backups: usize, ) -> BitFunResult<()> { let mut backups = Vec::new(); - let mut read_dir = fs::read_dir(backup_dir).await + let mut read_dir = fs::read_dir(backup_dir) + .await .map_err(|e| BitFunError::service(format!("Failed to read backup directory: {}", e)))?; - while let Some(entry) = read_dir.next_entry().await - .map_err(|e| BitFunError::service(format!("Failed to read backup entry: {}", e)))? { - + while let Some(entry) = read_dir + .next_entry() + .await + .map_err(|e| BitFunError::service(format!("Failed to read backup entry: {}", e)))? + { if let Some(file_name) = entry.file_name().to_str() { if file_name.ends_with(file_pattern) { if let Ok(metadata) = entry.metadata().await { diff --git a/src/crates/core/src/lib.rs b/src/crates/core/src/lib.rs index bb420fac..62aa467e 100644 --- a/src/crates/core/src/lib.rs +++ b/src/crates/core/src/lib.rs @@ -2,37 +2,34 @@ // BitFun Core Library - Platform-agnostic business logic // Four-layer architecture: Util -> Infrastructure -> Service -> Agentic -pub mod util; // Utility layer - General types, errors, helper functions -pub mod infrastructure; // Infrastructure layer - AI clients, storage, logging, events -pub mod service; // Service layer - Workspace, Config, FileSystem, Terminal, Git -pub mod agentic; // Agentic service layer - Agent system, tool system +pub mod agentic; // Agentic service layer - Agent system, tool system pub mod function_agents; // Function Agents - Function-based agents -pub mod miniapp; // MiniApp - AI-generated instant apps (Zero-Dialect Runtime) -// Re-export debug_log from infrastructure for backward compatibility +pub mod infrastructure; // Infrastructure layer - AI clients, storage, logging, events +pub mod miniapp; +pub mod service; // Service layer - Workspace, Config, FileSystem, Terminal, Git +pub mod util; // Utility layer - General types, errors, helper functions // MiniApp - AI-generated instant apps (Zero-Dialect Runtime) + // Re-export debug_log from infrastructure for backward compatibility pub use infrastructure::debug_log as debug; // Export main types -pub use util::types::*; pub use util::errors::*; +pub use util::types::*; // Export service layer components pub use service::{ - workspace::{WorkspaceService, WorkspaceProvider, WorkspaceManager}, - config::{ConfigService, ConfigManager}, + config::{ConfigManager, ConfigService}, + workspace::{WorkspaceManager, WorkspaceProvider, WorkspaceService}, }; // Export infrastructure components -pub use infrastructure::{ - ai::AIClient, - events::BackendEventManager, -}; +pub use infrastructure::{ai::AIClient, events::BackendEventManager}; // Export Agentic service core types pub use agentic::{ - core::{Session, DialogTurn, ModelRound, Message}, - tools::{Tool, ToolPipeline}, - execution::{ExecutionEngine, StreamProcessor}, + core::{DialogTurn, Message, ModelRound, Session}, events::{AgenticEvent, EventQueue, EventRouter}, + execution::{ExecutionEngine, StreamProcessor}, + tools::{Tool, ToolPipeline}, }; // Export ToolRegistry separately @@ -41,4 +38,3 @@ pub use agentic::tools::registry::ToolRegistry; // Version information pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub const CORE_NAME: &str = "BitFun Core"; - diff --git a/src/crates/core/src/miniapp/bridge_builder.rs b/src/crates/core/src/miniapp/bridge_builder.rs index 21281ce3..00ecabd7 100644 --- a/src/crates/core/src/miniapp/bridge_builder.rs +++ b/src/crates/core/src/miniapp/bridge_builder.rs @@ -145,19 +145,14 @@ fn escape_js_str(s: &str) -> String { pub fn build_import_map(deps: &[EsmDep]) -> String { let mut imports = serde_json::Map::new(); for dep in deps { - let url = dep.url.clone().unwrap_or_else(|| { - match &dep.version { - Some(v) => format!("https://esm.sh/{}@{}", dep.name, v), - None => format!("https://esm.sh/{}", dep.name), - } + let url = dep.url.clone().unwrap_or_else(|| match &dep.version { + Some(v) => format!("https://esm.sh/{}@{}", dep.name, v), + None => format!("https://esm.sh/{}", dep.name), }); imports.insert(dep.name.clone(), serde_json::Value::String(url)); } let json = serde_json::json!({ "imports": imports }); - format!( - r#""#, - json.to_string() - ) + format!(r#""#, json.to_string()) } /// Build CSP meta content from permissions (net.allow → connect-src). diff --git a/src/crates/core/src/miniapp/compiler.rs b/src/crates/core/src/miniapp/compiler.rs index a2446d72..2272408b 100644 --- a/src/crates/core/src/miniapp/compiler.rs +++ b/src/crates/core/src/miniapp/compiler.rs @@ -47,12 +47,7 @@ pub fn compile( let head_content = format!( "\n{}\n{}\n{}\n{}\n{}\n{}\n", - theme_default_style, - csp_tag, - scroll, - import_map, - bridge_script_tag, - style_tag, + theme_default_style, csp_tag, scroll, import_map, bridge_script_tag, style_tag, ); let html = if source.html.trim().is_empty() { @@ -165,7 +160,8 @@ mod tests { #[test] fn test_inject_into_head() { - let html = r#"x"#; + let html = + r#"x"#; let content = ""; let out = inject_into_head(html, content).unwrap(); assert!(out.contains("")); diff --git a/src/crates/core/src/miniapp/exporter.rs b/src/crates/core/src/miniapp/exporter.rs index 21754e1a..c8cc9abb 100644 --- a/src/crates/core/src/miniapp/exporter.rs +++ b/src/crates/core/src/miniapp/exporter.rs @@ -80,7 +80,11 @@ impl MiniAppExporter { } /// Export the MiniApp to a standalone application. - pub async fn export(&self, _app_id: &str, _options: ExportOptions) -> BitFunResult { + pub async fn export( + &self, + _app_id: &str, + _options: ExportOptions, + ) -> BitFunResult { Err(BitFunError::validation( "Export not yet implemented (skeleton)".to_string(), )) diff --git a/src/crates/core/src/miniapp/js_worker.rs b/src/crates/core/src/miniapp/js_worker.rs index 397a736a..f7cf7673 100644 --- a/src/crates/core/src/miniapp/js_worker.rs +++ b/src/crates/core/src/miniapp/js_worker.rs @@ -44,7 +44,10 @@ impl JsWorker { let stderr = child.stderr.take().ok_or("No stderr")?; let _stdout = child.stdout.take(); - let pending = Arc::new(Mutex::new(HashMap::>>::new())); + let pending = Arc::new(Mutex::new(HashMap::< + String, + oneshot::Sender>, + >::new())); let last_activity = Arc::new(AtomicI64::new( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -61,14 +64,15 @@ impl JsWorker { if line.is_empty() { continue; } - let _ = last_activity_clone.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |_| { - Some( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64, - ) - }); + let _ = + last_activity_clone.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |_| { + Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64, + ) + }); let msg: Value = match serde_json::from_str(&line) { Ok(v) => v, Err(_) => continue, @@ -76,10 +80,15 @@ impl JsWorker { let id = msg.get("id").and_then(Value::as_str).map(String::from); if let Some(id) = id { let result = if let Some(err) = msg.get("error") { - let msg = err.get("message").and_then(Value::as_str).unwrap_or("RPC error"); + let msg = err + .get("message") + .and_then(Value::as_str) + .unwrap_or("RPC error"); Err(msg.to_string()) } else { - msg.get("result").cloned().ok_or_else(|| "Missing result".to_string()) + msg.get("result") + .cloned() + .ok_or_else(|| "Missing result".to_string()) }; let mut guard = pending_clone.lock().await; if let Some(tx) = guard.remove(&id) { @@ -98,7 +107,12 @@ impl JsWorker { } /// Send a JSON-RPC request and wait for the response (with timeout). - pub async fn call(&self, method: &str, params: Value, timeout_ms: u64) -> Result { + pub async fn call( + &self, + method: &str, + params: Value, + timeout_ms: u64, + ) -> Result { let id = format!("rpc-{}", uuid::Uuid::new_v4()); let request = serde_json::json!({ "jsonrpc": "2.0", @@ -124,7 +138,10 @@ impl JsWorker { let mut stdin_guard = self.stdin.lock().await; let stdin = stdin_guard.as_mut().ok_or("Worker stdin closed")?; use tokio::io::AsyncWriteExt; - stdin.write_all(line.as_bytes()).await.map_err(|e| e.to_string())?; + stdin + .write_all(line.as_bytes()) + .await + .map_err(|e| e.to_string())?; stdin.flush().await.map_err(|e| e.to_string())?; drop(stdin_guard); diff --git a/src/crates/core/src/miniapp/js_worker_pool.rs b/src/crates/core/src/miniapp/js_worker_pool.rs index 9fb7eaae..42d7fdc8 100644 --- a/src/crates/core/src/miniapp/js_worker_pool.rs +++ b/src/crates/core/src/miniapp/js_worker_pool.rs @@ -2,7 +2,7 @@ use crate::miniapp::js_worker::JsWorker; use crate::miniapp::runtime_detect::{detect_runtime, DetectedRuntime}; -use crate::miniapp::types::{NpmDep, NodePermissions}; +use crate::miniapp::types::{NodePermissions, NpmDep}; use crate::util::errors::{BitFunError, BitFunResult}; use serde_json::Value; use std::path::PathBuf; @@ -38,9 +38,12 @@ impl JsWorkerPool { path_manager: Arc, worker_host_path: PathBuf, ) -> BitFunResult { - let runtime = detect_runtime() - .ok_or_else(|| BitFunError::validation("No JS runtime found (install Bun or Node.js)".to_string()))?; - let workers = Arc::new(Mutex::new(std::collections::HashMap::::new())); + let runtime = detect_runtime().ok_or_else(|| { + BitFunError::validation("No JS runtime found (install Bun or Node.js)".to_string()) + })?; + let workers = Arc::new(Mutex::new( + std::collections::HashMap::::new(), + )); // Background task: evict idle workers every 60s without waiting for a new spawn. let workers_bg = Arc::clone(&workers); @@ -113,21 +116,17 @@ impl JsWorkerPool { let app_dir = self.path_manager.miniapp_dir(app_id); if !app_dir.exists() { - return Err(BitFunError::NotFound(format!("MiniApp dir not found: {}", app_id))); + return Err(BitFunError::NotFound(format!( + "MiniApp dir not found: {}", + app_id + ))); } - let worker = JsWorker::spawn( - &self.runtime, - &self.worker_host_path, - &app_dir, - policy_json, - ) - .await - .map_err(|e| BitFunError::validation(e))?; + let worker = JsWorker::spawn(&self.runtime, &self.worker_host_path, &app_dir, policy_json) + .await + .map_err(|e| BitFunError::validation(e))?; - let _timeout_ms = node_perms - .and_then(|n| n.timeout_ms) - .unwrap_or(30_000); + let _timeout_ms = node_perms.and_then(|n| n.timeout_ms).unwrap_or(30_000); let worker = Arc::new(Mutex::new(worker)); guard.insert( app_id.to_string(), @@ -139,10 +138,7 @@ impl JsWorkerPool { Ok(worker) } - async fn evict_idle( - &self, - guard: &mut std::collections::HashMap, - ) { + async fn evict_idle(&self, guard: &mut std::collections::HashMap) { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -167,10 +163,7 @@ impl JsWorkerPool { } } - async fn evict_lru( - &self, - guard: &mut std::collections::HashMap, - ) { + async fn evict_lru(&self, guard: &mut std::collections::HashMap) { let (oldest_id, _) = guard .iter() .map(|(id, entry)| { @@ -204,11 +197,12 @@ impl JsWorkerPool { let worker = self .get_or_spawn(app_id, worker_revision, policy_json, permissions) .await?; - let timeout_ms = permissions - .and_then(|n| n.timeout_ms) - .unwrap_or(30_000); + let timeout_ms = permissions.and_then(|n| n.timeout_ms).unwrap_or(30_000); let guard = worker.lock().await; - guard.call(method, params, timeout_ms).await.map_err(BitFunError::validation) + guard + .call(method, params, timeout_ms) + .await + .map_err(BitFunError::validation) } /// Stop and remove the Worker for the app. @@ -241,11 +235,18 @@ impl JsWorkerPool { } pub fn has_installed_deps(&self, app_id: &str) -> bool { - self.path_manager.miniapp_dir(app_id).join("node_modules").exists() + self.path_manager + .miniapp_dir(app_id) + .join("node_modules") + .exists() } /// Install npm dependencies for the app (bun install or npm/pnpm install). - pub async fn install_deps(&self, app_id: &str, _deps: &[NpmDep]) -> BitFunResult { + pub async fn install_deps( + &self, + app_id: &str, + _deps: &[NpmDep], + ) -> BitFunResult { let app_dir = self.path_manager.miniapp_dir(app_id); let package_json = app_dir.join("package.json"); if !package_json.exists() { diff --git a/src/crates/core/src/miniapp/manager.rs b/src/crates/core/src/miniapp/manager.rs index 4755767d..442665f7 100644 --- a/src/crates/core/src/miniapp/manager.rs +++ b/src/crates/core/src/miniapp/manager.rs @@ -161,20 +161,10 @@ impl MiniAppManager { let id = Uuid::new_v4().to_string(); let now = Utc::now().timestamp_millis(); - let compiled_html = self.compile_source( - &id, - &source, - &permissions, - "dark", - workspace_root, - )?; - let runtime = Self::build_runtime_state( - 1, - now, - &source, - !source.npm_dependencies.is_empty(), - true, - ); + let compiled_html = + self.compile_source(&id, &source, &permissions, "dark", workspace_root)?; + let runtime = + Self::build_runtime_state(1, now, &source, !source.npm_dependencies.is_empty(), true); let app = MiniApp { id: id.clone(), @@ -310,10 +300,7 @@ impl MiniAppManager { /// Get app storage (KV) value. pub async fn get_storage(&self, app_id: &str, key: &str) -> BitFunResult { let storage = self.storage.load_app_storage(app_id).await?; - Ok(storage - .get(key) - .cloned() - .unwrap_or(serde_json::Value::Null)) + Ok(storage.get(key).cloned().unwrap_or(serde_json::Value::Null)) } /// Set app storage (KV) value. @@ -379,13 +366,8 @@ impl MiniAppManager { workspace_root: Option<&Path>, ) -> BitFunResult { let mut app = self.storage.load(app_id).await?; - app.compiled_html = self.compile_source( - app_id, - &app.source, - &app.permissions, - theme, - workspace_root, - )?; + app.compiled_html = + self.compile_source(app_id, &app.source, &app.permissions, theme, workspace_root)?; app.updated_at = Utc::now().timestamp_millis(); Self::ensure_runtime_state(&mut app); app.runtime.ui_recompile_required = false; @@ -405,13 +387,8 @@ impl MiniAppManager { app.version += 1; app.updated_at = Utc::now().timestamp_millis(); - app.compiled_html = self.compile_source( - app_id, - &app.source, - &app.permissions, - theme, - workspace_root, - )?; + app.compiled_html = + self.compile_source(app_id, &app.source, &app.permissions, theme, workspace_root)?; app.runtime = Self::build_runtime_state( app.version, app.updated_at, @@ -502,14 +479,13 @@ impl MiniAppManager { if esm_path.exists() { tokio::fs::copy(&esm_path, dest_source.join("esm_dependencies.json")) .await - .map_err(|e| BitFunError::io(format!("Failed to copy esm_dependencies.json: {}", e)))?; + .map_err(|e| { + BitFunError::io(format!("Failed to copy esm_dependencies.json: {}", e)) + })?; } else { - tokio::fs::write( - dest_source.join("esm_dependencies.json"), - "[]", - ) - .await - .map_err(|_e| BitFunError::io("Failed to write esm_dependencies.json"))?; + tokio::fs::write(dest_source.join("esm_dependencies.json"), "[]") + .await + .map_err(|_e| BitFunError::io("Failed to write esm_dependencies.json"))?; } let pkg_src = src.join("package.json"); diff --git a/src/crates/core/src/miniapp/mod.rs b/src/crates/core/src/miniapp/mod.rs index 74a1d1f5..1922f0af 100644 --- a/src/crates/core/src/miniapp/mod.rs +++ b/src/crates/core/src/miniapp/mod.rs @@ -13,11 +13,13 @@ pub mod types; pub use exporter::{ExportCheckResult, ExportOptions, ExportResult, ExportTarget, MiniAppExporter}; pub use js_worker_pool::{InstallResult, JsWorkerPool}; -pub use manager::{MiniAppManager, initialize_global_miniapp_manager, try_get_global_miniapp_manager}; +pub use manager::{ + initialize_global_miniapp_manager, try_get_global_miniapp_manager, MiniAppManager, +}; pub use permission_policy::resolve_policy; pub use runtime_detect::{DetectedRuntime, RuntimeKind}; pub use storage::MiniAppStorage; pub use types::{ - EsmDep, FsPermissions, MiniApp, MiniAppAiContext, MiniAppMeta, MiniAppPermissions, MiniAppSource, - NpmDep, NodePermissions, NetPermissions, PathScope, ShellPermissions, + EsmDep, FsPermissions, MiniApp, MiniAppAiContext, MiniAppMeta, MiniAppPermissions, + MiniAppSource, NetPermissions, NodePermissions, NpmDep, PathScope, ShellPermissions, }; diff --git a/src/crates/core/src/miniapp/permission_policy.rs b/src/crates/core/src/miniapp/permission_policy.rs index 2487e60e..a65edd4d 100644 --- a/src/crates/core/src/miniapp/permission_policy.rs +++ b/src/crates/core/src/miniapp/permission_policy.rs @@ -55,9 +55,7 @@ pub fn resolve_policy( let allow = shell .allow .as_ref() - .map(|v| { - Value::Array(v.iter().map(|s| Value::String(s.clone())).collect()) - }) + .map(|v| Value::Array(v.iter().map(|s| Value::String(s.clone())).collect())) .unwrap_or_else(|| Value::Array(Vec::new())); policy.insert("shell".to_string(), serde_json::json!({ "allow": allow })); } @@ -66,9 +64,7 @@ pub fn resolve_policy( let allow = net .allow .as_ref() - .map(|v| { - Value::Array(v.iter().map(|s| Value::String(s.clone())).collect()) - }) + .map(|v| Value::Array(v.iter().map(|s| Value::String(s.clone())).collect())) .unwrap_or_else(|| Value::Array(Vec::new())); policy.insert("net".to_string(), serde_json::json!({ "allow": allow })); } diff --git a/src/crates/core/src/miniapp/runtime_detect.rs b/src/crates/core/src/miniapp/runtime_detect.rs index 4595294f..39fce75c 100644 --- a/src/crates/core/src/miniapp/runtime_detect.rs +++ b/src/crates/core/src/miniapp/runtime_detect.rs @@ -40,9 +40,7 @@ pub fn detect_runtime() -> Option { } fn get_version(executable: &std::path::Path) -> Result { - let out = Command::new(executable) - .arg("--version") - .output()?; + let out = Command::new(executable).arg("--version").output()?; if out.status.success() { let v = String::from_utf8_lossy(&out.stdout); Ok(v.trim().to_string()) diff --git a/src/crates/core/src/miniapp/storage.rs b/src/crates/core/src/miniapp/storage.rs index 9bdf1857..945119f8 100644 --- a/src/crates/core/src/miniapp/storage.rs +++ b/src/crates/core/src/miniapp/storage.rs @@ -59,10 +59,18 @@ impl MiniAppStorage { let dir = self.app_dir(app_id); let source = self.source_dir(app_id); tokio::fs::create_dir_all(&dir).await.map_err(|e| { - BitFunError::io(format!("Failed to create miniapp dir {}: {}", dir.display(), e)) + BitFunError::io(format!( + "Failed to create miniapp dir {}: {}", + dir.display(), + e + )) })?; tokio::fs::create_dir_all(&source).await.map_err(|e| { - BitFunError::io(format!("Failed to create source dir {}: {}", source.display(), e)) + BitFunError::io(format!( + "Failed to create source dir {}: {}", + source.display(), + e + )) })?; Ok(()) } @@ -74,12 +82,14 @@ impl MiniAppStorage { return Ok(Vec::new()); } let mut ids = Vec::new(); - let mut read_dir = tokio::fs::read_dir(&root).await.map_err(|e| { - BitFunError::io(format!("Failed to read miniapps dir: {}", e)) - })?; - while let Some(entry) = read_dir.next_entry().await.map_err(|e| { - BitFunError::io(format!("Failed to read miniapps entry: {}", e)) - })? { + let mut read_dir = tokio::fs::read_dir(&root) + .await + .map_err(|e| BitFunError::io(format!("Failed to read miniapps dir: {}", e)))?; + while let Some(entry) = read_dir + .next_entry() + .await + .map_err(|e| BitFunError::io(format!("Failed to read miniapps entry: {}", e)))? + { let path = entry.path(); if path.is_dir() { if let Some(name) = path.file_name().and_then(|n| n.to_str()) { @@ -136,9 +146,8 @@ impl MiniAppStorage { BitFunError::io(format!("Failed to read meta: {}", e)) } })?; - serde_json::from_str(&content).map_err(|e| { - BitFunError::parse(format!("Invalid meta.json: {}", e)) - }) + serde_json::from_str(&content) + .map_err(|e| BitFunError::parse(format!("Invalid meta.json: {}", e))) } async fn load_source(&self, app_id: &str) -> BitFunResult { @@ -187,9 +196,9 @@ impl MiniAppStorage { if !p.exists() { return Ok(Vec::new()); } - let c = tokio::fs::read_to_string(&p).await.map_err(|e| { - BitFunError::io(format!("Failed to read package.json: {}", e)) - })?; + let c = tokio::fs::read_to_string(&p) + .await + .map_err(|e| BitFunError::io(format!("Failed to read package.json: {}", e)))?; let pkg: serde_json::Value = serde_json::from_str(&c) .map_err(|e| BitFunError::parse(format!("Invalid package.json: {}", e)))?; let empty = serde_json::Map::new(); @@ -225,29 +234,31 @@ impl MiniAppStorage { let meta = MiniAppMeta::from(app); let meta_path = self.meta_path(&app.id); let meta_json = serde_json::to_string_pretty(&meta).map_err(BitFunError::from)?; - tokio::fs::write(&meta_path, meta_json).await.map_err(|e| { - BitFunError::io(format!("Failed to write meta: {}", e)) - })?; + tokio::fs::write(&meta_path, meta_json) + .await + .map_err(|e| BitFunError::io(format!("Failed to write meta: {}", e)))?; let sd = self.source_dir(&app.id); - tokio::fs::write(sd.join(INDEX_HTML), &app.source.html).await.map_err(|e| { - BitFunError::io(format!("Failed to write index.html: {}", e)) - })?; - tokio::fs::write(sd.join(STYLE_CSS), &app.source.css).await.map_err(|e| { - BitFunError::io(format!("Failed to write style.css: {}", e)) - })?; - tokio::fs::write(sd.join(UI_JS), &app.source.ui_js).await.map_err(|e| { - BitFunError::io(format!("Failed to write ui.js: {}", e)) - })?; - tokio::fs::write(sd.join(WORKER_JS), &app.source.worker_js).await.map_err(|e| { - BitFunError::io(format!("Failed to write worker.js: {}", e)) - })?; + tokio::fs::write(sd.join(INDEX_HTML), &app.source.html) + .await + .map_err(|e| BitFunError::io(format!("Failed to write index.html: {}", e)))?; + tokio::fs::write(sd.join(STYLE_CSS), &app.source.css) + .await + .map_err(|e| BitFunError::io(format!("Failed to write style.css: {}", e)))?; + tokio::fs::write(sd.join(UI_JS), &app.source.ui_js) + .await + .map_err(|e| BitFunError::io(format!("Failed to write ui.js: {}", e)))?; + tokio::fs::write(sd.join(WORKER_JS), &app.source.worker_js) + .await + .map_err(|e| BitFunError::io(format!("Failed to write worker.js: {}", e)))?; - let esm_json = - serde_json::to_string_pretty(&app.source.esm_dependencies).map_err(BitFunError::from)?; - tokio::fs::write(sd.join(ESM_DEPS_JSON), esm_json).await.map_err(|e| { - BitFunError::io(format!("Failed to write esm_dependencies.json: {}", e)) - })?; + let esm_json = serde_json::to_string_pretty(&app.source.esm_dependencies) + .map_err(BitFunError::from)?; + tokio::fs::write(sd.join(ESM_DEPS_JSON), esm_json) + .await + .map_err(|e| { + BitFunError::io(format!("Failed to write esm_dependencies.json: {}", e)) + })?; self.write_package_json(&app.id, &app.source.npm_dependencies) .await?; @@ -271,23 +282,28 @@ impl MiniAppStorage { }); let p = self.app_dir(app_id).join(PACKAGE_JSON); let json = serde_json::to_string_pretty(&pkg).map_err(BitFunError::from)?; - tokio::fs::write(&p, json).await.map_err(|e| { - BitFunError::io(format!("Failed to write package.json: {}", e)) - })?; + tokio::fs::write(&p, json) + .await + .map_err(|e| BitFunError::io(format!("Failed to write package.json: {}", e)))?; Ok(()) } /// Save a version snapshot (for rollback). - pub async fn save_version(&self, app_id: &str, version: u32, app: &MiniApp) -> BitFunResult<()> { + pub async fn save_version( + &self, + app_id: &str, + version: u32, + app: &MiniApp, + ) -> BitFunResult<()> { let versions_dir = self.app_dir(app_id).join(VERSIONS_DIR); - tokio::fs::create_dir_all(&versions_dir).await.map_err(|e| { - BitFunError::io(format!("Failed to create versions dir: {}", e)) - })?; + tokio::fs::create_dir_all(&versions_dir) + .await + .map_err(|e| BitFunError::io(format!("Failed to create versions dir: {}", e)))?; let path = self.version_path(app_id, version); let json = serde_json::to_string_pretty(app).map_err(BitFunError::from)?; - tokio::fs::write(&path, json).await.map_err(|e| { - BitFunError::io(format!("Failed to write version file: {}", e)) - })?; + tokio::fs::write(&path, json) + .await + .map_err(|e| BitFunError::io(format!("Failed to write version file: {}", e)))?; Ok(()) } @@ -297,9 +313,9 @@ impl MiniAppStorage { if !p.exists() { return Ok(serde_json::json!({})); } - let c = tokio::fs::read_to_string(&p).await.map_err(|e| { - BitFunError::io(format!("Failed to read storage: {}", e)) - })?; + let c = tokio::fs::read_to_string(&p) + .await + .map_err(|e| BitFunError::io(format!("Failed to read storage: {}", e)))?; Ok(serde_json::from_str(&c).unwrap_or_else(|_| serde_json::json!({}))) } @@ -312,15 +328,15 @@ impl MiniAppStorage { ) -> BitFunResult<()> { self.ensure_app_dir(app_id).await?; let mut current = self.load_app_storage(app_id).await?; - let obj = current.as_object_mut().ok_or_else(|| { - BitFunError::validation("App storage is not an object".to_string()) - })?; + let obj = current + .as_object_mut() + .ok_or_else(|| BitFunError::validation("App storage is not an object".to_string()))?; obj.insert(key.to_string(), value); let p = self.storage_path(app_id); let json = serde_json::to_string_pretty(¤t).map_err(BitFunError::from)?; - tokio::fs::write(&p, json).await.map_err(|e| { - BitFunError::io(format!("Failed to write storage: {}", e)) - })?; + tokio::fs::write(&p, json) + .await + .map_err(|e| BitFunError::io(format!("Failed to write storage: {}", e)))?; Ok(()) } @@ -328,9 +344,9 @@ impl MiniAppStorage { pub async fn delete(&self, app_id: &str) -> BitFunResult<()> { let dir = self.app_dir(app_id); if dir.exists() { - tokio::fs::remove_dir_all(&dir).await.map_err(|e| { - BitFunError::io(format!("Failed to delete miniapp dir: {}", e)) - })?; + tokio::fs::remove_dir_all(&dir) + .await + .map_err(|e| BitFunError::io(format!("Failed to delete miniapp dir: {}", e)))?; } Ok(()) } @@ -342,12 +358,14 @@ impl MiniAppStorage { return Ok(Vec::new()); } let mut versions = Vec::new(); - let mut read_dir = tokio::fs::read_dir(&vdir).await.map_err(|e| { - BitFunError::io(format!("Failed to read versions dir: {}", e)) - })?; - while let Some(entry) = read_dir.next_entry().await.map_err(|e| { - BitFunError::io(format!("Failed to read versions entry: {}", e)) - })? { + let mut read_dir = tokio::fs::read_dir(&vdir) + .await + .map_err(|e| BitFunError::io(format!("Failed to read versions dir: {}", e)))?; + while let Some(entry) = read_dir + .next_entry() + .await + .map_err(|e| BitFunError::io(format!("Failed to read versions entry: {}", e)))? + { let name = entry.file_name(); let name = name.to_string_lossy(); if name.starts_with('v') && name.ends_with(".json") { @@ -370,8 +388,7 @@ impl MiniAppStorage { BitFunError::io(format!("Failed to read version: {}", e)) } })?; - serde_json::from_str(&c).map_err(|e| { - BitFunError::parse(format!("Invalid version file: {}", e)) - }) + serde_json::from_str(&c) + .map_err(|e| BitFunError::parse(format!("Invalid version file: {}", e))) } } diff --git a/src/crates/core/src/service/ai_memory/manager.rs b/src/crates/core/src/service/ai_memory/manager.rs index 9a786225..fee92d23 100644 --- a/src/crates/core/src/service/ai_memory/manager.rs +++ b/src/crates/core/src/service/ai_memory/manager.rs @@ -26,9 +26,9 @@ impl AIMemoryManager { let storage_path = path_manager.user_data_dir().join("ai_memories.json"); if let Some(parent) = storage_path.parent() { - fs::create_dir_all(parent) - .await - .map_err(|e| BitFunError::io(format!("Failed to create memory storage directory: {}", e)))?; + fs::create_dir_all(parent).await.map_err(|e| { + BitFunError::io(format!("Failed to create memory storage directory: {}", e)) + })?; } let storage = if storage_path.exists() { @@ -53,9 +53,9 @@ impl AIMemoryManager { let storage_path = workspace_path.join(".bitfun").join("ai_memories.json"); if let Some(parent) = storage_path.parent() { - fs::create_dir_all(parent) - .await - .map_err(|e| BitFunError::io(format!("Failed to create memory storage directory: {}", e)))?; + fs::create_dir_all(parent).await.map_err(|e| { + BitFunError::io(format!("Failed to create memory storage directory: {}", e)) + })?; } let storage = if storage_path.exists() { @@ -77,8 +77,9 @@ impl AIMemoryManager { .await .map_err(|e| BitFunError::io(format!("Failed to read memory storage file: {}", e)))?; - let storage: MemoryStorage = serde_json::from_str(&content) - .map_err(|e| BitFunError::Deserialization(format!("Failed to deserialize memory storage: {}", e)))?; + let storage: MemoryStorage = serde_json::from_str(&content).map_err(|e| { + BitFunError::Deserialization(format!("Failed to deserialize memory storage: {}", e)) + })?; debug!("Loaded {} memory points from disk", storage.memories.len()); Ok(storage) @@ -87,8 +88,9 @@ impl AIMemoryManager { /// Saves storage to disk. async fn save_storage(&self) -> BitFunResult<()> { let storage = self.storage.read().await; - let content = serde_json::to_string_pretty(&*storage) - .map_err(|e| BitFunError::serialization(format!("Failed to serialize memory storage: {}", e)))?; + let content = serde_json::to_string_pretty(&*storage).map_err(|e| { + BitFunError::serialization(format!("Failed to serialize memory storage: {}", e)) + })?; fs::write(&self.storage_path, content) .await diff --git a/src/crates/core/src/service/ai_rules/service.rs b/src/crates/core/src/service/ai_rules/service.rs index 131fe6c2..19dcded7 100644 --- a/src/crates/core/src/service/ai_rules/service.rs +++ b/src/crates/core/src/service/ai_rules/service.rs @@ -353,7 +353,10 @@ impl AIRulesService { Ok(Self::calculate_stats(&rules)) } - async fn load_project_rules_for_workspace(&self, workspace: &Path) -> BitFunResult> { + async fn load_project_rules_for_workspace( + &self, + workspace: &Path, + ) -> BitFunResult> { let mut all_rules = Vec::new(); let mut loaded_names = std::collections::HashSet::new(); @@ -456,10 +459,16 @@ The rules section has a number of possible rules/memories/context that you shoul prompt } - pub async fn build_system_prompt_for(&self, workspace_root: Option<&Path>) -> BitFunResult { + pub async fn build_system_prompt_for( + &self, + workspace_root: Option<&Path>, + ) -> BitFunResult { let user_rules = self.user_rules.read().await.clone(); let project_rules = match workspace_root { - Some(workspace_root) => self.load_project_rules_for_workspace(workspace_root).await?, + Some(workspace_root) => { + self.load_project_rules_for_workspace(workspace_root) + .await? + } None => Vec::new(), }; @@ -485,7 +494,10 @@ The rules section has a number of possible rules/memories/context that you shoul let project_rules = match self.load_project_rules_for_workspace(workspace_path).await { Ok(rules) => rules, Err(e) => { - warn!("Failed to load project rules for file '{}': {}", file_path, e); + warn!( + "Failed to load project rules for file '{}': {}", + file_path, e + ); return FileRulesResult { matched_count: 0, formatted_content: None, diff --git a/src/crates/core/src/service/bootstrap/bootstrap.rs b/src/crates/core/src/service/bootstrap/bootstrap.rs index 73747dc2..dd2c058d 100644 --- a/src/crates/core/src/service/bootstrap/bootstrap.rs +++ b/src/crates/core/src/service/bootstrap/bootstrap.rs @@ -35,15 +35,14 @@ async fn ensure_markdown_placeholder(path: &Path, content: &str) -> BitFunResult Ok(true) } -pub(crate) async fn initialize_workspace_persona_files( - workspace_root: &Path, -) -> BitFunResult<()> { +pub(crate) async fn initialize_workspace_persona_files(workspace_root: &Path) -> BitFunResult<()> { let bootstrap_path = workspace_root.join(BOOTSTRAP_FILE_NAME); let soul_path = workspace_root.join(SOUL_FILE_NAME); let user_path = workspace_root.join(USER_FILE_NAME); let identity_path = workspace_root.join(IDENTITY_FILE_NAME); - let created_bootstrap = ensure_markdown_placeholder(&bootstrap_path, BOOTSTRAP_TEMPLATE).await?; + let created_bootstrap = + ensure_markdown_placeholder(&bootstrap_path, BOOTSTRAP_TEMPLATE).await?; let created_soul = ensure_markdown_placeholder(&soul_path, SOUL_TEMPLATE).await?; let created_user = ensure_markdown_placeholder(&user_path, USER_TEMPLATE).await?; let created_identity = ensure_markdown_placeholder(&identity_path, IDENTITY_TEMPLATE).await?; @@ -116,9 +115,7 @@ pub(crate) async fn ensure_workspace_persona_files_for_prompt( Ok(()) } -pub async fn reset_workspace_persona_files_to_default( - workspace_root: &Path, -) -> BitFunResult<()> { +pub async fn reset_workspace_persona_files_to_default(workspace_root: &Path) -> BitFunResult<()> { let persona_templates = [ (BOOTSTRAP_FILE_NAME, BOOTSTRAP_TEMPLATE), (SOUL_FILE_NAME, SOUL_TEMPLATE), @@ -129,13 +126,15 @@ pub async fn reset_workspace_persona_files_to_default( for (file_name, template) in persona_templates { let file_path = workspace_root.join(file_name); let normalized_content = normalize_line_endings(template); - fs::write(&file_path, normalized_content).await.map_err(|e| { - BitFunError::service(format!( - "Failed to reset persona file '{}': {}", - file_path.display(), - e - )) - })?; + fs::write(&file_path, normalized_content) + .await + .map_err(|e| { + BitFunError::service(format!( + "Failed to reset persona file '{}': {}", + file_path.display(), + e + )) + })?; } debug!( diff --git a/src/crates/core/src/service/bootstrap/mod.rs b/src/crates/core/src/service/bootstrap/mod.rs index 039e9fc5..e29cced9 100644 --- a/src/crates/core/src/service/bootstrap/mod.rs +++ b/src/crates/core/src/service/bootstrap/mod.rs @@ -1,7 +1,4 @@ mod bootstrap; -pub(crate) use bootstrap::{ - build_workspace_persona_prompt, - initialize_workspace_persona_files, -}; -pub use bootstrap::reset_workspace_persona_files_to_default; \ No newline at end of file +pub use bootstrap::reset_workspace_persona_files_to_default; +pub(crate) use bootstrap::{build_workspace_persona_prompt, initialize_workspace_persona_files}; diff --git a/src/crates/core/src/service/config/mod.rs b/src/crates/core/src/service/config/mod.rs index 77494b89..032e6540 100644 --- a/src/crates/core/src/service/config/mod.rs +++ b/src/crates/core/src/service/config/mod.rs @@ -10,7 +10,6 @@ pub mod service; pub mod tool_config_sync; pub mod types; - pub use factory::ConfigFactory; pub use global::{ get_global_config_service, initialize_global_config, reload_global_config, diff --git a/src/crates/core/src/service/config/providers.rs b/src/crates/core/src/service/config/providers.rs index 0bff49c0..4a68d2ca 100644 --- a/src/crates/core/src/service/config/providers.rs +++ b/src/crates/core/src/service/config/providers.rs @@ -83,6 +83,7 @@ impl ConfigProvider for AIConfigProvider { for (agent_name, model_id) in &ai_config.agent_models { if !ai_config.models.iter().any(|m| m.id == *model_id) + && model_id != "auto" && model_id != "primary" && model_id != "fast" { diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index e69155a1..4aa0fae0 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -417,6 +417,45 @@ pub struct AIConfig { pub known_tools: Vec, } +impl AIConfig { + /// Resolves a configured model reference by `id`, `name`, or `model_name`. + pub fn resolve_model_reference(&self, model_ref: &str) -> Option { + self.models + .iter() + .find(|m| m.id == model_ref || m.name == model_ref || m.model_name == model_ref) + .map(|m| m.id.clone()) + } + + /// Resolves a model selector value. + /// + /// Special values: + /// - `primary`: must resolve to a valid primary model + /// - `fast`: first tries the configured fast model, then falls back to primary + /// + /// Regular values are resolved by `id`, `name`, or `model_name`. + pub fn resolve_model_selection(&self, model_ref: &str) -> Option { + match model_ref { + "primary" => self + .default_models + .primary + .as_deref() + .and_then(|value| self.resolve_model_reference(value)), + "fast" => self + .default_models + .fast + .as_deref() + .and_then(|value| self.resolve_model_reference(value)) + .or_else(|| { + self.default_models + .primary + .as_deref() + .and_then(|value| self.resolve_model_reference(value)) + }), + _ => self.resolve_model_reference(model_ref), + } + } +} + /// Mode configuration (tool configuration per mode). /// /// Model mapping has moved to `AIConfig.agent_models`, keyed by `mode_id`. @@ -717,7 +756,7 @@ pub struct AIModelConfig { /// Stored by the frontend when config is saved; falls back to base_url if absent. #[serde(default)] pub request_url: Option, - + pub api_key: String, /// Context window size (total token limit for input + output). pub context_window: Option, diff --git a/src/crates/core/src/service/git/git_utils.rs b/src/crates/core/src/service/git/git_utils.rs index 29b665d6..b23bc298 100644 --- a/src/crates/core/src/service/git/git_utils.rs +++ b/src/crates/core/src/service/git/git_utils.rs @@ -100,7 +100,10 @@ pub fn status_to_string(status: Status) -> String { const UNTRACKED_RECURSE_THRESHOLD: usize = 200; /// Collects file statuses from a `StatusOptions` scan. -fn collect_statuses(repo: &Repository, recurse_untracked: bool) -> Result, GitError> { +fn collect_statuses( + repo: &Repository, + recurse_untracked: bool, +) -> Result, GitError> { let mut status_options = StatusOptions::new(); status_options.include_untracked(true); status_options.include_ignored(false); diff --git a/src/crates/core/src/service/lsp/global.rs b/src/crates/core/src/service/lsp/global.rs index be13530e..d02e673f 100644 --- a/src/crates/core/src/service/lsp/global.rs +++ b/src/crates/core/src/service/lsp/global.rs @@ -2,8 +2,8 @@ //! //! Uses a global singleton to avoid adding dependencies to `AppState`. -use log::{info, warn}; use crate::infrastructure::try_get_path_manager_arc; +use log::{info, warn}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, OnceLock}; diff --git a/src/crates/core/src/service/lsp/manager.rs b/src/crates/core/src/service/lsp/manager.rs index 2898944d..9092579b 100644 --- a/src/crates/core/src/service/lsp/manager.rs +++ b/src/crates/core/src/service/lsp/manager.rs @@ -1,6 +1,5 @@ //! LSP protocol-layer manager - use anyhow::{anyhow, Result}; use log::{debug, error, info, warn}; use std::collections::HashMap; @@ -202,7 +201,6 @@ impl LspManager { Ok(()) } - /// Returns whether the server is running. pub async fn is_server_running(&self, language: &str) -> bool { let processes = self.processes.read().await; @@ -277,7 +275,6 @@ impl LspManager { self.shutdown().await } - /// Document open notification (protocol-only; does not include startup logic). pub async fn did_open(&self, language: &str, uri: &str, text: &str) -> Result<()> { let process = self.get_process(language).await?; diff --git a/src/crates/core/src/service/mcp/adapter/resource.rs b/src/crates/core/src/service/mcp/adapter/resource.rs index 0ad37713..beb4d6e9 100644 --- a/src/crates/core/src/service/mcp/adapter/resource.rs +++ b/src/crates/core/src/service/mcp/adapter/resource.rs @@ -30,10 +30,12 @@ impl ResourceAdapter { /// Converts MCP resource content to plain text. Binary (blob) content is summarized. pub fn to_text(content: &MCPResourceContent) -> String { - let text = content - .content - .as_deref() - .unwrap_or_else(|| content.blob.as_ref().map_or("(empty)", |_| "(binary content)")); + let text = content.content.as_deref().unwrap_or_else(|| { + content + .blob + .as_ref() + .map_or("(empty)", |_| "(binary content)") + }); format!("Resource: {}\n\n{}\n", content.uri, text) } diff --git a/src/crates/core/src/service/mcp/adapter/tool.rs b/src/crates/core/src/service/mcp/adapter/tool.rs index ab2c58e4..1c4e56b7 100644 --- a/src/crates/core/src/service/mcp/adapter/tool.rs +++ b/src/crates/core/src/service/mcp/adapter/tool.rs @@ -134,9 +134,16 @@ impl Tool for MCPToolWrapper { .. } => format!("[Audio: {}]", mime_type), crate::service::mcp::protocol::MCPToolResultContent::ResourceLink { - uri, name, .. - } => name.as_ref().map_or_else(|| uri.clone(), |n| format!("[Resource: {} ({})]", n, uri)), - crate::service::mcp::protocol::MCPToolResultContent::Resource { resource } => { + uri, + name, + .. + } => name.as_ref().map_or_else( + || uri.clone(), + |n| format!("[Resource: {} ({})]", n, uri), + ), + crate::service::mcp::protocol::MCPToolResultContent::Resource { + resource, + } => { format!("[Resource: {}]", resource.uri) } }) diff --git a/src/crates/core/src/service/mcp/protocol/types.rs b/src/crates/core/src/service/mcp/protocol/types.rs index 3b506d19..08ad55bb 100644 --- a/src/crates/core/src/service/mcp/protocol/types.rs +++ b/src/crates/core/src/service/mcp/protocol/types.rs @@ -194,7 +194,12 @@ pub struct MCPResourceContentMeta { pub struct MCPResourceContent { pub uri: String, /// Text or HTML content. Serialized as `text` per MCP spec; accepts `text` or `content` when deserializing. - #[serde(default, alias = "text", rename = "text", skip_serializing_if = "Option::is_none")] + #[serde( + default, + alias = "text", + rename = "text", + skip_serializing_if = "Option::is_none" + )] pub content: Option, /// Base64-encoded binary content (MCP spec). Used for video, images, etc. #[serde(skip_serializing_if = "Option::is_none")] @@ -274,11 +279,19 @@ impl MCPPromptMessageContent { pub fn text_or_placeholder(&self) -> String { match self { MCPPromptMessageContent::Plain(s) => s.clone(), - MCPPromptMessageContent::Block(MCPPromptMessageContentBlock::Text { text }) => text.clone(), - MCPPromptMessageContent::Block(MCPPromptMessageContentBlock::Image { mime_type, .. }) => { + MCPPromptMessageContent::Block(MCPPromptMessageContentBlock::Text { text }) => { + text.clone() + } + MCPPromptMessageContent::Block(MCPPromptMessageContentBlock::Image { + mime_type, + .. + }) => { format!("[Image: {}]", mime_type) } - MCPPromptMessageContent::Block(MCPPromptMessageContentBlock::Audio { mime_type, .. }) => { + MCPPromptMessageContent::Block(MCPPromptMessageContentBlock::Audio { + mime_type, + .. + }) => { format!("[Audio: {}]", mime_type) } MCPPromptMessageContent::Block(MCPPromptMessageContentBlock::Resource { resource }) => { diff --git a/src/crates/core/src/service/mod.rs b/src/crates/core/src/service/mod.rs index f44a174e..2b9c91ed 100644 --- a/src/crates/core/src/service/mod.rs +++ b/src/crates/core/src/service/mod.rs @@ -2,8 +2,8 @@ //! //! Contains core business logic: Workspace, Config, FileSystem, Git, Agentic, AIRules, MCP. -pub mod ai_memory; // AI memory point management pub(crate) mod agent_memory; // Agent memory prompt helpers +pub mod ai_memory; // AI memory point management pub mod ai_rules; // AI rules management pub(crate) mod bootstrap; // Workspace persona bootstrap helpers pub mod config; // Config management @@ -14,11 +14,11 @@ pub mod i18n; // I18n service pub mod lsp; // LSP (Language Server Protocol) system pub mod mcp; // MCP (Model Context Protocol) system pub mod project_context; // Project context management +pub mod remote_connect; // Remote Connect (phone → desktop) pub mod runtime; // Managed runtime and capability management pub mod session; // Session persistence pub mod snapshot; // Snapshot-based change tracking pub mod system; // System command detection and execution -pub mod remote_connect; // Remote Connect (phone → desktop) pub mod token_usage; // Token usage tracking pub mod workspace; // Workspace management // Diff calculation and merge service 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 1b5920ea..e33bac36 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 @@ -13,6 +13,20 @@ use std::sync::Arc; // ── Per-chat state ────────────────────────────────────────────────── +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum BotLanguage { + #[serde(rename = "zh-CN")] + ZhCN, + #[serde(rename = "en-US")] + EnUS, +} + +impl BotLanguage { + pub fn is_chinese(self) -> bool { + matches!(self, Self::ZhCN) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BotChatState { pub chat_id: String, @@ -42,6 +56,17 @@ impl BotChatState { } } +pub async fn current_bot_language() -> BotLanguage { + if let Some(service) = crate::service::get_global_i18n_service().await { + match service.get_current_locale().await { + crate::service::LocaleId::ZhCN => BotLanguage::ZhCN, + crate::service::LocaleId::EnUS => BotLanguage::EnUS, + } + } else { + BotLanguage::ZhCN + } +} + #[derive(Debug, Clone)] pub enum PendingAction { SelectWorkspace { @@ -94,13 +119,11 @@ pub struct BotInteractiveRequest { pub pending_action: PendingAction, } -pub type BotInteractionHandler = Arc< - dyn Fn(BotInteractiveRequest) -> Pin + Send>> + Send + Sync, ->; +pub type BotInteractionHandler = + Arc Pin + Send>> + Send + Sync>; -pub type BotMessageSender = Arc< - dyn Fn(String) -> Pin + Send>> + Send + Sync, ->; +pub type BotMessageSender = + Arc Pin + Send>> + Send + Sync>; pub struct ForwardRequest { pub session_id: String, @@ -207,60 +230,157 @@ pub fn parse_command(text: &str) -> BotCommand { // ── Static messages ───────────────────────────────────────────────── -pub const WELCOME_MESSAGE: &str = "\ +pub fn welcome_message(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "\ +欢迎使用 BitFun! + +要连接你的 BitFun 桌面端,请发送 BitFun Remote Connect 面板里显示的 6 位配对码。 + +如果你还没有配对码,请打开 BitFun Desktop -> Remote Connect -> Telegram/飞书机器人,复制 6 位配对码并发送到这里。" + } else { + "\ Welcome to BitFun! To connect your BitFun desktop app, please enter the 6-digit pairing code shown in your BitFun Remote Connect panel. -Need a pairing code? Open BitFun Desktop -> Remote Connect -> Telegram/Feishu Bot -> copy the 6-digit code and send it here."; +Need a pairing code? Open BitFun Desktop -> Remote Connect -> Telegram/Feishu Bot -> copy the 6-digit code and send it here." + } +} -pub const HELP_MESSAGE: &str = "\ +pub fn help_message(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "\ +可用命令: +/switch_workspace - 列出并切换工作区 +/resume_session - 恢复已有会话 +/new_code_session - 创建新的编码会话 +/new_cowork_session - 创建新的协作会话 +/cancel_task - 取消当前任务 +/help - 显示帮助信息" + } else { + "\ Available commands: /switch_workspace - List and switch workspaces /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"; +/help - Show this help message" + } +} + +pub fn paired_success_message(language: BotLanguage) -> String { + if language.is_chinese() { + format!("配对成功!BitFun 已连接。\n\n{}", help_message(language)) + } else { + format!( + "Pairing successful! BitFun is now connected.\n\n{}", + help_message(language) + ) + } +} + +fn label_switch_workspace(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "切换工作区" + } else { + "Switch Workspace" + } +} + +fn label_resume_session(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "恢复会话" + } else { + "Resume Session" + } +} + +fn label_new_code_session(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "新建编码会话" + } else { + "New Code Session" + } +} + +fn label_new_cowork_session(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "新建协作会话" + } else { + "New Cowork Session" + } +} -pub fn paired_success_message() -> String { - format!( - "Pairing successful! BitFun is now connected.\n\n{}", - HELP_MESSAGE - ) +fn label_cancel_task(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "取消任务" + } else { + "Cancel Task" + } } -pub fn main_menu_actions() -> Vec { +fn label_next_page(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "下一页" + } else { + "Next Page" + } +} + +fn other_label(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "其他" + } else { + "Other" + } +} + +pub fn main_menu_actions(language: BotLanguage) -> 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 (send /help for menu)", "/help"), + BotAction::primary(label_switch_workspace(language), "/switch_workspace"), + BotAction::secondary(label_resume_session(language), "/resume_session"), + BotAction::secondary(label_new_code_session(language), "/new_code_session"), + BotAction::secondary(label_new_cowork_session(language), "/new_cowork_session"), + BotAction::secondary( + if language.is_chinese() { + "帮助(发送 /help 查看菜单)" + } else { + "Help (send /help for menu)" + }, + "/help", + ), ] } -fn workspace_required_actions() -> Vec { - vec![BotAction::primary("Switch Workspace", "/switch_workspace")] +fn workspace_required_actions(language: BotLanguage) -> Vec { + vec![BotAction::primary( + label_switch_workspace(language), + "/switch_workspace", + )] } -fn session_entry_actions() -> Vec { +fn session_entry_actions(language: BotLanguage) -> Vec { vec![ - BotAction::primary("Resume Session", "/resume_session"), - BotAction::secondary("New Code Session", "/new_code_session"), - BotAction::secondary("New Cowork Session", "/new_cowork_session"), + BotAction::primary(label_resume_session(language), "/resume_session"), + BotAction::secondary(label_new_code_session(language), "/new_code_session"), + BotAction::secondary(label_new_cowork_session(language), "/new_cowork_session"), ] } -fn new_session_actions() -> Vec { +fn new_session_actions(language: BotLanguage) -> Vec { vec![ - BotAction::primary("New Code Session", "/new_code_session"), - BotAction::secondary("New Cowork Session", "/new_cowork_session"), + BotAction::primary(label_new_code_session(language), "/new_code_session"), + BotAction::secondary(label_new_cowork_session(language), "/new_cowork_session"), ] } -fn cancel_task_actions(command: impl Into) -> Vec { - vec![BotAction::secondary("Cancel Task", command.into())] +fn cancel_task_actions(language: BotLanguage, command: impl Into) -> Vec { + vec![BotAction::secondary( + label_cancel_task(language), + command.into(), + )] } // ── Main dispatch ─────────────────────────────────────────────────── @@ -270,88 +390,95 @@ pub async fn handle_command( cmd: BotCommand, images: Vec, ) -> HandleResult { + let language = current_bot_language().await; let image_contexts: Vec = - super::super::remote_server::images_to_contexts( - if images.is_empty() { None } else { Some(&images) }, - ); + super::super::remote_server::images_to_contexts(if images.is_empty() { + None + } else { + Some(&images) + }); match cmd { BotCommand::Start | BotCommand::Help => { if state.paired { HandleResult { - reply: HELP_MESSAGE.to_string(), - actions: main_menu_actions(), + reply: help_message(language).to_string(), + actions: main_menu_actions(language), forward_to_session: None, } } else { HandleResult { - reply: WELCOME_MESSAGE.to_string(), + reply: welcome_message(language).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(), + reply: if language.is_chinese() { + "配对码会自动处理。如果你需要重新配对,请在 BitFun Desktop 中重新启动连接。" + .to_string() + } else { + "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 => { if !state.paired { - return not_paired(); + return not_paired(language); } handle_switch_workspace(state).await } BotCommand::ResumeSession => { if !state.paired { - return not_paired(); + return not_paired(language); } if state.current_workspace.is_none() { - return need_workspace(); + return need_workspace(language); } handle_resume_session(state, 0).await } BotCommand::NewCodeSession => { if !state.paired { - return not_paired(); + return not_paired(language); } if state.current_workspace.is_none() { - return need_workspace(); + return need_workspace(language); } handle_new_session(state, "agentic").await } BotCommand::NewCoworkSession => { if !state.paired { - return not_paired(); + return not_paired(language); } if state.current_workspace.is_none() { - return need_workspace(); + return need_workspace(language); } handle_new_session(state, "Cowork").await } BotCommand::CancelTask(turn_id) => { if !state.paired { - return not_paired(); + return not_paired(language); } handle_cancel_task(state, turn_id.as_deref()).await } BotCommand::NumberSelection(n) => { if !state.paired { - return not_paired(); + return not_paired(language); } handle_number_selection(state, n).await } BotCommand::NextPage => { if !state.paired { - return not_paired(); + return not_paired(language); } handle_next_page(state).await } BotCommand::ChatMessage(msg) => { if !state.paired { - return not_paired(); + return not_paired(language); } handle_chat_message(state, &msg, image_contexts).await } @@ -360,19 +487,27 @@ pub async fn handle_command( // ── Helpers ───────────────────────────────────────────────────────── -fn not_paired() -> HandleResult { +fn not_paired(language: BotLanguage) -> HandleResult { HandleResult { - reply: "Not connected to BitFun Desktop. Please enter the 6-digit pairing code first." - .to_string(), + reply: if language.is_chinese() { + "尚未连接到 BitFun Desktop。请先发送 6 位配对码。".to_string() + } else { + "Not connected to BitFun Desktop. Please enter the 6-digit pairing code first." + .to_string() + }, actions: vec![], forward_to_session: None, } } -fn need_workspace() -> HandleResult { +fn need_workspace(language: BotLanguage) -> HandleResult { HandleResult { - reply: "No workspace selected. Use /switch_workspace first.".to_string(), - actions: workspace_required_actions(), + reply: if language.is_chinese() { + "尚未选择工作区。请先使用 /switch_workspace。".to_string() + } else { + "No workspace selected. Use /switch_workspace first.".to_string() + }, + actions: workspace_required_actions(language), forward_to_session: None, } } @@ -400,15 +535,13 @@ fn numbered_actions(labels: &[String]) -> Vec { .iter() .enumerate() .map(|(idx, label)| { - BotAction::secondary( - truncate_action_label(label, 28), - (idx + 1).to_string(), - ) + BotAction::secondary(truncate_action_label(label, 28), (idx + 1).to_string()) }) .collect() } fn build_question_prompt( + language: BotLanguage, tool_id: String, questions: Vec, current_index: usize, @@ -419,9 +552,14 @@ fn build_question_prompt( let question = &questions[current_index]; let mut actions = Vec::new(); let mut reply = format!( - "Question {}/{}\n", + "{} {}/{}\n", + if language.is_chinese() { + "问题" + } else { + "Question" + }, current_index + 1, - questions.len() + questions.len(), ); if !question.header.is_empty() { reply.push_str(&format!("{}\n", question.header)); @@ -431,21 +569,34 @@ fn build_question_prompt( reply.push_str(&format!("{}\n", question_option_line(idx, option))); } reply.push_str(&format!( - "{}. Other\n\n", - question.options.len() + 1 + "{}. {}\n\n", + question.options.len() + 1, + other_label(language), )); if awaiting_custom_text { - reply.push_str("Please type your custom answer."); + reply.push_str(if language.is_chinese() { + "请输入你的自定义答案。" + } else { + "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"); + reply.push_str(if language.is_chinese() { + "请回复一个或多个选项编号,用逗号分隔,例如:1,3" + } else { + "Reply with one or more option numbers, separated by commas. Example: 1,3" + }); } else { - reply.push_str("Reply with a single option number."); + reply.push_str(if language.is_chinese() { + "请回复单个选项编号。" + } else { + "Reply with a single option number." + }); let mut labels: Vec = question .options .iter() .map(|option| option.label.clone()) .collect(); - labels.push("Other".to_string()); + labels.push(other_label(language).to_string()); actions = numbered_actions(&labels); } @@ -482,12 +633,17 @@ fn parse_question_numbers(input: &str) -> Option> { async fn handle_switch_workspace(state: &mut BotChatState) -> HandleResult { use crate::service::workspace::get_global_workspace_service; + let language = current_bot_language().await; let ws_service = match get_global_workspace_service() { Some(s) => s, None => { return HandleResult { - reply: "Workspace service not available.".to_string(), + reply: if language.is_chinese() { + "工作区服务不可用。".to_string() + } else { + "Workspace service not available.".to_string() + }, actions: vec![], forward_to_session: None, }; @@ -497,8 +653,11 @@ async fn handle_switch_workspace(state: &mut BotChatState) -> HandleResult { let workspaces = ws_service.get_recent_workspaces().await; if workspaces.is_empty() { return HandleResult { - reply: "No workspaces found. Please open a project in BitFun Desktop first." - .to_string(), + reply: if language.is_chinese() { + "未找到工作区。请先在 BitFun Desktop 中打开一个项目。".to_string() + } else { + "No workspaces found. Please open a project in BitFun Desktop first.".to_string() + }, actions: vec![], forward_to_session: None, }; @@ -506,16 +665,32 @@ async fn handle_switch_workspace(state: &mut BotChatState) -> HandleResult { let effective_current: Option<&str> = state.current_workspace.as_deref(); - let mut text = String::from("Select a workspace:\n\n"); + let mut text = if language.is_chinese() { + String::from("请选择工作区:\n\n") + } else { + String::from("Select a workspace:\n\n") + }; let mut options: Vec<(String, String)> = Vec::new(); for (i, ws) in workspaces.iter().enumerate() { let path = ws.root_path.to_string_lossy().to_string(); let is_current = effective_current == Some(path.as_str()); - let marker = if is_current { " [current]" } else { "" }; + let marker = if is_current { + if language.is_chinese() { + " [当前]" + } else { + " [current]" + } + } else { + "" + }; text.push_str(&format!("{}. {}{}\n {}\n", i + 1, ws.name, marker, path)); options.push((path, ws.name.clone())); } - text.push_str("\nReply with the workspace number."); + text.push_str(if language.is_chinese() { + "\n请回复工作区编号。" + } else { + "\nReply with the workspace number." + }); let action_labels: Vec = options.iter().map(|(_, name)| name.clone()).collect(); state.pending_action = Some(PendingAction::SelectWorkspace { options }); @@ -529,10 +704,11 @@ async fn handle_switch_workspace(state: &mut BotChatState) -> HandleResult { async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleResult { use crate::agentic::persistence::PersistenceManager; use crate::infrastructure::PathManager; + let language = current_bot_language().await; let ws_path = match &state.current_workspace { Some(p) => std::path::PathBuf::from(p), - None => return need_workspace(), + None => return need_workspace(language), }; let page_size = 10usize; @@ -542,7 +718,11 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR Ok(pm) => std::sync::Arc::new(pm), Err(e) => { return HandleResult { - reply: format!("Failed to load sessions: {e}"), + reply: if language.is_chinese() { + format!("加载会话失败:{e}") + } else { + format!("Failed to load sessions: {e}") + }, actions: vec![], forward_to_session: None, }; @@ -553,7 +733,11 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR Ok(store) => store, Err(e) => { return HandleResult { - reply: format!("Failed to load sessions: {e}"), + reply: if language.is_chinese() { + format!("加载会话失败:{e}") + } else { + format!("Failed to load sessions: {e}") + }, actions: vec![], forward_to_session: None, }; @@ -564,7 +748,11 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR Ok(m) => m, Err(e) => { return HandleResult { - reply: format!("Failed to list sessions: {e}"), + reply: if language.is_chinese() { + format!("列出会话失败:{e}") + } else { + format!("Failed to list sessions: {e}") + }, actions: vec![], forward_to_session: None, }; @@ -573,10 +761,14 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR if all_meta.is_empty() { return HandleResult { - reply: "No sessions found in this workspace. Use /new_code_session or \ - /new_cowork_session to create one." - .to_string(), - actions: new_session_actions(), + reply: if language.is_chinese() { + "当前工作区没有会话。请使用 /new_code_session 或 /new_cowork_session 创建一个。" + .to_string() + } else { + "No sessions found in this workspace. Use /new_code_session or /new_cowork_session to create one." + .to_string() + }, + actions: new_session_actions(language), forward_to_session: None, }; } @@ -588,23 +780,53 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR let ws_name = ws_path .file_name() .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "Unknown".to_string()); + .unwrap_or_else(|| { + if language.is_chinese() { + "未知".to_string() + } else { + "Unknown".to_string() + } + }); - let mut text = format!("Sessions in {} (page {}):\n\n", ws_name, page + 1); + let mut text = if language.is_chinese() { + format!("{} 中的会话(第 {} 页):\n\n", ws_name, page + 1) + } else { + format!("Sessions in {} (page {}):\n\n", ws_name, page + 1) + }; let mut options: Vec<(String, String)> = Vec::new(); for (i, s) in sessions.iter().enumerate() { let is_current = state.current_session_id.as_deref() == Some(&s.session_id); - let marker = if is_current { " [current]" } else { "" }; + let marker = if is_current { + if language.is_chinese() { + " [当前]" + } else { + " [current]" + } + } else { + "" + }; let ts = chrono::DateTime::from_timestamp(s.last_active_at as i64 / 1000, 0) .map(|dt| dt.format("%m-%d %H:%M").to_string()) .unwrap_or_default(); let turn_count = s.turn_count; let msg_hint = if turn_count == 0 { - "no messages".to_string() + if language.is_chinese() { + "无消息".to_string() + } else { + "no messages".to_string() + } } else if turn_count == 1 { - "1 message".to_string() + if language.is_chinese() { + "1 条消息".to_string() + } else { + "1 message".to_string() + } } else { - format!("{turn_count} messages") + if language.is_chinese() { + format!("{turn_count} 条消息") + } else { + format!("{turn_count} messages") + } }; text.push_str(&format!( "{}. [{}] {}{}\n {} · {}\n", @@ -618,19 +840,31 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR options.push((s.session_id.clone(), s.session_name.clone())); } if has_more { - text.push_str("\n0 - Next page\n"); + text.push_str(if language.is_chinese() { + "\n0 - 下一页\n" + } else { + "\n0 - Next page\n" + }); } - text.push_str("\nReply with the session number."); + text.push_str(if language.is_chinese() { + "\n请回复会话编号。" + } else { + "\nReply with the session number." + }); - state.pending_action = Some(PendingAction::SelectSession { options, page, has_more }); + state.pending_action = Some(PendingAction::SelectSession { + options, + page, + has_more, + }); 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")); + action_labels.push(label_next_page(language).to_string()); + actions.push(BotAction::secondary(label_next_page(language), "0")); } HandleResult { reply: text, @@ -642,12 +876,17 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR async fn handle_new_session(state: &mut BotChatState, agent_type: &str) -> HandleResult { use crate::agentic::coordination::get_global_coordinator; use crate::agentic::core::SessionConfig; + let language = current_bot_language().await; let coordinator = match get_global_coordinator() { Some(c) => c, None => { return HandleResult { - reply: "BitFun session system not ready.".to_string(), + reply: if language.is_chinese() { + "BitFun 会话系统尚未就绪。".to_string() + } else { + "BitFun session system not ready.".to_string() + }, actions: vec![], forward_to_session: None, }; @@ -656,13 +895,29 @@ async fn handle_new_session(state: &mut BotChatState, agent_type: &str) -> Handl let ws_path = state.current_workspace.clone(); let session_name = match agent_type { - "Cowork" => "Remote Cowork Session", - _ => "Remote Code Session", + "Cowork" => { + if language.is_chinese() { + "远程协作会话" + } else { + "Remote Cowork Session" + } + } + _ => { + if language.is_chinese() { + "远程编码会话" + } else { + "Remote Code Session" + } + } }; let Some(workspace_path) = ws_path.clone() else { return HandleResult { - reply: "Please select a workspace first.".to_string(), + reply: if language.is_chinese() { + "请先选择工作区。".to_string() + } else { + "Please select a workspace first.".to_string() + }, actions: vec![], forward_to_session: None, }; @@ -685,23 +940,41 @@ async fn handle_new_session(state: &mut BotChatState, agent_type: &str) -> Handl let session_id = session.session_id.clone(); state.current_session_id = Some(session_id.clone()); let label = if agent_type == "Cowork" { - "cowork" + if language.is_chinese() { + "协作" + } else { + "cowork" + } } else { - "coding" + if language.is_chinese() { + "编码" + } else { + "coding" + } }; let workspace = workspace_path.as_str(); HandleResult { - reply: format!( - "Created new {} session: {}\nWorkspace: {}\n\n\ - You can now send messages to interact with the AI agent.", - label, session_name, workspace - ), + reply: if language.is_chinese() { + format!( + "已创建新的{}会话:{}\n工作区:{}\n\n你现在可以发送消息与 AI 助手交互。", + label, session_name, workspace + ) + } else { + format!( + "Created new {} session: {}\nWorkspace: {}\n\nYou can now send messages to interact with the AI agent.", + label, session_name, workspace + ) + }, actions: vec![], forward_to_session: None, } } Err(e) => HandleResult { - reply: format!("Failed to create session: {e}"), + reply: if language.is_chinese() { + format!("创建会话失败:{e}") + } else { + format!("Failed to create session: {e}") + }, actions: vec![], forward_to_session: None, }, @@ -709,15 +982,38 @@ async fn handle_new_session(state: &mut BotChatState, agent_type: &str) -> Handl } async fn handle_number_selection(state: &mut BotChatState, n: usize) -> HandleResult { + let language = current_bot_language().await; let pending = state.pending_action.take(); match pending { Some(PendingAction::SelectWorkspace { options }) => { if n < 1 || n > options.len() { state.pending_action = Some(PendingAction::SelectWorkspace { options }); return HandleResult { - 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)), + reply: if language.is_chinese() { + format!( + "无效选择。请输入 1-{}。", + state + .pending_action + .as_ref() + .map(|a| match a { + PendingAction::SelectWorkspace { options } => options.len(), + _ => 0, + }) + .unwrap_or(0) + ) + } else { + 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, }; @@ -738,7 +1034,11 @@ async fn handle_number_selection(state: &mut BotChatState, n: usize) -> HandleRe has_more, }); return HandleResult { - reply: format!("Invalid selection. Please enter 1-{max}."), + reply: if language.is_chinese() { + format!("无效选择。请输入 1-{max}。") + } else { + format!("Invalid selection. Please enter 1-{max}.") + }, actions: vec![], forward_to_session: None, }; @@ -772,12 +1072,17 @@ async fn handle_number_selection(state: &mut BotChatState, n: usize) -> HandleRe async fn select_workspace(state: &mut BotChatState, path: &str, name: &str) -> HandleResult { use crate::service::workspace::get_global_workspace_service; + let language = current_bot_language().await; let ws_service = match get_global_workspace_service() { Some(s) => s, None => { return HandleResult { - reply: "Workspace service not available.".to_string(), + reply: if language.is_chinese() { + "工作区服务不可用。".to_string() + } else { + "Workspace service not available.".to_string() + }, actions: vec![], forward_to_session: None, }; @@ -800,11 +1105,11 @@ async fn select_workspace(state: &mut BotChatState, path: &str, name: &str) -> H info!("Bot switched workspace to: {path}"); let session_count = count_workspace_sessions(path).await; - let reply = build_workspace_switched_reply(name, session_count); + let reply = build_workspace_switched_reply(language, name, session_count); let actions = if session_count > 0 { - session_entry_actions() + session_entry_actions(language) } else { - new_session_actions() + new_session_actions(language) }; HandleResult { reply, @@ -813,7 +1118,11 @@ async fn select_workspace(state: &mut BotChatState, path: &str, name: &str) -> H } } Err(e) => HandleResult { - reply: format!("Failed to switch workspace: {e}"), + reply: if language.is_chinese() { + format!("切换工作区失败:{e}") + } else { + format!("Failed to switch workspace: {e}") + }, actions: vec![], forward_to_session: None, }, @@ -840,22 +1149,47 @@ async fn count_workspace_sessions(workspace_path: &str) -> usize { .unwrap_or(0) } -fn build_workspace_switched_reply(name: &str, session_count: usize) -> String { - let mut reply = format!("Switched to workspace: {name}\n\n"); +fn build_workspace_switched_reply( + language: BotLanguage, + name: &str, + session_count: usize, +) -> String { + let mut reply = if language.is_chinese() { + format!("已切换到工作区:{name}\n\n") + } else { + format!("Switched to workspace: {name}\n\n") + }; if session_count > 0 { - let s = if session_count == 1 { "" } else { "s" }; - reply.push_str(&format!( - "This workspace has {session_count} existing session{s}. What would you like to do?\n\n\ - /resume_session - Resume an existing session\n\ - /new_code_session - Start a new coding session\n\ - /new_cowork_session - Start a new cowork session" - )); + if language.is_chinese() { + reply.push_str(&format!( + "这个工作区已有 {session_count} 个会话。你想做什么?\n\n\ + /resume_session - 恢复已有会话\n\ + /new_code_session - 开始新的编码会话\n\ + /new_cowork_session - 开始新的协作会话" + )); + } else { + let s = if session_count == 1 { "" } else { "s" }; + reply.push_str(&format!( + "This workspace has {session_count} existing session{s}. What would you like to do?\n\n\ + /resume_session - Resume an existing session\n\ + /new_code_session - Start a new coding session\n\ + /new_cowork_session - Start a new cowork session" + )); + } } else { - reply.push_str( - "No sessions found in this workspace. What would you like to do?\n\n\ - /new_code_session - Start a new coding session\n\ - /new_cowork_session - Start a new cowork session", - ); + if language.is_chinese() { + reply.push_str( + "这个工作区还没有会话。你想做什么?\n\n\ + /new_code_session - 开始新的编码会话\n\ + /new_cowork_session - 开始新的协作会话", + ); + } else { + reply.push_str( + "No sessions found in this workspace. What would you like to do?\n\n\ + /new_code_session - Start a new coding session\n\ + /new_cowork_session - Start a new cowork session", + ); + } } reply } @@ -865,20 +1199,43 @@ async fn select_session( session_id: &str, session_name: &str, ) -> HandleResult { + let language = current_bot_language().await; state.current_session_id = Some(session_id.to_string()); info!("Bot resumed session: {session_id}"); let last_pair = load_last_dialog_pair_from_turns(state.current_workspace.as_deref(), session_id).await; - let mut reply = format!("Resumed session: {session_name}\n\n"); + let mut reply = if language.is_chinese() { + format!("已恢复会话:{session_name}\n\n") + } else { + format!("Resumed session: {session_name}\n\n") + }; if let Some((user_text, assistant_text)) = last_pair { - reply.push_str("— Last conversation —\n"); - reply.push_str(&format!("You: {user_text}\n\n")); - reply.push_str(&format!("AI: {assistant_text}\n\n")); - reply.push_str("You can continue the conversation."); + reply.push_str(if language.is_chinese() { + "— 最近一次对话 —\n" + } else { + "— Last conversation —\n" + }); + reply.push_str(&format!( + "{}: {user_text}\n\n", + if language.is_chinese() { "你" } else { "You" } + )); + reply.push_str(&format!( + "{}: {assistant_text}\n\n", + if language.is_chinese() { "AI" } else { "AI" } + )); + reply.push_str(if language.is_chinese() { + "你可以继续对话。" + } else { + "You can continue the conversation." + }); } else { - reply.push_str("You can now send messages to interact with the AI agent."); + reply.push_str(if language.is_chinese() { + "你现在可以发送消息与 AI 助手交互。" + } else { + "You can now send messages to interact with the AI agent." + }); } HandleResult { @@ -979,33 +1336,43 @@ async fn handle_cancel_task( requested_turn_id: Option<&str>, ) -> HandleResult { use crate::service::remote_connect::remote_server::get_or_init_global_dispatcher; + let language = current_bot_language().await; 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(), + reply: if language.is_chinese() { + "当前没有可取消的活动会话。".to_string() + } else { + "No active session to cancel.".to_string() + }, + actions: session_entry_actions(language), forward_to_session: None, }; } }; let dispatcher = get_or_init_global_dispatcher(); - match dispatcher - .cancel_task(&session_id, requested_turn_id) - .await - { + match dispatcher.cancel_task(&session_id, requested_turn_id).await { Ok(_) => { state.pending_action = None; HandleResult { - reply: "Cancellation requested for the current task.".to_string(), + reply: if language.is_chinese() { + "已请求取消当前任务。".to_string() + } else { + "Cancellation requested for the current task.".to_string() + }, actions: vec![], forward_to_session: None, } } Err(e) => HandleResult { - reply: format!("Failed to cancel task: {e}"), + reply: if language.is_chinese() { + format!("取消任务失败:{e}") + } else { + format!("Failed to cancel task: {e}") + }, actions: vec![], forward_to_session: None, }, @@ -1064,9 +1431,14 @@ async fn handle_question_reply( pending_answer: Option, message: &str, ) -> HandleResult { + let language = current_bot_language().await; let Some(question) = questions.get(current_index).cloned() else { return HandleResult { - reply: "Question state is invalid.".to_string(), + reply: if language.is_chinese() { + "问题状态无效。".to_string() + } else { + "Question state is invalid.".to_string() + }, actions: vec![], forward_to_session: None, }; @@ -1085,7 +1457,11 @@ async fn handle_question_reply( pending_answer, ); return HandleResult { - reply: "Custom answer cannot be empty. Please type your custom answer.".to_string(), + reply: if language.is_chinese() { + "自定义答案不能为空。请输入你的自定义答案。".to_string() + } else { + "Custom answer cannot be empty. Please type your custom answer.".to_string() + }, actions: vec![], forward_to_session: None, }; @@ -1119,9 +1495,17 @@ async fn handle_question_reply( ); return HandleResult { reply: if question.multi_select { - "Invalid input. Reply with option numbers like `1,3`.".to_string() + if language.is_chinese() { + "输入无效。请回复选项编号,例如 `1,3`。".to_string() + } else { + "Invalid input. Reply with option numbers like `1,3`.".to_string() + } } else { - "Invalid input. Reply with a single option number.".to_string() + if language.is_chinese() { + "输入无效。请回复单个选项编号。".to_string() + } else { + "Invalid input. Reply with a single option number.".to_string() + } }, actions: vec![], forward_to_session: None, @@ -1140,7 +1524,11 @@ async fn handle_question_reply( None, ); return HandleResult { - reply: "Please reply with a single option number.".to_string(), + reply: if language.is_chinese() { + "请回复单个选项编号。".to_string() + } else { + "Please reply with a single option number.".to_string() + }, actions: vec![], forward_to_session: None, }; @@ -1152,11 +1540,9 @@ async fn handle_question_reply( for selection in selections { if selection == other_index { includes_other = true; - labels.push(Value::String("Other".to_string())); + labels.push(Value::String(other_label(language).to_string())); } else if selection >= 1 && selection <= question.options.len() { - labels.push(Value::String( - question.options[selection - 1].label.clone(), - )); + labels.push(Value::String(question.options[selection - 1].label.clone())); } else { restore_question_pending_action( state, @@ -1169,7 +1555,13 @@ async fn handle_question_reply( ); return HandleResult { reply: format!( - "Invalid selection. Please choose between 1 and {}.", + "{} 1 {} {}。", + if language.is_chinese() { + "无效选择。请选择" + } else { + "Invalid selection. Please choose between" + }, + if language.is_chinese() { "到" } else { "and" }, other_index ), actions: vec![], @@ -1195,7 +1587,11 @@ async fn handle_question_reply( pending_answer, ); return HandleResult { - reply: "Please type your custom answer for `Other`.".to_string(), + reply: if language.is_chinese() { + format!("请为“{}”输入你的自定义答案。", other_label(language)) + } else { + "Please type your custom answer for `Other`.".to_string() + }, actions: vec![], forward_to_session: None, }; @@ -1210,6 +1606,7 @@ async fn handle_question_reply( if current_index + 1 < questions.len() { let prompt = build_question_prompt( + language, tool_id, questions, current_index + 1, @@ -1245,10 +1642,17 @@ async fn handle_question_reply( }; } - submit_question_answers(&tool_id, &answers).await + let mut result = submit_question_answers(&tool_id, &answers).await; + if language.is_chinese() + && result.reply == "Answers submitted. Waiting for the assistant to continue..." + { + result.reply = "答案已提交,等待助手继续...".to_string(); + } + result } async fn handle_next_page(state: &mut BotChatState) -> HandleResult { + let language = current_bot_language().await; let pending = state.pending_action.take(); match pending { Some(PendingAction::SelectSession { page, has_more, .. }) if has_more => { @@ -1257,7 +1661,11 @@ async fn handle_next_page(state: &mut BotChatState) -> HandleResult { Some(action) => { state.pending_action = Some(action); HandleResult { - reply: "No more pages available.".to_string(), + reply: if language.is_chinese() { + "没有更多页面了。".to_string() + } else { + "No more pages available.".to_string() + }, actions: vec![], forward_to_session: None, } @@ -1271,6 +1679,7 @@ async fn handle_chat_message( message: &str, image_contexts: Vec, ) -> HandleResult { + let language = current_bot_language().await; if let Some(PendingAction::AskUserQuestion { tool_id, questions, @@ -1295,15 +1704,28 @@ async fn handle_chat_message( if let Some(pending) = state.pending_action.clone() { return match pending { PendingAction::SelectWorkspace { .. } => HandleResult { - reply: "Please reply with the workspace number.".to_string(), + reply: if language.is_chinese() { + "请回复工作区编号。".to_string() + } else { + "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() + if language.is_chinese() { + "请回复会话编号,或回复 `0` 查看下一页。".to_string() + } else { + "Please reply with the session number, or `0` for the next page." + .to_string() + } } else { - "Please reply with the session number.".to_string() + if language.is_chinese() { + "请回复会话编号。".to_string() + } else { + "Please reply with the session number.".to_string() + } }, actions: vec![], forward_to_session: None, @@ -1314,17 +1736,25 @@ async fn handle_chat_message( 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(), + reply: if language.is_chinese() { + "尚未选择工作区。请先使用 /switch_workspace 选择工作区。".to_string() + } else { + "No workspace selected. Use /switch_workspace to select one first.".to_string() + }, + actions: workspace_required_actions(language), forward_to_session: None, }; } if state.current_session_id.is_none() { return HandleResult { - reply: "No active session. Use /resume_session to resume one or \ - /new_code_session /new_cowork_session to create a new one." - .to_string(), - actions: session_entry_actions(), + reply: if language.is_chinese() { + "当前没有活动会话。请使用 /resume_session 恢复已有会话,或使用 /new_code_session /new_cowork_session 创建新会话。" + .to_string() + } else { + "No active session. Use /resume_session to resume one or /new_code_session /new_cowork_session to create a new one." + .to_string() + }, + actions: session_entry_actions(language), forward_to_session: None, }; } @@ -1334,10 +1764,19 @@ async fn handle_chat_message( let cancel_command = format!("/cancel_task {}", turn_id); HandleResult { reply: format!( - "Processing your message...\n\nIf needed, send `{}` to stop this request.", - cancel_command + "{}\n\n{}", + if language.is_chinese() { + "正在处理你的消息..." + } else { + "Processing your message..." + }, + if language.is_chinese() { + format!("如需停止本次请求,请发送 `{}`。", cancel_command) + } else { + format!("If needed, send `{}` to stop this request.", cancel_command) + } ), - actions: cancel_task_actions(cancel_command), + actions: cancel_task_actions(language, cancel_command), forward_to_session: Some(ForwardRequest { session_id, content: message.to_string(), @@ -1366,6 +1805,7 @@ pub async fn execute_forwarded_turn( use crate::service::remote_connect::remote_server::{ get_or_init_global_dispatcher, TrackerEvent, }; + let language = current_bot_language().await; let dispatcher = get_or_init_global_dispatcher(); @@ -1379,18 +1819,22 @@ pub async fn execute_forwarded_turn( Some(&forward.agent_type), forward.image_contexts, DialogTriggerSource::Bot, - Some(forward.turn_id), + Some(forward.turn_id.clone()), ) .await { - let msg = format!("Failed to send message: {e}"); + let msg = if language.is_chinese() { + format!("发送消息失败:{e}") + } else { + format!("Failed to send message: {e}") + }; return ForwardedTurnResult { display_text: msg.clone(), full_text: msg, }; } - let result = tokio::time::timeout(std::time::Duration::from_secs(300), async { + let result = tokio::time::timeout(std::time::Duration::from_secs(3600), async { let mut response = String::new(); loop { match event_rx.recv().await { @@ -1409,6 +1853,7 @@ pub async fn execute_forwarded_turn( serde_json::from_value::>(questions_value) { let request = build_question_prompt( + language, tool_id, questions, 0, @@ -1424,14 +1869,22 @@ pub async fn execute_forwarded_turn( } TrackerEvent::TurnCompleted => break, TrackerEvent::TurnFailed(e) => { - let msg = format!("Error: {e}"); + let msg = if language.is_chinese() { + format!("错误:{e}") + } else { + format!("Error: {e}") + }; return ForwardedTurnResult { display_text: msg.clone(), full_text: msg, }; } TrackerEvent::TurnCancelled => { - let msg = "Task was cancelled.".to_string(); + let msg = if language.is_chinese() { + "任务已取消。".to_string() + } else { + "Task was cancelled.".to_string() + }; return ForwardedTurnResult { display_text: msg.clone(), full_text: msg, @@ -1452,7 +1905,11 @@ pub async fn execute_forwarded_turn( // response — it is maintained directly from AgenticEvent and is not // subject to broadcast channel lag. let full_text = tracker.accumulated_text(); - let full_text = if full_text.is_empty() { response } else { full_text }; + let full_text = if full_text.is_empty() { + response + } else { + full_text + }; let mut display_text = full_text.clone(); const MAX_BOT_MSG_LEN: usize = 4000; @@ -1467,7 +1924,11 @@ pub async fn execute_forwarded_turn( ForwardedTurnResult { display_text: if display_text.is_empty() { - "(No response)".to_string() + if language.is_chinese() { + "(无回复)".to_string() + } else { + "(No response)".to_string() + } } else { display_text }, @@ -1477,7 +1938,11 @@ pub async fn execute_forwarded_turn( .await; result.unwrap_or_else(|_| ForwardedTurnResult { - display_text: "Response timed out after 5 minutes.".to_string(), + display_text: if language.is_chinese() { + "等待 1 小时后响应超时。".to_string() + } else { + "Response timed out after 1 hour.".to_string() + }, full_text: String::new(), }) } 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 8c745849..49cb2416 100644 --- a/src/crates/core/src/service/remote_connect/bot/feishu.rs +++ b/src/crates/core/src/service/remote_connect/bot/feishu.rs @@ -14,9 +14,10 @@ use tokio::sync::RwLock; use tokio_tungstenite::tungstenite::Message as WsMessage; use super::command_router::{ - execute_forwarded_turn, handle_command, main_menu_actions, paired_success_message, - parse_command, BotAction, BotActionStyle, BotChatState, BotInteractionHandler, - BotInteractiveRequest, BotMessageSender, HandleResult, WELCOME_MESSAGE, + current_bot_language, execute_forwarded_turn, handle_command, main_menu_actions, + paired_success_message, parse_command, welcome_message, BotAction, BotActionStyle, + BotChatState, BotInteractionHandler, BotInteractiveRequest, BotLanguage, BotMessageSender, + HandleResult, }; use super::{load_bot_persistence, save_bot_persistence, BotConfig, SavedBotConnection}; @@ -268,6 +269,54 @@ struct ParsedMessage { } impl FeishuBot { + fn invalid_pairing_code_message(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "配对码无效或已过期,请重试。" + } else { + "Invalid or expired pairing code. Please try again." + } + } + + fn enter_pairing_code_message(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "请输入 BitFun Desktop 中显示的 6 位配对码。" + } else { + "Please enter the 6-digit pairing code from BitFun Desktop." + } + } + + fn unsupported_message_type_message(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "暂不支持这种消息类型,请发送文本或图片。" + } else { + "This message type is not supported. Please send text or images." + } + } + + fn expired_download_message(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "这个下载链接已过期,请重新让助手发送一次。" + } else { + "This download link has expired. Please ask the agent again." + } + } + + fn sending_file_message(language: BotLanguage, file_name: &str) -> String { + if language.is_chinese() { + format!("正在发送“{file_name}”……") + } else { + format!("Sending \"{file_name}\"…") + } + } + + fn send_file_failed_message(language: BotLanguage, file_name: &str, error: &str) -> String { + if language.is_chinese() { + format!("无法发送“{file_name}”:{error}") + } else { + format!("Could not send \"{file_name}\": {error}") + } + } + pub fn new(config: FeishuConfig) -> Self { Self { config, @@ -481,12 +530,13 @@ impl FeishuBot { pub async fn send_action_card( &self, chat_id: &str, + language: BotLanguage, 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 card = Self::build_action_card(chat_id, language, content, actions); let resp = client .post("https://open.feishu.cn/open-apis/im/v1/messages") .query(&[("receive_id_type", "chat_id")]) @@ -508,10 +558,11 @@ impl FeishuBot { } async fn send_handle_result(&self, chat_id: &str, result: &HandleResult) -> Result<()> { + let language = current_bot_language().await; if result.actions.is_empty() { self.send_message(chat_id, &result.reply).await } else { - self.send_action_card(chat_id, &result.reply, &result.actions) + self.send_action_card(chat_id, language, &result.reply, &result.actions) .await } } @@ -624,20 +675,18 @@ impl FeishuBot { /// upload it to Feishu. Sends a plain-text error if the token has expired /// or the transfer fails. async fn handle_download_request(&self, chat_id: &str, token: &str) { - let path = { + let (path, language) = { let mut states = self.chat_states.write().await; - states - .get_mut(chat_id) - .and_then(|s| s.pending_files.remove(token)) + let state = states.get_mut(chat_id); + let language = current_bot_language().await; + let path = state.and_then(|s| s.pending_files.remove(token)); + (path, language) }; match path { None => { let _ = self - .send_message( - chat_id, - "This download link has expired. Please ask the agent again.", - ) + .send_message(chat_id, Self::expired_download_message(language)) .await; } Some(path) => { @@ -647,7 +696,7 @@ impl FeishuBot { .unwrap_or("file") .to_string(); let _ = self - .send_message(chat_id, &format!("Sending \"{file_name}\"…")) + .send_message(chat_id, &Self::sending_file_message(language, &file_name)) .await; match self.send_file_to_feishu_chat(chat_id, &path).await { Ok(()) => info!("Sent file to Feishu chat {chat_id}: {path}"), @@ -656,7 +705,11 @@ impl FeishuBot { let _ = self .send_message( chat_id, - &format!("⚠️ Could not send \"{file_name}\": {e}"), + &Self::send_file_failed_message( + language, + &file_name, + &e.to_string(), + ), ) .await; } @@ -665,8 +718,13 @@ impl FeishuBot { } } - fn build_action_card(chat_id: &str, content: &str, actions: &[BotAction]) -> serde_json::Value { - let body = Self::card_body_text(content); + fn build_action_card( + chat_id: &str, + language: BotLanguage, + content: &str, + actions: &[BotAction], + ) -> serde_json::Value { + let body = Self::card_body_text(language, content); let mut elements = vec![serde_json::json!({ "tag": "markdown", "content": body, @@ -714,7 +772,7 @@ impl FeishuBot { }) } - fn card_body_text(content: &str) -> String { + fn card_body_text(language: BotLanguage, content: &str) -> String { let mut removed_command_lines = false; let mut lines = Vec::new(); @@ -725,12 +783,14 @@ impl FeishuBot { continue; } if trimmed.contains("/cancel_task ") { - lines.push( - "If needed, use the Cancel Task button below to stop this request.".to_string(), - ); + lines.push(if language.is_chinese() { + "如需停止本次请求,请使用下方的“取消任务”按钮。".to_string() + } else { + "If needed, use the Cancel Task button below to stop this request.".to_string() + }); continue; } - lines.push(Self::replace_command_tokens(line)); + lines.push(Self::replace_command_tokens(language, line)); } let mut body = lines.join("\n").trim().to_string(); @@ -738,24 +798,74 @@ impl FeishuBot { if !body.is_empty() { body.push_str("\n\n"); } - body.push_str("Choose an action below."); + body.push_str(if language.is_chinese() { + "请选择下方操作。" + } else { + "Choose an action below." + }); } if body.is_empty() { - "Choose an action below.".to_string() + if language.is_chinese() { + "请选择下方操作。".to_string() + } else { + "Choose an action below.".to_string() + } } else { body } } - fn replace_command_tokens(line: &str) -> String { + fn replace_command_tokens(language: BotLanguage, 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"), + ( + "/switch_workspace", + if language.is_chinese() { + "切换工作区" + } else { + "Switch Workspace" + }, + ), + ( + "/resume_session", + if language.is_chinese() { + "恢复会话" + } else { + "Resume Session" + }, + ), + ( + "/new_code_session", + if language.is_chinese() { + "新建编码会话" + } else { + "New Code Session" + }, + ), + ( + "/new_cowork_session", + if language.is_chinese() { + "新建协作会话" + } else { + "New Cowork Session" + }, + ), + ( + "/cancel_task", + if language.is_chinese() { + "取消任务" + } else { + "Cancel Task" + }, + ), + ( + "/help", + if language.is_chinese() { + "帮助" + } else { + "Help" + }, + ), ]; replacements @@ -888,6 +998,7 @@ impl FeishuBot { } /// Backward-compatible wrapper: returns (chat_id, text) only for text/post with text content. + #[cfg(test)] fn parse_message_event(event: &serde_json::Value) -> Option<(String, String)> { let parsed = Self::parse_message_event_full(event)?; if parsed.text.is_empty() { @@ -1026,17 +1137,22 @@ impl FeishuBot { .send(WsMessage::Binary(pb::encode_frame(&resp_frame))) .await; - if let Some((chat_id, msg_text)) = Self::parse_message_event(&event) { + if let Some(parsed) = Self::parse_message_event_full(&event) { + let language = current_bot_language().await; + let chat_id = parsed.chat_id; + let msg_text = parsed.text; let trimmed = msg_text.trim(); if trimmed == "/start" { - self.send_message(&chat_id, WELCOME_MESSAGE).await.ok(); + self.send_message(&chat_id, welcome_message(language)) + .await + .ok(); } 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 result = HandleResult { - reply: paired_success_message(), - actions: main_menu_actions(), + reply: paired_success_message(language), + actions: main_menu_actions(language), forward_to_session: None, }; self.send_handle_result(&chat_id, &result).await.ok(); @@ -1051,28 +1167,20 @@ impl FeishuBot { return Some(chat_id); } else { - self.send_message( - &chat_id, - "Invalid or expired pairing code. Please try again.", - ) - .await - .ok(); + self.send_message(&chat_id, Self::invalid_pairing_code_message(language)) + .await + .ok(); } } else { - self.send_message( - &chat_id, - "Please enter the 6-digit pairing code from BitFun Desktop.", - ) - .await - .ok(); + self.send_message(&chat_id, Self::enter_pairing_code_message(language)) + .await + .ok(); } } else if let Some(chat_id) = Self::extract_message_chat_id(&event) { - self.send_message( - &chat_id, - "Only text messages are supported. Please send the 6-digit pairing code as text.", - ) - .await - .ok(); + let language = current_bot_language().await; + self.send_message(&chat_id, Self::enter_pairing_code_message(language)) + .await + .ok(); } None } @@ -1242,6 +1350,7 @@ impl FeishuBot { let bot = self.clone(); tokio::spawn(async move { const MAX_IMAGES: usize = 5; + let language = current_bot_language().await; let truncated = parsed.image_keys.len() > MAX_IMAGES; let keys_to_use = if truncated { &parsed.image_keys[..MAX_IMAGES] @@ -1255,30 +1364,60 @@ impl FeishuBot { }; if truncated { let msg = format!( - "⚠️ Only the first {} images will be processed; the remaining {} were discarded.", + "{} {} {}", + if language.is_chinese() { + "仅会处理前" + } else { + "Only the first" + }, MAX_IMAGES, - parsed.image_keys.len() - MAX_IMAGES, + if language.is_chinese() { + format!( + "张图片,其余 {} 张已丢弃。", + parsed.image_keys.len() - MAX_IMAGES + ) + } else { + format!( + "images will be processed; the remaining {} were discarded.", + parsed.image_keys.len() - MAX_IMAGES + ) + }, ); bot.send_message(&parsed.chat_id, &msg).await.ok(); } let text = if parsed.text.is_empty() && !images.is_empty() { - "[User sent an image]".to_string() + if language.is_chinese() { + "[用户发送了一张图片]".to_string() + } else { + "[User sent an image]".to_string() + } } else { parsed.text }; - bot.handle_incoming_message(&parsed.chat_id, &text, images).await; + bot.handle_incoming_message( + &parsed.chat_id, + &text, + images, + ) + .await; }); } else if let Some((chat_id, cmd)) = Self::parse_card_action_event(&event) { let bot = self.clone(); tokio::spawn(async move { - bot.handle_incoming_message(&chat_id, &cmd, vec![]).await; + bot.handle_incoming_message( + &chat_id, + &cmd, + vec![], + ) + .await; }); } else if let Some(chat_id) = Self::extract_message_chat_id(&event) { let bot = self.clone(); tokio::spawn(async move { + let language = current_bot_language().await; bot.send_message( &chat_id, - "This message type is not supported. Please send text or images.", + Self::unsupported_message_type_message(language), ).await.ok(); }); } @@ -1332,40 +1471,37 @@ impl FeishuBot { s.paired = true; s }); + let language = current_bot_language().await; if !state.paired { let trimmed = text.trim(); if trimmed == "/start" { - self.send_message(chat_id, WELCOME_MESSAGE).await.ok(); + self.send_message(chat_id, welcome_message(language)) + .await + .ok(); return; } if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) { if self.verify_pairing_code(trimmed).await { state.paired = true; let result = HandleResult { - reply: paired_success_message(), - actions: main_menu_actions(), + reply: paired_success_message(language), + actions: main_menu_actions(language), forward_to_session: None, }; self.send_handle_result(chat_id, &result).await.ok(); self.persist_chat_state(chat_id, state).await; return; } else { - self.send_message( - chat_id, - "Invalid or expired pairing code. Please try again.", - ) - .await - .ok(); + self.send_message(chat_id, Self::invalid_pairing_code_message(language)) + .await + .ok(); return; } } - self.send_message( - chat_id, - "Please enter the 6-digit pairing code from BitFun Desktop.", - ) - .await - .ok(); + self.send_message(chat_id, Self::enter_pairing_code_message(language)) + .await + .ok(); return; } @@ -1459,6 +1595,7 @@ impl FeishuBot { #[cfg(test)] mod tests { use super::FeishuBot; + use crate::service::remote_connect::bot::command_router::BotLanguage; #[test] fn parse_text_message_event() { @@ -1507,6 +1644,7 @@ mod tests { #[test] fn card_body_removes_slash_command_list() { let body = FeishuBot::card_body_text( + BotLanguage::EnUS, "Available commands:\n/switch_workspace - List and switch workspaces\n/help - Show this help message", ); diff --git a/src/crates/core/src/service/remote_connect/bot/mod.rs b/src/crates/core/src/service/remote_connect/bot/mod.rs index 7aebaca2..9f6784ba 100644 --- a/src/crates/core/src/service/remote_connect/bot/mod.rs +++ b/src/crates/core/src/service/remote_connect/bot/mod.rs @@ -280,8 +280,6 @@ const CODE_FILE_EXTENSIONS: &[&str] = &[ "swift", "vue", "svelte", - "html", - "htm", "css", "scss", "less", @@ -319,11 +317,22 @@ const CODE_FILE_EXTENSIONS: &[&str] = &[ "log", ]; +/// Extensions that should be treated as downloadable when referenced via +/// relative markdown links (matches mobile-web `DOWNLOADABLE_EXTENSIONS`). +const DOWNLOADABLE_EXTENSIONS: &[&str] = &[ + "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp", "rtf", "pages", + "numbers", "key", "png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico", "tiff", "tif", + "zip", "tar", "gz", "bz2", "7z", "rar", "dmg", "iso", "xz", "mp3", "wav", "ogg", "flac", "aac", + "m4a", "wma", "mp4", "avi", "mkv", "mov", "webm", "wmv", "flv", "csv", "tsv", "sqlite", "db", + "parquet", "epub", "mobi", "apk", "ipa", "exe", "msi", "deb", "rpm", "ttf", "otf", "woff", + "woff2", +]; + /// Check whether a bare file path (no protocol prefix) should be treated as /// a downloadable file based on its extension. /// -/// Only absolute local file paths are accepted in multi-workspace mode. -/// Code/config source files are filtered out even when absolute. +/// Absolute local file paths exclude source/config files. Relative links +/// are allowed when they point to known downloadable file types. fn is_downloadable_by_extension(file_path: &str) -> bool { let ext = std::path::Path::new(file_path) .extension() @@ -338,7 +347,7 @@ fn is_downloadable_by_extension(file_path: &str) -> bool { if is_absolute { !CODE_FILE_EXTENSIONS.contains(&ext.as_str()) } else { - false + DOWNLOADABLE_EXTENSIONS.contains(&ext.as_str()) } } @@ -524,7 +533,10 @@ const REMOTE_CONNECT_PERSISTENCE_FILENAME: &str = "remote_connect_persistence.js const LEGACY_BOT_PERSISTENCE_FILENAME: &str = "bot_connections.json"; pub fn bot_persistence_path() -> Option { - dirs::home_dir().map(|home| home.join(".bitfun").join(REMOTE_CONNECT_PERSISTENCE_FILENAME)) + dirs::home_dir().map(|home| { + home.join(".bitfun") + .join(REMOTE_CONNECT_PERSISTENCE_FILENAME) + }) } fn legacy_bot_persistence_path() -> Option { 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 fc566382..92d152c5 100644 --- a/src/crates/core/src/service/remote_connect/bot/telegram.rs +++ b/src/crates/core/src/service/remote_connect/bot/telegram.rs @@ -12,9 +12,9 @@ use std::sync::Arc; use tokio::sync::RwLock; use super::command_router::{ - execute_forwarded_turn, handle_command, paired_success_message, parse_command, BotAction, - BotChatState, BotInteractionHandler, BotInteractiveRequest, BotMessageSender, HandleResult, - WELCOME_MESSAGE, + current_bot_language, execute_forwarded_turn, handle_command, paired_success_message, + parse_command, welcome_message, BotAction, BotChatState, BotInteractionHandler, + BotInteractiveRequest, BotLanguage, BotMessageSender, HandleResult, }; use super::{load_bot_persistence, save_bot_persistence, BotConfig, SavedBotConnection}; use crate::service::remote_connect::remote_server::ImageAttachment; @@ -37,6 +37,54 @@ struct PendingPairing { } impl TelegramBot { + fn expired_download_message(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "这个下载链接已过期,请重新让助手发送一次。" + } else { + "This download link has expired. Please ask the agent again." + } + } + + fn sending_file_message(language: BotLanguage, file_name: &str) -> String { + if language.is_chinese() { + format!("正在发送“{file_name}”……") + } else { + format!("Sending \"{file_name}\"…") + } + } + + fn send_file_failed_message(language: BotLanguage, file_name: &str, error: &str) -> String { + if language.is_chinese() { + format!("无法发送“{file_name}”:{error}") + } else { + format!("Could not send \"{file_name}\": {error}") + } + } + + fn invalid_pairing_code_message(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "配对码无效或已过期,请重试。" + } else { + "Invalid or expired pairing code. Please try again." + } + } + + fn enter_pairing_code_message(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "请输入 BitFun Desktop 中显示的 6 位配对码。" + } else { + "Please enter the 6-digit pairing code from BitFun Desktop." + } + } + + fn cancel_button_hint(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "如需停止本次请求,请点击下方的“取消任务”按钮。" + } else { + "If needed, tap the Cancel Task button below to stop this request." + } + } + pub fn new(config: TelegramConfig) -> Self { Self { config, @@ -185,20 +233,18 @@ impl TelegramBot { /// send it. Sends a plain-text error if the token has expired or the /// transfer fails. async fn handle_download_request(&self, chat_id: i64, token: &str) { - let path = { + let (path, language) = { let mut states = self.chat_states.write().await; - states - .get_mut(&chat_id) - .and_then(|s| s.pending_files.remove(token)) + let state = states.get_mut(&chat_id); + let language = current_bot_language().await; + let path = state.and_then(|s| s.pending_files.remove(token)); + (path, language) }; match path { None => { let _ = self - .send_message( - chat_id, - "This download link has expired. Please ask the agent again.", - ) + .send_message(chat_id, Self::expired_download_message(language)) .await; } Some(path) => { @@ -208,7 +254,7 @@ impl TelegramBot { .unwrap_or("file") .to_string(); let _ = self - .send_message(chat_id, &format!("Sending \"{file_name}\"…")) + .send_message(chat_id, &Self::sending_file_message(language, &file_name)) .await; match self.send_file_as_document(chat_id, &path).await { Ok(()) => info!("Sent file to Telegram chat {chat_id}: {path}"), @@ -217,7 +263,11 @@ impl TelegramBot { let _ = self .send_message( chat_id, - &format!("⚠️ Could not send \"{file_name}\": {e}"), + &Self::send_file_failed_message( + language, + &file_name, + &e.to_string(), + ), ) .await; } @@ -242,7 +292,8 @@ impl TelegramBot { /// text is replaced with a friendlier prompt, and a Cancel Task button is /// added via the inline keyboard. async fn send_handle_result(&self, chat_id: i64, result: &HandleResult) { - let text = Self::clean_reply_text(&result.reply, !result.actions.is_empty()); + let language = current_bot_language().await; + let text = Self::clean_reply_text(language, &result.reply, !result.actions.is_empty()); if result.actions.is_empty() { self.send_message(chat_id, &text).await.ok(); } else { @@ -258,7 +309,7 @@ impl TelegramBot { /// Remove raw `/cancel_task ` instruction lines and replace them /// with a short hint that the button below can be used instead. - fn clean_reply_text(text: &str, has_actions: bool) -> String { + fn clean_reply_text(language: BotLanguage, text: &str, has_actions: bool) -> String { let mut lines: Vec = Vec::new(); let mut replaced_cancel = false; @@ -266,10 +317,7 @@ impl TelegramBot { let trimmed = line.trim(); if trimmed.contains("/cancel_task ") { if has_actions && !replaced_cancel { - lines.push( - "If needed, tap the Cancel Task button below to stop this request." - .to_string(), - ); + lines.push(Self::cancel_button_hint(language).to_string()); replaced_cancel = true; } continue; @@ -417,7 +465,6 @@ impl TelegramBot { let cq_id = cq["id"].as_str().unwrap_or("").to_string(); let chat_id = cq.pointer("/message/chat/id").and_then(|v| v.as_i64()); let data = cq["data"].as_str().map(|s| s.trim().to_string()); - if let (Some(chat_id), Some(data)) = (chat_id, data) { // Answer the callback query to dismiss the button spinner. self.answer_callback_query(&cq_id).await; @@ -492,16 +539,19 @@ impl TelegramBot { Ok(messages) => { for (chat_id, text, _images) in messages { let trimmed = text.trim(); + let language = current_bot_language().await; if trimmed == "/start" { - self.send_message(chat_id, WELCOME_MESSAGE).await.ok(); + self.send_message(chat_id, welcome_message(language)) + .await + .ok(); continue; } if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) { if self.verify_pairing_code(trimmed).await { info!("Telegram pairing successful, chat_id={chat_id}"); - let success_msg = paired_success_message(); + let success_msg = paired_success_message(language); self.send_message(chat_id, &success_msg).await.ok(); self.set_bot_commands().await.ok(); @@ -517,18 +567,15 @@ impl TelegramBot { } else { self.send_message( chat_id, - "Invalid or expired pairing code. Please try again.", + Self::invalid_pairing_code_message(language), ) .await .ok(); } } else { - self.send_message( - chat_id, - "Please enter the 6-digit pairing code from BitFun Desktop.", - ) - .await - .ok(); + self.send_message(chat_id, Self::enter_pairing_code_message(language)) + .await + .ok(); } } } @@ -589,37 +636,34 @@ impl TelegramBot { s.paired = true; s }); + let language = current_bot_language().await; if !state.paired { let trimmed = text.trim(); if trimmed == "/start" { - self.send_message(chat_id, WELCOME_MESSAGE).await.ok(); + self.send_message(chat_id, welcome_message(language)) + .await + .ok(); return; } 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(); + let msg = paired_success_message(language); self.send_message(chat_id, &msg).await.ok(); self.set_bot_commands().await.ok(); self.persist_chat_state(chat_id, state).await; return; } else { - self.send_message( - chat_id, - "Invalid or expired pairing code. Please try again.", - ) - .await - .ok(); + self.send_message(chat_id, Self::invalid_pairing_code_message(language)) + .await + .ok(); return; } } - self.send_message( - chat_id, - "Please enter the 6-digit pairing code from BitFun Desktop.", - ) - .await - .ok(); + self.send_message(chat_id, Self::enter_pairing_code_message(language)) + .await + .ok(); return; } diff --git a/src/crates/core/src/service/remote_connect/embedded_relay.rs b/src/crates/core/src/service/remote_connect/embedded_relay.rs index 71ce2640..505f8d9b 100644 --- a/src/crates/core/src/service/remote_connect/embedded_relay.rs +++ b/src/crates/core/src/service/remote_connect/embedded_relay.rs @@ -32,8 +32,8 @@ pub async fn start_embedded_relay( if let Some(dir) = static_dir { info!("Embedded relay: serving static files from {dir}"); - let serve_dir = tower_http::services::ServeDir::new(dir) - .append_index_html_on_directories(true); + let serve_dir = + tower_http::services::ServeDir::new(dir).append_index_html_on_directories(true); let static_app = axum::Router::<()>::new() .fallback_service(serve_dir) .layer(axum::middleware::from_fn(static_cache_headers)); diff --git a/src/crates/core/src/service/remote_connect/mod.rs b/src/crates/core/src/service/remote_connect/mod.rs index 315c0a74..09083b22 100644 --- a/src/crates/core/src/service/remote_connect/mod.rs +++ b/src/crates/core/src/service/remote_connect/mod.rs @@ -201,10 +201,9 @@ impl RemoteConnectService { message: String, ) { let server = RemoteServer::new(*shared_secret); - if let Ok((enc, nonce)) = server.encrypt_response( - &remote_server::RemoteResponse::Error { message }, - None, - ) { + if let Ok((enc, nonce)) = + server.encrypt_response(&remote_server::RemoteResponse::Error { message }, None) + { if let Some(ref client) = *relay_arc.read().await { let _ = client .send_relay_response(correlation_id, &enc, &nonce) @@ -375,7 +374,13 @@ impl RemoteConnectService { _ => self.config.web_app_url.clone(), }; - let qr_url = QrGenerator::build_url(&qr_payload, &web_app_url); + let client_language = if let Some(service) = crate::service::get_global_i18n_service().await + { + service.get_current_locale().await.as_str().to_string() + } else { + crate::service::LocaleId::ZhCN.as_str().to_string() + }; + let qr_url = QrGenerator::build_url(&qr_payload, &web_app_url, &client_language); let qr_svg = QrGenerator::generate_svg_from_url(&qr_url)?; let qr_data = QrGenerator::generate_png_base64_from_url(&qr_url)?; @@ -407,9 +412,7 @@ impl RemoteConnectService { { if let Some(ref client) = *relay_arc.read().await { let _ = client - .send_relay_response( - &correlation_id, &enc, &nonce, - ) + .send_relay_response(&correlation_id, &enc, &nonce) .await; } } @@ -438,9 +441,7 @@ impl RemoteConnectService { .encrypt_response(&response, request_id.as_deref()) { Ok((enc, resp_nonce)) => { - if let Some(ref client) = - *relay_arc.read().await - { + if let Some(ref client) = *relay_arc.read().await { let _ = client .send_relay_response( &correlation_id, @@ -646,8 +647,7 @@ impl RemoteConnectService { } }); - *self.bot_telegram_handle.write().await = - Some(BotHandle { stop_tx }); + *self.bot_telegram_handle.write().await = Some(BotHandle { stop_tx }); "https://t.me/BotFather".to_string() } @@ -667,12 +667,11 @@ impl RemoteConnectService { handle.stop(); } - let fs_bot = Arc::new(bot::feishu::FeishuBot::new( - bot::feishu::FeishuConfig { + let fs_bot = + Arc::new(bot::feishu::FeishuBot::new(bot::feishu::FeishuConfig { app_id: app_id.clone(), app_secret: app_secret.clone(), - }, - )); + })); fs_bot.register_pairing(&pairing_code).await?; let (stop_tx, stop_rx) = tokio::sync::watch::channel(false); @@ -756,8 +755,7 @@ impl RemoteConnectService { let (stop_tx, stop_rx) = tokio::sync::watch::channel(false); *self.telegram_bot.write().await = Some(tg_bot.clone()); - *self.bot_connected_info.write().await = - Some(format!("Telegram({chat_id})")); + *self.bot_connected_info.write().await = Some(format!("Telegram({chat_id})")); let bot_for_loop = tg_bot.clone(); tokio::spawn(async move { @@ -776,12 +774,10 @@ impl RemoteConnectService { handle.stop(); } - let fs_bot = Arc::new(bot::feishu::FeishuBot::new( - bot::feishu::FeishuConfig { - app_id: app_id.clone(), - app_secret: app_secret.clone(), - }, - )); + let fs_bot = Arc::new(bot::feishu::FeishuBot::new(bot::feishu::FeishuConfig { + app_id: app_id.clone(), + app_secret: app_secret.clone(), + })); fs_bot .restore_chat_state(&saved.chat_id, saved.chat_state.clone()) @@ -791,8 +787,7 @@ impl RemoteConnectService { *self.feishu_bot.write().await = Some(fs_bot.clone()); let cid = saved.chat_id.clone(); - *self.bot_connected_info.write().await = - Some(format!("Feishu({cid})")); + *self.bot_connected_info.write().await = Some(format!("Feishu({cid})")); let bot_for_loop = fs_bot.clone(); tokio::spawn(async move { @@ -962,9 +957,10 @@ async fn upload_mobile_web(relay_url: &str, room_id: &str, web_dir: &str) -> Res match check_result { Ok(resp) if resp.status().is_success() => { - let body: serde_json::Value = resp.json().await.map_err(|e| { - anyhow::anyhow!("parse check-web-files response: {e}") - })?; + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| anyhow::anyhow!("parse check-web-files response: {e}"))?; let needed: Vec = body["needed"] .as_array() .map(|arr| { @@ -977,9 +973,7 @@ async fn upload_mobile_web(relay_url: &str, room_id: &str, web_dir: &str) -> Res let existing = body["existing_count"].as_u64().unwrap_or(0); let total = body["total_count"].as_u64().unwrap_or(0); if needed.is_empty() { - info!( - "All {total} files already exist on relay server, no upload needed" - ); + info!("All {total} files already exist on relay server, no upload needed"); return Ok(()); } @@ -1017,8 +1011,7 @@ async fn upload_needed_files( use base64::{engine::general_purpose::STANDARD as B64, Engine}; use std::collections::HashMap; - let needed_set: std::collections::HashSet<&str> = - needed.iter().map(|s| s.as_str()).collect(); + let needed_set: std::collections::HashSet<&str> = needed.iter().map(|s| s.as_str()).collect(); let mut files_payload: Vec<(String, serde_json::Value, usize)> = Vec::new(); for f in all_files { diff --git a/src/crates/core/src/service/remote_connect/ngrok.rs b/src/crates/core/src/service/remote_connect/ngrok.rs index 07806a3f..291a1801 100644 --- a/src/crates/core/src/service/remote_connect/ngrok.rs +++ b/src/crates/core/src/service/remote_connect/ngrok.rs @@ -133,7 +133,10 @@ pub async fn start_ngrok_tunnel(local_port: u16) -> Result { "An ngrok process is already running (PID: {}).\n\ Please stop the existing ngrok process before starting a new tunnel,\n\ or use the existing tunnel directly.", - pids.iter().map(|p| p.to_string()).collect::>().join(", ") + pids.iter() + .map(|p| p.to_string()) + .collect::>() + .join(", ") )); } diff --git a/src/crates/core/src/service/remote_connect/pairing.rs b/src/crates/core/src/service/remote_connect/pairing.rs index befd0948..cb061b9f 100644 --- a/src/crates/core/src/service/remote_connect/pairing.rs +++ b/src/crates/core/src/service/remote_connect/pairing.rs @@ -241,10 +241,7 @@ mod tests { let mut protocol = PairingProtocol::new(device); // Step 1: Desktop initiates - let qr = protocol - .initiate("wss://relay.example.com") - .await - .unwrap(); + let qr = protocol.initiate("wss://relay.example.com").await.unwrap(); assert_eq!(protocol.state().await, PairingState::WaitingForScan); assert!(!qr.room_id.is_empty()); diff --git a/src/crates/core/src/service/remote_connect/qr_generator.rs b/src/crates/core/src/service/remote_connect/qr_generator.rs index 0a792173..f8549779 100644 --- a/src/crates/core/src/service/remote_connect/qr_generator.rs +++ b/src/crates/core/src/service/remote_connect/qr_generator.rs @@ -12,13 +12,13 @@ impl QrGenerator { /// Build the URL that the QR code points to. /// `web_app_url` = where the mobile web app is hosted. /// `payload.url` = the relay server that the mobile WebSocket should connect to. - pub fn build_url(payload: &QrPayload, web_app_url: &str) -> String { + pub fn build_url(payload: &QrPayload, web_app_url: &str, language: &str) -> String { let relay_ws = payload .url .replace("https://", "wss://") .replace("http://", "ws://"); format!( - "{web_app}/#/pair?room={room}&did={did}&pk={pk}&dn={dn}&relay={relay}&v={v}", + "{web_app}/#/pair?room={room}&did={did}&pk={pk}&dn={dn}&relay={relay}&v={v}&lang={lang}", web_app = web_app_url.trim_end_matches('/'), room = urlencoding::encode(&payload.room_id), did = urlencoding::encode(&payload.device_id), @@ -26,6 +26,7 @@ impl QrGenerator { dn = urlencoding::encode(&payload.device_name), relay = urlencoding::encode(&relay_ws), v = payload.version, + lang = urlencoding::encode(language), ) } @@ -58,3 +59,24 @@ impl QrGenerator { Ok(svg) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::remote_connect::pairing::QrPayload; + + #[test] + fn build_url_includes_language_parameter() { + let payload = QrPayload { + room_id: "room_123".to_string(), + url: "https://relay.example.com".to_string(), + device_id: "device_123".to_string(), + device_name: "BitFun Desktop".to_string(), + public_key: "public_key_value".to_string(), + version: 1, + }; + + let url = QrGenerator::build_url(&payload, "https://mobile.example.com", "en-US"); + assert!(url.contains("lang=en-US")); + } +} diff --git a/src/crates/core/src/service/remote_connect/relay_client.rs b/src/crates/core/src/service/remote_connect/relay_client.rs index f2198719..eb29ece0 100644 --- a/src/crates/core/src/service/remote_connect/relay_client.rs +++ b/src/crates/core/src/service/remote_connect/relay_client.rs @@ -16,9 +16,8 @@ use std::sync::Arc; use tokio::sync::{mpsc, RwLock}; use tokio_tungstenite::tungstenite::Message; -type WsStream = tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, ->; +type WsStream = + tokio_tungstenite::WebSocketStream>; /// Messages in the relay protocol (both directions). #[derive(Debug, Clone, Serialize, Deserialize)] @@ -403,9 +402,8 @@ async fn dial(ws_url: &str) -> Result { max_write_buffer_size: 64 * 1024 * 1024, ..Default::default() }; - let (stream, _) = - tokio_tungstenite::connect_async_with_config(ws_url, Some(config), false) - .await - .map_err(|e| anyhow!("dial {ws_url}: {e}"))?; + let (stream, _) = tokio_tungstenite::connect_async_with_config(ws_url, Some(config), false) + .await + .map_err(|e| anyhow!("dial {ws_url}: {e}"))?; Ok(stream) } 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 281de076..037681b2 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -1529,6 +1529,7 @@ impl RemoteExecutionDispatcher { .start_dialog_turn( session_id.to_string(), content.clone(), + None, Some(turn_id.clone()), resolved_agent_type, binding_workspace.clone(), @@ -1541,6 +1542,7 @@ impl RemoteExecutionDispatcher { .start_dialog_turn_with_image_contexts( session_id.to_string(), content.clone(), + None, image_contexts, Some(turn_id.clone()), resolved_agent_type, diff --git a/src/crates/core/src/service/snapshot/manager.rs b/src/crates/core/src/service/snapshot/manager.rs index 099dec47..208da6e2 100644 --- a/src/crates/core/src/service/snapshot/manager.rs +++ b/src/crates/core/src/service/snapshot/manager.rs @@ -508,14 +508,11 @@ impl WrappedTool { Err(e) => return Err(crate::util::errors::BitFunError::Tool(e.to_string())), }; - let snapshot_workspace = context - .workspace_root() - .map(PathBuf::from) - .ok_or_else(|| { - crate::util::errors::BitFunError::Tool( - "workspace is required in ToolUseContext for snapshot tracking".to_string(), - ) - })?; + let snapshot_workspace = context.workspace_root().map(PathBuf::from).ok_or_else(|| { + crate::util::errors::BitFunError::Tool( + "workspace is required in ToolUseContext for snapshot tracking".to_string(), + ) + })?; let snapshot_manager = get_or_create_snapshot_manager(snapshot_workspace.clone(), None) .await @@ -636,9 +633,9 @@ pub async fn get_or_create_snapshot_manager( let manager = Arc::new(SnapshotManager::new(workspace_dir.clone(), config).await?); { - let mut managers = snapshot_managers() - .write() - .map_err(|_| SnapshotError::ConfigError("Snapshot manager store lock poisoned".to_string()))?; + let mut managers = snapshot_managers().write().map_err(|_| { + SnapshotError::ConfigError("Snapshot manager store lock poisoned".to_string()) + })?; if let Some(existing) = managers.get(&workspace_dir) { return Ok(existing.clone()); } @@ -655,7 +652,9 @@ pub fn get_snapshot_manager_for_workspace(workspace_dir: &Path) -> Option SnapshotResult> { +pub fn ensure_snapshot_manager_for_workspace( + workspace_dir: &Path, +) -> SnapshotResult> { get_snapshot_manager_for_workspace(workspace_dir).ok_or_else(|| { SnapshotError::ConfigError(format!( "Snapshot manager not initialized for workspace: {}", diff --git a/src/crates/core/src/service/snapshot/service.rs b/src/crates/core/src/service/snapshot/service.rs index 31a3494b..9c386b49 100644 --- a/src/crates/core/src/service/snapshot/service.rs +++ b/src/crates/core/src/service/snapshot/service.rs @@ -296,7 +296,11 @@ impl SnapshotService { Ok(()) } - pub async fn reject_file(&self, session_id: &str, file_path: &Path) -> SnapshotResult> { + pub async fn reject_file( + &self, + session_id: &str, + file_path: &Path, + ) -> SnapshotResult> { self.ensure_initialized().await?; self.validate_file_path(file_path).await?; diff --git a/src/crates/core/src/service/snapshot/snapshot_core.rs b/src/crates/core/src/service/snapshot/snapshot_core.rs index 867e9319..734bef1e 100644 --- a/src/crates/core/src/service/snapshot/snapshot_core.rs +++ b/src/crates/core/src/service/snapshot/snapshot_core.rs @@ -261,10 +261,9 @@ impl SnapshotCore { .turns .get_mut(&turn_index) .ok_or_else(|| SnapshotError::ConfigError("turn not found".to_string()))?; - let op = turn - .operations - .get_mut(seq) - .ok_or_else(|| SnapshotError::ConfigError("seq_in_turn out of bounds".to_string()))?; + let op = turn.operations.get_mut(seq).ok_or_else(|| { + SnapshotError::ConfigError("seq_in_turn out of bounds".to_string()) + })?; op.tool_context.execution_time_ms = execution_time_ms; @@ -291,10 +290,9 @@ impl SnapshotCore { .turns .get_mut(&turn_index) .ok_or_else(|| SnapshotError::ConfigError("turn not found".to_string()))?; - let op = turn - .operations - .get_mut(seq) - .ok_or_else(|| SnapshotError::ConfigError("seq_in_turn out of bounds".to_string()))?; + let op = turn.operations.get_mut(seq).ok_or_else(|| { + SnapshotError::ConfigError("seq_in_turn out of bounds".to_string()) + })?; op.diff_summary = diff_summary; session.last_updated = SystemTime::now(); diff --git a/src/crates/core/src/service/snapshot/snapshot_system.rs b/src/crates/core/src/service/snapshot/snapshot_system.rs index 22062f30..f6e4efda 100644 --- a/src/crates/core/src/service/snapshot/snapshot_system.rs +++ b/src/crates/core/src/service/snapshot/snapshot_system.rs @@ -509,8 +509,9 @@ impl FileSnapshotSystem { /// Gets snapshot content (string), read directly from disk. pub async fn get_snapshot_content(&self, snapshot_id: &str) -> SnapshotResult { let content_bytes = self.restore_snapshot_content(snapshot_id).await?; - String::from_utf8(content_bytes) - .map_err(|e| SnapshotError::ConfigError(format!("Snapshot content is not valid UTF-8: {}", e))) + String::from_utf8(content_bytes).map_err(|e| { + SnapshotError::ConfigError(format!("Snapshot content is not valid UTF-8: {}", e)) + }) } /// Restores snapshot content (read directly from disk, without using in-memory cache). diff --git a/src/crates/core/src/service/terminal/src/pty/process.rs b/src/crates/core/src/service/terminal/src/pty/process.rs index 2f275241..c895b046 100644 --- a/src/crates/core/src/service/terminal/src/pty/process.rs +++ b/src/crates/core/src/service/terminal/src/pty/process.rs @@ -19,7 +19,9 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use std::thread; -use log::{debug, error, warn}; +#[cfg(windows)] +use log::debug; +use log::{error, warn}; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; use tokio::sync::mpsc; diff --git a/src/crates/core/src/service/terminal/src/shell/integration.rs b/src/crates/core/src/service/terminal/src/shell/integration.rs index 527d2874..0bf6b317 100644 --- a/src/crates/core/src/service/terminal/src/shell/integration.rs +++ b/src/crates/core/src/service/terminal/src/shell/integration.rs @@ -225,10 +225,7 @@ impl ShellIntegration { seq, OscSequence::CommandFinished { .. } | OscSequence::PromptStart ); - if should_flush - && !plain_output.is_empty() - && self.should_collect() - { + if should_flush && !plain_output.is_empty() && self.should_collect() { self.output_buffer.push_str(&plain_output); if let Some(cmd_id) = &self.current_command_id { events.push(ShellIntegrationEvent::OutputData { diff --git a/src/crates/core/src/service/token_usage/types.rs b/src/crates/core/src/service/token_usage/types.rs index 438837ac..25b974ca 100644 --- a/src/crates/core/src/service/token_usage/types.rs +++ b/src/crates/core/src/service/token_usage/types.rs @@ -77,7 +77,10 @@ pub enum TimeRange { ThisWeek, ThisMonth, All, - Custom { start: DateTime, end: DateTime }, + Custom { + start: DateTime, + end: DateTime, + }, } /// Query parameters for token usage diff --git a/src/crates/core/src/service/workspace/manager.rs b/src/crates/core/src/service/workspace/manager.rs index 243a632b..6788b10c 100644 --- a/src/crates/core/src/service/workspace/manager.rs +++ b/src/crates/core/src/service/workspace/manager.rs @@ -66,15 +66,21 @@ struct WorkspaceIdentityFrontmatter { } impl WorkspaceIdentity { - pub(crate) async fn load_from_workspace_root(workspace_root: &Path) -> Result, String> { + pub(crate) async fn load_from_workspace_root( + workspace_root: &Path, + ) -> Result, String> { let identity_path = workspace_root.join(IDENTITY_FILE_NAME); if !identity_path.exists() { return Ok(None); } - let content = fs::read_to_string(&identity_path) - .await - .map_err(|e| format!("Failed to read identity file '{}': {}", identity_path.display(), e))?; + let content = fs::read_to_string(&identity_path).await.map_err(|e| { + format!( + "Failed to read identity file '{}': {}", + identity_path.display(), + e + ) + })?; let identity = Self::from_markdown(&content)?; if identity.is_empty() { @@ -157,7 +163,11 @@ pub struct WorkspaceInfo { pub workspace_type: WorkspaceType, #[serde(rename = "workspaceKind", default)] pub workspace_kind: WorkspaceKind, - #[serde(rename = "assistantId", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "assistantId", + default, + skip_serializing_if = "Option::is_none" + )] pub assistant_id: Option, pub status: WorkspaceStatus, pub languages: Vec, @@ -313,7 +323,10 @@ impl WorkspaceInfo { }; if self.workspace_kind == WorkspaceKind::Assistant { - if let Some(name) = identity.as_ref().and_then(|identity| identity.name.as_ref()) { + if let Some(name) = identity + .as_ref() + .and_then(|identity| identity.name.as_ref()) + { self.name = name.clone(); } } @@ -698,7 +711,10 @@ impl WorkspaceManager { .insert(workspace_id.clone(), workspace.clone()); self.ensure_workspace_open(&workspace_id); if options.auto_set_current { - self.set_current_workspace_with_recent_policy(workspace_id.clone(), options.add_to_recent)?; + self.set_current_workspace_with_recent_policy( + workspace_id.clone(), + options.add_to_recent, + )?; } else { self.touch_workspace_access(&workspace_id, options.add_to_recent); } @@ -750,7 +766,11 @@ impl WorkspaceManager { /// Sets the active workspace among already opened workspaces. pub fn set_active_workspace(&mut self, workspace_id: &str) -> BitFunResult<()> { - if !self.opened_workspace_ids.iter().any(|id| id == workspace_id) { + if !self + .opened_workspace_ids + .iter() + .any(|id| id == workspace_id) + { return Err(BitFunError::service(format!( "Workspace is not opened: {}", workspace_id @@ -961,7 +981,8 @@ impl WorkspaceManager { /// Ensures a workspace stays in the opened list. fn ensure_workspace_open(&mut self, workspace_id: &str) { self.opened_workspace_ids.retain(|id| id != workspace_id); - self.opened_workspace_ids.insert(0, workspace_id.to_string()); + self.opened_workspace_ids + .insert(0, workspace_id.to_string()); } /// Returns manager statistics. diff --git a/src/crates/core/src/service/workspace/mod.rs b/src/crates/core/src/service/workspace/mod.rs index b25260a5..4cbfcc87 100644 --- a/src/crates/core/src/service/workspace/mod.rs +++ b/src/crates/core/src/service/workspace/mod.rs @@ -18,9 +18,8 @@ pub use context_generator::{ pub use factory::WorkspaceFactory; pub use identity_watch::WorkspaceIdentityWatchService; pub use manager::{ - GitInfo, ScanOptions, WorkspaceIdentity, WorkspaceInfo, WorkspaceManager, - WorkspaceManagerConfig, - WorkspaceManagerStatistics, WorkspaceKind, WorkspaceOpenOptions, WorkspaceStatistics, + GitInfo, ScanOptions, WorkspaceIdentity, WorkspaceInfo, WorkspaceKind, WorkspaceManager, + WorkspaceManagerConfig, WorkspaceManagerStatistics, WorkspaceOpenOptions, WorkspaceStatistics, WorkspaceStatus, WorkspaceSummary, WorkspaceType, }; pub use provider::{WorkspaceCleanupResult, WorkspaceProvider, WorkspaceSystemSummary}; diff --git a/src/crates/core/src/service/workspace/service.rs b/src/crates/core/src/service/workspace/service.rs index edbb696a..5544fe04 100644 --- a/src/crates/core/src/service/workspace/service.rs +++ b/src/crates/core/src/service/workspace/service.rs @@ -116,6 +116,13 @@ impl WorkspaceService { warn!("Failed to load workspace history on startup: {}", e); } + if let Err(e) = service.remap_legacy_assistant_workspace_records().await { + warn!( + "Failed to remap legacy assistant workspace records on startup: {}", + e + ); + } + if let Err(e) = service.ensure_assistant_workspaces().await { warn!("Failed to ensure assistant workspaces on startup: {}", e); } @@ -180,7 +187,9 @@ impl WorkspaceService { })?; } - let mut workspace = self.open_workspace_with_options(path, options.clone()).await?; + let mut workspace = self + .open_workspace_with_options(path, options.clone()) + .await?; if let Some(description) = options.description { workspace.description = Some(description); @@ -522,21 +531,24 @@ impl WorkspaceService { return Ok(None); } - let updated_identity = match WorkspaceIdentity::load_from_workspace_root(&workspace.root_path).await { - Ok(identity) => identity, - Err(error) => { - warn!( - "Failed to refresh workspace identity: workspace_id={} path={} error={}", - workspace_id, - workspace.root_path.display(), - error - ); - return Ok(None); - } - }; + let updated_identity = + match WorkspaceIdentity::load_from_workspace_root(&workspace.root_path).await { + Ok(identity) => identity, + Err(error) => { + warn!( + "Failed to refresh workspace identity: workspace_id={} path={} error={}", + workspace_id, + workspace.root_path.display(), + error + ); + return Ok(None); + } + }; - let changed_fields = - WorkspaceIdentity::collect_changed_fields(workspace.identity.as_ref(), updated_identity.as_ref()); + let changed_fields = WorkspaceIdentity::collect_changed_fields( + workspace.identity.as_ref(), + updated_identity.as_ref(), + ); let fallback_name = Self::assistant_display_name(workspace.assistant_id.as_deref()); let updated_name = updated_identity .as_ref() @@ -552,7 +564,9 @@ impl WorkspaceService { let workspace = manager .get_workspaces_mut() .get_mut(workspace_id) - .ok_or_else(|| BitFunError::service(format!("Workspace not found: {}", workspace_id)))?; + .ok_or_else(|| { + BitFunError::service(format!("Workspace not found: {}", workspace_id)) + })?; workspace.identity = updated_identity.clone(); workspace.name = updated_name.clone(); @@ -1009,6 +1023,198 @@ impl WorkspaceService { }) } + fn legacy_assistant_descriptor_from_path( + &self, + path: &Path, + ) -> Option { + let default_workspace = self + .path_manager + .legacy_default_assistant_workspace_dir(None); + if path == default_workspace { + return Some(AssistantWorkspaceDescriptor { + path: path.to_path_buf(), + assistant_id: None, + display_name: Self::assistant_display_name(None), + }); + } + + let assistant_root = self.path_manager.legacy_assistant_workspace_base_dir(None); + if path.parent()? != assistant_root { + return None; + } + + let file_name = path.file_name()?.to_string_lossy(); + let assistant_id = file_name.strip_prefix("workspace-")?; + if assistant_id.trim().is_empty() { + return None; + } + + Some(AssistantWorkspaceDescriptor { + path: path.to_path_buf(), + assistant_id: Some(assistant_id.to_string()), + display_name: Self::assistant_display_name(Some(assistant_id)), + }) + } + + async fn remap_legacy_assistant_workspace_records(&self) -> BitFunResult<()> { + let mut changed = false; + let mut manager = self.manager.write().await; + + for workspace in manager.get_workspaces_mut().values_mut() { + let Some(descriptor) = self.legacy_assistant_descriptor_from_path(&workspace.root_path) + else { + continue; + }; + let new_path = self + .path_manager + .resolve_assistant_workspace_dir(descriptor.assistant_id.as_deref(), None); + + if workspace.root_path != new_path { + info!( + "Remap legacy assistant workspace record: workspace_id={}, from={}, to={}", + workspace.id, + workspace.root_path.display(), + new_path.display() + ); + workspace.root_path = new_path; + changed = true; + } + + if workspace.workspace_kind != WorkspaceKind::Assistant { + workspace.workspace_kind = WorkspaceKind::Assistant; + changed = true; + } + + if workspace.assistant_id != descriptor.assistant_id { + workspace.assistant_id = descriptor.assistant_id.clone(); + changed = true; + } + } + + drop(manager); + + if changed { + self.save_workspace_data().await?; + } + + Ok(()) + } + + async fn migrate_legacy_assistant_workspaces(&self) -> BitFunResult<()> { + let assistant_root = self.path_manager.assistant_workspace_base_dir(None); + fs::create_dir_all(&assistant_root).await.map_err(|e| { + BitFunError::service(format!( + "Failed to create assistant workspace root '{}': {}", + assistant_root.display(), + e + )) + })?; + + let legacy_root = self.path_manager.legacy_assistant_workspace_base_dir(None); + let default_legacy_workspace = self + .path_manager + .legacy_default_assistant_workspace_dir(None); + let default_workspace = self.path_manager.default_assistant_workspace_dir(None); + + if fs::try_exists(&default_legacy_workspace) + .await + .map_err(|e| { + BitFunError::service(format!( + "Failed to inspect legacy assistant workspace '{}': {}", + default_legacy_workspace.display(), + e + )) + })? + && !fs::try_exists(&default_workspace).await.map_err(|e| { + BitFunError::service(format!( + "Failed to inspect assistant workspace '{}': {}", + default_workspace.display(), + e + )) + })? + { + fs::rename(&default_legacy_workspace, &default_workspace) + .await + .map_err(|e| { + BitFunError::service(format!( + "Failed to migrate assistant workspace '{}' to '{}': {}", + default_legacy_workspace.display(), + default_workspace.display(), + e + )) + })?; + info!( + "Migrated default assistant workspace: from={}, to={}", + default_legacy_workspace.display(), + default_workspace.display() + ); + } + + let mut entries = fs::read_dir(&legacy_root).await.map_err(|e| { + BitFunError::service(format!( + "Failed to read legacy assistant workspace root '{}': {}", + legacy_root.display(), + e + )) + })?; + + while let Some(entry) = entries.next_entry().await.map_err(|e| { + BitFunError::service(format!( + "Failed to iterate legacy assistant workspace root '{}': {}", + legacy_root.display(), + e + )) + })? { + let file_type = entry.file_type().await.map_err(|e| { + BitFunError::service(format!( + "Failed to inspect legacy assistant workspace entry '{}': {}", + entry.path().display(), + e + )) + })?; + if !file_type.is_dir() { + continue; + } + + let file_name = entry.file_name().to_string_lossy().to_string(); + let Some(assistant_id) = file_name.strip_prefix("workspace-") else { + continue; + }; + if assistant_id.trim().is_empty() { + continue; + } + + let target_path = self + .path_manager + .assistant_workspace_dir(assistant_id, None); + if fs::try_exists(&target_path).await.map_err(|e| { + BitFunError::service(format!( + "Failed to inspect assistant workspace '{}': {}", + target_path.display(), + e + )) + })? { + continue; + } + + fs::rename(entry.path(), &target_path).await.map_err(|e| { + BitFunError::service(format!( + "Failed to migrate assistant workspace '{}' to '{}': {}", + file_name, + target_path.display(), + e + )) + })?; + info!( + "Migrated named assistant workspace: assistant_id={}, to={}", + assistant_id, + target_path.display() + ); + } + + Ok(()) + } + fn normalize_workspace_options_for_path( &self, path: &Path, @@ -1016,8 +1222,9 @@ impl WorkspaceService { ) -> WorkspaceCreateOptions { if options.workspace_kind == WorkspaceKind::Assistant { if options.display_name.is_none() { - options.display_name = - Some(Self::assistant_display_name(options.assistant_id.as_deref())); + options.display_name = Some(Self::assistant_display_name( + options.assistant_id.as_deref(), + )); } return options; } @@ -1035,7 +1242,11 @@ impl WorkspaceService { options } - async fn discover_assistant_workspaces(&self) -> BitFunResult> { + async fn discover_assistant_workspaces( + &self, + ) -> BitFunResult> { + self.migrate_legacy_assistant_workspaces().await?; + let assistant_root = self.path_manager.assistant_workspace_base_dir(None); fs::create_dir_all(&assistant_root).await.map_err(|e| { BitFunError::service(format!( @@ -1127,7 +1338,8 @@ impl WorkspaceService { ..Default::default() }; - self.open_workspace_with_options(descriptor.path, options).await?; + self.open_workspace_with_options(descriptor.path, options) + .await?; has_current_workspace = true; } diff --git a/src/crates/core/src/util/errors.rs b/src/crates/core/src/util/errors.rs index 753bd99d..7db157db 100644 --- a/src/crates/core/src/util/errors.rs +++ b/src/crates/core/src/util/errors.rs @@ -124,11 +124,11 @@ impl BitFunError { pub fn validation>(msg: T) -> Self { Self::Validation(msg.into()) } - + pub fn ai>(msg: T) -> Self { Self::AIClient(msg.into()) } - + pub fn parse>(msg: T) -> Self { Self::Deserialization(msg.into()) } @@ -179,4 +179,4 @@ impl From for BitFunError { fn from(error: tokio::sync::AcquireError) -> Self { BitFunError::Semaphore(error.to_string()) } -} \ No newline at end of file +} diff --git a/src/crates/core/src/util/token_counter.rs b/src/crates/core/src/util/token_counter.rs index 70e257f1..fcc94f52 100644 --- a/src/crates/core/src/util/token_counter.rs +++ b/src/crates/core/src/util/token_counter.rs @@ -55,9 +55,7 @@ impl TokenCounter { } pub fn estimate_messages_tokens(messages: &[Message]) -> usize { - let mut total: usize = messages.iter() - .map(Self::estimate_message_tokens) - .sum(); + let mut total: usize = messages.iter().map(Self::estimate_message_tokens).sum(); total += 3; diff --git a/src/crates/core/src/util/types/ai.rs b/src/crates/core/src/util/types/ai.rs index 0cab0dbf..3735f04a 100644 --- a/src/crates/core/src/util/types/ai.rs +++ b/src/crates/core/src/util/types/ai.rs @@ -48,3 +48,13 @@ pub struct ConnectionTestResult { #[serde(skip_serializing_if = "Option::is_none")] pub error_details: Option, } + +/// Remote model info discovered from a provider API. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteModelInfo { + /// Provider model identifier (used as the actual model_name). + pub id: String, + /// Optional human-readable display name returned by the provider. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, +} diff --git a/src/crates/core/src/util/types/config.rs b/src/crates/core/src/util/types/config.rs index 9158e6e5..2abae95d 100644 --- a/src/crates/core/src/util/types/config.rs +++ b/src/crates/core/src/util/types/config.rs @@ -1,5 +1,5 @@ -use log::warn; use crate::service::config::types::AIModelConfig; +use log::warn; use serde::{Deserialize, Serialize}; fn append_endpoint(base_url: &str, endpoint: &str) -> String { @@ -151,7 +151,10 @@ impl TryFrom for AIConfig { match serde_json::from_str::(body_str) { Ok(value) => Some(value), Err(e) => { - warn!("Failed to parse custom_request_body: {}, config: {}", e, other.name); + warn!( + "Failed to parse custom_request_body: {}, config: {}", + e, other.name + ); None } } @@ -163,7 +166,9 @@ impl TryFrom for AIConfig { let request_url = other .request_url .filter(|u| !u.is_empty()) - .unwrap_or_else(|| resolve_request_url(&other.base_url, &other.provider, &other.model_name)); + .unwrap_or_else(|| { + resolve_request_url(&other.base_url, &other.provider, &other.model_name) + }); Ok(AIConfig { name: other.name.clone(), diff --git a/src/crates/core/src/util/types/mod.rs b/src/crates/core/src/util/types/mod.rs index b2a0b593..6fe35066 100644 --- a/src/crates/core/src/util/types/mod.rs +++ b/src/crates/core/src/util/types/mod.rs @@ -1,13 +1,13 @@ -pub mod core; pub mod ai; pub mod config; +pub mod core; +pub mod event; pub mod message; pub mod tool; -pub mod event; -pub use core::*; pub use ai::*; pub use config::*; +pub use core::*; +pub use event::*; pub use message::*; pub use tool::*; -pub use event::*; diff --git a/src/crates/transport/src/adapters/cli.rs b/src/crates/transport/src/adapters/cli.rs index 3eff0780..6fd53bd2 100644 --- a/src/crates/transport/src/adapters/cli.rs +++ b/src/crates/transport/src/adapters/cli.rs @@ -1,13 +1,12 @@ /// CLI transport adapter /// /// Uses tokio::mpsc channel to send events to CLI TUI renderer - use crate::traits::{TextChunk, ToolEventPayload, TransportAdapter}; use async_trait::async_trait; +use bitfun_events::AgenticEvent; use serde::{Deserialize, Serialize}; use std::fmt; use tokio::sync::mpsc; -use bitfun_events::AgenticEvent; /// CLI event type (for TUI rendering) #[derive(Debug, Clone, Serialize, Deserialize)] @@ -50,7 +49,7 @@ impl CliTransportAdapter { pub fn new(tx: mpsc::UnboundedSender) -> Self { Self { tx } } - + /// Create channel and get receiver (for creating TUI renderer) pub fn create_channel() -> (Self, mpsc::UnboundedReceiver) { let (tx, rx) = mpsc::unbounded_channel(); @@ -70,77 +69,109 @@ impl fmt::Debug for CliTransportAdapter { impl TransportAdapter for CliTransportAdapter { async fn emit_event(&self, _session_id: &str, event: AgenticEvent) -> anyhow::Result<()> { let cli_event = match event { - AgenticEvent::TextChunk { session_id, turn_id, round_id, text, .. } => { - CliEvent::TextChunk(TextChunk { - session_id, - turn_id, - round_id, - text, - timestamp: chrono::Utc::now().timestamp_millis(), - }) - } - AgenticEvent::DialogTurnStarted { session_id, turn_id, .. } => { - CliEvent::DialogTurnStarted { session_id, turn_id } - } - AgenticEvent::DialogTurnCompleted { session_id, turn_id, .. } => { - CliEvent::DialogTurnCompleted { session_id, turn_id } - } + AgenticEvent::TextChunk { + session_id, + turn_id, + round_id, + text, + .. + } => CliEvent::TextChunk(TextChunk { + session_id, + turn_id, + round_id, + text, + timestamp: chrono::Utc::now().timestamp_millis(), + }), + AgenticEvent::DialogTurnStarted { + session_id, + turn_id, + .. + } => CliEvent::DialogTurnStarted { + session_id, + turn_id, + }, + AgenticEvent::DialogTurnCompleted { + session_id, + turn_id, + .. + } => CliEvent::DialogTurnCompleted { + session_id, + turn_id, + }, _ => return Ok(()), }; - - self.tx.send(cli_event).map_err(|e| { - anyhow::anyhow!("Failed to send CLI event: {}", e) - })?; - + + self.tx + .send(cli_event) + .map_err(|e| anyhow::anyhow!("Failed to send CLI event: {}", e))?; + Ok(()) } - + async fn emit_text_chunk(&self, _session_id: &str, chunk: TextChunk) -> anyhow::Result<()> { - self.tx.send(CliEvent::TextChunk(chunk)).map_err(|e| { - anyhow::anyhow!("Failed to send text chunk: {}", e) - })?; + self.tx + .send(CliEvent::TextChunk(chunk)) + .map_err(|e| anyhow::anyhow!("Failed to send text chunk: {}", e))?; Ok(()) } - - async fn emit_tool_event(&self, _session_id: &str, event: ToolEventPayload) -> anyhow::Result<()> { - self.tx.send(CliEvent::ToolEvent(event)).map_err(|e| { - anyhow::anyhow!("Failed to send tool event: {}", e) - })?; + + async fn emit_tool_event( + &self, + _session_id: &str, + event: ToolEventPayload, + ) -> anyhow::Result<()> { + self.tx + .send(CliEvent::ToolEvent(event)) + .map_err(|e| anyhow::anyhow!("Failed to send tool event: {}", e))?; Ok(()) } - - async fn emit_stream_start(&self, session_id: &str, turn_id: &str, round_id: &str) -> anyhow::Result<()> { - self.tx.send(CliEvent::StreamStart { - session_id: session_id.to_string(), - turn_id: turn_id.to_string(), - round_id: round_id.to_string(), - }).map_err(|e| { - anyhow::anyhow!("Failed to send stream start: {}", e) - })?; + + async fn emit_stream_start( + &self, + session_id: &str, + turn_id: &str, + round_id: &str, + ) -> anyhow::Result<()> { + self.tx + .send(CliEvent::StreamStart { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + round_id: round_id.to_string(), + }) + .map_err(|e| anyhow::anyhow!("Failed to send stream start: {}", e))?; Ok(()) } - - async fn emit_stream_end(&self, session_id: &str, turn_id: &str, round_id: &str) -> anyhow::Result<()> { - self.tx.send(CliEvent::StreamEnd { - session_id: session_id.to_string(), - turn_id: turn_id.to_string(), - round_id: round_id.to_string(), - }).map_err(|e| { - anyhow::anyhow!("Failed to send stream end: {}", e) - })?; + + async fn emit_stream_end( + &self, + session_id: &str, + turn_id: &str, + round_id: &str, + ) -> anyhow::Result<()> { + self.tx + .send(CliEvent::StreamEnd { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + round_id: round_id.to_string(), + }) + .map_err(|e| anyhow::anyhow!("Failed to send stream end: {}", e))?; Ok(()) } - - async fn emit_generic(&self, event_name: &str, payload: serde_json::Value) -> anyhow::Result<()> { - self.tx.send(CliEvent::Generic { - event_name: event_name.to_string(), - payload, - }).map_err(|e| { - anyhow::anyhow!("Failed to send generic event: {}", e) - })?; + + async fn emit_generic( + &self, + event_name: &str, + payload: serde_json::Value, + ) -> anyhow::Result<()> { + self.tx + .send(CliEvent::Generic { + event_name: event_name.to_string(), + payload, + }) + .map_err(|e| anyhow::anyhow!("Failed to send generic event: {}", e))?; Ok(()) } - + fn adapter_type(&self) -> &str { "cli" } diff --git a/src/crates/transport/src/adapters/mod.rs b/src/crates/transport/src/adapters/mod.rs index dc7e803d..c34e4ed7 100644 --- a/src/crates/transport/src/adapters/mod.rs +++ b/src/crates/transport/src/adapters/mod.rs @@ -1,5 +1,4 @@ /// Transport adapters for different platforms - pub mod cli; pub mod websocket; diff --git a/src/crates/transport/src/adapters/tauri.rs b/src/crates/transport/src/adapters/tauri.rs index d810909f..b8eb0869 100644 --- a/src/crates/transport/src/adapters/tauri.rs +++ b/src/crates/transport/src/adapters/tauri.rs @@ -1,4 +1,3 @@ -use log::warn; /// Tauri transport adapter /// /// Uses Tauri's app.emit() system to send events to frontend @@ -7,9 +6,10 @@ use log::warn; #[cfg(feature = "tauri-adapter")] use crate::traits::{TextChunk, ToolEventPayload, TransportAdapter}; use async_trait::async_trait; +use bitfun_events::AgenticEvent; +use log::warn; use serde_json::json; use std::fmt; -use bitfun_events::AgenticEvent; #[cfg(feature = "tauri-adapter")] use tauri::{AppHandle, Emitter}; @@ -41,229 +41,422 @@ impl fmt::Debug for TauriTransportAdapter { impl TransportAdapter for TauriTransportAdapter { async fn emit_event(&self, _session_id: &str, event: AgenticEvent) -> anyhow::Result<()> { match event { - AgenticEvent::SessionCreated { session_id, session_name, agent_type, workspace_path } => { - self.app_handle.emit("agentic://session-created", json!({ - "sessionId": session_id, - "sessionName": session_name, - "agentType": agent_type, - "workspacePath": workspace_path, - }))?; + AgenticEvent::SessionCreated { + session_id, + session_name, + agent_type, + workspace_path, + } => { + self.app_handle.emit( + "agentic://session-created", + json!({ + "sessionId": session_id, + "sessionName": session_name, + "agentType": agent_type, + "workspacePath": workspace_path, + }), + )?; } AgenticEvent::SessionDeleted { session_id } => { - self.app_handle.emit("agentic://session-deleted", json!({ - "sessionId": session_id, - }))?; + self.app_handle.emit( + "agentic://session-deleted", + json!({ + "sessionId": session_id, + }), + )?; } - AgenticEvent::ImageAnalysisStarted { session_id, image_count, user_input, image_metadata } => { - self.app_handle.emit("agentic://image-analysis-started", json!({ - "sessionId": session_id, - "imageCount": image_count, - "userInput": user_input, - "imageMetadata": image_metadata, - }))?; + AgenticEvent::ImageAnalysisStarted { + session_id, + image_count, + user_input, + image_metadata, + } => { + self.app_handle.emit( + "agentic://image-analysis-started", + json!({ + "sessionId": session_id, + "imageCount": image_count, + "userInput": user_input, + "imageMetadata": image_metadata, + }), + )?; } - AgenticEvent::ImageAnalysisCompleted { session_id, success, duration_ms } => { - self.app_handle.emit("agentic://image-analysis-completed", json!({ - "sessionId": session_id, - "success": success, - "durationMs": duration_ms, - }))?; + AgenticEvent::ImageAnalysisCompleted { + session_id, + success, + duration_ms, + } => { + self.app_handle.emit( + "agentic://image-analysis-completed", + json!({ + "sessionId": session_id, + "success": success, + "durationMs": duration_ms, + }), + )?; } - AgenticEvent::DialogTurnStarted { session_id, turn_id, turn_index, user_input, original_user_input, user_message_metadata, subagent_parent_info } => { - self.app_handle.emit("agentic://dialog-turn-started", json!({ - "sessionId": session_id, - "turnId": turn_id, - "turnIndex": turn_index, - "userInput": user_input, - "originalUserInput": original_user_input, - "userMessageMetadata": user_message_metadata, - "subagentParentInfo": subagent_parent_info, - }))?; + AgenticEvent::DialogTurnStarted { + session_id, + turn_id, + turn_index, + user_input, + original_user_input, + user_message_metadata, + subagent_parent_info, + } => { + self.app_handle.emit( + "agentic://dialog-turn-started", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "turnIndex": turn_index, + "userInput": user_input, + "originalUserInput": original_user_input, + "userMessageMetadata": user_message_metadata, + "subagentParentInfo": subagent_parent_info, + }), + )?; } - AgenticEvent::ModelRoundStarted { session_id, turn_id, round_id, .. } => { - self.app_handle.emit("agentic://model-round-started", json!({ - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - }))?; + AgenticEvent::ModelRoundStarted { + session_id, + turn_id, + round_id, + .. + } => { + self.app_handle.emit( + "agentic://model-round-started", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "roundId": round_id, + }), + )?; } - AgenticEvent::TextChunk { session_id, turn_id, round_id, text, subagent_parent_info } => { - self.app_handle.emit("agentic://text-chunk", json!({ - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - "text": text, - "subagentParentInfo": subagent_parent_info, - }))?; + AgenticEvent::TextChunk { + session_id, + turn_id, + round_id, + text, + subagent_parent_info, + } => { + self.app_handle.emit( + "agentic://text-chunk", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "roundId": round_id, + "text": text, + "subagentParentInfo": subagent_parent_info, + }), + )?; } - AgenticEvent::ThinkingChunk { session_id, turn_id, round_id, content, subagent_parent_info } => { - self.app_handle.emit("agentic://text-chunk", json!({ - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - "text": content, - "contentType": "thinking", - "subagentParentInfo": subagent_parent_info, - }))?; + AgenticEvent::ThinkingChunk { + session_id, + turn_id, + round_id, + content, + subagent_parent_info, + } => { + self.app_handle.emit( + "agentic://text-chunk", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "roundId": round_id, + "text": content, + "contentType": "thinking", + "subagentParentInfo": subagent_parent_info, + }), + )?; } - AgenticEvent::ToolEvent { session_id, turn_id, tool_event, subagent_parent_info } => { - self.app_handle.emit("agentic://tool-event", json!({ - "sessionId": session_id, - "turnId": turn_id, - "toolEvent": tool_event, - "subagentParentInfo": subagent_parent_info, - }))?; + AgenticEvent::ToolEvent { + session_id, + turn_id, + tool_event, + subagent_parent_info, + } => { + self.app_handle.emit( + "agentic://tool-event", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "toolEvent": tool_event, + "subagentParentInfo": subagent_parent_info, + }), + )?; + } + AgenticEvent::DialogTurnCompleted { + session_id, + turn_id, + subagent_parent_info, + .. + } => { + self.app_handle.emit( + "agentic://dialog-turn-completed", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "subagentParentInfo": subagent_parent_info, + }), + )?; + } + AgenticEvent::SessionTitleGenerated { + session_id, + title, + method, + } => { + self.app_handle.emit( + "session_title_generated", + json!({ + "sessionId": session_id, + "title": title, + "method": method, + "timestamp": chrono::Utc::now().timestamp_millis(), + }), + )?; + } + AgenticEvent::DialogTurnCancelled { + session_id, + turn_id, + subagent_parent_info, + } => { + self.app_handle.emit( + "agentic://dialog-turn-cancelled", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "subagentParentInfo": subagent_parent_info, + }), + )?; + } + AgenticEvent::DialogTurnFailed { + session_id, + turn_id, + error, + subagent_parent_info, + } => { + self.app_handle.emit( + "agentic://dialog-turn-failed", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "error": error, + "subagentParentInfo": subagent_parent_info, + }), + )?; + } + AgenticEvent::TokenUsageUpdated { + session_id, + turn_id, + model_id, + input_tokens, + output_tokens, + total_tokens, + max_context_tokens, + is_subagent, + } => { + self.app_handle.emit( + "agentic://token-usage-updated", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "modelId": model_id, + "inputTokens": input_tokens, + "outputTokens": output_tokens, + "totalTokens": total_tokens, + "maxContextTokens": max_context_tokens, + "isSubagent": is_subagent, + }), + )?; + } + AgenticEvent::ContextCompressionStarted { + session_id, + turn_id, + subagent_parent_info, + compression_id, + trigger, + tokens_before, + context_window, + threshold, + } => { + self.app_handle.emit( + "agentic://context-compression-started", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "compressionId": compression_id, + "trigger": trigger, + "tokensBefore": tokens_before, + "contextWindow": context_window, + "threshold": threshold, + "subagentParentInfo": subagent_parent_info, + }), + )?; + } + AgenticEvent::ContextCompressionCompleted { + session_id, + turn_id, + subagent_parent_info, + compression_id, + compression_count, + tokens_before, + tokens_after, + compression_ratio, + duration_ms, + has_summary, + } => { + self.app_handle.emit( + "agentic://context-compression-completed", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "compressionId": compression_id, + "compressionCount": compression_count, + "tokensBefore": tokens_before, + "tokensAfter": tokens_after, + "compressionRatio": compression_ratio, + "durationMs": duration_ms, + "hasSummary": has_summary, + "subagentParentInfo": subagent_parent_info, + }), + )?; + } + AgenticEvent::ContextCompressionFailed { + session_id, + turn_id, + subagent_parent_info, + compression_id, + error, + } => { + self.app_handle.emit( + "agentic://context-compression-failed", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "compressionId": compression_id, + "error": error, + "subagentParentInfo": subagent_parent_info, + }), + )?; + } + AgenticEvent::SessionStateChanged { + session_id, + new_state, + } => { + self.app_handle.emit( + "agentic://session-state-changed", + json!({ + "sessionId": session_id, + "newState": new_state, + }), + )?; + } + AgenticEvent::ModelRoundCompleted { + session_id, + turn_id, + round_id, + has_tool_calls, + subagent_parent_info, + } => { + self.app_handle.emit( + "agentic://model-round-completed", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "roundId": round_id, + "hasToolCalls": has_tool_calls, + "subagentParentInfo": subagent_parent_info, + }), + )?; + } + _ => { + warn!("Unhandled AgenticEvent type in TauriAdapter"); } - AgenticEvent::DialogTurnCompleted { session_id, turn_id, subagent_parent_info, .. } => { - self.app_handle.emit("agentic://dialog-turn-completed", json!({ - "sessionId": session_id, - "turnId": turn_id, - "subagentParentInfo": subagent_parent_info, - }))?; - } - AgenticEvent::SessionTitleGenerated { session_id, title, method } => { - self.app_handle.emit("session_title_generated", json!({ - "sessionId": session_id, - "title": title, - "method": method, - "timestamp": chrono::Utc::now().timestamp_millis(), - }))?; - } - AgenticEvent::DialogTurnCancelled { session_id, turn_id, subagent_parent_info } => { - self.app_handle.emit("agentic://dialog-turn-cancelled", json!({ - "sessionId": session_id, - "turnId": turn_id, - "subagentParentInfo": subagent_parent_info, - }))?; - } - AgenticEvent::DialogTurnFailed { session_id, turn_id, error, subagent_parent_info } => { - self.app_handle.emit("agentic://dialog-turn-failed", json!({ - "sessionId": session_id, - "turnId": turn_id, - "error": error, - "subagentParentInfo": subagent_parent_info, - }))?; - } - AgenticEvent::TokenUsageUpdated { session_id, turn_id, model_id, input_tokens, output_tokens, total_tokens, max_context_tokens, is_subagent } => { - self.app_handle.emit("agentic://token-usage-updated", json!({ - "sessionId": session_id, - "turnId": turn_id, - "modelId": model_id, - "inputTokens": input_tokens, - "outputTokens": output_tokens, - "totalTokens": total_tokens, - "maxContextTokens": max_context_tokens, - "isSubagent": is_subagent, - }))?; - } - AgenticEvent::ContextCompressionStarted { session_id, turn_id, subagent_parent_info, compression_id, trigger, tokens_before, context_window, threshold } => { - self.app_handle.emit("agentic://context-compression-started", json!({ - "sessionId": session_id, - "turnId": turn_id, - "compressionId": compression_id, - "trigger": trigger, - "tokensBefore": tokens_before, - "contextWindow": context_window, - "threshold": threshold, - "subagentParentInfo": subagent_parent_info, - }))?; - } - AgenticEvent::ContextCompressionCompleted { session_id, turn_id, subagent_parent_info, compression_id, compression_count, tokens_before, tokens_after, compression_ratio, duration_ms, has_summary } => { - self.app_handle.emit("agentic://context-compression-completed", json!({ - "sessionId": session_id, - "turnId": turn_id, - "compressionId": compression_id, - "compressionCount": compression_count, - "tokensBefore": tokens_before, - "tokensAfter": tokens_after, - "compressionRatio": compression_ratio, - "durationMs": duration_ms, - "hasSummary": has_summary, - "subagentParentInfo": subagent_parent_info, - }))?; - } - AgenticEvent::ContextCompressionFailed { session_id, turn_id, subagent_parent_info, compression_id, error } => { - self.app_handle.emit("agentic://context-compression-failed", json!({ - "sessionId": session_id, - "turnId": turn_id, - "compressionId": compression_id, - "error": error, - "subagentParentInfo": subagent_parent_info, - }))?; - } - AgenticEvent::SessionStateChanged { session_id, new_state } => { - self.app_handle.emit("agentic://session-state-changed", json!({ - "sessionId": session_id, - "newState": new_state, - }))?; - } - AgenticEvent::ModelRoundCompleted { session_id, turn_id, round_id, has_tool_calls, subagent_parent_info } => { - self.app_handle.emit("agentic://model-round-completed", json!({ - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - "hasToolCalls": has_tool_calls, - "subagentParentInfo": subagent_parent_info, - }))?; - } - _ => { - warn!("Unhandled AgenticEvent type in TauriAdapter"); - } } Ok(()) } - + async fn emit_text_chunk(&self, _session_id: &str, chunk: TextChunk) -> anyhow::Result<()> { - self.app_handle.emit("agentic://text-chunk", json!({ - "sessionId": chunk.session_id, - "turnId": chunk.turn_id, - "roundId": chunk.round_id, - "text": chunk.text, - "timestamp": chunk.timestamp, - }))?; + self.app_handle.emit( + "agentic://text-chunk", + json!({ + "sessionId": chunk.session_id, + "turnId": chunk.turn_id, + "roundId": chunk.round_id, + "text": chunk.text, + "timestamp": chunk.timestamp, + }), + )?; Ok(()) } - - async fn emit_tool_event(&self, _session_id: &str, event: ToolEventPayload) -> anyhow::Result<()> { - self.app_handle.emit("agentic://tool-event", json!({ - "sessionId": event.session_id, - "turnId": event.turn_id, - "toolEvent": { - "tool_id": event.tool_id, - "tool_name": event.tool_name, - "event_type": event.event_type, - "params": event.params, - "result": event.result, - "error": event.error, - "duration_ms": event.duration_ms, - } - }))?; + + async fn emit_tool_event( + &self, + _session_id: &str, + event: ToolEventPayload, + ) -> anyhow::Result<()> { + self.app_handle.emit( + "agentic://tool-event", + json!({ + "sessionId": event.session_id, + "turnId": event.turn_id, + "toolEvent": { + "tool_id": event.tool_id, + "tool_name": event.tool_name, + "event_type": event.event_type, + "params": event.params, + "result": event.result, + "error": event.error, + "duration_ms": event.duration_ms, + } + }), + )?; Ok(()) } - - async fn emit_stream_start(&self, session_id: &str, turn_id: &str, round_id: &str) -> anyhow::Result<()> { - self.app_handle.emit("agentic://stream-start", json!({ - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - }))?; + + async fn emit_stream_start( + &self, + session_id: &str, + turn_id: &str, + round_id: &str, + ) -> anyhow::Result<()> { + self.app_handle.emit( + "agentic://stream-start", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "roundId": round_id, + }), + )?; Ok(()) } - - async fn emit_stream_end(&self, session_id: &str, turn_id: &str, round_id: &str) -> anyhow::Result<()> { - self.app_handle.emit("agentic://stream-end", json!({ - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - }))?; + + async fn emit_stream_end( + &self, + session_id: &str, + turn_id: &str, + round_id: &str, + ) -> anyhow::Result<()> { + self.app_handle.emit( + "agentic://stream-end", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "roundId": round_id, + }), + )?; Ok(()) } - - async fn emit_generic(&self, event_name: &str, payload: serde_json::Value) -> anyhow::Result<()> { + + async fn emit_generic( + &self, + event_name: &str, + payload: serde_json::Value, + ) -> anyhow::Result<()> { self.app_handle.emit(event_name, payload)?; Ok(()) } - + fn adapter_type(&self) -> &str { "tauri" } diff --git a/src/crates/transport/src/adapters/websocket.rs b/src/crates/transport/src/adapters/websocket.rs index 892afb0c..889ad219 100644 --- a/src/crates/transport/src/adapters/websocket.rs +++ b/src/crates/transport/src/adapters/websocket.rs @@ -1,13 +1,12 @@ /// WebSocket transport adapter /// /// Used for Web Server version, pushes events to browser via WebSocket - use crate::traits::{TextChunk, ToolEventPayload, TransportAdapter}; use async_trait::async_trait; +use bitfun_events::AgenticEvent; use serde_json::json; use std::fmt; use tokio::sync::mpsc; -use bitfun_events::AgenticEvent; /// WebSocket message type #[derive(Debug, Clone)] @@ -28,13 +27,13 @@ impl WebSocketTransportAdapter { pub fn new(tx: mpsc::UnboundedSender) -> Self { Self { tx } } - + /// Send JSON message fn send_json(&self, value: serde_json::Value) -> anyhow::Result<()> { let json_str = serde_json::to_string(&value)?; - self.tx.send(WsMessage::Text(json_str)).map_err(|e| { - anyhow::anyhow!("Failed to send WebSocket message: {}", e) - })?; + self.tx + .send(WsMessage::Text(json_str)) + .map_err(|e| anyhow::anyhow!("Failed to send WebSocket message: {}", e))?; Ok(()) } } @@ -51,7 +50,12 @@ impl fmt::Debug for WebSocketTransportAdapter { impl TransportAdapter for WebSocketTransportAdapter { async fn emit_event(&self, _session_id: &str, event: AgenticEvent) -> anyhow::Result<()> { let message = match event { - AgenticEvent::ImageAnalysisStarted { session_id, image_count, user_input, image_metadata } => { + AgenticEvent::ImageAnalysisStarted { + session_id, + image_count, + user_input, + image_metadata, + } => { json!({ "type": "image-analysis-started", "sessionId": session_id, @@ -60,7 +64,11 @@ impl TransportAdapter for WebSocketTransportAdapter { "imageMetadata": image_metadata, }) } - AgenticEvent::ImageAnalysisCompleted { session_id, success, duration_ms } => { + AgenticEvent::ImageAnalysisCompleted { + session_id, + success, + duration_ms, + } => { json!({ "type": "image-analysis-completed", "sessionId": session_id, @@ -68,7 +76,14 @@ impl TransportAdapter for WebSocketTransportAdapter { "durationMs": duration_ms, }) } - AgenticEvent::DialogTurnStarted { session_id, turn_id, turn_index, original_user_input, user_message_metadata, .. } => { + AgenticEvent::DialogTurnStarted { + session_id, + turn_id, + turn_index, + original_user_input, + user_message_metadata, + .. + } => { json!({ "type": "dialog-turn-started", "sessionId": session_id, @@ -78,7 +93,12 @@ impl TransportAdapter for WebSocketTransportAdapter { "userMessageMetadata": user_message_metadata, }) } - AgenticEvent::ModelRoundStarted { session_id, turn_id, round_id, .. } => { + AgenticEvent::ModelRoundStarted { + session_id, + turn_id, + round_id, + .. + } => { json!({ "type": "model-round-started", "sessionId": session_id, @@ -86,7 +106,13 @@ impl TransportAdapter for WebSocketTransportAdapter { "roundId": round_id, }) } - AgenticEvent::TextChunk { session_id, turn_id, round_id, text, .. } => { + AgenticEvent::TextChunk { + session_id, + turn_id, + round_id, + text, + .. + } => { json!({ "type": "text-chunk", "sessionId": session_id, @@ -95,7 +121,12 @@ impl TransportAdapter for WebSocketTransportAdapter { "text": text, }) } - AgenticEvent::ToolEvent { session_id, turn_id, tool_event, .. } => { + AgenticEvent::ToolEvent { + session_id, + turn_id, + tool_event, + .. + } => { json!({ "type": "tool-event", "sessionId": session_id, @@ -103,7 +134,11 @@ impl TransportAdapter for WebSocketTransportAdapter { "toolEvent": tool_event, }) } - AgenticEvent::DialogTurnCompleted { session_id, turn_id, .. } => { + AgenticEvent::DialogTurnCompleted { + session_id, + turn_id, + .. + } => { json!({ "type": "dialog-turn-completed", "sessionId": session_id, @@ -112,11 +147,11 @@ impl TransportAdapter for WebSocketTransportAdapter { } _ => return Ok(()), }; - + self.send_json(message)?; Ok(()) } - + async fn emit_text_chunk(&self, _session_id: &str, chunk: TextChunk) -> anyhow::Result<()> { self.send_json(json!({ "type": "text-chunk", @@ -128,8 +163,12 @@ impl TransportAdapter for WebSocketTransportAdapter { }))?; Ok(()) } - - async fn emit_tool_event(&self, _session_id: &str, event: ToolEventPayload) -> anyhow::Result<()> { + + async fn emit_tool_event( + &self, + _session_id: &str, + event: ToolEventPayload, + ) -> anyhow::Result<()> { self.send_json(json!({ "type": "tool-event", "sessionId": event.session_id, @@ -146,8 +185,13 @@ impl TransportAdapter for WebSocketTransportAdapter { }))?; Ok(()) } - - async fn emit_stream_start(&self, session_id: &str, turn_id: &str, round_id: &str) -> anyhow::Result<()> { + + async fn emit_stream_start( + &self, + session_id: &str, + turn_id: &str, + round_id: &str, + ) -> anyhow::Result<()> { self.send_json(json!({ "type": "stream-start", "sessionId": session_id, @@ -156,8 +200,13 @@ impl TransportAdapter for WebSocketTransportAdapter { }))?; Ok(()) } - - async fn emit_stream_end(&self, session_id: &str, turn_id: &str, round_id: &str) -> anyhow::Result<()> { + + async fn emit_stream_end( + &self, + session_id: &str, + turn_id: &str, + round_id: &str, + ) -> anyhow::Result<()> { self.send_json(json!({ "type": "stream-end", "sessionId": session_id, @@ -166,15 +215,19 @@ impl TransportAdapter for WebSocketTransportAdapter { }))?; Ok(()) } - - async fn emit_generic(&self, event_name: &str, payload: serde_json::Value) -> anyhow::Result<()> { + + async fn emit_generic( + &self, + event_name: &str, + payload: serde_json::Value, + ) -> anyhow::Result<()> { self.send_json(json!({ "type": event_name, "payload": payload, }))?; Ok(()) } - + fn adapter_type(&self) -> &str { "websocket" } diff --git a/src/crates/transport/src/emitter.rs b/src/crates/transport/src/emitter.rs index d7365ff0..4409cf71 100644 --- a/src/crates/transport/src/emitter.rs +++ b/src/crates/transport/src/emitter.rs @@ -1,11 +1,10 @@ +use crate::TransportAdapter; +use async_trait::async_trait; +use bitfun_events::EventEmitter; /// TransportEmitter - EventEmitter implementation based on TransportAdapter /// /// This is the bridge connecting core layer and transport layer - use std::sync::Arc; -use async_trait::async_trait; -use bitfun_events::EventEmitter; -use crate::TransportAdapter; /// TransportEmitter - Implements EventEmitter using TransportAdapter #[derive(Clone)] diff --git a/src/crates/transport/src/event_bus.rs b/src/crates/transport/src/event_bus.rs index 83f6b98c..3399f7fb 100644 --- a/src/crates/transport/src/event_bus.rs +++ b/src/crates/transport/src/event_bus.rs @@ -1,22 +1,20 @@ -use log::{warn, error}; /// Unified event bus - Manages event distribution for all platforms - - use crate::traits::TransportAdapter; +use bitfun_events::AgenticEvent; use dashmap::DashMap; +use log::{error, warn}; use std::sync::Arc; use tokio::sync::mpsc; -use bitfun_events::AgenticEvent; /// Event bus - Core event dispatcher #[derive(Clone)] pub struct EventBus { /// Active transport adapters (indexed by session_id) adapters: Arc>>, - + /// Event queue (async buffer) event_tx: mpsc::UnboundedSender, - + /// Whether logging is enabled #[allow(dead_code)] enable_logging: bool, @@ -44,52 +42,63 @@ impl EventBus { pub fn new(enable_logging: bool) -> Self { let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); let adapters: Arc>> = Arc::new(DashMap::new()); - + let adapters_clone = adapters.clone(); tokio::spawn(async move { while let Some(envelope) = event_rx.recv().await { if let Some(adapter) = adapters_clone.get(&envelope.session_id) { - if let Err(e) = adapter.emit_event(&envelope.session_id, envelope.event).await { - error!("Failed to emit event for session {}: {}", envelope.session_id, e); + if let Err(e) = adapter + .emit_event(&envelope.session_id, envelope.event) + .await + { + error!( + "Failed to emit event for session {}: {}", + envelope.session_id, e + ); } } else { warn!("No adapter registered for session: {}", envelope.session_id); } } }); - + Self { adapters, event_tx, enable_logging, } } - + /// Register transport adapter pub fn register_adapter(&self, session_id: String, adapter: Arc) { self.adapters.insert(session_id, adapter); } - + /// Unregister adapter pub fn unregister_adapter(&self, session_id: &str) { self.adapters.remove(session_id); } - + /// Emit event - pub async fn emit(&self, session_id: String, event: AgenticEvent, priority: EventPriority) -> anyhow::Result<()> { + pub async fn emit( + &self, + session_id: String, + event: AgenticEvent, + priority: EventPriority, + ) -> anyhow::Result<()> { let envelope = EventEnvelope { session_id, event, priority, }; - - self.event_tx.send(envelope).map_err(|e| { - anyhow::anyhow!("Failed to send event to queue: {}", e) - })?; - + + self.event_tx + .send(envelope) + .map_err(|e| anyhow::anyhow!("Failed to send event to queue: {}", e))?; + Ok(()) } - + /// Get active session count pub fn active_sessions(&self) -> usize { self.adapters.len() @@ -99,11 +108,10 @@ impl EventBus { #[cfg(test)] mod tests { use super::*; - + #[tokio::test] async fn test_event_bus_creation() { let bus = EventBus::new(true); assert_eq!(bus.active_sessions(), 0); } } - diff --git a/src/crates/transport/src/events.rs b/src/crates/transport/src/events.rs index de770e74..1ca4a4b4 100644 --- a/src/crates/transport/src/events.rs +++ b/src/crates/transport/src/events.rs @@ -1,8 +1,7 @@ /// Generic event definitions /// /// Supports multiple event types, uniformly distributed by transport layer - -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; /// Unified event enum - All events to be sent to frontend #[derive(Debug, Clone, Serialize, Deserialize)] @@ -10,19 +9,19 @@ use serde::{Serialize, Deserialize}; pub enum UnifiedEvent { /// Agentic system event Agentic(AgenticEventPayload), - + /// LSP event Lsp(LspEventPayload), - + /// File watch event FileWatch(FileWatchEventPayload), - + /// Profile generation event Profile(ProfileEventPayload), - + /// Snapshot event Snapshot(SnapshotEventPayload), - + /// Generic backend event Backend(BackendEventPayload), } diff --git a/src/crates/transport/src/lib.rs b/src/crates/transport/src/lib.rs index c6f6a0a6..d71222df 100644 --- a/src/crates/transport/src/lib.rs +++ b/src/crates/transport/src/lib.rs @@ -1,24 +1,23 @@ +pub mod adapters; +pub mod emitter; +pub mod event_bus; +pub mod events; /// BitFun Transport Layer /// /// Cross-platform communication abstraction layer, supports: /// - CLI (tokio mpsc) /// - Tauri (app.emit) /// - WebSocket/SSE (web server) - pub mod traits; -pub mod event_bus; -pub mod adapters; -pub mod events; -pub mod emitter; +pub use adapters::{CliEvent, CliTransportAdapter, WebSocketTransportAdapter}; pub use emitter::TransportEmitter; -pub use traits::{TransportAdapter, TextChunk, ToolEventPayload, ToolEventType, StreamEvent}; pub use event_bus::{EventBus, EventPriority}; pub use events::{ - UnifiedEvent, AgenticEventPayload, LspEventPayload, FileWatchEventPayload, - ProfileEventPayload, SnapshotEventPayload, BackendEventPayload, + AgenticEventPayload, BackendEventPayload, FileWatchEventPayload, LspEventPayload, + ProfileEventPayload, SnapshotEventPayload, UnifiedEvent, }; -pub use adapters::{CliEvent, CliTransportAdapter, WebSocketTransportAdapter}; +pub use traits::{StreamEvent, TextChunk, ToolEventPayload, ToolEventType, TransportAdapter}; #[cfg(feature = "tauri-adapter")] pub use adapters::TauriTransportAdapter; diff --git a/src/crates/transport/src/traits.rs b/src/crates/transport/src/traits.rs index c3a4abd0..8dbf1bbd 100644 --- a/src/crates/transport/src/traits.rs +++ b/src/crates/transport/src/traits.rs @@ -4,33 +4,50 @@ /// - CLI (tokio::mpsc channels) /// - Tauri (app.emit events) /// - WebSocket/SSE (web server) - use async_trait::async_trait; +use bitfun_events::AgenticEvent; use serde::{Deserialize, Serialize}; use std::fmt::Debug; -use bitfun_events::AgenticEvent; /// Transport adapter trait - All platforms must implement this interface #[async_trait] pub trait TransportAdapter: Send + Sync + Debug { /// Emit agentic event to frontend async fn emit_event(&self, session_id: &str, event: AgenticEvent) -> anyhow::Result<()>; - + /// Emit text chunk (streaming output) async fn emit_text_chunk(&self, session_id: &str, chunk: TextChunk) -> anyhow::Result<()>; - + /// Emit tool event - async fn emit_tool_event(&self, session_id: &str, event: ToolEventPayload) -> anyhow::Result<()>; - + async fn emit_tool_event( + &self, + session_id: &str, + event: ToolEventPayload, + ) -> anyhow::Result<()>; + /// Emit stream start event - async fn emit_stream_start(&self, session_id: &str, turn_id: &str, round_id: &str) -> anyhow::Result<()>; - + async fn emit_stream_start( + &self, + session_id: &str, + turn_id: &str, + round_id: &str, + ) -> anyhow::Result<()>; + /// Emit stream end event - async fn emit_stream_end(&self, session_id: &str, turn_id: &str, round_id: &str) -> anyhow::Result<()>; - + async fn emit_stream_end( + &self, + session_id: &str, + turn_id: &str, + round_id: &str, + ) -> anyhow::Result<()>; + /// Emit generic event (supports any event type) - async fn emit_generic(&self, event_name: &str, payload: serde_json::Value) -> anyhow::Result<()>; - + async fn emit_generic( + &self, + event_name: &str, + payload: serde_json::Value, + ) -> anyhow::Result<()>; + /// Get adapter type name fn adapter_type(&self) -> &str; } @@ -82,4 +99,3 @@ pub struct StreamEvent { pub event_type: String, pub payload: serde_json::Value, } - diff --git a/src/mobile-web/src/App.tsx b/src/mobile-web/src/App.tsx index d283a253..4b9c0803 100644 --- a/src/mobile-web/src/App.tsx +++ b/src/mobile-web/src/App.tsx @@ -3,6 +3,7 @@ import PairingPage from './pages/PairingPage'; import WorkspacePage from './pages/WorkspacePage'; import SessionListPage from './pages/SessionListPage'; import ChatPage from './pages/ChatPage'; +import { I18nProvider } from './i18n'; import { RelayHttpClient } from './services/RelayHttpClient'; import { RemoteSessionManager } from './services/RemoteSessionManager'; import { ThemeProvider } from './theme'; @@ -126,7 +127,9 @@ const AppContent: React.FC = () => { const App: React.FC = () => ( - + + + ); diff --git a/src/mobile-web/src/components/LanguageToggleButton.tsx b/src/mobile-web/src/components/LanguageToggleButton.tsx new file mode 100644 index 00000000..26117e14 --- /dev/null +++ b/src/mobile-web/src/components/LanguageToggleButton.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { useI18n } from '../i18n'; + +interface LanguageToggleButtonProps { + className?: string; +} + +const LanguageToggleButton: React.FC = ({ className }) => { + const { language, toggleLanguage, t } = useI18n(); + + return ( + + ); +}; + +export default LanguageToggleButton; + diff --git a/src/mobile-web/src/i18n/I18nProvider.tsx b/src/mobile-web/src/i18n/I18nProvider.tsx new file mode 100644 index 00000000..59c82665 --- /dev/null +++ b/src/mobile-web/src/i18n/I18nProvider.tsx @@ -0,0 +1,137 @@ +import React, { createContext, useCallback, useLayoutEffect, useMemo, useState } from 'react'; +import { DEFAULT_LANGUAGE, messages, type MobileLanguage } from './messages'; + +interface TranslateParams { + [key: string]: string | number; +} + +interface I18nContextValue { + language: MobileLanguage; + setLanguage: (language: MobileLanguage) => void; + toggleLanguage: () => void; + t: (key: string, params?: TranslateParams) => string; +} + +const STORAGE_KEY = 'bitfun-mobile-language'; + +function isLanguage(value: string | null | undefined): value is MobileLanguage { + return value === 'zh-CN' || value === 'en-US'; +} + +function getByPath(source: unknown, path: string): string | null { + const segments = path.split('.'); + let current: unknown = source; + + for (const segment of segments) { + if (!current || typeof current !== 'object' || !(segment in current)) { + return null; + } + current = (current as Record)[segment]; + } + + return typeof current === 'string' ? current : null; +} + +function interpolate(template: string, params?: TranslateParams): string { + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (_, key: string) => { + const value = params[key]; + return value == null ? '' : String(value); + }); +} + +export function translate(language: MobileLanguage, key: string, params?: TranslateParams): string { + const template = getByPath(messages[language], key) + ?? getByPath(messages[DEFAULT_LANGUAGE], key) + ?? key; + return interpolate(template, params); +} + +function detectInitialLanguage(): MobileLanguage { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (isLanguage(stored)) return stored; + } catch { + // ignore storage failures + } + + const urlLanguage = detectLanguageFromUrl(); + if (urlLanguage) return urlLanguage; + + const browserLanguage = navigator.language?.toLowerCase() || ''; + if (browserLanguage.startsWith('zh')) return 'zh-CN'; + return DEFAULT_LANGUAGE; +} + +function detectLanguageFromUrl(): MobileLanguage | null { + const candidates: Array = []; + + try { + candidates.push(new URLSearchParams(window.location.search).get('lang')); + } catch { + // ignore malformed search params + } + + const hash = window.location.hash || ''; + const hashQueryIndex = hash.indexOf('?'); + if (hashQueryIndex >= 0) { + try { + const hashQuery = hash.slice(hashQueryIndex + 1); + candidates.push(new URLSearchParams(hashQuery).get('lang')); + } catch { + // ignore malformed hash params + } + } + + for (const candidate of candidates) { + if (isLanguage(candidate)) { + return candidate; + } + } + + return null; +} + +export const I18nContext = createContext({ + language: DEFAULT_LANGUAGE, + setLanguage: () => {}, + toggleLanguage: () => {}, + t: (key) => key, +}); + +export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [language, setLanguageState] = useState(detectInitialLanguage); + + useLayoutEffect(() => { + document.documentElement.lang = language; + try { + localStorage.setItem(STORAGE_KEY, language); + } catch { + // ignore storage failures + } + }, [language]); + + const setLanguage = useCallback((nextLanguage: MobileLanguage) => { + setLanguageState(nextLanguage); + }, []); + + const toggleLanguage = useCallback(() => { + setLanguageState((prev) => (prev === 'zh-CN' ? 'en-US' : 'zh-CN')); + }, []); + + const value = useMemo(() => ({ + language, + setLanguage, + toggleLanguage, + t: (key, params) => translate(language, key, params), + }), [language, setLanguage, toggleLanguage]); + + return ( + + {children} + + ); +}; + +export type { MobileLanguage, TranslateParams }; + diff --git a/src/mobile-web/src/i18n/index.ts b/src/mobile-web/src/i18n/index.ts new file mode 100644 index 00000000..9ee132a6 --- /dev/null +++ b/src/mobile-web/src/i18n/index.ts @@ -0,0 +1,4 @@ +export { I18nProvider, translate } from './I18nProvider'; +export type { MobileLanguage, TranslateParams } from './I18nProvider'; +export { useI18n } from './useI18n'; + diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts new file mode 100644 index 00000000..6e486b9e --- /dev/null +++ b/src/mobile-web/src/i18n/messages.ts @@ -0,0 +1,264 @@ +export type MobileLanguage = 'zh-CN' | 'en-US'; + +type MessageLeaf = string; +type MessageTree = { [key: string]: MessageLeaf | MessageTree }; + +export const DEFAULT_LANGUAGE: MobileLanguage = 'en-US'; + +export const messages: Record = { + 'en-US': { + common: { + appName: 'BitFun Remote', + back: 'Back', + continue: 'Continue', + cancel: 'Cancel', + switch: 'Switch', + workspace: 'Workspace', + userId: 'User ID', + loading: 'Loading...', + language: 'Language', + switchLanguage: 'Switch language', + toggleTheme: 'Toggle theme', + attachImage: 'Attach image', + stop: 'Stop', + submit: 'Submit', + submitting: 'Submitting...', + submitted: 'Submitted', + other: 'Other', + customTextInput: 'Custom text input', + typeYourAnswer: 'Type your answer...', + itemCount: '{count} items', + justNow: 'just now', + minutesAgo: '{count}m ago', + hoursAgo: '{count}h ago', + daysAgo: '{count}d ago', + }, + pairing: { + enterUserIdToContinue: 'Enter your user ID to continue', + connectingAndPairing: 'Connecting and pairing...', + pairedLoadingSessions: 'Paired! Loading sessions...', + connectionError: 'Connection error', + invalidQrCode: 'Invalid QR code: missing room or public key', + userIdRequired: 'User ID is required', + tooManyAttempts: 'Too many failed attempts. Try again in {seconds}s.', + pairingFailed: 'Pairing failed', + fieldLabel: 'User ID', + placeholder: 'Enter a user ID', + note: 'The first successful connection binds this URL to your user ID for the current remote session.', + connecting: 'Connecting...', + retryIn: 'Retry in {seconds}s', + continue: 'Continue', + }, + sessions: { + remoteCockpit: 'Remote cockpit', + workspace: 'Workspace', + switchWorkspace: 'Switch workspace', + noWorkspaceSelected: 'No workspace selected', + launch: 'Launch', + startRemoteFlow: 'Start a new remote flow', + codeSession: 'Code Session', + codeSessionDesc: 'For coding anywhere, anytime.', + coworkSession: 'Cowork Session', + coworkSessionDesc: 'For assisting with everyday work.', + recent: 'Recent', + sessionHistory: 'Session history', + loadingSessions: 'Loading sessions...', + noSessions: 'No sessions yet. Create one to get started.', + untitledSession: 'Untitled Session', + loadingMore: 'Loading more...', + remoteCodeSession: 'Remote Code Session', + remoteCoworkSession: 'Remote Cowork Session', + agentCode: 'Code', + agentCowork: 'Cowork', + agentDefault: 'Default', + pullToRefresh: 'Pull to refresh', + }, + workspace: { + title: 'Workspace', + loadingInfo: 'Loading workspace info...', + currentWorkspace: 'Current Workspace', + unknownProject: 'Unknown Project', + noWorkspaceOpen: 'No workspace is currently open on the desktop.', + noWorkspaceHint: 'Select a recent workspace below, or open one on the desktop first.', + selectWorkspace: 'Select Workspace', + recentWorkspaces: 'Recent Workspaces', + noRecentWorkspaces: 'No recent workspaces found. Please open a workspace on the desktop first.', + failedToSetWorkspace: 'Failed to set workspace', + openingWorkspace: 'Opening workspace...', + }, + chat: { + session: 'Session', + loadingOlderMessages: 'Loading older messages...', + showResponse: 'Show response', + hideResponse: 'Hide response', + analyzingImage: 'Analyzing image with image understanding model...', + inputPlaceholder: 'How can I help you...', + workingPlaceholder: 'BitFun is working...', + imageAnalyzingPlaceholder: 'Analyzing image...', + imageAttachmentFallback: '(see attached images)', + askQuestionCount: '{count} question{suffix}', + waiting: 'Waiting', + modeAgentic: 'Agentic', + modePlan: 'Plan', + modeDebug: 'Debug', + thinking: 'Thinking...', + allTasksCompleted: 'All tasks completed', + task: 'Task', + toolCalls: '{count} tool call{suffix}', + done: '{count} done', + running: '{count} running', + thoughtCharacters: 'Thought {count} characters', + textCharacters: 'Text {count} characters', + readToolsDone: '{summary}', + readToolsRunning: '{summary} ({doneCount} done)', + fileLoading: 'Loading...', + fileUnavailable: 'File unavailable', + fileDownloading: 'Downloading...', + fileDownloaded: 'Downloaded', + clickToDownload: 'Click to download', + }, + tools: { + explore: 'Explore', + read: 'Read', + write: 'Write', + ls: 'LS', + shell: 'Shell', + glob: 'Glob', + grep: 'Grep', + delete: 'Delete', + task: 'Task', + search: 'Search', + edit: 'Edit', + web: 'Web', + todo: 'Todo', + }, + }, + 'zh-CN': { + common: { + appName: 'BitFun Remote', + back: '返回', + continue: '继续', + cancel: '取消', + switch: '切换', + workspace: '工作区', + userId: '用户 ID', + loading: '加载中...', + language: '语言', + switchLanguage: '切换语言', + toggleTheme: '切换主题', + attachImage: '添加图片', + stop: '停止', + submit: '提交', + submitting: '提交中...', + submitted: '已提交', + other: '其他', + customTextInput: '自定义输入', + typeYourAnswer: '请输入你的回答...', + itemCount: '{count} 项', + justNow: '刚刚', + minutesAgo: '{count} 分钟前', + hoursAgo: '{count} 小时前', + daysAgo: '{count} 天前', + }, + pairing: { + enterUserIdToContinue: '请输入你的用户 ID 继续', + connectingAndPairing: '正在连接并配对...', + pairedLoadingSessions: '配对成功,正在加载会话...', + connectionError: '连接异常', + invalidQrCode: '二维码无效:缺少 room 或 public key', + userIdRequired: '用户 ID 不能为空', + tooManyAttempts: '失败次数过多,请在 {seconds} 秒后重试。', + pairingFailed: '配对失败', + fieldLabel: '用户 ID', + placeholder: '请输入用户 ID', + note: '首次成功连接后,本次远程会话会把该 URL 绑定到你的用户 ID。', + connecting: '连接中...', + retryIn: '{seconds} 秒后重试', + continue: '继续', + }, + sessions: { + remoteCockpit: 'Remote cockpit', + workspace: '工作区', + switchWorkspace: '切换工作区', + noWorkspaceSelected: '未选择工作区', + launch: '启动', + startRemoteFlow: '开始一个新的远程流程', + codeSession: '代码会话', + codeSessionDesc: '随时随地进行编码。', + coworkSession: '协作会话', + coworkSessionDesc: '处理日常协作与办公任务。', + recent: '最近', + sessionHistory: '会话历史', + loadingSessions: '正在加载会话...', + noSessions: '还没有会话,先创建一个开始吧。', + untitledSession: '未命名会话', + loadingMore: '正在加载更多...', + remoteCodeSession: '远程代码会话', + remoteCoworkSession: '远程协作会话', + agentCode: '代码', + agentCowork: '协作', + agentDefault: '默认', + pullToRefresh: '下拉刷新', + }, + workspace: { + title: '工作区', + loadingInfo: '正在加载工作区信息...', + currentWorkspace: '当前工作区', + unknownProject: '未知项目', + noWorkspaceOpen: '桌面端当前没有打开工作区。', + noWorkspaceHint: '你可以在下方选择最近工作区,或先在桌面端打开一个工作区。', + selectWorkspace: '选择工作区', + recentWorkspaces: '最近工作区', + noRecentWorkspaces: '没有找到最近工作区,请先在桌面端打开一个工作区。', + failedToSetWorkspace: '设置工作区失败', + openingWorkspace: '正在打开工作区...', + }, + chat: { + session: '会话', + loadingOlderMessages: '正在加载更早的消息...', + showResponse: '展开回复', + hideResponse: '收起回复', + analyzingImage: '正在使用图像理解模型分析图片...', + inputPlaceholder: '我可以帮你做什么...', + workingPlaceholder: 'BitFun 正在处理中...', + imageAnalyzingPlaceholder: '正在分析图片...', + imageAttachmentFallback: '(见附带图片)', + askQuestionCount: '{count} 个问题', + waiting: '等待中', + modeAgentic: '智能代理', + modePlan: '规划', + modeDebug: '调试', + thinking: '思考中...', + allTasksCompleted: '所有任务已完成', + task: '任务', + toolCalls: '{count} 次工具调用', + done: '已完成 {count}', + running: '运行中 {count}', + thoughtCharacters: '思考 {count} 个字符', + textCharacters: '文本 {count} 个字符', + readToolsDone: '{summary}', + readToolsRunning: '{summary}(已完成 {doneCount})', + fileLoading: '加载中...', + fileUnavailable: '文件不可用', + fileDownloading: '下载中...', + fileDownloaded: '已下载', + clickToDownload: '点击下载', + }, + tools: { + explore: '探索', + read: '读取', + write: '写入', + ls: '列表', + shell: 'Shell', + glob: 'Glob', + grep: 'Grep', + delete: '删除', + task: '任务', + search: '搜索', + edit: '编辑', + web: '网络', + todo: '待办', + }, + }, +}; + diff --git a/src/mobile-web/src/i18n/useI18n.ts b/src/mobile-web/src/i18n/useI18n.ts new file mode 100644 index 00000000..f2d430df --- /dev/null +++ b/src/mobile-web/src/i18n/useI18n.ts @@ -0,0 +1,7 @@ +import { useContext } from 'react'; +import { I18nContext } from './I18nProvider'; + +export function useI18n() { + return useContext(I18nContext); +} + diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 290d95a9..df27b381 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { useI18n } from '../i18n'; import { RemoteSessionManager, SessionPoller, @@ -98,7 +99,7 @@ const CODE_FILE_EXTENSIONS = new Set([ 'c', 'cpp', 'cc', 'cxx', 'h', 'hpp', 'hxx', 'hh', 'cs', 'rb', 'php', 'swift', 'vue', 'svelte', - 'html', 'htm', 'css', 'scss', 'less', 'sass', + 'css', 'scss', 'less', 'sass', 'json', 'jsonc', 'yaml', 'yml', 'toml', 'xml', 'md', 'mdx', 'rst', 'txt', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', @@ -235,6 +236,7 @@ interface FileCardProps { const FileCard: React.FC = ({ path, onGetFileInfo, onDownload }) => { const { isDark } = useTheme(); + const { t } = useI18n(); const [state, setState] = useState({ status: 'loading' }); const onGetFileInfoRef = useRef(onGetFileInfo); onGetFileInfoRef.current = onGetFileInfo; @@ -289,7 +291,7 @@ const FileCard: React.FC = ({ path, onGetFileInfo, onDownload }) return ( - Loading… + {t('chat.fileLoading')} ); } @@ -297,7 +299,7 @@ const FileCard: React.FC = ({ path, onGetFileInfo, onDownload }) return ( - File unavailable + {t('chat.fileUnavailable')} ); } @@ -314,7 +316,7 @@ const FileCard: React.FC = ({ path, onGetFileInfo, onDownload }) role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleClick(); }} - title={isDownloading ? 'Downloading…' : isDone ? 'Downloaded' : 'Click to download'} + title={isDownloading ? t('chat.fileDownloading') : isDone ? t('chat.fileDownloaded') : t('chat.clickToDownload')} > @@ -514,6 +516,7 @@ const MarkdownContent: React.FC = ({ content, onFileDownlo // ─── Thinking (ModelThinkingDisplay-style) ─────────────────────────────────── const ThinkingBlock: React.FC<{ thinking: string; streaming?: boolean }> = ({ thinking, streaming }) => { + const { t } = useI18n(); const [open, setOpen] = useState(false); const wrapperRef = useRef(null); const [scrollState, setScrollState] = useState({ atTop: true, atBottom: true }); @@ -531,8 +534,8 @@ const ThinkingBlock: React.FC<{ thinking: string; streaming?: boolean }> = ({ th const charCount = thinking.length; const label = streaming && charCount === 0 - ? 'Thinking...' - : `Thought ${charCount} characters`; + ? t('chat.thinking') + : t('chat.thoughtCharacters', { count: charCount }); return (
@@ -567,25 +570,26 @@ const ThinkingBlock: React.FC<{ thinking: string; streaming?: boolean }> = ({ th // ─── Tool Card ────────────────────────────────────────────────────────────── const TOOL_TYPE_MAP: Record = { - explore: 'Explore', - read_file: 'Read', - write_file: 'Write', - list_directory: 'LS', - bash: 'Shell', - glob: 'Glob', - grep: 'Grep', - create_file: 'Write', - delete_file: 'Delete', - Task: 'Task', - search: 'Search', - edit_file: 'Edit', - web_search: 'Web', - TodoWrite: 'Todo', + explore: 'tools.explore', + read_file: 'tools.read', + write_file: 'tools.write', + list_directory: 'tools.ls', + bash: 'tools.shell', + glob: 'tools.glob', + grep: 'tools.grep', + create_file: 'tools.write', + delete_file: 'tools.delete', + Task: 'tools.task', + search: 'tools.search', + edit_file: 'tools.edit', + web_search: 'tools.web', + TodoWrite: 'tools.todo', }; // ─── TodoWrite card ───────────────────────────────────────────────────────── const TodoCard: React.FC<{ tool: RemoteToolStatus }> = ({ tool }) => { + const { t } = useI18n(); const [expanded, setExpanded] = useState(false); const todos: { id?: string; content: string; status: string }[] = useMemo(() => { @@ -623,7 +627,7 @@ const TodoCard: React.FC<{ tool: RemoteToolStatus }> = ({ tool }) => { {allDone && !expanded ? ( - All tasks completed + {t('chat.allTasksCompleted')} ) : inProgress && !expanded ? ( {inProgress.content} ) : null} @@ -671,10 +675,13 @@ function parseTaskInfo(tool: RemoteToolStatus): { description?: string; agentTyp /** * Summarize a subItem for display inside a Task card. */ -function subItemLabel(item: ChatMessageItem): string { +function subItemLabel( + item: ChatMessageItem, + t: (key: string, params?: Record) => string, +): string { if (item.type === 'thinking') { const len = (item.content || '').length; - return `Thought ${len} characters`; + return t('chat.thoughtCharacters', { count: len }); } if (item.type === 'tool' && item.tool) { const t = item.tool; @@ -683,7 +690,7 @@ function subItemLabel(item: ChatMessageItem): string { } if (item.type === 'text') { const len = (item.content || '').length; - return `Text ${len} characters`; + return t('chat.textCharacters', { count: len }); } return ''; } @@ -694,6 +701,7 @@ const TaskToolCard: React.FC<{ subItems?: ChatMessageItem[]; onCancelTool?: (toolId: string) => void; }> = ({ tool, now, subItems = [], onCancelTool }) => { + const { t } = useI18n(); const scrollRef = useRef(null); const prevCountRef = useRef(0); const [stepsExpanded, setStepsExpanded] = useState(false); @@ -741,7 +749,7 @@ const TaskToolCard: React.FC<{ )} - {taskInfo?.description || 'Task'} + {taskInfo?.description || t('chat.task')} {taskInfo?.agentType && ( {taskInfo.agentType} @@ -753,7 +761,7 @@ const TaskToolCard: React.FC<{ )} {isOtherSelected && ( setCustomTexts(prev => ({ ...prev, [qIdx]: e.target.value }))} disabled={submitted || submitting} @@ -1310,7 +1329,7 @@ const AskQuestionCard: React.FC = ({ tool, onAnswer }) => onClick={handleSubmit} > - {submitted ? 'Submitted' : submitting ? 'Submitting...' : 'Submit'} + {submitted ? t('common.submitted') : submitting ? t('common.submitting') : t('common.submit')}
); @@ -1552,15 +1571,10 @@ const ThemeToggleIcon: React.FC<{ isDark: boolean }> = ({ isDark }) => ( type AgentMode = 'agentic' | 'Plan' | 'debug'; -const MODE_OPTIONS: { id: AgentMode; label: string }[] = [ - { id: 'agentic', label: 'Agentic' }, - { id: 'Plan', label: 'Plan' }, - { id: 'debug', label: 'Debug' }, -]; - // ─── ChatPage ─────────────────────────────────────────────────────────────── const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, onBack, autoFocus }) => { + const { t } = useI18n(); const { getMessages, setMessages, @@ -1574,6 +1588,11 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, } = useMobileStore(); const { isDark, toggleTheme } = useTheme(); + const modeOptions: { id: AgentMode; label: string }[] = useMemo(() => ([ + { id: 'agentic', label: t('chat.modeAgentic') }, + { id: 'Plan', label: t('chat.modePlan') }, + { id: 'debug', label: t('chat.modeDebug') }, + ]), [t]); const messages = getMessages(sessionId); const [input, setInput] = useState(''); const [agentMode, setAgentMode] = useState('agentic'); @@ -1821,7 +1840,12 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, } try { - await sessionMgr.sendMessage(sessionId, text || '(see attached images)', agentMode, imageContexts); + await sessionMgr.sendMessage( + sessionId, + text || t('chat.imageAttachmentFallback'), + agentMode, + imageContexts, + ); pollerRef.current?.nudge(); } catch (e: any) { setError(e.message); @@ -1829,7 +1853,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, setImageAnalyzing(false); setOptimisticMsg(null); } - }, [input, pendingImages, isStreaming, sessionId, sessionMgr, setError, agentMode]); + }, [agentMode, imageAnalyzing, input, isStreaming, pendingImages, sessionId, sessionMgr, setError, t]); const handleImageSelect = useCallback(() => { fileInputRef.current?.click(); @@ -1927,14 +1951,14 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, const workspaceName = currentWorkspace?.project_name || currentWorkspace?.path?.split('/').pop() || ''; const gitBranch = currentWorkspace?.git_branch; - const displayName = liveTitle || sessionName || 'Session'; + const displayName = liveTitle || sessionName || t('chat.session'); return (
{/* Header */}
-
-
@@ -1964,7 +1988,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, {/* Messages */}
{isLoadingMore && ( -
Loading older messages…
+
{t('chat.loadingOlderMessages')}
)} {(() => { @@ -2030,7 +2054,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, - Show response + {t('chat.showResponse')}
); @@ -2052,7 +2076,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, - Hide response + {t('chat.hideResponse')} )} {hasItems ? ( @@ -2105,7 +2129,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, ] : []; const onCancel = (toolId: string) => { - sessionMgr.cancelTool(toolId, 'User cancelled').catch(err => { setError(String(err)); }); + sessionMgr.cancelTool(toolId, t('common.cancel')).catch(err => { setError(String(err)); }); }; return ( @@ -2178,7 +2202,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName,
- Analyzing image with image understanding model... + {t('chat.analyzingImage')}
@@ -2211,7 +2235,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName,