feat: 发布热更 1.5.94,修复中转历史会话并支持一键清空
This commit is contained in:
@@ -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` 段配置证书即可。
|
||||
|
||||
|
||||
@@ -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。
|
||||
@@ -2,10 +2,10 @@
|
||||
# 双击本文件即可修复 macOS「已损坏,移到废纸篓」——去掉隔离标记(quarantine)。
|
||||
# 只需运行一次。原理:本应用为免费分发、未做 Apple 公证,被系统加了隔离标记。
|
||||
|
||||
APP_NAME="Codex 插件市场.app"
|
||||
APP_NAME="TD工具箱.app"
|
||||
|
||||
echo "==================================================="
|
||||
echo " 修复 macOS「已损坏」提示 —— Codex 插件市场"
|
||||
echo " 修复 macOS「已损坏」提示 —— TD工具箱"
|
||||
echo "==================================================="
|
||||
echo
|
||||
|
||||
|
||||
+13
-2
@@ -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 引擎(全局代理用)
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
+302
-29
@@ -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,
|
||||
|
||||
+180
-5
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+224
-7
@@ -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");
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+2
-2
@@ -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 .",
|
||||
|
||||
+137
-38
@@ -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) {
|
||||
<div class="sk-subtitle">${esc(s.subtitle || s.name)}</div>
|
||||
<div class="sk-brief">${esc(s.brief || "")}</div>
|
||||
${agentBadges(s)}
|
||||
<div class="sk-meta">${meta}</div>
|
||||
<div class="sk-foot">
|
||||
<button class="btn primary small" data-install="${esc(String(s.id))}">${isIn ? "重新安装" : "安装"}</button>
|
||||
<button class="btn ghost small" data-detail="${esc(String(s.id))}">详情</button>
|
||||
<div class="sk-meta">${meta}</div>
|
||||
<div class="sk-card-actions">
|
||||
<button class="btn ghost small install" data-install="${esc(String(s.id))}" title="${isIn ? "重新安装技能" : "安装技能"}">${isIn ? "重新安装" : "安装"}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -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} · <button class="btn ghost small" id="store-more-btn">加载更多</button>`;
|
||||
$("#store-more-btn").addEventListener("click", () => renderStore(false));
|
||||
} else {
|
||||
more.classList.remove("hidden");
|
||||
more.innerHTML = `<span class="muted small">已全部显示 · 共 ${list.length} 个</span>`;
|
||||
}
|
||||
`通用 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 = `<span class="muted small">共 ${total.toLocaleString()} 个技能</span>`;
|
||||
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 ? `<span class="store-page-gap">…</span>` : "";
|
||||
last = p;
|
||||
return `${gap}<button class="store-page-btn${p === storePage ? " active" : ""}" data-store-page="${p}" aria-label="第 ${p + 1} 页" aria-current="${p === storePage ? "page" : "false"}">${p + 1}</button>`;
|
||||
}).join("");
|
||||
more.innerHTML = `
|
||||
<span class="store-page-summary">${storePage * STORE_PAGE + 1}-${Math.min((storePage + 1) * STORE_PAGE, total)} / ${total.toLocaleString()}</span>
|
||||
<div class="store-pagination" aria-label="技能分页">
|
||||
<button class="store-page-btn" data-store-page="${storePage - 1}" title="上一页" aria-label="上一页" ${storePage === 0 ? "disabled" : ""}>‹</button>
|
||||
${pageButtons}
|
||||
<button class="store-page-btn" data-store-page="${storePage + 1}" title="下一页" aria-label="下一页" ${storePage === pageCount - 1 ? "disabled" : ""}>›</button>
|
||||
</div>`;
|
||||
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 ? `<span class="nav-count">${count.toLocaleString()}</span>` : "";
|
||||
return `<div class="nav-item${active}" data-cat="${esc(key)}">
|
||||
return `<button type="button" class="nav-item${active}" data-cat="${esc(key)}" aria-pressed="${storeCat === key}">
|
||||
<span class="nav-ico">${CAT_ICON[name] || "•"}</span>
|
||||
<span class="nav-name">${esc(name)}</span>${c}</div>`;
|
||||
<span class="nav-name">${esc(name)}</span>${c}</button>`;
|
||||
};
|
||||
let html = "";
|
||||
html += `<div class="store-nav-group">精选</div>`;
|
||||
@@ -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) => `<button type="button" class="store-filter-chip${storeCat === key ? " active" : ""}" data-store-filter="${esc(key)}" aria-pressed="${storeCat === key}">${esc(name)}</button>`;
|
||||
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 = "(开发版不检查更新)";
|
||||
|
||||
+14
-7
@@ -24,7 +24,7 @@
|
||||
|
||||
<a class="promo" href="#" data-open="https://api.tokendun.cc" title="官方推荐中转 · api.tokendun.cc">
|
||||
<span class="promo-badge">官方推荐</span>
|
||||
<span class="promo-text">推荐中转 · <b>安全 · 稳定 · 可靠</b></span>
|
||||
<span class="promo-text">推荐中转 · <b>安全 · 稳定 · 可靠</b>(自备魔法访问)</span>
|
||||
<span class="promo-arrow">↗</span>
|
||||
</a>
|
||||
|
||||
@@ -53,21 +53,27 @@
|
||||
<!-- 右侧内容 -->
|
||||
<div class="store-main">
|
||||
<div class="store-head">
|
||||
<div class="store-crumb">
|
||||
<span class="muted">技能商店</span>
|
||||
<span class="store-crumb-sep">/</span>
|
||||
<span id="store-cat-title">全部技能</span>
|
||||
<div class="store-heading">
|
||||
<div class="store-title-row">
|
||||
<h2 id="store-cat-title">全部技能</h2>
|
||||
<span class="store-crumb-sep">/</span>
|
||||
<span class="store-count" id="store-count">—</span>
|
||||
</div>
|
||||
<p class="muted store-sub" id="store-cat-sub">通用 SKILL.md 标准,适配 Claude · Codex · OpenClaw</p>
|
||||
</div>
|
||||
<div class="store-tools">
|
||||
<input id="market-search" class="search" type="search" placeholder="搜索技能 / 作者 / 功能…" />
|
||||
<div class="segmented" id="store-sort">
|
||||
<div class="segmented" id="store-sort" aria-label="技能排序方式">
|
||||
<button class="seg active" data-sort="hot">热门</button>
|
||||
<button class="seg" data-sort="downloads">下载量</button>
|
||||
<button class="seg" data-sort="stars">星标</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted store-sub" id="store-cat-sub">全部技能 · 通用 SKILL.md 标准,适配 Claude · Codex · OpenClaw</p>
|
||||
<div class="store-filter-row" aria-label="快捷分类">
|
||||
<div class="store-filter-label">分类</div>
|
||||
<div class="store-category-chips" id="store-category-chips"></div>
|
||||
</div>
|
||||
<div id="market-grid" class="grid store-grid"></div>
|
||||
<div id="store-more" class="store-more hidden"></div>
|
||||
<div id="market-empty" class="empty hidden"></div>
|
||||
@@ -482,6 +488,7 @@
|
||||
<span id="sess-scope" class="muted small">当前会话</span>
|
||||
<button id="sess-refresh" class="btn ghost">刷新</button>
|
||||
<button id="sess-fetch-all" class="btn ghost">获取全部记录</button>
|
||||
<button id="sess-clear-all" class="btn danger">一键清除</button>
|
||||
<button id="sess-import" class="btn ghost">导入备份…</button>
|
||||
<button id="sess-backup" class="btn">备份全部…</button>
|
||||
</div>
|
||||
|
||||
+68
-42
@@ -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 图片 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
+107
-1
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
+374
-6
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user