diff --git a/agentmain.py b/agentmain.py index 6c5746bd9..6510cae81 100644 --- a/agentmain.py +++ b/agentmain.py @@ -12,6 +12,7 @@ from plugins.hooks import discover_and_load; discover_and_load() except Exception: pass from ga import GenericAgentHandler, smart_format, get_global_memory, format_error, consume_file +import paths script_dir = os.path.dirname(os.path.abspath(__file__)) BANNED_TOOLS = (['ask_user', 'start_long_term_update'] if '--no-user-tools' in sys.argv else []) @@ -23,7 +24,7 @@ def load_tool_schema(suffix=''): load_tool_schema() lang_suffix = '_en' if os.environ.get('GA_LANG', '') == 'en' else '' -mem_dir = os.path.join(script_dir, 'memory') +mem_dir = paths.memory_dir() if not os.path.exists(mem_dir): os.makedirs(mem_dir) mem_txt = os.path.join(mem_dir, 'global_mem.txt') if not os.path.exists(mem_txt): open(mem_txt, 'w', encoding='utf-8').write('# [Global Memory - L2]\n') @@ -34,6 +35,16 @@ def load_tool_schema(suffix=''): def get_system_prompt(): with open(os.path.join(script_dir, f'assets/sys_prompt{lang_suffix}.txt'), 'r', encoding='utf-8') as f: prompt = f.read() + # P1-A: layer an optional user override from the writable data root on top of the + # bundled baseline. Kept as an append (not a replace) so the agent never loses its + # core operating contract; users add house rules without editing the read-only bundle. + try: + ov = paths.sys_prompt_override(lang_suffix) + if os.path.isfile(ov): + with open(ov, 'r', encoding='utf-8') as f: + extra = f.read().strip() + if extra: prompt += f"\n\n# ── User overrides (sys_prompt_override) ──\n{extra}\n" + except Exception: pass prompt += f"\nToday: {time.strftime('%Y-%m-%d %a')}\n" prompt += get_global_memory() return prompt @@ -44,7 +55,7 @@ def get_system_prompt(): # output2_queue = agent.put_task(prompt2) class GenericAgent: def __init__(self): - os.makedirs(os.path.join(script_dir, 'temp'), exist_ok=True) + os.makedirs(paths.temp_dir(), exist_ok=True) self.lock = threading.Lock() self.task_dir = None self.history = []; self.handler = None; @@ -54,7 +65,7 @@ def __init__(self): self.peer_hint = True self.force_non_stream = False logid = f'{(time.time_ns() + random.randrange(1_000_000)) % 1_000_000:06d}' - self.log_path = os.path.join(script_dir, f'temp/model_responses/model_responses_{logid}.txt') + self.log_path = paths.temp_dir('model_responses', f'model_responses_{logid}.txt') self.llmclient = None self.load_llm_sessions() self.extra_sys_prompts = [] @@ -122,7 +133,7 @@ def _handle_slash_cmd(self, raw_query, display_queue): if not raw_query.startswith('/'): return raw_query if _sm := re.match(r'/session\.(\w+)=(.*)', raw_query.strip()): k, v = _sm.group(1), _sm.group(2) - vfile = os.path.join(script_dir, 'temp', v) + vfile = paths.temp_dir(v) if os.path.isfile(vfile): v = open(vfile, encoding='utf-8').read().strip() try: v = json.loads(v) # cover number parsing except (json.JSONDecodeError, ValueError): pass @@ -143,14 +154,14 @@ def run(self): self.task_queue.task_done(); continue self.is_running = True if len(raw_query) > 2000: - task_file = os.path.join(script_dir, 'temp', f'user_prompt_{os.getpid()}_{time.time_ns()}.md') + task_file = paths.temp_dir(f'user_prompt_{os.getpid()}_{time.time_ns()}.md') with open(task_file, 'w', encoding='utf-8') as f: f.write(raw_query) raw_query = f'Long user prompt saved to {task_file}. Read and execute.' rquery = smart_format(raw_query.replace('\n', ' '), max_str_len=200) self.history.append(f"[USER]: {rquery}") sys_prompt = get_system_prompt() + '\n'.join(self.extra_sys_prompts) + getattr(self.llmclient.backend, 'extra_sys_prompt', '') if self.peer_hint: sys_prompt += f"\n[Peer] 用户提及其他会话/后台任务状态时: temp/model_responses/ (只找近期修改的文件尾部)\n" - handler = GenericAgentHandler(self, self.history, os.path.join(script_dir, 'temp')) + handler = GenericAgentHandler(self, self.history, paths.temp_dir()) if getattr(self, 'no_print', False): handler.print = lambda *a, **k: None if self.handler and 'key_info' in self.handler.working: ki = re.sub(r'\n\[SYSTEM\] 此为.*?工作记忆[。\n]*', '', self.handler.working['key_info']) # 去旧 @@ -213,7 +224,7 @@ def run(self): import subprocess, platform cmd = [sys.executable, os.path.abspath(__file__)] + [a for a in sys.argv[1:]] + ['--nobg'] if args.task: - d = os.path.join(script_dir, f'temp/{args.task}'); os.makedirs(d, exist_ok=True) + d = paths.temp_dir(args.task); os.makedirs(d, exist_ok=True) out = open(os.path.join(d, 'stdout.log'), 'w', encoding='utf-8') err = open(os.path.join(d, 'stderr.log'), 'w', encoding='utf-8') else: out, err = subprocess.DEVNULL, subprocess.DEVNULL @@ -230,7 +241,7 @@ def run(self): histfile = args.history if args.task: - agent.task_dir = d = os.path.join(script_dir, f'temp/{args.task}'); nround = '' + agent.task_dir = d = paths.temp_dir(args.task); nround = '' infile = os.path.join(d, 'input.txt'); outfile = f'{d}/output{nround}.txt' if args.input: os.makedirs(d, exist_ok=True) @@ -288,7 +299,7 @@ def run(self): except Exception as e: if getattr(mod, 'ONCE', False): raise print(f'[Reflect] drain error: {e}'); result = f'[ERROR] {e}' - log_dir = os.path.join(script_dir, 'temp/reflect_logs'); os.makedirs(log_dir, exist_ok=True) + log_dir = paths.temp_dir('reflect_logs'); os.makedirs(log_dir, exist_ok=True) script_name = os.path.splitext(os.path.basename(args.reflect))[0] open(os.path.join(log_dir, f'{script_name}_{datetime.now():%Y-%m-%d}.log'), 'a', encoding='utf-8').write(f'[{datetime.now():%m-%d %H:%M}]\n{result}\n\n') if (on_done := getattr(mod, 'on_done', None)): diff --git a/assets/configure_mykey.py b/assets/configure_mykey.py index 755fae8fd..9aaaf3f69 100644 --- a/assets/configure_mykey.py +++ b/assets/configure_mykey.py @@ -24,7 +24,18 @@ } PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -MYKPY_PATH = os.path.join(PROJECT_ROOT, 'mykey.py') +# P1-D: write credentials to the user-writable data root (packaged bundle is read-only). +# Falls back to the project root for plain source checkouts where paths is unavailable +# or DATA_DIR == PROJECT_ROOT anyway. +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) +try: + import paths as _paths + MYKPY_PATH = _paths.mykey_path() + _BACKUP_DIR = _paths.DATA_DIR +except Exception: + MYKPY_PATH = os.path.join(PROJECT_ROOT, 'mykey.py') + _BACKUP_DIR = PROJECT_ROOT # ── 模型厂商定义 ─────────────────────────────────────────────────────────── @@ -1246,7 +1257,7 @@ def _backup_with_name(model_names, platform_ids): safe_name = 'mykey_backup' # 避免和源文件同名 if len(safe_name) > 100: safe_name = safe_name[:100] - backup_path = os.path.join(PROJECT_ROOT, f'{safe_name}.py') + backup_path = os.path.join(_BACKUP_DIR, f'{safe_name}.py') shutil.copy2(MYKPY_PATH, backup_path) return backup_path @@ -1371,6 +1382,7 @@ def main(): print(f"\n {C['green']}✓ 旧配置已备份至:{C['reset']} {C['dim']}{backup}{C['reset']}") # 写入 + os.makedirs(os.path.dirname(MYKPY_PATH), exist_ok=True) with open(MYKPY_PATH, 'w', encoding='utf-8') as f: f.write(content) print(f"\n {C['green']}✓ mykey.py 已生成!{C['reset']}") diff --git a/assets/ga_ultraplan.py b/assets/ga_ultraplan.py index 653cfbc7e..9c5b1f76d 100644 --- a/assets/ga_ultraplan.py +++ b/assets/ga_ultraplan.py @@ -7,10 +7,12 @@ __all__ = ["plan", "phase", "parallel", "mapchain"] _ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _ROOT not in sys.path: sys.path.insert(0, _ROOT) +import paths _PORT = int(os.environ.get("GA_ULTRAPLAN_PORT", "47831")) _T0 = time(); _phases = []; _phase_stack = []; _tasks = []; _current = "idle"; _events = []; _srv = None; _last = time(); _lock = threading.Lock(); _exec_lock = threading.Lock() _TASK_SLUG = "task"; _FUNC_SEQ = 0; _PLANNED = False; _SESSION = None; _sessions = {} -_RUN_DIR = os.path.abspath(os.environ.get("GA_ULTRAPLAN_RUNDIR", os.path.join(_ROOT, "temp", "ultraplan_default"))) +_RUN_DIR = os.path.abspath(os.environ.get("GA_ULTRAPLAN_RUNDIR", paths.temp_dir("ultraplan_default"))) os.makedirs(_RUN_DIR, exist_ok=True) def _bind(rundir): diff --git a/frontends/cost_tracker.py b/frontends/cost_tracker.py index 744258982..b9e99e92b 100644 --- a/frontends/cost_tracker.py +++ b/frontends/cost_tracker.py @@ -9,9 +9,13 @@ same `[Cache]` / `[Output]` print lines from `temp/*/stdout.log`. """ from __future__ import annotations -import glob, os, re, threading, time +import glob, os, re, sys, threading, time from dataclasses import dataclass, field +_repo = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _repo not in sys.path: sys.path.insert(0, _repo) +import paths + @dataclass class TokenStats: @@ -76,7 +80,7 @@ def scan_subagent_logs(since: float = 0.0, root: str | None = None) -> TokenStat `since=tui_start_time` to scope to this run. Best-effort: bad logs skipped.""" out = TokenStats() if since > 0: out.started_at = since - pattern = os.path.join(root, _SUBAGENT_GLOB) if root else _SUBAGENT_GLOB + pattern = os.path.join(root, _SUBAGENT_GLOB) if root else os.path.join(paths.temp_dir(), "*", "stdout.log") for p in glob.glob(pattern): try: if since and os.path.getmtime(p) < since: continue diff --git a/frontends/desktop/src-tauri/capabilities/default.json b/frontends/desktop/src-tauri/capabilities/default.json index aa9ad1e2d..6f91171cf 100644 --- a/frontends/desktop/src-tauri/capabilities/default.json +++ b/frontends/desktop/src-tauri/capabilities/default.json @@ -23,6 +23,10 @@ "allow-set-ga-source", "allow-clear-ga-source", "allow-shortcut-should-ask", - "allow-shortcut-decide" + "allow-shortcut-decide", + "allow-get-data-dir-override", + "allow-set-data-dir", + "allow-clear-data-dir", + "allow-move-data-dir" ] } diff --git a/frontends/desktop/src-tauri/permissions/bridge-commands.toml b/frontends/desktop/src-tauri/permissions/bridge-commands.toml index 313f49be9..99acd5a67 100644 --- a/frontends/desktop/src-tauri/permissions/bridge-commands.toml +++ b/frontends/desktop/src-tauri/permissions/bridge-commands.toml @@ -47,3 +47,23 @@ 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-get-data-dir-override" +description = "Read the user-selected GenericAgent data directory override." +commands.allow = ["get_data_dir_override"] + +[[permission]] +identifier = "allow-set-data-dir" +description = "Set the GenericAgent data directory override and restart the bridge." +commands.allow = ["set_data_dir"] + +[[permission]] +identifier = "allow-clear-data-dir" +description = "Clear the GenericAgent data directory override and restart the bridge." +commands.allow = ["clear_data_dir"] + +[[permission]] +identifier = "allow-move-data-dir" +description = "Move current data directory under a selected parent, delete the old directory, set it, and restart the bridge." +commands.allow = ["move_data_dir"] diff --git a/frontends/desktop/src-tauri/src/lib.rs b/frontends/desktop/src-tauri/src/lib.rs index 56b289cde..a163b376d 100644 --- a/frontends/desktop/src-tauri/src/lib.rs +++ b/frontends/desktop/src-tauri/src/lib.rs @@ -196,6 +196,47 @@ fn write_shortcut_pref(enabled: bool) { merge_settings(serde_json::json!({ "desktop_shortcut": enabled })); } +/// User-selected data directory override. Empty means the Python paths.py default +/// (~/GenericAgent_Data, or in-place for source/dev checkouts). +fn data_dir_override() -> Option { + let s = read_settings() + .get("data_dir_override") + .and_then(|v| v.as_str()) + .unwrap_or("") + .trim() + .to_string(); + if s.is_empty() { None } else { Some(s) } +} + +fn set_data_dir_override_value(dir: &str) -> Result { + let p = PathBuf::from(dir); + std::fs::create_dir_all(&p).map_err(|e| format!("cannot create data dir: {}", e))?; + let canon = p.canonicalize().unwrap_or(p); + merge_settings(serde_json::json!({ "data_dir_override": canon.to_string_lossy().to_string() })); + Ok(canon.to_string_lossy().to_string()) +} + +fn copy_dir_replace(src: &std::path::Path, dst: &std::path::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(()) +} /// Create (or overwrite) a desktop shortcut pointing at the CURRENT exe. Overwriting on every /// enabled launch is what makes the portable bundle relocatable: move the folder, relaunch, and /// the shortcut is rewritten to the new path. Windows-only (uses a .lnk via WScript.Shell). @@ -480,6 +521,10 @@ fn sanitize_bundle_env(cmd: &mut Command) { Some(src) => { cmd.env("GA_ROOT", src); } None => { cmd.env_remove("GA_ROOT"); } } + match data_dir_override() { + Some(dir) => { cmd.env("GA_DATA_DIR", dir); } + None => { cmd.env_remove("GA_DATA_DIR"); } + } } /// Run the offline prepare (install_windows.ps1 -Mode PrepareOnly) using bundled python + wheels. @@ -876,6 +921,64 @@ fn get_ga_source() -> String { .to_string() } +#[tauri::command] +fn get_data_dir_override() -> String { + data_dir_override().unwrap_or_default() +} + +#[tauri::command] +fn set_data_dir(app_handle: tauri::AppHandle, dir: String) -> Result { + let saved = set_data_dir_override_value(&dir)?; + switch_bridge(&app_handle)?; + Ok(saved) +} + +#[tauri::command] +fn clear_data_dir(app_handle: tauri::AppHandle) -> Result { + remove_setting("data_dir_override"); + switch_bridge(&app_handle)?; + Ok(String::new()) +} + +#[tauri::command] +fn move_data_dir(app_handle: tauri::AppHandle, current: String, dir: String) -> Result { + let src = PathBuf::from(current.trim()); + let parent = PathBuf::from(dir.trim()); + if current.trim().is_empty() { + return Err("current data dir is empty; open settings after bridge is ready and try again".into()); + } + if dir.trim().is_empty() { + return Err("target parent directory is empty".into()); + } + if !src.exists() { + return Err(format!("current data dir does not exist: {}", src.to_string_lossy())); + } + if !src.is_dir() { + return Err(format!("current data dir is not a directory: {}", 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 data_dir_name = src_c + .file_name() + .ok_or_else(|| "current data dir has no folder name".to_string())?; + let dst = parent_c.join(data_dir_name); + let dst_c = if dst.exists() { dst.canonicalize().unwrap_or(dst.clone()) } else { dst.clone() }; + if src_c == dst_c { + return Ok(set_data_dir_override_value(dst_c.to_string_lossy().as_ref())?); + } + if dst_c.starts_with(&src_c) { + return Err("target data dir cannot be inside the current data dir".into()); + } + if dst_c.exists() { + return Err(format!("target data dir already exists: {}", dst_c.to_string_lossy())); + } + copy_dir_replace(&src_c, &dst_c)?; + let saved = set_data_dir_override_value(dst_c.to_string_lossy().as_ref())?; + switch_bridge(&app_handle)?; + std::fs::remove_dir_all(&src_c).map_err(|e| format!("remove old data dir {:?}: {}", src_c, e))?; + Ok(saved) +} /// 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. @@ -984,7 +1087,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, get_data_dir_override, set_data_dir, clear_data_dir, move_data_dir, 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. diff --git a/frontends/desktop/static/app.js b/frontends/desktop/static/app.js index f3b674e03..94aacd3a4 100644 --- a/frontends/desktop/static/app.js +++ b/frontends/desktop/static/app.js @@ -279,6 +279,9 @@ let bridgeUiOffline = false; selectGaRoot: () => rpc('app/path/selectGaRoot', {}), openMykeyTemplate: () => rpc('app/path/open', { kind: 'mykeyTemplate' }), openMykey: () => rpc('app/path/open', { kind: 'mykey' }), + openDataDir: () => rpc('app/path/open', { kind: 'dataDir' }), + openMemoryDir: () => rpc('app/path/open', { kind: 'memoryDir' }), + openSysPromptOverride: () => rpc('app/path/open', { kind: 'sysPromptOverride' }), startService, stopService, getServiceLogs: (id, tail = 200) => rpc('services/logs', { id, tail }), @@ -289,6 +292,10 @@ let bridgeUiOffline = false; getGaSource: () => tauriInvoke('get_ga_source'), setGaSource: (dir) => tauriInvoke('set_ga_source', { dir }), clearGaSource: () => tauriInvoke('clear_ga_source'), + getDataDirOverride: () => tauriInvoke('get_data_dir_override'), + setDataDir: (dir) => tauriInvoke('set_data_dir', { dir }), + clearDataDir: () => tauriInvoke('clear_data_dir'), + moveDataDir: (current, dir) => tauriInvoke('move_data_dir', { current, dir }), getConductorModel: () => rpc('services/conductor/model/get', {}), saveConductorModel: (llmNo) => rpc('services/conductor/model/save', { llmNo }), tauriInvoke, @@ -716,6 +723,95 @@ bindClick('import-memory-btn', async (e) => { showChanToast(t('err.memoryImport'), err.message || String(err), 'err'); } }); +// 打开可写数据文件夹(报告 temp/ 与记忆 memory/ 所在),方便用户取用/编辑。 +bindClick('open-data-dir-btn', async (e) => { + e.stopPropagation(); + try { + const res = await window.ga.openDataDir(); + if (res && res.ok === false) throw new Error(res.error || 'open failed'); + } catch (err) { + showChanToast(t('err.openDataDir'), err.message || String(err), 'err'); + } +}); +const dataDirCurrentEl = document.getElementById('data-dir-current'); +const dataDirClearBtn = document.getElementById('data-dir-clear-btn'); +async function refreshDataDir() { + const cur = state.dataDir || ''; + let override = ''; + if (window.__TAURI__?.core?.invoke) { + try { override = await window.ga.getDataDirOverride(); } catch (_) { override = ''; } + } + if (dataDirCurrentEl) { + if (cur || override) { + dataDirCurrentEl.textContent = `${t('set.dataDirCurrent')}: ${cur || override}${override ? '' : ` (${t('set.dataDirDefault')})`}`; + dataDirCurrentEl.hidden = false; + } else { + dataDirCurrentEl.hidden = true; + } + } + if (dataDirClearBtn) dataDirClearBtn.hidden = !override; +} +bindClick('select-data-dir-btn', async (e) => { + e.stopPropagation(); + if (!window.__TAURI__?.core?.invoke) { + showChanToast(t('err.dataDirDesktopOnly'), '', 'err'); + return; + } + try { + const dir = await window.ga.tauriInvoke('pick_directory', { title: t('sys.dataDirPickTitle') }); + if (!dir) return; + showChanToast(t('sys.dataDirSwitching'), dir, 'ok'); + const saved = await window.ga.setDataDir(dir); + state.dataDir = saved; + await refreshDataDir(); + showChanToast(t('sys.dataDirSet'), saved, 'ok'); + } catch (err) { + showChanToast(t('err.dataDirSet'), err.message || String(err), 'err'); + } +}); +bindClick('move-data-dir-btn', async (e) => { + e.stopPropagation(); + if (!window.__TAURI__?.core?.invoke) { + showChanToast(t('err.dataDirDesktopOnly'), '', 'err'); + return; + } + try { + const dir = await window.ga.tauriInvoke('pick_directory', { title: t('sys.dataDirMoveTitle') }); + if (!dir) return; + const current = state.dataDir || ''; + const confirmed = await showConfirmDialog({ + title: t('confirm.moveDataDirTitle'), + message: `${t('confirm.moveDataDir')}\n\n${current}\n→\n${dir}`, + okText: t('set.moveDataDir'), + }); + if (!confirmed) return; + showChanToast(t('sys.dataDirMoving'), dir, 'ok'); + const saved = await window.ga.moveDataDir(current, dir); + state.dataDir = saved; + await refreshDataDir(); + showChanToast(t('sys.dataDirMoved'), saved, 'ok'); + } catch (err) { + showChanToast(t('err.dataDirMove'), err.message || String(err), 'err'); + } +}); +bindClick('data-dir-clear-btn', async (e) => { + e.stopPropagation(); + const confirmed = await showConfirmDialog({ + title: t('confirm.dataDirClearTitle'), + message: t('confirm.dataDirClear'), + okText: t('set.dataDirClear'), + }); + if (!confirmed) return; + try { + showChanToast(t('sys.dataDirSwitching'), '', 'ok'); + await window.ga.clearDataDir(); + await loadBridgeConfig(); + await refreshDataDir(); + showChanToast(t('sys.dataDirCleared'), '', 'ok'); + } catch (err) { + showChanToast(t('err.dataDirSet'), err.message || String(err), 'err'); + } +}); const gaSourceCurrentEl = document.getElementById('ga-source-current'); const gaSourceClearBtn = document.getElementById('ga-source-clear-btn'); async function refreshGaSource() { @@ -744,6 +840,7 @@ bindClick('ga-source-btn', async (e) => { showChanToast(t('sys.gaSourceSwitching'), dir, 'ok'); const project = await window.ga.setGaSource(dir); await refreshGaSource(); + await refreshDataDir(); showChanToast(t('sys.gaSourceSet'), project || dir, 'ok'); } catch (err) { showChanToast(t('err.gaSourceSet'), err.message || String(err), 'err'); @@ -761,12 +858,14 @@ bindClick('ga-source-clear-btn', async (e) => { showChanToast(t('sys.gaSourceSwitching'), '', 'ok'); await window.ga.clearGaSource(); await refreshGaSource(); + await refreshDataDir(); showChanToast(t('sys.gaSourceCleared'), '', 'ok'); } catch (err) { showChanToast(t('err.gaSourceSet'), err.message || String(err), 'err'); } }); refreshGaSource(); +refreshDataDir(); // 侧边栏「快速接入」:官方模型 → 预填表单;ga-token → 开 portal 写 mykey const pqEl = document.getElementById('provider-quickstart'); const pqGaBtn = document.getElementById('pq-ga-token'); @@ -3971,6 +4070,7 @@ async function loadBridgeConfig() { try { const res = await window.ga.getConfig(); const cfg = res?.config || {}; + state.dataDir = res?.dataDir || state.dataDir || ''; if (LANGS.includes(cfg.lang)) { lang = cfg.lang; applyI18n(); diff --git a/frontends/desktop/static/ga-web.js b/frontends/desktop/static/ga-web.js index 960f0b922..2c8e8edf2 100644 --- a/frontends/desktop/static/ga-web.js +++ b/frontends/desktop/static/ga-web.js @@ -126,6 +126,9 @@ selectGaRoot: () => rpc('app/path/selectGaRoot', {}), openMykeyTemplate: () => rpc('app/path/open', { kind: 'mykeyTemplate' }), openMykey: () => rpc('app/path/open', { kind: 'mykey' }), + openDataDir: () => rpc('app/path/open', { kind: 'dataDir' }), + openMemoryDir: () => rpc('app/path/open', { kind: 'memoryDir' }), + openSysPromptOverride: () => rpc('app/path/open', { kind: 'sysPromptOverride' }), pollSession: (sessionId, afterId = 0) => rpc('session/poll', { sessionId, afterId }), rpc, onBridgeMessage: (cb) => on('bridge-message', cb), diff --git a/frontends/desktop/static/i18n.js b/frontends/desktop/static/i18n.js index 52f6c8a5d..518449726 100644 --- a/frontends/desktop/static/i18n.js +++ b/frontends/desktop/static/i18n.js @@ -29,10 +29,14 @@ '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.gaSource': '接入外部 GenericAgent 源码', 'set.gaSourceClear': '改用内置版本', 'set.gaSourceCurrent': '当前接入', 'set.serviceManager': '后台服务管理', 'set.openDataDir': '打开数据文件夹', 'set.selectDataDir': '选择数据目录', 'set.moveDataDir': '移动数据目录', 'set.dataDirClear': '恢复默认数据目录', 'set.dataDirCurrent': '当前数据目录', 'set.dataDirDefault': '默认', 'set.importMykeyTip': '从 mykey.py 导入已有的模型与 API 配置', 'set.exportMykeyTip': '将当前模型与 API 配置导出为 mykey.py', 'set.importMemoryTip': '从另一个 GenericAgent 根目录导入记忆、模型响应和会话记录', + 'set.openDataDirTip': '打开可写数据文件夹(报告 temp/ 与记忆 memory/ 所在),可直接取用或编辑', + 'set.selectDataDirTip': '选择以后使用的数据目录;不会自动搬迁当前数据', + 'set.moveDataDirTip': '把当前数据目录整体移动到所选位置下,并删除旧目录', + 'set.dataDirClearTip': '清除自定义数据目录,恢复默认 ~/GenericAgent_Data', 'set.gaSourceTip': '选择包含 agentmain.py 的 GenericAgent 根目录,桌面端将使用其中的后端源码', 'set.gaSourceClearTip': '取消接入当前外部源码,切换回安装包内置的 GenericAgent 版本', 'set.serviceManagerTip': '查看并管理消息通道与后台进程的运行状态和日志', @@ -161,8 +165,23 @@ 'sys.mykeyExported': '模型配置已导出', 'sys.memoryImported': '记忆已导入', 'err.memoryImport': '导入记忆失败', + 'err.openDataDir': '打开数据文件夹失败', + 'err.dataDirSet': '设置数据目录失败', + 'err.dataDirMove': '移动数据目录失败', + 'err.dataDirDesktopOnly': '此功能仅在桌面版中可用', 'sys.memoryImportBackup': '原记忆已备份至', 'sys.memorySessions': '会话', + 'sys.dataDirPickTitle': '选择 GenericAgent 数据目录', + 'sys.dataDirMoveTitle': '选择新的 GenericAgent 数据目录位置', + 'sys.dataDirSwitching': '正在切换数据目录并重启后端...', + 'sys.dataDirMoving': '正在移动数据并切换目录...', + 'sys.dataDirSet': '已切换数据目录', + 'sys.dataDirMoved': '已移动并切换数据目录', + 'sys.dataDirCleared': '已恢复默认数据目录', + 'confirm.moveDataDirTitle': '移动数据目录?', + 'confirm.moveDataDir': '将当前数据目录整体移动到所选位置下,并删除旧目录。是否继续?', + 'confirm.dataDirClearTitle': '恢复默认数据目录?', + 'confirm.dataDirClear': '清除自定义数据目录后,后端会重启并使用默认 ~/GenericAgent_Data。旧目录不会删除。是否继续?', 'sys.gaSourcePickTitle': '选择要接入的 GenericAgent 源码根目录(含 agentmain.py)', 'sys.gaSourceSwitching': '正在切换并重启后端...', 'sys.gaSourceSet': '已接入独立 GenericAgent', @@ -223,10 +242,14 @@ '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.gaSource': 'Connect external GenericAgent source', 'set.gaSourceClear': 'Use bundled version', 'set.gaSourceCurrent': 'Connected to', 'set.serviceManager': 'Service manager', 'set.openDataDir': 'Open data folder', 'set.selectDataDir': 'Choose data folder', 'set.moveDataDir': 'Move data folder', 'set.dataDirClear': 'Restore default data folder', 'set.dataDirCurrent': 'Current data folder', 'set.dataDirDefault': 'default', '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.openDataDirTip': 'Open the writable data folder (holds reports under temp/ and memory/) to copy or edit files directly', + 'set.selectDataDirTip': 'Choose the data folder to use from now on; this does not move existing data', + 'set.moveDataDirTip': 'Move the current data folder under the selected location and delete the old folder', + 'set.dataDirClearTip': 'Clear the custom data folder and restore the default ~/GenericAgent_Data', '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', @@ -355,8 +378,23 @@ 'sys.mykeyExported': 'Model config exported', 'sys.memoryImported': 'Memory imported', 'err.memoryImport': 'Failed to import memory', + 'err.openDataDir': 'Failed to open data folder', + 'err.dataDirSet': 'Failed to set data folder', + 'err.dataDirMove': 'Failed to move data folder', + 'err.dataDirDesktopOnly': 'This feature is only available in the desktop app', 'sys.memoryImportBackup': 'Previous memory backed up to', 'sys.memorySessions': 'sessions', + 'sys.dataDirPickTitle': 'Choose GenericAgent data folder', + 'sys.dataDirMoveTitle': 'Choose the new GenericAgent data folder location', + 'sys.dataDirSwitching': 'Switching data folder and restarting backend...', + 'sys.dataDirMoving': 'Moving data and switching folder...', + 'sys.dataDirSet': 'Data folder switched', + 'sys.dataDirMoved': 'Data folder moved and switched', + 'sys.dataDirCleared': 'Default data folder restored', + 'confirm.moveDataDirTitle': 'Move data folder?', + 'confirm.moveDataDir': 'Move the current data folder under the selected location, switch to it, and delete the old folder. Continue?', + 'confirm.dataDirClearTitle': 'Restore default data folder?', + 'confirm.dataDirClear': 'The backend will restart and use the default ~/GenericAgent_Data. The old folder is not deleted. Continue?', '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', diff --git a/frontends/desktop/static/index.html b/frontends/desktop/static/index.html index 385a116b9..f8706f39f 100644 --- a/frontends/desktop/static/index.html +++ b/frontends/desktop/static/index.html @@ -661,6 +661,23 @@ + + + + +