// voice_cdp.js —— ChatGPT/Codex 语音输入注入:主进程侧。 // 用 CDP(Chrome DevTools Protocol)连接带 --remote-debugging-port 的 ChatGPT/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 { execFile, spawn } = require("child_process"); const fs = require("fs"); const net = require("net"); const crypto = require("crypto"); const os = require("os"); const path = require("path"); function dlog(...a) { try { console.log("[voice]", ...a); } catch {} } const router = require("./router"); const codexDesktop = require("./codex_desktop"); const globalDictation = require("./global_dictation"); 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 = 180000; // 轮询就绪最长等待:Windows 冷启动可能要 2~3 分钟 const PACKAGED_READY_POLL_MAX_MS = 180000; // Store/MSIX 版 AppID 启动后也要给足白屏加载时间 const WINDOWS_LAUNCH_TIMEOUT_MS = 4500; const WINDOWS_APPID_ACTIVATE_TIMEOUT_MS = 10000; const RECORD_MAX_MS = 60000; // 单次录音上限(注入脚本侧也限) // —— 状态机 —— // unknown / checking / not_running / running_no_port / restarting / waiting_debug_port / // unsupported_packaged / connecting / injecting / connected / disconnected / error let state = { phase: "unknown", port: DEFAULT_PORT, codexPath: "", codexPackaged: false, codexLaunchType: "exe", codexAppId: "", launchWarning: "", portDiagnostic: "", lastError: "", discovery: null, target: null, // { id, url, title } }; let session = null; // 当前 CdpSession let voiceScriptIdentifier = ""; // 用于断开时撤销后续页面的语音自动注入 let networkSessions = []; // worker 等附加 target 的 Network 监听 session let networkAttachGeneration = 0; let onStatusCb = null; let onSessionDeleteCb = null; let tokenSyncTimer = null; function snapshot() { const { phase, port, codexPath, codexPackaged, codexLaunchType, codexAppId, launchWarning, portDiagnostic, lastError, model, target, discovery } = state; return { phase, port, codexPath, codexPackaged, codexLaunchType, codexAppId, launchWarning, portDiagnostic, lastError, model, target, discovery }; } function setPhase(phase, extra = {}) { state = { ...state, phase, ...extra }; if (onStatusCb) { try { onStatusCb(snapshot()); } catch {} } } async function syncTokenUsage() { if (!session) return; const usage = await 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 scanCodexExecutable() { const candidates = codexDesktop.desktopExecutableCandidates(); const candidate = candidates.find((item) => item && item.path && !item.packaged) || candidates.find((item) => item && item.path && item.packaged); const exe = candidate && candidate.path ? candidate.path : ""; state.codexPath = exe; state.codexPackaged = !!(candidate && candidate.packaged); state.codexLaunchType = (candidate && candidate.launchType) || "exe"; state.codexAppId = (candidate && candidate.appId) || ""; state.discovery = codexDesktop.discoveryReport ? codexDesktop.discoveryReport() : null; return exe; } function desktopNotFoundError() { const summary = codexDesktop.discoverySummary ? codexDesktop.discoverySummary() : ""; const discovery = state.discovery || {}; const hasPackaged = Array.isArray(discovery.candidates) && discovery.candidates.some((item) => item && item.packaged); if (process.platform === "win32") { return "未找到可直接启动的 ChatGPT/Codex 桌面程序。" + (summary ? "扫描结果:" + summary + "。" : "") + (hasPackaged ? "检测到 WindowsApps/Store 受保护入口,但它不能直接附加调试端口。" : "") + "请安装官网下载的桌面版后重试。"; } return "未找到 ChatGPT/Codex 桌面程序。" + (summary ? "扫描结果:" + summary + "。" : ""); } function desktopLaunchError(exePath, err) { const msg = String((err && err.message) || err || ""); if (process.platform === "win32" && /eperm/i.test(msg)) { return "Windows 拒绝启动 ChatGPT/Codex 调试模式(EPERM)。路径:" + exePath + "。如果这是 Microsoft Store/WindowsApps 版本,请改装官网下载的桌面版;如果是普通 exe,请用管理员权限启动工具箱后再试。"; } return "启动 ChatGPT/Codex 失败:" + msg + (exePath ? "。路径:" + exePath : ""); } function compactError(err, fallback = "未知错误") { const parts = [ err && err.stderr, err && err.stdout, err && err.message, err, ].filter(Boolean).map((v) => String(v)); const raw = parts.find((v) => v.trim()) || fallback; return raw .replace(/Command failed:[\s\S]*?(?=(\r?\n|$))/i, "") .replace(/\s+/g, " ") .trim() .slice(0, 220) || fallback; } function compactCommandLine(cmd) { const text = String(cmd || "").replace(/\s+/g, " ").trim(); if (!text) return ""; const hasWindowsApps = /WindowsApps/i.test(text); const hasDebugArg = /--remote-debugging-port/i.test(text); const exe = (text.match(/(?:^|[ "])([A-Z]:\\[^"]*?(?:ChatGPT|Codex)[^"\\]*\.exe)/i) || [])[1] || ""; const pidPart = exe ? exe.replace(/^.*[\\/]/, "") : ""; const tags = []; if (hasWindowsApps) tags.push("WindowsApps"); tags.push(hasDebugArg ? "含调试参数" : "无调试参数"); return [pidPart, tags.join("/")].filter(Boolean).join(" "); } function updateWindowsProcessDiagnostic(port) { if (process.platform !== "win32" || !codexDesktop.windowsProcessDiagnostics) return null; try { const d = codexDesktop.windowsProcessDiagnostics(port); if (!d || !Array.isArray(d.rows) || !d.rows.length) { state.portDiagnostic = "未检测到 ChatGPT/Codex 进程"; return d; } state.portDiagnostic = d.hasDebugArg ? "进程命令行已包含调试端口参数" : "进程命令行未包含 --remote-debugging-port=" + port + ",调试端口不会打开"; const rowsSummary = d.rows.map((row) => { const name = (row && row.Name) || "process"; const pid = (row && row.ProcessId) || "?"; const cmd = compactCommandLine((row && row.CommandLine) || ""); return `${name}#${pid}${cmd ? " " + cmd : ""}`; }).join(" | "); if (rowsSummary) state.portDiagnostic += ";" + rowsSummary; state.portDiagnostic = state.portDiagnostic.slice(0, 260); return d; } catch (e) { state.portDiagnostic = "读取进程命令行失败:" + compactError(e); return null; } } function debugPortFailureMessage(action, port) { const diag = updateWindowsProcessDiagnostic(port); if (state.codexPackaged) { const reason = diag && diag.rows && diag.rows.length && !diag.hasDebugArg ? "当前 Store/MSIX 启动参数没有进入 Codex 进程,无法打开调试端口。" : "当前 Store/MSIX 启动方式未开放调试端口。"; return "已尝试通过 Windows AppID " + action + " ChatGPT/Codex,但未检测到调试端口。" + reason + "建议改装官网下载桌面版。"; } return action === "重启" ? "重启 ChatGPT 后未开放调试端口" : "Codex 启动后未开放调试端口,请重试"; } function isPackagedDebugArgMissing(port) { if (!state.codexPackaged || process.platform !== "win32") return false; const d = updateWindowsProcessDiagnostic(port); return !!(d && Array.isArray(d.rows) && d.rows.length && !d.hasDebugArg); } function setDebugPortFailure(action, port) { const unsupported = isPackagedDebugArgMissing(port); setPhase(unsupported ? "unsupported_packaged" : "error", { lastError: debugPortFailureMessage(action, port) + (state.launchWarning ? "(" + state.launchWarning + ")" : ""), portDiagnostic: state.portDiagnostic, }); } function windowsLaunchArgs(port) { return [ `--remote-debugging-port=${port}`, `--remote-allow-origins=http://127.0.0.1:${port}`, ]; } function quoteWindowsArgument(arg) { const text = String(arg || ""); if (text && !/[ \t"]/.test(text)) return text; let out = "\""; let backslashes = 0; for (const ch of text) { if (ch === "\\") { backslashes += 1; continue; } if (ch === "\"") { out += "\\".repeat(backslashes * 2 + 1) + "\""; backslashes = 0; continue; } out += "\\".repeat(backslashes) + ch; backslashes = 0; } out += "\\".repeat(backslashes * 2) + "\""; return out; } function windowsCommandLineArguments(args) { return args.map(quoteWindowsArgument).join(" "); } function windowsActivateAppId(appId, port) { return new Promise((resolve, reject) => { if (process.platform !== "win32") { reject(new Error("AppID activation only supports Windows")); return; } const script = [ "$ErrorActionPreference='Stop'", "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8", "$OutputEncoding=[System.Text.Encoding]::UTF8", "$code = @'", "using System;", "using System.Runtime.InteropServices;", "namespace TdCodexAppActivation {", "[ComImport, Guid(\"45BA127D-10A8-46EA-8AB7-56EA9078943C\")]", "public class ApplicationActivationManager {}", "[ComImport, Guid(\"2e941141-7f97-4756-ba1d-9decde894a3d\"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]", "public interface IApplicationActivationManager {", " [PreserveSig]", " int ActivateApplication([MarshalAs(UnmanagedType.LPWStr)] string appUserModelId, [MarshalAs(UnmanagedType.LPWStr)] string arguments, int options, out uint processId);", " [PreserveSig]", " int ActivateForFile([MarshalAs(UnmanagedType.LPWStr)] string appUserModelId, IntPtr itemArray, [MarshalAs(UnmanagedType.LPWStr)] string verb, out uint processId);", " [PreserveSig]", " int ActivateForProtocol([MarshalAs(UnmanagedType.LPWStr)] string appUserModelId, IntPtr itemArray, out uint processId);", "}", "public static class Launcher {", " const int COINIT_APARTMENTTHREADED = 0x2;", " const int RPC_E_CHANGED_MODE = unchecked((int)0x80010106);", " [DllImport(\"ole32.dll\")] static extern int CoInitializeEx(IntPtr pvReserved, int dwCoInit);", " [DllImport(\"ole32.dll\")] static extern void CoUninitialize();", " public static uint Activate(string appUserModelId, string arguments) {", " int coinit = CoInitializeEx(IntPtr.Zero, COINIT_APARTMENTTHREADED);", " bool shouldUninitialize = coinit >= 0;", " if (coinit < 0 && coinit != RPC_E_CHANGED_MODE) Marshal.ThrowExceptionForHR(coinit);", " try {", " var manager = (IApplicationActivationManager)new ApplicationActivationManager();", " uint processId = 0;", " int hr = manager.ActivateApplication(appUserModelId, arguments, 0, out processId);", " if (hr != 0) throw new COMException(\"ActivateApplication failed\", hr);", " return processId;", " } finally {", " if (shouldUninitialize) CoUninitialize();", " }", " }", "}", "}", "'@", "Add-Type -TypeDefinition $code -ErrorAction Stop", "$activatedProcessId=[TdCodexAppActivation.Launcher]::Activate($env:TD_CODEX_APPID, $env:TD_CODEX_LAUNCH_ARGS)", "[pscustomobject]@{ok=$true;pid=$activatedProcessId;args=$env:TD_CODEX_LAUNCH_ARGS}|ConvertTo-Json -Compress", ].join("\n"); const scriptPath = path.join(os.tmpdir(), "td-codex-appid-activate-" + process.pid + "-" + Date.now() + ".ps1"); try { fs.writeFileSync(scriptPath, script, "utf8"); } catch (e) { reject(e); return; } execFile("powershell.exe", ["-NoProfile", "-Sta", "-ExecutionPolicy", "Bypass", "-File", scriptPath], { env: { ...process.env, TD_CODEX_APPID: String(appId || ""), TD_CODEX_LAUNCH_PORT: String(port), TD_CODEX_LAUNCH_ARGS: windowsCommandLineArguments(windowsLaunchArgs(port)), }, windowsHide: true, timeout: WINDOWS_APPID_ACTIVATE_TIMEOUT_MS, }, (err, stdout) => { try { fs.rmSync(scriptPath, { force: true }); } catch {} if (err) { reject(err); return; } let data = null; try { data = JSON.parse(String(stdout || "").trim()); } catch {} resolve(data || { ok: true }); }); }); } function windowsShellStart(exePath, port) { return new Promise((resolve, reject) => { if (process.platform !== "win32") { reject(new Error("shell start only supports Windows")); return; } const launchType = state.codexLaunchType || "exe"; const target = launchType === "appId" ? "shell:AppsFolder\\" + String(state.codexAppId || exePath || "").trim() : String(exePath || "").replace(/^\\\\\?\\/, ""); if (launchType === "appId") { windowsActivateAppId(state.codexAppId || exePath, port).then((data) => { const pid = data && data.pid ? `,PID ${data.pid}` : ""; state.launchWarning = "已通过 Windows AppID 激活 Store/MSIX 版 Codex" + pid; resolve(true); }).catch((activateErr) => { execFile("explorer.exe", [target], { windowsHide: true, timeout: WINDOWS_LAUNCH_TIMEOUT_MS }, (err) => { const first = "COM 激活提示:" + compactError(activateErr, "AppID 激活失败"); if (err) { // explorer.exe often returns a failure code for shell:AppsFolder // even after Windows has already handed off and launched the app. const second = "explorer 提示:" + compactError(err, "explorer 启动失败"); state.launchWarning = (first + ";" + second).slice(0, 220); resolve(false); return; } state.launchWarning = (first + ";已通过 explorer 兜底拉起 Store/MSIX 版 Codex").slice(0, 220); resolve(true); }); }); return; } const script = [ "$target=$env:TD_CODEX_LAUNCH_TARGET", "$args=@('--remote-debugging-port=' + $env:TD_CODEX_LAUNCH_PORT, '--remote-allow-origins=http://127.0.0.1:' + $env:TD_CODEX_LAUNCH_PORT)", "Start-Process -FilePath $target -ArgumentList $args", ].join(";"); execFile("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script], { env: { ...process.env, TD_CODEX_LAUNCH_TARGET: target, TD_CODEX_LAUNCH_PORT: String(port), }, windowsHide: true, timeout: WINDOWS_LAUNCH_TIMEOUT_MS, }, (err) => { if (err) { reject(err); return; } state.launchWarning = ""; resolve(true); }); }); } // —— 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) return; if ( payload.kind === "delete-session" || payload.kind === "delete-session-clipboard-prepare" || payload.kind === "delete-session-clipboard-read" ) { if (onSessionDeleteCb) { try { await onSessionDeleteCb(payload); } catch (e) { dlog("永久删除会话请求失败:", e && e.message); } } return; } if (payload.kind === "debug-sidebar-dump") { dlog("侧边栏诊断:", JSON.stringify(payload.info || {})); return; } if (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) { // 清除 require 缓存,使 voice_inject.js 的修改能在重连时生效,无需重启整个应用 delete require.cache[require.resolve("./voice_inject")]; delete require.cache[require.resolve("./token_inject")]; const { INJECT_SCRIPT: FRESH_INJECT } = require("./voice_inject"); const { TOKEN_INJECT_SCRIPT: FRESH_TOKEN } = require("./token_inject"); 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"); // 语音注入脚本 const voiceScript = await sessionObj.send("Page.addScriptToEvaluateOnNewDocument", { source: FRESH_INJECT }); voiceScriptIdentifier = (voiceScript && voiceScript.identifier) || ""; await sessionObj.send("Runtime.evaluate", { expression: FRESH_INJECT, awaitPromise: false }); // Token 用量 UI 注入脚本(只负责 UI + localStorage,不管网络抓取) await sessionObj.send("Page.addScriptToEvaluateOnNewDocument", { source: FRESH_TOKEN }); await sessionObj.send("Runtime.evaluate", { expression: FRESH_TOKEN, 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); // listTargets() keeps its legacy [] fallback for the voice state machine. // Let the cleanup module probe the endpoint itself so a failed connection // cannot be mistaken for an available endpoint with no targets. await globalDictation.closeGlobalDictationTargets(port); const tgt = pickTarget(targets); if (!tgt) return false; closeNetworkSessions(); state.target = { id: tgt.id, url: tgt.url, title: tgt.title }; setPhase("connecting"); const ownedSession = await openSession(tgt.webSocketDebuggerUrl, handleBinding); session = ownedSession; // Token 用量:Network 层拦截 LLM 响应 bindNetworkHandlers(ownedSession); ownedSession.ws.on("close", () => { if (session !== ownedSession) return; stopTokenSync(); closeNetworkSessions(); session = null; setPhase("disconnected"); }); setPhase("injecting"); await inject(ownedSession); 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(); const maxMs = state.codexPackaged ? PACKAGED_READY_POLL_MAX_MS : READY_POLL_MAX_MS; let diagnosed = false; while (Date.now() - t0 < maxMs) { const targets = await listTargets(port); const tgt = pickTarget(targets); if (tgt) return tgt; if (!diagnosed && process.platform === "win32" && Date.now() - t0 >= 5000) { diagnosed = true; const d = updateWindowsProcessDiagnostic(port); setPhase("waiting_debug_port", { portDiagnostic: state.portDiagnostic }); if (state.codexPackaged && d && Array.isArray(d.rows) && d.rows.length && !d.hasDebugArg) { return null; } } await new Promise((r) => setTimeout(r, READY_POLL_INTERVAL)); } updateWindowsProcessDiagnostic(port); return null; } function spawnCodex(exePath, port) { if (state.codexPackaged && process.platform === "win32") { return windowsShellStart(exePath, port); } return new Promise((resolve, reject) => { let child = null; try { const args = process.platform === "win32" ? windowsLaunchArgs(port) : [`--remote-debugging-port=${port}`]; child = spawn(exePath, args, { detached: true, stdio: "ignore", windowsHide: false, }); } catch (e) { reject(e); return; } state.launchWarning = ""; let settled = false; const finish = (err) => { if (settled) return; settled = true; if (err) { reject(err); return; } try { child.unref(); } catch {} resolve(true); }; child.once("error", (err) => { if (process.platform === "win32" && /eperm/i.test(String((err && err.message) || err || ""))) { windowsShellStart(exePath, port).then(() => finish(null)).catch(finish); return; } finish(err); }); child.once("spawn", () => finish(null)); }); } // —— 对外:status / connect / disconnect —— function status() { return snapshot(); } function onStatus(cb) { onStatusCb = cb; } function onSessionDelete(cb) { onSessionDeleteCb = cb; } // connect(onStatusCb):流程见计划。forceRestart=true 时直接重启(前端已确认)。 async function connect(cb, opts = {}) { if (cb) onStatusCb = cb; const port = opts.port || DEFAULT_PORT; globalDictation.disableKeepVisible(); state.port = port; setPhase("checking", { lastError: "", launchWarning: "", portDiagnostic: "" }); const exe = scanCodexExecutable(); // 1. 端口已开? const ok = await connectToTarget(port); if (ok) return snapshot(); // 2. 判断 Codex 是否在跑 const running = codexDesktop.codexRunning(); if (!running && !opts.forceRestart) { // 没在跑也没开端口:直接尝试拉起带端口的实例(无需杀进程) if (!exe) { setPhase("error", { lastError: desktopNotFoundError(), discovery: state.discovery }); return snapshot(); } setPhase("restarting", { codexPath: exe }); try { await spawnCodex(exe, port); } catch (e) { setPhase("error", { lastError: desktopLaunchError(exe, e), discovery: state.discovery }); return snapshot(); } setPhase("waiting_debug_port", { codexPath: exe, lastError: "" }); const tgt = await waitForReady(port); if (!tgt) { setDebugPortFailure("启动", port); return snapshot(); } const ok2 = await connectToTarget(port); if (!ok2) setPhase("error", { lastError: "已启动但未找到可注入页面" }); return snapshot(); } // 3. 在跑但没端口:需用户在前端确认后 forceRestart 重启 if (running && !opts.forceRestart) { setPhase("running_no_port", { codexPath: state.codexPath }); return snapshot(); // 前端据此弹"重启确认" } // 4. forceRestart:杀进程 → 重启 if (!exe) { setPhase("error", { lastError: desktopNotFoundError(), discovery: state.discovery }); return snapshot(); } state.codexPath = exe; setPhase("restarting"); await codexDesktop.killCodex(); try { await spawnCodex(exe, port); } catch (e) { setPhase("error", { lastError: desktopLaunchError(exe, e), discovery: state.discovery }); return snapshot(); } setPhase("waiting_debug_port", { codexPath: exe, lastError: "" }); const tgt = await waitForReady(port); if (!tgt) { setDebugPortFailure("重启", port); return snapshot(); } const ok3 = await connectToTarget(port); if (!ok3) setPhase("error", { lastError: "已启动但未找到可注入页面" }); return snapshot(); } async function disconnect() { stopTokenSync(); closeNetworkSessions(); globalDictation.disableKeepVisible(); if (session) { const ownedSession = session; // 先让页面停止录音并删除已挂载按钮,再关闭 CDP;关闭后无法清理前台 DOM。 try { await ownedSession.send("Runtime.evaluate", { expression: "window.__voiceInjectDispose && window.__voiceInjectDispose()", awaitPromise: false, }); } catch {} if (voiceScriptIdentifier) { try { await ownedSession.send("Page.removeScriptToEvaluateOnNewDocument", { identifier: voiceScriptIdentifier }); } catch {} voiceScriptIdentifier = ""; } session = null; try { ownedSession.close(); } catch {} } setPhase("disconnected"); await globalDictation.closeGlobalDictationTargets(state.port); 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, onSessionDelete, sttInfo, probeStt, tokenStatus, tokenSet, tokenReset, // 供临时实测脚本复用 _listTargets: listTargets, _pickTarget: pickTarget, _openSession: openSession, _inject: inject, DEFAULT_PORT, };