diff --git a/Cargo.lock b/Cargo.lock index e6828553..c50fc8dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -909,7 +909,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1117,7 +1117,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2409,7 +2409,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2562,9 +2562,9 @@ dependencies = [ [[package]] name = "langgraph" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b925fdc6a07842f54e4426e33185e8f7d16c80574df819ceb4d6f8bee8111615" +checksum = "1ad5fb1669d00eccb11533ac6f89ba17d1f9e256a2f4f49fe016d7367e281e33" dependencies = [ "async-trait", "dotenvy", @@ -2583,9 +2583,9 @@ dependencies = [ [[package]] name = "langgraph-checkpoint-rs" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c5f0b99933e702eaf866532674ded639c48c74f827fa4fe13f36f62aac69af" +checksum = "d4c17b5fd19b488b2e2cf8910a63673fdf31f0e7efee1c295918b4d361c2f8a6" dependencies = [ "async-trait", "chrono", @@ -2602,9 +2602,9 @@ dependencies = [ [[package]] name = "langgraph-checkpoint-sqlite-rs" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f42b44e047d9a154f3468388f26bc8e24a82fee5754b6b3c95f6f61ac58aceb7" +checksum = "496be498690be3a0e500d28236c44a0c2d113f540b39ce47d75dfea48f7424ac" dependencies = [ "async-trait", "langgraph-checkpoint-rs", @@ -2618,9 +2618,9 @@ dependencies = [ [[package]] name = "langgraph-core-rs" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b093199fc4fcbfe719a8e9520904a7605c612873a11bbef142acd6da816bf67e" +checksum = "b7896b19fc4eb63a649450538220c40a01b61cfad2c858f5e7f480853774847d" dependencies = [ "async-trait", "chrono", @@ -2637,9 +2637,9 @@ dependencies = [ [[package]] name = "langgraph-derive" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "905351183a865abd688e2eb2082718d85d9c68ef6ddc6072470e686d11c34932" +checksum = "addb7aef2048aa86994b28a1e4f21577377ba4057abb47192de1e413faa42d19" dependencies = [ "proc-macro2", "quote", @@ -2648,9 +2648,9 @@ dependencies = [ [[package]] name = "langgraph-prebuilt" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b81cc1770264db55f009ff284f5184822c40da1bd608e0dba29ef3628d11df" +checksum = "d56329bf94ca66dae598f0076b609663131505800cd0bf5e4c8a106b57237190" dependencies = [ "async-stream", "async-trait", @@ -2666,9 +2666,9 @@ dependencies = [ [[package]] name = "langgraph-providers" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "717f701cb7e3c13d1f0daa1d1729d7cd555760ca059874910c0a28d63130e082" +checksum = "64a09678ab6b4abc5bc4cadc299a8fcc0cc2436f458b7452c700bd61cbe44c99" dependencies = [ "async-stream", "async-trait", @@ -3409,7 +3409,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.45.0", ] [[package]] @@ -3827,7 +3827,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.59.0", ] [[package]] @@ -4289,7 +4289,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5623,7 +5623,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6574,7 +6574,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4aa79908..5684b9fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ flock-agent = { path = "crates/flock-agent" } flock-workflow = { path = "crates/flock-workflow" } # LangGraph -langgraph = { version = "0.2.4", features = ["prebuilt", "providers", "sqlite"] } +langgraph = { version = "0.2.5", features = ["prebuilt", "providers", "sqlite"] } tokio = { version = "1", features = ["full"] } tokio-stream = "0.1" diff --git a/crates/flock-agent/src/agent_setup_helpers.rs b/crates/flock-agent/src/agent_setup_helpers.rs index a7e11d71..3af1b521 100644 --- a/crates/flock-agent/src/agent_setup_helpers.rs +++ b/crates/flock-agent/src/agent_setup_helpers.rs @@ -21,7 +21,7 @@ use super::sinks::OutputSink; /// when the global sandbox is configured. pub async fn filter_sandbox_tools(config: &Config, registry: &mut ToolRegistry) { let is_sandbox_configured = if let Some(db) = &config.db_manager { - flock_tools::daytona::get_sandbox_config(db).await.is_some() + flock_tools::sandbox_core::config::get_sandbox_config(db).await.is_some() } else { false }; diff --git a/crates/flock-core/src/config/settings/types.rs b/crates/flock-core/src/config/settings/types.rs index 015b4a0f..74c6c742 100644 --- a/crates/flock-core/src/config/settings/types.rs +++ b/crates/flock-core/src/config/settings/types.rs @@ -147,13 +147,24 @@ impl Default for SessionConfig { pub struct SandboxConfig { #[serde(default)] pub enabled: bool, + #[serde(default = "default_sandbox_provider")] + pub provider: Option, pub api_url: Option, pub api_key: Option, pub api_key_encrypted: Option, pub api_key_nonce: Option, + pub e2b_api_key: Option, + pub e2b_api_key_encrypted: Option, + pub e2b_api_key_nonce: Option, + pub e2b_api_url: Option, pub snapshot: Option, } +pub fn default_sandbox_provider() -> Option { + Some("e2b".to_string()) +} + + // --- Default value functions --- pub fn default_provider() -> String { "anthropic".to_string() diff --git a/crates/flock-tools/src/lib.rs b/crates/flock-tools/src/lib.rs index af9035b4..adae2954 100644 --- a/crates/flock-tools/src/lib.rs +++ b/crates/flock-tools/src/lib.rs @@ -4,10 +4,11 @@ pub mod adapter; pub mod file_cache; pub mod tool_executor; -pub mod daytona; +pub mod sandbox_core; pub mod registry; pub mod tools; + pub use tools::{baidu, builtin, google, math, mcp, openweather, sandbox, serper}; /// Snapshot of all registered tools and their provider metadata. diff --git a/crates/flock-tools/src/daytona/config.rs b/crates/flock-tools/src/sandbox_core/config.rs similarity index 57% rename from crates/flock-tools/src/daytona/config.rs rename to crates/flock-tools/src/sandbox_core/config.rs index 51419a81..9ac4b8f5 100644 --- a/crates/flock-tools/src/daytona/config.rs +++ b/crates/flock-tools/src/sandbox_core/config.rs @@ -4,6 +4,8 @@ use flock_core::config::settings::SandboxConfig; /// 获取当前启用的沙盒配置。若未启用或未配置,则返回 None。 pub async fn get_sandbox_config(db: &DbManager) -> Option { let mut cfg: SandboxConfig = db.get_config("sandbox").await?; + + // Decrypt Daytona key if let (Some(ct), Some(n)) = (&cfg.api_key_encrypted, &cfg.api_key_nonce) { if let Ok(salt) = db.get_or_create_salt().await { if let Ok(decrypted) = flock_core::crypto::decrypt_value(ct, n, &salt) { @@ -11,10 +13,38 @@ pub async fn get_sandbox_config(db: &DbManager) -> Option { } } } - if cfg.enabled && cfg.api_url.is_some() && cfg.api_key.is_some() { - Some(cfg) - } else { - None + + // Decrypt E2B key + if let (Some(ct), Some(n)) = (&cfg.e2b_api_key_encrypted, &cfg.e2b_api_key_nonce) { + if let Ok(salt) = db.get_or_create_salt().await { + if let Ok(decrypted) = flock_core::crypto::decrypt_value(ct, n, &salt) { + cfg.e2b_api_key = Some(decrypted); + } + } + } + + if !cfg.enabled { + return None; + } + + let provider = cfg.provider.as_deref().unwrap_or("e2b"); + match provider { + "e2b" => { + if cfg.e2b_api_key.is_some() { + Some(cfg) + } else { + None + } + } + "daytona" => { + if cfg.api_url.is_some() && cfg.api_key.is_some() { + Some(cfg) + } else { + None + } + } + "local" => Some(cfg), + _ => None, } } diff --git a/crates/flock-tools/src/daytona/constants.rs b/crates/flock-tools/src/sandbox_core/daytona/constants.rs similarity index 100% rename from crates/flock-tools/src/daytona/constants.rs rename to crates/flock-tools/src/sandbox_core/daytona/constants.rs diff --git a/crates/flock-tools/src/daytona/exec.rs b/crates/flock-tools/src/sandbox_core/daytona/exec.rs similarity index 87% rename from crates/flock-tools/src/daytona/exec.rs rename to crates/flock-tools/src/sandbox_core/daytona/exec.rs index acd7b0cc..b34f4bab 100644 --- a/crates/flock-tools/src/daytona/exec.rs +++ b/crates/flock-tools/src/sandbox_core/daytona/exec.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; use flock_core::db::DbManager; -use crate::daytona::config::get_sandbox_config; +use crate::sandbox_core::config::get_sandbox_config; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DaytonaSandboxResponse { @@ -22,26 +22,24 @@ struct ExecuteResponse { pub exit_code: Option, } -/// 在沙盒中执行指令,带有端点兼容重试机制 +/// 在 Daytona 沙盒中执行指令(通过 Toolbox API),带端点兼容重试机制 pub async fn execute_command_in_sandbox( db: &DbManager, sandbox_id: &str, command: &str, ) -> anyhow::Result<(String, i32)> { let cfg = get_sandbox_config(db).await - .ok_or_else(|| anyhow::anyhow!("云端 Daytona 沙箱未配置或未启用"))?; + .ok_or_else(|| anyhow::anyhow!("Daytona 沙箱未配置或未启用"))?; let api_url = cfg.api_url.as_ref().unwrap().trim_end_matches('/'); let api_key = cfg.api_key.as_ref().unwrap(); - - // 生成 Toolbox 执行请求 of URL list + let urls = if api_url.contains("app.daytona.io") { vec!( format!("https://proxy.app.daytona.io/toolbox/{}/toolbox/process/execute", sandbox_id), format!("https://proxy.app.daytona.io/toolbox/{}/process/execute", sandbox_id), ) } else { - // 自建模式 let base = api_url.trim_end_matches("/api").trim_end_matches("/"); vec!( format!("{}/toolbox/{}/toolbox/process/execute", base, sandbox_id), @@ -80,12 +78,10 @@ pub async fn execute_command_in_sandbox( return Err(anyhow::anyhow!("解析执行响应失败。原始响应体: {}", resp_text)); } } else if status == reqwest::StatusCode::NOT_FOUND { - // 如果 404,我们尝试下一个候选 endpoint last_error = Some(anyhow::anyhow!("Toolbox API 返回 404: {}", url)); continue; } else if status.is_server_error() || status.as_u16() == 502 || status.as_u16() == 503 { last_error = Some(anyhow::anyhow!("Toolbox API 响应服务端错误 ({}): {}", url, status)); - // 可能是沙盒内的 agent 还没完全起来,继续重试 } else { let err_body = resp.text().await.unwrap_or_default(); return Err(anyhow::anyhow!("Toolbox API 响应失败 ({}): {}", url, err_body)); @@ -96,8 +92,7 @@ pub async fn execute_command_in_sandbox( } } } - - // 如果不是最后一次尝试,等待一小段时间再重试 + if attempt < max_retries { tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; } diff --git a/crates/flock-tools/src/daytona/fs.rs b/crates/flock-tools/src/sandbox_core/daytona/fs.rs similarity index 98% rename from crates/flock-tools/src/daytona/fs.rs rename to crates/flock-tools/src/sandbox_core/daytona/fs.rs index 6bf3fd8e..ef44c80f 100644 --- a/crates/flock-tools/src/daytona/fs.rs +++ b/crates/flock-tools/src/sandbox_core/daytona/fs.rs @@ -1,5 +1,6 @@ use flock_core::db::DbManager; -use crate::daytona::{execute_command_in_sandbox, get_or_create_active_sandbox}; +use crate::sandbox_core::daytona::execute_command_in_sandbox; +use crate::sandbox_core::manager::get_or_create_active_sandbox; use serde::{Deserialize, Serialize}; use std::path::{Component, Path}; diff --git a/crates/flock-tools/src/daytona/lifecycle.rs b/crates/flock-tools/src/sandbox_core/daytona/lifecycle.rs similarity index 72% rename from crates/flock-tools/src/daytona/lifecycle.rs rename to crates/flock-tools/src/sandbox_core/daytona/lifecycle.rs index 69bb2689..8d2ef980 100644 --- a/crates/flock-tools/src/daytona/lifecycle.rs +++ b/crates/flock-tools/src/sandbox_core/daytona/lifecycle.rs @@ -1,75 +1,12 @@ use flock_core::db::DbManager; use flock_core::config::settings::SandboxConfig; -use crate::daytona::state::get_sandbox_id_mutex; -use crate::daytona::config::{get_sandbox_config, get_api_base}; -use crate::daytona::volume::get_or_create_volume; - -/// 销毁当前活跃的沙盒 -pub async fn destroy_active_sandbox(db: &DbManager) -> anyhow::Result<()> { - let cfg = match get_sandbox_config(db).await { - Some(c) => c, - None => return Ok(()), - }; - - let mutex = get_sandbox_id_mutex(); - let mut lock = mutex.lock().await; - - let sandbox_id = match lock.as_ref() { - Some(id) => id.clone(), - None => return Ok(()), - }; - - let client = reqwest::Client::new(); - let base = get_api_base(cfg.api_url.as_ref().unwrap()); - let api_key = cfg.api_key.as_ref().unwrap(); - - if let Some(ws_path) = crate::get_workspace_dir() { - if let Err(e) = crate::daytona::sync::sync_down(db, &sandbox_id, &ws_path).await { - crate::emit_info(&format!("Sync Down failed: {}", e)); - } - } - - let del_url = format!("{}/api/sandbox/{}", base, sandbox_id); - match client.delete(&del_url) - .header("Authorization", format!("Bearer {}", api_key)) - .send() - .await - { - Ok(resp) => { - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - crate::emit_info(&flock_core::tr(&format!("销毁沙盒返回非成功状态 (HTTP {}): {}", status, body), &format!("Destroying sandbox returned non-success status (HTTP {}): {}", status, body))); - } - } - Err(e) => { - crate::emit_info(&flock_core::tr(&format!("销毁沙盒请求失败: {}", e), &format!("Destroying sandbox request failed: {}", e))); - } - } +use crate::sandbox_core::config::{get_sandbox_config, get_api_base}; +use crate::sandbox_core::daytona::volume::get_or_create_volume; - *lock = None; - Ok(()) -} - -/// 获取或创建活跃的沙盒 ID -pub async fn get_or_create_active_sandbox(db: &DbManager) -> anyhow::Result { - let cfg = get_sandbox_config(db).await - .ok_or_else(|| anyhow::anyhow!(flock_core::tr("云端 Daytona 沙箱未启用或未配置。请在系统设置中配置有效的 API 地址和密钥。", "Cloud Daytona sandbox not enabled or configured. Please configure a valid API URL and Key in system settings.")))?; - - let mutex = get_sandbox_id_mutex(); - let mut lock = mutex.lock().await; - - if let Some(id) = lock.as_ref() { - if check_sandbox_alive(&cfg, id).await { - let _ = set_sandbox_public(&cfg, id, true).await; - return Ok(id.clone()); - } - crate::emit_info(&flock_core::tr(&format!("Daytona 沙盒 {} 已失效,准备重新创建...", id), &format!("Daytona sandbox {} has expired, preparing to recreate...", id))); - *lock = None; - } +/// 创建 Daytona 沙盒,等待 started 状态后返回 sandbox_id +pub async fn create_sandbox(db: &DbManager, cfg: &SandboxConfig) -> anyhow::Result { + crate::emit_info(&flock_core::tr("正在向云端申请启动 Daytona 沙盒...", "Requesting to start Daytona sandbox from the cloud...")); - crate::emit_info(&flock_core::tr("正在向云端申请启动沙盒...", "Requesting to start sandbox from the cloud...")); - let client = reqwest::Client::new(); let base = get_api_base(cfg.api_url.as_ref().unwrap()); let api_key = cfg.api_key.as_ref().unwrap(); @@ -78,8 +15,7 @@ pub async fn get_or_create_active_sandbox(db: &DbManager) -> anyhow::Result Some(id), Err(e) => { crate::emit_info(&format!("未能创建或获取云端 Volume: {} (将使用临时沙盒存储)", e)); @@ -88,7 +24,7 @@ pub async fn get_or_create_active_sandbox(db: &DbManager) -> anyhow::Result anyhow::Result anyhow::Result anyhow::Result v, Err(e) => anyhow::bail!( @@ -151,7 +84,7 @@ pub async fn get_or_create_active_sandbox(db: &DbManager) -> anyhow::Result anyhow::Result anyhow::Result anyhow::Result anyhow::Result { if code != 0 { crate::emit_info(&flock_core::tr( @@ -254,10 +186,8 @@ pub async fn get_or_create_active_sandbox(db: &DbManager) -> anyhow::Result anyhow::Result anyhow::Result<()> { + let client = reqwest::Client::new(); + let base = get_api_base(cfg.api_url.as_ref().unwrap()); + let api_key = cfg.api_key.as_ref().unwrap(); + + let del_url = format!("{}/api/sandbox/{}", base, sandbox_id); + match client.delete(&del_url) + .header("Authorization", format!("Bearer {}", api_key)) + .send() + .await + { + Ok(resp) => { + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!(flock_core::tr( + &format!("销毁 Daytona 沙盒返回非成功状态 (HTTP {}): {}", status, body), + &format!("Destroying Daytona sandbox returned non-success status (HTTP {}): {}", status, body) + )); + } + } + Err(e) => { + anyhow::bail!(flock_core::tr( + &format!("销毁 Daytona 沙盒请求失败: {}", e), + &format!("Destroying Daytona sandbox request failed: {}", e) + )); + } + } + Ok(()) +} + +/// 检查 Daytona 沙盒是否存活 pub async fn check_sandbox_alive(cfg: &SandboxConfig, id: &str) -> bool { let client = reqwest::Client::new(); let base = get_api_base(cfg.api_url.as_ref().unwrap()); @@ -304,7 +267,7 @@ pub async fn set_sandbox_public( let api_key = cfg.api_key.as_ref().unwrap(); let url = format!("{}/api/sandbox/{}/public/{}", base, sandbox_id, is_public); - + let res = client.post(&url) .header("Authorization", format!("Bearer {}", api_key)) .send() @@ -312,10 +275,6 @@ pub async fn set_sandbox_public( let status = res.status(); if status.is_success() { - // crate::emit_info(&flock_core::tr( - // &format!("Daytona 沙盒 {} 的 public 属性设置成功。", sandbox_id), - // &format!("Daytona sandbox {} public attribute set successfully.", sandbox_id) - // )); Ok(()) } else { let err_body = res.text().await.unwrap_or_default(); diff --git a/crates/flock-tools/src/daytona/mod.rs b/crates/flock-tools/src/sandbox_core/daytona/mod.rs similarity index 55% rename from crates/flock-tools/src/daytona/mod.rs rename to crates/flock-tools/src/sandbox_core/daytona/mod.rs index 9d0eb702..94faeefb 100644 --- a/crates/flock-tools/src/daytona/mod.rs +++ b/crates/flock-tools/src/sandbox_core/daytona/mod.rs @@ -1,5 +1,3 @@ -mod state; -mod config; mod lifecycle; mod exec; mod vnc; @@ -7,10 +5,11 @@ mod snapshot; mod constants; pub mod volume; pub mod fs; +pub mod provider; -pub use state::{get_active_sandbox_id, emit_human_takeover, get_sandbox_id_mutex}; -pub use config::{get_sandbox_config, get_api_base}; -pub use lifecycle::{destroy_active_sandbox, get_or_create_active_sandbox, set_sandbox_public, check_sandbox_alive}; +pub use lifecycle::{ + create_sandbox, destroy_daytona_sandbox, check_sandbox_alive, set_sandbox_public, +}; pub use exec::{execute_command_in_sandbox, DaytonaSandboxResponse}; pub use vnc::{ start_computer_use_in_sandbox, check_computer_use_status, @@ -18,5 +17,3 @@ pub use vnc::{ }; pub use snapshot::create_playwright_snapshot; pub use constants::*; - -pub mod sync; diff --git a/crates/flock-tools/src/sandbox_core/daytona/provider.rs b/crates/flock-tools/src/sandbox_core/daytona/provider.rs new file mode 100644 index 00000000..985266f0 --- /dev/null +++ b/crates/flock-tools/src/sandbox_core/daytona/provider.rs @@ -0,0 +1,132 @@ +use crate::sandbox_core::provider::SandboxProvider; +use flock_core::config::settings::SandboxConfig; +use flock_core::db::DbManager; +use async_trait::async_trait; +use std::path::Path; + +pub struct DaytonaProvider; + +#[async_trait] +impl SandboxProvider for DaytonaProvider { + async fn check_alive(&self, cfg: &SandboxConfig, sandbox_id: &str) -> bool { + crate::sandbox_core::daytona::check_sandbox_alive(cfg, sandbox_id).await + } + async fn create_sandbox(&self, db: &DbManager, cfg: &SandboxConfig) -> anyhow::Result { + crate::sandbox_core::daytona::create_sandbox(db, cfg).await + } + async fn destroy_sandbox(&self, _db: &DbManager, cfg: &SandboxConfig, sandbox_id: &str) -> anyhow::Result<()> { + crate::sandbox_core::daytona::destroy_daytona_sandbox(cfg, sandbox_id).await + } + async fn execute_command(&self, db: &DbManager, _cfg: &SandboxConfig, sandbox_id: &str, command: &str) -> anyhow::Result<(String, i32)> { + crate::sandbox_core::daytona::execute_command_in_sandbox(db, sandbox_id, command).await + } + async fn get_vnc_url(&self, db: &DbManager, _cfg: &SandboxConfig, sandbox_id: &str) -> anyhow::Result { + crate::sandbox_core::daytona::get_sandbox_vnc_url(db, sandbox_id).await + } + async fn ensure_vnc_running(&self, db: &DbManager, _cfg: &SandboxConfig, sandbox_id: &str) -> anyhow::Result<()> { + crate::sandbox_core::daytona::ensure_vnc_running_in_sandbox(db, sandbox_id).await + } + async fn sync_up(&self, db: &DbManager, sandbox_id: &str, ws_path: &Path) -> anyhow::Result<()> { + crate::sandbox_core::sync::sync_up(db, sandbox_id, ws_path).await + } + async fn sync_down(&self, db: &DbManager, sandbox_id: &str, ws_path: &Path) -> anyhow::Result<()> { + crate::sandbox_core::sync::sync_down(db, sandbox_id, ws_path).await + } + fn get_workspace_dir(&self) -> &str { + "/workspace" + } + async fn list_templates(&self, _db: &DbManager, cfg: &SandboxConfig) -> anyhow::Result { + let base = crate::sandbox_core::config::get_api_base(cfg.api_url.as_ref().unwrap()); + let api_key = cfg.api_key.as_ref().unwrap(); + + let client = reqwest::Client::new(); + let url = format!("{}/api/snapshots", base); + let resp = client.get(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .send() + .await?; + + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("Daytona API error ({}): {}", status, text); + } + + let val: serde_json::Value = serde_json::from_str(&text)?; + Ok(val) + } + async fn delete_template(&self, _db: &DbManager, cfg: &SandboxConfig, id: &str) -> anyhow::Result<()> { + let base = crate::sandbox_core::config::get_api_base(cfg.api_url.as_ref().unwrap()); + let api_key = cfg.api_key.as_ref().unwrap(); + + let client = reqwest::Client::new(); + let url = format!("{}/api/snapshots/{}", base, id); + let resp = client.delete(&url) + .header("Authorization", format!("Bearer {}", api_key)) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let err_body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Failed to delete Daytona snapshot: {} - {}", status, err_body); + } + Ok(()) + } + async fn cleanup_all_instances(&self, _db: &DbManager, cfg: &SandboxConfig) -> anyhow::Result { + let base = crate::sandbox_core::config::get_api_base(cfg.api_url.as_ref().unwrap()); + let api_key = cfg.api_key.as_ref().unwrap(); + + let client = reqwest::Client::new(); + let list_url = format!("{}/api/sandbox", base); + let resp = client.get(&list_url) + .header("Authorization", format!("Bearer {}", api_key)) + .send() + .await?; + + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("Failed to list Daytona sandboxes (HTTP {}): {}", status, text); + } + + let val: serde_json::Value = serde_json::from_str(&text)?; + let sandboxes = val.as_array() + .cloned() + .unwrap_or_else(|| { + val.get("items").or_else(|| val.get("data")) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() + }); + + let mut deleted = 0usize; + let mut failed = 0usize; + + for sb in &sandboxes { + let id = sb.get("id").and_then(|v| v.as_str()).unwrap_or_default(); + if id.is_empty() { continue; } + + let state_str = sb.get("state").or_else(|| sb.get("status")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if state_str == "deleted" || state_str == "archived" { continue; } + + let del_url = format!("{}/api/sandbox/{}", base, id); + match client.delete(&del_url) + .header("Authorization", format!("Bearer {}", api_key)) + .send() + .await + { + Ok(r) if r.status().is_success() => deleted += 1, + _ => failed += 1, + } + } + + Ok(flock_core::tr( + &format!("清理完成:已销毁 {} 个沙盒,失败 {} 个。", deleted, failed), + &format!("Cleanup complete: destroyed {} sandboxes, failed {}.", deleted, failed) + )) + } +} diff --git a/crates/flock-tools/src/daytona/snapshot.rs b/crates/flock-tools/src/sandbox_core/daytona/snapshot.rs similarity index 97% rename from crates/flock-tools/src/daytona/snapshot.rs rename to crates/flock-tools/src/sandbox_core/daytona/snapshot.rs index c06e2c1f..2dd20666 100644 --- a/crates/flock-tools/src/daytona/snapshot.rs +++ b/crates/flock-tools/src/sandbox_core/daytona/snapshot.rs @@ -1,6 +1,6 @@ use flock_core::db::DbManager; -use crate::daytona::config::{get_sandbox_config, get_api_base}; -use crate::daytona::{DISPLAY_ID, SCREEN_RESOLUTION, X11VNC_PORT, WEBSOCKIFY_PORT}; +use crate::sandbox_core::config::{get_sandbox_config, get_api_base}; +use crate::sandbox_core::daytona::{DISPLAY_ID, SCREEN_RESOLUTION, X11VNC_PORT, WEBSOCKIFY_PORT}; /// 创建一个预装 Playwright 的 Daytona Snapshot pub async fn create_playwright_snapshot( diff --git a/crates/flock-tools/src/daytona/vnc.rs b/crates/flock-tools/src/sandbox_core/daytona/vnc.rs similarity index 83% rename from crates/flock-tools/src/daytona/vnc.rs rename to crates/flock-tools/src/sandbox_core/daytona/vnc.rs index 2d15f617..de167605 100644 --- a/crates/flock-tools/src/daytona/vnc.rs +++ b/crates/flock-tools/src/sandbox_core/daytona/vnc.rs @@ -1,7 +1,7 @@ use flock_core::db::DbManager; -use crate::daytona::config::{get_sandbox_config, get_api_base}; -use crate::daytona::exec::execute_command_in_sandbox; -use crate::daytona::{DISPLAY_ID, SCREEN_RESOLUTION, X11VNC_PORT, WEBSOCKIFY_PORT}; +use crate::sandbox_core::config::{get_sandbox_config, get_api_base}; +use crate::sandbox_core::daytona::exec::execute_command_in_sandbox; +use crate::sandbox_core::daytona::{DISPLAY_ID, SCREEN_RESOLUTION, X11VNC_PORT, WEBSOCKIFY_PORT}; /// 启动沙盒中的 Computer Use(VNC桌面) pub async fn start_computer_use_in_sandbox( @@ -162,47 +162,7 @@ pub async fn check_computer_use_status( /// 确保沙盒中 VNC 桌面相关进程正在后台运行,具有自愈拉起和 setsid/nohup 防进程清理机制。 pub async fn ensure_vnc_running_in_sandbox(db: &DbManager, sandbox_id: &str) -> anyhow::Result<()> { - // 检查 websockify 是否已经在运行且在指定端口监听 - let check_cmd = format!("python3 -c \"import socket; s = socket.socket(); s.connect(('127.0.0.1', {}))\"", WEBSOCKIFY_PORT); - let (_, exit_code) = execute_command_in_sandbox(db, sandbox_id, &check_cmd).await.unwrap_or(("-1".to_string(), -1)); - if exit_code == 0 { - crate::emit_info(&flock_core::tr("检测到 VNC 桌面服务已经在运行。", "Detected VNC desktop service is already running.")); - return Ok(()); - } - - crate::emit_info(&flock_core::tr("检测到 VNC 服务未运行,手动拉起 Xvfb, VNC, noVNC...", "Detected VNC service not running, manually starting Xvfb, VNC, noVNC...")); - let launch_cmd = format!("sh -c '\ - if command -v start-vnc >/dev/null 2>&1; then \ - start-vnc; \ - else \ - export DISPLAY={display} && \ - rm -f /tmp/.X0-lock && \ - setsid nohup Xvfb {display} -screen 0 {res} >/tmp/xvfb.log 2>&1 & \ - sleep 1 && \ - setsid nohup fluxbox >/tmp/fluxbox.log 2>&1 & \ - sleep 1 && \ - setsid nohup x11vnc -display {display} -forever -shared -nopw -rfbport {vnc_port} >/tmp/x11vnc.log 2>&1 & \ - sleep 1 && \ - setsid nohup websockify --web /usr/share/novnc 0.0.0.0:{web_port} localhost:{vnc_port} >/tmp/websockify.log 2>&1 & \ - sleep 1; \ - fi'", - display = DISPLAY_ID, - res = SCREEN_RESOLUTION, - vnc_port = X11VNC_PORT, - web_port = WEBSOCKIFY_PORT - ); - - let (out, code) = execute_command_in_sandbox(db, sandbox_id, &launch_cmd).await?; - if code != 0 { - crate::emit_info(&flock_core::tr( - &format!("手动拉起桌面服务进程失败 (退出码 {}): {}", code, out), - &format!("Failed to manually start desktop service process (exit code {}): {}", code, out) - )); - } else { - crate::emit_info(&flock_core::tr("手动拉起桌面服务进程指令已发送。", "Manual start desktop service process command sent.")); - } - - Ok(()) + crate::sandbox_core::vnc_helper::ensure_vnc_running_in_sandbox(db, sandbox_id).await } /// 获取沙盒 6080 端口 of Preview URL,并自动转化为 noVNC 控制台 URL。 diff --git a/crates/flock-tools/src/daytona/volume.rs b/crates/flock-tools/src/sandbox_core/daytona/volume.rs similarity index 97% rename from crates/flock-tools/src/daytona/volume.rs rename to crates/flock-tools/src/sandbox_core/daytona/volume.rs index 31b128ee..ceec1bf1 100644 --- a/crates/flock-tools/src/daytona/volume.rs +++ b/crates/flock-tools/src/sandbox_core/daytona/volume.rs @@ -1,5 +1,5 @@ use flock_core::config::settings::SandboxConfig; -use crate::daytona::config::get_api_base; +use crate::sandbox_core::config::get_api_base; use serde_json::Value; pub async fn get_or_create_volume(cfg: &SandboxConfig, workspace_id: &str) -> anyhow::Result { diff --git a/crates/flock-tools/src/sandbox_core/e2b/builder.rs b/crates/flock-tools/src/sandbox_core/e2b/builder.rs new file mode 100644 index 00000000..9f66a2aa --- /dev/null +++ b/crates/flock-tools/src/sandbox_core/e2b/builder.rs @@ -0,0 +1,159 @@ +use anyhow::{Context, Result}; +use flock_core::config::settings::SandboxConfig; +use std::path::PathBuf; +use std::process::Stdio; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::process::Command; + +/// 自动构建 E2B 的专属桌面镜像(自带 VNC 和 Playwright) +/// 构建日志将通过传入的回调函数 (on_log) 实时推送 +pub async fn build_enhanced_template(api_key: &str, api_url: Option<&str>, name: &str, on_log: F) -> Result +where + F: Fn(String) + Send + 'static, +{ + let builder_dir = std::env::temp_dir().join("flock_e2b_builder"); + + if !builder_dir.exists() { + std::fs::create_dir_all(&builder_dir) + .context("Failed to create e2b_builder directory")?; + } + + let dockerfile_path = builder_dir.join("Dockerfile"); + let start_script_path = builder_dir.join("start_command.sh"); + let toml_path = builder_dir.join("e2b.toml"); + + // 删除残留的 e2b.toml,避免干扰新的构建 + if toml_path.exists() { + let _ = std::fs::remove_file(&toml_path); + } + + // 写入 start_command.sh 启动脚本 + let start_script_content = r#"#!/bin/bash +echo "=== Debug: Checking installed commands ===" +which Xvfb || echo "Xvfb missing" +which fluxbox || echo "fluxbox missing" +which x11vnc || echo "x11vnc missing" +which websockify || echo "websockify missing" +echo "==========================================" + +export DISPLAY=:0 +rm -f /tmp/.X0-lock +setsid Xvfb :0 -screen 0 1280x800x24 >/tmp/xvfb.log 2>&1 & +sleep 1 +setsid fluxbox >/tmp/fluxbox.log 2>&1 & +sleep 1 +setsid x11vnc -display :0 -forever -shared -nopw -rfbport 5900 >/tmp/x11vnc.log 2>&1 & +sleep 1 +setsid websockify --web /usr/share/novnc 0.0.0.0:6080 127.0.0.1:5900 >/tmp/websockify.log 2>&1 & +sleep 1 +"#; + let lf_content = start_script_content.replace("\r\n", "\n"); + std::fs::write(&start_script_path, lf_content) + .context("Failed to write start_command.sh")?; + + // 1. 写入 Dockerfile + let dockerfile_content = r#"FROM e2bdev/desktop:latest +USER root +# 复制启动脚本并赋予执行权限 +COPY start_command.sh /start_command.sh +RUN chmod +x /start_command.sh + +# 安装缺少的 VNC 窗口管理器和 noVNC 代理组件 +RUN apt-get update && apt-get install -y fluxbox websockify novnc + +# 直接使用已有的 python3 和 pip 安装 Playwright 及其浏览器依赖 +RUN pip install playwright && \ + playwright install chromium && \ + playwright install-deps chromium + +USER user +"#; + std::fs::write(&dockerfile_path, dockerfile_content) + .context("Failed to write Dockerfile")?; + + on_log("环境准备完毕,开始调用 E2B CLI 构建镜像 (E2B 2.0 在云端构建,不需要本地安装 Docker)...\n".to_string()); + + // 3. 执行 npx @e2b/cli template create + // 在 Windows 下需要调用 npx.cmd + let npx_cmd = if cfg!(windows) { "npx.cmd" } else { "npx" }; + + let mut cmd = Command::new(npx_cmd); + cmd.arg("-y") + .arg("@e2b/cli@latest") + .arg("template") + .arg("create") + .arg("-d") + .arg("Dockerfile") + .arg("-c") + .arg("/start_command.sh") + .arg("--ready-cmd") + .arg("python3 -c \"import socket; s = socket.socket(); s.connect(('127.0.0.1', 6080))\"") + .arg(name) + .env("E2B_API_KEY", api_key) + .current_dir(&builder_dir) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + if let Some(url) = api_url { + cmd.env("E2B_API_URL", url); + } + + let mut child = cmd.spawn() + .context("Failed to spawn npx @e2b/cli")?; + + let stdout = child.stdout.take().expect("Failed to open stdout"); + let stderr = child.stderr.take().expect("Failed to open stderr"); + + let mut stdout_reader = BufReader::new(stdout).lines(); + let mut stderr_reader = BufReader::new(stderr).lines(); + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let tx1 = tx.clone(); + + tokio::spawn(async move { + while let Ok(Some(line)) = stdout_reader.next_line().await { + let _ = tx1.send(line); + } + }); + + let tx2 = tx.clone(); + tokio::spawn(async move { + while let Ok(Some(line)) = stderr_reader.next_line().await { + let _ = tx2.send(line); + } + }); + + drop(tx); // Close the main sender so the receiver will end when tasks complete + + let mut full_output = String::new(); + while let Some(line) = rx.recv().await { + on_log(format!("{}\n", line)); + full_output.push_str(&line); + full_output.push('\n'); + } + + let status = child.wait().await?; + if !status.success() { + anyhow::bail!("E2B template build failed with exit code: {}.\nError details:\n{}", status, full_output); + } + + on_log("构建成功!正在解析 Template ID...\n".to_string()); + + // 解析出 template ID + let re = regex::Regex::new(r"(?i)\b(?:id|template_id)[:\s]+([a-z0-9]{20})\b").unwrap(); + if let Some(cap) = re.captures(&full_output) { + let id = cap.get(1).unwrap().as_str().to_string(); + return Ok(id); + } + + // 回退解析:寻找任何非 "dockerfile" 的 20 位字母数字组合作为 ID + let re_fallback = regex::Regex::new(r"\b([a-z0-9]{20})\b").unwrap(); + for cap in re_fallback.captures_iter(&full_output) { + let matched = cap.get(1).unwrap().as_str(); + if matched != "dockerfile" && matched != "template" { + return Ok(matched.to_string()); + } + } + + anyhow::bail!("构建成功,但未能从 E2B CLI 输出日志中提取出生成的 template_id") +} diff --git a/crates/flock-tools/src/sandbox_core/e2b/exec.rs b/crates/flock-tools/src/sandbox_core/e2b/exec.rs new file mode 100644 index 00000000..4006e17f --- /dev/null +++ b/crates/flock-tools/src/sandbox_core/e2b/exec.rs @@ -0,0 +1,127 @@ +use flock_core::config::settings::SandboxConfig; + +/// 在 E2B 沙盒中执行命令(通过 Connect Protocol) +/// 返回 (combined_output, exit_code) +pub async fn execute_command( + cfg: &SandboxConfig, + sandbox_id: &str, + command: &str, +) -> anyhow::Result<(String, i32)> { + let api_key = cfg.e2b_api_key.as_ref() + .ok_or_else(|| anyhow::anyhow!("E2B API key is missing"))?; + + let client = reqwest::Client::new(); + let url = format!("https://49983-{}.e2b.app/process.Process/Start", sandbox_id); + + #[derive(Debug, serde::Serialize)] + struct ProcessConfig { + cmd: String, + args: Vec, + envs: std::collections::HashMap, + cwd: String, + } + + #[derive(Debug, serde::Serialize)] + struct StartRequest { + process: ProcessConfig, + } + + let payload = StartRequest { + process: ProcessConfig { + cmd: "/bin/bash".to_string(), + args: vec!["-c".to_string(), command.to_string()], + envs: std::collections::HashMap::new(), + cwd: "/home/user".to_string(), + }, + }; + + let body_str = serde_json::to_string(&payload)?; + + let resp = client.post(&url) + .header("X-API-Key", api_key) + .header("Connect-Protocol-Version", "1") + .header("Content-Type", "application/connect+json") + .header(reqwest::header::USER_AGENT, "flock-agent") + .body(body_str) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let resp_text = resp.text().await.unwrap_or_default(); + anyhow::bail!("Failed to start command in E2B sandbox (HTTP {}): {}", status, resp_text); + } + + let mut stdout_accum = String::new(); + let mut stderr_accum = String::new(); + let mut exit_code = 0; + + use futures::StreamExt; + let mut body = resp.bytes_stream(); + let mut buffer = Vec::new(); + + while let Some(chunk_res) = body.next().await { + let chunk = chunk_res?; + buffer.extend_from_slice(&chunk); + + while buffer.len() >= 5 { + let flags = buffer[0]; + let length = u32::from_be_bytes([buffer[1], buffer[2], buffer[3], buffer[4]]) as usize; + + if buffer.len() >= 5 + length { + let payload_bytes = &buffer[5..5 + length]; + + if flags == 0x00 { + if let Ok(val) = serde_json::from_slice::(payload_bytes) { + if let Some(event) = val.get("event") { + if let Some(data) = event.get("data") { + if let Some(stdout_b64) = data.get("stdout").and_then(|v| v.as_str()) { + if let Ok(decoded) = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, stdout_b64) { + if let Ok(s) = String::from_utf8(decoded) { + stdout_accum.push_str(&s); + } + } + } + if let Some(stderr_b64) = data.get("stderr").and_then(|v| v.as_str()) { + if let Ok(decoded) = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, stderr_b64) { + if let Ok(s) = String::from_utf8(decoded) { + stderr_accum.push_str(&s); + } + } + } + } + if let Some(end) = event.get("end") { + if let Some(code) = end.get("exitCode").or_else(|| end.get("exit_code")).and_then(|v| v.as_i64()) { + exit_code = code as i32; + } + } + } + } + } + + buffer.drain(0..5 + length); + } else { + break; + } + } + } + + let combined_output = if stderr_accum.is_empty() { + stdout_accum + } else if stdout_accum.is_empty() { + stderr_accum + } else { + format!("{}\n{}", stdout_accum, stderr_accum) + }; + + Ok((combined_output, exit_code)) +} + +/// 获取 E2B 沙盒的 VNC URL(desktop 模板内置 noVNC on port 6080) +/// 直接返回固定格式 URL,不检查端口,不拉起任何进程。 +pub fn get_vnc_url(sandbox_id: &str) -> String { + format!( + "https://6080-{}.e2b.app/vnc.html?autoconnect=true&resize=scale&skip-preview-warning=true&skip_preview_warning=true", + sandbox_id + ) +} diff --git a/crates/flock-tools/src/sandbox_core/e2b/lifecycle.rs b/crates/flock-tools/src/sandbox_core/e2b/lifecycle.rs new file mode 100644 index 00000000..4a9ba854 --- /dev/null +++ b/crates/flock-tools/src/sandbox_core/e2b/lifecycle.rs @@ -0,0 +1,103 @@ +use flock_core::config::settings::SandboxConfig; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct E2BCreateSandboxRequest { + #[serde(rename = "templateID")] + pub template_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct E2BSandboxResponse { + #[serde(rename = "sandboxID")] + pub sandbox_id: String, + #[serde(rename = "templateID")] + pub template_id: String, +} + +/// 检查 E2B 沙盒是否存活 +pub async fn check_alive(cfg: &SandboxConfig, sandbox_id: &str) -> bool { + let api_key = match &cfg.e2b_api_key { + Some(k) => k, + None => return false, + }; + let base_url = cfg.e2b_api_url.as_deref().unwrap_or("https://api.e2b.app").trim_end_matches('/'); + + let client = reqwest::Client::new(); + let url = format!("{}/sandboxes/{}", base_url, sandbox_id); + + match client.get(&url) + .header("X-API-Key", api_key) + .send() + .await + { + Ok(r) => r.status().is_success(), + Err(_) => false, + } +} + +/// 创建 E2B 沙盒,返回 sandbox_id +pub async fn create_sandbox(cfg: &SandboxConfig) -> anyhow::Result { + let api_key = cfg.e2b_api_key.as_ref() + .ok_or_else(|| anyhow::anyhow!("E2B API key is missing"))?; + let base_url = cfg.e2b_api_url.as_deref().unwrap_or("https://api.e2b.app").trim_end_matches('/'); + + let mut template_id = cfg.snapshot.as_deref() + .unwrap_or("") + .trim(); + if template_id.is_empty() { + template_id = "jj17rl441iqdc6ea5mkb"; // 使用您发布的公共模版 ID 作为默认兜底 + } + + let client = reqwest::Client::new(); + let payload = E2BCreateSandboxRequest { + template_id: template_id.to_string(), + }; + + crate::emit_info(&flock_core::tr( + &format!("正在向 E2B 申请启动沙盒 (模版: {})...", template_id), + &format!("Requesting E2B sandbox (template: {})...", template_id) + )); + + let url = format!("{}/sandboxes", base_url); + let resp = client.post(url) + .header("X-API-Key", api_key) + .json(&payload) + .send() + .await?; + + let status = resp.status(); + let resp_text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("Failed to create E2B sandbox (HTTP {}): {}", status, resp_text); + } + + let sandbox: E2BSandboxResponse = serde_json::from_str(&resp_text) + .map_err(|e| anyhow::anyhow!("Failed to parse E2B sandbox response: {}, body: {}", e, resp_text))?; + + crate::emit_info(&flock_core::tr("E2B 沙盒已启动。", "E2B sandbox started.")); + Ok(sandbox.sandbox_id) +} + +/// 销毁 E2B 沙盒 +pub async fn destroy_sandbox(cfg: &SandboxConfig, sandbox_id: &str) -> anyhow::Result<()> { + let api_key = cfg.e2b_api_key.as_ref() + .ok_or_else(|| anyhow::anyhow!("E2B API key is missing"))?; + let base_url = cfg.e2b_api_url.as_deref().unwrap_or("https://api.e2b.app").trim_end_matches('/'); + + let client = reqwest::Client::new(); + let url = format!("{}/sandboxes/{}", base_url, sandbox_id); + + let resp = client.delete(&url) + .header("X-API-Key", api_key) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() && status.as_u16() != 204 { + let resp_text = resp.text().await.unwrap_or_default(); + anyhow::bail!("Failed to destroy E2B sandbox (HTTP {}): {}", status, resp_text); + } + + Ok(()) +} diff --git a/crates/flock-tools/src/sandbox_core/e2b/mod.rs b/crates/flock-tools/src/sandbox_core/e2b/mod.rs new file mode 100644 index 00000000..75ce9c65 --- /dev/null +++ b/crates/flock-tools/src/sandbox_core/e2b/mod.rs @@ -0,0 +1,7 @@ +pub mod lifecycle; +pub mod exec; +pub mod provider; +pub mod builder; + +pub use lifecycle::{check_alive, create_sandbox, destroy_sandbox}; +pub use exec::execute_command; diff --git a/crates/flock-tools/src/sandbox_core/e2b/provider.rs b/crates/flock-tools/src/sandbox_core/e2b/provider.rs new file mode 100644 index 00000000..1e584fb3 --- /dev/null +++ b/crates/flock-tools/src/sandbox_core/e2b/provider.rs @@ -0,0 +1,106 @@ +use crate::sandbox_core::provider::SandboxProvider; +use flock_core::config::settings::SandboxConfig; +use flock_core::db::DbManager; +use async_trait::async_trait; +use std::path::Path; + +pub struct E2bProvider; + +#[async_trait] +impl SandboxProvider for E2bProvider { + async fn check_alive(&self, cfg: &SandboxConfig, sandbox_id: &str) -> bool { + crate::sandbox_core::e2b::check_alive(cfg, sandbox_id).await + } + async fn create_sandbox(&self, _db: &DbManager, cfg: &SandboxConfig) -> anyhow::Result { + crate::sandbox_core::e2b::create_sandbox(cfg).await + } + async fn destroy_sandbox(&self, _db: &DbManager, cfg: &SandboxConfig, sandbox_id: &str) -> anyhow::Result<()> { + crate::sandbox_core::e2b::destroy_sandbox(cfg, sandbox_id).await + } + async fn execute_command(&self, _db: &DbManager, cfg: &SandboxConfig, sandbox_id: &str, command: &str) -> anyhow::Result<(String, i32)> { + crate::sandbox_core::e2b::execute_command(cfg, sandbox_id, command).await + } + async fn get_vnc_url(&self, _db: &DbManager, _cfg: &SandboxConfig, sandbox_id: &str) -> anyhow::Result { + Ok(crate::sandbox_core::e2b::exec::get_vnc_url(sandbox_id)) + } + async fn ensure_vnc_running(&self, db: &DbManager, _cfg: &SandboxConfig, sandbox_id: &str) -> anyhow::Result<()> { + crate::sandbox_core::vnc_helper::ensure_vnc_running_in_sandbox(db, sandbox_id).await + } + async fn sync_up(&self, db: &DbManager, sandbox_id: &str, ws_path: &Path) -> anyhow::Result<()> { + crate::sandbox_core::sync::sync_up(db, sandbox_id, ws_path).await + } + async fn sync_down(&self, db: &DbManager, sandbox_id: &str, ws_path: &Path) -> anyhow::Result<()> { + crate::sandbox_core::sync::sync_down(db, sandbox_id, ws_path).await + } + fn get_workspace_dir(&self) -> &str { + "/home/user" + } + async fn list_templates(&self, _db: &DbManager, cfg: &SandboxConfig) -> anyhow::Result { + let key = cfg.e2b_api_key.clone().unwrap_or_default(); + if key.is_empty() { + return Ok(serde_json::json!([])); + } + let base_url = cfg.e2b_api_url.as_deref().unwrap_or("https://api.e2b.app").trim_end_matches('/'); + let client = reqwest::Client::new(); + let url = format!("{}/templates", base_url); + let resp = client.get(&url) + .header("X-API-Key", key) + .send() + .await?; + + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + anyhow::bail!("E2B API error ({}): {}", status, text); + } + + let mut mapped: Vec = vec![]; + let list_val: serde_json::Value = serde_json::from_str(&text)?; + + if let Some(arr) = list_val.as_array() { + for item in arr { + let id = item.get("templateID") + .or_else(|| item.get("snapshotID")) + .or_else(|| item.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or_default(); + let name = item.get("aliases") + .and_then(|a| a.as_array()) + .and_then(|arr| arr.first()) + .and_then(|v| v.as_str()) + .unwrap_or(id); + if !id.is_empty() && !mapped.iter().any(|m| m.get("id").and_then(|v| v.as_str()) == Some(id)) { + mapped.push(serde_json::json!({ + "id": id, + "name": name, + "status": "active" + })); + } + } + } + Ok(serde_json::json!(mapped)) + } + async fn delete_template(&self, _db: &DbManager, cfg: &SandboxConfig, id: &str) -> anyhow::Result<()> { + let api_key = cfg.e2b_api_key.as_ref().ok_or_else(|| anyhow::anyhow!("E2B API key missing"))?; + let base_url = cfg.e2b_api_url.as_deref().unwrap_or("https://api.e2b.app").trim_end_matches('/'); + let client = reqwest::Client::new(); + let url = format!("{}/templates/{}", base_url, id); + let resp = client.delete(&url) + .header("X-API-Key", api_key) + .send() + .await?; + + let status = resp.status(); + if !status.is_success() { + let err_body = resp.text().await.unwrap_or_default(); + anyhow::bail!("Failed to delete E2B template: {} - {}", status, err_body); + } + Ok(()) + } + async fn cleanup_all_instances(&self, _db: &DbManager, _cfg: &SandboxConfig) -> anyhow::Result { + Ok(flock_core::tr( + "E2B 实例由平台自动进行生命周期托管与清理,无需手动干预。", + "E2B instances are self-cleaning and managed automatically by the platform." + )) + } +} diff --git a/crates/flock-tools/src/sandbox_core/local_provider.rs b/crates/flock-tools/src/sandbox_core/local_provider.rs new file mode 100644 index 00000000..a235e245 --- /dev/null +++ b/crates/flock-tools/src/sandbox_core/local_provider.rs @@ -0,0 +1,49 @@ +use crate::sandbox_core::provider::SandboxProvider; +use flock_core::config::settings::SandboxConfig; +use flock_core::db::DbManager; +use async_trait::async_trait; +use std::path::Path; + +pub struct LocalSandboxProvider; + +#[async_trait] +impl SandboxProvider for LocalSandboxProvider { + async fn check_alive(&self, _cfg: &SandboxConfig, _sandbox_id: &str) -> bool { + true + } + async fn create_sandbox(&self, _db: &DbManager, _cfg: &SandboxConfig) -> anyhow::Result { + crate::emit_info(&flock_core::tr("正在启动本地 Mock 沙盒 (占位)...", "Starting local mock sandbox (placeholder)...")); + Ok("local".to_string()) + } + async fn destroy_sandbox(&self, _db: &DbManager, _cfg: &SandboxConfig, _sandbox_id: &str) -> anyhow::Result<()> { + Ok(()) + } + async fn execute_command(&self, _db: &DbManager, _cfg: &SandboxConfig, _sandbox_id: &str, command: &str) -> anyhow::Result<(String, i32)> { + crate::emit_info(&format!("Local mock executing: {}", command)); + Ok((format!("Local mock execution placeholder: executing '{}'", command), 0)) + } + async fn get_vnc_url(&self, _db: &DbManager, _cfg: &SandboxConfig, _sandbox_id: &str) -> anyhow::Result { + anyhow::bail!("Local sandbox does not support VNC") + } + async fn ensure_vnc_running(&self, _db: &DbManager, _cfg: &SandboxConfig, _sandbox_id: &str) -> anyhow::Result<()> { + Ok(()) + } + async fn sync_up(&self, _db: &DbManager, _sandbox_id: &str, _ws_path: &Path) -> anyhow::Result<()> { + Ok(()) + } + async fn sync_down(&self, _db: &DbManager, _sandbox_id: &str, _ws_path: &Path) -> anyhow::Result<()> { + Ok(()) + } + fn get_workspace_dir(&self) -> &str { + "" + } + async fn list_templates(&self, _db: &DbManager, _cfg: &SandboxConfig) -> anyhow::Result { + Ok(serde_json::json!([])) + } + async fn delete_template(&self, _db: &DbManager, _cfg: &SandboxConfig, _id: &str) -> anyhow::Result<()> { + Ok(()) + } + async fn cleanup_all_instances(&self, _db: &DbManager, _cfg: &SandboxConfig) -> anyhow::Result { + Ok("Local provider does not support cleanup".to_string()) + } +} diff --git a/crates/flock-tools/src/sandbox_core/manager.rs b/crates/flock-tools/src/sandbox_core/manager.rs new file mode 100644 index 00000000..4368b5b1 --- /dev/null +++ b/crates/flock-tools/src/sandbox_core/manager.rs @@ -0,0 +1,181 @@ +/// sandbox_manager.rs — 沙盒管理中间层 +/// +/// 职责: +/// - 维护全局活跃沙盒 ID 的状态(ACTIVE_SANDBOX_ID) +/// - 根据 provider 字段分发到具体 provider 实现 +/// - 暴露统一接口:get_or_create、destroy、check_alive、get_vnc_url、execute_command + +use std::sync::OnceLock; +use tokio::sync::Mutex; +use flock_core::db::DbManager; +use crate::sandbox_core::config::get_sandbox_config; +use crate::sandbox_core::provider::SandboxProvider; + +static ACTIVE_SANDBOX_ID: OnceLock>> = OnceLock::new(); + +pub fn get_sandbox_id_mutex() -> &'static Mutex> { + ACTIVE_SANDBOX_ID.get_or_init(|| Mutex::new(None)) +} + +pub async fn get_active_sandbox_id() -> Option { + let mutex = get_sandbox_id_mutex(); + let lock = mutex.lock().await; + lock.clone() +} + +pub async fn clear_active_sandbox_id() { + let mutex = get_sandbox_id_mutex(); + let mut lock = mutex.lock().await; + *lock = None; +} + +/// 获取当前配置对应的 SandboxProvider 实现 +pub fn get_provider(provider_name: &str) -> Box { + match provider_name { + "e2b" => Box::new(crate::sandbox_core::e2b::provider::E2bProvider), + "local" => Box::new(crate::sandbox_core::local_provider::LocalSandboxProvider), + _ => Box::new(crate::sandbox_core::daytona::provider::DaytonaProvider), + } +} + +pub async fn check_sandbox_alive(cfg: &flock_core::config::settings::SandboxConfig, id: &str) -> bool { + let provider_name = cfg.provider.as_deref().unwrap_or("e2b"); + let provider = get_provider(provider_name); + provider.check_alive(cfg, id).await +} + +pub async fn get_or_create_active_sandbox(db: &DbManager) -> anyhow::Result { + let cfg = get_sandbox_config(db).await + .ok_or_else(|| anyhow::anyhow!(flock_core::tr( + "沙盒未启用或未配置。请在系统设置中配置有效的 API Key。", + "Sandbox not enabled or configured. Please configure a valid API Key in system settings." + )))?; + + let mutex = get_sandbox_id_mutex(); + let mut lock = mutex.lock().await; + + if let Some(id) = lock.as_ref() { + if check_sandbox_alive(&cfg, id).await { + // Daytona 需要额外 set_public(这个也可以封装到 check_alive 或独立方法中,但为了兼容暂时保留这里, + // 更好的做法是将其放在 create_sandbox 内部,不过目前我们在检测到复用时也可能需要) + if cfg.provider.as_deref().unwrap_or("e2b") == "daytona" { + let _ = crate::sandbox_core::daytona::set_sandbox_public(&cfg, id, true).await; + } + return Ok(id.clone()); + } + crate::emit_info(&flock_core::tr( + &format!("沙盒 {} 已失效,准备重新创建...", id), + &format!("Sandbox {} has expired, preparing to recreate...", id) + )); + *lock = None; + } + + let provider_name = cfg.provider.as_deref().unwrap_or("e2b"); + let provider = get_provider(provider_name); + + let sandbox_id = provider.create_sandbox(db, &cfg).await?; + + if let Some(ws_path) = crate::get_workspace_dir() { + if let Err(e) = provider.sync_up(db, &sandbox_id, &ws_path).await { + crate::emit_info(&format!("Sync Up failed: {}", e)); + } + } + + *lock = Some(sandbox_id.clone()); + Ok(sandbox_id) +} + +pub async fn destroy_active_sandbox(db: &DbManager) -> anyhow::Result<()> { + let cfg = match get_sandbox_config(db).await { + Some(c) => c, + None => return Ok(()), + }; + + let mutex = get_sandbox_id_mutex(); + let mut lock = mutex.lock().await; + + let sandbox_id = match lock.as_ref() { + Some(id) => id.clone(), + None => return Ok(()), + }; + + let provider_name = cfg.provider.as_deref().unwrap_or("e2b"); + let provider = get_provider(provider_name); + + if let Some(ws_path) = crate::get_workspace_dir() { + if let Err(e) = provider.sync_down(db, &sandbox_id, &ws_path).await { + crate::emit_info(&format!("Sync Down failed: {}", e)); + } + } + + if let Err(e) = provider.destroy_sandbox(db, &cfg, &sandbox_id).await { + crate::emit_info(&format!("Destroying sandbox request failed: {}", e)); + } + + *lock = None; + Ok(()) +} + +pub async fn execute_command_in_sandbox( + db: &DbManager, + sandbox_id: &str, + command: &str, +) -> anyhow::Result<(String, i32)> { + let cfg = get_sandbox_config(db).await + .ok_or_else(|| anyhow::anyhow!("沙箱未配置或未启用"))?; + + let provider_name = cfg.provider.as_deref().unwrap_or("e2b"); + let provider = get_provider(provider_name); + + provider.execute_command(db, &cfg, sandbox_id, command).await +} + +pub async fn get_sandbox_vnc_url(db: &DbManager, sandbox_id: &str) -> anyhow::Result { + let cfg = get_sandbox_config(db).await + .ok_or_else(|| anyhow::anyhow!(flock_core::tr( + "沙盒未配置或未启用", + "Sandbox not configured or enabled" + )))?; + + let provider_name = cfg.provider.as_deref().unwrap_or("e2b"); + let provider = get_provider(provider_name); + + provider.get_vnc_url(db, &cfg, sandbox_id).await +} + +pub async fn ensure_vnc_running_in_sandbox(db: &DbManager, sandbox_id: &str) -> anyhow::Result<()> { + let cfg = get_sandbox_config(db).await + .ok_or_else(|| anyhow::anyhow!("沙盒未配置"))?; + + let provider_name = cfg.provider.as_deref().unwrap_or("e2b"); + let provider = get_provider(provider_name); + + provider.ensure_vnc_running(db, &cfg, sandbox_id).await +} + +pub async fn list_sandbox_templates(db: &DbManager) -> anyhow::Result { + let cfg = get_sandbox_config(db).await + .ok_or_else(|| anyhow::anyhow!("沙盒未配置"))?; + + let provider_name = cfg.provider.as_deref().unwrap_or("e2b"); + let provider = get_provider(provider_name); + provider.list_templates(db, &cfg).await +} + +pub async fn delete_sandbox_template(db: &DbManager, id: &str) -> anyhow::Result<()> { + let cfg = get_sandbox_config(db).await + .ok_or_else(|| anyhow::anyhow!("沙盒未配置"))?; + + let provider_name = cfg.provider.as_deref().unwrap_or("e2b"); + let provider = get_provider(provider_name); + provider.delete_template(db, &cfg, id).await +} + +pub async fn cleanup_all_sandbox_instances(db: &DbManager) -> anyhow::Result { + let cfg = get_sandbox_config(db).await + .ok_or_else(|| anyhow::anyhow!("沙盒未配置"))?; + + let provider_name = cfg.provider.as_deref().unwrap_or("e2b"); + let provider = get_provider(provider_name); + provider.cleanup_all_instances(db, &cfg).await +} diff --git a/crates/flock-tools/src/sandbox_core/mod.rs b/crates/flock-tools/src/sandbox_core/mod.rs new file mode 100644 index 00000000..c0d3ab35 --- /dev/null +++ b/crates/flock-tools/src/sandbox_core/mod.rs @@ -0,0 +1,9 @@ +pub mod config; +pub mod daytona; +pub mod e2b; +pub mod manager; +pub mod provider; +pub mod local_provider; +pub mod state; +pub mod sync; +pub mod vnc_helper; diff --git a/crates/flock-tools/src/sandbox_core/provider.rs b/crates/flock-tools/src/sandbox_core/provider.rs new file mode 100644 index 00000000..c77c6230 --- /dev/null +++ b/crates/flock-tools/src/sandbox_core/provider.rs @@ -0,0 +1,20 @@ +use std::path::Path; +use async_trait::async_trait; +use flock_core::config::settings::SandboxConfig; +use serde_json::Value; + +#[async_trait] +pub trait SandboxProvider: Send + Sync { + async fn check_alive(&self, cfg: &SandboxConfig, sandbox_id: &str) -> bool; + async fn create_sandbox(&self, db: &flock_core::db::DbManager, cfg: &SandboxConfig) -> anyhow::Result; + async fn destroy_sandbox(&self, db: &flock_core::db::DbManager, cfg: &SandboxConfig, sandbox_id: &str) -> anyhow::Result<()>; + async fn execute_command(&self, db: &flock_core::db::DbManager, cfg: &SandboxConfig, sandbox_id: &str, command: &str) -> anyhow::Result<(String, i32)>; + async fn get_vnc_url(&self, db: &flock_core::db::DbManager, cfg: &SandboxConfig, sandbox_id: &str) -> anyhow::Result; + async fn ensure_vnc_running(&self, db: &flock_core::db::DbManager, cfg: &SandboxConfig, sandbox_id: &str) -> anyhow::Result<()>; + async fn sync_up(&self, db: &flock_core::db::DbManager, sandbox_id: &str, ws_path: &Path) -> anyhow::Result<()>; + async fn sync_down(&self, db: &flock_core::db::DbManager, sandbox_id: &str, ws_path: &Path) -> anyhow::Result<()>; + fn get_workspace_dir(&self) -> &str; + async fn list_templates(&self, db: &flock_core::db::DbManager, cfg: &SandboxConfig) -> anyhow::Result; + async fn delete_template(&self, db: &flock_core::db::DbManager, cfg: &SandboxConfig, id: &str) -> anyhow::Result<()>; + async fn cleanup_all_instances(&self, db: &flock_core::db::DbManager, cfg: &SandboxConfig) -> anyhow::Result; +} diff --git a/crates/flock-tools/src/daytona/state.rs b/crates/flock-tools/src/sandbox_core/state.rs similarity index 55% rename from crates/flock-tools/src/daytona/state.rs rename to crates/flock-tools/src/sandbox_core/state.rs index e6a3dc91..b44072a7 100644 --- a/crates/flock-tools/src/daytona/state.rs +++ b/crates/flock-tools/src/sandbox_core/state.rs @@ -1,18 +1,3 @@ -use std::sync::OnceLock; -use tokio::sync::Mutex; - -static ACTIVE_SANDBOX_ID: OnceLock>> = OnceLock::new(); - -pub fn get_sandbox_id_mutex() -> &'static Mutex> { - ACTIVE_SANDBOX_ID.get_or_init(|| Mutex::new(None)) -} - -pub async fn get_active_sandbox_id() -> Option { - let mutex = get_sandbox_id_mutex(); - let lock = mutex.lock().await; - lock.clone() -} - /// 向前端发送"需要人工接管"事件 pub fn emit_human_takeover(call_id: &str, msg_id: &str, message: &str, remote_url: Option) { if let Some(emitter) = crate::get_global_emitter() { diff --git a/crates/flock-tools/src/daytona/sync.rs b/crates/flock-tools/src/sandbox_core/sync.rs similarity index 72% rename from crates/flock-tools/src/daytona/sync.rs rename to crates/flock-tools/src/sandbox_core/sync.rs index 9207b67a..2720185b 100644 --- a/crates/flock-tools/src/daytona/sync.rs +++ b/crates/flock-tools/src/sandbox_core/sync.rs @@ -1,5 +1,5 @@ use flock_core::db::DbManager; -use crate::daytona::execute_command_in_sandbox; +use crate::sandbox_core::manager::execute_command_in_sandbox; use std::path::{Path, PathBuf}; use std::fs; use anyhow::Context; @@ -10,6 +10,20 @@ fn should_ignore(path: &Path) -> bool { } pub async fn sync_up(db: &DbManager, sandbox_id: &str, local_workspace: &Path) -> anyhow::Result<()> { + let mut ws_dir = "/workspace".to_string(); + if let Some(cfg) = crate::sandbox_core::config::get_sandbox_config(db).await { + if cfg.provider.as_deref().unwrap_or("e2b") == "local" { + return Ok(()); + } + let provider_name = cfg.provider.as_deref().unwrap_or("e2b"); + let provider = crate::sandbox_core::manager::get_provider(provider_name); + ws_dir = provider.get_workspace_dir().to_string(); + } + + if ws_dir.is_empty() { + return Ok(()); + } + // Create a temporary tarball let tar_path = std::env::temp_dir().join(format!("flock_sync_up_{}.tar.gz", sandbox_id)); let tar_file = fs::File::create(&tar_path).context("Failed to create tar file")?; @@ -40,24 +54,38 @@ pub async fn sync_up(db: &DbManager, sandbox_id: &str, local_workspace: &Path) - // Upload tarball let tar_data = fs::read(&tar_path)?; let b64_content = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &tar_data); - let cmd = format!("echo '{}' | base64 -d > /workspace/.flock_sync.tar.gz", b64_content); + let cmd = format!("echo '{}' | base64 -d > {}/.flock_sync.tar.gz", b64_content, ws_dir); execute_command_in_sandbox(db, sandbox_id, &cmd).await?; // Extract tarball - let cmd = "cd /workspace && tar -xzf .flock_sync.tar.gz && rm .flock_sync.tar.gz"; - execute_command_in_sandbox(db, sandbox_id, cmd).await?; + let cmd = format!("cd {} && tar -xzf .flock_sync.tar.gz && rm .flock_sync.tar.gz", ws_dir); + execute_command_in_sandbox(db, sandbox_id, &cmd).await?; let _ = fs::remove_file(tar_path); Ok(()) } pub async fn sync_down(db: &DbManager, sandbox_id: &str, local_workspace: &Path) -> anyhow::Result<()> { + let mut ws_dir = "/workspace".to_string(); + if let Some(cfg) = crate::sandbox_core::config::get_sandbox_config(db).await { + if cfg.provider.as_deref().unwrap_or("e2b") == "local" { + return Ok(()); + } + let provider_name = cfg.provider.as_deref().unwrap_or("e2b"); + let provider = crate::sandbox_core::manager::get_provider(provider_name); + ws_dir = provider.get_workspace_dir().to_string(); + } + + if ws_dir.is_empty() { + return Ok(()); + } + // Create a tarball in sandbox - let cmd = "cd /workspace && tar -czf .flock_sync_down.tar.gz --exclude='.flock_sync_down.tar.gz' --exclude='.git' --exclude='node_modules' --exclude='target' ."; - execute_command_in_sandbox(db, sandbox_id, cmd).await?; + let cmd = format!("cd {} && tar -czf .flock_sync_down.tar.gz --exclude='.flock_sync_down.tar.gz' --exclude='.git' --exclude='node_modules' --exclude='target' .", ws_dir); + execute_command_in_sandbox(db, sandbox_id, &cmd).await?; // Download tarball - let cmd = "base64 -w 0 /workspace/.flock_sync_down.tar.gz"; + let cmd = format!("base64 -w 0 {}/.flock_sync_down.tar.gz", ws_dir); let (b64, code) = execute_command_in_sandbox(db, sandbox_id, &cmd).await?; if code != 0 { anyhow::bail!("Failed to download tarball: {}", b64); @@ -121,8 +149,8 @@ pub async fn sync_down(db: &DbManager, sandbox_id: &str, local_workspace: &Path) let _ = fs::remove_file(tar_path); // Cleanup sandbox - let cmd = "rm -f /workspace/.flock_sync_down.tar.gz"; - let _ = execute_command_in_sandbox(db, sandbox_id, cmd).await; + let cmd = format!("rm -f {}/.flock_sync_down.tar.gz", ws_dir); + let _ = execute_command_in_sandbox(db, sandbox_id, &cmd).await; Ok(()) } diff --git a/crates/flock-tools/src/sandbox_core/vnc_helper.rs b/crates/flock-tools/src/sandbox_core/vnc_helper.rs new file mode 100644 index 00000000..3625df7c --- /dev/null +++ b/crates/flock-tools/src/sandbox_core/vnc_helper.rs @@ -0,0 +1,60 @@ +use flock_core::db::DbManager; +use crate::sandbox_core::manager::execute_command_in_sandbox; + +/// VNC 桌面服务端监听端口 (默认 6080,基于 websockify 提供 Web VNC 服务) +pub const WEBSOCKIFY_PORT: u16 = 6080; + +/// 原始 VNC 服务监听端口 (默认 5900) +pub const X11VNC_PORT: u16 = 5900; + +/// 默认的 X11 Display 标识 +pub const DISPLAY_ID: &str = ":0"; + +/// 桌面分辨率 +pub const SCREEN_RESOLUTION: &str = "1280x1024x24"; + +/// 确保沙盒中 VNC 桌面相关进程正在后台运行,具有自愈拉起和 setsid/nohup 防进程清理机制。 +pub async fn ensure_vnc_running_in_sandbox(db: &DbManager, sandbox_id: &str) -> anyhow::Result<()> { + // 检查 websockify 是否已经在运行且在指定端口监听 + let check_cmd = format!("python3 -c \"import socket; s = socket.socket(); s.connect(('127.0.0.1', {}))\"", WEBSOCKIFY_PORT); + let (_, exit_code) = execute_command_in_sandbox(db, sandbox_id, &check_cmd).await.unwrap_or(("-1".to_string(), -1)); + if exit_code == 0 { + crate::emit_info(&flock_core::tr("检测到 VNC 桌面服务已经在运行。", "Detected VNC desktop service is already running.")); + return Ok(()); + } + + crate::emit_info(&flock_core::tr("检测到 VNC 服务未运行,手动拉起 Xvfb, VNC, noVNC...", "Detected VNC service not running, manually starting Xvfb, VNC, noVNC...")); + let launch_cmd = format!("sh -c '\ + if command -v start-vnc >/dev/null 2>&1; then \ + setsid nohup start-vnc >/tmp/vnc_start.log 2>&1 & \ + sleep 2; \ + else \ + export DISPLAY={display} && \ + rm -f /tmp/.X0-lock && \ + setsid nohup Xvfb {display} -screen 0 {res} >/tmp/xvfb.log 2>&1 & \ + sleep 1 && \ + setsid nohup fluxbox >/tmp/fluxbox.log 2>&1 & \ + sleep 1 && \ + setsid nohup x11vnc -display {display} -forever -shared -nopw -rfbport {vnc_port} >/tmp/x11vnc.log 2>&1 & \ + sleep 1 && \ + setsid nohup websockify --web /usr/share/novnc 0.0.0.0:{web_port} localhost:{vnc_port} >/tmp/websockify.log 2>&1 & \ + sleep 1; \ + fi'", + display = DISPLAY_ID, + res = SCREEN_RESOLUTION, + vnc_port = X11VNC_PORT, + web_port = WEBSOCKIFY_PORT + ); + + let (out, code) = execute_command_in_sandbox(db, sandbox_id, &launch_cmd).await?; + if code != 0 { + crate::emit_info(&flock_core::tr( + &format!("手动拉起桌面服务进程失败 (退出码 {}): {}", code, out), + &format!("Failed to manually start desktop service process (exit code {}): {}", code, out) + )); + } else { + crate::emit_info(&flock_core::tr("手动拉起桌面服务进程指令已发送。", "Manual start desktop service process command sent.")); + } + + Ok(()) +} diff --git a/crates/flock-tools/src/tools/sandbox/browser/interactive.rs b/crates/flock-tools/src/tools/sandbox/browser/interactive.rs index 10ba3753..e0ed66e5 100644 --- a/crates/flock-tools/src/tools/sandbox/browser/interactive.rs +++ b/crates/flock-tools/src/tools/sandbox/browser/interactive.rs @@ -1,8 +1,11 @@ -use crate::daytona::{ - execute_command_in_sandbox, +use crate::sandbox_core::daytona::{ start_computer_use_in_sandbox, + DISPLAY_ID, +}; +use crate::sandbox_core::manager::{ + execute_command_in_sandbox, + get_sandbox_vnc_url, ensure_vnc_running_in_sandbox, - DISPLAY_ID, WEBSOCKIFY_PORT, }; use flock_core::ipc_interface::events::ToolCategory; use flock_core::db::DbManager; @@ -21,17 +24,14 @@ pub async fn handle_interactive( call_id: Option, msg_id: Option, ) -> Result { - let proxy_url = match crate::daytona::get_sandbox_vnc_url(db, sandbox_id).await { + let proxy_url = match get_sandbox_vnc_url(db, sandbox_id).await { Ok(u) => u, Err(e) => { crate::emit_info(&flock_core::tr( - &format!("获取动态 VNC URL 失败: {}。使用静态备用 URL...", e), - &format!("Failed to retrieve dynamic VNC URL: {}. Falling back to static VNC URL...", e), + &format!("获取 VNC URL 失败: {}", e), + &format!("Failed to get VNC URL: {}", e) )); - format!( - "https://{}-{}.proxy.app.daytona.io/vnc.html?autoconnect=true&resize=scale", - WEBSOCKIFY_PORT, sandbox_id - ) + return Err(format!("无法获取沙盒 VNC 连接地址: {}", e)); } }; @@ -105,7 +105,7 @@ pub async fn handle_interactive( &format!("检测到敏感网页元素(密码输入框/验证码),正在通知前端拉起人工接管横幅 (Call ID: {})...", cid), &format!("Sensitive page element detected (password input/captcha), notifying client to display takeover banner (Call ID: {})...", cid), )); - crate::daytona::emit_human_takeover( + crate::sandbox_core::state::emit_human_takeover( &cid, &mid, "人机协同远程桌面已拉起!检测到当前操作需要人工介入(如输入密码、手动验证码、安全登录等),大模型自动执行已暂停。您可以在右侧预览面板中直接操作页面。完成后请点击横幅上的【我已完成操作】按钮以恢复大模型的自动运行。", diff --git a/crates/flock-tools/src/tools/sandbox/browser/mod.rs b/crates/flock-tools/src/tools/sandbox/browser/mod.rs index 7c7f0fb0..1fb588fa 100644 --- a/crates/flock-tools/src/tools/sandbox/browser/mod.rs +++ b/crates/flock-tools/src/tools/sandbox/browser/mod.rs @@ -3,11 +3,8 @@ mod interactive; use crate::adapter::LangGraphToolAdapter; use crate::Tool; -use crate::daytona::{ - get_or_create_active_sandbox, execute_command_in_sandbox, - ensure_vnc_running_in_sandbox, - DISPLAY_ID, -}; +use crate::sandbox_core::manager::{get_or_create_active_sandbox, execute_command_in_sandbox, ensure_vnc_running_in_sandbox}; +use crate::sandbox_core::daytona::DISPLAY_ID; use flock_core::ipc_interface::events::ToolCategory; use langgraph::tool; use base64::{Engine as _, engine::general_purpose}; diff --git a/crates/flock-tools/src/tools/sandbox/code_execution.rs b/crates/flock-tools/src/tools/sandbox/code_execution.rs index 98bf9bc5..5c24347b 100644 --- a/crates/flock-tools/src/tools/sandbox/code_execution.rs +++ b/crates/flock-tools/src/tools/sandbox/code_execution.rs @@ -1,6 +1,6 @@ use crate::adapter::LangGraphToolAdapter; use crate::Tool; -use crate::daytona::{get_or_create_active_sandbox, execute_command_in_sandbox}; +use crate::sandbox_core::manager::{get_or_create_active_sandbox, execute_command_in_sandbox}; use flock_core::ipc_interface::events::ToolCategory; use langgraph::tool; use std::path::Path; @@ -45,7 +45,7 @@ pub async fn code_execution(code: String) -> Result { let db_clone = db.clone(); let sandbox_id_clone = sandbox_id.clone(); tokio::spawn(async move { - if let Err(e) = crate::daytona::sync::sync_down(&db_clone, &sandbox_id_clone, &ws_path).await { + if let Err(e) = crate::sandbox_core::sync::sync_down(&db_clone, &sandbox_id_clone, &ws_path).await { log::warn!("自动 Sync Down 失败: {}", e); } }); diff --git a/crates/flock-tools/src/tools/sandbox/computer_use.rs b/crates/flock-tools/src/tools/sandbox/computer_use.rs index 762a53cb..8f19a0a1 100644 --- a/crates/flock-tools/src/tools/sandbox/computer_use.rs +++ b/crates/flock-tools/src/tools/sandbox/computer_use.rs @@ -1,8 +1,8 @@ use crate::adapter::LangGraphToolAdapter; use crate::Tool; -use crate::daytona::{ - get_or_create_active_sandbox, execute_command_in_sandbox, - start_computer_use_in_sandbox, check_computer_use_status, ensure_vnc_running_in_sandbox, +use crate::sandbox_core::manager::{get_or_create_active_sandbox, execute_command_in_sandbox, ensure_vnc_running_in_sandbox, get_sandbox_vnc_url}; +use crate::sandbox_core::daytona::{ + start_computer_use_in_sandbox, check_computer_use_status, DISPLAY_ID, }; use flock_core::db::DbManager; @@ -211,15 +211,23 @@ pub async fn computer_use( match act.as_str() { "interactive" => { - let proxy_url = crate::daytona::get_sandbox_vnc_url(&db, &sandbox_id).await - .unwrap_or_else(|_| format!("https://{}-{}.proxy.app.daytona.io/vnc.html?autoconnect=true&resize=scale", crate::daytona::WEBSOCKIFY_PORT, sandbox_id)); + let proxy_url = match get_sandbox_vnc_url(&db, &sandbox_id).await { + Ok(url) => url, + Err(e) => { + crate::emit_info(&flock_core::tr( + &format!("获取 VNC URL 失败: {}", e), + &format!("Failed to get VNC URL: {}", e) + )); + return Err(format!("无法获取沙盒 VNC 连接地址: {}", e)); + } + }; if let (Some(cid), Some(mid), Some(app_mgr)) = (call_id.clone(), msg_id, crate::get_global_approval_manager()) { crate::emit_info(&flock_core::tr( &format!("检测到敏感桌面操作(验证码/人工介入),正在通知前端拉起人工接管横幅 (Call ID: {})...", cid), &format!("Sensitive desktop action detected (captcha/human verification), notifying client to display takeover banner (Call ID: {})...", cid) )); - crate::daytona::emit_human_takeover( + crate::sandbox_core::state::emit_human_takeover( &cid, &mid, "人机协同远程桌面已拉起!检测到当前桌面应用需要人工介入(如滑动拼图验证、扫码登录等),大模型自动执行已暂停。您可以在右侧预览面板中直接操作页面。完成后请点击横幅上的【我已完成操作】按钮以恢复大模型的自动运行。", @@ -301,11 +309,14 @@ pub async fn computer_use( let (image_md, _screenshot_saved) = capture_desktop_screenshot(&db, &sandbox_id, &session_id, &name_id).await; - let proxy_url = match crate::daytona::get_sandbox_vnc_url(&db, &sandbox_id).await { + let proxy_url = match get_sandbox_vnc_url(&db, &sandbox_id).await { Ok(u) => u, Err(e) => { - crate::emit_info(&flock_core::tr(&format!("获取动态 VNC URL 失败: {}。使用静态备用 URL...", e), &format!("Failed to retrieve dynamic VNC URL: {}. Falling back to static VNC URL...", e))); - format!("https://{}-{}.proxy.app.daytona.io/vnc.html?autoconnect=true&resize=scale", crate::daytona::WEBSOCKIFY_PORT, sandbox_id) + crate::emit_info(&flock_core::tr( + &format!("获取动态 VNC URL 失败: {}。", e), + &format!("Failed to retrieve dynamic VNC URL: {}.", e) + )); + return Err(format!("无法获取沙盒 VNC 连接地址: {}", e)); } }; diff --git a/crates/flock-tools/src/tools/sandbox/fs_tools.rs b/crates/flock-tools/src/tools/sandbox/fs_tools.rs index 41ab00a2..e718c09c 100644 --- a/crates/flock-tools/src/tools/sandbox/fs_tools.rs +++ b/crates/flock-tools/src/tools/sandbox/fs_tools.rs @@ -1,6 +1,6 @@ use crate::adapter::LangGraphToolAdapter; use crate::Tool; -use crate::daytona::fs::DaytonaFs; +use crate::sandbox_core::daytona::fs::DaytonaFs; use flock_core::ipc_interface::events::ToolCategory; use langgraph::tool; diff --git a/crates/flock-tools/src/tools/sandbox/request_human_assistance.rs b/crates/flock-tools/src/tools/sandbox/request_human_assistance.rs index fdb69deb..3d4b00f7 100644 --- a/crates/flock-tools/src/tools/sandbox/request_human_assistance.rs +++ b/crates/flock-tools/src/tools/sandbox/request_human_assistance.rs @@ -1,7 +1,7 @@ use crate::adapter::LangGraphToolAdapter; use crate::Tool; -use crate::daytona::{ - get_or_create_active_sandbox, get_sandbox_vnc_url, +use crate::sandbox_core::manager::{get_or_create_active_sandbox, get_sandbox_vnc_url}; +use crate::sandbox_core::daytona::{ start_computer_use_in_sandbox, ensure_vnc_running_in_sandbox }; use flock_core::ipc_interface::events::ToolCategory; @@ -42,8 +42,12 @@ pub async fn request_human_assistance( // 3. 拿到当前的 VNC 控制台 URL 链接 let proxy_url = match get_sandbox_vnc_url(&db, &sandbox_id).await { Ok(u) => u, - Err(_) => { - format!("https://6080-{}.proxy.app.daytona.io/vnc.html?autoconnect=true&resize=scale", sandbox_id) + Err(e) => { + crate::emit_info(&flock_core::tr( + &format!("获取 VNC URL 失败: {}", e), + &format!("Failed to get VNC URL: {}", e) + )); + return Err(format!("无法获取沙盒 VNC 连接地址: {}", e)); } }; @@ -59,7 +63,7 @@ pub async fn request_human_assistance( reason ); - crate::daytona::emit_human_takeover( + crate::sandbox_core::state::emit_human_takeover( &cid, &mid, &display_message, diff --git a/crates/flock-tools/src/tools/sandbox/sandbox_exec.rs b/crates/flock-tools/src/tools/sandbox/sandbox_exec.rs index 3892b1b1..9fb4ed60 100644 --- a/crates/flock-tools/src/tools/sandbox/sandbox_exec.rs +++ b/crates/flock-tools/src/tools/sandbox/sandbox_exec.rs @@ -1,6 +1,6 @@ use crate::adapter::LangGraphToolAdapter; use crate::Tool; -use crate::daytona::{get_or_create_active_sandbox, execute_command_in_sandbox}; +use crate::sandbox_core::manager::{get_or_create_active_sandbox, execute_command_in_sandbox}; use flock_core::ipc_interface::events::ToolCategory; use langgraph::tool; @@ -65,7 +65,7 @@ pub async fn sandbox_exec( let db_clone = db.clone(); let sandbox_id_clone = sandbox_id.clone(); tokio::spawn(async move { - if let Err(e) = crate::daytona::sync::sync_down(&db_clone, &sandbox_id_clone, &ws_path).await { + if let Err(e) = crate::sandbox_core::sync::sync_down(&db_clone, &sandbox_id_clone, &ws_path).await { log::warn!("自动 Sync Down 失败: {}", e); } }); diff --git a/crates/flock-tools/tests/diagnose_e2b.rs b/crates/flock-tools/tests/diagnose_e2b.rs new file mode 100644 index 00000000..4ed70287 --- /dev/null +++ b/crates/flock-tools/tests/diagnose_e2b.rs @@ -0,0 +1,37 @@ +use flock_core::db::DbManager; +use flock_core::config::settings::SandboxConfig; + +#[tokio::test] +async fn test_diagnose_e2b() { + let db = DbManager::init().await.expect("Failed to init DB"); + // Get E2B config + let cfg = flock_tools::sandbox_core::config::get_sandbox_config(&db).await + .expect("No active sandbox config"); + + let api_key = cfg.e2b_api_key.as_ref().expect("E2B API key is missing"); + let base_url = cfg.e2b_api_url.as_deref().unwrap_or("https://api.e2b.app").trim_end_matches('/'); + + let client = reqwest::Client::new(); + let url = format!("{}/sandboxes", base_url); + let resp = client.get(&url) + .header("X-API-Key", api_key) + .send() + .await + .expect("Failed to get E2B sandboxes"); + + let text = resp.text().await.unwrap_or_default(); + println!("Running E2B Sandboxes: {}", text); + + let val: serde_json::Value = serde_json::from_str(&text).expect("Failed to parse JSON"); + if let Some(arr) = val.as_array() { + for sb in arr { + if let Some(sandbox_id) = sb.get("sandboxID").or_else(|| sb.get("sandbox_id")).and_then(|v| v.as_str()) { + println!("Diagnosing E2B Sandbox: {}", sandbox_id); + let diag_cmd = "echo '--- websockify.log ---'; cat /tmp/websockify.log; echo '--- x11vnc.log ---'; cat /tmp/x11vnc.log"; + if let Ok((stdout, _exit_code)) = flock_tools::sandbox_core::e2b::exec::execute_command(&cfg, sandbox_id, diag_cmd).await { + println!("Diagnostics output:\n{}", stdout); + } + } + } + } +} diff --git a/crates/flock-tools/tests/diagnose_sandbox.rs b/crates/flock-tools/tests/diagnose_sandbox.rs index 4a7476a6..cda54801 100644 --- a/crates/flock-tools/tests/diagnose_sandbox.rs +++ b/crates/flock-tools/tests/diagnose_sandbox.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use flock_core::db::DbManager; -use flock_tools::daytona::{get_sandbox_config, get_api_base, execute_command_in_sandbox}; +use flock_tools::sandbox_core::config::{get_sandbox_config, get_api_base}; +use flock_tools::sandbox_core::daytona::execute_command_in_sandbox; #[tokio::test] async fn test_diagnose_sandbox() { @@ -127,3 +128,123 @@ async fn test_diagnose_sandbox() { } } } + +#[tokio::test] +async fn test_diagnose_e2b_templates() { + let db = match DbManager::init().await { + Ok(d) => d, + Err(e) => { + println!("Failed to open DB: {}", e); + return; + } + }; + + // Load sandbox config (enabled or not) + let mut cfg: flock_core::config::settings::SandboxConfig = db.get_config("sandbox").await.unwrap(); + if let (Some(ct), Some(n)) = (&cfg.e2b_api_key_encrypted, &cfg.e2b_api_key_nonce) { + if let Ok(salt) = db.get_or_create_salt().await { + if let Ok(decrypted) = flock_core::crypto::decrypt_value(ct, n, &salt) { + cfg.e2b_api_key = Some(decrypted); + } + } + } + + let key = cfg.e2b_api_key.unwrap_or_default(); + println!("Retrieved E2B Key length: {}", key.len()); + if key.is_empty() { + println!("E2B API Key is empty in database!"); + return; + } + + println!("E2B Key starts with: {}", &key[..std::cmp::min(10, key.len())]); + + let client = reqwest::Client::new(); + let url = "https://api.e2b.app/templates"; + let resp = match client.get(url) + .header("X-API-Key", &key) + .send() + .await + { + Ok(r) => r, + Err(e) => { + println!("HTTP Request failed: {}", e); + return; + } + }; + + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + println!("HTTP Response Status: {}", status); + println!("HTTP Response Body: {}", text); +} + +#[tokio::test] +async fn test_diagnose_e2b_sandbox() { + let db = match DbManager::init().await { + Ok(d) => d, + Err(e) => { + println!("Failed to open DB: {}", e); + return; + } + }; + + let mut cfg: flock_core::config::settings::SandboxConfig = db.get_config("sandbox").await.unwrap(); + if let (Some(ct), Some(n)) = (&cfg.e2b_api_key_encrypted, &cfg.e2b_api_key_nonce) { + if let Ok(salt) = db.get_or_create_salt().await { + if let Ok(decrypted) = flock_core::crypto::decrypt_value(ct, n, &salt) { + cfg.e2b_api_key = Some(decrypted); + } + } + } + + // Set template to the desktop template ID + cfg.snapshot = Some("k0wmnzir0zuzye6dndlw".to_string()); + + use flock_tools::sandbox_core::provider::SandboxProvider; + let prov = flock_tools::sandbox_core::e2b::provider::E2bProvider; + + println!("Starting E2B sandbox with template k0wmnzir0zuzye6dndlw..."); + let sandbox_id = match prov.create_sandbox(&db, &cfg).await { + Ok(id) => id, + Err(e) => { + println!("Failed to create sandbox: {}", e); + return; + } + }; + println!("Sandbox started successfully: {}", sandbox_id); + + // Ensure VNC running + println!("Ensuring VNC is running..."); + if let Err(e) = flock_tools::sandbox_core::daytona::ensure_vnc_running_in_sandbox(&db, &sandbox_id).await { + println!("ensure_vnc_running_in_sandbox error: {}", e); + } + + // 1. Check listening ports and env + println!("\n--- Env and Path ---"); + let env_out = match prov.execute_command(&db, &cfg, &sandbox_id, "env && echo 'PATH IS:' && echo $PATH").await { + Ok((out, _)) => out, + Err(e) => format!("Error executing env command: {}", e), + }; + println!("{}", env_out); + + // 2. Check installed debian packages + println!("\n--- Debian Packages Check ---"); + let dpkg_out = match prov.execute_command(&db, &cfg, &sandbox_id, "dpkg -l | grep -iE 'vnc|xvfb|fluxbox|websockify|novnc'").await { + Ok((out, _)) => out, + Err(e) => format!("Error executing dpkg command: {}", e), + }; + println!("{}", dpkg_out); + + // 3. Check logs of vnc + println!("\n--- VNC logs ---"); + let log_out = match prov.execute_command(&db, &cfg, &sandbox_id, "cat /tmp/vnc_start.log /tmp/websockify.log /tmp/x11vnc.log 2>/dev/null").await { + Ok((out, _)) => out, + Err(e) => format!("Error executing cat command: {}", e), + }; + println!("{}", log_out); + + // Cleanup + println!("\nDestroying sandbox..."); + let _ = prov.destroy_sandbox(&db, &cfg, &sandbox_id).await; + println!("Sandbox destroyed."); +} diff --git a/flock-ui/src-tauri/src/commands/common/sandbox.rs b/flock-ui/src-tauri/src/commands/common/sandbox.rs index d1da0172..50e20862 100644 --- a/flock-ui/src-tauri/src/commands/common/sandbox.rs +++ b/flock-ui/src-tauri/src/commands/common/sandbox.rs @@ -1,90 +1,30 @@ use tauri::State; use crate::commands::assistant::SharedAgentState; -/// 手动销毁当前活跃的 Daytona 沙盒(并清除内存缓存) +/// 手动销毁当前活跃的沙盒(并清除内存缓存) #[tauri::command] pub async fn destroy_sandbox( db: State<'_, crate::SharedDbManager>, ) -> Result<(), String> { - flock_tools::daytona::destroy_active_sandbox(&*db) + flock_tools::sandbox_core::manager::destroy_active_sandbox(&*db) .await .map_err(|e| e.to_string()) } -/// 列出并销毁 Daytona 上所有运行中的沙盒(清理历史遗留的僵尸沙盒) +/// 列出并销毁所有运行中的沙盒(清理历史遗留的僵尸沙盒) #[tauri::command] pub async fn cleanup_all_sandboxes( db: State<'_, crate::SharedDbManager>, ) -> Result { - use flock_core::db::DbManager; - use flock_tools::daytona::{get_sandbox_config, get_api_base}; - - let db_ref: &DbManager = &*db; - let cfg = get_sandbox_config(db_ref).await - .ok_or_else(|| flock_core::tr("沙盒未配置或未启用", "Sandbox not configured or enabled"))?; - - let base = get_api_base(cfg.api_url.as_ref().unwrap()); - let api_key = cfg.api_key.as_ref().unwrap(); - - let client = reqwest::Client::new(); - let list_url = format!("{}/api/sandbox", base); - let resp = client.get(&list_url) - .header("Authorization", format!("Bearer {}", api_key)) - .send() + let db_ref = &*db; + let res = flock_tools::sandbox_core::manager::cleanup_all_sandbox_instances(db_ref) .await - .map_err(|e| flock_core::tr( - &format!("获取沙盒列表失败: {}", e), - &format!("Failed to retrieve sandbox list: {}", e) - ))?; - - let text = resp.text().await.unwrap_or_default(); - let val: serde_json::Value = serde_json::from_str(&text) - .map_err(|e| flock_core::tr( - &format!("解析沙盒列表失败: {}", e), - &format!("Failed to parse sandbox list: {}", e) - ))?; - - let sandboxes = val.as_array() - .cloned() - .unwrap_or_else(|| { - val.get("items").or_else(|| val.get("data")) - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default() - }); - - let mut deleted = 0usize; - let mut failed = 0usize; - - for sb in &sandboxes { - let id = sb.get("id").and_then(|v| v.as_str()).unwrap_or_default(); - if id.is_empty() { continue; } - - let state_str = sb.get("state").or_else(|| sb.get("status")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - - // 只销毁 started / running / stopped 状态(跳过已删除的) - if state_str == "deleted" || state_str == "archived" { continue; } - - let del_url = format!("{}/api/sandbox/{}", base, id); - match client.delete(&del_url) - .header("Authorization", format!("Bearer {}", api_key)) - .send() - .await - { - Ok(r) if r.status().is_success() => deleted += 1, - _ => failed += 1, - } - } + .map_err(|e| e.to_string())?; // 清除本地缓存 - let _ = flock_tools::daytona::destroy_active_sandbox(db_ref).await; + let _ = flock_tools::sandbox_core::manager::destroy_active_sandbox(db_ref).await; - Ok(flock_core::tr( - &format!("清理完成:已销毁 {} 个沙盒,失败 {} 个。", deleted, failed), - &format!("Cleanup complete: destroyed {} sandboxes, failed {}.", deleted, failed) - )) + Ok(res) } /// 获取当前活动沙盒的 VNC 代理链接 @@ -93,15 +33,27 @@ pub async fn get_active_sandbox_vnc_url( _state: State<'_, SharedAgentState>, db: State<'_, crate::SharedDbManager>, ) -> Result, String> { - if let Some(sandbox_id) = flock_tools::daytona::get_active_sandbox_id().await { - match flock_tools::daytona::get_sandbox_vnc_url(&*db, &sandbox_id).await { + if let Some(sandbox_id) = flock_tools::sandbox_core::manager::get_active_sandbox_id().await { + match flock_tools::sandbox_core::manager::get_sandbox_vnc_url(&*db, &sandbox_id).await { Ok(url) => Ok(Some(url)), - Err(e) => { - log::warn!("{}", flock_core::tr( - &format!("获取动态 VNC URL 失败: {}。使用静态备用 URL...", e), - &format!("Failed to retrieve dynamic VNC URL: {}. Using static fallback URL...", e) - )); - Ok(Some(format!("https://6080-{}.proxy.app.daytona.io/vnc.html?autoconnect=true&resize=scale", sandbox_id))) + Err(_) => { + let fallback_url = match flock_tools::sandbox_core::config::get_sandbox_config(&*db).await { + Some(cfg) => { + let provider_name = cfg.provider.as_deref().unwrap_or("e2b"); + let provider = flock_tools::sandbox_core::manager::get_provider(provider_name); + provider.get_vnc_url(&*db, &cfg, &sandbox_id).await.unwrap_or_else(|_| { + if provider_name == "e2b" { + format!("https://6080-{}.e2b.app/vnc.html?autoconnect=true&resize=scale&skip-preview-warning=true&skip_preview_warning=true", sandbox_id) + } else { + format!("https://6080-{}.proxy.app.daytona.io/vnc.html?autoconnect=true&resize=scale", sandbox_id) + } + }) + } + None => { + format!("https://6080-{}.proxy.app.daytona.io/vnc.html?autoconnect=true&resize=scale", sandbox_id) + } + }; + Ok(Some(fallback_url)) } } } else { diff --git a/flock-ui/src-tauri/src/commands/file.rs b/flock-ui/src-tauri/src/commands/file.rs index a31fa4d4..62f424b1 100644 --- a/flock-ui/src-tauri/src/commands/file.rs +++ b/flock-ui/src-tauri/src/commands/file.rs @@ -1,5 +1,6 @@ use crate::workspace; -use flock_tools::daytona::{get_active_sandbox_id, fs::DaytonaFs}; +use flock_tools::sandbox_core::manager::get_active_sandbox_id; +use flock_tools::sandbox_core::daytona::fs::DaytonaFs; use std::path::PathBuf; async fn is_sandbox_active(_db: &crate::SharedDbManager) -> bool { @@ -63,7 +64,7 @@ pub async fn list_workspace_files( } } -fn map_daytona_entry(entry: flock_tools::daytona::fs::DaytonaFileEntry) -> workspace::FileEntry { +fn map_daytona_entry(entry: flock_tools::sandbox_core::daytona::fs::DaytonaFileEntry) -> workspace::FileEntry { workspace::FileEntry { name: entry.name, path: entry.path, diff --git a/flock-ui/src-tauri/src/commands/settings.rs b/flock-ui/src-tauri/src/commands/settings.rs index bf806b15..bf0c2388 100644 --- a/flock-ui/src-tauri/src/commands/settings.rs +++ b/flock-ui/src-tauri/src/commands/settings.rs @@ -18,6 +18,15 @@ pub async fn get_app_config( } sandbox_cfg.api_key_encrypted = None; sandbox_cfg.api_key_nonce = None; + + if sandbox_cfg.e2b_api_key_encrypted.is_some() { + sandbox_cfg.e2b_api_key = Some("••••••••".to_string()); + } else { + sandbox_cfg.e2b_api_key = None; + } + sandbox_cfg.e2b_api_key_encrypted = None; + sandbox_cfg.e2b_api_key_nonce = None; + *val = serde_json::to_value(&sandbox_cfg).unwrap_or_default(); } } @@ -39,8 +48,8 @@ pub async fn set_app_config( let db_inner = db.inner().clone(); let old_sandbox: Option = db_inner.get_config("sandbox").await; + // Daytona api key let resolved_api_key = sandbox_cfg.api_key.clone(); - if let Some(ref key_str) = resolved_api_key { if key_str == "••••••••" { if let Some(ref old) = old_sandbox { @@ -65,6 +74,33 @@ pub async fn set_app_config( sandbox_cfg.api_key_encrypted = None; sandbox_cfg.api_key_nonce = None; } + + // E2B api key + let resolved_e2b_key = sandbox_cfg.e2b_api_key.clone(); + if let Some(ref key_str) = resolved_e2b_key { + if key_str == "••••••••" { + if let Some(ref old) = old_sandbox { + sandbox_cfg.e2b_api_key_encrypted = old.e2b_api_key_encrypted.clone(); + sandbox_cfg.e2b_api_key_nonce = old.e2b_api_key_nonce.clone(); + } + sandbox_cfg.e2b_api_key = None; + } else if key_str.is_empty() { + sandbox_cfg.e2b_api_key_encrypted = None; + sandbox_cfg.e2b_api_key_nonce = None; + sandbox_cfg.e2b_api_key = None; + } else { + if let Ok(salt) = db_inner.get_or_create_salt().await { + if let Ok((ct, n)) = flock_core::crypto::encrypt_value(key_str, &salt) { + sandbox_cfg.e2b_api_key_encrypted = Some(ct); + sandbox_cfg.e2b_api_key_nonce = Some(n); + sandbox_cfg.e2b_api_key = None; + } + } + } + } else { + sandbox_cfg.e2b_api_key_encrypted = None; + sandbox_cfg.e2b_api_key_nonce = None; + } final_value = serde_json::to_value(&sandbox_cfg).unwrap_or(final_value); } @@ -76,47 +112,110 @@ pub async fn set_app_config( &format!("Failed to save config '{}': {}", key, e) ))?; - // 当 sandbox 被禁用时,将其 provider 标记为不可用 + // 当 sandbox 被禁用时,将其 provider 标记为不可用。同时,清空当前活跃沙盒的内存缓存。 if key == "sandbox" { let enabled = final_value.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false); if !enabled { let _ = db.set_tool_provider_available("sandbox", false).await; } + + // 关键修复:清除旧的活跃沙盒缓存,防止状态穿透 + let _ = flock_tools::sandbox_core::manager::clear_active_sandbox_id().await; } Ok(()) } -/// 测试云端沙盒(Daytona)的连通性,成功后将 sandbox provider 标记为可用 +/// 自动构建 E2B 专属模板 +#[tauri::command] +pub async fn build_e2b_template( + app: tauri::AppHandle, + db: State<'_, SharedDbManager>, + name: String, +) -> Result { + use flock_tools::sandbox_core::config::get_sandbox_config; + + let cfg = get_sandbox_config(&db).await + .ok_or_else(|| "沙盒未配置".to_string())?; + + let api_key = cfg.e2b_api_key.as_deref().or(cfg.api_key.as_deref()) + .ok_or_else(|| "E2B API Key 未配置".to_string())?; + + let template_id = flock_tools::sandbox_core::e2b::builder::build_enhanced_template( + api_key, + cfg.e2b_api_url.as_deref(), + &name, + move |log| { + print!("{}", log); + use std::io::Write; + let _ = std::io::stdout().flush(); + use tauri::Emitter; + let _ = app.emit("e2b-build-log", log); + } + ).await.map_err(|e| e.to_string())?; + + Ok(template_id) +} + +/// 测试云端沙盒(E2B 或 Daytona)的连通性,成功后将 sandbox provider 标记为可用 #[tauri::command] pub async fn test_sandbox_connection( db: State<'_, SharedDbManager>, + provider: String, api_url: String, mut api_key: String, ) -> Result { if api_key == "••••••••" { let old_sandbox: Option = db.get_config("sandbox").await; if let Some(cfg) = old_sandbox { - if let (Some(ct), Some(n)) = (cfg.api_key_encrypted, cfg.api_key_nonce) { - if let Ok(salt) = db.get_or_create_salt().await { - if let Ok(decrypted) = flock_core::crypto::decrypt_value(&ct, &n, &salt) { - api_key = decrypted; + if provider == "e2b" { + if let (Some(ct), Some(n)) = (cfg.e2b_api_key_encrypted, cfg.e2b_api_key_nonce) { + if let Ok(salt) = db.get_or_create_salt().await { + if let Ok(decrypted) = flock_core::crypto::decrypt_value(&ct, &n, &salt) { + api_key = decrypted; + } } + } else if let Some(key) = cfg.e2b_api_key { + api_key = key; + } + } else { + if let (Some(ct), Some(n)) = (cfg.api_key_encrypted, cfg.api_key_nonce) { + if let Ok(salt) = db.get_or_create_salt().await { + if let Ok(decrypted) = flock_core::crypto::decrypt_value(&ct, &n, &salt) { + api_key = decrypted; + } + } + } else if let Some(key) = cfg.api_key { + api_key = key; } - } else if let Some(key) = cfg.api_key { - api_key = key; } } } + if provider == "local" { + db.set_tool_provider_available("sandbox", true) + .await + .map_err(|e| e.to_string())?; + return Ok(flock_core::tr("本地沙盒已启用", "Local sandbox enabled")); + } + let client = reqwest::Client::new(); - let base = flock_tools::daytona::get_api_base(&api_url); - let url = format!("{}/api/sandbox", base); + let (url, is_e2b) = if provider == "e2b" { + let base_url = api_url.trim_end_matches('/'); + (format!("{}/sandboxes", base_url), true) + } else { + let base = flock_tools::sandbox_core::config::get_api_base(&api_url); + (format!("{}/api/sandbox", base), false) + }; - let send_result = client.get(&url) - .header("Authorization", format!("Bearer {}", api_key)) - .send() - .await; + let request_builder = client.get(&url); + let request_builder = if is_e2b { + request_builder.header("X-API-Key", &api_key) + } else { + request_builder.header("Authorization", format!("Bearer {}", api_key)) + }; + + let send_result = request_builder.send().await; let res = match send_result { Ok(r) => r, @@ -156,7 +255,7 @@ pub async fn create_playwright_snapshot( db: State<'_, SharedDbManager>, snapshot_name: String, ) -> Result { - flock_tools::daytona::create_playwright_snapshot( + flock_tools::sandbox_core::daytona::create_playwright_snapshot( &*db, &snapshot_name, ) @@ -164,21 +263,26 @@ pub async fn create_playwright_snapshot( .map_err(|e| e.to_string()) } -/// 列出所有 Daytona 沙盒 +/// 列出 Daytona 沙盒实例(E2B 不显示实例列表) #[tauri::command] -pub async fn list_daytona_sandboxes( +pub async fn list_sandboxes( db: State<'_, SharedDbManager>, ) -> Result { use flock_core::db::DbManager; - use flock_tools::daytona::{get_sandbox_config, get_api_base}; let db_ref: &DbManager = &*db; - let cfg = match get_sandbox_config(db_ref).await { + let cfg = match get_sandbox_config_regardless(db_ref).await { Some(c) => c, None => return Ok(serde_json::json!([])), }; - let base = get_api_base(cfg.api_url.as_ref().unwrap()); + let provider = cfg.provider.as_deref().unwrap_or("e2b"); + // E2B 不显示 instances,Daytona 会有残留需要手动销毁 + if provider == "e2b" || provider == "local" { + return Ok(serde_json::json!([])); + } + + let base = flock_tools::sandbox_core::config::get_api_base(cfg.api_url.as_ref().unwrap()); let api_key = cfg.api_key.as_ref().unwrap(); let client = reqwest::Client::new(); @@ -202,20 +306,25 @@ pub async fn list_daytona_sandboxes( Ok(val) } -/// 删除指定 Daytona 沙盒 +/// 删除指定 Daytona 沙盒(E2B 不需要此操作) #[tauri::command] -pub async fn delete_daytona_sandbox( +pub async fn delete_sandbox( db: State<'_, SharedDbManager>, id: String, ) -> Result<(), String> { use flock_core::db::DbManager; - use flock_tools::daytona::{get_sandbox_config, get_api_base}; + use flock_tools::sandbox_core::config::get_sandbox_config; let db_ref: &DbManager = &*db; let cfg = get_sandbox_config(db_ref).await .ok_or_else(|| flock_core::tr("沙盒未配置或未启用", "Sandbox not configured or enabled"))?; - let base = get_api_base(cfg.api_url.as_ref().unwrap()); + let provider = cfg.provider.as_deref().unwrap_or("e2b"); + if provider == "e2b" || provider == "local" { + return Ok(()); + } + + let base = flock_tools::sandbox_core::config::get_api_base(cfg.api_url.as_ref().unwrap()); let api_key = cfg.api_key.as_ref().unwrap(); let client = reqwest::Client::new(); @@ -231,9 +340,9 @@ pub async fn delete_daytona_sandbox( if resp.status().is_success() { // 如果删除的是当前活跃的沙盒,清理本地缓存 - if let Some(active_id) = flock_tools::daytona::get_active_sandbox_id().await { + if let Some(active_id) = flock_tools::sandbox_core::manager::get_active_sandbox_id().await { if active_id == id { - let _ = flock_tools::daytona::destroy_active_sandbox(db_ref).await; + flock_tools::sandbox_core::manager::clear_active_sandbox_id().await; } } Ok(()) @@ -252,14 +361,13 @@ pub async fn reuse_sandbox( sandbox_id: String, ) -> Result { use flock_core::db::DbManager; - use flock_tools::daytona::{get_sandbox_config, check_sandbox_alive}; let db_ref: &DbManager = &*db; - let cfg = get_sandbox_config(db_ref).await + let cfg = flock_tools::sandbox_core::config::get_sandbox_config(db_ref).await .ok_or_else(|| flock_core::tr("沙盒未配置或未启用", "Sandbox not configured or enabled"))?; // 验证沙盒是否存活 - if !check_sandbox_alive(&cfg, &sandbox_id).await { + if !flock_tools::sandbox_core::manager::check_sandbox_alive(&cfg, &sandbox_id).await { return Err(flock_core::tr( &format!("沙盒 {} 不存在或已停止", sandbox_id), &format!("Sandbox {} does not exist or has stopped", sandbox_id) @@ -267,7 +375,7 @@ pub async fn reuse_sandbox( } // 设置为活跃沙盒 - let mutex = flock_tools::daytona::get_sandbox_id_mutex(); + let mutex = flock_tools::sandbox_core::manager::get_sandbox_id_mutex(); let mut lock = mutex.lock().await; *lock = Some(sandbox_id.clone()); @@ -277,79 +385,58 @@ pub async fn reuse_sandbox( )) } -/// 列出所有 Daytona 快照 +/// 列出所有 Daytona/E2B 快照/自定义模板 #[tauri::command] -pub async fn list_daytona_snapshots( +pub async fn list_sandbox_templates( db: State<'_, SharedDbManager>, + provider: Option, + api_key: Option, ) -> Result { use flock_core::db::DbManager; - use flock_tools::daytona::{get_sandbox_config, get_api_base}; let db_ref: &DbManager = &*db; - let cfg = match get_sandbox_config(db_ref).await { - Some(c) => c, - None => return Ok(serde_json::json!([])), - }; - - let base = get_api_base(cfg.api_url.as_ref().unwrap()); - let api_key = cfg.api_key.as_ref().unwrap(); + let mut cfg = get_sandbox_config_regardless(db_ref).await.unwrap_or_default(); + if let Some(p) = provider { + cfg.provider = Some(p); + } + if let Some(k) = api_key { + if !k.is_empty() && !k.contains('•') { + if cfg.provider.as_deref().unwrap_or("e2b") == "e2b" { + cfg.e2b_api_key = Some(k); + } else { + cfg.api_key = Some(k); + } + } + } - let client = reqwest::Client::new(); - let url = format!("{}/api/snapshots", base); - let resp = client.get(&url) - .header("Authorization", format!("Bearer {}", api_key)) - .send() + let provider_name = cfg.provider.as_deref().unwrap_or("e2b"); + let provider_impl = flock_tools::sandbox_core::manager::get_provider(provider_name); + provider_impl.list_templates(db_ref, &cfg) .await - .map_err(|e| flock_core::tr( - &format!("请求快照列表失败: {}", e), - &format!("Failed to request snapshot list: {}", e) - ))?; - - let text = resp.text().await.unwrap_or_default(); - let val: serde_json::Value = serde_json::from_str(&text) - .map_err(|e| flock_core::tr( - &format!("解析快照列表失败: {}", e), - &format!("Failed to parse snapshot list: {}", e) - ))?; - - Ok(val) + .map_err(|e| e.to_string()) } -/// 删除指定 Daytona 快照 +/// 删除指定 Daytona/E2B 快照/模板 #[tauri::command] -pub async fn delete_daytona_snapshot( +pub async fn delete_sandbox_template( db: State<'_, SharedDbManager>, id: String, + provider: Option, ) -> Result<(), String> { use flock_core::db::DbManager; - use flock_tools::daytona::{get_sandbox_config, get_api_base}; let db_ref: &DbManager = &*db; - let cfg = get_sandbox_config(db_ref).await - .ok_or_else(|| flock_core::tr("沙盒未配置或未启用", "Sandbox not configured or enabled"))?; - - let base = get_api_base(cfg.api_url.as_ref().unwrap()); - let api_key = cfg.api_key.as_ref().unwrap(); + let mut cfg = get_sandbox_config_regardless(db_ref).await + .ok_or_else(|| flock_core::tr("沙盒未配置", "Sandbox not configured"))?; + if let Some(p) = provider { + cfg.provider = Some(p); + } - let client = reqwest::Client::new(); - let url = format!("{}/api/snapshots/{}", base, id); - let resp = client.delete(&url) - .header("Authorization", format!("Bearer {}", api_key)) - .send() + let provider_name = cfg.provider.as_deref().unwrap_or("e2b"); + let provider_impl = flock_tools::sandbox_core::manager::get_provider(provider_name); + provider_impl.delete_template(db_ref, &cfg, &id) .await - .map_err(|e| flock_core::tr( - &format!("发送删除快照请求失败: {}", e), - &format!("Failed to send delete snapshot request: {}", e) - ))?; - - if resp.status().is_success() { - Ok(()) - } else { - Err(flock_core::tr( - &format!("删除快照失败,HTTP 状态码: {}", resp.status()), - &format!("Failed to delete snapshot, HTTP status code: {}", resp.status()) - )) - } + .map_err(|e| e.to_string()) } #[tauri::command] @@ -357,3 +444,27 @@ pub fn set_locale(locale: String) { flock_core::set_locale(&locale); } +async fn get_sandbox_config_regardless(db: &flock_core::db::DbManager) -> Option { + let mut cfg: flock_core::config::settings::SandboxConfig = db.get_config("sandbox").await?; + + // Decrypt Daytona key + if let (Some(ct), Some(n)) = (&cfg.api_key_encrypted, &cfg.api_key_nonce) { + if let Ok(salt) = db.get_or_create_salt().await { + if let Ok(decrypted) = flock_core::crypto::decrypt_value(ct, n, &salt) { + cfg.api_key = Some(decrypted); + } + } + } + + // Decrypt E2B key + if let (Some(ct), Some(n)) = (&cfg.e2b_api_key_encrypted, &cfg.e2b_api_key_nonce) { + if let Ok(salt) = db.get_or_create_salt().await { + if let Ok(decrypted) = flock_core::crypto::decrypt_value(ct, n, &salt) { + cfg.e2b_api_key = Some(decrypted); + } + } + } + + Some(cfg) +} + diff --git a/flock-ui/src-tauri/src/lib.rs b/flock-ui/src-tauri/src/lib.rs index 2dd7b4b1..f9457f82 100644 --- a/flock-ui/src-tauri/src/lib.rs +++ b/flock-ui/src-tauri/src/lib.rs @@ -132,14 +132,15 @@ pub fn run() { // 系统配置 commands::get_app_config, commands::set_app_config, + commands::build_e2b_template, commands::set_locale, commands::test_sandbox_connection, commands::create_playwright_snapshot, - commands::list_daytona_sandboxes, - commands::delete_daytona_sandbox, + commands::list_sandboxes, + commands::delete_sandbox, commands::reuse_sandbox, - commands::list_daytona_snapshots, - commands::delete_daytona_snapshot, + commands::list_sandbox_templates, + commands::delete_sandbox_template, // 工具提供商 commands::list_tool_providers, commands::list_tools, diff --git a/flock-ui/src/components/Settings/SandboxSettings/components/SandboxActions.tsx b/flock-ui/src/components/Settings/SandboxSettings/components/SandboxActions.tsx index 69820328..360d43e5 100644 --- a/flock-ui/src/components/Settings/SandboxSettings/components/SandboxActions.tsx +++ b/flock-ui/src/components/Settings/SandboxSettings/components/SandboxActions.tsx @@ -9,9 +9,12 @@ import { import { useTranslation } from 'react-i18next'; interface SandboxActionsProps { + provider: 'e2b' | 'daytona' | 'local'; isAvailable: boolean; apiUrl: string; apiKey: string; + e2bApiKey: string; + e2bApiUrl: string; testing: boolean; disabling: boolean; onTestConnection: () => void; @@ -19,15 +22,25 @@ interface SandboxActionsProps { } export function SandboxActions({ + provider, isAvailable, apiUrl, apiKey, + e2bApiKey, + e2bApiUrl, testing, disabling, onTestConnection, onDisable, }: SandboxActionsProps) { const { t } = useTranslation(); + + const isTestDisabled = () => { + if (provider === 'e2b') return !e2bApiKey.trim() || !e2bApiUrl.trim(); + if (provider === 'daytona') return !apiUrl.trim() || !apiKey.trim(); + return false; // local is never disabled + }; + return ( {/* Test / re-test button */} @@ -37,7 +50,7 @@ export function SandboxActions({ leftSection={} onClick={onTestConnection} loading={testing} - disabled={!apiUrl.trim() || !apiKey.trim()} + disabled={isTestDisabled()} > {t('settings.sandbox.testBtn')} diff --git a/flock-ui/src/components/Settings/SandboxSettings/components/SandboxCredentials.tsx b/flock-ui/src/components/Settings/SandboxSettings/components/SandboxCredentials.tsx index 214eb84b..154cc906 100644 --- a/flock-ui/src/components/Settings/SandboxSettings/components/SandboxCredentials.tsx +++ b/flock-ui/src/components/Settings/SandboxSettings/components/SandboxCredentials.tsx @@ -5,65 +5,136 @@ import { TextInput, PasswordInput, Tooltip, + Select, } from '@mantine/core'; import { IconServer, IconKey, IconHelpCircle, + IconCpu, } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; interface SandboxCredentialsProps { + provider: 'e2b' | 'daytona' | 'local'; + onProviderChange: (val: 'e2b' | 'daytona' | 'local') => void; apiUrl: string; apiKey: string; + e2bApiKey: string; + e2bApiUrl: string; onApiUrlChange: (val: string) => void; onApiKeyChange: (val: string) => void; + onE2bApiKeyChange: (val: string) => void; + onE2bApiUrlChange: (val: string) => void; } export function SandboxCredentials({ + provider, + onProviderChange, apiUrl, apiKey, + e2bApiKey, + e2bApiUrl, onApiUrlChange, onApiKeyChange, + onE2bApiKeyChange, + onE2bApiUrlChange, }: SandboxCredentialsProps) { const { t } = useTranslation(); return ( - - - {t('settings.sandbox.apiUrl')} - - - + + {t('settings.sandbox.provider', 'Sandbox Provider')} } - placeholder={t('settings.sandbox.apiUrlPlaceholder')} - value={apiUrl} - onChange={(e) => onApiUrlChange(e.currentTarget.value)} + data={[ + { value: 'e2b', label: 'E2B (Recommended)' }, + { value: 'daytona', label: 'Daytona' }, + { value: 'local', label: 'Local (Placeholder)' }, + ]} + value={provider} + onChange={(val) => onProviderChange(val as any)} styles={{ input: { background: 'var(--flock-bg-surface)' } }} /> - - - {t('settings.sandbox.apiKey')} - - } - placeholder={t('settings.sandbox.apiKeyPlaceholder')} - value={apiKey} - onChange={(e) => onApiKeyChange(e.currentTarget.value)} - styles={{ input: { background: 'var(--flock-bg-surface)' } }} - /> + {provider === 'e2b' && ( + <> + + + {t('settings.sandbox.apiUrl')} + + } + placeholder="https://api.e2b.app" + value={e2bApiUrl} + onChange={(e) => onE2bApiUrlChange(e.currentTarget.value)} + styles={{ input: { background: 'var(--flock-bg-surface)' } }} + /> + + + + E2B API Key + + } + placeholder="e2b_..." + value={e2bApiKey} + onChange={(e) => onE2bApiKeyChange(e.currentTarget.value)} + styles={{ input: { background: 'var(--flock-bg-surface)' } }} + /> + + )} + + {provider === 'daytona' && ( + <> + + + {t('settings.sandbox.apiUrl')} + + + + + } + placeholder={t('settings.sandbox.apiUrlPlaceholder')} + value={apiUrl} + onChange={(e) => onApiUrlChange(e.currentTarget.value)} + styles={{ input: { background: 'var(--flock-bg-surface)' } }} + /> + + + + {t('settings.sandbox.apiKey')} + + } + placeholder={t('settings.sandbox.apiKeyPlaceholder')} + value={apiKey} + onChange={(e) => onApiKeyChange(e.currentTarget.value)} + styles={{ input: { background: 'var(--flock-bg-surface)' } }} + /> + + )} + + {provider === 'local' && ( + + Local process isolation is currently under planning. Selected tools will execute directly on host machine with basic constraints. + + )} ); } diff --git a/flock-ui/src/components/Settings/SandboxSettings/components/SandboxListSection.tsx b/flock-ui/src/components/Settings/SandboxSettings/components/SandboxListSection.tsx index e29337e3..a7fbdc35 100644 --- a/flock-ui/src/components/Settings/SandboxSettings/components/SandboxListSection.tsx +++ b/flock-ui/src/components/Settings/SandboxSettings/components/SandboxListSection.tsx @@ -41,7 +41,7 @@ export function SandboxListSection() { const fetchSandboxes = async () => { setLoading(true); try { - const data = await invoke('list_daytona_sandboxes'); + const data = await invoke('list_sandboxes'); // 解析 sandboxes 数组 let list: SandboxItem[] = []; if (Array.isArray(data)) { @@ -103,7 +103,7 @@ export function SandboxListSection() { const handleDelete = async (id: string) => { setDeletingId(id); try { - await invoke('delete_daytona_sandbox', { id }); + await invoke('delete_sandbox', { id }); notifications.show({ title: t('common.success'), message: t('settings.sandbox.deleteSuccessMsg'), diff --git a/flock-ui/src/components/Settings/SandboxSettings/components/SnapshotListSection.tsx b/flock-ui/src/components/Settings/SandboxSettings/components/SnapshotListSection.tsx index 3cab5466..f6525e1c 100644 --- a/flock-ui/src/components/Settings/SandboxSettings/components/SnapshotListSection.tsx +++ b/flock-ui/src/components/Settings/SandboxSettings/components/SnapshotListSection.tsx @@ -13,6 +13,7 @@ import { Autocomplete, Alert, Divider, + Stack, } from '@mantine/core'; import { IconTrash, @@ -24,6 +25,7 @@ import { IconStar, IconStarFilled, IconInfoCircle, + IconCpu, } from '@tabler/icons-react'; import { invoke } from '@tauri-apps/api/core'; import { notifications } from '@mantine/notifications'; @@ -38,17 +40,25 @@ interface SnapshotItem { } interface SnapshotListSectionProps { + provider: 'e2b' | 'daytona' | 'local'; currentDefaultSnapshot: string; onSetDefaultSnapshot: (name: string) => void; onCreateSnapshot: (name: string) => Promise; creatingSnapshot: boolean; + buildingE2b?: boolean; + e2bBuildLogs?: string[]; + onBuildE2bTemplate?: (name: string) => void; } export function SnapshotListSection({ + provider, currentDefaultSnapshot, onSetDefaultSnapshot, onCreateSnapshot, creatingSnapshot, + buildingE2b, + e2bBuildLogs, + onBuildE2bTemplate, }: SnapshotListSectionProps) { const { t } = useTranslation(); const [snapshots, setSnapshots] = useState([]); @@ -56,12 +66,12 @@ export function SnapshotListSection({ const [deletingId, setDeletingId] = useState(null); const [newSnapshotName, setNewSnapshotName] = useState(''); - const defaultSnapshotName = 'flock-playwright'; + const defaultSnapshotName = provider === 'e2b' ? 'flock-e2b-desktop' : 'flock-daytona-desktop'; const fetchSnapshots = async () => { setLoading(true); try { - const data = await invoke('list_daytona_snapshots'); + const data = await invoke('list_sandbox_templates', { provider }); let list: SnapshotItem[] = []; if (Array.isArray(data)) { list = data; @@ -71,7 +81,9 @@ export function SnapshotListSection({ list = data.data; } // Filter out system-managed templates - const userSnapshots = list.filter((snap: any) => !snap.general && !snap.system && !snap.isSystem); + const userSnapshots = provider === 'e2b' + ? list + : list.filter((snap: any) => !snap.general && !snap.system && !snap.isSystem); setSnapshots(userSnapshots); } catch (e) { console.error('获取快照列表失败:', e); @@ -87,7 +99,7 @@ export function SnapshotListSection({ useEffect(() => { fetchSnapshots(); - }, []); + }, [provider]); const handleCopy = (text: string) => { navigator.clipboard.writeText(text); @@ -101,7 +113,7 @@ export function SnapshotListSection({ const handleDelete = async (id: string) => { setDeletingId(id); try { - await invoke('delete_daytona_snapshot', { id }); + await invoke('delete_sandbox_template', { id, provider }); notifications.show({ title: t('common.success'), message: t('settings.sandbox.deleteSnapshotSuccess'), @@ -122,20 +134,20 @@ export function SnapshotListSection({ }; const handleCreate = async () => { - const name = newSnapshotName.trim() || defaultSnapshotName; - const existing = snapshots.find((s) => s.name === name); + const nameOrId = newSnapshotName.trim() || defaultSnapshotName; + const existing = snapshots.find((s) => s.name === nameOrId || s.id === nameOrId); if (existing) { - await onSetDefaultSnapshot(name); + await onSetDefaultSnapshot(existing.id); setNewSnapshotName(''); return; } - await onCreateSnapshot(name); + await onCreateSnapshot(nameOrId); setNewSnapshotName(''); fetchSnapshots(); }; const renderState = (snap: SnapshotItem) => { - const stateStr = (snap.state || snap.snapshotState || 'unknown').toLowerCase(); + const stateStr = (snap.state || snap.snapshotState || snap.status || 'unknown').toLowerCase(); let color = 'gray'; if (stateStr === 'ready' || stateStr === 'active') color = 'teal'; if (stateStr === 'building' || stateStr === 'creating') color = 'blue'; @@ -167,7 +179,7 @@ export function SnapshotListSection({ - {t('settings.sandbox.createSnapshotTitle')} + {provider === 'e2b' ? 'E2B Custom Sandboxes / Templates' : t('settings.sandbox.createSnapshotTitle')} @@ -184,14 +196,26 @@ export function SnapshotListSection({ @@ -204,7 +228,9 @@ export function SnapshotListSection({ mb="lg" style={{ fontSize: 12 }} > - {t('settings.sandbox.snapshotHint')} + {provider === 'e2b' + ? 'You can enter a template ID above to build a custom snapshot template, or choose an existing snapshot template from the list below (click the star icon to set as default and reuse it instantly).' + : t('settings.sandbox.snapshotHint')} @@ -212,22 +238,35 @@ export function SnapshotListSection({ {/* 快照列表 */} - + {t('settings.sandbox.snapshots')} - + + + + {e2bBuildLogs && e2bBuildLogs.length > 0 && ( + + + {buildingE2b ? 'Building Custom Template... (this might take a few minutes)' : 'Build Logs / Status:'} + + {e2bBuildLogs?.map((log, i) => ( +
{log}
+ ))} +
+ )} + {loading && snapshots.length === 0 ? ( @@ -256,7 +295,7 @@ export function SnapshotListSection({ {snapshots.map((snap) => { - const isDefault = currentDefaultSnapshot === snap.name; + const isDefault = currentDefaultSnapshot === snap.name || currentDefaultSnapshot === snap.id; return ( @@ -287,7 +326,7 @@ export function SnapshotListSection({ variant="subtle" color={isDefault ? 'yellow' : 'gray'} size="sm" - onClick={() => onSetDefaultSnapshot(snap.name)} + onClick={() => onSetDefaultSnapshot(snap.id)} > {isDefault ? : } diff --git a/flock-ui/src/components/Settings/SandboxSettings/hooks/useSandboxSettings.ts b/flock-ui/src/components/Settings/SandboxSettings/hooks/useSandboxSettings.ts index 66b9d364..63caed7a 100644 --- a/flock-ui/src/components/Settings/SandboxSettings/hooks/useSandboxSettings.ts +++ b/flock-ui/src/components/Settings/SandboxSettings/hooks/useSandboxSettings.ts @@ -1,5 +1,6 @@ import { useState, useEffect } from 'react'; import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; import { notifications } from '@mantine/notifications'; import { useTranslation } from 'react-i18next'; import { IconCheck, IconAlertCircle, IconPlugConnectedX } from '@tabler/icons-react'; @@ -7,8 +8,11 @@ import React from 'react'; interface SandboxConfig { enabled: boolean; + provider: string | null; api_url: string | null; api_key: string | null; + e2b_api_key: string | null; + e2b_api_url: string | null; snapshot: string | null; } @@ -19,19 +23,50 @@ interface ToolProvider { export function useSandboxSettings() { const { t } = useTranslation(); + const [provider, setProvider] = useState<'e2b' | 'daytona' | 'local'>('e2b'); const [apiUrl, setApiUrl] = useState('https://app.daytona.io'); const [apiKey, setApiKey] = useState(''); + const [e2bApiKey, setE2bApiKey] = useState(''); + const [e2bApiUrl, setE2bApiUrl] = useState('https://api.e2b.app'); const [snapshot, setSnapshot] = useState(''); const [testing, setTesting] = useState(false); const [disabling, setDisabling] = useState(false); const [creatingSnapshot, setCreatingSnapshot] = useState(false); const [isAvailable, setIsAvailable] = useState(false); const [activeTab, setActiveTab] = useState('config'); + const [snapshotsList, setSnapshotsList] = useState<{ id: string; name: string }[]>([]); + const [buildingE2b, setBuildingE2b] = useState(false); + const [e2bBuildLogs, setE2bBuildLogs] = useState([]); + + const fetchSnapshotsList = async () => { + try { + const data = await invoke('list_sandbox_templates', { + provider, + apiKey: provider === 'e2b' ? e2bApiKey : apiKey, + }); + let list: any[] = []; + if (Array.isArray(data)) { + list = data; + } else if (data && Array.isArray(data.items)) { + list = data.items; + } else if (data && Array.isArray(data.data)) { + list = data.data; + } + setSnapshotsList(list.map((item: any) => ({ id: item.id || item.name, name: item.name || item.id }))); + } catch (e) { + console.error('Failed to fetch snapshots:', e); + setSnapshotsList([]); + } + }; useEffect(() => { loadAll(); }, []); + useEffect(() => { + fetchSnapshotsList(); + }, [provider, e2bApiKey]); + const loadAll = async () => { try { const [config, providers] = await Promise.all([ @@ -39,8 +74,11 @@ export function useSandboxSettings() { invoke('list_tool_providers'), ]); if (config) { + if (config.provider) setProvider(config.provider as any); if (config.api_url) setApiUrl(config.api_url); if (config.api_key) setApiKey(config.api_key); + if (config.e2b_api_key) setE2bApiKey(config.e2b_api_key); + if (config.e2b_api_url) setE2bApiUrl(config.e2b_api_url); if (config.snapshot) setSnapshot(config.snapshot); } const sandboxProvider = providers.find((p) => p.id === 'sandbox'); @@ -55,8 +93,11 @@ export function useSandboxSettings() { key: 'sandbox', value: { enabled: isAvailable, + provider, api_url: apiUrl.trim(), api_key: apiKey.trim(), + e2b_api_key: e2bApiKey.trim(), + e2b_api_url: e2bApiUrl.trim(), snapshot: snapshot.trim() || null, ...overrides, }, @@ -64,7 +105,15 @@ export function useSandboxSettings() { }; const handleTestConnection = async () => { - if (!apiUrl.trim() || !apiKey.trim()) { + if (provider === 'e2b' && (!e2bApiKey.trim() || !e2bApiUrl.trim())) { + notifications.show({ + title: t('common.failed'), + message: t('settings.sandbox.testMissingFields'), + color: 'yellow', + }); + return; + } + if (provider === 'daytona' && (!apiUrl.trim() || !apiKey.trim())) { notifications.show({ title: t('common.failed'), message: t('settings.sandbox.testMissingFields'), @@ -77,8 +126,9 @@ export function useSandboxSettings() { try { await saveConfig({ enabled: true }); await invoke('test_sandbox_connection', { - apiUrl: apiUrl.trim(), - apiKey: apiKey.trim(), + provider, + apiUrl: provider === 'e2b' ? e2bApiUrl.trim() : apiUrl.trim(), + apiKey: provider === 'e2b' ? e2bApiKey.trim() : (provider === 'daytona' ? apiKey.trim() : ''), }); setIsAvailable(true); notifications.show({ @@ -134,16 +184,27 @@ export function useSandboxSettings() { } try { - await invoke('create_playwright_snapshot', { snapshotName: snapName }); - setSnapshot(snapName); - await saveConfig({ snapshot: snapName }); - notifications.show({ - title: t('settings.sandbox.snapshotDone'), - message: t('settings.sandbox.snapshotDoneMsg', { name: snapName }), - color: 'teal', - icon: React.createElement(IconCheck, { size: 18 }), - autoClose: 8000, - }); + if (provider === 'e2b' || provider === 'local') { + setSnapshot(snapName); + await saveConfig({ snapshot: snapName }); + notifications.show({ + title: t('common.success'), + message: t('settings.sandbox.saveDefaultSuccess'), + color: 'teal', + icon: React.createElement(IconCheck, { size: 18 }), + }); + } else { + await invoke('create_playwright_snapshot', { snapshotName: snapName }); + setSnapshot(snapName); + await saveConfig({ snapshot: snapName }); + notifications.show({ + title: t('settings.sandbox.snapshotDone'), + message: t('settings.sandbox.snapshotDoneMsg', { name: snapName }), + color: 'teal', + icon: React.createElement(IconCheck, { size: 18 }), + autoClose: 8000, + }); + } } catch (e) { notifications.show({ title: t('settings.sandbox.snapshotFailed'), @@ -176,18 +237,62 @@ export function useSandboxSettings() { } }; + const handleBuildE2bTemplate = async (name: string) => { + setBuildingE2b(true); + setE2bBuildLogs([]); + let unlisten: (() => void) | undefined; + try { + await saveConfig(); // Save current API key + + unlisten = await listen('e2b-build-log', (event) => { + setE2bBuildLogs((prev) => [...prev, event.payload]); + }); + + const templateId = await invoke('build_e2b_template', { name }); + + notifications.show({ + title: t('common.success'), + message: `E2B template ${templateId} built successfully!`, + color: 'teal', + icon: React.createElement(IconCheck, { size: 18 }), + }); + + setSnapshot(templateId); + await saveConfig({ snapshot: templateId }); + fetchSnapshotsList(); + } catch (e) { + notifications.show({ + title: t('common.failed'), + message: String(e), + color: 'red', + icon: React.createElement(IconAlertCircle, { size: 18 }), + autoClose: 10000, + }); + } finally { + if (unlisten) unlisten(); + setBuildingE2b(false); + } + }; + return { + provider, setProvider, apiUrl, setApiUrl, apiKey, setApiKey, + e2bApiKey, setE2bApiKey, + e2bApiUrl, setE2bApiUrl, snapshot, testing, disabling, creatingSnapshot, isAvailable, activeTab, setActiveTab, + snapshotsList, + buildingE2b, + e2bBuildLogs, handleTestConnection, handleDisable, handleCreateSnapshot, handleSetDefaultSnapshot, + handleBuildE2bTemplate, }; } diff --git a/flock-ui/src/components/Settings/SandboxSettings/index.tsx b/flock-ui/src/components/Settings/SandboxSettings/index.tsx index 186fea82..416f3782 100644 --- a/flock-ui/src/components/Settings/SandboxSettings/index.tsx +++ b/flock-ui/src/components/Settings/SandboxSettings/index.tsx @@ -15,9 +15,7 @@ import { IconInfoCircle, IconPlugConnected, IconPlugConnectedX, - IconSettings, - IconCpu, - IconCamera, + } from '@tabler/icons-react'; import { useTranslation } from 'react-i18next'; import { SandboxCredentials } from './components/SandboxCredentials'; @@ -30,18 +28,25 @@ export default function SandboxSettings() { const { t } = useTranslation(); const { + provider, setProvider, apiUrl, setApiUrl, apiKey, setApiKey, + e2bApiKey, setE2bApiKey, + e2bApiUrl, setE2bApiUrl, snapshot, testing, disabling, creatingSnapshot, isAvailable, activeTab, setActiveTab, + snapshotsList, + buildingE2b, + e2bBuildLogs, handleTestConnection, handleDisable, handleCreateSnapshot, handleSetDefaultSnapshot, + handleBuildE2bTemplate, } = useSandboxSettings(); return ( @@ -89,42 +94,62 @@ export default function SandboxSettings() { )} - setActiveTab(val as any)} - data={[ - { value: 'config', label: t('settings.sandbox.tabConfig') }, - { value: 'instances', label: t('settings.sandbox.tabInstances'), disabled: !isAvailable }, - { value: 'snapshots', label: t('settings.sandbox.tabSnapshots'), disabled: !isAvailable }, - ]} - size="xs" - mb="lg" - styles={{ - root: { - background: 'var(--flock-bg-surface)', - border: '1px solid var(--flock-border-dim)', - padding: 2, - borderRadius: 8, - }, - control: { - minWidth: 100, - } - }} - /> + {(() => { + const tabs: { value: string; label: string; disabled?: boolean }[] = [ + { value: 'config', label: t('settings.sandbox.tabConfig') } + ]; + if (provider === 'daytona') { + tabs.push({ value: 'instances', label: t('settings.sandbox.tabInstances'), disabled: !isAvailable }); + } + if (provider === 'daytona' || provider === 'e2b') { + tabs.push({ value: 'snapshots', label: t('settings.sandbox.tabSnapshots'), disabled: !isAvailable }); + } + if (tabs.length <= 1) return null; + + return ( + setActiveTab(val as any)} + data={tabs} + size="xs" + mb="lg" + styles={{ + root: { + background: 'var(--flock-bg-surface)', + border: '1px solid var(--flock-border-dim)', + padding: 2, + borderRadius: 8, + }, + control: { + minWidth: 100, + } + }} + /> + ); + })()} {activeTab === 'config' && ( )} - {activeTab === 'instances' && isAvailable && ( + {provider === 'daytona' && activeTab === 'instances' && isAvailable && ( )} - {activeTab === 'snapshots' && isAvailable && ( + {(provider === 'daytona' || provider === 'e2b') && activeTab === 'snapshots' && isAvailable && ( - + {activeTab === 'snapshots' && ( + + )} )} diff --git a/flock-ui/src/i18n/locales/en/settings.json b/flock-ui/src/i18n/locales/en/settings.json index d6c95ea7..b0835531 100644 --- a/flock-ui/src/i18n/locales/en/settings.json +++ b/flock-ui/src/i18n/locales/en/settings.json @@ -85,8 +85,8 @@ }, "sandbox": { "title": "Cloud Sandbox Settings", - "enable": "Enable Daytona Sandbox", - "enableTooltip": "When enabled, code execution and browser tools will run inside secure Daytona cloud sandboxes, shielding your host system from any malicious code.", + "enable": "Enable Cloud Sandbox", + "enableTooltip": "When enabled, code execution and browser tools will run inside secure cloud sandboxes, shielding your host system from any malicious code.", "apiUrl": "API Endpoint URL", "apiUrlPlaceholder": "Enter Daytona Service URL (e.g., https://app.daytona.io)", "apiUrlTooltip": "Enter the base service URL of Daytona (e.g., https://app.daytona.io). Please DO NOT append /api at the end, the system formats the requests automatically.", @@ -94,9 +94,9 @@ "apiKeyPlaceholder": "Enter API token starting with dtn_", "testBtn": "Test Connection", "testOk": "Connection OK", - "testOkMsg": "Successfully connected to Daytona Cloud Sandbox!", + "testOkMsg": "Successfully connected to Cloud Sandbox!", "testFailed": "Connection Failed", - "testFailedMsg": "Failed to connect to Daytona: {{error}}", + "testFailedMsg": "Failed to connect to sandbox: {{error}}", "testMissingFields": "Please fill in API URL and API Key before proceeding.", "saveSuccess": "Sandbox Configuration Saved", "saveSuccessMsg": "Cloud sandbox settings have been updated", @@ -104,8 +104,8 @@ "saveFailedMsg": "Failed to update sandbox configuration in database", "snapshotName": "Snapshot Name", "snapshotActive": "Active", - "snapshotTooltip": "Specify an existing Daytona Snapshot name (pre-installed with Playwright etc.). Future sandboxes will use this snapshot directly, no repeated installs needed. Leave empty to use Daytona's default image.", - "snapshotHint": "When using the Browser tool for the first time, the default Daytona image needs to install ~300MB of Playwright dependencies. You can enter a new name above to create a pre-built snapshot, or choose an existing snapshot template from the list below (click the star icon to set as default and reuse it instantly, without recreating it).", + "snapshotTooltip": "Specify an existing snapshot template name (pre-installed with Playwright etc.). Future sandboxes will use this snapshot directly, no repeated installs needed. Leave empty to use default image.", + "snapshotHint": "When using the Browser tool for the first time, the default image needs to install ~300MB of Playwright dependencies. You can enter a new name above to create a pre-built snapshot, or choose an existing snapshot template from the list below (click the star icon to set as default and reuse it instantly, without recreating it).", "snapshotCreateBtn": "Initialize Playwright Snapshot (~3-5 min)", "snapshotCreating": "Creating snapshot, please wait...", "snapshotDone": "Snapshot Created", @@ -118,7 +118,7 @@ "statusInactive": "Inactive", "disableBtn": "Disable Sandbox", "disableSuccess": "Sandbox Disabled", - "disableSuccessMsg": "Daytona sandbox tools have been disabled. Run the connectivity test again to re-enable.", + "disableSuccessMsg": "Cloud sandbox tools have been disabled. Run the connectivity test again to re-enable.", "retestHint": "Sandbox is disabled. Run the connection test again to re-enable.", "testOkAutoEnabled": "Connection successful — sandbox auto-enabled", "tabConfig": "Configuration", diff --git a/flock-ui/src/i18n/locales/zh/settings.json b/flock-ui/src/i18n/locales/zh/settings.json index 083573ca..94973506 100644 --- a/flock-ui/src/i18n/locales/zh/settings.json +++ b/flock-ui/src/i18n/locales/zh/settings.json @@ -85,8 +85,8 @@ }, "sandbox": { "title": "云端沙箱配置", - "enable": "启用 Daytona 沙盒", - "enableTooltip": "开启后,代码执行与浏览器工具将完全在安全的云端沙箱(Daytona)中运行,避免本地宿主机受到安全威胁。", + "enable": "启用云端沙盒", + "enableTooltip": "开启后,代码执行与浏览器工具将完全在安全的云端沙箱中运行,避免本地宿主机受到安全威胁。", "apiUrl": "API 终结点地址", "apiUrlPlaceholder": "请输入 Daytona 服务地址 (如 https://app.daytona.io)", "apiUrlTooltip": "请填写 Daytona 服务地址(例如 https://app.daytona.io)。请勿在末尾带上 /api,系统会自动处理并拼接请求路径。", @@ -94,9 +94,9 @@ "apiKeyPlaceholder": "请输入以 dtn_ 开头的 API 密钥", "testBtn": "测试连接", "testOk": "连接成功", - "testOkMsg": "成功连接到 Daytona 云端沙盒!", + "testOkMsg": "成功连接到云端沙盒!", "testFailed": "连接失败", - "testFailedMsg": "无法连接到 Daytona: {{error}}", + "testFailedMsg": "无法连接到沙盒: {{error}}", "testMissingFields": "请先填写 API 地址与 API 密钥后再进行操作。", "saveSuccess": "沙盒配置已保存", "saveSuccessMsg": "云端沙盒设置更新成功", @@ -104,8 +104,8 @@ "saveFailedMsg": "无法更新数据库中的沙盒配置", "snapshotName": "Snapshot 快照名称", "snapshotActive": "使用中", - "snapshotTooltip": "指定一个已存在的 Daytona Snapshot 名称(预装了 Playwright 等工具),后续每次创建沙盒将直接使用此快照,无需重复安装依赖。留空则使用 Daytona 默认镜像。", - "snapshotHint": "首次使用浏览器工具时,Daytona 默认镜像需要安装约 300MB 的 Playwright 依赖。您可以在上方输入新名称以创建预装镜像,或者从下方已有模板列表中选择已有的快照模板(点击星星图标设为默认即可直接复用,无需重复创建)。", + "snapshotTooltip": "指定一个已存在的快照模板名称(预装了 Playwright 等工具),后续每次创建沙盒将直接使用此快照,无需重复安装依赖。留空则使用默认镜像。", + "snapshotHint": "首次使用浏览器工具时,默认镜像需要安装约 300MB 的 Playwright 依赖。您可以在上方输入新名称以创建预装镜像,或者从下方已有模板列表中选择已有的快照模板(点击星星图标设为默认即可直接复用,无需重复创建)。", "snapshotCreateBtn": "初始化 Playwright 快照(约 3-5 分钟)", "snapshotCreating": "正在创建快照,请勿关闭...", "snapshotDone": "Snapshot 创建成功", @@ -118,7 +118,7 @@ "statusInactive": "未启用", "disableBtn": "停用沙盒", "disableSuccess": "沙盒已停用", - "disableSuccessMsg": "已成功停用 Daytona 沙盒工具。如需重新启用,请再次测试连通性。", + "disableSuccessMsg": "已成功停用云端沙盒工具。如需重新启用,请再次测试连通性。", "retestHint": "沙盒已停用。如需重新启用,请重新测试连接。", "testOkAutoEnabled": "连接成功并已自动开启沙盒", "tabConfig": "参数配置",