以前、ChatGPTの回答が終わったタイミングで通知音を鳴らし、バックグラウンドで完了したタブのfaviconを緑色のチェックマークへ変えるTampermonkeyスクリプトを作った。
前回の記事では、
音=どこかのChatGPTが回答完了した
緑の✓=このタブの回答が完了して、まだ確認していない
という役割分担まで実装した。
これだけでも、複数のChatGPTタブを並行して使うときの「どの回答が終わった?」という問題はかなり解消できた。
前回の記事はこちら。
ところが、使い続けているうちに、またひとつ小さな不便が見えてきた。
「では、今どのChatGPTがまだ考えているのか?」
今回は、この部分までfaviconで見分けられるように改善した。
Cursorを見ていて気づいた「処理中も見える」便利さ
普段、AIを使ったWeb制作ではChatGPTとCursorを並行して使うことが多い。
Cursorは処理中であることが視覚的に分かりやすい。
複数の処理を並行させていても、
「これはまだ作業中」
「これは終わった」
という状態を把握しやすい。
一方でChatGPTを複数タブ開いていると、
「このタブはもう終わった?」
「まだ生成していたっけ?」
と、一度タブを開いて確認することがあった。
これでは、せっかくAIへ作業を任せて別のことを始めても、何度も確認するために集中が途切れてしまう。
そこで前回の緑色の✓に加えて、回答生成中はオレンジ色の「…」faviconを表示するようにした。
faviconを3つの状態に分けた
今回の完成形はかなりシンプルだ。
通常のChatGPT favicon
→ 待機中、または回答確認済み
オレンジ色の「…」
→ ChatGPTが思考・回答生成中
緑色の「✓」
→ バックグラウンドで回答完了・まだ未確認
たとえばChatGPTを5タブ開いていて、
通常 | 🟠 | 🟢 | 🟠 | 通常
となっていれば、タブを開かなくても状態が分かる。
オレンジは「AIが仕事中」。
緑は「AIの仕事が終わり、自分の確認待ち」。
通常は「現在処理していない」。
実際に使ってみると、この区別が思った以上に分かりやすかった。
完了したタブを開けば自動的に元へ戻る
状態表示で意識したのは、余計な操作を増やさないことだ。
回答生成中にそのタブを開いても、オレンジの「…」は消えない。
まだChatGPTが処理しているのだから、そのまま表示する。
一方、バックグラウンドで回答が完了して緑の✓になったタブは、自分がそのタブを開いた時点で通常faviconへ戻る。
つまり、
緑の✓=まだ読んでいない回答
という意味になる。
メールの既読・未読に少し近い感覚でChatGPTのタブを扱える。
個人的には、この仕様がかなり使いやすい。
Tampermonkeyスクリプトをv1.3.0へ更新する
前回の記事ですでにTampermonkeyを導入している場合は、新しくスクリプトを作る必要はない。
次の手順で更新できる。
- Tampermonkeyの管理画面を開く
- 前回作成したChatGPT用スクリプトを開く
- コードをすべて削除する
- 以下のv1.3.0を丸ごと貼り付ける
- Ctrl + Sで保存する
- 開いているChatGPTタブを再読み込みする
初めて導入する場合は、Tampermonkeyで「新規スクリプト」を作成し、最初から入っているコードを削除して、以下を貼り付ければよい。
完成版スクリプト v1.3.0
// ==UserScript==
// @name ChatGPT 回答完了サウンド+状態favicon
// @namespace local.chatgpt.notify
// @version 1.3.0
// @description ChatGPTの回答生成中はオレンジfavicon、回答完了時は緑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:
// 前面タブでも回答完了時に緑✓を表示する
const COMPLETE_MARK_ONLY_WHEN_NOT_FOCUSED = true;
// 生成終了後、何ms待ってから完了と判定するか
const COMPLETE_DELAY = 1500;
// ==================================================
// 状態
// ==================================================
let wasGenerating = false;
let completionTimer = null;
let audioContext = null;
// normal / generating / completed
let faviconState = 'normal';
// ChatGPT本来のfaviconを保存
let originalFavicons = [];
// favicon要素がなかった場合に
// このスクリプトが作成した要素
let createdFavicon = null;
// ==================================================
// ChatGPT本来のfaviconを保存
// ==================================================
function saveOriginalFavicons() {
if (originalFavicons.length > 0) {
return;
}
const icons = [
...document.querySelectorAll(
'link[rel~="icon"]'
),
];
originalFavicons = icons.map((icon) => ({
element: icon,
href: icon.getAttribute('href'),
}));
}
saveOriginalFavicons();
// ==================================================
// 音声を有効化
// ==================================================
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;
// 1音目
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);
// 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 createGeneratingFavicon() {
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="#f59e0b"
/>
<circle
cx="20"
cy="32"
r="4"
fill="white"
/>
<circle
cx="32"
cy="32"
r="4"
fill="white"
/>
<circle
cx="44"
cy="32"
r="4"
fill="white"
/>
</svg>
`;
return (
'data:image/svg+xml;charset=utf-8,' +
encodeURIComponent(svg)
);
}
// ==================================================
// 回答完了 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 getFaviconElements() {
let icons = [
...document.querySelectorAll(
'link[rel~="icon"]'
),
];
if (icons.length === 0) {
if (!createdFavicon) {
createdFavicon =
document.createElement(
'link'
);
createdFavicon.rel =
'icon';
document.head.appendChild(
createdFavicon
);
}
icons = [createdFavicon];
}
return icons;
}
// ==================================================
// 思考・回答生成中faviconを表示
// ==================================================
function markGenerating() {
if (
faviconState ===
'generating'
) {
return;
}
saveOriginalFavicons();
faviconState =
'generating';
const icons =
getFaviconElements();
const favicon =
createGeneratingFavicon();
icons.forEach((icon) => {
icon.href = favicon;
});
console.log(
'[ChatGPT Notify] 思考・回答生成中です。'
);
}
// ==================================================
// 回答完了faviconを表示
// ==================================================
function markAnswerCompleted() {
saveOriginalFavicons();
faviconState =
'completed';
const icons =
getFaviconElements();
const favicon =
createCompletedFavicon();
icons.forEach((icon) => {
icon.href = favicon;
});
console.log(
'[ChatGPT Notify] 回答完了しました。'
);
}
// ==================================================
// 通常faviconへ戻す
// ==================================================
function restoreNormalFavicon() {
if (
faviconState ===
'normal'
) {
return;
}
faviconState = 'normal';
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;
console.log(
'[ChatGPT Notify] 通常faviconへ戻しました。'
);
}
// ==================================================
// ChatGPTが現在生成中か判定
// ==================================================
function isGenerating() {
if (
document.querySelector(
'[data-testid="stop-button"]'
)
) {
return true;
}
// UI変更へのフォールバック
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(
'stop streaming'
) ||
aria.includes(
'生成を停止'
) ||
aria.includes(
'回答を停止'
) ||
text === 'stop' ||
text === '停止'
);
}
);
}
// ==================================================
// 状態監視
// ==================================================
function checkState() {
const generating =
isGenerating();
// 生成開始
if (
generating &&
!wasGenerating
) {
wasGenerating = true;
if (completionTimer) {
clearTimeout(
completionTimer
);
completionTimer = null;
}
markGenerating();
return;
}
// 生成継続中
if (
generating &&
wasGenerating
) {
if (
faviconState !==
'generating'
) {
markGenerating();
}
return;
}
// 生成中 → 停止
if (
!generating &&
wasGenerating &&
!completionTimer
) {
completionTimer =
setTimeout(() => {
completionTimer = null;
// 一瞬だけStopボタンが
// 消えた場合への対策
if (isGenerating()) {
return;
}
wasGenerating = false;
const userIsLooking =
document.hasFocus() &&
document.visibilityState ===
'visible';
// 通知音
if (
!ONLY_WHEN_NOT_FOCUSED ||
!userIsLooking
) {
playNotificationSound();
}
// 回答完了favicon
if (
!COMPLETE_MARK_ONLY_WHEN_NOT_FOCUSED ||
!userIsLooking
) {
markAnswerCompleted();
} else {
restoreNormalFavicon();
}
}, COMPLETE_DELAY);
}
}
// ==================================================
// 完了済みタブを開いたら通常faviconへ戻す
// ==================================================
function clearCompletedWhenViewed() {
// 思考中faviconは消さない
if (
faviconState !==
'completed'
) {
return;
}
const userIsLooking =
document.hasFocus() &&
document.visibilityState ===
'visible';
if (userIsLooking) {
restoreNormalFavicon();
}
}
window.addEventListener(
'focus',
clearCompletedWhenViewed
);
document.addEventListener(
'visibilitychange',
() => {
if (
document.visibilityState ===
'visible'
) {
clearCompletedWhenViewed();
}
}
);
// ==================================================
// DOM変更監視
// ==================================================
const observer =
new MutationObserver(
checkState
);
observer.observe(
document.body,
{
childList: true,
subtree: true,
attributes: true,
}
);
// ==================================================
// 保険として定期チェック
// ==================================================
setInterval(
checkState,
1000
);
// 初回確認
checkState();
console.log(
'[ChatGPT Notify] 状態favicon+回答完了サウンドを監視しています。'
);
})();
動作確認はバックグラウンドタブで試す
保存したら、ChatGPTを再読み込みする。
その後、少し長めの回答になりそうな質問をChatGPTへ送信し、すぐに別のタブへ移動する。
正常に動作していれば、
回答生成開始
↓
オレンジの「…」favicon
そして回答が終わると、
緑色の「✓」favicon+通知音
へ変化する。
その後、そのChatGPTタブを開けば、緑の✓は消えて通常のfaviconへ戻る。
実際にバックグラウンドタブで試したところ、
思考中 → 回答完了 → 確認済み
という状態遷移で問題なく動作した。
なお、ブラウザには自動音声再生を制限する仕組みがあるため、ChatGPTを開いたあと、一度画面をクリックしたり実際にプロンプトを送信したりしておくと通知音が動作しやすい。
スクリプトが何を見て「思考中」と判断しているのか
今回のスクリプトは、ChatGPTの画面上に表示される「生成停止」に相当するボタンを検出している。
まず、
[data-testid="stop-button"]
が存在するか確認する。
見つからなかった場合には、ボタンのaria-labelやテキストから、
stop generatingstop streaming生成を停止回答を停止
なども探す。
つまり、
停止ボタンがある=ChatGPTが現在回答を生成している
という考え方で状態を判定している。
ただし、ここには注意点もある。
今回利用しているHTML要素やdata-testidは、ChatGPTの公開APIではない。
そのためChatGPT側の画面構造が変更された場合、将来的にこのスクリプトが動かなくなる可能性はある。
その場合は、ChatGPT側の新しいDOM構造に合わせてisGenerating()の判定部分を修正すればよい。
Tampermonkeyによる画面カスタマイズは便利だが、こうしたメンテナンスが必要になる可能性は理解して使った方がいい。
通知の設定は好みに合わせて変更できる
スクリプト冒頭には、いくつか設定値を用意している。
たとえば、
const ONLY_WHEN_NOT_FOCUSED = false;
をtrueへ変えると、ChatGPTを見ている最中には通知音を鳴らさず、別のタブを見ているときだけ音を鳴らすようにできる。
また、
const COMPLETE_MARK_ONLY_WHEN_NOT_FOCUSED = true;
は、緑の✓をバックグラウンドタブだけに表示する設定だ。
この状態なら、目の前で回答を読んでいるChatGPTが完了しても、わざわざ緑の✓にはならない。
個人的には、
思考中 → 常にオレンジ
バックグラウンド完了 → 緑
前面で完了 → 通常
という現在の設定が一番分かりやすかった。
AIを並列に使うなら「状態が見える」ことも重要
今回追加したのは、faviconを一つ増やしただけだ。
それでも複数のChatGPTへ同時に作業を任せていると、かなり便利だった。
以前は、
「どれが終わった?」
「どれがまだ考えている?」
と、ときどきタブを開いて確認していた。
今はタブを見るだけで分かる。
AIがどれだけ高速になっても、人間側が何度も処理状況を確認していたら、そのたびに集中が途切れる。
だからAI活用では、モデルそのものの性能だけではなく、
AIへ仕事を渡したあと、人間が安心して別の仕事へ移れる環境を作ること
も大切なのだと思う。
小さな改善だが、毎日何度も使う機能だからこそ効果は大きい。
前回の改善では、
「どのChatGPTの回答が終わったか」
が分かるようになった。
今回はさらに一歩進んで、
「どのChatGPTが今も働いているのか」
まで分かるようになった。
ChatGPTを1タブだけ使う人には必要のない機能かもしれない。
しかし、複数スレッドへ同時に質問を投げながらAIを並列運用している人にとっては、かなり実用的な改善になると思う。
