692 lines
29 KiB
JavaScript
692 lines
29 KiB
JavaScript
// 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 };
|