636 lines
24 KiB
JavaScript
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();
|
|
}
|
|
|
|
})();
|