fix: canvas image/video download - fix data URL parsing in cvDownloadPreview (v1.5.21, hot 1.5.23)

This commit is contained in:
mac
2026-07-20 08:48:56 +08:00
parent 42e23663ee
commit aadd2b04ae
28 changed files with 5881 additions and 329 deletions
+691
View File
@@ -0,0 +1,691 @@
// token_inject.js —— 注入到 Codex 页面的 Token 用量 UI 脚本,以字符串形式导出。
// 由 voice_cdp.js 经 CDP Runtime.evaluate 注入(复用语音 CDP 连接,无需独立端口)。
//
// 职责(仅 UI 层):
// 1. 在输入框下方工具栏内创建 token 用量条(优先放在模型选择左侧)
// 2. 读写 localStorage["td-token-daily"](按日期分桶,跨天自动清零)
// 3. 暴露 window.__tdTokenAdd(usage) 给主进程调用来累加并触发动画
// 4. 暴露 window.__tdTokenGet() / __tdTokenReset() 给主进程调用来读取/重置
// 5. 页面内兜底解析 fetch/XHR usage 与 Codex DOM token 文本
//
// Token 抓取优先由 voice_cdp.js 在 CDP 层面通过 Network.responseReceived 完成,
// 页面内 fetch/XHR 解析只作为兜底。
//
// 注意:脚本体用普通模板字符串包裹;内部避免出现 ${ 与裸反引号。
const TOKEN_INJECT_SCRIPT = `
(() => {
const VER = "td-token-v17";
if (window.__tdTokenInstalled === VER) return;
if (window.__tdTokenObserver) { try { window.__tdTokenObserver.disconnect(); } catch {} }
const oldBar = document.getElementById("td-token-bar");
if (oldBar) oldBar.remove();
const oldRow = document.getElementById("td-token-center-row");
if (oldRow) oldRow.remove();
["td-token-style-v4", "td-token-style-v5", "td-token-style-v6", "td-token-style-v7", "td-token-style-v8", "td-token-style-v9", "td-token-style-v10", "td-token-style-v11", "td-token-style-v12", "td-token-style-v13", "td-token-style-v14", "td-token-style-v15", "td-token-style-v16", "td-token-style-v17"].forEach((id) => {
const el = document.getElementById(id);
if (el) el.remove();
});
window.__tdTokenInstalled = VER;
// ============================================================================
// 1. 持久化
// ============================================================================
const STORE_KEY = "td-token-daily";
function todayKey() {
const d = new Date();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return d.getFullYear() + "-" + m + "-" + day;
}
function loadDaily() {
try {
const raw = localStorage.getItem(STORE_KEY);
if (!raw) return blankDaily();
const d = JSON.parse(raw);
if (d.date !== todayKey()) return blankDaily();
return normalizeDaily(d);
} catch { return blankDaily(); }
}
function blankDaily() {
return { date: todayKey(), total: 0, input: 0, output: 0, cached: 0, estimate: 0, unknown: 0, lastSource: "" };
}
function cleanNum(n) {
n = Number(n);
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
}
function normalizeDaily(d) {
const out = blankDaily();
out.date = d && d.date ? d.date : todayKey();
out.input = cleanNum(d && d.input);
out.output = cleanNum(d && d.output);
out.cached = cleanNum(d && d.cached);
out.estimate = cleanNum(d && (d.estimate || d.estimated));
out.unknown = cleanNum(d && d.unknown);
out.total = cleanNum(d && d.total) || out.input + out.output + out.cached + out.estimate + out.unknown;
const known = out.input + out.output + out.cached + out.estimate + out.unknown;
if (out.total > known) out.unknown += out.total - known;
out.lastSource = String((d && (d.lastSource || d.source)) || "");
if (out.unknown && !out.lastSource) out.lastSource = "legacy";
return out;
}
function saveDaily(data) {
try { localStorage.setItem(STORE_KEY, JSON.stringify(normalizeDaily(data))); } catch {}
}
let daily = loadDaily();
// ============================================================================
// 2. 创建 UI
// ============================================================================
const BAR_ID = "td-token-bar";
const STYLE_ID = "td-token-style-v17";
const TOKEN_CAP = 5000000000;
if (!document.getElementById(STYLE_ID)) {
const st = document.createElement("style");
st.id = STYLE_ID;
st.textContent = \`
#td-token-bar{
display:inline-block;position:relative;
flex:0 0 auto;width:228px;max-width:32vw;height:28px;
margin:0 6px;padding:0;border-radius:999px;
font:11px system-ui,-apple-system,sans-serif;
user-select:none;-webkit-app-region:no-drag;
pointer-events:none;opacity:.96;transition:opacity .18s,filter .18s;
background:transparent;border:0;
vertical-align:middle;box-sizing:border-box;
}
#td-token-bar:hover{opacity:1;filter:saturate(1.08)}
.td-tk-track{
position:absolute;left:0;right:0;top:50%;height:28px;
border-radius:8px;transform:translateY(-50%);
background:#fff;overflow:hidden;
box-shadow:0 1px 0 rgba(255,255,255,.92) inset,0 0 0 1px rgba(15,23,42,.10),0 5px 16px rgba(15,23,42,.20);
}
.td-tk-segments{
position:absolute;left:18px;right:72px;top:50%;
display:grid;grid-template-columns:repeat(8,1fr);gap:3px;
height:12px;transform:translateY(-50%);
}
.td-tk-seg{
position:relative;overflow:hidden;border-radius:3px;transform:skewX(-7deg);
background:linear-gradient(180deg,rgba(138,217,255,.22),rgba(23,103,204,.22));
box-shadow:0 0 0 1px rgba(54,157,239,.18) inset;
}
.td-tk-seg::before{
content:"";position:absolute;left:0;top:0;bottom:0;width:var(--td-tk-seg-fill,0%);
border-radius:inherit;background:linear-gradient(180deg,#52c8ff 0%,#2673dd 58%,#1554c5 100%);
box-shadow:0 0 9px rgba(42,163,255,.72),0 1px 0 rgba(255,255,255,.45) inset;
transition:width .8s cubic-bezier(.25,.8,.25,1);
}
.td-tk-segments.beat .td-tk-seg::before{animation:tdTkBeat .55s ease}
.td-tk-segments::after{
content:"";position:absolute;left:-30px;top:-3px;bottom:-3px;width:24px;
pointer-events:none;opacity:0;border-radius:999px;
background:linear-gradient(90deg,transparent,rgba(255,255,255,.9),transparent);
transform:skewX(-12deg);
}
.td-tk-segments.beat::after{animation:tdTkSweep .7s ease}
.td-tk-segments.beat + .td-tk-badge{animation:tdTkBadgeBeat .62s ease}
@keyframes tdTkBeat{
0%{filter:brightness(1)}
45%{filter:brightness(1.26)}
100%{filter:brightness(1)}
}
@keyframes tdTkSweep{
0%{left:-30px;opacity:0}
18%{opacity:.9}
100%{left:calc(100% + 30px);opacity:0}
}
@keyframes tdTkBadgeBeat{
0%{transform:translateY(-50%) scale(1)}
45%{transform:translateY(-50%) scale(1.08);filter:brightness(1.18)}
100%{transform:translateY(-50%) scale(1)}
}
.td-tk-badge{
position:absolute;right:18px;top:50%;z-index:2;
display:inline-flex;align-items:center;gap:3px;height:18px;padding:0 7px 0 5px;
border-radius:7px;transform:translateY(-50%);
color:#fff;font-size:11px;font-weight:800;letter-spacing:0;white-space:nowrap;
background:linear-gradient(180deg,#63c8ff 0%,#2c77df 64%,#1f60c9 100%);
box-shadow:0 0 12px rgba(53,159,246,.75),0 1px 0 rgba(255,255,255,.45) inset;
}
.td-tk-check{
display:inline-flex;align-items:center;justify-content:center;width:12px;height:12px;
border-radius:50%;background:#fff;color:#266bd5;font-size:10px;line-height:1;
}
.td-tk-cap{
position:absolute;right:7px;bottom:4px;width:10px;height:12px;border-radius:3px 3px 4px 4px;
background:linear-gradient(180deg,#dcffff 0%,#61ddfb 100%);
box-shadow:0 0 8px rgba(82,222,255,.75);opacity:.95;
}
.td-tk-cap::before{
content:"";position:absolute;left:2px;right:2px;top:-3px;height:3px;border-radius:2px 2px 0 0;
background:#cfffff;
}
.td-tk-delta{
position:absolute;right:38px;top:-8px;z-index:3;
font-size:10px;font-weight:700;white-space:nowrap;color:#fff;
padding:1px 5px;border-radius:999px;background:#2b7ee7;
opacity:0;transform:translateY(2px);transition:none;
}
.td-tk-delta.show{
animation:tdTkDeltaIn 1.6s ease forwards;
}
@keyframes tdTkDeltaIn{
0%{opacity:0;transform:translateY(2px)}
20%{opacity:1;transform:translateY(0)}
85%{opacity:1;transform:translateY(0)}
100%{opacity:0;transform:translateY(-4px)}
}
@media (max-width:640px){#td-token-bar{width:186px;max-width:44vw;margin:0 4px}.td-tk-segments{left:14px;right:58px;gap:2px}.td-tk-badge{right:12px;font-size:10px;padding:0 5px;height:17px}.td-tk-cap{display:none}}
@media (prefers-color-scheme:dark){
.td-tk-track{background:#fff;box-shadow:0 1px 0 rgba(255,255,255,.92) inset,0 0 0 1px rgba(255,255,255,.2),0 5px 16px rgba(0,0,0,.36)}
}
\`;
(document.head || document.documentElement).appendChild(st);
}
function buildBar() {
const div = document.createElement("div");
div.id = BAR_ID;
div.innerHTML =
'<div class="td-tk-track"><div class="td-tk-segments" id="td-tk-segments">' +
'<i class="td-tk-seg"></i><i class="td-tk-seg"></i><i class="td-tk-seg"></i><i class="td-tk-seg"></i>' +
'<i class="td-tk-seg"></i><i class="td-tk-seg"></i><i class="td-tk-seg"></i><i class="td-tk-seg"></i>' +
'</div><span class="td-tk-badge"><span class="td-tk-check">✓</span><span id="td-tk-badge-text">0 token</span></span><span class="td-tk-cap"></span></div>' +
'<span class="td-tk-delta" id="td-tk-delta"></span>';
return div;
}
// ============================================================================
// 3. 格式化 + 颜色 + 渲染
// ============================================================================
function fmtTokens(n) {
if (n < 1000) return String(n);
if (n < 1000000) return (n / 1000).toFixed(1) + "K";
if (n < 1000000000) return (n / 1000000).toFixed(2) + "M";
return (n / 1000000000).toFixed(2) + "B";
}
function fmtBadgeTokens(n) {
n = cleanNum(n);
if (n < 1000) return n + " token";
if (n < 1000000) return (n / 1000).toFixed(1).replace(/\.0$/, "") + "k token";
if (n < 1000000000) return (n / 1000000).toFixed(1).replace(/\.0$/, "") + "M token";
return (n / 1000000000).toFixed(1).replace(/\.0$/, "") + "B token";
}
function fmtDelta(n) {
if (n < 1000) return "+" + n;
if (n < 1000000) return "+" + (n / 1000).toFixed(1) + "K";
if (n < 1000000000) return "+" + (n / 1000000).toFixed(2) + "M";
return "+" + (n / 1000000000).toFixed(2) + "B";
}
function fillColor(total) {
return "linear-gradient(180deg,#52c8ff 0%,#2673dd 58%,#1554c5 100%)";
}
function progressPct(total) {
total = cleanNum(total);
if (!total) return 0;
return Math.max(0.8, Math.min(100, (total / TOKEN_CAP) * 100));
}
// 5B 上限下,低于几十 MB 的线性变化肉眼几乎不可见;视觉分段采用对数映射,实际百分比仍保留在线程提示中。
function visualProgressPct(total) {
total = cleanNum(total);
if (!total) return 0;
const scale = 100000;
const pct = Math.log1p(total / scale) / Math.log1p(TOKEN_CAP / scale) * 100;
return Math.max(2.4, Math.min(100, pct));
}
function paintSegments(pct) {
const segs = Array.from(document.querySelectorAll(".td-tk-seg"));
if (!segs.length) return;
const span = 100 / segs.length;
segs.forEach((seg, i) => {
const start = i * span;
const fill = Math.max(0, Math.min(100, ((pct - start) / span) * 100));
seg.style.setProperty("--td-tk-seg-fill", fill + "%");
});
}
let beatTimer = null;
function triggerBeat() {
const segments = document.getElementById("td-tk-segments");
if (!segments) return;
render();
segments.classList.remove("beat");
void segments.offsetWidth;
segments.classList.add("beat");
clearTimeout(beatTimer);
beatTimer = setTimeout(() => {
segments.classList.remove("beat");
}, 650);
}
function render() {
daily = normalizeDaily(daily);
const bar = document.getElementById(BAR_ID);
const badge = document.getElementById("td-tk-badge-text");
const pct = visualProgressPct(daily.total);
paintSegments(pct);
if (badge) badge.textContent = fmtBadgeTokens(daily.total);
if (bar) {
bar.title = usageTitle(daily);
}
}
function usageTitle(d) {
d = normalizeDaily(d);
const parts = ["今日 " + fmtTokens(d.total), "额度 5B", "进度 " + progressPct(d.total).toFixed(4).replace(/0+$/,"").replace(/\\.$/,"") + "%"];
if (d.input) parts.push("输入 " + fmtTokens(d.input));
if (d.output) parts.push("输出 " + fmtTokens(d.output));
if (d.cached) parts.push("缓存 " + fmtTokens(d.cached));
if (d.estimate) parts.push("估算 " + fmtTokens(d.estimate));
if (d.unknown) parts.push("未知 " + fmtTokens(d.unknown));
if (d.lastSource) parts.push("来源 " + d.lastSource);
return parts.join(" · ");
}
// ============================================================================
// 4. 动画:主进程通过 Runtime.evaluate 调用此函数触发
// ============================================================================
let lastNetworkAt = 0;
let lastPageUsageAt = 0;
let estimatedSinceUsage = 0;
let recentRealAdds = [];
let lastSessionTotal = null;
function showDelta(total) {
const delta = document.getElementById("td-tk-delta");
if (!delta || total <= 0) return;
delta.textContent = fmtDelta(total);
delta.classList.remove("show");
void delta.offsetWidth;
delta.classList.add("show");
}
function normalizeUsagePayload(payload, source) {
if (typeof payload === "number" || typeof payload === "string") {
return { total: cleanNum(payload), input: 0, output: 0, cached: 0, estimate: 0, source: source || "manual" };
}
const u = payload && typeof payload === "object" ? payload : {};
const input = cleanNum(u.input || u.input_tokens || u.prompt_tokens || u.promptTokenCount || u.prompt_token_count || u.inputTokenCount);
const output = cleanNum(u.output || u.output_tokens || u.completion_tokens || u.candidatesTokenCount || u.candidates_token_count || u.outputTokenCount);
const reasoning = cleanNum(u.reasoning_tokens || u.thoughtsTokenCount || u.thoughts_token_count);
const cached = cleanNum(u.cached || u.cached_tokens || u.cache_creation_input_tokens || 0) +
cleanNum(u.cache_read_input_tokens || 0) +
cleanNum(u.cachedContentTokenCount || 0) +
cleanNum(u.cached_content_token_count || 0);
const estimate = cleanNum(u.estimate || u.estimated);
const unknown = cleanNum(u.unknown);
const total = cleanNum(u.total || u.total_tokens || u.totalTokenCount || u.total_token_count || u.token_count) || input + output + reasoning + cached + estimate + unknown;
return { total, input, output: output + reasoning, cached, estimate, unknown, source: String(source || u.source || "manual") };
}
function animateTokenAdd(payload, source) {
const usage = normalizeUsagePayload(payload, source);
if (usage.total <= 0) return;
const src = usage.source || "manual";
const now = Date.now();
if (src === "network" || src === "page") {
recentRealAdds = recentRealAdds.filter((item) => now - item.at < 15000);
if (recentRealAdds.some((item) => item.total === usage.total && item.source !== src)) return;
recentRealAdds.push({ total: usage.total, source: src, at: now });
}
if (src === "network") lastNetworkAt = now;
if (src === "page") lastPageUsageAt = now;
if (src === "dom" && (now - lastNetworkAt < 12000 || now - lastPageUsageAt < 12000)) return;
if (src === "page" && now - lastNetworkAt < 8000) return;
if (src === "network" || src === "page") {
daily = loadDaily();
const subtract = Math.min(estimatedSinceUsage || daily.estimate, daily.total);
if (subtract > 0) {
daily.total = Math.max(0, daily.total - subtract);
daily.estimate = Math.max(0, daily.estimate - subtract);
saveDaily(daily);
estimatedSinceUsage = 0;
}
}
daily = loadDaily();
daily.total += usage.total;
daily.input += usage.input;
daily.output += usage.output;
daily.cached += usage.cached;
daily.unknown += usage.unknown;
if (src === "estimate") {
const est = usage.estimate || usage.total;
daily.estimate += est;
estimatedSinceUsage += est;
}
daily.lastSource = src;
saveDaily(daily);
// 本地 session 已接管时,Network 只保留兜底记账,不重复播放动画。
if (lastSessionTotal === null || src === "session") triggerBeat();
const shouldShowDelta = src !== "estimate" && src !== "dom";
if (shouldShowDelta && (lastSessionTotal === null || src === "session")) showDelta(usage.total);
}
function usageTotal(u) {
return u ? cleanNum(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 = cleanNum(usage.input_tokens || usage.prompt_tokens || usage.promptTokenCount || usage.prompt_token_count || usage.inputTokenCount);
const output = cleanNum(usage.output_tokens || usage.completion_tokens || usage.candidatesTokenCount || usage.candidates_token_count || usage.outputTokenCount);
const reasoning = cleanNum(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 = cleanNum(details.cached_tokens) +
cleanNum(usage.cache_creation_input_tokens) +
cleanNum(usage.cache_read_input_tokens) +
cleanNum(usage.cachedContentTokenCount) +
cleanNum(usage.cached_content_token_count);
const total = cleanNum(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;
}
function extractUsageFromText(text) {
let best = null;
const take = (obj) => { best = betterUsage(best, extractUsageFromObject(obj)); };
try { take(JSON.parse(text)); } catch {}
const lines = String(text || "").split(String.fromCharCode(13)).join("").split(String.fromCharCode(10));
for (const line of lines) {
const s = line.trim();
if (!s.startsWith("data:")) continue;
const payload = s.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
try { take(JSON.parse(payload)); } catch {}
}
return best;
}
function installNetworkFallback() {
if (window.__tdTokenNetFallbackInstalled === VER) return;
window.__tdTokenNetFallbackInstalled = VER;
const API_URL_RE = new RegExp("chat/completions|/responses|/messages|conversation|codex", "i");
const TEXT_CT_RE = new RegExp("json|text|event-stream", "i");
const rawFetch = window.fetch;
if (typeof rawFetch === "function") {
window.fetch = async function(...args) {
const res = await rawFetch.apply(this, args);
try {
const url = String((args[0] && args[0].url) || args[0] || "");
if (API_URL_RE.test(url)) {
const ct = (res.headers && res.headers.get && res.headers.get("content-type")) || "";
if (TEXT_CT_RE.test(ct)) {
res.clone().text().then((txt) => {
const usage = extractUsageFromText(txt);
if (usage && usage.total > 0) animateTokenAdd(usage, "page");
}).catch(() => {});
}
}
} catch {}
return res;
};
}
const XHR = window.XMLHttpRequest;
if (XHR && XHR.prototype) {
const rawOpen = XHR.prototype.open;
const rawSend = XHR.prototype.send;
XHR.prototype.open = function(method, url, ...rest) {
try { this.__tdTokenUrl = String(url || ""); } catch {}
return rawOpen.call(this, method, url, ...rest);
};
XHR.prototype.send = function(...args) {
try {
this.addEventListener("loadend", () => {
try {
const url = this.__tdTokenUrl || "";
const ct = this.getResponseHeader && (this.getResponseHeader("content-type") || "");
if (!API_URL_RE.test(url)) return;
if (!TEXT_CT_RE.test(ct)) return;
const txt = typeof this.responseText === "string" ? this.responseText : "";
const usage = extractUsageFromText(txt);
if (usage && usage.total > 0) animateTokenAdd(usage, "page");
} catch {}
});
} catch {}
return rawSend.apply(this, args);
};
}
}
function parseTokenText(text) {
const matches = String(text || "").matchAll(new RegExp("([0-9.,]+) *([KkMm]?) *tokens?", "gi"));
let best = 0;
for (const m of matches) {
let num = parseFloat(String(m[1]).replace(/,/g, ""));
if (!isFinite(num) || num <= 0) continue;
const unit = String(m[2] || "").toLowerCase();
if (unit === "k") num *= 1000;
else if (unit === "m") num *= 1000000;
best = Math.max(best, Math.round(num));
}
return best;
}
let domTokenBaseline = 0;
let domTokenBest = 0;
let domScanTimer = null;
function scanDomTokenText() {
const text = document.body ? document.body.innerText || "" : "";
const best = parseTokenText(text);
if (best <= 0) return;
if (!domTokenBaseline) {
domTokenBaseline = best;
domTokenBest = best;
return;
}
if (best > domTokenBest) {
const delta = best - domTokenBest;
domTokenBest = best;
if (delta > 0 && delta < 2000000) animateTokenAdd(delta, "dom");
}
}
function primeEstimateBaseline() {
const raw = document.body ? document.body.innerText || "" : "";
const best = parseTokenText(raw);
if (best > 0) {
domTokenBaseline = best;
domTokenBest = best;
}
}
function scheduleDomScan() {
clearTimeout(domScanTimer);
domScanTimer = setTimeout(() => {
scanDomTokenText();
}, 700);
}
function findToolbar() {
const rich = document.querySelector("DIV.ProseMirror, [contenteditable], textarea");
if (!rich) return null;
let p = rich;
for (let i = 0; i < 7 && p; i++) {
const foot = p.querySelector("[class*='_footer_'], [class*='composer-footer'], footer.composer, [class*='footer'][class*='composer']");
if (foot && foot.querySelectorAll("button, [role='button']").length >= 2) return foot;
p = p.parentElement;
}
return null;
}
function visibleText(el) {
return String((el && (el.getAttribute("aria-label") || el.title || el.textContent)) || "").trim();
}
function findModelControl(toolbar) {
const controls = Array.from(toolbar.querySelectorAll("button, [role='button'], [aria-haspopup], [data-testid*='model'], [class*='model']"));
const modelLike = controls.find((el) => /model|模型|gpt|claude|codex|o[0-9]|gemini|deepseek|glm|kimi/i.test(visibleText(el)));
if (modelLike) return modelLike;
const buttons = Array.from(toolbar.querySelectorAll("button, [role='button']"));
return buttons.length > 1 ? buttons[buttons.length - 2] : buttons[0] || null;
}
function centerUnderEditor(bar) {
const rich = document.querySelector("DIV.ProseMirror, [contenteditable], textarea");
if (!rich || !rich.parentElement) return false;
const row = document.createElement("div");
row.id = "td-token-center-row";
row.style.cssText = "display:flex;justify-content:center;align-items:center;width:100%;padding:2px 0 0;pointer-events:none;";
row.appendChild(bar);
const anchor = rich.parentElement;
anchor.insertAdjacentElement("afterend", row);
return true;
}
function cleanupCenterRow() {
const row = document.getElementById("td-token-center-row");
if (row && !row.querySelector("#" + BAR_ID)) row.remove();
}
function placeInToolbar(bar) {
const toolbar = findToolbar();
if (!toolbar) return false;
const model = findModelControl(toolbar);
if (model && model.parentElement && toolbar.contains(model.parentElement)) {
model.parentElement.insertBefore(bar, model);
} else if (model && toolbar.contains(model)) {
toolbar.insertBefore(bar, model);
} else {
toolbar.appendChild(bar);
}
cleanupCenterRow();
return true;
}
function mountBar() {
const existing = document.getElementById(BAR_ID);
if (existing) return;
const bar = buildBar();
if (!placeInToolbar(bar) && !centerUnderEditor(bar)) {
(document.body || document.documentElement).appendChild(bar);
}
render();
}
function maybeRelocate() {
const bar = document.getElementById(BAR_ID);
if (!bar) return false;
const row = document.getElementById("td-token-center-row");
if (row && row.contains(bar)) return placeInToolbar(bar);
if (bar.parentElement === document.body || bar.parentElement === document.documentElement) return placeInToolbar(bar);
return false;
}
// ============================================================================
// 5. MutationObserver:页面重绘后补回悬浮条
// ============================================================================
function tryMount() {
if (document.getElementById(BAR_ID)) return;
mountBar();
}
let mo = null;
function startObserver() {
if (mo) return;
installNetworkFallback();
mo = new MutationObserver((mutations) => {
maybeRelocate();
if (!document.getElementById(BAR_ID)) tryMount();
scheduleDomScan();
});
window.__tdTokenObserver = mo;
const root = document.body || document.documentElement;
if (root) mo.observe(root, { childList: true, subtree: true });
tryMount();
primeEstimateBaseline();
scheduleDomScan();
}
function ensureStarted() {
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", startObserver, { once: true });
} else {
startObserver();
}
}
ensureStarted();
// ============================================================================
// 7. 对外 API(主进程通过 Runtime.evaluate 调用)
// ============================================================================
window.__tdTokenAdd = (usage, source) => {
try {
const u = normalizeUsagePayload(usage, source || "external");
if (!u.total) return "bad";
animateTokenAdd(u, u.source);
return "ok";
} catch { return "err"; }
};
window.__tdTokenGet = () => {
const d = loadDaily();
return JSON.stringify(d);
};
window.__tdTokenSet = (usage) => {
try {
const next = normalizeUsagePayload(usage, "session");
const previousSessionTotal = lastSessionTotal;
lastSessionTotal = next.total;
daily = normalizeDaily({ ...next, date: todayKey(), lastSource: "session" });
saveDaily(daily);
render();
const delta = previousSessionTotal === null ? 0 : next.total - previousSessionTotal;
if (delta > 0) {
triggerBeat();
showDelta(delta);
}
return "ok";
} catch { return "err"; }
};
window.__tdTokenReset = () => {
daily = blankDaily();
estimatedSinceUsage = 0;
recentRealAdds = [];
lastSessionTotal = 0;
saveDaily(daily);
render();
return "ok";
};
})();
`;
module.exports = { TOKEN_INJECT_SCRIPT };