feat: add desktop app hot update channel

This commit is contained in:
mac
2026-07-15 18:01:56 +08:00
parent 2dc15d99ad
commit 322807c68d
45 changed files with 292746 additions and 10 deletions
+569
View File
@@ -0,0 +1,569 @@
// canvas.js —— 创意画布主进程中转执行层:复用 router.llmGroup() 的当前中转分组,
// 统一在主进程发起 image/video/tts 请求并把成品下载转 base64 data URL 返回渲染层。
// key 只在主进程流转;渲染层被 CSP 拦截的远程 URL 一律由这里下载转 base64。
const router = require("./router");
// —— 从 baseUrl 解析出主机名(失败则原样返回)——
function hostOf(baseUrl) {
try {
return new URL(baseUrl).host;
} catch {
return baseUrl || "";
}
}
// —— 去尾斜杠 ——
function trimSlash(u) {
return (u || "").replace(/\/$/, "");
}
// —— 可复用的异步 sleep(不留定时器句柄泄漏隐患)——
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// —— 统一请求头(与 router.fetchModels 的 User-Agent 一致)——
function headers(key) {
return {
Authorization: "Bearer " + (key || ""),
"User-Agent": "codex-market",
"Content-Type": "application/json",
};
}
// —— 下载任意远程资源 → base64 字符串 ——
async function downloadAsBase64(url) {
const res = await fetch(url);
if (!res.ok) throw new Error("下载失败 HTTP " + res.status);
const buf = Buffer.from(await res.arrayBuffer());
return buf.toString("base64");
}
// —— 组装 data URL ——
function toDataUrl(mime, b64) {
return `data:${mime};base64,${b64}`;
}
// ============================================================================
// 素材外链托管:兰空图床(Lsky Pro)——图生图参考图 / 图生视频素材统一走这里换公网直链
// 桌面端没有对外 Web 服务器,Seedance/火山上游只接受「可访问的图片 URL」,base64 会被拒收;
// 部分中转的图生图模型也用图片 URL 传参考图。所以 base64 素材先 POST 到兰空 /api/v1/upload
// 换成 http(s) 直链,再交给上游。
// 兰空契约:POST multipart/form-data(字段 file)+ 头 Authorization: Bearer <token>,
// 返回体公网直链在 data.links.url。
// 默认写死用户自建图床;可用环境变量 CANVAS_UPLOAD_ENDPOINT / CANVAS_UPLOAD_AUTH 覆盖(开发期切换)。
// ============================================================================
const MEDIA_HOST = {
// 兰空图床基址(不带尾斜杠)。上传端点固定为 {base}/api/v1/upload。
base: trimSlash(process.env.CANVAS_UPLOAD_ENDPOINT || "https://tc.tokendun.com"),
// 兰空上传 token(个人令牌,长期有效)。留空则上传时报错。
token: process.env.CANVAS_UPLOAD_AUTH || "9|u2ZJoV8p2KJU6zBdy1MtTbiqMV9TgbHBLvURVWwt",
};
// 从图床响应里挑出公网 URL(兰空 data.links.url,兼容 {url}/{data.url}/{data:[{url}]} 等形态)
function pickUploadedUrl(j) {
if (!j || typeof j !== "object") return "";
if (typeof j.url === "string") return j.url;
const d = j.data;
if (d && typeof d === "object") {
// 兰空 Pro:data.links.url
if (d.links && typeof d.links.url === "string") return d.links.url;
if (typeof d.url === "string") return d.url;
if (Array.isArray(d) && d[0] && typeof d[0].url === "string") return d[0].url;
}
return "";
}
// —— base64 dataURL → 兰空公网直链 ——
// 端点/令牌未配置 / 上传失败 / 未拿到合法 URL → 抛错(绝不把 base64 兜底发给上游)。
async function uploadMedia(dataUrl) {
if (!MEDIA_HOST.base || !MEDIA_HOST.token) {
throw new Error(
"素材需要图床外链:图床地址或上传令牌未配置(桌面端无法把 base64 图直接交给上游)。"
);
}
// dataURL → mime + Buffer → Blob,组 multipart(Node 18+ 原生 FormData/Blob)
const m = /^data:([^;,]+)?(;base64)?,(.*)$/is.exec(String(dataUrl || ""));
if (!m) throw new Error("参考图不是合法的 data URL");
const mime = m[1] || "image/png";
const buf = Buffer.from(m[3], m[2] ? "base64" : "utf-8");
const ext = (mime.split("/")[1] || "png").replace(/[^a-z0-9]/gi, "") || "png";
const fd = new FormData();
fd.append("file", new Blob([buf], { type: mime }), "ref." + ext);
const endpoint = MEDIA_HOST.base + "/api/v1/upload";
let res;
try {
// 注意:不手设 Content-Type,让 fetch 自动带 multipart boundary
res = await fetch(endpoint, {
method: "POST",
headers: { Authorization: "Bearer " + MEDIA_HOST.token, Accept: "application/json", "User-Agent": "codex-market" },
body: fd,
});
} catch (e) {
throw new Error("素材上传请求失败:" + String((e && e.message) || e));
}
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(`素材上传失败(${res.status}):${detail.slice(0, 200)}`);
}
let j;
try {
j = await res.json();
} catch {
throw new Error("图床返回非 JSON,无法解析上传结果");
}
// 兰空失败时也返回 200 + {status:false,message},据此给出明确错误
if (j && j.status === false) throw new Error("图床上传被拒:" + (j.message || "未知原因"));
const url = pickUploadedUrl(j);
if (!url || !/^https?:\/\//i.test(url)) throw new Error("图床未返回合法的公网 URL");
return url;
}
// —— 单个素材 → 公网直链:data URL 上传兰空换外链;http(s) 原样;空串返回 "" ——
async function resolveMat(v) {
const s = String(v || "").trim();
if (!s) return "";
if (/^data:/i.test(s)) return await uploadMedia(s);
if (/^https?:\/\//i.test(s)) return s;
throw new Error("素材需为 data URL 或公网 http(s) 地址");
}
// 当前中转是否可用:ready = 有可用 llmGroup。host 从 baseUrl 解析域名。
function status() {
const llm = router.llmGroup();
if (!llm) return { ready: false, provider: null, reason: "未配置可用中转分组" };
return {
ready: true,
provider: { name: llm.name || "", model: llm.model || "", host: hostOf(llm.baseUrl) },
};
}
// —— 读图片流式 SSE:逐行取 data:{...},partial 帧回调 onPartial,completed/末帧作最终图 ——
// 返回 { ok:true, mime, dataUrl } 或 null(非 SSE / 没拿到任何帧 → 交由调用方回退非流式)。
async function readImageSse(res, onPartial) {
const decoder = new TextDecoder();
let buf = "";
let lastB64 = ""; // 最近一帧(流自然结束时用作最终图)
let finalB64 = ""; // completed 事件的最终图
let sawData = false;
for await (const chunk of res.body) {
buf += decoder.decode(chunk, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (!line.startsWith("data:")) continue;
sawData = true;
const data = line.slice(5).trim();
if (data === "[DONE]") continue;
let ev;
try {
ev = JSON.parse(data);
} catch {
continue;
}
const b64 = ev && (ev.b64_json || (ev.data && ev.data[0] && ev.data[0].b64_json));
if (!b64) continue;
const type = String((ev && ev.type) || "");
lastB64 = b64;
if (/partial/i.test(type)) {
if (onPartial) {
try {
onPartial(toDataUrl("image/png", b64));
} catch {}
}
} else if (/completed/i.test(type)) {
finalB64 = b64;
}
}
}
if (!sawData) return null; // 首包/全程都不是 data: → 不是 SSE,回退
const b64 = finalB64 || lastB64;
if (!b64) return null;
return { ok: true, mime: "image/png", dataUrl: toDataUrl("image/png", b64) };
}
// —— 回退:非流式单次 POST —— gpt-image 不加 response_format,其它模型加 b64_json 容错 ——
async function runImageNonStream(base, llm, params, url, model) {
const isGptImage = /gpt-image/i.test(model);
const body = {
model,
prompt: params.prompt,
size: params.size || "1024x1024",
...(params.extra || {}),
};
if (!isGptImage) body.response_format = "b64_json";
// 参考图外链(图生图)——runImage 已解析好 _refUrl,非流式回退同样带上
if (params._refUrl) body[params.refField || "image"] = params._refUrl;
try {
const res = await fetch(url, { method: "POST", headers: headers(llm.key), body: JSON.stringify(body) });
if (!res.ok) {
const detail = await res.text().catch(() => "");
return { ok: false, error: `图像接口 ${res.status}:POST ${url} model=${model}${detail ? " → " + detail.slice(0, 240) : ""}` };
}
const j = await res.json();
const item = (j && j.data && j.data[0]) || {};
let b64 = item.b64_json;
const sourceUrl = item.url || "";
if (!b64 && item.url) b64 = await downloadAsBase64(item.url); // url 形式:主进程再下载转 base64
if (!b64) return { ok: false, error: `图像接口未返回 b64_json 或 url:POST ${url} model=${model}` };
return { ok: true, mime: "image/png", dataUrl: toDataUrl("image/png", b64), url: sourceUrl, _model: model };
} catch (e) {
return { ok: false, error: `图像接口请求失败:POST ${url} model=${model} → ${String((e && e.message) || e)}` };
}
}
// —— 文生图:stream-first —— POST {base}{endpointPath||"/v1/images/generations"} ——
// model 必须由用户填写(图像模型),为空不回退聊天模型,直接报错。
// 优先流式(partial 渐进帧经 onPartial 回传);非 2xx / 非 SSE / 未取到帧 → 回退非流式。
async function runImage(base, llm, params, onPartial) {
const model = params.model;
if (!model) return { ok: false, error: "请先填写图像模型" };
// 图生图:参考图(base64) → 兰空图床外链,只换一次,供流式/非流式两条路复用
if (params.image && !params._refUrl) {
const img = String(params.image).trim();
if (/^data:/i.test(img)) {
try {
params._refUrl = await uploadMedia(img);
} catch (e) {
return { ok: false, error: "参考图上传图床失败:" + String((e && e.message) || e) };
}
} else if (/^https?:\/\//i.test(img)) {
params._refUrl = img; // 已是公网URL,直接用
}
}
const path = params.endpointPath || "/v1/images/generations";
const url = base + path;
try {
const body = {
model,
prompt: params.prompt,
size: params.size || "1024x1024",
n: 1,
stream: true,
partial_images: 2,
...(params.extra || {}),
};
// 参考图外链塞进请求体(字段名可配,默认 image)
if (params._refUrl) body[params.refField || "image"] = params._refUrl;
const res = await fetch(url, {
method: "POST",
headers: headers(llm.key),
body: JSON.stringify(body),
});
const ct = res.headers.get("content-type") || "";
if (res.ok && res.body && /event-stream/i.test(ct)) {
const streamed = await readImageSse(res, onPartial);
if (streamed && streamed.ok) return { ...streamed, _model: model };
}
// 非 2xx / 非 SSE / 流里没拿到帧 → 回退非流式
} catch {
// 流式请求异常 → 回退非流式
}
return runImageNonStream(base, llm, params, url, model);
}
// —— TTS 配音:POST {base}/v1/audio/speech(响应为二进制音频)——
async function runTts(base, llm, params) {
const model = params.model || "tts-1";
const body = {
model,
input: params.text,
voice: params.voice || "alloy",
response_format: params.format || "mp3",
};
if (params.speed !== undefined && params.speed !== null && params.speed !== "") body.speed = params.speed;
const res = await fetch(base + "/v1/audio/speech", {
method: "POST",
headers: headers(llm.key),
body: JSON.stringify(body),
});
if (!res.ok) {
const detail = await res.text().catch(() => "");
return { ok: false, error: `TTS 接口返回 HTTP ${res.status}:${detail.slice(0, 300)}` };
}
const b64 = Buffer.from(await res.arrayBuffer()).toString("base64");
return { ok: true, mime: "audio/mpeg", dataUrl: toDataUrl("audio/mpeg", b64), _model: model };
}
// —— 递归深挖视频地址(覆盖 result_url、data.content.video_url、data.data.content.video_url 等嵌套)——
// port 自 提示词提取/server.js
function findVideoUrl(o) {
if (!o || typeof o === "string") return "";
if (Array.isArray(o)) {
for (const v of o) {
const u = findVideoUrl(v);
if (u) return u;
}
return "";
}
if (typeof o === "object") {
for (const k of ["video_url", "result_url", "url"]) {
const v = o[k];
if (typeof v === "string" && /^https?:\/\//.test(v)) return v;
}
for (const v of Object.values(o)) {
const u = findVideoUrl(v);
if (u) return u;
}
}
return "";
}
// —— 归一任务状态:各中转大小写/用词不一 —— port 自 提示词提取/server.js
function normalizeVideoStatus(raw) {
const s = String(raw || "").toLowerCase();
if (["succeeded", "success", "succeed", "done", "completed", "complete", "finished"].includes(s)) return "succeeded";
if (["failed", "fail", "failure", "error", "cancelled", "canceled", "timeout", "timed_out", "expired"].includes(s)) return "failed";
if (["in_progress", "running", "processing", "generating"].includes(s)) return "running";
return s || "queued";
}
function joinUrl(base, p) {
return trimSlash(base) + (String(p || "").startsWith("/") ? p : "/" + p);
}
// —— Seedance/TokenDun 视频:异步任务制(建任务 → 轮询 → 下载成品转 base64)——
// mode: "text"(文生视频,只用 prompt) | "image"(图生视频)。model 须用户填写。
// 图生视频素材两条来路:params.imageUrl(手填公网URL,优先) 或 params.image(上游 base64)。
// base64 会先经 uploadMedia() 换成公网直链再发上游(Seedance 绝不接受 base64)。
async function runVideo(base, llm, params) {
const model = params.model;
if (!model) return { ok: false, error: "请先填写视频模型" };
const createUrl = base + (params.endpointPath || "/v1/video/generations");
// 输入模式:text/first/firstlast/reference/video;兼容旧值 image→first
let mode = String(params.mode || "text");
if (mode === "image") mode = "first";
// —— body 组装:严格照中转文档(pulseaify Seedance)——扁平字段,非火山 metadata 格式 ——
// prompt 必填;duration 整数(5/10/15);ratio 顶层;quality 可选;async:true 走异步轮询
const body = { model, prompt: (params.prompt || "").trim(), async: true };
const dur = parseInt(params.seconds, 10);
if (!isNaN(dur) && dur > 0) body.duration = dur; // -1/空/auto → 省略,用中转默认(10)
if (params.ratio && params.ratio !== "auto") body.ratio = params.ratio; // 顶层 ratio
// 注:文档的 quality 字段示例值是 "hd",与 UI 的 720p/1080p 不同类,未确认合法值前不下发,避免 400
try {
// 参考图/素材:本中转支持 image(单图) / images(多图) / ref_assets;base64 先传兰空换公网URL
if (mode === "first") {
const img = await resolveMat(params.imageUrl || params.image);
if (!img) return { ok: false, error: "首帧图生视频需要首帧图:连上游文生图 / 上传 / 手填公网URL" };
body.image = img;
} else if (mode === "firstlast") {
// 本中转文档无「首尾帧」字段,退化为多图参考 images:[首,尾]
const first = await resolveMat(params.image);
const last = await resolveMat(params.lastFrameImage);
const arr = [first, last].filter(Boolean);
if (arr.length < 2) return { ok: false, error: "首尾帧模式需首帧和尾帧两张图(本中转会作多图参考处理)" };
body.images = arr;
} else if (mode === "reference") {
const refs = Array.isArray(params.referenceImages) ? params.referenceImages : [];
const resolved = (await Promise.all(refs.map((r) => resolveMat(r)))).filter(Boolean);
if (!resolved.length) return { ok: false, error: "多参考图模式需至少 1 张参考图" };
body.images = resolved;
} else if (mode === "video") {
return { ok: false, error: "该中转不支持「参考视频」模式,请改用 文生 / 首帧 / 多参考图" };
}
// 文/图生视频 prompt 都必填(文档要求)
if (!body.prompt) return { ok: false, error: "请填写提示词(本接口文生/图生视频都需要 prompt)" };
// —— 建任务 ——
const cres = await fetch(createUrl, {
method: "POST",
headers: headers(llm.key),
body: JSON.stringify(body),
});
if (!cres.ok) {
const detail = await cres.text().catch(() => "");
return { ok: false, error: `视频建任务失败(${cres.status}):${detail.slice(0, 300)}` };
}
let data;
try {
data = await cres.json();
} catch {
const detail = await cres.text().catch(() => "");
return { ok: false, error: `视频建任务返回非 JSON:${detail.slice(0, 300)}` };
}
const taskId =
(data.data && (data.data.task_id || data.data.id)) ||
data.task_id ||
data.id ||
(data.result && (data.result.task_id || data.result.id));
if (!taskId) return { ok: false, error: `视频建任务未返回任务ID:${JSON.stringify(data).slice(0, 300)}` };
// —— 轮询:每 5s,最多 120 次(~10min),对齐 提示词提取 ——
// poll 是 GET:只带 Authorization(不要带 Content-Type,个别网关对「GET 携带 body 类头」会返回非 2xx 而被误跳过)
const pollUrl = createUrl + "/" + encodeURIComponent(taskId);
const pollHeaders = { Authorization: "Bearer " + (llm.key || ""), "User-Agent": "codex-market" };
let lastPoll = ""; // 记录最近一次轮询的异常/状态,超时时回传便于排查
for (let i = 0; i < 120; i++) {
await sleep(5000);
let pd;
try {
const pr = await fetch(pollUrl, { headers: pollHeaders });
if (!pr.ok) {
lastPoll = `HTTP ${pr.status}: ` + (await pr.text().catch(() => "")).slice(0, 200);
continue;
}
pd = await pr.json();
} catch (e) {
lastPoll = "请求异常:" + String((e && e.message) || e);
continue;
}
const d = pd && pd.data && typeof pd.data === "object" && !Array.isArray(pd.data) ? pd.data : pd;
const st = normalizeVideoStatus((d && (d.status || d.state)) || (pd && pd.status));
lastPoll = "status=" + st;
if (st === "succeeded") {
const url = findVideoUrl(pd);
if (!url) return { ok: false, error: `视频任务完成但未找到视频地址:${JSON.stringify(pd).slice(0, 300)}` };
const b64 = await downloadAsBase64(url);
return { ok: true, mime: "video/mp4", dataUrl: toDataUrl("video/mp4", b64), url, _model: model };
}
if (st === "failed") {
const err = (d && d.error && (d.error.message || d.error)) || (d && (d.fail_reason || d.message || d.reason)) || (pd && pd.message) || st;
const raw = JSON.stringify(pd).slice(0, 500);
return { ok: false, error: "视频任务失败:" + (typeof err === "string" ? err : JSON.stringify(err)) + "\n上游原始返回:" + raw };
}
// queued/running → 继续等
}
return { ok: false, error: "视频轮询 10 分钟未完成(taskId=" + taskId + ",最近一次轮询:" + (lastPoll || "无") + ")" };
} catch (e) {
return { ok: false, error: "视频生成异常:" + String((e && e.message) || e) };
}
}
// —— Grok 视频:独立新增接口,按 /v1/videos/generations + /v1/videos/{request_id} 调用 ——
async function runGrokVideo(base, llm, params) {
const model = params.model || "grok-imagine-video";
const prompt = (params.prompt || "").trim();
if (!prompt) return { ok: false, error: "请填写 Grok 视频提示词" };
const body = { model, prompt };
if (params.resolution && params.resolution !== "auto") body.resolution = params.resolution;
const dur = parseInt(params.seconds, 10);
if (!isNaN(dur) && dur > 0) body.duration = dur;
let mode = String(params.mode || "text");
if (mode === "image") mode = "first";
try {
if (
mode === "text" &&
/grok-imagine-video-1\.5/i.test(model) &&
body.resolution === "1080p" &&
Number(body.duration) === 15
) {
return { ok: false, error: "1.5 的 1080p/15s 需要首帧图;请改用首帧图生视频,或降低文生视频规格" };
}
if (mode === "first") {
const img = await resolveMat(params.imageUrl || params.image);
if (!img) return { ok: false, error: "Grok 图生视频需要首帧图:上传 / 手填公网URL / 连接上游文生图" };
body.image = { image_url: img };
} else if (mode !== "text") {
return { ok: false, error: "Grok 视频新增接口只支持「文生视频」和「首帧图生视频」" };
}
const createUrl = joinUrl(base, params.endpointPath || "/v1/videos/generations");
const cres = await fetch(createUrl, {
method: "POST",
headers: headers(llm.key),
body: JSON.stringify(body),
});
if (!cres.ok) {
const detail = await cres.text().catch(() => "");
return { ok: false, error: `Grok 视频建任务失败(${cres.status}):${detail.slice(0, 300)}` };
}
const data = await cres.json();
const requestId = data.request_id || data.id || (data.data && (data.data.request_id || data.data.id));
if (!requestId) return { ok: false, error: `Grok 视频建任务未返回 request_id:${JSON.stringify(data).slice(0, 300)}` };
const pollUrl = joinUrl(base, "/v1/videos/" + encodeURIComponent(requestId));
const pollHeaders = { Authorization: "Bearer " + (llm.key || ""), Accept: "application/json", "User-Agent": "codex-market" };
let lastPoll = "";
for (let i = 0; i < 120; i++) {
await sleep(5000);
let pd;
try {
const pr = await fetch(pollUrl, { headers: pollHeaders });
if (!pr.ok) {
lastPoll = `HTTP ${pr.status}: ` + (await pr.text().catch(() => "")).slice(0, 200);
continue;
}
pd = await pr.json();
} catch (e) {
lastPoll = "请求异常:" + String((e && e.message) || e);
continue;
}
const d = pd && pd.data && typeof pd.data === "object" && !Array.isArray(pd.data) ? pd.data : pd;
const st = normalizeVideoStatus((d && (d.status || d.state)) || (pd && pd.status));
lastPoll = "status=" + st;
if (st === "succeeded") {
const url = findVideoUrl(pd);
if (!url) return { ok: false, error: `Grok 视频已完成但未找到视频地址:${JSON.stringify(pd).slice(0, 500)}` };
const b64 = await downloadAsBase64(url);
return { ok: true, mime: "video/mp4", dataUrl: toDataUrl("video/mp4", b64), url, _model: model };
}
if (st === "failed") {
const err = (d && d.error && (d.error.message || d.error)) || (d && (d.fail_reason || d.message || d.reason)) || (pd && pd.message) || st;
return { ok: false, error: "Grok 视频任务失败:" + (typeof err === "string" ? err : JSON.stringify(err)) };
}
}
return { ok: false, error: "Grok 视频轮询 10 分钟未完成(request_id=" + requestId + ",最近一次轮询:" + (lastPoll || "无") + ")" };
} catch (e) {
return { ok: false, error: "Grok 视频生成异常:" + String((e && e.message) || e) };
}
}
// 统一执行入口:dispatch 到 image/video/tts,返回 { ok, mime, dataUrl, meta } 或 { ok:false, error }。
// onPartial:可选,仅 image 流式生成时用来回传渐进帧(undefined 时安全忽略)。
async function run({ kind, params }, onPartial) {
const p = params || {};
// 解析渠道:指定 groupId 则按 id 取该分组,否则回退当前可用 llmGroup
const g = p.groupId ? router.groupById(p.groupId) : router.llmGroup();
if (!g) return { ok: false, error: "未配置可用中转分组" };
if (!g.baseUrl || !g.key) return { ok: false, error: "所选分组缺少地址或密钥" };
const base = trimSlash(g.baseUrl);
const host = hostOf(g.baseUrl);
const t0 = Date.now();
try {
let r;
if (kind === "image") r = await runImage(base, g, p, onPartial);
else if (kind === "tts") r = await runTts(base, g, p);
else if (kind === "video") r = await runVideo(base, g, p);
else if (kind === "grokVideo") r = await runGrokVideo(base, g, p);
else return { ok: false, error: "未知的画布任务类型:" + kind };
const model = (r && r._model) || p.model || g.model;
if (r && r._model !== undefined) delete r._model;
if (r && r.ok) r.meta = { model, ms: Date.now() - t0, host };
return r;
} catch (e) {
return { ok: false, error: String((e && e.message) || e), meta: { model: p.model || g.model, ms: Date.now() - t0, host } };
}
}
// 列出全部可用中转分组(供前端每个节点的「分组」下拉):返回 activeId 与精简分组信息。
function groups() {
const list = router.usableGroups();
if (!list || !list.length) return { ok: false, error: "未配置可用中转分组" };
const st = router.status();
const activeId = (st && st.activeId) || (router.llmGroup() || {}).id || null;
return {
ok: true,
activeId,
groups: list.map((g) => ({ id: g.id, name: g.name || "", model: g.model || "", host: hostOf(g.baseUrl) })),
};
}
// 拉取指定/当前中转分组可用的模型列表(复用 router.fetchModels),供前端填充模型下拉/datalist。
async function listModels(groupId) {
const g = groupId ? router.groupById(groupId) : router.llmGroup();
if (!g) return { ok: false, error: "未配置可用中转分组" };
if (!g.baseUrl || !g.key) return { ok: false, error: "所选分组缺少地址或密钥" };
try {
const models = await router.fetchModels(g.baseUrl, g.key);
return { ok: true, models };
} catch (e) {
return { ok: false, error: String((e && e.message) || e) };
}
}
module.exports = { status, groups, run, listModels, downloadAsBase64 };
+368
View File
@@ -0,0 +1,368 @@
// codex.js —— 扫描 ~/.codex、打包(排除隐私文件 + 生成辅助文件)、还原、config.toml 启用
// 忠实移植自 codex_exporter.py,纯 Node(fs / path / os)。
const fs = require("fs");
const path = require("path");
const os = require("os");
const CODEX_HOME = path.join(os.homedir(), ".codex");
const CONFIG_PATH = path.join(CODEX_HOME, "config.toml");
const ENABLED_FILE = "enabled-plugins.toml"; // 备份包里记录启用状态的文件名
// 打包时【绝对排除】的隐私 / 垃圾文件(就算勾了也不会被打包上传)
const BLOCKLIST_NAMES = new Set([
"auth.json",
"config.toml",
"history.jsonl",
".DS_Store",
"installation_id",
".codex-global-state.json",
".codex-global-state.json.bak",
]);
const BLOCKLIST_SUFFIX = [".sqlite", ".sqlite-shm", ".sqlite-wal", ".log", ".bak"];
function isBlocked(name) {
if (BLOCKLIST_NAMES.has(name)) return true;
return BLOCKLIST_SUFFIX.some((s) => name.endsWith(s));
}
function humanSize(num) {
const units = ["B", "K", "M", "G"];
let n = num;
for (const unit of units) {
if (n < 1024) {
return unit === "B" ? `${Math.round(n)}${unit}` : `${n.toFixed(1)}${unit}`;
}
n /= 1024;
}
return `${n.toFixed(1)}T`;
}
// 递归求目录大小
function dirSize(dir) {
let total = 0;
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (e) {
return 0;
}
for (const ent of entries) {
const full = path.join(dir, ent.name);
try {
if (ent.isDirectory()) {
total += dirSize(full);
} else if (ent.isFile()) {
total += fs.statSync(full).size;
}
} catch (e) {
/* 忽略无法访问的文件 */
}
}
return total;
}
// 扫描一个 codex 结构目录(可以是 ~/.codex,也可以是 clone 下来的备份)。
// 返回 [{category, source, name, path, size, rel}],rel 相对 base(用 "/" 分隔)。
function scanBase(base) {
const items = [];
const pluginsCache = path.join(base, "plugins", "cache");
const skillsDir = path.join(base, "skills");
if (isDir(pluginsCache)) {
for (const source of sortedDirNames(pluginsCache)) {
if (source.startsWith(".")) continue;
const sourcePath = path.join(pluginsCache, source);
if (!isDir(sourcePath)) continue;
for (const plugin of sortedDirNames(sourcePath)) {
if (plugin.startsWith(".")) continue;
const pluginPath = path.join(sourcePath, plugin);
if (!isDir(pluginPath)) continue;
items.push({
category: "插件 Plugin",
source,
name: plugin,
path: pluginPath,
size: dirSize(pluginPath),
rel: `plugins/cache/${source}/${plugin}`,
});
}
}
}
if (isDir(skillsDir)) {
for (const skill of sortedDirNames(skillsDir)) {
if (skill.startsWith(".")) continue;
const skillPath = path.join(skillsDir, skill);
if (!isDir(skillPath)) continue;
items.push({
category: "技能 Skill",
source: "skills",
name: skill,
path: skillPath,
size: dirSize(skillPath),
rel: `skills/${skill}`,
});
}
}
return items;
}
function isDir(p) {
try {
return fs.statSync(p).isDirectory();
} catch (e) {
return false;
}
}
function sortedDirNames(dir) {
try {
return fs.readdirSync(dir).sort();
} catch (e) {
return [];
}
}
// 拷贝目录,跳过隐私 / 垃圾文件(对齐 python copy_clean)。
function copyClean(src, dst) {
fs.mkdirSync(dst, { recursive: true });
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const ent of entries) {
if (isBlocked(ent.name)) continue;
const srcPath = path.join(src, ent.name);
const dstPath = path.join(dst, ent.name);
if (ent.isDirectory()) {
copyClean(srcPath, dstPath);
} else if (ent.isSymbolicLink()) {
try {
const link = fs.readlinkSync(srcPath);
try {
fs.unlinkSync(dstPath);
} catch (e) {
/* 目标不存在 */
}
fs.symlinkSync(link, dstPath);
} catch (e) {
/* 忽略坏链接 */
}
} else if (ent.isFile()) {
fs.copyFileSync(srcPath, dstPath);
}
}
}
// 插件在 config.toml 里的登记键: name@source 。技能返回 null。
function pluginKey(item) {
if (item.category.startsWith("插件")) {
return `${item.name}@${item.source}`;
}
return null;
}
// 从 config.toml 文本里取出所有已登记的 [plugins."KEY"] 键。
function configPluginHeaders(text) {
const set = new Set();
const re = /\[plugins\."([^"]+)"\]/g;
let m;
while ((m = re.exec(text)) !== null) {
set.add(m[1]);
}
return set;
}
// 把选中插件在本机的启用状态,写进备份包的 enabled-plugins.toml。返回记录的 keys。
function writeEnabledFile(selected, staging) {
const srcText = fs.existsSync(CONFIG_PATH)
? fs.readFileSync(CONFIG_PATH, "utf-8")
: "";
const enabledNow = configPluginHeaders(srcText);
const keys = selected
.map((it) => pluginKey(it))
.filter((k) => k && enabledNow.has(k));
const lines = [
"# 由 Codex 备份工具生成:仅记录插件启用状态,不含任何敏感配置",
"# 还原时会把这些插件在新机器的 config.toml 里标记为 enabled\n",
];
for (const k of keys) {
lines.push(`[plugins."${k}"]`);
lines.push("enabled = true\n");
}
fs.writeFileSync(path.join(staging, ENABLED_FILE), lines.join("\n"), "utf-8");
return keys;
}
// 把选中的项拷进 staging,保持相对结构,并生成辅助文件。
function packageItems(selected, staging, log) {
if (fs.existsSync(staging)) {
fs.rmSync(staging, { recursive: true, force: true });
}
fs.mkdirSync(staging, { recursive: true });
for (const it of selected) {
const dst = path.join(staging, it.rel);
fs.mkdirSync(path.dirname(dst), { recursive: true });
log(` 打包 ${it.category}: ${it.name} (${humanSize(it.size)})`);
copyClean(it.path, dst);
}
fs.writeFileSync(
path.join(staging, ".gitignore"),
[
"# 隐私 / 凭证,绝对不上传",
"auth.json",
"config.toml",
"history.jsonl",
"*.sqlite",
"*.sqlite-*",
"*.log",
"*.bak",
".DS_Store",
"installation_id",
].join("\n") + "\n",
"utf-8"
);
const restorePath = path.join(staging, "restore.sh");
fs.writeFileSync(
restorePath,
"#!/bin/bash\n" +
"# 在新电脑上运行本脚本,把插件/技能还原到 ~/.codex\n" +
"set -e\n" +
'DIR="$(cd "$(dirname "$0")" && pwd)"\n' +
'mkdir -p "$HOME/.codex/plugins/cache" "$HOME/.codex/skills"\n' +
'[ -d "$DIR/plugins/cache" ] && cp -R "$DIR/plugins/cache/." "$HOME/.codex/plugins/cache/" && echo "已还原插件"\n' +
'[ -d "$DIR/skills" ] && cp -R "$DIR/skills/." "$HOME/.codex/skills/" && echo "已还原技能"\n' +
'echo "完成 ✅ 重启 codex 生效"\n',
"utf-8"
);
try {
fs.chmodSync(restorePath, 0o755);
} catch (e) {
/* 忽略 */
}
// Windows 还原:restore.ps1(PowerShell 逻辑,用正斜杠,Windows 也接受)
fs.writeFileSync(
path.join(staging, "restore.ps1"),
[
'# 在 Windows 上运行:把插件/技能还原到 %USERPROFILE%\\.codex',
'$ErrorActionPreference = "Stop"',
'$dir = Split-Path -Parent $MyInvocation.MyCommand.Definition',
'$codex = "$env:USERPROFILE/.codex"',
'New-Item -ItemType Directory -Force -Path "$codex/plugins/cache" | Out-Null',
'New-Item -ItemType Directory -Force -Path "$codex/skills" | Out-Null',
'if (Test-Path "$dir/plugins/cache") {',
' Copy-Item -Recurse -Force -Path "$dir/plugins/cache/*" -Destination "$codex/plugins/cache"',
' Write-Host "已还原插件"',
'}',
'if (Test-Path "$dir/skills") {',
' Copy-Item -Recurse -Force -Path "$dir/skills/*" -Destination "$codex/skills"',
' Write-Host "已还原技能"',
'}',
'Write-Host "完成,重启 codex 生效"',
].join("\r\n") + "\r\n",
"utf-8"
);
// Windows 还原:restore.bat(双击即用,内部以 Bypass 策略调用 restore.ps1)
fs.writeFileSync(
path.join(staging, "restore.bat"),
[
"@echo off",
"chcp 65001 >nul",
'powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0restore.ps1"',
"echo.",
"pause",
].join("\r\n") + "\r\n",
"utf-8"
);
const lines = [
"# Codex 插件 / 技能 备份\n",
"由 Codex 备份工具生成。**已自动排除凭证文件**。\n",
"## 包含内容\n",
];
for (const it of selected) {
lines.push(`- \`${it.rel}\` (${humanSize(it.size)})`);
}
lines.push(
"\n## 在新电脑上还原",
"推荐用本工具的「从 GitHub 拉取还原」标签页;也可手动:\n",
"**macOS / Linux:**",
"```bash",
"bash restore.sh",
"```",
"**Windows:** 双击 `restore.bat`(或在 PowerShell 里运行 `restore.ps1`)\n",
"还原后重启 codex 即可。\n"
);
fs.writeFileSync(path.join(staging, "README.md"), lines.join("\n"), "utf-8");
const enabledKeys = writeEnabledFile(selected, staging);
if (enabledKeys.length) {
log(` 已记录 ${enabledKeys.length} 个插件的启用状态`);
}
log("打包完成 ✅");
}
// 把 keys 里缺失的插件登记项【追加】进本机 config.toml(带备份,不覆盖已有项)。
function enablePluginsInConfig(keys, log) {
keys = keys.filter((k) => k);
if (!keys.length) return;
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
let text = fs.existsSync(CONFIG_PATH)
? fs.readFileSync(CONFIG_PATH, "utf-8")
: "";
const existing = configPluginHeaders(text);
const toAdd = keys.filter((k) => !existing.has(k));
if (!toAdd.length) {
log(" 启用状态已是最新,config.toml 无需改动");
return;
}
// 备份
if (fs.existsSync(CONFIG_PATH)) {
const bak = CONFIG_PATH + ".bak-exporter"; // config.toml.bak-exporter
fs.copyFileSync(CONFIG_PATH, bak);
log(` 已备份 config.toml -> ${path.basename(bak)}`);
}
if (text && !text.endsWith("\n")) {
text += "\n";
}
const blocks = ["", "# --- 由 Codex 备份工具还原的插件启用项 ---"];
for (const k of toAdd) {
blocks.push(`[plugins."${k}"]`);
blocks.push("enabled = true");
blocks.push("");
}
fs.writeFileSync(CONFIG_PATH, text + blocks.join("\n"), "utf-8");
log(` 已在 config.toml 启用 ${toAdd.length} 个插件:${toAdd.join(", ")}`);
}
// 把选中的(来自 clone 的)项拷回 ~/.codex,并可选地在 config.toml 里启用插件。
function restoreItems(selected, log, enable = true) {
for (const it of selected) {
const dst = path.join(CODEX_HOME, it.rel);
fs.mkdirSync(path.dirname(dst), { recursive: true });
log(` 还原 ${it.category}: ${it.name} -> ${dst}`);
copyClean(it.path, dst);
}
if (enable) {
const keys = selected.map((it) => pluginKey(it));
enablePluginsInConfig(keys, log);
}
log("还原完成 ✅ 重启 codex 生效");
}
module.exports = {
CODEX_HOME,
CONFIG_PATH,
ENABLED_FILE,
humanSize,
dirSize,
scanBase,
copyClean,
pluginKey,
configPluginHeaders,
packageItems,
enablePluginsInConfig,
restoreItems,
};
+113
View File
@@ -0,0 +1,113 @@
// hotstate.js —— 热更新版本状态机(纯 fs,bootstrap 与 main 共用)。
// 维护 userData/hot/current.json:{ target, confirmed, blacklist[], attempts{} }
// - target :期望运行的热更版本
// - confirmed :最近一次证明健康(渲染层成功加载)的版本 = 安全回退点
// - blacklist :损坏版本,永不再选
// - attempts :未确认版本的启动尝试计数(先落盘再加载,崩溃后下次可判砖)
"use strict";
const fs = require("fs");
const path = require("path");
const MAX_TRY = 1; // 未确认版本最多尝试次数;超过即判砖回退
function statePath(hotBase) {
return path.join(hotBase, "current.json");
}
function readState(hotBase) {
try {
const s = JSON.parse(fs.readFileSync(statePath(hotBase), "utf8"));
return {
target: s.target || null,
confirmed: s.confirmed || null,
blacklist: Array.isArray(s.blacklist) ? s.blacklist : [],
attempts: s.attempts && typeof s.attempts === "object" ? s.attempts : {},
};
} catch {
return { target: null, confirmed: null, blacklist: [], attempts: {} };
}
}
function writeState(hotBase, state) {
try {
fs.mkdirSync(hotBase, { recursive: true });
fs.writeFileSync(statePath(hotBase), JSON.stringify(state, null, 2));
return true;
} catch {
return false; // 无写权限等 → 由调用方降级
}
}
// 版本目录是否为可加载的完整结构
function versionUsable(hotBase, ver) {
if (!ver) return false;
try {
return fs.existsSync(path.join(hotBase, ver, "electron", "main.js"));
} catch {
return false;
}
}
// bootstrap 调用:决定本次启动加载哪个版本。返回版本名(字符串)或 null(=用包内)。
// 有副作用:对 pending 版本自增 attempts 并【先落盘】,超限则拉黑回退。
function chooseVersion(hotBase) {
const state = readState(hotBase);
let cand = state.target;
if (!cand) return null;
// 被拉黑或目录不可用 → 回退 confirmed
if (state.blacklist.includes(cand) || !versionUsable(hotBase, cand)) {
cand = state.confirmed;
}
if (!versionUsable(hotBase, cand)) return null; // confirmed 也没了 → 包内
if (cand === state.confirmed) return cand; // 已确认健康,直接用
// cand 是刚应用、尚未确认的新版本(pending)
state.attempts[cand] = (state.attempts[cand] || 0) + 1;
const over = state.attempts[cand] > MAX_TRY;
if (over) {
// 之前给过机会仍未确认 → 判砖,拉黑并回退
if (!state.blacklist.includes(cand)) state.blacklist.push(cand);
delete state.attempts[cand];
state.target = state.confirmed;
writeState(hotBase, state);
return versionUsable(hotBase, state.confirmed) ? state.confirmed : null;
}
writeState(hotBase, state); // 关键:先落盘尝试计数,再返回让 bootstrap 加载
return cand;
}
// 加载崩溃时拉黑某版本
function blacklistVersion(hotBase, ver, reason) {
if (!ver) return;
const state = readState(hotBase);
if (!state.blacklist.includes(ver)) state.blacklist.push(ver);
delete state.attempts[ver];
if (state.target === ver) state.target = state.confirmed;
state.lastError = { ver, reason: String(reason || "").slice(0, 500) };
writeState(hotBase, state);
}
// 渲染层成功加载后确认健康
function confirmHealthy(hotBase, ver) {
if (!ver) return;
const state = readState(hotBase);
state.confirmed = ver;
state.target = ver;
delete state.attempts[ver];
state.blacklist = state.blacklist.filter((v) => v !== ver);
writeState(hotBase, state);
}
// 应用新版本后设为 target(等重启生效)
function setTarget(hotBase, ver) {
const state = readState(hotBase);
state.target = ver;
writeState(hotBase, state);
}
// 当前“有效内容版本”:优先已确认,其次包内(由调用方给 fallback)
function effectiveVersion(hotBase) {
const state = readState(hotBase);
return state.confirmed || null;
}
module.exports = {
MAX_TRY, readState, writeState, versionUsable,
chooseVersion, blacklistVersion, confirmHealthy, setTarget, effectiveVersion,
};
+137
View File
@@ -0,0 +1,137 @@
// hotupdate.js —— 热更新:拉取 manifest、下载、校验、解压应用(复用 lib/zip.js)。
"use strict";
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const { extractZip } = require("./zip");
const hotstate = require("./hotstate");
const UA = "codex-market";
const OWNER = "backup-bot";
const REPO = "backup";
const API = `https://up.tokendun.cc/api/v1/repos/${OWNER}/${REPO}`;
const HOT_TAG = "hot";
// 可选 ed25519 公钥(SPKI PEM)。留空则只做 sha256 校验;填入后强制校验签名。
const HOT_PUBKEY_PEM = "";
// 语义版本比较:a>b→1, a<b→-1, 相等→0
function cmp(a, b) {
const pa = String(a || "0").replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
const pb = String(b || "0").replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
}
return 0;
}
async function fetchJson(url, ms = 8000) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), ms);
try {
const res = await fetch(url, { headers: { "User-Agent": UA }, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} finally { clearTimeout(t); }
}
// 拉取 hot release 里的 hotupdate.json;失败返回 null(断网/无 release 时静默)
async function fetchManifest() {
let rel;
try {
rel = await fetchJson(`${API}/releases/tags/${HOT_TAG}`);
} catch { return null; }
const assets = (rel && rel.assets) || [];
const mAsset = assets.find((a) => a.name === "hotupdate.json");
if (!mAsset) return null;
try {
const m = await fetchJson(mAsset.browser_download_url || `${API.replace("/api/v1/repos", "")}/`);
// 若 bundleUrl 缺失,尝试从同 release 的 asset 里按 contentVersion 找 bundle
if (m && !m.bundleUrl && m.contentVersion) {
const b = assets.find((a) => a.name === `bundle-${m.contentVersion}.zip`);
if (b) m.bundleUrl = b.browser_download_url;
}
return m;
} catch { return null; }
}
// 评估是否有可用热更 / 是否需完整包。
// bundledHotVersion:随包内置的内容版本;appVersion:当前原生版本。
function evaluate(manifest, appVersion, bundledHotVersion, hotBase) {
if (!manifest || !manifest.contentVersion) return { hasHot: false };
const confirmed = hotstate.effectiveVersion(hotBase); // 已确认的热更版本
const effective = cmp(confirmed || "0", bundledHotVersion || "0") > 0 ? confirmed : (bundledHotVersion || "0");
const newer = cmp(manifest.contentVersion, effective) > 0;
const minOk = cmp(appVersion, manifest.minAppVersion || "0") >= 0;
const needFull = !!manifest.requiresFull || !minOk;
return {
hasHot: newer,
needFull: newer && needFull, // 有新内容但当前原生版本不够 → 必须下完整包
canHot: newer && minOk && !manifest.requiresFull,
contentVersion: manifest.contentVersion,
minAppVersion: manifest.minAppVersion || null,
notes: manifest.notes || "",
mandatory: !!manifest.mandatory,
};
}
// 下载 + 校验 + 解压 + 置 target。onProgress(percent 0..100)
async function applyManifest(manifest, hotBase, onProgress) {
if (!manifest || !manifest.bundleUrl || !manifest.sha256) {
throw new Error("热更清单不完整");
}
const ver = manifest.contentVersion;
onProgress && onProgress(1);
const res = await fetch(manifest.bundleUrl, { headers: { "User-Agent": UA } });
if (!res.ok) throw new Error(`下载失败(${res.status})`);
// 带进度读取
const total = Number(res.headers.get("content-length")) || 0;
const reader = res.body.getReader();
const chunks = [];
let got = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
got += value.length;
if (total && onProgress) onProgress(Math.min(99, Math.round((got / total) * 100)));
}
const buf = Buffer.concat(chunks.map((c) => Buffer.from(c)));
// sha256(必做)
const sum = crypto.createHash("sha256").update(buf).digest("hex");
if (sum.toLowerCase() !== String(manifest.sha256).toLowerCase()) {
throw new Error("SHA256 校验失败,已丢弃");
}
// ed25519 签名(可选:仅当内嵌公钥非空)
if (HOT_PUBKEY_PEM) {
if (!manifest.signature) throw new Error("缺少签名");
const ok = crypto.verify(
null,
Buffer.from(`${manifest.sha256}:${ver}`),
HOT_PUBKEY_PEM,
Buffer.from(manifest.signature, "base64")
);
if (!ok) throw new Error("签名校验失败");
}
// 解压到中转目录 → 校验结构 → 原子 rename
fs.mkdirSync(hotBase, { recursive: true });
const tmp = path.join(hotBase, `.tmp-${ver}`);
fs.rmSync(tmp, { recursive: true, force: true });
fs.mkdirSync(tmp, { recursive: true });
extractZip(buf, tmp);
if (!fs.existsSync(path.join(tmp, "electron", "main.js")) ||
!fs.existsSync(path.join(tmp, "renderer", "index.html"))) {
fs.rmSync(tmp, { recursive: true, force: true });
throw new Error("热更包结构不完整");
}
const dst = path.join(hotBase, ver);
fs.rmSync(dst, { recursive: true, force: true });
fs.renameSync(tmp, dst);
hotstate.setTarget(hotBase, ver); // 下次启动生效
onProgress && onProgress(100);
return { ok: true, version: ver };
}
module.exports = { fetchManifest, evaluate, applyManifest, cmp };
+873
View File
@@ -0,0 +1,873 @@
// memory.js —— 会话记忆:把 Codex 会话自动浓缩成「交接摘要」,边用边存。
// 分层记忆(Phase 0):
// 监视 ~/.codex/sessions 的 rollout-*.jsonl → 会话静默 idleSeconds 秒后自动摘要 →
// ① 存进 SQLite(userData/memory.db,历史可检索、只增不减 = 单一真源)
// ② 从库里【确定性聚合】出「项目核心记忆块」直接写进项目 AGENTS.md 标记段
// —— 关键:Codex 每次新会话必注入 AGENTS.md 全文,所以事实必须钉在这里,而不是放指针
// ③ HANDOFF.md 降级为「给人看的近期快照」;每会话摘要另 append 到 ~/.codex/memory/<hash>.md 供 grep 检索
// 摘要优先走「路由中转」里可用分组的 LLM;没配分组或调用失败则退化为本地规则提取。
// 核心块的渲染是【确定性】的(每次从库重新投影,非在上一版上叠加)→ 天然无「传话游戏」漂移。
const { app } = require("electron");
const fs = require("fs");
const path = require("path");
const os = require("os");
const crypto = require("crypto");
const initSqlJs = require("sql.js");
const sessions = require("./sessions");
const router = require("./router");
const AGENTS_START = "<!-- codex-memory:start -->";
const AGENTS_END = "<!-- codex-memory:end -->";
// —— 运行时状态 ——
let db = null;
let SQLmod = null;
let log = () => {};
let notify = () => {};
let watcher = null;
let sweepTimer = null;
let persistTimer = null;
const dirty = new Map(); // rollout 文件 → 最后一次变化的时间戳
const seenRollouts = new Set(); // 已见过的 rollout 文件(判定「新会话开始」)
const queue = [];
let working = false;
let cfg = null;
// ---------------------------------------------------------------------------
// 配置(userData/memory.json)
// ---------------------------------------------------------------------------
function cfgFile() {
return path.join(app.getPath("userData"), "memory.json");
}
function loadCfg() {
if (cfg) return cfg;
let raw = {};
try {
raw = JSON.parse(fs.readFileSync(cfgFile(), "utf-8"));
} catch {}
cfg = {
enabled: true, // 总开关
disabledProjects: [], // 关闭写入的项目 cwd 列表(记忆库照常记录,只是不写文件)
idleSeconds: 20, // 会话静默多少秒后视为「一段落」并摘要(另有「开新会话即刻结算上一个」触发)
backfillDays: 7, // 启动回填:只补最近 N 天的旧会话,防止首次启动海量调用
handoffSessions: 4, // HANDOFF.md 里保留最近几次会话
// —— 项目核心记忆块(写进 AGENTS.md,每轮注入)的预算与容量 ——
coreMaxLines: 55, // 核心块最多行数
coreMaxChars: 1400, // 核心块最多字符
coreDecisions: 12, // 核心块保留最近多少条去重决策
coreTopics: 12, // 历史话题索引保留最近多少条
archiveEnabled: true, // 是否把每会话摘要 append 到 ~/.codex/memory/<hash>.md 供 grep 检索
semanticCompact: true, // 是否用 LLM 做语义去重/取代(仅在配了中转分组时生效)
compactMinItems: 6, // 活跃条目达到多少条才值得跑一次语义整合
...raw,
};
// 兼容旧配置:老版本存的是 idleMinutes
if (typeof raw.idleMinutes === "number" && typeof raw.idleSeconds !== "number") {
cfg.idleSeconds = Math.max(10, Math.round(raw.idleMinutes * 60));
}
return cfg;
}
function saveCfg() {
fs.writeFileSync(cfgFile(), JSON.stringify(cfg, null, 2));
}
// ---------------------------------------------------------------------------
// SQLite(sql.js:内存库 + 落盘持久化)
// ---------------------------------------------------------------------------
function dbFile() {
return path.join(app.getPath("userData"), "memory.db");
}
async function ensureDb() {
if (db) return db;
if (!SQLmod) {
const wasm = require.resolve("sql.js/dist/sql-wasm.wasm");
SQLmod = await initSqlJs({ wasmBinary: fs.readFileSync(wasm) });
}
try {
db = new SQLmod.Database(fs.readFileSync(dbFile()));
} catch {
db = new SQLmod.Database();
}
db.run(`CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
ts TEXT, project TEXT, cwd TEXT, topic TEXT,
summary TEXT, decisions TEXT, next_steps TEXT, files TEXT,
rollout_path TEXT, rollout_size INTEGER, engine TEXT, updated_at TEXT
)`);
// —— Phase 1:结构化核心记忆 ——
db.run(`CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT)`);
// 每个 scope(项目 cwd)一行滚动状态
db.run(`CREATE TABLE IF NOT EXISTS core (
scope TEXT PRIMARY KEY,
state TEXT, next_steps TEXT, files TEXT, updated_at TEXT
)`);
// 决策/长期事实:一条一行。pinned=1 的长期事实(身份/偏好)永不淘汰
db.run(`CREATE TABLE IF NOT EXISTS core_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT, text TEXT, norm TEXT,
pinned INTEGER DEFAULT 0, status TEXT DEFAULT 'active',
session_id TEXT, ts TEXT
)`);
return db;
}
function persistDb() {
clearTimeout(persistTimer);
persistTimer = setTimeout(flushDb, 500);
}
function flushDb() {
clearTimeout(persistTimer);
if (!db) return;
try {
fs.writeFileSync(dbFile(), Buffer.from(db.export()));
} catch (e) {
log("会话记忆:记忆库写盘失败 " + (e.message || e));
}
}
function all(sql, params = []) {
const rows = [];
const st = db.prepare(sql);
st.bind(params);
while (st.step()) rows.push(st.getAsObject());
st.free();
return rows;
}
function parseArr(s) {
try {
const a = JSON.parse(s);
return Array.isArray(a) ? a.map(String).filter(Boolean) : [];
} catch {
return [];
}
}
// ---------------------------------------------------------------------------
// 摘要生成
// ---------------------------------------------------------------------------
function clip(s, n) {
s = String(s || "").replace(/\s+/g, " ").trim();
return s.length > n ? s.slice(0, n) + "…" : s;
}
// 把整段对话压缩成给 LLM 看的精简文本(控制 token:单条截断 + 总长掐头留尾)
function compressConv(conv) {
const parts = [];
for (const m of conv.messages) {
if (m.role === "user") parts.push("用户: " + clip(m.text, 600));
else if (m.role === "assistant") parts.push("助手: " + clip(m.text, 400));
}
let text = parts.join("\n");
if (text.length > 16000) {
text = text.slice(0, 6000) + "\n……(中间已省略)……\n" + text.slice(-10000);
}
return text;
}
const SUMMARY_PROMPT = (convText) =>
`你是"会话交接摘要器"。下面是用户与 AI 编程助手(Codex)在某个项目里的对话记录(已压缩,可能有省略)。
请只输出一个 JSON 对象(不要代码块、不要其它文字),字段:
{
"topic": "一句话主题,不超过30字",
"summary": "摘要,不超过200字。只写结论与当前状态,不写过程;要让没看过对话的人能接着干活",
"decisions": ["已确定的关键决策,每条不超过40字,没有就空数组"],
"next_steps": ["明确的下一步待办,每条不超过40字,没有就空数组"],
"files": ["对话中涉及的重要文件路径,最多8个"]
}
对话记录:
<<<
${convText}
>>>`;
// 调 LLM(复用路由中转的可用分组;chat / responses 两种协议都支持)
async function llmSummarize(convText) {
const g = router.llmGroup();
if (!g) throw new Error("未配置可用的中转分组");
const base = g.baseUrl.replace(/\/$/, "");
const prompt = SUMMARY_PROMPT(convText);
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 90000);
try {
let content = "";
const headers = {
"content-type": "application/json",
authorization: "Bearer " + g.key,
"User-Agent": "codex-market",
};
if (g.mode === "chat") {
let ep;
if (/\/chat\/completions$/.test(base)) ep = base;
else if (/\/v\d+$/.test(base)) ep = base + "/chat/completions";
else ep = base + "/v1/chat/completions";
const res = await fetch(ep, {
method: "POST",
signal: ctrl.signal,
headers,
body: JSON.stringify({ model: g.model, messages: [{ role: "user", content: prompt }], stream: false }),
});
if (!res.ok) throw new Error("HTTP " + res.status);
const j = await res.json();
content = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) || "";
} else {
const ep = /\/v\d+$/.test(base) ? base + "/responses" : base + "/v1/responses";
const res = await fetch(ep, {
method: "POST",
signal: ctrl.signal,
headers,
body: JSON.stringify({ model: g.model, input: prompt, stream: false }),
});
if (!res.ok) throw new Error("HTTP " + res.status);
const j = await res.json();
content = j.output_text || "";
if (!content && Array.isArray(j.output)) {
for (const it of j.output) {
if (it.type !== "message") continue;
for (const c of it.content || []) if (c.type === "output_text") content += c.text || "";
}
}
}
const m = String(content).match(/\{[\s\S]*\}/);
if (!m) throw new Error("LLM 未返回 JSON");
const d = JSON.parse(m[0]);
return {
topic: clip(d.topic, 60),
summary: String(d.summary || "").trim().slice(0, 600),
decisions: (Array.isArray(d.decisions) ? d.decisions : []).map((x) => clip(x, 80)).filter(Boolean).slice(0, 8),
next_steps: (Array.isArray(d.next_steps) ? d.next_steps : []).map((x) => clip(x, 80)).filter(Boolean).slice(0, 8),
files: (Array.isArray(d.files) ? d.files : []).map(String).filter(Boolean).slice(0, 8),
engine: "llm:" + (g.name || g.model || ""),
};
} finally {
clearTimeout(timer);
}
}
// 本地兜底:不调外部服务的规则提取(质量粗糙但可用)
function localSummarize(item, conv) {
const users = conv.messages.filter((m) => m.role === "user");
const asst = conv.messages.filter((m) => m.role === "assistant");
const blob = conv.messages.map((m) => m.text).join("\n");
const files = Array.from(
new Set(blob.match(/[\w./~-]+\.(?:js|ts|tsx|jsx|py|json|md|toml|yml|yaml|css|html|sh|go|rs|java|sql)\b/g) || [])
).slice(0, 8);
const summary = [
"首条需求:" + clip((users[0] && users[0].text) || item.title, 200),
asst.length ? "最后进展:" + clip(asst[asst.length - 1].text, 260) : "",
]
.filter(Boolean)
.join("\n");
return {
topic: clip(item.title || (users[0] && users[0].text) || "(无标题)", 60),
summary,
decisions: [],
next_steps: [],
files,
engine: "local",
};
}
// ---------------------------------------------------------------------------
// 摘要一条会话 → 落库 → 写 HANDOFF / AGENTS
// ---------------------------------------------------------------------------
async function summarizeFile(file) {
const items = sessions.listConversations({ includeSubagent: false });
const item = items.find((i) => i.path === file);
if (!item || !item.id) return; // 子 agent 线程或读不到元信息 → 跳过
const conv = sessions.readConversation(file);
if (!conv.messages.some((m) => m.role === "user")) return; // 空会话不摘要
let r;
try {
r = await llmSummarize(compressConv(conv));
} catch (e) {
log(`会话记忆:LLM 摘要失败(${e.message || e}),改用本地提取`);
r = localSummarize(item, conv);
}
await ensureDb();
db.run(
`INSERT INTO sessions (id, ts, project, cwd, topic, summary, decisions, next_steps, files,
rollout_path, rollout_size, engine, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
ts=excluded.ts, project=excluded.project, cwd=excluded.cwd, topic=excluded.topic,
summary=excluded.summary, decisions=excluded.decisions, next_steps=excluded.next_steps,
files=excluded.files, rollout_path=excluded.rollout_path, rollout_size=excluded.rollout_size,
engine=excluded.engine, updated_at=excluded.updated_at`,
[
item.id,
item.ts || "",
item.project || "",
item.cwd || "",
r.topic,
r.summary,
JSON.stringify(r.decisions),
JSON.stringify(r.next_steps),
JSON.stringify(r.files),
file,
item.size || 0,
r.engine,
new Date().toISOString(),
]
);
// Phase 1:把本次摘要并入结构化 core(决策去重/淘汰、长期事实 pin、滚动状态)
// Phase 2:有新决策进来时,跑一次 LLM 语义整合(近义合并 / 推翻作废,只输出操作,抗漂移)
if (item.cwd) {
try {
const added = consolidateCore(item.cwd, item.ts, item.id, r.decisions, r.summary, r.next_steps, r.files);
if (added > 0) await semanticCompact(item.cwd);
} catch (e) {
log("会话记忆:整合 core 失败 " + (e.message || e));
}
}
flushDb(); // 同步落盘:真源先持久,派生文件后写,避免 CLI/下次会话读到旧数据
if (projectWritable(item.cwd)) {
try {
writeProjectCore(item.cwd); // 核心块直接进 AGENTS.md(Codex 必读)
writeHandoff(item.cwd); // 详细快照(L2)
appendArchive(item.cwd, item, r); // grep 归档(L3,只增不删)
} catch (e) {
log("会话记忆:写核心/HANDOFF/归档失败 " + (e.message || e));
}
}
log(`会话记忆:已更新「${r.topic}」(${item.project || item.cwd || "未知项目"})`);
notify();
}
// 这个项目能不能写文件(总开关 + 项目开关 + 目录合法性)
function projectWritable(cwd) {
loadCfg();
if (!cfg.enabled) return false;
if (!cwd || cwd === os.homedir() || cwd === "/") return false; // 家目录/根目录不写,避免制造垃圾
if (cfg.disabledProjects.includes(cwd)) return false;
try {
return fs.statSync(cwd).isDirectory();
} catch {
return false;
}
}
function handoffPath(cwd) {
return path.join(cwd, "HANDOFF.md");
}
// 原子写:先写 .tmp 再 rename,避免 Codex 启动时读到写了一半的半截文件
function writeFileAtomic(file, text) {
const tmp = file + ".tmp";
fs.writeFileSync(tmp, text, "utf-8");
fs.renameSync(tmp, file);
}
// 项目 → grep 归档文件(放 ~/.codex/memory,不污染用户项目 git 树)
function hashCwd(cwd) {
return crypto.createHash("sha1").update(String(cwd)).digest("hex").slice(0, 12);
}
function archivePath(cwd) {
const d = path.join(os.homedir(), ".codex", "memory");
fs.mkdirSync(d, { recursive: true });
return path.join(d, hashCwd(cwd) + ".md");
}
// 每次会话摘要 append 到归档(只增不删,供模型 rg 检索长尾细节)
function appendArchive(cwd, item, r) {
if (!loadCfg().archiveEnabled) return;
const when = (item.ts || "").slice(0, 16).replace("T", " ");
const L = [`\n## ${when} · ${r.topic} [${item.id || ""}]`];
if (r.summary) L.push(r.summary);
if ((r.decisions || []).length) L.push("决策:" + r.decisions.join(";"));
if ((r.files || []).length) L.push("文件:" + r.files.join("、"));
try {
fs.appendFileSync(archivePath(cwd), L.join("\n") + "\n", "utf-8");
} catch {}
}
// 写 HANDOFF.md(L2):详细近期快照,给人看 / 被引导时读;核心已在 AGENTS.md,关键信息不依赖本文件
function writeHandoff(cwd) {
const rows = all(`SELECT * FROM sessions WHERE cwd = ? ORDER BY ts DESC LIMIT ?`, [cwd, loadCfg().handoffSessions]);
if (!rows.length) return;
const L = [];
L.push("<!-- 本文件由「Codex 插件市场 · 会话记忆」自动生成与覆盖,请勿手动编辑 -->");
L.push("# HANDOFF —— 会话交接快照(补充细节)");
L.push("");
L.push("> 核心记忆已写进本项目 `AGENTS.md`(Codex 每次会话自动读)。本文件是更详细的近期快照,供需要时参考;关键信息不依赖本文件。");
L.push("");
rows.forEach((r, i) => {
const dec = parseArr(r.decisions);
const next = parseArr(r.next_steps);
const files = parseArr(r.files);
const when = (r.ts || "").slice(0, 16).replace("T", " ");
L.push(`## ${i === 0 ? "最近会话" : "更早会话"} · ${when} · ${r.topic}`);
L.push("");
if (r.summary) L.push(r.summary, "");
if (dec.length) { L.push("**已确定的决策**:"); dec.forEach((d) => L.push(`- ${d}`)); L.push(""); }
if (next.length) { L.push("**下一步**:"); next.forEach((d) => L.push(`- ${d}`)); L.push(""); }
if (files.length) L.push(`**涉及文件**:${files.join("、")}`, "");
});
writeFileAtomic(handoffPath(cwd), L.join("\n"));
}
// 预算裁剪:首尾标记 + 检索指令行始终保留;超预算时优先砍末尾(历史话题),再砍靠后的内容
function enforceBudget(lines, maxLines, maxChars) {
const start = lines[0];
const end = lines[lines.length - 1];
const retrieval = lines.find((l) => l.startsWith("> 回答涉及")) || "";
let body = lines.slice(1, lines.length - 1).filter((l) => l !== retrieval);
const assemble = () => [start, ...body, retrieval, end].filter(Boolean).join("\n");
while (body.length && [start, ...body, retrieval, end].filter(Boolean).length > maxLines) body.pop();
while (body.length && assemble().length > maxChars) body.pop();
return assemble();
}
// —— Phase 1:结构化核心记忆的增量整合(同步、确定性 → 无漂移、无 RMW 竞态窗口)——
function normText(s) {
return String(s || "").toLowerCase().replace(/\s+/g, "").replace(/[。,、;:,.;:!??!「」『』()()]/g, "");
}
// 命中即视为「长期事实」(身份 / 称呼 / 偏好 / 背景)→ pinned,永不因预算淘汰
const PINNED_RE = /(称呼|叫我|叫你|用户(是|叫|名|有|为|的身份)|我(是|叫)|偏好|以后(都|请|叫|按)|习惯|喜欢|沟通(风格|方式)?|风格|背景|经验|职业|身份|资深|专职)/;
function classifyPinned(text) {
return PINNED_RE.test(String(text || "")) ? 1 : 0;
}
// 把一次会话摘要并入结构化 core:决策去重新增 + 超预算淘汰(归档不删) + 更新滚动状态
function consolidateCore(scope, ts, sessionId, decisions, summary, nextSteps, files) {
loadCfg();
const existing = all("SELECT norm FROM core_decisions WHERE scope = ? AND status='active'", [scope]).map((r) => r.norm);
const norms = new Set(existing);
let added = 0;
for (const d of decisions || []) {
const t = String(d || "").trim();
if (!t) continue;
const n = normText(t);
if (!n || norms.has(n)) continue;
norms.add(n);
added++;
db.run("INSERT INTO core_decisions (scope,text,norm,pinned,status,session_id,ts) VALUES (?,?,?,?,'active',?,?)", [
scope, t, n, classifyPinned(t), sessionId || "", ts || "",
]);
}
// 淘汰:非 pinned 的 active 决策超预算 → 归档(最旧先归档;pinned 永不淘汰、仍在库可检索)
const acts = all("SELECT id FROM core_decisions WHERE scope = ? AND status='active' AND pinned=0 ORDER BY ts DESC, id DESC", [scope]);
if (acts.length > cfg.coreDecisions) {
for (const d of acts.slice(cfg.coreDecisions)) db.run("UPDATE core_decisions SET status='archived' WHERE id = ?", [d.id]);
}
// 滚动状态(当前状态 / 下一步 / 关键文件)来自最近一次摘要
db.run(
`INSERT INTO core (scope,state,next_steps,files,updated_at) VALUES (?,?,?,?,?)
ON CONFLICT(scope) DO UPDATE SET state=excluded.state, next_steps=excluded.next_steps, files=excluded.files, updated_at=excluded.updated_at`,
[scope, clip(summary, 300), JSON.stringify((nextSteps || []).slice(0, 6)), JSON.stringify((files || []).slice(0, 8)), new Date().toISOString()]
);
return added;
}
// —— Phase 2:LLM 语义整合(只让 LLM 输出「操作」:哪些 id 是重复/被取代,不重写记忆文本 → 抗漂移)——
// 复用中转分组做一次 LLM 调用,返回原始文本
async function llmRaw(prompt) {
const g = router.llmGroup();
if (!g) throw new Error("未配置可用的中转分组");
const base = g.baseUrl.replace(/\/$/, "");
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 90000);
const headers = { "content-type": "application/json", authorization: "Bearer " + g.key, "User-Agent": "codex-market" };
try {
if (g.mode === "chat") {
let ep;
if (/\/chat\/completions$/.test(base)) ep = base;
else if (/\/v\d+$/.test(base)) ep = base + "/chat/completions";
else ep = base + "/v1/chat/completions";
const res = await fetch(ep, { method: "POST", signal: ctrl.signal, headers, body: JSON.stringify({ model: g.model, messages: [{ role: "user", content: prompt }], stream: false }) });
if (!res.ok) throw new Error("HTTP " + res.status);
const j = await res.json();
return (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) || "";
}
const ep = /\/v\d+$/.test(base) ? base + "/responses" : base + "/v1/responses";
const res = await fetch(ep, { method: "POST", signal: ctrl.signal, headers, body: JSON.stringify({ model: g.model, input: prompt, stream: false }) });
if (!res.ok) throw new Error("HTTP " + res.status);
const j = await res.json();
let content = j.output_text || "";
if (!content && Array.isArray(j.output)) {
for (const it of j.output) {
if (it.type !== "message") continue;
for (const c of it.content || []) if (c.type === "output_text") content += c.text || "";
}
}
return content;
} finally {
clearTimeout(timer);
}
}
const COMPACT_PROMPT = (list) =>
`你是"记忆去重/整合器"。下面是某项目已记录的若干事实/决策条目(每条带数字 id)。请只输出一个 JSON 对象(不要代码块、不要其它文字):
{
"duplicates": [ { "keep": <保留的id>, "remove": [<被合并的同义id>...], "text": "<该组最简洁准确的规范表述>" } ],
"superseded": [ { "old": <被后来推翻/过时的id>, "by": <取代它的新id> } ]
}
规则:
- 只合并【真正同义】的(如"称呼用户为小楼"与"用户身份称呼为小楼");拿不准就不要合并。
- superseded 只在明确"后者推翻前者"时给(如先"用 Redis"后"改用内存缓存");不确定不给。
- keep 选表述最清晰的那条;text 用该组最准确简洁的说法。
- 没有可合并/取代的,返回 {"duplicates":[],"superseded":[]}。
条目:
<<<
${list}
>>>`;
// 对某项目的活跃条目做语义整合:LLM 只判断关系,代码按 id 执行合并/作废(归档不删)
async function semanticCompact(scope) {
loadCfg();
if (!cfg.semanticCompact || !router.llmGroup()) return;
const rows = all("SELECT id, text FROM core_decisions WHERE scope = ? AND status='active' ORDER BY id ASC", [scope]);
if (rows.length < cfg.compactMinItems) return;
const idSet = new Set(rows.map((r) => r.id));
const list = rows.map((r) => `[${r.id}] ${r.text}`).join("\n");
let ops;
try {
const raw = await llmRaw(COMPACT_PROMPT(list));
const m = String(raw).match(/\{[\s\S]*\}/);
if (!m) return;
ops = JSON.parse(m[0]);
} catch (e) {
log("会话记忆:语义整合跳过(" + (e.message || e) + ")");
return; // 任何失败 → 保持现状,不动数据
}
applyCompactOps(scope, ops, idSet);
}
// 执行 LLM 给的操作,严格校验 id 属于本 scope 的活跃条目,非法一律忽略
function applyCompactOps(scope, ops, idSet) {
let changed = 0;
const valid = (id) => Number.isInteger(id) && idSet.has(id);
for (const g of (ops && ops.duplicates) || []) {
if (!valid(g.keep)) continue;
const removes = (g.remove || []).filter((id) => valid(id) && id !== g.keep);
if (!removes.length) continue;
if (g.text && String(g.text).trim()) {
const t = String(g.text).trim();
db.run("UPDATE core_decisions SET text = ?, norm = ? WHERE id = ? AND scope = ?", [t, normText(t), g.keep, scope]);
}
for (const id of removes) {
db.run("UPDATE core_decisions SET status='merged' WHERE id = ? AND scope = ?", [id, scope]);
idSet.delete(id);
changed++;
}
}
for (const s of (ops && ops.superseded) || []) {
if (!valid(s.old) || !valid(s.by) || s.old === s.by) continue;
db.run("UPDATE core_decisions SET status='superseded', superseded_by = ? WHERE id = ? AND scope = ?", [s.by, s.old, scope]);
idSet.delete(s.old);
changed++;
}
if (changed) {
flushDb();
log(`会话记忆:语义整合合并/作废 ${changed} 条`);
}
}
// 一次性迁移:把已有 sessions 按时间正序 replay 进结构化 core(幂等,靠 meta 标记只跑一次)
function migrateCoreFromSessions() {
let done = false;
try {
done = all("SELECT v FROM meta WHERE k='core_migrated'").length > 0;
} catch {}
if (done) return;
const rows = all("SELECT * FROM sessions WHERE cwd != '' ORDER BY ts ASC");
for (const r of rows) {
consolidateCore(r.cwd, r.ts, r.id, parseArr(r.decisions), r.summary, parseArr(r.next_steps), parseArr(r.files));
}
db.run("INSERT OR REPLACE INTO meta (k,v) VALUES ('core_migrated','1')");
flushDb();
}
// 【核心】从结构化 core 表渲染「项目核心记忆块」。pinned 长期事实置顶且永不被预算裁掉。
function renderProjectCore(cwd) {
loadCfg();
const c = all("SELECT * FROM core WHERE scope = ?", [cwd])[0];
const pinned = all("SELECT text FROM core_decisions WHERE scope = ? AND pinned=1 AND status='active' ORDER BY id ASC", [cwd]).map((r) => r.text);
const decs = all("SELECT text FROM core_decisions WHERE scope = ? AND pinned=0 AND status='active' ORDER BY ts DESC, id DESC LIMIT ?", [cwd, cfg.coreDecisions]).map((r) => r.text);
const topicRows = all("SELECT ts, topic FROM sessions WHERE cwd = ? ORDER BY ts DESC LIMIT ?", [cwd, cfg.coreTopics]);
if (!c && !pinned.length && !decs.length && !topicRows.length) return null;
const nextSteps = c ? parseArr(c.next_steps).slice(0, 6) : [];
const files = c ? parseArr(c.files) : [];
const L = [];
L.push(AGENTS_START);
L.push("## 项目记忆(Codex 插件市场自动维护 · 回答前据此 · 勿手动编辑本段)");
if (pinned.length) { L.push("**关于用户 / 长期事实(务必遵守)**:"); pinned.forEach((d) => L.push(`- ${d}`)); }
if (c && c.state) L.push(`**当前状态**:${clip(c.state, 300)}`);
if (decs.length) { L.push("**已定决策**:"); decs.forEach((d) => L.push(`- ${d}`)); }
if (nextSteps.length) { L.push("**下一步**:"); nextSteps.forEach((d) => L.push(`- ${d}`)); }
if (files.length) L.push(`**关键文件**:${files.join("、")}`);
if (topicRows.length) { L.push("**历史话题(需其中细节时用下面命令检索)**:"); topicRows.forEach((r) => L.push(`- ${(r.ts || "").slice(0, 10)} ${r.topic}`)); }
L.push(`> 回答涉及本段未列出的历史细节前,先运行 \`rg -n "关键词" ${archivePath(cwd)}\` 查证再答。`);
L.push(AGENTS_END);
return enforceBudget(L, cfg.coreMaxLines, cfg.coreMaxChars);
}
// 把核心记忆块写进项目 AGENTS.md:写前重读,只替换标记段,块外内容原样保留;标记残缺则放弃不硬盖
function writeProjectCore(cwd) {
const core = renderProjectCore(cwd);
if (!core) return;
const f = path.join(cwd, "AGENTS.md");
let text = "";
try { text = fs.readFileSync(f, "utf-8"); } catch {}
const si = text.indexOf(AGENTS_START);
const ei = text.indexOf(AGENTS_END);
let nt;
if (si >= 0 && ei > si) {
nt = text.slice(0, si) + core + text.slice(ei + AGENTS_END.length);
} else if (si >= 0 || ei >= 0) {
log("会话记忆:AGENTS.md 记忆标记不完整,跳过本次更新以免破坏文件");
return;
} else {
nt = (text ? text.trimEnd() + "\n\n" : "") + core + "\n";
}
if (nt !== text) writeFileAtomic(f, nt);
}
// 新版首启:先把已有 sessions 迁移进结构化 core,再把所有已知项目 AGENTS.md 升级为核心块格式
function refreshAllCores() {
try {
migrateCoreFromSessions();
} catch (e) {
log("会话记忆:core 迁移失败 " + (e.message || e));
}
let cwds = [];
try {
cwds = all("SELECT DISTINCT cwd FROM sessions WHERE cwd != ''").map((r) => r.cwd);
} catch {
return;
}
for (const cwd of cwds) {
if (!projectWritable(cwd)) continue;
try {
writeProjectCore(cwd);
writeHandoff(cwd);
} catch {}
}
}
// ---------------------------------------------------------------------------
// 监视 + 队列(串行处理,避免并发打爆中转 API)
// ---------------------------------------------------------------------------
function enqueue(file) {
if (!queue.includes(file)) queue.push(file);
pump();
}
async function pump() {
if (working) return;
working = true;
try {
while (queue.length) {
if (!loadCfg().enabled) {
queue.length = 0;
break;
}
const file = queue.shift();
try {
await summarizeFile(file);
} catch (e) {
log("会话记忆:摘要失败 " + (e.message || e));
}
}
} finally {
working = false;
}
}
// 定时清点:静默超过 idleSeconds 的脏文件 → 入队摘要
function sweep() {
const idleMs = loadCfg().idleSeconds * 1000;
const now = Date.now();
for (const [file, t] of dirty) {
if (now - t >= idleMs) {
dirty.delete(file);
enqueue(file);
}
}
}
// 立刻结算「除 exceptFile 外」所有待处理会话(用于「开新会话即刻把上一个存好」)
function flushPending(exceptFile) {
for (const [file] of dirty) {
if (file === exceptFile) continue;
dirty.delete(file);
enqueue(file);
}
}
// 启动回填:把上次运行后新增/长大的会话补摘要(限最近 N 天、单次最多 20 个)
// 一次性:对所有已知项目跑语义整合,清理迁移进来的近义冗余(meta 标记,仅首次 Phase 2 启动跑)
async function compactAllOnce() {
loadCfg();
if (!cfg.semanticCompact || !router.llmGroup()) return;
let done = false;
try {
done = all("SELECT v FROM meta WHERE k='compact_v1'").length > 0;
} catch {}
if (done) return;
const cwds = all("SELECT DISTINCT cwd FROM sessions WHERE cwd != ''").map((r) => r.cwd);
for (const cwd of cwds) {
try {
await semanticCompact(cwd);
if (projectWritable(cwd)) writeProjectCore(cwd);
} catch {}
}
db.run("INSERT OR REPLACE INTO meta (k,v) VALUES ('compact_v1','1')");
flushDb();
}
async function backfill() {
await ensureDb();
loadCfg();
if (!cfg.enabled) return;
refreshAllCores(); // 首启即把已有项目 AGENTS.md 升级为核心块格式
compactAllOnce().catch(() => {}); // 后台一次性语义整合已有近义项
const cutoff = Date.now() - cfg.backfillDays * 86400000;
const known = new Map(all("SELECT rollout_path, rollout_size FROM sessions").map((r) => [r.rollout_path, r.rollout_size]));
const items = sessions.listConversations({ includeSubagent: false });
let queued = 0;
let skippedOld = 0;
for (const it of items) {
const t = Date.parse(it.ts || "");
if (!t || t < cutoff) {
if (!known.has(it.path)) skippedOld++;
continue;
}
if (known.get(it.path) === it.size) continue; // 没变化
enqueue(it.path);
if (++queued >= 20) break;
}
if (queued) log(`会话记忆:发现 ${queued} 个新增/有变化的会话,开始后台摘要…`);
if (skippedOld) log(`会话记忆:${skippedOld} 个 ${cfg.backfillDays} 天前的旧会话未自动回填(可在「会话记忆」页对单条手动摘要)`);
}
function start(opts = {}) {
if (opts.log) log = opts.log;
if (opts.notify) notify = opts.notify;
loadCfg();
// 先把已存在的会话标记为「已见」,这样只有启动后【新开】的会话才触发「即刻结算上一个」
try {
for (const it of sessions.listConversations({ includeSubagent: true })) seenRollouts.add(it.path);
} catch {}
// 监视器常开(总开关只控制是否处理),这样开关切换无需重启
try {
fs.mkdirSync(sessions.SESSIONS_DIR, { recursive: true });
watcher = fs.watch(sessions.SESSIONS_DIR, { recursive: true }, (_ev, name) => {
if (!name || !/rollout-.*\.jsonl$/.test(String(name))) return;
const full = path.join(sessions.SESSIONS_DIR, String(name));
// 「开新会话即刻结算上一个」:首次见到某 rollout 文件 = 新会话开始 → 立刻把其它待处理的(多半是刚关掉的那个)先存好
if (!seenRollouts.has(full)) {
seenRollouts.add(full);
flushPending(full);
}
dirty.set(full, Date.now());
});
} catch (e) {
log("会话记忆:无法监视 sessions 目录 " + (e.message || e));
}
sweepTimer = setInterval(sweep, 5000);
backfill().catch((e) => log("会话记忆:启动扫描失败 " + (e.message || e)));
}
function stop() {
try {
if (watcher) watcher.close();
} catch {}
watcher = null;
clearInterval(sweepTimer);
flushDb(); // 退出前把内存库落盘
}
// ---------------------------------------------------------------------------
// 给 UI 的接口
// ---------------------------------------------------------------------------
async function statusInfo() {
await ensureDb();
loadCfg();
const g = router.llmGroup();
const projects = all(
`SELECT cwd, project, COUNT(*) AS n, MAX(ts) AS last FROM sessions WHERE cwd != '' GROUP BY cwd ORDER BY last DESC`
).map((p) => ({
cwd: p.cwd,
project: p.project || path.basename(p.cwd || ""),
count: p.n,
last: p.last,
enabled: !cfg.disabledProjects.includes(p.cwd),
hasHandoff: fs.existsSync(handoffPath(p.cwd)),
}));
return {
enabled: cfg.enabled,
idleSeconds: cfg.idleSeconds,
engine: g ? `LLM 摘要(分组「${g.name}」· ${g.model})` : "本地提取(未配置可用中转分组,质量有限)",
total: (all("SELECT COUNT(*) AS n FROM sessions")[0] || { n: 0 }).n,
pending: queue.length + dirty.size,
projects,
};
}
async function list({ project = "", q = "", limit = 200 } = {}) {
await ensureDb();
let sql = "SELECT * FROM sessions";
const cond = [];
const params = [];
if (project) {
cond.push("cwd = ?");
params.push(project);
}
if (q) {
cond.push("(topic LIKE ? OR summary LIKE ? OR decisions LIKE ? OR project LIKE ?)");
const s = `%${q}%`;
params.push(s, s, s, s);
}
if (cond.length) sql += " WHERE " + cond.join(" AND ");
sql += " ORDER BY ts DESC LIMIT ?";
params.push(limit);
return all(sql, params).map((r) => ({
...r,
decisions: parseArr(r.decisions),
next_steps: parseArr(r.next_steps),
files: parseArr(r.files),
}));
}
function setEnabled(on) {
loadCfg();
cfg.enabled = !!on;
saveCfg();
if (cfg.enabled) backfill().catch(() => {});
}
function toggleProject(cwd, on) {
loadCfg();
const set = new Set(cfg.disabledProjects);
if (on) set.delete(cwd);
else set.add(cwd);
cfg.disabledProjects = Array.from(set);
saveCfg();
if (on && projectWritable(cwd)) {
try {
writeProjectCore(cwd);
writeHandoff(cwd);
} catch {}
}
}
async function resummarize(file) {
await summarizeFile(file);
}
async function deleteRow(id) {
await ensureDb();
db.run("DELETE FROM sessions WHERE id = ?", [id]);
persistDb();
notify();
}
module.exports = {
start,
stop,
statusInfo,
list,
setEnabled,
toggleProject,
resummarize,
deleteRow,
handoffPath,
renderProjectCore,
writeProjectCore,
semanticCompact,
applyCompactOps,
};
+194
View File
@@ -0,0 +1,194 @@
// 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 };
+673
View File
@@ -0,0 +1,673 @@
// proxy.js —— 全局 / 系统代理:内置 sing-box,把「要密码的上游 SOCKS5」转成
// ① 系统代理:本地 mixed 端口(不要密码) + 设置 macOS/Windows 系统代理指过来;
// ② 全局 TUN:创建虚拟网卡接管整机流量(需管理员权限,启动时弹一次密码框)。
// macOS 系统代理不会发送用户名/密码,所以必须走本地中转——这正是本模块存在的原因。
const { app, safeStorage } = require("electron");
const { spawn, execFile } = require("child_process");
const { promisify } = require("util");
const fs = require("fs");
const net = require("net");
const path = require("path");
const http = require("http");
const pExecFile = promisify(execFile);
// —— 运行时状态(内存)——
let state = {
mode: "off", // off | system | global
child: null, // sing-box 子进程(系统模式) 或 osascript/powershell 提权进程(全局模式)
localPort: 0, // 系统模式本地 mixed 端口
services: [], // macOS:已设置系统代理的网络服务名,停止时逐一还原
onChange: null, // 状态变化回调(通知前端 + 托盘)
};
function setOnChange(cb) {
state.onChange = cb;
}
function emit() {
if (typeof state.onChange === "function") {
try {
state.onChange(status());
} catch {}
}
}
// ---------------------------------------------------------------------------
// 路径:sing-box 二进制 / 运行时目录 / 配置文件
// ---------------------------------------------------------------------------
function resDir() {
// 打包后二进制在 resources/singbox;开发时在项目 resources/singbox
return app.isPackaged
? path.join(process.resourcesPath, "singbox")
: path.join(__dirname, "..", "..", "resources", "singbox");
}
function binPath() {
const plat = process.platform;
let sub;
let name = "sing-box";
if (plat === "darwin") sub = process.arch === "arm64" ? "darwin-arm64" : "darwin-x64";
else if (plat === "win32") {
sub = "windows-x64";
name = "sing-box.exe";
} else {
throw new Error("全局代理暂只支持 macOS 与 Windows");
}
return path.join(resDir(), sub, name);
}
function binExists() {
try {
return fs.existsSync(binPath());
} catch {
return false;
}
}
function runtimeDir() {
const d = path.join(app.getPath("userData"), "proxy-runtime");
fs.mkdirSync(d, { recursive: true });
return d;
}
function cfgPath() {
return path.join(app.getPath("userData"), "proxy.json");
}
// ---------------------------------------------------------------------------
// 配置持久化(proxyStr 用 safeStorage 加密)
// ---------------------------------------------------------------------------
function enc(s) {
if (!s) return "";
try {
if (safeStorage.isEncryptionAvailable()) return "v1:" + safeStorage.encryptString(s).toString("base64");
} catch {}
return "b64:" + Buffer.from(s, "utf-8").toString("base64");
}
function dec(s) {
if (!s) return "";
try {
if (s.startsWith("v1:")) return safeStorage.decryptString(Buffer.from(s.slice(3), "base64"));
if (s.startsWith("b64:")) return Buffer.from(s.slice(4), "base64").toString("utf-8");
} catch {}
return "";
}
let conf = null;
function defaultConf() {
return { proxyStr: "", localPort: 0, appliedPort: 0 };
}
function loadConf() {
if (conf) return conf;
try {
const raw = JSON.parse(fs.readFileSync(cfgPath(), "utf-8"));
raw.proxyStr = raw.proxyStr ? dec(raw.proxyStr) : "";
conf = { ...defaultConf(), ...raw };
} catch {
conf = defaultConf();
}
return conf;
}
function saveConf() {
const out = { ...conf, proxyStr: enc(conf.proxyStr) };
const p = cfgPath();
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(out, null, 2));
try {
fs.chmodSync(p, 0o600);
} catch {}
}
function getConfig() {
loadConf();
return { proxyStr: conf.proxyStr, localPort: conf.localPort || 0 };
}
function setConfig({ proxyStr, localPort } = {}) {
loadConf();
if (proxyStr !== undefined) conf.proxyStr = String(proxyStr || "").trim();
if (localPort !== undefined) conf.localPort = parseInt(localPort, 10) || 0;
saveConf();
return getConfig();
}
// ---------------------------------------------------------------------------
// 解析上游代理字符串:支持
// host:port:user:pass / host:port / socks5://user:pass@host:port
// ---------------------------------------------------------------------------
function parseProxy(str) {
str = String(str || "").trim();
if (!str) return null;
const m = str.match(/^socks5?:\/\/(?:([^:@/]+):([^@/]+)@)?([^:/@]+):(\d+)/i);
if (m) return { host: m[3], port: parseInt(m[4], 10), user: m[1] || "", pass: m[2] || "" };
const parts = str.split(":");
if (parts.length === 4 && /^\d+$/.test(parts[1])) {
return { host: parts[0], port: parseInt(parts[1], 10), user: parts[2], pass: parts[3] };
}
if (parts.length === 2 && /^\d+$/.test(parts[1])) {
return { host: parts[0], port: parseInt(parts[1], 10), user: "", pass: "" };
}
return null;
}
// ---------------------------------------------------------------------------
// 找空闲端口(优先用用户指定端口,占用则随机取)
// ---------------------------------------------------------------------------
function findFreePort(preferred) {
return new Promise((resolve) => {
const tryListen = (port, fallback) => {
const srv = net.createServer();
srv.once("error", () => {
if (fallback) tryListen(0, false);
else resolve(0);
});
srv.listen(port, "127.0.0.1", () => {
const p = srv.address().port;
srv.close(() => resolve(p));
});
};
tryListen(preferred || 0, !!preferred);
});
}
// ---------------------------------------------------------------------------
// sing-box 配置生成
// ---------------------------------------------------------------------------
function outbounds(p) {
const proxy = { type: "socks", tag: "proxy", server: p.host, server_port: p.port, version: "5" };
if (p.user) {
proxy.username = p.user;
proxy.password = p.pass;
}
return [proxy, { type: "direct", tag: "direct" }];
}
function systemConfig(p, port) {
return {
log: { level: "warn" },
inbounds: [{ type: "mixed", tag: "mixed-in", listen: "127.0.0.1", listen_port: port }],
outbounds: outbounds(p),
route: { final: "proxy" },
};
}
function globalConfig(p, logPath) {
return {
// 让 sing-box 直接把日志写到文件(UTF-8),不依赖 shell 重定向 → 中文路径无损、判断启动可靠
log: { level: "info", output: logPath, timestamp: true },
dns: {
servers: [
// 用 TCP-53 远程 DNS(经代理解析)——比 DoT(853) 更通用,避免代理不放行加密 DNS 导致全站解析失败
{ type: "tcp", tag: "remote-dns", server: "8.8.8.8", detour: "proxy" },
{ type: "local", tag: "local-dns" },
],
final: "remote-dns",
strategy: "ipv4_only",
},
inbounds: [
// gvisor 用户态协议栈:Windows 上比 system 栈更稳、兼容性好
{ type: "tun", tag: "tun-in", address: ["172.19.0.1/30"], auto_route: true, strict_route: true, stack: "gvisor" },
],
outbounds: outbounds(p),
route: {
auto_detect_interface: true,
default_domain_resolver: "local-dns",
rules: [
{ action: "sniff" }, // 嗅探协议,供下面识别 DNS / QUIC
// 关键:把 DNS 查询劫持给 sing-box 自己用 TCP 解析。否则浏览器的 UDP DNS 被塞进 TUN → 交给只支持 TCP 的 SOCKS5 → 丢弃 → 全站解析失败 = 没网
{ protocol: "dns", action: "hijack-dns" },
{ port: 53, action: "hijack-dns" },
// QUIC(HTTP/3, UDP 443)经只支持 TCP 的 SOCKS5 会全丢 → 直接拒绝,逼浏览器回落到 TCP
{ network: "udp", port: 443, action: "reject" },
],
final: "proxy",
},
};
}
function writeCfg(name, obj) {
const p = path.join(runtimeDir(), name);
fs.writeFileSync(p, JSON.stringify(obj, null, 2));
return p;
}
// ---------------------------------------------------------------------------
// macOS:系统代理设置 / 还原
// ---------------------------------------------------------------------------
async function macServices() {
// 列出所有网络服务,去掉标题行与被禁用(带*)的
const { stdout } = await pExecFile("networksetup", ["-listallnetworkservices"]);
return stdout
.split("\n")
.slice(1)
.map((s) => s.trim())
.filter((s) => s && !s.startsWith("*"));
}
async function macSetSystemProxy(port) {
const services = await macServices();
const applied = [];
for (const svc of services) {
try {
await pExecFile("networksetup", ["-setwebproxy", svc, "127.0.0.1", String(port)]);
await pExecFile("networksetup", ["-setsecurewebproxy", svc, "127.0.0.1", String(port)]);
await pExecFile("networksetup", ["-setsocksfirewallproxy", svc, "127.0.0.1", String(port)]);
applied.push(svc);
} catch {
/* 某个服务设置失败(如未连接)则跳过 */
}
}
return applied;
}
async function macUnsetSystemProxy(services) {
for (const svc of services || []) {
for (const kind of ["-setwebproxystate", "-setsecurewebproxystate", "-setsocksfirewallproxystate"]) {
try {
await pExecFile("networksetup", [kind, svc, "off"]);
} catch {}
}
}
}
// ---------------------------------------------------------------------------
// Windows:系统代理设置 / 还原(HKCU,无需管理员)
// ---------------------------------------------------------------------------
const WIN_INET = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings";
// 通知 WinINET 代理设置已变更,让浏览器(Chrome/Edge 等)立即重新读取,而不是等重启
async function winRefreshInet() {
const ps =
`$s='[DllImport("wininet.dll",SetLastError=true)]public static extern bool InternetSetOption(IntPtr h,int o,IntPtr b,int l);';` +
`$t=Add-Type -MemberDefinition $s -Name W -Namespace I -PassThru;` +
`$t::InternetSetOption([IntPtr]::Zero,39,[IntPtr]::Zero,0)|Out-Null;` + // INTERNET_OPTION_SETTINGS_CHANGED
`$t::InternetSetOption([IntPtr]::Zero,37,[IntPtr]::Zero,0)|Out-Null`; // INTERNET_OPTION_REFRESH
await pExecFile("powershell", ["-NoProfile", "-Command", ps]).catch(() => {});
}
async function winSetSystemProxy(port) {
await pExecFile("reg", ["add", WIN_INET, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "1", "/f"]);
await pExecFile("reg", ["add", WIN_INET, "/v", "ProxyServer", "/d", `127.0.0.1:${port}`, "/f"]);
// 排除本地地址直连
await pExecFile("reg", ["add", WIN_INET, "/v", "ProxyOverride", "/d", "localhost;127.*;<local>", "/f"]).catch(() => {});
await winRefreshInet();
}
async function winUnsetSystemProxy() {
await pExecFile("reg", ["add", WIN_INET, "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f"]).catch(() => {});
await winRefreshInet();
}
// ---------------------------------------------------------------------------
// 系统代理模式:启动本地 sing-box mixed + 设系统代理
// ---------------------------------------------------------------------------
async function startSystem(p) {
const port = await findFreePort(conf.localPort || 0);
if (!port) throw new Error("找不到可用的本地端口");
const cfg = writeCfg("system.json", systemConfig(p, port));
const child = spawn(binPath(), ["run", "-c", cfg], { stdio: ["ignore", "pipe", "pipe"] });
let stderr = "";
child.stderr.on("data", (d) => (stderr += d.toString()));
await new Promise((resolve, reject) => {
let settled = false;
child.on("error", (e) => {
if (!settled) {
settled = true;
reject(e);
}
});
child.on("exit", (code) => {
if (!settled) {
settled = true;
reject(new Error(`sing-box 启动失败(${code}):${stderr.slice(0, 300)}`));
}
});
// 轮询本地端口,通了就算启动成功
const t0 = Date.now();
const poll = () => {
const s = net.connect(port, "127.0.0.1");
s.on("connect", () => {
s.destroy();
if (!settled) {
settled = true;
resolve();
}
});
s.on("error", () => {
s.destroy();
if (Date.now() - t0 > 8000) {
if (!settled) {
settled = true;
reject(new Error("sing-box 端口未就绪:" + stderr.slice(0, 200)));
}
} else setTimeout(poll, 300);
});
};
poll();
});
// 端口通了 → 设系统代理
let services = [];
if (process.platform === "darwin") services = await macSetSystemProxy(port);
else if (process.platform === "win32") await winSetSystemProxy(port);
state.mode = "system";
state.child = child;
state.localPort = port;
state.services = services;
conf.appliedPort = port;
saveConf();
child.on("exit", () => {
// 子进程意外退出 → 复位(并尝试还原系统代理)
if (state.child === child) {
cleanupSystemProxy().catch(() => {});
state.mode = "off";
state.child = null;
emit();
}
});
emit();
return status();
}
async function cleanupSystemProxy() {
if (process.platform === "darwin") await macUnsetSystemProxy(state.services);
else if (process.platform === "win32") await winUnsetSystemProxy();
state.services = [];
}
// ---------------------------------------------------------------------------
// 全局 TUN 模式:提权启动 sing-box(flag 文件做“可停止”,全程只弹一次密码)
// 原理:提权 shell 里后台跑 sing-box,前台 while 循环盯住 flag 文件;
// 我方(普通权限)删除 flag → 提权循环发现文件没了 → kill sing-box 退出。
// ---------------------------------------------------------------------------
function flagPath() {
return path.join(runtimeDir(), "global.flag");
}
function stripAnsi(s) {
return String(s).replace(/\x1b\[[0-9;]*m/g, "");
}
// 读日志:Windows 的系统错误常是 GBK 中文,UTF-8 读出来是乱码 → 检测到乱码就用 GBK 兜底
function readLogSmart(logf) {
let buf;
try {
buf = fs.readFileSync(logf);
} catch {
return "";
}
let s = buf.toString("utf-8");
if (s.includes("�")) {
for (const enc of ["gbk", "gb18030", "big5"]) {
try {
const d = new TextDecoder(enc).decode(buf);
if (!d.includes("�")) {
s = d;
break;
}
} catch {}
}
}
return stripAnsi(s);
}
// 盯 sing-box 日志判断启动成败(mac / Windows 通用):
// 出现 "sing-box started" → 成功;出现 FATAL → 失败;
// 提权进程(仅 mac)提前退出且无 started → 取消授权;超时同理。
function waitForStart(logf, timeoutMs, child, watchChildExit) {
return new Promise((resolve) => {
const t0 = Date.now();
let childExited = false;
if (watchChildExit && child) child.on("exit", () => (childExited = true));
const tick = () => {
const txt = readLogSmart(logf);
if (/sing-box started/i.test(txt)) return resolve({ ok: true });
const fatal = txt.match(/FATAL[^\n]*/i) || txt.match(/ERROR[^\n]*(bind|permission|denied|wintun|tun)[^\n]*/i);
if (fatal) return resolve({ ok: false, reason: "引擎启动失败:" + fatal[0].trim().slice(0, 260) });
if (childExited && Date.now() - t0 > 1500) {
return resolve({
ok: false,
reason: txt ? "启动失败:" + txt.trim().slice(-260) : "已取消管理员授权,全局代理未启动。",
});
}
if (Date.now() - t0 > timeoutMs) {
return resolve({
ok: false,
reason: txt
? "启动超时:" + txt.trim().slice(-260)
: "未获得管理员授权(密码框 / UAC 被取消),或引擎无法启动。",
});
}
setTimeout(tick, 400);
};
tick();
});
}
async function startGlobal(p) {
const flag = flagPath();
const logf = path.join(runtimeDir(), "global.log");
const cfg = writeCfg("global.json", globalConfig(p, logf));
// 先自检配置,避免弹了密码才发现配置错
await pExecFile(binPath(), ["check", "-c", cfg]);
try {
fs.unlinkSync(logf); // 清掉上次日志,保证这次判断读到的是本次输出
} catch {}
fs.writeFileSync(flag, "run");
let child;
if (process.platform === "darwin") {
// 提权 bash:后台跑 sing-box(日志由 sing-box 自己写文件),盯 flag;flag 删了或引擎挂了就退出
const sh = path.join(runtimeDir(), "run-global.sh");
fs.writeFileSync(
sh,
`#!/bin/bash
"${binPath()}" run -c "${cfg}" &
SB=$!
while [ -f "${flag}" ]; do sleep 1; kill -0 $SB 2>/dev/null || break; done
kill $SB 2>/dev/null
wait $SB 2>/dev/null
`
);
fs.chmodSync(sh, 0o755);
const osa = `do shell script "/bin/bash '${sh}'" with administrator privileges`;
child = spawn("osascript", ["-e", osa]);
} else if (process.platform === "win32") {
// PowerShell(Unicode 原生,兼容中文用户名路径):提权后台跑 sing-box,盯 flag,删了就杀。
// sing-box 自己把日志写进 log.output,不用 shell 重定向 → 避开 cmd 的 GBK 编码坑。
const ps = path.join(runtimeDir(), "run-global.ps1");
const psBody =
"" + // UTF-8 BOM,保证 PowerShell 按 UTF-8 读取(路径含中文也不乱)
`$ErrorActionPreference='SilentlyContinue'\r\n` +
// 先杀掉上次残留的引擎(此脚本已提权,能杀掉之前遗留的管理员进程),释放被占用的 wintun 网卡
`Stop-Process -Name sing-box -Force\r\n` +
`Start-Sleep -Milliseconds 800\r\n` +
`$proc = Start-Process -FilePath '${binPath()}' -ArgumentList @('run','-c','${cfg}') -WindowStyle Hidden -PassThru\r\n` +
`while (Test-Path '${flag}') { if ($proc.HasExited) { break }; Start-Sleep -Seconds 1 }\r\n` +
`if (-not $proc.HasExited) { Stop-Process -Id $proc.Id -Force }\r\n` +
`Stop-Process -Name sing-box -Force\r\n`;
fs.writeFileSync(ps, psBody, "utf-8");
// 非提权的外层 powershell 负责触发一次 UAC,把上面的脚本以管理员+隐藏窗口运行
const outer = `Start-Process powershell -Verb RunAs -WindowStyle Hidden -ArgumentList '-NoProfile','-ExecutionPolicy','Bypass','-File','${ps}'`;
child = spawn("powershell", ["-NoProfile", "-Command", outer], { windowsHide: true });
} else {
throw new Error("全局代理暂只支持 macOS 与 Windows");
}
// mac 的提权进程会随 sing-box 存活;win 的外层进程点完 UAC 立刻退出,故只在 mac 盯它早退
// 开 wintun/utun 网卡可能较慢,给 40 秒;那句 "open interface take too much time" 只是 WARN,会继续尝试
const res = await waitForStart(logf, 40000, child, process.platform === "darwin");
if (!res.ok) {
try {
fs.unlinkSync(flag);
} catch {}
try {
if (child) child.kill();
} catch {}
throw new Error(res.reason);
}
state.mode = "global";
state.localPort = 0;
if (process.platform === "darwin") {
// mac:提权进程存活即代理存活,它退出就复位状态
state.child = child;
child.on("exit", () => {
if (state.child === child) {
try {
fs.unlinkSync(flag);
} catch {}
state.mode = "off";
state.child = null;
emit();
}
});
} else {
// win:外层进程已退出,靠 flag 文件控制生命周期
state.child = null;
}
emit();
return status();
}
// ---------------------------------------------------------------------------
// 统一启动 / 停止
// ---------------------------------------------------------------------------
async function start(mode) {
loadConf();
if (!binExists()) throw new Error("缺少 sing-box 引擎文件,请重新安装本软件");
const p = parseProxy(conf.proxyStr);
if (!p) throw new Error("请先填写正确的代理地址,格式:host:port:user:pass");
if (state.mode !== "off") await stop();
if (mode === "system") return startSystem(p);
if (mode === "global") return startGlobal(p);
throw new Error("未知模式:" + mode);
}
async function stop() {
const prev = state.mode;
if (prev === "system") {
await cleanupSystemProxy();
if (state.child) {
try {
state.child.kill();
} catch {}
}
} else if (prev === "global") {
// 删 flag → 提权循环自行 kill sing-box 并拆除 TUN
try {
fs.unlinkSync(flagPath());
} catch {}
// 兜底:等提权进程收尾
await new Promise((r) => setTimeout(r, 1500));
}
state.mode = "off";
state.child = null;
state.localPort = 0;
state.services = [];
emit();
return status();
}
// 退出前清理:确保不把系统代理 / TUN 残留给用户
async function shutdown() {
if (state.mode === "off") return;
try {
await stop();
} catch {}
}
// 启动时的残留清理:上次若崩溃遗留了「指向我们端口的系统代理」,且引擎已不在 → 关掉它,
// 但不动用户自己(如 Clash)设的其它端口,避免误伤。
async function cleanupStale() {
loadConf();
if (process.platform !== "darwin" || !conf.appliedPort) return;
try {
const services = await macServices();
for (const svc of services) {
const { stdout } = await pExecFile("networksetup", ["-getsocksfirewallproxy", svc]).catch(() => ({ stdout: "" }));
if (/Enabled:\s*Yes/i.test(stdout) && stdout.includes("127.0.0.1") && stdout.includes(String(conf.appliedPort))) {
await macUnsetSystemProxy([svc]);
}
}
} catch {}
}
// ---------------------------------------------------------------------------
// 测试连通性:临时起一个本地 mixed 口,经它请求取出口 IP
// ---------------------------------------------------------------------------
async function testProxy() {
loadConf();
if (!binExists()) throw new Error("缺少 sing-box 引擎文件");
const p = parseProxy(conf.proxyStr);
if (!p) throw new Error("代理地址格式不对,应为 host:port:user:pass");
const port = await findFreePort(0);
const cfg = writeCfg("test.json", systemConfig(p, port));
const child = spawn(binPath(), ["run", "-c", cfg], { stdio: ["ignore", "ignore", "pipe"] });
let stderr = "";
child.stderr.on("data", (d) => (stderr += d.toString()));
try {
// 等端口就绪
await new Promise((resolve, reject) => {
const t0 = Date.now();
const poll = () => {
const s = net.connect(port, "127.0.0.1");
s.on("connect", () => {
s.destroy();
resolve();
});
s.on("error", () => {
s.destroy();
if (Date.now() - t0 > 6000) reject(new Error("引擎未就绪:" + stderr.slice(0, 200)));
else setTimeout(poll, 250);
});
};
poll();
});
const ip = await httpGetViaProxy(port, "http://api.ipify.org/");
return { ok: true, ip, proxy: `${p.host}:${p.port}` };
} finally {
try {
child.kill();
} catch {}
}
}
// 经本地 HTTP 代理端口发一个 GET,返回响应体(用于取出口 IP)
function httpGetViaProxy(port, targetUrl) {
return new Promise((resolve, reject) => {
const u = new URL(targetUrl);
const req = http.request(
{
host: "127.0.0.1",
port,
method: "GET",
path: targetUrl, // 绝对 URL → 走代理
headers: { Host: u.host, "User-Agent": "codex-market", Connection: "close" },
timeout: 15000,
},
(res) => {
const chunks = [];
res.on("data", (c) => chunks.push(c));
res.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8").trim()));
}
);
req.on("timeout", () => req.destroy(new Error("请求超时(15s)")));
req.on("error", reject);
req.end();
});
}
// ---------------------------------------------------------------------------
// 状态
// ---------------------------------------------------------------------------
function status() {
loadConf();
const p = parseProxy(conf.proxyStr);
return {
mode: state.mode, // off | system | global
running: state.mode !== "off",
localPort: state.localPort || 0,
binOk: binExists(),
platform: process.platform,
proxy: p ? { host: p.host, port: p.port, hasAuth: !!p.user } : null,
hasConfig: !!p,
};
}
module.exports = {
getConfig,
setConfig,
start,
stop,
status,
testProxy,
shutdown,
cleanupStale,
setOnChange,
};
+176
View File
@@ -0,0 +1,176 @@
// purge.js —— 检测本机 Codex 的所有安装位置,并「强制清除」。
// 跨平台(macOS / Windows / Linux)自动识别:数据目录、PATH 里的可执行文件、
// npm 全局包、Homebrew 安装等。用于「更新后中转失效 → 彻底清干净再重装」。
const fs = require("fs");
const path = require("path");
const os = require("os");
const { exec } = require("child_process");
const codex = require("./codex");
const IS_WIN = process.platform === "win32";
// 跑一条 shell 命令,带超时;失败返回 null(用于探测,绝不抛错)。
function run(command) {
return new Promise((resolve) => {
try {
exec(command, { timeout: 8000, windowsHide: true }, (err, stdout) => {
if (err) return resolve(null);
resolve(String(stdout || "").trim() || null);
});
} catch (e) {
resolve(null);
}
});
}
// 某个路径存在吗?(含符号链接本身)
function lstat(p) {
try {
return fs.lstatSync(p);
} catch (e) {
return null;
}
}
// 求路径体积:目录递归、文件取 size、符号链接算 0。
function sizeOf(p, st) {
if (!st) return 0;
if (st.isSymbolicLink()) return 0;
if (st.isDirectory()) return codex.dirSize(p);
try {
return fs.statSync(p).size;
} catch (e) {
return 0;
}
}
// 从可执行文件的真实路径回溯出 @openai/codex 包根目录。
// 例:.../@openai/codex/bin/codex.js -> .../@openai/codex
function pkgRootFromBin(realBin) {
let dir = path.dirname(realBin);
for (let i = 0; i < 6; i++) {
if (
path.basename(dir) === "codex" &&
path.basename(path.dirname(dir)) === "@openai"
) {
return dir;
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return null;
}
// 探测所有可能的 Codex 安装位置。返回去重后的候选清单(含 exists / size)。
async function detectInstalls() {
const home = os.homedir();
// key(绝对路径)-> 候选
const found = new Map();
const add = (label, kind, p) => {
if (!p) return;
let abs;
try {
abs = path.resolve(p);
} catch (e) {
return;
}
if (found.has(abs)) return;
found.set(abs, { label, kind, path: abs });
};
// 1) 数据目录(配置 / 登录凭证 / 会话 / 插件 / 技能)——始终列出
add("Codex 数据目录(配置 · 登录 · 会话 · 插件)", "data", path.join(home, ".codex"));
// 2) PATH 里的可执行文件(which / where 可能返回多行)
const whichOut = IS_WIN ? await run("where codex") : await run("which -a codex");
if (whichOut) {
for (const line of whichOut.split(/\r?\n/)) {
const bin = line.trim();
if (!bin) continue;
add("Codex 可执行文件(PATH)", "bin", bin);
// 顺着符号链接找到真实文件,再回溯 npm 包根
try {
const real = fs.realpathSync(bin);
if (real !== bin) add("Codex 可执行文件(真实位置)", "bin", real);
const root = pkgRootFromBin(real);
if (root) add("@openai/codex 安装包", "npm-package", root);
} catch (e) {
/* 断链/权限:忽略 */
}
}
}
// 3) npm 全局包与 npm 全局 bin
const npmRoot = await run(IS_WIN ? "npm.cmd root -g" : "npm root -g");
if (npmRoot) add("@openai/codex 安装包(npm 全局)", "npm-package", path.join(npmRoot, "@openai", "codex"));
const npmPrefix = await run(IS_WIN ? "npm.cmd prefix -g" : "npm prefix -g");
if (npmPrefix) {
const binDir = IS_WIN ? npmPrefix : path.join(npmPrefix, "bin");
for (const n of ["codex", "codex.cmd", "codex.ps1", "codex.exe"]) {
add("Codex 启动脚本(npm bin)", "bin", path.join(binDir, n));
}
add("@openai/codex 安装包(npm 全局)", "npm-package", path.join(npmPrefix, IS_WIN ? "node_modules" : "lib/node_modules", "@openai", "codex"));
}
// 4) 各平台常见固定位置兜底
if (IS_WIN) {
const appdata = process.env.APPDATA || path.join(home, "AppData", "Roaming");
for (const n of ["codex", "codex.cmd", "codex.ps1"]) {
add("Codex 启动脚本(%APPDATA%\\npm)", "bin", path.join(appdata, "npm", n));
}
add("@openai/codex 安装包(%APPDATA%\\npm)", "npm-package", path.join(appdata, "npm", "node_modules", "@openai", "codex"));
} else {
// macOS / Linux:Homebrew 与常见全局前缀
for (const prefix of ["/opt/homebrew", "/usr/local", path.join(home, ".npm-global")]) {
add("Codex 可执行文件(" + prefix + ")", "bin", path.join(prefix, "bin", "codex"));
add("@openai/codex 安装包(" + prefix + ")", "npm-package", path.join(prefix, "lib", "node_modules", "@openai", "codex"));
}
}
// 收尾:补 exists / size,只保留真实存在的项
const items = [];
for (const c of found.values()) {
const st = lstat(c.path);
if (!st) continue; // 不存在的不展示
items.push({
...c,
exists: true,
isSymlink: st.isSymbolicLink(),
isDir: st.isDirectory() && !st.isSymbolicLink(),
size: sizeOf(c.path, st),
sizeText: codex.humanSize(sizeOf(c.path, st)),
});
}
// 数据目录排最前,其余按体积降序
items.sort((a, b) => {
if (a.kind === "data") return -1;
if (b.kind === "data") return 1;
return b.size - a.size;
});
return items;
}
// 强制删除给定路径。为安全起见,只删除「当前探测得到」的路径,
// 防止渲染进程传入任意路径。返回每一项的删除结果。
async function purge(requestedPaths) {
const wanted = new Set((requestedPaths || []).map((p) => path.resolve(p)));
const detected = await detectInstalls();
const allowed = detected.filter((d) => wanted.has(d.path));
const results = [];
for (const item of allowed) {
try {
fs.rmSync(item.path, { recursive: true, force: true });
results.push({ path: item.path, label: item.label, ok: true, stillExists: !!lstat(item.path) });
} catch (e) {
results.push({ path: item.path, label: item.label, ok: false, error: String(e && e.message || e) });
}
}
return results;
}
module.exports = { detectInstalls, purge };
+884
View File
@@ -0,0 +1,884 @@
// router.js —— 本地路由/中转代理:Codex 直连本地,指令经此转发到"当前分组"的中转 API。
// 切分组只改内存里的 activeId,Codex 那头地址不变 → 秒切、无需重启 Codex。
const http = require("http");
const https = require("https");
const fs = require("fs");
const path = require("path");
const os = require("os");
const crypto = require("crypto");
const { URL } = require("url");
const { app, safeStorage } = require("electron");
const translate = require("./translate");
const CODEX_HOME = path.join(os.homedir(), ".codex");
const CLAUDE_HOME = path.join(os.homedir(), ".claude");
const PROVIDER_ID = "local-router"; // config.toml 里的 provider 名
function cfgPath() {
return path.join(app.getPath("userData"), "router.json");
}
// —— 加解密(复用 safeStorage)——
function enc(s) {
if (!s) return "";
try {
if (safeStorage.isEncryptionAvailable()) return "v1:" + safeStorage.encryptString(s).toString("base64");
} catch {}
return "b64:" + Buffer.from(s, "utf-8").toString("base64");
}
function dec(s) {
if (!s) return "";
try {
if (s.startsWith("v1:")) return safeStorage.decryptString(Buffer.from(s.slice(3), "base64"));
if (s.startsWith("b64:")) return Buffer.from(s.slice(4), "base64").toString("utf-8");
} catch {}
return "";
}
// —— 配置持久化 —— config: { port, routerKey, activeId, groups:[{id,name,baseUrl,key,model}] }
let config = null;
function defaultConfig() {
return { port: 8787, routerKey: "rk-" + crypto.randomBytes(16).toString("hex"), activeId: null, groups: [] };
}
function load() {
if (config) return config;
try {
const raw = JSON.parse(fs.readFileSync(cfgPath(), "utf-8"));
raw.routerKey = raw.routerKey ? dec(raw.routerKey) : defaultConfig().routerKey;
raw.groups = (raw.groups || []).map((g) => ({ ...g, key: dec(g.key) }));
config = { ...defaultConfig(), ...raw };
} catch {
config = defaultConfig();
}
ensureDefault();
return config;
}
function save() {
const out = {
...config,
routerKey: enc(config.routerKey),
groups: config.groups.map((g) => ({ ...g, key: enc(g.key) })),
};
const p = cfgPath();
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(out, null, 2));
try {
fs.chmodSync(p, 0o600);
} catch {}
}
function activeGroup() {
load();
return config.groups.find((g) => g.id === config.activeId) || null;
}
// —— 默认分组:客户原本的 Codex 配置(自己账号,直连)——
function readConfigToml() {
try {
return fs.readFileSync(path.join(CODEX_HOME, "config.toml"), "utf-8");
} catch {
return "";
}
}
function currentModelProvider() {
const head = readConfigToml().split("\n[")[0];
const m = head.match(/^\s*model_provider\s*=\s*["']([^"']+)/m);
return m ? m[1] : "";
}
// 读根级 model(新版桌面 Codex 登录态通常没有;用于复原时精确还原/清除中转写入的 model)
function currentRootModel() {
const head = readConfigToml().split("\n[")[0];
const m = head.match(/^\s*model\s*=\s*["']([^"']+)/m);
return m ? m[1] : "";
}
function extractProviderBlock(text, prov) {
const esc = prov.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`\\[model_providers\\.${esc}\\][\\s\\S]*?(?=\\n\\[|$)`, "m");
const m = text.match(re);
return m ? m[0].trimEnd() + "\n" : "";
}
function readAuthKey() {
try {
return JSON.parse(fs.readFileSync(path.join(CODEX_HOME, "auth.json"), "utf-8")).OPENAI_API_KEY || "";
} catch {
return "";
}
}
// 是否已用自己账号登录 Codex(ChatGPT OAuth)——凭证是 auth.json 的 tokens,而非 API key。
// 这是「我的账号 · 直连」的真实判据:只要 tokens 在,切中转只是覆盖 OPENAI_API_KEY,登录不会丢。
function isLoggedIn() {
try {
const a = JSON.parse(fs.readFileSync(path.join(CODEX_HOME, "auth.json"), "utf-8"));
return !!(a && a.tokens && (a.tokens.access_token || a.tokens.id_token || a.tokens.refresh_token));
} catch {
return false;
}
}
// 从 TOML 文本里删除某个 section(从 [header] 行删到下一个 [ 行之前),按行处理最稳
function removeTomlSection(text, header) {
const lines = text.split("\n");
const out = [];
let skip = false;
for (const line of lines) {
const t = line.trim();
if (t === `[${header}]`) {
skip = true;
continue;
}
if (skip && t.startsWith("[") && t.endsWith("]")) skip = false; // 遇到下一个 section,停止跳过
if (!skip) out.push(line);
}
return out.join("\n").replace(/\n{3,}/g, "\n\n");
}
function backupConfig(text) {
try {
const dir = path.join(CODEX_HOME, "backups_state", "router");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "config.toml." + Date.now() + ".bak"), text || "");
} catch {}
}
// 首次使用时,把当前(原始)Codex 配置快照为不可删除的「默认」分组。
// 铁律:默认分组必须代表「我的账号 · 直连」的登录态,绝不能是接入中转的状态,
// 否则「切回默认」会把 Codex 重新指向中转,用户就再也复原不回自己账号了。
function ensureDefault() {
if (!config) return;
const loggedIn = isLoggedIn();
const existing = config.groups.find((g) => g.isDefault);
if (existing) {
// 自愈:已用自己账号登录,但历史版本把默认分组错误地快照成了中转 provider →
// 纠正为「纯登录态」,保证「切回默认」永远能干净复原自己账号。
if (loggedIn && (existing.modelProvider || existing.providerBlock || existing.authKey)) {
existing.modelProvider = "";
existing.providerBlock = "";
existing.authKey = "";
save();
}
return;
}
if (loggedIn) {
// 已登录自己账号:默认分组 = Codex 原生登录直连(不覆盖 model_provider、无 API key、
// 保留 auth.json 的 tokens)。无论此刻 config.toml 指向哪个中转,都能干净复原。
config.groups.unshift({
id: "default",
isDefault: true,
name: "默认(我的账号 · 直连)",
modelProvider: "",
providerBlock: "",
authKey: "",
origModel: currentRootModel(), // 记住原始根级 model(新版桌面登录态通常无 → ""),复原时精确还原
});
if (!config.activeId) config.activeId = "default";
save();
return;
}
// 未用 OAuth 登录(纯 API key 用户,无 tokens 可保护):沿用原逻辑,快照当前直连 provider。
const prov = currentModelProvider();
if (!prov || prov === PROVIDER_ID) return; // 已是本地路由 / 无配置 → 不快照
const text = readConfigToml();
config.groups.unshift({
id: "default",
isDefault: true,
name: "默认(我的账号 · 直连)",
modelProvider: prov,
providerBlock: extractProviderBlock(text, prov),
authKey: readAuthKey(),
origModel: currentRootModel(),
});
if (!config.activeId) config.activeId = "default";
save();
}
// 模型目录路径(config.toml 的 model_catalog_json,默认 cc-switch-model-catalog.json)
function catalogPath() {
const m = readConfigToml().match(/model_catalog_json\s*=\s*["']([^"']+)/);
const name = m ? m[1] : "cc-switch-model-catalog.json";
return path.isAbsolute(name) ? name : path.join(CODEX_HOME, name);
}
// 把分组的模型 + 上下文窗口写进 Codex 模型目录,让 Codex 按真实窗口自动压缩
function applyModelCatalog(group) {
if (!group || group.isDefault || !group.model || !group.contextWindow) return;
const p = catalogPath();
let cat;
try {
cat = JSON.parse(fs.readFileSync(p, "utf-8"));
} catch {
return; // 无目录文件就不动
}
if (!cat.models || !cat.models.length) return;
const win = parseInt(group.contextWindow, 10);
if (!win) return;
const existing = cat.models.find((m) => m.slug === group.model);
if (existing) {
existing.context_window = win;
existing.max_context_window = win;
existing.visibility = "list";
} else {
const tpl = JSON.parse(JSON.stringify(cat.models[0])); // 克隆模板(含 base_instructions 等必需字段)
tpl.slug = group.model;
tpl.display_name = group.model;
tpl.description = group.model;
tpl.context_window = win;
tpl.max_context_window = win;
tpl.priority = 900;
tpl.visibility = "list";
cat.models.push(tpl);
}
try {
fs.writeFileSync(p + ".bak-router", fs.readFileSync(p));
} catch {}
fs.writeFileSync(p, JSON.stringify(cat, null, 2), "utf-8");
}
// 把 config.toml 根级 model 设成指定值
function setConfigModel(model) {
if (!model) return;
const cfgFile = path.join(CODEX_HOME, "config.toml");
let text = readConfigToml();
if (/^\s*model\s*=/m.test(text)) text = text.replace(/^\s*model\s*=.*$/m, `model = "${model}"`);
else text = text.replace(/^(\s*model_provider\s*=.*)$/m, `$1\nmodel = "${model}"`);
fs.writeFileSync(cfgFile, text, "utf-8");
}
// 恢复到默认(把 config.toml/auth.json 还原成客户原始配置,Codex 直连自己账号)
function restoreDirect() {
const def = config.groups.find((g) => g.isDefault);
if (!def) return;
const cfgFile = path.join(CODEX_HOME, "config.toml");
let text = readConfigToml();
backupConfig(text);
// 先移除本地路由段(无论哪种复原都要清掉)
text = removeTomlSection(text, `model_providers.${PROVIDER_ID}`);
if (def.modelProvider) {
// 有原始直连 provider(纯 API key 用户,如 Mac 的 custom):设根键 + 追加原始段,避免重复键。
// 此分支完全保持旧行为(不动根级 model 行),确保已在用的 Mac 环境零回归。
if (/^\s*model_provider\s*=/m.test(text)) text = text.replace(/^\s*model_provider\s*=.*$/m, `model_provider = "${def.modelProvider}"`);
else text = `model_provider = "${def.modelProvider}"\n` + text;
if (def.providerBlock) {
text = removeTomlSection(text, `model_providers.${def.modelProvider}`);
text = text.trimEnd() + "\n\n" + def.providerBlock;
}
} else {
// 纯登录态(Windows 新格式 OAuth):删根级 model_provider 覆盖,回到 Codex 原生登录直连。
text = text.replace(/^\s*model_provider\s*=.*$\n?/m, "");
// 清掉中转写入的根级 model;原始登录态通常无 model,若快照记了 origModel 则精确还原。
text = text.replace(/^\s*model\s*=.*$\n?/m, "");
if (def.origModel) text = `model = "${def.origModel}"\n` + text;
}
text = text.replace(/\n{3,}/g, "\n\n");
fs.writeFileSync(cfgFile, text, "utf-8");
const af = path.join(CODEX_HOME, "auth.json");
try {
const a = JSON.parse(fs.readFileSync(af, "utf-8"));
// 登录态:清空 OPENAI_API_KEY、保留 tokens → Codex 用 OAuth 直连;纯 key 用户则还原原始 key
a.OPENAI_API_KEY = def.authKey || null;
fs.writeFileSync(af, JSON.stringify(a, null, 2), "utf-8");
fs.chmodSync(af, 0o600);
} catch {}
}
function getGroupKey(id) {
load();
const g = config.groups.find((x) => x.id === id);
return g ? g.key : "";
}
// 按 id 取分组(含解密后的 key,供内部功能按指定分组直调中转)。只读,不改激活态/切回逻辑。
function groupById(id) {
load();
return config.groups.find((x) => x.id === id) || null;
}
// 可供画布等内部功能"按需选用"的中转分组列表:排除默认直连分组,需配齐 baseUrl+key。只读。
function usableGroups() {
load();
return config.groups.filter((g) => !g.isDefault && g.baseUrl && g.key);
}
// 给内部功能(如「会话记忆」摘要)用的可调用分组:优先当前激活的中转分组,
// 不可用则取第一个配置齐全的中转分组;默认分组(直连账号)不参与。
function llmGroup() {
load();
const usable = (g) => g && !g.isDefault && g.baseUrl && g.key && g.model;
const act = config.groups.find((g) => g.id === config.activeId);
if (usable(act)) return act;
return config.groups.find(usable) || null;
}
// 拉取某个中转 API 的模型列表(试 /v1/models 再 /models)
async function fetchModels(baseUrl, key) {
if (!baseUrl) throw new Error("请先填 API 地址");
const base = baseUrl.replace(/\/$/, "");
let lastErr = null;
for (const p of ["/v1/models", "/models"]) {
try {
const res = await fetch(base + p, {
headers: { Authorization: "Bearer " + (key || ""), "User-Agent": "codex-market" },
});
if (!res.ok) {
lastErr = new Error("HTTP " + res.status);
continue;
}
const j = await res.json();
const arr = Array.isArray(j) ? j : j.data || j.models || [];
const list = arr.map((m) => (typeof m === "string" ? m : m.id || m.name)).filter(Boolean);
if (list.length) return Array.from(new Set(list));
} catch (e) {
lastErr = e;
}
}
throw lastErr || new Error("未获取到模型列表(接口可能不支持 /v1/models)");
}
// —— 代理服务器 ——
let server = null;
// 抓取一次真实请求/响应到 userData/router-capture/(用于开发协议转换)
function captureDir() {
return path.join(app.getPath("userData"), "router-capture");
}
function writeCapture(name, data) {
try {
fs.mkdirSync(captureDir(), { recursive: true });
fs.writeFileSync(path.join(captureDir(), name), data);
} catch {}
}
function handle(req, res) {
const g = activeGroup();
if (!g) {
res.writeHead(503, { "content-type": "text/plain; charset=utf-8" });
res.end("本地路由:未选择中转分组");
return;
}
// 校验路由 key(Codex 用 auth.json 里的 routerKey 作 Bearer 发来)
const auth = req.headers["authorization"] || "";
if (config.routerKey && auth !== "Bearer " + config.routerKey) {
// 放行 localhost 但不匹配时也继续(避免 key 变动导致不可用),仅记录
}
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
let body = Buffer.concat(chunks);
const doCapture = /responses/.test(req.url || "");
// 分组指定了模型 → 覆盖请求体里的 model
if (g.model && body.length) {
try {
const j = JSON.parse(body.toString("utf-8"));
if (j && typeof j === "object" && "model" in j) {
j.model = g.model;
body = Buffer.from(JSON.stringify(j), "utf-8");
}
} catch {}
}
// —— chat 模式:Responses ⇄ Chat Completions 协议转换(DeepSeek/GLM/Kimi 等)——
if (g.mode === "chat" && /responses/.test(req.url || "")) {
let responsesReq = {};
try {
responsesReq = JSON.parse(body.toString("utf-8"));
} catch {}
const chatReq = translate.responsesToChat(responsesReq, g.model || responsesReq.model);
const chatBody = Buffer.from(JSON.stringify(chatReq), "utf-8");
// 拼 chat 端点:多数网关/厂商(DeepSeek/GLM/Kimi/one-api)是 /v1/chat/completions
const cbase = g.baseUrl.replace(/\/$/, "");
let chatEndpoint;
if (/\/chat\/completions$/.test(cbase)) chatEndpoint = cbase; // 用户已填全
else if (/\/v\d+$/.test(cbase)) chatEndpoint = cbase + "/chat/completions"; // 已含 /v1
else chatEndpoint = cbase + "/v1/chat/completions";
let chatUrl;
try {
chatUrl = new URL(chatEndpoint);
} catch {
res.writeHead(502);
res.end("分组 API 地址无效");
return;
}
const https2 = chatUrl.protocol === "https:";
const conv = new translate.ChatToResponses(chatReq.model);
let ended = false;
const endOnce = (tail) => {
if (ended) return;
ended = true;
if (tail) res.write(tail);
res.end();
};
res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache", connection: "keep-alive" });
if (doCapture) writeCapture("last-chat-request.json", `UPSTREAM ${chatUrl.href}\n\n` + JSON.stringify(chatReq, null, 2));
const up2 = (https2 ? https : http).request(
{
method: "POST",
hostname: chatUrl.hostname,
port: chatUrl.port || (https2 ? 443 : 80),
path: chatUrl.pathname + chatUrl.search,
headers: {
"content-type": "application/json",
authorization: "Bearer " + g.key,
accept: "text/event-stream",
host: chatUrl.host,
"content-length": Buffer.byteLength(chatBody),
},
},
(upRes) => {
if (upRes.statusCode !== 200) {
const eb = [];
upRes.on("data", (c) => eb.push(c));
upRes.on("end", () => {
const raw = Buffer.concat(eb).toString("utf-8").slice(0, 2000);
if (doCapture) writeCapture("last-chat-response.txt", `STATUS ${upRes.statusCode}\n\n` + raw);
// 提取上游错误消息,作为一条助手回复显示给 Codex(而不是"断连")
let human = raw.slice(0, 800);
try {
const j = JSON.parse(raw);
human = (j.error && (j.error.message || j.error)) || j.message || human;
} catch {}
let s = conv.onChunk({ choices: [{ delta: { content: `⚠️ 中转上游返回错误 (${upRes.statusCode}):\n${human}` } }] });
s += conv.finish();
endOnce(s);
});
return;
}
const rawBuf = [];
let sseBuf = "";
upRes.on("data", (chunk) => {
if (doCapture) rawBuf.push(chunk);
sseBuf += chunk.toString("utf-8");
let i;
while ((i = sseBuf.indexOf("\n")) >= 0) {
const line = sseBuf.slice(0, i).trim();
sseBuf = sseBuf.slice(i + 1);
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (data === "[DONE]") {
endOnce(conv.finish());
return;
}
try {
res.write(conv.onChunk(JSON.parse(data)));
} catch {}
}
});
upRes.on("end", () => {
if (doCapture) writeCapture("last-chat-response.txt", `STATUS 200\n\n` + Buffer.concat(rawBuf).toString("utf-8").slice(0, 8000));
endOnce(conv.finish());
});
}
);
up2.on("error", (e) => {
if (doCapture) writeCapture("last-chat-response.txt", "PROXY ERROR: " + String(e.message || e));
endOnce(conv.ev("error", { code: "proxy_error", message: String(e.message || e) }));
});
up2.end(chatBody);
return;
}
let target;
try {
target = new URL(g.baseUrl.replace(/\/$/, "") + req.url);
} catch {
res.writeHead(502);
res.end("分组 API 地址无效");
return;
}
const isHttps = target.protocol === "https:";
const mod = isHttps ? https : http;
const headers = { ...req.headers };
headers["host"] = target.host;
// Anthropic 风格请求(Claude Code 走 /v1/messages):保持客户端原本的认证方式,只把密钥换成分组的真实密钥。
// 关键:不要同时塞 Bearer + x-api-key —— 有的中转会据此判定为"直连 API 模式"而落到空账号池,返回 "No available accounts"。
const isAnthropic = /\/v1\/messages/.test(req.url || "") || "anthropic-version" in headers || "x-api-key" in headers;
if (isAnthropic) {
if ("x-api-key" in headers) {
headers["x-api-key"] = g.key; // 客户端用的是 x-api-key 模式
delete headers["authorization"];
} else {
headers["authorization"] = "Bearer " + g.key; // Claude Code 默认:只发 Bearer
delete headers["x-api-key"];
}
if (!headers["anthropic-version"]) headers["anthropic-version"] = "2023-06-01";
} else {
headers["authorization"] = "Bearer " + g.key; // 非 Anthropic(Codex 等):换成分组密钥
}
delete headers["content-length"];
if (body.length) headers["content-length"] = Buffer.byteLength(body);
if (doCapture) {
writeCapture("last-request.json", `URL ${req.method} ${req.url}\nUPSTREAM ${target.href}\n\n` + body.toString("utf-8"));
}
const up = mod.request(
{
method: req.method,
hostname: target.hostname,
port: target.port || (isHttps ? 443 : 80),
path: target.pathname + target.search,
headers,
},
(upRes) => {
res.writeHead(upRes.statusCode || 502, upRes.headers);
if (doCapture) {
const buf = [];
upRes.on("data", (c) => {
buf.push(c);
res.write(c);
});
upRes.on("end", () => {
res.end();
writeCapture("last-response.txt", `STATUS ${upRes.statusCode}\n\n` + Buffer.concat(buf).toString("utf-8"));
});
} else {
upRes.pipe(res); // 流式透传(SSE 也 OK)
}
}
);
up.on("error", (e) => {
if (!res.headersSent) res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
res.end("上游错误:" + e.message);
});
up.end(body);
});
}
function start() {
load();
if (server) return { running: true, port: config.port };
return new Promise((resolve, reject) => {
server = http.createServer(handle);
server.on("error", (e) => {
server = null;
reject(e);
});
server.listen(config.port, "127.0.0.1", () => resolve({ running: true, port: config.port }));
});
}
function stop() {
if (server) {
server.close();
server = null;
}
return { running: false };
}
function isRunning() {
return !!server;
}
function status() {
load();
return {
running: isRunning(),
port: config.port,
routerKey: config.routerKey,
address: `http://127.0.0.1:${config.port}`,
activeId: config.activeId,
claudeApplied: claudeApplied(),
groups: config.groups.map((g) => ({
id: g.id,
name: g.name,
baseUrl: g.isDefault ? "(Codex 原始配置 · 直连你的账号)" : g.baseUrl,
model: g.model || "",
mode: g.mode || "responses",
contextWindow: g.contextWindow || "",
hasKey: g.isDefault ? true : !!g.key,
isDefault: !!g.isDefault,
codexImage: !!g.codexImage,
})),
};
}
// ——————————————————————————————————————————————————————————————
// gen-image 生图技能:把「用于 Codex 生图」的中转分组凭证写进技能的 .env
// ——————————————————————————————————————————————————————————————
// 技能目录名可能因显示名不同而变(市场安装按 skill.name 派生中文目录),
// 故扫描各 skills/*/SKILL.md 的 name 字段来定位,而非写死路径。
function genImageSkillDir() {
const base = path.join(CODEX_HOME, "skills");
let names = [];
try {
names = fs.readdirSync(base, { withFileTypes: true })
.filter((d) => d.isDirectory() || d.isSymbolicLink())
.map((d) => d.name);
} catch {
return null;
}
for (const name of names) {
try {
const txt = fs.readFileSync(path.join(base, name, "SKILL.md"), "utf-8");
const fm = /^---\s*\n([\s\S]*?)\n---/.exec(txt);
const scope = fm ? fm[1] : txt.slice(0, 400);
const m = /^\s*name\s*:\s*(.+)$/im.exec(scope);
if (m && m[1].trim().replace(/^["']|["']$/g, "") === "gen-image") return path.join(base, name);
} catch {}
}
for (const guess of ["gen-image", "gen-image-生图"]) {
if (fs.existsSync(path.join(base, guess, "SKILL.md"))) return path.join(base, guess);
}
return null;
}
function writeGenImageEnv(dir, baseUrl, key, model) {
const body =
"# 由 TD工具箱「中转」自动写入:勾选“在 Codex 中用于生图”的分组凭证。\n" +
"# 取消勾选 / 删除该分组会被清除。请勿手动提交到 git。\n" +
`IMAGE_API_BASE_URL=${baseUrl || ""}\n` +
`IMAGE_API_KEY=${key || ""}\n` +
`IMAGE_MODEL=${model || "gpt-image-2"}\n`;
const p = path.join(dir, ".env");
fs.writeFileSync(p, body, "utf-8");
try { fs.chmodSync(p, 0o600); } catch {}
}
function clearGenImageEnv(dir) {
const p = path.join(dir, ".env");
try {
if (!fs.existsSync(p)) return;
// 只清除工具箱自动写入的那份,保留用户手工建的 .env(避免误删)
if (fs.readFileSync(p, "utf-8").includes("由 TD工具箱")) fs.unlinkSync(p);
} catch {}
}
// 依据当前分组的 codexImage 标记,同步写入/清除 gen-image 的 .env。
// 生图默认用 gpt-image-2(分组的 model 是对话模型,不用于生图)。
function syncGenImage() {
load();
const dir = genImageSkillDir();
const src = config.groups.find((g) => g.codexImage && !g.isDefault && g.baseUrl && g.key);
if (!dir) return { ok: false, reason: src ? "skill-not-installed" : "no-skill" };
if (!src) { clearGenImageEnv(dir); return { ok: true, cleared: true, dir }; }
writeGenImageEnv(dir, src.baseUrl, src.key, src.imageModel || "gpt-image-2");
return { ok: true, wrote: true, dir, group: src.name };
}
// —— 分组增删改 / 切换 ——
function upsertGroup(g) {
load();
let id;
if (g.id) {
const i = config.groups.findIndex((x) => x.id === g.id);
if (i >= 0) config.groups[i] = { ...config.groups[i], ...g };
else config.groups.push({ ...g });
id = g.id;
} else {
g.id = "g-" + crypto.randomBytes(6).toString("hex");
config.groups.push(g);
id = g.id;
}
if (!config.activeId) config.activeId = g.id;
// 生图来源唯一:勾选的分组独占,清掉其它分组的标记
const cur = config.groups.find((x) => x.id === id);
if (cur && cur.codexImage) {
for (const grp of config.groups) if (grp.id !== id) grp.codexImage = false;
}
save();
try { syncGenImage(); } catch {}
return status();
}
function deleteGroup(id) {
load();
const g = config.groups.find((x) => x.id === id);
if (g && g.isDefault) return { ...status(), error: "默认分组不可删除" };
config.groups = config.groups.filter((x) => x.id !== id);
if (config.activeId === id) config.activeId = config.groups[0] ? config.groups[0].id : null;
save();
try { syncGenImage(); } catch {} // 删掉的可能正是生图来源,同步清除 .env
return status();
}
function setActive(id) {
load();
const target = config.groups.find((g) => g.id === id);
if (!target) return status();
const before = currentModelProvider();
let needsRestart = false;
let mode = "proxy";
if (target.isDefault) {
// 切回默认 = 还原原始配置,Codex 直连自己账号(需重启 Codex)
restoreDirect();
mode = "direct";
needsRestart = before === PROVIDER_ID; // 之前在走路由 → 需重启
} else {
// 切到中转分组:确保 config.toml 指向本地路由
if (before !== PROVIDER_ID) {
applyToCodex();
needsRestart = true; // 从直连切到路由 → 需重启一次
}
// 若分组指定了模型:写模型目录窗口 + 设 config 模型,让 Codex 按真实窗口压缩
if (target.model) {
applyModelCatalog(target);
setConfigModel(target.model);
}
}
config.activeId = id;
save();
return { ...status(), _needsRestart: needsRestart, _mode: mode };
}
function setPort(port) {
load();
config.port = parseInt(port, 10) || 8787;
save();
return status();
}
// —— 自动配置 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`;
}
function applyToCodex() {
load();
// 本地路由需要一个中转分组做上游;若当前是默认/空,自动选第一个中转分组
const cur = config.groups.find((g) => g.id === config.activeId);
if (!cur || cur.isDefault) {
const proxy = config.groups.find((g) => !g.isDefault);
if (proxy) config.activeId = proxy.id;
}
const cfgFile = path.join(CODEX_HOME, "config.toml");
let text = "";
try {
text = fs.readFileSync(cfgFile, "utf-8");
} catch {}
// 备份
try {
fs.mkdirSync(path.join(CODEX_HOME, "backups_state", "router"), { recursive: true });
fs.writeFileSync(path.join(CODEX_HOME, "backups_state", "router", "config.toml." + Date.now() + ".bak"), text);
} catch {}
// 替换/插入 model_provider 根键
if (/^\s*model_provider\s*=/m.test(text)) {
text = text.replace(/^\s*model_provider\s*=.*$/m, `model_provider = "${PROVIDER_ID}"`);
} else {
text = `model_provider = "${PROVIDER_ID}"\n` + text;
}
// 清掉「默认分组」原始 provider 的旧段(已存进 router.json,避免残留误导)
const def = config.groups.find((g) => g.isDefault);
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}`);
text = text.trimEnd() + "\n\n" + block;
fs.writeFileSync(cfgFile, text, "utf-8");
// auth.json 写 routerKey(保留其它字段),Codex 会把它当 Bearer 发到本地路由
const authFile = path.join(CODEX_HOME, "auth.json");
let auth = {};
try {
auth = JSON.parse(fs.readFileSync(authFile, "utf-8"));
} catch {}
auth.OPENAI_API_KEY = config.routerKey;
try {
fs.writeFileSync(authFile, JSON.stringify(auth, null, 2), "utf-8");
fs.chmodSync(authFile, 0o600);
} catch {}
// 当前分组若指定了模型 → 写模型目录窗口 + 设 config 模型
const cur2 = config.groups.find((g) => g.id === config.activeId);
if (cur2 && !cur2.isDefault && cur2.model) {
applyModelCatalog(cur2);
setConfigModel(cur2.model);
}
save();
return { ok: true };
}
// —— 自动接入 Claude Code —— 写 ~/.claude/settings.json 的 env:ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN
// Claude Code 会把 settings.json 里 env 的键当环境变量注入每个会话,从而走本地路由。
function claudeSettingsPath() {
return path.join(CLAUDE_HOME, "settings.json");
}
function applyToClaudeCode() {
load();
// 本地路由需要一个中转分组做上游;若当前是默认/空,自动选第一个中转分组
const cur = config.groups.find((g) => g.id === config.activeId);
if (!cur || cur.isDefault) {
const proxy = config.groups.find((g) => !g.isDefault);
if (proxy) {
config.activeId = proxy.id;
save();
}
}
fs.mkdirSync(CLAUDE_HOME, { recursive: true });
const p = claudeSettingsPath();
let settings = {};
let existed = false;
try {
settings = JSON.parse(fs.readFileSync(p, "utf-8"));
existed = true;
} catch {}
// 备份原文件
if (existed) {
try {
const dir = path.join(CLAUDE_HOME, "backups_router");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, "settings.json." + Date.now() + ".bak"), fs.readFileSync(p));
} catch {}
}
settings.env = settings.env || {};
// 首次接入时记住用户原有的两项,便于「取消接入」还原
if (!config.claudePrev) {
config.claudePrev = {
base: Object.prototype.hasOwnProperty.call(settings.env, "ANTHROPIC_BASE_URL") ? settings.env.ANTHROPIC_BASE_URL : null,
token: Object.prototype.hasOwnProperty.call(settings.env, "ANTHROPIC_AUTH_TOKEN") ? settings.env.ANTHROPIC_AUTH_TOKEN : null,
};
}
settings.env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${config.port}`;
settings.env.ANTHROPIC_AUTH_TOKEN = config.routerKey;
fs.writeFileSync(p, JSON.stringify(settings, null, 2), "utf-8");
try {
fs.chmodSync(p, 0o600);
} catch {}
save();
return { ok: true, path: p };
}
// 取消接入:把 settings.json 里的两项还原成用户原值(或删除)
function restoreClaudeCode() {
load();
const p = claudeSettingsPath();
let settings = {};
try {
settings = JSON.parse(fs.readFileSync(p, "utf-8"));
} catch {
return { ok: true }; // 没有文件就无需还原
}
settings.env = settings.env || {};
const prev = config.claudePrev || { base: null, token: null };
for (const [k, v] of [["ANTHROPIC_BASE_URL", prev.base], ["ANTHROPIC_AUTH_TOKEN", prev.token]]) {
if (v == null) delete settings.env[k];
else settings.env[k] = v;
}
if (settings.env && Object.keys(settings.env).length === 0) delete settings.env;
fs.writeFileSync(p, JSON.stringify(settings, null, 2), "utf-8");
delete config.claudePrev;
save();
return { ok: true };
}
// Claude Code 是否已指向本地路由
function claudeApplied() {
try {
const s = JSON.parse(fs.readFileSync(claudeSettingsPath(), "utf-8"));
return !!(s.env && s.env.ANTHROPIC_BASE_URL === `http://127.0.0.1:${(config || {}).port || 8787}`);
} catch {
return false;
}
}
module.exports = {
load,
start,
stop,
isRunning,
status,
upsertGroup,
deleteGroup,
setActive,
setPort,
applyToCodex,
codexSnippet,
applyToClaudeCode,
restoreClaudeCode,
claudeApplied,
getGroupKey,
syncGenImage,
groupById,
usableGroups,
llmGroup,
fetchModels,
};
+66
View File
@@ -0,0 +1,66 @@
// 密钥安全存储:用 Electron safeStorage 加密后写入 userData/secrets.json
// name 取 "github" / "registry"。读时解密;无则返回空串。
const fs = require("fs");
const path = require("path");
const { app, safeStorage } = require("electron");
function secretsPath() {
return path.join(app.getPath("userData"), "secrets.json");
}
function readStore() {
try {
const txt = fs.readFileSync(secretsPath(), "utf-8");
return JSON.parse(txt || "{}");
} catch (e) {
return {};
}
}
function writeStore(store) {
const p = secretsPath();
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(store, null, 2), { encoding: "utf-8" });
try {
fs.chmodSync(p, 0o600);
} catch (e) {
/* 忽略权限设置失败(非 POSIX 平台) */
}
}
// 取密钥;无则空串
function getSecret(name) {
const store = readStore();
const enc = store[name];
if (!enc) return "";
try {
if (safeStorage.isEncryptionAvailable()) {
return safeStorage.decryptString(Buffer.from(enc, "base64"));
}
// 加密不可用时的降级:明文(base64)存储
return Buffer.from(enc, "base64").toString("utf-8");
} catch (e) {
return "";
}
}
// 存密钥;空值视为删除
function setSecret(name, value) {
const store = readStore();
if (!value) {
delete store[name];
writeStore(store);
return { ok: true };
}
let encB64;
if (safeStorage.isEncryptionAvailable()) {
encB64 = safeStorage.encryptString(value).toString("base64");
} else {
encB64 = Buffer.from(value, "utf-8").toString("base64");
}
store[name] = encB64;
writeStore(store);
return { ok: true };
}
module.exports = { getSecret, setSecret };
+234
View File
@@ -0,0 +1,234 @@
// sessions.js —— 读取 / 解析 / 导出 / 备份 / 导入 Codex 会话记录。
// 直接读 ~/.codex/sessions / archived_sessions 下的 rollout-*.jsonl,不依赖数据库,跨平台。
const fs = require("fs");
const path = require("path");
const os = require("os");
const CODEX_HOME = path.join(os.homedir(), ".codex");
const SESSIONS_DIR = path.join(CODEX_HOME, "sessions");
const ARCHIVED_SESSIONS_DIR = path.join(CODEX_HOME, "archived_sessions");
const SESSION_INDEX = path.join(CODEX_HOME, "session_index.jsonl");
// 递归找出所有 rollout-*.jsonl
function findRolloutFiles(base) {
const out = [];
function walk(dir) {
let ents;
try {
ents = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const ent of ents) {
const full = path.join(dir, ent.name);
if (ent.isDirectory()) walk(full);
else if (ent.isFile() && ent.name.startsWith("rollout-") && ent.name.endsWith(".jsonl")) out.push(full);
}
}
walk(base);
return out;
}
// 读取 session_index.jsonl,得到 {会话id: Codex 生成的标题}
function loadIndexTitles() {
const map = {};
try {
const txt = fs.readFileSync(SESSION_INDEX, "utf-8");
for (const line of txt.split("\n")) {
if (!line.trim()) continue;
try {
const d = JSON.parse(line);
if (d.id && d.thread_name) map[d.id] = d.thread_name;
} catch {}
}
} catch {}
return map;
}
// 只读文件开头一段,快速取元信息与首条用户消息(避免整读大文件)
function peekMeta(file) {
let head = "";
try {
const fd = fs.openSync(file, "r");
const buf = Buffer.alloc(262144); // 256KB:足够越过超长系统提示找到首条用户消息
const n = fs.readSync(fd, buf, 0, buf.length, 0);
fs.closeSync(fd);
head = buf.slice(0, n).toString("utf-8");
} catch {
return null;
}
const meta = { id: "", cwd: "", ts: "", source: "", threadSource: "", provider: "", title: "" };
const lines = head.split("\n");
for (const line of lines) {
if (!line.trim()) continue;
let d;
try {
d = JSON.parse(line);
} catch {
continue; // 可能是被截断的最后一行
}
const p = d.payload || {};
if (d.type === "session_meta") {
meta.id = p.session_id || p.id || "";
meta.cwd = p.cwd || "";
meta.ts = p.timestamp || d.timestamp || "";
meta.source = p.source || "";
meta.threadSource = p.thread_source || "";
meta.provider = p.model_provider || "";
} else if (d.type === "event_msg" && p.type === "user_message" && !meta.title) {
meta.title = String(p.message || "").replace(/\s+/g, " ").trim().slice(0, 60);
}
if (meta.id && meta.title) break;
}
return meta;
}
function normalizeListOptions(opts) {
if (typeof opts === "boolean") return { includeSubagent: opts, includeArchived: false };
return {
includeSubagent: !!(opts && opts.includeSubagent),
includeArchived: !!(opts && opts.includeArchived),
};
}
// 列出会话(默认只列当前 sessions,并排除子 agent 派生线程;全量模式会带 archived_sessions)
function listConversations(opts = {}) {
const { includeSubagent, includeArchived } = normalizeListOptions(opts);
const roots = [{ dir: SESSIONS_DIR, archived: false }];
if (includeArchived) roots.push({ dir: ARCHIVED_SESSIONS_DIR, archived: true });
const titles = loadIndexTitles();
const items = [];
for (const root of roots) {
const files = findRolloutFiles(root.dir);
for (const file of files) {
const m = peekMeta(file);
if (!m) continue;
if (!includeSubagent && m.threadSource === "subagent") continue;
let size = 0;
try {
size = fs.statSync(file).size;
} catch {}
items.push({
id: m.id,
title: titles[m.id] || m.title || "(无标题对话)",
project: m.cwd ? path.basename(m.cwd) : "",
cwd: m.cwd,
ts: m.ts,
source: m.source,
threadSource: m.threadSource,
provider: m.provider,
path: file,
size,
archived: root.archived,
});
}
}
items.sort((a, b) => (b.ts || "").localeCompare(a.ts || ""));
return items;
}
// 解析单个会话文件为可读消息列表
function readConversation(file) {
const messages = [];
let meta = {};
let content;
try {
content = fs.readFileSync(file, "utf-8");
} catch (e) {
return { meta, messages, error: String(e) };
}
for (const line of content.split("\n")) {
if (!line.trim()) continue;
let d;
try {
d = JSON.parse(line);
} catch {
continue;
}
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 || "" };
} else if (d.type === "event_msg" && p.type === "user_message") {
messages.push({ role: "user", text: String(p.message || ""), ts });
} else if (d.type === "event_msg" && p.type === "agent_message") {
messages.push({ role: "assistant", text: String(p.message || ""), ts });
} else if (d.type === "response_item" && p.type === "function_call") {
const name = p.name || "tool";
messages.push({ role: "tool", text: `调用工具:${name}`, ts, collapsed: true });
}
}
return { meta, messages };
}
// 导出为 Markdown 文本
function toMarkdown(item, conv) {
const lines = [];
lines.push(`# ${item.title || "Codex 会话记录"}`);
lines.push("");
if (item.project) lines.push(`- 项目:${item.project}`);
if (item.cwd) lines.push(`- 目录:${item.cwd}`);
if (item.ts) lines.push(`- 时间:${item.ts}`);
if (item.provider) lines.push(`- Provider:${item.provider}`);
lines.push("");
lines.push("---");
lines.push("");
for (const m of conv.messages) {
if (m.role === "user") lines.push(`## 👤 用户\n\n${m.text}\n`);
else if (m.role === "assistant") lines.push(`## 🤖 Codex\n\n${m.text}\n`);
else if (m.role === "tool") lines.push(`> 🔧 ${m.text}\n`);
}
return lines.join("\n");
}
// 备份:把整个 sessions/ 与索引复制到目标目录下的 codex-sessions-backup/
function backupAll(destDir, log = () => {}) {
const out = path.join(destDir, "codex-sessions-backup");
fs.mkdirSync(out, { recursive: true });
const files = findRolloutFiles(SESSIONS_DIR);
let n = 0;
for (const f of files) {
const rel = path.relative(SESSIONS_DIR, f);
const dst = path.join(out, "sessions", rel);
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.copyFileSync(f, dst);
n++;
}
if (fs.existsSync(SESSION_INDEX)) fs.copyFileSync(SESSION_INDEX, path.join(out, "session_index.jsonl"));
log(`已备份 ${n} 个会话文件到 ${out}`);
return { path: out, count: n };
}
// 导入:把外部备份目录里的 rollout-*.jsonl 合并进本机 sessions/(按原年月日结构,已存在则跳过)
function importSessions(srcDir, log = () => {}) {
// 支持传入 backup 根目录或其中的 sessions/ 子目录
let base = srcDir;
if (fs.existsSync(path.join(srcDir, "sessions"))) base = path.join(srcDir, "sessions");
const files = findRolloutFiles(base);
let added = 0;
let skipped = 0;
for (const f of files) {
const rel = path.relative(base, f);
const dst = path.join(SESSIONS_DIR, rel);
if (fs.existsSync(dst)) {
skipped++;
continue;
}
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.copyFileSync(f, dst);
added++;
}
log(`导入完成:新增 ${added} 个,跳过 ${skipped} 个(已存在)`);
return { added, skipped };
}
module.exports = {
CODEX_HOME,
SESSIONS_DIR,
ARCHIVED_SESSIONS_DIR,
listConversations,
readConversation,
toMarkdown,
backupAll,
importSessions,
};
+354
View File
@@ -0,0 +1,354 @@
// translate.js —— Responses API ⇄ Chat Completions 协议转换。
// 让只支持 chat/completions 的中转(DeepSeek/GLM/Kimi/通义等)也能给 Codex(用 responses)用。
// 参考实现:sybil-solutions/codex-shim、BerriAI/litellm。
const crypto = require("crypto");
function genId(prefix) {
return prefix + Date.now().toString(36) + crypto.randomBytes(4).toString("hex");
}
// content parts / string → 纯文本
function partsToText(content) {
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((p) => {
if (typeof p === "string") return p;
if (p && (p.type === "input_text" || p.type === "output_text" || p.type === "text")) return p.text || "";
return "";
})
.join("");
}
return "";
}
// Responses 的 tool(扁平)→ Chat Completions 的 tool(嵌套在 function 下)
function toChatTool(t) {
if (!t) return null;
if (t.type === "function" && t.function) return t; // 已是嵌套
if (t.type === "function" || t.name) {
return {
type: "function",
function: {
name: t.name,
description: t.description || "",
parameters: t.parameters || { type: "object", properties: {} },
},
};
}
return null; // 托管型工具(web_search 等)无 chat 等价,先跳过
}
function toChatToolChoice(tc) {
if (typeof tc === "string") return tc;
if (tc && tc.type === "function" && tc.name) return { type: "function", function: { name: tc.name } };
return tc;
}
// —— 请求:Responses → Chat Completions ——
function responsesToChat(req, overrideModel) {
const messages = [];
if (req.instructions) messages.push({ role: "system", content: req.instructions });
const pending = [];
const flush = () => {
if (pending.length) {
messages.push({ role: "assistant", content: null, tool_calls: pending.splice(0) });
}
};
const input = req.input;
if (typeof input === "string") {
messages.push({ role: "user", content: input });
} else if (Array.isArray(input)) {
for (const item of input) {
const t = item.type;
if (t === "message" || (item.role && !t)) {
flush();
const role = item.role === "developer" ? "system" : item.role;
messages.push({ role, content: partsToText(item.content) });
} else if (t === "function_call") {
const callId = item.call_id || item.id || "call_0";
pending.push({ id: callId, type: "function", function: { name: item.name || "", arguments: item.arguments || "" } });
} else if (t === "function_call_output") {
flush();
const out = typeof item.output === "string" ? item.output : JSON.stringify(item.output);
messages.push({ role: "tool", tool_call_id: item.call_id, content: out });
}
// reasoning item → 丢弃(chat 上游用不到)
}
}
flush();
const out = { model: overrideModel || req.model, messages, stream: req.stream !== false };
if (Array.isArray(req.tools)) {
const tools = req.tools.map(toChatTool).filter(Boolean);
if (tools.length) out.tools = tools;
}
if (req.tool_choice) out.tool_choice = toChatToolChoice(req.tool_choice);
if (req.max_output_tokens) out.max_tokens = req.max_output_tokens;
if (req.temperature != null) out.temperature = req.temperature;
if (req.top_p != null) out.top_p = req.top_p;
if (req.parallel_tool_calls != null) out.parallel_tool_calls = req.parallel_tool_calls;
const eff = req.reasoning && req.reasoning.effort;
if (eff) out.reasoning_effort = eff;
if (out.stream) out.stream_options = { include_usage: true };
return out;
}
function mapUsage(u) {
if (!u) return null;
return {
input_tokens: u.prompt_tokens || 0,
input_tokens_details: { cached_tokens: (u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0 },
output_tokens: u.completion_tokens || 0,
output_tokens_details: { reasoning_tokens: 0 },
total_tokens: u.total_tokens || 0,
};
}
// —— 响应:Chat Completions 流 → Responses 流事件(有状态转换器)——
class ChatToResponses {
constructor(model) {
this.model = model;
this.responseId = genId("resp_");
this.msgId = genId("msg_");
this.seq = 0;
this.outIndex = 0;
this.msgOpen = false;
this.msgClosed = false;
this.msgText = "";
this.msgOutputIndex = null;
this.tools = {}; // index → {id,name,arguments,outputIndex,closed}
this.usage = null;
this.started = false;
// 推理模型(DeepSeek-R1/v4、GLM、Kimi)的思考内容
this.reasonOpen = false;
this.reasonClosed = false;
this.reasonText = "";
this.reasonId = null;
this.reasonOutputIndex = null;
}
openReason() {
if (this.reasonOpen) return "";
this.reasonOpen = true;
this.reasonId = genId("rs_");
this.reasonOutputIndex = this.outIndex++;
let s = this.ev("response.output_item.added", {
output_index: this.reasonOutputIndex,
item: { id: this.reasonId, type: "reasoning", summary: [] },
});
s += this.ev("response.reasoning_summary_part.added", {
item_id: this.reasonId,
output_index: this.reasonOutputIndex,
summary_index: 0,
part: { type: "summary_text", text: "" },
});
return s;
}
closeReason() {
if (!this.reasonOpen || this.reasonClosed) return "";
this.reasonClosed = true;
let s = this.ev("response.reasoning_summary_text.done", {
item_id: this.reasonId,
output_index: this.reasonOutputIndex,
summary_index: 0,
text: this.reasonText,
});
s += this.ev("response.reasoning_summary_part.done", {
item_id: this.reasonId,
output_index: this.reasonOutputIndex,
summary_index: 0,
part: { type: "summary_text", text: this.reasonText },
});
s += this.ev("response.output_item.done", {
output_index: this.reasonOutputIndex,
item: { id: this.reasonId, type: "reasoning", summary: [{ type: "summary_text", text: this.reasonText }] },
});
return s;
}
ev(type, obj) {
const payload = Object.assign({ type, sequence_number: this.seq++ }, obj);
return `event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`;
}
baseResponse(status, output) {
return {
id: this.responseId,
object: "response",
created_at: Math.floor(Date.now() / 1000),
status,
error: null,
incomplete_details: null,
instructions: null,
model: this.model,
output,
parallel_tool_calls: true,
previous_response_id: null,
reasoning: { effort: null, summary: null },
store: false,
temperature: 1.0,
text: { format: { type: "text" } },
tool_choice: "auto",
tools: [],
top_p: 1.0,
truncation: "disabled",
usage: this.usage,
metadata: {},
};
}
start() {
const r = this.baseResponse("in_progress", []);
return this.ev("response.created", { response: r }) + this.ev("response.in_progress", { response: r });
}
openMsg() {
if (this.msgOpen) return "";
this.msgOpen = true;
this.msgOutputIndex = this.outIndex++;
let s = this.ev("response.output_item.added", {
output_index: this.msgOutputIndex,
item: { id: this.msgId, type: "message", status: "in_progress", role: "assistant", content: [] },
});
s += this.ev("response.content_part.added", {
item_id: this.msgId,
output_index: this.msgOutputIndex,
content_index: 0,
part: { type: "output_text", text: "", annotations: [] },
});
return s;
}
closeMsg() {
if (!this.msgOpen || this.msgClosed) return "";
this.msgClosed = true;
let s = this.ev("response.output_text.done", {
item_id: this.msgId,
output_index: this.msgOutputIndex,
content_index: 0,
text: this.msgText,
logprobs: [],
});
s += this.ev("response.content_part.done", {
item_id: this.msgId,
output_index: this.msgOutputIndex,
content_index: 0,
part: { type: "output_text", text: this.msgText, annotations: [] },
});
s += this.ev("response.output_item.done", {
output_index: this.msgOutputIndex,
item: {
id: this.msgId,
type: "message",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: this.msgText, annotations: [] }],
},
});
return s;
}
onToolDelta(tc) {
let out = "";
const index = tc.index != null ? tc.index : 0;
const fn = tc.function || {};
let st = this.tools[index];
if (!st) {
out += this.closeReason(); // 先关推理
out += this.closeMsg(); // 再关文本消息,才开工具项
const callId = tc.id || "call_" + index;
st = { id: callId, name: fn.name || "", arguments: "", outputIndex: this.outIndex++, closed: false };
this.tools[index] = st;
out += this.ev("response.output_item.added", {
output_index: st.outputIndex,
item: { id: st.id, type: "function_call", status: "in_progress", call_id: st.id, name: st.name, arguments: "" },
});
} else if (fn.name) {
st.name += fn.name;
}
if (fn.arguments) {
st.arguments += fn.arguments;
out += this.ev("response.function_call_arguments.delta", {
item_id: st.id,
output_index: st.outputIndex,
delta: fn.arguments,
});
}
return out;
}
closeTool(st) {
if (st.closed) return "";
st.closed = true;
let s = this.ev("response.function_call_arguments.done", {
item_id: st.id,
output_index: st.outputIndex,
arguments: st.arguments,
});
s += this.ev("response.output_item.done", {
output_index: st.outputIndex,
item: { id: st.id, type: "function_call", status: "completed", call_id: st.id, name: st.name, arguments: st.arguments },
});
return s;
}
// 喂一个 chat 流 chunk(已解析的 JSON),返回要写给 Codex 的 Responses 事件串
onChunk(obj) {
let out = "";
if (!this.started) {
out += this.start();
this.started = true;
}
if (obj.usage) this.usage = mapUsage(obj.usage);
const choice = obj.choices && obj.choices[0];
if (!choice) return out;
const delta = choice.delta || {};
// 推理内容(reasoning_content)→ 思考事件
const rc = delta.reasoning_content || delta.reasoning;
if (rc) {
out += this.openReason();
this.reasonText += rc;
out += this.ev("response.reasoning_summary_text.delta", {
item_id: this.reasonId,
output_index: this.reasonOutputIndex,
summary_index: 0,
delta: rc,
});
}
if (delta.content) {
out += this.closeReason(); // 有正文了,先收尾推理
out += this.openMsg();
this.msgText += delta.content;
out += this.ev("response.output_text.delta", {
item_id: this.msgId,
output_index: this.msgOutputIndex,
content_index: 0,
delta: delta.content,
logprobs: [],
});
}
if (Array.isArray(delta.tool_calls)) {
for (const tc of delta.tool_calls) out += this.onToolDelta(tc);
}
return out;
}
finish() {
let out = "";
if (!this.started) {
out += this.start();
this.started = true;
}
out += this.closeReason();
out += this.closeMsg();
const sts = Object.values(this.tools).sort((a, b) => a.outputIndex - b.outputIndex);
for (const st of sts) out += this.closeTool(st);
const output = [];
if (this.reasonOpen)
output.push({ id: this.reasonId, type: "reasoning", summary: [{ type: "summary_text", text: this.reasonText }] });
if (this.msgOpen)
output.push({
id: this.msgId,
type: "message",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: this.msgText, annotations: [] }],
});
for (const st of sts)
output.push({ id: st.id, type: "function_call", status: "completed", call_id: st.id, name: st.name, arguments: st.arguments });
out += this.ev("response.completed", { response: this.baseResponse("completed", output) });
return out;
}
}
module.exports = { responsesToChat, ChatToResponses };
+50
View File
@@ -0,0 +1,50 @@
// zip.js —— 零依赖 ZIP 解压(stored/deflate),供技能安装与热更新复用。
"use strict";
const fs = require("fs");
const path = require("path");
const zlib = require("zlib");
// 解析中央目录,把 zip 内容写入 destDir;返回写出的文件数。
function extractZip(buf, destDir) {
// 找 EOCD(0x06054b50),从尾部回扫
let eocd = -1;
for (let i = buf.length - 22; i >= 0 && i >= buf.length - 22 - 65536; i--) {
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
}
if (eocd < 0) throw new Error("不是有效的 zip(找不到 EOCD)");
const count = buf.readUInt16LE(eocd + 10);
let ptr = buf.readUInt32LE(eocd + 16); // 中央目录起始偏移
let written = 0;
for (let n = 0; n < count; n++) {
if (buf.readUInt32LE(ptr) !== 0x02014b50) break;
const method = buf.readUInt16LE(ptr + 10);
const compSize = buf.readUInt32LE(ptr + 20);
const nameLen = buf.readUInt16LE(ptr + 28);
const extraLen = buf.readUInt16LE(ptr + 30);
const commentLen = buf.readUInt16LE(ptr + 32);
const localOff = buf.readUInt32LE(ptr + 42);
const name = buf.toString("utf8", ptr + 46, ptr + 46 + nameLen);
ptr += 46 + nameLen + extraLen + commentLen;
// 读本地头,定位数据
if (buf.readUInt32LE(localOff) !== 0x04034b50) continue;
const lNameLen = buf.readUInt16LE(localOff + 26);
const lExtraLen = buf.readUInt16LE(localOff + 28);
const dataStart = localOff + 30 + lNameLen + lExtraLen;
const comp = buf.subarray(dataStart, dataStart + compSize);
// 防目录穿越
const safe = name.replace(/\\/g, "/").replace(/^\/+/, "");
if (safe.includes("../")) continue;
const outPath = path.join(destDir, safe);
if (safe.endsWith("/")) { fs.mkdirSync(outPath, { recursive: true }); continue; }
fs.mkdirSync(path.dirname(outPath), { recursive: true });
let data;
if (method === 0) data = comp;
else if (method === 8) data = zlib.inflateRawSync(comp);
else throw new Error(`不支持的压缩方式 ${method}`);
fs.writeFileSync(outPath, data);
written++;
}
return written;
}
module.exports = { extractZip };