ChatGPTの回答完了を音とfaviconで通知する方法|Tampermonkeyで複数タブを見失わない

AIとWeb制作

ChatGPTを仕事で使っていると、地味だけれど繰り返し発生する小さなストレスがある。

「回答、もう終わったかな?」

長めの処理をChatGPTに任せ、その間にCursorや別の作業へ移る。少ししてChatGPTを見るとまだ生成中。もう一度作業へ戻り、しばらくしてまた確認する。

一度なら気にならない。

ただ、ChatGPTを複数タブで並列に使うようになると、この確認作業が思った以上に集中を切ってしまう。

Cursorを使っていて便利だと感じていたのが、AIの処理が終わったときの通知音だった。

そこで、

「ChatGPTも回答が終わったら音を鳴らせないだろうか?」

と考え、Tampermonkeyを使って自分で追加することにした。

さらに使ってみると、

「音は鳴った。でも、どのChatGPTタブが終わったのか分からない」

という次の問題が出てきた。

そこで現在は、

回答完了時に音を鳴らし、バックグラウンドで完了したChatGPTタブのfaviconだけを緑色のチェックマークへ変更する

という形にしている。

この記事では、導入方法から完成版スクリプトまでまとめておく。

Tampermonkeyとは

Tampermonkeyは、Webページ上で自分のJavaScriptを動かせるブラウザ拡張機能だ。

今回やることはシンプルで、

  1. ChatGPTが回答生成を始めたことを検出する
  2. 回答生成が終わったことを検出する
  3. 完了したら通知音を鳴らす
  4. バックグラウンドタブならfaviconを緑色のチェックへ変更する
  5. そのタブを開いたら通常のfaviconへ戻す

という処理を追加する。

ChatGPTそのものへ変更を加えるわけではなく、自分のブラウザ内だけで動作する。

Tampermonkeyをインストールする

まず、ChromeウェブストアなどからTampermonkeyをインストールする。

インストール後、ブラウザ右上のTampermonkeyアイコンをクリックし、

「新規スクリプトを追加」

を選択する。

エディタが表示されたら、最初から入っているサンプルコードをすべて削除する。

そこへ、この記事の後半に掲載している完成版スクリプトを丸ごと貼り付ける。

貼り付けたら、

Ctrl + S

で保存。

Tampermonkeyの管理画面でスクリプトが有効になっていることを確認し、ChatGPTを一度リロードする。

これで基本設定は完了だ。

最初は通知音だけだった

最初に作ったのは、回答が終了したら「ピコン」と音を鳴らす仕組みだけだった。

ChatGPTへ処理を依頼する。

そのままCursorへ移る。

別の作業をしていると音が鳴る。

そこでChatGPTへ戻る。

これだけでもかなり快適になった。

大きかったのは、待ち時間が短くなったことではない。

「ChatGPTが終わったかどうかを確認しなくてよくなった」

ことだった。

Web制作やプログラミングでは、一度頭の中へ構造を組み立ててから考えることが多い。

その途中で「終わったかな?」と別タブへ移るだけでも、思考の流れが途切れることがある。

音が鳴るまでChatGPTのことを忘れられる。

それだけでも集中しやすくなった。

複数タブでは「どの回答が終わったか」が分からない

ところがChatGPTを複数タブで使っていると、通知音だけでは足りなかった。

たとえば、

  • タブ1:Webサイト設計
  • タブ2:記事作成
  • タブ3:コードレビュー
  • タブ4:調査
  • タブ5:別プロジェクト

という状態で、どこかの回答が終わって音が鳴る。

しかし、どのタブなのか分からない。

そこでfaviconを使うことにした。

バックグラウンドで回答が終了したChatGPTだけ、通常のfaviconから緑色のチェックマークへ変更する。

タブタイトルは変更しない。

これが意外と重要だった。

タブ名には、

「このタブでは何について話しているのか」

という役割がある。

そこへ記号を追加するより、

タブ名=内容の識別
favicon=回答完了状態の識別

