Perplexity Token Counter

Adds character/token count indicator to Perplexity conversations

// ==UserScript==
// @name         Perplexity Token Counter
// @namespace    https://lugia19.com
// @version      0.3
// @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 = 15000; // Check every 30 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 middle section with the chat title
		const middleSections = document.querySelectorAll('.hidden.min-w-0.grow.items-center.justify-center.text-center.md\\:flex');
		if (!middleSections.length) return false;

		const middleSection = middleSections[0];

		// Check if there's a chat title at the end
		const titleContainer = middleSection.querySelector('span.min-w-0');
		if (!titleContainer) return false;

		// Create our indicator
		lengthIndicator = document.createElement('div');
		lengthIndicator.className = 'perplexity-length-indicator';
		lengthIndicator.style.display = 'flex';
		lengthIndicator.style.alignItems = 'center';
		lengthIndicator.style.marginLeft = '4px';

		// Add a slash divider first
		const divider = document.createElement('div');
		divider.className = 'ultraLight font-sans text-sm text-textOff/50 dark:text-textOffDark/50';
		divider.innerHTML = `
            <svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="slash-forward" class="svg-inline--fa fa-slash-forward fa-xs" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512">
                <path fill="currentColor" d="M308.1 3.3c11.4 6.7 15.3 21.4 8.6 32.8l-272 464c-6.7 11.4-21.4 15.3-32.8 8.6S-3.4 487.3 3.3 475.9l272-464C282 .4 296.7-3.4 308.1 3.3z"></path>
            </svg>
        `;

		// Create token counter with styled text
		const counter = document.createElement('div');
		counter.className = 'py-sm pl-sm truncate default font-sans text-xs font-medium';
		counter.style.color = '#3b82f6'; // Blue color as requested
		counter.innerHTML = `<span>0 tokens</span>`;

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

		// Add the indicator after the title container
		middleSection.appendChild(lengthIndicator);

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

		return true;
	}

	async function updateLengthIndicator() {
		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('.py-sm.pl-sm span');
				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 (isConversationPage()) {
			if (!isLengthIndicatorPresent()) {
				// Start the retry process for injection
				startInjectionRetry();
			} else {
				updateLengthIndicator();
			}
		} else {
			// Not a conversation page, reset injection tracking
			clearTimeout(injectionRetryTimer);
			injectionAttempts = 0;
			injectionStartTime = 0;
		}
	}

	// 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 (30 seconds)
	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;
			clearTimeout(injectionRetryTimer); // Clear any ongoing retries
			injectionAttempts = 0;
			injectionStartTime = 0;
			setTimeout(checkAndUpdate, 1000); // Check after URL change
		}
	}).observe(document, { subtree: true, childList: true });
})();