Sleazy Fork is available in English.
在输入框右侧添加蓝色"复制发送"按钮,在编辑弹窗中添加记忆发送功能;指令抽屉命令、输入框/编辑弹窗包含触发文本时自动发送(均可配置)
// ==UserScript==
// @license MIT
// @name DZMM Copy and Send
// @namespace https://www.dzmm.ai/
// @version 1.9
// @description 在输入框右侧添加蓝色"复制发送"按钮,在编辑弹窗中添加记忆发送功能;指令抽屉命令、输入框/编辑弹窗包含触发文本时自动发送(均可配置)
// @author Allex0716
// @match https://www.dzmm.ai/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// ==/UserScript==
(function () {
'use strict';
// 保存到内存(JS 变量,页面刷新后自动清空)
let savedContent = '';
let savedEditContent = '';
// ========== 工具函数 ==========
/**
* 查找页面中真正的发送按钮
*
* 发送按钮(lucide-send)仅在输入框有内容时才出现,
* 需要排除自己注入的蓝色按钮,否则会死循环。
*
* @returns {HTMLButtonElement|null}
*/
function findSendButton() {
const allSendIcons = document.querySelectorAll('.lucide-send');
for (const icon of allSendIcons) {
const btn = icon.closest('button');
// 排除脚本注入的按钮:聊天输入框蓝色按钮、编辑弹窗内按钮(图标同为 lucide-send)
if (btn && !btn.classList.contains('dzmm-copy-send-btn')
&& !btn.classList.contains('dzmm-copy-send-edit-btn')) {
return btn;
}
}
return null;
}
/**
* 找到输入框旁边的按钮容器
*
* 该容器(div.flex.items-center)在页面初始状态就存在,
* 包含 plus 按钮或 send 按钮。
*
* @returns {HTMLElement|null}
*/
function findContainer() {
const input = document.querySelector('#chat-input');
if (!input) return null;
const parent = input.parentElement;
if (!parent) return null;
return parent.querySelector(':scope > .flex.items-center');
}
/**
* 获取容器内第一个按钮及其包裹层,用于克隆 DOM 结构
*
* @param {HTMLElement} container - 按钮容器
* @returns {{ wrapper: HTMLElement|null, button: HTMLElement|null }}
*/
function getFirstButtonInfo(container) {
const firstWrapper = container.querySelector(':scope > div');
const firstButton = firstWrapper ? firstWrapper.querySelector('button') : null;
return { wrapper: firstWrapper, button: firstButton };
}
/**
* 用原生 setter 设置 textarea 的值并触发 React 状态更新
*
* @param {HTMLTextAreaElement} el
* @param {string} value
*/
function setNativeValue(el, value) {
const nativeSetter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
).set;
nativeSetter.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
}
/**
* 动态查找发送按钮并点击
*
* 发送按钮可能在 React 渲染后才出现,通过 requestAnimationFrame 轮询等待。
*
* @param {number} retries - 剩余重试次数
*/
function triggerSend(retries = 10) {
const sendBtn = findSendButton();
if (sendBtn) {
sendBtn.click();
return;
}
if (retries > 0) {
requestAnimationFrame(() => triggerSend(retries - 1));
}
}
// ========== 聊天输入框蓝色按钮 ==========
/**
* 在聊天输入框右侧注入蓝色"记忆发送"按钮
*
* @returns {boolean} 是否注入成功(或已存在)
*/
function injectChatButton() {
const input = document.querySelector('#chat-input');
const container = findContainer();
if (!input || !container) return false;
// 避免重复注入
if (container.querySelector('.dzmm-copy-send-btn')) return true;
const { wrapper, button } = getFirstButtonInfo(container);
if (!wrapper || !button) return false;
// 创建新按钮包裹层(克隆结构)
const newWrapper = document.createElement('div');
newWrapper.style.cssText = wrapper.style.cssText;
newWrapper.style.marginRight = '6px';
// 创建蓝色按钮,克隆 send 按钮的样式
const newButton = document.createElement('button');
newButton.className = 'inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium ring-offset-background transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 active:opacity-70 py-2 rounded-full h-9 px-4 relative dzmm-copy-send-btn bg-blue-500 text-white hover:bg-blue-600';
newButton.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send size-4 shrink-0 -translate-x-px"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg>';
newButton.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const currentContent = input.value.trim();
if (currentContent) {
// 输入框有内容 → 保存并发送
savedContent = currentContent;
triggerSend();
} else if (savedContent) {
// 输入框无内容,但有保存的内容 → 填充并发送
setNativeValue(input, savedContent);
triggerSend();
}
// 无内容也无保存 → 不执行任何操作
});
newWrapper.appendChild(newButton);
// 插入到容器第一个按钮之前
container.insertBefore(newWrapper, wrapper);
return true;
}
// ========== 编辑弹窗蓝色按钮 ==========
/**
* 在编辑弹窗中找到关键 DOM 元素
*
* @returns {{ saveButton: HTMLButtonElement, buttonRow: HTMLElement, textarea: HTMLTextAreaElement, dialog: HTMLElement }|null}
*/
function findEditDialogElements() {
const allButtons = document.querySelectorAll('button');
for (const btn of allButtons) {
// 跳过已经注入过的按钮
if (btn.dataset.dzmmEditInjected) continue;
if (btn.textContent.includes('保存并重新生成回复')) {
// 按钮所在的容器(按钮行)
const buttonRow = btn.parentElement;
if (!buttonRow) continue;
// 向上查找弹窗容器,再向内查找 textarea
const dialog = btn.closest('[role="dialog"]')
|| btn.closest('[data-slot="dialog-content"]')
|| btn.closest('.fixed');
if (!dialog) continue;
const textarea = dialog.querySelector('textarea');
if (!textarea) continue;
return { saveButton: btn, buttonRow, textarea, dialog };
}
}
return null;
}
/**
* 在编辑弹窗的「保存并重新生成回复」按钮下方注入蓝色"记忆发送"按钮
*
* @returns {boolean} 是否注入成功(或弹窗未打开)
*/
function injectEditDialogButton() {
const elements = findEditDialogElements();
if (!elements) return false;
const { saveButton, buttonRow, textarea } = elements;
// 标记已注入,避免重复处理
saveButton.dataset.dzmmEditInjected = '1';
// 创建纵向 flex 容器包裹保存按钮和下方按钮行
const columnWrap = document.createElement('div');
columnWrap.style.display = 'flex';
columnWrap.style.flexDirection = 'column';
columnWrap.style.flex = '1';
columnWrap.style.minWidth = '0';
// 创建下方按钮行(清空 + 蓝色按钮,各占 50%)
const bottomRow = document.createElement('div');
bottomRow.style.display = 'flex';
bottomRow.style.gap = '6px';
// 清空按钮
const clearBtn = document.createElement('button');
clearBtn.className = saveButton.className;
// 清空按钮使用独立类名,避免与发送按钮的 dzmm-copy-send-edit-btn 混淆
clearBtn.classList.add('dzmm-copy-send-edit-clear-btn');
clearBtn.style.flex = '1';
clearBtn.textContent = '清空';
clearBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
setNativeValue(textarea, '');
});
// 蓝色按钮,宽度一半
const blueBtn = document.createElement('button');
blueBtn.className = saveButton.className;
blueBtn.classList.add('dzmm-copy-send-edit-btn');
blueBtn.classList.remove('bg-primary', 'text-primary-foreground', 'hover:bg-primary/90');
blueBtn.classList.add('bg-blue-500', 'text-white', 'hover:bg-blue-600');
blueBtn.style.flex = '1';
blueBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send size-4 shrink-0 -translate-x-px"><path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path><path d="m21.854 2.147-10.94 10.939"></path></svg>';
blueBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const currentContent = textarea.value.trim();
if (currentContent) {
// 弹窗有内容 → 保存并点击「保存并重新生成回复」
savedEditContent = currentContent;
saveButton.click();
} else if (savedEditContent) {
// 弹窗无内容,但有保存的内容 → 填充并点击
setNativeValue(textarea, savedEditContent);
requestAnimationFrame(() => {
saveButton.click();
});
}
// 无内容也无保存 → 不执行任何操作
});
// 组装:保存按钮在上,清空+蓝色按钮在下
bottomRow.appendChild(clearBtn);
bottomRow.appendChild(blueBtn);
buttonRow.insertBefore(columnWrap, saveButton);
columnWrap.appendChild(saveButton);
columnWrap.appendChild(bottomRow);
return true;
}
// ========== 指令抽屉命令自动发送(指令列表可配置) ==========
/** GM 存储键名:自定义指令列表(JSON 字符串数组) */
const DRAWER_COMMANDS_STORAGE_KEY = 'dzmm.drawerCommands';
/** 默认指令(完整指令型,来自线上抽屉实测 DOM) */
const DEFAULT_DRAWER_COMMANDS = [
'(描写当前画面)',
'(继续)',
'(推进剧情到下一个场景)',
'(时间流逝——)',
'(加快节奏)',
];
/** 自动发送轮询间隔 / 内容稳定判定时长(主输入框与编辑弹窗共用) */
const AUTO_SEND_POLL_MS = 150;
const AUTO_SEND_STABLE_MS = 300;
/** 当前生效的指令集合(可被 GM 存储中的自定义配置覆盖) */
const DRAWER_COMMAND_TEXTS = new Set(DEFAULT_DRAWER_COMMANDS);
/**
* 从 GM 存储加载自定义指令列表
*
* 存储格式:JSON 字符串数组。未配置或解析失败时回退到默认列表。
*
* @returns {string[]}
*/
function loadDrawerCommands() {
let list = null;
try {
const raw = GM_getValue(DRAWER_COMMANDS_STORAGE_KEY, '');
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
list = parsed.map((s) => String(s).trim()).filter(Boolean);
}
}
} catch (e) {
list = null;
}
if (!list || !list.length) {
list = DEFAULT_DRAWER_COMMANDS.slice();
}
DRAWER_COMMAND_TEXTS.clear();
list.forEach((t) => DRAWER_COMMAND_TEXTS.add(t));
return list;
}
/**
* 保存指令列表到 GM 存储并立即生效
*
* @param {string[]} list
*/
function saveDrawerCommands(list) {
try {
GM_setValue(DRAWER_COMMANDS_STORAGE_KEY, JSON.stringify(list));
} catch (e) {
// 无 GM 存储(如直接调试)时仅内存生效
}
DRAWER_COMMAND_TEXTS.clear();
list.forEach((t) => DRAWER_COMMAND_TEXTS.add(t));
}
/** GM 存储键名:自动发送触发文本(字符串,空 = 功能关闭) */
const SEND_TRIGGER_STORAGE_KEY = 'dzmm.sendTrigger';
/** 当前生效的触发文本;非空时,输入框内容包含它即触发蓝色发送按钮 */
let sendTriggerText = '';
/**
* 从 GM 存储加载触发文本(默认空字符串 = 功能关闭)
*
* @returns {string}
*/
function loadSendTrigger() {
try {
const raw = GM_getValue(SEND_TRIGGER_STORAGE_KEY, '');
// 不能 trim:空格(如两个空格)是合法的触发文本
sendTriggerText = typeof raw === 'string' ? raw : '';
} catch (e) {
sendTriggerText = '';
}
return sendTriggerText;
}
/**
* 保存触发文本并立即生效(空字符串 = 关闭)
*
* @param {string} text
*/
function saveSendTrigger(text) {
// 不做 trim:空格(如两个空格)是合法触发文本;仅空字符串表示关闭
const value = String(text || '');
sendTriggerText = value;
try {
GM_setValue(SEND_TRIGGER_STORAGE_KEY, value);
} catch (e) {
// 无 GM 存储(如直接调试)时仅内存生效
}
}
let commandsEditorEl = null;
/**
* 打开指令列表编辑弹窗(由脚本菜单触发)
*
* 每行一条指令;保存后写入 GM 存储并立即生效。
*/
function showDrawerCommandsEditor() {
if (commandsEditorEl) return;
const overlay = document.createElement('div');
overlay.style.cssText = 'position: fixed; inset: 0; z-index: 2147483647; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center;';
const panel = document.createElement('div');
panel.style.cssText = 'background: #fff; color: #111; border-radius: 12px; padding: 16px; width: 440px; max-width: 92vw; font: 14px/1.5 sans-serif; box-shadow: 0 8px 30px rgba(0,0,0,.3);';
const title = document.createElement('div');
title.textContent = '指令自动发送列表';
title.style.cssText = 'font-weight: 600; margin-bottom: 6px;';
const hint = document.createElement('div');
hint.textContent = '每行一条。点击抽屉命令后,输入框内容与其中任意一行完全一致时自动发送。';
hint.style.cssText = 'color: #666; font-size: 12px; margin-bottom: 8px;';
const textarea = document.createElement('textarea');
textarea.value = Array.from(DRAWER_COMMAND_TEXTS).join('\n');
textarea.rows = 10;
textarea.style.cssText = 'width: 100%; box-sizing: border-box; border: 1px solid #ccc; border-radius: 8px; padding: 8px; font: 13px/1.6 monospace; resize: vertical;';
function makeBtn(text, style, onClick) {
const b = document.createElement('button');
b.type = 'button';
b.textContent = text;
b.style.cssText = 'border: none; border-radius: 8px; padding: 6px 14px; cursor: pointer; font-size: 13px; ' + style;
b.addEventListener('click', onClick);
return b;
}
function close() {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
commandsEditorEl = null;
}
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display: flex; gap: 8px; margin-top: 12px; justify-content: flex-end;';
btnRow.appendChild(makeBtn('恢复默认', 'background: #eee; color: #333;', () => {
textarea.value = DEFAULT_DRAWER_COMMANDS.join('\n');
}));
btnRow.appendChild(makeBtn('取消', 'background: #eee; color: #333;', close));
btnRow.appendChild(makeBtn('保存', 'background: #3b82f6; color: #fff;', () => {
const list = textarea.value.split('\n').map((s) => s.trim()).filter(Boolean);
saveDrawerCommands(list);
close();
}));
panel.appendChild(title);
panel.appendChild(hint);
panel.appendChild(textarea);
panel.appendChild(btnRow);
overlay.appendChild(panel);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close();
});
document.body.appendChild(overlay);
commandsEditorEl = overlay;
textarea.focus();
}
let sendTriggerEditorEl = null;
/**
* 打开触发文本编辑弹窗(由脚本菜单触发)
*
* 输入框内容包含该文本时自动点击蓝色发送按钮;留空则关闭该功能。
*/
function showSendTriggerEditor() {
if (sendTriggerEditorEl) return;
const overlay = document.createElement('div');
overlay.style.cssText = 'position: fixed; inset: 0; z-index: 2147483647; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center;';
const panel = document.createElement('div');
panel.style.cssText = 'background: #fff; color: #111; border-radius: 12px; padding: 16px; width: 440px; max-width: 92vw; font: 14px/1.5 sans-serif; box-shadow: 0 8px 30px rgba(0,0,0,.3);';
const title = document.createElement('div');
title.textContent = '自动发送触发文本';
title.style.cssText = 'font-weight: 600; margin-bottom: 6px;';
const hint = document.createElement('div');
hint.textContent = '输入框(含编辑弹窗)内容包含该文本时,自动点击蓝色发送按钮。留空则关闭。常见用法:填两个空格,输入完内容后连敲两个空格即发送。';
hint.style.cssText = 'color: #666; font-size: 12px; margin-bottom: 8px;';
const field = document.createElement('input');
field.type = 'text';
field.value = sendTriggerText;
field.placeholder = '留空关闭,例如填两个空格';
field.style.cssText = 'width: 100%; box-sizing: border-box; border: 1px solid #ccc; border-radius: 8px; padding: 8px; font-size: 13px;';
function makeBtn(text, style, onClick) {
const b = document.createElement('button');
b.type = 'button';
b.textContent = text;
b.style.cssText = 'border: none; border-radius: 8px; padding: 6px 14px; cursor: pointer; font-size: 13px; ' + style;
b.addEventListener('click', onClick);
return b;
}
function close() {
if (overlay.parentNode) overlay.parentNode.removeChild(overlay);
sendTriggerEditorEl = null;
}
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display: flex; gap: 8px; margin-top: 12px; justify-content: flex-end;';
btnRow.appendChild(makeBtn('清空', 'background: #eee; color: #333;', () => {
field.value = '';
}));
btnRow.appendChild(makeBtn('取消', 'background: #eee; color: #333;', close));
btnRow.appendChild(makeBtn('保存', 'background: #3b82f6; color: #fff;', () => {
saveSendTrigger(field.value);
close();
}));
panel.appendChild(title);
panel.appendChild(hint);
panel.appendChild(field);
panel.appendChild(btnRow);
overlay.appendChild(panel);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close();
});
document.body.appendChild(overlay);
sendTriggerEditorEl = overlay;
field.focus();
}
let drawerAutoSendTimer = null;
let drawerLastSent = '';
let sendTriggerLast = '';
/**
* 触发「蓝色发送按钮」:先备份当前内容再发送
*
* 蓝色按钮是脚本注入的「记忆发送」按钮;若按钮尚未注入(极少数时序场景),
* 退化为直接备份 + 发送,行为保持一致。
*/
function triggerBlueSend() {
const btn = document.querySelector('.dzmm-copy-send-btn');
if (btn) {
btn.click();
return;
}
const input = document.querySelector('#chat-input');
if (input && input.value.trim()) {
savedContent = input.value.trim();
triggerSend();
}
}
/**
* 快速路径:发送按钮刚出现(输入框从空变为有内容)时立即检查内容是否匹配指令
*
* React 直接改 state 时不会触发 input 事件,但会把发送按钮渲染出来——
* 这是 DOM 变化,MutationObserver 能观察到,借此在抽屉点指令后瞬间发送。
*
* @returns {boolean} 是否命中并触发发送
*/
function tryDrawerMatchSend() {
const input = document.querySelector('#chat-input');
if (!input) return false;
const text = input.value.trim();
if (!text || text === drawerLastSent || !DRAWER_COMMAND_TEXTS.has(text)) {
return false;
}
drawerLastSent = text;
triggerSend();
// 发送后主动失焦,避免移动端键盘弹出(点击抽屉命令而非手动输入)
input.blur();
requestAnimationFrame(() => input.blur());
return true;
}
/**
* 监听聊天输入框内容变化:内容与某个指令完全一致时自动发送
*
* 为什么用轮询而不是 input 事件:
* 点击抽屉指令时是 React 直接改 state,DOM value 会变但不会触发 input 事件,
* 监听不到;只有轮询才能发现这类程序化赋值(实测:点指令无反应,输入一个空格后才发送)。
*
* - 每 150ms 轮询一次,内容连续 300ms 不变才认为输入完成,避免打字中途误发
* - 轮询是兜底;发送按钮出现的瞬间由 tryDrawerMatchSend 快路径抢先发送
* - lastSent 防重:发送后输入框会被站点清空,借此重置,允许重复发送同一指令
* - 副作用:手动输入这些文本并停顿也会触发发送,属于该方案的预期行为
*/
function setupDrawerAutoSend() {
const input = document.querySelector('#chat-input');
if (!input || input.dataset.dzmmAutoSend) return;
input.dataset.dzmmAutoSend = '1';
let lastValue = null;
let stableSince = 0;
// SPA 切换后输入框会重建,新输入框接管时清掉旧轮询
if (drawerAutoSendTimer) clearInterval(drawerAutoSendTimer);
drawerAutoSendTimer = setInterval(() => {
const raw = input.value;
const text = raw.trim();
const now = Date.now();
if (text !== lastValue) {
lastValue = text;
stableSince = now;
}
if (!text) {
// 发送后输入框被清空,重置防重标记,允许再次触发
drawerLastSent = '';
sendTriggerLast = '';
return;
}
if (now - stableSince < AUTO_SEND_STABLE_MS) return;
// 1) 指令抽屉:内容与某条指令完全一致时直接发送
if (text !== drawerLastSent && DRAWER_COMMAND_TEXTS.has(text)) {
drawerLastSent = text;
// 同时占住触发文本标记,避免发送失败(输入框未清空)时同一文本又被触发分支再点一次
sendTriggerLast = raw;
triggerSend();
input.blur();
return;
}
// 2) 触发文本:基于原始内容判断(保留末尾空格),空格触发文本依赖末尾空格
if (sendTriggerText && raw !== sendTriggerLast && raw.includes(sendTriggerText)) {
sendTriggerLast = raw;
triggerBlueSend();
}
}, AUTO_SEND_POLL_MS);
}
// ========== 编辑消息弹窗触发文本自动发送 ==========
let editTriggerTimer = null;
let editTriggerLast = '';
let editInitialValue = null;
/**
* 查找编辑弹窗的关键元素(供自动发送使用)
*
* 与 findEditDialogElements 的区别:不跳过已注入的按钮,
* 注入后按钮文本仍是「保存并重新生成回复」,需要持续可找到。
*
* @returns {{ saveButton: HTMLButtonElement, textarea: HTMLTextAreaElement, dialog: HTMLElement }|null}
*/
function findEditDialogForAutoSend() {
const allButtons = document.querySelectorAll('button');
for (const btn of allButtons) {
if (btn.textContent.includes('保存并重新生成回复')) {
const dialog = btn.closest('[role="dialog"]')
|| btn.closest('[data-slot="dialog-content"]')
|| btn.closest('.fixed');
if (!dialog) continue;
const textarea = dialog.querySelector('textarea');
if (!textarea) continue;
return { saveButton: btn, textarea, dialog };
}
}
return null;
}
/**
* 触发编辑弹窗内的蓝色按钮:备份内容并点击「保存并重新生成回复」
*
* 触发文本(通常是末尾空格)不应写入编辑内容:先 trim 再保存,
* trim 后的内容也计入防重,避免文本型触发词造成循环触发。
*
* @param {{ saveButton: HTMLButtonElement, textarea: HTMLTextAreaElement, dialog: HTMLElement }} els
*/
function triggerEditBlueSend(els) {
const trimmed = els.textarea.value.trim();
setNativeValue(els.textarea, trimmed);
editTriggerLast = trimmed;
const blueBtn = els.dialog.querySelector('.dzmm-copy-send-edit-btn');
// 等一帧再点击,确保 React 已应用 trim 后的内容
requestAnimationFrame(() => {
if (blueBtn) {
blueBtn.click();
} else if (trimmed) {
savedEditContent = trimmed;
els.saveButton.click();
}
});
}
/**
* 监听编辑消息弹窗内容变化:内容包含触发文本时自动触发蓝色按钮
*
* 编辑弹窗用 input 事件而非轮询,并记录弹窗打开时的初始内容:
* - input 事件本身不能区分「用户输入」和「站点预填派发的事件」
* - 因此只有在内容与初始值不同(用户真正修改)时才可能触发
* - 避免「消息本身带触发文本(如末尾两个空格)→ 打开弹窗即自动保存/清空」
*/
function setupEditDialogTriggerSend() {
if (editTriggerTimer) clearInterval(editTriggerTimer);
editTriggerTimer = setInterval(() => {
// 无弹窗时快速跳过,避免每 150ms 全页扫描按钮
if (!document.querySelector('[role="dialog"]')) {
editTriggerLast = '';
editInitialValue = null;
return;
}
const els = findEditDialogForAutoSend();
if (!els) {
editTriggerLast = '';
editInitialValue = null;
return;
}
// 每个 textarea 只挂一次 input 监听(弹窗每次打开可能是新元素)
if (els.textarea.dataset.dzmmEditTrigger) return;
els.textarea.dataset.dzmmEditTrigger = '1';
// 记录打开弹窗时的初始内容;只有内容被用户真正修改后才可能触发
editInitialValue = els.textarea.value;
let timer = null;
els.textarea.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(() => {
const raw = els.textarea.value;
if (!raw.trim()) {
editTriggerLast = '';
return;
}
if (!sendTriggerText || raw === editTriggerLast || !raw.includes(sendTriggerText)) return;
// 与打开弹窗时的内容一致:不是用户修改(可能是站点预填派发的 input 事件),不触发
if (editInitialValue === null || raw === editInitialValue) return;
editTriggerLast = raw;
triggerEditBlueSend(els);
}, AUTO_SEND_STABLE_MS);
});
}, AUTO_SEND_POLL_MS);
}
// ========== 初始化 ==========
function init() {
injectChatButton();
injectEditDialogButton();
setupDrawerAutoSend();
// 只有在聊天页面(有输入框或消息列表)且按钮未注入时才重试
var onChatPage = document.querySelector('#chat-input') || document.querySelector('#vlist');
if (onChatPage && !document.querySelector('.dzmm-copy-send-btn')) {
setTimeout(init, 500);
}
}
// 监听 DOM 变化:SPA 页面切换后按钮消失 + 弹窗出现
const observer = new MutationObserver(() => {
if (!document.querySelector('.dzmm-copy-send-btn')) {
injectChatButton();
}
injectEditDialogButton();
setupDrawerAutoSend();
tryDrawerMatchSend();
});
// 加载配置(指令列表 + 触发文本),并在脚本菜单注册配置入口
loadDrawerCommands();
loadSendTrigger();
setupEditDialogTriggerSend();
try {
GM_registerMenuCommand('设置指令自动发送列表', showDrawerCommandsEditor);
GM_registerMenuCommand('设置自动发送触发文本', showSendTriggerEditor);
} catch (e) {
// 非油猴环境(如直接调试)忽略
}
// 页面加载后启动
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// 等页面稳定后开始观察 DOM 变化
setTimeout(() => {
observer.observe(document.body, { childList: true, subtree: true });
}, 2000);
})();