// ==UserScript== // @name ChatGPT 排队队列 (Queue) // @namespace https://github.com/local/chatgpt-queue // @version 1.4.0 // @description GPT 正在生成时,新消息(含图片/附件)自动进入输入框上方的队列;可拖动排序、行内编辑、删除;GPT 答完自动逐条发送(带图自动重新上传)。支持 Alt+Enter 强制入队、暂停自动驾驶、刷新持久化。 // @author you // @icon https://chatgpt.com/favicon.ico // @match https://chatgpt.com/* // @match https://chat.openai.com/* // @run-at document-idle // @noframes // @grant none // @license MIT // ==/UserScript== (function () { 'use strict'; /* ────────────────────────────────────────────────────────────── * CONFIG —— 唯一的脆弱点。ChatGPT 改版时只改这里。 * 每个 key 给一组候选选择器,按顺序取第一个命中的。 * ────────────────────────────────────────────────────────────── */ const SEL = { composer: ['#prompt-textarea', 'div[contenteditable="true"][id="prompt-textarea"]', 'form div[contenteditable="true"]'], sendBtn: ['button[data-testid="send-button"]', 'button[aria-label="发送提示"]', '#composer-submit-button', 'button[aria-label="Send prompt"]', 'button[aria-label="Send message"]'], stopBtn: ['button[data-testid="stop-button"]', 'button[aria-label="停止回答"]', 'button[aria-label="停止流式传输"]', 'button[aria-label="Stop streaming"]', 'button[aria-label="Stop generating"]'], }; const STORE_KEY = 'cgptQueue.v1'; const IDLE_DEBOUNCE = 700; // 停止按钮消失后,确认空闲需稳定的毫秒数(防流式闪烁) const AUTO_SEND_DELAY= 600; // 空闲后到自动发送的缓冲,让页面落定 const TICK_MS = 350; // 轮询间隔(兜底,配合 MutationObserver) /* ── 小工具 ── */ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const pick = (list) => { for (const s of list) { const el = document.querySelector(s); if (el) return el; } return null; }; const visible = (el) => el && el.offsetParent !== null; function waitUntil(cond, timeout) { return new Promise((resolve) => { const t0 = Date.now(); const iv = setInterval(() => { if (cond() || Date.now() - t0 > timeout) { clearInterval(iv); resolve(); } }, 60); }); } /* ── ChatGPT 集成层 ── */ const getComposer = () => pick(SEL.composer); const getSendBtn = () => pick(SEL.sendBtn); // 只看 DOM 里在不在,不要 visible() —— 某些瞬间停止按钮 offsetParent 为 null 会被误判为空闲 → 漏排队直发 const getStopBtn = () => pick(SEL.stopBtn); /* ── 附件(图片/文件)截获与重灌 ── */ let pendingFiles = []; // 用户已附加但尚未发送的 File,截获自 paste/drop/ function captureFiles(fileList) { if (!fileList || !fileList.length) return; for (const f of fileList) { if (!f) continue; const dup = pendingFiles.some((g) => g.name === f.name && g.size === f.size && g.lastModified === f.lastModified); if (!dup) pendingFiles.push(f); } updateUI(); } function getFileInput() { const inputs = [...document.querySelectorAll('input[type="file"]')]; if (!inputs.length) return null; return inputs.find((i) => (i.accept || '').includes('image')) || inputs.find((i) => i.multiple) || inputs[0]; } // 把 File 数组重新塞回 ChatGPT 的文件 input,触发它自己的上传流程 function injectFiles(files) { const input = getFileInput(); if (!input) return false; const dt = new DataTransfer(); files.forEach((f) => dt.items.add(f)); input.files = dt.files; input.dispatchEvent(new Event('change', { bubbles: true })); return true; } // 清掉 composer 里已附加的全部缩略图(逐个点 × 按钮)。 // 关键:每点一个 × ChatGPT 会重渲染附件区,必须每轮重新查询,否则静态列表里的 // 旧按钮引用已游离、再点无效(多图时只能删掉一张)。子串模糊匹配 aria-label/title, // 范围限定在 composer 的 form 内,避免误点消息区按钮。 async function removeAttachments() { const re = /remove|delete|移除|删除|清除|dismiss/; let clicked = 0; for (let i = 0; i < 20; i++) { const composer = getComposer(); const scope = (composer && composer.closest('form')) || document; const btn = [...scope.querySelectorAll('button')].find((b) => { if (!visible(b)) return false; const lbl = ((b.getAttribute('aria-label') || '') + ' ' + (b.title || '')).toLowerCase(); return re.test(lbl); }); if (!btn) break; try { btn.click(); clicked++; } catch (e) {} await sleep(90); // 等 ChatGPT 重渲染再找下一个 } return clicked; } const firstImage = (item) => (item.files || []).find((f) => f.type && f.type.startsWith('image/')) || null; function disposeItem(item) { if (item && item._url) { URL.revokeObjectURL(item._url); item._url = null; } } function getComposerText() { const el = getComposer(); return el ? (el.innerText || '').replace(//g, '').trim() : ''; } function setComposerText(text) { const el = getComposer(); if (!el) return false; el.focus(); const sel = window.getSelection(); const range = document.createRange(); range.selectNodeContents(el); sel.removeAllRanges(); sel.addRange(range); // 先清空选中内容,再插入 —— execCommand 走原生输入路径,ProseMirror/React 才认 let ok = document.execCommand('insertText', false, text); if (!ok) { el.textContent = text; el.dispatchEvent(new InputEvent('input', { bubbles: true })); } return true; } function clearComposer() { const el = getComposer(); if (!el) return; el.focus(); const sel = window.getSelection(); const range = document.createRange(); range.selectNodeContents(el); sel.removeAllRanges(); sel.addRange(range); if (!document.execCommand('delete')) setComposerText(''); } async function sendNow(text, files) { if (files && files.length) { injectFiles(files); // 先灌图,触发上传 await sleep(120); } if (text) setComposerText(text); // 等发送按钮可点:ChatGPT 在附件上传完成前会禁用发送,所以这一步同时覆盖了「等上传」 await waitUntil(() => { const b = getSendBtn(); return b && !b.disabled && !getStopBtn(); }, files && files.length ? 30000 : 2000); const btn = getSendBtn(); if (btn && !btn.disabled) { btn.click(); return true; } // 兜底:直接派发 Enter const el = getComposer(); if (el) { el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true })); return true; } return false; } /* ── 状态 + 持久化 ── */ let state = { queue: [], autopilot: true, collapsed: false }; let busy = false; // GPT 是否正在生成 let idleTimer = null; let sending = false; // 自动发送进行中,防重入 let justSentUntil = 0; // 刚发出消息的宽限期:发出到「停止按钮出现」之间有空窗,这段时间一律按忙处理 function load() { try { const raw = localStorage.getItem(STORE_KEY); if (raw) { const p = JSON.parse(raw); state.queue = Array.isArray(p.queue) ? p.queue.filter((x) => x && typeof x.text === 'string').map((x) => ({ id: idSeq++, text: x.text, files: [] })) : []; state.autopilot = p.autopilot !== false; state.collapsed = !!p.collapsed; } } catch (e) { /* 损坏就用默认 */ } } function save() { try { // File 无法序列化:带附件的条目不持久化,刷新后只保留纯文本条目(会话内图片完整可用) const persist = { queue: state.queue.filter((it) => !it.files || it.files.length === 0).map((it) => ({ text: it.text })), autopilot: state.autopilot, collapsed: state.collapsed, }; localStorage.setItem(STORE_KEY, JSON.stringify(persist)); } catch (e) {} } /* ── 队列操作 ── */ let idSeq = 1; function enqueue(text, files) { const t = (text || '').trim(); const fs = files || []; if (!t && !fs.length) return; state.queue.push({ id: idSeq++, text: t, files: fs }); save(); renderList(); updateUI(); } function removeAt(i) { disposeItem(state.queue.splice(i, 1)[0]); save(); renderList(); updateUI(); } function clearAll() { state.queue.forEach(disposeItem); state.queue = []; save(); renderList(); updateUI(); } function moveItem(from, insertAt) { if (from === insertAt) return; const item = state.queue.splice(from, 1)[0]; if (from < insertAt) insertAt--; state.queue.splice(insertAt, 0, item); save(); renderList(); updateUI(); } // 编辑:把队列项的文字+图片灌回输入框(覆盖现有内容),并从队列移除,交回用户手动改/发 async function pullToComposer(i) { const item = state.queue[i]; if (!item) return; clearComposer(); await removeAttachments(); // 清掉输入框现有附件再覆盖 if (item.text) setComposerText(item.text); if (item.files && item.files.length) injectFiles(item.files); disposeItem(state.queue.splice(i, 1)[0]); save(); renderList(); updateUI(); } /* ── 自动发送核心 ── */ async function maybeSendNext() { if (sending || busy || !state.autopilot || state.queue.length === 0) return; sending = true; try { await sleep(AUTO_SEND_DELAY); if (busy || !state.autopilot) return; const next = state.queue[0]; if (!next) return; // 存草稿 → 清空 composer → 发队列项 → 还原草稿,这样输入框里有草稿时队列照样能发 const dText = getComposerText(); const dFiles = pendingFiles.slice(); if (dText || dFiles.length) { clearComposer(); await removeAttachments(); } const sent = await sendNow(next.text, next.files); if (sent) { disposeItem(state.queue.shift()); save(); renderList(); await waitUntil(() => busy, 5000); // 等它真的开始生成,避免下一条抢跑(带图上传更久) if (dText) setComposerText(dText); // 还原用户草稿 if (dFiles.length) { injectFiles(dFiles); pendingFiles = dFiles.slice(); } } } finally { sending = false; updateUI(); } } /* ── 忙碌状态监测(防抖 + 边沿驱动自动发送) ── */ function checkState() { const streaming = !!getStopBtn(); if (streaming) { if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } if (!busy) { busy = true; updateUI(); } } else if (busy && !idleTimer) { idleTimer = setTimeout(() => { idleTimer = null; busy = false; updateUI(); }, IDLE_DEBOUNCE); } if (!busy) maybeSendNext(); } /* ── 拦截 Enter ── */ function onKeydown(e) { if (e.key !== 'Enter' || e.shiftKey) return; if (e.isComposing || e.keyCode === 229) return; // IME 选词中,放过(中文输入安全) const el = getComposer(); if (!el || (e.target !== el && !el.contains(e.target))) return; const text = getComposerText(); const files = pendingFiles.slice(); // 当前已附加的图片/文件 // 实时查 + 宽限锁:busy 标志滞后、且发出到停止按钮出现之间有空窗,都会导致漏拦截直发 const liveBusy = busy || !!getStopBtn() || Date.now() < justSentUntil; const forceQueue = e.altKey; // Alt+Enter:无论忙不忙都入队 if (forceQueue || liveBusy) { if (!text && !files.length) return; e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); enqueue(text, files); clearComposer(); pendingFiles = []; // 先清空 composer 现有附件,清干净后再触发自动发送(否则重灌的图会和没删净的原图叠加) removeAttachments().then(() => { if (!liveBusy) maybeSendNext(); }); } else { pendingFiles = []; // 正常直发,附件被 ChatGPT 消费,清空截获缓存 justSentUntil = Date.now() + 3000; // 进入宽限期:随后 3 秒内的回车一律排队,堵住「思考中」空窗 } } /* ────────────────────────────────────────────────────────────── * UI * ────────────────────────────────────────────────────────────── */ let panel, listEl, countEl, hintEl, autoBtn, body; let dragIndex = -1; function injectStyles() { if (document.getElementById('cgptq-style')) return; const css = ` #cgptq { width: 100%; box-sizing: border-box; margin: 0 0 8px 0; font: 13px/1.4 -apple-system, "Segoe UI", system-ui, sans-serif; color: var(--cgptq-fg); --cgptq-fg:#374151; --cgptq-bg:rgba(255,255,255,.85); --cgptq-bd:rgba(0,0,0,.12); --cgptq-row:rgba(0,0,0,.035); --cgptq-rowh:rgba(0,0,0,.07); --cgptq-acc:#10a37f; } @media (prefers-color-scheme: dark) { #cgptq { --cgptq-fg:#d1d5db; --cgptq-bg:rgba(32,33,35,.85); --cgptq-bd:rgba(255,255,255,.14); --cgptq-row:rgba(255,255,255,.05); --cgptq-rowh:rgba(255,255,255,.1); } } #cgptq .cgptq-box { background:var(--cgptq-bg); border:1px solid var(--cgptq-bd); border-radius:12px; backdrop-filter:blur(8px); box-shadow:0 4px 16px rgba(0,0,0,.08); overflow:hidden; } #cgptq .cgptq-head { display:flex; align-items:center; gap:8px; padding:7px 10px; cursor:default; } #cgptq .cgptq-title { font-weight:600; display:flex; align-items:center; gap:6px; } #cgptq .cgptq-badge { background:var(--cgptq-acc); color:#fff; border-radius:9px; padding:1px 7px; font-size:11px; font-weight:700; } #cgptq .cgptq-spacer { flex:1; } #cgptq button.cgptq-btn { all:unset; cursor:pointer; padding:3px 8px; border-radius:7px; font-size:12px; color:var(--cgptq-fg); opacity:.8; transition:.12s; user-select:none; } #cgptq button.cgptq-btn:hover { background:var(--cgptq-rowh); opacity:1; } #cgptq .cgptq-body { padding:0 8px 8px; } #cgptq .cgptq-hint { font-size:11.5px; opacity:.7; padding:2px 4px 6px; } #cgptq .cgptq-list { display:flex; flex-direction:column; gap:4px; max-height:34vh; overflow-y:auto; } #cgptq .cgptq-row { display:flex; align-items:center; gap:7px; padding:6px 8px; background:var(--cgptq-row); border-radius:8px; transition:background .12s; border-top:2px solid transparent; border-bottom:2px solid transparent; } #cgptq .cgptq-row:hover { background:var(--cgptq-rowh); } #cgptq .cgptq-row.cgptq-dragging { opacity:.4; } #cgptq .cgptq-row.cgptq-over-top { border-top-color:var(--cgptq-acc); } #cgptq .cgptq-row.cgptq-over-bot { border-bottom-color:var(--cgptq-acc); } #cgptq .cgptq-grip { cursor:grab; opacity:.45; font-size:13px; line-height:1; } #cgptq .cgptq-grip:active { cursor:grabbing; } #cgptq .cgptq-num { opacity:.5; font-size:11px; min-width:14px; text-align:right; } #cgptq .cgptq-thumb { width:26px; height:26px; border-radius:5px; object-fit:cover; flex:none; } #cgptq .cgptq-clip { font-size:12px; opacity:.7; flex:none; } #cgptq .cgptq-text { flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } #cgptq .cgptq-act { opacity:0; transition:.12s; display:flex; gap:2px; } #cgptq .cgptq-row:hover .cgptq-act { opacity:1; } #cgptq .cgptq-act span { cursor:pointer; padding:2px 4px; border-radius:5px; font-size:12px; opacity:.7; } #cgptq .cgptq-act span:hover { background:var(--cgptq-rowh); opacity:1; } `; const st = document.createElement('style'); st.id = 'cgptq-style'; st.textContent = css; document.head.appendChild(st); } function buildPanel() { panel = document.createElement('div'); panel.id = 'cgptq'; panel.innerHTML = `