Codex Quota Compass UI

在 ChatGPT 页面直接显示 Codex 额度、重置时间、每日用量和客户端汇总。

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

// ==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) => ({
      '&': '&amp;',
      '<': '&lt;',
      '>': '&gt;',
      '"': '&quot;',
      "'": '&#039;',
    })[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();
  }
})();