と役割を分けたほうが分かりやすかった。

回答済みのタブをクリックすると、faviconは自動的に通常へ戻る。

つまり緑色のチェックは、

「回答は終わっているが、まだ自分が確認していないChatGPT」

という意味になる。

完成版Tampermonkeyスクリプト

以下が現在の完成版。

Tampermonkeyの既存コードをすべて削除し、そのまま貼り付ければよい。

// ==UserScript==
// @name         ChatGPT 回答完了サウンド+favicon通知
// @namespace    local.chatgpt.notify
// @version      1.2.0
// @description  ChatGPTの回答生成が完了したら音を鳴らし、バックグラウンドタブのfaviconを変更する
// @match        https://chatgpt.com/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(() => {
    'use strict';

    // --------------------------------------------------
    // 設定
    // --------------------------------------------------

    // true:
    // ChatGPTを見ていない時だけ音を鳴らす
    //
    // false:
    // ChatGPTを見ている時も必ず音を鳴らす
    const ONLY_WHEN_NOT_FOCUSED = false;

    // true:
    // バックグラウンドタブで回答が完了した場合だけ
    // faviconを変更する
    //
    // false:
    // 前面タブでも回答完了時にfaviconを変更する
    const MARK_ONLY_WHEN_NOT_FOCUSED = true;

    // 生成終了後、何ms待ってから完了と判定するか
    const COMPLETE_DELAY = 1500;

    let wasGenerating = false;
    let completionTimer = null;
    let audioContext = null;

    // favicon通知用
    let isAnswerCompleted = false;
    let originalFavicons = [];
    let createdFavicon = null;

    // --------------------------------------------------
    // 音声を有効化
    // --------------------------------------------------

    function unlockAudio() {
        if (!audioContext) {
            const AudioContext =
                window.AudioContext ||
                window.webkitAudioContext;

            if (AudioContext) {
                audioContext = new AudioContext();
            }
        }

        if (audioContext?.state === 'suspended') {
            audioContext.resume().catch(() => {});
        }
    }

    document.addEventListener(
        'pointerdown',
        unlockAudio,
        { passive: true }
    );

    document.addEventListener(
        'keydown',
        unlockAudio
    );

    // --------------------------------------------------
    // 通知音
    // --------------------------------------------------

    function playNotificationSound() {
        unlockAudio();

        if (!audioContext) {
            return;
        }

        const now = audioContext.currentTime;

        const osc1 =
            audioContext.createOscillator();

        const gain1 =
            audioContext.createGain();

        osc1.type = 'sine';

        osc1.frequency.setValueAtTime(
            740,
            now
        );

        gain1.gain.setValueAtTime(
            0.0001,
            now
        );

        gain1.gain.exponentialRampToValueAtTime(
            0.12,
            now + 0.01
        );

        gain1.gain.exponentialRampToValueAtTime(
            0.0001,
            now + 0.18
        );

        osc1.connect(gain1);
        gain1.connect(
            audioContext.destination
        );

        osc1.start(now);
        osc1.stop(now + 0.2);

        const osc2 =
            audioContext.createOscillator();

        const gain2 =
            audioContext.createGain();

        osc2.type = 'sine';

        osc2.frequency.setValueAtTime(
            980,
            now + 0.17
        );

        gain2.gain.setValueAtTime(
            0.0001,
            now + 0.17
        );

        gain2.gain.exponentialRampToValueAtTime(
            0.12,
            now + 0.18
        );

        gain2.gain.exponentialRampToValueAtTime(
            0.0001,
            now + 0.38
        );

        osc2.connect(gain2);
        gain2.connect(
            audioContext.destination
        );

        osc2.start(now + 0.17);
        osc2.stop(now + 0.4);
    }

    // --------------------------------------------------
    // 回答完了用 favicon を作成
    // --------------------------------------------------

    function createCompletedFavicon() {
        const svg = `
            <svg
                xmlns="http://www.w3.org/2000/svg"
                width="64"
                height="64"
                viewBox="0 0 64 64"
            >
                <circle
                    cx="32"
                    cy="32"
                    r="30"
                    fill="#22c55e"
                />

                <path
                    d="M17 33 L27 43 L48 20"
                    fill="none"
                    stroke="white"
                    stroke-width="7"
                    stroke-linecap="round"
                    stroke-linejoin="round"
                />
            </svg>
        `;

        return (
            'data:image/svg+xml;charset=utf-8,' +
            encodeURIComponent(svg)
        );
    }

    // --------------------------------------------------
    // 回答完了faviconを付ける
    // --------------------------------------------------

    function markAnswerCompleted() {
        if (isAnswerCompleted) {
            return;
        }

        isAnswerCompleted = true;

        let icons = [
            ...document.querySelectorAll(
                'link[rel~="icon"]'
            ),
        ];

        originalFavicons = icons.map(
            (icon) => ({
                element: icon,
                href: icon.getAttribute('href'),
            })
        );

        if (icons.length === 0) {
            createdFavicon =
                document.createElement('link');

            createdFavicon.rel = 'icon';

            document.head.appendChild(
                createdFavicon
            );

            icons = [createdFavicon];
        }

        const completedFavicon =
            createCompletedFavicon();

        icons.forEach((icon) => {
            icon.href = completedFavicon;
        });

        console.log(
            '[ChatGPT Notify] 回答完了faviconを表示しました。'
        );
    }

    // --------------------------------------------------
    // 回答完了faviconを解除
    // --------------------------------------------------

    function clearAnswerCompleted() {
        if (!isAnswerCompleted) {
            return;
        }

        isAnswerCompleted = false;

        originalFavicons.forEach(
            ({ element, href }) => {
                if (
                    !element ||
                    !element.isConnected
                ) {
                    return;
                }

                if (href !== null) {
                    element.setAttribute(
                        'href',
                        href
                    );
                } else {
                    element.removeAttribute(
                        'href'
                    );
                }
            }
        );

        if (
            createdFavicon &&
            createdFavicon.isConnected
        ) {
            createdFavicon.remove();
        }

        createdFavicon = null;
        originalFavicons = [];

        console.log(
            '[ChatGPT Notify] 回答完了faviconを解除しました。'
        );
    }

    // --------------------------------------------------
    // 回答完了したタブを開いたらfaviconを解除
    // --------------------------------------------------

    window.addEventListener(
        'focus',
        clearAnswerCompleted
    );

    document.addEventListener(
        'visibilitychange',
        () => {
            if (
                document.visibilityState ===
                'visible'
            ) {
                clearAnswerCompleted();
            }
        }
    );

    // --------------------------------------------------
    // ChatGPTが現在生成中か判定
    // --------------------------------------------------

    function isGenerating() {
        if (
            document.querySelector(
                '[data-testid="stop-button"]'
            )
        ) {
            return true;
        }

        const buttons = [
            ...document.querySelectorAll(
                'button'
            ),
        ];

        return buttons.some((button) => {
            const aria = (
                button.getAttribute(
                    'aria-label'
                ) || ''
            ).toLowerCase();

            const text = (
                button.textContent || ''
            )
                .trim()
                .toLowerCase();

            return (
                aria.includes(
                    'stop generating'
                ) ||
                aria.includes(
                    '生成を停止'
                ) ||
                text === 'stop' ||
                text === '停止'
            );
        });
    }

    // --------------------------------------------------
    // 状態監視
    // --------------------------------------------------

    function checkState() {
        const generating =
            isGenerating();

        if (
            generating &&
            !wasGenerating
        ) {
            wasGenerating = true;

            if (completionTimer) {
                clearTimeout(
                    completionTimer
                );

                completionTimer = null;
            }

            clearAnswerCompleted();

            return;
        }

        if (
            !generating &&
            wasGenerating &&
            !completionTimer
        ) {
            completionTimer =
                setTimeout(() => {
                    completionTimer = null;

                    if (isGenerating()) {
                        return;
                    }

                    wasGenerating = false;

                    const userIsLooking =
                        document.hasFocus() &&
                        document.visibilityState ===
                            'visible';

                    if (
                        !ONLY_WHEN_NOT_FOCUSED ||
                        !userIsLooking
                    ) {
                        playNotificationSound();
                    }

                    if (
                        !MARK_ONLY_WHEN_NOT_FOCUSED ||
                        !userIsLooking
                    ) {
                        markAnswerCompleted();
                    }
                }, COMPLETE_DELAY);
        }
    }

    // --------------------------------------------------
    // DOM変更監視
    // --------------------------------------------------

    const observer =
        new MutationObserver(checkState);

    observer.observe(
        document.body,
        {
            childList: true,
            subtree: true,
            attributes: true,
        }
    );

    // 保険として定期チェック
    setInterval(
        checkState,
        1000
    );

    console.log(
        '[ChatGPT Notify] 回答完了サウンド+favicon通知を監視しています。'
    );
})();

