Greasy Fork

Greasy Fork is available in English.

Perplexity Length Indicator

Adds character/token count indicator to Perplexity conversations

// ==UserScript==
// @name         Perplexity Length Indicator
// @namespace    https://lugia19.com
// @version      0.6
// @description  Adds character/token count indicator to Perplexity conversations
// @author       lugia19
// @license      MIT
// @match        https://www.perplexity.ai/*
// @grant        none
// ==/UserScript==


(function () {
	'use strict';

	const CHECK_INTERVAL = 30000; // Check every 15 seconds
	const RETRY_INTERVAL = 1000; // Retry every 1 second
	const MAX_RETRY_TIME = 30000; // Retry for up to 30 seconds

	let lengthIndicator = null;
	let injectionAttempts = 0;
	let injectionStartTime = 0;
	let injectionRetryTimer = null;
	const originalFetch = window.fetch;

	function isConversationPage() {
		return window.location.href.match(/https:\/\/www\.perplexity\.ai\/search\/.*-.*$/);
	}

	function getConversationId() {
		const match = window.location.href.match(/https:\/\/www\.perplexity\.ai\/search\/(.*)/);
		return match ? match[1] : null;
	}

	function isLengthIndicatorPresent() {
		return document.querySelector('.perplexity-length-indicator') !== null;
	}

	function injectLengthIndicator() {
		// Find the bottom right container with the help button
		const bottomRightContainer = document.querySelector('.bottom-md.right-md.m-sm.fixed.hidden.md\\:block .flex.items-center.gap-2');
		if (!bottomRightContainer) return false;

		// Create our indicator
		lengthIndicator = document.createElement('span');
		lengthIndicator.className = 'perplexity-length-indicator';

		// Start hidden if not on a conversation page
		if (!isConversationPage()) {
			lengthIndicator.style.display = 'none';
		}

		// Create token counter with styled text
		const counter = document.createElement('div');
		counter.className = 'bg-offsetPlus dark:bg-offsetPlusDark text-textMain dark:text-textMainDark md:hover:text-textOff md:dark:hover:text-textOffDark !bg-background dark:border-borderMain/25 dark:!bg-offset border shadow-subtle border-borderMain/50 font-sans focus:outline-none outline-none outline-transparent transition duration-300 ease-out font-sans select-none items-center relative group/button justify-center text-center items-center rounded-full cursor-pointer origin-center whitespace-nowrap inline-flex text-sm h-8 px-3';
		counter.style.display = 'flex';
		counter.style.alignItems = 'center';
		counter.style.justifyContent = 'center';

		// Create the text span with blue color
		const textSpan = document.createElement('span');
		textSpan.className = 'font-medium';
		textSpan.style.color = '#3b82f6'; // Keep the blue color
		textSpan.textContent = '0 tokens';

		counter.appendChild(textSpan);

		// Add components to the indicator
		lengthIndicator.appendChild(counter);

		// Add the indicator at the beginning of the container (before the help button)
		bottomRightContainer.insertBefore(lengthIndicator, bottomRightContainer.firstChild);

		// Reset injection retry counters
		clearTimeout(injectionRetryTimer);
		injectionAttempts = 0;
		injectionStartTime = 0;

		return true;
	}

	async function updateLengthIndicator() {
		// If not on a conversation page, hide the indicator
		if (!isConversationPage()) {
			if (lengthIndicator) {
				lengthIndicator.style.display = 'none';
			}
			return;
		}

		// On conversation page, show the indicator
		if (lengthIndicator) {
			lengthIndicator.style.display = '';
		}

		const conversationId = getConversationId();
		if (!conversationId) return;

		try {
			const response = await fetch(`https://www.perplexity.ai/rest/thread/${conversationId}?with_schematized_response=true&limit=9999`);
			const data = await response.json();

			let charCount = 0;

			if (data.entries && Array.isArray(data.entries)) {
				data.entries.forEach(entry => {
					// Add query string length
					if (entry.query_str) {
						charCount += entry.query_str.length;
					}

					// Add response text length
					if (entry.blocks && Array.isArray(entry.blocks)) {
						entry.blocks.forEach(block => {
							if (block.intended_usage === "ask_text" &&
								block.markdown_block &&
								block.markdown_block.answer) {
								charCount += block.markdown_block.answer.length;
							}
						});
					}
				});
			}

			// Estimate tokens (char count / 4)
			const tokenCount = Math.round(charCount / 4);

			// Update the indicator
			if (lengthIndicator) {
				const counterSpan = lengthIndicator.querySelector('span.font-medium');
				if (counterSpan) {
					counterSpan.textContent = `${tokenCount} tokens`;
				}
			}

		} catch (error) {
			console.error('Error fetching conversation data:', error);
		}
	}

	function startInjectionRetry() {
		// Start tracking injection attempts
		if (injectionStartTime === 0) {
			injectionStartTime = Date.now();
		}

		// Try to inject the indicator
		const injected = injectLengthIndicator();

		// If successful, update the indicator and stop retrying
		if (injected) {
			updateLengthIndicator();
			return;
		}

		// Check if we've reached the maximum retry time
		injectionAttempts++;
		const elapsedTime = Date.now() - injectionStartTime;

		if (elapsedTime < MAX_RETRY_TIME) {
			// Continue retrying
			injectionRetryTimer = setTimeout(startInjectionRetry, RETRY_INTERVAL);
		} else {
			// Reset counters after max retry time
			injectionAttempts = 0;
			injectionStartTime = 0;
			console.log('Failed to inject length indicator after maximum retry time');
		}
	}

	function checkAndUpdate() {
		if (!isLengthIndicatorPresent()) {
			// Start the retry process for injection
			startInjectionRetry();
		} else {
			updateLengthIndicator();
		}
	}

	// Setup fetch interception using the provided pattern
	window.fetch = async (...args) => {
		const [input, config] = args;

		let url;
		if (input instanceof URL) {
			url = input.href;
		} else if (typeof input === 'string') {
			url = input;
		} else if (input instanceof Request) {
			url = input.url;
		}

		const method = config?.method || (input instanceof Request ? input.method : 'GET');
		// Proceed with the original fetch
		const response = await originalFetch(...args);

		// Check if this is a request to the perplexity_ask endpoint
		if (url && url.includes('perplexity.ai/rest/sse/perplexity_ask') && method === 'POST') {
			// Wait a bit for the response to be processed and update
			console.log("UPDATING, GOT RESPONSE!")
			setTimeout(checkAndUpdate, 10000);
		}

		return response;
	};

	// Initial check
	setTimeout(checkAndUpdate, 1000);

	// Set up interval for regular checks
	setInterval(checkAndUpdate, CHECK_INTERVAL);

	// Listen for URL changes (for single-page apps)
	let lastUrl = window.location.href;
	new MutationObserver(() => {
		if (lastUrl !== window.location.href) {
			lastUrl = window.location.href;
			// Update the visibility based on new URL
			if (lengthIndicator) {
				lengthIndicator.style.display = isConversationPage() ? '' : 'none';
			}
			setTimeout(checkAndUpdate, 1000); // Check after URL change
		}
	}).observe(document, { subtree: true, childList: true });
})();