Files
cv/claude-arabic-voice/content.js
2026-06-02 18:02:41 +03:00

627 lines
25 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();
}
);
});
}
// ─── Offscreen Document Integration ──────────────────────────────────────
chrome.runtime.onMessage.addListener((message) => {
if (message.type === 'OFFSCREEN_RECORDING_RESULT') {
lastSpeechTime = Date.now();
interimText = message.payload.interimText || '';
finalText = message.payload.finalText || '';
updateInputField();
clearTimeout(silenceTimer);
silenceTimer = setTimeout(() => {
if (isListening && Date.now() - lastSpeechTime >= SILENCE_TIMEOUT) {
stopListening();
}
}, SILENCE_TIMEOUT);
} else if (message.type === 'OFFSCREEN_RECORDING_ERROR') {
console.error('[ClaudeVoice] Recognition error:', message.payload.error);
if (message.payload.error === 'no-speech') {
return; // offscreen script restarts it automatically
}
stopListening();
showStatus('error', '❌ خطأ: ' + getArabicError(message.payload.error));
} else if (message.type === 'OFFSCREEN_RECORDING_END') {
if (isListening) {
// Unexpected end from offscreen while we still consider ourselves listening
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();
try {
chrome.runtime.sendMessage({
type: 'START_RECORDING_FROM_CONTENT',
payload: { language: settings.language }
}, (response) => {
if (response && response.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(() => {
if (isListening && Date.now() - lastSpeechTime >= SILENCE_TIMEOUT) {
stopListening();
}
}, SILENCE_TIMEOUT);
} else {
console.error('[ClaudeVoice] Start failed:', response?.error);
showStatus('error', '❌ فشل بدء التسجيل');
}
});
} catch (e) {
console.error('[ClaudeVoice] Start message failed:', e);
showStatus('error', '❌ فشل الاتصال بالإضافة');
}
}
async function stopListening() {
if (!isListening) return;
isListening = false;
clearTimeout(silenceTimer);
clearTimeout(autoSendTimer);
chrome.runtime.sendMessage({ type: 'STOP_RECORDING_FROM_CONTENT' });
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();
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();
}
})();