在 ChatGPT 页面直接显示 Codex 额度、重置时间、每日用量和客户端汇总。
// ==UserScript==
// @name Codex Quota Compass UI
// @name:zh-CN Codex 用量指南针 UI
// @namespace https://chatgpt.com/codex
// @version 0.1.9
// @description 在 ChatGPT 页面直接显示 Codex 额度、重置时间、每日用量和客户端汇总。
// @description:zh-CN 在 ChatGPT 页面直接显示 Codex 额度、重置时间、每日用量和客户端汇总。
// @author creamtea47
// @license MIT
// @match https://chatgpt.com/*
// @run-at document-idle
// @grant GM_addStyle
// ==/UserScript==
(function () {
'use strict';
const CONFIG = {
DATE_BUCKET_MODE: 'utc',
USD_PER_CREDIT: 40 / 1000,
ROLLING_DAYS: 30,
USAGE_PATH: '/backend-api/wham/usage',
DAILY_USAGE_PATH: '/backend-api/wham/analytics/daily-workspace-usage-counts',
};
const ROOT_ID = 'codex-quota-compass-ui';
const SCRIPT_VERSION = '0.1.9';
const STORAGE_KEY = 'codex-quota-compass-ui:collapsed';
const FULLSCREEN_STORAGE_KEY = 'codex-quota-compass-ui:fullscreen';
const POSITION_STORAGE_KEY = 'codex-quota-compass-ui:position';
const TOKEN_UNIT_STORAGE_KEY = 'codex-quota-compass-ui:token-unit';
const DAY_MS = 24 * 60 * 60 * 1000;
const HOUR_SECONDS = 60 * 60;
const WEEK_SECONDS = 7 * 24 * HOUR_SECONDS;
const n = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
const round = (v, d = 2) => Number(Number(v).toFixed(d));
const pad2 = (x) => String(x).padStart(2, '0');
const last = (arr) => (arr.length ? arr[arr.length - 1] : undefined);
const escapeHtml = (s) =>
String(s ?? '').replace(/[&<>"']/g, (ch) => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
})[ch]);
const fmtInt = (v) => new Intl.NumberFormat().format(n(v));
const fmtNum = (v, digits = 2) =>
new Intl.NumberFormat(undefined, {
maximumFractionDigits: digits,
minimumFractionDigits: 0,
}).format(n(v));
const getTokenUnit = () => {
const value = localStorage.getItem(TOKEN_UNIT_STORAGE_KEY);
return value === 'm' || value === 'yi' ? value : 'raw';
};
const fmtTokensCompact = (v) => {
const unit = getTokenUnit();
if (unit === 'm') return `${fmtNum(n(v) / 1_000_000, 2)}M`;
if (unit === 'yi') return `${fmtNum(n(v) / 100_000_000, 2)}亿`;
return fmtInt(v);
};
const fmtTokensLong = fmtTokensCompact;
const fmtTokenWan = (v) => `≈ ${fmtNum(n(v) / 10_000, 2)} 万 tokens`;
const fmtTokenYi = (v) => `≈ ${fmtNum(n(v) / 100_000_000, 2)} 亿 tokens`;
const fmtTokenUnitHint = (v) => {
const unit = getTokenUnit();
if (unit === 'm') return fmtTokenWan(v);
if (unit === 'yi') return fmtTokenYi(v);
return '';
};
const tokenColumnLabel = (label) =>
getTokenUnit() === 'm'
? `${label} (M)`
: getTokenUnit() === 'yi'
? `${label} (亿)`
: label;
const fmtPct = (v) => `${fmtNum(n(v), 2)}%`;
const fmtLocal = (ms) => new Date(ms).toLocaleString();
const fmtUTC = (ms) =>
new Date(ms).toISOString().replace('T', ' ').replace('.000Z', ' UTC');
function parseRgb(color) {
const match = String(color || '').match(/rgba?\(([^)]+)\)/i);
if (!match) return null;
const [r, g, b] = match[1].split(',').slice(0, 3).map((v) => Number.parseFloat(v));
if (![r, g, b].every(Number.isFinite)) return null;
return { r, g, b };
}
function luminance({ r, g, b }) {
const channel = (v) => {
const x = v / 255;
return x <= 0.03928 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
}
function detectPageTheme() {
const bodyBg = getComputedStyle(document.body).backgroundColor;
const rootBg = getComputedStyle(document.documentElement).backgroundColor;
const rgb = parseRgb(bodyBg) || parseRgb(rootBg);
if (rgb) return luminance(rgb) < 0.45 ? 'dark' : 'light';
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function syncTheme(root) {
root.dataset.cqTheme = detectPageTheme();
}
function clamp(value, min, max) {
if (!Number.isFinite(value)) return min;
return Math.min(Math.max(value, min), Math.max(min, max));
}
function readSavedPosition() {
try {
const pos = JSON.parse(localStorage.getItem(POSITION_STORAGE_KEY) || 'null');
if (!pos || !Number.isFinite(pos.left) || !Number.isFinite(pos.top)) return null;
return pos;
} catch {
return null;
}
}
function applySavedPosition(root) {
const pos = readSavedPosition();
if (!pos) return;
const maxLeft = window.innerWidth - 80;
const maxTop = window.innerHeight - 44;
root.style.left = `${clamp(pos.left, 8, maxLeft)}px`;
root.style.top = `${clamp(pos.top, 8, maxTop)}px`;
root.style.right = 'auto';
root.style.bottom = 'auto';
}
function saveRootPosition(root) {
const rect = root.getBoundingClientRect();
localStorage.setItem(
POSITION_STORAGE_KEY,
JSON.stringify({
left: Math.round(rect.left),
top: Math.round(rect.top),
})
);
}
const ymdUTC = (x) => {
const d = new Date(x);
return [
d.getUTCFullYear(),
pad2(d.getUTCMonth() + 1),
pad2(d.getUTCDate()),
].join('-');
};
const ymdLocal = (x) => {
const d = new Date(x);
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
return d.toISOString().slice(0, 10);
};
const ymdForApi = (ms) =>
CONFIG.DATE_BUCKET_MODE === 'utc' ? ymdUTC(ms) : ymdLocal(ms);
const addDaysLocalMs = (ms, days) => {
const d = new Date(ms);
d.setDate(d.getDate() + days);
return d.getTime();
};
const addDaysForApi = (ms, days) =>
CONFIG.DATE_BUCKET_MODE === 'utc'
? ms + days * DAY_MS
: addDaysLocalMs(ms, days);
const firstDayOfMonthUTC = (ms) => {
const d = new Date(ms);
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-01`;
};
const firstDayOfMonthLocal = (ms) => {
const d = new Date(ms);
return ymdLocal(new Date(d.getFullYear(), d.getMonth(), 1).getTime());
};
const firstDayOfMonthForApi = (ms) =>
CONFIG.DATE_BUCKET_MODE === 'utc'
? firstDayOfMonthUTC(ms)
: firstDayOfMonthLocal(ms);
const utcOffsetLabel = (ms) => {
const offsetMinutes = -new Date(ms).getTimezoneOffset();
const sign = offsetMinutes >= 0 ? '+' : '-';
const abs = Math.abs(offsetMinutes);
return `UTC${sign}${pad2(Math.floor(abs / 60))}:${pad2(abs % 60)}`;
};
const tokenTotal = (obj = {}) =>
n(obj.text_total_tokens) ||
n(obj.cached_text_input_tokens) +
n(obj.uncached_text_input_tokens) +
n(obj.text_output_tokens);
const looksLikeJwt = (s) =>
typeof s === 'string' && s.length > 100 && s.split('.').length >= 3;
function findAccessToken(obj, depth = 0) {
if (!obj || typeof obj !== 'object' || depth > 8) return '';
for (const [key, value] of Object.entries(obj)) {
if (
typeof value === 'string' &&
/access/i.test(key) &&
looksLikeJwt(value)
) {
return value;
}
if (value && typeof value === 'object') {
const found = findAccessToken(value, depth + 1);
if (found) return found;
}
}
return '';
}
async function getAccessToken() {
try {
const res = await fetch('/api/auth/session', {
credentials: 'include',
headers: { accept: 'application/json' },
});
if (!res.ok) return '';
return findAccessToken(await res.json());
} catch {
return '';
}
}
async function apiGet(path, headers) {
const res = await fetch(path, {
method: 'GET',
credentials: 'include',
headers,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
if (res.status === 401) {
throw new Error('HTTP 401:没有拿到有效授权。请确认当前 Chrome 已登录 chatgpt.com,然后刷新页面重试。');
}
throw new Error(`HTTP ${res.status} ${res.statusText}: ${path}\n${text.slice(0, 500)}`);
}
return res.json();
}
function parseWindow(label, w, metadata = {}) {
const usedPercent = n(w?.used_percent);
const windowSeconds = n(w?.limit_window_seconds);
const resetAfterSeconds = n(w?.reset_after_seconds);
const resetAtSeconds = n(w?.reset_at);
const resetAtMs = resetAtSeconds * 1000;
const windowStartMs = resetAtMs - windowSeconds * 1000;
const serverNowMs = resetAtMs - resetAfterSeconds * 1000;
return {
name: label,
...metadata,
usedPercent,
usedRatio: usedPercent / 100,
windowSeconds,
windowDays: round(windowSeconds / 86400, 4),
windowStartMs,
resetAtMs,
serverNowMs,
resetAfterSeconds,
windowStartUTC: fmtUTC(windowStartMs),
windowStartLocal: fmtLocal(windowStartMs),
resetAtUTC: fmtUTC(resetAtMs),
resetAtLocal: fmtLocal(resetAtMs),
serverNowUTC: fmtUTC(serverNowMs),
serverNowLocal: fmtLocal(serverNowMs),
resetAfterHours: round(resetAfterSeconds / 3600, 2),
};
}
function windowDurationLabel(windowData) {
const seconds = n(windowData?.limit_window_seconds);
if (seconds <= 0) return '未知周期窗口';
const hours = seconds / HOUR_SECONDS;
if (hours >= 24) {
const days = hours / 24;
return `${Number.isInteger(days) ? days : round(days, 2)}天窗口`;
}
return `${Number.isInteger(hours) ? hours : round(hours, 2)}小时窗口`;
}
function collectRateLimitWindows(name, rateLimit, sourcePrefix, scope) {
const windows = [];
for (const field of ['primary_window', 'secondary_window']) {
const windowData = rateLimit?.[field];
if (!windowData) continue;
windows.push(parseWindow(
`${name} - ${windowDurationLabel(windowData)}`,
windowData,
{
scope,
sourcePath: `${sourcePrefix}.${field}`,
}
));
}
return windows;
}
function collectWindows(usage) {
const windows = collectRateLimitWindows(
'主限制',
usage?.rate_limit,
'usage.rate_limit',
'main'
);
for (const [index, item] of (usage?.additional_rate_limits ?? []).entries()) {
const name = item.limit_name || item.metered_feature || '额外限制';
windows.push(...collectRateLimitWindows(
name,
item?.rate_limit,
`usage.additional_rate_limits[${index}].rate_limit`,
'additional'
));
}
return windows;
}
function findWindowByDuration(windows, targetSeconds) {
// 字段名可能随接口调整,按窗口时长识别比固定 primary/secondary 更稳定。
const toleranceSeconds = Math.max(HOUR_SECONDS, targetSeconds * 0.05);
return windows.find((windowRow) =>
Math.abs(n(windowRow.windowSeconds) - targetSeconds) <= toleranceSeconds
);
}
function parseDailyRows(json) {
return (json.data ?? [])
.slice()
.sort((a, b) => String(a.date).localeCompare(String(b.date)))
.map((d) => {
const t = d.totals ?? {};
const credits = n(t.credits);
return {
date: d.date,
credits: round(credits, 6),
usd: round(credits * CONFIG.USD_PER_CREDIT, 2),
users: n(t.users),
threads: n(t.threads),
turns: n(t.turns),
tokens: tokenTotal(t),
cachedInputTokens: n(t.cached_text_input_tokens),
uncachedInputTokens: n(t.uncached_text_input_tokens),
outputTokens: n(t.text_output_tokens),
clientCount: Array.isArray(d.clients) ? d.clients.length : 0,
clientCredits: (d.clients ?? [])
.map((c) => `${c.client_id ?? 'UNKNOWN'}:${round(n(c.credits), 2)}`)
.join(' | '),
};
});
}
function summarizeClients(json) {
const map = new Map();
for (const day of json.data ?? []) {
for (const c of day.clients ?? []) {
const id = c.client_id ?? 'UNKNOWN';
const row = map.get(id) ?? {
client: id,
credits: 0,
usd: 0,
threads: 0,
turns: 0,
tokens: 0,
cachedInputTokens: 0,
uncachedInputTokens: 0,
outputTokens: 0,
};
const credits = n(c.credits);
row.credits += credits;
row.usd += credits * CONFIG.USD_PER_CREDIT;
row.threads += n(c.threads);
row.turns += n(c.turns);
row.tokens += tokenTotal(c);
row.cachedInputTokens += n(c.cached_text_input_tokens);
row.uncachedInputTokens += n(c.uncached_text_input_tokens);
row.outputTokens += n(c.text_output_tokens);
map.set(id, row);
}
}
return [...map.values()]
.map((r) => ({
...r,
credits: round(r.credits, 6),
usd: round(r.usd, 2),
}))
.sort((a, b) => b.tokens - a.tokens || b.credits - a.credits);
}
async function fetchDailyUsage(startDate, endExclusiveDate, headers) {
const qs = new URLSearchParams({
start_date: startDate,
end_date: endExclusiveDate,
group_by: 'day',
});
const url = `${CONFIG.DAILY_USAGE_PATH}?${qs}`;
const json = await apiGet(url, headers);
return {
url: location.origin + url,
rows: parseDailyRows(json),
clients: summarizeClients(json),
};
}
function summarizeRows(rangeName, rows, startDate, endExclusiveDate) {
const credits = rows.reduce((s, r) => s + n(r.credits), 0);
return {
range: rangeName,
bucketMode: CONFIG.DATE_BUCKET_MODE === 'utc' ? 'UTC日期桶' : '本地日期桶',
startDate,
endExclusiveDate,
dateBuckets: rows.length,
firstDate: rows[0]?.date ?? '',
lastDate: last(rows)?.date ?? '',
credits: round(credits, 6),
usd: round(credits * CONFIG.USD_PER_CREDIT, 2),
tokens: rows.reduce((s, r) => s + n(r.tokens), 0),
threads: rows.reduce((s, r) => s + n(r.threads), 0),
turns: rows.reduce((s, r) => s + n(r.turns), 0),
};
}
function buildWeeklyEstimate({
mainWeekly,
sinceResetRows,
sinceResetSummary,
sinceResetStartDate,
}) {
const usedPercent = n(mainWeekly.usedPercent);
const usedRatio = usedPercent / 100;
const includedCredits = n(sinceResetSummary.credits);
const resetDayRow = sinceResetRows.find((r) => r.date === sinceResetStartDate);
const resetDayCredits = n(resetDayRow?.credits);
const excludedCredits = Math.max(0, includedCredits - resetDayCredits);
if (usedRatio <= 0) {
return {
basis: `${mainWeekly.name} ${mainWeekly.sourcePath}`,
usedPercent,
note: '已用比例为 0,无法反推总额度。',
};
}
const totalWithResetDay = includedCredits / usedRatio;
const totalWithoutResetDay = excludedCredits / usedRatio;
const remainingWithResetDay = Math.max(0, totalWithResetDay - includedCredits);
const remainingWithoutResetDay = Math.max(0, totalWithoutResetDay - excludedCredits);
return {
basis: `${mainWeekly.name} ${mainWeekly.sourcePath}`,
usedPercent,
usedRatio: round(usedRatio, 4),
remainingRatio: round(1 - usedRatio, 4),
bucketMode: CONFIG.DATE_BUCKET_MODE === 'utc' ? 'UTC日期桶' : '本地日期桶',
includedCredits: round(includedCredits, 6),
includedUsd: round(includedCredits * CONFIG.USD_PER_CREDIT, 2),
resetDayCredits: round(resetDayCredits, 6),
resetDayUsd: round(resetDayCredits * CONFIG.USD_PER_CREDIT, 2),
excludedCredits: round(excludedCredits, 6),
excludedUsd: round(excludedCredits * CONFIG.USD_PER_CREDIT, 2),
totalCreditsWithResetDay: round(totalWithResetDay, 2),
totalUsdWithResetDay: round(totalWithResetDay * CONFIG.USD_PER_CREDIT, 2),
totalCreditsWithoutResetDay: round(totalWithoutResetDay, 2),
totalUsdWithoutResetDay: round(totalWithoutResetDay * CONFIG.USD_PER_CREDIT, 2),
remainingCreditsWithResetDay: round(remainingWithResetDay, 2),
remainingUsdWithResetDay: round(remainingWithResetDay * CONFIG.USD_PER_CREDIT, 2),
remainingCreditsWithoutResetDay: round(remainingWithoutResetDay, 2),
remainingUsdWithoutResetDay: round(remainingWithoutResetDay * CONFIG.USD_PER_CREDIT, 2),
note: 'daily analytics 只能按天聚合,实际值通常介于“排除重置日”和“包含重置日”之间。',
};
}
async function loadCompassData() {
const accessToken = await getAccessToken();
const headers = { accept: 'application/json' };
if (accessToken) headers.authorization = `Bearer ${accessToken}`;
const usage = await apiGet(CONFIG.USAGE_PATH, headers);
const windows = collectWindows(usage);
const mainWindows = windows.filter((windowRow) => windowRow.scope === 'main');
const mainWeekly = findWindowByDuration(mainWindows, WEEK_SECONDS);
const mainFiveHour = findWindowByDuration(mainWindows, 5 * HOUR_SECONDS);
if (!mainWeekly) {
throw new Error('没有找到主 7 天额度窗口,无法计算上次重置时间和周额度。');
}
const apiNowMs = mainWeekly.serverNowMs || Date.now();
const apiTodayDate = ymdForApi(apiNowMs);
const endExclusiveDate = ymdForApi(addDaysForApi(apiNowMs, 1));
const sinceResetStartDate = ymdForApi(mainWeekly.windowStartMs);
const monthStartDate = firstDayOfMonthForApi(apiNowMs);
const rollingStartDate = ymdForApi(
addDaysForApi(apiNowMs, -(CONFIG.ROLLING_DAYS - 1))
);
const sinceReset = await fetchDailyUsage(
sinceResetStartDate,
endExclusiveDate,
headers
);
const sinceResetSummary = summarizeRows(
`上次重置至今 ${sinceResetStartDate} ~ ${apiTodayDate}`,
sinceReset.rows,
sinceResetStartDate,
endExclusiveDate
);
const weeklyEstimate = buildWeeklyEstimate({
mainWeekly,
sinceResetRows: sinceReset.rows,
sinceResetSummary,
sinceResetStartDate,
});
const monthToDate = await fetchDailyUsage(monthStartDate, endExclusiveDate, headers);
const monthToDateSummary = summarizeRows(
`本月初至今 ${monthStartDate} ~ ${apiTodayDate}`,
monthToDate.rows,
monthStartDate,
endExclusiveDate
);
const rolling = await fetchDailyUsage(rollingStartDate, endExclusiveDate, headers);
const rollingSummary = summarizeRows(
`近${CONFIG.ROLLING_DAYS}天 ${rollingStartDate} ~ ${apiTodayDate}`,
rolling.rows,
rollingStartDate,
endExclusiveDate
);
return {
fetchedAt: Date.now(),
browserTimeZone: Intl.DateTimeFormat().resolvedOptions().timeZone || '未知',
browserUtcOffset: utcOffsetLabel(apiNowMs),
apiNowMs,
apiTodayDate,
endExclusiveDate,
sinceResetStartDate,
monthStartDate,
rollingStartDate,
windows,
mainWeekly,
mainFiveHour,
weeklyEstimate,
ranges: {
sinceReset: {
label: '上次重置至今',
summary: sinceResetSummary,
rows: sinceReset.rows,
clients: sinceReset.clients,
url: sinceReset.url,
},
monthToDate: {
label: '本月初至今',
summary: monthToDateSummary,
rows: monthToDate.rows,
clients: monthToDate.clients,
url: monthToDate.url,
},
rolling: {
label: `近${CONFIG.ROLLING_DAYS}天`,
summary: rollingSummary,
rows: rolling.rows,
clients: rolling.clients,
url: rolling.url,
},
},
};
}
function addStyles() {
const css = `
#${ROOT_ID} {
--cq-bg: #ffffff;
--cq-header: #ffffff;
--cq-panel: #f9f9f9;
--cq-panel-2: #f4f4f4;
--cq-text: #0d0d0d;
--cq-muted: #676767;
--cq-line: rgba(0,0,0,.1);
--cq-shadow: rgba(0,0,0,.12);
--cq-accent: #10a37f;
--cq-good: #10a37f;
--cq-warn: #d97706;
--cq-bad: #dc2626;
--cq-blue: #2563eb;
--cq-cyan: #0891b2;
position: fixed;
top: 76px;
right: 18px;
z-index: 2147483000;
width: min(460px, calc(100vw - 32px));
max-height: calc(100vh - 96px);
color: var(--cq-text);
font: 13px/1.45 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
color-scheme: light;
}
#${ROOT_ID}[data-cq-theme="dark"] {
--cq-bg: #212121;
--cq-header: #212121;
--cq-panel: #2f2f2f;
--cq-panel-2: #373737;
--cq-text: #ececec;
--cq-muted: #b4b4b4;
--cq-line: rgba(255,255,255,.14);
--cq-shadow: rgba(0,0,0,.32);
--cq-accent: #10a37f;
--cq-good: #10a37f;
--cq-warn: #f59e0b;
--cq-bad: #f87171;
--cq-blue: #60a5fa;
--cq-cyan: #22d3ee;
color-scheme: dark;
}
#${ROOT_ID} * { box-sizing: border-box; letter-spacing: 0; }
#${ROOT_ID}.cq-collapsed { width: auto; }
#${ROOT_ID}.cq-collapsed .cq-body { display: none; }
#${ROOT_ID}.cq-collapsed .cq-shell {
width: auto !important;
height: auto !important;
min-width: 0;
min-height: 0;
resize: none;
}
#${ROOT_ID}.cq-collapsed .cq-header {
min-height: 40px;
border-bottom: 0;
padding: 7px 9px;
}
#${ROOT_ID}.cq-collapsed .cq-title span[data-cq-status],
#${ROOT_ID}.cq-collapsed [data-cq-token-unit],
#${ROOT_ID}.cq-collapsed [data-cq-refresh],
#${ROOT_ID}.cq-collapsed [data-cq-fullscreen] {
display: none;
}
#${ROOT_ID}.cq-collapsed .cq-title strong {
max-width: 230px;
}
#${ROOT_ID}.cq-fullscreen {
inset: 18px;
width: auto !important;
max-height: none;
left: 18px !important;
top: 18px !important;
right: 18px !important;
bottom: 18px !important;
}
#${ROOT_ID}.cq-fullscreen .cq-shell {
height: calc(100vh - 36px);
resize: none;
}
#${ROOT_ID}.cq-fullscreen .cq-body {
max-height: calc(100vh - 92px);
}
#${ROOT_ID}.cq-fullscreen .cq-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
#${ROOT_ID}.cq-fullscreen .cq-card.cq-wide {
grid-column: span 2;
}
#${ROOT_ID}.cq-fullscreen .cq-table-wrap {
max-height: min(52vh, 620px);
}
#${ROOT_ID} .cq-shell {
overflow: hidden;
resize: both;
min-width: 360px;
min-height: 220px;
width: 100%;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 96px);
border: 1px solid var(--cq-line);
border-radius: 8px;
background: var(--cq-bg);
box-shadow: 0 18px 50px var(--cq-shadow);
}
#${ROOT_ID} .cq-header {
display: flex;
align-items: center;
gap: 10px;
min-height: 48px;
padding: 10px 12px;
border-bottom: 1px solid var(--cq-line);
background: var(--cq-header);
cursor: move;
user-select: none;
}
#${ROOT_ID}.cq-fullscreen .cq-header {
cursor: default;
}
#${ROOT_ID} .cq-title {
flex: 1;
min-width: 0;
}
#${ROOT_ID} .cq-title strong {
display: flex;
align-items: center;
gap: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
}
#${ROOT_ID} .cq-version {
flex: 0 0 auto;
border: 1px solid var(--cq-line);
border-radius: 999px;
padding: 1px 6px;
color: var(--cq-muted);
background: var(--cq-panel-2);
font-size: 10px;
font-weight: 600;
}
#${ROOT_ID} .cq-title span {
display: block;
color: var(--cq-muted);
font-size: 11px;
}
#${ROOT_ID} .cq-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
#${ROOT_ID} button {
border: 1px solid var(--cq-line);
border-radius: 6px;
background: var(--cq-panel-2);
color: var(--cq-text);
cursor: pointer;
font: inherit;
}
#${ROOT_ID} button:hover { border-color: rgba(255,255,255,.28); }
#${ROOT_ID} .cq-icon-btn {
display: inline-grid;
place-items: center;
width: 30px;
height: 30px;
padding: 0;
}
#${ROOT_ID} .cq-unit-btn {
min-width: 72px;
height: 30px;
padding: 0 8px;
font-size: 11px;
}
#${ROOT_ID} .cq-unit-btn[data-cq-active="true"] {
border-color: color-mix(in srgb, var(--cq-accent) 45%, var(--cq-line));
background: color-mix(in srgb, var(--cq-accent) 12%, var(--cq-panel-2));
}
#${ROOT_ID} .cq-body {
max-height: calc(100vh - 146px);
overflow: auto;
padding: 12px;
}
#${ROOT_ID} .cq-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
#${ROOT_ID} .cq-card {
border: 1px solid var(--cq-line);
border-radius: 8px;
background: var(--cq-panel);
padding: 10px;
}
#${ROOT_ID} .cq-card.cq-wide { grid-column: 1 / -1; }
#${ROOT_ID} .cq-label {
color: var(--cq-muted);
font-size: 11px;
margin-bottom: 4px;
}
#${ROOT_ID} .cq-value {
font-size: 20px;
font-weight: 700;
}
#${ROOT_ID} .cq-sub {
margin-top: 4px;
color: var(--cq-muted);
font-size: 11px;
}
#${ROOT_ID} .cq-meter {
display: grid;
grid-template-columns: 88px 1fr;
gap: 12px;
align-items: center;
}
#${ROOT_ID} .cq-ring {
width: 88px;
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
place-items: center;
border-radius: 50%;
background:
radial-gradient(circle at center, var(--cq-panel) 0 56%, transparent 57%),
conic-gradient(var(--cq-ring-color) calc(var(--cq-pct) * 1%), rgba(255,255,255,.12) 0);
}
#${ROOT_ID} .cq-ring-label {
color: var(--cq-muted);
font-size: 11px;
font-weight: 700;
line-height: 1;
margin-bottom: 3px;
}
#${ROOT_ID} .cq-ring span {
font-weight: 800;
font-size: 17px;
line-height: 1.05;
}
#${ROOT_ID} .cq-stack { display: grid; gap: 7px; }
#${ROOT_ID} .cq-kv {
display: flex;
justify-content: space-between;
gap: 10px;
border-bottom: 1px solid rgba(255,255,255,.06);
padding-bottom: 6px;
}
#${ROOT_ID} .cq-kv span:first-child { color: var(--cq-muted); }
#${ROOT_ID} .cq-kv span:last-child {
text-align: right;
overflow-wrap: anywhere;
}
#${ROOT_ID} .cq-tabs {
display: flex;
gap: 6px;
margin: 12px 0 8px;
}
#${ROOT_ID} .cq-tab {
flex: 1;
min-width: 0;
padding: 7px 8px;
color: var(--cq-muted);
}
#${ROOT_ID} .cq-tab.cq-active {
background: color-mix(in srgb, var(--cq-accent) 14%, var(--cq-panel-2));
color: var(--cq-text);
border-color: color-mix(in srgb, var(--cq-accent) 45%, var(--cq-line));
}
#${ROOT_ID} .cq-table-wrap {
max-height: 260px;
overflow: auto;
border: 1px solid var(--cq-line);
border-radius: 8px;
background: var(--cq-panel);
}
#${ROOT_ID} table {
width: 100%;
border-collapse: collapse;
min-width: 720px;
font-size: 12px;
}
#${ROOT_ID} th,
#${ROOT_ID} td {
padding: 7px 8px;
border-bottom: 1px solid rgba(255,255,255,.07);
text-align: right;
white-space: nowrap;
}
#${ROOT_ID} th:first-child,
#${ROOT_ID} td:first-child { text-align: left; }
#${ROOT_ID} th {
position: sticky;
top: 0;
background: var(--cq-panel-2);
color: var(--cq-text);
z-index: 1;
}
#${ROOT_ID} .cq-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin: 12px 0 7px;
}
#${ROOT_ID} .cq-section-head strong { font-size: 13px; }
#${ROOT_ID} .cq-section-head button { padding: 5px 8px; font-size: 12px; }
#${ROOT_ID} .cq-error {
border: 1px solid rgba(251,113,133,.42);
border-radius: 8px;
background: rgba(251,113,133,.13);
color: #fecdd3;
padding: 10px;
white-space: pre-wrap;
}
#${ROOT_ID} .cq-loading {
display: flex;
align-items: center;
gap: 10px;
color: var(--cq-muted);
}
#${ROOT_ID} .cq-spinner {
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,.2);
border-top-color: var(--cq-accent);
animation: cq-spin .9s linear infinite;
}
@keyframes cq-spin { to { transform: rotate(360deg); } }
@media (max-width: 720px) {
#${ROOT_ID} {
left: 12px;
right: 12px;
top: 64px;
width: auto;
}
#${ROOT_ID}.cq-fullscreen {
inset: 10px;
left: 10px !important;
top: 10px !important;
right: 10px !important;
bottom: 10px !important;
}
#${ROOT_ID}.cq-fullscreen .cq-shell { height: calc(100vh - 20px); }
#${ROOT_ID} .cq-grid { grid-template-columns: 1fr; }
#${ROOT_ID}.cq-fullscreen .cq-grid { grid-template-columns: 1fr; }
#${ROOT_ID}.cq-fullscreen .cq-card.cq-wide { grid-column: 1 / -1; }
#${ROOT_ID} .cq-meter { grid-template-columns: 76px 1fr; }
#${ROOT_ID} .cq-ring { width: 76px; }
}
`;
if (typeof GM_addStyle === 'function') {
GM_addStyle(css);
return;
}
const style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
}
function getRoot() {
let root = document.getElementById(ROOT_ID);
if (root) return root;
root = document.createElement('div');
root.id = ROOT_ID;
syncTheme(root);
if (localStorage.getItem(STORAGE_KEY) === '1') root.classList.add('cq-collapsed');
if (localStorage.getItem(FULLSCREEN_STORAGE_KEY) === '1') root.classList.add('cq-fullscreen');
root.innerHTML = `
<div class="cq-shell">
<div class="cq-header">
<div class="cq-title">
<strong>Codex Quota Compass <span class="cq-version">v${SCRIPT_VERSION}</span></strong>
<span data-cq-status>准备加载</span>
</div>
<div class="cq-actions">
<button class="cq-unit-btn" type="button" data-cq-token-unit title="切换 Token 显示单位">Token: 原始</button>
<button class="cq-icon-btn" type="button" data-cq-refresh title="刷新">↻</button>
<button class="cq-icon-btn" type="button" data-cq-fullscreen title="全屏/还原">⛶</button>
<button class="cq-icon-btn" type="button" data-cq-collapse title="折叠/展开">▾</button>
</div>
</div>
<div class="cq-body" data-cq-body></div>
</div>
`;
document.documentElement.appendChild(root);
if (!root.classList.contains('cq-fullscreen')) applySavedPosition(root);
new MutationObserver(() => syncTheme(root)).observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'style', 'data-theme'],
});
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => syncTheme(root));
root.querySelector('[data-cq-token-unit]').addEventListener('click', () => {
const unit = getTokenUnit();
const next = unit === 'raw' ? 'm' : unit === 'm' ? 'yi' : 'raw';
localStorage.setItem(TOKEN_UNIT_STORAGE_KEY, next);
updateTokenUnitButton(root);
if (root.__cqData) render(root, root.__cqData);
});
updateTokenUnitButton(root);
root.querySelector('[data-cq-refresh]').addEventListener('click', () => refresh(root));
root.querySelector('[data-cq-fullscreen]').addEventListener('click', () => {
root.classList.toggle('cq-fullscreen');
if (root.classList.contains('cq-fullscreen')) {
root.classList.remove('cq-collapsed');
localStorage.setItem(STORAGE_KEY, '0');
} else {
applySavedPosition(root);
}
localStorage.setItem(FULLSCREEN_STORAGE_KEY, root.classList.contains('cq-fullscreen') ? '1' : '0');
updateFullscreenButton(root);
});
updateFullscreenButton(root);
root.querySelector('[data-cq-collapse]').addEventListener('click', () => {
root.classList.toggle('cq-collapsed');
if (root.classList.contains('cq-collapsed')) {
root.classList.remove('cq-fullscreen');
localStorage.setItem(FULLSCREEN_STORAGE_KEY, '0');
updateFullscreenButton(root);
}
localStorage.setItem(STORAGE_KEY, root.classList.contains('cq-collapsed') ? '1' : '0');
});
setupDrag(root);
return root;
}
function setupDrag(root) {
const header = root.querySelector('.cq-header');
if (!header) return;
let dragging = false;
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
const onMove = (event) => {
if (!dragging) return;
const rect = root.getBoundingClientRect();
const nextLeft = clamp(startLeft + event.clientX - startX, 4, window.innerWidth - Math.min(80, rect.width));
const nextTop = clamp(startTop + event.clientY - startY, 4, window.innerHeight - Math.min(44, rect.height));
root.style.left = `${nextLeft}px`;
root.style.top = `${nextTop}px`;
root.style.right = 'auto';
root.style.bottom = 'auto';
};
const onUp = () => {
if (!dragging) return;
dragging = false;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
root.classList.remove('cq-dragging');
saveRootPosition(root);
};
header.addEventListener('mousedown', (event) => {
if (event.button !== 0) return;
if (root.classList.contains('cq-fullscreen')) return;
if (event.target.closest('button, input, select, textarea, a')) return;
const rect = root.getBoundingClientRect();
dragging = true;
startX = event.clientX;
startY = event.clientY;
startLeft = rect.left;
startTop = rect.top;
root.style.left = `${rect.left}px`;
root.style.top = `${rect.top}px`;
root.style.right = 'auto';
root.style.bottom = 'auto';
root.classList.add('cq-dragging');
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
event.preventDefault();
});
window.addEventListener('resize', () => {
if (root.classList.contains('cq-fullscreen')) return;
applySavedPosition(root);
saveRootPosition(root);
});
}
function updateTokenUnitButton(root) {
const button = root.querySelector('[data-cq-token-unit]');
if (!button) return;
const unit = getTokenUnit();
button.textContent =
unit === 'm' ? 'Token: M' : unit === 'yi' ? 'Token: 亿' : 'Token: 原始';
button.dataset.cqActive = String(unit !== 'raw');
}
function updateFullscreenButton(root) {
const button = root.querySelector('[data-cq-fullscreen]');
if (!button) return;
const isFullscreen = root.classList.contains('cq-fullscreen');
button.textContent = isFullscreen ? '↙' : '⛶';
button.title = isFullscreen ? '还原面板' : '全屏面板';
}
function setStatus(root, text) {
root.querySelector('[data-cq-status]').textContent = text;
}
function usageColor(percent) {
if (percent >= 85) return 'var(--cq-bad)';
if (percent >= 65) return 'var(--cq-warn)';
return 'var(--cq-good)';
}
function renderWindowMeter(windowRow) {
if (!windowRow) return '';
const pct = Math.max(0, Math.min(100, n(windowRow.usedPercent)));
return `
<div class="cq-card cq-wide">
<div class="cq-meter">
<div class="cq-ring" style="--cq-pct:${pct};--cq-ring-color:${usageColor(pct)}">
<div class="cq-ring-label">已用</div>
<span>${fmtPct(pct)}</span>
</div>
<div class="cq-stack">
<div>
<div class="cq-label">${escapeHtml(windowRow.name)}</div>
<div class="cq-value">${fmtNum(100 - pct, 2)}% 剩余</div>
</div>
<div class="cq-kv"><span>下次重置</span><span>${escapeHtml(windowRow.resetAtLocal)}</span></div>
<div class="cq-kv"><span>距离重置</span><span>${fmtNum(windowRow.resetAfterHours, 2)} 小时</span></div>
</div>
</div>
</div>
`;
}
function renderSummaryCards(data) {
const additionalWindows = data.windows.filter((windowRow) => windowRow.scope === 'additional');
const summaryWindows = [data.mainWeekly, data.mainFiveHour, ...additionalWindows].filter(Boolean);
const estimate = data.weeklyEstimate;
const rolling = data.ranges.rolling.summary;
return `
<div class="cq-grid">
${summaryWindows.map(renderWindowMeter).join('')}
<div class="cq-card">
<div class="cq-label">近${CONFIG.ROLLING_DAYS}天 Token</div>
<div class="cq-value">${fmtTokensLong(rolling.tokens)}</div>
<div class="cq-sub">${fmtTokenUnitHint(rolling.tokens) ? `${fmtTokenUnitHint(rolling.tokens)} · ` : ''}${fmtInt(rolling.turns)} 轮 · ${fmtInt(rolling.threads)} 线程</div>
</div>
<div class="cq-card">
<div class="cq-label">反推周额度</div>
<div class="cq-value">${fmtNum(estimate.totalCreditsWithoutResetDay ?? 0, 2)}</div>
<div class="cq-sub">排除重置日口径 Credits</div>
</div>
<div class="cq-card">
<div class="cq-label">估算剩余额度</div>
<div class="cq-value">${fmtNum(estimate.remainingCreditsWithoutResetDay ?? 0, 2)}</div>
<div class="cq-sub">约 $${fmtNum(estimate.remainingUsdWithoutResetDay ?? 0, 2)}</div>
</div>
<div class="cq-card">
<div class="cq-label">日期桶</div>
<div class="cq-value">${escapeHtml(data.apiTodayDate)}</div>
<div class="cq-sub">${escapeHtml(data.browserTimeZone)} · ${escapeHtml(data.browserUtcOffset)}</div>
</div>
</div>
`;
}
function table(headers, rows) {
return `
<div class="cq-table-wrap">
<table>
<thead><tr>${headers.map((h) => `<th>${escapeHtml(h.label)}</th>`).join('')}</tr></thead>
<tbody>
${rows.map((row) => `
<tr>
${headers.map((h) => `<td>${escapeHtml(h.format ? h.format(row[h.key], row) : row[h.key])}</td>`).join('')}
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
}
function rowsToCsv(rows) {
if (!rows.length) return '';
const keys = Object.keys(rows[0]);
const cell = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
return [keys.map(cell).join(','), ...rows.map((r) => keys.map((k) => cell(r[k])).join(','))].join('\n');
}
function copyCsv(rows, button) {
navigator.clipboard.writeText(rowsToCsv(rows)).then(() => {
const old = button.textContent;
button.textContent = '已复制';
window.setTimeout(() => { button.textContent = old; }, 1200);
});
}
function renderRange(root, data, key) {
const range = data.ranges[key];
root.dataset.cqRange = key;
root.querySelectorAll('.cq-tab').forEach((btn) => {
btn.classList.toggle('cq-active', btn.dataset.cqRange === key);
});
const summaryTarget = root.querySelector('[data-cq-range-summary]');
const rowsTarget = root.querySelector('[data-cq-range-rows]');
const clientsTarget = root.querySelector('[data-cq-range-clients]');
summaryTarget.innerHTML = `
<div class="cq-grid">
<div class="cq-card"><div class="cq-label">范围</div><div class="cq-value" style="font-size:15px">${escapeHtml(range.summary.range)}</div><div class="cq-sub">${range.summary.dateBuckets} 个日期桶</div></div>
<div class="cq-card"><div class="cq-label">累计 Token</div><div class="cq-value">${fmtTokensLong(range.summary.tokens)}</div><div class="cq-sub">${fmtTokenUnitHint(range.summary.tokens) ? `${fmtTokenUnitHint(range.summary.tokens)} · ` : ''}${fmtInt(range.summary.turns)} 轮 · ${fmtInt(range.summary.threads)} 线程</div></div>
<div class="cq-card"><div class="cq-label">累计 Credits</div><div class="cq-value">${fmtNum(range.summary.credits, 6)}</div><div class="cq-sub">约 $${fmtNum(range.summary.usd, 2)}</div></div>
<div class="cq-card"><div class="cq-label">API 日期</div><div class="cq-value" style="font-size:15px">${escapeHtml(range.summary.startDate)} → ${escapeHtml(range.summary.endExclusiveDate)}</div><div class="cq-sub">end_date 为排他边界</div></div>
</div>
`;
rowsTarget.innerHTML = table([
{ key: 'date', label: '日期桶' },
{ key: 'tokens', label: tokenColumnLabel('Token'), format: fmtTokensCompact },
{ key: 'cachedInputTokens', label: tokenColumnLabel('缓存输入'), format: fmtTokensCompact },
{ key: 'uncachedInputTokens', label: tokenColumnLabel('非缓存输入'), format: fmtTokensCompact },
{ key: 'outputTokens', label: tokenColumnLabel('输出'), format: fmtTokensCompact },
{ key: 'turns', label: '轮数', format: fmtInt },
{ key: 'threads', label: '线程', format: fmtInt },
{ key: 'credits', label: 'Credits', format: (v) => fmtNum(v, 6) },
{ key: 'clientCredits', label: '客户端Credits' },
], range.rows);
clientsTarget.innerHTML = table([
{ key: 'client', label: '客户端' },
{ key: 'tokens', label: tokenColumnLabel('Token'), format: fmtTokensCompact },
{ key: 'cachedInputTokens', label: tokenColumnLabel('缓存输入'), format: fmtTokensCompact },
{ key: 'uncachedInputTokens', label: tokenColumnLabel('非缓存输入'), format: fmtTokensCompact },
{ key: 'outputTokens', label: tokenColumnLabel('输出'), format: fmtTokensCompact },
{ key: 'turns', label: '轮数', format: fmtInt },
{ key: 'threads', label: '线程', format: fmtInt },
{ key: 'credits', label: 'Credits', format: (v) => fmtNum(v, 6) },
], range.clients);
root.querySelector('[data-cq-copy-days]').onclick = (event) => copyCsv(range.rows, event.currentTarget);
root.querySelector('[data-cq-copy-clients]').onclick = (event) => copyCsv(range.clients, event.currentTarget);
}
function render(root, data) {
root.__cqData = data;
updateTokenUnitButton(root);
const activeRange = data.ranges[root.dataset.cqRange] ? root.dataset.cqRange : 'sinceReset';
const body = root.querySelector('[data-cq-body]');
body.innerHTML = `
${renderSummaryCards(data)}
<div class="cq-section-head">
<strong>用量区间</strong>
</div>
<div class="cq-tabs">
${Object.entries(data.ranges).map(([key, range]) => `
<button class="cq-tab" type="button" data-cq-range="${key}">${escapeHtml(range.label)}</button>
`).join('')}
</div>
<div data-cq-range-summary></div>
<div class="cq-section-head">
<strong>每日明细</strong>
<button type="button" data-cq-copy-days>复制 CSV</button>
</div>
<div data-cq-range-rows></div>
<div class="cq-section-head">
<strong>客户端汇总</strong>
<button type="button" data-cq-copy-clients>复制 CSV</button>
</div>
<div data-cq-range-clients></div>
<div class="cq-sub" style="margin-top:10px">
数据来自 ChatGPT 当前登录会话;脚本不展示、不保存 accessToken 或 Cookie。
</div>
`;
root.querySelectorAll('.cq-tab').forEach((btn) => {
btn.addEventListener('click', () => renderRange(root, data, btn.dataset.cqRange));
});
renderRange(root, data, activeRange);
setStatus(root, `已更新:${new Date(data.fetchedAt).toLocaleTimeString()}`);
}
async function refresh(root) {
const body = root.querySelector('[data-cq-body]');
setStatus(root, '加载中');
body.innerHTML = '<div class="cq-loading"><span class="cq-spinner"></span><span>正在读取 Codex analytics...</span></div>';
try {
const data = await loadCompassData();
render(root, data);
} catch (err) {
setStatus(root, '加载失败');
body.innerHTML = `<div class="cq-error">${escapeHtml(err?.message || err)}</div>`;
}
}
function init() {
if (document.getElementById(ROOT_ID)) return;
addStyles();
const root = getRoot();
refresh(root);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();