LibreChat Shortcuts + Token Counter

Press Alt+S to click toggle-left-nav button on localhost:3080

Versión del día 31/3/2025. Echa un vistazo a la versión más reciente.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         LibreChat Shortcuts + Token Counter
// @namespace    http://tampermonkey.net/
// @version      1.9.0
// @description  Press Alt+S to click toggle-left-nav button on localhost:3080
// @author       bwhurd
// @match        http://localhost:3080/*
// @grant        none
// @run-at       document-end
// ==/UserScript==

// === Shortcut Keybindings ===
// Alt+S → Toggle sidebar (clicks #toggle-left-nav)
// Alt+N → New chat (clicks button[aria-label="New chat"])
// Alt+T → Scroll to top of message container
// Alt+Z → Scroll to bottom of message container
// Alt+A → Scroll up one message (.message-render)
// Alt+F → Scroll down one message (.message-render)

// Alt+r → refresh cost for conversation
// Alt+u → update the token cost per million

/*=============================================================
=                                                             =
=  IIFE #1 - Keyboard Shortcuts                               =
=                                                             =
=============================================================*/

(function () {
    'use strict';

    // === Inject custom CSS to override hidden footer button color ===
    const style = document.createElement('style');
    style.textContent = `
  .relative.hidden.items-center.justify-center {
    display:none;
  }
`;
    document.head.appendChild(style);


    // Shared scroll state object
    const ScrollState = {
        scrollContainer: null,
        isAnimating: false,
        finalScrollPosition: 0,
        userInterrupted: false,
    };

    function resetScrollState() {
        if (ScrollState.isAnimating) {
            ScrollState.isAnimating = false;
            ScrollState.userInterrupted = true;
        }
        ScrollState.scrollContainer = getScrollableContainer();
        if (ScrollState.scrollContainer) {
            ScrollState.finalScrollPosition = ScrollState.scrollContainer.scrollTop;
        }
    }

    // Find the first `.message-render`, then climb up until we detect a scrollable ancestor
    function getScrollableContainer() {
        const firstMessage = document.querySelector('.message-render');
        if (!firstMessage) return null;

        let container = firstMessage.parentElement;
        while (container && container !== document.body) {
            const style = getComputedStyle(container);
            if (
                container.scrollHeight > container.clientHeight &&
                style.overflowY !== 'visible' &&
                style.overflowY !== 'hidden'
            ) {
                return container;
            }
            container = container.parentElement;
        }

        // If none found, just use the main document scroller
        return document.scrollingElement || document.documentElement;
    }

    function checkGSAP() {
        if (
            typeof window.gsap !== "undefined" &&
            typeof window.ScrollToPlugin !== "undefined" &&
            typeof window.Observer !== "undefined" &&
            typeof window.Flip !== "undefined"
        ) {
            window.gsap = gsap;
            window.ScrollToPlugin = ScrollToPlugin;
            window.Observer = Observer;
            window.Flip = Flip;

            gsap.registerPlugin(ScrollToPlugin, Observer, Flip);
            console.log("✅ GSAP and plugins registered");
            initShortcuts();
        } else {
            console.warn("⏳ GSAP not ready. Retrying...");
            setTimeout(checkGSAP, 100);
        }
    }

    function loadGSAPLibraries() {
        const libs = [
            'https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.7/gsap.min.js',
            'https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.7/ScrollToPlugin.min.js',
            'https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.7/Observer.min.js',
            'https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.7/Flip.min.js',
        ];

        libs.forEach(src => {
            const script = document.createElement('script');
            script.src = src;
            script.async = false;
            document.head.appendChild(script);
        });

        checkGSAP();
    }

    function scrollToTop() {
        const container = getScrollableContainer();
        if (!container) return;
        gsap.to(container, {
            duration: 1.8,
            scrollTo: { y: 0 },
            ease: "power4.out"
        });
    }

    function scrollToBottom() {
        const container = getScrollableContainer();
        if (!container) return;
        gsap.to(container, {
            duration: 1.8,
            scrollTo: { y: "max" },
            ease: "power4.out"
        });
    }

    function scrollUpOneMessage() {
        const container = getScrollableContainer();
        if (!container) return;

        // Gather messages by .message-render
        const messages = [...document.querySelectorAll('.message-render')];
        const currentScrollTop = container.scrollTop;

        let target = null;
        for (let i = messages.length - 1; i >= 0; i--) {
            if (messages[i].offsetTop < currentScrollTop - 25) {
                target = messages[i];
                break;
            }
        }

        gsap.to(container, {
            duration: 0.8,
            scrollTo: { y: target?.offsetTop || 0 },
            ease: "power4.out"
        });
    }

    function scrollDownOneMessage() {
        const container = getScrollableContainer();
        if (!container) return;

        // Gather messages by .message-render
        const messages = [...document.querySelectorAll('.message-render')];
        const currentScrollTop = container.scrollTop;

        let target = null;
        for (let i = 0; i < messages.length; i++) {
            if (messages[i].offsetTop > currentScrollTop + 25) {
                target = messages[i];
                break;
            }
        }

        gsap.to(container, {
            duration: 0.8,
            scrollTo: { y: target?.offsetTop || container.scrollHeight },
            ease: "power4.out"
        });
    }

    function initShortcuts() {
        document.addEventListener('keydown', function (e) {
            if (!e.altKey || e.repeat) return;

            const key = e.key.toLowerCase();

            const keysToBlock = ['s', 'n', 't', 'z', 'a', 'f'];
            if (keysToBlock.includes(key)) {
                e.preventDefault();    // Prevent Chrome's default shortcut
                e.stopPropagation();   // Optional: stop bubbling up

                switch (key) {
                    case 's': {
                        const toggleButton = document.getElementById('toggle-left-nav');
                        if (toggleButton) {
                            toggleButton.click();
                            console.log('🧭 Sidebar toggled');
                        }
                        break;
                    }
                    case 'n': {
                        const newChatButton = document.querySelector('button[aria-label="New chat"]');
                        if (newChatButton) {
                            newChatButton.click();
                            console.log('🆕 New chat opened');
                        }
                        break;
                    }
                    case 't':
                        scrollToTop();
                        break;
                    case 'z':
                        scrollToBottom();
                        break;
                    case 'a':
                        scrollUpOneMessage();
                        break;
                    case 'f':
                        scrollDownOneMessage();
                        break;
                }
            }
        });

        console.log("✅ LibreChat shortcuts active");
    }


    // Start loading GSAP plugins and wait for them
    loadGSAPLibraries();
})();


