diff --git a/frontends/desktop/src-tauri/capabilities/default.json b/frontends/desktop/src-tauri/capabilities/default.json index cf96e341..aa9ad1e2 100644 --- a/frontends/desktop/src-tauri/capabilities/default.json +++ b/frontends/desktop/src-tauri/capabilities/default.json @@ -18,6 +18,10 @@ "allow-start-bridge-with-config", "allow-get-config", "allow-export-mykey", + "allow-pick-directory", + "allow-get-ga-source", + "allow-set-ga-source", + "allow-clear-ga-source", "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 cf78df39..313f49be 100644 --- a/frontends/desktop/src-tauri/permissions/bridge-commands.toml +++ b/frontends/desktop/src-tauri/permissions/bridge-commands.toml @@ -18,6 +18,26 @@ identifier = "allow-export-mykey" description = "Save mykey.py via native file dialog." commands.allow = ["export_mykey"] +[[permission]] +identifier = "allow-pick-directory" +description = "Pick a source directory via native folder dialog (import memory)." +commands.allow = ["pick_directory"] + +[[permission]] +identifier = "allow-get-ga-source" +description = "Read the user-set external GenericAgent source directory." +commands.allow = ["get_ga_source"] + +[[permission]] +identifier = "allow-set-ga-source" +description = "Point the desktop shell at an external GenericAgent source and restart the bridge." +commands.allow = ["set_ga_source"] + +[[permission]] +identifier = "allow-clear-ga-source" +description = "Clear the external GA source override and fall back to the bundled runtime." +commands.allow = ["clear_ga_source"] + [[permission]] identifier = "allow-shortcut-should-ask" description = "Check whether to show the first-run desktop-shortcut prompt." diff --git a/frontends/desktop/src-tauri/src/lib.rs b/frontends/desktop/src-tauri/src/lib.rs index 69ab0e3e..410881cc 100644 --- a/frontends/desktop/src-tauri/src/lib.rs +++ b/frontends/desktop/src-tauri/src/lib.rs @@ -330,12 +330,54 @@ fn running_inside_app_bundle() -> bool { .unwrap_or(false) } +/// User-set external GenericAgent source directory (design A: desktop as a thin shell). +/// Returns the path only when it is a valid GA checkout (has agentmain.py AND +/// frontends/desktop_bridge.py). An invalid/missing override returns None so callers fall +/// back to the bundle's own runtime/app — this is the "本体 moved/deleted" safety net. +fn valid_ga_source_override() -> Option { + let s = read_settings() + .get("ga_source_override") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + if s.is_empty() { + return None; + } + let p = PathBuf::from(&s); + if p.join("agentmain.py").exists() && p.join("frontends").join("desktop_bridge.py").exists() { + Some(p.to_string_lossy().to_string()) + } else { + None + } +} + +/// Remove a single key from the settings file (merge_settings can only add/overwrite). +fn remove_setting(key: &str) { + let mut obj = read_settings(); + obj.remove(key); + let val = serde_json::Value::Object(obj); + if let Ok(text) = serde_json::to_string_pretty(&val) { + let _ = std::fs::write(settings_path(), text); + } +} + /// 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. pub fn get_or_discover_config() -> (String, String) { let path = settings_path(); + // A user-set, still-valid external GA source wins over everything — including the bundle's + // own runtime/app. This is what turns the desktop app into a thin shell over a separate 本体. + // Uses the bundle python (which has deps installed) to run the external source. + if let Some(src) = valid_ga_source_override() { + let python = find_python(); + if !python.is_empty() { + return (python, src); + } + } + if bundle_root().is_some() { let python = find_python(); let project = find_project_dir().unwrap_or_default(); @@ -726,28 +768,103 @@ fn export_mykey(content: String) -> Result, String> { } } +#[tauri::command] +fn pick_directory(title: Option) -> Option { + let mut dlg = rfd::FileDialog::new(); + if let Some(t) = title { + if !t.is_empty() { + dlg = dlg.set_title(&t); + } + } + dlg.pick_folder().map(|p| p.to_string_lossy().into_owned()) +} + +/// Stop the current bridge (ours or a stale one) and free :14168 before respawning. +fn stop_current_bridge() { + request_bridge_shutdown(); + if let Some(mut child) = BRIDGE_PROCESS.lock().unwrap().take() { + let _ = child.kill(); + let _ = child.wait(); + } + let start = Instant::now(); + while is_bridge_running() && start.elapsed() < Duration::from_secs(6) { + thread::sleep(Duration::from_millis(150)); + } + if is_bridge_running() { + force_free_bridge_port(); + let start = Instant::now(); + while is_bridge_running() && start.elapsed() < Duration::from_secs(5) { + thread::sleep(Duration::from_millis(150)); + } + } +} + +/// Restart the bridge with whatever get_or_discover_config() now resolves to, then reload the +/// webview so it reconnects. Shared by set_ga_source / clear_ga_source. +fn switch_bridge(app_handle: &tauri::AppHandle) -> Result { + stop_current_bridge(); + let (py, project) = get_or_discover_config(); + if project.is_empty() { + return Err("no GenericAgent source resolved".into()); + } + spawn_bridge_process(&py, &project)?; + if !wait_for_port(14168, Duration::from_secs(20)) { + return Err("bridge did not become ready within 20s".into()); + } + show_bridge_window(app_handle); + Ok(project) +} + +#[tauri::command] +fn get_ga_source() -> String { + read_settings() + .get("ga_source_override") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() +} + +#[tauri::command] +fn set_ga_source(app_handle: tauri::AppHandle, dir: String) -> Result { + let p = PathBuf::from(&dir); + if !p.join("agentmain.py").exists() { + return Err("not a GenericAgent source: agentmain.py not found".into()); + } + if !p.join("frontends").join("desktop_bridge.py").exists() { + return Err("frontends/desktop_bridge.py not found in the selected directory".into()); + } + merge_settings(serde_json::json!({ "ga_source_override": dir })); + switch_bridge(&app_handle) +} + +#[tauri::command] +fn clear_ga_source(app_handle: tauri::AppHandle) -> Result { + remove_setting("ga_source_override"); + switch_bridge(&app_handle) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let args: Vec = std::env::args().collect(); let no_autostart = args.iter().any(|a| a == "--no-autostart"); let dev_mode = args.iter().any(|a| a == "--dev"); - let project_dir = find_project_dir().unwrap_or_default(); - let needs_prepare = needs_first_run_prepare(&project_dir); + // Resolve the effective config once (honors a user-set external GA source over the bundle). + // Use it for takeover + spawn so identity checks match what we actually launch. + let (eff_py, eff_project) = get_or_discover_config(); + let needs_prepare = needs_first_run_prepare(&eff_project); - takeover_stale_bridge(&project_dir); + takeover_stale_bridge(&eff_project); let bridge_ok = is_bridge_running(); let mut spawned_bridge = false; // Skip the early spawn when a first-run prepare is required (no venv yet); // the setup thread prepares the env first and then starts the bridge. if !bridge_ok && !no_autostart && !needs_prepare { - // Try to start bridge with saved/discovered config - let (py_str, dir_str) = get_or_discover_config(); - let dir = PathBuf::from(&dir_str); + let dir = PathBuf::from(&eff_project); let script = dir.join("frontends").join("desktop_bridge.py"); if script.exists() { - let mut cmd = Command::new(&py_str); + let mut cmd = Command::new(&eff_py); cmd.arg(&script).current_dir(&dir); sanitize_bundle_env(&mut cmd); #[cfg(windows)] @@ -767,7 +884,7 @@ pub fn run() { let _ = w.set_focus(); } })) - .invoke_handler(tauri::generate_handler![start_bridge_with_config, start_bridge, get_config, export_mykey, shortcut_should_ask, shortcut_decide]) + .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]) .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. @@ -776,7 +893,7 @@ pub fn run() { } let handle = app.handle().clone(); - let project_dir = project_dir.clone(); + let project_dir = eff_project.clone(); thread::spawn(move || { // Progress reporter: push status into the loading window (window.gaProgress). let main_win = handle.get_webview_window("main"); diff --git a/frontends/desktop/static/app.js b/frontends/desktop/static/app.js index 7ffc842b..d3e1814a 100644 --- a/frontends/desktop/static/app.js +++ b/frontends/desktop/static/app.js @@ -284,6 +284,10 @@ let bridgeUiOffline = false; getServicePanel: () => rpc('services/panel', {}), getMykeyContent: () => rpc('services/mykey/get', {}), saveMykeyContent: (content) => rpc('services/mykey/save', { content }), + importMemory: (sourceDir) => rpc('memory/import', { sourceDir }), + getGaSource: () => tauriInvoke('get_ga_source'), + setGaSource: (dir) => tauriInvoke('set_ga_source', { dir }), + clearGaSource: () => tauriInvoke('clear_ga_source'), getConductorModel: () => rpc('services/conductor/model/get', {}), saveConductorModel: (llmNo) => rpc('services/conductor/model/save', { llmNo }), tauriInvoke, @@ -683,6 +687,79 @@ bindClick('export-mykey-btn', async (e) => { showChanToast(t('err.mykeyExport'), err.message || String(err), 'err'); } }); +// 记忆/session 导入与外接 GA 源码。 +async function importMemoryFromDir() { + let sourceDir = ''; + if (window.__TAURI__?.core?.invoke) { + sourceDir = await window.ga.tauriInvoke('pick_directory', { title: t('sys.memoryPickTitle') }); + } else { + sourceDir = window.prompt(t('sys.memoryImportPrompt')) || ''; + } + if (!sourceDir) return; + const res = await window.ga.importMemory(sourceDir); + if (!res || res.ok === false) throw new Error((res && res.error) || 'import failed'); + const detail = `memory: ${res.memoryCopied}, model_responses: ${res.responsesCopied}` + + (res.responsesSkipped ? ` (skip ${res.responsesSkipped})` : '') + + `, ${t('sys.memorySessions')}: ${res.sessionsAdded || 0}` + + (res.backupDir ? `\n${t('sys.memoryImportBackup')}: ${res.backupDir}` : ''); + if (res.sessionsAdded) { + try { await loadSessions(); renderSessionList(); } catch (_) {} + } + showChanToast(t('sys.memoryImported'), detail, 'ok'); +} +bindClick('import-memory-btn', async (e) => { + e.stopPropagation(); + try { + await importMemoryFromDir(); + } catch (err) { + showChanToast(t('err.memoryImport'), err.message || String(err), 'err'); + } +}); +const gaSourceCurrentEl = document.getElementById('ga-source-current'); +const gaSourceClearBtn = document.getElementById('ga-source-clear-btn'); +async function refreshGaSource() { + if (!window.__TAURI__?.core?.invoke) return; + let cur = ''; + try { cur = await window.ga.getGaSource(); } catch (_) { cur = ''; } + if (gaSourceCurrentEl) { + if (cur) { + gaSourceCurrentEl.textContent = `${t('set.gaSourceCurrent')}: ${cur}`; + gaSourceCurrentEl.hidden = false; + } else { + gaSourceCurrentEl.hidden = true; + } + } + if (gaSourceClearBtn) gaSourceClearBtn.hidden = !cur; +} +bindClick('ga-source-btn', async (e) => { + e.stopPropagation(); + if (!window.__TAURI__?.core?.invoke) { + showChanToast(t('err.gaSourceDesktopOnly'), '', 'err'); + return; + } + try { + const dir = await window.ga.tauriInvoke('pick_directory', { title: t('sys.gaSourcePickTitle') }); + if (!dir) return; + showChanToast(t('sys.gaSourceSwitching'), dir, 'ok'); + const project = await window.ga.setGaSource(dir); + await refreshGaSource(); + showChanToast(t('sys.gaSourceSet'), project || dir, 'ok'); + } catch (err) { + showChanToast(t('err.gaSourceSet'), err.message || String(err), 'err'); + } +}); +bindClick('ga-source-clear-btn', async (e) => { + e.stopPropagation(); + try { + showChanToast(t('sys.gaSourceSwitching'), '', 'ok'); + await window.ga.clearGaSource(); + await refreshGaSource(); + showChanToast(t('sys.gaSourceCleared'), '', 'ok'); + } catch (err) { + showChanToast(t('err.gaSourceSet'), err.message || String(err), 'err'); + } +}); +refreshGaSource(); // 侧边栏「快速接入」:点击官方模型按钮 → 打开预填好的添加模型表单 const pqEl = document.getElementById('provider-quickstart'); if (pqEl) pqEl.addEventListener('click', (e) => { diff --git a/frontends/desktop/static/i18n.js b/frontends/desktop/static/i18n.js index e8e05cb7..5828e5bc 100644 --- a/frontends/desktop/static/i18n.js +++ b/frontends/desktop/static/i18n.js @@ -29,7 +29,7 @@ 'customPreset.removeTitle': '删除', 'customPreset.editTitle': '编辑', 'builtinPreset.restoreBtn': '恢复默认预设', - 'set.appearance': '外观', 'set.plainUi': '素色', 'set.fontSize': '聊天字号', 'set.lang': '语言', 'set.model': '模型', 'set.addModel': '添加模型', 'set.features': '功能', 'set.importMykey': '导入已有模型配置(mykey.py)', 'set.exportMykey': '导出当前模型配置', 'set.serviceManager': '后台服务管理', + 'set.appearance': '外观', 'set.plainUi': '素色', 'set.fontSize': '聊天字号', 'set.lang': '语言', 'set.model': '模型', 'set.addModel': '添加模型', 'set.features': '功能', 'set.importMykey': '导入已有模型配置(mykey.py)', 'set.exportMykey': '导出当前模型配置', 'set.importMemory': '导入已有记忆与会话记录(选择 GenericAgent 根目录)', 'set.gaSource': '接入独立 GenericAgent 源码(把桌面版当作壳)', 'set.gaSourceClear': '取消接入,改用内置版本', 'set.gaSourceCurrent': '当前接入', 'set.serviceManager': '后台服务管理', 'shortcut.askConfirm': '是否在桌面创建 GenericAgent 快捷方式?', 'appearance.light': '浅色', 'appearance.dark': '深色', 'set.noModels': '暂无模型,点击下方添加', @@ -150,6 +150,18 @@ 'sys.configSaved': '配置已保存', 'sys.mykeyImported': '模型配置已导入', 'sys.mykeyExported': '模型配置已导出', + 'sys.memoryImported': '记忆已导入', + 'err.memoryImport': '导入记忆失败', + 'sys.memoryImportBackup': '原记忆已备份至', + 'sys.memorySessions': '会话', + 'sys.gaSourcePickTitle': '选择要接入的 GenericAgent 源码根目录(含 agentmain.py)', + 'sys.gaSourceSwitching': '正在切换并重启后端...', + 'sys.gaSourceSet': '已接入独立 GenericAgent', + 'sys.gaSourceCleared': '已取消接入,恢复内置版本', + 'err.gaSourceSet': '接入失败', + 'err.gaSourceDesktopOnly': '此功能仅在桌面版中可用', + 'sys.memoryPickTitle': '选择 GenericAgent 根目录(包含 memory 与 temp 的目录)', + 'sys.memoryImportPrompt': '请输入 GenericAgent 根目录的完整路径(包含 memory 与 temp 的目录,而非 memory 文件夹本身):', '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': '发送', @@ -202,7 +214,7 @@ '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 (mykey.py)', 'set.exportMykey': 'Export current model config', '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 (mykey.py)', 'set.exportMykey': 'Export current model config', 'set.importMemory': 'Import existing memory & sessions (select GenericAgent root)', 'set.gaSource': 'Connect to a separate GenericAgent source (use desktop as a shell)', 'set.gaSourceClear': 'Disconnect, use the bundled version', 'set.gaSourceCurrent': 'Connected to', 'set.serviceManager': 'Service manager', 'shortcut.askConfirm': 'Create a desktop shortcut for GenericAgent?', 'appearance.light': 'Light', 'appearance.dark': 'Dark', 'set.noModels': 'No models yet — add one below', @@ -323,6 +335,18 @@ 'sys.configSaved': 'Configuration saved', 'sys.mykeyImported': 'Model config imported', 'sys.mykeyExported': 'Model config exported', + 'sys.memoryImported': 'Memory imported', + 'err.memoryImport': 'Failed to import memory', + 'sys.memoryImportBackup': 'Previous memory backed up to', + 'sys.memorySessions': 'sessions', + 'sys.gaSourcePickTitle': 'Select the GenericAgent source root to connect to (contains agentmain.py)', + 'sys.gaSourceSwitching': 'Switching and restarting the backend...', + 'sys.gaSourceSet': 'Connected to the external GenericAgent', + 'sys.gaSourceCleared': 'Disconnected, restored the bundled version', + 'err.gaSourceSet': 'Failed to connect', + '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):', '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 bc0ff956..ef62a7c1 100644 --- a/frontends/desktop/static/index.html +++ b/frontends/desktop/static/index.html @@ -649,6 +649,19 @@ + + + +