Files
codex-market-app/electron/lib/voice_cdp.js
T

988 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// voice_cdp.js —— Codex 语音输入注入:主进程侧。
// 用 CDP(Chrome DevTools Protocol)连接带 --remote-debugging-port 的 Codex 桌面版,
// 往其页面注入麦克风按钮脚本,录音经 binding 回传 → 调中转站 Whisper 转写 → 文字回填 composer。
// 不改 Codex 源码、不写补丁文件。无第三方依赖:CDP over WebSocket 用 Node 内置 net 手写最小 WS 客户端
// (Electron 34 主进程的 Node 无全局 WebSocket);HTTP 用全局 fetch;multipart 用全局 FormData/Blob。
//
// 机制参考 CodexPlusPlus 的 bridge.rs:Runtime.evaluate / Page.addScriptToEvaluateOnNewDocument /
// Runtime.addBinding + Runtime.bindingCalled。
const { spawn, execFile } = require("child_process");
const os = require("os");
const path = require("path");
const net = require("net");
const crypto = require("crypto");
function dlog(...a) { try { console.log("[voice]", ...a); } catch {} }
const router = require("./router");
const providerSync = require("./provider_sync");
const { INJECT_SCRIPT } = require("./voice_inject");
const { TOKEN_INJECT_SCRIPT } = require("./token_inject");
const tokenUsage = require("./token_usage");
const DEFAULT_PORT = 19222; // Codex 调试端口(避开 Chrome 默认 9222)
const BINDING_NAME = "__voiceBridge"; // 页面 → 主进程 的 CDP binding
const CDP_HTTP_TIMEOUT = 3000; // GET /json 超时
const CDP_WS_TIMEOUT = 5000; // WebSocket 握手超时
const CDP_CMD_TIMEOUT = 5000; // 单条 CDP 命令超时
const READY_POLL_INTERVAL = 500; // 轮询就绪间隔
const READY_POLL_MAX_MS = 20000; // 轮询就绪最长等待
const RECORD_MAX_MS = 60000; // 单次录音上限(注入脚本侧也限)
// —— 状态机 ——
// unknown / checking / not_running / running_no_port / restarting / connecting /
// injecting / connected / disconnected / error
let state = {
phase: "unknown",
port: DEFAULT_PORT,
codexPath: "",
lastError: "",
target: null, // { id, url, title }
};
let session = null; // 当前 CdpSession
let networkSessions = []; // worker 等附加 target 的 Network 监听 session
let networkAttachGeneration = 0;
let onStatusCb = null;
let tokenSyncTimer = null;
function snapshot() {
const { phase, port, codexPath, lastError, model, target } = state;
return { phase, port, codexPath, lastError, model, target };
}
function setPhase(phase, extra = {}) {
state = { ...state, phase, ...extra };
if (onStatusCb) {
try {
onStatusCb(snapshot());
} catch {}
}
}
async function syncTokenUsage() {
if (!session) return;
const usage = tokenUsage.readDaily();
if (!usage || !usage.available) return;
try {
await session.send("Runtime.evaluate", {
expression: "window.__tdTokenSet && window.__tdTokenSet(" + JSON.stringify(usage) + ")",
awaitPromise: false,
});
} catch {}
}
function startTokenSync() {
if (tokenSyncTimer) clearInterval(tokenSyncTimer);
tokenSyncTimer = setInterval(() => { syncTokenUsage().catch(() => {}); }, 2500);
syncTokenUsage().catch(() => {});
}
function stopTokenSync() {
if (tokenSyncTimer) clearInterval(tokenSyncTimer);
tokenSyncTimer = null;
}
// —— 探测 Codex 可执行路径 ——
function codexExecutable() {
const home = os.homedir();
if (process.platform === "darwin") {
const cands = [
path.join(home, "Applications/ChatGPT.app/Contents/MacOS/ChatGPT"),
"/Applications/ChatGPT.app/Contents/MacOS/ChatGPT",
"/Applications/Codex.app/Contents/MacOS/Codex",
];
for (const p of cands) {
try {
if (require("fs").existsSync(p)) return p;
} catch {}
}
return "";
}
if (process.platform === "win32") {
const local = process.env.LOCALAPPDATA || "";
const pf = process.env["ProgramFiles"] || "C:\\Program Files";
const cands = [
local && path.join(local, "Programs\\ChatGPT\\ChatGPT.exe"),
path.join(pf, "ChatGPT\\ChatGPT.exe"),
].filter(Boolean);
for (const p of cands) {
try {
if (require("fs").existsSync(p)) return p;
} catch {}
}
return "";
}
return "";
}
// —— HTTP:GET /json 取 targets ——
async function listTargets(port) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), CDP_HTTP_TIMEOUT);
try {
const res = await fetch(`http://127.0.0.1:${port}/json`, { signal: ctrl.signal });
if (!res.ok) return [];
const arr = await res.json();
return Array.isArray(arr) ? arr : [];
} catch {
return [];
} finally {
clearTimeout(t);
}
}
// —— 挑可注入的 Codex 主页面 target ——
// Codex 可能有多个 page:主对话页 app://-/index.html,以及 avatar-overlay / global-dictation
// 等带 ?initialRoute= 的子路由覆盖层。优先选「无 query 的主入口」,次选普通 codex 页,排除覆盖层。
function pickTarget(targets) {
let main = null; // url 恰为 app://-/index.html(无 query)
let fallback = null; // 其它非覆盖层 codex 页
for (const t of targets) {
if (t.type !== "page") continue;
const ws = t.webSocketDebuggerUrl;
if (!ws) continue;
const url = (t.url || "").toLowerCase();
const title = (t.title || "").toLowerCase();
// 覆盖层:带 initialRoute 的子路由(avatar-overlay / global-dictation 等)
const isOverlayRoute = url.includes("initialroute=");
const isCodex =
url.startsWith("app://-/index.html") || url.includes("index.html") || title.includes("codex");
if (!isCodex) continue;
if (isOverlayRoute) continue; // 覆盖层不注入
if (url === "app://-/index.html" || url === "app://-/index.html/") {
main = main || t;
} else {
fallback = fallback || t;
}
}
return main || fallback;
}
// —— 最小 WebSocket 客户端(Node 内置 net,无需依赖;仅支持 CDP 需要的文本帧)——
// 解析 ws://host:port/path,TCP 连接 → HTTP 升级握手(带 Sec-WebSocket-Key)→ 文本帧收发。
// 客户端发送必须掩码;CDP 服务端发送不掩码。帧可能分片到达,用缓冲区拼。
function wsConnect(wsUrl) {
const u = new URL(wsUrl);
const host = u.hostname;
const port = u.port || (u.protocol === "wss:" ? "443" : "80");
const pathStr = u.pathname + u.search;
const key = crypto.randomBytes(16).toString("base64");
const socket = net.connect({ host, port: Number(port) });
const events = { open: [], message: [], close: [], error: [] };
const emit = (name, arg) => (events[name] || []).forEach((cb) => cb(arg));
socket.setTimeout(CDP_WS_TIMEOUT, () => {
if (!handshakeDone) emit("error", new Error("WS 连接超时"));
try { socket.destroy(); } catch {}
});
// —— 发送一帧文本(客户端必须掩码)——
function sendFrame(str) {
const payload = Buffer.from(str, "utf8");
const len = payload.length;
// opcode 1 = 文本;FIN=1
let header;
const mask = crypto.randomBytes(4);
if (len < 126) {
header = Buffer.alloc(6);
header[0] = 0x81;
header[1] = 0x80 | len;
mask.copy(header, 2);
} else if (len < 65536) {
header = Buffer.alloc(8);
header[0] = 0x81;
header[1] = 0x80 | 126;
header.writeUInt16BE(len, 2);
mask.copy(header, 4);
} else {
header = Buffer.alloc(14);
header[0] = 0x81;
header[1] = 0x80 | 127;
header.writeUInt32BE(0, 2);
header.writeUInt32BE(len, 6);
mask.copy(header, 10);
}
const masked = Buffer.alloc(len);
for (let i = 0; i < len; i++) masked[i] = payload[i] ^ mask[i & 3];
socket.write(Buffer.concat([header, masked]));
}
// —— 接收缓冲 + 帧解析 ——
let buf = Buffer.alloc(0);
let handshakeDone = false;
function parseFrames() {
while (true) {
if (buf.length < 2) return;
const b0 = buf[0];
const b1 = buf[1];
const opcode = b0 & 0x0f;
const masked = (b1 & 0x80) !== 0;
let payloadLen = b1 & 0x7f;
let offset = 2;
if (payloadLen === 126) {
if (buf.length < 4) return;
payloadLen = buf.readUInt16BE(2);
offset = 4;
} else if (payloadLen === 127) {
if (buf.length < 10) return;
// 高 32 位忽略(CDP 帧不会那么大)
payloadLen = Number(buf.readUInt32BE(6));
offset = 10;
}
let maskKey = null;
if (masked) {
if (buf.length < offset + 4) return;
maskKey = buf.slice(offset, offset + 4);
offset += 4;
}
if (buf.length < offset + payloadLen) return;
let payload = buf.slice(offset, offset + payloadLen);
if (masked) {
const un = Buffer.alloc(payload.length);
for (let i = 0; i < payload.length; i++) un[i] = payload[i] ^ maskKey[i & 3];
payload = un;
}
buf = buf.slice(offset + payloadLen);
// 只处理文本(1)/ping(9)/pong(10)/关闭(8)
if (opcode === 0x8) {
// 关闭帧
emit("close");
try {
socket.end();
} catch {}
return;
} else if (opcode === 0x9) {
// ping → 回 pong(无掩码服务端不要求,但回时按服务端→客户端本可不掩码;此处回文本帧不规范,CDP 不发 ping)
continue;
} else if (opcode === 0xa) {
continue; // pong,忽略
} else {
// 文本帧(含分片首帧 opcode 0 也按文本拼;CDP 单帧文本足够,简单按单帧处理)
emit("message", payload.toString("utf8"));
}
}
}
socket.on("connect", () => {
const req =
`GET ${pathStr} HTTP/1.1\r\n` +
`Host: ${host}:${port}\r\n` +
`Upgrade: websocket\r\n` +
`Connection: Upgrade\r\n` +
`Sec-WebSocket-Key: ${key}\r\n` +
`Sec-WebSocket-Version: 13\r\n\r\n`;
socket.write(req);
});
socket.on("data", (chunk) => {
buf = Buffer.concat([buf, chunk]);
if (!handshakeDone) {
const idx = buf.indexOf("\r\n\r\n");
if (idx < 0) return;
const head = buf.slice(0, idx).toString("utf8");
buf = buf.slice(idx + 4);
if (!/101/.test(head.split("\r\n")[0])) {
emit("error", new Error("WS 握手失败: " + head.split("\r\n")[0]));
try {
socket.destroy();
} catch {}
return;
}
handshakeDone = true;
try { socket.setTimeout(0); } catch {}
emit("open");
parseFrames();
} else {
parseFrames();
}
});
socket.on("close", () => emit("close"));
socket.on("error", (e) => emit("error", e));
return {
send: sendFrame,
close: () => {
try {
socket.destroy();
} catch {}
},
on: (name, cb) => {
(events[name] = events[name] || []).push(cb);
},
_socket: socket,
};
}
// —— CDP WebSocket 会话 ——
function openSession(wsUrl, onBindingCalled) {
const pending = new Map(); // id → {resolve, reject}
const handlers = new Map(); // method → Set<cb>
let nextId = 1;
let closed = false;
const ws = wsConnect(wsUrl);
function send(method, params = {}) {
return new Promise((resolve, reject) => {
if (closed) return reject(new Error("CDP 会话已关闭"));
const id = nextId++;
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error("CDP 命令超时: " + method));
}, CDP_CMD_TIMEOUT);
pending.set(id, {
resolve: (value) => {
clearTimeout(timer);
resolve(value);
},
reject: (err) => {
clearTimeout(timer);
reject(err);
},
});
ws.send(JSON.stringify({ id, method, params }));
});
}
function on(method, cb) {
if (!handlers.has(method)) handlers.set(method, new Set());
handlers.get(method).add(cb);
}
function close() {
closed = true;
try {
ws.close();
} catch {}
}
ws.on("message", (data) => {
let msg;
try {
msg = JSON.parse(data);
} catch {
return;
}
if (msg.id != null) {
const p = pending.get(msg.id);
if (!p) return;
pending.delete(msg.id);
if (msg.error) p.reject(new Error(msg.error.message || "CDP error"));
else p.resolve(msg.result);
return;
}
if (msg.method) {
if (msg.method === "Runtime.bindingCalled" && onBindingCalled) {
try {
onBindingCalled(msg.params);
} catch {}
}
const set = handlers.get(msg.method);
if (set) for (const cb of set) cb(msg.params);
}
});
ws.on("close", () => {
closed = true;
for (const { reject } of pending.values()) {
try {
reject(new Error("CDP 会话关闭"));
} catch {}
}
pending.clear();
});
return new Promise((resolve, reject) => {
ws.on("open", () => resolve({ send, on, close, ws }));
ws.on("error", (e) => reject(new Error("CDP 连接失败:" + (e && e.message ? e.message : "ws error"))));
});
}
// —— 转写:中转站 /v1/audio/transcriptions(用 sttGroup:勾选了 STT 的分组)——
async function runStt(audioB64, mime) {
const g = router.sttGroup();
if (!g || !g.baseUrl || !g.key) throw new Error("未配置支持语音转文字的中转分组(在路由中转里勾选「支持语音转文字」)");
const base = (g.baseUrl || "").replace(/\/+$/, "");
const model = g.sttModel || "whisper-1";
const buf = Buffer.from(audioB64, "base64");
const ext = mime && mime.includes("webm") ? "webm" : mime && mime.includes("mp4") ? "mp4" : "wav";
const form = new FormData();
form.append("file", new Blob([buf], { type: mime || "audio/webm" }), `voice.${ext}`);
form.append("model", model);
form.append("response_format", "json");
const res = await fetch(base + "/v1/audio/transcriptions", {
method: "POST",
headers: { Authorization: "Bearer " + g.key },
body: form,
});
dlog("转写请求 →", base + "/v1/audio/transcriptions", "model=" + model, "size=" + buf.length, "HTTP " + res.status);
if (!res.ok) {
const detail = await res.text().catch(() => "");
throw new Error(`转写接口 HTTP ${res.status}:${detail.slice(0, 300)}`);
}
const j = await res.json();
return (j && (j.text || "")).trim();
}
// —— 主进程收到页面 binding 回传:处理转写并把文字送回页面 ——
async function handleBinding(params) {
const name = params && params.name;
if (name !== BINDING_NAME) return;
let payload;
try {
payload = JSON.parse(params.payload);
} catch {
return;
}
if (!payload || payload.kind !== "transcribe") return;
const { id, audioB64, mime } = payload;
dlog("binding 收到转写请求 id=" + id, "mime=" + mime, "audioB64长度=" + (audioB64?audioB64.length:0));
setPhase("transcribing");
let text = "";
let err = "";
try {
text = await runStt(audioB64, mime);
dlog("转写结果:", JSON.stringify(text).slice(0, 120));
} catch (e) {
err = String((e && e.message) || e);
dlog("转写失败:", err);
}
setPhase(connectedPhase());
// 把结果回调给页面
const expr = `window.__voiceInjectHandle && window.__voiceInjectHandle(${JSON.stringify(
{ id, text, err }
)})`;
try {
if (session) await session.send("Runtime.evaluate", { expression: expr, awaitPromise: false });
} catch {}
}
function connectedPhase() {
return session ? "connected" : "disconnected";
}
// —— 注入:addBinding + addScriptToEvaluateOnNewDocument + 立即 evaluate + Network 监听 ——
async function inject(sessionObj) {
await sessionObj.send("Runtime.enable");
try {
await sessionObj.send("Runtime.removeBinding", { name: BINDING_NAME });
} catch {}
await sessionObj.send("Runtime.addBinding", { name: BINDING_NAME });
await sessionObj.send("Page.enable");
// 语音注入脚本
await sessionObj.send("Page.addScriptToEvaluateOnNewDocument", { source: INJECT_SCRIPT });
await sessionObj.send("Runtime.evaluate", { expression: INJECT_SCRIPT, awaitPromise: false });
// Token 用量 UI 注入脚本(只负责 UI + localStorage,不管网络抓取)
await sessionObj.send("Page.addScriptToEvaluateOnNewDocument", { source: TOKEN_INJECT_SCRIPT });
await sessionObj.send("Runtime.evaluate", { expression: TOKEN_INJECT_SCRIPT, awaitPromise: false });
// 启用 Network 域,在 CDP 层面拦截 LLM 响应抓 token 用量
await sessionObj.send("Network.enable");
}
// ============================================================================
// Token 用量:CDP Network 域拦截 LLM 响应抓 usage(不受页面 CSP 限制)
// ============================================================================
// 待完成的请求:requestId → { url, mimeType }
const pendingNetworkReqs = new Map();
let nextNetworkSessionId = 1;
const tokenNet = {
matchedCount: 0,
usageCount: 0,
noUsageCount: 0,
lastMatchedUrl: "",
lastUsageUrl: "",
lastNoUsageUrl: "",
lastUsageTotal: 0,
lastError: "",
attachedWorkers: 0,
};
const LLM_URL_RE = /\/(chat\/completions|v1\/messages|messages|generateContent|streamGenerateContent|completions|responses|backend-api\/(?:codex|conversation|responses)|codex|conversation)(\/|$|\?)/i;
function networkReqKey(ownerSession, requestId) {
if (!ownerSession._tdTokenNetId) ownerSession._tdTokenNetId = nextNetworkSessionId++;
return ownerSession._tdTokenNetId + ":" + requestId;
}
function bindNetworkHandlers(ownerSession) {
if (!ownerSession) return;
if (!ownerSession._tdTokenNetId) ownerSession._tdTokenNetId = nextNetworkSessionId++;
ownerSession.on("Network.responseReceived", (params) => handleNetworkResponse(params, ownerSession));
ownerSession.on("Network.loadingFinished", (params) => handleNetworkLoadingFinished(params, ownerSession));
ownerSession.on("Network.loadingFailed", (params) => handleNetworkLoadingFailed(params, ownerSession));
}
function handleNetworkResponse(params, ownerSession) {
const { requestId, response } = params || {};
if (!requestId || !response || !ownerSession) return;
const url = response.url || "";
if (!LLM_URL_RE.test(url)) return;
// 只处理 JSON 响应
const mime = (response.mimeType || "").toLowerCase();
if (!mime.includes("json") && !mime.includes("text")) return;
tokenNet.matchedCount += 1;
tokenNet.lastMatchedUrl = url;
pendingNetworkReqs.set(networkReqKey(ownerSession, requestId), { url, mimeType: mime });
}
function handleNetworkLoadingFailed(params, ownerSession) {
const { requestId } = params || {};
if (requestId && ownerSession) pendingNetworkReqs.delete(networkReqKey(ownerSession, requestId));
}
async function handleNetworkLoadingFinished(params, ownerSession) {
const { requestId } = params || {};
if (!requestId || !ownerSession) return;
const key = networkReqKey(ownerSession, requestId);
if (!pendingNetworkReqs.has(key)) return;
const reqInfo = pendingNetworkReqs.get(key) || {};
pendingNetworkReqs.delete(key);
if (!session || !ownerSession) return;
try {
const r = await ownerSession.send("Network.getResponseBody", { requestId });
const body = (r && r.body) || "";
if (!body || body.length < 10) return;
// 可能被 base64 编码
let text = body;
if (r && r.base64Encoded) {
try { text = Buffer.from(body, "base64").toString("utf8"); } catch { return; }
}
// 解析 JSON 提取 usage
const usage = extractUsageFromText(text);
if (usage && usage.total > 0) {
tokenNet.usageCount += 1;
tokenNet.lastUsageUrl = reqInfo.url || "";
tokenNet.lastUsageTotal = usage.total || 0;
tokenNet.lastError = "";
dlog(
"token: +" + usage.total +
" input=" + (usage.input || 0) +
" output=" + (usage.output || 0) +
" cached=" + (usage.cached || 0) +
" from",
reqInfo.url || "?"
);
// 通过 Runtime.evaluate 调注入脚本更新 UI
try {
await session.send("Runtime.evaluate", {
expression: "window.__tdTokenAdd && window.__tdTokenAdd(" + JSON.stringify(usage) + ", 'network')",
awaitPromise: false,
});
} catch {}
} else {
tokenNet.noUsageCount += 1;
tokenNet.lastNoUsageUrl = reqInfo.url || "";
}
} catch (e) {
tokenNet.lastError = String((e && e.message) || e || "getResponseBody failed").slice(0, 160);
// getResponseBody 可能失败(响应体已被清掉或太大),静默跳过
}
}
function extractUsageFromText(text) {
let best = null;
const take = (obj) => {
best = betterUsage(best, extractUsageFromObject(obj));
};
try {
take(JSON.parse(text));
} catch {}
if (usageTotal(best) > 0) return best;
// Stream/SSE: data: {"usage":...}\n\n or data: [DONE]
const lines = String(text || "").split(/\r?\n/);
for (const line of lines) {
const s = line.trim();
const payload = s.startsWith("data:") ? s.slice(5).trim() : s;
if (!payload || payload === "[DONE]") continue;
try { take(JSON.parse(payload)); } catch {}
}
return best;
}
function cleanTokenNumber(n) {
n = Number(n);
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
}
function usageTotal(u) {
return u ? cleanTokenNumber(u.total) : 0;
}
function betterUsage(a, b) {
return usageTotal(b) > usageTotal(a) ? b : a;
}
function hasOwn(obj, key) {
return Object.prototype.hasOwnProperty.call(obj, key);
}
function looksLikeUsage(usage) {
if (!usage || typeof usage !== "object") return false;
const keys = [
"input_tokens", "prompt_tokens", "output_tokens", "completion_tokens", "total_tokens",
"promptTokenCount", "prompt_token_count", "inputTokenCount", "outputTokenCount",
"candidatesTokenCount", "candidates_token_count", "totalTokenCount", "total_token_count",
"token_count", "cache_creation_input_tokens", "cache_read_input_tokens",
"cachedContentTokenCount", "cached_content_token_count", "reasoning_tokens",
"thoughtsTokenCount", "thoughts_token_count",
];
if (keys.some((key) => hasOwn(usage, key))) return true;
return hasOwn(usage, "total") && (hasOwn(usage, "input") || hasOwn(usage, "output") || hasOwn(usage, "cached"));
}
function extractUsageFromRawUsage(usage) {
if (!usage || typeof usage !== "object") return null;
if (!looksLikeUsage(usage)) return null;
const input = cleanTokenNumber(
usage.input_tokens ||
usage.prompt_tokens ||
usage.promptTokenCount ||
usage.prompt_token_count ||
usage.inputTokenCount
);
const output = cleanTokenNumber(
usage.output_tokens ||
usage.completion_tokens ||
usage.candidatesTokenCount ||
usage.candidates_token_count ||
usage.outputTokenCount
);
const reasoning = cleanTokenNumber(usage.reasoning_tokens || usage.thoughtsTokenCount || usage.thoughts_token_count);
const details = usage.input_tokens_details || usage.prompt_tokens_details || usage.inputTokenDetails || usage.promptTokenDetails || {};
const cached =
cleanTokenNumber(details.cached_tokens) +
cleanTokenNumber(usage.cache_creation_input_tokens) +
cleanTokenNumber(usage.cache_read_input_tokens) +
cleanTokenNumber(usage.cachedContentTokenCount) +
cleanTokenNumber(usage.cached_content_token_count);
const total = cleanTokenNumber(
usage.total_tokens ||
usage.totalTokenCount ||
usage.total_token_count ||
usage.token_count ||
usage.total
) || input + output + reasoning + cached;
if (!total) return null;
return { total, input, output: output + reasoning, cached };
}
function extractUsageFromObject(obj) {
if (!obj || typeof obj !== "object") return null;
const direct =
extractUsageFromRawUsage(obj.usage) ||
extractUsageFromRawUsage(obj.usage_metadata) ||
extractUsageFromRawUsage(obj.usageMetadata) ||
extractUsageFromRawUsage(obj);
if (direct) return direct;
let best = null;
for (const val of Object.values(obj)) {
if (!val || typeof val !== "object") continue;
if (Array.isArray(val)) {
for (const item of val) best = betterUsage(best, extractUsageFromObject(item));
} else {
best = betterUsage(best, extractUsageFromObject(val));
}
}
return best;
}
async function attachNetworkSession(target, generation) {
if (!target || !target.webSocketDebuggerUrl) return;
let netSession = null;
try {
netSession = await openSession(target.webSocketDebuggerUrl, null);
bindNetworkHandlers(netSession);
await netSession.send("Network.enable");
if (!session || generation !== networkAttachGeneration) {
try { netSession.close(); } catch {}
return false;
}
networkSessions.push(netSession);
dlog("token network attached:", target.type || "target", target.id || "", target.url || "");
return true;
} catch (e) {
if (netSession) {
try { netSession.close(); } catch {}
}
tokenNet.lastError = String((e && e.message) || e || "attach network failed").slice(0, 160);
return false;
}
}
async function attachWorkerNetworkTargets(port, parentId) {
const targets = await listTargets(port);
const workers = targets.filter((t) =>
t &&
t.webSocketDebuggerUrl &&
(t.type === "worker" || t.type === "service_worker" || t.type === "shared_worker") &&
(!parentId || t.parentId === parentId)
);
const generation = networkAttachGeneration;
const results = await Promise.all(workers.map((worker) => attachNetworkSession(worker, generation)));
tokenNet.attachedWorkers = results.filter(Boolean).length;
}
function closeNetworkSessions() {
networkAttachGeneration += 1;
for (const s of networkSessions) {
try { s.close(); } catch {}
}
networkSessions = [];
pendingNetworkReqs.clear();
}
// —— 连接到已带端口的 Codex ——
async function connectToTarget(port) {
const targets = await listTargets(port);
const tgt = pickTarget(targets);
if (!tgt) return false;
closeNetworkSessions();
state.target = { id: tgt.id, url: tgt.url, title: tgt.title };
setPhase("connecting");
session = await openSession(tgt.webSocketDebuggerUrl, handleBinding);
session.on("Runtime.bindingCalled", handleBinding);
// Token 用量:Network 层拦截 LLM 响应
bindNetworkHandlers(session);
session.ws.on("close", () => {
stopTokenSync();
closeNetworkSessions();
session = null;
setPhase("disconnected");
});
setPhase("injecting");
await inject(session);
startTokenSync();
setPhase("connected", { target: state.target });
attachWorkerNetworkTargets(port, tgt.id).catch((e) => {
tokenNet.lastError = String((e && e.message) || e || "attach workers failed").slice(0, 160);
});
return true;
}
// —— 轮询直到出现 page target(重启后用)——
async function waitForReady(port) {
const t0 = Date.now();
while (Date.now() - t0 < READY_POLL_MAX_MS) {
const targets = await listTargets(port);
const tgt = pickTarget(targets);
if (tgt) return tgt;
await new Promise((r) => setTimeout(r, READY_POLL_INTERVAL));
}
return null;
}
// —— 启动接管:杀当前 Codex → 带端口重启 ——
function killCodex() {
return new Promise((resolve) => {
if (process.platform === "darwin") {
execFile("osascript", ["-e", 'tell application "ChatGPT" to quit'], () => {
// 兜底:osascript 没杀掉就 pkill
execFile("pgrep", ["-f", "ChatGPT.app/Contents/MacOS/ChatGPT"], (_e, out) => {
if (out && out.trim()) {
execFile("pkill", ["-f", "ChatGPT.app/Contents/MacOS/ChatGPT"], () =>
setTimeout(resolve, 800)
);
} else resolve();
});
});
} else if (process.platform === "win32") {
execFile("taskkill", ["/IM", "ChatGPT.exe", "/F"], () =>
setTimeout(resolve, 800)
);
} else resolve();
});
}
function spawnCodex(exePath, port) {
const child = spawn(exePath, [`--remote-debugging-port=${port}`], {
detached: true,
stdio: "ignore",
});
try {
child.unref();
} catch {}
}
// —— 对外:status / connect / disconnect ——
function status() {
return snapshot();
}
function onStatus(cb) {
onStatusCb = cb;
}
// connect(onStatusCb):流程见计划。forceRestart=true 时直接重启(前端已确认)。
async function connect(cb, opts = {}) {
if (cb) onStatusCb = cb;
const port = opts.port || DEFAULT_PORT;
state.port = port;
setPhase("checking");
// 1. 端口已开?
const ok = await connectToTarget(port);
if (ok) return snapshot();
// 2. 判断 Codex 是否在跑
const running = providerSync.codexRunning ? providerSync.codexRunning() : false;
if (!running && !opts.forceRestart) {
// 没在跑也没开端口:直接尝试拉起带端口的实例(无需杀进程)
const exe = codexExecutable();
if (!exe) {
setPhase("error", { lastError: "未找到 Codex 可执行文件" });
return snapshot();
}
state.codexPath = exe;
setPhase("restarting", { codexPath: exe });
spawnCodex(exe, port);
const tgt = await waitForReady(port);
if (!tgt) {
setPhase("error", { lastError: "Codex 启动后未开放调试端口,请重试" });
return snapshot();
}
const ok2 = await connectToTarget(port);
if (!ok2) setPhase("error", { lastError: "已启动但未找到可注入页面" });
return snapshot();
}
// 3. 在跑但没端口:需用户在前端确认后 forceRestart 重启
if (running && !opts.forceRestart) {
state.codexPath = codexExecutable();
setPhase("running_no_port", { codexPath: state.codexPath });
return snapshot(); // 前端据此弹"重启确认"
}
// 4. forceRestart:杀进程 → 重启
const exe = codexExecutable();
if (!exe) {
setPhase("error", { lastError: "未找到 Codex 可执行文件" });
return snapshot();
}
state.codexPath = exe;
setPhase("restarting");
await killCodex();
spawnCodex(exe, port);
const tgt = await waitForReady(port);
if (!tgt) {
setPhase("error", { lastError: "重启 Codex 后未开放调试端口" });
return snapshot();
}
const ok3 = await connectToTarget(port);
if (!ok3) setPhase("error", { lastError: "已启动但未找到可注入页面" });
return snapshot();
}
function disconnect() {
stopTokenSync();
closeNetworkSessions();
if (session) {
try {
session.close();
} catch {}
session = null;
}
setPhase("disconnected");
return snapshot();
}
// 当前语音转写配置(来自路由里勾选 STT 的分组),供前端只读展示。
function sttInfo() {
const g = router.sttGroup();
if (!g) return { ready: false, groupName: "", model: "" };
return {
ready: true,
groupName: g.name || "",
model: g.sttModel || "whisper-1",
baseUrl: (g.baseUrl || "").replace(/\/+$/, ""),
};
}
// 诊断:用每个非默认分组的真 key 探测 /v1/audio/transcriptions 是否存在。
// 404 = 路径不存在(不支持STT);400/401/4xx-body非404/200 = 路径存在(支持,鉴权/参数问题另算)。
async function probeStt() {
const groups = router.usableGroups ? router.usableGroups() : [];
const out = [];
const body = Buffer.from([0x1a, 0x45, 0xdf, 0xa3]); // 假 webm 头,只为触发路径判断
for (const g of groups) {
const base = (g.baseUrl || "").replace(/\/+$/, "");
if (!base || !g.key) { out.push({ name: g.name, baseUrl: base, status: 0, note: "缺地址或密钥" }); continue; }
try {
const form = new FormData();
form.append("file", new Blob([body], { type: "audio/webm" }), "v.webm");
form.append("model", g.sttModel || "whisper-1");
const res = await fetch(base + "/v1/audio/transcriptions", {
method: "POST",
headers: { Authorization: "Bearer " + g.key },
body: form,
});
const text = await res.text().catch(() => "");
const notFound = res.status === 404 || /404 page not found|not found/i.test(text.slice(0, 60));
out.push({
name: g.name,
baseUrl: base,
status: res.status,
supported: !notFound,
note: text.slice(0, 120).replace(/\s+/g, " "),
});
} catch (e) {
out.push({ name: g.name, baseUrl: base, status: 0, supported: false, note: String((e && e.message) || e).slice(0, 120) });
}
}
return out;
}
// —— Token 用量读取 / 重置(通过 CDP Runtime.evaluate 调注入脚本的 window API)——
async function tokenStatus() {
if (!session) return { date: "", total: 0 };
try {
const r = await session.send("Runtime.evaluate", {
expression: "window.__tdTokenGet ? window.__tdTokenGet() : JSON.stringify({date:'',total:0})",
awaitPromise: false,
});
const raw = (r && r.result && r.result.value) || "";
const parsed = (() => { try { return JSON.parse(raw); } catch { return { date: "", total: 0 }; } })();
return {
date: parsed.date || "",
total: Number(parsed.total) || 0,
input: Number(parsed.input) || 0,
output: Number(parsed.output) || 0,
cached: Number(parsed.cached) || 0,
estimate: Number(parsed.estimate || parsed.estimated) || 0,
unknown: Number(parsed.unknown) || 0,
lastSource: parsed.lastSource || parsed.source || "",
network: { ...tokenNet },
};
} catch {
return { date: "", total: 0 };
}
}
async function tokenSet(usage) {
if (!session) return false;
try {
await session.send("Runtime.evaluate", {
expression: "window.__tdTokenSet && window.__tdTokenSet(" + JSON.stringify(usage || {}) + ")",
awaitPromise: false,
});
return true;
} catch {
return false;
}
}
async function tokenReset() {
if (!session) return false;
try {
await session.send("Runtime.evaluate", {
expression: "window.__tdTokenReset ? window.__tdTokenReset() : 'no'",
awaitPromise: false,
});
return true;
} catch {
return false;
}
}
module.exports = {
status,
connect,
disconnect,
onStatus,
sttInfo,
probeStt,
tokenStatus,
tokenSet,
tokenReset,
// 供临时实测脚本复用
_listTargets: listTargets,
_pickTarget: pickTarget,
_openSession: openSession,
_inject: inject,
DEFAULT_PORT,
};