对话完成提示器(AI Moinitor)

监测AI聊天生成是否完成并通知提醒(带音效选择)

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         对话完成提示器(AI Moinitor)
// @namespace    http://tampermonkey.net/
// @version      3.2
// @description  监测AI聊天生成是否完成并通知提醒(带音效选择)
// @description:zh-CN 适配已做好,音效未做
// @author       who
// @license GPL-3.0-or-later
// @match https://yuanbao.tencent.com/*
// @match https://chatgpt.com/*
// @match https://chat.deepseek.com/*
// @match https://yiyan.baidu.com/*
// @match https://www.tongyi.com/*
// @grant        GM_addStyle
// @grant        GM_notification
// @grant        GM_setValue
// @grant        GM_getValue
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';

    // 全局配置变量
    let CONFIG = {};
    let state = {};


    // 域名到完整配置的映射表(包含所有参数)
    const DOMAIN_CONFIG_MAP = {
        // 元宝(腾讯)
        'yuanbao.tencent.com': {
            // 元素选择器
            // 选择器可以是类名、ID或其他CSS选择器
            // 用 # . 来区分ID和类名, className里有多个值就不加.
            TARGET_CLASS: "agent-chat__toolbar__copy__icon",
            SEND_BUTTON_SELECTOR: "#yuanbao-send-btn",
            INPUT_SELECTOR: ".style__text-area__edit___d1yNy",
        },
        // ChatGPT(OpenAI)
        'chatgpt.com': {
            TARGET_CLASS: "text-center",
            SEND_BUTTON_SELECTOR: "#composer-submit-button",
            INPUT_SELECTOR: "ProseMirror",
        },
        // DeepSeek
        'chat.deepseek.com': {
            TARGET_CLASS: "._965abe9",
            SEND_BUTTON_SELECTOR: "bcc55ca1",
            INPUT_SELECTOR: "_77cefa5",
        },
        // doubao  暂不支持
        'www.doubao.com': {
            TARGET_CLASS: "suggest-message-LJeEWd",
            SEND_BUTTON_SELECTOR: ".semi-button-content",
            INPUT_SELECTOR: ".editor-wrapper-AdiwSu",
        },
        // 百度一言
        'yiyan.baidu.com': {
            TARGET_CLASS: ".copy__OMlDWQ7D",
            SEND_BUTTON_SELECTOR: "#sendBtn",
            INPUT_SELECTOR: "yc-editor",
        },
        // 通义千问
        'www.tongyi.com': {
            TARGET_CLASS: "btn--YtZqkWMA",
            SEND_BUTTON_SELECTOR: ".operateBtn--qMhYIdIu",
            INPUT_SELECTOR: ".chatTextarea--RVTXJYOh",
        },
    };

    // 根据域名获取配置
    function getSiteConfig() {
        CONFIG = {
            // 时间设置
            MONITOR_TIMEOUT: 600000, // 10分钟
            ALERT_TIMEOUT: 3000,     // 3秒

            // 音效设置
            DEFAULT_SOUND: "chime",
            DEFAULT_DURATION: 2
        }

        // 状态变量
        state = {
            currentDomain: window.location.hostname,
            toolbarDetected: false,
            sendActionTriggered: false,
            toolbarObserver: null,
            monitoringTimeout: null,
            initialToolbarCount: 0,
            alertContainer: null,
            statusIndicator: null,
            soundSelector: null,
            selectedSound: GM_getValue('selectedSound', CONFIG.DEFAULT_SOUND),
            soundDuration: GM_getValue('soundDuration', CONFIG.DEFAULT_DURATION),
            customSoundUrl: GM_getValue('customSoundUrl', '')
        };

        // 匹配域名和配置
        console.log("当前域名:", state.currentDomain);
        CONFIG = {
            ...CONFIG,
            ...DOMAIN_CONFIG_MAP[state.currentDomain]
        };
    }



    // 添加核心样式 - 确保最高优先级
    GM_addStyle(`
        /* 状态指示器 - 强制显示 */
        #tm-status-indicator {
            position: fixed !important;
            z-index: 2147483647 !important; /* 最高优先级 */
            background: white !important;
            color: black !important;
            border-radius: 8px !important;
            padding: 12px 16px !important;
            font-size: 14px !important;
            box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important;
            border: 1px solid rgba(255,255,255,0.1) !important;
            min-width: 150px !important;
            cursor: move !important;
            user-select: none !important;
            display: block !important;
            font-family: Arial, sans-serif !important;
        }

        /* 状态点 */
        #ai-status-dot {
            width: 15px !important;
            height: 15px !important;
            border-radius: 50% !important;
            margin-right: 8px !important;
        }

        /* 状态文本 */
        #ai-status-text {
            display: inline-block !important;
            vertical-align: middle !important;
        }

        /* 设置按钮 */
        #ai-settings-btn {
            background: none !important;
            border: none !important;
            cursor: pointer !important;
        }

        /* 设置面板 */
        #ai-settings-panel {
            margin-top: 8px !important;
            display: none !important;
        }

        #ai-settings-panel.show {
            display: block !important;
        }

        /* 弹窗 - 强制显示 */
        .tm-toolbar-alert {
            position: fixed !important;
            top: 20px !important;
            right: 20px !important;
            width: 320px !important;
            background: linear-gradient(135deg, #1a2a6c, #2c3e50) !important;
            color: white !important;
            border-radius: 12px !important;
            box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3) !important;
            padding: 20px !important;
            z-index: 2147483646 !important; /* 仅次于状态指示器 */
            font-family: 'Segoe UI', Tahoma, sans-serif !important;
            animation: slideIn 0.6s ease-out forwards !important;
            border: 1px solid rgba(255, 255, 255, 0.1) !important;
            display: block !important;
        }

        @keyframes slideIn {
            0% { transform: translateX(120%); opacity: 0; }
            100% { transform: translateX(0); opacity: 1; }
        }

        .tm-toolbar-header {
            display: flex !important;
            align-items: center !important;
            margin-bottom: 15px !important;
            border-bottom: 1px solid rgba(255, 255, 255, 0.15) !important;
            padding-bottom: 12px !important;
        }

        .tm-toolbar-icon {
            width: 40px !important;
            height: 40px !important;
            background: rgba(255, 255, 255, 0.15) !important;
            border-radius: 50% !important;
            margin-right: 15px !important;
            display: flex !important;
            align-items: center !important;
            justify-content: center !important;
            font-size: 22px !important;
        }

        .tm-toolbar-title {
            font-size: 18px !important;
            font-weight: 600 !important;
        }

        .tm-toolbar-desc {
            font-size: 14px !important;
            line-height: 1.5 !important;
            margin-bottom: 18px !important;
            color: rgba(255, 255, 255, 0.85) !important;
        }

        .tm-toolbar-buttons {
            display: flex !important;
            justify-content: space-between !important;
            gap: 10px !important;
        }

        .tm-toolbar-btn {
            flex: 1 !important;
            padding: 8px 12px !important;
            background: rgba(255, 255, 255, 0.1) !important;
            border: none !important;
            color: white !important;
            border-radius: 8px !important;
            cursor: pointer !important;
            font-weight: 500 !important;
            font-size: 14px !important;
            transition: all 0.25s ease !important;
        }

        .tm-toolbar-btn:hover {
            background: rgba(255, 255, 255, 0.2) !important;
            transform: translateY(-2px) !important;
        }

        .tm-toolbar-btn.main {
            background: #3498db !important;
            font-weight: 600 !important;
        }

        .tm-toolbar-btn.main:hover {
            background: #2980b9 !important;
        }
    `);

    // 初始化函数
    function init() {
        console.log("当前域名:", window.location.hostname);
        // 允许浏览器通知
        if (Notification.permission !== "granted") {
            Notification.requestPermission();
        }

        // 根据不同AI配置选择器
        getSiteConfig();

        // 确保在DOM准备好后执行
        if (document.readyState === 'loading') {
            console.log("当前域名:", window.location.hostname);
            document.addEventListener('DOMContentLoaded', createStatusIndicator);
        } else {
            console.log("当前域名:", window.location.hostname);
            setTimeout(createStatusIndicator, 100);
        }
    }

    // 创建状态指示器 - 核心组件
    function createStatusIndicator() {
        // 如果已存在,先移除
        const existingIndicator = document.getElementById('tm-status-indicator');
        if (existingIndicator) existingIndicator.remove();

        // 创建容器
        state.statusIndicator = document.createElement('div');
        state.statusIndicator.id = 'tm-status-indicator';

        // 加载保存的位置
        const savedPosition = GM_getValue('indicatorPosition', {
            x: window.innerWidth - 280,
            y: window.innerHeight - 100
        });
        state.statusIndicator.style.left = `${savedPosition.x}px`;
        state.statusIndicator.style.top = `${savedPosition.y}px`;

        // 添加新面板结构
        state.statusIndicator.innerHTML = `
            <div style="display:flex;align-items:center;justify-content:space-between;">
                <div style="display:flex;align-items:center;">
                    <div id="ai-status-dot" style="width:15px;height:15px;border-radius:50%;margin-right:8px;"></div>
                    <span id="ai-status-text">等待初始化</span>
                </div>
                <button id="ai-settings-btn" style="background:none;border:none;cursor:pointer;">
                    <svg xmlns="http://www.w3.org/2000/svg" style="width:18px;height:18px;fill:#555;" viewBox="0 0 24 24">
                        <path d="M12 15.5A3.5 3.5 0 1 0 12 8.5a3.5 3.5 0 0 0 0 7Zm7.43-2.06 1.97 1.54c.2.16.25.45.11.67l-1.87 3.24c-.14.23-.43.31-.66.21l-2.32-.93a7.05 7.05 0 0 1-1.52.88l-.35 2.48a.5.5 0 0 1-.5.43h-3.74a.5.5 0 0 1-.5-.43l-.35-2.48a7.05 7.05 0 0 1-1.52-.88l-2.32.93a.5.5 0 0 1-.66-.21l-1.87-3.24a.5.5 0 0 1 .11-.67l1.97-1.54a6.97 6.97 0 0 1 0-1.76l-1.97-1.54a.5.5 0 0 1-.11-.67l1.87-3.24c.14-.23.43-.31.66-.21l2.32.93c.47-.36.98-.66 1.52-.88l.35-2.48a.5.5 0 0 1 .5-.43h3.74a.5.5 0 0 1 .5.43l.35 2.48c.54.22 1.05.52 1.52.88l2.32-.93c.23-.1.52-.02.66.21l1.87 3.24c.14.22.09.51-.11.67l-1.97 1.54c.07.29.11.59.11.88s-.04.59-.11.88Z"/>
                    </svg>
                </button>
            </div>
            <div id="ai-settings-panel" style="margin-top:8px;">
                <label><input type="radio" name="soundType" value="off">关</label><br>
                <label><input type="radio" name="soundType" value="bell">铃声</label><br>
                <label><input type="radio" name="soundType" value="dingdong">叮咚</label><br>
                <label>播放时长: <input type="number" id="ai-sound-duration" min="0" max="5" step="1" style="width:40px;"> 秒</label><br>
                <button id="ai-save-settings">保存</button>
            </div>
        `;

        // 添加到文档
        document.body.appendChild(state.statusIndicator);

        // 更新初始状态
        updateStatus('idle', '已初始化');

        // 设置初始音效选择
        setupSoundSelector();

        // 添加事件监听器
        document.getElementById('ai-settings-btn').addEventListener('click', toggleSettingsPanel);
        document.getElementById('ai-save-settings').addEventListener('click', saveSoundSettings);

        // 添加拖动功能
        addDragFunctionality();

        // 初始化其他组件
        setupEventListeners();
    }

    // 添加拖动功能
    function addDragFunctionality() {
        let isDragging = false;
        let dragOffsetX = 0;
        let dragOffsetY = 0;

        state.statusIndicator.addEventListener('mousedown', function(e) {
            // 防止在设置按钮上拖动
            if (e.target.closest('#ai-settings-btn') || e.target.closest('#ai-settings-panel')) {
                return;
            }

            isDragging = true;
            dragOffsetX = e.clientX - state.statusIndicator.getBoundingClientRect().left;
            dragOffsetY = e.clientY - state.statusIndicator.getBoundingClientRect().top;
            document.addEventListener('mousemove', dragIndicator);
            document.addEventListener('mouseup', stopDragging);
        });

        function dragIndicator(e) {
            if (!isDragging) return;

            const x = e.clientX - dragOffsetX;
            const y = e.clientY - dragOffsetY;

            // 限制在窗口范围内
            const maxX = window.innerWidth - state.statusIndicator.offsetWidth;
            const maxY = window.innerHeight - state.statusIndicator.offsetHeight;

            state.statusIndicator.style.left = `${Math.max(0, Math.min(x, maxX))}px`;
            state.statusIndicator.style.top = `${Math.max(0, Math.min(y, maxY))}px`;
        }

        function stopDragging() {
            isDragging = false;

            // 保存位置
            const rect = state.statusIndicator.getBoundingClientRect();
            GM_setValue('indicatorPosition', {
                x: rect.left + window.scrollX,
                y: rect.top + window.scrollY
            });

            document.removeEventListener('mousemove', dragIndicator);
            document.removeEventListener('mouseup', stopDragging);
        }
    }

    // 切换设置面板显示
    function toggleSettingsPanel(e) {
        e.stopPropagation();
        const settingsPanel = document.getElementById('ai-settings-panel');
        settingsPanel.classList.toggle('show');
    }

    // 设置初始音效选择
    function setupSoundSelector() {
        const settingsPanel = document.getElementById('ai-settings-panel');

        // 设置选中的音效类型
        const soundTypeMap = {
            chime: 'bell',
            ding: 'dingdong',
            off: 'off'
        };

        const reverseMap = {
            bell: 'chime',
            dingdong: 'ding',
            off: 'off'
        };

        const currentSelection = reverseMap[state.selectedSound] || state.selectedSound;
        const radioBtn = settingsPanel.querySelector(`input[value="${currentSelection}"]`);
        if (radioBtn) radioBtn.checked = true;

        // 设置音效时长
        const durationInput = document.getElementById('ai-sound-duration');
        durationInput.value = state.soundDuration;
    }

    // 保存音效设置
    function saveSoundSettings() {
        const soundType = document.querySelector('input[name="soundType"]:checked')?.value || 'chime';
        const durationInput = document.getElementById('ai-sound-duration');
        const duration = parseFloat(durationInput.value) || state.soundDuration;

        // 映射到原音效类型
        const soundMap = {
            bell: 'chime',
            dingdong: 'ding',
            off: 'off'
        };

        state.selectedSound = soundMap[soundType] || soundType;
        state.soundDuration = duration;

        GM_setValue('selectedSound', state.selectedSound);
        GM_setValue('soundDuration', state.soundDuration);

        document.getElementById('ai-settings-panel').classList.remove('show');
    }

    // 更新状态显示
    function updateStatus(stateType, message) {
        const statusDot = document.getElementById('ai-status-dot');
        const statusText = document.getElementById('ai-status-text');

        if (!statusDot || !statusText) {
            // 如果元素不存在,重新创建状态指示器
            createStatusIndicator();
            return;
        }

        switch(stateType) {
            case 'idle':
                statusDot.style.background =  '#2ecc71';
                break;
            case 'waiting':
                statusDot.style.background = '#f1c40f';
                break;
            case 'active':
                statusDot.style.background = '#e74c3c';
                break;
        }

        statusText.textContent = message;
        console.log(`[AI Monitor] ${message}`);
    }

    // 设置事件监听器
    function setupEventListeners() {
        console.log("CONFIG:", CONFIG);
        console.log("发送键选择器",CONFIG.SEND_BUTTON_SELECTOR);
        console.log("输入框选择器:", CONFIG.INPUT_SELECTOR);
        console.log("当前目标个数:", getToolbarCount());
        // 查找发送按钮
        findAndMonitorElement(CONFIG.SEND_BUTTON_SELECTOR, (button) => {
            button.addEventListener('click', handleSendAction);
            console.log("发送键等待发送", button);
            updateStatus('idle', '等待发送');
        });

        // 查找输入框
        findAndMonitorElement(CONFIG.INPUT_SELECTOR, (input) => {
            input.addEventListener('keydown', handleKeyDown);
            console.log("输入框等待发送", input);
            updateStatus('idle', '等待发送');
        });
    }

    // 通用元素查找和监控
    function findAndMonitorElement(selector, callback) {
        // 处理各种选择器类型
        const normalizedSelector = normalizeSelector(selector);

        // 如果已经有匹配元素立即执行回调
        const existingElement = document.querySelector(normalizedSelector);
        if (existingElement) {
            callback(existingElement);
            return;
        }

        // 监听DOM变化以捕获动态加载的元素
        const observer = new MutationObserver(() => {
            const element = document.querySelector(normalizedSelector);
            if (element) {
                observer.disconnect(); // 找到后停止观察
                callback(element);
            }
        });

        // 开始观察整个文档
        observer.observe(document.body, {
            childList: true,
            subtree: true
        });
    }

    // 标准化选择器处理
    function normalizeSelector(selector) {
        // 如果选择器以#开头,直接使用(ID选择器)
        if (selector.startsWith('#')) {
            return selector;
        }

        // 如果选择器以.开头,直接使用(类选择器)
        if (selector.startsWith('.')) {
            return selector;
        }

        // 如果包含空格说明是复合选择器,直接使用
        if (selector.includes(' ')) {
            return selector;
        }

        // 尝试用类选择器查找
        return `.${selector}`;
    }

    // 处理键盘事件
    function handleKeyDown(event) {
        if ((event.key === 'Enter' || event.keyCode === 13) && !event.shiftKey) {
            handleSendAction();
        }
    }

    // 处理发送动作
    function handleSendAction() {
        // 关闭现有弹窗
        closeAlert();

        // 重置状态
        resetMonitoringState();

        // 开始新监测
        startToolbarMonitoring();
    }

    // 启动工具栏监控
    function startToolbarMonitoring() {
        // 清除现有监控
        if (state.toolbarObserver) state.toolbarObserver.disconnect();
        if (state.monitoringTimeout) clearTimeout(state.monitoringTimeout);

        // 记录初始状态
        state.toolbarDetected = false;
        state.sendActionTriggered = true;
        state.initialToolbarCount = getToolbarCount();

        updateStatus('waiting', '生成中...');

        // 初始检查
        if (checkForNewToolbar()) return;

        // 设置DOM监控
        state.toolbarObserver = new MutationObserver(() => {
            if (checkForNewToolbar()) {
                state.toolbarObserver.disconnect();
            }
        });

        state.toolbarObserver.observe(document.body, {
            childList: true,
            subtree: true
        });

        // 设置超时
        state.monitoringTimeout = setTimeout(() => {
            if (state.toolbarObserver) state.toolbarObserver.disconnect();
            updateStatus('idle', '监控超时,准备下一次发送');
        }, CONFIG.MONITOR_TIMEOUT);
    }

    // 检查新工具栏
    function checkForNewToolbar() {
        const currentCount = getToolbarCount();
        if (currentCount > state.initialToolbarCount && !state.toolbarDetected) {
            state.toolbarDetected = true;
            showToolbarAlert();
            state.initialToolbarCount = currentCount;
            return true;
        }
        return false;
    }

    // 获取工具栏数量
    function getToolbarCount() {
        try{
            return document.querySelectorAll(`.${CONFIG.TARGET_CLASS}`).length
        }catch(e) {
            console.warn("获取工具栏数量失败:", e);
            try{
                return document.querySelectorAll(CONFIG.TARGET_CLASS).length;
            }catch(e2) {
                console.error("再次尝试获取工具栏数量失败:", e2);
                console.error("生成结束标志选择有误,尝试使用类名获取工具栏数量");
            }
        }
    }

    // 显示工具栏通知
    function showToolbarAlert() {
        // 关闭现有弹窗
        closeAlert();

        // 创建新弹窗
        state.alertContainer = document.createElement('div');
        state.alertContainer.className = 'tm-toolbar-alert';
        state.alertContainer.innerHTML = `
            <div class="tm-toolbar-desc">
                内容已生成完毕
            </div>
            <div class="tm-toolbar-buttons">
                <button class="tm-toolbar-btn" id="tm-close-alert">关闭</button>
            </div>
        `;

        // 浏览器通知
        if (Notification.permission === "granted") {
            new Notification("内容已生成完毕", { body: "请到浏览器查看" });
        }

        // Tampermonkey 系统通知
        GM_notification({
            title: "内容生成完毕",
            timeout: 5000,
            onclick: () => {
                window.focus();
            }
        });

        document.body.appendChild(state.alertContainer);

        // 添加事件
        document.getElementById('tm-close-alert').addEventListener('click', closeAlert);

        // 设置自动关闭
        setTimeout(closeAlert, CONFIG.ALERT_TIMEOUT);

        // 更新状态
        updateStatus('idle', '等待发送');
        playSound();
    }

    // 播放音效
    function playSound() {
        if (state.selectedSound === 'off') return;

        let soundUrl;

        switch(state.selectedSound) {
            case 'chime':
                soundUrl = 'https://assets.mixkit.co/sfx/preview/mixkit-melodic-bonus-collect-1938.mp3';
                break;
            case 'beep':
                soundUrl = 'https://assets.mixkit.co/sfx/preview/mixkit-retro-game-notification-212.mp3';
                break;
            case 'ding':
                soundUrl = 'https://assets.mixkit.co/sfx/preview/mixkit-correct-answer-tone-2870.mp3';
                break;
            case 'bell':
                soundUrl = 'https://assets.mixkit.co/sfx/preview/mixkit-winning-chimes-2015.mp3';
                break;
            case 'custom':
                soundUrl = state.customSoundUrl;
                break;
            default:
                soundUrl = 'https://assets.mixkit.co/sfx/preview/mixkit-melodic-bonus-collect-1938.mp3';
        }

        if (!soundUrl) return;

        try {
            const audio = new Audio(soundUrl);
            audio.volume = 0.5;
            audio.play();

            // 设置停止时间
            setTimeout(() => {
                audio.pause();
                audio.currentTime = 0;
            }, state.soundDuration * 1000);
        } catch (e) {
            console.error('音效播放失败:', e);
        }
    }

    // 关闭弹窗
    function closeAlert() {
        if (state.alertContainer && state.alertContainer.parentNode) {
            state.alertContainer.parentNode.removeChild(state.alertContainer);
            state.alertContainer = null;
        }
    }



    // 重置监控状态
    function resetMonitoringState() {
        if (state.toolbarObserver) {
            state.toolbarObserver.disconnect();
            state.toolbarObserver = null;
        }

        if (state.monitoringTimeout) {
            clearTimeout(state.monitoringTimeout);
            state.monitoringTimeout = null;
        }

        state.toolbarDetected = false;
        state.sendActionTriggered = false;
        updateStatus('idle', '准备下一次发送');
    }

    // 启动脚本
    init();
})();