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, }; // ─── 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); // 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(''); 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'); 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'], (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 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(''); loadModels(newProvider); }; 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) { apiKeyRef.current?.focus(); return; } if (!apiKey.trim() && isEncrypted) { // Only saving provider/model changes — preserve existing encrypted key setSaveStatus('saving'); safeStorageSet({ provider, model }, () => { 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 }, () => { if (chrome.runtime.lastError) { setSaveStatus('error'); setTimeout(() => setSaveStatus('idle'), 3000); } else { // Remove plaintext key if it existed chrome.storage.local.remove('apiKey'); 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 */}
{/* Model — fetched live from the selected provider */}
{modelsStatus === 'error' && modelsError && (
⚠ {modelsError}
)}
{/* API 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 →
{/* Save */}
{/* Info */}
How to use LexAI:
  1. Select any text on a webpage
  2. Click Fix, Rephrase, Shorten, Expand, or Explain
  3. Accept or replace the suggestion
); } createRoot(document.getElementById('app')!).render();