1325 lines
48 KiB
JavaScript
1325 lines
48 KiB
JavaScript
// 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 { 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<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) 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);
|
||
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;
|
||
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();
|
||
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");
|
||
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,
|
||
};
|