Merge branch 'yiqi_memory' into main

Add import-memory feature to the desktop app: import memory/,
model_responses/ and desktop_sessions.json from an existing GenericAgent
install so past memory and sessions carry over to the standalone desktop.
This commit is contained in:
yiqi-017 committed 2026-07-06 01:36:15 +08:00
commit 44f3706ddc
6 files changed
+192 -5

No files matched your search

@@ -18,6 +18,7 @@
"allow-start-bridge-with-config",
"allow-get-config",
"allow-export-mykey",
"allow-pick-directory",
"allow-shortcut-should-ask",
"allow-shortcut-decide"
]
@@ -18,6 +18,11 @@ 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-shortcut-should-ask"
description = "Check whether to show the first-run desktop-shortcut prompt."
+12 -1
View File
@@ -726,6 +726,17 @@ 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())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let args: Vec<String> = std::env::args().collect();
@@ -767,7 +778,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, 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.
+43 -2
View File
@@ -216,6 +216,7 @@ let bridgeUiOffline = false;
case 'services/bridge/exit': return http('/services/bridge/exit', { method: 'POST' });
case 'services/mykey/get': return http('/services/mykey');
case 'services/mykey/save': return http('/services/mykey', { method: 'POST', body: params || {} });
case 'memory/import': return http('/memory/import', { method: 'POST', body: params || {} });
case 'services/conductor/model/get': return http('/services/conductor/model');
case 'services/conductor/model/save': return http('/services/conductor/model', { method: 'POST', body: params || {} });
case 'app/path/selectGaRoot': return http('/config');
@@ -284,6 +285,7 @@ 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 }),
getConductorModel: () => rpc('services/conductor/model/get', {}),
saveConductorModel: (llmNo) => rpc('services/conductor/model/save', { llmNo }),
tauriInvoke,
@@ -336,7 +338,7 @@ const I18N = {
'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.serviceManager': '后台服务管理',
'shortcut.askConfirm': '是否在桌面创建 GenericAgent 快捷方式?',
'appearance.light': '浅色', 'appearance.dark': '深色',
'set.noModels': '暂无模型,点击下方添加',
@@ -456,6 +458,12 @@ const I18N = {
'modal.mykeyConfig': 'mykey.py 配置',
'sys.configSaved': '配置已保存',
'sys.mykeyImported': '模型配置已导入',
'sys.memoryImported': '记忆已导入',
'err.memoryImport': '导入记忆失败',
'sys.memoryImportBackup': '原记忆已备份至',
'sys.memorySessions': '会话',
'sys.memoryPickTitle': '选择 GenericAgent 根目录(包含 memory 与 temp 的目录)',
'sys.memoryImportPrompt': '请输入 GenericAgent 根目录的完整路径(包含 memory 与 temp 的目录,而非 memory 文件夹本身):',
'sys.mykeyExported': '模型配置已导出',
'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': '退出',
@@ -509,7 +517,7 @@ const I18N = {
'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.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',
@@ -629,6 +637,12 @@ const I18N = {
'modal.mykeyConfig': 'mykey.py',
'sys.configSaved': 'Configuration saved',
'sys.mykeyImported': 'Model config imported',
'sys.memoryImported': 'Memory imported',
'err.memoryImport': 'Failed to import memory',
'sys.memoryImportBackup': 'Previous memory backed up to',
'sys.memorySessions': 'sessions',
'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):',
'sys.mykeyExported': 'Model config exported',
'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',
@@ -1006,6 +1020,33 @@ bindClick('export-mykey-btn', async (e) => {
showChanToast(t('err.mykeyExport'), err.message || String(err), 'err');
}
});
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 pqEl = document.getElementById('provider-quickstart');
if (pqEl) pqEl.addEventListener('click', (e) => {
+5 -1
View File
@@ -649,6 +649,10 @@
<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="settings-services-btn" type="button">
<span data-ga-icon="broadcast"></span>
<span data-i18n="set.serviceManager"></span>
@@ -674,6 +678,6 @@
<!-- highlight.js -->
<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="app.js?v=208"></script>
<script src="app.js?v=212"></script>
</body>
</html>
+126 -1
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
@@ -188,6 +188,49 @@ class AgentManager:
except Exception as e:
print(f"[bridge] load sessions failed: {e}", file=sys.stderr)
def import_sessions(self, source_dir: str) -> dict:
"""把 source_dir/temp/desktop_sessions.json 合并进当前会话列表(按 id 去重)。
既更新内存(立即在侧边栏可见)又落盘。返回新增/跳过计数。
"""
src = Path(source_dir).expanduser().resolve()
src_file = src / "temp" / "desktop_sessions.json"
if not src_file.is_file():
return {"sessionsAdded": 0, "sessionsSkipped": 0, "sessionsFileFound": False}
try:
arr = json.loads(src_file.read_text(encoding="utf-8"))
if not isinstance(arr, list):
raise ValueError("desktop_sessions.json is not a list")
except Exception as e:
raise ValueError(f"cannot read desktop_sessions.json: {e}")
added = 0
skipped = 0
with self.lock:
for item in arr:
sid = item.get("id")
if not sid or sid in self.sessions:
skipped += 1
continue
msgs = item.get("messages", [])
sess = Session(id=sid, 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(sid, item.get("plan_path") or ""),
status="idle", agent=None,
llm_history=item.get("llm_history"),
llm_no=item.get("llm_no"))
self.sessions[sid] = sess
added += 1
if added:
self._persist()
return {"sessionsAdded": added, "sessionsSkipped": skipped, "sessionsFileFound": True}
def _mykey_file(self) -> Path:
p = Path(self.ga_root) / "mykey.py"
if not p.exists():
@@ -1737,6 +1780,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()})
@@ -1911,6 +2035,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)