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)