import React, { useEffect, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; import { safeSendMessage, safeStorageGet, safeStorageSet } from '@lib/messaging'; import { encryptApiKey, getOrCreateEncKey } from '@lib/crypto'; // ─── Provider config ────────────────────────────────────────────────────────── // Model lists are fetched live per provider (see loadModels) — no static lists here. const PROVIDERS = [ { id: 'openai', name: 'OpenAI', placeholder: 'sk-...', docsUrl: 'https://platform.openai.com/api-keys', }, { id: 'anthropic', name: 'Anthropic (Claude)', placeholder: 'sk-ant-...', docsUrl: 'https://console.anthropic.com/keys', }, { id: 'groq', name: 'Groq (Free tier)', placeholder: 'gsk_...', docsUrl: 'https://console.groq.com/keys', }, { id: 'openrouter', name: 'OpenRouter (100+ models)', placeholder: 'sk-or-...', docsUrl: 'https://openrouter.ai/keys', }, ]; // ─── Styles ─────────────────────────────────────────────────────────────────── const styles = { page: { minHeight: '100vh', background: 'linear-gradient(135deg, #1e1e2e 0%, #181825 100%)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: '16px 12px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', } as React.CSSProperties, card: { background: 'rgba(30,30,46,0.95)', border: '1px solid rgba(205,214,244,0.12)', borderRadius: '12px', padding: '16px', width: '100%', maxWidth: '420px', boxShadow: '0 8px 48px rgba(0,0,0,0.4)', color: '#cdd6f4', } as React.CSSProperties, logoRow: { display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px', } as React.CSSProperties, logo: { fontSize: '28px', } as React.CSSProperties, title: { fontSize: '22px', fontWeight: '800', color: '#89b4fa', margin: 0, } as React.CSSProperties, subtitle: { fontSize: '13px', color: '#6c7086', marginBottom: '14px', marginTop: '4px', } as React.CSSProperties, label: { display: 'block', fontSize: '13px', fontWeight: '600', color: '#a6adc8', marginBottom: '6px', textTransform: 'uppercase' as const, letterSpacing: '0.04em', } as React.CSSProperties, select: { width: '100%', padding: '10px 14px', borderRadius: '9px', border: '1px solid rgba(205,214,244,0.15)', background: 'rgba(49,50,68,0.7)', color: '#cdd6f4', fontSize: '14px', outline: 'none', cursor: 'pointer', marginBottom: '0', } as React.CSSProperties, input: { width: '100%', padding: '10px 14px', borderRadius: '9px', border: '1px solid rgba(205,214,244,0.15)', background: 'rgba(49,50,68,0.7)', color: '#cdd6f4', fontSize: '14px', outline: 'none', boxSizing: 'border-box' as const, } as React.CSSProperties, formGroup: { marginBottom: '12px', } as React.CSSProperties, hint: { fontSize: '12px', color: '#6c7086', marginTop: '6px', display: 'flex', alignItems: 'center', gap: '4px', } as React.CSSProperties, docsLink: { color: '#89b4fa', textDecoration: 'none', fontSize: '12px', } as React.CSSProperties, saveBtn: { width: '100%', padding: '10px', borderRadius: '10px', border: 'none', fontSize: '15px', fontWeight: '700', cursor: 'pointer', transition: 'all 0.2s ease', marginTop: '4px', } as React.CSSProperties, divider: { borderTop: '1px solid rgba(205,214,244,0.08)', margin: '12px 0', } as React.CSSProperties, statusBadge: { display: 'inline-flex', alignItems: 'center', gap: '6px', fontSize: '12px', padding: '4px 10px', borderRadius: '6px', marginTop: '12px', } as React.CSSProperties, }; const providerName = (id: string | null): string => PROVIDERS.find((p) => p.id === id)?.name ?? id ?? 'another provider'; // ─── Component ──────────────────────────────────────────────────────────────── function OptionsPage() { const [provider, setProvider] = useState('openai'); const [apiKey, setApiKey] = useState(''); const [model, setModel] = useState(''); const [models, setModels] = useState([]); const [modelsStatus, setModelsStatus] = useState<'idle' | 'loading' | 'error'>('idle'); const [modelsError, setModelsError] = useState(''); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [showKey, setShowKey] = useState(false); const [isEncrypted, setIsEncrypted] = useState(false); const apiKeyRef = useRef(null); // Which provider the stored key belongs to. A key is only usable for the // provider it was saved under — see handleProviderChange. const savedKeyProvider = useRef(null); const hasStoredKey = useRef(false); // Fetch the live model list from the provider (via the background worker, which // owns key decryption). Uses a freshly typed key if present, else the stored key. const loadModels = async (providerOverride?: string, keyOverride?: string) => { const prov = providerOverride ?? provider; const inlineKey = keyOverride ?? (apiKey.trim() || undefined); setModelsStatus('loading'); setModelsError(''); // Never ask the background to fall back to a key saved for another provider — // it comes back as a bogus "Invalid API Key" from the provider being switched to. if (!inlineKey && prov !== 'openrouter' && hasStoredKey.current && savedKeyProvider.current !== prov) { setModels([]); setModelsStatus('idle'); setModelsError(`Your saved key was entered for ${providerName(savedKeyProvider.current)}. Enter a ${providerName(prov)} API key above, then click ↻ Load.`); return; } const response = await safeSendMessage({ type: 'LIST_MODELS', provider: prov, apiKey: inlineKey }); if (!response) { setModels([]); setModelsStatus('error'); setModelsError('No response from the extension. Try reloading the page.'); return; } if (response.error) { setModels([]); setModelsStatus('error'); // A rejected key means the stored one is unusable — drop the 🔒 badge so // the field invites a new key instead of implying one is configured. if (response.keyRejected && !inlineKey) { hasStoredKey.current = false; setIsEncrypted(false); setModelsError(`${response.error} Enter a valid ${providerName(prov)} API key above and save again.`); return; } setModelsError(response.error); return; } const list: string[] = Array.isArray(response.models) ? response.models : []; setModels(list); setModelsStatus('idle'); // Keep the current selection if it's still valid; otherwise force a re-pick. setModel((prev) => (prev && list.includes(prev) ? prev : '')); }; useEffect(() => { safeStorageGet(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model', 'keyProvider'], (result) => { const prov = result.provider || 'openai'; if (result.provider) setProvider(result.provider); if (result.model) setModel(result.model); // Show placeholder if encrypted key exists if (result.apiKeyEnc && result.encKey) { setIsEncrypted(true); setApiKey(''); // don't show encrypted blob — show empty for re-entry or leave as is } else if (result.apiKey) { setApiKey(result.apiKey); setIsEncrypted(false); } if (result.apiKeyEnc || result.apiKey) { hasStoredKey.current = true; // Keys saved before `keyProvider` existed: assume the stored provider. savedKeyProvider.current = (result.keyProvider as string) || prov; // A key belonging to another provider is not usable here — don't show 🔒. if (savedKeyProvider.current !== prov) setIsEncrypted(false); } // If a key is already configured, pull the live model list on open. if (result.apiKeyEnc || result.apiKey || prov === 'openrouter') { loadModels(prov); } }); }, []); const currentProvider = PROVIDERS.find((p) => p.id === provider) ?? PROVIDERS[0]; const handleProviderChange = (newProvider: string) => { setProvider(newProvider); setModel(''); setModels([]); setModelsStatus('idle'); setModelsError(''); // The stored key belongs to whichever provider was saved last. Listing with // it after a switch makes the *new* provider reject it ("Invalid API Key"), // which reads as "my key doesn't work". Ask for the new key instead. const keyIsForThisProvider = hasStoredKey.current && savedKeyProvider.current === newProvider; // Drives both the 🔒 badge and whether Save may keep the existing key. setIsEncrypted(keyIsForThisProvider); if (apiKey.trim() || keyIsForThisProvider || newProvider === 'openrouter') { loadModels(newProvider); } else { setModelsError(`Enter your ${providerName(newProvider)} API key above, then click ↻ Load.`); } }; const handleSave = async () => { // Require an explicit model choice — prevents sending a stale/mismatched model // id to a provider (the root cause of the OpenRouter failures). if (!model) { setModelsError('Please load and select a model before saving.'); return; } // If key is blank and we already have an encrypted key, don't overwrite if (!apiKey.trim() && !isEncrypted) { setModelsError('Enter your API key before saving.'); apiKeyRef.current?.focus(); return; } setModelsError(''); if (!apiKey.trim() && isEncrypted) { // Only saving provider/model changes — preserve existing encrypted key. // isEncrypted is only true when that key belongs to `provider`, so // stamping keyProvider here is safe and upgrades pre-keyProvider saves. setSaveStatus('saving'); savedKeyProvider.current = provider; safeStorageSet({ provider, model, keyProvider: provider }, () => { if (chrome.runtime.lastError) { setSaveStatus('error'); setTimeout(() => setSaveStatus('idle'), 3000); } else { setSaveStatus('saved'); setTimeout(() => setSaveStatus('idle'), 2500); } }); return; } setSaveStatus('saving'); try { const key = await getOrCreateEncKey(); const apiKeyEncB64 = encryptApiKey(apiKey.trim(), key); safeStorageSet({ apiKeyEnc: apiKeyEncB64, provider, model, keyProvider: provider }, () => { if (chrome.runtime.lastError) { setSaveStatus('error'); setTimeout(() => setSaveStatus('idle'), 3000); } else { // Remove plaintext key if it existed chrome.storage.local.remove('apiKey'); savedKeyProvider.current = provider; hasStoredKey.current = true; setIsEncrypted(true); setApiKey(''); setSaveStatus('saved'); setTimeout(() => setSaveStatus('idle'), 2500); } }); } catch (err) { console.error('LexAI: Encryption failed', err); setSaveStatus('error'); setTimeout(() => setSaveStatus('idle'), 3000); } }; const saveBtnStyle: React.CSSProperties = { ...styles.saveBtn, background: saveStatus === 'saved' ? '#a6e3a1' : saveStatus === 'error' ? '#f38ba8' : 'linear-gradient(135deg, #89b4fa 0%, #b4befe 100%)', color: '#1e1e2e', }; return (
{/* Header */}

LexAI Settings

Configure your LLM provider and API key. Your key is encrypted and stored locally.

{/* Provider */}
{/* API Key — comes before Model: the live model list is fetched with this key */}
{ setApiKey(e.target.value); if (isEncrypted && e.target.value) setIsEncrypted(false); }} placeholder={isEncrypted ? '••••••• (encrypted — enter new key to change)' : currentProvider.placeholder} style={{ ...styles.input, paddingRight: '42px' }} onKeyDown={(e) => e.key === 'Enter' && handleSave()} />
🔒 Encrypted with TweetNaCl on your device. · Get API key →
{/* Model — fetched live from the selected provider using the key above */}
{/* Rendered whenever set — not only on load errors. Gating this on modelsStatus made the save-time messages invisible, so Save looked like a no-op. */} {modelsError && (
⚠ {modelsError}
)}
{/* Save */}
{/* Info */}
How to use LexAI:
  1. Select any text on a webpage
  2. Click Fix, Rephrase, Shorten, Expand, Explain, or Prompt (turns your text into an engineered AI prompt)
  3. Accept or replace the suggestion
); } createRoot(document.getElementById('app')!).render();