fix: canvas image/video download - fix data URL parsing in cvDownloadPreview (v1.5.21, hot 1.5.23)
This commit is contained in:
+372
-2
@@ -16,10 +16,14 @@ function dlog(...a) { try { console.log("[voice]", ...a); } catch {} }
|
||||
const router = require("./router");
|
||||
const providerSync = require("./provider_sync");
|
||||
const { INJECT_SCRIPT } = require("./voice_inject");
|
||||
const { TOKEN_INJECT_SCRIPT } = require("./token_inject");
|
||||
const tokenUsage = require("./token_usage");
|
||||
|
||||
const DEFAULT_PORT = 19222; // Codex 调试端口(避开 Chrome 默认 9222)
|
||||
const BINDING_NAME = "__voiceBridge"; // 页面 → 主进程 的 CDP binding
|
||||
const CDP_HTTP_TIMEOUT = 3000; // GET /json 超时
|
||||
const CDP_WS_TIMEOUT = 5000; // WebSocket 握手超时
|
||||
const CDP_CMD_TIMEOUT = 5000; // 单条 CDP 命令超时
|
||||
const READY_POLL_INTERVAL = 500; // 轮询就绪间隔
|
||||
const READY_POLL_MAX_MS = 20000; // 轮询就绪最长等待
|
||||
const RECORD_MAX_MS = 60000; // 单次录音上限(注入脚本侧也限)
|
||||
@@ -35,7 +39,10 @@ let state = {
|
||||
target: null, // { id, url, title }
|
||||
};
|
||||
let session = null; // 当前 CdpSession
|
||||
let networkSessions = []; // worker 等附加 target 的 Network 监听 session
|
||||
let networkAttachGeneration = 0;
|
||||
let onStatusCb = null;
|
||||
let tokenSyncTimer = null;
|
||||
|
||||
function snapshot() {
|
||||
const { phase, port, codexPath, lastError, model, target } = state;
|
||||
@@ -50,6 +57,29 @@ function setPhase(phase, extra = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function syncTokenUsage() {
|
||||
if (!session) return;
|
||||
const usage = tokenUsage.readDaily();
|
||||
if (!usage || !usage.available) return;
|
||||
try {
|
||||
await session.send("Runtime.evaluate", {
|
||||
expression: "window.__tdTokenSet && window.__tdTokenSet(" + JSON.stringify(usage) + ")",
|
||||
awaitPromise: false,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function startTokenSync() {
|
||||
if (tokenSyncTimer) clearInterval(tokenSyncTimer);
|
||||
tokenSyncTimer = setInterval(() => { syncTokenUsage().catch(() => {}); }, 2500);
|
||||
syncTokenUsage().catch(() => {});
|
||||
}
|
||||
|
||||
function stopTokenSync() {
|
||||
if (tokenSyncTimer) clearInterval(tokenSyncTimer);
|
||||
tokenSyncTimer = null;
|
||||
}
|
||||
|
||||
// —— 探测 Codex 可执行路径 ——
|
||||
function codexExecutable() {
|
||||
const home = os.homedir();
|
||||
@@ -139,6 +169,10 @@ function wsConnect(wsUrl) {
|
||||
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) {
|
||||
@@ -252,6 +286,7 @@ function wsConnect(wsUrl) {
|
||||
return;
|
||||
}
|
||||
handshakeDone = true;
|
||||
try { socket.setTimeout(0); } catch {}
|
||||
emit("open");
|
||||
parseFrames();
|
||||
} else {
|
||||
@@ -287,7 +322,20 @@ function openSession(wsUrl, onBindingCalled) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (closed) return reject(new Error("CDP 会话已关闭"));
|
||||
const id = nextId++;
|
||||
pending.set(id, { resolve, reject });
|
||||
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 }));
|
||||
});
|
||||
}
|
||||
@@ -406,7 +454,7 @@ function connectedPhase() {
|
||||
return session ? "connected" : "disconnected";
|
||||
}
|
||||
|
||||
// —— 注入:addBinding + addScriptToEvaluateOnNewDocument + 立即 evaluate ——
|
||||
// —— 注入:addBinding + addScriptToEvaluateOnNewDocument + 立即 evaluate + Network 监听 ——
|
||||
async function inject(sessionObj) {
|
||||
await sessionObj.send("Runtime.enable");
|
||||
try {
|
||||
@@ -414,8 +462,266 @@ async function inject(sessionObj) {
|
||||
} catch {}
|
||||
await sessionObj.send("Runtime.addBinding", { name: BINDING_NAME });
|
||||
await sessionObj.send("Page.enable");
|
||||
// 语音注入脚本
|
||||
await sessionObj.send("Page.addScriptToEvaluateOnNewDocument", { source: INJECT_SCRIPT });
|
||||
await sessionObj.send("Runtime.evaluate", { expression: INJECT_SCRIPT, awaitPromise: false });
|
||||
// Token 用量 UI 注入脚本(只负责 UI + localStorage,不管网络抓取)
|
||||
await sessionObj.send("Page.addScriptToEvaluateOnNewDocument", { source: TOKEN_INJECT_SCRIPT });
|
||||
await sessionObj.send("Runtime.evaluate", { expression: TOKEN_INJECT_SCRIPT, awaitPromise: false });
|
||||
// 启用 Network 域,在 CDP 层面拦截 LLM 响应抓 token 用量
|
||||
await sessionObj.send("Network.enable");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Token 用量:CDP Network 域拦截 LLM 响应抓 usage(不受页面 CSP 限制)
|
||||
// ============================================================================
|
||||
// 待完成的请求:requestId → { url, mimeType }
|
||||
const pendingNetworkReqs = new Map();
|
||||
let nextNetworkSessionId = 1;
|
||||
const tokenNet = {
|
||||
matchedCount: 0,
|
||||
usageCount: 0,
|
||||
noUsageCount: 0,
|
||||
lastMatchedUrl: "",
|
||||
lastUsageUrl: "",
|
||||
lastNoUsageUrl: "",
|
||||
lastUsageTotal: 0,
|
||||
lastError: "",
|
||||
attachedWorkers: 0,
|
||||
};
|
||||
const LLM_URL_RE = /\/(chat\/completions|v1\/messages|messages|generateContent|streamGenerateContent|completions|responses|backend-api\/(?:codex|conversation|responses)|codex|conversation)(\/|$|\?)/i;
|
||||
|
||||
function networkReqKey(ownerSession, requestId) {
|
||||
if (!ownerSession._tdTokenNetId) ownerSession._tdTokenNetId = nextNetworkSessionId++;
|
||||
return ownerSession._tdTokenNetId + ":" + requestId;
|
||||
}
|
||||
|
||||
function bindNetworkHandlers(ownerSession) {
|
||||
if (!ownerSession) return;
|
||||
if (!ownerSession._tdTokenNetId) ownerSession._tdTokenNetId = nextNetworkSessionId++;
|
||||
ownerSession.on("Network.responseReceived", (params) => handleNetworkResponse(params, ownerSession));
|
||||
ownerSession.on("Network.loadingFinished", (params) => handleNetworkLoadingFinished(params, ownerSession));
|
||||
ownerSession.on("Network.loadingFailed", (params) => handleNetworkLoadingFailed(params, ownerSession));
|
||||
}
|
||||
|
||||
function handleNetworkResponse(params, ownerSession) {
|
||||
const { requestId, response } = params || {};
|
||||
if (!requestId || !response || !ownerSession) return;
|
||||
const url = response.url || "";
|
||||
if (!LLM_URL_RE.test(url)) return;
|
||||
// 只处理 JSON 响应
|
||||
const mime = (response.mimeType || "").toLowerCase();
|
||||
if (!mime.includes("json") && !mime.includes("text")) return;
|
||||
tokenNet.matchedCount += 1;
|
||||
tokenNet.lastMatchedUrl = url;
|
||||
pendingNetworkReqs.set(networkReqKey(ownerSession, requestId), { url, mimeType: mime });
|
||||
}
|
||||
|
||||
function handleNetworkLoadingFailed(params, ownerSession) {
|
||||
const { requestId } = params || {};
|
||||
if (requestId && ownerSession) pendingNetworkReqs.delete(networkReqKey(ownerSession, requestId));
|
||||
}
|
||||
|
||||
async function handleNetworkLoadingFinished(params, ownerSession) {
|
||||
const { requestId } = params || {};
|
||||
if (!requestId || !ownerSession) return;
|
||||
const key = networkReqKey(ownerSession, requestId);
|
||||
if (!pendingNetworkReqs.has(key)) return;
|
||||
const reqInfo = pendingNetworkReqs.get(key) || {};
|
||||
pendingNetworkReqs.delete(key);
|
||||
if (!session || !ownerSession) return;
|
||||
try {
|
||||
const r = await ownerSession.send("Network.getResponseBody", { requestId });
|
||||
const body = (r && r.body) || "";
|
||||
if (!body || body.length < 10) return;
|
||||
// 可能被 base64 编码
|
||||
let text = body;
|
||||
if (r && r.base64Encoded) {
|
||||
try { text = Buffer.from(body, "base64").toString("utf8"); } catch { return; }
|
||||
}
|
||||
// 解析 JSON 提取 usage
|
||||
const usage = extractUsageFromText(text);
|
||||
if (usage && usage.total > 0) {
|
||||
tokenNet.usageCount += 1;
|
||||
tokenNet.lastUsageUrl = reqInfo.url || "";
|
||||
tokenNet.lastUsageTotal = usage.total || 0;
|
||||
tokenNet.lastError = "";
|
||||
dlog(
|
||||
"token: +" + usage.total +
|
||||
" input=" + (usage.input || 0) +
|
||||
" output=" + (usage.output || 0) +
|
||||
" cached=" + (usage.cached || 0) +
|
||||
" from",
|
||||
reqInfo.url || "?"
|
||||
);
|
||||
// 通过 Runtime.evaluate 调注入脚本更新 UI
|
||||
try {
|
||||
await session.send("Runtime.evaluate", {
|
||||
expression: "window.__tdTokenAdd && window.__tdTokenAdd(" + JSON.stringify(usage) + ", 'network')",
|
||||
awaitPromise: false,
|
||||
});
|
||||
} catch {}
|
||||
} else {
|
||||
tokenNet.noUsageCount += 1;
|
||||
tokenNet.lastNoUsageUrl = reqInfo.url || "";
|
||||
}
|
||||
} catch (e) {
|
||||
tokenNet.lastError = String((e && e.message) || e || "getResponseBody failed").slice(0, 160);
|
||||
// getResponseBody 可能失败(响应体已被清掉或太大),静默跳过
|
||||
}
|
||||
}
|
||||
|
||||
function extractUsageFromText(text) {
|
||||
let best = null;
|
||||
const take = (obj) => {
|
||||
best = betterUsage(best, extractUsageFromObject(obj));
|
||||
};
|
||||
try {
|
||||
take(JSON.parse(text));
|
||||
} catch {}
|
||||
if (usageTotal(best) > 0) return best;
|
||||
|
||||
// Stream/SSE: data: {"usage":...}\n\n or data: [DONE]
|
||||
const lines = String(text || "").split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const s = line.trim();
|
||||
const payload = s.startsWith("data:") ? s.slice(5).trim() : s;
|
||||
if (!payload || payload === "[DONE]") continue;
|
||||
try { take(JSON.parse(payload)); } catch {}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function cleanTokenNumber(n) {
|
||||
n = Number(n);
|
||||
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
|
||||
}
|
||||
|
||||
function usageTotal(u) {
|
||||
return u ? cleanTokenNumber(u.total) : 0;
|
||||
}
|
||||
|
||||
function betterUsage(a, b) {
|
||||
return usageTotal(b) > usageTotal(a) ? b : a;
|
||||
}
|
||||
|
||||
function hasOwn(obj, key) {
|
||||
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||
}
|
||||
|
||||
function looksLikeUsage(usage) {
|
||||
if (!usage || typeof usage !== "object") return false;
|
||||
const keys = [
|
||||
"input_tokens", "prompt_tokens", "output_tokens", "completion_tokens", "total_tokens",
|
||||
"promptTokenCount", "prompt_token_count", "inputTokenCount", "outputTokenCount",
|
||||
"candidatesTokenCount", "candidates_token_count", "totalTokenCount", "total_token_count",
|
||||
"token_count", "cache_creation_input_tokens", "cache_read_input_tokens",
|
||||
"cachedContentTokenCount", "cached_content_token_count", "reasoning_tokens",
|
||||
"thoughtsTokenCount", "thoughts_token_count",
|
||||
];
|
||||
if (keys.some((key) => hasOwn(usage, key))) return true;
|
||||
return hasOwn(usage, "total") && (hasOwn(usage, "input") || hasOwn(usage, "output") || hasOwn(usage, "cached"));
|
||||
}
|
||||
|
||||
function extractUsageFromRawUsage(usage) {
|
||||
if (!usage || typeof usage !== "object") return null;
|
||||
if (!looksLikeUsage(usage)) return null;
|
||||
const input = cleanTokenNumber(
|
||||
usage.input_tokens ||
|
||||
usage.prompt_tokens ||
|
||||
usage.promptTokenCount ||
|
||||
usage.prompt_token_count ||
|
||||
usage.inputTokenCount
|
||||
);
|
||||
const output = cleanTokenNumber(
|
||||
usage.output_tokens ||
|
||||
usage.completion_tokens ||
|
||||
usage.candidatesTokenCount ||
|
||||
usage.candidates_token_count ||
|
||||
usage.outputTokenCount
|
||||
);
|
||||
const reasoning = cleanTokenNumber(usage.reasoning_tokens || usage.thoughtsTokenCount || usage.thoughts_token_count);
|
||||
const details = usage.input_tokens_details || usage.prompt_tokens_details || usage.inputTokenDetails || usage.promptTokenDetails || {};
|
||||
const cached =
|
||||
cleanTokenNumber(details.cached_tokens) +
|
||||
cleanTokenNumber(usage.cache_creation_input_tokens) +
|
||||
cleanTokenNumber(usage.cache_read_input_tokens) +
|
||||
cleanTokenNumber(usage.cachedContentTokenCount) +
|
||||
cleanTokenNumber(usage.cached_content_token_count);
|
||||
const total = cleanTokenNumber(
|
||||
usage.total_tokens ||
|
||||
usage.totalTokenCount ||
|
||||
usage.total_token_count ||
|
||||
usage.token_count ||
|
||||
usage.total
|
||||
) || input + output + reasoning + cached;
|
||||
if (!total) return null;
|
||||
return { total, input, output: output + reasoning, cached };
|
||||
}
|
||||
|
||||
function extractUsageFromObject(obj) {
|
||||
if (!obj || typeof obj !== "object") return null;
|
||||
const direct =
|
||||
extractUsageFromRawUsage(obj.usage) ||
|
||||
extractUsageFromRawUsage(obj.usage_metadata) ||
|
||||
extractUsageFromRawUsage(obj.usageMetadata) ||
|
||||
extractUsageFromRawUsage(obj);
|
||||
if (direct) return direct;
|
||||
let best = null;
|
||||
for (const val of Object.values(obj)) {
|
||||
if (!val || typeof val !== "object") continue;
|
||||
if (Array.isArray(val)) {
|
||||
for (const item of val) best = betterUsage(best, extractUsageFromObject(item));
|
||||
} else {
|
||||
best = betterUsage(best, extractUsageFromObject(val));
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
async function attachNetworkSession(target, generation) {
|
||||
if (!target || !target.webSocketDebuggerUrl) return;
|
||||
let netSession = null;
|
||||
try {
|
||||
netSession = await openSession(target.webSocketDebuggerUrl, null);
|
||||
bindNetworkHandlers(netSession);
|
||||
await netSession.send("Network.enable");
|
||||
if (!session || generation !== networkAttachGeneration) {
|
||||
try { netSession.close(); } catch {}
|
||||
return false;
|
||||
}
|
||||
networkSessions.push(netSession);
|
||||
dlog("token network attached:", target.type || "target", target.id || "", target.url || "");
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (netSession) {
|
||||
try { netSession.close(); } catch {}
|
||||
}
|
||||
tokenNet.lastError = String((e && e.message) || e || "attach network failed").slice(0, 160);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function attachWorkerNetworkTargets(port, parentId) {
|
||||
const targets = await listTargets(port);
|
||||
const workers = targets.filter((t) =>
|
||||
t &&
|
||||
t.webSocketDebuggerUrl &&
|
||||
(t.type === "worker" || t.type === "service_worker" || t.type === "shared_worker") &&
|
||||
(!parentId || t.parentId === parentId)
|
||||
);
|
||||
const generation = networkAttachGeneration;
|
||||
const results = await Promise.all(workers.map((worker) => attachNetworkSession(worker, generation)));
|
||||
tokenNet.attachedWorkers = results.filter(Boolean).length;
|
||||
}
|
||||
|
||||
function closeNetworkSessions() {
|
||||
networkAttachGeneration += 1;
|
||||
for (const s of networkSessions) {
|
||||
try { s.close(); } catch {}
|
||||
}
|
||||
networkSessions = [];
|
||||
pendingNetworkReqs.clear();
|
||||
}
|
||||
|
||||
// —— 连接到已带端口的 Codex ——
|
||||
@@ -423,17 +729,26 @@ async function connectToTarget(port) {
|
||||
const targets = await listTargets(port);
|
||||
const tgt = pickTarget(targets);
|
||||
if (!tgt) return false;
|
||||
closeNetworkSessions();
|
||||
state.target = { id: tgt.id, url: tgt.url, title: tgt.title };
|
||||
setPhase("connecting");
|
||||
session = await openSession(tgt.webSocketDebuggerUrl, handleBinding);
|
||||
session.on("Runtime.bindingCalled", handleBinding);
|
||||
// Token 用量:Network 层拦截 LLM 响应
|
||||
bindNetworkHandlers(session);
|
||||
session.ws.on("close", () => {
|
||||
stopTokenSync();
|
||||
closeNetworkSessions();
|
||||
session = null;
|
||||
setPhase("disconnected");
|
||||
});
|
||||
setPhase("injecting");
|
||||
await inject(session);
|
||||
startTokenSync();
|
||||
setPhase("connected", { target: state.target });
|
||||
attachWorkerNetworkTargets(port, tgt.id).catch((e) => {
|
||||
tokenNet.lastError = String((e && e.message) || e || "attach workers failed").slice(0, 160);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -545,6 +860,8 @@ async function connect(cb, opts = {}) {
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
stopTokenSync();
|
||||
closeNetworkSessions();
|
||||
if (session) {
|
||||
try {
|
||||
session.close();
|
||||
@@ -601,6 +918,56 @@ async function probeStt() {
|
||||
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,
|
||||
@@ -608,6 +975,9 @@ module.exports = {
|
||||
onStatus,
|
||||
sttInfo,
|
||||
probeStt,
|
||||
tokenStatus,
|
||||
tokenSet,
|
||||
tokenReset,
|
||||
// 供临时实测脚本复用
|
||||
_listTargets: listTargets,
|
||||
_pickTarget: pickTarget,
|
||||
|
||||
Reference in New Issue
Block a user