ChatGPT Desktop Notifier

Adds 🌀 while generating and ✅ when done, plus desktop notifications.

Version au 17/07/2025. Voir la dernière version.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name           ChatGPT Desktop Notifier
// @name:ja        ChatGPT デスクトップ通知 & タイトルバッジ
// @namespace      https://github.com/usou/
// @version        1.0.0
// @description    Adds 🌀 while generating and ✅ when done, plus desktop notifications.
// @description:ja 生成中は🌀・完了で✅、完了時にデスクトップ通知を行います。
// @author         usou
// @license        MIT
// @match          *://chat.openai.com/*
// @match          *://chatgpt.com/*
// @grant          GM_notification
// @run-at         document-end
// @noframes
// ==/UserScript==

(() => {
  'use strict';

  /* ========= 定数 ========= */
  const PREFIX = { GEN: '🌀', DONE: '✅' };
  const STREAMING_SELECTORS = [
    '.result-streaming',
    'button[data-testid="stop-button"]',
    'svg.animate-spin'
  ];

  /* ========= ヘルパ ========= */
  const stripBadges  = title => title.replace(/^(?:🌀|✅)\s*/, '');
  const clearPrefix  = () => { document.title = stripBadges(document.title); };
  const isGenerating = () => STREAMING_SELECTORS.some(sel => document.querySelector(sel));

  const notifyDone = () => {
    GM_notification({
      title: `「${stripBadges(document.title)}」${PREFIX.DONE}`,
      text:  ' ', // 空文字では通知が来ない
      timeout: 5000,
      onclick: () => window.focus()
    });
  };

  /* ========= Enter 送信時にバッジ消去 ========= */
  document.addEventListener('keydown', e => {
    if (e.key === 'Enter' && !e.shiftKey && e.target?.tagName === 'TEXTAREA') {
      clearPrefix();
    }
  }, true);

  /* ========= 状態管理 ========= */
  let generating    = isGenerating();
  let lastBadge     = '';     // '' | PREFIX.GEN | PREFIX.DONE
  let updatingTitle = false;  // 再入防止

  const refreshBadge = () => {
    if (!lastBadge || updatingTitle) return;
    const clean   = stripBadges(document.title);
    const desired = lastBadge + clean;
    if (document.title === desired) return; // 既に付与済み
    updatingTitle = true;
    document.title = desired;
    updatingTitle = false;
  };

  /* ========= DOM 監視(生成開始 / 終了) ========= */
  const domObserver = new MutationObserver(() => {
    const nowGenerating = isGenerating();
    if (nowGenerating && !generating) {
      lastBadge = PREFIX.GEN;   // 生成開始
      refreshBadge();
    } else if (!nowGenerating && generating) {
      lastBadge = PREFIX.DONE;  // 生成終了
      refreshBadge();
      notifyDone();
    }
    generating = nowGenerating;
  });
  domObserver.observe(document.documentElement, { childList: true, subtree: true });

  /* ========= タイトル監視(差し替え対策) ========= */
  const watchTitle = titleNode => {
    if (!titleNode || titleNode.__badgeObserver) return;
    const obs = new MutationObserver(() => {
      if (!updatingTitle) refreshBadge();
    });
    obs.observe(titleNode, { childList: true, characterData: true, subtree: true });
    titleNode.__badgeObserver = obs;
    refreshBadge(); // 監視開始時にも付与
  };

  watchTitle(document.querySelector('title'));

  const headObserver = new MutationObserver(records => {
    records.forEach(record => record.addedNodes.forEach(node => {
      if (node.nodeName === 'TITLE') watchTitle(node);
    }));
  });
  headObserver.observe(document.head, { childList: true });
})();