From a74576c3e112cf3da01649c8b9d65ee7aa101ff2 Mon Sep 17 00:00:00 2001 From: mac Date: Fri, 11 Sep 2026 09:06:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8F=91=E5=B8=83=E7=83=AD=E6=9B=B4=20?= =?UTF-8?q?1.5.94=EF=BC=8C=E4=BF=AE=E5=A4=8D=E4=B8=AD=E8=BD=AC=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E4=BC=9A=E8=AF=9D=E5=B9=B6=E6=94=AF=E6=8C=81=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E6=B8=85=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 +- build/activation-guide.txt | 18 + build/fix-quarantine.command | 4 +- electron-builder.yml | 15 +- electron/lib/codex_desktop.js | 201 ++++++++++ electron/lib/deeplink.js | 54 +++ electron/lib/global_dictation.js | 656 +++++++++++++++++++++++++++++++ electron/lib/hotupdate.js | 7 + electron/lib/memory.js | 37 ++ electron/lib/provider_sync.js | 186 ++++++++- electron/lib/router.js | 331 ++++++++++++++-- electron/lib/sessions.js | 185 ++++++++- electron/lib/voice_cdp.js | 8 + electron/main.js | 231 ++++++++++- electron/preload.js | 1 + package-lock.json | 4 +- package.json | 4 +- renderer/app.js | 175 +++++++-- renderer/index.html | 21 +- renderer/styles.css | 110 ++++-- scripts/publish-hot.js | 4 + test/codex-data-dir.test.js | 108 ++++- test/codex-desktop.test.js | 32 ++ test/deeplink.test.js | 34 ++ test/global-dictation.test.js | 440 +++++++++++++++++++++ test/hotupdate.test.js | 43 ++ test/router.test.js | 380 +++++++++++++++++- test/session-restore.test.js | 77 +++- 28 files changed, 3217 insertions(+), 155 deletions(-) create mode 100644 build/activation-guide.txt create mode 100644 electron/lib/deeplink.js create mode 100644 electron/lib/global_dictation.js create mode 100644 test/codex-desktop.test.js create mode 100644 test/deeplink.test.js create mode 100644 test/global-dictation.test.js create mode 100644 test/hotupdate.test.js diff --git a/README.md b/README.md index 64ea268..6c97369 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,16 @@ npm run dist:win # 产出 dist/*Setup.exe 只有临时(ad-hoc)签名。macOS 对「从网上下载」的这类 app 会自动加隔离标记 (`com.apple.quarantine`) 并由 Gatekeeper 拦截,在 Apple 芯片上表现为「已损坏」。 -**解决**:把 app 拖到「应用程序」后,在「终端」执行一次(去掉隔离标记): +**解决**:把 app 拖到「应用程序」后,双击 DMG 里的 `fix-quarantine.command`,或在「终端」执行一次(去掉隔离标记): ```bash -xattr -cr "/Applications/Codex 插件市场.app" +xattr -cr "/Applications/TD工具箱.app" ``` 之后正常双击即可打开,只需做一次。(若提示权限不足,命令前加 `sudo ` 再回车输入开机密码。) +DMG 里同时放了 `激活教程.txt`,客户可以先看这个文件再操作。 + > 想彻底免掉这一步、让用户双击即开,需要 Apple Developer 账号($99/年)做 > Developer ID 签名 + 公证,届时在 `electron-builder.yml` 的 `mac` 段配置证书即可。 diff --git a/build/activation-guide.txt b/build/activation-guide.txt new file mode 100644 index 0000000..12c529a --- /dev/null +++ b/build/activation-guide.txt @@ -0,0 +1,18 @@ +TD工具箱 macOS 激活教程 + +如果你第一次打开时被 macOS 拦截,请按下面步骤操作: + +1. 把「TD工具箱.app」拖到「Applications」。 +2. 双击本 DMG 里下方的「fix-quarantine.command」。 +3. 如果系统提示需要权限,输入当前 Mac 的登录密码。 +4. 重新打开「TD工具箱.app」。 + +如果仍然提示「Apple 无法验证」: + +1. 先在终端里执行: + xattr -cr "/Applications/TD工具箱.app" +2. 再手动打开应用。 + +说明: +- 这个包使用的是临时签名,不是 Apple Developer 正式证书。 +- 小范围内部使用通常够用;如果要长期稳定发给客户,建议改成 Developer ID + notarization。 diff --git a/build/fix-quarantine.command b/build/fix-quarantine.command index 1aaaf12..da8ac49 100755 --- a/build/fix-quarantine.command +++ b/build/fix-quarantine.command @@ -2,10 +2,10 @@ # 双击本文件即可修复 macOS「已损坏,移到废纸篓」——去掉隔离标记(quarantine)。 # 只需运行一次。原理:本应用为免费分发、未做 Apple 公证,被系统加了隔离标记。 -APP_NAME="Codex 插件市场.app" +APP_NAME="TD工具箱.app" echo "===================================================" -echo " 修复 macOS「已损坏」提示 —— Codex 插件市场" +echo " 修复 macOS「已损坏」提示 —— TD工具箱" echo "===================================================" echo diff --git a/electron-builder.yml b/electron-builder.yml index e7d2dbb..40d6df5 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -6,6 +6,11 @@ directories: output: dist buildResources: build +protocols: + - name: TD ToolBox Import + schemes: + - tdtoolbox + # 无 Apple 证书时,electron-builder 会跳过签名、留下残缺封印; # 此钩子在打 DMG 前对 mac app 做完整 ad-hoc 重签名,修复封印。 afterPack: scripts/afterPack.js @@ -57,8 +62,8 @@ dmg: title: "${productName}-${arch}" # 加大窗口,容纳「修复已损坏」脚本 window: - width: 540 - height: 420 + width: 560 + height: 500 contents: # app 图标(左) - x: 140 @@ -74,6 +79,12 @@ dmg: y: 320 type: file path: build/fix-quarantine.command + # 激活教程(底部文本文件) + - x: 270 + y: 410 + type: file + path: build/activation-guide.txt + name: 激活教程.txt win: # 内置 sing-box 引擎(全局代理用) diff --git a/electron/lib/codex_desktop.js b/electron/lib/codex_desktop.js index 97ffe95..54ce303 100644 --- a/electron/lib/codex_desktop.js +++ b/electron/lib/codex_desktop.js @@ -12,6 +12,32 @@ const EXE_NAMES = process.platform === "win32" const WIN_PROCESS_NAMES = EXE_NAMES.filter((n) => /\.exe$/i.test(n)); let lastDiscovery = null; +const GLOBAL_DICTATION_HIDE_SCRIPT = ` +set hiddenCount to 0 +tell application "System Events" + set targetProcesses to every process whose name is "ChatGPT" or name is "Codex" + repeat with p in targetProcesses + tell p + repeat with w in windows + try + set titleText to title of w as text + set roleText to role of w as text + set subroleText to subrole of w as text + set windowSize to size of w + set widthValue to item 1 of windowSize as integer + set heightValue to item 2 of windowSize as integer + if titleText is "Codex" and roleText is "AXWindow" and subroleText is "AXDialog" and widthValue >= 500 and widthValue <= 900 and heightValue >= 60 and heightValue <= 120 then + set position of w to {-10000, -10000} + set hiddenCount to hiddenCount + 1 + end if + end try + end repeat + end tell + end repeat +end tell +return hiddenCount +`; + function existsFile(p) { try { return !!p && fs.statSync(p).isFile(); @@ -443,6 +469,179 @@ function codexRunning() { return false; } +function execFileText(file, args, options = {}) { + return new Promise((resolve, reject) => { + execFile(file, args, { + encoding: "utf-8", + windowsHide: true, + timeout: options.timeout === undefined + ? 2200 + : Math.max(1, Number(options.timeout) || 1), + signal: options.signal, + env: options.env || process.env, + }, (error, stdout) => { + if (error) { + error.stdout = stdout || error.stdout || ""; + reject(error); + return; + } + resolve(String(stdout || "")); + }); + }); +} + +function isCodexProcessCommand(command) { + const text = String(command || "").replace(/\s+/g, " ").trim(); + if (!text || isOwnWindowsAppCandidate(text, "")) return false; + return /(?:^|[\\/])(?:Codex|ChatGPT)\.app[\\/]/i.test(text) || + /(?:^|[\\/\s])(?:Codex|ChatGPT)(?:\.exe)?(?:\s|$)/i.test(text) || + /WindowsApps[\\/]OpenAI\.(?:Codex|CodexBeta|ChatGPT-Desktop)_/i.test(text); +} + +function hideGlobalDictationWindows(options = {}) { + if (process.platform !== "darwin") { + return Promise.resolve({ + ok: false, + status: "unsupported-platform", + hidden: 0, + error: "native global dictation window cleanup is only available on macOS", + }); + } + + const run = options.execFile || execFile; + return new Promise((resolve) => { + run("osascript", ["-e", GLOBAL_DICTATION_HIDE_SCRIPT], { + encoding: "utf-8", + windowsHide: true, + timeout: Math.max(500, Number(options.timeoutMs) || 5000), + signal: options.signal, + }, (error, stdout, stderr) => { + const output = String(stdout || "").trim(); + const hidden = /^\d+$/.test(output) ? Number(output) : 0; + if (error) { + resolve({ + ok: false, + status: "native-command-failed", + hidden, + error: String((error && error.message) || stderr || error), + }); + return; + } + resolve({ + ok: true, + status: hidden ? "hidden" : "not-found", + hidden, + error: "", + }); + }); + }); +} + +async function debugPortOwner(port, options = {}) { + const value = Number(port); + if (!Number.isInteger(value) || value < 1 || value > 65535) { + return { verified: false, owned: false, reason: "invalid-port", processes: [] }; + } + const signal = options.signal; + const totalTimeoutMs = Math.max(1, Number(options.timeoutMs) || 5000); + const deadline = Date.now() + totalTimeoutMs; + const commandOptions = (maxMs) => { + if (signal && signal.aborted) { + throw signal.reason instanceof Error ? signal.reason : new Error("debug port owner check aborted"); + } + const remaining = deadline - Date.now(); + if (remaining <= 0) { + const error = new Error("debug port owner check timed out"); + error.code = "ETIMEDOUT"; + throw error; + } + return { + timeout: Math.max(1, Math.min(maxMs, remaining)), + signal, + }; + }; + + if (process.platform === "darwin") { + try { + const output = await execFileText("lsof", [ + "-nP", + "-a", + `-iTCP:${value}`, + "-sTCP:LISTEN", + "-Fp", + ], commandOptions(2200)); + const pids = [...new Set( + output.split(/\r?\n/).filter((line) => /^p\d+$/.test(line)).map((line) => line.slice(1)), + )]; + const processes = []; + for (const pid of pids) { + try { + const command = (await execFileText( + "ps", + ["-p", pid, "-o", "command="], + commandOptions(2200), + )).trim(); + processes.push({ pid: Number(pid), command }); + } catch {} + } + return { + verified: true, + owned: processes.some((item) => isCodexProcessCommand(item.command)), + reason: processes.length ? "process-inspected" : "listener-not-found", + processes, + }; + } catch (e) { + return { + verified: false, + owned: false, + reason: String((e && e.message) || e), + processes: [], + }; + } + } + + if (process.platform === "win32") { + const script = [ + "$port=[int]$env:TD_CODEX_DEBUG_PORT", + "$rows=@()", + "Get-NetTCPConnection -State Listen -LocalPort $port -ErrorAction SilentlyContinue | ForEach-Object {", + " $p=Get-CimInstance Win32_Process -Filter ('ProcessId=' + $_.OwningProcess) -ErrorAction SilentlyContinue", + " if($p){ $rows += [pscustomobject]@{ProcessId=$p.ProcessId;Name=$p.Name;ExecutablePath=$p.ExecutablePath;CommandLine=$p.CommandLine} }", + "}", + "$rows|ConvertTo-Json -Depth 4 -Compress", + ].join(";"); + for (const shell of ["powershell.exe", "pwsh.exe"]) { + try { + const output = (await execFileText(shell, [ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + script, + ], { + ...commandOptions(3500), + env: { ...process.env, TD_CODEX_DEBUG_PORT: String(value) }, + })).trim(); + const parsed = output ? JSON.parse(output) : []; + const rows = Array.isArray(parsed) ? parsed : [parsed]; + const processes = rows.map((row) => ({ + pid: Number(row.ProcessId) || 0, + command: [row.Name, row.ExecutablePath, row.CommandLine].filter(Boolean).join(" "), + })); + return { + verified: true, + owned: processes.some((item) => isCodexProcessCommand(item.command)), + reason: processes.length ? "process-inspected" : "listener-not-found", + processes, + }; + } catch {} + } + return { verified: false, owned: false, reason: "process-inspection-failed", processes: [] }; + } + + return { verified: false, owned: false, reason: "unsupported-platform", processes: [] }; +} + function windowsProcessRows() { if (process.platform !== "win32") return []; const escapedNames = WIN_PROCESS_NAMES.map((name) => "'" + name.replace(/'/g, "''") + "'").join(","); @@ -532,6 +731,8 @@ module.exports = { discoveryReport, discoverySummary, codexRunning, + debugPortOwner, + hideGlobalDictationWindows, windowsProcessDiagnostics, killCodex, }; diff --git a/electron/lib/deeplink.js b/electron/lib/deeplink.js new file mode 100644 index 0000000..3f3b373 --- /dev/null +++ b/electron/lib/deeplink.js @@ -0,0 +1,54 @@ +// TD工具箱自定义协议解析。保持纯 Node 实现,便于单独测试。 +const { URL } = require("url"); + +const SCHEME = "tdtoolbox"; + +function trim(value) { + return String(value || "").trim(); +} + +function isHttpUrl(value) { + try { + const u = new URL(value); + return u.protocol === "https:" || u.protocol === "http:"; + } catch { + return false; + } +} + +function parseTdToolboxImport(rawUrl) { + let url; + try { + url = new URL(String(rawUrl || "")); + } catch { + return { ok: false, error: "深链格式无效" }; + } + if (url.protocol !== `${SCHEME}:`) return { ok: false, error: "深链协议无效" }; + if (url.host !== "v1" || url.pathname !== "/import") return { ok: false, error: "深链接口不存在" }; + + const q = url.searchParams; + const input = { + resource: trim(q.get("resource")), + app: trim(q.get("app")), + name: trim(q.get("name")), + endpoint: trim(q.get("endpoint")), + apiKey: trim(q.get("apiKey")), + model: trim(q.get("model")), + source: trim(q.get("source")), + sourceGroupId: trim(q.get("sourceGroupId")), + }; + + if (input.resource !== "provider") return { ok: false, error: "仅支持导入 provider 资源" }; + if (input.app !== "codex") return { ok: false, error: "仅支持 Codex 分组导入" }; + if (input.source !== "sub2api") return { ok: false, error: "仅支持 Sub2API 来源" }; + if (!input.name || input.name.length > 80) return { ok: false, error: "分组名称无效" }; + if (!isHttpUrl(input.endpoint)) return { ok: false, error: "API 地址必须是 http/https URL" }; + if (!input.apiKey || input.apiKey.length > 4096) return { ok: false, error: "API 密钥无效" }; + if (!input.model || input.model.length > 200) return { ok: false, error: "模型无效" }; + if (!/^[A-Za-z0-9_-]{1,128}$/.test(input.sourceGroupId)) return { ok: false, error: "来源分组 ID 无效" }; + + input.name = input.name.replace(/[\r\n\0]/g, ""); + return { ok: true, input }; +} + +module.exports = { SCHEME, parseTdToolboxImport }; diff --git a/electron/lib/global_dictation.js b/electron/lib/global_dictation.js new file mode 100644 index 0000000..42a1b4f --- /dev/null +++ b/electron/lib/global_dictation.js @@ -0,0 +1,656 @@ +"use strict"; + +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const KEEP_VISIBLE_KEY = "global-dictation-keep-visible"; +const GLOBAL_DICTATION_ROUTE = "/global-dictation"; +const DEFAULT_HTTP_TIMEOUT_MS = 1500; +const DEFAULT_OWNER_TIMEOUT_MS = 5000; +const DEFAULT_CLOSE_SETTLE_MS = 100; +const DEFAULT_CLOSE_RETRIES = 2; +const RETRYABLE_IO_CODES = new Set(["EACCES", "EBUSY", "EEXIST", "ENOTEMPTY", "EPERM"]); + +let codexHome = path.join(os.homedir(), ".codex"); +let watchedStateFile = ""; +let guardLog = () => {}; +let guardBusy = false; +let endpointOwnerVerifier = null; +const cleanupFlights = new Map(); + +function sleepSync(ms) { + if (!ms) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} + +function retryIo(fn) { + const delays = [0, 10, 25, 50, 100, 200]; + let lastError; + for (const delay of delays) { + if (delay) sleepSync(delay); + try { + return fn(); + } catch (e) { + lastError = e; + if (!RETRYABLE_IO_CODES.has(e && e.code)) throw e; + } + } + throw lastError; +} + +function setCodexDataDir(dir) { + if (typeof dir !== "string" || !dir.trim() || !path.isAbsolute(dir.trim())) { + throw new Error("Codex data directory must be an absolute path"); + } + codexHome = path.resolve(dir.trim()); +} + +function setEndpointOwnerVerifier(verifier) { + endpointOwnerVerifier = typeof verifier === "function" ? verifier : null; +} + +function stateFileFor(home = codexHome) { + return path.join(home, ".codex-global-state.json"); +} + +function acquireLock(file, waitMs = 600) { + const lockFile = file + ".td-dictation.lock"; + const deadline = Date.now() + Math.max(0, waitMs); + while (true) { + try { + const fd = fs.openSync(lockFile, "wx", 0o600); + return { fd, lockFile }; + } catch (e) { + if (!e || e.code !== "EEXIST") throw e; + try { + const ageMs = Date.now() - fs.statSync(lockFile).mtimeMs; + if (ageMs > 30000) { + fs.rmSync(lockFile, { force: true }); + continue; + } + } catch {} + if (Date.now() >= deadline) return null; + sleepSync(25); + } + } +} + +function releaseLock(lock) { + if (!lock) return; + try { fs.closeSync(lock.fd); } catch {} + try { fs.rmSync(lock.lockFile, { force: true }); } catch {} +} + +function writeCompleteTemp(file, content, mode, label) { + const tmp = `${file}.${process.pid}.${Date.now()}.${label}.tmp`; + const fd = fs.openSync(tmp, "wx", mode || 0o600); + try { + fs.writeFileSync(fd, content); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + try { fs.chmodSync(tmp, mode || 0o600); } catch {} + return tmp; +} + +function writeBackupOnce(file, content, mode) { + const backup = file + ".td-before-global-dictation.bak"; + let tmp = ""; + try { + tmp = writeCompleteTemp(backup, content, mode, "backup"); + fs.linkSync(tmp, backup); + } catch (e) { + if (!e || e.code !== "EEXIST") throw e; + } finally { + if (tmp) { + try { fs.rmSync(tmp, { force: true }); } catch {} + } + } + return backup; +} + +function isJsonWhitespaceByte(value) { + return value === 0x20 || value === 0x09 || value === 0x0a || value === 0x0d; +} + +function patchKeepVisibleFalse(file, original) { + const text = original.toString("latin1"); + const pattern = new RegExp(`"${KEEP_VISIBLE_KEY}"\\s*:\\s*true`, "g"); + let match; + let valueOffset = -1; + while ((match = pattern.exec(text))) { + valueOffset = match.index + match[0].lastIndexOf("true"); + } + if (valueOffset < 1 || !isJsonWhitespaceByte(original[valueOffset - 1])) { + return { ok: false, changed: false, reason: "unsafe-format" }; + } + + const patchOffset = valueOffset - 1; + let fd; + try { + fd = retryIo(() => fs.openSync(file, "r+")); + const current = retryIo(() => fs.readFileSync(fd)); + if (!current.equals(original)) { + return { ok: false, changed: false, reason: "concurrent-write" }; + } + const descriptorStat = fs.fstatSync(fd); + const pathStat = fs.statSync(file); + if (descriptorStat.dev !== pathStat.dev || descriptorStat.ino !== pathStat.ino) { + return { ok: false, changed: false, reason: "concurrent-write" }; + } + + const patch = Buffer.from("false", "ascii"); + const written = fs.writeSync(fd, patch, 0, patch.length, patchOffset); + if (written !== patch.length) throw new Error("global dictation state short write"); + fs.fsyncSync(fd); + } finally { + if (fd !== undefined) { + try { fs.closeSync(fd); } catch {} + } + } + + const saved = retryIo(() => fs.readFileSync(file)); + try { + const parsed = JSON.parse(saved.toString("utf8")); + if (parsed && parsed[KEEP_VISIBLE_KEY] === false) { + return { ok: true, changed: true, reason: "disabled" }; + } + } catch {} + return { ok: false, changed: false, reason: "concurrent-write" }; +} + +function sleepAsync(ms, signal) { + return new Promise((resolve, reject) => { + if (signal && signal.aborted) { + reject(signal.reason instanceof Error ? signal.reason : abortError("request aborted")); + return; + } + const timer = setTimeout(done, Math.max(0, Number(ms) || 0)); + function done() { + if (signal) signal.removeEventListener("abort", aborted); + resolve(); + } + function aborted() { + clearTimeout(timer); + signal.removeEventListener("abort", aborted); + reject(signal.reason instanceof Error ? signal.reason : abortError("request aborted")); + } + if (signal) signal.addEventListener("abort", aborted, { once: true }); + }); +} + +function createFileIfMissing(file, content, mode) { + let tmp = ""; + try { + tmp = writeCompleteTemp(file, content, mode, "td-dictation-create"); + try { + fs.linkSync(tmp, file); + } catch (e) { + if (e && e.code === "EEXIST") return false; + throw e; + } + const written = retryIo(() => fs.readFileSync(file)); + return written.equals(content); + } finally { + if (tmp) { + try { fs.rmSync(tmp, { force: true }); } catch {} + } + } +} + +function rewriteStateAtomically(file, original, state, mode) { + const next = Buffer.from(JSON.stringify(state), "utf8"); + if (next.equals(original)) { + return { ok: true, changed: false, reason: "already-disabled" }; + } + + let tmp = ""; + try { + const current = retryIo(() => fs.readFileSync(file)); + if (!current.equals(original)) { + return { ok: false, changed: false, reason: "concurrent-write" }; + } + tmp = writeCompleteTemp(file, next, mode, "td-dictation-atomic"); + const beforeRename = retryIo(() => fs.readFileSync(file)); + if (!beforeRename.equals(original)) { + return { ok: false, changed: false, reason: "concurrent-write" }; + } + fs.renameSync(tmp, file); + tmp = ""; + + const saved = retryIo(() => fs.readFileSync(file)); + const parsed = JSON.parse(saved.toString("utf8")); + if (parsed && parsed[KEEP_VISIBLE_KEY] === false) { + return { ok: true, changed: true, reason: "disabled-atomic" }; + } + return { ok: false, changed: false, reason: "verification-failed" }; + } finally { + if (tmp) { + try { fs.rmSync(tmp, { force: true }); } catch {} + } + } +} + +function disableKeepVisible(options = {}) { + const home = options.codexHome ? path.resolve(options.codexHome) : codexHome; + const file = stateFileFor(home); + + let lock; + try { + lock = acquireLock(file, options.lockWaitMs); + if (!lock) return { ok: false, changed: false, reason: "locked", file }; + + for (let attempt = 0; attempt < 5; attempt++) { + if (!fs.existsSync(file)) { + const next = Buffer.from(JSON.stringify({ [KEEP_VISIBLE_KEY]: false }, null, 2) + "\n", "utf8"); + if (createFileIfMissing(file, next, 0o600)) { + return { ok: true, changed: true, reason: "created-disabled", file }; + } + continue; + } + + const original = retryIo(() => fs.readFileSync(file)); + let state; + try { + state = JSON.parse(original.toString("utf8")); + } catch (e) { + return { ok: false, changed: false, reason: "invalid-json", error: e.message, file }; + } + if (!state || typeof state !== "object" || Array.isArray(state)) { + return { ok: false, changed: false, reason: "invalid-state", file }; + } + if (state[KEEP_VISIBLE_KEY] === false) { + return { ok: true, changed: false, reason: "already-disabled", file }; + } + + state[KEEP_VISIBLE_KEY] = false; + const mode = fs.statSync(file).mode & 0o777; + writeBackupOnce(file, original, mode); + const patched = patchKeepVisibleFalse(file, original); + if (patched.ok) return { ...patched, file }; + if (patched.reason === "unsafe-format") { + const rewritten = rewriteStateAtomically(file, original, state, mode); + if (rewritten.ok) return { ...rewritten, file }; + if (rewritten.reason === "concurrent-write") continue; + return { ...rewritten, file }; + } + if (patched.reason === "concurrent-write") continue; + return { ...patched, file }; + } + return { ok: false, changed: false, reason: "concurrent-write", file }; + } catch (e) { + return { + ok: false, + changed: false, + reason: "write-failed", + error: String((e && e.message) || e), + file, + }; + } finally { + releaseLock(lock); + } +} + +function decodeRoute(value) { + let route = String(value || "").trim(); + for (let i = 0; i < 2 && /%[0-9a-f]{2}/i.test(route); i++) { + try { route = decodeURIComponent(route); } catch { break; } + } + if (route && !route.startsWith("/")) route = "/" + route; + return route.toLowerCase(); +} + +function parseCodexPageUrl(rawUrl) { + try { + const url = new URL(String(rawUrl || "")); + const pathname = url.pathname.toLowerCase(); + if ( + url.protocol !== "app:" || + url.hostname !== "-" || + (pathname !== "/index.html" && pathname !== "/index.html/") + ) return null; + return url; + } catch {} + return null; +} + +function routeInfo(rawUrl) { + const url = parseCodexPageUrl(rawUrl); + if (!url) return { url: null, present: false, route: "" }; + for (const [key, value] of url.searchParams.entries()) { + if (key.toLowerCase() === "initialroute") { + return { url, present: true, route: decodeRoute(value) }; + } + } + return { url, present: false, route: "" }; +} + +function initialRoute(rawUrl) { + return routeInfo(rawUrl).route; +} + +function isCodexMainTarget(target) { + if (!target || String(target.type || "").toLowerCase() !== "page") return false; + const info = routeInfo(target.url); + return !!info.url && !info.present; +} + +function isGlobalDictationTarget(target) { + if (!target || String(target.type || "").toLowerCase() !== "page") return false; + const info = routeInfo(target.url); + return !!info.url && info.present && info.route === GLOBAL_DICTATION_ROUTE; +} + +async function fetchWithTimeout(fetchImpl, url, timeoutMs, signal) { + return runWithTimeout( + (requestSignal) => fetchImpl(url, { signal: requestSignal }), + timeoutMs, + signal, + ); +} + +function abortError(message, code) { + const error = new Error(message); + error.name = "AbortError"; + if (code) error.code = code; + return error; +} + +async function runWithTimeout(task, timeoutMs, signal) { + const controller = new AbortController(); + let timer; + let abortListener; + const interrupted = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = abortError("request timed out", "ETIMEDOUT"); + controller.abort(error); + reject(error); + }, Math.max(1, Number(timeoutMs) || DEFAULT_HTTP_TIMEOUT_MS)); + if (signal) { + abortListener = () => { + const reason = signal.reason instanceof Error + ? signal.reason + : abortError(signal.reason ? String(signal.reason) : "request aborted"); + controller.abort(reason); + reject(reason); + }; + if (signal.aborted) abortListener(); + else signal.addEventListener("abort", abortListener, { once: true }); + } + }); + try { + return await Promise.race([ + Promise.resolve().then(() => task(controller.signal)), + interrupted, + ]); + } finally { + clearTimeout(timer); + if (signal && abortListener) signal.removeEventListener("abort", abortListener); + } +} + +async function listTargets(port, options = {}) { + const fetchImpl = options.fetchImpl || global.fetch; + if (typeof fetchImpl !== "function") { + return { available: false, status: "failed", targets: [], error: "fetch unavailable" }; + } + try { + const { response, targets } = await runWithTimeout( + async (requestSignal) => { + const response = await fetchImpl(`http://127.0.0.1:${Number(port)}/json`, { + signal: requestSignal, + }); + const targets = response && response.ok ? await response.json() : null; + return { response, targets }; + }, + options.timeoutMs || DEFAULT_HTTP_TIMEOUT_MS, + options.signal, + ); + if (!response || !response.ok) { + return { + available: false, + status: "port-unavailable", + targets: [], + error: response ? `HTTP ${response.status || "error"}` : "empty response", + }; + } + if (!Array.isArray(targets)) { + return { available: false, status: "failed", targets: [], error: "invalid target list" }; + } + return { available: true, status: "available", targets, error: "" }; + } catch (e) { + const aborted = !!(options.signal && options.signal.aborted); + const timedOut = !!(e && e.code === "ETIMEDOUT"); + return { + available: false, + status: aborted ? "aborted" : timedOut ? "timeout" : "port-unavailable", + targets: [], + error: String((e && e.message) || e), + }; + } +} + +async function runCloseGlobalDictationTargets(port, options = {}) { + const fetchImpl = options.fetchImpl || global.fetch; + const listed = Array.isArray(options.targets) + ? { available: true, status: "available", targets: options.targets, error: "" } + : await listTargets(port, options); + const matches = listed.targets.filter(isGlobalDictationTarget); + const result = { + ok: listed.available, + status: listed.status, + available: listed.available, + matched: matches.length, + closed: 0, + remaining: listed.available ? matches.length : null, + targetIds: matches.map((target) => target.id).filter(Boolean), + errors: [], + }; + if (!listed.available || typeof fetchImpl !== "function") { + result.status = listed.status || "port-unavailable"; + if (listed.error) result.errors.push(listed.error); + return result; + } + if (!matches.length) { + result.status = "not-found"; + result.remaining = 0; + return result; + } + if (!listed.targets.some(isCodexMainTarget)) { + const verifier = options.endpointOwnerVerifier || endpointOwnerVerifier; + let ownership = null; + if (typeof verifier === "function") { + try { + ownership = await runWithTimeout( + (requestSignal) => verifier(Number(port), { + signal: requestSignal, + timeoutMs: options.ownerTimeoutMs || DEFAULT_OWNER_TIMEOUT_MS, + }), + options.ownerTimeoutMs || DEFAULT_OWNER_TIMEOUT_MS, + options.signal, + ); + } catch (e) { + ownership = { + verified: false, + owned: false, + reason: String((e && e.message) || e), + }; + } + } + const owned = ownership === true || !!(ownership && ownership.owned); + if (!owned) { + result.ok = false; + result.status = ownership && ownership.verified ? "foreign-endpoint" : "unverified-endpoint"; + result.errors.push((ownership && ownership.reason) || "Codex endpoint ownership could not be verified"); + return result; + } + } + + const closeResults = await Promise.all(matches.map(async (target) => { + if (!target.id) { + return { closed: false, error: "global dictation target has no id" }; + } + try { + const response = await fetchWithTimeout( + fetchImpl, + `http://127.0.0.1:${Number(port)}/json/close/${encodeURIComponent(target.id)}`, + options.timeoutMs || DEFAULT_HTTP_TIMEOUT_MS, + options.signal, + ); + if (response && response.ok) return { closed: true, error: "" }; + return { closed: false, error: `failed to close target ${target.id}` }; + } catch (e) { + return { closed: false, error: String((e && e.message) || e) }; + } + })); + result.closed = closeResults.filter((item) => item.closed).length; + result.errors.push(...closeResults.map((item) => item.error).filter(Boolean)); + if (result.errors.length) { + result.ok = false; + result.status = options.signal && options.signal.aborted ? "aborted" : "failed"; + return result; + } + + const settleMs = options.settleMs === undefined + ? DEFAULT_CLOSE_SETTLE_MS + : Math.max(0, Number(options.settleMs) || 0); + if (settleMs) { + try { + await sleepAsync(settleMs, options.signal); + } catch (e) { + result.ok = false; + result.status = "aborted"; + result.remaining = null; + result.errors.push(String((e && e.message) || e)); + return result; + } + } + + const verified = await listTargets(port, options); + if (!verified.available) { + result.ok = false; + result.status = verified.status === "aborted" ? "aborted" : "verification-failed"; + result.remaining = null; + if (verified.error) result.errors.push(verified.error); + return result; + } + const remaining = verified.targets.filter(isGlobalDictationTarget); + result.remaining = remaining.length; + if (remaining.length) { + result.ok = false; + result.status = "still-present"; + result.targetIds = remaining.map((target) => target.id).filter(Boolean); + return result; + } + result.status = "closed"; + return result; +} + +async function runCloseGlobalDictationWithRetry(port, options = {}) { + const retries = options.retries === undefined + ? DEFAULT_CLOSE_RETRIES + : Math.max(0, Number(options.retries) || 0); + const retryDelayMs = options.retryDelayMs === undefined + ? 120 + : Math.max(0, Number(options.retryDelayMs) || 0); + let totalClosed = 0; + let lastResult = null; + + for (let attempt = 0; attempt <= retries; attempt++) { + const attemptOptions = attempt === 0 + ? options + : { ...options, targets: undefined }; + lastResult = await runCloseGlobalDictationTargets(port, attemptOptions); + totalClosed += lastResult.closed || 0; + if (lastResult.status !== "still-present") { + return { ...lastResult, closed: totalClosed, attempts: attempt + 1 }; + } + if (attempt < retries && retryDelayMs) { + try { + await sleepAsync(retryDelayMs, options.signal); + } catch (e) { + return { + ...lastResult, + ok: false, + status: "aborted", + closed: totalClosed, + remaining: null, + attempts: attempt + 1, + errors: [...lastResult.errors, String((e && e.message) || e)], + }; + } + } + } + return { ...lastResult, closed: totalClosed, attempts: retries + 1 }; +} + +function closeGlobalDictationTargets(port, options = {}) { + const hasCustomOptions = + !!options.fetchImpl || + !!options.signal || + !!options.endpointOwnerVerifier || + Array.isArray(options.targets) || + options.timeoutMs !== undefined || + options.ownerTimeoutMs !== undefined || + options.settleMs !== undefined || + options.retries !== undefined || + options.retryDelayMs !== undefined; + if (hasCustomOptions) return runCloseGlobalDictationWithRetry(port, options); + const key = String(Number(port)); + if (cleanupFlights.has(key)) return cleanupFlights.get(key); + const flight = runCloseGlobalDictationWithRetry(port, options).finally(() => { + if (cleanupFlights.get(key) === flight) cleanupFlights.delete(key); + }); + cleanupFlights.set(key, flight); + return flight; +} + +function enforceGuard() { + if (guardBusy) return; + guardBusy = true; + try { + const result = disableKeepVisible(); + if (result.changed) guardLog("已关闭 Codex 全局听写栏常驻显示"); + else if (!result.ok && result.reason !== "locked") { + guardLog(`关闭 Codex 全局听写栏常驻显示失败:${result.error || result.reason}`); + } + } finally { + guardBusy = false; + } +} + +function startGuard(options = {}) { + stopGuard(); + if (options.codexHome) setCodexDataDir(options.codexHome); + guardLog = typeof options.log === "function" ? options.log : () => {}; + watchedStateFile = stateFileFor(); + const initial = disableKeepVisible(); + fs.watchFile(watchedStateFile, { interval: options.intervalMs || 2000, persistent: false }, enforceGuard); + return initial; +} + +function stopGuard() { + if (watchedStateFile) { + try { fs.unwatchFile(watchedStateFile, enforceGuard); } catch {} + } + watchedStateFile = ""; + guardLog = () => {}; +} + +module.exports = { + KEEP_VISIBLE_KEY, + GLOBAL_DICTATION_ROUTE, + setCodexDataDir, + setEndpointOwnerVerifier, + disableKeepVisible, + startGuard, + stopGuard, + isCodexMainTarget, + isGlobalDictationTarget, + closeGlobalDictationTargets, + _initialRoute: initialRoute, + _listTargets: listTargets, + _stateFileFor: stateFileFor, +}; diff --git a/electron/lib/hotupdate.js b/electron/lib/hotupdate.js index 57f5c5b..81c9091 100644 --- a/electron/lib/hotupdate.js +++ b/electron/lib/hotupdate.js @@ -64,11 +64,18 @@ function evaluate(manifest, appVersion, bundledHotVersion, hotBase) { const newer = cmp(manifest.contentVersion, effective) > 0; const minOk = cmp(appVersion, manifest.minAppVersion || "0") >= 0; const needFull = !!manifest.requiresFull || !minOk; + const fullAppVersion = manifest.fullAppVersion || ""; + // 兼容协议注册等必须由完整安装包完成的场景: + // 旧版先安装热更,热更生效后再引导安装同版本完整包。 + const needFullAfterHot = !!manifest.requiresFullAfterHot && + (!fullAppVersion || cmp(appVersion, fullAppVersion) < 0); return { hasHot: newer, needFull: newer && needFull, // 有新内容但当前原生版本不够 → 必须下完整包 canHot: newer && minOk && !manifest.requiresFull, + needFullAfterHot, contentVersion: manifest.contentVersion, + fullAppVersion: fullAppVersion || null, minAppVersion: manifest.minAppVersion || null, notes: manifest.notes || "", mandatory: !!manifest.mandatory, diff --git a/electron/lib/memory.js b/electron/lib/memory.js index f31d3b9..87a16fc 100644 --- a/electron/lib/memory.js +++ b/electron/lib/memory.js @@ -964,6 +964,42 @@ async function deleteRow(id) { notify(); } +async function clearAllSessions() { + await ensureDb(); + const rows = all("SELECT id, cwd, rollout_path FROM sessions"); + const scopes = Array.from(new Set(rows.map((row) => row.cwd).filter(Boolean))); + for (const row of rows) { + deletedSessionIds.add(String(row.id || "")); + if (row.rollout_path) { + dirty.delete(row.rollout_path); + seenRollouts.delete(row.rollout_path); + } + } + queue.length = 0; + const snapshot = Buffer.from(db.export()); + try { + db.run("BEGIN"); + db.run("DELETE FROM sessions"); + // 保留 pinned=1 的长期身份/偏好事实;清掉由会话产生的普通决策与项目摘要。 + db.run("DELETE FROM core_decisions WHERE COALESCE(pinned,0)<>1"); + db.run("DELETE FROM core"); + db.run("COMMIT"); + flushDbStrict(); + } catch (e) { + try { db.run("ROLLBACK"); } catch {} + try { db.close(); } catch {} + db = new SQLmod.Database(snapshot); + throw e; + } + for (const scope of scopes) { + try { fs.rmSync(archivePath(scope), { force: true }); } catch {} + try { removeProjectCore(scope); } catch (e) { log("清空会话记忆块失败:" + (e.message || e)); } + try { removeGeneratedHandoff(scope); } catch (e) { log("清空会话交接文件失败:" + (e.message || e)); } + } + notify(); + return { count: rows.length, scopes: scopes.length }; +} + async function deleteSession(id, cwd) { deletedSessionIds.add(String(id || "")); await ensureDb(); @@ -1019,6 +1055,7 @@ module.exports = { resummarize, deleteRow, deleteSession, + clearAllSessions, handoffPath, renderProjectCore, writeProjectCore, diff --git a/electron/lib/provider_sync.js b/electron/lib/provider_sync.js index 404b2c7..e0d4ee9 100644 --- a/electron/lib/provider_sync.js +++ b/electron/lib/provider_sync.js @@ -15,6 +15,7 @@ let GLOBAL_STATE = path.join(CODEX_HOME, ".codex-global-state.json"); // Codex 桌面端侧栏现在优先读这个本地目录库,而不是直接扫 state_*.sqlite。 // 只改 threads / rollout / session_index 而不同步这里,重启后侧栏仍会空。 let LOCAL_CATALOG_DB = path.join(CODEX_HOME, "sqlite", "codex-dev.db"); +const STATE_DB_UPSERT_BATCH_SIZE = 200; function setCodexDataDir(dir) { if (typeof dir !== "string" || !path.isAbsolute(dir)) throw new Error("Codex 数据目录必须是绝对路径"); @@ -351,7 +352,12 @@ function threadValue(record, target, column) { function threadUpdateSet(columns, target, parameterized = false) { return columns.filter((c) => c !== "id").map((c) => { if (c === "model_provider") return `${c}=${parameterized ? "?" : sql(target)}`; - if (c === "has_user_event") return `${c}=CASE WHEN excluded.${c}=1 THEN 1 ELSE threads.${c} END`; + if (c === "has_user_event") { + const subagent = columns.includes("thread_source") + ? "LOWER(COALESCE(excluded.thread_source,''))='subagent'" + : "0"; + return `${c}=CASE WHEN ${subagent} THEN 0 WHEN excluded.${c}=1 THEN 1 ELSE threads.${c} END`; + } if (["cwd", "first_user_message", "preview", "thread_source"].includes(c)) { return `${c}=CASE WHEN COALESCE(excluded.${c},'')<>'' THEN excluded.${c} ELSE threads.${c} END`; } @@ -449,13 +455,21 @@ function readRolloutRecord(file, titles) { if (!rec.first_user_message) rec.first_user_message = firstText(p.message || p.input).trim(); } } + if ( + String(rec.thread_source || "").toLowerCase() === "subagent" + || (rec.source && typeof rec.source === "object" && rec.source.subagent) + || (typeof rec.source === "string" && /"subagent"\s*:/.test(rec.source)) + ) { + rec.thread_source = "subagent"; + rec.has_user_event = 0; + } rec.updated_at_ms = Math.max(rec.updated_at_ms, rec.created_at_ms); rec.title = cleanThreadTitle(titles[rec.id] || "", rec.first_user_message, 80) || "(无标题对话)"; rec.preview = cleanUserVisibleText(rec.first_user_message).replace(/\s+/g, " ").trim().slice(0, 200); return rec.id ? rec : null; } -function buildThreadUpsertSql(records, target, cols) { +function buildThreadUpsertSql(records, target, cols, opts = {}) { const desired = threadColumns(cols); const updateSet = threadUpdateSet(desired, target); const lines = ["BEGIN;"]; @@ -465,12 +479,20 @@ function buildThreadUpsertSql(records, target, cols) { `ON CONFLICT(id) DO UPDATE SET ${updateSet.join(",")};` ); } - // 【增强】确保数据库中所有会话的 provider 都更新为目标(兜底逻辑) - lines.push(`UPDATE threads SET model_provider=${sql(target)} WHERE COALESCE(model_provider,'')<>${sql(target)};`); + if (opts.updateAllProviders !== false) { + // 【增强】确保数据库中所有会话的 provider 都更新为目标(兜底逻辑) + lines.push(`UPDATE threads SET model_provider=${sql(target)} WHERE COALESCE(model_provider,'')<>${sql(target)};`); + } lines.push("COMMIT;"); return lines.join("\n") + "\n"; } +function chunksOf(items, size) { + const chunks = []; + for (let i = 0; i < items.length; i += size) chunks.push(items.slice(i, i + size)); + return chunks; +} + function updateProjectState(records, log = () => {}) { if (!fs.existsSync(GLOBAL_STATE)) return { addedProjects: 0, removedProjectless: 0 }; let state; @@ -580,6 +602,7 @@ function rebuildLocalThreadCatalog(records, target, log = () => {}) { const seen = new Set(); for (const r of records) { if (!r || !r.id || seen.has(r.id) || r.archived) continue; + if (String(r.thread_source || "").toLowerCase() === "subagent") continue; const hasText = !!(r.first_user_message || r.preview || (r.title && r.title !== "(无标题对话)")); const hasUser = !!r.has_user_event || hasText; if (!hasUser) continue; @@ -715,6 +738,7 @@ async function rebuildLocalThreadCatalogAsync(records, target, log = () => {}) { const seen = new Set(); for (const r of records) { if (!r || !r.id || seen.has(r.id) || r.archived) continue; + if (String(r.thread_source || "").toLowerCase() === "subagent") continue; const hasText = !!(r.first_user_message || r.preview || (r.title && r.title !== "(无标题对话)")); if (!(r.has_user_event || hasText)) continue; seen.add(r.id); @@ -823,6 +847,134 @@ function forceHasUserEventSql(userEventIds) { .join("\n"); } +function forceSubagentNoUserEventSql(columns) { + if (!columns || !columns.has("has_user_event")) return ""; + const checks = []; + if (columns.has("thread_source")) checks.push("LOWER(COALESCE(thread_source,''))='subagent'"); + if (columns.has("source")) checks.push("LOWER(COALESCE(source,'')) LIKE '%\"subagent\"%'"); + if (!checks.length) return ""; + return `UPDATE threads SET has_user_event=0 WHERE COALESCE(has_user_event,0)<>0 AND (${checks.join(" OR ")});`; +} + +function backupSqlite3(dbPath, suffix) { + const backupDir = path.join(CODEX_HOME, "backups_state", "visibility-repair"); + fs.mkdirSync(backupDir, { recursive: true }); + const backupPath = path.join(backupDir, `${path.basename(dbPath)}.${suffix}.bak`); + const escaped = backupPath.replace(/'/g, "''"); + runSqlite3(dbPath, `.timeout 5000\n.backup '${escaped}'\n`); + return backupPath; +} + +// 旧版本曾把 subagent 当成顶层用户会话写入 state/catalog。这里做轻量幂等修复, +// 不扫描或改写大体积 rollout;sqlite3 可用时允许与 Codex 的 WAL 连接并发事务。 +// Windows 无 sqlite3 CLI 且 Codex 正在运行时不冒险整库覆盖,等下次 Codex 退出后再修。 +async function repairLegacySessionVisibility(log = () => {}) { + const stateDbNames = (() => { + try { return fs.readdirSync(CODEX_HOME).filter((name) => /^state_.*\.sqlite$/.test(name)); } catch { return []; } + })(); + const suffix = ts(); + const result = { stateRows: 0, catalogRows: 0, backups: [], skipped: false }; + + if (sqlite3Available()) { + for (const name of stateDbNames) { + const dbPath = path.join(CODEX_HOME, name); + const cols = sqliteCols(dbPath); + const clearSql = forceSubagentNoUserEventSql(cols); + if (!clearSql) continue; + const before = Number(runSqlite3( + dbPath, + `SELECT COUNT(*) FROM threads WHERE COALESCE(has_user_event,0)<>0 AND (` + + `${cols.has("thread_source") ? "LOWER(COALESCE(thread_source,''))='subagent'" : "0"}` + + `${cols.has("source") ? `${cols.has("thread_source") ? " OR " : ""}LOWER(COALESCE(source,'')) LIKE '%\"subagent\"%'` : ""}` + + ");\n" + ).trim() || 0); + if (!before) continue; + result.backups.push(backupSqlite3(dbPath, suffix)); + runSqlite3(dbPath, `.timeout 5000\nBEGIN IMMEDIATE;\n${clearSql}\nCOMMIT;\n`); + result.stateRows += before; + } + if (fs.existsSync(LOCAL_CATALOG_DB)) { + const catalogCols = new Set( + runSqlite3(LOCAL_CATALOG_DB, "PRAGMA table_info(local_thread_catalog);\n") + .split("\n").map((line) => line.split("|")[1]).filter(Boolean) + ); + if (catalogCols.has("thread_source")) { + const before = Number(runSqlite3( + LOCAL_CATALOG_DB, + "SELECT COUNT(*) FROM local_thread_catalog WHERE LOWER(COALESCE(thread_source,''))='subagent';\n" + ).trim() || 0); + if (before) { + result.backups.push(backupSqlite3(LOCAL_CATALOG_DB, suffix)); + runSqlite3( + LOCAL_CATALOG_DB, + ".timeout 5000\nBEGIN IMMEDIATE;\n" + + "DELETE FROM local_thread_catalog WHERE LOWER(COALESCE(thread_source,''))='subagent';\n" + + "UPDATE local_thread_catalog_metadata SET catalog_revision=COALESCE(catalog_revision,0)+1 WHERE id=1;\n" + + "COMMIT;\n" + ); + result.catalogRows = before; + } + } + } + if (result.stateRows || result.catalogRows) { + log(`启动修复会话目录:state ${result.stateRows} 条,侧栏移除 subagent ${result.catalogRows} 条`); + } + return result; + } + + if (codexRunning()) { + result.skipped = true; + result.reason = "Codex 正在运行且系统没有 sqlite3,已延后到下次启动"; + log(`启动修复会话目录跳过:${result.reason}`); + return result; + } + + const SQL = await loadSqlJs(); + for (const name of stateDbNames) { + const dbPath = path.join(CODEX_HOME, name); + const db = new SQL.Database(fs.readFileSync(dbPath)); + try { + const cols = sqlJsTableColumns(db, "threads"); + const clearSql = forceSubagentNoUserEventSql(cols); + if (!clearSql) continue; + db.run(clearSql); + const changed = db.getRowsModified(); + if (!changed) continue; + const backupPath = `${dbPath}.${suffix}.bak`; + fs.copyFileSync(dbPath, backupPath); + result.backups.push(backupPath); + fs.writeFileSync(dbPath, Buffer.from(db.export())); + result.stateRows += changed; + } finally { + db.close(); + } + } + if (fs.existsSync(LOCAL_CATALOG_DB)) { + const db = new SQL.Database(fs.readFileSync(LOCAL_CATALOG_DB)); + try { + const cols = sqlJsTableColumns(db, "local_thread_catalog"); + if (cols.has("thread_source")) { + db.run("DELETE FROM local_thread_catalog WHERE LOWER(COALESCE(thread_source,''))='subagent'"); + const changed = db.getRowsModified(); + if (changed) { + try { db.run("UPDATE local_thread_catalog_metadata SET catalog_revision=COALESCE(catalog_revision,0)+1 WHERE id=1"); } catch {} + const backupPath = `${LOCAL_CATALOG_DB}.${suffix}.bak`; + fs.copyFileSync(LOCAL_CATALOG_DB, backupPath); + result.backups.push(backupPath); + fs.writeFileSync(LOCAL_CATALOG_DB, Buffer.from(db.export())); + result.catalogRows = changed; + } + } + } finally { + db.close(); + } + } + if (result.stateRows || result.catalogRows) { + log(`启动修复会话目录:state ${result.stateRows} 条,侧栏移除 subagent ${result.catalogRows} 条`); + } + return result; +} + function sqlJsTableColumns(db, table) { const result = db.exec(`PRAGMA table_info("${String(table).replace(/"/g, '""')}")`); if (!result.length) return new Set(); @@ -921,6 +1073,10 @@ function upsertStateDbSqlJs(db, records, target, userEventIds, cwdById, log = () db.run("UPDATE threads SET has_user_event=1 WHERE id=? AND COALESCE(has_user_event,0)<>1", [id]); ueRows += db.getRowsModified(); } + const checks = []; + if (cols.has("thread_source")) checks.push("LOWER(COALESCE(thread_source,''))='subagent'"); + if (cols.has("source")) checks.push("LOWER(COALESCE(source,'')) LIKE '%\"subagent\"%'"); + if (checks.length) db.run(`UPDATE threads SET has_user_event=0 WHERE COALESCE(has_user_event,0)<>0 AND (${checks.join(" OR ")})`); } if (cols.has("cwd")) { for (const [id, cwd] of Object.entries(cwdById || {})) { @@ -986,8 +1142,6 @@ async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) { continue; } if (!lines.length) continue; - const blob = lines.join(""); - const hasUE = blob.includes('"user_message"') || blob.includes('"user_input"'); let tid = null; let rewrote = false; for (let i = 0; i < lines.length; i++) { @@ -1015,10 +1169,10 @@ async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) { fs.writeFileSync(file, lines.join(""), "utf-8"); changedFiles++; } - if (tid && hasUE) userEventIds.add(tid); const rec = readRolloutRecord(file, titles); if (rec) { rec.model_provider = target; + if (rec.has_user_event) userEventIds.add(rec.id); records.push(rec); } } @@ -1063,9 +1217,17 @@ async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) { const beforeProvider = Number(runSqlite3(dbPath, `SELECT COUNT(*) FROM threads WHERE COALESCE(model_provider,'')<>${sql(target)};\n`).trim() || 0); const beforeUserEvent = Number(runSqlite3(dbPath, "SELECT COUNT(*) FROM threads WHERE COALESCE(has_user_event,0)<>1;\n").trim() || 0); const beforeRows = Number(runSqlite3(dbPath, "SELECT COUNT(*) FROM threads;\n").trim() || 0); - runSqlite3(dbPath, buildThreadUpsertSql(records, target, cols)); + let batchDone = 0; + for (const batch of chunksOf(records, STATE_DB_UPSERT_BATCH_SIZE)) { + batchDone += batch.length; + onProgress({ phase: `更新数据库 ${name}`, done: batchDone, total: records.length }); + runSqlite3(dbPath, buildThreadUpsertSql(batch, target, cols, { updateAllProviders: false })); + } + runSqlite3(dbPath, `BEGIN;\nUPDATE threads SET model_provider=${sql(target)} WHERE COALESCE(model_provider,'')<>${sql(target)};\nCOMMIT;\n`); // 兜底:强制把 rollout 已确认有用户消息的线程 has_user_event 拉起来 if (forceUeSql) runSqlite3(dbPath, "BEGIN;\n" + forceUeSql + "\nCOMMIT;\n"); + const clearSubagentSql = forceSubagentNoUserEventSql(cols); + if (clearSubagentSql) runSqlite3(dbPath, "BEGIN;\n" + clearSubagentSql + "\nCOMMIT;\n"); runSqlite3(dbPath, "PRAGMA wal_checkpoint(TRUNCATE);\n"); const afterUserEvent = Number(runSqlite3(dbPath, "SELECT COUNT(*) FROM threads WHERE COALESCE(has_user_event,0)<>1;\n").trim() || 0); const afterRows = Number(runSqlite3(dbPath, "SELECT COUNT(*) FROM threads;\n").trim() || 0); @@ -1172,4 +1334,10 @@ async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) { }; } -module.exports = { syncProviderToCurrent, codexRunning, targetProvider, setCodexDataDir }; +module.exports = { + syncProviderToCurrent, + repairLegacySessionVisibility, + codexRunning, + targetProvider, + setCodexDataDir, +}; diff --git a/electron/lib/router.js b/electron/lib/router.js index 1f851ff..0abbb91 100644 --- a/electron/lib/router.js +++ b/electron/lib/router.js @@ -111,6 +111,7 @@ function restoreFileState(file, state) { function writeCodexConfigPair(configText, authText) { const configFile = path.join(CODEX_HOME, "config.toml"); const authFile = path.join(CODEX_HOME, "auth.json"); + assertManagedConfigComplete(configText); const beforeConfig = readFileState(configFile); const beforeAuth = readFileState(authFile); try { @@ -372,7 +373,7 @@ function currentRootModel() { } function extractProviderBlock(text, prov) { const esc = prov.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - const re = new RegExp(`\\[model_providers\\.${esc}\\][\\s\\S]*?(?=\\n\\[|$)`, "m"); + const re = new RegExp(`\\[model_providers\\.${esc}\\][\\s\\S]*?(?=\\n\\[|$)`); const m = text.match(re); return m ? m[0].trimEnd() + "\n" : ""; } @@ -442,6 +443,46 @@ function removeTomlSection(text, header) { function isManagedProviderId(id) { return id === PROVIDER_ID || id === DIRECT_PROVIDER_ID; } +function providerBlockIsComplete(text, id) { + const block = extractProviderBlock(text, id); + return !!(block && /^\s*base_url\s*=/m.test(block) && /^\s*wire_api\s*=\s*["']responses["']/m.test(block)); +} +function managedCompatibilityProviderIds() { + const def = config && Array.isArray(config.groups) ? config.groups.find((g) => g && g.isDefault) : null; + const id = String((def && def.modelProvider) || "").trim(); + // openai 是 Codex 内置 provider;托管的两个 ID 已由固定兼容段覆盖。 + // 这里只接管安全的 TOML bare key,避免历史异常值生成非法 section。 + if (!id || id === "openai" || isManagedProviderId(id) || !/^[A-Za-z0-9_-]+$/.test(id)) return []; + return [id]; +} +function managedProviderBlocksAreComplete(text) { + return [PROVIDER_ID, DIRECT_PROVIDER_ID, ...managedCompatibilityProviderIds()] + .every((id) => providerBlockIsComplete(text, id)); +} +function removeManagedProviderSections(text, includeCompatibility = true) { + let out = removeTomlSection(text, `model_providers.${PROVIDER_ID}`); + out = removeTomlSection(out, `model_providers.${DIRECT_PROVIDER_ID}`); + if (includeCompatibility) { + for (const id of managedCompatibilityProviderIds()) { + out = removeTomlSection(out, `model_providers.${id}`); + } + } + return out; +} +function providerBlockValue(text, id, key) { + const block = extractProviderBlock(text, id); + if (!block) return ""; + const esc = String(key).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const m = block.match(new RegExp(`^\\s*${esc}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, "m")); + return m ? (m[1] !== undefined ? m[1] : m[2]) : ""; +} +function assertManagedConfigComplete(text) { + const provider = modelProviderFromText(text); + if (!isManagedProviderId(provider)) return; + if (managedProviderBlocksAreComplete(text)) return; + const required = [PROVIDER_ID, DIRECT_PROVIDER_ID, ...managedCompatibilityProviderIds()].join("、"); + throw new Error(`拒绝写入不完整的 Codex config.toml:托管模式缺少 provider 兼容段(${required})`); +} function configLooksManaged(text) { const s = String(text || ""); if (!s.trim()) return false; @@ -449,12 +490,92 @@ function configLooksManaged(text) { return s.includes(`[model_providers.${PROVIDER_ID}]`) || s.includes(`[model_providers.${DIRECT_PROVIDER_ID}]`); } +function codexConfigRepairPlan() { + load(); + const text = readConfigToml(); + const provider = modelProviderFromText(text); + if (provider === PROVIDER_ID && !managedProviderBlocksAreComplete(text)) { + return { needed: true, mode: "proxy", reason: "托管 provider 兼容段缺失或不完整" }; + } + if (provider === DIRECT_PROVIDER_ID && !managedProviderBlocksAreComplete(text)) { + const direct = config.groups.find((g) => g.id === config.activeId && !g.isDefault && !isToolOnlyGroup(g) && !isClaudeGroup(g) && g.mode !== "chat" && g.key) + || config.groups.find((g) => !g.isDefault && !isToolOnlyGroup(g) && !isClaudeGroup(g) && g.mode !== "chat" && g.key === readAuthKey()); + return { + needed: true, + mode: "direct", + groupId: direct && direct.id, + reason: "托管 provider 兼容段缺失或不完整", + }; + } + if (String(text || "").trim()) return { needed: false }; + + const authText = readTextIfExists(path.join(CODEX_HOME, "auth.json")); + let auth = {}; + try { auth = JSON.parse(authText || "{}"); } catch {} + const key = auth.OPENAI_API_KEY || ""; + if (!key) return { needed: false }; + if (key === config.routerKey) { + return { needed: true, mode: "proxy", reason: "config.toml 为空但 auth.json 仍是本地路由 Key" }; + } + const direct = config.groups.find((g) => !g.isDefault && !isToolOnlyGroup(g) && !isClaudeGroup(g) && g.mode !== "chat" && g.key === key) + || config.groups.find((g) => g.id === config.activeId && !g.isDefault && !isToolOnlyGroup(g) && !isClaudeGroup(g) && g.mode !== "chat" && g.key); + if (direct && authLooksManaged(authText)) { + return { needed: true, mode: "direct", groupId: direct.id, reason: "config.toml 为空但 auth.json 仍是中转 Key" }; + } + return { needed: false }; +} + +function repairCodexConfigIfNeeded() { + const plan = codexConfigRepairPlan(); + if (!plan.needed) return { repaired: false }; + const currentText = readConfigToml(); + const currentProvider = modelProviderFromText(currentText); + if (isManagedProviderId(currentProvider) && String(currentText || "").trim()) { + const fallbackGroup = config.groups.find((g) => g.id === plan.groupId) + || config.groups.find((g) => g.id === config.activeId); + const sourceId = providerBlockIsComplete(currentText, currentProvider) + ? currentProvider + : (providerBlockIsComplete(currentText, PROVIDER_ID) ? PROVIDER_ID : DIRECT_PROVIDER_ID); + const fallbackBase = currentProvider === PROVIDER_ID + ? `http://127.0.0.1:${config.port}` + : (fallbackGroup && fallbackGroup.baseUrl ? responsesProviderBase(fallbackGroup.baseUrl) : ""); + const baseUrl = providerBlockValue(currentText, sourceId, "base_url") || fallbackBase; + if (!baseUrl) return { repaired: false, error: "没有可用于修复兼容 provider 的 API 地址" }; + const name = providerBlockValue(currentText, sourceId, "name") + || (fallbackGroup && fallbackGroup.name) + || (currentProvider === PROVIDER_ID ? "本地路由" : "中转直连"); + let repairedText = removeManagedProviderSections(currentText); + repairedText = repairedText.trimEnd() + "\n\n" + managedProviderBlocks(name, baseUrl); + backupConfig(currentText); + writeCodexConfigPair(repairedText, readTextForUpdate(path.join(CODEX_HOME, "auth.json"))); + return { + repaired: true, + mode: currentProvider === PROVIDER_ID ? "proxy" : "direct", + reason: plan.reason, + groupId: plan.groupId, + preservedModel: currentRootModel(), + }; + } + if (plan.mode === "proxy") { + applyToCodex(); + return { repaired: true, mode: "proxy", reason: plan.reason }; + } + if (plan.mode === "direct" && plan.groupId) { + const result = applyDirectToCodex(plan.groupId); + if (result && result.ok) return { repaired: true, mode: "direct", reason: plan.reason, groupId: plan.groupId }; + return { repaired: false, error: result && result.error ? result.error : "直连分组不可用" }; + } + return { repaired: false, error: "没有可用于修复的中转分组" }; +} + // 当前 Codex 是否仍保留“本地路由”接管痕迹。这个状态只用于页面显示和取消按钮, // 不把直连中转(td-direct)误显示成已启用本地路由。 function codexRouteApplied() { + try { repairCodexConfigIfNeeded(); } catch {} const text = readConfigToml(); - if (modelProviderFromText(text) === PROVIDER_ID) return true; - if (extractProviderBlock(text, PROVIDER_ID)) return true; + const provider = modelProviderFromText(text); + if (provider === PROVIDER_ID) return true; + if (provider === DIRECT_PROVIDER_ID) return false; try { const auth = JSON.parse(readTextIfExists(path.join(CODEX_HOME, "auth.json")) || "{}"); return auth.OPENAI_API_KEY === config.routerKey; @@ -665,6 +786,31 @@ function catalogPath() { return path.isAbsolute(name) ? name : path.join(CODEX_HOME, name); } +function catalogSyncStatePath(catalogFile) { + return `${catalogFile}.td-router-state.json`; +} + +function readCatalogManagedSlugs(catalogFile) { + try { + const state = JSON.parse(fs.readFileSync(catalogSyncStatePath(catalogFile), "utf-8")); + return new Set(Array.isArray(state.managedSlugs) ? state.managedSlugs.filter((slug) => typeof slug === "string") : []); + } catch { + return new Set(); + } +} + +function writeCatalogManagedSlugs(catalogFile, slugs) { + safeWriteFileSync( + catalogSyncStatePath(catalogFile), + JSON.stringify({ managedSlugs: [...slugs].sort() }, null, 2), + "utf-8" + ); +} + +function isToolOwnedCatalog(catalogFile) { + return path.resolve(catalogFile) === path.resolve(path.join(CODEX_HOME, "cc-switch-model-catalog.json")); +} + // 国产模型的实际可用窗口通常小于 Codex 模板模型。分组未填写时给出保守值, // 让 Codex 在上游拒绝请求前预留足够空间完成自动压缩。用户填写的值始终优先 // (≥1024 即生效;过小的值多半也是有意为之,比如想强制尽早压缩)。 @@ -754,12 +900,33 @@ function applyModelCatalog(group) { } catch {} safeWriteFileSync(p, JSON.stringify(cat, null, 2), "utf-8"); } -// 【根治「切模型丢会话」】把分组中转站 /models 返回的全部文本模型,先登记进 Codex 模型目录。 -// 关键:用户是在 Codex 本体的模型选择器里切模型的,那条路径不经过本 App,App 的 applyModelCatalog -// 不会触发;若目标 slug 不在目录里,Codex 会以未知模型启动、侧栏历史还原失败 = 会话"消失"。 -// 因此必须【先回填】:把该中转能用的模型全部登记为 visibility=list,用户在 Codex 里选哪个都不丢。 -// 非文本模型(画图/审阅/语音等)不是对话模型,排除;任何网络/解析失败都不动既有目录(离线安全)。 -const NON_TEXT_MODEL_RE = /(image|imagine|whisper|audio|voice|tts|stt|embed|rerank|review|moderation|vision-ocr)/i; +// Codex 模型目录只公开当前分组可用的同族文本模型。中转站的 /models 往往返回 +// GPT、Claude、国产模型和多媒体模型的混合库存,不能把整站库存灌进选择器。 +const NON_TEXT_MODEL_RE = /(image|imagine|whisper|audio|voice|speech|tts|stt|embed|rerank|review|moderation|vision-ocr|video|seedance|veo|sora|banana|kling|hailuo)/i; +function configuredCatalogModels(group) { + const values = [ + group && group.model, + ...(Array.isArray(group && group.models) ? group.models : []), + ...(Array.isArray(group && group.routeModels) ? group.routeModels : []), + ]; + return Array.from(new Set(values.filter((slug) => typeof slug === "string" && slug.trim()).map((slug) => slug.trim()))); +} +function allowedCatalogModels(group, providerIds) { + const available = Array.from(new Set( + (providerIds || []).filter((slug) => typeof slug === "string" && slug.trim() && !NON_TEXT_MODEL_RE.test(slug)) + )); + const configured = configuredCatalogModels(group).filter((slug) => !NON_TEXT_MODEL_RE.test(slug)); + const rootModel = currentRootModel(); + const anchor = configured[0] || rootModel || ""; + if (/^gpt-/i.test(anchor)) { + const allowed = available.filter((slug) => /^gpt-/i.test(slug)); + for (const slug of [...configured, rootModel]) { + if (/^gpt-/i.test(slug) && !NON_TEXT_MODEL_RE.test(slug) && !allowed.includes(slug)) allowed.push(slug); + } + return allowed; + } + return configured; +} async function syncCatalogFromProvider(group) { try { if (!group || group.isDefault || !group.baseUrl || !group.key) return { ok: false, skipped: true }; @@ -770,8 +937,8 @@ async function syncCatalogFromProvider(group) { } catch (e) { return { ok: false, error: (e && e.message) || String(e) }; // 网络/接口失败 → 不动既有目录 } - const textIds = ids.filter((s) => !NON_TEXT_MODEL_RE.test(s)); - if (!textIds.length) return { ok: false, error: "无可登记的文本模型" }; + const allowedIds = allowedCatalogModels(group, ids); + if (!allowedIds.length) return { ok: false, error: "无可登记的分组文本模型" }; const p = catalogPath(); let cat; try { @@ -788,7 +955,18 @@ async function syncCatalogFromProvider(group) { let added = 0; let revealed = 0; let refreshed = 0; - for (const slug of textIds) { + let hidden = 0; + const allowed = new Set(allowedIds); + const managedSlugs = readCatalogManagedSlugs(p); + const hideAllStale = isToolOwnedCatalog(p); + for (const model of cat.models) { + if (!model || allowed.has(model.slug) || model.visibility !== "list") continue; + if (hideAllStale || managedSlugs.has(model.slug)) { + model.visibility = "hide"; + hidden++; + } + } + for (const slug of allowedIds) { const ex = cat.models.find((m) => m.slug === slug); if (ex) { if (ex.visibility !== "list") { ex.visibility = "list"; revealed++; } @@ -812,11 +990,13 @@ async function syncCatalogFromProvider(group) { cat.models.push(t); added++; } - if (added || revealed || refreshed) { + for (const slug of allowedIds) managedSlugs.add(slug); + if (added || revealed || refreshed || hidden) { try { fs.writeFileSync(p + ".bak-router", fs.readFileSync(p)); } catch {} safeWriteFileSync(p, JSON.stringify(cat, null, 2), "utf-8"); } - return { ok: true, added, revealed, refreshed, total: textIds.length }; + writeCatalogManagedSlugs(p, managedSlugs); + return { ok: true, added, revealed, refreshed, hidden, total: allowedIds.length }; } catch (e) { return { ok: false, error: (e && e.message) || String(e) }; } @@ -875,6 +1055,22 @@ function setRootModelInText(text, model) { return `model = "${model}"\n${text}`; } +function setRootTomlValueInText(text, key, value) { + const rendered = typeof value === "boolean" ? String(value) : JSON.stringify(String(value)); + const lines = text.split("\n"); + const firstSection = lines.findIndex((line) => /^\s*\[/.test(line)); + const rootEnd = firstSection === -1 ? lines.length : firstSection; + const existingIndex = lines.slice(0, rootEnd).findIndex( + (line) => line.trim().startsWith(`${key}=`) || line.trim().startsWith(`${key} =`) + ); + if (existingIndex >= 0) { + lines[existingIndex] = `${key} = ${rendered}`; + } else { + lines.splice(rootEnd, 0, `${key} = ${rendered}`); + } + return lines.join("\n"); +} + // 恢复到默认(把 config.toml/auth.json 还原成客户原始配置,Codex 直连自己账号) function restoreDirect() { const def = config.groups.find((g) => g.isDefault); @@ -1019,6 +1215,26 @@ function knownModelsForBase(baseUrl) { return []; } +// Codex Responses provider 的 base_url 语义是“后续自动追加 /responses”。 +// 模型列表探测允许用户填站点根(自动试 /v1/models),因此直连与路由转发也必须 +// 把站点根规范到 /v1;已填 /v1、/v2 或完整 /responses 地址时不重复改写。 +function responsesProviderBase(baseUrl) { + const base = String(baseUrl || "").replace(/\/+$/, ""); + try { + const u = new URL(base); + const pathname = u.pathname.replace(/\/+$/, ""); + if (/\/responses$/i.test(pathname)) { + u.pathname = pathname.replace(/\/responses$/i, ""); + return u.href.replace(/\/+$/, ""); + } + if (/\/v\d+$/i.test(pathname)) return base; + u.pathname = pathname === "/" ? "/v1" : `${pathname}/v1`; + return u.href.replace(/\/+$/, ""); + } catch { + return base; + } +} + // 拉取某个中转 API 的模型列表(试 /v1/models 再 /models) async function fetchModels(baseUrl, key) { if (!baseUrl) throw new Error("请先填 API 地址"); @@ -1294,7 +1510,16 @@ function handle(req, res) { let target; try { - target = new URL(g.baseUrl.replace(/\/$/, "") + req.url); + const openAIUpstream = g.mode !== "chat" && !isClaudeGroup(g); + // Codex 的 provider base_url 可能带 /v1;本地路由自身统一挂在根路径。 + // 先去掉入口里的版本段,再拼上游 base,避免 /v1/v1/responses。 + const upstreamPath = openAIUpstream && /^\/v\d+\/responses\b/i.test(req.url || "") + ? req.url.replace(/^\/v\d+/i, "") + : req.url; + const upstreamBase = !openAIUpstream + ? g.baseUrl.replace(/\/$/, "") + : responsesProviderBase(g.baseUrl); + target = new URL(upstreamBase + upstreamPath); } catch { res.writeHead(502); res.end("分组 API 地址无效"); @@ -1367,6 +1592,7 @@ function handle(req, res) { function start() { load(); + try { repairCodexConfigIfNeeded(); } catch {} if (server) return { running: true, port: config.port }; return new Promise((resolve, reject) => { server = http.createServer(handle); @@ -1400,6 +1626,10 @@ function releaseCodexTakeover() { text = removeTomlSection(text, `model_providers.${PROVIDER_ID}`); text = removeTomlSection(text, `model_providers.${DIRECT_PROVIDER_ID}`); if (managedProvider) { + // 托管期间为旧会话补的 provider 别名也要一起清理;用户已手改 provider 时则完全保留。 + for (const id of managedCompatibilityProviderIds()) { + text = removeTomlSection(text, `model_providers.${id}`); + } // 根级还指向本工具,才删根级 model_provider 和根级 model;否则不碰用户配置。 text = text.replace(/^\s*model_provider\s*=.*$\n?/m, ""); text = text.replace(/^\s*model\s*=.*$\n?/m, ""); @@ -1446,6 +1676,8 @@ function isRunning() { function status() { load(); + let repair = { repaired: false }; + try { repair = repairCodexConfigIfNeeded(); } catch (e) { repair = { repaired: false, error: String((e && e.message) || e) }; } return { running: isRunning(), port: config.port, @@ -1453,6 +1685,7 @@ function status() { address: `http://127.0.0.1:${config.port}`, activeId: config.activeId, activeClaudeId: config.activeClaudeId, + repair, claudeApplied: claudeApplied(), codexApplied: codexRouteApplied(), groups: config.groups.map((g) => ({ @@ -1470,12 +1703,15 @@ function status() { sttModel: g.sttModel || "", multimodal: !!g.multimodal, oneMillionModels: Array.isArray(g.oneMillionModels) ? g.oneMillionModels : [], + source: g.source || "", + sourceGroupId: g.sourceGroupId || "", })), }; } function defaultConfigInfo() { load(); + try { repairCodexConfigIfNeeded(); } catch {} const def = config.groups.find((g) => g.isDefault) || null; if (def && snapshotOfficialLogin(def)) save(); const currentConfigToml = readConfigToml(); @@ -1626,6 +1862,24 @@ function upsertGroup(g) { } return st; } + +function importSub2apiProvider(input) { + load(); + const existing = config.groups.find( + (g) => !g.isDefault && g.source === "sub2api" && String(g.sourceGroupId) === String(input.sourceGroupId) + ); + const result = upsertGroup({ + ...(existing ? { id: existing.id } : {}), + name: input.name, + baseUrl: input.endpoint, + key: input.apiKey, + model: input.model, + mode: "responses", + source: "sub2api", + sourceGroupId: String(input.sourceGroupId), + }); + return { ...result, _imported: true, _updated: !!existing }; +} function deleteGroup(id) { load(); const g = config.groups.find((x) => x.id === id); @@ -1745,7 +1999,26 @@ function setPort(port) { // —— 自动配置 Codex —— 写 config.toml 的 local-router provider + auth.json 的 routerKey function codexSnippet() { load(); - return `model_provider = "${PROVIDER_ID}"\n\n[model_providers.${PROVIDER_ID}]\nname = "本地路由"\nbase_url = "http://127.0.0.1:${config.port}"\nwire_api = "responses"\nrequires_openai_auth = true`; + const base = `http://127.0.0.1:${config.port}`; + return `model_provider = "${PROVIDER_ID}"\n\n${managedProviderBlocks("本地路由", base).trimEnd()}`; +} + +function managedProviderBlocks(name, baseUrl) { + const providerName = JSON.stringify(String(name || "TD 工具箱")); + const compatibilityName = JSON.stringify(`${String(name || "TD 工具箱")}(历史会话兼容)`); + const base = JSON.stringify(String(baseUrl || "").replace(/\/+$/, "")); + const blocks = []; + for (const id of [PROVIDER_ID, DIRECT_PROVIDER_ID, ...managedCompatibilityProviderIds()]) { + blocks.push( + `[model_providers.${id}]`, + `name = ${id === PROVIDER_ID || id === DIRECT_PROVIDER_ID ? providerName : compatibilityName}`, + `base_url = ${base}`, + 'wire_api = "responses"', + "requires_openai_auth = true", + "" + ); + } + return blocks.join("\n"); } function applyToCodex() { @@ -1775,9 +2048,10 @@ function applyToCodex() { if (def && def.modelProvider && def.modelProvider !== PROVIDER_ID) { text = removeTomlSection(text, `model_providers.${def.modelProvider}`); } - // 替换/插入 [model_providers.local-router] 段:先删旧的(含被 bug 弄重复的),再追加干净的 - const block = `[model_providers.${PROVIDER_ID}]\nname = "本地路由"\nbase_url = "http://127.0.0.1:${config.port}"\nwire_api = "responses"\nrequires_openai_auth = true\n`; - text = removeTomlSection(text, `model_providers.${PROVIDER_ID}`); + // 托管 provider 与历史默认 provider 都保留完整兼容段。历史线程可能记录任意一个 ID, + // 恢复时会按线程自己的 provider 查配置,缺一个就会一直停在 loading。 + const block = managedProviderBlocks("本地路由", `http://127.0.0.1:${config.port}`); + text = removeManagedProviderSections(text); text = text.trimEnd() + "\n\n" + block; // auth.json 写 routerKey(保留其它字段),Codex 会把它当 Bearer 发到本地路由 @@ -1793,8 +2067,7 @@ function applyToCodex() { } text = ensureCatalogLinked(text); writeCodexConfigPair(text, JSON.stringify(auth, null, 2)); - // 先回填该中转站的全部文本模型进目录(异步、不阻塞、失败不动目录), - // 保证用户之后在 Codex 本体里切到任何模型都不会因"模型不在目录"而丢会话。 + // 收敛为当前分组的同族文本模型,避免把中转站全量库存灌进 Codex。 if (cur2 && !cur2.isDefault) syncCatalogFromProvider(cur2).catch(() => {}); save(); return { ok: true }; @@ -1835,8 +2108,7 @@ function applyDirectToCodex(groupId) { backupConfig(text); // 清理其他接管痕迹:本地路由段、上次直连段、默认分组原始 provider 段 - text = removeTomlSection(text, `model_providers.${PROVIDER_ID}`); - text = removeTomlSection(text, `model_providers.${DIRECT_PROVIDER_ID}`); + text = removeManagedProviderSections(text); if (def && def.modelProvider && def.modelProvider !== DIRECT_PROVIDER_ID) { text = removeTomlSection(text, `model_providers.${def.modelProvider}`); } @@ -1846,9 +2118,11 @@ function applyDirectToCodex(groupId) { } else { text = `model_provider = "${DIRECT_PROVIDER_ID}"\n` + text; } - // 追加 [model_providers.td-direct] 段:base_url = 分组真实中转地址(非 127.0.0.1) - const base = g.baseUrl.replace(/\/+$/, ""); - const block = `[model_providers.${DIRECT_PROVIDER_ID}]\nname = "${(g.name || "中转直连").replace(/"/g, "")}"\nbase_url = "${base}"\nwire_api = "responses"\nrequires_openai_auth = true\n`; + // 追加托管与历史 provider 兼容段:base_url 都是分组真实中转地址(非 127.0.0.1) + const base = responsesProviderBase(g.baseUrl); + const block = managedProviderBlocks(g.name || "中转直连", base); + text = setRootTomlValueInText(text, "model_reasoning_effort", "high"); + text = setRootTomlValueInText(text, "disable_response_storage", true); text = text.trimEnd() + "\n\n" + block; text = text.replace(/\n{3,}/g, "\n\n"); @@ -1865,9 +2139,7 @@ function applyDirectToCodex(groupId) { } text = ensureCatalogLinked(text); writeCodexConfigPair(text, JSON.stringify(auth, null, 2)); - // 先回填:把该中转站能用的全部文本模型登记进 Codex 模型目录(异步、不阻塞保存、失败不动目录)。 - // 这样即便用户之后在 Codex 本体的模型选择器里切到别的模型(那条路径不经过本 App), - // 目标模型也已在目录里 → Codex 能正常启动、侧栏历史不再"消失"。 + // 异步收敛当前分组模型目录;网络失败时保持现状。 syncCatalogFromProvider(g).catch(() => {}); config.activeId = g.id; save(); @@ -2015,6 +2287,7 @@ module.exports = { status, defaultConfigInfo, upsertGroup, + importSub2apiProvider, deleteGroup, setActive, setPort, diff --git a/electron/lib/sessions.js b/electron/lib/sessions.js index 73458a4..699c284 100644 --- a/electron/lib/sessions.js +++ b/electron/lib/sessions.js @@ -8,6 +8,9 @@ const { cleanThreadTitle, cleanUserVisibleText, stripInternalThoughtBlocks, look const codexDataDir = require("./codex_data_dir"); let permanentDeleteBusy = false; +let clearAllBusy = false; +const DEFAULT_LIST_LIMIT = 500; +const ALL_LIST_LIMIT = 5000; function paths() { const codexHome = codexDataDir.get(); @@ -95,7 +98,8 @@ function peekMeta(file) { } const p = d.payload || {}; if (d.type === "session_meta") { - meta.id = p.session_id || p.id || ""; + // 新版 subagent 的 session_id 指向父线程,id 才是当前子线程。 + meta.id = p.id || p.session_id || ""; meta.cwd = p.cwd || ""; meta.ts = p.timestamp || d.timestamp || ""; meta.source = p.source || ""; @@ -363,6 +367,171 @@ async function deleteConversation(filePath) { } } +// 清空全部 Codex 会话:删除 rollout 正文、状态库线程关联记录、桌面侧栏目录和会话索引。 +// 配置、登录态、项目、插件、自动化任务均保留;执行前由调用方保证 Codex 已完全退出。 +function deleteAllThreadLinkedRows(SQL, dbPath, { requireThreads = false } = {}) { + if (!fs.existsSync(dbPath)) return { changed: false, rows: 0 }; + let db; + try { + db = new SQL.Database(sqliteBufferWithCommittedWal(dbPath)); + const tables = sqlJsRows(db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") + .map((row) => row.name).filter(Boolean); + if (requireThreads && !tables.includes("threads")) return { changed: false, rows: 0 }; + db.run("PRAGMA foreign_keys=ON"); + db.run("PRAGMA secure_delete=ON"); + db.run("BEGIN"); + let rows = 0; + const deferred = []; + for (const table of tables) { + // 自动化定义不是会话正文;即使它记录了 target_thread_id,也必须保留。 + if (table === "automations") continue; + const columns = sqlJsRows(db, "PRAGMA table_info(" + quoteIdent(table) + ")").map((row) => String(row.name || "")); + const fks = sqlJsRows(db, "PRAGMA foreign_key_list(" + quoteIdent(table) + ")"); + const linked = columns.filter((name) => /(^|_)(thread_id|parent_thread_id|child_thread_id)$/i.test(name)); + const referencesThreads = fks + .filter((fk) => String(fk.table || "").toLowerCase() === "threads") + .map((fk) => String(fk.from || "")); + const targets = Array.from(new Set(linked.concat(referencesThreads))).filter(Boolean); + if (table === "threads") { + deferred.push(table); + continue; + } + for (const column of targets) { + db.run("DELETE FROM " + quoteIdent(table) + " WHERE " + quoteIdent(column) + " IS NOT NULL"); + rows += db.getRowsModified(); + } + } + if (tables.includes("threads")) { + db.run("DELETE FROM " + quoteIdent("threads")); + rows += db.getRowsModified(); + } + db.run("COMMIT"); + if (!rows) return { changed: false, rows: 0 }; + db.run("VACUUM"); + const fkErrors = sqlJsRows(db, "PRAGMA foreign_key_check"); + const integrity = sqlJsRows(db, "PRAGMA integrity_check"); + if (fkErrors.length || !integrity.length || integrity.some((row) => String(Object.values(row)[0] || "").toLowerCase() !== "ok")) { + throw new Error("SQLite 清空会话后完整性校验失败,已停止替换"); + } + replaceStateDb(dbPath, Buffer.from(db.export())); + return { changed: true, rows }; + } catch (e) { + if (db) try { db.run("ROLLBACK"); } catch {} + throw e; + } finally { + if (db) try { db.close(); } catch {} + } +} + +function copyIfExists(source, target) { + if (!fs.existsSync(source)) return false; + fs.mkdirSync(path.dirname(target), { recursive: true }); + const stat = fs.statSync(source); + if (stat.isDirectory()) { + fs.cpSync(source, target, { recursive: true, force: true }); + } else { + fs.copyFileSync(source, target); + } + return true; +} + +function pruneEmptyDirs(root) { + if (!fs.existsSync(root)) return; + let entries; + try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + if (entry.isDirectory()) pruneEmptyDirs(path.join(root, entry.name)); + } + try { + if (!fs.readdirSync(root).length) fs.rmdirSync(root); + } catch {} +} + +async function clearAllConversations() { + if (clearAllBusy) throw new Error("已有清空会话任务正在执行,请稍后重试"); + clearAllBusy = true; + let backup = ""; + const stagedDirs = []; + const copiedFiles = []; + try { + const { codexHome, sessionsDir, archivedSessionsDir, sessionIndex } = paths(); + const rolloutFiles = sessionRoots().flatMap((root) => findRolloutFiles(root)); + const timestamp = new Date().toISOString().replace(/[-:T.Z]/g, "").slice(0, 14); + backup = path.join(codexHome, "backups_state", "session-clear", timestamp); + fs.mkdirSync(backup, { recursive: true }); + + // 会话正文用同盘 rename 暂存,不复制 700+ 个大 JSONL;清空成功后保留备份,便于恢复。 + for (const [name, source] of [["sessions", sessionsDir], ["archived_sessions", archivedSessionsDir]]) { + if (!fs.existsSync(source)) continue; + const target = path.join(backup, name); + fs.renameSync(source, target); + stagedDirs.push({ source, target }); + fs.mkdirSync(source, { recursive: true }); + } + if (fs.existsSync(sessionIndex)) { + copyIfExists(sessionIndex, path.join(backup, "session_index.jsonl")); + copiedFiles.push([sessionIndex, path.join(backup, "session_index.jsonl")]); + fs.writeFileSync(sessionIndex, "", "utf-8"); + } + + for (const name of fs.readdirSync(codexHome)) { + if (!/^state_.*\.sqlite(?:-(?:wal|shm))?$/.test(name)) continue; + const source = path.join(codexHome, name); + const target = path.join(backup, name); + copyIfExists(source, target); + copiedFiles.push([source, target]); + } + const catalogDb = path.join(codexHome, "sqlite", "codex-dev.db"); + for (const suffix of ["", "-wal", "-shm"]) { + const source = catalogDb + suffix; + if (!fs.existsSync(source)) continue; + const target = path.join(backup, "codex-dev.db" + suffix); + copyIfExists(source, target); + copiedFiles.push([source, target]); + } + + const indexLines = copiedFiles.some(([source]) => source === sessionIndex) + ? fs.readFileSync(path.join(backup, "session_index.jsonl"), "utf-8").split("\n").filter((line) => line.trim()).length + : 0; + const SQL = await initSqlJs({ + locateFile: (file) => path.join(global.__BUNDLE_ROOT__ || path.join(__dirname, "..", ".."), "node_modules", "sql.js", "dist", file), + }); + let stateRowsRemoved = 0; + for (const name of fs.readdirSync(codexHome).filter((item) => /^state_.*\.sqlite$/.test(item))) { + const result = deleteAllThreadLinkedRows(SQL, path.join(codexHome, name), { requireThreads: true }); + stateRowsRemoved += result.rows; + } + const catalog = deleteAllThreadLinkedRows(SQL, catalogDb); + return { + ok: true, + filesRemoved: rolloutFiles.length, + indexEntriesRemoved: indexLines, + stateRowsRemoved, + catalogRowsRemoved: catalog.rows, + backup, + }; + } catch (error) { + // 任一 SQLite/文件步骤失败,恢复正文目录、索引和数据库备份,避免半清空状态。 + for (const [source, target] of copiedFiles) { + try { + if (fs.existsSync(target)) { + fs.mkdirSync(path.dirname(source), { recursive: true }); + fs.copyFileSync(target, source); + } + } catch {} + } + for (const { source, target } of stagedDirs.reverse()) { + try { + fs.rmSync(source, { recursive: true, force: true }); + if (fs.existsSync(target)) fs.renameSync(target, source); + } catch {} + } + throw error; + } finally { + clearAllBusy = false; + } +} + function findConversationById(id) { const target = String(id || "").trim(); if (!target || target.length > 160) return null; @@ -469,16 +638,21 @@ function repairSkillMentionTitles(log = () => {}) { } function normalizeListOptions(opts) { - if (typeof opts === "boolean") return { includeSubagent: opts, includeArchived: false }; + if (typeof opts === "boolean") return { includeSubagent: opts, includeArchived: false, limit: DEFAULT_LIST_LIMIT }; + const requestedLimit = Number(opts && opts.limit); + const limit = Number.isFinite(requestedLimit) && requestedLimit > 0 + ? Math.min(Math.floor(requestedLimit), ALL_LIST_LIMIT) + : DEFAULT_LIST_LIMIT; return { includeSubagent: !!(opts && opts.includeSubagent), includeArchived: !!(opts && opts.includeArchived), + limit, }; } // 列出会话(默认只列当前 sessions,并排除子 agent 派生线程;全量模式会带 archived_sessions) function listConversations(opts = {}) { - const { includeSubagent, includeArchived } = normalizeListOptions(opts); + const { includeSubagent, includeArchived, limit } = normalizeListOptions(opts); const { sessionsDir, archivedSessionsDir } = paths(); const roots = [{ dir: sessionsDir, archived: false }]; if (includeArchived) roots.push({ dir: archivedSessionsDir, archived: true }); @@ -510,7 +684,7 @@ function listConversations(opts = {}) { } } items.sort((a, b) => (b.ts || "").localeCompare(a.ts || "")); - return items; + return items.slice(0, limit); } // 解析单个会话文件为可读消息列表 @@ -534,7 +708,7 @@ function readConversation(file) { const p = d.payload || {}; const ts = d.timestamp || ""; if (d.type === "session_meta") { - meta = { id: p.session_id || p.id || "", cwd: p.cwd || "", ts: p.timestamp || ts, provider: p.model_provider || "" }; + meta = { id: p.id || p.session_id || "", cwd: p.cwd || "", ts: p.timestamp || ts, provider: p.model_provider || "" }; } else if (d.type === "event_msg" && p.type === "user_message") { messages.push({ role: "user", text: cleanUserVisibleText(String(p.message || "")), ts }); } else if (d.type === "event_msg" && p.type === "agent_message") { @@ -677,6 +851,7 @@ module.exports = { backupAll, importSessions, deleteConversation, + clearAllConversations, findConversationById, repairSkillMentionTitles, }; diff --git a/electron/lib/voice_cdp.js b/electron/lib/voice_cdp.js index 0e03291..4b0a450 100644 --- a/electron/lib/voice_cdp.js +++ b/electron/lib/voice_cdp.js @@ -16,6 +16,7 @@ const path = require("path"); function dlog(...a) { try { console.log("[voice]", ...a); } catch {} } const router = require("./router"); const codexDesktop = require("./codex_desktop"); +const globalDictation = require("./global_dictation"); const { INJECT_SCRIPT } = require("./voice_inject"); const { TOKEN_INJECT_SCRIPT } = require("./token_inject"); const tokenUsage = require("./token_usage"); @@ -1014,6 +1015,10 @@ function closeNetworkSessions() { // —— 连接到已带端口的 Codex —— async function connectToTarget(port) { const targets = await listTargets(port); + // listTargets() keeps its legacy [] fallback for the voice state machine. + // Let the cleanup module probe the endpoint itself so a failed connection + // cannot be mistaken for an available endpoint with no targets. + await globalDictation.closeGlobalDictationTargets(port); const tgt = pickTarget(targets); if (!tgt) return false; closeNetworkSessions(); @@ -1120,6 +1125,7 @@ function onSessionDelete(cb) { async function connect(cb, opts = {}) { if (cb) onStatusCb = cb; const port = opts.port || DEFAULT_PORT; + globalDictation.disableKeepVisible(); state.port = port; setPhase("checking", { lastError: "", launchWarning: "", portDiagnostic: "" }); const exe = scanCodexExecutable(); @@ -1184,6 +1190,7 @@ async function connect(cb, opts = {}) { async function disconnect() { stopTokenSync(); closeNetworkSessions(); + globalDictation.disableKeepVisible(); if (session) { const ownedSession = session; // 先让页面停止录音并删除已挂载按钮,再关闭 CDP;关闭后无法清理前台 DOM。 @@ -1205,6 +1212,7 @@ async function disconnect() { } catch {} } setPhase("disconnected"); + await globalDictation.closeGlobalDictationTargets(state.port); return snapshot(); } diff --git a/electron/main.js b/electron/main.js index f3b7989..41bb743 100644 --- a/electron/main.js +++ b/electron/main.js @@ -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"); diff --git a/electron/preload.js b/electron/preload.js index ada1a5d..30f384b 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -55,6 +55,7 @@ contextBridge.exposeInMainWorld("api", { sessionsDataDirReset: () => ipcRenderer.invoke("sessions-data-dir-reset"), sessionRead: (path) => ipcRenderer.invoke("session-read", { path }), sessionDeletePermanently: (path) => ipcRenderer.invoke("session-delete-permanently", { path }), + sessionsClearAll: () => ipcRenderer.invoke("sessions-clear-all"), sessionExport: (item) => ipcRenderer.invoke("session-export", { item }), sessionsBackup: () => ipcRenderer.invoke("sessions-backup"), sessionsImport: () => ipcRenderer.invoke("sessions-import"), diff --git a/package-lock.json b/package-lock.json index 71436b1..3bd64a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-market", - "version": "1.5.21", + "version": "1.5.86", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-market", - "version": "1.5.21", + "version": "1.5.86", "license": "MIT", "dependencies": { "electron-updater": "^6.8.9", diff --git a/package.json b/package.json index 4f60e63..24d4e2d 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "codex-market", - "version": "1.5.21", + "version": "1.5.86", "description": "TD工具箱 / 技能商店 / 备份工具 —— 跨平台桌面客户端", "author": "Codex Market", "license": "MIT", "main": "electron/bootstrap.js", - "hotContentVersion": "1.5.84", + "hotContentVersion": "1.5.94", "hotMinAppVersion": "1.5.0", "scripts": { "start": "electron .", diff --git a/renderer/app.js b/renderer/app.js index 52cb02b..46817f5 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -561,8 +561,8 @@ async function voiceLoad() { let skillCat = null; // 完整 catalog {total, categories, skills} let storeCat = "__all__"; // 当前分类(__all__ / __hot__ / 中文分类名) let storeSort = "hot"; // hot / downloads / stars -let storeShown = 0; // 已渲染数量(分页) -const STORE_PAGE = 60; +let storePage = 0; +const STORE_PAGE = 24; const installed = new Set(JSON.parse(localStorage.getItem("installedSkills") || "[]")); let installedSkills = []; // 磁盘实况(~/.codex/skills 扫描结果) let installedDirs = new Set(); // 已安装技能的目录名,用于精确标注“已安装” @@ -668,10 +668,11 @@ function skillCardHTML(s) {
${esc(s.subtitle || s.name)}
${esc(s.brief || "")}
${agentBadges(s)} -
${meta}
- - +
${meta}
+
+ +
`; } @@ -682,7 +683,7 @@ function renderStore(reset = true) { const more = $("#store-more"); if (storeCat === "__installed__") { renderInstalledSkills(); return; } const list = storeFiltered(); - if (reset) { storeShown = 0; grid.innerHTML = ""; } + if (reset) storePage = 0; if (!list.length) { grid.innerHTML = ""; empty.classList.remove("hidden"); @@ -691,35 +692,75 @@ function renderStore(reset = true) { return; } empty.classList.add("hidden"); - const next = list.slice(storeShown, storeShown + STORE_PAGE); - grid.insertAdjacentHTML("beforeend", next.map(skillCardHTML).join("")); - storeShown += next.length; - // 副标题 + const pageCount = Math.ceil(list.length / STORE_PAGE); + storePage = Math.min(storePage, pageCount - 1); + const first = storePage * STORE_PAGE; + const next = list.slice(first, first + STORE_PAGE); + grid.innerHTML = next.map(skillCardHTML).join(""); + $("#store-cat-title").textContent = storeCat === "__all__" ? "全部技能" : storeCat === "__hot__" ? "热门技能" : storeCat; + $("#store-count").textContent = `${list.length.toLocaleString()} 个`; $("#store-cat-sub").textContent = - `${list.length} 个技能 · 通用 SKILL.md 标准,适配 Claude · Codex · OpenClaw`; - // 加载更多 - if (storeShown < list.length) { - more.classList.remove("hidden"); - more.innerHTML = `已显示 ${storeShown} / ${list.length} · `; - $("#store-more-btn").addEventListener("click", () => renderStore(false)); - } else { - more.classList.remove("hidden"); - more.innerHTML = `已全部显示 · 共 ${list.length} 个`; - } + `通用 SKILL.md 标准,适配 Claude · Codex · OpenClaw`; + renderStorePager(more, pageCount, list.length); bindStoreCards(); } +function renderStorePager(more, pageCount, total) { + more.classList.remove("hidden"); + if (pageCount <= 1) { + more.innerHTML = `共 ${total.toLocaleString()} 个技能`; + return; + } + const pages = new Set([0, pageCount - 1, storePage - 1, storePage, storePage + 1]); + const orderedPages = [...pages].filter((p) => p >= 0 && p < pageCount).sort((a, b) => a - b); + let last = -1; + const pageButtons = orderedPages.map((p) => { + const gap = p - last > 1 ? `…` : ""; + last = p; + return `${gap}`; + }).join(""); + more.innerHTML = ` + ${storePage * STORE_PAGE + 1}-${Math.min((storePage + 1) * STORE_PAGE, total)} / ${total.toLocaleString()} +
+ + ${pageButtons} + +
`; + more.querySelectorAll("[data-store-page]").forEach((btn) => btn.addEventListener("click", () => { + const nextPage = Number(btn.dataset.storePage); + if (Number.isNaN(nextPage) || nextPage < 0 || nextPage >= pageCount || nextPage === storePage) return; + storePage = nextPage; + renderStore(false); + $("#market-grid").scrollIntoView({ block: "start", behavior: "smooth" }); + })); +} + function bindStoreCards() { const grid = $("#market-grid"); + grid.querySelectorAll(".skill-card[data-id]").forEach((card) => { + if (card._bound) return; card._bound = true; + card.tabIndex = 0; + card.setAttribute("role", "button"); + card.setAttribute("aria-label", `查看 ${card.querySelector(".sk-name")?.textContent || "技能"} 详情`); + const openDetail = () => openSkillDetail(card.dataset.id); + card.addEventListener("click", (ev) => { + if (ev.target.closest("[data-install]")) return; + openDetail(); + }); + card.addEventListener("keydown", (ev) => { + if (ev.key !== "Enter" && ev.key !== " ") return; + ev.preventDefault(); + openDetail(); + }); + }); grid.querySelectorAll("[data-install]").forEach((btn) => { if (btn._bound) return; btn._bound = true; - btn.addEventListener("click", () => installSkill(btn.dataset.install, btn)); - }); - grid.querySelectorAll("[data-detail]").forEach((btn) => { - if (btn._bound) return; btn._bound = true; - btn.addEventListener("click", () => openSkillDetail(btn.dataset.detail)); + btn.addEventListener("click", (ev) => { + ev.stopPropagation(); + installSkill(btn.dataset.install, btn); + }); }); } @@ -731,8 +772,9 @@ function renderInstalledSkills() { more.classList.add("hidden"); more.innerHTML = ""; $("#store-cat-title").textContent = "我已安装"; + $("#store-count").textContent = `${installedSkills.length.toLocaleString()} 个`; $("#store-cat-sub").textContent = - `${installedSkills.length} 个已安装技能 · Codex 侧位于 ~/.codex/skills · 卸载会同时删除工具箱仓库实体`; + `Codex 侧位于 ~/.codex/skills · 卸载会同时删除工具箱仓库实体`; if (!installedSkills.length) { grid.innerHTML = ""; empty.classList.remove("hidden"); @@ -784,9 +826,9 @@ function renderStoreNav() { const item = (key, name, count) => { const active = storeCat === key ? " active" : ""; const c = count != null ? `${count.toLocaleString()}` : ""; - return ``; + ${esc(name)}${c}`; }; let html = ""; html += `
精选
`; @@ -797,15 +839,32 @@ function renderStoreNav() { for (const c of skillCat.categories) html += item(c.name, c.name, c.count); nav.innerHTML = html; nav.querySelectorAll(".nav-item").forEach((el) => - el.addEventListener("click", async () => { - storeCat = el.dataset.cat; - nav.querySelectorAll(".nav-item").forEach((x) => x.classList.remove("active")); - el.classList.add("active"); - $("#market-grid").scrollTop = 0; - if (storeCat === "__installed__") await loadInstalledSkills({ includeSize: true, includeMeta: true }); - renderStore(true); - }) + el.addEventListener("click", () => selectStoreCategory(el.dataset.cat)) ); + renderStoreCategoryChips(); +} + +function renderStoreCategoryChips() { + const chips = $("#store-category-chips"); + if (!skillCat || !chips) return; + const quickCategories = skillCat.categories.slice(0, 9); + const item = (key, name) => ``; + chips.innerHTML = [ + item("__all__", "全部"), + item("__hot__", "热门"), + ...quickCategories.map((c) => item(c.name, c.name)), + ].join(""); + chips.querySelectorAll("[data-store-filter]").forEach((el) => + el.addEventListener("click", () => selectStoreCategory(el.dataset.storeFilter)) + ); +} + +async function selectStoreCategory(category) { + storeCat = category; + $("#market-grid").scrollTop = 0; + if (storeCat === "__installed__") await loadInstalledSkills({ includeSize: true, includeMeta: true }); + renderStoreNav(); + renderStore(true); } async function installSkill(id, btn) { @@ -1172,6 +1231,8 @@ let sessData = []; let sessCurrent = null; let sessLoaded = false; let sessAllMode = false; +const SESS_DEFAULT_LIMIT = 500; +const SESS_ALL_LIMIT = 5000; function renderSessionDataDir(info) { if (!info || !info.path) return; @@ -1290,8 +1351,8 @@ async function sessLoad(opts = {}) { try { sessData = await apic.sessionsList( sessAllMode - ? { includeSubagent: true, includeArchived: true } - : { includeSubagent: false, includeArchived: false } + ? { includeSubagent: true, includeArchived: true, limit: SESS_ALL_LIMIT } + : { includeSubagent: false, includeArchived: false, limit: SESS_DEFAULT_LIMIT } ); sessLoaded = true; sessCurrent = null; @@ -1329,6 +1390,33 @@ $("#sess-delete").addEventListener("click", async (ev) => { }); $("#sess-search").addEventListener("input", sessRender); +$("#sess-clear-all").addEventListener("click", async (ev) => { + const status = await apic.providerSyncStatus(); + if (status.running) { + toast("请先完全退出 Codex(Cmd+Q)再清除会话记录", "err"); + return; + } + const confirmed = confirm( + "确定清除全部会话记录?\n\n将删除 sessions、archived_sessions、Codex 会话索引、状态库线程、侧栏目录和工具箱会话记忆中的全部对话和项目摘要(已固定的长期偏好会保留)。\n不会删除中转配置、登录凭证、项目源码、插件和自动化任务。\n\n执行前会自动备份,操作不可直接撤销。" + ); + if (!confirmed) return; + const btn = ev.currentTarget; + busy(btn, true, "清除中…"); + try { + const r = await apic.sessionsClearAll(); + sessData = []; + sessCurrent = null; + $("#sess-empty").classList.remove("hidden"); + $("#sess-conv").classList.add("hidden"); + sessRender(); + if (r.cleanupWarning) toast(`会话已清除,但会话记忆清理有警告:${r.cleanupWarning}`, "err"); + else toast(`已清除 ${r.filesRemoved || 0} 个会话文件和 ${r.memoryRemoved || 0} 条会话记忆;执行前备份在 ${r.backup}`, "ok"); + } catch (e) { + toast(`清除会话失败:${e.message || e}`, "err"); + } finally { + busy(btn, false); + } +}); $("#sess-refresh").addEventListener("click", sessLoad); $("#session-data-dir-select").addEventListener("click", (ev) => changeSessionDataDir(false, ev.currentTarget)); $("#session-data-dir-reset").addEventListener("click", (ev) => changeSessionDataDir(true, ev.currentTarget)); @@ -2268,6 +2356,17 @@ $("#btn-check-update").addEventListener("click", async (ev) => { showUpdateModal({ version: h.contentVersion, notes: h.notes, mandatory: h.mandatory, kind: "hot" }); return; } + if (h && (h.needFull || h.needFullAfterHot)) { + info.textContent = `需要安装完整包 v${h.fullAppVersion || h.contentVersion}`; + showUpdateModal({ + version: h.fullAppVersion || h.contentVersion, + notes: `${h.notes || ""}${h.needFullAfterHot ? "\n\n本次更新还需要安装完整包,以注册 TD 工具箱深链协议。" : ""}`.trim(), + mandatory: h.mandatory, + url: RELEASE_FALLBACK, + kind: "full", + }); + return; + } // 否则查完整安装包 const r = await apic.checkUpdate(); if (r.dev) info.textContent = "(开发版不检查更新)"; diff --git a/renderer/index.html b/renderer/index.html index 8cbd26d..1ce347c 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -24,7 +24,7 @@ 官方推荐 - 推荐中转 · 安全 · 稳定 · 可靠 + 推荐中转 · 安全 · 稳定 · 可靠(自备魔法访问) ↗ @@ -53,21 +53,27 @@
-
- 技能商店 - / - 全部技能 +
+
+

全部技能

+ / + — +
+

通用 SKILL.md 标准,适配 Claude · Codex · OpenClaw

-
+
-

全部技能 · 通用 SKILL.md 标准,适配 Claude · Codex · OpenClaw

+
+
分类
+
+
@@ -482,6 +488,7 @@ 当前会话 +
diff --git a/renderer/styles.css b/renderer/styles.css index 8cc8182..ddd7f8a 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -377,31 +377,31 @@ h3 { margin: 0; font-size: 16px; } .mem-line b { color: var(--text); margin-right: 8px; font-weight: 600; } /* =========================================================================== - 技能商店(cocoloop 镜像)—— 侧栏分类 + 精致卡片 + 技能商店(cocoloop 镜像)—— 高密度目录浏览 =========================================================================== */ -.store { display: grid; grid-template-columns: 236px 1fr; gap: 18px; +.store { display: grid; grid-template-columns: 218px 1fr; gap: 14px; height: calc(100vh - 150px); min-height: 420px; } /* 左侧分类导航 */ .store-nav { display: flex; flex-direction: column; background: var(--bg-2); - border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } + border-right: 1px solid var(--border); overflow: hidden; } .store-nav-head { display: flex; align-items: baseline; justify-content: space-between; - padding: 13px 15px 9px; border-bottom: 1px solid var(--border); } + padding: 9px 10px 8px; border-bottom: 1px solid var(--border); } .store-nav-title { font-size: 12px; font-weight: 700; color: var(--muted); letter-spacing: .5px; } .store-nav-total { font-size: 11px; color: var(--accent); font-weight: 700; } -.store-nav-list { flex: 1; overflow-y: auto; padding: 8px; display: flex; +.store-nav-list { flex: 1; overflow-y: auto; padding: 6px; display: flex; flex-direction: column; gap: 2px; } .store-nav-list::-webkit-scrollbar { width: 8px; } .store-nav-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 8px; } .store-nav-group { font-size: 10px; font-weight: 700; color: var(--muted); - text-transform: uppercase; letter-spacing: .6px; padding: 10px 10px 4px; opacity: .7; } + text-transform: uppercase; letter-spacing: .6px; padding: 9px 8px 3px; opacity: .7; } .nav-item { display: flex; align-items: center; gap: 9px; padding: 8px 10px; - border-radius: 9px; cursor: pointer; border: 1px solid transparent; - transition: .12s; color: var(--text); } + border-radius: 7px; cursor: pointer; border: 1px solid transparent; width: 100%; + text-align: left; font-family: inherit; background: transparent; transition: .12s; color: var(--text); } .nav-item:hover { background: var(--card); } -.nav-item.active { background: var(--grad); color: #fff; box-shadow: var(--shadow); } +.nav-item.active { background: var(--grad); color: #fff; box-shadow: 0 4px 14px rgba(91,108,255,.24); } .nav-item.active .nav-count { background: rgba(255,255,255,.22); color: #fff; } .nav-ico { width: 18px; text-align: center; font-size: 14px; flex-shrink: 0; } .nav-name { flex: 1; font-size: 13px; font-weight: 600; white-space: nowrap; @@ -410,67 +410,88 @@ h3 { margin: 0; font-size: 16px; } background: var(--card-2); padding: 1px 8px; border-radius: 999px; flex-shrink: 0; } /* 右侧主区 */ -.store-main { display: flex; flex-direction: column; min-width: 0; } +.store-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; } .store-head { display: flex; align-items: center; justify-content: space-between; - gap: 16px; flex-wrap: wrap; } -.store-crumb { display: flex; align-items: baseline; gap: 8px; font-size: 20px; - font-weight: 700; } -.store-crumb .muted { font-size: 13px; font-weight: 600; } + gap: 16px; min-height: 42px; } +.store-heading { min-width: 0; } +.store-title-row { display: flex; align-items: baseline; gap: 8px; } +.store-title-row h2 { font-size: 22px; line-height: 1.15; } .store-crumb-sep { color: var(--muted); font-weight: 400; } -.store-tools { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } -.store-sub { margin: 6px 0 14px; font-size: 12.5px; } +.store-count { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } +.store-tools { display: flex; gap: 8px; align-items: center; flex: 0 0 auto; } +.store-tools .search { width: min(240px, 25vw); padding: 8px 10px; border-radius: 7px; } +.store-tools .segmented { border-radius: 7px; padding: 2px; flex-shrink: 0; } +.store-tools .seg { padding: 5px 9px; border-radius: 5px; white-space: nowrap; } +.store-sub { margin: 3px 0 0; font-size: 12px; } +.store-filter-row { display: flex; align-items: flex-start; gap: 9px; margin: 13px 0 12px; } +.store-filter-label { color: var(--muted); font-size: 12px; line-height: 28px; flex: 0 0 auto; } +.store-category-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.store-filter-chip { height: 28px; padding: 0 10px; border: 1px solid var(--border); border-radius: 6px; + color: var(--muted); background: var(--card); font: 600 12px/1 inherit; cursor: pointer; } +.store-filter-chip:hover { color: var(--text); border-color: var(--accent); } +.store-filter-chip.active { border-color: transparent; color: #fff; background: var(--grad); } .store-grid { overflow-y: auto; align-content: start; padding: 2px 4px 4px 2px; - flex: 1; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); } + flex: 1; min-height: 0; grid-template-columns: repeat(auto-fill, minmax(205px, 1fr)); gap: 10px; } .store-grid::-webkit-scrollbar { width: 9px; } .store-grid::-webkit-scrollbar-thumb { background: var(--border); border-radius: 9px; } /* 技能卡片 */ .skill-card { background: var(--card); border: 1px solid var(--border); - border-radius: var(--radius); padding: 15px; display: flex; flex-direction: column; - gap: 11px; transition: .15s; position: relative; overflow: hidden; } + border-radius: 8px; min-height: 188px; padding: 12px; display: flex; flex-direction: column; + gap: 8px; transition: .15s; position: relative; overflow: hidden; cursor: pointer; } .skill-card::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; background: var(--grad); opacity: 0; transition: .15s; } -.skill-card:hover { transform: translateY(-3px); box-shadow: var(--shadow); +.skill-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); border-color: var(--accent); } +.skill-card:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } .skill-card:hover::before { opacity: 1; } -.sk-top { display: flex; align-items: flex-start; gap: 11px; } -.sk-ico { width: 42px; height: 42px; flex-shrink: 0; display: grid; place-items: center; - font-size: 22px; background: var(--card-2); border: 1px solid var(--border); - border-radius: 11px; } +.sk-top { display: flex; align-items: flex-start; gap: 9px; } +.sk-ico { width: 36px; height: 36px; flex-shrink: 0; display: grid; place-items: center; + font-size: 19px; background: var(--card-2); border: 1px solid var(--border); border-radius: 7px; } .sk-headings { min-width: 0; flex: 1; } -.sk-name { font-weight: 700; font-size: 15px; line-height: 1.25; word-break: break-word; } +.sk-name { font-weight: 700; font-size: 14px; line-height: 1.28; word-break: break-word; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .sk-author { color: var(--muted); font-size: 12px; margin-top: 2px; } -.sk-sec { flex-shrink: 0; width: 24px; height: 24px; display: grid; place-items: center; - border-radius: 7px; font-size: 11px; font-weight: 800; } +.sk-sec { flex-shrink: 0; width: 21px; height: 21px; display: grid; place-items: center; + border-radius: 5px; font-size: 10px; font-weight: 800; } .sk-sec.S { background: linear-gradient(135deg,#f59e0b,#f97316); color: #fff; } .sk-sec.A { background: rgba(74,222,128,.16); color: var(--ok); } .sk-sec.B { background: var(--card-2); color: var(--muted); } -.sk-subtitle { font-weight: 600; font-size: 13.5px; line-height: 1.4; color: var(--text); } -.sk-brief { color: var(--muted); font-size: 12.5px; line-height: 1.55; flex: 1; - display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; - overflow: hidden; } -.sk-agents { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } -.sk-agents .lab { font-size: 11px; color: var(--muted); margin-right: 2px; } -.agent-badge { font-size: 11px; font-weight: 700; padding: 2px 9px; border-radius: 999px; +.sk-subtitle { font-weight: 600; font-size: 12.5px; line-height: 1.35; color: var(--text); + display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; } +.sk-brief { color: var(--muted); font-size: 12px; line-height: 1.45; flex: 1; + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } +.sk-agents { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; } +.store-grid .sk-agents .lab { display: none; } +.agent-badge { font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: 999px; display: inline-flex; align-items: center; gap: 5px; background: var(--card-2); border: 1px solid var(--border); } -.agent-badge::before { content: ""; width: 6px; height: 6px; border-radius: 50%; } +.agent-badge::before { content: ""; width: 5px; height: 5px; border-radius: 50%; } .agent-badge.claude::before { background: #d97757; } .agent-badge.codex::before { background: #5b6cff; } .agent-badge.openclaw::before { background: #a855f7; } .agent-badge.mm { background: rgba(245,158,11,.14); border-color: transparent; color: #f59e0b; } .agent-badge.mm::before { display: none; } -.sk-meta { display: flex; align-items: center; gap: 13px; padding-top: 9px; - border-top: 1px solid var(--border); color: var(--muted); font-size: 12px; - flex-wrap: wrap; } +.sk-meta { display: flex; align-items: center; gap: 9px; color: var(--muted); font-size: 11px; + min-width: 0; flex-wrap: nowrap; overflow: hidden; } +.sk-meta > span { white-space: nowrap; } .sk-meta .cred { margin-left: auto; font-size: 10px; font-weight: 700; padding: 1px 7px; border: 1px solid var(--border); border-radius: 6px; letter-spacing: .3px; } -.sk-foot { display: flex; align-items: center; gap: 9px; } -.sk-foot .btn.primary { flex: 1; } +.sk-foot { display: flex; align-items: center; gap: 8px; padding-top: 8px; border-top: 1px solid var(--border); } +.sk-card-actions { display: flex; gap: 4px; margin-left: auto; } +.sk-card-actions .install { min-width: 58px; } -/* 加载更多 */ +/* 分页 */ .store-more { display: flex; align-items: center; justify-content: center; gap: 12px; - padding: 14px; color: var(--muted); font-size: 13px; } + padding: 12px 0 2px; color: var(--muted); font-size: 12px; } +.store-pagination { display: flex; align-items: center; gap: 4px; } +.store-page-btn { width: 28px; height: 28px; display: grid; place-items: center; padding: 0; + border: 1px solid var(--border); border-radius: 6px; background: var(--card); color: var(--muted); + font: 600 12px/1 inherit; cursor: pointer; } +.store-page-btn:hover:not(:disabled) { color: var(--text); border-color: var(--accent); } +.store-page-btn.active { background: var(--grad); border-color: transparent; color: #fff; } +.store-page-btn:disabled { opacity: .4; cursor: not-allowed; } +.store-page-gap { padding: 0 2px; color: var(--muted); } /* 技能详情弹窗内的排版 */ .sd-hero { display: flex; align-items: flex-start; gap: 14px; margin-bottom: 4px; } @@ -486,6 +507,11 @@ h3 { margin: 0; font-size: 16px; } @media (max-width: 820px) { .store { grid-template-columns: 1fr; height: auto; } .store-nav { max-height: 200px; } + .store-head { align-items: flex-start; flex-direction: column; } + .store-tools { width: 100%; } + .store-tools .search { flex: 1; width: auto; } + .store-grid { max-height: none; overflow: visible; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); } + .store-filter-row { align-items: flex-start; } } /* 品牌 logo 图片 */ diff --git a/scripts/publish-hot.js b/scripts/publish-hot.js index 1855c68..e38462d 100644 --- a/scripts/publish-hot.js +++ b/scripts/publish-hot.js @@ -31,6 +31,8 @@ const minAppVersion = arg("--min", pkg.hotMinAppVersion || "1.5.0"); const notes = arg("--notes", ""); const mandatory = has("--mandatory"); const requiresFull = has("--requires-full"); +const requiresFullAfterHot = has("--requires-full-after-hot"); +const fullAppVersion = arg("--full-app-version", ""); // —— token —— let token = process.env.GIT_TOKEN || ""; @@ -91,6 +93,8 @@ const manifest = { contentVersion: version, minAppVersion, requiresFull, + requiresFullAfterHot, + fullAppVersion, bundleUrl: `${DL}/${bundleName}`, size: buf.length, sha256, diff --git a/test/codex-data-dir.test.js b/test/codex-data-dir.test.js index 06c8ae9..90f7cb5 100644 --- a/test/codex-data-dir.test.js +++ b/test/codex-data-dir.test.js @@ -26,7 +26,7 @@ function loadModules(userData) { function writeRollout(root, id, title) { const dir = path.join(root, "sessions", "2026", "08", "06"); fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, "rollout-test.jsonl"), [ + fs.writeFileSync(path.join(dir, `rollout-${id}.jsonl`), [ JSON.stringify({ type: "session_meta", payload: { session_id: id, cwd: "/work/demo", timestamp: "2026-08-06T10:00:00.000Z" } }), JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: title } }), ].join("\n") + "\n"); @@ -59,3 +59,109 @@ test("会话数据目录切换后列表读取新目录,配置可持久化", () fs.rmSync(root, { recursive: true, force: true }); } }); + +test("会话列表默认限量,避免大量历史记录一次性阻塞 IPC", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-market-data-dir-")); + const userData = path.join(root, "user-data"); + const dataDir = path.join(root, "many-codex"); + fs.mkdirSync(userData, { recursive: true }); + for (let i = 0; i < 520; i++) { + const id = `${String(i).padStart(8, "0")}-1111-4111-8111-111111111111`; + writeRollout(dataDir, id, `第 ${i} 份记录`); + } + + try { + const { dataDir: codexDir, sessions } = loadModules(userData); + codexDir.set(dataDir); + assert.equal(sessions.listConversations().length, 500); + assert.equal(sessions.listConversations({ limit: 12 }).length, 12); + assert.equal(sessions.listConversations({ limit: 9999 }).length, 520); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("subagent 同时含 id/session_id 时使用自身 id,不误绑定父线程", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-market-data-dir-")); + const userData = path.join(root, "user-data"); + const dataDir = path.join(root, "subagent-codex"); + const parentId = "33333333-3333-4333-8333-333333333333"; + const childId = "44444444-4444-4444-8444-444444444444"; + const dir = path.join(dataDir, "sessions", "2026", "08", "17"); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `rollout-${childId}.jsonl`), [ + JSON.stringify({ + type: "session_meta", + payload: { + id: childId, + session_id: parentId, + cwd: "/work/subagent", + timestamp: "2026-08-17T10:00:00.000Z", + thread_source: "subagent", + }, + }), + JSON.stringify({ type: "event_msg", payload: { type: "user_message", message: "子任务" } }), + ].join("\n") + "\n"); + + try { + const { dataDir: codexDir, sessions } = loadModules(userData); + codexDir.set(dataDir); + const items = sessions.listConversations({ includeSubagent: true, limit: 10 }); + assert.equal(items.length, 1); + assert.equal(items[0].id, childId); + const conversation = sessions.readConversation(items[0].path); + assert.equal(conversation.meta.id, childId); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + + +test("一键清空会话会清理正文、索引、状态库和侧栏目录并保留备份", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-market-clear-sessions-")); + const userData = path.join(root, "user-data"); + const dataDir = path.join(root, "codex"); + const id = "55555555-5555-4555-8555-555555555555"; + fs.mkdirSync(userData, { recursive: true }); + writeRollout(dataDir, id, "待清除会话"); + fs.mkdirSync(path.join(dataDir, "archived_sessions", "2026", "08"), { recursive: true }); + fs.writeFileSync(path.join(dataDir, "archived_sessions", "2026", "08", `rollout-${id}.jsonl`), "archived\n"); + fs.writeFileSync(path.join(dataDir, "session_index.jsonl"), JSON.stringify({ id, thread_name: "待清除会话" }) + "\n"); + const SQL = require("sql.js"); + const init = await SQL({ locateFile: (file) => path.join(__dirname, "../node_modules/sql.js/dist", file) }); + const state = new init.Database(); + state.run("CREATE TABLE threads (id TEXT PRIMARY KEY, title TEXT)"); + state.run("CREATE TABLE thread_artifacts (thread_id TEXT, payload TEXT)"); + state.run("INSERT INTO threads VALUES (?, ?)", [id, "待清除会话"]); + state.run("INSERT INTO thread_artifacts VALUES (?, ?)", [id, "artifact"]); + fs.writeFileSync(path.join(dataDir, "state_5.sqlite"), Buffer.from(state.export())); + state.close(); + fs.mkdirSync(path.join(dataDir, "sqlite"), { recursive: true }); + const catalog = new init.Database(); + catalog.run("CREATE TABLE local_thread_catalog (thread_id TEXT, display_title TEXT)"); + catalog.run("CREATE TABLE automations (id TEXT PRIMARY KEY, name TEXT)"); + catalog.run("INSERT INTO local_thread_catalog VALUES (?, ?)", [id, "待清除会话"]); + catalog.run("INSERT INTO automations VALUES (?, ?)", ["keep", "保留自动化"]); + fs.writeFileSync(path.join(dataDir, "sqlite", "codex-dev.db"), Buffer.from(catalog.export())); + catalog.close(); + + try { + const { dataDir: codexDir, sessions } = loadModules(userData); + codexDir.set(dataDir); + const result = await sessions.clearAllConversations(); + assert.equal(result.filesRemoved, 2); + assert.equal(sessions.listConversations({ includeArchived: true, includeSubagent: true }).length, 0); + assert.equal(fs.readFileSync(path.join(dataDir, "session_index.jsonl"), "utf-8"), ""); + assert.equal(fs.existsSync(result.backup), true); + const stateAfter = new init.Database(fs.readFileSync(path.join(dataDir, "state_5.sqlite"))); + assert.equal(stateAfter.exec("SELECT COUNT(*) FROM threads")[0].values[0][0], 0); + assert.equal(stateAfter.exec("SELECT COUNT(*) FROM thread_artifacts")[0].values[0][0], 0); + stateAfter.close(); + const catalogAfter = new init.Database(fs.readFileSync(path.join(dataDir, "sqlite", "codex-dev.db"))); + assert.equal(catalogAfter.exec("SELECT COUNT(*) FROM local_thread_catalog")[0].values[0][0], 0); + assert.equal(catalogAfter.exec("SELECT COUNT(*) FROM automations")[0].values[0][0], 1); + catalogAfter.close(); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/test/codex-desktop.test.js b/test/codex-desktop.test.js new file mode 100644 index 0000000..c105900 --- /dev/null +++ b/test/codex-desktop.test.js @@ -0,0 +1,32 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const codexDesktop = require("../electron/lib/codex_desktop"); + +test("native dictation cleanup moves only the bounded Codex dialog off-screen", async () => { + let command = null; + const result = await codexDesktop.hideGlobalDictationWindows({ + execFile(file, args, options, callback) { + command = { file, args, options }; + callback(null, "2\n", ""); + }, + }); + + if (process.platform === "darwin") { + assert.equal(result.ok, true); + assert.equal(result.status, "hidden"); + assert.equal(result.hidden, 2); + assert.equal(command.file, "osascript"); + assert.deepEqual(command.args.slice(0, 1), ["-e"]); + assert.match(command.args[1], /titleText is "Codex"/); + assert.match(command.args[1], /subroleText is "AXDialog"/); + assert.match(command.args[1], /set position of w to \{-10000, -10000\}/); + } else { + assert.equal(result.ok, false); + assert.equal(result.status, "unsupported-platform"); + assert.equal(result.hidden, 0); + assert.equal(command, null); + } +}); diff --git a/test/deeplink.test.js b/test/deeplink.test.js new file mode 100644 index 0000000..71f7c2b --- /dev/null +++ b/test/deeplink.test.js @@ -0,0 +1,34 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const { parseTdToolboxImport } = require("../electron/lib/deeplink"); + +const validUrl = + "tdtoolbox://v1/import?resource=provider&app=codex&name=Sub2API&endpoint=https%3A%2F%2Fapi.example.com&apiKey=sk-test&model=gpt-5.5&source=sub2api&sourceGroupId=42"; + +test("parses a valid Sub2API provider deep link", () => { + const result = parseTdToolboxImport(validUrl); + assert.equal(result.ok, true); + assert.deepEqual(result.input, { + resource: "provider", + app: "codex", + name: "Sub2API", + endpoint: "https://api.example.com", + apiKey: "sk-test", + model: "gpt-5.5", + source: "sub2api", + sourceGroupId: "42", + }); +}); + +test("rejects unsupported schemes, endpoints, and sources", () => { + for (const url of [ + "ccswitch://v1/import?resource=provider", + validUrl.replace("endpoint=https%3A%2F%2Fapi.example.com", "endpoint=ftp%3A%2F%2Fapi.example.com"), + validUrl.replace("source=sub2api", "source=unknown"), + ]) { + const result = parseTdToolboxImport(url); + assert.equal(result.ok, false); + assert.ok(result.error); + } +}); diff --git a/test/global-dictation.test.js b/test/global-dictation.test.js new file mode 100644 index 0000000..a875b87 --- /dev/null +++ b/test/global-dictation.test.js @@ -0,0 +1,440 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +const globalDictation = require("../electron/lib/global_dictation"); + +function fixture(state = {}) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "td-global-dictation-")); + const file = path.join(home, ".codex-global-state.json"); + fs.writeFileSync(file, JSON.stringify(state, null, 2) + "\n"); + return { home, file }; +} + +test("disables persistent dictation bar without changing unrelated state", { concurrency: false }, () => { + const fx = fixture({ + globalDictationHotkey: "LeftCommand", + "global-dictation-keep-visible": true, + "project-order": ["/work/a"], + nested: { enabled: true }, + }); + + try { + const result = globalDictation.disableKeepVisible({ codexHome: fx.home }); + const saved = JSON.parse(fs.readFileSync(fx.file, "utf8")); + const backup = JSON.parse(fs.readFileSync(fx.file + ".td-before-global-dictation.bak", "utf8")); + + assert.equal(result.ok, true); + assert.equal(result.changed, true); + assert.equal(saved["global-dictation-keep-visible"], false); + assert.equal(saved.globalDictationHotkey, "LeftCommand"); + assert.deepEqual(saved["project-order"], ["/work/a"]); + assert.deepEqual(saved.nested, { enabled: true }); + assert.equal(backup["global-dictation-keep-visible"], true); + assert.equal(fs.readdirSync(fx.home).some((name) => name.endsWith(".td-dictation.tmp")), false); + } finally { + fs.rmSync(fx.home, { recursive: true, force: true }); + } +}); + +test("changes only the keep-visible token instead of rewriting the state file", { concurrency: false }, () => { + const fx = fixture({ + before: "保留", + "global-dictation-keep-visible": true, + after: { nested: 1 }, + }); + + try { + const original = fs.readFileSync(fx.file); + const tokenOffset = original.indexOf(Buffer.from(" true")); + const result = globalDictation.disableKeepVisible({ codexHome: fx.home }); + const saved = fs.readFileSync(fx.file); + + assert.equal(result.ok, true); + assert.equal(saved.subarray(0, tokenOffset).equals(original.subarray(0, tokenOffset)), true); + assert.equal(saved.subarray(tokenOffset, tokenOffset + 5).toString("ascii"), "false"); + assert.equal(saved.subarray(tokenOffset + 5).equals(original.subarray(tokenOffset + 5)), true); + } finally { + fs.rmSync(fx.home, { recursive: true, force: true }); + } +}); + +test("atomically rewrites minified state while preserving unrelated state", { concurrency: false }, () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "td-global-dictation-minified-")); + const file = path.join(home, ".codex-global-state.json"); + const original = Buffer.from('{"global-dictation-keep-visible":true,"untouched":1,"nested":{"enabled":true}}'); + fs.writeFileSync(file, original); + + try { + const result = globalDictation.disableKeepVisible({ codexHome: home }); + const saved = JSON.parse(fs.readFileSync(file, "utf8")); + assert.equal(result.ok, true); + assert.equal(result.reason, "disabled-atomic"); + assert.equal(saved["global-dictation-keep-visible"], false); + assert.equal(saved.untouched, 1); + assert.deepEqual(saved.nested, { enabled: true }); + assert.equal(fs.readFileSync(file).equals(original), false); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("persistent lock prevents an unsafe concurrent write", { concurrency: false }, () => { + const fx = fixture({ "global-dictation-keep-visible": true, untouched: 1 }); + const lock = fx.file + ".td-dictation.lock"; + fs.writeFileSync(lock, "busy"); + + try { + const result = globalDictation.disableKeepVisible({ codexHome: fx.home, lockWaitMs: 0 }); + const saved = JSON.parse(fs.readFileSync(fx.file, "utf8")); + + assert.equal(result.ok, false); + assert.equal(result.reason, "locked"); + assert.equal(saved["global-dictation-keep-visible"], true); + assert.equal(saved.untouched, 1); + } finally { + fs.rmSync(fx.home, { recursive: true, force: true }); + } +}); + +test("creates an explicit disabled state when the state file is absent", { concurrency: false }, () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "td-global-dictation-missing-")); + const file = path.join(home, ".codex-global-state.json"); + + try { + const result = globalDictation.disableKeepVisible({ codexHome: home }); + const saved = JSON.parse(fs.readFileSync(file, "utf8")); + + assert.equal(result.ok, true); + assert.equal(result.changed, true); + assert.equal(result.reason, "created-disabled"); + assert.deepEqual(saved, { "global-dictation-keep-visible": false }); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); + +test("recognizes only the exact global dictation page route", () => { + assert.equal(globalDictation.isGlobalDictationTarget({ + type: "page", + url: "app://-/index.html?initialRoute=/global-dictation", + }), true); + assert.equal(globalDictation.isGlobalDictationTarget({ + type: "page", + url: "app://-/index.html?initialRoute=%2Fglobal-dictation", + }), true); + assert.equal(globalDictation.isGlobalDictationTarget({ + type: "page", + url: "app://-/index.html?initialRoute=global-dictation", + }), true); + assert.equal(globalDictation.isGlobalDictationTarget({ + type: "page", + url: "app://-/index.html", + }), false); + assert.equal(globalDictation.isGlobalDictationTarget({ + type: "page", + url: "app://-/index.html?initialRoute=/avatar-overlay", + }), false); + assert.equal(globalDictation.isGlobalDictationTarget({ + type: "worker", + url: "app://-/index.html?initialRoute=/global-dictation", + }), false); + assert.equal(globalDictation.isGlobalDictationTarget({ + type: "page", + url: "https://example.com/index.html?initialRoute=/global-dictation", + }), false); + assert.equal(globalDictation.isGlobalDictationTarget({ + type: "page", + url: "app://other/index.html?initialRoute=/global-dictation", + }), false); + assert.equal(globalDictation.isGlobalDictationTarget({ + type: "page", + url: "app://-/other.html?initialRoute=/global-dictation", + }), false); + assert.equal(globalDictation.isCodexMainTarget({ + type: "page", + url: "app://-/index.html?theme=light", + }), true); + assert.equal(globalDictation.isCodexMainTarget({ + type: "page", + url: "app://-/index.html?initialRoute=/avatar-overlay", + }), false); +}); + +test("closes only global dictation targets through CDP HTTP", async () => { + const calls = []; + const initialTargets = [ + { id: "main", type: "page", url: "app://-/index.html" }, + { id: "avatar", type: "page", url: "app://-/index.html?initialRoute=/avatar-overlay" }, + { id: "dictation", type: "page", url: "app://-/index.html?initialRoute=%2Fglobal-dictation" }, + ]; + const remainingTargets = initialTargets.filter((target) => target.id !== "dictation"); + let listCount = 0; + const fetchImpl = async (url) => { + calls.push(url); + if (url.endsWith("/json")) { + const targets = listCount++ === 0 ? initialTargets : remainingTargets; + return { ok: true, json: async () => targets }; + } + return { ok: true }; + }; + + const result = await globalDictation.closeGlobalDictationTargets(19222, { fetchImpl }); + + assert.equal(result.ok, true); + assert.equal(result.available, true); + assert.equal(result.matched, 1); + assert.equal(result.closed, 1); + assert.equal(result.remaining, 0); + assert.equal(result.status, "closed"); + assert.deepEqual(calls, [ + "http://127.0.0.1:19222/json", + "http://127.0.0.1:19222/json/close/dictation", + "http://127.0.0.1:19222/json", + ]); +}); + +test("CDP unavailable is reported as unavailable instead of success", async () => { + const result = await globalDictation.closeGlobalDictationTargets(19222, { + fetchImpl: async () => { + throw new Error("connection refused"); + }, + }); + + assert.equal(result.ok, false); + assert.equal(result.available, false); + assert.equal(result.status, "port-unavailable"); + assert.equal(result.matched, 0); + assert.equal(result.closed, 0); + assert.equal(result.remaining, null); +}); + +test("does not close a matching route on a foreign debug endpoint", async () => { + const calls = []; + const fetchImpl = async (url) => { + calls.push(url); + return { + ok: true, + json: async () => [{ + id: "foreign-dictation", + type: "page", + url: "app://-/index.html?initialRoute=/global-dictation", + }], + }; + }; + + const result = await globalDictation.closeGlobalDictationTargets(19222, { + fetchImpl, + endpointOwnerVerifier: async () => ({ verified: true, owned: false, reason: "other app" }), + }); + + assert.equal(result.ok, false); + assert.equal(result.status, "foreign-endpoint"); + assert.equal(result.closed, 0); + assert.deepEqual(calls, ["http://127.0.0.1:19222/json"]); +}); + +test("closes an orphaned dictation target only when the port owner is Codex", async () => { + const calls = []; + let listCount = 0; + const fetchImpl = async (url) => { + calls.push(url); + if (url.endsWith("/json")) { + const targets = listCount++ === 0 + ? [{ id: "orphan", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" }] + : []; + return { ok: true, json: async () => targets }; + } + return { ok: true }; + }; + + const result = await globalDictation.closeGlobalDictationTargets(19222, { + fetchImpl, + endpointOwnerVerifier: async () => ({ verified: true, owned: true, reason: "Codex" }), + }); + + assert.equal(result.ok, true); + assert.equal(result.status, "closed"); + assert.equal(result.closed, 1); + assert.deepEqual(calls, [ + "http://127.0.0.1:19222/json", + "http://127.0.0.1:19222/json/close/orphan", + "http://127.0.0.1:19222/json", + ]); +}); + +test("aborts a slow orphan owner check at the shared deadline", async () => { + let verifierSignal; + const fetchImpl = async (url) => { + assert.equal(url, "http://127.0.0.1:19222/json"); + return { + ok: true, + json: async () => [{ + id: "orphan", + type: "page", + url: "app://-/index.html?initialRoute=/global-dictation", + }], + }; + }; + const endpointOwnerVerifier = async (_port, options) => { + verifierSignal = options.signal; + await new Promise((resolve) => { + if (options.signal.aborted) resolve(); + else options.signal.addEventListener("abort", resolve, { once: true }); + }); + return { verified: false, owned: false, reason: "timed out" }; + }; + + const result = await globalDictation.closeGlobalDictationTargets(19222, { + fetchImpl, + endpointOwnerVerifier, + ownerTimeoutMs: 20, + }); + + assert.equal(result.ok, false); + assert.equal(result.status, "unverified-endpoint"); + assert.equal(verifierSignal.aborted, true); +}); + +test("reports when keep-visible immediately recreates the target", async () => { + const initialTargets = [ + { id: "main", type: "page", url: "app://-/index.html" }, + { id: "dictation-a", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" }, + ]; + const recreatedTargets = [ + initialTargets[0], + { id: "dictation-b", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" }, + ]; + let listCount = 0; + const fetchImpl = async (url) => { + if (url.endsWith("/json")) { + return { + ok: true, + json: async () => listCount++ === 0 ? initialTargets : recreatedTargets, + }; + } + return { ok: true }; + }; + + const result = await globalDictation.closeGlobalDictationTargets(19222, { + fetchImpl, + retries: 0, + settleMs: 0, + }); + + assert.equal(result.ok, false); + assert.equal(result.status, "still-present"); + assert.equal(result.closed, 1); + assert.equal(result.remaining, 1); + assert.deepEqual(result.targetIds, ["dictation-b"]); +}); + +test("retries when Codex immediately recreates the dictation target", async () => { + const main = { id: "main", type: "page", url: "app://-/index.html" }; + const targetA = { id: "dictation-a", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" }; + const targetB = { id: "dictation-b", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" }; + const targetLists = [ + [main, targetA], + [main, targetB], + [main, targetB], + [main], + ]; + let listCount = 0; + const closed = []; + const fetchImpl = async (url) => { + if (url.endsWith("/json")) { + const targets = targetLists[Math.min(listCount, targetLists.length - 1)]; + listCount += 1; + return { ok: true, json: async () => targets }; + } + closed.push(url.split("/").pop()); + return { ok: true }; + }; + + const result = await globalDictation.closeGlobalDictationTargets(19222, { + fetchImpl, + settleMs: 0, + retryDelayMs: 0, + }); + + assert.equal(result.ok, true); + assert.equal(result.status, "closed"); + assert.equal(result.closed, 2); + assert.equal(result.attempts, 2); + assert.deepEqual(closed, ["dictation-a", "dictation-b"]); +}); + +test("target response body parsing cannot outlive the configured timeout", async () => { + const result = await globalDictation.closeGlobalDictationTargets(19222, { + timeoutMs: 20, + fetchImpl: async () => ({ + ok: true, + json: async () => new Promise(() => {}), + }), + }); + + assert.equal(result.ok, false); + assert.equal(result.available, false); + assert.equal(result.status, "timeout"); + assert.equal(result.closed, 0); +}); + +test("failed post-close verification is not reported as success", async () => { + const initialTargets = [ + { id: "main", type: "page", url: "app://-/index.html" }, + { id: "dictation", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" }, + ]; + let listCount = 0; + const fetchImpl = async (url) => { + if (url.endsWith("/json")) { + listCount += 1; + if (listCount > 1) throw new Error("verification endpoint disconnected"); + return { ok: true, json: async () => initialTargets }; + } + return { ok: true }; + }; + + const result = await globalDictation.closeGlobalDictationTargets(19222, { fetchImpl }); + + assert.equal(result.ok, false); + assert.equal(result.status, "verification-failed"); + assert.equal(result.closed, 1); + assert.equal(result.remaining, null); + assert.match(result.errors.join(" "), /verification endpoint disconnected/); +}); + +test("custom cleanup calls do not share stale targets or abort signals", async () => { + const firstTargets = [ + { id: "main-a", type: "page", url: "app://-/index.html" }, + { id: "dictation-a", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" }, + ]; + const secondTargets = [ + { id: "main-b", type: "page", url: "app://-/index.html" }, + { id: "dictation-b", type: "page", url: "app://-/index.html?initialRoute=/global-dictation" }, + ]; + const closed = []; + const fetchImpl = async (url) => { + if (url.includes("/json/close/")) { + closed.push(url.split("/").pop()); + return { ok: true }; + } + return { ok: true, json: async () => [] }; + }; + + const first = globalDictation.closeGlobalDictationTargets(19222, { + fetchImpl, + targets: firstTargets, + }); + const second = globalDictation.closeGlobalDictationTargets(19222, { + fetchImpl, + targets: secondTargets, + }); + + assert.notEqual(first, second); + await Promise.all([first, second]); + assert.deepEqual(closed.sort(), ["dictation-a", "dictation-b"]); +}); diff --git a/test/hotupdate.test.js b/test/hotupdate.test.js new file mode 100644 index 0000000..62ea68c --- /dev/null +++ b/test/hotupdate.test.js @@ -0,0 +1,43 @@ +const test = require("node:test"); +const assert = require("node:assert/strict"); + +const hotupdate = require("../electron/lib/hotupdate"); + +test("旧壳先热更,再提示安装完整包", () => { + const result = hotupdate.evaluate( + { + contentVersion: "1.5.86", + minAppVersion: "1.5.0", + requiresFull: false, + requiresFullAfterHot: true, + fullAppVersion: "1.5.86", + }, + "1.5.22", + "1.5.85", + "/tmp/codex-market-hot-test", + ); + + assert.equal(result.hasHot, true); + assert.equal(result.canHot, true); + assert.equal(result.needFull, false); + assert.equal(result.needFullAfterHot, true); + assert.equal(result.fullAppVersion, "1.5.86"); +}); + +test("完整包版本一致后不再重复提示", () => { + const result = hotupdate.evaluate( + { + contentVersion: "1.5.86", + minAppVersion: "1.5.0", + requiresFull: false, + requiresFullAfterHot: true, + fullAppVersion: "1.5.86", + }, + "1.5.86", + "1.5.86", + "/tmp/codex-market-hot-test", + ); + + assert.equal(result.hasHot, false); + assert.equal(result.needFullAfterHot, false); +}); diff --git a/test/router.test.js b/test/router.test.js index c257dd7..7f660fc 100644 --- a/test/router.test.js +++ b/test/router.test.js @@ -264,6 +264,77 @@ test("small explicit context windows are honored instead of silently ignored", { } }); +test("GPT groups expose only GPT text models and hide stale mixed-provider inventory", { concurrency: false }, async () => { + const fx = fixture({ + activeId: "gpt", + configToml: [ + 'model_catalog_json = "cc-switch-model-catalog.json"', + 'model_provider = "td-direct"', + 'model = "gpt-5.6-sol"', + 'model_reasoning_effort = "xhigh"', + "", + "[model_providers.td-direct]", + 'name = "GPT relay"', + 'base_url = "https://proxy.example.test/v1"', + 'wire_api = "responses"', + "", + "[model_providers.local-router]", + 'name = "GPT relay"', + 'base_url = "https://proxy.example.test/v1"', + 'wire_api = "responses"', + "", + ].join("\n"), + groups: [{ + id: "gpt", + name: "GPT relay", + baseUrl: "https://proxy.example.test", + key: "gpt-key", + model: "gpt-5.5", + mode: "responses", + }], + }); + const catalogFile = path.join(fx.codexHome, "cc-switch-model-catalog.json"); + fs.writeFileSync(catalogFile, JSON.stringify({ + models: [ + { slug: "gpt-5.5", context_window: 128000, visibility: "list" }, + { slug: "gpt-5.6-sol", context_window: 128000, visibility: "list" }, + { slug: "deepseek-v4-pro", context_window: 128000, visibility: "list" }, + { slug: "qwen3.8-max-preview", context_window: 128000, visibility: "list" }, + { slug: "claude-opus-4-8", context_window: 128000, visibility: "list" }, + { slug: "gpt-image-2", context_window: 128000, visibility: "list" }, + { slug: "seedance2.0", context_window: 128000, visibility: "list" }, + ], + })); + const previousFetch = global.fetch; + global.fetch = async () => ({ + ok: true, + json: async () => ({ + data: [ + { id: "gpt-5.5" }, + { id: "gpt-5.6-sol" }, + { id: "gpt-5.4-mini" }, + { id: "deepseek-v4-pro" }, + { id: "qwen3.8-max-preview" }, + { id: "claude-opus-4-8" }, + { id: "gpt-image-2" }, + { id: "seedance2.0" }, + ], + }), + }); + const router = loadRouter(fx); + try { + const result = await router.syncCatalogFromProvider(router.llmGroup()); + assert.equal(result.ok, true); + assert.equal(result.hidden, 5); + const catalog = JSON.parse(fs.readFileSync(catalogFile, "utf-8")); + const visible = catalog.models.filter((model) => model.visibility === "list").map((model) => model.slug).sort(); + assert.deepEqual(visible, ["gpt-5.4-mini", "gpt-5.5", "gpt-5.6-sol"]); + } finally { + global.fetch = previousFetch; + fs.rmSync(fx.root, { recursive: true, force: true }); + } +}); + test("running router writes the newly selected model immediately", { concurrency: false }, async () => { const probe = http.createServer(); const port = await listen(probe); @@ -361,9 +432,11 @@ test("stopped router writes the selected group directly to Codex immediately", { const configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8"); assert.match(configToml, /^model_provider = "td-direct"$/m); assert.match(configToml, /^model = "gpt-model-b"$/m); + assert.match(configToml, /^model_reasoning_effort = "high"$/m); + assert.match(configToml, /^disable_response_storage = true$/m); assert.match(configToml, /^\[model_providers\.td-direct\]$/m); assert.match(configToml, /^base_url = "https:\/\/proxy\.example\.test\/v1"$/m); - assert.doesNotMatch(configToml, /^\[model_providers\.local-router\]$/m); + assert.match(configToml, /^\[model_providers\.local-router\]$/m); const auth = JSON.parse(fs.readFileSync(path.join(fx.codexHome, "auth.json"), "utf-8")); assert.equal(auth.OPENAI_API_KEY, "key-b"); @@ -377,6 +450,267 @@ test("stopped router writes the selected group directly to Codex immediately", { } }); +test("status repairs an empty Codex config when router auth is still active", { concurrency: false }, () => { + const fx = fixture({ + activeId: "model-a", + configToml: " \n", + auth: { + auth_mode: "chatgpt", + OPENAI_API_KEY: "rk-test-router-key", + tokens: { access_token: "official-access-token" }, + }, + groups: [ + { + id: "model-a", + name: "Model A", + baseUrl: "https://proxy.example.test", + key: "key-a", + model: "gpt-model-a", + mode: "responses", + }, + ], + }); + const previousFetch = global.fetch; + global.fetch = async () => { + throw new Error("offline test"); + }; + const router = loadRouter(fx); + try { + const status = router.status(); + assert.equal(status.repair.repaired, true); + assert.equal(status.repair.mode, "proxy"); + const configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8"); + assert.match(configToml, /^model_provider = "local-router"$/m); + assert.match(configToml, /^\[model_providers\.local-router\]$/m); + assert.match(configToml, /^\[model_providers\.td-direct\]$/m); + assert.match(configToml, /^base_url = "http:\/\/127\.0\.0\.1:8787"$/m); + assert.match(configToml, /^model = "gpt-model-a"$/m); + } finally { + global.fetch = previousFetch; + fs.rmSync(fx.root, { recursive: true, force: true }); + } +}); + +test("status repairs a managed provider that is missing its provider block", { concurrency: false }, () => { + const fx = fixture({ + activeId: "model-a", + configToml: [ + 'model_provider = "local-router"', + 'model = "gpt-model-a"', + "", + ].join("\n"), + auth: { + auth_mode: "chatgpt", + OPENAI_API_KEY: "rk-test-router-key", + tokens: { access_token: "official-access-token" }, + }, + groups: [ + { + id: "model-a", + name: "Model A", + baseUrl: "https://proxy.example.test", + key: "key-a", + model: "gpt-model-a", + mode: "responses", + }, + ], + }); + const previousFetch = global.fetch; + global.fetch = async () => { + throw new Error("offline test"); + }; + const router = loadRouter(fx); + try { + const status = router.status(); + assert.equal(status.repair.repaired, true); + assert.equal(status.repair.mode, "proxy"); + const configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8"); + assert.match(configToml, /^model_provider = "local-router"$/m); + assert.match(configToml, /^\[model_providers\.local-router\]$/m); + assert.match(configToml, /^\[model_providers\.td-direct\]$/m); + assert.match(configToml, /^wire_api = "responses"$/m); + } finally { + global.fetch = previousFetch; + fs.rmSync(fx.root, { recursive: true, force: true }); + } +}); + +test("status adds a missing compatibility provider without resetting the current model", { concurrency: false }, () => { + const fx = fixture({ + activeId: "model-b", + configToml: [ + 'model_provider = "td-direct"', + 'model = "gpt-5.6-sol"', + 'model_reasoning_effort = "xhigh"', + "", + "[model_providers.td-direct]", + 'name = "Model B"', + 'base_url = "https://proxy.example.test/v1"', + 'wire_api = "responses"', + "requires_openai_auth = true", + "", + ].join("\n"), + auth: { + auth_mode: "chatgpt", + OPENAI_API_KEY: "key-b", + tokens: { access_token: "official-access-token" }, + }, + groups: [ + { + id: "model-b", + name: "Model B", + baseUrl: "https://proxy.example.test", + key: "key-b", + model: "gpt-model-b", + mode: "responses", + }, + ], + }); + const router = loadRouter(fx); + try { + const status = router.status(); + assert.equal(status.repair.repaired, true); + assert.equal(status.repair.mode, "direct"); + assert.equal(status.repair.preservedModel, "gpt-5.6-sol"); + const configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8"); + assert.match(configToml, /^model_provider = "td-direct"$/m); + assert.match(configToml, /^model = "gpt-5.6-sol"$/m); + assert.match(configToml, /^model_reasoning_effort = "xhigh"$/m); + assert.match(configToml, /^\[model_providers\.td-direct\]$/m); + assert.match(configToml, /^\[model_providers\.local-router\]$/m); + assert.equal((configToml.match(/^model\s*=/gm) || []).length, 1); + } finally { + fs.rmSync(fx.root, { recursive: true, force: true }); + } +}); + + +test("status preserves the legacy default provider for historical sessions after switching relay", { concurrency: false }, () => { + const fx = fixture({ + officialAuth: { OPENAI_API_KEY: "official-key" }, + activeId: "model-b", + configToml: [ + 'model_provider = "td-direct"', + 'model = "gpt-5.6-sol"', + "", + "[model_providers.td-direct]", + 'name = "Model B"', + 'base_url = "https://proxy.example.test/v1"', + 'wire_api = "responses"', + "requires_openai_auth = true", + "", + ].join("\n"), + auth: { OPENAI_API_KEY: "key-b" }, + groups: [{ id: "model-b", name: "Model B", baseUrl: "https://proxy.example.test", key: "key-b", model: "gpt-model-b", mode: "responses" }], + }); + const routerFile = path.join(fx.userData, "router.json"); + const routerConfig = JSON.parse(fs.readFileSync(routerFile, "utf-8")); + routerConfig.groups[0].modelProvider = "custom"; + fs.writeFileSync(routerFile, JSON.stringify(routerConfig, null, 2)); + const router = loadRouter(fx); + try { + const result = router.setActive("model-b"); + assert.equal(result._mode, "direct"); + const configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8"); + assert.match(configToml, /^\[model_providers\.custom\]$/m); + assert.match(configToml, /^base_url = "https:\/\/proxy\.example\.test\/v1"$/m); + assert.match(configToml, /^wire_api = "responses"$/m); + } finally { + fs.rmSync(fx.root, { recursive: true, force: true }); + } +}); + +test("status repairs an empty Codex config when direct provider auth is still active", { concurrency: false }, () => { + const fx = fixture({ + activeId: "model-b", + configToml: " \n", + auth: { + auth_mode: "chatgpt", + OPENAI_API_KEY: "key-b", + tokens: { access_token: "official-access-token" }, + }, + groups: [ + { + id: "model-b", + name: "Model B", + baseUrl: "https://proxy.example.test", + key: "key-b", + model: "gpt-model-b", + mode: "responses", + }, + ], + }); + const previousFetch = global.fetch; + global.fetch = async () => { + throw new Error("offline test"); + }; + const router = loadRouter(fx); + try { + const status = router.status(); + assert.equal(status.repair.repaired, true); + assert.equal(status.repair.mode, "direct"); + const configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8"); + assert.match(configToml, /^model_provider = "td-direct"$/m); + assert.match(configToml, /^model = "gpt-model-b"$/m); + assert.match(configToml, /^model_reasoning_effort = "high"$/m); + assert.match(configToml, /^\[model_providers\.td-direct\]$/m); + assert.match(configToml, /^\[model_providers\.local-router\]$/m); + assert.match(configToml, /^base_url = "https:\/\/proxy\.example\.test\/v1"$/m); + } finally { + global.fetch = previousFetch; + fs.rmSync(fx.root, { recursive: true, force: true }); + } +}); + +test("Sub2API provider import is idempotent and does not touch Codex config", { concurrency: false }, () => { + const fx = fixture({ + activeId: "default", + groups: [], + }); + const router = loadRouter(fx); + const beforeConfig = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8"); + const beforeAuth = fs.readFileSync(path.join(fx.codexHome, "auth.json"), "utf-8"); + try { + const first = router.importSub2apiProvider({ + name: "Sub2API · Codex", + endpoint: "https://proxy.example.test", + apiKey: "sk-first", + model: "gpt-5.5", + sourceGroupId: "group-42", + }); + assert.equal(first._imported, true); + assert.equal(first._updated, false); + assert.equal(first.activeId, "default"); + assert.equal(first.groups.filter((g) => g.source === "sub2api").length, 1); + + const second = router.importSub2apiProvider({ + name: "Sub2API · Codex Pro", + endpoint: "https://proxy.example.test/v1", + apiKey: "sk-second", + model: "gpt-5.6", + sourceGroupId: "group-42", + }); + assert.equal(second._updated, true); + assert.equal(second.activeId, "default"); + assert.equal(second.groups.filter((g) => g.source === "sub2api").length, 1); + + const imported = second.groups.find((g) => g.source === "sub2api"); + assert.equal(imported.name, "Sub2API · Codex Pro"); + assert.equal(imported.baseUrl, "https://proxy.example.test/v1"); + assert.equal(imported.model, "gpt-5.6"); + assert.equal(imported.mode, "responses"); + assert.equal(imported.sourceGroupId, "group-42"); + + const persisted = JSON.parse(fs.readFileSync(path.join(fx.userData, "router.json"), "utf-8")); + assert.equal(persisted.groups.filter((g) => g.source === "sub2api").length, 1); + assert.equal(persisted.groups[1].key, b64("sk-second")); + assert.equal(fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8"), beforeConfig); + assert.equal(fs.readFileSync(path.join(fx.codexHome, "auth.json"), "utf-8"), beforeAuth); + } finally { + fs.rmSync(fx.root, { recursive: true, force: true }); + } +}); + test("direct switch retries a transient Windows-style auth.json lock", { concurrency: false }, () => { const fx = fixture({ activeId: "default", @@ -791,11 +1125,14 @@ test("large chunked requests are forwarded with one content-length and no client const chunks = []; req.on("data", (chunk) => chunks.push(chunk)); req.on("end", () => { - observed = { - headers: req.headers, - rawHeaders: req.rawHeaders, - body: Buffer.concat(chunks), - }; + if (/\/responses\b/.test(req.url || "")) { + observed = { + url: req.url, + headers: req.headers, + rawHeaders: req.rawHeaders, + body: Buffer.concat(chunks), + }; + } res.writeHead(200, { "content-type": "application/json" }); res.end('{"ok":true}'); }); @@ -875,6 +1212,7 @@ test("large chunked requests are forwarded with one content-length and no client assert.equal(response.statusCode, 200); assert.equal(response.body, '{"ok":true}'); assert.ok(observed); + assert.equal(observed.url, "/v1/responses"); assert.equal(observed.body.length, payload.length); assert.deepEqual(JSON.parse(observed.body.toString("utf-8")), JSON.parse(payload.toString("utf-8"))); assert.equal(observed.headers["content-length"], String(payload.length)); @@ -895,6 +1233,36 @@ test("large chunked requests are forwarded with one content-length and no client } }); +test("responses direct switch upgrades a site root base_url for Codex", { concurrency: false }, () => { + const fx = fixture({ + activeId: null, + auth: {}, + groups: [ + { + id: "first-relay", + name: "First \\ Relay", + baseUrl: "https://proxy.example.test/", + key: "first-key", + model: "gpt-first", + mode: "responses", + }, + ], + }); + const router = loadRouter(fx); + try { + const result = router.setActive("first-relay"); + assert.equal(result._mode, "direct"); + assert.equal(result._needsRestart, true); + const configToml = fs.readFileSync(path.join(fx.codexHome, "config.toml"), "utf-8"); + assert.match(configToml, /^base_url = "https:\/\/proxy\.example\.test\/v1"$/m); + assert.match(configToml, /^name = "First \\\\ Relay"$/m); + const auth = JSON.parse(fs.readFileSync(path.join(fx.codexHome, "auth.json"), "utf-8")); + assert.equal(auth.OPENAI_API_KEY, "first-key"); + } finally { + fs.rmSync(fx.root, { recursive: true, force: true }); + } +}); + test("chat multimodal group injects MiniMax evidence before forwarding to DeepSeek", { concurrency: false }, async () => { let upstreamBody; const upstream = http.createServer((req, res) => { diff --git a/test/session-restore.test.js b/test/session-restore.test.js index 9baa385..0ab748c 100644 --- a/test/session-restore.test.js +++ b/test/session-restore.test.js @@ -41,6 +41,26 @@ function writeSubagentRollout(root, id, parentId) { ].join("\n") + "\n"); } +async function writeLegacyPollutedState(root, id, subId) { + const SQL = await initSqlJs({ locateFile: (file) => path.join(__dirname, "../node_modules/sql.js/dist", file) }); + const state = new SQL.Database(); + state.run("CREATE TABLE threads (id TEXT PRIMARY KEY, source TEXT, thread_source TEXT, has_user_event INTEGER)"); + state.run("INSERT INTO threads VALUES (?,?,?,?)", [id, "vscode", "user", 1]); + state.run("INSERT INTO threads VALUES (?,?,?,?)", [subId, "[object Object]", "subagent", 1]); + fs.writeFileSync(path.join(root, "state_5.sqlite"), Buffer.from(state.export())); + state.close(); + + fs.mkdirSync(path.join(root, "sqlite"), { recursive: true }); + const catalog = new SQL.Database(); + catalog.run("CREATE TABLE local_thread_catalog (thread_id TEXT PRIMARY KEY, thread_source TEXT)"); + catalog.run("CREATE TABLE local_thread_catalog_metadata (id INTEGER PRIMARY KEY, catalog_revision INTEGER NOT NULL DEFAULT 0)"); + catalog.run("INSERT INTO local_thread_catalog VALUES (?,?)", [id, "user"]); + catalog.run("INSERT INTO local_thread_catalog VALUES (?,?)", [subId, "subagent"]); + catalog.run("INSERT INTO local_thread_catalog_metadata VALUES (1,0)"); + fs.writeFileSync(path.join(root, "sqlite", "codex-dev.db"), Buffer.from(catalog.export())); + catalog.close(); +} + test("会话恢复在没有 sqlite3 时插入线程并创建侧栏目录", { concurrency: false }, async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-session-restore-")); const dataDir = path.join(root, "codex"); @@ -146,6 +166,7 @@ test("subagent source 对象不再让 Windows(sql.js) 路径整批失败,且 const subSource = state.exec(`SELECT source FROM threads WHERE id='${subId}'`)[0].values[0][0]; assert.ok(subSource.startsWith('{"subagent"'), `source 应为 JSON 字符串,实际: ${subSource}`); assert.ok(subSource.includes("Dirac")); + assert.equal(state.exec(`SELECT has_user_event FROM threads WHERE id='${subId}'`)[0].values[0][0], 0); state.close(); // 自建 codex-dev.db 必须带 user_version(对齐 Codex 迁移链 v30), @@ -153,7 +174,61 @@ test("subagent source 对象不再让 Windows(sql.js) 路径整批失败,且 const catalog = new SQL.Database(fs.readFileSync(path.join(dataDir, "sqlite", "codex-dev.db"))); const uv = catalog.exec("PRAGMA user_version")[0].values[0][0]; assert.equal(uv, 30); - assert.equal(catalog.exec("SELECT COUNT(*) FROM local_thread_catalog")[0].values[0][0], 2); + // 子任务仍进 state 库,但不再作为 Codex 顶层侧栏任务展示; + // 否则大量 subagent 会话会污染侧栏,点击派生线程容易一直转圈。 + assert.equal(catalog.exec("SELECT COUNT(*) FROM local_thread_catalog")[0].values[0][0], 1); + catalog.close(); + } finally { + Module._load = originalLoad; + childProcess.spawnSync = originalSpawnSync; + global.__BUNDLE_ROOT__ = originalBundleRoot; + delete require.cache[PROVIDER_MODULE]; + delete require.cache[DESKTOP_MODULE]; + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("启动轻量迁移清理旧 subagent 顶层目录污染", { concurrency: false }, async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codex-session-repair-")); + const dataDir = path.join(root, "codex"); + const userData = path.join(root, "app-data"); + const id = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; + const subId = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"; + const originalLoad = Module._load; + const originalSpawnSync = childProcess.spawnSync; + const originalBundleRoot = global.__BUNDLE_ROOT__; + + try { + fs.mkdirSync(userData, { recursive: true }); + fs.mkdirSync(dataDir, { recursive: true }); + await writeLegacyPollutedState(dataDir, id, subId); + Module._load = function (request, parent, isMain) { + if (request === "electron") return { app: { getPath: () => userData } }; + return originalLoad.call(this, request, parent, isMain); + }; + childProcess.spawnSync = function (command, args, options) { + if (command === "sqlite3") return { error: new Error("sqlite3 not installed"), status: null, stdout: "", stderr: "" }; + return originalSpawnSync.call(this, command, args, options); + }; + delete require.cache[PROVIDER_MODULE]; + delete require.cache[DESKTOP_MODULE]; + global.__BUNDLE_ROOT__ = path.resolve(__dirname, ".."); + const provider = require(PROVIDER_MODULE); + const desktop = require(DESKTOP_MODULE); + desktop.codexRunning = () => false; + provider.setCodexDataDir(dataDir); + + const result = await provider.repairLegacySessionVisibility(() => {}); + assert.equal(result.stateRows, 1); + assert.equal(result.catalogRows, 1); + + const SQL = await initSqlJs({ locateFile: (file) => path.join(__dirname, "../node_modules/sql.js/dist", file) }); + const state = new SQL.Database(fs.readFileSync(path.join(dataDir, "state_5.sqlite"))); + assert.equal(state.exec(`SELECT has_user_event FROM threads WHERE id='${subId}'`)[0].values[0][0], 0); + state.close(); + const catalog = new SQL.Database(fs.readFileSync(path.join(dataDir, "sqlite", "codex-dev.db"))); + assert.equal(catalog.exec("SELECT COUNT(*) FROM local_thread_catalog")[0].values[0][0], 1); + assert.equal(catalog.exec(`SELECT COUNT(*) FROM local_thread_catalog WHERE thread_id='${subId}'`)[0].values[0][0], 0); catalog.close(); } finally { Module._load = originalLoad;