ChatGPT Timer

发送后右下角开始计时,回复完成即停止;通过 fetch/XHR 拦截 + DOM 观察,适配 ChatGPT 动态页面

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         ChatGPT Timer
// @namespace    https://github.com/lueluelue2006
// @version      3.0
// @description  发送后右下角开始计时,回复完成即停止;通过 fetch/XHR 拦截 + DOM 观察,适配 ChatGPT 动态页面
// @author       schweigen
// @match        https://chatgpt.com/
// @match        https://chatgpt.com/c/*
// @match        https://chatgpt.com/g/*
// @match        https://chatgpt.com/share/*
// @run-at       document-start
// @grant        none
// @inject-into  page
// @license      MIT
// ==/UserScript==

/*
  设计要点(简述):
  - 主信号:拦截 window.fetch / XMLHttpRequest,识别对会话接口的请求,开始计时;当响应流结束(SSE 流完)时停止计时。
  - 兜底信号:DOM 观察 [data-testid="stop-button"] 按钮的出现/消失;出现→忙碌,消失→空闲。
  - 合并逻辑:任一信号认为“忙碌”则启动计时,全部空闲才停止,避免误判/并发请求。
  - 以 postMessage 在页面上下文与内容脚本之间通信,确保跨沙箱稳定;同时 @inject-into page 让 fetch 补丁尽早生效。
*/

