Files
cv/claude-arabic-voice/content.js

636 lines
24 KiB
JavaScript

// 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();
}
);
});
}
// ─── Speech Recognition Setup ────────────────────────────────────────────
function initSpeechRecognition() {
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SpeechRecognition) {
console.warn('[ClaudeVoice] Speech recognition not supported in this browser');
return null;
}
const recog = new SpeechRecognition();
recog.continuous = true;
recog.interimResults = true;
recog.lang = settings.language;
recog.maxAlternatives = 1;
recog.onresult = (event) => {
lastSpeechTime = Date.now();
interimText = '';
finalText = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const result = event.results[i];
if (result.isFinal) {
finalText += result[0].transcript + ' ';
} else {
interimText += result[0].transcript;
}
}
updateInputField();
// Reset silence timer on new speech
clearTimeout(silenceTimer);
silenceTimer = setTimeout(() => {
if (isListening && Date.now() - lastSpeechTime >= SILENCE_TIMEOUT) {
stopListening();
}
}, SILENCE_TIMEOUT);
};
recog.onerror = (event) => {
console.error('[ClaudeVoice] Recognition error:', event.error);
if (event.error === 'no-speech') {
// No speech detected, restart if still listening
if (isListening) {
try { recog.stop(); } catch (e) { }
setTimeout(() => {
if (isListening) {
try { recog.start(); } catch (e) { }
}
}, 300);
}
return;
}
stopListening();
showStatus('error', '❌ خطأ: ' + getArabicError(event.error));
};
recog.onend = () => {
// If we're still supposed to be listening, restart
if (isListening) {
try {
recog.start();
} catch (e) {
console.warn('[ClaudeVoice] Restart failed:', e);
}
}
};
return recog;
}
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 (!recognition) {
recognition = initSpeechRecognition();
}
if (!recognition) {
showStatus('error', '❌ المتصفح لا يدعم التعرف على الصوت');
return;
}
// Update language in case it changed
recognition.lang = settings.language;
try {
recognition.start();
isListening = true;
lastSpeechTime = Date.now();
updateMicButton(true);
showStatus('listening', '🎤 جارٍ الاستماع...');
// Auto-stop after max time
clearTimeout(autoSendTimer);
autoSendTimer = setTimeout(() => {
if (isListening) stopListening();
}, MAX_RECORDING_TIME);
// Silence detection
clearTimeout(silenceTimer);
silenceTimer = setTimeout(() => {
if (isListening && Date.now() - lastSpeechTime >= SILENCE_TIMEOUT) {
stopListening();
}
}, SILENCE_TIMEOUT);
} catch (e) {
console.error('[ClaudeVoice] Start failed:', e);
showStatus('error', '❌ فشل بدء التسجيل');
}
}
async function stopListening() {
if (!recognition) return;
isListening = false;
clearTimeout(silenceTimer);
clearTimeout(autoSendTimer);
try {
recognition.stop();
} catch (e) { /* ignore */ }
updateMicButton(false);
// If we have final text, process it
const text = finalText.trim() || interimText.trim();
if (text) {
showStatus('processing', '⏳ جارٍ المعالجة...');
if (settings.useGemini && settings.geminiApiKey) {
await processWithGemini(text);
} else {
// Just insert the text directly
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 {
const response = await chrome.runtime.sendMessage({
type: 'GEMINI_PROCESS_VOICE',
payload: {
apiKey: settings.geminiApiKey,
model: settings.geminiModel,
text: text,
language: settings.language
}
});
if (response && response.success) {
const processedText = response.data.text;
insertTextIntoClaude(processedText);
showStatus('done', '✅ تمت المعالجة بواسطة Gemini');
} else {
// Fallback: insert original text
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();
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();
}
})();