ChatGPT Response Complete Notifier

Sends a desktop notification with text preview when ChatGPT finishes a response. Robustly handles 'New Chat' detection and prevents notification spam.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         ChatGPT Response Complete Notifier
// @namespace    http://tampermonkey.net/
// @version      1.0.1
// @author       ramhaidar
// @description  Sends a desktop notification with text preview when ChatGPT finishes a response. Robustly handles 'New Chat' detection and prevents notification spam.
// @homepage     https://github.com/ramhaidar/ChatGPT-Response-Complete-Notifier
// @source       https://github.com/ramhaidar/ChatGPT-Response-Complete-Notifier/raw/refs/heads/main/chatgpt-response-complete-notifier.user.js
// @license      GPL-3.0
// @match        https://chatgpt.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_log
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // [CRN] Configuration
  const CONFIG = {
    DEBUG_MODE: false,
    NOTIFICATION_COOLDOWN: 3000,
    PERMISSION_KEY: 'crn_notification_permission_granted',
    POLL_INTERVAL: 500 // Check status every 500ms
  };

  // [CRN] State tracking
  let state = {
    isStreaming: false,
    lastNotificationTime: 0,
    lastButtonState: null,
    buttonObserver: null,
    pollInterval: null,
    notificationPermissionGranted: false,
    observedForm: null,
    lastUrlPath: window.location.pathname // Track URL changes
  };

  // [CRN] Enhanced logging
  function crnLog(message, data = null) {
    if (CONFIG.DEBUG_MODE) {
      const timestamp = new Date().toISOString();
      const logMessage = `[CRN] [${timestamp}] ${message}`;
      console.log(logMessage);
      if (data !== null && typeof data === 'object') {
        console.log(`[CRN] DATA:`, JSON.stringify(data, null, 2));
      }
    }
  }

  // [CRN] Check notification permission
  async function checkNotificationPermissionNative() {
    crnLog('🔑 Checking native notification permission...');
    if (!('Notification' in window)) return false;
    if (Notification.permission === 'granted') {
      state.notificationPermissionGranted = true;
      return true;
    }
    const userDenied = GM_getValue('notification_permission_denied', false);
    if (userDenied) return false;

    try {
      const permission = await Notification.requestPermission();
      state.notificationPermissionGranted = permission === 'granted';
      if (state.notificationPermissionGranted) {
        GM_setValue(CONFIG.PERMISSION_KEY, true);
        return true;
      } else {
        GM_setValue('notification_permission_denied', true);
        return false;
      }
    } catch (error) {
      return false;
    }
  }

  // [CRN] Get current button state
  function getCurrentButtonState() {
    // Primary selectors for the send/stop button
    const buttonSelectors = [
      '#composer-submit-button',
      'button[data-testid="send-button"]',
      'button[data-testid="stop-button"]',
      'button[aria-label="Send prompt"]',
      'button[aria-label="Stop streaming"]'
    ];

    let button = null;
    for (const selector of buttonSelectors) {
      button = document.querySelector(selector);
      if (button) break;
    }

    if (!button) return null;

    const dataTestId = button.getAttribute('data-testid');
    const ariaLabel = button.getAttribute('aria-label');
    const isDisabled = button.disabled;

    return {
      buttonElement: button,
      dataTestId: dataTestId,
      ariaLabel: ariaLabel,
      isDisabled: isDisabled,
      isSendButton: dataTestId === 'send-button' && ariaLabel === 'Send prompt',
      isStopButton: dataTestId === 'stop-button' && ariaLabel === 'Stop streaming',
      timestamp: Date.now()
    };
  }

  // [CRN] Handle button state changes
  function handleButtonStateChange(buttonState) {
    // Case 1: Streaming started (Stop button visible)
    if (buttonState.isStopButton) {
      if (!state.isStreaming) {
        crnLog('▶️ Streaming started');
        state.isStreaming = true;
      }
      return;
    }

    // Case 2: Response COMPLETED (Send button visible AND disabled)
    // Note: Button is disabled immediately after streaming stops
    if (buttonState.isSendButton && buttonState.isDisabled && state.isStreaming) {
      crnLog('✅ Response completed!');
      state.isStreaming = false;
      showResponseCompleteNotification();
      return;
    }

    // Case 3: Reset states (e.g. user clicked New Chat manually while streaming was stuck, or page refreshed)
    if (buttonState.isSendButton && !buttonState.isDisabled) {
      if (state.isStreaming) {
        // If we were streaming, but now see an enabled send button, we might have missed the stop.
        // But usually this happens on New Chat where context resets.
        crnLog('🔄 Resetting streaming state (Ready)');
        state.isStreaming = false;
      }
    }
  }

  // [CRN] Show notification
  function showResponseCompleteNotification() {
    const now = Date.now();
    if (now - state.lastNotificationTime < CONFIG.NOTIFICATION_COOLDOWN) return;

    crnLog('🔔 Response complete - showing notification');

    if (!state.notificationPermissionGranted) {
      checkNotificationPermissionNative().then(granted => {
        if (granted) actuallyShowNotification();
      });
      return;
    }

    actuallyShowNotification();
  }

  // [CRN] Show native notification
  function actuallyShowNotification() {
    if (!('Notification' in window)) {
      fallbackToGMNotification();
      return;
    }

    try {
      const lastAssistantMessage = document.querySelector([
        'div[data-message-author-role="assistant"]:last-of-type div.markdown',
        'div[data-message-author-role="assistant"]:last-of-type div.prose',
        'div[data-message-author-role="assistant"]:last-of-type > div > div',
        'div[data-message-author-role="assistant"]:last-of-type span',
        'div[data-message-author-role="assistant"]:last-of-type'
      ].join(', '));

      let previewText = 'ChatGPT response completed';

      if (lastAssistantMessage) {
        const content = lastAssistantMessage.textContent?.trim() || '';
        if (content) {
          const cleanContent = content.replace(/\s+/g, ' ').trim();
          previewText = cleanContent.substring(0, 150) + (cleanContent.length > 150 ? '...' : '');
        }
      }

      const notification = new Notification('✅ ChatGPT Response Complete', {
        body: previewText,
        icon: 'https://chatgpt.com/favicon.ico',
        badge: 'https://chatgpt.com/favicon.ico',
        silent: false
      });

      notification.onclick = function () {
        window.focus();
        this.close();
      };

      state.lastNotificationTime = Date.now();

    } catch (error) {
      fallbackToGMNotification();
    }
  }

  // [CRN] Fallback
  function fallbackToGMNotification() {
    try {
      if (typeof GM_notification === 'function') {
        GM_notification({
          title: '✅ ChatGPT Response Complete',
          text: 'Response completed',
          image: 'https://chatgpt.com/favicon.ico',
          timeout: 8000,
          onclick: function () { window.focus(); }
        });
      } else {
        alert('ChatGPT Response Complete!');
        window.focus();
      }
    } catch (e) { }
  }

  // [CRN] Setup Button Observer (Watches attributes of the specific form)
  function setupButtonObserver() {
    if (state.buttonObserver) {
      state.buttonObserver.disconnect();
      state.buttonObserver = null;
    }

    // Find the container form
    const container = document.querySelector('form[aria-label="Chat input form"]') ||
      document.querySelector('div[data-testid="conversation-container"] footer');

    if (!container) {
      crnLog('⚠️ Input container not found yet.');
      return;
    }

    state.observedForm = container;

    state.buttonObserver = new MutationObserver(() => {
      const currentState = getCurrentButtonState();
      if (currentState) handleButtonStateChange(currentState);
    });

    state.buttonObserver.observe(container, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ['data-testid', 'aria-label', 'disabled', 'class']
    });

    // Initial check
    const initialState = getCurrentButtonState();
    if (initialState) handleButtonStateChange(initialState);
  }

  // [CRN] Main Heartbeat (Polling)
  // Handles "New Chat" detection via URL and Form presence checks
  function startPolling() {
    if (state.pollInterval) clearInterval(state.pollInterval);

    state.pollInterval = setInterval(() => {
      // 1. Check for NEW CHAT via URL
      const currentPath = window.location.pathname;

      // If URL changed (e.g. /c/abc -> /c/def or /c/abc -> /)
      if (state.lastUrlPath !== currentPath) {
        crnLog(`🌐 URL changed from "${state.lastUrlPath}" to "${currentPath}". Resetting context.`);
        state.lastUrlPath = currentPath;
        state.isStreaming = false; // Force reset
        setupButtonObserver(); // Re-attach to new DOM
        return; // Skip button check this tick
      }

      // 2. Check if our observed form is still alive
      // If the form was removed from DOM (e.g. during a UI swap), re-initialize
      if (state.observedForm && !document.body.contains(state.observedForm)) {
        crnLog('⚠️ Observed form removed from DOM. Re-initializing.');
        state.isStreaming = false;
        setupButtonObserver();
        return;
      }

      // 3. Standard Button State Check (if observer is working, this is redundant but safe)
      // The observer handles this efficiently, but we keep a check here just in case
      // the observer missed something or hasn't attached yet.
      const currentState = getCurrentButtonState();
      if (currentState) {
        handleButtonStateChange(currentState);
      }

    }, CONFIG.POLL_INTERVAL);

    crnLog('✅ Polling heartbeat started');
  }

  // [CRN] Initialize
  async function initialize() {
    crnLog('🚀 Initializing (No Spam Version)');

    state.notificationPermissionGranted = await checkNotificationPermissionNative();

    setupButtonObserver();
    startPolling();

    window.addEventListener('beforeunload', () => {
      if (state.buttonObserver) state.buttonObserver.disconnect();
      clearInterval(state.pollInterval);
    });

    crnLog('✅ Initialization Complete');
  }

  // [CRN] Start
  if (document.readyState === 'complete' || document.readyState === 'interactive') {
    initialize();
  } else {
    window.addEventListener('DOMContentLoaded', () => setTimeout(initialize, 200));
    window.addEventListener('load', () => setTimeout(initialize, 500));
  }

  // [CRN] Expose test function to global scope for console testing
  unsafeWindow.testCRNNotification = function () {
    crnLog('🧪 Manual test');
    actuallyShowNotification();
  };
})();