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

699 lines
27 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 {
// Try via background service worker first
let processedText = null;
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) {
processedText = response.data.text;
} else {
console.warn('[ClaudeVoice] Background response not successful:', response?.error);
}
} catch (bgErr) {
console.warn('[ClaudeVoice] Background message failed, trying direct fetch:', bgErr);
}
// Fallback: direct fetch to server
if (!processedText) {
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] Direct fetch failed:', directResponse.status);
}
} catch (fetchErr) {
console.warn('[ClaudeVoice] Direct 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();
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();
}
})();