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:
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."
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user