fix: Codex 自动压缩不触发 —— 接管时链接模型目录(绝对路径)进 config.toml (hot 1.5.83)
This commit is contained in:
+430
-93
@@ -1,20 +1,21 @@
|
||||
// voice_cdp.js —— Codex 语音输入注入:主进程侧。
|
||||
// 用 CDP(Chrome DevTools Protocol)连接带 --remote-debugging-port 的 Codex 桌面版,
|
||||
// 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 { spawn, execFile } = require("child_process");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
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 providerSync = require("./provider_sync");
|
||||
const codexDesktop = require("./codex_desktop");
|
||||
const { INJECT_SCRIPT } = require("./voice_inject");
|
||||
const { TOKEN_INJECT_SCRIPT } = require("./token_inject");
|
||||
const tokenUsage = require("./token_usage");
|
||||
@@ -25,28 +26,39 @@ const CDP_HTTP_TIMEOUT = 3000; // GET /json 超时
|
||||
const CDP_WS_TIMEOUT = 5000; // WebSocket 握手超时
|
||||
const CDP_CMD_TIMEOUT = 5000; // 单条 CDP 命令超时
|
||||
const READY_POLL_INTERVAL = 500; // 轮询就绪间隔
|
||||
const READY_POLL_MAX_MS = 20000; // 轮询就绪最长等待
|
||||
const 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 / connecting /
|
||||
// injecting / connected / disconnected / error
|
||||
// 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, lastError, model, target } = state;
|
||||
return { phase, port, codexPath, lastError, model, target };
|
||||
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 };
|
||||
@@ -59,7 +71,7 @@ function setPhase(phase, extra = {}) {
|
||||
|
||||
async function syncTokenUsage() {
|
||||
if (!session) return;
|
||||
const usage = tokenUsage.readDaily();
|
||||
const usage = await tokenUsage.readDaily();
|
||||
if (!usage || !usage.available) return;
|
||||
try {
|
||||
await session.send("Runtime.evaluate", {
|
||||
@@ -81,36 +93,290 @@ function stopTokenSync() {
|
||||
}
|
||||
|
||||
// —— 探测 Codex 可执行路径 ——
|
||||
function codexExecutable() {
|
||||
const home = os.homedir();
|
||||
if (process.platform === "darwin") {
|
||||
const cands = [
|
||||
path.join(home, "Applications/ChatGPT.app/Contents/MacOS/ChatGPT"),
|
||||
"/Applications/ChatGPT.app/Contents/MacOS/ChatGPT",
|
||||
"/Applications/Codex.app/Contents/MacOS/Codex",
|
||||
];
|
||||
for (const p of cands) {
|
||||
try {
|
||||
if (require("fs").existsSync(p)) return p;
|
||||
} catch {}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
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") {
|
||||
const local = process.env.LOCALAPPDATA || "";
|
||||
const pf = process.env["ProgramFiles"] || "C:\\Program Files";
|
||||
const cands = [
|
||||
local && path.join(local, "Programs\\ChatGPT\\ChatGPT.exe"),
|
||||
path.join(pf, "ChatGPT\\ChatGPT.exe"),
|
||||
].filter(Boolean);
|
||||
for (const p of cands) {
|
||||
try {
|
||||
if (require("fs").existsSync(p)) return p;
|
||||
} catch {}
|
||||
}
|
||||
return "";
|
||||
return "未找到可直接启动的 ChatGPT/Codex 桌面程序。" +
|
||||
(summary ? "扫描结果:" + summary + "。" : "") +
|
||||
(hasPackaged ? "检测到 WindowsApps/Store 受保护入口,但它不能直接附加调试端口。" : "") +
|
||||
"请安装官网下载的桌面版后重试。";
|
||||
}
|
||||
return "";
|
||||
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 ——
|
||||
@@ -427,7 +693,22 @@ async function handleBinding(params) {
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!payload || payload.kind !== "transcribe") 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");
|
||||
@@ -456,6 +737,11 @@ function connectedPhase() {
|
||||
|
||||
// —— 注入: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 });
|
||||
@@ -463,11 +749,12 @@ async function inject(sessionObj) {
|
||||
await sessionObj.send("Runtime.addBinding", { name: BINDING_NAME });
|
||||
await sessionObj.send("Page.enable");
|
||||
// 语音注入脚本
|
||||
await sessionObj.send("Page.addScriptToEvaluateOnNewDocument", { source: INJECT_SCRIPT });
|
||||
await sessionObj.send("Runtime.evaluate", { expression: INJECT_SCRIPT, awaitPromise: false });
|
||||
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: TOKEN_INJECT_SCRIPT });
|
||||
await sessionObj.send("Runtime.evaluate", { expression: TOKEN_INJECT_SCRIPT, awaitPromise: false });
|
||||
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");
|
||||
}
|
||||
@@ -732,18 +1019,19 @@ async function connectToTarget(port) {
|
||||
closeNetworkSessions();
|
||||
state.target = { id: tgt.id, url: tgt.url, title: tgt.title };
|
||||
setPhase("connecting");
|
||||
session = await openSession(tgt.webSocketDebuggerUrl, handleBinding);
|
||||
session.on("Runtime.bindingCalled", handleBinding);
|
||||
const ownedSession = await openSession(tgt.webSocketDebuggerUrl, handleBinding);
|
||||
session = ownedSession;
|
||||
// Token 用量:Network 层拦截 LLM 响应
|
||||
bindNetworkHandlers(session);
|
||||
session.ws.on("close", () => {
|
||||
bindNetworkHandlers(ownedSession);
|
||||
ownedSession.ws.on("close", () => {
|
||||
if (session !== ownedSession) return;
|
||||
stopTokenSync();
|
||||
closeNetworkSessions();
|
||||
session = null;
|
||||
setPhase("disconnected");
|
||||
});
|
||||
setPhase("injecting");
|
||||
await inject(session);
|
||||
await inject(ownedSession);
|
||||
startTokenSync();
|
||||
setPhase("connected", { target: state.target });
|
||||
attachWorkerNetworkTargets(port, tgt.id).catch((e) => {
|
||||
@@ -755,44 +1043,66 @@ async function connectToTarget(port) {
|
||||
// —— 轮询直到出现 page target(重启后用)——
|
||||
async function waitForReady(port) {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < READY_POLL_MAX_MS) {
|
||||
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;
|
||||
}
|
||||
|
||||
// —— 启动接管:杀当前 Codex → 带端口重启 ——
|
||||
function killCodex() {
|
||||
return new Promise((resolve) => {
|
||||
if (process.platform === "darwin") {
|
||||
execFile("osascript", ["-e", 'tell application "ChatGPT" to quit'], () => {
|
||||
// 兜底:osascript 没杀掉就 pkill
|
||||
execFile("pgrep", ["-f", "ChatGPT.app/Contents/MacOS/ChatGPT"], (_e, out) => {
|
||||
if (out && out.trim()) {
|
||||
execFile("pkill", ["-f", "ChatGPT.app/Contents/MacOS/ChatGPT"], () =>
|
||||
setTimeout(resolve, 800)
|
||||
);
|
||||
} else resolve();
|
||||
});
|
||||
});
|
||||
} else if (process.platform === "win32") {
|
||||
execFile("taskkill", ["/IM", "ChatGPT.exe", "/F"], () =>
|
||||
setTimeout(resolve, 800)
|
||||
);
|
||||
} else resolve();
|
||||
});
|
||||
}
|
||||
function spawnCodex(exePath, port) {
|
||||
const child = spawn(exePath, [`--remote-debugging-port=${port}`], {
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
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));
|
||||
});
|
||||
try {
|
||||
child.unref();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// —— 对外:status / connect / disconnect ——
|
||||
@@ -802,31 +1112,39 @@ function status() {
|
||||
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");
|
||||
setPhase("checking", { lastError: "", launchWarning: "", portDiagnostic: "" });
|
||||
const exe = scanCodexExecutable();
|
||||
// 1. 端口已开?
|
||||
const ok = await connectToTarget(port);
|
||||
if (ok) return snapshot();
|
||||
// 2. 判断 Codex 是否在跑
|
||||
const running = providerSync.codexRunning ? providerSync.codexRunning() : false;
|
||||
const running = codexDesktop.codexRunning();
|
||||
if (!running && !opts.forceRestart) {
|
||||
// 没在跑也没开端口:直接尝试拉起带端口的实例(无需杀进程)
|
||||
const exe = codexExecutable();
|
||||
if (!exe) {
|
||||
setPhase("error", { lastError: "未找到 Codex 可执行文件" });
|
||||
setPhase("error", { lastError: desktopNotFoundError(), discovery: state.discovery });
|
||||
return snapshot();
|
||||
}
|
||||
state.codexPath = exe;
|
||||
setPhase("restarting", { codexPath: exe });
|
||||
spawnCodex(exe, port);
|
||||
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) {
|
||||
setPhase("error", { lastError: "Codex 启动后未开放调试端口,请重试" });
|
||||
setDebugPortFailure("启动", port);
|
||||
return snapshot();
|
||||
}
|
||||
const ok2 = await connectToTarget(port);
|
||||
@@ -835,23 +1153,27 @@ async function connect(cb, opts = {}) {
|
||||
}
|
||||
// 3. 在跑但没端口:需用户在前端确认后 forceRestart 重启
|
||||
if (running && !opts.forceRestart) {
|
||||
state.codexPath = codexExecutable();
|
||||
setPhase("running_no_port", { codexPath: state.codexPath });
|
||||
return snapshot(); // 前端据此弹"重启确认"
|
||||
}
|
||||
// 4. forceRestart:杀进程 → 重启
|
||||
const exe = codexExecutable();
|
||||
if (!exe) {
|
||||
setPhase("error", { lastError: "未找到 Codex 可执行文件" });
|
||||
setPhase("error", { lastError: desktopNotFoundError(), discovery: state.discovery });
|
||||
return snapshot();
|
||||
}
|
||||
state.codexPath = exe;
|
||||
setPhase("restarting");
|
||||
await killCodex();
|
||||
spawnCodex(exe, port);
|
||||
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) {
|
||||
setPhase("error", { lastError: "重启 Codex 后未开放调试端口" });
|
||||
setDebugPortFailure("重启", port);
|
||||
return snapshot();
|
||||
}
|
||||
const ok3 = await connectToTarget(port);
|
||||
@@ -859,14 +1181,28 @@ async function connect(cb, opts = {}) {
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
async function disconnect() {
|
||||
stopTokenSync();
|
||||
closeNetworkSessions();
|
||||
if (session) {
|
||||
const ownedSession = session;
|
||||
// 先让页面停止录音并删除已挂载按钮,再关闭 CDP;关闭后无法清理前台 DOM。
|
||||
try {
|
||||
session.close();
|
||||
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();
|
||||
@@ -973,6 +1309,7 @@ module.exports = {
|
||||
connect,
|
||||
disconnect,
|
||||
onStatus,
|
||||
onSessionDelete,
|
||||
sttInfo,
|
||||
probeStt,
|
||||
tokenStatus,
|
||||
|
||||
Reference in New Issue
Block a user