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.

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

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.10
// @description  在输入框右侧添加蓝色"复制发送"按钮,在编辑弹窗中添加记忆发送功能;指令抽屉命令、输入框/编辑弹窗包含触发文本时自动发送(均可配置)
// @author       Allex0716
// @match        https://www.dzmm.ai/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// ==/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;
    }

    /**
     * 获取容器内第一个按钮、其包裹层,以及插入新按钮时的参照节点
     *
     * 站点存在两种结构,需要分别兼容:
     * 1. 新版:按钮是容器的直接子元素(如 mic / plus 裸按钮,无包裹层);
     * 2. 旧版:按钮被 framer-motion 的动画 div 包裹。
     * 只有旧版结构需要克隆包裹层样式,新版直接把按钮当作插入参照。
     *
     * @param {HTMLElement} container - 按钮容器
     * @returns {{ wrapper: HTMLElement|null, button: HTMLElement|null, anchor: HTMLElement|null }}
     *          anchor 为插入新按钮的参照节点(包裹层或按钮本身)
     */
    function getFirstButtonInfo(container) {
        // 旧版结构:容器直接子元素中是带动画的包裹层
        const firstWrapper = container.querySelector(':scope > div');
        if (firstWrapper) {
            const wrappedButton = firstWrapper.querySelector('button');
            if (wrappedButton) {
                return { wrapper: firstWrapper, button: wrappedButton, anchor: firstWrapper };
            }
        }

        // 新版结构:按钮裸挂在容器下,无包裹层
        const bareButton = container.querySelector(':scope > button');
        if (bareButton) {
            return { wrapper: null, button: bareButton, anchor: bareButton };
        }

        return { wrapper: null, button: null, anchor: null };
    }

    /**
     * 解析蓝色按钮应插入的父节点与参照节点
     *
     * 站点在「输入框为空 / 有内容」两种状态下渲染结构不同:
     * 1. 空输入态:输入行下有 div.flex.items-center 容器(mic / plus 按钮);
     *    旧版站点还会给按钮套一层 framer-motion 动画 div,需要克隆其样式。
     * 2. 有内容态:整个容器被卸载,站点改为直接渲染一个发送按钮作为输入行子元素,
     *    此时以输入行的动作区(最后一个子元素)为参照,把蓝色按钮插到它前面。
     *
     * 注意:输入行(textarea 的父元素)在两种状态下都存在,因此第二种情况的
     * 插入结果不会随输入内容变化被站点连带卸载。
     *
     * @param {HTMLElement} row - 输入行容器(textarea 的父元素)
     * @returns {{ parent: HTMLElement, anchor: HTMLElement, wrapper: HTMLElement|null }|null}
     */
    function getInjectTarget(row) {
        const container = row.querySelector(':scope > .flex.items-center');
        if (container) {
            const { wrapper, button, anchor } = getFirstButtonInfo(container);
            // 容器已渲染但内部按钮尚未出现时返回 null,交给上层重试
            if (!button || !anchor) return null;
            return { parent: container, anchor, wrapper };
        }

        // 有内容态:容器不存在,输入行的最后一个子元素即站点的发送按钮
        const action = row.lastElementChild;
        if (!action || action.tagName === 'TEXTAREA') return null;
        return { parent: row, anchor: action, wrapper: null };
    }

    /**
     * 用原生 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');
        if (!input) return false;
        const row = input.parentElement;
        if (!row) return false;

        // 避免重复注入:按钮可能挂在容器内(空输入态)或输入行下(有内容态)
        if (row.querySelector('.dzmm-copy-send-btn')) return true;

        const target = getInjectTarget(row);
        if (!target) return false;

        // 创建新按钮包裹层:旧版结构克隆动画包裹层样式,其余情况只保留按钮间距
        const newWrapper = document.createElement('div');
        // 类名供「隐藏话筒按钮」样式的相邻选择器使用
        newWrapper.className = 'dzmm-copy-send-wrapper';
        newWrapper.style.cssText = target.wrapper ? target.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);
        // 插入到动作区第一个按钮之前
        target.parent.insertBefore(newWrapper, target.anchor);
        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 存储(如直接调试)时仅内存生效
        }
    }

    /** GM 存储键名:是否隐藏输入框右侧的话筒按钮 */
    const HIDE_MIC_STORAGE_KEY = 'dzmm.hideMicButton';

    /** 「隐藏话筒按钮」开关状态 */
    let hideMicButton = false;

    /** 脚本菜单中「隐藏话筒按钮」项的 id,用于刷新菜单文案 */
    let hideMicMenuId = null;

    /**
     * 从 GM 存储加载「隐藏话筒按钮」开关
     *
     * @returns {boolean} 当前是否开启
     */
    function loadHideMicButton() {
        try {
            hideMicButton = GM_getValue(HIDE_MIC_STORAGE_KEY, false) === true;
        } catch (e) {
            hideMicButton = false;
        }
        return hideMicButton;
    }

    /**
     * 应用或移除「隐藏话筒按钮」样式
     *
     * 开启时向 head 注入一段 CSS,隐藏蓝色按钮右侧相邻的话筒按钮(新页面输入框
     * 右侧的语音按钮);关闭时移除样式节点,恢复站点原样。重复调用是安全的,
     * 样式节点已存在时不再重复插入。
     */
    function applyHideMicStyle() {
        const STYLE_ID = 'dzmm-hide-mic-style';
        const existing = document.getElementById(STYLE_ID);

        if (!hideMicButton) {
            if (existing) existing.remove();
            return;
        }
        if (existing) return;

        const style = document.createElement('style');
        style.id = STYLE_ID;
        // .dzmm-copy-send-wrapper 是脚本包裹蓝色按钮的容器,相邻选择器只命中紧随
        // 其后的那个按钮,不会误伤更后面的加号按钮。:is() 的各分支独立生效,
        // 老浏览器不支持 :has() 时仍可用 aria-label / title 兜底。
        style.textContent = '.dzmm-copy-send-wrapper + button:is(:has(.lucide-mic), [aria-label*="voice" i], [title*="voice" i]) { display: none !important; }';
        (document.head || document.documentElement).appendChild(style);
    }

    /**
     * 切换「隐藏话筒按钮」:持久化配置并立即生效
     */
    function toggleHideMicButton() {
        hideMicButton = !hideMicButton;
        try {
            GM_setValue(HIDE_MIC_STORAGE_KEY, hideMicButton);
        } catch (e) {
            // 无 GM 存储(如直接调试)时仅内存生效
        }
        applyHideMicStyle();
        registerHideMicMenu();
    }

    /**
     * 注册或刷新脚本菜单中的「隐藏话筒按钮」开关项
     *
     * 菜单文案带上当前开关状态。支持 GM_unregisterMenuCommand 的环境先注销旧项
     * 再注册,避免每次切换多出一条菜单;不支持时保留首次注册的菜单项。
     */
    function registerHideMicMenu() {
        const label = `隐藏话筒按钮:${hideMicButton ? '已开启' : '已关闭'}(点击切换)`;

        if (hideMicMenuId !== null) {
            try {
                GM_unregisterMenuCommand(hideMicMenuId);
                hideMicMenuId = null;
            } catch (e) {
                // 无法注销:保留原菜单项,避免出现重复条目(文案会停留在上次状态)
                return;
            }
        }
        try {
            hideMicMenuId = GM_registerMenuCommand(label, toggleHideMicButton);
        } catch (e) {
            // 非油猴环境(如直接调试)忽略
        }
    }

    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();
        // SPA 切换或 head 被重建后补回「隐藏话筒按钮」样式
        applyHideMicStyle();

        // 只有在聊天页面(有输入框或消息列表)且按钮未注入时才重试
        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();
    loadHideMicButton();
    applyHideMicStyle();
    setupEditDialogTriggerSend();
    try {
        GM_registerMenuCommand('设置指令自动发送列表', showDrawerCommandsEditor);
        GM_registerMenuCommand('设置自动发送触发文本', showSendTriggerEditor);
    } catch (e) {
        // 非油猴环境(如直接调试)忽略
    }
    registerHideMicMenu();

    // 页面加载后启动
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

    // 等页面稳定后开始观察 DOM 变化
    setTimeout(() => {
        observer.observe(document.body, { childList: true, subtree: true });
    }, 2000);
})();