実際の動作を確認する

保存後、ChatGPTのページを再読み込みする。

ブラウザの音声再生制限があるため、ChatGPT上で一度クリックしたり、実際にプロンプトを送信したりしておく。

その後、少し長めの回答を生成させて、別のタブやCursorへ移動する。

正常に動けば、

回答完了 → 通知音

となる。

さらに、そのChatGPTがバックグラウンドタブだった場合は、faviconが緑色のチェックマークへ変わる。

そのタブを開けば、通常のChatGPT faviconへ戻る。

複数タブを使っている場合、

音=どこかのChatGPTが終わった
緑色のfavicon=このChatGPTの回答が終わった

という役割になる。

好みに合わせて設定を変更できる

スクリプト上部の、

const ONLY_WHEN_NOT_FOCUSED = false;

trueにすると、ChatGPTを見ていないときだけ通知音が鳴る。

私は現在falseにしているため、前面で見ているChatGPTでも完了時には音が鳴る。

一方、

const MARK_ONLY_WHEN_NOT_FOCUSED = true;

は、favicon変更をバックグラウンドタブだけに限定する設定だ。

これはtrueのままが使いやすい。

今見ているChatGPTなら回答が終わったことは分かるので、わざわざfaviconまで変える必要がないからだ。

動かなくなったときに確認したいこと

