feat: 发布热更 1.5.94,修复中转历史会话并支持一键清空

This commit is contained in:
mac
2026-09-11 09:06:41 +08:00
parent 0446d09b3b
commit a74576c3e1
28 changed files with 3217 additions and 155 deletions
+224 -7
View File
@@ -17,6 +17,7 @@ const sessions = require("./lib/sessions");
const codexDataDir = require("./lib/codex_data_dir");
const providerSync = require("./lib/provider_sync");
const router = require("./lib/router");
const deeplink = require("./lib/deeplink");
const canvas = require("./lib/canvas");
const canvasStore = require("./lib/canvas_store");
const team = require("./lib/team");
@@ -24,6 +25,7 @@ const promptAnalysis = require("./lib/prompt-analysis");
const claudedesk = require("./lib/claudedesk");
const voice = require("./lib/voice_cdp");
const codexDesktop = require("./lib/codex_desktop");
const globalDictation = require("./lib/global_dictation");
const tokenWatch = require("./lib/token_watch");
const proxy = require("./lib/proxy");
const memory = require("./lib/memory");
@@ -95,6 +97,9 @@ let lastPull = null; // { base, items } —— gh-restore 用上次 pull 的结
let voiceSessionDeleteBusy = false;
let stateWriteOperation = "";
const sessionDeleteClipboard = new Map();
let pendingStartupDeepLink = null;
let globalDictationCleanupTimer = null;
let globalDictationCleanupAttempt = 0;
async function withStateWriteLock(label, fn) {
if (stateWriteOperation) throw new Error(`正在${stateWriteOperation},请完成后再${label}`);
@@ -178,6 +183,116 @@ function showWindow() {
if (process.platform === "darwin" && app.dock) app.dock.show();
}
function configureGlobalDictation(codexHome) {
globalDictation.setEndpointOwnerVerifier((port, options) => codexDesktop.debugPortOwner(port, options));
const result = globalDictation.startGuard({ codexHome, log });
if (result.changed) log("已关闭 Codex 全局听写栏常驻显示");
else if (!result.ok) {
log(`关闭 Codex 全局听写栏常驻显示失败:${result.error || result.reason}`);
}
return result;
}
async function cleanupGlobalDictation(reason = "") {
const cdp = await globalDictation.closeGlobalDictationTargets(voice.DEFAULT_PORT);
if (cdp.available || !codexDesktop.hideGlobalDictationWindows) {
return cdp;
}
const native = await codexDesktop.hideGlobalDictationWindows();
if (native && native.hidden) {
log(`已将 ChatGPT 全局听写悬浮栏移出屏幕${reason ? `(${reason})` : ""}`);
return {
...cdp,
ok: true,
status: "native-hidden",
native,
closed: native.hidden,
remaining: 0,
};
}
return { ...cdp, native };
}
function scheduleGlobalDictationCleanup() {
if (globalDictationCleanupTimer) clearTimeout(globalDictationCleanupTimer);
globalDictationCleanupTimer = null;
globalDictationCleanupAttempt = 0;
const delays = [1500, 3000, 6000, 12000, 24000];
const run = async () => {
globalDictationCleanupTimer = null;
const result = await cleanupGlobalDictation("启动清理");
if (result.status === "closed" || result.status === "native-hidden" || result.status === "not-found") {
if (result.closed) log(`已清理 ChatGPT 全局听写悬浮栏 ${result.closed} 个`);
return;
}
if (globalDictationCleanupAttempt >= delays.length || isQuitting) return;
const delay = delays[globalDictationCleanupAttempt++];
globalDictationCleanupTimer = setTimeout(() => {
run().catch((e) => log(`重试清理 ChatGPT 全局听写悬浮栏失败:${e.message || e}`));
}, delay);
globalDictationCleanupTimer.unref?.();
};
run().catch((e) => log(`清理 ChatGPT 全局听写悬浮栏失败:${e.message || e}`));
}
function notifyRouterChanged(status) {
rebuildTrayMenu();
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("router-changed", status);
}
async function handleTdToolboxDeepLink(rawUrl) {
const parsed = deeplink.parseTdToolboxImport(rawUrl);
if (!parsed.ok) {
await dialog.showMessageBox({
type: "warning",
title: "TD工具箱导入失败",
message: "配置深链无法识别",
detail: parsed.error,
buttons: ["知道了"],
});
return;
}
showWindow();
const input = parsed.input;
const answer = await dialog.showMessageBox(mainWindow, {
type: "question",
title: "导入 Sub2API 分组",
message: `导入「${input.name}」到路由中转?`,
detail: "将保存 API 地址、密钥和默认模型到本机分组。不会自动启用该分组,也不会修改 ~/.codex 配置。",
buttons: ["取消", "导入"],
defaultId: 1,
cancelId: 0,
noLink: true,
});
if (answer.response !== 1) return;
try {
const status = router.importSub2apiProvider(input);
notifyRouterChanged(status);
await dialog.showMessageBox(mainWindow, {
type: "info",
title: "导入完成",
message: status._updated ? "已更新 Sub2API 分组" : "已导入 Sub2API 分组",
detail: "请到「路由中转」选择该分组后再接入 Codex。",
buttons: ["知道了"],
});
notifyRouterChanged(router.status());
} catch (e) {
log("Sub2API 分组导入失败:" + ((e && e.message) || e));
await dialog.showMessageBox(mainWindow, {
type: "error",
title: "导入失败",
message: "保存 Sub2API 分组失败",
detail: String((e && e.message) || e),
buttons: ["知道了"],
});
}
}
function rebuildTrayMenu() {
if (!tray) return;
const st = router.status();
@@ -233,13 +348,54 @@ function setupTray() {
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on("second-instance", () => showWindow());
app.on("second-instance", (_e, argv) => {
const rawUrl = argv.find((item) => item.startsWith(`${deeplink.SCHEME}://`));
if (rawUrl) {
handleTdToolboxDeepLink(rawUrl).catch((e) => log("处理深链失败:" + ((e && e.message) || e)));
return;
}
showWindow();
});
}
app.on("open-url", (event, rawUrl) => {
if (!rawUrl || !rawUrl.startsWith(`${deeplink.SCHEME}://`)) return;
event.preventDefault();
if (app.isReady()) {
handleTdToolboxDeepLink(rawUrl).catch((e) => log("处理深链失败:" + ((e && e.message) || e)));
} else {
pendingStartupDeepLink = rawUrl;
}
});
if (process.defaultApp && process.argv.length > 1) {
app.setAsDefaultProtocolClient(deeplink.SCHEME, process.execPath, [path.resolve(process.argv[1])]);
} else {
app.setAsDefaultProtocolClient(deeplink.SCHEME);
}
const startupDeepLink = process.argv.find((item) => item.startsWith(`${deeplink.SCHEME}://`));
if (startupDeepLink) pendingStartupDeepLink = startupDeepLink;
app.whenReady().then(async () => {
providerSync.setCodexDataDir(codexDataDir.get());
router.setCodexDataDir(codexDataDir.get());
const activeCodexDataDir = codexDataDir.get();
providerSync.setCodexDataDir(activeCodexDataDir);
router.setCodexDataDir(activeCodexDataDir);
configureGlobalDictation(activeCodexDataDir);
try {
const startupRouter = router.status();
if (startupRouter.repair && startupRouter.repair.repaired) {
log(`启动修复 Codex provider:${startupRouter.repair.reason || startupRouter.repair.mode}`);
}
} catch (e) {
log(`启动修复 Codex provider 失败:${e.message || e}`);
}
createWindow();
if (pendingStartupDeepLink) {
const rawUrl = pendingStartupDeepLink;
pendingStartupDeepLink = null;
handleTdToolboxDeepLink(rawUrl).catch((e) => log("处理深链失败:" + ((e && e.message) || e)));
}
setupTray();
app.on("activate", () => showWindow());
setupAutoUpdate();
@@ -275,7 +431,11 @@ app.whenReady().then(async () => {
if (r.migrated.length) log(`启动迁移 plugin: ${r.migrated.length} 个转为软链`);
if (r.errors.length) log(`启动迁移 plugin 错误: ${JSON.stringify(r.errors)}`);
});
// 启动自愈:把当前激活分组的全部文本模型先回填进 Codex 模型目录。
deferStartupWork("启动修复会话目录", 5000, async () => {
await providerSync.repairLegacySessionVisibility(log);
});
scheduleGlobalDictationCleanup();
// 启动自愈:把当前激活分组的同族文本模型回填进 Codex 模型目录。
// 目的——用户在 Codex 本体的模型选择器里切模型(不经本 App)时,若目标模型不在目录里,
// Codex 会以未知模型启动、侧栏历史还原失败 = "切模型后会话记录消失"。先回填即根治,
// 且既有安装无需重新保存分组即可自动修复。异步、失败不动目录(离线安全)。
@@ -283,7 +443,9 @@ app.whenReady().then(async () => {
const active = router.llmGroup(); // 当前激活且配齐 baseUrl+key+model 的中转分组(排除默认直连)
if (!active) return;
const r = await router.syncCatalogFromProvider(active);
if (r && r.ok && (r.added || r.revealed)) log(`启动同步模型目录:新增 ${r.added} · 显示 ${r.revealed} · 共 ${r.total}`);
if (r && r.ok && (r.added || r.revealed || r.hidden)) {
log(`启动同步模型目录:新增 ${r.added} · 显示 ${r.revealed} · 隐藏 ${r.hidden} · 共 ${r.total}`);
}
});
});
@@ -294,9 +456,28 @@ async function quitApp() {
if (quitting) return;
quitting = true;
isQuitting = true;
if (globalDictationCleanupTimer) {
clearTimeout(globalDictationCleanupTimer);
globalDictationCleanupTimer = null;
}
try {
await Promise.race([
voice.disconnect(),
new Promise((resolve) => setTimeout(resolve, 2500)),
]);
} catch {}
try {
await Promise.race([
cleanupGlobalDictation("退出清理"),
new Promise((resolve) => setTimeout(resolve, 2500)),
]);
} catch {}
try {
memory.stop(); // 停监视 + 记忆库落盘
} catch {}
try {
globalDictation.stopGuard();
} catch {}
try {
// 最多等 5 秒完成网络还原 / 拆 TUN / 杀引擎,超时也继续退出
await Promise.race([proxy.shutdown(), new Promise((r) => setTimeout(r, 5000))]);
@@ -718,7 +899,16 @@ async function startupUpdateCheck() {
});
return;
}
if (r.needFull) { await checkUpdateManual(); return; } // 有新内容但需完整包
if (r.needFull || r.needFullAfterHot) {
sendUpdate("update-status", {
state: "available-manual",
version: r.fullAppVersion || r.contentVersion,
url: GITEA_RELEASES_PAGE,
notes: `${r.notes || ""}${r.needFullAfterHot ? "\n\n本次更新还需要安装完整包,以注册 TD 工具箱深链协议。" : ""}`.trim(),
mandatory: r.mandatory,
});
return;
}
}
} catch { /* 热更检测失败 → 退回完整包检测 */ }
await checkUpdateManual();
@@ -1534,6 +1724,7 @@ ipcMain.handle("sessions-data-dir-select", async () => {
const next = codexDataDir.set(picked.filePaths[0]);
providerSync.setCodexDataDir(next.path);
router.setCodexDataDir(next.path);
configureGlobalDictation(next.path);
memory.restartWatching();
log(`会话数据目录已切换:${next.path}`);
return next;
@@ -1542,6 +1733,7 @@ ipcMain.handle("sessions-data-dir-reset", async () => {
const next = codexDataDir.reset();
providerSync.setCodexDataDir(next.path);
router.setCodexDataDir(next.path);
configureGlobalDictation(next.path);
memory.restartWatching();
log(`会话数据目录已恢复默认:${next.path}`);
return next;
@@ -1554,6 +1746,31 @@ ipcMain.handle("session-read", async (_e, { path: filePath }) => {
return sessions.readConversation(filePath);
});
ipcMain.handle("sessions-clear-all", async () => {
return withStateWriteLock("清空全部会话", async () => {
if (providerSync.codexRunning()) throw new Error("请先完全退出 Codex,再清空会话记录");
const result = await sessions.clearAllConversations();
let memoryRemoved = 0;
let cleanupWarning = "";
try {
const memoryResult = await memory.clearAllSessions();
memoryRemoved = memoryResult.count || 0;
} catch (e) {
cleanupWarning = String((e && e.message) || e);
log("会话主体已清空,但工具箱会话记忆清理失败:" + cleanupWarning);
}
try {
// 停止待处理的摘要任务,避免已删除 rollout 被重新写回记忆库。
memory.restartWatching();
} catch (e) {
const warning = String((e && e.message) || e);
cleanupWarning = cleanupWarning ? cleanupWarning + ";" + warning : warning;
log("会话已清空,但重启会话记忆监视器失败:" + warning);
}
return { ...result, memoryRemoved, cleanupWarning };
});
});
ipcMain.handle("session-delete-permanently", async (_e, { path: filePath } = {}) => {
return withStateWriteLock("永久删除会话", async () => {
if (providerSync.codexRunning()) throw new Error("请先完全退出 Codex,再永久删除会话");
@@ -1904,7 +2121,7 @@ voice.onSessionDelete(async (request) => {
cancelId: 0,
});
if (answer.response !== 1) return;
voice.disconnect();
await voice.disconnect();
try {
const stopped = await codexDesktop.killCodex();
if (!stopped || !stopped.exited) throw new Error("Codex 未完全退出,已停止删除以保护 SQLite");