Perfexity.ai

Resizable Sidebar · Smart Scroll · Notifications · Settings in Profile Menu

K instalaci tototo skriptu si budete muset nainstalovat rozšíření jako Tampermonkey, Greasemonkey nebo Violentmonkey.

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

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Violentmonkey.

K instalaci tohoto skriptu si budete muset nainstalovat rozšíření jako Tampermonkey nebo Userscripts.

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

K instalaci tohoto skriptu si budete muset nainstalovat manažer uživatelských skriptů.

(Už mám manažer uživatelských skriptů, nechte mě ho nainstalovat!)

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.

(Už mám manažer uživatelských stylů, nechte mě ho nainstalovat!)

// ==UserScript==
// @name         Perfexity.ai
// @namespace    https://greasyfork.org/users/1566018
// @version      2.1
// @description  Resizable Sidebar · Smart Scroll · Notifications · Settings in Profile Menu
// @author       ReNDoM
// @match        https://www.perplexity.ai/*
// @grant        none
// @run-at       document-end
// @homepageURL  https://greasyfork.org/scripts/564497
// @supportURL   https://greasyfork.org/scripts/564497/feedback
// ==/UserScript==

(function () {
    'use strict';

    // ─── Constants ────────────────────────────────────────────────────────────────
    const SIDEBAR_MIN   = 200;
    const SIDEBAR_MAX   = 800;
    const SIDEBAR_DEFAULT = 280;
    const KEY_WIDTH     = 'perfexity_sidebar_width';
    const KEY_SETTINGS  = 'perfexity_settings';

    // ─── State ────────────────────────────────────────────────────────────────────
    let isResizing      = false;
    let sidebarOuter    = null;
    let sidebarInner    = null;
    let sidebarWrapper  = null;
    let mainContent     = null;
    let resizeHandle    = null;
    let audioCtx        = null;
    let hasRedirected   = false;
    let hasNotified     = false;

    const savedWidth = parseInt(localStorage.getItem(KEY_WIDTH)) || SIDEBAR_DEFAULT;

    let settings = {
        autoScroll:               true,
        autoRedirect:             true,
        resizeHandle:             true,
        notificationEnabled:      true,
        notificationDesktop:      true,
        notificationSound:        true,
        notificationOnlyInactive: false,
        ...JSON.parse(localStorage.getItem(KEY_SETTINGS) || '{}')
    };

    const saveSettings = () => localStorage.setItem(KEY_SETTINGS, JSON.stringify(settings));

    // ─── Audio ────────────────────────────────────────────────────────────────────
    function initAudio() {
        if (audioCtx) return;
        try {
            audioCtx = new (window.AudioContext || window.webkitAudioContext)();
            if (audioCtx.state === 'suspended') audioCtx.resume();
        } catch (e) { console.warn('[Perfexity] AudioContext failed:', e); }
    }

    function ensureAudio() {
        return new Promise(resolve => {
            if (!audioCtx) initAudio();
            if (!audioCtx || audioCtx.state === 'running') return resolve();
            audioCtx.resume().then(resolve).catch(resolve);
        });
    }

    function playSound() {
        if (!settings.notificationSound) return;
        ensureAudio().then(() => {
            if (!audioCtx) return;
            try {
                const osc  = audioCtx.createOscillator();
                const gain = audioCtx.createGain();
                osc.connect(gain);
                gain.connect(audioCtx.destination);
                osc.frequency.setValueAtTime(800, audioCtx.currentTime);
                osc.frequency.exponentialRampToValueAtTime(400, audioCtx.currentTime + 0.1);
                gain.gain.setValueAtTime(0, audioCtx.currentTime);
                gain.gain.linearRampToValueAtTime(0.3, audioCtx.currentTime + 0.01);
                gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.3);
                osc.start(audioCtx.currentTime);
                osc.stop(audioCtx.currentTime + 0.3);
            } catch (e) { console.warn('[Perfexity] Sound failed:', e); }
        });
    }

    // ─── Notifications ────────────────────────────────────────────────────────────
    function requestPermission() {
        if ('Notification' in window && Notification.permission === 'default')
            Notification.requestPermission();
    }

    function notify() {
        if (!settings.notificationEnabled || hasNotified) return;
        if (settings.notificationOnlyInactive && !document.hidden) return;
        hasNotified = true;
        playSound();
        if (settings.notificationDesktop && Notification.permission === 'granted') {
            new Notification('Perfexity.ai', {
                body: 'Antwort ist fertig generiert!',
                icon: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%2320d9d2"><path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>',
                tag: 'perfexity-done',
                requireInteraction: false
            });
        }
    }

    // ─── Answer Detection ─────────────────────────────────────────────────────────
    function watchAnswers() {
        let wasGenerating = false;
        setInterval(() => {
            const isGenerating = !!(
                document.querySelector('button[aria-label*="Antwort anhalten"]') ||
                document.querySelector('button[aria-label*="anhalten"]')
            );
            if (isGenerating && !wasGenerating) {
                wasGenerating = true;
                hasNotified = false;
                if (settings.autoScroll) scrollToBottom();
            }
            if (!isGenerating && wasGenerating) {
                wasGenerating = false;
                setTimeout(() => ensureAudio().then(notify), 1000);
            }
        }, 500);
    }

    // ─── Scroll ───────────────────────────────────────────────────────────────────
    function getScrollable() {
        const main = document.querySelector('main') || document.querySelector('[role="main"]');
        if (!main) return document.body;
        for (const el of main.querySelectorAll('*')) {
            const s = window.getComputedStyle(el);
            if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 5)
                return el;
        }
        return main;
    }

    function scrollToBottom() {
        if (!settings.autoScroll) return;
        setTimeout(() => getScrollable().scrollTo({ top: 999999, behavior: 'smooth' }), 800);
    }

    function scrollOnLoad() {
        if (!settings.autoScroll) return;
        const run = () => document.querySelectorAll('*').forEach(el => {
            const s = window.getComputedStyle(el);
            if ((s.overflowY === 'auto' || s.overflowY === 'scroll') && el.scrollHeight > el.clientHeight + 10)
                el.scrollTo({ top: 999999, behavior: 'smooth' });
        });
        [1200, 2500, 4500].forEach(t => setTimeout(run, t));
    }

    // ─── Auto-Redirect ────────────────────────────────────────────────────────────
    function redirectToLast() {
        if (!settings.autoRedirect || window.location.pathname !== '/' || hasRedirected) return;
        const t = setInterval(() => {
            const a = document.querySelector('a[data-testid^="thread-title"]');
            if (a) {
                clearInterval(t);
                const href = a.getAttribute('href');
                if (href && href !== '/') { hasRedirected = true; window.location.href = href; }
            }
        }, 200);
        setTimeout(() => clearInterval(t), 5000);
    }

    // ─── Sidebar DOM ──────────────────────────────────────────────────────────────
    function findElements() {
        sidebarOuter = document.querySelector('.group\\/sidebar');
        if (!sidebarOuter) return false;
        sidebarInner   = sidebarOuter.querySelector(':scope > div');
        sidebarWrapper = sidebarOuter.parentElement;
        mainContent    = sidebarWrapper?.nextElementSibling
        || document.querySelector('.flex.size-full.flex-1 > div:last-child')
        || document.querySelector('[class*="grow"][class*="flex-col"][class*="isolate"]')
        || null;
        return !!sidebarWrapper;
    }

    function applyWidth(w) {
        if (!sidebarWrapper) return;

        sidebarWrapper.style.setProperty('width',     `${w}px`, 'important');
        sidebarWrapper.style.setProperty('min-width', `${w}px`, 'important');
        sidebarWrapper.style.setProperty('flex-shrink', '0',    'important');

        for (const el of [sidebarOuter, sidebarInner].filter(Boolean)) {
            el.style.setProperty('width',     `${w}px`, 'important');
            el.style.setProperty('min-width', `${w}px`, 'important');
            el.style.setProperty('max-width', `${w}px`, 'important');
        }

        const navInner = sidebarOuter.querySelector('div[style*="margin-left: 8px"]');
        if (navInner) {
            navInner.style.setProperty('width', `${w - 16}px`, 'important');
            navInner.querySelector(':scope > div.absolute')
                ?.style.setProperty('width', `${w - 16}px`, 'important');
        }

        const hist = sidebarOuter.querySelector('.-ml-md[style*="width"]')
        || sidebarOuter.querySelector('[class*="-ml-md"][class*="shrink-0"][style*="width"]');
        if (hist) hist.style.setProperty('width', `${w - 8}px`, 'important');

        if (mainContent) mainContent.style.setProperty('min-width', '0', 'important');

        localStorage.setItem(KEY_WIDTH, w);
        if (resizeHandle) resizeHandle.style.left = `${w - 6}px`;
    }

    function isSidebarVisible() {
        if (!sidebarOuter) return false;
        const r = sidebarOuter.getBoundingClientRect();
        return r.left >= 0 && r.width > 50;
    }

    function updateHandle() {
        if (resizeHandle)
            resizeHandle.style.display = settings.resizeHandle && isSidebarVisible() ? 'block' : 'none';
    }

    // ─── Resize Handle ────────────────────────────────────────────────────────────
    function initResize() {
        if (!findElements()) { setTimeout(initResize, 1000); return; }

        document.getElementById('perfexity-resize-handle')?.remove();
        applyWidth(savedWidth);

        resizeHandle = document.createElement('div');
        resizeHandle.id = 'perfexity-resize-handle';
        resizeHandle.style.cssText = `
            position:fixed;top:0;bottom:0;left:${savedWidth - 6}px;width:12px;
            background:transparent;cursor:col-resize;z-index:9999;
            display:none;pointer-events:auto;
        `;
        resizeHandle.addEventListener('mouseenter', () => {
            resizeHandle.style.background = 'linear-gradient(90deg,transparent,#21808D,transparent)';
        });
        resizeHandle.addEventListener('mouseleave', () => {
            if (!isResizing) resizeHandle.style.background = 'transparent';
        });
        document.body.appendChild(resizeHandle);
        updateHandle();

        // Periodic DOM check (SPA)
        setInterval(() => {
            if (!sidebarWrapper || !document.contains(sidebarWrapper)) {
                if (findElements()) applyWidth(parseInt(localStorage.getItem(KEY_WIDTH)) || savedWidth);
            }
            updateHandle();
        }, 2000);

        let startX = 0, startW = 0;

        resizeHandle.addEventListener('mousedown', e => {
            isResizing = true;
            startX = e.clientX;
            startW = parseInt(localStorage.getItem(KEY_WIDTH)) || savedWidth;
            document.body.style.cssText += 'cursor:col-resize;user-select:none;';
            e.preventDefault();
        });

        document.addEventListener('mousemove', e => {
            if (!isResizing) return;
            applyWidth(Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, startW + e.clientX - startX)));
        });

        document.addEventListener('mouseup', () => {
            if (!isResizing) return;
            isResizing = false;
            document.body.style.cursor = '';
            document.body.style.userSelect = '';
            resizeHandle.style.background = 'transparent';
        });
    }

    // ─── Settings Menu ────────────────────────────────────────────────────────────
    const SETTINGS_DEFS = [
        { key: 'autoScroll',               label: 'Auto-Scroll',              desc: 'Scrollt automatisch nach unten beim Generieren',  indent: false },
        { key: 'autoRedirect',             label: 'Letzten Thread öffnen',    desc: 'Öffnet den letzten Thread beim Start',            indent: false },
        { key: 'resizeHandle',             label: 'Resize-Handle',            desc: 'Zeigt den Griff zum Verbreitern der Sidebar',     indent: false },
        { key: 'notificationEnabled',      label: 'Benachrichtigungen',       desc: 'Benachrichtigt wenn die Antwort fertig ist',      indent: false },
        { key: 'notificationDesktop',      label: '🔔 Desktop-Notification',   desc: 'Zeigt eine Browser-Benachrichtigung an',          indent: true  },
        { key: 'notificationSound',        label: '🔊 Sound',                  desc: 'Spielt einen dezenten Ton ab',                   indent: true  },
        { key: 'notificationOnlyInactive', label: 'Nur bei inaktivem Tab',    desc: 'Nur benachrichtigen wenn Tab nicht sichtbar',     indent: true  },
    ];

    function injectSettingsMenu() {
        const obs = new MutationObserver(() => {
            const dropdown = document.querySelector('.bg-raised.shadow-overlay[style*="min-width: 220px"]');
            if (!dropdown || dropdown.querySelector('#perfexity-section')) return;

            const firstSep = dropdown.querySelector('[role="separator"]');
            if (!firstSep) return;

            const currentWidth = parseInt(localStorage.getItem(KEY_WIDTH)) || savedWidth;

            // Separator
            const sep = document.createElement('div');
            sep.setAttribute('role', 'separator');
            sep.setAttribute('aria-orientation', 'horizontal');
            sep.className = 'border-subtlest my-xs mx-sm border-t';

            // Section
            const section = document.createElement('div');
            section.id = 'perfexity-section';
            section.style.padding = '4px 0';
            section.innerHTML = `
                <style>
                    .pf-hd{display:flex;align-items:center;gap:8px;padding:6px 12px 4px;font-size:11px;font-weight:600;opacity:.4;text-transform:uppercase;letter-spacing:.05em;pointer-events:none;}
                    .pf-row{display:flex;align-items:center;justify-content:space-between;padding:6px 12px;border-radius:8px;cursor:pointer;gap:12px;font-size:13px;color:var(--color-foreground);}
                    .pf-row:hover{background:rgba(128,128,128,.08);}
                    .pf-ind{margin-left:16px;padding-left:10px;border-left:2px solid rgba(128,128,128,.2);}
                    .pf-lbl{flex:1;min-width:0;}
                    .pf-lbl-title{font-size:13px;}
                    .pf-lbl-desc{font-size:11px;opacity:.5;margin-top:1px;}
                    .pf-tog{position:relative;display:inline-flex;align-items:center;border:none;border-radius:999px;width:34px;height:20px;padding:0;cursor:pointer;flex-shrink:0;transition:background .15s;}
                    .pf-tog-thumb{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#fff;transition:transform .15s;}
                    .pf-sl-wrap{padding:4px 12px 8px;}
                    .pf-sl-row{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;font-size:12px;color:var(--color-foreground);}
                    .pf-sl-val{color:#21808D;font-weight:500;}
                    .pf-sl{-webkit-appearance:none;appearance:none;width:100%;height:4px;border-radius:2px;background:rgba(128,128,128,.2);outline:none;cursor:pointer;}
                    .pf-sl::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:#21808D;cursor:pointer;}
                    .pf-sl::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#21808D;border:none;cursor:pointer;}
                    .pf-ft{text-align:center;font-size:10px;opacity:.3;padding:2px 0 4px;}
                </style>
                <div class="pf-hd">
                    <svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor"><path d="M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z"/></svg>
                    Perfexity.ai
                </div>
                ${SETTINGS_DEFS.map(({ key, label, desc, indent }) => `
                    <div class="pf-row${indent ? ' pf-ind' : ''}" data-key="${key}">
                        <div class="pf-lbl">
                            <div class="pf-lbl-title">${label}</div>
                            <div class="pf-lbl-desc">${desc}</div>
                        </div>
                        <button type="button" role="switch" class="pf-tog"
                            aria-checked="${settings[key]}"
                            style="background:${settings[key] ? '#21808D' : 'rgba(128,128,128,.3)'}">
                            <span class="pf-tog-thumb"
                                style="transform:translateX(${settings[key] ? '14px' : '0'})"></span>
                        </button>
                    </div>
                `).join('')}
                <div class="pf-sl-wrap">
                    <div class="pf-sl-row">
                        <span>Sidebar-Breite</span>
                        <span class="pf-sl-val" id="pf-w-val">${currentWidth}px</span>
                    </div>
                    <input type="range" class="pf-sl" id="pf-w-sl" min="${SIDEBAR_MIN}" max="${SIDEBAR_MAX}" value="${currentWidth}">
                </div>
                <div class="pf-ft">Perfexity.ai v2.0</div>
            `;

            // Show/hide sub-settings
            const setSubVisible = () => SETTINGS_DEFS.filter(s => s.indent).forEach(s => {
                const r = section.querySelector(`[data-key="${s.key}"]`);
                if (r) r.style.display = settings.notificationEnabled ? 'flex' : 'none';
            });
            setSubVisible();

            // Toggle events
            section.querySelectorAll('[data-key]').forEach(row => {
                row.addEventListener('click', e => {
                    e.stopPropagation();
                    const key = row.dataset.key;
                    settings[key] = !settings[key];
                    saveSettings();
                    const btn   = row.querySelector('.pf-tog');
                    const thumb = row.querySelector('.pf-tog-thumb');
                    btn.setAttribute('aria-checked', settings[key]);
                    btn.style.background          = settings[key] ? '#21808D' : 'rgba(128,128,128,.3)';
                    thumb.style.transform         = `translateX(${settings[key] ? '14px' : '0'})`;
                    if (key === 'resizeHandle')        updateHandle();
                    if (key === 'notificationEnabled') { setSubVisible(); if (settings[key]) requestPermission(); }
                    if (key === 'notificationDesktop' && settings[key]) requestPermission();
                    if (key === 'notificationSound'   && settings[key]) playSound();
                });
            });

            // Slider
            const sl  = section.querySelector('#pf-w-sl');
            const val = section.querySelector('#pf-w-val');
            sl.addEventListener('input', e => { val.textContent = `${e.target.value}px`; applyWidth(+e.target.value); });
            sl.addEventListener('click',     e => e.stopPropagation());
            sl.addEventListener('mousedown', e => e.stopPropagation());

            firstSep.after(section);
            firstSep.after(sep);
        });

        obs.observe(document.body, { childList: true, subtree: true });
    }

    // ─── Init ─────────────────────────────────────────────────────────────────────
    injectSettingsMenu();
    setTimeout(redirectToLast,  1500);
    setTimeout(initResize,      1200);
    setTimeout(watchAnswers,    3000);
    setTimeout(scrollOnLoad,    1200);
    setTimeout(requestPermission, 4000);

    // Init AudioContext on first interaction
    document.addEventListener('click', function once() {
        initAudio();
        document.removeEventListener('click', once);
    }, { passive: true });

    // SPA navigation reset
    let lastUrl = location.href;
    setInterval(() => {
        if (location.href === lastUrl) return;
        lastUrl       = location.href;
        hasNotified   = false;
        hasRedirected = false;
        sidebarOuter  = sidebarInner = sidebarWrapper = mainContent = null;
        setTimeout(() => {
            if (findElements()) applyWidth(parseInt(localStorage.getItem(KEY_WIDTH)) || savedWidth);
            updateHandle();
        }, 800);
    }, 1000);

})();