diff --git a/frontends/desktop/src-tauri/capabilities/default.json b/frontends/desktop/src-tauri/capabilities/default.json index aa9ad1e2d..e5401c2cf 100644 --- a/frontends/desktop/src-tauri/capabilities/default.json +++ b/frontends/desktop/src-tauri/capabilities/default.json @@ -22,6 +22,7 @@ "allow-get-ga-source", "allow-set-ga-source", "allow-clear-ga-source", + "allow-move-ga-runtime", "allow-shortcut-should-ask", "allow-shortcut-decide" ] diff --git a/frontends/desktop/src-tauri/permissions/bridge-commands.toml b/frontends/desktop/src-tauri/permissions/bridge-commands.toml index 313f49be9..27dc07df9 100644 --- a/frontends/desktop/src-tauri/permissions/bridge-commands.toml +++ b/frontends/desktop/src-tauri/permissions/bridge-commands.toml @@ -47,3 +47,8 @@ commands.allow = ["shortcut_should_ask"] identifier = "allow-shortcut-decide" description = "Persist the user's desktop-shortcut choice and create it if enabled." commands.allow = ["shortcut_decide"] + +[[permission]] +identifier = "allow-move-ga-runtime" +description = "Copy the current GA workspace to a user-selected folder and switch to it." +commands.allow = ["move_ga_runtime"] diff --git a/frontends/desktop/src-tauri/src/lib.rs b/frontends/desktop/src-tauri/src/lib.rs index 56b289cde..c0b63df0f 100644 --- a/frontends/desktop/src-tauri/src/lib.rs +++ b/frontends/desktop/src-tauri/src/lib.rs @@ -4,7 +4,7 @@ use std::sync::Mutex; use std::net::TcpStream; use std::time::{Duration, Instant}; use std::thread; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use tauri::Manager; #[cfg(windows)] @@ -372,6 +372,60 @@ fn remove_setting(key: &str) { } } +fn restore_setting(key: &str, value: Option) { + match value { + Some(v) => merge_settings(serde_json::json!({ key: v })), + None => remove_setting(key), + } +} + +fn copy_dir_replace(src: &Path, dst: &Path) -> Result<(), String> { + std::fs::create_dir_all(dst).map_err(|e| format!("create {:?}: {}", dst, e))?; + for entry in std::fs::read_dir(src).map_err(|e| format!("read {:?}: {}", src, e))? { + let entry = entry.map_err(|e| e.to_string())?; + let sp = entry.path(); + let dp = dst.join(entry.file_name()); + let ft = entry.file_type().map_err(|e| e.to_string())?; + if ft.is_dir() { + if dp.exists() && !dp.is_dir() { + std::fs::remove_file(&dp).map_err(|e| format!("remove {:?}: {}", dp, e))?; + } + copy_dir_replace(&sp, &dp)?; + } else if ft.is_file() { + if let Some(parent) = dp.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + std::fs::copy(&sp, &dp).map_err(|e| format!("copy {:?} -> {:?}: {}", sp, dp, e))?; + } + } + Ok(()) +} + +fn bundled_project_dir() -> Option { + let app = bundle_root()?.join("app"); + if app.join("agentmain.py").exists() { Some(app) } else { None } +} + +fn same_path(a: &Path, b: &Path) -> bool { + let aa = a.canonicalize().unwrap_or_else(|_| a.to_path_buf()); + let bb = b.canonicalize().unwrap_or_else(|_| b.to_path_buf()); + #[cfg(windows)] + { display_path(&aa).eq_ignore_ascii_case(&display_path(&bb)) } + #[cfg(not(windows))] + { aa == bb } +} + +fn display_path(path: &Path) -> String { + let s = path.to_string_lossy().to_string(); + #[cfg(windows)] + { + if let Some(rest) = s.strip_prefix("\\\\?\\") { + return rest.to_string(); + } + } + s +} + /// Read config from settings file, or auto-discover and save. /// Self-contained bundles always prefer their own runtime/app over stale user settings, /// otherwise an old ~/.ga_desktop_settings.json can silently point the UI at a different checkout. @@ -631,7 +685,7 @@ fn bridge_reported_identity() -> Option { fn norm_path(p: &str) -> String { std::fs::canonicalize(p) - .map(|c| c.to_string_lossy().to_string()) + .map(|c| display_path(&c)) .unwrap_or_else(|_| p.to_string()) } @@ -646,11 +700,7 @@ fn bridge_identity_matches(project_dir: &str) -> bool { if reported_build != env!("GA_BUILD_ID") { return false; } - let (a, b) = (norm_path(reported_root), norm_path(project_dir)); - #[cfg(windows)] - { a.eq_ignore_ascii_case(&b) } - #[cfg(not(windows))] - { a == b } + path_matches(reported_root, project_dir) } /// Last resort when a stale bridge ignores POST /services/bridge/exit (e.g. an old build with @@ -740,6 +790,29 @@ fn wait_for_port(port: u16, timeout: Duration) -> bool { false } +fn path_matches(a: &str, b: &str) -> bool { + let (a, b) = (norm_path(a), norm_path(b)); + #[cfg(windows)] + { a.eq_ignore_ascii_case(&b) } + #[cfg(not(windows))] + { a == b } +} + +fn wait_for_bridge_identity(expected_ga_root: &str, timeout: Duration) -> bool { + let start = Instant::now(); + while start.elapsed() < timeout { + if let Some(id) = bridge_reported_identity() { + let reported_root = id.get("ga_root").and_then(|v| v.as_str()).unwrap_or(""); + let reported_build = id.get("build_id").and_then(|v| v.as_str()).unwrap_or(""); + if reported_build == env!("GA_BUILD_ID") && path_matches(reported_root, expected_ga_root) { + return true; + } + } + thread::sleep(Duration::from_millis(150)); + } + false +} + fn spawn_bridge_process(python_path: &str, project_dir: &str) -> Result<(), String> { if is_bridge_running() { return Ok(()); @@ -859,12 +932,16 @@ fn switch_bridge(app_handle: &tauri::AppHandle) -> Result { if project.is_empty() { return Err("no GenericAgent source resolved".into()); } + let expected_ga_root = valid_ga_source_override().unwrap_or_else(|| project.clone()); spawn_bridge_process(&py, &project)?; if !wait_for_port(14168, Duration::from_secs(20)) { return Err("bridge did not become ready within 20s".into()); } + if !wait_for_bridge_identity(&expected_ga_root, Duration::from_secs(10)) { + return Err(format!("bridge did not switch to GA workspace: {}", expected_ga_root)); + } show_bridge_window(app_handle); - Ok(project) + Ok(expected_ga_root) } #[tauri::command] @@ -876,6 +953,62 @@ fn get_ga_source() -> String { .to_string() } +#[tauri::command] +fn move_ga_runtime(app_handle: tauri::AppHandle, dir: String) -> Result { + let previous_override = read_settings() + .get("ga_source_override") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let src_s = valid_ga_source_override().or_else(|| find_project_dir()).unwrap_or_default(); + let src = PathBuf::from(src_s.trim()); + let parent = PathBuf::from(dir.trim()); + if src_s.trim().is_empty() { + return Err("current GA workspace is empty; wait for the bridge to become ready and try again".into()); + } + if dir.trim().is_empty() { + return Err("target parent directory is empty".into()); + } + if !src.is_dir() || !src.join("agentmain.py").exists() { + return Err(format!("current GA workspace is invalid: {}", src.to_string_lossy())); + } + std::fs::create_dir_all(&parent).map_err(|e| format!("cannot create target parent dir: {}", e))?; + let src_c = src.canonicalize().unwrap_or(src.clone()); + let parent_c = parent.canonicalize().unwrap_or(parent.clone()); + let name = src_c.file_name().and_then(|s| s.to_str()).unwrap_or("GenericAgent"); + let folder_name = if name == "app" { "GenericAgent" } else { name }; + let dst = parent_c.join(folder_name); + let dst_c = if dst.exists() { dst.canonicalize().unwrap_or(dst.clone()) } else { dst.clone() }; + if same_path(&src_c, &dst_c) { + merge_settings(serde_json::json!({ "ga_source_override": display_path(&dst_c) })); + return switch_bridge(&app_handle).map_err(|err| { + restore_setting("ga_source_override", previous_override.clone()); + err + }); + } + if dst_c.starts_with(&src_c) { + return Err("target directory cannot be inside the current GA workspace".into()); + } + if dst_c.exists() { + return Err(format!("target GA workspace already exists: {}", display_path(&dst_c))); + } + copy_dir_replace(&src_c, &dst_c)?; + merge_settings(serde_json::json!({ "ga_source_override": display_path(&dst_c) })); + if let Err(err) = switch_bridge(&app_handle) { + restore_setting("ga_source_override", previous_override); + return Err(err); + } + + // The packaged bundle's own runtime/app is still needed by the desktop shell and + // bridge. Only remove the old source when it is already a user-selected external copy. + let bundled = bundled_project_dir(); + if !bundled.as_ref().map(|p| same_path(&src_c, p)).unwrap_or(false) { + if let Err(e) = std::fs::remove_dir_all(&src_c) { + eprintln!("remove old runtime {:?}: {}", src_c, e); + } + } + Ok(display_path(&dst_c)) +} + /// Run the contract probe (frontends/ga_contract_probe.py, shipped with the bundle) against a /// target ga_root using the bundle python. Returns Ok(()) if the核 satisfies the bridge/conductor /// contract, or Err(message) listing what's missing / why it's incompatible. @@ -984,7 +1117,7 @@ pub fn run() { let _ = w.set_focus(); } })) - .invoke_handler(tauri::generate_handler![start_bridge_with_config, start_bridge, get_config, export_mykey, pick_directory, get_ga_source, set_ga_source, clear_ga_source, shortcut_should_ask, shortcut_decide, get_prepare_error]) + .invoke_handler(tauri::generate_handler![start_bridge_with_config, start_bridge, get_config, export_mykey, pick_directory, get_ga_source, set_ga_source, clear_ga_source, move_ga_runtime, shortcut_should_ask, shortcut_decide, get_prepare_error]) .setup(move |app| { // Show the loading window immediately so the first-run prepare isn't a blank screen. // The window starts on loading.html (a local page), so no "connection refused" flash. diff --git a/frontends/desktop/static/app.js b/frontends/desktop/static/app.js index 7f04d4c0e..85bc8449b 100644 --- a/frontends/desktop/static/app.js +++ b/frontends/desktop/static/app.js @@ -289,6 +289,7 @@ let bridgeUiOffline = false; getGaSource: () => tauriInvoke('get_ga_source'), setGaSource: (dir) => tauriInvoke('set_ga_source', { dir }), clearGaSource: () => tauriInvoke('clear_ga_source'), + moveGaRuntime: (dir) => tauriInvoke('move_ga_runtime', { dir }), getConductorModel: () => rpc('services/conductor/model/get', {}), saveConductorModel: (llmNo) => rpc('services/conductor/model/save', { llmNo }), tauriInvoke, @@ -716,6 +717,29 @@ bindClick('import-memory-btn', async (e) => { showChanToast(t('err.memoryImport'), err.message || String(err), 'err'); } }); +bindClick('move-ga-runtime-btn', async (e) => { + e.stopPropagation(); + if (!window.__TAURI__?.core?.invoke) { + showChanToast(t('err.gaRuntimeDesktopOnly'), '', 'err'); + return; + } + try { + const dir = await window.ga.tauriInvoke('pick_directory', { title: t('sys.gaRuntimeMoveTitle') }); + if (!dir) return; + const confirmed = await showConfirmDialog({ + title: t('confirm.gaRuntimeMoveTitle'), + message: t('confirm.gaRuntimeMove'), + okText: t('set.moveGaRuntime'), + }); + if (!confirmed) return; + showChanToast(t('sys.gaRuntimeMoving'), dir, 'ok'); + const project = await window.ga.moveGaRuntime(dir); + await refreshGaSource(); + showChanToast(t('sys.gaRuntimeMoved'), project || dir, 'ok'); + } catch (err) { + showChanToast(t('err.gaRuntimeMove'), err.message || String(err), 'err'); + } +}); const gaSourceCurrentEl = document.getElementById('ga-source-current'); const gaSourceClearBtn = document.getElementById('ga-source-clear-btn'); async function refreshGaSource() { diff --git a/frontends/desktop/static/i18n.js b/frontends/desktop/static/i18n.js index 52f6c8a5d..0787d9196 100644 --- a/frontends/desktop/static/i18n.js +++ b/frontends/desktop/static/i18n.js @@ -29,10 +29,11 @@ 'customPreset.removeTitle': '删除', 'customPreset.editTitle': '编辑', 'builtinPreset.restoreBtn': '恢复默认预设', - 'set.appearance': '外观', 'set.plainUi': '素色', 'set.fontSize': '聊天字号', 'set.lang': '语言', 'set.model': '模型', 'set.addModel': '添加模型', 'set.features': '功能', 'set.importMykey': '导入模型配置', 'set.exportMykey': '导出模型配置', 'set.importMemory': '导入记忆与会话记录', 'set.gaSource': '接入外部 GenericAgent 源码', 'set.gaSourceClear': '改用内置版本', 'set.gaSourceCurrent': '当前接入', 'set.serviceManager': '后台服务管理', + 'set.appearance': '外观', 'set.plainUi': '素色', 'set.fontSize': '聊天字号', 'set.lang': '语言', 'set.model': '模型', 'set.addModel': '添加模型', 'set.features': '功能', 'set.importMykey': '导入模型配置', 'set.exportMykey': '导出模型配置', 'set.importMemory': '导入记忆与会话记录', 'set.moveGaRuntime': '复制并切换 GA 工作目录', 'set.gaSource': '接入外部 GenericAgent 源码', 'set.gaSourceClear': '改用内置版本', 'set.gaSourceCurrent': '当前接入', 'set.serviceManager': '后台服务管理', 'set.importMykeyTip': '从 mykey.py 导入已有的模型与 API 配置', 'set.exportMykeyTip': '将当前模型与 API 配置导出为 mykey.py', 'set.importMemoryTip': '从另一个 GenericAgent 根目录导入记忆、模型响应和会话记录', + 'set.moveGaRuntimeTip': '把当前 GA 工作目录复制到所选位置,并切换后端使用新位置', 'set.gaSourceTip': '选择包含 agentmain.py 的 GenericAgent 根目录,桌面端将使用其中的后端源码', 'set.gaSourceClearTip': '取消接入当前外部源码,切换回安装包内置的 GenericAgent 版本', 'set.serviceManagerTip': '查看并管理消息通道与后台进程的运行状态和日志', @@ -161,6 +162,8 @@ 'sys.mykeyExported': '模型配置已导出', 'sys.memoryImported': '记忆已导入', 'err.memoryImport': '导入记忆失败', + 'err.gaRuntimeMove': '复制并切换 GA 工作目录失败', + 'err.gaRuntimeDesktopOnly': '此功能仅在桌面版中可用', 'sys.memoryImportBackup': '原记忆已备份至', 'sys.memorySessions': '会话', 'sys.gaSourcePickTitle': '选择要接入的 GenericAgent 源码根目录(含 agentmain.py)', @@ -171,6 +174,11 @@ 'err.gaSourceDesktopOnly': '此功能仅在桌面版中可用', 'sys.memoryPickTitle': '选择 GenericAgent 根目录(包含 memory 与 temp 的目录)', 'sys.memoryImportPrompt': '请输入 GenericAgent 根目录的完整路径(包含 memory 与 temp 的目录,而非 memory 文件夹本身):', + 'sys.gaRuntimeMoveTitle': '选择新的 GA 工作目录位置', + 'sys.gaRuntimeMoving': '正在复制并切换 GA 工作目录...', + 'sys.gaRuntimeMoved': '已切换到新的 GA 工作目录', + 'confirm.gaRuntimeMoveTitle': '复制并切换 GA 工作目录?', + 'confirm.gaRuntimeMove': '当前 GA 工作目录会复制到你选择的位置,并重启后端使用新目录。安装包内置目录会保留以保证桌面壳可启动;如果当前已是外部目录,旧目录会在切换成功后删除。是否继续?', 'st.starting': '启动中…', 'st.stopping': '停止中…', 'st.online': '在线', 'st.offline': '离线', 'st.error': '错误', 'st.running': '运行', 'st.abnormal': '异常', 'act.configure': '配置', 'act.logs': '日志', 'act.restart': '重启', 'act.stop': '停止', 'act.start': '启动', 'act.exit': '退出', 'act.copy': '复制', 'act.copied': '已复制', 'act.copyTex': 'TeX', 'act.send': '发送', @@ -223,10 +231,11 @@ 'customPreset.removeTitle': 'Delete', 'customPreset.editTitle': 'Edit', 'builtinPreset.restoreBtn': 'Restore defaults', - 'set.appearance': 'Appearance', 'set.plainUi': 'Plain', 'set.fontSize': 'Chat font size', 'set.lang': 'Language', 'set.model': 'Model', 'set.addModel': 'Add model', 'set.features': 'Features', 'set.importMykey': 'Import model config', 'set.exportMykey': 'Export model config', 'set.importMemory': 'Import memory and sessions', 'set.gaSource': 'Connect external GenericAgent source', 'set.gaSourceClear': 'Use bundled version', 'set.gaSourceCurrent': 'Connected to', 'set.serviceManager': 'Service manager', + 'set.appearance': 'Appearance', 'set.plainUi': 'Plain', 'set.fontSize': 'Chat font size', 'set.lang': 'Language', 'set.model': 'Model', 'set.addModel': 'Add model', 'set.features': 'Features', 'set.importMykey': 'Import model config', 'set.exportMykey': 'Export model config', 'set.importMemory': 'Import memory and sessions', 'set.moveGaRuntime': 'Copy and switch GA workspace', 'set.gaSource': 'Connect external GenericAgent source', 'set.gaSourceClear': 'Use bundled version', 'set.gaSourceCurrent': 'Connected to', 'set.serviceManager': 'Service manager', 'set.importMykeyTip': 'Import existing model and API settings from mykey.py', 'set.exportMykeyTip': 'Export the current model and API settings as mykey.py', 'set.importMemoryTip': 'Import memory, model responses, and sessions from another GenericAgent root directory', + 'set.moveGaRuntimeTip': 'Copy the current GA workspace to the selected location and switch the backend to it', 'set.gaSourceTip': 'Select a GenericAgent root containing agentmain.py and use its backend source code', 'set.gaSourceClearTip': 'Disconnect the current external source and switch back to the bundled GenericAgent version', 'set.serviceManagerTip': 'View and manage message channels, background processes, and their logs', @@ -355,6 +364,8 @@ 'sys.mykeyExported': 'Model config exported', 'sys.memoryImported': 'Memory imported', 'err.memoryImport': 'Failed to import memory', + 'err.gaRuntimeMove': 'Failed to copy and switch GA workspace', + 'err.gaRuntimeDesktopOnly': 'This feature is only available in the desktop app', 'sys.memoryImportBackup': 'Previous memory backed up to', 'sys.memorySessions': 'sessions', 'sys.gaSourcePickTitle': 'Select the GenericAgent source root to connect to (contains agentmain.py)', @@ -365,6 +376,11 @@ 'err.gaSourceDesktopOnly': 'This feature is only available in the desktop app', 'sys.memoryPickTitle': 'Select the GenericAgent root directory (the folder containing memory and temp)', 'sys.memoryImportPrompt': 'Enter the full path of the GenericAgent root directory (the folder containing memory and temp, not the memory folder itself):', + 'sys.gaRuntimeMoveTitle': 'Choose the new GA workspace location', + 'sys.gaRuntimeMoving': 'Copying and switching GA workspace...', + 'sys.gaRuntimeMoved': 'Switched to the new GA workspace', + 'confirm.gaRuntimeMoveTitle': 'Copy and switch GA workspace?', + 'confirm.gaRuntimeMove': 'The current GA workspace will be copied to the selected location and the backend will restart from it. The bundled folder is kept so the desktop shell can still start; if the current folder is already external, the old folder is deleted after a successful switch. Continue?', 'st.starting': 'Starting…', 'st.stopping': 'Stopping…', 'st.online': 'Online', 'st.offline': 'Offline', 'st.error': 'Error', 'st.running': 'Running', 'st.abnormal': 'Error', 'act.configure': 'Configure', 'act.logs': 'Logs', 'act.restart': 'Restart', 'act.stop': 'Stop', 'act.start': 'Start', 'act.exit': 'Exit', 'act.copy': 'Copy', 'act.copied': 'Copied', 'act.copyTex': 'TeX', 'act.send': 'Send', diff --git a/frontends/desktop/static/index.html b/frontends/desktop/static/index.html index 385a116b9..6bdb2683e 100644 --- a/frontends/desktop/static/index.html +++ b/frontends/desktop/static/index.html @@ -661,6 +661,10 @@ +