Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ Make sure you have the following prerequisites installed:
- Rust toolchain (install via [rustup](https://rustup.rs/))
- [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for desktop development

**Windows only**: The desktop build links against a **prebuilt** OpenSSL (no OpenSSL source compile). `desktop:dev` and all `desktop:build*` scripts use `ensure-openssl-windows.mjs` (via `desktop-tauri-build.mjs` for builds): the first time OpenSSL is needed, it downloads [FireDaemon OpenSSL 3.5.5](https://download.firedaemon.com/FireDaemon-OpenSSL/openssl-3.5.5.zip) into `.bitfun/cache/`; later runs reuse that cache. Override with `OPENSSL_DIR` pointing at the **`x64`** folder from the ZIP, or `BITFUN_SKIP_OPENSSL_BOOTSTRAP=1` and your own `OPENSSL_*`.
**Windows only**: The desktop build links against a **prebuilt** OpenSSL (no OpenSSL source compile). You do **not** need to download the ZIP by hand: the first time OpenSSL is required, tooling fetches [FireDaemon OpenSSL 3.5.5](https://download.firedaemon.com/FireDaemon-OpenSSL/openssl-3.5.5.zip) into **`.bitfun/cache/`** and later runs reuse that cache. **`pnpm run desktop:dev`** and all **`pnpm run desktop:build*`** scripts run `ensure-openssl-windows.mjs` (builds use `desktop-tauri-build.mjs`). **If you compile with plain `cargo`** (without those pnpm entrypoints), run **`node scripts/ensure-openssl-windows.mjs`** once from the repo root first — it performs the same download and prints **`OPENSSL_*`** lines for PowerShell. Override with `OPENSSL_DIR` pointing at the **`x64`** folder from the ZIP, or `BITFUN_SKIP_OPENSSL_BOOTSTRAP=1` and your own `OPENSSL_*`.

```bash
# Install dependencies
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ Mini Apps 从对话中涌现,Skills 在社区里更新,Agent 在协作中进
- [Rust 工具链](https://rustup.rs/)
- [Tauri 前置依赖](https://v2.tauri.app/start/prerequisites/)(桌面端开发需要)

**Windows 特别说明**:桌面使用**预编译 OpenSSL**(不编译 OpenSSL 源码)。`desktop:dev` 与全部 `desktop:build*` 会通过 `ensure-openssl-windows.mjs`(构建走 `desktop-tauri-build.mjs`)自动准备:首次需要时下载 [FireDaemon OpenSSL 3.5.5](https://download.firedaemon.com/FireDaemon-OpenSSL/openssl-3.5.5.zip) 到 `.bitfun/cache/`,之后复用缓存。可自行设置 `OPENSSL_DIR` ZIP 内 **`x64`** 目录, `BITFUN_SKIP_OPENSSL_BOOTSTRAP=1` 并自行配置 `OPENSSL_*`。
**Windows 特别说明**:桌面使用**预编译 OpenSSL**(不编译 OpenSSL 源码)。**无需手动下载 ZIP**:首次需要时会自动拉取 [FireDaemon OpenSSL 3.5.5](https://download.firedaemon.com/FireDaemon-OpenSSL/openssl-3.5.5.zip) 到 **`.bitfun/cache/`**,之后复用缓存。`pnpm run desktop:dev` 与全部 `desktop:build*` 会调用 `ensure-openssl-windows.mjs`(构建经 `desktop-tauri-build.mjs`)。**若只用 `cargo` 手动编译**(不经过上述 pnpm 入口),请先在仓库根目录执行一次 **`node scripts/ensure-openssl-windows.mjs`**,脚本会完成相同下载并打印可在 PowerShell 中粘贴的 **`OPENSSL_*`** 环境变量。也可自行将 `OPENSSL_DIR` 设为 ZIP 内 **`x64`** 目录,或设 `BITFUN_SKIP_OPENSSL_BOOTSTRAP=1` 并自行配置 `OPENSSL_*`。

```bash
# 安装依赖
Expand Down
72 changes: 69 additions & 3 deletions src/apps/desktop/src/api/agentic_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ pub struct CreateSessionRequest {
pub session_name: String,
pub agent_type: String,
pub workspace_path: String,
#[serde(default)]
pub remote_connection_id: Option<String>,
pub config: Option<SessionConfigDTO>,
}

Expand All @@ -36,6 +38,8 @@ pub struct SessionConfigDTO {
pub enable_context_compression: Option<bool>,
pub compression_threshold: Option<f32>,
pub model_name: Option<String>,
#[serde(default)]
pub remote_connection_id: Option<String>,
}

#[derive(Debug, Serialize)]
Expand Down Expand Up @@ -73,6 +77,15 @@ pub struct StartDialogTurnResponse {
pub message: String,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EnsureCoordinatorSessionRequest {
pub session_id: String,
pub workspace_path: String,
#[serde(default)]
pub remote_connection_id: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EnsureAssistantBootstrapRequest {
Expand Down Expand Up @@ -142,19 +155,25 @@ pub struct CancelToolRequest {
pub struct DeleteSessionRequest {
pub session_id: String,
pub workspace_path: String,
#[serde(default)]
pub remote_connection_id: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RestoreSessionRequest {
pub session_id: String,
pub workspace_path: String,
#[serde(default)]
pub remote_connection_id: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListSessionsRequest {
pub workspace_path: String,
#[serde(default)]
pub remote_connection_id: Option<String>,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -186,6 +205,16 @@ pub async fn create_session(
coordinator: State<'_, Arc<ConversationCoordinator>>,
request: CreateSessionRequest,
) -> Result<CreateSessionResponse, String> {
fn norm_conn(s: Option<String>) -> Option<String> {
s.map(|x| x.trim().to_string()).filter(|x| !x.is_empty())
}
let remote_conn = norm_conn(request.remote_connection_id.clone()).or_else(|| {
request
.config
.as_ref()
.and_then(|c| norm_conn(c.remote_connection_id.clone()))
});

let config = request
.config
.map(|c| SessionConfig {
Expand All @@ -197,10 +226,12 @@ pub async fn create_session(
enable_context_compression: c.enable_context_compression.unwrap_or(true),
compression_threshold: c.compression_threshold.unwrap_or(0.8),
workspace_path: Some(request.workspace_path.clone()),
remote_connection_id: remote_conn.clone(),
model_id: c.model_name,
})
.unwrap_or(SessionConfig {
workspace_path: Some(request.workspace_path.clone()),
remote_connection_id: remote_conn.clone(),
..Default::default()
});

Expand Down Expand Up @@ -233,6 +264,38 @@ pub async fn update_session_model(
.map_err(|e| format!("Failed to update session model: {}", e))
}

/// Load the session into the coordinator process when it exists on disk but is not in memory.
/// Uses the same remote→local session path mapping as `restore_session`.
#[tauri::command]
pub async fn ensure_coordinator_session(
coordinator: State<'_, Arc<ConversationCoordinator>>,
request: EnsureCoordinatorSessionRequest,
) -> Result<(), String> {
let session_id = request.session_id.trim();
if session_id.is_empty() {
return Err("session_id is required".to_string());
}
if coordinator
.get_session_manager()
.get_session(session_id)
.is_some()
{
return Ok(());
}

let wp = request.workspace_path.trim();
if wp.is_empty() {
return Err("workspace_path is required when the session is not loaded".to_string());
}

let effective = get_effective_session_path(wp, request.remote_connection_id.as_deref()).await;
coordinator
.restore_session(&effective, session_id)
.await
.map(|_| ())
.map_err(|e| e.to_string())
}

#[tauri::command]
pub async fn start_dialog_turn(
_app: AppHandle,
Expand Down Expand Up @@ -426,7 +489,8 @@ pub async fn delete_session(
coordinator: State<'_, Arc<ConversationCoordinator>>,
request: DeleteSessionRequest,
) -> Result<(), String> {
let effective_path = get_effective_session_path(&request.workspace_path).await;
let effective_path =
get_effective_session_path(&request.workspace_path, request.remote_connection_id.as_deref()).await;
coordinator
.delete_session(&effective_path, &request.session_id)
.await
Expand All @@ -438,7 +502,8 @@ pub async fn restore_session(
coordinator: State<'_, Arc<ConversationCoordinator>>,
request: RestoreSessionRequest,
) -> Result<SessionResponse, String> {
let effective_path = get_effective_session_path(&request.workspace_path).await;
let effective_path =
get_effective_session_path(&request.workspace_path, request.remote_connection_id.as_deref()).await;
let session = coordinator
.restore_session(&effective_path, &request.session_id)
.await
Expand All @@ -453,7 +518,8 @@ pub async fn list_sessions(
request: ListSessionsRequest,
) -> Result<Vec<SessionResponse>, String> {
// Map remote workspace path to local session storage path
let effective_path = get_effective_session_path(&request.workspace_path).await;
let effective_path =
get_effective_session_path(&request.workspace_path, request.remote_connection_id.as_deref()).await;
let summaries = coordinator
.list_sessions(&effective_path)
.await
Expand Down
63 changes: 41 additions & 22 deletions src/apps/desktop/src/api/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,9 @@ impl AppState {
workspace.connection_id.clone(),
workspace.connection_name.clone(),
).await;
state_manager
.set_active_connection_hint(Some(workspace.connection_id.clone()))
.await;
log::info!("Remote workspace registered: {} on {}",
workspace.remote_path, workspace.connection_name);
Ok(())
Expand All @@ -381,32 +384,48 @@ impl AppState {
self.remote_workspace.read().await.clone()
}

/// Clear current remote workspace
pub async fn clear_remote_workspace(&self) {
// Get the remote_path before clearing so we can unregister the specific workspace
let remote_path = {
let guard = self.remote_workspace.read().await;
guard.as_ref().map(|w| w.remote_path.clone())
};

// Clear local state
*self.remote_workspace.write().await = None;

// Remove this specific workspace from persistence (not all of them)
if let Some(path) = &remote_path {
if let Ok(manager) = self.get_ssh_manager_async().await {
if let Err(e) = manager.remove_remote_workspace(path).await {
log::warn!("Failed to remove persisted remote workspace: {}", e);
}
/// Remove one remote workspace from persistence + registry (`connection_id` + `remote_path`).
pub async fn unregister_remote_workspace_entry(&self, connection_id: &str, remote_path: &str) {
let rp = bitfun_core::service::remote_ssh::normalize_remote_workspace_path(remote_path);
if let Ok(manager) = self.get_ssh_manager_async().await {
if let Err(e) = manager.remove_remote_workspace(connection_id, &rp).await {
log::warn!("Failed to remove persisted remote workspace: {}", e);
}

// Unregister from the global registry
if let Some(state_manager) = bitfun_core::service::remote_ssh::get_remote_workspace_manager() {
state_manager.unregister_remote_workspace(path).await;
}
if let Some(state_manager) = bitfun_core::service::remote_ssh::get_remote_workspace_manager() {
state_manager
.unregister_remote_workspace(connection_id, &rp)
.await;
}
let mut slot = self.remote_workspace.write().await;
let clear_slot = slot
.as_ref()
.map(|w| {
w.connection_id == connection_id
&& bitfun_core::service::remote_ssh::normalize_remote_workspace_path(&w.remote_path)
== rp
})
.unwrap_or(false);
if clear_slot {
*slot = None;
if let Some(m) = bitfun_core::service::remote_ssh::get_remote_workspace_manager() {
m.set_active_connection_hint(None).await;
}
}
log::info!(
"Remote workspace entry removed: connection_id={}, remote_path={}",
connection_id,
rp
);
}

log::info!("Remote workspace unregistered: {:?}", remote_path);
/// Clear current remote pointer and remove its persisted/registry entry (legacy SSH "close").
pub async fn clear_remote_workspace(&self) {
let snap = { self.remote_workspace.read().await.clone() };
if let Some(w) = snap {
self.unregister_remote_workspace_entry(&w.connection_id, &w.remote_path)
.await;
}
}

/// Check if currently in a remote workspace
Expand Down
Loading
Loading