Files
codex-market-app/electron/lib/provider_sync.js
T
2026-07-15 18:01:56 +08:00

195 lines
6.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// provider_sync.js —— 一键把历史会话同步到「当前账号」的 provider,使其在 Codex 侧栏重新显示。
// 复刻 CodexPlusPlus 的 provider-sync:改 rollout 首行 provider + state DB(provider/has_user_event/cwd)。
// 必须在 Codex 已退出时运行。数据库用纯 JS 的 sql.js(免原生编译,跨平台)。
const fs = require("fs");
const path = require("path");
const os = require("os");
const { execFileSync } = require("child_process");
const initSqlJs = require("sql.js");
const CODEX_HOME = path.join(os.homedir(), ".codex");
// 检测 Codex 是否在运行(尽力而为,跨平台)
function codexRunning() {
try {
if (process.platform === "darwin") {
return execFileSync("pgrep", ["-f", "Codex.app/Contents/MacOS/Codex"], { encoding: "utf-8" }).trim().length > 0;
}
if (process.platform === "win32") {
return /Codex\.exe/i.test(execFileSync("tasklist", [], { encoding: "utf-8" }));
}
} catch {
/* pgrep 无匹配时退出码非 0,视为未运行 */
}
return false;
}
// 目标 provider = config.toml 根级 model_provider,缺省 openai
function targetProvider() {
try {
const head = fs.readFileSync(path.join(CODEX_HOME, "config.toml"), "utf-8").split("\n[")[0];
const m = head.match(/^\s*model_provider\s*=\s*["']([^"']+)/m);
if (m) return m[1];
} catch {}
return "openai";
}
function findRollouts() {
const out = [];
for (const root of [path.join(CODEX_HOME, "sessions"), path.join(CODEX_HOME, "archived_sessions")]) {
const walk = (dir) => {
let ents;
try {
ents = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of ents) {
const full = path.join(dir, e.name);
if (e.isDirectory()) walk(full);
else if (e.isFile() && e.name.startsWith("rollout-") && e.name.endsWith(".jsonl")) out.push(full);
}
};
walk(root);
}
return out;
}
function ts() {
const d = new Date();
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
}
async function syncProviderToCurrent(log = () => {}, onProgress = () => {}) {
if (codexRunning()) throw new Error("检测到 Codex 正在运行,请先完全退出 Codex 再同步。");
const target = targetProvider();
log(`目标 provider:${target}`);
onProgress({ phase: "备份中", done: 0, total: 0 });
// 1) 备份
const backup = path.join(CODEX_HOME, "backups_state", "provider-sync", ts());
fs.mkdirSync(backup, { recursive: true });
for (const name of ["config.toml", ".codex-global-state.json", ".codex-global-state.json.bak"]) {
const src = path.join(CODEX_HOME, name);
if (fs.existsSync(src)) fs.copyFileSync(src, path.join(backup, name));
}
for (const db of fs.readdirSync(CODEX_HOME).filter((f) => /^state_.*\.sqlite/.test(f))) {
fs.copyFileSync(path.join(CODEX_HOME, db), path.join(backup, db));
}
log(`已备份到 ${backup}`);
// 2) 遍历 rollout:改首行 provider,收集 thread_id / cwd / has_user_event
const userEventIds = new Set();
const cwdById = {};
let changedFiles = 0;
const rolloutFiles = findRollouts();
const rtotal = rolloutFiles.length;
let ri = 0;
for (const file of rolloutFiles) {
ri++;
if (ri % 5 === 0 || ri === rtotal) onProgress({ phase: "改写会话文件", done: ri, total: rtotal });
let lines;
try {
lines = fs.readFileSync(file, "utf-8").split(/(?<=\n)/); // 保留换行
} catch {
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++) {
const line = lines[i];
if (!line.trim()) continue;
let rec;
try {
rec = JSON.parse(line);
} catch {
continue;
}
if (rec.type === "session_meta" && rec.payload && typeof rec.payload === "object") {
tid = rec.payload.id || rec.payload.session_id || null;
const cwd = rec.payload.cwd;
if (tid && cwd) cwdById[tid] = cwd;
if (rec.payload.model_provider !== target) {
rec.payload.model_provider = target;
lines[i] = JSON.stringify(rec) + "\n";
rewrote = true;
}
break;
}
}
if (rewrote) {
fs.writeFileSync(file, lines.join(""), "utf-8");
changedFiles++;
}
if (tid && hasUE) userEventIds.add(tid);
}
log(`改写 rollout 文件:${changedFiles} 个;含用户消息的会话:${userEventIds.size} 条`);
// 3) 更新所有 state_*.sqlite
onProgress({ phase: "更新数据库", done: 0, total: 0 });
const SQL = await initSqlJs({
// wasm 恒在【包内】node_modules(热更包不含 node_modules),故用 BUNDLE_ROOT 而非 __dirname
locateFile: (f) =>
path.join(global.__BUNDLE_ROOT__ || path.join(__dirname, "..", ".."),
"node_modules", "sql.js", "dist", f),
});
let provRows = 0;
let ueRows = 0;
let cwdRows = 0;
for (const name of fs.readdirSync(CODEX_HOME).filter((f) => /^state_.*\.sqlite$/.test(f))) {
const dbPath = path.join(CODEX_HOME, name);
let db;
try {
db = new SQL.Database(fs.readFileSync(dbPath));
const tbl = db.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='threads'");
if (!tbl.length) {
db.close();
continue;
}
const cols = new Set(db.exec("PRAGMA table_info(threads)")[0].values.map((r) => r[1]));
if (!cols.has("model_provider")) {
db.close();
continue;
}
db.run("UPDATE threads SET model_provider=? WHERE COALESCE(model_provider,'')<>?", [target, target]);
provRows += db.getRowsModified();
if (cols.has("has_user_event")) {
for (const tid of userEventIds) {
db.run("UPDATE threads SET has_user_event=1 WHERE id=? AND COALESCE(has_user_event,0)<>1", [tid]);
ueRows += db.getRowsModified();
}
}
if (cols.has("cwd")) {
for (const tid of Object.keys(cwdById)) {
db.run("UPDATE threads SET cwd=? WHERE id=? AND COALESCE(cwd,'')<>?", [cwdById[tid], tid, cwdById[tid]]);
cwdRows += db.getRowsModified();
}
}
fs.writeFileSync(dbPath, Buffer.from(db.export()));
db.close();
} catch (e) {
if (db) try { db.close(); } catch {}
log(` 数据库跳过 ${name}:${e.message || e}`);
}
}
log(`数据库更新:provider ${provRows} 行、has_user_event ${ueRows} 行、cwd ${cwdRows} 行`);
onProgress({ phase: "完成", done: 0, total: 0 });
return {
target,
totalConversations: rtotal,
changedFiles,
userEvents: userEventIds.size,
provRows,
ueRows,
cwdRows,
backup,
};
}
module.exports = { syncProviderToCurrent, codexRunning, targetProvider };