分享一个官号 Codex 用量可视化油猴脚本

2026-05-29 17:08 1

最近写了一个用于 ChatGPT Codex analytics 页面的油猴脚本,致谢参考脚本:完全准确查询你的GPT订阅周限的方法


插件地址:



主要功能:



  • 在 Codex analytics 页面直接显示 5 小时 / 7 天窗口用量

  • 显示已用百分比、剩余比例、下次重置时间

  • 支持上次重置至今、本月初至今、近 30 天统计

  • 支持每日 Token 明细和客户端汇总

  • Token 单位支持原始值、M、亿切换

  • 支持表格 CSV 复制

  • 面板可折叠、可调整大小,也支持全屏查看宽表格

  • 主题配色会跟随 ChatGPT 官网浅色/深色模式

  • 脚本不会展示或保存 accessToken / Cookie,只使用当前登录会话请求官方 analytics 接口


适用页面:


https://chatgpt.com/codex/cloud/settings/analytics


如果你也经常看 Codex 用量,多人共用一个官号没经过sub2、cpa2中转站想查看具体总用量,或者觉得官方 analytics 页面信息不够直观,可以试试看。





Tips:claude桌宠是codex的宠物

最新回复 (9)
  • zding 05-29 17:18
    2



    天才程序员要死了,熬不到重置

  • 楼主 05-29 17:24
    3

    我有预感还会重置 ^-^ 4.8出来了应该不会一个屁也不放

  • An3ingLora 05-29 17:25
    4



    为啥我没显示?是 team 的问题吗?

  • yuhuangxian 05-29 17:30
    5

    这个如果可以直接用在chatgpt.com聊天页面就好了,我自用的号一般就手动访问cloud/settings/analytics看。

  • lueluelue 05-29 17:31
    6

    凌晨 1 点感觉有可能会重置,毕竟有一个 Codex 大更新

  • An3ingLora 06-01 09:11
    7

    佬我看了下 console,除了一些 i18n 报错就剩这个:

  • BreakALeg 06-07 10:21
    8

    针对佬的脚本让gpt优化了下,

    主要修改:



    • 版本改为 0.1.9-fixed

    • 移除第三方自动更新地址,防止修复版被远程版本覆盖

    • 优先使用 Cookie 鉴权,仅在 401 时读取 Access Token

    • 严格校验额度窗口字段,避免错误查询到 1970 年

    • 三个 analytics 请求改为并发

    • 刷新时取消旧请求,避免旧数据覆盖新数据

    • 低于 1% 使用率时不再输出不可靠的周额度估算

    • 修复透明背景导致的主题误判

    • 增加 localStorage、剪贴板和 HTTP 错误处理

    • HTTP 错误不再把服务器响应正文直接显示在页面上


    // ==UserScript==
    // @name Codex Quota Compass UI - Fixed
    // @name:zh-CN Codex 用量指南针 UI - 修复版
    // @namespace https://chatgpt.com/codex
    // @version 0.2.0
    // @description 显示 Codex 用量、重置时间、每日统计和客户端汇总
    // @author creamtea47 / fixed
    // @license MIT
    // @match https://chatgpt.com/*
    // @run-at document-idle
    // @grant GM_addStyle
    // ==/UserScript==

    (() => {
    'use strict';

    const CONFIG = Object.freeze({
    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 VERSION = '0.2.0';

    const KEYS = Object.freeze({
    collapsed: `${ROOT_ID}:collapsed`,
    fullscreen: `${ROOT_ID}:fullscreen`,
    position: `${ROOT_ID}:position`,
    tokenUnit: `${ROOT_ID}:token-unit`,
    });

    const DAY_MS = 86_400_000;

    let activeController = null;
    let requestSerial = 0;

    class HttpError extends Error {
    constructor(response, path) {
    super(`HTTP ${response.status} ${response.statusText}`);
    this.name = 'HttpError';
    this.status = response.status;
    this.path = path;
    }
    }

    function safeGet(key) {
    try {
    return localStorage.getItem(key);
    } catch {
    return null;
    }
    }

    function safeSet(key, value) {
    try {
    localStorage.setItem(key, value);
    } catch {
    // UI 偏好保存失败不影响主功能。
    }
    }

    function toNumber(value, fallback = 0) {
    if (value === null || value === undefined || value === '') {
    return fallback;
    }

    const number = Number(value);
    return Number.isFinite(number) ? number : fallback;
    }

    function requireNumber(value, field) {
    if (value === null || value === undefined || value === '') {
    throw new Error(`接口字段 ${field} 缺失`);
    }

    const number = Number(value);

    if (!Number.isFinite(number)) {
    throw new Error(`接口字段 ${field} 不是有效数字`);
    }

    return number;
    }

    function round(value, digits = 2) {
    return Number(toNumber(value).toFixed(digits));
    }

    function clamp(value, min, max) {
    if (!Number.isFinite(value)) return min;
    return Math.min(Math.max(value, min), Math.max(min, max));
    }

    function escapeHtml(value) {
    return String(value ?? '').replace(
    /[&<>"']/g,
    character =>
    ({
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#039;',
    })[character]
    );
    }

    function pad2(value) {
    return String(value).padStart(2, '0');
    }

    function formatInteger(value) {
    return new Intl.NumberFormat().format(toNumber(value));
    }

    function formatNumber(value, digits = 2) {
    return new Intl.NumberFormat(undefined, {
    minimumFractionDigits: 0,
    maximumFractionDigits: digits,
    }).format(toNumber(value));
    }

    function formatOptional(value, digits = 2) {
    return Number.isFinite(value) ? formatNumber(value, digits) : '—';
    }

    function formatPercent(value) {
    return `${formatNumber(value, 2)}%`;
    }

    function formatLocal(ms) {
    const date = new Date(ms);
    return Number.isFinite(date.getTime()) ? date.toLocaleString() : '—';
    }

    function formatUtc(ms) {
    const date = new Date(ms);

    if (!Number.isFinite(date.getTime())) {
    return '—';
    }

    return date
    .toISOString()
    .replace('T', ' ')
    .replace(/\.\d{3}Z$/, ' UTC');
    }

    function getTokenUnit() {
    const value = safeGet(KEYS.tokenUnit);
    return value === 'm' || value === 'yi' ? value : 'raw';
    }

    function formatTokens(value) {
    const tokens = toNumber(value);
    const unit = getTokenUnit();

    if (unit === 'm') {
    return `${formatNumber(tokens / 1_000_000, 2)}M`;
    }

    if (unit === 'yi') {
    return `${formatNumber(tokens / 100_000_000, 2)}亿`;
    }

    return formatInteger(tokens);
    }

    function tokenLabel(label) {
    const unit = getTokenUnit();

    if (unit === 'm') return `${label} (M)`;
    if (unit === 'yi') return `${label} (亿)`;

    return label;
    }

    function tokenHint(value) {
    const unit = getTokenUnit();

    if (unit === 'm') {
    return `≈ ${formatNumber(toNumber(value) / 10_000, 2)} 万 tokens`;
    }

    if (unit === 'yi') {
    return `≈ ${formatNumber(
    toNumber(value) / 100_000_000,
    2
    )} 亿 tokens`;
    }

    return '';
    }

    function tokenTotal(object = {}) {
    const explicitTotal = Number(object.text_total_tokens);

    if (Number.isFinite(explicitTotal)) {
    return explicitTotal;
    }

    return (
    toNumber(object.cached_text_input_tokens) +
    toNumber(object.uncached_text_input_tokens) +
    toNumber(object.text_output_tokens)
    );
    }

    function ymdUtc(ms) {
    const date = new Date(ms);

    return [
    date.getUTCFullYear(),
    pad2(date.getUTCMonth() + 1),
    pad2(date.getUTCDate()),
    ].join('-');
    }

    function ymdLocal(ms) {
    const date = new Date(ms);
    date.setMinutes(date.getMinutes() - date.getTimezoneOffset());
    return date.toISOString().slice(0, 10);
    }

    function ymdForApi(ms) {
    return CONFIG.DATE_BUCKET_MODE === 'utc'
    ? ymdUtc(ms)
    : ymdLocal(ms);
    }

    function addDays(ms, days) {
    if (CONFIG.DATE_BUCKET_MODE === 'utc') {
    return ms + days * DAY_MS;
    }

    const date = new Date(ms);
    date.setDate(date.getDate() + days);
    return date.getTime();
    }

    function firstDayOfMonth(ms) {
    const date = new Date(ms);

    if (CONFIG.DATE_BUCKET_MODE === 'utc') {
    return `${date.getUTCFullYear()}-${pad2(
    date.getUTCMonth() + 1
    )}-01`;
    }

    return ymdLocal(
    new Date(date.getFullYear(), date.getMonth(), 1).getTime()
    );
    }

    function utcOffsetLabel(ms) {
    const offsetMinutes = -new Date(ms).getTimezoneOffset();
    const sign = offsetMinutes >= 0 ? '+' : '-';
    const absolute = Math.abs(offsetMinutes);

    return (
    `UTC${sign}` +
    `${pad2(Math.floor(absolute / 60))}:` +
    `${pad2(absolute % 60)}`
    );
    }

    function looksLikeJwt(value) {
    return (
    typeof value === 'string' &&
    value.length > 100 &&
    value.split('.').length >= 3
    );
    }

    function findAccessToken(value, depth = 0, visited = new WeakSet()) {
    if (
    !value ||
    typeof value !== 'object' ||
    depth > 8 ||
    visited.has(value)
    ) {
    return '';
    }

    visited.add(value);

    for (const [key, child] of Object.entries(value)) {
    if (
    typeof child === 'string' &&
    /access/i.test(key) &&
    looksLikeJwt(child)
    ) {
    return child;
    }

    if (child && typeof child === 'object') {
    const found = findAccessToken(child, depth + 1, visited);
    if (found) return found;
    }
    }

    return '';
    }

    async function getAccessToken(signal) {
    try {
    const response = await fetch('/api/auth/session', {
    credentials: 'include',
    headers: {
    accept: 'application/json',
    },
    signal,
    });

    if (!response.ok) return '';

    return findAccessToken(await response.json());
    } catch (error) {
    if (error?.name === 'AbortError') throw error;
    return '';
    }
    }

    async function apiGet(path, headers, signal) {
    const response = await fetch(path, {
    method: 'GET',
    credentials: 'include',
    headers,
    signal,
    });

    if (!response.ok) {
    throw new HttpError(response, path);
    }

    const contentType = response.headers.get('content-type') || '';

    if (!contentType.includes('application/json')) {
    throw new Error(`接口没有返回 JSON:${path}`);
    }

    return response.json();
    }

    function parseWindow(label, windowData) {
    const usedPercent = requireNumber(
    windowData?.used_percent,
    `${label}.used_percent`
    );

    const windowSeconds = requireNumber(
    windowData?.limit_window_seconds,
    `${label}.limit_window_seconds`
    );

    const resetAfterSeconds = requireNumber(
    windowData?.reset_after_seconds,
    `${label}.reset_after_seconds`
    );

    const resetAtSeconds = requireNumber(
    windowData?.reset_at,
    `${label}.reset_at`
    );

    if (
    usedPercent < 0 ||
    usedPercent > 100 ||
    windowSeconds <= 0 ||
    resetAfterSeconds < 0 ||
    resetAtSeconds <= 0
    ) {
    throw new Error(`${label} 返回了不合理的窗口数据`);
    }

    const resetAtMs = resetAtSeconds * 1000;
    const windowStartMs = resetAtMs - windowSeconds * 1000;
    const serverNowMs = resetAtMs - resetAfterSeconds * 1000;

    const earliestReasonable = Date.UTC(2020, 0, 1);
    const latestReasonable = Date.now() + 2 * DAY_MS;

    if (
    serverNowMs < earliestReasonable ||
    serverNowMs > latestReasonable
    ) {
    throw new Error(`${label} 返回了异常的服务器时间`);
    }

    return {
    name: label,
    usedPercent,
    usedRatio: usedPercent / 100,
    windowSeconds,
    windowDays: round(windowSeconds / 86_400, 4),
    windowStartMs,
    resetAtMs,
    serverNowMs,
    resetAfterSeconds,
    resetAfterHours: round(resetAfterSeconds / 3600, 2),
    windowStartUtc: formatUtc(windowStartMs),
    windowStartLocal: formatLocal(windowStartMs),
    resetAtUtc: formatUtc(resetAtMs),
    resetAtLocal: formatLocal(resetAtMs),
    serverNowUtc: formatUtc(serverNowMs),
    serverNowLocal: formatLocal(serverNowMs),
    };
    }

    function collectWindows(usage) {
    const windows = [];

    if (usage?.rate_limit?.primary_window) {
    windows.push(
    parseWindow(
    '主限制 - 5小时窗口',
    usage.rate_limit.primary_window
    )
    );
    }

    if (usage?.rate_limit?.secondary_window) {
    windows.push(
    parseWindow(
    '主限制 - 7天窗口',
    usage.rate_limit.secondary_window
    )
    );
    }

    for (const item of usage?.additional_rate_limits ?? []) {
    const name =
    item.limit_name ||
    item.metered_feature ||
    '额外限制';

    if (item?.rate_limit?.primary_window) {
    windows.push(
    parseWindow(
    `${name} - 5小时窗口`,
    item.rate_limit.primary_window
    )
    );
    }

    if (item?.rate_limit?.secondary_window) {
    windows.push(
    parseWindow(
    `${name} - 7天窗口`,
    item.rate_limit.secondary_window
    )
    );
    }
    }

    return windows;
    }

    function parseDailyRows(json) {
    const data = Array.isArray(json?.data) ? json.data : [];

    return data
    .slice()
    .sort((a, b) =>
    String(a?.date ?? '').localeCompare(String(b?.date ?? ''))
    )
    .map(day => {
    const totals = day?.totals ?? {};
    const credits = toNumber(totals.credits);
    const clients = Array.isArray(day?.clients)
    ? day.clients
    : [];

    return {
    date: day?.date ?? '',
    credits: round(credits, 6),
    usd: round(credits * CONFIG.USD_PER_CREDIT, 2),
    users: toNumber(totals.users),
    threads: toNumber(totals.threads),
    turns: toNumber(totals.turns),
    tokens: tokenTotal(totals),
    cachedInputTokens: toNumber(
    totals.cached_text_input_tokens
    ),
    uncachedInputTokens: toNumber(
    totals.uncached_text_input_tokens
    ),
    outputTokens: toNumber(totals.text_output_tokens),
    clientCount: clients.length,
    clientCredits: clients
    .map(client => {
    const id = client?.client_id ?? 'UNKNOWN';
    return `${id}:${round(
    toNumber(client?.credits),
    2
    )}`;
    })
    .join(' | '),
    };
    });
    }

    function summarizeClients(json) {
    const map = new Map();
    const data = Array.isArray(json?.data) ? json.data : [];

    for (const day of data) {
    const clients = Array.isArray(day?.clients)
    ? day.clients
    : [];

    for (const client of clients) {
    const id = client?.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 = toNumber(client?.credits);

    row.credits += credits;
    row.usd += credits * CONFIG.USD_PER_CREDIT;
    row.threads += toNumber(client?.threads);
    row.turns += toNumber(client?.turns);
    row.tokens += tokenTotal(client);
    row.cachedInputTokens += toNumber(
    client?.cached_text_input_tokens
    );
    row.uncachedInputTokens += toNumber(
    client?.uncached_text_input_tokens
    );
    row.outputTokens += toNumber(
    client?.text_output_tokens
    );

    map.set(id, row);
    }
    }

    return [...map.values()]
    .map(row => ({
    ...row,
    credits: round(row.credits, 6),
    usd: round(row.usd, 2),
    }))
    .sort(
    (a, b) =>
    b.tokens - a.tokens ||
    b.credits - a.credits
    );
    }

    async function fetchDailyUsage(
    startDate,
    endExclusiveDate,
    headers,
    signal
    ) {
    const query = new URLSearchParams({
    start_date: startDate,
    end_date: endExclusiveDate,
    group_by: 'day',
    });

    const path = `${CONFIG.DAILY_USAGE_PATH}?${query}`;
    const json = await apiGet(path, headers, signal);

    return {
    url: location.origin + path,
    rows: parseDailyRows(json),
    clients: summarizeClients(json),
    };
    }

    function summarizeRows(
    rangeName,
    rows,
    startDate,
    endExclusiveDate
    ) {
    const credits = rows.reduce(
    (sum, row) => sum + toNumber(row.credits),
    0
    );

    return {
    range: rangeName,
    bucketMode:
    CONFIG.DATE_BUCKET_MODE === 'utc'
    ? 'UTC日期桶'
    : '本地日期桶',
    startDate,
    endExclusiveDate,
    dateBuckets: rows.length,
    firstDate: rows[0]?.date ?? '',
    lastDate: rows.at(-1)?.date ?? '',
    credits: round(credits, 6),
    usd: round(credits * CONFIG.USD_PER_CREDIT, 2),
    tokens: rows.reduce(
    (sum, row) => sum + toNumber(row.tokens),
    0
    ),
    threads: rows.reduce(
    (sum, row) => sum + toNumber(row.threads),
    0
    ),
    turns: rows.reduce(
    (sum, row) => sum + toNumber(row.turns),
    0
    ),
    };
    }

    function buildWeeklyEstimate({
    mainSecondary,
    sinceResetRows,
    sinceResetSummary,
    sinceResetStartDate,
    }) {
    const usedPercent = toNumber(mainSecondary.usedPercent);
    const usedRatio = usedPercent / 100;

    if (usedRatio < 0.01 || usedRatio > 1) {
    return {
    basis: '主限制 - 7天窗口',
    usedPercent,
    note:
    usedRatio <= 0
    ? '当前已用比例为 0,无法反推额度'
    : '当前已用比例低于 1% 或数据异常,估算误差过大',
    };
    }

    const includedCredits = toNumber(
    sinceResetSummary.credits
    );

    const resetDay = sinceResetRows.find(
    row => row.date === sinceResetStartDate
    );

    const resetDayCredits = toNumber(resetDay?.credits);

    const excludedCredits = Math.max(
    0,
    includedCredits - resetDayCredits
    );

    const totalWithResetDay =
    includedCredits / usedRatio;

    const totalWithoutResetDay =
    excludedCredits / usedRatio;

    const remainingWithResetDay = Math.max(
    0,
    totalWithResetDay - includedCredits
    );

    const remainingWithoutResetDay = Math.max(
    0,
    totalWithoutResetDay - excludedCredits
    );

    return {
    basis: '主限制 - 7天窗口',
    usedPercent,
    usedRatio: round(usedRatio, 4),
    remainingRatio: round(1 - usedRatio, 4),

    includedCredits: round(includedCredits, 6),
    resetDayCredits: round(resetDayCredits, 6),
    excludedCredits: round(excludedCredits, 6),

    totalCreditsWithResetDay: round(
    totalWithResetDay,
    2
    ),
    totalCreditsWithoutResetDay: round(
    totalWithoutResetDay,
    2
    ),

    remainingCreditsWithResetDay: round(
    remainingWithResetDay,
    2
    ),
    remainingCreditsWithoutResetDay: round(
    remainingWithoutResetDay,
    2
    ),

    remainingUsdWithoutResetDay: round(
    remainingWithoutResetDay *
    CONFIG.USD_PER_CREDIT,
    2
    ),

    note:
    '每日统计只能按天聚合,实际结果通常处于包含和排除重置日两种估算之间。',
    };
    }

    async function fetchCompassDataWithHeaders(
    headers,
    signal
    ) {
    const usage = await apiGet(
    CONFIG.USAGE_PATH,
    headers,
    signal
    );

    if (!usage?.rate_limit?.secondary_window) {
    throw new Error(
    '没有找到 rate_limit.secondary_window,接口结构可能已经变化'
    );
    }

    const windows = collectWindows(usage);

    const mainSecondary = parseWindow(
    '主限制 - 7天窗口',
    usage.rate_limit.secondary_window
    );

    const apiNowMs = mainSecondary.serverNowMs;

    const apiTodayDate = ymdForApi(apiNowMs);
    const endExclusiveDate = ymdForApi(
    addDays(apiNowMs, 1)
    );

    const sinceResetStartDate = ymdForApi(
    mainSecondary.windowStartMs
    );

    const monthStartDate = firstDayOfMonth(apiNowMs);

    const rollingStartDate = ymdForApi(
    addDays(apiNowMs, -(CONFIG.ROLLING_DAYS - 1))
    );

    const [
    sinceReset,
    monthToDate,
    rolling,
    ] = await Promise.all([
    fetchDailyUsage(
    sinceResetStartDate,
    endExclusiveDate,
    headers,
    signal
    ),
    fetchDailyUsage(
    monthStartDate,
    endExclusiveDate,
    headers,
    signal
    ),
    fetchDailyUsage(
    rollingStartDate,
    endExclusiveDate,
    headers,
    signal
    ),
    ]);

    const sinceResetSummary = summarizeRows(
    `上次重置至今 ${sinceResetStartDate} ~ ${apiTodayDate}`,
    sinceReset.rows,
    sinceResetStartDate,
    endExclusiveDate
    );

    const monthToDateSummary = summarizeRows(
    `本月初至今 ${monthStartDate} ~ ${apiTodayDate}`,
    monthToDate.rows,
    monthStartDate,
    endExclusiveDate
    );

    const rollingSummary = summarizeRows(
    `近${CONFIG.ROLLING_DAYS}天 ${rollingStartDate} ~ ${apiTodayDate}`,
    rolling.rows,
    rollingStartDate,
    endExclusiveDate
    );

    const weeklyEstimate = buildWeeklyEstimate({
    mainSecondary,
    sinceResetRows: sinceReset.rows,
    sinceResetSummary,
    sinceResetStartDate,
    });

    return {
    fetchedAt: Date.now(),
    browserTimeZone:
    Intl.DateTimeFormat().resolvedOptions()
    .timeZone || '未知',
    browserUtcOffset: utcOffsetLabel(apiNowMs),
    apiTodayDate,
    windows,
    mainSecondary,
    weeklyEstimate,

    ranges: {
    sinceReset: {
    label: '上次重置至今',
    summary: sinceResetSummary,
    rows: sinceReset.rows,
    clients: sinceReset.clients,
    },

    monthToDate: {
    label: '本月初至今',
    summary: monthToDateSummary,
    rows: monthToDate.rows,
    clients: monthToDate.clients,
    },

    rolling: {
    label: `近${CONFIG.ROLLING_DAYS}天`,
    summary: rollingSummary,
    rows: rolling.rows,
    clients: rolling.clients,
    },
    },
    };
    }

    async function loadCompassData(signal) {
    const cookieHeaders = {
    accept: 'application/json',
    };

    try {
    return await fetchCompassDataWithHeaders(
    cookieHeaders,
    signal
    );
    } catch (error) {
    if (error?.name === 'AbortError') throw error;
    if (!(error instanceof HttpError) || error.status !== 401) {
    throw error;
    }
    }

    const accessToken = await getAccessToken(signal);

    if (!accessToken) {
    throw new Error(
    '没有获得有效授权,请确认当前浏览器已经登录 chatgpt.com'
    );
    }

    return fetchCompassDataWithHeaders(
    {
    accept: 'application/json',
    authorization: `Bearer ${accessToken}`,
    },
    signal
    );
    }

    function parseRgb(color) {
    const match = String(color || '').match(
    /rgba?\(([^)]+)\)/i
    );

    if (!match) return null;

    const values = match[1]
    .split(',')
    .map(value => Number.parseFloat(value.trim()));

    const [r, g, b, alpha = 1] = values;

    if (
    ![r, g, b, alpha].every(Number.isFinite) ||
    alpha <= 0.01
    ) {
    return null;
    }

    return { r, g, b };
    }

    function luminance({ r, g, b }) {
    const channel = value => {
    const normalized = value / 255;

    return normalized <= 0.03928
    ? normalized / 12.92
    : ((normalized + 0.055) / 1.055) ** 2.4;
    };

    return (
    0.2126 * channel(r) +
    0.7152 * channel(g) +
    0.0722 * channel(b)
    );
    }

    function detectTheme() {
    const bodyBackground =
    getComputedStyle(document.body).backgroundColor;

    const rootBackground =
    getComputedStyle(
    document.documentElement
    ).backgroundColor;

    const rgb =
    parseRgb(bodyBackground) ||
    parseRgb(rootBackground);

    if (rgb) {
    return luminance(rgb) < 0.45
    ? 'dark'
    : 'light';
    }

    return matchMedia(
    '(prefers-color-scheme: dark)'
    ).matches
    ? 'dark'
    : 'light';
    }

    function syncTheme(root) {
    root.dataset.theme = detectTheme();
    }

    function addStyles() {
    const css = `
    #${ROOT_ID} {
    --bg: #fff;
    --panel: #f7f7f8;
    --panel2: #ececec;
    --text: #111;
    --muted: #666;
    --line: rgba(0,0,0,.12);
    --shadow: rgba(0,0,0,.2);
    --accent: #10a37f;
    --warning: #d97706;
    --danger: #dc2626;

    position: fixed;
    top: 72px;
    right: 18px;
    z-index: 2147483000;
    width: min(470px, calc(100vw - 30px));
    max-height: calc(100vh - 90px);
    color: var(--text);
    font: 13px/1.45 system-ui, sans-serif;
    }

    #${ROOT_ID}[data-theme="dark"] {
    --bg: #212121;
    --panel: #2f2f2f;
    --panel2: #383838;
    --text: #ececec;
    --muted: #b4b4b4;
    --line: rgba(255,255,255,.14);
    --shadow: rgba(0,0,0,.45);
    --warning: #f59e0b;
    --danger: #f87171;
    }

    #${ROOT_ID} * {
    box-sizing: border-box;
    }

    #${ROOT_ID}.collapsed {
    width: auto;
    }

    #${ROOT_ID}.collapsed .body {
    display: none;
    }

    #${ROOT_ID}.collapsed .shell {
    resize: none;
    min-width: 0;
    min-height: 0;
    }

    #${ROOT_ID}.fullscreen {
    inset: 14px !important;
    width: auto !important;
    max-height: none;
    }

    #${ROOT_ID}.fullscreen .shell {
    width: 100%;
    height: calc(100vh - 28px);
    resize: none;
    }

    #${ROOT_ID}.fullscreen .body {
    max-height: calc(100vh - 82px);
    }

    #${ROOT_ID} .shell {
    width: 100%;
    min-width: 350px;
    min-height: 220px;
    max-width: calc(100vw - 30px);
    max-height: calc(100vh - 90px);
    overflow: hidden;
    resize: both;
    border: 1px solid var(--line);
    border-radius: 10px;
    background: var(--bg);
    box-shadow: 0 18px 50px var(--shadow);
    }

    #${ROOT_ID} .header {
    display: flex;
    align-items: center;
    gap: 8px;
    min-height: 48px;
    padding: 9px 11px;
    border-bottom: 1px solid var(--line);
    cursor: move;
    user-select: none;
    }

    #${ROOT_ID}.fullscreen .header {
    cursor: default;
    }

    #${ROOT_ID} .title {
    flex: 1;
    min-width: 0;
    }

    #${ROOT_ID} .title strong {
    display: block;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    }

    #${ROOT_ID} .status,
    #${ROOT_ID} .sub,
    #${ROOT_ID} .label {
    color: var(--muted);
    font-size: 11px;
    }

    #${ROOT_ID} .actions {
    display: flex;
    gap: 5px;
    }

    #${ROOT_ID} button {
    min-height: 29px;
    border: 1px solid var(--line);
    border-radius: 7px;
    padding: 4px 8px;
    background: var(--panel2);
    color: var(--text);
    cursor: pointer;
    font: inherit;
    }

    #${ROOT_ID} .icon {
    width: 30px;
    padding: 0;
    }

    #${ROOT_ID} .body {
    max-height: calc(100vh - 140px);
    overflow: auto;
    padding: 11px;
    }

    #${ROOT_ID} .grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 8px;
    }

    #${ROOT_ID} .card {
    min-width: 0;
    border: 1px solid var(--line);
    border-radius: 9px;
    padding: 10px;
    background: var(--panel);
    }

    #${ROOT_ID} .wide {
    grid-column: 1 / -1;
    }

    #${ROOT_ID} .value {
    margin-top: 2px;
    font-size: 19px;
    font-weight: 700;
    overflow-wrap: anywhere;
    }

    #${ROOT_ID} .meter {
    display: grid;
    grid-template-columns: 84px 1fr;
    align-items: center;
    gap: 12px;
    }

    #${ROOT_ID} .ring {
    display: grid;
    place-items: center;
    width: 84px;
    aspect-ratio: 1;
    border-radius: 50%;
    background:
    radial-gradient(
    circle,
    var(--panel) 0 56%,
    transparent 57%
    ),
    conic-gradient(
    var(--ring-color) calc(var(--percent) * 1%),
    var(--panel2) 0
    );
    }

    #${ROOT_ID} .ring-inner {
    text-align: center;
    font-weight: 800;
    }

    #${ROOT_ID} .kv {
    display: flex;
    justify-content: space-between;
    gap: 10px;
    margin-top: 6px;
    padding-bottom: 5px;
    border-bottom: 1px solid var(--line);
    }

    #${ROOT_ID} .kv span:first-child {
    color: var(--muted);
    }

    #${ROOT_ID} .kv span:last-child {
    text-align: right;
    overflow-wrap: anywhere;
    }

    #${ROOT_ID} .tabs {
    display: flex;
    gap: 6px;
    margin: 11px 0 8px;
    }

    #${ROOT_ID} .tab {
    flex: 1;
    color: var(--muted);
    }

    #${ROOT_ID} .tab.active {
    border-color: var(--accent);
    color: var(--text);
    }

    #${ROOT_ID} .section {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    margin: 12px 0 7px;
    }

    #${ROOT_ID} .table-wrap {
    max-height: 280px;
    overflow: auto;
    border: 1px solid var(--line);
    border-radius: 8px;
    }

    #${ROOT_ID} table {
    width: 100%;
    min-width: 760px;
    border-collapse: collapse;
    background: var(--panel);
    font-size: 12px;
    }

    #${ROOT_ID} th,
    #${ROOT_ID} td {
    padding: 7px 8px;
    border-bottom: 1px solid var(--line);
    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;
    z-index: 1;
    background: var(--panel2);
    }

    #${ROOT_ID} .loading {
    display: flex;
    align-items: center;
    gap: 9px;
    color: var(--muted);
    }

    #${ROOT_ID} .spinner {
    width: 17px;
    height: 17px;
    border: 2px solid var(--line);
    border-top-color: var(--accent);
    border-radius: 50%;
    animation: cq-spin .8s linear infinite;
    }

    #${ROOT_ID} .error {
    padding: 10px;
    border: 1px solid var(--danger);
    border-radius: 8px;
    white-space: pre-wrap;
    }

    @keyframes cq-spin {
    to {
    transform: rotate(360deg);
    }
    }

    @media (max-width: 720px) {
    #${ROOT_ID} {
    top: 62px;
    left: 10px;
    right: 10px;
    width: auto;
    }

    #${ROOT_ID} .grid {
    grid-template-columns: 1fr;
    }

    #${ROOT_ID} .wide {
    grid-column: auto;
    }

    #${ROOT_ID} .shell {
    min-width: 0;
    }
    }
    `;

    if (typeof GM_addStyle === 'function') {
    GM_addStyle(css);
    } else {
    const style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);
    }
    }

    function readPosition() {
    try {
    const position = JSON.parse(
    safeGet(KEYS.position) || 'null'
    );

    if (
    !position ||
    !Number.isFinite(position.left) ||
    !Number.isFinite(position.top)
    ) {
    return null;
    }

    return position;
    } catch {
    return null;
    }
    }

    function applyPosition(root) {
    const position = readPosition();
    if (!position) return;

    root.style.left = `${clamp(
    position.left,
    5,
    window.innerWidth - 80
    )}px`;

    root.style.top = `${clamp(
    position.top,
    5,
    window.innerHeight - 44
    )}px`;

    root.style.right = 'auto';
    root.style.bottom = 'auto';
    }

    function savePosition(root) {
    const rectangle = root.getBoundingClientRect();

    safeSet(
    KEYS.position,
    JSON.stringify({
    left: Math.round(rectangle.left),
    top: Math.round(rectangle.top),
    })
    );
    }

    function updateButtons(root) {
    const unitButton = root.querySelector(
    '[data-action="unit"]'
    );

    const fullscreenButton = root.querySelector(
    '[data-action="fullscreen"]'
    );

    const collapseButton = root.querySelector(
    '[data-action="collapse"]'
    );

    const unit = getTokenUnit();

    unitButton.textContent =
    unit === 'm'
    ? 'Token: M'
    : unit === 'yi'
    ? 'Token: 亿'
    : 'Token: 原始';

    const fullscreen =
    root.classList.contains('fullscreen');

    fullscreenButton.textContent =
    fullscreen ? '↙' : '⛶';

    fullscreenButton.title =
    fullscreen ? '还原' : '全屏';

    const collapsed =
    root.classList.contains('collapsed');

    collapseButton.textContent =
    collapsed ? '▸' : '▾';

    collapseButton.title =
    collapsed ? '展开' : '折叠';
    }

    function setStatus(root, text) {
    const element = root.querySelector(
    '[data-status]'
    );

    if (element) element.textContent = text;
    }

    function setupDrag(root) {
    const header = root.querySelector('.header');

    let dragging = false;
    let startX = 0;
    let startY = 0;
    let startLeft = 0;
    let startTop = 0;

    function onMove(event) {
    if (!dragging) return;

    const rectangle = root.getBoundingClientRect();

    const nextLeft = clamp(
    startLeft + event.clientX - startX,
    4,
    window.innerWidth -
    Math.min(80, rectangle.width)
    );

    const nextTop = clamp(
    startTop + event.clientY - startY,
    4,
    window.innerHeight -
    Math.min(44, rectangle.height)
    );

    root.style.left = `${nextLeft}px`;
    root.style.top = `${nextTop}px`;
    root.style.right = 'auto';
    root.style.bottom = 'auto';
    }

    function onUp() {
    if (!dragging) return;

    dragging = false;
    document.removeEventListener(
    'mousemove',
    onMove
    );
    document.removeEventListener(
    'mouseup',
    onUp
    );

    savePosition(root);
    }

    header.addEventListener('mousedown', event => {
    if (
    event.button !== 0 ||
    root.classList.contains('fullscreen') ||
    event.target.closest('button, a, input, select')
    ) {
    return;
    }

    const rectangle =
    root.getBoundingClientRect();

    dragging = true;
    startX = event.clientX;
    startY = event.clientY;
    startLeft = rectangle.left;
    startTop = rectangle.top;

    root.style.left = `${rectangle.left}px`;
    root.style.top = `${rectangle.top}px`;
    root.style.right = 'auto';
    root.style.bottom = 'auto';

    document.addEventListener(
    'mousemove',
    onMove
    );
    document.addEventListener(
    'mouseup',
    onUp
    );

    event.preventDefault();
    });

    window.addEventListener('resize', () => {
    if (!root.classList.contains('fullscreen')) {
    applyPosition(root);
    savePosition(root);
    }
    });
    }

    function createRoot() {
    let root = document.getElementById(ROOT_ID);

    if (root) return root;

    root = document.createElement('div');
    root.id = ROOT_ID;

    if (safeGet(KEYS.collapsed) === '1') {
    root.classList.add('collapsed');
    }

    if (safeGet(KEYS.fullscreen) === '1') {
    root.classList.add('fullscreen');
    }

    root.innerHTML = `
    <div class="shell">
    <div class="header">
    <div class="title">
    <strong>
    Codex Quota Compass v${VERSION}
    </strong>
    <span class="status" data-status>
    准备加载
    </span>
    </div>

    <div class="actions">
    <button type="button" data-action="unit">
    Token: 原始
    </button>

    <button
    class="icon"
    type="button"
    data-action="refresh"
    title="刷新"
    >↻</button>

    <button
    class="icon"
    type="button"
    data-action="fullscreen"
    title="全屏"
    >⛶</button>

    <button
    class="icon"
    type="button"
    data-action="collapse"
    title="折叠"
    >▾</button>
    </div>
    </div>

    <div class="body" data-body></div>
    </div>
    `;

    document.documentElement.appendChild(root);

    syncTheme(root);

    if (!root.classList.contains('fullscreen')) {
    applyPosition(root);
    }

    const observer = new MutationObserver(() =>
    syncTheme(root)
    );

    observer.observe(document.documentElement, {
    attributes: true,
    attributeFilter: [
    'class',
    'style',
    'data-theme',
    ],
    });

    const media = matchMedia(
    '(prefers-color-scheme: dark)'
    );

    const themeHandler = () => syncTheme(root);

    if (typeof media.addEventListener === 'function') {
    media.addEventListener('change', themeHandler);
    } else if (typeof media.addListener === 'function') {
    media.addListener(themeHandler);
    }

    root
    .querySelector('[data-action="unit"]')
    .addEventListener('click', () => {
    const current = getTokenUnit();

    const next =
    current === 'raw'
    ? 'm'
    : current === 'm'
    ? 'yi'
    : 'raw';

    safeSet(KEYS.tokenUnit, next);
    updateButtons(root);

    if (root.__data) {
    render(root, root.__data);
    }
    });

    root
    .querySelector('[data-action="refresh"]')
    .addEventListener('click', () =>
    refresh(root)
    );

    root
    .querySelector('[data-action="fullscreen"]')
    .addEventListener('click', () => {
    root.classList.toggle('fullscreen');

    if (root.classList.contains('fullscreen')) {
    root.classList.remove('collapsed');
    safeSet(KEYS.collapsed, '0');
    } else {
    applyPosition(root);
    }

    safeSet(
    KEYS.fullscreen,
    root.classList.contains('fullscreen')
    ? '1'
    : '0'
    );

    updateButtons(root);
    });

    root
    .querySelector('[data-action="collapse"]')
    .addEventListener('click', () => {
    root.classList.toggle('collapsed');

    if (root.classList.contains('collapsed')) {
    root.classList.remove('fullscreen');
    safeSet(KEYS.fullscreen, '0');
    }

    safeSet(
    KEYS.collapsed,
    root.classList.contains('collapsed')
    ? '1'
    : '0'
    );

    updateButtons(root);
    });

    setupDrag(root);
    updateButtons(root);

    return root;
    }

    function usageColor(percent) {
    if (percent >= 85) return 'var(--danger)';
    if (percent >= 65) return 'var(--warning)';
    return 'var(--accent)';
    }

    function renderWindow(windowData) {
    const percent = clamp(
    toNumber(windowData.usedPercent),
    0,
    100
    );

    return `
    <div class="card wide">
    <div class="meter">
    <div
    class="ring"
    style="
    --percent:${percent};
    --ring-color:${usageColor(percent)};
    "
    >
    <div class="ring-inner">
    <div class="label">已用</div>
    <div>${formatPercent(percent)}</div>
    </div>
    </div>

    <div>
    <div class="label">
    ${escapeHtml(windowData.name)}
    </div>

    <div class="value">
    ${formatNumber(100 - percent, 2)}% 剩余
    </div>

    <div class="kv">
    <span>下次重置</span>
    <span>
    ${escapeHtml(
    windowData.resetAtLocal
    )}
    </span>
    </div>

    <div class="kv">
    <span>距离重置</span>
    <span>
    ${formatNumber(
    windowData.resetAfterHours,
    2
    )} 小时
    </span>
    </div>
    </div>
    </div>
    </div>
    `;
    }

    function renderSummary(data) {
    const main5Hour = data.windows.find(
    item => item.name === '主限制 - 5小时窗口'
    );

    const estimate = data.weeklyEstimate;
    const rolling = data.ranges.rolling.summary;
    const hint = tokenHint(rolling.tokens);

    return `
    <div class="grid">
    ${renderWindow(data.mainSecondary)}
    ${main5Hour ? renderWindow(main5Hour) : ''}

    <div class="card">
    <div class="label">
    近${CONFIG.ROLLING_DAYS}天 Token
    </div>

    <div class="value">
    ${formatTokens(rolling.tokens)}
    </div>

    <div class="sub">
    ${hint ? `${escapeHtml(hint)} · ` : ''}
    ${formatInteger(rolling.turns)} 轮 ·
    ${formatInteger(rolling.threads)} 线程
    </div>
    </div>

    <div class="card">
    <div class="label">反推周额度</div>
    <div class="value">
    ${formatOptional(
    estimate.totalCreditsWithoutResetDay,
    2
    )}
    </div>
    <div class="sub">
    排除重置日口径 Credits
    </div>
    </div>

    <div class="card">
    <div class="label">估算剩余额度</div>
    <div class="value">
    ${formatOptional(
    estimate.remainingCreditsWithoutResetDay,
    2
    )}
    </div>
    <div class="sub">
    约 $${formatOptional(
    estimate.remainingUsdWithoutResetDay,
    2
    )}
    </div>
    </div>

    <div class="card">
    <div class="label">日期桶</div>
    <div class="value">
    ${escapeHtml(data.apiTodayDate)}
    </div>
    <div class="sub">
    ${escapeHtml(data.browserTimeZone)} ·
    ${escapeHtml(data.browserUtcOffset)}
    </div>
    </div>

    ${
    estimate.note
    ? `
    <div class="card wide">
    <div class="label">估算说明</div>
    <div class="sub">
    ${escapeHtml(estimate.note)}
    </div>
    </div>
    `
    : ''
    }
    </div>
    `;
    }

    function renderTable(headers, rows) {
    if (!rows.length) {
    return `
    <div class="card">
    暂无数据
    </div>
    `;
    }

    return `
    <div class="table-wrap">
    <table>
    <thead>
    <tr>
    ${headers
    .map(
    header =>
    `<th>${escapeHtml(
    header.label
    )}</th>`
    )
    .join('')}
    </tr>
    </thead>

    <tbody>
    ${rows
    .map(
    row => `
    <tr>
    ${headers
    .map(header => {
    const raw = row[header.key];

    const formatted =
    typeof header.format ===
    'function'
    ? header.format(raw, row)
    : raw;

    return `<td>${escapeHtml(
    formatted
    )}</td>`;
    })
    .join('')}
    </tr>
    `
    )
    .join('')}
    </tbody>
    </table>
    </div>
    `;
    }

    function rowsToCsv(rows) {
    if (!rows.length) return '';

    const keys = Object.keys(rows[0]);

    const cell = value =>
    `"${String(value ?? '').replace(
    /"/g,
    '""'
    )}"`;

    return [
    keys.map(cell).join(','),
    ...rows.map(row =>
    keys
    .map(key => cell(row[key]))
    .join(',')
    ),
    ].join('\n');
    }

    async function copyCsv(rows, button) {
    const oldText = button.textContent;

    try {
    await navigator.clipboard.writeText(
    rowsToCsv(rows)
    );

    button.textContent = '已复制';
    } catch {
    button.textContent = '复制失败';
    } finally {
    setTimeout(() => {
    button.textContent = oldText;
    }, 1200);
    }
    }

    function renderRange(root, data, key) {
    const range = data.ranges[key];
    if (!range) return;

    root.dataset.range = key;

    root.querySelectorAll('.tab').forEach(button => {
    button.classList.toggle(
    'active',
    button.dataset.range === key
    );
    });

    const summaryElement = root.querySelector(
    '[data-range-summary]'
    );

    const rowsElement = root.querySelector(
    '[data-range-rows]'
    );

    const clientsElement = root.querySelector(
    '[data-range-clients]'
    );

    const hint = tokenHint(range.summary.tokens);

    summaryElement.innerHTML = `
    <div class="grid">
    <div class="card">
    <div class="label">范围</div>
    <div class="value" style="font-size:15px">
    ${escapeHtml(range.summary.range)}
    </div>
    <div class="sub">
    ${formatInteger(
    range.summary.dateBuckets
    )} 个日期桶
    </div>
    </div>

    <div class="card">
    <div class="label">累计 Token</div>
    <div class="value">
    ${formatTokens(range.summary.tokens)}
    </div>
    <div class="sub">
    ${hint ? `${escapeHtml(hint)} · ` : ''}
    ${formatInteger(
    range.summary.turns
    )} 轮 ·
    ${formatInteger(
    range.summary.threads
    )} 线程
    </div>
    </div>

    <div class="card">
    <div class="label">累计 Credits</div>
    <div class="value">
    ${formatNumber(
    range.summary.credits,
    6
    )}
    </div>
    <div class="sub">
    约 $${formatNumber(
    range.summary.usd,
    2
    )}
    </div>
    </div>

    <div class="card">
    <div class="label">API 日期范围</div>
    <div class="value" style="font-size:14px">
    ${escapeHtml(
    range.summary.startDate
    )}

    ${escapeHtml(
    range.summary.endExclusiveDate
    )}
    </div>
    <div class="sub">
    end_date 为排他边界
    </div>
    </div>
    </div>
    `;

    rowsElement.innerHTML = renderTable(
    [
    { key: 'date', label: '日期桶' },
    {
    key: 'tokens',
    label: tokenLabel('Token'),
    format: formatTokens,
    },
    {
    key: 'cachedInputTokens',
    label: tokenLabel('缓存输入'),
    format: formatTokens,
    },
    {
    key: 'uncachedInputTokens',
    label: tokenLabel('非缓存输入'),
    format: formatTokens,
    },
    {
    key: 'outputTokens',
    label: tokenLabel('输出'),
    format: formatTokens,
    },
    {
    key: 'turns',
    label: '轮数',
    format: formatInteger,
    },
    {
    key: 'threads',
    label: '线程',
    format: formatInteger,
    },
    {
    key: 'credits',
    label: 'Credits',
    format: value =>
    formatNumber(value, 6),
    },
    {
    key: 'clientCredits',
    label: '客户端 Credits',
    },
    ],
    range.rows
    );

    clientsElement.innerHTML = renderTable(
    [
    { key: 'client', label: '客户端' },
    {
    key: 'tokens',
    label: tokenLabel('Token'),
    format: formatTokens,
    },
    {
    key: 'cachedInputTokens',
    label: tokenLabel('缓存输入'),
    format: formatTokens,
    },
    {
    key: 'uncachedInputTokens',
    label: tokenLabel('非缓存输入'),
    format: formatTokens,
    },
    {
    key: 'outputTokens',
    label: tokenLabel('输出'),
    format: formatTokens,
    },
    {
    key: 'turns',
    label: '轮数',
    format: formatInteger,
    },
    {
    key: 'threads',
    label: '线程',
    format: formatInteger,
    },
    {
    key: 'credits',
    label: 'Credits',
    format: value =>
    formatNumber(value, 6),
    },
    ],
    range.clients
    );

    root.querySelector(
    '[data-copy="days"]'
    ).onclick = event =>
    copyCsv(
    range.rows,
    event.currentTarget
    );

    root.querySelector(
    '[data-copy="clients"]'
    ).onclick = event =>
    copyCsv(
    range.clients,
    event.currentTarget
    );
    }

    function render(root, data) {
    root.__data = data;
    updateButtons(root);

    const activeRange =
    data.ranges[root.dataset.range]
    ? root.dataset.range
    : 'sinceReset';

    const body = root.querySelector('[data-body]');

    body.innerHTML = `
    ${renderSummary(data)}

    <div class="section">
    <strong>用量区间</strong>
    </div>

    <div class="tabs">
    ${Object.entries(data.ranges)
    .map(
    ([key, range]) => `
    <button
    class="tab"
    type="button"
    data-range="${key}"
    >
    ${escapeHtml(range.label)}
    </button>
    `
    )
    .join('')}
    </div>

    <div data-range-summary></div>

    <div class="section">
    <strong>每日明细</strong>
    <button
    type="button"
    data-copy="days"
    >
    复制 CSV
    </button>
    </div>

    <div data-range-rows></div>

    <div class="section">
    <strong>客户端汇总</strong>
    <button
    type="button"
    data-copy="clients"
    >
    复制 CSV
    </button>
    </div>

    <div data-range-clients></div>

    <div class="sub" style="margin-top:10px">
    数据来自当前 ChatGPT 登录会话。
    脚本不会保存或显示访问令牌和 Cookie。
    </div>
    `;

    root.querySelectorAll('.tab').forEach(button => {
    button.addEventListener('click', () => {
    renderRange(
    root,
    data,
    button.dataset.range
    );
    });
    });

    renderRange(root, data, activeRange);

    setStatus(
    root,
    `已更新:${new Date(
    data.fetchedAt
    ).toLocaleTimeString()}`
    );
    }

    function friendlyError(error) {
    if (!(error instanceof HttpError)) {
    return error?.message || String(error);
    }

    if (error.status === 401) {
    return (
    'HTTP 401:没有获得有效授权。\n' +
    '请确认当前页面已经登录 ChatGPT,然后刷新重试。'
    );
    }

    if (error.status === 403) {
    return (
    'HTTP 403:当前账号无权访问该接口,' +
    '或者接口策略已经变化。'
    );
    }

    if (error.status === 404) {
    return (
    'HTTP 404:接口不存在,ChatGPT 内部接口可能已经调整。'
    );
    }

    if (error.status === 429) {
    return (
    'HTTP 429:请求过于频繁,请稍后手动刷新。'
    );
    }

    return (
    `HTTP ${error.status}:请求失败。\n` +
    `${error.path}`
    );
    }

    async function refresh(root) {
    activeController?.abort();

    const controller = new AbortController();
    activeController = controller;

    const currentRequest = ++requestSerial;

    const body = root.querySelector('[data-body]');

    setStatus(root, '加载中');

    body.innerHTML = `
    <div class="loading">
    <span class="spinner"></span>
    <span>正在读取 Codex analytics...</span>
    </div>
    `;

    try {
    const data = await loadCompassData(
    controller.signal
    );

    if (
    controller.signal.aborted ||
    currentRequest !== requestSerial
    ) {
    return;
    }

    render(root, data);
    } catch (error) {
    if (
    error?.name === 'AbortError' ||
    currentRequest !== requestSerial
    ) {
    return;
    }

    setStatus(root, '加载失败');

    body.innerHTML = `
    <div class="error">
    ${escapeHtml(friendlyError(error))}
    </div>
    `;
    }
    }

    function init() {
    if (document.getElementById(ROOT_ID)) return;

    addStyles();

    const root = createRoot();
    refresh(root);
    }

    if (document.readyState === 'loading') {
    document.addEventListener(
    'DOMContentLoaded',
    init,
    { once: true }
    );
    } else {
    init();
    }
    })();
  • worsteggs 07-15 11:08
    9

    好用啊!找了很久。因为我现在是公用官号的,不知道有没有办法统计所有人每个任务的token消耗情况?

  • 楼主 07-15 11:11
    10

    官方暂时只能区分客户端 TUI、vs、codex ^-^ 我们也是共用,还是只能靠cc-switch 互相看各自用量

* 帖子来源Linux.do
返回