Skip to content
Closed
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
1 change: 1 addition & 0 deletions frontends/desktop/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
]
Expand Down
5 changes: 5 additions & 0 deletions frontends/desktop/src-tauri/permissions/bridge-commands.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
151 changes: 142 additions & 9 deletions frontends/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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())
}

Expand All @@ -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
Expand Down Expand Up @@ -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(());
Expand Down Expand Up @@ -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]
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions frontends/desktop/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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() {
Expand Down
20 changes: 18 additions & 2 deletions frontends/desktop/static/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -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': '查看并管理消息通道与后台进程的运行状态和日志',
Expand Down Expand Up @@ -161,6 +162,8 @@
'sys.mykeyExported': '模型配置已导出',
'sys.memoryImported': '记忆已导入',
'err.memoryImport': '导入记忆失败',
'err.gaRuntimeMove': '复制并切换 GA 工作目录失败',
'err.gaRuntimeDesktopOnly': '此功能仅在桌面版中可用',
'sys.memoryImportBackup': '原记忆已备份至',
'sys.memorySessions': '会话',
'sys.gaSourcePickTitle': '选择要接入的 GenericAgent 源码根目录(含 agentmain.py)',
Expand All @@ -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': '发送',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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)',
Expand All @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions frontends/desktop/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down