mikecoding / ChatGPT自动继续

// ==UserScript==
// @name         ChatGPT自动继续
// @namespace    local.chatgpt.autocontinue
// @version      1.0.0
// @description  ChatGPT网页版,当AI回复完成后,自动在聊天框发送继续
// @match        https://chatgpt.com/c/*
// @run-at       document-idle
// @noframes
// @grant        GM_notification
// @grant        GM_registerMenuCommand
// @license MIT
// ==/UserScript==

(() => {
  'use strict';

  const MESSAGE = '继续';
  const CHECK_MS = 2000;
  const IDLE_GRACE_MS = 3000;
  const MIN_SEND_GAP_MS = 5000;
  const ACK_TIMEOUT_MS = 12000;
  const RETRY_WAIT_MS = 3000;
  const RESPONSE_TIMEOUT_MS = 180000;
  const REPLY_QUIET_MS = 10000;
  const MAX_FAILS = 3;
  const MAX_SENDS = 1000;
  const LIMIT_SCAN_MS = 10000;
  const HARD_LIMIT_RE =
    /已达到最大长度|达到最大长度|maximum length/i;

  const EDITOR_SEL =
    'div[contenteditable="true"][role="textbox"],' +
    'div[contenteditable="true"].ProseMirror';

  let enabled = false;
  let rootEl = null;
  let path = location.pathname;
  let pending = null;
  let preparing = false;
  let isStreaming = false;
  let idleSince = 0;
  let lastSend = 0;
  let nextAttemptAt = 0;
  let sendCount = 0;
  let fails = 0;
  let hardStop = '';
  let statusText = '';
  let statusAt = 0;
  let lastLimitScan = 0;
  let generation = 0;

  const log = (...args) =>
    console.log(
      '%c[自动继续 v5]',
      'color:#10a37f;font-weight:bold',
      ...args
    );

  const visible = el =>
    !!(el && el.isConnected && el.getClientRects().length);

  // ---------- 定位输入区 ----------

  function getRoot() {
    if (
      rootEl &&
      rootEl.isConnected &&
      [...rootEl.querySelectorAll(EDITOR_SEL)].some(visible)
    ) {
      return rootEl;
    }

    rootEl = null;

    const ed = [
      ...document.querySelectorAll(EDITOR_SEL)
    ].find(visible);

    if (!ed) return null;

rootEl =
  ed.closest('.ComposerLayoutRoot-XCKS7O') ||
  ed.closest('[data-composer-layout="single-line"]')
    ?.parentElement ||
  ed.closest('form') ||
  ed.parentElement;

    log(
      '输入区定位:',
      rootEl?.getAttribute('data-composer-body') !== null
        ? 'composer-body'
        : rootEl?.tagName
    );

    return rootEl;
  }

  function getEditor() {
    const r = getRoot();

    return r
      ? [...r.querySelectorAll(EDITOR_SEL)].find(visible) || null
      : null;
  }

  // ---------- 检测停止按钮 ----------

  function getStop() {
    const r = getRoot();
    if (!r) return null;

    for (
      const b of r.querySelectorAll(
        'button[aria-label],button[data-testid]'
      )
    ) {
      const label =
        (b.getAttribute('aria-label') || '').trim();

      const tid =
        (b.getAttribute('data-testid') || '').trim();

      if (
        visible(b) &&
        (
          tid === 'stop-button' ||
          /^(stop|stop generating|stop response|cancel response|停止|停止生成|停止回复|停止回答|中断生成|中止生成|终止生成)$/i.test(label)
        )
      ) {
        return b;
      }
    }

    return null;
  }

  // ---------- 检测发送按钮 ----------

  function getSend() {
    const r = getRoot();
    if (!r) return null;

    for (const b of r.querySelectorAll('button')) {
      const label =
        (b.getAttribute('aria-label') || '').trim();

      const tid =
        (b.getAttribute('data-testid') || '').trim();

      if (
        visible(b) &&
        !b.disabled &&
        b.getAttribute('aria-disabled') !== 'true' &&
        (
          tid === 'send-button' ||
          tid === 'composer-send-button' ||
          /^(send|send message|send prompt|发送|发送消息|发送信息|提交)$/i.test(label)
        )
      ) {
        return b;
      }
    }

    return null;
  }

  // ---------- 消息检测 ----------

  function lastTurn(role) {
    const turns = document.querySelectorAll(
      `[data-message-author-role="${role}"]`
    );

    return {
      count: turns.length,
      el: turns.length
        ? turns[turns.length - 1]
        : null
    };
  }

  function newTurn(before, role) {
    const now = lastTurn(role);

    return (
      now.count > before.count ||
      (!!now.el && now.el !== before.el)
    );
  }

  // ---------- 长度限制检测 ----------

  function limitHit() {
    const now = Date.now();

    if (now - lastLimitScan < LIMIT_SCAN_MS) {
      return '';
    }

    lastLimitScan = now;

    for (
      const el of document.querySelectorAll('[role="alert"]')
    ) {
      const t = (el.textContent || '').trim();

      if (t && HARD_LIMIT_RE.test(t)) {
        return t.slice(0, 120);
      }
    }

    return '';
  }

  // ---------- 状态管理 ----------

  function status(t) {
    statusText = t;
    statusAt = Date.now();
    render();
  }

  function notifyStop(why) {
    hardStop = why;
    enabled = false;
    preparing = false;
    generation++;

    log('停止:', why);
    status('⛔ ' + why);

    try {
      if (typeof GM_notification === 'function') {
        GM_notification({
          title: 'ChatGPT 自动继续已停止',
          text: why,
          timeout: 8000
        });
      }
    } catch (_) {}
  }

  function attemptFailed(why) {
    fails++;

    log(
      `发送未生效(${fails}/${MAX_FAILS}):${why}`
    );

    pending = null;
    preparing = false;

    if (fails >= MAX_FAILS) {
      return notifyStop(
        `连续 ${fails} 次发送失败:${why}`
      );
    }

    nextAttemptAt = Date.now() + RETRY_WAIT_MS;
    idleSince = Date.now() - IDLE_GRACE_MS;

    status(
      `⚠ ${why},${Math.ceil(RETRY_WAIT_MS / 1000)} 秒后真正重试(${fails}/${MAX_FAILS})`
    );
  }

  function completed() {
    pending = null;
    fails = 0;
    idleSince = Date.now();
    nextAttemptAt = 0;

    log('本轮已确认结束,等待下一次发送');

    status('✅ 回复已结束,等待发送下一条');
  }

  // ---------- 真正点击发送 ----------

  function clickSend(expectedGeneration) {
    preparing = false;

    if (
      !enabled ||
      hardStop ||
      generation !== expectedGeneration ||
      location.pathname !== path
    ) {
      return;
    }

    if (getStop()) {
      status('⏳ 页面正在生成,暂不发送');
      return;
    }

    const ed = getEditor();

    if (
      !ed ||
      (ed.textContent || '').trim() !== MESSAGE
    ) {
      return attemptFailed(
        '输入框内容变化,未点击发送'
      );
    }

    const b = getSend();

    if (!b) {
      return attemptFailed(
        '未找到可点击的发送按钮(输入内容已保留)'
      );
    }

    const beforeUser = lastTurn('user');
    const beforeAssistant = lastTurn('assistant');

    try {
      b.click();
    } catch (e) {
      return attemptFailed(
        '点击发送按钮报错:' + String(e)
      );
    }

    lastSend = Date.now();
    sendCount++;

    pending = {
      at: lastSend,
      beforeUser,
      beforeAssistant,
      ack: false,
      sawStop: false,
      replyText: null,
      quietAt: 0
    };

    log(
      `第 ${sendCount} 次点击发送;等待页面确认`,
      {
        label: b.getAttribute('aria-label'),
        testid: b.getAttribute('data-testid')
      }
    );

    status(
      `📤 第 ${sendCount} 次已点击发送,正在核实是否生效…`
    );
  }

  // ---------- 写入消息 ----------

  function trySend() {
    if (
      preparing ||
      pending ||
      !enabled ||
      getStop()
    ) {
      return;
    }

    const ed = getEditor();

    if (!ed) {
      status('⏳ 未找到当前输入框,继续等待');
      return;
    }

    const existing =
      (ed.textContent || '').trim();

    if (existing && existing !== MESSAGE) {
      status(
        '⏸ 输入框有手动草稿,不覆盖、不发送'
      );
      return;
    }

    if (!existing) {
      ed.focus();

      let ok = false;

      try {
        ok = document.execCommand(
          'insertText',
          false,
          MESSAGE
        );
      } catch (e) {
        log('插入文字出错:', e);
      }

      if (
        !ok ||
        (ed.textContent || '').trim() !== MESSAGE
      ) {
        return attemptFailed(
          'Firefox/编辑器未接受文字插入'
        );
      }

      log('已写入输入框:', MESSAGE);
    } else {
      log(
        '复用上次未成功发送而保留的文字,再次尝试点击'
      );
    }

    preparing = true;

    const token = generation;

    status(
      '✍️ 已准备好文字,等待发送按钮变为可用…'
    );

    setTimeout(
      () => clickSend(token),
      250
    );
  }

  // ---------- 主循环 ----------

  function evaluate() {
    if (!pill.isConnected && document.body) {
      document.body.appendChild(pill);
    }

    const now = Date.now();

    // 会话切换时自动暂停
    if (location.pathname !== path) {
      path = location.pathname;
      enabled = false;
      pending = null;
      preparing = false;
      generation++;
      rootEl = null;
      lastSend = 0;
      idleSince = 0;
      nextAttemptAt = 0;
      fails = 0;
      hardStop = '';

      status(
        '⏸ 已切换会话,出于安全考虑已暂停;请手动启动'
      );

      log('切换会话:已暂停', path);
    }

    const wasStreaming = isStreaming;

    isStreaming = !!getStop();

    if (isStreaming) {
      idleSince = 0;

      if (pending) {
        pending.ack = true;
        pending.sawStop = true;
        fails = 0;
      }
    } else if (wasStreaming && !pending) {
      idleSince = now;
    }

    if (!enabled || hardStop) {
      render();
      return;
    }

    // ---------- 等待上次发送确认 ----------

    if (pending) {
      if (
        !pending.ack &&
        newTurn(pending.beforeUser, 'user')
      ) {
        pending.ack = true;
        fails = 0;

        log(
          '已确认新用户消息出现在页面中'
        );
      }

      // 已经看到停止按钮,等待它消失
      if (pending.sawStop) {
        if (!isStreaming) {
          completed();
        }

        render();
        return;
      }

      // 尚未确认发送成功
      if (!pending.ack) {
        if (
          now - pending.at >= ACK_TIMEOUT_MS
        ) {
          attemptFailed(
            '点击后未看到新用户消息,也未检测到开始生成'
          );
        }

        render();
        return;
      }

      // 短回复可能在两次轮询之间完成
      if (
        newTurn(
          pending.beforeAssistant,
          'assistant'
        )
      ) {
        const curr =
          lastTurn('assistant').el;

        const t = curr
          ? (curr.textContent || '')
          : '';

        if (t !== pending.replyText) {
          pending.replyText = t;
          pending.quietAt = now;
        } else if (
          pending.quietAt &&
          now - pending.quietAt >= REPLY_QUIET_MS
        ) {
          log(
            '未观察到停止按钮,但新助手消息已稳定 10 秒'
          );

          completed();
        }
      }

      if (
        pending &&
        now - pending.at >= RESPONSE_TIMEOUT_MS
      ) {
        notifyStop(
          '发送已确认,但无法判断回复是否结束,已停止以免重复发送'
        );
      }

      render();
      return;
    }

    if (preparing || isStreaming) {
      render();
      return;
    }

    if (!idleSince) {
      idleSince = now;
    }

    const hard = limitHit();

    if (hard) {
      notifyStop(hard);
      render();
      return;
    }

    if (sendCount >= MAX_SENDS) {
      notifyStop(
        `达到发送上限 ${MAX_SENDS} 次`
      );
      render();
      return;
    }

    if (
      now >= nextAttemptAt &&
      now - idleSince >= IDLE_GRACE_MS &&
      now - lastSend >= MIN_SEND_GAP_MS
    ) {
      trySend();
    }

    render();
  }

  // ---------- 右下角按钮 ----------

  const pill = document.createElement('div');

  pill.style.cssText =
    'position:fixed;' +
    'right:14px;' +
    'bottom:14px;' +
    'z-index:2147483000;' +
    'display:flex;' +
    'align-items:center;' +
    'gap:10px;' +
    'background:rgba(24,24,26,.94);' +
    'color:#fff;' +
    'padding:8px 12px;' +
    'border-radius:16px;' +
    'box-shadow:0 4px 16px rgba(0,0,0,.35);' +
    'font:500 12.5px/1.35 system-ui,sans-serif;' +
    'max-width:420px;';

  const btn = document.createElement('button');

  btn.type = 'button';

  btn.style.cssText =
    'flex:none;' +
    'border:0;' +
    'border-radius:999px;' +
    'padding:8px 16px;' +
    'font:600 13px/1 system-ui,sans-serif;' +
    'color:#fff;' +
    'cursor:pointer;' +
    'white-space:nowrap;';

  const stat = document.createElement('span');

  stat.style.cssText =
    'flex:1 1 auto;' +
    'min-width:0;' +
    'word-break:break-word;';

  pill.append(btn, stat);

  if (document.body) {
    document.body.appendChild(pill);
  } else {
    addEventListener(
      'DOMContentLoaded',
      () => document.body.appendChild(pill)
    );
  }

  // ---------- 暂停 / 继续 ----------

  function toggle() {
    generation++;

    if (enabled) {
      enabled = false;
      pending = null;
      preparing = false;

      status(
        '⏸ 已暂停,不再自动发送'
      );

      log('手动暂停');
    } else {
      enabled = true;
      hardStop = '';
      fails = 0;
      pending = null;
      preparing = false;
      nextAttemptAt = 0;

      isStreaming = !!getStop();

      idleSince = isStreaming
        ? 0
        : Date.now() - IDLE_GRACE_MS;

      status(
        isStreaming
          ? '▶ 已启动,等待本轮生成结束'
          : '▶ 已启动,准备发送“继续”'
      );

      log(
        '手动启动,当前检测生成中:',
        isStreaming
      );

      setTimeout(evaluate, 100);
    }

    render();
  }

  btn.addEventListener('click', toggle);

  // ---------- 状态显示 ----------

  let lastRendered = '';

  function render() {
    let label = enabled
      ? '⏸ 暂停'
      : '▶ 继续';

    const bg = hardStop
      ? '#d93025'
      : isStreaming
        ? '#1a73e8'
        : '#10a37f';

    const live =
      statusText &&
      Date.now() - statusAt < 9000;

    let t = live
      ? statusText
      : !enabled
        ? (
            hardStop
              ? '⛔ ' + hardStop
              : '⏸ 已暂停'
          )
        : pending
          ? (
              pending.ack
                ? '⌛ 消息已发送,等待本轮回复结束…'
                : '🔎 已点击发送,等待确认…'
            )
          : isStreaming
            ? '🔄 正在生成,暂不发送'
            : preparing
              ? '✍️ 准备点击发送按钮…'
              : '✅ 等待发送(已尝试 ' +
                sendCount + ' 次)';

    const value =
      label + '|' + bg + '|' + t;

    if (value !== lastRendered) {
      lastRendered = value;

      btn.textContent = label;
      btn.style.background = bg;
      stat.textContent = t;
    }
  }

  // ---------- 油猴菜单 ----------

  try {
    if (
      typeof GM_registerMenuCommand === 'function'
    ) {
      GM_registerMenuCommand(
        '暂停 / 继续自动发送',
        toggle
      );

      GM_registerMenuCommand(
        '显示当前诊断信息',
        () => {
          const r = getRoot();

          const info = {
            enabled,
            path,
            editorFound: !!getEditor(),
            rootFound: !!r,
            stopFound: !!getStop(),
            sendFound: !!getSend(),
            pending,
            preparing,
            fails,
            sendCount,
            lastSend,

            composerButtons: r
              ? [
                  ...r.querySelectorAll('button')
                ].map(b => ({
                  label:
                    b.getAttribute('aria-label'),
                  testid:
                    b.getAttribute('data-testid'),
                  disabled: b.disabled,
                  visible: visible(b)
                })).slice(-20)
              : []
          };

          log('当前诊断:', info);

          status(
            '🔎 诊断信息已输出到浏览器控制台(F12)'
          );
        }
      );
    }
  } catch (_) {}

  // ---------- 启动 ----------

  setInterval(evaluate, CHECK_MS);

  evaluate();

  log(
    'v5 已加载,默认暂停。点右下角「▶ 继续」启动。'
  );
})();