ChatGPT Desktop Notifier

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

Dovrai installare un'estensione come Tampermonkey, Greasemonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Violentmonkey per installare questo script.

Dovrai installare un'estensione come Tampermonkey o Userscripts per installare questo script.

Dovrai installare un'estensione come ad esempio Tampermonkey per installare questo script.

Dovrai installare un gestore di script utente per installare questo script.

(Ho già un gestore di script utente, lasciamelo installare!)

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione come ad esempio Stylus per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

Dovrai installare un'estensione per la gestione degli stili utente per installare questo stile.

(Ho già un gestore di stile utente, lasciamelo installare!)

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