DZMM Copy and Send

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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