ChatGPT Timestamp Injector

Displays the timestamp at the end of each ChatGPT message.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         ChatGPT Timestamp Injector
// @namespace    https://kuds.win/
// @version      1.0
// @description  Displays the timestamp at the end of each ChatGPT message.
// @author       KUDs
// @match        https://chatgpt.com/*
// @icon         https://chatgpt.com/favicon.ico
// @grant        none
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    /** フォーマット済み日時文字列を取得する関数 */
    function formatTimestamp(date) {
        // 各数値を2桁ゼロ埋め(年以外)
        const pad = (n) => n.toString().padStart(2, '0');
        const YYYY = date.getFullYear();
        const M = date.getMonth() + 1;
        const D = date.getDate();
        const HH = pad(date.getHours());
        const mm = pad(date.getMinutes());
        const ss = pad(date.getSeconds());
        return `${YYYY}/${M}/${D} ${HH}:${mm}:${ss}`;
    }

    /** チャットIDをURLから取得する関数 */
    function getCurrentChatId() {
        // パス形式: /c/<chat-id> または /g/<team-id>/c/<chat-id> (また、/share/<share-id>は今回は除外)
        const match = location.pathname.match(/^\/(?:c|g\/[^\/]+\/c)\/([^\/]+)/);
        return match ? match[1] : null;
    }

    let accessToken = null;
    /** アクセストークンを取得(既に取得済みなら再利用) */
    async function getAccessToken() {
        if (accessToken) return accessToken;
        try {
            const res = await fetch('/api/auth/session');
            if (!res.ok) throw new Error(res.statusText);
            const data = await res.json();
            accessToken = data.accessToken;
            return accessToken;
        } catch (err) {
            console.error('Failed to get access token:', err);
            return null;
        }
    }

    /** 会話データを取得する関数 */
    async function fetchConversationData(chatId) {
        const token = await getAccessToken();
        if (!token) return null;
        try {
            const res = await fetch(`/backend-api/conversation/${chatId}`, {
                headers: {
                    'Authorization': `Bearer ${token}`,
                    'X-Authorization': `Bearer ${token}`
                }
            });
            if (!res.ok) throw new Error(res.statusText);
            return await res.json();
        } catch (err) {
            console.error('Failed to fetch conversation data:', err);
            return null;
        }
    }

    /** メッセージ要素にタイムスタンプを付与する関数 */
    function appendTimestampElement(messageElem, timestamp) {
        // **重複防止**:すでに挿入済みならスキップ
        if (messageElem.querySelector('time.chatgpt-timestamp')) return;

        // 時刻をフォーマットし<time>要素を生成
        const date = new Date(timestamp * 1000);
        const timeText = formatTimestamp(date);
        const timeEl = document.createElement('time');
        timeEl.className = 'chatgpt-timestamp w-full';
        timeEl.dateTime = date.toISOString();
        timeEl.title = date.toLocaleString();
        timeEl.textContent = timeText;

        // 役割に応じて左右寄せを切り替え
        const role = messageElem.getAttribute('data-message-author-role');
        Object.assign(timeEl.style, {
            fontStyle: 'italic',
            opacity: '0.6',
            fontSize: '0.875rem',
            display: 'block',
            textAlign: role === 'user' ? 'right' : 'left'
        });
        messageElem.appendChild(timeEl);
    }

    // 挿入済みメッセージIDのセット(重複防止)
    const processedIds = new Set();

    // MutationObserverの設定(新しいメッセージの追加を監視)
    const mainElem = document.querySelector('main');
    let suppressObserver = false;
    let suppressedQueue = [];
    const observer = new MutationObserver((mutations) => {
        for (const mut of mutations) {
            for (const node of mut.addedNodes) {
                if (!(node instanceof HTMLElement)) continue;
                // 追加されたノード(またはその子孫)にメッセージ要素があるか確認
                let messageElements = [];
                if (node.hasAttribute && node.hasAttribute('data-message-id')) {
                    messageElements.push(node);
                } else {
                    messageElements = Array.from(node.querySelectorAll?.('[data-message-id]') || []);
                }
                for (const msgElem of messageElements) {
                    const msgId = msgElem.getAttribute('data-message-id');
                    if (!msgId || processedIds.has(msgId)) continue;
                    if (suppressObserver) {
                        // 一時抑制中はキューに溜めるだけ
                        suppressedQueue.push(msgId);
                    } else {
                        // 新規メッセージに対しタイムスタンプを取得・表示
                        updateMessageTimestamp(msgElem, msgId);
                    }
                }
            }
        }
    });
    if (mainElem) {
        observer.observe(mainElem, { childList: true, subtree: true });
    }

    /** 特定のメッセージIDに対してタイムスタンプを取得し挿入する関数 */
    async function updateMessageTimestamp(messageElem, messageId, retryCount = 0) {
        const chatId = getCurrentChatId();
        if (!chatId) return;
        const convo = await fetchConversationData(chatId);
        if (!convo || !convo.mapping) return;
        const node = convo.mapping[messageId];
        if (!node || !node.message || node.message.create_time === undefined) {
            // 該当メッセージが会話データに無い(※生成中の可能性)場合、一定回数リトライ
            if (retryCount < 10) {
                setTimeout(() => updateMessageTimestamp(messageElem, messageId, retryCount + 1), 1000);
            } else {
                console.warn(`Timestamp not found for message ${messageId}`);
            }
        } else {
            // タイムスタンプ挿入
            appendTimestampElement(messageElem, node.message.create_time);
            processedIds.add(messageId);
        }
    }

    /** 現在の会話内の全メッセージにタイムスタンプを付与 */
    async function processAllMessagesInConversation() {
        const chatId = getCurrentChatId();
        if (!chatId) return;
        const convo = await fetchConversationData(chatId);
        if (!convo) return;
        // 会話データから順序通りメッセージのリストを取得
        const mapping = convo.mapping || {};
        const startNodeId = convo.current_node || Object.values(mapping).find(n => !n.children || n.children.length === 0)?.id;
        if (!startNodeId) {
            console.warn('No valid start node for conversation');
            return;
        }
        // 末尾(最新)から親をたどって最初のメッセージまでリスト化
        const nodes = [];
        let nodeId = startNodeId;
        while (nodeId) {
            const node = mapping[nodeId];
            if (!node) break;
            // ルートに到達
            if (node.parent === undefined) break;
            // systemや非表示のメタメッセージをスキップ
            const msg = node.message;
            if (msg && msg.author.role !== 'system' &&
                msg.content?.content_type !== 'model_editable_context' &&
                msg.content?.content_type !== 'user_editable_context') {
                nodes.unshift(node);
            }
            nodeId = node.parent;
        }
        // DOM上の各メッセージ要素に対応するタイムスタンプを付与
        for (const node of nodes) {
            const msg = node.message;
            if (!msg || msg.id === undefined || msg.create_time === undefined) continue;
            const msgId = msg.id;
            const elem = document.querySelector(`[data-message-id="${msgId}"]`);
            if (elem && !processedIds.has(msgId)) {
                appendTimestampElement(elem, msg.create_time);
                processedIds.add(msgId);
            }
        }
    }

    // チャットの切り替えや初期読み込みを監視し、適宜タイムスタンプを挿入
    let currentChatId = null;
    setInterval(() => {
        const chatId = getCurrentChatId();
        if (chatId && chatId !== currentChatId) {
            // チャットが新規に開かれた/切り替わった
            currentChatId = chatId;
            processedIds.clear();
            suppressObserver = true;
            suppressedQueue = [];
            // 少し待ってから過去メッセージにまとめてタイムスタンプを付与
            setTimeout(async () => {
                await processAllMessagesInConversation();
                // 抑制中に溜まった新規メッセージも処理する
                for (const newId of suppressedQueue) {
                    const elem = document.querySelector(`[data-message-id="${newId}"]`);
                    if (elem && !processedIds.has(newId)) {
                        updateMessageTimestamp(elem, newId);
                    }
                }
                suppressedQueue = [];
                suppressObserver = false;
            }, 500);
        }
    }, 1000);
})();