2026-08-16 12:45:32 +08:00
// voice_cdp.js —— ChatGPT/Codex 语音输入注入:主进程侧。
// 用 CDP( Chrome DevTools Protocol)连接带 --remote-debugging-port 的 ChatGPT/Codex 桌面版,
2026-07-16 19:16:45 +08:00
// 往其页面注入麦克风按钮脚本,录音经 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。
2026-08-16 12:45:32 +08:00
const { execFile , spawn } = require ( "child_process" );
const fs = require ( "fs" );
2026-07-16 19:16:45 +08:00
const net = require ( "net" );
const crypto = require ( "crypto" );
2026-08-16 12:45:32 +08:00
const os = require ( "os" );
const path = require ( "path" );
2026-07-16 19:16:45 +08:00
function dlog (... a ) { try { console . log ( "[voice]" , ... a ); } catch {} }
const router = require ( "./router" );
2026-08-16 12:45:32 +08:00
const codexDesktop = require ( "./codex_desktop" );
2026-07-16 19:16:45 +08:00
const { INJECT_SCRIPT } = require ( "./voice_inject" );
2026-07-20 08:48:56 +08:00
const { TOKEN_INJECT_SCRIPT } = require ( "./token_inject" );
const tokenUsage = require ( "./token_usage" );
2026-07-16 19:16:45 +08:00
const DEFAULT_PORT = 19222 ; // Codex 调试端口(避开 Chrome 默认 9222)
const BINDING_NAME = "__voiceBridge" ; // 页面 → 主进程 的 CDP binding
const CDP_HTTP_TIMEOUT = 3000 ; // GET /json 超时
2026-07-20 08:48:56 +08:00
const CDP_WS_TIMEOUT = 5000 ; // WebSocket 握手超时
const CDP_CMD_TIMEOUT = 5000 ; // 单条 CDP 命令超时
2026-07-16 19:16:45 +08:00
const READY_POLL_INTERVAL = 500 ; // 轮询就绪间隔
2026-08-16 12:45:32 +08:00
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 ;
2026-07-16 19:16:45 +08:00
const RECORD_MAX_MS = 60000 ; // 单次录音上限(注入脚本侧也限)
// —— 状态机 ——
2026-08-16 12:45:32 +08:00
// unknown / checking / not_running / running_no_port / restarting / waiting_debug_port /
// unsupported_packaged / connecting / injecting / connected / disconnected / error
2026-07-16 19:16:45 +08:00
let state = {
phase : "unknown" ,
port : DEFAULT_PORT ,
codexPath : "" ,
2026-08-16 12:45:32 +08:00
codexPackaged : false ,
codexLaunchType : "exe" ,
codexAppId : "" ,
launchWarning : "" ,
portDiagnostic : "" ,
2026-07-16 19:16:45 +08:00
lastError : "" ,
2026-08-16 12:45:32 +08:00
discovery : null ,
2026-07-16 19:16:45 +08:00
target : null , // { id, url, title }
};
let session = null ; // 当前 CdpSession
2026-08-16 12:45:32 +08:00
let voiceScriptIdentifier = "" ; // 用于断开时撤销后续页面的语音自动注入
2026-07-20 08:48:56 +08:00
let networkSessions = []; // worker 等附加 target 的 Network 监听 session
let networkAttachGeneration = 0 ;
2026-07-16 19:16:45 +08:00
let onStatusCb = null ;
2026-08-16 12:45:32 +08:00
let onSessionDeleteCb = null ;
2026-07-20 08:48:56 +08:00
let tokenSyncTimer = null ;
2026-07-16 19:16:45 +08:00
function snapshot () {
2026-08-16 12:45:32 +08:00
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 };
2026-07-16 19:16:45 +08:00
}
function setPhase ( phase , extra = {}) {
state = { ... state , phase , ... extra };
if ( onStatusCb ) {
try {
onStatusCb ( snapshot ());
} catch {}
}
}
2026-07-20 08:48:56 +08:00
async function syncTokenUsage () {
if ( ! session ) return ;
2026-08-16 12:45:32 +08:00
const usage = await tokenUsage . readDaily ();
2026-07-20 08:48:56 +08:00
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 ;
}
2026-07-16 19:16:45 +08:00
// —— 探测 Codex 可执行路径 ——
2026-08-16 12:45:32 +08:00
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 ;
2026-07-16 19:16:45 +08:00
}
2026-08-16 12:45:32 +08:00
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 ;
2026-07-16 19:16:45 +08:00
}
2026-08-16 12:45:32 +08:00
}
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 ;
2026-07-16 19:16:45 +08:00
try {
2026-08-16 12:45:32 +08:00
data = JSON . parse ( String ( stdout || "" ). trim ());
2026-07-16 19:16:45 +08:00
} catch {}
2026-08-16 12:45:32 +08:00
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 ;
2026-07-16 19:16:45 +08:00
}
2026-08-16 12:45:32 +08:00
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 );
});
});
2026-07-16 19:16:45 +08:00
}
// —— 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 ));
2026-07-20 08:48:56 +08:00
socket . setTimeout ( CDP_WS_TIMEOUT , () => {
if ( ! handshakeDone ) emit ( "error" , new Error ( "WS 连接超时" ));
try { socket . destroy (); } catch {}
});
2026-07-16 19:16:45 +08:00
// —— 发送一帧文本(客户端必须掩码)——
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 ;
2026-07-20 08:48:56 +08:00
try { socket . setTimeout ( 0 ); } catch {}
2026-07-16 19:16:45 +08:00
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 ++ ;
2026-07-20 08:48:56 +08:00
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 );
},
});
2026-07-16 19:16:45 +08:00
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 ;
}
2026-08-16 12:45:32 +08:00
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 ;
2026-07-16 19:16:45 +08:00
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" ;
}
2026-07-20 08:48:56 +08:00
// —— 注入:addBinding + addScriptToEvaluateOnNewDocument + 立即 evaluate + Network 监听 ——
2026-07-16 19:16:45 +08:00
async function inject ( sessionObj ) {
2026-08-16 12:45:32 +08:00
// 清除 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" );
2026-07-16 19:16:45 +08:00
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" );
2026-07-20 08:48:56 +08:00
// 语音注入脚本
2026-08-16 12:45:32 +08:00
const voiceScript = await sessionObj . send ( "Page.addScriptToEvaluateOnNewDocument" , { source : FRESH_INJECT });
voiceScriptIdentifier = ( voiceScript && voiceScript . identifier ) || "" ;
await sessionObj . send ( "Runtime.evaluate" , { expression : FRESH_INJECT , awaitPromise : false });
2026-07-20 08:48:56 +08:00
// Token 用量 UI 注入脚本(只负责 UI + localStorage,不管网络抓取)
2026-08-16 12:45:32 +08:00
await sessionObj . send ( "Page.addScriptToEvaluateOnNewDocument" , { source : FRESH_TOKEN });
await sessionObj . send ( "Runtime.evaluate" , { expression : FRESH_TOKEN , awaitPromise : false });
2026-07-20 08:48:56 +08:00
// 启用 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 ();
2026-07-16 19:16:45 +08:00
}
// —— 连接到已带端口的 Codex ——
async function connectToTarget ( port ) {
const targets = await listTargets ( port );
const tgt = pickTarget ( targets );
if ( ! tgt ) return false ;
2026-07-20 08:48:56 +08:00
closeNetworkSessions ();
2026-07-16 19:16:45 +08:00
state . target = { id : tgt . id , url : tgt . url , title : tgt . title };
setPhase ( "connecting" );
2026-08-16 12:45:32 +08:00
const ownedSession = await openSession ( tgt . webSocketDebuggerUrl , handleBinding );
session = ownedSession ;
2026-07-20 08:48:56 +08:00
// Token 用量:Network 层拦截 LLM 响应
2026-08-16 12:45:32 +08:00
bindNetworkHandlers ( ownedSession );
ownedSession . ws . on ( "close" , () => {
if ( session !== ownedSession ) return ;
2026-07-20 08:48:56 +08:00
stopTokenSync ();
closeNetworkSessions ();
2026-07-16 19:16:45 +08:00
session = null ;
setPhase ( "disconnected" );
});
setPhase ( "injecting" );
2026-08-16 12:45:32 +08:00
await inject ( ownedSession );
2026-07-20 08:48:56 +08:00
startTokenSync ();
2026-07-16 19:16:45 +08:00
setPhase ( "connected" , { target : state . target });
2026-07-20 08:48:56 +08:00
attachWorkerNetworkTargets ( port , tgt . id ). catch (( e ) => {
tokenNet . lastError = String (( e && e . message ) || e || "attach workers failed" ). slice ( 0 , 160 );
});
2026-07-16 19:16:45 +08:00
return true ;
}
// —— 轮询直到出现 page target(重启后用)——
async function waitForReady ( port ) {
const t0 = Date . now ();
2026-08-16 12:45:32 +08:00
const maxMs = state . codexPackaged ? PACKAGED_READY_POLL_MAX_MS : READY_POLL_MAX_MS ;
let diagnosed = false ;
while ( Date . now () - t0 < maxMs ) {
2026-07-16 19:16:45 +08:00
const targets = await listTargets ( port );
const tgt = pickTarget ( targets );
if ( tgt ) return tgt ;
2026-08-16 12:45:32 +08:00
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 ;
}
}
2026-07-16 19:16:45 +08:00
await new Promise (( r ) => setTimeout ( r , READY_POLL_INTERVAL ));
}
2026-08-16 12:45:32 +08:00
updateWindowsProcessDiagnostic ( port );
2026-07-16 19:16:45 +08:00
return null ;
}
function spawnCodex ( exePath , port ) {
2026-08-16 12:45:32 +08:00
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 ));
2026-07-16 19:16:45 +08:00
});
}
// —— 对外:status / connect / disconnect ——
function status () {
return snapshot ();
}
function onStatus ( cb ) {
onStatusCb = cb ;
}
2026-08-16 12:45:32 +08:00
function onSessionDelete ( cb ) {
onSessionDeleteCb = cb ;
}
2026-07-16 19:16:45 +08:00
// connect(onStatusCb):流程见计划。forceRestart=true 时直接重启(前端已确认)。
async function connect ( cb , opts = {}) {
if ( cb ) onStatusCb = cb ;
const port = opts . port || DEFAULT_PORT ;
state . port = port ;
2026-08-16 12:45:32 +08:00
setPhase ( "checking" , { lastError : "" , launchWarning : "" , portDiagnostic : "" });
const exe = scanCodexExecutable ();
2026-07-16 19:16:45 +08:00
// 1. 端口已开?
const ok = await connectToTarget ( port );
if ( ok ) return snapshot ();
// 2. 判断 Codex 是否在跑
2026-08-16 12:45:32 +08:00
const running = codexDesktop . codexRunning ();
2026-07-16 19:16:45 +08:00
if ( ! running && ! opts . forceRestart ) {
// 没在跑也没开端口:直接尝试拉起带端口的实例(无需杀进程)
if ( ! exe ) {
2026-08-16 12:45:32 +08:00
setPhase ( "error" , { lastError : desktopNotFoundError (), discovery : state . discovery });
2026-07-16 19:16:45 +08:00
return snapshot ();
}
setPhase ( "restarting" , { codexPath : exe });
2026-08-16 12:45:32 +08:00
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 : "" });
2026-07-16 19:16:45 +08:00
const tgt = await waitForReady ( port );
if ( ! tgt ) {
2026-08-16 12:45:32 +08:00
setDebugPortFailure ( "启动" , port );
2026-07-16 19:16:45 +08:00
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 ) {
2026-08-16 12:45:32 +08:00
setPhase ( "error" , { lastError : desktopNotFoundError (), discovery : state . discovery });
2026-07-16 19:16:45 +08:00
return snapshot ();
}
state . codexPath = exe ;
setPhase ( "restarting" );
2026-08-16 12:45:32 +08:00
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 : "" });
2026-07-16 19:16:45 +08:00
const tgt = await waitForReady ( port );
if ( ! tgt ) {
2026-08-16 12:45:32 +08:00
setDebugPortFailure ( "重启" , port );
2026-07-16 19:16:45 +08:00
return snapshot ();
}
const ok3 = await connectToTarget ( port );
if ( ! ok3 ) setPhase ( "error" , { lastError : "已启动但未找到可注入页面" });
return snapshot ();
}
2026-08-16 12:45:32 +08:00
async function disconnect () {
2026-07-20 08:48:56 +08:00
stopTokenSync ();
closeNetworkSessions ();
2026-07-16 19:16:45 +08:00
if ( session ) {
2026-08-16 12:45:32 +08:00
const ownedSession = session ;
// 先让页面停止录音并删除已挂载按钮,再关闭 CDP;关闭后无法清理前台 DOM。
2026-07-16 19:16:45 +08:00
try {
2026-08-16 12:45:32 +08:00
await ownedSession . send ( "Runtime.evaluate" , {
expression : "window.__voiceInjectDispose && window.__voiceInjectDispose()" ,
awaitPromise : false ,
});
2026-07-16 19:16:45 +08:00
} catch {}
2026-08-16 12:45:32 +08:00
if ( voiceScriptIdentifier ) {
try {
await ownedSession . send ( "Page.removeScriptToEvaluateOnNewDocument" , { identifier : voiceScriptIdentifier });
} catch {}
voiceScriptIdentifier = "" ;
}
2026-07-16 19:16:45 +08:00
session = null ;
2026-08-16 12:45:32 +08:00
try {
ownedSession . close ();
} catch {}
2026-07-16 19:16:45 +08:00
}
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 ;
}
2026-07-20 08:48:56 +08:00
// —— 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 ;
}
}
2026-07-16 19:16:45 +08:00
module . exports = {
status ,
connect ,
disconnect ,
onStatus ,
2026-08-16 12:45:32 +08:00
onSessionDelete ,
2026-07-16 19:16:45 +08:00
sttInfo ,
probeStt ,
2026-07-20 08:48:56 +08:00
tokenStatus ,
tokenSet ,
tokenReset ,
2026-07-16 19:16:45 +08:00
// 供临时实测脚本复用
_listTargets : listTargets ,
_pickTarget : pickTarget ,
_openSession : openSession ,
_inject : inject ,
DEFAULT_PORT ,
};