diff --git a/entrypoints/background.ts b/entrypoints/background.ts index a2e117b..38bd73e 100644 --- a/entrypoints/background.ts +++ b/entrypoints/background.ts @@ -18,6 +18,7 @@ async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs = 3 interface AnalyzePayload { text: string; action: string; + style?: string; } interface LexAIConfig { @@ -35,7 +36,9 @@ interface LexAIResponse { // ─── System prompts ─────────────────────────────────────────────────────────── -function getSystemPrompt(action: string): string { +function getSystemPrompt(action: string, style?: string): string { + // Normalize 'fix' (used by context menu) to 'grammar' + const normalizedAction = action === 'fix' ? 'grammar' : action; const prompts: Record = { grammar: 'You are a professional grammar editor. Fix all grammar, spelling, and punctuation errors in the provided text. ' + @@ -58,7 +61,11 @@ function getSystemPrompt(action: string): string { 'Break down complex terms, jargon, or concepts so anyone can understand. ' + 'Be concise but clear. Return only the explanation, no extra commentary.', }; - return prompts[action] ?? prompts.grammar; + const base = prompts[normalizedAction] ?? prompts.grammar; + const styleModifier = style && style !== 'Default' + ? ` Write in a ${style.toLowerCase()} style.` + : ''; + return base + styleModifier; } // ─── Encryption helpers ─────────────────────────────────────────────────────── @@ -89,7 +96,7 @@ async function callOpenAI(payload: AnalyzePayload, config: LexAIConfig): Promise body: JSON.stringify({ model, messages: [ - { role: 'system', content: getSystemPrompt(payload.action) }, + { role: 'system', content: getSystemPrompt(payload.action, payload.style) }, { role: 'user', content: payload.text }, ], max_tokens: 1024, @@ -127,7 +134,7 @@ async function callAnthropic(payload: AnalyzePayload, config: LexAIConfig): Prom body: JSON.stringify({ model, max_tokens: 1024, - system: getSystemPrompt(payload.action), + system: getSystemPrompt(payload.action, payload.style), messages: [{ role: 'user', content: payload.text }], }), }); @@ -161,7 +168,7 @@ async function callGroq(payload: AnalyzePayload, config: LexAIConfig): Promise { console.log('LexAI background service worker started'); // ─── Context menus ─────────────────────────────────────────────────────── + const CONTEXT_ACTIONS = ['fix', 'rephrase', 'shorten', 'expand', 'explain'] as const; + const CONTEXT_ACTION_LABELS: Record = { + fix: 'Fix Grammar', + rephrase: 'Rephrase', + shorten: 'Shorten', + expand: 'Expand', + explain: 'Explain', + }; + const CONTEXT_STYLES = ['Formal', 'Casual', 'Academic', 'Creative', 'Concise']; + 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-explain', - title: '⚡ LexAI: Explain', - contexts: ['selection'], + chrome.contextMenus.removeAll(() => { + CONTEXT_ACTIONS.forEach(action => { + chrome.contextMenus.create({ + id: `lexai-${action}`, + title: `⚡ LexAI: ${CONTEXT_ACTION_LABELS[action]}`, + contexts: ['selection'], + }); + CONTEXT_STYLES.forEach(style => { + chrome.contextMenus.create({ + id: `lexai-${action}-${style.toLowerCase()}`, + parentId: `lexai-${action}`, + title: style, + contexts: ['selection'], + }); + }); + }); }); }); chrome.contextMenus.onClicked.addListener((info, tab) => { if (!info.selectionText || !tab?.id) return; - const action = info.menuItemId.toString().replace('lexai-', ''); + // Parse action and style from menuItemId e.g. "lexai-fix-formal" + const parts = info.menuItemId.toString().replace('lexai-', '').split('-'); + const action = parts[0]; + const style = parts[1] ? parts[1].charAt(0).toUpperCase() + parts[1].slice(1) : 'Default'; chrome.tabs.sendMessage(tab.id, { type: 'lexai-context-menu', action, text: info.selectionText, + style, }); }); // ─── Message handler ───────────────────────────────────────────────────── chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (message.type === 'ANALYZE_TEXT') { - handleAnalyzeText(message.payload as AnalyzePayload) + // Support both { payload: { text, action, style } } (content.ts) and + // { text, action, style } (popup) formats + const payload: AnalyzePayload = message.payload ?? { + text: message.text as string, + action: message.action as string, + style: message.style as string | undefined, + }; + handleAnalyzeText(payload) .then(sendResponse) .catch((err) => sendResponse({ error: String(err) })); return true; // Keep channel open for async response diff --git a/entrypoints/content.ts b/entrypoints/content.ts index b656295..077a43f 100644 --- a/entrypoints/content.ts +++ b/entrypoints/content.ts @@ -19,6 +19,12 @@ export default defineContentScript({ // Lock flag — prevents mouseup from resetting stored selection while modal is open let selectionLocked = false; + // Writing style — persisted in chrome.storage.local + let currentStyle = 'Default'; + chrome.storage.local.get(['writingStyle'], (res) => { + currentStyle = (res.writingStyle as string) || 'Default'; + }); + // ─── Selection capture (called immediately on mouseup) ──────────────────── function captureSelectionNow(): boolean { @@ -615,7 +621,7 @@ export default defineContentScript({ try { const response = await safeSendMessage({ type: 'ANALYZE_TEXT', - payload: { text: textToProcess, action }, + payload: { text: textToProcess, action, style: currentStyle }, }) as { error?: string; result?: string } | null; hideToolbar(); @@ -623,7 +629,7 @@ export default defineContentScript({ if (response === null) return; // safeSendMessage already handled the error if (response?.error) { - showModal(`❌ ${response.error}`, null, snapStart, snapEnd, snapElement, snapRange); + showModal(`❌ ${response.error}`, null, snapStart, snapEnd, snapElement, snapRange, action, textToProcess); } else { // Tone analysis is display-only — no Replace button (pass null for originalText) const isExplainAction = action === 'explain'; @@ -631,6 +637,7 @@ export default defineContentScript({ response?.result ?? '(no result)', isExplainAction ? null : textToProcess, snapStart, snapEnd, snapElement, snapRange, + action, textToProcess, ); } } catch (err) { @@ -639,7 +646,7 @@ export default defineContentScript({ String(err).includes('message channel closed')) { showErrorToast('LexAI was updated — please refresh this page.'); } else { - showModal(`❌ Error: ${String(err)}`, null, snapStart, snapEnd, snapElement, snapRange); + showModal(`❌ Error: ${String(err)}`, null, snapStart, snapEnd, snapElement, snapRange, action, textToProcess); } } } @@ -655,6 +662,8 @@ export default defineContentScript({ snapEnd: number, snapElement: HTMLTextAreaElement | HTMLInputElement | null, snapRange: Range | null, + action?: string, + textForRegenerate?: string, ) { if (modal) modal.remove(); @@ -741,6 +750,102 @@ export default defineContentScript({ }); modal.appendChild(body); + // ─── Style selector row ─────────────────────────────────────────────── + const STYLES = ['Default', 'Formal', 'Casual', 'Academic', 'Creative', 'Concise']; + + const styleRow = document.createElement('div'); + Object.assign(styleRow.style, { + display: 'flex', + alignItems: 'center', + gap: '8px', + marginBottom:'12px', + }); + + const styleLabel = document.createElement('span'); + styleLabel.textContent = 'Style:'; + Object.assign(styleLabel.style, { + fontSize: '12px', + color: '#a6adc8', + flexShrink: '0', + }); + + const styleSelect = document.createElement('select'); + Object.assign(styleSelect.style, { + background: 'rgba(49,50,68,0.9)', + color: '#cdd6f4', + border: '1px solid rgba(205,214,244,0.2)', + borderRadius: '6px', + padding: '4px 8px', + fontSize: '12px', + cursor: 'pointer', + flex: '1', + outline: 'none', + }); + + STYLES.forEach(s => { + const opt = document.createElement('option'); + opt.value = s; + opt.textContent = s; + styleSelect.appendChild(opt); + }); + + // Load saved style and pre-select + chrome.storage.local.get(['writingStyle'], (res) => { + const saved = (res.writingStyle as string) || 'Default'; + styleSelect.value = saved; + currentStyle = saved; + }); + + const regenBtn = document.createElement('button'); + regenBtn.textContent = '↺ Regenerate'; + regenBtn.setAttribute('data-lexai', 'true'); + Object.assign(regenBtn.style, { + background: 'rgba(137,180,250,0.15)', + color: '#89b4fa', + border: '1px solid rgba(137,180,250,0.3)', + borderRadius: '6px', + padding: '5px 10px', + fontSize: '12px', + fontWeight: '600', + cursor: 'pointer', + flexShrink: '0', + whiteSpace: 'nowrap', + }); + + async function doRegenerate() { + if (!action || !textForRegenerate) return; + const chosenStyle = styleSelect.value; + // Save style + currentStyle = chosenStyle; + await chrome.storage.local.set({ writingStyle: chosenStyle }); + // Show spinner + body.innerHTML = ' Regenerating…'; + regenBtn.disabled = true; + styleSelect.disabled = true; + + const response = await safeSendMessage({ + type: 'ANALYZE_TEXT', + payload: { text: textForRegenerate, action, style: chosenStyle }, + }) as { error?: string; result?: string } | null; + + regenBtn.disabled = false; + styleSelect.disabled = false; + + if (!response || response.error) { + body.textContent = `❌ ${response?.error ?? 'Unknown error'}`; + } else { + body.textContent = response.result ?? '(no result)'; + } + } + + styleSelect.addEventListener('change', () => doRegenerate()); + regenBtn.addEventListener('click', () => doRegenerate()); + + styleRow.appendChild(styleLabel); + styleRow.appendChild(styleSelect); + styleRow.appendChild(regenBtn); + modal.appendChild(styleRow); + // Buttons const btnRow = document.createElement('div'); Object.assign(btnRow.style, { display: 'flex', gap: '8px' }); @@ -949,6 +1054,7 @@ export default defineContentScript({ chrome.runtime.onMessage.addListener((message) => { if (message.type === 'lexai-context-menu') { selectedText = message.text as string; + currentStyle = (message.style as string) || 'Default'; // For context menu, we have no DOM selection positions — zero them out storedStart = -1; storedEnd = -1; diff --git a/entrypoints/popup/Popup.tsx b/entrypoints/popup/Popup.tsx index ea19a65..3df018a 100644 --- a/entrypoints/popup/Popup.tsx +++ b/entrypoints/popup/Popup.tsx @@ -37,6 +37,10 @@ async function safeSendMessage( } } +// ─── Constants ─────────────────────────────────────────────────────────────── + +const WRITING_STYLES = ['Default', 'Formal', 'Casual', 'Academic', 'Creative', 'Concise']; + // ─── Types ─────────────────────────────────────────────────────────────────── type Action = 'fix' | 'rephrase' | 'shorten' | 'expand'; @@ -97,6 +101,29 @@ const S = { boxSizing: 'border-box' as const, fontFamily: 'inherit', }, + styleRow: { + display: 'flex', + alignItems: 'center', + gap: '8px', + marginTop: '10px', + marginBottom: '2px', + }, + styleLabel: { + fontSize: '12px', + color: '#a6adc8', + flexShrink: 0, + }, + styleSelect: { + background: 'rgba(49,50,68,0.95)', + border: '1px solid rgba(205,214,244,0.2)', + borderRadius: '6px', + color: '#cdd6f4', + fontSize: '12px', + padding: '4px 8px', + cursor: 'pointer', + flex: 1, + outline: 'none', + }, actionsRow: { display: 'flex', flexWrap: 'wrap' as const, @@ -190,16 +217,20 @@ function Popup() { const [error, setError] = useState(''); const [processing, setProcessing] = useState(false); const [copied, setCopied] = useState(false); + const [writingStyle, setWritingStyle] = useState('Default'); const sessionRestored = useRef(false); - // Load config + restore session input + // Load config + restore session input + load writing style useEffect(() => { - safeStorageGet(chrome.storage.local, ['provider', 'apiKey', 'apiKeyEnc', 'model'], (result) => { + safeStorageGet(chrome.storage.local, ['provider', 'apiKey', 'apiKeyEnc', 'model', 'writingStyle'], (result) => { if (result.apiKey || result.apiKeyEnc) { setConfigured(true); setProvider(result.provider || 'openai'); setModel(result.model || ''); } + if (result.writingStyle) { + setWritingStyle(result.writingStyle); + } }); if (!sessionRestored.current) { @@ -219,6 +250,13 @@ function Popup() { safeStorageSet(chrome.storage.session, { lexai_popup_input: val }); }; + // Save writing style on change + const handleStyleChange = (e: React.ChangeEvent) => { + const val = e.target.value; + setWritingStyle(val); + safeStorageSet(chrome.storage.local, { writingStyle: val }); + }; + const openSettings = () => { try { if (typeof chrome !== 'undefined' && chrome.runtime?.id) { @@ -239,8 +277,7 @@ function Popup() { const response = await safeSendMessage({ type: 'ANALYZE_TEXT', - action, - text, + payload: { text, action, style: writingStyle }, }); setProcessing(false); @@ -331,6 +368,21 @@ function Popup() { rows={4} /> + {/* Style selector */} +
+ Style: + +
+ {/* Action buttons */}
{actions.map(({ label, id }) => (