Files
LexAI/entrypoints/options/Options.tsx
john kevin asprec 6aee260533
All checks were successful
CI — Test & Build / Test & Build (push) Successful in 1m33s
fix: never send an API key to the provider it wasn't entered for (v1.0.2)
A stored key carried no record of which provider it belonged to. Options
saves {provider, model} without the key whenever the field is blank (which
it always is after a save, since it shows the encrypted badge instead), so
switching provider left the previous provider's key attached to the new one.
Every call then failed with that provider's own "Invalid API Key" while the
UI still showed a key as configured.

- types.ts: new `keyProvider` storage field, added to CONFIG_STORAGE_KEYS
- background.ts: keyProviderMismatch() guards the chat, COPY_AS and
  stored-key LIST_MODELS paths; absent keyProvider (pre-upgrade) is allowed
- Options.tsx: stamps keyProvider on every save; drops the encrypted badge
  and requires a new key when the saved one belongs to another provider or
  is rejected; save-time guard messages are now actually rendered (they were
  gated on modelsStatus === 'error' and never drew, so Save looked dead)
- providers.ts: providerLabel(); settings hint appended to 401/403 only;
  listModels reports keyRejected and labels errors with the display name
- Anthropic: send anthropic-dangerous-direct-browser-access on the chat path

Docs: CLAUDE.md version-bump rule corrected — wxt.config.ts reads
pkg.version, so package.json is the only place to edit.

typecheck clean, 58/58 tests, build clean (281.72 kB, manifest 1.0.2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:50:07 +08:00

511 lines
18 KiB
TypeScript

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<string[]>([]);
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<HTMLInputElement>(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<string | null>(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 (
<div style={styles.page}>
<div style={styles.card}>
{/* Header */}
<div style={styles.logoRow}>
<span style={styles.logo}></span>
<h1 style={styles.title}>LexAI Settings</h1>
</div>
<p style={styles.subtitle}>
Configure your LLM provider and API key. Your key is encrypted and stored locally.
</p>
<div style={styles.divider} />
{/* Provider */}
<div style={styles.formGroup}>
<label style={styles.label}>LLM Provider</label>
<select
style={styles.select}
value={provider}
onChange={(e) => handleProviderChange(e.target.value)}
>
{PROVIDERS.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
{/* API Key — comes before Model: the live model list is fetched with this key */}
<div style={styles.formGroup}>
<label style={styles.label}>
API Key{' '}
{isEncrypted && (
<span
title="API key is encrypted with TweetNaCl secretbox"
style={{ fontSize: '14px', marginLeft: '4px', verticalAlign: 'middle' }}
>
🔒
</span>
)}
</label>
<div style={{ position: 'relative' }}>
<input
ref={apiKeyRef}
type={showKey ? 'text' : 'password'}
value={apiKey}
onChange={(e) => {
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()}
/>
<button
onClick={() => setShowKey((s) => !s)}
style={{
position: 'absolute',
right: '10px',
top: '50%',
transform: 'translateY(-50%)',
background: 'none',
border: 'none',
color: '#6c7086',
cursor: 'pointer',
fontSize: '14px',
padding: '4px',
}}
title={showKey ? 'Hide key' : 'Show key'}
>
{showKey ? '🙈' : '👁'}
</button>
</div>
<div style={styles.hint}>
<span>🔒 Encrypted with TweetNaCl on your device.</span>
<span>·</span>
<a
href={currentProvider.docsUrl}
target="_blank"
rel="noopener noreferrer"
style={styles.docsLink}
>
Get API key
</a>
</div>
</div>
{/* Model — fetched live from the selected provider using the key above */}
<div style={styles.formGroup}>
<label style={styles.label}>Model</label>
<div style={{ display: 'flex', gap: '8px' }}>
<select
style={{ ...styles.select, flex: 1, opacity: models.length === 0 ? 0.6 : 1 }}
value={model}
onChange={(e) => setModel(e.target.value)}
disabled={modelsStatus === 'loading' || models.length === 0}
>
{modelsStatus === 'loading' && <option value="">Loading models</option>}
{modelsStatus !== 'loading' && models.length === 0 && (
<option value="">
{modelsStatus === 'error' ? 'Failed to load — see below' : 'Load models to choose'}
</option>
)}
{models.length > 0 && <option value=""> Select a model </option>}
{models.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
<button
type="button"
onClick={() => loadModels()}
disabled={modelsStatus === 'loading'}
title="Fetch the current model list from your provider"
style={{
background: 'rgba(137,180,250,0.15)',
color: '#89b4fa',
border: '1px solid rgba(137,180,250,0.3)',
borderRadius: '9px',
padding: '0 12px',
fontSize: '13px',
fontWeight: 600,
cursor: modelsStatus === 'loading' ? 'not-allowed' : 'pointer',
whiteSpace: 'nowrap',
flexShrink: 0,
}}
>
{modelsStatus === 'loading' ? '⏳' : '↻ Load'}
</button>
</div>
{/* 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 && (
<div style={{ ...styles.hint, color: modelsStatus === 'error' ? '#f38ba8' : '#f9e2af' }}>
{modelsError}
</div>
)}
</div>
{/* Save */}
<button style={saveBtnStyle} onClick={handleSave} disabled={saveStatus === 'saving'}>
{saveStatus === 'saving'
? '⏳ Saving…'
: saveStatus === 'saved'
? '✓ Settings Saved!'
: saveStatus === 'error'
? '✕ Save Failed — Try Again'
: '💾 Save Settings'}
</button>
<div style={styles.divider} />
{/* Info */}
<div style={{ fontSize: '12px', color: '#6c7086', lineHeight: '1.6' }}>
<strong style={{ color: '#a6adc8' }}>How to use LexAI:</strong>
<ol style={{ margin: '8px 0 0 16px', padding: 0 }}>
<li>Select any text on a webpage</li>
<li>Click Fix, Rephrase, Shorten, Expand, Explain, or Prompt (turns your text into an engineered AI prompt)</li>
<li>Accept or replace the suggestion</li>
</ol>
</div>
</div>
</div>
);
}
createRoot(document.getElementById('app')!).render(<OptionsPage />);