feat: LEXAI-13 API encryption, LEXAI-11 context menu, LEXAI-12 tone analysis

- LEXAI-13: TweetNaCl secretbox encryption for API keys in Options + background
  - getOrCreateEncKey() generates per-device 32-byte key stored in chrome.storage.local
  - Keys saved as nonce+ciphertext (base64) under apiKeyEnc
  - Backward compat: falls back to plaintext apiKey if no encrypted key found
  - Lock icon 🔒 shown in Options label when key is encrypted
- LEXAI-11: Right-click context menu with Fix, Rephrase, Shorten, Expand, Tone
  - Registered via onInstalled in background service worker
  - Sends lexai-context-menu message to content script
  - Content script listener added at bottom of main()
- LEXAI-12: Tone analysis button added to floating toolbar (teal #2dd4bf)
  - Analysis-only: showModal called with null originalText = no Replace button
  - Tone system prompt updated to pure analysis (no rewrite)
This commit is contained in:
Forge
2026-03-06 15:06:43 +08:00
parent 004819472d
commit 0273356f4d
3 changed files with 192 additions and 29 deletions

View File

@@ -1,4 +1,5 @@
import { defineBackground } from 'wxt/utils/define-background'; import { defineBackground } from 'wxt/utils/define-background';
import nacl from 'tweetnacl';
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
@@ -10,6 +11,8 @@ interface AnalyzePayload {
interface LexAIConfig { interface LexAIConfig {
provider?: string; provider?: string;
apiKey?: string; apiKey?: string;
apiKeyEnc?: string;
encKey?: string;
model?: string; model?: string;
} }
@@ -39,13 +42,26 @@ function getSystemPrompt(action: string): string {
'Make it richer and more informative while staying on topic. ' + 'Make it richer and more informative while staying on topic. ' +
'Return ONLY the expanded text.', 'Return ONLY the expanded text.',
tone: tone:
'You are a writing coach. Analyze the tone of the provided text (e.g. formal, casual, aggressive, passive) ' + 'You are a writing coach. Analyze the tone of the provided text. ' +
'and rewrite it to be professional and clear. ' + 'Describe the tone characteristics (e.g. formal, casual, aggressive, passive, confident, etc.) ' +
'Return ONLY the improved text.', 'and note any issues like passive voice, wordiness, or emotional bias. ' +
'Return a brief, clear analysis — no rewriting, no extra commentary.',
}; };
return prompts[action] ?? prompts.grammar; return prompts[action] ?? prompts.grammar;
} }
// ─── Encryption helpers ───────────────────────────────────────────────────────
async function decryptApiKey(encKeyB64: string, apiKeyEncB64: string): Promise<string | null> {
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);
}
// ─── Provider implementations ───────────────────────────────────────────────── // ─── Provider implementations ─────────────────────────────────────────────────
async function callOpenAI(payload: AnalyzePayload, config: LexAIConfig): Promise<LexAIResponse> { async function callOpenAI(payload: AnalyzePayload, config: LexAIConfig): Promise<LexAIResponse> {
@@ -198,23 +214,34 @@ async function callOpenRouter(payload: AnalyzePayload, config: LexAIConfig): Pro
// ─── Main handler ───────────────────────────────────────────────────────────── // ─── Main handler ─────────────────────────────────────────────────────────────
async function handleAnalyzeText(payload: AnalyzePayload): Promise<LexAIResponse> { async function handleAnalyzeText(payload: AnalyzePayload): Promise<LexAIResponse> {
const config = await chrome.storage.local.get(['provider', 'apiKey', 'model']) as LexAIConfig; const stored = await chrome.storage.local.get(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model']);
const config = stored as LexAIConfig;
if (!config.apiKey || config.apiKey.trim() === '') { // Resolve API key — prefer encrypted path, fall back to plaintext for backward compat
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 { error: 'No API key configured. Please open LexAI settings (click the extension icon).' }; return { error: 'No API key configured. Please open LexAI settings (click the extension icon).' };
} }
const resolvedConfig: LexAIConfig = { ...config, apiKey: apiKey.trim() };
const provider = config.provider || 'openai'; const provider = config.provider || 'openai';
switch (provider) { switch (provider) {
case 'openai': case 'openai':
return callOpenAI(payload, config); return callOpenAI(payload, resolvedConfig);
case 'anthropic': case 'anthropic':
return callAnthropic(payload, config); return callAnthropic(payload, resolvedConfig);
case 'groq': case 'groq':
return callGroq(payload, config); return callGroq(payload, resolvedConfig);
case 'openrouter': case 'openrouter':
return callOpenRouter(payload, config); return callOpenRouter(payload, resolvedConfig);
default: default:
return { error: `Unknown provider: "${provider}". Please check LexAI settings.` }; return { error: `Unknown provider: "${provider}". Please check LexAI settings.` };
} }
@@ -225,6 +252,46 @@ async function handleAnalyzeText(payload: AnalyzePayload): Promise<LexAIResponse
export default defineBackground(() => { export default defineBackground(() => {
console.log('LexAI background service worker started'); console.log('LexAI background service worker started');
// ─── Context menus ───────────────────────────────────────────────────────
chrome.runtime.onInstalled.addListener(() => {
chrome.contextMenus.create({
id: 'lexai-grammar',
title: '⚡ LexAI: Fix Grammar',
contexts: ['selection'],
});
chrome.contextMenus.create({
id: 'lexai-rephrase',
title: '⚡ LexAI: Rephrase',
contexts: ['selection'],
});
chrome.contextMenus.create({
id: 'lexai-shorten',
title: '⚡ LexAI: Shorten',
contexts: ['selection'],
});
chrome.contextMenus.create({
id: 'lexai-expand',
title: '⚡ LexAI: Expand',
contexts: ['selection'],
});
chrome.contextMenus.create({
id: 'lexai-tone',
title: '⚡ LexAI: Analyze Tone',
contexts: ['selection'],
});
});
chrome.contextMenus.onClicked.addListener((info, tab) => {
if (!info.selectionText || !tab?.id) return;
const action = info.menuItemId.toString().replace('lexai-', '');
chrome.tabs.sendMessage(tab.id, {
type: 'lexai-context-menu',
action,
text: info.selectionText,
});
});
// ─── Message handler ─────────────────────────────────────────────────────
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'ANALYZE_TEXT') { if (message.type === 'ANALYZE_TEXT') {
handleAnalyzeText(message.payload as AnalyzePayload) handleAnalyzeText(message.payload as AnalyzePayload)

View File

@@ -186,6 +186,7 @@ export default defineContentScript({
{ label: '↺ Rephrase', action: 'rephrase', color: '#89b4fa' }, { label: '↺ Rephrase', action: 'rephrase', color: '#89b4fa' },
{ label: '↓ Shorten', action: 'shorten', color: '#fab387' }, { label: '↓ Shorten', action: 'shorten', color: '#fab387' },
{ label: '↑ Expand', action: 'expand', color: '#cba6f7' }, { label: '↑ Expand', action: 'expand', color: '#cba6f7' },
{ label: '🎭 Tone', action: 'tone', color: '#2dd4bf' },
]; ];
actions.forEach(({ label, action, color }) => { actions.forEach(({ label, action, color }) => {
@@ -340,7 +341,13 @@ export default defineContentScript({
if (response?.error) { if (response?.error) {
showModal(`${response.error}`, null, snapStart, snapEnd, snapElement, snapRange); showModal(`${response.error}`, null, snapStart, snapEnd, snapElement, snapRange);
} else { } else {
showModal(response?.result ?? '(no result)', textToProcess, snapStart, snapEnd, snapElement, snapRange); // Tone analysis is display-only — no Replace button (pass null for originalText)
const isToneAction = action === 'tone';
showModal(
response?.result ?? '(no result)',
isToneAction ? null : textToProcess,
snapStart, snapEnd, snapElement, snapRange,
);
} }
} catch (err) { } catch (err) {
hideToolbar(); hideToolbar();
@@ -653,5 +660,18 @@ export default defineContentScript({
} }
} }
}); });
// ─── Context menu trigger from background ─────────────────────────────────
chrome.runtime.onMessage.addListener((message) => {
if (message.type === 'lexai-context-menu') {
selectedText = message.text as string;
// For context menu, we have no DOM selection positions — zero them out
storedStart = -1;
storedEnd = -1;
storedElement = null;
storedRange = null;
runAction(message.action as string);
}
});
}, },
}); });

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import nacl from 'tweetnacl';
// ─── Provider config ────────────────────────────────────────────────────────── // ─── Provider config ──────────────────────────────────────────────────────────
@@ -34,6 +35,23 @@ const PROVIDERS = [
}, },
]; ];
// ─── Encryption helpers ───────────────────────────────────────────────────────
async function getOrCreateEncKey(): Promise<Uint8Array> {
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 ─────────────────────────────────────────────────────────────────── // ─── Styles ───────────────────────────────────────────────────────────────────
const styles = { const styles = {
@@ -183,13 +201,22 @@ function OptionsPage() {
const [model, setModel] = useState('gpt-4o-mini'); const [model, setModel] = useState('gpt-4o-mini');
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [isEncrypted, setIsEncrypted] = useState(false);
const apiKeyRef = useRef<HTMLInputElement>(null); const apiKeyRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
safeStorageGet(["provider", "apiKey", "model"], (result) => { safeStorageGet(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model'], (result) => {
if (result.provider) setProvider(result.provider); if (result.provider) setProvider(result.provider);
if (result.apiKey) setApiKey(result.apiKey);
if (result.model) setModel(result.model); 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);
}
}); });
}, []); }, []);
@@ -201,13 +228,16 @@ function OptionsPage() {
if (p) setModel(p.models[0]); if (p) setModel(p.models[0]);
}; };
const handleSave = () => { const handleSave = async () => {
if (!apiKey.trim()) { // If key is blank and we already have an encrypted key, don't overwrite
if (!apiKey.trim() && !isEncrypted) {
apiKeyRef.current?.focus(); apiKeyRef.current?.focus();
return; return;
} }
if (!apiKey.trim() && isEncrypted) {
// Only saving provider/model changes — preserve existing encrypted key
setSaveStatus('saving'); setSaveStatus('saving');
safeStorageSet({ provider, apiKey: apiKey.trim(), model }, () => { safeStorageSet({ provider, model }, () => {
if (chrome.runtime.lastError) { if (chrome.runtime.lastError) {
setSaveStatus('error'); setSaveStatus('error');
setTimeout(() => setSaveStatus('idle'), 3000); setTimeout(() => setSaveStatus('idle'), 3000);
@@ -216,6 +246,39 @@ function OptionsPage() {
setTimeout(() => setSaveStatus('idle'), 2500); 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 = { const saveBtnStyle: React.CSSProperties = {
@@ -237,7 +300,7 @@ function OptionsPage() {
<h1 style={styles.title}>LexAI Settings</h1> <h1 style={styles.title}>LexAI Settings</h1>
</div> </div>
<p style={styles.subtitle}> <p style={styles.subtitle}>
Configure your LLM provider and API key. Your key is stored locally and never shared. Configure your LLM provider and API key. Your key is encrypted and stored locally.
</p> </p>
<div style={styles.divider} /> <div style={styles.divider} />
@@ -272,14 +335,27 @@ function OptionsPage() {
{/* API Key */} {/* API Key */}
<div style={styles.formGroup}> <div style={styles.formGroup}>
<label style={styles.label}>API Key</label> <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' }}> <div style={{ position: 'relative' }}>
<input <input
ref={apiKeyRef} ref={apiKeyRef}
type={showKey ? 'text' : 'password'} type={showKey ? 'text' : 'password'}
value={apiKey} value={apiKey}
onChange={(e) => setApiKey(e.target.value)} onChange={(e) => {
placeholder={currentProvider.placeholder} 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' }} style={{ ...styles.input, paddingRight: '42px' }}
onKeyDown={(e) => e.key === 'Enter' && handleSave()} onKeyDown={(e) => e.key === 'Enter' && handleSave()}
/> />
@@ -303,7 +379,7 @@ function OptionsPage() {
</button> </button>
</div> </div>
<div style={styles.hint}> <div style={styles.hint}>
<span>🔒 Stored only on your device.</span> <span>🔒 Encrypted with TweetNaCl on your device.</span>
<span>·</span> <span>·</span>
<a <a
href={currentProvider.docsUrl} href={currentProvider.docsUrl}
@@ -334,7 +410,7 @@ function OptionsPage() {
<strong style={{ color: '#a6adc8' }}>How to use LexAI:</strong> <strong style={{ color: '#a6adc8' }}>How to use LexAI:</strong>
<ol style={{ margin: '8px 0 0 16px', padding: 0 }}> <ol style={{ margin: '8px 0 0 16px', padding: 0 }}>
<li>Select any text on a webpage</li> <li>Select any text on a webpage</li>
<li>Click Fix, Rephrase, Shorten, or Expand</li> <li>Click Fix, Rephrase, Shorten, Expand, or Tone</li>
<li>Accept or replace the suggestion</li> <li>Accept or replace the suggestion</li>
</ol> </ol>
</div> </div>