fix: Codex 自动压缩不触发 —— 接管时链接模型目录(绝对路径)进 config.toml (hot 1.5.83)
This commit is contained in:
+527
-47
@@ -5,6 +5,8 @@ const { autoUpdater } = require("electron-updater");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
const { execFile } = require("child_process");
|
||||
const git = require("isomorphic-git");
|
||||
const gitHttp = require("isomorphic-git/http/node");
|
||||
|
||||
@@ -12,13 +14,16 @@ const codex = require("./lib/codex");
|
||||
const purge = require("./lib/purge");
|
||||
const secrets = require("./lib/secrets");
|
||||
const sessions = require("./lib/sessions");
|
||||
const codexDataDir = require("./lib/codex_data_dir");
|
||||
const providerSync = require("./lib/provider_sync");
|
||||
const router = require("./lib/router");
|
||||
const canvas = require("./lib/canvas");
|
||||
const canvasStore = require("./lib/canvas_store");
|
||||
const team = require("./lib/team");
|
||||
const promptAnalysis = require("./lib/prompt-analysis");
|
||||
const claudedesk = require("./lib/claudedesk");
|
||||
const voice = require("./lib/voice_cdp");
|
||||
const codexDesktop = require("./lib/codex_desktop");
|
||||
const tokenWatch = require("./lib/token_watch");
|
||||
const proxy = require("./lib/proxy");
|
||||
const memory = require("./lib/memory");
|
||||
@@ -87,6 +92,15 @@ let tray = null;
|
||||
let isQuitting = false;
|
||||
let trayHinted = false; // Windows:首次最小化到托盘时弹一次气泡提示
|
||||
let lastPull = null; // { base, items } —— gh-restore 用上次 pull 的结果
|
||||
let voiceSessionDeleteBusy = false;
|
||||
let stateWriteOperation = "";
|
||||
const sessionDeleteClipboard = new Map();
|
||||
|
||||
async function withStateWriteLock(label, fn) {
|
||||
if (stateWriteOperation) throw new Error(`正在${stateWriteOperation},请完成后再${label}`);
|
||||
stateWriteOperation = label;
|
||||
try { return await fn(); } finally { stateWriteOperation = ""; }
|
||||
}
|
||||
|
||||
// —— 日志:主进程 → 前端 api.onLog ——
|
||||
function log(msg) {
|
||||
@@ -95,6 +109,24 @@ function log(msg) {
|
||||
}
|
||||
}
|
||||
|
||||
function deferStartupWork(label, delayMs, fn) {
|
||||
setTimeout(() => {
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
const r = fn();
|
||||
const done = (out) => {
|
||||
const ms = Date.now() - t0;
|
||||
if (ms > 300) log(`${label}完成:${ms}ms`);
|
||||
return out;
|
||||
};
|
||||
if (r && typeof r.then === "function") r.then(done).catch((e) => log(`${label}失败:${e.message || e}`));
|
||||
else done(r);
|
||||
} catch (e) {
|
||||
log(`${label}异常:${e.message || e}`);
|
||||
}
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 窗口
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -149,14 +181,19 @@ function showWindow() {
|
||||
function rebuildTrayMenu() {
|
||||
if (!tray) return;
|
||||
const st = router.status();
|
||||
const groupItems = st.groups.map((g) => ({
|
||||
label: (g.id === st.activeId ? "● " : "○ ") + g.name,
|
||||
click: () => {
|
||||
router.setActive(g.id);
|
||||
rebuildTrayMenu();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("router-changed", router.status());
|
||||
},
|
||||
}));
|
||||
const groupItems = st.groups.map((g) => {
|
||||
const toolOnly = !g.isDefault && (g.codexImage || g.codexVideo || g.stt);
|
||||
return {
|
||||
label: (g.id === st.activeId ? "● " : "○ ") + g.name + (toolOnly ? "(插件专用)" : ""),
|
||||
enabled: !toolOnly,
|
||||
click: () => {
|
||||
if (toolOnly) return;
|
||||
router.setActive(g.id);
|
||||
rebuildTrayMenu();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("router-changed", router.status());
|
||||
},
|
||||
};
|
||||
});
|
||||
const px = proxy.status();
|
||||
const pxLabel =
|
||||
px.mode === "global" ? "全局代理:运行中(整机 TUN)" : px.mode === "system" ? "系统代理:运行中" : "代理:未开启";
|
||||
@@ -200,6 +237,8 @@ if (!app.requestSingleInstanceLock()) {
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
providerSync.setCodexDataDir(codexDataDir.get());
|
||||
router.setCodexDataDir(codexDataDir.get());
|
||||
createWindow();
|
||||
setupTray();
|
||||
app.on("activate", () => showWindow());
|
||||
@@ -209,13 +248,14 @@ app.whenReady().then(async () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("proxy-changed", st);
|
||||
rebuildTrayMenu();
|
||||
});
|
||||
proxy.cleanupStale().catch(() => {});
|
||||
deferStartupWork("启动清理代理残留", 15000, () => proxy.cleanupStale());
|
||||
// 会话记忆:后台监视 ~/.codex/sessions,会话静默后自动摘要入库 + 写 HANDOFF.md
|
||||
memory.start({
|
||||
log,
|
||||
notify: () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("memory-changed");
|
||||
},
|
||||
startupScan: false,
|
||||
});
|
||||
// 启动时不再自动接管/启动本地路由:用户可能只想用其它功能。
|
||||
// 保存分组已直接写 Codex 直连配置;只有用户显式「启用本地路由」才 start() 路由进程。
|
||||
@@ -225,34 +265,26 @@ app.whenReady().then(async () => {
|
||||
} catch (e) {
|
||||
log(`刷新托盘失败:${e.message || e}`);
|
||||
}
|
||||
try {
|
||||
deferStartupWork("启动迁移 skill", 60000, () => {
|
||||
const r = migrateLegacySkills();
|
||||
if (r.migrated.length) log(`启动迁移 skill: ${r.migrated.length} 个转为软链`);
|
||||
if (r.errors.length) log(`启动迁移 skill 错误: ${JSON.stringify(r.errors)}`);
|
||||
} catch (e) {
|
||||
log(`启动迁移 skill 异常: ${e.message || e}`);
|
||||
}
|
||||
try {
|
||||
});
|
||||
deferStartupWork("启动迁移 plugin", 75000, () => {
|
||||
const r = migrateLegacyPlugins();
|
||||
if (r.migrated.length) log(`启动迁移 plugin: ${r.migrated.length} 个转为软链`);
|
||||
if (r.errors.length) log(`启动迁移 plugin 错误: ${JSON.stringify(r.errors)}`);
|
||||
} catch (e) {
|
||||
log(`启动迁移 plugin 异常: ${e.message || e}`);
|
||||
}
|
||||
});
|
||||
// 启动自愈:把当前激活分组的全部文本模型先回填进 Codex 模型目录。
|
||||
// 目的——用户在 Codex 本体的模型选择器里切模型(不经本 App)时,若目标模型不在目录里,
|
||||
// Codex 会以未知模型启动、侧栏历史还原失败 = "切模型后会话记录消失"。先回填即根治,
|
||||
// 且既有安装无需重新保存分组即可自动修复。异步、失败不动目录(离线安全)。
|
||||
try {
|
||||
deferStartupWork("启动同步模型目录", 30000, async () => {
|
||||
const active = router.llmGroup(); // 当前激活且配齐 baseUrl+key+model 的中转分组(排除默认直连)
|
||||
if (active) {
|
||||
router.syncCatalogFromProvider(active)
|
||||
.then((r) => { if (r && r.ok && (r.added || r.revealed)) log(`启动同步模型目录:新增 ${r.added} · 显示 ${r.revealed} · 共 ${r.total}`); })
|
||||
.catch((e) => log(`启动同步模型目录失败:${e.message || e}`));
|
||||
}
|
||||
} catch (e) {
|
||||
log(`启动同步模型目录异常:${e.message || e}`);
|
||||
}
|
||||
if (!active) return;
|
||||
const r = await router.syncCatalogFromProvider(active);
|
||||
if (r && r.ok && (r.added || r.revealed)) log(`启动同步模型目录:新增 ${r.added} · 显示 ${r.revealed} · 共 ${r.total}`);
|
||||
});
|
||||
});
|
||||
|
||||
// 统一退出:清理代理(杀 sing-box + 还原网络) → 停本地路由 → 销毁托盘 → 退出。
|
||||
@@ -303,6 +335,274 @@ const UPDATE_REPO = "codex-market-app";
|
||||
// up 域名给 API/直连,update 域名给用户浏览下载页(管理登录会话在此)。
|
||||
const GITEA_RELEASES_API = `https://update.tokendun.cc/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`;
|
||||
const GITEA_RELEASES_PAGE = `https://update.tokendun.cc/${UPDATE_OWNER}/${UPDATE_REPO}/releases`;
|
||||
const CODEX_INSTALLER_RELEASE_TAG = "codex-installers";
|
||||
const CODEX_WINDOWS_MSIX_NAME = "Codex-Windows-x64.msix";
|
||||
const CODEX_MAC_DMG_NAME = "ChatGPT.dmg";
|
||||
const CODEX_WINDOWS_MSIX_URL =
|
||||
`https://update.tokendun.cc/${UPDATE_OWNER}/${UPDATE_REPO}/releases/download/${CODEX_INSTALLER_RELEASE_TAG}/${CODEX_WINDOWS_MSIX_NAME}`;
|
||||
const CODEX_WINDOWS_MSIX_SHA256_URL = `${CODEX_WINDOWS_MSIX_URL}.sha256`;
|
||||
const CODEX_MAC_DMG_URL =
|
||||
`https://update.tokendun.cc/${UPDATE_OWNER}/${UPDATE_REPO}/releases/download/${CODEX_INSTALLER_RELEASE_TAG}/${CODEX_MAC_DMG_NAME}`;
|
||||
const CODEX_MAC_DMG_SHA256_URL = `${CODEX_MAC_DMG_URL}.sha256`;
|
||||
const codexInstallerDownloadControllers = new Map();
|
||||
|
||||
function codexInstallerTarget(platform) {
|
||||
if (platform === "windows") {
|
||||
return {
|
||||
platform,
|
||||
fileName: CODEX_WINDOWS_MSIX_NAME,
|
||||
url: CODEX_WINDOWS_MSIX_URL,
|
||||
sha256Url: CODEX_WINDOWS_MSIX_SHA256_URL,
|
||||
label: "Codex Windows 版",
|
||||
};
|
||||
}
|
||||
if (platform === "mac") {
|
||||
return {
|
||||
platform,
|
||||
fileName: CODEX_MAC_DMG_NAME,
|
||||
url: CODEX_MAC_DMG_URL,
|
||||
sha256Url: CODEX_MAC_DMG_SHA256_URL,
|
||||
label: "Codex Mac 版",
|
||||
};
|
||||
}
|
||||
throw new Error("未知安装平台");
|
||||
}
|
||||
|
||||
function sendCodexInstallerProgress(data) {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send("codex-installer-progress", data);
|
||||
}
|
||||
}
|
||||
|
||||
function codexInstallerCachePath(fileName) {
|
||||
return path.join(app.getPath("userData"), "installers", fileName);
|
||||
}
|
||||
|
||||
function isAbortError(e) {
|
||||
return (e && e.name === "AbortError") || /aborted|abort/i.test(String(e && e.message ? e.message : e));
|
||||
}
|
||||
|
||||
function assertCodexInstallerNotCanceled(signal) {
|
||||
if (signal && signal.aborted) {
|
||||
const err = new Error("已取消下载");
|
||||
err.name = "AbortError";
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function parseSha256(text) {
|
||||
const m = String(text || "").match(/\b[a-fA-F0-9]{64}\b/);
|
||||
return m ? m[0].toLowerCase() : "";
|
||||
}
|
||||
|
||||
function hashFile(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const h = crypto.createHash("sha256");
|
||||
fs.createReadStream(file)
|
||||
.on("data", (chunk) => h.update(chunk))
|
||||
.on("error", reject)
|
||||
.on("end", () => resolve(h.digest("hex")));
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchInstallerSha256(sha256Url, signal) {
|
||||
const res = await fetch(sha256Url, {
|
||||
headers: { "User-Agent": "codex-market", "Cache-Control": "no-cache" },
|
||||
signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`安装包校验文件未发布或不可访问:HTTP ${res.status}`);
|
||||
const sha256 = parseSha256(await res.text());
|
||||
if (!sha256) throw new Error("安装包校验文件格式不正确");
|
||||
return sha256;
|
||||
}
|
||||
|
||||
async function downloadCodexInstaller({ fileName, url, expectedSha256, platform, signal }) {
|
||||
const installerPath = codexInstallerCachePath(fileName);
|
||||
const tmpPath = `${installerPath}.download`;
|
||||
fs.mkdirSync(path.dirname(installerPath), { recursive: true });
|
||||
assertCodexInstallerNotCanceled(signal);
|
||||
|
||||
if (fs.existsSync(installerPath)) {
|
||||
sendCodexInstallerProgress({ platform, phase: "verify", message: "校验本地缓存…", percent: 60 });
|
||||
assertCodexInstallerNotCanceled(signal);
|
||||
const cachedSha256 = await hashFile(installerPath);
|
||||
assertCodexInstallerNotCanceled(signal);
|
||||
if (cachedSha256 === expectedSha256) {
|
||||
sendCodexInstallerProgress({ platform, phase: "cached", message: "已使用本地缓存", percent: 62 });
|
||||
return installerPath;
|
||||
}
|
||||
try { fs.rmSync(installerPath, { force: true }); } catch {}
|
||||
}
|
||||
|
||||
sendCodexInstallerProgress({ platform, phase: "download", message: "开始下载安装包…", downloaded: 0, total: 0, percent: 0 });
|
||||
const res = await fetch(url, { headers: { "User-Agent": "codex-market" }, signal });
|
||||
if (!res.ok) throw new Error(`安装包下载失败:HTTP ${res.status}`);
|
||||
if (!res.body || typeof res.body.getReader !== "function") throw new Error("当前运行环境不支持流式下载");
|
||||
|
||||
const total = Number(res.headers.get("content-length") || 0);
|
||||
const reader = res.body.getReader();
|
||||
const out = fs.createWriteStream(tmpPath);
|
||||
const streamError = new Promise((_, reject) => out.once("error", reject));
|
||||
let downloaded = 0;
|
||||
let lastEmit = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
assertCodexInstallerNotCanceled(signal);
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = Buffer.from(value);
|
||||
downloaded += chunk.length;
|
||||
if (!out.write(chunk)) await Promise.race([streamError, new Promise((resolve) => out.once("drain", resolve))]);
|
||||
const now = Date.now();
|
||||
if (now - lastEmit > 300) {
|
||||
lastEmit = now;
|
||||
sendCodexInstallerProgress({
|
||||
platform,
|
||||
phase: "download",
|
||||
downloaded,
|
||||
total,
|
||||
percent: total ? Math.round((downloaded / total) * 100) : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
await Promise.race([streamError, new Promise((resolve) => out.end(resolve))]);
|
||||
} catch (e) {
|
||||
try { out.destroy(); } catch {}
|
||||
try { fs.rmSync(tmpPath, { force: true }); } catch {}
|
||||
throw e;
|
||||
}
|
||||
|
||||
sendCodexInstallerProgress({ platform, phase: "verify", message: "校验安装包…", percent: 60 });
|
||||
assertCodexInstallerNotCanceled(signal);
|
||||
const sha256 = await hashFile(tmpPath);
|
||||
assertCodexInstallerNotCanceled(signal);
|
||||
if (sha256 !== expectedSha256) {
|
||||
try { fs.rmSync(tmpPath, { force: true }); } catch {}
|
||||
throw new Error("安装包 SHA256 校验失败,已停止安装");
|
||||
}
|
||||
fs.renameSync(tmpPath, installerPath);
|
||||
return installerPath;
|
||||
}
|
||||
|
||||
async function downloadCodexInstallerForPlatform(platform) {
|
||||
const target = codexInstallerTarget(platform);
|
||||
if (codexInstallerDownloadControllers.has(platform)) throw new Error(`${target.label}安装包正在下载中`);
|
||||
const controller = new AbortController();
|
||||
codexInstallerDownloadControllers.set(platform, controller);
|
||||
try {
|
||||
sendCodexInstallerProgress({ platform, phase: "checking", message: "读取安装包校验信息…", percent: 2 });
|
||||
const expectedSha256 = await fetchInstallerSha256(target.sha256Url, controller.signal);
|
||||
const installerPath = await downloadCodexInstaller({
|
||||
fileName: target.fileName,
|
||||
url: target.url,
|
||||
expectedSha256,
|
||||
platform,
|
||||
signal: controller.signal,
|
||||
});
|
||||
sendCodexInstallerProgress({ platform, phase: "downloaded", message: `${target.label}安装包已下载并校验完成`, percent: 100 });
|
||||
return { ok: true, platform, path: installerPath };
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) {
|
||||
try { fs.rmSync(`${codexInstallerCachePath(target.fileName)}.download`, { force: true }); } catch {}
|
||||
sendCodexInstallerProgress({ platform, phase: "canceled", message: `${target.label}安装包下载已取消`, percent: 0 });
|
||||
throw new Error("已取消下载");
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
if (codexInstallerDownloadControllers.get(platform) === controller) {
|
||||
codexInstallerDownloadControllers.delete(platform);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancelCodexInstallerDownload(platform) {
|
||||
const target = codexInstallerTarget(platform);
|
||||
const controller = codexInstallerDownloadControllers.get(platform);
|
||||
if (!controller) return { ok: true, canceled: false };
|
||||
controller.abort();
|
||||
sendCodexInstallerProgress({ platform, phase: "canceling", message: `正在取消${target.label}安装包下载…`, percent: 0 });
|
||||
return { ok: true, canceled: true };
|
||||
}
|
||||
|
||||
function execFileAsync(file, args, opts = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(file, args, opts, (err, stdout, stderr) => {
|
||||
if (!err) return resolve({ stdout, stderr });
|
||||
const msg = String(stderr || stdout || err.message || err).trim();
|
||||
reject(new Error(msg || `${path.basename(file)} 执行失败`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function runCodexWindowsMsix(installerPath) {
|
||||
const systemRoot = process.env.SystemRoot || "C:\\Windows";
|
||||
const bundledPowerShell = path.join(systemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
|
||||
const powerShell = fs.existsSync(bundledPowerShell) ? bundledPowerShell : "powershell.exe";
|
||||
const args = [
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy", "Bypass",
|
||||
"-Command",
|
||||
"& { param([string]$pkg) Add-AppxPackage -Path $pkg }",
|
||||
installerPath,
|
||||
];
|
||||
return execFileAsync(powerShell, args, { windowsHide: false, timeout: 15 * 60 * 1000 });
|
||||
}
|
||||
|
||||
async function installCodexWindows() {
|
||||
if (process.platform !== "win32") {
|
||||
throw new Error("Windows 一键安装只能在 Windows 上运行;可先在软件内下载 MSIX,再发给 Windows 用户安装。");
|
||||
}
|
||||
const { path: installerPath } = await downloadCodexInstallerForPlatform("windows");
|
||||
sendCodexInstallerProgress({ platform: "windows", phase: "install", message: "正在调用 PowerShell 安装…", percent: 85 });
|
||||
await runCodexWindowsMsix(installerPath);
|
||||
sendCodexInstallerProgress({ platform: "windows", phase: "done", message: "Codex Windows 版安装完成", percent: 100 });
|
||||
return { ok: true, path: installerPath };
|
||||
}
|
||||
|
||||
function findMountedAppBundle(mountDir) {
|
||||
const items = fs.readdirSync(mountDir, { withFileTypes: true });
|
||||
const appItem = items.find((ent) => ent.isDirectory() && ent.name.endsWith(".app"));
|
||||
if (!appItem) throw new Error("DMG 中没有找到 .app 应用");
|
||||
return path.join(mountDir, appItem.name);
|
||||
}
|
||||
|
||||
async function runCodexMacDmg(dmgPath) {
|
||||
const mountDir = path.join(app.getPath("temp"), `codex-installer-${Date.now()}`);
|
||||
fs.rmSync(mountDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(mountDir, { recursive: true });
|
||||
let mounted = false;
|
||||
try {
|
||||
sendCodexInstallerProgress({ platform: "mac", phase: "install", message: "正在挂载 DMG…", percent: 68 });
|
||||
await execFileAsync("/usr/bin/hdiutil", ["attach", dmgPath, "-nobrowse", "-readonly", "-mountpoint", mountDir], { timeout: 5 * 60 * 1000 });
|
||||
mounted = true;
|
||||
|
||||
const appPath = findMountedAppBundle(mountDir);
|
||||
const targetPath = path.join("/Applications", path.basename(appPath));
|
||||
sendCodexInstallerProgress({ platform: "mac", phase: "install", message: `正在复制到 /Applications/${path.basename(appPath)}…`, percent: 82 });
|
||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
await execFileAsync("/usr/bin/ditto", [appPath, targetPath], { timeout: 15 * 60 * 1000 });
|
||||
|
||||
sendCodexInstallerProgress({ platform: "mac", phase: "install", message: "正在卸载 DMG…", percent: 94 });
|
||||
await execFileAsync("/usr/bin/hdiutil", ["detach", mountDir], { timeout: 2 * 60 * 1000 });
|
||||
mounted = false;
|
||||
return { appPath: targetPath };
|
||||
} finally {
|
||||
if (mounted) {
|
||||
try { await execFileAsync("/usr/bin/hdiutil", ["detach", mountDir], { timeout: 2 * 60 * 1000 }); } catch {}
|
||||
}
|
||||
try { fs.rmSync(mountDir, { recursive: true, force: true }); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
async function installCodexMac() {
|
||||
if (process.platform !== "darwin") {
|
||||
throw new Error("Mac 一键安装只能在 macOS 上运行;可先在软件内下载 DMG,再发给 Mac 用户安装。");
|
||||
}
|
||||
const { path: installerPath } = await downloadCodexInstallerForPlatform("mac");
|
||||
const result = await runCodexMacDmg(installerPath);
|
||||
sendCodexInstallerProgress({ platform: "mac", phase: "done", message: "Codex Mac 版安装完成", percent: 100 });
|
||||
return { ok: true, path: installerPath, appPath: result.appPath };
|
||||
}
|
||||
|
||||
// 比较版本号:a>b 返回 1,a<b 返回 -1,相等 0
|
||||
function cmpVersion(a, b) {
|
||||
@@ -874,19 +1174,23 @@ function safeSkillDir(name) {
|
||||
}
|
||||
|
||||
// 列出已安装技能:扫描 ~/.codex/skills/<name>/
|
||||
function skillInstalledList() {
|
||||
function skillInstalledList(opts = {}) {
|
||||
const base = path.join(codex.CODEX_HOME, "skills");
|
||||
if (!fs.existsSync(base)) return [];
|
||||
const includeSize = !!opts.includeSize;
|
||||
const includeMeta = opts.includeMeta !== false;
|
||||
// 建索引:磁盘目录名 -> catalog 条目(中文名/中文简介)。读不到 catalog 就空索引,回退 SKILL.md。
|
||||
let byDir = null;
|
||||
try {
|
||||
const list = (skillCatalog() || []);
|
||||
const items = Array.isArray(list) ? list : (list.skills || list.items || list.data || []);
|
||||
byDir = new Map();
|
||||
for (const s of items) {
|
||||
if (s && s.name) byDir.set(safeSkillDir(s.name), s);
|
||||
}
|
||||
} catch {}
|
||||
if (includeMeta) {
|
||||
try {
|
||||
const list = (skillCatalog() || []);
|
||||
const items = Array.isArray(list) ? list : (list.skills || list.items || list.data || []);
|
||||
byDir = new Map();
|
||||
for (const s of items) {
|
||||
if (s && s.name) byDir.set(safeSkillDir(s.name), s);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
const out = [];
|
||||
for (const ent of fs.readdirSync(base, { withFileTypes: true })) {
|
||||
const isDir = ent.isDirectory();
|
||||
@@ -895,7 +1199,7 @@ function skillInstalledList() {
|
||||
const dir = path.join(base, ent.name);
|
||||
let mtime = 0;
|
||||
try { mtime = fs.statSync(dir).mtimeMs; } catch {}
|
||||
const meta = readSkillMeta(dir, ent.name);
|
||||
const meta = includeMeta ? readSkillMeta(dir, ent.name) : { name: ent.name, description: "" };
|
||||
// 优先用 catalog 的中文名/中文简介覆盖显示(SKILL.md 的 name 多为英文 slug,中文展示名在 catalog)
|
||||
const info = byDir && byDir.get(ent.name);
|
||||
out.push({
|
||||
@@ -903,7 +1207,7 @@ function skillInstalledList() {
|
||||
name: (info && info.name) || meta.name,
|
||||
subtitle: (info && info.subtitle) || "",
|
||||
description: (info && (info.brief || info.subtitle)) || meta.description,
|
||||
size: dirSize(dir),
|
||||
size: includeSize ? dirSize(dir) : 0,
|
||||
mtime,
|
||||
symlink: isLink,
|
||||
});
|
||||
@@ -921,9 +1225,11 @@ function builtinPluginInfo(name) {
|
||||
}
|
||||
|
||||
// 列出已安装插件:扫描 ~/.codex/plugins/cache/<source>/<name>/
|
||||
function pluginInstalledList() {
|
||||
function pluginInstalledList(opts = {}) {
|
||||
const base = path.join(codex.CODEX_HOME, "plugins", "cache");
|
||||
if (!fs.existsSync(base)) return [];
|
||||
const includeSize = !!opts.includeSize;
|
||||
const includeMeta = opts.includeMeta !== false;
|
||||
const out = [];
|
||||
for (const src of fs.readdirSync(base, { withFileTypes: true })) {
|
||||
const isSrcDir = src.isDirectory();
|
||||
@@ -938,14 +1244,14 @@ function pluginInstalledList() {
|
||||
let mtime = 0;
|
||||
try { mtime = fs.statSync(dir).mtimeMs; } catch {}
|
||||
// 中文标题/中文介绍优先取内置 plugins.json;磁盘 plugin.json 作兜底
|
||||
const info = builtinPluginInfo(ent.name);
|
||||
const info = includeMeta ? builtinPluginInfo(ent.name) : null;
|
||||
out.push({
|
||||
source: src.name,
|
||||
name: ent.name,
|
||||
displayName: (info && info.displayName) || ent.name, // 中文名,匹配不到回退英文 slug
|
||||
description: (info && info.description) || readPluginDescription(dir),
|
||||
description: includeMeta ? ((info && info.description) || readPluginDescription(dir)) : "",
|
||||
rel: `plugins/cache/${src.name}/${ent.name}`,
|
||||
size: dirSize(dir),
|
||||
size: includeSize ? dirSize(dir) : 0,
|
||||
mtime,
|
||||
symlink: isLink,
|
||||
protected: src.name.startsWith("openai-") && !isLink,
|
||||
@@ -1087,14 +1393,29 @@ function migrateLegacyPlugins() {
|
||||
// ---------------------------------------------------------------------------
|
||||
ipcMain.handle("skill-catalog", async () => skillCatalog());
|
||||
ipcMain.handle("skill-install", async (_e, { skill }) => skillInstall(skill));
|
||||
ipcMain.handle("skill-installed", async () => skillInstalledList());
|
||||
ipcMain.handle("skill-installed", async (_e, opts = {}) => skillInstalledList(opts || {}));
|
||||
ipcMain.handle("skill-uninstall", async (_e, { dir }) => skillUninstall(dir));
|
||||
ipcMain.handle("skill-migrate", async () => migrateLegacySkills());
|
||||
ipcMain.handle("plugin-catalog", async () => pluginCatalog());
|
||||
ipcMain.handle("plugin-install", async (_e, { plugin }) => pluginInstall(plugin));
|
||||
ipcMain.handle("plugin-installed", async () => pluginInstalledList());
|
||||
ipcMain.handle("plugin-installed", async (_e, opts = {}) => pluginInstalledList(opts || {}));
|
||||
ipcMain.handle("plugin-uninstall", async (_e, { rel }) => pluginUninstall(rel));
|
||||
ipcMain.handle("plugin-migrate", async () => migrateLegacyPlugins());
|
||||
ipcMain.handle("codex-installer-info", async () => ({
|
||||
platform: process.platform,
|
||||
isWindows: process.platform === "win32",
|
||||
isMac: process.platform === "darwin",
|
||||
windowsUrl: CODEX_WINDOWS_MSIX_URL,
|
||||
windowsSha256Url: CODEX_WINDOWS_MSIX_SHA256_URL,
|
||||
macUrl: CODEX_MAC_DMG_URL,
|
||||
macSha256Url: CODEX_MAC_DMG_SHA256_URL,
|
||||
windowsCachePath: codexInstallerCachePath(CODEX_WINDOWS_MSIX_NAME),
|
||||
macCachePath: codexInstallerCachePath(CODEX_MAC_DMG_NAME),
|
||||
}));
|
||||
ipcMain.handle("codex-download-installer", async (_e, { platform }) => downloadCodexInstallerForPlatform(platform));
|
||||
ipcMain.handle("codex-cancel-installer-download", async (_e, { platform }) => cancelCodexInstallerDownload(platform));
|
||||
ipcMain.handle("codex-install-windows", async () => installCodexWindows());
|
||||
ipcMain.handle("codex-install-mac", async () => installCodexMac());
|
||||
|
||||
ipcMain.handle("scan-local", async () => {
|
||||
return codex.scanBase(codex.CODEX_HOME);
|
||||
@@ -1201,6 +1522,30 @@ ipcMain.handle("set-secret", async (_e, { name, value }) => {
|
||||
});
|
||||
|
||||
// —— 会话记录:浏览 / 读取 / 导出 / 备份 / 导入 ——
|
||||
ipcMain.handle("sessions-data-dir-info", async () => codexDataDir.info());
|
||||
ipcMain.handle("sessions-data-dir-select", async () => {
|
||||
const current = codexDataDir.info();
|
||||
const picked = await dialog.showOpenDialog(mainWindow, {
|
||||
title: "选择 Codex 会话数据目录",
|
||||
defaultPath: current.path,
|
||||
properties: ["openDirectory"],
|
||||
});
|
||||
if (picked.canceled || !picked.filePaths.length) return { canceled: true, ...current };
|
||||
const next = codexDataDir.set(picked.filePaths[0]);
|
||||
providerSync.setCodexDataDir(next.path);
|
||||
router.setCodexDataDir(next.path);
|
||||
memory.restartWatching();
|
||||
log(`会话数据目录已切换:${next.path}`);
|
||||
return next;
|
||||
});
|
||||
ipcMain.handle("sessions-data-dir-reset", async () => {
|
||||
const next = codexDataDir.reset();
|
||||
providerSync.setCodexDataDir(next.path);
|
||||
router.setCodexDataDir(next.path);
|
||||
memory.restartWatching();
|
||||
log(`会话数据目录已恢复默认:${next.path}`);
|
||||
return next;
|
||||
});
|
||||
ipcMain.handle("sessions-list", async (_e, opts = {}) => {
|
||||
return sessions.listConversations(opts || {});
|
||||
});
|
||||
@@ -1209,6 +1554,20 @@ ipcMain.handle("session-read", async (_e, { path: filePath }) => {
|
||||
return sessions.readConversation(filePath);
|
||||
});
|
||||
|
||||
ipcMain.handle("session-delete-permanently", async (_e, { path: filePath } = {}) => {
|
||||
return withStateWriteLock("永久删除会话", async () => {
|
||||
if (providerSync.codexRunning()) throw new Error("请先完全退出 Codex,再永久删除会话");
|
||||
const result = await sessions.deleteConversation(filePath);
|
||||
try {
|
||||
await memory.deleteSession(result.id, result.cwd);
|
||||
return { ok: true, ...result };
|
||||
} catch (e) {
|
||||
log("会话主体已删除,但工具箱记忆清理失败:" + (e.message || e));
|
||||
return { ok: true, cleanupWarning: String(e.message || e), ...result };
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("session-export", async (_e, { item }) => {
|
||||
const conv = sessions.readConversation(item.path);
|
||||
const md = sessions.toMarkdown(item, conv);
|
||||
@@ -1239,7 +1598,15 @@ ipcMain.handle("sessions-import", async () => {
|
||||
title: "选择要导入的备份目录(含 sessions 的文件夹)",
|
||||
});
|
||||
if (r.canceled || !r.filePaths.length) return { canceled: true };
|
||||
return sessions.importSessions(r.filePaths[0], log);
|
||||
return withStateWriteLock("导入并恢复会话", async () => {
|
||||
if (providerSync.codexRunning()) throw new Error("请先完全退出 Codex,再导入并恢复会话");
|
||||
const imported = sessions.importSessions(r.filePaths[0], log);
|
||||
const onProgress = (p) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("provider-sync-progress", p);
|
||||
};
|
||||
const synced = await providerSync.syncProviderToCurrent(log, onProgress);
|
||||
return { ...imported, ...synced, restored: true };
|
||||
});
|
||||
});
|
||||
|
||||
// —— 会话记忆:状态 / 检索 / 开关 / 手动摘要 ——
|
||||
@@ -1275,11 +1642,12 @@ ipcMain.handle("provider-sync", async () => {
|
||||
const onProgress = (p) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send("provider-sync-progress", p);
|
||||
};
|
||||
return providerSync.syncProviderToCurrent(log, onProgress);
|
||||
return withStateWriteLock("同步会话账号", () => providerSync.syncProviderToCurrent(log, onProgress));
|
||||
});
|
||||
|
||||
// —— 本地路由 ——
|
||||
ipcMain.handle("router-status", async () => router.status());
|
||||
ipcMain.handle("router-default-config", async () => router.defaultConfigInfo());
|
||||
ipcMain.handle("router-upsert-group", async (_e, { group }) => {
|
||||
const s = router.upsertGroup(group);
|
||||
rebuildTrayMenu();
|
||||
@@ -1309,9 +1677,14 @@ ipcMain.handle("router-start", async () => {
|
||||
return router.status();
|
||||
});
|
||||
ipcMain.handle("router-stop", async () => {
|
||||
router.stop();
|
||||
const r = router.stopAndRestoreDirect();
|
||||
rebuildTrayMenu();
|
||||
return router.status();
|
||||
return { ...r, status: router.status() };
|
||||
});
|
||||
ipcMain.handle("router-disable-codex", async () => {
|
||||
const r = router.disableCodex();
|
||||
rebuildTrayMenu();
|
||||
return { ...r, status: router.status() };
|
||||
});
|
||||
ipcMain.handle("router-apply-codex", async () => {
|
||||
const r = router.applyToCodex();
|
||||
@@ -1374,6 +1747,34 @@ ipcMain.handle("canvas-run", (e, { kind, params }) =>
|
||||
);
|
||||
ipcMain.handle("canvas-groups", () => canvas.groups());
|
||||
ipcMain.handle("canvas-models", (_e, { groupId }) => canvas.listModels(groupId));
|
||||
ipcMain.handle("canvas-workspaces", () => ({ ok: true, workspaces: canvasStore.list() }));
|
||||
ipcMain.handle("canvas-workspace-create", (_e, { name } = {}) => {
|
||||
try { return { ok: true, workspace: canvasStore.create(name) }; }
|
||||
catch (err) { return { ok: false, error: String((err && err.message) || err) }; }
|
||||
});
|
||||
ipcMain.handle("canvas-workspace-load", (_e, { id } = {}) => {
|
||||
try { return { ok: true, workspace: canvasStore.load(id) }; }
|
||||
catch (err) { return { ok: false, error: String((err && err.message) || err) }; }
|
||||
});
|
||||
ipcMain.handle("canvas-workspace-rename", (_e, { id, name } = {}) => {
|
||||
try { return { ok: true, workspace: canvasStore.rename(id, name) }; }
|
||||
catch (err) { return { ok: false, error: String((err && err.message) || err) }; }
|
||||
});
|
||||
ipcMain.handle("canvas-workspace-save", (_e, { workspace } = {}) => {
|
||||
try { return { ok: true, workspace: canvasStore.save(workspace) }; }
|
||||
catch (err) { return { ok: false, error: String((err && err.message) || err) }; }
|
||||
});
|
||||
ipcMain.on("canvas-workspace-save-now", (_e, workspace) => {
|
||||
try { canvasStore.save(workspace); } catch {}
|
||||
});
|
||||
ipcMain.handle("canvas-media-cache", async (_e, payload = {}) => {
|
||||
try { return { ok: true, ...(await canvasStore.cacheMedia(payload)) }; }
|
||||
catch (err) { return { ok: false, error: String((err && err.message) || err) }; }
|
||||
});
|
||||
ipcMain.handle("canvas-media-read", async (_e, payload = {}) => {
|
||||
try { return { ok: true, ...(await canvasStore.readMedia(payload)) }; }
|
||||
catch (err) { return { ok: false, error: String((err && err.message) || err) }; }
|
||||
});
|
||||
|
||||
// —— 多 Agent 员工团队生成(需求 → 团队 → 打包 zip)——
|
||||
ipcMain.handle("team-generate", async (_e, params) => {
|
||||
@@ -1442,8 +1843,87 @@ ipcMain.handle("voice-connect", (e, opts) =>
|
||||
}, opts || {})
|
||||
);
|
||||
ipcMain.handle("voice-disconnect", () => voice.disconnect());
|
||||
ipcMain.handle("voice-restart-codex", async () => {
|
||||
try {
|
||||
await voice.disconnect();
|
||||
} catch (e) {
|
||||
log("重启 Codex:断开注入失败:" + (e && e.message ? e.message : e));
|
||||
}
|
||||
try {
|
||||
const stopped = await codexDesktop.killCodex();
|
||||
if (!stopped || !stopped.exited) {
|
||||
return { ok: false, error: "Codex 未完全退出,已停止重启以保护运行中会话" };
|
||||
}
|
||||
} catch (e) {
|
||||
return { ok: false, error: "退出 Codex 失败:" + (e && e.message ? e.message : e) };
|
||||
}
|
||||
try {
|
||||
const restarted = await voice.connect(null, {});
|
||||
return { ok: true, phase: restarted.phase, lastError: restarted.lastError };
|
||||
} catch (e) {
|
||||
return { ok: false, error: "重新启动 Codex 失败:" + (e && e.message ? e.message : e) };
|
||||
}
|
||||
});
|
||||
ipcMain.handle("voice-stt-info", () => voice.sttInfo());
|
||||
ipcMain.handle("voice-probe-stt", async () => voice.probeStt());
|
||||
voice.onSessionDelete(async (request) => {
|
||||
const payload = typeof request === "string" ? { kind: "delete-session", threadId: request } : (request || {});
|
||||
const requestId = String(payload.requestId || "");
|
||||
if (payload.kind === "delete-session-clipboard-prepare") {
|
||||
if (requestId) {
|
||||
sessionDeleteClipboard.set(requestId, { text: clipboard.readText(), expires: Date.now() + 5000 });
|
||||
setTimeout(() => sessionDeleteClipboard.delete(requestId), 6000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let threadId = String(payload.threadId || "");
|
||||
if (payload.kind === "delete-session-clipboard-read") {
|
||||
const saved = sessionDeleteClipboard.get(requestId);
|
||||
sessionDeleteClipboard.delete(requestId);
|
||||
const copied = clipboard.readText();
|
||||
if (saved && saved.expires > Date.now()) {
|
||||
try { clipboard.writeText(saved.text); } catch {}
|
||||
}
|
||||
const hit = copied.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
|
||||
if (!hit) throw new Error("未能从 Codex 原生菜单读取会话 ID,已停止删除");
|
||||
threadId = hit[0];
|
||||
}
|
||||
if (voiceSessionDeleteBusy) return;
|
||||
voiceSessionDeleteBusy = true;
|
||||
const item = sessions.findConversationById(threadId);
|
||||
if (!item) { voiceSessionDeleteBusy = false; throw new Error("未找到对应的本地会话,已停止删除"); }
|
||||
try {
|
||||
await withStateWriteLock("永久删除会话", async () => {
|
||||
const answer = await dialog.showMessageBox(mainWindow, {
|
||||
type: "warning",
|
||||
title: "彻底删除本地会话",
|
||||
message: "永久删除“" + (item.title || "未命名会话") + "”?",
|
||||
detail: "将退出 Codex,然后删除会话正文、归档副本、state SQLite 线程索引、标题索引和工具箱会话记忆。此操作不可恢复。",
|
||||
buttons: ["取消", "彻底删除"],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
});
|
||||
if (answer.response !== 1) return;
|
||||
voice.disconnect();
|
||||
try {
|
||||
const stopped = await codexDesktop.killCodex();
|
||||
if (!stopped || !stopped.exited) throw new Error("Codex 未完全退出,已停止删除以保护 SQLite");
|
||||
const result = await sessions.deleteConversation(item.path);
|
||||
try { await memory.deleteSession(result.id, result.cwd); } catch (e) { log("会话主体已删除,但工具箱记忆清理失败:" + (e.message || e)); }
|
||||
log("已彻底删除会话 " + result.id + ":正文 " + result.filesRemoved + ",SQLite 命中 " + result.stateRowsRemoved + " 个");
|
||||
} finally {
|
||||
try {
|
||||
const restarted = await voice.connect(null, {});
|
||||
if (!restarted || restarted.phase !== "connected") log("删除后重新启动 Codex 未连接:" + ((restarted && restarted.lastError) || "未知原因"));
|
||||
} catch (e) {
|
||||
log("删除后重新启动 Codex 失败:" + (e.message || e));
|
||||
}
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
voiceSessionDeleteBusy = false;
|
||||
}
|
||||
});
|
||||
|
||||
// —— Token 用量监控(注入 Codex 页面,复用 CDP 连接)——
|
||||
ipcMain.handle("token-status", async () => tokenWatch.tokenStatus());
|
||||
|
||||
Reference in New Issue
Block a user