LibreChat Shortcuts + Hide Header + Token Counter

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

目前为 2025-03-28 提交的版本。查看 最新版本

// ==UserScript==
// @name         LibreChat Shortcuts + Hide Header + Token Counter
// @namespace    http://tampermonkey.net/
// @version      1.8
// @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)

/*=============================================================
=                                                             =
=  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 #3 token counter                                      =
=                                                             =
=============================================================*/

(function () {
    'use strict';

    // ~4 chars per token estimate
    const estimateTokens = (text) => Math.ceil((text || "").trim().length / 4);

    // Create the badge element
    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"; // changed
    badge.style.color = "#a9a9a9";
    badge.style.fontFamily = "monospace";
    badge.style.userSelect = "none";
    badge.style.alignSelf = "center";
    badge.style.marginTop = "16px"; // moved down
    badge.textContent = "loading...";

    // Insert the badge in the row with mic button
    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 it's already inserted, skip
        if (flexRow.querySelector("#token-count-badge")) return true;

        // The mic button is our anchor
        const micButton = flexRow.querySelector('button[title="Use microphone"]');
        if (!micButton) return false;

        // Insert the badge before the mic button
        flexRow.insertBefore(badge, micButton);
        return true;
    }

    // Figure out if a message is user or assistant
    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";

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

    // Main logic: calculates turn-by-turn input and output
    function updateTokenCounts() {
        const messages = Array.from(document.querySelectorAll('.message-render'));
        if (!messages.length) {
            badge.textContent = "0 | 0 | ∑ 0 | $0.0000";
            return;
        }

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

        let cumulativeInputTokens = 0;
        let cumulativeOutputTokens = 0;
        let priorContextTokens = 0; // track how many tokens are in all previous turns

        // Each user→assistant pair is a turn
        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 userTokens = current.tokens;
                const assistantTokens = next.tokens;

                // For turn n, input = priorContextTokens + current user message
                const inputTokensThisTurn = priorContextTokens + userTokens;
                const outputTokensThisTurn = assistantTokens;

                cumulativeInputTokens += inputTokensThisTurn;
                cumulativeOutputTokens += outputTokensThisTurn;

                // Update prior context to include user + assistant from this turn
                priorContextTokens += userTokens + assistantTokens;

                // Skip the assistant in the loop
                i++;
            }
        }

        const totalTokens = cumulativeInputTokens + cumulativeOutputTokens;

        // Cost calculations
        //   Input cost: $2.50 per 1M tokens
        //   Output cost: $10.00 per 1M tokens
        const costInput = (cumulativeInputTokens / 1_000_000) * 2.50;
        const costOutput = (cumulativeOutputTokens / 1_000_000) * 10.00;
        const costTotal = costInput + costOutput;

        // Show: input | output | ∑ total | $X.XXXX
        badge.textContent =
            `${cumulativeInputTokens} | ${cumulativeOutputTokens} | ∑ ${totalTokens} | $${costTotal.toFixed(4)}`;
    }

    // Initialize, attach observer
    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();
    }

    // Kick off
    waitForLayoutAndInitialize();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址