fix: canvas image/video download - fix data URL parsing in cvDownloadPreview (v1.5.21, hot 1.5.23)
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user