このスクリプトでは、ChatGPT上の生成停止ボタンなどを利用して、回答生成中かどうかを判定している。

そのためChatGPTのUIが将来変更された場合、通知が動かなくなる可能性はある。

その場合、Tampermonkeyそのものが壊れたとは限らない。

まず疑うのは、

ChatGPT側のHTML構造が変わり、生成状態を検出できなくなった

というケースだ。

ユーザースクリプトによるカスタマイズなので、ここはある程度メンテナンス前提になる。

それでも、自分で中身を確認できる短いスクリプトなので、必要になれば修正できる。

AIを「待つ」のではなく、いったん忘れる

今回の改善は小さい。

回答が終わったら音が鳴る。

バックグラウンドで終わったタブだけ、緑色のfaviconになる。

それだけだ。

しかし、実際の仕事ではかなり快適になった。

ChatGPTへ処理を投げる。

そのことはいったん忘れる。

Cursorで別の作業へ集中する。

音が鳴ったらブラウザを見る。

緑色のチェックが付いているChatGPTだけ確認する。

以前はAIの回答を待ちながら仕事をしていた。

今は、

AIに仕事を任せたら、そのAIのことさえ一度忘れて仕事を続けられる。

AIそのものの性能を上げることはできなくても、AIと付き合う環境は自分で改善できる。

毎日何度も使う道具だからこそ、こうした小さな不便を一つずつなくしていくことが、結果としてかなり大きな効率化につながるのだと思う。

この記事を読んで、何か感じるものはありましたか?
読んで何か感じるものがあったら、そっと「いいね」を押してもらえると嬉しいです。

関連記事

目次