merge main into RoundSquisheen

This commit is contained in:
dd3xp committed 2026-07-06 11:14:32 -04:00
commit 8bb91aa0f5
7 files changed
+501 -61

No files matched your search

@@ -18,6 +18,10 @@
"allow-start-bridge-with-config",
"allow-get-config",
"allow-export-mykey",
"allow-pick-directory",
"allow-get-ga-source",
"allow-set-ga-source",
"allow-clear-ga-source",
"allow-shortcut-should-ask",
"allow-shortcut-decide"
]
@@ -18,6 +18,26 @@ identifier = "allow-export-mykey"
description = "Save mykey.py via native file dialog."
commands.allow = ["export_mykey"]
[[permission]]
identifier = "allow-pick-directory"
description = "Pick a source directory via native folder dialog (import memory)."
commands.allow = ["pick_directory"]
[[permission]]
identifier = "allow-get-ga-source"
description = "Read the user-set external GenericAgent source directory."
commands.allow = ["get_ga_source"]
[[permission]]
identifier = "allow-set-ga-source"
description = "Point the desktop shell at an external GenericAgent source and restart the bridge."
commands.allow = ["set_ga_source"]
[[permission]]
identifier = "allow-clear-ga-source"
description = "Clear the external GA source override and fall back to the bundled runtime."
commands.allow = ["clear_ga_source"]
[[permission]]
identifier = "allow-shortcut-should-ask"
description = "Check whether to show the first-run desktop-shortcut prompt."
+126 -9
View File
@@ -330,12 +330,54 @@ fn running_inside_app_bundle() -> bool {
.unwrap_or(false)
}
/// User-set external GenericAgent source directory (design A: desktop as a thin shell).
/// Returns the path only when it is a valid GA checkout (has agentmain.py AND
/// frontends/desktop_bridge.py). An invalid/missing override returns None so callers fall
/// back to the bundle's own runtime/app — this is the "本体 moved/deleted" safety net.
fn valid_ga_source_override() -> Option<String> {
let s = read_settings()
.get("ga_source_override")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
if s.is_empty() {
return None;
}
let p = PathBuf::from(&s);
if p.join("agentmain.py").exists() && p.join("frontends").join("desktop_bridge.py").exists() {
Some(p.to_string_lossy().to_string())
} else {
None
}
}
/// Remove a single key from the settings file (merge_settings can only add/overwrite).
fn remove_setting(key: &str) {
let mut obj = read_settings();
obj.remove(key);
let val = serde_json::Value::Object(obj);
if let Ok(text) = serde_json::to_string_pretty(&val) {
let _ = std::fs::write(settings_path(), text);
}
}
/// Read config from settings file, or auto-discover and save.
/// Self-contained bundles always prefer their own runtime/app over stale user settings,
/// otherwise an old ~/.ga_desktop_settings.json can silently point the UI at a different checkout.
pub fn get_or_discover_config() -> (String, String) {
let path = settings_path();
// A user-set, still-valid external GA source wins over everything — including the bundle's
// own runtime/app. This is what turns the desktop app into a thin shell over a separate 本体.
// Uses the bundle python (which has deps installed) to run the external source.
if let Some(src) = valid_ga_source_override() {
let python = find_python();
if !python.is_empty() {
return (python, src);
}
}
if bundle_root().is_some() {
let python = find_python();
let project = find_project_dir().unwrap_or_default();
@@ -726,28 +768,103 @@ fn export_mykey(content: String) -> Result<Option<String>, String> {
}
}
#[tauri::command]
fn pick_directory(title: Option<String>) -> Option<String> {
let mut dlg = rfd::FileDialog::new();
if let Some(t) = title {
if !t.is_empty() {
dlg = dlg.set_title(&t);
}
}
dlg.pick_folder().map(|p| p.to_string_lossy().into_owned())
}
/// Stop the current bridge (ours or a stale one) and free :14168 before respawning.
fn stop_current_bridge() {
request_bridge_shutdown();
if let Some(mut child) = BRIDGE_PROCESS.lock().unwrap().take() {
let _ = child.kill();
let _ = child.wait();
}
let start = Instant::now();
while is_bridge_running() && start.elapsed() < Duration::from_secs(6) {
thread::sleep(Duration::from_millis(150));
}
if is_bridge_running() {
force_free_bridge_port();
let start = Instant::now();
while is_bridge_running() && start.elapsed() < Duration::from_secs(5) {
thread::sleep(Duration::from_millis(150));
}
}
}
/// Restart the bridge with whatever get_or_discover_config() now resolves to, then reload the
/// webview so it reconnects. Shared by set_ga_source / clear_ga_source.
fn switch_bridge(app_handle: &tauri::AppHandle) -> Result<String, String> {
stop_current_bridge();
let (py, project) = get_or_discover_config();
if project.is_empty() {
return Err("no GenericAgent source resolved".into());
}
spawn_bridge_process(&py, &project)?;
if !wait_for_port(14168, Duration::from_secs(20)) {
return Err("bridge did not become ready within 20s".into());
}
show_bridge_window(app_handle);
Ok(project)
}
#[tauri::command]
fn get_ga_source() -> String {
read_settings()
.get("ga_source_override")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string()
}
#[tauri::command]
fn set_ga_source(app_handle: tauri::AppHandle, dir: String) -> Result<String, String> {
let p = PathBuf::from(&dir);
if !p.join("agentmain.py").exists() {
return Err("not a GenericAgent source: agentmain.py not found".into());
}
if !p.join("frontends").join("desktop_bridge.py").exists() {
return Err("frontends/desktop_bridge.py not found in the selected directory".into());
}
merge_settings(serde_json::json!({ "ga_source_override": dir }));
switch_bridge(&app_handle)
}
#[tauri::command]
fn clear_ga_source(app_handle: tauri::AppHandle) -> Result<String, String> {
remove_setting("ga_source_override");
switch_bridge(&app_handle)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let args: Vec<String> = std::env::args().collect();
let no_autostart = args.iter().any(|a| a == "--no-autostart");
let dev_mode = args.iter().any(|a| a == "--dev");
let project_dir = find_project_dir().unwrap_or_default();
let needs_prepare = needs_first_run_prepare(&project_dir);
// Resolve the effective config once (honors a user-set external GA source over the bundle).
// Use it for takeover + spawn so identity checks match what we actually launch.
let (eff_py, eff_project) = get_or_discover_config();
let needs_prepare = needs_first_run_prepare(&eff_project);
takeover_stale_bridge(&project_dir);
takeover_stale_bridge(&eff_project);
let bridge_ok = is_bridge_running();
let mut spawned_bridge = false;
// Skip the early spawn when a first-run prepare is required (no venv yet);
// the setup thread prepares the env first and then starts the bridge.
if !bridge_ok && !no_autostart && !needs_prepare {
// Try to start bridge with saved/discovered config
let (py_str, dir_str) = get_or_discover_config();
let dir = PathBuf::from(&dir_str);
let dir = PathBuf::from(&eff_project);
let script = dir.join("frontends").join("desktop_bridge.py");
if script.exists() {
let mut cmd = Command::new(&py_str);
let mut cmd = Command::new(&eff_py);
cmd.arg(&script).current_dir(&dir);
sanitize_bundle_env(&mut cmd);
#[cfg(windows)]
@@ -767,7 +884,7 @@ pub fn run() {
let _ = w.set_focus();
}
}))
.invoke_handler(tauri::generate_handler![start_bridge_with_config, start_bridge, get_config, export_mykey, shortcut_should_ask, shortcut_decide])
.invoke_handler(tauri::generate_handler![start_bridge_with_config, start_bridge, get_config, export_mykey, pick_directory, get_ga_source, set_ga_source, clear_ga_source, shortcut_should_ask, shortcut_decide])
.setup(move |app| {
// Show the loading window immediately so the first-run prepare isn't a blank screen.
// The window starts on loading.html (a local page), so no "connection refused" flash.
@@ -776,7 +893,7 @@ pub fn run() {
}
let handle = app.handle().clone();
let project_dir = project_dir.clone();
let project_dir = eff_project.clone();
thread::spawn(move || {
// Progress reporter: push status into the loading window (window.gaProgress).
let main_win = handle.get_webview_window("main");
+77
View File
@@ -284,6 +284,10 @@ let bridgeUiOffline = false;
getServicePanel: () => rpc('services/panel', {}),
getMykeyContent: () => rpc('services/mykey/get', {}),
saveMykeyContent: (content) => rpc('services/mykey/save', { content }),
importMemory: (sourceDir) => rpc('memory/import', { sourceDir }),
getGaSource: () => tauriInvoke('get_ga_source'),
setGaSource: (dir) => tauriInvoke('set_ga_source', { dir }),
clearGaSource: () => tauriInvoke('clear_ga_source'),
getConductorModel: () => rpc('services/conductor/model/get', {}),
saveConductorModel: (llmNo) => rpc('services/conductor/model/save', { llmNo }),
tauriInvoke,
@@ -683,6 +687,79 @@ bindClick('export-mykey-btn', async (e) => {
showChanToast(t('err.mykeyExport'), err.message || String(err), 'err');
}
});
// 记忆/session 导入与外接 GA 源码。
async function importMemoryFromDir() {
let sourceDir = '';
if (window.__TAURI__?.core?.invoke) {
sourceDir = await window.ga.tauriInvoke('pick_directory', { title: t('sys.memoryPickTitle') });
} else {
sourceDir = window.prompt(t('sys.memoryImportPrompt')) || '';
}
if (!sourceDir) return;
const res = await window.ga.importMemory(sourceDir);
if (!res || res.ok === false) throw new Error((res && res.error) || 'import failed');
const detail = `memory: ${res.memoryCopied}, model_responses: ${res.responsesCopied}`
+ (res.responsesSkipped ? ` (skip ${res.responsesSkipped})` : '')
+ `, ${t('sys.memorySessions')}: ${res.sessionsAdded || 0}`
+ (res.backupDir ? `\n${t('sys.memoryImportBackup')}: ${res.backupDir}` : '');
if (res.sessionsAdded) {
try { await loadSessions(); renderSessionList(); } catch (_) {}
}
showChanToast(t('sys.memoryImported'), detail, 'ok');
}
bindClick('import-memory-btn', async (e) => {
e.stopPropagation();
try {
await importMemoryFromDir();
} catch (err) {
showChanToast(t('err.memoryImport'), err.message || String(err), 'err');
}
});
const gaSourceCurrentEl = document.getElementById('ga-source-current');
const gaSourceClearBtn = document.getElementById('ga-source-clear-btn');
async function refreshGaSource() {
if (!window.__TAURI__?.core?.invoke) return;
let cur = '';
try { cur = await window.ga.getGaSource(); } catch (_) { cur = ''; }
if (gaSourceCurrentEl) {
if (cur) {
gaSourceCurrentEl.textContent = `${t('set.gaSourceCurrent')}: ${cur}`;
gaSourceCurrentEl.hidden = false;
} else {
gaSourceCurrentEl.hidden = true;
}
}
if (gaSourceClearBtn) gaSourceClearBtn.hidden = !cur;
}
bindClick('ga-source-btn', async (e) => {
e.stopPropagation();
if (!window.__TAURI__?.core?.invoke) {
showChanToast(t('err.gaSourceDesktopOnly'), '', 'err');
return;
}
try {
const dir = await window.ga.tauriInvoke('pick_directory', { title: t('sys.gaSourcePickTitle') });
if (!dir) return;
showChanToast(t('sys.gaSourceSwitching'), dir, 'ok');
const project = await window.ga.setGaSource(dir);
await refreshGaSource();
showChanToast(t('sys.gaSourceSet'), project || dir, 'ok');
} catch (err) {
showChanToast(t('err.gaSourceSet'), err.message || String(err), 'err');
}
});
bindClick('ga-source-clear-btn', async (e) => {
e.stopPropagation();
try {
showChanToast(t('sys.gaSourceSwitching'), '', 'ok');
await window.ga.clearGaSource();
await refreshGaSource();
showChanToast(t('sys.gaSourceCleared'), '', 'ok');
} catch (err) {
showChanToast(t('err.gaSourceSet'), err.message || String(err), 'err');
}
});
refreshGaSource();
// 侧边栏「快速接入」:点击官方模型按钮 → 打开预填好的添加模型表单
const pqEl = document.getElementById('provider-quickstart');
if (pqEl) pqEl.addEventListener('click', (e) => {
+26 -2
View File
@@ -29,7 +29,7 @@
'customPreset.removeTitle': '删除',
'customPreset.editTitle': '编辑',
'builtinPreset.restoreBtn': '恢复默认预设',
'set.appearance': '外观', 'set.plainUi': '素色', 'set.fontSize': '聊天字号', 'set.lang': '语言', 'set.model': '模型', 'set.addModel': '添加模型', 'set.features': '功能', 'set.importMykey': '导入已有模型配置(mykey.py)', 'set.exportMykey': '导出当前模型配置', 'set.serviceManager': '后台服务管理',
'set.appearance': '外观', 'set.plainUi': '素色', 'set.fontSize': '聊天字号', 'set.lang': '语言', 'set.model': '模型', 'set.addModel': '添加模型', 'set.features': '功能', 'set.importMykey': '导入已有模型配置(mykey.py)', 'set.exportMykey': '导出当前模型配置', 'set.importMemory': '导入已有记忆与会话记录(选择 GenericAgent 根目录)', 'set.gaSource': '接入独立 GenericAgent 源码(把桌面版当作壳)', 'set.gaSourceClear': '取消接入,改用内置版本', 'set.gaSourceCurrent': '当前接入', 'set.serviceManager': '后台服务管理',
'shortcut.askConfirm': '是否在桌面创建 GenericAgent 快捷方式?',
'appearance.light': '浅色', 'appearance.dark': '深色',
'set.noModels': '暂无模型,点击下方添加',
@@ -150,6 +150,18 @@
'sys.configSaved': '配置已保存',
'sys.mykeyImported': '模型配置已导入',
'sys.mykeyExported': '模型配置已导出',
'sys.memoryImported': '记忆已导入',
'err.memoryImport': '导入记忆失败',
'sys.memoryImportBackup': '原记忆已备份至',
'sys.memorySessions': '会话',
'sys.gaSourcePickTitle': '选择要接入的 GenericAgent 源码根目录(含 agentmain.py)',
'sys.gaSourceSwitching': '正在切换并重启后端...',
'sys.gaSourceSet': '已接入独立 GenericAgent',
'sys.gaSourceCleared': '已取消接入,恢复内置版本',
'err.gaSourceSet': '接入失败',
'err.gaSourceDesktopOnly': '此功能仅在桌面版中可用',
'sys.memoryPickTitle': '选择 GenericAgent 根目录(包含 memory 与 temp 的目录)',
'sys.memoryImportPrompt': '请输入 GenericAgent 根目录的完整路径(包含 memory 与 temp 的目录,而非 memory 文件夹本身):',
'st.starting': '启动中…', 'st.stopping': '停止中…', 'st.online': '在线', 'st.offline': '离线', 'st.error': '错误', 'st.running': '运行', 'st.abnormal': '异常',
'act.configure': '配置', 'act.logs': '日志', 'act.restart': '重启', 'act.stop': '停止', 'act.start': '启动', 'act.exit': '退出',
'act.copy': '复制', 'act.copied': '已复制', 'act.copyTex': 'TeX', 'act.send': '发送',
@@ -202,7 +214,7 @@
'customPreset.removeTitle': 'Delete',
'customPreset.editTitle': 'Edit',
'builtinPreset.restoreBtn': 'Restore defaults',
'set.appearance': 'Appearance', 'set.plainUi': 'Plain', 'set.fontSize': 'Chat font size', 'set.lang': 'Language', 'set.model': 'Model', 'set.addModel': 'Add model', 'set.features': 'Features', 'set.importMykey': 'Import model config (mykey.py)', 'set.exportMykey': 'Export current model config', 'set.serviceManager': 'Service manager',
'set.appearance': 'Appearance', 'set.plainUi': 'Plain', 'set.fontSize': 'Chat font size', 'set.lang': 'Language', 'set.model': 'Model', 'set.addModel': 'Add model', 'set.features': 'Features', 'set.importMykey': 'Import model config (mykey.py)', 'set.exportMykey': 'Export current model config', 'set.importMemory': 'Import existing memory & sessions (select GenericAgent root)', 'set.gaSource': 'Connect to a separate GenericAgent source (use desktop as a shell)', 'set.gaSourceClear': 'Disconnect, use the bundled version', 'set.gaSourceCurrent': 'Connected to', 'set.serviceManager': 'Service manager',
'shortcut.askConfirm': 'Create a desktop shortcut for GenericAgent?',
'appearance.light': 'Light', 'appearance.dark': 'Dark',
'set.noModels': 'No models yet — add one below',
@@ -323,6 +335,18 @@
'sys.configSaved': 'Configuration saved',
'sys.mykeyImported': 'Model config imported',
'sys.mykeyExported': 'Model config exported',
'sys.memoryImported': 'Memory imported',
'err.memoryImport': 'Failed to import memory',
'sys.memoryImportBackup': 'Previous memory backed up to',
'sys.memorySessions': 'sessions',
'sys.gaSourcePickTitle': 'Select the GenericAgent source root to connect to (contains agentmain.py)',
'sys.gaSourceSwitching': 'Switching and restarting the backend...',
'sys.gaSourceSet': 'Connected to the external GenericAgent',
'sys.gaSourceCleared': 'Disconnected, restored the bundled version',
'err.gaSourceSet': 'Failed to connect',
'err.gaSourceDesktopOnly': 'This feature is only available in the desktop app',
'sys.memoryPickTitle': 'Select the GenericAgent root directory (the folder containing memory and temp)',
'sys.memoryImportPrompt': 'Enter the full path of the GenericAgent root directory (the folder containing memory and temp, not the memory folder itself):',
'st.starting': 'Starting…', 'st.stopping': 'Stopping…', 'st.online': 'Online', 'st.offline': 'Offline', 'st.error': 'Error', 'st.running': 'Running', 'st.abnormal': 'Error',
'act.configure': 'Configure', 'act.logs': 'Logs', 'act.restart': 'Restart', 'act.stop': 'Stop', 'act.start': 'Start', 'act.exit': 'Exit',
'act.copy': 'Copy', 'act.copied': 'Copied', 'act.copyTex': 'TeX', 'act.send': 'Send',
+14 -1
View File
@@ -649,6 +649,19 @@
<span data-ga-icon="folderSimple"></span>
<span data-i18n="set.exportMykey"></span>
</button>
<button class="set-btn set-btn-follow" id="import-memory-btn" type="button">
<span data-ga-icon="folderSimple"></span>
<span data-i18n="set.importMemory"></span>
</button>
<button class="set-btn set-btn-follow" id="ga-source-btn" type="button">
<span data-ga-icon="folderSimple"></span>
<span data-i18n="set.gaSource"></span>
</button>
<div class="set-hint" id="ga-source-current" hidden></div>
<button class="set-btn set-btn-follow" id="ga-source-clear-btn" type="button" hidden>
<span data-ga-icon="x"></span>
<span data-i18n="set.gaSourceClear"></span>
</button>
<button class="set-btn set-btn-follow" id="settings-services-btn" type="button">
<span data-ga-icon="broadcast"></span>
<span data-i18n="set.serviceManager"></span>
@@ -675,6 +688,6 @@
<script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.9.0/build/highlight.min.js" crossorigin="anonymous"></script>
<script src="phosphor-icons.js?v=2"></script>
<script src="i18n.js?v=1"></script>
<script src="app.js?v=216"></script>
<script src="app.js?v=217"></script>
</body>
</html>
+234 -49
View File
@@ -36,7 +36,7 @@ WS API (state sync):
"""
from __future__ import annotations
import asyncio, atexit, contextlib, importlib, json, os, re, subprocess, sys
import asyncio, atexit, contextlib, importlib, json, os, re, shutil, subprocess, sys
from collections import Counter, deque
import threading, time, traceback, uuid
from dataclasses import dataclass, field
@@ -148,6 +148,8 @@ class AgentManager:
self.config: Dict[str, Any] = {}
self.sessions: Dict[str, Session] = {}
self.active_session_id: Optional[str] = None
self._sessions_dir = Path(self.ga_root) / "temp" / "desktop_sessions"
# Legacy monolithic store; migrated into _sessions_dir on first load, then retired.
self._sessions_file = Path(self.ga_root) / "temp" / "desktop_sessions.json"
self._load_sessions()
@@ -155,56 +157,157 @@ class AgentManager:
def mykey_path(self) -> str:
return str(Path(self.ga_root) / "mykey.py")
def _persist(self):
def _session_dict(self, s: "Session") -> dict:
llm_hist = None
if s.agent and hasattr(s.agent, 'llmclient'):
try: llm_hist = s.agent.llmclient.backend.history
except Exception: pass
if llm_hist is None:
llm_hist = s.llm_history
return {"id": s.id, "title": s.title, "cwd": s.cwd,
"created_at": s.created_at, "updated_at": s.updated_at,
"messages": s.messages, "msg_seq": s.msg_seq,
"pinned": s.pinned, "untitled": s.untitled,
"plan_scan_baseline": s.plan_scan_baseline,
"plan_path": s.plan_path or "",
"llm_no": s.llm_no,
"llm_history": llm_hist}
def _session_file(self, sid: str) -> Path:
return self._sessions_dir / f"{sid}.json"
def _persist_session(self, s: "Session"):
"""Write a single session file. Cost is O(one session), independent of how many
sessions exist — this is the fix for the monolithic-file scaling problem."""
try:
self._sessions_file.parent.mkdir(parents=True, exist_ok=True)
arr = []
self._sessions_dir.mkdir(parents=True, exist_ok=True)
with self.lock:
for s in self.sessions.values():
llm_hist = None
if s.agent and hasattr(s.agent, 'llmclient'):
try: llm_hist = s.agent.llmclient.backend.history
except Exception: pass
if llm_hist is None:
llm_hist = s.llm_history
arr.append({"id": s.id, "title": s.title, "cwd": s.cwd,
"created_at": s.created_at, "updated_at": s.updated_at,
"messages": s.messages, "msg_seq": s.msg_seq,
"pinned": s.pinned, "untitled": s.untitled,
"plan_scan_baseline": s.plan_scan_baseline,
"plan_path": s.plan_path or "",
"llm_no": s.llm_no,
"llm_history": llm_hist})
self._sessions_file.write_text(json.dumps(arr, ensure_ascii=False, default=str), encoding="utf-8")
data = self._session_dict(s)
tmp = self._sessions_dir / f"{s.id}.json.tmp"
tmp.write_text(json.dumps(data, ensure_ascii=False, default=str), encoding="utf-8")
os.replace(tmp, self._session_file(s.id)) # atomic swap
except Exception as e:
print(f"[bridge] persist sessions failed: {e}", file=sys.stderr)
print(f"[bridge] persist session {s.id} failed: {e}", file=sys.stderr)
def _delete_session_file(self, sid: str):
try:
f = self._session_file(sid)
if f.exists():
f.unlink()
except Exception as e:
print(f"[bridge] delete session file {sid} failed: {e}", file=sys.stderr)
def _persist(self):
"""Write every session (one file each). Used for bulk ops (import) / full flush."""
with self.lock:
sessions = list(self.sessions.values())
for s in sessions:
self._persist_session(s)
def _session_from_item(self, item: dict) -> "Session":
msgs = item.get("messages", [])
return Session(id=item["id"], title=item.get("title", "New chat"),
cwd=item.get("cwd", self.ga_root),
created_at=item.get("created_at", time.time()),
updated_at=item.get("updated_at", time.time()),
messages=msgs,
msg_seq=item.get("msg_seq", 0),
pinned=item.get("pinned", False),
untitled=item.get("untitled", True),
plan_scan_baseline=_load_plan_baseline(item, msgs),
plan_path=_sanitize_desktop_plan_path(item["id"], item.get("plan_path") or ""),
status="idle", agent=None,
llm_history=item.get("llm_history"),
llm_no=item.get("llm_no"))
def _load_sessions(self):
# New format: one file per session under temp/desktop_sessions/.
try:
if not self._sessions_file.exists():
return
arr = json.loads(self._sessions_file.read_text(encoding="utf-8"))
for item in arr:
msgs = item.get("messages", [])
sess = Session(id=item["id"], title=item.get("title", "New chat"),
cwd=item.get("cwd", self.ga_root),
created_at=item.get("created_at", time.time()),
updated_at=item.get("updated_at", time.time()),
messages=msgs,
msg_seq=item.get("msg_seq", 0),
pinned=item.get("pinned", False),
untitled=item.get("untitled", True),
plan_scan_baseline=_load_plan_baseline(item, msgs),
plan_path=_sanitize_desktop_plan_path(
item["id"], item.get("plan_path") or ""),
status="idle", agent=None,
llm_history=item.get("llm_history"),
llm_no=item.get("llm_no"))
self.sessions[sess.id] = sess
if self.sessions:
self.active_session_id = max(self.sessions.values(), key=lambda s: s.updated_at).id
if self._sessions_dir.is_dir():
for f in self._sessions_dir.glob("*.json"):
try:
item = json.loads(f.read_text(encoding="utf-8"))
sess = self._session_from_item(item)
self.sessions[sess.id] = sess
except Exception as e:
print(f"[bridge] load session {f.name} failed: {e}", file=sys.stderr)
except Exception as e:
print(f"[bridge] load sessions failed: {e}", file=sys.stderr)
print(f"[bridge] load sessions dir failed: {e}", file=sys.stderr)
# One-time migration from the legacy monolithic desktop_sessions.json.
try:
if self._sessions_file.exists():
arr = json.loads(self._sessions_file.read_text(encoding="utf-8"))
for item in arr:
if not isinstance(item, dict) or item.get("id") in self.sessions:
continue
try:
sess = self._session_from_item(item)
self.sessions[sess.id] = sess
self._persist_session(sess)
except Exception as e:
print(f"[bridge] migrate session failed: {e}", file=sys.stderr)
# Retire the legacy file so we do not migrate again next launch.
with contextlib.suppress(Exception):
self._sessions_file.rename(
self._sessions_file.parent / (self._sessions_file.name + ".migrated"))
except Exception as e:
print(f"[bridge] migrate sessions failed: {e}", file=sys.stderr)
if self.sessions:
self.active_session_id = max(self.sessions.values(), key=lambda s: s.updated_at).id
def import_sessions(self, source_dir: str) -> dict:
"""把 source 的桌面会话合并进当前列表(按 id 去重)。
兼容两种源格式:新版 temp/desktop_sessions/<id>.json,以及旧版单文件
temp/desktop_sessions.json(含已退休的 .migrated)。只落盘新增的会话。
"""
src = Path(source_dir).expanduser().resolve()
items: List[dict] = []
found = False
# New per-session format.
src_dir = src / "temp" / "desktop_sessions"
if src_dir.is_dir():
for f in src_dir.glob("*.json"):
try:
items.append(json.loads(f.read_text(encoding="utf-8")))
found = True
except Exception:
continue
# Legacy monolithic format (live or already retired).
for legacy in (src / "temp" / "desktop_sessions.json",
src / "temp" / "desktop_sessions.json.migrated"):
if legacy.is_file():
found = True
try:
arr = json.loads(legacy.read_text(encoding="utf-8"))
if isinstance(arr, list):
items.extend(x for x in arr if isinstance(x, dict))
except Exception:
continue
if not found:
return {"sessionsAdded": 0, "sessionsSkipped": 0, "sessionsFileFound": False}
added = 0
skipped = 0
new_sessions: List["Session"] = []
with self.lock:
for item in items:
sid = item.get("id")
if not sid or sid in self.sessions:
skipped += 1
continue
sess = self._session_from_item(item)
self.sessions[sid] = sess
new_sessions.append(sess)
added += 1
for sess in new_sessions:
self._persist_session(sess)
return {"sessionsAdded": added, "sessionsSkipped": skipped, "sessionsFileFound": True}
def _mykey_file(self) -> Path:
p = Path(self.ga_root) / "mykey.py"
@@ -571,7 +674,7 @@ class AgentManager:
sess.updated_at = time.time()
if role == "user" and content.strip() and sess.title == "New chat":
sess.title = content.strip().replace("\n", " ")[:40]
self._persist()
self._persist_session(sess)
return msg
def create_session(self, cwd: Optional[str] = None) -> Session:
@@ -581,7 +684,7 @@ class AgentManager:
self.sessions[sid] = sess
self.active_session_id = sid
emit_session_state(sess, "created")
self._persist()
self._persist_session(sess)
return sess
def get_session(self, sid: str) -> Session:
@@ -602,7 +705,7 @@ class AgentManager:
with contextlib.suppress(Exception):
sess.agent.abort()
emit_session_state(sess, "closed")
self._persist()
self._delete_session_file(sid)
_purge_session_uploads(sid)
return {"ok": True, "sessionId": sid}
@@ -837,7 +940,7 @@ class AgentManager:
with contextlib.suppress(Exception):
sess.agent.next_llm(int(llm_no))
sess.updated_at = time.time()
self._persist()
self._persist_session(sess)
return {"ok": True, "sessionId": sid, "llmNo": sess.llm_no, "model": self._live_model(sess)}
@@ -1442,7 +1545,7 @@ async def patch_session_handler(request):
if "plan_scan_baseline" in data:
sess.plan_scan_baseline = int(data["plan_scan_baseline"])
sess.updated_at = time.time()
manager._persist()
manager._persist_session(sess)
return json_ok({"ok": True, "session": manager.snapshot(sess, include_messages=False)})
@@ -1748,6 +1851,87 @@ async def mykey_save_handler(request):
return json_ok({"ok": True, "path": str(manager._mykey_file()), "profiles": profiles})
def _import_memory_from(source_dir: str, ga_root: str) -> dict:
"""把 source_dir 的 memory/ 与 temp/model_responses/ 导入到 ga_root。
memory/: 先整体备份现有 ga_root/memory 到 temp/memory_import_backup_<ts>/,再覆盖同名文件、补齐新文件。
temp/model_responses/: 文件名带 pid/logid 天然唯一,只拷目标端不存在的,已存在的跳过。
"""
src = Path(source_dir).expanduser().resolve()
dst_root = Path(ga_root).resolve()
if not src.is_dir():
raise ValueError(f"source is not a directory: {src}")
if src == dst_root:
raise ValueError("source is the same as current GA root")
src_mem = src / "memory"
src_resp = src / "temp" / "model_responses"
if not src_mem.is_dir() and not src_resp.is_dir():
raise ValueError("not a GA directory (no memory/ or temp/model_responses/)")
memory_copied = 0
responses_copied = 0
responses_skipped = 0
backup_dir = ""
# --- memory/: 备份后覆盖 ---
if src_mem.is_dir():
dst_mem = dst_root / "memory"
if dst_mem.is_dir() and any(dst_mem.iterdir()):
ts = time.strftime("%Y%m%d_%H%M%S")
backup_root = dst_root / "temp" / f"memory_import_backup_{ts}"
backup_root.mkdir(parents=True, exist_ok=True)
shutil.copytree(dst_mem, backup_root / "memory")
backup_dir = str(backup_root)
dst_mem.mkdir(parents=True, exist_ok=True)
for item in src_mem.rglob("*"):
rel = item.relative_to(src_mem)
target = dst_mem / rel
if item.is_dir():
target.mkdir(parents=True, exist_ok=True)
else:
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, target)
memory_copied += 1
# --- temp/model_responses/: 补齐缺失 ---
if src_resp.is_dir():
dst_resp = dst_root / "temp" / "model_responses"
dst_resp.mkdir(parents=True, exist_ok=True)
for item in src_resp.rglob("*"):
if item.is_dir():
continue
rel = item.relative_to(src_resp)
target = dst_resp / rel
if target.exists():
responses_skipped += 1
continue
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(item, target)
responses_copied += 1
return {
"ok": True,
"memoryCopied": memory_copied,
"responsesCopied": responses_copied,
"responsesSkipped": responses_skipped,
"backupDir": backup_dir,
}
async def memory_import_handler(request):
data = await read_json(request)
source_dir = (data.get("sourceDir") or "").strip()
if not source_dir:
return json_ok({"ok": False, "error": "missing_sourceDir"}, status=400)
try:
result = _import_memory_from(source_dir, manager.ga_root)
result.update(manager.import_sessions(source_dir))
except Exception as e:
return json_ok({"ok": False, "error": str(e)}, status=400)
return json_ok(result)
async def conductor_model_get_handler(request):
return json_ok({"model": _conductor_settings()})
@@ -1922,6 +2106,7 @@ def create_app():
app.router.add_get("/services/panel", service_panel_handler)
app.router.add_get("/services/mykey", mykey_get_handler)
app.router.add_post("/services/mykey", mykey_save_handler)
app.router.add_post("/memory/import", memory_import_handler)
app.router.add_get("/services/conductor/model", conductor_model_get_handler)
app.router.add_post("/services/conductor/model", conductor_model_save_handler)
app.router.add_post("/services/stop-extras", stop_extras_handler)