332 lines
11 KiB
JavaScript
332 lines
11 KiB
JavaScript
// 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 === 'OPEN_PERMISSION_PAGE') {
|
|
chrome.tabs.create({ url: chrome.runtime.getURL('claude-arabic-voice/permission.html') });
|
|
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');
|
|
}
|