feat: add desktop app hot update channel
This commit is contained in:
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user