DZMM Copy and Send

在输入框右侧添加蓝色"复制发送"按钮,在编辑弹窗中添加记忆发送功能;指令抽屉命令、输入框/编辑弹窗包含触发文本时自动发送(均可配置)

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(У мене вже є менеджер скриптів, дайте мені встановити його!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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);
})();