// 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 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, };