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 nacl from 'tweetnacl';
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -10,6 +11,8 @@ interface AnalyzePayload {
interface LexAIConfig {
provider?: string;
apiKey?: string;
apiKeyEnc?: string;
encKey?: string;
model?: string;
}
@@ -39,13 +42,26 @@ function getSystemPrompt(action: string): string {
'Make it richer and more informative while staying on topic. ' +
'Return ONLY the expanded text.',
tone:
'You are a writing coach. Analyze the tone of the provided text (e.g. formal, casual, aggressive, passive) ' +
'and rewrite it to be professional and clear. ' +
'Return ONLY the improved text.',
'You are a writing coach. Analyze the tone of the provided text. ' +
'Describe the tone characteristics (e.g. formal, casual, aggressive, passive, confident, etc.) ' +
'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;
}
// ─── 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 ─────────────────────────────────────────────────
async function callOpenAI(payload: AnalyzePayload, config: LexAIConfig): Promise<LexAIResponse> {
@@ -198,23 +214,34 @@ async function callOpenRouter(payload: AnalyzePayload, config: LexAIConfig): Pro
// ─── Main handler ─────────────────────────────────────────────────────────────
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).' };
}
const resolvedConfig: LexAIConfig = { ...config, apiKey: apiKey.trim() };
const provider = config.provider || 'openai';
switch (provider) {
case 'openai':
return callOpenAI(payload, config);
return callOpenAI(payload, resolvedConfig);
case 'anthropic':
return callAnthropic(payload, config);
return callAnthropic(payload, resolvedConfig);
case 'groq':
return callGroq(payload, config);
return callGroq(payload, resolvedConfig);
case 'openrouter':
return callOpenRouter(payload, config);
return callOpenRouter(payload, resolvedConfig);
default:
return { error: `Unknown provider: "${provider}". Please check LexAI settings.` };
}
@@ -225,6 +252,46 @@ async function handleAnalyzeText(payload: AnalyzePayload): Promise<LexAIResponse
export default defineBackground(() => {
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) => {
if (message.type === 'ANALYZE_TEXT') {
handleAnalyzeText(message.payload as AnalyzePayload)

View File

@@ -186,6 +186,7 @@ export default defineContentScript({
{ label: '↺ Rephrase', action: 'rephrase', color: '#89b4fa' },
{ label: '↓ Shorten', action: 'shorten', color: '#fab387' },
{ label: '↑ Expand', action: 'expand', color: '#cba6f7' },
{ label: '🎭 Tone', action: 'tone', color: '#2dd4bf' },
];
actions.forEach(({ label, action, color }) => {
@@ -340,7 +341,13 @@ export default defineContentScript({
if (response?.error) {
showModal(`${response.error}`, null, snapStart, snapEnd, snapElement, snapRange);
} 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) {
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 { createRoot } from 'react-dom/client';
import nacl from 'tweetnacl';
// ─── 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 ───────────────────────────────────────────────────────────────────
const styles = {
@@ -183,13 +201,22 @@ function OptionsPage() {
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<HTMLInputElement>(null);
useEffect(() => {
safeStorageGet(["provider", "apiKey", "model"], (result) => {
safeStorageGet(['provider', 'apiKey', 'apiKeyEnc', 'encKey', 'model'], (result) => {
if (result.provider) setProvider(result.provider);
if (result.apiKey) setApiKey(result.apiKey);
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]);
};
const handleSave = () => {
if (!apiKey.trim()) {
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, apiKey: apiKey.trim(), model }, () => {
safeStorageSet({ provider, model }, () => {
if (chrome.runtime.lastError) {
setSaveStatus('error');
setTimeout(() => setSaveStatus('idle'), 3000);
@@ -216,6 +246,39 @@ function OptionsPage() {
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 = {
@@ -237,7 +300,7 @@ function OptionsPage() {
<h1 style={styles.title}>LexAI Settings</h1>
</div>
<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>
<div style={styles.divider} />
@@ -272,14 +335,27 @@ function OptionsPage() {
{/* API Key */}
<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' }}>
<input
ref={apiKeyRef}
type={showKey ? 'text' : 'password'}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={currentProvider.placeholder}
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()}
/>
@@ -303,7 +379,7 @@ function OptionsPage() {
</button>
</div>
<div style={styles.hint}>
<span>🔒 Stored only on your device.</span>
<span>🔒 Encrypted with TweetNaCl on your device.</span>
<span>·</span>
<a
href={currentProvider.docsUrl}
@@ -334,7 +410,7 @@ function OptionsPage() {
<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, or Expand</li>
<li>Click Fix, Rephrase, Shorten, Expand, or Tone</li>
<li>Accept or replace the suggestion</li>
</ol>
</div>