LibreChat Shortcuts + Token Counter

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

Устаревшая версия за 31.03.2025. Перейдите к последней версии.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==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);