merge main into RoundSquisheen
This commit is contained in:
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."
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user