import React, { useEffect, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; import nacl from 'tweetnacl'; // ─── Provider config ────────────────────────────────────────────────────────── const PROVIDERS = [ { id: 'openai', name: 'OpenAI', placeholder: 'sk-...', models: ['gpt-4o', 'gpt-4o-mini', 'gpt-3.5-turbo'], docsUrl: 'https://platform.openai.com/api-keys', }, { id: 'anthropic', name: 'Anthropic (Claude)', placeholder: 'sk-ant-...', models: ['claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229'], docsUrl: 'https://console.anthropic.com/keys', }, { id: 'groq', name: 'Groq (Free tier)', placeholder: 'gsk_...', models: ['llama-3.3-70b-versatile', 'llama-3.1-8b-instant', 'mixtral-8x7b-32768'], docsUrl: 'https://console.groq.com/keys', }, { id: 'openrouter', name: 'OpenRouter (100+ models)', placeholder: 'sk-or-...', models: ['openai/gpt-4o-mini', 'anthropic/claude-3-5-haiku', 'meta-llama/llama-3.3-70b-instruct:free'], docsUrl: 'https://openrouter.ai/keys', }, ]; // ─── Encryption helpers ─────────────────────────────────────────────────────── async function getOrCreateEncKey(): Promise { return new Promise((resolve) => { chrome.storage.local.get(['encKey'], (result) => { if (result.encKey) { resolve(Uint8Array.from(atob(result.encKey as string), c => c.charCodeAt(0))); } else { const key = nacl.randomBytes(32); const keyB64 = btoa(String.fromCharCode(...key)); chrome.storage.local.set({ encKey: keyB64 }); resolve(key); } }); }); } // ─── Styles ─────────────────────────────────────────────────────────────────── const styles = { page: { minHeight: '100vh', background: 'linear-gradient(135deg, #1e1e2e 0%, #181825 100%)', display: 'flex', alignItems: 'flex-start', justifyContent: 'center', padding: '40px 20px', 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: '16px', padding: '32px', width: '100%', maxWidth: '480px', boxShadow: '0 8px 48px rgba(0,0,0,0.4)', color: '#cdd6f4', } as React.CSSProperties, logoRow: { display: 'flex', alignItems: 'center', gap: '10px', 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: '28px', 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: '20px', } 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: '12px', borderRadius: '10px', border: 'none', fontSize: '15px', fontWeight: '700', cursor: 'pointer', transition: 'all 0.2s ease', marginTop: '8px', } as React.CSSProperties, divider: { borderTop: '1px solid rgba(205,214,244,0.08)', margin: '24px 0', } as React.CSSProperties, statusBadge: { display: 'inline-flex', alignItems: 'center', gap: '6px', fontSize: '12px', padding: '4px 10px', borderRadius: '6px', marginTop: '12px', } as React.CSSProperties, }; // ─── Safe chrome storage wrappers ──────────────────────────────────────────── function safeStorageGet(keys: string[], callback: (result: Record) => void) { try { if (typeof chrome === 'undefined' || !chrome.runtime?.id) return; chrome.storage.local.get(keys, callback); } catch (err) { console.warn('LexAI: Extension context invalidated', err); } } function safeStorageSet(data: Record, callback?: () => void) { try { if (typeof chrome === 'undefined' || !chrome.runtime?.id) return; if (callback) { chrome.storage.local.set(data, callback); } else { chrome.storage.local.set(data); } } catch (err) { console.warn('LexAI: Extension context invalidated', err); } } // ─── Component ──────────────────────────────────────────────────────────────── function OptionsPage() { const [provider, setProvider] = useState('openai'); const [apiKey, setApiKey] = useState(''); const [model, setModel] = useState('gpt-4o-mini'); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [showKey, setShowKey] = useState(false); const [isEncrypted, setIsEncrypted] = useState(false); const apiKeyRef = useRef(null); useEffect(() => { safeStorageGet(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model'], (result) => { 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); } }); }, []); const currentProvider = PROVIDERS.find((p) => p.id === provider) ?? PROVIDERS[0]; const handleProviderChange = (newProvider: string) => { setProvider(newProvider); const p = PROVIDERS.find((p) => p.id === newProvider); if (p) setModel(p.models[0]); }; const handleSave = async () => { // 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 nonce = nacl.randomBytes(24); const encoded = new TextEncoder().encode(apiKey.trim()); const encrypted = nacl.secretbox(encoded, nonce, key); const combined = new Uint8Array(nonce.length + encrypted.length); combined.set(nonce); combined.set(encrypted, nonce.length); const apiKeyEncB64 = btoa(String.fromCharCode(...combined)); 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 */}
{/* 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 Tone
  3. Accept or replace the suggestion
); } createRoot(document.getElementById('app')!).render();