ChatGPT Desktop Notifier

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

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

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