desktop: add workspace copy and switch action
Add a user-triggered desktop action to copy the current GA workspace to a chosen location and verify the bridge actually switches there before reporting success.
This commit is contained in:
1 parent
284b33216a
commit
9e56a95ac8
6 files changed
+194
-11
No files matched your search
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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"]
|
||||
@@ -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<String>) {
|
||||
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<PathBuf> {
|
||||
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<serde_json::Value> {
|
||||
|
||||
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<String, String> {
|
||||
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<String, String> {
|
||||
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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -661,6 +661,10 @@
|
||||
<span data-ga-icon="folderSimple"></span>
|
||||
<span data-i18n="set.importMemory"></span>
|
||||
</button>
|
||||
<button class="set-btn set-btn-follow" id="move-ga-runtime-btn" type="button" data-i18n-title="set.moveGaRuntimeTip">
|
||||
<span data-ga-icon="folderSimple"></span>
|
||||
<span data-i18n="set.moveGaRuntime"></span>
|
||||
</button>
|
||||
<button class="set-btn set-btn-follow" id="ga-source-btn" type="button" data-i18n-title="set.gaSourceTip">
|
||||
<span data-ga-icon="folderSimple"></span>
|
||||
<span data-i18n="set.gaSource"></span>
|
||||
|
||||
Reference in new issue
Block a user