diff --git a/CHANGELOG.md b/CHANGELOG.md index b10a6ab..77381d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ ### 新功能 (Features) +- **便携模式双向迁移** — 便携模式下面板设置新增「迁移回本机」:把 U 盘上的配置、Agent、记忆、模型渠道与媒体数据迁回本机默认位置;本机已有数据先整体改名备份(.backup-时间戳)再替换,避免新旧数据混合,引擎与运行时按常规方式在本机安装 - **统一模型渠道** — 新增「模型渠道」页面(通用分组):一处维护模型接入配置(服务商预设 / 接口地址 / API Key / 模型列表),一键同步到 OpenClaw、Hermes 与晴辰助手,不再需要在三处重复配置 - 同步为显式推送并逐项确认:写入 OpenClaw 只更新对应 provider 且保留未知字段(自动备份);写入 Hermes 走环境变量与配置命令(自动备份);同步助手为一次性拷贝 - 支持从 OpenClaw 现有模型配置一键导入渠道、从服务商在线拉取模型列表、渠道修改后的「有未同步变更」提醒 diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 6ef0dcb..75de9da 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -8784,7 +8784,7 @@ const ALWAYS_LOCAL = new Set([ 'cancel_media_job', 'list_media_jobs', 'delete_media_job', 'reveal_media_asset', 'reveal_media_output_dir', 'load_media_asset', // 便携模式是本进程属性,不能代理到远程实例 - 'get_portable_status', 'migrate_to_portable', + 'get_portable_status', 'migrate_to_portable', 'migrate_to_local', ]) // === 工具函数 === @@ -13585,6 +13585,10 @@ const handlers = { throw new Error('Web 模式不支持迁移为便携式,请使用桌面客户端执行') }, + migrate_to_local() { + throw new Error('Web 模式不支持便携迁移,请使用桌面客户端执行') + }, + get_openclaw_dir() { const panelConfig = readPanelConfig() const info = applyOpenclawPathConfig(panelConfig) diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 1c4ba94..0163786 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -689,6 +689,11 @@ fn hermes_home() -> PathBuf { if let Some(ctx) = crate::commands::portable::portable_context() { return ctx.hermes_home.clone(); } + local_hermes_home_default() +} + +/// 本机(非便携)Hermes 数据目录默认值;便携模式迁移回本机时也用它定位目标 +pub(crate) fn local_hermes_home_default() -> PathBuf { if let Ok(h) = std::env::var("HERMES_HOME") { return PathBuf::from(h); } diff --git a/src-tauri/src/commands/portable.rs b/src-tauri/src/commands/portable.rs index c4a2829..daae603 100644 --- a/src-tauri/src/commands/portable.rs +++ b/src-tauri/src/commands/portable.rs @@ -517,6 +517,125 @@ fn migrate_to_portable_impl( })) } +/// 目录存在且非空才值得备份;空目录直接复用,避免产生噪音备份 +fn needs_backup(path: &Path) -> bool { + if path.is_file() { + return true; + } + std::fs::read_dir(path) + .map(|mut entries| entries.next().is_some()) + .unwrap_or(false) +} + +/// 目标已存在时的备份路径(同级、带时间戳),rename 即时完成 +fn backup_sibling_path(path: &Path, timestamp: &str) -> PathBuf { + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "data".into()); + path.with_file_name(format!("{name}.backup-{timestamp}")) +} + +/// 将便携数据迁移回本机默认位置(migrate_to_portable 的反向)。 +/// 语义:以 U 盘数据为准——本机已有数据先整体改名备份(.backup-<时间戳>), +/// 再全新复制,避免新旧数据合并出难排查的混合状态。 +/// 引擎与运行时不迁移(本机按常规方式安装),只搬用户数据。 +fn migrate_to_local_impl( + source_openclaw_dir: &Path, + source_hermes_home: &Path, + source_panel_config: Option, + target_openclaw_dir: &Path, + target_hermes_home: &Path, +) -> Result { + // 防呆:目标不能位于便携源内部或与其相同(自定义路径可能指回 U 盘) + if path_is_inside_or_same(target_openclaw_dir, source_openclaw_dir) + || path_is_inside_or_same(source_openclaw_dir, target_openclaw_dir) + { + return Err("本机 OpenClaw 目录与便携目录重叠,无法迁移".into()); + } + if path_is_inside_or_same(target_hermes_home, source_hermes_home) + || path_is_inside_or_same(source_hermes_home, target_hermes_home) + { + return Err("本机 Hermes 目录与便携目录重叠,无法迁移".into()); + } + + let timestamp = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string(); + let mut backups: Vec = Vec::new(); + let mut warnings: Vec = Vec::new(); + + // OpenClaw 数据(含面板级 clawpanel/ 子目录:模型渠道、媒体数据随之带回) + let mut copied_openclaw = false; + if source_openclaw_dir.is_dir() { + if target_openclaw_dir.exists() && needs_backup(target_openclaw_dir) { + let bak = backup_sibling_path(target_openclaw_dir, ×tamp); + std::fs::rename(target_openclaw_dir, &bak).map_err(|e| { + format!("备份本机 OpenClaw 数据失败(若本机 Gateway 正在运行请先停止): {e}") + })?; + backups.push(bak.to_string_lossy().to_string()); + } + copy_dir_recursive(source_openclaw_dir, target_openclaw_dir)?; + copied_openclaw = true; + } else { + warnings.push("portable-openclaw-missing".into()); + } + + // Hermes 数据 + let mut copied_hermes = false; + if source_hermes_home.is_dir() { + if target_hermes_home.exists() && needs_backup(target_hermes_home) { + let bak = backup_sibling_path(target_hermes_home, ×tamp); + std::fs::rename(target_hermes_home, &bak) + .map_err(|e| format!("备份本机 Hermes 数据失败: {e}"))?; + backups.push(bak.to_string_lossy().to_string()); + } + copy_dir_recursive(source_hermes_home, target_hermes_home)?; + copied_hermes = true; + } + + // 面板配置:写入本机 openclaw 目录下的 clawpanel.json; + // 与正向迁移同理清洗绝对路径字段(便携配置里可能残留指向 U 盘的路径) + let (panel, removed_keys) = sanitized_panel_config(source_panel_config); + let target_panel_config = target_openclaw_dir.join("clawpanel.json"); + std::fs::create_dir_all(target_openclaw_dir) + .map_err(|e| format!("创建本机数据目录失败: {e}"))?; + let panel_text = serde_json::to_string_pretty(&panel) + .map_err(|e| format!("序列化 clawpanel.json 失败: {e}"))?; + std::fs::write(&target_panel_config, panel_text) + .map_err(|e| format!("写入本机 clawpanel.json 失败: {e}"))?; + + Ok(json!({ + "openclawDir": target_openclaw_dir.to_string_lossy(), + "hermesHome": target_hermes_home.to_string_lossy(), + "panelConfigPath": target_panel_config.to_string_lossy(), + "copiedOpenclaw": copied_openclaw, + "copiedHermesHome": copied_hermes, + "backups": backups, + "removedPanelKeys": removed_keys, + // 引擎与运行时不迁移:本机没有安装时需按常规方式安装 + "enginesNotMigrated": true, + "warnings": warnings, + })) +} + +/// 便携模式 → 本机:把 U 盘上的用户数据迁移回本机默认位置。 +/// 当前进程仍是便携模式;迁移后请从本机安装的 ClawPanel 启动查看 +#[tauri::command] +pub fn migrate_to_local() -> Result { + let Some(ctx) = portable_context() else { + return Err("当前不是便携模式,无需迁移回本机".into()); + }; + let target_openclaw = crate::commands::default_openclaw_dir(); + let target_hermes = crate::commands::hermes::local_hermes_home_default(); + let panel_config = crate::commands::read_panel_config_from(&ctx.panel_config_path); + migrate_to_local_impl( + &ctx.openclaw_dir, + &ctx.hermes_home, + panel_config, + &target_openclaw, + &target_hermes, + ) +} + fn active_standalone_engine_dir() -> Option { let cli_path = crate::utils::resolve_openclaw_cli_path()?; if crate::utils::classify_cli_source(&cli_path) != "standalone" { @@ -785,6 +904,91 @@ mod tests { let _ = std::fs::remove_dir_all(&root); } + #[test] + fn migrate_to_local_backs_up_existing_and_copies_portable_data() { + let usb_openclaw = temp_root("to-local-src-oc"); + let usb_hermes = temp_root("to-local-src-hm"); + let local_openclaw = temp_root("to-local-dst-oc"); + let local_hermes = temp_root("to-local-dst-hm"); + + // U 盘数据 + std::fs::write(usb_openclaw.join("openclaw.json"), br#"{ "from": "usb" }"#).unwrap(); + std::fs::create_dir_all(usb_openclaw.join("clawpanel").join("media")).unwrap(); + std::fs::write( + usb_openclaw.join("clawpanel").join("model-channels.json"), + br#"{ "version": 1 }"#, + ) + .unwrap(); + std::fs::write(usb_hermes.join("config.yaml"), b"model: usb\n").unwrap(); + + // 本机已有旧数据(应被整体备份而非覆盖合并) + std::fs::write( + local_openclaw.join("openclaw.json"), + br#"{ "from": "local" }"#, + ) + .unwrap(); + std::fs::write(local_openclaw.join("local-only.txt"), b"keep-in-backup").unwrap(); + + let panel = json!({ + "accessPassword": "usb-pw", + "nodePath": "F:\\ClawPanelPortable\\runtimes\\node" + }); + + let report = migrate_to_local_impl( + &usb_openclaw, + &usb_hermes, + Some(panel), + &local_openclaw, + &local_hermes, + ) + .unwrap(); + + // U 盘数据落到本机 + let restored = std::fs::read_to_string(local_openclaw.join("openclaw.json")).unwrap(); + assert!(restored.contains("usb")); + assert!(local_openclaw + .join("clawpanel") + .join("model-channels.json") + .is_file()); + assert!(local_hermes.join("config.yaml").is_file()); + // 面板配置写入且绝对路径字段被清洗 + let panel_restored: Value = serde_json::from_str( + &std::fs::read_to_string(local_openclaw.join("clawpanel.json")).unwrap(), + ) + .unwrap(); + assert_eq!(panel_restored["accessPassword"], "usb-pw"); + assert!(panel_restored.get("nodePath").is_none()); + // 本机旧数据完整备份 + let backups = report["backups"].as_array().unwrap(); + assert_eq!(backups.len(), 1); + let backup_dir = PathBuf::from(backups[0].as_str().unwrap()); + assert!(backup_dir.join("local-only.txt").is_file()); + let old = std::fs::read_to_string(backup_dir.join("openclaw.json")).unwrap(); + assert!(old.contains("local")); + assert_eq!(report["enginesNotMigrated"], true); + + for dir in [ + &usb_openclaw, + &usb_hermes, + &local_openclaw, + &local_hermes, + &backup_dir, + ] { + let _ = std::fs::remove_dir_all(dir); + } + } + + #[test] + fn migrate_to_local_rejects_overlapping_paths() { + let usb = temp_root("to-local-overlap"); + let nested = usb.join("data").join("openclaw"); + std::fs::create_dir_all(&nested).unwrap(); + let err = migrate_to_local_impl(&nested, &usb.join("hm"), None, &usb, &usb.join("hm2")) + .unwrap_err(); + assert!(err.contains("重叠")); + let _ = std::fs::remove_dir_all(&usb); + } + #[test] fn migration_creates_portable_layout_and_sanitizes_host_paths() { let target = temp_root("migrate-target"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7f6863c..365399e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -116,6 +116,7 @@ pub fn run() { config::write_panel_config, portable::get_portable_status, portable::migrate_to_portable, + portable::migrate_to_local, config::test_proxy, config::get_npm_registry, config::set_npm_registry, diff --git a/src/lib/tauri-api.js b/src/lib/tauri-api.js index 46940e1..7bc1663 100644 --- a/src/lib/tauri-api.js +++ b/src/lib/tauri-api.js @@ -382,6 +382,10 @@ export const api = { getOpenclawDir: () => invoke('get_openclaw_dir'), // 便携模式状态(启动期固定不变,只读) getPortableStatus: () => invoke('get_portable_status'), + migrateToLocal: () => { + invalidate('get_portable_status', 'read_panel_config', 'check_installation', 'get_version_info') + return invoke('migrate_to_local') + }, migrateToPortable: (targetRoot) => { invalidate('get_portable_status', 'read_panel_config', 'check_installation', 'get_version_info') return invoke('migrate_to_portable', { targetRoot }) diff --git a/src/locales/modules/settings.js b/src/locales/modules/settings.js index c71c6b6..f34d28d 100644 --- a/src/locales/modules/settings.js +++ b/src/locales/modules/settings.js @@ -28,6 +28,16 @@ export default { portableDesktopOnly: _('便携模式迁移仅支持桌面客户端', 'Portable migration is only available in the desktop client', '便攜模式遷移僅支援桌面客戶端', 'ポータブル移行はデスクトップクライアントでのみ利用できます', '휴대용 마이그레이션은 데스크톱 클라이언트에서만 사용할 수 있습니다'), portableAlreadyEnabled: _('已启用便携模式', 'Portable mode enabled', '已啟用便攜模式', 'ポータブルモード有効', '휴대용 모드 활성화됨'), portableEnabledHint: _('当前会话已从便携目录启动,配置和 OpenClaw 数据会优先写入该目录。', 'This session was started from a portable directory. Config and OpenClaw data are written there first.', '目前工作階段已從便攜目錄啟動,設定與 OpenClaw 資料會優先寫入該目錄。'), + portableRestoreBtn: _('迁移回本机', 'Migrate to This Computer', '遷移回本機'), + portableRestoreHint: _('把 U 盘上的配置、Agent、记忆等用户数据迁回本机默认位置;本机已有数据会先整体备份。', 'Copies your config, agents, and memory from the portable drive back to this computer; existing local data is backed up first.', '把 USB 上的設定、Agent、記憶等使用者資料遷回本機預設位置;本機已有資料會先整體備份。'), + portableRestoreConfirm: _('确定把便携数据迁移回本机?', 'Migrate portable data back to this computer?', '確定把便攜資料遷移回本機?'), + portableRestoreImpactBackup: _('本机已有的 OpenClaw / Hermes 数据将整体改名备份(.backup-时间戳),然后以 U 盘数据替换', 'Existing local OpenClaw / Hermes data is renamed to a timestamped backup, then replaced with the portable data', '本機已有的 OpenClaw / Hermes 資料將整體改名備份(.backup-時間戳),然後以 USB 資料取代'), + portableRestoreImpactEngines: _('引擎与运行时不迁移:本机未安装 OpenClaw / Hermes 时需按常规方式安装', 'Engines and runtimes are not migrated: install OpenClaw / Hermes locally if not present', '引擎與執行環境不遷移:本機未安裝 OpenClaw / Hermes 時需按常規方式安裝'), + portableRestoreImpactRestart: _('迁移完成后请从本机安装的 ClawPanel 启动查看(当前会话仍是便携模式)', 'After migration, launch the locally installed ClawPanel (this session stays in portable mode)', '遷移完成後請從本機安裝的 ClawPanel 啟動查看(目前工作階段仍是便攜模式)'), + portableRestoring: _('正在迁移回本机...', 'Migrating to this computer...', '正在遷移回本機...'), + portableRestoreDone: _('已迁移回本机', 'Migrated to this computer', '已遷移回本機'), + portableRestoreBackups: _('本机原数据备份位置', 'Local data backed up to', '本機原資料備份位置'), + portableRestoreNext: _('下一步:从本机安装的 ClawPanel 启动即可使用迁回的数据;确认无误后可自行删除备份目录。', 'Next: launch the locally installed ClawPanel to use the restored data; delete the backup folders once verified.', '下一步:從本機安裝的 ClawPanel 啟動即可使用遷回的資料;確認無誤後可自行刪除備份目錄。'), portableModeHint: _('选择一个新的 U 盘目录,面板会创建 portable.json,复制当前面板配置和 OpenClaw 数据,并清理本机绝对路径。完成后从目标目录里的程序重新启动。', 'Choose a new USB directory. ClawPanel will create portable.json, copy current panel config and OpenClaw data, and remove host-specific absolute paths. Restart from the target directory when done.', '選擇一個新的 U 盤目錄,面板會建立 portable.json,複製目前面板設定與 OpenClaw 資料,並清理本機絕對路徑。完成後從目標目錄裡的程式重新啟動。'), portableMigrateBtn: _('迁移为便携式', 'Migrate to Portable', '遷移為便攜式', 'ポータブルへ移行', '휴대용으로 마이그레이션'), portableTargetRequired: _('请先填写目标目录', 'Please enter a target directory', '請先填寫目標目錄', '対象ディレクトリを入力してください', '대상 디렉터리를 입력하세요'), diff --git a/src/pages/settings.js b/src/pages/settings.js index 9957dd6..9e6b688 100644 --- a/src/pages/settings.js +++ b/src/pages/settings.js @@ -509,6 +509,9 @@ function bindEvents(page) { case 'migrate-portable': await handleMigrateToPortable(page) break + case 'migrate-local': + await handleMigrateToLocal(page) + break } } catch (e) { toast(e.toString(), 'error') @@ -825,6 +828,11 @@ async function loadPortableMigration(page) { ${escapeHtml(status.root || '')}
${t('settings.portableEnabledHint')}
+
+ +
+
${t('settings.portableRestoreHint')}
+ ` return } @@ -870,6 +878,47 @@ function renderPortableMigrationResult(report) { ` } +// 便携模式 → 本机:本机已有数据整体备份后以 U 盘数据替换,引擎不迁移 +async function handleMigrateToLocal(page) { + const resultEl = page.querySelector('#portable-restore-result') + const ok = await showConfirm({ + message: t('settings.portableRestoreConfirm'), + impact: [ + t('settings.portableRestoreImpactBackup'), + t('settings.portableRestoreImpactEngines'), + t('settings.portableRestoreImpactRestart'), + ], + }) + if (!ok) return + if (resultEl) { + resultEl.style.display = 'block' + resultEl.innerHTML = `
${t('settings.portableRestoring')}
` + } + let report + try { + report = await api.migrateToLocal() + } catch (e) { + // 失败必须把结果框切到错误态,不能停留在"迁移中" + if (resultEl) { + resultEl.innerHTML = `
${escapeHtml(e?.message || String(e))}
` + } + throw e + } + if (resultEl) { + const backups = Array.isArray(report?.backups) ? report.backups : [] + resultEl.innerHTML = ` +
+
${t('settings.portableRestoreDone')}
+
${t('settings.portableMigrateOpenclaw')}: ${escapeHtml(report?.openclawDir || '')}
+
${t('settings.portableMigrateHermes')}: ${escapeHtml(report?.hermesHome || '')}
+ ${backups.length ? `
${t('settings.portableRestoreBackups')}:
    ${backups.map(b => `
  • ${escapeHtml(b)}
  • `).join('')}
` : ''} +
${t('settings.portableRestoreNext')}
+
+ ` + } + toast(t('settings.portableRestoreDone'), 'success') +} + async function handleMigrateToPortable(page) { const input = page.querySelector('[data-name="portable-target-root"]') const resultEl = page.querySelector('#portable-migrate-result')