ChatGPT Thinking Quick Switch

Floating quick buttons for ChatGPT thinking effort: 均衡, 超高, and Pro 扩展.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

Advertisement:

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

Advertisement:

// ==UserScript==
// @name         ChatGPT Thinking Quick Switch
// @namespace    https://chatgpt.com/
// @version      0.1.3
// @description  Floating quick buttons for ChatGPT thinking effort: 均衡, 超高, and Pro 扩展.
// @author       Codex
// @license      MIT
// @homepageURL  https://github.com/jiang-yuan/chatgpt-thinking-quick-switch
// @match        https://chatgpt.com/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  const UI_ID = 'cgpt-thinking-quick-switch';
  const STYLE_ID = 'cgpt-thinking-quick-switch-style';
  const SWITCHING_ATTR = 'data-cgpt-tqs-switching';
  const SCRIPT_VERSION = '0.1.3';
  const POSITION_MARGIN = 12;

  const TARGETS = [
    {
      key: 'balanced',
      label: '均衡',
      title: '切换到均衡思考强度',
      matches: (text) => /^(均衡|balanced|balance)$/i.test(normalizeText(text)),
    },
    {
      key: 'ultra',
      label: '超高',
      title: '切换到超高思考强度',
      matches: (text) => /^(超高|ultra|ultra high|very high|highest|maximum)$/i.test(normalizeText(text)),
    },
    {
      key: 'pro_extended',
      label: 'Pro',
      title: '切换到 Pro 扩展思考强度;普通 Pro/专业不会被当作命中',
      matches: matchesProExtended,
    },
  ];

  const KNOWN_EFFORT_MATCHERS = [
    ...TARGETS.map((target) => target.matches),
    (text) => /^(极速|快速|fast|quick)$/i.test(normalizeText(text)),
    (text) => /^(高级|高|advanced|high)$/i.test(normalizeText(text)),
  ];

  let scheduled = false;
  let lastStatus = '';
  let menuCloakTimer = 0;

  function normalizeText(text) {
    return String(text || '')
      .replace(/\s+/g, ' ')
      .trim();
  }

  function matchesProExtended(text) {
    const value = normalizeText(text);
    if (!value) return false;

    const hasProName = /\bpro\b/i.test(value) || /专业/i.test(value);
    const hasExtended = /扩展|extended|extension/i.test(value);
    return hasProName && hasExtended;
  }

  function isVisible(element) {
    if (!element || !(element instanceof HTMLElement)) return false;
    const style = window.getComputedStyle(element);
    if (style.display === 'none' || style.visibility === 'hidden') return false;
    if (style.opacity === '0' && !isCloakedMenuElement(element)) return false;

    const rect = element.getBoundingClientRect();
    return rect.width > 0 && rect.height > 0;
  }

  function isCloakedMenuElement(element) {
    return document.documentElement.hasAttribute(SWITCHING_ATTR) && !!element.closest('[role="menu"], [role="listbox"]');
  }

  function getElementText(element) {
    if (!element) return '';

    return normalizeText(
      element.innerText ||
        element.textContent ||
        element.getAttribute('aria-label') ||
        element.getAttribute('title') ||
        ''
    );
  }

  function isKnownEffortText(text) {
    return KNOWN_EFFORT_MATCHERS.some((matches) => matches(text));
  }

  function findPromptBox() {
    return document.querySelector(
      [
        '#prompt-textarea',
        '[data-testid="prompt-textarea"]',
        '[contenteditable="true"][role="textbox"]',
        'textarea[placeholder*="ChatGPT"]',
        'textarea[placeholder*="聊天"]',
      ].join(',')
    );
  }

  function findComposer() {
    const promptBox = findPromptBox();
    if (!promptBox) return null;

    const form = promptBox.closest('form');
    if (form) return form;

    let current = promptBox.parentElement;
    while (current && current !== document.body) {
      const rect = current.getBoundingClientRect();
      const hasButtons = current.querySelectorAll('button').length >= 2;
      const isLikelyComposer = hasButtons && rect.height > 32 && rect.height < 220 && rect.bottom > window.innerHeight * 0.45;
      if (isLikelyComposer) return current;
      current = current.parentElement;
    }

    return promptBox.parentElement;
  }

  function findEffortTrigger() {
    const composer = findComposer();
    const scopedButtons = composer ? Array.from(composer.querySelectorAll('button')) : [];
    const scopedMatch = scopedButtons.find((button) => {
      if (button.closest(`#${UI_ID}`)) return false;
      return isVisible(button) && isLikelyEffortTrigger(button);
    });
    if (scopedMatch) return scopedMatch;

    return Array.from(document.querySelectorAll('button')).find((button) => {
      if (!isVisible(button) || button.closest(`#${UI_ID}`)) return false;
      return isLikelyEffortTrigger(button);
    });
  }

  function isLikelyEffortTrigger(button) {
    const text = getElementText(button);
    if (!isKnownEffortText(text)) return false;

    const ariaLabel = normalizeText(button.getAttribute('aria-label'));
    if (/个人资料|profile|account/i.test(ariaLabel)) return false;
    if (text.length > 24) return false;

    return true;
  }

  function findOpenMenuItems() {
    const menuRoots = Array.from(document.querySelectorAll('[role="menu"], [role="listbox"]')).filter(isVisible);
    const selectors = '[role="menuitemradio"], [role="menuitem"], [role="option"]';
    const itemElements = menuRoots.length
      ? menuRoots.flatMap((root) => Array.from(root.querySelectorAll(selectors)))
      : Array.from(document.querySelectorAll(selectors));

    return itemElements
      .filter(isVisible)
      .map((element) => ({
        element,
        text: getElementText(element),
        checked:
          element.getAttribute('aria-checked') === 'true' ||
          element.getAttribute('data-state') === 'checked' ||
          element.querySelector('[data-state="checked"], [aria-checked="true"]') != null,
      }))
      .filter((item) => item.text.length > 0 && item.text.length <= 40);
  }

  function waitFor(predicate, timeoutMs = 1600, intervalMs = 50) {
    const start = Date.now();
    return new Promise((resolve, reject) => {
      const tick = () => {
        let value = null;
        try {
          value = predicate();
        } catch {
          value = null;
        }

        if (value) {
          resolve(value);
          return;
        }

        if (Date.now() - start >= timeoutMs) {
          reject(new Error('Timed out while waiting for ChatGPT menu state.'));
          return;
        }

        window.setTimeout(tick, intervalMs);
      };

      tick();
    });
  }

  function realClick(element) {
    if (!element) return false;

    // Do not pass view: window here. Tampermonkey's sandboxed window can throw in MouseEvent init.
    const eventInit = { bubbles: true, cancelable: true, composed: true };
    [
      'pointerover',
      'pointerenter',
      'mouseover',
      'mouseenter',
      'pointermove',
      'mousemove',
      'pointerdown',
      'mousedown',
      'pointerup',
      'mouseup',
    ].forEach((type) => {
      element.dispatchEvent(new MouseEvent(type, eventInit));
    });

    try {
      element.click();
    } catch {
      return false;
    }

    return true;
  }

  function enableMenuCloak(timeoutMs = 3200) {
    document.documentElement.setAttribute(SWITCHING_ATTR, 'true');
    window.clearTimeout(menuCloakTimer);
    menuCloakTimer = window.setTimeout(disableMenuCloak, timeoutMs);
  }

  function disableMenuCloak() {
    window.clearTimeout(menuCloakTimer);
    menuCloakTimer = 0;
    document.documentElement.removeAttribute(SWITCHING_ATTR);
  }

  async function openEffortMenu() {
    const trigger = findEffortTrigger();
    if (!trigger) {
      throw new Error('没有找到 ChatGPT 原生思考强度按钮。');
    }

    realClick(trigger);
    await waitFor(() => findOpenMenuItems().length > 0);
    return findOpenMenuItems();
  }

  function closeMenus() {
    const eventInit = {
      key: 'Escape',
      code: 'Escape',
      keyCode: 27,
      which: 27,
      bubbles: true,
      cancelable: true,
    };

    document.activeElement?.dispatchEvent(new KeyboardEvent('keydown', eventInit));
    document.dispatchEvent(new KeyboardEvent('keydown', eventInit));
  }

  function getCurrentEffortText() {
    return getElementText(findEffortTrigger());
  }

  async function readCheckedEffortText() {
    const items = await openEffortMenu();
    const checked = items.find((item) => item.checked);
    closeMenus();
    return checked ? checked.text : '';
  }

  async function selectTarget(target) {
    setStatus('busy', `正在切换到 ${target.label}`);
    enableMenuCloak();

    try {
      const items = await openEffortMenu();
      const visibleTexts = items.map((item) => item.text);
      const matched = items.find((item) => target.matches(item.text));

      if (!matched) {
        closeMenus();
        const available = visibleTexts.join(' / ') || '未扫描到可用项';
        throw new Error(`没有找到 ${target.title}。当前菜单项:${available}`);
      }

      realClick(matched.element);

      await waitFor(() => target.matches(getCurrentEffortText()), 2200).catch(async () => {
        const checkedText = await readCheckedEffortText().catch(() => '');
        if (!target.matches(checkedText)) {
          throw new Error(`点击后未确认切到 ${target.label}。当前显示:${getCurrentEffortText() || checkedText || '未知'}`);
        }
      });

      setStatus('ok', `已切换到 ${target.label}`);
      refreshActiveButton();
    } catch (error) {
      setStatus('error', error instanceof Error ? error.message : String(error));
    } finally {
      disableMenuCloak();
    }
  }

  function ensureStyle() {
    if (document.getElementById(STYLE_ID)) return;

    const style = document.createElement('style');
    style.id = STYLE_ID;
    style.textContent = `
      #${UI_ID} {
        position: fixed;
        z-index: 2147483647;
        display: flex;
        gap: 6px;
        align-items: center;
        pointer-events: auto;
        transition: opacity 120ms ease, transform 120ms ease;
      }

      #${UI_ID}[hidden] {
        display: none;
      }

      #${UI_ID} button {
        min-width: 52px;
        height: 34px;
        padding: 0 12px;
        border: 1px solid rgba(0, 0, 0, 0.16);
        border-radius: 8px;
        background: rgba(255, 255, 255, 0.96);
        color: #111;
        box-shadow: 0 4px 14px rgba(0, 0, 0, 0.08);
        cursor: pointer;
        font: 500 13px/1 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        letter-spacing: 0;
        white-space: nowrap;
      }

      #${UI_ID} button:hover {
        background: #f5f5f5;
        border-color: rgba(0, 0, 0, 0.28);
      }

      #${UI_ID} button:disabled {
        cursor: default;
        opacity: 0.62;
      }

      #${UI_ID} button[data-active="true"] {
        background: #111;
        border-color: #111;
        color: #fff;
      }

      #${UI_ID}[data-state="error"] button {
        border-color: #d33a2c;
      }

      html.dark #${UI_ID} button,
      [data-theme="dark"] #${UI_ID} button {
        background: rgba(32, 33, 35, 0.96);
        border-color: rgba(255, 255, 255, 0.22);
        color: #f5f5f5;
        box-shadow: 0 4px 14px rgba(0, 0, 0, 0.32);
      }

      html.dark #${UI_ID} button:hover,
      [data-theme="dark"] #${UI_ID} button:hover {
        background: #2c2d30;
      }

      html.dark #${UI_ID} button[data-active="true"],
      [data-theme="dark"] #${UI_ID} button[data-active="true"] {
        background: #f5f5f5;
        border-color: #f5f5f5;
        color: #111;
      }

      html[${SWITCHING_ATTR}="true"] [role="menu"],
      html[${SWITCHING_ATTR}="true"] [role="listbox"] {
        opacity: 0 !important;
      }
    `;
    document.head.appendChild(style);
  }

  function createUi() {
    ensureStyle();

    let root = document.getElementById(UI_ID);
    if (root) return root;

    root = document.createElement('div');
    root.id = UI_ID;
    root.dataset.scriptVersion = SCRIPT_VERSION;

    TARGETS.forEach((target) => {
      const button = document.createElement('button');
      button.type = 'button';
      button.dataset.target = target.key;
      button.textContent = target.label;
      button.title = target.title;
      button.addEventListener('click', () => selectTarget(target));
      root.appendChild(button);
    });

    document.body.appendChild(root);
    return root;
  }

  function setStatus(state, message) {
    const root = createUi();
    root.dataset.state = state;
    root.title = message;
    lastStatus = message;

    Array.from(root.querySelectorAll('button')).forEach((button) => {
      button.disabled = state === 'busy';
    });
  }

  function refreshActiveButton() {
    const root = createUi();
    const currentText = getCurrentEffortText();
    const activeTarget = TARGETS.find((target) => target.matches(currentText));

    Array.from(root.querySelectorAll('button')).forEach((button) => {
      button.dataset.active = String(button.dataset.target === activeTarget?.key);
      button.disabled = root.dataset.state === 'busy';
    });

    if (activeTarget && root.dataset.state !== 'busy') {
      root.dataset.state = 'ready';
      root.title = `当前思考强度:${currentText}`;
    } else if (!lastStatus) {
      root.title = '点击快捷按钮会实时扫描 ChatGPT 原生思考强度菜单';
    }
  }

  function positionUi() {
    const root = createUi();
    const composer = findComposer();
    const trigger = findEffortTrigger();

    if (!composer || !trigger) {
      root.hidden = true;
      return;
    }

    root.hidden = false;

    const composerRect = composer.getBoundingClientRect();
    const triggerRect = trigger.getBoundingClientRect();
    const rootRect = root.getBoundingClientRect();
    const width = rootRect.width || 180;
    const height = rootRect.height || 34;

    let left = composerRect.right + 8;
    let top = triggerRect.top + (triggerRect.height - height) / 2;

    if (left + width > window.innerWidth - POSITION_MARGIN) {
      left = Math.min(window.innerWidth - width - POSITION_MARGIN, composerRect.right - width - POSITION_MARGIN);
      top = composerRect.top - height - 8;
    }

    left = Math.max(POSITION_MARGIN, left);
    top = Math.max(POSITION_MARGIN, Math.min(window.innerHeight - height - POSITION_MARGIN, top));

    root.style.left = `${Math.round(left)}px`;
    root.style.top = `${Math.round(top)}px`;
  }

  function refresh() {
    scheduled = false;
    createUi();
    positionUi();
    refreshActiveButton();
  }

  function scheduleRefresh() {
    if (scheduled) return;
    scheduled = true;
    window.requestAnimationFrame(refresh);
  }

  function init() {
    document.getElementById(UI_ID)?.remove();
    scheduleRefresh();

    const observer = new MutationObserver(scheduleRefresh);
    observer.observe(document.body, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ['aria-expanded', 'aria-checked', 'data-state', 'class', 'style'],
    });

    window.addEventListener('resize', scheduleRefresh, { passive: true });
    window.addEventListener('scroll', scheduleRefresh, { passive: true, capture: true });
    window.setInterval(scheduleRefresh, 1500);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init, { once: true });
  } else {
    init();
  }
})();