您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Voice interface for Claude with TTS functionality, speech recognition, auto-reading and drag positioning
// ==UserScript== // @name Improved Claude Voice Interface // @namespace http://tampermonkey.net/ // @version 0.7 // @description Voice interface for Claude with TTS functionality, speech recognition, auto-reading and drag positioning // @author You // @match https://claude.ai/chat/* // @match https://claude.ai/* // @match https://claude.ai // @license MIT // @grant none // ==/UserScript== (function() { 'use strict'; // Configuration const config = { buttonHeight: '50px', activeBackgroundColor: '#4a86e8', inactiveBackgroundColor: '#6c757d', activeTextColor: 'white', inactiveTextColor: '#e0e0e0', readRateMultiplier: 1.0, autoReadNewMessages: true, checkInterval: 1000, // Check for new messages every 1 second readingDelay: 100, // Delay to check if 50 characters of message have been loaded startAfterCharacters: 50 //Number of characters to require before starting reading }; // State variables let isSpeaking = false; let isRecording = false; let currentMessageElement = null; let lastMessageElement = null; let checkIntervalId = null; let processingMessage = false; let recognition = null; // Drag state variables let isDragging = false; let startY = 0; let startTop = 0; // Wait for the page to fully load window.addEventListener('load', function() { setTimeout(initializeVoiceInterface, 1500); }); function initializeVoiceInterface() { console.log('Claude Voice Interface: Initializing...'); // CSS for highlighting (can be customized) const highlightStyle = document.createElement('style'); highlightStyle.textContent = ` .active-highlight { background-color: #e6e6d8; border-radius: 5px; } `; document.head.appendChild(highlightStyle); // Create the voice interface bar const voiceBar = document.createElement('div'); voiceBar.id = 'claude-voice-bar'; voiceBar.style.width = '100%'; voiceBar.style.display = 'flex'; voiceBar.style.justifyContent = 'space-between'; voiceBar.style.alignItems = 'center'; voiceBar.style.boxSizing = 'border-box'; voiceBar.style.padding = '10px'; voiceBar.style.backgroundColor = '#f8f9fa'; voiceBar.style.borderTop = '1px solid #dee2e6'; voiceBar.style.position = 'fixed'; voiceBar.style.top = '0'; voiceBar.style.left = '0'; voiceBar.style.zIndex = '10000'; // Increased z-index to make sure it's above other elements // Create the left button (Read/Pause) const speakButton = document.createElement('button'); speakButton.id = 'claude-speak-button'; speakButton.innerHTML = `${createPlayIcon()} <span style="margin-left: 8px;">READ</span>`; speakButton.style.width = '48%'; speakButton.style.height = config.buttonHeight; speakButton.style.borderRadius = '8px'; speakButton.style.border = 'none'; speakButton.style.backgroundColor = config.inactiveBackgroundColor; speakButton.style.color = config.inactiveTextColor; speakButton.style.cursor = 'pointer'; speakButton.style.display = 'flex'; speakButton.style.justifyContent = 'center'; speakButton.style.alignItems = 'center'; speakButton.style.transition = 'all 0.3s'; speakButton.style.fontWeight = 'bold'; // Create the right button (Speech-to-Text) const recordButton = document.createElement('button'); recordButton.id = 'claude-record-button'; recordButton.innerHTML = `${createEmptyCircleIcon()} <span style="margin-left: 8px;">LISTEN</span>`; recordButton.style.width = '48%'; recordButton.style.height = config.buttonHeight; recordButton.style.borderRadius = '8px'; recordButton.style.border = 'none'; recordButton.style.backgroundColor = config.inactiveBackgroundColor; recordButton.style.color = config.inactiveTextColor; recordButton.style.cursor = 'pointer'; recordButton.style.display = 'flex'; recordButton.style.justifyContent = 'center'; recordButton.style.alignItems = 'center'; recordButton.style.transition = 'all 0.3s'; recordButton.style.fontWeight = 'bold'; // Create drag handle const dragHandle = document.createElement('div'); dragHandle.id = 'claude-voice-drag-handle'; dragHandle.innerHTML = createDragIcon(); dragHandle.style.width = '50px'; dragHandle.style.left = '50%'; // Center it dragHandle.style.cursor = 'ns-resize'; dragHandle.style.display = 'flex'; dragHandle.style.justifyContent = 'center'; dragHandle.style.alignItems = 'center'; // Add elements to voice bar voiceBar.appendChild(speakButton); voiceBar.appendChild(recordButton); voiceBar.appendChild(dragHandle); // Simply append to the body document.body.appendChild(voiceBar); console.log('Claude Voice Interface: Interface added to page'); // Add event listeners speakButton.addEventListener('click', toggleSpeaking); recordButton.addEventListener('click', toggleRecording); // Add drag functionality setupDragHandlers(voiceBar, dragHandle); // Initialize speech recognition if available initializeSpeechRecognition(); // Start monitoring for new messages if auto-read is enabled if (config.autoReadNewMessages) { startMessageMonitoring(); } } function initializeSpeechRecognition() { // Check if browser supports speech recognition if ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window) { const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; recognition = new SpeechRecognition(); recognition.continuous = true; recognition.onend = function (e) { if (isRecording) { recognition.start(); } } recognition.onresult = function (e) { let results = e.results let str = results[results.length - 1][0].transcript; if (str[0] === ' ') { str = ' ' + str[1].toUpperCase() + str.slice(2) + '. '; } else { str = str[0].toUpperCase() + str.slice(1) + '. '; } document.querySelector('.ProseMirror').innerText += str; } recognition.onerror = function(event) { console.error('Recognition error:', event.error); }; // Enable the record button const recordButton = document.getElementById('claude-record-button'); if (recordButton) { recordButton.disabled = false; recordButton.style.opacity = '1'; } } else { console.warn('Speech Recognition not supported in this browser'); } } function setupDragHandlers(voiceBar, dragHandle) { // Mouse events for desktop dragHandle.addEventListener('mousedown', function(e) { e.preventDefault(); startDrag(e.clientY); }); document.addEventListener('mousemove', function(e) { if (isDragging) { doDrag(e.clientY); } }); document.addEventListener('mouseup', function() { endDrag(); }); // Touch events for mobile dragHandle.addEventListener('touchstart', function(e) { e.preventDefault(); startDrag(e.touches[0].clientY); }); document.addEventListener('touchmove', function(e) { if (isDragging) { doDrag(e.touches[0].clientY); } }); document.addEventListener('touchend', function() { endDrag(); }); function startDrag(clientY) { isDragging = true; startY = clientY; startTop = parseInt(voiceBar.style.top || '0'); } function doDrag(clientY) { const deltaY = startY - clientY; const newTop = Math.max(0, startTop - deltaY); voiceBar.style.top = `${newTop}px`; } function endDrag() { if (isDragging) { isDragging = false; } } } function startMessageMonitoring() { console.log('Claude Voice Interface: Starting message monitoring'); // Store the current last message ID const currentLastMessage = findLatestClaudeMessage(); if (currentLastMessage) { lastMessageElement = currentLastMessage console.log('Claude Voice Interface: Initial message:', lastMessageElement); } // Start checking for new messages checkIntervalId = setInterval(checkForNewMessages, config.checkInterval); } function checkForNewMessages() { if (processingMessage) return; // Don't check for new messages if we're already processing one const latestMessage = findLatestClaudeMessage(); if (!latestMessage || latestMessage === lastMessageElement) return; // If we aren't reading something else if (!isSpeaking) { console.log('Claude Voice Interface: New message detected'); lastMessageElement = latestMessage; // Check if the message has finished generating let isLoading = latestMessage.dataset.isStreaming === 'true'; //if it's still loading, wait for startAfterReading (50) characters or it to finish loading if (!isLoading) { readMessage(latestMessage, isLoading); } else { let lengthCheckInterval = setInterval(() => { try { isLoading = latestMessage.dataset.isStreaming === 'true'; if (latestMessage.textContent.trim().length >= config.startAfterCharacters || !isLoading) { readMessage(latestMessage, isLoading); clearInterval(lengthCheckInterval); } } catch (e) { clearInterval(lengthCheckInterval); console.error(`Error: ${e.message}`); } }, config.readingDelay); } } } function toggleSpeaking() { const speakButton = document.getElementById('claude-speak-button'); // Always cancel existing speech first to avoid buggy behavior window.speechSynthesis.cancel(); if (!isSpeaking) { console.log('Claude Voice Interface: Starting speech'); // Start reading the last message const message = findLatestClaudeMessage(); if (message) { readMessage(message); } return; } else { console.log('Claude Voice Interface: Stopping speech'); // Instead of pausing, we'll cancel and remember we want to resume resetSpeechState(); speakButton.innerHTML = `${createPlayIcon()} <span style="margin-left: 8px;">RESUME</span>`; return; } } function readMessage(message, isLoading = false, spokenSegments = []) { if (!message) { console.log('Claude Voice Interface: No message to read'); return; } currentMessageElement = message; // Prepare the text to be read if (!message.textContent.trim()) { console.log('Claude Voice Interface: Message has no text content'); return; } // Recursively process the DOM to wrap words for highlighting function traverse(node) { let segments = []; // Skip script, style, and other non-content elements if (node.nodeType === Node.ELEMENT_NODE) { const tagName = node.tagName ? node.tagName.toLowerCase() : null; //Skip these tag types if (['script', 'style', 'noscript', 'svg', 'canvas', 'button', 'text-reader'].includes(tagName)) { return segments; } //Special processing for preformatted code blocks if (tagName === 'pre') { let codeBlockType = node?.children[0]?.children[0]?.textContent; if (codeBlockType) { segments.push({ text: `, ${codeBlockType} section detected, please view in chat, `, el: node?.children[0]?.children[0] }); } //Skip further processing, ignore unknown pre block types return segments; } // Process this element's children recursively for (let child of node.childNodes) {segments = segments.concat(traverse(child));} return segments; } // Process text nodes that have content if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent.trim(); if (text === '') return segments; // Split into words const words = node.textContent.split(' '); // Split into individual words for (const [i, word] of words.entries()) { const wrapper = document.createElement('text-reader'); if (i < words.length - 1) { //Put a space back in segments.push({ text: word + ' ', el: node.parentElement }); } else { //Add a comma and space to get a brief pause in spoken version and keep separation from the next line of text segments.push({ text: word + ', ', el: node.parentElement }); } } } return segments; } // Start the traversal let segments = traverse(message); //Remove the last segment if we are still streaming in case it is a partial word if (isLoading) { segments.pop(); } //Remove any previously spoken segments if we started speaking before it finished streaming segments = segments.slice(spokenSegments.length); //Collect all the readable fragments and create a map to track the current word location function updateCharacterMap(segments) { let text = '', characterMap = {}; for (let segment of segments) { //Create a map of every character location from the previous max to our new max after appending the text together let oldLength = text.length; text += segment.text; let newLength = text.length; for (let i = oldLength; i < newLength; i++) { characterMap[i] = segment.el; } } return {text, characterMap}; } let {text, characterMap} = updateCharacterMap(segments); // Cancel any ongoing speech first window.speechSynthesis.cancel(); // Create new utterance const utterance = new SpeechSynthesisUtterance(text); utterance.rate = config.readRateMultiplier; /*let voice = speechSynthesis.getVoices().find(voice => {return voice.voiceURI === localStorage['claude-text-reader-voice'];}) if (voice) { utterance.voice = voice; }*/ // Set up events let updatedSegments = segments, updatedCharacterMap = characterMap; utterance.onboundary = function (e) { try { //console.log(e.name, e.charIndex, characterMap[e.charIndex].classList, characterMap[e.charIndex]); //Check to make sure our segments still exist, rebuild the elements/segments list if it's been updated by streaming let el = updatedCharacterMap[e.charIndex]; console.log(document.body.contains(el), el); if (!document.body.contains(el)) { console.log('Claud Voice Interface: DOM Refreshed, Updating Element References'); updatedSegments = traverse(message); let results = updateCharacterMap(updatedSegments); updatedCharacterMap = results.characterMap el = updatedCharacterMap[e.charIndex]; } if (!el.classList.contains('active-highlight')) { //Remove the class from all the other elements for (let oldEl of Array.from(message.querySelectorAll('.active-highlight'))) { oldEl.classList.remove('active-highlight'); } //Add highlight class to the new element el.classList.add('active-highlight'); } } catch (e) {console.error('Claude Voice Interface - error in highlighting');} } utterance.onstart = function() { console.log('Claude Voice Interface: Speech started'); isSpeaking = true; const speakButton = document.getElementById('claude-speak-button'); speakButton.innerHTML = `${createPauseIcon()} <span style="margin-left: 8px;">PAUSE</span>`; speakButton.style.backgroundColor = config.activeBackgroundColor; speakButton.style.color = config.activeTextColor; speakButton.style.fontWeight = 'bold'; }; utterance.onend = function() { //Check to see if it was still loading when we started to play the message if (isLoading) { //Get the updated message and play it, checking to see if it's still loading now and pass in what we've read so far console.log('Claude Voice Intercace speech continuing streaming message'); isLoading = message.dataset.isStreaming === 'true'; readMessage(message, isLoading, segments); } else { console.log('Claude Voice Interface: Speech ended'); resetSpeechState(); } }; utterance.onerror = function(event) { console.error('Claude Voice Interface: Speech synthesis error:', event.error); resetSpeechState(); }; // Start speaking - slight delay to try and get the boundary event to fire consistently setTimeout(() => { window.speechSynthesis.speak(utterance); }, 50); } function findLatestClaudeMessage() { try { return document.querySelectorAll('div[data-test-render-count]')[Array.from(document.querySelectorAll('div[data-test-render-count]')).length - 1].querySelector('div[data-is-streaming]') } catch (e) { return null; } } function resetSpeechState() { // Cancel any ongoing speech window.speechSynthesis.cancel(); // Reset speech state isSpeaking = false; // Reset button appearance const speakButton = document.getElementById('claude-speak-button'); speakButton.innerHTML = `${createPlayIcon()} <span style="margin-left: 8px;">READ</span>`; speakButton.style.backgroundColor = config.inactiveBackgroundColor; speakButton.style.color = config.inactiveTextColor; speakButton.style.fontWeight = 'normal'; // Remove any highlighting document.querySelectorAll('.active-highlight').forEach(el => el.classList.remove('active-highlight')); } function toggleRecording() { if (!recognition) { console.warn('Speech Recognition not initialized'); alert('Speech Recognition is not supported in this browser'); return; } const recordButton = document.getElementById('claude-record-button'); if (!isRecording) { // Start recording isRecording = true; try { recognition.start(); recordButton.innerHTML = `${createFilledCircleIcon()} <span style="margin-left: 8px;">LISTENING</span>`; recordButton.style.backgroundColor = config.activeBackgroundColor; recordButton.style.color = config.activeTextColor; recordButton.style.fontWeight = 'bold'; console.log('Claude Voice Interface: Started listening'); } catch (e) { console.error('Error starting recognition:', e); isRecording = false; alert('Error starting speech recognition: ' + e.message); } } else { // Stop recording isRecording = false; try { recognition.stop(); } catch (e) { console.error('Error stopping recognition:', e); } recordButton.innerHTML = `${createEmptyCircleIcon()} <span style="margin-left: 8px;">LISTEN</span>`; recordButton.style.backgroundColor = config.inactiveBackgroundColor; recordButton.style.color = config.inactiveTextColor; recordButton.style.fontWeight = 'normal'; //And try and click the submit button document.querySelector('button svg path[d="M208.49,120.49a12,12,0,0,1-17,0L140,69V216a12,12,0,0,1-24,0V69L64.49,120.49a12,12,0,0,1-17-17l72-72a12,12,0,0,1,17,0l72,72A12,12,0,0,1,208.49,120.49Z"]').parentElement.parentElement.click() } } function insertTextToInput(text) { if (!text.trim()) return; // First, try the specific selector for Claude's input field let inputField = document.querySelector('div.ProseMirror[contenteditable="true"]'); if (!inputField) { console.log('Could not find Claude input with ProseMirror class, trying alternative selectors'); // If not found, try backup selectors const potentialSelectors = [ 'textarea', '[role="textbox"]', '[contenteditable="true"]', 'input[type="text"]', '.chat-input', '[class*="promptTextarea"]', '[class*="TextInput"]' ]; for (const selector of potentialSelectors) { const elements = document.querySelectorAll(selector); for (const element of elements) { // Skip hidden elements or elements not visible in the viewport if (element.offsetParent === null) continue; // Check if this is likely to be the chat input const placeholder = element.getAttribute('placeholder') || ''; const aria = element.getAttribute('aria-label') || ''; const classes = element.className || ''; if (placeholder.includes('message') || placeholder.includes('Message') || aria.includes('message') || aria.includes('Message') || classes.includes('message') || classes.includes('Message') || classes.includes('input') || classes.includes('Input')) { inputField = element; break; } } if (inputField) break; } } else { console.log('Found Claude input with ProseMirror class'); } if (inputField) { console.log('Claude Voice Interface: Found input field, inserting text'); // For textarea or input elements if (inputField.tagName === 'TEXTAREA' || inputField.tagName === 'INPUT') { inputField.value = text; // Trigger multiple events to ensure the UI updates inputField.dispatchEvent(new Event('input', { bubbles: true })); inputField.dispatchEvent(new Event('change', { bubbles: true })); inputField.focus(); } // For contenteditable divs (including ProseMirror) else if (inputField.getAttribute('contenteditable') === 'true') { // Focus the element first inputField.focus(); // Clear existing content inputField.innerHTML = ''; // Insert text properly for contenteditable // Using document.execCommand for better compatibility with contenteditable document.execCommand('insertText', false, text); // Backup - use innerHTML if document.execCommand didn't work if (!inputField.textContent) { inputField.innerHTML = text; } // Fire several events to make sure Claude's UI notices the change inputField.dispatchEvent(new Event('input', { bubbles: true })); inputField.dispatchEvent(new Event('change', { bubbles: true })); // Additional event for React-based UIs const customEvent = new CustomEvent('input-change', { bubbles: true, detail: { text } }); inputField.dispatchEvent(customEvent); } console.log('Text inserted into input field'); } else { console.warn('Claude Voice Interface: Could not find input field'); alert('Could not find input field to insert text. Check console for details.'); console.error('Failed to find any matching input field. Text was:', text); } } function resetRecordingState() { // Stop recognition if active if (recognition && isRecording) { try { recognition.stop(); } catch (e) { console.error('Error stopping recognition during reset:', e); } } // Reset recording state isRecording = false; recognizedText = ''; collectedSpeechSegments = []; // Clear any pending timeouts if (listeningTimeoutId) { clearTimeout(listeningTimeoutId); listeningTimeoutId = null; } // Reset button appearance const recordButton = document.getElementById('claude-record-button'); if (recordButton) { recordButton.innerHTML = `${createEmptyCircleIcon()} <span style="margin-left: 8px;">LISTEN</span>`; recordButton.style.backgroundColor = config.inactiveBackgroundColor; recordButton.style.color = config.inactiveTextColor; recordButton.style.fontWeight = 'normal'; } } // Icon creation functions function createPlayIcon() { return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"> <path d="M11.596 8.697l-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z"/> </svg>`; } function createPauseIcon() { return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"> <path d="M5.5 3.5A1.5 1.5 0 0 1 7 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5zm5 0A1.5 1.5 0 0 1 12 5v6a1.5 1.5 0 0 1-3 0V5a1.5 1.5 0 0 1 1.5-1.5z"/> </svg>`; } function createEmptyCircleIcon() { return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"> <circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" fill="none"/> </svg>`; } function createFilledCircleIcon() { return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"> <circle cx="8" cy="8" r="7" fill="currentColor"/> </svg>`; } function createDragIcon() { return `<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <g id="SVGRepo_bgCarrier" stroke-width="0"></g> <g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g> <g id="SVGRepo_iconCarrier"> <path d="M5 9H13H19M5 15H19" stroke="#878787" stroke-width="2" stroke-linecap="round"></path> </g> </svg>` } })();