diff --git a/apps/desktop/src/components/config/DriverStoreDialog.vue b/apps/desktop/src/components/config/DriverStoreDialog.vue index c305fb042..4b069732f 100644 --- a/apps/desktop/src/components/config/DriverStoreDialog.vue +++ b/apps/desktop/src/components/config/DriverStoreDialog.vue @@ -243,15 +243,31 @@ async function uninstallDriver(dbType: string) { const importingZip = ref(false); -async function importOfflineZip() { - if (isWeb || importingZip.value) return; - const { open } = await import("@tauri-apps/plugin-dialog"); - const selected = await open({ - title: "选择离线驱动包", - multiple: false, - filters: [{ name: "ZIP", extensions: ["zip"] }], +function chooseWebOfflineZip(): Promise { + return new Promise((resolve) => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".zip"; + input.onchange = () => resolve(input.files?.[0] ?? null); + input.click(); }); - if (typeof selected !== "string") return; +} + +async function importOfflineZip() { + if (importingZip.value) return; + let selected: string | File | null = null; + if (isWeb) { + selected = await chooseWebOfflineZip(); + } else { + const { open } = await import("@tauri-apps/plugin-dialog"); + const path = await open({ + title: "选择离线驱动包", + multiple: false, + filters: [{ name: "ZIP", extensions: ["zip"] }], + }); + selected = typeof path === "string" ? path : null; + } + if (!selected) return; importingZip.value = true; progress.value = null; try { diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 71289da49..c52303107 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -179,8 +179,16 @@ export async function invalidateAgentRegistryCache(): Promise { await post("/api/agents/invalidate-registry-cache", {}); } -export async function importAgentsFromZip(_path: string): Promise { - throw new Error("Offline ZIP import is only available in the desktop app"); +export async function importAgentsFromZip(fileOrPath: string | File): Promise { + if (typeof fileOrPath === "string") { + throw new Error("Offline ZIP import in web mode requires a File object, not a file path"); + } + const formData = new FormData(); + formData.append("file", fileOrPath); + const res = await fetch("/api/agents/import-offline", { method: "POST", body: formData }); + if (!res.ok) throw new Error(await res.text()); + const result: { count: number } = await res.json(); + return result.count; } export async function importAgentJar(_dbType: string, _path: string): Promise { diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 580765f9a..64215a806 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -389,7 +389,10 @@ export async function invalidateAgentRegistryCache(): Promise { return invoke("invalidate_agent_registry_cache"); } -export async function importAgentsFromZip(path: string): Promise { +export async function importAgentsFromZip(path: string | File): Promise { + if (typeof path !== "string") { + throw new Error("Desktop offline ZIP import requires a local file path"); + } return invoke("import_agents_from_zip", { path }); } diff --git a/crates/dbx-web/src/main.rs b/crates/dbx-web/src/main.rs index 19ccbf140..f59aee0f0 100644 --- a/crates/dbx-web/src/main.rs +++ b/crates/dbx-web/src/main.rs @@ -92,6 +92,7 @@ async fn main() { .route("/agents/install", post(routes::agents::install_agent)) .route("/agents/upgrade-all", post(routes::agents::upgrade_all_agents)) .route("/agents/uninstall", post(routes::agents::uninstall_agent)) + .route("/agents/import-offline", post(routes::agents::import_agents_from_zip)) .route( "/agents/java-runtime", get(routes::agents::get_agent_java_runtime_config).post(routes::agents::set_agent_java_runtime_config), @@ -199,7 +200,7 @@ async fn main() { // Build app let mut app = Router::new() .nest("/api", api) - .layer(DefaultBodyLimit::max(50 * 1024 * 1024)) + .layer(DefaultBodyLimit::max(300 * 1024 * 1024)) .layer(tower_http::trace::TraceLayer::new_for_http()) .layer(cors); diff --git a/crates/dbx-web/src/routes/agents.rs b/crates/dbx-web/src/routes/agents.rs index ceaba3b4b..1898f5fe0 100644 --- a/crates/dbx-web/src/routes/agents.rs +++ b/crates/dbx-web/src/routes/agents.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use axum::extract::{Path, State}; +use axum::extract::{Multipart, Path, State}; use axum::response::sse::{Event, Sse}; use axum::Json; use dbx_core::agent_manager::{ @@ -9,7 +9,7 @@ use dbx_core::agent_manager::{ }; use dbx_core::agent_service::{ build_agent_list, download_temp_path, fetch_registry, find_local_agent_jar, github_url_to_r2_path, - install_local_agent, invalidate_registry_cache, replace_download, + import_offline_zip, install_local_agent, invalidate_registry_cache, replace_download, OfflineImportProgress, }; use futures::Stream; use serde::Deserialize; @@ -127,6 +127,48 @@ pub async fn invalidate_agent_registry_cache() -> Result Ok(Json(serde_json::json!({ "ok": true }))) } +pub async fn import_agents_from_zip( + State(state): State>, + mut multipart: Multipart, +) -> Result, AppError> { + let tmp_dir = state.data_dir.join("tmp"); + std::fs::create_dir_all(&tmp_dir).map_err(|err| AppError(err.to_string()))?; + + while let Some(field) = multipart.next_field().await.map_err(|err| AppError(err.to_string()))? { + let file_name = field.file_name().unwrap_or("offline-drivers.zip").to_string(); + if !file_name.to_ascii_lowercase().ends_with(".zip") { + return Err(AppError("Offline driver package must be a .zip file".to_string())); + } + + let data = field.bytes().await.map_err(|err| AppError(err.to_string()))?; + let zip_path = tmp_dir.join(format!("agent-offline-{}.zip", uuid::Uuid::new_v4())); + std::fs::write(&zip_path, &data).map_err(|err| AppError(err.to_string()))?; + + let tx = progress_sender(&state, "global").await; + let result = import_offline_zip(&state.app.agent_manager, &zip_path, |p: OfflineImportProgress| { + send_progress( + &tx, + serde_json::json!({ + "step": p.step, + "downloaded": p.current as u64, + "total": p.total as u64, + "db_type": p.label, + "current": p.current, + "total_drivers": p.total, + }), + ); + }) + .map_err(AppError); + let _ = std::fs::remove_file(&zip_path); + + let result = result?; + send_progress(&tx, serde_json::json!({ "step": "done" })); + return Ok(Json(serde_json::json!({ "count": result.drivers_installed.len() as u32 }))); + } + + Err(AppError("No file uploaded".to_string())) +} + pub async fn reinstall_jre( State(state): State>, Json(req): Json, diff --git a/packages/app-tests/webDriverManagement.test.ts b/packages/app-tests/webDriverManagement.test.ts index 088ca79fb..c4f341a73 100644 --- a/packages/app-tests/webDriverManagement.test.ts +++ b/packages/app-tests/webDriverManagement.test.ts @@ -5,6 +5,7 @@ import test from "node:test"; const apiSource = readFileSync("apps/desktop/src/lib/api.ts", "utf8"); const httpSource = readFileSync("apps/desktop/src/lib/http.ts", "utf8"); const tauriSource = readFileSync("apps/desktop/src/lib/tauri.ts", "utf8"); +const driverStoreSource = readFileSync("apps/desktop/src/components/config/DriverStoreDialog.vue", "utf8"); const webMainSource = readFileSync("crates/dbx-web/src/main.rs", "utf8"); const webRoutesSource = readFileSync("crates/dbx-web/src/routes/mod.rs", "utf8"); @@ -41,3 +42,14 @@ test("web backend exposes agent driver management routes", () => { assert.match(webMainSource, /\/agents\/java-runtime/); assert.match(webMainSource, /\/ai\/models/); }); + +test("web runtime supports importing an offline agent driver zip", () => { + assert.match(httpSource, /export async function importAgentsFromZip\(fileOrPath: string \| File\)/); + assert.match(httpSource, /FormData/); + assert.match(httpSource, /\/api\/agents\/import-offline/); + assert.doesNotMatch(httpSource, /Offline ZIP import is only available in the desktop app/); + assert.match(webMainSource, /\/agents\/import-offline/); + assert.match(driverStoreSource, /chooseWebOfflineZip/); + assert.match(driverStoreSource, /accept = "\.zip"/); + assert.doesNotMatch(driverStoreSource, /if \(isWeb \|\| importingZip\.value\) return;/); +});