ChatGPT Message Tracker

Tracks and displays ChatGPT message usage based on model limits, with a toggle button to reopen the info panel. Adds support for gpt-4 model, makes model usage collapsible, and persists collapse state between page reloads.

Tính đến 17-09-2024. Xem phiên bản mới nhất.

// ==UserScript==
// @name         ChatGPT Message Tracker
// @namespace    http://tampermonkey.net/
// @version      1.4
// @description  Tracks and displays ChatGPT message usage based on model limits, with a toggle button to reopen the info panel. Adds support for gpt-4 model, makes model usage collapsible, and persists collapse state between page reloads.
// @author       @MartianInGreen
// @license      MIT
// @match        https://chatgpt.com/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    /***********************
     * Configuration
     ***********************/

    // Define the target URL to monitor
    const TARGET_URL = 'https://chatgpt.com/backend-api/conversation';

    // Define model limits and rolling window durations (in milliseconds)
    const MODEL_LIMITS = {
        "gpt-4o": {
            limit: 80,
            window: 3 * 60 * 60 * 1000, // 3 hours
            unlimited: false
        },
        "gpt-4o-mini": {
            limit: Infinity,
            window: 3 * 60 * 60 * 1000, // 3 hours
            unlimited: true
        },
        "o1-preview": {
            limit: 50,
            window: 7 * 24 * 60 * 60 * 1000, // 1 week
            unlimited: false
        },
        "o1-mini": {
            limit: 50,
            window: 24 * 60 * 60 * 1000, // 1 day
            unlimited: false
        },
        // Added gpt-4 model
        "gpt-4": {
            limit: 40,
            window: 3 * 60 * 60 * 1000, // 3 hours
            unlimited: false
        }
    };


    // LocalStorage keys
    const STORAGE_KEY = 'chatgpt_message_tracker';
    const COLLAPSE_STATE_KEY = 'chatgpt_message_tracker_collapse_state';

    /***********************
     * Utility Functions
     ***********************/

    /**
     * Retrieves the stored data from localStorage.
     * @returns {Object} The stored data or an empty object.
     */
    function getStoredData() {
        const data = localStorage.getItem(STORAGE_KEY);
        return data ? JSON.parse(data) : {};
    }

    /**
     * Saves the data to localStorage.
     * @param {Object} data The data to store.
     */
    function saveData(data) {
        localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
    }

    /**
     * Retrieves the collapse state from localStorage.
     * @returns {Object} The collapse state or an empty object.
     */
    function getCollapseState() {
        const state = localStorage.getItem(COLLAPSE_STATE_KEY);
        return state ? JSON.parse(state) : {};
    }

    /**
     * Saves the collapse state to localStorage.
     * @param {Object} state The state to store.
     */
    function saveCollapseState(state) {
        localStorage.setItem(COLLAPSE_STATE_KEY, JSON.stringify(state));
    }

    /**
     * Cleans up old timestamps based on the rolling window.
     * @param {Array<number>} timestamps Array of timestamp numbers.
     * @param {number} window Duration in milliseconds.
     * @returns {Array<number>} Cleaned array of timestamps.
     */
    function cleanTimestamps(timestamps, window) {
        const now = Date.now();
        return timestamps.filter(timestamp => now - timestamp <= window);
    }

    /**
     * Formats remaining time for display.
     * @param {number} ms Milliseconds.
     * @returns {string} Formatted time string.
     */
    function formatTime(ms) {
        const totalSeconds = Math.floor(ms / 1000);
        const days = Math.floor(totalSeconds / (24 * 3600));
        const hours = Math.floor((totalSeconds % (24 * 3600)) / 3600);
        const minutes = Math.floor((totalSeconds % 3600) / 60);
        const seconds = totalSeconds % 60;
        let parts = [];
        if (days > 0) parts.push(`${days}d`);
        if (hours > 0) parts.push(`${hours}h`);
        if (minutes > 0) parts.push(`${minutes}m`);
        parts.push(`${seconds}s`);
        return parts.join(' ');
    }

    /***********************
     * Data Tracking
     ***********************/

    // Initialize or retrieve stored data
    let usageData = getStoredData();

    /**
     * Logs a message sent using a specific model.
     * @param {string} model The model used.
     */
    function logMessage(model) {
        if (!(model in MODEL_LIMITS)) return; // Ignore unknown models

        const now = Date.now();

        // Initialize usage arrays if not present
        if (!usageData[model]) {
            usageData[model] = [];
        }

        // Log the message for the specific model
        usageData[model].push(now);

        // Clean old timestamps
        const window = MODEL_LIMITS[model].window;
        if (window > 0) {
            usageData[model] = cleanTimestamps(usageData[model], window);
        }

        // If the model is gpt-4, also log it towards gpt-4o
        if (model === "gpt-4") {
            const gpt4oModel = "gpt-4o";
            if (!usageData[gpt4oModel]) {
                usageData[gpt4oModel] = [];
            }
            usageData[gpt4oModel].push(now);
            const gpt4oWindow = MODEL_LIMITS[gpt4oModel].window;
            usageData[gpt4oModel] = cleanTimestamps(usageData[gpt4oModel], gpt4oWindow);
        }

        // Save updated data
        saveData(usageData);

        // Update UI
        updateUI();
    }

    /***********************
     * Network Interception
     ***********************/

    /**
     * Intercepts fetch calls.
     */
    (function() {
        const originalFetch = window.fetch;
        window.fetch = function(...args) {
            const [resource, config] = args;
            if (typeof resource === 'string' && resource === TARGET_URL) {
                // Clone the request to read the body
                return originalFetch.apply(this, args).then(response => {
                    if (config && config.method === 'POST' && config.body) {
                        try {
                            const body = JSON.parse(config.body);
                            const model = body.model;
                            if (model) {
                                logMessage(model);
                            }
                        } catch (e) {
                            console.error('Failed to parse fetch request body:', e);
                        }
                    }
                    return response;
                });
            }
            return originalFetch.apply(this, args);
        };
    })();

    /**
     * Intercepts XMLHttpRequest calls.
     */
    (function() {
        const originalOpen = XMLHttpRequest.prototype.open;
        const originalSend = XMLHttpRequest.prototype.send;

        XMLHttpRequest.prototype.open = function(method, url, async, user, password) {
            this._method = method;
            this._url = url;
            return originalOpen.apply(this, arguments);
        };

        XMLHttpRequest.prototype.send = function(body) {
            if (this._url === TARGET_URL && this._method === 'POST' && body) {
                try {
                    const parsedBody = JSON.parse(body);
                    const model = parsedBody.model;
                    if (model) {
                        logMessage(model);
                    }
                } catch (e) {
                    console.error('Failed to parse XHR request body:', e);
                }
            }
            return originalSend.apply(this, arguments);
        };
    })();

    /***********************
     * UI Creation
     ***********************/

    // Create the UI container
    const uiContainer = document.createElement('div');
    uiContainer.style.position = 'fixed';
    uiContainer.style.bottom = '20px';
    uiContainer.style.right = '20px';
    uiContainer.style.width = '250px';
    uiContainer.style.maxHeight = '500px';
    uiContainer.style.overflowY = 'auto';
    uiContainer.style.backgroundColor = 'rgba(0, 0, 0, 0.85)';
    uiContainer.style.color = '#fff';
    uiContainer.style.padding = '15px';
    uiContainer.style.borderRadius = '8px';
    uiContainer.style.boxShadow = '0 0 10px rgba(0,0,0,0.5)';
    uiContainer.style.zIndex = '10000';
    uiContainer.style.fontFamily = 'Arial, sans-serif';
    uiContainer.style.fontSize = '14px';
    uiContainer.style.cursor = 'move';
    uiContainer.style.display = 'block'; // Ensure it's visible initially
    uiContainer.style.left = 'auto'; // Reset left and top to allow positioning
    uiContainer.style.top = 'auto';

    // Add a header
    const header = document.createElement('div');
    header.textContent = '📊 Message Tracker';
    header.style.fontWeight = 'bold';
    header.style.marginBottom = '10px';
    header.style.position = 'relative';
    uiContainer.appendChild(header);

    // Add a close button
    const closeButton = document.createElement('span');
    closeButton.textContent = '✖';
    closeButton.style.position = 'absolute';
    closeButton.style.top = '0';
    closeButton.style.right = '0';
    closeButton.style.cursor = 'pointer';
    closeButton.title = 'Close';
    closeButton.addEventListener('click', () => {
        uiContainer.style.display = 'none';
        toggleButton.style.display = 'block'; // Show the toggle button when panel is closed
    });
    header.appendChild(closeButton);

    // Add content area
    const content = document.createElement('div');
    uiContainer.appendChild(content);

    // Append to body
    document.body.appendChild(uiContainer);

    /**
     * Updates the UI with the current usage data.
     */
    function updateUI() {
        // Clear existing content
        content.innerHTML = '';

        const now = Date.now();

        // Retrieve collapse state
        const collapseState = getCollapseState();

        for (const [model, config] of Object.entries(MODEL_LIMITS)) {
            const modelName = model;
            const usage = usageData[model] || [];

            let used = 0;
            let remaining = config.limit;

            if (config.unlimited) {
                used = usage.length;
                remaining = '∞';
            } else {
                // Clean old timestamps
                const cleaned = cleanTimestamps(usage, config.window);
                if (cleaned.length !== usage.length) {
                    usageData[model] = cleaned;
                    saveData(usageData);
                }

                used = cleaned.length;
                remaining = config.limit - used;
                if (remaining < 0) remaining = 0;
            }

            // Calculate time until the oldest message falls out of the window
            let timeLeft = 'N/A';
            if (!config.unlimited && usageData[model] && usageData[model].length > 0) {
                const oldest = usageData[model][0];
                const elapsed = now - oldest;
                const windowDuration = config.window;
                if (elapsed < windowDuration) {
                    const remainingTime = windowDuration - elapsed;
                    timeLeft = formatTime(remainingTime);
                }
            }

            // Create a container for the model
            const modelContainer = document.createElement('div');
            modelContainer.style.marginBottom = '8px';
            modelContainer.style.borderBottom = '1px solid #444';
            modelContainer.style.paddingBottom = '8px';

            // Create the clickable header for collapsing
            const modelHeader = document.createElement('div');
            modelHeader.textContent = `Model: ${modelName}`;
            modelHeader.style.fontWeight = 'bold';
            modelHeader.style.cursor = 'pointer';
            modelHeader.style.display = 'flex';
            modelHeader.style.justifyContent = 'space-between';
            modelHeader.style.alignItems = 'center';

            // Add an arrow indicator
            const arrow = document.createElement('span');
            arrow.textContent = collapseState[model] === false ? '▼' : '▶';
            arrow.style.transition = 'transform 0.2s';
            modelHeader.appendChild(arrow);

            modelContainer.appendChild(modelHeader);

            // Create the details section
            const details = document.createElement('div');
            details.style.marginTop = '5px';

            const usageInfo = document.createElement('div');
            usageInfo.textContent = `Used: ${used} / ${config.unlimited ? '∞' : config.limit} messages`;
            details.appendChild(usageInfo);

            if (!config.unlimited) {
                const remainingInfo = document.createElement('div');
                remainingInfo.textContent = `Remaining: ${remaining} messages`;
                details.appendChild(remainingInfo);

                const timeInfo = document.createElement('div');
                timeInfo.textContent = `Time until reset: ${timeLeft}`;
                details.appendChild(timeInfo);
            }

            modelContainer.appendChild(details);
            content.appendChild(modelContainer);

            // Set initial display based on collapse state
            if (collapseState[model] === false) {
                details.style.display = 'block';
                arrow.style.transform = 'rotate(0deg)';
            } else {
                details.style.display = 'none';
                arrow.style.transform = 'rotate(-90deg)';
            }

            // Toggle functionality
            modelHeader.addEventListener('click', () => {
                if (details.style.display === 'none') {
                    details.style.display = 'block';
                    arrow.style.transform = 'rotate(0deg)';
                    collapseState[model] = false;
                } else {
                    details.style.display = 'none';
                    arrow.style.transform = 'rotate(-90deg)';
                    collapseState[model] = true;
                }
                saveCollapseState(collapseState);
            });
        }
    }

    /***********************
     * UI Interactivity
     ***********************/

    // Make the UI draggable
    (function() {
        let isDragging = false;
        let startX, startY, initialX, initialY;

        header.addEventListener('mousedown', (e) => {
            isDragging = true;
            startX = e.clientX;
            startY = e.clientY;
            const rect = uiContainer.getBoundingClientRect();
            initialX = rect.left;
            initialY = rect.top;
            document.addEventListener('mousemove', onMouseMove);
            document.addEventListener('mouseup', onMouseUp);
            e.preventDefault(); // Prevent text selection
        });

        function onMouseMove(e) {
            if (!isDragging) return;
            const dx = e.clientX - startX;
            const dy = e.clientY - startY;
            uiContainer.style.left = `${initialX + dx}px`;
            uiContainer.style.top = `${initialY + dy}px`;
            uiContainer.style.right = 'auto';
            uiContainer.style.bottom = 'auto';
        }

        function onMouseUp() {
            isDragging = false;
            document.removeEventListener('mousemove', onMouseMove);
            document.removeEventListener('mouseup', onMouseUp);
        }
    })();

    /***********************
     * Toggle Button Creation
     ***********************/

    // Create the toggle button
    const toggleButton = document.createElement('button');
    toggleButton.textContent = '📊 Show Tracker';
    toggleButton.style.position = 'fixed';
    toggleButton.style.bottom = '20px';
    toggleButton.style.right = '40px';
    toggleButton.style.padding = '10px 15px';
    toggleButton.style.backgroundColor = '#008cba';
    toggleButton.style.color = '#fff';
    toggleButton.style.border = 'none';
    toggleButton.style.borderRadius = '5px';
    toggleButton.style.cursor = 'pointer';
    toggleButton.style.boxShadow = '0 2px 6px rgba(0,0,0,0.3)';
    toggleButton.style.zIndex = '10000';
    toggleButton.style.fontSize = '14px';
    toggleButton.style.display = 'none'; // Initially hidden since panel is visible

    toggleButton.addEventListener('click', () => {
        uiContainer.style.display = 'block';
        toggleButton.style.display = 'none';
    });

    document.body.appendChild(toggleButton);

    /***********************
     * Initial UI Update
     ***********************/

    updateUI();

    /***********************
     * Periodic Cleanup and UI Refresh
     ***********************/

    // Periodically clean old timestamps and refresh UI
    setInterval(() => {
        let dataChanged = false;
        const now = Date.now();

        for (const [model, config] of Object.entries(MODEL_LIMITS)) {
            if (!usageData[model]) continue;

            const cleaned = cleanTimestamps(usageData[model], config.window);
            if (cleaned.length !== usageData[model].length) {
                usageData[model] = cleaned;
                dataChanged = true;
            }
        }

        if (dataChanged) {
            saveData(usageData);
            updateUI();
        }
    }, 60 * 1000); // Every 1 minute

})();