diff --git a/crates/agent-gui/src-tauri/src/commands/app/data_dir.rs b/crates/agent-gui/src-tauri/src/commands/app/data_dir.rs new file mode 100644 index 000000000..7cf08dcbb --- /dev/null +++ b/crates/agent-gui/src-tauri/src/commands/app/data_dir.rs @@ -0,0 +1,386 @@ +//! Relocating the LiveAgent data root (`~/.liveagent` by default) from settings. +//! +//! Migration stops K-brain, copies the current root into an empty target, +//! verifies every copied file, then records the target in the pointer file of +//! the default root. The new location takes effect after the app restarts; the +//! old directory is left untouched so a failed or interrupted move never loses +//! data, and the user can delete it once satisfied. +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; + +use serde::Serialize; +use tauri::{AppHandle, Manager}; + +use crate::runtime::data_home::{ + data_root, data_root_env_override, default_data_root, write_pointer, POINTER_FILE_NAME, +}; +use crate::services::kbrain_backend::KBrainBackendState; + +/// Entries that always stay in the default root: the relocation pointer, the +/// browser extension (Chrome records its absolute path) and git worktrees +/// (repositories record their absolute paths). +const PINNED_TO_DEFAULT_ROOT: [&str; 3] = [POINTER_FILE_NAME, "extension", "worktree"]; +/// Finder/Explorer metadata that does not make a target directory "non-empty". +const IGNORED_TARGET_ENTRIES: [&str; 2] = [".DS_Store", "desktop.ini"]; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DataDirStatus { + pub current: String, + pub default: String, + pub custom: bool, + /// Environment variable that pins the data location, if any; relocation is + /// disabled while it is set. + pub env_override: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DataDirMigrateResult { + pub previous: String, + pub target: String, + pub files: u64, + pub bytes: u64, +} + +fn roots() -> Result<(PathBuf, PathBuf), String> { + let current = data_root().ok_or_else(|| "无法定位用户目录".to_string())?; + let default = default_data_root().ok_or_else(|| "无法定位用户目录".to_string())?; + Ok((current, default)) +} + +#[tauri::command] +pub fn data_dir_status() -> Result { + let (current, default) = roots()?; + Ok(DataDirStatus { + custom: current != default, + current: current.to_string_lossy().into_owned(), + default: default.to_string_lossy().into_owned(), + env_override: data_root_env_override().map(str::to_string), + }) +} + +#[tauri::command(rename_all = "snake_case")] +pub async fn data_dir_migrate( + app: AppHandle, + target: String, +) -> Result { + if let Some(name) = data_root_env_override() { + return Err(format!("数据目录由环境变量 {name} 指定,无法在设置中修改")); + } + let (source, default) = roots()?; + let target = PathBuf::from(target.trim()); + validate_target(&source, &target, &default)?; + + let backend = app + .try_state::>() + .map(|state| Arc::clone(state.inner())); + if let Some(backend) = &backend { + backend.shutdown(); + } + let (copy_source, copy_target, pointer_root) = + (source.clone(), target.clone(), default.clone()); + let outcome = tauri::async_runtime::spawn_blocking(move || { + let stats = migrate_sync(©_source, ©_target, &pointer_root)?; + Ok::<_, String>(stats) + }) + .await + .map_err(|error| format!("数据迁移任务异常退出:{error}")) + .and_then(|result| result); + + match outcome { + Ok((files, bytes)) => Ok(DataDirMigrateResult { + previous: source.to_string_lossy().into_owned(), + target: target.to_string_lossy().into_owned(), + files, + bytes, + }), + Err(error) => { + // The old root is still authoritative: bring K-brain back on it. + if let Some(backend) = backend { + let handle = app.clone(); + let _ = + tauri::async_runtime::spawn_blocking(move || backend.ensure_started(&handle)) + .await; + } + Err(error) + } + } +} + +/// Copies `source` into `target`, verifies it and switches the pointer. On any +/// failure the entries created in `target` are removed again. +fn migrate_sync(source: &Path, target: &Path, default_root: &Path) -> Result<(u64, u64), String> { + fs::create_dir_all(target).map_err(|e| format!("创建目标目录失败:{e}"))?; + let mut created = Vec::new(); + let result = + copy_root(source, target, source == default_root, &mut created).and_then(|stats| { + write_pointer(default_root, Some(target)) + .map_err(|e| format!("写入数据目录配置失败:{e}"))?; + Ok(stats) + }); + if result.is_err() { + for entry in created { + let _ = if entry.is_dir() && !entry.is_symlink() { + fs::remove_dir_all(&entry) + } else { + fs::remove_file(&entry) + }; + } + } + result +} + +fn validate_target(source: &Path, target: &Path, default_root: &Path) -> Result<(), String> { + if !target.is_absolute() { + return Err("请选择一个绝对路径".into()); + } + if !source.is_dir() { + return Err(format!("当前数据目录不存在:{}", source.display())); + } + let source_real = fs::canonicalize(source).map_err(|e| format!("解析当前数据目录失败:{e}"))?; + let target_real = resolve_lexically(target)?; + if target_real == source_real { + return Err("新目录与当前数据目录相同".into()); + } + if target_real.starts_with(&source_real) || source_real.starts_with(&target_real) { + return Err("新目录不能位于当前数据目录之内,也不能包含当前数据目录".into()); + } + match fs::metadata(target) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(format!("读取目标目录失败:{error}")), + Ok(meta) if !meta.is_dir() => Err("目标路径不是目录".into()), + Ok(_) => { + let pinned_allowed = target == default_root; + let entries = fs::read_dir(target).map_err(|e| format!("读取目标目录失败:{e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("读取目标目录失败:{e}"))?; + let name = entry.file_name(); + let name = name.to_string_lossy(); + let ignorable = IGNORED_TARGET_ENTRIES.contains(&name.as_ref()) + || (pinned_allowed && PINNED_TO_DEFAULT_ROOT.contains(&name.as_ref())); + if !ignorable { + return Err(format!( + "目标目录不为空,请选择空目录(或先清理 {})", + target.display() + )); + } + } + Ok(()) + } + } +} + +/// Canonicalizes the nearest existing ancestor and re-appends the rest, so a +/// not-yet-created target can still be compared against the source. +fn resolve_lexically(path: &Path) -> Result { + let mut existing = path.to_path_buf(); + let mut rest = Vec::new(); + while !existing.exists() { + match (existing.file_name(), existing.parent()) { + (Some(name), Some(parent)) => { + rest.push(name.to_os_string()); + existing = parent.to_path_buf(); + } + _ => break, + } + } + let mut resolved = fs::canonicalize(&existing).map_err(|e| format!("解析目标目录失败:{e}"))?; + for name in rest.into_iter().rev() { + if Path::new(&name) + .components() + .any(|c| matches!(c, Component::ParentDir)) + { + return Err("目标路径不能包含 ..".into()); + } + resolved.push(name); + } + Ok(resolved) +} + +fn copy_root( + source: &Path, + target: &Path, + skip_pinned: bool, + created: &mut Vec, +) -> Result<(u64, u64), String> { + let mut stats = (0u64, 0u64); + let entries = fs::read_dir(source).map_err(|e| format!("读取数据目录失败:{e}"))?; + for entry in entries { + let entry = entry.map_err(|e| format!("读取数据目录失败:{e}"))?; + let name = entry.file_name(); + if skip_pinned && PINNED_TO_DEFAULT_ROOT.contains(&name.to_string_lossy().as_ref()) { + continue; + } + let dest = target.join(&name); + if dest.symlink_metadata().is_ok() { + // Only ignorable metadata files can pre-exist (see validate_target). + continue; + } + created.push(dest.clone()); + copy_entry(&entry.path(), &dest, &mut stats)?; + } + Ok(stats) +} + +fn copy_entry(source: &Path, dest: &Path, stats: &mut (u64, u64)) -> Result<(), String> { + let meta = + fs::symlink_metadata(source).map_err(|e| format!("读取 {} 失败:{e}", source.display()))?; + let file_type = meta.file_type(); + if file_type.is_symlink() { + let link = fs::read_link(source) + .map_err(|e| format!("读取链接 {} 失败:{e}", source.display()))?; + create_symlink(&link, dest, source) + .map_err(|e| format!("复制链接 {} 失败:{e}", source.display()))?; + stats.0 += 1; + } else if file_type.is_dir() { + fs::create_dir_all(dest).map_err(|e| format!("创建目录 {} 失败:{e}", dest.display()))?; + let entries = + fs::read_dir(source).map_err(|e| format!("读取 {} 失败:{e}", source.display()))?; + for entry in entries { + let entry = entry.map_err(|e| format!("读取 {} 失败:{e}", source.display()))?; + copy_entry(&entry.path(), &dest.join(entry.file_name()), stats)?; + } + } else { + let copied = + fs::copy(source, dest).map_err(|e| format!("复制 {} 失败:{e}", source.display()))?; + // Verify against the bytes actually copied: files that are appended to + // concurrently (logs) may legitimately grow in the source meanwhile. + let written = fs::metadata(dest) + .map_err(|e| format!("校验 {} 失败:{e}", dest.display()))? + .len(); + if written != copied || copied < meta.len() { + return Err(format!("校验 {} 失败:大小不一致", dest.display())); + } + stats.0 += 1; + stats.1 += copied; + } + Ok(()) +} + +#[cfg(unix)] +fn create_symlink(link: &Path, dest: &Path, _source: &Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(link, dest) +} + +#[cfg(windows)] +fn create_symlink(link: &Path, dest: &Path, source: &Path) -> std::io::Result<()> { + if fs::metadata(source).map(|m| m.is_dir()).unwrap_or(false) { + std::os::windows::fs::symlink_dir(link, dest) + } else { + std::os::windows::fs::symlink_file(link, dest) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::data_home::read_pointer; + + fn seed(root: &Path) { + fs::create_dir_all(root.join("sessions/a")).unwrap(); + fs::write(root.join("sessions/a/session.jsonl"), b"{\"x\":1}\n").unwrap(); + fs::write(root.join("config.sqlite"), vec![7u8; 4096]).unwrap(); + fs::create_dir_all(root.join("extension")).unwrap(); + fs::write(root.join("extension/manifest.json"), b"{}").unwrap(); + fs::create_dir_all(root.join("worktree/repo")).unwrap(); + } + + #[test] + fn migration_copies_data_and_switches_pointer() { + let temp = tempfile::tempdir().unwrap(); + let default_root = temp.path().join(".liveagent"); + seed(&default_root); + let target = temp.path().join("data").join("liveagent"); + + validate_target(&default_root, &target, &default_root).unwrap(); + let (files, bytes) = migrate_sync(&default_root, &target, &default_root).unwrap(); + + assert_eq!(files, 2); + assert_eq!(bytes, 4096 + 8); + assert_eq!( + fs::read(target.join("sessions/a/session.jsonl")).unwrap(), + b"{\"x\":1}\n" + ); + assert!( + !target.join("extension").exists(), + "extension stays in the default root" + ); + assert!( + !target.join("worktree").exists(), + "worktrees stay in the default root" + ); + assert_eq!(read_pointer(&default_root), Some(target)); + assert!( + default_root.join("config.sqlite").exists(), + "old data is kept" + ); + } + + #[test] + fn moving_back_to_default_requires_cleared_default_root() { + let temp = tempfile::tempdir().unwrap(); + let default_root = temp.path().join(".liveagent"); + let custom = temp.path().join("custom"); + seed(&custom); + fs::create_dir_all(default_root.join("extension")).unwrap(); + write_pointer(&default_root, Some(&custom)).unwrap(); + + // Only pinned entries left in the default root: allowed. + validate_target(&custom, &default_root, &default_root).unwrap(); + migrate_sync(&custom, &default_root, &default_root).unwrap(); + assert_eq!(read_pointer(&default_root), None); + assert!(default_root.join("sessions/a/session.jsonl").exists()); + + // Stale data in the default root blocks the move. + let elsewhere = temp.path().join("elsewhere"); + seed(&elsewhere); + let error = validate_target(&elsewhere, &default_root, &default_root).unwrap_err(); + assert!(error.contains("不为空"), "{error}"); + } + + #[test] + fn rejects_nested_relative_and_non_empty_targets() { + let temp = tempfile::tempdir().unwrap(); + let source = temp.path().join(".liveagent"); + seed(&source); + let default_root = source.clone(); + + for target in [ + source.join("inner"), + temp.path().to_path_buf(), + PathBuf::from("relative"), + ] { + assert!( + validate_target(&source, &target, &default_root).is_err(), + "{}", + target.display() + ); + } + let busy = temp.path().join("busy"); + fs::create_dir_all(&busy).unwrap(); + fs::write(busy.join("file.txt"), b"x").unwrap(); + assert!(validate_target(&source, &busy, &default_root).is_err()); + + let finder_only = temp.path().join("finder"); + fs::create_dir_all(&finder_only).unwrap(); + fs::write(finder_only.join(".DS_Store"), b"x").unwrap(); + validate_target(&source, &finder_only, &default_root).unwrap(); + } + + #[test] + fn failed_copy_removes_partial_target() { + let temp = tempfile::tempdir().unwrap(); + let default_root = temp.path().join(".liveagent"); + seed(&default_root); + // A pointer that cannot be written (a directory in its place) fails the + // final step after the copy succeeded. + fs::create_dir_all(default_root.join(POINTER_FILE_NAME).join("blocker")).unwrap(); + let target = temp.path().join("target"); + + assert!(migrate_sync(&default_root, &target, &default_root).is_err()); + assert_eq!(fs::read_dir(&target).unwrap().count(), 0); + assert!(default_root.join("config.sqlite").exists()); + } +} diff --git a/crates/agent-gui/src-tauri/src/commands/app/mod.rs b/crates/agent-gui/src-tauri/src/commands/app/mod.rs index 2cb7a2e22..0ea265ec1 100644 --- a/crates/agent-gui/src-tauri/src/commands/app/mod.rs +++ b/crates/agent-gui/src-tauri/src/commands/app/mod.rs @@ -1,4 +1,5 @@ pub mod app; +pub mod data_dir; pub mod fonts; pub mod proxy_detect; pub mod system; diff --git a/crates/agent-gui/src-tauri/src/commands/config/settings/db.rs b/crates/agent-gui/src-tauri/src/commands/config/settings/db.rs index 6cb99ce64..d43a43756 100644 --- a/crates/agent-gui/src-tauri/src/commands/config/settings/db.rs +++ b/crates/agent-gui/src-tauri/src/commands/config/settings/db.rs @@ -15,8 +15,7 @@ pub(crate) fn config_dir() -> Result { fs::create_dir_all(&dir).map_err(|e| format!("创建开发配置目录失败:{e}"))?; return Ok(dir); } - let home = crate::runtime::data_home::home_dir().ok_or_else(|| "无法定位用户目录".to_string())?; - let dir = home.join(format!(".{}", env!("CARGO_PKG_NAME"))); + let dir = crate::runtime::data_home::data_root().ok_or_else(|| "无法定位用户目录".to_string())?; fs::create_dir_all(&dir).map_err(|e| format!("创建配置目录失败:{e}"))?; Ok(dir) } diff --git a/crates/agent-gui/src-tauri/src/commands/integration/browser.rs b/crates/agent-gui/src-tauri/src/commands/integration/browser.rs index 8b339826f..b0d7ac92a 100644 --- a/crates/agent-gui/src-tauri/src/commands/integration/browser.rs +++ b/crates/agent-gui/src-tauri/src/commands/integration/browser.rs @@ -51,7 +51,8 @@ pub struct BrowserExtensionInstallInfo { /// 或 .app 移动后即失效;固定到 .liveagent 下路径终身稳定,内容由每次启动 /// 的同步保持最新。 fn liveagent_extension_dir() -> Option { - crate::runtime::data_home::home_dir().map(|home| home.join(".liveagent").join("extension")) + // Chrome 记录的是绝对路径,自定义数据目录时也固定留在默认根目录。 + crate::runtime::data_home::default_data_root().map(|root| root.join("extension")) } /// 同步数据源:打包产物为 bundle resources 下的 browser-extension/ diff --git a/crates/agent-gui/src-tauri/src/commands/mod.rs b/crates/agent-gui/src-tauri/src/commands/mod.rs index 804f73e61..9c5c2d2af 100644 --- a/crates/agent-gui/src-tauri/src/commands/mod.rs +++ b/crates/agent-gui/src-tauri/src/commands/mod.rs @@ -16,6 +16,7 @@ pub mod runtime_commands; pub mod workspace_commands; pub use app_commands::app; +pub use app_commands::data_dir; pub use app_commands::fonts; pub use app_commands::proxy_detect; pub use app_commands::system; diff --git a/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs b/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs index 351cc2f61..6abc1ced4 100644 --- a/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs +++ b/crates/agent-gui/src-tauri/src/commands/workspace/checkpoint.rs @@ -150,9 +150,9 @@ fn sanitize_conversation_id(id: &str) -> Option { } fn checkpoints_root() -> Result { - let home = - crate::runtime::data_home::home_dir().ok_or_else(|| "Failed to locate the user home directory".to_string())?; - Ok(home.join(".liveagent").join("checkpoints")) + let root = crate::runtime::data_home::data_root() + .ok_or_else(|| "Failed to locate the user home directory".to_string())?; + Ok(root.join("checkpoints")) } fn conversation_dir(conversation_id: &str) -> Result { diff --git a/crates/agent-gui/src-tauri/src/lib.rs b/crates/agent-gui/src-tauri/src/lib.rs index 8c1be5c45..683fab646 100644 --- a/crates/agent-gui/src-tauri/src/lib.rs +++ b/crates/agent-gui/src-tauri/src/lib.rs @@ -193,6 +193,8 @@ macro_rules! app_invoke_handler { commands::update::app_release_announcement, commands::update::app_release_announcement_preview, commands::update::app_restart, + commands::data_dir::data_dir_status, + commands::data_dir::data_dir_migrate, commands::app::app_runtime_platform, commands::app::app_frontend_ready, commands::app::app_set_close_window_behavior, diff --git a/crates/agent-gui/src-tauri/src/runtime/data_home.rs b/crates/agent-gui/src-tauri/src/runtime/data_home.rs index 1ac3d2bbe..0c4b7d5a0 100644 --- a/crates/agent-gui/src-tauri/src/runtime/data_home.rs +++ b/crates/agent-gui/src-tauri/src/runtime/data_home.rs @@ -1,8 +1,16 @@ //! Explicit developer home for isolated native acceptance. Normal installations //! keep the OS home. An invalid explicit override fails closed instead of using //! personal data; release binaries do not accept the developer override. +//! +//! The data root (`~/.liveagent` by default) can be relocated from settings: +//! the chosen directory is recorded in a pointer file inside the default root +//! and resolved once per process, so a relocation takes effect after restart. use std::ffi::OsString; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +pub(crate) const DATA_DIR_NAME: &str = ".liveagent"; +pub(crate) const POINTER_FILE_NAME: &str = "data-dir.json"; fn select_home( override_home: Option, @@ -29,10 +37,107 @@ pub(crate) fn home_dir() -> Option { select_home(explicit, dirs::home_dir) } +/// Default data root: `/.liveagent`. Holds the relocation pointer and the +/// entries that never move (browser extension, git worktrees). +pub(crate) fn default_data_root() -> Option { + home_dir().map(|home| home.join(DATA_DIR_NAME)) +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct DataDirPointer { + path: PathBuf, +} + +/// Reads the relocation pointer stored in `default_root`. Missing, unreadable or +/// non-absolute pointers resolve to `None` (the default root stays in use). +pub(crate) fn read_pointer(default_root: &Path) -> Option { + let raw = std::fs::read(default_root.join(POINTER_FILE_NAME)).ok()?; + let pointer = match serde_json::from_slice::(&raw) { + Ok(pointer) => pointer, + Err(error) => { + eprintln!("ignoring invalid {POINTER_FILE_NAME}: {error}"); + return None; + } + }; + (pointer.path.is_absolute() && pointer.path != default_root).then_some(pointer.path) +} + +/// Records `target` as the data root; `None` (or the default root itself) +/// removes the pointer. Written via a temp file + rename so a crash never +/// leaves a truncated pointer behind. +pub(crate) fn write_pointer(default_root: &Path, target: Option<&Path>) -> std::io::Result<()> { + let path = default_root.join(POINTER_FILE_NAME); + match target.filter(|target| *target != default_root) { + None => match std::fs::remove_file(&path) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => Err(error), + _ => Ok(()), + }, + Some(target) => { + std::fs::create_dir_all(default_root)?; + let body = serde_json::to_vec_pretty(&DataDirPointer { + path: target.to_path_buf(), + }) + .map_err(std::io::Error::other)?; + let tmp = default_root.join(format!("{POINTER_FILE_NAME}.tmp")); + std::fs::write(&tmp, body)?; + std::fs::rename(&tmp, &path) + } + } +} + +static DATA_ROOT: OnceLock> = OnceLock::new(); + +/// Effective data root for this process: the relocation pointer when present, +/// otherwise `/.liveagent`. Cached so every subsystem agrees on one +/// location until the app restarts. Environment overrides +/// (`LIVEAGENT_CONFIG_DIR`, `LIVEAGENT_HOME`, `K_BRAIN_HOME`) are applied by +/// the individual callers and still take priority. +pub(crate) fn data_root() -> Option { + DATA_ROOT + .get_or_init(|| { + let default_root = default_data_root()?; + Some(read_pointer(&default_root).unwrap_or(default_root)) + }) + .clone() +} + +/// Environment variables that pin the data location and therefore disable +/// relocation from settings. +pub(crate) fn data_root_env_override() -> Option<&'static str> { + ["LIVEAGENT_CONFIG_DIR", "LIVEAGENT_HOME", "K_BRAIN_HOME"] + .into_iter() + .find(|name| std::env::var_os(name).is_some_and(|value| !value.is_empty())) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn pointer_round_trips_and_default_target_removes_it() { + let temp = tempfile::tempdir().unwrap(); + let default_root = temp.path().join(".liveagent"); + assert_eq!(read_pointer(&default_root), None); + + let target = temp.path().join("elsewhere"); + write_pointer(&default_root, Some(&target)).unwrap(); + assert_eq!(read_pointer(&default_root), Some(target)); + + write_pointer(&default_root, Some(&default_root)).unwrap(); + assert!(!default_root.join(POINTER_FILE_NAME).exists()); + write_pointer(&default_root, None).unwrap(); + } + + #[test] + fn invalid_pointer_keeps_default_root() { + let temp = tempfile::tempdir().unwrap(); + let default_root = temp.path().to_path_buf(); + for body in ["not json", r#"{"path":"relative/dir"}"#] { + std::fs::write(default_root.join(POINTER_FILE_NAME), body).unwrap(); + assert_eq!(read_pointer(&default_root), None); + } + } + #[test] fn isolated_home_never_resolves_personal_home() { let directory = std::env::temp_dir().join("liveagent-isolated-home-test"); diff --git a/crates/agent-gui/src-tauri/src/runtime/sandbox.rs b/crates/agent-gui/src-tauri/src/runtime/sandbox.rs index fd8de9196..f8fc8f983 100644 --- a/crates/agent-gui/src-tauri/src/runtime/sandbox.rs +++ b/crates/agent-gui/src-tauri/src/runtime/sandbox.rs @@ -421,7 +421,7 @@ fn sensitive_home_subdirs() -> [&'static str; 4] { } fn app_config_dir() -> Option { - crate::runtime::data_home::home_dir().map(|home| home.join(format!(".{}", env!("CARGO_PKG_NAME")))) + crate::runtime::data_home::data_root() } fn sensitive_dirs() -> Vec { diff --git a/crates/agent-gui/src-tauri/src/services/browser/launcher.rs b/crates/agent-gui/src-tauri/src/services/browser/launcher.rs index 790264c23..a6d447b61 100644 --- a/crates/agent-gui/src-tauri/src/services/browser/launcher.rs +++ b/crates/agent-gui/src-tauri/src/services/browser/launcher.rs @@ -11,8 +11,8 @@ use crate::runtime::process::{ /// 浏览器自动化专用 profile,与用户日常浏览器 profile 隔离(防登录态/凭据暴露)。 /// 见 docs/design/browser-automation.md。 pub(crate) fn automation_profile_dir() -> Result { - let home = crate::runtime::data_home::home_dir().ok_or_else(|| "无法定位用户主目录".to_string())?; - Ok(home.join(".liveagent").join("browser-profile")) + let root = crate::runtime::data_home::data_root().ok_or_else(|| "无法定位用户主目录".to_string())?; + Ok(root.join("browser-profile")) } /// 按平台探测已安装的 Chromium 系浏览器:Chrome → Edge → Chromium/Brave。 diff --git a/crates/agent-gui/src-tauri/src/services/kbrain_backend.rs b/crates/agent-gui/src-tauri/src/services/kbrain_backend.rs index 9f392d4b5..8fa137b65 100644 --- a/crates/agent-gui/src-tauri/src/services/kbrain_backend.rs +++ b/crates/agent-gui/src-tauri/src/services/kbrain_backend.rs @@ -299,8 +299,8 @@ impl KBrainBackendState { .app_data_dir() .map_err(|error| format!("failed to resolve LiveAgent app data directory: {error}"))?; let binary = resolve_binary()?; - let home = - crate::runtime::data_home::home_dir().ok_or_else(|| "failed to resolve LiveAgent home".to_string())?; + let data_root = crate::runtime::data_home::data_root() + .ok_or_else(|| "failed to resolve LiveAgent home".to_string())?; let preferred = std::env::var_os("LIVEAGENT_HOME") .filter(|v| !v.is_empty()) .map(PathBuf::from); @@ -310,7 +310,7 @@ impl KBrainBackendState { let overridden = preferred.is_some() || legacy_override.is_some(); let root = preferred .or(legacy_override) - .unwrap_or_else(|| home.join(".liveagent")); + .unwrap_or(data_root); let legacy = (!overridden).then(|| data_dir.join(BACKEND_DIR)); self.ensure_started_with_paths(binary, root, legacy) } diff --git a/crates/agent-gui/src-tauri/src/services/mcp_oauth/store.rs b/crates/agent-gui/src-tauri/src/services/mcp_oauth/store.rs index d771cd53c..3e0acd910 100644 --- a/crates/agent-gui/src-tauri/src/services/mcp_oauth/store.rs +++ b/crates/agent-gui/src-tauri/src/services/mcp_oauth/store.rs @@ -116,8 +116,7 @@ fn keyring_load(server_id: &str) -> Result, String> { // ---- 文件降级后端 ---- fn file_store_path() -> Result { - let home = crate::runtime::data_home::home_dir().ok_or_else(|| "无法定位用户目录".to_string())?; - let dir = home.join(format!(".{}", env!("CARGO_PKG_NAME"))); + let dir = crate::runtime::data_home::data_root().ok_or_else(|| "无法定位用户目录".to_string())?; fs::create_dir_all(&dir).map_err(|e| format!("创建配置目录失败:{e}"))?; Ok(dir.join(FILE_STORE_NAME)) } diff --git a/crates/agent-gui/src-tauri/src/services/memory/mod.rs b/crates/agent-gui/src-tauri/src/services/memory/mod.rs index 32be57a12..5ca7fa03f 100644 --- a/crates/agent-gui/src-tauri/src/services/memory/mod.rs +++ b/crates/agent-gui/src-tauri/src/services/memory/mod.rs @@ -15,7 +15,6 @@ use serde_json::{json, Value}; use sha2::{Digest, Sha256}; use uuid::Uuid; -const MEMORY_DIR_NAME: &str = ".liveagent"; const MEMORY_ROOT_DIR: &str = "memory"; const DB_FILENAME: &str = "memory-index.sqlite3"; const MAX_BODY_BYTES: usize = 8 * 1024; diff --git a/crates/agent-gui/src-tauri/src/services/memory/paths.rs b/crates/agent-gui/src-tauri/src/services/memory/paths.rs index 053d4e925..bd7b0544e 100644 --- a/crates/agent-gui/src-tauri/src/services/memory/paths.rs +++ b/crates/agent-gui/src-tauri/src/services/memory/paths.rs @@ -229,8 +229,8 @@ fn memory_root_dir() -> Result { if std::env::var_os("LIVEAGENT_CONFIG_DIR").is_some() { return Ok(crate::commands::settings::config_dir()?.join(MEMORY_ROOT_DIR)); } - let home = crate::runtime::data_home::home_dir().ok_or_else(|| "无法定位用户目录".to_string())?; - Ok(home.join(MEMORY_DIR_NAME).join(MEMORY_ROOT_DIR)) + let root = crate::runtime::data_home::data_root().ok_or_else(|| "无法定位用户目录".to_string())?; + Ok(root.join(MEMORY_ROOT_DIR)) } fn ensure_root_dirs(root: &Path) -> Result<(), String> { diff --git a/crates/agent-gui/src/agent-ui-adapters/settingsExtension.tsx b/crates/agent-gui/src/agent-ui-adapters/settingsExtension.tsx index 07db452eb..ff5dd737a 100644 --- a/crates/agent-gui/src/agent-ui-adapters/settingsExtension.tsx +++ b/crates/agent-gui/src/agent-ui-adapters/settingsExtension.tsx @@ -1,8 +1,9 @@ -import { Archive, Info, Keyboard } from "@liveagent/ui/components/IconSet"; +import { Archive, HardDrive, Info, Keyboard } from "@liveagent/ui/components/IconSet"; import type { SettingsSectionDefinition, UiExtensionSlots } from "@liveagent/ui/contracts/registry"; import { isMacOsTauri, MacOsTitleBarSpacer } from "../components/MacOsTitleBarSpacer"; import { AboutSection } from "../pages/settings/AboutSection"; import { BackupSyncSection } from "../pages/settings/BackupSyncSection"; +import { DataDirectorySection } from "../pages/settings/DataDirectorySection"; import { GlobalShortcutsSection } from "../pages/settings/GlobalShortcutsSection"; import type { SettingsPageProps } from "../pages/settings/types"; @@ -47,6 +48,15 @@ export function createSettingsExtension(props: SettingsPageProps): { /> ), }, + { + id: "data-dir", + groupKey: "settings.groupOther", + groupOrder: 50, + order: 17, + labelKey: "settings.navDataDir", + icon: , + render: () => , + }, { id: "about", groupKey: "settings.groupOther", diff --git a/crates/agent-gui/src/i18n/config.ts b/crates/agent-gui/src/i18n/config.ts index a5b6574d2..281fab05f 100644 --- a/crates/agent-gui/src/i18n/config.ts +++ b/crates/agent-gui/src/i18n/config.ts @@ -177,6 +177,22 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "settings.globalShortcuts": "快捷键", "settings.navShortcuts": "快捷键", "settings.navBackup": "备份与同步", + "settings.navDataDir": "数据目录", + "settings.dataDirDesc": + "LiveAgent 的会话、配置、记忆、技能和上传文件都保存在这里。更改后会把现有数据完整复制到新位置,校验通过后重启生效。", + "settings.dataDirCurrent": "当前位置", + "settings.dataDirDefaultHint": "使用默认位置。", + "settings.dataDirCustomHint": "已自定义;默认位置为 {path}。", + "settings.dataDirChange": "更改位置…", + "settings.dataDirRestoreDefault": "恢复默认", + "settings.dataDirConfirmTitle": "迁移数据目录", + "settings.dataDirConfirmDesc": + "将暂停后台服务,把全部数据复制到上面的目录(会在所选位置下创建 LiveAgent 文件夹,目标须为空),校验通过后自动重启。旧目录原样保留,确认无误后可手动删除。迁移失败会继续使用当前目录。", + "settings.dataDirConfirmAction": "开始迁移", + "settings.dataDirMigrating": "正在迁移到 {path},数据较多时需要几分钟,请勿关闭应用…", + "settings.dataDirDone": "迁移完成,正在重启。旧数据仍保留在 {path}。", + "settings.dataDirFailed": "迁移失败,仍在使用当前目录:{message}", + "settings.dataDirEnvLocked": "数据目录由环境变量 {name} 指定,需移除该变量后才能在这里修改。", "settings.backupLocalTitle": "本地备份", "settings.backupExport": "导出配置", "settings.backupExportHint": "保存为单个 JSON 文件", @@ -541,6 +557,24 @@ export const GUI_TRANSLATION_OVERRIDES: Record> = "settings.globalShortcuts": "Keyboard Shortcuts", "settings.navShortcuts": "Shortcuts", "settings.navBackup": "Backup & Sync", + "settings.navDataDir": "Data Directory", + "settings.dataDirDesc": + "LiveAgent stores sessions, settings, memory, skills and uploads here. Changing it copies all existing data to the new location and restarts once the copy is verified.", + "settings.dataDirCurrent": "Current location", + "settings.dataDirDefaultHint": "Using the default location.", + "settings.dataDirCustomHint": "Customized; the default location is {path}.", + "settings.dataDirChange": "Change…", + "settings.dataDirRestoreDefault": "Restore default", + "settings.dataDirConfirmTitle": "Move data directory", + "settings.dataDirConfirmDesc": + "Background services pause while all data is copied to the directory above (a LiveAgent folder is created in the location you picked; it must be empty). LiveAgent restarts after verification. The old directory is kept as is; delete it manually once everything looks right. If the move fails, the current directory stays in use.", + "settings.dataDirConfirmAction": "Move", + "settings.dataDirMigrating": + "Moving data to {path}. This can take a few minutes; keep LiveAgent open…", + "settings.dataDirDone": "Move complete, restarting. The old data remains at {path}.", + "settings.dataDirFailed": "Move failed; still using the current directory: {message}", + "settings.dataDirEnvLocked": + "The data directory is set by the {name} environment variable. Remove it to change the location here.", "settings.backupLocalTitle": "Local backup", "settings.backupExport": "Export config", "settings.backupExportHint": "Save as a single JSON file", diff --git a/crates/agent-gui/src/lib/system/dataDir.ts b/crates/agent-gui/src/lib/system/dataDir.ts new file mode 100644 index 000000000..980cc8238 --- /dev/null +++ b/crates/agent-gui/src/lib/system/dataDir.ts @@ -0,0 +1,39 @@ +import { invoke } from "@liveagent/app/shims/tauriCore"; + +export type DataDirStatus = { + current: string; + default: string; + custom: boolean; + /** Environment variable that pins the data location; relocation is disabled while set. */ + envOverride: string | null; +}; + +export type DataDirMigrateResult = { + previous: string; + target: string; + files: number; + bytes: number; +}; + +/** Folder created inside the directory the user picks, so picking a busy drive root still works. */ +export const DATA_DIR_FOLDER_NAME = "LiveAgent"; + +export function dataDirTargetFor(picked: string, defaultRoot: string): string { + const separator = defaultRoot.includes("\\") ? "\\" : "/"; + const trimmed = picked.replace(/[\\/]+$/, ""); + const last = trimmed.split(/[\\/]/).pop() ?? ""; + if (last === DATA_DIR_FOLDER_NAME) return trimmed; + return `${trimmed}${separator}${DATA_DIR_FOLDER_NAME}`; +} + +export function loadDataDirStatus(): Promise { + return invoke("data_dir_status"); +} + +export function migrateDataDir(target: string): Promise { + return invoke("data_dir_migrate", { target }); +} + +export function restartApp(): Promise { + return invoke("app_restart"); +} diff --git a/crates/agent-gui/src/pages/settings/DataDirectorySection.tsx b/crates/agent-gui/src/pages/settings/DataDirectorySection.tsx new file mode 100644 index 000000000..ff1ccbdf7 --- /dev/null +++ b/crates/agent-gui/src/pages/settings/DataDirectorySection.tsx @@ -0,0 +1,147 @@ +import { + SettingsCard, + SettingsRow, + SettingsSection, +} from "@liveagent/ui/components/settings/SettingsLayout"; +import { Button } from "@liveagent/ui/components/ui/button"; +import { useConfirmDialog } from "@liveagent/ui/components/ui/confirm-dialog"; +import { useLocale } from "@liveagent/ui/i18n/index"; +import { useCallback, useEffect, useState } from "react"; +import { useDirectoryPicker } from "../../agent-ui-adapters/directoryPicker"; +import { + type DataDirStatus, + dataDirTargetFor, + loadDataDirStatus, + migrateDataDir, + restartApp, +} from "../../lib/system/dataDir"; + +type Phase = + | { kind: "idle" } + | { kind: "migrating"; target: string } + | { kind: "error"; message: string } + | { kind: "done"; previous: string }; + +function errorText(reason: unknown): string { + return reason instanceof Error ? reason.message : String(reason); +} + +/** Relocates the whole LiveAgent data root (desktop config + K-brain sessions). */ +export function DataDirectorySection() { + const { t } = useLocale(); + const { confirm, dialog } = useConfirmDialog(); + const { pickDirectory } = useDirectoryPicker(); + const [status, setStatus] = useState(null); + const [phase, setPhase] = useState({ kind: "idle" }); + + useEffect(() => { + let cancelled = false; + loadDataDirStatus() + .then((next) => { + if (!cancelled) setStatus(next); + }) + .catch((reason: unknown) => { + if (!cancelled) setPhase({ kind: "error", message: errorText(reason) }); + }); + return () => { + cancelled = true; + }; + }, []); + + const migrate = useCallback( + async (target: string) => { + if (!status) return; + const confirmed = await confirm({ + title: t("settings.dataDirConfirmTitle"), + subtitle: target, + description: t("settings.dataDirConfirmDesc"), + confirmLabel: t("settings.dataDirConfirmAction"), + cancelLabel: t("settings.backupCancel"), + }); + if (!confirmed) return; + setPhase({ kind: "migrating", target }); + try { + const result = await migrateDataDir(target); + setPhase({ kind: "done", previous: result.previous }); + await restartApp(); + } catch (reason) { + setPhase({ kind: "error", message: errorText(reason) }); + } + }, + [confirm, status, t], + ); + + const choose = useCallback(async () => { + if (!status) return; + const picked = await pickDirectory(status.current); + if (!picked) return; + await migrate(dataDirTargetFor(picked, status.default)); + }, [migrate, pickDirectory, status]); + + const busy = phase.kind === "migrating" || phase.kind === "done"; + const locked = Boolean(status?.envOverride); + + return ( +
+ + + + + {status?.current ?? "…"} + +
+ {status?.custom ? ( + + ) : null} + +
+
+ } + /> + + {locked ? ( +

+ {t("settings.dataDirEnvLocked").replace("{name}", status?.envOverride ?? "")} +

+ ) : null} + {phase.kind === "migrating" ? ( +

+ {t("settings.dataDirMigrating").replace("{path}", phase.target)} +

+ ) : null} + {phase.kind === "done" ? ( +

+ {t("settings.dataDirDone").replace("{path}", phase.previous)} +

+ ) : null} + {phase.kind === "error" ? ( +

+ {t("settings.dataDirFailed").replace("{message}", phase.message)} +

+ ) : null} + + {dialog} + + ); +} diff --git a/crates/agent-gui/test/settings/data-dir.test.mjs b/crates/agent-gui/test/settings/data-dir.test.mjs new file mode 100644 index 000000000..ec199d163 --- /dev/null +++ b/crates/agent-gui/test/settings/data-dir.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +const loader = createTsModuleLoader(); +const { dataDirTargetFor } = loader.loadModule("src/lib/system/dataDir.ts"); + +test("data dir target creates a LiveAgent folder inside the picked directory", () => { + assert.equal(dataDirTargetFor("/Volumes/Data", "/Users/a/.liveagent"), "/Volumes/Data/LiveAgent"); + assert.equal(dataDirTargetFor("D:\\", "C:\\Users\\a\\.liveagent"), "D:\\LiveAgent"); + assert.equal(dataDirTargetFor("D:\\Apps\\", "C:\\Users\\a\\.liveagent"), "D:\\Apps\\LiveAgent"); +}); + +test("picking an existing LiveAgent folder uses it directly", () => { + assert.equal( + dataDirTargetFor("/Volumes/Data/LiveAgent/", "/Users/a/.liveagent"), + "/Volumes/Data/LiveAgent", + ); +});