import { defineBackground } from 'wxt/utils/define-background'; import nacl from 'tweetnacl'; import type { AnalyzePayload, LexAIConfig, LexAIResponse } from '@lib/types'; // ─── Fetch with timeout ─────────────────────────────────────────────────────── async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs = 30000): Promise { const controller = new AbortController(); const id = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { ...options, signal: controller.signal }); } finally { clearTimeout(id); } } // ─── System prompts ─────────────────────────────────────────────────────────── function getSystemPrompt(action: string, style?: string): string { // Normalize 'fix' (used by context menu) to 'grammar' const normalizedAction = action === 'fix' ? 'grammar' : action; const prompts: Record = { grammar: 'You are a professional grammar editor. Fix all grammar, spelling, and punctuation errors in the provided text. ' + 'Preserve the original meaning and tone as closely as possible. ' + 'Return ONLY the corrected text — no explanations, no preamble.', rephrase: 'You are a skilled writing assistant. Rephrase the provided text to make it clearer, more engaging, and more professional. ' + 'Keep the same meaning and approximate length. ' + 'Return ONLY the rephrased text — no explanations.', shorten: 'You are a concise editor. Shorten the provided text by at least 30% while preserving the core message. ' + 'Remove filler words, redundant phrases, and unnecessary detail. ' + 'Return ONLY the shortened text.', expand: 'You are an experienced writer. Expand the provided text with more detail, context, and supporting points. ' + 'Make it richer and more informative while staying on topic. ' + 'Return ONLY the expanded text.', explain: 'You are a helpful teacher. Explain the following text in simple, easy-to-understand language. ' + 'Break down complex terms, jargon, or concepts so anyone can understand. ' + 'Be concise but clear. Return only the explanation, no extra commentary.', }; const base = prompts[normalizedAction] ?? prompts.grammar; const styleModifier = style && style !== 'Default' ? ` Write in a ${style.toLowerCase()} style.` : ''; return base + styleModifier; } // ─── Encryption helpers ─────────────────────────────────────────────────────── async function decryptApiKey(encKeyB64: string, apiKeyEncB64: string): Promise { const key = Uint8Array.from(atob(encKeyB64), c => c.charCodeAt(0)); const combined = Uint8Array.from(atob(apiKeyEncB64), c => c.charCodeAt(0)); const nonce = combined.slice(0, 24); const cipher = combined.slice(24); const decrypted = nacl.secretbox.open(cipher, nonce, key); if (!decrypted) return null; return new TextDecoder().decode(decrypted); } // Resolve the usable API key from stored config: prefer the encrypted path, // fall back to plaintext for backward compat. Returns null if none is set. async function resolveApiKey(config: LexAIConfig): Promise { let apiKey = config.apiKey; if (config.apiKeyEnc && config.encKey) { const decrypted = await decryptApiKey(config.encKey, config.apiKeyEnc); if (decrypted) apiKey = decrypted; } if (!apiKey || apiKey.trim() === '') return null; return apiKey.trim(); } // ─── Provider implementations ───────────────────────────────────────────────── async function callOpenAI(payload: AnalyzePayload, config: LexAIConfig): Promise { const model = config.model || 'gpt-4o-mini'; let res: Response; try { res = await fetchWithTimeout('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.apiKey}`, }, body: JSON.stringify({ model, messages: [ { role: 'system', content: getSystemPrompt(payload.action, payload.style) }, { role: 'user', content: payload.text }, ], max_tokens: 1024, temperature: 0.7, }), }); } catch (err) { return { error: `Network error reaching OpenAI: ${String(err)}` }; } const data = await res.json(); if (!res.ok) { const msg = data?.error?.message ?? `HTTP ${res.status}`; return { error: `OpenAI error: ${msg}` }; } const result = data?.choices?.[0]?.message?.content as string | undefined; if (!result) return { error: 'OpenAI returned an empty response.' }; return { result: result.trim() }; } async function callAnthropic(payload: AnalyzePayload, config: LexAIConfig): Promise { const model = config.model || 'claude-3-5-haiku-20241022'; let res: Response; try { res = await fetchWithTimeout('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': config.apiKey!, 'anthropic-version': '2023-06-01', }, body: JSON.stringify({ model, max_tokens: 1024, system: getSystemPrompt(payload.action, payload.style), messages: [{ role: 'user', content: payload.text }], }), }); } catch (err) { return { error: `Network error reaching Anthropic: ${String(err)}` }; } const data = await res.json(); if (!res.ok) { const msg = data?.error?.message ?? `HTTP ${res.status}`; return { error: `Anthropic error: ${msg}` }; } const result = data?.content?.[0]?.text as string | undefined; if (!result) return { error: 'Anthropic returned an empty response.' }; return { result: result.trim() }; } async function callGroq(payload: AnalyzePayload, config: LexAIConfig): Promise { const model = config.model || 'llama-3.3-70b-versatile'; let res: Response; try { res = await fetchWithTimeout('https://api.groq.com/openai/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.apiKey}`, }, body: JSON.stringify({ model, messages: [ { role: 'system', content: getSystemPrompt(payload.action, payload.style) }, { role: 'user', content: payload.text }, ], max_tokens: 1024, temperature: 0.7, }), }); } catch (err) { return { error: `Network error reaching Groq: ${String(err)}` }; } const data = await res.json(); if (!res.ok) { const msg = data?.error?.message ?? `HTTP ${res.status}`; return { error: `Groq error: ${msg}` }; } const result = data?.choices?.[0]?.message?.content as string | undefined; if (!result) return { error: 'Groq returned an empty response.' }; return { result: result.trim() }; } async function callOpenRouter(payload: AnalyzePayload, config: LexAIConfig): Promise { const model = config.model || 'openai/gpt-4o-mini'; let res: Response; try { res = await fetchWithTimeout('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.apiKey}`, 'HTTP-Referer': 'https://lexai.dev', 'X-Title': 'LexAI', }, body: JSON.stringify({ model, messages: [ { role: 'system', content: getSystemPrompt(payload.action, payload.style) }, { role: 'user', content: payload.text }, ], max_tokens: 1024, }), }); } catch (err) { return { error: `Network error reaching OpenRouter: ${String(err)}` }; } const data = await res.json(); if (!res.ok) { const msg = data?.error?.message ?? `HTTP ${res.status}`; return { error: `OpenRouter error: ${msg}` }; } const result = data?.choices?.[0]?.message?.content as string | undefined; if (!result) return { error: 'OpenRouter returned an empty response.' }; return { result: result.trim() }; } // ─── Generic provider call (used by copy-as and future features) ────────────── async function callProvider(config: LexAIConfig, text: string, systemPrompt: string): Promise { const payload: AnalyzePayload = { text, action: '__custom__' }; const provider = config.provider || 'openai'; switch (provider) { case 'openai': return callOpenAIWithPrompt(payload, config, systemPrompt); case 'anthropic': return callAnthropicWithPrompt(payload, config, systemPrompt); case 'groq': return callGroqWithPrompt(payload, config, systemPrompt); case 'openrouter': return callOpenRouterWithPrompt(payload, config, systemPrompt); default: return { error: `Unknown provider: "${provider}". Please check LexAI settings.` }; } } async function callOpenAIWithPrompt(payload: AnalyzePayload, config: LexAIConfig, systemPrompt: string): Promise { const model = config.model || 'gpt-4o-mini'; let res: Response; try { res = await fetchWithTimeout('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.apiKey}` }, body: JSON.stringify({ model, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: payload.text }, ], max_tokens: 1024, temperature: 0.7, }), }); } catch (err) { return { error: `Network error reaching OpenAI: ${String(err)}` }; } const data = await res.json(); if (!res.ok) return { error: `OpenAI error: ${data?.error?.message ?? `HTTP ${res.status}`}` }; const result = data?.choices?.[0]?.message?.content as string | undefined; if (!result) return { error: 'OpenAI returned an empty response.' }; return { result: result.trim() }; } async function callAnthropicWithPrompt(payload: AnalyzePayload, config: LexAIConfig, systemPrompt: string): Promise { const model = config.model || 'claude-3-5-haiku-20241022'; let res: Response; try { res = await fetchWithTimeout('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': config.apiKey!, 'anthropic-version': '2023-06-01' }, body: JSON.stringify({ model, max_tokens: 1024, system: systemPrompt, messages: [{ role: 'user', content: payload.text }], }), }); } catch (err) { return { error: `Network error reaching Anthropic: ${String(err)}` }; } const data = await res.json(); if (!res.ok) return { error: `Anthropic error: ${data?.error?.message ?? `HTTP ${res.status}`}` }; const result = data?.content?.[0]?.text as string | undefined; if (!result) return { error: 'Anthropic returned an empty response.' }; return { result: result.trim() }; } async function callGroqWithPrompt(payload: AnalyzePayload, config: LexAIConfig, systemPrompt: string): Promise { const model = config.model || 'llama-3.3-70b-versatile'; let res: Response; try { res = await fetchWithTimeout('https://api.groq.com/openai/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.apiKey}` }, body: JSON.stringify({ model, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: payload.text }, ], max_tokens: 1024, temperature: 0.7, }), }); } catch (err) { return { error: `Network error reaching Groq: ${String(err)}` }; } const data = await res.json(); if (!res.ok) return { error: `Groq error: ${data?.error?.message ?? `HTTP ${res.status}`}` }; const result = data?.choices?.[0]?.message?.content as string | undefined; if (!result) return { error: 'Groq returned an empty response.' }; return { result: result.trim() }; } async function callOpenRouterWithPrompt(payload: AnalyzePayload, config: LexAIConfig, systemPrompt: string): Promise { const model = config.model || 'openai/gpt-4o-mini'; let res: Response; try { res = await fetchWithTimeout('https://openrouter.ai/api/v1/chat/completions', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.apiKey}`, 'HTTP-Referer': 'https://lexai.dev', 'X-Title': 'LexAI', }, body: JSON.stringify({ model, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: payload.text }, ], max_tokens: 1024, }), }); } catch (err) { return { error: `Network error reaching OpenRouter: ${String(err)}` }; } const data = await res.json(); if (!res.ok) return { error: `OpenRouter error: ${data?.error?.message ?? `HTTP ${res.status}`}` }; const result = data?.choices?.[0]?.message?.content as string | undefined; if (!result) return { error: 'OpenRouter returned an empty response.' }; return { result: result.trim() }; } // ─── Live model listing ─────────────────────────────────────────────────────── const MODEL_LIST_ENDPOINTS: Record = { openai: 'https://api.openai.com/v1/models', groq: 'https://api.groq.com/openai/v1/models', openrouter: 'https://openrouter.ai/api/v1/models', anthropic: 'https://api.anthropic.com/v1/models', }; // Drop non-chat models (embeddings, audio, image, etc.) so the picker stays useful. const NON_CHAT_MODEL_RE = /embedding|whisper|tts|dall-e|audio|realtime|moderation|image|guard|transcribe|speech|rerank/i; async function listModels(provider: string, apiKey?: string): Promise<{ models?: string[]; error?: string }> { const url = MODEL_LIST_ENDPOINTS[provider]; if (!url) return { error: `Unknown provider: "${provider}". Please check LexAI settings.` }; const headers: Record = { 'Content-Type': 'application/json' }; if (provider === 'anthropic') { if (!apiKey) return { error: 'Anthropic requires an API key to list models.' }; headers['x-api-key'] = apiKey; headers['anthropic-version'] = '2023-06-01'; // Allow the extension origin to call Anthropic directly (avoids a CORS 403). headers['anthropic-dangerous-direct-browser-access'] = 'true'; } else if (apiKey) { // OpenRouter's list is public, so the key is optional there; OpenAI/Groq require it. headers['Authorization'] = `Bearer ${apiKey}`; } let res: Response; try { res = await fetchWithTimeout(url, { method: 'GET', headers }, 15000); } catch (err) { return { error: `Network error reaching ${provider}: ${String(err)}` }; } const data = await res.json().catch(() => null); if (!res.ok) { const msg = data?.error?.message ?? data?.error ?? `HTTP ${res.status}`; return { error: `${provider} error: ${msg}` }; } const raw = Array.isArray(data?.data) ? data.data : []; const ids = raw .map((m: any) => (typeof m === 'string' ? m : m?.id)) .filter((id: unknown): id is string => typeof id === 'string' && id.length > 0) .filter((id: string) => !NON_CHAT_MODEL_RE.test(id)) .sort((a: string, b: string) => a.localeCompare(b)); if (ids.length === 0) return { error: `No models returned by ${provider}.` }; return { models: ids }; } // ─── Main handler ───────────────────────────────────────────────────────────── async function handleAnalyzeText(payload: AnalyzePayload): Promise { const stored = await chrome.storage.local.get(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model']); const config = stored as LexAIConfig; const apiKey = await resolveApiKey(config); if (!apiKey) { return { error: 'No API key configured. Please open LexAI settings (click the extension icon).' }; } const resolvedConfig: LexAIConfig = { ...config, apiKey }; const provider = config.provider || 'openai'; switch (provider) { case 'openai': return callOpenAI(payload, resolvedConfig); case 'anthropic': return callAnthropic(payload, resolvedConfig); case 'groq': return callGroq(payload, resolvedConfig); case 'openrouter': return callOpenRouter(payload, resolvedConfig); default: return { error: `Unknown provider: "${provider}". Please check LexAI settings.` }; } } // ─── Background entry ───────────────────────────────────────────────────────── export default defineBackground(() => { console.log('LexAI background service worker started'); // ─── Context menus ─────────────────────────────────────────────────────── const CONTEXT_ACTIONS = ['fix', 'rephrase', 'shorten', 'expand', 'explain'] as const; const CONTEXT_ACTION_LABELS: Record = { fix: 'Fix Grammar', rephrase: 'Rephrase', shorten: 'Shorten', expand: 'Expand', explain: 'Explain', }; const CONTEXT_STYLES = ['Formal', 'Casual', 'Academic', 'Creative', 'Concise']; chrome.runtime.onInstalled.addListener(() => { chrome.contextMenus.removeAll(() => { CONTEXT_ACTIONS.forEach(action => { chrome.contextMenus.create({ id: `lexai-${action}`, title: `⚡ LexAI: ${CONTEXT_ACTION_LABELS[action]}`, contexts: ['selection'], }); CONTEXT_STYLES.forEach(style => { chrome.contextMenus.create({ id: `lexai-${action}-${style.toLowerCase()}`, parentId: `lexai-${action}`, title: style, contexts: ['selection'], }); }); }); }); }); chrome.contextMenus.onClicked.addListener((info, tab) => { if (!info.selectionText || !tab?.id) return; // Parse action and style from menuItemId e.g. "lexai-fix-formal" const parts = info.menuItemId.toString().replace('lexai-', '').split('-'); const action = parts[0]; const style = parts[1] ? parts[1].charAt(0).toUpperCase() + parts[1].slice(1) : 'Default'; chrome.tabs.sendMessage(tab.id, { type: 'lexai-context-menu', action, text: info.selectionText, style, }); }); // ─── Message handler ───────────────────────────────────────────────────── chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (message.type === 'ANALYZE_TEXT') { // Support both { payload: { text, action, style } } (content.ts) and // { text, action, style } (popup) formats const payload: AnalyzePayload = message.payload ?? { text: message.text as string, action: message.action as string, style: message.style as string | undefined, }; handleAnalyzeText(payload) .then(sendResponse) .catch((err) => sendResponse({ error: String(err) })); return true; // Keep channel open for async response } if (message.type === 'COPY_AS') { const { text, format } = message as { text: string; format: string }; chrome.storage.local.get(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model']) .then(async (stored) => { const config = stored as LexAIConfig; const apiKey = await resolveApiKey(config); if (!apiKey) { sendResponse({ error: 'No API key configured. Please open LexAI settings.' }); return; } const resolvedConfig: LexAIConfig = { ...config, apiKey }; const systemPrompt = `Reformat the following text as ${format}. Return only the reformatted result, no explanation.`; return callProvider(resolvedConfig, text, systemPrompt); }) .then((res) => { if (res) sendResponse(res); }) .catch((err) => sendResponse({ error: String(err) })); return true; } if (message.type === 'LIST_MODELS') { const provider = (message.provider as string) || 'openai'; // Prefer an inline key (freshly typed, not yet saved); else use the stored key. const inlineKey = (message.apiKey as string | undefined)?.trim() || undefined; (async () => { let apiKey = inlineKey; if (!apiKey) { const stored = await chrome.storage.local.get(['apiKey', 'apiKeyEnc', 'encKey']); apiKey = (await resolveApiKey(stored as LexAIConfig)) ?? undefined; } if (!apiKey && provider !== 'openrouter') { return { error: 'No API key found. Enter your API key above, then click Load models.' }; } return listModels(provider, apiKey); })() .then(sendResponse) .catch((err) => sendResponse({ error: String(err) })); return true; } }); });