From a763adaf365342eb035d60b26215599022fe0f87 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sun, 12 Apr 2026 21:43:54 +0800 Subject: [PATCH] feat: stream real-time progress for agent SDK install/upgrade/uninstall Replace the spinner-only UX with live log output during agent SDK operations, matching the existing OpenCode plugin install experience. Backend: emit structured events (started/log/completed/failed) via EventEmitter during npm install and binary download. npm commands now run with piped stdio for line-by-line streaming; binary downloads report chunked progress every 1 MB. Frontend: subscribe to `app://agent-install` events through a new `useAgentInstallStream` hook and render a theme-aware log terminal below the preflight checks panel. Also fixes the install log container in both agent settings and the OpenCode plugins modal: auto-scroll no longer shifts the outer page, and colours now follow the active light/dark theme. Co-Authored-By: Claude Opus 4.6 (1M context) --- src-tauri/src/acp/binary_cache.rs | 71 +++- src-tauri/src/commands/acp.rs | 364 ++++++++++++++---- src-tauri/src/web/event_bridge.rs | 4 + src-tauri/src/web/handlers/acp.rs | 24 +- .../settings/acp-agent-settings.tsx | 71 +++- .../settings/opencode-plugins-modal.tsx | 11 +- src/hooks/use-agent-install-stream.ts | 74 ++++ src/lib/api.ts | 16 +- src/lib/tauri.ts | 16 +- src/lib/types.ts | 8 + 10 files changed, 541 insertions(+), 118 deletions(-) create mode 100644 src/hooks/use-agent-install-stream.ts diff --git a/src-tauri/src/acp/binary_cache.rs b/src-tauri/src/acp/binary_cache.rs index e7a3393..2c71e21 100644 --- a/src-tauri/src/acp/binary_cache.rs +++ b/src-tauri/src/acp/binary_cache.rs @@ -188,29 +188,30 @@ fn parse_version_parts(input: &str) -> Vec { .collect() } -/// Ensure a binary agent is available locally. -/// Returns the absolute path to the executable. -pub async fn ensure_binary_for_agent( +/// Same as `ensure_binary_for_agent` but calls `on_progress` with human-readable +/// status messages during download / extraction. +pub async fn ensure_binary_for_agent_with_progress( agent_type: AgentType, version: &str, archive_url: &str, cmd_name: &str, + on_progress: impl Fn(&str), ) -> Result { if let Some(path) = find_cached_binary_for_agent(agent_type, version, cmd_name)? { + on_progress("Binary already cached, skipping download"); return Ok(path); } let agent_id = agent_cache_key(agent_type); - ensure_binary(&agent_id, version, archive_url, cmd_name).await + ensure_binary_with_progress(&agent_id, version, archive_url, cmd_name, on_progress).await } -/// Ensure a binary is available for a specific cache key. -/// Returns the absolute path to the executable. -pub async fn ensure_binary( +async fn ensure_binary_with_progress( agent_id: &str, version: &str, archive_url: &str, cmd_name: &str, + on_progress: impl Fn(&str), ) -> Result { if let Some(path) = find_cached_binary(agent_id, version, cmd_name)? { return Ok(path); @@ -236,12 +237,14 @@ pub async fn ensure_binary( let result: Result = async { let archive_path = tmp_dir.join("archive"); - download_file(archive_url, &archive_path).await?; + on_progress(&format!("Downloading {archive_url}")); + download_file_with_progress(archive_url, &archive_path, &on_progress).await?; let extract_dir = tmp_dir.join("extracted"); std::fs::create_dir_all(&extract_dir) .map_err(|e| AcpError::DownloadFailed(format!("failed to create extract dir: {e}")))?; + on_progress("Extracting archive..."); if archive_url.ends_with(".tar.gz") || archive_url.ends_with(".tgz") { extract_tar_gz(&archive_path, &extract_dir)?; } else if archive_url.ends_with(".tar.bz2") || archive_url.ends_with(".tbz2") { @@ -255,6 +258,7 @@ pub async fn ensure_binary( } // Find the binary in extracted files and move to final location. + on_progress("Locating binary..."); let extracted_bin = find_binary_recursive(&extract_dir, &bin_name).ok_or_else(|| { AcpError::DownloadFailed(format!("binary '{bin_name}' not found in archive")) })?; @@ -270,6 +274,7 @@ pub async fn ensure_binary( )); } set_executable_permissions(&final_path)?; + on_progress("Binary installed successfully"); Ok(final_path) } .await; @@ -313,7 +318,13 @@ pub(crate) fn find_binary_recursive(dir: &PathBuf, name: &str) -> Option Result<(), AcpError> { +async fn download_file_with_progress( + url: &str, + dest: &PathBuf, + on_progress: &impl Fn(&str), +) -> Result<(), AcpError> { + use futures_util::StreamExt; + let response = reqwest::Client::new() .get(url) .send() @@ -327,13 +338,43 @@ async fn download_file(url: &str, dest: &PathBuf) -> Result<(), AcpError> { ))); } - let bytes = response - .bytes() - .await - .map_err(|e| AcpError::DownloadFailed(format!("failed to read response: {e}")))?; + let total_size = response.content_length(); + let mut downloaded: u64 = 0; + let mut last_reported_mb: u64 = 0; + let mut stream = response.bytes_stream(); + let mut file = std::fs::File::create(dest) + .map_err(|e| AcpError::DownloadFailed(format!("failed to create archive file: {e}")))?; - std::fs::write(dest, &bytes) - .map_err(|e| AcpError::DownloadFailed(format!("failed to write archive: {e}")))?; + use std::io::Write; + while let Some(chunk) = stream.next().await { + let chunk = + chunk.map_err(|e| AcpError::DownloadFailed(format!("failed to read chunk: {e}")))?; + file.write_all(&chunk) + .map_err(|e| AcpError::DownloadFailed(format!("failed to write archive: {e}")))?; + downloaded += chunk.len() as u64; + + // Report progress every 1MB + let current_mb = downloaded / (1024 * 1024); + if current_mb > last_reported_mb { + last_reported_mb = current_mb; + if let Some(total) = total_size { + let total_mb = total as f64 / (1024.0 * 1024.0); + on_progress(&format!( + "Downloading... {current_mb:.0} MB / {total_mb:.1} MB" + )); + } else { + on_progress(&format!("Downloading... {current_mb:.0} MB")); + } + } + } + + if let Some(total) = total_size { + let total_mb = total as f64 / (1024.0 * 1024.0); + on_progress(&format!("Download complete ({total_mb:.1} MB)")); + } else { + let final_mb = downloaded as f64 / (1024.0 * 1024.0); + on_progress(&format!("Download complete ({final_mb:.1} MB)")); + } Ok(()) } diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index 96503a4..cfcf26c 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -46,6 +46,41 @@ fn emit_acp_agents_updated( ); } +const AGENT_INSTALL_EVENT: &str = "app://agent-install"; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum AgentInstallEventKind { + Started, + Log, + Completed, + Failed, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct AgentInstallEvent { + pub task_id: String, + pub kind: AgentInstallEventKind, + pub payload: String, +} + +fn emit_agent_install_event( + emitter: &EventEmitter, + task_id: &str, + kind: AgentInstallEventKind, + payload: impl Into, +) { + crate::web::event_bridge::emit_event( + emitter, + AGENT_INSTALL_EVENT, + AgentInstallEvent { + task_id: task_id.to_string(), + kind, + payload: payload.into(), + }, + ); +} + fn is_version_like(value: &str) -> bool { value.chars().any(|c| c.is_ascii_digit()) && value.contains('.') } @@ -215,46 +250,130 @@ async fn detect_local_version(agent_type: AgentType) -> Option { /// may not have synced niche packages like `@agentclientprotocol/*`. const NPM_OFFICIAL_REGISTRY: &str = "https://registry.npmjs.org"; -async fn install_npm_global_package(package: &str) -> Result<(), AcpError> { +/// Run an npm command with piped stdout/stderr, streaming each line as a log event. +/// Returns (success: bool, collected_stderr: String) so callers can inspect errors. +async fn run_npm_streaming( + args: &[&str], + task_id: &str, + emitter: &EventEmitter, +) -> Result<(bool, String), AcpError> { + use tokio::io::{AsyncBufReadExt, BufReader}; + + let mut cmd = crate::process::tokio_command("npm"); + for arg in args { + cmd.arg(arg); + } + cmd.stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + let mut child = cmd.spawn().map_err(|e| { + AcpError::protocol(format!("failed to spawn npm: {e}")) + })?; + + let stdout = child.stdout.take(); + let stderr = child.stderr.take(); + let emitter_clone = emitter.clone(); + let task_id_owned = task_id.to_string(); + + let stdout_handle = tokio::spawn({ + let emitter = emitter_clone.clone(); + let task_id = task_id_owned.clone(); + async move { + if let Some(out) = stdout { + let reader = BufReader::new(out); + let mut lines = reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + emit_agent_install_event( + &emitter, &task_id, AgentInstallEventKind::Log, &line, + ); + } + } + } + }); + + let stderr_handle = tokio::spawn({ + let emitter = emitter_clone; + let task_id = task_id_owned; + async move { + let mut collected = String::new(); + if let Some(err) = stderr { + let reader = BufReader::new(err); + let mut lines = reader.lines(); + while let Ok(Some(line)) = lines.next_line().await { + emit_agent_install_event( + &emitter, &task_id, AgentInstallEventKind::Log, &line, + ); + if !collected.is_empty() { + collected.push('\n'); + } + collected.push_str(&line); + } + } + collected + } + }); + + let (_, stderr_result) = tokio::join!(stdout_handle, stderr_handle); + let collected_stderr = stderr_result.unwrap_or_default(); + + let status = child.wait().await.map_err(|e| { + AcpError::protocol(format!("failed to wait for npm process: {e}")) + })?; + + Ok((status.success(), collected_stderr)) +} + +async fn install_npm_global_package_streaming( + package: &str, + task_id: &str, + emitter: &EventEmitter, +) -> Result<(), AcpError> { let registry_arg = format!("--registry={NPM_OFFICIAL_REGISTRY}"); - let output = crate::process::tokio_command("npm") - .arg("install") - .arg("-g") - .arg(®istry_arg) - .arg(package) - .output() - .await - .map_err(|e| AcpError::protocol(format!("failed to run npm install -g: {e}")))?; + emit_agent_install_event( + emitter, task_id, AgentInstallEventKind::Log, + format!("$ npm install -g {package}"), + ); - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); + let (success, stderr) = run_npm_streaming( + &["install", "-g", ®istry_arg, package], + task_id, + emitter, + ).await?; + + if !success { // EACCES: permission denied — retry with a user-local --prefix so // we don't require root/sudo on macOS / Linux. - // Check EACCES first: an EEXIST error message may also contain EACCES - // context, and the --force retry would fail again without the prefix - // fallback. if stderr.contains("EACCES") { - return install_npm_to_user_prefix(package, ®istry_arg).await; + emit_agent_install_event( + emitter, task_id, AgentInstallEventKind::Log, + "Permission denied, retrying with user prefix...", + ); + return install_npm_to_user_prefix_streaming( + package, ®istry_arg, task_id, emitter, + ).await; } // EEXIST: file conflict — retry with --force to overwrite if stderr.contains("EEXIST") { - let retry = crate::process::tokio_command("npm") - .arg("install") - .arg("-g") - .arg("--force") - .arg(®istry_arg) - .arg(package) - .output() - .await - .map_err(|e| AcpError::protocol(format!("failed to run npm install -g --force: {e}")))?; - if !retry.status.success() { - let retry_stderr = String::from_utf8_lossy(&retry.stderr); - // The --force retry itself may fail with EACCES on systems - // where the global prefix is not writable. + emit_agent_install_event( + emitter, task_id, AgentInstallEventKind::Log, + "File conflict, retrying with --force...", + ); + let (retry_success, retry_stderr) = run_npm_streaming( + &["install", "-g", "--force", ®istry_arg, package], + task_id, + emitter, + ).await?; + if !retry_success { if retry_stderr.contains("EACCES") { - return install_npm_to_user_prefix(package, ®istry_arg).await; + emit_agent_install_event( + emitter, task_id, AgentInstallEventKind::Log, + "Permission denied on --force retry, falling back to user prefix...", + ); + return install_npm_to_user_prefix_streaming( + package, ®istry_arg, task_id, emitter, + ).await; } let err = retry_stderr.trim().to_string(); let msg = if err.is_empty() { @@ -281,7 +400,12 @@ async fn install_npm_global_package(package: &str) -> Result<(), AcpError> { /// Fallback: install an npm package into a user-local prefix (`~/.codeg/npm-global/`) /// when the system global prefix is not writable (EACCES). -async fn install_npm_to_user_prefix(package: &str, registry_arg: &str) -> Result<(), AcpError> { +async fn install_npm_to_user_prefix_streaming( + package: &str, + registry_arg: &str, + task_id: &str, + emitter: &EventEmitter, +) -> Result<(), AcpError> { let prefix = crate::process::user_npm_prefix().ok_or_else(|| { AcpError::protocol( "npm install -g failed with EACCES and could not determine home directory for fallback" @@ -298,41 +422,33 @@ async fn install_npm_to_user_prefix(package: &str, registry_arg: &str) -> Result })?; let prefix_arg = format!("--prefix={}", prefix.display()); - let output = crate::process::tokio_command("npm") - .arg("install") - .arg("-g") - .arg(&prefix_arg) - .arg(registry_arg) - .arg(package) - .output() - .await - .map_err(|e| { - AcpError::protocol(format!("failed to run npm install -g with user prefix: {e}")) - })?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); + emit_agent_install_event( + emitter, task_id, AgentInstallEventKind::Log, + format!("$ npm install -g --prefix={} {package}", prefix.display()), + ); + + let (success, stderr) = run_npm_streaming( + &["install", "-g", &prefix_arg, registry_arg, package], + task_id, + emitter, + ).await?; + + if !success { // EEXIST in the user prefix: retry with --force to overwrite stale files // from a previous installation. if stderr.contains("EEXIST") { - let force_retry = crate::process::tokio_command("npm") - .arg("install") - .arg("-g") - .arg("--force") - .arg(&prefix_arg) - .arg(registry_arg) - .arg(package) - .output() - .await - .map_err(|e| { - AcpError::protocol(format!( - "failed to run npm install -g --force with user prefix: {e}" - )) - })?; - if !force_retry.status.success() { - let err = String::from_utf8_lossy(&force_retry.stderr) - .trim() - .to_string(); + emit_agent_install_event( + emitter, task_id, AgentInstallEventKind::Log, + "File conflict in user prefix, retrying with --force...", + ); + let (force_success, force_stderr) = run_npm_streaming( + &["install", "-g", "--force", &prefix_arg, registry_arg, package], + task_id, + emitter, + ).await?; + if !force_success { + let err = force_stderr.trim().to_string(); let msg = if err.is_empty() { format!( "failed to install npm package (user prefix {}, --force)", @@ -2448,10 +2564,13 @@ pub async fn acp_update_agent_config( pub(crate) async fn acp_download_agent_binary_core( agent_type: AgentType, + task_id: String, emitter: &EventEmitter, ) -> Result<(), AcpError> { + emit_agent_install_event(emitter, &task_id, AgentInstallEventKind::Started, ""); + let meta = registry::get_agent_meta(agent_type); - match meta.distribution { + let result = match meta.distribution { registry::AgentDistribution::Binary { version, cmd, @@ -2469,25 +2588,55 @@ pub(crate) async fn acp_download_agent_binary_core( )) })?; - let _ = binary_cache::ensure_binary_for_agent(agent_type, version, fallback.url, cmd) - .await?; + emit_agent_install_event( + emitter, &task_id, AgentInstallEventKind::Log, + format!("Downloading {} v{version} for {platform}", meta.name), + ); + + let emitter_clone = emitter.clone(); + let task_id_clone = task_id.clone(); + let _ = binary_cache::ensure_binary_for_agent_with_progress( + agent_type, version, fallback.url, cmd, + move |msg| { + emit_agent_install_event( + &emitter_clone, &task_id_clone, AgentInstallEventKind::Log, msg, + ); + }, + ) + .await?; emit_acp_agents_updated(emitter, "binary_downloaded", Some(agent_type)); Ok(()) } registry::AgentDistribution::Npx { .. } => Err( AcpError::protocol("download is only supported for binary agents"), ), + }; + + match &result { + Ok(()) => { + emit_agent_install_event( + emitter, &task_id, AgentInstallEventKind::Completed, + format!("{} installed successfully", meta.name), + ); + } + Err(e) => { + emit_agent_install_event( + emitter, &task_id, AgentInstallEventKind::Failed, e.to_string(), + ); + } } + result } #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn acp_download_agent_binary( agent_type: AgentType, + task_id: String, app: tauri::AppHandle, ) -> Result<(), AcpError> { let emitter = EventEmitter::Tauri(app); - acp_download_agent_binary_core(agent_type, &emitter).await + acp_download_agent_binary_core(agent_type, task_id, &emitter).await } pub(crate) async fn acp_detect_agent_local_version_core( @@ -2525,11 +2674,14 @@ pub async fn acp_detect_agent_local_version( pub(crate) async fn acp_prepare_npx_agent_core( agent_type: AgentType, registry_version: Option, + task_id: String, db: &AppDatabase, emitter: &EventEmitter, ) -> Result { + emit_agent_install_event(emitter, &task_id, AgentInstallEventKind::Started, ""); + let meta = registry::get_agent_meta(agent_type); - match meta.distribution { + let result = match meta.distribution { registry::AgentDistribution::Npx { package, .. } => { let default = agent_setting_service::AgentDefaultInput { agent_type, @@ -2546,7 +2698,16 @@ pub(crate) async fn acp_prepare_npx_agent_core( .flatten() .and_then(|m| m.installed_version); - install_npm_global_package(package).await?; + emit_agent_install_event( + emitter, &task_id, AgentInstallEventKind::Log, + format!("Installing {} ({package})", meta.name), + ); + install_npm_global_package_streaming(package, &task_id, emitter).await?; + + emit_agent_install_event( + emitter, &task_id, AgentInstallEventKind::Log, + "Detecting installed version...", + ); let resolved = detect_local_version(agent_type) .await .or_else(|| version_from_package_spec(package)) @@ -2575,7 +2736,22 @@ pub(crate) async fn acp_prepare_npx_agent_core( registry::AgentDistribution::Binary { .. } => Err(AcpError::protocol( "prepare is only supported for npx agents", )), + }; + + match &result { + Ok(version) => { + emit_agent_install_event( + emitter, &task_id, AgentInstallEventKind::Completed, + format!("{} v{version} installed successfully", meta.name), + ); + } + Err(e) => { + emit_agent_install_event( + emitter, &task_id, AgentInstallEventKind::Failed, e.to_string(), + ); + } } + result } #[cfg(feature = "tauri-runtime")] @@ -2583,44 +2759,72 @@ pub(crate) async fn acp_prepare_npx_agent_core( pub async fn acp_prepare_npx_agent( agent_type: AgentType, registry_version: Option, + task_id: String, db: State<'_, AppDatabase>, app: tauri::AppHandle, ) -> Result { let emitter = EventEmitter::Tauri(app); - acp_prepare_npx_agent_core(agent_type, registry_version, &db, &emitter).await + acp_prepare_npx_agent_core(agent_type, registry_version, task_id, &db, &emitter).await } pub(crate) async fn acp_uninstall_agent_core( agent_type: AgentType, + task_id: String, db: &AppDatabase, emitter: &EventEmitter, ) -> Result<(), AcpError> { + emit_agent_install_event(emitter, &task_id, AgentInstallEventKind::Started, ""); + let meta = registry::get_agent_meta(agent_type); - match meta.distribution { - registry::AgentDistribution::Binary { .. } => { - binary_cache::clear_agent_cache(agent_type)?; + emit_agent_install_event( + emitter, &task_id, AgentInstallEventKind::Log, + format!("Uninstalling {}...", meta.name), + ); + + let result: Result<(), AcpError> = async { + match meta.distribution { + registry::AgentDistribution::Binary { .. } => { + binary_cache::clear_agent_cache(agent_type)?; + } + registry::AgentDistribution::Npx { package, .. } => { + uninstall_npm_global_package(package).await?; + } } - registry::AgentDistribution::Npx { package, .. } => { - uninstall_npm_global_package(package).await?; + + agent_setting_service::set_installed_version(&db.conn, agent_type, None) + .await + .map_err(|e| AcpError::protocol(e.to_string()))?; + emit_acp_agents_updated(emitter, "agent_uninstalled", Some(agent_type)); + Ok(()) + } + .await; + + match &result { + Ok(()) => { + emit_agent_install_event( + emitter, &task_id, AgentInstallEventKind::Completed, + format!("{} uninstalled successfully", meta.name), + ); + } + Err(e) => { + emit_agent_install_event( + emitter, &task_id, AgentInstallEventKind::Failed, e.to_string(), + ); } } - - agent_setting_service::set_installed_version(&db.conn, agent_type, None) - .await - .map_err(|e| AcpError::protocol(e.to_string()))?; - emit_acp_agents_updated(emitter, "agent_uninstalled", Some(agent_type)); - Ok(()) + result } #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub async fn acp_uninstall_agent( agent_type: AgentType, + task_id: String, db: State<'_, AppDatabase>, app: tauri::AppHandle, ) -> Result<(), AcpError> { let emitter = EventEmitter::Tauri(app); - acp_uninstall_agent_core(agent_type, &db, &emitter).await + acp_uninstall_agent_core(agent_type, task_id, &db, &emitter).await } pub(crate) async fn acp_reorder_agents_core( diff --git a/src-tauri/src/web/event_bridge.rs b/src-tauri/src/web/event_bridge.rs index df42cdd..b6473af 100644 --- a/src-tauri/src/web/event_bridge.rs +++ b/src-tauri/src/web/event_bridge.rs @@ -50,6 +50,9 @@ pub enum EventEmitter { #[cfg(feature = "tauri-runtime")] Tauri(tauri::AppHandle), WebOnly(Arc), + /// Silent no-op emitter — drops all events. Used when streaming progress + /// is not needed (e.g. legacy non-streaming call paths). + Noop, } /// Unified event emission: sends to both Tauri webview and Web clients (if applicable). @@ -70,5 +73,6 @@ pub fn emit_event( EventEmitter::WebOnly(broadcaster) => { broadcaster.send(event, &payload); } + EventEmitter::Noop => {} } } diff --git a/src-tauri/src/web/handlers/acp.rs b/src-tauri/src/web/handlers/acp.rs index 05999ae..e74445e 100644 --- a/src-tauri/src/web/handlers/acp.rs +++ b/src-tauri/src/web/handlers/acp.rs @@ -459,12 +459,19 @@ pub async fn acp_update_agent_config( Ok(Json(())) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpDownloadAgentBinaryParams { + pub agent_type: AgentType, + pub task_id: String, +} + pub async fn acp_download_agent_binary( Extension(state): Extension>, - Json(params): Json, + Json(params): Json, ) -> Result, AppCommandError> { let emitter = state.emitter.clone(); - acp_commands::acp_download_agent_binary_core(params.agent_type, &emitter) + acp_commands::acp_download_agent_binary_core(params.agent_type, params.task_id, &emitter) .await .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; Ok(Json(())) @@ -487,6 +494,7 @@ pub async fn acp_detect_agent_local_version( pub struct AcpPrepareNpxAgentParams { pub agent_type: AgentType, pub registry_version: Option, + pub task_id: String, } pub async fn acp_prepare_npx_agent( @@ -498,6 +506,7 @@ pub async fn acp_prepare_npx_agent( let result = acp_commands::acp_prepare_npx_agent_core( params.agent_type, params.registry_version, + params.task_id, db, &emitter, ) @@ -506,13 +515,20 @@ pub async fn acp_prepare_npx_agent( Ok(Json(result)) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpUninstallAgentParams { + pub agent_type: AgentType, + pub task_id: String, +} + pub async fn acp_uninstall_agent( Extension(state): Extension>, - Json(params): Json, + Json(params): Json, ) -> Result, AppCommandError> { let db = &state.db; let emitter = state.emitter.clone(); - acp_commands::acp_uninstall_agent_core(params.agent_type, db, &emitter) + acp_commands::acp_uninstall_agent_core(params.agent_type, params.task_id, db, &emitter) .await .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; Ok(Json(())) diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index e5db4cc..e37ace7 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -85,6 +85,7 @@ import type { ModelProviderInfo, PreflightResult, } from "@/lib/types" +import { useAgentInstallStream } from "@/hooks/use-agent-install-stream" import { OpencodePluginsModal } from "./opencode-plugins-modal" interface AgentCheckState { @@ -2624,6 +2625,9 @@ export function AcpAgentSettings() { const busyActionRef = useRef>(new Set()) const handledSearchAgentRef = useRef(null) const agentListRef = useRef(null) + const installStream = useAgentInstallStream() + const [streamAgentType, setStreamAgentType] = useState(null) + const installLogEndRef = useRef(null) const sortedAgents = useMemo( () => @@ -2748,6 +2752,30 @@ export function AcpAgentSettings() { [runPreflight] ) + useEffect(() => { + return () => installStream.reset() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + const container = installLogEndRef.current?.parentElement + if (container) { + container.scrollTop = container.scrollHeight + } + }, [installStream.logs]) + + useEffect(() => { + if ( + installStream.status === "success" || + installStream.status === "failed" + ) { + if (streamAgentType) { + runPreflight(streamAgentType).catch(() => {}) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [installStream.status]) + useEffect(() => { refreshAgents().catch((err) => { console.error("[Settings] refresh agents failed:", err) @@ -2940,11 +2968,14 @@ export function AcpAgentSettings() { [agent.agent_type]: kind ?? (mode === "download" ? "download_binary" : "upgrade_binary"), })) + const taskId = crypto.randomUUID() + setStreamAgentType(agent.agent_type) + await installStream.start(taskId) try { if (mode === "upgrade") { await acpClearBinaryCache(agent.agent_type) } - await acpDownloadAgentBinary(agent.agent_type) + await acpDownloadAgentBinary(agent.agent_type, taskId) await runPreflight(agent.agent_type) const detectedVersion = await acpDetectAgentLocalVersion( agent.agent_type @@ -2990,7 +3021,8 @@ export function AcpAgentSettings() { })) } }, - [runPreflight, t] + // eslint-disable-next-line react-hooks/exhaustive-deps + [runPreflight, t, installStream.start] ) const runNpxAction = useCallback( @@ -3002,10 +3034,14 @@ export function AcpAgentSettings() { ...prev, [agent.agent_type]: mode === "install" ? "install_npx" : "upgrade_npx", })) + const taskId = crypto.randomUUID() + setStreamAgentType(agent.agent_type) + await installStream.start(taskId) try { const installedVersion = await acpPrepareNpxAgent( agent.agent_type, - agent.registry_version + agent.registry_version, + taskId ) setAgents((prev) => prev.map((item) => @@ -3062,7 +3098,8 @@ export function AcpAgentSettings() { })) } }, - [runPreflight, t] + // eslint-disable-next-line react-hooks/exhaustive-deps + [runPreflight, t, installStream.start] ) const runUninstallAction = useCallback( @@ -3077,8 +3114,11 @@ export function AcpAgentSettings() { ? "uninstall_binary" : "uninstall_npx", })) + const taskId = crypto.randomUUID() + setStreamAgentType(agent.agent_type) + await installStream.start(taskId) try { - await acpUninstallAgent(agent.agent_type) + await acpUninstallAgent(agent.agent_type, taskId) setAgents((prev) => prev.map((item) => item.agent_type === agent.agent_type @@ -3105,7 +3145,8 @@ export function AcpAgentSettings() { })) } }, - [runPreflight, t] + // eslint-disable-next-line react-hooks/exhaustive-deps + [runPreflight, t, installStream.start] ) const handleFixAction = async (agent: AcpAgentInfo, action: UiFixAction) => { @@ -5003,6 +5044,24 @@ export function AcpAgentSettings() { {t("preflight.notRun")} )} + {installStream.status !== "idle" && + streamAgentType === selectedAgent.agent_type && ( +
+ {installStream.logs.map((line, i) => ( +
+ {line} +
+ ))} +
+
+ )}
diff --git a/src/components/settings/opencode-plugins-modal.tsx b/src/components/settings/opencode-plugins-modal.tsx index 30c7d8c..a68bd07 100644 --- a/src/components/settings/opencode-plugins-modal.tsx +++ b/src/components/settings/opencode-plugins-modal.tsx @@ -60,7 +60,10 @@ export function OpencodePluginsModal({ }, [open]) useEffect(() => { - logEndRef.current?.scrollIntoView({ behavior: "smooth" }) + const container = logEndRef.current?.parentElement + if (container) { + container.scrollTop = container.scrollHeight + } }, [stream.logs]) useEffect(() => { @@ -240,11 +243,13 @@ export function OpencodePluginsModal({ )} {stream.status !== "idle" && ( -
+
{stream.logs.map((line, i) => (
{line}
diff --git a/src/hooks/use-agent-install-stream.ts b/src/hooks/use-agent-install-stream.ts new file mode 100644 index 0000000..738937d --- /dev/null +++ b/src/hooks/use-agent-install-stream.ts @@ -0,0 +1,74 @@ +import { useCallback, useRef, useState } from "react" +import { subscribe } from "@/lib/platform" +import type { AgentInstallEvent, AgentInstallEventKind } from "@/lib/types" + +const AGENT_INSTALL_EVENT = "app://agent-install" + +export type AgentInstallStatus = "idle" | "running" | "success" | "failed" + +interface AgentInstallStreamState { + status: AgentInstallStatus + logs: string[] + error: string | null +} + +export function useAgentInstallStream() { + const [state, setState] = useState({ + status: "idle", + logs: [], + error: null, + }) + const unsubRef = useRef<(() => void) | null>(null) + + const start = useCallback(async (taskId: string) => { + setState({ status: "running", logs: [], error: null }) + + unsubRef.current?.() + + const unsub = await subscribe( + AGENT_INSTALL_EVENT, + (event) => { + if (event.task_id !== taskId) return + + switch (event.kind as AgentInstallEventKind) { + case "started": + setState((prev) => ({ ...prev, status: "running" })) + break + case "log": + setState((prev) => ({ + ...prev, + logs: [...prev.logs, event.payload], + })) + break + case "completed": + setState((prev) => ({ + ...prev, + status: "success", + logs: [...prev.logs, event.payload], + })) + unsubRef.current?.() + break + case "failed": + setState((prev) => ({ + ...prev, + status: "failed", + error: event.payload, + logs: [...prev.logs, `ERROR: ${event.payload}`], + })) + unsubRef.current?.() + break + } + } + ) + + unsubRef.current = unsub + }, []) + + const reset = useCallback(() => { + unsubRef.current?.() + unsubRef.current = null + setState({ status: "idle", logs: [], error: null }) + }, []) + + return { ...state, start, reset } +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 73a21b0..685812f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -184,9 +184,10 @@ export async function acpClearBinaryCache(agentType: AgentType): Promise { } export async function acpDownloadAgentBinary( - agentType: AgentType + agentType: AgentType, + taskId: string ): Promise { - return getTransport().call("acp_download_agent_binary", { agentType }) + return getTransport().call("acp_download_agent_binary", { agentType, taskId }) } export async function acpDetectAgentLocalVersion( @@ -197,16 +198,21 @@ export async function acpDetectAgentLocalVersion( export async function acpPrepareNpxAgent( agentType: AgentType, - registryVersion?: string | null + registryVersion: string | null | undefined, + taskId: string ): Promise { return getTransport().call("acp_prepare_npx_agent", { agentType, registryVersion: registryVersion ?? null, + taskId, }) } -export async function acpUninstallAgent(agentType: AgentType): Promise { - return getTransport().call("acp_uninstall_agent", { agentType }) +export async function acpUninstallAgent( + agentType: AgentType, + taskId: string +): Promise { + return getTransport().call("acp_uninstall_agent", { agentType, taskId }) } export async function acpUpdateAgentPreferences( diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index e7cb080..bda989a 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -172,9 +172,10 @@ export async function acpClearBinaryCache(agentType: AgentType): Promise { } export async function acpDownloadAgentBinary( - agentType: AgentType + agentType: AgentType, + taskId: string ): Promise { - return invoke("acp_download_agent_binary", { agentType }) + return invoke("acp_download_agent_binary", { agentType, taskId }) } export async function acpDetectAgentLocalVersion( @@ -185,16 +186,21 @@ export async function acpDetectAgentLocalVersion( export async function acpPrepareNpxAgent( agentType: AgentType, - registryVersion?: string | null + registryVersion: string | null | undefined, + taskId: string ): Promise { return invoke("acp_prepare_npx_agent", { agentType, registryVersion: registryVersion ?? null, + taskId, }) } -export async function acpUninstallAgent(agentType: AgentType): Promise { - return invoke("acp_uninstall_agent", { agentType }) +export async function acpUninstallAgent( + agentType: AgentType, + taskId: string +): Promise { + return invoke("acp_uninstall_agent", { agentType, taskId }) } export async function acpUpdateAgentPreferences( diff --git a/src/lib/types.ts b/src/lib/types.ts index fb269bf..c395de5 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -936,6 +936,14 @@ export interface PluginInstallEvent { payload: string } +export type AgentInstallEventKind = "started" | "log" | "completed" | "failed" + +export interface AgentInstallEvent { + task_id: string + kind: AgentInstallEventKind + payload: string +} + // ─── Chat Channels ─── export type ChannelType = "lark" | "telegram" | "weixin"