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:
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user