feat(portable): 便携模式支持迁移回本机——双向迁移闭环

migrate_to_portable 的反向:便携模式下面板设置新增「迁移回本机」。

- 语义:以 U 盘数据为准,本机已有 OpenClaw / Hermes 数据先整体改名
  备份(.backup-时间戳,rename 即时完成;空目录直接复用不备份),
  再全新复制,避免新旧数据合并出难排查的混合状态
- 覆盖:OpenClaw 数据目录(含模型渠道、创作中心数据)、Hermes 数据
  目录、面板配置(与正向迁移同理清洗指向 U 盘的绝对路径字段)
- 不迁移引擎与运行时(本机按常规方式安装),报告卡片明示备份位置
  与下一步操作;重叠路径防呆;确认弹窗列出影响清单
- Web 模式明确拒绝并提示用桌面端;i18n 简/繁/英

测试:migrate_to_local_impl 备份/复制/清洗/重叠拒绝 2 个单元测试;
cargo test --lib 316 通过,node --test 428 通过,clippy/fmt 干净。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
晴天
2026-07-05 22:20:26 -07:00
parent bfc46841fa
commit b65296a489
8 changed files with 279 additions and 1 deletions

View File

@@ -27,6 +27,7 @@
### 新功能 (Features)
- **便携模式双向迁移** — 便携模式下面板设置新增「迁移回本机」:把 U 盘上的配置、Agent、记忆、模型渠道与媒体数据迁回本机默认位置本机已有数据先整体改名备份.backup-时间戳)再替换,避免新旧数据混合,引擎与运行时按常规方式在本机安装
- **统一模型渠道** — 新增「模型渠道」页面(通用分组):一处维护模型接入配置(服务商预设 / 接口地址 / API Key / 模型列表),一键同步到 OpenClaw、Hermes 与晴辰助手,不再需要在三处重复配置
- 同步为显式推送并逐项确认:写入 OpenClaw 只更新对应 provider 且保留未知字段(自动备份);写入 Hermes 走环境变量与配置命令(自动备份);同步助手为一次性拷贝
- 支持从 OpenClaw 现有模型配置一键导入渠道、从服务商在线拉取模型列表、渠道修改后的「有未同步变更」提醒

View File

@@ -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)

View File

@@ -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);
}

View File

@@ -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<Value>,
target_openclaw_dir: &Path,
target_hermes_home: &Path,
) -> Result<Value, String> {
// 防呆:目标不能位于便携源内部或与其相同(自定义路径可能指回 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<String> = Vec::new();
let mut warnings: Vec<String> = 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, &timestamp);
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, &timestamp);
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<Value, String> {
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<PathBuf> {
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");

View File

@@ -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,

View File

@@ -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 })

View File

@@ -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', '請先填寫目標目錄', '対象ディレクトリを入力してください', '대상 디렉터리를 입력하세요'),

View File

@@ -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) {
<code style="font-size:var(--font-size-xs)">${escapeHtml(status.root || '')}</code>
</div>
<div class="form-hint" style="margin-top:var(--space-xs)">${t('settings.portableEnabledHint')}</div>
<div style="display:flex;align-items:center;gap:var(--space-sm);margin-top:var(--space-sm)">
<button class="btn btn-secondary btn-sm" data-action="migrate-local">${t('settings.portableRestoreBtn')}</button>
</div>
<div class="form-hint" style="margin-top:var(--space-xs)">${t('settings.portableRestoreHint')}</div>
<div id="portable-restore-result" style="margin-top:var(--space-sm);display:none"></div>
`
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 = `<div style="color:var(--text-tertiary);font-size:var(--font-size-sm)">${t('settings.portableRestoring')}</div>`
}
let report
try {
report = await api.migrateToLocal()
} catch (e) {
// 失败必须把结果框切到错误态,不能停留在"迁移中"
if (resultEl) {
resultEl.innerHTML = `<div style="color:var(--error);font-size:var(--font-size-sm)">${escapeHtml(e?.message || String(e))}</div>`
}
throw e
}
if (resultEl) {
const backups = Array.isArray(report?.backups) ? report.backups : []
resultEl.innerHTML = `
<div style="padding:10px 12px;border:1px solid var(--border-primary);border-radius:var(--radius-sm);background:var(--bg-secondary);font-size:var(--font-size-sm);line-height:1.6">
<div style="font-weight:600;color:var(--success);margin-bottom:4px">${t('settings.portableRestoreDone')}</div>
<div>${t('settings.portableMigrateOpenclaw')}: <code>${escapeHtml(report?.openclawDir || '')}</code></div>
<div>${t('settings.portableMigrateHermes')}: <code>${escapeHtml(report?.hermesHome || '')}</code></div>
${backups.length ? `<div style="margin-top:4px">${t('settings.portableRestoreBackups')}:</div><ul style="margin:2px 0 0 18px;padding:0">${backups.map(b => `<li><code style="font-size:var(--font-size-xs)">${escapeHtml(b)}</code></li>`).join('')}</ul>` : ''}
<div style="margin-top:6px;color:var(--text-secondary)">${t('settings.portableRestoreNext')}</div>
</div>
`
}
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')