(function () {
  'use strict';

  // ---------------------- 工具:字符串匹配 ----------------------
  const GEN_URL_PATTERNS = [
    /\/backend-api\/conversation(\b|\/)/i,
    /\/backend-api\/messages(\b|\/)/i,
    /\/client-bff\/conversation(\b|\/)/i,
    /\/backend-api\/generate(\b|\/)/i,
    /\/api\/conversation(\b|\/)/i,
    /\/api\/messages(\b|\/)/i,
    /\/v1\/messages(\b|\/)/i
  ];

  function likelyGeneration(url, method, headers, body) {
    try {
      const m = (method || 'GET').toUpperCase();
      if (!(m === 'POST' || m === 'PATCH')) return false;
      if (typeof url === 'string') {
        if (!GEN_URL_PATTERNS.some(re => re.test(url))) return false;
      } else if (url && typeof url.url === 'string') {
        if (!GEN_URL_PATTERNS.some(re => re.test(url.url))) return false;
      } else {
        return false;
      }
      // 额外温和的启发式:Accept/Content-Type
      const h = normalizeHeaders(headers);
      const accept = (h['accept'] || '').toLowerCase();
      const ctype = (h['content-type'] || '').toLowerCase();
      if (accept.includes('text/event-stream')) return true;
      if (ctype.includes('application/json')) return true;
      return true; // 保守:匹配到 URL 即认为是生成
    } catch (_) {
      return false;
    }
  }

  function normalizeHeaders(h) {
    const out = {};
    if (!h) return out;
    try {
      // Headers 对象
      if (typeof Headers !== 'undefined' && h instanceof Headers) {
        for (const [k, v] of h.entries()) out[k.toLowerCase()] = String(v);
        return out;
      }
    } catch (_) {}
    // 普通对象/数组
    try {
      if (Array.isArray(h)) {
        for (const [k, v] of h) out[String(k).toLowerCase()] = String(v);
        return out;
      }
      if (typeof h === 'object') {
        for (const k of Object.keys(h)) out[k.toLowerCase()] = String(h[k]);
        return out;
      }
    } catch (_) {}
    return out;
  }

  // ---------------------- 页面内注入:拦截 fetch/XHR ----------------------
  // 用 <script> 注入到 page 上下文,这样能可靠补丁 window.fetch,不影响页面原逻辑。
  function injectNetworkHooks() {
    const s = document.createElement('script');
    s.textContent = `(() => {
      const post = (type, detail) => {
        try { window.postMessage({ source: 'gpt-reply-timer', type, detail }, '*'); } catch (_) {}
      };

      const patterns = [${GEN_URL_PATTERNS.map(r => r.toString()).join(',')}];
      const normHeaders = ${normalizeHeaders.toString()};
      const isGen = (url, method, headers, body) => {
        try {
          const m = (method || 'GET').toUpperCase();
          if (!(m === 'POST' || m === 'PATCH')) return false;
          const urlStr = (typeof url === 'string') ? url : (url && url.url) || '';
          if (!patterns.some(re => re.test(urlStr))) return false;
          const h = normHeaders(headers);
          const accept = (h['accept'] || '').toLowerCase();
          const ctype = (h['content-type'] || '').toLowerCase();
          if (accept.includes('text/event-stream')) return true;
          if (ctype.includes('application/json')) return true;
          return true;
        } catch (_) { return false; }
      };

      // ---- fetch ----
      try {
        const origFetch = window.fetch;
        if (origFetch) {
          window.fetch = function(input, init) {
            let url = input;
            let method = (init && init.method) || (input && input.method) || 'GET';
            const headers = (init && init.headers) || (input && input.headers);
            const body = (init && init.body) || (input && input.body);
            const track = isGen(url, method, headers, body);
            if (track) post('start', { via: 'fetch' });
            const p = origFetch.apply(this, arguments);
            if (track) {
              p.then(res => {
                try {
                  const c = res.clone();
                  if (c.body && c.body.getReader) {
                    const reader = c.body.getReader();
                    const readLoop = () => reader.read().then(({ done }) => {
                      if (done) { post('stop', { via: 'fetch', how: 'stream-end' }); return; }
                      return readLoop();
                    }).catch(() => post('stop', { via: 'fetch', how: 'stream-error' }));
                    readLoop();
                  } else {
                    c.text().finally(() => post('stop', { via: 'fetch', how: 'no-body' }));
                  }
                } catch (_) {
                  post('stop', { via: 'fetch', how: 'clone-failed' });
                }
              }).catch(() => post('stop', { via: 'fetch', how: 'rejected' }));
            }
            return p;
          };
        }
      } catch (_) {}

      // ---- XHR 兜底 ----
      try {
        const OrigXHR = window.XMLHttpRequest;
        if (OrigXHR) {
          const Proto = OrigXHR.prototype;
          const _open = Proto.open;
          const _send = Proto.send;
          let last = new WeakMap();
          Proto.open = function(method, url) {
            this.__gpt_timer_meta = { method, url };
            return _open.apply(this, arguments);
          };
          Proto.send = function(body) {
            const meta = this.__gpt_timer_meta || {};
            const track = isGen(meta.url, meta.method, this._headers, body);
            if (track) post('start', { via: 'xhr' });
            this.addEventListener('loadend', () => { if (track) post('stop', { via: 'xhr', how: 'loadend' }); });
            return _send.apply(this, arguments);
          };
          const _setReqHeader = Proto.setRequestHeader;
          Proto.setRequestHeader = function(k, v) {
            this._headers = this._headers || {};
            this._headers[k] = v;
            return _setReqHeader.apply(this, arguments);
          };
        }
      } catch (_) {}
    })();`;
    (document.head || document.documentElement).appendChild(s);
    s.remove();
  }

  // ---------------------- UI:右下角计时器 ----------------------
  function createTimerUI() {
    if (document.getElementById('gpt-reply-timer')) return;
    const wrap = document.createElement('div');
    wrap.id = 'gpt-reply-timer';
    Object.assign(wrap.style, {
      position: 'fixed',
      right: '12px',
      bottom: '12px',
      zIndex: '2147483647',
      background: 'rgba(20,20,20,0.9)',
      color: '#e6e6e6',
      fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
      fontSize: '14px',
      lineHeight: '1',
      padding: '10px 12px',
      borderRadius: '10px',
      boxShadow: '0 4px 16px rgba(0,0,0,0.35)',
      userSelect: 'none',
      display: 'none',
      alignItems: 'center',
      gap: '8px',
      border: '1px solid rgba(255,255,255,0.12)'
    });

    const dot = document.createElement('span');
    Object.assign(dot.style, {
      display: 'inline-block',
      width: '10px',
      height: '10px',
      borderRadius: '50%',
      background: '#ff5252',
      boxShadow: '0 0 6px rgba(255,82,82,0.8) inset'
    });

    const text = document.createElement('span');
    text.textContent = '00:00.0';
    text.style.minWidth = '64px';
    text.style.textAlign = 'right';

    const label = document.createElement('span');
    label.textContent = 'Replying';
    label.style.opacity = '0.8';

    wrap.appendChild(dot);
    wrap.appendChild(text);
    wrap.appendChild(label);
    document.documentElement.appendChild(wrap);

    return { wrap, dot, text, label };
  }

  function formatElapsed(ms) {
    const total = Math.max(0, ms|0);
    const m = Math.floor(total / 60000);
    const s = Math.floor((total % 60000) / 1000);
    const d = Math.floor((total % 1000) / 100); // 1/10 秒
    const mm = m.toString().padStart(2, '0');
    const ss = s.toString().padStart(2, '0');
    return `${mm}:${ss}.${d}`;
  }

  // ---------------------- 状态机:合并网络 + DOM 信号 ----------------------
  let ui = null;
  let running = false;
  let startAt = 0;
  let rafId = 0;
  let netActive = 0; // 网络拦截活跃计数
  let domBusy = false; // DOM 观察到 "Stop" 按钮存在
  // 每条回复的定位与耗时记录
  let currentTurnEl = null;     // 本次生成对应的 assistant article
  let replyStartAt = 0;         // 本次生成起始时间戳

  function ensureUI() { if (!ui) ui = createTimerUI(); return ui; }

  function startTimer() {
    if (running) return;
    running = true;
    startAt = performance.now();
    replyStartAt = startAt;
    currentTurnEl = null; // 等待观察器捕捉到本次新增的 assistant turn
    const { wrap, dot, text, label } = ensureUI();
    wrap.style.display = 'flex';
    dot.style.background = '#ff5252';
    dot.style.boxShadow = '0 0 6px rgba(255,82,82,0.8) inset';
    label.textContent = 'Replying';
    const loop = () => {
      const now = performance.now();
      text.textContent = formatElapsed(now - startAt);
      rafId = requestAnimationFrame(loop);
    };
    rafId = requestAnimationFrame(loop);
  }

  function stopTimer() {
    if (!running) return;
    running = false;
    cancelAnimationFrame(rafId);
    const { dot, label } = ensureUI();
    dot.style.background = '#4caf50';
    dot.style.boxShadow = '0 0 6px rgba(76,175,80,0.8) inset';
    label.textContent = 'Done';
    // 不自动隐藏:保留耗时,便于查看;下次开始时复用

    // 在对应的 assistant 消息工具栏上标注本次用时
    try {
      const target = resolveReplyTarget();
      if (target && replyStartAt) {
        const spent = Math.max(0, (performance.now() - replyStartAt) | 0);
        annotateTurnDuration(target, spent);
      }
    } catch (_) {}
  }

  function recompute() {
    const busy = (netActive > 0) || domBusy;
    if (busy) startTimer(); else stopTimer();
  }

  // ---------------------- 监听:页面消息(来自 fetch/XHR 注入) ----------------------
  window.addEventListener('message', (ev) => {
    const d = ev && ev.data;
    if (!d || d.source !== 'gpt-reply-timer') return;
    if (d.type === 'start') { netActive += 1; recompute(); }
    else if (d.type === 'stop') { netActive = Math.max(0, netActive - 1); recompute(); }
  });

  // ---------------------- 监听:DOM(stop 按钮) ----------------------
  function observeDom() {
    const update = () => {
      // ChatGPT 一直使用 data-testid 做测试钩子,相对稳定
      const stopBtn = document.querySelector('[data-testid="stop-button"]');
      domBusy = !!stopBtn;
      recompute();
    };
    const mo = new MutationObserver((muts) => {
      // 变更频繁,做一次聚合判断即可
      update();
    });
    mo.observe(document.documentElement || document.body, { childList: true, subtree: true });
    // 初始探测
    update();
  }

  // ---------------------- 监听:DOM(assistant 新消息 + 标注恢复) ----------------------
  function isAssistantArticle(el) {
    return el && el.tagName === 'ARTICLE' && el.getAttribute('data-turn') === 'assistant';
  }

  function getLastAssistant() {
    const list = Array.from(document.querySelectorAll('article[data-turn="assistant"]'));
    return list.at(-1) || null;
  }

  function findToolbar(article) {
    if (!article) return null;
    const divs = article.querySelectorAll('div');
    const candidates = Array.from(divs).filter(d =>
      d.classList && d.classList.contains('flex') && d.classList.contains('justify-start') &&
      d.querySelector('button[aria-haspopup="menu"]')
    );
    return candidates.at(-1) || null;
  }

  function annotateTurnDuration(article, ms) {
    try {
      article.dataset.replyMs = String(ms);
      const tb = findToolbar(article);
      if (!tb) return;
      let btn = tb.querySelector('button[data-reply-duration="true"]');
      if (!btn) {
        btn = document.createElement('button');
        btn.dataset.replyDuration = 'true';
        btn.setAttribute('aria-label', '回复耗时');
        btn.className = 'text-token-text-secondary hover:bg-token-bg-secondary rounded-lg';
        const inner = document.createElement('span');
        inner.className = 'flex items-center justify-center h-8 px-2';
        inner.style.fontFamily = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
        inner.style.fontVariantNumeric = 'tabular-nums';
        inner.style.fontSize = '12px';
        btn.appendChild(inner);
        // 插到“三点”按钮左侧;否则追加到末尾
        const kebab = tb.querySelector('button[aria-haspopup="menu"]') || Array.from(tb.querySelectorAll('button')).at(-1);
        if (kebab && kebab.parentElement === tb) tb.insertBefore(btn, kebab); else tb.appendChild(btn);
      }
      const seconds = (ms / 1000).toFixed(1);
      const label = `⏱ ${seconds}s`;
      const span = btn.firstElementChild || btn;
      span.textContent = label;
    } catch (_) {}
  }

  function resolveReplyTarget() {
    // 优先使用观察到的本次新建 turn;否则回退到当前最后一条 assistant
    if (currentTurnEl && document.contains(currentTurnEl)) return currentTurnEl;
    return getLastAssistant();
  }

  function observeAssistantTurns() {
    const mo = new MutationObserver((muts) => {
      for (const m of muts) {
        if (m.type !== 'childList') continue;
        for (const n of m.addedNodes) {
          if (isAssistantArticle(n)) {
            // 记录本次生成对应的节点
            if (running && !currentTurnEl) currentTurnEl = n;
            // 若该节点已有耗时数据但未渲染徽标,尝试恢复
            const ms = Number(n?.dataset?.replyMs || '');
            if (ms > 0) annotateTurnDuration(n, ms);
          }
          // 深层嵌套新增时,尝试自底向上找到 article
          if (n.nodeType === 1) {
            const a = n.closest && n.closest('article[data-turn="assistant"]');
            if (a && isAssistantArticle(a)) {
              if (running && !currentTurnEl) currentTurnEl = a;
              const ms = Number(a?.dataset?.replyMs || '');
              if (ms > 0) annotateTurnDuration(a, ms);
            }
          }
        }
      }
    });
    mo.observe(document.documentElement || document.body, { childList: true, subtree: true });
    // 初始恢复:已存在的消息
    const last = getLastAssistant();
    if (last && last.dataset.replyMs) annotateTurnDuration(last, Number(last.dataset.replyMs));
  }

  // ---------------------- 初始化 ----------------------
  function onReady(fn) {
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn, { once: true });
    else fn();
  }

  // 先注入网络拦截,尽量抢在 App 初始化之前
  try { injectNetworkHooks(); } catch (_) {}
  // DOM 就绪后再安装 UI 与观察器
  onReady(() => { ensureUI(); observeDom(); observeAssistantTurns(); });

})();