Perfexity.ai

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

})();