NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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 已加载,默认暂停。点右下角「▶ 继续」启动。'
);
})();