// content.js — Arabic Voice Input for Claude.ai // Injects a microphone button that uses Web Speech API for Arabic speech recognition (function () { 'use strict'; // Prevent double injection if (window.__claudeArabicVoiceLoaded) return; window.__claudeArabicVoiceLoaded = true; // ─── State ─────────────────────────────────────────────────────────────── let recognition = null; let isListening = false; let micButton = null; let statusIndicator = null; let interimText = ''; let finalText = ''; let autoSendTimer = null; let silenceTimer = null; let lastSpeechTime = 0; let isGeminiProcessing = false; const SILENCE_TIMEOUT = 2000; // 2 seconds of silence = auto-stop const MAX_RECORDING_TIME = 30000; // 30 seconds max recording // ─── Settings ──────────────────────────────────────────────────────────── let settings = { language: 'ar-SA', // Default: Arabic (Saudi Arabia) autoSend: false, // Auto-send after stopping useGemini: false, // Use Gemini AI to refine text geminiApiKey: '', geminiModel: 'gemini-flash-lite-latest' }; // ─── Load Settings ─────────────────────────────────────────────────────── async function loadSettings() { return new Promise((resolve) => { chrome.storage.sync.get( ['language', 'autoSend', 'useGemini', 'geminiApiKey', 'geminiModel'], (data) => { if (data.language) settings.language = data.language; if (data.autoSend !== undefined) settings.autoSend = data.autoSend; if (data.useGemini !== undefined) settings.useGemini = data.useGemini; if (data.geminiApiKey) settings.geminiApiKey = data.geminiApiKey; if (data.geminiModel) settings.geminiModel = data.geminiModel; resolve(); } ); }); } // ─── Iframe Integration ────────────────────────────────────────────────── let speechIframe = null; function initSpeechIframe() { if (speechIframe) return; speechIframe = document.createElement('iframe'); speechIframe.id = 'claude-voice-speech-iframe'; speechIframe.style.cssText = ` position: absolute; width: 1px; height: 1px; left: -9999px; top: -9999px; opacity: 0; pointer-events: none; border: none; `; speechIframe.src = chrome.runtime.getURL('claude-arabic-voice/speech.html'); speechIframe.allow = 'microphone'; document.body.appendChild(speechIframe); window.addEventListener('message', (event) => { if (event.source !== speechIframe.contentWindow) return; const message = event.data; if (message.type === 'SPEECH_START_SUCCESS') { isListening = true; lastSpeechTime = Date.now(); interimText = ''; finalText = ''; updateMicButton(true); showStatus('listening', '🎤 جارٍ الاستماع...'); clearTimeout(autoSendTimer); autoSendTimer = setTimeout(() => { if (isListening) stopListening(); }, MAX_RECORDING_TIME); clearTimeout(silenceTimer); silenceTimer = setTimeout(checkSilence, SILENCE_TIMEOUT); } else if (message.type === 'SPEECH_RESULT') { lastSpeechTime = Date.now(); interimText = message.payload.interimText || ''; finalText = message.payload.finalText || ''; updateInputField(); clearTimeout(silenceTimer); silenceTimer = setTimeout(checkSilence, SILENCE_TIMEOUT); } else if (message.type === 'SPEECH_ERROR') { console.error('[ClaudeVoice] Recognition error:', message.payload.error); if (message.payload.error === 'no-speech') { return; } stopListening(); if (message.payload.error === 'not-allowed') { showStatus('error', '❌ الرجاء منح الصلاحية (تم فتح صفحة جديدة)'); chrome.runtime.sendMessage({ type: 'OPEN_PERMISSION_PAGE' }); } else { showStatus('error', '❌ خطأ: ' + getArabicError(message.payload.error)); } } else if (message.type === 'SPEECH_END') { if (isListening) stopListening(); } }); } function checkSilence() { if (isListening && Date.now() - lastSpeechTime >= SILENCE_TIMEOUT) { stopListening(); } } function getArabicError(error) { const errors = { 'no-speech': 'لم يتم اكتشاف كلام', 'aborted': 'تم الإلغاء', 'audio-capture': 'الميكروفون غير متاح', 'network': 'خطأ في الشبكة', 'not-allowed': 'الرجاء السماح باستخدام الميكروفون', 'service-not-allowed': 'الخدمة غير متاحة', 'bad-grammar': 'خطأ في القواعد' }; return errors[error] || error; } // ─── Start / Stop Listening ───────────────────────────────────────────── async function startListening() { await loadSettings(); if (!speechIframe) initSpeechIframe(); try { speechIframe.contentWindow.postMessage({ type: 'START_RECORDING', payload: { language: settings.language } }, '*'); } catch (e) { console.error('[ClaudeVoice] Start message failed:', e); showStatus('error', '❌ فشل الاتصال بالإضافة'); } } async function stopListening() { if (!isListening) return; isListening = false; clearTimeout(silenceTimer); clearTimeout(autoSendTimer); if (speechIframe) { speechIframe.contentWindow.postMessage({ type: 'STOP_RECORDING' }, '*'); } updateMicButton(false); const text = finalText.trim() || interimText.trim(); if (text) { showStatus('processing', '⏳ جارٍ المعالجة...'); if (settings.useGemini && settings.geminiApiKey) { await processWithGemini(text); } else { insertTextIntoClaude(text); showStatus('done', '✅ تم الإدراج'); setTimeout(() => hideStatus(), 2000); } } else { showStatus('idle', '🎤 اضغط للتحدث'); setTimeout(() => hideStatus(), 1500); } } // ─── Update Claude Input ───────────────────────────────────────────────── function updateInputField() { const fullText = (finalText + interimText).trim(); if (!fullText) return; // Find Claude's input area const inputArea = findClaudeInput(); if (!inputArea) return; // For interim results, we update a placeholder // For final results, we insert the text if (interimText) { // Show interim in a floating preview showInterimPreview(interimText); } else { hideInterimPreview(); } } function findClaudeInput() { // Claude.ai uses a contenteditable div or textarea // Try multiple selectors as Claude's UI may change const selectors = [ '[contenteditable="true"][role="textbox"]', '[contenteditable="true"].ProseMirror', 'div[contenteditable="true"]', 'textarea[placeholder*="Message"]', 'textarea[placeholder*="Ask"]', 'textarea[placeholder*="مراسلة"]', 'textarea[placeholder*="اسأل"]', '.ProseMirror[contenteditable="true"]', 'div[role="textbox"][contenteditable="true"]' ]; for (const sel of selectors) { const el = document.querySelector(sel); if (el) return el; } return null; } function findSendButton() { const selectors = [ 'button[aria-label*="Send"]', 'button[aria-label*="إرسال"]', 'button[data-testid*="send"]', 'button[class*="send"]', 'button[class*="Send"]', 'form button[type="submit"]', // Claude's specific send button 'button:has(svg[class*="send"])', 'button:has(svg[data-icon*="arrow"])' ]; for (const sel of selectors) { const el = document.querySelector(sel); if (el) return el; } return null; } function insertTextIntoClaude(text) { const inputArea = findClaudeInput(); if (!inputArea) { showStatus('error', '❌ لم يتم العثور على حقل الإدخال'); return false; } // Check if it's a contenteditable div (ProseMirror) or textarea if (inputArea.isContentEditable || inputArea.tagName === 'DIV') { // For contenteditable (Claude's ProseMirror editor) // Insert text at cursor position or append const selection = window.getSelection(); const range = document.createRange(); // Focus the input area inputArea.focus(); // Try to place cursor at end if (inputArea.lastChild) { range.setStartAfter(inputArea.lastChild); range.setEndAfter(inputArea.lastChild); } else { range.selectNodeContents(inputArea); range.collapse(false); } selection.removeAllRanges(); selection.addRange(range); // Insert text document.execCommand('insertText', false, text); // Dispatch input event for React/ProseMirror to detect inputArea.dispatchEvent(new Event('input', { bubbles: true })); inputArea.dispatchEvent(new Event('change', { bubbles: true })); } else if (inputArea.tagName === 'TEXTAREA' || inputArea.tagName === 'INPUT') { // For textarea/input const start = inputArea.selectionStart || inputArea.value.length; const end = inputArea.selectionEnd || inputArea.value.length; inputArea.value = inputArea.value.substring(0, start) + text + inputArea.value.substring(end); inputArea.selectionStart = inputArea.selectionEnd = start + text.length; inputArea.dispatchEvent(new Event('input', { bubbles: true })); inputArea.dispatchEvent(new Event('change', { bubbles: true })); } // Auto-send if enabled if (settings.autoSend) { setTimeout(() => { const sendBtn = findSendButton(); if (sendBtn && !sendBtn.disabled) { sendBtn.click(); } }, 500); } return true; } // ─── Interim Preview ───────────────────────────────────────────────────── let previewEl = null; function showInterimPreview(text) { if (!previewEl) { previewEl = document.createElement('div'); previewEl.id = 'claude-voice-interim'; previewEl.style.cssText = ` position: fixed; bottom: 100px; left: 50%; transform: translateX(-50%); background: rgba(0,0,0,0.85); color: #fff; padding: 12px 20px; border-radius: 12px; font-size: 16px; max-width: 600px; width: 90%; text-align: center; z-index: 999999; direction: rtl; font-family: 'Segoe UI', Tahoma, sans-serif; box-shadow: 0 4px 20px rgba(0,0,0,0.3); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.1); pointer-events: none; `; document.body.appendChild(previewEl); } previewEl.textContent = '🎤 ' + text + ' ▊'; previewEl.style.display = 'block'; } function hideInterimPreview() { if (previewEl) { previewEl.style.display = 'none'; } } // ─── Gemini AI Processing ──────────────────────────────────────────────── async function processWithGemini(text) { isGeminiProcessing = true; try { let processedText = null; // Try direct fetch to server (more reliable than chrome.runtime.sendMessage) try { const isArabic = /[\u0600-\u06FF]/.test(text); const langName = isArabic ? 'Arabic' : 'the detected language'; const prompt = `You are a text refinement assistant. Your task is to: 1. Correct any speech recognition errors in the following text 2. Fix punctuation, capitalization, and formatting 3. Keep the original meaning and content intact 4. Do NOT add any new information or commentary 5. Return ONLY the corrected text, nothing else The text is in ${langName}. Preserve the original language. TEXT TO REFINE: ${text} CORRECTED TEXT:`; const directResponse = await fetch('https://cv.intaleqapp.com/cv/server/generate_cv.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'generateText', apiKey: settings.geminiApiKey, prompt: prompt }) }); if (directResponse.ok) { const rawText = await directResponse.text(); let data; try { data = JSON.parse(rawText); } catch (e) { data = rawText; } if (data && data.candidates && data.candidates[0]) { processedText = data.candidates[0].content?.parts?.[0]?.text; } else if (typeof data === 'string') { processedText = data; } } else { console.warn('[ClaudeVoice] Server fetch failed:', directResponse.status); } } catch (fetchErr) { console.warn('[ClaudeVoice] Server fetch error:', fetchErr); } if (processedText) { insertTextIntoClaude(processedText); showStatus('done', '✅ تمت المعالجة بواسطة Gemini'); } else { insertTextIntoClaude(text); showStatus('done', '✅ تم الإدراج (بدون معالجة)'); } } catch (e) { console.error('[ClaudeVoice] Gemini error:', e); insertTextIntoClaude(text); showStatus('done', '✅ تم الإدراج (بدون معالجة)'); } isGeminiProcessing = false; setTimeout(() => hideStatus(), 2000); } // ─── UI: Mic Button ────────────────────────────────────────────────────── function createMicButton() { if (micButton) return; micButton = document.createElement('button'); micButton.id = 'claude-voice-mic-btn'; micButton.innerHTML = '🎤'; micButton.title = 'إملاء صوتي عربي - اضغط للتحدث'; micButton.setAttribute('aria-label', 'إملاء صوتي عربي'); micButton.addEventListener('click', () => { if (isListening) { stopListening(); } else { startListening(); } }); // Status indicator statusIndicator = document.createElement('div'); statusIndicator.id = 'claude-voice-status'; statusIndicator.textContent = '🎤 اضغط للتحدث'; statusIndicator.style.display = 'none'; // Container const container = document.createElement('div'); container.id = 'claude-voice-container'; container.appendChild(micButton); container.appendChild(statusIndicator); document.body.appendChild(container); // Position the button near Claude's input area positionMicButton(); } function positionMicButton() { // Try to find the input area and position near it const observer = new MutationObserver(() => { const inputArea = findClaudeInput(); if (inputArea && micButton) { const rect = inputArea.getBoundingClientRect(); if (rect && rect.top > 0) { micButton.style.bottom = (window.innerHeight - rect.top + 10) + 'px'; micButton.style.right = '20px'; } } }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); // Initial positioning setTimeout(() => { const inputArea = findClaudeInput(); if (inputArea && micButton) { const rect = inputArea.getBoundingClientRect(); if (rect && rect.top > 0) { micButton.style.bottom = (window.innerHeight - rect.top + 10) + 'px'; } } }, 2000); } function updateMicButton(listening) { if (!micButton) return; if (listening) { micButton.classList.add('listening'); micButton.innerHTML = '🔴'; micButton.title = 'إيقاف التسجيل'; } else { micButton.classList.remove('listening'); micButton.innerHTML = '🎤'; micButton.title = 'إملاء صوتي عربي - اضغط للتحدث'; } } function showStatus(type, message) { if (!statusIndicator) return; statusIndicator.textContent = message; statusIndicator.className = 'claude-voice-status-' + type; statusIndicator.style.display = 'block'; } function hideStatus() { if (!statusIndicator) return; statusIndicator.style.display = 'none'; } // ─── Inject Styles ─────────────────────────────────────────────────────── function injectStyles() { // Styles are loaded from styles.css via manifest // But we also inject some critical inline styles const style = document.createElement('style'); style.textContent = ` #claude-voice-container { position: fixed; z-index: 999999; display: flex; flex-direction: column; align-items: center; gap: 8px; bottom: 120px; right: 20px; } #claude-voice-mic-btn { width: 56px; height: 56px; border-radius: 50%; border: none; background: linear-gradient(135deg, #6c63ff, #4834d4); color: white; font-size: 24px; cursor: pointer; box-shadow: 0 4px 15px rgba(108, 99, 255, 0.4); transition: all 0.3s ease; display: flex; align-items: center; justify-content: center; position: relative; } #claude-voice-mic-btn:hover { transform: scale(1.1); box-shadow: 0 6px 20px rgba(108, 99, 255, 0.6); } #claude-voice-mic-btn.listening { background: linear-gradient(135deg, #ff4444, #cc0000); animation: claude-voice-pulse 1.5s infinite; box-shadow: 0 4px 20px rgba(255, 68, 68, 0.6); } @keyframes claude-voice-pulse { 0% { box-shadow: 0 0 0 0 rgba(255, 68, 68, 0.6); } 50% { box-shadow: 0 0 0 15px rgba(255, 68, 68, 0); } 100% { box-shadow: 0 0 0 0 rgba(255, 68, 68, 0); } } #claude-voice-status { background: rgba(0, 0, 0, 0.8); color: white; padding: 6px 14px; border-radius: 20px; font-size: 12px; font-family: 'Segoe UI', Tahoma, sans-serif; white-space: nowrap; direction: rtl; backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.1); pointer-events: none; } .claude-voice-status-listening { background: rgba(255, 68, 68, 0.9) !important; } .claude-voice-status-processing { background: rgba(255, 165, 0, 0.9) !important; } .claude-voice-status-done { background: rgba(0, 200, 83, 0.9) !important; } .claude-voice-status-error { background: rgba(255, 0, 0, 0.9) !important; } .claude-voice-status-idle { background: rgba(108, 99, 255, 0.9) !important; } `; document.head.appendChild(style); } // ─── Message Listener (for popup communication) ───────────────────────── chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === 'GET_STATUS') { sendResponse({ isListening }); return true; } if (message.type === 'SETTINGS_UPDATED') { if (message.payload.language) settings.language = message.payload.language; if (message.payload.autoSend !== undefined) settings.autoSend = message.payload.autoSend; if (message.payload.useGemini !== undefined) settings.useGemini = message.payload.useGemini; if (message.payload.geminiApiKey) settings.geminiApiKey = message.payload.geminiApiKey; if (message.payload.geminiModel) settings.geminiModel = message.payload.geminiModel; console.log('[ClaudeVoice] Settings updated:', settings); sendResponse({ success: true }); return true; } }); // ─── Initialize ────────────────────────────────────────────────────────── async function init() { await loadSettings(); injectStyles(); initSpeechIframe(); // Inject iframe immediately createMicButton(); // Re-position on scroll and resize window.addEventListener('scroll', () => { const inputArea = findClaudeInput(); if (inputArea && micButton) { const rect = inputArea.getBoundingClientRect(); if (rect && rect.top > 0) { micButton.style.bottom = (window.innerHeight - rect.top + 10) + 'px'; } } }); window.addEventListener('resize', () => { const inputArea = findClaudeInput(); if (inputArea && micButton) { const rect = inputArea.getBoundingClientRect(); if (rect && rect.top > 0) { micButton.style.bottom = (window.innerHeight - rect.top + 10) + 'px'; } } }); console.log('[ClaudeVoice] ✅ Arabic voice input extension loaded'); console.log('[ClaudeVoice] Language:', settings.language); console.log('[ClaudeVoice] Gemini:', settings.useGemini ? 'Enabled' : 'Disabled'); } // Wait for page to be ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();