// background.js — Service Worker // Handles all Gemini API calls (avoids CORS issues from content scripts) const GEMINI_MODEL = 'gemini-flash-lite-latest'; const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`; // ─── Offscreen Document Management ─────────────────────────────────────────── let recordingTabId = null; async function setupOffscreenDocument(path) { const offscreenUrl = chrome.runtime.getURL(path); const existingContexts = await chrome.runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'], documentUrls: [offscreenUrl] }); if (existingContexts.length > 0) { return; } await chrome.offscreen.createDocument({ url: path, reasons: ['USER_MEDIA'], justification: 'Recording microphone input for speech recognition', }); } chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === 'GEMINI_REQUEST') { handleGeminiRequest(message.payload) .then(result => sendResponse({ success: true, data: result })) .catch(err => sendResponse({ success: false, error: err.message })); return true; // Keep message channel open for async } if (message.type === 'GEMINI_PROCESS_VOICE') { handleVoiceProcessing(message.payload) .then(result => sendResponse({ success: true, data: result })) .catch(err => sendResponse({ success: false, error: err.message })); return true; // Keep message channel open for async } // --- Offscreen Document Relays --- if (message.type === 'START_RECORDING_FROM_CONTENT') { recordingTabId = sender.tab ? sender.tab.id : null; setupOffscreenDocument('claude-arabic-voice/offscreen.html') .then(() => { chrome.runtime.sendMessage({ type: 'START_RECORDING', payload: message.payload }, (response) => { sendResponse(response || { success: true }); }); }) .catch(err => { console.error('Failed to setup offscreen doc', err); sendResponse({ success: false, error: err.message }); }); return true; } if (message.type === 'STOP_RECORDING_FROM_CONTENT') { chrome.runtime.sendMessage({ type: 'STOP_RECORDING' }, (response) => { sendResponse(response || { success: true }); }); return true; } if (message.type.startsWith('OFFSCREEN_RECORDING_')) { if (recordingTabId) { chrome.tabs.sendMessage(recordingTabId, message).catch(() => {}); } } }); // ─── Core API call ─────────────────────────────────────────────────────────── async function handleGeminiRequest({ apiKey, prompt, tab, action = 'generateText', jobDescription = '', jobTitle = '', postText = '', template = 'default' }) { // Rate limit check const canProceed = await checkRateLimit(); if (!canProceed) { throw new Error('Daily limit reached (1,000 requests). Resets at midnight PT.'); } // Truncate text — 12k chars to fit list_analysis prompts const maxChars = 12000; const trimmedPrompt = prompt && prompt.length > maxChars ? prompt.substring(0, maxChars) + '\n\n[Truncated]' : prompt; const MAX_RETRIES = 3; const RETRY_DELAYS = [0, 15000, 30000]; // No delay on first attempt let lastError = ''; for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { if (RETRY_DELAYS[attempt] > 0) await delay(RETRY_DELAYS[attempt]); try { const response = await fetch('https://cv.intaleqapp.com/cv/server/generate_cv.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: action, apiKey: apiKey, prompt: trimmedPrompt, jobDescription: jobDescription, jobTitle: jobTitle, postText: postText, template: template }) }); if (response.ok) { const data = await response.json(); if (action === 'generatePdf') { if (!data.pdf) throw new Error('Empty PDF response from server.'); await incrementUsage(); return { pdf: data.pdf, filename: data.filename, fromCache: false }; } else if (action === 'generateComment') { const comment = data.comment; if (!comment) { console.error('[LJA-BG] Empty comment. Full response:', JSON.stringify(data).substring(0, 300)); lastError = 'Empty comment from server.'; continue; } await incrementUsage(); return { comment, fromCache: false }; } else if (action === 'repurposePost') { const result = data.result; if (!result) { console.error('[LJA-BG] Empty result. Full response:', JSON.stringify(data).substring(0, 300)); lastError = 'Empty result from server.'; continue; } await incrementUsage(); return { result, fromCache: false }; } else { const text = data.candidates?.[0]?.content?.parts?.[0]?.text; if (!text) { console.error('[LJA-BG] Empty text. Full response:', JSON.stringify(data).substring(0, 500)); lastError = 'Empty response from API. Server returned: ' + JSON.stringify(data).substring(0, 200); continue; } await incrementUsage(); return { text, fromCache: false }; } } const status = response.status; const errData = await response.json().catch(() => ({})); const errMsg = errData.error?.message || ''; if (status === 429 || status === 503) { lastError = `API quota limit (${status}): ${errMsg || 'Too many tokens'}. Waiting before retry...`; continue; } else if (status === 400) { throw new Error('Request error: ' + (errMsg || 'Bad request')); } else if (status === 403) { throw new Error('Access denied. Check your API key in settings.'); } else { throw new Error(`API Error (${status}): ${errMsg || 'Unknown'}`); } } catch (e) { if (e.message.startsWith('Request error') || e.message.startsWith('Access denied') || e.message.startsWith('API Error')) { throw e; } lastError = e.message || 'Network error'; if (attempt === MAX_RETRIES - 1) throw new Error(`Failed after ${MAX_RETRIES} attempts: ${lastError}`); } } throw new Error(`API busy. ${lastError}. Please wait 1-2 minutes and try again.`); } // ─── Rate Limiting ─────────────────────────────────────────────────────────── async function checkRateLimit() { const today = new Date().toDateString(); const data = await storageGet(['usageDate', 'usageCount']); if (data.usageDate !== today) return true; // New day, reset return (data.usageCount || 0) < 1000; } async function incrementUsage() { const today = new Date().toDateString(); const data = await storageGet(['usageDate', 'usageCount']); if (data.usageDate !== today) { await storageSet({ usageDate: today, usageCount: 1 }); } else { await storageSet({ usageCount: (data.usageCount || 0) + 1 }); } } // ─── Cache ─────────────────────────────────────────────────────────────────── async function getCached(key) { const data = await storageGet(['analysisCache']); const cache = data.analysisCache || {}; const entry = cache[key]; if (!entry) return null; // Expire after 24 hours if (Date.now() - entry.timestamp > 24 * 60 * 60 * 1000) { return null; } return entry.value; } async function setCached(key, value) { const data = await storageGet(['analysisCache']); const cache = data.analysisCache || {}; // Keep max 50 entries const keys = Object.keys(cache); if (keys.length >= 50) { // Remove oldest const oldest = keys.sort((a, b) => cache[a].timestamp - cache[b].timestamp)[0]; delete cache[oldest]; } cache[key] = { value, timestamp: Date.now() }; await storageSet({ analysisCache: cache }); } // ─── Utilities ─────────────────────────────────────────────────────────────── function delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function hashString(str) { let hash = 0; for (let i = 0; i < Math.min(str.length, 200); i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; } return Math.abs(hash).toString(36); } function storageGet(keys) { return new Promise(resolve => chrome.storage.local.get(keys, resolve)); } function storageSet(data) { return new Promise(resolve => chrome.storage.local.set(data, resolve)); } // ─── Voice Processing (for Claude Arabic Voice extension) ──────────────────── async function handleVoiceProcessing({ apiKey, model, text, language }) { if (!apiKey) { throw new Error('Gemini API key is required'); } // Detect if text is Arabic 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 response = await fetch('https://cv.intaleqapp.com/cv/server/generate_cv.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'generateText', apiKey: apiKey, prompt: prompt }) }); if (!response.ok) { const errText = await response.text().catch(() => ''); let errMsg; try { const errData = JSON.parse(errText); errMsg = errData.error?.message || `HTTP ${response.status}`; } catch (e) { errMsg = `HTTP ${response.status}: ${errText.substring(0, 200)}`; } throw new Error(`Gemini API error: ${errMsg}`); } const responseText = await response.text(); let data; try { data = JSON.parse(responseText); } catch (e) { return { text: responseText.trim() }; } if (data.candidates && data.candidates[0]) { const resultText = data.candidates[0].content?.parts?.[0]?.text; if (resultText) { return { text: resultText.trim() }; } } if (data.error) { throw new Error(`Server error: ${data.error}${data.details ? ' - ' + JSON.stringify(data.details) : ''}`); } if (typeof data === 'string') { return { text: data.trim() }; } throw new Error('Empty response from Gemini API'); }