/*=============================================================
=                                                             =
=  IIFE #2 token counter                                      =
=                                                             =
=============================================================*/

setTimeout(() => {
    (function () {
        'use strict';

        // Change from const to let so they can be updated dynamically
        let COST_PER_MILLION_INPUT = 2.50;  // Cost per million tokens for input
        let COST_PER_MILLION_OUTPUT = 10.00; // Cost per million tokens for output


        const estimateTokens = (text) => Math.ceil((text || "").trim().length / 4);

        const badge = document.createElement("span");
        badge.id = "token-count-badge";
        badge.style.fontSize = "10px";
        badge.style.padding = "1px 0 0 6px";
        badge.style.borderRadius = "8px";
        badge.style.background = "transparent";
        badge.style.color = "#a9a9a9";
        badge.style.fontFamily = "monospace";
        badge.style.userSelect = "none";
        badge.style.alignSelf = "center";
        badge.style.marginTop = "16px";

        const refreshBtn = document.createElement("button");
        refreshBtn.id = "refresh-btn";
        refreshBtn.title = "Refresh token count";
        refreshBtn.textContent = "↻";
        refreshBtn.style.marginLeft = "6px";
        refreshBtn.style.cursor = "pointer";
        refreshBtn.style.fontSize = "10px";
        refreshBtn.style.border = "none";
        refreshBtn.style.background = "transparent";
        refreshBtn.style.color = "#a9a9a9";
        refreshBtn.style.userSelect = "none";
        refreshBtn.style.fontFamily = "monospace";
        refreshBtn.style.transformOrigin = "center";
        refreshBtn.style.padding = "0";

        refreshBtn.onclick = () => {
            refreshBtn.style.transition = 'transform 0.15s ease';
            refreshBtn.style.transform = 'scale(1.4)';
            setTimeout(() => {
                refreshBtn.style.transform = 'scale(1)';
            }, 150);
            updateTokenCounts();
        };

        function insertBadgeInFlexRow() {
            const flexRow = [...document.querySelectorAll("div.flex")].find(el =>
                                                                            el.className.includes("items-between") && el.className.includes("pb-2")
                                                                           );
            if (!flexRow) return false;

            if (flexRow.querySelector("#token-count-badge")) return true;

            const micButton = flexRow.querySelector('button[title="Use microphone"]');
            if (!micButton) return false;

            flexRow.insertBefore(badge, micButton);
            return true;
        }

        function inferRole(msgEl) {
            const wrapper = msgEl.closest('.group, .message');
            if (wrapper?.classList.contains('user')) return 'user';
            if (wrapper?.classList.contains('assistant')) return 'assistant';

            const label = wrapper?.querySelector('span, div')?.innerText?.toLowerCase() || '';
            if (label.includes("you")) return "user";
            if (label.includes("gpt") || label.includes("assistant")) return "assistant";

            const all = Array.from(document.querySelectorAll('.message-render'));
            const index = all.indexOf(msgEl);
            return index % 2 === 0 ? "user" : "assistant";
        }

        function updateTokenCounts() {
            const messages = Array.from(document.querySelectorAll('.message-render'));
            if (!messages.length) {
                badge.textContent = `0 | 0 | ∑ 0 | $0.0000`;
                badge.appendChild(refreshBtn);
                return;
            }

            const conversation = messages.map(msg => ({
                role: inferRole(msg),
                content: msg.innerText || "",
                tokens: estimateTokens(msg.innerText || "")
            }));

            let cumulativeInputTokens = 0;
            let cumulativeOutputTokens = 0;
            let priorContextTokens = 0;

            for (let i = 0; i < conversation.length - 1; i++) {
                const current = conversation[i];
                const next = conversation[i + 1];

                if (current.role === "user" && next?.role === "assistant") {
                    const inputTokensThisTurn = priorContextTokens + current.tokens;
                    const outputTokensThisTurn = next.tokens;

                    cumulativeInputTokens += inputTokensThisTurn;
                    cumulativeOutputTokens += outputTokensThisTurn;

                    priorContextTokens += current.tokens + next.tokens;
                    i++; // skip assistant message
                }
            }

            const totalTokens = cumulativeInputTokens + cumulativeOutputTokens;
            const costInputCalculated = (cumulativeInputTokens / 1_000_000) * COST_PER_MILLION_INPUT;
            const costOutputCalculated = (cumulativeOutputTokens / 1_000_000) * COST_PER_MILLION_OUTPUT;
            const totalCostCalculated = costInputCalculated + costOutputCalculated;

            badge.textContent = `${cumulativeInputTokens} @ $${COST_PER_MILLION_INPUT}/M | ${cumulativeOutputTokens} @ $${COST_PER_MILLION_OUTPUT}/M | ∑ ${totalTokens} | $${totalCostCalculated.toFixed(3)}`;
            badge.appendChild(refreshBtn);
        }

        document.addEventListener('keydown', function(e) {
            if (e.altKey && e.key.toLowerCase() === 'r' && !e.repeat) {
                e.preventDefault();
                refreshBtn.style.transition = 'transform 0.15s ease';
                refreshBtn.style.transform = 'scale(1.4)';
                setTimeout(() => {
                    refreshBtn.style.transform = 'scale(1)';
                }, 150);
                updateTokenCounts();
            }
        });

        document.addEventListener('keydown', function(e) {
            if (e.altKey && e.key.toLowerCase() === 'u' && !e.repeat) {
                e.preventDefault();
                const newCosts = prompt(
                    `Enter new costs for input and output per million tokens, separated by a comma.\n(Current: ${COST_PER_MILLION_INPUT}, ${COST_PER_MILLION_OUTPUT})`,
                    `${COST_PER_MILLION_INPUT},${COST_PER_MILLION_OUTPUT}`
        );
        if (newCosts !== null) {
            const parts = newCosts.split(',').map(s => parseFloat(s.trim()));
            if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1])) {
                COST_PER_MILLION_INPUT = parts[0];
                COST_PER_MILLION_OUTPUT = parts[1];
                updateTokenCounts();
            } else {
                alert("Invalid input. Please enter two valid numbers separated by a comma.");
            }
        }
    }
});



        function waitForLayoutAndInitialize(retries = 20) {
            const messagesRoot = document.querySelector('.message-render')?.parentElement;
            const badgeReady = insertBadgeInFlexRow();

            if (!badgeReady && retries > 0) {
                setTimeout(() => waitForLayoutAndInitialize(retries - 1), 500);
                return;
            }

            if (messagesRoot) {
                const observer = new MutationObserver(() => {
                    updateTokenCounts();
                });
                observer.observe(messagesRoot, { childList: true, subtree: true });
            }

            updateTokenCounts();
        }

        waitForLayoutAndInitialize();
    })();
}, 1000);